如何在 Protobuf 中使用 Maps
1. 簡介
協定緩衝區(protobuf)提供了一種快速有效的方法來序列化結構化資料。它們是 JSON 的緊湊、高性能替代品。
與基於文字且需要解析的 JSON 不同,protobuf 針對多種語言產生最佳化的程式碼。這使得在不同系統之間發送結構化資料變得更加容易。
使用 protobuf,我們在.proto
檔案中定義一次資料結構。然後,我們使用產生的程式碼來處理跨流和跨平台的資料傳輸。在處理類型化、結構化資料時,它們是理想的選擇——尤其是當有效載荷只有幾兆位元組時。
Protobuf 支援常見類型,如字串、整數、布林值和浮點數。它們還可以與清單和地圖很好地配合使用,使複雜數據易於管理。在本教程中,我們將學習如何在 protobuf 中使用地圖。
2. 理解 Protobuf 中的 Map
讓我們探索如何定義和使用映射作為 protobuf 訊息的一部分。
2.1.什麼是地圖?
映射是一種鍵值資料結構,類似字典。
每個鍵都連結到一個特定的值,這使得查找變得快速且有效率。我們可以將 DNS 系統類比為:每個網域名稱都指向一個 IP 位址。地圖的工作方式類似。
2.2.定義地圖的語法
Protobuf 3 開箱即用支援地圖。
這是一個簡單的例子:
message Dictionary {
map<string, string> pairs = 1;
}
map<key_type, value_type>
定義欄位。鍵必須是標量型,如string
、 int32
或bool
。該值可以是任何有效的 protobuf 類型 - scalar
、 enum
,甚至是另一個訊息。
3. 在 Protobuf 中實作 Maps
現在我們已經了解了使用 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 欄位定義訊息
讓我們先定義一個包含映射的簡單 protobuf 訊息。在這裡,我們創建一個 protobuf 模式,其中restaurants
地圖將餐廳名稱儲存為按鍵,將其菜單儲存為值。菜單本身是另一張地圖,將食物與其價格對應:
syntax = "proto3"
message Menu {
map<string, float> items = 1;
}
message FoodDelivery {
map<string, Menu> restaurants = 1;
}
3.3.填充地圖
現在我們已經在模式中定義了一個映射,我們需要在程式碼中用資料填充它。
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();
首先,我們定義餐廳的菜單。然後我們在地圖上填充餐廳名稱及其各自的菜單:
Food.FoodDelivery.Builder foodData = Food.FoodDelivery.newBuilder();
我們首先創建地圖的一個實例。接下來,我們只需將餐廳放置到位並建立地圖:
foodData.putRestaurants("Pizza Place", pizzaMenu);
foodData.putRestaurants("Sushi Place", sushiMenu);
return foodData.build();
4. 從二進位檔案儲存和檢索數據
接下來,我們將把protobuf
映射資料寫入二進位檔案 - 這個過程稱為序列化。這確保了高效存儲和輕鬆傳輸。當然,我們也會透過反序列化該欄位來讀取它。
4.1.將 Protobuf 映射序列化為二進位文件
序列化將我們的結構化資料轉換為緊湊的二進位格式,使其輕量且快速地儲存或透過網路發送。讓我們看看如何實現這一點。
我們首先定義要寫入資料的檔案的檔案路徑:
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 映射。我們將首先開啟一個輸入流,然後使用 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)));
}
}
在這裡,我們顯示儲存的資料。由於我們的地圖將所有餐廳名稱作為鍵並將其各自的菜單作為值,因此我們可以簡單地遍歷數據並記錄每個餐廳及其菜單項目和價格。
5.測試我們的實現
為了確保我們的 Protobuf 映射操作正常運作,我們驗證序列化是否正確地將資料寫入文件,並確認反序列化是否正確恢復原始資料。
我們還需要捕獲日誌輸出並檢查是否記錄了預期的數據。因此我們先驗證序列化是否正確發生:
@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
映射是否等於deserializedData
的映射。此後,讓我們驗證是否正確記錄了數據:
@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 中的對應可以提供一種結構化且有效的方法來管理資料模型中的鍵值關係。在本文中,我們探討如何在 Java 中定義、序列化和反序列化 Protobuf 映射,以確保我們的資料保持緊湊、可讀且易於傳輸。透過實施強大的單元測試,我們驗證了序列化和反序列化過程是否正常運行,並保持資料完整性。
透過正確的 Maven 設定和最佳實踐,我們現在可以自信地將 Protobuf 映射整合到我們的應用程式中,利用它們的效能優勢,同時保持我們的程式碼庫清潔且可維護。
本文相關的程式碼可在 GitHub 上找到。