Open SunshowerC opened 4 years ago
本文面向的前端小伙伴: 有前端 BFF 开发经验或对此有兴趣的 对 gRPC 和 protobuf 协议有一定理解的
本文面向的前端小伙伴:
首先简单谈一下 BFF (Back-end for Front-end), BFF的概念大家可能都听滥了,这里就不复制粘贴一些陈词滥调了,不了解的可以推荐看这篇文章了解下。
那么简单来说,BFF 就是做一个进行接口聚合裁剪的 http server。
BFF
随着后端 go 语言的流行,很多大公司的都转向了用 go 开发微服务。而总所周知,go 是 谷歌家的,那么自然,同样是谷歌家开发的 rpc 框架 gRPC 就被 go 语言广泛用了起来。
如果前端 BFF 层需要对接 go 后端提供的 gRPC + protobuf 接口,而不是前端所熟悉的 RESTful API,那么咱们就需要使用 grpc-node 来发起 gRPC 的接口调用了。
本文就是来和大家一起理解下 grpc-node 中的 client interceptor(拦截器) 到底该怎么用?
grpc 拦截器和我们所知道的 axios 拦截器类似,都是在请求发出前,或者请求响应前,在请求的各个阶段进行我们的一些处理。
例如:给每个请求加上 token 参数,给每个请求响应都校验下 errMsg 字段是否有值。
这些统一的逻辑,每个请求都写一遍就太扯了,一般我们都会在拦截器里统一处理这些逻辑。
在讲 grpc-node 拦截器之前,我们先假定一个 pb 协议文件,方便后面大家理解案例。
grpc-node
pb
下面所有的案例都以这个简单的 pb 协议为基准:
package "hello" service HelloService { rpc SayHello(HelloReq) returns (HelloResp) {} } message HelloReq { string name = 1; } message HelloResp { string msg = 1; }
那么最简单的一个 client 拦截器怎么写呢?
// 没有干任何事情,透传所有操作的拦截器 const interceptor = (options, nextCall: Function) => { return new InterceptingCall(nextCall(options)); }
没错,根据规范:
express
next
options
options.method_descriptor.path
/<package名>.<service名>/<rpc名>
/hello.HelloService/SayHello
options.method_descriptor.requestSerialize
options.method_descriptor.responseDeserialize
options.method_descriptor.requestStream
options.method_descriptor.responseStream
一般情况下,我们对 options 不会做任何修改,因为如果后面还有其他拦截器,这就会影响到下游的拦截器的 options 值了。
以上的 interceptor demo 只是简单说下 拦截器的规范,demo 没有干任何实质性的事情。
那么如果我们要在请求出站前做一些骚操作时,我们应该怎么做呢?
这就要用到 Requester 了
Requester
在 InterceptingCall 的第二个参数中,我们可以传入一个 request 对象,来处理请求发出前的操作。
InterceptingCall
const interceptor = (options, nextCall: Function) => { const requester = { start(){}, sendMessage(){}, halfClose(){}, cancel(){}, } return new InterceptingCall(nextCall(options), requester); }
requester 其实就是个俱备指定参数的对象, 结构如下:
// ts 定义如下 interface Requester { start?: (metadata: Metadata, listener: Listener, next: Function) => void; sendMessage?: (message: any, next: Function) => void; halfClose?: (next: Function) => void; cancel?: (next: Function) => void; }
在启动出站调用之前调用的拦截方法。
start?: (metadata: Metadata, listener: Listener, next: Function) => void;
参数
const requester = { start(metadata, listener, next) { next(metadata, listener) } }
在每个出站消息之前调用的拦截方法。
sendMessage?: (message: any, next: Function) => void;
const requester = { sendMessage(message, next) { // 对于当前 pb 协议 // message === { name: 'xxxx' } next(message) } }
当出站流关闭时(在消息发送后)调用的拦截方法。
halfClose?: (next: Function) => void;
从客户端取消请求时调用的拦截方法。比较少用到
cancel?: (next: Function) => void;
既然出站拦截操作,自然有入站拦截操作。
入站拦截方法在前面提到的 Requester.start 方法中的 listener 进行定义
Requester.start
interface Listener { onReceiveMetadata?: (metadata: Metadata, next: Function) => void; onReceiveMessage?: (message: any, next: Function) => void; onReceiveStatus?: (status: StatusObject, next: Function) => void; }
接收响应元数据时触发的入站拦截方法。
const requester = { start(metadata, listener) { const newListener = { onReceiveMetadata(metadata, next) { next(metadata) } } } }
接收到响应消息时触发的入站拦截方法。
const newListener = { onReceiveMessage(message, next) { // 对于当前 pb 协议 // message === {msg: 'hello xxx'} next(message) } }
接收到状态时触发的入站拦截方法
const newListener = { onReceiveStatus(status, next) { // 成功调用时, status 为 {code:0, details:"OK"} next(status) } }
那么上面描述了那么多个拦截器入站出站的拦截相关方法,那么具体他们的执行顺序是怎么样的呢,下面简单说下, 单个拦截器:
那么问题来了,如果我们配置了多个拦截器,假设配置顺序是 [interceptorA, interceptorB, interceptorC],那么拦截器的执行顺序会是:
[interceptorA, interceptorB, interceptorC]
interceptorA 出站 -> interceptorB 出站 -> interceptorC 出站 -> grpc.Call -> interceptorC 入站 -> interceptorB 入站 -> interceptorA 入站
可以看到,执行顺序是类似栈,先进后出,后进先出。
那么看这流程图,大家可能会下意识觉得多个拦截器的执行顺序会是:
拦截器A: 1. start 2. sendMessage 3. halfClost 拦截器B: 4. start 5. sendMessage 6. halfClost 拦截器C: ......
但是实际上并非如此。
前面提到,每个拦截器都会有一个 next 方法,next 方法的执行,其实就是执行下一个拦截器的同一个阶段的拦截方法,例如:
// 拦截器A start(metadata, listener, next) { // 此处执行的next 其实是执行拦截器 B // 的 start 方法 next(metadata, listener) } // 拦截器 B start(metadata, listener, next) { // 此处的 metadata, listener 就是上一个拦截器传递的值 next(metadata, listener) }
所以,最后多个拦截器的具体方法执行顺序会是:
出站阶段: start(拦截器A) -> start(拦截器B) -> sendMessage(拦截器A) -> sendMessage(拦截器B) -> halfClost(拦截器A) -> halfClost(拦截器B) -> grpc.Call -> 入站阶段: onReceiveMetadata(拦截器B) -> onReceiveMetadata(拦截器A) -> onReceiveMessage(拦截器B) -> onReceiveMessage(拦截器A) -> onReceiveStatus(拦截器B) -> onReceiveStatus(拦截器A)
看了那么多定义,估计人都懵了,大家可能对拦截器的作用没有太大的概念,下面看下 拦截器的实际应用场景。
可以在请求与响应拦截器中,记录日志
const logInterceptor = (options, nextCall) => { return new grpc.InterceptingCall(nextCall(options), { start(metadata, listener, next) { next(metadata, { onReceiveMessage(resp, next) { logger.info(`请求:${options.method_descriptor.path} 响应体:${JSON.stringify(resp)}`) next(resp); } }); }, sendMessage(message, next) { logger.info(`发起请求:${options.method_descriptor.path};请求参数:${JSON.stringify(message)}`) next(message); } }); }; const client = new hello_proto.HelloService('localhost:50051', grpc.credentials.createInsecure(), { interceptors: [logInterceptor] });
微服务场景最大的好处是业务分割,但是在 BFF 层,如果微服务接口还未完成,就很容易被微服务那边阻塞,就类似前端被后端接口阻塞一样。
那么,我们就可以用同样的思路,来在拦截器层面实现 grpc 接口的数据 mock
const interceptor = (options, nextCall) => { let savedListener // 通过环境变量,或其他判断逻辑,判断当前是否需要 mock 接口 const isMockEnv = true return new grpc.InterceptingCall(nextCall(options), { start: function (metadata, listener, next) { // 保存 listener, 以便后续调用响应入站的 method savedListener = listener // 如果是 mock 环境,就不需要 调用 next 方法,避免请求出站到 server if(!isMockEnv) { next(metadata, listener); } }, sendMessage(message, next) { if(isMockEnv) { // 根据需要, 构造自己的 mock 数据 const mockData = { hello: 'hello interceptor' } // 调用前面保存了的 listener 响应方法,onReceiveMessage, onReceiveStatus必须都调用 savedListener.onReceiveMetadata(new grpc.Metadata()); savedListener.onReceiveMessage(mockData); savedListener.onReceiveStatus({code: grpc.status.OK}); } else { next(message); } } }); };
原理很简单,其实就是让请求不出站,直接在出站准备阶段,调用入站响应的方法。
有时候可能 server 端异常,导致接口异常,可以在拦截器响应入站阶段,判断状态,避免应用异常。
const fallbackInterceptor = (options, nextCall) => { let savedMessage let savedMessageNext return new grpc.InterceptingCall(nextCall(options), { start: function (metadata, listener, next) { next(metadata, { onReceiveMessage(message, next) { // 暂且保存 message 和 next,等到 接口响应状态 确定后,再响应 savedMessage = message; savedMessageNext = next; }, onReceiveStatus(status, next) { if (status.code !== grpc.status.OK) { // 如果 接口响应异常,响应预设数据,避免 xxx undefined savedMessageNext({ errCode: status.code, errMsg: status.details, result: [] }); // 设定当前接口为正常 next({ code: grpc.status.OK, details: 'OK' }); } else { savedMessageNext(savedMessage); next(status); } } }); } }); };
原理也不复杂,大概就是捕获异常状态,响应正常状态以及预设数据。
可以看到, grpc 的拦截器概念并没有什么特殊或者难以理解的地方,和我们常用的拦截器,例如 axios 拦截器理念基本一致,都是提供方法来对请求阶段与响应阶段做一些自定义的统一逻辑处理。
grpc
axios
本文主要是对 grpc-node 的拦截器做简单的解读,希望本文能给正在用 grpc-node 做 BFF 层的同学一些帮助。
前言
首先简单谈一下 BFF (Back-end for Front-end), BFF的概念大家可能都听滥了,这里就不复制粘贴一些陈词滥调了,不了解的可以推荐看这篇文章了解下。
那么简单来说,
BFF
就是做一个进行接口聚合裁剪的 http server。随着后端 go 语言的流行,很多大公司的都转向了用 go 开发微服务。而总所周知,go 是 谷歌家的,那么自然,同样是谷歌家开发的 rpc 框架 gRPC 就被 go 语言广泛用了起来。
如果前端 BFF 层需要对接 go 后端提供的 gRPC + protobuf 接口,而不是前端所熟悉的 RESTful API,那么咱们就需要使用 grpc-node 来发起 gRPC 的接口调用了。
本文就是来和大家一起理解下 grpc-node 中的 client interceptor(拦截器) 到底该怎么用?
grpc 拦截器是什么?有啥用?
grpc 拦截器和我们所知道的 axios 拦截器类似,都是在请求发出前,或者请求响应前,在请求的各个阶段进行我们的一些处理。
例如:给每个请求加上 token 参数,给每个请求响应都校验下 errMsg 字段是否有值。
这些统一的逻辑,每个请求都写一遍就太扯了,一般我们都会在拦截器里统一处理这些逻辑。
grpc-node client interceptor
在讲
grpc-node
拦截器之前,我们先假定一个pb
协议文件,方便后面大家理解案例。下面所有的案例都以这个简单的 pb 协议为基准:
Client Interceptor 的创建
那么最简单的一个 client 拦截器怎么写呢?
没错,根据规范:
express
中间件的next
options
参数,描述了当前 gRPC 请求的一些属性options.method_descriptor.path
: 等于/<package名>.<service名>/<rpc名>
例如,这里就是/hello.HelloService/SayHello
options.method_descriptor.requestSerialize
: 序列化请求参数对象成为 buffer 的函数,同时会对请求参数中非必要数据裁剪掉options.method_descriptor.responseDeserialize
: 对响应 buffer 数据反序列化成 json 对象options.method_descriptor.requestStream
: boolean, 请求是不是 流式传输options.method_descriptor.responseStream
: boolean, 响应是不是 流式传输一般情况下,我们对 options 不会做任何修改,因为如果后面还有其他拦截器,这就会影响到下游的拦截器的 options 值了。
以上的 interceptor demo 只是简单说下 拦截器的规范,demo 没有干任何实质性的事情。
那么如果我们要在请求出站前做一些骚操作时,我们应该怎么做呢?
这就要用到
Requester
了Requester (出站前拦截处理)
在
InterceptingCall
的第二个参数中,我们可以传入一个 request 对象,来处理请求发出前的操作。requester 其实就是个俱备指定参数的对象, 结构如下:
Requester.start
在启动出站调用之前调用的拦截方法。
参数
Requester.sendMessage
在每个出站消息之前调用的拦截方法。
Requester.halfClose
当出站流关闭时(在消息发送后)调用的拦截方法。
Requester.cancel
从客户端取消请求时调用的拦截方法。比较少用到
Listener (入站前拦截处理)
既然出站拦截操作,自然有入站拦截操作。
入站拦截方法在前面提到的
Requester.start
方法中的 listener 进行定义Listener.onReceiveMetadata
接收响应元数据时触发的入站拦截方法。
Listener.onReceiveMessage
接收到响应消息时触发的入站拦截方法。
Listener.onReceiveStatus
接收到状态时触发的入站拦截方法
grpc interceptor 执行顺序
那么上面描述了那么多个拦截器入站出站的拦截相关方法,那么具体他们的执行顺序是怎么样的呢,下面简单说下, 单个拦截器:
多拦截器执行顺序
那么问题来了,如果我们配置了多个拦截器,假设配置顺序是
[interceptorA, interceptorB, interceptorC]
,那么拦截器的执行顺序会是:可以看到,执行顺序是类似栈,先进后出,后进先出。
那么看这流程图,大家可能会下意识觉得多个拦截器的执行顺序会是:
但是实际上并非如此。
前面提到,每个拦截器都会有一个
next
方法,next
方法的执行,其实就是执行下一个拦截器的同一个阶段的拦截方法,例如:所以,最后多个拦截器的具体方法执行顺序会是:
应用场景
看了那么多定义,估计人都懵了,大家可能对拦截器的作用没有太大的概念,下面看下 拦截器的实际应用场景。
请求与响应的 log
可以在请求与响应拦截器中,记录日志
mock 数据
微服务场景最大的好处是业务分割,但是在 BFF 层,如果微服务接口还未完成,就很容易被微服务那边阻塞,就类似前端被后端接口阻塞一样。
那么,我们就可以用同样的思路,来在拦截器层面实现 grpc 接口的数据 mock
原理很简单,其实就是让请求不出站,直接在出站准备阶段,调用入站响应的方法。
异常请求 fallback
有时候可能 server 端异常,导致接口异常,可以在拦截器响应入站阶段,判断状态,避免应用异常。
原理也不复杂,大概就是捕获异常状态,响应正常状态以及预设数据。
结语
可以看到,
grpc
的拦截器概念并没有什么特殊或者难以理解的地方,和我们常用的拦截器,例如axios
拦截器理念基本一致,都是提供方法来对请求阶段与响应阶段做一些自定义的统一逻辑处理。本文主要是对
grpc-node
的拦截器做简单的解读,希望本文能给正在用grpc-node
做 BFF 层的同学一些帮助。