1. 日志通用理论
1.1 引入日志的原因
在项目开发过程中,日志的重要性不言而喻,如果没有日志的存在,就会出现以下问题:
- 无法确认当前系统的状态,出了问题也不知道
- 出现了问题以后没有办法及时定位排查
- 难以对日志进行归纳、整理、总结等操作
1.2 日志级别
在大多数的公司规范当中,日志是有不同级别的,常见级别如下:
- DEBUG 级别:记录一些辅助排查问题的信息,一般在线上不会打印
- INFO 级别:中性的描述了发生了什么事情。线上一般从这个级别开始打印
- WARN 级别:系统当中发生了一些不好的事情(偶尔触发可以接收,频繁触发需要关注)
- ERROR 级别:系统当中出现了一些不应该出现的错误,需要及时处理
除此以外还有一些额外的日志级别:
- DATA:有些公司用这个级别来处理接收请求与响应的日志
- FATAL:一般用来表示及其严重的错误,需要立刻手工介入
1.3 日志参考规范
什么时候该打日志?需要打什么级别的日志?这些都没有统一的规范,这时可以参考一个宁滥勿缺原则:如果纠结是否需要打日志,那就打上;如果纠结是否要打更高级别的日志,那就打上更高级别。
有些大佬的做法:
- 统一利用 AOP 机制,记录任何跟第三方交互的请求与响应,包括数据库、缓存、RPC调用,使用 DEBUG 级别
- 统一利用 AOP 机制,当系统接收请求以及返回响应,使用 INFO 级别日志
- 怀疑系统出现了某些问题,偶尔触发没有问题但是频繁触发需要关注,使用 WARN 级别日志
- 当某些条件不应该触发却触发了或者有人攻击系统的时候,需要记录 ERROR 日志
2. zap 快速入门
2.1 Go 接入日志模块
在 Go 项目当中,可以使用 zap 作为日志框架
⭐ zap 项目地址:https://github.com/uber-go/zap
Go 接入 zap 非常简单,只需要按照以下代码替换全局 Logger 对象即可:
go
import "go.uber.org/zap"
func InitLogger() {
logger, err := zap.NewDevelopment()
if err != nil {
panic(err)
}
// 替换全局Logger对象
zap.ReplaceGlobals(logger)
}
2.2 webook 日志使用案例
在我们当前这个 webook 项目中,还没有接入日志模块,现在我们演示在该系统当中如何打印 DEBUG、INFO、WARN、ERROR 各种级别的日志
2.2.1 ERROR 级别演示

上图所示代码便是 ERROR 级别日志的案例,因为正常情况下调用验证码服务验证短信是不会出现错误的,因此这里可以需要使用 ERROR 级别,并且我们在这里也可以看出两个细节:
- 后端系统是不会也不应该把 err 等错误信息返回给前端的
- 对于手机号这类敏感字段,哪怕是在日志中也不应该打印出来
2.2.2 WARN 级别演示

上图所示代码便是 WARN 级别日志的案例,因为正常情况是不可能会触发一分钟内连续发送多个验证码请求的(意味着有人可能会攻击你的系统),因此偶尔出现不需要关注,频繁出现则需要关注
💡 温馨提示:同样这里不应该打印手机号等敏感字段
2.2.3 INFO 级别演示

上图所示代码便是 INFO 级别日志的案例,这次中性的记录了用户首次进行微信扫码登录,开始用户注册的流程
2.2.4 DEBUG 级别演示

上图所示代码便是 DEBUG 级别日志的案例,记录了和第三方短信服务平台交互的日志
2.3 zap 优雅实践
在上述使用案例中,我们都是通过直接使用 zap 内部全局 Logger 对象完成的操作,但是包变量无法实现不同模块日志的相互隔离,因此我们还是希望保持依赖注入的写法:
3. zap 实战
3.1 封装统一日志接口
现在的情况是业务代码与 zap 日志紧密耦合在一起,如果后续需要更换其他的日志框架,就需要同步修改业务代码,因此我们可以抽象出自己的一套统一日志接口
step1:定义统一日志接口 LoggerX
go
package logx
type LoggerX interface {
Debug(msg string, args ...Field)
Info(msg string, args ...Field)
Warn(msg string, args ...Field)
Error(msg string, args ...Field)
}
type Field struct {
Key string
Value any
}
这里我们模拟 zap 的日志定义,参数使用 Field 结构体封装,我们还可以借鉴 zap 提供如 zap.Error、zap.String类似快速创建 Field 的方法
step2:定义 ZapLogger 的结构体实现
go
package logx
import "go.uber.org/zap"
type ZapLogger struct {
logger *zap.Logger
}
func NewZapLogger(logger *zap.Logger) *ZapLogger {
return &ZapLogger{
logger: logger,
}
}
func (z *ZapLogger) toArgs(args []Field) []zap.Field {
var result = make([]zap.Field, len(args))
for i, arg := range args {
result[i] = zap.Any(arg.Key, arg.Value)
}
return result
}
func (z *ZapLogger) Debug(msg string, args ...Field) {
z.logger.Debug(msg, z.toArgs(args)...)
}
func (z *ZapLogger) Info(msg string, args ...Field) {
z.logger.Info(msg, z.toArgs(args)...)
}
func (z *ZapLogger) Warn(msg string, args ...Field) {
z.logger.Warn(msg, z.toArgs(args)...)
}
func (z *ZapLogger) Error(msg string, args ...Field) {
z.logger.Error(msg, z.toArgs(args)...)
}
step3:业务代码改造成使用自定义接口
go
type UserHandler struct {
EmailCompile *regexp.Regexp
PasswordCompile *regexp.Regexp
NicknameCompile *regexp.Regexp
BirthdayCompile *regexp.Regexp
AboutMeCompile *regexp.Regexp
svc service.IUserService
codeSvc service.CodeService
jwtHdl IJwtHandler
logger logx.LoggerX
}
3.2 封装请求响应日志中间件
目前我们有一个通用功能需要解决:那就是使用 AOP 机制在系统的入口接收请求以及出口返回响应的地方记录日志,当然 Gin 框架提供的 middleware 机制本质上就是 AOP 的实现方式,即我们可以借助 Gin 的 middleware 实现这个通用日志处理功能:
go
package middleware
import (
"bytes"
"context"
"github.com/gin-gonic/gin"
"io"
"time"
)
type LogMiddlewareBuilder struct {
allowReqBody bool // 是否打印请求体
allowRespBody bool // 是否打印响应体
logFunc func(context context.Context, al AccessLog) // 打印逻辑
}
func NewLogMiddlewareBuilder(logFunc func(context context.Context, al AccessLog)) *LogMiddlewareBuilder {
return &LogMiddlewareBuilder{
logFunc: logFunc,
}
}
func (l *LogMiddlewareBuilder) AllowReqBody() *LogMiddlewareBuilder {
l.allowReqBody = true
return l
}
func (l *LogMiddlewareBuilder) AllowRespBody() *LogMiddlewareBuilder {
l.allowRespBody = true
return l
}
func (l *LogMiddlewareBuilder) Build() gin.HandlerFunc {
return func(ctx *gin.Context) {
// 1. 打印请求
var accessLog AccessLog
accessLog.url = ctx.Request.URL.String()
accessLog.method = ctx.Request.Method
// 究竟要不要打印请求体(可能很大)
// 究竟是用info呢还是debug呢?
if l.allowReqBody && ctx.Request.Body != nil {
data, _ := ctx.GetRawData()
// 需要将数据放回去
ctx.Request.Body = io.NopCloser(bytes.NewBuffer(data))
accessLog.reqBody = string(data[:1024])
}
// 2. 打印响应
if l.allowRespBody {
// 替换ctx中的responseWrite
ctx.Writer = &responseWriter{
ResponseWriter: ctx.Writer,
al: &accessLog,
}
}
start := time.Now()
defer func() {
accessLog.duration = time.Since(start)
l.logFunc(ctx, accessLog)
}()
// 放行
ctx.Next()
}
}
// AccessLog 允许打印的日志对象
type AccessLog struct {
statusCode int
url string
method string
reqBody string
respBody string
duration time.Duration
}
type responseWriter struct {
gin.ResponseWriter
al *AccessLog
}
func (w *responseWriter) Write(data []byte) (n int, err error) {
w.al.respBody = string(data)
return w.ResponseWriter.Write(data)
}
func (w *responseWriter) WriteHeader(code int) {
w.al.statusCode = code
w.ResponseWriter.WriteHeader(code)
}