ISP――コピーの代役に、いいねの保存まで求めない
作成: / 公開: / 内容更新:
執筆:tamito0201 / 掲載・運営:プロマリ
丸い共有ボタンの裏側を掘る連載の第7回。SOLID の4つ目の原則 ISP を、飛べないペンギンの例から、プラグインの5つの小さな口(ポート)とテストの代役まで読み進めます。全部入りのインターフェースにすると、コピーの代役に何が要るのか。作業コピーでの実験と図15点で確かめます。
プラグインの外の能力は、5つの小さな口に分かれている
前のページでは、ISPが「利用側に、使わない能力まで求めないように、インターフェースを分ける」原則だと確かめました。鳥の例では、飛ぶと泳ぐを別々の約束にすると、ペンギンに意味のない fly を書かせずに済みました。ここからは、この連載で読んでいる共有ボタンのプラグイン(promari-sns-share)の実物で確かめます。コードは promari-toolkit のコミット 041605e の時点のものです。
共有ボタンを押すと、プラグインはブラウザの機能を使います。記事の URL をクリップボードに書き込む、スマートフォンの共有メニューを出す、共有画面を小さな窓で開く、といった機能です。プラグインの内側のコードは、こうしたブラウザの機能を直接は呼びません。間にポートという差し込み口のインターフェースを置き、その向こうにブラウザの実装をつなぎます。この記事では、ポートを「口」とも呼びます。口は web/src/domain/gateway フォルダに5つあり、コピーの口は次のとおりです。
/** 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行目の interface ClipboardGateway がコピーの口の名前、3行目の write が「文字列を書き込み、終わったら知らせる(Promise)」というメソッドです。5行目の fallback は、書き込みに失敗したときの代わりの手段で、名前の後ろの ? は「無くてもよい」という印です。コピーの口に入っているのは、この2つだけです。
| 口(インターフェース) | できること | メソッド | ブラウザの実装 |
|---|---|---|---|
| ClipboardGateway | URL をコピーする | write・fallback? | BrowserClipboard |
| NativeShareGateway | 端末の共有メニューを出す | available・share | BrowserNativeShare |
| ShareWindowGateway | 共有画面を小さな窓で開く | open | BrowserPopupWindow |
| ShareActivityPublisher | 押されたことを知らせる | publish | CustomEventShareActivityPublisher |
| SharedPageGateway | 共有するページのタイトルと URL を読む | read | BrowserSharedPage |
どの口も、メソッドやプロパティは1つか2つです。「共有のために外の世界でできること」を、能力ごとに1つずつ別の口にしています。ブラウザの実装も口ごとに1つずつで、BrowserClipboard が実装するのは ClipboardGateway だけです。口は、外の世界の能力ごとに1つずつ。これが、このプラグインの ISP の当て方です。
五つの口の図は、まず上から名前を暗記するより、利用者の動きから一つ選んでたどると読みやすくなります。記事のURLをコピーしたいなら、ClipboardGatewayからBrowserClipboardへ進みます。ページのタイトルを読みたいなら、SharedPageGatewayからBrowserSharedPageへ進みます。同じブラウザを使っていても、頼む仕事が違うので入口も違います。
ブラウザの機能をまとめてBrowserServiceと呼ぶことはできますが、その名前だけでは、何を頼めるかが分かりません。コピーのために受け取った部品へ、タイトルを読む機能まで付いていると、使わない側もその型を知ることになります。五つに分けた図は、ブラウザの構造を写した図ではなく、このプラグインが外へ頼みたい仕事を分けた図として見てください。
ここではファイルの数も五つですが、それが原則の決めた正解の数ではありません。機能が増えたり、利用する場面が分かれたりすれば、必要な口の形は変わります。大事なのは、今ある五つが、それぞれどの仕事に応じたものなのかを説明できることです。「小さい口にした」という説明に、具体的な利用側を添えると判断が見えます。
誰が、どの口を使うか
口を分ける単位を決めるのは、口を使う側、つまり利用側です。このプラグインで口を使う利用側は2つあります。1つはボタンが押されたときの処理(HandleShareClickUseCase)、もう1つは共有欄を画面に組み立てる部品です。
クリックの処理が使うのは、コピー・端末の共有・共有の窓・知らせの4つの口です。押されたボタンの種類によって、コピーするか、端末の共有メニューを出すか、窓を開くかのどれか1つを行い、処理が正常に戻ると「押された」という知らせを出します。画面を組み立てる部品が使うのは、ページを読む口(今のページのタイトルと URL を読む)と、端末の共有の口のうち「この端末で共有メニューが使えるか」(available)だけです。
そして、いいねの保存のための口は、どこにもありません。共有欄にはいいねのボタンを出せるのに、保存の口が無いのはなぜか。これは4ページ目で読みます。
利用側の図は、行を一つ選んで、そこから出る線だけを追ってみましょう。画面を組み立てる部品は、現在のページを読み、端末の共有ボタンを出せるかを調べます。ここへクリップボードの書き込み能力を渡しても、仕事は増えません。必要な情報と、実行する操作の線が、別々の場所から出ていることが見えます。
クリックの処理の線は、複数のボタンを受け持つので四本あります。ただし、一回のクリックですべてを動かすわけではありません。コピーならclipboard、端末の共有ならnativeShareというように選び、その結果を知らせます。「一回の呼び出しで使うもの」と「この利用側が受け持つすべての場面で使うもの」を区別すると、束ねた四本の理由を読めます。
ShareGateways は、クリックが一緒に使う4つの束
クリックの処理は、4つの口をまとめて受け取ります。その「まとめ」も、インターフェースとして書かれています。
/**
* The outside capabilities that sharing needs. The domain states what it needs;
* infrastructure implements each interface with browser APIs, and tests supply plain objects.
*/
import type { ClipboardGateway } from './ClipboardGateway.ts';
import type { NativeShareGateway } from './NativeShareGateway.ts';
import type { ShareWindowGateway } from './ShareWindowGateway.ts';
import type { ShareActivityPublisher } from './ShareActivityPublisher.ts';
export interface ShareGateways {
readonly clipboard: ClipboardGateway;
readonly nativeShare: NativeShareGateway;
readonly popup: ShareWindowGateway;
readonly activity: ShareActivityPublisher;
}最後の6行の ShareGateways は、clipboard・nativeShare・popup・activity の4つの項目を持ちます。項目の型は、それぞれ別の小さな口です。冒頭のコメントには「ドメイン(内側)が必要なものを述べ、infrastructure が各インターフェースをブラウザの機能で実装し、テストは普通のオブジェクトを渡す」と書かれています。
分けた口をまた1つにまとめたら、ISP に逆戻りではないか、と感じるかもしれません。参考にした Zenn の本でも、分けたインターフェースを1つにまとめ直す形を「Interface Soup」と呼び、避けるべきものとしています。ShareGateways が違うのは、2つの点です。1つは、束ねた4つはどれも、クリックの処理が実際に使うものだということ。もう1つは、この束を丸ごと実装するクラスが無いことです。束の中身は、BrowserClipboard や BrowserNativeShare といった、口ごとに別々の実装の寄せ集めです。実装する側から見ると、口は分かれたままです。
束の外に残した口もあります。ページを読む口(SharedPageGateway)です。クリックの処理は、押されたボタンがすでに持っている URL を使うので、ページを読み直しません。使わない口は、束に入れていないのです。クリックの処理の入口を見てみます。
async execute(context: ShareClickCommand): Promise<ShareClickResult> {
const outcome = await this.#perform(context);
this.#gateways.activity.publish({ destination: context.button.key, url: context.button.url, placement: context.placement });
return outcome;
}
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';
}
}最初の execute が入口です。まずボタンの種類に応じた処理(#perform)を行い、その正常に戻った次の行で activity.publish を呼んで、押されたことを知らせます。#perform の中の case ‘copy’ がコピーの場合で、clipboard.write に URL を渡し、失敗したら fallback を呼びます。fallback?.( ) の ?. は「fallback があれば呼ぶ」という書き方で、口の約束で fallback が「無くてもよい」とされていることに合わせています。コピーの場合に触る口は、clipboard と activity の2つだけです。共有の窓や端末の共有には触れません。
考えてみる:クリックの処理は、コピーのときに popup の口を使わないのに、束で受け取っている。これは ISP に反しない?
ISP が見るのは、利用側ごとの必要です。ここでの利用側は「クリックの処理」全体で、押されたボタンの種類によっては popup も nativeShare も使います。だから4つを束で受け取るのは、この利用側にとって必要な分だけです。もしコピーだけを担当する処理を別に作るなら、その処理には clipboard と activity だけを渡すのが ISP にかなった形です。ただしそうすると、どのボタンにどの処理を使うかを選ぶ場所が、画面の側に増えます。分け方を変えると、分岐の置き場所も動くのです。
実装は一つでも、利用側に見せる口は分けられる
分ける単位を、もう一枚の図で確かめましょう。これは現在のブラウザ実装を変更した図ではなく、カモの例を一般化した説明例です。同じ道具箱を、二人が別々の窓から見ています。
印刷を頼む人の窓には、印刷の取っ手だけが見えます。スキャンを頼む人の窓には、スキャンの取っ手だけが見えます。向こう側の複合機は一台のままです。実装が複数の能力を持っていても、利用側が依存する約束を必要な範囲へ絞れる、という図です。
この考え方で見ると、「一つのクラスが全部を実装しているか」だけではISPを判定できないと分かります。複合機の実装が印刷とスキャンを両方持っていても、印刷する側がスキャンの契約に引きずられなければよいのです。反対に、内部のクラスをたくさんに分けても、全利用側へ全部入りの型を渡していたら、依存の広さは変わりません。
ShareGatewaysのトレーも、利用側の範囲から読みます。このトレーを受け取るクリック処理は、四つの口を担当する場面があります。一方、ClipboardGatewayだけを実装する部品は、トレー全体を実装する必要がありません。渡す側のまとめ方と、各部品が引き受ける約束を、二つの窓として分けると理解しやすくなります。
二つの窓をコードで考えるときは、利用側の入口に注目します。印刷する処理の引数が「印刷できるもの」なら、スキャンのメソッドが増えても、その引数の約束は変わりません。同じ複合機のオブジェクトを渡すことはできますが、使う側は自分に見える印刷の約束だけに頼れます。
一方、引数が「複合機のすべて」なら、関係のない項目も同じ型へ集まります。テストの代役も、その広い型を満たさなければなりません。窓の数だけを増やしても、入口が全部入りのままでは目的を果たせません。図の窓を、実際に利用側が受け取る型へ対応させることが大切です。
この違いは、実装ファイルを開かなくても、利用側のコンストラクタや関数の引数から調べられます。どんな能力を要求しているかを一覧にし、本文で実際に呼ぶものへ印を付けます。余った能力があるとき、それが別の実行経路で必要なのか、本当に要らないのかを確かめると、分ける候補を見つけられます。
もし後からコピーだけの利用側を独立させるなら、その窓へトレーを丸ごと渡す必要があるかを再検討します。今の束が適切だったことは、将来のすべての利用側にも適切だという証明ではありません。使う側が変わったときに、口の範囲も見直す。分割は一度きりの整理ではなく、利用する仕事に合わせる判断です。











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