处理错误
到目前为止,我们还没有讨论过可能在业务逻辑内部或外部出现的错误。显然,这对于一个生产就绪的 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 状态码(如 404
和 500
),但主要区别在于它们有更具描述性的名称,而且总共的代码数量比 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
包中的 Error
和 Errorf
函数在服务器端创建错误。我们可以选择多个错误代码。我们只展示了两个,但它们是常见的错误代码。最后,在客户端,我们看到了如何根据错误代码采取相应的行动,通过将 Go 错误转换为 Status
并根据状态码编写条件。