1. 程式人生 > >一個 json 轉換工具

一個 json 轉換工具

      在前後端的資料協議(主要指httpwebsocket)的問題上,如果前期溝通好了,那麼資料協議上問題會很好解決,前後端商議一種都可以接受的格式即可。但是如果接入的是老系統、第三方系統,或者由於某些奇怪的需求(如為了節省流量,json 資料使用單字母作為key值,或者對某一段資料進行了加密),這些情況下就無法商議,需要在前端做資料轉換,如果不轉換,那麼奔放的資料格式可讀性差,也會造成專案難以維護。

 

      這也正是我在專案種遇到的問題,網上也找了一些方案,要麼過於複雜,要麼有些功能不能很好的支援,於是有了這個工具 class-converter。歡迎提 issue 和 star~~https://github.com/zquancai/class-converter

 

下面我們用例子來說明下:

面對如下的Server返回的一個使用者user資料:

{
    "i": 1234,
    "n": "name",
    "a": "1a2b3c4d5e6f7a8b"
}

或者這個樣的: 

{
    "user_id": 1234,
    "user_name": "name",
    "u_avatar": "1a2b3c4d5e6f7a8b"
}

資料裡的 avatar 欄位在使用時,可能需要拼接成一個 url,例如 https://xxx.cdn.com/1a2b3c4d5e6f7a8b.png

當然可以直接這麼做:

const json = {
    "i": 1234,
    "n": "name",
    "a": "1a2b3c4d5e6f7a8b",
};
const data = {};
const keyMap = {
    i: 'id',
    n: 'name',
    a: 'avatar',
}
Object.entries(json).forEach(([key, value]) => {
    data[keyMap[key]] = value;
});
// data = { id: 1234, name: 'name', avatar: '1a2b3c4d5e6f7a8b' }

然後我們進一步就可以把這個抽象成一個方法,像下面這個樣:

const jsonConverter = (json, keyMap) => {
    const data = {};
    Object.entries(json).forEach(([key, value]) => {
        data[keyMap[key]] = value;
    });
    return data;
}

如果這個資料擴充套件了,添加了教育資訊,user 資料結構看起來這個樣:

{
    "i": 1234,
    "n": "name",
    "a": "1a2b3c4d5e6f7a8b",
    "edu": {
        "u": "South China Normal University",
        "ea": 1
    }
}

此時的 jsonConverter 方法已經無法正確轉換 edu 欄位的資料,需要做一些修改:

const json = {
    "i": 1234,
    "n": "name",
    "a": "1a2b3c4d5e6f7a8b",
    "edu": {
        "u": "South China Normal University",
        "ea": 1
    }
};
const data = {};
const keyMap = {
    i: 'id',
    n: 'name',
    a: 'avatar',
    edu: {
        key: 'education',
        keyMap: {
            u: 'universityName',
            ea: 'attainment'
        }
    },
}

隨著資料複雜度的上升,keyMap 資料結構會變成一個臃腫的配置檔案,此外 jsonConverter 方法會越來越複雜,以至於後面同樣難以維護。但是轉換後的資料格式,對於專案來說,資料的可讀性是很高的。所以,這個轉換必須做,但是方式可以更優雅一點。

寫這個工具的初衷也是為了更優雅的進行資料轉換。

 

工具用法

還是上面的例子(這裡使用typescript寫法):

import { toClass, property } from 'class-converter';
// 待解析的資料
const json = {
    "i": 1234,
    "n": "name",
    "a": "1a2b3c4d5e6f7a8b",
};
class User {
    @property('i')
    id: number;
    
    @property('n')
    name: string;
    
    @property('a')
    avatar: string;
}
const userIns = toClass(json, User);

你可以輕而易舉的獲得下面的資料:

// userIns 是 User 的一個例項
const userIns = {
    id: 1234,
    name: 'name',
    avatar: '1a2b3c4d5e6f7a8b',
}
userIns instanceof User // true

Json 類既是文件又是類似於上文說的與keyMap類似的配置檔案,並且可以反向使用。

import { toPlain } from 'class-converter';
const user = toPlain(userIns, User);
// user 資料結構
{
    i: 1234,
    n: 'name',
    a: '1a2b3c4d5e6f7a8b',
};

  

這是一個最簡單的例子,我們來一個複雜的資料結構:

{
  "i": 10000,
  "n": "name",
  "user": {
    "i": 20000,
    "n": "name1",
    "email": "zqczqc",
    // {"i":1111,"n":"department"}
    "d": "eyJpIjoxMTExLCJuIjoiZGVwYXJ0bWVudCJ9",
    "edu": [
      {
        "i": 1111,
        "sn": "szzx"
      },
      {
        "i": 2222,
        "sn": "scnu"
      },
      {
         "i": 3333
      }
    ]
  }
}

這是後端返回的一個叫package的json物件,欄位意義在文件中這麼解釋:

  • i:package 的 id
  • n:package 的名字
  • user:package 的所有者,一個使用者
    • i:使用者 id
    • n:使用者名稱稱
    • email:使用者email,但是隻有郵箱字首
    • d:使用者的所在部門,使用了base64編碼了一個json字串
      • i:部門 id
      • n:部門名稱
    • edu:使用者的教育資訊,陣列格式
      • i:學校 id
      • sn:學校名稱

我們的期望是將這一段資料解析成,不看文件也能讀懂的一個json物件,首先我們經過分析得出上面一共有4類實體物件:package、使用者資訊、部門資訊、教育資訊。

下面是程式碼實現:

import {
    toClass, property, array, defaultVal,
    beforeDeserialize, deserialize, optional
} from 'class-converter';
// 教育資訊
class Education {
    @property('i')
    id: number;
    
    // 提供一個預設值
    @defaultVal('unknow')
    @prperty('sn')
    schoolName: string;
}
// 部門資訊
class Department {
    @property('i')
    id: number;
    
    @prperty('n')
    name: string;
}
// 使用者資訊
class User {
  @property('i')
  id: number;
  @property('n')
  name: string;
  
  // 保留一份郵箱字首資料
  @optional()
  @property()
  emailPrefix: string;
  
  @optional()
  // 這裡希望自動把字尾加上去
  @deserialize(val => `${val}@xxx.com`)
  @property()
  email: string;
  
  @beforeDeserialize(val => JSON.parse(atob(val)))
  @typed(Department)
  @property('d')
  department: Department;
  
  @array()
  @typed(Education)
  @property('edu')
  educations: Education[];
}
// package
class Package {
  @property('i')
  id: number;
  
  @property('n')
  name: string;
  
  @property('user', User)
  owner: User;
} 

資料已經定義完畢,這時只要我們執行toClass方法就可以得到我們想要的資料格式:

{
  id: 10000,
  name: 'name',
  owner: {
    id: 20000,
    name: 'name1',
    emailPrefix: 'zqczqc',
    email: "[email protected]",
    department: {
        id: 1111,
        name: 'department'
    },
    educations: [
      {
        id: 1111,
        schoolName: 'szzx'
      },
      {
        id: 2222,
        schoolName: 'scnu'
      },
      {
        id: 3333,
        schoolName: 'unknow'
      }
    ]
  }
}

上面這一份資料,相比後端返回的資料格式,可讀性大大提升。這裡的用法出現了@deserialize@beforeDeserialize@yped的裝飾器,這裡對這幾個裝飾器是管道方式呼叫的(前一個的輸出一個的輸入),這裡做一個解釋:

  • beforeDeserialize 第一個引數可以最早拿到當前屬性值,這裡可以做一些解碼操作
  • typed這個是轉換的型別,入參是一個類,相當於自動呼叫toClass,並且調動時的第一個引數是beforeDeserialize的返回值或者當前屬性值(如果沒有@beforeDeserialize裝飾器)。如果使用了@array裝飾器,則會對每一項陣列元素都執行這個轉換
  • deserialize這個裝飾器是最後執行的,第一個引數是beforeDeserialize返回值,@typed返回值,或者當前屬性值(如果前面兩個裝飾器都沒設定的話)。在這個裝飾器裡可以做一些資料訂正的操作

這三個裝飾器是在執行toClass時才會呼叫的,同樣的,當呼叫toPlain時也會有對應的裝飾器@serialize@fterSerialize,結合@typed進行一個相反的過程。下面將這兩個轉換過程的流程繪製出來。

呼叫 toClass的過程:

呼叫 toPlain的過程是呼叫 toClass的逆過程,但是有些許不一樣,有一個注意點就是:在呼叫 toClass時允許出現一對多的情況,就是一個屬性可以派生出多個屬性,所以呼叫呼叫 toPlain時需要使用 @serializeTarget來標記使用哪一個值作為逆過程的原始值,具體用法可以參考文