随着项目的发展,除了业务所在的WebService之外,有了内部系统的业务需求,涵盖客服财务统计报表等,在项目子系统篇中能看到详细的介绍。今天在这里要说的是系统间的桥梁:RPC(Remote Procedure Call)
其实这也不是什么新鲜的概念,上世纪70年代就提出过理论,80年代就实际应用过。RPC多是用于对部署于其他主机或者网络空间的服务的请求。所以比作系统间的桥梁也是比较合适的。
我们的业务系统和内部系统都是由Django搭建的,所以rpc服务显然也是要找支持python语言的。其他语言下的优秀框架很多,也不是说不能用,但是不要随意增加项目的技术复杂度(Python和其他语言的协同应用)。
因此眼光落在了SimpleXMLRPCServer和zerorpc上。
关于各自的介绍在链接里都能看到,就简单比较一下优缺点把。
优点 | 缺点 | |
---|---|---|
SimpleXMLRPCServer | python自带库,不用额外安装 | 数据包大,速度慢 |
zerorpc | 底层使用ZeroMQ和MessagePack,速度快,响应时间短,并发高 | 额外安装,文档不多 |
调研时自己写过一个workbench,把代码贴上来。
[server端代码]
import zerorpc
from SimpleXMLRPCServer import SimpleXMLRPCServer
class RPCServer(object):
"""docstring for RPCServer"""
def __init__(self):
super(RPCServer, self).__init__()
self.data = {str(i): i for i in range(100)}
self.data2 = None
def getObj(self):
print('get data')
return self.data
def sendObj(self, data):
print('send data')
self.data2 = data
# zerorpc
s = zerorpc.Server(RPCServer())
s.bind('tcp://0.0.0.0:4243')
s.run()
# SimpleXMLRPCServer
server = SimpleXMLRPCServer(('localhost',4242), allow_none=True)
server.register_introspection_functions()
server.register_instance(RPCServer())
server.serve_forever()
[client端代码]
import zerorpc
import time
import xmlrpclib
# zerorpc
def zerorpc_client():
print('zerorpc client')
c = zerorpc.Client()
c.connect('tcp://127.0.0.1:4243')
data = {str(i): i for i in range(100)}
start = time.clock()
for i in range(5000):
c.getObj()
for i in range(5000):
c.sendObj(data)
print('total time %s' % (time.clock() - start))
# SimpleXMLRPCServer
def xmlrpc_client():
print('xmlrpc client')
c = xmlrpclib.ServerProxy('http://localhost:4242')
data = {str(i): i for i in range(100)}
start = time.clock()
for i in range(5000):
c.getObj()
for i in range(5000):
c.sendObj(data)
print('xmlrpc total time %s' % (time.clock() - start))
都是本机测试,结果zerorpc性能要高10%-20%,加上ZeroMQ和MessagePack带来的优势,所以选择了zerorpc。
后来业务上又新增了NodeJS的服务,同样需要请求业务服务器的数据,相比HTTP请求,RPC消耗的资源更少,这时就非常庆幸最初选择了zerorpc,因为它还能够无缝兼容javascript。
从zerorpc的使用方式可以看出我们只需要提供一个包含所有函数的instance,因此这里尤其适合将函数按模块划分,并由一个主类(MainClass)多重继承而来。
就像这样:
class RPCModuleA(object):
...
class RPCModuleB(object):
...
class RPCServer(RPCModuleA,
RPCModuleB):
...
最初在项目中使用的RPC服务就仅仅只有几个小类,一个主类,然后命令行一跑,往后台一放,OK了。
在简单的需求下,RPC服务的压力不高,调用不频繁,这样的结构已经足够了。
但是随着依赖RPC的业务越来越多,问题也就一点一点暴露出来,首先就是调试的问题。所有rpc服务都会将server端抛出的异常返回给client端,但是如果是一个不会抛异常的BUG呢?
我就遇到这样一个问题,只是更新数据库里的一条记录,但是却传错了参数,导致错误的记录被更新了。通常的代码里,我们打个断点或者在函数里打个日志输出一下参数和返回值就行,但是在这里我们会面临2个问题:
- RPC服务很有可能在另一台主机上,甚至是线上服务器,不能停机调试
- 涉及到的函数可能不止一个,考虑到以后会有越来越多的函数,不可能在每个函数里都重复一遍打日志的代码,那会很愚蠢
所以需要一个简单的方法能够打出所有的调用请求、参数和返回值。
好在我们用的是python。 so let’s do it in python way.
我们都知道python instance在初始化时调用的是 __init__
函数,因此我们可以在所有父类的__init__
函数执行完后对这个instance做些手脚。
class func_wrapper(object):
def __init__(self, func):
self.func = func
class RPCServer(RPCModuleA,
RPCModuleB):
def __init__(self):
super(RPCServer, self).__init__()
for func_name in dir(self):
if not func_name.startswith('_'):
func = getattr(self, func_name)
if callable(func):
setattr(self, func_name, func_wrapper(func))
代码里我们能看到我对RPCServer的所有以非下划线开头的函数(包括继承而来的)都封装了一遍并替换掉了原函数。但是func_wrapper(func)是一个instance,不是函数,也就不能以函数形式调用。了解python的同学应该知道,python的built-in函数callable
可以检查一个object是否可以以函数形式调用,看一下文档(python2 callable)就能知道class instance如果有__call__
函数就能被调用。那这就很简单了,我们加上这个函数并在其中调用原先的函数对象,然后把我们关心的函数名,参数,返回值都打出来,就像这样:
class func_wrapper(object):
def __init__(self, func):
self.func = func
def __call__(self, *args, **kwargs):
result = self.func(*args, **kwargs)
print(self.func.__name__, args, kwargs, result)
return result
非常好,这样目的达到了,运行一下吧。。。呵呵!报错了。
AttributeError: 'func_wrapper' object has no attribute '__name__'
没错,RPC server需要把所有函数名都暴露给client,把原来的函数替换了但是名字没留下,自然是要出错。修改一下,很简单:
class func_wrapper(object):
def __init__(self, func):
self.func = func
def __call__(self, *args, **kwargs):
result = self.func(*args, **kwargs)
print(self.func.__name__, args, kwargs, result)
return result
@property
def __name__(self):
return self.func.__name__
class RPCServer(RPCModuleA,
RPCModuleB):
def __init__(self):
super(RPCServer, self).__init__()
for func_name in dir(self):
if not func_name.startswith('_'):
func = getattr(self, func_name)
if callable(func):
setattr(self, func_name, func_wrapper(func))
好了,这样就完成了我们对每一次调用都把函数名,参数,返回值打出日志的目的了。
但是这还没完。项目是越做越大的,新问题是越来越多的。随着rpc服务的内容变多,新出现了2个问题:
- 每次更新代码都要重启一遍服务,能不能autoreload?
- 重启服务是简单,但是经常遇到client把端口占住了,RPCServer关了就开不了了。
第二个问题需要解释一下,测试时client和server都在同一台主机上,client端为了节省资源,把rpcclient对象一直保持住,避免多次建立连接。发布到生产环境时基本不会这么处理,但是我们毕竟还是创业初期,服务器资源也很紧张,难免遇到多个服务部署在同一台机器上的时候,所以这个问题还是需要解决的。
解决办法是使用了django.utils.autoreload模块,它把两个问题都解决了。
def startRPCServer():
s = zerorpc.Server(RPCServer())
s.bind('tcp://0.0.0.0:' + RPC_PORT)
s.run()
if __name__ == '__main__':
from django.utils.autoreload import main
main(startRPCServer)
django用manager.py runserver来启动也是使用的autoreload来监测文件变化。这样既能占住端口,又能无缝更新代码。
不过实际使用时还是有点小问题。如果你使用Sublime Text的REPL包来运行python脚本的话,当你把REPL tab关掉后不会如你所想的一样把占用端口的进程也杀掉。其中的原因我想是因为autoreload起了2个进程,一个进程监测文件,一个进程是我们实际的RPCServer,而关闭tab只是关闭了监测进程而已。关于这个还没有什么解决办法,日后有办法了再来更新吧。
大体上关于zerorpc的应用就到这里了,项目的体量还不至于大到需要分布式RPC服务。虽然我很想尝试,但是可能得以后才有机会了。除了上述说到的内容以外还做了一些输出重定向的工作,用于其它的日志输出,里面有些打印调用栈的知识点,就在以后关于python技巧的文章里再说吧。
如有错误,欢迎指正。