0%

go 库学习之 log 库

这篇文章将开始学习 go 标准库提供的 log 功能。log 包提供了简单的、格式化日志输出功能,log/syslog 包则提供了访问系统日志服务(syslog)的能力,而 log/slog 包则提供了结构化日志功能。

这篇文章将首先学习 loglog/syslog 包。

log 包

go 标准库的 log 包实现了简易的日志功能。log 库定义了 Logger 类型,该类型提供了一些方法来进行格式化输出。同时为了简化使用、省去手动创建 Logger 对象的步骤,该包也预定义了一个 标准的 logger 对象,可以直接通过工具函数 Print[f|ln]Fatal[f|ln]Panic[f|ln] 来使用该 logger 对象。

Logger 类型

Logger 类型用于表示一个日志对象,通过日志对象即可将日志消息写入到指定的 io.Writer 中。Logger 类型的定义如下,它可以用于并发环境,即可在同时在多个 goroutine 中使用同一个 Logger 对象,Logger 内部能够确保序列化地访问 Writer。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Logger struct {
// 输出操作的互斥锁,从而支持并发写入操作
outMu sync.Mutex
// 日志消息的输出目的地
out io.Writer // destination for output

// 日志消息输出的前缀
prefix atomic.Pointer[string] // prefix on each line to identify the logger (but see Lmsgprefix)
// 日志对象的属性,会影响日志输出的行为
flag atomic.Int32 // properties
// 是否丢弃日志
// 当 out 字段为 io.Discard 时,该字段会被设置为 true
isDiscard atomic.Bool
}

Logger 对象的创建

log.New 函数用于创建一个 Logger:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 创建 Logger 对象
// out 参数设置日志消息的目的地,它可以是实现了 `io.Writer` 接口的任意类型
// prefix 用于设置每条日志消息的前缀
// 如果没有设置 Lmsgprefix 标志,那么前缀就是放在日志行的开头
// 如果设置了 Lmsgprefix 标志,那么前缀是放在日志头之后
// flag 参数用于设置日志对象的属性
func New(out io.Writer, prefix string, flag int) *Logger {
l := new(Logger)
l.SetOutput(out)
l.SetPrefix(prefix)
l.SetFlags(flag)
return l
}

flag 标志可以是如下值,用于控制日志对象的日志输出行为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const (
// 日志输出中包含日期
Ldate = 1 << iota // the date in the local time zone: 2009/01/23
// 日志输出中包含时间
Ltime // the time in the local time zone: 01:23:23
// 日志输出中包含微秒时间
Lmicroseconds // microsecond resolution: 01:23:23.123123. assumes Ltime.
// 日志输出中包含完整文件路径名称
Llongfile // full file name and line number: /a/b/c/d.go:23
// 日志输出中包含短文件名称
Lshortfile // final file name element and line number: d.go:23. overrides Llongfile
// 日志输出的日期时间采用 UTC
LUTC // if Ldate or Ltime is set, use UTC rather than the local time zone
// 日志消息前缀的位置
// 设置该标志后,prefix 会被放在日志消息体前,而不是日志行行首
Lmsgprefix // move the "prefix" from the beginning of the line to before the message
// 标准日志对象所使用的标志
LstdFlags = Ldate | Ltime // initial values for the standard logger
)

日志输出的实现

日志输出的核心实现是在 Logger.output 函数中,它的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
func (l *Logger) output(pc uintptr, calldepth int, appendOutput func([]byte) []byte) error {
// 如果 logger 对象已经设置了 discard 标志
// 直接返回
if l.isDiscard.Load() {
return nil
}

// 获取当前时间、日志前缀、日志 flags
now := time.Now() // get this early.

prefix := l.Prefix()
flag := l.Flags()

var file string
var line int
// 如果需要输出文件信息
if flag&(Lshortfile|Llongfile) != 0 {
// 如果没有指定 pc,则通过 calldepth 来获取调用者的文件、行信息
// calleddepth 表示 `日志输出调用函数` 距离当前 `runtime.Caller` 调用函数的深度。
if pc == 0 {
var ok bool
_, file, line, ok = runtime.Caller(calldepth)
if !ok {
file = "???"
line = 0
}
} else {
// 否则通过 pc 来获取文件、行信息
// pc 的值应该是调用栈中 `日志输出函数` 调用点所在函数的 pc 值
fs := runtime.CallersFrames([]uintptr{pc})
f, _ := fs.Next()
file = f.File
if file == "" {
file = "???"
}
line = f.Line
}
}

// 获取 buffer
buf := getBuffer()
defer putBuffer(buf)
// 添加日志消息头
formatHeader(buf, now, prefix, flag, file, line)
// 添加日志消息内容
// 通过定义一个回调函数,让 appendOutput 自己处理如何将日志消息内容添加到 buf 中
*buf = appendOutput(*buf)
// 判断是否需要添加换行符
if len(*buf) == 0 || (*buf)[len(*buf)-1] != '\n' {
*buf = append(*buf, '\n')
}

// 输出日志消息之前,先 lock
l.outMu.Lock()
defer l.outMu.Unlock()
// 输出日志消息
_, err := l.out.Write(*buf)
return err
}

为了获取日志输出点(即调用日志输出函数的代码位置)的文件名、行号信息,log 包使用 runtime 包,并通过 calldepth 或者 pc 值来获取这些信息

  • 如果 pc 值为 0,则通过 runtime.Caller(calldepth) 来获取。calleddepth 表示调用栈中 日志输出函数 调用点距离 runtime.Caller 调用点的深度。
  • 如果指定了 pc 值,则通过 runtime.CallersFrames([]uintptr{pc}) 并通过 Frames.Next() 来获取,因此 pc 的值应该是调用栈中 日志输出函数 调用点所在函数的 pc 值

runtime.Caller(skip) 中的 skip 参数表示调用栈帧的层次,如果为 0 则表示调用 runtime.Caller 的函数所在的栈帧,如果为 1,则表示调用链更上一层函数所在的栈帧。如果是一个简单示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// caller.go
package main

func f1() {
f2()
}

func f2() {
f3()
}

func main() {
println("caller test start")
f1()
println("caller test end")
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// callee.go
package main

import (
"fmt"
"runtime"
)

func f3() {
f4()
f5()
}

func f4() {
// 0 表示调用 runtime.Caller 的函数所在的栈帧
// 输出的是 f4() 函数中 `runtime.Caller` 调用点的文件、行信息
fmt.Println(runtime.Caller(0))
fmt.Println(runtime.Caller(1))
fmt.Println(runtime.Caller(2))
fmt.Println(runtime.Caller(3))
fmt.Println(runtime.Caller(4))
fmt.Println(runtime.Caller(5))
}

func f5() {
pc, _, _, _ := runtime.Caller(0)
frames := runtime.CallersFrames([]uintptr{pc})

for {
f, more := frames.Next()
fmt.Println(f.File, f.Line, f.Func.Name())

if !more {
break
}
}
}
1
2
3
4
5
6
7
8
9
10
# ./caller
caller test start
4780932 /root/code/private/go/go_std/log/caller/callee.go 17 true
4780878 /root/code/private/go/go_std/log/caller/callee.go 10 true
4783120 /root/code/private/go/go_std/log/caller/caller.go 9 true
4783115 /root/code/private/go/go_std/log/caller/caller.go 5 true
4783114 /root/code/private/go/go_std/log/caller/caller.go 14 true
4412106 /usr/local/go/src/runtime/proc.go 272 true
/root/code/private/go/go_std/log/caller/callee.go 26 main.f5
caller test end

output 函数的 appendOutput 参数是一个回调函数,用于将日志消息内容添加到目标缓冲区中。因为 logger 库提供的 API 不同,日志消息内容的添加方式不同,因此通过定义一个回调函数,让 appendOutput 自己处理如何将日志消息内容添加到 buf 中

logger 库为了高效地管理日志输出所使用的缓冲区,定义了一个 bufferPoolgetBuffer 函数用于从该 Pool 获取一个 buffer,在将日志消息写入到 io.Writer 后,通过 putBuffer 函数将该 buffer 放回 Pool 中:

1
var bufferPool = sync.Pool{New: func() any { return new([]byte) }}

一个完整日志消息的构造可以分为如下三个步骤:

  • formatHeader(buf, now, prefix, flag, file, line) 用于在 buf 中添加日志消息头
  • 通过 appendOutput 添加日志消息体
  • 最后会判断日志消息的末尾是否存在换行符,如果没有,自动添加一个换行符,以保证每条日志消息总是单独一行

在构造完日志消息后,就是调用 l.out.Write(*buf) 输出日志消息了。

日志输出 API

在学习日志输出的核心实现后,再来看 logger 库提供的日志输出 API。由于这些日志输出 API 都是借助 fmt 库中的格式化输出函数来构造日志消息体,因此和 fmt 库的 API 类似,提供三种 API 的变体:XXXXXXlnXXXf

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Print 系列函数
func (l *Logger) Print(v ...any)
func (l *Logger) Println(v ...any)
func (l *Logger) Printf(format string, v ...any)

// Fatal 系列函数
func (l *Logger) Fatal(v ...any)
func (l *Logger) Fatalln(v ...any)
func (l *Logger) Fatalf(format string, v ...any)

// Panic 系列函数
func (l *Logger) Panic(v ...any)
func (l *Logger) Panicln(v ...any)
func (l *Logger) Panicf(format string, v ...any)

// 更灵活的日志输出函数
// 尤其是 calledepth 可以通过参数指定
func (l *Logger) Output(calldepth int, s string) error
  • Print 系列函数用于单纯输出日志消息
  • Fatal 系列函数在输出日志消息后,会调用 os.Exit(1) 退出程序
  • Panic 系列函数在输出日志消息后,会以日志消息为参数,触发 panic 流程

Output 函数可以让我们更自由地输出日志,尤其是 calledepth 参数,可以让我们对日志输出函数进行包装的同时,正确地输出 日志输出调用点 的文件名、行信息,如下是一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import (
"fmt"
"log"
"os"
)

func PrintWithPrefix(l *log.Logger, prefix string, v ...any) {
l.Print(append([]any{prefix}, v...)...)
}

func OutputWithPrefix(l *log.Logger, prefix string, v ...any) {
l.Output(2, fmt.Sprint(append([]any{prefix}, v...)...))
}

func main() {
l := log.New(os.Stdout, "", log.LstdFlags|log.Lshortfile)
PrintWithPrefix(l, "wrapper logger", "log message 1")
PrintWithPrefix(l, "wrapper logger", "log message 2")

OutputWithPrefix(l, "wrapper logger", "log message 3")
OutputWithPrefix(l, "wrapper logger", "log message 4")
}
1
2
3
4
5
# go run wrap_log.go
2024/12/07 19:15:00 wrap_log.go:10: wrapper loggerlog message 1
2024/12/07 19:15:00 wrap_log.go:10: wrapper loggerlog message 2
2024/12/07 19:15:00 wrap_log.go:22: wrapper loggerlog message 3
2024/12/07 19:15:00 wrap_log.go:23: wrapper loggerlog message 4

可以看到,在对日志输出函数进行包裹后,PrintWithPrefix 总是打印 Print 函数所在的文件行信息,而 OutputWithPrefix 则可以正确打印日志输出函数调用点所在的文件行信息。

Logger 类型的其他方法

通过上面 Logger 类型的定义可以看到,其内部实现都是非导出字段,因此 Logger 类型也提供了一系列方法,来获取和设置日志对象的各种属性。下面简单列举如下:

1
2
3
4
5
6
7
8
9
10
11
12
// 返回日志对象的 flags
func (l *Logger) Flags() int
// 设置日志对象的 flags
func (l *Logger) SetFlags(flag int)
// 返回日志对象的 Prefix
func (l *Logger) Prefix() string
// 设置日志对象的 Prefix
func (l *Logger) SetPrefix(prefix string)
// 返回日志对象的 Writer
func (l *Logger) Writer() io.Writer
// 设置日志消息的 Writer
func (l *Logger) SetOutput(w io.Writer)

标准 Logger 对象

为了简化 log 包的使用,log 包预定义了一个 标准 Logger 对象。从它的创建方式可以看出,标准 logger 对象 将日志写入到标准错误上,并打印每条日志消息的日期时间,

1
2
3
4
5
const (
LstdFlags = Ldate | Ltime
)

var std = New(os.Stderr, "", LstdFlags)

如下是 log 包提供的顶层系列函数,这些函数操作的就是该 标准 Logger 对象,其行为与 Logger 类型的对应方法相同,这里就不再赘述:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 返回标准日志对象,从而可以继续修改标准日志对象的行为
func Default() *Logger { return std }

func Print(v ...any)
func Println(v ...any)
func Printf(format string, v ...any)

func Fatal(v ...any)
func Fatalln(v ...any)
func Fatalf(format string, v ...any)

func Panic(v ...any)
func Panicln(v ...any)
func Panicf(format string, v ...any)

func Output(calldepth int, s string) error

func Flags() int
func SetFlags(flag int)
func Prefix() string
func SetPrefix(prefix string)
func Writer() io.Writer
func SetOutput(w io.Writer)

如下是使用 标准 Logger 对象 的简单示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import "log"

func main() {
s := "test log message"
log.Printf(s)
stdLogger := log.Default()

stdLogger.SetPrefix("prefix/")
log.Printf(s)

stdLogger.SetFlags(log.LstdFlags | log.Lmsgprefix)
log.Printf(s)
}
1
2
3
4
# go run msgprefix.go
2024/12/07 11:35:13 test log message
prefix/2024/12/07 11:35:13 test log message
2024/12/07 11:35:13 prefix/test log message

syslog 包

标准库的 log/syslog 包提供了访问系统 log 服务的简易接口,它可以通过 UNIX socket、UDP 或者 TCP 将日志消息发送给系统的 syslog 守护进程。在和系统 syslog 服务建立连接时,只需要 Dial 一次。如果出现写入失败,syslog 客户端会自动尝试重连到 Server。当前 syslog 包已经处于冻结状态,不会再接收新特性。

创建 syslog 客户端

有两种方式来向 syslog 服务发送日志,一种是仍然通过 logger 包提供的 API 来发送日志,另一种是通过 syslog.Writer 对象来写入日志。

syslog.NewLogger 函数返回一个 *log.Logger 对象,通过该对象,我们仍然可以使用熟悉的日志 API 来发送日志,只不过该日志对象的底层 Writer 是和本机 syslog 服务建立的连接。需要注意,日志 API 发送日志消息时,添加的消息头会作为整个 syslog 消息体的一部分存在。

1
2
3
4
5
6
7
8
9
10
func NewLogger(p Priority, logFlag int) (*log.Logger, error) {
// 创建一个和本机 syslog 服务的连接
s, err := New(p, "")
if err != nil {
return nil, err
}
// 创建的仍然是 log.Logger 类型对象
// 只不过底层 Writer 是和 syslog 服务的连接
return log.New(s, "", logFlag), nil
}

另外一种是直接通过 syslog.Writer 对象来写入日志,syslog.Writer 的类型定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type Writer struct {
// 优先级,它是 Facility 和 Severity 结合
priority Priority
// 日志 tag
tag string
// 日志输出方的主机名
// 即使用该库的客户端的主机名
hostname string
// 网络协议
network string
// 网络地址
raddr string

// 互斥锁
mu sync.Mutex // guards conn
// serverConn 接口
// 和 syslog 连接的抽象
// 正常情况下,动态类型就是 netConn
conn serverConn
}

可以通过如下两个函数创建来创建 syslog.Writer 对象:

1
2
3
4
5
// 创建和本机 syslog 服务的连接
func New(priority Priority, tag string) (*Writer, error)

// 通过 network 和 raddr 参数,可以创建和任意网络地址的 syslog 服务的连接
func Dial(network, raddr string, priority Priority, tag string) (*Writer, error)
  • priority 参数用于指定 SYSLOG 日志的优先级,它是 FacilitySeverity 的结合值,其中 Facility 是日志消息的来源,Severity 是日志消息的重要性。
  • tag 参数用来区分日志消息,如果没有指定,默认会使用 [os.Args][0],即程序名

在得到 syslog.Writer 对象后,就可以通过如下函数直接向 syslog 输出日志消息:

1
2
3
4
5
6
7
8
9
10
11
12
// 以 Writer 创建时指定的 priority 发送消息
func (w *Writer) Write(b []byte) (int, error)

// 以特定 priority 发送消息,忽略 Writer 本身的 priority
func (w *Writer) Emerg(m string) error
func (w *Writer) Alert(m string) error
func (w *Writer) Crit(m string) error
func (w *Writer) Err(m string) error
func (w *Writer) Warning(m string) error
func (w *Writer) Notice(m string) error
func (w *Writer) Info(m string) error
func (w *Writer) Debug(m string) error

一些实现细节

unixSyslog 函数用于和本机 syslog 服务建立连接,如下是它的实现,可以看出其是如何和本机 syslog 服务建立的连接的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func unixSyslog() (conn serverConn, err error) {
logTypes := []string{"unixgram", "unix"}
logPaths := []string{"/dev/log", "/var/run/syslog", "/var/run/log"}
// 网络协议可以是 unixgram,unix
for _, network := range logTypes {
// unix 服务的路径信息
for _, path := range logPaths {
// 如果建立连接成功,直接返回连接
conn, err := net.Dial(network, path)
if err == nil {
return &netConn{conn: conn, local: true}, nil
}
}
}
return nil, errors.New("Unix syslog delivery error")
}

而如下函数则用于将待发送的日志消息格式化成 SYSLOG 格式的消息进行发送,SYSLOG 消息格式为:<PRI>TIMESTAMP HOSTNAME TAG[PID]: MSG

1
func (n *netConn) writeString(p Priority, hostname, tag, msg, nl string)

简单示例

如下是使用 log/syslog 包向本机 syslog 服务发送日志消息的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package main

import (
"log"
"log/syslog"
)

func main() {
l, err := syslog.NewLogger(syslog.LOG_USER|syslog.LOG_INFO, log.LstdFlags)
if err != nil {
return
}

l.Println("syslog message 1")

w := l.Writer().(*syslog.Writer)
w.Alert("syslog message 2")
}
1
2
3
4
# more /var/log/syslog
......
Dec 7 20:30:03 OPS-3430 ./main[2363580]: 2024/12/07 20:30:03 syslog message 1
Dec 7 20:30:03 OPS-3430 ./main[2363580]: syslog message 2

总结

本文介绍了 go 标准库 log 包和 log/syslog 包的基本使用方法和一些实现细节,log 包提供了简单易用的日志输出功能,而 log/syslog 包则提供了向 syslog 服务发送日志的能力。它们为 Gopher 提供了最基本的日志功能。