PythonでProtocol Buffers3を使う&悩みどころを解説


こんにちは、@s_tsukaです。

最近PythonとProtocol Buffers3を使ってクラスの自動生成、Serialize/Deserializeを行いました。

せっかくなのでそれらの使い方と難しいところ・悩みどころをメモしておきます。

Protocol Buffers3

まずはProtocol Buffers (protobuf) について簡単に。

https://developers.google.com/protocol-buffers/

https://github.com/google/protobuf

Protocol BuffersはGoogleが開発したメッセージフォーマットです。フォーマットなのでcsv, json, avro, parquetなどの親戚とでも思っておけば良いです。メッセージはバイナリ形式なので転送効率が良くなるという代物です。

単にメッセージのフォーマット、というだけでなくメッセージを定義するSchemaとしての役目もあります。これについてはあとで使い方の節で解説します。

現在メインで使われているのはver2ですが、今回はver3を使います。

https://developers.google.com/protocol-buffers/docs/proto3

使い方

ここからは実際にprotobuf3のSchemaを定義して、そこからPythonクラスを自動生成して使ってみます。

ほぼこちら(https://developers.google.com/protocol-buffers/docs/pythontutorial)と同じですが、こっちはver2の定義なので、一部変更して使います。

まずはprotocコマンドを利用できるようにしておきます。

brew install protobuf
# protoc --version
# libprotoc 3.2.0

homebrewの人はこんな感じで。その他の環境は公式docを御覧ください。

次に生成するクラスのschemaを作成します。

syntax = "proto3";

package tutorial;

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

  enum PhoneType {
    MOBILE = 0;
    HOME = 1;
    WORK = 2;
  }

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

  repeated PhoneNumber phones = 4;
}

message AddressBook {
  repeated Person people = 1;
}

このファイルを生成したあとで以下のコマンドを実行します。

protoc --python_out=./ ./addressbook.proto

addressbook_pb2.pyというファイルが生成されるので、あとはこれをimportすれば生成したクラスが利用できるようになります。

早速使ってみましょう。

# coding=utf-8

from addressbook_pb2 import *


def main(args=None):
    address_book = AddressBook()
    p1 = address_book.people.add()
    p2 = address_book.people.add()

    p1.name = 'person1'
    p2.name = 'person2'

    print(address_book)

if __name__ == "__main__":
    main()

# Output
# [tsukaby@tsukamac python-study]% python example-protobuf.py
# people {
#     name: "person1"
# }
# people {
#     name: "person2"
# }

ちゃんと使えました。APIがProtobufのものになるのでadd()とか若干トリッキーな気もしますが・・・

protobuf pythonにはParseとMessageToJsonという関数が用意されています。Parseを使うとjson stringからprotobuf message objectへ、MessageToJsonを使うとprotobuf message objectからjson stringへ変換ができます。

# coding=utf-8

from addressbook_pb2 import *

# requirements.txtでprotobuf==3.2.0が必要です
from google.protobuf.json_format import *


def main(args=None):
    json_str = """
    {
      "people": [
        {
          "name": "person1"
        },
        {
          "name": "person2"
        }
      ]
    }
    """

    address_book = Parse(json_str, AddressBook())
    print('print address_book')
    print(address_book)

    address_book_json = MessageToJson(address_book)
    print('print address_book(json)')
    print(address_book_json)
    

if __name__ == "__main__":
    main()

# Output
# [tsukaby@tsukamac python-study]% python example-protobuf.py
# print address_book
# people {
#     name: "person1"
# }
# people {
#     name: "person2"
# }
# 
# print address_book(json)
# {
#     "people": [
#         {
#             "name": "person1"
#         },
#         {
#             "name": "person2"
#         }
#     ]
# }


簡単ですね。

悩みどころ

ここからは自分が困ったことについて書いていきます。

空と零を区別したい(フィールドが消されてしまう)

上記でpersonを作成しました。このとき、nameしか値を設定しなかったのでjsonもその通りnameフィールドだけになっています。これはまあそうですよね。

しかし時にはid, emailに値を設定しなかったとしても、それらのフィールドも0と”(空文字列)としてjson出力してほしいときがあります。

そんなときはMessageToJsonのincluding_default_value_fieldsをTrueにしましょう。

# coding=utf-8

from addressbook_pb2 import *

# requirements.txtでprotobuf==3.2.0が必要です
from google.protobuf.json_format import *


def main(args=None):
    p = Person()
    p.name = 'person1'
    person_json = MessageToJson(p, including_default_value_fields=True)
    print(person_json)


if __name__ == "__main__":
    main()

# Output
# [tsukaby@tsukamac python-study]% python example-protobuf.py
# {
#     "phones": [],
#     "email": "",
#     "name": "person1",
#     "id": 0
# }

idやemailが0や”で出力されました。

一見これでいいともおもえるのですが、このPerson jsonを受け付けるAPIサーバがあったとしてそれが以下のようなバリデーションの仕様だとしたらどうでしょうか。(実際にあった・・・)

  1. idは0でもよいが必ずなければならない
  2. emailはフィールドは存在しないまたは1文字以上なら良い (0文字はNG)

including_default_value_fieldsはFalseにしたままで、emailフィールドを設定しないでidフィールドだけ0に設定すれば良いのでは?と思います。普通そう考えるはずです。

# coding=utf-8

from addressbook_pb2 import *

# requirements.txtでprotobuf==3.2.0が必要です
from google.protobuf.json_format import *


def main(args=None):
    p = Person()
    p.name = 'person1'
    p.id = 0
    person_json = MessageToJson(p, including_default_value_fields=False)
    print(person_json)


if __name__ == "__main__":
    main()

# Output
# [tsukaby@tsukamac python-study]% python example-protobuf.py
# {
#     "name": "person1"
# }

p.id = 0をしているにもかかわらず結果はidフィールドがありません。

including_default_value_fieldsをTrueにしてしまうとidは解決できますが、今度はemailが空文字列になってしまってNGです。あちらを立てればこちらが立たず・・・

これについては我らが@xuwei_k先生が助言をくれました。ありがとう・・・!Scalaの話じゃないのに教えてくれてほんとありがとう!

https://twitter.com/xuwei\_k/status/839408202567462914

これについて調べたら@matsu_charaさんのこちらの記事が大変参考になりました。

ProtocolBuffersでprimitiveのデフォルト値と値が入っていないことを区別したいときにどう書くか

idフィールドは必ずjsonにマッピングしてほしいので、そこにgoogle.protobuf.Int32Valueを使います。

// 関係ないところは省略しています

import "google/protobuf/wrappers.proto";

...

message Person {
...
  google.protobuf.Int32Value id = 2;
...
# coding=utf-8

from addressbook_pb2 import *

# requirements.txtでprotobuf==3.2.0が必要です
from google.protobuf.json_format import *


def main(args=None):
    p = Person()
    p.name = 'person1'
    p.id.value = 0
    person_json = MessageToJson(p, including_default_value_fields=False)
    print(person_json)


if __name__ == "__main__":
    main()

# Output
# [tsukaby@tsukamac python-study]% python example-protobuf.py
# {
#     "name": "person1",
#     "id": 0
# }

これでちゃんとemailはないけどidはある、という状況を作れました。

ところがこれも完璧ではなくて、p.id.value = 0というようにvalueという余計なものが入ってきています。wrapper typeを使う以上仕方ない・・・かな

中途半端にフィールドがある状態になる

先程と同様ですが、これもjsonへのdeserializeで苦労した点です。

少しschemaを変えます。

syntax = "proto3";

package tutorial;

message Company {
  HeadOffice head_office = 1;
}

message HeadOffice {
  Location location = 1;
}

message Location {
  repeated string popular_names = 1;
}

若干不自然ですが・・・会社が本社を持っていて、本社は場所を持っていて、場所は通称を複数持っているというネストしている構造です。

このjsonをparseしてobjにしてから再度jsonにしてみます。

# coding=utf-8

from addressbook_pb2 import *

# requirements.txtでprotobuf==3.2.0が必要です
from google.protobuf.json_format import *


def main(args=None):

    json_str = """
    {
      "head_office": {
        "location": {
          "popular_names": []
        }
      }
    }
    """

    c = Parse(json_str, Company())
    print('company message')
    print(c)
    c_json = MessageToJson(c, including_default_value_fields=False)
    print('company json')
    print(c_json)


if __name__ == "__main__":
    main()

# Output
# [tsukaby@tsukamac python-study]% python example-protobuf.py
# company message
# head_office {
#   location {
#   }
# }
#
# company json
# {
#   "headOffice": {
#     "location": {}
#   }
# }

元のjsonのpopular_nameが空配列だったにもかかわらず、protobuf messageの段階で消えています・・。これは・・どうしたらいいんでしょうか。

実際こういうリクエストを弾いてくるAPIサーバがあって、苦労しました。これについては未だに解決策がわかっていません。

まとめ

  • サンプルの通り、割と簡単にprotobufを利用できます
  • 空と零の区別はwrapper typeを使いましょう
  • Parse関数は上記の通り完璧にparseしてくれるわけではないので注意が必要です