用现代方式在 React 中使用 gRPC:从 gRPC-web 到 Connect
最近在探索如何在 React 项目中使用 gRPC 与后端交互, 搜了网上很多文章, 但是很震惊居然没有一篇是从 0 开始介绍得比较清楚的, 所以简单探索了一下, 用 react + rust + go 实现了一个简单的人员 CRUD 服务.
完整代码: github.com/Arichy/reac.... 请注意虽然本文介绍了多套方案, 但是在代码仓库里前端使用的是 @bufbuild + @connect 全家桶, 没有使用 grpc-web
和 protobuf-ts
.
1. 概念区分
在进入正文之前, 有几个容易混淆的概念需要区分一下:
特性 | Protobuf | RPC | gRPC | gRPC-web | Connect |
---|---|---|---|---|---|
定义 | Protocol Buffers,谷歌开发的结构化数据序列化格式 | Remote Procedure Call,远程过程调用,一种通用概念 | Google 开发的高性能 RPC 框架,基于 HTTP/2 和 Protocol Buffers | 浏览器端 gRPC 的实现,使前端可以直接与 gRPC 服务通信 | 更现代的 RPC 框架,注重开发体验,兼容 gRPC |
创建者 | 通用概念,无单一创建者 | Buf 公司 | |||
传输协议 | 不适用(仅序列化格式) | 多种 | HTTP/2 | HTTP/1.1 或 HTTP/2(需要代理) | HTTP/1.1 或 HTTP/2 |
序列化 | 二进制,高效紧凑 | 多种 | Protocol Buffers | Protocol Buffers | Protocol Buffers 或 JSON |
浏览器支持 | 通过 JS 库支持 | 不直接支持 | 不直接支持 | 通过特殊客户端支持 | 原生支持,不需要代理 |
代码生成 | 使用 protoc 之类的编译器生成多语言代码 | 取决于实现 | 复杂,需要 protoc | 复杂,需要 protoc 和插件 | 简化,使用 buf 工具链 |
使用场景 | 高效数据交换,跨语言系统 | 分布式系统通信 | 微服务间通信,后端服务 | 浏览器 → 服务器通信 | 全栈应用,跨平台 |
开发体验 | 需要额外的编译步骤 | 基础 | 功能丰富但配置复杂 | 配置复杂,需要代理 | 简化配置,优秀 DX |
(以上表格由 Github Copilot 生成)
总的来说:
- protobuf 是一种数据格式, 常用二进制序列化.
- rpc 是一种通信概念
- gRPC 是一种实现了 rpc 的框架, 使用 protobuf 作为数据格式
- gRPC-web 是 gRPC 的浏览器端实现, 但是由于浏览器不支持 http/2, 所以需要一个代理服务来将 http/1.1 转换为 http/2
- connect 是一个新的 rpc 框架, 兼容 gRPC, 但是更现代化, 同时也有自己的 connect 通信协议
2. 技术栈 + 结构
- 前端: React + TypeScript + Vite + gRPC 相关库(这一点很很混乱, 下面会介绍)
- gRPC 后端: Rust + tonic (一个 Rust 的 gRPC 实现)
- connect 后端: Go + connect
- protobuf files
- envoy + Docker (Docker 可选, 因为我不想在本地安装 envoy, 所以用 Docker 来运行 envoy)
目录结构 (一些常规文件省略):
scss
.
├── frontend/ (generated by vite, regular files omitted)
│ ├── src/
│ │ ├── gen/
│ │ └── grpc.ts
│ ├── package.json
│ ├── vite.config.ts
│ ├── buf.gen.yaml
│ └── tsconfig.json
├── rust-grpc-backend/ (a simple Rust gRPC server, regular files omitted)
│ ├── src/
│ ├── build.rs
│ └── Cargo.toml
├── go-connect-backend/ (a simple Go connect server, regular files omitted)
│ ├── gen/ (generated by `buf generate`)
│ ├── buf.yaml
│ ├── buf.gen.yaml
│ └── main.go
├── proto/
│ └── person.proto
├── envoy.yaml
└── docker-compose.yml
envoy
首先需要知道一个点, gRPC 是基于 HTTP/2 的. 虽然现代浏览器支持 HTTP/2, 但 JavaScript 的 fetch API 无法直接控制 HTTP/2 的流、头部帧等底层能力, 这使得 gRPC 无法在浏览器中原生使用. 举个例子, gRPC 对 HTTP/2 有特定的帧格式要求, 但是 fetch
API 并没有提供这样的能力. 所以我们需要一个代理服务来将浏览器发出的 HTTP/1.1 请求转换为 HTTP/2 请求. 目前比较流行的代理服务就是 envoy (放心只需要最基本的使用, 看不懂这个也没关系). 在根目录创建一个 envoy.yaml
文件, 内容如下:
yaml
admin:
access_log_path: /tmp/admin_access.log
address:
socket_address: { address: 0.0.0.0, port_value: 9901 }
static_resources:
listeners:
- name: listener_0
address:
socket_address: { address: 0.0.0.0, port_value: 8080 } # 前端请求发送的地址
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
'@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
codec_type: auto
stat_prefix: ingress_http
route_config:
name: local_route
virtual_hosts:
- name: local_service
domains: ['*']
routes:
- match: { prefix: '/' }
route:
cluster: person_service
timeout: 0s
max_stream_duration:
grpc_timeout_header_max: 0s
cors:
allow_origin_string_match:
- prefix: '*'
allow_methods: GET, PUT, DELETE, POST, OPTIONS
allow_headers: keep-alive,user-agent,cache-control,content-type,content-transfer-encoding,x-accept-content-transfer-encoding,x-accept-response-streaming,x-user-agent,x-grpc-web,grpc-timeout
max_age: '1728000'
expose_headers: grpc-status,grpc-message
http_filters:
- name: envoy.filters.http.grpc_web
typed_config:
'@type': type.googleapis.com/envoy.extensions.filters.http.grpc_web.v3.GrpcWeb
- name: envoy.filters.http.cors
typed_config:
'@type': type.googleapis.com/envoy.extensions.filters.http.cors.v3.Cors
- name: envoy.filters.http.router
typed_config:
'@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
clusters:
- name: person_service
connect_timeout: 0.25s
type: logical_dns
http2_protocol_options: {}
lb_policy: round_robin
load_assignment:
cluster_name: person_service
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
# address: backend # 如果用 docker-compose 同时运行 backend + envoy, 用这个
address: host.docker.internal # 如果只用 Docker 运行 envoy, 用这个
port_value: 50051 # backend 的 grpc server port
看不懂没关系, 我也看不懂. 这不是重点, 只需要关心上面注释出来的两个端口和一个 address 就可以了. 创建好就可以通过 docker-compose 来运行了. 下面是一个简单的 docker-compose.yml
文件:
yaml
services:
# Envoy proxy for gRPC-Web support
envoy:
image: envoyproxy/envoy:v1.33-latest
ports:
- '8080:8080'
volumes:
- ./envoy.yaml:/etc/envoy/envoy.yaml # 将 envoy.yaml 挂载到容器里
现在可以通过 docker-compose up
来运行 envoy 了. 这个命令会运行 envoy, 并且将 8080 端口映射到宿主机的 8080 端口, 等待将来自前端的 gRPC-web 的请求转发给 50051 端口的 Rust gRPC server.
3. 处理 proto 文件 + gRPC 通信
这里我们用 proto/person.proto
作为例子, 定义最基本的 CRUD 操作 (这个文件是 copilot 帮我写的, 在 ListPeople 的时候会返回一个分页的结果, 但是没有实现分页的逻辑, 只是为了演示 proto 文件的定义):
proto
syntax = "proto3";
package person;
// The Person message represents an individual person in our system
message Person {
string id = 1;
string name = 2;
string email = 3;
int32 age = 4;
}
// Request message for creating a new person
message CreatePersonRequest {
Person person = 1;
}
// Response message for creating a new person
message CreatePersonResponse {
Person person = 1;
}
// Request message for retrieving a person by ID
message GetPersonRequest {
string id = 1;
}
// Response message for getting a person
message GetPersonResponse {
Person person = 1;
}
// Request message for updating a person
message UpdatePersonRequest {
Person person = 1;
}
// Response message for updating a person
message UpdatePersonResponse {
Person person = 1;
}
// Request message for deleting a person
message DeletePersonRequest {
string id = 1;
}
// Response message for deleting a person
message DeletePersonResponse {
bool success = 1;
}
// Request message for listing people
message ListPeopleRequest {
// Optional pagination fields
int32 page_size = 1;
int32 page_token = 2;
}
// Response message for listing people
message ListPeopleResponse {
repeated Person people = 1;
int32 next_page_token = 2;
}
// The PersonService provides CRUD operations for managing people
service PersonService {
// Create a new person
rpc CreatePerson (CreatePersonRequest) returns (CreatePersonResponse);
// Get a person by ID
rpc GetPerson (GetPersonRequest) returns (GetPersonResponse);
// Update an existing person
rpc UpdatePerson (UpdatePersonRequest) returns (UpdatePersonResponse);
// Delete a person by ID
rpc DeletePerson (DeletePersonRequest) returns (DeletePersonResponse);
// List all people with optional pagination
rpc ListPeople (ListPeopleRequest) returns (ListPeopleResponse);
}
3.1 Rust 端
在 Rust 端, 我们需要使用 tonic 来生成 gRPC 相关的代码. 这块不是重点也很简单, 所以就不展开了, 可以自己查看文档. 其实就是写一个 build.rs
文件, tonic-build
就会自动将 proto 文件编译成 Rust 代码, 然后我们就可以在 Rust 代码中使用了.
3.2 前端
这部分是本文的重点, 也是最麻烦的. 我在查找文档的时候, 被一大堆库弄晕了, 花了很长时间才理清楚每个库是做什么的. 主要是因为 gRPC 的 js 不仅可以在浏览器端使用, 还可以在 Node.js 端使用, 所以有很多库是针对 Node.js 的, 但是我们需要的是浏览器端的. 所以需要装很多库, 有些库单纯是 js 语言本身的, 和平台无关. 有的库会提供浏览器的支持.
官方实现
TL;DR: 不推荐使用官方实现, 各种库很混乱, 构建的命令巨恶心, 并且只支持 commonjs, 不支持 ES module.
官方的 gRPC-web 实现是 grpc-web. 你需要先安装一个编译器:
brew install protobuf
这行语句安装的是 protobuf, 在命令行里的使用 protoc
调用. 它是一个编译器, 我们需要用它来将 proto 文件编译成其他语言的代码. 但是由于 protobuf
官方只支持了 C++, C#, Dart, Go, Java, Kotlin, Python, 没有支持 js, 所以我们需要安装一个插件来生成 js 代码. 这个插件就是 protoc-gen-js, 可以通过 npm 安装. 官方文档教程写的全局安装, 但是我个人喜欢局部安装到项目里.
使用官方实现的一个项目的 package.json
应该有以下库:
json
{
"dependencies": {
"grpc-web": "提供浏览器端与 gRPC 服务器通信的能力, 重点是提供通信能力",
"google-protobuf": "提供 protobuf 的 JavaScript 实现, 重点是提供 protobuf 的序列化和反序列化能力"
},
"devDependencies": {
"protoc-gen-js": "上文提到的插件, 会被 protoc 调用, 用于将 proto 文件编译成 js 代码, 重点是编译能力",
"protoc-gen-grpc-web": "另一个插件, 会被 protoc 调用, 生成 grpc-web 相关的客户端代码"
}
}
然后运行一段脚本来将 proto 文件编译成 js 代码:
json
{
"scripts": {
"gen-proto": "rm -rf src/generated && mkdir -p src/generated && protoc -I=../proto --js_out=import_style=commonjs,binary:src/generated --grpc-web_out=import_style=commonjs,mode=grpcwebtext:src/generated ../proto/*.proto"
}
}
说实话光是看到这个编译命令就两眼一黑, 我尽量解释一下是在干什么. 先抛开前两个删除已有目录+创建目录, 我们来看 protoc
命令:
-I=../proto
表明去哪个目录下找 proto 文件. 因为这个package.json
文件是在frontend
下, 所以这里是上一层的../proto
目录.--js_out=import_style=commonjs,binary:src/generated
这个js_out
会传给protoc-gen-js
插件, 下面会详细介绍.../proto/*.proto
是要编译的 proto 文件, 也是传给protoc
的直接参数.
这个 --js_out
的样子看着过于恶心, 其格式为 --js_out=[OPTIONS:]output_dir
, 其中 OPTIONS
是由逗号分隔的选项, output_dir
是输出目录. 这里的 OPTIONS
有两个选项:
import_style=commonjs
是指定生成的 js 代码使用 commonjs 模块化, 这个选项是可选的, 如果不指定, 默认是commonjs
模块化.binary
说明生成的 js 代码应该支持对消息进行二进制的序列化和反序列化
src/generated
是生成的 js 代码的路径
然后是 --grpc-web_out
参数, 这个参数的格式和 --js_out
一样, 就不过多赘述.
运行这个命令, 会在 src/generated
下生成两个文件:
person_pb.js
: 这个文件是protoc-gen-js
生成的 js 代码, 里面包含了 proto 文件定义的消息类型和序列化/反序列化的逻辑person_grpc_web_pb.js
: 这个文件是protoc-gen-grpc-web
生成的 js 代码, 里面包含了 gRPC 的客户端代码, 也就是我们需要调用发送请求的代码.
很不幸, protoc-gen-js
生成的 js 代码只支持 commonjs, 不支持 esm, 并且里面使用了 exports
变量
js
goog.object.extend(exports, proto.person);
我没有找到方法让 Vite 成功打包生成的代码, 所以不推荐用官方的实现.
protobuf-ts
protobuf-ts 是一个第三方的实现. 这个实现方案只需要装两个库即可:
json
{
"dependencies": {
"@protobuf-ts/grpcweb-transport": "gRPC 的运行时库"
},
"devDependencies": {
"@protobuf-ts/plugin": "protoc 的编译器插件, 用于将 proto 文件编译成 ts 代码"
}
}
然后修改编译命令:
json
{
"scripts": {
"gen-proto": "rm -rf src/generated && mkdir -p src/generated && protoc -I=../proto --ts_out=src/generated ../proto/*.proto"
}
}
可以看出, 这个方案依然依赖 protoc
命令, 但是不需要安装 protoc-gen-js
和 protoc-gen-grpc-web
插件, 只需要安装 @protobuf-ts/plugin
插件, 并且命令也简洁了许多. 这个插件会被 protoc
调用, 用于将 proto 文件编译成 ts 代码. 这个方案的好处是, 生成的 ts 代码是 esm 模块化的, 可以直接在 vite 中使用.
然后更新 frontend/src/grpc.ts
ts
import { GrpcWebFetchTransport } from '@protobuf-ts/grpcweb-transport';
import { PersonServiceClient } from './generated/person.client';
const apiUrl = 'http://localhost:8080';
const transport = new GrpcWebFetchTransport({
baseUrl: apiUrl,
});
export const personClient = new PersonServiceClient(transport);
现在在 React 里就可以愉快地使用了 (这里配合了 react-query):
ts
import { personClient } from './grpc';
// in component
const peopleQueryKey = ['people'];
// READ
const { data: people } = useQuery({
queryKey: peopleQueryKey,
queryFn: async () => {
const response = await personClient.listPeople({ pageSize: 1, pageToken: 1 });
return response.response.people;
},
});
// CREATE
const handleAddPerson = async (person: Person) => {
try {
await personClient.createPerson(CreatePersonRequest.create({ person })); // option 1: using create method
queryClient.invalidateQueries({ queryKey: peopleQueryKey });
setSelectedPerson(null);
} catch (err) {
console.error('Error adding person:', err);
}
};
// UPDATE
const handleUpdatePerson = async (person: Person) => {
try {
await personClient.updatePerson({ person }); // option 2: directly pass params
queryClient.invalidateQueries({ queryKey: peopleQueryKey });
setSelectedPerson(null);
} catch (err) {
console.error('Error updating person:', err);
}
};
接下来一个个运行就可以了:
docker-compose up
运行 envoy- 进入
rust-grpc-backend
,cargo run
运行 rust grpc server - 进入
frontend
,yarn dev
运行前端 - 打开浏览器, 访问 http://localhost:5173 (vite 默认端口)
bufbuild + connect 方案
这是另一个更为系统性 + 现代化的第三方方案, 需要安装以下几个库:
json
{
"dependencies": {
"@bufbuild/protobuf": "protobuf 的核心库, 提供了 protobuf 的运行时支持",
"@connectrpc/connect": "connect 的核心库, 提供了平台无关的 connect 的运行时支持",
"@connectrpc/connect-web": "connect 的 grpc-web 插件, 用于将在前端提供 grpc-web 的通信能力",
"@connectrpc/connect-query": "可选, 提供 react-query 的支持, 虽然不用装这个也可以使用 react-query"
},
"devDependencies": {
"@bufbuild/buf": "proto 文件的编译器",
"@bufbuild/protoc-gen-es": "编译器插件, 生成 es 代码"
}
}
之所以有 bufbuild 和 connect 两个命名空间, 是因为它们有不同的作用. @bufbuild
的库是处理 proto 文件和 protobuf 格式的, 而 @connect
的库是处理 connect/gRPC 协议的.
使用这个方案就可以摆脱复杂的 protoc
命令了, 使用 buf
命令来取代. 这个命令需要在 frontend 目录下创建一个 buf.gen.yaml
文件, 内容如下:
yaml
# Learn more: https://buf.build/docs/configuration/v2/buf-gen-yaml
version: v2 # migration from v1 guide: https://github.com/connectrpc/connect-es/blob/main/MIGRATING.md
inputs:
- directory: ../proto
clean: true
plugins:
- local: protoc-gen-es # 这个插件会被 buf 调用, 用于将 proto 文件编译成 ts 代码
opt: target=ts # 表明我们要生成 ts 代码
out: src/gen # 生成的代码存放目录
include_imports: true
然后在 package.json
中添加一个命令:
json
{
"scripts": {
"gen-proto": "buf generate"
}
}
运行这个命令, 会在 src/gen
下生成一个文件: src/gen/person_pb.ts
. 然后更新 frontend/src/grpc.ts
文件:
ts
import { createClient, Transport } from '@connectrpc/connect';
import { createGrpcWebTransport } from '@connectrpc/connect-web';
import { PersonService } from './gen/person_pb';
const apiUrl = 'http://localhost:8080';
export const transport: Transport = createGrpcWebTransport({
baseUrl: apiUrl,
});
export const personClient = createClient(PersonService, transport);
@connectrpc/connect-web
库还有一个方法 createConnectTransport
, 这个方法创建的 transport 会使用 connect 协议和后端通信, 而不是 gRPC-web 协议. 但是这个方法需要在后端使用 connect 的实现, 而不是 gRPC 的实现. 目前 connect 官方支持了 go, node.js, swift, kotlin, dart, 还未支持 rust. 所以我们这里只能使用 gRPC-web 协议. 但是 connect 的实现会比 gRPC 的实现更现代化, 所以如果后端有支持 connect 的实现, 可以考虑使用 createConnectTransport
方法.
4. Go connect server
文章开头说了, connect 是一个新的 rpc 框架, 兼容 gRPC, 但是更现代化, 同时也有自己的 connect 通信协议. 我们探索一下如何用 go 来实现一个 connect server. 这里我们使用 connect-go, 按照教程安装好必要的工具后, 在项目根目录下新建一个 go 项目目录, 命名为 go-connect-server
, 然后在里面执行:
Step 1 . go mod init example.com/go-connect-server
初始化 go 项目, 包名为 example.com/go-connect-server
Step 2 . buf config init
, 会生成一个 buf.yaml
文件, 内容如下, 不用管它
Step 3 . 创建一个 buf.gen.yaml
文件, 内容如下:
yaml
version: v2
inputs:
- directory: ../proto
clean: true
plugins:
- local: protoc-gen-go # 将 proto 文件编译为 go 代码
out: gen
opt: paths=source_relative
- local: protoc-gen-connect-go # 生成 connect 通信协议相关代码
out: gen
opt: paths=source_relative
Step 4 . 非常重要的一点, 需要修改 person.proto
文件, 在其中加入一行
proto
option go_package = "example.com/go-connect-backend/gen;person";
go_package
定义的是这个 proto 文件生成的 go 代码文件所在的包名. 其中 example.com/go-connect-backend
是 go 项目的包名, gen
是生成的 go 代码文件所在的目录, person
是 package name.
Step 5 . 执行 buf generate
, 会在 go-connect-backend/gen
目录下生成一个 person.pb.go
文件, 这个文件是将 proto 文件编译为 go 代码, 里面包含了 proto 文件定义的消息类型和序列化/反序列化的逻辑. 同时会在 go-connect-backend/gen/personconnect
目录下生成一个 person.connect.go
文件, 这个文件负责处理 connect 协议通信.
然后按照教程写一个基本的服务就可以了, 要注意可能需要处理跨域. 服务启动之后, 在前端就可以通过 createConnectTransport
方法来使用 connect 协议与后端通信了. Connect 使用 HTTP/1.1 (当然, 也支持 HTTP/2) + JSON(或 protobuf)作为传输协议, 在浏览器中无需额外代理服务就能工作, 因此前端可直接请求后端.
5. 总结
本文介绍了如何在 React 项目中使用 gRPC 与后端 gRPC server 交互, 并且使用 envoy 作为代理转发, 还简单探索了一下 go 的 connect server. 以下是一些关键信息:
- 分清楚 protobuf 和 gRPC. 前者是数据格式, 后者是通信协议, 所以才会出现一堆包要装, 因为不同的包处理的是不同的部分.
- gRPC 是基于 HTTP/2 的, 但是浏览器 js 缺少操控 HTTP/2 的底层能力, 所以需要一个代理服务来将 HTTP/1.1 转换为 HTTP/2. envoy 是一个比较流行的选择.
- 官方的 grpc-web 实现比较老旧, 推荐使用流行的第三方方案, 比如 protobuf-ts 或者 connect.
- connect 是一个新的 rpc 框架, 兼容 gRPC, 但是更现代化, 同时也有自己的 connect 通信协议. 如果后端有支持 connect 的实现, 可以考虑使用
createConnectTransport
方法, 这样就不需要使用 envoy 作为代理服务, 直接使用请求服务即可.
6. 一些小细节
- 我个人有很严重的硬盘洁癖, 非常不喜欢在文件系统的各个地方留下文件, 久而久之就吃满硬盘. 所以我会尽可能减少全局安装命令, 这也是使用 Docker 来运行 envoy, 而不是全局安装 envoy 然后运行的原因.
- 在前端我们安装了
@bufbuild/buf
和@bufbuild/protoc-gen-es
这两个库, 安装到了node_modules
里, 然后通过 npm scripts 运行. 有的教程可能会让你全局安装, 可以根据个人喜好来决定. - 由于 go 没有 npm scripts 的机制, 所以 go 的几个 tools 只能通过
go install
全局安装. 这里我们安装了grpcurl
,buf
,protoc-gen-go
,protoc-gen-connect-go
这几个工具, 其中grpcurl
如果你已经通过brew
安装, 就可以不用再次用 go 安装了. 这里全局安装了一个buf
, 其实在前端装的@bufbuild/buf
是这个buf
的一个 wrapper.@bufbuild/buf
会拉取buf
的 binary 到node_modules
里, 然后自己调用.