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 | $ brew install protobuf |
我们要在 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 | syntax = "proto3"; |
编译,生成 Golang 代码:
1 | $ protoc --go_out=. hello.proto |
go_out
告诉 protoc 编译器加载 protoc-gen-go 工具来生成代码,把生成代码放到当前目录。忽略打印的 WARNING,protoc
在当前路径下生成了一个 hello.pb.go
的文件。里面有很多内容,包括:
1 | // Code generated by protoc-gen-go. DO NOT EDIT. |
上面抽出来的这些代码我们都很熟悉了,它把 proto 文件中写的 message
翻译成了 golang 的结构体,这样我们就能直接在 Go 中使用这个结构了。
在编译时,你也可以编译成其他语言的,用 --<lang>_out
指定一个输出目录就行了:
1 | $ protoc --java_out=. --cpp_out=. hello.proto |
你可以生成各种语言的代码看看,Go 生成的代码绝对是最简洁、最看得懂的(之一)。
Protobuf 语法
我们把 Protocol Buffers 代码写在 .proto
文件中。
注释
Protobuf 的注释还是我们熟悉的 C/C++ 风格:
1 | // 这是注释 |
语法版本
.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 | message SongServerRequest { |
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 |
|
整数 | int32 ,int64 |
int32 ,int64 |
变长编码,对负数编码效率低 |
无符号整数 | uint32 ,uint64 |
uint32 ,uint64 |
变长编码 |
有符号整数 | sint32 ,sint64 |
int32 ,int64 |
变长编码,对负数编码比 int32/64 高效 |
定长整数 | fixed32 ,fixed64 |
int32 ,int64 |
固定空间,定长编码,适合大数 |
定长有符号整数 | sfixed32 ,sfixed64 |
int32 ,int64 |
定长编码 |
浮点数 | float ,double |
float32 ,float64 |
此外,还有几个复合类型:
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 | enum EnumNotAllowingAlias { |
reserved
reserved 用来指明此 message 不使用(保留)某些字段。通过编码或字段名来设置保留:
1 | message AllNormalypes { |
声明保留的字段就不能再定义,否则编译会出错。
message
一个 message 可以作为另一个 message 的字段出现:
1 | message SomeOtherMessage { |
服务
我们常把 Proto Buffers 用在 RPC 里嘛,所以 Proto Buffers 是可以直接定义 RPC 服务接口的。这个接口可以直接用 gRPC 去实现:
1 | service SearchService { |
编译时 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 | grpc |
- 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 | syntax = "proto3"; |
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 298、Protobuf Issue 1070 等等一些东西。总而言之,Protobuf 生成 gRPC 代码的工具最近有些变化,目前使用 protoc 生成 Golang 的 gRPC 代码,需要安装一个额外的包:
1 | $ go install google.golang.org/grpc/cmd/protoc-gen-go-grpc |
然后:
1 | $ cd proto |
忽略输出的 Missing 'go_package' option
WARNING。就可以看到生成的接口文件了:
1 | proto/ |
--go_out
生成的user.pb.go
中定义了 message 在 golang 中的结构体,以及其编码解码。--go-rpc_out
生成的user_grpc.pb.go
里面是 service 在 golang 中的 gRPC 服务、客户端接口。
服务端实现
查看 user_grpc.pb.go
中的代码,发现里面定义有这些东西:
1 | // UserInfoService is the service API for UserInfo service. |
靠我六级高达 410 分的惊人英语实力,信达雅地翻译一下:
UserInfoService
是 UserInfo 服务的服务端接口。在调用RegisterUserInfoService
之前, 应该将各种服务功能的具体实现赋给UserInfoService
的对应字段。RegisterUserInfoService
在 gRPC 服务器实例上注册一个UserInfoService
实例。
新建一个 server/main.go
,在里面编写具体的服务端代码。首先要导入 protobuf 的接口:
1 | import "grpc.learn/proto" |
这里我把所有代码都放到一个叫 grpc.learn
的 go module
里了,所以这样去取 proto 子包(即刚才写的 proto 文件生成代码的包)。
你去看 gRPC 的官方例子,里面还会有给这个 protobuf 接口的包取个名字:
1 | import ( |
这里我没取名字,没关系,这不重要。
在 UserInfoService
中 GetUserInfo
字段是函数类型的。所以可以把一个符合声明的函数赋给 UserInfoService
实例:
1 | // getUserInfo 实现具体的获取用户信息的逻辑 |
Tips:Golang 函数与函数类型 (给 Go 语言初学者)
Golang 中函数是一等公民,任何可以使用其他类型对象的地方都可以使用函数。
Golang 也支持函数类型,即将相同声明(参数接收相同个数、类型,返回相同个数、类型)的函数看作一个类型的,可以用 type 给函数类型取名字。例如:
1 | // Operation 是计算的抽象 |
我们把各种接收两个 float64 参数、返回 float64 的函数都看作是 Operation 的实例。
1 | func Add(Number1, Number2 float64) float64 { |
Add 和 Sub 都满足了声明 func(Number1, Number2 float64) float64
,所以都是 Operation 函数类型的实例。函数类型的实例是具体可调用的函数:
1 | var oper Operation |
回到 gRPC,我们的 GetUserInfo 是一个 func(context.Context, *UserRequest) (*UserResponse, error)
函数类型的字段(只是没有给这个函数类型取名字),我们可以把任何满足了这个声明的函数赋值给它。这里我们把具体实现 getUserInfo
赋给 GetUserInfo
。在其他地方使用 GetUserInfo(...)
时,就相当于使用了 getUserInfo(...)
。
在 main 函数中,我们需要实例化并开启服务。首先需要实例化一个 grpc 服务,然后把我们的 UserInfo 服务实例注册上。接下来,监听 TCP 网络,在监听上启动 gRPC 服务:
1 | func main() { |
客户端实现
客户端的实现上,和我上一篇文章写的用 net/rpc
其实差别不大。
首先是连接网络:
1 | address := "localhost:8080" |
利用 gRPC 的 ClientConn
,实例化一个客户端:
1 | client := proto.NewUserInfoClient(clientConn) |
(这里的 proto 还是之前 protoc 生成代码的那个包)
然后就可以调用服务了,请求参数是 context 和一个 UserRequest 实例的指针,返回 UserResponse 和 error:
1 | req := &proto.UserRequest{Name: "foo"} |
以上介绍的内容都使用了最新的版本,相比于 Google 搜索 go gRPC 教程
结果比较靠前的几篇中文文章,这里介绍的方法从 protobuf 编译到服务端实现上都有一定区别。新旧 API 各有特色,但我觉得都还是很好用的,都挺顺手。
P.S. 我的环境是 macOS Catalina
, go 1.12
,libprotoc 3.13.0
,protoc-gen-go v1.25.0
,protoc-gen-go-grpc v0.0.0-20200917190803-0f7e218c2cf4
。
完整的代码我还是放到了 Gist 中,需要的话可以自取:
1 | By("CDFMLR", "2020-09-18") |