本稿は、「Comparing Data Serialization Formats: Code, Size, and Performance」の抄訳です。
この投稿では、データシリアライゼーションのさまざまな手法を取り上げ、構造化データ向けの主要な代表的フォーマットを比較します。これらはいずれも、Qtプロジェクトで容易に利用することができます。
現実的なシナリオを用いて、QDataStream、XML、JSON、CBOR、そして Protobuf の各データシリアライゼーション用フォーマットをテストし、比較を行いました。その結果、フォーマットごとに、コード、データサイズ、そしてシリアライズおよびデシリアライズ時間におけるパフォーマンスに違いが見られました。用途に応じた最適な形式を選択する際に、この情報をご参照ください。
以下の内容について詳しく解説します:
これらのフォーマットを適切にテストするために、複数のネスト階層と、QUuid、QDateTime、QColor などさまざまな Qt 型を含むタスク管理データ構造を使用しました。これにより、各データシリアライゼーション方式が複雑な入れ子構造を持つデータにどのように対応するかを検証できる、現実的なシナリオを構築しました。
次のステップとして、Qt Test ベンチマークを作成しました。データシリアライゼーション用フォーマットをテストするのに十分なデータ量を確保するため、1万件のタスクを含む TaskManager を生成し、それぞれのフォーマットを処理するために、QtCoreSerialization 関数と QtProtobuf 関数を定義しました:
#include "task_manager.h"
#include <QtTest/QTest>
TaskManager generateTasks(size_t amount);
class QtSerializationBenchmarks : public QObject
{
Q_OBJECT
public:
QtSerializationBenchmarks()
: m_testData(generateTasks(10'000))
{}
private slots:
void QtCoreSerialization_data() const;
void QtCoreSerialization();
void QtProtobuf();
private:
TaskManager m_testData;
};
ここでは、それぞれのフォーマットについてテスト内容を詳しく見ていきます。
Qt Core Serialization の概要でも説明されているように、構造化データにはさまざまなデータシリアライゼーション形式があります。本テストでは、アプリケーション設定を除き、複雑なデータ構造に適した汎用的なフォーマットに焦点を当てました。
QDataStream、XML、JSON、CBOR の各フォーマットは共通したインターフェースを備えており、serialize 関数が TaskManager をエンコードされた形式へ変換し、deserialize 関数が元のオブジェクトを再構築します。
各フォーマットでシリアライズ→デシリアライズ処理を一往復し、その結果が入力データと一致することを確認しました:
struct SerializationFormat {
QByteArray (*serialize)(const TaskManager &);
TaskManager (*deserialize)(const QByteArray &);
};
void QtSerializationBenchmarks::QtCoreSerialization_data() const {
QTest::addColumn("format");
QTest::newRow("QDataStream") << SerializationFormat{
serializeDataStream, deserializeDataStream };
QTest::newRow("XML") << SerializationFormat{
serializeXml, deserializeXml };
QTest::newRow("JSON") << SerializationFormat{
serializeJson, deserializeJson };
QTest::newRow("CBOR") << SerializationFormat{
serializeCbor, deserializeCbor };
}
void QtSerializationBenchmarks::QtCoreSerialization()
{
QFETCH(SerializationFormat, format);
QByteArray encodedData;
TaskManager taskManager;
QBENCHMARK {
encodedData = format.serialize(m_testData);
}
QBENCHMARK {
taskManager = format.deserialize(encodedData);
}
QTest::setBenchmarkResult(encodedData.size(), QTest::BytesAllocated);
QCOMPARE_EQ(taskManager, m_testData);
}
QDataStream は、Qt が標準で提供するバイナリ形式のデータシリアライゼーション機能であり、ストリーミング演算子を用いて型安全なデータ処理を行います。入出力演算子を実装することで、任意の型をシリアライズすることが可能です。
シリアライズ処理では、各データ構造に対して出力ストリーミング演算子を定義し、階層全体でフィールドの順序が一貫するようにしました。
#include <QtCore/QDataStream>
QDataStream &operator<<(QDataStream &stream, const TaskHeader &header) {
return stream << header.id << header.name << header.created << header.color;
}
QDataStream &operator<<(QDataStream &stream, const Task &task) {
return stream << task.header << task.description << qint8(task.priority) << task.completed;
}
QDataStream &operator<<(QDataStream &stream, const TaskList &list) {
return stream << list.header << list.tasks;
}
QDataStream &operator<<(QDataStream &stream, const TaskManager &manager) {
return stream << manager.user << manager.version << manager.lists;
}
QByteArray serializeDataStream(const TaskManager &manager)
{
QByteArray data;
QDataStream stream(&data, QIODevice::WriteOnly);
stream.setVersion(QDataStream::Qt_6_10);
stream << manager;
return data;
}
デシリアライズ処理では、データの完全性を保証するために、シリアライズ時の出力用演算子に対応した入力演算子を実装し、同じ順序でフィールドを読み取りました。
QDataStream &operator>>(QDataStream &stream, TaskHeader &header) {
return stream >> header.id >> header.name >> header.created >> header.color;
}
QDataStream &operator>>(QDataStream &stream, Task &task) {
qint8 priority;
stream >> task.header >> task.description >> priority >> task.completed;
task.priority = Task::Priority(priority);
return stream;
}
QDataStream &operator>>(QDataStream &stream, TaskList &list) {
return stream >> list.header >> list.tasks;
}
QDataStream &operator>>(QDataStream &stream, TaskManager &manager) {
return stream >> manager.user >> manager.version >> manager.lists;
}
TaskManager deserializeDataStream(const QByteArray &data)
{
TaskManager manager;
QDataStream stream(data);
stream.setVersion(QDataStream::Qt_6_10);
stream >> manager;
return manager;
}
Qt では、QXmlStreamWriter と QXmlStreamReader を通じて XML によるシリアライゼーションをサポートしています。Qt 固有のバイナリ形式である QDataStream とは異なり、XML は広く知られた規格であるため、異なるシステムやプログラミング言語間での相互運用性が保証されます。
シリアライズ処理では、QXmlStreamWriter を使用してデータ階層を XML の要素および属性へ変換します:
#include <QtCore/QXmlStreamWriter>
void encodeXmlHeader(QXmlStreamWriter &writer, const TaskHeader &header) {
writer.writeAttribute("id"_L1, header.id.toString(QUuid::WithoutBraces));
writer.writeAttribute("name"_L1, header.name);
writer.writeAttribute("color"_L1, header.color.name());
writer.writeAttribute("created"_L1, header.created.toString(Qt::ISODate));
}
void encodeXmlTask(QXmlStreamWriter &writer, const Task &task) {
writer.writeStartElement("task"_L1);
encodeXmlHeader(writer, task.header);
writer.writeAttribute("priority"_L1, QString::number(qToUnderlying(task.priority)));
writer.writeAttribute("completed"_L1, task.completed ? "true"_L1 : "false"_L1);
writer.writeCharacters(task.description);
writer.writeEndElement();
}
void encodeXmlTaskList(QXmlStreamWriter &writer, const TaskList &list) {
writer.writeStartElement("tasklist"_L1);
encodeXmlHeader(writer, list.header);
for (const auto &task : list.tasks)
encodeXmlTask(writer, task);
writer.writeEndElement();
}
void encodeXmlTaskManager(QXmlStreamWriter &writer, const TaskManager &manager) {
writer.writeStartElement("taskmanager"_L1);
writer.writeAttribute("user"_L1, manager.user);
writer.writeAttribute("version"_L1, manager.version.toString());
for (const auto &list : manager.lists)
encodeXmlTaskList(writer, list);
writer.writeEndElement();
}
QByteArray serializeXml(const TaskManager &manager)
{
QByteArray data;
QXmlStreamWriter writer(&data);
writer.writeStartDocument();
encodeXmlTaskManager(writer, manager);
writer.writeEndDocument();
return data;
}
デシリアライズ処理では、QXmlStreamReader が XML ドキュメントを順に処理します。readNextStartElement() を使って各要素を走査し、属性からデータを抽出してオブジェクト階層を再構築します:
#include <QtCore/QXmlStreamReader>
TaskHeader decodeXmlHeader(const QXmlStreamAttributes &attrs) {
return TaskHeader {
.id = QUuid(attrs.value("id"_L1).toString()),
.name = attrs.value("name"_L1).toString(),
.color = QColor(attrs.value("color"_L1).toString()),
.created = QDateTime::fromString(attrs.value("created"_L1).toString(), Qt::ISODate)
};
}
Task decodeXmlTask(QXmlStreamReader &reader) {
const auto attrs = reader.attributes();
return Task {
.header = decodeXmlHeader(attrs),
.priority = Task::Priority(attrs.value("priority"_L1).toInt()),
.completed = attrs.value("completed"_L1) == "true"_L1,
.description = reader.readElementText(),
};
}
TaskList decodeXmlTaskList(QXmlStreamReader &reader) {
const auto attrs = reader.attributes();
return TaskList {
.header = decodeXmlHeader(attrs),
.tasks = [](auto &reader) {
QList tasks;
while (reader.readNextStartElement() && reader.name() == "task"_L1)
tasks.append(decodeXmlTask(reader));
return tasks;
}(reader)
};
}
TaskManager decodeXmlTaskManager(QXmlStreamReader &reader) {
const auto attrs = reader.attributes();
return TaskManager {
.user = attrs.value("user"_L1).toString(),
.version = QVersionNumber::fromString(attrs.value("version"_L1).toString()),
.lists = [](auto &reader) {
QList taskLists;
while (reader.readNextStartElement() && reader.name() == "tasklist"_L1)
taskLists.append(decodeXmlTaskList(reader));
return taskLists;
}(reader)
};
}
TaskManager deserializeXml(const QByteArray &data)
{
QXmlStreamReader reader(data);
while (reader.readNextStartElement() && reader.name() == "taskmanager")
return decodeXmlTaskManager(reader);
return {};
}
Qt では、QJsonDocument、QJsonObject、および QJsonArray を通じて JSON によるシリアライゼーションをサポートしています。JSON は人間が読みやすいテキスト形式であり、Web API や設定ファイルの標準フォーマットとして広く利用されています。
シリアライズ処理では、入れ子になった型を扱うための補助関数を用いて、各データ構造を QJsonObject に変換し、JSON ドキュメントを生成します:
#include <QtCore/QJsonArray>
#include <QtCore/QJsonDocument>
#include <QtCore/QJsonObject>
QJsonValue encodeJsonHeader(const TaskHeader &header) {
return QJsonObject{
{ "id"_L1, header.id.toString(QUuid::WithoutBraces) },
{ "name"_L1, header.name },
{ "color"_L1, header.color.name() },
{ "created"_L1, header.created.toString(Qt::ISODate) }
};
}
QJsonValue encodeJsonTask(const Task &task) {
return QJsonObject{
{ "header"_L1, encodeJsonHeader(task.header) },
{ "description"_L1, task.description },
{ "priority"_L1, qToUnderlying(task.priority) },
{ "completed"_L1, task.completed }
};
}
QJsonValue encodeJsonTaskList(const TaskList &list) {
return QJsonObject{
{ "header"_L1, encodeJsonHeader(list.header) },
{ "tasks"_L1, [](const auto &tasks) {
QJsonArray array;
for (const auto &t : tasks)
array.append(encodeJsonTask(t));
return array;
}(list.tasks) }
};
}
QJsonValue encodeJsonTaskManager(const TaskManager &manager) {
return QJsonObject{
{ "user"_L1, manager.user },
{ "version"_L1, manager.version.toString() },
{ "lists"_L1, [](const auto &lists) {
QJsonArray array;
for (const auto &l : lists)
array.append(encodeJsonTaskList(l));
return array;
}(manager.lists) }
};
}
QByteArray serializeJson(const TaskManager &manager)
{
return QJsonDocument(encodeJsonTaskManager(manager))
.toJson(QJsonDocument::Compact);
}
デシリアライズ処理では、JSON ドキュメントをパースし、キーを基に値を取得します。その際、QJsonArray はリストに、QJsonObject は入れ子構造のデータに変換されます:
TaskHeader decodeJsonHeader(const QJsonObject &obj) {
return {
.id = QUuid(obj["id"_L1].toString()),
.name = obj["name"_L1].toString(),
.color = QColor(obj["color"_L1].toString()),
.created = QDateTime::fromString(obj["created"_L1].toString(), Qt::ISODate)
};
}
Task decodeJsonTask(const QJsonObject &obj) {
return {
.header = decodeJsonHeader(obj["header"_L1].toObject()),
.priority = Task::Priority(obj["priority"_L1].toInt()),
.completed = obj["completed"_L1].toBool(),
.description = obj["description"_L1].toString()
};
}
TaskList decodeJsonTaskList(const QJsonObject &obj) {
return {
.header = decodeJsonHeader(obj["header"_L1].toObject()),
.tasks = [](const auto &obj) {
QList tasks;
for (const auto &taskValue : obj["tasks"_L1].toArray())
tasks.append(decodeJsonTask(taskValue.toObject()));
return tasks;
}(obj)
};
}
TaskManager decodeJsonTaskManager(const QJsonObject &obj) {
return {
.user = obj["user"_L1].toString(),
.version = QVersionNumber::fromString(obj["version"_L1].toString()),
.lists = [](const auto &obj) {
QList lists;
for (const auto &listValue : obj["lists"_L1].toArray())
lists.append(decodeJsonTaskList(listValue.toObject()));
return lists;
}(obj)
};
}
TaskManager deserializeJson(const QByteArray &data)
{
const auto jsonRoot = QJsonDocument::fromJson(data).object();
return decodeJsonTaskManager(jsonRoot);
}
CBOR(Concise Binary Object Representation)は、JSON に代わるコンパクトなバイナリ形式を提供します。Qt では、 QCborValue、 QCborMap、 QCborArray クラス、および QCborStreamReader と QCborStreamWriter API を通じて、CBOR をネイティブにサポートしています。
シリアライズ処理は JSON と非常によく似ており、TaskManager の各フィールドをキーと値のペアとして書き出します。ただし、生成されるデータはコンパクトなバイナリ形式で保存されるため、より省スペースで、パースも高速です:
#include <QtCore/QCborArray>
#include <QtCore/QCborMap>
#include <QtCore/QCborValue>
QCborMap encodeCborHeader(const TaskHeader &header) {
return {
{ "id"_L1, QCborValue(header.id) },
{ "name"_L1, header.name },
{ "color"_L1, header.color.name() },
{ "created"_L1, QCborValue(header.created) }
};
}
QCborMap encodeCborTask(const Task &task) {
return {
{"header"_L1, encodeCborHeader(task.header)},
{"description"_L1, task.description},
{"priority"_L1, qToUnderlying(task.priority)},
{"completed"_L1, task.completed}
};
}
QCborMap encodeCborTaskList(const TaskList &list) {
return {
{ "header"_L1, encodeCborHeader(list.header) },
{ "tasks"_L1, [](const auto &tasks) {
QCborArray tasksArray;
for (const auto &t : tasks)
tasksArray.append(encodeCborTask(t));
return tasksArray;
}(list.tasks) }
};
}
QCborMap encodeCborTaskManager(const TaskManager &manager) {
return {
{ "user"_L1, manager.user },
{ "version"_L1, manager.version.toString() },
{ "lists"_L1, [](const auto &lists) {
QCborArray listsArray;
for (const auto &l : lists)
listsArray.append(encodeCborTaskList(l));
return listsArray;
}(manager.lists) }
};
}
QByteArray serializeCbor(const TaskManager &manager)
{
return QCborValue(encodeCborTaskManager(manager)).toCbor();
}
デシリアライズ処理では、QCborValue を使用するか、CBOR 構造を手動で走査することで、CBOR データを対応する Qt 型に読み戻します。これにより、論理構造を保ったまま、JSON 表現と CBOR 表現の間で容易に相互変換が行えます:
TaskHeader decodeCborHeader(const QCborMap &map) {
return TaskHeader{
.id = map["id"_L1].toUuid(),
.name = map["name"_L1].toString(),
.color = QColor(map["color"_L1].toString()),
.created = map["created"_L1].toDateTime(),
};
}
Task decodeCborTask(const QCborMap &map) {
return Task{
.header = decodeCborHeader(map["header"_L1].toMap()),
.priority = Task::Priority(map["priority"_L1].toInteger()),
.completed = map["completed"_L1].toBool(),
.description = map["description"_L1].toString()
};
}
TaskList decodeCborTaskList(const QCborMap &map) {
return TaskList {
.header = decodeCborHeader(map["header"_L1].toMap()),
.tasks = [](const auto &map) {
QList tasks;
for (const auto &taskValue : map["tasks"_L1].toArray())
tasks.append(decodeCborTask(taskValue.toMap()));
return tasks;
}(map)
};
}
TaskManager decodeCborTaskManager(const QCborMap &map) {
return TaskManager {
.user = map["user"_L1].toString(),
.version = QVersionNumber::fromString(map["version"_L1].toString()),
.lists = [](const auto &map) {
QList lists;
for (const auto &listValue : map["lists"_L1].toArray())
lists.append(decodeCborTaskList(listValue.toMap()));
return lists;
}(map)
};
}
TaskManager deserializeCbor(const QByteArray &data)
{
const auto cborRoot = QCborValue::fromCbor(data).toMap();
return decodeCborTaskManager(cborRoot);
}
Protobuf はインターフェース定義言語(IDL)を用いて、.proto ファイル内でデータ構造を定義します。その後、Protocol Buffer コンパイラがシリアライズ用コードを自動生成し、実装を簡潔かつ型安全なものにします。他のフォーマットではシリアライズ処理を手動で実装する必要がありますが、Protobuf ではスキーマ定義に基づいて効率的なバイナリ形式のシリアライズコードが自動生成されます。
データ構造は、Protobuf の IDL 構文を使って定義しました。QtProtobufQtCoreTypes および QtProtobufQtGuiTypes モジュールは、QUuid、QDateTime、QColor といった Qt 型を自動的に変換し、Qt の型システムと密接に統合します:
syntax = "proto3";
package proto;
import "QtCore/QtCore.proto";
import "QtGui/QtGui.proto";
message TaskHeader {
QtCore.QUuid id = 1;
string name = 2;
QtGui.QColor color = 3;
QtCore.QDateTime crated = 4;
}
message Task {
enum Priority {
Low = 0;
Medium = 1;
High = 2;
Critical = 3;
}
TaskHeader header = 1;
Priority priority = 2;
bool completed = 3;
string description = 4;
}
message TaskList {
TaskHeader header = 1;
repeated Task tasks = 2;
}
message TaskManager {
string user = 1;
QtCore.QVersionNumber version = 2;
repeated TaskList lists = 5;
}
task_manager.qpb.h ヘッダーは、.proto ファイルの定義から自動生成されたものです。TaskManager 構造体内に変換演算子を実装し、独自の TaskManager 型と生成された proto::TaskManager 型の間の橋渡しを行いました。
テストデータを Protobuf 形式に変換した後は、QProtobufSerializer が実際のバイナリシリアライズおよびデシリアライズ処理を担当し、複雑なデータ変換をシンプルな API 呼び出しに簡略化しています:
#include "task_manager.qpb.h"
#include <QtProtobuf/QProtobufSerializer>
#include <QtProtobufQtCoreTypes/QtProtobufQtCoreTypes>
#include <QtProtobufQtGuiTypes/QtProtobufQtGuiTypes>
TaskManager::operator proto::TaskManager() const {
proto::TaskManager protoManager;
protoManager.setUser(user);
protoManager.setVersion(version);
auto readHeader = [](TaskHeader header) {
proto::TaskHeader h;
h.setId_proto(header.id);
h.setName(header.name);
h.setColor(header.color);
h.setCrated(header.created);
return h;
};
QList protoLists;
for (const auto &list : lists) {
proto::TaskList protoList;
protoList.setHeader(readHeader(list.header));
QList protoTasks;
for (const auto &task : list.tasks) {
proto::Task t;
t.setHeader(readHeader(task.header));
t.setDescription(task.description);
t.setPriority(proto::Task::Priority(task.priority));
t.setCompleted(task.completed);
protoTasks.append(t);
}
protoList.setTasks(std::move(protoTasks));
protoLists.append(std::move(protoList));
}
protoManager.setLists(std::move(protoLists));
return protoManager;
}
void QtSerializationBenchmarks::QtProtobuf()
{
const proto::TaskManager protoTestData = m_testData;
QtProtobuf::registerProtobufQtCoreTypes();
QtProtobuf::registerProtobufQtGuiTypes();
QProtobufSerializer serializer;
QByteArray encodedData;
proto::TaskManager protoTaskManager;
QBENCHMARK {
encodedData = serializer.serialize(&protoTestData);
}
QBENCHMARK {
serializer.deserialize(&protoTaskManager, encodedData);
}
QTest::setBenchmarkResult(encodedData.size(), QTest::BytesAllocated);
QCOMPARE_EQ(protoTaskManager, protoTestData);
}
各シリアライゼーション用フォーマットを比較するため、10,000 件のタスクを複数のタスクリストに分配した TaskManager を用いてベンチマークを実施しました。
テストは、Qt 6.10.0 を用いて、macOS(ARM64 アーキテクチャ) 上で行いました。
以下は、各データシリアライゼーション用フォーマットのテスト結果の概要です。
| フォーマット | シリアライズ時間 | デシリアライズ時間 | シリアライズ後のサイズ |
| QDataStream | 0.50 ms | 1 ms | 1127 KB |
| XML | 6.5 ms | 7.5 ms | 1757 KB |
| JSON | 14 ms | 6 ms | 2005 KB |
| CBOR | 10 ms | 6.7 ms | 1691 KB |
| QtProtobuf | 10 ms | 7.7 ms | 890 KB |
QDataStream は全体として最も高速なフォーマットであり、シリアライズ時間は 1 ミリ秒未満、デシリアライズも非常に高速でした。シンプルなストリーミング演算子を利用しているため、Qt ベースのアプリケーション内では高い効率を発揮します。ただし、Qt に強く依存しているため、QDataStream は Qt 環境内での内部データ処理や一時的なデータ保存に最も適しています。
XML は人間が読みやすいテキストベースの構造を持ち、標準化されたスキーマを強力にサポートしています。透明性や手動での編集が必要なシステムにおいては、柔軟な選択肢です。記述が冗長なためファイルサイズは比較的大きくなりますが、実際の多くのユースケースで十分な性能を発揮し、レガシーシステムとの設定やデータ交換にも適しています。
JSON は、汎用的な互換性とシンプルさにより、現在も広く採用されているフォーマットです。軽量なデータ交換が求められる Web アプリケーションやネットワークプロトコルに特に適しています。デシリアライズは高速ですが、シリアライズは比較的遅く、生成されるファイルは今回のテストで最も大きなサイズとなりました。それでも、JSON の読みやすさと幅広いエコシステムによるサポートにより、API やクロスプラットフォーム間でのデータ交換において信頼性の高い選択肢となっています。
CBOR(Concise Binary Object Representation)は、JSON に似たデータ構造を持つコンパクトなバイナリ形式を提供します。JSON と同様の構造的なわかりやすさを維持しつつ、より少ないストレージ容量で表現できるのが特長です。QDataStream と比べるとパフォーマンスは中程度ですが、データのコンパクトさと相互運用性のバランスに優れています。特に、JSON との互換性を保ちながら効率性を高めたいネットワークプロトコルや通信シナリオに適しています。
Protobuf は、効率的なバイナリエンコーディングとスキーマベースの構造のおかげで、最も小さいシリアライズ出力を達成していました。厳密な型付けを採用しているため、より予測可能かつ安全なデータの送受信や保存を行うことができます。一方で、コード生成が必要となる点や、それに伴う変換処理のオーバーヘッドが開発フローを複雑にする可能性もあります。Protobuf は、帯域幅やストレージ効率が重要となる大容量データの保存やネットワーク通信に特に適しています。
最適なデータシリアライゼーション用フォーマットは、パフォーマンス、サイズ、可読性、あるいはクロスプラットフォームでの互換性等の、どの点を重視するかによって異なります。Qt 環境内での速度に優れているものは QDataStream ですが、JSON や XML はより広い相互運用性を提供します。CBOR と Protobuf は効率と構造のバランスが取れており、特に Protobuf は最もコンパクトな出力を実現します。
各データシリアライゼーション方式の長所とトレードオフを理解することで、アプリケーションの速度・スケーラビリティ・保守性を最適化するための判断が可能になります。
この記事で紹介したベンチマークコードは、こちらからご覧いただけます。
Qt のデータ処理機能について詳しく知りたい方は、こちらをご参照ください。