Protocol Buffer 基礎知識:Python

適用於 Python 程式設計人員的 Protocol Buffer 基本簡介。

本教學課程為 Python 程式設計人員提供 Protocol Buffer 的基本簡介。透過逐步建立簡單的範例應用程式,向您展示如何:

  • .proto 檔案中定義訊息格式。
  • 使用 Protocol Buffer 編譯器。
  • 使用 Python Protocol Buffer API 寫入和讀取訊息。

這並非 Python 中 Protocol Buffer 的完整使用指南。如需更詳細的參考資訊,請參閱Protocol Buffer 語言指南 (proto2)Protocol Buffer 語言指南 (proto3)Python API 參考資料Python 產生程式碼指南,以及編碼參考資料

問題領域

我們要使用的範例是一個非常簡單的「通訊錄」應用程式,可以將人員的聯絡方式讀取和寫入檔案。通訊錄中的每個人都有姓名、ID、電子郵件地址和聯絡電話號碼。

您要如何序列化和擷取這類結構化資料?有幾種方法可以解決這個問題:

  • 使用 Python 序列化。這是預設方法,因為它內建於語言中,但無法妥善處理結構描述演變,而且如果您需要與以 C++ 或 Java 撰寫的應用程式共用資料,效果也不佳。
  • 您可以發明一種臨時方法,將資料項目編碼成單一字串,例如將 4 個整數編碼為「12:3:-23:67」。這是一種簡單且彈性的方法,但確實需要編寫一次性的編碼和剖析程式碼,而且剖析會產生少許執行階段成本。這最適合用於編碼非常簡單的資料。
  • 將資料序列化為 XML。這種方法可能非常吸引人,因為 XML (在某種程度上) 是人類可讀的,而且有許多語言的繫結程式庫。如果您想要與其他應用程式/專案共用資料,這可能是個不錯的選擇。但是,XML 以空間密集而聞名,而且編碼/解碼可能會對應用程式造成巨大的效能損失。此外,導覽 XML DOM 樹狀結構比導覽類別中的簡單欄位通常要複雜得多。

除了這些選項之外,您還可以使用 Protocol Buffer。Protocol Buffer 是彈性、效率高且自動化的解決方案,可以準確地解決這個問題。使用 Protocol Buffer,您可以編寫要儲存的資料結構的 .proto 描述。Protocol Buffer 編譯器會從中建立一個類別,以有效率的二進位格式實作 Protocol Buffer 資料的自動編碼和剖析。產生的類別提供構成 Protocol Buffer 的欄位的 Getter 和 Setter,並處理讀取和寫入 Protocol Buffer 作為單元的詳細資訊。重要的是,Protocol Buffer 格式支援隨著時間推移擴充格式的概念,以便程式碼仍然可以讀取以舊格式編碼的資料。

範例程式碼的位置

範例程式碼包含在原始碼套件的「examples」目錄下。在此處下載。

定義您的協定格式

若要建立您的通訊錄應用程式,您需要從 .proto 檔案開始。.proto 檔案中的定義很簡單:您為要序列化的每個資料結構新增一個訊息,然後為訊息中的每個欄位指定名稱和類型。以下是定義訊息的 .proto 檔案 addressbook.proto

syntax = "proto2";

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;
}

message AddressBook {
  repeated Person people = 1;
}

如您所見,語法與 C++ 或 Java 類似。讓我們逐步瀏覽檔案的每個部分,看看它的作用。

.proto 檔案以套件宣告開頭,這有助於防止不同專案之間的命名衝突。在 Python 中,套件通常由目錄結構決定,因此您在 .proto 檔案中定義的 package 對產生的程式碼沒有任何影響。但是,您仍然應該宣告一個套件,以避免在 Protocol Buffer 名稱空間以及非 Python 語言中發生名稱衝突。

接下來,您會看到訊息定義。訊息只是一個包含一組類型化欄位的彙總。許多標準的簡單資料類型可用作欄位類型,包括 boolint32floatdoublestring。您也可以使用其他訊息類型作為欄位類型,為訊息新增更多結構 - 在上面的範例中,Person 訊息包含 PhoneNumber 訊息,而 AddressBook 訊息包含 Person 訊息。您甚至可以在其他訊息內定義巢狀訊息類型 - 如您所見,PhoneNumber 類型是在 Person 內定義的。如果您希望欄位之一具有預先定義的值清單,也可以定義 enum 類型 - 在此處,您想要指定電話號碼可以是以下電話類型之一:PHONE_TYPE_MOBILEPHONE_TYPE_HOMEPHONE_TYPE_WORK

每個元素上的 " = 1"、" = 2" 標記識別該欄位在二進位編碼中使用的唯一「標籤」。標籤編號 1-15 比更高的編號需要少一個位元組來編碼,因此作為最佳化,您可以決定將這些標籤用於常用或重複的元素,將標籤 16 和更高的編號留給較不常用的選用元素。重複欄位中的每個元素都需要重新編碼標籤編號,因此重複欄位特別適合用於此最佳化。

每個欄位都必須使用下列其中一個修飾詞進行註解:

  • optional:欄位可能會或可能不會設定。如果未設定選用欄位值,則會使用預設值。對於簡單類型,您可以指定自己的預設值,就像我們在範例中對電話號碼 type 所做的那樣。否則,會使用系統預設值:數值類型為零、字串為空字串、布林值為 false。對於內嵌訊息,預設值始終是訊息的「預設執行個體」或「原型」,其中沒有設定任何欄位。呼叫存取器以取得未明確設定的選用 (或必要) 欄位的值,始終會傳回該欄位的預設值。
  • repeated:欄位可能會重複任意次數 (包括零次)。重複值的順序將保留在 Protocol Buffer 中。將重複欄位視為動態調整大小的陣列。
  • required:必須提供欄位的值,否則訊息將被視為「未初始化」。序列化未初始化的訊息會引發例外狀況。剖析未初始化的訊息將會失敗。除此之外,必要欄位的行為與選用欄位完全相同。

您可以在Protocol Buffer 語言指南中找到撰寫 .proto 檔案的完整指南,包括所有可能的欄位類型。不過,不要尋找類似於類別繼承的功能 - Protocol Buffer 不會執行該功能。

編譯您的 Protocol Buffers

現在您有了 .proto,接下來需要做的是產生您需要讀取和寫入 AddressBook (因此也包括 PersonPhoneNumber) 訊息的類別。若要執行此操作,您需要在您的 .proto 上執行 Protocol Buffer 編譯器 protoc

  1. 如果您尚未安裝編譯器,請下載套件並按照 README 中的指示操作。

  2. 現在執行編譯器,指定來源目錄 (應用程式的原始碼所在位置 - 如果您未提供值,則會使用目前目錄)、目的地目錄 (您希望產生的程式碼前往的位置;通常與 $SRC_DIR 相同) 以及 .proto 的路徑。在這種情況下,您…

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

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

    Protoc 也能夠使用 --pyi_out 產生 Python Stub (.pyi)。

這會在您指定的目的地目錄中產生 addressbook_pb2.py (或 addressbook_pb2.pyi)。

Protocol Buffer API

與您產生 Java 和 C++ Protocol Buffer 程式碼不同,Python Protocol Buffer 編譯器不會直接為您產生資料存取程式碼。相反地 (如果您查看 addressbook_pb2.py 就會看到),它會為您的所有訊息、列舉和欄位產生特殊的描述元,以及一些神祕的空類別,每個訊息類型一個

import google3
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import runtime_version as _runtime_version
from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder
_runtime_version.ValidateProtobufRuntimeVersion(
    _runtime_version.Domain.GOOGLE_INTERNAL,
    0,
    20240502,
    0,
    '',
    'main.proto'
)
# @@protoc_insertion_point(imports)

_sym_db = _symbol_database.Default()

DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\nmain.proto\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, 'google3.main_pb2', _globals)
if not _descriptor._USE_C_DESCRIPTORS:
  DESCRIPTOR._loaded_options = None
  _globals['_PERSON']._serialized_start=25
  _globals['_PERSON']._serialized_end=316
  _globals['_PERSON_PHONENUMBER']._serialized_start=122
  _globals['_PERSON_PHONENUMBER']._serialized_end=210
  _globals['_PERSON_PHONETYPE']._serialized_start=212
  _globals['_PERSON_PHONETYPE']._serialized_end=316
  _globals['_ADDRESSBOOK']._serialized_start=318
  _globals['_ADDRESSBOOK']._serialized_end=365
# @@protoc_insertion_point(module_scope)

每個類別中的重要行是 __metaclass__ = reflection.GeneratedProtocolMessageType。雖然 Python Metaclass 的運作方式細節超出本教學課程的範圍,但您可以將它們視為建立類別的範本。在載入時,GeneratedProtocolMessageType Metaclass 會使用指定的描述元來建立您需要使用的每個訊息類型的所有 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。此外,在設定欄位之前讀取欄位的值會傳回預設值。

person.no_such_field = 1  # raises AttributeError
person.id = "1234"        # raises TypeError

如需 Protocol 編譯器為任何特定欄位定義產生的確切成員的詳細資訊,請參閱Python 產生程式碼參考資料

列舉

列舉會由 Metaclass 展開為一組具有整數值的符號常數。因此,例如,常數 addressbook_pb2.Person.PhoneType.PHONE_TYPE_WORK 的值為 2。

標準訊息方法

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

  • IsInitialized():檢查是否已設定所有必要欄位。
  • __str__():傳回訊息的人類可讀表示法,特別適用於偵錯。(通常以 str(message)print message 叫用。)
  • CopyFrom(other_msg):使用給定訊息的值覆寫訊息。
  • Clear():將所有元素清除回空狀態。

這些方法實作 Message 介面。如需詳細資訊,請參閱 Message 的完整 API 文件

剖析與序列化

最後,每個 Protocol Buffer 類別都有方法,可使用 Protocol Buffer 二進位格式寫入和讀取您選擇的類型的訊息。這些方法包括:

  • SerializeToString():序列化訊息並將其以字串形式傳回。請注意,位元組是二進位的,而不是文字;我們僅使用 str 類型作為方便的容器。
  • ParseFromString(data):從給定的字串剖析訊息。

這些只是為剖析和序列化提供的幾個選項。同樣地,請參閱 Message API 參考資料以取得完整清單。

寫入訊息

現在讓我們嘗試使用您的 Protocol Buffer 類別。您希望通訊錄應用程式能夠做的第一件事是將個人詳細資訊寫入您的通訊錄檔案。若要執行此操作,您需要建立和填入 Protocol Buffer 類別的執行個體,然後將它們寫入輸出串流。

以下程式會從檔案讀取 AddressBook、根據使用者輸入向其中新增一個新的 Person,然後將新的 AddressBook 寫回檔案。直接呼叫或參考 Protocol 編譯器產生的程式碼的部分會以醒目提示。

#!/usr/bin/env python3

import addressbook_pb2
import sys

# This function fills in a Person message based on user input.
def PromptForAddress(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.")

# Main procedure:  Reads the entire address book from a file,
#   adds one person based on user input, then writes it back out to the same
#   file.
if len(sys.argv) != 2:
  print("Usage:", sys.argv[0], "ADDRESS_BOOK_FILE")
  sys.exit(-1)

address_book = addressbook_pb2.AddressBook()

# Read the existing address book.
try:
  with open(sys.argv[1], "rb") as f:
    address_book.ParseFromString(f.read())
except IOError:
  print(sys.argv[1] + ": Could not open file.  Creating a new one.")

# Add an address.
PromptForAddress(address_book.people.add())

# Write the new address book back to disk.
with open(sys.argv[1], "wb") as f:
  f.write(address_book.SerializeToString())

讀取訊息

當然,如果您無法從通訊錄中取得任何資訊,通訊錄就沒有多大用處!此範例會讀取上述範例建立的檔案,並列印其中的所有資訊。

#!/usr/bin/env python3

import addressbook_pb2
import sys

# Iterates though all people in the AddressBook and prints info about them.
def ListPeople(address_book):
  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)

# Main procedure:  Reads the entire address book from a file and prints all
#   the information inside.
if len(sys.argv) != 2:
  print("Usage:", sys.argv[0], "ADDRESS_BOOK_FILE")
  sys.exit(-1)

address_book = addressbook_pb2.AddressBook()

# Read the existing address book.
with open(sys.argv[1], "rb") as f:
  address_book.ParseFromString(f.read())

ListPeople(address_book)

擴充 Protocol Buffer

在您發布使用 Protocol Buffer 的程式碼後,遲早您會想要「改進」Protocol Buffer 的定義。如果您希望您的新緩衝區向後相容,而您的舊緩衝區向前相容 - 而且您幾乎肯定希望如此 - 那麼您需要遵循一些規則。在新版本的 Protocol Buffer 中:

  • 絕不能變更任何現有欄位的標籤編號。
  • 絕不能新增或刪除任何必要欄位。
  • 可以刪除選用或重複欄位。
  • 可以新增新的選用或重複欄位,但您必須使用新的標籤編號 (也就是說,從未在此 Protocol Buffer 中使用過的標籤編號,即使是被刪除的欄位也是如此)。

(對於這些規則,有一些例外情況,但很少使用。)

如果您遵循這些規則,舊程式碼將很高興地讀取新訊息,並簡單地忽略任何新欄位。對於舊程式碼,已刪除的選用欄位只會具有其預設值,而刪除的重複欄位將會是空的。新程式碼也會透明地讀取舊訊息。但是,請記住,新的選用欄位不會出現在舊訊息中,因此您需要明確檢查它們是否已使用 has_ 設定,或在您的 .proto 檔案中使用標籤編號後的 [default = value] 提供合理的預設值。如果未為選用元素指定預設值,則會改為使用類型特定的預設值:對於字串,預設值為空字串。對於布林值,預設值為 false。對於數值類型,預設值為零。另請注意,如果您新增了新的重複欄位,您的新程式碼將無法判斷它是被留空 (由新程式碼) 還是從未設定過 (由舊程式碼),因為它沒有 has_ 旗標。

進階用法

Protocol Buffer 的用途不僅僅是簡單的存取器和序列化。請務必瀏覽 Python API 參考資料,以查看您還可以使用它們做什麼。

Protocol 訊息類別提供的一個關鍵功能是反射。您可以反覆運算訊息的欄位,並操作它們的值,而無需針對任何特定訊息類型編寫程式碼。使用反射的一個非常有效的方法是將 Protocol 訊息與其他編碼 (例如 XML 或 JSON) 相互轉換。反射更進階的用途可能是找出相同類型的兩個訊息之間的差異,或開發一種「Protocol 訊息的正規表示式」,您可以在其中編寫符合特定訊息內容的運算式。如果您發揮您的想像力,Protocol Buffer 可以應用於比您最初預期的更廣泛的問題!

反射是作為 Message 介面的一部分提供的。