自己动手实现RPC

作者:禅与计算机程序设计艺术

1.简介

在分布式计算中,远程过程调用(Remote Procedure Call)就是服务间通信的一种方式。其核心思想是允许不同的进程或计算机之间通过网络进行通信,而不需要了解底层网络协议,屏蔽了复杂的底层传输细节,使得开发人员可以像调用本地函数一样方便地调用远端服务。相比于传统的基于Socket的客户端-服务器模型,RPC提供了更加便利、易用和面向对象的编程接口。 目前市面上主流的RPC框架有Apache Thrift、Google gRPC等。本文将从最基本的RPC机制出发,一步步介绍如何自己动手实现一个简单的RPC框架。阅读本文后,读者可以轻松掌握一种简单但功能完整的RPC框架的设计、开发和应用方法。

2.基本概念

2.1 服务(Service)

远程过程调用的一个核心概念就是服务(service)。它指的是一个提供某种功能的实体,比如一个算法引擎,或者是一个数据库服务。服务一般由两部分组成:一个接口定义文件(.proto),用于描述服务的方法签名和请求参数;一个运行在服务器上的进程,负责监听客户端的请求并处理请求。一个服务通常由多个方法构成,每个方法都定义了输入的参数及返回值类型,并且可以被客户端远程调用。

2.2 协议缓冲区(Protocol Buffers)

Google为了解决数据交换的问题,推出了Protocol Buffers(以下简称Protobuf),它是一种可用于序列化结构化数据的语言无关、平台独立、可扩展的消息格式。它使用.proto文件作为配置描述,利用编译器生成各种语言的类库,用户即可通过该类库将各个语言的数据结构序列化到字节序列中,进而通过网络发送至对方。Protobuf能够自动生成代码,节省了开发时间,提高了效率,而且支持数据压缩、验证等功能。

2.3 网络传输(Transportation)

当服务部署到服务器之后,就可以开始接收来自客户端的请求。对于网络通信,服务需要提供底层网络传输能力。目前,最流行的两种网络传输协议是TCP/IP和HTTP。HTTP协议是一种不具备状态的无连接协议,即一次连接只处理一个请求。另一方面,TCP/IP协议是一种具有状态的连接协议,既可以一次处理多个请求,又可以保证数据包按序到达。因此,选择哪种传输协议是比较重要的。

3.RPC框架概览

RPC框架包含如下几个主要模块:

  • 传输层:负责网络传输,例如建立连接、断开连接、收发消息等。
  • 协议层:负责编解码,例如如何编码一个字符串、一个整数等。
  • 序列化层:负责将对象转换为字节流,例如Java中的Serialization API,Python中的Pickle。
  • 网络层:在传输层之上,维护远程主机地址,负责路由转发。
  • 客户端 Stub:用户调用时会创建这个Stub,用来发送请求、接收响应。
  • 服务器 Skeleton:Skeleton接收客户端请求,调用本地服务,然后将结果封装成响应返回给客户端。

下图展示了一个典型的RPC框架流程:

4.RPC框架设计与实现

RPC框架的设计与实现可以分为以下四个步骤:

  • 确定通信模式
  • 确定传输协议
  • 设计IDL文件
  • 实现序列化与反序列化
  • 实现传输协议
  • 实现网络传输
  • 实现客户端Stub
  • 实现服务器Skeleton

下面逐个详细阐述一下这些步骤。

4.1 确定通信模式

RPC是一个双向通信模式,即客户端调用服务端的服务,同时也能让服务端调用客户端的服务。因此,首先要考虑服务调用的方向性问题。如果是服务调用客户端的服务,则称之为“过程”(procedure)模式;否则,则称之为“函数”(function)模式。一般来说,在同一个进程内采用“过程”模式,在不同进程间采用“函数”模式。

4.2 确定传输协议

确定传输协议通常包括两方面的考虑。第一,是否需要加密传输;第二,需要什么样的传输协议。通常,安全的传输协议是TLS/SSL、SSH等;而非安全的传输协议如HTTP、TCP/IP等。

4.3 设计IDL文件

为了实现RPC,我们需要先定义服务的接口,即服务的输入输出参数。我们通常会使用.proto文件作为接口定义文件,其中包含服务名、方法名和参数类型定义。下面是一个.proto文件的例子:

// calculator.proto 文件
syntax = "proto3"; // 指定protobuf版本

package example; // 定义包名

message Request {
    int32 a = 1; // 参数定义
    int32 b = 2;
}

message Response {
    int32 c = 1; // 返回值定义
}

service Calculator { // 服务定义
    rpc add (Request) returns (Response); // 方法定义
    rpc sub (Request) returns (Response);
    rpc mul (Request) returns (Response);
    rpc div (Request) returns (Response);
}

上例定义了一个计算器服务,其中包含四个方法:add、sub、mul、div。每一个方法的输入参数都是Request,返回值为Response

4.4 实现序列化与反序列化

要实现服务的远程调用,首先需要将调用参数序列化成字节流,然后再将字节流通过网络传输给服务端,并将返回值反序列化。一般情况下,序列化和反序列化的过程需要依赖特定的编程语言,例如Java中的Serialization API,Python中的Pickle。当然也可以手动实现序列化和反序列化逻辑。

4.5 实现传输协议

根据选定的传输协议,我们需要实现相应的网络传输组件,例如建立连接、断开连接、接收消息、发送消息等。我们可以使用现有的开源项目如Netty等。

4.6 实现网络传输

完成网络传输组件的实现之后,接下来就是实现客户端Stub和服务器Skeleton。客户端Stub负责将本地调用请求发送给网络传输组件,并等待服务端的响应;服务器Skeleton负责接受网络传输组件的调用请求,并调用本地服务处理请求,最后将结果封装成返回响应发送给客户端。

客户端Stub与服务端Skeleton之间的通信路径可以分为三种情况:

  1. 一跳呼叫:客户端直接调用服务器,将请求发送至网络传输组件的端口,并阻塞等待服务器的响应。
  2. 多跳呼叫:客户端首先调用中间件(如注册中心),获取到服务器的网络地址信息,然后再调用服务器。中间件可以缓存服务地址信息,避免重复查询。
  3. 数据透传:客户端通过网络传输组件将请求发送至中间件,中间件在收到请求之后,再通过网络传输组件将请求发送至服务器,并阻塞等待服务器的响应。

4.7 实现客户端Stub

客户端Stub负责将本地调用请求发送给网络传输组件,并等待服务端的响应。首先,Stub需要解析IDL文件,获取到服务名和方法名,并构造请求参数。然后,将请求参数序列化为字节数组,并通过网络传输组件发送给服务器。最后,接收到服务器的响应,Stub反序列化字节数组,并返回结果。

4.8 实现服务器Skeleton

服务器Skeleton负责接受网络传输组件的调用请求,并调用本地服务处理请求,最后将结果封装成返回响应发送给客户端。首先,Skeleton需要解析请求的字节流,并反序列化得到调用参数。然后,调用本地服务处理请求,得到结果。最后,将结果序列化为字节数组,并通过网络传输组件返回给客户端。

4.9 小结

通过以上步骤,我们已经实现了一个简单的RPC框架。当然,由于篇幅原因,这里没有讲太多的底层技术细节。如果你对此感兴趣,欢迎继续阅读相关资料。

猜你喜欢

转载自blog.csdn.net/universsky2015/article/details/133504729