1. 简介

Protocol Buffers(简称protobuf)提供了一种快速高效的序列化结构化数据的方式。它是JSON的紧凑、高性能替代方案。

与基于文本且需要解析的JSON不同,protobuf会为多种语言生成优化代码。这使得在不同系统间传输结构化数据更加便捷。

使用protobuf时,我们只需在*.proto*文件中定义一次数据结构。然后利用生成的代码处理跨流和平台的数据传输。当处理类型化结构化数据(尤其是负载只有几兆字节)时,protobuf是理想选择。

Protobuf支持字符串、整数、布尔值和浮点数等常见类型,也能很好地处理列表和Map,使复杂数据管理变得简单。本教程将学习如何在protobuf中使用Map。

2. 理解Protobuf中的Map

让我们探索如何在protobuf消息中定义和使用Map。

2.1. 什么是Map?

Map是键值对数据结构,类似于字典

每个键都链接到特定值,使查找快速高效。我们可以将DNS系统作为类比:每个域名指向一个IP地址。Map的工作方式类似。

2.2. 定义Map的语法

Protobuf 3原生支持Map

简单示例如下:

message Dictionary {
    map<string, string> pairs = 1;
}

map<key_type, value_type> 定义字段。键必须是标量类型,如stringint32bool。值可以是任何有效的protobuf类型——标量枚举,甚至是另一个消息。

3. 在Protobuf中实现Map

既然已经了解了使用protobuf的优势,现在通过构建一个食品配送系统将理论付诸实践,其中每个餐厅都有自己的菜单。

3.1. 在代码库中设置Protobuf

在定义消息结构之前,必须将Protoc编译器集成到构建生命周期中。这可以通过在项目的pom.xml文件中配置protobuf-maven-plugin实现。这样,协议缓冲区定义会在Maven构建过程中自动编译成Java类。

将插件配置添加到pom.xml文件的build部分:

<build>
    <plugins>
        <plugin>
              <groupId>org.xolstice.maven.plugins</groupId>
              <artifactId>protobuf-maven-plugin</artifactId>
              <version>0.6.1</version>
            <configuration>
                <protoSourceRoot>${project.basedir}/src/main/proto</protoSourceRoot>
                <protocArtifact>com.google.protobuf:protoc:4.30.2:exe:${os.detected.classifier}</protocArtifact>
            </configuration>
            <executions>
                <execution>
                    <goals>
                        <goal>compile</goal>
                        <goal>test-compile</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

除了编译器,还需要协议缓冲区运行时。将其依赖项添加到Maven POM文件:

<dependency>
    <groupId>com.google.protobuf</groupId>
    <artifactId>protobuf-java</artifactId>
    <version>4.30.2</version>
</dependency>

可以使用其他版本的运行时,只要与编译器版本相同即可。

3.2. 定义包含Map字段的消息

让我们从定义一个包含Map的简单protobuf消息开始。这里创建一个protobuf模式,其中restaurants Map以餐厅名称为键,菜单为值。菜单本身是另一个Map,将食品项映射到价格:

syntax = "proto3";
message Menu {
    map<string, float> items = 1;
}

message FoodDelivery {
    map<string, Menu> restaurants = 1;
}

3.3. 填充Map

既然已在模式中定义了Map,现在需要在代码中填充数据。

*protobuf中的map<k, v>*结构类似Java的HashMap,允许高效存储键值对**。

本例使用*map<string, Menu>*存储餐厅名称作为键,对应菜单项作为值:

Food.Menu pizzaMenu = Food.Menu.newBuilder()
  .putItems("Margherita", 12.99f)
  .putItems("Pepperoni", 14.99f)
  .build();

Food.Menu sushiMenu = Food.Menu.newBuilder()
  .putItems("Salmon Roll", 10.50f)
  .putItems("Tuna Roll", 12.33f)
  .build();

首先定义餐厅菜单。然后用餐厅名称及其菜单填充Map:

Food.FoodDelivery.Builder foodData = Food.FoodDelivery.newBuilder();

先创建Map实例。接着直接放入餐厅并构建Map:

foodData.putRestaurants("Pizza Place", pizzaMenu);
foodData.putRestaurants("Sushi Place", sushiMenu);

return foodData.build();

4. 从二进制文件存储和检索数据

**接下来,将protobuf Map数据写入二进制文件——这个过程称为序列化**。这确保高效存储和便捷传输。当然,我们也会通过反序列化字段将其读回。

4.1. 将Protobuf Map序列化到二进制文件

序列化将结构化数据转换为紧凑的二进制格式,使其轻量且易于存储或网络传输。让我们看看如何实现。

首先定义文件路径:

private final String FILE_PATH = "src/main/resources/foodfile.bin";

然后编写序列化逻辑:

public void serializeToFile(Food.FoodDelivery delivery) {
    try (FileOutputStream fos = new FileOutputStream(FILE_PATH)) {
        delivery.writeTo(fos);
        logger.info("Successfully wrote to the file.");
    } catch (IOException ioe) {
        logger.warning("Error serializing the Map or writing the file");
    }
}

生成的源文件允许直接写入输出流。

4.2. 将二进制文件反序列化为Protobuf Map

现在,将二进制文件反序列化回Protobuf Map。打开输入流,使用Protobuf编译器生成的方法解析存储的数据:

public Food.FoodDelivery deserializeFromFile(Food.FoodDelivery delivery) {
    try (FileInputStream fis = new FileInputStream(FILE_PATH)) {
        return Food.FoodDelivery.parseFrom(fis);
    } catch (FileNotFoundException e) {
        logger.severe(String.format("File not found: %s location", FILE_PATH));
        return Food.FoodDelivery.newBuilder().build();
    } catch (IOException e) {
        logger.warning(String.format("Error reading file: %s location", FILE_PATH));
        return Food.FoodDelivery.newBuilder().build();
    }
}

*打开文件输入流并传递给parseFrom()*方法,该方法重建protobuf对象。如果文件缺失或为空,则记录问题**。

4.3. 反序列化后显示结果

反序列化数据后,显示结果:

public void displayRestaurants(Food.FoodDelivery delivery) {
    Map<String, Food.Menu> restaurants = delivery.getRestaurantsMap();
    for (Map.Entry<String, Food.Menu> restaurant : restaurants.entrySet()) {
        logger.info(String.format("Restaurant: %s", restaurant.getKey()));
        restaurant.getValue()
          .getItemsMap()
          .forEach((menuItem, price) -> logger.info(String.format(" - %s costs $ %.2f", menuItem, price)));
    }
}

这里显示存储的数据。由于Map以餐厅名称为键,对应菜单为值,只需遍历数据并记录每个餐厅及其菜单项和价格。

5. 测试实现

为确保Protobuf Map操作正确,验证序列化是否正确写入文件,并确认反序列化是否正确恢复原始数据

还需要捕获日志输出并检查是否记录了预期数据。首先验证序列化是否正确:

@Test
void givenProtobufObject_whenSerializeToFile_thenFileShouldExist() {
    foodDelivery.serializeToFile(testData);
    File file = new File(FILE_PATH);
    assertTrue(file.exists(), "Serialized file should exist");
}

验证序列化后,测试反序列化:

@Test
void givenSerializedFile_whenDeserialize_thenShouldMatchOriginalData() {
    foodDelivery.serializeToFile(testData);
    Food.FoodDelivery deserializedData = foodDelivery.deserializeFromFile(testData);
    assertEquals(testData.getRestaurantsMap(), deserializedData.getRestaurantsMap(), "Deserialized data should match the original data");
}

这里先序列化文件,然后检查testData Map是否等于deserializedData的Map。接着验证是否正确记录数据:

@Test
void givenDeserializedObject_whenDisplayRestaurants_thenShouldLogCorrectOutput() {
    foodDelivery.serializeToFile(testData);
    Food.FoodDelivery deserializedData = foodDelivery.deserializeFromFile(testData);
    Logger logger = Logger.getLogger(FoodDelivery.class.getName());
    TestLogHandler testHandler = new TestLogHandler();
    logger.addHandler(testHandler);
    logger.setUseParentHandlers(false);
    foodDelivery.displayRestaurants(deserializedData);
    List<String> logs = testHandler.getLogs();
    assertTrue(logs.stream().anyMatch(log -> log.contains("Restaurant: Pizza Place")),
      "Log should contain 'Restaurant: Pizza Place'");
    assertTrue(logs.stream().anyMatch(log -> log.contains("Margherita costs $ 12.99")),
      "Log should contain 'Margherita costs $ 12.99'");
}

为验证应用程序是否在执行期间记录预期消息,需要一种编程方式捕获和检查日志输出。TestLogHandler通过扩展Java的Handler实现此功能:

static class TestLogHandler extends Handler {
    private final List<String> logMessages = new ArrayList<>();

    @Override
    public void publish(LogRecord record) {
        if (record.getLevel().intValue() >= Level.INFO.intValue()) {
            logMessages.add(record.getMessage());
        }
    }

    @Override
    public void flush() {
    }

    @Override
    public void close() throws SecurityException {
    }

    public List<String> getLogs() {
        return logMessages;
    }
}

它有一个日志消息列表,将每个级别大于等于INFO的LogRecord推入其中。使用列表存储以保持日志在控制台中出现的顺序。

6. 结论

在Protobuf中使用Map为数据模型中的键值关系管理提供了结构化且高效的方式。本文探讨了如何在Java中定义、序列化和反序列化Protobuf Map,确保数据保持紧凑、可读且易于传输。通过实现健壮的单元测试,验证了序列化和反序列化过程正确运行,维护了数据完整性。

通过正确的Maven设置和最佳实践,现在可以自信地将Protobuf Map集成到应用程序中,利用其性能优势,同时保持代码库的整洁和可维护性。

本文相关代码可在GitHub上获取。