Lambda環境のTypeScriptでミドルウェアパターンを適用した

## はじめに

この記事は [フリュー Advent Calendar 2023](https://qiita.com/advent-calendar/2023/furyu) の6日目の記事となります
サーバレスアーキテクチャでアプリのバックエンドを開発しています。
今回AWS Lambda環境でTypeScriptを用いてミドルウェアパターンを実装したのでどうしてそうしたかったのかどう実装したのか紹介します。

## 問題

handlerの一連の処理の中でアプリのバージョンチェックやメンテナンスチェックなどをしています。
このチェックはほぼどのAPIでも共通処理となっています。
チェック処理が増えるとその分責務が曖昧になったり、同一コードが増えるなど課題があると思っていました。

```typescript:イメージ
export const lambdaHandler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
    if (isUnderMaintenance()) {
        return MaintenanceResponse;
    }
    if (!isSupportedAppVersion()) {
        return UnsupportedAppVersionResponse;
    }
    return {
        statusCode: 200,
        body: JSON.stringify({
            message: 'hello',
        }),
    };
};
```

## 対応方法

ミドルウェアパターンの適用を検討しました。
ミドルウェアパターンはリクエスト/レスポンス処理においてパイプラインのように組み立てられリクエストやレスポンスに処理を加えられます。

さきほどの例で言うと下記の変更を加えます。
1. チェック処理を別関数にミドルウェアとして切り出す
2. 本処理をミドルウェアの分だけラップする
3. ラップした関数を実行する

こうすることで各チェックの責任をミドルウェア側に移動し、各APIはそれを利用するだけにしました。

### 実装

コードは下記の通りです。

```typescript:ミドルウェアの型定義

// 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`を持たせました。

```typescript:ミドルウェアをまとめるラッパー
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`にしています。

```typescript:個別のミドルウェア例
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)
        }
    },
}
```

```typescript:利用例
// 本来やりたかった処理を分離独立
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`出力を追加して動いているか動作確認します。

```bash:request
% curl  http://127.0.0.1:3000/hello
{"message":"hello"}
```

```bash:ログ
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 ミドルウェア