在 React 项目中使用 Protobuf 协议

前言

本文总结了在 React App 中处理 ProtoBuf 协议的一种方式, 同时包含 React 自定义 Hook, axios interceptors 等内容, 共同组成了前后端交互的相关功能.

跟随本文的步骤, 可以完整的创建一个独立的示例 App.

本文主要按照以下几个步骤进行:

  1. 创建 React App
  2. 安装依赖
  3. 创建自定义 Hook
  4. 创建并配置 axios 实例
  5. 编写 Proto 定义
  6. 创建自定义组件组件
  7. 为 axios 实例添加 interceptors 函数
  8. 将组件添加到 App
  9. 修改 index.js
  10. 启动项目

本文假设你已经有了可以正常运行的 Node.js 环境. 如果还没有, 你可以从 官网 进行下载安装.


创建 React App

使用 npm 创建一个名为 proto-test 的项目:

1
$ npx create-react-app proto-test

按照文档的推荐, 无需预先安装 create-react-app 工具, 并且建议先卸载全局安装的 create-react-app

创建项目并安装依赖的过程可能会持续几分钟, 请耐心等待


安装依赖

首先进入 proto-test 目录

1
$ cd proto-test

由于 React 18 版本相关依赖存在一些原因未知的问题, 这里使用 React 17 版本.
通过修改 package.json 文件指定依赖版本, 将 package.json 文件中的 dependencies 值改为如下内容:

1
2
3
4
5
6
7
8
"dependencies": {
"protobufjs": "^6.11.3",
"axios": "^0.25.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-scripts": "5.0.0",
"web-vitals": "^2.1.4"
}

保存 package.json 文件后, 使用如下命令安装全部依赖

1
$ npm i

注意: Protobufjs 文档中给出的相关示例, 并不完全适用于 react app, 原文档仅供参考.

Axios 文档
Protobufjs 文档


创建自定义 Hook

首先, 我们在项目的 src 目录下创建一个名为 hooks 子目录, 并在其中创建一个名为 useAxios.js 的文件.

1
2
3
$ cd src
$ mkdir hooks
$ touch hooks/useAxios.js

使用文本编辑器打开 useAxios.js 文件, 向其中粘贴如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import { useState, useEffect } from 'react';


const useAxios = (configObj) => {
const {
axiosInstance,
method,
url,
requestConfig = {},
} = configObj;

const [response, setResponse] = useState([]);
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);

useEffect(() => {
const controller = new AbortController();

const fetchData = async () => {
try {
const res = await axiosInstance[method.toLowerCase()](url, {
...requestConfig,
signal: controller.signal
});
setResponse(res.data);
} catch(err) {
console.log(err.message);
setError(err.message);
} finally {
setLoading(false);
}
};

fetchData();

return () => controller.abort();
}, []);

return [response, error, loading];
};


export default useAxios;

这段代码主要实现的功能如下:

  • 使用箭头函数语法声明一个函数, 并赋值给 useAxios 变量;
  • 对参数 configObj 进行解构赋值(Destructuring Assignment), 获得 axiosInstance, method, url, requestConfig 四个变量, 并为 requestConfig 设置默认值.
  • 分别使用 useState Hook 设置了三个局部状态变量 response, error, loading.
  • useEffect Hook 中使用 axios 执行请求, 并更新局部状态变量. 返回一个使用 controller 清除状态的函数.
  • 返回 response, error, loading 三个变量.
  • 使用 export default 对外暴露刚刚声明的 useAxios 变量.


创建并配置 axios 实例

在 src 目录下创建一个名为 apis 的子目录, 并在其中创建一个名为 base.js 的文件

1
2
$ mkdir apis
$ touch apis/base.js

向 base.js 中粘贴如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import axios from 'axios';
import protobuf from 'protobufjs';

const BASE_URL = 'http://192.168.0.106:8080/api/v1/';

const instance = axios.create({
baseURL: BASE_URL,
headers: {
'Accept': 'application/x-protobuf',
},
responseType: 'arraybuffer',
});

export default instance;

在这段代码中, 实现的功能如下:

  • 设置了全局常量 BASE_URL, 这是你将要请求的接口 url 的通用前缀.
  • 创建了一个 axios 实例并赋值给 instance 变量, 并在传入的 config 参数对象中设置请求头的 Accept 字段和响应实体将要使用的数据类型 arraybuffer.
  • 使用 export default 对外暴露刚刚创建的 axios 实例 instance.


编写 Proto 定义

在 src 目录中创建一个名为 protos 的子目录, 并在其中创建一个名为 ExampleResponse.proto 的文件.

1
2
$ mkdir protos
$ touch protos/ExampleResponse.proto

在 ExampleResponse.proto 文件中, 粘贴如下代码:

1
2
3
4
5
6
7
8
9
10
syntax = "proto3";

message ExampleResponse {
enum Status {
OK = 0;
ERR = 1;
}
optional Status status = 1;
string msg = 2;
}

在这段代码中, 你定义了:

  • 一个 ExampleResponse 类型的 message, 其中包含一个自定义枚举类型的 status 字段和一个 string 类型的 msg 字段.
  • 将 message 名称与文件名设计为同名, 以便后续可以更方便的加载.

你还需要实现一个使用这个消息定义的接口服务, 使用相同的 proto 定义对数据进行编码


创建自定义组件

在 src 目录中, 创建名为 components 的子目录, 并在其中创建一个名为 ExampleComponent.js 的文件.

1
2
$ mkdir components
$ touch components/ExampleComponent.js

打开 ExampleComponent.js 文件, 向其中粘贴如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import useAxios from '../hooks/useAxios';
import axios from '../apis/base';
import ExampleResponse from '../protos/ExampleResponse.proto';

function ExampleComponent() {

const [msg, error, loading] = useAxios({
axiosInstance: axios,
method: "GET",
url: "/hello/protobuf",
requestConfig: {
pb: {
response: ExampleResponse,
}
}
})

return (
<div className="App">
{loading && <p>loading...</p>}

{!loading && error && <p>{error}</p>}

{!loading && !error && msg && <p>{JSON.stringify(msg)}</p>}
</div>
);
}

export default ExampleComponent;

在这段代码中, 实现的功能如下:

  • 导入了前几步声明的 useAxios Hook, axios 实例, 以及 ExampleResponse.proto 文件.
  • 在 App 组件中使用 useAxios Hook 发送请求, 在参数对象中指定要求的参数值. 在 requestConfig 中添加一个自定义字段 pb, 里面指定响应所使用的 proto 文件. url 的值和之前声明的 BASE_URL 拼接到一起组成了完整的 api 地址.
  • 返回组件, 根据 response, errorloading 的值决定页面中展示的数据.


为 axios 实例添加 interceptors 函数

在 apis/base.js 文件中, export default instance 语句的上方, 粘贴如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/**
* 移除 object 中指定的 key
*/
const omit = (obj, uselessKeys) =>
Object.keys(obj).reduce((acc, key) => {
return uselessKeys.includes(key) ?
acc :
{...acc, key: obj[key]}
}, {});


/**
* 根据 proto 文件导入后的路径获取 message 类型名称
* 要求文件名与主要 message 同名
*/
const getProtoName = (path) => {
const start = path.lastIndexOf("/") + 1;
const end = path.indexOf(".");
return path.substring(start, end);
};


/**
* 对 protobuf 协议的 response.data 进行解码, 转换成对象返回
*/
instance.interceptors.response.use(async (response) => {
const { config } = response;
const { pb } = config;

if (pb.response && response.data) {
const root = await protobuf.load(pb.response)
const msgType = root.lookupType(getProtoName(pb.response));
const msg = msgType.decode(new Uint8Array(response.data));
const newData = msgType.toObject(msg);
const newRes = omit(response, ["data"]);
return {
...newRes,
data: newData,
};
}
return response;
});

在这段代码中, 实现的功能如下:

  • 声明了一个 omit 函数, 用来移除对象中指定的键值对.
  • 声明了一个 getProtoName 函数, 用于从导入的 proto 文件中获取文件名, 用于加载 message 类型.
  • 使用 instance.interceptors.response.use 为 axios 实例添加一个拦截器, 用于在 response 返回给请求者之前对其进行处理. 这里使用了 async 函数, 主要是为了处理 protobuf.load 相关的逻辑. 这里与文档中的用法稍有区别.
  • 在拦截器函数内部, 使用对象解构的方式获得 response.configresponse.config.pb 对象, 分别赋值给 configpb 变量. pb 对象是在组件中发起请求时, 添加到 requestConfig 中的自定义对象, 其中包含响应数据所对应的 proto 文件.
  • 使用 protobufjs 对响应数据进行处理, 在返回前对数据进行解码.

有两点需要注意:

  • 在 protobufjs 的文档中, 示例代码使用 protobuf.load 加载 proto 文件时直接使用字符串类型的文件名作为参数, 但是在 react 的项目中, 实际的文件路径和文件系统中的路径并不一致, 所以直接使用文件系统中的文件路径做参数会导致异常. 这里先在组件中把 proto 文件导入为模块, 再使用模块路径进行加载可以正常运行.

  • 在使用 msgType.decode 对响应实体数据进行解码时, 要求参数使用 Uint8Array 类型, 首先需要进行一次类型转换. 其次, 使用 Uint8Array.from 进行转换时, 无返回值, 会导致后续没有数据. 这里需要使用 new Uint8Array 的方式进行类型转换.


将组件添加到 App

将 src/app.js 中的代码替换为如下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import * as React from 'react';

import ExampleComponent from './components/ExampleComponent';

function App() {

return (
<main className="App">
<h1>useAxios Hooks</h1>
<ExampleComponent />
</main>
);
}

export default App;

在这段代码中, 实现的功能如下:

  • 声明了 App 组件, 这个组件将作为整个应用的基础
  • 导入刚刚创建的 ExampleComponent 组件, 并将其添加到 App 中.


修改 index.js

打开 src/index.js, 使用如下代码替换文件中已有的内容:

1
2
3
4
5
6
7
8
9
10
11
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';

ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);

这段代码, 实现了如下功能:

  • 渲染 App 组件


启动项目

打开命令行, 进入到项目的根目录, 使用如下命令启动项目:

1
$ npm start

项目启动完成后, 就可以看到后端返回的 Protobuf 协议数据, 解码后展示在了页面中.


完整代码

[Github]示例代码

示例代码中使用的 api 需要替换为你自己实现的后端 api


参考资料

[1] [YouTube]Use Axios with React Hooks for Async-Await Requests
[2] [Issue]illegal token ‘<’ (/demo.proto, line 1)
[3] [Issue]Uncaught Error: illegal buffer