feat(server): 支持 Granian 与 rloop/uvloop/winloop 事件循环

- 新增 scripts/serve.py 启动器:server auto/uvicorn/granian,循环可用性与平台校验(显式不可用直接报错)
- pyproject 增加 granian 可选 extra(granian[reload,rloop,uvloop,winloop],平台 marker 由 granian 维护)
- tests/test_serve.py 覆盖服务器/循环选择与命令拼装;README 补充启动方式、循环矩阵与多 worker 限制
This commit is contained in:
snowy-yang committed 2026-10-23 22:12:08 +08:00
1 parent e168f43b81
commit 0cae770ace
5 files changed
+355 -4

No files matched your search

+171
View File
@@ -0,0 +1,171 @@
"""启动器 —— uvicorn / granian,可选事件循环 rloop / uvloop / winloop。
用法(在仓库根目录执行):
uv run python scripts/serve.py # 默认:装了 granian 就优先 granian,否则 uvicorn
uv run python scripts/serve.py --server granian # Rust HTTP 服务器
uv run python scripts/serve.py --server granian --loop rloop # 仅 Unix(granian[rloop])
uv run python scripts/serve.py --server granian --loop uvloop # 仅 Unix(granian[uvloop])
uv run python scripts/serve.py --server granian --loop winloop # 仅 Windows(granian[winloop])
uv run python scripts/serve.py --server uvicorn --loop uvloop # uvicorn 在 Unix 上等价
事件循环可用性(与 granian 的 extras / 平台 marker 一致):
asyncio 始终可用
rloop rloop 包,仅 Unix(granian 内置的 Rust 循环)
uvloop uvloop 包,仅 Unix
winloop winloop 包,仅 Windows(uvloop 的 Windows 实现)
`auto` 交给服务器自行选择:granian 优先 rloop → uvloop → winloop → asyncio;
uvicorn 优先 uvloop(已安装时)→ asyncio。
环境变量(参数优先):SERVER / LOOP / HOST / PORT / WORKERS / RELOAD。
PORT 默认读取 .env 后的 16688;HOST 默认 0.0.0.0(局域网播放器可直连)。
"""
from __future__ import annotations
import argparse
import importlib.util
import os
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from app import __version__, config # noqa: E402
APP_TARGET = "app.main:app"
SERVERS = ("auto", "uvicorn", "granian")
LOOPS = ("auto", "asyncio", "rloop", "uvloop", "winloop")
#: 每个循环对应的可导入包(asyncio 无依赖)
LOOP_PACKAGES = {"rloop": "rloop", "uvloop": "uvloop", "winloop": "winloop"}
def has_module(name: str) -> bool:
"""模块是否可导入(不实际导入,避免触发事件循环初始化)。"""
try:
return importlib.util.find_spec(name) is not None
except (ImportError, ValueError):
return False
def pick_server(requested: str) -> str:
"""auto:安装了 granian 就用 granian,否则 uvicorn。"""
if requested not in SERVERS:
raise SystemExit(f"不支持的 server: {requested}(可选:{', '.join(SERVERS)})")
if requested != "auto":
return requested
return "granian" if has_module("granian") else "uvicorn"
def available_loops(server: str) -> list[str]:
"""当前平台/安装状态下该服务器可用的事件循环(含 auto)。"""
loops = ["auto", "asyncio"]
if server == "granian":
loops.extend(name for name in ("rloop", "uvloop", "winloop") if has_module(LOOP_PACKAGES[name]))
elif has_module("uvloop"):
loops.append("uvloop")
return loops
def resolve_loop(server: str, loop: str) -> str:
"""校验事件循环可用性;显式请求不可用时给出可操作的原因(不做静默降级)。"""
if loop not in LOOPS:
raise SystemExit(f"不支持的 loop: {loop}(可选:{', '.join(LOOPS)})")
if loop == "auto":
return loop
if server == "uvicorn":
if loop == "asyncio":
return loop
if loop == "uvloop":
if not has_module("uvloop"):
raise SystemExit("uvloop 未安装(仅 Unix 可用):uv sync --extra granian 或改用 --loop auto")
return loop
raise SystemExit(f"uvicorn 不支持 --loop {loop};请改用 --server granian(granian 支持 rloop/uvloop/winloop)")
package = LOOP_PACKAGES.get(loop)
if package and not has_module(package):
hint = "仅 Windows 可用" if loop == "winloop" else "仅 Unix 可用"
raise SystemExit(
f"granian 的 {loop} 事件循环不可用({package} 未安装,{hint})。可用:{', '.join(available_loops(server))}"
)
return loop
def _env_bool(name: str) -> bool:
return os.environ.get(name, "").strip().lower() in ("1", "true", "yes", "on")
def build_command(
server: str,
*,
host: str,
port: int,
workers: int,
loop: str,
reload: bool,
) -> list[str]:
command = [sys.executable, "-m", server]
if server == "granian":
command += ["--interface", "asgi", "--host", host, "--port", str(port)]
command += ["--workers", str(workers), "--loop", loop]
if reload:
command.append("--reload")
if reload and not has_module("watchfiles"):
print("[serve] 警告:granian --reload 需要 watchfiles(uv sync --extra granian 已包含)", flush=True)
command.append(APP_TARGET)
else:
command += [APP_TARGET, "--host", host, "--port", str(port)]
command += ["--workers", str(workers), "--loop", loop]
if reload:
command.append("--reload")
return command
def _arg_or_env(value: str | None, env_name: str, default: str | None = None) -> str | None:
return value if value is not None else os.environ.get(env_name, default)
def main(argv: list[str] | None = None) -> int:
config.load_env()
parser = argparse.ArgumentParser(description="MusicServer 启动器(uvicorn / granian)")
parser.add_argument("--server", choices=SERVERS, help="HTTP 服务器(默认 auto:优先 granian)")
parser.add_argument("--loop", choices=LOOPS, help="事件循环(默认 auto)")
parser.add_argument("--host", help="监听地址(默认 0.0.0.0)")
parser.add_argument("--port", type=int, help="端口(默认 PORT 或 16688)")
parser.add_argument("--workers", type=int, help="worker 进程数(默认 1)")
parser.add_argument("--reload", action="store_true", help="开发热重载(强制单 worker)")
args = parser.parse_args(argv)
server = pick_server(str(_arg_or_env(args.server, "SERVER", "auto")))
if not has_module(server):
raise SystemExit(f"未安装 {server}:uv sync --extra granian(或 uv add {server})")
loop = resolve_loop(server, str(_arg_or_env(args.loop, "LOOP", "auto")))
host = str(_arg_or_env(args.host, "HOST", "0.0.0.0"))
port = args.port or config.port()
workers = args.workers or int(os.environ.get("WORKERS") or 1)
reload = args.reload or _env_bool("RELOAD")
if reload and workers > 1:
print(f"[serve] --reload 与多 worker 不兼容,workers 从 {workers} 改为 1", flush=True)
workers = 1
command = build_command(server, host=host, port=port, workers=workers, loop=loop, reload=reload)
print(
f"[serve] MusicServer {__version__} | {server} | loop={loop} | http://{host}:{port} "
f"| workers={workers}{' | reload' if reload else ''}",
flush=True,
)
try:
return subprocess.call(command)
except KeyboardInterrupt: # Ctrl+C 由子进程自行处理,父进程安静退出
return 0
if __name__ == "__main__":
raise SystemExit(main())