ソフトウェアアーキテクチャ移行の進め方
QtWidgetsのレガシーコードベースをQtQuickへ移行するシリーズ、第3回です。前回までに、Qtアプリケーションで用いられる典型的なソフトウェアアーキテクチャを比較し、MVPパターンを目指すことを決定しました。また、アーキテクチャ変更前にレガシーアプリケーションをテストで保護する方法についても説明しました。
アプリケーションの保護が完了した今、ソフトウェアアーキテクチャ移行の本格的な開始時です。これは複数ステップからなるプロセスであり、本稿でその手順を提示します。前回投稿で紹介しテストで堅牢性を確保したサンプルアプリケーションを用いて解説します。
このサンプルプロジェクトで意外に思われる点の一つは、QTableWidgetとQComboBoxを独立したアイテムモデルなしで利用していることです。現代では誰もこんなことはしないと考えるかもしれませんが、実際にはアプリケーション内で速度を優先して手抜きをした箇所が見つかるものです。あまり一般的ではない状況かもしれませんが、対処方法を理解しておく必要があります。
良好な移行のためのアドバイスとして、まずこうした欠落しているアイテムモデルへの対応から着手することをお勧めします。このサンプルアプリケーションの場合、WindowクラスのupdateComboBox()メソッドとupdateTable()メソッドを削除することを意味します。
これは、次のようにRepositoryクラス上に薄いアダプターを実装するだけの簡単な作業です(コミット 18d01e3)。
class ItemTableModel : public QAbstractTableModel {
Q_OBJECT
public:
explicit ItemTableModel(QObject *parent = nullptr);
void reloadData();
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
int columnCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override;
QHash roleNames() const override;
private:
Repository *m_repository;
};
実装面では、ほとんどの手法は単純ですが、理解を深めるためにいくつか焦点を当てます。
まず、Repositoryから来るデータをラップする方法を示すdata()メソッドです。
QVariant ItemTableModel::data(const QModelIndex &index, int role) const {
if (!index.isValid() || index.parent().isValid()) {
return {};
}
if (role != Qt::DisplayRole && role != Qt::UserRole) {
return {};
}
const auto item = m_repository->findAllItems().value(index.row());
if (role == Qt::UserRole) {
return item.id();
}
switch (index.column()) {
case 0:
return item.name;
case 1:
return item.sellIn;
case 2:
return item.quality;
default:
return {};
}
Q_UNREACHABLE();
}
提案された実装は特に効率的ではありませんが、これは主にコードをシンプルに保つためです(Repositoryにはキャッシュやより高度なクエリ機能が望ましいでしょう)。とはいえ、各項目を行に、各フィールドを列に単純にマッピングしている点が非常に明確に示されています。また、項目のid()に対してUserRoleを使用しています。これは現時点では厳密には必要ありませんが、後々重要になります。
UserRoleを使用するため、後続の検証を容易にするために名前を付けることが推奨されます。
QHash ItemTableModel::roleNames() const {
auto result = QAbstractItemModel::roleNames();
result.insert(Qt::UserRole, "user");
return result;
}
最後に、reloadData()の実装について説明します。
void ItemTableModel::reloadData() {
const auto topLeft = index(0, 0);
const auto bottomRight = index(rowCount() - 1, columnCount() - 1);
emit dataChanged(topLeft, bottomRight);
}
このメソッドは単にビューに対してデータ全体を再読み込みするよう通知するだけです。繰り返しになりますが、これは単純化のために非常に素朴な実装であり、実際のシステムではより洗練された実装が必要になるでしょう。
Repositoryをラップする動作するアイテムモデルができたので、次はWindowでそれを利用します(commit ce4c3f2)。具体的には次の3点です。
m_itemModelフィールドをWindowに導入QTableWidgetからQTableViewに切り替えItemModelを使用これにより、Windowのコンストラクタが少し変わります。
Window::Window(QWidget *parent)
: QWidget(parent)
, ui(new Ui::Window)
, m_repository(Repository::instance())
, m_itemModel(new ItemTableModel(this)) // the new model field
{
ui->setupUi(this);
ui->currentItemCombo->setModel(m_itemModel); // using the model in the combo box
ui->tableView->setModel(m_itemModel); // now a table view
connect(ui->currentItemCombo, &QComboBox::currentIndexChanged,
this, &Window::onCurrentItemChanged);
connect(ui->qualitySpinBox, &QSpinBox::valueChanged,
this, &Window::onQualityChanged);
connect(ui->sellInSpinBox, &QSpinBox::valueChanged,
this, &Window::onSellInChanged);
connect(ui->updateButton, &QPushButton::clicked,
this, &Window::onUpdateCurrentItemQuality);
m_itemModel->reloadData(); // Needs to be done everywhere we were calling updateTable() before
onCurrentItemChanged();
ui->tableView->horizontalHeader()->resizeSections(QHeaderView::ResizeToContents);
}
コンストラクタに加えて、クラス内のあらゆる場所にある updateComboBox() および updateTable() の呼び出しを削除し、それらを m_itemModel->reloadData() で置き換える必要があります。これで、QAbstractItemModel サブクラスへの切り替えは完了です。
さて、プロキシの導入を始めましょう。このサンプルアプリケーションでは2つのプロキシが必要です。1つはItemドメインオブジェクト用、もう1つはWindowに隠された残りのビジネスロジックを受け取るために使用します。
設定が容易なプロキシは、ドメインオブジェクトをラップするために必要なものです。今回のケースでは単純に Itemです。本シリーズの最初の部分で見たように、これは QObjectを継承する必要があり、今回のケースでは Item上で利用可能なすべての情報を反映し、それらがバインド可能な Q_PROPERTYとして公開されることを保証します(コミット 8050846)。
したがって、インターフェースは次のようになります。
class ItemProxy : public QObject {
Q_OBJECT
Q_PROPERTY(quint64 itemId READ itemId WRITE setItemId NOTIFY itemIdChanged)
Q_PROPERTY(QString name READ name WRITE setName NOTIFY nameChanged)
Q_PROPERTY(int sellIn READ sellIn WRITE setSellIn NOTIFY sellInChanged)
Q_PROPERTY(int quality READ quality WRITE setQuality NOTIFY qualityChanged)
public:
explicit ItemProxy(QObject *parent = nullptr);
[[nodiscard]] Item item() const;
void reloadData();
[[nodiscard]] quint64 itemId() const;
void setItemId(quint64 id);
[[nodiscard]] QString name() const;
void setName(const QString &name);
[[nodiscard]] int sellIn() const;
void setSellIn(int sellIn);
[[nodiscard]] int quality() const;
void setQuality(int quality);
signals:
void itemIdChanged(quint64 itemId);
void nameChanged(const QString &name);
void sellInChanged(int sellIn);
void qualityChanged(int quality);
private:
Item m_item;
Repository *m_repository;
};
セッターとゲッターは単に基盤となるItemへの転送であり、必要に応じて変更を通知します。
QString ItemProxy::name() const {
return m_item.name;
}
void ItemProxy::setName(const QString &name) {
if (m_item.name == name) {
return;
}
m_item.name = name;
emit nameChanged(m_item.name);
}
興味深い部分はreloadData()です。
void ItemProxy::reloadData() {
const auto newItem = m_repository->findItem(itemId());
if (m_item == newItem) {
return;
}
m_item = newItem;
emit sellInChanged(m_item.sellIn);
emit qualityChanged(m_item.quality);
}
この処理はラップされたItemを直接Repositoryに問い合わせて更新します。そして実際にItemが変更されている場合、その変更を通知します。
これにより、Windowへの最初の根本的な変更(コミット2ed0152)を実現できます。
m_currentItemは削除され、代わりにItemProxyのインスタンスであるm_itemProxyが使用m_currentItemを経由していた全ての呼び出しは、代わりにm_itemProxyを経由updateFields()は完全に削除され、代わりにスピンボックスをプロキシに直接接続したがって、Windowのコンストラクタは次のようになります。
Window::Window(QWidget *parent)
: QWidget(parent)
, ui(new Ui::Window)
, m_repository(Repository::instance())
, m_itemProxy(new ItemProxy(this))
, m_itemModel(new ItemTableModel(this))
{
ui->setupUi(this);
// The two following connects are new and allow to get rid of updateFields()
connect(m_itemProxy, &ItemProxy::sellInChanged,
ui->sellInSpinBox, &QSpinBox::setValue);
connect(m_itemProxy, &ItemProxy::qualityChanged,
ui->qualitySpinBox, &QSpinBox::setValue);
ui->currentItemCombo->setModel(m_itemModel);
ui->tableView->setModel(m_itemModel);
connect(ui->currentItemCombo, &QComboBox::currentIndexChanged,
this, &Window::onCurrentItemChanged);
connect(ui->qualitySpinBox, &QSpinBox::valueChanged,
this, &Window::onQualityChanged);
connect(ui->sellInSpinBox, &QSpinBox::valueChanged,
this, &Window::onSellInChanged);
connect(ui->updateButton, &QPushButton::clicked,
this, &Window::onUpdateCurrentItemQuality);
updateTable();
onCurrentItemChanged();
ui->tableView->horizontalHeader()->resizeSections(QHeaderView::ResizeToContents);
}
それでは次に、ビジネスルールの受け取りを目的とした別のプロキシを紹介しましょう。
この時点で、現在のItem(この概念は現在ItemProxyによって具現化されている)の値と、システム内で利用可能なすべてのItem(ItemTableModelによって表される)のデータをWindowに表示してあります。
これらはGUIの文脈において明確な関係性を持っているため、両者を結びつけるためにPageProxyを導入します(コミット 020620a)。
class PageProxy : public QObject {
Q_OBJECT
Q_PROPERTY(ItemProxy* currentItem READ item CONSTANT)
Q_PROPERTY(ItemTableModel* model READ model CONSTANT)
QML_ELEMENT
public:
explicit PageProxy(QObject *parent = nullptr);
[[nodiscard]] ItemProxy *item() const;
[[nodiscard]] ItemTableModel *model() const;
private slots:
void onValueChanged();
private:
ItemProxy *m_itemProxy;
ItemTableModel *m_itemModel;
Repository *m_repository;
};
実装はまだかなり簡素ですが、このクラスが実際にどのように要素を結びつけるのかを理解するためには、いくつかの重要なポイントを押さえることが重要です。
PageProxy::PageProxy(QObject *parent)
: QObject(parent)
, m_itemProxy(new ItemProxy(this))
, m_itemModel(new ItemTableModel(this))
, m_repository(Repository::instance())
{
connect(m_itemProxy, &ItemProxy::qualityChanged,
this, &PageProxy::onValueChanged);
connect(m_itemProxy, &ItemProxy::sellInChanged,
this, &PageProxy::onValueChanged);
}
まずコンストラクタは、ItemProxy によって通知される変更に対してどのように反応するかを示します。onValueChanged() を見てみましょう。
void PageProxy::onValueChanged() {
m_repository->save(m_itemProxy->item());
m_itemModel->reloadData();
}
したがって、現在のItemで値が変更されるたびに、ItemProxyによって通知されます。これを受けて、PageProxyはRepositoryを介して現在のアイテム変更を保存し、ItemTableModelにデータの再読み込みを要求することで反応します。これはかなり単純なルールですが、どこからか始めなければなりません。
単純であっても、これはすでにWindow(コミット 948ec57)に興味深い変更をもたらしており、特にそのコンストラクタにおいて顕著です。
Window::Window(QWidget *parent)
: QWidget(parent)
, ui(new Ui::Window)
, m_repository(Repository::instance())
, m_pageProxy(new PageProxy(this))
{
ui->setupUi(this);
// Here we folded `onCurrentItemChanged()` into a lambda
connect(ui->currentItemCombo, &QComboBox::currentIndexChanged,
[=] {
const auto id = ui->currentItemCombo->currentData().value();
m_pageProxy->item()->setItemId(id);
});
// We can connect both ways to the ItemProxy in the PageProxy now
connect(m_pageProxy->item(), &ItemProxy::sellInChanged,
ui->sellInSpinBox, &QSpinBox::setValue);
connect(ui->sellInSpinBox, &QSpinBox::valueChanged,
m_pageProxy->item(), &ItemProxy::setSellIn);
connect(m_pageProxy->item(), &ItemProxy::qualityChanged,
ui->qualitySpinBox, &QSpinBox::setValue);
connect(ui->qualitySpinBox, &QSpinBox::valueChanged,
m_pageProxy->item(), &ItemProxy::setQuality);
// This didn't change
connect(ui->updateButton, &QPushButton::clicked,
this, &Window::onUpdateCurrentItemQuality);
// updateTable() is gone
ui->currentItemCombo->setModel(m_pageProxy->model());
ui->tableView->setModel(m_pageProxy->model());
ui->tableView->horizontalHeader()->resizeSections(QHeaderView::ResizeToContents);
}
これらの変更に伴い、Windowからさらにいくつかのメソッドが削除されました。
onUpdateCurrentItemQuality()のみ残存)updateTable()は削除され、その役割はPageProxyが担うWindowの空っぽ化に向けて大きな一歩を踏み出しましたが、移行はまだ完了していません。実際にはItemTableModelとItemProxyを統合したに過ぎません。残存するビジネスロジックの大部分は、依然としてPageProxyへ移行する必要があります。
このシリーズの次回(最終回)では、ビジネスルールを新しいプロキシに移行する作業を完了させます。その後、このMVPへの移行が実際のGUI開発においてどのように役立つかを確認します。
enioka Haute Coutureについて
あらゆる業種でITシステムへの依存度が高まっています。しかし多くの企業が、この重要な資産の管理権を失っています。
enioka Haute Couture は、プロジェクトの領域・組織・タイミングのいずれに起因する複雑なソフトウェア開発の習得を専門としています。顧客チームと密接に連携し、オーダーメイドのソフトウェアソリューションを開発します。ソフトウェア開発においては、顧客が管理権を保持できるよう、eniokaは一歩踏み込んだ取り組みを行います。
eniokaは顧客チームが適切な組織体制・技術・ツールを採用する支援を行います。さらに、適切な場合にはフリー&オープンソースソフトウェアプロジェクトの立ち上げ支援や既存プロジェクトとの連携も提供します。
enioka Haute Couture は、お客様がシステムを完全に管理しながら開発ニーズを実現する、信頼できるパートナーです。ベンダーやサービスプロバイダーへの依存から解放されます。