Edited at

Proto2 vs Proto3

More than 1 year has passed since last update.


はじめに

Protocol BuffersはGoogle謹製のデータシリアライゼーションのツールです。言語非依存な形式でメッセージフォーマットを記述するとそのスタブを色々な言語向けに生成してくれる。XMLやJSONと違ってバイナリフォーマットであることが特徴で、転送時の効率が良いことが期待されます。似たような技術として MessagePackがありますね。さらにgRPCというフレームワークを使うとプロトコルのやり取りの定義までできて、RESTful APIでのSwaggerみたいな位置付けにもなる(ドキュメント生成+ブラウザでの呼びだしみたいなことはできないけど)。

とは言え、外部から呼び出されるAPIにgRPC + Protocol Buffers というのはあまりないと思っていて、そこは昔だとSOAP+XML、今はREST+JSONか、流行り始めている GraphQL+JSONになるのかな。それよりはむしろ内部的なサーバ間のやり取りとかで効率化重視のために Protocol Buffersを使うというのが一般的な気がします。

で、訳あって久しぶりに Protocol Buffers を触ってみようと思ったのですが、いつの間にかスキーマの書き方が変わっていた。というか、従来の記述形式に加えて新たに proto3というのが追加されていて、これが結構違う(そして上位互換じゃない)のでその違いをまとめてみたいと思います。


Protocl Buffersの基礎

Protocol Buffersではメッセージフォーマットを.protoファイルに記述します。そしてそれをprotocというツールで各言語のスタブファイルに変換して自分のプログラムから使います。例として、Personという人の情報を持つメッセージフォーマットを考えてみます。Protoファイルはこんな感じ。

message Person {

required string name = 1;
optional int32 id = 2;
repeated string emails = 3;
}



人の情報として名前、ID、メール(複数)取れることになっていて、IDはつけてもつけなくても良いことにしています。なお、後で詳しく出て来るけど、ここでは従来の記述形式(proto2)で書いています。

そしてこれを例えばPythonのコードに変換してみます。なお、最新のprotocだとWarningが出ますがとりあえず気にせずに。

$ protoc -I. --python_out=. person.proto

で、出てきたファイルを使ってみる。

import person_pb2

person = person_pb2.Person()
person.id = 1234
person.name = 'John Doe'
person.emails.append('John.Doe@example.com')
person.emails.append('JD@example.org')

serialized = person.SerializeToString()
print('Encoded:')
print(repr(serialized))

person2 = person_pb2.Person()
person2.ParseFromString(serialized)
print('\nDecoded:')
print(person2)

見れば大体わかると思うけど、Personのオブジェクトを作ってそれにパラメータ設定しています。そして、SerializeToString()というメソッドでバイト列にシリアライズ。そのあとは、逆にその文字列からParseFromString()メソッドを使ってオブジェクトを再構成しています。

実行するとこんな感じ。

$ pip install protobuf

$ python person_test.py
Encoded:
b'\n\x08John Doe\x10\xd2\t\x1a\x14John.Doe@example.com\x1a\x0eJD@example.org'

Decoded:
name: "John Doe"
id: 1234
emails: "John.Doe@example.com"
emails: "JD@example.org"

似たような感じで、他の言語でも簡単にメッセージフォーマットのシリアライズ・デシリアライズができます。


proto2とproto3の違い

上記の例で使ったのは従来のProtoファイル形式ですが、これに加えて Proto3という新しいProtoファイルの記述形式があります。それがgRPCと共に発表されたのが2015年2月。私が気がついたのはつい最近ですが、世の中的にはもう3年も前から使われ始めてました (^^;

Proto3に関しては、ここに説明があるので、それを眺めながら特徴をまとめてみます。


バージョンの表記

最初の(コメントではない)行に syntax = "proto3"を入れる必要があります。これにより、protocコンパイラがどちらのスキーマ記述形式で書いているのかを判断しているのでしょう。

上で「Warningがでるけど気にしないで」と書きましたが、具体的にはこんなWarningです。


[libprotobuf WARNING google/protobuf/compiler/parser.cc:546] No syntax specified for the proto file: person.proto. Please use 'syntax = "proto2";' or 'syntax = "proto3";' to specify a syntax version. (Defaulted to proto2 syntax.)


「デフォルトでは従来のproto2を想定して処理するけど、syntaxで利用するバージョンを明記してね」ということですね。proto3の導入に伴って従来のproto2もとばっちりを受けた形ですが、逆に言うとproto2が影響を受けたのはこれだけで、それ以外はこれまで通り使い続けられます。大きな仕様変更だったのでそういうことにしたのでしょう。


requiredoptionalの廃止

proto3で導入された変更の中でこれが一番驚きましたが、各フィールドが必須なのかオプショナルなのかを指定できなくなっています。フィールドは基本全てオプショナルで、フィールドのタイプで指定できるのは繰り返しを示すrepeatedだけになっています。

フィールドの必須指定は便利だったと思うんだけど、なぜ廃止になったのか? 賛否両論ありながらこういうことになったのは、どうやら、requiredを使うと過去互換を取りにくくなるからということのようです。最初に設計した時には必須だと思っていても、将来的にそれが必須であり続ける保証はない。でも、一度requiredにしてしまうと取り消せないので、過去互換を取り続けるためにはそこに何か値を入れ続けなければならず無駄だ、という主張のようです。

そのあたり、proto2を使い続ける人に向けて注意書きとしてここに書かれていたりします。


packedエンコーディングがデフォルトに

proto2では、repeatedで指定された数値タイプのフィールドは必ずしも最適な配置がされておらず(詰められていなかった)、以下のようにフィールド宣言で明示的に指定する必要がありました。

repeated int32 samples = 4 [packed=true];

これが、proto3では指定しなくてもデフォルトの動作になったとのこと。


フィールドのデフォルト値が指定不可に

これまでproto2ではオプショナルフィールドのデフォルト値指定ができました。例えばこんな感じ。

optional int32 page_size = 3 [default = 20];

これが、proto3ではできなくなりました。代わりにフィールドの型ごとにデフォルトの値が決まっています。


デフォルト値

文字列
空文字列

バイト列
空バイト列

ブーリアン型
false(偽)

数値型
ゼロ

列挙型(enum)
定義の最初の値(数値は0)

他のメッセージ型の参照
不定(各言語の実装による)

Proto3ではフィールドを必須指定できなくなり全てがオプショナルになったのに、なんとも不可解な変更です。というのも、例えば数値型の場合に受け側でゼロを受け取った場合、送り側で値を指定しなかったのか、明示的にゼロを指定したのかがわからない。そして実際、送り側でデフォルト値に当たる数値を明示的に指定してもシリアライズする時に省略されてしまう(つまり指定しないのと同じ)とのこと。なので、受け手では本当に知りようがないということになります。

簡単にちょっと試してみましょう。まずはproto2とproto3で同じようなメッセージフォーマットの定義をします。全てオプショナルな文字列、整数、ブーリアン型のフィールドを持ち、デフォルト値は空文字列、ゼロ、Falseです。

syntax = "proto2";

message CanBeEmpty2 {
optional string s = 1 [default = ""];
optional int32 n = 2 [default = 0];
optional bool f = 3 [default = false];
}

syntax = "proto3";

message CanBeEmpty3 {
string s = 1;
int32 n = 2;
bool f = 3;
}

これらを使って見ます。まず最初は値を何も設定せずにシリアライズ。次にデフォルト値と同じ値を設定してシリアライズです。

import all_default_2_pb2

import all_default_3_pb2

ad2 = all_default_2_pb2.CanBeEmpty2()
ad3 = all_default_3_pb2.CanBeEmpty3()

print('Values are not set')
print('all_default_2:', repr(ad2.SerializeToString()))
print('all_default_3:', repr(ad3.SerializeToString()))

ad2.s = ad3.s = ""
ad2.n = ad3.n = 0
ad2.f = ad3.f = False

print('\nValues are set')
print('all_default_2:', repr(ad2.SerializeToString()))
print('all_default_3:', repr(ad3.SerializeToString()))

実行するとこんな感じ

$ protoc -I. --python_out=. all_default_2.proto

$ protoc -I. --python_out=. all_default_3.proto
$ python all_default_test.py
Values are not set
all_default_2: b''
all_default_3: b''

Values are set
all_default_2: b'\n\x00\x10\x00\x18\x00'
all_default_3: b''

デフォルト値と同じ値を設定した時の挙動が違うことが確認できる。

デフォルト値が設定できなくなった理由は、ここにも少し書かれていますが、Protocol Buffersで定義するメッセージフォーマットをより単純な構造体にすることで、各言語での実装を容易にし、性能・効率を上げるということのようです。加えて、メッセージフォーマットをアップデートする際に不用意にデフォルト値を変えてしまい挙動がおかしくなる、という事故を防止するという側面もあるのかも知れません。


列挙型(enum)の最初の値がゼロに

enumは引き続き使い続けられますが、「定義の最初の値はゼロにする」という制約が加わりました。これはデフォルト値の指定ができなくなり、「enumは定義の最初の値をデフォルトとする(値は0)」ということになったからですかね。


Any型の導入

Any型はどんなメッセージフォーマットの値も取ることのできる便利なデータ型で、メッセージフォーマットはprotoファイルで定義しなくても良いみたい。でも、そのやり方が今ひとつわからなかったので、複数のメッセージフォーマットをAny型の配列に入れるというのを試してみる。まずはprotoファイル。

syntax = "proto3";

import "google/protobuf/any.proto";

message ErrorStatus {
string message = 1;
repeated google.protobuf.Any details = 2;
}
message Detail1 {
int32 errno = 1;
string reason =2;
}
message Detail2 {
float timestamp = 1;
}

ErrorStatusdetailsフィールドをAny型の繰り返しに設定している。そしてそこに入る可能性のあるメッセージとしてDetail1Detail2を定義してます。

そしてこれを使ったテストプログラムがこちら。

from error_status_pb2 import ErrorStatus, Detail1, Detail2

import time

error = ErrorStatus()
error.message = 'syntax error'

d1 = Detail1()
d1.errno = 123
d1.reason = 'invalid syntax'

d2 = Detail2()
d2.timestamp = time.time()

for d in (d1,d2):
error.details.add().Pack(d)

print(error)

for d in error.details:
dtype = d.TypeName()
msg = eval(dtype)()
d.Unpack(msg)
print(dtype)
print(msg)

Detail1Detail2型のメッセージを作り、それをAny型のPackメソッドを使って格納。そしてそのあとに逆にそれをUnpackメソッドを使って取り出しています。Any型はエンコードされたデータだけでなく元の型の名前も覚えてくれていてTypeNameメソッドでそれを取り出すことができます。上記を実行するとこのようになります。

message: "syntax error"

details {
type_url: "type.googleapis.com/Detail1"
value: "\010{\022\016invalid syntax"
}
details {
type_url: "type.googleapis.com/Detail2"
value: "\rK\312\265N"
}

Detail1
errno: 123
reason: "invalid syntax"

Detail2
timestamp: 1524966784.0

このAny型を使うと従来のextensionsを置き換えることができるとのことで、proto3ではextensionsが使えなくなっています。


JSONマッピング

Proto3ではJSONフォーマットへのマッピングを用意しています。

proto3
JSON

message
object

enum
string

map
object

repeated
array

bool
true/false

string
string

bytes
base64 string

int32, fixed32, uint32
number

int64, fixed64
string

float, double
number

Timestamp
string (RFC3339)

Duration
string

Struct
obejct

だいたい想像通りだと思いますが、ひとつ気にしておく必要がありそうなのが 64bit数値の扱いですかね。JavaScript(ECMAScript)の仕様的にはNumberは最大53bitなので、int64, fixed64は入りきらない。代わりに数字を文字列で表すことになります。

以下はPythonでJSONを出力する例。 MessageToJsonを使うと一発でできます。デフォルトではデフォルト値に設定したフィールドは省略されてしまうので、全部表記したい時には including_default_value_fieldsTrueにすると良いです。

Protoファイル

syntax = "proto3";

import "google/protobuf/timestamp.proto";
import "google/protobuf/duration.proto";

message SpaceShuttleProgram {
enum OrbiterName {
ATLANTIS = 0;
CHALLENGER = 1;
COLUMBIA = 2;
DISCOVERY = 3;
ENDEAVOUR = 4;
ENTERPRISE = 5;
}

message Orbiter {
OrbiterName name = 1;
bool prototype = 2;
}

message Mission {
OrbiterName orbiter_name = 1;
int32 crew = 2;
google.protobuf.Timestamp timestamp = 3;
google.protobuf.Duration duration = 4;
}

map<string, Orbiter> orbiters = 1;
repeated Mission missions = 2;
}

Pythonファイル

import space_shuttle_program_pb2 as sspp

from google.protobuf.json_format import MessageToJson
from datetime import timedelta

def set_orbiter(ssp, ovd, name, prototype):
orbiter = ssp.orbiters[ovd]
orbiter.name = name
orbiter.prototype = prototype
return orbiter

def add_mission(ssp, orbiter_name, crew, timestamp, duration):
m = ssp.missions.add()
m.orbiter_name = orbiter_name
m.crew = crew
m.timestamp.FromJsonString(timestamp)
m.duration.FromTimedelta(duration)

def main():
ssp = sspp.SpaceShuttleProgram()

set_orbiter(ssp, 'OV-104', ssp.ATLANTIS, False)
set_orbiter(ssp, 'OV-099', ssp.CHALLENGER, False)
set_orbiter(ssp, 'OV-102', ssp.COLUMBIA, False)
set_orbiter(ssp, 'OV-103', ssp.DISCOVERY, False)
set_orbiter(ssp, 'OV-105', ssp.ENDEAVOUR, False)
set_orbiter(ssp, 'OV-101', ssp.ENTERPRISE, True)

add_mission(ssp, ssp.COLUMBIA, 2, '1981-04-12T12:00:04Z', timedelta(2,6))
add_mission(ssp, ssp.CHALLENGER, 4, '1983-04-04T18:30:00Z', timedelta(5,0))
add_mission(ssp, ssp.DISCOVERY, 6, '1984-08-30T12:41:50Z', timedelta(6,0))
add_mission(ssp, ssp.ATLANTIS, 5, '1985-10-03T15:15:30Z', timedelta(4, 1))

print(MessageToJson(ssp, including_default_value_fields=True))

main()

出力されたJSON

{

"orbiters": {
"OV-099": {
"name": "CHALLENGER",
"prototype": false
},
"OV-101": {
"name": "ENTERPRISE",
"prototype": true
},
"OV-102": {
"name": "COLUMBIA",
"prototype": false
},
"OV-103": {
"name": "DISCOVERY",
"prototype": false
},
"OV-104": {
"name": "ATLANTIS",
"prototype": false
},
"OV-105": {
"name": "ENDEAVOUR",
"prototype": false
}
},
"missions": [
{
"orbiterName": "COLUMBIA",
"crew": 2,
"timestamp": "1981-04-12T12:00:04Z",
"duration": "172806s"
},
{
"orbiterName": "CHALLENGER",
"crew": 4,
"timestamp": "1983-04-04T18:30:00Z",
"duration": "432000s"
},
{
"orbiterName": "DISCOVERY",
"crew": 6,
"timestamp": "1984-08-30T12:41:50Z",
"duration": "518400s"
},
{
"crew": 5,
"timestamp": "1985-10-03T15:15:30Z",
"duration": "345601s",
"orbiterName": "ATLANTIS"
}
]
}


まとめ

Proto2とProto3の違いを色々と見てきましたが、2と3で設計思想が大きく変わっているように思います。2はお節介にいろいろやってくれて便利だけど使い道によってはやり過ぎで性能を犠牲にしてしまう可能性がある。3は割り切って基本の「データをシリアライズする」というところにフォーカスして効率化をしているという印象でした。その分、細かいところのチェックをユーザ側でしなければならない場合もあって、2を使っていた人からみると機能的に後退しているイメージを持つ人がいるようにも思いますが、まぁproto2も引き続き使い続けられるようなので、用途に応じて使い分ければ良いのかな。