PROMARI JOURNAL

DIP――ブラウザの機能を、ポートの向こうへ置く

作成: / 公開: / 内容更新:

執筆:tamito0201 / 掲載・運営:プロマリ

丸い共有ボタンの裏側を掘る連載の第8回。SOLID の5つ目の原則 DIP を、「コピー」のボタン1つから確かめます。コピーを頼むコードはブラウザを知らず、約束(ポート)だけを知る。実行時の呼び出しは外へ、import は内へ。import の向きを数え、DI・DI コンテナ・ファクトリの役目を読み、わざと境界を破って何が止めるかまで、図16点で読み解…

PASS IT ON

ひとつの発見を、次の会話へ。

実装を選んで渡す場所:組み立て役

ここまでで、コピーを頼む側(application)は ClipboardGateway という約束だけを知り、ブラウザの実装(infrastructure)もその約束に合わせて作られていることを見ました。すると、疑問が1つ残ります。では、誰が BrowserClipboard を作って、ユースケースに渡しているのか。その答えが、このページで読む組み立て役です。

中で作らず、外から受け取る:DI

まず、ユースケースの側を見ます。HandleShareClickUseCase は、使う道具を自分で作っていません。コンストラクタの引数で、ShareGateways をまるごと受け取ります。

TypeScriptweb/src/application/HandleShareClickUseCase.ts(25〜30行目)
export class HandleShareClickUseCase {
  readonly #gateways: ShareGateways;

  constructor(gateways: ShareGateways) {
    this.#gateways = gateways;
  }

2行目で、受け取った道具をしまっておく欄を用意し、4〜6行目のコンストラクタで、外から渡された ShareGateways をそのまま入れています。new BrowserClipboard() のような、具体的な道具を作る行はありません。このように、部品が使う道具を中で作らず、外から受け取る書き方を、DI(Dependency Injection、依存性の注入)と呼びます。この名前は、Martin Fowler が2004年の記事で、いくつかの呼び名を議論したうえで付けたものです。

図4.1-1 中で作らず、外から手渡す
図4.1-1 中で作らず、外から手渡す。説明(現在のしくみ)。道具を中で作らず、コンストラクタで外から受け取ります。これが DI(依存性の注入)です。何を渡すかは、渡す側が決めます。図4.1-1 中で作らず、外から手渡す。説明(現在のしくみ)。道具を中で作らず、コンストラクタで外から受け取ります。これが DI(依存性の注入)です。何を渡すかは、渡す側が決めます。

名前が似ているので、DI と DIP はよく混同されますが、別の言葉です。DIP は「方針も道具も、約束のほうを向く」という依存の向きの原則です。DI は「道具を外から受け取る」という渡し方の技法です。DI を使えば DIP を実現しやすくなりますが、DI を使っただけで DIP になるわけではありません。たとえば、コンストラクタで受け取る型が BrowserClipboard(具体的な道具)のままなら、外から渡してはいても、方針のコードはブラウザの実装を知ったままです。受け取る型が、使う側の決めた約束になっていること。そこまでそろって、はじめて DIP になります。

DIとDIPの違いは、矢印を二本だけ描くと確かめられます。まず「誰が作って渡すか」の線を描き、次に「受け取るクラスが、どの型の名前を知るか」の線を描きます。外からBrowserClipboardを渡していても、受け取る型がその具体的なクラスなら、二本目の線は道具へ向いたままです。

反対に、小さな関数を渡す形でも、必要な能力だけを使う側が決めていれば、依存を整理できる場合があります。必ずクラスとimplementsをそろえることが、原則の条件ではありません。このプラグインではインターフェースとコンストラクタを使って、その関係を読みやすくしています。書き方の選択と、向きを決める原則を分けて理解します。

DI コンテナ:3つのモジュールを積む

外から受け取るようにすると、今度は「外」で誰かが道具を作って渡す必要があります。部品の数が少なければ、前のページの main.ts のように new を並べれば済みます。けれど、このプラグインでは、ページの情報を読む部品、共有欄を組み立てる部品、クリックのユースケースなどが互いに組み合わさっていて、渡す順番を手で書くのは大変です。そこで、DI コンテナという仕組みに任せています。使っているのはInversifyJSというライブラリです。

TypeScriptweb/src/composition/ShareContainer.ts(39〜52行目)
  /** 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 だけが見つかったのは、このためでした。

provideの行は、左から一度に暗記しようとせず、右端のnew BrowserClipboardから読むと分かりやすくなります。「ここで本物を作る」と分かったら、その左にあるTOKENS.ClipboardGatewayを見ます。「この口の名前で渡す」と分かります。最後に、その作成に必要な前提が何かを見ます。この行では依存の一覧が空なので、先に受け取る道具はありません。

トークンは、ブラウザの機能そのものではありません。コンテナの中で、どの約束に何を結び付けたかを探すための札です。方針のクラスはその札を引いて道具を探すのではなく、組み立てられた道具をコンストラクタで受け取ります。札を読む仕事を外へ置くことで、方針の中へコンテナの都合を持ち込まずに済みます。

ShareContainer には、同じようなまとまりが3つあります。名札カードと既定の設定を渡す generatedInput、いま読んだ browserInfrastructure、ユースケースを組み立てる shareApplication です。起動するときは、この3つを積んでコンテナを作ります。入口の index.ts がしているのは、ShareContainer.create(CATALOG, DEFAULTS) でコンテナを作り、共有欄の独自要素を登録することだけです。index.ts も、infrastructure を直接は読み込みません。

図4.2-1 3つのモジュールを積み、1つだけ差し替える
図4.2-1 3つのモジュールを積み、1つだけ差し替える。現在のしくみ。差し替えるのは infrastructure のモジュール1つだけ。ほかの2つは、ページでもテストでも同じものを使います。図4.2-1 3つのモジュールを積み、1つだけ差し替える。現在のしくみ。差し替えるのは infrastructure のモジュール1つだけ。ほかの2つは、ページでもテストでも同じものを使います。

このつくりの効き目は、テストで分かります。組み立て役のテストでは、3つのうち browserInfrastructure だけを、記録するだけの代役を結ぶモジュールに差し替えています。

TypeScriptweb/test/composition.test.ts(20〜32行目)
/** 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か所、変わらないのはユースケースのコード全部です。

三つのモジュールの図では、差し替わった段だけでなく、残った二段にも注目してください。名札カードを読む側と、ユースケースを作る側は、そのまま使っています。ブラウザとの接続だけを記録用の部品へ替えるので、実際の組み立て方を保ったまま、渡された値や通知先を観察できます。

このテストで確かめられるのは、接続された部品が期待した相手へ仕事を渡すことです。ブラウザのクリップボードや小窓の機能そのものは、記録する代役へ置き換えています。したがって、組み立てのテストが緑でも、ブラウザAPIの許可や表示まで確認できたとは言えません。どの段を本物のままにしたかを図へ残すと、検証の範囲も読み取れます。

組み立て役のテストには、「結び付けていない約束を取り出そうとしたら、黙って undefined を返さずに失敗する」ことを確かめる1件もあります。差し込み口に何も差さっていないまま動き出して、コピーを押した瞬間に初めて壊れる、という事故を、起動の時点で止めるためです。

未登録の約束を取り出すテストも、いつ気づくかを具体的に読みます。登録が無いまま、その約束を解決しようとした時点で失敗します。このプラグインの起動で必要な部品を組み立てるときには、早く見つけられます。ただ、どんな構成でも未使用の全登録を起動時に検査してくれる、と保証する話ではありません。

例外の文面が出たことだけでなく、何を取り出そうとしたかを残すのも大事です。差し込み口の名札が分かれば、組み立て役のどの結び付けを調べればよいかが絞れます。境界を増やした分、接続の失敗を診断しやすくすることまで考えると、読む場所が増える費用を抑えられます。

要素ごとに作る:ファクトリ

DI コンテナが1つずつ道具を作って配ってくれるなら、どの部品も1つずつで足りそうです。ところが、クリックのユースケースだけは、そうはいきません。1つのページに、記事の上と記事の下の2か所に共有欄を置けるからです。コピーの道具は1つを共有してかまいませんが、「共有しました」という知らせは、押された共有欄の要素から、その要素の名前で出す必要があります。

そこで、このプラグインでは、ユースケースそのものではなく、「要素とイベント名を受け取って、ユースケースを1つ作る関数」をコンテナに結んでいます。このように、作ることだけを受け持つ部品をファクトリと呼びます。型の名前は HandleShareClickUseCaseFactory で、中身は (element, eventName) => new HandleShareClickUseCase(…) です。組み立て役のテストでは、イベント名 share-top の要素と share-bottom の要素で別々のユースケースが作られ、どちらもコピーは同じ代役に記録し、知らせは要素ごとに分かれることを確かめています。

図4.3-1 要素ごとに作る:ファクトリ
図4.3-1 要素ごとに作る:ファクトリ。現在のしくみ。要素ごとに違う部分があるので、ユースケースそのものではなく「作る関数(ファクトリ)」をコンテナに結んでいます。図4.3-1 要素ごとに作る:ファクトリ。現在のしくみ。要素ごとに違う部分があるので、ユースケースそのものではなく「作る関数(ファクトリ)」をコンテナに結んでいます。

DI、DI コンテナ、ファクトリの関係を、ここで整理しておきます。DI は「道具を中で作らず、外から受け取る」という渡し方です。DI コンテナは、その「外」で、どの約束にどの実装を結ぶかを覚えておき、組み立てて渡してくれる仕組みです。ファクトリは、作ることだけを受け持つ関数で、コンテナの中でも外でも使えます。このプラグインでは、コンテナが要素ごとの事情を知らなくて済むように、要素ごとに違う部分だけをファクトリに任せています。どれも、DIP で「約束の向こう」に置いた実装を、最後にどこかで選んで渡すための道具です。

ファクトリの図では、コピーの道具と知らせの道具を分けて追ってみます。コピーする能力は、共有欄が上にあっても下にあっても同じです。一方、知らせをどの要素から出すかは、押された共有欄によって違います。だから、一部を共有しながら、要素に結び付く部分は作り分ける形になっています。

すべてを一個にすれば、下の共有欄を押したのに上の要素から知らせる、といった混線を起こしかねません。すべてを毎回作り直せば、共有してよいものまで増えます。どの部品がどの状態を持つかから、作る単位を選んでいます。DIコンテナを使ったから自動でちょうどよい単位になるのではなく、その単位を組み立て役へ記述した結果です。

ブラウザの実装を選ぶのは、組み立て役の1か所だけにしました。四つの層のクラスは DI コンテナを知らず、これまでどおりコンストラクタで約束を受け取るだけです。コンテナを使うのは composition だけで、ほかの層が inversify を読み込めば、依存の境界のテストが止めます。

考えてみる:DI コンテナを使わないと、DIP は守れないの?

守れます。DIP が求めているのは import の向きで、道具をどう渡すかではありません。2ページ目の main.ts のように、組み立てる場所で new を並べて手で渡しても、方針のコードが約束だけを知っていれば DIP です。DI コンテナは、部品が多くなって手で渡すのが大変になったときの便利な道具で、使うかどうかは部品の数と組み合わせの複雑さで決めます。組み立て役の詳しい設計は、連載3.4「Composition RootとDIコンテナ――具体実装をどこで選ぶか」でもう一度扱う予定です。

起動時の配線と、クリック時の利用を分ける

組み立ての話が増えると、クリックのたびに大きなコンテナを調べるように思えるかもしれません。次の図では、起動して配線する場面と、配線した道具を使う場面を、二つに分けました。

図4.4-1 選んでつなぐ場面と、使う場面を分ける
図4.4-1 選んでつなぐ場面と、使う場面を分ける。4.0.0の組み立てと実行の模型。具体的な道具を選ぶのは組み立て役です。ユースケースは、渡された口を使って仕事を頼みます。図4.4-1 選んでつなぐ場面と、使う場面を分ける。4.0.0の組み立てと実行の模型。具体的な道具を選ぶのは組み立て役です。ユースケースは、渡された口を使って仕事を頼みます。

「起動時」の配線盤では、約束の札へ本物のブラウザ実装を結びます。テストなら、同じ札へ記録する代役を結べます。ここは具体的な道具の名前を知る場所です。「クリック時」の場面では、受け取っておいた道具のwriteを呼びます。使う側が、毎回「今回はBrowserClipboardか」と選び直す必要はありません。

図で配線後に利用の場面へ進むのは、実行中に設定を一切変更できないという保証ではありません。このユースケースが、渡された部品を使う役割であり、具体的な道具を探し回る役割ではないことを示しています。どこで選ぶかと、どこで使うかを分けると、コードを読む順番も決めやすくなります。

もしユースケースの中でコンテナから道具を取り出す形に変えると、そのクラスは必要な口だけでなく、道具の探し方まで知ることになります。コンストラクタの引数を見るだけでは、何を使うかが分かりにくくなります。この実装でコンテナをcompositionへ閉じ込めるのは、ライブラリ名を隠すためだけではありません。依存する能力を、クラスの入口に見える形で残すためでもあります。

配線盤の練習では、本番用とテスト用を二枚描いてみます。約束の札は同じ場所に置き、本番側にはBrowserClipboard、テスト側には記録する代役を結びます。クリック処理が、二枚の図で同じままなら、外の道具を替える場所を外へ集められています。

次に、知らせの送り先だけが違う二つの共有欄を足します。コピーの道具は共有し、通知先を持つ部分をそれぞれ作れば、先ほどのファクトリの意味が見えてきます。道具を替える軸と、要素ごとに作る軸は別です。この二軸を分けると、コンテナの登録が多くても、どの違いを表しているかを追いやすくなります。

実装を探す人には、配線盤の入口が分かる名前も必要です。方針のクラスに具体名が無い分、初めて読む人は、どこで本物を選ぶかを探します。このプラグインではShareContainerという組み立て役からたどれます。具体名を隠すことだけで終わらず、選ぶ場所を見つけやすくすることも、読みやすい境界の一部です。

小さなアプリなら、図の起動時の配線は数行のnewで済みます。大きくなれば、今のように登録の仕組みを使えます。どちらの場合も、利用する側が組み立て方法を知る必要はありません。コンテナを導入するかどうかを決める前に、この二つの場面を分けて描いてみるのが、手頃な始め方です。

COMMENTS
コメント…

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

PASS IT ON

この気づきを、誰かにも。

この記事を書いた人

Takaomi Murasaki

Promari SNS Shareの開発を通して、Webの仕組みや設計の選び方を紹介しています。動く実装と、その判断に至るまでを記事に残しています。

公開記事 11 件

プロフィールを見る
THANK YOU FOR READING.すべての記事へ ↗

FROM INSIGHT TO IMPACT

「できたらいいな」を、
動く仕組みに。

記事で見つけたヒントを、あなたの事業へ。
新しいサービスも、手間のかかる業務も。
いまの課題から、つくるべきものを一緒に考えます。

開発・AI・研修の実績を見る

まだ、仕様書はいりません。

「何から始める?」から、ご一緒に。

課題がまとまっていなくても大丈夫。テーマを選ぶと相談文をご用意します。
連絡先の必須入力は、お名前とメールアドレスだけ。

まずは課題の整理から相談する

ご相談後の流れ

  1. 01 内容を確認
  2. 02 メールでご連絡
  3. 03 課題・進め方をご相談
送信だけで契約やお申込みが確定することはありません。