验证请求

在本章中,我们将首先减少检查请求消息某些属性的代码。我们将使用 protoc-gen-validate 插件来生成某些消息的验证代码。这个插件可以帮助我们在请求消息的属性检查时更加简洁和方便。例如,在检查任务的描述长度和到期日期时,我们可以直接调用生成的 Validate() 函数,它会告诉我们请求消息是否符合要求。

我们将做的第一件事是安装该插件。它是由 Buf 维护的,可以通过以下命令进行安装:

$ go install github.com/envoyproxy/protoc-gen-validate

安装完成后,我们可以使用 protoc--validate_out 选项来生成验证代码。

无论是手动使用 protoc 还是通过 Buf CLI,我们都需要从 GitHub 仓库复制 validate.proto 文件。可以通过以下链接找到该文件: https://github.com/bufbuild/protoc-gen-validate/blob/main/validate/validate.proto 。我们将把它复制到我们的 proto 文件夹中的 validate 目录下:

proto
└── validate
    └── validate.proto

接下来,我们可以在其他 proto 文件中导入该文件,并使用提供的验证规则作为字段选项。

我们将以 proto/todo/v2/todo.proto 中的 AddTaskRequest 为例。当前内容如下:

message AddTaskRequest {
  string description = 1;
  google.protobuf.Timestamp due_date = 2;
}

每次我们尝试添加任务时,服务器端会检查 description 是否为空,以及 due_date 是否大于 time.Now()

现在,我们将把这个逻辑编码到我们的 proto 文件中。首先,我们需要导入 validate.proto 文件。然后,我们将能够访问 validate.rules 字段选项,该选项包含了多种类型的规则集合。我们将处理 stringTimestamp 类型,并使用 min_lengt_now 字段。第一个规则描述了在调用 Validate 时,字符串应该具有的最小长度;第二个规则则要求提供的 Timestamp 必须是未来的时间。

import "validate/validate.proto";

// ...

message AddTaskRequest {
  string description = 1 [
    (validate.rules).string.min_len = 1
  ];

  google.protobuf.Timestamp due_date = 2 [
    (validate.rules).timestamp.gt_now = true
  ];
}

一旦我们定义了这些规则,我们就需要生成代码来检查这些规则。否则,这些选项就没有意义。我们将使用 protoc 手动生成代码,然后我会展示如何使用 BufBazel 来生成。

正如前面提到的,使用该插件时,可以在 protoc 命令中使用 --validate_out 选项,命令如下:

$ protoc -Iproto --go_out=proto --go_opt=paths=source_relative --go-grpc_out=proto --go-grpc_opt=paths=source_relative --validate_out="lang=go,paths=source_relative:proto" proto/todo/v2/*.proto

请注意,这个命令与我们之前运行的命令类似。我们只是添加了一个新选项,告诉它在 Go 代码上工作,并根据 v2 文件夹中的 proto 文件生成代码。

现在,在 Protobuf 和 gRPC 生成的代码基础上,您应该还会在 v2 文件夹中看到一个 .pb.validate.go 文件。它应该如下所示:

proto/todo/v2
├── todo.pb.go
├── todo.pb.validate.go
├── todo.proto
└── todo_grpc.pb.go

在生成的 todo.pb.validate.go 文件中,你应该能够看到类似以下的函数:

// Validate checks the field values on Task with the rules
// defined in the proto definition for this message. If any rules are violated,
// the first error encountered is returned, or nil if there are no violations.
func (m *Task) Validate() error {
  return m.validate(false)
}

现在,我们将在服务器端的 AddTask 端点中使用这个函数。当前的检查逻辑如下:

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")
  }
  //...
}

我们将这部分代码替换为调用 Validate 函数。我们只需要调用 in.Validate(),如果返回错误,就直接返回该错误;否则,继续执行:

func (s *server) AddTask(_ context.Context, in *pb.AddTaskRequest) (*pb.AddTaskResponse, error) {
  if err := in.Validate(); err != nil {
    return nil, err
  }
  //...
}

这样做非常简单,我们避免了手动编写所有的检查逻辑,并且确保在不同的端点中返回一致的错误信息。

接下来,我们可以回到客户端,取消注释错误部分的函数,逐个测试:

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

  fmt.Println("-------------------")
}

对于第一个 addTask,我们应该得到如下错误:

$ go run ./client 0.0.0.0:50051
-------ERROR-------
rpc error: code = Unknown desc = invalid AddTaskRequest .Description: value length must be at least 1 runes

对于第二个 addTask,我们应该得到如下错误:

$ go run ./client 0.0.0.0:50051
-------ERROR-------
rpc error: code = Unknown desc = invalid AddTaskRequest .DueDate: value must be greater than now

注意,错误代码是 Unknown。截至本书编写时,protoc-gen-validate 插件似乎没有自定义的错误代码,这可能会在插件的 v2 版本中出现。然而,它为我们提供了简单的验证代码和清晰的错误消息。

Buf

使用 Buf CLI 配合 protoc-gen-validate 非常简单。我们只需要在 YAML 配置文件中添加一些设置来生成代码。首先,我们需要在 buf.yaml 文件中添加对 protoc-gen-validate 的依赖:

version: v1
#...
deps:
  - buf.build/envoyproxy/protoc-gen-validate

这告诉 Buf 在生成过程中需要 protoc-gen-validate,它会自动处理如何拉取该依赖。

接下来,我们需要在 buf.gen.yaml 文件中配置插件:

version: v1
plugins:
  #...
  - plugin: buf.build/bufbuild/validate-go
    out: proto
    opt: paths=source_relative

这些选项与我们之前手动输入的相同。现在,我们可以像平常一样运行以下命令来生成代码:

$ buf generate proto

你现在应该得到与使用 protoc 命令生成相同的三个文件:todo.pb.validate.gotodo_grpc.pb.gotodo.pb.go。需要注意的是,在这种情况下,我们还生成了 v1 版本的代码和 validate.proto 文件。

Bazel

如同往常一样,首先我们需要在 WORKSPACE.bazel 文件中定义依赖项。我们将从 GitHub 获取 protoc-gen-validate 项目,并加载其相关依赖:

以下代码引用了一个名为 PROTOC_GEN_VALIDATE_VERSION 的变量。这个变量在 chapter8 文件夹中的 versions.bzl 文件中定义。为了保持代码的独立性,我们在此不包含版本信息。

#...
git_repository(
    name = "com_envoyproxy_protoc_gen_validate",
    tag = PROTOC_GEN_VALIDATE_VERSION,
    remote = "https://github.com/bufbuild/protoc-gen-validate"
)

load("@com_envoyproxy_protoc_gen_validate//bazel:repositories.bzl", "pgv_dependencies")
load("@com_envoyproxy_protoc_gen_validate//:dependencies.bzl", "go_third_party")

pgv_dependencies()

# gazelle:repository_macro deps.bzl%go_third_party
go_third_party()

现在,我们需要更新 deps.bzl 文件中的依赖项。我们可以通过输入以下命令来完成:

$ bazel run //:gazelle-update-repos

最后,我们需要生成代码并将其链接到 proto/todo/v2/BUILD.bazel 中现有的 todo go_library

首先,在 v2_protoproto_library 中添加对 protoc-gen-validate validate.proto 的依赖。这将允许 todo.proto 导入它:

proto_library(
    name = "v2_proto",
    #...
    deps = [
        #...
        "@com_envoyproxy_protoc_gen_validate//validate:validate_proto",
    ],
)

然后,我们将用 pgv_go_proto_library 替换 v2_go_protogo_proto_librarypgv 代表 protoc-gen-validate)。除此之外,我们还将添加对 protoc-gen-validate 库的依赖,以便生成的代码能够访问编译所需的任何 protoc-gen-validate 内部代码:

load("@com_envoyproxy_protoc_gen_validate//bazel:pgv_proto_library.bzl", "pgv_go_proto_library")

#...
pgv_go_proto_library(
    name = "v2_go_proto",
    compilers = ["@io_bazel_rules_go//proto:go_grpc"],
    importpath = "github.com/PacktPublishing/gRPC-Go-for-Professionals/proto/todo/v2",
    proto = ":v2_proto",
    deps = ["@com_envoyproxy_protoc_gen_validate//validate:validate_go"],
)

最后,为了避免下次运行 Gazelle 时导入 validate/validate.proto 的路径发生歧义,我们将其映射到 @com_envoyproxy_protoc_gen_validate//validate:validate_proto(在 protoc-gen-validate 中定义)。我们可以在 proto/todo/v2/BUILD.bazel 文件的顶部添加以下 Gazelle 指令:

# gazelle:resolve proto validate/validate.proto
@com_envoyproxy_protoc_gen_validate//validate:validate_proto

现在,我们用 pgv_go_proto_library 替换了旧的 v2_go_proto,依赖此库的代码将自动访问生成的 Validate 函数。

我们可以尝试运行服务器:

$ 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
-------ERROR-------
rpc error: code = Unknown desc = invalid AddTaskRequest
.Description: value length must be at least 1 runes

总结:我们看到了如何在 proto 文件中编码验证逻辑,并通过 protoc-gen-validate 自动生成验证代码。这使得我们的代码更加简洁,并为我们的 API 端点提供了一致的错误消息。