OCP――共有先を一つ足したときの差分を追う
作成: / 公開: / 内容更新:
執筆:tamito0201 / 掲載・運営:プロマリ
丸い共有ボタンの裏側を掘る連載の第5回。SOLID の2つ目の原則 OCP を、本物の SNS(Bluesky)の追加を通じて確かめます。名札カード1枚で済む変化と、共通処理のコードを変更することになる変化。差分を1行ずつ追いかけ、閉じる向きは変化の来やすさで選ぶことを、図22点で読み解きます。
足せたのに、記事のURLが入っていない
前のページでは、Bluesky の名札カードを1枚足すだけで、Bluesky のボタンが画面に出るようになりました。OCPの言う「足すだけで済む」形です。このページでは、そのボタンを実際に押すと何が起きるかを確かめます。
その前に、1ページ目で見た仕組みを短く思い出しておきます。共有ボタンが押されると、プラグインはまず、いま開いているページのタイトルと URL を1つにまとめます。次に、名札カードの [params] に書かれた対応表を見ながら、それらの値を共有画面の URL に並べます。[params] の左側は SNS が受け取る欄の名前、右側はプラグインが用意した値の名前でした。この「どの SNS でも同じ手順で動く部分」を、この記事では共通処理と呼んでいます。
Bluesky の名札カードに書いた対応表は、text = ‘text’ の1行だけです。「Bluesky の text という欄に、共有文を入れる」という意味です。共有文は、ふつうはページのタイトルです。この名札カードで、実際にボタンが作った URL がこちらです。
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/…)は、どこにも入っていません。
つまり、このボタンで Bluesky に共有すると、投稿欄にはタイトルだけが入り、記事へのリンクが入りません。読んだ人がその投稿から記事へ飛べないので、共有ボタンとしては大事なところが抜けています。
X では、この問題は起きません。X の名札カードには url = ‘url’ と text = ‘text’ の2行があり、記事の URL とタイトルを別々の欄で渡せるからです。ところが Bluesky の投稿画面が受け取る欄は、text の1つだけです。Bluesky の公式ドキュメントも、リンクを載せたいときは URL を text の中に含めるよう書いています。そのため Bluesky には、「タイトルのあとに記事の URL をつなげた1つの文字列」を text 欄へ渡す必要があります。
読める・作れる・役に立つを、三つのランプで分ける
名札カードを受け付け、URLも作れ、テストも通った。それでも共有する記事へのリンクが抜けていました。このずれを一つの「成功」にまとめないために、次の図では確認の窓を三つに分けています。
最初のランプは、名札カードを決まった形式で読めたかです。二つ目は、共有画面のURLという形を作れたかです。三つ目は、その値が今回の利用目的を満たすかです。段階1では、前の二つを通っても、「投稿から記事へ戻れる」という目的に必要なURLが欠けていました。手前のランプが緑でも、奥のランプまでは自動で緑になりません。
この図の三つ目は、投稿が完了したという意味でもありません。こちらが作る共有用の値に記事のURLを含められたか、という範囲です。実際の投稿画面での表示、ログイン、投稿の確定は外部サービスと利用者の操作に関わります。どこまでを自分のコードで確かめたかを限定すれば、ボタンを作れたことを投稿成功へ取り違えずに済みます。
では、テストには何を書けばよいでしょうか。「BlueskyのURLが文字列として返った」だけでなく、「textの中に共有文と記事URLが両方ある」を答えとして書けます。さらに、共有文が空のときに余計な空白を付けないこと、日本語や空白を含むときにも値を壊さないことなど、入力の違いに沿って確認を増やせます。合格する経路だけでなく、欲しい中身を具体的に書くのです。
追加前に、読者の言葉で「押すと、投稿欄にこの記事のタイトルとリンクが入る」と一文を書いておくのも役立ちます。その一文と実際に作った値を比べれば、型や生成が通ったあとに、まだ足りないものを見つけられます。原則の検証と、機能の完成はつながっていますが、同じ確認ではありません。両方を確かめて初めて、追加した意味が説明できます。
この失敗は、設定ファイル方式そのものが悪いという結論にもなりません。既存の口で表せることと、今回必要なことの差が見えたので、次に広げる場所を具体的に選べます。広げる前の小さな試行が、設計の境目を照らしたと読むと、失敗した出力も役に立つ記録になります。
名札カードが選べる値は、6種類の注文票
では、名札カードの右側に「タイトル+記事の URL」と書けば解決するのでしょうか。実は、それはできません。名札カードの右側に書けるのは、プラグインがあらかじめ用意した値の名前だけだからです。その一覧は、コードの中にこう書かれています。
/** Share request fields available to URL templates. */
export type ShareRequestField = 'url' | 'title' | 'text' | 'via' | 'site' | 'hashtagsCsv';一覧にあるのは、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品目を足すしかありません。
六つの品目の図を見たら、「新しいSNSが来た」から直ちに「七つ目を足す」と進まず、まず既存の値で要件を満たせるかを調べます。URLだけを送ればよいのか、共有文とURLを両方送る必要があるのか。その違いで、必要な変更は変わります。要求を短い例にしてから、注文票へ当てる順です。
今回の段階2では、共有文とURLを一つにした値を追加しました。それをBluesky専用の分岐へ置くと、同じ値が欲しい共有先が次に来たとき、似た処理をまた書くことになります。反対に、任意の式を設定へ書ける巨大な仕組みに広げると、検証する対象が急に増えます。この二つの間で、実際に必要になった一つの値を、共通に使える形で足しています。
新しい名前を付けるときは、その値の意味も決めます。「共有文とURLを空白でつなぐ」のか、「タイトルとURL」なのか、「本文を短く切ってURLを足す」のか。似ていても別の要求です。名前だけを先に足すと、生成器とブラウザ側が同じ言葉を別の意味で扱う危険があります。短い入力と期待する出力を先に並べると、約束を揃えやすくなります。
注文票に1品足すと、共通処理のコードを変更することになる
そこで、注文票に7品目を足すことにしました。名前は textWithUrl です。「URL つきの共有文」という意味で、共有文のあとに空白を1つはさんで記事の URL をつなげた文字列を表します。これができれば、Bluesky の名札カードは、右側を text から textWithUrl に書き換えるだけで済みます。
ただし、新しい値の名前を1つ足すと、その名前が通る道すじのすべてに、「textWithUrl とは何か」を教えなければなりません。名札カードに書かれた textWithUrl は、次の順番で処理されます。
(1)作り直すとき:生成器が名札カードを読み、右側の名前が「書いてよい名前の一覧」にあるかを確かめます。
(2)ボタンが押されたとき:URL を組み立てる部品(ShareDestination)が対応表を引き、「textWithUrl の値はどこから取り出すか」を調べます。
(3)取り出すとき:ページの情報をまとめる部品(ShareRequest)が、タイトルと URL をつないだ文字列を作って返します。
(4)動かす前:これらのコードが書き間違えていないかを、型チェックが「値の名前の一覧」と照らし合わせて確かめます。
この道すじに合わせて、6つのファイルを変えました。図3.3-1 が全体像です。上の3つが共通処理(ドメイン層)のファイル、下の3つが生成器・説明書・テストです。
ここからは、変えた行を実際のコードで見ていきます。載せているのは git diff という「変更前と変更後の違い」を表示するコマンドの結果で、ファイルの場所を示す行を短くし、変わっていない行の一部を「…」で省いています。行の頭に + がある行が足した行、- がある行が消した行です。何も付いていない行は、場所の目印として載せている変わっていない行です。
--- 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 が②の取り出し口につながります。
三つのファイルを読むときは、「名前を認める」「値を作る」「名前から値へつなぐ」の順に置くと迷いにくくなります。型の一覧へ名前を足しただけでは値は生まれません。取り出し口を作っただけでは、名札カードからそこへたどり着けません。図で同じtextWithUrlの札を追うと、一つの意味を複数の場所へ伝えていることが見えてきます。
型の対応表をRecordで書いていることも、ここで役立ちます。値の名前を増やしたのに、その取り出し方が無ければ、型検査で不一致を見つける手がかりになります。ただし、取り出すコードの意味まで自動で正しくなるわけではありません。間違えてr.titleを返していても、文字列という型だけは合うため、結果のテストが必要です。
新しい値を作る一行は短いですが、空の値を外すことと、残った値を空白でつなぐことを分けて読むと、試す入力が見つかります。共有文だけが空ならURLだけが残り、両方あれば間に一つ空白が入ります。このように短いコードでも、入力と出力の対応を図や例で確かめると、追加した意味を説明しやすくなります。
①〜③はどれも、X・LINE・Facebook を足したときには一度も書き換えなかった共通処理のファイルです。
次に、残りの3つのファイルです。
--- 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 の中で使える形に置き換えたものです。
残りの三つは、共通処理の外にあるから後回しにしてよい、という部品ではありません。生成器の一覧が古ければ、ブラウザ側に新しい値があってもカードを受け付けられません。説明書が古ければ、追加する人は使える値を見つけられません。テストが無ければ、名前は通っても値の意味が間違っていることを見逃します。別々の場所が、同じ一つの約束を支えています。
図の矢印を逆にたどってみると、確認漏れを探せます。出力のURLから「この文字列を作ったのはどこか」へ戻り、さらに「どの名前で選ばれたか」「その名前を誰が許したか」へ戻ります。一番下の値だけを直しても、上流がその名前を受け付けなければ届きません。新しい値が通る道すじを、一往復して読んでみるわけです。
説明書への追記も、単に単語を一覧へ足して終わりにしないほうが親切です。値の意味、空の共有文の扱い、どんな共有先で使うかが分かれば、次の追加を既存の口で済ませられるか判断できます。仕組みを広げた人だけが使い方を知っている状態では、差し込み口はあるのに使われないままになります。
6つのファイルを変えたあと、型チェックとテストはすべて通りました。X や LINE のテストは、1件も書き換えていません。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件も書き換えずに全部通ったことが、その証拠です。
この答えの「影響しない」は、既存の値を作る手順を変えず、新しい名前を使うカードだけが新しい値を選ぶ、という設計上の範囲です。ビルドの失敗や、対応表への書き間違いまで起こらないと保証したものではありません。既存の期待値を保ったテストは、その範囲を実際に確かめた証拠として読みます。
また、テストが通った範囲の外には、外部サービス側の仕様変更や、実ブラウザでの表示が残ります。値の生成を確かめたテストと、投稿画面を開く確認を分けて記録すれば、「何が今までどおりだったか」が明確になります。追加のたびにすべてを目視でやり直す必要はありませんが、何を自動化し、何を別に確認するかは決めておきます。
ここまでの差分を短く言うと、段階1では用意済みの材料を選び、段階2では材料の種類を増やしました。どちらもボタンが一つ増える話ですが、設計の側からは違う変更です。この区別を覚えておくと、後半で「どんな追加なら閉じているか」を具体的に言えるようになります。











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