はじめに
protobufの任意のMessage
をdeserializeする方法を調べたので、備忘メモを残しておきます。
誤りやより良い方法などありましたら、ぜひご指摘ください。
以下の環境で確認しています。
$ lsb_release -d
Description: Ubuntu 18.04.5 LTS
$ uname -r
5.5.8
$ uname -m
x86_64
$ protoc --version
libprotoc 3.14.0
protobufの任意のMessage
をdeserializeする
protobufでdeserializeするためには、
「ユーザ定義Message
型」のインスタンスのParseFromXXX()
メソッドを呼び出す必要があります。
イベント発生時にdeserializeする場合などは
この「ユーザ定義Message
型」を動的に決定する必要があります。
C++では型情報を取り回す方法としてtype_info
型が用意されていますが、
残念ながらtype_info
型からインスタンスを生成することはできないので今回は使えません。
というわけで、protobufで動的に「ユーザ定義Message
型」を決定し、deserializeするためには
下記3つのアプローチが考えられます。
-
方式0. 実インスタンス方式
- Parseする可能性のある
Message
の実インスタンスを持っておき、必要なタイミングでParseFromXXX()
を呼び出す
- Parseする可能性のある
-
方式1. デフォルトインスタンス方式
- Parseする可能性のある
Message
のデフォルトインスタンスへの参照を持っておき、
必要なタイミングでインスタンスを生成してからParseFromXXX()
を呼び出す
- Parseする可能性のある
-
方式2.
Descriptor
方式- Parseする可能性のある
Message
の型情報(Descriptor
)へのポインタを持っておき、
必要なタイミングでインスタンスを生成してからParseFromXXX()
を呼び出す
- Parseする可能性のある
方式0はメモリ効率性からあまり現実的ではなさそうなのと、
仮に採用できたとして実装もそのままなので今回は省略します。
先に結論を言えば、選べるなら方式1の方が簡単ですし、効率が良い分望ましいです。
すでにDescriptor
で管理していて変更が難しい場合には、方式2を選択することになると思います。
方式1. デフォルトインスタンス方式
デフォルトインスタンス方式の場合の処理は下記のようになります。
- immutableなデフォルトインスタンスから、mutableな
Message
インスタンスを生成する - 生成した
Message
インスタンスのParseFromXXX
を呼び出し、deserializeする - 「ユーザ定義
Message
型」にdown castして参照する
実装は下記のようになります。
Message::New
で取得したインスタンスの解放責任は呼び出し元にあるので、ここではunique_ptr
で受けています。
static std::unique_ptr<Message> LoadMessageBasedOnMessage(
const Message& message) {
// 1. Message::Newで、mutableなMessageを生成する
// 得たMessageは呼び出し元が解放しなければならないので、unique_ptrで受ける
auto new_message = std::unique_ptr<Message>(message.New());
if (new_message == nullptr) {
return nullptr;
}
// 2. Message::ParseFromXXXで、Messageをdeserialize
new_message->ParseFromString(serialized_message);
return new_message;
}
呼び出し元は以下のようなイメージです。
protobufでは、C++のRTTIが使えないときでもdynamic castできるように
DynamicCastToGenerated
というAPIが用意されています。
std::unique_ptr<Message> message =
LoadMessageBasedOnMessage(SampleMessage::default_instance());
// 3. ユーザ定義Message型にdown cast
SampleMessage* sample_message =
DynamicCastToGenerated<SampleMessage>(message.get());
方式2. Descriptor方式
Descriptor方式の場合の処理は下記のようになります。
-
DynamicMessageFactory
を生成する -
DynamicMessageFactory::GetPrototype
で、Descriptorからconst Messageを取得 -
Message::New
で、mutableなMessage
インスタンスを生成する - 生成した
Message
インスタンスのParseFromXXX
を呼び出し、deserializeする - 「ユーザ定義
Message
型」にコピーして参照する
Descriptor
からMessage
を生成するためには、
DynamicMessageFactory::GetPrototype
を使います。
ただし、注意点が2つあります。
1つめは、GetPrototype
で生成したMessage
および、そこからNew
したMessage
は、
DynamicMessageFactory
が破棄されたあとは参照してはいけません。
2つめは、GetPrototype
で生成したMessage
は、DynamicMessage
型にユーザ定義Message
型と同じReflection
をもたせたものです。
つまり、振る舞いとしてはユーザ定義Message
型と同じですが、
型としては異なるのでユーザ定義Message
型へのdown castはできません。
厳密にユーザ定義Message
型として使うためには、
ユーザ定義Message
型のインスタンスにCopyFrom
する必要があります。
これらを加味し、方式1と同等の処理を行った実装が下記です。
返すMessage
以上に生存期間の長いDynamicMessageFactory
が必要なので、引数で渡してもらっています。
static std::unique_ptr<Message> LoadMessageBasedOnDescriptor(
DynamicMessageFactory* factory, const Descriptor* descriptor) {
if ((factory == nullptr) || (descriptor == nullptr)) {
return nullptr;
}
// 1. DynamicMessageFactory::GetPrototypeで、Descriptorからconst Messageを取得
const Message* prototype = factory->GetPrototype(descriptor);
if (prototype == nullptr) {
return nullptr;
}
// 2. Message::Newで、mutableなMessageを生成する
// 得たMessageは呼び出し元が解放しなければならないので、unique_ptrで受ける
auto message = std::unique_ptr<Message>(prototype->New());
if (message == nullptr) {
return nullptr;
}
// 3. Message::ParseFromXXXで、Messageをdeserialize
message->ParseFromString(serialized_message);
return message;
}
呼び出し元は以下のような感じです。
down castができないので、Message::CopyFrom
しています。
Reflection
経由でしかプロパティにアクセスせず、
かつDynamicMessageFactrory
の生存期間をMessage
より長くできるのであれば、
4.のコピーを避けることもできると思います。
{
// 0. DynamicMessageFactoryを生成
DynamicMessageFactory factory;
std::unique_ptr<Message> message =
LoadMessageBasedOnDescriptor(&factory, SampleMessage::descriptor());
// 4. ユーザ定義Message型にコピー
sample_message.CopyFrom(*message);
// 5. ここでDynamicMessageFactory解放
// 以降、factoryから生成したmessageは参照してはいけない
// sample_messageは引き続き使用できる
}
性能比較
それぞれ1,000,000回実行した時間の平均を比較すると、
方式2の方が約2倍時間がかかっています。
方式 | 時間[ns] |
---|---|
方式1 | 1,927 |
方式2 | 3,716 |
コピーが1回→2回に増えているので、およそ想定通りです。
計測環境は以下のとおりです。
$ grep 'model name' /proc/cpuinfo | uniq -c
8 model name : Intel(R) Core(TM) i5-8259U CPU @ 2.30GHz
$ grep MemTotal /proc/meminfo
MemTotal: 32752704 kB
$ uname -r
5.5.8
$ lsb_release -d
Description: Ubuntu 18.04.5 LTS
終わりに
今回、DynamicMessageFactory::GetPrototype
のドキュメントを読んでいて、
以下の文言を見つけたのが調べるきっかけでした。
New
したら独立したインスタンスが返ってくると思い込んでいたので、驚きました。
Also, any objects created by calling the prototype's New() method share some data with the prototype, so these must be destroyed before the DynamicMessageFactory is destroyed.
protobuf
ではC++のような静的型付け言語でもかなり動的に型を処理できるよう工夫してくれています。
一方で効率も求められるので、どうしてもトリッキーな処理や直感に背く振る舞いも出てきてしまうのだと思います。
あらためて、protobuf
で動的に型を処理する場合は慎重にならないとと感じました。
蛇足ですが、今回DynamicMessageFactory
とDynamicMessage
の関係を確認するのに
valgrind --leak-check=full --show-leak-kinds=all
がとても役に立ってくれました。
実験コード
今回の記事で使った実験コードは、以下に置いています。