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/ というフォルダです。約束の持ち主は、使う側なのです。
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行のファイルに閉じ込められています。方針の側は、このファイルの存在すら知りません。
呼び出しの向きと、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 の向きだけが逆になったのです。
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ページ目で、型とテストによる、もっと確かな見張りも確かめます。
前回の小さなポートが、そのまま抽象になる
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 することになり、「内側の層は外側を知らない」という約束が破れます。すると、方針のコードの型検査に、ブラウザの実装のファイルまで巻き込まれ、ブラウザの型が無い環境では通らなくなります。約束の置き場所は、中身と同じくらい大事な設計の一部です。











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