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 コンテナ: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 だけが見つかったのは、このためでした。

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か所、変わらないのはユースケースのコード全部です。

組み立て役のテストには、「結び付けていない約束を取り出そうとしたら、黙って 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 で「約束の向こう」に置いた実装を、最後にどこかで選んで渡すための道具です。

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

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

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

COMMENTS
コメント…

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

PASS IT ON

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

この記事を書いた人

Takaomi Murasaki

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

公開記事 10 件

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

FROM INSIGHT TO IMPACT

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

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

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

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

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

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

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

ご相談後の流れ

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