选择正确的字段标签
正如你所知道的,字段标签与实际数据一起序列化,以便 Protobuf
知道将数据反序列化到哪个字段。而由于这些标签是以 varint
编码的,标签越大,对序列化数据大小的影响也越大。在这一部分,我们将讨论两个你必须考虑的问题,以确保这些标签不会过多地影响你的有效载荷大小。
必需/可选
如果你了解这个权衡,使用较大的字段标签可能是可以接受的。一个常见的处理大标签的方式是将它们用于可选字段。可选字段意味着它们很少被填充数据,并且由于 Protobuf 不序列化未填充的字段,因此标签本身不会被序列化。然而,当我们偶尔填充这些字段时,就会产生一定的成本。
这种设计的一个优势是,可以将相关的信息放在一起,而不需要创建大量消息来保持字段标签的小巧。这样可以让代码更易于阅读,并让开发者清楚自己可以填充哪些字段。
然而,缺点是,如果你创建的是面向用户的 API,你可能会经常遇到成本增加的情况。这可能是因为用户不了解如何正确使用你的 API,或者只是因为用户有特定的需求。在公司环境中也可能发生这种情况,但可以通过资深软件工程师或内部文档来减轻这种情况。
让我们来看一个大标签带来的缺点示例。为了说明问题,假设我们有以下消息(helpers/tags.proto
):
message Tags { int32 tag = 1; int32 tag2 = 16; int32 tag3 = 2048; int32 tag4 = 262_144; int32 tag5 = 33_554_432; int32 tag6 = 536_870_911; }
protobuf
请注意,这些数字并不是随机的。如果你记得在 Protobuf
入门时,我解释过标签是以 varint
编码的。这些数字是当标签序列化时需要额外一个字节的阈值。
接下来,我们将计算逐步设置字段值后消息的大小。我们从一个空对象开始,然后依次设置 tag
、tag2
等。还需要注意的是,我们将为所有字段设置相同的值(1)。这样可以展示仅序列化标签时的开销。
在 helpers/tags.go
中,我们有如下代码:
package main
import (
"fmt"
"log"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
pb "github.com/PacktPublishing/gRPC-Go-for-Professionals/helpers/proto"
)
func serializedSize[M protoreflect.ProtoMessage](msg M) int {
out, err := proto.Marshal(msg)
if err != nil {
log.Fatal(err)
}
return len(out)
}
func main() {
t := &pb.Tags{}
tags := []int{1, 16, 2048, 262_144, 33_554_432, 536_870_911}
fields := []*int32{&t.Tag, &t.Tag2, &t.Tag3, &t.Tag4, &t.Tag5, &t.Tag6}
sz := serializedSize(t)
fmt.Printf("0 - %d\n", sz)
for i, f := range fields {
*f = 1
sz := serializedSize(t)
fmt.Printf("%d - %d\n", tags[i], sz-(i+1))
}
}
go
我们重用了之前看到的 serializedSize
函数。我们通过解引用字段的指针来设置字段,然后计算包含新字段的 Tag
消息的大小,并打印结果。为了公平起见,我们对结果做了一些调整,只显示标签的字节数。我们通过从大小中减去 i+1
来操作,因为 i
是从 0 开始索引的(所以 +1)。因此,实际上我们是减去已经设置的字段数量,这也是序列化数据时不包含标签的大小(值为 1 时,标签占 1 字节)。
如果我们运行这段代码,输出会是:
$ go run tags.go Tag Bytes ---- ---- 0 0 1 1 16 3 2048 6 262144 10 33554432 15 536870911 20
bash
这告诉我们,每次通过一个阈值时,我们的序列化数据中会多一个字节的开销。开始时,我们有一个空消息,所以得到的是 0 字节,然后我们设置标签为 1,它被序列化成 1 字节,接着是标签为 2 序列化为 2 字节,依此类推。我们可以通过查看两行之间的差异来得到开销。例如,设置标签为 2048 的字段的开销,比设置标签为 16 的字段要多 3 字节(6 – 3 字节)。
总结来说,我们需要为最常填充或必填的字段保留较小的字段标签。因为这些标签几乎总是会被序列化,我们希望最小化标签序列化的影响。对于可选字段,我们可以使用较大的标签,以便将相关字段放在一起,从而避免重复的有效载荷增加。
拆分消息
通常,我们倾向于拆分消息,以保持较小的对象和更少的字段,从而减少标签的大小。这使我们能够将信息组织成实体,理解给定信息的含义。例如,我们的 Task
消息就是这种做法的一个例子。它将信息分组,并且我们可以在其他地方重用该实体,例如在 UpdateTasksRequest
中,将一个完整的 Task
作为请求传递。
然而,虽然将信息拆分成实体是很有意义的,但这并不是没有代价的。使用用户定义类型会影响你的有效载荷的大小。让我们来看一个拆分消息的例子,以及这如何影响序列化数据的大小。这个例子展示了拆分消息时的大小开销。为了说明这一点,我们将创建一个包含名字和名字包装的消息。第一次我们只设置字符串,第二次我们只设置包装器。以下是我所指的这种消息:
message ComplexName { string name = 1; } message Split { string name = 1; ComplexName complex_name = 2; }
protobuf
现在,让我们先不考虑这个例子的实际用途。我们只是想证明拆分消息会有开销。
接下来,我们将编写一个 main
函数,首先设置名字的值,然后计算其大小并打印出来。然后,我们清除名字字段,设置 ComplexName.name
字段,再计算其大小并打印。如果有开销,大小应该是不同的。在 helpers/split.go
中,我们有如下代码:
package main
import (
"fmt"
"log"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
pb "github.com/PacktPublishing/gRPC-Go-for-Professionals/helpers/proto"
)
func serializedSize[M protoreflect.ProtoMessage](msg M) int {
out, err := proto.Marshal(msg)
if err != nil {
log.Fatal(err)
}
return len(out)
}
func main() {
s := &pb.Split{Name: "Packt"}
sz := serializedSize(s)
fmt.Printf("With Name: %d\n", sz)
s.Name = ""
s.ComplexName = &pb.ComplexName{Name: "Packt"}
sz = serializedSize(s)
fmt.Printf("With ComplexName: %d\n", sz)
}
go
如果我们运行这段代码,应该得到如下结果:
$ go run split.go With Name: 7 With ComplexName: 9
bash
这两个大小确实不同。那么差异在哪里呢?差异在于,用户定义的类型是以 “长度限定” 类型(Length-Delimited)进行序列化的。在我们的例子中,简单的名字会序列化为 0a 05 50 61 63 6b 74
。其中,0a
是表示 “长度限定”+标签 1 的线型类型,其余的是字符。但对于复杂类型,序列化结果是 12 07 0a 05 50 61 63 6b 74
。我们可以识别出最后 7 个字节,但前面有两个字节:12
是表示 “长度限定”+标签 2 的线型类型,07
是接下来字节的长度。
总结来说,我们再次面临一个权衡。消息中的标签越多,产生有效载荷大小开销的可能性就越大。然而,如果我们尝试拆分消息以保持标签小,那么也会增加开销,因为数据将被序列化为 “长度限定” 数据。
改进 UpdateTasksRequest
为了总结我们在上一节学到的内容,我们将改进 UpdateTasksRequest
的序列化大小。这一点很重要,因为该消息的使用上下文决定了其序列化开销。因为这是一个客户端流式 RPC 接口,客户端会发送零次或多次此消息。也就是说,任何序列化数据大小的开销都会被客户端发送该消息的次数所放大。
以下代码在随附的 GitHub 仓库中可以找到。你可以在 |
如果我们查看当前的消息定义,代码如下:
message UpdateTasksRequest { Task task = 1; }
protobuf
这正是我们想要的,但现在我们知道,某些额外的字节将因为子消息的存在而被序列化。为了解决这个问题,我们可以简单地复制用户可以修改的字段以及描述要更新的任务的 ID。这样,我们就可以得到以下定义:
message UpdateTasksRequest { uint64 id = 1; string description = 2; bool done = 3; google.protobuf.Timestamp due_date = 4; }
protobuf
这与 Task
消息的定义是一样的。
现在,您可能会认为我们是在重复自己,浪费时间这样做。然而,这样做有两个重要的好处:
-
我们不再需要因序列化用户定义类型而产生的开销。每个请求我们节省了 2 个字节(标签 + 类型和长度)。
-
我们现在可以更好地控制用户可能更新的字段。如果我们不希望用户再修改
due_date
,我们只需要从UpdateTasksRequest
消息中删除它,并保留标签 4。
为了证明这种方法在序列化数据大小方面更高效,我们可以暂时修改 server/impl.go
中的 UpdateTasks
函数,以便在 第 5 章 和 第 6 章 中都能使用它。为了计算有效载荷的大小,我们可以使用之前提到的 proto.Marshal
并累加总的序列化大小。最终,我们可以在接收到 EOF
时将结果打印到终端。
在 第 6 章 中,这样看起来是这样的:
func (s *server) UpdateTasks(stream pb.TodoService_UpdateTasksServer) error {
totalLength := 0
for {
req, err := stream.Recv()
if err == io.EOF {
log.Println("TOTAL: ", totalLength)
return stream.SendAndClose(&pb.UpdateTasksResponse{})
}
if err != nil {
return err
}
out, _ := proto.Marshal(req)
totalLength += len(out)
s.d.updateTask(
req.Id,
req.Description,
req.DueDate.AsTime(),
req.Done,
)
}
}
go
对于 第 5 章,这样会导致每个请求通过网络发送 56 字节的数据,而在 第 6 章 中,我们只发送 50 字节的数据。虽然这看起来在小规模时可能微不足道,但一旦我们接收到流量,它会迅速累积,并影响我们的成本。