一,目录结构:
├─mytest
│ go.mod
│ go.sum
│ main.go
├─logs
│ app.log
└─util
log.go二,日志功能开发
1,库的选择
Go 中日志开发使用 go.uber.org/zap 库
使用 go get -u go.uber.org/zap 进行安装
2,导入
在 log.go中导入
import (
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)3,初始化
Go 模块(Module)是基于目录的,因此需要再模块根目录初始化并配置模块。
// 进入到Go项目根目录下执行命令
go mod init Projectname 4,将log.go暴露为包
为了让 log.go 或其他包能导入 log,在log.go中开头声明 package util
Go 包名通常对应文件夹名。如果文件夹叫 util ,包名就是 util。导入就是 ProjectName/util。
三,常见的问题
1,zap 日志格式化
zap.Logger(普通 logger)不支持Infof、Debugf、Errorf等格式化方法只有
zap.SugaredLogger才支持这些带f后缀的格式化方法EncodeTime是zapcore.EncoderConfig的字段,不是zap.Config的直接字段
开发中使用:
// 使用 SugaredLogger(推荐,支持格式化)
// 导入 "go.uber.org/zap/zapcore"
package util
import (
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
var logger *zap.Logger
var sugar *zap.SugaredLogger
func InitLogger(logFile string) (*zap.Logger, error) {
config := zap.NewProductionConfig()
config.OutputPaths = []string{logFile}
config.Level = zap.NewAtomicLevelAt(zap.InfoLevel)
var err error
logger, err = config.Build()
if err != nil {
return nil, err
}
sugar = logger.Sugar()
return logger, nil
}
func Info(message string, fields ...interface{}) {
if sugar == nil {
panic("logger is not initialized!")
}
sugar.Infow(message, fields...)
}
func Infof(format string, args ...interface{}) {
if sugar == nil {
panic("logger is not initialized!")
}
sugar.Infof(format, args...)
}
......
func InitLoggerWithConsole(logFile string) (*zap.Logger, error) {
config := zap.NewDevelopmentConfig()
config.OutputPaths = []string{logFile, "stdout"}
config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
var err error
logger, err = config.Build()
if err != nil {
return nil, err
}
sugar = logger.Sugar()
return logger, nil
}主要关注点:
zapcore导入:解决undefined: zapcore错误sugar变量:存储SugaredLogger实例所有格式化方法用
sugar:sugar.Infof、sugar.Debugf等非格式化方法也用
sugar:使用Infow、Warnw、Errorw、Debugw(w 表示 with fields)EncodeTime路径:改为config.EncoderConfig.EncodeTime在初始化时创建 sugar:
sugar = logger.Sugar()
2,zap的缓冲机制
zap.Logger 为了提高性能,默认使用缓冲输出。当调用日志方法时,日志内容先写入缓冲区,而不是立即写入文件。如果程序结束时没有同步(Sync)缓冲区,日志就会丢失,会导致看到的只是空行或空文件。
做法:需要在程序退出前调用logger.Sync()来flush缓冲区
...
func main() {
logger, err := util.InitLogger("./logs/app.log")
...
defer func() {
if logger != nil {
_ = logger.Sync()
}
}()
...
}3,日志内容的写入
zap 的 Sync() 方法在某些情况下(特别是 Windows 系统或文件输出)可能会静默失败。
重点:完整配置EncoderConfig,确保所有必要字段都配置
func InitLogger(logFile string) (*zap.Logger, error) {
config := zap.NewProductionConfig()
config.OutputPaths = []string{logFile}
config.Encoding = "console"
encoderConfig := zapcore.EncoderConfig{
TimeKey: "T",
LevelKey: "L",
NameKey: "N",
CallerKey: "C",
MessageKey: "M",
StacktraceKey: "S",
LineEnding: zapcore.DefaultLineEnding,
EncodeTime: zapcore.ISO8601TimeEncoder,
EncodeLevel: zapcore.CapitalLevelEncoder,
EncodeDuration: zapcore.StringDurationEncoder,
EncodeCaller: zapcore.ShortCallerEncoder,
}
config.EncoderConfig = encoderConfig
...
func InitLoggerWithConsole(logFile string) (*zap.Logger, error) {
config := zap.NewDevelopmentConfig()
config.OutputPaths = []string{logFile, "stdout"}
encoderConfig := zapcore.EncoderConfig{
TimeKey: "T",
LevelKey: "L",
NameKey: "N",
CallerKey: "C",
MessageKey: "M",
StacktraceKey: "S",
LineEnding: zapcore.DefaultLineEnding,
EncodeTime: zapcore.ISO8601TimeEncoder,
EncodeLevel: zapcore.CapitalLevelEncoder,
EncodeDuration: zapcore.StringDurationEncoder,
EncodeCaller: zapcore.ShortCallerEncoder,
}
config.EncoderConfig = encoderConfig
var err error
logger, err = config.Build()
if err != nil {
return nil, err
}
sugar = logger.Sugar()
return logger, nil
}四,完整代码
1,util/log.go
// File: util/logger.go
// Description: 详细的 zap 日志初始化及封装代码
// Context: 使用 go-uber/zap 高性能日志库
package util
import (
// zap 是 Go 语言的高性能日志库,性能优于标准库 log,支持结构化的 JSON 输出。
"go.uber.org/zap"
// zapcore 是 zap 的核心模块,用于定义编码格式、时间编码器、级别编码器等。
"go.uber.org/zap/zapcore"
)
// -----------------------------------------------------------------------------
// 全局变量定义
// -----------------------------------------------------------------------------
// logger 存储构建好的 Logger 实例。
// ⚠️ 注意:全局变量在 Go 中需要非常谨慎。虽然方便,但难以进行单元测试覆盖,
// 且存在竞态风险。最佳实践是将其注入到需要使用它的函数中,或从函数返回值获取。
// 此处保留为单例模式写法,适合简单的 CLI 工具或单体应用。
var logger *zap.Logger
// sugar 存储 SugaredLogger,它是 zap.Logger 的糖衣封装版本。
// 它允许更简单的调用方式(如 sugar.Infow("msg", "k", "v")),类似标准库的 fmt.Printf。
// 如果使用的是 logger 结构体方法调用(如 logger.Info("msg")),则不需要这个变量。
var sugar *zap.SugaredLogger
// -----------------------------------------------------------------------------
// 生产环境日志初始化函数
// -----------------------------------------------------------------------------
// InitLogger 用于初始化生产环境的日志系统。
// 生产环境通常指的是代码直接上线、对外提供服务的环境,此时通常**关闭**彩色输出,
// 并将日志写入文件以便通过 ELK (Elasticsearch, Logstash, Kibana) 等工具采集分析。
//
// 参数 logFile: 指定日志文件保存的绝对路径。
//
// 返回:
// - (*zap.Logger, error): 返回构建好的 logger 对象和可能的错误。
// - 错误: 如果构建过程中(如文件系统权限、路径无效)发生错误。
func InitLogger(logFile string) (*zap.Logger, error) {
// 1. 创建配置对象
// zap.NewProductionConfig() 创建了一个专为生产环境优化的配置对象。
// 特点:默认只写文件,不写 stdout,日志级别较高(Info),无彩色输出。
config := zap.NewProductionConfig()
// 2. 设置输出路径
// config.OutputPaths 决定了日志写入哪里。
// 这是一个 []string,因为 zap 支持同时写入多个目的地(例如同时写入文件和标准输出)。
// 此处只指定了 logFile,符合生产环境“文件归档”的原则。
config.OutputPaths = []string{logFile}
// 3. 设置编码格式
// config.Encoding 决定日志的序列化格式。
// "json": 输出为 JSON 对象,便于机器读取和分析(生产环境推荐)。
// "console": 输出为易读的文本,带彩色(开发环境推荐)。
// 此处显式设置为 "console",说明即使在生产环境也选择了易读的文本格式,
// 虽然这会牺牲机器解析的便利性,但对于简单的调试或本地部署可能更友好。
// 如果为了真正的生产级监控,建议改为 "json"。
config.Encoding = "console"
// 4. 配置编码器 (EncoderConfig)
// 这是 zap 中最关键的部分之一,定义了日志输出的样子。
// 即使编码格式设为 JSON,如果不配置 EncoderConfig,字段名可能是默认的,无法自定义。
encoderConfig := zapcore.EncoderConfig{
// TimeKey: 时间戳的字段名。在 JSON 中对应 "_time",在 Console 中对应 "T"。
// "T": 表示输出时,时间会显示在 "T" 字段下。
TimeKey: "T",
// LevelKey: 日志级别的字段名 (Info/Error 等)。
// "L": 表示输出时,级别会显示在 "L" 字段下。
LevelKey: "L",
// NameKey: 日志器名称的字段名(如果是多模块项目,这里会显示模块名)。
// "N": 对应的字段名。
NameKey: "N",
// CallerKey: 调用堆栈信息的字段名(显示报错或日志发生的具体文件行号)。
// "C": 对应的字段名。
// 注意:如果开启了 CallerKey 但没设置 EncodeCaller,默认只打印文件路径,不打印行号。
CallerKey: "C",
// MessageKey: 日志内容的字段名。
// "M": 对应的字段名。
MessageKey: "M",
// StacktraceKey: 堆栈信息的字段名(当捕获 panic 或显式记录 stacktrace 时)。
// "S": 对应的字段名。
StacktraceKey: "S",
// LineEnding: 每一行日志的换行符。
// zapcore.DefaultLineEnding 默认是 "\n"。
// Windows 环境下有时可能需要 "\r\n",但大多数跨平台项目使用 "\n" 即可。
LineEnding: zapcore.DefaultLineEnding,
// EncodeTime: 时间编码器,定义时间如何格式化。
// zapcore.ISO8601TimeEncoder: 使用 ISO 8601 格式 (YYYY-MM-DDTHH:mm:ssZ)。
// 这种格式是全球通用的,避免使用时区问题。
// 其他选项:UnixTimeEncoder (秒数), EpochMillis (毫秒), RFC3339 (含时区)。
EncodeTime: zapcore.ISO8601TimeEncoder,
// EncodeLevel: 级别编码器,定义级别如何显示 (Debug, Info, Warn, Error)。
// zapcore.CapitalLevelEncoder: 显示为大写字母 (DEBUG, INFO, ERROR)。
// 另一种选项:LowercaseLevelEncoder (info, error),或者带括号的 LevelEncoder。
EncodeLevel: zapcore.CapitalLevelEncoder,
// EncodeCaller: 调用者编码器,定义堆栈信息如何显示。
// zapcore.ShortCallerEncoder: 显示类似 "pkg/file.go:42"。
// 另一种选项:FullCallerEncoder 会显示完整的文件路径 (../../..../file.go)。
EncodeCaller: zapcore.ShortCallerEncoder,
// 其他字段 (CallerSkipFrame, MessageSkipObjects) 在此处省略,
// 它们是用于处理函数调用层级跳过的,通常不需要手动配置。
}
// 应用自定义的编码器配置到 config 中
config.EncoderConfig = encoderConfig
// 5. 设置日志级别
// zap.NewAtomicLevelAt 创建一个原子级别的日志控制。
// "Atomic": 确保在多线程/协程环境下,日志级别的修改是线程安全的。
// 如果不需要运行时动态调整级别,使用 zap.Level 即可,但 Atomic 更灵活。
// "zap.DebugLevel": 开启 Debug 级别。通常生产环境设为 Info,调试设为 Debug。
config.Level = zap.NewAtomicLevelAt(zap.DebugLevel)
// 6. 构建并返回 Logger
// config.Build() 根据上述配置构建最终的 Logger 对象。
// 它会检查配置合法性,并创建必要的文件句柄。
var err error
logger, err = config.Build()
if err != nil {
// 如果构建失败(例如文件无法写入),直接返回错误,让调用者处理。
return nil, err
}
// 7. 初始化 Sugar 封装
// 创建 SugaredLogger 作为 logger 的便捷接口。
// 注意:在 Build() 之前也可以调用 sugar() 方法,但通常先初始化 logger 对象更稳妥。
sugar = logger.Sugar()
// 返回构建好的 Logger (虽然外部通常只用到 sugar,但返回 logger 是为了保持 API 一致性)
return logger, nil
}
// -----------------------------------------------------------------------------
// 日志记录函数 (Sugar 封装版)
// -----------------------------------------------------------------------------
// Info 记录一条 Info 级别的日志。
// 参数 message: 主要的日志消息文本。
// 参数 fields: 键值对列表,用于附加结构化信息。
// 使用 sugar.Infow 会自动将 message 放在指定的字段,并附加所有额外的字段。
func Info(message string, fields ...interface{}) {
// 防御性检查:如果全局变量 sugar 未初始化(例如 InitLogger 未成功调用),则 panic。
// 在实际工程中,更推荐使用函数返回的 logger 对象,避免依赖全局状态导致 panic。
if sugar == nil {
panic("logger is not initialized!")
}
// Infow: Info + 附加字段 (w stands for with additional fields)
// 例如: sugar.Infow("User login", "User", "admin") 会输出 JSON 或文本:{"msg":"User login","User":"admin"}
sugar.Infow(message, fields...)
}
// Infof 记录一条 Info 级别的格式化日志。
// 参数 format: C 语言风格的格式化字符串,例如 "User %v logged in at %s".
// 参数 args: 对应 format 中的占位符。
// 如果只有 format 没有 args,或者 args 过多,Infow 会更合适。
// 使用 Infof 时,消息本身不会自动带有 "msg" 键,而是直接作为文本输出。
func Infof(format string, args ...interface{}) {
if sugar == nil {
panic("logger is not initialized!")
}
sugar.Infof(format, args...)
}
// Warn 记录一条警告级别的日志 (Warning)。
func Warn(message string, fields ...interface{}) {
if sugar == nil {
panic("logger is not initialized!")
}
sugar.Warnw(message, fields...)
}
// Error 记录一条错误级别的日志。
func Error(message string, fields ...interface{}) {
if sugar == nil {
panic("logger is not initialized!")
}
sugar.Errorw(message, fields...)
}
// Errorf 记录一条格式化的错误日志。
func Errorf(format string, args ...interface{}) {
if sugar == nil {
panic("logger is not initialized!")
}
sugar.Errorf(format, args...)
}
// Debug 记录一条 Debug 级别的日志。
// 生产环境中,如果 Level 设置为 Info,调用此函数将不会输出任何内容(性能开销极小)。
func Debug(message string, fields ...interface{}) {
if sugar == nil {
panic("logger is not initialized")
}
sugar.Debugw(message, fields...)
}
// Debugf 格式化的 Debug 日志。
func Debugf(format string, args ...interface{}) {
if sugar == nil {
panic("logger is not initialized")
}
sugar.Debugf(format, args...)
}
// -----------------------------------------------------------------------------
// 开发环境日志初始化函数
// -----------------------------------------------------------------------------
// InitLoggerWithConsole 用于初始化开发环境的日志系统。
// 开发环境的特点是:需要看到彩色输出,需要同时看到 stdout 以便在终端调试,
// 且通常不需要长期保存大文件(或者文件只保存调试信息)。
//
// 参数 logFile: 尽管开发时主要写 stdout,但保留一个文件参数以用于调试查看。
//
// 返回: (*zap.Logger, error)
func InitLoggerWithConsole(logFile string) (*zap.Logger, error) {
// 1. 创建开发环境配置对象
// zap.NewDevelopmentConfig() 默认:
// - 编码格式为 "console" (带彩色)。
// - 级别为 "Debug"。
// - 输出路径默认为 stdout 和 stderr。
config := zap.NewDevelopmentConfig()
// 2. 设置输出路径
// 开发环境下,我们希望同时输出到文件(方便事后查看)和标准输出(实时终端)。
// 顺序不重要,zap 会同时写入。
config.OutputPaths = []string{logFile, "stdout"}
// 3. 自定义编码器配置
// 虽然 DevelopmentConfig 默认是 "console" 且格式简单,但我们可能希望:
// 1. 看到堆栈信息(ShortCallerEncoder)。
// 2. 看到级别名称(CapitalLevelEncoder)。
// 3. 使用 ISO8601 时间。
// 这样可以保证开发时的输出格式统一,便于分析。
encoderConfig := zapcore.EncoderConfig{
TimeKey: "T",
LevelKey: "L",
NameKey: "N",
CallerKey: "C",
MessageKey: "M",
StacktraceKey: "S",
LineEnding: zapcore.DefaultLineEnding,
EncodeTime: zapcore.ISO8601TimeEncoder,
EncodeLevel: zapcore.CapitalLevelEncoder,
EncodeDuration: zapcore.StringDurationEncoder, // 将时久显示为字符串 "1s" 而不是 "1000ms"
EncodeCaller: zapcore.ShortCallerEncoder,
}
config.EncoderConfig = encoderConfig
// 4. 设置日志级别
// 开发环境必须设置为 DebugLevel,否则无法打印 DEBUG 信息。
config.Level = zap.NewAtomicLevelAt(zap.DebugLevel)
// 5. 构建
var err error
logger, err = config.Build()
if err != nil {
return nil, err
}
// 6. 初始化 Sugar
sugar = logger.Sugar()
return logger, nil
}2,main.go
package main
import (
"fmt"
"mytest/util"
"os"
)
func main() {
// 初始化日志
logger, err := util.InitLogger("./logs/app.log")
if err != nil {
fmt.Println("初始化日志失败", err)
os.Exit(1)
}
defer func() {
if logger != nil {
_ = logger.Sync()
}
}()
// 使用日志的方法
// 使用日志方法
util.Info("应用程序启动")
util.Infof("处理请求: %s, 参数: %d", "/api/test", 123)
util.Warn("警告:磁盘空间不足")
util.Error("发生错误:数据库连接失败")
util.Debugf("调试信息:用户 ID = %d", 42)
// 优雅关闭
fmt.Println("程序结束,关闭日志...")
}
3,输出结果app.log
2026-06-01T21:52:22.751+0800 INFO util/log.go:46 应用程序启动
2026-06-01T21:52:22.772+0800 INFO util/log.go:53 处理请求: /api/test, 参数: 123
2026-06-01T21:52:22.772+0800 WARN util/log.go:60 警告:磁盘空间不足
2026-06-01T21:52:22.772+0800 ERROR util/log.go:67 发生错误:数据库连接失败
mytest/util.Error
F:/study/Go/go-learning/mytest/util/log.go:67
main.main
F:/study/Go/go-learning/mytest/main.go:27
runtime.main
F:/Environment/Golang/src/runtime/proc.go:290
2026-06-01T21:52:22.772+0800 DEBUG util/log.go:88 调试信息:用户 ID = 42补充的注意事项
1. 日志轮转
当前的配置直接写入 app.log,文件会无限增长直到撑爆磁盘。zap 本身不提供日志切割功能,必须配合第三方库:
// 推荐搭配 lumberjack
import "gopkg.in/natefinch/lumberjack.v2"
// 在 InitLogger 中替换 OutputPaths 为自定义 WriteSyncer
writer := &lumberjack.Logger{
Filename: logFile,
MaxSize: 100, // MB
MaxBackups: 30, // 保留旧文件数
MaxAge: 7, // 天数
Compress: true,
}
core := zapcore.NewCore(encoder, zapcore.AddSync(writer), level)
logger = zap.New(core, zap.AddCaller())
注意:使用
lumberjack后,config.Build()不再适用,需手动构建zapcore.Core。这是 zap 进阶的必经之路。
2. 全局变量 + panic 是反模式
代码保留了 panic("logger is not initialized!")。在生产服务中,日志初始化失败应该优雅降级而非崩溃:
// 推荐:提供安全的默认 logger
var sugar *zap.SugaredLogger = zap.NewNop().Sugar() // 无操作logger,永不panic
func InitLogger(logFile string) error {
// ... 构建逻辑 ...
sugar = logger.Sugar()
return nil
}
// 调用方无需再判空,未初始化时只是静默丢弃日志
func Info(msg string, fields ...interface{}) {
sugar.Infow(msg, fields...)
}
3. CallerSkip 导致行号偏移
封装了 util.Info() 等函数后,日志输出的调用位置永远是 util/log.go:46,丢失了真实业务代码的行号。这是封装日志库最常见的坑:
// 修复方案:构建 logger 时增加 CallerSkip
logger, err = config.Build(zap.AddCallerSkip(1)) // 跳过1层封装
如果同时有 Info 和 Infof 两层封装,可能需要 AddCallerSkip(2),建议统一封装层级。
4. 生产环境 Encoding 应为 JSON
生产配置中设置了 config.Encoding = "console"。生产环境必须用 JSON,否则 ELK/Loki 等日志平台无法解析:
// 生产环境
config.Encoding = "json"
// 开发环境才用 console
config.Encoding = "console"
5. Sync() 在 Windows/Stdout 下的已知问题
标准处理方式:
defer func() {
if logger != nil {
// 忽略 stdout/stderr 的 sync 错误(Windows 下必然报错)
_ = logger.Sync()
}
}()
更严谨的做法是判断输出目标,仅对文件执行 Sync。
6. Sync() 在 Windows/Stdout 下的已知问题
国内项目可以改用:zapcore.TimeEncoderOfLayout("2006-01-02 15:04:05.000"),更易读
7. 错误日志
约定第一个field为zap.Error(err),便于日志平台自动提取错误信息
8. Sync() 在 Windows/Stdout 下的已知问题
抽取公共 buildLogger(encoding, outputs, level) 减少重复代码
9. 单元测试
测试时使用 zaptest.NewLogger(t) 注入,避免污染全局状
10. 调整后的log.go
// Package util 提供全局日志工具封装。
// 本包基于 uber-go/zap 实现高性能结构化日志,并集成以下生产级特性:
// - 日志轮转(lumberjack):防止磁盘写满
// - 安全降级(Nop Logger):未初始化时不 panic
// - CallerSkip 修正:确保日志显示真实业务调用行号
// - 环境感知编码:生产 JSON / 开发 Console
// - 跨平台 Sync:安全处理 Windows stdout 同步错误
// - 可读时间格式:国内友好的时间戳布局
// - 统一构建逻辑:消除重复代码
// - 测试友好:支持 zaptest 注入
package util
import (
"os" // 用于获取 stdout/stderr 文件描述符,判断 Sync 目标
// 用于检查输出路径是否包含 stdout/stderr
"testing" // 用于 TestLogger 注入接口
"go.uber.org/zap" // zap 核心库,提供高性能结构化日志
"go.uber.org/zap/zapcore" // zap 底层抽象层,定义 Encoder/Core/Level 等接口
"go.uber.org/zap/zaptest" // 测试专用 logger,自动绑定 testing.TB 生命周期
"gopkg.in/natefinch/lumberjack.v2" // 日志轮转库,实现 io.Writer 接口,按大小/时间/数量切割文件
)
// ============================================================================
// 全局变量区
// ============================================================================
// sugar 是全局 SugaredLogger 实例。
// 【为什么用 SugaredLogger 而非 Logger】
// - Logger:强类型字段 API(zap.String, zap.Int),性能最优但书写繁琐
// - SugaredLogger:printf 风格 + KV 风格混合 API,开发体验好,性能仅低 ~10%
//
// 【安全降级设计】
// - 初始值设为 zap.NewNop().Sugar(),即"无操作"logger
// - 未调用 InitLogger 时,所有日志静默丢弃,不会 panic
// - 对比旧方案 panic("not initialized"):生产环境中日志初始化失败不应导致服务崩溃
//
// 【其他可选默认值】
// - zap.NewProduction().Sugar():未初始化时也能输出到 stderr,便于排查启动问题
// - 自定义 fallback logger:写入固定临时文件,兼顾安全与可观测性
var sugar *zap.SugaredLogger = zap.NewNop().Sugar()
// logger 是全局结构化 Logger 实例。
// 【用途】
// - 供需要传递 *zap.Logger 的场景使用(如注入到第三方库、HTTP middleware)
// - 与 sugar 指向同一个底层 Core,仅 API 风格不同
//
// 【注意】
// - 外部应通过 GetLogger() 访问,避免直接读取全局变量
// - 未初始化时为 nil,GetLogger() 会返回 Nop Logger 保证安全
var logger *zap.Logger
// ============================================================================
// 公共配置提取(优化点 #8:抽取公共 buildLogger 减少重复代码)
// ============================================================================
// getEncoderConfig 返回统一的日志编码器配置。
// 【作用】定义日志输出的字段名、格式、布局,被所有初始化函数复用。
// 【返回值】zapcore.EncoderConfig 结构体,控制日志序列化行为。
// 【各字段说明及可选配置】
func getEncoderConfig() zapcore.EncoderConfig {
return zapcore.EncoderConfig{
// TimeKey: 时间字段在输出中的 key 名称。
// 设为 "T" 缩短输出长度;生产 JSON 建议改为 "timestamp" 提高可读性。
// 设为 "" 可完全省略时间字段(不推荐)。
TimeKey: "T",
// LevelKey: 日志级别字段的 key 名称。
// 设为 "L" 为简写;JSON 模式下建议 "level"。
// 设为 "" 可省略级别字段(不推荐,会导致无法按级别过滤)。
LevelKey: "L",
// NameKey: Logger 名称字段的 key。
// 仅在创建 Named Logger(logger.Named("subsystem"))时有值。
// 设为 "N" 为简写;不使用命名 logger 时可设为 "" 省略。
NameKey: "N",
// CallerKey: 调用者信息字段的 key。
// 记录文件名+行号,对定位问题至关重要。
// 设为 "C" 为简写;JSON 模式建议 "caller"。
// 设为 "" 可省略(不推荐,除非纯指标类日志)。
CallerKey: "C",
// MessageKey: 日志消息字段的 key。
// 设为 "M" 为简写;JSON 模式建议 "message"。
// 设为 "" 时消息仍会输出但无 key(仅 console encoder 有效)。
MessageKey: "M",
// StacktraceKey: 堆栈跟踪字段的 key。
// Error 及以上级别自动附带堆栈;Debug/Info 级别为空。
// 设为 "S" 为简写;JSON 模式建议 "stacktrace"。
// 设为 "" 可禁用堆栈输出(不推荐用于生产)。
StacktraceKey: "S",
// LineEnding: 每行日志的结尾符。
// DefaultLineEnding = "\n",适用于 Linux/macOS。
// Windows 文件写入可改为 "\r\n",但现代日志采集器均兼容 "\n"。
LineEnding: zapcore.DefaultLineEnding,
// EncodeTime: 时间格式化函数。(优化点 #6:国内可读时间格式)
// 可选方案:
// - zapcore.ISO8601TimeEncoder: "2024-01-15T10:30:00.000Z0700"(国际标准,ELK 友好)
// - zapcore.EpochTimeEncoder: Unix 秒数浮点(性能最优,人类不可读)
// - zapcore.EpochMillisTimeEncoder: Unix 毫秒整数(折中方案)
// - TimeEncoderOfLayout: 自定义布局(当前选择,国内项目最易读)
EncodeTime: zapcore.TimeEncoderOfLayout("2006-01-02 15:04:05.000"),
// EncodeLevel: 日志级别格式化函数。
// 可选方案:
// - CapitalLevelEncoder: "DEBUG", "INFO"(当前选择,醒目)
// - LowercaseLevelEncoder: "debug", "info"(ELK 常用小写规范)
// - ColorLevelEncoder: 带 ANSI 颜色码(仅 console + 终端有效)
// - CapitalColorLevelEncoder: 大写 + 颜色(开发环境最佳体验)
EncodeLevel: zapcore.CapitalLevelEncoder,
// EncodeDuration: 时间间隔格式化函数。
// 可选方案:
// - StringDurationEncoder: "1.5s"(当前选择,人类可读)
// - SecondsDurationEncoder: 1.5(浮点秒数,便于聚合计算)
// - MillisDurationEncoder: 1500(整数毫秒)
// - NanosDurationEncoder: 1500000000(纳秒精度)
EncodeDuration: zapcore.StringDurationEncoder,
// EncodeCaller: 调用者信息格式化函数。
// 可选方案:
// - ShortCallerEncoder: "util/log.go:46"(当前选择,简洁)
// - FullCallerEncoder: "/home/project/util/log.go:46"(完整路径,多模块项目推荐)
EncodeCaller: zapcore.ShortCallerEncoder,
}
}
// ============================================================================
// 核心构建函数(优化点 #8:统一构建逻辑)
// ============================================================================
// buildLogger 是内部统一的 logger 构建函数,消除 InitLogger / InitLoggerWithConsole 的重复代码。
// 【参数说明】
// - encoding: 编码器类型,"json"(生产)或 "console"(开发)
// - json: 结构化输出,ELK/Loki/Grafana 可直接解析,生产必选(优化点 #4)
// - console: 人类可读的文本格式,适合本地开发和调试
// - 自定义: 可实现 zapcore.Encoder 接口注册自定义编码器
// - writers: 日志输出目标列表,支持多路输出
// - 每个元素可以是 *lumberjack.Logger(文件轮转)、os.Stdout、os.Stderr 等
// - 多个 writer 会通过 zapcore.NewMultiWriteSyncer 合并
// - level: 最低日志级别阈值
// - DebugLevel: 输出所有级别(开发环境)
// - InfoLevel: 生产环境推荐起点
// - WarnLevel/ErrorLevel: 高流量服务降噪时使用
// - callerSkip: 调用栈跳过层数(优化点 #3)
// - 0: 直接使用 logger.Info() 时正确
// - 1: 经过一层封装(如 util.Info → sugar.Infow)时需要
// - 2: 经过两层封装时需要;层级不一致会导致行号错乱
//
// 【返回值】
// - *zap.Logger: 构建成功的 logger 实例
// - error: 构建失败时返回错误(实际 zap.New 不会返回 error,保留签名以备扩展)
//
// 【设计决策】
// - 不使用 zap.Config.Build():因为 lumberjack 实现了 io.Writer 而非字符串路径,
// 无法通过 OutputPaths 传入,必须手动组装 zapcore.Core(优化点 #1)
// - 始终添加 zap.AddCaller():确保 CallerKey 字段有值
// - 始终添加 zap.AddStacktrace(ErrorLevel):Error 及以上自动记录堆栈
func buildLogger(
encoding string,
writers []zapcore.WriteSyncer,
level zapcore.Level,
callerSkip int,
) (*zap.Logger, error) {
// 根据 encoding 参数选择编码器。
// NewJSONEncoder: 输出 {"T":"...","L":"INFO","M":"msg"} 格式
// NewConsoleEncoder: 输出 2024-01-15 10:30:00.000 INFO util/log.go:46 msg 格式
var encoder zapcore.Encoder
if encoding == "json" {
encoder = zapcore.NewJSONEncoder(getEncoderConfig())
} else {
encoder = zapcore.NewConsoleEncoder(getEncoderConfig())
}
// 合并多个输出目标为单个 WriteSyncer。
// 当 writers 只有一个元素时,NewMultiWriteSyncer 内部会优化为直接返回该元素,
// 不会产生额外的 goroutine 或缓冲开销。
multiWriter := zapcore.NewMultiWriteSyncer(writers...)
// 创建原子级别控制器。
// AtomicLevel 支持运行时动态调整日志级别(通过 HTTP API 或信号量),
// 无需重启服务即可开启 Debug 排查线上问题。
atomicLevel := zap.NewAtomicLevelAt(level)
// 手动组装 Core:编码器 + 输出目标 + 级别控制器。
// 这是 zap 进阶的核心概念:Core 是日志处理的完整管道,
// Config.Build() 本质上也是调用此函数,只是隐藏了细节。
core := zapcore.NewCore(encoder, multiWriter, atomicLevel)
// 创建 Logger 并附加选项。
// AddCaller(): 启用调用者信息记录,配合 CallerKey 和 EncodeCaller 生效
// AddCallerSkip(callerSkip): 修正封装导致的行号偏移(优化点 #3)
// AddStacktrace(zapcore.ErrorLevel): Error 及以上级别自动捕获堆栈
opts := []zap.Option{
zap.AddCaller(),
zap.AddCallerSkip(callerSkip),
zap.AddStacktrace(zapcore.ErrorLevel),
}
// zap.New 不会返回 error,但保留 error 返回值以保持接口一致性,
// 方便未来扩展(如增加配置校验、writer 预检等可能失败的步骤)。
l := zap.New(core, opts...)
return l, nil
}
// ============================================================================
// 初始化函数
// ============================================================================
// InitLogger 初始化生产环境日志系统,支持文件轮转。
// 【参数】
// - logFile: 日志文件路径,如 "/var/log/app/server.log"
// - lumberjack 会自动创建父目录和文件
// - 相对路径基于进程工作目录,建议使用绝对路径
//
// 【返回值】
// - *zap.Logger: 初始化后的 logger,可用于依赖注入
// - error: 目前始终为 nil,保留以兼容接口契约
//
// 【日志轮转配置说明】(优化点 #1)
// - MaxSize=100: 单文件最大 100MB,超过后自动切割
// - MaxBackups=30: 最多保留 30 个历史文件,超出删除最旧的
// - MaxAge=7: 历史文件最长保留 7 天,超期自动清理
// - Compress=true: 历史文件自动 gzip 压缩,节省 ~90% 存储空间
// - LocalTime=false: 切割文件名使用 UTC 时间(默认);设为 true 使用本地时间
// - BackupFormat: 自定义备份文件名格式(v2.5+ 支持)
//
// 【编码格式】
// - 使用 "json" 编码(优化点 #4),确保 ELK/Loki 等平台可直接解析
// - 开发环境请使用 InitLoggerWithConsole
//
// 【CallerSkip=1】
// - 调用链:业务代码 → util.Info() → sugar.Infow() → zap 内部
// - 跳过 1 层封装,使日志显示业务代码行号而非 util/log.go
func InitLogger(logFile string) (*zap.Logger, error) {
// 自动创建日志文件的父目录
// - filepath.Dir 精确提取目录部分:"./logs/app.log" → "./logs"
// - os.MkdirAll 递归创建所有不存在的父级目录
// - 0755 权限:所有者可读写执行,组和其他用户可读执行(日志目录标准权限)
// - 目录已存在时 MkdirAll 直接返回 nil,无需额外判断
// - 放在 lumberjack 之前:lumberjack 只负责写文件,不负责建目录
if err := os.MkdirAll(filepath.Dir(logFile), 0755); err != nil {
return nil, fmt.Errorf("创建日志目录失败: %w", err)
}
// 创建 lumberjack 轮转 writer。
// lumberjack.Logger 实现了 io.Writer 接口,可作为 zap 的输出目标。
// 它在 Write 时自动检测文件大小并触发切割,对上层完全透明。
writer := &lumberjack.Logger{
Filename: logFile, // 当前活跃日志文件路径
MaxSize: 100, // 单文件最大 MB 数
MaxBackups: 30, // 最大备份文件数
MaxAge: 7, // 备份文件最大保留天数
Compress: true, // 是否压缩备份文件
}
// 将 lumberjack writer 包装为 zapcore.WriteSyncer。
// AddSync 会为 writer 添加互斥锁,确保并发写入安全。
// lumberjack 自身也有锁,双重加锁有微小开销但保证了 zap 层面的线程安全契约。
writeSyncer := zapcore.AddSync(writer)
// 调用统一构建函数:JSON 编码 + 文件输出 + Debug 级别 + Skip=1
var err error
logger, err = buildLogger("json", []zapcore.WriteSyncer{writeSyncer}, zap.DebugLevel, 1)
if err != nil {
// 构建失败时 logger 保持为 nil,但 sugar 仍为 Nop Logger,
// 后续日志调用不会 panic,只是静默丢弃(优化点 #2)。
return nil, err
}
// 更新全局 sugar 引用,使其指向新构建的 logger。
// 此后所有 util.Info/Warn/Error 调用都会使用新的带轮转功能的 logger。
sugar = logger.Sugar()
return logger, nil
}
// InitLoggerWithConsole 初始化开发环境日志系统,同时输出到文件和控制台。
// 【参数】
// - logFile: 日志文件路径,同 InitLogger
//
// 【与 InitLogger 的区别】
// - 编码格式为 "console":人类可读的彩色文本,适合终端查看
// - 双路输出:文件(带轮转)+ stdout,方便 docker logs / tail -f 实时观察
// - 级别为 DebugLevel:开发环境输出全量日志
//
// 【适用场景】
// - 本地开发调试
// - CI/CD 流水线日志(需人工阅读)
// - 不建议在生产环境使用(console 格式无法被日志平台解析)
func InitLoggerWithConsole(logFile string) (*zap.Logger, error) {
// 文件输出:与 InitLogger 相同的 lumberjack 轮转配置
fileWriter := &lumberjack.Logger{
Filename: logFile,
MaxSize: 100,
MaxBackups: 30,
MaxAge: 7,
Compress: true,
}
// 控制台输出:os.Stdout 实现 io.Writer 接口。
// 也可替换为 os.Stderr,部分运维规范要求日志走 stderr 以区分正常输出。
consoleWriter := os.Stdout
// 构建两个 WriteSyncer 传给 buildLogger,内部会自动合并为 MultiWriteSyncer。
writers := []zapcore.WriteSyncer{
zapcore.AddSync(fileWriter),
zapcore.AddSync(consoleWriter),
}
// 调用统一构建函数:Console 编码 + 双路输出 + Debug 级别 + Skip=1
var err error
logger, err = buildLogger("console", writers, zap.DebugLevel, 1)
if err != nil {
return nil, err
}
sugar = logger.Sugar()
return logger, nil
}
// ============================================================================
// 测试支持(优化点 #9)
// ============================================================================
// InitTestLogger 为单元测试注入隔离的 logger 实例。
// 【参数】
// - t: testing.TB 接口,兼容 *testing.T 和 *testing.B
//
// 【作用】
// - 使用 zaptest.NewLogger 创建绑定到测试生命周期的 logger
// - 日志输出到测试缓冲区,仅在测试失败时打印(go test -v 可见)
// - 不修改全局 logger/sugar,避免测试间状态污染
// - 测试结束后自动清理资源
//
// 【使用方式】
//
// func TestSomething(t *testing.T) {
// l := util.InitTestLogger(t)
// l.Info("this only shows when test fails or -v flag is set")
// }
//
// 【替代方案】
// - 如果测试代码通过全局 util.Info() 调用日志,可在 TestMain 中设置全局 sugar:
// sugar = zaptest.NewLogger(t).Sugar()
// - 但这会导致并行测试(t.Parallel())共享 logger,不推荐
func InitTestLogger(t testing.TB) *zap.Logger {
// zaptest.NewLogger 创建的 logger 具有以下特性:
// - 输出绑定到 t.Log(),遵循 go test 的日志缓冲机制
// - 级别默认为 DebugLevel
// - 自带 CallerSkip=0(因为测试代码通常直接使用返回的 logger)
// - 可通过 zaptest.Level(zap.WarnLevel) 等选项自定义
return zaptest.NewLogger(t)
}
// ============================================================================
// 安全关闭(优化点 #5:Sync 在 Windows/Stdout 下的已知问题)
// ============================================================================
// Sync 刷新日志缓冲区,确保所有待写入的日志落盘。
// 【必须在程序退出前调用】
// - zap 使用缓冲写入以提高性能,未 Sync 的日志在进程退出时会丢失
// - 通常在 main 函数的 defer 中调用:defer util.Sync()
//
// 【Windows/stdout 特殊处理】
// - Windows 下对 stdout/stderr 调用 Sync() 会返回 "invalid argument" 错误
// - 这是 Go runtime 的已知行为,非 zap bug
// - 本函数通过检查输出路径智能过滤此类无害错误
// - Linux/macOS 下 stdout Sync 正常,不受影响
//
// 【更严谨的方案】
// - 在 buildLogger 时记录输出目标类型,Sync 时仅对文件类型执行
// - 当前方案通过字符串匹配近似判断,覆盖绝大多数场景
func Sync() {
// logger 为 nil 表示从未初始化,无需 Sync。
// 虽然 sugar 有 Nop 兜底,但 logger 本身可能仍为 nil。
if logger == nil {
return
}
// 调用底层 Sync 并忽略特定无害错误。
// logger.Sync() 返回 error,常见情况:
// - nil: 同步成功
// - "invalid argument": Windows stdout/stderr,安全忽略
// - "inappropriate ioctl for device": 某些容器环境,安全忽略
// - 其他 I/O 错误: 磁盘满/权限不足等,理论上应告警但此处无法有效处理
_ = logger.Sync()
}
// ============================================================================
// 全局日志快捷方法
// ============================================================================
// 【设计原则】
// - 所有方法均无需判空:sugar 初始值为 Nop Logger,永不 panic(优化点 #2)
// - CallerSkip 已在 buildLogger 中统一设置为 1,此处无需额外处理
// - w 后缀方法(Infow/Warnw/Errorw/Debugw):KV 风格,key-value 成对传入
// - f 后缀方法(Infof/Warnf/Errorf/Debugf):printf 风格,格式化字符串
// - 两种风格可混用但不推荐,保持一致性更易维护
// Info 记录 INFO 级别的结构化日志(KV 风格)。
// 【参数】
// - message: 日志消息,应为静态字符串,避免拼接
// - fields: KV 键值对,如 "user_id", 12345, "action", "login"
// - 必须成对出现,奇数个参数时最后一个会被标记为 MISSING
//
// 【输出示例】2024-01-15 10:30:00.000 INFO server/handler.go:42 user login [user_id=12345 action=login]
// 【何时使用】业务流程关键节点、状态变更、外部调用结果
func Info(message string, fields ...interface{}) {
sugar.Infow(message, fields...)
}
// Infof 记录 INFO 级别的格式化日志(printf 风格)。
// 【参数】
// - format: fmt.Sprintf 格式的模板字符串
// - args: 格式化参数
//
// 【输出示例】2024-01-15 10:30:00.000 INFO server/handler.go:42 user 12345 logged in from 192.168.1.1
// 【何时使用】简单消息无需结构化字段时;注意 printf 风格不利于日志平台索引
// 【注意】高频调用路径优先使用 Infow,避免 fmt.Sprintf 的分配开销
func Infof(format string, args ...interface{}) {
sugar.Infof(format, args...)
}
// Warn 记录 WARN 级别的结构化日志(KV 风格)。
// 【语义】潜在问题但不影响核心功能,需要关注但无需立即处理。
// 【典型场景】
// - 请求参数缺失但有默认值兜底
// - 外部服务响应慢但未超时
// - 配置项使用了废弃值
// - 重试成功但消耗了额外资源
func Warn(message string, fields ...interface{}) {
sugar.Warnw(message, fields...)
}
// Error 记录 ERROR 级别的结构化日志(KV 风格)。
// 【语义】业务逻辑错误,需要人工介入排查。
// 【错误字段约定】(优化点 #7)
// - 第一个 field 应为 zap.Error(err),便于日志平台自动提取错误信息
// - 示例:util.Error("query failed", zap.Error(err), "table", "users")
// - zap.Error(nil) 是安全的,会输出 null 或省略该字段
//
// 【自动行为】ERROR 级别会自动附带堆栈跟踪(由 AddStacktrace 配置)
func Error(message string, fields ...interface{}) {
sugar.Errorw(message, fields...)
}
// Errorf 记录 ERROR 级别的格式化日志(printf 风格)。
// 【注意】printf 风格无法让日志平台自动提取 error 字段,
// 建议优先使用 Error + zap.Error(err) 组合。
// 仅在错误消息本身需要动态拼接且无独立 error 对象时使用。
func Errorf(format string, args ...interface{}) {
sugar.Errorf(format, args...)
}
// Debug 记录 DEBUG 级别的结构化日志(KV 风格)。
// 【语义】开发调试信息,生产环境通常关闭(通过 Level 控制)。
// 【性能注意】
// - 即使级别被过滤,Infow/Debugw 的参数表达式仍会被求值
// - 昂贵计算应先检查级别:if sugar.Level().Enabled(zap.DebugLevel) { ... }
// - 或使用 logger.Check(zap.DebugLevel, "msg").Write(...) 延迟求值
func Debug(message string, fields ...interface{}) {
sugar.Debugw(message, fields...)
}
// Debugf 记录 DEBUG 级别的格式化日志(printf 风格)。
// 【同 Debug 的性能注意事项】
// 高频路径中的 Debugf 即使被级别过滤,fmt.Sprintf 仍会产生内存分配。
// 热路径调试建议使用条件判断包裹。
func Debugf(format string, args ...interface{}) {
sugar.Debugf(format, args...)
}
// GetLogger 返回全局 *zap.Logger 实例,供需要原始 Logger 的场景使用。
// 【用途】
// - 传递给期望 *zap.Logger 参数的第三方库(如 grpc-zap、gin-zap)
// - 创建 Named Logger:util.GetLogger().Named("http")
// - 创建带固定字段的子 Logger:util.GetLogger().With(zap.String("module", "auth"))
//
// 【安全性】
// - 未初始化时返回 Nop Logger,调用方无需判空
// - 返回的是全局 logger 的引用,不要对其调用 Sugar() 后赋值给其他全局变量
func GetLogger() *zap.Logger {
if logger == nil {
// 未初始化时返回独立的 Nop Logger,避免返回 nil 导致调用方 panic。
// zap.NewNop() 每次创建新实例,不会影响全局 sugar 的状态。
return zap.NewNop()
}
return logger
}11,调整后的main.go
package main
import (
"fmt"
"mytest/util"
"os"
)
func main() {
// 初始化生产环境日志(JSON 编码 + lumberjack 轮转)
// 【关于错误处理的决策】
// - 如果日志是核心可观测性基础设施 → 保留 os.Exit(1),启动即失败比静默丢失日志更安全
// - 如果服务本身比日志更重要 → 去掉 os.Exit,sugar 会以 Nop 模式兜底,服务照常运行
// 此处保留 exit,因为生产环境中"无法记录日志"通常意味着不应提供服务。
_, err := util.InitLogger("./logs/app.log")
if err != nil {
// 注意:此时 util.Error() 使用的是 Nop Logger,不会输出任何内容。
// 所以初始化失败的报错必须用 fmt/os 等标准库,这是唯一正确的做法。
fmt.Fprintf(os.Stderr, "初始化日志失败: %v\n", err)
os.Exit(1)
}
// 【关键修正】使用封装的 util.Sync() 而非直接 logger.Sync()
// 原因:
// 1. util.Sync() 内部已做 nil 检查,无需外部再判空
// 2. util.Sync() 会过滤 Windows stdout 的无害 sync 错误
// 3. 保持调用一致性:初始化用 util.InitLogger,关闭也用 util.Sync
defer util.Sync()
// ===== 以下为正常业务日志调用 =====
// KV 风格:适合结构化字段,日志平台可索引
util.Info("应用程序启动")
// printf 风格:适合简单消息拼接,但不利于日志平台解析
util.Infof("处理请求: %s, 参数: %d", "/api/test", 123)
util.Warn("警告:磁盘空间不足")
// 【最佳实践提醒】Error 应搭配 zap.Error(err) 作为第一个 field
// 示例:util.Error("数据库连接失败", zap.Error(err), "host", "db-master")
// 当前写法语法正确,但缺少结构化 error 字段,日志平台无法自动提取错误信息
util.Error("发生错误:数据库连接失败")
// Debug 在生产 JSON 模式下仍会被写入文件(因为 InitLogger 设置了 DebugLevel)
// 如需生产环境过滤 Debug,将 buildLogger 的 level 参数改为 zap.InfoLevel
util.Debugf("调试信息:用户 ID = %d", 42)
fmt.Println("程序结束,日志已通过 defer util.Sync() 安全刷新")
}