深入学习 GRPC - 1. 非加密非流式的字节结构

本文基于以下版本:

github.com/golang/protobuf: v1.3.2
google.golang.org/grpc: v1.25.1
nginx: openresty v1.15.8.2

本篇主要进行非加密非流式 GRPC 的通信在字节层面的讨论,假设读者对 GRPC、HTTP/2 等已有基本的了解。
本篇使用一个简单的 proto:

syntax = "proto3";

package pb;

service Hot {
  rpc Inc (IntReq) returns (IntResp);
}

message IntReq {
  int32 i = 1;
}

message IntResp {
  int32 i = 1;
}

以及如下的 golang 服务端代码:

package main

import (
    "context"
    "net"
    "os"

    "grpc_hot/pb"

    "google.golang.org/grpc"
)

type HotService struct{}

func (svc *HotService) Inc(_ context.Context, req *pb.IntReq) (*pb.IntResp, error) {
    return &pb.IntResp{I: req.GetI() + 1}, nil
}

func main() {
    port := "30080"
    if len(os.Args) >= 2 {
        port = os.Args[1]
    }

    srv := grpc.NewServer()
    pb.RegisterHotServer(srv, &HotService{})
    l, err := net.Listen("tcp", ":"+port)
    if nil != err {
        println(err.Error())
        return
    }
    srv.Serve(l)
}

和客户端代码:

package main

import (
    "context"
    "os"

    "grpc_hot/pb"

    "google.golang.org/grpc"
)

func main() {
    port := "30080"
    if len(os.Args) >= 2 {
        port = os.Args[1]
    }

    conn, err := grpc.Dial("127.0.0.1:"+port, grpc.WithInsecure())
    if nil != err {
        println(err.Error())
        return
    }
    defer conn.Close()
    cli := pb.NewHotClient(conn)
    resp, err := cli.Inc(context.Background(), &pb.IntReq{I: 6})
    if nil != err {
        println(err.Error())
        return
    }
    println("resp:", resp.GetI())
}

1.1. HTTP/2

启动上述 golang 的服务端,调用一次客户端,均使用默认端口。使用 wireshark 抓包,总共抓到 19 帧。除去那些不包含 TCP 荷载的帧,我们首先逐帧来看看它们的 HTTP 这一层长什么亚子。

frame source TCP payload / HTTP content
04 client 50 52 49 20 2a 20 48 54 54 50 2f 32 2e 30 0d 0a
0d 0a 53 4d 0d 0a 0d 0a
06 client 00 00 00 04 00 00 00 00 00
07 server 00 00 06 04 00 00 00 00 00 00 05 00 00 40 00
09 server 00 00 00 04 01 00 00 00 00
11 client 00 00 00 04 01 00 00 00 00
12 client 00 00 38 01 04 00 00 00 01 83 86 45 89 62 b8 d7
c6 74 b1 92 a2 7f 41 85 b8 c8 00 f0 7f 5f 8b 1d
75 d0 62 0d 26 3d 4c 4d 65 64 7a 8a 9a ca c8 b4
c7 60 2b 89 b5 c3 40 02 74 65 86 4d 83 35 05 b1
1f 00 00 07 00 01 00 00 00 01 00 00 00 00 02 08
06
14 server 00 00 04 08 00 00 00 00 00 00 00 00 07 00 00 08
06 00 00 00 00 00 02 04 10 10 09 0e 07 07
15 client 00 00 08 06 01 00 00 00 00 02 04 10 10 09 0e 07
07
16 server 00 00 0e 01 04 00 00 00 01 88 5f 8b 1d 75 d0 62
0d 26 3d 4c 4d 65 64 00 00 07 00 00 00 00 00 01
00 00 00 00 02 08 07 00 00 18 01 05 00 00 00 01
40 88 9a ca c8 b2 12 34 da 8f 01 30 40 89 9a ca
c8 b5 25 42 07 31 7f 00
17 client 00 00 04 08 00 00 00 00 00 00 00 00 07 00 00 08
06 00 00 00 00 00 02 04 10 10 09 0e 07 07
18 server 00 00 08 06 01 00 00 00 00 02 04 10 10 09 0e 07
07

除第 4 帧外,HTTP 层的结构均如下:

+-----------------------------------------------+
|                 Length (24)                   |
+---------------+---------------+---------------+
|   Type (8)    |   Flags (8)   |
+-+-------------+---------------+-------------------------------+
|R|                 Stream Identifier (31)                      |
+=+=============================================================+
|                   Frame Payload (0...)                      ...
+---------------------------------------------------------------+
  • Length:荷载的字节数,注意是 HTTP 的荷载,不是 TCP 的荷载
  • Type:HTTP 帧的类型
frame type code
DATA 0x0
HEADERS 0x1
PRIORITY 0x2
RST_STREAM 0x3
SETTINGS 0x4
PUSH_PROMISE 0x5
PING 0x6
GOAWAY 0x7
WINDOW_UPDATE 0x8
CONTINUATION 0x9
  • Flags:不同类型的帧具有不同的 flag 定义

1.1.1. 连接

第 4 帧用许多语言都表示为这样:

"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"

客户端通过这样一帧去试探服务端是否支持 HTTP/2。
接下来第 6、7、9、11 帧,两端相互请求 SETTINGS
SETTINGS 帧的荷载为零到多组键值对,每组键值对的结构为 2 字节的 id 和 4 字节的值。如第 7 帧包含一组键值对,id 为 00 05,值为 00 00 40 00
id 和值的定义见 RFC-7540, section 6.5.2.

1.1.2. 首部

第 12 帧,客户端向服务端发送 HTTP 请求的首部。
HEADERS 帧的荷载结构如下:

+---------------+
|Pad Length? (8)|
+-+-------------+-----------------------------------------------+
|E|                 Stream Dependency? (31)                     |
+-+-------------+-----------------------------------------------+
|  Weight? (8)  |
+-+-------------+-----------------------------------------------+
|                   Header Block Fragment (*)                 ...
+---------------------------------------------------------------+
|                           Padding (*)                       ...
+---------------------------------------------------------------+

本文的情况中,HEADERS 的帧荷载只有 Header Block Fragment 字段存在。其他字段的定义见 RFC-7540, section 6.2.
Fragment 的编解码使用 HPACK 算法(RFC-7541),包括霍夫曼编码。我们可以使用 Golang 的副标准库当中的封装来解码第 12 帧的 fragment。

import "golang.org/x/net/http2/hpack"

func decodeHeaders(bs []byte) {
    d := hpack.NewDecoder(128, nil)
    hdrs, _ := d.DecodeFull(bs)
    for _, hdr := range hdrs {
        println(hdr.Name, hdr.Value)
    }
}

其中传入的字节序列长度为帧的 Length 字段指示的 0x38,但可以看到帧荷载的实际长度不止 0x38,后面剩余的 16 个字节应该是一段 trailer,具体是什么暂时不得而知。这里打印出的 header 如下:

:method POST
:scheme http
:path /pb.Hot/Inc
:authority :30081
content-type application/grpc
user-agent grpc-go/1.25.1
te trailers

可以看到这里的首部还包含 HTTP/1.x 中的 method 和 path,由于使用了静态索引表和霍夫曼编码,实际传输的首部只有 56 字节,通信精简的效果很明显。
同样,第 16 帧服务端发送的 HEADERS 帧,从长度上看也包含 trailer,首部解码出来如下:

:status 200
content-type application/grpc

神奇的是整个过程中没有一个 DATA 帧,那么 GRPC 使用的 HTTP body 在哪里呢,我猜你也猜到了。

1.2. GRPC

1.2.1. 请求

在第 12 帧的 HTTP 首部里可以看到,对于 GRPC 调用的请求,method 始终是 POST,路径是 /{包名}.{服务名}/{方法名}
而请求的数据放在这一帧的 trailer 中,只有当这部分数据的长度大于 16 KB,才会分为多个帧发送,此时才会出现独立的 DATA 帧,此情况暂不讨论。
Trailer 中的数据其实也是对应一个 TCP 荷载的结构。第 12 帧的 trailer 为:00 00 07 00 01 00 00 00 01 00 00 00 00 02 08 06,从第 4 个字节来看,它正是一个 DATA 帧。
DATA 帧的荷载结构对 HTTP 是透明的,真正的定义在于 GRPC 这一层。GRPC 中 DATA 帧的荷载结构如下:

+---------------+
| Compressed(8) |
+---------------+-----------------------------------------------+
|                          Length (32)                          |
+---------------------------------------------------------------+
|                           Data (*)                          ...
+---------------------------------------------------------------+
  • CompressedData 字段是否被压缩,0 为未压缩,1 为压缩
    ,此时压缩的算法会标记在首部的 Message-Encoding 字段
  • LengthData 的字节数
  • Data:实际的数据,默认为 ProtoBuf 编码,编码算法见 这里

这里 DATA 帧的荷载是 00 00 00 00 02 08 06,表明 Data 未段未压缩,长度为 2,内容为 08 06

1.2.2. 响应

和请求的帧相同的套路,我们可以看清第 16 帧中的响应数据。不过在这个逻辑上的 DATA 帧后面还有一个 HAEDERS 帧,解码出来是这样:

grpc-status 0
grpc-message

至此,我们已基本看清一个非加密非流式的最简单情况下的 GRPC 请求在字节层面的样子。

1.3. Nginx 代理

下一篇我们将会通过使用带 TLS 的 Nginx 代理非加密 GRPC 节点,来讨论带 TLS 的 GRPC 协议。所以这里先给出一个简单的非加密 Nginx 代理非加密 GRPC 节点的 Nginx 配置,包括负载均衡。
我们启动两个 golang 的服务端节点,端口分别为 3008130082。在 Nginx 配置文件的 http 段中加入:

upstream grpc_hot {
    server 127.0.0.1:30081;
    server 127.0.0.1:30082;
}
server {
    listen 30080 http2;
    location / {
        grpc_pass grpc://grpc_hot;
    }
}

References

RFC-7540: Hypertext Transfer Protocol Version 2 (HTTP/2)
RFC-7541: HPACK: Header Compression for HTTP/2
Protocol Buffers: Encoding
Introducing gRPC Support with NGINX 1.13.10

Licensed under CC BY-SA 4.0