分布式系统应用——gRPC教程
此文章用于本人分布式第一次作业的参考手册
一、RPC介绍
RPC
是Remote Procedure Call
的简称,中文叫远程过程调用。
简单理解:现在有两台服务器A和B。部署在A服务器上的应用,想调用部署在B服务器上的另一个应用提供的方法,由于不在一个内存空间,不能直接调用,需要通过网络来达到调用的效果。现在,我们在A服务的一个本地方法中封装调用B的逻辑,然后只需要在本地使用这个方法,就达到了调用B的效果。
对使用者来说,屏蔽了细节。你只需要知道调用这个方法返回的结果,而无需关注底层逻辑。从封装的那个方法角度来看,调用B之前我们需要如下约定:
- 调用的语义,也可以理解为接口规范。(比如
RESTful
)- 网络传输协议 (比如
HTTP
)- 数据序列化反序列化规范(比如
JSON
)。
图1 RPC示例
RPC
是一种客户端-服务端(Client/Server
)模式。从某种角度来看,所有本身应用程序之外的调用都可以归类为RPC
。无论是微服务、第三方HTTP
接口,还是读写数据库中间件Mysql
、Redis
。
HTTP
只是一个通信协议,工作在计算机网络的最高层。而RPC
是一个完整的远程调用方案。它包含了:接口规范、传输协议、数据序列化反序列化规范。
二、什么是gRPC和protobuf
2.1 gRPC
gRPC官网:https://grpc.io/docs/
gRPC是一个高性能、开源和通用的RPC框架,面向移动和HTTP/2设计。支持的语言如下:
Language | OS | Compilers / SDK |
C/C++ | Linux, Mac | GCC 6.3+, Clang 6+ |
C/C++ | Windows 10+ | Visual Studio 2017+ |
C# | Linux, Mac | .NET Core, Mono 4+ |
C# | Windows 10+ | .NET Core, NET 4.5+ |
Dart | Windows, Linux, Mac | Dart 2.12+ |
Go | Windows, Linux, Mac | Go 1.13+ |
Java | Windows, Linux, Mac | Java 8+ (KitKat+ for Android) |
Kotlin | Windows, Linux, Mac | Kotlin 1.3+ |
Node.js | Windows, Linux, Mac | Node v8+ |
Objective-C | macOS 10.10+, iOS 9.0+ | Xcode 12+ |
PHP | Linux, Mac | PHP 7.0+ |
Python | Windows, Linux, Mac | Python 3.5+ |
Ruby | Windows, Linux, Mac | Ruby 2.3+ |
优点
- 性能:体积小、速度快
- 使用简单:编译器负责序列化和反序列化;向后兼容性好
- 跨平台、跨语言、可扩展
缺点
- 不适合对基于文本的文档建模:例如html
- 自解释性差,必须有proto文件才能知道数据结构
2.2 架构
在gRPC中,客户端应用程序可以直接调用不同机器上的服务器应用程序的方法,就像它是一个本地对象一样,使你更容易创建分布式应用程序和服务。正如许多RPC系统一样,gRPC是围绕着定义一个服务的想法,指定可以远程调用的方法及其参数和返回类型。在服务器端,服务器实现这个接口并运行gRPC服务器来处理客户端的调用。在客户端,客户端有一个stub桩代码
提供与服务器相同的方法。
图2 gRPC架构
gRPC客户端和服务器可以在各种环境中运行并相互沟通——从Google内部的服务器到你自己的桌面——并且可以用任何gRPC支持的语言编写。因此,你可以很容易地用Java创建一个gRPC服务器,客户端用Go、Python或Ruby。此外,最新的谷歌API将有gRPC版本的接口,让你轻松地在你的应用程序中建立谷歌功能。
2.3 protobuf
2.3.1 简介
默认情况下,gRPC使用谷歌成熟的开源机制Protocol Buffers来序列化 结构化的数据(尽管它也可以用于其他数据格式,如JSON)。
序列化:将数据结构或对象转换成二进制的过程
反序列化:将在序列化过程中所产生的二进制串转换成数据结构或对象的过程。
protobuf是谷歌开源的一种数据格式,适合高性能,对响应速度有要求的数据传输场景。因profobuf是二进制数据格式,需要编码和解码。数据本身不具有可读性。因此只能反序列化之后得到真正可读的数据。
优势:
- 序列化后体积相比)son和XML很小,适合网络传输
- 支持跨平台多语言
- 消息格式升级和兼容性还不错
- 序列化反序列化速度很快
protobuf的文档地址:[https://developers.google.com/protocol-buffers/docs/overview
在使用 Protocol Buffers时,第一步是为你想在proto file中序列化的数据定义结构:这是一个以.proto
为扩展名的普通文本文件。Protocol Buffers的结构是messages,每个message是一个小的逻辑信息记录,包含一系列称为fields的name-value对。下面是一个简单的例子。
message Person {
string name = 1;
int32 id = 2;
bool has_ponycopter = 3;
}
然后,一旦你指定了你的数据结构,你就可以使用协议缓冲区编译器protoc
从你的proto定义中用你喜欢的语言生成数据访问类。这些类为每个字段提供简单的访问器,如name()
和set_name()
,以及将整个结构序列化/解析为原始字节的方法。因此,如果你选择的语言是C++,在上面的例子中运行编译器将生成一个名为Person的类。然后你可以在你的应用程序中使用这个类来填充、序列化和检索Protocol Buffers的信息。
// The greeter service definition.
service Greeter {
// Sends a greeting
rpc SayHello (HelloRequest) returns (HelloReply) {}
}
// The request message containing the user's name.
message HelloRequest {
string name = 1;
}
// The response message containing the greetings
message HelloReply {
string message = 1;
}
gRPC使用带有一个特殊的gRPC插件的protoc
,从你的proto文件中生成代码:你会得到生成的gRPC客户端和服务器代码,以及用于填充、序列化和检索你的消息类型的常规protocol buffer。
2.3.2 安装
1️⃣ 第一步:下载通用编译器
地址:https://github.com/protocolbuffers/protobuf/releases
根据不同的操作系统,下载不同的包,64位windows电脑,就下载protoc-3.20.3-win64.zip
。
2️⃣ 第二步:配置环境变量
下载完毕后解压缩放至任意目录下,将bin目录配置到Path环境变量中
3️⃣ 第三步:安装go专用的protoc的生成器
go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@latest
go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2@latest
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
4️⃣ 第四步:vscode上安装vscode-proto3
2.3.3 初体验
假设,我们现在需要传输用户信息,其中有username和age两个字段
//指定的当前proto语法的版本,有2和3
syntax = "proto3";
//option go_package = "path;name"; ath 表示生成的go文件的存放地址,会自动生成目录的
// name 表示生成的go文件所属的包名
option go_package="../service";
// 指定等会文件生成出来的package
package service;
message User {
string username = 1;
int32 age = 2;
}
运行protoc命令编译成go中间文件
# 编译user.proto之后输出到service文件夹
protoc --go_out=../service user.proto
测试
package main
import (
"fmt"
"google.golang.org/protobuf/proto"
"testProto/service"
)
func main() {
user := &service.User{
Username: "mszlu",
Age: 20,
}
//转换为protobuf
marshal, err := proto.Marshal(user)
if err != nil {
panic(err)
}
newUser := &service.User{}
err = proto.Unmarshal(marshal, newUser)
if err != nil {
panic(err)
}
fmt.Println(newUser.String())
}
输出
username:"mszlu" age:20
2.3.4 proto文件介绍
1. message
message
:protobuf
中定义一个消息类型是通过关键字message
字段指定的。
消息就是需要传输的数据格式的定义。
message关键字类似于C++中的class,Java中的class,go中的struct,例如:
message User {
string username = 1;
int32 age = 2;
}
在消息中承载的数据分别对应于每一个字段。
其中每个字段都有一个名字和一种类型 。
2. 字段规则
-
required
:消息体中必填字段,不设置会导致编解码异常。(默认,protobuf2中才用写) -
optional
: 消息体中可选字段。 -
repeated
: 消息体中可重复字段,重复的值的顺序会被保留,在go中重复的会被定义为切片。
message User {
string username = 1;
int32 age = 2;
optional string password = 3;
repeated string address = 4;
}
3. 字段映射
proto Type | Notes | C++ Type | Python Type | Go Type |
double | double | float | float64 | |
float | float | float | float32 | |
int32 | 使用变长编码,对于负值的效率很低,如果你的域有 可能有负值,请使用sint64替代 | int32 | int | int32 |
uint32 | 使用变长编码 | uint32 | int/long | uint32 |
uint64 | 使用变长编码 | uint64 | int/long | uint64 |
sint32 | 使用变长编码,这些编码在负值时比int32高效的多 | int32 | int | int32 |
sint64 | 使用变长编码,有符号的整型值。编码时比通常的 int64高效。 | int64 | int/long | int64 |
fixed32 | 总是4个字节,如果数值总是比总是比228大的话,这 个类型会比uint32高效。 | uint32 | int | uint32 |
fixed64 | 总是8个字节,如果数值总是比总是比256大的话,这 个类型会比uint64高效。 | uint64 | int/long | uint64 |
sfixed32 | 总是4个字节 | int32 | int | int32 |
sfixed32 | 总是4个字节 | int32 | int | int32 |
sfixed64 | 总是8个字节 | int64 | int/long | int64 |
bool | bool | bool | bool | |
string | 一个字符串必须是UTF-8编码或者7-bit ASCII编码的文 本。 | string | str/unicode | string |
bytes | 可能包含任意顺序的字节数据。 | string | str | []byte |
4.默认值
protobuf3 删除了 protobuf2 中用来设置默认值的 default 关键字,取而代之的是protobuf3为各类型定义的默认值,也就是约定的默认值,如下表所示:
类型 | 默认值 |
bool | false |
整型 | 0 |
string | 空字符串"" |
枚举enum | 第一个枚举元素的值,因为Protobuf3强制要求第一个枚举元素的值必须是0,所以枚举的默认值就是0; |
message | 不是null,而是DEFAULT_INSTANCE |
5. 标识号
标识号
:在消息体的定义中,每个字段都必须要有一个唯一的标识号,标识号是[0,2^29-1]范围内的一个整数。
message Person {
string name = 1; // (位置1)
int32 id = 2;
optional string email = 3;
repeated string phones = 4; // (位置4)
}
以Person为例,name=1,id=2, email=3, phones=4 中的1-4就是标识号。
6. 定义多个消息类型
一个proto文件中可以定义多个消息类型
message UserRequest {
string username = 1;
int32 age = 2;
optional string password = 3;
repeated string address = 4;
}
message UserResponse {
string username = 1;
int32 age = 2;
optional string password = 3;
repeated string address = 4;
}
7、 嵌套消息
可以在其他消息类型中定义、使用消息类型,在下面的例子中,Person消息就定义在PersonInfo消息内,如 :
message PersonInfo {
message Person {
string name = 1;
int32 height = 2;
repeated int32 weight = 3;
}
repeated Person info = 1;
}
如果你想在它的父消息类型的外部重用这个消息类型,你需要以PersonInfo.Person的形式使用它,如:
message PersonMessage {
PersonInfo.Person info = 1;
}
当然,你也可以将消息嵌套任意多层,如 :
message Grandpa { // Level 0
message Father { // Level 1
message son { // Level 2
string name = 1;
int32 age = 2;
}
}
message Uncle { // Level 1
message Son { // Level 2
string name = 1;
int32 age = 2;
}
}
}
8. 定义服务
如果想要将消息类型用在RPC系统中,可以在.proto文件中定义一个RPC服务接口,protocol buffer 编译器将会根据所选择的不同语言生成服务接口代码及存根。
service SearchService {
//rpc 服务的函数名 (传入参数)返回(返回参数)
rpc Search (SearchRequest) returns (SearchResponse);
}
上述代表表示,定义了一个RPC服务,该方法接收SearchRequest返回SearchResponse
三、gRPC实例
3.1 protobuf文件
hello.proto
syntax = "proto3";
package service;
option go_package = '../service';
// 定义request model
message ProductRequest {
int32 prod_id = 1; // 1代表顺序
}
// 定义response model
message ProductResponse {
int32 prod_stock = 1; // 1代表顺序
}
// 定义服务主体
service ProdService {
// 定义方法
rpc GetProductStock(ProductRequest) returns(ProductResponse);
}
执行命令:protoc --go_out=. --go-grpc_out=. *.proto
生成service文件夹hello.pb.go
,hello_grpc.pb.go
hello.pb.go
:结构体相关,每个结构体都有5个相同的方法,分别是Reset()
、String()
、ProtoMessage()
、ProtoReflect()
、Descriptor()
;
hello_grpc.pb.go
:rpc相关的方法,分为(Client)客户端,(Server)服务端。
- Client端:有接口,结构体,建立客户端连接的方法。
- Server端:有接口,结构体,方法等6个,需要注意的是服务端在使用前需要重写结构体并实现接口方法才行,否则会报错。
3.2 服务端实现
服务端需要创建一个结构体,同时实现在hello.proto种声明的接口
server.go
package main
import (
"context"
"grpc01/service"
"log"
"net"
"google.golang.org/grpc"
)
type server struct {
service.UnimplementedProdServiceServer
}
func (s *server) GetProductStock(ctx context.Context, in *service.ProductRequest) (*service.ProductResponse, error) {
return &service.ProductResponse{ProdStock: in.GetProdId()}, nil
}
func main() {
//1、创建服务
s := grpc.NewServer()
//2、注册服务
service.RegisterProdServiceServer(s, &server{})
//3、监听服务端口
listener, err := net.Listen("tcp", ":8002")
if err != nil {
log.Fatal("服务监听端口失败", err)
}
//4、启动服务
_ = s.Serve(listener)
}
3.3 客户端实现
client.go
package main
import (
"context"
"fmt"
"google.golang.org/grpc"
service "grpc01/service"
"log"
)
func main() {
//1、 建立连接
conn, err := grpc.Dial(":8002", grpc.WithInsecure())
if err != nil {
log.Fatalf("did not connect: %v", err)
}
defer conn.Close()
// 2. 调用 hello_grpc.pb.go 中的NewProdServiceClient方法
productServiceClient := service.NewProdServiceClient(conn)
//3、调用rpc方法
resp, err := productServiceClient.GetProductStock(context.Background(), &service.ProductRequest{
ProdId: 233456,
})
if err != nil {
log.Fatal("调用gRPC方法错误: ", err)
}
fmt.Println("调用gRPC方法成功,ProdStock = ", resp.ProdStock)
}