""" 日志管理模块 基于 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 ( "{time:YYYY-MM-DD HH:mm:ss} | " "{extra[worker]} | " "{level: <8} | " "{name}:{function}:{line} - " "{message}" ) def _get_log_format_simple() -> str: """ 获取简单日志格式(无 worker 标识) Returns: 日志格式字符串 """ return ( "{time:YYYY-MM-DD HH:mm:ss} | " "{level: <8} | " "{name}:{function}:{line} - " "{message}" ) 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)