やりたいこと
これまでTerraformなどのIaCツールを使用してAWSリソースのデプロイを試してきましたが、CDKを使えば、普段使い慣れたTypeScriptやPythonの文法でTerraformと同様の役割を果たせるため、その一連の処理を試してみたいです!
CDKプロジェクトの作成
まず、以下の公式ドキュメントを見てCDKのプロジェクトを作ります。
cdk init app --language typescript
次はブートストラップし、CDK実行するのに必要なAmazon S3 バケット、AWS IAM ロール、AWS Systems Manager (SSM) パラメータストアを行います!
cdk bootstrap
続いて、bin配下のファイルを開き、AWSの接続関連設定を行います。
個人的には、なるべくAWSのアクセス情報をソースコードに書かず、環境変数に設定するのがセキュリティ的によいかと思います。
これで初期設定がひとまず完了!
ほかにもいろいろcdk系コマンドがあるので、必要なときまたドキュメントを精査しましょう!
AppSync、DynamoDBをデプロイ
まず、二つフォルダを追加します。
shemasフォルダには、appsyncのスキーマを格納、vtlフォルダにはappsyncのリゾルバに必要なvtlファイルを格納。
VTLとは、AWS AppSync や API Gateway のマッピングテンプレートで、リクエストやレスポンスを変換し、DynamoDBとやり取りするために使われます。
DynamoDBは独自のJSON形式(Amazon Ion に近い構造)を使ってデータを格納するため、AppSync や API Gateway のマッピングテンプレート(VTL)でリクエストやレスポンスの変換が必要になります。
スキーマから編集します。今回はUserテーブルを操作するスキーマを作ります。
idをパーティションキー、nameをソートキーにするので、!マークをつけて「必須」とします。
Userテーブルに対して、CRUD(新規作成、読み取り、更新、削除)を行います。
type User {
id: String!
name: String!
birthday: String
email: String
}
type Query {
getUser(id: String!, name: String): User
getUsers: [User!]!
}
input UserInput {
name: String!
birthday: String
email: String
}
input UpdateUserInput {
id: String!
name: String!
birthday: String
email: String
}
type Mutation {
addUser(input: UserInput!): User
updateUser(input: UpdateUserInput!): User
deleteUser(id: String!): User
}
次に、lib配下のファイルを開き、こちらでStackを編集することになります!
以下のように編集します!
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import path = require("path");
import * as appsync from "aws-cdk-lib/aws-appsync";
import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
export class TestCdkStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// appsync
const api = new appsync.GraphqlApi(this, "Api", {
name: "UserApi",
schema: appsync.SchemaFile.fromAsset(
path.join(__dirname, "../schemas/schema.graphql")
),
authorizationConfig: {
defaultAuthorization: {
authorizationType: appsync.AuthorizationType.API_KEY,
},
},
});
// dynamodb
const table = new dynamodb.Table(this, "UserTable", {
tableName: "user",
partitionKey: {
name: "id",
type: dynamodb.AttributeType.STRING,
},
sortKey: {
name: "name",
type: dynamodb.AttributeType.STRING,
},
removalPolicy: cdk.RemovalPolicy.DESTROY, // cdk destroyでDB削除可
});
// appsyncのdatasource
const dataSource = api.addDynamoDbDataSource("DynamoDataSource", table);
// query
dataSource.createResolver("getUsersResolver", {
typeName: "Query",
fieldName: "getUsers",
requestMappingTemplate: appsync.MappingTemplate.dynamoDbScanTable(),
responseMappingTemplate: appsync.MappingTemplate.dynamoDbResultList(),
});
dataSource.createResolver("getUserResolver", {
typeName: "Query",
fieldName: "getUser",
// requestMappingTemplate: appsync.MappingTemplate.dynamoDbGetItem('id', 'id'),
// requestMappingTemplate: appsync.MappingTemplate.dynamoDbQuery(
// appsync.KeyCondition.eq("id", "id")
// ),
requestMappingTemplate: appsync.MappingTemplate.fromFile(
path.join(__dirname, "../vtl/get_user.vtl")
),
// responseMappingTemplate: appsync.MappingTemplate.dynamoDbResultList(),
responseMappingTemplate: appsync.MappingTemplate.dynamoDbResultItem(),
});
// mutation
dataSource.createResolver("addUserResolver", {
typeName: "Mutation",
fieldName: "addUser",
requestMappingTemplate: appsync.MappingTemplate.dynamoDbPutItem(
appsync.PrimaryKey.partition("id").auto().sort("name").is("input.name"),
appsync.Values.projecting("input")
),
responseMappingTemplate: appsync.MappingTemplate.dynamoDbResultItem(),
});
dataSource.createResolver("updateUserResolver", {
typeName: "Mutation",
fieldName: "updateUser",
requestMappingTemplate: appsync.MappingTemplate.dynamoDbPutItem(
appsync.PrimaryKey.partition("id")
.is("input.id")
.sort("name")
.is("input.name"),
appsync.Values.projecting("input")
),
responseMappingTemplate: appsync.MappingTemplate.dynamoDbResultItem(),
});
dataSource.createResolver("DeleteUserResolver", {
typeName: "Mutation",
fieldName: "deleteUser",
requestMappingTemplate: appsync.MappingTemplate.dynamoDbDeleteItem(
"id",
"id"
),
responseMappingTemplate: appsync.MappingTemplate.dynamoDbResultItem(),
});
}
}
ではデプロイします!
cdk deploy
デプロイ内容の説明①:AppSyncとDynamoDB
逐次内容を説明します!
まず、AppSyncのGraphqlApiを作成します!
作ったスキーマを読み取り、AppSyncのGraphqlApiに渡します!
Cognito認証がないので、デフォルトのAPIキーの認証で作ります!
// appsync
const api = new appsync.GraphqlApi(this, "Api", {
name: "UserApi",
schema: appsync.SchemaFile.fromAsset(
path.join(__dirname, "../schemas/schema.graphql")
),
authorizationConfig: {
defaultAuthorization: {
authorizationType: appsync.AuthorizationType.API_KEY,
},
},
});
次に、DynamoDBを作ります!
idをパーティションキー、nameをソートキーにします!
こちらのキー名は、AppSyncのスキーマを異なると、AppSyncからDynamoDBを操作するときに、エラーになりますので、気を付けてください。
// dynamodb
const table = new dynamodb.Table(this, "UserTable", {
tableName: "user",
partitionKey: {
name: "id",
type: dynamodb.AttributeType.STRING,
},
sortKey: {
name: "name",
type: dynamodb.AttributeType.STRING,
},
removalPolicy: cdk.RemovalPolicy.DESTROY, // cdk destroyでDB削除可
});
DynamoDBをAppSyncのデータソースにする設定です。
AppSyncのリゾルバは直でDynamoDBを操作するか、Lambdaを挟み外部APIをやり取りするか、など設定できます。
今回直でDynamoDBをいじるので、DynamoDBをAppSyncのデータソースに設定します。
// appsyncのdatasource
const dataSource = api.addDynamoDbDataSource("DynamoDataSource", table);
デプロイ内容の説明②:Queryリゾルバ
では、各リゾルバのはまったところは説明しますね!まずQuery関連です!
// query
dataSource.createResolver("getUsersResolver", {
typeName: "Query",
fieldName: "getUsers",
requestMappingTemplate: appsync.MappingTemplate.dynamoDbScanTable(),
responseMappingTemplate: appsync.MappingTemplate.dynamoDbResultList(),
});
dataSource.createResolver("getUserResolver", {
typeName: "Query",
fieldName: "getUser",
// requestMappingTemplate: appsync.MappingTemplate.dynamoDbGetItem('id', 'id'),
// requestMappingTemplate: appsync.MappingTemplate.dynamoDbQuery(
// appsync.KeyCondition.eq("id", "id")
// ),
requestMappingTemplate: appsync.MappingTemplate.fromFile(
path.join(__dirname, "../vtl/get_user.vtl")
),
// responseMappingTemplate: appsync.MappingTemplate.dynamoDbResultList(),
responseMappingTemplate: appsync.MappingTemplate.dynamoDbResultItem(),
});
全データ取得のgetUsersについて、複数件レコードを取り出すので、responseMappingTemplate: appsync.MappingTemplate.dynamoDbResultList(),を間違えて、responseMappingTemplate: appsync.MappingTemplate.dynamoDbResultItem(),にすると、データがあるのに、nullで返されます。。。
逆に場合も同じく、getUserで一件取る場合、必ずappsync.MappingTemplate.dynamoDbResultItem(),に書いてください。
今回はソートキーありのDynamoDBテーブルを使用しているため、appsync.MappingTemplate.dynamoDbGetItem('id', 'id') がそのまま使えなくなります!!!
(ソートキーなしのときには大丈夫でした!)
DynamoDB上では、name(ソートキー)なしで、id(パーティションキー)だけで検索できるのですが、
dynamoDbGetItem関数には、ソートキーなしで検索と設定したり、ソートをパラメータとし一緒にて渡したりすることができないため、MappingTemplate.dynamoDbGetItemのマッピング変換が原因で「DynamoDBスキーマと合わない」というエラーが発生してしまいます。
ちなみに、マッピングテンプレートをいろいろ試すとき、AppSyncのスキーマにも合わせてnameいるかいらないかパラメータを変える必要があります!
type Query {
getUser(id: String!, name: String): User //はてなを削除か、パラメータ自体削除か
getUsers: [User!]!
}
試しに「requestMappingTemplate: appsync.MappingTemplate.dynamoDbGetItem('id', 'id'),」でAWSへデプロイし、クエリすると、以下のエラーになります!
The provided key element does not match the schema (Service: DynamoDb, Status Code: 400, Request ID: LEAT19JUJL8R3PP4QK1CN396P3VV4KQNSO5AEMVJF66Q9ASUAAJG) (SDK Attempt Count: 1
そもそも、ソートキーがある場合、get-itemではなくqueryを使ってレコードを取得するほうがようですが、試しにrequestMappingTemplate: appsync.MappingTemplate.dynamoDbQuery(appsync.KeyCondition.eq("id", "id")),でデプロイしたところ、同じくクエリ時にエラーが発生しました!
おそらく、query条件の書き方が間違っているか、裏でのマッピング変換が想定通り動作せず、DynamoDBがクエリパラメータを正しく受け取れていない可能性がありますね。
Cannot return null for non-nullable type: 'String' within parent 'MoonCatUser' (/getMoonCatUser/id)
やはり、マッピングテンプレートの仕組みを理解するコストが高く、最後は直接VTLで書いて、成功しました!
VTLでは、get-itemでPKとSK両方をパラメータとしてDynamoDBに渡すことができるので、get-itemでやりました!
{
"version": "2017-02-28",
"operation": "GetItem",
"key" : {
"id" : $util.dynamodb.toDynamoDBJson($ctx.args.id),
"name" : $util.dynamodb.toDynamoDBJson($ctx.args.name)
}
}
デプロイ内容の説明③:Mutationリゾルバ
ミューテーションについて、複雑な処理を試していないので、今回は全部マッピングテンプレートで成功しました!
レコードの新規作成処理について、idを自動で発行したいので、auto処理を入れました!
レコードの更新処理について、DynamoDBには「update-item」という処理がなく、レコードを更新するには「put-item」で上書きすることになります!
一部のブログには、「update-item」がないため「Lambda」で更新しようという方法が紹介されていますが、実際にはLambdaは不要で、「CRUD」的な考え方でputを使えば大丈夫です!
// mutation
dataSource.createResolver("addUserResolver", {
typeName: "Mutation",
fieldName: "addUser",
requestMappingTemplate: appsync.MappingTemplate.dynamoDbPutItem(
appsync.PrimaryKey.partition("id").auto().sort("name").is("input.name"),
appsync.Values.projecting("input")
),
responseMappingTemplate: appsync.MappingTemplate.dynamoDbResultItem(),
});
dataSource.createResolver("updateUserResolver", {
typeName: "Mutation",
fieldName: "updateUser",
requestMappingTemplate: appsync.MappingTemplate.dynamoDbPutItem(
appsync.PrimaryKey.partition("id")
.is("input.id")
.sort("name")
.is("input.name"),
appsync.Values.projecting("input")
),
responseMappingTemplate: appsync.MappingTemplate.dynamoDbResultItem(),
});
dataSource.createResolver("DeleteUserResolver", {
typeName: "Mutation",
fieldName: "deleteUser",
requestMappingTemplate: appsync.MappingTemplate.dynamoDbDeleteItem(
"id",
"id"
),
responseMappingTemplate: appsync.MappingTemplate.dynamoDbResultItem(),
});
終わりに
リゾルバーのfieldNameはスキーマと一致していないとエラーになります!
特に、DynamoDBのテーブル名が長くなると、大文字・小文字の入力ミスが原因でエラーが発生しやすいです。
また、マッピングテンプレートの書き方を間違えると、DynamoDBにレコードが存在しているのに取得できないこともよくあります!
マッピングテンプレート関連の関数の使い方を理解するのも大変でした。。。
appsync.MappingTemplate.関数名 の形で書く際に、どのようにマッピング変換が行われるのかをしっかり理解していないと、DynamoDBに対して想定通りのクエリが実行されません。さらに、公式ドキュメントの例も少なく、学習コストが高い印象でした。
ネット上にはさまざまなブログがあるものの、ほとんどが単純な処理の例しか載っていません。
そのため、複数のテーブルをまたぐクエリなどの複雑な処理を行う場合は、マッピングテンプレートを使うよりも、直接VTLで書く方が理解しやすいと感じました。。。
今回はマッピングテンプレートでさんざんハマって、少し嫌いになったのですが、複雑なCRUD処理がない限り、マッピングテンプレートの方が便利かもしれません。
私のようにもともとVTLがわかる人間であれば、マッピングテンプレートの仕組みを理解するよりも、VTLで書いた方が時間を浪費せずに速く進めると思いました!
参考サイト