前言
由于目前公司采用了 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 的语法都能一目了然。这里有两种命名空间 framework
和school
,PBStudent
引用了 PBUser
,可以认为PBStudent
继承了PBUser
。
一般来说,前后端需要统一约束一个请求 model 和响应 model,比如请求中哪些字段是必须的,返回体中又有哪些字段,这里用 MessageType.proto
的PBMessageRequest
来定义请求体所需字段,PBMessageResponse
定义为返回体的字段。
PBMessageType
是接口的枚举,后端所有的接口都写在这里,用注释表示具体请求参数和返回参数类型。比如这里只定义了一个接口getStudentList
。
拿到后端提供的这份 *.proto
文件后,是不是已经可以基本了解到:有一个 getStudentList
的接口,请求参数是PBStudentListReq
,返回的参数是PBStudentListRsp
。
所以说 proto 文件可以直接作为前后端沟通的文档。
步骤
1. 新建一个 vue 项目
同时添加安装 axios
和protobufjs
。
# vue create vue-protobuf
# npm install axios protobufjs --save-dev
2. 在 src
目录下新建一个 proto
目录,用来存放 *.proto
文件, 并将写好的 proto 文件拷贝进去。
此时的项目目录和package.json
:
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)
一下:
可以发现,这个模块在原型链上定义了 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 的记录。