Protocol Bufferの基本:Go

GoプログラマーのためのProtocol Bufferの基本入門。

このチュートリアルでは、Protocol Buffer言語のproto3バージョンを使用して、Protocol Bufferを扱うためのGoプログラマー向けの基本的な入門を提供します。簡単なサンプルアプリケーションの作成を通して、以下の方法を説明します。

  • .protoファイルでメッセージ形式を定義する。
  • Protocol Bufferコンパイラを使用する。
  • Go Protocol Buffer APIを使用してメッセージを書き込み、読み取る。

これはGoでProtocol Bufferを使用するための包括的なガイドではありません。より詳細なリファレンス情報については、Protocol Buffer言語ガイドGo APIリファレンスGo生成コードガイド、およびエンコーディングリファレンスを参照してください。

問題領域

ここで使用する例は、人々の連絡先の詳細をファイルとの間で読み書きできる非常にシンプルな「アドレス帳」アプリケーションです。アドレス帳の各個人は、名前、ID、メールアドレス、および連絡先の電話番号を持っています。

このような構造化データをシリアライズおよび取得するにはどうすればよいでしょうか?この問題を解決するにはいくつかの方法があります。

  • gobsを使用してGoのデータ構造をシリアライズします。これはGo固有の環境では良い解決策ですが、他のプラットフォーム向けに作成されたアプリケーションとデータを共有する必要がある場合にはうまく機能しません。
  • データ項目を単一の文字列にエンコードするアドホックな方法を発明できます。たとえば、4つの整数を「12:3:-23:67」としてエンコードするなどです。これはシンプルで柔軟なアプローチですが、一回限りのエンコードおよび解析コードの記述が必要であり、解析にはわずかなランタイムコストがかかります。これは非常に単純なデータをエンコードするのに最適です。
  • データをXMLにシリアライズします。XMLは(ある程度)人間が読める形式であり、多くの言語用のバインディングライブラリがあるため、このアプローチは非常に魅力的です。他のアプリケーション/プロジェクトとデータを共有したい場合には、良い選択肢となる可能性があります。ただし、XMLは非常にスペース効率が悪く、エンコード/デコードにはアプリケーションに大きなパフォーマンスペナルティを課す可能性があります。また、XML DOMツリーのナビゲーションは、通常クラス内の単純なフィールドをナビゲートするよりもかなり複雑です。

Protocol Bufferは、まさにこの問題を解決するための柔軟で効率的かつ自動化されたソリューションです。Protocol Bufferを使用すると、保存したいデータ構造の.proto記述を記述します。それに基づいて、Protocol Bufferコンパイラは、効率的なバイナリ形式でProtocol Bufferデータの自動エンコードと解析を実装するクラスを作成します。生成されたクラスは、Protocol Bufferを構成するフィールドのゲッターとセッターを提供し、Protocol Bufferをユニットとして読み書きする詳細を処理します。重要なことに、Protocol Buffer形式は、古い形式でエンコードされたデータをコードが読み取ることができるように、時間の経過とともに形式を拡張するという考え方をサポートしています。

サンプルコードの入手先

私たちの例は、Protocol Bufferを使用してエンコードされたアドレス帳データファイルを管理するためのコマンドラインアプリケーションのセットです。コマンドadd_person_goは、データファイルに新しいエントリを追加します。コマンドlist_people_goは、データファイルを解析し、データをコンソールに出力します。

完全な例は、GitHubリポジトリのexamplesディレクトリにあります。

プロトコル形式の定義

アドレス帳アプリケーションを作成するには、まず.protoファイルから始める必要があります。.protoファイルの定義はシンプルです。シリアライズする各データ構造に対してmessageを追加し、メッセージ内の各フィールドに名前と型を指定します。私たちの例では、メッセージを定義する.protoファイルはaddressbook.protoです。

.protoファイルは、パッケージ宣言で始まり、これにより異なるプロジェクト間での名前の衝突を防ぐのに役立ちます。

syntax = "proto3";
package tutorial;

import "google/protobuf/timestamp.proto";

go_packageオプションは、このファイルのすべての生成コードを含むパッケージのインポートパスを定義します。Goパッケージ名は、インポートパスの最後のパスコンポーネントになります。たとえば、私たちの例では「tutorialpb」というパッケージ名を使用します。

option go_package = "github.com/protocolbuffers/protobuf/examples/go/tutorialpb";

次に、メッセージ定義があります。メッセージは、型付きフィールドのセットを含む単なる集約です。boolint32floatdoublestringなど、多くの標準的な単純データ型がフィールド型として利用可能です。フィールド型として他のメッセージ型を使用することで、メッセージにさらに構造を追加することもできます。

message Person {
  string name = 1;
  int32 id = 2;  // Unique ID number for this person.
  string email = 3;

  message PhoneNumber {
    string number = 1;
    PhoneType type = 2;
  }

  repeated PhoneNumber phones = 4;

  google.protobuf.Timestamp last_updated = 5;
}

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

// Our address book file is just one of these.
message AddressBook {
  repeated Person people = 1;
}

上記の例では、PersonメッセージにはPhoneNumberメッセージが含まれ、AddressBookメッセージにはPersonメッセージが含まれています。他のメッセージ内にネストされたメッセージ型を定義することもできます。ご覧のとおり、PhoneNumber型はPerson内で定義されています。フィールドの1つに定義済みの値リストの1つを持たせたい場合は、enum型を定義することもできます。ここでは、電話番号がPHONE_TYPE_MOBILEPHONE_TYPE_HOME、またはPHONE_TYPE_WORKのいずれかであることを指定したいとします。

各要素の「 = 1」、「 = 2」マーカーは、フィールドがバイナリエンコーディングで使用する一意の「タグ」を識別します。タグ番号1〜15は、より高い番号よりもエンコードに必要なバイト数が1バイト少ないため、最適化として、これらのタグを一般的に使用される要素または繰り返される要素に使用し、タグ16以降をあまり使用されないオプション要素に残すことができます。繰り返されるフィールドの各要素は、タグ番号を再エンコードする必要があるため、繰り返されるフィールドはこの最適化の特に良い候補です。

フィールド値が設定されていない場合、デフォルト値が使用されます。数値型の場合はゼロ、文字列の場合は空文字列、bool値の場合はfalseです。埋め込みメッセージの場合、デフォルト値は常にメッセージの「デフォルトインスタンス」または「プロトタイプ」であり、フィールドは何も設定されていません。明示的に設定されていないフィールドの値を取得するためにアクセサーを呼び出すと、常にそのフィールドのデフォルト値が返されます。

フィールドがrepeatedの場合、フィールドは任意の回数(ゼロを含む)繰り返される場合があります。繰り返される値の順序は、Protocol Bufferで保持されます。繰り返されるフィールドを動的にサイズ変更される配列と考えてください。

可能なすべてのフィールド型を含む、.protoファイルの書き方に関する完全なガイドは、Protocol Buffer言語ガイドにあります。ただし、クラス継承に似た機能を探さないでください。Protocol Bufferはそれを行いません。

Protocol Bufferのコンパイル

.protoができたので、次に必要なのは、AddressBook(したがってPersonおよびPhoneNumber)メッセージを読み書きするために必要なクラスを生成することです。これを行うには、Protocol Bufferコンパイラprotoc.protoに対して実行する必要があります。

  1. コンパイラをインストールしていない場合は、パッケージをダウンロードして、READMEの手順に従ってください。

  2. Go Protocol Bufferプラグインをインストールするには、次のコマンドを実行します。

    go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
    

    コンパイラプラグインprotoc-gen-goは、デフォルトで$GOPATH/binである$GOBINにインストールされます。Protocolコンパイラprotocがそれを見つけるためには、$PATHに存在する必要があります。

  3. 次に、コンパイラを実行し、ソースディレクトリ(アプリケーションのソースコードが存在する場所。値を指定しない場合は現在のディレクトリが使用されます)、宛先ディレクトリ(生成されたコードの出力先。多くの場合$SRC_DIRと同じ)、および.protoへのパスを指定します。この場合、次のように呼び出します。

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

    Goコードが必要なため、--go_outオプションを使用します。同様のオプションは、他のサポートされている言語にも提供されています。

これにより、指定した宛先ディレクトリにgithub.com/protocolbuffers/protobuf/examples/go/tutorialpb/addressbook.pb.goが生成されます。

Protocol Buffer API

addressbook.pb.goを生成すると、次の便利な型が得られます。

  • Peopleフィールドを持つAddressBook構造体。
  • NameIdEmail、およびPhonesのフィールドを持つPerson構造体。
  • NumberTypeのフィールドを持つPerson_PhoneNumber構造体。
  • Person_PhoneTypeと、Person.PhoneType enumの各値に対して定義された値。

生成されるものの詳細については、Go生成コードガイドで詳しく読むことができますが、ほとんどの場合、これらを完全に通常のGo型として扱うことができます。

以下は、list_peopleコマンドの単体テストからのPersonのインスタンスを作成する方法の例です。

p := pb.Person{
    Id:    1234,
    Name:  "John Doe",
    Email: "jdoe@example.com",
    Phones: []*pb.Person_PhoneNumber{
        {Number: "555-4321", Type: pb.PhoneType_PHONE_TYPE_HOME},
    },
}

メッセージの書き込み

Protocol Bufferを使用する目的全体は、データをシリアライズして、他の場所で解析できるようにすることです。Goでは、Protocol Bufferデータをシリアライズするために、protoライブラリのMarshal関数を使用します。Protocol Bufferメッセージのstructへのポインターは、proto.Messageインターフェースを実装します。proto.Marshalを呼び出すと、Protocol Bufferがワイヤー形式でエンコードされて返されます。たとえば、add_personコマンドでこの関数を使用します。

book := &pb.AddressBook{}
// ...

// Write the new address book back to disk.
out, err := proto.Marshal(book)
if err != nil {
    log.Fatalln("Failed to encode address book:", err)
}
if err := ioutil.WriteFile(fname, out, 0644); err != nil {
    log.Fatalln("Failed to write address book:", err)
}

メッセージの読み取り

エンコードされたメッセージを解析するには、protoライブラリのUnmarshal関数を使用します。これを呼び出すと、in内のデータがProtocol Bufferとして解析され、結果がbookに配置されます。したがって、list_peopleコマンドでファイルを解析するには、次を使用します。

// Read the existing address book.
in, err := ioutil.ReadFile(fname)
if err != nil {
    log.Fatalln("Error reading file:", err)
}
book := &pb.AddressBook{}
if err := proto.Unmarshal(in, book); err != nil {
    log.Fatalln("Failed to parse address book:", err)
}

Protocol Bufferの拡張

Protocol Bufferを使用するコードをリリースした後、遅かれ早かれ、Protocol Bufferの定義を「改善」したいと思うでしょう。新しいバッファに後方互換性を持たせ、古いバッファに前方互換性を持たせたい場合(そしてほぼ確実にそうしたいはずです)、従う必要のあるルールがいくつかあります。Protocol Bufferの新しいバージョンでは

  • 既存のフィールドのタグ番号を変更してはなりません
  • フィールドを削除できます
  • 新しいフィールドを追加できますが、新しいタグ番号(つまり、このProtocol Bufferで一度も使用されたことのないタグ番号。削除されたフィールドでも不可)を使用する必要があります。

(これらのルールにはいくつかの例外がありますが、めったに使用されません。)

これらのルールに従うと、古いコードは新しいメッセージを問題なく読み取り、新しいフィールドを単に無視します。古いコードにとって、削除された単数フィールドは単にデフォルト値を持ち、削除された繰り返しフィールドは空になります。新しいコードも古いメッセージを透過的に読み取ります。

ただし、新しいフィールドは古いメッセージには存在しないため、デフォルト値に対して何らかの合理的な処理を行う必要があることに注意してください。型固有のデフォルト値が使用されます。文字列の場合、デフォルト値は空文字列です。ブール値の場合、デフォルト値はfalseです。数値型の場合、デフォルト値はゼロです。