Featured image of post コード探偵ロックの事件簿【API Gateway】窓口のない捜査本部〜マイクロサービスに個別に会いに行く悲劇〜

コード探偵ロックの事件簿【API Gateway】窓口のない捜査本部〜マイクロサービスに個別に会いに行く悲劇〜

複数マイクロサービスへの直接通信でクライアントが疲弊する問題を、API Gateway と集約オブジェクトで一本化する Perl/Moo 実装です

フロントエンドチームに来た探偵

バックエンドチームから連絡が来た。今度は Order サービスのベースパスが変わるという。3ヶ月で 6 回目だった。

僕はモニタの右側に Excel を開き、「API エンドポイント一覧」のシートを探した。更新済みの黄色セルが散らばる表の中に、また新しい修正箇所を書き足す。それがここ数ヶ月の仕事の一部だった。

「失礼、フロントエンドチームはここかね」

振り向かずに答えた。「田中さんから聞いてきましたか」

「ああ、境界の揉め事があるなら LCI に行けと言われてね。そちらのシニアエンジニアから紹介状をもらってきたよ」

ロックさんと名乗るその人は、名刺を机に置きながら、何のことわりもなく隣の椅子を引いて座った。そして開いたままの Excel を眺めた。

「これは……捜査員名簿かね。それとも戦争のときに作る緊急連絡先の一覧か」

初めて画面を指摘された気がした。同僚は誰もこのファイルに何も言わなかった。「API のエンドポイントが変わるたびに、フロントのコードを修正するためです。一覧がないと何を直せばいいか分からない」

「なるほど。全員に個別に会いに行くから、全員の住所が必要になる、ということだね」

僕は少し間を置いてから「そのとおりです」と答えた。初対面の人間に言われた言葉とは思えないくらい、正確だった。

「それで、ワトソン君。コードを見せてもらえるか」

「……田中さんから、その呼び名の説明も受けていますか」

「受けていないね」

「では流します」


4 本の電話線

「これが、チェックアウト処理のコードです」

僕は VS Code を前面に出した。淡々と説明する気力しかなかった。「user_serviceproduct_serviceorder_servicepayment_service。4 つ全部を直接呼んでいます」

 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
package CheckoutController;
use v5.36;
use Moo;
use Types::Standard qw(Object);

has user_service    => (is => 'ro', isa => Object, required => 1);
has product_service => (is => 'ro', isa => Object, required => 1);
has order_service   => (is => 'ro', isa => Object, required => 1);
has payment_service => (is => 'ro', isa => Object, required => 1);

sub checkout ($self, $user_id, $product_id, $amount) {
    my $user    = $self->user_service->get_user($user_id);
    my $product = $self->product_service->get_product($product_id);
    my $stock   = $self->product_service->check_stock($product_id, $amount);

    die "Out of stock\n" unless $stock->{available};

    my $order = $self->order_service->create_order({
        user_id    => $user_id,
        product_id => $product_id,
        amount     => $amount,
    });
    my $payment = $self->payment_service->charge({
        user_id  => $user_id,
        order_id => $order->{id},
        total    => $product->{price} * $amount,
    });

    return { order => $order, payment => $payment };
}

ロックさんはコードを見ながら、has の行を声に出さずに指で数えた。4 本ぶんの指を折った。

「認証は?」

「各サービスにそれぞれ実装されています。先月 Product チームがトークン検証の方式を変えたとき、フロントは 5 ファイル修正しました」

「ふむ。4 人に個別に電話をかける捜査本部だね」ロックさんはそう言って、少しだけ楽しそうに見えた。楽しそうに見えたのは外側からの印象で、内心は読めなかった。「各サービスの部屋を直接叩いて回る。一人が引っ越せば、住所録を更新しなければならない」

「そのとおりです。3ヶ月で 6 回、あのスプレッドシートを更新しました」

「——今は動いているんです」僕はそこで付け足した。「修正コストを許容するしかないのでは、と正直思っています」

ロックさんは少し間を置いた。

「今は動いている、ね。だがここ 3 ヶ月でスプレッドシートに何行追加した?」

「6 行です」

「バグは一点で燃える。結合はじわじわ燃える。今日は動いているが、次の変更が来るたびに君はまたあのファイルを開く。それが事件だよ、ワトソン君」

「……ロックさんは、設計の問題だと言うんですか」

「私が言うのではない。そのスプレッドシートが言っている」

ロックさんはコードを指した。「容疑者は 4 本の電話線そのものだ。正確には——クライアントがバックエンドの内部構造をすべて知っていることだよ。サービスの数だけ結合が増え、変更のたびにクライアントが巻き込まれる。窓口のない捜査本部の事件だ」


受付を一人立てろ

「解決策を見せましょう」

ロックさんは新しいファイルを開いた。

まず受付を設計する

「最初に ApiGateway を立てる。クライアントはこの受付にしか会わなくていい」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package ApiGateway;
use v5.36;
use Moo;
use Types::Standard qw(Object HashRef);

has aggregator   => (is => 'ro', isa => Object, required => 1);
has _route_table => (is => 'rw', isa => HashRef, default => sub { {} });

sub register ($self, $path, $handler) {
    $self->_route_table->{$path} = $handler;
    return;
}

sub handle ($self, $token, $path, @args) {
    $self->_authenticate($token);
    my $handler = $self->_route_table->{$path}
        or die "Not Found: $path\n";
    return $handler->(@args);
}

sub _authenticate ($self, $token) {
    die "Unauthorized\n" unless defined($token) && $token ne '';
    return;
}

「ルーティングテーブルに登録するんですか」

「そうだ。どのパスをどのハンドラへ回すかを、この表一枚で管理する。バックエンドが変わっても、クライアントは handle('/checkout', ...) を呼ぶだけでいい。Order サービスのベースパスが変わっても、クライアントのコードは変わらない」

「認証を Gateway に集めると……」

「肥大化する、と言いたいんだね」ロックさんは先回りした。「そのリスクは本物だ。単一の Gateway がすべての責務を抱え込めば、それは新しいモノリスになる。だから窓口は薄く保つ。認証確認と転送だけを窓口の仕事にする」

集約の仕事を専門室へ

「複数サービスへの呼び出しは、別の担当に任せる」

 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
package Gateway::CheckoutAggregator;
use v5.36;
use Moo;
use Types::Standard qw(Object);

has user_service    => (is => 'ro', isa => Object, required => 1);
has product_service => (is => 'ro', isa => Object, required => 1);
has order_service   => (is => 'ro', isa => Object, required => 1);
has payment_service => (is => 'ro', isa => Object, required => 1);

sub aggregate ($self, $user_id, $product_id, $amount) {
    my $user    = $self->user_service->get_user($user_id);
    my $product = $self->product_service->get_product($product_id);
    my $stock   = $self->product_service->check_stock($product_id, $amount);

    die "Out of stock\n" unless $stock->{available};

    my $order = $self->order_service->create_order({
        user_id    => $user_id,
        product_id => $product_id,
        amount     => $amount,
    });
    my $payment = $self->payment_service->charge({
        user_id  => $user_id,
        order_id => $order->{id},
        total    => $product->{price} * $amount,
    });

    return { order => $order, payment => $payment };
}

「窓口は薄く。集約は専門室へ」ロックさんが言った。「窓口が自分で 4 部屋を走り回れば、それはすぐに肥大化した Gateway になる。仕事を振れば、窓口は薄いまま保てる」

「受付が案内係で、Gateway::CheckoutAggregator が連絡係、ということですか」

「結構。受付が走り回れば神になる。仕事を振れば薄いまま保てる」

クライアントの変化

「最後に、チェックアウトのコードだ」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
package CheckoutController;
use v5.36;
use Moo;
use Types::Standard qw(Object);

has gateway => (is => 'ro', isa => Object, required => 1);

sub checkout ($self, $token, $user_id, $product_id, $amount) {
    return $self->gateway->handle($token, '/checkout', $user_id, $product_id, $amount);
}

has の行が 1 本になっていた。user_serviceproduct_serviceorder_servicepayment_service も消えていた。

gateway しかいない」

「そうだよ、ワトソン君。チェックアウトは /checkout への経路だけを知っている。バックエンドが分割されようと統合されようと、ルーティングテーブルを直すだけだ」

「……Gateway を一枚置いても、内部サービスの分割は変わらないんでしょう?」

「そうだよ。変わらない」ロックさんはそこで一度止まった。「変わるのは変更の被害半径だ。今は 4 サービスの変更が全部クライアントへ届く。Gateway を挟めば、変更はルーティングテーブルで止まる。クライアントに火が回らない」

(Gateway は魔法ではない。依存が消えるわけではない。だが変更がどこで止まるかが変わる——そういうことか)

「あの」と僕は続けた。「先週の設計会議で『ACL』という話が出ていました。外部の語彙を内部に持ち込まないための境界だと。Gateway と何が違うんですか」

「ACL は翻訳係だ。借り物の言葉を自分たちの言葉に直す係」ロックさんは手で境界線を引くような動作をした。「Gateway は受付係だ。訪問者を適切な部屋へ案内する係。翻訳係がいても、受付がいなければ訪問者は全部の部屋を叩いて回る。役割が違う」

「では、Gateway の内部で ACL を使うこともありますか」

「よくある。Gateway が案内し、各サービスとの接続点で ACL が翻訳する。競合ではなく、役割の分担だよ」


構造の変化

Before と After の依存関係を図にすると、変化が明確になります。

Before: クライアントが 4 サービスを直接知っている

	classDiagram
    class CheckoutController {
        +user_service
        +product_service
        +order_service
        +payment_service
        +checkout(user_id, product_id, amount)
    }
    CheckoutController --> UserService
    CheckoutController --> ProductService
    CheckoutController --> OrderService
    CheckoutController --> PaymentService

After: Gateway が窓口を一本化する

	classDiagram
    class CheckoutController {
        +gateway
        +checkout(token, user_id, product_id, amount)
    }
    class ApiGateway {
        +aggregator
        -_route_table
        +register(path, handler)
        +handle(token, path, args)
        -_authenticate(token)
    }
    class CheckoutAggregator {
        +user_service
        +product_service
        +order_service
        +payment_service
        +aggregate(user_id, product_id, amount)
    }
    CheckoutController --> ApiGateway
    ApiGateway --> CheckoutAggregator
    CheckoutAggregator --> UserService
    CheckoutAggregator --> ProductService
    CheckoutAggregator --> OrderService
    CheckoutAggregator --> PaymentService

CheckoutController が知るのは ApiGateway だけです。バックエンドのサービスが再編されても、クライアントのコードには届きません。


電話線が 1 本になる

テストで確認した。

1
2
3
4
5
6
# After: CheckoutController が gateway だけを知っている
ok(CheckoutController->can('gateway'),          'gateway への依存がある');
ok(!CheckoutController->can('user_service'),    'user_service への直接依存がない');
ok(!CheckoutController->can('product_service'), 'product_service への直接依存がない');
ok(!CheckoutController->can('order_service'),   'order_service への直接依存がない');
ok(!CheckoutController->can('payment_service'), 'payment_service への直接依存がない');

全部 ok だった。

「仕様変更の修正箇所が……ルーティングテーブルで止まります。今まで src/api/ を全部開いていたのに」

「これでようやく、チェックアウトはチェックアウトの仕事だけをすればよくなった」

バックエンドが新しいサービス群に切り替わっても、CheckoutController は変更不要なことをテストでも確認できる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Aggregator を差し替えても CheckoutController は無改修で動く
my $new_agg = NewAggregator->new(...);
my $gateway = ApiGateway->new(aggregator => $new_agg);
$gateway->register('/checkout', sub ($user_id, $product_id, $amount) {
    return $new_agg->aggregate($user_id, $product_id, $amount);
});

my $ctrl   = CheckoutController->new(gateway => $gateway);
my $result = $ctrl->checkout('valid-token', 1, 1, 3);
ok($result, 'バックエンドを切り替えても CheckoutController は変更なしで動く');

「スプレッドシートが……要らなくなります」

気づくと Excel が開いたままだった。閉じた。シートが 6 枚あった。

「ロックさん。今日の費用は」

「6 枚のシートだったね」ロックさんは立ち上がりながら言った。「では 6 冊、廃盤になった技術書を要求する。どれかは指定しない。見つけてきたまえ」

「廃盤の本は自分で探すしかないのでは」

「それが醍醐味だよ。——次にバックエンドが変わったとき、そのルーティングテーブルだけを直したまえ、ワトソン君」


探偵の調査報告書

容疑(アンチパターン)真実(パターン)証拠(効果)
Direct Client-to-Microservice CommunicationAPI Gateway パターンクライアントが gateway だけを知り、バックエンドの変更が届かない
4 サービスへの直接依存ApiGateway による窓口の一本化ルーティングテーブルで変更を吸収し、クライアントの修正が不要になる
認証のクロスカッティング重複Gateway への認証集約各サービスが個別に認証実装しなくてよくなる
クライアント側の API 一覧管理_route_table によるルーティング管理エンドポイント変更の被害半径がルーティングテーブルで止まる

推理のステップ

  1. クライアントが直接知っているサービスの数を数える(has の行を数えるだけでよい)
  2. 認証・ロギング等のクロスカッティング処理がどのサービスに重複しているかを確認する
  3. ApiGateway を立て、ルーティングテーブルと認証集約を担わせる
  4. 複数サービスへの呼び出しを Gateway::CheckoutAggregator(または類似の集約オブジェクト)に委譲する
  5. クライアントを gateway だけに依存させ、内部サービスの存在を知らなくする
  6. バックエンドの変更(メソッド名・エンドポイント・サービス分割)がクライアントに届かないことをテストで確認する

Gateway が肥大化する(God Gateway になる)リスクには注意が必要です。認証確認と転送だけを Gateway の責務とし、複数サービスへの集約は専用のアグリゲーターへ委譲することで、Gateway を薄く保つことができます。

ACL(Anti-Corruption Layer)との使い分けも重要です。Gateway は「誰をどの部屋へ案内するか」を担い、ACL は「部屋の中で何をどの言葉に翻訳するか」を担います。役割が異なるため、競合せず組み合わせて使うことができます。

ロックより

「窓口のない建物に、訪問者を通してはいけない。全部の部屋の住所を渡された者は、やがて住所の管理で疲弊する。受付を一人立てれば、変更は受付の机の上で止まる。クライアントは経路だけを知り、部屋の数を知らなくていい。スプレッドシートを閉じたまえ、ワトソン君」

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