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.
这段定义提到三点:
分布式环境。
写起来就跟调用本地函数一样。
程序员无需关注与远程的交互细节。
这三点也可以用来描述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框架需要解决什么问题?
我们看文章开头提到的三点:
分布式环境。
写起来就跟调用本地函数一样。
程序员无需关注与远程的交互细节。
接下来逐条展开。
第一点,分布式环境,也就是说,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「说给开发游戏的你」,聊聊服务端,聊聊游戏开发。