linux-web: B/S 架构——采集引擎后台线程 + aiohttp WebSocket 实时推送 + Web 仪表盘 + systemd 部署
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
B/S 服务端:时间线采集引擎(后台线程)+ WebSocket 实时推送 + Web 仪表盘
|
||||
Linux 服务器常驻运行: python -m src.web.server (或 systemd,见 deploy/a-stock-timeline.service)
|
||||
端口默认 8100,可用环境变量 PORT 覆盖。
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import WSMsgType, web
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
||||
|
||||
from src.realtime.collector import TimelineCollector # noqa: E402
|
||||
|
||||
PORT = int(os.environ.get('PORT', '8100'))
|
||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
WEB_DIR = Path(__file__).resolve().parent
|
||||
RECENT_MAX = 500
|
||||
|
||||
recent = deque(maxlen=RECENT_MAX) # 最近事件(内存)
|
||||
clients = set() # 活跃 WS 连接
|
||||
|
||||
|
||||
async def broadcast(event: dict):
|
||||
recent.append(event)
|
||||
payload = json.dumps(event, ensure_ascii=False)
|
||||
dead = set()
|
||||
for ws in clients:
|
||||
try:
|
||||
await ws.send_str(payload)
|
||||
except Exception:
|
||||
dead.add(ws)
|
||||
for ws in dead:
|
||||
clients.discard(ws)
|
||||
|
||||
|
||||
async def collector_task(_app):
|
||||
"""把阻塞式采集循环放进线程池,事件桥接到 asyncio 广播"""
|
||||
loop = asyncio.get_running_loop()
|
||||
collector = TimelineCollector(emit=lambda rec: loop.call_soon_threadsafe(
|
||||
asyncio.ensure_future, broadcast(rec)))
|
||||
|
||||
async def poll():
|
||||
while True:
|
||||
await loop.run_in_executor(None, collector.one_cycle)
|
||||
await asyncio.sleep(60)
|
||||
|
||||
task = asyncio.create_task(poll())
|
||||
# 启动即推最近历史(从 jsonl 恢复)
|
||||
feed = ROOT / 'data' / 'realtime_feed.jsonl'
|
||||
if feed.exists():
|
||||
try:
|
||||
lines = feed.read_text(encoding='utf-8').strip().splitlines()[-200:]
|
||||
for line in lines:
|
||||
try:
|
||||
recent.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
yield
|
||||
task.cancel()
|
||||
|
||||
|
||||
async def index(_request):
|
||||
return web.FileResponse(WEB_DIR / 'index.html')
|
||||
|
||||
|
||||
async def recent_events(_request):
|
||||
return web.json_response({'events': list(recent)})
|
||||
|
||||
|
||||
async def stats(_request):
|
||||
return web.json_response({'clients': len(clients), 'buffered': len(recent)})
|
||||
|
||||
|
||||
async def ws_handler(request):
|
||||
ws = web.WebSocketResponse(heartbeat=30)
|
||||
await ws.prepare(request)
|
||||
clients.add(ws)
|
||||
await ws.send_str(json.dumps({'kind': '连接成功',
|
||||
'data': {'msg': '实时推送已连接', 'buffered': len(recent)}},
|
||||
ensure_ascii=False))
|
||||
try:
|
||||
async for msg in ws:
|
||||
if msg.type == WSMsgType.ERROR:
|
||||
break
|
||||
finally:
|
||||
clients.discard(ws)
|
||||
return ws
|
||||
|
||||
|
||||
def build_app():
|
||||
app = web.Application()
|
||||
app.router.add_get('/', index)
|
||||
app.router.add_get('/api/recent', recent_events)
|
||||
app.router.add_get('/api/stats', stats)
|
||||
app.router.add_get('/ws', ws_handler)
|
||||
app.cleanup_ctx.append(collector_task)
|
||||
return app
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print('JQuant 时间线 B/S 服务启动: http://0.0.0.0:{} (WS: /ws)'.format(PORT))
|
||||
web.run_app(build_app(), host='0.0.0.0', port=PORT)
|
||||
Reference in New Issue
Block a user