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 だ」と思い込んでいたら、コピーのボタンで記事そのものを開いてしまいます。

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 を開けば共有できる」という約束を、そもそも立てていません。立てていない約束には、誰も頼らずに済みます。

同じ open でも、条件が違う

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

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

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