理系学生日記

おまえはいつまで学生気分なのか

Lambda関数の共通処理のためにTypeScriptでmiddyのミドルウェアを書く

最近ようやく重い腰を上げてTypeScriptでプログラムを書き始めました。 一方で、Lambda関数を多く書く必要も生じてきていて、その結果としてLambda関数で使い回す処理は共通化したくなってきます。

これを目的として、Lambda関数用のミドルウェアエンジンであるmiddyを使い始めました。

middyとは

前述のとおり、middyはLambda関数用のミドルウェアエンジンです。

百聞は一見にしかずで、例えば以下のような業務ロジックをLambda関数のハンドラがあるとします。 API Gatewayから呼び出されるLambda関数をイメージしていますが、ここにはバリデーションやエラーハンドリングは含まれません。

// This is your common handler, in no way different than what you are used to doing every day in AWS Lambda
const lambdaHandler = async (event, context) => {
 // we don't need to deserialize the body ourself as a middleware will be used to do that
 const { creditCardNumber, expiryMonth, expiryYear, cvc, nameOnCard, amount } = event.body

 // do stuff with this data
 // ...

 const response = { result: 'success', message: 'payment processed correctly'}
 return {statusCode: 200, body: JSON.stringify(response)}
}

上記のような純粋な業務ロジックハンドラを、ハンドラバリデーションやエラーハンドリングを行うLambda関数にラップするのがmiddyです。 全コードはmiddyのA quick exampleを参照いただきたいのですが、 以下のようにして共通処理でラップされたハンドラを作れます。middyではこのラッピングをmiddifyと読んでいます。

// Let's "middyfy" our handler, then we will be able to attach middlewares to it
const handler = middy()
  .use(jsonBodyParser()) // parses the request body when it's a JSON and converts it to an object
  .use(validator({eventSchema})) // validates the input
  .use(httpErrorHandler()) // handles common http errors and returns proper responses
  .handler(lambdaHandler)

middyが実施すること

middyの挙動をわかりやすく示す図がHow it worksに記載されています。

middyのミドルウェアは、実質的にリクエスト処理フェーズ(before)、レスポンス処理フェーズ(after)、エラー処理フェーズ(onError)の3つの関数を持てるオブジェクトです。 middyでは、このミドルウェアを順に適用することで、業務ロジックを玉ねぎのようにラップしていけます。 したがって、共通処理をmiddyのミドルウェアとして提供しさえすれば、開発者は業務ロジックの開発へ専念できるようになります。

TypeScriptへの対応状況

公式でもTypeScriptに対応していることは宣言されています。

Middy can be used with TypeScript with typings built in in every official package.

Use with TypeScript

一方で、middy開発チームはTypeScriptの専門家ではないということも記載されており、あまり期待値は上げない方が良さそうです。 ただ、私が使う限りにおいてはそれほど問題を感じませんでした。

The Middy core team does not use TypeScript often and we can't certainly claim that we are TypeScript experts. We tried our best to come up with type definitions that should give TypeScript users a good experience. There is certainly room for improvement, so we would be more than happy to receive contributions 😊

With TypeScript

提供されるミドルウェア

公式に提供されるミドルウェアの一覧はOfficial middlewaresに存在します。 また、各ミドルウェアの実装は公式リポジトリにあり、実装もかなり軽いので、コードを見ていただくと理解が早そうです。

個人の感想になりますが、既存のミドルウェアの多くはAPI Gatewayと連携して使うことを意図しているものが多いと感じます。 ぼくはAppSyncのDirect Lambda Resolverでmiddyを使うので、 多くのミドルウェアは自作しなければなりませんでした。

ただ、ミドルウェアエンジンを正確に作っていくのは骨の折れる作業なので、自作しないで良いのは楽です。

実際にミドルウェアを書いてみる

middy 3.1.0のミドルウェアのInterfaceは以下のように定義されます。

export interface MiddlewareObj<TEvent = any, TResult = any, TErr = Error, TContext extends LambdaContext = LambdaContext> {
  before?: MiddlewareFn<TEvent, TResult, TErr, TContext>
  after?: MiddlewareFn<TEvent, TResult, TErr, TContext>
  onError?: MiddlewareFn<TEvent, TResult, TErr, TContext>
}

これを受けて、zodを使ったバリデーション用ミドルウェアを書くと、以下のように書けました。 このミドルウェアを使って業務ロジックのハンドラをmiddifyすれば、業務ロジック側ではバリデーションが必要なくなります。

import middy from "@middy/core";
import { AppSyncResolverEvent } from "aws-lambda";
import { ZodError, ZodTypeAny } from "zod";

type options<Z extends ZodTypeAny> = {
  schema: Z;
};

export const zodValidator = <T, Z extends ZodTypeAny>(
  opts: options<Z>
): middy.MiddlewareObj<AppSyncResolverEvent<T>> => ({
  before: (request) => {
    const { schema } = opts;

    // NG だった場合は ZodError が throw される
    try {
      schema.parse(request.event.arguments);
    } catch (e: unknown) {
      if (e instanceof ZodError) {
        throw new ValidationError(validationErrorMessage(e));
      }
    }
  },
});

感想

共通ロジックをうまく隠蔽できるというのはミドルウェアパターンの旨味ですが、Lambda関数用のミドルウェアとしてはかなり使いやすく感じました。 ミドルウェアがシンプルなのも良いですね。 エンジン実装部分も読みやすく、内部の動きもわかりやすいです。

チームとしてLambda関数をどう効率的に書いていくか、というのは初めて取り組んだ課題だったのですが、 middyは1つの解のように感じました。