Amplify CLIで作ったAppSyncエンドポイントからLambdaを起動する

# この記事について

この記事は [AWS AmplifyとAWS×フロントエンド Advent Calendar 2022](https://qiita.com/advent-calendar/2022/amplify) に向けて書かれた記事です。

AmplifyにはAmplify CLIというものがあり、それを使って簡潔なコードを元にAppSyncのエンドポイントやDynamo DBのテーブルといったバックエンドを作成することができます。 Amplify CLIの詳細が気になる方は [公式ドキュメント](https://docs.amplify.aws/cli/) へどうぞ。

公式ドキュメントにも記載されているように、AppSyncのエンドポイントからLambdaを呼び出すことができます。Lambdaを使うと、バックエンドのビジネスロジックを作成することも可能です。
この記事では例を交えながら、Lambdaを使ってバックエンドを作成する方法を紹介します。

# 例題

Spotifyのような音楽ストリーミングサービスを作っているとします。
このサービスではユーザが好きな曲を選んでプレイリストを作成することができるとします。
シンプルなケースでは以下のようなGraph QLスキーマでモデリングすることが可能です。

```graphql
# 曲
type Music @model {
  title: String!
}

# プレイリスト
type Playlist @model {
  title: String!
}

# プレイリストと曲の関連付け
type PlaylistMusic @model {
  playlistId: ID! @primaryKey(sortKeyFields:["order"])
  order: Int! # プレイリスト内での曲の順番
  musicId: ID!
}
```

この例題を元に、問題点とその問題をLambdaを使って解決する方法を紹介します。

## 問題点1　プレイリスト作成時にフロントエンドからのリクエストが多数発生

プレイリストを作成するためには、２つのテーブルに対して複数のアイテムを作成する必要があります。

* `Playlist` テーブル - 必ず一つ作成する
* `PlaylistMusic` テーブル - プレイリストが含む曲の数だけ作成する

TypeScriptでのコードは例えばこうなります。

```ts
const createPlaylist = async (title: string, musicIds: string[]) => {
  const id = uuid()
  await Promise.all([
    API.graphql(graphqlOperation(createPlaylist, {input: { id, title }})),
    ...musicIds.map((musicId, order) => {
      return API.graphql(graphqlOperation(createPlaylistMusic, {
        input: {
          playlistId: id,
          order,
          musicId
        }
      }))
    })
  ]
}
```

例えばプレイリストが50曲を含んでいるとしたら、 `Playlist` の作成で1リクエストと `PlaylistMusic` の作成で50リクエストの合計51リクエストを送る必要があります。
フロントエンドとバックエンドの間の通信にはインターネットを使うことがほとんどだと思うので、パフォーマンスの観点から考えるとなるべくリクエスト数は減らしたいです。
また、次の問題点とも関わってきますが、一部のリクエストだけ成功した時にデータの不整合が発生します。

## 問題点2　プレイリスト編集時にデータ不整合の危険性

プレイリストを編集して曲の順番を入れ替えるケースを考えます。この時にはPlaylistMusicを更新します。
例えば、１曲目と２曲目の順番を入れ替えるとしましょう。0-indexの場合、１曲目の`order`は0です。

1. `order=0`と`order=1`のアイテムを削除
1. `order=1`だったアイテムを`order=0`として作成
1. `order=0`だったアイテムを`order=1`として作成

以下はコードの例です。

```ts
// 1曲目と2曲目を入れ替える
await API.graphql(graphqlOperation(deletePlaylistMusic, {input: {playlistId, order: 0}))
await API.graphql(graphqlOperation(deletePlaylistMusic, {input: {playlistId, order: 1}))
await API.graphql(graphqlOperation(createPlaylistMusic, {input: {playlistId, order: 1, musicId: "元0曲目のID"}))
await API.graphql(graphqlOperation(createPlaylistMusic, {input: {playlistId, order: 0, musicId: "元1曲目のID"}))
```

フロントエンドとバックエンドの通信が不安定な可能性もあります。もし、deleteだけ成功しcreateが失敗するとプレイリストから２曲が削除された状態になってしまいます。

# Lambdaでバックエンド作成

AmplifyでWebサービスを作るとどうしてもフロントエンドにロジックが集中しがちになってしまうと私は思います。これ自体は悪いことではなくて、Amplifyを使うのは少ないエンジニアで新規サービスを早く立ち上げたい時がメインだと思うので、そういった状況ではフロントエンドのコードだけ書けばサービスが作れるというのは素晴らしいことだと考えています。

一方で、現実のサービスを作っていると以上のような問題に遭遇することもあるかと思います。
Amplifyは使い続けたい、なるべく簡単に解決したい、という希望があるならば、**Lambda** を使うことをお勧めします。
この記事の残りの部分では、例に沿ってLambda関数を作成する手順を紹介していきます。

## Amplify CLIでLambda関数作成

CLIからLambda関数を作るには、`$ amplify add function`とコマンドを打てば作成できます。手順は [公式ドキュメント](https://docs.amplify.aws/cli/function/) を参照してください。
この時点ではLambda関数の実装はテンプレートのままでも良いです。
今回作成するLambda関数の名前は`playlistBackend`にします。

## AppSyncとLambdaを接続

続いて、AppSyncのエンドポイントからLambdaを呼び出せるようにします。

スキーマファイルに`Mutation`を追加して、`@function`ディレクティブを使います。手順: [公式ドキュメント](https://docs.amplify.aws/cli/graphql/custom-business-logic/#lambda-function-resolver)
今回はプレイリストの作成と更新をするミューテーションをそれぞれ`createPlaylistLambda`、`updatePlaylistLambda`と命名してみました。これらをLambda関数に繋ぎます。

```graphql
type Mutation {
  createPlaylistLambda(input: CreatePlaylistLambdaInput): Playlist @function(name: "playlistBackend-${env}")
  updatePlaylistLambda(input: UpdatePlaylistLambdaInput): Playlist @function(name: "playlistBackend-${env}")
}
```

ミューテーションの引数はスキーマファイルの中で定義する必要があります。
2つの引数はこのように定義しました。
```graphql
input CreatePlaylistLambdaInput {
  title: String!
  musicIds: [ID!]!
}

input UpdatePlaylistLambdaInput {
  id: String!
  musicIds: [ID!]
}
```

引数を定義するときは`type`ではなく`input`キーワードを使わないといけないことに注意が必要です。

## Lambdaでロジックを実装

それでは最後にLambda関数にロジックを実装していきます。LambdaではJavaScript, Pythonなどいくつかの言語が使えます。
今回のサンプルコードはJavaScriptで書きます。ESM形式を使います。
まず初めにコードを紹介します。

```js

export const handler = async (event) => {
  switch (event.fieldName) {
    case "createPlaylistLambda":
      return await handleCreate(event)
    case "updatePlaylistLambda":
      rerturn await handleUpdate(event)
  }
}

const hendleCreate = (event) => {
  // ...
  // AppSync経由もしくはDynamo DBを直接操作して、PlaylistとPlaylistMusicを作る

  return {
    id
    title
  } // 作成されたPlaylistを返す
}

const handleUpdate = (event) => {
  // ...
  // PlaylistとPlaylistMusicを更新する

  return {
    id
    title
  } // 更新されたPlaylistを返す
}
```

呼び出されたミューテーションを判定するには`event.filedName`を見るとわかります。
Lambda関数はこのように似た処理をするクエリとミューテーションをまとめて１つのLambda関数としてデプロイすることを以下の理由からお勧めします。

* コールドスタートが起こる可能性を下げられる
  * Lambdaは内部ではコンテナとして動いており、コンテナは永続的ではなく作られたり廃棄されたりを繰り返します。Lambdaが呼び出された時にコンテナが存在していないと、コンテナを立ち上げる処理も入るので時間がかかります。この現象は**コールドスタート**と呼ばれています。
  * この記事の例のように、2つのミューテーションを同じLambda関数としてデプロイすると、コールドスタートが起こる可能性が下がり平均リクエスト速度の短縮が期待できます。
* コードの重複を防げる
  * create処理とupdate処理では似たようなコードが登場することも多いです。１つのLambdaとしてデプロイすることで共通化が可能です。
  * **Lambda Layer**を使えばLambda関数間でもコードを共有することは可能ですが、createとupdateのような同時に変更する可能性が高い処理であればわざわざLayerを作るメリットは無いのかなと思います。

# まとめ
この記事ではLambdaを使ってAmplifyバックエンドを作成する方法を紹介しました。
基本はフロントエンドの実装だけで済ませるんだけど、やっぱりバックエンドの実装も必要になる時ってあると思います。
AmplifyとAppSync GraphQLの開発速度は活かしたままバックエンドを作る方法としてLambdaを使えます。
ぜひ試してみてください。

しいて言えばLambdaもJavaScriptではなくTypeScriptで実装したい。ドキュメントにはTypeScriptを使う例も載っているんですが、Layerを入れた時の扱いが分からず挫折しています。知見を持っている方いらっしゃれば教えてほしいです。