DynamoDBを使って外部APIのレスポンスをキャッシュする仕組みを作った

DynamoDBについてTTLを設定したテーブルでAPIのレスポンスをキャッシュするという案を考え、実装したときの備忘録。

※AWSでサーバーレスなアプリケーションを組む際に有効な選択肢となるAPI Gatewayにもキャッシュする仕組みがありますが、今回は事前にアプリケーション内でLambdaの関数URLを使っていたこともあり採用できませんでした

## 外部APIについて
- データが頻繁に更新されるようなAPIではない
- 1日当たりの1000回までのリクエスト制限がある

## DynamoDBを選んだ理由
- AWSの無料枠内で十分なものを構築できる
    - 読み取りキャパシティユニット（WCU）、書き込みキャパシティユニット（RCU）ともにそれぞれ月25ユニットまで無料（2023年11月現在）
    - [プロビジョニング済みキャパシティーの料金 - Amazon DynamoDB | AWS](https://aws.amazon.com/jp/dynamodb/pricing/provisioned/)
- TTL（TimeToLive）機能を設定することで指定時間を過ぎた項目について自動で削除できる
    - 今回のキャッシュという目的に合致している

## アプリケーションについて
今回の話からは外れるので関係してそうな箇所を少しだけ
- Nuxt3を使用
    - `NITRO_PRESET=aws-lambda`を指定してビルドしたものをServerless Frameworkを使用してAWSにデプロイ
    - 静的コンテンツ（ビルドによって生成された`_nuxt`フォルダ、`favicon.ico`、`images`以下の画像ファイルなどドキュメントルート以下に配置するリソース等）は[serverless-s3-sync](https://www.serverless.com/plugins/serverless-s3-sync)プラグインを使用してS3にアップロード
    - リクエストはCloudFrontが処理
    - CloudFrontディストリビューションは以下の2つのオリジンを持つ
        - Lambdaの関数URLを向いたnuxt-ssr-engineオリジン
        - S3のバケットを向いたnuxt-static-resourcesオリジン

ビヘイビアの設定は以下のように静的コンテンツへのリクエストとのマッチを優先しています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2384986/83a0a111-6e44-b1c7-217d-203a6a1f7cc1.png)

## DynamoDBテーブルについて
Serverless Frameworkを使っているため、serverless.ymlの中で以下のようなCloudFormationテンプレートを元に作成しています。
`※リソース名やテーブル名は記事用に書き換えてます`
```yml:serverlss.yml
resources:
  Resources:
    # 外部APIのレスポンスをキャッシュするためのDynamoDBテーブル
    APICacheDynamoDBTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: api-cache
        AttributeDefinitions:
          - AttributeName: request_identifier
            AttributeType: S
        KeySchema:
          - AttributeName: request_identifier
            KeyType: HASH
        BillingMode: PROVISIONED
        ProvisionedThroughput:
          ReadCapacityUnits: 5
          WriteCapacityUnits: 5
        TimeToLiveSpecification:
          AttributeName: ttl
          Enabled: true
```

属性について
- request_identifier
    - APIのパスとクエリパラメータを合わせた文字列を格納します
    - 具体的には以下のようなデータが入ります
        - /api/v1/prefectures
        - /api/v1/cities
        - /api/v1/population/composition/perYear?prefCode=1&cityCode=01100
- response_data
    - serverless.ymlの中の定義には含まれませんが、response_dataという属性に外部APIのレスポンスJSONを格納します
    - DynamoDBテーブルの操作は[AWS-SDK for javascript v3](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/)の[@aws-sdk/client-dynamodb](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-dynamodb/)を使用
        - 外部APIのレスポンスJSONがリストのとき、response_data属性の型は`L`
        - 外部APIのレスポンスJSONがオブジェクトのとき、response_data属性の型は`M`
        - [Amazon DynamoDB でサポートされるデータ型と命名規則 - Amazon DynamoDB](https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/HowItWorks.NamingRulesDataTypes.html#HowItWorks.DataTypeDescriptors)
    - レスポンスJSONはそのままではなく[@aws-sdk/util-dynamodb](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/modules/_aws_sdk_util_dynamodb.html)のmarshallを使用し、DynamoDB固有のJSON形式へ変換する必要がありました
- ttl
    - TimeToLiveSpecificationに設定しているttl属性
    - unixタイムスタンプを格納します

---
その他、外部APIについて日当たりのリクエスト数の制限があるので、日別の外部APIへのリクエスト数を管理するテーブルも作成しました。

```yml:serverlss.yml(抜粋)
resources:
  Resources:
    # 外部APIへのリクエスト数を日別で管理するDynamoDBテーブル
    APIRequestsDynamoDBTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: api-requests
        AttributeDefinitions:
          - AttributeName: date
            AttributeType: N
        KeySchema:
          - AttributeName: date
            KeyType: HASH
        BillingMode: PROVISIONED
        ProvisionedThroughput:
          ReadCapacityUnits: 1
          WriteCapacityUnits: 1
```

属性について
- date
    - yyyymmddの日付文字列を数値の属性として格納します
- request_count
    - リクエスト数を格納します
    - UpdateTableコマンドを使用してインクリメントしています

## 修正前後のイメージ図
S3バケットとか端折ってますが今回の修正に関わる箇所は以下のような感じ
![sakai-nuxt改修前後のイメージ.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2384986/285fdfc6-19a6-6233-866c-4a83a5d4e840.png)

- 修正前
    - リクエストの度、外部APIにリクエスト
- 修正後
    - ①DynamoDBテーブルにデータがあるかチェック（GetItem）
    - ②データが見つかったらそのデータをそのままユーザーに返す（③,④を経由しない）
    - ③,④を経由した際はLambdaに返ってきたときにDynamoDBにキャッシュする（PutItem）

## 開発環境について
元々Nuxt3のアプリケーションを開発するのにDockerを使用した開発を構築していたのでそこに追加する形で[dynamodb-local](https://hub.docker.com/r/amazon/dynamodb-local)のDockerイメージを使用しています。

```yml:docker-compose.development.yml
services:
  dynamodb:
    image: amazon/dynamodb-local
    command: -jar DynamoDBLocal.jar -sharedDb -dbPath . -optimizeDbBeforeStartup
    volumes:
      - dynamodb:/home/dynamodblocal
    ports:
      - 8000:8000

  awscli:
    image: amazon/aws-cli
    environment:
      # dynamodbコンテナに対して接続するためのaws-cliなので認証キー/シークレットキーの設定はダミーでOK
      - AWS_ACCESS_KEY_ID=fake_access_key
      - AWS_SECRET_ACCESS_KEY=face_secret_access_key
      - AWS_DEFAULT_REGION=ap-northeast-1
    tty: true
    command:
      - /bin/sh
    entrypoint: [""]

volumes:
  dynamodb:
    driver: local
```
こちらのコンテナ環境、開発を進めるのにとても便利ですが稀に重複できないはずのパーティションキーが重複するといったことが発生しました。。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2384986/597661f2-3c6d-f02e-b32f-57586de8142d.png)
- 本番では発生していないのでとりあえず放置中:triumph:
- ドキュメントあまり細かく読んでないのでもしかしたらdynamodbコンテナのオプションとかで解決できる問題かもしれない？？

---
上記docker-compoese.development.ymlの中でdynamodbというサービス名で動かしているため、Nuxt3のアプリケーションからは`http://dynamodb:8000`というエンドポイントURLでアクセスできます。
本番は当然別のURL(`https://dynamodb.ap-northeast-1.amazonaws.com`)となるため環境変数を使用することで開発環境と本番環境で接続先を切り替えられるようにしています。
```.env.local
# DynamoDBのエンドポイント(開発環境用)
DYNAMODB_ENDPOINT=http://dynamodb:8000
```

## キャッシュする仕組みのサンプルコード
外部APIへのリクエストを行う処理はリクエストに必要となるAPIキーを隠す目的でserver/api配下に構築しています（フロントエンドから直接外部APIへのリクエストは行わない）。
元々、外部APIへのリクエストを行うのに`$fetch.create`から作成できる固有のデータフェッチを行う関数をNuxt3の[server/utils](https://nuxt.com/docs/guide/directory-structure/server#server-utilities)の中に作成していたので、それを拡張する形で実装しました。

以下は抜粋した修正前と修正後のコードです。
一部名称について置き換えているのと、アプリケーション固有の実装(onResponse内のレスポンスデータのエラーハンドリング等)は除いています。
```typescript:server/utils/useHogeApiFetch.ts(修正前)
export const useHogeApiFetch = () => {
  const baseURL = "[外部APIのベースURL]";

  /**
   * 外部APIからのデータ取得を行う
   */
  const hogeApiFetch = $fetch.create({
    baseURL,
    method: "GET",
    onRequest({ options }) {
      options.headers = new Headers(options.headers);
      options.headers.set("X-API-KEY", process.env?.HOGE_API_KEY ?? "");
    },
    onResponse({ request, response }) {
      // アプリケーション固有のエラーハンドリング処理(省略)
      // アプリケーション固有のデータ返却処理(省略)
    },
  });

  return {
    hogeApiFetch,
  };
};
```

```typescript:server/utils/useHogeApiFetch.ts(修正後)
import {
  DynamoDBClient,
  GetItemCommand,
  PutItemCommand,
  UpdateItemCommand,
} from "@aws-sdk/client-dynamodb";
import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";

export const useHogeApiFetch = () => {
  const baseURL = "[外部APIのベースURL]";

  /**
   * TTL秒(1週間)
   *
   * 60 * 60 * 24 * 7 = 604800
   */
  const TTL_SECOND = 604800;

  /**
   * 外部APIからのデータ取得を行う
   * @param {string} apiPath - 「外部APIのベースURL」以降のクエリパラメータを含むパス
   * @returns {Promise<T>}
   */
  const hogeApiFetch = async <T>(apiPath: string): Promise<T> => {
    const dynamodbClient = new DynamoDBClient({
      region: process.env.AWS_DEFAULT_REGION,
      endpoint: process.env.DYNAMODB_ENDPOINT,
    });

    // リクエスト識別子を元にDynamoDBテーブルを検索
    const queryCommand = new GetItemCommand({
      TableName: "api-cache",
      Key: marshall({
        request_identifier: `/${apiPath}`,
      }),
    });
    const response = await dynamodbClient.send(queryCommand);

    // データが取得出来てたら外部APIへのリクエストを実施せずに早期リターン
    // response_data属性の型がM(Object)かL(List)かでunmarshallの分岐
    if (response.Item !== undefined && "response_data" in response?.Item) {
      if ("M" in response.Item.response_data) {
        return {
          data: unmarshall(response.Item.response_data.M!),
          error: null,
        } as T;
      } else if ("L" in response.Item.response_data) {
        return {
          data: response.Item.response_data.L?.map((item) => unmarshall(item.M!)),
          error: null,
        } as T;
      }
    }

    // 外部APIからデータ取得
    return $fetch.create<T>({
      baseURL,
      method: "GET",
      onRequest({ options }) {
        options.headers = new Headers(options.headers);
        options.headers.set("X-API-KEY", process.env?.HOGE_API_KEY ?? "");
      },
      async onResponse({ request, response }) {
        // アプリケーション固有のエラーハンドリング処理(省略)
      
        // DynamoDBに外部APIのレスポンスJSONをキャッシュ
        const urlData = new URL(request.toString());
        const unixTime = Math.floor(new Date().getTime() / 1000);
        const putItemCommand = new PutItemCommand({
          TableName: "api-cache",
          Item: marshall({
            request_identifier: urlData.pathname + urlData.search,
            response_data: response._data.result,
            ttl: unixTime + TTL_SECOND,
          }),
        });

        // 外部APIへの日当たりのリクエスト数を管理するテーブルを更新(インクリメント)
        const yyyymmdd = parseInt(new Date().toISOString().slice(0, 10).replace(/-/g, ""));
        const updateItemCommand = new UpdateItemCommand({
          TableName: "api-requests",
          Key: marshall({ date: yyyymmdd }),
          UpdateExpression: "SET request_count = if_not_exists(request_count, :initial) + :increment",
          ExpressionAttributeValues: marshall({ ":initial": 0, ":increment": 1 }),
        });

        await Promise.all([dynamodbClient.send(putItemCommand), dynamodbClient.send(updateItemCommand)]);

        // アプリケーション固有のデータ返却処理(省略)
      },
    })(apiPath);
  };

  return {
    hogeApiFetch,
  };
};
```

## 動作確認
### 修正前
`※本番は今回の修正をデプロイ済みなので開発環境で修正を開始する前のバージョンに戻して動作確認しています。。`

修正前の時点での動作は以下のような感じ（アプリケーションの中で使用する6つのマスタデータの取得を行っています）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2384986/b4a58ee0-9fdd-b1ee-d212-c7c9bb5b9a5e.png)

![DynamoDBのキャッシュの仕組み導入前(ローカル環境で確認)2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2384986/443f17a3-8732-80fc-a9da-feba5e50fa44.png)
- リクエストの度に外部APIへの通信を行っているためレスポンスの取得時間は速くなったり遅くなったりしていることがわかります（都道府県を取得するAPIのみ条件で絞り込んでる）。
- 都道府県のデータを取得するAPIについてページリロードで5回取得したところ191ms～432msで推移しました。

### 修正後
以下はDynamoDBのキャッシュテーブルが空の状態での初回アクセス時の様子です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2384986/488ddcf5-b5d7-d149-e26b-f0b2fc0b21ce.png)
- DynamoDBテーブルへのGetItemのチェック処理や外部APIから取得したデータをPutItemでインサートする処理を追加したため、初回の処理は修正前より全体的に100ms程度遅くなっているようです。

以下はDynamoDBテーブルにキャッシュされたあとの2回目以降のアクセス時の様子です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2384986/f234e414-4cc5-87a3-3d42-c24233c5c3a5.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2384986/4012650d-7f8b-329c-fc8e-9c756a21e76f.png)
- 修正前のときよりも全体的に応答を返すまでの時間が短くなっていることが確認できました。
- 都道府県のデータを取得するAPIについてページリロードで5回取得したところ60ms～70msで推移しました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2384986/691e1861-2702-9712-2bfe-01230d7f7b53.png)
- マスタデータ以外のデータを取得するAPIについても全体的に2回目以降の応答スピードが上がっていることが確認できました。

---
以下はマネジメントコンソール上でDynamoDBテーブルについて探索を行ったときの様子
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2384986/efe53347-71ba-f275-9072-19085923b0a9.png)

- request_identifier、response_data、ttl属性が存在することを確認できました。
- response_data属性には項目ごとに異なる型(M,L)のデータを入れられていることを確認できました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2384986/3fa5122f-f284-6ab7-0c26-90ec4ff7b47d.png)
- 日別のリクエスト数について保持できていることを確認

## その他注意事項
本番ではLambdaがDynamoDBを操作するため、Lambdaに紐づくIAMロールとして以下のようなポリシーの追記が必要でした。。:upside_down:
```diff:serverless.yml
provider:
  name: aws
  stage: dev
  region: ${env:AWS_DEFAULT_REGION}
  runtime: nodejs18.x
+  iamRoleStatements:
+    - Effect: "Allow"
+      Action:
+        - dynamodb:GetItem
+        - dynamodb:PutItem
+        - dynamodb:UpdateItem
+      Resource:
+        - !Join
+          - ":"
+          - - "arn:aws:dynamodb"
+            - ${env:AWS_DEFAULT_REGION}
+            - ${aws:accountId}
+            - "table/api-cache"
+        - !Join
+          - ":"
+          - - "arn:aws:dynamodb"
+            - ${env:AWS_DEFAULT_REGION}
+            - ${aws:accountId}
+            - "table/api-requests"
```
- 今回は[サンプルコード](#サンプルコード)で紹介したコードの中で`GetItemCommand`、`PutItemCommand`、`UpdateItemCommand`を使用しているため3つのアクションに対する許可が必要でした。
- ここで設定するものはServerless Frameworkを使用したデプロイを行うときのIAMユーザーに紐づくロールとは別なので注意が必要。
- マネジメントコンソールからだとLambdaの「アクセス権限」のページで確認できる

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2384986/3ba64de6-0031-ae0b-6e99-bb65f73d003b.png)


## 参考サイト
- [AWS SDK for JavaScript v3でDynamoDB JSONと通常のJSONの間でデータ構造の変換を行う ~ util-dynamodb ~ | DevelopersIO](https://dev.classmethod.jp/articles/js-dynamodb-json-marshall-unmarshall/)
    - JSONレスポンスをDynamoDBに格納しようとしてエラーが出たときに見たページ
    - marshall/unmarshallの相互変換が必要なことがわかりました
- [Nuxt3入門(第9回) - Nuxt3アプリケーションをサーバーレス環境にデプロイする | 豆蔵デベロッパーサイト](https://developer.mamezou-tech.com/nuxt/nuxt3-serverless-deploy/)
    - 今回のサーバーレスアプリケーションについてこちらのサイトの情報をベースとして作成しています。
    - 「[アプリケーションについて](#アプリケーションについて)」に書いた構成のベースはこちらのページで学びました
- [Amazon DynamoDB でサポートされるデータ型と命名規則 - Amazon DynamoDB](https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/HowItWorks.NamingRulesDataTypes.html)
    - 外部APIのレスポンスJSONを格納する型について調べたときに見たページ
- [DynamoDB local (ダウンロード可能バージョン) のセットアップ - Amazon DynamoDB](https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/DynamoDBLocal.html)
    - DynamoDBの開発環境を作る際に参考となるページ