微服务—go中使用gRPC
1. gRPC简介
gRPC 是一个高性能、开源、通用的RPC框架,由Google推出,基于HTTP2协议标准设计开发,默认采用Protocol Buffers数据序列化协议,支持多种开发语言。gRPC提供了一种简单的方法来精确的定义服务,并且为客户端和服务端自动生成可靠的功能库。gRPC 是一个高性能、开源、通用的RPC框架,由Google推出,基于HTTP2协议标准设计开发,默认采用Protocol Buffers数据序列化协议,支持多种开发语言。gRPC提供了一种简单的方法来精确的定义服务,并且为客户端和服务端自动生成可靠的功能库。
在gRPC客户端可以直接调用不同服务器上的远程程序,使用姿势看起来就像调用本地程序一样,很容易去构建分布式应用和服务。和很多RPC系统一样,服务端负责实现定义好的接口并处理客户端的请求,客户端根据接口描述直接调用需要的服务。客户端和服务端可以分别使用gRPC支持的不同语言实现。
1.1. 主要特性
强大的IDL:
gRPC使用ProtoBuf来定义服务,ProtoBuf是由Google开发的一种数据序列化协议(类似于XML、JSON、hessian)。ProtoBuf能够将数据进行序列化,并广泛应用在数据存储、通信协议等方面。
多语言支持:
gRPC支持多种语言,并能够基于语言自动生成客户端和服务端功能库。目前已提供了C版本grpc、Java版本grpc-java 和 Go版本grpc-go,其它语言的版本正在积极开发中,其中,grpc支持C、C++、Node.js、Python、Ruby、Objective-C、PHP和C#等语言,grpc-java已经支持Android开发。
HTTP2:
gRPC基于HTTP2标准设计,所以相对于其他RPC框架,gRPC带来了更多强大功能,如双向流、头部压缩、多复用请求等。这些功能给移动设备带来重大益处,如节省带宽、降低TCP链接次数、节省CPU使用和延长电池寿命等。同时,gRPC还能够提高了云端服务和Web应用的性能。gRPC既能够在客户端应用,也能够在服务器端应用,从而以透明的方式实现客户端和服务器端的通信和简化通信系统的构建。
更多介绍请查看官方网站
2. Protobuf⇢Go转换
这里使用一个测试文件对照说明常用结构的protobuf到golang的转换。只说明关键部分代码,详细内容请查看完整文件。示例文件在proto/test目录下。
2.1.1. Package
在proto文件中使用package关键字声明包名,默认转换成go中的包名与此一致,如果需要指定不一样的包名,可以使用go_package选项:
package test; option go_package="test";
2.1.2. Message
proto中的message对应go中的struct,全部使用驼峰命名规则。嵌套定义的message,enum转换为go之后,名称变为Parent_Child结构。
示例proto:
// Test 测试 message Test { int32 age = 1; int64 count = 2; double money = 3; float score = 4; string name = 5; bool fat = 6; bytes char = 7; // Status 枚举状态 enum Status { OK = 0; FAIL = 1; } Status status = 8; // Child 子结构 message Child { string sex = 1; } Child child = 9; map<string, string> dict = 10; }
转换结果:
// Status 枚举状态 type Test_Status int32 const ( Test_OK Test_Status = 0 Test_FAIL Test_Status = 1 ) // Test 测试 type Test struct { Age int32 `protobuf:"varint,1,opt,name=age" json:"age,omitempty"` Count int64 `protobuf:"varint,2,opt,name=count" json:"count,omitempty"` Money float64 `protobuf:"fixed64,3,opt,name=money" json:"money,omitempty"` Score float32 `protobuf:"fixed32,4,opt,name=score" json:"score,omitempty"` Name string `protobuf:"bytes,5,opt,name=name" json:"name,omitempty"` Fat bool `protobuf:"varint,6,opt,name=fat" json:"fat,omitempty"` Char []byte `protobuf:"bytes,7,opt,name=char,proto3" json:"char,omitempty"` Status Test_Status `protobuf:"varint,8,opt,name=status,enum=test.Test_Status" json:"status,omitempty"` Child *Test_Child `protobuf:"bytes,9,opt,name=child" json:"child,omitempty"` Dict map[string]string `protobuf:"bytes,10,rep,name=dict" json:"dict,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` } // Child 子结构 type Test_Child struct { Sex string `protobuf:"bytes,1,opt,name=sex" json:"sex,omitempty"` }
除了会生成对应的结构外,还会有些工具方法,如字段的getter:
func (m *Test) GetAge() int32 { if m != nil { return m.Age } return 0 }
枚举类型会生成对应名称的常量,同时会有两个map方便使用:
var Test_Status_name = map[int32]string{ 0: "OK", 1: "FAIL", } var Test_Status_value = map[string]int32{ "OK": 0, "FAIL": 1, }
2.1.3. Service
定义一个简单的Service,TestService有一个方法Test,接收一个Request参数,返回Response:
// TestService 测试服务 service TestService { // Test 测试方法 rpc Test(Request) returns (Response) {}; } // Request 请求结构 message Request { string name = 1; } // Response 响应结构 message Response { string message = 1; }
转换结果:
// 客户端接口 type TestServiceClient interface { // Test 测试方法 Test(ctx context.Context, in *Request, opts ...grpc.CallOption) (*Response, error) } // 服务端接口 type TestServiceServer interface { // Test 测试方法 Test(context.Context, *Request) (*Response, error) }
生成的go代码中包含该Service定义的接口,客户端接口已经自动实现了,直接供客户端使用者调用,服务端接口需要由服务提供方实现。
3. Protobuf语法
3.1.1. 基本规范
1.文件以.proto做为文件后缀,除结构定义外的语句以分号结尾
2.结构定义可以包含:message、service、enum
3.rpc方法定义结尾的分号可有可无
4。Message命名采用驼峰命名方式,字段命名采用小写字母加下划线分隔方式
message SongServerRequest { required string song_name = 1; }
5.Enums类型名采用驼峰命名方式,字段命名采用大写字母加下划线分隔方式
enum Foo { FIRST_VALUE = 1; SECOND_VALUE = 2; }
6.Service与rpc方法名统一采用驼峰式命名
3.1.2. 字段规则
1.字段格式:限定修饰符 | 数据类型 | 字段名称 | = | 字段编码值 | [字段默认值]
2.限定修饰符包含 required\optional\repeated
Required:表示是一个必须字段,必须相对于发送方,在发送消息之前必须设置该字段的值,对于接收方,必须能够识别该字段的意思。发送之前没有设置required字段或者无法识别required字段都会引发编解码异常,导致消息被丢弃
Optional:表示是一个可选字段,可选对于发送方,在发送消息时,可以有选择性的设置或者不设置该字段的值。对于接收方,如果能够识别可选字段就进行相应的处理,如果无法识别,则忽略该字段,消息中的其它字段正常处理。---因为optional字段的特性,很多接口在升级版本中都把后来添加的字段都统一的设置为optional字段,这样老的版本无需升级程序也可以正常的与新的软件进行通信,只不过新的字段无法识别而已,因为并不是每个节点都需要新的功能,因此可以做到按需升级和平滑过渡
Repeated:表示该字段可以包含0~N个元素。其特性和optional一样,但是每一次可以包含多个值。可以看作是在传递一个数组的值
3.数据类型
Protobuf定义了一套基本数据类型。几乎都可以映射到C++\Java等语言的基础数据类型
+ N 表示打包的字节并不是固定。而是根据数据的大小或者长度
+ 关于 fixed32 和int32的区别。fixed32的打包效率比int32的效率高,但是使用的空间一般比int32多。因此一个属于时间效率高,一个属于空间效率高
4.字段名称
a. 字段名称的命名与C、C++、Java等语言的变量命名方式几乎是相同的
b. protobuf建议字段的命名采用以下划线分割的驼峰式。例如 first_name 而不是firstName
5.字段编码值
a.有了该值,通信双方才能互相识别对方的字段,相同的编码值,其限定修饰符和数据类型必须相同,编码值的取值范围为 1~2^32(4294967296)
b.其中 1~15的编码时间和空间效率都是最高的,编码值越大,其编码的时间和空间效率就越低,所以建议把经常要传递的值把其字段编码设置为1-15之间的值
c.1900~2000编码值为Google protobuf 系统内部保留值,建议不要在自己的项目中使用
6.字段默认值
当在传递数据时,对于required数据类型,如果用户没有设置值,则使用默认值传递到对端
3.1.3. service如何定义
如果想要将消息类型用在RPC系统中,可以在.proto文件中定义一个RPC服务接口,protocol buffer编译器会根据所选择的不同语言生成服务接口代码
例如,想要定义一个RPC服务并具有一个方法,该方法接收SearchRequest并返回一个SearchResponse,此时可以在.proto文件中进行如下定义:
service SearchService {
rpc Search (SearchRequest) returns (SearchResponse) {}
}
生成的接口代码作为客户端与服务端的约定,服务端必须实现定义的所有接口方法,客户端直接调用同名方法向服务端发起请求,比较麻烦的是,即便业务上不需要参数也必须指定一个请求消息,一般会定义一个空message
3.1.4. Message如何定义
1.一个message类型定义描述了一个请求或响应的消息格式,可以包含多种类型字段
2.例如定义一个搜索请求的消息格式,每个请求包含查询字符串、页码、每页数目
3.字段名用小写,转为go文件后自动变为大写,message就相当于结构体
syntax = "proto3"; message SearchRequest { string query = 1; // 查询字符串 int32 page_number = 2; // 页码 int32 result_per_page = 3; // 每页条数 }
4.首行声明使用的protobuf版本为proto3
5.SearchRequest 定义了三个字段,每个字段声明以分号结尾,.proto文件支持双斜线 // 添加单行注释
3.1.5. 添加更多Message类型
一个.proto文件中可以定义多个消息类型,一般用于同时定义多个相关的消息,例如在同一个.proto文件中同时定义搜索请求和响应消息
syntax = "proto3"; // SearchRequest 搜索请求 message SearchRequest { string query = 1; // 查询字符串 int32 page_number = 2; // 页码 int32 result_per_page = 3; // 每页条数 } // SearchResponse 搜索响应 message SearchResponse { ... }
3.1.6. 如何使用其他Message
message支持嵌套使用,作为另一message中的字段类型
message SearchResponse { repeated Result results = 1; } message Result { string url = 1; string title = 2; repeated string snippets = 3; }
3.1.7. Message嵌套的使用
1.支持嵌套消息,消息可以包含另一个消息作为其字段。也可以在消息内定义一个新的消息
2.内部声明的message类型名称只可在内部直接使用
message SearchResponse { message Result { string url = 1; string title = 2; repeated string snippets = 3; } repeated Result results = 1; }
3.另外,还可以多层嵌套
message Outer { // Level 0 message MiddleAA { // Level 1 message Inner { // Level 2 int64 ival = 1; bool booly = 2; } } message MiddleBB { // Level 1 message Inner { // Level 2 int32 ival = 1; bool booly = 2; } } }
3.1.8. proto3的Map类型
1.proto3支持map类型声明
map<key_type, value_type> map_field = N; message Project {...} map<string, Project> projects = 1;
2.键、值类型可以是内置的类型,也可以是自定义message类型
3.字段不支持repeated属性
3.1.9. .proto文件编译
1.通过定义好的.proto文件生成Java, Python, C++, Go, Ruby, JavaNano, Objective-C, or C# 代码,需要安装编译器protoc
2.当使用protocol buffer编译器运行.proto文件时,编译器将生成所选语言的代码,用于使用在.proto文件中定义的消息类型、服务接口约定等。不同语言生成的代码格式不同:
a. C++: 每个.proto文件生成一个.h文件和一个.cc文件,每个消息类型对应一个类
b. Java: 生成一个.java文件,同样每个消息对应一个类,同时还有一个特殊的Builder类用于创建消息接口
c. Python: 姿势不太一样,每个.proto文件中的消息类型生成一个含有静态描述符的模块,该模块与一个元类metaclass在运行时创建需要的Python数据访问类
d. Go: 生成一个.pb.go文件,每个消息类型对应一个结构体
e. Ruby: 生成一个.rb文件的Ruby模块,包含所有消息类型
f. JavaNano: 类似Java,但不包含Builder类
g. Objective-C: 每个.proto文件生成一个pbobjc.h和一个pbobjc.m文件
h. C#: 生成.cs文件包含,每个消息类型对应一个类
3.1.10. import导入定义
1.可以使用import语句导入使用其它描述文件中声明的类型
2.protobuf 接口文件可以像C语言的h文件一个,分离为多个,在需要的时候通过 import导入需要对文件。其行为和C语言的#include或者java的import的行为大致相同,例如import "others.proto";
3.protocol buffer编译器会在 -I / --proto_path参数指定的目录中查找导入的文件,如果没有指定该参数,默认在当前目录中查找
3.1.11. 包的使用
1.在.proto文件中使用package声明包名,避免命名冲突
syntax = "proto3"; package foo.bar; message Open {...}
2.在其他的消息格式定义中可以使用包名+消息名的方式来使用类型,如
message Foo { ... foo.bar.Open open = 1; ... }
3.在不同的语言中,包名定义对编译后生成的代码的影响不同
a. C++ 中:对应C++命名空间,例如Open会在命名空间foo::bar中
b. Java 中:package会作为Java包名,除非指定了option jave_package选项
c. Python 中:package被忽略
d. Go 中:默认使用package名作为包名,除非指定了option go_package选项
e. JavaNano 中:同Java
f. C# 中:package会转换为驼峰式命名空间,如Foo.Bar,除非指定了option csharp_namespace选项
4. 小案例
按照惯例,这里也从一个Hello项目开始,本项目定义了一个商品 Service。
流程:
- 编写
.proto
描述文件 - 编译生成
.pb.go
文件 - 服务端实现约定的接口并提供服务
- 客户端按照约定调用
.pb.go
文件中的方法请求服务
项目结构:
Step1:编写描述文件:Prod.proto
syntax="proto3"; package services; message ProdRequest { int32 prod_id = 1; //商品ID } message ProdResponse { int32 prod_stock = 1; //商品库存 } service ProductService { rpc GetProductStock (ProdRequest) returns (ProdResponse); }
Prod.proto文件中定义了一个ProductService ,该服务包含一个GetProductStock 方法,同时声明了ProdRequest和ProdResponse消息结构用于请求和响应。客户端使用ProdRequest参数调用GetProductStock 方法请求服务端,服务端响应ProdResponse消息。一个最简单的服务就定义好了。
Step2:编译生成.pb.go
文件
$ cd server/pb
# 编译Prod.proto
$ protoc -I . --go_out=plugins=grpc:. ./Prod.proto
在当前目录内生成的Prod.pb.go文件,按照.proto文件中的说明,包含服务端接口ProductServer描述,客户端接口及实现ProdClient,及ProdRequest、ProdResponse结构体。
注意:不要手动编辑该文件
server/services/ProductService.go
package services import context "context" type ProdService struct{} func (*ProdService) GetProductStock(ctx context.Context, req *ProdRequest) (*ProdResponse, error) { return &ProdResponse{ ProdStock: 100, }, nil }
Step3:实现服务端接口 server/server.go
package main import ( "go_code/grpc-1/server/services" "google.golang.org/grpc" "log" "net" ) func main() { rpcServer := grpc.NewServer() services.RegisterProductServiceServer(rpcServer, new(services.ProdService)) listen, err := net.Listen("tcp", ":8081") if err != nil { log.Fatalf("启动网络监听失败 %v\n", err) } rpcServer.Serve(listen) }
服务端引入编译后的proto包,定义一个空结构用于实现约定的接口,接口描述可以查看hello.pb.go文件中的HelloServer接口描述。实例化grpc Server并注册HelloService,开始提供服务。
运行:
go run server.go
Step4:实现客户端调用 client/client.go
package main import ( "context" "fmt" "log" "go_code/grpc-1/client/services" "google.golang.org/grpc" ) func main() { conn, err := grpc.Dial(":8081", grpc.WithInsecure()) if err != nil { log.Fatalf("连接GRPC服务端失败 %v\n", err) } defer conn.Close() prodClient := services.NewProductServiceClient(conn) prodRes, err := prodClient.GetProductStock(context.Background(), &services.ProdRequest{ProdId: 12}) if err != nil { log.Fatalf("请求GRPC服务端失败 %v\n", err) } fmt.Println(prodRes.ProdStock) }
客户端初始化连接后直接调用hello.pb.go中实现的SayHello方法,即可向服务端发起请求,使用姿势就像调用本地方法一样。
运行:
go run client.go 100 // 接收到服务端响应
如果你收到了"100"的回复,恭喜你已经会使用grpc了。
建议到这里仔细看一看Prod.pb.go文件中的内容,对比Prod.proto文件,理解protobuf中的定义转换为golang后的结构。