开发基于 gRPC 协议的 Node 服务
本文由腾讯文档的前端开发工程师张南华撰写。他曾在 Shopee 主导上万 qps 配置中心项目的研发工作,负责 Node 项目架构、技术方案设计等核心研发工作。目前主要负责腾讯文档前端容器与新品类研发工作。擅长 Node 服务工程化、前端性能优化、质量体系建设等相关内容。喜欢研究新技术、参加开源活动。
祝大家端午安康!
前言
在 Shopee 任职期间,我在开发 gRPC 协议的 node 微服务时有过不错的一些实践,配置中心、差分服务、官网服务等。因此我写下这篇文章,做一些些的总结,记录一下碰到的问题、解决的方法以及一些个人的小小见解。
这篇文章主要会介绍一下在前端领域使用 gRPC 协议的方法、使用时碰到的一些问题以及目前开源社区的一些反应。所有的内容不仅出自个人的感受,还会有一些相应的资料作为支撑。文章里面不会有具体的实现细节,我觉得没太必要,官方文档告诉你得更多更准确,更多的可能会是一些方法论的内容。
开始之前给大家简单介绍一下 gRPC 的背景知识,有基础的同学可以直接跳过。
What is gRPC?
gRPC is a modern, open source remote procedure call (RPC) framework that can run anywhere. It enables client and server applications to communicate transparently, and makes it easier to build connected systems.
Read the longer Motivation & Design Principles[1] post for background on why we created gRPC.
gRPC 是一个由 Google 推出的、高性能、开源、通用的 rpc 框架。它是基于 HTTP2 协议标准设计开发,默认采用 Protocol Buffers 数据序列化协议,支持多种开发语言。通俗说就是一种 Google 设计的二进制rpc协议。
What is protobuf?
hello.pb
// The greeter service definition.
service Greeter {
// Sends a greeting
rpc SayHello (HelloRequest) returns (HelloReply) {}
}
// The request message containing the user's name.
message HelloRequest {
string name = 1;
}
// The response message containing the greetings
message HelloReply {
string message = 1;
}
gRPC 协议是基于 protobuf 进行通信的。开发者在 protobuf 文件里面定义详细的 service,message。在 messgae 内部,为请求、返回的字段定义精确的数据类型。protobuf 文件再通过编译成各种语言版本的文件,提供给 grpc 服务的 server、client 使用。
server & client 的使用
动态编译
官方提供了 node-grpc 类库,为 node 端使用 gRPC 协议提供了一系列的支持。其中 `packages/proto-loader`[2] 提供了一个动态编译 protobuf 文件的功能。它会将一个 protobuf 文件内的 server 转化成一个实例对象返回。如下我们就获取了一个 routeguide 对象,然后我们就可以使用这个对象去做接口访问或者创建一个server。
var PROTO_PATH = __dirname + '/../../../protos/hello.proto';
var grpc = require('@grpc/grpc-js');
var protoLoader = require('@grpc/proto-loader');
// Suggested options for similarity to existing grpc.load behavior
var packageDefinition = protoLoader.loadSync(
PROTO_PATH,
{keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true
});
var protoDescriptor = grpc.loadPackageDefinition(packageDefinition);
// The protoDescriptor object has the full package hierarchy
var helloObj = protoDescriptor.hello;
使用 helloObj 对象为创建 gRPC server 提供 protobuf 对象的定义
import grpc from "@grpc/grpc-js";
// 具体方法实现可以去看官方的样例。
// https://grpc.io/docs/languages/node/basics/
// 这里的方法都是(call,callback,metedata)=>{} 类型的。
// 这里只举一个例子
function sayHello(call,callback) {
// do something
callback();
}
function getServer() {
var server = new grpc.Server();
server.addService(helloObj.Greeter.service, {
sayHello: sayHello,
});
return server;
}
var greeterServer = getServer();
greeterServer.bindAsync('0.0.0.0:50051', grpc.ServerCredentials.createInsecure(), () => {
routeServer.start();
});
使用 helloObj 对象建立 gRPC 连接
真正应用于业务时,服务具体接口的实现及代码结构应该要更有逻辑,这里只是作为调用样例展示。
var client = new helloObj.Greeter('localhost:50051',
grpc.credentials.createInsecure());
function sayHello(callback) {
const helloReq = {
name: "you"
};
var call = client.sayHello(name, function(err, response) {
console.log('Greeting:', response.message);
});
}
静态编译
使用官方的类库 grpc-tools,编译生成 _pb.d.ts
和 _grpc_pb.d.ts
文件,前者将 protobuf 里面的 message、enum 等定义生成代码的具体实现,后者则生产 client、server 的接口及具体的类。下面的例子会列举 hello_pb.d.ts
及 hello_grpc_pb.d.ts
文件这个例子。
因为有生成 ts 声明文件,因此在静态编译时,我们也因此可以使用 ts 类型啦。
// hello_pb.d.ts 里面 HelloRequest 的定义
import * as jspb from "google-protobuf";
export class HelloRequest extends jspb.Message {
getName(): string;
setName(value: number): HelloRequest;
serializeBinary(): Uint8Array;
toObject(includeInstance?: boolean): HelloRequest.AsObject;
static toObject(includeInstance: boolean, msg: Book): HelloRequest.AsObject;
}
export namespace HelloRequest {
export type AsObject = {
name: string,
}
hello_grpc_pb.d.ts
的 GreeterClient
,第一个参数接收 address(即服务地址),生成可调用的 client 去访问 server。目前最新的版本支持 uds、dns 两种格式,如果需要支持 etcd,需要自行实现官方的 resolver 去做 etcd 地址的解析及获取。[3]
// hello_grpc_pb.d.ts 文件里面截取需要的内容
import * as hello_pb from "./hello_pb";
import * as grpc from "@grpc/grpc-js";
interface IGreeterService extends grpc.ServiceDefinition<grpc.UntypedServiceImplementation> {
sayHello: IGreeterServiceService_ISayHello;
}
export const GreeterService: IGreeterService;
export class GreeterClient extends grpc.Client {
constructor(address: string, credentials: grpc.ChannelCredentials, options?: object);
sayHello(
argument: helloworld_pb.HelloRequest,
callback: grpc.requestCallback<helloworld_pb.HelloReply>
): grpc.ClientUnaryCall;
sayHello(
argument: helloworld_pb.HelloRequest,
metadataOrOptions: grpc.Metadata | grpc.CallOptions | null,
callback: grpc.requestCallback<helloworld_pb.HelloReply>
): grpc.ClientUnaryCall;
sayHello(
argument: helloworld_pb.HelloRequest,
metadata: grpc.Metadata | null,
options: grpc.CallOptions | null,
callback: grpc.requestCallback<helloworld_pb.HelloReply>
): grpc.ClientUnaryCall;
}
使用静态文件建立服务
import {GreeterService, IGreeterService} from "./proto/hello_grpc_pb";
function sayHello(call,callback,metedata){
// do something
callback();
}
function main() {
var server = new grpc.Server();
server.addService(GreeterService,
{sayHello: sayHello});
server.bindAsync('0.0.0.0:50051', grpc.ServerCredentials.createInsecure(), () => {
server.start();
});
}
使用静态文件建立 Client 连接
import { GreeterClient } from "./proto/hello_grpc_pb";
import { HelloRequest, HelloReply } from "./proto/hello_pb";
const client = new GreeterClient("127.0.0.1:50051", grpc.credentials.createInsecure());
const helloRequest = new HelloRequest();
helloRequest.setName('hello world!')
// 完成一次调用
client.sayHello(request, (err, reply: HelloReply) => {
if (err != null) {
debug(`[sayHello] err:\nerr.message: ${err.message}\nerr.stack:\n${err.stack}`);
reject(err); return;
}
log(`[sayHello] HelloReply: ${JSON.stringify(reply.toObject())}`);
resolve(book);
});
二进制rpc协议?
这里用一张图简单解释一下,一次gRPC请求发生的事情。就以客户端发起一次sayHello请求为例。
如果是动态调用的话,在序列化HelloRequest前还有一个步骤。json对象向HelloRequest的转化。
Why use gRPC?
在版本推进的过程中,ShopeePay 的前端团队承接的一些内容越来越多,从最开始简单的微服务接口的合并转发、到技术项目以 node 服务实现、再到部分业务服务直接由 node 承担。那为什么 shopee 的前端 node 服务要使用 gRPC 呢?直接使用 http 协议的 koa、express 开源框架不香吗?
其实最开始的时候,最早有一些 node 服务是用 http 协议去实现的。但是随着基建渐渐成熟、开发环境逐渐稳定、前端寻求承担团队更多的业务职责之后,我们最终还是开始使用 gRPC。那除了上面介绍的 gRPC 二进制流协议本身的优势之外,这里当然也有一部分是为了开发环境的兼容。
协议同步
在微服务的架构中,前后端网关(grpc 微服务)和 node 微服务的通讯、后台 go 微服务和 node 微服务的相互调用是避免不了。如果使用 http 服务,就会面临协议沟通上的问题,即网关会增加特殊逻辑去访问 http 接口、go 服务及网关访问 node 的 http 服务时也无法直接发起 grpc 连接,http 服务也无法直接访问一个 gRPC 服务。
下图简单的介绍了一下区别,当 http 服务的职责比较单一,或者是作为一个单纯的”资源提供者“,那还是可以接受的,开发者需要在代码里面重新定义新的网关地址,为该服务封装新的 http 前缀地址;反之则是相对比较麻烦的。当然总可以通过一些 hack 的手法去达到目的,但无疑都会增加很多的开销,得不偿失。
而在协议统一之后,我们的 gRPC 服务就与语言无关了。通讯时即不需要做特殊逻辑的代码,又可以享受统一网关的支持、监控平台数据的采集等通用的功能支持,不需要自实现一套。
服务路由复杂化
ShopeePay 的线上环境是是部署在 Kubernetes 集群内部的。由 ingress 分配给 pod 对应的 ingress 域名,可以理解为内部域名,只能在集群内部访问。线上环境,集群内部不允许访问任何公网域名,需要申请白名单。因此我们需要访问集群内的node服务的话,需要做以下几件事情:
为集群内的 node 服务申请公网域名,通过公网域名访问。 同时也需要向运维申请对应域名在集群内部的访问白名单权限。
带来的后果就是,服务数量对应申请的域名也越来越多,接口路由的这些职责本来就应该由网关去承担,但却因此复杂化了。
同时就在前端网关中应用的实际场景来说,前端网关适配了一些额外的 http 服务,既有业务相关,也有技术侧相关的一些服务。当用户请求这些服务的接口时,会先去访问前端网关,网关再去请求服务对外的公网域名,公网域名经过 nginx 代理到 ingress 域名,然后才访问到接口。
全链路
对于从客户端发起一次请求,再到客户端接收响应,在复杂的业务场景里面整个链路是相当长的,业务网关(gRPC 服务)会将唯一的 trace-id 存放在 metadata 里面,然后在一整个链路上传递下去。metedata 可以理解为 gRPC 协议的特有的 header,http 服务从协议层就无法获取。如果链路中访问的有 http 服务,那么一整个请求链路就会出现断链,这样对我们线上查找链路的错误日志会造成比较大的麻烦。
挑战
其实抛开 gRPC 协议不谈,开发微服务和普通的node服务没什么差别。我们没有使用 protobuf.js[4],它也使用 node 实现了 gRPC 协议,同时在我看来这个 gRPC 库更灵活,可以拦截请求,完成一些比如 json 解析器等比较好用的事情,但是官方项目的 manager 认为这种拦截请求并更改的行为是相当不安全的,对比之下我们就坚持官方库。
适配后台网关
正好说到这个,在业务进展的过程里,我们前后端其实都碰到了针对网关的优化。对于网关这一主体来说,不应该也不需要存储任何 pb 文件的,鉴权的 pb 接口除外。之前介绍的时候有说过,gRPC 必须基于 gRPC 的 pb 文件通讯,不同语言编译成不同的版本的源文件。那这里前后端是分别怎么解决这个问题的呢?
后端网关发送请求时传递一个标志位和 json 数据,当 go 服务接收请求获取到该标志位时,就由服务侧将 json 转化为 go 服务需要的 pb struct 对象。从实现层看起来,就是网关传递 json,go 服务接收 json,协议没变但是没有涉及二进制的转换。
而前端服务因为底层库直接给开发者的就是 call 对象,不支持拦截请求。所以我们放弃了去更改,而是为接入后端网关时做了一层适配,我们采用了一个统一的 protobuf message,我们称之 CommonMessage,发起请求和获取请求都由 CommonMessage 去序列化、反序列化。因为 CommonMessage 的 messgae 只有一个二进制,带来的问题是,前端开发者调试的时候需要费一下心转化哈哈。
CommonMessage 的pb定义
// The request message containing the common content.
message CommonMessage {
byte content = 1;
}
grpc vs grpc-js
当你打开 grpc-node[5] 这个地址时,明晃晃的告诉大家,node 的 grpc 官方库有两个版本。一个是纯 c 的 grpc,一个是纯 js 的 grpc-js。在我们决定使用并开发 grpc 微服务时,当时的版本是 grpc,因此我们也经历的一次版本升级。这里不会详细的介绍两者的区别,因为没有比这写的更详细的了。https://github.com/grpc/grpc-node/blob/master/PACKAGE-COMPARISON.md
当然,推动我们更新大版本的动力是,grpc 在使用 http2 协议打包传送的数据越大,性能就越差。而 grpc-js 则不会有这个性能损耗。之前面临的一个问题,在我们的测试环境只传递 300KB 的数据为返回时,grpc 消耗 1000~2000ms,grpc-js 则维持在了 20~30ms。其余的迁移可以参考https://github.com/grpc/grpc-node/tree/master/packages/grpc-js
callback(回包数据大小) | grpc-js | grpc |
---|---|---|
5KB | 1~10ms | 1~10ms |
500KB | 1~10ms | 1000ms~2000ms |
callback 参看两个版本库的源码,可以知道它主要做了反序列化、打包的一些事情。
// grpc 源码
/**
* Send a response to a unary or client streaming call.
* @private
* @param {grpc.Call} call The call to respond on
* @param {*} value The value to respond with
* @param {grpc~serialize} serialize Serialization function for the
* response
* @param {grpc.Metadata=} metadata Optional trailing metadata to send with
* status
* @param {number=} [flags=0] Flags for modifying how the message is sent.
*/
function sendUnaryResponse(call, value, serialize, metadata, flags) {
// a
var end_batch = {};
var statusMetadata = new Metadata();
var status = {
code: constants.status.OK,
details: 'OK'
};
if (metadata) {
statusMetadata = metadata;
}
var message;
try {
message = serialize(value);
} catch (e) {
common.log(constants.logVerbosity.ERROR, e);
e.code = constants.status.INTERNAL;
handleError(call, e);
return;
}
status.metadata = statusMetadata._getCoreRepresentation();
if (!call.metadataSent) {
end_batch[grpc.opType.SEND_INITIAL_METADATA] =
(new Metadata())._getCoreRepresentation();
call.metadataSent = true;
}
message.grpcWriteFlags = flags;
end_batch[grpc.opType.SEND_MESSAGE] = message;
end_batch[grpc.opType.SEND_STATUS_FROM_SERVER] = status;
// 打点
// b
call.startBatch(end_batch, function (){});
// 打点
// c
}
在 grpc 库的源码里,a-b:组装返回的 messge 和 metadata,b-c:使用 http2 发起请求。a-b 的序列化数据、打包数据耗时打点约为 1~2ms,主要的耗时都在 b-c 这一段代码。startBatch 方法是用 c 实现的,无从优化,因此只能选择升级为 grpc-js。截止到写这篇文章时,grpc 库已经处于 deprecated 状态了。
拥抱?.开源社区
gRPC node 版本的开源生态感觉起来不是特别好。在 GitHub 上看一些项目 issue 查找问题的过程中,我时不时碰到这样的回答 “放弃node,转入go语言的怀抱”,因此常常不得不自己上手解决一些问题,比如为 grpc 协议 fork一个 node-grpc-interceptors[6] (ctx 增加 response、增加 errorcallback)、压测尝试 grpc 是否支持 worker(多进程)等等。这属实让人感受到一些沮丧,荜露蓝蒌的感受。
在 20 年 4 月份 @grpc/grpc-js 1.0.0[7]正式发布之后,官方又开始迅速迭代。这反而引发了上文所说的 grpc-js 新旧版本的 Resolver 类不兼容,导致之前我们一位大神自定义的 etcd 的 Resolver 在新版本报错的情况;proto-tools 的版本与 grpc-js 的版本有相对比较严格的定义;官方的实现的 plugin 插件与私人实现的 plugin 重名冲突,导致无法生成必要的 ts 文件等。为了解决这些问题,我们采取了一些不太合适的解决方式(固定版本等),但我相信随着版本的逐渐稳定,这些问题也就会不再出现。
新的 Protobuff 仓库
Protobuff 是在 ShopeePay 做的一个管理前端 protobuff 文件、生成的 static 文件以及为网关类服务提供 proto client 对象的公共项目。
在 ShopeePay 的前端服务越来越来多的场景下,我们也不得不面对和业务服务一样的问题,越来越多的服务对应越来越多的 protobuff 文件及配置、node 服务的 gRPC 请求调用这样的公共模块(发起 gRPC 调用需要 proto 文件,一个服务的 proto 在多个服务的代码里面维护)越来越强烈剥离出去的需求。
因此我们就做了这样的一个公共仓库,由于 grpc-js 不暴露任何接口拦截的可能,于是上文说的 json 解析器(json to protobuff message 对象)的应用也就无从说起。那怎么做呢?最终我们采取了给前端网关提供一个存储在内存的 client 对象列表的方案(动态编译),供网关服务调用接口使用。同时我们也手动实现 etcd 的 resolver,注册,解决了我们动态获取 Kubernetes 部署的 pod的内网 ip 地址的需求。于是就完美的满足了当前的环境开发、使用的需求。
主要的受益在以下几个方面:
前端网关是存储 protobuff 文件的,但是在 node_modules 里面存放,所以服务、网关和 pb 文件是解耦的。 在网关类应用时,静态生成的类只有通过属性的 set 方法才能设置,因此不被采纳。所以网关类应用获益于这个项目,实现了 pb 配置和网关代码的耦合。 发布上线时,从网关一份 pb、服务本身一份 pb、n 个调用方的 n 份 pb 的改动转变为 Protobuff 仓库的一份 pb。 ...
总结
兜兜转转写了很多,其实每一段内容都可以更加深入的展开来讲讲。gRPC 协议的 ‘hello world’,一些为了推动前端微服务化在 ShopeePay 做的尝试,在需求迭代时碰到的一些问题以及去解决的过程等等。gRPC 很好用也很不好用,很好用是指接入很方便、上手很快、性能很稳定、日常需求 cover 很简单,很不好用是指深入碰到问题得自己解决,目前的版本还在快速迭代等等。
即是总结语,也是展望语。
以上就是我在 ShopeePay 任职期间接触 gRPC 相关的全部日常。感谢前老大 BrandonXiang、HeyLi 的在 Shopee 任职时提供的各方面的帮助,同时也很幸运在那个优秀的前端团队内拧了许久的螺丝钉(~_~)。展望即是,在新的团队也可以摸摸索索的前行,把自己不熟悉的领域变得熟悉起来,继续拧好自己手里的那颗不太一样的新螺丝钉。
关注我们
我们将为你带来最前沿的前端资讯。
Motivation & Design Principles: https://grpc.io/blog/principles/
[2]packages/proto-loader
: https://github.com/grpc/grpc-node/tree/master/packages/proto-loader
https://github.com/grpc/grpc/blob/master/doc/naming.md
[4]protobuf.js: https://github.com/protobufjs/protobuf.js
[5]grpc-node: https://github.com/grpc/grpc-node
[6]node-grpc-interceptors: https://github.com/NanhuaZhang/node-grpc-interceptors
[7]@grpc/grpc-js 1.0.0: https://github.com/grpc/grpc-node/releases/tag/%40grpc%2Fgrpc-js%401.0.0