MicroPython 项目必备!uLogLite-手把手教你写带级别 / 轮转 / 过滤的日志模块

在以下代码中,我们使用自定义类实现一个日志记录模块,能够实现如下功能:

1.PNG

  • 定义日志级别​:定义不同的日志级别,例如 DEBUGINFOWARNINGERRORCRITICAL
  • 实现日志过滤器​:根据日志级别过滤日志消息。
  • 实现日志格式器​:格式化日志消息的输出。
  • 创建日志记录器​:将日志记录器、过滤器和格式器结合起来。
  • 日志文件轮转​:当日志文件达到指定的最大条数或轮转时间间隔时,会自动创建新的日志文件,并将日志条数计数器重置。
  • 异常处理​:支持记录异常堆栈信息,并可以选择是否在终端显示异常信息。
  • 线程安全​:使用互斥锁来确保多线程环境下的线程安全,避免日志写入过程中出现竞争条件。

示例代码如下:

# Python env   : MicroPython v1.23.0
# -*- coding: utf-8 -*-        
# @Time    : 2024/9/16 下午2:40   
# @Author  : 李清水            
# @File    : logger.py       
# @Description : 日志记录模块,具有级别分类、日志过滤器和格式器功能

# ======================================== 导入相关模块 =========================================

# 导入时间模块,用于获取当前时间
import time
# 导入os模块,用于文件操作
import os
# 导入线程模块和锁
import _thread
# 导入系统相关的模块
import sys
# 导入micropython模块,用于优化代码
import micropython

# ======================================== 全局变量 ============================================

# ======================================== 功能函数 ============================================

# ======================================== 自定义类 ============================================

# 定义日志级别
class LogLevel:
    DEBUG    = 1 # 调试级别
    INFO     = 2 # 信息级别
    WARNING  = 3 # 警告级别
    ERROR    = 4 # 错误级别
    CRITICAL = 5 # 严重错误级别

# 日志记录器类
class uLogLite:
    """
​    日志记录器类,提供多级别日志记录、过滤和格式化功能,支持文件轮转和线程安全操作。

​    该类封装了完整的日志记录流程,包括日志级别过滤、消息格式化、文件输出和轮转管理。
​    支持通过过滤器链实现灵活的日志过滤,可通过格式器自定义日志输出样式。

​    Attributes:
​        name (str): 日志器标识名称,用于生成日志文件名前缀
​        level (int): 当前日志记录级别(LogLevel枚举值)
​        filters (List[Callable[[int, str], bool]]): 日志过滤器函数列表
​        formatter (Optional[object]): 日志格式器实例,需实现format(level,message)方法
​        output (str): 输出目标,"terminal"表示终端输出,其他字符串表示文件夹路径,这里仅支持单级文件夹路径。
​        max_logs (int): 单个日志文件最大记录条数限制
​        rotate_interval (int): 日志轮转时间间隔(单位:小时)
​        log_count (int): 当前文件已记录日志条数
​        last_rotation_time (float): 最后一次轮转的时间戳(UNIX时间戳)
​        lock (Lock): 线程互斥锁对象
​        log_file (Optional[TextIO]): 当前日志文件句柄

​    Methods:
​        __init__(self, name: str, level: int = LogLevel.DEBUG, output: str = "terminal",
​                max_logs: int = 1000, rotate_interval: int = 24) -> None:
​            初始化日志记录器实例

​        add_filter(self, log_filter: Callable[[int, str], bool]) -> None:
​            添加日志过滤器函数

​        set_formatter(self, formatter: object) -> None:
​            设置日志格式器实例

​        log(self, level: int, message: str) -> None:
​            基础日志记录方法

​        debug(self, message: str) -> None:
​            记录DEBUG级别日志

​        info(self, message: str) -> None:
​            记录INFO级别日志

​        warning(self, message: str) -> None:
​            记录WARNING级别日志

​        error(self, message: str) -> None:
​            记录ERROR级别日志

​        critical(self, message: str) -> None:
​            记录CRITICAL级别日志

​        exception(self, exc: Exception, terminal_display: bool = True) -> None:
​            记录异常堆栈信息

​        close(self) -> None:
​            安全关闭日志文件

​    Private Methods:
​        _open_log_file(self) -> None:
​            打开/创建日志文件并初始化计数器

​        __is_single_level_path(self, path: str) -> bool:
​            检查是否为有效的单级文件夹路径(适用于MicroPython文件系统)

​        _apply_filters(self, level: int, message: str) -> bool:
​            应用所有注册的过滤器

​        _format_message(self, level: int, message: str) -> str:
​            格式化日志消息

​        _write_log(self, formatted_message: str) -> None:
​            线程安全的日志写入操作

​        _should_rotate(self) -> bool:
​            检查是否达到轮转条件

​        _rotate_log_file(self) -> None:
​            执行日志文件轮转操作
​    """
​    ​def __init__(self, name: str, level: int = LogLevel.DEBUG, output: str = "terminal",
                 max_logs: int = 1000, rotate_interval: int = 24) -> None:
        """
​        日志记录器初始化

​        Args:
​            name (str): 日志器标识名称
​            level (int): 默认日志级别(LogLevel枚举)
​            output (str): 输出目标,"terminal"或文件夹路径,这里仅支持单级文件夹路径。
​            max_logs (int): 最大日志条数限制
​            rotate_interval (int): 日志轮转时间间隔(小时)

​        Raises:
​            OSError: 当文件创建失败、name为空或空字符串、level不在LogLevel枚举中、output不是字符串时抛出。
​        """
​        ​# 判断name是否为空和是否为空字符串
        if not name or name == "":
            raise ValueError("name cannot be empty")

        # 判断name是不是为字符串
        if not isinstance(name, str):
            raise TypeError("name must be a string")

        # 判断 level 是否是整数,且是 LogLevel 里面的值
        if not isinstance(level, int) or level not in [getattr(LogLevel, attr) for attr in dir(LogLevel) if
                                                       not attr.startswith('__')]:
            raise TypeError("level must be an integer of LogLevel enum")

        # 判断output是否合法
        if not isinstance(output, str):
            raise TypeError("output must be a string")

        # 验证output输出仅为单级文件夹路径
        if output != "terminal":
            if not self._is_single_level_path(output):
                raise ValueError(
                    "Output path must be a single-level directory\n"
                    "Examples: 'logs', '/data', 'mylog'"
                )

        # 初始化日志记录器,接受日志名称、日志级别和输出方式(文件或终端)
        self.name = name
        self.level = level
        # 初始化过滤器列表
        self.filters = []
        # 初始化格式器
        self.formatter = None
        # 日志输出方式:可以是"terminal"或文件夹路径
        self.output = output
        # 最大日志条数
        self.max_logs = max_logs
        # 轮转时间间隔(小时)
        self.rotate_interval = rotate_interval

        # 初始化日志条数计数器
        self.log_count = 0
        # 记录最后一次轮转时间
        self.last_rotation_time = time.time()

        # 创建互斥锁
        self.lock = _thread.allocate_lock()

        # 打开日志文件
        self._open_log_file()

    @staticmethod
    def _is_single_level_path(path: str) -> bool:
        """
​        检查是否为有效的单级文件夹路径(适用于MicroPython文件系统)

​        单级文件夹路径支持以下格式:
​            "logs"    - 当前目录下的相对路径
​            "/logs"   - 根目录下的绝对路径
​            "" 或 "." - 当前目录
​            "/"       - 根目录本身

​        Args:
​            path (str): 待检查的路径字符串

​        Returns:
​            bool: 返回检查结果:
​                - True:  是合法的单级路径
​                - False: 是多级路径或包含非法字符
​        """
​        ​# 空路径或当前目录
        if not path or path == "." or path == "/":
            return True

        # 标准化路径(处理开头/结尾的斜杠)
        normalized = path.strip("/")

        # 检查是否包含子路径分隔
        return "/" not in normalized and "\\" not in normalized

    @micropython.native
    def _open_log_file(self) -> None:
        """
​        创建/打开日志文件,如果是已有文件,会读取行数初始化计数器,新文件计数器从0开始。

​        Args:
​            None

​        Returns:
​            None

​        Raises:
​            OSError: 当文件创建失败时抛出

​        Notes:
​            - 文件名格式: {name}_{年月日_时分秒}.txt
​            - 失败时自动降级到终端输出
​        """
​        ​# 获取当前时间:格式为 (year, month, mday, hour, minute, second, weekday, yearday)
        t = time.localtime()
        # 记录时间戳并格式化为年月日_时分秒字符串(如20240917_143001)
        timestamp = "{:04}{:02}{:02}_{:02}{:02}{:02}".format(t[0], t[1], t[2], t[3], t[4], t[5])

        # 如果输出到终端
        if self.output == "terminal":
            # 日志文件句柄设为None,终端模式不需要文件操作
            self.log_file = None
            self.log_count = 0
        else:
            # 根据名称和时间戳生成日志文件名:名称_时间戳.txt
            log_filename = f"{self.output}/{self.name}_{timestamp}.txt"

            # 尝试创建目录(只处理单层目录)
            try:
                os.mkdir(self.output)
            except OSError:
                # 目录已存在则忽略
                pass

            try:
                # 检查文件是否已存在
                try:
                    os.stat(log_filename)
                    file_exists = True
                except OSError:
                    file_exists = False

                # 以追加模式打开文件(不存在则创建)
                self.log_file = open(log_filename, 'a')

                # 行数统计
                if file_exists:
                    count = 0
                    with open(log_filename, 'r') as f:
                        while True:
                            # 每次读取128字节块(适应内存受限环境)
                            chunk = f.read(128)
                            # 到达文件末尾
                            if not chunk:
                                break
                            # 统计换行符数量(每行一个)
                            count += chunk.count('\n')
                    self.log_count = count
                else:
                    # 如果是新建文件,初始化计数器
                    self.log_count = 0
            except OSError as e:
                # 打开文件失败,打印错误信息
                print(f"无法打开文件 {log_filename}: {e}")
                # 文件句柄设为None
                self.log_file = None

    def add_filter(self, log_filter: callable[[int, str], bool]) -> None:
        """
​        添加日志过滤器

​        Args:
​            log_filter (callable): 过滤函数,接收(level,message)返回bool

​        Returns:
​            None
​        """
​        ​# 添加日志过滤器
        self.filters.append(log_filter)

    def set_formatter(self, formatter: object) -> None:
        """
​        设置日志格式器

​        Args:
​            formatter (object): 实现format(level,message)方法的对象
​            必须有格式化日志消息的 format(level,message) 方法

​        Returns:
​            None

​        Raises:
​            TypeError: 当格式器没有实现 format(level,message) 方法时抛出
​        """
​        ​# 检查格式器是否实现了format(level,message)方法
        # hasattr(formatter, "format") 检查 formatter 是否有 format 属性
        # callable(getattr(formatter, "format")) 确保 format 是一个可调用的方法,而不是普通的属性
        if not hasattr(formatter, "format") or not callable(getattr(formatter, "format")):
            raise TypeError("formatter must implement a callable format(level, message) method")

        # 设置日志格式器
        self.formatter = formatter

    def _apply_filters(self, level: int, message: str) -> bool:
        """
​        应用所有注册的过滤器,如果任意一个过滤器返回False,日志不会被记录。

​        Args:
​            level   [int]: 日志级别
​            message [str]: 日志消息

​        Returns:
​            bool: 当所有过滤器返回True时通过
​        """
​        ​# 应用日志过滤器,如果任意一个过滤器返回False,日志不会被记录
        for log_filter in self.filters:
            if not log_filter(level, message):
                return False
        return True

    @micropython.native
    def _format_message(self, level: int, message: str) -> str:
        """
​        格式化日志消息,如果设置了格式器,则使用它,否则使用默认格式。

​        Args:
​            level   [int]: 日志级别。
​            message [str]: 日志消息。

​        Returns:
​            str: 格式化后的完整日志条目。
​        """
​        ​# 如果设置了格式器,则使用它,否则使用默认格式
        if self.formatter:
            return self.formatter.format(level, message)
        return f"{self.name} - {level}: {message}"

    @micropython.native
    def _write_log(self, formatted_message: str) -> None:
        """
​        线程安全的日志写入操作,根据日志输出方式选择写入方式,可以选择写入文件或终端。

​        执行流程:
​            1. 获取线程锁
​            2. 检查轮转条件
​            3. 执行实际写入
​            4. 更新计数器
​            5. 释放锁

​        Args:
​            formatted_message [str]: 格式化后的完整日志条目。

​        Returns:
​            None

​        Notes:
​            - 自动处理日志轮转
​            - 写入失败时降级到终端输出
​        """

​        ​# 锁定线程,确保线程安全
        with self.lock:
            # # 记录日志写入开始时间
            # start_time = time.ticks_us()

            # 在写入日志前检查是否需要轮转
            if self.output != "terminal" and (self.log_count >= self.max_logs or self._should_rotate()):
                # 执行轮转
                self._rotate_log_file()

            # 写入日志,如果设置了文件输出,则写入文件,否则输出到终端
            if self.output == "terminal":
                # 输出到终端
                print(formatted_message)
            else:
                # 如果文件句柄有效,写入日志文件
                if self.log_file:
                    # 写入消息并追加换行符
                    self.log_file.write(formatted_message + "\n")
                    # 立即刷新日志消息到缓冲区
                    self.log_file.flush()
                    # 增加日志条数计数器
                    self.log_count += 1

                else:
                    # 如果文件句柄无效,输出到终端
                    print("can not open file, output to terminal:", formatted_message)

            # # 记录日志写入结束时间
            # end_time = time.ticks_us()
            # # 计算写入日志耗时
            # # 计算耗时
            # write_time = time.ticks_diff(end_time, start_time)
            # # 打印耗时,单位是ms
            # print(f"write log cost {write_time / 1000} ms")

    def _rotate_log_file(self) -> None:
        """
​        执行日志文件轮转操作,包括关闭当前文件、创建新文件和重置计数器。

​        Args:
​            None

​        Returns:
​            None

​        Raises:
​            OSError: 当文件操作失败时抛出
​        """
​        ​# # 调试输出
        # print(f"[Rotation] Count:{self.log_count} Max:{self.max_logs}")

        # 如果文件句柄有效
        if self.log_file:
            # 关闭当前日志文件
            self.log_file.close()
        # 打开新的日志文件
        self._open_log_file()
        # 重置日志条数计数器
        self.log_count = 0
        # 更新最后一次轮转时间
        self.last_rotation_time = time.time()

    @micropython.native
    def _should_rotate(self) -> bool:
        """
​        检查是否达到日志轮转条件。

​        Args:
​            None

​        Returns:
​            bool: 需要轮转返回True,否则返回False
​        """
​        ​# 计算上一次轮转时间和当前时间差值
        current_hour = time.localtime()[3]
        last_hour = time.localtime(self.last_rotation_time)[3]
        time_diff = time.time() - self.last_rotation_time

        # 时间条件满足以下任一:
        # 1. 达到间隔小时数
        # 2. 小时数发生变化且时间差超过1小时(处理跨天情况)
        return (time_diff >= self.rotate_interval * 3600) or \
            (current_hour != last_hour and time_diff >= 3600)

    @micropython.native
    def log(self, level: int, message: str) -> None:
        """
​        基础日志记录方法。

​        Args:
​            level (int): 日志级别(使用LogLevel枚举值)
​            message (str): 要记录的日志内容

​        Returns:
​            None

​        Raises:
​            ValueError: 当日志级别无效或message为空时抛出。
​        """
​        ​# 判断 level 是否是整数,且是 LogLevel 里面的值
        if not isinstance(level, int) or level not in [getattr(LogLevel, attr) for attr in dir(LogLevel) if
                                                       not attr.startswith('__')]:
            raise TypeError("level must be an integer of LogLevel enum")

        # 判断message是不是字符串或空字符串
        if not isinstance(message, str) or message == "":
            raise ValueError("message must be a string")

        # 记录日志,首先检查日志级别和过滤器
        if level >= self.level and self._apply_filters(level, message):
            formatted_message = self._format_message(level, message)
            # 根据设置输出日志
            self._write_log(formatted_message)

    def debug(self, message: str) -> None:
        """
​        记录DEBUG级别日志。

​        Args:
​            message (str): 调试信息内容

​        Returns:
​            None
​        """
​        ​# 记录调试日志
        self.log(LogLevel.DEBUG, message)

    def info(self, message: str) -> None:
        """
​        记录INFO级别日志。

​        Args:
​            message (str): 普通信息内容

​        Returns:
​            None
​        """
​        ​# 记录信息日志
        self.log(LogLevel.INFO, message)

    def warning(self, message: str) -> None:
        """
​        记录WARNING级别日志。

​        Args:
​            message (str): 警告信息内容

​        Returns:
​            None
​        """
​        ​# 记录警告日志
        self.log(LogLevel.WARNING, message)

    def error(self, message: str) -> None:
        """
​        记录ERROR级别日志。

​        Args:
​            message (str): 错误信息内容

​        Returns:
​            None
​        """
​        ​# 记录错误日志
        self.log(LogLevel.ERROR, message)

    def critical(self, message: str) -> None:
        """
​        记录CRITICAL级别日志。

​        Args:
​            message (str): 严重错误信息内容

​        Returns:
​            None
​        """
​        ​# 记录严重错误日志
        self.log(LogLevel.CRITICAL, message)

    def exception(self, exc: Exception, terminal_display: bool = True) -> None:
        """
​        记录异常堆栈信息。

​        Args:
​            exc (Exception): 异常对象
​            terminal_display (bool): 是否在终端显示,默认为True

​        Returns:
​            None
​        """
​        ​# 打印异常堆栈信息到终端
        if isinstance(exc, Exception):
            # 同时将异常信息写入日志文件
            if self.output != "terminal" and self.log_file:
                sys.print_exception(exc, self.log_file)

            # 若设置终端显示标志位为True,则打印异常信息到终端
            if terminal_display:
                sys.print_exception(exc)

    def close(self) -> None:
        """
​        安全关闭日志文件。

​        Args:
​            None

​        Returns:
​            None
​        """
​        ​# 关闭日志文件,如果文件输出被使用
        if self.output != "terminal" and self.log_file:
            self.log_file.close()

# ======================================== 初始化配置 ==========================================

# ========================================  主程序  ===========================================

1.logger 日志记录类的实现原理

在以上的 logger 日志记录类中,我们提供了以下方法:

2.png

我们实现记录不同级别日志的方法,如 debuginfowarning 等,主要是调用了 log 方法:

3.png

@micropython.native
def log(self, level: int, message: str) -> None:
    """
​    基础日志记录方法。

​    Args:
​        level (int): 日志级别(使用LogLevel枚举值)
​        message (str): 要记录的日志内容

​    Returns:
​        None

​    Raises:
​        ValueError: 当日志级别无效或message为空时抛出。
​    """
​    ​# 判断 level 是否是整数,且是 LogLevel 里面的值
    if not isinstance(level, int) or level not in [getattr(LogLevel, attr) for attr in dir(LogLevel) if
                                                   not attr.startswith('__')]:
        raise TypeError("level must be an integer of LogLevel enum")

    # 判断message是不是字符串或空字符串
    if not isinstance(message, str) or message == "":
        raise ValueError("message must be a string")

    # 记录日志,首先检查日志级别和过滤器
    if level >= self.level and self._apply_filters(level, message):
        formatted_message = self._format_message(level, message)
        # 根据设置输出日志
        self._write_log(formatted_message)

log 方法首先检查日志级别和过滤器,然后调用 _write_log 方法输出格式化和过滤后的日志信息:

4.png

@micropython.native
def _write_log(self, formatted_message: str) -> None:
    """
​    线程安全的日志写入操作,根据日志输出方式选择写入方式,可以选择写入文件或终端。

​    执行流程:
​        1. 获取线程锁
​        2. 检查轮转条件
​        3. 执行实际写入
​        4. 更新计数器
​        5. 释放锁

​    Args:
​        formatted_message [str]: 格式化后的完整日志条目。

​    Returns:
​        None

​    Notes:
​        - 自动处理日志轮转
​        - 写入失败时降级到终端输出
​    """

​    ​# 锁定线程,确保线程安全
    with self.lock:
        # # 记录日志写入开始时间
        # start_time = time.ticks_us()

        # 在写入日志前检查是否需要轮转
        if self.output != "terminal" and (self.log_count >= self.max_logs or self._should_rotate()):
            # 执行轮转
            self._rotate_log_file()

        # 写入日志,如果设置了文件输出,则写入文件,否则输出到终端
        if self.output == "terminal":
            # 输出到终端
            print(formatted_message)
        else:
            # 如果文件句柄有效,写入日志文件
            if self.log_file:
                # 写入消息并追加换行符
                self.log_file.write(formatted_message + "\n")
                # 立即刷新日志消息到缓冲区
                self.log_file.flush()
                # 增加日志条数计数器
                self.log_count += 1

            else:
                # 如果文件句柄无效,输出到终端
                print("can not open file, output to terminal:", formatted_message)

        # # 记录日志写入结束时间
        # end_time = time.ticks_us()
        # # 计算写入日志耗时
        # # 计算耗时
        # write_time = time.ticks_diff(end_time, start_time)
        # # 打印耗时,单位是ms
        # print(f"write log cost {write_time / 1000} ms")

_write_log() 主要负责将格式化后的日志消息写入到终端或文件:

5.png

  1. 首先通过锁 self.lock,确保多个线程同时写日志时不会发生竞争条件,保证日志写入的原子性,然后检查是否达到日志轮转的条件。
  2. 并且判断日志输出地址:
    1. 如果日志输出方式是终端 (terminal),则直接打印日志消息
    2. 如果日志输出方式是文件,则将日志消息写入指定的日志文件
  3. 如果输出到文件,确保文件句柄有效后,将日志消息写入文件;同时调用 flush() 方法立即刷新缓冲区,这里,每写入一条日志,增加 log_count 计数器;

在检查是否达到日志轮转的条件时,主要是检查是否达到最大日志条数或日志轮转时间间隔,如果满足条件,调用 _rotate_log_file() 方法关闭当前日志文件,打开一个新的日志文件(通过调用 _open_log_file() 方法),并重置日志条数计数器:

6.png

def _rotate_log_file(self) -> None:
    """
​    执行日志文件轮转操作,包括关闭当前文件、创建新文件和重置计数器。

​    Args:
​        None

​    Returns:
​        None

​    Raises:
​        OSError: 当文件操作失败时抛出
​    """
​    ​# # 调试输出
    # print(f"[Rotation] Count:{self.log_count} Max:{self.max_logs}")

    # 如果文件句柄有效
    if self.log_file:
        # 关闭当前日志文件
        self.log_file.close()
    # 打开新的日志文件
    self._open_log_file()
    # 重置日志条数计数器
    self.log_count = 0
    # 更新最后一次轮转时间
    self.last_rotation_time = time.time()

@micropython.native
def _should_rotate(self) -> bool:
    """
​    检查是否达到日志轮转条件。

​    Args:
​        None

​    Returns:
​        bool: 需要轮转返回True,否则返回False
​    """
​    ​# 计算上一次轮转时间和当前时间差值
    current_hour = time.localtime()[3]
    last_hour = time.localtime(self.last_rotation_time)[3]
    time_diff = time.time() - self.last_rotation_time

    # 时间条件满足以下任一:
    # 1. 达到间隔小时数
    # 2. 小时数发生变化且时间差超过1小时(处理跨天情况)
    return (time_diff >= self.rotate_interval * 3600) or \
        (current_hour != last_hour and time_diff >= 3600)

在调用 _open_log_file() 方法时,若选择日志输出为文件,我们会根据当前时间生成新的日志文件名,并以追加模式打开该文件以进行日志记录,同时日志文件名中包含时间戳:

@micropython.native
def _open_log_file(self) -> None:
    """
​    创建/打开日志文件,如果是已有文件,会读取行数初始化计数器,新文件计数器从0开始。

​    Args:
​        None

​    Returns:
​        None

​    Raises:
​        OSError: 当文件创建失败时抛出

​    Notes:
​        - 文件名格式: {name}_{年月日_时分秒}.txt
​        - 失败时自动降级到终端输出
​    """
​    ​# 获取当前时间:格式为 (year, month, mday, hour, minute, second, weekday, yearday)
    t = time.localtime()
    # 记录时间戳并格式化为年月日_时分秒字符串(如20240917_143001)
    timestamp = "{:04}{:02}{:02}_{:02}{:02}{:02}".format(t[0], t[1], t[2], t[3], t[4], t[5])

    # 如果输出到终端
    if self.output == "terminal":
        # 日志文件句柄设为None,终端模式不需要文件操作
        self.log_file = None
        self.log_count = 0
    else:
        # 根据名称和时间戳生成日志文件名:名称_时间戳.txt
        log_filename = f"{self.output}/{self.name}_{timestamp}.txt"

        # 尝试创建目录(只处理单层目录)
        try:
            os.mkdir(self.output)
        except OSError:
            # 目录已存在则忽略
            pass

        try:
            # 检查文件是否已存在
            try:
                os.stat(log_filename)
                file_exists = True
            except OSError:
                file_exists = False

            # 以追加模式打开文件(不存在则创建)
            self.log_file = open(log_filename, 'a')

            # 行数统计
            if file_exists:
                count = 0
                with open(log_filename, 'r') as f:
                    while True:
                        # 每次读取128字节块(适应内存受限环境)
                        chunk = f.read(128)
                        # 到达文件末尾
                        if not chunk:
                            break
                        # 统计换行符数量(每行一个)
                        count += chunk.count('\n')
                self.log_count = count
            else:
                # 如果是新建文件,初始化计数器
                self.log_count = 0
        except OSError as e:
            # 打开文件失败,打印错误信息
            print(f"无法打开文件 {log_filename}: {e}")
            # 文件句柄设为None
            self.log_file = None

_open_log_file() 方法进行如下操作:

7.png

  1. 记录时间戳,生成唯一日志文件​名:​time.localtime() 获取当前时间,返回一个包含年、月、日、时、分、秒等信息的元组 (year, month, mday, hour, minute, second, weekday, yearday),通过 format() 方法格式化成 YYYYMMDD_HHMMSS 形式,例如:20240917_143001,这个时间戳用于生成唯一的日志文件名,避免文件名冲突。
  2. 处理终端输出模式和日志文件模式:
    1. 如果用户选择了 "terminal" 作为日志输出方式,则不创建日志文件,并且将日志句柄设为 None,同时日志计数器设为 0;
    2. 如果用户传入日志文件保持路径,则结合日志存储路径 self.output、日志名称 self.name 和时间戳 timestamp,拼接出完整的日志文件名。
  3. 处理日志文件​:​首先检查文件是否存在,接着以追加模式打开文件,日志会追加到文件末尾,同时统计日志行数并更新 self.log_count,这里统计日志行数的目的是在日志轮换机制中避免重复编号。
  4. ​处理文件打开失败:​如果发生 OSError(比如存储空间不足、权限不足),则打印错误信息并降级为终端模式(self.log_file = None)。

除去使用 debug()info()warning()error()exception() 等方法输出常见的日志消息外,我们也可以进行异常堆栈信息的记录,主要通过 exception() 方法实现:

def exception(self, exc: Exception, terminal_display: bool = True) -> None:
    """
​    记录异常堆栈信息。

​    Args:
​        exc (Exception): 异常对象
​        terminal_display (bool): 是否在终端显示,默认为True

​    Returns:
​        None
​    """
​    ​# 打印异常堆栈信息到终端
    if isinstance(exc, Exception):
        # 同时将异常信息写入日志文件
        if self.output != "terminal" and self.log_file:
            sys.print_exception(exc, self.log_file)

        # 若设置终端显示标志位为True,则打印异常信息到终端
        if terminal_display:
            sys.print_exception(exc)

该方法会将异常对象的堆栈信息同时写入日志文件(如果日志输出方式设置为文件,并且文件句柄有效),并根据设置决定是否在终端显示异常信息;sys.print_exception() 方法用于将异常详细信息输出到指定的日志文件或终端,这样不仅可以记录异常信息,还能在终端方便地查看和调试。

这里,我们使用单独的 LogLevel 类而不是将日志级别定义为 Logger 类的类变量:

8.png

主要是出于职责分离和增强可扩展性的原因:

  • 职责分离​:LogLevel 类专门用于管理日志级别,定义不同的日志等级(如 DEBUG、INFO、WARNING 等),这样可以确保日志级别的定义与日志记录器的行为逻辑分离,代码更加清晰;而 Logger 类负责日志的记录、过滤、格式化等功能,不关心日志级别的具体定义
  • 增强可扩展性​:将来可能需要添加新的日志级别(如 TRACEFATAL),如果将日志级别作为 Logger 类的类变量,那么扩展日志级别时就需要修改 Logger 类的代码将日志级别独立为 LogLevel 类,可以更方便地扩展或修改日志级别,而不影响 Logger 类的代码

2.日志过滤器和日志格式器实现的注意要点

要实现完整记录功能,我们需要日志记录器、日志过滤器和日志格式器配合工作,其中日志过滤器和日志格式器需要用户自行实现:

  • 日志记录器​:记录、管理和存储系统中的事件和消息,支持不同的日志级别(如 DEBUGINFOERROR 等),允许根据设定的级别输出或过滤日志消息,还支持日志轮转功能(基于时间间隔或日志条数),可以将日志输出到终端或文件,同时确保多线程环境下的线程安全日志写入
  • 日志过滤器​:日志过滤器用于决定哪些日志消息应该被记录,用户可以根据具体需求实现不同的过滤器,例如根据日志级别过滤日志消息、根据日志中是否包含关键字过滤日志消息或根据创建日志的时间来过滤
  • 日志格式器​:日志格式器用于定义日志消息的输出格式,用户可以实现不同的格式器来满足各种需求,例如在日志消息中加入时间或根据日志级别对日志添加不同颜色代码以在终端上显示不同颜色

在创建日志过滤器和日志格式器时,它们需要满足特定的接口要求,以便能够正确地与日志记录系统集成:

  • 对于日志过滤器来说​:日志过滤器通常是一个函数或可调用对象(一个实现了 call 方法的类),其作用是决定某条日志消息是否应该被记录,滤器函数或对象需要返回布尔值:True 表示允许记录日志,False 表示过滤掉该日志;同时过滤器应接收与日志记录相关的参数,最少需要包含日志的 levelmessage 两个参数,如下为常见的日志级别、日志长度、时间段和关键字过滤器实现:

    # 日志级别过滤器
    def level_filter(level: int, message: str) -> bool:
        """
    ​    简单的日志过滤器,允许记录INFO级别及以上的日志
    
    ​    Args:
    ​        level (int): 日志级别(使用LogLevel枚举值)
    ​        message (str): 日志消息内容
    
    ​    Returns:
    ​        bool: True表示记录日志,False表示不记录
    ​    """
    
    ​    ​# 简单的日志过滤器,只允许记录INFO级别及以上的日志
        return level >= LogLevel.INFO
    
    # 日志长度过滤器
    def length_filter(max_length: int) -> callable:
        """
    ​    生成日志长度过滤器函数
    
    ​    Args:
    ​        max_length (int): 允许的最大日志长度(字节数)
    
    ​    Returns:
    ​        callable[[int, str], bool]: 生成的过滤器函数
    ​    """
    ​    ​def filter_fn(level, message):
            return len(message) <= max_length
        return filter_fn
    
    # 时间段过滤器
    def time_range_filter(start_time: str, end_time: str) -> callable:
        """
    ​    生成时间段过滤器函数(24小时制)
    
    ​    Args:
    ​        start_time (str): 开始时间(格式'HH:MM',如'09:00')
    ​        end_time (str): 结束时间(格式'HH:MM',如'18:00')
    
    ​    Returns:
    ​        callable[[int, str], bool]: 生成的过滤器函数
    
    ​    Raises:
    ​        ValueError: 当时间格式无效时抛出
    ​    """
    ​    ​def filter_fn(level, message):
            # 获取当前时间的小时和分钟
            current_time = time.localtime()
            current_hour_minute = f"{current_time[3]:02}:{current_time[4]:02}"
            # 比较当前时间是否在指定范围内
            return start_time <= current_hour_minute <= end_time
        return filter_fn
    
    # 关键字过滤器
    def keyword_filter(keywords: list) -> callable:
        """
    ​    生成关键字过滤器函数
    
    ​    Args:
    ​        keywords (list[str]): 需要包含的关键字列表
    
    ​    Returns:
    ​        callable[[int, str], bool]: 生成的过滤器函数
    ​    """
    ​    ​def filter_fn(level, message):
            # 检查日志消息是否包含任何关键字
            return any(keyword in message for keyword in keywords)
        return filter_fn
    

    这里,有的同学可能会问,为什么使用日志级别过滤器时,可以传入level, ​message参数,而这两个参数没有在日志级别过滤器函数入口参数部分显式定义?例如,在 logger 类的 _apply_filters 方法中,使用如下代码调用添加的过滤器函数:

    def _apply_filters(self, level, message):
        '''
    ​    应用日志过滤器,如果任意一个过滤器返回False,日志不会被记录
    ​    ​:param​ level   [int]: 日志级别
    ​    ​:param​ message [str]: 日志消息
    ​    ​:return​ [bool]: True表示记录日志,False表示不记录日志
    ​    '''
    ​    ​# 应用日志过滤器,如果任意一个过滤器返回False,日志不会被记录
        for log_filter in self.filters:
            if not log_filter(level, message):
                return False
        return True
    

    这是因为日志级别过滤器函数使用了闭包的特性,所谓闭包就是指外部函数(这里指的是过滤器函数)内部定义的函数(这里指的是内部定义的 filter_fn 函数),并且内部函数可以访问外部函数的变量,即使外部函数的作用域已经结束。简单来说,就是我们在添加过滤器时,会使用如下语句:

    # 过滤掉日志长度超过50个字符的日志
    logger.add_filter(length_filter(50))
    # 过滤掉不含'FreakStudio'关键字的日志
    logger.add_filter(keyword_filter(["FreakStudio"]))
    

    这里,实际上仅仅是调用了外部的日志过滤器函数,并且传入日志过滤器函数需要的特定参数(例如关键字和日志最大长度),而并没有执行内部的 filter_fn 函数:

9.png

执行的结果是返回内部 filter_fn 函数,同时通过 add_filter 方法被传递到了 logger 类对应示例的 self.filters 过滤器函数对应列表中:

def add_filter(self, log_filter):
    '''
​    添加日志过滤器
​    ​:param​ log_filter [function]: 日志过滤器
​    ​:return: None
​    '''
​    ​# 添加日志过滤器
    self.filters.append(log_filter)

只有在_apply_filters应用过滤器方法中,使用log_filter(level, message)语句时,才是真正调用内部filter_fn函数,并传入level​ 和 ​message参数的时候。

10.png

此时,内部 filter_fn 是每个过滤器函数的具体实现,最终返回布尔值 TrueFalse,控制日志是否通过过滤。

上述调用时序图如下所示:

11.png

通过这样的方式,每个过滤器专注于一类过滤规则,例如过滤日志的级别、长度、时间范围、或是否包含特定关键字,这种模块化的设计可以让多个过滤器组合使用,提升代码的灵活性和可维护性。

  • 对于日志格式器来说​:格式器一般是一个类,且必须提供一个格式化方法(即 format 方法),用于接收日志的 levelmessage 等信息,并返回格式化后的字符串。例如下面代码中,我们实现了一个简单的输出时间和消息的格式器:

    class TimeMessageFormatter:
        """
        时间和消息格式器类,提供简洁的日志格式化功能
    
        该类将日志消息格式化为固定格式:"YYYY-MM-DD HH:MM:SS - message",
        忽略日志级别信息,适用于需要简化日志输出的场景。
    
        Attributes:
            None
    
        Methods:
            format(level, message):
                核心格式化方法,生成带时间戳的日志消息
        """
        def format(self, level: int, message: str) -> str:
            """
            格式化日志消息,生成带时间戳的输出字符串
    
            Args:
                level (int): 日志级别(未使用,保持接口统一)
                message (str): 原始日志内容
    
            Returns:
                str: 格式化后的日志消息,格式为:
                    "YYYY-MM-DD HH:MM:SS - message"
    
            Notes:
                - 时间戳使用本地时间
                - 日志级别参数被忽略(保持接口兼容性)
                - 返回字符串不包含换行符
            """
            # 获取当前时间的本地时间元组
            local_time = time.localtime()
    
            # 构造ISO 8601格式时间字符串(YYYY-MM-DD HH:MM:SS)
            current_time = "{:04}-{:02}-{:02} {:02}:{:02}:{:02}".format(
                local_time[0],  # 年 (4位数字)
                local_time[1],  # 月 (01-12)
                local_time[2],  # 日 (01-31)
                local_time[3],  # 时 (00-23)
                local_time[4],  # 分 (00-59)
                local_time[5]   # 秒 (00-59)
            )
    
            # 返回格式化消息(不包含日志级别信息)
            return f"{current_time} - {message}"
    

3.日志记录类的应用实验

在以下代码中,使用自定义日志记录模块,在 MicroPython 环境中实现日志记录、过滤和轮换功能;通过日志级别、长度、关键字过滤器控制记录的日志内容,并使用颜色格式器增强日志的可读性;代码还演示了如何处理和记录异常堆栈信息。

以下代码可以在我们提供的资料包中的 elegance-devkit v1\Demo\71 FileSys_Logger 文件夹找到。

示例代码如下所示:

# Python env   : MicroPython v1.23.0
# -*- coding: utf-8 -*-        
# @Time    : 2024/9/16 下午2:40   
# @Author  : 李清水            
# @File    : main.py       
# @Description : 文件系统类实验,使用logger模块完成日志记录功能

# ======================================== 导入相关模块 =========================================

# 导入日志模块
from logger import uLogLite, LogLevel
# 导入os模块,用于文件操作
import os
# 导入时间相关模块
import time
# 导入硬件相关模块
import machine

# ======================================== 全局变量 ============================================

# 定义文件路径,用于存储日志数据
logfile_path = "/logs"
# 创建日志文件目录
os.mkdir(logfile_path)

# ======================================== 功能函数 ============================================

# 日志级别过滤器
def level_filter(level: int, message: str) -> bool:
    """
​    简单的日志过滤器,允许记录INFO级别及以上的日志

​    Args:
​        level (int): 日志级别(使用LogLevel枚举值)
​        message (str): 日志消息内容

​    Returns:
​        bool: True表示记录日志,False表示不记录
​    """

​    ​# 简单的日志过滤器,只允许记录INFO级别及以上的日志
    return level >= LogLevel.INFO

# 日志长度过滤器
def length_filter(max_length: int) -> callable:
    """
​    生成日志长度过滤器函数

​    Args:
​        max_length (int): 允许的最大日志长度(字节数)

​    Returns:
​        callable[[int, str], bool]: 生成的过滤器函数
​    """
​    ​def filter_fn(level, message):
        return len(message) <= max_length
    return filter_fn

# 时间段过滤器
def time_range_filter(start_time: str, end_time: str) -> callable:
    """
​    生成时间段过滤器函数(24小时制)

​    Args:
​        start_time (str): 开始时间(格式'HH:MM',如'09:00')
​        end_time (str): 结束时间(格式'HH:MM',如'18:00')

​    Returns:
​        callable[[int, str], bool]: 生成的过滤器函数

​    Raises:
​        ValueError: 当时间格式无效时抛出
​    """
​    ​def filter_fn(level, message):
        # 获取当前时间的小时和分钟
        current_time = time.localtime()
        current_hour_minute = f"{current_time[3]:02}:{current_time[4]:02}"
        # 比较当前时间是否在指定范围内
        return start_time <= current_hour_minute <= end_time
    return filter_fn

# 关键字过滤器
def keyword_filter(keywords: list) -> callable:
    """
​    生成关键字过滤器函数

​    Args:
​        keywords (list[str]): 需要包含的关键字列表

​    Returns:
​        callable[[int, str], bool]: 生成的过滤器函数
​    """
​    ​def filter_fn(level, message):
        # 检查日志消息是否包含任何关键字
        return any(keyword in message for keyword in keywords)
    return filter_fn

# 打印目录中的所有.txt文件
def print_txt_files_in_directory(directory: str) -> None:
    """
​    遍历并打印目录中所有.txt文件的内容

​    Args:
​        directory (str): 要遍历的目录路径

​    Returns:
​        None

​    Raises:
​        OSError: 当目录访问失败时抛出
​    """
​    ​try:
        # 列出目录中的所有文件和子目录
        files = os.listdir(directory)

        # 遍历目录中的所有文件
        for file in files:
            # 检查文件是否以 .txt 结尾
            if file.endswith('.txt'):
                # 打印文件名称
                file_path = directory + '/' + file
                print(f"Reading file: {file_path}")

                try:
                    # 打开并读取文件
                    with open(file_path, 'r') as f:
                        # 逐行读取文件内容并打印
                        for line in f:
                            # end='' 防止自动换行
                            print(line, end='')
                except OSError as e:
                    print(f"Error reading file {file_path}: {e}")
                # 打印空行分隔文件内容
                print()
    except OSError as e:
        print(f"Error listing directory {directory}: {e}")

# ======================================== 自定义类 ============================================

# 终端颜色格式器
class ColoredFormatter:
    """
​    终端颜色格式器类,为日志消息添加ANSI颜色控制字符和时间戳。

​    该类根据日志级别自动为消息添加不同颜色的ANSI转义序列,并统一添加标准化时间戳。
​    支持所有标准日志级别(DEBUG/INFO/WARNING/ERROR/CRITICAL)的颜色定制。

​    Attributes:
​        COLORS (dict): 颜色代码映射表,包含:
​            - LogLevel.DEBUG: 蓝色
​            - LogLevel.INFO: 绿色
​            - LogLevel.WARNING: 黄色
​            - LogLevel.ERROR: 红色
​            - LogLevel.CRITICAL: 红色背景
​            - "ENDC": 颜色重置代码

​    Methods:
​        format(level, message):
​            核心格式化方法,添加颜色和时间戳
​    """
​    ​# 定义颜色代码
    COLORS = {
        LogLevel.DEBUG: '\033[94m',     # 蓝色
        LogLevel.INFO: '\033[92m',      # 绿色
        LogLevel.WARNING: '\033[93m',   # 黄色
        LogLevel.ERROR: '\033[91m',     # 红色
        LogLevel.CRITICAL: '\033[41m',  # 红色背景
        "ENDC": '\033[0m'               # 重置颜色
    }

    def format(self, level: int, message: str) -> str:
        """
​        格式化日志消息,添加时间戳和颜色控制字符

​        Args:
​            level (int): 日志级别(使用LogLevel枚举值)
​            message (str): 原始日志内容

​        Returns:
​            str: 格式化后的日志消息,包含:
​                 - 颜色控制字符(根据日志级别)
​                 - 时间戳(YYYY-MM-DD HH:MM:SS格式)
​                 - 日志级别和原始消息

​        Raises:
​            ValueError: 当日志级别无效时可能抛出

​        Notes:
​            - 颜色控制遵循ANSI转义序列
​            - 时间戳使用UTC时间
​            - 未识别的日志级别使用默认颜色
​        """
​        ​# 获取当前时间的本地时间元组
        local_time = time.localtime()

        # 构建ISO 8601格式时间字符串(YYYY-MM-DD HH:MM:SS)
        timestamp = "{:04}-{:02}-{:02} {:02}:{:02}:{:02}".format(
            local_time[0],  # 年 (4位数字)
            local_time[1],  # 月 (01-12)
            local_time[2],  # 日 (01-31)
            local_time[3],  # 时 (00-23)
            local_time[4],  # 分 (00-59)
            local_time[5]  # 秒 (00-59)
        )

        # 获取对应级别的颜色代码,默认使用ENDC(重置颜色)
        color = self.COLORS.get(level, self.COLORS["ENDC"])
        # 返回带有颜色的格式化消息,结尾重置颜色
        return f"{color} [log]: {timestamp} - {level}: {message}{self.COLORS['ENDC']}"

# ======================================== 初始化配置 ==========================================

# 上电延时3s
time.sleep(3)
# 打印调试信息
print("FreakStudio : Using logger module to save status of system")

# 创建RTC对象
rtc = machine.RTC()
# 设置RTC时间,以修改系统内部时间,也可以从NTP服务器获取时间
# 设置系统时间为 2024年9月17日 14:40:00
rtc.datetime((2024, 9, 17, 0, 14, 40, 0, 0))
# 创建日志对象:日志名称为"MyLogger",日志级别为INFO,输出到文件,最大日志数为20条
# 若是日志数超过20条,则会再创建一个新的文件
logger = uLogLite("MyLogger", LogLevel.INFO, logfile_path, max_logs=20)

# 添加多个过滤器
# 过滤掉INFO通知级别以下的日志
logger.add_filter(level_filter)
# 过滤掉日志长度超过50个字符的日志
logger.add_filter(length_filter(50))
# 过滤掉不含'FreakStudio'关键字的日志
logger.add_filter(keyword_filter(["FreakStudio"]))
# 设置使用带颜色的终端格式器
logger.set_formatter(ColoredFormatter())

# ========================================  主程序  ===========================================

# 测试日志模块
print("Test logger module output log")
# 记录调试信息,由于日志级别过滤器,该日志不会被记录
logger.debug("FreakStudio : This is a debug message")
# 延时30ms
time.sleep_ms(30)

# 记录信息日志
logger.info("FreakStudio : This is an info message")
# 由于日志长度过滤器,该日志不会被记录
logger.info("FreakStudio : This is a very very long long long long long info message,"
            "and the length is more than 50")
# 延时30ms
time.sleep_ms(30)

# 记录警告日志
logger.warning("FreakStudio : This is a warning message")
# 由于日志关键字过滤器,该日志不会被记录
logger.warning("This is a warning message")
# 延时30ms
time.sleep_ms(30)

# 记录错误日志
logger.error("FreakStudio : This is an error message")
# 延时30ms
time.sleep_ms(30)

# 记录严重错误日志
logger.critical("FreakStudio : This is a critical message")
# 延时30ms
time.sleep_ms(30)

# 测试日志轮换功能
print("Test logger rotate log file")
# 循环记录100条日志,测试日志轮换功能
for i in range(20):
    # 记录调试日志
    logger.debug(f"FreakStudio : This is a debug message {i}")
    time.sleep_ms(100)
    # 记录信息日志
    logger.info(f"FreakStudio : This is an info message {i}")
    time.sleep_ms(100)
    # 记录警告日志
    logger.warning(f"FreakStudio : This is a warning message {i}")
    time.sleep_ms(100)
    # 记录错误日志
    logger.error(f"FreakStudio : This is an error message {i}")
    time.sleep_ms(100)
    # 记录严重错误日志
    logger.critical(f"FreakStudio : This is a critical message {i}")
    time.sleep_ms(100)

# 测试日志记录异常堆栈信息功能
print("Test logger record exception stack")
# 故意抛出异常
try:
    1 / 0
except Exception as e:
    # 记录异常堆栈信息同时在终端显示
    logger.exception(e, terminal_display=True)

# 关闭日志文件
logger.close()
# 输出调试信息
print("FreakStudio : logging file write done")

在以上代码中,我们进行了如下流程:

  • 创建一个日志记录器 Logger 对象 logger,可以记录不同级别的日志信息,如调试、信息、警告、错误和严重错误,配置了最大日志数为 20 条,这里我们设置的日志文件保存地址为根目录下 log 文件夹中。

  • 日志记录器配置了多个过滤器和一个带颜色的格式器,用于控制记录的日志内容和格式

    • 日志过滤器​:
      • 级别过滤器 level_filter:只记录 INFO 级别及以上的日志。
      • 长度过滤器 length_filter:只记录长度不超过 50 个字符的日志。
      • 关键字过滤器 keyword_filter:只记录包含 "FreakStudio" 关键字的日志。
    • 日志格式器​:用于格式化日志消息并添加颜色,以便在终端中区分不同日志级别。
  • 创建一个 RTC 对象,并设置系统内部时间。

  • 打印调试信息,测试日志记录功能:

    • 记录各种级别的日志信息,验证过滤器和格式器的工作情况。
    • 测试日志轮换功能,通过循环记录多条日志以触发日志轮换。
  • 记录异常堆栈信息,模拟抛出异常并记录异常信息。

  • 关闭日志文件。

    这里,我们定义了一个 ColoredFormatter 类用于为日志消息添加颜色,以增强在终端中的可读性:

# 终端颜色格式器
class ColoredFormatter:
    """
​    终端颜色格式器类,为日志消息添加ANSI颜色控制字符和时间戳。

​    该类根据日志级别自动为消息添加不同颜色的ANSI转义序列,并统一添加标准化时间戳。
​    支持所有标准日志级别(DEBUG/INFO/WARNING/ERROR/CRITICAL)的颜色定制。

​    Attributes:
​        COLORS (dict): 颜色代码映射表,包含:
​            - LogLevel.DEBUG: 蓝色
​            - LogLevel.INFO: 绿色
​            - LogLevel.WARNING: 黄色
​            - LogLevel.ERROR: 红色
​            - LogLevel.CRITICAL: 红色背景
​            - "ENDC": 颜色重置代码

​    Methods:
​        format(level, message):
​            核心格式化方法,添加颜色和时间戳
​    """
​    ​# 定义颜色代码
    COLORS = {
        LogLevel.DEBUG: '\033[94m',     # 蓝色
        LogLevel.INFO: '\033[92m',      # 绿色
        LogLevel.WARNING: '\033[93m',   # 黄色
        LogLevel.ERROR: '\033[91m',     # 红色
        LogLevel.CRITICAL: '\033[41m',  # 红色背景
        "ENDC": '\033[0m'               # 重置颜色
    }

    def format(self, level: int, message: str) -> str:
        """
​        格式化日志消息,添加时间戳和颜色控制字符

​        Args:
​            level (int): 日志级别(使用LogLevel枚举值)
​            message (str): 原始日志内容

​        Returns:
​            str: 格式化后的日志消息,包含:
​                 - 颜色控制字符(根据日志级别)
​                 - 时间戳(YYYY-MM-DD HH:MM:SS格式)
​                 - 日志级别和原始消息

​        Raises:
​            ValueError: 当日志级别无效时可能抛出

​        Notes:
​            - 颜色控制遵循ANSI转义序列
​            - 时间戳使用UTC时间
​            - 未识别的日志级别使用默认颜色
​        """
​        ​# 获取当前时间的本地时间元组
        local_time = time.localtime()

        # 构建ISO 8601格式时间字符串(YYYY-MM-DD HH:MM:SS)
        timestamp = "{:04}-{:02}-{:02} {:02}:{:02}:{:02}".format(
            local_time[0],  # 年 (4位数字)
            local_time[1],  # 月 (01-12)
            local_time[2],  # 日 (01-31)
            local_time[3],  # 时 (00-23)
            local_time[4],  # 分 (00-59)
            local_time[5]  # 秒 (00-59)
        )

        # 获取对应级别的颜色代码,默认使用ENDC(重置颜色)
        color = self.COLORS.get(level, self.COLORS["ENDC"])
        # 返回带有颜色的格式化消息,结尾重置颜色
        return f"{color} [log]: {timestamp} - {level}: {message}{self.COLORS['ENDC']}"

其中,COLORS 是一个字典,定义了不同日志级别对应的终端颜色代码:

  • \033[94m:蓝色,用于 DEBUG 级别。
  • \033[92m:绿色,用于 INFO 级别。
  • \033[93m:黄色,用于 WARNING 级别。
  • \033[91m:红色,用于 ERROR 级别。
  • \033[41m:红色背景,用于 CRITICAL 级别。
  • \033[0m:重置颜色,用于恢复默认颜色。

这里,终端显示不同颜色是通过使用 ANSI 转义码(ANSI escape codes)实现的,这些转义码是控制终端文本样式的标准机制,包括颜色、粗体、下划线等效果:ANSI 转义码是一个特殊的字符序列,用于告诉终端如何显示文本,它们以 \033(或 \e)开头,后跟方括号 [ 和一系列参数,最后以字母结尾。

12.png

在格式化日志消息 format 方法中,我们首先获取当前的本地时间元组,然后将时间元组格式化为字符串,格式为 YYYY-MM-DD HH:MM:SS,表示日志记录的时间。

在颜色选择与消息构造的代码中:

# 根据日志级别选择颜色
    color = self.COLORS.get(level, self.COLORS["ENDC"])
    # 返回带有颜色的格式化消息
    return f"{color} [log]: {timestamp} - {level}: {message}{self.COLORS['ENDC']}"

我们根据日志级别从 COLORS 字典中获取对应的颜色代码,如果级别未在字典中定义,则使用 ENDC 作为默认值;构造最终的日志消息,包含时间戳、日志级别、日志内容,并用颜色代码包裹,最终在日志消息末尾添加 ENDC 以重置颜色。

最后,我们还定义了一个用于遍历指定目录中的所有后缀为 .txt 的文件,并逐行打印文件内容的函数:

# 打印目录中的所有.txt文件
def print_txt_files_in_directory(directory: str) -> None:
    """
​    遍历并打印目录中所有.txt文件的内容

​    Args:
​        directory (str): 要遍历的目录路径

​    Returns:
​        None

​    Raises:
​        OSError: 当目录访问失败时抛出
​    """
​    ​try:
        # 列出目录中的所有文件和子目录
        files = os.listdir(directory)

        # 遍历目录中的所有文件
        for file in files:
            # 检查文件是否以 .txt 结尾
            if file.endswith('.txt'):
                # 打印文件名称
                file_path = directory + '/' + file
                print(f"Reading file: {file_path}")

                try:
                    # 打开并读取文件
                    with open(file_path, 'r') as f:
                        # 逐行读取文件内容并打印
                        for line in f:
                            # end='' 防止自动换行
                            print(line, end='')
                except OSError as e:
                    print(f"Error reading file {file_path}: {e}")
                # 打印空行分隔文件内容
                print()
    except OSError as e:
        print(f"Error listing directory {directory}: {e}")

函数接受一个参数 directory,表示要遍历的目录,首先我们使用 os.listdir(directory) 列出指定目录中的所有文件和子目录,然后遍历 files 列表中的每个条目,检查文件后缀并处理 .txt 文件,进行逐行读取文件内容。

系统整个流程如下图所示:

13.png

烧录代码,打开终端,输出如下:

14.png

可以看到,日志文件写入完毕,我们使用 os.listdir(logfile_path) 命令查看当前目录下面文件:

15.png

显示出多个名称带有时间戳的文件,接着调用 print_txt_files_in_directory 函数,查看文件内容:

print_txt_files_in_directory(logfile_path)

16.png

可以看到,日志过滤器功能正常,以下日志均没有被记录:

# 记录调试信息,由于日志级别过滤器,该日志不会被记录
logger.debug("FreakStudio : This is a debug message")
# 由于日志长度过滤器,该日志不会被记录
logger.info("FreakStudio : This is a very very long long long long long info message,"
            "and the length is more than 50")
# 由于日志关键字过滤器,该日志不会被记录
logger.warning("This is a warning message")

在后面记录的多条日志中,分为多个文件进行存储:

ScreenShot_2026-06-26_054257_360.png

可以看到,文件中也记录了异常的堆栈信息:

18.png

mmexport1782423889111.gif

这里需要注意的是,在自定义的 logger 类中,写入日志文件时我们是以追加模式进行,当文件越来越大时,文件系统需要更多时间找到文件的末尾位置并执行写入操作,我们在 logger 类的 _write_log 方法中,添加计算耗时的代码:

@micropython.native
def _write_log(self, formatted_message: str) -> None:
    """
​    线程安全的日志写入操作,根据日志输出方式选择写入方式,可以选择写入文件或终端。

​    执行流程:
​        1. 获取线程锁
​        2. 检查轮转条件
​        3. 执行实际写入
​        4. 更新计数器
​        5. 释放锁

​    Args:
​        formatted_message [str]: 格式化后的完整日志条目。

​    Returns:
​        None

​    Notes:
​        - 自动处理日志轮转
​        - 写入失败时降级到终端输出
​    """

​    ​# 锁定线程,确保线程安全
    with self.lock:
        # 记录日志写入开始时间
        start_time = time.ticks_us()
        
        ... ...
        
        # 记录日志写入结束时间
        end_time = time.ticks_us()
        # 计算写入日志耗时
        # 计算耗时
        write_time = time.ticks_diff(end_time, start_time)
        # 打印耗时,单位是ms
        print(f"write log cost {write_time / 1000} ms")

清空树莓派 Pico 上的所有文件,烧录代码,打开终端:

20.png

可以看到,写入时间阶段性变大,在需要创建新文件时也会增加耗时。

同时,我们也可以通过 os.stat('/logs/MyLogger_20240917_144011.txt') 指令来查看文件相关信息:

21.png

>>> os.stat('/logs/MyLogger_20240917_144011.txt')
(32768, 0, 0, 0, 0, 0, 1690, 1726584014, 1726584014, 1726584014)

这里,输出信息包括 (mode, ino, dev, nlink, uid, gid, size, atime, mtime, ctime)

22.png

这里,除去上述我们编写的过滤器函数和格式器类外,大家也可以定义自己需要的特定过滤器函数,此时需要注意:

  • 过滤规则的灵活性​:日志过滤器应支持根据多种条件进行过滤,包括但不限于日志级别、关键字、时间范围、消息长度、设备编号或会话编号等
  • 接口一致性​:日志过滤器应具有一致的接口,方便集成和扩展
  • 可组合性​:日志系统应允许多个过滤器共同作用,即日志消息需要通过所有过滤器的检查才能被记录,此过滤器应该是可组合的

而在自定义格式器时,需要注意除去带颜色的终端格式外,日志格式器应支持多种输出格式,常见的格式化方式包括:

  • JSON格式​:结构化日志,便于机器解析和处理
  • 简单文本格式​:输出日志级别、时间戳、消息
  • 自定义格式​:根据用户需求,支持更复杂的格式(如加入设备 ID、会话 ID 等)

相比于 MicroPython 的 logging 模块,我们自定义的 logger 类同样提供了日志记录的核心功能,包括日志级别、格式化、处理程序(终端和文件)和异常处理,并且实现了线程安全的机制,同时支持文件轮转功能。但 MicroPython 的 logging 模块,提供了更多的功能和扩展性,例如 FilterHandler 的更多选项,适合更复杂的日志记录需求。

posted @ 2026-09-03 17:28  FreakStudio  阅读(7)  评论(0)    收藏  举报