👓

AppSyncSimulatorでGetBatchItem

2022/12/18に公開

はじめに

この記事はTeam DELTA Advent Calendar 2022 18 日目の記事です 🎄

前回から数日ですが、よろしくお願いいたします。DELTA 最高齢にして最若輩エンジニアの長田(おさだ)の 3 回目の登場です。

DELTA ではエンジニアとして働かせていただいていますので、今回は技術記事になります。
普段から使用している技術の振り返りとして、自己学習も兼ねたものになっております。

記事の背景

先日の記事でも書かせていただきましたが、現在クリニックの予約管理システムを担当しています。
そのプロダクトでは以下のような技術をメインに使用しています。

  • フロントエンド:React
  • バックエンド: AWS AppSync、Amazon DynamoDB、AWS Lambda
  • バックエンド側のツール:Serverless Framework

AppSyncのローカル環境開発にはserverless-appsync-simulatorを使用しているのですが、serverless-appsync-simulatorDynamoDBからのデータ取得に癖があり苦戦しました。
特にBatchGetItemという取得方法について紹介が少なかったため、再現と解決方法を記載します。

今回のゴール

  • serverless-offline を利用してローカル環境を起動
  • AppSync のデータソースとして DynamoDB を指定
  • ローカル環境で DynamoDB から BatchGetItem を使用したデータ取得の再現
# リクエスト
query MyQuery {
  getParent(parentId: "P001") {
    parentId
    name
    description
    children {
      childId
      name
      description
    }
  }
}

に対して以下の通りにデータ取得したい

// レスポンス
{
  "data": {
    "getParent": {
      "parentId": "P001",
      "name": "親 Item",
      "description": "子 Item001 と 003 を保持しています 。",
      "children": [
        {
          "childId": "C001",
          "name": "子 Item001",
          "description": "子 Item001 です。取得される想定です。"
        },
        {
          "childId": "C003",
          "name": "子 Item003",
          "description": "子 Item003 です。取得される想定です。"
        }
      ]
    }
  }
}

Parent

parentId name children description
P001 親 Item [C001,C003] 子 Item001 と 003 を保持しています 。

Child

childId name description
C001 子 Item001 子 Item001 です。選択される想定です。
C002 子 Item002 子 Item002 です。選択されない想定です。
C003 子 Item003 子 Item003 です。選択される想定です。

環境構築

今回のソースは以下になります。
https://github.com/EjiOsa/ImplementAppSyncByLocal

注意点
serverless_XXX.ts といくつか serverless.ts の複製があります。何回か修正があったので参考用に履歴として残しています。

ローカル環境構築

『Serverless Framework + TypeScript で AppSync 環境を構築する』を再現

まずは DELTA の山田さんの記事を参考に構築します。
いくつか変更点があったので、その辺りも説明しながら作成します。(2022 年 12 月 10 日の執筆)

https://zenn.dev/merutin/articles/e1de2cbe575b13

まずは参考資料の通りに

npx serverless create -t aws-nodejs-typescript

で serverless を作成して、handler.ts や package.json を修正します。

山田さんの記事を再現した serverless.ts

差分

  • frameworkVersion が 3 へ変更
  • webpack から esbuild へ変更
    • plugins の serverless-esbuild
    • appsync-simulator の location
serverless_Lambda.ts
import type { AWS } from "@serverless/typescript";

const serverlessConfiguration: AWS = {
  service: "ImplementAppSyncByLocal",
  frameworkVersion: "3", // 3へ変更されていました
  plugins: [
    "serverless-esbuild", // webpackからesbuildへ変更
    "serverless-appsync-simulator",
    "serverless-appsync-plugin",
    "serverless-offline",
  ],
  provider: {
    name: "aws",
    runtime: "nodejs14.x",
    stage: "local", // 今回はローカル環境のため固定値にしています
    region: "ap-northeast-1",
    environment: {
      AWS_NODEJS_CONNECTION_REUSE_ENABLED: "1",
      NODE_OPTIONS: "--enable-source-maps --stack-trace-limit=1000",
    },
  },
  functions: {
    sampleFunction: {
      handler: "src/handler.responseOK",
    },
  },
  package: { individually: true },
  custom: {
    esbuild: {
      bundle: true,
      minify: false,
      sourcemap: true,
      exclude: ["aws-sdk"],
      target: "node14",
      define: { "require.resolve": undefined },
      platform: "node",
      concurrency: 10,
    },
    "appsync-simulator": {
      location: ".esbuild/.build", // webpackからの変更に伴って修正しています
      apiKey: "da2-fakeApiId123456",
      watch: false,
    },
    appSync: {
      name: "appsync-sample",
      authenticationType: "API_KEY",
      schema: "./graphql/schema.graphql",
      apiKeys: [
        {
          name: "test-api-key",
          description: "AppSync test",
          expiresAfter: "30d",
        },
      ],
      defaultMappingTemplates: {
        request: false,
        response: false,
      },
      mappingTemplates: [
        {
          dataSource: "dataSourcesLambda",
          type: "Query",
          field: "getOK",
        },
      ],
      dataSources: [
        {
          type: "AWS_LAMBDA",
          name: "dataSourcesLambda",
          config: {
            functionName: "sampleFunction",
          },
        },

      ],
    },
  },
};

module.exports = serverlessConfiguration;
handler.ts

import "source-map-support/register";

import { AppSyncResolverHandler } from "aws-lambda";

export const responseOK: AppSyncResolverHandler<any, any> = async (event) => {
  console.log("event", event);

  return "OK";
};
graphql/schema_Lambda.graphql
type Query {
  getOK: String!
}

ただし上記まで作成しても

TypeError: Cannot convert undefined or null to object

というエラーで起動できないはずです。
https://github.com/serverless-appsync/serverless-appsync-simulator/issues/170
でコメントされていますが、cfn-resolver-lib を 1.1.7 に固定する必要がありますので"resolutions":{ "serverless-appsync-simulator/cfn-resolver-lib": "1.1.7"}を package.json の最上位階層に追記します。

package.json
{
  "name": "implementappsyncbylocal",
  "version": "1.0.0",
  "description": "Serverless aws-nodejs-typescript template",
  "main": "serverless.ts",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "engines": {
    "node": ">=14.15.0"
  },
  "dependencies": {},
  "devDependencies": {
    "@serverless/typescript": "^3.0.0",
    "@types/aws-lambda": "^8.10.71",
    "@types/node": "^14.14.25",
    "esbuild": "^0.14.11",
    "serverless": "^3.0.0",
    "serverless-esbuild": "^1.23.3",
    "ts-node": "^10.4.0",
    "tsconfig-paths": "^3.9.0",
    "typescript": "^4.1.3"
  },
  "author": "The serverless webpack authors (https://github.com/elastic-coders/serverless-webpack)",
  "license": "MIT",
+  "resolutions": {
+    "serverless-appsync-simulator/cfn-resolver-lib": "1.1.7"
+  }
}

では、以下のコマンドで起動できるか確認します。

npx serverless offline start

起動後に http://localhost:20002/ へアクセスして、GraphiQL を実行することで data が取得できることを確認します。

192.168.10.4:20002

さて、ここまでで山田さんの記事は再現できました。
ですが Lambda の返却しかないので DB が必要です。
今回はserverless-appsync-simulatorDynamoDBからのデータ取得しますので、Docker でDynamoDBを準備します。

DynamoDB

公式のものをダウンロードするか、こちらを使用してください。
https://github.com/EjiOsa/ImplementAppSyncByLocal/tree/main/localStack

serverless-dynamodb-local

docker-compose.yml
version: '3.9'

services:
  dynamodb:
    build:
      context: ./dynamodb
    ports:
      - 8000:8000
    volumes:
      - db:/home/dynamodblocal/db
  admin:
    image: aaronshaf/dynamodb-admin
    environment:
      - DYNAMO_ENDPOINT=dynamodb:8000
    ports:
      - 8001:8001
    depends_on:
      - dynamodb
volumes:
  db:
docker-compose up -d

admin を 8001 にしていますので、http://localhost:8001/ にアクセスして DynamoDB が起動できているか確認してみましょう。
まだテーブル作成もしていないので、何も入っていませんが起動確認が出来れば問題ないです。

データ作成

json 形式で作成して、配列で値を保持しています。
serverless.ts 内の seed で指定していますが、migrations ディレクトリ配下に作成しています。
https://github.com/EjiOsa/ImplementAppSyncByLocal/tree/main/migrations

migrations/parent.json
[
  {
    "parentId": "P001",
    "name": "親 Item",
    "description": "子 Item001 と 003 を保持しています 。",
    "childrenIds": ["C001", "C003"]
  }
]

serverless.ts の変更点が少々多いのですが、重要な部分なので折りたたまずに表示しています。

変更点の説明

  • ① dynamodb
    seed の部分で local 環境で格納するデータを指定しています。
  • ② resources
    DynamoDB でのテーブル定義になります。パーティションキーを設定しています。(ソートキー等もここで設定します。)
  • ③ 削除箇所
    Lambda への接続で指定していた部分のため削除しています。
    • functions
    • defaultMappingTemplates
    • mappingTemplates
    • dataSources
serverless_DynamoDB.ts
const serverlessConfiguration: AWS = {
----
  plugins: [
    "serverless-esbuild", // webpackからesbuildへ変更
    "serverless-appsync-simulator",
    "serverless-appsync-plugin",
+    "serverless-dynamodb-local", // DynamoDBへの接続のために追加
    "serverless-offline",
  ],
-  functions: { // ③
-    sampleFunction: {
-      handler: "src/handler.responseOK",
-    },
-  },

----

  custom: {
    esbuild: {
      bundle: true,
      minify: false,
      sourcemap: true,
      exclude: ["aws-sdk"],
      target: "node14",
      define: { "require.resolve": undefined },
      platform: "node",
      concurrency: 10,
    },
    "appsync-simulator": {
      location: ".esbuild/.build", // webpackからの変更に伴って修正しています
      apiKey: "da2-fakeApiId123456",
      watch: false,
    },
+    dynamodb: { // ①
+      stages: ["local"],
+      start: {
+        docker: true, // 書かなくてもDockerのDynamoDBになりますが、READMEに記載があったため
+        port: "8000",
+        inMemory: true,
+        migrate: true,
+        seed: true,
+        convertEmptyValues: true,
+        noStart: true, // これがないとpluginを起動してDocker接続しない
+      },
+      seed: {
+        local: {
+          sources: [
+            { table: "Parent", sources: ["migrations/parent.json"] },
+            { table: "Child", sources: ["migrations/child.json"] },
+          ],
+        },
+      },
+    },
    appSync: {
      name: "appsync-sample",
      authenticationType: "API_KEY",
      schema: "./graphql/schema.graphql",
      apiKeys: [
        {
          name: "test-api-key",
          description: "AppSync test",
          expiresAfter: "30d",
        },
      ],
-      defaultMappingTemplates: { // ③
-        request: false,
-        response: false,
-      },
-      mappingTemplates: [
-        {
-          dataSource: "dataSourcesLambda",
-          type: "Query",
-          field: "getOK",
-        },
-      ],
      dataSources: [
-        { // ③
-          type: "AWS_LAMBDA",
-          name: "dataSourcesLambda",
-          config: {
-            functionName: "sampleFunction",
-          },
-        },
      ],
    },
+  resources: { // ②
+    Resources: {
+      Parent: {
+        Type: "AWS::DynamoDB::Table",
+        Properties: {
+          TableName: "Parent",
+          AttributeDefinitions: [
+            { AttributeName: "parentId", AttributeType: "S" },
+          ],
+          KeySchema: [{ AttributeName: "parentId", KeyType: "HASH" }],
+          ProvisionedThroughput: {
+            ReadCapacityUnits: 1,
+            WriteCapacityUnits: 1,
+          },
+        },
+      },
+      Child: {
+        Type: "AWS::DynamoDB::Table",
+        Properties: {
+          TableName: "Child",
+          AttributeDefinitions: [
+            { AttributeName: "childId", AttributeType: "S" },
+          ],
+          KeySchema: [{ AttributeName: "childId", KeyType: "HASH" }],
+          ProvisionedThroughput: {
+            ReadCapacityUnits: 1,
+            WriteCapacityUnits: 1,
+          },
+        },
+      },
+    },
+  },
----

schema_DynamoDB.graphql
- type Query {
-  getOK: String!
- }

+ type Parent {
+   parentId: String!
+   name: String
+   description: String
+   childIds: [String]
+ }

+ type Child {
+  parentId: String!
+  name: String
+  description: String
+ }

上記の修正完了後に

yarn add -D serverless-dynamodb-local
npx serverless offline start

をしてみると http://localhost:8001/ でデータが入っていることが確認できます。
(Error: Unknown type "Query".が出ているかもしれませんが問題ありません)

データ確認はできましたが、http://localhost:20002/ にアクセスしてもNO SCHEMA AVAILABLEの表示で何も取得できません。
リゾルバを作成していないからです。

GetItem でデータ取得

まずは GetItem のリゾルバを作成します。
今回は./graphql/mapping-templates に作成しています。

Query.getParent.request.vtl
{
    "version": "2018-05-29",
    "operation": "GetItem",
    "key" : {
        "parentId" : { "S" : "$ctx.args.parentId" }
    }
}
Query.getParent.response.vtl
$util.toJson($ctx.result)
schema_GetItem.graphql
type Parent {
  parentId: String!
  name: String
  description: String
  childIds: [String]
}

type Child {
  childId: String!
  name: String
  description: String
}

+ type Query {
+  getParent(parentId: String!): Parent
+  getChild(childId: String!): Child
+ }

調べてて困ったこと

どれも serverless を yml 形式で記載していて困りました。
豆蔵さんの GitHubを参考に["./migrations/parent.json"]{ AttributeName: "parentId", AttributeType: "S" }の部分を確認しました。

追加点の説明

  • ① iamRoleStatements
    ローカルでは不要ですが、明示的に Action を記載するために追加しています。

  • ② mappingTemplatesLocation と mappingTemplates
    リゾルバの格納場所やリゾルバの設定を定義しています。
    field は schema.graphql の Query で定義しているものです。

  • ③ dataSources
    mappingTemplate から DynamoDB への橋渡しをしています。
    name は mappingTemplates.dataSource です。
    config.tableName は resources.Resources です。

serverless_GetItem.ts
  provider: {
    name: "aws",
    runtime: "nodejs14.x",
    stage: "local", // 今回はローカル環境のため固定値にしています
    region: "ap-northeast-1",
    environment: {
      AWS_NODEJS_CONNECTION_REUSE_ENABLED: "1",
      NODE_OPTIONS: "--enable-source-maps --stack-trace-limit=1000",
    },
+    iamRoleStatements: [ // ①
+      {
+        Effect: "Allow",
+        Action: [
+          "dynamodb:GetItem",
+        ],
+        Resource: [
+          { "Fn::GetAtt": ["Parent", "Arn"] },
+          { "Fn::GetAtt": ["Child", "Arn"] },
+        ],
+      },
+    ],
  },
  package: { individually: true },

    appSync: {
      name: "appsync-sample",
      authenticationType: "API_KEY",
      schema: "./graphql/schema.graphql",
      apiKeys: [
        {
          name: "test-api-key",
          description: "AppSync test",
          expiresAfter: "30d",
        },
      ],
+      mappingTemplatesLocation: "./graphql/mapping-templates", // ②
+      mappingTemplates: [
+        {
+          dataSource: "dataSourceParent",
+          type: "Query",
+          field: "getParent",
+          request: "Query.getParent.request.vtl",
+          response: "Query.getParent.response.vtl",
+        },
+        {
+          dataSource: "dataSourceChild",
+          type: "Query",
+          field: "getChild",
+          request: "Query.getChild.request.vtl",
+          response: "Query.getChild.response.vtl",
+        },
+      ],
      dataSources: [ // ③
+        {
+          type: "AMAZON_DYNAMODB",
+          name: "dataSourceParent",
+          config: {
+            tableName: { Ref: "Parent" },
+            serviceRoleArn: {
+              Fn: ":GetAtt: [AppSyncDynamoDBServiceRole, Arn]",
+            },
+          },
+        },
+        {
+          type: "AMAZON_DYNAMODB",
+          name: "dataSourceChild",
+          config: {
+            tableName: { Ref: "Child" },
+            serviceRoleArn: {
+              Fn: ":GetAtt: [AppSyncDynamoDBServiceRole, Arn]",
+            },
+          },
+        },
      ]

http://localhost:20002/ へアクセスすることでgetParentが取得できることを確認できます。
192.168.10.4:200002
getParentで取得できましたが

"childIds": [
    "C001",
    "C003"
]

となっていて、childIds は文字列の配列のままです。
childIds から child の Item を取得するために BatchGetItem を設定します。

BatchGetItem でデータ取得

serverless.ts
    iamRoleStatements: [
      {
        Effect: "Allow",
        Action: [
          // 今回はGetItemとBatchGetItemのみ使用するため2つにしています
          "dynamodb:GetItem",
+          "dynamodb:BatchGetItem",
        ],
        Resource: [
          { "Fn::GetAtt": ["Manga", "Arn"] },
          { "Fn::GetAtt": ["Genre", "Arn"] },
        ],
      },
    ],
  },
  package: { individually: true },

      mappingTemplates: [
        {
          dataSource: "dataSourceParent",
          type: "Query",
          field: "getParent",
          request: "Query.getParent.request.vtl",
          response: "Query.getParent.response.vtl",
        },
        {
          dataSource: "dataSourceChild",
          type: "Query",
          field: "getChild",
          request: "Query.getChild.request.vtl",
          response: "Query.getChild.response.vtl",
        },
+        {
+          dataSource: "dataSourceChild",
+          type: "Parent",
+          field: "children",
+          request: "Parent.children.request.vtl",
+          response: "Parent.children.response.vtl",
+        },
      ],

リゾルバにも記載していますが、parent から child を取得する際にchildrenに変更しています。
dynamoDB にはchildIdsのままで格納されています。(そのため seed データは修正しません)

schema.graphql
type Parent {
  parentId: String!
  name: String
  description: String
-  childIds: [String]
+  children: [Child]
}

type Child {
  childId: String!
  name: String
  description: String
}

type Query {
  getParent(parentId: String!): Parent
  getChild(childId: String!): Child
}

ちょっと頑張って読んでいただければ分かるのですが、parent テーブルのchildIdsを foreach で回して"childId": "C001"というような配列を作成して、BatchGetItem で一括で取得しています。

Parent.children.request.vtl
#set($childIds= $util.defaultIfNull($ctx.source.childIds,[]))

#set($ids = [])
#foreach($id in $childIds)
    $util.qr($ids.add({
        "childId": $util.dynamodb.toDynamoDB($id)
    }))
#end

{
  "version" : "2018-05-29",
  "operation": "BatchGetItem",
  "tables": {
    "Child": {
      "keys": $util.toJson($ids)
    }
  }
}
Parent.children.response.vtl
$util.toJson($ctx.result.data.Child)

さあ、では BatchGetItem してみましょう!!!

{
  "data": {
    "getParent": {
      "parentId": "P001",
      "name": "親 Item",
      "description": "子 Item001 と 003 を保持しています 。",
      "children": null
    }
  }
}

"children": nullです。取得できていません。
これは
/node_modules/amplify-appsync-simulator/lib/data-loader/dynamo-db/index.js
を見ていただくと分かるのですが、DynamoDBDataLoader.prototype.scanまでしかなくて、DynamoDBDataLoader.prototype.BatchGetItemDynamoDBDataLoader.prototype.BatchPutItemDynamoDBDataLoader.prototype.BatchDeleteItemがないんです。
そうなんです。amplify-appsync-simulator で BatchGetItem は公式にサポートされていません。

ですので
https://github.com/EjiOsa/ImplementAppSyncByLocal/tree/main/dynamo-db
/node_modules/amplify-appsync-simulator/lib/data-loader/の dynamo-db に置き換えてください。

192.168.10.4:20002

add BatchGetItem to appsync-simulator

こちらで修正が進められています。
https://github.com/aws-amplify/amplify-cli/pull/7952

これ元々は DELTA 代表の丹さんがプルリク出していたのですが、放置していたらしく Close に。。。
(「はぇ~~、公式に PR 出してる」ってビックリしたもので Close だけど知ってほしかった部分です)
https://github.com/aws-amplify/amplify-cli/pull/5965

まとめ

以上となります。DELTA の丹さんの PR と山田さんの記事を参考にしながら、AppSync のローカル環境で BatchGetItem ができるところまでできました。
node_modules の中身までははじめて読んでみたのですが、読みこなすまではもう少し時間が必要そうです。
これからも精進していきたいと思います。

引き続き、アドベントカレンダーの購読もよろしくお願いいたします。

参考
株式会社 DELTA って?
【2022 年 12 月版】SEVENRICH GROUP の開発チーム「DELTA」の自己紹介

https://github.com/serverless-appsync/serverless-appsync-simulator
https://github.com/mamezou-tech/serverless-example-typescript

Discussion