日志是系统可观测性的重要组成部分,logrus作为Go语言中最流行的结构化日志库之一,提供了强大的扩展能力。本文将深入探讨如何通过自定义格式和Hook扩展实现专业的日志解决方案。
1. 自定义日志格式实现
a. 为什么要自定义格式
logrus默认提供JSON和Text两种格式,但在实际场景中我们通常需要:
- 更直观的颜色区分
- 定制化的字段排列
- 关键信息的突出显示
- 源码位置的快速定位
b. 实现方法
Formatter接口的源码
// Formatter 接口用于实现自定义的 Formatter。它接收一个 `Entry` 对象。
// 它公开了所有字段,包括默认字段:
//
// * `entry.Data["msg"]`:由 Info、Warn、Error 等方法传递的消息。
// * `entry.Data["time"]`:时间戳。
// * `entry.Data["level"]`:记录日志时使用的日志级别。
//
// 通过 `WithField` 或 `WithFields` 添加的任何其他字段也会包含在 `entry.Data` 中。
// Format 方法应返回一个字节数组,该数组随后会被写入到 `logger.Out`。
type Formatter interface {
Format(*Entry) ([]byte, error)
}
想自定义 logrus 日志库日志的格式,要通过实现logrus.Formatter 接口来搞定:
// Format
//
// @Description: 自定义日志输出格式,实现 Formatter 接口中的 Format(entry *logrus.Entry) ([]byte, error) 方法
func (t *LogFormatter) Format(entry *logrus.Entry) ([]byte, error) {
// 根据日志级别设置不同的颜色变量,用于高亮显示日志等级
var levelColor int
switch entry.Level {
// 对于 Debug 和 Trace 级别,设置颜色为灰色
case logrus.DebugLevel, logrus.TraceLevel:
levelColor = gray
// 对于 Warn 级别,设置颜色为黄色
case logrus.WarnLevel:
levelColor = yellow
// 对于 Error、Fatal 和 Panic 级别,设置颜色为红色
case logrus.ErrorLevel, logrus.FatalLevel, logrus.PanicLevel:
levelColor = red
// 默认情况下(例如 Info 级别),设置颜色为蓝色
default:
levelColor = blue
}
// 定义一个字节缓冲区,用于存储格式化后的日志信息
// 注意:如果你在自己的代码中直接调用 Formatter 的 Format 方法,
// 而没有经过 Logger 的标准日志处理流程,那么 entry.Buffer 可能就没有被赋值,
// 需要你手动创建一个新的 bytes.Buffer。
var b *bytes.Buffer
// 如果 entry 已经存在缓冲区,则直接使用它
if entry.Buffer != nil {
b = entry.Buffer
} else {
// 如果没有缓冲区,则新建一个
b = &bytes.Buffer{}
}
// 将 entry 中的时间戳格式化为自定义格式 "2006-01-02 15:04:05"
timestamp := entry.Time.Format("2006-01-02 15:04:05")
// 检查 entry 是否包含调用者信息(例如日志调用的文件、函数和行号)
if entry.HasCaller() {
// 获取调用函数的名称
funcVal := entry.Caller.Function
// 获取调用文件名和行号,使用 path.Base 只显示文件名而非完整路径
fileVal := fmt.Sprintf("%s:%d", path.Base(entry.Caller.File), entry.Caller.Line)
// 使用 fmt.Fprintf 将格式化后的日志信息写入缓冲区
// 格式化字符串中包含:时间戳、颜色控制码、日志级别、文件信息、函数名称以及日志消息
fmt.Fprintf(b, "[%s] \x1b[%dm[%s]\x1b[0m %s %s %s\n",
timestamp, // 格式化后的时间戳
levelColor, // 日志级别对应的颜色
entry.Level, // 日志级别
fileVal, // 文件名和行号
funcVal, // 调用的函数名称
entry.Message) // 日志消息
} else {
// 如果没有调用者信息,则仅输出时间戳、颜色控制码、日志级别和日志消息
fmt.Fprintf(b, "[%s] \x1b[%dm[%s]\x1b[0m %s\n",
timestamp, // 格式化后的时间戳
levelColor, // 日志级别对应的颜色
entry.Level, // 日志级别
entry.Message) // 日志消息
}
// 返回缓冲区中的字节数据,以及 nil 表示没有错误发生
return b.Bytes(), nil
}
效果展示
[2025-02-09 20:59:36] [info] main.go:14 main.main 测试日志
[2025-02-09 20:59:36] [warning] main.go:15 main.main 测试日志
[2025-02-09 20:59:36] [error] main.go:16 main.main 测试日志
2. Hook 扩展功能实现
a. Hook 功能扩展的典型应用
logrus的Hook机制允许我们在日志事件发生时执行自定义操作,典型应用场景包括:
- 日志文件切割
- 敏感信息过滤
- 日志报警触发
- 多端同步输出
b. 按时间分割的文件日志 Hook 实现
ⅰ. 核心结构定义
type FileDateHook struct {
file *os.File // 当前日志文件
logPath string // 日志存储根目录
fileDate string // 当前日期标识
appName string // 应用名称
}
ⅱ. 接口方法实现
Formatter接口的源码
// 当日志记录达到你实现的接口中 `Levels()` 返回的日志级别时,将触发的钩子。
// 注意:该钩子不会在 goroutine 或者带有工作者的 channel 中触发,
// 如果你的调用是非阻塞的,并且你不希望 `Levels()` 返回级别的日志调用被阻塞,
// 那么你需要自己处理这方面的功能。
type Hook interface {
Levels() []Level
Fire(*Entry) error
}
Levels方法:
func (hook FileDateHook) Levels() []logrus.Level {
return logrus.AllLevels
}
声明处理所有级别的日志事件
Fire方法:
func (hook FileDateHook) Fire(entry *logrus.Entry) error {
// 时间检测与文件切换逻辑...
// 写入日志到文件...
}
核心流程:
- 提取日志时间并格式化
- 判断日期是否变更
- 关闭旧文件(如果存在)
- 创建新目录和日志文件
- 写入日志内容
具体实现:
func (hook FileDateHook) Fire(entry *logrus.Entry) error {
// 步骤1:提取日志时间并格式化
timer := entry.Time.Format("2006-01-02")
line, _ := entry.String()
// 步骤2:判断时间是否变化
if hook.fileDate == timer {
hook.file.Write([]byte(line))
return nil
}
// 步骤3:时间变化时的处理
hook.file.Close() // 关闭旧文件
// 生成新文件名(示例:logPath/2023-10-10/app.log)
os.MkdirAll(fmt.Sprintf("%s/%s", hook.logPath, timer), os.ModePerm)
filename := fmt.Sprintf("%s/%s/%s.log", hook.logPath, timer, hook.appName)
// 打开新文件
hook.file, _ = os.OpenFile(filename, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0600)
// 更新当前时间标识
hook.fileDate = timer
hook.file.Write([]byte(line)) // 写入日志
return nil
}
ⅲ. 初始化日志文件,注册钩子
- 生成初始时间目录
- 打开初始日志文件
- 注册钩子
// InitFile
//
// @Description: 初始化日志文件,将日志写入到指定路径下按照日期划分的日志文件中
func InitFile(logPath, appName string) {
// 生成初始时间目录
fileDate := time.Now().Format("2006-01-02")
// 创建目录(示例:/var/log/myapp/2023-10-10/)
err := os.MkdirAll(fmt.Sprintf("%s/%s", logPath, fileDate), os.ModePerm)
if err != nil {
logrus.Error(err)
return
}
filename := fmt.Sprintf("%s/%s/%s.log", logPath, fileDate, appName)
// 打开初始日志文件
file, err := os.OpenFile(filename, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0600)
if err != nil {
logrus.Error(err)
return
}
// 注册钩子
fileHook := FileDateHook{file, logPath, fileDate, appName}
logrus.AddHook(&fileHook)
}
ⅳ. logrus 库初始化主流程中调用
// InitLogrus 初始化Logrus日志库
//
// @Description: 初始化日志库
func InitLogrus() {
// ...
l := global.Config.Log
InitFile(l.Dir, l.App)
}
ⅴ. 最终效果
3. 扩展logrus中Hook的实现原理
在 Logrus 中,Hook 机制允许你在日志记录过程中插入额外的处理逻辑,比如将日志写入文件、发送到远程服务器等。以你注册的 fileHook 为例,整个触发和执行过程如下:
-
注册 Hook:
当你调用logrus.AddHook(&fileHook)时,Logrus 内部会将这个 Hook 加入到一个 Hook 列表中。此时,fileHook必须实现 Logrus 定义的 Hook 接口,其中包含两个方法:Levels() []Level:返回一个日志级别的切片,表示该 Hook 只在这些日志级别时被触发。Fire(*Entry) error:当日志记录的级别与 Hook 支持的级别匹配时,会调用此方法,传入当前的日志 Entry。
-
创建日志 Entry:
当你调用诸如logrus.Info(),logrus.Warn()或logrus.Error()等日志记录方法时,Logrus 会创建一个Entry对象。这个Entry包含了日志的详细信息,如时间戳、日志级别、消息内容、调用者信息等。 -
检查并触发 Hook:
在日志 Entry 被处理之前,Logrus 会遍历所有已注册的 Hook。对于每一个 Hook,Logrus 会:- 调用 Hook 的
Levels()方法,获取该 Hook 支持的日志级别列表。 - 检查当前日志 Entry 的级别是否在该列表中。
- 如果匹配,则同步调用该 Hook 的
Fire(entry)方法,并将当前日志 Entry 作为参数传入。
- 调用 Hook 的
例如,如果 fileHook.Levels() 返回了 [logrus.InfoLevel, logrus.WarnLevel, logrus.ErrorLevel],那么当你记录一个 Info、Warn 或 Error 级别的日志时,fileHook.Fire(entry) 就会被调用。
- Hook 的执行:
在fileHook.Fire(entry)方法中,你可以编写自定义逻辑(例如将日志格式化后写入到指定的文件中)。因为这个方法是在日志记录流程中被同步调用的,所以如果该方法执行时间较长,会阻塞日志记录流程。如果你需要异步处理,则需要在Fire方法内部自行启动一个 goroutine 或使用其他异步手段。 - 日志正常输出:
除了 Hook 机制外,Logrus 还会将日志通过默认或自定义的 Formatter 输出到配置的输出目标(例如控制台或文件)。Hook 的执行不会影响日志的主流程,它只是一个附加的处理步骤。
总结一下,当你调用 logrus.AddHook(&fileHook) 注册了 fileHook 后,每当日志记录被触发,Logrus 会检查该日志的级别是否符合 fileHook 支持的级别,如果符合,就会调用 fileHook.Fire(entry),从而执行你在 Fire 方法中定义的日志写入文件等操作。这样,你就能在日志被记录的同时,将日志信息写入到文件中,实现多种输出方式的组合。