Python 在Python中使用Protocol Buffers基礎介紹

授客發表於2024-10-13

實踐環境

protoc-25.4-win64.zip

下載地址:

https://github.com/protocolbuffers/protobuf/releases

https://github.com/protocolbuffers/protobuf/releases/download/v25.4/protoc-25.4-win64.zip

protobuf 5.27.2

pip install protobuf==5.27.2

Python 3.9.13

問題域

本文將使用的示例是一個非常簡單的“地址簿”應用程式,它可以從檔案中讀取和寫入人們的聯絡方式。通訊簿中的每個人都有一個姓名、一個ID、一個電子郵件地址和一個聯絡電話號碼。

如何序列化和檢索這樣的結構化資料?有幾種方法可以解決這個問題:

使用Python pickle。這是預設方法,因為它內建於語言中,但它不能很好地處理模式演化,如果你需要與用C++或Java編寫的應用程式共享資料,它也不能很好的工作。

你可以發明一種特殊的方法將資料項編碼為單個字串,例如將4個整數編碼為“12:3:-23:67”。這是一種簡單而靈活的方法,儘管它確實需要編寫一次性編碼和解析程式碼,並且解析帶來的執行時成本很小。這最適合對非常簡單的資料進行編碼。

將資料序列化為XML。這種方法非常有吸引力,因為XML(某種程度上)是人類可讀的,並且有許多語言的繫結庫。如果想與其他應用程式/專案共享資料,這可能是一個不錯的選擇。然而,眾所周知,XML是空間密集型的,對其進行編碼/解碼會給應用程式帶來巨大的效能損失。此外訪問XML DOM樹訪問類中的簡單欄位要複雜得多。

可以使用協議緩衝區(Protocol buffers)替代這些選擇。協議緩衝區是解決這個問題的靈活、高效、自動化的解決方案。使用協議緩衝區 ,可以編寫希望儲存的資料結構的.proto描述。協議緩衝區編譯器將從該檔案建立一個類,該類以有效的二進位制格式實現協議緩衝區資料的自動編碼和解析。生成的類為構成協議緩衝區的欄位提供getterssetters方法,並處理將協議緩衝區作為一個單元進行讀寫的細節。重要的是,協議緩衝區格式支援隨著時間的推移擴充套件格式的想法,這樣程式碼仍然可以讀取用舊格式編碼的資料。

定義協議格式(編寫proto檔案)

要建立地址簿應用程式,需要從.proto檔案開始。.proto檔案中的定義很簡單:為要序列化的每個資料結構新增一個訊息(message),然後為訊息中的每個欄位指定名稱和型別。

示例:addressbook.proto

syntax = "proto2"; // proto2指定proto buffer的版本

package tutorial;

message Person {
  optional string name = 1;
  optional int32 id = 2;
  optional string email = 3;

  enum PhoneType {
    PHONE_TYPE_UNSPECIFIED = 0;
    PHONE_TYPE_MOBILE = 1;
    PHONE_TYPE_HOME = 2;
    PHONE_TYPE_WORK = 3;
  }

  message PhoneNumber {
    optional string number = 1;
    optional PhoneType type = 2 [default = PHONE_TYPE_HOME];
  }

  repeated PhoneNumber phones = 4; // phones欄位是一個重複欄位,可以包含多個電話號碼。
}

message AddressBook {
  repeated Person people = 1;
}

說明:

以上這個.proto檔案以package宣告開始,這有助於防止不同專案之間的命名衝突。在Python中,包通常由目錄結構決定,因此在.proto檔案定義的package對生成的程式碼沒有影響。但是,仍然應該宣告一個package,以避免在協議緩衝區名稱空間以及非Python語言中的名稱衝突。

接下來,是訊息定義。訊息只是包含一組型別欄位的集合。許多標準的簡單資料型別可以作為欄位型別使用,包括boolint32floatdoublestring。還可以透過使用其他訊息型別作為欄位型別來為訊息新增更多的結構 - 在上面的示例中,Person訊息包含PhoneNumber訊息,而AddressBook訊息包含Person訊息。甚至可以定義巢狀在其他訊息中的訊息型別-如上,PhoneNumber型別定義在Person中。如果希望其中一個欄位具有預定義的值列表之一,也可以定義列舉型別 - 在這裡希望指定電話號碼可以是以下電話型別之一:

  • PHONE_TYPE_MOBILE
  • PHONE_TYPE_HOME
  • PHONE_TYPE_WORK

每個元素上的“=1”、“=2”標記標識該欄位在二進位制編碼中使用的唯一“標記”,這確保了在序列化和反序列化過程中,‌每個欄位可以被正確地識別和處理。‌這些數字標籤在編譯時被轉換為名稱空間和型別簽名,‌從而保證了欄位的唯一性。使用1-15的標記編號比使用更高的數字要少一個位元組編碼,因此作為最佳化,可以決定將這些標籤用於常用或重複的元素,將16及更高標記編號的用於不太常用的可選元素。重複欄位中的每個元素都需要重新編碼標記號,因此重複欄位特別適合此最佳化。

每個欄位都必須使用以下修飾符之一進行註解:

  • optional:該欄位可以設定,也可以不設定。如果未設定可選欄位值,則使用預設值。對於簡單型別,可以指定自己的預設值,就像示例中為電話號碼type所做的那樣。否則,將使用系統預設值:數字型別的預設值為零,字串型別的預設值為空字串,布林型別的預設值為false。對於嵌入式訊息,預設值始終是訊息的“預設例項”或“原型”,其沒有設定任何欄位。呼叫訪問器以獲取尚未顯式設定的可選(或必需)欄位的值時,始終返回該欄位的預設值。
  • repeated:該欄位可以重複任意多次(包括零次),表示該欄位可以包含多個值。將重複欄位視為動態大小的陣列,重複值的順序將在協議緩衝區中保留。
  • required:必須提供該欄位的值,否則該訊息將被視為“未初始化”。序列化未初始化的訊息將引發異常。解析未初始化的訊息將失敗。除此之外,必需欄位的行為與可選欄位完全相同。

重要

required是永久的,在將欄位標記為required時應非常小心。如果在某個時候希望停止編寫或傳送必需欄位,將該欄位更改為可選欄位將很成問題 - 舊的讀取器會認為沒有此欄位的訊息不完整,並可能會意外地拒絕或刪除它們。你應該考慮為協議緩衝區編寫特定於應用程式的自定義驗證例程。在Google 強烈不贊成使用required欄位;在 proto2 語法中定義的大多數訊息僅使用optionalrepeated。(Proto3 根本不支援required欄位。)

編譯協議緩衝區

現在有了.proto,接下來需要做的就是生成讀寫 AddressBook(以及 PersonPhoneNumber)訊息所需的類。為此,需要在 .proto 上執行協議緩衝區編譯器 protoc

1、下載protoc後解壓,將protoc所在bin目錄路徑新增到系統環境變數

>protoc --version
libprotoc 25.4

2、現在執行編譯器,指定源目錄(應用程式原始碼所在的位置 - 如果未提供值,則使用當前目錄)、目標目錄(希望生成的程式碼的儲存目錄;通常與 $SRC_DIR 相同)和 .proto 的路徑。如下:

protoc -I=$SRC_DIR --python_out=$DST_DIR $SRC_DIR/addressbook.proto

因為想要 Python 類,所以使用 --python_out 選項 - 為其他受支援的語言提供了類似的選項。

protoc還可以使用--pyi_out生成python存根(.pyi)。

這會在你指定的目標目錄中生成對應的xxxx_pb2.py

實踐:cmd開啟控制檯,進入到addressbook.proto3所在目錄,然後執行以下命令

protoc --python_out=. addressbook.proto2

命令執行成功後,會再當前目錄下生成與.proto2檔案同名目錄(例中為addressbook),目錄下自動生成對應的py檔案(例中為proto2_pb2.py,實踐時將其複製到addressbook.proto2所在目錄並從命名為addressbook_pb2.py

協議緩衝區 API

與生成 Java 和 C++ 協議緩衝區程式碼不同,Python 協議緩衝區編譯器不會直接為你生成資料訪問程式碼。相反(如果你檢視 addressbook_pb2.py,你就會看到),它會為你的所有訊息、列舉和欄位生成特殊描述符,以及一些神秘的空類,每個訊息型別一個類。

# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler.  DO NOT EDIT!
# source: addressbook.proto2
# Protobuf Python Version: 4.25.4
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder
# @@protoc_insertion_point(imports)

_sym_db = _symbol_database.Default()




DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x12\x61\x64\x64ressbook.proto2\x12\x08tutorial\"\xa3\x02\n\x06Person\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\n\n\x02id\x18\x02 \x01(\x05\x12\r\n\x05\x65mail\x18\x03 \x01(\t\x12,\n\x06phones\x18\x04 \x03(\x0b\x32\x1c.tutorial.Person.PhoneNumber\x1aX\n\x0bPhoneNumber\x12\x0e\n\x06number\x18\x01 \x01(\t\x12\x39\n\x04type\x18\x02 \x01(\x0e\x32\x1a.tutorial.Person.PhoneType:\x0fPHONE_TYPE_HOME\"h\n\tPhoneType\x12\x1a\n\x16PHONE_TYPE_UNSPECIFIED\x10\x00\x12\x15\n\x11PHONE_TYPE_MOBILE\x10\x01\x12\x13\n\x0fPHONE_TYPE_HOME\x10\x02\x12\x13\n\x0fPHONE_TYPE_WORK\x10\x03\"/\n\x0b\x41\x64\x64ressBook\x12 \n\x06people\x18\x01 \x03(\x0b\x32\x10.tutorial.Person')

_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'addressbook.proto2_pb2', _globals)
if _descriptor._USE_C_DESCRIPTORS == False:
  DESCRIPTOR._options = None
  _globals['_PERSON']._serialized_start=33
  _globals['_PERSON']._serialized_end=324
  _globals['_PERSON_PHONENUMBER']._serialized_start=130
  _globals['_PERSON_PHONENUMBER']._serialized_end=218
  _globals['_PERSON_PHONETYPE']._serialized_start=220
  _globals['_PERSON_PHONETYPE']._serialized_end=324
  _globals['_ADDRESSBOOK']._serialized_start=326
  _globals['_ADDRESSBOOK']._serialized_end=373
# @@protoc_insertion_point(module_scope)

每個類中的重要行是 __metaclass__ = reflection.GeneratedProtocolMessageType。可以將它們視為建立類的模板。在載入時,GeneratedProtocolMessageType 元類使用指定的描述符來建立使用每種訊息型別所需的所有 Python 方法,並將它們新增到相關的類中。然後可以在程式碼中使用完全填充的類。

所有這一切的最終效果是,你可以使用 Person 類,就好像它將 Message 基類的每個欄位定義為常規欄位一樣。例如:

import addressbook_pb2
person = addressbook_pb2.Person()
person.id = 1234
person.name = "John Doe"
person.email = "jdoe@example.com"
phone = person.phones.add()
phone.number = "555-4321"
phone.type = addressbook_pb2.Person.PHONE_TYPE_HOME

注意,這些賦值不僅僅是向通用 Python 物件新增任意新欄位。如果你嘗試分配 .proto 檔案中未定義的欄位,則會引發 AttributeError。如果你將欄位分配給錯誤型別的值,則會引發 TypeError。此外,在設定欄位之前讀取欄位的值會返回預設值。

列舉

元類將列舉擴充套件為一組具有整數值的符號常量。因此,例如,常量 addressbook_pb2.Person.PhoneType.PHONE_TYPE_WORK 的值為 2。

標準訊息方法

每個訊息類還包含許多其他方法,讓你可以檢查或操作整個訊息,包括:

  • IsInitialized(): 檢查是否已設定所有必需的欄位。
  • __str__():返回訊息的可讀表示形式,特別適用於除錯。(通常這樣呼叫 str(message)print(message)
  • CopyFrom(other_msg):使用給定訊息的值覆蓋訊息。
  • Clear():清除所有元素,使其返回到空狀態。

這些方法實現了 Message 介面。有關更多資訊,請參閱 Message 的完整 API 文件

解析和序列化

每個協議緩衝區類都具有使用協議緩衝區二進位制格式來寫入和讀取所選型別訊息的方法。這些方法包括:

  • SerializeToString():序列化訊息並將其作為字串返回。注意,bytes是二進位制的,不是文字;僅將 str 型別用作方便的容器。
  • ParseFromString(data):從給定的字串解析訊息。

這些只是用於解析和序列化的選擇中的一部分。同樣,請參閱Message API 參考以獲取完整列表。

重要

協議緩衝區和麵向物件設計 協議緩衝區類基本上是資料持有者(如 C 中的結構),不提供其他功能;它們在物件模型中不是好的首要公民。如果想為生成的類新增更豐富的行為,最好的方法是將生成的協議緩衝區類包裝在特定於應用程式的類中。如果你無法控制 .proto 檔案的設計(例如,如果正在複用來自另一個專案的檔案),那麼包裝協議緩衝區也是一個好主意。在這種情況下,您可以使用包裝器類來構建更適合你應用程式的獨特環境的介面:隱藏一些資料和方法,公開便捷功能等。絕不應透過繼承生成的類繼承來向它們新增行為。這會破壞內部機制,而且無論如何也不是好的物件導向實踐。

編寫訊息

假設希望通訊錄應用程式能夠做到的第一件事就是將個人詳細資訊寫入通訊錄檔案。為此,需要建立和填充協議緩衝區類的例項,然後將它們寫入輸出流。

這裡示例程式碼從檔案中讀取 AddressBook,根據使用者輸入向其中新增一個新 Person,然後將新的 AddressBook 再次寫回檔案。直接呼叫或引用協議編譯器生成的程式碼的部分已突出顯示。

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

import addressbook_pb2
import os

def PromptForAddress(person):
    '''基於使用者輸入填充Person訊息'''

    person.id = int(input('Enter person ID number: '))
    person.name = input('Enter name: ')

    email = input('Enter email address (blank for none): ')
    if email != '':
        person.email = email

    while True:
        number = input('Enter a phone number (or leave blank to finish): ')
        if number == '':
            break

        phone_number = person.phones.add()
        phone_number.number = number

        phone_type = input('Is this a mobile, home, or work phone? ')
        if phone_type == 'mobile':
            phone_number.type = addressbook_pb2.Person.PhoneType.PHONE_TYPE_MOBILE
        elif phone_type == 'home':
            phone_number.type = addressbook_pb2.Person.PhoneType.PHONE_TYPE_HOME
        elif phone_type == 'work':
            phone_number.type = addressbook_pb2.Person.PhoneType.PHONE_TYPE_WORK
        else:
            print('Unknown phone type; leaving as default value.')


address_book = addressbook_pb2.AddressBook()

# 讀取已存在地址簿
if os.path.exists('my_addressbook.db'):
    with open('my_addressbook.db', 'rb') as f:
        address_book.ParseFromString(f.read())

# 新增一個通訊地址
PromptForAddress(address_book.people.add())

# 將通訊地址寫到磁碟
with open('my_addressbook.db', 'wb') as f:
    f.write(address_book.SerializeToString())

執行程式後按提示輸入內容,形如以下

Enter person ID number: 1
Enter name: shouke
Enter email address (blank for none): shouke@163.com
Enter a phone number (or leave blank to finish): 15813735565
Is this a mobile, home, or work phone? mobile
Enter a phone number (or leave blank to finish): 

讀取訊息

此示例讀取上述示例建立的檔案,並列印其中的所有資訊

# -*- coding:utf-8 -*-

import addressbook_pb2

def ListPeople(address_book):
  '''遍歷地址簿中的所有people並列印相關資訊'''

  for person in address_book.people:
    print('Person ID: ', person.id)
    print('Name: ', person.name)
    if person.HasField('email'):
      print('E-mail address: ', person.email)

    for phone_number in person.phones:
      if phone_number.type == addressbook_pb2.Person.PhoneType.PHONE_TYPE_MOBILE:
        print('Mobile phone #: ', end='')
      elif phone_number.type == addressbook_pb2.Person.PhoneType.PHONE_TYPE_HOME:
        print('Home phone #: ', end='')
      elif phone_number.type == addressbook_pb2.Person.PhoneType.PHONE_TYPE_WORK:
        print('Work phone #: ', end='')
      print(phone_number.number)



address_book = addressbook_pb2.AddressBook()

# 讀取已存在地址簿
with open('my_addressbook.db', 'rb') as f:
  address_book.ParseFromString(f.read())

ListPeople(address_book)

執行輸出:

Person ID:  1
Name:  shouke
E-mail address:  shouke@163.com
Mobile phone #: 15813735565

另一個示例

例子中,定義了一個名為Device的訊息,它有4個欄位:namepricetypelabels

device.proto

syntax = "proto3";
message Device {
  string name = 1;
  int32 price = 2;
  string type = 3;
  map<string, string> labels = 15;
}

根據device.proto檔案生成python檔案

protoc --python_out=. device.proto

自動在當前目錄下生成device目錄及device/proto3_pb2.py檔案

使用生成的py檔案(複製上述py檔案並重新命名為device_pb2.py,和以下檔案存放在同級目錄)

my_test.py

# -*- coding:utf-8 -*-

import device_pb2

# 建立一個Person物件並設定欄位值
device = device_pb2.Device()
device.name = '聯想小星'
device.price =  3999
device.type = 'Notebook'
device.labels['color'] = 'red'
device.labels['outlook'] = 'fashionable'


# 序列化Person物件為二進位制字串
serialized_device = device.SerializeToString()
print(f"序列化後的資料:{serialized_device}")

# 反序列化二進位制字串為一個新的Person物件
new_device = device_pb2.Device()
new_device.ParseFromString(serialized_device)

# 輸出新的Device物件的欄位值
print(type(new_device.labels)) # <class 'google._upb._message.ScalarMapContainer'>
for label, value in new_device.labels.items():
    print(label, value) # 輸出內容形如:color red

print(new_device.labels) # {'color': 'red', 'outlook': 'fashionable'}

print(f'反序列化後的資料:裝置名稱={new_device.name}, 價格={new_device.price}, 型別={new_device.type}, 標籤={new_device.labels}')
# 輸出:反序列化後的資料:裝置名稱=聯想小星, 價格=3999, 型別=Notebook, 標籤={'color': 'red', 'outlook': 'fashionable'}

參考連結

https://protobuf.dev/getting-started/pythontutorial/

https://protobuf.com.cn/getting-started/pythontutorial/

https://protobuf.dev/programming-guides/proto3/

相關文章