什么样的RPC才是好用的RPC

什么样的RPC才是好用的RPC

现在RPC框架很多,但是真正好的RPC却是少之又少。那么什么是好的RPC,什么是不好的RPC呢,有一个评判标准吗?下面是我列举出来的衡量RPC好用与否的几条标准:

  • 真的像本地函数一样调用
  • 使用简单,用户只需要关注业务即可
  • 灵活,RPC调用的序列化方式可以自由定制,比如支持json,支持msgpack等方式

下面来分别解释这几条标准。

  • 标准1:真的像本地函数一样调用

    RPC的本质是为了屏蔽网络的细节和复杂性,提供易用的api,让用户就像调用本地函数一样实现远程调用,所以RPC最重要的就是“像调用本地函数一样”实现远程调用,完全不让用户感知到底层的网络。真正好用的RPC接口,他的调用形式是和本地函数无差别的,但是本地函数调用是灵活多变的。服务器如果提供和客户端完全一致的调用形式将是非常好用的,这也是RPC框架的一个巨大挑战

  • 标准2:使用简单,用户只需要关注业务即可

    RPC的使用简单直接,非常自然,就是和调用本地函数一样,不需要写一大堆额外代码,用户只用写业务逻辑代码,而不用关注框架的细节,其他的事情都由RPC框架完成。

  • 标准3:灵活,RPC调用的序列化方式可以自由定制

    RPC调用的数据格式支持多种编解码方式,比如一些通用的json格式、msgpack格式或者boost.serialization等格式,甚至支持用户自己定义的格式,这样使用起来才会更灵活。

RPC框架评估

下面根据这几个标准来评估一些国内外知名大公司的RPC框架,这些框架的用法在github的wiki中都有使用示例,使用示例代码均来自官方提供的例子。

谷歌gRPC

gRPC最近发布了1.0版本,他是谷歌公司用c++开发的一个RPC框架,并提供了多种客户端。

  • 协议定义

  • 服务器代码

  • 客户端代码

  • 评价

    gRPC调用的序列化用的是protocal buffer,RPC服务接口需要在.proto文件中定义,使用稍显繁琐。根据标准1,gRPC并没有完全实现像本地调用一样,虽然很接近了,但做不到,原因是RPC接口中必须带一个Context的参数,并且返回类型必须是Status,这些限制导致gRPC无法做到像本地接口一样调用。
    根据标准2,gRPC的使用不算简单,需要关注诸多细节,比如Context和Status等框架的细节。根据标准3,gRPC只支持pb协议,无法扩展支持其他协议。

    综合评价:70分。

百度sofa-pbRPC

sofa-pbRPC是百度用c++开发的一个RPC框架,和gRPC有点类似,也是基于protocal buffer的,需要定义协议。

  • 协议定义

  • 服务器端代码

  • 客户端代码

  • 评价

    sofa-pbRPC的使用并没有像sofa这个名字那样sofa,根据标准1,服务端的RPC接口比gRPC更加复杂,更加远离本地调用了。根据标准2,用户要做很多额外的事,需要关注框架的很多细节,比较难用。根据标准3,同样只支持pb协议,无法支持其他协议。

    综合评价:50分。

腾讯Pebble

腾讯开源的Pebble也是基于protocal buffer的,不过他的用法比gRPC和sofaRPC更好用,思路都是类似的,先定义协议。

  • 协议定义

  • 服务器端代码

  • 客户端代码

  • 评价

    Pebble比gRPC和sofa-pbrpc更好用,根据标准1,调用方式和本地调用一致了,接口中没有任何限制。根据标准2,除了定义协议稍显繁琐之外已经比较易用了,不过服务器在使用上还是有一些限制,比如注册服务的时候只能注册一个类对象的指针,不能支持lambda表达式,std::function或者普通的function。根据标准3,gRPC只支持pb协议,无法扩展支持其他协议。

    综合评价:75分。

apache msgpack-RPC

msgpack-RPC是基于msgpack定义的RPC框架,不同于基于pb的RPC,他无需定义专门的协议。

  • 服务器端代码

  • 客户端代码

  • 评价

    msgpack-RPC使用起来也很简单,不需要定义proto文件,根据标准1,客户端的调用和本地调用一致,不过,服务器的RPC接口有一个msgpack::rpc::request对象,并且也必须派生于base类,使用上有一定的限制。根据标准2,服务器端提供RPC服务的时候需要根据method的名字来dispatch,这种方式不符合开闭原则,使用起来有些不方便。根据标准3,msgpack-rpc只支持msgpack的序列化,不能支持其他的序列化方式。

    综合评价:80分。

总结

目前虽然国内外各大公司都推出了自己的RPC框架,但是真正好用易用的RPC框架却是不多的,这里对各个厂商的RPC框架仅从好用的角度做一个评价,一家之言,仅供参考,希望可以为大家做RPC的技术选型的时候提供一些评判依据。

boost序列化二进制

需要注意的是,二进制的反序列化需要用户管理new出来的内存。

boost序列化std::tuple

由于boost的序列化默认不支持std::tuple,所以需要通过一个扩展来实现std::tuple的序列化。

测试代码

通过递归模版也可以实现

现代C++函数式编程

概述

函数式编程是一种编程范式,它有下面的一些特征:

  • 函数是一等公民,可以像数据一样传来传去。
  • 高阶函数
  • 递归
  • pipeline
  • 惰性求值
  • 柯里化
  • 偏应用函数

C++98/03中的函数对象,和C++11中的Lambda表达式、std::function和std::bind让C++的函数式编程变得容易。我们可以利用C++11/14里的新特性来实现高阶函数、链式调用、惰性求值和柯理化等函数式编程特性。本文将通过一些典型示例来讲解如何使用现代C++来实现函数式编程。

高阶函数和pipeline的表现形式

高阶函数就是参数为函数或返回值为函数的函数,经典的高阶函数就是map、filter、fold和compose函数,比如Scala中高阶函数:

  • map

  • filter

  • fold

  • compose

上面的例子中,有的是参数为函数,有的是参数和返回值都是函数。高阶函数不仅语义上更加抽象泛化,还能实现“函数是一等公民”,将函数像data一样传来传去或者组合,非常灵活。其中,compose还可以实现惰性求值,compose的返回结果是一个函数,我们可以保存起来,在后面需要的时候调用。

pipeline把一组函数放到一个数组或是列表中,然后把数据传给这个列表。数据就像一个链条一样顺序地被各个函数所操作,最终得到我们想要的结果。它的设计哲学就是让每个功能就做一件事,并把这件事做到极致,软件或程序的拼装会变得更为简单和直观。
Scala中的链式调用是这样的:

用法和Unix Shell的管道操作比较像,|前面的数据或函数作为|后面函数的输入,顺序执行直到最后一个函数。

这种管道方式的函数调用让逻辑看起来更加清晰明了,也非常灵活,允许你将多个高阶函数自由组合成一个链条,同时还可以保存起来实现惰性求值。现代C++实现这种pipeline也是比较容易的,下面来讲解如何充分借助C++11/14的新特性来实现这些高阶函数和pipeline。

实现pipeline的关键技术

根据前面介绍的pipeline表现形式,可以把pipeline分解为几部分:高阶函数,惰性求值,运算符|、柯里化和pipeline,把这几部分实现之后就可以组成一个完整的pipeline了。下面来分别介绍它们的实现技术。

高阶函数

函数式编程的核心就是函数,它是一等公民,最灵活的函数就是高阶函数,现代C++的算法中已经有很多高阶函数了,比如for_each, transform.

这些高阶函数不仅可以接受Lambda表达式,还能接受std::function、函数对象、普通的全局函数,很灵活。需要注意的是,普通的全局函数在pipeline时存在局限性,因为它不像函数对象一样可以保存起来延迟调用,所以我们需要一个方法将普通的函数转换为函数对象。std::bind也可以将函数转化为函数对象,但是bind不够通用,使用的时候它只能绑定有限的参数,如果函数本身就是可变参数的就无法bind了,所以,这个函数对象必须是泛化的,类似于这样:

上面的函数对象内部包装了一个普通函数的调用,当函数调用的时候实际上会调用普通函数globle_func,但是这个代码不通用,它无法用于其他的函数。为了让这个转换变得通用,我们可以借助一个宏来实现function到functor的转换。

我们先定义了一个宏,这个宏根据参数来生成一个可变参数的函数对象,这个函数对象的类型名为tfn_加普通函数的函数名,之所以要加一个前缀tfn_,是为了避免类型名和函数名重名。define_functor_type宏只是定义了一个函数对象的类型,用起来略感不便,还可以再简化一下,让使用更方便。我们可以再定义一个宏来生成转换后的函数对象:

make_globle_functor生成了一个可以直接使用的全局函数对象,使用起来更方便了。用这个方法就可以将普通函数转成pipeline中的函数对象了。接下来我们来探讨实现惰性求值的关键技术。

惰性求值

惰性求值是将求值运算延迟到需要值时候进行,通常的做法是将函数或函数的参数保存起来,在需要的时候才调用函数或者将保存的参数传入函数实现调用。现代C++里已经提供可以保存起来的函数对象和lambda表达式,因此需要解决的问题是如何将参数保存起来,然后在需要的时候传给函数实现调用。我们可以借助std::tuple、type_traits和可变模版参数来实现目标。

上面的测试代码中,我们先把参数保存到一个tuple中,然后在需要的时候将参数和函数f传入tuple_apply,最终实现了f函数的调用。tuple_apply实现了一个“魔法”将tuple变成了函数的参数,来看看这个“魔法”具体是怎么实现的。

tuple_apply_impl实现的关键是在于可变模版参数的展开,可变模版参数的展开又借助了std::index_sequence

运算符operator|

pipeline的一个主要表现形式是通过运算符|来将data和函数分隔开或者将函数和函数组成一个链条,比如像下面的unix shell命令:

C++实现类似的调用可以通过重载运算符来实现,下面是data和函数通过|连接的实现代码:

除了data和函数通过|连接之外,还需要实现函数和函数通过|连接,我们通过可变参数来实现:

其中fn_chain是一个可以接受任意个函数的函数对象,它的实现将在后面介绍。通过|运算符重载我们可以实现类似于unix shell的pipeline表现形式。

柯里化

函数式编程中比较灵活的一个地方就是柯里化(currying),柯里化是把多个参数的函数变换成单参数的函数,并返回一个新函数,这个新函数处理剩下的参数。以Scala的柯里化为例:

  • 未柯里化的函数

  • 柯里化之后

currying之后add(1)(2)等价于add(1,2),这种currying默认是从左到右的,如果希望从右到左呢,然而大部分编程语言没有实现更灵活的curring。C++11里面的std::bind可以实现currying,但要实现向左或向右灵活的currying比较困难,可以借助tuple和前面介绍的tuple_apply来实现一个更灵活的currying函数对象。

从测试代码中可以看到这个currying函数对象,既可以从左边currying又可以从右边currying,非常灵活。不过使用上还不太方便,没有fn(1)(2)(3)这样方便,我们可以通过运算符重载来简化书写,由于C++标准中不允许重载全局的operater()符,并且operater()符无法区分到底是从左边还是从右边currying,所以我们选择重载<<和>>操作符来分别表示从左至右currying和从右至左currying。

有了这两个重载运算符,测试代码可以写得更简洁了。

curry_functor利用了tuple的特性,内部有两个空的tuple,一个用来保存left currying的参数,一个用来保存right currying的参数,不断地currying时,通过tuple_cat把新currying的参数保存到tuple中,最后调用的时候将tuple成员和参数组成一个最终的tuple,然后通过tuple_apply实现调用。有了前面这些基础设施之后我们实现pipeline也是水到渠成。

pipeline

通过运算符|重载,我们可以实现一个简单的pipeline:

这个简单的pipeline虽然可以实现管道方式的链式计算,但是它只是将data和函数通过|连接起来了,还没有实现函数和函数的连接,并且是立即计算的,没有实现延迟计算。因此我们还需要实现通过|连接函数,从而实现灵活的pipeline。我们可以通过一个function chain来接受任意个函数并组成一个函数链。利用可变模版参数、tuple和type_traits就可以实现了。

测试代码中用一个fn_chain和运算符|将所有的函数组合成了一个函数链,在需要的时候调用,从而实现了惰性求值。

fn_chain的实现思路是这样的:内部有一个std::tuple

在调用call_impl的过程中,将std::index_sequence不断展开,先从tuple中获取第I个function,然后调用获得第I个function的执行结果,将这个执行结果作为下次调用的参数,不断地递归调用,直到最后一个函数完成调用为止,返回最终的链式调用的结果。

至此我们实现具备惰性求值、高阶函数和currying特性的完整的pipeline,有了这个pipeline,我们可以实现经典的流式计算和AOP,接下来我们来看看如何利用pipeline来实现流式的mapreduce和灵活的AOP。

实现一个pipeline形式的mapreduce和AOP

前面的pipeline已经可以实现链式调用了,要实现pipeline形式的mapreduce关键就是实现map、filter和reduce等高阶函数。下面是它们的具体实现:

这些高阶函数还需要转换成支持currying的functor,前面我们已经定义了一个普通的函数对象转换为柯里化的函数对象的方法:

通过下面这个宏让currying functor用起来更简洁:

我们定义了map、reduce和filter支持柯里化的三个全局函数对象,接下来我们就可以把它们组成一个pipeline了。

上面的例子实现了pipeline的mapreduce,这个pipeline支持currying还可以任意组合,非常方便和灵活。

有了这个pipeline,实现灵活的AOP也是很容易的:

上面的测试例子中,核心逻辑是func函数,我们可以在func之前或之后插入切面逻辑,切面逻辑可以不断地加到链条前面或者后面,实现很巧妙,使用很常灵活。
总结

本文通过介绍函数式编程的概念入手,分析了函数式编程的表现形式和特性,最终通过现代C++的新特性和一些模版元技巧实现了一个非常灵活的pipeline,展示了现代C++实现函数式编程的方法和技巧,同时也提现了现代C++的强大威力和无限的可能性。文中完整的代码可以从我的GitHub上查看。

本文的代码和思路参考和借鉴了这篇文章,在此表示感谢。

注:本文是作者发表在程序员8月刊上的文章,转载请注明出处。

Kapok发布1.0版本了

kapok1.0发布了

kapok的特点

kapok是一个高性能跨平台的对象-json序列化的库,对象序列化后是标准的json格式,json格式的字符串可以直接反序列化为对象。简单,易用,header-only,只需要引用Kapok.hpp即可。它由c++14实现,因此需要支持C++14的编译器。

kapok除了支持结构体、容器和数组之外,还支持boost.optional和boost.variant,使用起来非常方便。

性能对比

kapok序列化和反序列化的性能很高,综合性能比msgpack要高,下面是性能对比的代码

综合测试

测试msgpack

测试kapok

测试结果

单项测试

分别测试msgpack序列化和反序列化的耗时

分别测试kapok的序列化和反序列化的耗时

测试结果

测试结论

从单项测试结果看,msgpack的序列化比kapok快大约2-3倍,而kapok的反序列化速度比msgpack快了5倍,综合性能,kapok比msgpack快4-5倍。

使用示例

  • 普通对象

  • 枚举对象

  • std::pair

  • std::array

  • std::tuple

  • boost.optional

  • boost.variant

  • 嵌套的容器和对象

需要注意的地方

  • kapok不支持指针,当序列化的对象中含有指针的时候会出现编译错误。

  • 反序列化的时候,如果反序列化和序列化的字段类型不匹配的时候,kapok会忽略该字段,不会赋值。

  • 序列化/反序列化过程中kapok可能会抛出异常,用户需要在外面捕捉异常。

  • kapok不是线程安全的,如果需要多线程操作的话,请用户在外面自行加锁。

  • 序列化的对象一定要定义META宏,否则会出现编译错误。

rest_rpc的三种工作模式

rest_rpc的三种工作模式

rest_rpc的使用非常灵活,支持三种工作模式:请求-应答模式、stream流模式和订阅发布模式,下面来分别介绍这三种工作模式。

三种工作模式

  • 请求-应答模式

    这种模式就是使用RPC的经典模式,用起来也最简单,客户端发送调用请求,服务器返回调用结果。请求和结果都是json格式。

  • stream流模式

    流模式就是传统的tcp/ip流的发送方式,客户端发送原始的二进制流数据到服务器,服务器根据约定的协议处理数据。流模式下,服务器不会返回响应给客户端。一般情况下,流模式和请求应答模式结合起来使用会更方便。

  • 订阅发布模式

    订阅发布模式可以看成是前两种模式的一种扩展,订阅者接受某个主题的结果,发布者触发某一个主题的调用,调用的结果会发送到所有订阅了该主题的订阅者。请求-应答模式可以看作是一种特殊的订阅-发布模式,即订阅者和发布者都是自己。

    订阅发布模式要分为订阅和发布两种使用情况,订阅主题的时候通过请求应答模式是非常方便的,在发布主题的时候可以使用请求应答模式也可以使用stream流模式,发布的时候可以要求服务器返回调用结果,也可以不需要返回结果。

    订阅者候始终是被动接收服务器广播的结果,仅仅在被动接收数据之前允许使用请求-应答模式告诉服务器订阅了什么主题。

    需要注意的是,请求-应答和stream模式可以在一个client里使用,而订阅发布需要一个单独的client。

三种工作模式的使用

  • 请求应答模式

    请求应答模式下仅仅需要调用通用接口call接口即可,使用示例

  • stream流模式

    stream流模式仅仅需要调用call_binary接口即可

  • 订阅发布模式

    服务器

    订阅客户端

    发布客户端

rest_rpc使用示例 — 文件上传

rest_rpc使用示例

rest_rpc即支持请求-应答模式,也支持no-response模式,用户可以随意选择这两种模式。rest_rpc支持json格式数据的传输,还支持无性能损耗的原始二进制流的传输(无需通过base64或者16进制转码),用户可以随意选择自己需要的传输格式。
下面是一个客户端向服务器传送文件的例子,这个例子中展示了如何选择应答和非应答模式,以及用json传输和二进制传输,非常灵活地完成传文件的功能。

服务器端的代码:

  • 先创建一个文件传输的类

这个类定义了4个服务函数,这4个函数是直接暴漏给客户端的,可以看到,其中upload函数是直接接受二进制流数据,它没有返回值,所以它不会向客户端返回响应,其他的函数则会向客户端返回一个response来表明服务器端的调用是否成功。
这几个函数通过字面意思就可以清晰地知道含义,文件传输流程是这样的,客户端先告诉服务器希望保存的文件名,服务器应答成功之后,客户端就开始分段发文件的二进制流数据给服务器了,当文件传输完之后,再通知服务器文件传输完成,服务器保存文件,至此,整个文件传输的过程结束了。

  • 发布服务接口

业务类定义完成之后,接下来就是暴露服务接口给客户端了,让客户端可以像调用本地函数一样很方便地使用,而这对于rest_rpc来说是非常简单的事。

这样我就对外提供了4个服务接口,接下来看看客户端是如何调用这几个服务接口实现文件上传的。

客户端的代码

总结

可以看到rest_rpc使用起来非常灵活,也非常简单,后续还会继续增加一些rest_rpc的使用示例。完整的代码可以在rest_rpc的exapmle中看到。

Copy Protected by Chetan's WP-Copyprotect.