压缩负载

Protobuf 中,数据被序列化为二进制格式,这比文本数据的负载要小得多,但我们仍然可以在二进制数据之上应用压缩。gRPC 为我们提供了 gzip 压缩器( https://pkg.go.dev/google.golang.org/grpc/encoding/gzip ),并且对于更高级的用例,允许我们编写自己的压缩器( https://pkg.go.dev/google.golang.org/grpc/encoding )。

在深入了解如何使用 gzip 压缩器之前,需要理解一个重要的概念:无损压缩可能会导致负载大小增大。如果你的负载不包含重复数据(gzip 主要用于压缩重复数据),你发送的字节数可能会比实际需要的更多。因此,在启用 gzip 压缩之前,你需要对典型负载进行实验,看看 gzip 压缩如何影响其大小。

为了展示这个例子,我在 helpers 文件夹中包含了一个名为 gzip.go 的文件,其中包含一个辅助函数 compressedSize。该函数返回序列化数据的原始大小和经过 gzip 压缩后的大小:

func compressedSize[M protoreflect.ProtoMessage](msg M) (int, int) {
    var b bytes.Buffer
    gz := gzip.NewWriter(&b)
    out, err := proto.Marshal(msg)

    if err != nil {
        log.Fatal(err)
    }

    if _, err := gz.Write(out); err != nil {
        log.Fatal(err)
    }

    if err := gz.Close(); err != nil {
        log.Fatal(err)
    }

    return len(out), len(b.Bytes())
}

这是一个通用函数,我们可以用它来处理任何消息。我们可以首先尝试使用一个不适合压缩的消息,比如 Int32Value。所以,在 gzip.go 文件的 main 函数中,我们将创建一个 Int32Value 实例,传递给 compressedSize 函数,并打印原始大小和压缩后的大小:

func main() {
    var data int32 = 268_435_456
    i32 := &wrapperspb.Int32Value{
        Value: data,
    }
    o, c := compressedSize(i32)
    fmt.Printf("original: %d\ncompressed: %d\n", o, c)
}

运行这个代码,我们应该得到以下结果:

$ go run gzip.go
original: 6
compressed: 30

压缩后的负载比原始的还大五倍。这显然是在生产环境中应避免的。显然,大多数时候我们发送的并不是如此简单的消息。让我们来看一个更具体的例子。我们将使用前面书中定义的 Task 消息:

syntax = "proto3";

package todo;
import "google/protobuf/timestamp.proto";
option go_package = "github.com/PacktPublishing/gRPC-Go-forProfessionals/helpers/proto";

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

然后,我们可以使用以下命令编译它:

$ protoc --go_out=. \
    --go_opt=module=github.com/PacktPublishing/gRPC-Go-for-Professionals/helpers \
    proto/todo.proto

接下来,我们创建一个 Task 实例,并传递给 compressedSize 函数,以查看压缩的结果:

func main() {
    task := &pb.Task{
        Id: 1,
        Description: "This is a task",
        DueDate: timestamppb.New(time.Now().Add(5 * 24 * time.Hour)),
    }
    o, c := compressedSize(task)
    fmt.Printf("original: %d\ncompressed: %d\n", o, c)
}

如果我们运行它,我们会得到以下大小:

$ go run gzip.go
original: 32
compressed: 57

虽然比之前的例子好一些,但仍然不够高效,因为我们发送的字节数比实际需要的要多。因此,在我们看到的这些情况下,使用 gzip 压缩并不合理。

最后,让我们来看一个压缩有用的例子。假设我们大多数 Task 实例的描述都很长。例如,我们可以有如下的任务描述:

task := &pb.Task{
    //...
    Description: `This is a task that is quite long and requires a lot
    of work.
    We are not sure we can finish it even after 5 days.
    Some planning will be needed and a meeting is required.`,
    //...
}

然后,运行 compressedSize 函数,我们会得到以下大小:

$ go run gzip.go
original: 192
compressed: 183

从这个例子中我们可以得出一个结论:在启用 gzip 压缩之前,我们需要了解我们的数据。

接下来,让我们看看如何启用 gzip 压缩。

在服务器端(server/main.go),这非常简单,只需添加以下导入:

_ "google.golang.org/grpc/encoding/gzip"

请注意,我们在导入前加了一个下划线,以避免编译器提示我们没有使用该导入。

这就是服务器端的所有操作。在客户端,虽然代码稍微多一点,但也是很简单的。我们可以通过添加 DialOption 来启用所有 RPC 端点的压缩,或者通过添加 CallOption 来为单个端点启用压缩( https://pkg.go.dev/google.golang.org/grpc#CallOption )。

对于第一个选项,我们可以简单地添加如下代码:

opts := []grpc.DialOption{
    //...
    grpc.WithDefaultCallOptions(grpc.UseCompressor(gzip.Name)),
}

gzip 添加的导入与服务器端相同,但没有前面的下划线。

对于为单个调用添加压缩,我们可以使用 CallOption。如果我们想为 AddTask 调用添加 gzip 压缩,可以这样写:

res, err := c.AddTask(context.Background(), req, grpc.UseCompressor(gzip.Name))

总结来说,我们看到了一直启用压缩并不是一个好主意,只有在测试过我们的数据后,我们才应该启用压缩。接着,我们看到了如何在服务器端和客户端注册 gzip 压缩器。最后,我们看到了如何全局启用压缩或为单个调用启用压缩。