Featured image of post コードシェフの仕込み帳【Proxy】見えない守衛〜同じ問い合わせを毎回繰り返すコードを、本物と同じ窓口を持つ代理でまとめる〜

コードシェフの仕込み帳【Proxy】見えない守衛〜同じ問い合わせを毎回繰り返すコードを、本物と同じ窓口を持つ代理でまとめる〜

権限チェックとキャッシュが3つの呼び出し元に散在し、1か所の書き忘れでセキュリティバグ。ProxyパターンをPerlとMooで実装し、実APIと同じcheck_stockシグネチャを持つ代理クラスが認可とキャッシュを一か所で担い、呼び出し元はオブジェクトを差し替えるだけにする。

ランチタイムが終わった食堂は、少し緩んだ空気になる。

昼過ぎ、私はホールのカウンターで今日の食材リストを整理していた。何が何個使われたか、補充が要るものはどれか——在庫確認が、一日に何度もある仕事だ。シェフは厨房で来週分の仕込みをしていた。

引き戸が開いた。

26歳前後の女性が入ってきた。軽装で、ショルダーバッグを持っていた。「ブログを読んで」と言った。電話も紹介もなく、直接来た人間の空気だった。

シェフが厨房から顔を出した。

「見ててみろ」と私に向かって言って、エプロンは外さずに厨房に戻った。

私は少し背筋が伸びた。

彼女がカウンターにPCを開いた。IngredientStockService と、その下に3つの関数が並んでいた。plan_menucalculate_costgenerate_shopping_list——どれも $service->check_stock を呼んでいる。

plan_menudie "権限なし\n" が書いてある。calculate_cost にも同じ行がある。generate_shopping_list にはない。

——あ、これ。

この記事で学ぶこと

この記事は、「権限チェックとキャッシュを各呼び出し元が個別に持ち、1か所だけ書き忘れてセキュリティバグになった」という問題を、Proxyパターンで整理する話です。実APIと同じ check_stock シグネチャを持つ代理クラスを間に置き、呼び出し元はオブジェクトを差し替えるだけにします。

学ぶことひとことで言うと
Proxy パターン実体(RealSubject)と同じインターフェースを持つ代理を介してアクセスを制御する構造パターン
caller-side-preprocessing権限チェックやキャッシュが各呼び出し元に散在しており、1か所の書き忘れや書き間違いが他の場所に影響しない(見えにくいバグになる)状態
Moo での代理実装has _real で実体を保持し、同名のメソッドで委譲する。認可に必要な情報は has _user で構築時に注入し、呼び出しシグネチャは変えない
透過的な差し替え呼び出し元の関数本体は変えない。渡すオブジェクトを $service から $proxy に差し替えるだけで動作が変わる
責務の分離実APIは通信だけ、Proxyはアクセス制御だけを知っている。それぞれを独立してテストできる

対象読者は、次のような人を想定しています。

  • PerlとMooの基本(hasnew)がなんとなく分かる
  • 「同じ処理を複数の関数に書いて、1か所だけ直し忘れた」という経験がある

技術スタックはPerl / Mooです。コードはすべて手元で動かし、テストが通ることを確認しています。

3か所に書いていた

「コードを直す前に——」と私は言った。「generate_shopping_list に権限チェックが書いていないのは、わざとですか」

「あ、それは書き忘れました」と彼女は言った。少し恥ずかしそうに。「先週、ブログの読者から変なデータが見えると言われて気づきました。権限を持っていないユーザーが在庫を参照できていた」

彼女が話した。レシピ管理アプリを一人で作っていて、食材の在庫確認を外部API経由で行っている。IngredientStockService::check_stock を3か所から呼んでいる。パフォーマンスを気にしてキャッシュを追加したが、各関数にローカルで書いた。権限チェックも同じ——plan_menucalculate_cost には書いたが、generate_shopping_list は書き忘れた。

コードは、こうだった。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
package User;
use Moo;
use v5.36;
has role => (is => 'ro', required => 1);

# 実 API(外部サービス呼び出し)
package IngredientStockService;
use Moo;
use v5.36;
sub check_stock {
    my ($self, $ingredient) = @_;
    # 外部HTTP呼び出し(遅い・rate-limitがある)
    return { available => 1, count => 50 };  # 模擬
}

package main;

# 呼び出し元1: メニュー計画
sub plan_menu {
    my ($service, $user, @ingredients) = @_;
    die "権限なし\n" unless $user->role eq 'chef';
    my %cache;
    for my $ingredient (@ingredients) {
        my $stock = $cache{$ingredient} //= $service->check_stock($ingredient);
        # ...
    }
}

# 呼び出し元2: コスト計算(同じ権限チェックとキャッシュを再実装)
sub calculate_cost {
    my ($service, $user, @ingredients) = @_;
    die "権限なし\n" unless $user->role eq 'chef';
    my %cache;
    for my $ingredient (@ingredients) {
        my $stock = $cache{$ingredient} //= $service->check_stock($ingredient);
        # ...
    }
}

# 呼び出し元3: 買い物リスト生成(権限チェック忘れ + キャッシュなし)
sub generate_shopping_list {
    my ($service, @ingredients) = @_;
    # 権限チェックなし ← バグ
    for my $ingredient (@ingredients) {
        my $stock = $service->check_stock($ingredient);  # キャッシュなし・毎回呼ぶ
        # ...
    }
}

問題は3つあった。plan_menucalculate_cost で同じ権限チェックを2か所に書いている。generate_shopping_list は書き忘れた。キャッシュが各関数にローカルなので、同じ食材を別の関数から2回呼んでも共有されない——plan_menucheck_stock('tomato') を呼んでも、次に calculate_costcheck_stock('tomato') を呼ぶとまた実APIを叩く。

caller-side-preprocessing——キャッシュと権限チェックが各呼び出し元に散在している状態だ」とシェフが厨房から声を出した。いつの間にか入口に立っていた。「呼び出し元の数だけ同じロジックを書く。1か所の書き忘れが、別の場所には影響しない——だから見えにくい」

シェフが厨房に戻った。「続けろ」と言って。

代理を作る

IngredientStockProxy を作ります」と私は言った。

IngredientStockService の前に代理クラスを挟む。_real 属性に本物のAPIを持って、_user を構築時に渡す。_cache でキャッシュを管理する。権限チェックとキャッシュを、代理が担う」

ホワイトボードに書いた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Proxy: 本物と同じ check_stock シグネチャを持つ
package IngredientStockProxy;
use Moo;
use v5.36;

has _real  => (is => 'ro', required => 1);   # 実 API
has _user  => (is => 'ro', required => 1);   # 構築時に注入
has _cache => (is => 'ro', default => sub { {} });

sub check_stock {
    my ($self, $ingredient) = @_;                  # 同じシグネチャ
    die "権限なし\n" unless $self->_user->role eq 'chef';
    return $self->_cache->{$ingredient} if exists $self->_cache->{$ingredient};
    $self->_cache->{$ingredient} = $self->_real->check_stock($ingredient);
}

「権限チェックとキャッシュを Proxy が担います。呼び出し元は——」

「呼び出し元のコードも変わりますか?」と彼女が聞いた。

「あ——」と私は一拍置いた。

$proxy->check_stock($ingredient) に変える、それは分かる。でも plan_menu の引数が変わるのか、$user の渡し方が変わるのか、そこで言葉が止まった。

シェフが厨房から出てきた。

「代理は本物と同じ窓口を持つ」とシェフは言った。「IngredientStockService::check_stock も、IngredientStockProxy::check_stock も——シグネチャは ($self, $ingredient) で同じだ。呼び出し元は $service$proxy に差し替えるだけ。check_stock の書き方は変えない」

そうか。_userProxy->new に渡している——check_stock($ingredient) には渡さない。だから呼び出し元は check_stock($ingredient) をそのまま書ける。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# Proxy を構築して差し替え
my $proxy = IngredientStockProxy->new(
    _real => IngredientStockService->new,
    _user => $user,  # 構築時に渡す
);

# 呼び出し元: check_stock の書き方は変えない
sub plan_menu {
    my ($service, @ingredients) = @_;
    for my $ingredient (@ingredients) {
        my $stock = $service->check_stock($ingredient);  # Proxy が権限確認・キャッシュ担当
        # ...
    }
}

# generate_shopping_list は関数の中身を変えていない
sub generate_shopping_list {
    my ($service, @ingredients) = @_;
    for my $ingredient (@ingredients) {
        my $stock = $service->check_stock($ingredient);  # real でも proxy でも同じ書き方
        # ...
    }
}

generate_shopping_list の本体はBeforeと一字も変わっていない。渡すオブジェクトを $proxy にするだけで、権限チェックが透過的に効く。

「でも——」と彼女が言った。「IngredientStockService 自体にキャッシュと権限チェックを持たせれば、新しいクラスは要らないのでは?」

シェフが答えた。

「できる。動く。ただ——APIクラスが通信とキャッシュと認可の3つを抱えることになる。APIの通信だけをテストしたいとき、権限チェックとキャッシュが一緒についてくる。キャッシュ戦略を変えたいとき(たとえばメモリからRedisに)にもAPIクラスを開く必要がある」

「共通のヘルパー関数にする方法もあります——fetch_stock($service, $user, $ingredient) のように」と私は言った。

「それも動く。だが呼び出し方が変わる——$service->check_stock($ingredient)fetch_stock($service, $user, $ingredient) になる。$service オブジェクトを期待している場所にヘルパー関数は渡せない。Proxyなら——$service が来る場所に $proxy をそのまま渡せる。ヘルパー関数を育てていくと、実体を保持して同じ名前のメソッドを持つ——結局、Proxyになる」

「Proxyにすると」とシェフは続けた。「実APIは通信だけ、Proxyはアクセスの制御だけを知っている。それぞれを独立してテストできる」

Proxyパターン——実体(RealSubject)と同じインターフェースを持つ代理(Proxy)を介してアクセスを制御する構造パターン。呼び出し元は代理の存在を知らず、同じメソッドを呼ぶだけでいい。Proxyは認可・キャッシュ・ログなどのアクセス制御を担い、実体への委譲を一か所に集める。Mooでは has _real で実体を保持し、同名のメソッドで委譲する。

Facade・Builder・Iterator・Chain of Responsibility との違い

前々前々回のFacadeは「複数のサブシステムへの呼び出しを一本化する」パターンだった。前々前回のBuilderは「オブジェクトの部品をどう積み上げるか」を引き取るパターンだった。前々回のIteratorは「コレクションの走査インターフェースを固定する」パターンだった。前回のChain of Responsibilityは「複数のハンドラが連鎖して、誰が処理するかを動的に決める」パターンだった。Proxyは違う——実体と1対1で対応する代理だ。複数のハンドラの連鎖ではなく、1つの実体への固定の窓口。Decoratorと混同されやすいが、Decoratorは振る舞いを重ねて拡張するのに対し、Proxyはアクセスを制御する——インターフェースは同じで、目的が「何を追加するか」ではなく「誰がアクセスできるか」にある。

試食合格

テストを走らせた。まずBefore(caller-side-preprocessing)で、問題の現場を確認する。

1
2
3
4
5
6
7
ok 1 - plan_menu: 権限なし → die
ok 2 - calculate_cost: 権限なし → die
ok 3 - generate_shopping_list: 権限なし trainee でも通る(権限チェック忘れのバグ)
ok 4 - plan_menu: 同じ食材2回 → API呼び出しは1回(ローカルキャッシュ)
ok 5 - plan_menu + calculate_cost: 同じ食材で API が2回呼ばれる(キャッシュ未共有)
ok 6 - generate_shopping_list: 同じ食材3回 → API3回(キャッシュなし)
1..6

テスト3番——generate_shopping_list は権限なしユーザーでも通ってしまう。ok になっているが、これがバグだ。テスト5番——plan_menucalculate_cost でキャッシュが共有されないので、同じ食材を2回実APIから取得している。

次にAfter(Proxyパターン)で確認する。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
ok 1 - 権限なしユーザーのプロキシ → check_stock で die
ok 2 - 権限ありユーザーのプロキシ → check_stock が正常に返る
ok 3 - 同じ食材2回 → real API 呼び出しは1回(Proxy キャッシュ)
ok 4 - plan_menu + calculate_cost が同じ proxy 経由 → API呼び出しは1回(キャッシュ共有)
ok 5 - 代替可能性: generate_shopping_list は real でも proxy でも同じ結果
ok 6 - 代替可能性: trainee の proxy を渡すと generate_shopping_list でも権限エラー
ok 7 - MockStockService->can('check_stock')
ok 8 - IngredientStockProxy->can('check_stock')
ok 9 - 3食材×各2回呼び出し → real API は各1回・合計3回
1..9

全テスト通過、警告なし。

テスト5番と6番——generate_shopping_list の関数本体は変えていない。渡すオブジェクトが $real_service$proxy かで動作が変わる。テスト4番——plan_menucalculate_cost が同じ $proxy を使うと、_cache が共有されて実APIへの呼び出しは1回になる。

彼女がAfterのコードを見た。plan_menucalculate_costgenerate_shopping_list のどこにも die unless $user->role が書いていない。

「権限チェックがどこにも書いていない——」

「Proxyに書いてある」とシェフは言った。「3か所に書かなくていい。書き忘れもない」


シェフの仕込み工程表

問題(調理ミス)技法(パターン)効果(仕上がり)
権限チェックとキャッシュが各呼び出し元に散在し(caller-side-preprocessing)、1か所の書き忘れでセキュリティバグ、キャッシュが関数をまたいで共有されないProxyパターン:実APIと同じ check_stock シグネチャを持つ代理クラスが認可とキャッシュを一か所で担う権限チェックとキャッシュが1か所。呼び出し元は $service$proxy に差し替えるだけ。書き忘れがなくなる。実APIとProxyを独立してテストできる

工程

  1. 権限チェックやキャッシュが複数の呼び出し元に重複して書かれていないかを確認する
  2. 実APIと同じ名前のメソッドを持つProxyクラスを use Moo で作る
  3. has _real で実APIを属性として保持する
  4. 認可に必要な情報(_user など)を構築時に注入する——呼び出しシグネチャは変えない
  5. has _cache でキャッシュをインスタンス属性として持つ
  6. メソッドの中で: 認可チェック → キャッシュ確認 → 実APIへの委譲の順に処理する
  7. 呼び出し元を $service$proxy に差し替える。メソッド呼び出しは変えない
  8. 実APIのテスト(Proxyなし)とProxyのテスト(実APIをシンプルなインスタンスで)を独立して書く

シェフより

「代理に3つ以上のことを任せるな。認可と委譲とキャッシュで十分だ。本物のAPIは通信だけを知っていればいい。本物と同じ窓口を持つ代理を作れ——呼び出す側は差し替えるだけでいい。書き忘れた場所があっても、次からは代理が断る」


彼女が立ち上がった。「ありがとうございました」と言って、PCをバッグにしまった。「やってみます」と言って引き戸を開けた。

ドアが閉まった。

私はホワイトボードの前に立ったまま、check_stock という文字を見ていた。

IngredientStockService::check_stock と、IngredientStockProxy::check_stock——同じシグネチャだ。それで「差し替えるだけ」が言える。

私は「代理クラスを間に挟む」まで言えた。その先——「呼び出し元は変わりますか?」と聞かれたとき、止まった。「同じ窓口を持つ」という言葉が出てこなかった。_usercheck_stock に渡さずに構築時に注入する——その理由が、すぐには繋がらなかった。

でも今は——どこで止まったかは分かる。「同じ窓口」。次に似たことを説明するとき、ここから言えるかもしれない。

ep13のときは「見えることと伝えることは違う」と思った。今日は——詰まった場所が見えた。それは少し前進だと思う。

シェフは何も言わなかった。「見ててみろ」と言ったきり、厨房に戻っていた。

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