メインコンテンツまでスキップ

Chain of Responsibility — ミドルウェアという形

ミドルウェアの合成と、型による文脈の受け渡しを扱います。

このパターンが解こうとした問題

GoF は Chain of Responsibility を、要求を処理オブジェクトの連なりへ委ねていくパターンとして収録しています1

送り手は誰が処理するかを知らず、連なりの先頭に渡すだけです。各段は「自分で処理する」か「次へ渡す」かを決めます。認証・ログ・レート制限のように、本来の処理の前後に差し込みたい関心を並べる用途で今もよく使われます。

このパターンは、名前よりも ミドルウェア という呼び名のほうが通りがよくなりました。ただし両者は完全に同じではありません。Chain of Responsibility は、要求が連なりを進んでどこかの受け手が処理した時点で止まる形です1。後処理という段はこの形には現れません。ミドルウェアは次を呼んだあとに戻ってきて処理を続けられます。手渡していく骨格は共通で、戻りの扱いが足されていると見るのが正確です。

クラスで素直に書く

各段をクラスにして、次の段への参照を持たせます。

type AppRequest = {url: string; headers: Record<string, string>};

abstract class Handler {
private nextHandler: Handler | undefined;

setNext(handler: Handler): Handler {
this.nextHandler = handler;
return handler; // 連結を書きやすくするため次を返す
}

handle(request: AppRequest): Response {
if (this.nextHandler === undefined) {
return new Response('not handled', {status: 404});
}
return this.nextHandler.handle(request);
}
}

class AuthHandler extends Handler {
override handle(request: AppRequest): Response {
if (request.headers.authorization === undefined) {
return new Response('unauthorized', {status: 401});
}
return super.handle(request);
}
}

class LoggingHandler extends Handler {
override handle(request: AppRequest): Response {
console.log(`-> ${request.url}`);
return super.handle(request);
}
}

// 連なりの末端に本来の処理を置く。置き忘れると 404 が返る
class AppHandler extends Handler {
override handle(_request: AppRequest): Response {
return new Response('ok');
}
}

const auth = new AuthHandler();
auth.setNext(new LoggingHandler()).setNext(new AppHandler());

const response = auth.handle({url: '/articles', headers: {authorization: 'Bearer x'}});

動きますが、連なりが可変の状態として組み立てられています。setNext を呼ぶ順序で挙動が決まり、組み立て後にどういう並びになったかは型に現れません。

言語機能で置き換える

各段を「文脈と次の段を受け取る関数」にします。これが Node.js のフレームワークで広く使われている形です。

type AppRequest = {url: string; headers: Record<string, string>};
type Next = () => Response;
type Middleware = (request: AppRequest, next: Next) => Response;

const auth: Middleware = (request, next) =>
request.headers.authorization === undefined
? new Response('unauthorized', {status: 401})
: next();

const logging: Middleware = (request, next) => {
console.log(`-> ${request.url}`);
return next();
};

function compose(middlewares: Middleware[], final: (request: AppRequest) => Response) {
return (request: AppRequest): Response => {
const run = (index: number): Response =>
index === middlewares.length
? final(request)
: middlewares[index](request, () => run(index + 1));
return run(0);
};
}

const handle = compose([auth, logging], () => new Response('ok'));
const response = handle({url: '/articles', headers: {authorization: 'Bearer x'}});

抽象クラスも setNext も消え、並びは配列リテラルとして 1 か所に書かれます。組み立てが式になったので、どの順で並んでいるかがその場で読めます。

段が文脈を足すとき

ミドルウェアはよく、後続のために情報を足します。認証が済んだら userId を後続に渡したい、という形です。ここで型を効かせられます。

type AppRequest = {url: string; headers: Record<string, string>};

// 次の段が受け取る文脈の型を、段ごとに変えられるようにする
type Step<In, Out> = (context: In, next: (context: Out) => Response) => Response;

const auth: Step<AppRequest, AppRequest & {userId: string}> = (context, next) => {
const token = context.headers.authorization;
if (token === undefined) {
return new Response('unauthorized', {status: 401});
}
return next({...context, userId: token.replace('Bearer ', '')});
};

const handle = (request: AppRequest): Response =>
auth(request, (context) => {
// ここでは userId が必ずあることが型でわかる
return new Response(`hello ${context.userId}`);
});

const response = handle({url: '/articles', headers: {authorization: 'Bearer u1'}});

auth を通ったあとの文脈には userId があると型が知っています。認証前のコードから context.userId を読もうとすれば、コンパイル時に止まります。本章のクラス版では、この積み上げを表していません — 表そうとすれば Handler に型引数を持たせることになり、関数の版と同じ話に行き着きます。

本連載の判断

判定: 言語機能で代替できる。 段をクラスにして setNext で連結する形を選ぶ理由は、TypeScript では見当たりません。関数と配列で書くほうが短く、並びが式として読めます。

そのうえで、文脈の型を段ごとに積み上げるかどうかが実務上の分かれ目になります。

  • 段が文脈を足さない (ログ、計測、レート制限) — 上の Middleware の形で足ります。全段が同じ文脈の型を見ます。
  • 段が文脈を足す (認証、テナント解決、本文の解析)Step<In, Out> の形にして、後続が受け取る型を段ごとに変えます。

この判定が覆る条件

型を積み上げる書き方は、素朴な合成関数に通すと崩れますcompose(middlewares: Middleware[]) のように単一の型の配列で受けた場合、段ごとに違う型を要素型として表せないため、共通の型に潰れます。上の例で auth を直接呼んで入れ子にしているのはそのためです。

これは言語の限界ではありません。可変長タプル型に再帰的な条件型を組み合わせると、配列のまま段ごとの型をつなぐ合成関数が書けます (型定義が長くなるため本章では示しません)。段の連結をメソッドチェーンで書く形 (.use(auth).use(tenant)) を採っているライブラリもあり、こちらは入れ子も深くなりません。代償はいずれも、その仕組みを支える型定義が読みにくくなることです。

**選ぶのは「型の積み上げを取るか、合成の型定義の読みやすさを取るか」**であって、両立しないわけではありません。

もう 1 つ、非同期の段が混ざるなら戻り値を Promise<Response> に統一します。同期と非同期が混在すると、next() の戻り値の扱いが段ごとに変わって読みにくくなります。

既存記事との関係

  • プレゼンテーション層 が Laravel のミドルウェアに触れています。あちらは PHP のフレームワークが提供する仕組みの使い方、本章は TypeScript で同じ構造を自分で組む話です。言語も対象も異なります。
  • 関数の応用 が高階関数の書き方を扱っています。
  • Decorator も関数で包む構造ですが、あちらは 1 つの処理に振る舞いを足す形、こちらは複数の段を順に通す形です。

Footnotes

  1. Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides『Design Patterns: Elements of Reusable Object-Oriented Software』(Addison-Wesley, 1994)。Chain of Responsibility は振る舞いに関するパターンとして収録されています。意図の要約は本ガイドによるもので、原典とは照合していません。要求が連なりを進み、どこかの受け手が処理した時点で止まる形であること (「The request gets passed along the chain until a receiver handles the request.」) は二次情報で確認しました。出典: Chain-of-responsibility pattern(Wikipedia)。 2