はじめに
Webフロントエンド開発を中心に行っている寺島です。
前回に引き続いて、Web フロントエンド刷新プロジェクトにおけるソフトウェアアーキテクチャについて紹介します。 前回の内容が前提となっているので、先に以下の記事をご覧ください。
この文章では、最初にプロジェクトで重視すべきと考えている点と全体的な方針を紹介します。 これは、具体的な構成の理由を理解するために重要な情報です。
そのあとで、具体的な構成を紹介をします。 対象が Web フロントエンドということもあり、具体的にはソフトウェアモジュールとその関係性・責任を紹介します。
重視しているもの
前回の記事で紹介した通り、一度のリリースのスコープを小さくして反復的に開発・リリースを行う方針をとっています。 したがって、そうでない場合と比較して一度実装された部分の拡張や変更が頻繁に発生すると考えられます。
そんな中でも、開発スピードを維持し変更による障害を低く維持したいです。それには、ソフトウェアが高いモジュール性を保つことが重要と考えています。 つまり、モジュールの責任が明確であり依存関係が秩序的であることで、変更による影響が構造的に予測しやすくなるなどのメリットを享受し、継続的な開発を維持したいと考えました。
実際、プロジェクト開始から基本的に二週間に一度のリリースを維持しすることができています。
アーキテクチャの方針
ソフトウェアをモジュールとそれらの関連として表現しようとしたときに、トップレベルになるそれぞれのモジュールをどのような観点で表現するかを選択する必要があります。 一般的には以下の二種類があると思います。これらのいずれかが一方より優れているというものではなく、それぞれの特徴を踏まえて選択する必要があります。
- ソフトウェアを構成する機能的な役割に基づいて分類する
- ビジネスを構成する概念に基づいて分類する
このプロジェクトでは前者を選択しました。
その理由ですが、20 年以上積み重なってきた実装から概念を整理しソフトウェアとして再構成すること自体 (時間をかけたとしても) 容易なことではないと考えました。 さらに、この長い期間に多くの社員が入社・退社をしているので、業務やそれに付随する概念を把握し言語化できる人が存在することも期待できないと考えるのが妥当と判断しました。
また、現状のソフトウェアはそもそもモジュールという概念で表現されるような実装のグルーピングがほとんどないため、何よりも変更による影響範囲を予測しやすくするためのグルーピングを早く行うべきであると考えました。 逆に、一度グルーピングができれば、それらの配置を変更して後者のように分類していくことも可能であるはずです (容易であるとは限りませんが)。
以上のように、このプロジェクトでは『いち早くソフトウェアに秩序を与えること』を重視し、ソフトウェアを構成する機能的な役割に基づいてトップレベルのモジュールを分割し、ソフトウェアを構成する方針を取りました。
一方で、『ビジネスを構成する概念に基づいて分類する』方針を採用しそれに合わせた組織・開発プロセスを構築できれば、Web Components という技術要素の性質も相まって、 ビジネス領域ごとにソフトウェアの開発を独立させることが容易となり、結果、もっと機敏な開発や改善が実現できたかもしれません。 (マイクロフロントエンドと呼ばれるコンセプトに近いものだと思います)
ソフトウェアを構成する要素と関係
前述のとおり、本プロジェクトではソフトウェアの構成を機能的な役割に基づいて分割することにしました。 この章では、トップレベルの要素とそれらの関係を最初に示し、それ以降で各モジュールの役割と、その内部的な構成要素などを紹介していきます。
なお、ここで紹介する各構成要素は、プロジェクト開始当初はディレクトリ階層で表現していました。 ただ、それぞれのモジュールごとにビルドを行うことでモジュールの境界がより明確になることをメリットに感じ、現在は別々の npm package としています。 以降では、その前提で説明します。
実際は npm workspace を使って monorepo として、単一の git リポジトリで管理されています。
全体
全体的には以下のような構成になっています (矢印は依存関係を示しています)。
まず、アプリケーションとライブラリという二つの構成要素で分類しています。 物理的には、ただのディレクトリ階層です。これらに含まれる各モジュールが npm package となります。
アプリケーションは、直接ブラウザ上で読み込ませて使う JS ライブラリとしてビルド・デプロイされる単位です。 それぞれは独立しており、依存関係を持ちません。
また、既存の資産を利用して独立したアプリケーションを構築するなどの場合もこのカテゴリに新しいパッケージを追加していく想定です。
そして、アプリケーションはライブラリに依存しています。 ライブラリにはいくつかのパッケージが含まれますが、そのいずれにも依存することができます。 一方でライブラリ内のパッケージ間には依存方向を定めており、これを破ることはできません。
実装上は eslint の import/no-extraneous-dependencies
ルールを利用し、ここで定めている依存関係に合わせて各 npm package の dependencies フィールドを設定しています。
それぞれの役割や依存対象についてはの説明は以降の個別パッケージの説明で紹介します。
domain パッケージ
DDD の文脈などでも用いられる用語の『ドメイン』を意識して domain という名前を与えています。 システム全体で見たときには、このような概念の多くはバックエンドで実装されるため、あくまでもフロントエンド領域から見える概念やロジックのみを対象としています。
前述のとおり、現在のソフトウェアで用いられている概念を理解し、整理し、表現することは容易ではありません。
したがって、このパッケージの現実的な責任として、UI ライブラリなどに依存しないロジックを集約するものとしています。 そのため、このパッケージで実装されているコードはアプリケーションが React.js であっても Vue.js であっても利用できるという状態にしています。
例えば、商品を購入するロジックの型は以下のような感じになります (実際のコードではありません)。
type BuyItemApi = (id: string, num: number) => Promise<ShoppingCart> type BuyItem = (client: BuyItemApi) => (id: string, num: number) => Promise<ShoppingCart>
特定のフレームワークなどには依存させずに、素朴な型と関数で必要なロジックなどを表現していきます。
また、外部APIの呼び出しを行う関数を引数として受け取り、依存の注入をさせるように実装しています。
これは、外部呼出しの詳細を infra パッケージの責任とし、domain パッケージの責任を、例えば、購入処理における検証などのロジックに限定するためです。
また、このような実装のおかげで単体テストは、client
に jest.fn()
を渡すことで比較的簡単に実装できます。
domain パッケージ内のコードの整理は、概念の体系がわからないという理由でかなり難しいです。 そのため、近しいと思われるものを近く置くということは行われていますが、それほど構造化などがされておらず、並列に色々な概念が並んでいるような状態です。 一方で、TypeScript は比較的コードの配置変更が容易なので、ある程度コードが増えてきたところで再配置を行うという形で問題ないだろうと判断しています。
infra パッケージ
domain パッケージで抽象化された、外部API呼び出しや Web Storage などの永続化機構とのやり取りが想定される部分の具体的な実装をします。 現状のアプリケーションに存在する API には歴史的な経緯などから直感的ではない呼び出し方法、レスポンス、エラー表現など、多様な実装が存在します。 素朴にそれを直接呼び出すような実装を各パッケージ内に配置すると、ソフトウェアの大部分が現状の実装に引っ張られた状態になってしまいます。
それを回避するために、ユースケースなどから妥当と思われるインターフェースを検討しそれを型で表現し domain に配置します。 そして、それを infra で実装することで、現状の実装に引っ張られる部分を infra に押し込むことができます。
また、フロントエンドと同様にバックエンドも刷新を進めています。既存の API が新しいものに置き換わった際に必要な変更も局所化することができます。 infra パッケージに依存しているのは、アプリケーションカテゴリーに属するパッケージです。それぞれのアプリケーションが利用する実装を選択し、ライブラリカテゴリに属するその他パッケージらはそれらの詳細を知らない状態を維持しています。
styles パッケージ
このパッケージでは、サイト内で統一的に利用したいルールや scss 変数を配置します。 Storybook for HTML でドキュメンテーションを行うなどしていますが、ソフトウェアの実装的には特筆すべきことはなく、単にほかのパッケージから変数やルールを参照できるようにしているという状態です。
components パッケージ
本プロジェクトではフロントエンドライブラリに Vue.js を利用しています。 そして、このパッケージには以下が実装・定義されています。
- Vue.js のコンポーネント
- リアクティブな値 (
Ref
など) やライフサイクルフック (mounted
など) に依存するロジック - コンポーネントが要求するデータやオブジェクトを注入するための
InjectionKey
コンポーネントは Atomic Desgin を意識して三層でカテゴライズをしています。
既存の UI にはそれぞれの時代の名残などで同じ役割でも多様性があります。そのため、どのレイヤに割り当てるかやコンポーネント自体をどう定義するかに難しさがあります。
これを機に統一的なUIパーツを定義して、そこに置き換えていくという方法もあり得ましたが、 domain パッケージと同様に、存在する画面の全体やそのユースケースなどが見えない状態でそれらを決定しながら進めるというのは容易ではありません。
『いち早くソフトウェアに秩序を与えること』を重視すると決めたため、この段階で行うことのスコープは小さくし、とにかくコンポーネントへの移行を迅速に行うべきと判断しました。 一方で、明らかに表示に違和感があるものや、別の場所で実装した既存のコンポーネントで置き換えても問題がないと明らかな場合は関係者に確認したうえでそちらを利用することにしています。 (ある時期にデザインを変更したけど、そこへの追従がされていない比較的古いページなどが典型的な例です)
また、いったんコンポーネントにした後で、デザインを特定のものに寄せていくということは可能です。 そのような変更をできるだけ容易にするために、ロジック部分はコンポジション関数らに分離して再構成をしやすくしています。
加えて、前述のとおり、特定の API クライアントを直接インポートせずに親コンポーネントから provide されたものを利用しています。 結果、 components パッケージは infra パッケージについて知る必要がなくなります。 これによって、Storybook など特定の開発環境においては、任意のふるまいをする実装を注入することができるので、テストやデバッグが容易になります。
webcomponents パッケージ
主に components パッケージで実装したコンポーネントを Web Components にして既存のアプリケーション内で利用可能にする役割を持ちます。 加えて、既存の実装と協調するための汚れ役をすべてここに集約します。
Vue.js コンポーネントを Web Components に変換するのには Vue CLI の Web components ビルドの機能を使っており、内部的には @vue/web-component-wrapper が利用されています。
既存アプリケーション向けの対応としては、大きく分けて以下の責任を持ちます
- JSP から受け取る属性値と、対応する Vue.js コンポーネントの props のマッピング
- Vue.js コンポーネントから発出されるイベントに応じた既存実装の実行
- 既存実装の動作に応答して Web Components の属性値を変更
それぞれについて以下の図に示すように責任ごとに実装を分離し、組み合わせることで実現しています。
これによって、 components パッケージのコンポーネントが既存の実装に強く依存したロジックを持つ必要がなくなったり、段階的なソフトウェアの変化に合わせて修正がしやすくなったりしています。 Vue.js コンポーネントの再利用性を高めることにもつながります。
pages パッケージ
前回の記事で紹介した通り、本プロジェクトのメインアプローチは、JSP によるマークアップや無秩序なJS・CSSと Web Components を共存させながら UI の実装を刷新するものです。 一方で、これが進めば特定ページにおいて、JSP によるマークアップや既存のJS・CSSを排除することも視野に入ります。
特に Vue.js のコンポーネントの実装と Web Components のための実装で明確に責任を分けているため、比較的スムーズに既存コンポーネントを再利用できます。結果、このような方針をとることができます。
pages パッケージには、このような時に対象ページに対応したマルチページアプリケーションのための実装を配置します。
そのため、このパッケージの責任は特定ページのユースケースを満たすような画面表示やインタラクション、外部API とのコミュニケーションを適切に実行することです。
ここで、対象ページで利用するサーバから提供されるデータがすべてWeb API から取得できればシンプルですが、バックエンドの開発状況によっては依然として JSP からデータを受け取る必要があります。 そのような場合は、JSP内で必要なデータをJSONにシリアライズし、JS のグローバル変数として参照できるような JS を JSP で組み立てます。 そして、ページコンポーネント (Atomic Desgin における Page レイヤ) ではグローバル変数経由で初期値などを受け取り、子コンポーネントにデータを渡します。
この方針は、Node.js で SSR を行った際にサーバサイドで設定されたグローバルストアの状態をクライアントに渡す際と同様なものだと思います。
一方で、このグローバル状態を参照するロジックを直接ページコンポーネントに配置すると、Web API 側の実装が段階的に行われた場合に初期状態を取得する部分の実装が複雑化することが予想されます。
これを軽減するために、ここでも状態の取得を抽象化し、実装は infra パッケージに配置することにします。 これによって、ページコンポーネントに詰め込まれていた外部データの取得という責任を分離できます。
ページコンポーネントは以下のような関数のみに依存させ、これの実装はルートコンポーネントから注入されます。
type FetchState = () => Promise<State>
例えば簡略化した実装は以下です。
const fetchState: FetchState = ()=> fetch("/some-api") .then(res => ({ ...res.json(), ...window.__STATE__}))
このような実装にすることで、ルートコンポーネントから注入する実装を用途に応じて任意のものに変更できます。 例えば、ECサイトのシステムは、本来 JSP を前提としたアプリケーションです。したがって、自動の結合テストを行うには、アプリケーションサーバが動作する環境やデータベースのセットアップなどが必要ですが、それを頻繁に用意することや、その環境を GitHub Actions などからオンデマンドで利用することもアプリケーションの特性上難しいです。 ですが、ここまでの構成と Cypress などを合わせて利用することで、フロントエンド領域については自動的なテストを GitHub Actions などで実行することができます。
これらは、特別な工夫ではなく、依存性逆転や依存性注入など一般的なパターンを踏襲したにすぎません。
おわりに
刷新プロジェクトにおけるソフトウェアの構成とそのようにした目的や背景を紹介しました。
一方、開発の進行と様々な環境変化 (チーム・プロダクト・世の中の技術) に応じてこれも変化させる必要があると考えています。 それにはその時々でいくらかのコストがかかりますが、コードのリファクタリングと同様にその時々で少しずつ変化をしないと後々大きなツケを払う必要が出てくることもあります。 (このプロジェクトもそのツケを払っているという一面があります...)
そのためには、変更を容易にするにはどうすればよいかということを継続的に検討し実施する姿勢が必要です。 このようなことの積み重ねの結果、ソフトウェアが真にソフトであり続けることができると考えています。
次回は利用している具体的なツールやサービスについて紹介します。
本プロジェクトは現在も進行中です。制約が多い中でソフトなソフトウェアを作ることに興味がある人を募集しています。 以下から概要を確認ください。