PROMARI JOURNAL

OCP――共有先を一つ足したときの差分を追う

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

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

丸い共有ボタンの裏側を掘る連載の第5回。SOLID の2つ目の原則 OCP を、本物の SNS(Bluesky)の追加を通じて確かめます。名札カード1枚で済む変化と、共通処理のコードを変更することになる変化。差分を1行ずつ追いかけ、閉じる向きは変化の来やすさで選ぶことを、図22点で読み解きます。

PASS IT ON

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

足せたのに、記事のURLが入っていない

前のページでは、Bluesky の名札カードを1枚足すだけで、Bluesky のボタンが画面に出るようになりました。OCPの言う「足すだけで済む」形です。このページでは、そのボタンを実際に押すと何が起きるかを確かめます。

その前に、1ページ目で見た仕組みを短く思い出しておきます。共有ボタンが押されると、プラグインはまず、いま開いているページのタイトルと URL を1つにまとめます。次に、名札カードの [params] に書かれた対応表を見ながら、それらの値を共有画面の URL に並べます。[params] の左側は SNS が受け取る欄の名前、右側はプラグインが用意した値の名前でした。この「どの SNS でも同じ手順で動く部分」を、この記事では共通処理と呼んでいます。

Bluesky の名札カードに書いた対応表は、text = ‘text’ の1行だけです。「Bluesky の text という欄に、共有文を入れる」という意味です。共有文は、ふつうはページのタイトルです。この名札カードで、実際にボタンが作った URL がこちらです。

TextBluesky のボタンが作った URL(段階1・途中を省略)
https://bsky.app/intent/compose?text=OCP%E2%80%95…%E8%BF%BD%E3%81%86

? から後ろが、Bluesky に渡す値です。text= のあとには %E2%80… のような記号の並びが続いています。これは日本語のタイトルを、URL の中でも使える形に置き換えたものです。そして、それで URL は終わっています。記事の URL(https://promari.jp/…)は、どこにも入っていません。

図3-1 足せたのに、記事のURLが入っていない
図3-1 足せたのに、記事のURLが入っていない。実測(追加後のURL)。X には url と text の2つが渡りますが、Bluesky が受け取るのは text の1つだけ。タイトルだけが渡り、記事のURLが抜けていました。図3-1 足せたのに、記事のURLが入っていない。実測(追加後のURL)。X には url と text の2つが渡りますが、Bluesky が受け取るのは text の1つだけ。タイトルだけが渡り、記事のURLが抜けていました。

つまり、このボタンで Bluesky に共有すると、投稿欄にはタイトルだけが入り、記事へのリンクが入りません。読んだ人がその投稿から記事へ飛べないので、共有ボタンとしては大事なところが抜けています。

X では、この問題は起きません。X の名札カードには url = ‘url’ と text = ‘text’ の2行があり、記事の URL とタイトルを別々の欄で渡せるからです。ところが Bluesky の投稿画面が受け取る欄は、text の1つだけです。Bluesky の公式ドキュメントも、リンクを載せたいときは URL を text の中に含めるよう書いています。そのため Bluesky には、「タイトルのあとに記事の URL をつなげた1つの文字列」を text 欄へ渡す必要があります。

名札カードが選べる値は、6種類の注文票

では、名札カードの右側に「タイトル+記事の URL」と書けば解決するのでしょうか。実は、それはできません。名札カードの右側に書けるのは、プラグインがあらかじめ用意した値の名前だけだからです。その一覧は、コードの中にこう書かれています。

TypeScriptweb/src/domain/model/ShareDestinationSpec.ts(抜き出し)
/** Share request fields available to URL templates. */
export type ShareRequestField = 'url' | 'title' | 'text' | 'via' | 'site' | 'hashtagsCsv';
図3.1-1 渡せる値は、6種類の注文票
図3.1-1 渡せる値は、6種類の注文票。現在のしくみ。名札カードが選べるのは、注文票に並んだ6種類だけです。欲しい値が無いときは、注文票そのものを直す必要があります。図3.1-1 渡せる値は、6種類の注文票。現在のしくみ。名札カードが選べるのは、注文票に並んだ6種類だけです。欲しい値が無いときは、注文票そのものを直す必要があります。

一覧にあるのは、url(記事の URL)・title(タイトル)・text(共有文)・via(X で「@だれそれ から」と表示する投稿者名)・site(サイト名)・hashtagsCsv(ハッシュタグをカンマでつないだもの)の6つです。お店の注文票に6品が並んでいるように、名札カードの右側は、この6つの中から1つを選ぶことしかできません。「タイトル+記事の URL」という7品目は、注文票に載っていないのです。名札カードは、共通処理が用意した6種類の値から選ぶだけで、自分で値を作れない。一覧に無い名前を書くと、名札カードを読むときに生成器がエラーにして止めます。

「それなら、名札カードに記事の URL をそのまま書けばよいのでは」と思うかもしれません。これもできません。名札カードは Bluesky について1枚だけ書くファイルで、サイトのどの記事にも同じ1枚が使われます。ところが記事の URL は、記事ごとに違います。いま読まれている記事の URL を知っているのは、ボタンが押された瞬間にページの情報をまとめる共通処理の部品(ShareRequest)だけなのです。

名札カードが悪いわけではありません。名札カードは、「用意された6つの値を選んで並べるだけで表せる共有先」を足すための仕組みです。X も LINE も Facebook も、それで足りました。Bluesky は、その想定から外れた共有先だったのです。欲しい値が注文票に無いのなら、注文票そのもの、つまり共通処理の側に7品目を足すしかありません。

注文票に1品足すと、共通処理のコードを変更することになる

そこで、注文票に7品目を足すことにしました。名前は textWithUrl です。「URL つきの共有文」という意味で、共有文のあとに空白を1つはさんで記事の URL をつなげた文字列を表します。これができれば、Bluesky の名札カードは、右側を text から textWithUrl に書き換えるだけで済みます。

ただし、新しい値の名前を1つ足すと、その名前が通る道すじのすべてに、「textWithUrl とは何か」を教えなければなりません。名札カードに書かれた textWithUrl は、次の順番で処理されます。

(1)作り直すとき:生成器が名札カードを読み、右側の名前が「書いてよい名前の一覧」にあるかを確かめます。

(2)ボタンが押されたとき:URL を組み立てる部品(ShareDestination)が対応表を引き、「textWithUrl の値はどこから取り出すか」を調べます。

(3)取り出すとき:ページの情報をまとめる部品(ShareRequest)が、タイトルと URL をつないだ文字列を作って返します。

(4)動かす前:これらのコードが書き間違えていないかを、型チェックが「値の名前の一覧」と照らし合わせて確かめます。

この道すじに合わせて、6つのファイルを変えました。図3.2-1 が全体像です。上の3つが共通処理(ドメイン層)のファイル、下の3つが生成器・説明書・テストです。

図3.2-1 注文票に1品足したときの差分(段階2)
図3.2-1 注文票に1品足したときの差分(段階2)。実測(検証結果)。今度は閉じていた共通処理(ドメイン層)のコードを3ファイル変更しました。変えたのは、足すための注文票そのものです。図3.2-1 注文票に1品足したときの差分(段階2)。実測(検証結果)。今度は閉じていた共通処理(ドメイン層)のコードを3ファイル変更しました。変えたのは、足すための注文票そのものです。

ここからは、変えた行を実際のコードで見ていきます。載せているのは git diff という「変更前と変更後の違い」を表示するコマンドの結果で、ファイルの場所を示す行を短くし、変わっていない行の一部を「…」で省いています。行の頭に + がある行が足した行、- がある行が消した行です。何も付いていない行は、場所の目印として載せている変わっていない行です。

Diff段階2の git diff ①〜③(共通処理の3ファイル)。+ が足した行、- が消した行
--- a/web/src/domain/model/ShareDestinationSpec.ts
+++ b/web/src/domain/model/ShareDestinationSpec.ts
@@ ① 値の名前の一覧 @@
 /** Share request fields available to URL templates. */
-export type ShareRequestField = 'url' | 'title' | 'text' | 'via' | 'site' | 'hashtagsCsv';
+export type ShareRequestField = 'url' | 'title' | 'text' | 'via' | 'site' | 'hashtagsCsv' | 'textWithUrl';

--- a/web/src/domain/model/ShareRequest.ts
+++ b/web/src/domain/model/ShareRequest.ts
@@ ② ページの情報をまとめる ShareRequest @@
   get hashtagsCsv(): string {
     return this.hashtags.join(',');
   }
+
+  /** Shared text followed by the URL, for destinations that accept only one text field. */
+  get textWithUrl(): string {
+    return [this.text, this.url].filter(Boolean).join(' ');
+  }

--- a/web/src/domain/model/ShareDestination.ts
+++ b/web/src/domain/model/ShareDestination.ts
@@ ③ 値の名前と取り出し方の対応表 @@
 const FIELD: Readonly<Record<ShareRequestField, (r: ShareRequest) => string>> = {
   url: (r) => r.url,
   …
   hashtagsCsv: (r) => r.hashtagsCsv,
+  textWithUrl: (r) => r.textWithUrl,
 };

① ShareDestinationSpec.ts(値の名前の一覧):注文票そのものです。- の行が変更前、+ の行が変更後で、行の最後に | ‘textWithUrl’ が付いたことだけが違います。TypeScript では、この一覧に無い名前をほかの場所で使うと、動かす前の型チェックでエラーになります。先にここへ足しておくことで、②と③で textWithUrl という名前を使えるようになります。

② ShareRequest.ts(ページの情報をまとめる部品):get textWithUrl() という取り出し口を足しました。中の1行は3つの動きでできています。[this.text, this.url] で共有文と URL を並べ、.filter(Boolean) で空のもの(共有文が無い場合など)を取り除き、.join(‘ ‘) で間に空白を1つ入れてつなぎます。たとえば共有文が「OCP入門」、URL が https://promari.jp/blog/… なら、「OCP入門 https://promari.jp/blog/…」という1つの文字列になります。

③ ShareDestination.ts(値の名前と取り出し方の対応表):URL を組み立てる部品は、FIELD という対応表で「名前が url なら r.url を、名前が text なら r.text を取り出す」と決めています。r は、②の ShareRequest のことです。ここに「名前が textWithUrl なら r.textWithUrl を取り出す」という1行を足しました。これで、名札カードの textWithUrl が②の取り出し口につながります。

①〜③はどれも、X・LINE・Facebook を足したときには一度も書き換えなかった共通処理のファイルです。

次に、残りの3つのファイルです。

Diff段階2の git diff ④〜⑥(生成器・説明書・テスト)。+ が足した行、- が消した行
--- a/tools/config.py
+++ b/tools/config.py
@@ ④ 生成器が受け付ける値の名前 @@
-REQUEST_FIELDS: Final = frozenset({"url", "title", "text", "via", "site", "hashtagsCsv"})
+REQUEST_FIELDS: Final = frozenset({"url", "title", "text", "via", "site", "hashtagsCsv", "textWithUrl"})

--- a/docs/customization.md
+++ b/docs/customization.md
@@ ⑤ 説明書の「[params] に書ける値」 @@
 - `[params]` maps query parameter names to request fields (`url`, `title`, `text`,
-  `via`, `site`, `hashtagsCsv`). An empty `endpoint` with no `[params]` means the
+  `via`, `site`, `hashtagsCsv`, `textWithUrl`). An empty `endpoint` with no `[params]` means the

--- a/web/test/domain.test.ts
+++ b/web/test/domain.test.ts
@@ ⑥ 新しい値のテスト @@
+  it('textWithUrl は共有文と URL を空白でつないで渡す', () => {
+    const bluesky = new ShareDestination({ key: 'bluesky', action: ShareAction.Open, endpoint: 'https://bsky.app/intent/compose', params: { text: 'textWithUrl' } });
+    const request = ShareRequest.create({ url: 'https://a.jp/', title: 'T', text: 'a b' });
+    assert.equal(bluesky.shareUrl(request), 'https://bsky.app/intent/compose?text=a%20b%20https%3A%2F%2Fa.jp%2F');
+  });

④ tools/config.py(生成器が受け付ける名前):名札カードを読む生成器も、「書いてよい名前の一覧」(REQUEST_FIELDS)を別に持っています。一覧に無い名前を見つけると、その場でエラーにして止まります。ここに textWithUrl を足さないと、①〜③を直しても、名札カードを読ませた時点で止められてしまいます。

⑤ docs/customization.md(説明書):説明書の「[params] に書ける値」の一覧に、textWithUrl を1語足しました。次に共有先を足す人が、この値を使えると分かるようにするためです。

⑥ web/test/domain.test.ts(テスト):textWithUrl がきちんと働くかを確かめるテストを1件足しました。text 欄に textWithUrl を使う Bluesky 用の共有先を作り、共有文「a b」と URL「https://a.jp/」を渡すと、https://bsky.app/intent/compose?text=a%20b%20https%3A%2F%2Fa.jp%2F という URL ができるかを見ています。%20 は空白、%3A%2F%2F は「://」を、URL の中で使える形に置き換えたものです。

6つのファイルを変えたあと、型チェックとテストはすべて通りました。X や LINE のテストは、1件も書き換えていません。Bluesky のボタンが作る URL は、次のように変わりました。

Text検証用の ShareDestination が作った Bluesky の URL(途中を省略)
段階1(名札カードに text = 'text' と書いたとき)
https://bsky.app/intent/compose?text=OCP%E2%80%95…%E8%BF%BD%E3%81%86

段階2(text = 'textWithUrl' に変えたとき)
https://bsky.app/intent/compose?text=OCP%E2%80%95…%E8%BF%BD%E3%81%86%20https%3A%2F%2Fpromari.jp%2Fblog%2Fshare-serial-2-2-ocp%2F

段階1の URL は、タイトルの記号の並びで終わっていました。段階2の URL では、そのあとに %20(空白)と、https%3A%2F%2Fpromari.jp で始まる記事の URL が続いています。これで、Bluesky の投稿欄にはタイトルと記事へのリンクが両方入るようになりました。

ただし、このページの変更は、前のページとは性質が違います。前のページでは名札カードを1枚足すだけで、共通処理は1行も変えませんでした。今回は、閉じていたはずの共通処理を3ファイル変えています。変えたのは Bluesky 専用の処理ではなく、どの共有先でも使える「注文票」でした。この違いが何を意味するのかを、次のページで整理します。

考えてみる:textWithUrl を足すと、X や LINE のボタンにも影響するの?

しません。X や LINE の名札カードは textWithUrl を使っていないので、①〜③を変えても、X や LINE のボタンが作る URL は1文字も変わりません。足したのは「新しい品目」であって、今ある品目の作り方には手を付けていないからです。既存のテストを1件も書き換えずに全部通ったことが、その証拠です。

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