DIP――ブラウザの機能を、ポートの向こうへ置く
作成: / 公開: / 内容更新:
執筆:tamito0201 / 掲載・運営:プロマリ
丸い共有ボタンの裏側を掘る連載の第8回。SOLID の5つ目の原則 DIP を、「コピー」のボタン1つから確かめます。コピーを頼むコードはブラウザを知らず、約束(ポート)だけを知る。実行時の呼び出しは外へ、import は内へ。import の向きを数え、DI・DI コンテナ・ファクトリの役目を読み、わざと境界を破って何が止めるかまで、図16点で読み解…
まず一般的な例で:ボタンとデータベース
前のページでは、DIPを「方針のコードも、道具のコードも、あいだに置いた約束のほうを向く」とまとめました。このページでは、いったん共有ボタンから離れて、よくある小さな例で、この「向く」の意味をつかみます。題材は、ボタンを押したら、入力した文字をデータベースに保存する、という処理です。
ボタンが保存先を自分で作る形
何も考えずに書くと、たいていは次のようになります。
import { DatabaseSaver } from './DatabaseSaver';
export class Button {
private saver = new DatabaseSaver();
onClick(text: string): void {
this.saver.save(text);
}
}上から読んでいきます。1行目のimportで、Button は DatabaseSaver という具体的な保存の部品の名前を知ります。4行目では、その DatabaseSaver を自分で new して作り、持っています。6〜8行目のクリックの処理では、その保存の部品に文字を渡しています。短くて、読みやすいコードです。
困りごとは、変化が来たときに現れます。1つ目は、保存先を変えたくなったときです。「データベースではなく、ファイルに保存したい」と言われたら、ボタンのコードを開いて、1行目と4行目を書き換えることになります。ボタンの役目は「押されたら保存を頼む」ことで、それ自体は何も変わっていないのに、です。
2つ目は、ボタンだけを試したくなったときです。「クリックしたら、入力した文字がちゃんと保存に回るか」を確かめたいだけなのに、ボタンが自分で DatabaseSaver を作ってしまうので、データベースが用意できない場所ではテストが動きません。3つ目は、作る順番です。保存の担当がまだ DatabaseSaver を作り終えていなければ、ボタンの担当も作り始められません。
3つとも原因は同じです。ボタン(方針)が、保存の具体的なやり方(道具)を直接知っていることです。依存の矢印は、ボタンから DatabaseSaver へ、一方通行に伸びています。
「保存する」という約束を挟む
そこで、ボタンと保存のあいだに、「保存する」という約束を1つ挟みます。約束には、どう保存するかは書かず、「文字列を受け取って保存する」ことだけを書きます。TypeScript では、これをインターフェースで表します。
// Saver.ts:ボタンが必要とする「保存する」という約束
export interface Saver {
save(text: string): Promise<void>;
}
// Button.ts:約束だけを知り、保存の部品は外から受け取る
import type { Saver } from './Saver';
export class Button {
constructor(private readonly saver: Saver) {}
async onClick(text: string): Promise<void> {
await this.saver.save(text);
}
}上の3行が約束です。save という名前で文字列を受け取り、終わったら知らせる(Promise<void> を返す)。それだけです。データベースという言葉は、どこにも出てきません。下の Button は、import で Saver という約束の名前だけを知ります。そして、コンストラクタの引数で、約束を満たす部品を外から受け取ります。ボタンの中に new は1つもありません。
// DatabaseSaver.ts:保存の部品のほうが、約束に合わせる
import type { Saver } from './Saver';
export class DatabaseSaver implements Saver {
async save(text: string): Promise<void> {
// データベースへ書き込む(中身は省略)
}
}
// main.ts:どの部品を使うかは、組み立てる場所だけが知る
const button = new Button(new DatabaseSaver());
// test:代役を渡せば、データベースなしで試せる
const saved: string[] = [];
const testButton = new Button({ save: async (t) => { saved.push(t); } });DatabaseSaver は implements Saver と書いて、約束に合わせて作られます。import しているのは、やはり Saver という約束のほうです。そして、どの部品を使うかは、最後の main.ts のような「組み立てる場所」だけが知っています。テストでは、書き込むはずの文字を配列に記録するだけの代役を渡せば、データベースが無くてもボタンを試せます。
ここで、import の矢印を並べてみます。約束を挟む前は、ボタンから DatabaseSaver へ矢印が伸びていました。挟んだ後は、ボタンから Saver へ、そして DatabaseSaver からも Saver へ、矢印が伸びています。保存の部品から出る矢印の向きが、ボタンの側へと逆になりました。これが「依存性逆転」の「逆転」です。保存の部品は、もう誰かに使われるのを待つだけの存在ではなく、ボタンが決めた約束に合わせる側へ回りました。
変化が来たときの動きも変わります。ファイルに保存したくなったら、FileSaver を1つ作って、組み立てる場所で渡す部品を替えるだけです。ボタンのコードは開きません。これは、連載2.2で読んだ OCP の「足すだけで済む」形でもあります。DIP は、OCP の差し込み口を、コードの矢印の向きから作る方法だと言えます。
約束は、使う側の都合で書く
もう1つ、見落としやすい点があります。約束(Saver)を、どちらの都合で書くかです。
保存の担当に任せると、約束は「データベースの接続を開く」「表の名前を指定する」「行を挿入する」のように、データベースの手順をそのまま並べたものになりがちです。これでは、名前がインターフェースになっただけで、ボタンは相変わらずデータベースの都合を知っています。ファイルに替えようとしても、「表の名前」をファイルでどう扱うのか、と困ってしまいます。
DIP の約束は、使う側(ボタン)が「何をしてほしいか」で書きます。ボタンが欲しいのは「この文字を保存しておいて」だけなので、約束も save(text) の1つだけです。約束を使う側の近くに置くのも、そのためです。このプラグインで、ClipboardGateway がブラウザの実装の隣(infrastructure)ではなく、方針の側(domain)に置かれているのも、同じ理由です。
DIP を守らないとどうなるかも、ここでまとめておきます。保存先の変更がボタンの書き換えになる。ボタンを試すのに本物の道具が要る。道具ができるまで方針のコードを作れない。そして、道具が増えるたびに、方針のコードに「この場合はこちら」の分岐が積み上がる。Martin の Copy の例で、出力先ごとに if 文が増えていったのと同じ姿です。
考えてみる:約束を挟めば、どんな道具でも差し替えられるの?
約束の形に合う道具なら差し替えられますが、合わない道具は差し替えられません。Saver の約束は「文字列を1つ受け取って保存する」だけなので、画像を保存したくなったら、約束そのものを直すことになります。それに、差し替えた道具が約束の形だけを満たして、中身の約束(保存したら成功を返す、失敗したら失敗を返す)を破っていたら、使う側は壊れます。形だけでなく中身の約束まで守らせるのは、前々回の連載2.3で読んだ LSP の話です。
このページの「守っていない例・守っている例・守らないとどうなるか」という進め方と、ボタンとデータベース保存の題材は、NakuRei さんの本『SOLID原則完全に理解した!になるための本』の「依存性逆転の原則」の章を参考に、この連載の TypeScript で書き直しました。Martin の原典は、C++ Report のコラムThe Dependency Inversion Principle(1996、Web Archive に残る写し)で読めます。







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