处理错误

到目前为止,我们还没有讨论过可能在业务逻辑内部或外部出现的错误。显然,这对于一个生产就绪的 API 来说并不好,因此我们将讨论如何解决这些问题。在本节中,我们将集中精力讨论名为 AddTask 的 RPC 端点。

在开始编码之前,我们需要了解 gRPC 中的错误是如何工作的,但这应该不难,因为它们与我们在 REST API 中习惯的错误处理方式非常相似。

错误是通过一个包装结构体 Status 返回的。这个结构体可以通过多种方式构造,但我们在本节中关注的是以下两种方式:

func Error(c codes.Code, msg string) error
func Errorf(c codes.Code, format string, a ...interface{}) error

它们都接受一个错误消息和一个错误代码。我们重点关注错误代码,因为消息只是描述错误的字符串。状态代码是预定义的代码,在不同的 gRPC 实现中是一致的。它们类似于 HTTP 状态码(如 404500),但主要区别在于它们有更具描述性的名称,而且总共的代码数量比 HTTP 少得多(只有 16 个)。

要查看所有这些代码,你可以访问 gRPC Go 文档( https://pkg.go.dev/google.golang.org/grpc/codes#Code )。文档中对每个错误都有很好的解释,比 HTTP 状态码更为清晰,因此不必担心。对于本节内容,我们关注两个常见的错误:

  • InvalidArgument(无效参数)

  • Internal(内部错误)

InvalidArgument 表示客户端提供了一个不正确的参数,导致端点无法正常工作。Internal 表示系统的某个预期属性出现了故障。

InvalidArgument 非常适用于验证输入。我们将在 AddTask 中使用它,确保 Task 的描述不能为空(没有描述的任务是没有意义的),并且必须指定一个不在过去的到期日期。请注意,我们将到期日期设为必填项,但如果你想将其设为可选项,只需要检查请求中的 DueDate 属性是否为 nil,然后根据情况处理即可。

import (
    //...
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
)

func (s *server) AddTask(_ context.Context, in *pb.AddTaskRequest) (*pb.AddTaskResponse, error) {
    if len(in.Description) == 0 {
        return nil, status.Error(
            codes.InvalidArgument,
            "expected a task description, got an empty string",
        )
    }

    if in.DueDate.AsTime().Before(time.Now().UTC()) {
        return nil, status.Error(
            codes.InvalidArgument,
            "expected a task due_date that is in the future",
        )
    }

    //...
}

这些检查将确保我们数据库中的任务有意义,并且到期日期总是在未来。

接下来,我们还有一个错误可能来自 addTask 函数,它是从数据库中转发来的错误。我们可以进行详细检查,根据每个数据库错误返回更精确的错误代码,但为了简便起见,我们只是将所有数据库错误视为 Internal 错误。

我们将从 addTask 函数中获取潜在的错误,并做类似我们之前处理 InvalidArgument 错误的操作,不过这次会使用 Internal 错误代码,并使用 Errorf 函数传递错误的详细信息:

func (s *server) AddTask(_ context.Context, in *pb.AddTaskRequest) (*pb.AddTaskResponse, error) {
    //...
    id, err := s.d.addTask(in.Description, in.DueDate.AsTime())
    if err != nil {
        return nil, status.Errorf(
            codes.Internal,
            "unexpected error: %s",
            err.Error(),
        )
    }
    //...
}

现在,服务器端的代码就完成了。接下来我们可以转向客户端——如果您之前没有注意到,我们已经在 addTask 函数中 “处理” 了错误。我们有以下几行代码:

res, err := c.AddTask(context.Background(), req)
if err != nil {
    panic(err)
}

当然,客户端可能会做更复杂的错误处理或甚至恢复,但我们现在的目标是确保服务器端的错误能够正确地传播到客户端。为了测试 InvalidArgument 错误,我们可以尝试添加一个没有描述的任务。在 main 函数的末尾,我们可以添加以下内容:

import (
    //...
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
)

func main() {
    //...
    fmt.Println("-------ERROR-------")
    addTask(c, "", dueDate)
    fmt.Println("-------------------")
}

然后,我们运行服务器:

$ go run ./server 0.0.0.0:50051
listening at 0.0.0.0:50051

客户端应该返回预期的错误:

$ go run ./client 0.0.0.0:50051
-------ERROR-------
panic: rpc error: code = InvalidArgument desc = expected a task description, got an empty string

然后,我们可以通过提供一个过去的 Time 实例来检查到期日期错误:

fmt.Println("-------ERROR-------")
// addTask(c, "", dueDate)
addTask(c, "not empty", time.Now().Add(-5 * time.Second))
fmt.Println("-------------------")

我们应该得到以下输出:

$ go run ./client 0.0.0.0:50051
-------ERROR-------
panic: rpc error: code = InvalidArgument desc = expected a task due_date that is in the future

最后,我们不展示 Internal 错误,因为这会让我们在内存数据库中创建一个假错误,但请理解它会返回如下内容:

$ go run ./client 0.0.0.0:50051
-------ERROR-------
panic: rpc error: code = Internal desc = unexpected error: <AN_ERROR_MESSAGE>

在结束本节之前,重要的是要理解如何检查错误的类型并相应地处理它们。我们基本上会引发 panic,但提供更具可读性的错误消息。例如,假设我们遇到了以下错误:

rpc error: code = InvalidArgument desc = expected a task due_date that is in the future

我们希望将其打印为:

InvalidArgument: expected a task due_date that is in the future

为此,我们将修改 addTask,如果出现错误,我们会尝试通过 FromError 函数将其转换为 Status,如果转换成功,我们将打印错误代码和错误消息;如果没有成功转换为 Status,我们将像以前一样 panic:

func addTask(c pb.TodoServiceClient, description string, dueDate time.Time) uint64 {
    //...
    res, err := c.AddTask(context.Background(), req)

    if err != nil {
        if s, ok := status.FromError(err); ok {
            switch s.Code() {
            case codes.InvalidArgument, codes.Internal:
                log.Fatalf("%s: %s", s.Code(), s.Message())
            default:
                log.Fatal(s)
            }
        } else {
            panic(err)
        }
    }

    //...
}

现在,当我们运行客户端并遇到我们之前定义的错误时,我们会得到以下输出:

$ go run ./client 0.0.0.0:50051
-------ERROR-------
InvalidArgument: expected a task due_date that is in the future

Bazel

这里展示的命令每次更新与 gRPC 相关的导入时都需要执行。为了简化起见,我们在本章中只展示了一次这些命令,并假设您能够在其他章节中自己执行这些命令。

随着本章内容的展开,我们将添加更多的依赖项。因此,我们需要更新我们的 BUILD 文件。如果我们尝试使用 Bazel 启动服务器,目前会遇到以下错误:

No dependencies were provided.
Check that imports in Go sources match importpath attributes in deps.

为了解决这个问题,我们只需运行 gazelle 命令,如下所示:

$ bazel run //:gazelle

然后,我们就可以正确地运行服务器和客户端:

$ bazel run //server:server 0.0.0.0:50051
listening at 0.0.0.0:50051
$ bazel run //client:client 0.0.0.0:50051

最后,我们了解到,我们可以通过 status 包中的 ErrorErrorf 函数在服务器端创建错误。我们可以选择多个错误代码。我们只展示了两个,但它们是常见的错误代码。最后,在客户端,我们看到了如何根据错误代码采取相应的行动,通过将 Go 错误转换为 Status 并根据状态码编写条件。