• Go实现日志2——支持结构化和hook


    代码保存在:https://github.com/liwook/Go-projects/tree/main/log/sulogV2​​​​​​​

    1.日志结构化

    日志记录的事件以结构化格式(键值对,或通常是 JSON)表示,随后可以通过编程方式对其进行解析,便于对日志进行监控、警报、审计等其他形式的分析。

    结构化的格式例子。

    time=2023-10-08T21:09:03.912+08:00 level=INFO msg=TextHandler 姓名=陈

    那这里就要回顾下(entry).log方法。

    1. func (e *Entry) log(level Level, msg string) {
    2. e.Message = msg,e.Time = time.Now(),e.Level = level
    3. e.File, e.Line, e.Func = file, line, runtime.FuncForPC(pc).Name()
    4. e.Func = e.Func[strings.LastIndex(e.Func, "/")+1:]
    5. e.write()
    6. }
    7. func (e *Entry) write() {
    8. e.logger.mu.Lock()
    9. defer e.logger.mu.Unlock()
    10. e.logger.opt.formatter.Format(e)
    11. e.logger.opt.output.Write(e.Buffer.Bytes())
    12. }

    在e.write()中,其主要是两步:按照规定的格式化数据后,把信息存在e.Buffer中;然后经由e.write()写入。重点在格式化数据那步,所以我们要看e.format方法。

    那就回到Formatter接口。而JSON类型的是已经结构化的,重点来看看TEXT文本格式的。

    文本格式结构化

    在formatter.go文件中添加些常量:

    1. //formatter.go
    2. const (
    3. defaultTimestampFormat = time.RFC3339
    4. KeyMsg = "msg"
    5. KeyLevel = "level"
    6. KeyTime = "time"
    7. KeyFunc = "func"
    8. KeyFile = "file"
    9. )

     在formatter_text.go中进行修改添加:

    1. //formatter_text.go
    2. // 格式是: 时间 日志等级 文件:所在行号 函数名称 日志内容
    3. func (f *TextFormatter) Format(e *Entry) error {
    4. if !f.DisableTimestamp {
    5. if f.TimestampFormat == "" {
    6. f.TimestampFormat = time.RFC3339
    7. }
    8. f.appendKeyValue(e.Buffer, KeyTime, e.Time.Format(f.TimestampFormat))
    9. }
    10. f.appendKeyValue(e.Buffer, KeyLevel, LevelName[e.Level]) //添加日志等级
    11. if !e.logger.opt.disableCaller {
    12. if e.File != "" {
    13. short := e.File
    14. for i := len(e.File) - 1; i > 0; i-- {
    15. if e.File[i] == '/' {
    16. short = e.File[i+1:]
    17. break
    18. }
    19. }
    20. //添加函数名和文件名
    21. f.appendKeyValue(e.Buffer, KeyFunc, short)
    22. f.appendKeyValue(e.Buffer, KeyFile, e.File+":"+strconv.Itoa(e.Line))
    23. }
    24. }
    25. f.appendKeyValue(e.Buffer, KeyMsg, e.Message)
    26. e.Buffer.WriteString("\n")
    27. return nil
    28. }
    29. //上一节的写法,没有结构化的
    30. func (f *TextFormatter) Format(e *Entry) error {
    31. if !f.DisableTimestamp {
    32. if f.TimestampFormat == "" {
    33. f.TimestampFormat = time.RFC3339
    34. }
    35. e.Buffer.WriteString(fmt.Sprintf("%s %s", e.Time.Format(f.TimestampFormat), LevelNameMapping[e.Level]))
    36. } else {
    37. e.Buffer.WriteString(LevelNameMapping[e.Level])
    38. }
    39. if e.File != "" {
    40. short := e.File
    41. for i := len(e.File) - 1; i > 0; i-- {
    42. if e.File[i] == '/' {
    43. short = e.File[i+1:]
    44. break
    45. }
    46. }
    47. e.Buffer.WriteString(fmt.Sprintf(" %s:%d %s", short, e.Line, e.Func))
    48. }
    49. e.Buffer.WriteString(" ")
    50. e.Buffer.WriteString(e.Message)
    51. e.Buffer.WriteByte('\n')
    52. return nil
    53. }

     新的Format方法也好理解,主要是增添了调用appendKeyValue方法,该方法是进行键值对格式书写的。

    1. func (f *TextFormatter) appendKeyValue(b *bytes.Buffer, key string, value any) {
    2. if b.Len() > 0 {
    3. b.WriteByte(' ')
    4. }
    5. b.WriteString(key)
    6. b.WriteByte('=')
    7. f.appendValue(b, value)
    8. }
    9. func (f *TextFormatter) appendValue(b *bytes.Buffer, value any) {
    10. stringVal, ok := value.(string)
    11. if !ok {
    12. stringVal = fmt.Sprint(value)
    13. b.WriteString(stringVal)
    14. } else {
    15. b.WriteString(fmt.Sprintf("%q", stringVal)) //这样就是加""符号的
    16. }
    17. }

    到现在TEXT文本格式的也可以进行结构化输出了。

    1. sulog.Info("hello info")
    2. //输出结果
    3. time="2024-01-29T11:45:33+08:00" level="INFO" func="main.go" file="D:/code/gWork/log/sulogV2/cmd/main.go:42" msg="hello info"

    增添WithFieldWithFields

    做到这一步还不够,还有这种需求,想额外输出name="li" age=13这种的。就不单单是输出msg=...。

    level="INFO" func="main.go" file="D:/code/gWork/log/sulogV2/cmd/main.go:42" msg="hello info" name="li" age=13

    代码使用例子:两个方法WithFieldWithFields

    1. sulog.WithField("age", "11").Info("ok withField")
    2. sulog.WithFields(sulog.Fields{
    3. "name": "li",
    4. "age": 32,
    5. }).Info("ok withFields")

    调用WithField后,还可以继续调用,说明其返回值应该是logger或者entry。

    在logger.go中添加这两个方法,该方法返回值是*Entry。这两个方法都需要从entryPool中获取一个entry。

    1. //optins.go
    2. // Fields type, used to pass to `WithFields`.
    3. type Fields map[string]any
    4. //logger.go
    5. func (l *logger) WithField(key string, value any) *Entry {
    6. entry := l.entry()
    7. defer l.releaseEntry(entry)
    8. return entry.WithField(key, value)
    9. }
    10. func (l *logger) WithFields(fields Fields) *Entry {
    11. entry := l.entry()
    12. defer l.releaseEntry(entry)
    13. return entry.WithFields(fields)
    14. }
    15. //全局的 std
    16. func WithField(key string, value any) *Entry {
    17. return std.WithField(key, value)
    18. }
    19. func WithFields(fields Fields) *Entry {
    20. return std.WithFields(fields)
    21. }

     那接着就到了结构体Entry中了。(Entry).WithField也是调用WithFields。

    而在WithFields方法的参数中,fields的键值对的值是any类型,所以需要用到reflcect来进行类型判别筛选。WithFields返回值是*Entry,该方法中是返回了一个新Entry,所以需要拷贝*logger,data属性。

    1. //entry.go
    2. type Entry struct {
    3. //该结构体其他的没有改变,只是DataMap不再使用,改为使用Data,基本是一样的意思的
    4. // DataMap map[string]any //为了日志是json格式使用的
    5. Data Fields //保存WithField中的数据
    6. .................
    7. }
    8. // Add a single field to the Entry.
    9. func (entry *Entry) WithField(key string, value any) *Entry {
    10. return entry.WithFields(Fields{key: value})
    11. }
    12. // Add a map of fields to the Entry.
    13. func (entry *Entry) WithFields(fields Fields) *Entry {
    14. data := make(Fields, len(entry.Data)+len(fields))
    15. //为了可以这样使用sulog.WithField("name","li").WithField("addr","zhong").Info(1)
    16. for k, v := range entry.Data {
    17. data[k] = v
    18. }
    19. for k, v := range fields {
    20. isErrField := false
    21. if t := reflect.TypeOf(v); t != nil {
    22. //如果value类型是函数类型,是不符合要求的
    23. if t.Kind() == reflect.Func || (t.Kind() == reflect.Pointer && t.Elem().Kind() == reflect.Func) {
    24. isErrField = true
    25. }
    26. }
    27. if isErrField {
    28. tmp := fmt.Sprintf("can not add field %q", k)
    29. fmt.Println(tmp)
    30. } else {
    31. data[k] = v
    32. }
    33. }
    34. return &Entry{logger: entry.logger, Data: data}
    35. }

    WithFields返回*Entry后可以继续打印日志,说明Entry也需要实现Debug这些方法。

    1. // entry打印方法
    2. func (e *Entry) Debug(args ...any) {
    3. e.Log(3, DebugLevel, args...) //3是调用 runtime.Caller() 时,传入的栈深度
    4. }
    5. // 自定义格式
    6. func (e *Entry) Debugf(format string, args ...any) {
    7. e.Logf(3, DebugLevel, format, args...)
    8. }

    在sulog.WithField("age", "11").Info("ok withField")代码中,WithField后会进行releseEntry,即entry.Buffer也被重置了。那要获取到之前的Buffer的话,那就需要在WithFields中拷贝entry.Buffer(或者重新new),这可能会浪费些性能。

    1. func (entry *Entry) WithFields(fields Fields) *Entry {
    2. data := make(Fields, len(entry.Data)+len(fields))
    3. .........................
    4. //return &Entry{logger: entry.logger, Data: data}
    5. return &Entry{logger: entry.logger, Data: data,Buffer:entry.Buffer}//拷贝Buffer
    6. }

     再看回到使用entry.Buffer的地方(entry).log方法中,这里是没有对entry.Buffer进行new的,直接使用的话,是会出问题的。

    所以我们也可以仿照entryPool那样,也使用sync.Pool类型来做一个Buffer池。

    在logger中添加sync.Pool类型的Buffer池

    在logger结构体中添加如下

    1. type logger struct {
    2. .............
    3. entryPool *sync.Pool
    4. bufferPool *sync.Pool //添加Buffer池
    5. }
    6. func New(opts ...Option) *logger {
    7. logger := &logger{opt: initOptions(opts...), Hooks: make(LevelHooks)}
    8. logger.entryPool = &sync.Pool{New: func() any { return entry(logger) }}
    9. //初始化bufferPool
    10. logger.bufferPool = &sync.Pool{New: func() any { return new(bytes.Buffer) }}
    11. return logger
    12. }
    13. //entry.go
    14. //创建entry 的方法就有了改变,不再需要new(bytes.Buffer)
    15. func entry(logger *logger) *Entry {
    16. return &Entry{
    17. logger: logger,
    18. // Buffer: new(bytes.Buffer),
    19. // DataMap: make(map[string]any, 5)
    20. Data: make(map[string]any, 5),
    21. }
    22. }

     之后修改(Entry).log方法,在其中通过bufferPool获取Buffer。

    1. func (e *Entry) log(depth int, level Level, msg string) {
    2. e.Time = time.Now()
    3. e.Level = level
    4. e.Message = msg
    5. if !e.logger.opt.disableCaller {
    6. if pc, file, line, ok := runtime.Caller(depth); !ok {
    7. e.File = "???"
    8. e.Func = "???"
    9. } else {
    10. e.File, e.Line, e.Func = file, line, runtime.FuncForPC(pc).Name()
    11. e.Func = e.Func[strings.LastIndex(e.Func, "/")+1:]
    12. }
    13. }
    14. bufPool := e.logger.bufferPool
    15. buffer := bufPool.Get().(*bytes.Buffer)//获取buffer
    16. e.Buffer = buffer
    17. defer func() { //使用完后,放回bufferPool池中
    18. e.Buffer = nil
    19. buffer.Reset()
    20. bufPool.Put(buffer)
    21. }()
    22. e.write()
    23. }

    而且这样写,使用sulog.Info("info")也正常了,因为创建entry时候是没有new(bytes.Buffer)。

    接着来看看测试例子:

    1. sulog.WithField("name", "11").Info("ok withField")
    2. sulog.WithFields(sulog.Fields{
    3. "name": "li",
    4. "age": 32,
    5. }).Info("ok withFields")
    6. //打印结果
    7. time="2024-01-29T13:52:42+08:00" level="INFO" func="main.go" file="D:/code/gWork/log/sulogV2/cmd/main.go:46" msg="ok withField" name="11"
    8. time="2024-01-29T13:52:42+08:00" level="INFO" func="main.go" file="D:/code/gWork/log/sulogV2/cmd/main.go:50" msg="ok withFields" age=32 name="li"

    区分WithField中的键"level" 和 sulog.Info()中的"level"

    到这里是基本完成了,但还是可能会有问题,比如:

    sulog.WithField("level", "debug").Info("ok withField")

    这种日志等级应该是info,但是在WithField中level写到是debug,那就会有冲突了。时间和函数名那些要是也这样写的话,也会有冲突的。那该怎么做呢,那就做个区分,只要是在WithField中的,一致使用field.*来表示,level就用field.level来表示。

    在formatter.go文件中添加如下代码:

    该函数功能是查看WithField中要是有"level","file"这些,就把key修改成"fields.levle"等等。

    1. //data是WithField方法中的数据
    2. func prefixFieldClashes(data Fields) {
    3. for k, v := range data {
    4. switch k {
    5. case KeyMsg:
    6. data["fields."+KeyMsg] = v
    7. delete(data, KeyMsg)
    8. case KeyLevel:
    9. data["fields."+KeyLevel] = v
    10. delete(data, KeyLevel)
    11. case KeyFunc:
    12. data["fields."+KeyFunc] = v
    13. delete(data, KeyFunc)
    14. case KeyTime:
    15. data["fields."+KeyTime] = v
    16. delete(data, KeyTime)
    17. case KeyFile:
    18. data["fields."+KeyFile] = v
    19. delete(data, KeyFile)
    20. }
    21. }
    22. }

    那么在JSON格式和TEXT格式的Format方法中使用。

    1. func (f *JsonFormatter) Format(e *Entry) error {
    2. data := make(Fields, len(e.Data)+5)
    3. prefixFieldClashes(e.Data)
    4. for k, v := range e.Data {
    5. data[k] = v
    6. }
    7. if !f.DisableTimestamp {
    8. if f.TimestampFormat == "" {
    9. f.TimestampFormat = time.RFC3339
    10. }
    11. data[KeyTime] = e.Time.Format(f.TimestampFormat)
    12. }
    13. data[KeyLevel] = LevelName[e.Level]
    14. if e.File != "" {
    15. data[KeyFile] = e.File + ":" + strconv.Itoa(e.Line)
    16. data[KeyFunc] = e.Func
    17. }
    18. data[KeyMsg] = e.Message
    19. return sonic.ConfigDefault.NewEncoder(e.Buffer).Encode(data)
    20. }
    21. //TEXT文本格式的
    22. func (f *TextFormatter) Format(e *Entry) error {
    23. prefixFieldClashes(e.Data)
    24. if !f.DisableTimestamp {
    25. ................
    26. }
    27. ...............
    28. f.appendKeyValue(e.Buffer, KeyMsg, e.Message)
    29. //加上WithField()中的
    30. for k, v := range e.Data {
    31. f.appendKeyValue(e.Buffer, k, v)
    32. }
    33. e.Buffer.WriteString("\n")
    34. return nil
    35. }

    来看看测试结果

    1. sulog.SetOptions(sulog.WithDisableCaller(true))
    2. sulog.SetOptions(sulog.WithFormatter(&sulog.JsonFormatter{DisableTimestamp: true}))
    3. sulog.WithFields(sulog.Fields{
    4. "level": "info",
    5. "name": "lihai",
    6. "msg": "this field message",
    7. }).Info("ok withField")
    8. //打印结果
    9. {"fields.msg":"this field message","name":"lihai","fields.level":"info","level":"INFO","msg":"我们ok withField"}

    在使用该日志时,鼓励用sulog.WithFields(log.Fields{}).Infof() 这种方式替代sulog.Infof("Failed to send event %s to topic %s"), 也就是不是用 %s,%d 这种方式格式化,而是直接传入变量 event,topic 给 log.Fields ,这样就显得结构化日志输出,很人性化美观。

    2.支持hook

    钩子(Hooks),可以理解成函数,可以让我们在日志输出前或输出后进行一些额外的处理。常见的使用场景包括发送邮件、写入数据库、上传到云端等。

    一个日志,是有很多日志等级的,那就可能需要筛选出符合条件的等级才能触发hook。

    在logger结构体中添加变量Hooks:

    即是可以添加多个日志等级的hook,一个日志等级可以有多个hook。

    1. //新添加hooks.go文件,添加该类型
    2. type LevelHooks map[Level][]Hook
    3. //logger.go
    4. type logger struct {
    5. ...................
    6. Hooks LevelHooks //多个日志等级的多个hook
    7. }

    定义Hook

    其肯定是有对应的可执行的日志等级列表和执行的函数这两个的。

    给用户使用,那我们可以定义成接口,命名为Hook。

    用户实现Hook接口即可。

    1. type Hook interface {
    2. Levels() []Level
    3. Fire(*Entry) error
    4. }

    所以在Hook中用户需要做的就是在Fire方法中定义想如何操作这一条日志的方法,在Levels方法中定义想展示的日志级别

    给LevelHooks添加方法

    那肯定是有添加hook(Add)执行所有hook(Fire)的方法。

    1. func (hooks LevelHooks) Add(hook Hook) {
    2. for _, level := range hook.Levels() {
    3. hooks[level] = append(hooks[level], hook)
    4. }
    5. }
    6. //执行日志等级列表中全部hook
    7. func (hooks LevelHooks) Fire(level Level, entry *Entry) error {
    8. for _, hook := range hooks[level] {
    9. if err := hook.Fire(entry); err != nil {
    10. return err
    11. }
    12. }
    13. return nil
    14. }

    那在logger结构体中增加AddHook方法。

    1. // 添加hook
    2. func (l *logger) AddHook(hook Hook) {
    3. //用锁,保证了执行hook时候就不能添加hook
    4. l.mu.Lock()
    5. defer l.mu.Unlock()
    6. l.Hooks.Add(hook)
    7. }
    8. func AddHook(hook Hook) {
    9. std.AddHook(hook)
    10. }

    很明显,(LevelHooks).Add在(logger).AddHook中调用了,而Fire没有使用,那这样的话,钩子函数就没有能执行。

    执行hook

    那其应该是在哪被调用呢?应该在(Entry).log中。

    1. func (e *Entry) log(depth int, level Level, msg string) {
    2. ............
    3. if !e.logger.opt.disableCaller {
    4. .....................
    5. }
    6. e.fireHooks() //执行hook, 就是在这添加这句
    7. bufPool := e.logger.bufferPool
    8. ...............
    9. e.write()
    10. }
    11. //这种写法不好,执行hook时候一直霸占锁,不妥
    12. func (entry *Entry) fireHooks() {
    13. entry.logger.mu.Lock()
    14. err := entry.logger.Hooks.Fire(entry.Level, entry)
    15. if err != nil {
    16. fmt.Fprintf(os.Stderr, "Failed to fire hook: %v\n", err)
    17. }
    18. entry.logger.mu.Unlock()
    19. }

    一般是很容易想到上面的fireHooks的实现,但这种实现不好,要是hook是比较耗时的操作,那就会一直霸占锁,可以会引起阻塞。

    解决办法:我们可以用个临时变量把所有的hook拷贝过来,之后就可以释放锁,然后就可以执行hook。这样就不会长时间霸占锁。

    1. func (entry *Entry) fireHooks() {
    2. var tmpHooks LevelHooks //临时变量
    3. entry.logger.mu.Lock()
    4. tmpHooks = make(LevelHooks, len(entry.logger.Hooks))
    5. //进行拷贝
    6. for k, v := range entry.logger.Hooks {
    7. tmpHooks[k] = v
    8. }
    9. entry.logger.mu.Unlock()
    10. err := tmpHooks.Fire(entry.Level, entry) //执行hook
    11. if err != nil {
    12. fmt.Fprintf(os.Stderr, "Failed to fire hook: %v\n", err)
    13. }
    14. }

    测试:

    实现Hook接口。

    1. type hook struct{}
    2. func (h *hook) Levels() []sulog.Level {
    3. // return sulog.AllLevels
    4. return []sulog.Level{sulog.InfoLevel, sulog.DebugLevel}//只有info和debug两种等级
    5. }
    6. func (h *hook) Fire(entry *sulog.Entry) error {
    7. fmt.Printf("send emial. this is a hook func:%v\n", entry.Data)
    8. return nil
    9. }
    10. func main() {
    11. sulog.SetOptions(sulog.WithDisableCaller(true))
    12. sulog.SetOptions(sulog.WithFormatter(&sulog.TextFormatter{DisableTimestamp: true}))
    13. sulog.AddHook(&hook{})
    14. sulog.WithField("name", "11").Info("ok withField")
    15. fmt.Println()
    16. sulog.WithField("country", "China").Error("ok withField")
    17. }

    效果:

    1. this is a hook func:map[name:11]
    2. level="INFO" msg="ok withField" name="11"
    3. level="ERROR" msg="ok withField" country="China"

  • 相关阅读:
    Angular 中的路由
    想进入游戏建模行业,这4点必须了解
    【vscode远程开发】使用内网穿透实现在公网环境下远程访问
    新零售六大模式,你学会了吗?
    Linux 虚拟机内挂载 iso 文件
    LQ0134 最大降雨量【中位数】
    SpingMvc入门
    Centos7 bash:jps:Command not found....
    【LeetCode】No.78. Subsets -- Java Version
    DOM 文档对象模型(Document Object Model), W3C 组织推荐的处理可扩展标记语言的标准编程接口。
  • 原文地址:https://blog.csdn.net/m0_57408211/article/details/135906524