Go deep: wrong packaging and unpacking

Posted by soul on Tue, 14 Dec 2021 07:30:21 +0100

Think about it, maybe a quarter of our Go code is related to error handling, and we have accepted that errors are everywhere. However, it seems that Go's error handling is not powerful enough and lacks the logic of a unified error handling process; After a lot of discussion, Go 1.13 introduces wrong packaging and unpacking, which may optimize our error handling process to some extent.

Too long to see the version
  • Error has two functions: one is to let the code enter a specific error handling process, and the other is to tell the programmer what happened
  • error interface only contains the method of Error() string. It is difficult for error to complete the above two roles only through string matching
  • Go introduced wrong packaging and unpacking in version 1.13
    • Just FMT Errorf("...%w...", ..., err, ...) You can complete the packaging of error
    • You can use errors Is (error, target error) bool and errors As (error error, target interface {}) bool implements unpacking. The functions are: whether error contains target and whether it contains errors that can be converted to target
  • In practice, we can always
    • Wrap error to add context parameters for function calls for troubleshooting
    • Print and unpack at the bottom of the final stack. The print directly uses the Error() string method to unpack and parse the required fixed errors and return them as the response of the API interface

(too long to see the end of the version)

Suppose we need to implement a service to return the data corresponding to the ID in the request for the administrator user, otherwise an error is returned; The service needs to comply with cloud api3 The error code specification of 0 is very simple:

func HasPermission(ctx context.Context, uin string) error {
  role, err := getRole(ctx, uin)
  if err != nil {
    // logging uin
    return err
  }
  if role != admin {
    return apierr.NewUnauthorizedOperationNoPermission()
  }
  return nil
}

func (s *Service) GetData(ctx context.Context, req *Request) (*Response, error) {
  if err := HasPermission(ctx, req.Uin); err != nil {
    if err == apierr.UnauthorizedOperationNoPermission {
      // new a Response with error message and return it
    }
    // logging req and err, pack and return a Response
  }
}

Here we omit the logic of looking up the database and returning the results. This is just a simple interface, which only includes two steps - authentication and database query - each step may have different errors: some may need to directly return the cloud API 3.0 error code conforming to the specification for return to the requester, and some may need to log the intermediate status and parameters for debugging.

Error handling becomes very complex. We often need to err == SomeError or err Error() = = someerrorstring for comparison, but in this way, it is difficult to associate the context of the error and troubleshooting becomes difficult.

The error handling of the interface with only two steps becomes so complex, so how should we reconstruct the error handling logic of our Go code?

Role of error

Before answering the last question, we need to recall, what kind of responsibility should Golang's Error assume and what role should it play in code operation?

In fact, the role of error is divided into code oriented and programmer oriented.

  • For code: let the code enter a specific error handling process
  • For programmers: Tell programmers what happened

Therefore, the processing of error should be oriented towards these two points:

  • Type judgment for code (what kind of error is the error)
  • For programmers: print strings (show how errors occur)

But error is just an interface with Error() string. How to realize the dual role of error?

Packaging and unpacking of error

Golang introduced the packaging and unpacking of error in the release of 1.13. See [Working with Errors in Go 1.13](https://blog.golang.org/go1.13-errors) . Here we will give a brief introduction to the grammar, and then explain how to practice it in detail later.

error packaging

For example, suppose the function receives an error and wants to add more context information:

func NewOSError(msg string) error {
  return &OSError{msg}
}

var usingWindows = NewOSError("Upgrading Windows. Sit back and relax.")

func send(message string) error {
  return usingWindows
}

func Send(message string) error {
  err := send(message)
    if err != nil {
    e := fmt.Errorf("Send(%q): %w", message, err)
    return e
    }
  return nil
}

func main() {
  err := Send("I'm using a Mac.")
  if err != nil {
    println(err.Error())
  }
}
// Send("I'm using a Mac."): Upgrading Windows. Sit back and relax.

We are simply calling FMT When error F, replace% v with% w, and then print the error message, error automatically calls its Error() string method. But it is called "error packaging" because the new error obtained by this method can be unpacked.

Unpacking of error

errors.Is(err error, target error) bool

errors. The is (ERR error, target error) bool method unpacks all errors wrapped in err. If any of them = = target after unpacking, it returns true. For example:

func main() {
  err := Send("I'm using a Mac.")
  if err != nil {
    if errors.Is(err, usingWindows) {
      println(" Less than a minute remaining...")
    } else {
      println(err.Error())
    }
  }
}
//  Less than a minute remaining...

errors.As(err error, target interface{}) bool

Func as (error, target interface {}) bool method will unpack all errors wrapped in err and see whether the type can be converted to the type of target. If so, assign the converted result to target. For example:

func main() {
  err := Send("I'm using a Mac.")
  if err != nil {
    var osError *OSError
    if errors.As(err, &osError) {
      println("Got an OSError!")
    } else {
      println(err.Error())
    }
  }
}
// Got an OSError!

Practice of error packaging and unpacking

Back to the code we just mentioned, our hope is that there are two roles corresponding to error:

  • For code: the interface can finally correctly return the Response conforming to cloud API 3.0 according to error
  • For programmers: be able to record the context in the call chain and finally print it out

Therefore, the original code can be designed as follows:

func HasPermission(ctx context.Context, uin string) error {
  var err error
  defer func() { // Add context information
    if err != nil {
      err = fmt.Errorf("HasPermission(%q): %w", uin, err)
    }
  }()
  role, err := getRole(ctx, uin)
  if err != nil {
    return err
  }
  if role != admin {
    return apierr.NewUnauthorizedOperationNoPermission()
  }
  return nil
}

func (s *Service) GetData(ctx context.Context, req *Request) (*Response, error) {
  var err error
  handler := func(e error) (*Response, error) {
    log.Errorf("GetData(%q): %q", req, e.Error()) // Print error message
    r := &Response{}
    var apiError *APIError
    if errors.As(e, &apiError) { // Unpack the error and get a "returnable" error
      r.Error = apiError.ToError()
    } else { // Unable to unpack, using the default "returnable" error
      r.Error = apierr.NewFailedOperationError(e)
    }
  }
  if err := HasPermission(ctx, req.Uin); err != nil {
    return handler(err)
  }
  data, err := retriveData(ctx, req.Key)
  if err != nil {
    return handler(err)
  }
  // return normally
}