update
This commit is contained in:
1 parent
f935448389
commit
3f97dd92ef
26 files changed
+1730
-316
No files matched your search
@@ -0,0 +1,61 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.so
|
||||
.env
|
||||
.env/
|
||||
.venv/
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
|
||||
# Build artifacts
|
||||
*.bin
|
||||
*.o
|
||||
*.a
|
||||
*.out
|
||||
|
||||
# Documentation (exclude READMEs)
|
||||
*.md
|
||||
!README.md
|
||||
docs/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Data files (handled via volumes)
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
data/
|
||||
chroma/
|
||||
|
||||
# Other modules not needed for build
|
||||
bot4cj/
|
||||
core/
|
||||
gateway/
|
||||
lib*/
|
||||
waf/
|
||||
captcha/
|
||||
smtp/
|
||||
static/
|
||||
tests/
|
||||
3d/
|
||||
BotOps/
|
||||
pymsg/tests/
|
||||
@@ -0,0 +1,11 @@
|
||||
# AI Review 服务配置
|
||||
# 复制此文件为 ai_review/.env 并填入你的 API Key
|
||||
|
||||
# DeepSeek/OpenAI API 密钥
|
||||
OPENAI_API_KEY=your_api_key_here
|
||||
|
||||
# API Base URL(默认使用 DeepSeek)
|
||||
OPENAI_BASE_URL=https://api.deepseek.com/v1
|
||||
|
||||
# 使用的模型名称
|
||||
MODEL_NAME=deepseek-chat
|
||||
@@ -0,0 +1,18 @@
|
||||
.env
|
||||
.env/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.so
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
!README.md
|
||||
.envrc
|
||||
.venv/
|
||||
venv/
|
||||
ENV/
|
||||
chroma/
|
||||
.DS_Store
|
||||
@@ -0,0 +1,43 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
LABEL maintainer="qingsong"
|
||||
LABEL service="ai_review"
|
||||
LABEL description="AI内容审核服务 - DeepSeek + RAG"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 安装依赖(ai_review不依赖pymsg)
|
||||
COPY requirements.txt /app/
|
||||
RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
|
||||
# 复制应用代码
|
||||
COPY . /app/
|
||||
|
||||
# 创建ChromaDB持久化目录
|
||||
RUN mkdir -p /app/chroma
|
||||
VOLUME ["/app/chroma"]
|
||||
|
||||
# 创建非root用户
|
||||
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8007
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8007/health', timeout=5)" || exit 1
|
||||
|
||||
# 默认环境变量
|
||||
ENV AI_REVIEW_HOST=0.0.0.0 \
|
||||
AI_REVIEW_PORT=8007 \
|
||||
CHROMA_PERSIST_DIR=/app/chroma
|
||||
|
||||
CMD ["python", "ai_review.py"]
|
||||
@@ -0,0 +1,2 @@
|
||||
# 数据库备份输出目录
|
||||
# 容器内备份会输出到此目录,可直接复制走
|
||||
@@ -21,7 +21,6 @@ captcha — 基于 pymsg 协议(MsgServer)的图形验证码服务。
|
||||
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import secrets
|
||||
@@ -34,7 +33,7 @@ from PIL import Image, ImageDraw, ImageFont
|
||||
# pymsg 导入
|
||||
import sys as _sys
|
||||
_sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from pymsg import MsgServer # noqa: E402
|
||||
from pymsg import RpcServer # noqa: E402
|
||||
|
||||
# ── 配置 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -100,54 +99,21 @@ class CaptchaServer:
|
||||
def __init__(self, addr="127.0.0.1", port=8009, workers=4):
|
||||
self.addr = addr
|
||||
self.port = port
|
||||
self._server = MsgServer(addr, port, workers=workers)
|
||||
|
||||
# 路由表
|
||||
self._routes = {
|
||||
"image": self._handle_image,
|
||||
"verify": self._handle_verify,
|
||||
"health": self._handle_health,
|
||||
}
|
||||
self._server = RpcServer(addr, port, workers=workers)
|
||||
self._server.add_route("image", self._handle_image)
|
||||
self._server.add_route("verify", self._handle_verify)
|
||||
self._server.add_route("health", self._handle_health)
|
||||
|
||||
# ── 启动 / 停止 ──────────────────────────────────────────────────
|
||||
def start(self) -> None:
|
||||
"""启动服务(阻塞当前线程)。"""
|
||||
print(f"[captcha] 监听 tcp://{self.addr}:{self.port}")
|
||||
self._server.start(self._dispatch)
|
||||
self._server.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""从另一线程停止服务。"""
|
||||
self._server.stop()
|
||||
|
||||
# ── 消息调度 -------------------------------------------------------
|
||||
def _dispatch(self, sock, addr, text):
|
||||
"""解析 JSON 中的 _route,分发给对应的 handler,并回复结果。"""
|
||||
try:
|
||||
req = json.loads(text)
|
||||
except json.JSONDecodeError as e:
|
||||
self._reply(sock, False, error=f"JSON 解析失败: {e}")
|
||||
return
|
||||
|
||||
route = req.pop("_route", "")
|
||||
handler = self._routes.get(route)
|
||||
if handler is None:
|
||||
self._reply(sock, False, error=f"未知路由: {route}")
|
||||
return
|
||||
|
||||
try:
|
||||
result = handler(req)
|
||||
self._reply(sock, True, data=result)
|
||||
except Exception as e:
|
||||
self._reply(sock, False, error=str(e))
|
||||
|
||||
def _reply(self, sock, success, data=None, error=None):
|
||||
resp = {"success": success}
|
||||
if data is not None:
|
||||
resp["data"] = data
|
||||
if error is not None:
|
||||
resp["error"] = error
|
||||
self._server.reply(sock, json.dumps(resp, ensure_ascii=False))
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 路由处理
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
.env
|
||||
.env/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.so
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
!README.md
|
||||
.envrc
|
||||
.venv/
|
||||
venv/
|
||||
ENV/
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
.DS_Store
|
||||
@@ -0,0 +1,53 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
LABEL maintainer="qingsong"
|
||||
LABEL service="db"
|
||||
LABEL description="dbv2 - pymsg协议数据库服务"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 安装pymsg共享库
|
||||
COPY pymsg/ /app/pymsg/
|
||||
RUN pip install /app/pymsg/
|
||||
|
||||
# 安装依赖
|
||||
COPY db/requirements.txt /app/
|
||||
RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
|
||||
# 复制应用代码
|
||||
COPY db/ /app/db/
|
||||
|
||||
# 入口脚本
|
||||
RUN chmod +x /app/db/docker-entrypoint.sh
|
||||
|
||||
# 创建数据持久化目录并设置权限
|
||||
RUN mkdir -p /app/data && chown -R 1000:1000 /app/data
|
||||
VOLUME ["/app/data"]
|
||||
|
||||
# 创建非root用户
|
||||
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
WORKDIR /app/db
|
||||
|
||||
EXPOSE 8005
|
||||
|
||||
# 健康检查:尝试连接TCP端口
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD python -c "import socket; s=socket.socket(); s.settimeout(2); s.connect(('localhost', 8005)); s.close()" || exit 1
|
||||
|
||||
# 默认使用SQLite,数据持久化到/app/data/database.db
|
||||
ENV DATABASE_URL=sqlite:////app/data/database.db \
|
||||
LISTEN_ADDR=0.0.0.0 \
|
||||
LISTEN_PORT=8005 \
|
||||
WORKERS=4
|
||||
|
||||
ENTRYPOINT ["/app/db/docker-entrypoint.sh"]
|
||||
+304
@@ -0,0 +1,304 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
db_tools — SQLite 数据库备份、导出、导入工具
|
||||
|
||||
用法:
|
||||
python db_tools.py backup <output_path> # 安全备份数据库(使用 sqlite3 backup API)
|
||||
python db_tools.py export <output.json> # 导出所有表数据为 JSON
|
||||
python db_tools.py import <input.json> # 从 JSON 导入数据
|
||||
python db_tools.py vacuum # 执行 VACUUM 压缩数据库
|
||||
python db_tools.py wal-checkpoint # 强制 WAL 检查点(确保数据写入主库)
|
||||
python db_tools.py info # 显示数据库信息和表统计
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
|
||||
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///database.db")
|
||||
|
||||
# 从 sqlite:///path 提取文件路径
|
||||
def _get_db_path() -> str:
|
||||
if DATABASE_URL.startswith("sqlite:////"):
|
||||
return DATABASE_URL[10:] # 绝对路径 sqlite:////app/data/xxx.db
|
||||
elif DATABASE_URL.startswith("sqlite:///"):
|
||||
return DATABASE_URL[9:] # 相对路径
|
||||
else:
|
||||
return "database.db"
|
||||
|
||||
|
||||
def _connect() -> sqlite3.Connection:
|
||||
"""创建数据库连接(配置 PRAGMA)。"""
|
||||
conn = sqlite3.connect(_get_db_path())
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA synchronous=NORMAL")
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def backup(output_path: str):
|
||||
"""
|
||||
使用 SQLite 官方 backup API 进行在线热备份(不需要停机)。
|
||||
这是最安全的备份方式,支持读写并发。
|
||||
"""
|
||||
src = _get_db_path()
|
||||
print(f"[backup] 正在备份数据库: {src} -> {output_path}")
|
||||
|
||||
if not os.path.exists(src):
|
||||
print(f"[backup] 错误: 数据库文件不存在: {src}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
start = datetime.now(timezone.utc)
|
||||
|
||||
# 使用 sqlite3 backup API(支持在线备份)
|
||||
src_conn = sqlite3.connect(src)
|
||||
dst_conn = sqlite3.connect(output_path)
|
||||
try:
|
||||
src_conn.backup(dst_conn)
|
||||
dst_conn.commit()
|
||||
finally:
|
||||
dst_conn.close()
|
||||
src_conn.close()
|
||||
|
||||
size_mb = os.path.getsize(output_path) / (1024 * 1024)
|
||||
elapsed = (datetime.now(timezone.utc) - start).total_seconds()
|
||||
print(f"[backup] 备份完成: {output_path} ({size_mb:.2f} MB, 耗时 {elapsed:.2f}s)")
|
||||
|
||||
|
||||
def export_json(output_path: str):
|
||||
"""导出所有表数据为 JSON 格式(便于跨平台传输/迁移)。"""
|
||||
print(f"[export] 正在导出数据到: {output_path}")
|
||||
conn = _connect()
|
||||
|
||||
result = {
|
||||
"_meta": {
|
||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||
"db_path": _get_db_path(),
|
||||
"sqlite_version": sqlite3.sqlite_version,
|
||||
},
|
||||
"tables": {}
|
||||
}
|
||||
|
||||
try:
|
||||
# 获取所有用户表
|
||||
tables = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
||||
).fetchall()
|
||||
|
||||
for (table_name,) in tables:
|
||||
# 获取列信息
|
||||
columns = [desc[1] for desc in conn.execute(f"PRAGMA table_info({table_name})").fetchall()]
|
||||
|
||||
# 获取行数
|
||||
count = conn.execute(f"SELECT COUNT(*) FROM [{table_name}]").fetchone()[0]
|
||||
|
||||
# 获取所有数据
|
||||
rows = conn.execute(f"SELECT * FROM [{table_name}]").fetchall()
|
||||
data = [dict(row) for row in rows]
|
||||
|
||||
result["tables"][table_name] = {
|
||||
"columns": columns,
|
||||
"row_count": count,
|
||||
"data": data,
|
||||
}
|
||||
print(f"[export] 表 {table_name}: {count} 行")
|
||||
|
||||
# 同时导出 schema
|
||||
schema = []
|
||||
for (table_name,) in tables:
|
||||
ddl = conn.execute(
|
||||
"SELECT sql FROM sqlite_master WHERE type='table' AND name=?",
|
||||
(table_name,)
|
||||
).fetchone()[0]
|
||||
if ddl:
|
||||
schema.append(ddl + ";")
|
||||
|
||||
result["_meta"]["schema"] = schema
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
json.dump(result, f, ensure_ascii=False, indent=2, default=str)
|
||||
|
||||
size_mb = os.path.getsize(output_path) / (1024 * 1024)
|
||||
print(f"[export] 导出完成: {output_path} ({size_mb:.2f} MB)")
|
||||
|
||||
|
||||
def import_json(input_path: str):
|
||||
"""从 JSON 文件导入数据(注意:会清空现有数据!)。"""
|
||||
print(f"[import] 正在从 {input_path} 导入数据")
|
||||
|
||||
if not os.path.exists(input_path):
|
||||
print(f"[import] 错误: 文件不存在: {input_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
with open(input_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
conn = _connect()
|
||||
|
||||
try:
|
||||
# 是否清空现有表(需要确认)
|
||||
tables = data.get("tables", {})
|
||||
if tables:
|
||||
print(f"[import] 警告: 即将导入 {len(tables)} 个表,现有数据将被覆盖!")
|
||||
# 为安全起见,不自动清空,由用户先备份
|
||||
|
||||
# 按外键依赖顺序导入(先主表后子表,这里简单按名字排序)
|
||||
for table_name in sorted(tables.keys()):
|
||||
table_data = tables[table_name]
|
||||
rows = table_data.get("data", [])
|
||||
columns = table_data.get("columns", [])
|
||||
|
||||
if not rows or not columns:
|
||||
print(f"[import] 跳表 {table_name}: 无数据或列定义")
|
||||
continue
|
||||
|
||||
# 先清空表
|
||||
conn.execute(f"DELETE FROM [{table_name}]")
|
||||
|
||||
# 批量插入
|
||||
placeholders = ", ".join(["?"] * len(columns))
|
||||
col_names = ", ".join(f"[{c}]" for c in columns)
|
||||
insert_sql = f"INSERT INTO [{table_name}] ({col_names}) VALUES ({placeholders})"
|
||||
|
||||
conn.executemany(insert_sql, [tuple(row.get(col) for col in columns) for row in rows])
|
||||
print(f"[import] 表 {table_name}: 导入 {len(rows)} 行")
|
||||
|
||||
conn.commit()
|
||||
print("[import] 导入完成")
|
||||
else:
|
||||
print("[import] 没有可导入的表数据")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def vacuum():
|
||||
"""执行 VACUUM 压缩数据库,回收空间、重建索引。"""
|
||||
print("[vacuum] 正在执行 VACUUM...")
|
||||
db_path = _get_db_path()
|
||||
before_size = os.path.getsize(db_path) / (1024 * 1024)
|
||||
|
||||
conn = _connect()
|
||||
try:
|
||||
# WAL 检查点先将所有数据写入主库
|
||||
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
conn.execute("VACUUM")
|
||||
conn.execute("ANALYZE")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
after_size = os.path.getsize(db_path) / (1024 * 1024)
|
||||
saved = before_size - after_size
|
||||
print(f"[vacuum] 完成: {before_size:.2f} MB -> {after_size:.2f} MB (节省 {saved:.2f} MB)")
|
||||
|
||||
|
||||
def wal_checkpoint():
|
||||
"""强制 WAL 检查点,确保所有写入都持久化到主数据库文件。"""
|
||||
print("[wal-checkpoint] 执行 WAL 检查点...")
|
||||
conn = _connect()
|
||||
try:
|
||||
result = conn.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone()
|
||||
# busy, log, checkpointed
|
||||
print(f"[wal-checkpoint] 结果: busy={result[0]}, log_pages={result[1]}, checkpointed={result[2]}")
|
||||
finally:
|
||||
conn.close()
|
||||
print("[wal-checkpoint] 完成")
|
||||
|
||||
|
||||
def show_info():
|
||||
"""显示数据库信息和表统计。"""
|
||||
db_path = _get_db_path()
|
||||
print("=" * 50)
|
||||
print("SQLite 数据库信息")
|
||||
print("=" * 50)
|
||||
print(f"文件路径: {os.path.abspath(db_path)}")
|
||||
|
||||
if os.path.exists(db_path):
|
||||
size_mb = os.path.getsize(db_path) / (1024 * 1024)
|
||||
mtime = datetime.fromtimestamp(os.path.getmtime(db_path), tz=timezone.utc)
|
||||
print(f"文件大小: {size_mb:.2f} MB")
|
||||
print(f"最后修改: {mtime.isoformat()}")
|
||||
else:
|
||||
print("文件大小: (不存在)")
|
||||
|
||||
# WAL 文件
|
||||
wal_path = db_path + "-wal"
|
||||
shm_path = db_path + "-shm"
|
||||
if os.path.exists(wal_path):
|
||||
wal_size = os.path.getsize(wal_path) / 1024
|
||||
print(f"WAL 文件: {wal_size:.2f} KB")
|
||||
if os.path.exists(shm_path):
|
||||
shm_size = os.path.getsize(shm_path) / 1024
|
||||
print(f"SHM 文件: {shm_size:.2f} KB")
|
||||
|
||||
conn = _connect()
|
||||
try:
|
||||
print(f"SQLite 版本: {sqlite3.sqlite_version}")
|
||||
|
||||
# PRAGMA 信息
|
||||
journal_mode = conn.execute("PRAGMA journal_mode").fetchone()[0]
|
||||
synchronous = conn.execute("PRAGMA synchronous").fetchone()[0]
|
||||
page_size = conn.execute("PRAGMA page_size").fetchone()[0]
|
||||
page_count = conn.execute("PRAGMA page_count").fetchone()[0]
|
||||
free_pages = conn.execute("PRAGMA freelist_count").fetchone()[0]
|
||||
|
||||
print(f"Journal 模式: {journal_mode}")
|
||||
print(f"同步级别: {synchronous}")
|
||||
print(f"页大小: {page_size} bytes")
|
||||
|
||||
# 表统计
|
||||
tables = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
|
||||
).fetchall()
|
||||
|
||||
print(f"\n表统计 ({len(tables)} 个表):")
|
||||
print("-" * 40)
|
||||
total_rows = 0
|
||||
for (table_name,) in tables:
|
||||
count = conn.execute(f"SELECT COUNT(*) FROM [{table_name}]").fetchone()[0]
|
||||
total_rows += count
|
||||
print(f" {table_name:20s} {count:>8d} 行")
|
||||
print("-" * 40)
|
||||
print(f" {'总计':20s} {total_rows:>8d} 行")
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
|
||||
cmd = sys.argv[1]
|
||||
|
||||
if cmd == "backup":
|
||||
output = sys.argv[2] if len(sys.argv) > 2 else f"backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.db"
|
||||
backup(output)
|
||||
elif cmd == "export":
|
||||
output = sys.argv[2] if len(sys.argv) > 2 else f"export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
|
||||
export_json(output)
|
||||
elif cmd == "import":
|
||||
if len(sys.argv) < 3:
|
||||
print("用法: python db_tools.py import <input.json>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
import_json(sys.argv[2])
|
||||
elif cmd == "vacuum":
|
||||
vacuum()
|
||||
elif cmd == "wal-checkpoint":
|
||||
wal_checkpoint()
|
||||
elif cmd == "info":
|
||||
show_info()
|
||||
else:
|
||||
print(f"未知命令: {cmd}", file=sys.stderr)
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+43
-44
@@ -37,6 +37,7 @@ from datetime import datetime, date, time as dt_time, timezone, timedelta
|
||||
from sqlalchemy import (
|
||||
create_engine, MetaData, Table,
|
||||
select, insert, update, delete, func, and_,
|
||||
event,
|
||||
)
|
||||
from sqlalchemy import types as sa_types
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
@@ -44,12 +45,32 @@ from sqlalchemy.exc import SQLAlchemyError
|
||||
# pymsg 导入
|
||||
import sys as _sys
|
||||
_sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from pymsg import MsgServer # noqa: E402
|
||||
from pymsg import RpcServer # noqa: E402
|
||||
|
||||
# ── 数据库配置 ──────────────────────────────────────────────────────────
|
||||
|
||||
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///database.db")
|
||||
|
||||
# ── SQLite 连接配置(针对 Docker/多线程环境优化)──────────────────────
|
||||
|
||||
def _configure_sqlite(dbapi_connection, connection_record):
|
||||
"""为每个 SQLite 连接设置 PRAGMA,优化并发与持久化性能。
|
||||
|
||||
- WAL 模式:读写不互斥,并发性能大幅提升,Docker 卷挂载下更稳定
|
||||
- NORMAL 同步级别:在断电时可能丢失最近提交的事务(但不会损坏数据库)
|
||||
配合 WAL checkpoint 足够安全,性能显著提升
|
||||
- foreign_keys=ON:启用外键约束
|
||||
- busy_timeout:锁等待超时 5 秒,避免 SQLITE_BUSY 错误
|
||||
- mmap_size:使用内存映射 I/O 提升读取性能
|
||||
"""
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA synchronous=NORMAL")
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.execute("PRAGMA busy_timeout=5000")
|
||||
cursor.execute("PRAGMA mmap_size=268435456") # 256MB
|
||||
cursor.close()
|
||||
|
||||
# ── 日志模块常量 ────────────────────────────────────────────────────────
|
||||
|
||||
MAX_MESSAGE_LEN = 8192 # message 上限 8KB
|
||||
@@ -80,9 +101,20 @@ class DBServer:
|
||||
def __init__(self, addr="127.0.0.1", port=8005, workers=4):
|
||||
self.addr = addr
|
||||
self.port = port
|
||||
self.engine = create_engine(DATABASE_URL)
|
||||
self.engine = create_engine(
|
||||
DATABASE_URL,
|
||||
connect_args={"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {},
|
||||
)
|
||||
# 为 SQLite 连接应用 PRAGMA 优化
|
||||
if DATABASE_URL.startswith("sqlite"):
|
||||
event.listen(self.engine, "connect", _configure_sqlite)
|
||||
self.metadata = MetaData()
|
||||
self._server = MsgServer(addr, port, workers=workers)
|
||||
self._server = RpcServer(
|
||||
addr, port, workers=workers,
|
||||
json_default=_json_default,
|
||||
on_error=lambda route, e: traceback.print_exc(),
|
||||
)
|
||||
self._server.add_route("execute", self._handle_execute)
|
||||
|
||||
# 日志表缓存(避免重复反射)
|
||||
self._logs_table_cache = {}
|
||||
@@ -98,54 +130,18 @@ class DBServer:
|
||||
# 抛 InvalidRequestError),反射操作需串行化
|
||||
self._reflect_lock = threading.Lock()
|
||||
|
||||
# 路由表
|
||||
self._routes = {
|
||||
"execute": self._handle_execute,
|
||||
}
|
||||
|
||||
# ── 启动 / 停止 ──────────────────────────────────────────────────
|
||||
def start(self) -> None:
|
||||
"""启动服务(阻塞当前线程)。"""
|
||||
print(f"[dbv2] 监听 tcp://{self.addr}:{self.port}")
|
||||
self._ensure_logs_table() # 确保 Logs 表已建好(外部 MsgClient 写日志前)
|
||||
self._ensure_logs_table() # 确保 Logs 表已建好(外部写日志前)
|
||||
self._start_cleanup_thread()
|
||||
self._server.start(self._dispatch)
|
||||
self._server.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""从另一线程停止服务。"""
|
||||
self._server.stop()
|
||||
|
||||
# ── 消息调度 -------------------------------------------------------
|
||||
def _dispatch(self, sock, addr, text):
|
||||
"""解析 JSON 中的 _route,分发给对应的 handler,并回复结果。"""
|
||||
try:
|
||||
req = json.loads(text)
|
||||
except json.JSONDecodeError as e:
|
||||
self._reply(sock, False, error=f"JSON 解析失败: {e}")
|
||||
return
|
||||
|
||||
route = req.pop("_route", "")
|
||||
handler = self._routes.get(route)
|
||||
if handler is None:
|
||||
self._reply(sock, False, error=f"未知路由: {route}")
|
||||
return
|
||||
|
||||
try:
|
||||
result = handler(req, sock=sock)
|
||||
self._reply(sock, True, data=result)
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
self._reply(sock, False, error=str(e))
|
||||
|
||||
def _reply(self, sock, success, data=None, error=None):
|
||||
resp = {"success": success}
|
||||
if data is not None:
|
||||
resp["data"] = data
|
||||
if error is not None:
|
||||
resp["error"] = error
|
||||
self._server.reply(sock, json.dumps(resp, ensure_ascii=False,
|
||||
default=_json_default))
|
||||
|
||||
# ── 表反射 ---------------------------------------------------------
|
||||
def _get_table(self, table_name):
|
||||
# double-check:_tables_cache 只在反射完整完成后写入(dict 读写 GIL
|
||||
@@ -245,7 +241,7 @@ class DBServer:
|
||||
# 路由处理 — execute
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
def _handle_execute(self, data, **kw):
|
||||
def _handle_execute(self, data):
|
||||
action = data.get("action", "select")
|
||||
table_name = data.get("table")
|
||||
if not table_name:
|
||||
@@ -460,9 +456,12 @@ class DBServer:
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
default_addr = os.environ.get("LISTEN_ADDR", "0.0.0.0")
|
||||
default_port = int(os.environ.get("LISTEN_PORT", "8005"))
|
||||
|
||||
parser = argparse.ArgumentParser(description="dbv2 — pymsg 数据库服务")
|
||||
parser.add_argument("--addr", default="127.0.0.1", help="监听地址")
|
||||
parser.add_argument("--port", type=int, default=8005, help="监听端口")
|
||||
parser.add_argument("--addr", default=default_addr, help="监听地址")
|
||||
parser.add_argument("--port", type=int, default=default_port, help="监听端口")
|
||||
parser.add_argument("--workers", type=int, default=4,
|
||||
help="回调并发线程数(0=同步串行模式)")
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
DATA_DIR="/app/data"
|
||||
DB_PATH="${DATABASE_URL#sqlite:///}"
|
||||
if [ "$DB_PATH" = "$DATABASE_URL" ]; then
|
||||
# URL 不是 sqlite:/// 格式,使用默认路径
|
||||
DB_PATH="$DATA_DIR/database.db"
|
||||
fi
|
||||
|
||||
echo "数据目录: $DATA_DIR"
|
||||
echo "数据库路径: $DB_PATH"
|
||||
|
||||
# 确保数据目录存在并可写
|
||||
mkdir -p "$(dirname "$DB_PATH")"
|
||||
|
||||
# 初始化数据库表(如果不存在)
|
||||
if [[ "$DATABASE_URL" == sqlite:* ]]; then
|
||||
echo "初始化数据库 schema..."
|
||||
python -c "
|
||||
import os
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
db_url = os.environ.get('DATABASE_URL', 'sqlite:////app/data/database.db')
|
||||
engine = create_engine(
|
||||
db_url,
|
||||
connect_args={'check_same_thread': False} if db_url.startswith('sqlite') else {},
|
||||
)
|
||||
# 应用 SQLite PRAGMA 优化
|
||||
from sqlalchemy import event
|
||||
@event.listens_for(engine, 'connect')
|
||||
def _configure_sqlite(dbapi_conn, _):
|
||||
cur = dbapi_conn.cursor()
|
||||
cur.execute('PRAGMA journal_mode=WAL')
|
||||
cur.execute('PRAGMA synchronous=NORMAL')
|
||||
cur.execute('PRAGMA foreign_keys=ON')
|
||||
cur.execute('PRAGMA busy_timeout=5000')
|
||||
cur.close()
|
||||
|
||||
with open('create_sqlite3.sql', 'r') as f:
|
||||
schema_sql = f.read()
|
||||
with engine.connect() as conn:
|
||||
for stmt in schema_sql.split(';'):
|
||||
stmt = stmt.strip()
|
||||
if stmt:
|
||||
try:
|
||||
conn.execute(text(stmt))
|
||||
except Exception as e:
|
||||
pass
|
||||
conn.commit()
|
||||
print('Schema 初始化完成.')
|
||||
"
|
||||
fi
|
||||
|
||||
echo "启动 dbv2 服务 ${LISTEN_ADDR:-0.0.0.0}:${LISTEN_PORT:-8005}..."
|
||||
exec python dbv2.py --addr "${LISTEN_ADDR:-0.0.0.0}" --port "${LISTEN_PORT:-8005}" --workers "${WORKERS:-4}"
|
||||
@@ -0,0 +1,18 @@
|
||||
.env
|
||||
.env/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.so
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
!README.md
|
||||
.envrc
|
||||
.venv/
|
||||
venv/
|
||||
ENV/
|
||||
*.bin
|
||||
.DS_Store
|
||||
@@ -0,0 +1,48 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
LABEL maintainer="qingsong"
|
||||
LABEL service="dnfs"
|
||||
LABEL description="Dendrachy Network File-System - 树状网络文件系统"
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 设置环境变量
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
# 安装系统依赖(如果有需要)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 先复制pymsg共享库并安装
|
||||
COPY pymsg/ /app/pymsg/
|
||||
RUN pip install /app/pymsg/
|
||||
|
||||
# 复制requirements并安装依赖
|
||||
COPY dnfs/requirements.txt /app/
|
||||
RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
|
||||
# 移除flask_sqlalchemy遗留依赖(已不使用)
|
||||
RUN pip uninstall -y flask_sqlalchemy 2>/dev/null || true
|
||||
|
||||
# 复制应用代码
|
||||
COPY dnfs/ /app/dnfs/
|
||||
|
||||
# 创建非root用户运行(安全最佳实践)
|
||||
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
WORKDIR /app/dnfs
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 8006
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8006/get_notes', data=b'{\"pid\":0}', timeout=3)" || exit 1
|
||||
|
||||
# 启动服务
|
||||
CMD ["python", "dnfs.py"]
|
||||
+19
-14
@@ -32,30 +32,36 @@ from log_client import logs
|
||||
# pymsg 导入
|
||||
import sys as _sys
|
||||
_sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from pymsg import MsgClient # noqa: E402
|
||||
from pymsg import RpcClient, RpcError # noqa: E402
|
||||
|
||||
_DBV2_ADDR = '127.0.0.1'
|
||||
_DBV2_PORT = 8005
|
||||
_DBV2_ADDR = os.environ.get('DB_HOST', '127.0.0.1')
|
||||
_DBV2_PORT = int(os.environ.get('DB_PORT', '8005'))
|
||||
|
||||
# 管理员判定已上移至 gateway:登录时命中 [Admin] users 白名单即在 Token payload
|
||||
# 写入 is_admin=true,本服务通过 info.get('is_admin') 读取,不再本地维护名单。
|
||||
# 旧 Token 无此字段,按非管理员处理(安全方向)。
|
||||
|
||||
_rpc = RpcClient(_DBV2_ADDR, _DBV2_PORT, "dnfs")
|
||||
|
||||
|
||||
def _call_dbv2(payload: dict, timeout: int = 10) -> dict:
|
||||
"""通过 pymsg 短连接调用 dbv2 数据库服务。
|
||||
自动注入 ``_route: "execute"``。
|
||||
"""通过 pymsg RPC 调用 dbv2 数据库服务(自动注入 ``_route: "execute"``)。
|
||||
|
||||
返回完整响应 dict(``{"success": ..., "data"/"error": ...}``),
|
||||
与历史行为一致,调用方无需改动。服务端业务错误返回 success=False;
|
||||
网络层故障抛 ConnectionError。
|
||||
"""
|
||||
payload = {**payload, "_route": "execute"}
|
||||
try:
|
||||
client = MsgClient(_DBV2_ADDR, _DBV2_PORT, "dnfs")
|
||||
resp_text = client.call(json.dumps(payload), timeout=timeout)
|
||||
return json.loads(resp_text)
|
||||
data = _rpc.call("execute", payload, timeout=timeout)
|
||||
return {"success": True, "data": data}
|
||||
except RpcError as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
except Exception as e:
|
||||
raise ConnectionError(
|
||||
f"dbv2 调用失败 ({_DBV2_ADDR}:{_DBV2_PORT}): {e}"
|
||||
) from None
|
||||
|
||||
REVIEW_SERVER_URL = 'http://127.0.0.1:8007/review'
|
||||
REVIEW_SERVER_URL = os.environ.get('REVIEW_URL', 'http://127.0.0.1:8007/review')
|
||||
def review_content(content: str, timeout: int = 10) -> Dict[str, Any]:
|
||||
"""
|
||||
调用 AI 内容审核服务的 /review 接口,对给定文本进行审核。
|
||||
@@ -545,10 +551,9 @@ def get_note():
|
||||
# """返回应用主页"""
|
||||
# return render_template('index.html')
|
||||
|
||||
ADDR='127.0.0.1'
|
||||
PORT=8006
|
||||
ADDR = os.environ.get('LISTEN_ADDR', '0.0.0.0')
|
||||
PORT = int(os.environ.get('LISTEN_PORT', '8006'))
|
||||
if __name__ == "__main__":
|
||||
logs.info('dnfs', f'服务启动,监听 http://{ADDR}:{PORT}')
|
||||
print(f'Listen at http://{ADDR}:{PORT}')
|
||||
#app.run(host="127.0.0.1", port=8006)
|
||||
serve(app, host='127.0.0.1', port=8006)
|
||||
serve(app, host=ADDR, port=PORT)
|
||||
+34
-35
@@ -1,8 +1,7 @@
|
||||
"""
|
||||
统一日志客户端 — 各服务共用。
|
||||
"""统一日志客户端 — 各服务共用(dnfs / smtp / static 三份副本内容一致,改动请同步)。
|
||||
|
||||
设计要点:
|
||||
- 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表
|
||||
- 通过 pymsg RPC 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表
|
||||
- 异步非阻塞:内存队列 + 后台线程批量推送,不拖慢业务
|
||||
- fail-safe:db 服务不可用时静默丢弃,绝不影响业务
|
||||
- 缓冲批量:队列满 50 条或超时 2 秒时自动 flush
|
||||
@@ -26,12 +25,12 @@ import atexit
|
||||
from datetime import datetime, timezone
|
||||
|
||||
_sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), ".."))
|
||||
from pymsg import MsgClient # noqa: E402
|
||||
from pymsg import RpcClient, RpcError # noqa: E402
|
||||
|
||||
# ── 配置 ──────────────────────────────────────────────────────────────
|
||||
|
||||
_DBV2_ADDR = '127.0.0.1'
|
||||
_DBV2_PORT = 8005
|
||||
_DBV2_ADDR = _os.environ.get('DB_HOST', '127.0.0.1')
|
||||
_DBV2_PORT = int(_os.environ.get('DB_PORT', '8005'))
|
||||
|
||||
_BATCH_SIZE = 50 # 队列满 50 条时 flush
|
||||
_FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush
|
||||
@@ -41,8 +40,10 @@ _TIMEOUT = 5 # socket 超时(秒)
|
||||
class LogClient:
|
||||
"""异步批量日志推送客户端。"""
|
||||
|
||||
def __init__(self, batch_size=_BATCH_SIZE,
|
||||
def __init__(self, service_name="log",
|
||||
batch_size=_BATCH_SIZE,
|
||||
flush_interval=_FLUSH_INTERVAL):
|
||||
self._service_name = service_name
|
||||
self._batch_size = batch_size
|
||||
self._flush_interval = flush_interval
|
||||
self._queue = queue.Queue()
|
||||
@@ -133,35 +134,33 @@ class LogClient:
|
||||
self._send(batch)
|
||||
|
||||
def _send(self, batch):
|
||||
"""通过 pymsg 使用 execute 路由逐条 INSERT 到 dbv2 Logs 表。"""
|
||||
"""通过 pymsg RPC(execute 路由)逐条 INSERT 到 dbv2 Logs 表。"""
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
try:
|
||||
client = MsgClient(_DBV2_ADDR, _DBV2_PORT, "dnfs-log")
|
||||
for entry in batch:
|
||||
extra_val = entry.get("extra")
|
||||
payload = {
|
||||
"_route": "execute",
|
||||
"action": "insert",
|
||||
"table": "Logs",
|
||||
"values": {
|
||||
"ts": now,
|
||||
"service": entry.get("service"),
|
||||
"level": entry.get("level"),
|
||||
"message": entry.get("message"),
|
||||
"trace_id": entry.get("trace_id"),
|
||||
"extra": json.dumps(extra_val, ensure_ascii=False)
|
||||
if extra_val is not None else None,
|
||||
},
|
||||
}
|
||||
resp = client.call(json.dumps(payload), timeout=_TIMEOUT)
|
||||
resp_obj = json.loads(resp)
|
||||
if not resp_obj.get("success"):
|
||||
import sys
|
||||
print(f"[log_client] INSERT 失败: {resp_obj.get('error')}",
|
||||
file=sys.stderr)
|
||||
except Exception as e:
|
||||
import sys
|
||||
print(f"[log_client] 发送失败: {e}", file=sys.stderr)
|
||||
client = RpcClient(_DBV2_ADDR, _DBV2_PORT, self._service_name + "-log")
|
||||
for entry in batch:
|
||||
extra_val = entry.get("extra")
|
||||
payload = {
|
||||
"action": "insert",
|
||||
"table": "Logs",
|
||||
"values": {
|
||||
"ts": now,
|
||||
"service": entry.get("service"),
|
||||
"level": entry.get("level"),
|
||||
"message": entry.get("message"),
|
||||
"trace_id": entry.get("trace_id"),
|
||||
"extra": json.dumps(extra_val, ensure_ascii=False)
|
||||
if extra_val is not None else None,
|
||||
},
|
||||
}
|
||||
try:
|
||||
client.call("execute", payload, timeout=_TIMEOUT)
|
||||
except RpcError as e:
|
||||
# 业务错误(如表结构不符):打印后继续下一条
|
||||
print(f"[log_client] INSERT 失败: {e}", file=_sys.stderr)
|
||||
except Exception as e:
|
||||
# 网络错误:db 不可用,放弃本批剩余(fail-safe)
|
||||
print(f"[log_client] 发送失败: {e}", file=_sys.stderr)
|
||||
break
|
||||
|
||||
def _shutdown(self):
|
||||
"""进程退出时 flush 剩余日志。"""
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
version: '3.8'
|
||||
|
||||
# ── 持久化说明 ──────────────────────────────────────────────────────
|
||||
# 默认使用 Docker named volume(适合生产,性能好)。
|
||||
# 开发调试或需要直接访问数据库文件时,可改为 bind mount:
|
||||
# volumes:
|
||||
# - ./data/db:/app/data # SQLite 数据库文件
|
||||
# - ./backups:/app/backups # 备份输出目录
|
||||
#
|
||||
# 注意:bind mount 在 macOS/Windows 上文件系统性能比 named volume 差,
|
||||
# 但方便直接复制/查看数据库文件。
|
||||
|
||||
services:
|
||||
# ── 数据库服务 (pymsg TCP协议) ──────────────────────────────────
|
||||
db:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: db/Dockerfile
|
||||
container_name: micro-db
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:8005:8005"
|
||||
volumes:
|
||||
# 方式1: Docker named volume(推荐生产)
|
||||
- db_data:/app/data
|
||||
# 方式2: Bind mount(开发/需要直接访问文件时取消注释)
|
||||
# - ./data/db:/app/data
|
||||
# 备份输出目录(bind mount 方便取备份文件)
|
||||
- ./backups:/app/backups
|
||||
environment:
|
||||
- LISTEN_ADDR=0.0.0.0
|
||||
- LISTEN_PORT=8005
|
||||
- DATABASE_URL=sqlite:////app/data/database.db
|
||||
- WORKERS=4
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import socket; s=socket.socket(); s.settimeout(2); s.connect(('localhost', 8005)); s.close()"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
networks:
|
||||
- micro_net
|
||||
|
||||
# ── AI内容审核服务 ──────────────────────────────────────────────
|
||||
ai_review:
|
||||
build:
|
||||
context: ./ai_review
|
||||
dockerfile: Dockerfile
|
||||
container_name: micro-ai-review
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:8007:8007"
|
||||
volumes:
|
||||
- chroma_data:/app/chroma
|
||||
# - ./data/chroma:/app/chroma # bind mount 选项
|
||||
environment:
|
||||
- AI_REVIEW_HOST=0.0.0.0
|
||||
- AI_REVIEW_PORT=8007
|
||||
- CHROMA_PERSIST_DIR=/app/chroma
|
||||
env_file:
|
||||
- ./ai_review/.env
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8007/health', timeout=5)"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
networks:
|
||||
- micro_net
|
||||
|
||||
# ── DNFS 树状文件系统服务 ──────────────────────────────────────
|
||||
dnfs:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: dnfs/Dockerfile
|
||||
container_name: micro-dnfs
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:8006:8006"
|
||||
environment:
|
||||
- LISTEN_ADDR=0.0.0.0
|
||||
- LISTEN_PORT=8006
|
||||
- DB_HOST=db
|
||||
- DB_PORT=8005
|
||||
- REVIEW_URL=http://ai_review:8007/review
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
ai_review:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8006/get_notes', data=b'{\"pid\":0}', timeout=3)"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
networks:
|
||||
- micro_net
|
||||
|
||||
networks:
|
||||
micro_net:
|
||||
driver: bridge
|
||||
name: micro-network
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
name: micro-db-data
|
||||
chroma_data:
|
||||
name: micro-chroma-data
|
||||
@@ -0,0 +1,386 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# 微服务容器化管理脚本
|
||||
# 用法: ./docker.sh [command]
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# 颜色输出
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
|
||||
|
||||
check_docker() {
|
||||
if ! command -v docker &> /dev/null; then
|
||||
error "Docker 未安装,请先安装 Docker Desktop"
|
||||
fi
|
||||
if ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then
|
||||
error "Docker Compose 未安装"
|
||||
fi
|
||||
}
|
||||
|
||||
# 使用 docker compose 或 docker-compose(兼容旧版本)
|
||||
docker_compose() {
|
||||
if docker compose version &> /dev/null; then
|
||||
docker compose "$@"
|
||||
else
|
||||
docker-compose "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_build() {
|
||||
info "构建所有服务镜像..."
|
||||
docker_compose build --progress=plain "$@"
|
||||
info "构建完成"
|
||||
}
|
||||
|
||||
cmd_up() {
|
||||
info "启动服务(后台运行)..."
|
||||
docker_compose up -d "$@"
|
||||
info "服务已启动"
|
||||
echo ""
|
||||
cmd_ps
|
||||
}
|
||||
|
||||
cmd_down() {
|
||||
info "停止并移除容器..."
|
||||
docker_compose down "$@"
|
||||
info "服务已停止"
|
||||
}
|
||||
|
||||
cmd_start() {
|
||||
info "启动已存在的容器..."
|
||||
docker_compose start "$@"
|
||||
}
|
||||
|
||||
cmd_stop() {
|
||||
info "停止运行中的容器..."
|
||||
docker_compose stop "$@"
|
||||
}
|
||||
|
||||
cmd_restart() {
|
||||
info "重启服务..."
|
||||
docker_compose restart "$@"
|
||||
}
|
||||
|
||||
cmd_ps() {
|
||||
info "服务状态:"
|
||||
docker_compose ps
|
||||
}
|
||||
|
||||
cmd_logs() {
|
||||
if [ -z "$1" ]; then
|
||||
docker_compose logs -f --tail=100
|
||||
else
|
||||
docker_compose logs -f --tail=100 "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_shell() {
|
||||
local service=${1:-dnfs}
|
||||
info "进入 $service 容器 shell..."
|
||||
docker_compose exec "$service" /bin/bash || docker_compose exec "$service" /bin/sh
|
||||
}
|
||||
|
||||
cmd_rebuild() {
|
||||
info "重新构建并启动服务..."
|
||||
docker_compose build --no-cache "$@"
|
||||
docker_compose up -d "$@"
|
||||
}
|
||||
|
||||
cmd_clean() {
|
||||
warn "此操作将停止所有容器并删除数据卷(数据库和ChromaDB数据将丢失!)"
|
||||
read -p "确认继续? [y/N] " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
docker_compose down -v
|
||||
info "已清理所有容器和数据卷"
|
||||
else
|
||||
info "已取消"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_pull() {
|
||||
info "拉取基础镜像..."
|
||||
docker pull python:3.11-slim
|
||||
}
|
||||
|
||||
cmd_health() {
|
||||
info "检查服务健康状态..."
|
||||
local services=("db:8005" "ai_review:8007" "dnfs:8006")
|
||||
for svc in "${services[@]}"; do
|
||||
IFS=':' read -r name port <<< "$svc"
|
||||
if nc -z 127.0.0.1 "$port" 2>/dev/null; then
|
||||
echo -e " ${GREEN}✓${NC} $name :$port 健康"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} $name :$port 不可达"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# ── 数据库管理命令 ──────────────────────────────────────────────
|
||||
|
||||
DB_CONTAINER="micro-db"
|
||||
|
||||
_check_db_running() {
|
||||
if ! docker ps --format '{{.Names}}' | grep -q "^${DB_CONTAINER}$"; then
|
||||
error "db 容器未运行,请先启动服务: ./docker.sh up"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_db_info() {
|
||||
_check_db_running
|
||||
info "数据库信息:"
|
||||
docker_compose exec db python db_tools.py info
|
||||
}
|
||||
|
||||
cmd_db_backup() {
|
||||
_check_db_running
|
||||
local backup_file="${1:-}"
|
||||
if [ -z "$backup_file" ]; then
|
||||
backup_file="backup_$(date +%Y%m%d_%H%M%S).db"
|
||||
fi
|
||||
info "执行数据库备份..."
|
||||
# 先做 WAL checkpoint 确保数据完整
|
||||
docker_compose exec db python db_tools.py wal-checkpoint
|
||||
# 备份到容器内的 /app/backups 目录(映射到宿主机 ./backups)
|
||||
docker_compose exec db python db_tools.py backup "/app/backups/$backup_file"
|
||||
echo ""
|
||||
info "备份文件: ./backups/$backup_file"
|
||||
ls -lh "./backups/$backup_file" 2>/dev/null || info "(文件已写入容器 /app/backups/) "
|
||||
}
|
||||
|
||||
cmd_db_export() {
|
||||
_check_db_running
|
||||
local export_file="${1:-}"
|
||||
if [ -z "$export_file" ]; then
|
||||
export_file="export_$(date +%Y%m%d_%H%M%S).json"
|
||||
fi
|
||||
info "导出数据库为 JSON..."
|
||||
docker_compose exec db python db_tools.py export "/app/backups/$export_file"
|
||||
echo ""
|
||||
info "导出文件: ./backups/$export_file"
|
||||
}
|
||||
|
||||
cmd_db_import() {
|
||||
local import_file="$1"
|
||||
if [ -z "$import_file" ]; then
|
||||
error "用法: ./docker.sh db-import <json文件路径>"
|
||||
fi
|
||||
if [ ! -f "./backups/$import_file" ] && [ ! -f "$import_file" ]; then
|
||||
# 检查是否在backups目录
|
||||
error "导入文件不存在: $import_file (请放在 ./backups/ 目录下或提供绝对路径)"
|
||||
fi
|
||||
_check_db_running
|
||||
|
||||
local container_path="/app/backups/$(basename "$import_file")"
|
||||
# 如果是宿主机路径且不在backups下,先复制到backups
|
||||
if [ -f "$import_file" ] && [ ! -f "./backups/$(basename "$import_file")" ]; then
|
||||
warn "复制文件到 backups 目录..."
|
||||
cp "$import_file" "./backups/"
|
||||
fi
|
||||
|
||||
warn "即将导入数据,现有数据将被覆盖!"
|
||||
read -p "确认继续? [y/N] " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
# 先备份当前数据
|
||||
cmd_db_backup "before_import_$(date +%Y%m%d_%H%M%S).db"
|
||||
info "开始导入..."
|
||||
docker_compose exec db python db_tools.py import "$container_path"
|
||||
else
|
||||
info "已取消"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_db_vacuum() {
|
||||
_check_db_running
|
||||
info "执行 VACUUM 压缩数据库..."
|
||||
docker_compose exec db python db_tools.py vacuum
|
||||
}
|
||||
|
||||
cmd_db_restore() {
|
||||
local backup_file="$1"
|
||||
if [ -z "$backup_file" ]; then
|
||||
error "用法: ./docker.sh db-restore <备份文件路径> (从 ./backups/ 目录)"
|
||||
error "示例: ./docker.sh db-restore backup_20260730_120000.db"
|
||||
fi
|
||||
|
||||
local backup_path="./backups/$backup_file"
|
||||
if [ ! -f "$backup_path" ]; then
|
||||
# 尝试绝对路径
|
||||
if [ -f "$backup_file" ]; then
|
||||
backup_path="$backup_file"
|
||||
else
|
||||
error "备份文件不存在: $backup_file"
|
||||
fi
|
||||
fi
|
||||
|
||||
_check_db_running
|
||||
|
||||
warn "即将从备份恢复数据库,当前数据将被覆盖!"
|
||||
warn "备份文件: $backup_path"
|
||||
read -p "确认继续? [y/N] 输入 YES 确认: " -r
|
||||
echo
|
||||
if [[ "$REPLY" == "YES" ]]; then
|
||||
info "停止 dnfs 和 ai_review 服务..."
|
||||
docker_compose stop dnfs ai_review
|
||||
|
||||
info "复制备份文件到容器..."
|
||||
docker cp "$backup_path" "$DB_CONTAINER:/app/data/database.db"
|
||||
|
||||
info "重启 db 服务..."
|
||||
docker_compose restart db
|
||||
|
||||
# 等待db健康
|
||||
info "等待 db 服务恢复..."
|
||||
sleep 5
|
||||
|
||||
info "启动其他服务..."
|
||||
docker_compose start dnfs ai_review
|
||||
|
||||
info "恢复完成!"
|
||||
else
|
||||
info "已取消"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_db_list_backups() {
|
||||
info "备份文件列表 (./backups/):"
|
||||
if [ -d "./backups" ]; then
|
||||
ls -lhS ./backups/ 2>/dev/null | grep -v "total" | grep -v ".gitkeep" || echo " (无备份文件)"
|
||||
else
|
||||
echo " backups 目录不存在"
|
||||
fi
|
||||
}
|
||||
|
||||
usage() {
|
||||
echo "微服务容器化管理脚本"
|
||||
echo ""
|
||||
echo "用法: ./docker.sh <command> [options]"
|
||||
echo ""
|
||||
echo "── 服务管理 ──"
|
||||
echo " build 构建所有服务镜像"
|
||||
echo " up 启动所有服务(后台运行)"
|
||||
echo " down 停止并移除所有容器"
|
||||
echo " start 启动已停止的容器"
|
||||
echo " stop 停止运行中的容器"
|
||||
echo " restart 重启服务"
|
||||
echo " ps 查看服务状态"
|
||||
echo " logs [svc] 查看日志(默认所有服务,可指定服务名)"
|
||||
echo " shell [svc] 进入容器shell(默认dnfs)"
|
||||
echo " rebuild 重新构建(无缓存)并启动"
|
||||
echo " health 检查服务健康状态"
|
||||
echo " pull 拉取最新基础镜像"
|
||||
echo " clean 停止并删除所有容器+数据卷(危险!)"
|
||||
echo ""
|
||||
echo "── 数据库管理 (SQLite) ──"
|
||||
echo " db-info 查看数据库信息和表统计"
|
||||
echo " db-backup [file] 备份数据库(.db 二进制文件,输出到 ./backups/)"
|
||||
echo " db-export [file] 导出数据为 JSON(跨平台传输,输出到 ./backups/)"
|
||||
echo " db-import <file> 从 JSON 导入数据(文件放在 ./backups/ 下)"
|
||||
echo " db-restore <file> 从 .db 备份文件恢复"
|
||||
echo " db-vacuum VACUUM 压缩数据库"
|
||||
echo " db-ls 列出备份文件"
|
||||
echo ""
|
||||
echo "示例:"
|
||||
echo " ./docker.sh build dnfs # 仅构建dnfs服务"
|
||||
echo " ./docker.sh logs dnfs # 查看dnfs日志"
|
||||
echo " ./docker.sh shell db # 进入db容器"
|
||||
echo " ./docker.sh db-backup # 立即备份"
|
||||
echo " ./docker.sh db-export # 导出JSON用于迁移"
|
||||
echo " ./docker.sh db-ls # 查看现有备份"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 主逻辑
|
||||
check_docker
|
||||
|
||||
case "${1:-}" in
|
||||
build)
|
||||
shift
|
||||
cmd_build "$@"
|
||||
;;
|
||||
up)
|
||||
shift
|
||||
cmd_up "$@"
|
||||
;;
|
||||
down)
|
||||
shift
|
||||
cmd_down "$@"
|
||||
;;
|
||||
start)
|
||||
shift
|
||||
cmd_start "$@"
|
||||
;;
|
||||
stop)
|
||||
shift
|
||||
cmd_stop "$@"
|
||||
;;
|
||||
restart)
|
||||
shift
|
||||
cmd_restart "$@"
|
||||
;;
|
||||
ps|status)
|
||||
cmd_ps
|
||||
;;
|
||||
logs)
|
||||
shift
|
||||
cmd_logs "$@"
|
||||
;;
|
||||
shell)
|
||||
shift
|
||||
cmd_shell "$@"
|
||||
;;
|
||||
rebuild)
|
||||
shift
|
||||
cmd_rebuild "$@"
|
||||
;;
|
||||
health)
|
||||
cmd_health
|
||||
;;
|
||||
pull)
|
||||
cmd_pull
|
||||
;;
|
||||
clean)
|
||||
cmd_clean
|
||||
;;
|
||||
# ── 数据库管理命令 ──
|
||||
db-info|dbinfo)
|
||||
cmd_db_info
|
||||
;;
|
||||
db-backup|dbbackup)
|
||||
shift
|
||||
cmd_db_backup "$@"
|
||||
;;
|
||||
db-export|dbexport)
|
||||
shift
|
||||
cmd_db_export "$@"
|
||||
;;
|
||||
db-import|dbimport)
|
||||
shift
|
||||
cmd_db_import "$@"
|
||||
;;
|
||||
db-restore|dbrestore)
|
||||
shift
|
||||
cmd_db_restore "$@"
|
||||
;;
|
||||
db-vacuum|dbvacuum)
|
||||
cmd_db_vacuum
|
||||
;;
|
||||
db-ls|dbls)
|
||||
cmd_db_list_backups
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
+69
-49
@@ -179,61 +179,85 @@ server = MsgServer("0.0.0.0", 8005, workers=4)
|
||||
- `MsgClient.stop()` 可从另一线程调用以中断 `listen()`。
|
||||
- `send()` 和 `recv()` 内部不做同步 —— 如果多线程共享同一 socket,调用方需自行串行化。
|
||||
|
||||
## JSON RPC 约定层(v0.3.0)
|
||||
|
||||
项目内所有 pymsg 通信都遵循同一 JSON RPC 约定(与 C++/Go 客户端字节级兼容):
|
||||
|
||||
```
|
||||
请求: {"_route": "<路由名>", ...业务字段}
|
||||
响应: {"success": true, "data": ...} | {"success": false, "error": "..."}
|
||||
```
|
||||
|
||||
`RpcServer` / `RpcClient` 把这套约定的编解码、路由分发、错误包装收敛为开箱即用的类(`pymsg/rpc.py`),各服务无需再手写 `_dispatch` / `_reply`。
|
||||
|
||||
### `RpcServer` — JSON RPC 服务端
|
||||
|
||||
```python
|
||||
from pymsg import RpcServer
|
||||
|
||||
rpc = RpcServer("127.0.0.1", 8005, workers=4)
|
||||
|
||||
@rpc.route("execute")
|
||||
def _execute(req): # handler(req: dict) -> data
|
||||
return {"rows": []} # 返回值自动包成 {"success": true, "data": ...}
|
||||
# 抛异常自动包成 {"success": false, "error": str(e)}
|
||||
rpc.start() # 阻塞当前线程
|
||||
```
|
||||
|
||||
| 构造参数 | 含义 |
|
||||
|----------|------|
|
||||
| `addr` / `port` | 监听地址与端口 |
|
||||
| `workers` | 回调并发线程数(透传 `MsgServer`,默认 0) |
|
||||
| `json_default` | 响应 JSON 序列化兜底函数(如日期转 ISO 8601) |
|
||||
| `on_error` | 可选钩子 `on_error(route, exc)`,handler 异常时调用(记日志用) |
|
||||
|
||||
### `RpcClient` — JSON RPC 客户端
|
||||
|
||||
```python
|
||||
from pymsg import RpcClient, RpcError
|
||||
|
||||
rpc = RpcClient("127.0.0.1", 8005, "dnfs", timeout=10.0)
|
||||
try:
|
||||
data = rpc.call("execute", {"action": "select", "table": "Users"})
|
||||
except RpcError as e:
|
||||
... # 服务端业务错误(success=false)
|
||||
```
|
||||
|
||||
`call(route, payload=None, *, timeout=None)`:自动注入 `_route`、JSON 编解码、检查 `success`,返回 `data` 字段;业务错误抛 `RpcError`,网络故障仍抛 `ConnectionRefusedError` / `TimeoutError`(来自 `MsgClient`)。
|
||||
|
||||
## 实际用例
|
||||
|
||||
### 服务端 — 数据库服务(dbv2)
|
||||
|
||||
db 服务通过 pymsg 暴露通用 CRUD 接口。每条请求是带 `_route` 字段的 JSON 对象,服务端按路由分发到对应处理函数,通过同一 socket 回复结果。
|
||||
db 服务基于 `RpcServer` 暴露通用 CRUD 接口(路由 `execute`):
|
||||
|
||||
```python
|
||||
import json
|
||||
from pymsg import MsgServer
|
||||
from pymsg import RpcServer
|
||||
|
||||
class DBServer:
|
||||
def __init__(self):
|
||||
self._server = MsgServer("127.0.0.1", 8005)
|
||||
self._routes = {"execute": self._handle_execute}
|
||||
def __init__(self, addr="127.0.0.1", port=8005, workers=4):
|
||||
self._server = RpcServer(addr, port, workers=workers,
|
||||
json_default=_json_default)
|
||||
self._server.add_route("execute", self._handle_execute)
|
||||
|
||||
def start(self):
|
||||
self._server.start(self._dispatch)
|
||||
|
||||
def _dispatch(self, sock, addr, text):
|
||||
req = json.loads(text)
|
||||
route = req.pop("_route", "")
|
||||
handler = self._routes.get(route)
|
||||
if handler is None:
|
||||
self._server.reply(sock,
|
||||
json.dumps({"success": False, "error": "未知路由"}))
|
||||
return
|
||||
try:
|
||||
result = handler(req)
|
||||
self._server.reply(sock,
|
||||
json.dumps({"success": True, "data": result}))
|
||||
except Exception as e:
|
||||
self._server.reply(sock,
|
||||
json.dumps({"success": False, "error": str(e)}))
|
||||
self._server.start()
|
||||
|
||||
def _handle_execute(self, data):
|
||||
# ... 通过 SQLAlchemy 执行 SQL,返回 dict 列表
|
||||
# ... 通过 SQLAlchemy 执行 SQL,返回 dict 列表;异常自动包装为错误响应
|
||||
pass
|
||||
```
|
||||
|
||||
### 客户端 — RPC 调用(dnfs → db)
|
||||
|
||||
dnfs 笔记服务自身没有数据库,所有 CRUD 操作均通过 pymsg 调用 db。
|
||||
dnfs 笔记服务自身没有数据库,所有 CRUD 操作均通过 `RpcClient` 调用 db:
|
||||
|
||||
```python
|
||||
import json
|
||||
from pymsg import MsgClient
|
||||
from pymsg import RpcClient
|
||||
|
||||
def _call_dbv2(payload):
|
||||
client = MsgClient("127.0.0.1", 8005, "dnfs")
|
||||
resp = client.call(json.dumps(payload), timeout=10.0)
|
||||
return json.loads(resp)
|
||||
_rpc = RpcClient("127.0.0.1", 8005, "dnfs")
|
||||
|
||||
# 创建笔记
|
||||
result = _call_dbv2({
|
||||
"_route": "execute",
|
||||
data = _rpc.call("execute", {
|
||||
"action": "insert",
|
||||
"table": "Notes",
|
||||
"values": {"title": "会议记录", "content": "..."}
|
||||
@@ -242,24 +266,19 @@ result = _call_dbv2({
|
||||
|
||||
### 客户端 — 异步日志推送(log_client)
|
||||
|
||||
各服务通过 pymsg 将日志异步推送到 db。使用 `MsgClient.call()` 逐批插入。
|
||||
各服务通过统一 `log_client.py` 将日志异步推送到 db,底层为 `RpcClient`:
|
||||
|
||||
```python
|
||||
import json
|
||||
from pymsg import MsgClient
|
||||
from pymsg import RpcClient
|
||||
|
||||
def _send(batch):
|
||||
client = MsgClient("127.0.0.1", 8005, "gateway-log")
|
||||
for entry in batch:
|
||||
client.call(json.dumps({
|
||||
"_route": "execute",
|
||||
"action": "insert",
|
||||
"table": "Logs",
|
||||
"values": {
|
||||
"ts": now, "service": entry["service"],
|
||||
"level": entry["level"], "message": entry["message"],
|
||||
}
|
||||
}), timeout=5)
|
||||
client = RpcClient("127.0.0.1", 8005, "log-client")
|
||||
for entry in batch:
|
||||
client.call("execute", {
|
||||
"action": "insert",
|
||||
"table": "Logs",
|
||||
"values": {"ts": now, "service": entry["service"],
|
||||
"level": entry["level"], "message": entry["message"]},
|
||||
}, timeout=5)
|
||||
```
|
||||
|
||||
## 跨语言互操作
|
||||
@@ -289,6 +308,7 @@ pymsg 的线格式有意设计得极其简单,使其他语言的服务也能
|
||||
| `ConnectionError`(内置) | 完整消息接收前 socket 断开 |
|
||||
| `ConnectionRefusedError`(内置) | `MsgClient.call()` 或 `send()` 无法连接目标 |
|
||||
| `RuntimeError` | `MsgClient.listen()` 在 `connect()` 之前调用 |
|
||||
| `pymsg.RpcError` | `RpcClient.call()` 收到 `success=false` 业务错误或非法 RPC 响应 |
|
||||
|
||||
`MsgServer` 内部会吞掉客户端断连错误(移除该客户端,继续服务)。`broadcast()` 静默跳过已断开的 socket。
|
||||
|
||||
|
||||
+19
-3
@@ -1,7 +1,7 @@
|
||||
# pymsg 模块技术手册
|
||||
|
||||
> 文档版本:2026-07-30
|
||||
> 代码版本:0.2.0
|
||||
> 代码版本:0.3.0
|
||||
> 语言:Python 3.9+(核心)、C++(gateway 集成)、Go(waf 集成)
|
||||
> 定位:跨语言 TCP 消息帧协议库
|
||||
|
||||
@@ -144,9 +144,11 @@ log_client.push(entry)
|
||||
```
|
||||
pymsg/
|
||||
├── pymsg/
|
||||
│ └── __init__.py # 核心实现(~460 行)
|
||||
│ ├── __init__.py # 核心协议与 MsgClient/MsgServer(~470 行)
|
||||
│ └── rpc.py # JSON RPC 约定层 RpcClient/RpcServer(~170 行,v0.3.0)
|
||||
├── tests/
|
||||
│ └── test_threadpool.py # 线程池/同步模式单元测试(纯 stdlib)
|
||||
│ ├── test_threadpool.py # 线程池/同步模式单元测试(纯 stdlib)
|
||||
│ └── test_rpc.py # RPC 约定层单元测试(纯 stdlib)
|
||||
├── README.md # 用户文档
|
||||
├── pyproject.toml # 项目配置
|
||||
└── .gitignore
|
||||
@@ -189,6 +191,19 @@ pymsg/
|
||||
| `MsgServer._run_task(...)` | L413 | 工作线程:执行回调、失败打标记 |
|
||||
| `MsgServer._reap_failed(socketlist)` | L434 | 主循环统一移除失败连接 |
|
||||
|
||||
#### RPC 约定层(rpc.py,v0.3.0)
|
||||
|
||||
| 函数/类 | 行号 | 功能 |
|
||||
|---------|------|------|
|
||||
| `RpcError` | L28 | 业务错误异常(success=false / 非法响应) |
|
||||
| `RpcClient` | L32 | JSON RPC 客户端 |
|
||||
| `RpcClient.call(route, payload, *, timeout)` | L46 | 注入 _route、编解码、检查 success,返回 data |
|
||||
| `RpcServer` | L80 | JSON RPC 服务端(基于 MsgServer) |
|
||||
| `RpcServer.route(name)` / `add_route` | L119/L126 | 注册路由 handler(`fn(req) -> data`) |
|
||||
| `RpcServer.start()` / `stop()` | L131/L135 | 启动(阻塞)/ 停止 |
|
||||
| `RpcServer._dispatch` | L140 | 路由分发与异常包装 |
|
||||
| `RpcServer._reply` | L164 | 响应包装(支持 json_default) |
|
||||
|
||||
#### C++ 实现(Msg.hpp)
|
||||
|
||||
| 函数/类 | 行号 | 功能 |
|
||||
@@ -736,6 +751,7 @@ python test_runner.py
|
||||
|------|------|---------|
|
||||
| 0.1.0 | — | 初始版本:核心协议、MsgClient、MsgServer、跨语言实现 |
|
||||
| 0.2.0 | 2026-07-30 | `MsgServer` 新增 `workers` 参数:回调可提交线程池并发执行;同连接串行(busy 标记)、每连接发送锁防帧交错、回调异常故障隔离;`tests/test_threadpool.py` 单元测试 |
|
||||
| 0.3.0 | 2026-07-30 | 新增 `rpc.py` JSON RPC 约定层:`RpcClient`(_route 注入 + 编解码 + success 检查 + `RpcError`)、`RpcServer`(路由分发 + 响应包装 + `json_default`/`on_error` 钩子);db/smtp/captcha 服务端与 dnfs/log_client 客户端完成迁移;`tests/test_rpc.py` 单元测试 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -456,3 +456,9 @@ class MsgServer:
|
||||
pass
|
||||
self._clients.clear()
|
||||
self._send_locks.clear()
|
||||
|
||||
|
||||
# ── JSON RPC 约定层(依赖上面的 MsgClient / MsgServer,故放在末尾导入)──
|
||||
from .rpc import RpcClient, RpcError, RpcServer # noqa: E402
|
||||
|
||||
__all__ += ["RpcClient", "RpcServer", "RpcError"]
|
||||
@@ -0,0 +1,171 @@
|
||||
"""pymsg.rpc — pymsg 线协议之上的 JSON RPC 约定层。
|
||||
|
||||
约定(与 C++/Go 客户端字节级兼容)::
|
||||
|
||||
请求: {"_route": "<路由名>", ...业务字段}
|
||||
响应: {"success": true, "data": ...}
|
||||
{"success": false, "error": "..."}
|
||||
|
||||
把各服务(db / smtp / captcha)重复的 ``_dispatch`` / ``_reply`` 服务端
|
||||
分发逻辑,以及各调用方(dnfs / log_client)重复的 ``_route`` 注入 +
|
||||
JSON 编解码客户端逻辑,收敛为 ``RpcServer`` / ``RpcClient`` 两个类。
|
||||
|
||||
异常分层::
|
||||
|
||||
RpcError — 远端返回的业务错误(success=false)或非法响应
|
||||
pymsg.TimeoutError — 网络层超时(来自 MsgClient)
|
||||
ConnectionRefusedError — 目标不可达(来自 MsgClient)
|
||||
ConnectionError — 连接中断(来自 MsgClient)
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from . import MsgClient, MsgServer
|
||||
|
||||
__all__ = ["RpcClient", "RpcServer", "RpcError"]
|
||||
|
||||
|
||||
class RpcError(Exception):
|
||||
"""远端返回 ``success=false`` 的业务错误,或响应不符合 RPC 约定。"""
|
||||
|
||||
|
||||
class RpcClient:
|
||||
"""JSON RPC 客户端(短连接,每次调用独立 TCP 连接)。
|
||||
|
||||
Usage::
|
||||
|
||||
rpc = RpcClient("127.0.0.1", 8005, "dnfs")
|
||||
data = rpc.call("execute", {"action": "select", "table": "Users"})
|
||||
"""
|
||||
|
||||
def __init__(self, addr: str, port: int, name: str = "rpc",
|
||||
timeout: float = 10.0) -> None:
|
||||
self._client = MsgClient(addr, port, name)
|
||||
self._timeout = timeout
|
||||
|
||||
def call(self, route: str, payload: "dict | None" = None, *,
|
||||
timeout: "float | None" = None):
|
||||
"""调用指定路由,返回响应中的 ``data`` 字段。
|
||||
|
||||
Args:
|
||||
route: 路由名(注入为 ``_route`` 字段)。
|
||||
payload: 业务字段 dict(``_route`` 键会被覆盖)。
|
||||
timeout: 本次调用的超时秒数,缺省用构造时的值。
|
||||
|
||||
Returns:
|
||||
服务端返回的 ``data``(可能为 None)。
|
||||
|
||||
Raises:
|
||||
RpcError: 服务端返回 success=false,或响应非法。
|
||||
pymsg.TimeoutError: 等待响应超时。
|
||||
ConnectionRefusedError: 目标服务不可达。
|
||||
"""
|
||||
req = dict(payload or {})
|
||||
req["_route"] = route
|
||||
resp = self._client.call(
|
||||
json.dumps(req, ensure_ascii=False),
|
||||
timeout=self._timeout if timeout is None else timeout,
|
||||
)
|
||||
try:
|
||||
obj = json.loads(resp)
|
||||
except json.JSONDecodeError as e:
|
||||
raise RpcError(f"响应不是合法 JSON: {e}") from None
|
||||
if not isinstance(obj, dict) or "success" not in obj:
|
||||
raise RpcError(f"非法 RPC 响应: {resp[:200]!r}")
|
||||
if not obj["success"]:
|
||||
raise RpcError(obj.get("error", "未知错误"))
|
||||
return obj.get("data")
|
||||
|
||||
|
||||
class RpcServer:
|
||||
"""JSON RPC 服务端:基于 MsgServer,自带路由分发与响应包装。
|
||||
|
||||
handler 签名为 ``handler(req: dict) -> data``:
|
||||
|
||||
- 返回值放入 ``{"success": true, "data": ...}`` 回复;
|
||||
- 抛出的异常自动包装为 ``{"success": false, "error": str(e)}``,
|
||||
服务不会因此崩溃。
|
||||
|
||||
Usage::
|
||||
|
||||
rpc = RpcServer("127.0.0.1", 8005, workers=4)
|
||||
|
||||
@rpc.route("execute")
|
||||
def _execute(req):
|
||||
return {"rows": []}
|
||||
|
||||
rpc.start() # 阻塞当前线程
|
||||
"""
|
||||
|
||||
def __init__(self, addr: str, port: int, *, workers: int = 0,
|
||||
json_default: "callable | None" = None,
|
||||
on_error: "callable | None" = None) -> None:
|
||||
"""
|
||||
Args:
|
||||
addr: 监听地址。
|
||||
port: 监听端口。
|
||||
workers: 回调并发线程数(透传 MsgServer,0=同步串行)。
|
||||
json_default: 响应 JSON 序列化的兜底函数(如日期转 ISO),
|
||||
透传 ``json.dumps(default=...)``。
|
||||
on_error: 可选钩子 ``on_error(route, exc)``,handler 抛异常
|
||||
时调用(用于记日志);None 表示静默。
|
||||
"""
|
||||
self._server = MsgServer(addr, port, workers=workers)
|
||||
self._routes: "dict" = {}
|
||||
self._json_default = json_default
|
||||
self._on_error = on_error
|
||||
|
||||
# ── 路由注册 -------------------------------------------------------
|
||||
def route(self, name: str):
|
||||
"""装饰器:注册路由处理函数。"""
|
||||
def deco(fn):
|
||||
self._routes[name] = fn
|
||||
return fn
|
||||
return deco
|
||||
|
||||
def add_route(self, name: str, fn: "callable") -> None:
|
||||
"""直接注册路由处理函数(等价于 route 装饰器)。"""
|
||||
self._routes[name] = fn
|
||||
|
||||
# ── 启动 / 停止 ----------------------------------------------------
|
||||
def start(self) -> None:
|
||||
"""启动服务(阻塞当前线程)。"""
|
||||
self._server.start(self._dispatch)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""从另一线程停止服务。"""
|
||||
self._server.stop()
|
||||
|
||||
# ── 分发与响应包装 --------------------------------------------------
|
||||
def _dispatch(self, sock, addr, text):
|
||||
try:
|
||||
req = json.loads(text)
|
||||
except json.JSONDecodeError as e:
|
||||
self._reply(sock, False, error=f"JSON 解析失败: {e}")
|
||||
return
|
||||
|
||||
route = req.pop("_route", "")
|
||||
handler = self._routes.get(route)
|
||||
if handler is None:
|
||||
self._reply(sock, False, error=f"未知路由: {route}")
|
||||
return
|
||||
|
||||
try:
|
||||
result = handler(req)
|
||||
self._reply(sock, True, data=result)
|
||||
except Exception as e:
|
||||
if self._on_error is not None:
|
||||
try:
|
||||
self._on_error(route, e)
|
||||
except Exception:
|
||||
pass
|
||||
self._reply(sock, False, error=str(e))
|
||||
|
||||
def _reply(self, sock, success, data=None, error=None):
|
||||
resp = {"success": success}
|
||||
if data is not None:
|
||||
resp["data"] = data
|
||||
if error is not None:
|
||||
resp["error"] = error
|
||||
self._server.reply(sock, json.dumps(
|
||||
resp, ensure_ascii=False, default=self._json_default))
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "pymsg"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
description = "Length-prefixed variable-length socket message framing"
|
||||
requires-python = ">=3.9"
|
||||
license = "MIT"
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""RpcClient / RpcServer(JSON RPC 约定层)单元测试。
|
||||
|
||||
纯标准库,直接运行:
|
||||
|
||||
cd pymsg
|
||||
python -m unittest discover -s tests -v
|
||||
"""
|
||||
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from pymsg import RpcClient, RpcError, RpcServer, send
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
class _RpcFixture:
|
||||
"""后台线程运行 RpcServer;routes: {name: fn}。"""
|
||||
|
||||
def __init__(self, routes, workers=0, json_default=None):
|
||||
self.port = _free_port()
|
||||
self.server = RpcServer("127.0.0.1", self.port, workers=workers,
|
||||
json_default=json_default)
|
||||
for name, fn in routes.items():
|
||||
self.server.add_route(name, fn)
|
||||
self.thread = threading.Thread(target=self.server.start, daemon=True)
|
||||
|
||||
def __enter__(self):
|
||||
self.thread.start()
|
||||
deadline = time.time() + 5
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", self.port), timeout=0.2):
|
||||
return self
|
||||
except OSError:
|
||||
time.sleep(0.02)
|
||||
raise RuntimeError("server did not start in time")
|
||||
|
||||
def __exit__(self, *exc):
|
||||
self.server.stop()
|
||||
self.thread.join(timeout=5)
|
||||
return False
|
||||
|
||||
|
||||
class TestRpc(unittest.TestCase):
|
||||
|
||||
def test_call_returns_data(self):
|
||||
with _RpcFixture({"echo": lambda req: {"got": req["msg"]}}) as fx:
|
||||
rpc = RpcClient("127.0.0.1", fx.port, "t")
|
||||
self.assertEqual(rpc.call("echo", {"msg": "hi"}), {"got": "hi"})
|
||||
|
||||
def test_route_field_is_injected(self):
|
||||
seen = {}
|
||||
|
||||
def handler(req):
|
||||
seen.update(req)
|
||||
return None
|
||||
|
||||
with _RpcFixture({"r": handler}) as fx:
|
||||
RpcClient("127.0.0.1", fx.port).call("r", {"a": 1})
|
||||
# _route 已被服务端 pop,handler 只看到业务字段
|
||||
self.assertEqual(seen, {"a": 1})
|
||||
|
||||
def test_business_error_raises_rpcerror(self):
|
||||
def handler(req):
|
||||
raise ValueError("bad input")
|
||||
|
||||
with _RpcFixture({"x": handler}) as fx:
|
||||
with self.assertRaises(RpcError) as cm:
|
||||
RpcClient("127.0.0.1", fx.port).call("x")
|
||||
self.assertIn("bad input", str(cm.exception))
|
||||
|
||||
def test_unknown_route_raises_rpcerror(self):
|
||||
with _RpcFixture({}) as fx:
|
||||
with self.assertRaises(RpcError) as cm:
|
||||
RpcClient("127.0.0.1", fx.port).call("nope")
|
||||
self.assertIn("未知路由", str(cm.exception))
|
||||
|
||||
def test_malformed_json_gets_error_response(self):
|
||||
"""服务端收到非法 JSON:返回 success=false 且不崩溃,后续请求正常。"""
|
||||
with _RpcFixture({"ok": lambda req: "fine"}) as fx:
|
||||
with socket.create_connection(("127.0.0.1", fx.port), timeout=5) as s:
|
||||
send(s, "not-json{{{")
|
||||
from pymsg import recv
|
||||
resp = json.loads(recv(s, timeout=5))
|
||||
self.assertFalse(resp["success"])
|
||||
self.assertIn("JSON 解析失败", resp["error"])
|
||||
# 服务仍然存活
|
||||
self.assertEqual(RpcClient("127.0.0.1", fx.port).call("ok"), "fine")
|
||||
|
||||
def test_data_none_when_absent(self):
|
||||
with _RpcFixture({"n": lambda req: None}) as fx:
|
||||
self.assertIsNone(RpcClient("127.0.0.1", fx.port).call("n"))
|
||||
|
||||
def test_json_default_serialization(self):
|
||||
def default(o):
|
||||
if isinstance(o, datetime):
|
||||
return o.isoformat()
|
||||
return str(o)
|
||||
|
||||
now = datetime(2026, 7, 30, 12, 0, tzinfo=timezone.utc)
|
||||
with _RpcFixture({"t": lambda req: {"ts": now}}, json_default=default) as fx:
|
||||
data = RpcClient("127.0.0.1", fx.port).call("t")
|
||||
self.assertEqual(data["ts"], now.isoformat())
|
||||
|
||||
def test_concurrent_rpc_with_workers(self):
|
||||
def handler(req):
|
||||
time.sleep(0.3)
|
||||
return req["i"] * 2
|
||||
|
||||
with _RpcFixture({"dbl": handler}, workers=4) as fx:
|
||||
results = [None] * 6
|
||||
|
||||
def w(i):
|
||||
results[i] = RpcClient("127.0.0.1", fx.port).call("dbl", {"i": i})
|
||||
|
||||
t0 = time.monotonic()
|
||||
ts = [threading.Thread(target=w, args=(i,)) for i in range(6)]
|
||||
for t in ts:
|
||||
t.start()
|
||||
for t in ts:
|
||||
t.join(10)
|
||||
elapsed = time.monotonic() - t0
|
||||
|
||||
self.assertEqual(results, [i * 2 for i in range(6)])
|
||||
self.assertLess(elapsed, 1.2, f"workers=4 应并发,实际 {elapsed:.2f}s")
|
||||
|
||||
def test_per_call_timeout_override(self):
|
||||
with _RpcFixture({"slow": lambda req: time.sleep(2)}) as fx:
|
||||
rpc = RpcClient("127.0.0.1", fx.port, timeout=10.0)
|
||||
from pymsg import TimeoutError
|
||||
with self.assertRaises(TimeoutError):
|
||||
rpc.call("slow", timeout=0.3)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+38
-47
@@ -1,8 +1,7 @@
|
||||
"""
|
||||
统一日志客户端 — 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表。
|
||||
"""统一日志客户端 — 各服务共用(dnfs / smtp / static 三份副本内容一致,改动请同步)。
|
||||
|
||||
设计要点:
|
||||
- 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表
|
||||
- 通过 pymsg RPC 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表
|
||||
- 异步非阻塞:内存队列 + 后台线程批量推送,不拖慢业务
|
||||
- fail-safe:db 服务不可用时静默丢弃,绝不影响业务
|
||||
- 缓冲批量:队列满 50 条或超时 2 秒时自动 flush
|
||||
@@ -10,9 +9,9 @@
|
||||
用法:
|
||||
from log_client import logs
|
||||
|
||||
logs.info("smtp", "验证码发送成功")
|
||||
logs.error("smtp", "验证码发送失败", extra={"errno": 2002})
|
||||
logs.push("smtp", "WARNING", "登录失败次数超限",
|
||||
logs.info("dnfs", "节点新增成功 id=42")
|
||||
logs.error("dnfs", "数据库连接失败", extra={"errno": 2002})
|
||||
logs.push("gateway", "WARNING", "登录失败次数超限",
|
||||
trace_id="req-abc", extra={"ip": "1.2.3.4"})
|
||||
"""
|
||||
|
||||
@@ -26,12 +25,12 @@ import atexit
|
||||
from datetime import datetime, timezone
|
||||
|
||||
_sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), ".."))
|
||||
from pymsg import MsgClient # noqa: E402
|
||||
from pymsg import RpcClient, RpcError # noqa: E402
|
||||
|
||||
# ── 配置 ──────────────────────────────────────────────────────────────
|
||||
|
||||
_DBV2_ADDR = '127.0.0.1'
|
||||
_DBV2_PORT = 8005
|
||||
_DBV2_ADDR = _os.environ.get('DB_HOST', '127.0.0.1')
|
||||
_DBV2_PORT = int(_os.environ.get('DB_PORT', '8005'))
|
||||
|
||||
_BATCH_SIZE = 50 # 队列满 50 条时 flush
|
||||
_FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush
|
||||
@@ -39,12 +38,9 @@ _TIMEOUT = 5 # socket 超时(秒)
|
||||
|
||||
|
||||
class LogClient:
|
||||
"""异步批量日志推送客户端。
|
||||
"""异步批量日志推送客户端。"""
|
||||
|
||||
通过 MsgClient(pymsg TCP 协议)逐条 INSERT 到 dbv2 的 Logs 表。
|
||||
"""
|
||||
|
||||
def __init__(self, service_name="smtp",
|
||||
def __init__(self, service_name="log",
|
||||
batch_size=_BATCH_SIZE,
|
||||
flush_interval=_FLUSH_INTERVAL):
|
||||
self._service_name = service_name
|
||||
@@ -88,9 +84,7 @@ class LogClient:
|
||||
def flush(self):
|
||||
"""手动 flush 队列中剩余的日志(含后台线程持有的局部 batch)。"""
|
||||
self._flush_signal.set() # 通知后台线程立即 flush 其局部 batch
|
||||
# 等待后台线程响应(queue.get 超时最长 0.5s)
|
||||
time.sleep(0.6)
|
||||
# 排空主队列中尚未被线程取走的条目
|
||||
time.sleep(0.6) # 等待后台线程响应
|
||||
batch = []
|
||||
while True:
|
||||
try:
|
||||
@@ -113,7 +107,6 @@ class LogClient:
|
||||
|
||||
while not self._stop.is_set():
|
||||
timeout = self._flush_interval - (time.monotonic() - last_flush)
|
||||
# 限制最长阻塞时间,以便及时响应 flush() 信号
|
||||
chk_timeout = max(0.1, min(timeout, 0.5))
|
||||
try:
|
||||
item = self._queue.get(timeout=chk_timeout)
|
||||
@@ -141,35 +134,33 @@ class LogClient:
|
||||
self._send(batch)
|
||||
|
||||
def _send(self, batch):
|
||||
"""通过 pymsg 使用 execute 路由逐条 INSERT 到 dbv2 Logs 表。"""
|
||||
"""通过 pymsg RPC(execute 路由)逐条 INSERT 到 dbv2 Logs 表。"""
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
try:
|
||||
client = MsgClient(_DBV2_ADDR, _DBV2_PORT, self._service_name + "-log")
|
||||
for entry in batch:
|
||||
extra_val = entry.get("extra")
|
||||
payload = {
|
||||
"_route": "execute",
|
||||
"action": "insert",
|
||||
"table": "Logs",
|
||||
"values": {
|
||||
"ts": now,
|
||||
"service": entry.get("service"),
|
||||
"level": entry.get("level"),
|
||||
"message": entry.get("message"),
|
||||
"trace_id": entry.get("trace_id"),
|
||||
"extra": json.dumps(extra_val, ensure_ascii=False)
|
||||
if extra_val is not None else None,
|
||||
},
|
||||
}
|
||||
resp = client.call(json.dumps(payload), timeout=_TIMEOUT)
|
||||
resp_obj = json.loads(resp)
|
||||
if not resp_obj.get("success"):
|
||||
import sys
|
||||
print(f"[log_client] INSERT 失败: {resp_obj.get('error')}",
|
||||
file=sys.stderr)
|
||||
except Exception as e:
|
||||
import sys
|
||||
print(f"[log_client] 发送失败: {e}", file=sys.stderr)
|
||||
client = RpcClient(_DBV2_ADDR, _DBV2_PORT, self._service_name + "-log")
|
||||
for entry in batch:
|
||||
extra_val = entry.get("extra")
|
||||
payload = {
|
||||
"action": "insert",
|
||||
"table": "Logs",
|
||||
"values": {
|
||||
"ts": now,
|
||||
"service": entry.get("service"),
|
||||
"level": entry.get("level"),
|
||||
"message": entry.get("message"),
|
||||
"trace_id": entry.get("trace_id"),
|
||||
"extra": json.dumps(extra_val, ensure_ascii=False)
|
||||
if extra_val is not None else None,
|
||||
},
|
||||
}
|
||||
try:
|
||||
client.call("execute", payload, timeout=_TIMEOUT)
|
||||
except RpcError as e:
|
||||
# 业务错误(如表结构不符):打印后继续下一条
|
||||
print(f"[log_client] INSERT 失败: {e}", file=_sys.stderr)
|
||||
except Exception as e:
|
||||
# 网络错误:db 不可用,放弃本批剩余(fail-safe)
|
||||
print(f"[log_client] 发送失败: {e}", file=_sys.stderr)
|
||||
break
|
||||
|
||||
def _shutdown(self):
|
||||
"""进程退出时 flush 剩余日志。"""
|
||||
@@ -179,4 +170,4 @@ class LogClient:
|
||||
|
||||
# ── 全局单例 ──────────────────────────────────────────────────────────
|
||||
|
||||
logs = LogClient(service_name="smtp")
|
||||
logs = LogClient()
|
||||
+10
-41
@@ -19,7 +19,6 @@ SMTP 账号经 .env 配置:SMTP_SERVER / SMTP_PORT / SMTP_USER / __PASSWORD
|
||||
(__PASSWORD 兼容别名 SMTP_PASSWORD)。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import smtplib
|
||||
@@ -60,7 +59,7 @@ from log_client import logs
|
||||
# pymsg 导入
|
||||
import sys as _sys
|
||||
_sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from pymsg import MsgServer # noqa: E402
|
||||
from pymsg import RpcServer # noqa: E402
|
||||
|
||||
# ── 配置 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -96,14 +95,14 @@ class SmtpServer:
|
||||
def __init__(self, addr="127.0.0.1", port=8008, workers=4):
|
||||
self.addr = addr
|
||||
self.port = port
|
||||
self._server = MsgServer(addr, port, workers=workers)
|
||||
|
||||
# 路由表
|
||||
self._routes = {
|
||||
"send_email": self._handle_send_email,
|
||||
"verify_email": self._handle_verify_email,
|
||||
"health": self._handle_health,
|
||||
}
|
||||
self._server = RpcServer(
|
||||
addr, port, workers=workers,
|
||||
on_error=lambda route, e: logs.error(
|
||||
"smtp", f"路由 {route} 处理异常: {e}"),
|
||||
)
|
||||
self._server.add_route("send_email", self._handle_send_email)
|
||||
self._server.add_route("verify_email", self._handle_verify_email)
|
||||
self._server.add_route("health", self._handle_health)
|
||||
|
||||
# ── 启动 / 停止 ──────────────────────────────────────────────────
|
||||
def start(self) -> None:
|
||||
@@ -118,42 +117,12 @@ class SmtpServer:
|
||||
print(f"[smtp] 警告: SMTP 配置缺失: {','.join(missing)}(.env 加载 {_env_loaded} 项)")
|
||||
print(f"[smtp] 监听 tcp://{self.addr}:{self.port}")
|
||||
logs.info("smtp", f"开始监听 tcp://{self.addr}:{self.port}")
|
||||
self._server.start(self._dispatch)
|
||||
self._server.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""从另一线程停止服务。"""
|
||||
self._server.stop()
|
||||
|
||||
# ── 消息调度 -------------------------------------------------------
|
||||
def _dispatch(self, sock, addr, text):
|
||||
"""解析 JSON 中的 _route,分发给对应的 handler,并回复结果。"""
|
||||
try:
|
||||
req = json.loads(text)
|
||||
except json.JSONDecodeError as e:
|
||||
self._reply(sock, False, error=f"JSON 解析失败: {e}")
|
||||
return
|
||||
|
||||
route = req.pop("_route", "")
|
||||
handler = self._routes.get(route)
|
||||
if handler is None:
|
||||
self._reply(sock, False, error=f"未知路由: {route}")
|
||||
return
|
||||
|
||||
try:
|
||||
result = handler(req)
|
||||
self._reply(sock, True, data=result)
|
||||
except Exception as e:
|
||||
logs.error("smtp", f"路由 {route} 处理异常: {e}")
|
||||
self._reply(sock, False, error=str(e))
|
||||
|
||||
def _reply(self, sock, success, data=None, error=None):
|
||||
resp = {"success": success}
|
||||
if data is not None:
|
||||
resp["data"] = data
|
||||
if error is not None:
|
||||
resp["error"] = error
|
||||
self._server.reply(sock, json.dumps(resp, ensure_ascii=False))
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 路由处理
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
+38
-42
@@ -1,8 +1,7 @@
|
||||
"""
|
||||
统一日志客户端 — 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表。
|
||||
"""统一日志客户端 — 各服务共用(dnfs / smtp / static 三份副本内容一致,改动请同步)。
|
||||
|
||||
设计要点:
|
||||
- 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表
|
||||
- 通过 pymsg RPC 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表
|
||||
- 异步非阻塞:内存队列 + 后台线程批量推送,不拖慢业务
|
||||
- fail-safe:db 服务不可用时静默丢弃,绝不影响业务
|
||||
- 缓冲批量:队列满 50 条或超时 2 秒时自动 flush
|
||||
@@ -10,8 +9,10 @@
|
||||
用法:
|
||||
from log_client import logs
|
||||
|
||||
logs.info("static", "服务启动成功")
|
||||
logs.error("static", "文件系统未启用", extra={"path": "/data"})
|
||||
logs.info("dnfs", "节点新增成功 id=42")
|
||||
logs.error("dnfs", "数据库连接失败", extra={"errno": 2002})
|
||||
logs.push("gateway", "WARNING", "登录失败次数超限",
|
||||
trace_id="req-abc", extra={"ip": "1.2.3.4"})
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -24,12 +25,12 @@ import atexit
|
||||
from datetime import datetime, timezone
|
||||
|
||||
_sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), ".."))
|
||||
from pymsg import MsgClient # noqa: E402
|
||||
from pymsg import RpcClient, RpcError # noqa: E402
|
||||
|
||||
# ── 配置 ──────────────────────────────────────────────────────────────
|
||||
|
||||
_DBV2_ADDR = '127.0.0.1'
|
||||
_DBV2_PORT = 8005
|
||||
_DBV2_ADDR = _os.environ.get('DB_HOST', '127.0.0.1')
|
||||
_DBV2_PORT = int(_os.environ.get('DB_PORT', '8005'))
|
||||
|
||||
_BATCH_SIZE = 50 # 队列满 50 条时 flush
|
||||
_FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush
|
||||
@@ -37,12 +38,9 @@ _TIMEOUT = 5 # socket 超时(秒)
|
||||
|
||||
|
||||
class LogClient:
|
||||
"""异步批量日志推送客户端。
|
||||
"""异步批量日志推送客户端。"""
|
||||
|
||||
通过 MsgClient(pymsg TCP 协议)逐条 INSERT 到 dbv2 的 Logs 表。
|
||||
"""
|
||||
|
||||
def __init__(self, service_name="static",
|
||||
def __init__(self, service_name="log",
|
||||
batch_size=_BATCH_SIZE,
|
||||
flush_interval=_FLUSH_INTERVAL):
|
||||
self._service_name = service_name
|
||||
@@ -136,35 +134,33 @@ class LogClient:
|
||||
self._send(batch)
|
||||
|
||||
def _send(self, batch):
|
||||
"""通过 pymsg 使用 execute 路由逐条 INSERT 到 dbv2 Logs 表。"""
|
||||
"""通过 pymsg RPC(execute 路由)逐条 INSERT 到 dbv2 Logs 表。"""
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
try:
|
||||
client = MsgClient(_DBV2_ADDR, _DBV2_PORT, self._service_name + "-log")
|
||||
for entry in batch:
|
||||
extra_val = entry.get("extra")
|
||||
payload = {
|
||||
"_route": "execute",
|
||||
"action": "insert",
|
||||
"table": "Logs",
|
||||
"values": {
|
||||
"ts": now,
|
||||
"service": entry.get("service"),
|
||||
"level": entry.get("level"),
|
||||
"message": entry.get("message"),
|
||||
"trace_id": entry.get("trace_id"),
|
||||
"extra": json.dumps(extra_val, ensure_ascii=False)
|
||||
if extra_val is not None else None,
|
||||
},
|
||||
}
|
||||
resp = client.call(json.dumps(payload), timeout=_TIMEOUT)
|
||||
resp_obj = json.loads(resp)
|
||||
if not resp_obj.get("success"):
|
||||
import sys
|
||||
print(f"[log_client] INSERT 失败: {resp_obj.get('error')}",
|
||||
file=sys.stderr)
|
||||
except Exception as e:
|
||||
import sys
|
||||
print(f"[log_client] 发送失败: {e}", file=sys.stderr)
|
||||
client = RpcClient(_DBV2_ADDR, _DBV2_PORT, self._service_name + "-log")
|
||||
for entry in batch:
|
||||
extra_val = entry.get("extra")
|
||||
payload = {
|
||||
"action": "insert",
|
||||
"table": "Logs",
|
||||
"values": {
|
||||
"ts": now,
|
||||
"service": entry.get("service"),
|
||||
"level": entry.get("level"),
|
||||
"message": entry.get("message"),
|
||||
"trace_id": entry.get("trace_id"),
|
||||
"extra": json.dumps(extra_val, ensure_ascii=False)
|
||||
if extra_val is not None else None,
|
||||
},
|
||||
}
|
||||
try:
|
||||
client.call("execute", payload, timeout=_TIMEOUT)
|
||||
except RpcError as e:
|
||||
# 业务错误(如表结构不符):打印后继续下一条
|
||||
print(f"[log_client] INSERT 失败: {e}", file=_sys.stderr)
|
||||
except Exception as e:
|
||||
# 网络错误:db 不可用,放弃本批剩余(fail-safe)
|
||||
print(f"[log_client] 发送失败: {e}", file=_sys.stderr)
|
||||
break
|
||||
|
||||
def _shutdown(self):
|
||||
"""进程退出时 flush 剩余日志。"""
|
||||
@@ -174,4 +170,4 @@ class LogClient:
|
||||
|
||||
# ── 全局单例 ──────────────────────────────────────────────────────────
|
||||
|
||||
logs = LogClient(service_name="static")
|
||||
logs = LogClient()
|
||||
Reference in new issue
Block a user