实现一个简单的 gRPC 客户端

2022-03-22 ⏳4.9分钟(1.9千字)

最近在跟朋友讨论的时候提到了 gRPC 协议,我之前也写过介绍 gRPC 的文章。大家一般都直接用官方的 grpc 库通信。但就我观察,绝大多数都是 Unary 请求调用,Stream 调用极少。而 gRPC 的 Unary 调用跟普通的 http/2 请求区别不大,我们完全可以自己手写一个简单的 gRPC 客户端。今天就以 go 语言为例,跟大家分享一下实现思路。坚持使用官方 SDK 的朋友也不要错过,这篇文章也能为理解 gRPC 协议提供一些帮助。

在介绍客户端之前,我们需要一个能用来测试的服务端程序。这部分可以直接使用官方的 SDK 实现。

首先,我们定义 proto 文件:

syntax = "proto3";

option go_package = "taoshu.in/foo/hello";

package hello;

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

因为我们需要在 go 语言中导入生成的 grpc 代码,所以需要使用go_package设置生成代码的包名。

然后,我们使用 protoc 生成对应的 pb 文件和 grpc 文件:

protoc \
    --go_out=. \
    --go_opt=paths=source_relative \
    --go-grpc_out=. \
    --go-grpc_opt=paths=source_relative \
    hello/hello.proto

执行成功后,我们会看到如下文件:

hello
├── hello.pb.go
├── hello.proto
└── hello_grpc.pb.go

hello.pb.go包含了接口入参和出参消息的定义,hello_grpc.pb.go则定义了服务端和客户端所需要实现的接口,以及客户端的实现代码。服务端要做的就是先要实现对应的接口。我们可以创建一个hello.go文件,内容如下:

package hello

import (
  context "context"
)

type HelloServer struct {
  // gRPC 要求每个服务的实现必须嵌入对应的结构体
  // 这个结构体也是自动生成的
  UnimplementedGreeterServer
}

// SayHello 实现 GreeterServer 中的 SayHello 方法
// 也就是在 proto 文件中定义的方法
// GreeterServer 接口由 protoc 自动生成
func (s *HelloServer) SayHello(ctx context.Context, in *HelloRequest) (*HelloReply, error) {
  out := &HelloReply{}
  out.Message = "Hello " + in.Name

  return out, nil
}

实现了接口逻辑后,我们还得编写服务启动代码,核心逻辑如下:

// 监听端口(正式代码需要处理错误)
ln, _ := net.Listen("tcp", "127.0.0.1:8080")
// 新建服务
grpcServer := grpc.NewServer()
// 注册接口
hello.RegisterGreeterServer(grpcServer, &hello.HelloServer{})
// 启动服务
grpcServer.Serve(ln)

好了,到这我们就得到了一个最简单的 gRPC 服务。运行代码后服务会监听 8080 端口。

接下来,我们要写自己的客户端代码了。但在开始之前,我们需要简单回顾一下 gRPC 的通信协议。

首先,gRPC 底层使用 http2 协议。http2 使用 frame 传输数据,头信息跟数据使用不同的 frame,可以交叉传输,而不必像 http1 那样只能先传头信息再传数据。一个 Unary 请求的结构如下:

HEADERS (flags = END_HEADERS)
:method = POST
:scheme = http
:path = /hello.Greeter/SayHello
:authority = 127.0.0.1:8080
content-type = application/grpc+proto
trailers = TE

DATA (flags = END_STREAM)
<Length-Prefixed Message>

这里有几个注意的点:

最后需要注意的就是数据的结构为Length-Prefixed消息。简单来说就是要在 pb 数据之前加上一个五字节前缀,其中第一个字节表示是否压缩 pb 数据,后四个字节以大端顺序保存一个三十二位整数,用来记录 pb 数据的长度。

说完请求消息,我们再看响应消息:

HEADERS (flags = END_HEADERS)
:status = 200
content-type = application/grpc+proto

DATA
<Length-Prefixed Message>

HEADERS (flags = END_STREAM, END_HEADERS)
grpc-status = 0 # OK
grpc-message = ""

这里注意,服务端先发了一个 header frame,里面有:statuscontent-type等信息,然后发数据帧,最后又发了一个 header 帧,里面是 grpc-statusgrpc-message。发完数据后又的的头信息就叫做 trailer 头。gRPC 为什么要这样设计我们就不展开了,有兴趣的朋友可以看我头给出的文章链接。

好了,万事具备,我们可以设计自己的 gRPC 客户端了。

从上面的分析我们可以得出,只要将请求对象序列化成 pb 字节流,然后在前面插入五个字节的前缀,设置好 pb 数据的长度,最后再用 http 标准库将数据发送给 gRPC 服务端就好。收到 gRPC 服务端的响应之后,我们再把 body 反序列化成响应对象,这就大功告成了。

等等,我们可以用 go 语言的 http 标准库吗?理论上是可以的,因为 http 标准库早就支持 http2 协议了。但是,肯定有个但是,http 标准库在发起 http2 请求的时候要求服务端必须使用 tls 加密。为了推动 https 的普及,保护用户隐私,各大浏览器强行要求 http2 通信必须使用 tls 加密。所以 go 的标准库也有这样的要求。但一般来说内网服务运行在受控环境,而且 tls 配置和证书管理都很麻烦,所以多数内网服务都不用 tls 加密(这是不对的)。所以,我们需要研究一种可以在明文 tcp 连接发起 http2 通信的办法。

其实,方法很简单,我们只需要自己准备一个http2.Transport就可以了。

var plainTextH2Transport = &http2.Transport{
  // 允许发起 http:// 请求
  AllowHTTP: true,
  // 默认 http2 会发起 tls 连接
  // 可以通过 DialTLS 拦截并改为发起普通 TCP 连接
  DialTLS: func(network, addr string, _ *tls.Config) (net.Conn, error) {
    return net.Dial(network, addr)
  },
}

net/http框架中,Transport负责处理真正的网络通信并且维护底层的 TCP 连接。我们这里声明的plainTextH2Transport是一个包一级的全局变量,只有这样才能实现复用 TCP 连接的效果。

而我们前面所说的明文 http2 客户端只需要这样声明就可以用了:

c := http.Client{Transport: plainTextH2Transport}
// c 支持发起明文 http2 请求

就是这么朴实无华!为了方便扩展,我们可以设计如下结构体:

type GrpcClient struct {
  http.Client
}

func NewGrpcClient() GrpcClient {
  return grpcClient{http.Client{Transport: plainTextH2Transport}}
}

因为我们在GrpcClient嵌入了http.Client,所以GrpcClient本身也是一个http.Client。接下来我们就需要实现 Unary 请求逻辑。接口签名如下:

func (c *GrpcClient) DoUnary(ctx context.Context, api string, in, out proto.Message) (*http.Response, error) {
}

除去ctx外,核心入参有三个,api/in/out。其实api就是接口的完整URL,在我们的例子中应该是http://127.0.0.1:8080/hello.Greeter/SayHello,跟前面的 proto 和服务端监听的地址一一对应。inout对应gRPC接口的请求消息和返回消息。因为我们希望写一个通用的客户端,所以统一将类型设置为proto.Message。如果可以自动生成代码,我们可以根据 proto 文件确定每一个接口和入参和出参,然后分别生成对应的 DoUnary 方法。但我们今天是要设计一个简单的客户端,不想用自动生成代码这样的重量级方案。所以,只能统一设成proto.Message类型。但这种设计有一个缺点,要求调用方在调用之前就得把 in 和 out 都初始化好。但没办法,这是目前唯一不用生成代码的方案了。

返回值有两个,第一个是底层的 http 响应。通过它我们可以读取 http 协议状态码和各种头信息(gRPC 的 metadata)。第二个返回错误。一般来说,调用方只需要检查是否有错误就行,不需要访问第一个返回值。

好,下面我们分析 DoUnary 的代码实现。首先,我们需要构造所谓的 Length-Prefixed 消息:

// 正式代码需要处理错误
pb, _ := proto.Marshal(in)

bs := make([]byte, 5)
// 第一个字节默认为0,表示不压缩
// 后四个字节以大端的形式保存 pb 消息长度
binary.BigEndian.PutUint32(bs[1:], uint32(len(pb)))
// 使用 MultiReader 「连接」两个 []byte 避免无谓的内存拷贝
body := io.MultiReader(bytes.NewReader(bs), bytes.NewReader(pb))

到这里,我们就准备好了 Length-Prefixed 消息,而且还把它封装成一个 io.Reader 对象。然后,我们就要发起 http2 请求:

// 正式代码需要处理错误
req, _ := http.NewRequest("POST", api, body)

req = req.WithContext(ctx)
// 设置必要的头信息
req.Header.Set("trailers", "TE")
req.Header.Set("content-type", "application/grpc+proto")
// 正式代码需要处理错误
resp, _ = c.Do(req)
defer resp.Body.Close()

最后,我们读取所有的返回数据,将其并解码为响应对象:

// 正式代码需要处理错误
pb, _ = io.ReadAll(resp.Body)
// Unary 调用出错不会返回 body
// 此时 grpc-status 跟 http 状态码一同返回
// 不能通过 Trailer 读取
if status := resp.Header.Get("grpc-status"); status != "" {
  var c int
  if c, err = strconv.Atoi(status); err != nil {
    return
  }
  err = grpc.Errorf(codes.Code(c), resp.Header.Get("grpc-message"))
  return
}
// 因为是 Unary,可以直接跳过前五个字节
err = proto.Unmarshal(pb[5:], out)

完整代码可以从这里获取。以上就是一个最简单的 gRCP 客户端。

最后,我们启动服务端,然后运行下面的代码:

c := NewGrpcClient()

api := "http://127.0.0.1:8080/hello.Greeter/SayHello"
in := &hello.HelloRequest{Name: "涛叔"}
out := &hello.HelloReply{}

// 正式代码需要处理错误
resp, _ := c.DoUnary(context.Background(), api, in, out)

fmt.Println(out.Message, resp.Trailer.Get("trace-id"))

程序会输出「Hello 涛叔」。

写到这里,本文的主要内容也就差不多了。希望朋友们都能在充分了解的基础上根据自己的业务场景订制甚至是开发自己的工具和框架。用百分之二十的精力解决百分之八十的问题。Keep it simple😄