序
最近在对复杂系统进行功能拆分(在函数调用层面,将软件实例进行进程级的一个功能切割,也就是把一个复杂系统里的各个功能规整解耦合成子系统然后再把这个子系统拆出来),
程序进行功能拆分解耦合成各种小型服务化功能,通常会使用一个独立子服务+二进制api接口封装库方式来进行交互。
当独立子系统拆分后,原有的在本进程调用的普通函数接口,就需要改造成一个远程调用服务,来进行跨进程调用(甚至类似分布式系统,将这个子系统部署在其它主机)。
这过程中就无法回避跨进程间交互进行参数传递和返回值获取了。(这是非常模式化的一件事,而且也很繁琐)
主要麻烦的地方在于,本身函数传参只是一行代码就能搞定的事,改造成RPC(Remote Procedure Call)远程调用之后,会混入类似各种套接字创建,发送接收,数据校验等交互上的麻烦事。
那么有没有一种方法来非常简单的把远程调用传参和获取返回值这个步骤给自动化?(主要是懒,还有这样就能早点下班)
此处拒绝高大上的各种RPC框架,以及复杂通信协议,只考虑函数级调用的远程服务实现,所有api接口均为二进制接口(因为本身就是本地微服务,没必要再磊一个复杂的通信协议栈上去)
graph TB caller-->|Rpc调用|funcC subgraph 本机 caller-->|Rpc调用|funcB subgraph 本进程 caller-->|函数调用|funcA end subgraph 服务进程 funcB end end subgraph 远程主机 funcC end
Refrence
库以及demo实现:https://github.com/Vladivostoks/EzRpc
所以偷懒的姿势是?
开始之前,先比较一下,确保方案大方向能够实施。
函数调用和rpc对比
函数调用可视为四步:
- 找到对于函数入口
- 参数入栈
- 函数执行并得到一个返回值.
- 返回值返回
作为类比,整个RPC过程即要做成:
- 向服务端发起对应的功能调用请求 -> 调用约定
- 调用端发送服务调用参数,服务端接收调用参数 -> 参数入栈
- 入栈参数展开,并调用真正的服务函数 -> 函数执行
- 将真正的服务函数返回值返回 -> 返回值返回
实现以上步骤,就能够实现一个函数代理。
对于手工设计这些rpc步骤实现,是没有问题的,但我们这边但目标是,只要是能够代码自动完成的,那就坚决不手动去做。
最终效果呈现
对于模式化的代码生成,c++的模版是不二选择。
希望的是最终本进程进行远程调用一个函数就和在本地进行调用函数一样。(甚至是代码形式上的一样)
调用端+服务端整个框架压缩到10句以下
发送端:
- 首先按照通信方式生成一个支持对应通信方式的RpcStack对象,这个对象负责和实际执行端进行数据收发交互。
- 将这个RpcStack对象以及对端需要执行的动作(后简称
调用约定
)注入到函数代理对象中 - 像一个正常函数一样调用它并获取返回值,通过异常捕获的方式捕获通信过程中可能产生的错误。
1 | using rpc_type = enum{ |
接收端:
- 得到请求,拿到通信链路,然后接收对端给过来的约定符号,看看要调用哪个方法
- 找到调用的方法,并注入到这个微形的rpc调用模版中
- 由模版中完成方法调用和返回值返回,一把梭
1 | using rpc_type = enum{ |
rpc模版封装
发送端代理模版封装
发送端需要做的事情相对简单,就是发送调用约定
,发送参数
然后接收返回值
。
发送和接收都是模式化的步骤,区别仅在于不同参数(返回值)类型,其要发送的字节数是不同的。
因为和普通的函数调用一样,代理接口一样需要传参数,所以对于每个参数的发送字节数,我们只需要将它们丢进去,由模版推倒类型后调用对应的发送接口就好了。
另外为了提高效率,其必然都是常量引用传递的,或者指针也一样(当然不是指针值本身),不使用值传递。
返回值需要单独在模版中声明,这个暂时没有办法推倒。
具体实现
发送调用约定
及返回值接收
没有什么特别的,主要描述一下个数可变类型不固定的参数压栈的模版方法。此处使用变参模版进行递归进行实现。
1 | // function 1 |
变参模版递归以及参数类型萃取
里面涉及了两个很有趣的概念,变参模版的递归
以及参数类型萃取
。
- 变参数模版递归,首先形如:
1 | pushStack(a,b,c,d); |
的调用方式,都会匹配进function2
或者function3
,并且__THIS_ARG&
或者__THIS_ARG*
会匹配上decltype(a)
(也就是a的实际数据类型)
而后,在pushStack中出发递归调用:
1 | pushStack(b,c,d); |
同理,在这一层处理数据b的类型。
最终会递归到
1 | pushStack(); |
此时根据c++模版的SFINAE机制,最终会匹配到function1
:
1 | template<class ... __ARGS> |
从而结束递归。
- 参数类型萃取
在前面我们提到另外为了提高效率,其必然都是常量引用传递的,或者指针也一样(当然不是指针值本身),不使用值传递。
而在模版函数中,我们希望发送的是这个指针指向数据的类型而不是指针类型,因此在此处也涉及到了参数萃取.
拿形参为指针类型举例:
1 | long long a = 0; |
如果模版函数写为
1 | template<class __THIS_ARG,class ... __ARGS> |
那么__THIS_ARG
类型推倒出来的类型为long long *
,显然后面的sizeof会出现问题。
因此,此处的模版函数为
1 | template<class __THIS_ARG,class ... __ARGS> |
使用long long *
去匹配__THIS_ARG*
,因为把long long
萃取到了__THIS_ARG
中。
函数萃取在服务端还有更多的应用。
- 有了前面的操作之后,对于发送端来说,最大的问题就解决了,当然我们还需要
stack_
对象负责完成底层通信,另外我们希望能像函数一样调用这个代理方法。
这个简单,直接拿一个代理类封装一下,并实现operator()就好了,这样每个RpcCallProxy
对象就是一个仿函数(或者理解为闭包)。
1 | template<class RET> |
operator()
是一个成员函数同时也是一个模版函数,其形参的模版类型列表也是自动推导,返回值需要模版声明。
服务端代理模版封装
服务端设计
接收端的流程需要对应发送端,需要:
- 获取
调用约定
,明白客户端需要调用什么函数方法 - 获取客户端压栈的调用实参
- 调用本地方法,并得到返回值
- 返回值返回
其中关键的有两步,就是上面的2和3;接收对端压栈的实际参数,并调用本地函数。
实际上第二步的过程,可以依赖第三步中本地函数声明来自动化。
下面我们拆开依次实现上面两个步骤。
实参接收
实参接收模版类
假设我们已经知道了本地的形参列表,那么应该如何去依次接收这个列表里的参数?
和发送端类似,依然是使用递归,只不过这次使用模版类递归
为何发送的时候使用模版函数递归,而接收的时候使用类模版递归?
主要原因是接收端是延迟调用,数据并不是接收一个就使用一个,而是全部接收完再进行调用。
而发送端是解析一个数据就处理一个数据。
正常的情况,就是写成下面这样:
1 | /** |
假如有一个参数列表为RpcArgs<int, long long, bool, short>
的模版,
那么首先它会适配class RpcArgs <__THIS_TYPE, __ARGS_TYPE ...>
, 这里的__THIS_TYPE
就是int
, 而__ARGS_TYPE ...
就是long long, bool, short
,
而它的继承的父类声明为public RpcArgs<long long, bool, short>
那么这个int
就提取出来了,然后我们在构造函数里调用通信栈去接收这个参数就行啦。
同时,父类又会继续展开下去,依次对参数完成接收。
一直到 template <> class RpcArgs <>
结束递归。
RpcArgs<int, long long, bool, short>
声明的对象最后得到的就是一个函数栈,箭头表示栈生长方向:
<======== <============ <======= <=========
|–int–|--long long–|--bool–|--short–|
特别注意!因为类初始化的顺序,父类总是优先于子类初始化,所以参数列表中,short
是最先接收的,而int
是最后接收的。
有趣的是,如果是以压栈形式进行函数调用,参数的压栈顺序也是从右到左的,果然并不是巧合
实参展开,接口调用
在得到RpcArgs<int, long long, bool, short>
对象之后,应该怎么去把这个参数栈展开输入到我们想调用的函数呢?
很早之前考虑这个rpc函数代理模版实现的时候,就卡在这一步,调用本地函数涉及到压栈参数展开以及传入本地函数???
这让我一度以为这是c做不到,直到看到了std::thread构造函数调用方法,这才发现其实早有
类似问题 *c - How do I expand a tuple into variadic template function’s arguments?* 的解决方法,而RpcArgs也是参考std::tuple原理设计的。
魔法来了!
参考了std::tuple
的参数展开,我们按照类似的步骤设计以下代码:
参数展开如下:
1 | //progress1 |
其中,proxy_func_
为我们真正想要调用的"函数",ret_t
为proxy_func_
的返回值类型,__ARGS_TYPE...
为proxy_func_
的参数列表。
这俩放后面说,函数加引号是因为它也可以是一个仿函数(functor)或者在其它一些语言里又叫闭包。
整个过程其实就是progress2
静态重载了progress1
,拆成两步的原因依然是参数萃取。(这里萃取的是__ARGS_TYPE...
的个数)
其中的参数展开的关键步骤是getArgs<I>(args)...
em。。。先观察一下相对好理解的progress2
:
args_size<T>::value
用来获取类型T的模版参数个数,这里T = RpcArgs<__ARGS_TYPE...>
,也就是说获取的是__ARGS_TYPE...
的个数std::make_index_sequence<N>()
用来生成一个类型为std::index_sequence<I...>
的参数。- 打个比方比较容易说明
std::make_index_sequence<4>()
的结果为std::index_sequence<0,1,2,3>
- 因为这俩模版类,因此需要c++14以上标准才能支持编译
- 打个比方比较容易说明
然后就到了progress1
:
-
乍一看这个函数声明
ret_t do_call(__ARGS& args,std::index_sequence<I...>) const
感觉不太对劲,实际上完整的应该是ret_t do_call(__ARGS& args,std::index_sequence<I...> use_less) const
,因为后面的这个use_less
实际在函数内是用不到的(这边取useless也是为了表示这玩意没用),所以只剩下类型声明。 -
关键来了
return proxy_func_(getArgs<I>(args)...);
,我们想要调用的函数是proxy_func_
,getArgs<I>(args)...
为c++11语法糖,参数包扩展。- 如果
<I...> = <0,1,2,3>
,那么proxy_func_(getArgs<I>(args)...)
就变成了proxy_func_(getArgs<0>(args),getArgs<1>(args),getArgs<2>(args),getArgs<3>(args))
- 如果
-
所谓的
getArgs<I>
就是获取RpcArgs<Args...>
中的第I个参数而已。
下面补上从参数栈中获取第i个参数的getArgs<I>
实现:
1 | template <int N, class ... __ARGS_TYPE> |
借助element
对参数栈RpcArgs
进行剥壳提取参数,依然是迭代和萃取,描述到此处应该已经能理解这种类似的模版元编程手法。
以上都是在编译期间由编译器完成推倒。
有了以上这些组件之后,我们再加亿点点封装!
把之前实现到do_call
,getArgs
,RpcArgs
再组合一下成一个服务端代理类RpcServerProxy
,实现如下:
1 | template<class __RET_TYPE,class __FUN_TYPE,class ... __ARGS_TYPE> |
已经很接近完成了,不过美中不足的是,对于一个模版类,我们在使用的时候还需要声明模版具体类型,尤其是里面的返回值类型和形参列表,其在函数声明类型中均已包含,那么能不能再节省掉这部分的类型声明?
在实际体验中,因为有了RpcCallProxy
和RpcArgs
已经能够自动的发送参数和接收参数实际已完成大半重复的工作量,
但对于RpcServerProxy
的使用,还不足够方便(冗长的模版参数列表)。
实际上由于函数声明的存在,我们还可以进一步进行封装,直接绑定外部函数声明,通过萃取函数声明中的返回值类型和形参列表来更进一步节省RpcArgs
模版参数的声明和函数调用以及参数返回流程。
本地函数的形参&返回值萃取
剩下需要解决的就是:
- 根据
proxy_func_
的类型__FUN_TYPE
,推导参数列表__ARGS_TYPE...
和返回值类型__RET_TYPE
推导。 proxy_func_
本身的类型规整,进行完备的考虑。
仿函数的引入大大的增加了复杂度,区别于一个函数指针,传入的仿函数对象,可能是左值,右值,或者是指针的情况,并且!还得考虑它们的生命周期和对应的管理者
函数指针类型
函数类型对标std::bind
实现,支持闭包。
这带来了一点复杂度,即这个函数类型即可以是普通函数指针,也可以是一个闭包函数。(仿函数)
闭包不展开了,cpp里的闭包就是重载了()操作符的类,即实现operator()
方法的类,即也支持匿名函数绑定到这个rpc框架中(可以实现自动接收远程参数并调用一个匿名函数)。
c++的匿名函数比较特殊,在无外部参数捕获的时候,它是一个函数指针,而在有外部参数捕获的时候,它是一个对象(仿函数)
因为这个类型即可能是函数指针,也可能是一个对象指针。(这也是为啥上一节描述对函数
加了引号,因为其是广义上的函数,包含了仿函数这种对象)
所以在后面我们用一个类型func_t单独指代这个proxy_func_
.
返回值类型萃取和绑定"函数"类型规整
返回值类型依然使用萃取的方式,对operator()进行特殊的处理,如果是对象,那么查询其operator()
成员函数的返回值类型:
1 | /** |
写到这里,模版特化以及SFINAE用出了一种正则表达式的错觉.
函数类型直接使用#include <type_traits>
库提供的std::decay
进行类型退化(退化
指把引用或者指针指向的类型萃取出来)
规整后:
1 | template<class __FUN_TYPE,class ... __ARGS_TYPE> |
真的是最后一步了
由于c++的模版类的类型参数没法自动推导,到目前为止,我们封装的RpcServerProxy
还只是一个模版对象。
那么只要用一个创建这个对象的函数对这个对象的创建过程进行一个封装,就不用对这个模版类模版列表进行手动声明了。
1 | /* check lamda function */ |
最终达到的效果,使用bind
构造一个RpcServerProxy
对象,然后注入stack的时候,完成延迟调用:
单独声明了一个RpcServerProxyBase
虚基类作为RpcServerProxy
的抽象接口,可以用指针或者引用接住bind
的返回结果。
1 | ez_rpc::bind( |
小结
实践已经检验这套封装还是可以有效提高效率降低加班时间,也算是达到了目的。
不过缺点也很明显,一堆模版类,让人连看的欲望都不会有,可读性为0。
好在使用的时候接口封装程度相对足够高,用起来比看起来简单,尤其是仿函数支持,使用过程中还是很重要的。
仿函数支持
仿函数支持的目的是为了被调用函数既能获取远端传入信息,也可以获得本地传入的信息。
和标准库中std::bind
的实现不同的是,在服务端,ez_rpc::bind
并没有输入函数的实参,因而无法由函数实参推导形参模版列表,所有的形参模版列表均由函数声明中萃取。
因此也就没法简单的像std::bind
使用占位符了。
因此我们使用类似匿名函数的外部捕获方案来获取本地的一些非全局信息输入方式。
graph TB subgraph 服务端 本地参数-->|注入|仿函数 仿函数-->|bind|RpcServerProxy accpet-->|远程参数|RpcServerProxy end subgraph 调用端 caller-->|请求|accpet end
局限性
虽说方便了不少,不过局限性依然在:
- 参数必须是pod类型(或描述为stardard layer标准布局),毕竟这个需要跨进程使用。
- 参数不能为数组,比如
char[20]
这种,毕竟模版只能推出一个字长的指针。(可以封装到结构体中推导结构体类型) - 参数size不能太大,不能搞个几兆的结构体,毕竟在这套模版里,对标函数调用,所有的实参还是保存在栈中(可以修改为保存在堆中解除这个限制)
- 在发送端引入了c++的异常机制,需要额外注意异常安全(其实是去掉异常的,只要对端返回值放到调用端的形参中返回,只不过强迫症没这么处理)
- 不像本地函数调用一样,能够支持不定长参数输入,输入和取出的参数个数必须保持一致(要求会不会太高了?)
- 不定长参数函数比如
printf
,或者是一些声明3个参数的回调但是输入4个参数的情况(调用约定中,如果是由调用者清楚调用堆栈是可以这么做的)
- 不定长参数函数比如