Files
trumandu--myboot/myboot/core/logger.py
T
2026-07-13 12:34:57 +08:00

417 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
日志管理模块
基于 loguru 的日志管理,提供初始化配置功能
所有代码可以直接使用: from loguru import logger
"""
import logging
import os
import re
import sys
from typing import Optional, Union
from loguru import logger as loguru_logger
from dynaconf import Dynaconf
from .config import get_settings
logger = loguru_logger
def _get_worker_info() -> str:
"""
获取当前 worker 信息
Returns:
worker 标识字符串,格式为 "Worker-1/4" 或 "Main" (主进程)
"""
worker_id = os.environ.get("MYBOOT_WORKER_ID")
worker_count = os.environ.get("MYBOOT_WORKER_COUNT")
if worker_id and worker_count:
return f"Worker-{worker_id}/{worker_count}"
return "Main" # Main process
def _get_log_format_with_worker() -> str:
"""
获取带 worker 标识的日志格式
Returns:
日志格式字符串
"""
return (
"<green>{time:YYYY-MM-DD HH:mm:ss}</green> | "
"<yellow>{extra[worker]}</yellow> | "
"<level>{level: <8}</level> | "
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - "
"<level>{message}</level>"
)
def _get_log_format_simple() -> str:
"""
获取简单日志格式(无 worker 标识)
Returns:
日志格式字符串
"""
return (
"<green>{time:YYYY-MM-DD HH:mm:ss}</green> | "
"<level>{level: <8}</level> | "
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - "
"<level>{message}</level>"
)
def _parse_json_config(value) -> bool:
"""解析 JSON 配置值为布尔值"""
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.lower() in ("true", "1", "yes", "on")
return bool(value)
def _convert_logging_format_to_loguru(user_format: str) -> str:
"""
将标准 logging 格式转换为 loguru 格式
Args:
user_format: 用户提供的日志格式字符串
Returns:
转换后的 loguru 格式字符串
"""
format_mapping = {
"%(asctime)s": "{time:YYYY-MM-DD HH:mm:ss}",
"%(name)s": "{name}",
"%(levelname)s": "{level: <8}",
"%(message)s": "{message}",
"%(filename)s": "{file.name}",
"%(funcName)s": "{function}",
"%(lineno)d": "{line}",
}
result = user_format
for old, new in format_mapping.items():
result = result.replace(old, new)
return result
def _build_json_handler_kwargs(log_level: str) -> tuple[dict, dict]:
"""
构建 JSON 格式的 handler 参数
Returns:
(console_kwargs, file_kwargs) 元组
"""
console_kwargs = {
"sink": sys.stdout,
"serialize": True,
"level": log_level,
"backtrace": True,
"diagnose": True,
}
file_kwargs = {
"serialize": True,
"level": log_level,
"rotation": "10 MB",
"retention": "7 days",
"compression": "zip",
"backtrace": True,
"diagnose": True,
}
return console_kwargs, file_kwargs
def _build_text_handler_kwargs(log_format: str, log_level: str) -> tuple[dict, dict]:
"""
构建文本格式的 handler 参数
Returns:
(console_kwargs, file_kwargs) 元组
"""
console_kwargs = {
"sink": sys.stdout,
"format": log_format,
"level": log_level,
"colorize": True,
"backtrace": True,
"diagnose": True,
}
file_kwargs = {
"format": log_format,
"level": log_level,
"rotation": "10 MB",
"retention": "7 days",
"compression": "zip",
"backtrace": True,
"diagnose": True,
}
return console_kwargs, file_kwargs
def _get_third_party_config(config_obj) -> dict:
"""获取第三方库日志配置"""
try:
return config_obj.logging.third_party
except (AttributeError, KeyError):
pass
try:
return config_obj.get("logging.third_party", {})
except (AttributeError, KeyError):
return {}
def _configure_third_party_loggers(third_party_config: dict) -> None:
"""配置第三方库的日志级别"""
if not isinstance(third_party_config, dict):
return
for logger_name, level_name in third_party_config.items():
if isinstance(level_name, str):
std_logger = logging.getLogger(logger_name)
level = getattr(logging, level_name.upper(), logging.INFO)
std_logger.setLevel(level)
def _add_file_handler(log_file: str, file_kwargs: dict) -> None:
"""添加文件日志 handler"""
log_dir = os.path.dirname(log_file)
if log_dir:
os.makedirs(log_dir, exist_ok=True)
loguru_logger.add(log_file, **file_kwargs)
def setup_logging(config: Optional[Union[str, Dynaconf]] = None, enable_worker_info: bool = True) -> None:
"""
根据配置文件或配置对象初始化 loguru 日志系统
Args:
config: 配置文件路径或配置对象(Dynaconf),如果为 None 则使用默认配置
enable_worker_info: 是否启用 worker 信息显示(多 worker 模式下自动启用)
"""
# 获取配置对象
config_obj = config if isinstance(config, Dynaconf) else get_settings(config)
# 移除默认的 handler 并配置 worker 上下文
loguru_logger.remove()
loguru_logger.configure(extra={"worker": _get_worker_info()})
# 获取日志级别
log_level = config_obj.get("logging.level", "INFO").upper()
# 构建 handler 参数
use_json = _parse_json_config(config_obj.get("logging.json", False))
if use_json:
console_kwargs, file_kwargs = _build_json_handler_kwargs(log_level)
else:
# 确定日志格式
user_format = config_obj.get("logging.format", None)
if user_format:
log_format = _convert_logging_format_to_loguru(user_format)
else:
worker_count = os.environ.get("MYBOOT_WORKER_COUNT")
is_multi_worker = worker_count and int(worker_count) > 1
log_format = _get_log_format_with_worker() if (is_multi_worker and enable_worker_info) else _get_log_format_simple()
console_kwargs, file_kwargs = _build_text_handler_kwargs(log_format, log_level)
# 添加 handlers
loguru_logger.add(**console_kwargs)
log_file = config_obj.get("logging.file")
if log_file:
_add_file_handler(log_file, file_kwargs)
# 配置第三方库日志
_configure_third_party_loggers(_get_third_party_config(config_obj))
def configure_worker_logger(worker_id: int, total_workers: int) -> None:
"""
配置 worker 进程的日志上下文
在多 worker 模式下,每个 worker 进程启动时调用此函数,
以便在日志中显示 worker 标识
Args:
worker_id: Worker 进程 ID (从 1 开始)
total_workers: 总 worker 数量
"""
worker_info = f"Worker-{worker_id}/{total_workers}"
loguru_logger.configure(extra={"worker": worker_info})
def setup_worker_logging(worker_id: int, total_workers: int, config: Optional[Union[str, Dynaconf]] = None) -> None:
"""
为 worker 进程重新初始化日志系统
在多 worker 模式下,每个 worker 进程启动后调用此函数,
重新配置日志格式以显示 worker 标识
Args:
worker_id: Worker 进程 ID (从 1 开始)
total_workers: 总 worker 数量
config: 配置文件路径或配置对象(Dynaconf),如果为 None 则使用默认配置
"""
# 获取配置对象
config_obj = config if isinstance(config, Dynaconf) else get_settings(config)
# 获取日志级别
log_level = config_obj.get("logging.level", "INFO").upper()
# 移除现有的 handler
loguru_logger.remove()
# 设置 worker 上下文
worker_info = f"Worker-{worker_id}/{total_workers}"
loguru_logger.configure(extra={"worker": worker_info})
# 使用带 worker 标识的日志格式
log_format = _get_log_format_with_worker()
# 添加控制台输出 handler
loguru_logger.add(
sys.stdout,
format=log_format,
level=log_level,
colorize=True,
backtrace=True,
diagnose=True,
)
# 为了向后兼容,提供 get_logger 函数
# 但实际上直接使用 loguru 的 logger 即可
def get_logger(name: str = "app"):
"""
获取日志器实例(向后兼容)
注意:建议直接使用 `from loguru import logger`
Args:
name: 日志器名称(loguru 中用于标识,可通过 bind 方法绑定)
Returns:
loguru Logger 实例
"""
return loguru_logger.bind(name=name)
# issue #12: % 风格占位符检测(%s/%d/%r/%f/%x 等;%% 为转义,单独匹配以便排除)
_PERCENT_PLACEHOLDER_PATTERN = re.compile(
r"%(?:%|[-+ #0]*(?:\d+|\*)?(?:\.(?:\d+|\*))?[hlL]?[diouxXeEfFgGcrsa])"
)
# issue #12: {} 风格占位符检测(完整的 {...} 对;{{ }} 转义在检测前剔除)
_BRACE_PLACEHOLDER_PATTERN = re.compile(r"\{[^{}]*\}")
# 为了向后兼容,提供 Logger 类
class Logger:
"""
日志器类(向后兼容)
注意:建议直接使用 `from loguru import logger`
issue #12: 同时支持 logging 风格的 % 占位符("hello %s")和
loguru 原生的 {} 占位符("hello {}")。日志调用绝不向业务代码
抛出格式化异常,格式化失败时降级输出消息本体。
"""
def __init__(self, name: str = "app"):
"""
初始化日志器
Args:
name: 日志器名称
"""
self.name = name
self._logger = loguru_logger.bind(name=name)
@staticmethod
def _contains_percent_placeholder(message: str) -> bool:
"""消息是否含真正的 % 占位符(%% 转义不算)"""
return any(
match.group() != "%%"
for match in _PERCENT_PLACEHOLDER_PATTERN.finditer(message)
)
@staticmethod
def _contains_brace_placeholder(message: str) -> bool:
"""消息是否含 {} 风格占位符({{ }} 转义不算)"""
unescaped = message.replace("{{", "").replace("}}", "")
return _BRACE_PLACEHOLDER_PATTERN.search(unescaped) is not None
def _format_message(self, message, args):
"""
issue #12: 统一占位符预处理
规则:
1. 含 % 占位符且有位置参数且不含 {} 占位符 → 先做 % 预格式化,
args 置空后交给 loguru
2. 含 {} 占位符 → 原样交给 loguru{} 优先于 %,保持 loguru 原生语义)
3. 预格式化异常(参数不匹配等)→ 回退为原样传递,不抛错
Returns:
(message, args) 元组,供 loguru 调用使用
"""
if not args or not isinstance(message, str):
return message, args
if self._contains_brace_placeholder(message):
# {} 占位符优先,交给 loguru 内部的 str.format
return message, args
if self._contains_percent_placeholder(message):
try:
return message % args, ()
except Exception:
# 参数不匹配等预格式化失败:回退为原样传递
return message, args
return message, args
def _safe_log(self, log_method, message, args, kwargs) -> None:
"""
issue #12: 安全调用 loguru 日志方法
loguru 内部的 str.format 路径若抛错(如消息含孤立 {),
捕获后降级为无参输出消息本体,绝不向业务代码抛格式化异常。
kwargs(命名 {name} 占位符 / extra)保持透传。
"""
formatted, remaining_args = self._format_message(message, args)
try:
log_method(formatted, *remaining_args, **kwargs)
except Exception:
try:
log_method("{}", formatted)
except Exception:
# 日志降级输出也失败时静默放弃,保证业务调用不受影响
pass
def debug(self, message: str, *args, **kwargs) -> None:
"""记录调试日志"""
self._safe_log(self._logger.debug, message, args, kwargs)
def info(self, message: str, *args, **kwargs) -> None:
"""记录信息日志"""
self._safe_log(self._logger.info, message, args, kwargs)
def warning(self, message: str, *args, **kwargs) -> None:
"""记录警告日志"""
self._safe_log(self._logger.warning, message, args, kwargs)
def error(self, message: str, *args, **kwargs) -> None:
"""记录错误日志"""
self._safe_log(self._logger.error, message, args, kwargs)
def critical(self, message: str, *args, **kwargs) -> None:
"""记录严重错误日志"""
self._safe_log(self._logger.critical, message, args, kwargs)
def exception(self, message: str, *args, **kwargs) -> None:
"""记录异常日志"""
self._safe_log(self._logger.exception, message, args, kwargs)