pfan123 / Articles

经验文章
169 stars 25 forks source link

RPC 框架介绍 #76

Open pfan123 opened 4 years ago

pfan123 commented 4 years ago

RPC,远程过程调用(Remote Procedure Call,RPC)是一个计算机通信协议,该协议允许运行于一台计算机的程序调用另一台计算机的上的程序。通俗讲,RPC 通过把网络通讯抽象为远程的过程调用,调用远程的过程就像调用本地的子程序一样方便,从而屏蔽了通讯复杂性,使开发人员可以无需关注网络编程的细节,将更多的时间和精力放在业务逻辑本身的实现上,提高工作效率。

RPC 本质上是一种 Inter-process communication(IPC)—— 进程间通信的形式。常见的进程间通信方式如管道、共享内存是同一台物理机上的两个进程间的通信,而 RPC 就是两个在不同物理机上的进程之间的通信。概括的说,RPC 就是在一台机器上调用另一台机器上的方法,这种调用在远程机器上对代码的执行就像在本机上对代码的执行一样,只是迁移了一个执行环境而已。

RPC 是一种 C/S 架构的服务模型,server 端提供接口供 client端 调用,client 端向 server 端发送数据,server 端接收 client 端的数据进行相关计算并将结果返回给 client 端。

rpc_procedure

为了实现上述 RPC 步骤,许多 RPC 工具被研发出来。这些 RPC 工具大多使用“接口描述语言” —— interface description language (IDL) 来提供跨平台跨语言的服务调用。现在生产中用的最多的 IDL 是Google开源的 protobuf

OSI 网络通信模型 中,RPC 跨越了传输层应用层,而 传输层 常见的协议有 TCPUDP应用层 常见协议有 HTTPFTPSMTP

  • TCP,连接是基于字节流,一种可以保证可靠数据传输的传输层协议,如网页请求资源。
  • UDP,基于报文流,一种无连接的传输层协议,它无法保证数据传输可靠性,但传输效率更高,开销更小,如视频、语言电话。

常见的开源 RPC 框架

语言平台绑定的开源 RPC 框架

跨语言平台的开源 RPC 框架

接下来开始介绍下 gRPCThrift RPC 框架。

gRPC

gRPC (gRPC Remote Procedure Calls) 是 Google 发起的一个 开源远程过程调用 (Remote procedure call) 系统。该系统基于 HTTP/2 协议传输,使用 Protocol Buffers 作为 接口描述语言(Interface description language,缩写IDL)。

其他功能包含:

最常见的应用场景是:

rpc_procedure

接下来通过 gRPC 的架构和 RPC 生命周期的概览来了解 gRPC 的主要概念。

gRPC 服务定义

正如其他 RPC 系统,gRPC 基于如下思想:定义一个服务, 指定其可以被远程调用的方法及其参数和返回类型。gRPC 默认使用 protocol buffers 作为接口定义语言,来描述服务接口和有效载荷消息结构。如果有需要的话,可以使用其他替代方案。

service HelloService {
  rpc SayHello (HelloRequest) returns (HelloResponse);
}

message HelloRequest {
  required string greeting = 1;
}

message HelloResponse {
  required string reply = 1;
}

gRPC 定义了四种类型的服务接口方法:

rpc SayHello(HelloRequest) returns (HelloResponse){
}
rpc LotsOfReplies(HelloRequest) returns (stream HelloResponse){
}
rpc LotsOfGreetings(stream HelloRequest) returns (HelloResponse) {
}
rpc BidiHello(stream HelloRequest) returns (stream HelloResponse){
}

使用 API 接口

gRPC 提供 protocol buffer 编译插件,能够从一个服务定义的 .proto 文件生成客户端和服务端代码。通常 gRPC 用户可以在服务端实现这些API,并从客户端调用它们。

同步 vs 异步

同步 RPC 调用一直会阻塞直到从服务端获得一个应答,这与 RPC 希望的抽象最为接近。另一方面网络内部是异步的,并且在许多场景下能够在不阻塞当前线程的情况下启动 RPC 是非常有用的。

在多数语言里,gRPC 编程接口同时支持同步和异步的特点。

RPC 生命周期

我们来了解当 gRPC 客户端调用 gRPC 服务端的方法时到底发生了什么,大体流程如下:

单项 RPC

首先我们来了解一下最简单的 RPC 形式:客户端发出单个请求,获得单个响应。

服务端流式 RPC

服务端流式 RPC 除了在得到客户端请求信息后发送回一个应答流之外,与我们的简单例子一样。在发送完所有应答后,服务端的状态详情(状态码和可选的状态信息)和可选的跟踪元数据被发送回客户端,以此来完成服务端的工作。客户端在接收到所有服务端的应答后也完成了工作。

客户端流式 RPC

客户端流式 RPC 也基本与我们的简单例子一样,区别在于客户端通过发送一个请求流给服务端,取代了原先发送的单个请求。服务端通常(但并不必须)会在接收到客户端所有的请求后发送回一个应答,其中附带有它的状态详情和可选的跟踪数据。

双向流式 RPC

双向流式 RPC ,调用由客户端调用方法来初始化,而服务端则接收到客户端的元数据,方法名和截止时间。服务端可以选择发送回它的初始元数据或等待客户端发送请求。 下一步怎样发展取决于应用,因为客户端和服务端能在任意顺序上读写 - 这些流的操作是完全独立的。例如服务端可以一直等直到它接收到所有客户端的消息才写应答,或者服务端和客户端可以像"乒乓球"一样:服务端后得到一个请求就回送一个应答,接着客户端根据应答来发送另一个请求,以此类推。

截止时间

gRPC 允许客户端在调用一个远程方法前指定一个最后期限值。这个值指定了在客户端可以等待服务端多长时间来应答,超过这个时间值 RPC 将结束并返回DEADLINE_EXCEEDED错误。在服务端可以查询这个期限值来看是否一个特定的方法已经过期,或者还剩多长时间来完成这个方法。 各语言来指定一个截止时间的方式是不同的 - 比如在 Python 里一个截止时间值总是必须的,但并不是所有语言都有一个默认的截止时间。

RPC 终止

在 gRPC 里,客户端和服务端对调用成功的判断是独立的、本地的,他们的结论可能不一致。这意味着,比如你有一个 RPC 在服务端成功结束("我已经返回了所有应答!"),到那时在客户端可能是失败的("应答在最后期限后才来到!")。也可能在客户端把所有请求发送完前,服务端却判断调用已经完成了。

取消 RPC

无论客户端还是服务端均可以再任何时间取消一个 RPC 。一个取消会立即终止 RPC 这样可以避免更多操作被执行。它不是一个"撤销", 在取消前已经完成的不会被回滚。当然,通过同步调用的 RPC 不能被取消,因为直到 RPC 结束前,程序控制权还没有交还给应用。

元数据集

元数据是一个特殊 RPC 调用对应的信息(授权详情]) ,这些信息以键值对的形式存在,一般键的类型是字符串,值的类型一般也是字符串(当然也可以是二进制数据)。元数据对 gRPC 本事来说是不透明的 - 它让客户端提供调用相关的信息给服务端,反之亦然。 对于元数据的访问是语言相关的。

流控制

TBD

配置

TBD

频道

在创建客户端存根时,一个 gRPC 频道提供一个特定主机和端口服务端的连接。客户端可以通过指定频道参数来修改 gRPC 的默认行为,比如打开关闭消息压缩。一个频道具有状态,包含已连接空闲 。 gRPC 如何处理关闭频道是语言相关的。有些语言可允许询问频道状态。

Node.js 使用 gRPC

我们如何通过 Node.js 来使用 gRPC 呢?流程如下

// 文件 helloworld.proto
syntax = "proto3";

option java_multiple_files = true;
option java_package = "io.grpc.examples.helloworld";
option java_outer_classname = "HelloWorldProto";
option objc_class_prefix = "HLW";

package helloworld;

// The greeting 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;
}
const PROTO_PATH = __dirname + 'protos/helloworld.proto';

const grpc = require('grpc');
const protoLoader = require('@grpc/proto-loader');
const packageDefinition = protoLoader.loadSync(
    PROTO_PATH,
    {keepCase: true,
     longs: String,
     enums: String,
     defaults: true,
     oneofs: true
    });
const hello_proto = grpc.loadPackageDefinition(packageDefinition).helloworld;

server.js

const grpc = require('grpc');
const protoLoader = require('@grpc/proto-loader');
const packageDefinition = protoLoader.loadSync(
    PROTO_PATH,
    {keepCase: true,
     longs: String,
     enums: String,
     defaults: true,
     oneofs: true
    });
const hello_proto = grpc.loadPackageDefinition(packageDefinition).helloworld;
/**
 * Implements the SayHello RPC method.
 */
function sayHello(call, callback) {
  callback(null, {message: 'Hello ' + call.request.name});
}

/**
 * Starts an RPC server that receives requests for the Greeter service at the
 * sample server port
 */
function main() {
  var server = new grpc.Server();
  server.addService(hello_proto.Greeter.service, {sayHello: sayHello});
  server.bind('0.0.0.0:50051', grpc.ServerCredentials.createInsecure());
  server.start();
}

main();

client.js

const PROTO_PATH = __dirname + 'protos/helloworld.proto';

const grpc = require('grpc');
const protoLoader = require('@grpc/proto-loader');
const packageDefinition = protoLoader.loadSync(
    PROTO_PATH,
    {keepCase: true,
     longs: String,
     enums: String,
     defaults: true,
     oneofs: true
    });
const hello_proto = grpc.loadPackageDefinition(packageDefinition).helloworld;

function main() {
  var client = new hello_proto.Greeter('localhost:50051',
                                       grpc.credentials.createInsecure());
  var user;
  if (process.argv.length >= 3) {
    user = process.argv[2];
  } else {
    user = 'world';
  }
  client.sayHello({name: user}, function(err, response) {
    console.log('Greeting:', response.message);
  });
}

main();

详情请参考:node 调用 rpc 实例,提供运行时加载 .proto 与编译时实例。

Thrift

Thrift 是一个跨语言的服务部署框架,最初由 Facebook 于 2007 年开发,2008年进入 Apache 开源项目。Thrift 通过 接口描述语言(Interface description language,缩写IDL)来定义 RPC 的接口和数据类型,然后通过编译器生成不同语言的代码(目前支持 C++, Java, Python, PHP, Ruby, Erlang, Perl, Haskell, C#, Cocoa, Smalltalk 和 OCaml),并由生成的代码负责 RPC 协议层传输层 的实现。

利用 Thrift 用户只需要做三件事:

Thrift 基础架构

Thrift 是 C/S 的架构体系,通过代码生成工具将 IDL 接口定义文件生成服务器端和客户端代码(可以为不同语言),从而实现服务端和客户端跨语言的支持。用户在 Thirft 描述文件中声明自己的服务,这些服务经过编译后会生成相应语言的代码文件,然后客户端调用服务、服务器端提供服务来实现服务。其中 protocol(协议层, 定义数据传输格式,可以为二进制或者XML等)和 transport(传输层,定义数据传输方式,可以为 TCP/IP 传输,内存共享或者文件共享等)被用作运行时库。

Thrift 协议栈如下图所示:

rpc_procedure

在 Client 和 Server 最顶层是用户自定义的处理逻辑,用户只需要编写用户逻辑,就可以完成整套的 RPC 调用流程。用户逻辑的下面是 Thrift Runtime 自动生成的代码,这些代码主要用于结构化数据的解析,发送和接收,同时服务器端的自动生成代码中还包含了RPC 请求的转发(Client 的A调用转发到Server A函数进行处理)。

TProtocol 层

TProtocol 主要负责结构化数据组装成 Message,或者从 Message 结构中读出结构化数据。TProtocol 将一个有类型的数据转化为字节流以交给 TTransport 进行传输,或者从 TTransport 中读取一定长度的字节数据转化为特定类型的数据。如 int32 会被 TBinaryProtocol Encode 为一个四字节的字节数据,或者 TBinaryProtocol 从 TTransport 中取出四个字节的数据 Decode 为 int32。

协议(TProtocol)层提供了序列化和反序列化。Thrift 提供了以下协议:

TTranport 层

传输(Transport)层是负责读写传输的。Thrift 支持以下几种:

TServer 层

TServer 负责接收 Client 的请求,并将请求转发到 Processor 进行处理。TServer 主要任务就是高效的接受 Client 的请求,特别是在高并发请求的情况下快速完成请求。Thrift 支持以下几种服务类型:

TProcessor 层

TProcessor 负责对 Client 的请求做出响应,包括 RPC 请求转发,调用参数解析和用户逻辑调用,返回值写回等处理步骤。Processor 是服务器端从 Thrift 框架转入用户逻辑的关键流程,同时也负责向 Message 结构中写入数据或者读出数据。处理器的接口也很简单:

interface TProcessor {
    bool process(TProtocol in, TProtocol out) throws TException
}

Underlying I/O 层

底层 IO 模块,负责实际的数据传输通信,包括 Socket,文件,或者压缩数据流等。

TTransport 负责以字节流方式发送和接收 Message,是底层 IO 模块在 Thrift 框架中的实现,每一个底层 IO 模块都会有一个对应 TTransport 来负责 Thrift 的字节流 (Byte Stream) 数据在该 IO 模块上的传输。例如 TSocket 对应 Socket 传输,TFileTransport 对应文件传输。

Thrift 数据类型

Thrift 通过一个中间语言 IDL(interface definition language,接口定义语言)来定义 RPC 的接口和数据类型。Thrift IDL类型系统由预定义的基本类型,用户定义的结构,容器类型,异常和服务定义组成,接下了解下:

Base Types(基本类型)

请注意,Thrift 不支持无符号整数,因为无法直接转换为目标语言中的本机(原始)类型。

Containers(容器)

Thrift 容器是强类型的,可映射到大多数编程语言中常用的容器类型。使用 C++ 模板类来标注。有三种可用类型:

typedef list<string> listType
typedef set<string> setType
typedef map<string, string> mapType

C++ 的标准模板库( Standard Template Library,简称 STL )是一个容器和算法的类库。容器往往包含同一类型的数据。STL 中比较常用的容器是 vector,set 和 map,比较常用的算法有 Sort 等。

Structs(结构体)

Thrift 结构体定义了一个用在多种语言之间的通用对象。定义一个 Thrift 结构体的基本语法与 C 结构体定义非常相似。域可由一个整型域标识符(在该结构体的作用域内是唯一的),以及可选的默认值来标注。

struct Phone{
        1: i32 id,
        2: string number,
        3: PhoneType type, // 自定义类型
}

struct TrafficEnv {
    1: bool Open = false,
    2: string Env = "",
}

Exceptions(异常)

异常在语法和功能上都与结构体相同,唯一的区别是使用 exception 关键词,而非 struct 关键词进行声明。 生成的对象继承自各目标编程语言中适当的异常基类,以便与任何给定语言中的本地异常处理无缝地整合。

exception exceptionType{
    1: i32 errCode;
    2: string msg;
    3: string info
}

Services(服务)

使用 Thrift 类型定义服务,对一个服务的定义在语法上等同于在面向对象编程中定义一个接口(或一个纯虚抽象类)。Thrift编译器生成实现该接口的客户与服务器存根。服务的定义如下:

使用 Thrift 类型来定义服务。服务的定义在语义上等同于 OOP 编程中定义的接口(或纯抽象类)。Thrift 编译器会生成实现这些接口的 client 和 server stub。

语法:

service <name> {
    <returntype> <name>(<arguments>)
    [throws (<exceptions>)]
    ...
}

例子:

service StringCache {
    void set(1:i32 key, 2:string value),  // void 是一个有效的函数返回类型
    string get(1:i32 key) throws (1:KeyNotFound knf),
    void delete(1:i32 key)
}

service Hello{
    string helloString(1:string para);
    i32 helloInt(1:i32 para);
    bool helloBoolean(1:bool para);
    void helloVoid();
    string helloNull();
}

Typedefs(类型定义)

Thrift 支持 C / C ++ 类型定义风格。

// 类型定义
typedef hash_map<UrlTableProperties *, string> PropertiesMap;

typedef i32 MyInteger   
typedef Tweet ReTweet  

// 类和结构体
class UrlTable { ...
class UrlTableTester { ...
struct UrlTableProperties { ...

// using 别名
using PropertiesMap = hash_map<UrlTableProperties *, string>;

// 枚举
enum UrlTableErrors { ...

引申C++ 风格指南 -- 类型命名

Enums(枚举)

枚举创建一个带有命名值的枚举类型,枚举类型第一个元素默认为0,也可以为其赋值,但后一个元素必须比前面的元素大。赋予的任何常量值必须为非负数。

enum TweetType {
    TWEET,       
    RETWEET = 2, 
    DM = 0xa,   
    REPLY
}             

struct Tweet {
    1: required i32 userId;
    2: required string userName;
    3: required string text;
    4: optional Location loc;
    5: optional TweetType tweetType = TweetType.TWEET // 5
    16: optional string language = "english"
}

Namespaces(命名空间)

Thrift 的命名空间类似于 C++ 中的 namespace 和 java 中的 package,提供了一种隔离代码文件的方式,避免命名冲突等。

namespace java xxx
namespace cpp xxx
namespace php xxx

Comments(注释)

Thrift 支持 Java 中的多行注释

Thrift支持 shell、C 风格的多行以及 Java / C ++ 风格的单行注释。

# This is a valid comment.

/*
 * This is a multi-line comment.
 * Just like in C.
 */

// C++/Java style single-line comments work just as well.

Includes(引入)

为了提高代码可用性,是代码可复用,经常将不同类别的代码写在不同的文件中,将代码隔离开。include 使得来自另一个文件的所有符号都可见(带前缀使用),并将相应的 include 语句添加到此Thrift 文档生成的代码中。

include "Hello.thrift"

struct TweetSearchResult {
    1: list<tweet.Tweet> tweets; // 2
}

const i32 demoConst = Hello.intConst

Constants(常量)

Thrift 可以定义用于多种语言的常量,复杂类型和结构可使用 JSON 表示法指定。

const i32 INT_CONST = 1234;    // 1
const map<string,string> MAP_CONST = {"hello": "world", "goodnight": "moon"}

Node.js 使用 Thrift

Thrift Javascript Library 官方推出的基于浏览器的 Apache Thrift 实现通过 XHR 和 WebSocket 在 Http [s] 协议上使用 JSON 协议来支持 RPC 的工具库。

安装

yarn add thrift

使用示例:

service hello_svc {
  string get_message(1: string name)
}
$ brew install thrift
// 得到浏览器端/服务端 hello_svc.js
$ thrift -gen js -gen js:node hello.thrift   
// 也可以直接编译 ts 版本
$ thrift --gen js:ts file.thrift
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Hello Thrift</title>
  </head>
  <body>
    Name: <input type="text" id="name_in">
    <input type="button" id="get_msg" value="Get Message" >
    <div id="output"></div>

    <script src="thrift.js"></script>
    <script src="gen-js/hello_svc.js"></script>
    <script>
      (function() {
        var transport = new Thrift.TXHRTransport("/hello");
        var protocol  = new Thrift.TJSONProtocol(transport);
        var client    = new hello_svcClient(protocol);
        var nameElement = document.getElementById("name_in");
        var outputElement = document.getElementById("output");
        document.getElementById("get_msg")
          .addEventListener("click", function(){
            client.get_message(nameElement.value, function(result) {
              outputElement.innerHTML = result;
            });
          });
      })();
    </script>
  </body>
</html>
const thrift = require('thrift');
const hello_svc = require('./gen-nodejs/hello_svc.js');

const hello_handler = {
  get_message: function(name, result) {
    const msg = "Hello " + name + "!";
    result(null, msg);
  }
}

const hello_svc_opt = {
  transport: thrift.TBufferedTransport,
  protocol: thrift.TJSONProtocol,
  processor: hello_svc,
  handler: hello_handler
};

const server_opt = {
  staticFilePath: ".",
  services: {
    "/hello": hello_svc_opt
  }
}

const server = Thrift.createWebServer(server_opt);
const port = 9099;
server.listen(port);
console.log("Http/Thrift Server running on port: " + port);

Other Resource

grpc

gRPC 官方文档中文版

gRPC官网

Thrift: The Missing Guide

thrift-parser 解析 IDL 语法数

序列化和反序列化

Thrift: Scalable Cross-Language Services Implementation

An incomplete guide to facebook thrift

Apache Thrift

Serialization

tutorial.thrift

Thrift RPC详解(转载)

Node —— RPC

6种微服务RPC框架,你知道几个?