From 3f4b003fea1dd414afd6640294129499312854f9 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:22:49 +0800 Subject: [PATCH 1/2] =?UTF-8?q?refactor(logging):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E8=BF=90=E8=A1=8C=E6=80=81=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E5=88=86=E6=B5=81=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在框架层新增项目运行态日志分流能力 - 保持默认 OneDragon 日志行为不变,由项目显式启用分流 - 使用 delay=True 减少日志文件提前占用 --- docs/develop/one_dragon/modules/logger.md | 169 ++++++++++++++++++ src/one_dragon/utils/log_utils.py | 199 ++++++++++++++++++++-- 2 files changed, 351 insertions(+), 17 deletions(-) create mode 100644 docs/develop/one_dragon/modules/logger.md diff --git a/docs/develop/one_dragon/modules/logger.md b/docs/develop/one_dragon/modules/logger.md new file mode 100644 index 0000000000..e98d109c06 --- /dev/null +++ b/docs/develop/one_dragon/modules/logger.md @@ -0,0 +1,169 @@ +# Runner 日志分流设计 + +## 背景 + +`script_runner` 以独立命令行进程运行脚本链。这个进程里会同时出现两类日志: + +- runner 主流程日志:脚本链状态、脚本 stdout、启动失败、监控超时、进程清理异常。 +- OneDragon 框架日志:配置读取、上下文初始化、推送服务、HTTP 请求、通知渠道异常。 + +这两类日志关注点不同。runner 主日志面向脚本链运行排查,框架日志面向底层服务排查。它们如果写入同一个轮转文件,在 Windows 下还可能因为多个 `TimedRotatingFileHandler` 同时持有同一个文件句柄,导致归档时出现 `WinError 32`。 + +## 目标 + +- GUI 默认日志行为不变,继续使用框架默认 `log.txt`。 +- runner 主流程日志写入 `script_chainer_runner.log`。 +- runner 进程中触发的框架日志写入 `script_chainer_framework.log`。 +- 不让两个 logger 共享同一个文件 handler 或同一个轮转文件。 +- 日志分流能力由 OneDragon 框架提供,但默认不启用。 +- 各项目根据自己的运行场景显式开启日志分流。 + +## 非目标 + +- 不重写所有框架模块的 logger 获取方式。 +- 不把 OneDragon 全局 `log` 改成依赖注入。 +- 不改变 GUI、配置编辑器等常规进程的默认日志文件。 + +## 框架层设计 + +`one_dragon.utils.log_utils` 提供通用日志配置能力。 + +默认 logger: + +```text +OneDragon -> /.log/log.txt +``` + +显式分流入口: + +```python +configure_project_runtime_logging( + project_logger_name: str, + project_log_file_path: str, + framework_log_file_path: str, + *, + level: int = logging.INFO, + project_add_console_handler: bool = False, + framework_add_console_handler: bool = False, + framework_logger_name: str = LOGGER_NAME, +) -> ProjectRuntimeLoggingContext +``` + +这个 API 会分别配置: + +```text +project_logger_name -> project_log_file_path +framework_logger_name -> framework_log_file_path +``` + +返回值 `ProjectRuntimeLoggingContext` 包含: + +- `project_logger` +- `framework_logger` +- `project_log_file_path` +- `framework_log_file_path` + +该能力是 opt-in。项目不调用 `configure_project_runtime_logging(...)` 时,框架默认日志仍然写 `log.txt`。 + +## Handler 管理 + +框架创建的 handler 会带上 `_one_dragon_logger_owner` 标记。重新配置 logger 时,只移除同一 logger 上由框架托管的 handler,不移除外部手动挂载的 handler。 + +这样可以支持: + +- 默认 logger 初始化。 +- 运行态重新配置。 +- 保留第三方或调用方额外挂载的 handler。 + +文件日志使用 `TimedRotatingFileHandler`: + +```text +when = midnight +interval = 1 +backupCount = 3 +delay = True +``` + +`delay=True` 表示 handler 创建时不立即打开文件,第一次写日志时才打开。这样可以减少 import 阶段的文件句柄占用。 + +## ScriptChainer 适配层 + +`script_chainer.win_exe.runner_logging` 是 ScriptChainer runner 的日志适配层。它不重新实现日志系统,只定义 runner 场景下的项目配置: + +```text +RUNNER_LOGGER_NAME = ScriptChainerRunner +RUNNER_LOG_FILE_NAME = script_chainer_runner.log +RUNNER_FRAMEWORK_LOG_FILE_NAME = script_chainer_framework.log +``` + +路径策略: + +- 打包运行时:使用 `sys.executable` 所在目录下的 `.log/` +- 源码运行时:沿用框架默认工作目录 `.log/` + +runner 适配层暴露: + +```python +log = logging.getLogger(RUNNER_LOGGER_NAME) +configure_runner_runtime_logging() +``` + +`configure_runner_runtime_logging()` 内部调用框架的 `configure_project_runtime_logging(...)`。 + +## Runner 使用方式 + +`script_runner.py` 只导入 runner 适配层提供的 logger 和配置函数: + +```python +from script_chainer.utils.runner_logging import ( + configure_runner_runtime_logging, + log, +) +``` + +在 `run_chain()` 开始时调用: + +```python +configure_runner_runtime_logging() +``` + +之后 `script_runner.py` 中的 `log.info(...)` / `log.error(...)` 都写入 `script_chainer_runner.log`。 + +runner 进程中框架模块直接使用的 `one_dragon.utils.log_utils.log` 会写入 `script_chainer_framework.log`。 + +## 日志文件布局 + +打包运行时: + +```text +/.log/script_chainer_runner.log +/.log/script_chainer_framework.log +``` + +GUI 默认: + +```text +/.log/log.txt +``` + +## 失败模式与规避 + +Windows 下同一个日志文件不能可靠地被多个 `TimedRotatingFileHandler` 同时轮转。当前设计通过分文件规避: + +```text +ScriptChainerRunner -> script_chainer_runner.log +OneDragon -> script_chainer_framework.log +``` + +每个日志文件只有一套框架托管的 file handler,归档时不会互相占用同一个文件。 + +## 权衡 + +当前 OneDragon 代码中很多模块直接使用全局 `log`。完整改造成 logger 注入会带来较大改动。日志分流选择在进程启动时显式重定向框架 logger,可以用较小改动达成隔离目标。 + +这也保留了清晰边界: + +- 框架提供可选分流能力。 +- 项目决定何时启用。 +- runner 只关心自己的 logger。 +- GUI 默认行为不受影响。 diff --git a/src/one_dragon/utils/log_utils.py b/src/one_dragon/utils/log_utils.py index ed4b31f657..0364b5de0a 100644 --- a/src/one_dragon/utils/log_utils.py +++ b/src/one_dragon/utils/log_utils.py @@ -1,39 +1,168 @@ import logging import os +from contextlib import suppress +from dataclasses import dataclass from logging.handlers import TimedRotatingFileHandler from one_dragon.utils import os_utils +LOGGER_NAME = 'OneDragon' +_HANDLER_OWNER_ATTR = '_one_dragon_logger_owner' -def get_logger(): - logger = logging.getLogger('OneDragon') - logger.handlers.clear() - logger.setLevel(logging.INFO) - formatter = logging.Formatter('[%(asctime)s.%(msecs)03d] [%(filename)s %(lineno)d] [%(levelname)s]: %(message)s', '%H:%M:%S') +@dataclass(slots=True) +class LoggerConfig: + level: int = logging.INFO + log_file_path: str | None = None + default_name: str = 'log.txt' + add_console_handler: bool = True + propagate: bool = False + + +@dataclass(slots=True) +class ProjectRuntimeLoggingContext: + """项目显式启用的运行时日志分流结果。""" + + project_logger: logging.Logger + framework_logger: logging.Logger + project_log_file_path: str + framework_log_file_path: str + - log_file_path = os.path.join(os_utils.get_path_under_work_dir('.log'), 'log.txt') - archive_handler = TimedRotatingFileHandler(log_file_path, when='midnight', interval=1, backupCount=3, encoding='utf-8') - archive_handler.setLevel(logging.INFO) - archive_handler.setFormatter(formatter) - logger.addHandler(archive_handler) +def get_log_formatter() -> logging.Formatter: + return logging.Formatter( + '[%(asctime)s.%(msecs)03d] [%(filename)s %(lineno)d] [%(levelname)s]: %(message)s', + '%H:%M:%S', + ) - console_handler = logging.StreamHandler() - console_handler.setLevel(logging.INFO) - console_handler.setFormatter(formatter) - logger.addHandler(console_handler) +def configure_logger(logger: logging.Logger, config: LoggerConfig) -> logging.Logger: + """显式配置 logger。 + + 职责只有一个:将一个现成的 logger 调整到目标配置。 + 仅会替换框架自己创建的 handler,不会移除外部追加的 handler。 + """ + _close_managed_handlers(logger) + logger.setLevel(config.level) + logger.propagate = config.propagate + logger.addHandler(_build_file_handler(logger, config)) + if config.add_console_handler: + logger.addHandler(_prepare_handler(logging.StreamHandler(), logger, config)) return logger -def set_log_level(level: int) -> None: +def get_or_create_logger(name: str, config: LoggerConfig | None = None) -> logging.Logger: + """获取指定名称的 logger。 + + - 若框架尚未为该 logger 挂载默认 handler,则按给定配置初始化 + - 若已初始化过,则直接复用 + - 不会因为外部额外挂载了 handler 而跳过框架默认配置 + """ + logger = logging.getLogger(name) + if any(_handler_belongs_to_logger(handler, logger) for handler in logger.handlers): + return logger + return configure_logger(logger, config or LoggerConfig()) + + +def configure_project_runtime_logging( + project_logger_name: str, + project_log_file_path: str, + framework_log_file_path: str, + *, + level: int = logging.INFO, + project_add_console_handler: bool = False, + framework_add_console_handler: bool = False, + framework_logger_name: str = LOGGER_NAME, +) -> ProjectRuntimeLoggingContext: + """为项目运行态显式启用项目日志与框架日志分流。 + + 默认的框架日志仍然写入 `log.txt`;只有项目主动调用本函数时, + 才会把项目 logger 和框架 logger 分别切到指定文件。 + """ + if project_logger_name == framework_logger_name: + raise ValueError( + 'configure_project_runtime_logging 需要不同的 ' + 'project_logger_name 和 framework_logger_name;否则 ' + '_configure_runtime_logger 会对同一个 logger 调用两次 ' + '_close_managed_handlers,导致 ProjectRuntimeLoggingContext ' + '静默丢失其中一套 handler 配置。' + ) + + project_logger = logging.getLogger(project_logger_name) + framework_logger = logging.getLogger(framework_logger_name) + + framework_logger = _configure_runtime_logger( + framework_logger, + log_file_path=framework_log_file_path, + level=level, + add_console_handler=framework_add_console_handler, + ) + project_logger = _configure_runtime_logger( + project_logger, + log_file_path=project_log_file_path, + level=level, + add_console_handler=project_add_console_handler, + ) + return ProjectRuntimeLoggingContext( + project_logger=project_logger, + framework_logger=framework_logger, + project_log_file_path=project_log_file_path, + framework_log_file_path=framework_log_file_path, + ) + + +def _configure_runtime_logger( + logger: logging.Logger, + *, + log_file_path: str, + level: int, + add_console_handler: bool, +) -> logging.Logger: + return configure_logger( + logger, + LoggerConfig( + level=level, + log_file_path=log_file_path, + add_console_handler=add_console_handler, + propagate=False, + ), + ) + + +def get_log_file_path(log_file_path: str | None = None, default_name: str = 'log.txt') -> str: + """获取日志文件路径。 + + - 未传 `log_file_path` 时,使用工作目录 `.log/` 下的默认文件名 + - 传相对路径/文件名时,仍然放在工作目录 `.log/` 下 + - 传绝对路径时,直接使用 + """ + configured = (log_file_path or '').strip() + if not configured: + configured = default_name + if os.path.isabs(configured): + return configured + return os.path.join(os_utils.get_path_under_work_dir('.log'), configured) + + +def get_logger(): + """获取框架默认 logger。 + + 若尚未初始化,则按默认配置初始化一次;若已经存在框架默认 handler,则直接复用。 + """ + return get_or_create_logger(LOGGER_NAME, LoggerConfig()) + + +def set_log_level(level: int, logger: logging.Logger | None = None) -> None: """ 显示日志等级 :param level: :return: """ - log.setLevel(level) - for handler in log.handlers: + target = logger or log + target.setLevel(level) + for handler in target.handlers: + if not _handler_belongs_to_logger(handler, target): + continue handler.setLevel(level) @@ -51,4 +180,40 @@ def mask_text(text: str) -> str: return text[:2] + '*' * (len(text) - 4) + text[-2:] +def _close_managed_handlers(logger: logging.Logger) -> None: + for handler in list(logger.handlers): + if not _handler_belongs_to_logger(handler, logger): + continue + logger.removeHandler(handler) + with suppress(Exception): + handler.close() + + +def _handler_belongs_to_logger(handler: logging.Handler, logger: logging.Logger) -> bool: + return getattr(handler, _HANDLER_OWNER_ATTR, None) == logger.name + + +def _build_file_handler(logger: logging.Logger, config: LoggerConfig) -> logging.Handler: + handler = TimedRotatingFileHandler( + get_log_file_path(config.log_file_path, default_name=config.default_name), + when='midnight', + interval=1, + backupCount=3, + encoding='utf-8', + delay=True, + ) + return _prepare_handler(handler, logger, config) + + +def _prepare_handler( + handler: logging.Handler, + logger: logging.Logger, + config: LoggerConfig, +) -> logging.Handler: + setattr(handler, _HANDLER_OWNER_ATTR, logger.name) + handler.setLevel(config.level) + handler.setFormatter(get_log_formatter()) + return handler + + log = get_logger() From 00d96b2bd91d8eea7fe1af3bd255e9ece32567b7 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:56:36 +0800 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20=E4=BF=AE=E5=A4=8D=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/develop/one_dragon/modules/logger.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/develop/one_dragon/modules/logger.md b/docs/develop/one_dragon/modules/logger.md index e98d109c06..4dfdb12539 100644 --- a/docs/develop/one_dragon/modules/logger.md +++ b/docs/develop/one_dragon/modules/logger.md @@ -115,7 +115,7 @@ configure_runner_runtime_logging() `script_runner.py` 只导入 runner 适配层提供的 logger 和配置函数: ```python -from script_chainer.utils.runner_logging import ( +from script_chainer.win_exe.runner_logging import ( configure_runner_runtime_logging, log, )