Go 微服务基础:Protobuf & gRPC

Go 微服务基础:Protobuf & gRPC

这篇文章写的稍微有点乱,因为写作过程中思路有大断流。

本文历时很多天,期间我的两大主要开发环境 —— Python 和 Golang 都炸了,顺便,包管理工具 Homebrew 也出了点小问题(全都是因为一条没经过深思的错误的 brew upgrade 命令)。尝试手动修复,搞了一天,无果。没办法 Time Machine 恢复了一下,由于最近几天都没备份😭,S/L 大法不太灵,它一下子给我回到了十天前。各种手动检查、清理、git pull,又花费了一天。。。

不过这些怎么样都无所谓啦,我想说的是,大家在敲回车前还是要三思,尤其是使用管理员权限时。还有备份不要忘啊,再忙也要记得做备份哦。


我们开门见山,废话结束!这篇文章前半部分介绍了 Protocol Buffers 的安装使用和基本语法。后半部分基于 Golang,通过一个实例,介绍 Protobuf 与 gRPC 的使用。

[TOC]

Protobuf

Protocol Buffers 是一种序列化数据结构的协议。对于透过管道或存储数据进行通信的程序开发上是很有用的。这个方法包含一个接口描述语言,描述一些数据结构,并提供程序工具根据这些描述产生代码,用于将这些数据结构产生或解析数据流。

—— WikiPedia

总而言之, Protocol Buffers,简称 Protobuf, 是一种数据描述语言(和 XML、JSON 这些类似)。Protobuf 配套的工具可以自动生成将各种语言中的数据结构序列化为 Protobuf 表示,然后反序列化到任意支持的语言中。

在 RPC 中,跨语言的数据编码、解码是个问题, Protobuf 是一种比较高效的解决方案。gRPC 就是基于 Protobuf 的一套 RPC 框架。

安装 Protobuf

在 macOS 上,使用 homebrew 可以快速安装 protobuf。 brew 绝对是我见过最省事的包管理,忽略国内众所周知的原因带来的一些小问题,几乎满足了一切日常安装需要:

1
2
3
4
$ brew install protobuf
...
$ protoc --version
libprotoc 3.13.0

我们要在 Golang 中使用 protobuf,需要把 ptotobuf “编译”成 Golang,需要安装 protoc-gen-go 工具来做这件事:

1
$ go install google.golang.org/protobuf/cmd/protoc-gen-go

下面先尝试使用它完成一次 HelloWorld,后面再介绍 Protobuf 的基本语法呀。

使用 Protobuf

首先,创建一个 hello.proto 文件:

1
$ vim hello.proto

写入如下 Protocol Buffers 代码:

1
2
3
4
5
6
7
8
syntax =  "proto3";

package hello;

message Hello {
string world = 1;
int32 meaningless_count = 2;
}

编译,生成 Golang 代码:

1
$ protoc --go_out=. hello.proto

go_out 告诉 protoc 编译器加载 protoc-gen-go 工具来生成代码,把生成代码放到当前目录。忽略打印的 WARNING,protoc 在当前路径下生成了一个 hello.pb.go 的文件。里面有很多内容,包括:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.25.0
// protoc v3.13.0
// source: hello.proto

package hello

type Hello struct {
...
World string `protobuf:"bytes,1,opt,name=world,proto3" json:"world,omitempty"`
MeaninglessCount int32 `protobuf:"varint,2,opt,name=meaningless_count,json=meaninglessCount,proto3" json:"meaningless_count,omitempty"`
}

func (x *Hello) Reset() {...}
func (x *Hello) String() string {...}
func (*Hello) ProtoMessage() {}
func (x *Hello) ProtoReflect() protoreflect.Message {...}
func (*Hello) Descriptor() ([]byte, []int) {...}
func (x *Hello) GetWorld() string {...}
func (x *Hello) GetMeaninglessCount() int32 {...}

...

上面抽出来的这些代码我们都很熟悉了,它把 proto 文件中写的 message 翻译成了 golang 的结构体,这样我们就能直接在 Go 中使用这个结构了。

在编译时,你也可以编译成其他语言的,用 --<lang>_out 指定一个输出目录就行了:

1
$ protoc --java_out=. --cpp_out=. hello.proto

你可以生成各种语言的代码看看,Go 生成的代码绝对是最简洁、最看得懂的(之一)。

Protobuf 语法

我们把 Protocol Buffers 代码写在 .proto 文件中。

注释

Protobuf 的注释还是我们熟悉的 C/C++ 风格:

1
2
// 这是注释
/* 这也是注释 */

语法版本

.proto 的第一行会指明所用的语法版本,我们现在用的是 proto3 版本:

1
syntax = "proto3";

导入

Protocol Buffers 文件也可以导入其他文件中的内容:

1
import "project/others.proto"

包声明

Protocol Buffers 使用 package 关键字来声明包(可选):

1
package foo.bar;

如果需要指定在不同语言中的名称,可以使用 option

1
option java_package = "com.example.foo";

消息

message 关键字来定义消息。消息,就是 RPC 中客户端给服务端穿的参数以及服务端给客户端返回的结果。使用 message MsgName {...} 的语法来声明:

1
2
3
message SongServerRequest {
string song_name = 1;
}

Message 的名称一般用驼峰命名。message 的内容是一些个「字段」。

字段

字段的格式为:

1
{repeated} <type> <field_name> = <fieldNumber> { [ fieldOptions ] };

这里花括号中的内容是可选的,尖括号中的内容是需要替换的,后面的 [ fieldOptions ] 方括号是protobuf 语法的一部分。

去掉所有可选部分,一个最基本的字段写作 type field_name = fieldNumber

1
string song_name = 1;
  • type 很好理解,就是字段的数据类型,后面会详细介绍。
  • field_name 是字段名称,采用下划线命名
  • fieldNumber 是一个数字,字段的编号。

一个 message 的每个字段都要这个唯一编号。建议一次写好后永远也不要改这个编号,因为这个编号是用于以消息二进制格式标识字段的。需要注意一点是,最好把出现频繁的消息元素使用编号 1~15(只需一字节编码),更大的数字就需要更多空间了,可用的取值范围是 [1, 19000) U (20000, 2^29)(中间挖掉的是 protocol buffers 保留的 )

类型

protobuf 支持的基本类型有:

类型 proto 对应 go 类型 说明
字节数组 bytes []byte 长度不超过 2^32
字符串 string string
布尔类型 bool bool
整数 int32int64 int32int64 变长编码,对负数编码效率低
无符号整数 uint32uint64 uint32uint64 变长编码
有符号整数 sint32sint64 int32int64 变长编码,对负数编码比 int32/64 高效
定长整数 fixed32fixed64 int32int64 固定空间,定长编码,适合大数
定长有符号整数 sfixed32sfixed64 int32int64 定长编码
浮点数 floatdouble float32float64

此外,还有几个复合类型:

repeated

repeated 可以用来表示可变数组,也就相当于 Golang 的切片类型。

1
repeated string hobby = 1;

这里的 hobby 就可以有好多个值,编译成 Golang 可以看到它就是一个 []string

1
Hobby []string `protobuf:"bytes,1,rep,name=hobby,proto3" json:"hobby,omitempty"`

map

map 是键值对类型:

1
map<string, int64> something = 1;

enum

1
2
3
4
enum EnumNotAllowingAlias {
UNKNOWN2 = 0;
STARTED2 = 1;
}

reserved

reserved 用来指明此 message 不使用(保留)某些字段。通过编码或字段名来设置保留:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
message AllNormalypes {
reserved 2, 4 to 6;
reserved "field14", "field11";
double field1 = 1;
// float field2 = 2;
int32 field3 = 3;
// int64 field4 = 4;
// uint32 field5 = 5;
// uint64 field6 = 6;
sint32 field7 = 7;
sint64 field8 = 8;
fixed32 field9 = 9;
fixed64 field10 = 10;
// sfixed32 field11 = 11;
sfixed64 field12 = 12;
bool field13 = 13;
// string field14 = 14;
bytes field15 = 15;
}

声明保留的字段就不能再定义,否则编译会出错。

message

一个 message 可以作为另一个 message 的字段出现:

1
2
3
4
5
6
7
8
message SomeOtherMessage {
SearchResponse.Result result = 1;
message Result {
string url = 1;
string title = 2;
repeated string snippets = 3;
}
}

服务

我们常把 Proto Buffers 用在 RPC 里嘛,所以 Proto Buffers 是可以直接定义 RPC 服务接口的。这个接口可以直接用 gRPC 去实现:

1
2
3
service SearchService {
rpc Search (SearchRequest) returns (SearchResponse);
}

编译时 Proto Buffers 会根据选择的语言生成服务接口代码和存根(Stub)。

接下来,介绍如何在 gRPC 中使用 Ptotobuf。

gRPC with Golang

什么是 gRPC?官网上它给自己下的定义是 A high-performance, open source universal RPC framework —— 一个高性能的开源通用 RPC 框架。

gRPC 在通信的过程中使用 Protobuf。所以它基本上 Protobuf 支持的语言 gRPC 都支持,Go、C++、Java、Python 这些我们常用的后端语言都可以用 gRPC,而且是可以跨语言调用。

接下来,我们着眼于 Golang,通过一个简单实例,看看 gRPC 如何使用。

我们的目标是做一个处理用户信息的 RPC 实例。客户端通过给定用户名,从服务端查询用户信息。

最终实现的项目结构如下:

1
2
3
4
5
6
7
8
9
10
11
grpc
├── client
│   └── main.go
├── go.mod
├── go.sum
├── proto
│   ├── user.pb.go
│   ├── user.proto
│   └── user_grpc.pb.go
└── server
└── main.go
  • proto 目录里我们写 user.proto 来定义 RPC 服务接口。
  • server 实现一个 gRPC 服务端。
  • client 实现客户端。

在项目根目录 grpc 目录下初始化了一个 go 模块:

1
$ go mod init grpc.learn

安装 gRPC 的 Go 包:

1
$ go get goole.golang.org/grpc

Protobuf 编写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
syntax = "proto3";

package proto;

// 用户信息请求参数
message UserRequest {
string name = 1;
}

// 用户信息请求响应
message UserResponse {
int32 id = 1;
string name = 2;
int32 age = 3;
repeated string hobby = 4;
}

// 用户信息接口
service UserInfo {
// 获取用户信息,请求参数为 UserRequest,返回响应为 UserResponse
rpc GetUserInfo (UserRequest) returns (UserResponse) {}
}

Protobuf 编译

这里我遇到一个坑, 网上的资料一般都是教用:

1
protoc --go_out=plugins=grpc:. user.proto

但现在(2020.08.18)这个不行了,会报错:

1
--go_out: protoc-gen-go: plugins are not supported; use 'protoc --go-grpc_out=...' to generate gRPC

我查了 gRPC 官网的有关文档,以及有关的 GitHub gRPC Issue 298Protobuf Issue 1070 等等一些东西。总而言之,Protobuf 生成 gRPC 代码的工具最近有些变化,目前使用 protoc 生成 Golang 的 gRPC 代码,需要安装一个额外的包:

1
$ go install google.golang.org/grpc/cmd/protoc-gen-go-grpc

然后:

1
2
$ cd proto
$ protoc -I . --go_out=. --go-grpc_out=. ./user.proto

忽略输出的 Missing 'go_package' option WARNING。就可以看到生成的接口文件了:

1
2
3
4
proto/
├── user.pb.go
├── user.proto
└── user_grpc.pb.go
  • --go_out 生成的 user.pb.go 中定义了 message 在 golang 中的结构体,以及其编码解码。
  • --go-rpc_out 生成的 user_grpc.pb.go 里面是 service 在 golang 中的 gRPC 服务、客户端接口。

服务端实现

查看 user_grpc.pb.go 中的代码,发现里面定义有这些东西:

1
2
3
4
5
6
7
8
9
10
11
// UserInfoService is the service API for UserInfo service.
// Fields should be assigned to their respective handler implementations only before
// RegisterUserInfoService is called. Any unassigned fields will result in the
// handler for that method returning an Unimplemented error.
type UserInfoService struct {
// 获取用户信息,请求参数为 UserRequest,返回响应为 UserResponse
GetUserInfo func(context.Context, *UserRequest) (*UserResponse, error)
}

// RegisterUserInfoService registers a service implementation with a gRPC server.
func RegisterUserInfoService(s grpc.ServiceRegistrar, srv *UserInfoService) {...}

靠我六级高达 410 分的惊人英语实力,信达雅地翻译一下:

  • UserInfoService 是 UserInfo 服务的服务端接口。在调用 RegisterUserInfoService 之前, 应该将各种服务功能的具体实现赋给 UserInfoService 的对应字段。
  • RegisterUserInfoService 在 gRPC 服务器实例上注册一个 UserInfoService 实例。

新建一个 server/main.go,在里面编写具体的服务端代码。首先要导入 protobuf 的接口:

1
import "grpc.learn/proto"

这里我把所有代码都放到一个叫 grpc.learngo module 里了,所以这样去取 proto 子包(即刚才写的 proto 文件生成代码的包)。

你去看 gRPC 的官方例子,里面还会有给这个 protobuf 接口的包取个名字:

1
2
3
import (
pb "google.golang.org/grpc/examples/helloworld/helloworld"
)

这里我没取名字,没关系,这不重要。

UserInfoServiceGetUserInfo 字段是函数类型的。所以可以把一个符合声明的函数赋给 UserInfoService 实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// getUserInfo 实现具体的获取用户信息的逻辑
func getUserInfo(ctx context.Context, request *proto.UserRequest) (response *proto.UserResponse, err error) {
name := request.Name

fmt.Println("GetUserInfo: name =", name)

// Fake query
if name == "foo" {
response = &proto.UserResponse{
Id: 1,
Name: "foo",
Age: 12,
Hobby: []string{"eating", "sleep"},
}
return response, nil
}

return response, fmt.Errorf("unknown user: name=%s", name)
}

// 实例化一个 UserInfoService
var userInfoService = proto.UserInfoService{
GetUserInfo: getUserInfo,
}

Tips:Golang 函数与函数类型 (给 Go 语言初学者)

Golang 中函数是一等公民,任何可以使用其他类型对象的地方都可以使用函数。

Golang 也支持函数类型,即将相同声明(参数接收相同个数、类型,返回相同个数、类型)的函数看作一个类型的,可以用 type 给函数类型取名字。例如:

1
2
// Operation 是计算的抽象
type Operation func(Number1, Number2 float64) float64

我们把各种接收两个 float64 参数、返回 float64 的函数都看作是 Operation 的实例

1
2
3
4
5
6
7
func Add(Number1, Number2 float64) float64 {
return Number1 + Number2
}

func Sub(Number1, Number2 float64) float64 {
return Number1 - Number2
}

Add 和 Sub 都满足了声明 func(Number1, Number2 float64) float64,所以都是 Operation 函数类型的实例。函数类型的实例是具体可调用的函数:

1
2
3
var oper Operation
oper = Add
oper(1.0, 1.0) // 相当于调用 Add(1.0, 1.0)

回到 gRPC,我们的 GetUserInfo 是一个 func(context.Context, *UserRequest) (*UserResponse, error) 函数类型的字段(只是没有给这个函数类型取名字),我们可以把任何满足了这个声明的函数赋值给它。这里我们把具体实现 getUserInfo 赋给 GetUserInfo。在其他地方使用 GetUserInfo(...) 时,就相当于使用了 getUserInfo(...)


在 main 函数中,我们需要实例化并开启服务。首先需要实例化一个 grpc 服务,然后把我们的 UserInfo 服务实例注册上。接下来,监听 TCP 网络,在监听上启动 gRPC 服务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func main() {
// 实例化 gRPC,注册服务
s := grpc.NewServer()
proto.RegisterUserInfoService(s, &userInfoService)

// 监听网络
address := "localhost:8080"
listener, err := net.Listen("tcp", address)
if err != nil {
panic(err)
}
fmt.Println("Listening tcp:", address)

// 启动 gRPC 服务
err = s.Serve(listener)
if err != nil {
panic(err)
}
}

客户端实现

客户端的实现上,和我上一篇文章写的用 net/rpc 其实差别不大。

首先是连接网络:

1
2
3
4
5
6
address := "localhost:8080"
clientConn, err := grpc.Dial(address, grpc.WithInsecure())
if err != nil {
fmt.Println("Dial error:", err)
}
defer clientConn.Close()

利用 gRPC 的 ClientConn,实例化一个客户端:

1
client := proto.NewUserInfoClient(clientConn)

(这里的 proto 还是之前 protoc 生成代码的那个包)

然后就可以调用服务了,请求参数是 context 和一个 UserRequest 实例的指针,返回 UserResponse 和 error:

1
2
3
4
5
6
7
8
req := &proto.UserRequest{Name: "foo"}

resp, err := client.GetUserInfo(context.Background(), req)

if err != nil {
fmt.Println("GetUserInfo error:", err)
}
fmt.Printf("resp: %#v", resp)

以上介绍的内容都使用了最新的版本,相比于 Google 搜索 go gRPC 教程 结果比较靠前的几篇中文文章,这里介绍的方法从 protobuf 编译到服务端实现上都有一定区别。新旧 API 各有特色,但我觉得都还是很好用的,都挺顺手。

P.S. 我的环境是 macOS Catalinago 1.12libprotoc 3.13.0protoc-gen-go v1.25.0protoc-gen-go-grpc v0.0.0-20200917190803-0f7e218c2cf4

完整的代码我还是放到了 Gist 中,需要的话可以自取:

1
2
3
By("CDFMLR", "2020-09-18")
log.Println("看看今天的日期,再看看近期诸事,我们的开发者还是要更努力啊。")
// See you.💪