在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] s = 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
.
在使用过程中还发现了这个问题:
需要打个补丁解决:
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
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· AI与.NET技术实操系列:基于图像分类模型对图像进行分类
· go语言实现终端里的倒计时
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 25岁的心里话
· 闲置电脑爆改个人服务器(超详细) #公网映射 #Vmware虚拟网络编辑器
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· 零经验选手,Compose 一天开发一款小游戏!
· AI Agent开发,如何调用三方的API Function,是通过提示词来发起调用的吗