背景:
最近项目中有一个需求:在新项目中使用grpc进行前后端通信
。我便基于此需求开始了新的研究。
首先我是想抄作业的,但是翻了很多相关grpc-web的文章,写的都不是很详细,再涉及到grpc-web服务的升级迭代,生成的代码有了变动,导致我根本没找到什么可以复用的有效资料。
没办法,只能自己搞了!文章会有点长,耐心看完应该会有些帮助。如果有大佬看到可以留下些意见,对grpc和go语言都是现学现卖的小白阶段。
其实原理还是比较简单的。如果还不了解grpc的同学可以自己去搜索一下相关知识,这个在度娘那能搜到很多。我在这里简要概括一下:
grpc释义
gRPC 是 Google 开发的一个高效的、开源的远程过程调用 (Remote Procedure Call, RPC) 框架,旨在跨网络分布式系统中实现不同服务之间的通信。它建立在 HTTP/2 协议之上,利用 Protocol Buffers 作为序列化协议,具有以下几个特点:
-
跨语言支持
gRPC 支持多种编程语言,包括 Go、Java、Python、C++、Node.js 等。这使得开发者可以用不同的语言编写各自的服务,同时通过 gRPC 轻松进行通信。 -
高效的二进制传输
gRPC 使用 Protocol Buffers (Protobuf) 作为其接口定义语言 (IDL) 和数据序列化格式。相比于传统的基于文本的序列化格式(如 JSON 或 XML),Protobuf 是一种高效的二进制格式,具有更小的消息体积和更快的序列化/反序列化速度。 -
HTTP/2 支持
gRPC 使用 HTTP/2 进行通信,具备双向流、多路复用、头部压缩等特性,使得它在需要高性能、低延迟的应用场景中具有显著优势。 -
多种通信模式
gRPC 支持多种通信模式,不仅可以实现简单的一对一的请求-响应模型,还支持:
服务端流式 RPC:客户端发送一个请求,服务端可以返回多个响应。
客户端流式 RPC:客户端发送多个请求,服务端返回一个响应。
双向流式 RPC:客户端和服务端可以互相发送多个消息,形成流式通信。
5. 强类型定义
gRPC 中的服务及消息都需要通过 Protocol Buffers 来定义,提供了强类型的接口,减少了通信中的潜在错误,并且具有更好的可维护性和扩展性。
gRPC-Web 及其与 gRPC 的关系
虽然 gRPC 非常强大,但它原生是为后端服务之间的通信设计的,使用 HTTP/2 和 Protobuf 可能无法直接在浏览器环境中兼容。浏览器对 HTTP/2 和某些传输方式的支持较为有限,而且通常不能直接处理 Protobuf 编码的二进制数据。这时,gRPC-Web 出现了。
gRPC-Web 是 gRPC 的一个扩展,它允许前端应用(如在浏览器中运行的 JavaScript 应用)通过 gRPC 与后端服务通信。gRPC-Web 作为一种精简的 gRPC 实现,它通常配合一个代理(如 Envoy)使用,将浏览器的 HTTP/1.x 或 HTTP/2 请求转换为标准的 gRPC 格式,然后再发送到后端 gRPC 服务器。
gRPC-Web 的特点
兼容浏览器
gRPC-Web 让 Web 应用可以使用大部分 gRPC 的功能,虽然有些特性(如双向流)在 gRPC-Web 中受限,但它可以处理基本的请求-响应和单向流。
无需更改后端
后端仍然可以使用标准的 gRPC 服务,gRPC-Web 通过代理进行转换,无需对现有的 gRPC 服务进行重大的修改。
减少客户端复杂性
gRPC-Web 提供了简化的客户端 API,前端开发者可以像使用传统的 REST API 一样调用后端服务,但享受 gRPC 的高性能和高效的数据传输。
gRPC 和 gRPC-Web 的关系总结
gRPC 是一种后端服务之间高效通信的框架,支持多种语言和多种通信模式。
gRPC-Web 是 gRPC 的一个精简版本,专门为浏览器环境设计,允许前端通过 HTTP/1.x 或 HTTP/2 与 gRPC 后端服务通信。
它们共同构成了一个强大的生态系统,让前后端之间的通信更加高效和安全,同时保持了现代 Web 应用的开发灵活性。
而我们的需求主要是为了前后端统一用一套接口规范。即同一套proto文件,来生成前后端的接口。
概念解释清楚了,开始操作,大概可以分为以下几个部分:
-
定义服务和消息类型
使用 Protocol Buffers (Protobuf) 定义 gRPC 服务及其请求和响应消息格式。这些定义描述了客户端和服务端如何通信,类似于 API 的契约。 -
生成客户端和服务端代码
基于 Protobuf 文件,通过 protoc 编译器生成服务端和客户端的代码。对于前端 gRPC-Web 通信,客户端代码需要使用 gRPC-Web 的插件生成 TypeScript 或 JavaScript 文件。 -
实现后端服务
在服务端实现 gRPC 服务逻辑。根据生成的代码,编写服务端的业务逻辑处理请求,并返回相应的响应。这个过程与标准 gRPC 服务实现相同。 -
设置代理(如 Envoy)
由于浏览器无法直接与 gRPC 服务通信,通常需要配置一个代理(如 Envoy)。代理将前端发送的 gRPC-Web 请求转换为标准 gRPC 请求,并将响应转发回浏览器。代理还负责处理 gRPC-Web 请求的 HTTP/1.x 或 HTTP/2 兼容性问题。 -
前端集成 gRPC-Web
在前端应用中使用 gRPC-Web 客户端库调用 gRPC 服务。通过生成的客户端代码,前端可以发起 gRPC-Web 请求,与后端进行通信。前端与使用传统 API 的方式相似,但底层使用 gRPC 协议。 -
通信和调试
前端通过 gRPC-Web 发出请求,代理将请求转换并转发到 gRPC 服务。服务端处理请求并返回响应,代理将响应再传回前端。调试时,确保代理和服务端正确配置,且通信符合预期。
我以为我会很通顺的做完这几步,真正实践时候发现坑还挺多的。一步步来吧。
- 定义服务和消息类型(.proto文件)
// 使用 proto3 语法版本
syntax = "proto3";// 定义包名为 calculation,这个包名会影响生成的代码的命名空间
package calculation;// 设置 Go 语言生成的包路径和包名
option go_package = "./calculation;proto";// 定义 CalculationService 服务,包含两个 RPC 方法:Add 和 Subtract
service CalculationService {// 定义 Add RPC 方法,接收 AddRequest 消息,返回 AddResponse 消息rpc Add(AddRequest) returns (AddResponse);// 定义 Subtract RPC 方法,接收 SubtractRequest 消息,返回 SubtractResponse 消息rpc Subtract(SubtractRequest) returns (SubtractResponse);
}// 定义 AddRequest 消息结构,包含两个字段 num1 和 num2,都是 double 类型
message AddRequest {double num1 = 1; // 第一个加数double num2 = 2; // 第二个加数
}// 定义 AddResponse 消息结构,包含一个字段 result,表示加法结果
message AddResponse {double result = 1; // 加法的结果
}// 定义 SubtractRequest 消息结构,包含两个字段 num1 和 num2,都是 double 类型
message SubtractRequest {double num1 = 1; // 被减数double num2 = 2; // 减数
}// 定义 SubtractResponse 消息结构,包含一个字段 result,表示减法结果
message SubtractResponse {double result = 1; // 减法的结果
}
这个proto文件中定义了两个方法,最简单的两个数字的加减,我在其中添加了注释。
接下来就是对proto文件的编译。
这里首先要确保已经安装了如下插件:
全局安装protoc:
mac:brew install protobuflinux:sudo apt install -y protobuf-compiler
全局安装protoc-gen-grpc-web地址:protoc-gen-grpc-web
前端项目中安装grpc-web (npm)
然后将proto文件放到前端项目根路径下,并在同级目录新建一个文件夹generated存放生成的代码,执行命令:
protoc -I=. calculation.proto \--js_out=import_style=commonjs:./generated \--grpc-web_out=import_style=typescript,mode=grpcwebtext:./generated
正常会在generated目录下生成三个文件:calculation_pb.js, calculation_pb_d.ts, ApiServiceClientPb.ts
按流程接下来我们就可以使用它了。
在index.vue中:
// 从生成的 gRPC 客户端文件中导入 CalculationServiceClient 类
import { CalculationServiceClient } from '@generated/ApiServiceClientPb'// 从生成的消息类型文件中导入所有的消息类型,命名为 api
import * as api from '@generated/api_pb.js'// 创建一个 AddRequest 请求对象,用于存储加法操作的两个参数
const request = new api.AddRequest()
// 设置第一个加数 num1,值为 10
request.setNum1(10)
// 设置第二个加数 num2,值为 1
request.setNum2(1)// 创建一个 CalculationServiceClient 客户端实例,指向后端的 `/api` 端点
const client = new CalculationServiceClient('/api')// 异步函数,用于调用 gRPC 服务
async function grpcCall() {// 使用 client 调用 add 方法,传入请求对象和空的元数据对象 {}client.add(request, {}, (err, response) => {// 如果调用过程中发生错误,输出错误信息if (err) {console.error('Error:', err)} // 如果调用成功,输出响应结果else {// 从响应中获取结果 result 并输出console.log('Response-Result:', response.getResult())}})
}
到这里,抛开代理的事情不谈,我们应该是可以在控制台看到接口调用的。但是并没有(会有报错,详细报错信息可以试一下)。
原因是这里生成的代码(api.pb.js)中包含了语法:
var jspb = require('google-protobuf');
和
goog.object.extend(exports, proto.api);
这里使用了CommonJS 模块规范,但是我们项目使用了Vite,Vite 默认支持 ES Module(ESM)规范。这里我第一次尝试是使用@rollup/plugin-commonjs
插件来解决,但是失败了,原因未知,反正我没成功,有兴趣可以尝试,大佬成功了可以告诉我一下哈!
对CommonJS 模块规范和ES Module规范不了解的转至这篇文章:搞清CommonJS、AMD、CMD、ES6的联系与区别
我这里用了笨办法:
将var jspb = require('google-protobuf');
改为import * as jspb from 'google-protobuf';
,并将goog.object.extend(exports, proto.api);
改为
const { ApiRequest, ApiResponse } = proto.api;
export { ApiRequest, ApiResponse };
这样就可以完美解决这个问题。并且不会影响另一个生成文件中的引用。(如下)
import * as grpcWeb from 'grpc-web';
import * as api_pb from './api_pb'; // proto import: "api.proto"
可是这会面临一个问题:如果proto文件中定义的接口很多很繁杂,并且每次更改之后都要重新来改。显然是不现实的。所以后面我封装了shell命令来帮我们完成这个操作,可以不用担心。
先把对proto文件生成和替换CommonJS 模块规范的代码贴出来吧!
- 在package.json中添加script:
"protoc": "sh ./protoc/protoc.sh"
新建protoc文件夹,在其中新建protoc.sh文件。
# grpc_node_plugin的路径
PROTOC_GEN_GRPC_PATH="../node_modules/.bin/grpc_tools_node_protoc_plugin"# 写入生成代码的目标目录(.js和.d.ts文件)
OUT_DIR="./generated"
# 传入的 .proto 文件名,用户可以动态修改这个字段,例如修改为 user.proto
PROTO_FILE_NAME="api" # 动态修改此变量为你需要的 proto 文件名# 自动提取包名
PROTO_FILE="${PROTO_FILE_NAME}.proto"
if [ -f "$PROTO_FILE" ]; then# 使用 grep 提取 package 行,并使用 awk 提取包名 动态修改此变量为需要的 proto 包名PROTO_PACKAGE_NAME=$(grep -m 1 '^package ' "$PROTO_FILE" | awk '{print $2}' | sed 's/;//') # 去掉分号echo "Detected package name: $PROTO_PACKAGE_NAME"
elseecho "Error: Proto file '$PROTO_FILE' not found."exit 1
fi# 移除生成目录中的旧文件
rm -rf ${OUT_DIR}/*
# 生成web所需的文件,动态使用传入的 .proto 文件
protoc \--js_out="import_style=commonjs:${OUT_DIR}" \--grpc-web_out="import_style=typescript,mode=grpcwebtext:${OUT_DIR}" \./${PROTO_FILE_NAME}.proto# 使用动态文件名拼接生成的 api_pb.js 文件路径
GENERATED_FILE="${OUT_DIR}/${PROTO_FILE_NAME}_pb.js"echo ${GENERATED_FILE}# 替换api_pb.js中的内容,生成的是commonjs格式,需要手动替换为es6语法。
node ./protoc/generateExports.js ${GENERATED_FILE} ${PROTO_PACKAGE_NAME}
其中唯一要修改的是文件名PROTO_FILE_NAME
,包名会通过文件名自动检索。当然这一步也可以优化成让代码自动检索文件夹内所有proto文件并执行。
- 在同级文件夹下新建文件
generateExports.js
,用来进行文件替换。
import fs from 'node:fs'
// import path from 'node:path'
import process from 'node:process'// 从命令行获取文件路径参数,如果没有传递则使用默认路径
// const filePath = process.argv[2]
const filePath = process.argv[2]
const packageName = process.argv[3]
try {// 读取指定的文件let fileContent = fs.readFileSync(filePath, 'utf-8')// 删除 goog.object.extend(exports, proto.api); api为包名,packageNameconst regex = new RegExp(`goog\\.object\\.extend\\(exports, proto\\.${packageName}\\);\\n?`, 'g')fileContent = fileContent.replace(regex, '')// 替换 var jspb = require('google-protobuf'); 为 import * as jspb from 'google-protobuf';fileContent = fileContent.replace(/var jspb = require\('google-protobuf'\);\n?/g, 'import * as jspb from \'google-protobuf\';\n')// 提取 goog.exportSymbol 语句,并生成 ES6 模块导出const exportSymbols = []const exportSymbolRegex = new RegExp(`goog\\.exportSymbol\\('proto\\.${packageName}\\.(\\w+)', null, global\\);\\n?`, 'g')fileContent = fileContent.replace(exportSymbolRegex, (match, p1) => {exportSymbols.push(p1)return match // 保留原有语句})// 添加 ES6 模块导出语句const exportStatements = exportSymbols.length > 0 ? `\n\nconst { ${exportSymbols.join(', ')} } = proto.${packageName};\nexport { ${exportSymbols.join(', ')} };` : ''const outputContent = fileContent + exportStatements// 写入生成的文件fs.writeFileSync(filePath, `${outputContent}\n`)// eslint-disable-next-line no-consoleconsole.log(`${filePath} has been generated successfully.`)
}
catch (error) {console.error(`Error processing file: ${filePath}`, error)
}
这样就完成了我们前面的基本准备。正常在执行index.vue的代码就能看到请求了。
接下来开始配置envoy代理部分:
这里由于我对envoy也没了解过。所以对它的一大串配置也是难受得很。只能慢慢试探。
- 下载地址:envoy
- 创建配置文件:
static_resources:listeners:- name: listener_0address:socket_address:address: 0.0.0.0port_value: 8080 # 代理前端gRPC请求的端口filter_chains:- filters:- name: envoy.filters.network.http_connection_managertyped_config:"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManagerstat_prefix: ingress_httpaccess_log:- name: envoy.access_loggers.stdouttyped_config:"@type": type.googleapis.com/envoy.extensions.access_loggers.stream.v3.StdoutAccessLoghttp_filters:- name: envoy.filters.http.grpc_webtyped_config:"@type": type.googleapis.com/envoy.extensions.filters.http.grpc_web.v3.GrpcWeb- name: envoy.filters.http.routertyped_config:"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Routerroute_config:name: local_routevirtual_hosts:- name: local_servicedomains: ["*"]routes:- match:prefix: "/" # 匹配所有请求route:cluster: grpc_backend # 将请求转发到集群clusters:- name: grpc_backend # 集群名称type: LOGICAL_DNSconnect_timeout: 5sdns_lookup_family: V4_ONLYlb_policy: ROUND_ROBINhttp2_protocol_options: {} # 启用HTTP/2load_assignment:cluster_name: grpc_backendendpoints:- lb_endpoints:- endpoint:address:socket_address:address: 127.0.0.1port_value: 8088 # 后端 gRPC 服务的端口# transport_socket:# name: envoy.transport_sockets.tls# typed_config:# "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext# sni: 127.0.0.1 # 后端服务的 SNI
这里网上找的都没法用,改了很多次,其中有几个要注意的点:
http2_protocol_options: {} # 启用HTTP/2
这里一定要打开,因为grpc需要http2.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.router typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
这里要注意一定要加上envoy.filters.http.grpc_web
这个,并加上对应地址。且envoy.filters.http.grpc_web
一定要在最后面。
其他配置:
- 顶部socket_address下面配置的地址是你要代理的前端接口地址。这里要在vue项目中单独配置反向代理给它。如下:
// vite.config.jsproxy: {'/proxy': {target: env.VITE_APP_API_BASEURL,changeOrigin: command === 'serve' && env.VITE_OPEN_PROXY === 'true',rewrite: path => path.replace(/^\/proxy/, ''),},'/api': {target: 'http://127.0.0.1:8080',changeOrigin: true,ws: true,rewrite: path => path.replace(/^\/api/, ''),},},
其中/api是我要代理出去的配置。这里端口8080和envoy相对应。所以我在index.vue中会这样写:
const client = new CalculationServiceClient('/api')
-
底部的socket_address配置是我们要代理出去的目标地址。这里我将后端服务起在了本地的8088端口。
-
启动envoy:
envoy -c /path/to/your/envoy.yaml --log-level debug
这样我们就完成了代码的闭环,如果你的后端没有问题的话,接口应该就可以获取到数据了!
到这里我的功能(接口调用,前后端交互)基本完成了,但是这么简单去调用很难维护。我试了一下grpc的库:@improbable-eng/grpc-web
来完成这部分的调用操作。但是失败了,因为它需要的参数和我生成的文件对不上。不知道是不是因为它们的更新版本不对等。有知道的大佬可以告诉一下或者推荐文章给我,万分感谢!
所以这里无奈我只能自己手动封装了。封装逻辑在下一篇文章中vue3+Ts中grpc-web的代码封装思路。
我还分享了一篇简单的go语言实现grpc功能的最简单的例子代码: 用 Go 语言实现一个最简单的 gRPC 服务端,有兴趣可以关注一下。