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

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

こんにちは!

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コードに設定するだけで簡単に各サービスと連携できます。

また、RestAPI開発経験者の方は、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. AppSyncのマッピングリゾルバーの作成

スキーマ定義を作成しただけでは、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の開発はお気軽にご相談ください。