PROMARI JOURNAL

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

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

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

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

PASS IT ON

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

プラグインの実物:コピーの差し込み口

前のページでは、ボタンと保存のあいだに「保存する」という約束を挟むと、import の矢印が逆になることを見ました。このページでは、共有ボタンのプラグインに戻り、コピーの差し込み口(ポート)の実物を読みます。1ページ目の地図のとおり、約束は domain に、ブラウザの実装は infrastructure に、コピーを頼む手順は application に置かれています。

ClipboardGateway:6行の約束

まずは約束そのものです。ファイル全体でも6行しかありません。

TypeScriptweb/src/domain/gateway/ClipboardGateway.ts(全文)
/** 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/ というフォルダです。約束の持ち主は、使う側なのです。

図3.1-1 ClipboardGateway:文字列を書き込み、成功か失敗を返す
図3.1-1 ClipboardGateway:文字列を書き込み、成功か失敗を返す。現在のしくみ。約束は「書き込む」と「うまくいかなかったときの代わり」の2つだけ。ブラウザの実装も、テストの代役も、この形に合わせます。図3.1-1 ClipboardGateway:文字列を書き込み、成功か失敗を返す。現在のしくみ。約束は「書き込む」と「うまくいかなかったときの代わり」の2つだけ。ブラウザの実装も、テストの代役も、この形に合わせます。

BrowserClipboard:約束に合わせる側

次は、壁の向こうの実装です。ブラウザの Clipboard API を使って、約束を満たします。

TypeScriptweb/src/infrastructure/BrowserClipboard.ts(全文)
/** 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 のうち、コピーの部分です。

TypeScriptweb/src/application/HandleShareClickUseCase.ts(5行目と38〜50行目の抜き出し)
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 という言葉は出てきません。

図3.3-1 呼び出しは外へ、import は内へ
図3.3-1 呼び出しは外へ、import は内へ。現在のしくみ。実行時は外へ向かって呼び出しますが、import はどちらも domain のポートへ向かいます。この2つの向きのずれが「逆転」です。図3.3-1 呼び出しは外へ、import は内へ。現在のしくみ。実行時は外へ向かって呼び出しますが、import はどちらも domain のポートへ向かいます。この2つの向きのずれが「逆転」です。

ここで、2つの向きを並べてみます。ボタンを押したとき、処理は「ユースケース → 約束の write → ブラウザの実装 → navigator.clipboard」と、内側から外側へ向かって進みます。一方、コードの import は、ユースケースから ClipboardGateway へ、そして BrowserClipboard からも ClipboardGateway へ。どちらも内側の domain へ向かっています。

実行時の呼び出しは外へ、import は内へ。この2つの向きがずれていることが、DIP の「逆転」の正体です。約束を挟まなければ、import も呼び出しと同じく外へ伸び、ユースケースがブラウザの実装を知ることになっていました。約束を挟んだことで、呼び出しの向きはそのままに、import の向きだけが逆になったのです。

import の向きを数える

「内側は外側を知らない」と言うのは簡単ですが、本当にそうなっているかは、数えてみないと分かりません。コマンドで確かめてみます。

Shellpromari-sns-share で、import の向きと道具の名指しを数える(コミット 041605e)
$ 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.ts

1つ目は、domain と application の中で、infrastructure のフォルダを指している行があるファイルの数です。0件でした。内側の2つの層は、ブラウザの実装を1つも知りません。2つ目は、BrowserClipboard という名前が出てくるファイルです。実装そのものを除くと、組み立て役の ShareContainer.ts だけでした。3つ目は、navigator が出てくるファイルです。infrastructure の2つのほかに、domain のファイルが1つ出てきましたが、中を見ると、コメントの中に navigator.share という語が書かれているだけで、コードではありません。

図3.4-1 import の向きを数える
図3.4-1 import の向きを数える。実測(grep の出力)。内側から外側への import は0件。ブラウザの実装を名指ししているのは、組み立て役だけでした。図3.4-1 import の向きを数える。実測(grep の出力)。内側から外側への import は0件。ブラウザの実装を名指ししているのは、組み立て役だけでした。

数えると、ブラウザの機能を実際に動かしているのは infrastructure だけで、それを名指しで選んでいるのは組み立て役だけだと分かります。方針のコード(domain と application)は、約束の名前しか知りません。ただし、grep は「文字列が出てくるか」を数えるだけで、コメントとコードを区別しません。このあとの5ページ目で、型とテストによる、もっと確かな見張りも確かめます。

前回の小さなポートが、そのまま抽象になる

domain/gateway/ には、ClipboardGateway のほかにも約束が並んでいます。端末の共有メニューを呼ぶ NativeShareGateway、共有画面の窓を開く ShareWindowGateway、共有の操作をページへ知らせる ShareActivityPublisher、共有するページの URL とタイトルを読む SharedPageGateway です。前回の連載2.4では、ISPの目で、これらを能力ごとに小さく分けた理由を読みました。コピーの代役を作るのに、いいねの保存まで求めない、という話でした。

図3.5-1 小さなポートが、そのまま抽象になる
図3.5-1 小さなポートが、そのまま抽象になる。現在のしくみ。能力ごとに分けた5つの小さな約束が、そのまま5つの差し込み口になっています。1つの口には、1つの実装が差さります。図3.5-1 小さなポートが、そのまま抽象になる。現在のしくみ。能力ごとに分けた5つの小さな約束が、そのまま5つの差し込み口になっています。1つの口には、1つの実装が差さります。

DIP の目で見ると、この5つの小さな約束が、そのまま5つの差し込み口になっています。1つの口には、1つのブラウザの実装が差さります。そして、口が小さいほど、差し込むものを作るのも簡単です。コピーの代役なら、write と fallback の2つを書けば済みます。ISP で約束を小さく分けたから、DIP の差し込み口を、代役でも本物でも気軽に差し替えられるのです。2つの原則は、別々のことを言いながら、同じ差し込み口を支えています。

考えてみる:ClipboardGateway を infrastructure のフォルダに置いたら、何が変わるの?

コードの動きは変わりませんが、import の向きが崩れます。ユースケースが ClipboardGateway を使うには、infrastructure のフォルダを import することになり、「内側の層は外側を知らない」という約束が破れます。すると、方針のコードの型検査に、ブラウザの実装のファイルまで巻き込まれ、ブラウザの型が無い環境では通らなくなります。約束の置き場所は、中身と同じくらい大事な設計の一部です。

COMMENTS
コメント…

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

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 課題・進め方をご相談
送信だけで契約やお申込みが確定することはありません。