基于Vue3+Ts+Vite项目中grpc-Web的应用以及其中的坑

背景:

最近项目中有一个需求:在新项目中使用grpc进行前后端通信。我便基于此需求开始了新的研究。

首先我是想抄作业的,但是翻了很多相关grpc-web的文章,写的都不是很详细,再涉及到grpc-web服务的升级迭代,生成的代码有了变动,导致我根本没找到什么可以复用的有效资料。

没办法,只能自己搞了!文章会有点长,耐心看完应该会有些帮助。如果有大佬看到可以留下些意见,对grpc和go语言都是现学现卖的小白阶段。

其实原理还是比较简单的。如果还不了解grpc的同学可以自己去搜索一下相关知识,这个在度娘那能搜到很多。我在这里简要概括一下:

grpc释义

gRPC 是 Google 开发的一个高效的、开源的远程过程调用 (Remote Procedure Call, RPC) 框架,旨在跨网络分布式系统中实现不同服务之间的通信。它建立在 HTTP/2 协议之上,利用 Protocol Buffers 作为序列化协议,具有以下几个特点:

  1. 跨语言支持
    gRPC 支持多种编程语言,包括 Go、Java、Python、C++、Node.js 等。这使得开发者可以用不同的语言编写各自的服务,同时通过 gRPC 轻松进行通信。

  2. 高效的二进制传输
    gRPC 使用 Protocol Buffers (Protobuf) 作为其接口定义语言 (IDL) 和数据序列化格式。相比于传统的基于文本的序列化格式(如 JSON 或 XML),Protobuf 是一种高效的二进制格式,具有更小的消息体积和更快的序列化/反序列化速度。

  3. HTTP/2 支持
    gRPC 使用 HTTP/2 进行通信,具备双向流、多路复用、头部压缩等特性,使得它在需要高性能、低延迟的应用场景中具有显著优势。

  4. 多种通信模式
    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文件,来生成前后端的接口。

概念解释清楚了,开始操作,大概可以分为以下几个部分:
  1. 定义服务和消息类型
    使用 Protocol Buffers (Protobuf) 定义 gRPC 服务及其请求和响应消息格式。这些定义描述了客户端和服务端如何通信,类似于 API 的契约。

  2. 生成客户端和服务端代码
    基于 Protobuf 文件,通过 protoc 编译器生成服务端和客户端的代码。对于前端 gRPC-Web 通信,客户端代码需要使用 gRPC-Web 的插件生成 TypeScript 或 JavaScript 文件。

  3. 实现后端服务
    在服务端实现 gRPC 服务逻辑。根据生成的代码,编写服务端的业务逻辑处理请求,并返回相应的响应。这个过程与标准 gRPC 服务实现相同。

  4. 设置代理(如 Envoy)
    由于浏览器无法直接与 gRPC 服务通信,通常需要配置一个代理(如 Envoy)。代理将前端发送的 gRPC-Web 请求转换为标准 gRPC 请求,并将响应转发回浏览器。代理还负责处理 gRPC-Web 请求的 HTTP/1.x 或 HTTP/2 兼容性问题。

  5. 前端集成 gRPC-Web
    在前端应用中使用 gRPC-Web 客户端库调用 gRPC 服务。通过生成的客户端代码,前端可以发起 gRPC-Web 请求,与后端进行通信。前端与使用传统 API 的方式相似,但底层使用 gRPC 协议。

  6. 通信和调试
    前端通过 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 protobuf
      linux: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"
else
    echo "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为包名,packageName
  const 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-console
  console.log(`${filePath} has been generated successfully.`)
}
catch (error) {
  console.error(`Error processing file: ${filePath}`, error)
}

这样就完成了我们前面的基本准备。正常在执行index.vue的代码就能看到请求了。

接下来开始配置envoy代理部分:

这里由于我对envoy也没了解过。所以对它的一大串配置也是难受得很。只能慢慢试探。

  1. 下载地址:envoy
  2. 创建配置文件:
static_resources:
  listeners:
    - name: listener_0
      address:
        socket_address:
          address: 0.0.0.0
          port_value: 8080 # 代理前端gRPC请求的端口

      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
                stat_prefix: ingress_http
                access_log:
                  - name: envoy.access_loggers.stdout
                    typed_config:
                      "@type": type.googleapis.com/envoy.extensions.access_loggers.stream.v3.StdoutAccessLog
                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
                route_config:
                  name: local_route
                  virtual_hosts:
                    - name: local_service
                      domains: ["*"]
                      routes:
                        - match:
                            prefix: "/" # 匹配所有请求
                          route:
                            cluster: grpc_backend # 将请求转发到集群

  clusters:
    - name: grpc_backend # 集群名称
      type: LOGICAL_DNS
      connect_timeout: 5s
      dns_lookup_family: V4_ONLY
      lb_policy: ROUND_ROBIN
      http2_protocol_options: {} # 启用HTTP/2
      load_assignment:
        cluster_name: grpc_backend
        endpoints:
          - lb_endpoints:
              - endpoint:
                  address:
                    socket_address:
                      address: 127.0.0.1
                      port_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.js
      proxy: {
        '/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 服务端,有兴趣可以关注一下。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值