AppSyncSimulatorでGetBatchItem
はじめに
この記事はTeam DELTA Advent Calendar 2022 18 日目の記事です 🎄
前回から数日ですが、よろしくお願いいたします。DELTA 最高齢にして最若輩エンジニアの長田(おさだ)の 3 回目の登場です。
DELTA ではエンジニアとして働かせていただいていますので、今回は技術記事になります。
普段から使用している技術の振り返りとして、自己学習も兼ねたものになっております。
記事の背景
先日の記事でも書かせていただきましたが、現在クリニックの予約管理システムを担当しています。
そのプロダクトでは以下のような技術をメインに使用しています。
- フロントエンド:React
- バックエンド: AWS AppSync、Amazon DynamoDB、AWS Lambda
- バックエンド側のツール:Serverless Framework
AppSync
のローカル環境開発にはserverless-appsync-simulator
を使用しているのですが、serverless-appsync-simulator
でDynamoDB
からのデータ取得に癖があり苦戦しました。
特に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 です。選択される想定です。 |
環境構築
今回のソースは以下になります。
注意点
serverless_XXX.ts といくつか serverless.ts の複製があります。何回か修正があったので参考用に履歴として残しています。
ローカル環境構築
『Serverless Framework + TypeScript で AppSync 環境を構築する』を再現
まずは DELTA の山田さんの記事を参考に構築します。
いくつか変更点があったので、その辺りも説明しながら作成します。(2022 年 12 月 10 日の執筆)
まずは参考資料の通りに
npx serverless create -t aws-nodejs-typescript
で serverless を作成して、handler.ts や package.json を修正します。
山田さんの記事を再現した serverless.ts
差分
- frameworkVersion が 3 へ変更
- webpack から esbuild へ変更
- plugins の serverless-esbuild
- appsync-simulator の location
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;
import "source-map-support/register";
import { AppSyncResolverHandler } from "aws-lambda";
export const responseOK: AppSyncResolverHandler<any, any> = async (event) => {
console.log("event", event);
return "OK";
};
type Query {
getOK: String!
}
ただし上記まで作成しても
TypeError: Cannot convert undefined or null to object
というエラーで起動できないはずです。"resolutions":{ "serverless-appsync-simulator/cfn-resolver-lib": "1.1.7"}
を 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 が取得できることを確認します。
さて、ここまでで山田さんの記事は再現できました。
ですが Lambda の返却しかないので DB が必要です。
今回はserverless-appsync-simulator
でDynamoDB
からのデータ取得しますので、Docker でDynamoDB
を準備します。
DynamoDB
公式のものをダウンロードするか、こちらを使用してください。
serverless-dynamodb-local
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 ディレクトリ配下に作成しています。
[
{
"parentId": "P001",
"name": "親 Item",
"description": "子 Item001 と 003 を保持しています 。",
"childrenIds": ["C001", "C003"]
}
]
serverless.ts の変更点が少々多いのですが、重要な部分なので折りたたまずに表示しています。
変更点の説明
- ① dynamodb
seed の部分で local 環境で格納するデータを指定しています。 - ② resources
DynamoDB でのテーブル定義になります。パーティションキーを設定しています。(ソートキー等もここで設定します。) - ③ 削除箇所
Lambda への接続で指定していた部分のため削除しています。- functions
- defaultMappingTemplates
- mappingTemplates
- dataSources
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,
+ },
+ },
+ },
+ },
+ },
----
- 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 に作成しています。
{
"version": "2018-05-29",
"operation": "GetItem",
"key" : {
"parentId" : { "S" : "$ctx.args.parentId" }
}
}
$util.toJson($ctx.result)
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 です。
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
が取得できることを確認できます。
getParent
で取得できましたが
"childIds": [
"C001",
"C003"
]
となっていて、childIds は文字列の配列のままです。
childIds から child の Item を取得するために BatchGetItem を設定します。
BatchGetItem でデータ取得
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 データは修正しません)
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 で一括で取得しています。
#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)
}
}
}
$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.BatchGetItemやDynamoDBDataLoader.prototype.BatchPutItemやDynamoDBDataLoader.prototype.BatchDeleteItemがないんです。
そうなんです。amplify-appsync-simulator で BatchGetItem は公式にサポートされていません。
ですので/node_modules/amplify-appsync-simulator/lib/data-loader/
の dynamo-db に置き換えてください。
add BatchGetItem to appsync-simulator
こちらで修正が進められています。
これ元々は DELTA 代表の丹さんがプルリク出していたのですが、放置していたらしく Close に。。。
(「はぇ~~、公式に PR 出してる」ってビックリしたもので Close だけど知ってほしかった部分です)
まとめ
以上となります。DELTA の丹さんの PR と山田さんの記事を参考にしながら、AppSync のローカル環境で BatchGetItem ができるところまでできました。
node_modules の中身までははじめて読んでみたのですが、読みこなすまではもう少し時間が必要そうです。
これからも精進していきたいと思います。
引き続き、アドベントカレンダーの購読もよろしくお願いいたします。
参考
株式会社 DELTA って?
【2022 年 12 月版】SEVENRICH GROUP の開発チーム「DELTA」の自己紹介
Discussion