在生产环境使用 gRPC

sorcererxw /

本文将总结部分工作当中在后端引入 gRPC 过程当中所做的工作,将主要基于 Go 来展示 gRPC 的实践。

兼容 HTTP/JSON 接口

由于是在既有的系统上做迁移,必然需要考虑到两点:

  • 原有的服务如何调用 gRPC 接口
  • 新服务如何调用 HTTP 接口

在我们已经使用 ProtoBuf 定义了服务每个 RPC 和 HTTP 映射了时候,理想的情况下是将 ProtoBuf 作为 Single source of truth,直接生成 gRPC client + HTTP client,最好他们还能使用相同的抽象类型,屏蔽底层的传输协议。

Envoy or gRPC-Gateway?

为了使 gRPC 兼容原有的 HTTP 接口,业界已经有了不少方案:https://github.com/envoyproxy/envoyhttps://github.com/grpc-ecosystem/grpc-gateway......不过总体上都是通过一个前置网关实现转换协议。

envoy 需要在容器内单独启动一个 envoy sidecar 网关进程,通过其 gRPC-JSON transcoder 实现协议转换,是理想当中美好的方案:将协议转换工作下沉到基础设施,所有语言都能兼容,服务本身专注 gRPC 的实现,可以保证代码简洁。

而 gRPC-Gateway 方案基于 ProtoBuf 定义生成一个 HTTP 服务,它会接受 HTTP 请求并转而调用 gRPC 服务,它可以嵌入在现有的服务代码当中,跟随服务一起启动。

相比 gRPC-Gateway,envoy 方案需要服务多引入一个外部依赖,就目前来讲,在初期迁移的过程中,选用 gRPC-Gateway 可以在一个进程内控制所有逻辑,便于调试,更加简单务实。

定义 ProtoBuf RPC 接口映射

gRPC 本身已经提供了一套 JSON 类型转换协议,另一边 Google API 还提供了 HTTP 接口到 gRPC 接口映射的规范。gRPC-Gateway 就是在这一套规范的基础上实现了协议转换。它的表现力非常强,几乎完全满足常规的 HTTP 接口的所有需求:

  • 支持映射 query/path parameter/body 参数到 Request Message
  • 支持所有 HTTP Method
  • 支持多个 HTTP 接口映射同一个 RPC
service UserService {
	rpc GetUser(GetUserRequest) returns (GetUserResponse) {
		option(google.api.http) = {
			get: "/internals/users/{id}"
			additional_bindings: {
        post: "/internals/user/getById"
        body: "*"
			}
		}	
	};
}

更多的可以参考 Google HttpRule 的注释文档 以及 gRPC 文档,其对协议与用法做了非常详细的说明。

我们只需要根据 ProtoBuf 定义,通过 protoc-gen-grpc-gateway 就能生成相应的 RPC 映射逻辑代码了。

挂载 gRPC-Gateway

gRPC-Gateway 提供了多种挂载方式:

  • RegisterHandlerServer:绑定一个 Server implement,由 gRPC-Gateway 转换 HTTP 请求直接调用对应的 Server,不支持 stream 请求。
  • RegisterHandlerFromEndpoint:绑定一个目标地址,由 gRPC-Gateway 去连接那个地址实现反向代理。
  • RegisterHandler:绑定一个 gRPC Server Connection,由 gRPC-Gateway 反向代理。
  • RegisterHandlerClient:绑定一个 gRPC Client,由 gRPC-Gateway 将 HTTP 请求转换为对此 Client 的调用。

目前 gRPC-Gateway 更加推荐使用反向代理的方式,其能够最大程度发挥 gRPC 的能力。

那我们可以在一个进程内分别启动一个 gRPC Server 和一个 HTTP Server,让它们分别监听两个端口,让 HTTP Server 根据规则拦截请求经由 gRPC-Gateway 发送至 gRPC Server:

// HTTP
gw := runtime.NewServeMux() // 新建 grpc gateway ServerMux
service.RegisterHandlerFromEndpoint(context.Background(), gw, ":9090", []grpc.DialOption{grpc.WithInsecure()}) // 注册目标 gRPC 服务

e := echo.New()
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
	return func(c echo.Context) error {
		// 使用 echo 完成 HTTP 路由
		// 在最外层直接拦截符合规则的请求,避免经过任何 HTTP Middleware
		if strings.HasPrefix(c.Path(), "/internals/") {
			gw.ServeHTTP(c.Response(), c.Request())
			return nil
		}
		return next(c)
	}
})
e.Use(MiddlewareA)
e.Use(MiddlewareB)
e.GET("/v1/user", handler)
// ...
go e.Start(":3000")

// gRPC

l, _ := net.Listen("tcp", ":9090")
srv := grpc.NewServer()
// ...
go srv.Serve(l)
⚠️
由于增加了一个端口,使用 K8S 服务发现的时候,千万不要忘了在 K8S Service 声明当中配置 grpc 的端口映射。

监听同一个端口?

社区里面还有另外一种流行的方式,即使用 https://github.com/soheilhy/cmux 根据 HTTP 类型 + Content-Type 区分目标地址,如果请求为 HTTP/2 且 Content-Type 为 application/grpc 则直接将请求转发至 gRPC Server。这种方案相当于在服务进程内再加上一道网关,这样一来可以实现 gRPC 和 HTTP 服务监听同一个端口,一定程度上降低使用者的认知成本(啥都不用管,就往这个端口上连)。

不过实践下来发现,这种方案虽然看起来美好,实际上带来的收益非常有限。相反地,存在一些缺点:

  • 进程退出的时候,需要正确协调 cmux、grpc server、http server 的退出顺序,否则会造成死锁。
  • 无法直接根据 HTTP Path 分流,依然需要通过另外的 HTTP Router 进一步分流。

为前端提供接口

更进一步,除了提供内部调用,我们还可以使用 gRPC 来为前端提供接口。就目前来说我们绝大多数接口都可以直接使用 gRPC 来定义,通过网关将客户端的 HTTP 请求转换为 gRPC 调用。这样带来的好处非常明显:

  • 强类型输入输出
  • 通过 ProtoBuf 直接生成 OpenAPI 文档
  • 服务进程只需要关心 gRPC 服务,更加简洁
  • 通过网关转换协议,不对客户端造成影响

不过一些特殊接口还是需要单独处理,比如渲染图片、网页这类不输出 JSON 的接口。

一些陷阱

在将 HTTP 接口映射到 gRPC 的时候,会因为原来不太好的实践,导致在转换数据结构的存在一些麻烦:

  • 多态类型

    不少既有接口使用了 polymorphic type ,形如:

    {
    	"type": "POST" | "USER" | "COMMENT",
    	"data": Post | User | Comment
    }

    这样的类型无法使用 ProtoBuf 来定义,并不是一个通用的设计,更好的设计应该是:

    {
    	"type": "POST" | "USER" | "COMMENT",
    	"post": Post,
    	"user": User,
    	"comment": Comment,
    }

    但是如果尝试使用 gRPC 去改造这类的接口,为了保证兼容,只能在返回值上放弃强类型约束,使用 google.protobuf.Value 来定义对应 data 字段,并将此字段标记为废弃。

  • 空值处理

    由于 ProtoBuf 在序列化的时候会去掉空值,比如空的 Map、Array,相应的 transcoded JSON 自然也不会有相应字段(undefined)。当上游服务拿到数据的时候,直接读写对应字段,可能引发空指针。所以访问 RPC 返回值需要做到空安全,而不应该一厢情愿地认为某一个字段必不为空。

    不过 grpc-gateway 在序列化 JSON 的时候,可以自定义 codec,默认情况下,它使用了 protojson,并为其开启了 EmitUnpopulated ,即保留所有零值的字段而不使用 undefined。

    defaultMarshaler = &HTTPBodyMarshaler{
    		Marshaler: &JSONPb{
    			MarshalOptions: protojson.MarshalOptions{
    				EmitUnpopulated: true,
    			},
    			UnmarshalOptions: protojson.UnmarshalOptions{
    				DiscardUnknown: true,
    			},
    		},
    	}

    在绝大多数情况下,除了让每一个 JSON Response 都冗余大量数据,这样是没有太大问题的,避免了上游直接访问触发空指针。但是还是需要注意上游在调用的时候,是否会需要明确区分零值和 null 两种状态。如果上游需要根据 field == null 来执行相应的逻辑,我们就需要引入 google.protobuf 的各种装箱类型来表示额外的 null 状态。

  • 类型转换

    不少服务内没有明确区分数据模型(PO)、数据传输模型(DTO)— 程序会直接将数据库对象反序列化成进程内的对象,最后直接暴露到外部。

    response.Write(GetDataFromDB()) // 一个极端的例子
    

    现在 gRPC 生成的代码代码当中,明确地单独定义了 DTO,需要代码显式地转换类型。

    如果使用如 Go、Java,它们的类型系统不支持 structural subtyping,这也意味着需要编写不少代码来实现类型转换。

    当然,使用如 TypeScript 这样拥有更加灵活类型系统的语言可以处理这个问题,但这也不意味将数据库模型不经转换直接传到外部是好的主意,这会大大提高系统间的耦合。

HTTP/JSON Client 改造

之前的文章里面提过,在刚刚引入 Go 的时候,基于 ProtoBuf 实现过一个基础的 HTTP Client 生成器。由于 ProtoBuf 强大的表现力和代码生成器良好的设计,我们使用 ProtoBuf 在其上面定义了大量内部接口、甚至是很多外部服务的接口,通过其生成 HTTP Client 为我们提供了一个通用简单的 RPC Client,大大提高了研发效率。

现在,由于这个 HTTP Client 已经被大量使用,在逐步迁移 gRPC 的过程中,希望能够尽可能减少不兼容,让新旧版本行为保持一致。

这个 Client interface 设计在一开始就考虑到了与 gRPC 的兼容性,现在我们只需要作细微的调整,就能平滑地替换掉各个服务的 RPC Client:

// 原来版本
type UserServiceClient interface {
	GetUser(ctx context.Context, req *GetUserRequest, ...option.Option) (*GetUserResponse, error)
}

// grpc 生成的版本
type UserServiceClient interface {
	GetUser(ctx context.Context, req *GetUserRequest, ...grpc.CallOption) (*GetUserResponse, error)
}

上面的 option.Option 是我们自定义的调用选项,而 grpc.CallOption 是 gRPC 要求的调用选项,两者目前在类型是无法互相兼容的,只需要改造 option.Option 让其实现两套 interface,就能平滑地兼容新旧两个版本。

userservice.GetUser(ctx, &userservice.GetUserRequest{}, option.WithRetry(2))

客户端容错

gRPC 是一个纯粹的 RPC 工具,本身并没有内置降级熔断、降级等方案,Google 给出的方案就是通过 Service Mesh 将流量管控服务治理能力下沉到基础设施。容器内的 envoy sidecar 支持熔断,可以通过错误注入的方式,让 gRPC client 直接抛出错误。虽然通过这种方式,可以在被调用方发生异常的时候保护被调用方服务,但是我们依然应当在调用方进程内实现相应的容错处理,不可放任错误扩散,造成整个服务链路的雪崩。

所以,我们在调用 RPC 的时候还是需要对错误做相应的降级处理:

// 原始方式
res, err := userservice.GetUser(ctx, &userService.GetUserRequest{})
if err!=nil {
	if s,ok:=status.FromError(err);ok {
			if grpcErr.Code() == codes.Unavailable {
					// 降级
					res = &userservice.GetUserResponse{}
			} else {
					return err
			}
	} else {
		return err
	} 
}

上面展示了 Go 当中处理 gRPC 错误的方式,非常原始,无法想象每一次 RPC 调用都需要写这么多的错误处理逻辑。我们需要找到一种更加优雅的方案。

我们可以通过为整个 Client 挂载一个熔断+降级的 intercepter 来处理异常,如果发生熔断且 CallOption 当中包含特定的 DegradeOption,则覆写这次调用的结果:

// interceptor
var breaker = gobreaker.NewCircuitBreaker(
	gobreaker.Settings{
		...
		IsSuccessful: func (err error) bool {
			// 判断错误是否被计入熔断
			if s, ok := status.FromError(err); ok {
				switch s.Code() {
				// 忽略特定 error code
				case codes.InvalidArgument, codes.NotFound, codes.Canceled:
					return true
				}
			}
			return err == nil
		},
	},
)

// intercepte is the grpc interceptor func
func intercepte(
  ctx context.Context,
	method string,
	req,reply interface{},
	cc *grpc.ClientConn,
	invoker grpc.UnaryInvoker,
	opts ...grpc.CallOption,
) error {
	 // 获取一个熔断器执行任务
   err := breaker.Execute(func() (interface{},error) {
      return nil,invoker(ctx,method,req,reply,cc,opts...)
   })
   if errors.Is(err,gobreaker.ErrOpenState) || 
			errors.Is(err,gobreaker.ErrTooManyRequests) {
			// 如果发生熔断则降级
      if d,ok := extractDegrade(ctx,opts);ok {
         if err := copier.Copy(reply,d); err != nil {
            panic("cannot copy to dst")
         }
         return nil
      }
      return status.Error(codes.Unavailable,err.Error())
   }
   return err
}

// 从 option 中导出降级选项
func extractDegrade(ctx context.Context, opts []grpc.CallOption) (interface{}, bool) {
	for _, o := range opts {
		if d, ok := o.(degrader); ok {
			return d.degrade(ctx), true
		}
	}
	return nil, false
}

通过上面的 interceptor,我们就能够在调用 RPC 的时候优雅地实现熔断降级:

// 新的方式

res, err := userservice.GetUser(ctx, &userservice.GetUserRequest{}, WithDegrade(&userservice.GetUserResponse{}))

服务发现与负载均衡

我们服务直接使用了 K8S 提供的服务发现能力。我们只需要声明不同 gRPC Service 所指向的目标 K8S Service,就可以直接访问对应服务。为了降低维护成本,我们在 ProtoBuf 上通过自定义 Service Option 的方式,为每一个 Service 定义了对应默认的 K8S service name(将 grpc service 与 K8S service 关联起来):

service UserService {
	option (google.api.default_host) = "user-service"
	rpc GetUser(GetUserRequest) returns (GetUserResponse)
}

这样我们就可以在生成的代码里面直接连接对应的服务:

func DialUserService(ctx context.Context, opts ...grpc.DialOption) (grpc.ClientConnInterface, error) {
	return grpc.DialContext(ctx, "user-service:9090", opts...)
}

由于 envoy 原生支持 gRPC,不需要服务进程自己实现负载均衡,只需要连接对应 Service,envoy 就能均匀地往下游分发 gRPC 流量。

健康检查

K8S 没有原生支持 gRPC 协议探针,不过我们可以使用标准的 gRPC Health Checking Protocol 来实现健康检查。

在 gRPC-go 当中已经集成了 grpc_health_v1,只需要为 gRPC server 注册一个 Health Server:

import (
	grpc "google.golang.org/grpc"
	grpcHealth "google.golang.org/grpc/health"
	grpc_health_v1 "google.golang.org/grpc/health/grpc_health_v1"
)

grpcServ := grpc.NewServer()
grpc_health_v1.RegisterHealthServer(grpcServ, grpcHealth.NewServer())

另外我们还需要在相应的 K8S deployment 当中声明相应的 gRPC 健康检查命令,我们可以直接使用社区提供的 https://github.com/grpc-ecosystem/grpc-health-probe,它是 grpc_health_v1 的 client 端实现。

# 通过 Dockerfile 在容器内预装 grpc_health_probe

RUN GRPC_HEALTH_PROBE_VERSION=v0.3.1 && \
    wget -qO/bin/grpc_health_probe https://github.com/grpc-ecosystem/grpc-health-probe/releases/download/${GRPC_HEALTH_PROBE_VERSION}/grpc_health_probe-linux-amd64 && \
    chmod +x /bin/grpc_health_probe
spec:
  containers:
		- name: grpc
			...
			readinessProbe:
	      exec:
	        command: ["/bin/grpc_health_probe", "-addr=:9090"]
	      initialDelaySeconds: 5
	    livenessProbe:
	      exec:
	        command: ["/bin/grpc_health_probe", "-addr=:9090"]
	      initialDelaySeconds: 10
			...

其他

管理 ProtoBuf 依赖

在大型 monorepo 当中,所有依赖都以本地文件的方式存在,相互引用非常方便。但是,在其他项目当中引入第三方 ProtoBuf 非常不方便,我们往往需要将第三方依赖直接存放在一个 third_party 目录下,以便直接依赖。

project
├── third_party
│   └── google
│       └── api
│           └── annotation.proto
└── proto
    └── service
        └── user
            └── v1
                └── api.proto

在使用 google.api.annotation 的时候,笔者同样遇到这样的问题,上面的方式显然不是一种优雅的方案,一方面造成了冗余,另一方面未来更新比较麻烦。恰好 ProtoBuf 构建工具 https://github.com/bufbuild/buf 正式发布了 ProtoBuf 中央仓库服务(The Buf Schema Rregistry),我们可以直接使用 Buf 管理 ProtoBuf 外部依赖:

# buf.yaml
version: v1
deps:
  - buf.build/googleapis/googleapis

通过上面的声明,就能够在构建的时候直接 import "google/api/annotations.proto"; 了。

Node.js + TypeScript

当前我们有大量的 Node.js+TypeScript 的服务,让其支持 gRPC 一方面可以提高开发效率,另一方面让 server 端代码免去兼容 HTTP 的必要(直接用 gRPC client 就好了)。目前 gRPC 官方提供了 @grpc/grpc-js ,用于在 Node 当中使用 gRPC。不同于其他语言提供代码生成器的方案,它需要使用者在运行时动态导入 proto 定义,然后放可调用其中方法。这意味则在编译期没有任何类型约束,这样一来,我们就失去了使用 ProtoBuf Schema 或者 TypeScipt 所带来的好处了。

为了解决这个问题,社区里面有大量解决方案。经过对比,笔者选择了 https://github.com/stephenh/ts-proto:它不使用 JavaScript + d.ts 的方案,而是直接生成了纯 TypeScript 的 Server 和 Client,内部实现也是直接调用了 grpc-js,是对官方 SDK 的一层良好的封装。

它也能与 Buf 良好配合:

# node.gen.yaml
version: v1

plugins:
	- name: ts_proto
    path: ./node_modules/.bin/protoc-gen-ts_proto
    out: .
    opt:
      - forceLong=string
      - esModuleInterop=false
      - context=true
      - env=node
      - lowerCaseServiceMethods=true
      - stringEnums=true
      - unrecognizedEnum=false
      - useDate=true
      - useOptionals=true
      - outputServices=grpc-js
    strategy: all

不过相比 grpc-go,grpc-js 的 grpc client interceptor 引入了更多的切面,给开发者带来了更多的认知门槛,具体可以参考这篇 node grpc client interceptor 设计:

proposal/L5-node-client-interceptors.md at master · grpc/proposal
A repository for gRFCs . Contribute to grpc/proposal development by creating an account on GitHub.
https://github.com/grpc/proposal/blob/master/L5-node-client-interceptors.md

另外,由于 grpc 本身支持 stream 模式,无法简单地使用 async/await 来实现代码,为了整体接口的统一,grpc-js 生成的 rpc 方法都是类型 Callback 的模式:

interface ServiceClient {
	call(
	   input: Request,
	   callback: (err: grpc.ServiceError | null, value?: Response) => void,
	): grpc.ClientUnaryCall
}

我们通过自己实现 client 代码生成器,在代码生成的时候就完成封装以支持 async/await 模式,获得更好的使用体验:

const client = UserServiceClient.createDefaultInstance()

const user = await client.getUser(
  {
    username: "guoguo",
  },
  {
		deadline: 1000,
    interceptors: [
			interceptors.withRetry(2),
			interceptors.withDegrade({} as GetUserResponse),
		],
  }
);

总结

引入 gRPC 在一定程度上提高了服务间调用的性能,更加重要的是通过统一的 RPC 定义+生成代码的方式,为服务带来了更可靠的通讯类型,提高了开发效率。但没有银弹,另一方面 gRPC 给整个系统引入了更多的复杂性,我们不得不面临系统内同时存在 HTTP/JSON 和 gRPC 两套 RPC 体系;同时,调试 gRPC 相比 HTTP 也会有一定的麻烦。

能够顺利地在不同语言当中使用 gRPC,离不开大量社区提供的开源工具和最佳实践,除了上面提到的,更多的资料还能在 https://github.com/grpc-ecosystem/awesome-grpc 仓库当中找到。