コード食堂の中休みは、静かだ。
昼の営業が終わると、シェフは短い休憩に入る。私はその時間を使って、ホールのカウンターの端にスツールを引き出し、ノートPCを開いた。今日こそ確認しようと思っていたことがある。
先週——ちょうど一週間前、私はここでラーメンのトッピングのことを教えてもらった。「包んで重ねれば、中身は変えなくていい」というシェフの言葉が、ずっと頭の片隅にある。料理の話だとわかっているのに、どうしてか自分のコードのことが浮かんでしまう。
私の案件——在庫管理ツール——の中に、外部のAPIを呼び出している箇所がある。そのAPIのレスポンスを毎回取り出して使っているのだが、何か所あるのか、今の自分にはよくわからない。
「自分のやっていること」を、自分で把握できていない気がする。
ノートPCを開いたものの、どのファイルを見ればいいかわからないまま手が止まっている——そこへ、引き戸が静かに開いた。
本日の持ち込み素材
午後の早い時間。日差しが傾き始めた頃合い。
入ってきたのは、落ち着いた雰囲気の女性だった。歳は三十代前半くらいだろうか。ビジネスの相談をしに来た人のような、緊張も切迫感もない佇まいで、少し周りを見回してからカウンターに近づいてきた。
「すみません。同業者の方に伺って——コードの設計を見ていただけると聞いて来ました」
ちょうど厨房から出てきたシェフが、手を拭きながら「座れ」と短く言った。それからいつも通り、「何を持ち込んだ」と続ける。
「食材マスタを参照するサービスを3つ持っていまして」と彼女は言いながら、ノートPCをカウンターに置いた。私は自分のPCを脇に避けて場所を作る。「外部のモジュールが仕様変更をして、直し忘れた1か所でエラーが出ました。同じことがまた起きそうで——何かやり方があるのかなと思って」
淡々として、自己批判的でない。起きたことを事実として整理して話す口調だった。
彼女が持ち込んだコードを見ると、OrderService・StockService・InvoiceService という3つのファイルがあった。それぞれが FoodMasterClient という外部モジュールを呼び出して、食材の名前や価格を取得している。
| |
「FoodMasterClient が2.0にバージョンアップしたとき、price_yen というフィールド名が unit_price に変わりました」と彼女は続けた。「OrderService と StockService は修正したんですが、InvoiceService を直し忘れて——翌朝、請求書の生成でエラーが出て気づきました」
3つのファイルに、同じフィールド名が散らばっている。
私にはコードの詳細は読めないけれど、「3か所に同じ言葉がある」という状況は、なんとなく理解できた。そして——今の自分のことが、また頭に浮かんだ。あの在庫ツールの中にも、外部のAPIを呼び出している箇所がある。あそこも、同じことになっていないだろうか。
でも今は、彼女の話に集中しよう。シェフが動き始めた。
変換が散在している
シェフはしばらくコードを眺めてから、ターミナルを開いて一行打ち込んだ。
| |
画面に3行、表示された。OrderService.pm・StockService.pm・InvoiceService.pm——それぞれに price_yen という文字列が並んでいる。
「3か所、FoodMasterClient の言葉が入ってる」とシェフは言った。「この3つのファイル、全部、外の規格を知ってる」
「外の規格……」
私は小声で繰り返した。「外の言葉が中に入ってる」という言い方に、何か引っかかった。
シェフが厨房から、輸入スパイスの袋を一つ取り出してきた。赤みがかった粉が入った小袋で、ラベルに saffron 0.5oz と書いてある。カウンターに置いて、彼女に向けた。
「これ、使うとき、今どうしてる?」
彼女が少し考えてから「毎回、グラムに換算して使います」と答えた。スープを作るとき、ソースを作るとき、デザートを作るとき——シェフが指を折りながら確認していくと、彼女は「……はい、全員が毎回」と続けた。袋を手に取って、oz の数字を眺めている。
「換算を間違えたら、全部の料理で違う量になる」とシェフは言って、コードに目を戻した。「price_yen を読んで変数に入れる——これが換算処理だ。3か所でやっている。FoodMasterClient が unit_price に変えたとき、3か所の換算を全部直さなければならない。1か所でも忘れると、今回みたいなことになる」
これが今回の問題の名前だ。
scattered-adaptation(変換の散在): 外部モジュールの内部仕様を各サービスが直接参照・変換するコードが複数箇所に散在する状態。外部仕様が変わるたびに全箇所の修正が必要になる。
「最初は OrderService だけでした」と彼女は続けた。「StockService を作るとき、同じように書けばいいかと思って。InvoiceService も同様に——外のモジュールが仕様を変えるとは思っていなかったので」
後悔ではなく、経緯の説明だった。「なぜそうなったか」をちゃんと把握している人の話し方だった。
私は黙って聞いていた。片付けに手をつけながら、半分くらい頭が別のことを考えていた。先月書いた在庫ツール——外部APIのレスポンスから値を取り出している部分が、何か所あったかな。確認していなかった。
変換役を一か所に立てる
「変換を担当する仲立ちを一つ作る」
シェフがそう言って、引き出しから細長いスプーンを取り出した。よく見ると、両側に目盛りが刻んである。片側に oz、反対側に g と書いてある。
「これを使えばいい。0.5ozを計ったら、こっちの目盛りを読む。14gと出る。スープもソースもデザートも——このスプーン1本で換算する。料理人は oz を知らなくていい」
彼女がそのスプーンを手に取って眺めた。
「コードも同じだ」とシェフは言った。「変換をここ1か所に任せれば、他は price_yen を知らなくていい」
Adapter パターン——インターフェースの規格が合わない2つのクラスを「仲立ちクラス1枚」で繋ぐ技法。GoFが定義した構造パターンのひとつで、別名 Wrapper(ラッパー)とも呼ばれる。
構造はこうなる。
classDiagram
class FoodItemRepository {
<<Role>>
+find(item_code)
}
class FoodMasterAdapter {
-_client: FoodMasterClient
+find(item_code) FoodItem
}
class FoodItem {
+name: str
+price: int
+tax_rate: float
}
class FoodMasterClient {
+get_item(item_code) hash
}
class OrderService {
-repository: FoodItemRepository
+calculate_price(item_code)
}
FoodItemRepository <|.. FoodMasterAdapter : with
FoodMasterAdapter --> FoodMasterClient : has _client
FoodMasterAdapter ..> FoodItem : creates
OrderService --> FoodItemRepository : has repository
まず、アプリケーション側が期待するインターフェースを Moo::Role で定義する。これが Target(ターゲット)——「こういう規格で話しかけてほしい」という宣言だ。
| |
次に、find が返す値オブジェクト。ハッシュリファレンスではなく、->name・->price・->tax_rate というメソッドで値を取り出せるオブジェクトにする。
| |
そして、変換の仲立ちクラス。これが Adapter——FoodMasterClient(Adaptee: 既存の、インターフェースが合わないクラス)をラップし、FoodItemRepository Role を実装する。
| |
最後に、3つのサービスクラスを書き直す。use FoodMasterClient が消え、代わりに FoodItemRepository Role を受け取るだけになる。
| |
StockService も InvoiceService も同じ構造になる——has repository で受け取って、->find を呼んで、FoodItem のメソッドを使う。
シェフがコードを示しながら言った。「OrderService は FoodMasterClient を知らない。StockService も、InvoiceService も。3つ全部、FoodMasterClient のフィールド名を知らない」
彼女がコードを読みながら、しばらく黙っていた。画面を見つめて、何かを考えている。それからゆっくり顔を上げて、シェフに聞いた。
「でも——この FoodMasterAdapter 自体は、unit_price というフィールド名を知っていますよね。外のモジュールが次にまた変わったら、このアダプターを直さなければならない。修正が1か所に移っただけで、修正が必要なことには変わらないんじゃないですか?」
シェフが小さく息を吐いた。「いい問いだ」
私もカウンターの端からそのやりとりを聞いていた。変換スプーンに置き換えると、どうなるだろう。換算スプーンを1本作っても、oz の目盛り自体が変わったら——スプーンを作り直さなければならない。それはそうだ。でも——
「つまり……外の目盛りを知っているのは、変換スプーン1本だけ、ということですか?」
思わず口に出してしまった。合っているかどうか自信がなかった。
シェフが一瞬こちらを見て「そんなとこだ」と短く返した。それから彼女に向き直って続けた。
「そうだ。Adapter も変更する必要がある。だが——今は unit_price という名前が何か所にある? 3か所だ。1か所でも忘れると、今回みたいなことになった。Adapter を使えば——unit_price を知っているのは FoodMasterAdapter::find の1か所だけだ」
「外の仕様が変わったとき、修正がゼロになるわけじゃない。変わったのは——直す場所が3か所から1か所になった。直し忘れる場所が、構造的に存在しなくなった」
「それだけじゃない。FoodMasterClient が別のモジュールに丸ごと替わっても、OrderService は変えなくていい。変えるのは Adapter だけだ。外の変化を、Adapter が全部受け取る」
彼女が「なるほど」と静かに言った。問題の構造が腑に落ちた人の顔だった——ように見えた。
それから少し考えてから、自分で確認するように続けた。「じゃあ——テストするときも、FoodMasterClient の代わりにモックを返す Adapter を差し込めば……OrderService を、外部モジュールなしで単体テストできる?」
「そうだ」とシェフが返した。
一言だけ付け加えた。「前にここに来た人は、同じインターフェースのまま機能を足した。今回は違う言葉を同じ言葉に変えた。包む構造は似ているが、やっていることは別だ」
私は先ほどの「変換スプーン」の感触を思い出した。Decorator は「元の皿の上にトッピングを重ねる」。Adapter は「oz の目盛りを g の目盛りに読み替える」。どちらも包んでいる。でも、前者は積み上げで、後者は言い換えだ——そう言葉にしたとき、なんとなく腑に落ちた気がした。
試食合格
コードを書き直して、テストを走らせた。
3つのサービスが正しく動く。FoodMasterAdapter が FoodItemRepository Role を実装していることも確認できた。find が正しい FoodItem オブジェクトを返している。
| |
「unit_price が次にまた変わったとしても、直すのは FoodMasterAdapter::find の1行だけですね」と彼女が自分で確認した。
「そうだ」とシェフが返した。それから少し間を置いてから付け加えた。
「外の規格が変わっても、厨房のレシピは変えなくていい。変換役が1か所にいれば」
彼女が「変換役を1か所に、ですか」と繰り返した。少し考えてから「わかりました。持ち帰ります」と言って立ち上がった。感謝の言葉は短く、もうすでに頭の中でコードを整理している様子だった。
引き戸が閉まる音がした。
片付けをしながら、その言葉を反芻した。
変換役を1か所に——そういうことか。
閉店後の静かな食堂で、私はもう一度ノートPCを開いた。在庫管理ツールのコード。外部APIのレスポンスから値を取り出している部分を、grep で探してみた。
| |
3行、表示された。
今日は直さない。直し方もまだわからない。でも今日、確認した。確認しようとして、確認した。それが、今日のことだった。
シェフの仕込み工程表
| 問題(調理ミス) | 技法(パターン) | 効果(仕上がり) |
|---|---|---|
外部モジュールの内部フィールド名(price_yen)が3サービスに散在 | Adapter(仲立ちクラス一枚) | 変換コードが FoodMasterAdapter::find の1か所に集約 |
| 外部仕様変更のたびに全箇所の修正が必要 | Target Role(FoodItemRepository)で境界を定義 | 3サービスは外部モジュールの仕様を知らない |
| 外部モジュールがないと単体テストできない | リポジトリをコンストラクタ注入 | モックアダプターで外部依存なしにテスト可能 |
工程
Step 1: Target Role を定義する
アプリが期待するインターフェースを Moo::Role で定義する。requires 'find' で「このロールを with するクラスは必ず find を実装すること」を強制する。
| |
Step 2: Value Object を作成する
find が返す値を、ハッシュリファレンスではなくオブジェクトとして定義する。呼び出し側は ->price のようにメソッドで値を取得し、ハッシュのキー名を知らなくてよくなる。
| |
Step 3: Adapter クラスを作成する
外部モジュール(Adaptee)を has で保持し、with 'FoodItemRepository' で Target Role を実装する。find の中で Adaptee のインターフェース変換を行う——これが Adapter の唯一の責務だ。
| |
Step 4: サービスクラスを書き直す
use FoodMasterClient を削除し、has repository => (is => 'ro', required => 1) で FoodItemRepository を受け取る形に変える。サービスは find を呼んで FoodItem のメソッドを使うだけになる。
Step 5: アプリケーション起動時に組み立てる
どの Adapter を使うかを決めるのは、アプリケーションの初期化箇所だ。
| |
テスト時はここを差し替える——FoodMasterClient を使わないモッククラスを渡せばよい。
シェフより
外のモジュールが変わるたびに全部直す——それは「外の規格を全員が覚えている状態」だ。換算スプーンを使わずに、全員が頭の中でオンス計算をしている厨房と同じだ。
Adapter の仕事は、その換算を一人で引き受けることだ。外のモジュールの言葉を知っているのは Adapter だけ、それ以外は FoodItem の言葉しか知らない——そういう役割分担にする。
一つ正直に言っておく。外の仕様が変わったとき、修正がゼロになるわけではない。Adapter を直す必要はある。ただし、1か所だ。3か所が1か所になった——この「位置の変化」が、修正漏れを構造的に起きにくくする。直し忘れる場所が、存在しなくなる。
