4、日志格式定制:Formatter对象、自定义时间格式、自定义日志前缀
日志这东西,光有内容还不够,格式也很关键。你想想看,线上出问题了,一堆日志堆在那,要是格式乱七八糟,你找关键信息得找到猴年马月去。Python 的 logging 模块给了我们一个很灵活的工具——Formatter 对象,说白了就是用来定义日志长什么样的。
4.1 Formatter 对象:日志的“排版师”
Formatter 是 logging 模块里的一个核心类。它的任务就是把 LogRecord(日志记录对象)里的各种信息,比如时间、级别、模块名、消息内容,按照你指定的模板拼成一行字符串。
我刚开始用的时候,觉得这东西不就是个格式化字符串嘛,有啥好讲的。后来在项目里踩了坑才发现,格式定得好,排查问题能省一半时间。
基本用法很简单:
import logging
# 创建一个 Formatter 对象
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
# 把它绑定到 handler 上
handler = logging.StreamHandler()
handler.setFormatter(formatter)
logger = logging.getLogger('my_logger')
logger.addHandler(handler)
logger.setLevel(logging.DEBUG)
logger.info('这是一条测试日志')
# 输出:2025-01-15 10:30:45,123 - INFO - 这是一条测试日志
这里的 %(asctime)s、%(levelname)s、%(message)s 都是占位符。Formatter 支持的占位符还挺多的,我列几个常用的:
| 占位符 | 含义 | 示例输出 |
|---|---|---|
%(asctime)s |
日志产生的时间 | 2025-01-15 10:30:45,123 |
%(levelname)s |
日志级别名称 | INFO、ERROR |
%(message)s |
日志消息内容 | 用户登录成功 |
%(name)s |
Logger 的名称 | my_logger |
%(filename)s |
产生日志的文件名 | app.py |
%(lineno)d |
产生日志的行号 | 42 |
%(funcName)s |
产生日志的函数名 | login |
%(process)d |
进程 ID | 12345 |
%(thread)d |
线程 ID | 67890 |
%(asctime)s|%(levelname)s|%(message)s,用竖线分隔,方便用 awk 或 cut 命令切割。
4.2 自定义时间格式:别让时间拖后腿
默认的 %(asctime)s 输出格式是 2025-01-15 10:30:45,123。这个格式其实还行,但有时候我们需要更精确的时间,或者想改成别的格式。比如我有个项目需要记录毫秒级的时间戳用于性能分析,默认格式就不够用了。
Formatter 允许你通过 datefmt 参数自定义时间格式,语法跟 time.strftime() 一样:
import logging
# 自定义时间格式:精确到毫秒,并且用下划线代替空格
formatter = logging.Formatter(
'%(asctime)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d_%H:%M:%S.%f' # %f 表示微秒
)
handler = logging.StreamHandler()
handler.setFormatter(formatter)
logger = logging.getLogger('time_logger')
logger.addHandler(handler)
logger.setLevel(logging.DEBUG)
logger.info('带微秒的时间格式')
# 输出:2025-01-15_10:30:45.123456 - INFO - 带微秒的时间格式
常用的时间格式化指令:
| 指令 | 含义 | 示例 |
|---|---|---|
%Y |
四位年份 | 2025 |
%m |
两位月份 | 01 |
%d |
两位日期 | 15 |
%H |
24小时制小时 | 10 |
%M |
分钟 | 30 |
%S |
秒 | 45 |
%f |
微秒(6位) | 123456 |
%z |
时区偏移 | +0800 |
%f 微秒在 Python 的 logging 中默认是补零到6位的。如果你只想要毫秒(3位),可以用 %(msecs)d 这个占位符,它直接输出毫秒部分。不过 %(msecs)d 不能放在 datefmt 里,得放在格式字符串中。
举个例子,如果你想要 2025-01-15 10:30:45.123 这种带毫秒的格式:
formatter = logging.Formatter(
'%(asctime)s.%(msecs)d - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
# 输出:2025-01-15 10:30:45.123 - INFO - 消息内容
嗯,这里要注意,%(msecs)d 是 LogRecord 自带的属性,不是通过 datefmt 控制的。所以你得把它写在格式字符串里,而不是 datefmt 参数里。
4.3 自定义日志前缀:给日志打上“标签”
日志前缀,说白了就是每条日志前面加的那段固定信息。除了时间、级别这些标准字段,我们经常需要加一些自定义信息。比如:
- 服务名称(在微服务架构里特别重要)
- 请求 ID(用于链路追踪)
- 环境标识(dev、test、prod)
- 用户 ID(审计日志需要)
我有个项目是微服务架构,十几个服务同时跑,日志全打到同一个 Elasticsearch 集群里。要是每条日志不带上服务名,你根本分不清是哪来的。
实现自定义前缀有几种方式:
方式一:在格式字符串里硬编码
最简单粗暴的方式,直接把固定文本写进格式里:
formatter = logging.Formatter(
'[订单服务] %(asctime)s - %(levelname)s - %(message)s'
)
# 输出:[订单服务] 2025-01-15 10:30:45,123 - INFO - 订单创建成功
这种方式适合服务名固定不变的情况。但如果你有多个服务,每个服务都得写一遍,维护起来有点烦。
方式二:使用 Logger 的 name 属性
Logger 的 name 属性天然适合做前缀。你可以在创建 Logger 时传入服务名:
logger = logging.getLogger('订单服务')
formatter = logging.Formatter(
'[%(name)s] %(asctime)s - %(levelname)s - %(message)s'
)
# 输出:[订单服务] 2025-01-15 10:30:45,123 - INFO - 订单创建成功
这样你只需要在创建 Logger 时改一下名字,格式模板不用动。我在项目中经常用这个方式,配合配置文件,非常灵活。
方式三:使用 Filter 添加自定义字段
这是最强大的方式。你可以写一个自定义 Filter,往 LogRecord 里注入额外的字段:
import logging
class RequestIDFilter(logging.Filter):
def filter(self, record):
# 假设你有一个全局的请求 ID 上下文
record.request_id = get_current_request_id() # 伪代码
return True
formatter = logging.Formatter(
'[%(request_id)s] %(asctime)s - %(levelname)s - %(message)s'
)
handler = logging.StreamHandler()
handler.setFormatter(formatter)
handler.addFilter(RequestIDFilter())
logger = logging.getLogger('my_app')
logger.addHandler(handler)
logger.setLevel(logging.DEBUG)
logger.info('用户登录成功')
# 输出:[req-abc123] 2025-01-15 10:30:45,123 - INFO - 用户登录成功
filter() 方法可以修改 LogRecord 对象的属性。你添加的属性,在 Formatter 里直接用 %(属性名)s 就能引用。这招在链路追踪场景下特别好用。
方式四:继承 Formatter 类
如果你需要更复杂的逻辑,比如根据日志级别动态改变前缀颜色,可以继承 Formatter 类:
import logging
class CustomFormatter(logging.Formatter):
def format(self, record):
# 在格式化之前,可以修改 record 的内容
if record.levelno == logging.ERROR:
record.custom_prefix = '[严重错误]'
else:
record.custom_prefix = '[普通信息]'
return super().format(record)
formatter = CustomFormatter(
'%(custom_prefix)s %(asctime)s - %(levelname)s - %(message)s'
)
handler = logging.StreamHandler()
handler.setFormatter(formatter)
logger = logging.getLogger('custom_logger')
logger.addHandler(handler)
logger.setLevel(logging.DEBUG)
logger.error('数据库连接失败')
# 输出:[严重错误] 2025-01-15 10:30:45,123 - ERROR - 数据库连接失败
format() 方法里修改了 record.msg 原始消息。结果同一个 Logger 的多个 Handler 都引用了同一个 LogRecord 对象,导致后面的 Handler 拿到的消息已经被改过了。所以,尽量不要在 Formatter 里修改原始消息内容,要加东西就加自定义属性。
4.4 综合示例:一个生产级的日志格式
最后,我分享一个我在生产环境中常用的日志格式配置。它包含了时间、服务名、请求 ID、文件名、行号、级别和消息:
import logging
class ProductionFormatter(logging.Formatter):
def __init__(self, service_name='unknown'):
self.service_name = service_name
super().__init__(
fmt='%(asctime)s.%(msecs)d | %(service_name)s | %(request_id)s | '
'%(filename)s:%(lineno)d | %(levelname)s | %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
def format(self, record):
record.service_name = self.service_name
# 如果没有 request_id,给个默认值
if not hasattr(record, 'request_id'):
record.request_id = '-'
return super().format(record)
# 使用示例
formatter = ProductionFormatter(service_name='订单服务')
handler = logging.StreamHandler()
handler.setFormatter(formatter)
logger = logging.getLogger('order_service')
logger.addHandler(handler)
logger.setLevel(logging.DEBUG)
# 模拟有请求 ID 的情况
logger.info('订单创建成功', extra={'request_id': 'req-001'})
# 输出:2025-01-15 10:30:45.123 | 订单服务 | req-001 | app.py:42 | INFO | 订单创建成功
# 模拟没有请求 ID 的情况
logger.info('系统启动完成')
# 输出:2025-01-15 10:30:46.456 | 订单服务 | - | app.py:46 | INFO | 系统启动完成
这种格式的好处是:
- 可解析性强:用竖线分隔,任何日志分析工具都能轻松解析
- 信息完整:时间精确到毫秒,有服务名和请求 ID 用于追踪
- 容错性好:即使某个字段缺失,也有默认值,不会导致格式化失败
说白了,日志格式这件事,没有绝对的标准。但有一条原则我始终遵守:写日志是为了给人看、给机器分析的。所以格式要兼顾可读性和可解析性。你花 10 分钟把格式定好,将来排查问题时能省下 10 个小时。这笔账,怎么算都划算。