在Django中使用zerorpc

在Django中使用zerorpc

前言

随着系统架构从集中式单点服务器到分布式微服务方向的迁移,RPC是一个不可回避的话题.如何在系统中引入对开发者友好,性能可靠的RPC服务是一个值得深思的问题.

在调研了Thrift,gRPC,zerorpc等方案后,基于以下2点最后选择了zerorpc:

  • Thrift,gRPC学习成本高,开发者需要重新定义返回结构增加了工作量
  • zerorpc完美契合Python,能快速开发,并且支持Node.js,适用于当前技术栈

问题

虽然zerorpc可以直接嵌入当前系统框架中,但是还是有一些问题需要去考虑解决

  • rpc 接口如何定义

  • rpc 服务如何启动

  • 高并发情况下客户端的可靠性

 

服务端

在当前的系统中大量使用Celery,djang-celery定义Task的方式是在每个install app中定义tasks.py文件,然后通过@task装饰器来生成Task.所以这里为了方便定义rpc interface设计一套类似于Celery的规范.需要输出rpc interface的app下面创建rpcs.py文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# rpcs.py
# coding: utf-8
 
from eebo.core.utils.zrpc import rpc
from .models import Ticket
from .serializers import TicketSerializer
 
 
@rpc.register()
def get_ticket():
    t = Ticket.objects.first()
    s = TicketSerializer(t)
    return s.data
 
 
@rpc.register(name='ticket_list', stream=True)
def get_tickets(n):
    qs = Ticket.objects.all()[:n]
    = TicketSerializer(qs, many=True)
    return iter(s.data)

  

rpc.register装饰器用来注册函数到rpc服务上,可选参数:

  • name: 客户调用方法名称, 没有写的情况下就是func name如get_ticket
  • stream: 默认False, 如果为True, 则使用zerorpc的流式响应传输, 数据量比较大的情况时使用, 返回可迭代对象

我们来看看eebo.core.utils.zrpc如何来实现这个注册过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# coding: utf-8
 
import zerorpc
 
 
class RPC(object):
    @classmethod
    def register(cls, name=None, stream=False):
        def _wrapper(func):
            setattr(cls, name or func.__name__, zerorpc.stream(
                lambda self, *args, **kwargs: func(*args, **kwargs)) if stream
                    else staticmethod(func))
            return func
 
        return _wrapper
 
 
rpc = RPC()

  

通过一个类方法来往类上面绑定方法,需要注意的是name的定义必须是全局唯一的.

现在我们有了定义rpc interface的方法,下面来看看如何启动rpc server.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
# runrpc.py
# coding: utf-8
 
import re
import sys
import imp as _imp
import importlib
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
 
from eebo.core.utils.zrpc import rpc, ServerExecMiddleware
 
naiveip_re = re.compile(r"""^(?:
(?P<addr>
    (?P<ipv4>\d{1,3}(?:\.\d{1,3}){3}) |         # IPv4 address
    (?P<ipv6>\[[a-fA-F0-9:]+\]) |               # IPv6 address
    (?P<fqdn>[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*) # FQDN
):)?(?P<port>\d+)$""", re.X)
 
 
class Command(BaseCommand):
    help = "Starts a lightweight RPC server for development."
 
    default_addr = '127.0.0.1'
    default_port = '4242'
 
    def add_arguments(self, parser):
        parser.add_argument('addrport',
                            nargs='?',
                            help='Optional port number, or ipaddr:port')
 
    def handle(self, *args, **options):
 
        self.use_ipv6 = False
        if not options['addrport']:
            self.addr = ''
            self.port = self.default_port
        else:
            m = re.match(naiveip_re, options['addrport'])
            if m is None:
                raise CommandError('"%s" is not a valid port number '
                                   'or address:port pair.' %
                                   options['addrport'])
            self.addr, _ipv4, _ipv6, _fqdn, self.port = m.groups()
            if not self.port.isdigit():
                raise CommandError("%r is not a valid port number." %
                                   self.port)
            if self.addr:
                if _ipv6:
                    self.addr = self.addr[1:-1]
                    self.use_ipv6 = True
                    self._raw_ipv6 = True
                elif self.use_ipv6 and not _fqdn:
                    raise CommandError('"%s" is not a valid IPv6 address.' %
                                       self.addr)
        if not self.addr:
            self.addr = self.default_addr_ipv6 if self.use_ipv6 else self.default_addr
            self._raw_ipv6 = self.use_ipv6
        self.run(**options)
 
    def run(self, **options):
        """Run the server, using the autoreloader if needed."""
        self.autodiscover_rpc()
 
        server = self.get_server()
 
        try:
            server.run()
        except KeyboardInterrupt:
            server.close()
            sys.exit(0)
 
    def autodiscover_rpc(self, related_name='rpcs'):
        for pkg in settings.INSTALLED_APPS:
            try:
                pkg_path = importlib.import_module(pkg).__path__
            except AttributeError:
                continue
 
            try:
                _imp.find_module(related_name, pkg_path)
            except ImportError:
                continue
 
            try:
                importlib.import_module('{0}.{1}'.format(pkg, related_name))
            except ImportError:
                pass
 
    def get_server(self, *args, **options):
        """Return the default zerorpc server for the runner."""
        import zerorpc
        server = zerorpc.Server(rpc, heartbeat=30)
        server.bind("tcp://{0}:{1}".format(self.addr, self.port))
        # close django old connections
        zerorpc.Context.get_instance().register_middleware(ServerExecMiddleware())
 
        # for sentry
        try:
            from raven.contrib.zerorpc import SentryMiddleware
            if hasattr(settings, 'RAVEN_CONFIG'):
                sentry = SentryMiddleware(hide_zerorpc_frames=False,
                                          dsn=settings.RAVEN_CONFIG['dsn'])
                zerorpc.Context.get_instance().register_middleware(sentry)
        except ImportError:
            pass
 
        return server

  

runrpc.py是一个Django management commands 文件需要放到某个install app目录的management/commands下面,启动服务器:

python manage.py runrpc 0.0.0.0:4242
  • autodiscover_rpc 自动发现rpc interface注册函数
  • get_server 生成zerorpc server对象

get_server中对zerorpc注册了2个中间件,SentryMiddleware用于捕获rpc interface抛出的异常发送到sentry,ServerExecMiddleware用于处理Django db connection,看看代码:

1
2
3
4
5
6
7
8
9
10
11
12
# zrpc.py
# coding: utf-8
 
from django.db import close_old_connections
 
class ServerExecMiddleware(object):
 
    def server_before_exec(self, request_event):
        close_old_connections()
 
    def server_after_exec(self, request_event, reply_event):
        close_old_connections()

  

在每个rpc interface被调用前与调用后都调用close_old_connections关闭db connection,这里是为了实现django.db中对请求处理前与处理后注册信号:

1
2
3
4
django.db.__init__.py
 
signals.request_started.connect(close_old_connections)
signals.request_finished.connect(close_old_connections)

  

目的是保证在rpc interface中使用ORM时,connection没有超时断开.

客户端

由于rpc的调用是阻塞的,不能全局只创建一个client.但是也不能每个请求都创建client,所以这里参考redis-py的client实现,定义一个支持连接池的zerorpc client.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# zrpc.py
# coding: utf-8
 
import os
import zerorpc
 
from redis.connection import BlockingConnectionPool
from gevent.queue import LifoQueue
 
class Connection(object):
    def __init__(self, connect_to, heartbeat=30):
        self.client = zerorpc.Client(heartbeat=heartbeat)
        self.client.connect(connect_to)
        self.pid = os.getpid()
 
    def disconnect(self):
        self.client.close()
 
 
class RPCClient(object):
    def __init__(self, connect_to, heartbeat=30):
        self.connection_pool = BlockingConnectionPool(connection_class=Connection,
            queue_class=LifoQueue, timeout=heartbeat, connect_to=connect_to, heartbeat=heartbeat)
 
    def close(self):
        self.connection_pool.disconnect()
 
    def __getattr__(self, name):
        return lambda *args, **kwargs: self(name, *args, **kwargs)
 
    def __call__(self, name, *args, **kwargs):
        connection = self.connection_pool.get_connection('')
        try:
            return getattr(connection.client, name)(*args, **kwargs)
        finally:
            self.connection_pool.release(connection)

  

这里直接复用了redis-py定义的连接池,当前系统使用gunicorn + gevent的方式启动Django服务,所以queue_class使用了gevent的LifoQueue.

在使用过程中还发现了这个问题:

https://github.com/0rpc/zerorpc-python/issues/123

需要打个补丁解决:

1
2
3
4
5
import zmq.green as zmq
 
# patch zmq garbage-collection Thread to use green Context:
from zmq.utils.garbage import gc
gc.context = zmq.Context()

  

总结

技术的选型需要契合项目实际情况,不要盲目上新技术引入不必要的成本.为了推广方案,必须全局的考虑方案是否易使用,是否易部署.

完整代码:

https://gist.github.com/zhu327/5b6c06eccc5758d4e642ee899a518687

posted @   派对实验室  阅读(2072)  评论(1编辑  收藏  举报
编辑推荐:
· AI与.NET技术实操系列:基于图像分类模型对图像进行分类
· go语言实现终端里的倒计时
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
阅读排行:
· 25岁的心里话
· 闲置电脑爆改个人服务器(超详细) #公网映射 #Vmware虚拟网络编辑器
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· 零经验选手,Compose 一天开发一款小游戏!
· AI Agent开发,如何调用三方的API Function,是通过提示词来发起调用的吗
点击右上角即可分享
微信分享提示