1. 概述

本文将深入探讨 Google Protocol Buffers(protobuf)中的 packed repeated 字段。Protocol Buffers 是一种高效的语言中立、平台中立的数据结构序列化方案。在 protobuf 中,repeated 关键字用于定义可容纳多个值的字段。

为提升重复字段的序列化效率,protobuf 引入了 packed 选项。它通过特殊编码技术进一步压缩消息体积,本文将详细解析其原理与实现。

2. 重复字段

在讨论 packed 选项前,我们先理解 repeated 标签的含义。以 repeated.proto 文件为例:

syntax = "proto3";
option java_multiple_files = true;
option java_package = "com.baeldung.grpc.repeated";
package repeated;

message PackedOrder {
  int32 orderId = 1;
  repeated int32 productIds = 2 [packed = true];
}

message UnpackedOrder {
  int32 orderId = 1;
  repeated int32 productIds = 2 [packed = false];
}

service OrderService {
  rpc createOrder(UnpackedOrder) returns (UnpackedOrder){}
}

该文件定义了两个消息类型 PackedOrderUnpackedOrder,以及一个 OrderService 服务。**productIds 字段的 repeated 标签表明它可存储多个整数值,类似于集合或数组**。自 protobuf v2.1.0 起,重复字段默认启用 packed 选项。为演示效果,我们显式设置 packed = false

✅ 关键特性:

  • 修改 packed 选项无需调整业务代码
  • 仅影响序列化时的内部编码方式

实现 OrderService 的 RPC 方法:

public class OrderService extends OrderServiceGrpc.OrderServiceImplBase {
    @Override
    public void createOrder(UnpackedOrder unpackedOrder, StreamObserver<UnpackedOrder> responseObserver) {
        List productIds = unpackedOrder.getProductIdsList();
        if(validateProducts(productIds)) {
            int orderID = insertOrder(unpackedOrder);
            UnpackedOrder createdUnpackedOrder = UnpackedOrder.newBuilder(unpackedOrder)
              .setOrderId(orderID)
              .build();
            responseObserver.onNext(createdUnpackedOrder);
            responseObserver.onCompleted();
        }
    }
}

protoc 插件会自动生成 getProductIdsList() 方法获取重复字段元素列表,无论是否启用 packed 选项。最后生成订单 ID 并返回客户端。

调用 RPC 的测试代码:

@Test
void whenUnpackedRepeatedProductIds_thenCreateUnpackedOrderAndInvokeRPC() {
    UnpackedOrder.Builder unpackedOrderBuilder = UnpackedOrder.newBuilder();
    unpackedOrderBuilder.setOrderId(1);
    Arrays.stream(fetchProductIds()).forEach(unpackedOrderBuilder::addProductIds);
    UnpackedOrder unpackedOrderRequest = unpackedOrderBuilder.build();
    UnpackedOrder unpackedOrderResponse = orderClientStub.createOrder(unpackedOrderRequest);
    assertInstanceOf(Integer.class, unpackedOrderResponse.getOrderId());
}

编译时 protoc 插件会为 UnpackedOrder 生成 Java 类。通过流式处理调用 addProductIds() 填充重复字段。所有重复字段(无论是否打包)都会生成前缀为 add 的方法,最后调用 RPC 获取订单 ID。

3. 打包重复字段

打包重复字段的核心差异在于序列化前的编码方式。先看序列化方法:

void serializeObject(String file, GeneratedMessageV3 object) throws IOException {
    try(FileOutputStream fileOutputStream = new FileOutputStream(file)) {
        object.writeTo(fileOutputStream);
    }
}

该方法调用 GeneratedMessageV3writeTo() 方法将对象序列化到文件。PackedOrderUnpackedOrder 均继承此方法。

对比序列化后的文件大小:

@Test
void whenSerializeUnpackedOrderAndPackedOrderObject_thenSizeofPackedOrderObjectIsLess() throws IOException {
    UnpackedOrder.Builder unpackedOrderBuilder = UnpackedOrder.newBuilder();
    unpackedOrderBuilder.setOrderId(1);
    Arrays.stream(fetchProductIds()).forEach(unpackedOrderBuilder::addProductIds);
    UnpackedOrder unpackedOrder = unpackedOrderBuilder.build();
    String unpackedOrderObjFileName = FOLDER_TO_WRITE_OBJECTS + "unpacked_order.bin";

    serializeObject(unpackedOrderObjFileName, unpackedOrder);

    PackedOrder.Builder packedOrderBuilder = PackedOrder.newBuilder();
    packedOrderBuilder.setOrderId(1);
    Arrays.stream(fetchProductIds()).forEach(packedOrderBuilder::addProductIds);
    PackedOrder packedOrder = packedOrderBuilder.build();
    String packedOrderObjFileName = FOLDER_TO_WRITE_OBJECTS + "packed_order.bin";

    serializeObject(packedOrderObjFileName, packedOrder);
    
    long sizeOfUnpackedOrderObjectFile = getFileSize(unpackedOrderObjFileName);
    long sizeOfPackedOrderObjectFile = getFileSize(packedOrderObjFileName);
    long sizeReductionPercentage = (sizeOfUnpackedOrderObjectFile - sizeOfPackedOrderObjectFile) * 100/sizeOfUnpackedOrderObjectFile;
    logger.info("Packed field saved {}% over unpacked field", sizeReductionPercentage);
    assertTrue(sizeOfUnpackedOrderObjectFile > sizeOfPackedOrderObjectFile);
}

创建包含相同产品 ID 的 unpackedOrderpackedOrder 对象,序列化后比较文件大小。结果符合预期:未打包对象文件大于打包对象文件

控制台输出:

Packed field saved 29% over unpacked field

本例中 20 个产品 ID 的打包版本节省了 29% 空间。随着元素数量增加,压缩率会进一步提升并趋于稳定

⚠️ 注意:packed 选项仅适用于基本数值类型。

4. 编码对比:未打包 vs 打包字段

使用 protoscope 工具 检查序列化文件内容。protoscope 是一种可读的文本格式,用于展示 protobuf 底层线缆格式

查看 unpacked_order.bin

#cat unpacked_order.bin | protoscope -explicit-wire-types
1:VARINT 1
2:VARINT 266
2:VARINT 629
2:VARINT 725
2:VARINT 259
2:VARINT 353
2:VARINT 746
more elements...

输出以键值对展示字段编号和值。**productId(字段编号 2)的每个值都作为独立 VARINT 线缆类型编码**,即每条键值对记录单独编码。

查看 packed_order.bin

#cat packed_order.bin | protoscope -explicit-wire-types -explicit-length-prefixes
1:VARINT 1
2:LEN 38 `fc06c0058e047293069702ea04c203ba0165c005d601da02dc02a307a804f101ca019a02df03`

启用 packed 选项后,所有值被整体编码为单个 LEN 线缆记录(38 字节):

fc 06 c0 05 8e 04 72 93 06 97 02 ea 04 c2 03 ba 01 65 c0 05 d6 01 da 02 dc 02 a3 07 a8 04 f1 01 ca 01 9a 02 df 03

📌 编码细节可参考:

5. 总结

本文深入分析了 protobuf 中重复字段的 packed 选项:

  • 打包字段通过整体编码显著减少体积
  • 提升序列化性能
  • 仅适用于基本数值类型(VARINT/I32/I64)

完整代码示例见 GitHub 仓库


原始标题:Packed Repeated Fields in Protobuf in Java | Baeldung