日志组件与日志修补¶
日志往往在程序出问题时才被认真对待,但对异步 bot 而言,它也是观察并发处理、协议连接与插件运行状态的主要窗口。melobot 的日志系统并不要求所有代码共用同一个日志器;它根据记录发生的位置和当前 bot 上下文,在运行时选择真正接收日志的对象。
本文会说明这套选择规则、通用日志接口,以及如何接入标准库和常见的第三方日志器。
日志器是如何被选择的¶
在业务模块中,通常只需导入动态日志代理并直接使用:
from melobot.log import logger
logger.info("天气插件已加载")
这里的 logger 并非某个固定的全局实例,而是一个与导入位置所在文件关联的代理对象。每次记录日志时,代理都会按以下顺序解析实际日志器:
顺序 |
日志域 |
何时使用 |
|---|---|---|
1 |
模块域 |
当前文件或其父目录/父包配置了日志器 |
2 |
bot 域 |
上述没有可用日志器,当前处于 bot 上下文,且该 bot 初始化时提供了 |
3 |
顶级域 |
上述两者都没有可用日志器 |
4 |
丢弃 |
顶级域也被设为 |
模块域按文件路径形成一棵树。假设调用代码位于 plugins/weather/handlers.py,系统会先找该文件对应结点的日志器;没有则向 plugins/weather、plugins 乃至更高层目录逐级查找。找到的第一个日志器便会生效,因此更具体的配置会覆盖更宽泛的配置。
配置三个日志域¶
顶级域是整个进程的最终兜底。melobot 默认已经为它配置了 Logger("[global]"),所以不做任何设置时也能看到框架日志。主入口可以按需替换它:
from melobot.log import Logger, LogLevel, set_global_logger
set_global_logger(Logger("app", level=LogLevel.INFO, to_dir="logs"))
bot 域由创建 Bot 时的 logger 参数设置:
from melobot import Bot
from melobot.log import Logger, LogLevel
bot = Bot("bot_a", logger=Logger("bot_a", level=LogLevel.INFO))
只要日志调用点没有模块域配置,在 bot 的处理流中调用 logger.info(...) 就会落到 bot 域的日志器上。
模块域使用 set_module_logger() 设置。第一个参数可以是可导入的模块名、模块对象,或一个存在的文件/目录路径:
from melobot.log import Logger, LogLevel, NullLogger, set_module_logger
# 为 melobot 自身及其子模块设置较详细的日志
set_module_logger("melobot", Logger("framework", level=LogLevel.DEBUG))
# 为本地插件目录及其后代设置单独日志器
set_module_logger("./plugins", Logger("plugins", to_dir="logs"))
# 精确屏蔽一个噪声较大的模块
set_module_logger("melobot.bot.dispatch", NullLogger())
None 与 NullLogger 不同
将某个模块域设为 None 表示“这里没有配置”,解析仍会继续向父模块、bot 域和顶级域回退。若要明确屏蔽此模块及其后代的日志,应设置 NullLogger;它是一个真正的空日志器,会终止向上查找并丢弃所有记录。
域配置会影响整个进程,适合放在主入口或统一的日志配置模块中。插件和工具模块应当只取得 logger 并记录日志,不应擅自修改顶级域或其他模块的域配置。
get_logger() 与导入 logger 的效果相同。绝大多数情况下推荐后者:它可以在模块顶层导入一次,代理仍会在每次调用时重新依据当前 bot 上下文选择日志器。
通用日志接口¶
GenericLogger 是 melobot 对日志器的最小抽象。bot 的 logger 参数、模块域和顶级域都接受符合这个接口的对象。它包含两类方法:
方法 |
用途 |
|---|---|
|
记录普通消息 |
|
在 |
|
仅在需要输出时计算较昂贵的消息参数 |
|
记录消息以及一个对象的可读表示 |
|
记录当前异常,并附带相关对象 |
普通消息可以直接使用 f-string:
logger.info(f"已连接到上游,账号:{self_id}")
try:
await request_api()
except Exception:
logger.exception("请求上游接口失败")
若生成消息参数本身很昂贵,例如序列化大型事件或计算诊断信息,应使用 generic_lazy()。它使用 %s 占位符,而每个参数由一个无参函数提供:
from melobot.log import LogLevel, logger
logger.generic_lazy(
"收到事件 %s,完整内容:%s",
lambda: event.id,
lambda: serialize_event_for_debug(event),
level=LogLevel.DEBUG,
)
此方法会先检查目标等级是否开启,再调用这些函数;因此在生产环境关闭 DEBUG 后,serialize_event_for_debug 不会执行。
generic_obj() 适合在诊断信息中输出结构化对象;内置日志器会使用 Rich 进行可读渲染:
logger.generic_obj(
"无法解析输入包,相关数据:",
{"packet": packet, "source": source, "retry": retry_count},
level=LogLevel.ERROR,
)
try:
await action.execute()
except Exception:
logger.generic_exc("行为执行失败", {"action": action, "event": event})
generic_exc() 等价于先调用 exception,再以 ERROR 级别记录对象。
依赖注入取得的日志器¶
日志器同样是自动依赖项:
from melobot import Bot
from melobot.log import GenericLogger
async def setup(bot: Bot, logger: GenericLogger) -> None:
logger.info(f"正在初始化 {bot.name}")
这里注入的是当前 bot 的 logger 属性,不是按模块域解析后的代理。如果 bot 初始化时没有提供日志器,该依赖无法满足。因此,想要遵守模块域规则时使用 from melobot.log import logger;想要明确把日志器作为处理函数或 hook 的依赖时,使用 GenericLogger 注解。
内置 Logger¶
Logger 基于标准库 logging.Logger,实现了 GenericLogger,并额外提供彩色控制台、Rich 对象渲染、异常格式化和滚动文件输出。多数项目直接使用它即可:
from melobot import Bot
from melobot.log import Logger, LogLevel
app_logger = Logger(
"my-bot",
level=LogLevel.INFO,
file_level=LogLevel.DEBUG,
to_console=True,
to_dir="logs",
two_stream=True,
)
bot = Bot("my-bot", logger=app_logger)
常用初始化参数如下:
参数 |
默认值 |
说明 |
|---|---|---|
|
|
显示在日志中的日志器名称,也是文件名前缀 |
|
|
控制台输出的最低等级 |
|
|
文件输出的最低等级 |
|
|
是否输出到控制台; |
|
|
日志目录;提供后启用滚动文件输出 |
|
|
是否将普通日志与 |
|
|
是否在每行中显示 |
|
|
使用传统的按日志等级着色样式,而不是 Rich 内容渲染样式 |
|
|
非传统样式下,是否分别突出显示警告内容 |
|
|
非传统样式下,是否分别突出显示错误内容 |
|
|
在主线程的运行期异步渲染日志内容,降低渲染阻塞 |
提供 to_dir 时,每个文件达到 1 MiB 会滚动,保留最多 10 个备份。two_stream=False 时写入 <name>.log;设为 True 后,DEBUG、INFO 写入 <name>.out.log,WARNING 及以上写入 <name>.err.log。若目录不存在,Logger 会创建最后一级目录;因此其父目录需要已经存在。
is_parallel=True 会把 Rich 格式化交给独立的渲染任务处理,适合大量、复杂对象的日志;代价是少量相邻日志可能改变显示顺序。
创建后可使用 set_level() 修改控制台等级:
app_logger.set_level(LogLevel.DEBUG)
该方法不会改变文件 handler 的等级;需要保留文件调试信息、但让控制台保持简洁时,这正是预期行为。
修补已有日志器¶
已有项目可能已经使用标准库、Loguru 或 Structlog。此时无需重写整套日志配置:logger_patch() 会在原日志器对象上补充 generic_lazy、generic_obj 和 generic_exc,并将它标记为兼容的 GenericLogger。
修补会原地修改对象
logger_patch 返回的仍是传入的日志器对象,只是其接口被扩展。请在完成第三方日志器自身的 handler、sink 或 processor 配置后再修补,并将返回值提供给 Bot 或日志域配置。
标准库 logging¶
标准库日志器使用 StandardPatch:
import logging
from melobot import Bot
from melobot.log import StandardPatch, logger_patch
std_logger = logging.getLogger("my-bot")
std_logger.setLevel(logging.DEBUG)
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter("%(asctime)s | %(levelname)s | %(message)s"))
std_logger.addHandler(handler)
bot_logger = logger_patch(std_logger, StandardPatch(std_logger))
bot = Bot("my-bot", logger=bot_logger)
StandardPatch 会通过标准库的等级判断实现惰性参数求值,并保留当前异常信息。标准库 handler、formatter、传播规则仍完全由你的 logging 配置决定。
Loguru¶
Loguru 使用 LoguruPatch。先按 Loguru 的方式配置 sink,再进行修补:
import sys
from loguru import logger as loguru_logger
from melobot import Bot
from melobot.log import LoguruPatch, logger_patch
loguru_logger.remove()
loguru_logger.add(sys.stderr, level="INFO")
bot_logger = logger_patch(loguru_logger, LoguruPatch(loguru_logger))
bot = Bot("my-bot", logger=bot_logger)
Loguru 使用 {} 格式化,而 melobot 的 generic_lazy 使用 %s。LoguruPatch 会在转发时完成这种占位符转换,并通过 Loguru 的 lazy=True 保持惰性求值。普通的 debug、info 等调用仍遵守 Loguru 自身的格式化语法。
Structlog¶
Structlog 使用 StructlogPatch:
import structlog
from melobot import Bot
from melobot.log import StructlogPatch, logger_patch
# processor、renderer 等配置按项目需要完成
struct_logger = structlog.get_logger("my-bot")
bot_logger = logger_patch(struct_logger, StructlogPatch(struct_logger))
bot = Bot("my-bot", logger=bot_logger)
StructlogPatch 将消息和参数直接交给 Structlog,因此 JSON renderer、上下文字段和 processor 链仍由 Structlog 配置控制。需要注意的是,当前实现会在调用 Structlog 前执行所有 generic_lazy 的参数函数;它主要用于接口兼容,不保证关闭日志等级时跳过昂贵计算。此类场景可在调用前自行判断等级,或实现下节的自定义修补器。
自定义修补器¶
要接入其他日志库,目标对象首先需要提供 debug、info、warning、error、critical 与 exception 方法。随后实现一个符合 LazyLogMethod 调用约定的对象,并交给 logger_patch。下面的例子假设第三方日志器有 enabled(level) 用于判断等级:
from typing import Any, Callable
from melobot.log import LogLevel, logger_patch
class MyLazyPatch:
def __init__(self, target: Any) -> None:
self.target = target
def __call__(
self,
msg: str,
*arg_getters: Callable[[], Any],
level: LogLevel,
with_exc: bool = False,
) -> None:
# 必须先判断,再执行 getter,才能真正保持惰性
if not self.target.enabled(level):
return
message = msg % tuple(getter() for getter in arg_getters)
if with_exc:
self.target.exception(message)
return
methods = {
LogLevel.DEBUG: self.target.debug,
LogLevel.INFO: self.target.info,
LogLevel.WARNING: self.target.warning,
LogLevel.ERROR: self.target.error,
LogLevel.CRITICAL: self.target.critical,
}
methods[level](message)
third_party_logger = create_third_party_logger(...)
bot_logger = logger_patch(third_party_logger, MyLazyPatch(third_party_logger))
自定义实现的关键在于三点:只在确实会记录时调用 arg_getters;with_exc=True 时使用目标库能保留当前异常栈的方法;明确处理 LogLevel 到目标库等级或方法的映射。若目标库支持结构化字段,也可以不进行 % 插值,而是将 msg 和计算后的参数作为字段传入。
事件循环中的未捕获异常¶
调用 run() 或 run_async() 时,默认会临时安装 melobot 的事件循环异常处理器,并通过当前日志机制报告未捕获异常。strict_log=False(默认)会把“从未取回的任务异常”视为调试信息;设为 True 后会以错误级别打印完整异常栈:
bot.run(strict_log=True)
若宿主程序已经接管事件循环的异常策略,可将 use_exc_handler=False,保留其原有处理器。无论哪种方式,都应尽量在自己的协程中捕获并记录可预期的业务异常;事件循环异常处理器只应作为最后一道诊断防线。
总结¶
业务模块使用动态 logger 即可获得模块优先、bot 次之、全局兜底的日志路由。需要统一配置时,在主入口设置日志域;需要可移植的高级记录时依赖 GenericLogger;已有项目则通过修补器保留原有日志生态。这样既能让框架和插件共享一致的诊断能力,也不会强迫整个项目迁移到单一日志库。
下一篇将介绍:实用组件。