Go 语言编程之旅 第三章 RPC 应用


Go 语言编程之旅 第三章 RPC 应用


第三章 RPC 应用

在上一个章节中,我们介绍了使用 Go 语言的 gin 框架来做一个基于 HTTP 服务的 Web 项目,接下来在本章节, 我们将介绍 Go 语言中最流行的 RPC 框架:gRPC(从 Github Star 数来看),并带你探索其相对应的技术栈。

首先我们将对 gRPC 和 Protobuf 进行介绍,让你形成一个基本的概念,知道 gRPC 是什么、Protobuf 又是什么, 然后会在接下来会对两者做更进一步的使用和详细介绍。

3.1 遨游 gRPC 和 Protobuf

3.1.1.1 什么是 RPC

RPC 代指远程过程调用(Remote Procedure Call),它的调用包含了传输协议和编码(对象序列)协议等等, 允许运行于一台计算机的程序调用另一台计算机的子程序,而开发人员无需额外地为这个交互作用编程, 因此我们也常常称 RPC 调用,就像在进行本地函数调用一样方便。

3.1.1.2 什么是 gRPC

gRPC 是一个高性能、开源和通用的 RPC 框架,面向移动和基于 HTTP/2 设计。目前提供 C、Java 和 Go 语言等等版本, 分别是:grpc、grpc-java、grpc-go,其中 C 版本支持 C、C++、Node.js、Python、Ruby、Objective-C、PHP 和 C# 支持。

gRPC 基于 HTTP/2 标准设计,带来诸如双向流、流控、头部压缩、单 TCP 连接上的多复用请求等特性。 这些特性使得其在移动设备上表现更好,在一定的情况下更节省空间占用。

gRPC 的接口描述语言(Interface description language,缩写 IDL)使用的是 Protobuf,都是由 Google 开源的。

3.1.1.3 gRPC 调用模型

接下来我们一起看看 gRPC 的一个最简的调用模型,便于在脑海中形成一个基本调用流转,如下官方图:

  1. 客户端(gRPC Stub)在程序中调用某方法,发起 RPC 调用。
  2. 对请求信息使用 Protobuf 进行对象序列化压缩(IDL)。
  3. 服务端(gRPC Server)接收到请求后,解码请求体,进行业务逻辑处理并返回。
  4. 对响应结果使用 Protobuf 进行对象序列化压缩(IDL)。
  5. 客户端接受到服务端响应,解码请求体。回调被调用的 A 方法,唤醒正在等待响应(阻塞)的客户端调用并返回响应结果。

3.1.3.1 什么是 Protobuf

Protocol Buffers(Protobuf)是一种与语言、平台无关,可扩展的序列化结构化数据的数据描述语言,我们常常称其为 IDL, 常用于通信协议,数据存储等等,相较于 JSON、XML,它更小、更快,因此也更受开发人员的青眯。

3.1.3.2 基本语法

syntax = "proto3";

package helloworld;

service Greeter {
    rpc SayHello (HelloRequest) returns (HelloReply) {}
}

message HelloRequest {
    string name = 1;
}

message HelloReply {
    string message = 1;
}
  1. 第一行(非空的非注释行)声明使用 proto3 语法。如果不声明,将默认使用 proto2 语法。 同时建议无论是用 v2 还是 v3 版本,都应当进行显式声明。而在版本上,目前主流推荐使用 v3 版本。
  2. 定义名为 Greeter 的 RPC 服务(Service),其包含 RPC 方法 SayHello,入参为 HelloRequest 消息体(message), 出参为 HelloReply 消息体。
  3. 定义 HelloRequest、HelloReply 消息体,每一个消息体的字段包含三个属性:类型、字段名称、字段编号。 在消息体的定义上,除类型以外均不可重复。

在编写完.proto 文件后,我们一般会进行编译和生成对应语言的 proto 文件操作, 这个时候 Protobuf 的编译器会根据选择的语言不同、调用的插件情况,生成相应语言的 Service Interface Code 和 Stubs。

3.1.3.3 基本数据类型

在生成了对应语言的 proto 文件后,需要注意的是 protobuf 所生成出来的数据类型并非与原始的类型完全一致, 因此你需要有一个基本的了解,下面是我列举了的一些常见的类型映射,如下表:

.proto Type C++ Type Java Type Go Type PHP Type
double double double float64 float
float float float float32 float
int32 int32 int int32 integer
int64 int64 long int64 integer/string
uint32 uint32 int uint32 integer
uint64 uint64 long uint64 integer/string
sint32 int32 int int32 integer
sint64 int64 long int64 integer/string
fixed32 uint32 int uint32 integer
fixed64 uint64 long uint64 integer/string
sfixed32 int32 int int32 integer
sfixed64 int64 long int64 integer/string
bool bool boolean bool boolean
string string String string string
bytes string ByteString []byte string

3.1.4 思考 gRPC

3.1.4.1 gRPC 与 RESTful API 对比

特性 gRPC RESTful API
规范 必须.proto 可选 OpenAPI
协议 HTTP/2 任意版本的 HTTP 协议
有效载荷 Protobuf(小、二进制) JSON(大、易读)
浏览器支持 否(需要 grpc-web)
流传输 客户端、服务端、双向 客户端、服务端
代码生成 OpenAPI+ 第三方工具

3.1.4.2 gRPC 优势

3.1.4.2.1 性能

gRPC 使用的 IDL 是 Protobuf,Protobuf 在客户端和服务端上都能快速地进行序列化,并且序列化后的结果较小, 能够有效地节省传输占用的数据大小。另外众多周知,gRPC 是基于 HTTP/2 协议进行设计的,有非常显著的优势。

另外常常会有人问,为什么是 Protobuf,为什么 gRPC 不用 JSON、XML 这类 IDL 呢,我想主要有如下原因:

在定义上更简单,更明了。
数据描述文件只需原来的 1/10 至 1/3。
解析速度是原来的 20 倍至 100 倍。
减少了二义性。
生成了更易使用的数据访问类。
序列化和反序列化速度快。
开发者本身在传输过程中并不需要过多的关注其内容。

3.1.4.2.2 代码生成

在代码生成上,我们只需要一个 proto 文件就能够定义 gRPC 服务和消息体的约定, 并且 gRPC 及其生态圈提供了大量的工具从 proto 文件中生成服务基类、消息体、客户端等等代码, 也就是客户端和服务端共用一个 proto 文件就可以了,保证了 IDL 的一致性且减少了重复工作。

3.1.4.2.3 流传输

gRPC 通过 HTTP/2 对流传输提供了大量的支持:

Unary RPC:一元 RPC。
Server-side streaming RPC:服务端流式 RPC。
Client-side streaming RPC:客户端流式 RPC。
Bidirectional streaming RPC:双向流式 RPC。

3.1.4.2.4 超时和取消

gRPC 允许客户端设置截止时间,若超出截止时间那么本次 RPC 请求将会被取消,与此同时服务端也会接收到取消动作的事件, 因此客户端和服务端都可以在达到截止时间后进行取消事件的相关联动处理。

并且根据 Go 语言的上下文(context)的特性,截止时间的传递是可以一层层传递下去的, 也就是我们可以通过一层层 gRPC 调用来进行上下文的传播截止日期和取消事件,有助于我们处理一些上下游的连锁问题等等场景。 但是同时也会带来隐患,如果没有适当处理,第一层的上下文取消,可以把最后的调用也给取消掉, 这在某些场景下可能是有问题的(需要根据实际业务场景判别)。

3.1.4.3 gRPC 缺点

3.1.4.3.1 可读性

默认情况下 gRPC 使用 Protobuf 作为其 IDL,Protobuf 序列化后本质上是二进制格式的数据,并不可读,因此其可读性差, 没法像 HTTP/1.1 那样直接目视调试,除非进行其它的特殊操作调整格式支持。

3.1.4.3.2 浏览器支持

目前来讲,我们无法直接通过浏览器来调用我们的 gRPC 服务,这意味着单从调试上来讲就没那么便捷了,更别提在其它的应用场景上了。

那官方有没有其余的工具协助呢,有的,gRPC-Web 提供了一个 JavaScript 库,使浏览器客户端可以访问 gRPC 服务, 但它也是有限的 gRPC 支持(对流传输的支持比较弱)。gRPC-Web 由两部分组成:一个支持浏览器的 JavaScript 客户端, 以及服务器上的一个 gRPC-Web 代理。调用流程为:gRPC-Web 客户端调用代理,代理将根据 gRPC 请求转发到 gRPC 服务。

但总归是需要额外的组件进行支持的,因此对浏览器的支持是有限的。

3.1.4.3.3 外部组件支持

gRPC 是基于 HTTP/2 设计的,HTTP/2 标准在 2015 年 5 月以 RFC 7540 正式发表,虽然已经过去了好几年,HTTP/3 也已经有了踪影, 但目前为止各大外部组件对 gRPC 这类基于 HTTP/2 设计的组件支持仍然不够完美,甚至有少数暂时就完全不支持。 与此同时,即使外部组件支持了,但其在社区上的相关资料也比较少,需要开发人员花费部分精力进行识别和研究,这是一个需要顾及的点。

3.1.5 小结

在本章节中,我们初步的介绍了 gRPC 和 Protobuf 是什么东西,并且简单的对 gRPC 的优缺点和传统的 RESTful API 进行了对比, 希望借此能够让你形成一个初步的印象,接下来的章节中,我们将进一步对 gRPC 和 Protobuf 进行进一步的说明和使用。

3.2 Protobuf 的使用和了解

3.2.1 安装

3.2.1.1 protoc 安装

在 gRPC 开发中,我们常常需要与 Protobuf 进行打交道,而在编写了.proto 文件后,我们会需要到一个编译器, 那就是 protoc,protoc 是 Protobuf 的编译器,是用 C++ 所编写的,其主要功能是用于编译.proto 文件。

接下来我们进行 protoc 的安装,在命令行下执行安装命令(需要依赖一些库,可根据错误提示搜索并进行依赖库的安装):

$ wget https://github.com/google/protobuf/releases/download/v3.11.2/protobuf-all-3.11.2.zip
$ unzip protobuf-all-3.11.2.zip && cd protobuf-3.11.2/
$ ./configure
$ make
$ make install

检查是否安装成功,如下:

$ protoc --version

如果出现如下类似报错:

protoc: error while loading shared libraries: libprotobuf.so.15: cannot open shared object file: No such file or directory

则在命令行执行 ldconfig 命令后,再次运行即可成功。但这是为什么呢,为什么要执行这条命令才能够正常运行 protoc 命令呢, 我们可以通过安装时的控制台输出的信息得知,Protocol Buffers Libraries 的默认安装路径在 /usr/local/lib 下,如下:

Libraries have been installed in:
   /usr/local/lib
...

实际上在安装了 protoc 后,我们同时安装了一个新的动态链接库,而 ldconfig 命令一般默认在系统启动时运行, 所以在特定情况下会找不到这个新安装的 lib,因此我们要手动执行 ldconfig,让动态链接库为系统所共享, 它是一个动态链接库管理命令,这就是 ldconfig 命令的作用。

上面是Linux系统的安装方式,下面说下win10的安装方式。

https://github.com/protocolbuffers/protobuf/releases 下载 v23.4的win64包 https://github.com/protocolbuffers/protobuf/releases/tag/v23.4 ,包解压后放在合适目录,如 G:\WorkSoft\protoc 下, 然后设置环境变量,新增 Path目录: G:\WorkSoft\protoc\bin ,接着查看版本。

3.2.1.2 protoc 插件安装

我们在上一步安装了 protoc 编译器,但是还是不够的,针对不同的语言,还需要不同的运行时的 protoc 插件, 那么对应 Go 语言就是 protoc-gen-go 插件,接下来可以在命令行执行如下安装命令:

$ go get -u github.com/golang/protobuf/protoc-gen-go@v1.3.2

同时 protoc-gen-go 因为一直处于更新迭代的过程中,如果不锁定版本,随着时间的推移, 很有可能会出现不兼容的情况(因为需要与 proto 软件包版本相匹配),这是非常麻烦的,因此我们也可以通过如下命令进行安装:

$ GIT_TAG="v1.3.2"
$ go get -d -u github.com/golang/protobuf/protoc-gen-go
$ git -C "$(go env GOPATH)"/src/github.com/golang/protobuf checkout $GIT_TAG
$ go install github.com/golang/protobuf/protoc-gen-go

将所编译安装的 Protoc Plugin 的可执行文件中移动到相应的 bin 目录下,例如:

$ mv $GOPATH/bin/protoc-gen-go /usr/local/go/bin/

这里的命令操作并非是绝对必须的,主要目的是将二进制文件 protoc-gen-go 移动到 bin 目录下, 让其可以直接运行 protoc-gen-go 执行,只要达到这个效果就可以了。

win10下操作会有问题,需要到目录 G:\GoPath\pkg\mod\github.com\golang\protobuf@v1.3.2\protoc-gen-go 下执行 go build ., 把生成的 protoc-gen-go.exe 移动到目录 G:\GoPath\bin 下。

3.2.2 初始化 Demo 项目

接下来我们初始化一个 gRPC 专用的 Demo 项目,用于演示后续的 gRPC 和 Protobuf 应用,执行下述命令:

$ mkdir -p $HOME/go-programming-tour-book/grpc-demo
$ cd $HOME/go-programming-tour-book/grpc-demo
$ go mod init github.com/go-programming-tour-book/grpc-demo

在初始化目录结构后,新建 server、client、proto 目录,便于后续的使用,最终目录结构如下:

grpc-demo
├── go.mod
├── client
├── proto
└── server

3.2.3 编译和生成 proto 文件

3.2.3.1 创建 proto 文件

我们在项目的 proto 目录下新建 helloworld.proto 文件,写入如下声明:

syntax = "proto3";

package helloworld;

service Greeter {
    rpc SayHello (HelloRequest) returns (HelloReply) {}
}

message HelloRequest {
    string name = 1;
}
    
message HelloReply {
    string message = 1;
}

3.2.3.2 生成 proto 文件

接下来我们在项目的根目录下,执行 protoc 的相关命令来生成对应的 pb.go 文件,如下:

$ protoc --go_out=plugins=grpc:. ./proto/*.proto 
  • –go_out:设置所生成 Go 代码输出的目录,该指令会加载 protoc-gen-go 插件达到生成 Go 代码的目的, 生成的文件以 .pb.go 为文件后缀,在这里 “:”(冒号)号充当分隔符的作用,后跟命令所需要的参数集, 在这里代表着要将所生成的 Go 代码输出到所指向 protoc 编译的当前目录。
  • plugins=plugin1+plugin2:指定要加载的子插件列表,我们定义的 proto 文件是涉及了 RPC 服务的, 而默认是不会生成 RPC 代码的,因此需要在 go_out 中给出 plugins 参数传递给 protoc-gen-go,告诉编译器, 请支持 RPC(这里指定了内置的 grpc 插件)。

在执行这条命令后,就会生成此 proto 文件的对应.pb.go 文件,如下:

helloworld.pb.go 
helloworld.proto

3.2.3.3 生成的.pb.go 文件

我们查看刚刚所生成的 helloworld.pb.go 文件,pb.go 文件是针对 proto 文件所生成的对应的 Go 语言代码, 是我们实际在应用中将会引用到的文件,我们一起看看生成出来的文件提供了什么功能,代码如下:

type HelloRequest struct {
    Name                 string   `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
    ...
}

func (m *HelloRequest) Reset()         { *m = HelloRequest{} }
func (m *HelloRequest) String() string { return proto.CompactTextString(m) }
func (*HelloRequest) ProtoMessage()    {}
func (*HelloRequest) Descriptor() ([]byte, []int) {
    return fileDescriptor_4d53fe9c48eadaad, []int{0}
}
func (m *HelloRequest) GetName() string {...}

在上述代码中,主要涉及针对 HelloRequest 类型,其包含了一组 Getters 方法,能够提供便捷的取值方式, 并且处理了一些空指针取值的情况,还能够通过 Reset 方法来重置该参数。而该方法通过实现 ProtoMessage 方法, 以此表示这是一个实现了 proto.Message 的接口。另外 HelloReply 类型也是类似的生成结果,因此不重复概述。

接下来我们看到.pb.go 文件的初始化方法,其中比较特殊的就是 fileDescriptor 的相关语句,如下:

func init() {
    proto.RegisterType((*HelloRequest)(nil), "helloworld.HelloRequest")
    proto.RegisterType((*HelloReply)(nil), "helloworld.HelloReply")
}

func init() { proto.RegisterFile("proto/helloworld.proto", fileDescriptor_4d53fe9c48eadaad) }

var fileDescriptor_4d53fe9c48eadaad = []byte{
    0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xe2, 0x12, 0x2b, 0x28, 0xca, 0x2f,
    ...
}

实际上我们所看到的 fileDescriptor_4d53fe9c48eadaad 表示的是一个经过编译后的 proto 文件,是对 proto 文件的整体描述, 其包含了 proto 文件名、引用(import)内容、包(package)名、选项设置、所有定义的消息体(message)、 所有定义的枚举(enum)、所有定义的服务( service)、所有定义的方法(rpc method)等等内容, 可以认为就是整个 proto 文件的信息你都能够取到。

同时在我们的每一个 Message Type 中都包含了 Descriptor 方法,Descriptor 代指对一个消息体(message)定义的描述, 而这一个方法则会在 fileDescriptor 中寻找属于自己 Message Field 所在的位置再进行返回,如下:

func (*HelloRequest) Descriptor() ([]byte, []int) {
    return fileDescriptor_4d53fe9c48eadaad, []int{0}
}

func (*HelloReply) Descriptor() ([]byte, []int) {
    return fileDescriptor_4d53fe9c48eadaad, []int{1}
}

接下来我们再往下看可以看到 GreeterClient 接口,因为 Protobuf 是客户端和服务端可共用一份.proto 文件的, 因此除了存在数据描述的信息以外,还会存在客户端和服务端的相关内部调用的接口约束和调用方式的实现, 在后续我们在多服务内部调用的时候会经常用到,如下:

type GreeterClient interface {
    SayHello(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloReply, error)
}

type greeterClient struct {
    cc *grpc.ClientConn
}

func NewGreeterClient(cc *grpc.ClientConn) GreeterClient {
    return &greeterClient{cc}
}

func (c *greeterClient) SayHello(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloReply, error) {
    out := new(HelloReply)
    err := c.cc.Invoke(ctx, "/helloworld.Greeter/SayHello", in, out, opts...)
    if err != nil {
        return nil, err
    }
    return out, nil
}

helloworld.pb.go 文件 完整内容:

// Code generated by protoc-gen-go. DO NOT EDIT.
// source: proto/helloworld.proto

package helloworld

import (
    context "context"
    fmt "fmt"
    proto "github.com/golang/protobuf/proto"
    grpc "google.golang.org/grpc"
    codes "google.golang.org/grpc/codes"
    status "google.golang.org/grpc/status"
    math "math"
)

// Reference imports to suppress errors if they are not otherwise used.
var _ = proto.Marshal
var _ = fmt.Errorf
var _ = math.Inf

// This is a compile-time assertion to ensure that this generated file
// is compatible with the proto package it is being compiled against.
// A compilation error at this line likely means your copy of the
// proto package needs to be updated.
const _ = proto.ProtoPackageIsVersion3 // please upgrade the proto package

type HelloRequest struct {
    Name                 string   `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
    XXX_NoUnkeyedLiteral struct{} `json:"-"`
    XXX_unrecognized     []byte   `json:"-"`
    XXX_sizecache        int32    `json:"-"`
}

func (m *HelloRequest) Reset()         { *m = HelloRequest{} }
func (m *HelloRequest) String() string { return proto.CompactTextString(m) }
func (*HelloRequest) ProtoMessage()    {}
func (*HelloRequest) Descriptor() ([]byte, []int) {
    return fileDescriptor_4d53fe9c48eadaad, []int{0}
}

func (m *HelloRequest) XXX_Unmarshal(b []byte) error {
    return xxx_messageInfo_HelloRequest.Unmarshal(m, b)
}
func (m *HelloRequest) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) {
    return xxx_messageInfo_HelloRequest.Marshal(b, m, deterministic)
}
func (m *HelloRequest) XXX_Merge(src proto.Message) {
    xxx_messageInfo_HelloRequest.Merge(m, src)
}
func (m *HelloRequest) XXX_Size() int {
    return xxx_messageInfo_HelloRequest.Size(m)
}
func (m *HelloRequest) XXX_DiscardUnknown() {
    xxx_messageInfo_HelloRequest.DiscardUnknown(m)
}

var xxx_messageInfo_HelloRequest proto.InternalMessageInfo

func (m *HelloRequest) GetName() string {
    if m != nil {
        return m.Name
    }
    return ""
}

type HelloReply struct {
    Message              string   `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"`
    XXX_NoUnkeyedLiteral struct{} `json:"-"`
    XXX_unrecognized     []byte   `json:"-"`
    XXX_sizecache        int32    `json:"-"`
}

func (m *HelloReply) Reset()         { *m = HelloReply{} }
func (m *HelloReply) String() string { return proto.CompactTextString(m) }
func (*HelloReply) ProtoMessage()    {}
func (*HelloReply) Descriptor() ([]byte, []int) {
    return fileDescriptor_4d53fe9c48eadaad, []int{1}
}

func (m *HelloReply) XXX_Unmarshal(b []byte) error {
    return xxx_messageInfo_HelloReply.Unmarshal(m, b)
}
func (m *HelloReply) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) {
    return xxx_messageInfo_HelloReply.Marshal(b, m, deterministic)
}
func (m *HelloReply) XXX_Merge(src proto.Message) {
    xxx_messageInfo_HelloReply.Merge(m, src)
}
func (m *HelloReply) XXX_Size() int {
    return xxx_messageInfo_HelloReply.Size(m)
}
func (m *HelloReply) XXX_DiscardUnknown() {
    xxx_messageInfo_HelloReply.DiscardUnknown(m)
}

var xxx_messageInfo_HelloReply proto.InternalMessageInfo

func (m *HelloReply) GetMessage() string {
    if m != nil {
        return m.Message
    }
    return ""
}

func init() {
    proto.RegisterType((*HelloRequest)(nil), "helloworld.HelloRequest")
    proto.RegisterType((*HelloReply)(nil), "helloworld.HelloReply")
}

func init() { proto.RegisterFile("proto/helloworld.proto", fileDescriptor_4d53fe9c48eadaad) }

var fileDescriptor_4d53fe9c48eadaad = []byte{
    // 144 bytes of a gzipped FileDescriptorProto
    0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xe2, 0x12, 0x2b, 0x28, 0xca, 0x2f,
    0xc9, 0xd7, 0xcf, 0x48, 0xcd, 0xc9, 0xc9, 0x2f, 0xcf, 0x2f, 0xca, 0x49, 0xd1, 0x03, 0x0b, 0x08,
    0x71, 0x21, 0x44, 0x94, 0x94, 0xb8, 0x78, 0x3c, 0x40, 0xbc, 0xa0, 0xd4, 0xc2, 0xd2, 0xd4, 0xe2,
    0x12, 0x21, 0x21, 0x2e, 0x96, 0xbc, 0xc4, 0xdc, 0x54, 0x09, 0x46, 0x05, 0x46, 0x0d, 0xce, 0x20,
    0x30, 0x5b, 0x49, 0x8d, 0x8b, 0x0b, 0xaa, 0xa6, 0x20, 0xa7, 0x52, 0x48, 0x82, 0x8b, 0x3d, 0x37,
    0xb5, 0xb8, 0x38, 0x31, 0x1d, 0xa6, 0x08, 0xc6, 0x35, 0xf2, 0xe4, 0x62, 0x77, 0x2f, 0x4a, 0x4d,
    0x2d, 0x49, 0x2d, 0x12, 0xb2, 0xe3, 0xe2, 0x08, 0x4e, 0xac, 0x04, 0xeb, 0x12, 0x92, 0xd0, 0x43,
    0x72, 0x01, 0xb2, 0x65, 0x52, 0x62, 0x58, 0x64, 0x0a, 0x72, 0x2a, 0x95, 0x18, 0x92, 0xd8, 0xc0,
    0x2e, 0x35, 0x06, 0x04, 0x00, 0x00, 0xff, 0xff, 0xb8, 0x68, 0x51, 0x79, 0xc3, 0x00, 0x00, 0x00,
}

// Reference imports to suppress errors if they are not otherwise used.
var _ context.Context
var _ grpc.ClientConn

// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
const _ = grpc.SupportPackageIsVersion4

// GreeterClient is the client API for Greeter service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://godoc.org/google.golang.org/grpc#ClientConn.NewStream.
type GreeterClient interface {
    SayHello(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloReply, error)
}

type greeterClient struct {
    cc *grpc.ClientConn
}

func NewGreeterClient(cc *grpc.ClientConn) GreeterClient {
    return &greeterClient{cc}
}

func (c *greeterClient) SayHello(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloReply, error) {
    out := new(HelloReply)
    err := c.cc.Invoke(ctx, "/helloworld.Greeter/SayHello", in, out, opts...)
    if err != nil {
        return nil, err
    }
    return out, nil
}

// GreeterServer is the server API for Greeter service.
type GreeterServer interface {
    SayHello(context.Context, *HelloRequest) (*HelloReply, error)
}

// UnimplementedGreeterServer can be embedded to have forward compatible implementations.
type UnimplementedGreeterServer struct {
}

func (*UnimplementedGreeterServer) SayHello(ctx context.Context, req *HelloRequest) (*HelloReply, error) {
    return nil, status.Errorf(codes.Unimplemented, "method SayHello not implemented")
}

func RegisterGreeterServer(s *grpc.Server, srv GreeterServer) {
    s.RegisterService(&_Greeter_serviceDesc, srv)
}

func _Greeter_SayHello_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
    in := new(HelloRequest)
    if err := dec(in); err != nil {
        return nil, err
    }
    if interceptor == nil {
        return srv.(GreeterServer).SayHello(ctx, in)
    }
    info := &grpc.UnaryServerInfo{
        Server:     srv,
        FullMethod: "/helloworld.Greeter/SayHello",
    }
    handler := func(ctx context.Context, req interface{}) (interface{}, error) {
        return srv.(GreeterServer).SayHello(ctx, req.(*HelloRequest))
    }
    return interceptor(ctx, in, info, handler)
}

var _Greeter_serviceDesc = grpc.ServiceDesc{
    ServiceName: "helloworld.Greeter",
    HandlerType: (*GreeterServer)(nil),
    Methods: []grpc.MethodDesc{
        {
            MethodName: "SayHello",
            Handler:    _Greeter_SayHello_Handler,
        },
    },
    Streams:  []grpc.StreamDesc{},
    Metadata: "proto/helloworld.proto",
}

3.2.4 更多的类型支持

在前面的例子中,我们已经大致了解到 Protobuf 的基本使用和内部情况,而其本身也支持了很多的数据类型, 在本节我们将挑选一些常用的类型进行讲解。

3.2.4.1 通用类型

在 Protobuf 中一共支持 double、float、int32、int64、uint32、uint64、sint32、sint64、fixed32、fixed64、 sfixed32、sfixed64、bool、string、bytes 类型,例如一开始使用的是字符串类型,当然你也可以根据实际情况,修改成上述类型,例如:

message HelloRequest {
    bytes name = 1;
}

另外我们常常会遇到需要传递动态数组的情况,在 protobuf 中,我们可以使用 repeated 关键字,如果一个字段被声明为 repeated, 那么该字段可以重复任意次(包括零次),重复值的顺序将保留在 protobuf 中,将重复字段视为动态大小的数组,如下:

message HelloRequest {
    repeated string name = 1;
}

3.2.4.2 嵌套类型

嵌套类型,也就是字面意思,在 message 消息体中,又嵌套了其它的 message 消息体,一共有两种模式,如下:

message HelloRequest {
    message World {
        string name = 1;
    }
    
    repeated World worlds = 1;
}

第一种是将 World 消息体定义在 HelloRequest 消息体中,也就是其归属在消息体 HelloRequest 下, 若要调用则需要使用 HelloRequest.World 的方式,外部才能引用成功。

第二种是将 World 消息体定义在外部,一般比较推荐使用这种方式,清晰、方便,如下:

message World {
    string name = 1;
}

message HelloRequest {
    repeated World worlds = 1;
}

3.2.4.3 Oneof

如果你希望你的消息体可以包含多个字段,但前提条件是最多同时只允许设置一个字段,那么就可以使用 oneof 关键字来实现这个功能,如下:

message HelloRequest {
    oneof name {
        string nick_name = 1;
        string true_name = 2;
    }
}

3.2.4.4 Enum

枚举类型,限定你所传入的字段值必须是预定义的值列表之一,如下:

enum NameType {
    NickName = 0;
    TrueName = 1;
}

message HelloRequest {
    string name = 1;
    NameType nameType = 2;
}

3.2.4.5 Map

map 类型,需要设置键和值的类型,格式为 map<key_type, value_type> map_field = N; ,示例如下:

message HelloRequest {
    map<string, string> names = 2;
}

3.2.5 小结

在本章节中,我们对 Protobuf 进行了更详细的使用和说明,我们可得知.proto 文件需要通过 Protobuf 的编译器 protoc 来编译后才能够使用, 而在各个语言的具体插件实现中,protoc-gen-go 是 protoc 中针对 Go 语言的 protoc plugin,它们是相对隔离且解耦的, 因此在未来我们也可以自己实现一个 protoc plugin,针对企业内部的定制化需求非常的方便。

另外在 Protobuf 的类型使用上,其支持大量的类型,我在上文中只列举了在应用开发中常见的,大家可以根据实际需求再进一步的了解。

3.3 gRPC 的使用和了解

3.3.1 安装

我们在 grpc-demo 项目下,在命令行执行 Go 语言的 gRPC 库的安装命令,如下:

$ go get -u google.golang.org/grpc@v1.29.1

3.3.2 gRPC 的四种调用方式

在 gRPC 中,一共包含四种调用方式,分别是:

  1. Unary RPC:一元 RPC。
  2. Server-side streaming RPC:服务端流式 RPC。
  3. Client-side streaming RPC:客户端流式 RPC。
  4. Bidirectional streaming RPC:双向流式 RPC。

不同的调用方式往往代表着不同的应用场景,我们接下来将一同深入了解各个调用方式的实现和使用场景, 在下述代码中,我们统一将项目下的 proto 引用名指定为 pb,并设置端口号都由外部传入,如下:

import (
    ...
    // 设置引用别名
    pb "github.com/go-programming-tour-book/grpc-demo/proto"
)

var port string

func init() {
    flag.StringVar(&port, "p", "8000", "启动端口号")
    flag.Parse()
}

我们下述的调用方法都是在 server 目录下的 server.go 和 client 目录的 client.go 中完成, 需要注意的该两个文件的 package 名称应该为 main(IDE 默认会创建与目录名一致的 package 名称), 这样子你的 main 方法才能够被调用,并且在本章中我们的 proto 引用都会以引用别名 pb 来进行调用。

另外我们在每个调用方式的 Proto 小节都会给出该类型 RPC 方法的 Proto 定义, 请注意自行新增并在项目根目录执行重新编译生成语句,如下:

$ protoc --go_out=plugins=grpc:. ./proto/*.proto
3.3.2.1 Unary RPC:一元 RPC

一元 RPC,也就是是单次 RPC 调用,简单来讲就是客户端发起一次普通的 RPC 请求,响应,是最基础的调用类型,也是最常用的方式。

3.3.2.1.1 Proto

syntax = "proto3";

package UnaryRPC;

service Greeter {
    rpc SayHello (HelloRequest) returns (HelloReply) {};
}

message HelloRequest {
    string name = 1;
}

message HelloReply {
    string message = 1;
}

3.3.2.1.2 Server

package main

import (
    "context"
    "flag"
    "net"
    pb "github.com/go-programming-tour-book/grpc-demo/proto"
    "google.golang.org/grpc"
)

var port string

func init() {
    flag.StringVar(&port, "p", "8000", "启动端口号")
    flag.Parse()
}

type GreeterServer struct{}

func (s *GreeterServer) SayHello(ctx context.Context, r *pb.HelloRequest) (*pb.HelloReply, error) {
    return &pb.HelloReply{Message: "hello.world"}, nil
}

func main() {
    server := grpc.NewServer()
    pb.RegisterGreeterServer(server, &GreeterServer{})
    lis, _ := net.Listen("tcp", ":"+port)
    server.Serve(lis)
}
  • 创建 gRPC Server 对象,你可以理解为它是 Server 端的抽象对象。
  • 将 GreeterServer(其包含需要被调用的服务端接口)注册到 gRPC Server 的内部注册中心。这样可以在接受到请求时, 通过内部的 “服务发现”,发现该服务端接口并转接进行逻辑处理。
  • 创建 Listen,监听 TCP 端口。
  • gRPC Server 开始 lis.Accept,直到 Stop 或 GracefulStop。

3.3.2.1.3 Client

package main

import (
    "flag"
    "log"
    "context"
    pb "github.com/go-programming-tour-book/grpc-demo/proto"
    "google.golang.org/grpc"
)

var port string

func init() {
    flag.StringVar(&port, "p", "8000", "启动端口号")
    flag.Parse()
}

func main() {
    conn, _ := grpc.Dial(":"+port, grpc.WithInsecure())
    defer conn.Close()

    client := pb.NewGreeterClient(conn)
    _ = DoSayHello(client)  // DoSayHello方法无特定含义,可以为任意名称
}

func DoSayHello(client pb.GreeterClient) error {
    resp, _ := client.SayHello(context.Background(), &pb.HelloRequest{Name: "eddycjy"})
    log.Printf("client.SayHello resp: %s", resp.Message)
    return nil
}
  • 创建与给定目标(服务端)的连接句柄。
  • 创建 Greeter 的客户端对象。
  • 发送 RPC 请求,等待同步响应,得到回调后返回响应结果。
3.3.2.2 Server-side streaming RPC:服务端流式 RPC

服务器端流式 RPC,也就是是单向流,并代指 Server 为 Stream,Client 为普通的一元 RPC 请求。

简单来讲就是客户端发起一次普通的 RPC 请求,服务端通过流式响应多次发送数据集,客户端 Recv 接收数据集。

3.3.2.2.1 Proto

service Greeter {
    rpc SayList (HelloRequest) returns (stream HelloReply) {};
}

3.3.2.2.2 Server

type GreeterServer struct{}

func (s *GreeterServer) SayList(r *pb.HelloRequest, stream pb.Greeter_SayListServer) error {
    for n := 0; n <= 6; n++ {
        _ = stream.Send(&pb.HelloReply{Message: "hello.list"})
    }

    return nil
}

func main() {
    server := grpc.NewServer()
    pb.RegisterGreeterServer(server, &GreeterServer{})
    lis, _ := net.Listen("tcp", ":"+port)
    server.Serve(lis)
}

在 Server 端,主要留意 stream.Send 方法,通过阅读源码,可得知是 protoc 在生成时, 根据定义生成了各式各样符合标准的接口方法。最终再统一调度内部的 SendMsg 方法,该方法涉及以下过程:

  • 消息体(对象)序列化。
  • 压缩序列化后的消息体。
  • 对正在传输的消息体增加 5 个字节的 header(标志位)。
  • 判断压缩 + 序列化后的消息体总字节长度是否大于预设的 maxSendMessageSize(预设值为 math.MaxInt32),若超出则提示错误。
  • 写入给流的数据集。

3.3.2.2.3 Client

func main() {
    conn, _ := grpc.Dial(":"+port, grpc.WithInsecure())
    defer conn.Close()

    client := pb.NewGreeterClient(conn)
    r := pb.HelloRequest{Name: "abcdef"}
    _ = ToSayList(client, &r)
}

func ToSayList(client pb.GreeterClient, r *pb.HelloRequest) error {
    stream, _ := client.SayList(context.Background(), r)
    for {
        resp, err := stream.Recv()
        if err == io.EOF {
            break
        }
        if err != nil {
            return err
        }

        log.Printf("resp: %v", resp)
    }

    return nil
}

在 Client 端,主要留意 stream.Recv() 方法,我们可以思考一下,什么情况下会出现 io.EOF ,又在什么情况下会出现错误信息呢? 实际上 stream.Recv 方法,是对 ClientStream.RecvMsg 方法的封装,而 RecvMsg 方法会从流中读取完整的 gRPC 消息体,我们可得知:

  • RecvMsg 是阻塞等待的。
  • RecvMsg 当流成功/结束(调用了 Close)时,会返回 io.EOF。
  • RecvMsg 当流出现任何错误时,流会被中止,错误信息会包含 RPC 错误码。而在 RecvMsg 中可能出现如下错误,例如:
    1. io.EOF、io.ErrUnexpectedEOF
    2. transport.ConnectionError
    3. google.golang.org/grpc/codes(gRPC 的预定义错误码)

需要注意的是,默认的 MaxReceiveMessageSize 值为 1024 *1024* 4,若有特别需求,可以适当调整。

3.3.2.4 Client-side streaming RPC:客户端流式 RPC

客户端流式 RPC,单向流,客户端通过流式发起多次 RPC 请求给服务端,服务端发起一次响应给客户端。

3.3.2.4.1 Proto

service Greeter {
    rpc SayRecord(stream HelloRequest) returns (HelloReply) {};
}

3.3.2.4.2 Server

type GreeterServer struct{}

func (s *GreeterServer) SayRecord(stream pb.Greeter_SayRecordServer) error {
    for {
        resp, err := stream.Recv()
        if err == io.EOF {
            return stream.SendAndClose(&pb.HelloReply{Message: "say.record"})
        }
        if err != nil {
            return err
        }

        log.Printf("resp: %v", resp)
    }

    return nil
}

func main() {
    server := grpc.NewServer()
    pb.RegisterGreeterServer(server, &GreeterServer{})
    lis, _ := net.Listen("tcp", ":"+port)
    server.Serve(lis)
}

你可以发现在这段程序中,我们对每一个 Recv 都进行了处理,当发现 io.EOF (流关闭) 后, 需要通过 stream.SendAndClose 方法将最终的响应结果发送给客户端,同时关闭正在另外一侧等待的 Recv。

3.3.2.4.3 Client

func main() {
    conn, _ := grpc.Dial(":"+port, grpc.WithInsecure())
    defer conn.Close()

    client := pb.NewGreeterClient(conn)
    r := pb.HelloRequest{Name: "abcdef"}
    _ = DoSayRecord(client, &r)
}

func DoSayRecord(client pb.GreeterClient, r *pb.HelloRequest) error {
    stream, _ := client.SayRecord(context.Background())
    for n := 0; n < 6; n++ {
        _ = stream.Send(r)
    }
    resp, _ := stream.CloseAndRecv()

    log.Printf("resp err: %v", resp)
    return nil
}

在 Server 端的 stream.CloseAndRecv,与 Client 端 stream.SendAndClose 是配套使用的方法。

3.3.2.5 Bidirectional streaming RPC:双向流式 RPC

双向流式 RPC,顾名思义是双向流,由客户端以流式的方式发起请求,服务端同样以流式的方式响应请求。

首个请求一定是 Client 发起,但具体交互方式(谁先谁后、一次发多少、响应多少、什么时候关闭)根据程序编写的方式来确定(可以结合协程)。

假设该双向流是按顺序发送的话。

3.3.2.5.1 Proto

service Greeter {
    rpc SayRoute(stream HelloRequest) returns (stream HelloReply) {};
}

3.3.2.5.2 Server

type GreeterServer struct{}

func (s *GreeterServer) SayRoute(stream pb.Greeter_SayRouteServer) error {
    n := 0
    for {
        _ = stream.Send(&pb.HelloReply{Message: "say.route"})

        resp, err := stream.Recv()
        if err == io.EOF {
            return nil
        }
        if err != nil {
            return err
        }

        n++
        log.Printf("resp: %v", resp)
    }
}

func main() {
    server := grpc.NewServer()
    pb.RegisterGreeterServer(server, &GreeterServer{})
    lis, _ := net.Listen("tcp", ":"+port)
    server.Serve(lis)
}

3.3.2.5.3 Client

func main() {
    conn, _ := grpc.Dial(":"+port, grpc.WithInsecure())
    defer conn.Close()

    client := pb.NewGreeterClient(conn)
    r := pb.HelloRequest{Name: "abcdef"}
    _ = DoSayRoute(client, &r)
}

func DoSayRoute(client pb.GreeterClient, r *pb.HelloRequest) error {
    stream, _ := client.SayRoute(context.Background())
    for n := 0; n <= 6; n++ {
        _ = stream.Send(r)
        resp, err := stream.Recv()
        if err == io.EOF {
            break
        }
        if err != nil {
            return err
        }

        log.Printf("resp err: %v", resp)
    }

    _ = stream.CloseSend()

    return nil
}

3.3.3 思考 Unary 和 Streaming RPC

3.3.3.1 为什么不用 Unary RPC

StreamingRPC 为什么要存在呢,是 Unary RPC 有什么问题吗,通过模拟业务场景,可得知在使用 Unary RPC 时,有如下问题:

  • 在一些业务场景下,数据包过大,可能会造成瞬时压力。
  • 接收数据包时,需要所有数据包都接受成功且正确后,才能够回调响应,进行业务处理(无法客户端边发送,服务端边处理)。

3.3.3.2 为什么用 Streaming RPC

  • 持续且大数据包场景。
  • 实时交互场景。

3.3.3.3 思考模拟场景

每天早上 6 点,都有一批百万级别的数据集要同从 A 同步到 B,在同步的时候, 会做一系列操作(归档、数据分析、画像、日志等),这一次性涉及的数据量确实大。

在同步完成后,也有人马上会去查阅数据,为了新的一天筹备。也符合实时性。 在仅允许使用 Unary 或 StreamingRPC 的情况下,两者相较下,这个场景下更适合使用 Streaming RPC。

3.3.4 Client 与 Server 是如何交互的

刚刚我们对 gRPC 的四种调用方式进行了探讨,但光会用还是不够的,知其然知其所然很重要, 因此我们需要对 gRPC 的整体调用流转有一个基本印象,那么最简单的方式就是对 Client 端调用 Server 端进行抓包去剖析, 看看整个过程中它都做了些什么事。

我们另外启动了一个测试用的后端 gRPC 服务,它的监听端口号为 10001,然后我们使用一个 gRPC 客户端用一元 RPC 来调用它, 查看抓包情况如下:

我们略加整理发现共有十二个行为,从上到下分别是 Magic、SETTINGS、HEADERS、DATA、SETTINGS、WINDOW_UPDATE、PING、 HEADERS、DATA、HEADERS、WINDOW_UPDATE、PING 是比较重要的。

接下来我们将针对每个行为进行分析,而在开始分析之前,我希望你自行思考一下,它们的作用都是什么, 大胆猜测一下,带着疑问去学习效果更佳。

3.3.4.1 行为分析

3.3.4.1.1 Magic

Magic 帧的主要作用是建立 HTTP/2 请求的前言。在 HTTP/2 中,要求两端都要发送一个连接前言,作为对所使用协议的最终确认, 并确定 HTTP/2 连接的初始设置,客户端和服务端各自发送不同的连接前言。

而上图中的 Magic 帧是客户端的前言之一,内容为 PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n,以确定启用 HTTP/2 连接。

3.3.4.1.2 SETTINGS

SETTINGS 帧的主要作用是设置这一个连接的参数,作用域是整个连接而并非单一的流。

而上图的 SETTINGS 帧都是空 SETTINGS 帧,图一是客户端连接的前言(Magic 和 SETTINGS 帧分别组成连接前言)。 图二是服务端的。另外我们从图中可以看到多个 SETTINGS 帧,这是为什么呢?是因为发送完连接前后, 客户端和服务端还需要有一步互动确认的动作。对应的就是带有 ACK 标识 SETTINGS 帧。

3.3.4.1.3 HEADERS

HEADERS 帧的主要作用是存储和传播 HTTP 的标头信息。我们关注到 HEADERS 里有一些眼熟的信息,分别如下:

method:POST
scheme:http
path:/proto.SearchService/Search
authority::10001
content-type:application/grpc
user-agent:grpc-go/1.20.0-dev

你会发现这些东西非常眼熟,其实都是 gRPC 的基础属性,实际上远远不止这些,只是设置了多少展示多少。 例如像平时常见的 grpc-timeout、grpc-encoding 也是在这里设置的。

3.3.4.1.4 DATA

DATA 帧的主要作用是装填主体信息,是数据帧。而在上图中,可以很明显看到我们的请求参数 gRPC 存储在里面。只需要了解到这一点就可以了。

3.3.4.1.5 HEADERS, DATA, HEADERS

在上图中 HEADERS 帧比较简单,就是告诉我们 HTTP 响应状态和响应的内容格式。

在上图中 DATA 帧主要承载了响应结果的数据集,图中的 gRPC Server 就是我们 RPC 方法的响应结果。

在上图中 HEADERS 帧主要承载了 gRPC 的状态信息,对应图中的 grpc-status 和 grpc-message 就是我们本次 gRPC 调用状态的结果。

3.3.4.2 其它步骤

3.3.4.2.1 WINDOW_UPDATE

主要作用是管理和流的窗口控制。通常情况下打开一个连接后,服务器和客户端会立即交换 SETTINGS 帧来确定流控制窗口的大小。 默认情况下,该大小设置为约 65 KB,但可通过发出一个 WINDOW_UPDATE 帧为流控制设置不同的大小。

3.3.4.2.2 PING/PONG

主要作用是判断当前连接是否仍然可用,也常用于计算往返时间。其实也就是 PING/PONG,大家对此应该很熟。

3.3.4.3 小结

在本章节中,我们对于 gRPC 的基本使用和交互原理进行了一个简单剖析,我们总结如下:

  • gRPC 一共支持四种调用方式,分别是:

      Unary RPC:一元 RPC。
      Server-side streaming RPC:服务端流式 RPC。
      Client-side streaming RPC:客户端流式 RPC。
      Bidirectional streaming RPC:双向流式 RPC。
    
  • gRPC 在建立连接之前,客户端/服务端都会发送连接前言(Magic+SETTINGS),确立协议和配置项。

  • gRPC 在传输数据时,是会涉及滑动窗口(WINDOW_UPDATE)等流控策略的。

  • 传播 gRPC 附加信息时,是基于 HEADERS 帧进行传播和设置;而具体的请求/响应数据是存储的 DATA 帧中的。

  • gRPC 请求/响应结果会分为 HTTP 和 gRPC 状态响应(grpc-status、grpc-message)两种类型。

  • 客户端发起 PING,服务端就会回应 PONG,反之亦可。

3.4 运行一个 gRPC 服务

在了解了 gRPC 和 Protobuf 的具体使用和情况后,我们将结合常见的应用场景,完成一个 gRPC 服务。 而为了防止重复用工,这一个 gRPC 服务将会直接通过 HTTP 调用我们上一章节的博客后端服务,以此来获得标签列表的业务数据, 我们只需要把主要的精力集中在 gRPC 服务相关联的知识上就可以了,同时后续的数个章节知识点的开展都会围绕着这个服务来进行。

3.4.1 初始化项目

$ mkdir -p $HOME/go-programming-tour-book/tag-service
$ cd $HOME/go-programming-tour-book/tag-service
$ go mod init github.com/go-programming-tour-book/tag-service

并创建以下子级目录,便于后续章节的使用,最终的目录结构如下:

tag-service
├── main.go
├── go.mod
├── go.sum
├── pkg
├── internal
├── proto
├── server
└── third_party

完成项目基础目录的创建后,在项目根目录执行 grpc 的安装命令。

go get -u google.golang.org/grpc@v1.29.1

3.4.2 编译和生成 proto 文件

在正式的开始编写服务前,我们需要先编写对应的 RPC 方法所需的 proto 文件,这是我们日常要先做的事情之一, 因此接下来我们开始进行公共 proto 的编写,在项目的 proto 目录下新建 common.proto 文件,写入如下代码:

syntax = "proto3";

package proto;

message Pager {
    int64 page = 1;
    int64 page_size = 2;
    int64 total_rows = 3;
}

接着再编写获取标签列表的 RPC 方法,我们继续新建 tag.proto 文件,写入如下代码:

syntax = "proto3";

package proto;

import "proto/common.proto";

service TagService {
    rpc GetTagList (GetTagListRequest) returns (GetTagListReply) {}
}

message GetTagListRequest {
    string name = 1;
    uint32 state = 2;
}

message Tag {
    int64 id = 1;
    string name = 2;
    uint32 state = 3;
}

message GetTagListReply {
    repeated Tag list = 1;
    Pager pager = 2;
}

在上述 proto 代码中,我们引入了公共文件 common.proto,并依据先前博客后端服务一致的数据结构定义了 RPC 方法, 完成后我们就可以编译和生成 proto 文件,在项目根目录下执行如下命令:

$ protoc --go_out=plugins=grpc:. ./proto/*.proto 

需要注意的一点是,我们在 tag.proto 文件中 import 了 common.proto,因此在执行 protoc 命令生成时, 如果你只执行命令 protoc --go_out=plugins=grpc:. ./proto/tag.proto 是会存在问题的。

因此建议若所需生成的 proto 文件和所依赖的 proto 文件都在同一目录下,可以直接执行 ./proto/*.proto 命令来解决, 又或是指定所有含关联的 proto 引用 ./proto/common.proto ./proto/tag.proto ,这样子就可以成功生成.pb.go 文件, 并且避免了很多的编译麻烦。

但若实在是存在多层级目录的情况,可以利用 protoc 命令的 -I 和 M 指令来进行特定处理。

3.4.3 编写 gRPC 方法

3.4.3.1 获取博客 API 的数据

由于我们的数据源是第二章节的博客后端,因此我们需要编写一个最简单的 API SDK 去进行调用, 我们在项目的 pkg 目录新建 bapi 目录,并创建 api.go 文件,写入如下代码:

const (
    APP_KEY    = "eddycjy"
    APP_SECRET = "go-programming-tour-book"
)

type AccessToken struct {
    Token string `json:"token"`
}

func (a *API) getAccessToken(ctx context.Context) (string, error) {
    body, err := a.httpGet(ctx, fmt.Sprintf("%s?app_key=%s&app_secret=%s", "auth", APP_KEY, APP_SECRET))
    if err != nil {
        return "", err
    }

    var accessToken AccessToken
    _ = json.Unmarshal(body, &accessToken)
    return accessToken.Token, nil
}

func (a *API) httpGet(ctx context.Context, path string) ([]byte, error) {
    resp, err := http.Get(fmt.Sprintf("%s/%s", a.URL, path))
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    body, _ := ioutil.ReadAll(resp.Body)
    return body, nil
}

首先我们编写了两个主要方法,分别是 API SDK 统一的 HTTP GET 的请求方法, 以及所有 API 请求都需要带上的 AccessToken 的获取,接下来就是具体的获取标签列表的方法编写,如下:

type API struct {
    URL string
}

func NewAPI(url string) *API {
    return &API{URL: url}
}

func (a *API) GetTagList(ctx context.Context, name string) ([]byte, error) {
    token, err := a.getAccessToken(ctx)
    if err != nil {
        return nil, err
    }

    body, err := a.httpGet(ctx, fmt.Sprintf("%s?token=%s&name=%s", "api/v1/tags", token, name))
    if err != nil {
        return nil, err
    }

    return body, nil
}

上述代码主要是实现从第二章的博客后端中获取 AccessToken 和完成各类数据源的接口编写,并不是本章节的重点, 因此只进行了简单实现,若有兴趣可以进一步的实现 AccessToken 的缓存和刷新,以及多 HTTP Method 的接口调用等等。

3.4.3.2 编写 gRPC Server

在完成了 API SDK 的编写后,我们在项目的 server 目录下创建 tag.go 文件,针对获取标签列表的接口逻辑进行编写,如下:

import (
    pb "github.com/go-programming-tour-book/tag-service/proto"
    ...
)

type TagServer struct {}

func NewTagServer() *TagServer {
    return &TagServer{}
}

func (t *TagServer) GetTagList(ctx context.Context, r *pb.GetTagListRequest) (*pb.GetTagListReply, error) {
    api := bapi.NewAPI("http://127.0.0.1:8000")
    body, err := api.GetTagList(ctx, r.GetName())
    if err != nil {
        return nil, err
    }

    tagList := pb.GetTagListReply{}
    err = json.Unmarshal(body, &tagList)
    if err != nil {
        return nil, errcode.TogRPCError(errcode.Fail)
    }

    return &tagList, nil
}

在上述代码中,我们主要是指定了博客后端的服务地址(http://127.0.0.1:8000),然后调用 GetTagList 方法的 API, 通过 HTTP 调用到第二章节所编写的博客后端服务获取标签列表数据,然后利用 json.Unmarshal 的特性,将其直接转换,并返回。

3.4.4 编写启动文件

我们在项目根目录下创建 main.go 文件,写入如下启动逻辑:

func main() {
    s := grpc.NewServer()
    pb.RegisterTagServiceServer(s, server.NewTagServer())

    var port string = "8001"   // 不能与blog服务的端口一致,这是另外一个服务
    lis, err := net.Listen("tcp", ":"+port)
    if err != nil {
        log.Fatalf("net.Listen err: %v", err)
    }

    err = s.Serve(lis)
    if err != nil {
        log.Fatalf("server.Serve err: %v", err)
    }
}

至此,我们一个简单的标签服务就完成了,它将承担我们整个篇章的研讨功能。 接下来我们在项目根目录下执行 go run main.go 命令,启动这个服务,检查是否一切是否正常,

3.4.5 调试 gRPC 接口

在服务启动后,我们除了要验证服务是否正常运行,还要调试或验证 RPC 方法是否运行正常,而 gRPC 是基于 HTTP/2 协议的, 因此不像普通的 HTTP/1.1 接口可以直接通过 postman 或普通的 curl 进行调用。但目前开源社区也有一些方案, 例如像 grpcurl,grpcurl 是一个命令行工具,可让你与 gRPC 服务器进行交互,安装命令如下:

$ go get github.com/fullstorydev/grpcurl
$ go install github.com/fullstorydev/grpcurl/cmd/grpcurl

在win10上安装grpcurl会出问题,可以直接到目录 G:\GoPath\pkg\mod\github.com\fullstorydev\grpcurl@v1.8.7\cmd\grpcurl 下, 修改 grpcurl.go 文件名为 main.go, 然后执行 go build .,把生成的 grpcurl.exe 移动到目录 G:\GoPath\bin 下, 别忘了把文件名修改回来,修改 main.go 文件名为 grpcurl.go

但使用该工具的前提是 gRPC Server 已经注册了反射服务,因此我们需要修改上述服务的启动文件,如下:

import (
    "google.golang.org/grpc/reflection"
    ...
)

func main() {
    s := grpc.NewServer()
    pb.RegisterTagServiceServer(s, server.NewTagServer())
    reflection.Register(s)
    ...
}

reflection 包是 gRPC 官方所提供的反射服务,我们在启动文件新增了 reflection.Register 方法的调用后,我们需要重新启动服务,反射服务才可用。

接下来我们就可以借助 grpcurl 工具进行调试了,一般我们可以首先执行下述 list 命令:

$ grpcurl -plaintext localhost:8001 list
grpc.reflection.v1alpha.ServerReflection
proto.TagService

$ grpcurl -plaintext localhost:8001 list proto.TagService
proto.TagService.GetTagList

我们一共指定了三个选项,分别是:

  • plaintext:grpcurl 工具默认使用 TLS 认证(可通过 -cert 和 -key 参数设置公钥和密钥), 但由于我们的服务是非 TLS 认证的,因此我们需要通过指定这个选项来忽略 TLS 认证。
  • localhost:8001:指定我们运行的服务 HOST。
  • list:指定所执行的命令,list 子命令可获取该服务的 RPC 方法列表信息。例如上述的输出结果, 一共有两个方法,一个是注册的反射方法,一个是我们自定义的 RPC Service 方法, 因此可以更进一步的执行命令 grpcurl -plaintext localhost:8001 list proto.TagService 查看其子类的 RPC 方法信息。

在了解该服务具体有什么 RPC 方法后,我们可以执行下述命令去调用 RPC 方法:

$ grpcurl -plaintext -d '{"name":"Go"}' localhost:8001 proto.TagService.GetTagList  
{
  "list": [
    {
      "id": "1",
      "name": "Go",
      "state": 1
    }
  ],
  "pager": {
    "page": "1",
    "pageSize": "10",
    "totalRows": "1"
  }
}

在这里我们使用到了 grpcurl 工具的-d 选项,其输入的内容必须为 JSON 格式,该内容将被解析, 最终以 protobuf 二进制格式传输到 gRPC Server,你可以简单理解为 RPC 方法的入参信息,也可以不传,不指定-d 选项即可。

3.4.6 gRPC 错误处理

在项目的实际运行中,常常会有各种奇奇怪怪的问题触发,也就是要返回错误的情况,在这里我们可以将我们的数据源, 也就是博客的后端服务停掉,再利用工具重新请求,看看报什么错误,如下:

$ grpcurl -plaintext localhost:8001 proto.TagService.GetTagList 
ERROR:
  Code: Unknown
  Message: Get http://127.0.0.1:8000/api/v1/tags?name=: dial tcp 127.0.0.1:8000: connect: connection refused

你会发现其返回的字段分为两个,一个是 Code,另外一个是 Message, 也就是对应着我们前文提到 grpc-status 和 grpc-message 两个字段,它们共同代表着我们 gRPC 的整体调用情况。

3.4.6.1 gRPC 状态码

那我们更细致来看,这些 gRPC 的内部状态又分别有哪些呢,目前官方给出的全部状态响应码如下:

Code     Status     Notes
0     OK     成功
1     CANCELLED     该操作被调用方取消
2     UNKNOWN     未知错误
3     INVALID_ARGUMENT     无效的参数
4     DEADLINE_EXCEEDED     在操作完成之前超过了约定的最后期限。
5     NOT_FOUND     找不到
6     ALREADY_EXISTS     已经存在
7     PERMISSION_DENIED     权限不足
8     RESOURCE_EXHAUSTED     资源耗尽
9     FAILED_PRECONDITION     该操作被拒绝,因为未处于执行该操作所需的状态
10     ABORTED     该操作被中止
11     OUT_OF_RANGE     超出范围,尝试执行的操作超出了约定的有效范围
12     UNIMPLEMENTED     未实现
13     INTERNAL     内部错误
14     UNAVAILABLE     该服务当前不可用。
15     DATA_LOSS     不可恢复的数据丢失或损坏。

那么对应在我们刚刚的调用结果,状态码是 UNKNOWN,这是为什么呢,我们可以查看底层的处理源码,如下:

func FromError(err error) (s *Status, ok bool) {
    ...
    if se, ok := err.(interface {
        GRPCStatus() *Status
    }); ok {
        return se.GRPCStatus(), true
    }
    return New(codes.Unknown, err.Error()), false
}

我们可以看到,实际上若不是 GRPCStatus 类型的方法,都是默认返回 codes.Unknown,也就是未知。而我们目前的报错, 实际上是直接返回 return err 的,那么 gRPC 内部当然不认得,那我们可以怎么处理呢,实际上只要跟着内部规范实现即可。

3.4.6.2 错误码处理

我们在项目的 pkg 目录下新建 errcode 目录,并创建 errcode.go 文件,写入如下方法:

type Error struct {
    code int
    msg  string
}

var _codes = map[int]string{}

func NewError(code int, msg string) *Error {
    if _, ok := _codes[code]; ok {
        panic(fmt.Sprintf("错误码 %d 已经存在,请更换一个", code))
    }
    _codes[code] = msg
    return &Error{code: code, msg: msg}
}

func (e *Error) Error() string {
    return fmt.Sprintf("错误码:%d, 错误信息::%s", e.Code(), e.Msg())
}

func (e *Error) Code() int {
    return e.code
}

func (e *Error) Msg() string {
    return e.msg
}

接下来继续在目录下新建 common_error.go 文件,写入如下公共错误码:

var (
    Success          = NewError(0, "成功")
    Fail             = NewError(10000000, "内部错误")
    InvalidParams    = NewError(10000001, "无效参数")
    Unauthorized     = NewError(10000002, "认证错误")
    NotFound         = NewError(10000003, "没有找到")
    Unknown          = NewError(10000004, "未知")
    DeadlineExceeded = NewError(10000005, "超出最后截止期限")
    AccessDenied     = NewError(10000006, "访问被拒绝")
    LimitExceed      = NewError(10000007, "访问限制")
    MethodNotAllowed = NewError(10000008, "不支持该方法")
)

继续在目录下新建 rpc_error.go 文件,写入如下 RPC 相关的处理方法:

func TogRPCError(err *Error) error {
    s := status.New(ToRPCCode(err.Code()), err.Msg())
    return s.Err()
}

func ToRPCCode(code int) codes.Code {
    var statusCode codes.Code
    switch code {
    case Fail.Code():
        statusCode = codes.Internal
    case InvalidParams.Code():
        statusCode = codes.InvalidArgument
    case Unauthorized.Code():
        statusCode = codes.Unauthenticated
    case AccessDenied.Code():
        statusCode = codes.PermissionDenied
    case DeadlineExceeded.Code():
        statusCode = codes.DeadlineExceeded
    case NotFound.Code():
        statusCode = codes.NotFound
    case LimitExceed.Code():
        statusCode = codes.ResourceExhausted
    case MethodNotAllowed.Code():
        statusCode = codes.Unimplemented
    default:
        statusCode = codes.Unknown
    }

    return statusCode
}

3.4.6.3 业务错误码

这个时候你会发现,你返回的错误最后都会被转换为 RPC 的错误信息,那原始的业务错误码,我们可以放在哪里呢, 因为没有业务错误码,怎么知道错在具体哪个业务板块,面向用户的客户端又如何特殊处理呢?

那么实际上,在 gRPC 的状态消息中其一共包含三个属性,分别是错误代码、错误消息、错误详细信息, 因此我们可以通过错误详细信息这个字段来实现这个功能,其 googleapis 的 status.pb.go 原型如下:

type Status struct {
    Code      int32 `protobuf:"..."`
    Message   string `protobuf:"..."`
    Details   []*any.Any `protobuf:"..."`
    ...
}

因此我们只需要对应其下层属性,让其与我们的应用程序的错误码机制产生映射关系即可, 首先我们在 common.proto 中增加 any.proto 文件的引入和消息体 Error 的定义,将其作为我们应用程序的错误码原型,如下:

import "google/protobuf/any.proto";

package proto;

message Pager {...}

message Error {
    int32 code = 1;
    string message = 2;
    google.protobuf.Any detail = 3;
}

接着重新执行编译命令 protoc --go_out=plugins=grpc:. ./proto/*.proto , 再打开刚刚编写的 rpc_error.go 文件,修改 TogRPCError 方法,新增 Details 属性,如下:

func TogRPCError(err *Error) error {
    s, _ := status.New(ToRPCCode(err.Code()), err.Msg()).WithDetails(&pb.Error{Code: int32(err.Code()), Message: err.Msg()})
    return s.Err()
}

这时候又有新的问题了,那就是服务自身,在处理 err 时,如何能够获取到错误类型呢,我们可以通过新增 FromError 方法,代码如下:

type Status struct {
    *status.Status
}

func FromError(err error) *Status {
    s, _ := status.FromError(err)
    return &Status{s}
}

而针对有的应用程序,除了希望把业务错误码放进 Details 中,还希望把其它信息也放进去的话,我们继续新增下述方法:

func ToRPCStatus(code int, msg string) *Status {
    s, _ := status.New(ToRPCCode(code), msg).WithDetails(&pb.Error{Code: int32(code), Message: msg})
    return &Status{s}
}

3.4.6.4 验证

我们在项目的 errcode 目录下新建 module_error.go 文件,写入模块的业务错误码,如下:

var (
    ErrorGetTagListFail = NewError(20010001, "获取标签列表失败")
)

接下来我们修改 server 目录下的 tag.go 文件中的 GetTagList 方法,将业务错误码填入(同时建议记录日志,可参见第二章节),如下:

func (t *TagServer) GetTagList(ctx context.Context, r *pb.GetTagListRequest) (*pb.GetTagListReply, error) {
    _, err := http.Get("http://127.0.0.1:8000/api/v1/tags?name=" + r.GetName())
    if err != nil {
        return nil, errcode.TogRPCError(errcode.ErrorGetTagListFail)
    }
    ...
}

这个时候我们还是保持该 RPC 服务的数据源(博客服务)的停止运行,并在添加完业务错误码后重新运行 RPC 服务, 然后利用 grpcurl 工具查看错误码是否达到我们的预期结果,如下:

$ grpcurl -plaintext localhost:8001 proto.TagService.GetTagList  
ERROR:
  Code: Unknown
  Message: 获取标签列表失败
  Details:
  1)    {
          "@type": "type.googleapis.com/proto.Error",
          "code": 20010001,
          "message": "获取标签列表失败"
        }

那么外部客户端可通过 Details 属性知道业务错误码了,那内部客户端要如何使用呢,如下:

err := errcode.TogRPCError(errcode.ErrorGetTagListFail)
sts := errcode.FromError(err)
details := sts.Details()

最终错误信息是以我们 RPC 所返回的 err 进行传递的,因此我们只需要利用先前编写的 FromError 方法, 解析为 status,接着调用 Details 方法,进行 Error 的断言,就可以精确的获取到业务错误码了。

3.4.7. 为什么,是什么

在上文中我们分别有提到一些特殊的操作,虽然你已经用了,但你可能会不理解,本着知其然知其所以然的思想,我们继续深究,看看都是什么。

3.4.7.1 为什么可以转换

在 3.4.3 小节中,我们编写 RPC 方法时,可以直接用 json 和 protobuf 所生成出来的结构体互相转换, 这是为什么呢,为什么可以这么做,我们可以一起看看所生成的.pb.go 文件内容,如下:

type GetTagListReply struct {
    List                 []*Tag   `protobuf:"... json:"list,omitempty"`
    Pager                *Pager   `protobuf:"... json:"pager,omitempty"`
}

实际上在 protoc 生成.pb.go 文件时,会在所生成的结构体上打入 JSON Tag,其默认的规则就是下划线命名, 因此可以通过该方式进行转换,而若是出现字段刚好不兼容的情况,我们也可以通过结构体转结构体的方式,最后达到这种效果。

3.4.7.2 为什么零值不展示

在实际的运行调用中,有一个问题常常被初学者所问到,占比非常高,那就是为什么在调用过程中,有的数据没有展示出来, 例如:name 为空字符串、state 为 0 的话,就不会在 RPC 返回的数据中展示出来,你会发现其实是有规律的, 他们都是零值,你可以看到所生成的.pb.go 文件内容,如下:

type Tag struct {
    Id                   int64    `protobuf:"... json:"id,omitempty"`
    Name                 string   `protobuf:"... json:"name,omitempty"`
    State                uint32   `protobuf:"... json:"state,omitempty"`
    ...
}

在上小节也有提到,实际上所生成的结构体是有打 JSON Tag 的,它在所有的字段中都标明了 omitempty 属性, 也就是当值为该类型的零值时将不会序列化该字段。

那么紧跟这个问题,就会出现第二个最常见的被提问的疑惑,那就是能不能解决这个”问题“,实际上这个并不是”问题“, 因为这是 Protobuf 的规范,在官方手册的 JSON Mapping 小节明确指出,如果字段在 Protobuf 中具有默认值, 则默认情况下会在 JSON 编码数据中将其省略以节省空间。

3.4.7.3 googleapis 是什么

googleapis 代指 Google API 的公共接口定义,在 Github 上搜索 googleapis 就可以找到对应的仓库了, 不过需要注意的是由于 Go 具有不同的目录结构,因此很难在原始的 googleapis 存储库存储和生成 Go gRPC 源代码, 因此 Go gRPC 实际使用的是 go-genproto 仓库,该仓库有如下两个主要使用来源:

  1. google/protobuf:protobuf 和 ptypes 子目录中的代码是均从存储库派生的, protobuf 中的消息体用于描述 Protobuf 本身。 ptypes 下的消息体定义了常见的常见类型。
  2. googleapis/googleapis:专门用于与 Google API 进行交互的类型。

3.5 进行服务间内调

在上一个章节中,我们运行了一个最基本的 gRPC 服务,那么在实际上的应用场景,我们的服务是会有多个的, 并且随着需求的迭代拆分重合,服务会越来越多,到上百个也是颇为常见的。因此在这么多的服务中, 最常见的就是 gRPC 服务间的内调行为,再细化下来,其实就是客户端如何调用 gRPC 服务端的问题, 那么在本章节我们将会进行使用和做一个深入了解。

3.5.1 进行 gRPC 调用

理论上在任何能够执行 Go 语言代码,且网络互通的地方都可以进行 gRPC 调用,它并不受限于必须在什么类型应用程序下才能够调用。 接下来我们在项目下新建 client 目录,创建 client.go 文件,编写一个示例来调用我们先前所编写的 gRPC 服务,如下代码:

package main

import (
    ...
    pb "github.com/go-programming-tour-book/tag-service/proto"
)

func main() {
    ctx := context.Background()
    clientConn, _ := GetClientConn(ctx, "localhost:8004", nil)
    defer clientConn.Close()
  
    tagServiceClient := pb.NewTagServiceClient(clientConn)
    resp, _ := tagServiceClient.GetTagList(ctx, &pb.GetTagListRequest{Name: "Go"})
    
    log.Printf("resp: %v", resp)
}

func GetClientConn(ctx context.Context, target string, opts []grpc.DialOption) (*grpc.ClientConn, error) {
    opts = append(opts, grpc.WithInsecure())
    return grpc.DialContext(ctx, target, opts...)
}

在上述 gRPC 调用的示例代码中,一共分为三大步,分别是:

  • grpc.DialContext:创建给定目标的客户端连接,另外我们所要请求的服务端是非加密模式的, 因此我们调用了 grpc.WithInsecure 方法禁用了此 ClientConn 的传输安全性验证。
  • pb.NewTagServiceClient:初始化指定 RPC Proto Service 的客户端实例对象。
  • tagServiceClient.GetTagList:发起指定 RPC 方法的调用。

3.5.2 grpc.Dial 做了什么

常常有的人会说在调用 grpc.Dial 或 grpc.DialContext 方法时,客户端就已经与服务端建立起了连接,但这对不对呢, 这是需要细心思考的一个点,客户端真的是一调用 Dial 相关方法就马上建立了可用连接吗,我们一起尝试一下,示例代码:

func main() {
    ctx := context.Background()
    clientConn, _ := GetClientConn(ctx, "localhost:8004", nil)
    defer clientConn.Close()
}

在上述代码中,我们只保留了创建给定目标的客户端连接的部分代码,然后执行该程序,接着马上查看抓包工具的情况下, 竟然提示一个包都没有,那么这算真正连接了吗?

实际上,如果你真的想在调用 DialContext 方法时就马上打通与服务端的连接,那么你需要调用 WithBlock 方法来进行设置, 那么它在发起拨号连接时就会阻塞等待连接完成,并且最终连接会到达 Ready 状态,这样子在此刻的连接才是正式可用的,代码如下:

func main() {
    ctx := context.Background()
    clientConn, _ := GetClientConn(
        ctx,
        "localhost:8004",
        []grpc.DialOption{grpc.WithBlock()},
    )
    defer clientConn.Close()
}

再次进行抓包,查看效果,如下:

image

3.5.2.1 源码分析

那么在调用 grpc.Dial 或 grpc.DialContext 方法时,到底做了什么事情呢,为什么还要调用 WithBlock 方法那么“麻烦”, 接下来我们一起看看正在调用时运行的 goroutine 情况,如下:

我们可以看到有几个核心方法一直在等待/处理信号,通过分析底层源码可得知。涉及如下:

func (ac *addrConn) connect()
func (ac *addrConn) resetTransport()
func (ac *addrConn) createTransport(addr resolver.Address, copts transport.ConnectOptions, connectDeadline time.Time)
func (ac *addrConn) getReadyTransport()

在这里主要分析所提示的 resetTransport 方法,看看都做了什么。核心代码如下:

func (ac *addrConn) resetTransport() {
    for i := 0; ; i++ {
        if ac.state == connectivity.Shutdown {
            return
        }
        ...
        connectDeadline := time.Now().Add(dialDuration)
        ac.updateConnectivityState(connectivity.Connecting)
        newTr, addr, reconnect, err := ac.tryAllAddrs(addrs, connectDeadline)
        if err != nil {
            if ac.state == connectivity.Shutdown {
                return
            }
            ac.updateConnectivityState(connectivity.TransientFailure)
            timer := time.NewTimer(backoffFor)
            select {
            case <-timer.C:
                ...
            }
            continue
        }

        if ac.state == connectivity.Shutdown {
            newTr.Close()
            return
        }
        ...
        if !healthcheckManagingState {
            ac.updateConnectivityState(connectivity.Ready)
        }
        ...

        if ac.state == connectivity.Shutdown {
            return
        }
        ac.updateConnectivityState(connectivity.TransientFailure)
    }
}

通过上述代码可得知,在该方法中会不断地去尝试创建连接,若成功则结束。 否则不断地根据 Backoff 算法的重试机制去尝试创建连接,直到成功为止。

3.5.2.2 小结

因此单纯调用 grpc.DialContext 方法是异步建立连接的,并不会马上就成为可用连接了, 仅处于 Connecting 状态(需要多久则取决于外部因素,例如:网络),正式要到达 Ready 状态,这个连接才算是真正的可用。

我们再回顾到前面的示例中,为什么抓包时一个包都抓不到,实际上连接立即建立了,但 main 结束的很快, 因此可能刚建立就被销毁了,也可能还处于 Connecting 状态,没来得及产生具体的网络活动,自然也就抓取不到任何包了。

3.6 同时提供 HTTP 接口

3.6.1 为什么要提供其它协议的支持

在我们完成了多个 gRPC 服务后,总会有遇到一个需求,那就是提供 HTTP 接口,又或者针对一个 RPC 方法,提供多种协议的支持,但为什么会出现这种情况呢?

这基本是由于以下几种可能性,第一:心跳、监控接口等等,第二:业务场景变化,同一个 RPC 方法需要针对多种协议的业务场景提供它的服务了,但是总不可能重现一个一模一样的,因此多协议支持就非常迫切了。

另外我们有在前文讲过,gRPC 协议本质上是 HTTP/2 协议,如果该服务想要在同个端口适配两种协议流量的话,是需要进行特殊处理的。因此在接下来的内容里,我们就将讲讲接触频率最高的 HTTP/1.1 接口的支持,和与其对应所延伸出来的多种方案和思考。

接下来的将分为三个大案例进行实操讲解,虽然每个案例的代码都是相对独立的,但在知识点上是相互关联的。

3.6.2 另起端口监听 HTTP

那么第一种,也就是最基础的需求:实现 gRPC(HTTP/2)和 HTTP/1.1 的支持,允许分为两个端口来进行,我们打开项目根目录的 main.go 文件,修改其启动逻辑,并分别实现 gRPC 和 HTTP/1.1 的运行逻辑,写入如下代码:

var grpcPort string
var httpPort string

func init() {
    flag.StringVar(&grpcPort, "grpc_port", "8001", "gRPC 启动端口号")
    flag.StringVar(&httpPort, "http_port", "9001", "HTTP 启动端口号")
    flag.Parse()
}

首先我们将原本的 gRPC 服务启动端口,调整为 HTTP/1.1 和 gRPC 的端口号读取,接下来我们实现具体的服务启动逻辑,继续写入如下代码:

func RunHttpServer(port string) error {
    serveMux := http.NewServeMux()
    serveMux.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
        _, _ = w.Write([]byte(`pong`))
    })

    return http.ListenAndServe(":"+port, serveMux)
}

func RunGrpcServer(port string) error {
    s := grpc.NewServer()
    pb.RegisterTagServiceServer(s, server.NewTagServer())
    reflection.Register(s)
    lis, err := net.Listen("tcp", ":"+port)
    if err != nil {
        return err
    }

    return s.Serve(lis)
}

在上述代码中,我们一共把服务启动分为了两个方法,分别是针对 HTTP 的 RunHttpServer 方法,其作用是初始化一个新的 HTTP 多路复用器,并新增了一个 /ping 路由及其 Handler,可用于做基本的心跳检测。另外 gRPC 与之前一致,保持实现了 gRPC Server 的相关逻辑,仅是重新封装为 RunGrpcServer 方法。

接下来我们编写启动逻辑,继续写入如下代码:

func main() {
    errs := make(chan error)
    go func() {
        err := RunHttpServer(httpPort)
        if err != nil {
            errs <- err
        }
    }()
    go func() {
        err := RunGrpcServer(grpcPort)
        if err != nil {
            errs <- err
        }
    }()

    select {
    case err := <-errs:
        log.Fatalf("Run Server err: %v", err)
    }
}

在上述代码中,我们先专门声明了一个 chan 用于接收 goroutine 的 err 信息,接下来分别在 goroutine 中调用 RunHttpServer 和 RunGrpcServer 方法,那为什么要放到 goroutine 中去调用呢,是因为实际上监听 HTTP EndPoint 和 gRPC EndPoint 是一个阻塞的行为。

而如果 RunHttpServer 或 RunGrpcServer 方法启动或运行出现了问题,会将 err 写入 chan 中,因此我们只需要利用 select 对其进行检测即可。

接下来我们进行验证,检查输出结果是否与我们的预期一致,命令如下:

$ grpcurl -plaintext localhost:8001 proto.TagService.GetTagList

$ curl http://127.0.0.1:8002/ping

第一条命令应当输出获取标签列表的结果集,第二条命令应当输出 pong 字符串,至此完成在一个应用程序中分别在不同端口监听 gRPC Server 和 HTTP Server 的功能。

3.6.3 在同端口号同时监听

在上小节我们完成了双端口监听不同的流量的需求,但是在一些使用或部署场景下,会比较麻烦,还要兼顾两个端口,这时候就会出现希望在一个端口上兼容多种协议的需求。

3.6.3.1 介绍和安装

在 Go 语言中,我们可以使用第三方开源库 cmux 来实现多协议支持的功能,cmux 是根据有效负载(payload)对连接进行多路复用(也就是匹配连接的头几个字节来进行区分当前连接的类型),可以在同一 TCP Listener 上提供 gRPC、SSH、HTTPS、HTTP、Go RPC 以及几乎所有其它协议的服务,是一个相对通用的方案。

但需要注意的是,一个连接可以是 gRPC 或 HTTP,但不能同时是两者。也就是说,我们假设客户端连接用于 gRPC 或 HTTP,但不会同时在同一连接上使用两者。

接下来我们在项目根目录下执行如下安装命令:

$ go get -u github.com/soheilhy/cmux@v0.1.4

3.6.3.2 多协议的支持

我们正式开始编码,我们再打开项目根目录下的启动文件 main.go,修改为如下代码:

var port string

func init() {
    flag.StringVar(&port, "port", "8003", "启动端口号")
    flag.Parse()
}

首先我们调整了启动端口号的默认端口号,而由于是在同端口,因此调整回一个端口变量,接下来我们编写具体的 Listener 的实现逻辑,与上小节其实本质上是一样的内容,但重新拆分了 TCP、gRPC、HTTP 的逻辑,以便于连接多路复用器的使用,修改为如下代码:

func RunTCPServer(port string) (net.Listener, error) {
    return net.Listen("tcp", ":"+port)
}

func RunGrpcServer() *grpc.Server {
    s := grpc.NewServer()
    pb.RegisterTagServiceServer(s, server.NewTagServer())
    reflection.Register(s)

    return s
}

func RunHttpServer(port string) *http.Server {
    serveMux := http.NewServeMux()
    serveMux.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
        _, _ = w.Write([]byte(`pong`))
    })

    return &http.Server{
        Addr:    ":" + port,
        Handler: serveMux,
    }
}

接下来我们修改 main 中的启动逻辑,如下:

func main() {
    l, err := RunTCPServer(port)
    if err != nil {
        log.Fatalf("Run TCP Server err: %v", err)
    }

    m := cmux.New(l)
    grpcL := m.MatchWithWriters(cmux.HTTP2MatchHeaderFieldPrefixSendSettings("content-type", "application/grpc"))
    httpL := m.Match(cmux.HTTP1Fast())
    
    grpcS := RunGrpcServer()
    httpS := RunHttpServer(port)
    go grpcS.Serve(grpcL)
    go httpS.Serve(httpL)

    err = m.Serve()
    if err != nil {
        log.Fatalf("Run Serve err: %v", err)
    }
}

在上述代码中,我们需要注意是几点,第一个点是第一个初始化的就是 TCP Listener,因为是实际上 gRPC(HTTP/2)、HTTP/1.1 在网络分层上都是基于 TCP 协议的,第二个点是 content-type 的 application/grpc 标识,在章节 3.4.4.1.3 中,我们曾经分析过 gRPC 的也有特定标志位,也就是 application/grpc,同样的 cmux 也是基于这个标识去进行分流。

至此,基于 cmux 实现的同端口支持多协议已经完成了,你需要重新启动服务进行验证,确保 grpcurl 工具和利用 curl 调用 HTTP/1.1 接口响应正常。

3.6.4 同端口同方法提供双流量支持

虽然你做了很多的尝试,但需求方还是想要更直接的方式,需求方就想在应用里实现一个 RPC 方法对 gRPC(HTTP/2)和 HTTP/1.1 的双流量支持,而不是单单是像前面那几个章节一样,只是单纯的另起 HTTP Handler,经过你的深入交流,其实他们是想用 gRPC 作为内部 API 的通讯的同时也想对外提供 RESTful,又不想搞个转换网关,写两套又太繁琐不符合….

同时也有内部的开发人员反馈说,他们平时就想在本地/开发调试时直接调用接口做一下基础验证….不想每次还要调用一下 grpcurl 工具,看一下 list,再填写入参,相较和直接用 Postman 这类工具(具有 Web UI),那可是繁琐多了…

那有没有其它办法呢,实际上是有的,目前开源社区中的 grpc-gateway,就可以实现这个功能,如下图(来源自官方图):

grpc-gateway 是 protoc 的一个插件,它能够读取 protobuf 的服务定义,并生成一个反向代理服务器,将 RESTful JSON API 转换为 gRPC,它主要是根据 protobuf 的服务定义中的 google.api.http 进行生成的。

简单来讲,grpc-gateway 能够将 RESTful 转换为 gRPC 请求,实现同一个 RPC 方法提供 gRPC 协议和 HTTP/1.1 的双流量支持的需求。

3.6.4.1 grpc-gateway 介绍和安装

我们需要安装 grpc-gateway 的 protoc-gen-grpc-gateway 插件,安装命令如下:

$ go get -u github.com/grpc-ecosystem/grpc-gateway/protoc-gen-grpc-gateway@v1.14.5

将所编译安装的 Protoc Plugin 的可执行文件从 $GOPATH 中移动到相应的 bin 目录下,例如:

$ mv $GOPATH/bin/protoc-gen-grpc-gateway /usr/local/go/bin/

这里的命令操作并非是绝对必须的,主要目的是将二进制文件 protoc-gen-grpc-gateway 移动到 bin 目录下, 让其可以执行,确保在 $PATH 下,只要达到这个效果就可以了。

在win10上,直接到目录 G:\GoPath\pkg\mod\github.com\grpc-ecosystem\grpc-gateway@v1.16.0\protoc-gen-grpc-gateway 下, 然后执行 go build .,把生成的 protoc-gen-grpc-gateway.exe 移动到目录 G:\GoPath\bin 下。

3.6.4.2 Proto 文件的处理

3.6.4.2.1 Proto 文件修改和编译

那么针对 grpc-gateway 的使用,我们需要调整项目 proto 命令下的 tag.proto 文件,修改为如下:

syntax = "proto3";

package proto;

import "proto/common.proto";
import "google/api/annotations.proto";

service TagService {
    rpc GetTagList (GetTagListRequest) returns (GetTagListReply) {
        option (google.api.http) = {
            get: "/api/v1/tags"
        };
    }
}
...

我们在 proto 文件中增加了 google/api/annotations.proto 文件的引入,并在对应的 RPC 方法中新增了针对 HTTP 路由的注解。接下来我们重新编译 proto 文件,在项目根目录执行如下命令:

$ protoc -I/usr/local/include -I. \
       -I$GOPATH/src \
       -I$GOPATH/src/github.com/grpc-ecosystem/grpc-gateway/third_party/googleapis \
       --grpc-gateway_out=logtostderr=true:. \
       ./proto/*.proto

在win10上,如下操作

protoc -I. -IG:\GoPath -IG:\GoPath\pkg\mod\github.com\grpc-ecosystem\grpc-gateway@v1.16.0\third_party\googleapis --grpc-gateway_out=logtostderr=true:. ./proto/*.proto

执行完毕后将生成 tag.pb.gw.go 文件,也就是目前 proto 目录下用.pb.go 和.pb.gw.go 两种文件,分别对应两类功能支持。

我们这里使用到了一个新的 protoc 命令选项 -I 参数,它的格式为:-IPATH, --proto_path=PATH,作用是指定 import 搜索的目录(也就是 Proto 文件中的 import 命令),可指定多个,如果不指定则默认当前工作目录。

另外在实际使用场景中,还有一个较常用的选项参数,M 参数,例如 protoc 的命令格式为:Mfoo/bar.proto=quux/shme,则在生成、编译 Proto 时将所指定的包名替换为所要求的名字(如:foo/bar.proto 编译后为包名为 quux/shme),更多的选项支持可执行 protoc –help 命令查看帮助文档。

3.6.4.2.2 annotations.proto 是什么

我们刚刚在 grpc-gateway 的 proto 文件生成中用到了 google/api/annotations.proto 文件,实际上它是 googleapis 的产物,在前面的章节我们有介绍过。

另外你可以结合 grpc-gateway 的 protoc 的生成命令来看,你会发现它在 grpc-gateway 的仓库下的 third_party 目录也放了个 googleapis,因此在引用 annotations.proto 时,用的就是 grpc-gateway 下的,这样子可以保证其兼容性和稳定性(版本可控)。

那么 annotations.proto 文件到底是什么,又有什么用呢,我们一起看看它的文件内容,如下:

syntax = "proto3";

package google.api;

import "google/api/http.proto";
import "google/protobuf/descriptor.proto";
...
extend google.protobuf.MethodOptions {
  HttpRule http = 72295728;
}

查看核心使用的 http.proto 文件中的一部分内容,如下:

message HttpRule {
  string selector = 1;
  oneof pattern {
    string get = 2;
    string put = 3;
    string post = 4;
    string delete = 5;
    string patch = 6;
    CustomHttpPattern custom = 8;
  }

  string body = 7;
  string response_body = 12;
  repeated HttpRule additional_bindings = 11;
}

总的来说,主要是针对的 HTTP 转换提供支持,定义了 Protobuf 所扩展的 HTTP Option,在 Proto 文件中可用于定义 API 服务的 HTTP 的相关配置,并且可以指定每一个 RPC 方法都映射到一个或多个 HTTP REST API 方法上。

因此如果你没有引入 annotations.proto 文件和在 Proto 文件中填写相关 HTTP Option 的话,执行生成命令,不会报错,但也不会生成任何东西。

3.6.4.3 服务逻辑实现

接下来我们开始实现基于 grpc-gateway 的在同端口下同 RPC 方法提供 gRPC(HTTP/2)和 HTTP/1.1 双流量的访问支持,我们打开项目根目录下的启动文件 main.go,修改为如下代码:

var port string

func init() {
    flag.StringVar(&port, "port", "8004", "启动端口号")
    flag.Parse()
}

3.6.4.3.1 不同协议的分流

我们调整了这个案例的服务启动端口号,然后继续在 main.go 中写入如下代码:

func grpcHandlerFunc(grpcServer *grpc.Server, otherHandler http.Handler) http.Handler {
    return h2c.NewHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if r.ProtoMajor == 2 && strings.Contains(r.Header.Get("Content-Type"), "application/grpc") {
            grpcServer.ServeHTTP(w, r)
        } else {
            otherHandler.ServeHTTP(w, r)
        }
    }), &http2.Server{})
}

这是一个很核心的方法,重要的分流和设置一共有两个部分,如下:

gRPC 和 HTTP/1.1 的流量区分:
    对 ProtoMajor 进行判断,该字段代表客户端请求的版本号,客户端始终使用 HTTP/1.1 或 HTTP/2。
    Header 头 Content-Type 的确定:grpc 的标志位 application/grpc 的确定。
gRPC 服务的非加密模式的设置:关注代码中的"h2c"标识,“h2c” 标识允许通过明文 TCP 运行 HTTP/2 的协议,此标识符用于 HTTP/1.1 升级标头字段以及标识 HTTP/2 over TCP,而官方标准库 golang.org/x/net/http2/h2c 实现了 HTTP/2 的未加密模式,我们直接使用即可。

在整体的方法逻辑上来讲,我们可以看到关键之处在于调用了 h2c.NewHandler 方法进行了特殊处理,h2c.NewHandler 会返回一个 http.handler,其主要是在内部逻辑是拦截了所有 h2c 流量,然后根据不同的请求流量类型将其劫持并重定向到相应的 Hander 中去处理,最终以此达到同个端口上既提供 HTTP/1.1 又提供 HTTP/2 的功能了。

3.6.4.3.2 Server 实现

完成了不同协议的流量分发和处理后,我们需要实现其 Server 的具体逻辑,继续在 main.go 文件中写入如下代码:

import (
    "github.com/grpc-ecosystem/grpc-gateway/runtime"
    ...
)

func RunServer(port string) error {
    httpMux := runHttpServer()
    grpcS := runGrpcServer()
    gatewayMux := runGrpcGatewayServer()

    httpMux.Handle("/", gatewayMux)

    return http.ListenAndServe(":"+port, grpcHandlerFunc(grpcS, httpMux))
}

func runHttpServer() *http.ServeMux {
    serveMux := http.NewServeMux()
    serveMux.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
        _, _ = w.Write([]byte(`pong`))
    })

    return serveMux
}

func runGrpcServer() *grpc.Server {
    s := grpc.NewServer()
    pb.RegisterTagServiceServer(s, server.NewTagServer())
    reflection.Register(s)

    return s
}

func runGrpcGatewayServer() *runtime.ServeMux {
    endpoint := "0.0.0.0:" + port
    gwmux := runtime.NewServeMux()
    dopts := []grpc.DialOption{grpc.WithInsecure()}
    _ = pb.RegisterTagServiceHandlerFromEndpoint(context.Background(), gwmux, endpoint, dopts)

    return gwmux
}

在上述代码中,与先前的案例中主要差异在于 RunServer 方法中的 grpc-gateway 相关联的注册,核心在于调用了 RegisterTagServiceHandlerFromEndpoint 方法去注册 TagServiceHandler 事件,其内部会自动转换并拨号到 gRPC Endpoint,并在上下文结束后关闭连接。

另外在注册 TagServiceHandler 事件时,我们在 grpc.DialOption 中通过设置 grpc.WithInsecure 指定了 Server 为非加密模式,否则程序在运行时将会出现问题,因为 gRPC Server/Client 在启动和调用时,必须明确其是否加密。

3.6.4.3.3 运行和验证

接下来我们编写 main 启动方法,调用 RunServer 方法,如下:

func main() {
    err := RunServer(port)
    if err != nil {
        log.Fatalf("Run Serve err: %v", err)
    }
}

完成服务的再启动后我们进行 RPC 方法的验证,如下:

$ curl http://127.0.0.1:8004/ping
$ curl http://127.0.0.1:8004/api/v1/tags
$ grpcurl -plaintext localhost:8004 proto.TagService.GetTagList 

正确的情况下,都会返回响应数据,分别对应心跳检测、RPC 方法的 HTTP/1.1 和 RPC 方法的 gRPC(HTTP/2)的响应。

3.6.4.3.4 自定义错误

在完成验证后,我们又想到,在 gRPC 中我们可以通过引用 google.golang.org/grpc/status 内的方法可以对 grpc-status、grpc-message 以及 grpc-details 详细进行定制(我们的 errcode 包就是这么做的),但是 grpc-gateway 又怎么定制呢,它作为一个代理,会怎么提示错误信息呢,如下:

{
    "error": "获取标签列表失败",
    "code": 2,
    "message": "获取标签列表失败",
    "details": [{
        "@type": "type.googleapis.com/proto.Error",
        "code": 20010001,
        "message": "获取标签列表失败"
    }]
}

通过结果上来看,这是真真实实的把 grpc 错误给完整转换了过来,太直接了,这显然不利于浏览器端阅读,调用的客户端会不知道以什么为标准。

实际上,grpc-status 的含义其实对应的是我们的 HTTP 状态码,业务错误码对应着客户端所需的消息主体,因此我们需要对 grpc-gateway 的错误进行定制,继续在 main.go 文件中写入如下代码:

type httpError struct {
    Code    int32  `json:"code,omitempty"`
    Message string `json:"message,omitempty"`
}

func grpcGatewayError(ctx context.Context, _ *runtime.ServeMux, marshaler runtime.Marshaler, w http.ResponseWriter, _ *http.Request, err error) {
    s, ok := status.FromError(err)
    if !ok {
        s = status.New(codes.Unknown, err.Error())
    }

    httpError := httpError{Code: int32(s.Code()), Message: s.Message()}
    details := s.Details()
    for _, detail := range details {
        if v, ok := detail.(*pb.Error); ok {
            httpError.Code = v.Code
            httpError.Message = v.Message
        }
    }

    resp, _ := json.Marshal(httpError)
    w.Header().Set("Content-type", marshaler.ContentType())
    w.WriteHeader(runtime.HTTPStatusFromCode(s.Code()))
    _, _ = w.Write(resp)
}

在上述代码中,我们针对所返回的 gRPC 错误进行了两次处理,将其转换为对应的 HTTP 状态码和对应的错误主体,以确保客户端能够根据 RESTful API 的标准来进行交互。

接下来只需要将为 grpc-gateway 所定制的错误处理方法,注册到对应的地方就可以了,如下:

func runGrpcGatewayServer() *runtime.ServeMux {
    endpoint := "0.0.0.0:" + port

    runtime.HTTPError = grpcGatewayError

    gwmux := runtime.NewServeMux()
    dopts := []grpc.DialOption{grpc.WithInsecure()}
    _ = pb.RegisterTagServiceHandlerFromEndpoint(context.Background(), gwmux, endpoint, dopts)

    return gwmux
}
...

重启服务再进行验证,查看输出结果:

$ curl -v http://127.0.0.1:8004/api/v1/tags 
< HTTP/1.1 500 Internal Server Error
< Content-Type: application/json

{"code":20010001,"message":"获取标签列表失败"}

可以看到所输出的 HTTP 状态码和消息主体都是正确的。

3.6.4.4 如何实现的

虽然在上面我们已经讲到了 gRPC(HTTP/2)和 HTTP/1.1 的分流是通过 Header 中的 Content-Type 和 ProtoMajor 标识来进行分流的,但是分流后的处理逻辑又是怎么样的呢,gRPC 要进行注册(RegisterTagServiceServer),grpc-gateway 也要进行注册(RegisterTagServiceHandlerFromEndpoint),到底有什么用呢?

解铃还须系铃人,我们接下来将进行探索,看看 grpc-gateway 是如何实现的,那对于我们开发人员来讲,最常触碰到的就是.pb.gw.go 的注册方法,如下:

func RegisterTagServiceHandlerFromEndpoint(ctx context.Context, mux *runtime.ServeMux, endpoint string, opts []grpc.DialOption) (err error) {
    conn, err := grpc.Dial(endpoint, opts...)
    if err != nil {
        return err
    }
    defer func() {
        if err != nil {
            if cerr := conn.Close(); cerr != nil {
                grpclog.Infof("Failed to close conn to %s: %v", endpoint, cerr)
            }
            return
        }
        go func() {
            <-ctx.Done()
            if cerr := conn.Close(); cerr != nil {
                grpclog.Infof("Failed to close conn to %s: %v", endpoint, cerr)
            }
        }()
    }()

    return RegisterTagServiceHandler(ctx, mux, conn)
}

实际上在调用这类 RegisterXXXXHandlerFromEndpoint 注册方法时,主要是进行 gRPC 连接的创建和管控,它在内部就已经调用了 grpc.Dial 对 gRPC Server 进行拨号连接,并保持住了一个 Conn 便于后续的 HTTP/1/1 调用转发。另外在关闭连接的处理上,处理的也比较的稳健,统一都是放到 defer 中进行关闭,又或者根据 context 的上下文来控制连接的关闭时间。

接下来就是,确切的内部注册方法 RegisterTagServiceHandler,其实际上调用的是如下方法:

func RegisterTagServiceHandlerClient(ctx context.Context, mux *runtime.ServeMux, client TagServiceClient) error {
  
    mux.Handle("GET", pattern_TagService_GetTagList_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
    
        ctx, cancel := context.WithCancel(req.Context())
        defer cancel()
        inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
        rctx, _ := runtime.AnnotateContext(ctx, mux, req)
        resp, md, _ := request_TagService_GetTagList_0(rctx, inboundMarshaler, client, req, pathParams)
        ctx = runtime.NewServerMetadataContext(ctx, md)

        forward_TagService_GetTagList_0(ctx, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
    })

    return nil
}

该方法包含了整体的 HTTP/1.1 转换到 gRPC 的前置操作,至少包含了以下四大处理:

注册方法:会将当前 RPC 方法所预定义的 HTTP Endpoint(根据 proto 文件所生成的.pb.gw.go 中所包含的信息)注册到外部所传入的 HTTP 多路复用器中,也就是对应我们程序中的 runtime.NewServeMux 方法所返回的 gmux。

超时时间:会根据外部所传入的上下文进行控制。

请求/响应数据:根据所传入的 MIME 类型进行默认序列化,例如:application/jsonpb、application/json。另外其在实现上是一个 Marshaler,也就是我们可以通过调用 grpc-gateway 中的 runtime.WithMarshalerOption 方法来注册我们所需要的 MIME 类型及其对应的 Marshaler。

Metadata(元数据):会将 gRPC metadata 转换为 context 中,便于使用。

3.6.5 其它方案

那么除了在应用中实现诸如 grpc-gateway 这种应用代理以外,还有没有其它的外部方案呢?

外部方案,也就是外部组件,普遍是代指网关,目前 Envoy 有提供 gRPC-JSON transcoder 来支持 RESTful JSON API 客户端通过 HTTP/1.1 向 Envoy 发送请求并代理到 gRPC 服务。另外像是 APISIX 也有提供类似的功能,其目前也进入了 Apache 开始孵化,也值得关注。

实际上可以选择的方案并不是特别多,并且都不是以单一技术方案提供,均是作为网关中的其中一个功能提供的,大家有兴趣的话可以深入了解。

3.7 生成接口文档

你所开发的接口有没有接口文档,你的接口文档有没有及时更新,是一个永恒的话题。 在本章节我们将继续使用 Swagger 作为我们的接口文档平台,但是与第二章不同,我们的载体变成了 Protobuf,Protobuf 是强规范的, 其本身就包含了字段名和字段类型等等信息,因此其会更加的简便。

在接下来的章节中,我们将继续基于同端口同 RPC 方法支持双流量(grpc-gateway 方案)的服务代码来进行开发和演示。

3.7.1 安装和下载

3.7.1.1 安装 Protoc Plugin

针对后续 Swagger 接口文档的开发和使用,我们需要安装 protoc 的插件 protoc-gen-swagger, 它的作用是通过 proto 文件来生成 swagger 定义(.swagger.json),安装命令如下:

$ go get -u github.com/grpc-ecosystem/grpc-gateway/protoc-gen-swagger

在win10上,直接到目录 G:\GoPath\pkg\mod\github.com\grpc-ecosystem\grpc-gateway@v1.16.0\protoc-gen-swagger 下, 然后执行 go build .,把生成的 protoc-gen-swagger.exe 移动到目录 G:\GoPath\bin 下。

3.7.1.2 下载 Swagger UI 文件

Swagger 提供可视化的接口管理平台,也就是 Swagger UI,我们首先需要到 https://github.com/swagger-api/swagger-ui 上将其tag源码压缩包下载下来, 接着在项目的 third_party 目录下新建 swagger-ui 目录,将其 dist 目录下的所有资源文件拷贝到我们项目的 third_party/swagger-ui 目录中去。

3.7.2 静态资源转换

在上一步中我们已经将 Swagger UI 的资源文件拷贝到了项目的 swagger-ui 目录中,但是这时候我们的应用程序还不可以使用它, 我们需要使用 go-bindata 库将其资源文件转换为 Go 代码,便于我们后续使用,安装命令如下:

$ go get -u github.com/go-bindata/go-bindata/...

在win10上后续操作会执行不了,到 https://github.com/go-bindata/go-bindata 下下载tag包, 解压后在 go-bindata 目录下执行 go build .,把生成的 go-bindata.exe 移动到目录 G:\GoPath\bin 下。

接下来我们在项目的 pkg 目录下新建 swagger-ui 目录,并在项目根目录执行下述转换命令:

$ go-bindata --nocompress -pkg swagger -o pkg/swagger/data.go third_party/swagger-ui/...

在执行完毕后,应当在项目的 pkg/swagger 目录下创建了 data.go 文件。

3.7.3 Swagger UI 处理和访问

为了让刚刚转换的静态资源代码能够让外部访问到,我们需要安装 go-bindata-assetfs 库, 它能够结合 net/http 标准库和 go-bindata 所生成 Swagger UI 的 Go 代码两者来供外部访问,安装命令如下:

$ go get -u github.com/elazarl/go-bindata-assetfs/...

安装完成后,我们打开启动文件 main.go,修改 HTTP Server 相关的代码,如下:

import (
    assetfs "github.com/elazarl/go-bindata-assetfs"
    "github.com/go-programming-tour-book/tag-service/pkg/swagger"
    ...
)

func runHttpServer() *http.ServeMux {
    serveMux := http.NewServeMux()
    serveMux.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
        _, _ = w.Write([]byte(`pong`))
    })

    prefix := "/swagger-ui/"
    fileServer := http.FileServer(&assetfs.AssetFS{
        Asset:    swagger.Asset,
        AssetDir: swagger.AssetDir,
        Prefix:   "third_party/swagger-ui",
    })
    serveMux.Handle(prefix, http.StripPrefix(prefix, fileServer))
    
    return serveMux
}

在上述代码中,我们通过引用先前通过 go-bindata-assetfs 所生成的 data.go 文件中的资源信息, 结合两者来对外提供 swagger-ui 的 Web UI 服务。需要注意的是,因为所生成的文件的原因, 因此 swagger.Asset 和 swagger.AssetDir 的引用在 IDE 识别上(会标红)存着一定的问题, 但实际程序运行是没有问题的,只需要通过命令行启动就可以了。

我们重新运行该服务,通过浏览器访问 http://127.0.0.1:8004/swagger-ui/,查看结果如下:

以上看到的就是 Swagger UI 的默认展示界面,默认展示的是 Demo 示例(也就是输入框中的 swagger 地址), 如果你已经看到如上界面,那说明一切正常。

3.7.4 Swagger 描述文件生成和读取

既然 Swagger UI 已经能够看到了,那我们自己的接口文档又如何读取呢,其实刚刚在上一步,你可以看到默认示例, 读取的是一个 swagger.json 的远程地址,也就是只要我们本地服务中也有对应的 swagger.json,就能够展示我们的服务接口文档了。

因此我们先需要进行 swagger 定义文件的生成,我们在项目根目录下执行以下命令:

$ protoc -I/usr/local/include -I. \
  -I$GOPATH/src \
  -I$GOPATH/src/github.com/grpc-ecosystem/grpc-gateway/third_party/googleapis \
  --swagger_out=logtostderr=true:. \
  ./proto/*.proto

win10下执行:

protoc -I. -IG:\GoPath -IG:\GoPath\pkg\mod\github.com\grpc-ecosystem\grpc-gateway@v1.16.0\third_party\googleapis --swagger_out=logtostderr=true:. ./proto/*.proto

执行完毕后你会发现 proto 目录下会多出 common.swagger.json 和 tag.swagger.json 两个文件,文件内容是对应的 API 描述信息。

接下来我们需要让浏览器能够访问到我们本地所生成的 swagger.json,也就是需要有一个能够访问本地 proto 目录下的 .swagger.json 的文件服务, 我们继续修改 main.go 文件,如下:

func runHttpServer() *http.ServeMux {
    ...
    serveMux.Handle(prefix, http.StripPrefix(prefix, fileServer))
    
    serveMux.HandleFunc("/swagger/", func(w http.ResponseWriter, r *http.Request) {
        if !strings.HasSuffix(r.URL.Path, "swagger.json") {
            http.NotFound(w, r)
            return
        }

        p := strings.TrimPrefix(r.URL.Path, "/swagger/")
        p = path.Join("proto", p)

        http.ServeFile(w, r, p)
    })

    return serveMux
}

我们重新运行该服务,通过浏览器访问 http://127.0.0.1:8004/swagger/tag.swagger.json,查看能够成功得到所生成的 API 描述信息。

3.7.5 查看接口文档

接下来我们只需要把想要查看的 swagger.json 的访问地址,填入输入框中,点击“Explore”就可以查看到对应的接口信息了,如下图:

3.7.6 小结

至此,我们就完成了 Swagger 文档的生成和使用,而目前使用上虽然是基于每一个服务运行起来的 Swagger 站点, 但是在实际的环境中,也可以让每个服务仅提供 Swagger 定义,接着在统一的平台提供 Swagger 站点来读取 Swagger 定义, 这样子就不需要每一个服务都运行 Swagger 站点了,同时由于入口统一了,鉴权也能在这基础上完成。

3.8 拦截器介绍和实际使用

3.9 Metadata 和 RPC 自定义认证

3.10 进行链路追踪

3.11 服务注册和发现

3.12 实现自定义的 protoc 插件

3.13 对接口进行版本管理

3.14 思考

附注






参考资料

Go 语言编程之旅:一起用 Go 做项目 https://golang2.eddycjy.com/


返回