AWS AmplifyのFunctionsでバッチ処理を実装する

9 min read読了の目安(約8800字

弊社の開発合宿で久々にAWS Amplifyに触れてみました。

いまさらな感想ですが、Amplify ConsoleのUIが刷新されていたり、Amplify Admin UIが追加されていたりして、サービスの成長速度に驚きました。

さて、今回はFunctions(Lambda)を使ってDynamoDBに格納されたデータをバッチ処理する試みをしましたので、Amplifyを使う上でのノウハウの一つとして紹介したいと思います。

やりたいこと

AmplifyのAPIのバックエンドに作成されたDynamoDBに対してバッチ処理を行います。

今回は例として以下のような処理を実装しました。

  1. テーブル内に楽曲のタイトルが入っている
  2. 各楽曲のカバーアートURLをバッチ処理で取得・更新する

使用するツール,ライブラリのバージョン

$ node -v
v14.16.0

$ npm -v
7.10.0

$ amplify -v
4.48.0

下準備

サンプルアプリケーションの作成

本記事で実施する内容はフロントエンドと無関係ですが、実際にはNext.jsと組み合わせる形で使う想定なので、Next.jsのアプリケーションを作成しておきます。

# サンプルアプリケーションを作成
$ npx create-next-app --example with-typescript-eslint-jest

# サンプルアプリケーションを起動
$ cd batch-sample
$ npm run dev

Amplifyの初期化

Amplify CLIにて初期化します。

$ amplify init
? Enter a name for the project batchsample
? Initialize the project with the above configuration? No
? Enter a name for the environment dev
? Choose your default editor: Visual Studio Code
? Choose the type of app that you're building javascript
Please tell us about your project
? What javascript framework are you using react
? Source Directory Path:  src
? Distribution Directory Path: out
? Build Command:  npm run-script build
? Start Command: npm run-script start
Using default provider  awscloudformation
? Select the authentication method you want to use: AWS profile
? Please choose the profile you want to use default

Amplify APIの作成

予めAPIのスキーマを作成しておきます。

schema.graphql
type Song @model @auth(rules: [{ allow: public }]) {
  id: ID!
  type: String!
  title: String!
  artist: String!
  coverArtUrl: String
}

上記要件に合うようなスキーマでAmplify APIを作成します。認証方法はAPI KEYとします。

$ amplify add api
? Please select from one of the below mentioned services: GraphQL
? Provide API name: batchsample
? Choose the default authorization type for the API API key
? Enter a description for the API key: api key
? After how many days from now the API key should expire (1-365): 7
? Do you want to configure advanced settings for the GraphQL API No, I am done.
? Do you have an annotated GraphQL schema? Yes
? Provide your schema file path: schema.graphql

追加ができたら次のコマンドを実行してAPIをデプロイします。

$ amplify push
✔ Successfully pulled backend environment dev from the cloud.

Current Environment: dev

| Category | Resource name | Operation | Provider plugin   |
| -------- | ------------- | --------- | ----------------- |
| Api      | batchsample   | Create    | awscloudformation |
? Are you sure you want to continue? Yes

? Do you want to generate code for your newly created GraphQL API Yes
? Choose the code generation language target typescript
? Enter the file name pattern of graphql queries, mutations and subscriptions src/graphql/**/*.ts
? Do you want to generate/update all possible GraphQL operations - queries, mutations and subscriptions Yes
? Enter maximum statement depth [increase from default if your schema is deeply nested] 2
? Enter the file name for the generated code src/API.ts

Amplify Functionの作成

バッチ処理を行うFunction(Lambda)を作成します。Amplify Functionは定周期トリガーを設定できるので、5分周期で起動するよう設定します。

$ amplify add function
? Select which capability you want to add: Lambda function (serverless function)
? Provide an AWS Lambda function name: spotifyapi
? Choose the runtime that you want to use: NodeJS
? Choose the function template that you want to use: Hello World

Available advanced settings:
- Resource access permissions
- Scheduled recurring invocation
- Lambda layers configuration

? Do you want to configure advanced settings? Yes
? Do you want to access other resources in this project from your Lambda function? Yes
? Select the categories you want this function to have access to. api
? Select the operations you want to permit on batchsample Query, Mutation

You can access the following resource attributes as environment variables from your Lambda function
	API_BATCHSAMPLE_GRAPHQLAPIENDPOINTOUTPUT
	API_BATCHSAMPLE_GRAPHQLAPIIDOUTPUT
	API_BATCHSAMPLE_GRAPHQLAPIKEYOUTPUT
	ENV
	REGION
? Do you want to invoke this function on a recurring schedule? Yes
? At which interval should the function be invoked: Minutes
? Enter the rate in minutes: 5
? Do you want to configure Lambda layers for this function? No
? Do you want to edit the local lambda function now? No

出来上がったLambdaを確認すると、Amplify APIのエンドポイントやAPI KEYが環境変数として渡されています。これはとても助かりますね。

Amplify Functionの実装

Lambdaで実施する処理は以下の通りです。

  1. Amplify APIで楽曲を取得
  2. Spotify APIを使って、楽曲名からジャケットURLを取得
  3. Amplify APIで楽曲にジャケットURLを含めて更新

これを行うプログラムは以下の通りです。

amplify/backend/function/spotifyapi/src/index.js
/* Amplify Params - DO NOT EDIT
	API_BATCHSAMPLE_GRAPHQLAPIENDPOINTOUTPUT
	API_BATCHSAMPLE_GRAPHQLAPIIDOUTPUT
	API_BATCHSAMPLE_GRAPHQLAPIKEYOUTPUT
	ENV
	REGION
Amplify Params - DO NOT EDIT */

const SpotifyWebApi = require('spotify-web-api-node')
const spotifyClient = async () => {
  const client = new SpotifyWebApi({
    clientId: 'xxx', // TODO: 環境変数化するとベター
    clientSecret: 'xxx', // TODO: 環境変数化するとベター
    redirectUri: 'http://localhost:8888',
  })

  client.setRefreshToken(
    'xxx' // TODO: 環境変数化するとベター
  )

  const data = await client.refreshAccessToken()
  client.setAccessToken(data.body['access_token'])

  return client
}

const { GraphQLClient, gql } = require('graphql-request')
const graphqlClient = () => {
  return new GraphQLClient(
    process.env.API_BATCHSAMPLE_GRAPHQLAPIENDPOINTOUTPUT,
    {
      headers: { 'x-api-key': process.env.API_BATCHSAMPLE_GRAPHQLAPIKEYOUTPUT },
    }
  )
}

const query = gql`
  {
    listSongs {
      items {
        id
        title
        artist
      }
    }
  }
`

const mutation = gql`
  mutation UpdateSong($id: ID!, $coverArtUrl: String!) {
    updateSong(input: { id: $id, coverArtUrl: $coverArtUrl }) {
      id
      coverArtUrl
    }
  }
`

exports.handler = async (event) => {
  const graphql = graphqlClient()
  const spotify = await spotifyClient()

  // 楽曲一覧を取得
  const res = await graphql.request(query)
  const listSongs = await Promise.all(
    res.listSongs.items.map(async (item) => {
      // 楽曲情報をSpotifyから取得
      const search_response = await spotify.searchTracks(item.title)
      const track = search_response.body.tracks.items[0]
      if (!track) return null

      return {
        id: item.id,
        coverArtUrl: track.album.images[0].url,
        spotifyUrl: track.external_urls.spotify,
      }
    })
  )

  await Promise.all(
    listSongs
      .filter((v) => v)
      .map(async (item) => {
        // カバーアートURLを更新
        await graphql.request(mutation, {
          id: item.id,
          coverArtUrl: item.coverArtUrl,
          youtubeUrl: item.spotifyUrl,
        })
      })
  )
}

package.jsonの dependencies に利用するパッケージの追加をしておきます。

amplify/backend/function/spotifyapi/src/pacage.json
{
  "name": "spotifyapi",
  "version": "2.0.0",
  "description": "Lambda function generated by Amplify",
  "main": "index.js",
  "license": "Apache-2.0",
  "dependencies": {
    "graphql": "^15.5.0",
    "graphql-request": "^3.4.0",
    "spotify-web-api-node": "^5.0.2"
  }
}

Spotify APIの利用方法については次の記事を参考にしました。

動作確認

データの準備

Amplify Admin UIにログインして、サイドメニューの「GraphQL API」からAppSyncのコンソールへ移動します。

「クエリを実行」をクリックすると、GraphQLのPlaygroundを利用できます。

以下のようなmutationを実行することで、処理前の楽曲情報を追加します。

追加後のDynamoDBのSongsテーブルは次のようになりました。

デプロイ

Functionをデプロイします。

$ amplify push

しばらく待つとLambdaが実行され、DynamoDBに楽曲のカバーアートURLがセットされました。

ちなみに、デプロイをしなくてもTerminalで以下コマンドを叩くことで、手元の環境でFunctionを実行することができます。

$ amplify mock function spotifyapi

Amplifyへの期待

久々にAmplifyを触りましたが、インフラからバックエンドまでCLIですぐに作成できるのは良いですね。

一方、触っていて以下のような点で難しさを感じたので、メモしておきます。

  • Amplify FunctionのトリガーがAPIかScheduleしか選択できないので、ちょっと不便
    • DynamoDB Streamを使って、データを追加・更新したタイミングでFunctionが呼び出せると嬉しい
  • Amplify FunctionのランタイムにRubyを追加してほしい
  • Amplify APIはバックエンドがDynamoDBということもあり検索があまり得意ではない
    • ElasticSearchとの連携が推奨されているが、スケーラビリティやコストで不利
    • Aurora Serverlessとかと連携できると使いみちが拡がるのでは?

まとめ

AWS Amplifyを使ったシステムでバッチ処理を実装する際、参考になれば幸いです。