走进前端 BFF 之 grpc-node 拦截器操作指南

2,346

前言

本文面向的前端小伙伴:

  • 有前端 BFF 开发经验或对此有兴趣的
  • 对 gRPC 和 protobuf 协议有一定理解的

首先简单谈一下 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 协议为基准:

package "hello"

service HelloService {
    rpc SayHello(HelloReq) returns (HelloResp) {}
}

message HelloReq {
    string name = 1;
}

message HelloResp {
    string msg = 1;
}

Client Interceptor 的创建

那么最简单的一个 client 拦截器怎么写呢?

// 没有干任何事情,透传所有操作的拦截器
const interceptor = (options, nextCall: Function) => {
  return new InterceptingCall(nextCall(options));
}

没错,根据规范:

  • 每个 client interceptor 必须是个函数,每次请求都会执行一遍来创建一个新的拦截器实例
  • 函数需要 return 一个 InterceptingCall 实例
  • InterceptingCall 实例可以传递一个 nextCall() 参数,来继续调用下一个拦截器,类似的 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 对象,来处理请求发出前的操作。

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;
}

Requester.start

在启动出站调用之前调用的拦截方法。

start?: (metadata: Metadata, listener: Listener, next: Function) => void;

参数

  • metadata: 请求的 metadata,可以对 metadata 进行增添删除操作
  • listener: 监听器,用于监听入站操作,下面会讲到
  • next:执行下一个拦截器的 requester.start, 类似 express 的 next。 此处的 next 可以传递两个参数:metadata 和 listener。
const requester = {
    start(metadata, listener, next) {
        next(metadata, listener)
    }
}

Requester.sendMessage

在每个出站消息之前调用的拦截方法。

sendMessage?: (message: any, next: Function) => void;
  • message: protobuf 的请求体
  • next:拦截器调用链,此处 next 可传递 message 参数
const requester = {
    sendMessage(message, next) {
        // 对于当前 pb 协议
        // message === { name: 'xxxx' }
        next(message)
    }
}

Requester.halfClose

当出站流关闭时(在消息发送后)调用的拦截方法。

halfClose?: (next: Function) => void;
  • next: 链式调用,无需传参

Requester.cancel

从客户端取消请求时调用的拦截方法。比较少用到

cancel?: (next: Function) => void;

Listener (入站前拦截处理)

既然出站拦截操作,自然有入站拦截操作。

入站拦截方法在前面提到的 Requester.start 方法中的 listener 进行定义

interface Listener {
  onReceiveMetadata?: (metadata: Metadata, next: Function) => void;
  onReceiveMessage?: (message: any, next: Function) => void;
  onReceiveStatus?: (status: StatusObject, next: Function) => void;
}

Listener.onReceiveMetadata

接收响应元数据时触发的入站拦截方法。

const requester = {
    start(metadata, listener) {
        const newListener = {
            onReceiveMetadata(metadata, next) {
                next(metadata)
            }
        }
    }
}

Listener.onReceiveMessage

接收到响应消息时触发的入站拦截方法。

const newListener = {
    onReceiveMessage(message, next) {
        // 对于当前 pb 协议
        // message === {msg: 'hello xxx'}
        next(message)
    }
}

Listener.onReceiveStatus

接收到状态时触发的入站拦截方法

const newListener = {
    onReceiveStatus(status, next) {
        // 成功调用时, status 为 {code:0, details:"OK"}
        next(status)
    }
}

grpc interceptor 执行顺序

那么上面描述了那么多个拦截器入站出站的拦截相关方法,那么具体他们的执行顺序是怎么样的呢,下面简单说下, 单个拦截器:

  1. 请求先出站, 执行顺序如下:
    1. start
    2. sendMessage
    3. halfClost
  2. 请求后入站,执行顺序
    1. onReceiveMetadata
    2. onReceiveMessage
    3. onReceiveStatus

多拦截器执行顺序

那么问题来了,如果我们配置了多个拦截器,假设配置顺序是 [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) 

应用场景

看了那么多定义,估计人都懵了,大家可能对拦截器的作用没有太大的概念,下面看下 拦截器的实际应用场景。

请求与响应的 log

可以在请求与响应拦截器中,记录日志


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]
  });

mock 数据

微服务场景最大的好处是业务分割,但是在 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); 
      }
    }
  }); 
};

原理很简单,其实就是让请求不出站,直接在出站准备阶段,调用入站响应的方法。

异常请求 fallback

有时候可能 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-node 的拦截器做简单的解读,希望本文能给正在用 grpc-node 做 BFF 层的同学一些帮助。




本文首发于 github 博客
如文章对你有帮助,你的 star 是对我最大的支持
其他文章:


插播信息:
深圳 Shopee 长期内推
岗位:前端,后端(要转go),产品,UI,测试,安卓,IOS,运维 全都要,招聘详情JD看拉勾。
薪酬福利:20K-50K😳,7点下班😏,免费水果😍,免费晚餐😊,15天年假👏,14天带薪病假。
简历发邮箱:chenweiyu6909@gmail.com
或者加我微信:cwy13920