整体目标
涉及的库均为Python3自带库实现
- logging
- sys
- enum
终端显示彩色基本原理参考👉Terminal里的颜色的那些事
Python打印日志可以直接借用logging
自带的库实现,但是默认的打印实在太丑了,长下面这样
这只是一条日志看着还好比较清爽,如果多了之后可想而知,大片红色看着由多么糟糕,借鉴SpringBoot的处理怎么做到SpringBoot那样的彩色日志输出呢?
这里突然想起来,Java自带的日志框架(JUL)打印也是一片红色,可能原生的东西都比较呆正。
日志框架的组成
日志框架无论哪种语言基本都是遵循一样的做法,包括以下组件:
- Logger:日志记录器,用于记录打印日志,我们操作的就是这个对象
- Handler:处理器,有些也叫Appender,就是实际处理日志记录的东西,比如说我们生成一条日志字符串,这个字符串是输出到控制台,还是输出到文件呢?或者说两者都输出呢?这就是处理器做的事情,如果存在多个处理器还可以重复输出。
- Formatter:格式化器,有些框架也叫Layout,就是格式化信息的,比如我们看到上面工工整整的时间,日志级别,就是我们定义好的格式,交给他处理。
正常打印一条日志,都是由日志记录器开始,日志记录器存在属性Handler处理器,处理器Handler存在属性Formatter。因此我们需要操作的对象就是Formatter。
import logging
if __name__ == '__main__':
# Logger Handler Formatter 三者之间的关系,相互链接建立联系
formatter = logging.Formatter(logging.BASIC_FORMAT)
handler = logging.StreamHandler()
handler.setFormatter(formatter)
logging.getLogger().addHandler(handler)
因此我们如果要自定义处理器或者自定义格式化器,都是从上面的setFormatter和addHandler入手的。
如何自定义这些组件? 答案是继承,Python中我们无法实现接口,但是可以继承基类,只要是儿子,自然也就是处理器,或者格式化器。
class CustomFormatter(logging.Formatter):
# 重写父类的方法,再调用父类,相当于增强方法
def format(self, record):
# 这里就可以处理记录record的了
s = super().format(record)
return s
我们的基本思路就是拿到需要格式化的数据,%(asctime)s
时间字符串,%(levelname)s
等级字符串这些,拿到这些数据那就可以进行彩色处理了。
终端彩色显示
ANSI转义序列(ANSI escape sequences)是一种带内信号的转义序列标准,用于控制视频文本终端上的光标位置、颜色和其他选项。在文本中嵌入确定的字节序列,大部分以ESC转义字符和"["字符开始,终端会把这些字节序列解释为相应的指令,而不是普通的字符编码。
大体原理就是类似\r
代表回到开头,\n
代表换行这种,具有一定的转义含义的东西,在终端不同的转义含义就可以控制输出的字体颜色,背景颜色,字体加粗,划线等等。
基本格式为:
\033[XXXm
其中\033[
以及后面的m
都是固定的,只有XXX代表不同含义,分别由;
分割,比如下面的例子
print("\033[31;9;3mHello Python\033[m")
其中31表示前景色(字体颜色)为红色,9表示划除线,3表示斜体
效果图
注意:并不是说第一个数字一定要控制颜色,也可以完全没有颜色控制的!
具体的ANSI转义表参考复杂正规版
简单的网上搜一下就好,就是说每个数字代表什么含义的码表,比如你想要粗体应该是数字1。
我们的目的是颜色,标准颜色的数字是30~37分别如下表示:
颜色 | 前景色代码 |
---|---|
黑色 | 30 |
红色 | 31 |
绿色 | 32 |
黄色 | 33 |
蓝色 | 34 |
品红 | 35 |
青色 | 36 |
白色 | 37 |
print("\033[31mINFO\033[m")
print("\033[32mINFO\033[m")
print("\033[33mINFO\033[m")
print("\033[34mINFO\033[m")
print("\033[35mINFO\033[m")
print("\033[36mINFO\033[m")
print("\033[37mINFO\033[m")
效果图
所以我们只需要获取到指定字符串然后包裹封装就可以了,问题是怎么获取呢?
完整实现
from enum import Enum
import logging
# 枚举类用来记录颜色
class Color(Enum):
BLACK = 30
RED = 31
GREEN = 32
YELLOW = 33
BLUE = 34
CYAN = 36
WHITE = 37
DEFAULT = 39
# ANSI转义颜色包裹需要颜色显示的字符串
def color_text(text: str, color: Color) -> str:
return "\033[{}m{}\033[0m".format(str(color.value), text)
# 自定义格式化器继承Formatter,在format格式化之前拿到需要变化的字段字符串,这里主要就是日志级别和记录器名称
class CustomFormatter(logging.Formatter):
def format(self, record):
if record.levelname == "INFO":
level_name_color = Color.GREEN
elif record.levelname == "ERROR":
level_name_color = Color.RED
elif record.levelname == "WARNING":
level_name_color = Color.YELLOW
else:
level_name_color = Color.DEFAULT
record.levelname = color_text(record.levelname, level_name_color)
record.name = color_text(record.name, Color.CYAN)
s = super().format(record)
return s
# 设置自定义格式化器,这里封装一个方法用来获取日志记录器,正好在这里添加格式化器,控制台输出处理器Handler默认是StreamHandler
def getLogger(name: str = __name__):
logger = logging.getLogger(name)
handler = logging.StreamHandler()
handler.setLevel("DEBUG")
format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
handler.setFormatter(CustomFormatter(fmt=format))
logger.addHandler(handler)
logger.propagate = 0
return logger
效果图
注意:这里需要配置记录器的日志级别,记录器的日志级别向下传递给处理器,所以其实处理器本质不设置日志级别也是可以的,由记录器传递。
上面还有个关键问题,为什么整体颜色还是红色?
答案是对于终端的输出分为stdout和stderr,而StreamHandler.stream输出流默认输出为stderr。
因此我们还需要加一行关键的代码。
在getLogger方法中添加
handler.stream = sys.stdout
最终效果图
Note:
- 为了更接近SpringBoot效果,对格式化内容做了调整
- 日志级别其实最高也就WARNING共7个字符,但是由于加了转义序列,实际长度并不是7,因此如果太小则会出现对不齐的情况,这里采用
%(levelname)18s
占位18个字符右对齐。
个人认为比较好看的格式:
"%(asctime)s %(levelname)18s %(process)d --- [%(module)10s] [ %(name)15s] %(funcName)-20s \t: %(message)s"
效果图
顺序分别记录下面信息:
- 日志时间
- 日志级别
- 进程ID
- 模块名,即输出日志的文件名称
- 记录器Logger名称
- 方法名
技巧: 对于需要改变的颜色因为包裹了转义字符,需要给占位符的字符尽量大些(实际显示并不会占位太长),而对于不需要颜色显示的字段,尽量估算最大可能显示的长度占位。
高级玩法
如何让日志在行内打印?
如果日志打印一直是重复的话,我们希望打印内容不要换行,而是行内打印,类似print下面的用法。
try:
last_time = time.time()
while True:
print(f"\rHello Python...{round(time.time()-last_time,2)}秒", end='')
time.sleep(1)
except KeyboardInterrupt:
print("\n程序运行结束")
sys.exit(0)
效果图
这里涉及两个知识:
\r
转义的含义是光标移动到开头- end=’’ 说明没行的结束没有换行符号
\n
print的本质就是输出流,输出对象就是sys.out
,之前提过输出流分为stdout和stderr,作为标准输出,通过end=’’ 这样在流写二进制这种字节数值给计算机终端时候,判断没有\n
就不会进行换行操作,于是一直在当前行打印,由于光标移动到了开头,所以打印的实际效果是:
# 第一遍打印
Hello Python...1秒
# 第二遍打印
Hello Python...2秒
但是由于都在第一行,所以显示的效果就是在计算秒表,这是基本原理。
日志行内打印完整实现
# 这是自定义的格式化器,只要是为了ANSI转义包裹下字符串颜色显示
class CustomFormatter(logging.Formatter):
# 主要目的是为了记录上一次打印的数据
last_str = None
def format(self, record):
if record.levelname == "INFO":
level_name_color = Color.GREEN
elif record.levelname == "ERROR":
level_name_color = Color.RED
elif record.levelname == "WARNING":
level_name_color = Color.YELLOW
else:
level_name_color = Color.DEFAULT
record.levelname = color_text(record.levelname, level_name_color)
record.funcName = color_text(record.funcName, Color.CYAN)
s = super().format(record)
self.last_str = s
return s
class Logger(logging.Logger):
def __init__(self, name):
super().__init__(name)
# 覆盖原本的info方法,就是用来增强该方法,定义一个inline可选参数,如果指定则行内打印
def info(self, msg, inline: bool = False, *args, **kwargs):
if inline:
for handler in self.handlers:
if isinstance(handler, logging.StreamHandler):
# 就是这里保证打印在一行的,等价print(end='')的作用
old = handler.terminator
handler.terminator = ''
# 实际打印方法还是原来的,由于方法包裹,调用方法永远是info,因此设置stacklevel为2这样调用方法名就是上级了
super().info(msg, *args, stacklevel=2)
if isinstance(handler.formatter, CustomFormatter):
if handler.formatter.last_str is not None:
# 如果存在字符我们就回到光标处继续打印
handler.stream.write('\r' + handler.formatter.last_str)
handler.stream.flush()
# 结束重置结束符,不然一直回行内打印(单例对象)
handler.terminator = old
else:
super().info(msg, *args, stacklevel=2)
效果图