文章尚未完成,仅保留大纲
一、前言
1.1 目标
在服务上模拟前端发送 grpc-web-text
格式的请求,并且能解析服务器返回的相同格式的响应。
1.2 实现步骤
- 学习 grpc、protobuf 、grpc-web 协议
- 搭建最小测试环境
- 运行测试环境,确认编码规则一致
- 逆向推导
.proto
文件 - 测试
.proto
文件序列化与反序列化结果 - [非必须] 手动实现 protobuf 与 base64 之间的转码与解码
二、gRPC
2.1 gRPC 协议
https://grpc.io/docs/what-is-grpc/
2.2 protobuf 原理
2.3 gRPC-web 协议
https://grpc.github.io/grpc/core/md_doc__p_r_o_t_o_c_o_l-_w_e_b.html
三、环境配置
3.1 gRPC-Web 环境搭建
项目代码运行所需的环境:
- Docker
- Node.js 16+
- protobuf compiler
- JavaScript
1. 安装 Protobuf 编译器
sudo apt update
sudo apt install protobuf-compiler
protoc --version
2. 下载 gRPC-Web Echo Server 项目代码
gRPC-Web-Echo 是我根据 grpc-web example 仓库的 Echo Server 项目改进而来,把跑在 Docker 容器里的前端和后端提取了出来,以方便本地测试。后端使用了 nodemon 热更新,前端则采用 webpack 实现。
下载代码:
https://github.com/Eished/gRPC-Web-Example
git clone git@github.com:Eished/gRPC-Web-Example.git
该项目包含三个部分:
- Front-end JS client (web-client)
- Envoy proxy (运行在 docker 中的代理)
- gRPC backend server (node-server)
进入项目根目录:
3. 启动 Envoy 代理
docker-compose up -d
4. 启动 gRPC 后端服务
cd node-server
npm install
npm run start
6. 启动前端服务
cd web-client
npm install
npm run start
访问 http://localhost:8088/ ,在页面输入任意字符发送,服务器将返回输入的字符。
四、逆向推导 proto 文件
4.1 请求的 proto 文件还原
1. 通过浏览器的Network控制面板查看请求
- 请求头内容类型为
application/grpc-web-text
, - payload为
2. 查看发出请求的函数
- 打上断点,
- 单步调试分析主要流程,
- 找到参数进行序列化的函数
3. 把序列化的JS文件下载(非必须,在上一步找到该代码复制保存也可以)
- 找到请求的序列化函数
4. 根据该序列化函数推导出请求参数的名称和类型
例如参数名
phase
,writeEnum(1,r)
表示phase
序号为1
并且类型为enum
,其它参数类似。得到的
.proto
文件如下
5. 使用得到的proto文件进行序列化和请求测试
在 gRPC-Web Echo Server 测试环境中使用
.proto
进行序列化测试将该文件复制到项目的
protos
目录中请求正常发出,并且有正确的response,response解析会报错,因为response的proto文件还没有分析,接下来还原response的proto文件。
4.2 响应的 proto 文件还原
有了前面的经验,能推测出响应的函数名,搜索后果然如此。
根据该序列化函数推导出请求参数的名称和类型
在 gRPC-Web Echo Server 测试环境中测试
五、测试 proto 文件序列化与反序列化
5.1 安装依赖,编译ts代码
https://github.com/timostamm/protobuf-ts
安装转换插件
# with npm:
npm install @protobuf-ts/plugin
# with yarn:
yarn add @protobuf-ts/plugin
安装运行时依赖
# with npm:
npm install @protobuf-ts/runtime
# with yarn:
yarn add @protobuf-ts/runtime
安装 node-fetch
# For Node.js, use the polyfill node-fetch.
npm install node-fetch
yarn add node-fetch
根据proto文件编译ts代码
npx protoc \
--ts_out protos/ \
--ts_opt long_type_string \
--proto_path protos \
protos/test.proto
5.2 编写代码运行
import { GrpcWebFetchTransport } from '@protobuf-ts/grpcweb-transport'
import { TestRequest } from 'protos/test'
import { TestClient } from 'protos/test.client'
const transport = new GrpcWebFetchTransport({
baseUrl: 'https://目标api地址/',
})
const client = new TestClient(transport)
const step1 = TestRequest.create({
msg: 1,
})
const response = await client.test(step1).then((res) => res.response)
console.log('test', response)
测试正常运行,但 protobuf-ts/grpcweb-transport
有些服务器自定义错误状态码无法被抛出,抛出是统一的状态码。所以手动处理 protobuf。
六、手动实现 protobuf 与 base64 之间的转码与解码
不使用 protobuf-ts 发送\接收请求,而是序列化为 protobuf 再转码 base64,使用 axios 发送请求并处理响应。
6.1 编译 gRPC-web 客户端代码
安装编译器
# 代码编译器
yarn add ts-protoc-gen -D
仅生成 protobuf 文件,无需生成 Service 和 Client 文件
protoc \
--plugin=protoc-gen-ts=./node_modules/.bin/protoc-gen-ts \
--js_out=import_style=commonjs,binary:./protos \
--ts_out=grpc-web:./protos \
-I ./protos \
protos/test.proto
可选:如果使用 gRPC-web 发送请求时需要安装
# grpc-web 基本依赖
yarn add google-protobuf @types/google-protobuf @improbable-eng/grpc-web
# 编码转换器
yarn add @improbable-eng/grpc-web-node-http-transport
6.2 protobuf 转 base64
使用 TypeScript 进行转码:
简易版:
// 将 Protocol Buffers 数据 Uint8Array 放入 Buffer
const encodedData = Buffer.from(TestRequest.serializeBinary())
console.log(encodedData)
// 创建一个包含5个零字节的 Buffer
const zeroBuffer = Buffer.alloc(5)
// 第5位写入Uint8Array数据长度
zeroBuffer.writeInt32BE(TestRequest.serializeBinary().length, 1)
// 将 Buffer 和 TestRequest 的二进制数据拼接在一起
const combinedBuffer = Buffer.concat([zeroBuffer, encodedData])
console.log(combinedBuffer.toString('base64'))
生成 header 的完整版:
const toBytesInt32 = (num: number): Uint8Array => {
// an Int32 takes 4 bytes
const arr = new ArrayBuffer(4)
const view = new DataView(arr)
// byteOffset = 0; litteEndian = false
view.setUint32(0, num, false)
return new Uint8Array(arr)
}
const createHeader = (name: string, value: string): Buffer => {
const buffers: Array<number> = []
for (const char of name) {
buffers.push(char.charCodeAt(0))
}
buffers.push(':'.charCodeAt(0))
for (const char of value) {
buffers.push(char.charCodeAt(0))
}
buffers.push('\r'.charCodeAt(0))
buffers.push('\n'.charCodeAt(0))
return Buffer.from(buffers)
}
export const encodeProtobuf = <
GRPCReply extends { serializeBinary: () => Uint8Array },
>(
reply: GRPCReply,
): string => {
const replyBuffer = reply.serializeBinary()
const replyLength = toBytesInt32(replyBuffer.length)
const headers = Buffer.from([
...createHeader('grpc-status', '0'),
...createHeader('grpc-message', 'OK'),
])
const headersLength = toBytesInt32(headers.length)
const response = Buffer.from([
0, // mark response start ("data" frame coming next)
...replyLength,
...replyBuffer,
128, // end of "data" frame, trailer frame coming next
...headersLength,
...headers,
])
return response.toString('base64')
}
function main(): void {
// UserPB is protobuf object created with protoc-gen-grpc
// Any protobuf object with a serializeBinary function
// which returns an Uint8Array will do
const reply = new UserPB.SetUserReply()
reply.setUserId('mock-user-id')
reply.setFirstName('foo')
reply.setLastName('bar')
const encoded = encodeProtobuf(reply)
console.log(encoded)
}
main()
6.3 base64 转 protobuf
直接解析:
echo "AAAAAAwIARDABxoFL3Rlc3Q=" | base64 -d | xxd
使用 protobuf compiler 解析:
echo "AAAAAAwIARDABxoFL3Rlc3Q=" | base64 -d | tail -c +6 | protoc --decode_raw
使用 TypeScript 进行解码:
头部解析 + 数据解析
#!/usr/bin/env node
import { exec } from 'child_process'
import { promisify } from 'util'
import minimist from 'minimist'
import chalk from 'chalk'
import which from 'which'
const argv = minimist(process.argv.slice(2))
const asyncExec = promisify(exec)
const { log } = console
const grpcWebTexts: Array<string> = [
'AAAAADEKB2pvZmxveWQSJmh0dHBzOi8vcGljc3VtLnBob3Rvcy9pZC8xMzMvMjc0Mi8xODI4',
'AAAAAAMInRQ=gAAAACBncnBjLXN0YXR1czowDQpncnBjLW1lc3NhZ2U6T0sNCg==',
]
const getBinaryHex = (grpcTexts: Array<string>) => {
const buffers: Array<string> = []
grpcTexts.forEach(text => {
const buffer = Buffer.from(text.trim(), 'base64')
const bufString = buffer.toString('hex')
buffers.push(bufString)
})
return buffers
}
const validateProtocExist = () => {
const resolved = which.sync('protoc', { nothrow: false })
if (!resolved) {
log(
chalk.red(
`\ngrpcwebtext-parser requires ${chalk.bgRed(
chalk.white(`protoc`),
)}\nPlease download it before running this program\n`,
),
)
process.exit(1)
}
}
function main() {
validateProtocExist()
const { _: text } = argv
if (text.length === 0) {
console.error('No input grpc text, defaulting to demo values')
}
// bytes are in base 16
const buffers = text.length ? getBinaryHex(text) : getBinaryHex(grpcWebTexts)
// eslint-disable-next-line @typescript-eslint/no-misused-promises
buffers.forEach(async (buffer, i) => {
const responseStartMarkerLen = 2
if (buffer.substr(0, responseStartMarkerLen) !== '00')
return log(chalk.bgRed('Cannot Parse'))
const frameLen = buffer.substr(2, 4 * 2)
const frameLenInt = parseInt(frameLen, 16) * 2
const frameDataIndex = responseStartMarkerLen + frameLen.length
const frameData = buffer.substr(frameDataIndex, frameLenInt)
const frameParsed = await asyncExec(
`echo ${frameData} | xxd -r -p | protoc --decode_raw`,
)
log(`\n${chalk.bgBlue('Web gRPC text:')} ${text[i] || grpcWebTexts[i]}\n`)
log(frameParsed.stdout)
const trailersLen = 2
const trailersLenIndex = frameDataIndex + frameLenInt
const hasTrailers = buffer.substr(trailersLenIndex, trailersLen) === '80'
if (hasTrailers) {
const headerLenIndex = trailersLenIndex + trailersLen
const headerLen = buffer.substr(headerLenIndex, 4 * 2)
const headerLenInt = parseInt(headerLen, 16) * 2
const headerIndex = headerLenIndex + headerLen.length
const headerData = buffer.substr(headerIndex, headerLenInt)
log('Trailing headers:')
// '0d0a' is hex value of the '\r\n' ASCII characters
headerData.split('0d0a').forEach(header => {
log(Buffer.from(header, 'hex').toString('ascii').replace(':', ': '))
})
}
})
}
main()