Hello everyone, I am fried fish.
In the previous post I was in How to Program for Errors? ” shares the Go founder’s interpretation of one of the Go proverbs, Errors are values.
This article is still an error session. Don't just check errors, handle them gracefully in the Go proverb. The original article has the same name and is written by @Dave Cheney. The following "I" refer to the original author.
This proverb is closely related to Errors are value.
errors are just values
I've spent a lot of time thinking about the best way to handle errors in Go programs. I really wish there was a single way to deal with errors so that we could teach all Go programmers by reciting the same way we teach math or the alphabet.
Ultimately my conclusion is: there is no single way to handle errors. Instead, I think error handling in Go can be grouped into three core strategies.
Sentinel error
The first form of error handling is often referred to as Sentinel errors.
The following code:
if err == ErrSomething { … }
The name comes from the practice in computer programming of using specific values to indicate that no further processing is possible. So in Go, we often use specific values to represent errors.
Examples include: values like io.EOF, or lower level error constants like syscall.ENOENT in the syscall package.
There are even some sentinel errors that signal that the error didn't happen, such as:
- go/build.NoGoError.
- path/filepath.SkipDir in path/filepath.Walk.
Using sentinel values is the least flexible error-handling strategy because the caller must use the equality operator to compare the result to a pre-declared value. This becomes problematic when you want to provide more context, because returning a different error would break the equality check.
Even something as meaningful as adding some context to the error using fmt.Errorf would break the caller's equality test. Instead, the caller will be forced to look at the output of the error's Error method to see if it matches a particular string.
Don't check the output of error.Error
As a bystander, I think you should never check the output of the error.Error method. The Error method on the Error interface exists for humans (meaning human readability when reading), not for code.
The content of that string belongs to the log file, or is displayed on the screen. You should not try to change the behavior of your program by checking it.
I know sometimes this is not possible, as someone pointed out on twitter, this advice does not apply to writing tests.
Still, in my opinion comparing the wrong string form is a bad code smell and you should try to avoid it.
Sentinel errors become part of your public API
If your public function or method returns an error with a specific value, then that value must be public and of course documented in the API documentation.
If your API defines an interface that returns a specific error, then all implementations of that interface should be limited to returning only that error, even if they could provide a more descriptive error.
We can see this in io.Reader. Functions like io.Copy require the reader to return exactly io.EOF in order to signal the caller that there is no data, but this is not a bug.
Sentinel error created a dependency between two packages
By far the worst problem with sentinel error values is that they create source dependencies between two packages.
For example: in order to check if an error is equal to io.EOF, your code must import the io package.
This specific example doesn't sound bad since it's so common, but imagine when many packages in your project export error values that other packages in your project have to import to check for specific error conditions, There will be obvious coupling.
I've used this pattern on a large project, and I can tell you that the "ghost" of bad design -- in the form of import loops -- never leaves our heads.
Note: This problem is obvious when Go modules is not careful, because grpc, grpc-gateway, etcd have compatibility problems with various package versions all year round. Once there is a dependency, it will be passively upgraded, and then the application will not run because the version is missing.
Conclusion: Avoid Sentinel Errors
Recommendation: Avoid using sentinel error values in the code you write .
While in the standard library they are used in a few cases, it's not a pattern you should emulate.
If someone asks you to export an error value from your package, you should politely decline and suggest other approaches, such as the ones I'll discuss next.
error type
The second form of error handling is the way of error types.
The following code:
if err, ok := err.(SomeType); ok { … }
Error type refers to a type that you create that implements the error interface. In this example, the three fields of type MyError represent: file, line of code, and message.
type MyError struct {
Msg string
File string
Line int
}
func (e *MyError) Error() string {
return fmt.Sprintf("%s:%d: %s”, e.File, e.Line, e.Msg)
}
return &MyError{"Something happened", “server.go", 42}
Because the MyError error is a type, the caller can use type assertions to extract additional context from the error.
err := something()
switch err := err.(type) {
case nil:
// call succeeded, nothing to do
case *MyError:
fmt.Println(“error occurred on line:”, err.Line)
default:
// unknown error
}
A big improvement of error types compared to error values is their ability to wrap the underlying error to provide more context (contextual information).
A better example is the os.PathError type, which records both the file operation to be performed and the file path in the type.
// PathError records an error and the operation
// and file path that caused it.
type PathError struct {
Op string
Path string
Err error // the cause
}
func (e *PathError) Error() string
wrong type of question
So the caller can use type assertion or type conversion, and the error type must be exposed.
If your code implements an interface whose contract requires a specific error type, then all implementers of that interface will need to depend on the package that defines the error type.
This in-depth knowledge of the package type creates strong coupling with the caller, making the API brittle.
Conclusion: Avoid using the wrong type
While error types are better than sentinel error values because they capture more context about what went wrong, error types also have many problems with error values.
So my advice is to avoid using the wrong type, or at least avoid making it part of the public API.
opaque error
Now let's look at the third type of error handling. In my opinion, this section is about the most flexible error handling strategy because it requires the least coupling between your code and the caller.
I call this style Opaque errors because while you know an error has occurred, you don't have the ability to see inside the error. As the caller, all you know about the result of the operation is: it succeeded, or it didn't.
That's what opaque error handling is all about -- just returning an error without making any assumptions about its contents. If you take this stance, then error handling can be very useful as a debugging aid.
The following code:
import “github.com/quux/bar”
func fn() error {
x, err := bar.Foo()
if err != nil {
return err
}
// use x
}
For example: Foo's contract doesn't guarantee what it will return in the wrong context. The author of Foo is now free to annotate errors passing through it with additional context without breaking its contract with the caller.
Assertion error for behavior instead of type
In a few cases, using a dichotomy (error or not) for error handling is not enough.
For example: interaction with outside of your process, such as network activity, requires the caller to look at the nature of the error to determine whether it is reasonable to retry the operation.
In this case, instead of asserting that the error is a specific type or value, we can assert that the error implements a specific behavior. Consider this example.
type temporary interface {
Temporary() bool
}
// IsTemporary returns true if err is temporary.
func IsTemporary(err error) bool {
te, ok := err.(temporary)
return ok && te.Temporary()
}
We can pass any error to the IsTemporary method to determine if the error can be retried.
If the error does not implement the Temporary interface; that is, it does not have a Temporary method, then the error is not temporary.
If the error does implement Temporary, the caller may be able to retry the operation if Temporary returns true.
The key here is that this logic can be implemented without importing the wrongly defined package, and without knowing the underlying type of err, we're just interested in its behavior.
Don't just check for errors, handle them gracefully
This brings me to my second Go proverb; Don't just check errors, handle them gracefully.
Can you ask some questions about the code below?
func AuthenticateRequest(r *Request) error {
err := authenticate(r.User)
if err != nil {
return err
}
return nil
}
An obvious suggestion is that the five lines of the function could be replaced with:
return authenticate(r.User)
But it's the simple stuff that everyone should catch in a code review. More fundamentally, the problem with this code is that I can't tell where the original error came from.
If authenticate returns an error, AuthenticateRequest will return the error to its caller, who may do the same, and so on. At the top of the program, the body of the program will print the error to the screen or log file, and what is printed is: No such file or directory.
There is no information about the file and line that produced the error. There is no stack trace of the call stack that caused the error.
The authors of this code will be forced to do a lengthy profiling of their code to discover which code path throws the file not found error.
Donovan and Kernighan's The Go Programming Language recommends that you use fmt.Errorf to add context to error paths.
func AuthenticateRequest(r *Request) error {
err := authenticate(r.User)
if err != nil {
return fmt.Errorf("authenticate failed: %v", err)
}
return nil
}
But as we saw earlier, this pattern is not compatible with the use of sentinel error values or type assertions, because the error value is converted to a string, merged with another string, and then converted with fmt.Errorf to An error that breaks equality and any context of the original error.
Annotation error
I would like to come up with a way to add context to errors, namely Annotating errors, aka adding annotations to errors.
I will introduce a simple package. The code is at github.com/pkg/errors (it has been officially introduced by Go since Go1.13 and has been recognized).
The errors package has two main functions.
// Wrap annotates cause with a message.
func Wrap(cause error, message string) error
The first function is Wrap, which takes an error and a message, and generates a new error.
// Cause unwraps an annotated error.
func Cause(err error) error
The second function is Cause, which takes an error that may have been wrapped, and unwraps it to restore the original error.
Using these two functions, we can now annotate any errors and recover the underlying errors if we need to check.
Consider this example of a function that reads the contents of a file into memory.
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
}
We'll use this function to write a function that reads the configuration file and then calls it from main.
func ReadConfig() ([]byte, error) {
home := os.Getenv("HOME")
config, err := ReadFile(filepath.Join(home, ".settings.xml"))
return config, errors.Wrap(err, "could not read config")
}
func main() {
_, err := ReadConfig()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
}
If the ReadConfig code path fails because we used errors.Wrap, we get a nice K&D style error comment.
could not read config: open failed: open /Users/dfc/.settings.xml: no such file or directory
Because errors.Wrap produces an error stack, we can examine the stack for additional debugging information. This is the same example again, but this time we replace fmt.Println with errors.Print.
func main() {
_, err := ReadConfig()
if err != nil {
errors.Print(err)
os.Exit(1)
}
}
We will get something like this:
readfile.go:27: could not read config
readfile.go:14: open failed
open /Users/dfc/.settings.xml: no such file or directory
The first line is from ReadConfig, the second is from the os.Open part of ReadFile, and the rest is from the os package itself, which carries no location information.
Now that we have introduced the concept of wrapping errors to produce a stack, we need to discuss the opposite case, which is unpacking errors. This is the realm of the errors.Cause function.
// IsTemporary returns true if err is temporary.
func IsTemporary(err error) bool {
te, ok := errors.Cause(err).(temporary)
return ok && te.Temporary()
}
In operation, whenever you need to check that an error matches a specific value or type, you should first restore the original error using the errors.Cause function.
Handle errors only once
The last thing I want to say is that you should only handle errors once. Handling an error means checking the error value and making a decision.
func Write(w io.Writer, buf []byte) {
w.Write(buf)
}
If you make less than one decision, you ignore the error. As we can see here, errors from w.Write are discarded.
But making more than one decision for one mistake is also problematic.
func Write(w io.Writer, buf []byte) error {
_, err := w.Write(buf)
if err != nil {
// annotated error goes to log file
log.Println("unable to write:", err)
// unannotated error returned to caller
return err
}
return nil
}
In this example, if an error occurs during writing, a line is written to the log file indicating the file and line where the error occurred, and the error is returned to the caller, who may log it, and Go back to it, all the way to the top of the program.
So you get a bunch of duplicate lines in the log file, but at the top of the program you get the original error without any background. Does anyone use Java?
func Write(w io.Write, buf []byte) error {
_, err := w.Write(buf)
return errors.Wrap(err, "write failed")
}
Using the errors package gives you the ability to add context to error values, programming the values in a way that both humans and machines can check.
Summarize
Errors are part of your package's public API, treat them with the same care as any other part of your public API.
For maximum flexibility, I recommend that you try to treat all errors as opaque. In cases where you can't, assert errors for behavior rather than type or value.
Minimize the number of sentinel error values in your program and use error.Wrap to convert errors into opaque errors when they occur.
If you need to check, use errors.Cause to recover the underlying error.
Hope every Gopher learns how to handle Go errors more gracefully!
The article is continuously updated, you can read it on WeChat by searching [Brain Fried Fish]. This article has been included in GitHub github.com/eddycjy/blog . To learn Go language, you can see the Go learning map and route . Welcome to Star to urge you to update.
Go Book Series
- Introduction to the Go language series: a preliminary exploration of the actual combat of the Go project
- Go Programming Journey: Deep Dive into Go Projects
- Go Language Design Philosophy: Understanding Go Why and Design Thinking
- Go Language Advanced Tour: Go deeper into the Go source code
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。