如何在前端中使用protobuf(vue篇)

fengxianqi發表於2018-10-25

前言

由於目前公司採用了ProtoBuf做前後端資料互動,進公司以來一直用的是公司大神寫好的基礎庫,完全不瞭解底層是如何解析的,一旦報錯只能求人,作為一隻還算有鑽研精神的猿,應該去了解一下底層的實現,在這裡記錄一下學習過程。

Protobuf簡單介紹

Google Protocol Buffer(簡稱 Protobuf)是一種輕便高效的結構化資料儲存格式,平臺無關、語言無關、可擴充套件,可用於通訊協議和資料儲存等領域。

有幾個優點:

  • 1.平臺無關,語言無關,可擴充套件;
  • 2.提供了友好的動態庫,使用簡單;
  • 3.解析速度快,比對應的XML快約20-100倍;
  • 4.序列化資料非常簡潔、緊湊,與XML相比,其序列化之後的資料量約為1/3到1/10。

個人感受: 前後端資料傳輸用json還是protobuf其實對開發來說沒啥區別,protobuf最後還是要解析成json才能用。個人覺得比較好的幾點是:

  • 1.前後端都可以直接在專案中使用protobuf,不用再額外去定義model;
  • 2.protobuf可以直接作為前後端資料和介面的文件,大大減少了溝通成本;

沒有使用protobuf之前,後端語言定義的介面和欄位,前端是不能直接使用的,前後端溝通往往需要維護一份介面文件,如果後端欄位有改動,需要去修改文件並通知前端,有時候文件更新不及時或容易遺漏,溝通成本比較大。 使用protobuf後,protobuf檔案由後端統一定義,protobuf直接可以作為文件,前端只需將protobuf檔案拷貝進前端專案即可。如果後端欄位有改動,只需通知前端更新protobuf檔案即可,因為後端是直接使用了protobuf檔案,因此protobuf檔案一般是不會出現遺漏或錯誤的。長此以往,團隊合作效率提升是明顯的。

廢話了一大堆,下面進入正題。 我這裡講的主要是在vue中的使用,是目前本人所在的公司專案實踐,大家可以當做參考。

思路

前端中需要使用 protobuf.js 這個庫來處理proto檔案。

protobuf.js 提供了幾種方式來處理proto。

  • 直接解析,如protobuf.load("awesome.proto", function(err, root) {...})
  • 轉化為JSON或js後使用,如protobuf.load("awesome.json", function(err, root) {...})
  • 其他

眾所周知,vue專案build後生成的dist目錄中只有html,css,js,images等資源,並不會有.proto檔案的存在,因此需要用protobuf.js這個庫將*.proto處理成*.js*.json,然後再利用庫提供的方法來解析資料,最後得到資料物件。

PS: 實踐發現,轉化為js檔案會更好用一些,轉化後的js檔案直接在原型鏈上定義了一些方法,非常方便。因此後面將會是使用這種方法來解析proto。

預期目標

在專案中封裝一個request.js模組,希望能像下面這樣使用,呼叫api時只需指定請求和響應的model,然後傳遞請求引數,不需關心底層是如何解析proto的,api返回一個Promise物件:

// /api/student.js 定義介面的檔案
import request from '@/lib/request'

// params是object型別的請求引數
// school.PBStudentListReq 是定義好的請求體model
// school.PBStudentListRsp 是定義好的響應model
// getStudentList 是介面名稱
export function getStudentList (params) {
  const req = request.create('school.PBStudentListReq', params)
  return request('getStudentList', req, 'school.PBStudentListRsp')
}

// 在HelloWorld.vue中使用
import { getStudentList } from '@/api/student'
export default {
  name: 'HelloWorld',
  created () {

  },
  methods: {
    _getStudentList () {
      const req = {
        limit = 20,
        offset = 0
      }
      getStudentList(req).then((res) => {
        console.log(res)
      }).catch((res) => {
        console.error(res)
      })
    }
  }
}
複製程式碼

準備工作

1.拿到一份定義好的proto檔案。

雖然語法簡單,但其實前端不用怎麼關心如何寫proto檔案,一般都是由後端來定義和維護。在這裡大家可以直接用一下我定義好的一份demo

// User.proto
package framework;
syntax = "proto3";

message PBUser {
    uint64 user_id = 0;
    string name = 1;
    string mobile = 2;
}

// Class.proto
package school;
syntax = "proto3";

message PBClass {
    uint64 classId = 0;
    string name = 1;
}

// Student.proto
package school;
syntax = "proto3";

import "User.proto";
import "Class.proto";

message PBStudent {
    uint64 studentId = 0;
    PBUser user = 1;
    PBClass class = 2;
    PBStudentDegree degree = 3;
}

enum PBStudentDegree {
  PRIMARY = 0;   // 小學生
  MIDDLE = 1;    // 中學生
  SENIOR = 2;    // 高中生
  COLLEGE = 3;   // 大學生
}

message PBStudentListReq {
  uint32 offset = 1;
  uint32 limit = 2;
}

message PBStudentListRsp {
  repeated PBStudent list = 1;
}



// MessageType.proto
package framework;
syntax = "proto3";
// 公共請求體
message PBMessageRequest {
    uint32 type = 1;                            // 訊息型別
    bytes messageData = 2;                      // 請求資料
    uint64 timestamp = 3;                       // 客戶端時間戳
    string version = 4;                         // api版本號

    string token = 14;                          // 使用者登入後伺服器返回的 token,用於登入校驗
}

// 訊息響應包
message PBMessageResponse {
    uint32 type = 3;                            // 訊息型別
    bytes messageData = 4;                      // 返回資料

    uint32 resultCode = 6;                      // 返回的結果碼
    string resultInfo = 7;                      // 返回的結果訊息提示文字(用於錯誤提示)
}
// 所有的介面
enum PBMessageType {
    // 學生相關
    getStudentList = 0;                         // 獲取所有學生的列表, PBStudentListReq => PBStudentListRsp
}
複製程式碼

其實不用去學習proto的語法都能一目瞭然。這裡有兩種名稱空間frameworkschoolPBStudent引用了PBUser,可以認為PBStudent繼承了PBUser

一般來說,前後端需要統一約束一個請求model和響應model,比如請求中哪些欄位是必須的,返回體中又有哪些欄位,這裡用MessageType.protoPBMessageRequest來定義請求體所需欄位,PBMessageResponse定義為返回體的欄位。

PBMessageType 是介面的列舉,後端所有的介面都寫在這裡,用註釋表示具體請求引數和返回引數型別。比如這裡只定義了一個介面getStudentList

拿到後端提供的這份*.proto檔案後,是不是已經可以基本瞭解到:有一個getStudentList的介面,請求引數是PBStudentListReq,返回的引數是PBStudentListRsp

所以說proto檔案可以直接作為前後端溝通的文件。

步驟

1.新建一個vue專案

同時新增安裝axiosprotobufjs

# vue create vue-protobuf
# npm install axios protobufjs --save-dev
複製程式碼

2.在src目錄下新建一個proto目錄,用來存放*.proto檔案,並將寫好的proto檔案拷貝進去。

此時的專案目錄和package.json

如何在前端中使用protobuf(vue篇)

3.將*.proto檔案生成src/proto/proto.js(重點)

protobufjs提供了一個叫pbjs的工具,這是一個神器,根據引數不同可以打包成xx.json或xx.js檔案。比如我們想打包成json檔案,在根目錄執行:

npx pbjs -t json src/proto/*.proto > src/proto/proto.json
複製程式碼

可以在src/proto目錄下生成一個proto.json檔案,檢視請點選這裡。 之前說了:實踐證明打包成js模組才是最好用的。我這裡直接給出最終的命令

npx pbjs -t json-module -w commonjs -o src/proto/proto.js  src/proto/*.proto
複製程式碼

-w引數可以指定打包js的包裝器,這裡用的是commonjs,詳情請各位自己去看文件。執行命令後在src/proto目錄下生成的proto.js。在chrome中console.log(proto.js)一下:

如何在前端中使用protobuf(vue篇)
可以發現,這個模組在原型鏈上定義了load, lookup等非常有用的api,這正是後面我們將會用到的。 為以後方便使用,我們將命令新增到package.json的script中:

  "scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint",
    "proto": "pbjs -t json-module -w commonjs -o src/proto/proto.js  src/proto/*.proto"
  },
複製程式碼

以後更新proto檔案後,只需要npm run proto即可重新生成最新的proto.js。

4. 封裝request.js

在前面生成了proto.js檔案後,就可以開始封裝與後端互動的基礎模組了。首先要知道,我們這裡是用axios來發起http請求的。

整個流程:開始呼叫介面 -> request.js將資料變成二進位制 -> 前端真正發起請求 -> 後端返回二進位制的資料 -> request.js處理二進位制資料 -> 獲得資料物件。

可以說request.js相當於一個加密解密的中轉站。在src/lib目錄下新增一個request.js檔案,開始開發:

既然我們的介面都是二進位制的資料,所以需要設定axios的請求頭,使用arraybuffer,如下:

import axios from 'axios'
const httpService = axios.create({
  timeout: 45000,
  method: 'post',
  headers: {
    'X-Requested-With': 'XMLHttpRequest',
    'Content-Type': 'application/octet-stream'
  },
  responseType: 'arraybuffer'
})
複製程式碼

MessageType.proto裡面定義了與後端約定的介面列舉、請求體、響應體。發起請求前需要將所有的請求轉換為二進位制,下面是request.js的主函式

import protoRoot from '@/proto/proto'
import protobuf from 'protobufjs'

// 請求體message
const PBMessageRequest = protoRoot.lookup('framework.PBMessageRequest')
// 響應體的message
const PBMessageResponse = protoRoot.lookup('framework.PBMessageResponse')

const apiVersion = '1.0.0'
const token = 'my_token'

function getMessageTypeValue(msgType) {
  const PBMessageType = protoRoot.lookup('framework.PBMessageType')
  const ret = PBMessageType.values[msgType]
  return ret
}

/**
 * 
 * @param {*} msgType 介面名稱
 * @param {*} requestBody 請求體引數
 * @param {*} responseType 返回值
 */
function request(msgType, requestBody, responseType) { 
  // 得到api的列舉值
  const _msgType = getMessageTypeValue(msgType)

  // 請求需要的資料
  const reqData = {
    timeStamp: new Date().getTime(),
    type: _msgType,
    version: apiVersion,
    messageData: requestBody,
    token: token
  }
}
  // 將物件序列化成請求體例項
  const req = PBMessageRequest.create(reqData)
  
  // 呼叫axios發起請求
  // 這裡用到axios的配置項:transformRequest和transformResponse
  // transformRequest 發起請求時,呼叫transformRequest方法,目的是將req轉換成二進位制
  // transformResponse 對返回的資料進行處理,目的是將二進位制轉換成真正的json資料
  return httpService.post('/api', req, {
    transformRequest,
    transformResponse: transformResponseFactory(responseType)
  }).then(({data, status}) => {
    // 對請求做處理
    if (status !== 200) {
      const err = new Error('伺服器異常')
      throw err
    }
    console.log(data)
  },(err) => {
    throw err
  })
}
// 將請求資料encode成二進位制,encode是proto.js提供的方法
function transformRequest(data) {
  return PBMessageRequest.encode(data).finish()
}

function isArrayBuffer (obj) {
  return Object.prototype.toString.call(obj) === '[object ArrayBuffer]'
}

function transformResponseFactory(responseType) {
  return function transformResponse(rawResponse) {
    // 判斷response是否是arrayBuffer
    if (rawResponse == null || !isArrayBuffer(rawResponse)) {
      return rawResponse
    }
    try {
      const buf = protobuf.util.newBuffer(rawResponse)
      // decode響應體
      const decodedResponse = PBMessageResponse.decode(buf)
      if (decodedResponse.messageData && responseType) {
        const model = protoRoot.lookup(responseType)
        decodedResponse.messageData = model.decode(decodedResponse.messageData)
      }
      return decodedResponse
    } catch (err) {
      return err
    }
  }
}

// 在request下新增一個方法,方便用於處理請求引數
request.create = function (protoName, obj) {
  const pbConstruct = protoRoot.lookup(protoName)
  return pbConstruct.encode(obj).finish()
}

// 將模組暴露出去
export default request
複製程式碼

最後寫好的具體程式碼請看:request.js。 其中用到了lookup(),encode(), finish(), decode()等幾個proto.js提供的方法。

5. 呼叫request.js

在.vue檔案直接呼叫api前,我們一般不直接使用request.js來直接發起請求,而是將所有的介面再封裝一層,因為直接使用request.js時要指定請求體,響應體等固定的值,多次使用會造成程式碼冗餘。

我們習慣上在專案中將所有後端的介面放在src/api的目錄下,如針對student的介面就放在src/api/student.js檔案中,方便管理。 將getStudentList的介面寫在src/api/student.js

import request from '@/lib/request'

// params是object型別的請求引數
// school.PBStudentListReq 是定義好的請求體model
// school.PBStudentListRsp 是定義好的響應model
// getStudentList 是介面名稱
export function getStudentList (params) {
  const req = request.create('PBStudentListReq', params)
  return request('getStudentList', req, 'school.PBStudentListRsp')
}
// 後面如果再新增介面直接以此類推
export function getStudentById (id) {
  // const req = ...
  // return request(...)
}
複製程式碼

6. 在.vue中使用介面

需要哪個介面,就import哪個介面,返回的是Promise物件,非常方便。

<template>
  <div class="hello">
    <button @click="_getStudentList">獲取學生列表</button>
  </div>
</template>

<script>
import { getStudentList } from '@/api/student'
export default {
  name: 'HelloWorld',
  methods: {
    _getStudentList () {
      const req = {
        limit: 20,
        offset: 0
      }
      getStudentList(req).then((res) => {
        console.log(res)
      }).catch((res) => {
        console.error(res)
      })
    }
  },
  created () {
  }
}
</script>

<style lang="scss">

</style>
複製程式碼

總結

整個demo的程式碼: demo

前端使用的整個流程:

  • 1. 將後端提供的所有的proto檔案拷進src/proto資料夾
  • 2. 執行npm run proto 生成proto.js
  • 3. 根據介面列舉在src/api下寫介面
  • 4. .vue檔案中使用介面。

(其中1和2可以合併在一起寫一個自動化的指令碼,每次更新只需執行一下這個指令碼即可)。

寫的比較囉嗦,文筆也不好,大家見諒。

這個流程就是我感覺比較好的一個proto在前端的實踐,可能並不是最好,如果在你們公司有其他更好的實踐,歡迎大家一起交流分享。

後續

在vue中使用是需要打包成一個js模組來使用比較好(這是因為vue在生產環境中打包成只有html,css,js等檔案)。但在某些場景,比如在Node環境中,一個Express的專案,生產環境中是允許出現.proto檔案的,這時候可以採取protobuf.js提供的其他方法來動態解析proto,不再需要npm run proto這種操作了。

後面有時間我會再寫一篇在node端動態解析proto的記錄。

相關文章