
柒啊
2023/04/26阅读:11主题:前端之巅同款
[3分钟]GO:关于代码中的错误处理
六、错误处理
我已经给出了几个关于错误处理的 演示文稿 ,并在我的博客上写了很多关于错误处理的文章。我在昨天的会议上也讲了很多关于错误处理的内容,所以在这里不再赘述。
-
https://dave.cheney.net/2014/12/24/inspecting-errors -
https://dave.cheney.net/2016/04/07/constant-errors
相反,我想介绍与错误处理相关的两个其他方面。
6.1 通过消除错误来消除错误处理
如果你昨天在我的演讲中,我谈到了改进错误处理的提案。但是你知道有什么比改进错误处理的语法更好吗?那就是根本不需要处理错误。
注意: 我不是说“删除你的错误处理”。我的建议是,修改你的代码,这样就不用处理错误了。
本节从 John Ousterhout
最近的著作 “软件设计哲学” 中汲取灵感。该书的其中一章是“定义不存在的错误”。我们将尝试将此建议应用于 Go
语言。
6.1.1 计算行数
让我们编写一个函数来计算文件中的行数。
func CountLines(r io.Reader) (int, error) {
var (
br = bufio.NewReader(r)
lines int
err error
)
for {
_, err = br.ReadString('\n')
lines++
if err != nil {
break
}
}
if err != io.EOF {
return 0, err
}
return lines, nil
}
由于我们遵循前面部分的建议【指 API设计 章节】,CountLines
需要一个 io.Reader
,而不是一个 *File
;它的任务是调用者为我们想要计算的内容提供 io.Reader
。
我们构造一个 bufio.Reader
,然后在一个循环中调用 ReadString
方法,递增计数器直到我们到达文件的末尾,然后我们返回读取的行数。
至少这是我们想要编写的代码,但是这个函数由于需要错误处理而变得更加复杂。 例如,有这样一个奇怪的结构:
_, err = br.ReadString('\n')
lines++
if err != nil {
break
}
我们在检查错误之前增加了行数,这样做看起来很奇怪。
我们必须以这种方式编写它的原因是,如果在遇到换行符之前就读到文件结束,则 ReadString
将返回错误。如果文件中没有换行符,同样会出现这种情况。
为了解决这个问题,我们重新排列逻辑增来加行数,然后查看是否需要退出循环。
注意: 这个逻辑仍然不完美,你能发现错误吗?
但是我们还没有完成检查错误。当 ReadString
到达文件末尾时,预期它会返回 io.EOF
。ReadString
需要某种方式在没有什么可读时来停止。因此,在我们将错误返回给 CountLine
的调用者之前,我们需要检查错误是否是 io.EOF
,如果不是将其错误返回,否则我们返回 nil
说一切正常。
我认为这是 Russ Cox(Go团队负责人)
观察到错误处理可能会模糊函数操作的一个很好的例子。我们来看一个改进的版本。
func CountLines(r io.Reader) (int, error) {
sc := bufio.NewScanner(r)
lines := 0
for sc.Scan() {
lines++
}
return lines, sc.Err()
}
这个改进的版本从 bufio.Reader
切换到 bufio.Scanner
。
在 bufio.Scanner
内部使用 bufio.Reader
,但它添加了一个很好的抽象层,它有助于通过隐藏 CountLines
的操作来消除错误处理。
注意:
bufio.Scanner
可以扫描任何模式,但默认情况下它会查找换行符。
如果扫描程序匹配了一行文本并且没有遇到错误,则 sc.Scan()
方法返回 true
。因此,只有当扫描仪的缓冲区中有一行文本时,才会调用 for
循环的主体。这意味着我们修改后的 CountLines
正确处理没有换行符的情况,并且还处理文件为空的情况。
其次,当 sc.Scan
在遇到错误时返回 false
,我们的 for
循环将在到达文件结尾或遇到错误时退出。bufio.Scanner
类型会记住遇到的第一个错误,一旦我们使用 sc.Err()
方法退出循环,我们就可以获取该错误。
最后, sc.Err()
负责处理 io.EOF
并在达到文件末尾时将其转换为 nil
,而不会遇到其他错误。
贴士: 当遇到难以忍受的错误处理时,请尝试将某些操作提取到辅助程序类型中。
6.1.2. WriteResponse
我的第二个例子受到了 Errors are values
博客文章 的启发。
在本章前面我们已经看过处理打开、写入和关闭文件的示例。错误处理是存在的,但是是在可接受范围内的,因为操作可以封装在诸如 ioutil.ReadFile
和 ioutil.WriteFile
之类的辅助程序中。但是,在处理底层网络协议时,有必要使用 I/O
原始的错误处理来直接构建响应,这样就可能会变得重复。看一下构建 HTTP
响应的 HTTP
服务器的这个片段。
type Header struct {
Key, Value string
}
type Status struct {
Code int
Reason string
}
func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error {
_, err := fmt.Fprintf(w, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason)
if err != nil {
return err
}
for _, h := range headers {
_, err := fmt.Fprintf(w, "%s: %s\r\n", h.Key, h.Value)
if err != nil {
return err
}
}
if _, err := fmt.Fprint(w, "\r\n"); err != nil {
return err
}
_, err = io.Copy(w, body)
return err
}
首先,我们使用 fmt.Fprintf
构造状态码并检查错误。 然后对于每个标题,我们写入键值对,每次都检查错误。 最后,我们使用额外的 \r\n
终止标题部分,检查错误之后将响应主体复制到客户端。 最后,虽然我们不需要检查 io.Copy
中的错误,但我们需要将 io.Copy
返回的两个返回值形式转换为 WriteResponse
的单个返回值。
这里很多重复性的工作。 我们可以通过引入一个包装器类型 errWriter
来使其更容易。
errWriter
实现 io.Writer
接口,因此可用于包装现有的 io.Writer
。 errWriter
将数据传递给其底层 writer
,直到检测到错误。 从此时起,它会丢弃任何写入并返回先前的错误。
type errWriter struct {
io.Writer
err error
}
func (e *errWriter) Write(buf []byte) (int, error) {
// 先判断之前有没有错误
if e.err != nil {
return 0, e.err
}
var n int
n, e.err = e.Writer.Write(buf)
return n, nil
}
func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error {
ew := &errWriter{Writer: w}
fmt.Fprintf(ew, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason)
for _, h := range headers {
fmt.Fprintf(ew, "%s: %s\r\n", h.Key, h.Value)
}
fmt.Fprint(ew, "\r\n")
io.Copy(ew, body)
return ew.err
}
将 errWriter
应用于 WriteResponse
可以显着提高代码的清晰度。 每个操作不再需要自己做错误检查。 通过检查 ew.err
字段,将错误报告移动到函数末尾,从而避免转换从 io.Copy
的两个返回值。
总结:1)检查逻辑,减少不必要的错误 2)使用更合适的库及函数 3)将相似的error抽离封装,将对error的处理封装进函数
6.2. 错误只处理一次
最后,我想提一下你应该只处理错误一次。 处理错误意味着检查错误值并做出单一决定。
// WriteAll writes the contents of buf to the supplied writer.
func WriteAll(w io.Writer, buf []byte) {
w.Write(buf)
}
如果你做出的决定少于一个,则忽略该错误。 正如我们在这里看到的那样, w.WriteAll
的错误被丢弃。【这里的决定是发现错误之后的决定吗?这里没有很理解】
但是,针对单个错误做出多个决策也是有问题的。 以下是我经常遇到的代码。
func WriteAll(w io.Writer, buf []byte) error {
_, err := w.Write(buf)
if err != nil {
log.Println("unable to write:", err) // annotated error goes to log file 注释的 error 放到 log 文件
return err // unannotated error returned to caller 未注释的 error 返回给调用者
}
return nil
}
在此示例中,如果在 w.Write
期间发生错误,则会写入日志文件,注明错误发生的文件与行数,并且错误也会返回给调用者,调用者可能会记录该错误并将其返回到上一级,一直回到程序的顶部。
调用者可能正在做同样的事情
func WriteConfig(w io.Writer, conf *Config) error {
buf, err := json.Marshal(conf)
if err != nil {
log.Printf("could not marshal config: %v", err)
return err
}
if err := WriteAll(w, buf); err != nil {
log.Println("could not write config: %v", err)
return err
}
return nil
}
因此你在日志文件中得到一堆重复的内容,
unable to write: io.EOF
could not write config: io.EOF
但在程序的顶部,虽然得到了原始错误,但没有相关内容【没有提示信息】。
err := WriteConfig(f, &conf)
fmt.Println(err) // io.EOF
我想深入研究这一点,因为我认为日志记录和返回的问题不只是个人喜好问题。
func WriteConfig(w io.Writer, conf *Config) error {
buf, err := json.Marshal(conf)
if err != nil {
log.Printf("could not marshal config: %v", err)
// oops, forgot to return
}
if err := WriteAll(w, buf); err != nil {
log.Println("could not write config: %v", err)
return err
}
return nil
}
很多问题是程序员忘记从错误中返回。正如我们之前谈到的那样,Go
语言风格是使用 **guard clauses**
,随着函数的进行检查前提条件并提前返回。
在这个例子中,作者检查了错误,记录了它,但忘了返回。这就引起了一个微妙的错误。
Go
语言中的错误处理规定,如果出现错误,你不能对其他返回值的内容做出任何假设。 由于 JSON
解析失败,buf
的内容未知,可能它什么都没有,但更糟的是它可能包含解析的 JSON
片段部分。
由于程序员在检查并记录错误后忘记返回,因此损坏的缓冲区将传递给 WriteAll
,这可能会成功,因此配置文件将被错误地写入。但是,该函数会正常返回,并且发生问题的唯一日志行是有关 JSON
解析错误,而与写入配置失败有关。
6.2.1. 为错误添加相关内容
发生错误的原因是作者试图在错误消息中添加 context【上下文,即提示信息】
。 他们试图给自己留下一些线索,指出错误的根源。
让我们看看使用 fmt.Errorf
的另一种方式。
func WriteConfig(w io.Writer, conf *Config) error {
buf, err := json.Marshal(conf)
if err != nil {
return fmt.Errorf("could not marshal config: %v", err)
}
if err := WriteAll(w, buf); err != nil {
return fmt.Errorf("could not write config: %v", err)
}
return nil
}
func WriteAll(w io.Writer, buf []byte) error {
_, err := w.Write(buf)
if err != nil {
return fmt.Errorf("write failed: %v", err)
}
return nil
}
通过将注释与返回的错误组合起来,就更难以忘记错误的返回来避免意外继续。
如果写入文件时发生 I/O
错误,则 error
的 Error()
方法会报告以下类似的内容;
could not write config: write failed: input/output error
6.2.2. 使用 github.com/pkg/errors 包装 errors
fmt.Errorf
模式适用于注释错误 message
,但这样做的代价是模糊了原始错误的类型。 我认为将错误视为不透明值对于松散耦合的软件非常重要,因此如果你使用错误值,需要做的唯一事情是原始错误的类型应该无关紧要的
-
检查它是否为 nil。 -
输出或记录它。
但是在某些不常见的情况下,比如您需要恢复原始错误。 在这种情况下,使用类似我的 errors
包来注释这样的错误, 如下
func ReadFile(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, errors.Wrap(err, "open failed")
}
defer f.Close()
buf, err := ioutil.ReadAll(f)
if err != nil {
return nil, errors.Wrap(err, "read failed")
}
return buf, nil
}
func ReadConfig() ([]byte, error) {
home := os.Getenv("HOME")
config, err := ReadFile(filepath.Join(home, ".settings.xml"))
return config, errors.WithMessage(err, "could not read config")
}
func main() {
_, err := ReadConfig()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
}
现在报告的错误就是 K&D 样式错误,
could not read config: open failed: open /Users/dfc/.settings.xml: no such file or directory
并且错误值保留对原始原因的引用。
func main() {
_, err := ReadConfig()
if err != nil {
fmt.Printf("original error: %T %v\n", errors.Cause(err), errors.Cause(err))
fmt.Printf("stack trace:\n%+v\n", err)
os.Exit(1)
}
}
因此,你可以恢复原始错误并打印堆栈跟踪;
original error: *os.PathError open /Users/dfc/.settings.xml: no such file or directory
stack trace:
open /Users/dfc/.settings.xml: no such file or directory
open failed
main.ReadFile
/Users/dfc/devel/practical-go/src/errors/readfile2.go:16
main.ReadConfig
/Users/dfc/devel/practical-go/src/errors/readfile2.go:29
main.main
/Users/dfc/devel/practical-go/src/errors/readfile2.go:35
runtime.main
/Users/dfc/go/src/runtime/proc.go:201
runtime.goexit
/Users/dfc/go/src/runtime/asm_amd64.s:1333
could not read config
使用 errors
包,你可以以人和机器都可检查的方式向错误值添加上下文。 如果昨天你来听我的演讲,你会知道这个库在被移植到即将发布的 Go
语言版本的标准库中。
总结:1)对错误只进行一次处理 2)如果错误的类型不重要,使用
fmt.Errorf
添加上下文【提示信息】来避免忘记返回错误 3)如果原始错误很重要,使用errors
处理错误
内容学习于该博客:英文博客[1]
同时借鉴于该翻译:中文翻译[2]
参考资料
英文博客: https://dave.cheney.net/practical-go/presentations/qcon-china.html
[2]中文翻译: https://github.com/llitfkitfk/go-best-practice/blob/master/README.md
作者介绍
