RPC入门
RPC的语义是远程过程调用,在一般的印象中,就是将一个服务调用封装在一个本地方法中,让调用者像使用本地方法一样调用服务,对其屏蔽实现细节。而具体的实现是通过调用方和服务方的一套约定,基于TCP长连接进行数据交互达成。
上面的解释似云里雾里,仅仅了解到这种程度是远远不够的,还需要更进一步,以相对底层和抽象的视角来理解RPC。
三个特点
广义上来讲,所有本应用程序外的调用都可以归类为RPC,不管是分布式服务,第三方服务的HTTP接口,还是读写Redis的一次请求。从抽象的角度来讲,它们都一样是RPC,由于不在本地执行,都有三个特点:
- 需要事先约定调用的语义(接口语法),亦即函数映射
- 需要网络传输
- 需要约定网络传输中的内容格式,存在数据转换为字节流的过程
以一次Redis调用为例,执行redis.set("rpc", 1)这个调用,其中:
set及其参数("rpc", 1),就是对调用语义的约定,由redis的API给出- RedisServer会监听一个服务端口,通过TCP传输内容,用异步事件驱动实现高并发
- 底层库会约定数据如何进行编解码,如何标识命令和参数,如何表示结果,如何表示数据的结尾等等
这三个特点都是因为调用不在本地而不得不衍生出来的问题,也因此决定了RPC的形态。所有的RPC解决方案都是在解决这三个问题,不断地在提出更加优良的解决方案,试图达到更好的性能,更低的使用成本。 本文也将围绕这三个特点来展开内容。
常规的RPC一般都是基于一个大的内部服务,进行分布式拆分,由于其语义上以本地方法的作为入口,那么天然的就更倾向于具备高性能、支持复杂参数和返回值、跨语言等特性。下图是RPC调用的过程示意图:
因此,RPC过程需要解决三个问题:
- 如何确定要执行的函数? 在本地调用中,函数主体通过函数指针函数指定,然后调用函数,编译器通过函数指针函数自动确定函数在内存中的位置。但是在 RPC 中,调用不能通过函数指针完成,因为它们的内存地址可能完全不同。因此,调用方和被调用方都需要维护一个{ function <-> ID }映射表,以确保调用正确的函数。
- 如何表达参数? 本地过程调用中传递的参数是通过堆栈内存结构实现的,但 RPC 不能直接使用内存传递参数,因此参数或返回值需要在传输期间序列化并转换成字节流,反之亦然。
- 如何进行网络传输? 函数的调用方和被调用方通常是通过网络连接的,也就是说,function ID 和序列化字节流需要通过网络传输,因此,只要能够完成传输,调用方和被调用方就不受某个网络协议的限制。例如,一些 RPC 框架使用 TCP 协议,一些使用 HTTP。
以往实现跨服务调用的时候,我们会采用RESTful API的方式,被调用方会对外提供一个HTTP接口,调用方按要求发起HTTP请求并接收API接口返回的响应数据。既然使用API调用也能实现类似远程调用的目的,为什么还要用RPC呢?
使用 RPC 的目的是让我们调用远程方法像调用本地方法一样无差别。并且基于RESTful API通常是基于HTTP协议,传输数据采用JSON等文本协议,相较于RPC 直接使用TCP协议,传输数据多采用二进制协议来说,RPC通常相比RESTful API性能会更好。
RESTful API多用于前后端之间的数据传输,而目前微服务架构下各个微服务之间多采用RPC调用。一次RPC调用大致需要以下过程:
- IDL(Interface Description Language)文件,以中立方式描述接口,使得不同语言编写的程序可以相互通信
- 生成代码。将IDL文件转换为静态库
- 编解码,内存中文件<->字节序列
- 通信协议,规定数据的传输内容与格式
- 网络传输
分层结构
以Thrift结构为例,了解RPC分层结构。
- 编码解码层
- 协议层
- 网络通信层
编解码层
数据格式
-
语言特定的格式,编程语言内建的将内存对象编码为字节序列的格式
-
文本格式,JSON,XML,CSV等文本格式,具有人类可读性
-
二进制编码,具备跨语言和高性能等优点,例如Thrift的BinaryProtocol,Protobuf等
-
由于前两种都有一定的限制,可能会导致歧义,常使用二进制编码的形式对代码进行编码,实现可以有TLV编码和Varint编码。
二进制编码
- TLV编码
T-Tag:标签,可以理解为类型
L-Langth:长度
V-Value:值,Value也可以是一个TLV结构
协议层
协议构造:
- LENGTH:数据包大小
- HEADER MAGIC: 标识版本信息,协议解析时候快速校验
- SEQUENCE NUMBER: 表示数据包的 segID可用于多路复用,单连接内递增
- HEADER SIZE: 头部长度,从第14个字节开始计算一直到 PAYLOAD前
- PROTOCOL ID:编解码方式,有 Binary 和Compact 两种
- TRANSFORM ID: 压方式,如 zlib 和snappyINFO ID: 传递一些定制的 meta 信息
- PAYLOAD: 消息体
协议解析:
网络通信层
网络库
- 提供易用 API
- 封装底层 Socket API
- 连接管理和事件分发
- 功能
- 协议支持: tcp、udp 和 uds 等
- 优雅退出、异常处理等
- 性能
- 应用层 buffer 减少 copy
- 高性能定时器、对象池等
gPRC介绍
RPC框架都有以下几个目标:
- 尽可能快地序列化、反序列化
- 序列化后的体积越小越好
- 跨语言,和语言无关
- 简单、类型明确
- 易扩展,可以简单的迭代,向后兼容
所有RPC框架都是在围绕这几个点不断优化,以更优的方案,达到更低的成本,更快的速度。要想达到这个目的,内容编码方式就是一个非常重要的点,RPC调用的request和response内容在调用过程中有着不小的消耗:
- 内容的序列化、反序列化,如果效率更高,则对CPU消耗会更小
- 内容会在网络中传输,协议栈拷贝成本、带宽成本、GC等。体积越小,效率越高
Protocol Buffers
gRPC对此的解决方案是丢弃json、xml这种传统策略,使用 Protocol Buffers,这是Google开发的一种跨语言、跨平台、可扩展的用于序列化数据协议。
// XXXX.proto
service Test {
rpc HowRpcDefine (Request) returns (Response) ; // 定义一个RPC方法
}
message Request {
//类型 | 字段名字| 标号
int64 user_id = 1;
string name = 2;
}
message Response {
repeated int64 ids = 1; // repeated 表示数组
Value info = 2; // 可嵌套对象
map<int, Value> values = 3; // 可输出map映射
}
message Value {
bool is_man = 1;
int age = 2;
}
以上是一个使用样例,包含方法定义、入参、出参。可以看出有几个明确的特点:
- 有明确的类型,支持的类型有多种
- 每个field会有名字
- 每个field有一个数字标号,一般按顺序排列(下文编解码会用到这个点)
- 能表达数组、map映射等类型
- 通过嵌套message可以表达复杂的对象
- 方法、参数的定义落到一个.proto 文件中,依赖双方需要同时持有这个文件,并依此进行编解码
这可以满足RPC调用的需求,具体的使用语法此处不做赘述。
作为一个以跨语言为目标的序列化方案,protobuf能做到一份.proto文件走天下,不管什么语言,都能以同一份proto文件作为约定,不用A语言写一份,B语言写一份,各个依赖的服务将proto文件原样拷贝一份即可。
但.proto文件并不是代码,不能执行,要想直接跨语言是不行的,必须得有对应语言的中间代码才行,中间代码要有以下能力:
- 将message转成对象,例如golang里是struct,Ruby里是class,需要各自表达后,才能被理解
- 需要有进行编解码的代码,能解码内容为自己语言的对象、能将对象编码为对应的数据
由于message是自己定义的,而且有特定的类型等,一套通用的编解码代码是不行的(类似json),特定的proto需要对应的方法,对message编解码,不同的message编解码策略还不一样。
这些代码用手写是不行的,protobuf对此的解决方案是,提供一个统一的protoc工具,这个一个C++”翻译“工具,可以通过proto文件,生成某特定语言的中间代码,实现上面说的两个能力。也就是说,protobuf通过自动化编译器的方式统一提供了这种能力,避免人肉写。
// 依赖目录 生成golang中间代码 对应proto文件地址
protoc -I=$SRC_DIR --go_out=$DST_DIR $SRC_DIR/XXX.proto
protoc -I=$SRC_DIR --java_out=$DST_DIR $SRC_DIR/XXX.proto // 生成java中间代码
执行结果是对应语言的中间代码,以golang为例,会生成一个xx.pb.go文件,里面就是对应rpc、message的结构体,以及编解码的function。
由于每个field有标号,当proto文件新增字段、message、rpc时也能自然向后兼容,这涉及编解码的策略,下文会详细讨论。
直观对比
为什么选择protobuf,而不是普及最广的json作为编码方案? 可以做一个直观对比,以上文proto中的Response为例,一次输出json的结果是:
"{"ids":[123,456],"info":{"is_man":true,"age":20},"values":{"110":{"is_man":false,"age":18}}}"
所有内容被打包成了一个字符串,里面包含字段名、value,当Reponse很大时,体积消耗很大,浪费主要在三个方面:
- 字段名,例如上面的“ids”、“info”等,如果json体大,则重复会更多
- 数字用字符串表达了,例如123数字变成了“123”,这在编码后体积由一个字节变成三字节
- 类型字符,如[ 、 ]、{ 、}
但如果是protobuf呢? 输出是一段人眼无法理解的二进制串,里面:
- 去掉了字段名,转而以字段标号替代,通过标号可以在proto中找到字段名
- 没有类型字符等
- 用二进制表达内容,不会将数字转成字符串
- 字段值按顺序依次排列
这使得protobuf的编码结果体积,通常是json编码后的十分之一以下。同时由于排列简单,其解析算法的时空复杂度远小于json,对cpu消耗也小很多。这使得protobuf在大数据量、高频率的数据交互场景下,远胜于json,被大规模分布式RPC场景广泛使用。
gRPC使用示例
首先完成proto代码部分:
syntax = "proto3"; // 版本声明,使用Protocol Buffers v3版本
option go_package = "xx"; // 指定生成的Go代码在你项目中的导入路径
package pb; // 包名
// 定义服务
service Greeter {
// SayHello 方法
rpc SayHello (HelloRequest) returns (HelloResponse) {}
}
// 请求消息
message HelloRequest {
string name = 1;
}
// 响应消息
message HelloResponse {
string reply = 1;
}
Server端gPRC使用示例,运行使用go build即可:
package main
import (
"context"
"fmt"
"hello_server/pb"
"net"
"google.golang.org/grpc"
)
// hello server
type server struct {
pb.UnimplementedGreeterServer
}
func (s *server) SayHello(_ context.Context, in *pb.HelloRequest) (*pb.HelloResponse, error) {
return &pb.HelloResponse{Reply: "Hello " + in.Name}, nil
}
func main() {
// 监听本地的8972端口
lis, err := net.Listen("tcp", ":8972")
if err != nil {
fmt.Printf("failed to listen: %v", err)
return
}
s := grpc.NewServer() // 创建gRPC服务器
pb.RegisterGreeterServer(s, &server{}) // 在gRPC服务端注册服务
// 启动服务
err = s.Serve(lis)
if err != nil {
fmt.Printf("failed to serve: %v", err)
return
}
}
之后,我们在项目根目录下执行以下命令,根据hello.proto在http_client项目下生成 go 源码文件:
protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative pb/hello.proto
编写Client端代码调用SayHelloRPC服务:
package main
import (
"context"
"flag"
"log"
"time"
"hello_client/pb"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
// hello_client
const (
defaultName = "world"
)
var (
addr = flag.String("addr", "127.0.0.1:8972", "the address to connect to")
name = flag.String("name", defaultName, "Name to greet")
)
func main() {
flag.Parse()
// 连接到server端,此处禁用安全传输
conn, err := grpc.Dial(*addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Fatalf("did not connect: %v", err)
}
defer conn.Close()
c := pb.NewGreeterClient(conn)
// 执行RPC调用并打印收到的响应数据
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
r, err := c.SayHello(ctx, &pb.HelloRequest{Name: *name})
if err != nil {
log.Fatalf("could not greet: %v", err)
}
log.Printf("Greeting: %s", r.GetReply())
}