PROMARI JOURNAL

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

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

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

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

PASS IT ON

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

共有ボタンの「開く URL」は、誰にとっても開く先か

ここからは、共有ボタンの実物に戻ります。前のページでは、長方形と正方形の例で、LSPが見ている「約束」を確かめました。このページでは、共有先の部品に、どんな約束があるかを見ていきます。

その前に、連載で使ってきた呼び名を短く思い出しておきます。このプラグインでは、共有先ごとに名札カードという小さな設定ファイルが1枚ずつあります。X なら destinations/x.toml、コピーなら destinations/copy.toml です。名札カードには、共有先の名前(key)、押したときの動き(action)、開く共有画面の住所(endpoint)などが書いてあります。そして、どの SNS でも同じ手順で動く共有欄の土台のコードを、この連載では共通処理と呼んでいます。

コピーの名札カードは、アイコンの行を除くと次の7行です。1行目はコメント、2〜6行目が項目、最後の [params] が「SNS に渡す値の対応表」です。

TOMLdestinations/copy.toml(コピーの名札カード・icon の行は省略)
# URL のコピー(共有画面なし)。
key = 'copy'
label = 'URLをコピー'
brand_color = '#5F6368'
action = 'copy'
endpoint = ''

[params]

X の名札カードと比べると、違いは2つです。action が open(開く)ではなく copy(コピー)であること。そして、endpoint が空で、[params] にも何も書かれていないことです。コピーには開く共有画面が無いので、住所も、渡す値の対応表もありません。

名札カードを読んで、共有画面の URL を組み立てる部品が ShareDestination です。その中の shareUrl というメソッドを見てみます。

TypeScriptweb/src/domain/model/ShareDestination.ts(36〜47行目)
  /**
   * Return the share dialog URL, or the page URL for copy and native sharing.
   * Queries are encoded with RFC 3986 and empty values are omitted.
   */
  shareUrl(request: ShareRequest): string {
    if (!this.#endpoint) return request.url;
    const pairs = Object.entries(this.#params)
      .map(([name, field]) => [name, FIELD[field](request)] as const)
      .filter(([, value]) => value !== '')
      .map(([name, value]) => `${UriEncoder.encode(name)}=${UriEncoder.encode(value)}`);
    return this.#endpoint + (pairs.length ? `?${pairs.join('&')}` : '');
  }

最初のコメントは「共有画面の URL を返す。コピーと端末の共有では、ページの URL を返す」という意味です。本文の1行目、if (!this.#endpoint) return request.url; がそれを受け持っています。endpoint が空なら、共有画面の URL を組み立てずに、いま読んでいる記事の URL(request.url)をそのまま返します。2行目からあとは、endpoint がある共有先のための手順で、[params] の対応表どおりに値を並べ、endpoint の後ろにつなげます。

図3-1 shareUrl が返す URL の役目
図3-1 shareUrl が返す URL の役目。現在のしくみ。メソッドの名前も返す値の型(string)も同じですが、URL の役目が違います。コピーと端末の共有では、開く先ではありません。図3-1 shareUrl が返す URL の役目。現在のしくみ。メソッドの名前も返す値の型(string)も同じですが、URL の役目が違います。コピーと端末の共有では、開く先ではありません。

つまり、shareUrl が返すものは、共有先によって意味が違います。X では「開けば投稿画面になる URL」です。コピーと端末の共有では「記事そのものの URL」です。メソッドの名前も、返す値の型(string)も同じなのに、返ってくる URL の役目が違うのです。もし、使う側が「shareUrl は、開けば共有できる URL だ」と思い込んでいたら、コピーのボタンで記事そのものを開いてしまいます。

コードの一番上にある、endpointが空なら記事のURLを返す行が、図の分かれ道です。同じstringという型でも、Xでは投稿画面の住所、コピーではコピーする内容になります。値の形だけを見て、全部を「開く先」と呼んでしまうと、後半の意味が落ちます。名前にURLが付いていることも、ブラウザで開く命令を含むわけではありません。

ここでshareUrlを直し、コピーにも無理に開く画面の住所を作る案は、利用者の目的から外れます。コピーは画面を開く必要がないからです。直すべきなのは、全員が同じ画面を開くという、使う側の思い込みです。図を読むときには、矢印の先にある文字列だけでなく、その文字列を何に使うかまで一緒に追ってください。

action を見て、実行方法を選ぶ

このプラグインは、この取り違えを防ぐために、URL だけを見て動きを決めることをしていません。押されたときに何をするかは、名札カードの action を見て決めています。その判断を受け持つのが ShareActionPolicy で、実行するのが HandleShareClickUseCase です。

TypeScriptweb/src/domain/service/ShareActionPolicy.ts(14〜17行目)と web/src/application/HandleShareClickUseCase.ts(38〜60行目の抜き出し)
  /** Choose the click action; the application layer executes it through gateways. */
  static decide({ action, popup }: { action: ShareAction; popup: unknown }): ShareActionDecision {
    return action === ShareAction.Copy ? 'copy' : action === ShareAction.Native ? 'native' : popup ? 'popup' : 'follow';
  }

  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';
        }
      }
      case 'native': {
        preventDefault();
        return this.#gateways.nativeShare.share({ title: button.title, url: button.url }).then(() => 'shared' as const, () => 'share-dismissed' as const);
      }
      case 'popup': {
        if (button.popup && this.#gateways.popup.open(button.href, button.popup)) { preventDefault(); return 'popup'; }
        return 'follow';
      }
      case 'follow':
        return 'follow'; // Let the browser follow the link.

前半の decide は、action を見て、4つの実行方法のどれにするかを決めます。action が copy なら ‘copy’、native なら ‘native’、どちらでもなければ(つまり open なら)、小窓の設定(popup)があれば ‘popup’、無ければ ‘follow’ です。follow は「ブラウザにリンクをそのままたどらせる」という意味です。

後半の #perform は、決まった実行方法ごとに、別々の手順を取ります。’copy’ なら、まず preventDefault() でリンクをたどるブラウザの動きを止め、クリップボードに記事の URL(button.url)を書き込みます。書き込みが終わったら ‘copied’ を、失敗したら代わりの方法(fallback)を出して ‘copy-fallback’ を返します。’native’ なら、端末の共有メニューを開きます。’popup’ なら、共有画面の URL(button.href)を小窓で開きます。ここで大事なのは、共有画面の URL(href)を使うのは ‘popup’ のときだけで、コピーと端末の共有では記事の URL(url)を使っていることです。

図3.1-1 action を見て、実行方法を選ぶ
図3.1-1 action を見て、実行方法を選ぶ。現在のしくみ。href(共有画面の URL)を使うのは、開くときだけです。コピーと端末の共有は、記事の url を使います。図3.1-1 action を見て、実行方法を選ぶ。現在のしくみ。href(共有画面の URL)を使うのは、開くときだけです。コピーと端末の共有は、記事の url を使います。

共有 URL だけを取り出して開かず、action との組み合わせで実行方法を選ぶ。これが、コピーや端末の共有を、開く共有先と同じ部品の並びに置いても壊れない理由です。LSP の目で言えば、このプラグインは「shareUrl を開けば共有できる」という約束を、そもそも立てていません。立てていない約束には、誰も頼らずに済みます。

「終わった」は、書き込みの後に伝える

コピーの約束には、何をするかに加えて、いつ完了と伝えるかも含まれます。次の図は、実物の処理にあるawaitを、時間の順に並べ直したものです。矢印は経過時間の向きを表しますが、長さは測定値ではありません。

図3.2-1 書き込みを待ってから、copiedを返す
図3.2-1 書き込みを待ってから、copiedを返す。4.0.0の処理順と、比較用の説明例。成功の返事は、依頼を始めた後ではなく、書き込みが成功した後です。順番も約束に含めます。図3.2-1 書き込みを待ってから、copiedを返す。4.0.0の処理順と、比較用の説明例。成功の返事は、依頼を始めた後ではなく、書き込みが成功した後です。順番も約束に含めます。

書き込みを頼んだ直後は、まだ途中です。Promiseが成功で完了するのを待ち、そのあとにcopiedという結果を返します。図では、書き込み完了の印より後に、copiedの札を置きました。この順番だからこそ、呼び出した側は成功の結果を見て、次の案内へ進めます。awaitは、単に行数を増やす記号ではなく、その順序を保つ場所です。

下の赤い流れでは、書き込みを始めただけで先にcopiedを返しています。あとから書き込みが拒否されても、使う側には成功したように見えてしまいます。同じPromise<void>を返す代役でも、いつ成功にするかが違えば、約束を守れません。観察窓に結果だけでなく順番も入れる必要があるのは、こういう場面です。

実物のHandleShareClickUseCaseは、writeが失敗するとcatchへ入り、用意されていればfallbackを呼んでcopy-fallbackを返します。この結果は、直接のコピーが成功したという意味ではありません。代わりの案内へ進んだという結果です。テストでもcopiedとcopy-fallbackを同じ成功として丸めず、どの経路を通ったのかを区別しておきます。

順番の図をテストへ落とすなら、書き込みを途中で待たせる代役を考えられます。まだPromiseを完了させていない間に、copiedが返っていないかを調べます。その後に成功で完了させ、そこで初めてcopiedを受け取るかを確認します。時間を何秒待ったかよりも、完了させる前と後という二つの状態を分けるのが要点です。

固定の待ち時間を置くだけでは、速い端末と遅い端末で結果が変わることがあります。説明の図も、矢印の長さへミリ秒を書いていません。確かめたいのは速さではなく前後関係です。何を待っているかを自分で制御できる代役なら、端末の処理速度に頼らずに、その約束を確認しやすくなります。

失敗の順番も同じように追えます。writeが拒否された後にfallbackを呼ぶのであって、成功するか分からないうちから両方を同時に呼ぶわけではありません。両方が並行すると、実際には書き込めているのに代わりの入力欄も開くなど、使う人が迷う結果になりえます。操作の選び方だけでなく、成功・失敗が決まる前後にも契約があります。

利用者が表示を見てから貼り付けるまでには、ほかのアプリがクリップボードを書き換える可能性もあります。この処理が約束するのは、自分が依頼した書き込みが完了した時点のことです。未来の貼り付けまで永久に同じ内容を保つことは約束していません。境目を具体的に書くと、必要なテストと、責任の外にあることが分かれます。

同じ open でも、条件が違う

実行方法の中にも、事前条件があります。小窓で開くのは、URL が mailto: で始まらないときだけです。メールの共有先(email.toml)も action は open ですが、開く先がメールソフトなので、小窓を開いても何も残らないからです。この判断は ShareActionPolicy.canOpenInPopup が受け持ち、共有欄を組み立てるときに、メールの共有先には小窓の設定を付けません。

端末の共有にも事前条件があります。ブラウザが共有の仕組み(Web Share API の navigator.share)を持っていることです。こちらは、共有欄を組み立てるときに DestinationSelectionPolicy が確かめ、仕組みを持たないブラウザでは、端末の共有のボタンをそもそも並べません。押す前に条件を満たせないボタンは、画面に出さない。事前条件を、押したあとではなく、並べる時点で守っているのです。

この節の条件は、どれも同じ種類ではありません。メールを小窓で開かないのは、実行方法を選ぶための方針です。navigator.shareが存在するかは、その環境で使える機能の確認です。さらに、機能が存在しても、呼び出しが成功するとは限りません。図に入口の条件が一つ描いてあるからといって、そこを通れば必ず最後まで成功すると読み替えないようにします。

共有先を同じ一覧へ置くことと、全員に同じブラウザAPIを要求することは、ここでも別です。共有先のデータとactionを受け取った処理が、能力に応じた仕事を選びます。厳密には、今回壊すのは子クラスの継承関係ではありません。「この一覧のどれにも同じ約束を期待できるか」という、置き換えの考え方を、共有先のデータを使う場面へ当てはめて見ています。

COMMENTS
コメント…

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

PASS IT ON

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

この記事を書いた人

Takaomi Murasaki

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

公開記事 11 件

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

FROM INSIGHT TO IMPACT

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

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

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

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

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

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

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

ご相談後の流れ

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