DIP――ブラウザの機能を、ポートの向こうへ置く
作成: / 公開: / 内容更新:
執筆:tamito0201 / 掲載・運営:プロマリ
丸い共有ボタンの裏側を掘る連載の第8回。SOLID の5つ目の原則 DIP を、「コピー」のボタン1つから確かめます。コピーを頼むコードはブラウザを知らず、約束(ポート)だけを知る。実行時の呼び出しは外へ、import は内へ。import の向きを数え、DI・DI コンテナ・ファクトリの役目を読み、わざと境界を破って何が止めるかまで、図16点で読み解…
プラグインの実物:コピーの差し込み口
前のページでは、ボタンと保存のあいだに「保存する」という約束を挟むと、import の矢印が逆になることを見ました。このページでは、共有ボタンのプラグインに戻り、コピーの差し込み口(ポート)の実物を読みます。1ページ目の地図のとおり、約束は domain に、ブラウザの実装は infrastructure に、コピーを頼む手順は application に置かれています。
ClipboardGateway:6行の約束
まずは約束そのものです。ファイル全体でも6行しかありません。
/** The ability to put text on the clipboard. Infrastructure implements it. */
export interface ClipboardGateway {
write(text: string): Promise<void>;
/** Optional clipboard fallback, such as a URL prompt. */
fallback?(text: string): void;
}1行目のコメントは「クリップボードに文字を置く能力。infrastructure が実装する」という意味です。約束の中身は2つです。3行目の write は、文字列を受け取って書き込み、Promise<void> を返します。値は何も返さず、書き込めたら約束が果たされ(resolve)、書き込めなかったら失敗として完了する(reject)、という形で、成功か失敗かだけを知らせます。5行目の fallback は、名前の後ろに ? が付いていて、持っていなくてもよい任意の能力です。書き込みに失敗したとき、URL を手で写せる入力欄を出すような「代わりの手段」を表します。
ここには、navigator も、クリップボードの権限も、ブラウザの名前も出てきません。前のページの Saver と同じで、コピーを頼む側が「何をしてほしいか」だけを書いた約束です。そして、この約束が置かれているのは、ブラウザの実装の隣ではなく、domain/gateway/ というフォルダです。約束の持ち主は、使う側なのです。
ClipboardGatewayを読むときは、名前にClipboardが入っているからブラウザの詳細だ、と判断しないようにします。ここで表しているのは、利用者のために文字をクリップボードへ置くという能力です。その能力をどのAPIで呼ぶかまでは、約束に入っていません。外部の世界に関係する言葉を一切使わないことが、抽象化の目的ではありません。
そして、Promiseがrejectされたことは、直ちに契約違反という意味ではありません。書き込みを拒否された場合に失敗を伝えるのも、約束された結果の一つです。契約に反するのは、書けていないのに成功したと伝える、といった振る舞いです。「約束が破られる」というPromiseの比喩と、LSPでいう契約違反を混ぜないように読みます。
BrowserClipboard:約束に合わせる側
次は、壁の向こうの実装です。ブラウザの Clipboard API を使って、約束を満たします。
/** Implements ClipboardGateway with the browser Clipboard API. */
import type { ClipboardGateway } from '../domain/gateway/ClipboardGateway.ts';
export class BrowserClipboard implements ClipboardGateway {
write(text: string): Promise<void> {
return navigator.clipboard?.writeText ? navigator.clipboard.writeText(text) : Promise.reject(new Error('clipboard unavailable'));
}
fallback(text: string): void {
window.prompt('URL', text);
}
}2行目で、domain の ClipboardGateway を import しています。前のページの DatabaseSaver と同じく、実装のほうが約束の名前を知っている形です。4行目の implements ClipboardGateway で、この約束に合わせて作ることを宣言します。形が合っていなければ、ここで型検査が止めます。
6行目が、ブラウザの事情を引き受けている行です。navigator.clipboard?.writeText と書いて、クリップボードの機能が無い環境かどうかを先に確かめ、あれば書き込み、無ければ「clipboard unavailable(クリップボードが使えない)」という失敗の約束を返します。機能が無いことを、例外で落ちるのではなく、約束どおりの「失敗」として返しているところが大事です。10行目の fallback は、ブラウザの window.prompt で、URL を選択した状態の入力欄を出します。
ブラウザの細かい事情(機能があるか、どの関数を呼ぶか、代わりに何を出すか)は、すべてこの12行のファイルに閉じ込められています。方針の側は、このファイルの存在すら知りません。
この実装の条件分岐は、navigator自体が存在するブラウザ環境を前提にしています。clipboardやwriteTextが無い場合には、失敗のPromiseを返します。navigatorという名前そのものが無い環境を、あらゆる場合に安全に扱う万能のラッパーではありません。Nodeの方針テストでこの実装を呼ばず、代役を差す理由も、ここにあります。
また、writeTextが存在することは、書き込みの許可が得られたこととは違います。実際の呼び出しから返るPromiseも、そのまま呼び出した側へ渡します。成功か失敗かを、ブラウザの結果に合わせて受け取れるようになっています。APIの有無を調べる行と、結果を待つ行の二つを追うと、どこで環境の事情を引き受けたかが見えます。
呼び出しの向きと、import の向き
では、コピーを頼む側を読みます。共有ボタンが押されたときの手順を受け持つユースケース、HandleShareClickUseCase のうち、コピーの部分です。
import type { ShareGateways } from '../domain/gateway/ShareGateways.ts';
// …
async #perform({ button, preventDefault }: ShareClickCommand): Promise<ShareClickResult> {
const decision = ShareActionPolicy.decide(button);
switch (decision) {
case 'copy': {
preventDefault();
try {
await this.#gateways.clipboard.write(button.url);
return 'copied';
} catch {
this.#gateways.clipboard.fallback?.(button.url);
return 'copy-fallback';
}
}1行目で import しているのは、domain の ShareGateways です。これは、ClipboardGateway を含む外の能力をひとまとめにした約束です。4行目で、押されたボタンに対して「開く・コピー・端末の共有」のどれをするかを決め、コピーなら6行目の case に入ります。9行目で、約束の write に記事の URL を渡し、書き込みが終わるのを await で待ちます。約束が果たされれば 10行目で copied(コピーできた)を返し、失敗なら catch に進んで、12行目で任意の fallback があれば呼び、13行目で copy-fallback(代わりの手段を出した)を返します。
書き込みが終わるのを待ってから copied を返すので、渡された実装が契約を守る限り、未完了のうちに成功を返すことを避けられます。そして、このファイルのどこにも、navigator という言葉は出てきません。
ここで、2つの向きを並べてみます。ボタンを押したとき、処理は「ユースケース → 約束の write → ブラウザの実装 → navigator.clipboard」と、内側から外側へ向かって進みます。一方、コードの import は、ユースケースから ClipboardGateway へ、そして BrowserClipboard からも ClipboardGateway へ。どちらも内側の domain へ向かっています。
実行時の呼び出しは外へ、import は内へ。この2つの向きがずれていることが、DIP の「逆転」の正体です。約束を挟まなければ、import も呼び出しと同じく外へ伸び、ユースケースがブラウザの実装を知ることになっていました。約束を挟んだことで、呼び出しの向きはそのままに、import の向きだけが逆になったのです。
TypeScriptのinterfaceは、実行時に中継するオブジェクトとして残るものではありません。図の「約束のwrite」は、呼び出し側がどの契約を使うかを示す場所です。実行時には、渡されたオブジェクトのwriteが呼ばれます。壁の中に別のプログラムが一段増えて、そこが転送していると考える必要はありません。
import typeも、型を確かめるための依存です。実行時のJavaScriptでは取り除かれても、ソースコードの設計上は、どの型の変更に追従するかを決めています。DIPで見ている矢印は、通信の回数や関数呼び出しの回数ではありません。ソースを変更するとき、どの名前や約束に合わせなければならないかの関係です。
import の向きを数える
「内側は外側を知らない」と言うのは簡単ですが、本当にそうなっているかは、数えてみないと分かりません。コマンドで確かめてみます。
$ grep -rl "infrastructure/" web/src/domain web/src/application | wc -l
0
$ grep -rl "BrowserClipboard" web/src
web/src/composition/ShareContainer.ts
web/src/infrastructure/BrowserClipboard.ts
$ grep -rl "navigator" web/src
web/src/infrastructure/BrowserNativeShare.ts
web/src/infrastructure/BrowserClipboard.ts
web/src/domain/service/DestinationSelectionPolicy.ts1つ目は、domain と application の中で、infrastructure のフォルダを指している行があるファイルの数です。0件でした。内側の2つの層は、ブラウザの実装を1つも知りません。2つ目は、BrowserClipboard という名前が出てくるファイルです。実装そのものを除くと、組み立て役の ShareContainer.ts だけでした。3つ目は、navigator が出てくるファイルです。infrastructure の2つのほかに、domain のファイルが1つ出てきましたが、中を見ると、コメントの中に navigator.share という語が書かれているだけで、コードではありません。
数えると、ブラウザの機能を実際に動かしているのは infrastructure だけで、それを名指しで選んでいるのは組み立て役だけだと分かります。方針のコード(domain と application)は、約束の名前しか知りません。ただし、grep は「文字列が出てくるか」を数えるだけで、コメントとコードを区別しません。このあとの5ページ目で、型とテストによる、もっと確かな見張りも確かめます。
検索で0件という結果は、指定した語と範囲で見つからなかったということです。別の書き方で読み込んだ依存まで、文字列検索だけで網羅した証明にはなりません。この節では、最初に地図をつかむための手軽な確認として使い、後でコードを読んで判定する境界のテストへ進みます。
コメントにnavigatorが見つかった例は、検索の限界を知るのに役立ちます。その語を説明文へ書くことと、ブラウザの変数を実行時に参照することは違います。見つかった行をすべて違反として削ると、役立つ説明まで失われます。数を出したら、何を数えたかを一つずつ確認する。この順番を飛ばさないようにします。
前回の小さなポートが、そのまま抽象になる
domain/gateway/ には、ClipboardGateway のほかにも約束が並んでいます。端末の共有メニューを呼ぶ NativeShareGateway、共有画面の窓を開く ShareWindowGateway、共有の操作をページへ知らせる ShareActivityPublisher、共有するページの URL とタイトルを読む SharedPageGateway です。前回の連載2.4では、ISPの目で、これらを能力ごとに小さく分けた理由を読みました。コピーの代役を作るのに、いいねの保存まで求めない、という話でした。
DIP の目で見ると、この5つの小さな約束が、そのまま5つの差し込み口になっています。1つの口には、1つのブラウザの実装が差さります。そして、口が小さいほど、差し込むものを作るのも簡単です。コピーの代役なら、write と fallback の2つを書けば済みます。ISP で約束を小さく分けたから、DIP の差し込み口を、代役でも本物でも気軽に差し替えられるのです。2つの原則は、別々のことを言いながら、同じ差し込み口を支えています。
考えてみる:ClipboardGateway を infrastructure のフォルダに置いたら、何が変わるの?
コードの動きは変わりませんが、import の向きが崩れます。ユースケースが ClipboardGateway を使うには、infrastructure のフォルダを import することになり、「内側の層は外側を知らない」という約束が破れます。ただし、純粋な型を移すだけで、同じフォルダのブラウザ実装まで自動で読み込まれるわけではありません。境界の規約と型の検査は区別します。約束の置き場所は、中身と同じくらい大事な設計の一部です。
純粋な型の移動と、境界の違反を分けて見る
約束のファイルをどこへ置くかの話には、二つの検査が出てきます。フォルダの境界を守る検査と、ブラウザの型を使っていないかの検査です。次の図では、同じ六行の約束を、違う住所へ移しただけの場面として比べます。
「内側の約束へ参照」では、ClipboardGatewayがdomain側にあり、applicationもinfrastructureも、その約束を参照しています。「外側へ約束だけ移動」では、約束だけをinfrastructureへ移し、applicationからそこへ線を引きました。このリポジトリの境界規約では、その向きが許されていないため、依存の境界を調べる検査で見つけたい変更です。
ただし、ファイルの中身が同じ純粋なインターフェースなら、移しただけでブラウザAPIが突然混ざるわけではありません。TypeScriptは、同じフォルダに置かれた全ファイルを、その一つのimportから無条件に読み込むものでもありません。「住所を外側へ移すと、必ずDOMの型エラーになる」とまでは言えないのです。
ブラウザの型を使った実装を一緒に読み込んだり、約束の中へDOMの型を入れたりすれば、内側の型検査にも影響します。けれど、それは純粋な型の移動だけとは別の変更です。図で境界規約の判定と型の中身の話を分けたのは、境界の規約と型の仕組みを同じものとして説明しないためです。
ファイルの図を手元のコードへ当てるときは、住所と中身を別の欄へ書きます。住所の欄にはdomainやinfrastructure、中身の欄には文字列だけの契約か、ブラウザの型を含むかを書きます。外側の住所に純粋な型がある場合と、内側の住所にブラウザの詳細を含む型がある場合を、同じ「フォルダの問題」で片付けずに済みます。
後者では、ファイルの位置が規約どおりでも、依存の意味は内側へ漏れています。前者では、中身は純粋でも、このリポジトリが決めた方向の規約に合いません。二つの欄を埋めると、何を移動し、何の型を見直すべきかを分けて提案できます。
修正後は、図の矢印だけを見ず、実際のimportも読み戻します。古い場所を経由して再exportするなど、参照先の住所が一段隠れている場合があるからです。名前を変えたことではなく、方針がどの契約へ依存する形になったかを確かめます。この回の境界テストが再exportも見る理由につながります。
置き場所を内側へ戻す理由は、たまたま型エラーを避けたいからではありません。この約束を、方針の側が必要とする能力として扱い、外側の実装がそれに従う関係を保ちたいからです。検査が何を止めるかだけでなく、なぜその向きを選んだかを言葉にすると、フォルダの規約を形だけ守る状態から一歩進めます。













このページ(3ページ目)の感想・質問・設計へのコメント