はじめに
この記事は フリュー Advent Calendar 2023 の6日目の記事となります
サーバレスアーキテクチャでアプリのバックエンドを開発しています。
今回AWS Lambda環境でTypeScriptを用いてミドルウェアパターンを実装したのでどうしてそうしたかったのかどう実装したのか紹介します。
問題
handlerの一連の処理の中でアプリのバージョンチェックやメンテナンスチェックなどをしています。
このチェックはほぼどのAPIでも共通処理となっています。
チェック処理が増えるとその分責務が曖昧になったり、同一コードが増えるなど課題があると思っていました。
export const lambdaHandler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
if (isUnderMaintenance()) {
return MaintenanceResponse;
}
if (!isSupportedAppVersion()) {
return UnsupportedAppVersionResponse;
}
return {
statusCode: 200,
body: JSON.stringify({
message: 'hello',
}),
};
};
対応方法
ミドルウェアパターンの適用を検討しました。
ミドルウェアパターンはリクエスト/レスポンス処理においてパイプラインのように組み立てられリクエストやレスポンスに処理を加えられます。
さきほどの例で言うと下記の変更を加えます。
- チェック処理を別関数にミドルウェアとして切り出す
- 本処理をミドルウェアの分だけラップする
- ラップした関数を実行する
こうすることで各チェックの責任をミドルウェア側に移動し、各APIはそれを利用するだけにしました。
実装
コードは下記の通りです。
// API Gateway + Lambdaでのエンドポイントの関数の型
export type ApiRequestHandler = (event: APIGatewayProxyEvent) => Promise<APIGatewayProxyResult>
export interface Middleware {
type: 'preprocessor' | 'postprocessor';
wrap: (handler: ApiRequestHandler) => ApiRequestHandler;
}
// 前処理型
export interface Preprocessor extends Middleware {
type: 'preprocessor';
}
// 後処理型
export interface Postprocessor extends Middleware {
type: 'postprocessor';
}
前処理なのか後処理なのかをわかりやすくするためにtype
を持たせました。
export interface MiddlewareWrapperInput {
preprocessors: Preprocessor[],
core: ApiRequestHandler,
postprocessors: Postprocessor[],
}
export const middlewareWrapper = {
wrap(input: MiddlewareWrapperInput): ApiRequestHandler {
const preProcessesWrapped = input.preprocessors.reduceRight((handler, middleware) => middleware.wrap(handler), input.core)
return input.postprocessors.reduce((handler, middleware) => middleware.wrap(handler), preProcessesWrapped)
}
}
各ミドルウェアは配列に記載した順に適用されるようにしたかったのでpreprocessors
はreduceRight
にしています。
export const maintenanceChecker: Preprocessor = {
type: 'preprocessor',
wrap: (apiRequestHandler: ApiRequestHandler): ApiRequestHandler => {
return (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
// 付加された前処理
if (isMaintenance()) {
return Promise.resolve(MaintenanceResponse)
}
// 元の処理
return apiRequestHandler(event)
}
},
}
export const supportedAppVersionChecker: Preprocessor = {
type: 'preprocessor',
wrap: (apiRequestHandler: ApiRequestHandler): ApiRequestHandler => {
return (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
// 付加された前処理
if (!isSupporttedAppVersion()) {
return Promise.resolve(UnsupportedAppVersionResponse)
}
// 元の処理
return apiRequestHandler(event)
}
},
}
// 本来やりたかった処理を分離独立
const coreProcessor = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
return {
statusCode: 200,
body: JSON.stringify({
message: 'hello',
}),
};
};
// middlewareを適用したhandlerを作成
const wrappedHandler = middlewareWrapper.wrap({
preprocessors: [maintenanceChecker, supportedAppVersionChecker],
core: coreProcessor,
postprocessors: [],
});
// エンドポイント
export const lambdaHandler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
return await wrappedHandler(event);
};
middlewareWrapper
を作らなくてもmaintenanceChecker.wrap(supportedAppVersionChecker.wrap(coreProcessor))
とネストしても書けますが今後増えたときにわかりにくそうなのでまとめる存在を用意しました。
動作確認
各処理の中にconsole.log
出力を追加して動いているか動作確認します。
% curl http://127.0.0.1:3000/hello
{"message":"hello"}
sam local start-api // AWS SAMでローカルエミュレート
(起動ログ省略)
Invoking app.lambdaHandler (nodejs18.x)
Reuse the created warm container for Lambda function 'HelloWorldFunction'
Lambda function 'HelloWorldFunction' is already running
2023-12-05T04:53:51.778Z 43c19670-0f0e-4fb5-afcc-ac013cb2cf9b INFO メンテナンスチェックをしました
2023-12-05T04:53:51.779Z 43c19670-0f0e-4fb5-afcc-ac013cb2cf9b INFO バージョンチェックをしました
2023-12-05T04:53:51.779Z 43c19670-0f0e-4fb5-afcc-ac013cb2cf9b INFO コア処理をしました
END RequestId: 43c19670-0f0e-4fb5-afcc-ac013cb2cf9b
REPORT RequestId: 43c19670-0f0e-4fb5-afcc-ac013cb2cf9b Init Duration: 0.31 ms Duration: 250.54 ms Billed Duration: 251 ms Memory Size: 128 MB Max Memory Used: 128 MB
期待通りの順番で動いているようです。
まとめ
Lambda環境のTypeScriptでのミドルウェアパターン適用例を紹介しました。
よくある形としては
MiddlewareManager.use(fooMiddleware).use(barMiddleware).dispatch()
のようなイメージがありますが今回は簡単なWrapperを追加する形で実現しました。
実際のプロダクトではミドルウェアとして処理を分離できそうなところがまだあるのでさらに引き剥がしていきたいです。
そうすると元々のhandlerの仕事はUsecase層へのInput作成/Output処理だけになりそうです。
また関数分離して単体テストも書きやすくなったのも嬉しかったです。
参考資料
Node.jsデザインパターン 6.9 ミドルウェア