├── .gitignore ├── LICENSE ├── README.md ├── gateway ├── __init__.py ├── handler.py ├── hub.py └── server.py ├── mpc.py ├── requirements.txt └── sdk.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | to-do.txt 91 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Timmy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### TORMQ 2 | 3 | *** 4 | 5 | ``` 6 | ************* ************* ************* 7 | * client * * client * * client * 8 | ************* ************* ************* 9 | | | | 10 | | | | 11 | ************* ************** ************** 12 | * websocket * * websocket * * websocket * 13 | * HUB * * HUB * * HUB * 14 | ************* ************** ************** 15 | \ sub | sub sub/ 16 | \ | / 17 | ------------------|-------------------- 18 | | pub 19 | **************** 20 | * * 21 | * MPC * 22 | * * 23 | **************** 24 | | sub 25 | ------------------|------------------- 26 | / | \ 27 | / | \ 28 | *********** ********** ************ 29 | * sdk * * sdk * * sdk * 30 | * service * * http * * other... * 31 | *********** ********** ************ 32 | ``` 33 | 34 | Tormq 是基于Tornado ZeroMQ的开发的消息推送框架,具有高性能,高并发,高可用性,可伸缩扩容等特点. 35 | 36 | Tormq 按模块可划分为3部分: 37 | 38 | - MPC 消息发布中心,集中处理消息,是SDK,gateway的中介 39 | - SDK 集成在业务系统中,用于发布消息,基于topic的订阅,可实现点对点或者广播消息推送 40 | - gateway Tornado WebSocket 服务器,并且实现了Hub用于中转消息 41 | 42 | ### Example 43 | 44 | #### MPC 45 | 46 | ```shell 47 | python mpc.py -s 5559 -p 5560 48 | ``` 49 | 50 | - s: MPC 面向SDK发布者的端口 51 | - p: MPC 面向gateway的端口 52 | 53 | #### gateway 54 | 55 | ```shell 56 | python gateway/server.py -r 127.0.0.1:5560 -p 8000 57 | ``` 58 | 59 | - r: 对接MPC的pub socket 60 | - p: Tornado的WebSocket服务端口 61 | 62 | #### SDK 63 | 64 | ```python 65 | from sdk import Publisher 66 | 67 | pub = Publisher('127.0.0.1', 5559) 68 | pub.send('ehr:api', 'hello world') # 话题, 消息 69 | ``` 70 | 71 | #### WebSocket 72 | 73 | ```javascript 74 | var s = new WebSocket('ws://localhost:8000/ws'); 75 | 76 | s.onopen = function(){ 77 | this.send('{"event":"subscribe","topic":"ehr:api"}'); 78 | } 79 | 80 | s.onmessage = function(v){ 81 | console.log(v.data); 82 | } 83 | ``` 84 | 85 | 在建立WS连接后,需要发送订阅主题的action消息,SDK推送对应主题的消息,WS就会收到. 86 | 87 | 这里的订阅过程实现只是示例,具体的 认证/订阅/退订 的消息约定需要根据实际使用场景来设计. 88 | 89 | 比如需要发送推送消息: 90 | 91 | ```python 92 | pub = Publisher('127.0.0.1', 5559) 93 | pub.send('ehr', 'hello world') # 话题, 消息 94 | ``` 95 | 96 | 所有订阅了以`ehr`为前缀topic的WS都会收到该消息. 97 | 98 | ### 特性 99 | 100 | - 高并发 101 | - Tornado异步处理并发请求 102 | - pyzmq对Tornado异步回调的支持 103 | - Hub实现了订阅过程对MPC的解耦,通过inproc方式的订阅,支持更多订阅 104 | - 高性能 105 | - ZeroMQ高性能网络消息框架 106 | - 高可用 107 | - 得益于ZeroMQ的特性,无论是MPC SDK gateway 哪一个崩溃,都不会影响到整个消息系统的运行,只需要重启奔溃的部分即可 108 | - 伸缩扩容 109 | - 在当前gateway实例已经不能满足并发量的前提下,可以横向增加gateway的实例数,并不会提高MPC的并发压力,因为每个gateway实例与MPC只有1个pub/sub连接 -------------------------------------------------------------------------------- /gateway/__init__.py: -------------------------------------------------------------------------------- 1 | u""" 2 | gateway 消息发布网关 3 | 收到MPC发布的消息后,发布消息到websocket 4 | """ -------------------------------------------------------------------------------- /gateway/handler.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | u""" 3 | tornado WebSocket handler 4 | 挂接WebSocket连接到ZeroMQ连接上 5 | """ 6 | 7 | import json 8 | from tornado import websocket 9 | from tornado.log import app_log 10 | from hub import Subscriber 11 | 12 | 13 | class PushWebSocket(websocket.WebSocketHandler): 14 | u""" 15 | 消息推送WebSocket连接 16 | 17 | 认证: 18 | 可以考虑重写prepare方法获取cookie/querystring来做认证 19 | """ 20 | def check_origin(self, origin): 21 | return True 22 | 23 | def open(self): 24 | u""" 25 | 如果在prepare做了认证 26 | 可以在连接open后直接self.sub.subscribe()订阅默认用户id的topic 27 | """ 28 | self.sub = Subscriber(self.push) 29 | 30 | def on_message(self, message): 31 | app_log.debug('client message: {}'.format(message)) 32 | # to-do: 定义 订阅/退订 消息格式 33 | try: 34 | content = json.loads(message) 35 | event = content['event'] 36 | if event == 'subscribe': 37 | u''' 38 | 更新订阅topic 39 | { 40 | "event": "subscribe", 41 | "topic": "ehr:api" 42 | } 43 | ''' 44 | topic = content['topic'] 45 | self.sub.subscribe(topic) 46 | elif event == 'unsubscribe': 47 | u''' 48 | 退订 49 | {"event": "unsubscribe",} 50 | ''' 51 | self.sub.unsubscribe() 52 | else: 53 | pass 54 | except: 55 | pass 56 | 57 | def on_close(self): 58 | if hasattr(self, 'sub'): 59 | self.sub.close() 60 | app_log.debug('connection close') 61 | 62 | def push(self, msg): 63 | if self.ws_connection: 64 | self.write_message(msg) 65 | -------------------------------------------------------------------------------- /gateway/hub.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import os 4 | import json 5 | import re 6 | import zmq 7 | from zmq.eventloop.zmqstream import ZMQStream 8 | from tornado.log import app_log 9 | 10 | pid = os.getpid() 11 | context = zmq.Context() 12 | 13 | 14 | class Hub(object): 15 | u""" 16 | 本地消息中心 17 | 获取MPC发布的消息,并发送到WebSocket绑定的socket上 18 | 19 | 此类为一个本地的pub/sub-HUB,任何新websocket连接进来都会通过一个zmqsocket订阅到HUB 20 | 的pub端,与此同时这个HUB自己也会订阅一个真正的消息中心MPC 21 | 22 | websocket连接中通过ipc连接协议订阅HUB 23 | 24 | HUB通过tcp连接协议订阅MPC 25 | """ 26 | def __init__(self, host, port): 27 | self.sub = context.socket(zmq.SUB) 28 | self.sub.setsockopt(zmq.SUBSCRIBE, '') 29 | self.sub.connect('tcp://{}:{}'.format(host, port)) 30 | 31 | self.local_pub = context.socket(zmq.PUB) # 本地发布者绑定inproc进程间通信 32 | self.local_pub.bind('inproc:///tmp/hub_{}'.format(pid)) 33 | 34 | self.substream = ZMQStream(self.sub) 35 | self.substream.on_recv(self.recv) 36 | 37 | def recv(self, msg): 38 | app_log.info('Message: {}'.format(msg)) 39 | self.local_pub.send_multipart(msg) 40 | 41 | 42 | class Subscriber(object): 43 | u""" 44 | 本地订阅者 45 | 订阅topic匹配关系 46 | 47 | 订阅 ehr:api:1 48 | 匹配消息 'ehr', 'ehr:api', 'ehr:api:1' 49 | 50 | 实现直连推送,或者广播 51 | """ 52 | def __init__(self, callback): 53 | self.callback = callback 54 | self.topic = '' 55 | 56 | self.sock = context.socket(zmq.SUB) 57 | self.sock.connect('inproc:///tmp/hub_{}'.format(pid)) 58 | 59 | self.stream = ZMQStream(self.sock) 60 | self.stream.on_recv(self.recv) 61 | 62 | def subscribe(self, topic): 63 | if not isinstance(topic, basestring) or not topic: 64 | return 65 | 66 | self.topic = topic 67 | self.sock.setsockopt(zmq.SUBSCRIBE, str(topic.split(':')[0])) 68 | 69 | def unsubscribe(self): 70 | if self.topic: 71 | self.sock.setsockopt(zmq.UNSUBSCRIBE, str(self.topic.split(':')[0])) 72 | self.topic = '' 73 | 74 | def recv(self, msg): 75 | _, body = msg 76 | try: 77 | data = json.loads(body) 78 | topic = data.get('topic', '') 79 | if not topic: 80 | return 81 | if re.match(r'^{}(:.+)?$'.format(topic), self.topic): 82 | self.callback(body) 83 | except: 84 | pass 85 | 86 | def close(self): 87 | self.topic = None 88 | self.callback = None 89 | self.stream.close() -------------------------------------------------------------------------------- /gateway/server.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | u""" 3 | 启动tornado服务 4 | """ 5 | 6 | import logging 7 | logging.basicConfig(level=logging.DEBUG) 8 | 9 | import sys 10 | import getopt 11 | import tornado 12 | from zmq.eventloop.ioloop import ZMQIOLoop 13 | from handler import PushWebSocket 14 | from hub import Hub 15 | 16 | loop = ZMQIOLoop() 17 | loop.install() 18 | 19 | 20 | settings = { 21 | 'xsrf_cookies': False, 22 | 'debug': True, 23 | 'autoreload': True, 24 | 'websocket_ping_interval': 60 # 定时发送ping, 保持心跳 25 | } 26 | 27 | 28 | app = tornado.web.Application([ 29 | (r'/ws', PushWebSocket), 30 | ], **settings) 31 | 32 | 33 | if __name__ == '__main__': 34 | port = '8000' 35 | remote = '127.0.0.1:5560' # MPC 消息发布中心 发布 地址 36 | 37 | opts, argvs = getopt.getopt(sys.argv[1:], 'r:p:') 38 | for op, value in opts: 39 | if op == '-r': 40 | remote = value 41 | if op == '-p': 42 | port = int(value) 43 | 44 | Hub(*remote.split(':')) 45 | 46 | app.listen(port) 47 | 48 | loop.start() 49 | -------------------------------------------------------------------------------- /mpc.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | # 4 | # ************* ************* ************* 5 | # * client * * client * * client * 6 | # ************* ************* ************* 7 | # | | | 8 | # | | | 9 | # ************* ************** ************** 10 | # * websocket * * websocket * * websocket * 11 | # * HUB * * HUB * * HUB * 12 | # ************* ************** ************** 13 | # \ sub | sub sub/ 14 | # \ | / 15 | # ------------------|-------------------- 16 | # | pub 17 | # **************** 18 | # * * 19 | # * MPC * 20 | # * * 21 | # **************** 22 | # | sub 23 | # ------------------|------------------- 24 | # / | \ 25 | # / | \ 26 | # *********** ********** ************ 27 | # * sdk * * sdk * * sdk * 28 | # * service * * http * * other... * 29 | # *********** ********** ************ 30 | # 31 | u""" 32 | MPC 消息发布中心 33 | 接受sdk发布的消息,并转发到gateway的消息hub上 34 | 35 | python mpc.py -s 5559 -p 5560 36 | """ 37 | 38 | import getopt 39 | import sys 40 | import logging 41 | import zmq 42 | 43 | def serv_forever(sub_p, pub_p): 44 | 45 | try: 46 | context = zmq.Context() 47 | # Socket facing clients 48 | frontend = context.socket(zmq.SUB) 49 | frontend.bind('tcp://*:{}'.format(sub_p)) 50 | 51 | frontend.setsockopt(zmq.SUBSCRIBE, '') 52 | 53 | # Socket facing services 54 | backend = context.socket(zmq.PUB) 55 | backend.bind('tcp://*:{}'.format(pub_p)) 56 | 57 | zmq.device(zmq.FORWARDER, frontend, backend) 58 | except Exception, e: 59 | print 'bringing down zmq device' 60 | raise e 61 | finally: 62 | frontend.close() 63 | backend.close() 64 | context.term() 65 | 66 | 67 | if __name__ == "__main__": 68 | sub_p = 5559 69 | pub_p = 5560 70 | opts, argvs = getopt.getopt(sys.argv[1:], 's:p:') 71 | for op, value in opts: 72 | if op == '-s': 73 | sub_p = int(value) 74 | if op == '-p': 75 | pub_p = int(value) 76 | logging.info('starting...') 77 | serv_forever(sub_p, pub_p) -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyzmq==16.0.2 2 | tornado==4.5.1 -------------------------------------------------------------------------------- /sdk.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | u""" 3 | 消息发布 sdk 4 | 向 MPC 消息发布中心发布消息 5 | 6 | from sdk import Publisher 7 | 8 | pub = Publisher('127.0.0.1', 5559) 9 | pub.send() 10 | """ 11 | import time 12 | import json 13 | import zmq 14 | 15 | __all__ = ('Publisher',) 16 | 17 | context = zmq.Context() 18 | 19 | 20 | class Publisher(object): 21 | u""" 22 | sdk 消息发布者 23 | host: MPC host 24 | port: MPC sub port, default 5559 25 | """ 26 | def __init__(self, host, port): 27 | self.sock = context.socket(zmq.PUB) 28 | self.sock.connect('tcp://{}:{}'.format(host, port)) 29 | time.sleep(0.2) 30 | 31 | def send(self, topic, body): 32 | u""" 33 | 发送消息,具体实现需要设计消息格式 34 | """ 35 | top = topic.split(':')[0] 36 | msg = { 37 | 'topic': topic, 38 | 'data': body 39 | } 40 | self.sock.send_multipart([top, json.dumps(msg, ensure_ascii=False)]) 41 | 42 | def __del__(self): 43 | if hasattr(self, 'sock') and isinstance(self.sock, context._socket_class): 44 | self.sock.close() --------------------------------------------------------------------------------