RPC与gPRC原理入门 | 青训营

104 阅读11分钟

RPC入门

RPC的语义是远程过程调用,在一般的印象中,就是将一个服务调用封装在一个本地方法中,让调用者像使用本地方法一样调用服务,对其屏蔽实现细节。而具体的实现是通过调用方和服务方的一套约定,基于TCP长连接进行数据交互达成。

上面的解释似云里雾里,仅仅了解到这种程度是远远不够的,还需要更进一步,以相对底层抽象的视角来理解RPC。

三个特点

广义上来讲,所有本应用程序外的调用都可以归类为RPC,不管是分布式服务,第三方服务的HTTP接口,还是读写Redis的一次请求。从抽象的角度来讲,它们都一样是RPC,由于不在本地执行,都有三个特点:

  • 需要事先约定调用的语义(接口语法),亦即函数映射
  • 需要网络传输
  • 需要约定网络传输中的内容格式,存在数据转换为字节流的过程

以一次Redis调用为例,执行redis.set("rpc", 1)这个调用,其中:

  • set及其参数("rpc", 1),就是对调用语义的约定,由redis的API给出
  • RedisServer会监听一个服务端口,通过TCP传输内容,用异步事件驱动实现高并发
  • 底层库会约定数据如何进行编解码,如何标识命令和参数,如何表示结果,如何表示数据的结尾等等

这三个特点都是因为调用不在本地而不得不衍生出来的问题,也因此决定了RPC的形态。所有的RPC解决方案都是在解决这三个问题,不断地在提出更加优良的解决方案,试图达到更好的性能,更低的使用成本。 本文也将围绕这三个特点来展开内容。

常规的RPC一般都是基于一个大的内部服务,进行分布式拆分,由于其语义上以本地方法的作为入口,那么天然的就更倾向于具备高性能、支持复杂参数和返回值、跨语言等特性。下图是RPC调用的过程示意图:

image.png

因此,RPC过程需要解决三个问题:

  1. 如何确定要执行的函数? 在本地调用中,函数主体通过函数指针函数指定,然后调用函数,编译器通过函数指针函数自动确定函数在内存中的位置。但是在 RPC 中,调用不能通过函数指针完成,因为它们的内存地址可能完全不同。因此,调用方和被调用方都需要维护一个{ function <-> ID }映射表,以确保调用正确的函数。
  2. 如何表达参数? 本地过程调用中传递的参数是通过堆栈内存结构实现的,但 RPC 不能直接使用内存传递参数,因此参数或返回值需要在传输期间序列化并转换成字节流,反之亦然。
  3. 如何进行网络传输? 函数的调用方和被调用方通常是通过网络连接的,也就是说,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分层结构。

  • 编码解码层
  • 协议层
  • 网络通信层

image.png

编解码层

image.png

数据格式

  • 语言特定的格式,编程语言内建的将内存对象编码为字节序列的格式

  • 文本格式,JSON,XML,CSV等文本格式,具有人类可读性

  • 二进制编码,具备跨语言和高性能等优点,例如Thrift的BinaryProtocol,Protobuf等

  • 由于前两种都有一定的限制,可能会导致歧义,常使用二进制编码的形式对代码进行编码,实现可以有TLV编码和Varint编码。

二进制编码

  • TLV编码

T-Tag:标签,可以理解为类型

L-Langth:长度

V-Value:值,Value也可以是一个TLV结构

协议层

image.png

协议构造:

  • LENGTH:数据包大小
  • HEADER MAGIC: 标识版本信息,协议解析时候快速校验
  • SEQUENCE NUMBER: 表示数据包的 segID可用于多路复用,单连接内递增
  • HEADER SIZE: 头部长度,从第14个字节开始计算一直到 PAYLOAD前
  • PROTOCOL ID:编解码方式,有 Binary 和Compact 两种
  • TRANSFORM ID: 压方式,如 zlib 和snappyINFO ID: 传递一些定制的 meta 信息
  • PAYLOAD: 消息体

协议解析:

image.png

网络通信层

image.png

网络库

  • 提供易用 API
    • 封装底层 Socket API
    • 连接管理和事件分发
  • 功能
    • 协议支持: tcp、udp 和 uds 等
    • 优雅退出、异常处理等
  • 性能
    • 应用层 buffer 减少 copy
    • 高性能定时器、对象池等

gPRC介绍

RPC框架都有以下几个目标:

  • 尽可能快地序列化、反序列化
  • 序列化后的体积越小越好
  • 跨语言,和语言无关
  • 简单、类型明确
  • 易扩展,可以简单的迭代,向后兼容

所有RPC框架都是在围绕这几个点不断优化,以更优的方案,达到更低的成本,更快的速度。要想达到这个目的,内容编码方式就是一个非常重要的点,RPC调用的requestresponse内容在调用过程中有着不小的消耗:

  • 内容的序列化、反序列化,如果效率更高,则对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.protohttp_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())
}