RPC 解决了什么问题

4,897 阅读11分钟
原文链接: mp.weixin.qq.com

RPC(remote procedure call),远程过程调用,相信大家都不陌生。如果有不清楚的同学可以看下wiki定义:

In distributed computing a remote procedure call (RPC) is when a computer program causes a procedure (subroutine) to execute in another address space(commonly on another computer on a shared network), which is coded as if it were a normal (local) procedure call, without the programmer explicitly coding the details for the remote interaction. 

这段定义提到三点:

  1. 分布式环境。

  2. 写起来就跟调用本地函数一样。

  3. 程序员无需关注与远程的交互细节。

这三点也可以用来描述RPC框架需要解决的问题。

RPC本身并不会涉及什么高深的技术,因此在github上随手一搜,就会发现,RPC库跟网络库一样是造轮子的一大重灾区。

话虽如此,写玩具代码也是程序员的一大乐趣。相信看完这篇文章,你也会加入造RPC库轮子的大军中去。

在进入主题之前,我们首先来消除下二义性。

web开发中也有RPC的概念,与现在比较流行的RESTful相反,是基于HTTP协议设计的一类基于「操作」的API:

GET /operation?id=anId

而小说君接下来要讲的RPC,更多地描述的是「代码」层面的RPC。

比如下面一段逻辑,服务器A处理某个请求时,需要从其他的服务器拿数据,可以这样写:

var data = serviceDelegate.GetData(id);

代码是这样写,GetData内部做的事情实际上就是把服务id、方法id以及参数打包成一个消息发出去,等到其他服务器回过来消息之后,再回调到发起者的相应方法。

三年前,小说君在腾讯参与一款页游项目的开发。可能是因为历史遗留原因,该项目并没有采用RPC。因此作为逻辑狗,代码写起来非常蛋疼。

举个简单的例子,发送一个含有a和b两个参数的请求包这样的简单操作,应用层程序员需要先分配一个请求对象,然后人肉给a和b赋值,再手动调下序列化函数,最后发送。接收包时的操作类似。

如果该项目引入RPC,那发请求就是一行代码,跟调用普通函数的写法一样;收请求则是由框架自动回调一个signature即包参数的函数。应用层程序员根本不需要关注「包」、「序列化」、「反序列化」这些概念,少写不少重复代码。

当然,RPC也并不是尽善尽美,看过《Unix编程艺术》的同学可能对书中批评RPC的章节仍有印象,小说君简单总结一下:

  • RPC接口不具备自我描述性。

  • RPC太容易扩展,容易增加系统复杂度。

  • RPC透明性差,程序员无法直接获知某个RPC接口的调用成本。

  • RPC鼓励程序员视跨机调用为无成本行为。

简单地说,RPC受「人」的因素影响巨大。RPC和本地函数外观一模一样,RPC就会被无节操的程序员扩散在系统各处。

如果只是上层逻辑倒还好,可以通过一定的沙盒机制杜绝级联效应。但是小说君最近在review新接手的框架时就发现,之前的维护者在框架层面不少主流程中用RPC绕来绕去,简直把后来人绕晕。

RPC本意是减轻程序员编码负担,但是如此一来,就完全违背了设计本意。

云风写过一篇博客是「RPC之恶」,其中也提到了一个滥用RPC的例子:系统库内置的sort函数往往需要用户传入comparer,有的程序员就会在comparer中调用RPC,甚至comparer的后续执行还需要RPC的远程返回结果。

这显然是不对的,「流程」获取数据,「算法」处理数据;而不是「算法」执行过程中自行根据「流程」获取数据,再自行处理数据。

不过,排除了「人」的因素之后,RPC带来的收益还是相当明显的,特别对于游戏项目或者应用项目的后端开发来说,服务定义可控,流程可控,即使出现前述问题,要么是能较快定位到问题,要么是不至于给项目带来灾难性影响。

是否在项目中采用RPC,是一个见仁见智的抉择问题。本文接下来就聊聊RPC框架需要解决什么问题,以及如何设计RPC框架。

RPC框架需要解决什么问题?

我们看文章开头提到的三点:

  1. 分布式环境。

  2. 写起来就跟调用本地函数一样。

  3. 程序员无需关注与远程的交互细节。

接下来逐条展开。

第一点,分布式环境,也就是说,RPC框架需要帮程序员做好方法调用到数据的转换,然后再借助网络库发出去;接收侧的网络库收到后将数据推给接收侧的RPC框架,RPC框架再帮程序员做好数据到方法回调的转换。

流程很简单,网络库相关的实现可以参考服务端系列的第一篇文章「从零手写服务端框架」;方法调用与数据的互转就更简单了——想办法将文章开头小说君吐槽的人肉打解包逻辑自动化完成,再序列化即可。

序列化的方案通常有两种:一种是有一定自描述能力的,常见于protobuf、msgpack等第三方库;一种是基于纯数据流,通常是项目组自行维护的。

当然,一个完备的RPC框架理应可以透明替换序列化方案。

第二点,写起来就跟调用本地函数一样。这点决定了RPC框架设计的好坏。

RPC框架的核心设计意图就是让应用层程序员调用起来非常自然、不需要有太多包袱。魔兽世界以及网易很多游戏采用的类bigworld服务端框架,其RPC甚至还向上层程序员隐藏了客户端session切进程的细节。

本地函数可以大概分为两类:同步和异步。

对于异步函数,基本任何语言和平台都能做到RPC与本地函数写法一致,比如下面两行方法调用,分辨不出哪次调用需要发网络包。

remoteServiceDelegate.PostMessage(msg); localServiceDelegate.PostMessage(msg);

而同步函数的实现就会有些棘手,在有些语言或平台上甚至无法实现。

先看一个本地同步函数的调用例子:

var ret = localServiceDelegate.SendMessage(msg);

现在,我们把SendMessage改成一个需要网络通信的异步RPC调用。

如果想实现语义上跟本地函数版本一致,那语言或平台就需要有保存执行上下文的能力,等到SendMessage的远端结果返回时,再恢复上下文,并把返回结果赋给ret。

在支持异步语法的语言/平台,这种语义可以原生支持,写成这样:

var ret = await localServiceDelegate.SendMessage(msg);  //支持async/await语义
var ret = yield localServiceDelegate.SendMessage(msg);  //支持yield语义

如果不支持异步语法,但是支持闭包的话,也没问题,可以这样写:

localServiceDelegate.SendMessage(msg, (ret) =>
{
});

如果闭包都不支持的话,就麻烦了。

前述两种,之所以接近本地函数的调用形式,是因为语义上有保证——异步数据返回时,执行上下文与异步请求发送时一致。

不支持闭包,如果想要保存现场,就需要人肉定义执行上下文结构——发请求时hold住相关环境,收到回应时取出相关环境,并回调注册的回调函数。

所以只是写应用层的话,小说君认为,如果采用闭包都不原生支持的语言,那应该已经离现代编程太远了。

第三点,程序员无需关注与远程交互的细节。

何谓交互细节?

回想一下,小说君在之前的几篇服务端文章中分别引入了网关消息队列数据服务分布式一致性设施

这些设施「外部设施(比如第三方的SDK)有本质区别,每一种设施都专注建模一类特定问题并解决,因此小说君称其为「基础设施抽象

与这些基础设施抽象打交道,就不免需要关注与其的交互细节。比如,与网关打交道需要指定组播的组id,与消息队列打交道需要指定频道,与数据服务打交道要指定的内容就更多了。

如果这些细节全部暴露在应用层的话,那应用层程序员的负担就会大大增加。因此,RPC框架还应该解决这类问题——提供额外的中间层,让程序员认知统一。

我们用「服务」这个概念来统一认知。

对于同一个服务,调用方需要借助服务的委托(Delegate)发起服务提供的某条RPC;接收方则有该服务的对应实现(Implementation),RPC会回调Impl的对应方法。

不同的基础设施抽象有不同的设施层的协议,因此RPC框架还需要针对不同的协议定制适配器(Adaptor)。适配器这个概念(Concept)是面向RPC层定义的——对于Implementation来说,Adaptor是一个持续产出消息的流;对于Delegate来说,Adaptor是一个可以接受消息的传输器。

与此同时,应用层不需要有统一的Adaptor概念,因此Adaptor可以向应用层提供特化的接口。

下面我们来看一些实现细节。

首先是RPC层的协议定义,简单分为两部分:

  • 一部分用来标识一次调用session,调用方分配sessionId,实现方处理完返回数据时带上sessionId,调用方就能回调之前注册的闭包,还原上下文,还可以实现超时管理。

  • 一部分用来做方法的dispatch。不论是用第三方序列化库还是自行维护,都至少需要序列化方法Id、参数等信息。

而至于整体的消息流,就是:

应用 -> RPC -> Adaptor -> 基础设施协议 -> Adaptor -> RPC -> 应用

其中,Adaptor与RPC层的关系满足下面几点:

  • RPC层与Adaptor层完全无关。不同Adaptor需要针对RPC层提供相同接口。两者互不关注具体实现。

  • 服务的Delegate构可以基于不同类型的Adaptor构造,可以向Adaptor发送消息,可以定制服务特定的路由规则。

  • 服务的Implementation可以注册在不同的Adaptor上。

本来打算服务端系列先到此为止,结果写着写着发现只讲RPC就已经篇幅这么长了,而剩下还有大约一半内容,只能强行续一篇了。

RPC的实现本身非常容易,相信动手做过的同学都很清楚。如果协议中的方法dispatch部分基于文本实现,在python、lua这种动态语言中,甚至连自动化工具都不需要,一百行代码就能搞定一个RPC库。

现在,我们所说的RPC就只是普通的远程方法调用,虽然对应用层完全隐藏了其他设施的协议细节,但是这样一来其他设施的强大特性我们也就无法利用了。

还有另一种与RPC平行的抽象来特化RPC的形式,这种抽象与RPC共同组成了应用层的开发规范。

下篇文章,我们来聊聊消息流的模型定义,以及节点间通信的pattern定义

如果对文章有疑问或者对新话题有兴趣请务必直接留言回复!

服务端系列文章的链接,以及后续的主题(按顺序阅读更佳):

从零手写服务端框架

面向中间件的开发模式

如何快速搭建数据服务

面向微服务的服务端架构

以消息队列为中心的服务端架构

聊聊无状态服务

聊聊分布式锁

基于redis构建数据服务

RPC解决了什么问题(本篇)

聊聊消息流模型()

个人订阅号:gamedev101「说给开发游戏的你」,聊聊服务端,聊聊游戏开发。