Qt におけるシリアル化

この記事は The Qt BlogSerialization in and with Qt を翻訳したものです。
執筆: Maurice Kalinowski, 2018年05月31日

この連載の最初の記事 で、メッセージの構築と組み合わせ、テレメトリセンサーでのオーバーヘッドの削減を紹介しました。

今回は、メッセージのペイロード(本体)とその最適化について紹介します。

Qt にはオブジェクトのシリアル化の方法がいくつかあります。パート1では、JSON を利用しました。この場合、すべてのセンサーの情報は QJsonObject に収められ、QJsonDocument が情報を QByteArray に変換する役を担いました。

QJsonObject jobject;

jobject["SensorID"] = m_id;

jobject["AmbientTemperature"] = m_ambientTemperature;

jobject["ObjectTemperature"] = m_objectTemperature;

jobject["AccelerometerX"] = m_accelerometerX;

jobject["AccelerometerY"] = m_accelerometerY;

jobject["AccelerometerZ"] = m_accelerometerZ;

jobject["Altitude"] = m_altitude;

jobject["Light"] = m_light;

jobject["Humidity"] = m_humidity;

QJsonDocument doc( jobject );

 

return doc.toJson();

JSON にはいくつもの利点があります。

  • 文字列ベースの JSON は宣言的で、人間にも簡単に読める
  • 情報の構造化が可能
  • 一般的な情報の交換はとても簡単
  • 値の追加などのメッセージの拡張も可能
  • クラウド側には JSON を受信しパースするためのソリューションがいくらでもある

しかしながら、このアプローチにもいくつかの制限があります。まず、JSON メッセージの生成は大量の演算が必要な重い処理です。私たちのサンプルのリポジトリにある パート2のベンチマーク によると、10,000 メッセージのシリアライズ/デシリアライズに 263ms かかっています。これはメッセージ単位では些細な数字かもしれませんが、この時間は電気の消費量とみなすこともできます。充電されずに数年動作するように設計されたセンサーにおいては、これは大きな影響です。

MQTT のメッセージのペイロードのサイズが、センサーの更新毎に 346 バイトとなっていのも問題です。センサーが 8 つの倍精度整数と 1 つの固定長の文字列を送るだけの場合、これは大きなオーバーヘッドとなるでしょう。

前回の記事のコメントでは、QJsonDocument::Compact を紹介していますが、これによってペイロードのサイズは平均で 290 バイトとなりました。

では、これをどう改善すればよいでしょう?

ちょっと前に文字列ベースの JSON という書き方をしましたが、バイナリベースの JSON というものも存在し、可読性は下がりますがその他の利点はそのまま利用できます。重要なことは、ベンチマークをしてみたところ、doc.toJSON() を doc.toBinaryData() と単に変えるだけで、テストのスピードが2倍になり、ベンチマークの処理は 125ms で完了しました。

ペイロードを確認したところ、メッセージのサイズは 338 バイトと、ほとんど差はありませんでした。しかし、メッセージの中の文字列の割合が増加する場合は、話がまた違ってきます。プロジェクトの内容やサードパーティのソリューションの利用の可否によって選択できるオプションの数は増えます。

プロジェクトが「Qt の世界に閉じている場合」で、データのフローが変わらないような場合は、QDataStream は現実的なオプションになりえます。

SensorInformation クラスにこの対応を追加するには、以下の2つのオペレータが必要です。

QDataStream &operator<<(QDataStream &, const SensorInformation &);
QDataStream &operator>>(QDataStream &, SensorInformation &);

The implementation is straightforward as well. Below it is shown for the serialization:

QDataStream &operator<<(QDataStream &out, const SensorInformation &item)
{
    QDataStream::FloatingPointPrecision prev = out.floatingPointPrecision();
    out.setFloatingPointPrecision(QDataStream::DoublePrecision);
    out << item.m_id
        << item.m_ambientTemperature
        << item.m_objectTemperature
        << item.m_accelerometerX
        << item.m_accelerometerY
        << item.m_accelerometerZ
        << item.m_altitude
        << item.m_light
        << item.m_humidity;
    out.setFloatingPointPrecision(prev);
    return out;}

 

ベンチマークによると、QDataStream の利用により 26ms しか処理がかからなくなり、これは文字列ベースの JSON の10分の1になります。それから、平均的なメッセージのサイズは 290 バイトだったものがたった 84 バイトになりました。というわけで、前提条件自体に問題がなければ、QDataStream は間違いなく良い選択肢です。

プロジェクトにサードパーティのコンポーネントの追加が可能な場合における、もっとも有名なシリアル化のソリューションは、Google が提供している Protocol Buffers(protobuf) になります。

protobuf を組み合わせるにはいくつかの変更が必要になります。まず、protobuf はデータやメッセージの構造を IDL によって表現します。SensorInformation の場合は、以下のようになります。

syntax = "proto2";

package serialtest;

message Sensor {
    required string id = 1;
    required double ambientTemperature = 2;
    required double objectTemperature = 3;
    required double accelerometerX = 4;
    required double accelerometerY = 5;
    required double accelerometerZ = 6;
    required double altitude = 7;
    required double light = 8;
    required double humidity = 9;
}

protobuf のコードジェネレーター(protoc) を qmake プロジェクトで扱うには、エクストラコンパイラのステップを以下のように追加する必要があります。

PROTO_FILE = sensor.proto
protoc.output = $${OUT_PWD}/${QMAKE_FILE_IN_BASE}.pb.cc
protoc.commands = $${PROTO_PATH}/bin/protoc -I=$$relative_path($${PWD}, $${OUT_PWD}) --cpp_out=. ${QMAKE_FILE_NAME}
protoc.variable_out = GENERATED_SOURCES
protoc.input = PROTO_FILE
QMAKE_EXTRA_COMPILERS += protoc

次に、オブジェクトサイズの比較可能なベンチマークを得るために、QDataStream や JSON と同じように扱えるような QObject を継承した SensorInformationProto クラスを作成し、生成された構造体をそのクラスのメンバーとして使うようにします。

class SensorInformationProto : public QObject
{
    Q_OBJECT
    Q_PROPERTY(double ambientTemperature READ ambientTemperature WRITE setAmbientTemperature NOTIFY ambientTemperatureChanged)
[...]

public:
    SensorInformationProto(const std::string &pr);
[...]

     std::string serialize() const;
 [...]

private:
    serialtest::Sensor m_protoInfo;
};

この protoInfo のシリアル化の関数は protoc により生成されるため、転送されるペイロードの生成は以下のようになります。

std::string SensorInformationProto::serialize() const
{
    std::string res;
    m_protoInfo.SerializeToString(&res);
    return res;
}

 

一つ前のソリューションと比較すると、protobuf は std::string を利用するため、バイト配列として保持し(手動で)変換をしない限りは QString としては扱えません。これによって全体的な処理が遅くなってしまします。

パフォーマンス的な話をすると、ベンチマークの結果はよい感じでした。10,000 アイテムのベンチマークでたった 5 ms しかかからず、メッセージの平均サイズは 82 バイトでした。

以下の表が様々な選択肢の結果のまとめとなります。

Payload Size Time(ms)
JSON (text) 346 263
JSON (binary) 338 125
QDataStream 84 26
Protobuf 82 5

もうひとつの選択肢は CBOR で、これは Qt 5.12 に向けて Thiago Macieira が実装中のものです。しかし、開発は初期段階のため、今回の記事には含めることができませんでした。メーリングリストでの議論 によると、JSON と比較した場合には、すべてのメリットを保持した上に、パフォーマンスの結果はかなりいい感じのようです。

というわけで、MQTT のメッセージのペイロードとするデータのシリアル化についていくつかのアプローチを紹介しました。Qt だけで実現したものもあれば、外部のソリューション(protobuf)を利用したものもあります。紹介したとおり、外部のソリューションでも Qt の場合は連携は簡単です。

最後に免責事項です。すべてのベンチマークは、センサーデモのシナリオに基づいたものだということを強調しておきます。各メッセージ毎のデータ量が少ないケースを想定していました。データ構造が大きい場合には、結果は異なる可能性があり、最適なソリューションも違うかもしれません。

次回の記事では、DDS におけるメッセージのインテグレーションを紹介します。この連載の概要は Lars のこちらの記事 を参照してください。


Blog Topics:

Comments