DIP――ブラウザの機能を、ポートの向こうへ置く
作成: / 公開: / 内容更新:
執筆:tamito0201 / 掲載・運営:プロマリ
丸い共有ボタンの裏側を掘る連載の第8回。SOLID の5つ目の原則 DIP を、「コピー」のボタン1つから確かめます。コピーを頼むコードはブラウザを知らず、約束(ポート)だけを知る。実行時の呼び出しは外へ、import は内へ。import の向きを数え、DI・DI コンテナ・ファクトリの役目を読み、わざと境界を破って何が止めるかまで、図16点で読み解…
実装を選んで渡す場所:組み立て役
ここまでで、コピーを頼む側(application)は ClipboardGateway という約束だけを知り、ブラウザの実装(infrastructure)もその約束に合わせて作られていることを見ました。すると、疑問が1つ残ります。では、誰が BrowserClipboard を作って、ユースケースに渡しているのか。その答えが、このページで読む組み立て役です。
中で作らず、外から受け取る:DI
まず、ユースケースの側を見ます。HandleShareClickUseCase は、使う道具を自分で作っていません。コンストラクタの引数で、ShareGateways をまるごと受け取ります。
export class HandleShareClickUseCase {
readonly #gateways: ShareGateways;
constructor(gateways: ShareGateways) {
this.#gateways = gateways;
}2行目で、受け取った道具をしまっておく欄を用意し、4〜6行目のコンストラクタで、外から渡された ShareGateways をそのまま入れています。new BrowserClipboard() のような、具体的な道具を作る行はありません。このように、部品が使う道具を中で作らず、外から受け取る書き方を、DI(Dependency Injection、依存性の注入)と呼びます。この名前は、Martin Fowler が2004年の記事で、いくつかの呼び名を議論したうえで付けたものです。
名前が似ているので、DI と DIP はよく混同されますが、別の言葉です。DIP は「方針も道具も、約束のほうを向く」という依存の向きの原則です。DI は「道具を外から受け取る」という渡し方の技法です。DI を使えば DIP を実現しやすくなりますが、DI を使っただけで DIP になるわけではありません。たとえば、コンストラクタで受け取る型が BrowserClipboard(具体的な道具)のままなら、外から渡してはいても、方針のコードはブラウザの実装を知ったままです。受け取る型が、使う側の決めた約束になっていること。そこまでそろって、はじめて DIP になります。
DI コンテナ:3つのモジュールを積む
外から受け取るようにすると、今度は「外」で誰かが道具を作って渡す必要があります。部品の数が少なければ、前のページの main.ts のように new を並べれば済みます。けれど、このプラグインでは、ページの情報を読む部品、共有欄を組み立てる部品、クリックのユースケースなどが互いに組み合わさっていて、渡す順番を手で書くのは大変です。そこで、DI コンテナという仕組みに任せています。使っているのはInversifyJSというライブラリです。
/** Browser implementations of the domain interfaces. */
static browserInfrastructure(
publisher: ShareActivityPublisherFactory = (element, eventName) => new CustomEventShareActivityPublisher(element, eventName),
): ContainerModule {
return new ContainerModule(({ bind }) => {
provide(bind, TOKENS.ShareDestinationRepository, [TOKENS.ShareDestinationDefinitions],
definitions => new InMemoryShareDestinationRepository(definitions));
provide(bind, TOKENS.SharedPageGateway, [], () => new BrowserSharedPage());
provide(bind, TOKENS.NativeShareGateway, [], () => new BrowserNativeShare());
provide(bind, TOKENS.ClipboardGateway, [], () => new BrowserClipboard());
provide(bind, TOKENS.ShareWindowGateway, [], () => new BrowserPopupWindow());
constant(bind, TOKENS.ShareActivityPublisherFactory, publisher);
});
}1行目のコメントは「domain のインターフェースの、ブラウザ用の実装」という意味です。この関数は、ContainerModule という「結び付けのまとまり」を1つ返します。中の provide は、左から「どこに結ぶか(コンテナ)・どの約束か(トークン)・何を先に受け取るか・どう作るか」を並べたものです。10行目を読むと、TOKENS.ClipboardGateway という約束の札に、() => new BrowserClipboard() という作り方を結んでいます。BrowserClipboard という名前を書いて、具体的な道具を選んでいるのは、プラグイン全体でこの行だけです。3ページ目で grep したとき、実装を除いて ShareContainer.ts だけが見つかったのは、このためでした。
ShareContainer には、同じようなまとまりが3つあります。名札カードと既定の設定を渡す generatedInput、いま読んだ browserInfrastructure、ユースケースを組み立てる shareApplication です。起動するときは、この3つを積んでコンテナを作ります。入口の index.ts がしているのは、ShareContainer.create(CATALOG, DEFAULTS) でコンテナを作り、共有欄の独自要素を登録することだけです。index.ts も、infrastructure を直接は読み込みません。
このつくりの効き目は、テストで分かります。組み立て役のテストでは、3つのうち browserInfrastructure だけを、記録するだけの代役を結ぶモジュールに差し替えています。
/** Replaces the browser with recording fakes, keeping the real repository. */
const fakeInfrastructure = (calls: string[], published: Array<{ element: string; eventName: string; activity: ShareActivity }>) =>
new ContainerModule(({ bind }) => {
const { provide, constant } = TypedBinding;
provide(bind, TOKENS.ShareDestinationRepository, [TOKENS.ShareDestinationDefinitions], definitions => new InMemoryShareDestinationRepository(definitions));
constant(bind, TOKENS.SharedPageGateway, { read: () => ({ url: 'https://a.jp/post/', title: 'Hello', site: 'Promari' }) });
constant(bind, TOKENS.NativeShareGateway, { available: true, share: async d => { calls.push(`share:${d.url}`); } });
constant(bind, TOKENS.ClipboardGateway, { write: async t => { calls.push(`copy:${t}`); }, fallback: t => { calls.push(`fallback:${t}`); } });
constant(bind, TOKENS.ShareWindowGateway, { open: href => { calls.push(`popup:${href}`); return true; } });
constant(bind, TOKENS.ShareActivityPublisherFactory, (element, eventName) => ({
publish: activity => { published.push({ element: element.id, eventName, activity }); },
}));
});8行目が、コピーの代役です。write は、受け取った文字を calls という配列に「copy:(URL)」として記録するだけで、クリップボードには触りません。fallback も同じように記録するだけです。形は ClipboardGateway の約束どおりなので、ユースケースはこれが本物か代役かを区別できませんし、区別する必要もありません。このモジュールを ShareContainer.create の3つ目の引数に渡すと、残りの2つのモジュールは本物のまま、ブラウザの部分だけが代役に入れ替わります。差し替えるのは1か所、変わらないのはユースケースのコード全部です。
組み立て役のテストには、「結び付けていない約束を取り出そうとしたら、黙って undefined を返さずに失敗する」ことを確かめる1件もあります。差し込み口に何も差さっていないまま動き出して、コピーを押した瞬間に初めて壊れる、という事故を、起動の時点で止めるためです。
要素ごとに作る:ファクトリ
DI コンテナが1つずつ道具を作って配ってくれるなら、どの部品も1つずつで足りそうです。ところが、クリックのユースケースだけは、そうはいきません。1つのページに、記事の上と記事の下の2か所に共有欄を置けるからです。コピーの道具は1つを共有してかまいませんが、「共有しました」という知らせは、押された共有欄の要素から、その要素の名前で出す必要があります。
そこで、このプラグインでは、ユースケースそのものではなく、「要素とイベント名を受け取って、ユースケースを1つ作る関数」をコンテナに結んでいます。このように、作ることだけを受け持つ部品をファクトリと呼びます。型の名前は HandleShareClickUseCaseFactory で、中身は (element, eventName) => new HandleShareClickUseCase(…) です。組み立て役のテストでは、イベント名 share-top の要素と share-bottom の要素で別々のユースケースが作られ、どちらもコピーは同じ代役に記録し、知らせは要素ごとに分かれることを確かめています。
DI、DI コンテナ、ファクトリの関係を、ここで整理しておきます。DI は「道具を中で作らず、外から受け取る」という渡し方です。DI コンテナは、その「外」で、どの約束にどの実装を結ぶかを覚えておき、組み立てて渡してくれる仕組みです。ファクトリは、作ることだけを受け持つ関数で、コンテナの中でも外でも使えます。このプラグインでは、コンテナが要素ごとの事情を知らなくて済むように、要素ごとに違う部分だけをファクトリに任せています。どれも、DIP で「約束の向こう」に置いた実装を、最後にどこかで選んで渡すための道具です。
ブラウザの実装を選ぶのは、組み立て役の1か所だけにしました。四つの層のクラスは DI コンテナを知らず、これまでどおりコンストラクタで約束を受け取るだけです。コンテナを使うのは composition だけで、ほかの層が inversify を読み込めば、依存の境界のテストが止めます。
考えてみる:DI コンテナを使わないと、DIP は守れないの?
守れます。DIP が求めているのは import の向きで、道具をどう渡すかではありません。2ページ目の main.ts のように、組み立てる場所で new を並べて手で渡しても、方針のコードが約束だけを知っていれば DIP です。DI コンテナは、部品が多くなって手で渡すのが大変になったときの便利な道具で、使うかどうかは部品の数と組み合わせの複雑さで決めます。組み立て役の詳しい設計は、連載3.4「Composition RootとDIコンテナ――具体実装をどこで選ぶか」でもう一度扱う予定です。









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