日志组件与日志修补

日志往往在程序出问题时才被认真对待,但对异步 bot 而言,它也是观察并发处理、协议连接与插件运行状态的主要窗口。melobot 的日志系统并不要求所有代码共用同一个日志器;它根据记录发生的位置当前 bot 上下文,在运行时选择真正接收日志的对象。

本文会说明这套选择规则、通用日志接口,以及如何接入标准库和常见的第三方日志器。

日志器是如何被选择的

在业务模块中,通常只需导入动态日志代理并直接使用:

from melobot.log import logger

logger.info("天气插件已加载")

这里的 logger 并非某个固定的全局实例,而是一个与导入位置所在文件关联的代理对象。每次记录日志时,代理都会按以下顺序解析实际日志器:

顺序

日志域

何时使用

1

模块域

当前文件或其父目录/父包配置了日志器

2

bot 域

上述没有可用日志器,当前处于 bot 上下文,且该 bot 初始化时提供了 logger

3

顶级域

上述两者都没有可用日志器

4

丢弃

顶级域也被设为 None,此时丢弃日志

模块域按文件路径形成一棵树。假设调用代码位于 plugins/weather/handlers.py,系统会先找该文件对应结点的日志器;没有则向 plugins/weatherplugins 乃至更高层目录逐级查找。找到的第一个日志器便会生效,因此更具体的配置会覆盖更宽泛的配置。

配置三个日志域

顶级域是整个进程的最终兜底。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 参数、模块域和顶级域都接受符合这个接口的对象。它包含两类方法:

方法

用途

debuginfowarningerrorcritical

记录普通消息

exception

except 块中记录消息与当前异常栈

generic_lazy

仅在需要输出时计算较昂贵的消息参数

generic_obj

记录消息以及一个对象的可读表示

generic_exc

记录当前异常,并附带相关对象

普通消息可以直接使用 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)

常用初始化参数如下:

参数

默认值

说明

name

"[default]"

显示在日志中的日志器名称,也是文件名前缀

level

INFO

控制台输出的最低等级

file_level

DEBUG

文件输出的最低等级

to_console

True

是否输出到控制台;ERROR 及以上会写入 stderr,其余写入 stdout

to_dir

None

日志目录;提供后启用滚动文件输出

two_stream

False

是否将普通日志与 WARNING 以上的问题日志分开写入文件

add_tag

True

是否在每行中显示 name 标签

legacy

False

使用传统的按日志等级着色样式,而不是 Rich 内容渲染样式

yellow_warn

True

非传统样式下,是否分别突出显示警告内容

red_error

True

非传统样式下,是否分别突出显示错误内容

is_parallel

False

在主线程的运行期异步渲染日志内容,降低渲染阻塞

提供 to_dir 时,每个文件达到 1 MiB 会滚动,保留最多 10 个备份。two_stream=False 时写入 <name>.log;设为 True 后,DEBUGINFO 写入 <name>.out.logWARNING 及以上写入 <name>.err.log。若目录不存在,Logger 会创建最后一级目录;因此其父目录需要已经存在。

is_parallel=True 会把 Rich 格式化交给独立的渲染任务处理,适合大量、复杂对象的日志;代价是少量相邻日志可能改变显示顺序。

创建后可使用 set_level() 修改控制台等级:

app_logger.set_level(LogLevel.DEBUG)

该方法不会改变文件 handler 的等级;需要保留文件调试信息、但让控制台保持简洁时,这正是预期行为。

修补已有日志器

已有项目可能已经使用标准库、Loguru 或 Structlog。此时无需重写整套日志配置:logger_patch() 会在原日志器对象上补充 generic_lazygeneric_objgeneric_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 使用 %sLoguruPatch 会在转发时完成这种占位符转换,并通过 Loguru 的 lazy=True 保持惰性求值。普通的 debuginfo 等调用仍遵守 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 的参数函数;它主要用于接口兼容,不保证关闭日志等级时跳过昂贵计算。此类场景可在调用前自行判断等级,或实现下节的自定义修补器。

自定义修补器

要接入其他日志库,目标对象首先需要提供 debuginfowarningerrorcriticalexception 方法。随后实现一个符合 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_getterswith_exc=True 时使用目标库能保留当前异常栈的方法;明确处理 LogLevel 到目标库等级或方法的映射。若目标库支持结构化字段,也可以不进行 % 插值,而是将 msg 和计算后的参数作为字段传入。

事件循环中的未捕获异常

调用 run()run_async() 时,默认会临时安装 melobot 的事件循环异常处理器,并通过当前日志机制报告未捕获异常。strict_log=False(默认)会把“从未取回的任务异常”视为调试信息;设为 True 后会以错误级别打印完整异常栈:

bot.run(strict_log=True)

若宿主程序已经接管事件循环的异常策略,可将 use_exc_handler=False,保留其原有处理器。无论哪种方式,都应尽量在自己的协程中捕获并记录可预期的业务异常;事件循环异常处理器只应作为最后一道诊断防线。

总结

业务模块使用动态 logger 即可获得模块优先、bot 次之、全局兜底的日志路由。需要统一配置时,在主入口设置日志域;需要可移植的高级记录时依赖 GenericLogger;已有项目则通过修补器保留原有日志生态。这样既能让框架和插件共享一致的诊断能力,也不会强迫整个项目迁移到单一日志库。

下一篇将介绍:实用组件