作者:cuizi7
目录
RPC(Remote Procedure Call Protocol)即远程过程调用,通俗地说就是实现部署在一台服务器上的应用调用部署在另一台服务器上的应用所提供的函数或方法(或运行在两个不同进程中的应用调用对方的函数或方法)。由于两个应用不在同一个内存空间,不能直接调用,此时就需要通过网络来表达调用的语义和传达调用的数据。
为了简化多进程架构交易程序的开发,vn.py项目提供了RPC模块vnpy.rpc,该模块除了支持远程函数调用外,还支持服务端向客户端的数据广播推送。基于该模块,vn.py相关的应用程序可以轻易实现前端数据展示和后端交易引擎间的解耦,即可以在服务器上运行后端引擎而在本机运行前端UI 。
对于量化交易,一种常见的使用场景是需要策略或交易引擎在交易时间段内不间断运行,对于比特币或者外汇等交易品种,甚至需要策略24小时不间断运行,这就对策略运行的软硬件平台的稳定性提出了很高的要求,在这样的需求下,将策略运行在本地计算机显然不是一个经济合理的选择。基于该RPC模块,可以在服务器上运行策略,在本地对策略和账户进行监控,亦可以在本地直接调用VnTrader引擎的一系列接口,而本地出现的软硬件故障丝毫不会影响程序后端的持续稳定运行。
对于机构或者团队用户,一种可能的使用场景一个或多个用户/交易员面对vn.py的前端,他们不关心vn.py的安装过程、开发环境的搭建和后端引擎的运行流程,不关心或是无权限获知策略的源代码及运行过程,亦或是他们需要多人对同一个策略或账户进行管理。此时只需要利用RPC模块,将vn.py的后端引擎和交易策略部署在服务器上,用户或交易员便可以通过在本机简单安装前端模块,连接对应的后端服务,实现对黑盒状态的引擎和策略的监控及管理。
总而言之,当VnTrader或者其他vn.py应用需要多进程或分布式运行时,使用RPC模块是很好的选择。
要实现 RPC,需要解决三个问题。
首先,要解决通讯的问题,主要通过在客户端和服务器之间建立连接,RPC交换的所有数据都在这个连接中传输。
第二,要解决寻址的问题,也就是说客户端的应用怎么告诉RPC框架,如何连接到服务端以及调用的函数名是什么,怎样才能完成调用。
第三,要解决序列和反序列化的问题。当客户端的应用发起RPC调用时,函数的参数值需要通过底层的网络协议传递到服务端,由于网络协议是二进制的,内存中的参数的值要序列化成二进制的形式;当服务端接收到请求后,也要对参数进行反序列化,将参数恢复为内存中的表达方式进而进行本地调用。反之,服务端将返回值发送到客户端也需要经过类似的过程。
针对以上问题,vnpy.rpc均给出了自己的解决方案。
对于通讯的问题,vnpy.rpc使用ZMQ作为底层通讯库。ZMQ是一个消息队列,是类似于Socket的一系列接口。ZMQ用于node与node间的通信,node可以是主机或者进程。ZMQ提供了三个基本的通信模型,分别是“Request-Reply”、“Publish-Subscribe”以及“Parallel Pipeline”。
在通常的客户端到服务端到跨进程函数调用的过程中,vnpy.rpc使用了ZMQ的“Request-Reply(请求-响应)”模型。在这一过程中,客户端将序列化好的请求发送至服务端,服务端将请求反序列化后寻址找到对应的函数进行本地调用,再将序列化的返回值发送回客户端,在等待服务端返回的过程中,客户端是阻塞的。下面是一个简单的 ZMQ Request-Reply 模型的 Python 实现,在这个例子中,序列化反序列化和寻址的过程均被省略,只留有通讯的部分:
"""RPC 服务端""" import zmq # zmq端口相关 context = zmq.Context() # 请求响应socket socketREP = context.socket(zmq.REP) # 绑定端口 socketREP.bind("tcp://*:2014") while True: # 使用poll来等待事件到达,等待1秒(1000毫秒) if not socketREP.poll(1000): continue # 从请求响应socket收取请求数据 req = socketREP.recv() # 通过请求响应socket返回调用结果 socketREP.send('Server received \'%s\' and response' % req)
"""RPC 客户端""" import zmq # zmq端口相关 context = zmq.Context() # 请求发出socket socketREQ = context.socket(zmq.REQ) # 连接端口 socketREQ.connect("tcp://localhost:2014") # 发送请求并等待回应 socketREQ.send('Hello World!') # 收到回应 rep = socketREQ.recv() print(rep)
除了跨进程调用函数外,vnpy.rpc还支持支持服务端向客户端主动数据推送,在这一过程中,vnpy.rpc使用了 ZMQ 的“Publish-Subscribe(推送-订阅)”模型。服务端可主动向客户端推送带有主题标识的序列化的数据,所有订阅了该主题的客户端均会收到数据,客户端将数据反序列化后对数据进行处理。下面是一个简单的 ZMQ Publish-Subscribe 模型的 Python 实现,在这个例子中,序列化反序列化的过程均被省略,只留有通讯的部分:
"""RPC 服务端""" import zmq from time import sleep # zmq端口相关 context = zmq.Context() # 数据广播socket socketPUB = context.socket(zmq.PUB) # 绑定端口 socketPUB.bind("tcp://*:2014") topic = 'hello' data = 'Hello World!' while True: # 通过广播socket发送数据 socketPUB.send_multipart([topic, data]) sleep(2)
import zmq # zmq端口相关 context = zmq.Context() # 广播订阅socket socketSUB = context.socket(zmq.SUB) # 连接端口 socketSUB.connect("tcp://localhost:2014") # 订阅特定主题的广播数据,使用''来订阅所有的主题 socketSUB.setsockopt(zmq.SUBSCRIBE, '') while True: # 使用poll来等待事件到达,等待1秒(1000毫秒) if not socketSUB.poll(1000): continue # 从订阅socket收取广播数据 topic, data = socketSUB.recv_multipart() print('Client received \'%s\'' % data)
vnpy.rpc 所要解决的寻址问题主要是服务端收到请求后如何调用正确的函数的问题。
在客户端,得益于Python的动态性,vnpy.rpc的客户端类利用__getattr__
方法获取用户调用的函数名和变量,将函数名和变量一起打包序列化后发送至服务端。
在服务端,用户可以通过register(func)
函数注册将会被调用的函数。服务端会将函数名和函数的映射关系存储在__functions
字典中,当收到客户端的请求时,服务端会反序列化收到的数据并从中解析出函数名和参数,再通过函数名寻找__functions
字典中对应的函数,进而本地调用函数。
相关函数的实现将会在下文 RpcClient 和 RpcServer 的介绍中展示。
序列化和反序列化即数据结构或对象与二进制序列之间相互转换的过程。
vnpy.rpc默认提供了三种序列化和反序列化工具,分别是msgpack、json和cPickle。对于Python来说msgpack和json仅能序列化“简单”的数据结构,如列表、元组、字典等,而cPickle可以直接序列化复杂的Python对象。msgpack和json相比于cPickle通用性更好,前二者相比,msgpack性能更佳而json的序列化结果有更好的可读性且在互联网环境中应用更广泛。vnpy.rpc建议使用 msgpack,用户也可根据自己的使用场景在三种工具之间进行选择。当然,其他的序列化协议如protobuf等也能够满足RPC的需要。
vnpy.rpc提供了一个RPC对象(RpcObject
)类,在该类中实现了序列化(pack
)与反序列化(unpack
)接口以及切换序列化工具的相关方法,vnpy.rpc的服务端和客户端类只需继承该类就可以方便地序列化和反序列化请求。
RPC对象类的实现如下:
class RpcObject(object): """ RPC对象 """ def __init__(self): """Constructor""" # 默认使用msgpack作为序列化工具 #self.useMsgpack() self.usePickle() def pack(self, data): """打包/序列化""" pass def unpack(self, data): """解包/反序列化""" pass def __jsonPack(self, data): """使用json打包""" return dumps(data) def __jsonUnpack(self, data): """使用json解包""" return loads(data) def __msgpackPack(self, data): """使用msgpack打包""" return packb(data) def __msgpackUnpack(self, data): """使用msgpack解包""" return unpackb(data) def __picklePack(self, data): """使用cPickle打包""" return pDumps(data) def __pickleUnpack(self, data): """使用cPickle解包""" return pLoads(data) def useJson(self): """使用json作为序列化工具""" self.pack = self.__jsonPack self.unpack = self.__jsonUnpack def useMsgpack(self): """使用msgpack作为序列化工具""" self.pack = self.__msgpackPack self.unpack = self.__msgpackUnpack def usePickle(self): """使用cPickle作为序列化工具""" self.pack = self.__picklePack self.unpack = self.__pickleUnpack
vnpy.rpc的核心是RpcServer
和RpcClient
两个类,这两个模块分别作为服务端和客户端运行在两个不同的内存空间,他们都是RpcObject
的子类,继承了RpcObject
的序列化和反序列化函数。本节将介绍这两个类。
RpcServer
是 vnpy.rpc的服务端类,运行在服务器上。该类最核心的方法是run
,该方法运行在单独的工作线程中,接收客户端发送的请求并将之反序列化,得到客户端要调用的函数名及其参数;run
在类的成员变量__functions
中可得到函数名对应的函数(该过程即寻址),调用该函数,随后将返回值序列化并发送回客户端。该方法的实现如下。
class RpcServer(RpcObject): """RPC服务器""" ... def run(self): """服务器运行函数""" while self.__active: # 使用poll来等待事件到达,等待1秒(1000毫秒) if not self.__socketREP.poll(1000): continue # 从请求响应socket收取请求数据 reqb = self.__socketREP.recv() # 序列化解包 req = self.unpack(reqb) # 获取函数名和参数 name, args, kwargs = req # 获取引擎中对应的函数对象,并执行调用,如果有异常则捕捉后返回 try: func = self.__functions[name] r = func(*args, **kwargs) rep = [True, r] except Exception as e: rep = [False, traceback.format_exc()] # 序列化打包 repb = self.pack(rep) # 通过请求响应socket返回调用结果 self.__socketREP.send(repb)
如上文所述,vnpy.rpc除了远程调用外还有服务端向客户端推送数据的功能,该功能在RpcServer
中通过publish(topic, data)
函数实现,在该函数中,要推送的数据被序列化,然后和推送的主题一起发送给所有客户端。该方法的实现如下。
class RpcServer(RpcObject): """RPC服务器""" ... def publish(self, topic, data): """ 广播推送数据 topic:主题内容 data:具体的数据 """ # 序列化数据 datab = self.pack(data) # 通过广播socket发送数据 self.__socketPUB.send_multipart([topic, datab])
除此之外,start()
,stop()
和register(func)
三个函数分别用于服务端的启动、停止和服务端被远程调用函数的注册,其实现和事件引擎部分对应函数相似,在此不再赘述。
RpcClient
是vnpy.rpc的客户端类,该类被用户直接调用,或和被用户直接调用的程序运行在同一内存空间内。
在Python中,当用户调用一个类中未事先定义的方法时,用户实际上调用了类中的___getattr()___
方法。RpcClient
实现了这一方法,在该方法中获取到了用户尝试调用的方法名和参数,并将之序列化,发送给RpcServer
;随后程序阻塞,直至收到服务端的回报;程序将回报反序列化后检测其中的状态码,据此决定是要将函数返回值返回还是抛出异常。整个过程在外部程序看来与调用本地的方法无异。__getattr()__
的实现如下。
class RpcClient(RpcObject): """RPC客户端""" ... def __getattr__(self, name): """实现远程调用功能""" # 执行远程调用任务 def dorpc(*args, **kwargs): # 生成请求 req = [name, args, kwargs] # 序列化打包请求 reqb = self.pack(req) # 发送请求并等待回应 self.__socketREQ.send(reqb) repb = self.__socketREQ.recv() # 序列化解包回应 rep = self.unpack(repb) # 若正常则返回结果,调用失败则触发异常 if rep[0]: return rep[1] else: raise RemoteException(rep[1]) return dorpc
为了实现服务端向客户端的数据推送功能,客户端也有运行在单独工作线程中的run()
方法,客户端在该方法中等待服务端的主动数据推送,在收到推送后将之反序列化,并调用事先定义好的数据处理函数(callback(topic, data)
)。另外,通过调用subscribeTopic(topic)
方法可以使客户端订阅特定的主题,以便客户端只接受特定主题的推送。与推送数据相关的方法实现如下。
class RpcClient(RpcObject): ... def run(self): """客户端运行函数""" while self.__active: # 使用poll来等待事件到达,等待1秒(1000毫秒) if not self.__socketSUB.poll(1000): continue # 从订阅socket收取广播数据 topic, datab = self.__socketSUB.recv_multipart() # 序列化解包 data = self.unpack(datab) # 调用回调函数处理 self.callback(topic, data) def callback(self, topic, data): """回调函数,必须由用户实现""" raise NotImplementedError def subscribeTopic(self, topic): """ 订阅特定主题的广播数据 可以使用topic=''来订阅所有的主题 """ self.__socketSUB.setsockopt(zmq.SUBSCRIBE, topic)
另外,RpcClient同样通过start()
和stop()
函数实现客户端连续运行的启动和停止,其实现在此不再赘述。
由于 TCP 连接本身的特点,RPC 服务端无法实时监测客户端的连接情况,当客户端异常断连,服务端却在照常执行逻辑,可能会造成意想不到的后果。所以在网络状况恶劣的情况下,可以考虑加入心跳检测,该功能 vnpy.rpc 本身并未提供,需要开发者按需加入 。
由于 RPC 的分布式和多进程特性,可能会产生多个客户端或进程访问同一个资源的情况。当出现这种情况时就应当考虑线程安全的问题。