コード設計編: context による縦軸分類とレイヤードアーキテクチャ(新規開発のメモ書きシリーズ3)
Posted: Updated:
流行りの monorepo 風味と DDD 風味?
今回はコードの設計について書き残します。主に JavaScript 界の話です。Web アプリケーション全体の設計は次回で、今回はコード面の設計に限定して書き留めています。プロダクト全体のアーキテクチャは次の記事で述べる予定ですが大雑把には、メディアっぽいサービスでありつつも SPA + SSR が許容される程度には要件定義の時点でコードの行数がかさむことが約束されたプロダクトです。
今回は大きく分けて下記について述べています
- ディレクトリ構造
- オブジェクトの種類と責務
- Flux 的なデータフロー
あくまで風味なので今回、専門用語の意味ズレなどは優しくお願いします...
このシリーズの他の記事はこちら。
- 技術要素編: web アプリが新陳代謝を続けるための依存関係の厳選
- ビルド設定編: UA に応じた最適な JS バンドルの配信と webpack との距離感
- アーキテクチャ編: SSR と CDN ( Fastly ) とユーザー依存情報の分離
過去の反省を活かしたディレクトリ構造
Workspaces | Yarn に乗っかった monorepo 構成です。プログラムが疎であっても、人間の作業や認知は疎なはずがない、っていうやつです。今後 Web アプリ本体から独立したサブシステムが開発される可能性がありますが、それらも(少なくとも node で書く場合は)workspaces 以下に配置して管理するつもりです。
- (root) - docs - scripts - workspaces - app - client - utils.ts ( クライアントサイドのみで使うユーティリティ ) - index.ts ( クライアントサイドのブートストラップ ) - context ( 関心事や機能の単位で分類した flux 一式の分類 ) - foo-process - actions ( Service へのアクセス、Entity 生成、Store への dispatch など ) - SelectFooAction.ts - SubmitFooAction.ts - components - FooSubmitButton - FooSubmitButton.css - FooSubmitButton.tsx - index.ts - FooSelectionDialog - intents ( Store に対するメッセージ名と Payload の interface 定義 ) - SelectFooIntent.ts - SubmitFooIntent.ts - stores ( Entity を除いて表示に必要な state を保持 ) - FooProcessStore.ts - PresupposeFooProcess.ts ( context を利用するとき最初に呼び出すセットアップ ) - bar-list - user-env ( ユーザー環境情報とか... ) - user-auth ( ユーザー認証関係とかもイチ context 扱い ) - domain ( ドメインモデル置き場 ) - foo - FooEntity.ts (データエンティティ、ビジネスロジックを含む ) - FooRepository.ts ( hydrate 対応の Entity の容れ物、実体はただの Map ) - bar - baz - foundation ( context に属さないサバクラ共通利用コード ) - components ( プリミティブな UI コンポーネントが属する ) - Button - Button.css - Button.tsx - index.ts - Dialog - ... - containers ( URL と対応するルートコンポーネント ) - styles - utils - Makefile - package.json - tsconfig.client.compat.json - tsconfig.client.json - tsconfig.json - server - middlewares ( express 用ユーティリティ ) - services ( fetchr Service、API とおしゃべりする実体 ) - utils ( サーバーサイドのみで使うユーティリティ ) - views ( React 使わないトコの handlebars テンプレート ) - index.ts ( サーバーサイドのブートストラップ ) - static - typings - app-protobuf ( proto からの自動生成コード、app/server あたりで import して使う ) - app-serviceworker ( Service Worker コード ) - Makefile - package.json - rollup.config.js - sw.js - filename-revving ( こういうユーティリティも... ) - yarn-outdated-notifier ( ...いくつか並ぶ ) - Dockerfile - Makefile - package.json - README.md
app は途中で npm + lerna にしたり、さらに yarn workspaces に切り替えたりの歴史的経緯の最中であまり気持ちが固まっていません。現状の app を app-shared のようにリネームし、client と server はそれぞれのビルド設定ごと独立した workspace に切り出すか考え中です。コードはともかく、ビルド設定周りがキレイに切り離せるようにできるかが課題になります。(特に CSS Modules...)
オブジェクト分類よりも context 分類を優先
今回は特定の機能または関心を context としてグルーピングして、その context (機能的な文脈)に関する component や action を配置しました。context の粒度判断は作りながら考えているところですが、ひとつの目安として、独立した Flux ループを成せるようなら context として独立できる見込みが高いです。
よくある話ですが components、stores、actions、みたいなオブジェクトの分類が先行すると、その直下で大量のファイルが散らかることになります。付け焼き刃でサブディレクトリを加えて整頓しようとしても、本来的に無関係なコンポーネントが入り乱れる中で分類をひねり出すことになり不毛な判断を強いられがちです。このような状況を避けるためにも、(アプリケーションの規模にもよりますが、一定の規模を超えるようなら)オブジェクトの分類より上位にくるべきグルーピングを見いだした方がよいでしょう。
コンポーネントも context を活かして分類
なんとなくのイメージと説明のため敢えて Atomic Design の分類に対応させると
- foundation/components -> atoms
- context/*/components -> organisms | molecules
- container/ -> templates | pages
がおおよその関係です。前回は components/ui、components/project、components/page という分類のみでコンポーネントを散らかしていましたが、 context で自然と適切にグルーピングされるようになっています。
特定の organisms に依存したサブコンポーネントでしかないのに molecules として別ディレクトリに仕分けられるような事故は少なくとも context でグルーピングされるため抑制されます。開発者が整理したいだけなら organisms とは...molecules とは...みたいな分類をこねまわすより、ディレクトリ構造に従うだけで済むのが幸せです。
ていうかコンテキスト不在のままで、ただの分類名の直下にモノを溢れさせるから散らかるし、そもそもビジュアル原案がコンポーネント志向でデザインされていないのに molecules たりうる成立条件を満たせてしまうことが本当にあるんだろうか的な悲観さえk...うわなにをするおまえr
ということで開発者サイドによるコンポーネント粒度の分類のみで「Atomic Design やってます!」って言うわけにもいかず、デザイナー方面と「Atomic Design しようぜ!」っていう意識高い会話もしなかったので、そういう分類はしていません
オブジェクトの種類と責務
Flux ライクなオブジェクトのほかに、fluxible 固有のものと、設計上独自に足し合わせたものを合わせていくつかのオブジェクトを分類しています。オブジェクトの役割については、冒頭のディレクトリツリーに合わせて簡単な説明を併記しているので参考にしてください。
今回は .NETのエンタープライズアプリケーションアーキテクチャ第2版 を読んだ上で、React アプリケーションへの落とし込みにあたっては 複雑なJavaScriptアプリケーションを考えながら作る話 をだいぶ参考にしました。
オブジェクトの命名については Flux っぽい命名を引き継いでいます。fluxbile 自体がクラシカルな Flux であることに加え、命名上の共通知(この名前ならきっとこういう役割だろう、というパターン)を活かすためになるべく平凡に寄せています。強いて言うなら、fetchr の Service は名前が凡庸すぎるので他との差別化が必要な疑いあり、仮に FetchrService
という扱いにしています。
図の左に示す分類はレイヤードアーキテクチャ風のものでありつつ雑に捕捉すると次のような位置付けです。
- Presentation -> 表示に関する View 的な層
- Component
- Application -> アプリケーションロジックに関する層
- Action, Store, ( Route は Presentation に近いかも... )
- Domain -> ビジネスロジックに関する層(他のクライアントアプリとも共通すべき層)
- Entity
- Infrastructure -> ネットワークとか永続化とか具体的な技術実装の層
- Service, Repository
Write/Read を分離しつつシンプルな Flux を保つ
主に Component -> Action -> Repository の Write Stack と、Store ( + Repository ) -> Component の Read Stack があり、Action が dispatch した Intent を Store が受け取ることで、Write Stack と Read Stack のスイッチが発生します。CQRS 的な分離を想定してはいるものの、コードを書くときは「データエンティティは Repository 経由で Read/Writeしてね」というルールがあるだけでシンプルな Flux のままです。
強いて言うなら、Action 内に Service の呼び出しや、Entity の新規作成から Repository への書き込みなどが集中するため、Action にあまりベタベタ書かなくても済むように頻出する処理はパターン化できるように = 他クラスの責務として素直に預けられるように調整していく必要があります。
Entity と Repository によるデータのコレクション管理
同じ domain ディレクトリに押し込まれている Entity と Repository は、単純にデータとコレクションの関係です。
Entity には BFF の向こう側にある API Gateway から受け取ったオブジェクトを引数にとってインスタンスを生成する create()
クラスメソッドと、既存インスタンスを継承して新しいインスタンスを返す extend()
クラスメソッドが実装されています。あとは各データエンティティに必要なビジネスロジックを実装しています。
interface FooEntityProperties extends api.IFoo { ... } export class FooEntity implements FooEntityProperties, DomainEntity { private static readonly defaults: FooEntityProperties = { ... }; public readonly barProp: number; public readonly bazProp: string; private constructor(properties: FooEntityProperties) { defaultsDeep(this, properties, FooEntity.defaults); } public static create(properties: FooEntityProperties): FooEntity { return new ThemeEntity(properties); } public static extend(base: FooEntity, overrides: any): FooEntity { return new ThemeEntity({...base, ...overrides}); } public get someLogic(): boolean { // implement; } }
データ更新時は extend を利用することで、差分更新しつつ別のインスタンスにバントタッチすることで、shouldComponentUpdate などでの比較の手間を減らしている....のですが、このままだと差分がなくても新しいインスタンスになってしまうので、extend 時のコストはかかりますが変更がない場合は今のインスタンスをそのまま返すようなロジックを加えたいところ。シンプルかつ低コストに書ければよいと思いつつ放置中。
Repository は SSR のため hydrate の仕組みが加わっている以外は、基本的にただの Map を継承した単純なクラスです。ちな IE11 とか Edge はネイティブな Map を extends して class 作ると怒られるので、polyfill.io とは別で代替実装を突っ込んでいます...。げふん。
Store と Repository による状態と実体の分離
Store も Repository もいかにも(表示用の)データを溜め込みそうな名前ですが、前図のとおり Store は Application 層であり、Repository は Infrastructure 層です。Store はアプリケーションの画面に近い「状態」を保持し、Repository は SPA セッション内で永続化したい「データエンティティ」を保持します。
Store には表示したいデータエンティティの id やその並び順などを保持させますが、実体はあくまで Repository の中にあって、Store が自分の状態を元に Repository から必要なデータを収集、整形して Component に提供します。
データエンティティの保持については、各エンティティをクライアントサイド内で一意なデータとして厳密に管理するか、多少の齟齬や重複を許容して画面ごとの「状態」にまるっと含めてしまうかは判断が分かれるところでもあります。今回はそれなりのリアルタイム性と、それに関連するトランザクションが発生する予定であるため、間違いなく常に最新のデータを参照して一意に管理できるよう Repository を設けました。
Component の脱スマート UI のための Entity
スマート UI = 賢すぎるUI は、jQuery 全盛期にもよく見られた症状ですが View や Presentation の層がロジックについてあまりに何でも知りすぎている状態です。React 界においても何の対策もしないと Component にロジックを散らかすハメになり、ビジネスロジックが入り組んできたときに破滅の足音が近づいてきます。
今回は多少の拡大解釈すら覚悟しつつ、表示上のビジネスロジックと呼べそうな処理は Entity に寄せて、Componet 上では Entity のプロパティを素直に参照するだけで済むようにしています。Presentation を軽くするために表示内容や条件分岐をどこまで Entity に寄せきるか力加減の見定めが今後の課題です。
Entity に含まれる参照 id を元に、他 domain とのリレーションを解決するのは Store で処理していますが、これは抽象化してしまいたい
複数のエンティティや異なるドメインにまたがるビジネスロジックは考え中
β版ということで初期はアプリオケーションとしての規模も(結果的に)極小であったため未実装でやり過ごしているのが、複数のエンティティや異なるドメインにまたがるビジネスロジックの処理です。特に Application 層との接点になる部分で顕在化しますが、今後 Domain 層と Application 層の接点に薄い DomainService を設けることを考えています。
この位置付けの DomainService がアレコレやりすぎるとあっという間に Entity が薄くなりそうな気もしています。DoaminService が常に薄くいられるように、チーム開発的な紳士協定ではない実装上の強制力ないし必然的な誘導を仕向けるデザインがないか検討中です。
Flux によるいつものデータフロー
繰り返すようですが Flux なので今更取り立てて書くポイントはありません。オブジェクト間のデータフローを定義しつつも、責務の切り分けについては解釈が曖昧...というかパーツが足りないという点については、前述したオブジェクト設計で解決済みです。命名のとこでも似たようなことを述べましたが、奇をてらわないというのは非常に重要と考えます。
基本フロー
fluxible に依存しているため比較的、クラシカルな Flux 構成です。Dispatcher や ActionCreator 周りこそ簡略化されているものの、Component が Action を呼び、Action が処理をして Store にメッセージを送り、Store が emitChange すると変更が Component に伝わる流れになっています。
fluxible の特徴ですが、fluxible に基づく Flux フローを実行するためには context (前述したディレクトリ分類とは関係ありません)と呼ばれるオブジェクトのメソッドを呼び出す必要があります。Component なら ComponentContext、Action なら ActionContext、Store なら StoreContext といったように既定されていて、それぞれ利用できるメソッドも異なります。Flux の基本フローを乱すような行儀の悪い実装ができないようになっているのはチーム開発で地味に役立ちます。
connectToStores の接続点
現在は、URL 単位でひとつ存在するルート相当の Container だけが connectToStores しています。次フェーズの開発では、前述した context の中にも containers を持つことを許そうかと思います。Store の emitChange による影響範囲を限定することで、RootContainer にぶら下がるコンポーネントツリー全体ではなく、ContextualContainer 内のみを更新させることを狙っています。
- Root Container - ContextualContainerA <- connectStores(ContextualStoreA) - some UI Components... - ContextualContainerB <- connectStores(ContextualStoreB) - some UI Components... - ContextualContainerC <- connectStores(ContextualStoreC) - some UI Components...
イメージとしてはこのようになり、StoreA が更新されたとき影響するのは ContextualContainerA だけ、みたいな状態を作りやすくすることを期待しています。
文章ばっかですまんかった
なんか人前で喋るような機会いただけたらもうちょっと図説で簡略化します...。
そんな感じで Flux + ゆるレイヤードアーキテクチャ風の話でした。境界面の考え方は実際に書いてみないとわからんなぁ、というのはよくあることですね。次はプロダクト全体のアーキテクチャ編です。