Featured image of post コードシェフの仕込み帳【Adapter】変換役を一か所に〜外部仕様への依存が散在するコードを、仲立ちクラス一枚で整理する〜

コードシェフの仕込み帳【Adapter】変換役を一か所に〜外部仕様への依存が散在するコードを、仲立ちクラス一枚で整理する〜

外部モジュールの内部仕様への依存が3サービスに散在し、仕様変更のたびに修正漏れが起きるコードを、Adapterパターンで整理します。PerlとMooで「変換役を一か所」に集める設計へ。仕組みから丁寧に解説します。

コード食堂の中休みは、静かだ。

昼の営業が終わると、シェフは短い休憩に入る。私はその時間を使って、ホールのカウンターの端にスツールを引き出し、ノートPCを開いた。今日こそ確認しようと思っていたことがある。

先週——ちょうど一週間前、私はここでラーメンのトッピングのことを教えてもらった。「包んで重ねれば、中身は変えなくていい」というシェフの言葉が、ずっと頭の片隅にある。料理の話だとわかっているのに、どうしてか自分のコードのことが浮かんでしまう。

私の案件——在庫管理ツール——の中に、外部のAPIを呼び出している箇所がある。そのAPIのレスポンスを毎回取り出して使っているのだが、何か所あるのか、今の自分にはよくわからない。

「自分のやっていること」を、自分で把握できていない気がする。

ノートPCを開いたものの、どのファイルを見ればいいかわからないまま手が止まっている——そこへ、引き戸が静かに開いた。

本日の持ち込み素材

午後の早い時間。日差しが傾き始めた頃合い。

入ってきたのは、落ち着いた雰囲気の女性だった。歳は三十代前半くらいだろうか。ビジネスの相談をしに来た人のような、緊張も切迫感もない佇まいで、少し周りを見回してからカウンターに近づいてきた。

「すみません。同業者の方に伺って——コードの設計を見ていただけると聞いて来ました」

ちょうど厨房から出てきたシェフが、手を拭きながら「座れ」と短く言った。それからいつも通り、「何を持ち込んだ」と続ける。

「食材マスタを参照するサービスを3つ持っていまして」と彼女は言いながら、ノートPCをカウンターに置いた。私は自分のPCを脇に避けて場所を作る。「外部のモジュールが仕様変更をして、直し忘れた1か所でエラーが出ました。同じことがまた起きそうで——何かやり方があるのかなと思って」

淡々として、自己批判的でない。起きたことを事実として整理して話す口調だった。

彼女が持ち込んだコードを見ると、OrderServiceStockServiceInvoiceService という3つのファイルがあった。それぞれが FoodMasterClient という外部モジュールを呼び出して、食材の名前や価格を取得している。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# OrderService.pm の一部
package OrderService;
use Moo;
use v5.36;
use FoodMasterClient;

has _client => (is => 'ro', default => sub { FoodMasterClient->new });

sub calculate_price {
    my ($self, $item_code) = @_;
    my $data  = $self->_client->get_item($item_code);
    my $price = $data->{price_yen};    # FoodMasterClient の内部フィールドを直接参照
    my $tax   = $data->{tax_rate};
    return int($price * (1 + $tax));
}

FoodMasterClient が2.0にバージョンアップしたとき、price_yen というフィールド名が unit_price に変わりました」と彼女は続けた。「OrderServiceStockService は修正したんですが、InvoiceService を直し忘れて——翌朝、請求書の生成でエラーが出て気づきました」

3つのファイルに、同じフィールド名が散らばっている。

私にはコードの詳細は読めないけれど、「3か所に同じ言葉がある」という状況は、なんとなく理解できた。そして——今の自分のことが、また頭に浮かんだ。あの在庫ツールの中にも、外部のAPIを呼び出している箇所がある。あそこも、同じことになっていないだろうか。

でも今は、彼女の話に集中しよう。シェフが動き始めた。

変換が散在している

シェフはしばらくコードを眺めてから、ターミナルを開いて一行打ち込んだ。

1
grep 'price_yen' *.pm

画面に3行、表示された。OrderService.pmStockService.pmInvoiceService.pm——それぞれに price_yen という文字列が並んでいる。

「3か所、FoodMasterClient の言葉が入ってる」とシェフは言った。「この3つのファイル、全部、外の規格を知ってる」

「外の規格……」

私は小声で繰り返した。「外の言葉が中に入ってる」という言い方に、何か引っかかった。

シェフが厨房から、輸入スパイスの袋を一つ取り出してきた。赤みがかった粉が入った小袋で、ラベルに saffron 0.5oz と書いてある。カウンターに置いて、彼女に向けた。

「これ、使うとき、今どうしてる?」

彼女が少し考えてから「毎回、グラムに換算して使います」と答えた。スープを作るとき、ソースを作るとき、デザートを作るとき——シェフが指を折りながら確認していくと、彼女は「……はい、全員が毎回」と続けた。袋を手に取って、oz の数字を眺めている。

「換算を間違えたら、全部の料理で違う量になる」とシェフは言って、コードに目を戻した。「price_yen を読んで変数に入れる——これが換算処理だ。3か所でやっている。FoodMasterClientunit_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(ターゲット)——「こういう規格で話しかけてほしい」という宣言だ。

1
2
3
4
5
package FoodItemRepository;
use Moo::Role;
use v5.36;

requires 'find';   # find($item_code) を実装することを要求する

次に、find が返す値オブジェクト。ハッシュリファレンスではなく、->name->price->tax_rate というメソッドで値を取り出せるオブジェクトにする。

1
2
3
4
5
6
7
package FoodItem;
use Moo;
use v5.36;

has name     => (is => 'ro', required => 1);
has price    => (is => 'ro', required => 1);
has tax_rate => (is => 'ro', required => 1);

そして、変換の仲立ちクラス。これが Adapter——FoodMasterClientAdaptee: 既存の、インターフェースが合わないクラス)をラップし、FoodItemRepository Role を実装する。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
package FoodMasterAdapter;
use Moo;
use v5.36;
with 'FoodItemRepository';   # Target Role を実装する

has _client => (is => 'ro', default => sub { FoodMasterClient->new });

# ★ FoodMasterClient の内部フィールド名を知っているのはここだけ ★
sub find {
    my ($self, $item_code) = @_;
    my $data = $self->_client->get_item($item_code);
    return FoodItem->new(
        name     => $data->{item_name},
        price    => $data->{unit_price},   # ← 変換はここ1か所
        tax_rate => $data->{tax_rate},
    );
}

最後に、3つのサービスクラスを書き直す。use FoodMasterClient が消え、代わりに FoodItemRepository Role を受け取るだけになる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
package OrderService;
use Moo;
use v5.36;

has repository => (is => 'ro', required => 1);   # FoodItemRepository を受け取る

sub calculate_price {
    my ($self, $item_code) = @_;
    my $item = $self->repository->find($item_code);
    return int($item->price * (1 + $item->tax_rate));
    # FoodMasterClient も unit_price も price_yen も一切知らない
}

StockServiceInvoiceService も同じ構造になる——has repository で受け取って、->find を呼んで、FoodItem のメソッドを使う。

シェフがコードを示しながら言った。「OrderServiceFoodMasterClient を知らない。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つのサービスが正しく動く。FoodMasterAdapterFoodItemRepository Role を実装していることも確認できた。find が正しい FoodItem オブジェクトを返している。

1
2
3
4
5
6
# テスト結果の一部
ok - FoodMasterAdapter  FoodItemRepository Role を実装している
ok - OrderService: 正しく計算できる480 * 1.08  int = 518
ok - InvoiceService: 正しい請求書行を生成する
ok - モックアダプター: OrderService  FoodMasterClient なしでテストできる
ok - OCP: OrderService は別の食材マスタソースでも変更なしで動作

unit_price が次にまた変わったとしても、直すのは FoodMasterAdapter::find の1行だけですね」と彼女が自分で確認した。

「そうだ」とシェフが返した。それから少し間を置いてから付け加えた。

「外の規格が変わっても、厨房のレシピは変えなくていい。変換役が1か所にいれば」

彼女が「変換役を1か所に、ですか」と繰り返した。少し考えてから「わかりました。持ち帰ります」と言って立ち上がった。感謝の言葉は短く、もうすでに頭の中でコードを整理している様子だった。

引き戸が閉まる音がした。

片付けをしながら、その言葉を反芻した。

変換役を1か所に——そういうことか。

閉店後の静かな食堂で、私はもう一度ノートPCを開いた。在庫管理ツールのコード。外部APIのレスポンスから値を取り出している部分を、grep で探してみた。

1
grep 'stock_count' *.pm

3行、表示された。

今日は直さない。直し方もまだわからない。でも今日、確認した。確認しようとして、確認した。それが、今日のことだった。


シェフの仕込み工程表

問題(調理ミス)技法(パターン)効果(仕上がり)
外部モジュールの内部フィールド名(price_yen)が3サービスに散在Adapter(仲立ちクラス一枚)変換コードが FoodMasterAdapter::find の1か所に集約
外部仕様変更のたびに全箇所の修正が必要Target Role(FoodItemRepository)で境界を定義3サービスは外部モジュールの仕様を知らない
外部モジュールがないと単体テストできないリポジトリをコンストラクタ注入モックアダプターで外部依存なしにテスト可能

工程

Step 1: Target Role を定義する

アプリが期待するインターフェースを Moo::Role で定義する。requires 'find' で「このロールを with するクラスは必ず find を実装すること」を強制する。

1
2
3
package FoodItemRepository;
use Moo::Role;
requires 'find';

Step 2: Value Object を作成する

find が返す値を、ハッシュリファレンスではなくオブジェクトとして定義する。呼び出し側は ->price のようにメソッドで値を取得し、ハッシュのキー名を知らなくてよくなる。

1
2
3
4
5
package FoodItem;
use Moo;
has name     => (is => 'ro', required => 1);
has price    => (is => 'ro', required => 1);
has tax_rate => (is => 'ro', required => 1);

Step 3: Adapter クラスを作成する

外部モジュール(Adaptee)を has で保持し、with 'FoodItemRepository' で Target Role を実装する。find の中で Adaptee のインターフェース変換を行う——これが Adapter の唯一の責務だ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package FoodMasterAdapter;
use Moo;
with 'FoodItemRepository';

has _client => (is => 'ro', default => sub { FoodMasterClient->new });

sub find {
    my ($self, $item_code) = @_;
    my $data = $self->_client->get_item($item_code);
    return FoodItem->new(
        name     => $data->{item_name},
        price    => $data->{unit_price},   # ← 変換はここ1か所
        tax_rate => $data->{tax_rate},
    );
}

Step 4: サービスクラスを書き直す

use FoodMasterClient を削除し、has repository => (is => 'ro', required => 1)FoodItemRepository を受け取る形に変える。サービスは find を呼んで FoodItem のメソッドを使うだけになる。

Step 5: アプリケーション起動時に組み立てる

どの Adapter を使うかを決めるのは、アプリケーションの初期化箇所だ。

1
2
my $repository = FoodMasterAdapter->new;
my $order      = OrderService->new(repository => $repository);

テスト時はここを差し替える——FoodMasterClient を使わないモッククラスを渡せばよい。

シェフより

外のモジュールが変わるたびに全部直す——それは「外の規格を全員が覚えている状態」だ。換算スプーンを使わずに、全員が頭の中でオンス計算をしている厨房と同じだ。

Adapter の仕事は、その換算を一人で引き受けることだ。外のモジュールの言葉を知っているのは Adapter だけ、それ以外は FoodItem の言葉しか知らない——そういう役割分担にする。

一つ正直に言っておく。外の仕様が変わったとき、修正がゼロになるわけではない。Adapter を直す必要はある。ただし、1か所だ。3か所が1か所になった——この「位置の変化」が、修正漏れを構造的に起きにくくする。直し忘れる場所が、存在しなくなる。

comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。