Protocol Buffer 基礎:Python

為 Python 程式設計師提供的 Protocol Buffers 入門基礎。

本教程為 Python 程式設計師提供了 Protocol Buffers 的基本入門介紹。透過建立一個簡單的示例應用程式,它將向您展示如何:

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

這並非在 Python 中使用 protocol buffers 的全面指南。有關更詳細的參考資訊,請參閱Protocol Buffer 語言指南 (proto2)Protocol Buffer 語言指南 (proto3)Python API 參考Python 生成程式碼指南編碼參考

問題領域

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

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

  • 使用 Python 序列化(pickling)。這是預設方法,因為它內置於語言中,但它不能很好地處理模式演變,而且如果您需要與用 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 檔案中的定義很簡單:為您想要序列化的每個資料結構新增一個 message,然後為訊息中的每個欄位指定名稱和型別。這是定義您的訊息的 .proto 檔案,addressbook.proto

edition = "2023";

package tutorial;

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

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

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

  repeated PhoneNumber phones = 4;
}

message AddressBook {
  repeated Person people = 1;
}

正如你所見,語法類似於 C++ 或 Java。讓我們逐一檢視檔案的每個部分,看看它們的作用。

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

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

每個元素上的 " = 1"、" = 2" 標記標識了該欄位在二進位制編碼中使用的唯一“標籤”(tag)。標籤號 1-15 比較大的數字少用一個位元組來編碼,因此作為一種最佳化,您可以決定將這些標籤用於常用或重複的元素,而將標籤 16 及以上的數字留給不常用的可選元素。重複欄位中的每個元素都需要重新編碼標籤號,因此重複欄位特別適合進行這種最佳化。

您可以在Protocol Buffer 語言指南中找到編寫 .proto 檔案的完整指南——包括所有可能的欄位型別。但是,不要尋找類似於類繼承的功能——protocol buffers 不支援這些功能。

編譯你的 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 存根 (.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 元類的具體工作方式超出了本教程的範圍,但您可以將它們視為建立類的模板。在載入時,GeneratedProtocolMessageType 元類使用指定的描述符為每種訊息型別建立所有您需要使用的 Python 方法,並將它們新增到相關類中。然後,您可以在程式碼中使用完全填充的類。

所有這一切的最終效果是,您可以像使用定義了 Message 基類的每個欄位為常規欄位的 Person 類一樣使用它。例如,您可以編寫

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

有關協議編譯器為任何特定欄位定義生成哪些成員的詳細資訊,請參閱Python 生成程式碼參考

列舉

列舉由元類擴充套件為一組具有整數值的符號常量。因此,例如,常量 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 參考以獲取完整列表。

您還可以輕鬆地將訊息序列化為 JSON 並從 JSON 解析訊息。json_format 模組提供了對此的幫助:

  • MessageToJson(message): 將訊息序列化為 JSON 字串。
  • Parse(json_string, message): 將 JSON 字串解析到給定訊息中。

例如:

from google.protobuf import json_format
import addressbook_pb2

person = addressbook_pb2.Person()
person.id = 1234
person.name = "John Doe"
person.email = "jdoe@example.com"

# Serialize to JSON
json_string = json_format.MessageToJson(person)

# Parse from JSON
new_person = addressbook_pb2.Person()
json_format.Parse(json_string, new_person)

寫入訊息

現在讓我們嘗試使用您的 Protocol Buffer 類。您的地址簿應用程式首先需要能夠將個人詳細資訊寫入您的地址簿檔案。為此,您需要建立並填充您的 Protocol Buffer 類的例項,然後將它們寫入輸出流。

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

#!/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 中從未使用的標籤號,即使是被刪除的欄位也一樣)。

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

如果您遵循這些規則,舊程式碼將愉快地讀取新訊息並簡單地忽略任何新欄位。對於舊程式碼,已刪除的可選欄位將簡單地具有其預設值,而已刪除的重複欄位將為空。新程式碼也將透明地讀取舊訊息。但是,請記住,舊訊息中不會存在新的可選欄位,因此您需要顯式檢查它們是否已設定(使用 HasField('field_name')),或在 .proto 檔案中標籤號後使用 [default = value] 提供一個合理的預設值。如果未為可選元素指定預設值,則使用型別特定的預設值:對於字串,預設值為空字串。對於布林值,預設值為 false。對於數字型別,預設值為零。另請注意,如果您添加了新的重複欄位,新程式碼將無法判斷它是被留空(由新程式碼)還是根本未設定(由舊程式碼),因為沒有對其進行 HasField 檢查。

高階用法

Protocol Buffers 的用途超出了簡單的訪問器和序列化。請務必瀏覽Python API 參考以瞭解您還可以用它們做什麼。

Protocol 訊息類提供的一個關鍵特性是“反射”。您可以遍歷訊息的欄位並操作其值,而無需針對任何特定的訊息型別編寫程式碼。使用反射的一種非常有用的方法是將協議訊息轉換為其他編碼(例如 XML 或 JSON)以及從其他編碼轉換(請參閱解析和序列化以獲取示例)。反射更高階的用途可能是查詢同一型別的兩個訊息之間的差異,或者開發一種“協議訊息的正則表示式”,您可以在其中編寫匹配特定訊息內容的表示式。如果您發揮想象力,Protocol Buffers 可以應用於比您最初預期更廣泛的問題!

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