0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

エラーを潰しながら、AWS CDKでAppSync、DynamoDBをデプロイしてみた

Posted at

やりたいこと

これまでTerraformなどのIaCツールを使用してAWSリソースのデプロイを試してきましたが、CDKを使えば、普段使い慣れたTypeScriptやPythonの文法でTerraformと同様の役割を果たせるため、その一連の処理を試してみたいです!

CDKプロジェクトの作成

まず、以下の公式ドキュメントを見てCDKのプロジェクトを作ります。

cdk init app --language typescript

実行後、このようなフォルダ構成ができました!
image.png

次はブートストラップし、CDK実行するのに必要なAmazon S3 バケット、AWS IAM ロール、AWS Systems Manager (SSM) パラメータストアを行います!

cdk bootstrap

続いて、bin配下のファイルを開き、AWSの接続関連設定を行います。
個人的には、なるべくAWSのアクセス情報をソースコードに書かず、環境変数に設定するのがセキュリティ的によいかと思います。

image.png

これで初期設定がひとまず完了!

ほかにもいろいろcdk系コマンドがあるので、必要なときまたドキュメントを精査しましょう!

AppSync、DynamoDBをデプロイ

まず、二つフォルダを追加します。
shemasフォルダには、appsyncのスキーマを格納、vtlフォルダにはappsyncのリゾルバに必要なvtlファイルを格納。

VTLとは、AWS AppSync や API Gateway のマッピングテンプレートで、リクエストやレスポンスを変換し、DynamoDBとやり取りするために使われます。
DynamoDBは独自のJSON形式(Amazon Ion に近い構造)を使ってデータを格納するため、AppSync や API Gateway のマッピングテンプレート(VTL)でリクエストやレスポンスの変換が必要になります。

image.png

スキーマから編集します。今回は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を編集することになります!

image.png

以下のように編集します!

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(),に書いてください。

image.png

今回はソートキーありのDynamoDBテーブルを使用しているため、appsync.MappingTemplate.dynamoDbGetItem('id', 'id') がそのまま使えなくなります!!!
(ソートキーなしのときには大丈夫でした!)

DynamoDB上では、name(ソートキー)なしで、id(パーティションキー)だけで検索できるのですが、

image.png

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

image.png

そもそも、ソートキーがある場合、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)

image.png

やはり、マッピングテンプレートの仕組みを理解するコストが高く、最後は直接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)
  }
}

image.png

デプロイ内容の説明③:Mutationリゾルバ

ミューテーションについて、複雑な処理を試していないので、今回は全部マッピングテンプレートで成功しました!

image.png

レコードの新規作成処理について、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で書いた方が時間を浪費せずに速く進めると思いました!

参考サイト

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?