import re import logging import datetime from pathlib import Path from logging.handlers import RotatingFileHandler from chongming_config import get_config config = get_config() env = config["default"]["env"] server_config = config[env]["server"] log_config = config[env]["logging"] class ColoredFormatter(logging.Formatter): """ 为 HTTP 状态码添加颜色的日志格式化器 根据 HTTP 状态码的首位数字为日志消息中的状态码添加不同颜色: - 2xx: 绿色(成功) - 3xx: 青色(重定向) - 4xx: 黄色(客户端错误) - 5xx: 红色(服务器错误) Attributes: COLORS: 状态码首位数字到 ANSI 颜色代码的映射字典 RESET: ANSI 重置颜色代码 """ COLORS = { "2": "\033[32m", # 绿色 2xx "3": "\033[36m", # 青色 3xx "4": "\033[33m", # 黄色 4xx "5": "\033[31m", # 红色 5xx } RESET = "\033[0m" def format(self, record): """ 格式化日志记录,为状态码添加颜色 如果日志记录包含 status_code 属性,则查找消息中的 "status=数字" 模式 并为其添加对应的颜色代码。格式化完成后恢复原始消息以避免影响其他 handler。 Args: record: 日志记录对象,应包含 msg 属性和可选的 status_code 属性 Returns: str: 格式化后的日志消息字符串,状态码部分可能包含 ANSI 颜色代码 """ # 保存原始消息 original_msg = record.msg # 如果日志记录包含 status_code 属性,则为其添加颜色 status_code = getattr(record, "status_code", None) if status_code and isinstance(status_code, int): first_digit = str(status_code)[0] color = self.COLORS.get(first_digit, "") colored_status = f"{color}{status_code}{self.RESET}" # 替换消息中的 "status=数字" 为带颜色的版本 record.msg = re.sub( rf"status={status_code}\b", f"status={colored_status}", original_msg ) result = super().format(record) # 恢复原始消息,避免影响其他 handler record.msg = original_msg return result class DailyRotatingFileHandler(RotatingFileHandler): """ 按自然日切分 + 大小轮转的文件处理器 日志文件名形如 {log_name}-2026-08-19.log:进程启动时按当天日期命名, 跨天后写入时自动切换到新日期文件,避免长时间运行的进程把跨天日志 全部堆积到启动当天的文件里。同时保留 RotatingFileHandler 的大小轮转 (单文件超限追加 .1/.2 后缀)。 历史文件(跨天文件与大小备份)按修改时间保留最近 backup_count 个, 超出部分在切换日期的瞬间清理。 """ def __init__( self, log_dir: str, log_name: str, max_bytes: int, backup_count: int, encoding: str = "utf-8", ): self.log_dir = Path(log_dir) self.log_name = log_name self._current_date = datetime.date.today() super().__init__( str(self.log_dir / f"{log_name}-{self._current_date}.log"), encoding=encoding, maxBytes=max_bytes, backupCount=backup_count, ) def _build_filename(self, day: datetime.date) -> str: return str(self.log_dir / f"{self.log_name}-{day}.log") def _cleanup_old_files(self) -> None: """保留最近 backup_count 个日志文件,删除更早的历史文件""" # 按 (日期, 轮转序号) 排序,不依赖 mtime(同日备份可能同时落盘): # 日期越大越新;同日内主文件(.log)最新,.N 备份按 N 递减更旧 pattern = re.compile( rf"^{re.escape(self.log_name)}-(\d{{4}}-\d{{2}}-\d{{2}})\.log(?:\.(\d+))?$" ) def sort_key(path: Path): match = pattern.match(path.name) if not match: return (datetime.date.min, 0) return ( datetime.date.fromisoformat(match.group(1)), -int(match.group(2) or 0), ) files = sorted( self.log_dir.glob(f"{self.log_name}-*.log*"), key=sort_key, reverse=True, ) for old in files[self.backupCount :]: try: old.unlink() except OSError: pass def emit(self, record): # 跨天:关闭旧文件流,切换到新日期文件,并清理过期历史文件 today = datetime.date.today() if today != self._current_date: self._current_date = today self.close() self.baseFilename = self._build_filename(today) self.stream = self._open() self._cleanup_old_files() super().emit(record) class ExcludeWatchFilesFilter(logging.Filter): """ 日志过滤器,用于排除第三方库的日志输出 过滤掉来自 watchfiles、aiosqlite、apscheduler、sqlalchemy 等库的日志记录, 避免这些库的详细日志污染应用日志输出。 """ def __init__(self): """ 初始化过滤器,设置需要过滤的日志名称前缀列表 """ super().__init__() self._filter_starts_with_tulpe = ( "watchfiles", "aiosqlite", "apscheduler", "sqlalchemy", "sqlitedict", "sqlalchemy.engine.Engine", ) def filter(self, record): """ 判断是否应该保留该日志记录 Args: record: 日志记录对象,包含 name 属性表示日志来源 Returns: bool: 如果日志名称不以过滤列表中的任何前缀开头则返回 True(保留), 否则返回 False(过滤掉) """ return not record.name.startswith(self._filter_starts_with_tulpe) _configured_loggers = set() def setup_logging(logger: logging.Logger, log_name: str = "app"): """ 配置日志系统,包括文件和控制台输出 为指定的 logger 配置以下功能: 1. 设置日志级别和格式 2. 创建按自然日切分的轮转文件处理器(跨天自动切换文件,支持文件大小限制和备份数量控制) 3. 创建带颜色格式化的控制台处理器 4. 应用过滤器排除第三方库日志 该函数具有防重复配置机制,同一个 log_name 只会配置一次。 Args: logger: 需要配置的 logging.Logger 实例 log_name: 日志名称标识,用于区分不同的日志配置和生成日志文件名,默认为 "app" Returns: None Raises: 无显式抛出异常,但可能因文件系统权限问题导致目录创建或文件写入失败 """ if log_name in _configured_loggers: return _configured_loggers.add(log_name) log_level = getattr(logging, log_config["level"], logging.INFO) log_format = log_config["format"] # 创建文件夹 log_file_path = Path(log_config["file"]) if log_file_path != Path("."): log_file_path.mkdir(parents=True, exist_ok=True) # 配置根日志记录器 logger.setLevel(log_level) # 创建格式化器 plain_formatter = logging.Formatter(log_format) color_formatter = ColoredFormatter(log_format) # 创建按自然日切分的轮转文件处理器(跨天自动切文件,单文件超限再按大小轮转) max_bytes = log_config.get("max_size", 10 * 1024 * 1024) # 默认 10MB backup_count = log_config.get("backup_count", 5) # 默认保留 5 个历史文件 file_handler = DailyRotatingFileHandler( log_config["file"], log_name, max_bytes=max_bytes, backup_count=backup_count, ) file_handler.setFormatter(plain_formatter) file_handler.addFilter(ExcludeWatchFilesFilter()) # 创建控制台处理器并添加过滤器 console_handler = logging.StreamHandler() console_handler.setFormatter(color_formatter) console_handler.addFilter(ExcludeWatchFilesFilter()) # 添加处理器到根日志记录器 logger.addHandler(file_handler) logger.addHandler(console_handler) print( f"日志系统已配置 - 级别: {logging.getLevelName(log_level)}, 文件: {log_config['file']} " f"(最大:{max_bytes / 1024 / 1024:.1f}MB, 备份:{backup_count})" ) def get_logger(name: str) -> logging.Logger: """ 获取配置好的日志记录器实例 根据名称获取或创建 logger 实例,并自动调用 setup_logging 进行配置。 如果该名称的 logger 已经配置过,则直接返回已配置的实例。 Args: name: 日志记录器名称,通常使用模块名如 __name__ Returns: logging.Logger: 已配置完成的日志记录器实例,可直接用于记录日志 """ logger = logging.getLogger(name) setup_logging(logger, name) return logger