
AWSのラムダTypeScript関数をミドルウェアで簡素化する方法
サーバーのコードを書くのは面倒なものです。
実際のビジネスロジックに加えて、ヘッダー、コール、セキュリティ、バリデーションなどにも気を配る必要があります。
AWS lambdaでサーバーレスの世界に移行しても、この責任がなくなるわけではありません。
サーバーの場合、これらは通常、ミドルウェアのパターンで解決されます。
例えばAWS lambdaの場合は、複数の方法で対処します。
- lambdaミドルウェアを手動で書く
- lambdaミドルウェアを使う
- middyミドルウェアを使う
- APIゲートウェイの利用
手動でラムダミドルウェアを書く
簡単な例を使って説明してみましょう。
2つの数字の和を返すエンドポイントはこんな感じです。
import { APIGatewayProxyResult, APIGatewayEvent, Context } from "aws-lambda";
export async function add(event: APIGatewayEvent, context: Context): Promise<APIGatewayProxyResult> {
const { a, b } = JSON.parse(event.body ?? "{}");
const sum = a + b;
return {
statusCode: 200,
body: JSON.stringify({ result: sum })
}
}
これでヘッダーやバリデーションの処理をしていないにもかかわらず、 すでに2つのミドルウェアを抽出することができました。
import { APIGatewayProxyResult, APIGatewayEvent, Context } from "aws-lambda";
type Summands = {
a: number;
b: number;
};
async function sum({ a, b }: Summands): Promise<{ result: number }> {
return { result: a + b };
}
function inputParser<Result>(
handler: ({ a, b }: Summands) => Promise<Result>
): (event: APIGatewayEvent) => Promise<Result> {
return (event: APIGatewayEvent) => {
const { a, b } = JSON.parse(event.body ?? "{}");
return handler({ a, b });
};
}
function jsonSerializer<Event>(
handler: (event: Event) => Promise<object>
): (event: Event) => Promise<APIGatewayProxyResult> {
return async (event: Event) => {
return {
statusCode: 200,
body: JSON.stringify(await handler(event)),
};
};
}
export const add: (
event: APIGatewayEvent,
context: Context
) => Promise<APIGatewayProxyResult> = jsonSerializer(inputParser(sum));
この例を目の前にして、私たちは疑問を抱くかもしれません。
ここでいうミドルウェアとは何でしょうか?
それは最も単純な形で、ハンドラ関数を受け取り、拡張されたハンドラ関数を返す高次関数です。
例:wrapApiResponseは、オブジェクトを返すハンドラを受け取り、ApiGatewayProxyResultを返すハンドラに変換するような。
Pro
- ミドルウェアの機能を完全にコントロールできる
- 不要なコードの肥大化がない
Contra
- ミドルウェアの作成に時間がかかる
- カスタムソリューションは安全ではないかもしれない
ラムダミドルウェアの使用
上記の例を、ラムダミドルウェアを使うとどのようになるでしょうか?
import { APIGatewayProxyResult, APIGatewayEvent, Context } from "aws-lambda";
import { IsNumber } from "class-validator";
import { compose } from "@lambda-middleware/compose";
import { classValidator } from "@lambda-middleware/class-validator";
import { errorHandler } from "@lambda-middleware/http-error-handler";
import { jsonSerializer } from "@lambda-middleware/json-serializer";
class Summands {
@IsNumber()
a!: number;
@IsNumber()
b!: number;
}
async function sum({
body: { a, b },
}: {
body: Summands;
}): Promise<{ result: number }> {
return { result: a + b };
}
export const add: (
event: APIGatewayEvent,
context: Context
) => Promise<APIGatewayProxyResult> = compose(
errorHandler(),
jsonSerializer(),
classValidator({ bodyType: Summands })
)(sum);
jsonSerializerは、上記のカスタムメイドのソリューションが行うこととほぼ同じことを行いますが、
値がapplication/jsonのContent-Typeヘッダーも追加します。
classValiadorは、上記のinputParserをより精巧にしたものです。
class-validatorライブラリとSummandsクラスを利用して、入力を検証し、
aとbが実際に数字に設定されているかどうかを確認します。
JSON.parseはただ単にany typeを返すだけですが、このミドルウェアはevent.bodyを正しくタイプすることで、
検証済みのデータのみに依存することを保証します。
errorHandlerは、バリデーションが失敗したときにclassValidatorから投げられるバリデーションエラーをhttpレスポンスに変換するために必要です。
Pro
書くコードが減る
無料で機能を追加
型の安全性が高い
Contra
必要のないコードを含まざるを得ない場合がある(この場合は検証やエラー処理)。
他の人が書いたコードを理解しなければならないことがある
ミディミドルウェアの利用
AWSラムダのためのもう一つのミドルウェアフレームワークがmiddyです。同じ機能でも、以下のようになります。
import { APIGatewayEvent, APIGatewayProxyResult, Callback, Context } from 'aws-lambda'
import middy from "@middy/core";
import jsonBodyParser from "@middy/http-json-body-parser";
import httpErrorHandler from "@middy/http-error-handler";
import responseSerializer from "@middy/http-response-serializer";
import validator from "@middy/validator";
interface Summands {
a: number;
b: number;
}
const summandsSchema = {
type: "object",
properties: {
body: {
type: "object",
properties: {
a: { type: "number" },
b: { type: "number" },
},
required: ["a", "b"],
},
},
};
async function sum({
body: { a, b },
}: {
body: Summands;
}): Promise<{ result: number }> {
return { result: a + b };
}
export const add: (
event: APIGatewayEvent,
context: Context,
callback: Callback
) => Promise<APIGatewayProxyResult> | void = middy(
(sum as unknown) as (event: APIGatewayEvent) => Promise<APIGatewayProxyResult>
)
.use(jsonBodyParser())
.use(validator({ inputSchema: summandsSchema }))
.use(responseSerializer({
serializers: [{
regex: /^application\/json$/,
serializer: ({ body }) => JSON.stringify(body)
}],
default: 'application/json'
}))
.use(httpErrorHandler());
middyの場合、ミドルウェアは.useで追加されるカスタムフォーマットで定義されています。これは残念ながら、TypeScriptがミドルウェアから型付けを推測できないため、ハンドラの型付けを強制する必要があることを意味します。
JSONスキーマを使った検証は、非同期の検証が使えないなど、少し制限があります。しかし、パフォーマンスは格段に向上しています。
Pro
書くコードが少ない
無料で機能を追加
多くの既存ミドルウェアの中から選べる
Contra
タイピングのサポートが不十分
必要のないコードを入れざるを得ないことがある(ここでは検証とエラー処理)。
他の人が書いたコードを理解しなければならないことがある
APIゲートウェイの利用
これまで見てきた機能のほとんどは、AWS API Gatewayを使うことでも解決できます。ハンドラを見てみましょう。
interface Summands {
a: number;
b: number;
}
export async function sum({
a,b
}: Summands): Promise<{ result: number }> {
return { result: a + b };
}
また、関連するサーバーレスの構成
functions:
create:
handler: handlers.add
events:
- http:
path: /
method: post
request:
schema:
application/json:
definitions: {}
$schema: http://json-schema.org/draft-04/schema#
type: object
title: Summands
required: ["a", "b"],
properties:
a:
type: "number"
b:
type: "number"
template:
application/json: '#set($body = $util.parseJson($input.body)) {"a": $body.a, "b": $body.b}'
Lambdaのバージョン2では、JSONオブジェクトを直接返すことができ、statusCodeが定義されていない限り、ApiGatewayで文字列化されます。
定義のスキーマに基づいてAPI Gatewayで検証が行われ、テンプレートを使って関連情報が抽出されます。
Pro
- 書くコードが少ない
- リクエストはAPIゲートウェイで停止されるため、Lambdaの呼び出しにかかるコストを削減できる
- ビジネスロジックがミドルウェアから強く分離される
Contra
- コードベースのソリューションに比べて機能が制限される
- 強力な型付けができない
- コーディングだけでなく、Infrastructure-as-codeも理解しなければならない
どのミドルウェアを使うべきか?
このように、ミドルウェアはラムダコードを簡素化し、実際のビジネスロジックを可視化します。ミドルウェアは状況に応じて使い分ける必要があります。個人的には、AWSを最大限に活用するためのAPI Gateway構成か、AWSでサポートされていない機能のための関数ベースのミドルウェアのどちらかを使いたいと思っています。