返回介绍

8.2 记录日志

发布于 2024-10-11 12:39:10 字数 13225 浏览 0 评论 0 收藏 0

即便没有表现出来,你的程序依旧可能有 bug。这在软件开发里是很自然的事情。日志是一种找到这些 bug,更好地了解程序工作状态的方法。日志是开发人员的眼睛和耳朵,可以用来跟踪、调试和分析代码。基于此,标准库提供了 log 包,可以对日志做一些最基本的配置。根据特殊需要,开发人员还可以自己定制日志记录器。

在 UNIX 里,日志有很长的历史。这些积累下来的经验都体现在 log 包的设计里。传统的 CLI(命令行界面)程序直接将输出写到名为 stdout 的设备上。所有的操作系统上都有这种设备,这种设备的默认目的地是标准文本输出。默认设置下,终端会显示这些写到 stdout 设备上的文本。这种单个目的地的输出用起来很方便,不过你总会碰到需要同时输出程序信息和输出执行细节的情况。这些执行细节被称作日志。当想要记录日志时,你希望能写到不同的目的地,这样就不会将程序的输出和日志混在一起了。

为了解决这个问题,UNIX 架构上增加了一个叫作 stderr 的设备。这个设备被创建为日志的默认目的地。这样开发人员就能将程序的输出和日志分离开来。如果想在程序运行时同时看到程序输出和日志,可以将终端配置为同时显示写到 stdoutstderr 的信息。不过,如果用户的程序只记录日志,没有程序输出,更常用的方式是将一般的日志信息写到 stdout ,将错误或者警告信息写到 stderr

8.2.1 log 包

让我们从 log 包提供的最基本的功能开始,之后再学习如何创建定制的日志记录器。记录日志的目的是跟踪程序什么时候在什么位置做了什么。这就需要通过某些配置在每个日志项上要写的一些信息,如代码清单 8-2 所示。

代码清单 8-2 跟踪日志的样例

TRACE: 2009/11/10 23:00:00.000000 /tmpfs/gosandbox-/prog.go:14: message

在代码清单 8-2 中,可以看到一个由 log 包产生的日志项。这个日志项包含前缀、日期时间戳、该日志具体是由哪个源文件记录的、源文件记录日志所在行,最后是日志消息。让我们看一下如何配置 log 包来输出这样的日志项,如代码清单 8-3 所示。

代码清单 8-3 listing03.go

01 // 这个示例程序展示如何使用最基本的 log 包
02 package main
03
04 import (
05   "log"
06 )
07
08 func init() {
09   log.SetPrefix("TRACE: ")
10   log.SetFlags(log.Ldate | log.Lmicroseconds | log.Llongfile)
11 }
12
13 func main() {
14   // Println 写到标准日志记录器
15   log.Println("message")
16
17   // Fatalln 在调用 Println() 之后会接着调用 os.Exit(1)
18   log.Fatalln("fatal message")
19
20   // Panicln 在调用 Println() 之后会接着调用 panic()
21   log.Panicln("panic message")
22 }

如果执行代码清单 8-3 中的程序,输出的结果会和代码清单 8-2 所示的输出类似。让我们分析一下代码清单 8-4 中的代码,看看它是如何工作的。

代码清单 8-4 listing03.go:第 08 行到第 11 行

08 func init() {
09   log.SetPrefix("TRACE: ")
10   log.SetFlags(log.Ldate | log.Lmicroseconds | log.Llongfile)
11 }

在第 08 行到第 11 行,定义的函数名为 init() 。这个函数会在运行 main() 之前作为程序初始化的一部分执行。通常程序会在这个 init() 函数里配置日志参数,这样程序一开始就能使用 log 包进行正确的输出。在这段程序的第 9 行,设置了一个字符串,作为每个日志项的前缀。这个字符串应该是能让用户从一般的程序输出中分辨出日志的字符串。传统上这个字符串的字符会全部大写。

有几个和 log 包相关联的标志,这些标志用来控制可以写到每个日志项的其他信息。代码清单 8-5 展示了目前包含的所有标志。

代码清单 8-5 golang.org/src/log/log.go

const (
 // 将下面的位使用或运算符连接在一起,可以控制要输出的信息。没有
 // 办法控制这些信息出现的顺序(下面会给出顺序)或者打印的格式
 // (格式在注释里描述)。这些项后面会有一个冒号:
 //  2009/01/23 01:23:23.123123 /a/b/c/d.go:23: message

 // 日期: 2009/01/23
 Ldate = 1 << iota

 // 时间: 01:23:23
 Ltime

 // 毫秒级时间: 01:23:23.123123。该设置会覆盖 Ltime 标志
 Lmicroseconds

 // 完整路径的文件名和行号: /a/b/c/d.go:23
 Llongfile

 // 最终的文件名元素和行号: d.go:23
 // 覆盖 Llongfile
 Lshortfile

 // 标准日志记录器的初始值
 LstdFlags = Ldate | Ltime
)

代码清单 8-5 是从 log 包里直接摘抄的源代码。这些标志被声明为常量,这个代码块中的第一个常量叫作 Ldate ,使用了特殊的语法来声明,如代码清单 8-6 所示。

代码清单 8-6 声明 Ldate 常量

// 日期: 2009/01/23
Ldate = 1 << iota

关键字 iota 在常量声明区里有特殊的作用。这个关键字让编译器为每个常量复制相同的表达式,直到声明区结束,或者遇到一个新的赋值语句。关键字 iota 的另一个功能是, iota 的初始值为 0,之后 iota 的值在每次处理为常量后,都会自增 1。让我们更仔细地看一下这个关键字,如代码清单 8-7 所示。

代码清单 8-7 使用关键字 iota

const (
 Ldate = 1 << iota // 1 << 0 = 000000001 = 1
 Ltime        // 1 << 1 = 000000010 = 2
 Lmicroseconds   // 1 << 2 = 000000100 = 4
 Llongfile     // 1 << 3 = 000001000 = 8
 Lshortfile     // 1 << 4 = 000010000 = 16
 ...
)

代码清单 8-7 展示了常量声明背后的处理方法。操作符 << 对左边的操作数执行按位左移操作。在每个常量声明时,都将 1 按位左移 iota 个位置。最终的效果使为每个常量赋予一个独立位置的位,这正好是标志希望的工作方式。

常量 LstdFlags 展示了如何使用这些标志,如代码清单 8-8 所示。

代码清单 8-8 声明 LstdFlags 常量

const (
 ...
 LstdFlags = Ldate(1) | Ltime(2) = 00000011 = 3
)

在代码清单 8-8 中看到,因为使用了复制操作符, LstdFlags 打破了 iota 常数链。由于有 | 运算符用于执行或操作,常量 LstdFlags 被赋值为 3。对位进行或操作等同于将每个位置的位组合在一起,作为最终的值。如果对位 1 和 2 进行或操作,最终的结果就是 3。

让我们看一下我们要如何设置日志标志,如代码清单 8-9 所示。

代码清单 8-9 listing03.go:第 08 行到第 11 行

08 func init() {
09   ...
10   log.SetFlags(log.Ldate | log.Lmicroseconds | log.Llongfile)
11 }

这里我们将 LdateLmicrosecondsLlongfile 标志组合在一起,将该操作的值传入 SetFlags 函数。这些标志值组合在一起后,最终的值是 13 ,代表第 1、3 和 4 位为 1(00001101)。由于每个常量表示单独一个位,这些标志经过或操作组合后的值,可以表示每个需要的日志参数。之后 log 包会按位检查这个传入的整数值,按照需求设置日志项记录的信息。

初始完 log 包后,可以看一下 main() 函数,看它是是如何写消息的,如代码清单 8-10 所示。

代码清单 8-10 listing03.go:第 13 行到第 22 行

13 func main() {
14   // Println 写到标准日志记录器
15   log.Println("message")
16
17   // Fatalln 在调用 Println() 之后会接着调用 os.Exit(1)
18   log.Fatalln("fatal message")
19
20   // Panicln 在调用 Println() 之后会接着调用 panic()
21   log.Panicln("panic message")
22 }

代码清单 8-10 展示了如何使用 3 个函数 PrintlnFatallnPanicln 来写日志消息。这些函数也有可以格式化消息的版本,只需要用 f 替换结尾的 lnFatal 系列函数用来写日志消息,然后使用 os.Exit(1) 终止程序。 Panic 系列函数用来写日志消息,然后触发一个 panic 。除非程序执行 recover 函数,否则会导致程序打印调用栈后终止。 Print 系列函数是写日志消息的标准方法。

log 包有一个很方便的地方就是,这些日志记录器是多 goroutine 安全的。这意味着在多个 goroutine 可以同时调用来自同一个日志记录器的这些函数,而不会有彼此间的写冲突。标准日志记录器具有这一性质,用户定制的日志记录器也应该满足这一性质。

现在知道了如何使用和配置 log 包,让我们看一下如何创建一个定制的日志记录器,以便可以让不同等级的日志写到不同的目的地。

8.2.2 定制的日志记录器

要想创建一个定制的日志记录器,需要创建一个 Logger 类型值。可以给每个日志记录器配置一个单独的目的地,并独立设置其前缀和标志。让我们来看一个示例程序,这个示例程序展示了如何创建不同的 Logger 类型的指针变量来支持不同的日志等级,如代码清单 8-11 所示。

代码清单 8-11 listing11.go

01 // 这个示例程序展示如何创建定制的日志记录器
02 package main
03
04 import (
05   "io"
06   "io/ioutil"
07   "log"
08   "os"
09 )
10
11 var (
12   Trace  *log.Logger // 记录所有日志
13   Info  *log.Logger // 重要的信息
14   Warning *log.Logger // 需要注意的信息
15   Error  *log.Logger // 非常严重的问题
16 )
17
18 func init() {
19   file, err := os.OpenFile("errors.txt",
20     os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
21   if err != nil {
22     log.Fatalln("Failed to open error log file:", err)
23   }
24
25   Trace = log.New(ioutil.Discard,
26     "TRACE: ",
27     log.Ldate|log.Ltime|log.Lshortfile)
28
29   Info = log.New(os.Stdout,
30     "INFO: ",
31     log.Ldate|log.Ltime|log.Lshortfile)
32
33   Warning = log.New(os.Stdout,
34     "WARNING: ",
35     log.Ldate|log.Ltime|log.Lshortfile)
36
37   Error = log.New(io.MultiWriter(file, os.Stderr),
38     "ERROR: ",
39     log.Ldate|log.Ltime|log.Lshortfile)
40 }
41
42 func main() {
43   Trace.Println("I have something standard to say")
44   Info.Println("Special Information")
45   Warning.Println("There is something you need to know about")
46   Error.Println("Something has failed")
47 }

代码清单 8-11 展示了一段完整的程序,这段程序创建了 4 种不同的 Logger 类型的指针变量,分别命名为 TraceInfoWarningError 。每个变量使用不同的配置,用来表示不同的重要程度。让我们来分析一下这段代码是如何工作的。

在第 11 行到第 16 行,我们为 4 个日志等级声明了 4 个 Logger 类型的指针变量,如代码清单 8-12 所示。

代码清单 8-12 listing11.go:第 11 行到第 16 行

11 var (
12   Trace  *log.Logger // 记录所有日志
13   Info  *log.Logger // 重要的信息
14   Warning *log.Logger // 需要注意的信息
15   Error  *log.Logger // 非常严重的问题
16 )

在代码清单 8-12 中可以看到对 Logger 类型的指针变量的声明。我们使用的变量名很简短,但是含义明确。接下来,让我们看一下 init() 函数的代码是如何创建每个 Logger 类型的值并将其地址赋给每个变量的,如代码清单 8-13 所示。

代码清单 8-13 listing11.go:第 25 行到第 39 行

25   Trace = log.New(ioutil.Discard,
26     "TRACE: ",
27     log.Ldate|log.Ltime|log.Lshortfile)
28
29   Info = log.New(os.Stdout,
30     "INFO: ",
31     log.Ldate|log.Ltime|log.Lshortfile)
32
33   Warning = log.New(os.Stdout,
34     "WARNING: ",
35     log.Ldate|log.Ltime|log.Lshortfile)
36
37   Error = log.New(io.MultiWriter(file, os.Stderr),
38     "ERROR: ",
39     log.Ldate|log.Ltime|log.Lshortfile)

为了创建每个日志记录器,我们使用了 log 包的 New 函数,它创建并正确初始化一个 Logger 类型的值。函数 New 会返回新创建的值的地址。在 New 函数创建对应值的时候,我们需要给它传入一些参数,如代码清单 8-14 所示。

代码清单 8-14 golang.org/src/log/log.go

// New 创建一个新的 Logger。out 参数设置日志数据将被写入的目的地
// 参数 prefix 会在生成的每行日志的最开始出现
// 参数 flag 定义日志记录包含哪些属性
func New(out io.Writer, prefix string, flag int) *Logger {
  return &Logger{out: out, prefix: prefix, flag: flag}
}

代码清单 8-14 展示了来自 log 包的源代码里的 New 函数的声明。第一个参数 out 指定了日志要写到的目的地。这个参数传入的值必须实现了 io.Writer 接口。第二个参数 prefix 是之前看到的前缀,而日志的标志则是最后一个参数。

在这个程序里, Trace 日志记录器使用了 ioutil 包里的 Discard 变量作为写到的目的地,如代码清单 8-15 所示。

代码清单 8-15 listing11.go:第 25 行到第 27 行

25   Trace = log.New(ioutil.Discard,
26     "TRACE: ",
27     log.Ldate|log.Ltime|log.Lshortfile)

变量 Discard 有一些有意思的属性,如代码清单 8-16 所示。

代码清单 8-16 golang.org/src/io/ioutil/ioutil.go

// devNull 是一个用 int 作为基础类型的类型
type devNull int

// Discard 是一个 io.Writer,所有的 Write 调用都不会有动作,但是会成功返回
var Discard io.Writer = devNull(0)

// io.Writer 接口的实现
func (devNull) Write(p []byte) (int, error) {
  return len(p), nil
}

代码清单 8-16 展示了 Discard 变量的声明以及相关的实现。 Discard 变量的类型被声明为 io.Writer 接口类型,并被给定了一个 devNull 类型的值 0。基于 devNull 类型实现的 Write 方法,会忽略所有写入这一变量的数据。当某个等级的日志不重要时,使用 Discard 变量可以禁用这个等级的日志。

日志记录器 InfoWarning 都使用 stdout 作为日志输出,如代码清单 8-17 所示。

代码清单 8-17 listing11.go:第 29 行到第 35 行

29   Info = log.New(os.Stdout,
30     "INFO: ",
31     log.Ldate|log.Ltime|log.Lshortfile)
32
33   Warning = log.New(os.Stdout,
34     "WARNING: ",
35     log.Ldate|log.Ltime|log.Lshortfile)

变量 Stdout 的声明也有一些有意思的地方,如代码清单 8-18 所示。

代码清单 8-18 golang.org/src/os/file.go

// Stdin、Stdout 和 Stderr 是已经打开的文件,分别指向标准输入、标准输出和
// 标准错误的文件描述符
var (
  Stdin = NewFile(uintptr(syscall.Stdin), "/dev/stdin")
  Stdout = NewFile(uintptr(syscall.Stdout), "/dev/stdout")
  Stderr = NewFile(uintptr(syscall.Stderr), "/dev/stderr")
)

os/file_unix.go

// NewFile 用给出的文件描述符和名字返回一个新 File
func NewFile(fd uintptr, name string) *File {

在代码清单 8-18 中可以看到 3 个变量的声明,分别表示所有操作系统里都有的 3 个标准输入/输出,即 StdinStdoutStderr 。这 3 个变量都被声明为 File 类型的指针,这个类型实现了 io.Writer 接口。有了这个知识,我们来看一下最后的日志记录器 Error ,如代码清单 8-19 所示。

代码清单 8-19 listing11.go:第 37 行到第 39 行

37   Error = log.New(io.MultiWriter(file, os.Stderr),
38     "ERROR: ",
39     log.Ldate|log.Ltime|log.Lshortfile)

在代码清单 8-19 中可以看到 New 函数的第一个参数来自一个特殊的函数。这个特殊的函数就是 io 包里的 MultiWriter 函数,如代码清单 8-20 所示。

代码清单 8-20 包 io 里的 MultiWriter 函数的声明

io.MultiWriter(file, os.Stderr)

代码清单 8-20 单独展示了 MultiWriter 函数的调用。这个函数调用会返回一个 io.Writer 接口类型值,这个值包含之前打开的文件 file ,以及 stderrMultiWriter 函数是一个变参函数,可以接受任意个实现了 io.Writer 接口的值。这个函数会返回一个 io.Writer 值,这个值会把所有传入的 io.Writer 的值绑在一起。当对这个返回值进行写入时,会向所有绑在一起的 io.Writer 值做写入。这让类似 log.New 这样的函数可以同时向多个 Writer 做输出。现在,当我们使用 Error 记录器记录日志时,输出会同时写到文件和 stderr

现在知道了该如何创建定制的记录器了,让我们看一下如何使用这些记录器来写日志消息,如代码清单 8-21 所示。

代码清单 8-21 listing11.go:第 42 行到第 47 行

42 func main() {
43   Trace.Println("I have something standard to say")
44   Info.Println("Special Information")
45   Warning.Println("There is something you need to know about")
46   Error.Println("Something has failed")
47 }

代码清单 8-21 展示了代码清单 8-11 中的 main() 函数。在第 43 行到第 46 行,我们用自己创建的每个记录器写一条消息。每个记录器变量都包含一组方法,这组方法与 log 包里实现的那组函数完全一致,如代码清单 8-22 所示。

代码清单 8-22 展示了为 Logger 类型实现的所有方法。

代码清单 8-22 不同的日志方法的声明

func (l *Logger) Fatal(v ...interface{})
func (l *Logger) Fatalf(format string, v ...interface{})
func (l *Logger) Fatalln(v ...interface{})
func (l *Logger) Flags() int
func (l *Logger) Output(calldepth int, s string) error
func (l *Logger) Panic(v ...interface{})
func (l *Logger) Panicf(format string, v ...interface{})
func (l *Logger) Panicln(v ...interface{})
func (l *Logger) Prefix() string
func (l *Logger) Print(v ...interface{})
func (l *Logger) Printf(format string, v ...interface{})
func (l *Logger) Println(v ...interface{})
func (l *Logger) SetFlags(flag int)
func (l *Logger) SetPrefix(prefix string)

8.2.3 结论

log 包的实现,是基于对记录日志这个需求长时间的实践和积累而形成的。将输出写到 stdout ,将日志记录到 stderr ,是很多基于命令行界面(CLI)的程序的惯常使用的方法。不过如果你的程序只输出日志,那么使用 stdoutstderr 和文件来记录日志是很好的做法。

标准库的 log 包包含了记录日志需要的所有功能,推荐使用这个包。我们可以完全信任这个包的实现,不仅仅是因为它是标准库的一部分,而且社区也广泛使用它。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文