一个 Unary API

在底层协议方面,正如我们在 【第 1 章 网络基础】 中提到的,Unary API 使用客户端的 Send HeaderSend MessageHalf-Close 操作,服务器端则使用 Send MessageSend Trailer 操作。如果您需要复习这些操作,我建议您快速查看本书第 1 章的 RPC 操作部分。这将有助于理解在调用该 API 端点时发生了什么。

最简单且最常见的 API 端点就是单一请求(Unary)端点。这些大致上对应于您可能在 REST API 中使用的 GETPOST 和其他 HTTP 动词。您发送一个请求并收到一个响应。通常,这些端点将是您最常使用的,用来表示对某一资源的操作。例如,如果您编写一个登录方法,您只需要发送 LoginRequest 并接收 LoginResponse

在本节中,我们将编写一个名为 AddTask 的 RPC 端点。顾名思义,这个端点将创建一个新的任务。因此,在实现之前,我们需要定义任务是什么。

一个任务是包含以下属性的对象:

  • id:该任务的唯一标识符,类型为数字

  • description:任务的实际描述,用户可读取的内容

  • done:任务是否已完成

  • due_date:任务的到期时间

将这些属性转换为 Protobuf 代码时,我们可以写成如下(文件位于 chapter5/proto/todo/v1 目录中的 todo.proto):

syntax = "proto3";

package todo.v1;

import "google/protobuf/timestamp.proto";

option go_package = "github.com/PacktPublishing/gRPC-Go-for-Professionals/proto/todo/v1";

message Task {
  uint64 id = 1;
  string description = 2;
  bool done = 3;
  google.protobuf.Timestamp due_date = 4;
}

请注意,这里使用了 Timestamp 类型。这是 Protobuf 提供的一个著名类型,属于 google.protobuf 包。我们使用这个类型来表示任务应该完成的未来时间点。我们本来也可以自己编写一个 Date 类型,或者使用 googleapis 仓库中定义的 Date 类型( google/type/date.proto ),但 Timestamp 对于这个 API 来说已经足够了。

现在我们有了任务,我们可以考虑我们的 RPC 端点。我们希望端点接收一个任务,并将其插入到任务列表中,同时将该任务的标识符返回给客户端:

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

message AddTaskResponse {
  uint64 id = 1;
}

service TodoService {
  rpc AddTask(AddTaskRequest) returns (AddTaskResponse);
}

这里一个重要的细节是,我们并没有直接使用 Task 消息作为 AddTask 端点的参数,而是创建了一个包装器 AddTaskRequest 来包含该端点所需的信息。只是包含必要的数据,没有更多。我们本可以直接使用 Task 消息,但那样可能会导致客户端发送不必要的数据(例如,设置 ID,但服务器会忽略它)。此外,未来版本的 API 中,我们可以在不影响 Task 消息的情况下向 AddTaskRequest 中添加更多字段。我们实际上解耦了请求/响应的数据表示与实际的数据表示。

代码生成

如果您到现在还不确定如何从 proto 文件生成代码,我强烈建议您查看第 4 章,我们在其中介绍了三种方法来实现这一点。在本章中,我们将手动生成所有代码,但您可以在 GitHub 仓库的 chapter5 文件夹中找到如何使用 BufBazel 做同样的事情。

现在我们已经创建了 API 端点的接口,接下来我们希望能够实现它背后的逻辑。实现这一目标的第一步是生成一些 Go 代码。为此,我们将使用 protocsource_relative 值来设置 paths 选项。知道我们的 todo.proto 文件位于 proto/todo/v1/ 文件夹下后,我们可以运行以下命令:

$ protoc --go_out=. \
    --go_opt=paths=source_relative \
    --go-grpc_out=. \
    --go-grpc_opt=paths=source_relative \
    proto/todo/v1/*.proto

运行此命令后,您应该会得到一个类似如下的 proto/todo/v1/ 目录:

proto/todo/v1/
├── todo.pb.go
├── todo.proto
└── todo_grpc.pb.go

这就是我们开始的所有内容。

检查生成的代码

在生成的代码中,我们有两个文件——Protobuf 生成的代码和 gRPC 生成的代码。Protobuf 生成的代码位于 todo.pb.go 文件中。如果我们检查这个文件,最重要的内容是以下代码(已简化):

type Task struct {
    Id          uint64
    Description string
    Done        bool
    DueDate     *timestamppb.Timestamp
}

type AddTaskRequest struct {
    Description string
    DueDate     *timestamppb.Timestamp
}

type AddTaskResponse struct {
    Id uint64
}

这意味着我们现在可以在 Go 代码中创建 TaskAddTaskRequestAddTaskResponse 的实例,接下来我们将在本章中做的正是这件事。

对于 gRPC 生成的代码(todo_grpc.pb.go),会为客户端和服务器生成接口。它们应该如下所示:

type TodoServiceClient interface {
    AddTask(ctx context.Context, in *AddTaskRequest, opts ...grpc.CallOption) (*AddTaskResponse, error)
}

type TodoServiceServer interface {
    AddTask(context.Context, *AddTaskRequest) (*AddTaskResponse, error)
    mustEmbedUnimplementedTodoServiceServer()
}

它们看起来很相似,但服务器端的 AddTask 是我们需要实现逻辑的唯一一个。客户端的 AddTask 基本上是生成请求来调用服务器上的 API 端点,并返回我们收到的响应。

注册服务

要让 gRPC 知道如何处理特定的请求,我们需要注册一个服务的实现。为了注册这样的服务,我们将调用在 todo_grpc.pb.go 中生成的一个函数。在我们的案例中,这个函数名为 RegisterTodoServiceServer,其函数签名如下:

func RegisterTodoServiceServer(s grpc.ServiceRegistrar, srv TodoServiceServer)

这个函数接收两个参数:grpc.ServiceRegistrar(这是一个接口,grpc.Server 实现了这个接口)和 TodoServiceServer(这是我们之前看到的接口)。这个函数会将 gRPC 框架提供的通用服务器与我们实现的端点链接起来,这样框架就知道如何处理请求。

接下来,第一步是创建我们的服务器。我们首先需要创建一个结构体,它嵌入了 UnimplementedTodoServiceServer,这是一个生成的结构体,包含了端点的默认实现。在我们的案例中,默认实现如下所示:

func (UnimplementedTodoServiceServer) AddTask(context.Context, *AddTaskRequest) (*AddTaskResponse, error) {
    return nil, status.Errorf(codes.Unimplemented, "method AddTask not implemented")
}

如果我们没有在服务器中实现 AddTask 方法,那么每次调用时都会调用这个端点,并返回一个错误。虽然这看起来没有多大用处,因为它什么都不做,只是返回一个错误,但实际上这是一个安全网,我们将在后面讨论如何演进 API 时看到它的作用。

接下来,我们的服务器将包含一个对数据库的引用。你可以根据自己熟悉的任何数据库来进行适配,但在我们的例子中,我们将使用一个接口来抽象数据库,这样可以让我们专注于 gRPC 而不是其他技术(例如 MongoDB)。

因此,我们的服务器类型(server/server.go)将如下所示:

package main

import (
    pb "github.com/PacktPublishing/gRPC-Go-for-Professionals/proto/todo/v1"
)

type server struct {
    d db
    pb.UnimplementedTodoServiceServer
}

接下来,让我们看看 db 接口是如何定义的。我们将首先定义一个 addTask 方法,它接收一个 descriptiondueDate 参数,并返回任务创建的 id 或一个错误。需要注意的是,这个数据库接口应该与生成的代码解耦。之所以这样做,是为了在未来演进 API 时能够更灵活,因为如果我们更改了端点或 Request/Response 对象,就不需要修改接口及其所有实现。这样,接口就与生成的代码解耦了。

server/db.go 中,我们可以写出如下代码:

package main

import "time"

type db interface {
    addTask(description string, dueDate time.Time) (uint64, error)
}

这个接口让我们可以使用假的数据库实现进行测试,并在非发布环境中实现一个内存数据库。

最后一步是实现内存数据库。我们将使用一个常规的 Task 数组来存储我们的待办事项,并且 addTask 方法将简单地将任务添加到这个数组中,并返回当前任务的 ID。我们可以在 server/in_memory.go 文件中添加如下代码:

package main

import (
    "time"
    pb "github.com/PacktPublishing/gRPC-Go-for-Professionals/proto/todo/v1"
    "google.golang.org/protobuf/types/known/timestamppb"
)

type inMemoryDb struct {
    tasks []*pb.Task
}

func New() db {
    return &inMemoryDb{}
}

func (d *inMemoryDb) addTask(description string, dueDate time.Time) (uint64, error) {
    nextId := uint64(len(d.tasks) + 1)
    task := &pb.Task{
        Id:          nextId,
        Description: description,
        DueDate:     timestamppb.New(dueDate),
    }
    d.tasks = append(d.tasks, task)
    return nextId, nil
}

这里有几点需要注意。首先,这显然不是一个优化过的 “数据库”,它仅用于开发环境。其次,如果我们有多个数据库实现(比如 inMemoryDbmongoDb 实现),我们可以使用 Golang 的构建标签在编译时选择运行的数据库。例如,在 in_memory.go 文件的顶部,我们可以使用以下代码:

//go:build in_memory_db
//...
type inMemoryDb struct

func New() db

而在 mongodb.go 文件中,我们可以这样写:

//go:build mongodb
//...
type mongoDb struct

func New() db

这样,我们就可以在编译时选择使用哪个 New 函数,从而创建一个 inMemoryDbmongoDb 实例。

最后,你可能已经注意到,在实现这个 “数据库” 时,我们使用了生成的代码。由于这是一个仅在开发环境中使用的 “数据库”,它与生成的代码耦合并不会造成问题。最重要的是不要让 db 接口与生成的代码耦合,这样你就可以使用任何数据库,而不需要处理生成的代码。

现在,我们终于可以注册我们的服务器类型了。为此,我们只需进入 server/main.go 中的 main 函数,并添加如下代码:

import pb "github.com/PacktPublishing/gRPC-Go-for-Professionals/proto/todo/v1"
//...
s := grpc.NewServer(opts...)
pb.RegisterTodoServiceServer(s, &server{
    d: New(),
})
defer s.Stop()

这意味着我们已经将 gRPC 服务器 s 与创建的 server 实例链接起来。请注意,New 函数是我们在 in_memory.go 文件中定义的函数。

实现 AddTask

为了实现,我们将创建一个名为 server/impl.go 的文件,该文件将包含所有端点的实现。请注意,这只是为了方便,你也可以为每个 RPC 端点创建一个单独的文件。

现在,正如你可能记得的那样,我们为服务器生成的接口要求我们实现以下函数:

AddTask(context.Context, *AddTaskRequest) (*AddTaskResponse, error)

所以,我们只需通过在函数前缀中添加服务器实例的名称和服务器类型来将该函数添加到我们的服务器类型中:

func (s *server) AddTask(_ context.Context, in *pb.AddTaskRequest) (*pb.AddTaskResponse, error) {
}

最后,我们可以实现这个函数。它将调用我们 db 接口中的 addTask 方法,由于这个方法目前不会返回错误,我们将使用给定的 ID 并将其作为 AddTaskResponse 返回:

package main

import (
    "context"
    pb "github.com/PacktPublishing/gRPC-Go-for-Professionals/proto/todo/v1"
)

func (s *server) AddTask(_ context.Context, in *pb.AddTaskRequest) (*pb.AddTaskResponse, error) {
    id, _ := s.d.addTask(in.Description, in.DueDate.AsTime())
    return &pb.AddTaskResponse{Id: id}, nil
}

需要注意的是,AsTimegoogle.golang.org/protobuf/types/known/timestamppb 包提供的一个函数,它将返回一个 Golang time.Time 对象。timestamppb 包是一个函数集合,允许我们以 Go 的习惯用法操作 google.protobuf.Timestamp 对象,并将其用于我们的 Go 代码中。

现在,你可能觉得这实现有点简单,但请记住,我们目前只是在开始阶段。稍后在书中,我们将进行错误处理,并学习如何拒绝不正确的参数。

从客户端调用 AddTask

最后,让我们看看如何从 Go 客户端代码调用该端点。这非常简单,因为我们已经在上一章中创建了代码模板。

我们将创建一个名为 AddTask 的函数,它将调用我们在服务器上注册的 API 端点。为此,我们需要传递一个 TodoServiceClient 实例、任务的描述和到期日期。稍后我们将创建客户端实例,但请注意,TodoServiceClient 是我们在检查生成的代码时看到的接口。在 client/main.go 文件中,我们可以添加如下内容:

import (
    //...
    "google.golang.org/protobuf/types/known/timestamppb"
    pb "github.com/PacktPublishing/gRPC-Go-for-Professionals/proto/todo/v1"
    //...
)

func addTask(c pb.TodoServiceClient, description string, dueDate time.Time) uint64 {
}

之后,使用这些参数,我们可以构造一个新的 AddTaskRequest 实例,并将其发送到服务器:

func addTask(c pb.TodoServiceClient, description string, dueDate time.Time) uint64 {
    req := &pb.AddTaskRequest{
        Description: description,
        DueDate: timestamppb.New(dueDate),
    }
    res, err := c.AddTask(context.Background(), req)
    //...
}

最终,我们将从 API 调用中收到一个 AddTaskResponse 或一个错误。如果有错误,我们在屏幕上记录它;如果没有错误,我们则记录并返回任务的 ID:

func addTask(c pb.TodoServiceClient, description string, dueDate time.Time) uint64 {
    //...
    if err != nil {
        panic(err)
    }
    fmt.Printf("added task: %d\n", res.Id)
    return res.Id
}

为了调用这个函数,我们需要使用一个生成的函数 NewTodoServiceClient,我们将连接传递给它,它会返回一个新的 TodoServiceClient 实例。之后,我们只需在客户端的 main 函数的末尾添加如下几行代码:

conn, err := grpc.Dial(addr, opts...)

if err != nil {
    log.Fatalf("did not connect: %v", err)
}
c := pb.NewTodoServiceClient(conn)

fmt.Println("--------ADD--------")
dueDate := time.Now().Add(5 * time.Second)
addTask(c, "This is a task", dueDate)
fmt.Println("-------------------")

defer func(conn *grpc.ClientConn) {
    /*...*/
}(conn)

在这里,我们正在添加一个任务,设置它的到期时间为五秒后,描述为 “This is a task”。这只是一个示例,我鼓励你尝试自己做更多的调用,使用不同的值。

现在,我们基本上可以运行服务器和客户端,看看它们如何交互。要运行服务器,请使用以下命令:

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

然后,在另一个终端中,以类似的方式运行客户端:

$ go run ./client 0.0.0.0:50051
--------ADD--------
added task: 1
-------------------

最后,要停止服务器,你只需按下终端中的 【Ctrl + C】。

因此,我们可以看到,我们已经将服务实现注册到服务器,并且我们的客户端正确地发送了请求并收到了响应。我们所做的是一个简单的 Unary API 端点示例。

Bazel

在本节中,我们将了解如何使用 Bazel 运行应用程序。然而,因为每个章节都要进行这样的解释会变得重复,所以我想提前提醒你,我们不会每次都重复这些步骤。在每一节中,你可以运行下面看到的 bazel run 命令(用于服务器和客户端),而 gazelle 命令只在本节中有用。

此时,你的 Bazel BUILD 文件可能已经过时。为了同步它们,我们可以简单地运行 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
--------ADD--------
added task: 1
-------------------

这是我们服务器和客户端正常工作的另一个例子。我们可以通过 go runbazel run 命令运行它们。我们现在对 Unary API 很有信心,让我们转到服务器流式 API。