I recently mentioned the gRPC protocol in a discussion with a friend, and I’ve written about gRPC before. People usually use the official grpc library to communicate directly. But as far as I can see, most of them are Unary requests and very few are stream calls. Unary requests of gRPC are not much different from ordinary http/2 calls, so we can write a simple gRPC client by ourselves. Today, we will share our thoughts on the go language as an example. For those of you who insist on using the official SDK, don’t miss this article as it will also provide some help in understanding the operation of the gRPC protocol.

Before introducing the client, we need a server-side program that can be used for testing. This part can be implemented directly using the official SDK.

First, we define the proto file.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
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;
}

Since we need to import the generated grpc code in the go language, we need to set the package name of the generated code using go_package.

Then, we use protoc to generate the corresponding pb and grpc files.

1
2
3
4
5
6
protoc \
    --go_out=. \
    --go_opt=paths=source_relative \
    --go-grpc_out=. \
    --go-grpc_opt=paths=source_relative \
    hello/hello.proto

After successful execution, we will see the following file.

1
2
3
4
hello
├── hello.pb.go
├── hello.proto
└── hello_grpc.pb.go

hello.pb.go contains the definition of the interface input and output messages, and hello_grpc.pb.go defines the interface to be implemented by the server and the client, as well as the implementation code for the client. All the server side has to do is to implement the corresponding interface first. We can create a hello.go file with the following contents.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
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
}

After implementing the interface logic, we still have to write the service startup code with the following core logic.

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

Well, here we have the simplest gRPC service. After running the code the service will listen on port 8080.

Next, we’re going to write our own client code. But before we start, we need to briefly review the gRPC communication protocol.

First of all, gRPC uses the http2 protocol at the bottom. http2 uses frames to transfer data, with different frames for headers and data, so that they can be crossed over, rather than having to pass headers and then data, as in http1. The structure of a Unary request is as follows.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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>

There are several points to note here.

  • :path holds the path to the interface, taking the value corresponding to the definition of the proto, with the structure /package name. service/method name
  • content-type indicates the data encoding type, pb corresponds to application/grpc+proto, or you can use json
  • trailers is fixed to TE to indicate that the trailer headers can be handled correctly. Some gRPC libraries require this field to be passed

The last thing to note is the structure of the data as a Length-Prefixed message. This is simply a five-byte prefix before the pb data, where the first byte indicates whether the pb data is compressed or not, and the next four bytes hold a thirty-two-bit integer in big-endian order, which is used to record the length of the pb data.

After the request message, let’s look at the response message.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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 = ""

Note here that the server first sends a header frame with :status and content-type information, then sends a data frame, and finally sends another header frame with grpc-status and grpc-message. The header after the data is sent is called the trailer header. gRPC is designed in such a way that we will not expand on it, if you are interested, you can see the link to the article given in my header.

Well, everything is ready, we can design our own gRPC client.

From the above analysis, we can conclude that we just need to serialize the request object into a pb byte stream, then insert a five-byte prefix in front, set the length of the pb data, and finally use the http standard library to send the data to the gRPC server. After receiving the response from the gRPC server, we deserialize the body into a response object, and we are done.

Wait, can we use the go language http standard library? Theoretically, yes, because the http standard library already supports the http2 protocol. However, there is a catch: the http standard library requires the server to use tls encryption when making http2 requests. In order to promote the popularity of https and protect user privacy, major browsers force http2 communication to be encrypted with tls. So go’s standard library also has this requirement. But generally speaking, intranet services run in a controlled environment, and tls configuration and certificate management are troublesome, so most intranet services do not use tls encryption (which is not true). So, we need to investigate a way to initiate http2 communication over plaintext tcp connections.

In fact, the method is very simple, we just need to prepare an http2.Transport ourselves.

1
2
3
4
5
6
7
8
9
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)
  },
}

In the net/http framework, Transport is responsible for handling the real network communication and maintaining the underlying TCP connection. The plainTextH2Transport we declare here is a global variable at the package level, which is the only way to reuse TCP connections.

The plaintext http2 client we described earlier can be used by simply declaring it as follows.

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

It’s that simple and uncomplicated! To facilitate expansion, we can design the following structure.

1
2
3
4
5
6
7
type GrpcClient struct {
  http.Client
}

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

Since we have embedded http.Client in GrpcClient, GrpcClient itself is also an http.Client. Next we need to implement the Unary request logic. The interface signature is as follows.

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

Apart from ctx, there are three core inputs, api/in/out. In fact, api is the full URL of the interface, in our case http://127.0.0.1:8080/hello.Greeter/SayHello, which corresponds to the previous proto and the address the server listens to. in and out correspond to the request and return messages of the gRPC interface. Since we want to write a generic client, we set the type to proto.Message uniformly. If we could generate the code automatically, we could determine each interface and the incoming and outgoing parameters from the proto file, and then generate the corresponding DoUnary methods separately. But we are designing a simple client today, and we don’t want to use a heavyweight solution like auto-generated code. So, we have to set it to proto.Message type uniformly. But this design has a drawback, it requires the caller to initialize both in and out before calling. But it can’t be helped, it’s the only solution that doesn’t require code generation.

There are two return values, the first one is the underlying http response. Through it we can read the http protocol status code and various headers (gRPC metadata). The second one returns an error. Generally, the caller only needs to check for errors and does not need to access the first return value.

Okay, let’s analyze the code implementation of DoUnary. First, we need to construct the so-called Length-Prefixed message.

1
2
3
4
5
6
7
8
9
// 正式代码需要处理错误
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))

At this point, we have the Length-Prefixed message ready, and we have wrapped it in an io. Then, we have to make the http2 request.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 正式代码需要处理错误
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()

Finally, we read all the returned data, take it and decode it into response objects.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 正式代码需要处理错误
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)

The full code is available from here. The above is a simplest gRCP client.

Finally, we start the server side and run the following code.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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"))

The program will output “Hello 涛叔”.