PROMARI JOURNAL

LSP――Copyに「開くURL」を要求すると何が壊れるか

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

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

丸い共有ボタンの裏側を掘る連載の第6回。SOLID の3つ目の原則 LSP を、長方形と正方形の例と、共有ボタンの実物で確かめます。コピーのボタンに「開く URL」を求める書き換えを実際に作ると、型検査は通り、テストが5件落ちました。事前条件・事後条件・不変条件の3つの約束を、図15点で読み解きます。

PASS IT ON

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

検証:action を見ずに開くと、何が壊れるか

3ページ目では、このプラグインが action を見て実行方法を選んでいることを確かめました。では、もし action を見ずに、「どの共有先も共有 URL を開けば共有できる」と思い込んだコードを書いたら、何が壊れるのでしょうか。LSPの違反を、実際に作って確かめてみます。

図4-1 検証:action を見ずに開いてみる
図4-1 検証:action を見ずに開いてみる。検証の手順(実測)。書き換える前の結果を先に取ってから書き換えます。比べる相手がないと、何が壊れたかを数えられません。図4-1 検証:action を見ずに開いてみる。検証の手順(実測)。書き換える前の結果を先に取ってから書き換えます。比べる相手がないと、何が壊れたかを数えられません。

検証は、次の順で進めました。まず、プラグインのリポジトリをgit worktreeで作業用のコピーに写しました。公開しているプラグインには何も入れていません。次に、何も変えていない状態で、型検査と Node のテストを流しました。型検査は通り、テストは63件すべて通りました。そのうえで、クリックを処理する HandleShareClickUseCase の #perform を、action を見ない形に書き換え、もう一度流します。

書き換えた差分は次のとおりです。行頭の – が消した行、+ が足した行です。

Diff検証用に書き換えた HandleShareClickUseCase.ts の差分(一時作業コピーのみ・本体には入れていない)
@@ -5,3 +5,2 @@
 import type { ShareGateways } from '../domain/gateway/ShareGateways.ts';
-import { ShareActionPolicy } from '../domain/service/ShareActionPolicy.ts';
 import type { ShareButtonViewModel } from './BuildShareBarUseCase.ts';
@@ -38,30 +37,5 @@ export class HandleShareClickUseCase {
   async #perform({ button, preventDefault }: ShareClickCommand): Promise<ShareClickResult> {
-    const decision = ShareActionPolicy.decide(button);
-    switch (decision) {
(ここに消した25行が続く。copy・native・popup・follow・default の分岐で、3ページ目で読んだ部分なので省略)
-    }
+    // 実験:action を見ず、「どの共有先も href を開けば共有できる」と思い込む。
+    if (button.popup && this.#gateways.popup.open(button.href, button.popup)) { preventDefault(); return 'popup'; }
+    return 'follow';
   }

書き換えたあとの処理は、2行だけです。小窓の設定があれば、共有 URL(button.href)を小窓で開いて ‘popup’ を返します。無ければ ‘follow’ を返し、ブラウザにリンクをたどらせます。X や Facebook のボタンなら、これで今までどおり動きます。差分の行数は、足した行が3、消した行が29でした(git diff –stat の数え方です。省略した行も含みます)。

型検査は通り、テストが5件落ちた

書き換えたあとに、型検査とテストを流した結果が次です。

Shell書き換えたあとの型検査とテストの結果(抜き出し。所要時間と呼び出し履歴は省略)
$ pnpm run typecheck > /dev/null; echo $?
0
$ node --test 'web/test/**/*.test.ts'
✖ copy はクリップボードへ書いて copied を返し、既定動作を止める
  'follow' !== 'copied'
✖ 書き込みの完了を待ってから結果を返す(完了前に成功を名乗らない)
  TypeError: finish is not a function
✖ clipboard が失敗したら fallback を出し、copied とは返さない
  + 'follow'
  - 'copy-fallback'
✖ 端末共有の成功と拒否で、従来どおり操作イベントを一度だけ通知する
  'follow' !== 'shared'
✖ クリックの通知は要素ごと・イベント名ごとに分かれる
  'follow' !== 'copied'
ℹ tests 63
ℹ pass 58
ℹ fail 5

最初の2行が型検査です。終了コードは0で、型検査はまた何も言いませんでした。書き換えた処理も、受け取る値と返す値の型は元と同じだからです。2ページ目の正方形と同じで、形の合い方だけを見る仕組みは、この取り違えを見つけません。

下の部分が Node のテストです。63件のうち58件が通り、5件が落ちました。✖ の行が落ちたテストの名前で、その下が落ちた理由です。’follow’ !== ‘copied’ は「返ってきたのは follow で、期待していた copied と違う」という意味で、+ と – の2行も同じことを表しています。2件目の TypeError は、クリップボードに書く部品が一度も呼ばれなかったので、書き込みの終わりを知らせる関数(finish)が用意されないまま使われた、というエラーです。落ちた5件は、どれもコピーか端末の共有のテストでした。

図4.1-1 型検査は通り、テストが5件落ちた
図4.1-1 型検査は通り、テストが5件落ちた。実測(検証結果)。形だけを見る型検査は通りました。落ちた5件は、どれもコピーと端末の共有の結果(事後条件)を書いたテストです。図4.1-1 型検査は通り、テストが5件落ちた。実測(検証結果)。形だけを見る型検査は通りました。落ちた5件は、どれもコピーと端末の共有の結果(事後条件)を書いたテストです。

5件を捕まえたのは、テストに事後条件が書いてあったからです。「コピーを押したら、クリップボードに書いて copied を返す」「書き込みが終わる前に成功を名乗らない」「失敗したら copied とは返さない」。どれも、コピーの処理が守るべき結果の約束です。約束をテストに書いておいたので、約束を破った形が、実行する前に見つかりました。一方で、X や Facebook のテストは落ちませんでした。開く共有先にとっては、書き換えたあとの処理も、約束どおりだったからです。

コピーのボタンが、コピーしなかった

テストが落ちたことは分かりました。では、実際の画面では何が起きるのでしょうか。書き換えた処理と元の処理に、同じボタンを渡して比べる小さなスクリプトを書き、それぞれが何を呼んだかを記録しました。ブラウザの部品の代わりに、呼ばれたことを記録するだけの部品を渡しています(見やすくするため、URL に付く計測用の値は外しました)。

同じボタンを、元の処理と書き換えた処理に渡した結果(実測)
ボタン処理返した結果リンクを止めたか呼ばれた部品
X元の処理popup止めた小窓で投稿画面を開く・計測の知らせ
X書き換えた処理popup止めた小窓で投稿画面を開く・計測の知らせ
コピー元の処理copied止めたクリップボードへ記事の URL を書く・計測の知らせ
コピー書き換えた処理follow止めない計測の知らせだけ
端末の共有元の処理shared止めた端末の共有メニューを開く・計測の知らせ
端末の共有書き換えた処理follow止めない計測の知らせだけ

X のボタンは、元の処理でも書き換えた処理でも、まったく同じ動きでした。違ったのは、コピーと端末の共有です。書き換えた処理では、クリップボードにも端末の共有メニューにも、何も頼んでいません。呼ばれたのは、計測の知らせ(promari-sns-share という名前のイベント)だけでした。

図4.2-1 コピーのボタンが、コピーしなかった
図4.2-1 コピーのボタンが、コピーしなかった。実測(呼ばれた部品)とコードから読める画面の動き。クリップボードは空のまま、計測の知らせだけが出ます。約束が弱くなっても、エラーは1つも出ません。図4.2-1 コピーのボタンが、コピーしなかった。実測(呼ばれた部品)とコードから読める画面の動き。クリップボードは空のまま、計測の知らせだけが出ます。約束が弱くなっても、エラーは1つも出ません。

ここから先は、表示の部品のコードから読める動きです(ブラウザで押した様子は、今回は測っていません)。この記事の上にある丸い共有欄では、コピーのボタンはリンクではなく、ただのボタンとして描かれています。書き換えた処理は follow を返してリンクに任せますが、たどるリンクがありません。つまり、押しても何も起きず、「コピーしました」の知らせも出ません。丸くない標準の共有欄では、コピーのボタンは記事の URL へのリンクとして描かれているので、同じ記事をもう一度開き直すことになります。

そして、どちらの場合も、計測の知らせだけは出ています。アクセス解析がこの知らせを数えていれば、「コピーのボタンが押された」と記録されます。利用者の手元には何もコピーされていないのに、です。コピーの約束(事後条件)が弱くなっても、エラーは1つも出ません。静かに壊れるので、テストで約束を書いておかないと、気づくのが遅れます。

考えてみる:X のテストは1件も落ちなかったのに、なぜこの書き換えはだめなの?

書き換えた処理は、開く共有先だけを前提にしています。X や Facebook は開く共有先なので、前提どおりに動きました。けれど、同じ並びにはコピーや端末の共有も入っています。使う側(クリックの処理)が、並びの中の一部の共有先にしか通用しない約束に頼ったので、残りの共有先に取り替えたときに壊れたのです。LSP の違反は、「たいていの場合は動く」という形で現れることが多く、それが見つけにくさの理由です。

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