Webベースのグラフィックエディタのアーキテクチャと7つのデザインパターン(2)
*この記事は、FEConf 2023で発表された『The Architecture of Web-Based Graphic Editors and 7 Design Patterns』のセッションを要約したものです。発表内容は2つのパートに分けて公開します。Part 1ではWebベースのグラフィックエディタの基本的なアーキテクチャと、その中に組み込まれているデザインパターンについて解説しました。Part 2では、実際にグラフィックエディタを実装し、その問題を解決していく過程で、デザインパターンをより深く掘り下げていきます。この記事の画像はすべて同名の発表スライドから引用しており、個別の出典表記は省略しています。発表スライドはFEConf 2023のウェブサイトからダウンロードできます。
「Webベースのグラフィックエディタのアーキテクチャと7つのデザインパターン」 / Heungwoon Shim、Naver フロントエンドエンジニア、FEConf 2023で発表
グラフィックエディタの実装とデザインパターンの適用
このセクションでは、サンプルグラフィックエディタを実装し、5つのデザインパターンを適用する方法を探ります。特定の実装詳細よりも、全体の流れとコンセプトに焦点を当ててご覧いただくことをお勧めします。
1. 保存されたモデルからコントローラー(Part)を作成する
私たちが作成するサンプルグラフィックエディタは、選択ツール、矩形ツール、ペンツールをサポートすると仮定しましょう。このエディタのドメインモデルは、矩形、円、パスで構成される以下のJSONで表現できます。
グラフィックエディタのドメインモデル
さて、最初の目標である「モデルからコントローラーを作成する」を見てみましょう。前述したように、内部構造は下の図のように実装されています。ハイライトされている部分は、作成に関与する構造です。グラフィックエディタはモデルを読み込み、グラフィックビューアに表示します。したがって、モデルがグラフィックエディタに渡されると、それがグラフィックビューアに現れます。
モデルからコントローラー(Part)を作成する
このプロセスは、次のような疑似コードで表現できます。左側のコードは、グラフィックエディタがグラフィックビューアにPartの作成を指示し、モデルをパラメータとして渡す様子を示しています。グラフィックビューアはcreateParts
を呼び出し、モデルをパラメータとして受け取り、モデルの配列をループ処理し、各アイテムのタイプに基づいて特定のPartを作成します。Partを作成した後、addPart
を使用してそれらをレンダリングし、画面に表示します。
モデルからコントローラーを作成する疑似コード
しかし、このPart作成ロジックは少しおかしくありませんか?下のハイライトされたコードは、新しい図形が追加されるたびに修正する必要があります。私たちは以前、変更される部分を分離するためにデザインパターンを使用できると述べました。ここでは、この変更される部分を分離するためにSimple Factoryを使用します。
非効率的なPart作成コード
ファクトリー3兄弟
解決策を見る前に、ファクトリーについて簡単に復習しましょう。ファクトリーには3つのタイプがあります。
- Simple Factory: オブジェクト生成を1つのクラスに集約するパターン。
- Factory Methodパターン: スーパークラスでオブジェクト生成のインターフェースを提供し、どのクラスをインスタンス化するかはサブクラスに任せるパターン。
- Abstract Factoryパターン: インターフェースを実装し、コンポジションを用いて関連する製品ファミリーを生成するパターン。
この例を解決するにはSimple Factoryで十分なので、それを使用します。
解決策 - 変更される部分の分離
ハイライトされたコードのif-else
条件文をPartFactory
に移動します。PartFactory
が分離されたため、それを呼び出すGraphicViewer
はもはや特定のPartタイプを知る必要がなくなります。したがって、createParts
関数は単にモデルをループ処理し、PartFactory
を呼び出してPartを作成するだけです。これは、新しいタイプが追加または変更された場合、GraphicViewer
を修正する必要はなく、PartFactory
のみを更新すればよいことを意味します。
変更される部分の分離
この方法には、主に2つの利点があります。
- 開放/閉鎖原則(OCP)
- 依存性逆転の原則(DIP)
Simple Factoryを使用することで、拡張に対しては開いており、修正に対しては閉じているというOCPを遵守します。また、生成責任をPartFactory
に委譲し、Part
という抽象に依存することでDIPも遵守します。
2. コントローラーからビュー(Figure)を作成する
次に、ビューの作成を見てみましょう。先ほど見たモデルは一次元のモデルでした。しかし、私たちは一次元のモデルを編集するだけでなく、入れ子構造、レイヤー、グループも扱う必要があります。これらの入れ子構造をどのように表現できるでしょうか?入れ子モデルは下の図のようになります。
入れ子モデル
入れ子モデルを簡単に扱うためのパターンがあります。それはコンポジットパターンです。コンポジットパターンは、クライアントが個々のオブジェクトと複合オブジェクトを統一的に扱えるようにします。
コンポジットパターン
コンポジットパターンの概念は、部分-全体階層という考え方から来ています。ノードのツリーを、あたかも単一のオブジェクトであるかのように扱います。つまり、下の図の灰色のグループ領域は、水色のノードオブジェクトと同じように扱われます。
部分-全体階層
このため、このパターンはグラフィカルユーザーインターフェースを実装する際によく使用され、Reactのレンダリングプロセスと非常に似ていることがわかります。
このパターンを使用すると、個々のビューと入れ子になったビューをレンダリングのために同じタイプとして扱うことができます。左側のコードでは、addChild
関数がPartをパラメータとして受け取りますが、これは単一のオブジェクトでも複合オブジェクトでもかまいません。どちらのタイプも子として追加できます。次に、render()
コードでは、モデルが最初にロードされたり変更されたりすると、Partのrenderメソッドが呼び出されます。それは自身をレンダリングし、次に子をループ処理してレンダリングします。
しかし、ここには1つ問題があります。テキストPartのように子を持つことができないコンポーネントの場合、右側のコードに示すように、addChild
が呼び出されたときにエラーを投げるコードが必要です。
コンポジットパターン
要約すると、このパターンを使用すると、レンダリングのような操作を単純なコードで構造全体に再帰的に適用できます。
コンポジットパターンの特徴
前の例はSRP(単一責任の原則)に違反していました。そのため、コンポジットパターンは透明性のためにSRPを犠牲にすることで知られています。ここで透明性とは、個々のオブジェクトと複合オブジェクトを同じ型として扱うことを意味します。これにより、型安全性が低下します。前の例のテキストPartで見たように、論理的に矛盾した操作が発生する可能性があり、それを防がなければなりません。したがって、このパターンは設計時に透明性と安全性のバランスを取る必要があると言われています。
コンポジットパターンの特徴
3. ツールを使用してモデルを修正する
次に、ツールを使用してモデルを修正する方法を見ていきましょう。編集プロセスに関連する構造には、下の図に示すように、Event Dispatcher、Command Stack、Root Part、Tool、Request、EditPolicyが含まれます。
編集に関連する構造
前述の通り、編集タスクとは「イベントを状態変化に変換するプロセス」です。このプロセスには特別なコンポーネントがあります。それは「ツール」です。先ほど紹介した構造でこのプロセスを詳しく見ると、下の図のようになります。
イベントフローの内部構造
Event Dispatcherは、低レベルのブラウザイベントを高レベルのエディタイベントに変換する役割を担います。Event Dispatcherはマスキングレイヤーを通じてイベントを受け取り、それを処理してキャンバスに渡します。
ツールの特徴
先ほど説明したツールには、明確な特徴があります。それは、同じイベントであっても、どのツールが選択されているかによって結果が異ならなければならないということです。例えば、矩形ツールが選択されている場合、ドラッグすると矩形が描画されるべきです。ペンツールが選択されている場合は、ペンで描画されるべきです。つまり、グラフィックエディタは一度に1つのツールしかアクティブにできません。
ツールの実装
左側のコードはEvent Dispatcherのものです。イベントリスニング部分は疑似コードで表現されています。例えば、mousemove
イベントを受け取り、transmitMouseMove
を呼び出します。transmitMouseMove
は次にイベントをビューアに渡します。右側のビューアのコードでは、イベントを受け取り、現在アクティブなツールに基づいて、矩形、円、またはペンストロークといった適切な要素を画面に描画します。
ツールの実装
このコードにも問題があります。右側のif-else
文は、新しいツールが追加されるたびに肥大化してしまいます。これは、GraphicViewer
のコードが修正に対して閉じていないことを意味します。また、同様のコードが繰り返されています。
解決策 - ステートパターン
この問題を解決する方法は、ステートパターンです。右側のコードでは、if-else
文が削除され、イベントはreceiveEvent
メソッドでツールに直接渡されます。Tool
は現在の状態を表すオブジェクトとなり、抽象基底クラスが状態固有の処理を具象サブクラスに委譲します。つまり、現在の状態を1つのクラスとして表現することが、ステートパターンの本質です。これにより、各状態の振る舞いを独自のクラスに分離でき、コード管理が容易になり、繰り返される条件ロジックを排除できます。
ステートパターンを使用すると、GraphicViewer
は修正に対して閉じており、拡張に対して開いている状態になり、OCPを遵守できます。
ステートパターン
要約すると、前の例の矩形、円、ペンツールは、それぞれの特定の状態を処理する具象クラスとして実装されました。この方法では、新しいツールの機能を実装するために、基本のTool
クラスを継承するだけで済みます。
4. MVC構造の完成
では、これまでのイベントフローを再確認し、MVC構造を完成させましょう。先ほどツールについて学びましたが、前の例のようにツールが直接モデルを修正することは望ましいでしょうか?MVC構造では、誰がモデルの修正方法を知っているべきでしょうか?
例えば、ビットマップを消去する消しゴムツールがあるとします。この消しゴムはビットマップ専用なので、左側のビットマップ画像は消去できますが、右側のベクター画像は消去できません。
この状況で、もし消しゴムツールが直接モデルを修正する場合、消去可能なPartとそうでないPartの両方について知っている必要があります。すると、新しいPartが追加されるたびに、その要素が消去可能かどうかを知る必要が出てきます。これは、新しい要素が追加されるにつれてコードを常に修正しなければならないことを意味し、消しゴムツールの開放/閉鎖原則に違反します。
これを解決するためには、Part自体がイベントの解釈方法を知っているべきです。消しゴムが要素の削除を要求したとき、Partがこのイベントを受け取り、処理します。
消しゴムツールのイベント処理
これをより根本的に考えてみましょう。MVCを使用する場合、誰がモデルを修正するのでしょうか?コントローラーです。私たちの例では、Part
がコントローラーと見なせます。したがって、Part
はイベントの解釈方法を知っており、モデルを修正できなければなりません。そして、ツールはイベントを直接Part
に渡すのではなく、イベントをRequest
オブジェクトに処理し、Part
にモデルの修正を依頼します。これにより、様々な編集リクエストをRequest
オブジェクトにカプセル化できます。ここで、Request
は高レベルのイベントと考えることができます。
では、モデル修正ロジックを直接Part
の中に置くべきでしょうか?それも問題です。なぜなら、編集ロジックが似ている場合、異なるPart
間で重複したロジックが発生する可能性があるからです。これは再びPart
の開放/閉鎖原則に違反することになります。
Partが修正ロジックを知っていることの問題点
解決策 - EditPolicyパターン
この問題を賢く解決する方法は、EditPolicy
パターンです。EditPolicy
は、Part
からモデル修正ロジックを分離して作成されたマイクロコントローラーであり、このロジックを再利用可能にします。
EditPolicy
以下のコードは、ツールのドラッグ動作を表しています。mousedown
イベントが発生するとドラッグが開始され、mouseup
イベントが発生するとdragEnd
が呼び出されます。dragEnd
では、changeModel
関数が呼び出されてモデルが変更されます。そして、ResizeTool
のchangeModel
メソッドは、以下に示すように特定のタスクを実行します。
EditPolicyの例 - リサイズツール
では、ResizeTool
からPolicyを分離しましょう。ResizeTool
のモデル修正コードをResizePolicy
に移動します。これにより、ResizeTool
のchangeModel
が抽象化されます。そして、Part
にinstallPolicy
関数を追加し、複数のポリシーを管理できるようにします。つまり、ポリシーは配列に格納され、Part
が様々なコントローラーロジックを適用できるようになります。
EditPolicyの抽出
これは、ResizeTool
はリクエストを送信するだけでよく、ResizeTool
にあったchangeModel
ロジックは、そのスーパークラスであるTool
に移動されることを意味します。
スーパークラスへの委譲
ストラテジーパターン
先ほど見たEditPolicy
は、ストラテジーパターンの実装です。このパターンは、変化するアルゴリズム(モデル修正ロジック)をコンテキスト(Tool
とPart
)から分離し、タスクをPolicy
オブジェクトに委譲することで機能します。Part
は、実行時であっても動的に自身の振る舞いを変更するためにPolicy
を選択できます。また、アルゴリズムの再利用も容易になります。
ストラテジーパターンの特徴
5. 作業履歴の管理
このセクションは、作業履歴を管理するために使用されるundoとredoに関連しています。私たちがこれまで構築してきたイベントフローでは、イベントはEvent Dispatcher、Graphic Viewer、Tool、Partを通過し、最終的にEditPolicy
がモデルを修正します。
EditPolicyによるコマンド作成
もしEditPolicy
のプロセスをもう一度改善するなら、EditPolicy
が直接モデルを修正する代わりに、Command
というオブジェクトを作成するようにします。このコマンドはCommand Stack
にプッシュされ、それがコマンドを実行してモデルを変更します。Command Stack
の内部では、モデル修正をカプセル化したCommand
オブジェクトが次々とスタックされ、undoやredoのような操作を可能にします。
Command Stack
最終的なフローの概要は下の図のようになります。ツールがイベントを受け取り、Part
のEditPolicy
にコマンドの作成を要求し、このコマンドがモデルを修正します。モデルが修正されると、Part
はリフレッシュを要求し、ビューが更新されます。
最終的なMVCフロー
結論
ここまで、Webベースのグラフィックエディタのアーキテクチャを通して、7つのデザインパターンを探求してきました。また、サンプルの実装コードを改善することで、デザインパターンを適用する方法も探りました。
今日共有した内容は、デザインパターンがどのように適用できるかの一例に過ぎません。この経験が、皆さんが独自のグラフィックエディタを構築する際に価値あるものとなり、また業務でデザインパターンを適用する際に役立つことを願っています。ぜひご自身の経験を通してより良いパターンを発見し、それを他の人々と共有し、その適用に協力し合うことをお勧めします。ありがとうございました。
Discussion