これから始めるAppSync開発!AppSyncとGraphQL開発手法をCTOが解説します😎

これから始めるAppSync開発!AppSyncとGraphQL開発手法をCTOが解説します😎

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは!

AppSync 開発の情報は、API Gateway と比較すると少ないと思います。GraphQL がまだまだ浸透していない日本の開発市場では、経験値のない開発チームが AppSync の開発方法を整理するのには苦労しますよね。

筆者も最初の AppSync 開発案件は非常に緊張しましたが、最近ではある程度開発手法が固まり、開発に安定感がついてきました。

本記事では、AppSync での API 開発の流れをソースコード交えて解説します!

想定する読者

  • AppSync を導入検討しているエンジニア
  • 自己学習で AppSync 触ったけど実戦経験がなくて不安なヒト
  • RestAPI 開発と具体的にどのような違いがあるのか把握したいヒト

はじめに

GraphQL の学習コストは高い?

わたし達は、Amplify フレームワークの機能で GraphQL のコードを自動生成しています。

AppSync?GraphQL が難しそう…なんてことはありません。

注意点としては、自動生成できるのはあくまでもフロントエンドの GraphQL クライアントで指定する GraphQL コード(Query, Mutation, Subscription)のみです。GraphQL スキーマファイルは自分で作成する必要があります。

※GraphQL スキーマファイルとはバリデーションルールを記述した API 定義書と考えておいてください(後述)

AppSync の構築は Amplify で自動化するのとゴリゴリ CloudFormation 作るのはどっちが良いの?

まず、Amplify は schema.graphql ファイルを起点に、DynamoDB, AppSync, ElasticSearch などを自動的に構築してくれますが、スキーマの変更には制約を伴います。(例えば後から GSI 変更が非常に難しい等)

Amplify の Github の Issue を見るとまだまだ不具合も多く、プロダクション運用には慎重にならなければいけません。

わたし達は、Amplify は GraphQL のクライアント利用に留めています。

既存の AWS 環境へ AppSync を結合できる?

AppSync では、既存の DynamoDB、Cognito、IAM と簡単に連携できます。

もし既存環境の構築が、CloudFormation で自動化されているなら、Ref などを用いて ARN を取得、AppSync の CloudFormation コードに設定するだけで簡単に各サービスと連携できます。

また、RestAP I開発経験者の方は、AppSync VS API Gateway の記事を読んでおくと、以降の理解がスムーズかと思います。

AppSync の開発フローをサンプルコードで解説します

ToDo アプリケーションを題材に、サンプルコードと共に解説を行います。

尚、わたし達は AWS インフラ構築を普段 Serverless Framework で行っていますので、Serverless Framework のコードで解説を行います。

ファイル/ディレクトリ構成

  • /serverless.yml
  • /resources/cognito.yml
  • /resources/dynamodb-tables.yml
  • /resources/iam-role-statements.yml
  • /appsync/stack.yml
  • /appsync/datasources.yml
  • /appsync/schemas/todo.graphql
  • /appsync/mappingtemplates.yml
  • /appsync/resolvers/Query.listTodo.request.vtl
  • /appsync/resolvers/Query.listTodo.response.vtl
  • /appsync/resolvers/Mutation.createTodo.request.vtl
  • /appsync/resolvers/Mutation.createTodo.response.vtl

STEP1. AppSync に関連するリソースを構築

/serverless.yml

プロジェクトのルートディレクトリに作成しましょう。

※ServerlessFramework の serverless-appsync-plugin を使用しますので、事前に npm install serverless-appsync-plugin を実行してください

service: todo-serverless
provider:
  name: aws
  runtime: nodejs12.
  stage: ${opt:stage,"dev"}
  region: ap-northeast-1
  profile: ragate
  environment:
    DYNAMODB_TABLE_PREFIX: ${self:provider.stage}

plugins:
  - serverless-appsync-plugin

custom:
  appSync: ${file(./appsync/stack.yml)}

resources:
  - ${file(./resources/cognito.yml)}
  - ${file(./resources/dynamodb-tables.yml)}
  - ${file(./resources/iam-role-statements.yml)}

/resources/cognito.yml

プロジェクトに応じて設定調整を行ってください。下記のサンプルでは、Email でのサインインが可能なユーザープールを作成しています。

また余談ですが、Cognito を AppSync の提供 API への認証のみで利用する場合、ID プール構築は不要です。IAM 認可が必要な多くのケースは、サイト管理者のアクセスを厳重に調整したいケースです。(IAM 認可でもユーザーの権限管理は実現可能ですが工数を多く消費するので必要な時にのみ認可は設定しましょう)

Resources:
  ExampleUserPool:
    Type: 'AWS::Cognito::UserPool'
    Properties:
      AccountRecoverySetting:
        RecoveryMechanisms:
          - Name: 'verified_email'
            Priority: 1
      AdminCreateUserConfig:
        AllowAdminCreateUserOnly: false
        InviteMessageTemplate:
          EmailMessage: 'Your username is {username} and temporary password is {####}.'
          EmailSubject: 'Your temporary password'
          SMSMessage: 'Your username is {username} and temporary password is {####}.'
      AliasAttributes:
        - email
        - preferred_username 
      AutoVerifiedAttributes:
        - email
      DeviceConfiguration: 
        ChallengeRequiredOnNewDevice: false
        DeviceOnlyRememberedOnUserPrompt: false
      EmailConfiguration:
        EmailSendingAccount: COGNITO_DEFAULT
      EmailVerificationMessage: 'Your verification code is {####}.'
      EmailVerificationSubject: 'Your verification code'
      MfaConfiguration: OFF
      Policies:
        PasswordPolicy:
          MinimumLength: 8
          RequireLowercase: false
          RequireNumbers: false
          RequireSymbols: false
          RequireUppercase: false
          TemporaryPasswordValidityDays: 365
      Schema:
        - AttributeDataType: String
          DeveloperOnlyAttribute: false
          Mutable: true
          Name: email
          Required: true
      SmsAuthenticationMessage: 'Your verification code is {####}.'
      SmsVerificationMessage: 'Your verification code is {####}.'
      UsernameConfiguration:
        CaseSensitive: true
      UserPoolAddOns:
        AdvancedSecurityMode: AUDIT 
      UserPoolName: ${self:service}-${self:provider.stage}-user-pool
      UserPoolTags:
        Service: ${self:service}-${self:provider.stage}
      VerificationMessageTemplate:
        DefaultEmailOption: CONFIRM_WITH_CODE
        EmailMessage: 'Your verification code is {####}.'
        EmailSubject: 'Your verification code'
        SmsMessage: 'Your verification code is {####}.'

  ExampleUserPoolClient:
    Type: AWS::Cognito::UserPoolClient
    Properties:
      CallbackURLs:
        - 'http://localhost:3000'
      ClientName: ${self:service}-${self:provider.stage}-user-pool-client
      DefaultRedirectURI: 'http://localhost:3000'
      ExplicitAuthFlows:
        - ALLOW_USER_PASSWORD_AUTH
        - ALLOW_ADMIN_USER_PASSWORD_AUTH
        - ALLOW_USER_SRP_AUTH
        - ALLOW_REFRESH_TOKEN_AUTH
      LogoutURLs:
        - 'http://localhost:3000'
      PreventUserExistenceErrors: ENABLED
      ReadAttributes:
        - email
        - preferred_username
      RefreshTokenValidity: 10
      SupportedIdentityProviders:
        - COGNITO 
      UserPoolId:
        Ref: ExampleUserPool
      WriteAttributes: 
        - email
        - preferred_username

  ExampleUserPoolGroupCustomer:
    Type: AWS::Cognito::UserPoolGroup
    Properties:
      Description: '一般ユーザー(ToDo閲覧のみ可能)'
      GroupName: Customer
      Precedence: 1 
      UserPoolId: !Ref ExampleUserPool

  ExampleUserPoolGroupAdmin:
    Type: AWS::Cognito::UserPoolGroup
    Properties:
      Description: '管理者(ToDo閲覧及び作成が可能)'
      GroupName: Admin
      Precedence: 0
      UserPoolId: !Ref ExampleUserPool

/resources/dynamodb-tables.yml

DynamoDB テーブルは、シンプルに todo テーブルのみ作成します。

Resources:
  todo:
    Type: 'AWS::DynamoDB::Table'
    DeletionPolicy: Retain
    Properties:
      TableName: ${self:provider.environment.DYNAMODB_TABLE_PREFIX}-example-todo
      AttributeDefinitions:
        - AttributeName: id
          AttributeType: S
      BillingMode: PAY_PER_REQUEST
      KeySchema:
        - AttributeName: id
          KeyType: HASH

/resources/iam-role-statements.yml

AppSync サービスをプリシンバルに、Assume ロールを設定します。

Resources:
  AppSyncLoggingServiceRole:
    Type: AWS::IAM::Role
    Properties:
      Path: /
      RoleName: AppSyncLoggingServiceRole-${self:provider.stage}
      ManagedPolicyArns:
        - "arn:aws:iam::aws:policy/service-role/AWSAppSyncPushToCloudWatchLogs"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: "Allow"
            Principal:
              Service:
                - "appsync.amazonaws.com"
            Action:
              - "sts:AssumeRole"

STEP2. AppSync の構築

Serverless Framework の AppSync プラグインを利用して AppSync を構築します。

/appsync/stack.yml

今回、AppSync の構築は ServerlessFramework のプラグインで行っています。(CloudFormation に近いシンタックスですが CloudFormation ではありません)

ゴリゴリ AppSync の CloudFormation を作成するよりも、シンプルに描きやすい構文を提供してくれるので Serverless Framework 環境をもし利用できるのならプラグイン利用を推奨します。

- name: "PrivateAppsyncEndpoint"
  authenticationType: AMAZON_COGNITO_USER_POOLS
  schema:
    - appsync/schemas/todo.graphql
  caching:
    behavior: FULL_REQUEST_CACHING 
    ttl: 3600 
    atRestEncryption: 
    transitEncryption: 
    type: 'T2_SMALL'
  userPoolConfig:
    defaultAction: ALLOW 
    userPoolId: !Ref ExampleUserPool
  logConfig:
    loggingRoleArn: { Fn::GetAtt: [AppSyncLoggingServiceRole, Arn] }
    level: ERROR 
    excludeVerboseContent: false
  mappingTemplatesLocation: appsync/resolvers
  mappingTemplates:
    - ${file(./appsync/mappingtemplates.yml)}
  dataSources:
    - ${file(./appsync/datasources.yml)}

注意点としては、上記サンプルでは全てのリクエストにキャッシュを適用してますが、リゾルバー単位でキャッシュ指定できる指定方法もあります。

また、「PrivateAppsyncEndpoint」という名称で GraphQLAPI を構築していますが、GraphQL をパブリック公開するケースが発生した時のためにそのような名称にしています。実際、会員限定コンテンツではなく SEO を意識したアプリ構築を行うケースでは PublicAPI を別途構築します。

/appsync/datasources.yml

先ほど構築した DynamoDB を指定していますが、ここに既存で構築されている DynamoDB を指定すれば既存環境へも簡単に AppSync を導入することが可能です。

例えば、すでに RestAPI で構築されているが、社内事情で AppSync へ乗り換えたいなんて時も安心ですよね。(試験的に AppSync 使ってみたいなんて時も問題ないです)

- type: AMAZON_DYNAMODB
  name: "ToDo"
  description: "It is toDo table."
  config:
    tableName: { Ref: todo } 
    versioned: false 

STEP3. スキーマファイルの作成

/appsync/schemas/todo.graphql

恐らくここがもっともつまずくと思います。ただ、ポイントをを抑えれば作成することはそんなに大変ではありません。(ポイントは後述しますね)

type Mutation {
    createTodo(input: CreateTodoInput!, condition: ModelTodoConditionInput): Todo
    @aws_auth(cognito_groups: ["Admin"])
}

type Query {
    getTodo(id: ID!): Todo
    listTodos(filter: ModelTodoFilterInput, limit: Int, nextToken: String): ModelTodoConnection
}

type Todo {
    id: ID!
    name: String!
    description: String
}

type ModelTodoConnection {
    items: [Todo]
    nextToken: String
}

input CreateTodoInput {
    id: ID
    name: String!
    description: String
}

input ModelBooleanInput {
    ne: Boolean
    eq: Boolean
    attributeExists: Boolean
    attributeType: ModelAttributeTypes
}

input ModelFloatInput {
    ne: Float
    eq: Float
    le: Float
    lt: Float
    ge: Float
    gt: Float
    between: [Float]
    attributeExists: Boolean
    attributeType: ModelAttributeTypes
}

input ModelIDInput {
    ne: ID
    eq: ID
    le: ID
    lt: ID
    ge: ID
    gt: ID
    contains: ID
    notContains: ID
    between: [ID]
    beginsWith: ID
    attributeExists: Boolean
    attributeType: ModelAttributeTypes
    size: ModelSizeInput
}

input ModelIntInput {
    ne: Int
    eq: Int
    le: Int
    lt: Int
    ge: Int
    gt: Int
    between: [Int]
    attributeExists: Boolean
    attributeType: ModelAttributeTypes
}

input ModelSizeInput {
    ne: Int
    eq: Int
    le: Int
    lt: Int
    ge: Int
    gt: Int
    between: [Int]
}

input ModelStringInput {
    ne: String
    eq: String
    le: String
    lt: String
    ge: String
    gt: String
    contains: String
    notContains: String
    between: [String]
    beginsWith: String
    attributeExists: Boolean
    attributeType: ModelAttributeTypes
    size: ModelSizeInput
}

input ModelTodoConditionInput {
    name: ModelStringInput
    description: ModelStringInput
    and: [ModelTodoConditionInput]
    or: [ModelTodoConditionInput]
    not: ModelTodoConditionInput
}

input ModelTodoFilterInput {
    id: ModelIDInput
    name: ModelStringInput
    description: ModelStringInput
    and: [ModelTodoFilterInput]
    or: [ModelTodoFilterInput]
    not: ModelTodoFilterInput
}

enum ModelSortDirection {
    ASC
    DESC
}

enum ModelAttributeTypes {
    binary
    binarySet
    bool
    list
    map
    number
    numberSet
    string
    stringSet
    _null
}

ポイントその1:type を最初に作成すること

インプット周りはフロントエンドの事情で微調整する可能性があるので、先に欲しいデータ内容とフロントエンドが利用したい GraphQL の機能(Query, Mutation, Subcscription)を定義します。

type Mutation {
    createTodo(input: CreateTodoInput!, condition: ModelTodoConditionInput): Todo
    @aws_auth(cognito_groups: ["Admin"])
}

type Query {
    getTodo(id: ID!): Todo
    listTodos(filter: ModelTodoFilterInput, limit: Int, nextToken: String): ModelTodoConnection
}

type Todo {
    id: ID!
    name: String!
    description: String
}

type ModelTodoConnection {
    items: [Todo]
    nextToken: String
}

ポイントその2:単一取得系及び複数取得系はコピペ作成

DynamoDB、ElasticSearch へのリクエストの際にフロントエンドから指定するパラメーター(input オブジェクト)は多くのケースで共通なので、コピペで作成が可能です。

input CreateTodoInput {
  ...
}

input ModelBooleanInput {
  ...
}

input ModelFloatInput {
  ...
}

input ModelIDInput {
  ...
}

input ModelIntInput {
  ...
}

input ModelSizeInput {
  ...
}

input ModelStringInput {
  ...
}

input ModelTodoConditionInput {
  ...
}

input ModelTodoFilterInput {
  ...
}

enum ModelSortDirection {
  ...
}

enum ModelAttributeTypes {
  ...
}

STEP4. AppSyn cのマッピングリゾルバーの作成

スキーマ定義を作成しただけでは、AppSync はバックエンドと通信しません。スキーマはあくまでもインプットとアウトプットの I/F 仕様を書いたものなので、実際に動作させるプログラムを VTL 形式で作成する必要があります。(後述)

※VTL を介さず、直接 Lambda と AppSync を接続させる方法もあります

/appsync/mappingtemplates.yml

スキーマに定義したルートオブジェクト(type Query, type Mutation)とデータソースをマッピングします。ここで、例えば ElasticSearch を指定すれば後続の処理は ElasticSearch と通信しますし、DynamoDB または Lambda を指定することも可能です。(Lambda にすれば単一のデータソースに縛られずに複数のデータソースと通信し処理を行うことが可能です)

- dataSource: "ToDo"
  type: "Query"
  field: "listTodos"
- dataSource: "ToDo"
  type: "Mutation"
  field: "createTodo"

/appsync/resolvers/Query.listTodo.request.vtl

ここでは、絞り込みなどは指定せず Scan し Items を返却します。ここに、DynamoDB に関する探索条件を指定すれば、プログラミングせずに JSON 定義のみで DynamoDB と通信することが可能です。

VTL ファイルでは、input オブジェクトに指定した各種パラメーターを利用することができます。

尚、本サンプルではファイル名を Query.xxxx request.vtl とするだけで、mappingtemplates.yml に指定したフィールド名に対応したリゾルバーに自動的に設定されるようになっています。詳しくはこちら

{
    "version": "2017-02-28",
    "operation": "Scan"
}

デプロイすると、自動的にリゾルバーにアタッチされていることがわかります。(デプロイ方法は後述)

また、マッピングリゾルバーテンプレート(VTLファイル)のコードは AppSync 管理コンソールで雛形を取得することが可能です。補足として、完全に自動化はできず、自動生成されたコードに手を加えることが必要なケースが多いです。

/appsync/resolvers/Query.listTodo.response.vtl

レスポンスはリクエストマッピングリゾルバーの返り値をそのまま返却します。

AppSync はここで返却したオブジェクトを、GraphQL スキーマで定義したオブジェクトへ変換してくれますが、もしGraphQL スキーマで定義したオブジェクト形式を満たしていない場合はエラーをフロントエンドへ返却するので注意しましょう。(レスポンスのバリデーションも AppSync にお任せです)

$util.toJson($ctx.result)

/appsync/resolvers/Mutation.createTodo.request.vtl

ポイントなのは、DynamoDB に設定するPK(id)を、AppSync の $util.autoId() で自動生成している点です。AppSync の VTL では、$util に様々な便利関数が存在するのでチェックしておきましょう。

{
    "version" : "2017-02-28",
    "operation" : "PutItem",
    "key" : {
        ## If object "id" should come from GraphQL arguments, change to $util.dynamodb.toDynamoDBJson($ctx.args.id)
        "id": $util.dynamodb.toDynamoDBJson($util.autoId()),
    },
    "attributeValues" : $util.dynamodb.toMapValuesJson($context.args.input)
}

/appsync/resolvers/Mutation.createTodo.response.vtl

$util.toJson($ctx.result)

デプロイの実行

Serverless Framework の公式サイトに則り、下記のコマンドを実行しましょう。

npm install -g serverless
cd [serverless.yml_directory]
sls deploy -v

デプロイが成功したら、あとはフロントエンドの SDK で呼び出すだけです。

AppSync とフロントエンドの通信方法は、別記事で公開予定です。

また、buildspec.yml を使用して Code Build でデプロイを自動化することも可能です。

version: 0.2

phases:
  install:
    runtime-versions:
      nodejs: 10
      docker: 18
  pre_build:
    commands:
      - yarn
      - npm install -g serverless
  build:
    commands:
      - yarn deploy
  post_build:
    commands:
      - echo Build completed on `date`

最後に、AppSync での開発フローを整理

例えばアジャイル形式のプロジェクトで週一回程度定例があるとして、そこで生まれた機能要件に対して下記のようなワークフローで AppSync 開発へアプローチします。

  1. 新しいデータソースが必要か検討
  2. スキーマ情報を適宜追加または更新
  3. マッピングリゾルバーを追加または更新

尚、データソースに Lambda を指定することで、AppSync→Lambda のようなリクエストを行うことも可能なので、基本的に API Gateway で出来ることは AppSync で実現可能です。

注意点としては、RestAPI のような API のバージョニング(/v1/xxxxx)は AppSync 及び GraphQL では使用しません。GraphQL での API 開発では基本的に既存の API を随時更新しながら開発するのがベターとされているので、API のバージョニングの思想は存在しません。(リーンスタートアップ的な考え方ですね)

まとめ

正直、初めのうちはとっつきにくいところは多々あるかと思いますが、慣れてくると新規事業の立ち上げは RestAPI 開発よりも早く行えるようになり、事実としてわたし達は GraphQL を採用してから、新規開発の速度は30%以上早くなっています。(早く作ることで結果、お客様もコスト安になりウィンウィンです)

GraphQL、AppSyncの開発はお気軽にご相談ください。