LoginSignup
7
5

More than 1 year has passed since last update.

ServerlessFramework+AppSyncで、マッピングテンプレート(VTLファイル)レスなGraphQL構成を実現する

Posted at

はじめに

AppSync-Serverless

AppSyncは、GraphQLの開発を容易にする、AWSが提供するマネージドサービスです。
ServerlessFrameworkでもAppSyncに対応したプラグインが存在し、それらを利用することで、GraphQLを簡単にサービスに取り込むことが可能になります。

典型的な方法として、AppSyncのリゾルバー(GraphQLのAPIとデータソースを連携させるための処理)では Apache Velocity Template Language (VTL) で記述されたマッピングテンプレートを利用します。
ただ、すでにAppSyncを利用している人は実感があるかもしれませんが、このマッピングテンプレートで、複雑な処理をしようとすると、なかなか苦労します。

定義するだけでDynamoDBへのアクセスを実現できるのは良いのですが、実際のサービス開発となると、単にDynamoDBへアクセスするだけではなく、複雑なバリデーションを行ったり、処理完了後に通知を行ったりすることが必要となり、その都度調査しながら、VTLファイルを作成するのが大変に感じる状況がありました。

AppSyncは、データソースとして、DynamoDBだけでなく、Lambdaも利用することができます。
そこで今回は、AppSyncから直接DynamoDBにはアクセスせず、Lambdaを利用するようにすることで、マッピングテンプレート(VTLファイル)レスなGraphQL構成を実現したいと思います。
サービスの内容は、シンプルなTODO管理です。

ファイル構成

全体を把握しやすくするため、最初にファイル構成を示します。

├── customs
│   ├── appsync-dev.yml     ・・・ AppSyncの内容における環境ごとの定義内容
│   └── dev.yml             ・・・ Lambdaやその他のリソースにおける環境ごとの定義内容
├── resources
│   ├── serverless-cognito.yml
│   └── serverless-dynamodb.yml
├── appsync
│   ├── appsync.yml         ・・・ serverless-appsync-pluginの定義内容
│   ├── schemas
│   │   └── todos.graphql   ・・・ GraphQLのスキーマ定義
│   └── serverless.yml      ・・・ AppSyncに関するServerlessFramework定義
├── common
│   ├── appsync_util.py
│   └── timestamp_util.py
│── todos
│       ├── domain
│       │   └── todo_service.py
│    ├── todo_handler.py   ・・・ Lambdaのハンドラ
│    └── serverless.yml    ・・・ Lambdaに関するServerlessFramework定義
└── requirements.txt

ServerlessFrameworkを利用してデプロイできるようにしていますが、Lambdaの定義と、AppSync関連の定義を分けています。
今回、ServerlessFramework自体の定義方法や利用方法については、説明を割愛しますが、おおよそ以下のようにしています。

  • Lambdaは、今回Pythonを利用して実装しています。Lambda関数の内容は todos の配下になります。
  • AppSyncの内容は appsync の配下になります。

詳細については、以下のリポジトリに関連ファイル一式を登録していますので、そちらを参照してください。

Lambdaの内容

Lambdaに関する定義は、以下のファイルに別れます。

  • customs/dev.yml ・・・ 環境ごとに定義する内容です。
  • todos/serverless.yml ・・・ ServerlessFrameworkのデプロイ用の定義です。

customs/dev.yml

dev.yml の内容は、AWSプロファイルの定義や、全体で共通となる定義を行っています。
AppSyncとは直接関係はありませんが、デプロイサイズを小さくして高速化できるよう、 Serverless Python Requirements プラグイン を利用して、Lambda Layers を利用できるようにしています。

customs/dev.yml
deployRegion: us-west-2
profile: todoapp-dev
runtime: python3.8

prop:
  retentionInDays: 3
  timeout: 30
  memorySize: 256

layers:
  - ${cf:todoapp-requirements-layer-dev.PythonRequirementsLambdaLayerQualifiedArn}

todos/serverless.yml

serverless.yml は、Lambda関数の定義をしています。
ServerlessFrameworkにおける通常のLambda定義と同じ内容ですが、AppSyncから呼び出される際は events の定義は不要です。
また、関数名(ここでは、「findTodoList」や「addTodo」など)は、別途AppSyncから参照される名前になりますので、わかりやすい名前をつけておくと良いでしょう。

todos/serverless.yml
custom: ${file(../customs/${opt:stage, 'dev'}.yml)}

service: todoapp-todos

provider:
  name: aws
  runtime: ${self:custom.runtime}
  stage: ${opt:stage, 'dev'}
  region: ${self:custom.deployRegion}
  profile: ${self:custom.profile}
  timeout: ${self:custom.prop.timeout}
  memorySize: ${self:custom.prop.memorySize}
  iam:
    role:
      statements:
        - Effect: 'Allow'
          Action:
            - "xray:PutTraceSegments"
            - "xray:PutTelemetryRecords"
            - "dynamodb:GetItem"
            - "dynamodb:PutItem"
            - "dynamodb:UpdateItem"
            - "dynamodb:DeleteItem"
            - "dynamodb:Query"
            - "dynamodb:Scan"
          Resource: '*'

package:
  patterns:
    - '../common/**'
    - '!tests/**'

plugins:
  - serverless-prune-plugin

functions:
  findTodoList:
    handler: todo_handler.find_todolist_handler
    layers: ${self:custom.layers}
  findTodo:
    handler: todo_handler.find_todo_handler
    layers: ${self:custom.layers}
  addTodo:
    handler: todo_handler.add_todo_handler
    layers: ${self:custom.layers}
  updateTodo:
    handler: todo_handler.update_todo_handler
    layers: ${self:custom.layers}
  deleteTodo:
    handler: todo_handler.delete_todo_handler
    layers: ${self:custom.layers}

Lambda関数の実装(Python)

Lambda関数の実装内容です。

AppSyncからのイベントは、JSONの内容をdictに変換した内容として受信されます(eventパラメータ)。
GraphQLのメッセージは、 arguments 配下に格納されますので、そこからメッセージのパラメータを取得するようにしています。

todo_id = event.get('arguments', {}).get('todo_id')

一覧表示で、条件を指定してのフィルタリングを行えるようにしているので、 find_todolist_handler関数の内容は少し複雑になっています。
この内容については、GraphQLのスキーマ定義に合わせて、データをパースできるようにしています。

また、データの格納にはDynamoDBを利用していますが、処理の内容は todo_service.py に実装しています。
この内容自体は、AppSyncには直接関係はないため、今回は説明を割愛しますが、具体的な実装内容はリポジトリを参照してください。
DynamoDBに対して、動的にフィルター条件を指定できるように工夫していたりします。

todos/todo_handler.py
import logging

from common.appsync_util import FilterInput

from domain.todo_service import TodoService

logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)


def find_todolist_handler(event, context):
    logger.info(event)

    filters = event.get('arguments', {}).get('filters')
    next_token = event.get('arguments', {}).get('next_token')

    filter_options = []
    if (filters is not None) and (len(filters) > 0):
        for filter in filters:
            filter_attr = list(filter.keys())[0]
            condition_dict = filter[filter_attr]
            condition_fn = list(condition_dict.keys())[0]
            condition_value = condition_dict[condition_fn]

            filter_input = FilterInput(attr=filter_attr, condition=condition_fn, value=condition_value)
            filter_options.append(filter_input)

    todo_service = TodoService()
    todolist, next_token = todo_service.find_todolist(filters=filter_options, next_token=next_token)

    response = {
        'todos': todolist,
        'next_token': next_token
    }
    return response


def find_todo_handler(event, context):
    todo_id = event.get('arguments', {}).get('todo_id')
    todo_service = TodoService()
    todo = todo_service.find_todo(todo_id)
    return todo


def add_todo_handler(event, context):
    logger.info(event)

    todo_params = event.get('arguments', {}).get('todo')
    todo_service = TodoService()
    todo = todo_service.add_todo(todo_params)
    return todo


def update_todo_handler(event, context):
    logger.info(event)

    todo_params = event.get('arguments', {}).get('todo')
    todo_service = TodoService()
    todo = todo_service.update_todo(todo_params)
    return todo


def delete_todo_handler(event, context):
    todo_id = event.get('arguments', {}).get('todo_id')
    todo_service = TodoService()
    deleted = todo_service.delete_todo(todo_id)
    return deleted

AppSyncの内容(ServerlessFramework関連)

AppSyncについては、まずServerlessFrameworkに関する内容について説明します。
定義内容は、以下のファイルに別れます。

  • customs/appsync-dev.yml ・・・ 環境ごとに定義する内容です。
  • appsync/appsync.yml ・・・ AppSyncの定義内容です。
  • appsync/serverless.yml ・・・ ServerlessFrameworkのデプロイ用の定義です。

customs/appsync-dev.yml

Lambdaに関する dev.yml と同じような内容ですが、AppSyncに関する定義の構成上、同一のファイルにしにくかったので、ファイルを分けています。

customs/appsync-dev.yml
deployRegion: us-west-2
accountId: ${opt:accountId, ''}
profile: todoapp-dev

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

# Cognito
cognito:
  userPoolId: us-west-2_XXXXXXXXXX
  clientId: XXXXXXXXXXXXXXXXXXX
  authorizer:
    name: todoapp-authorizer
    arn: arn:aws:cognito-idp:{self:deployRegion}:{self:accountId}:userpool/uus-west-2_XXXXXXXXXX

定義のポイント

この内容では、

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

のように定義して、AppSyncに関する定義を分割した上で、参照できるようにしています。

appsync/appsync.yml

AppSyncの定義は、Serverless Appsync Plugin プラグイン を利用して行います。
このファイルは、前述した customs/appsync-dev.yml で include されます。

appsync/appsync.yml
- name: "todoapp-endpoint-${opt:stage, 'dev'}"
  schema:
    - ./schemas/todos.graphql
  authenticationType: AMAZON_COGNITO_USER_POOLS
  userPoolConfig:
    defaultAction: ALLOW
    awsRegion: ${self:custom.deployRegion}
    userPoolId: ${self:custom.cognito.userPoolId}
  defaultMappingTemplates:
    request: false
    response: false
  mappingTemplatesLocation: ./mapping-templates
  # クエリ/リゾルバー
  mappingTemplates:
    # タスク情報を複数件取得する
    - dataSource: findTodolistFn
      type: Query
      field: listTodos
    # タスク情報を1件取得する
    - dataSource: findTodoFn
      type: Query
      field: getTodo
    # タスクを登録する
    - dataSource: addTodoFn
      type: Mutation
      field: addTodo
    # タスクを更新する
    - dataSource: updateTodoFn
      type: Mutation
      field: updateTodo
    # タスクを削除する
    - dataSource: deleteTodoFn
      type: Mutation
      field: deleteTodo
  # データソース
  dataSources:
    - type: AMAZON_DYNAMODB
      name: todoTable
      description: TODO
      config:
        tableName: todo
        serviceRoleArn: { Fn::GetAtt: [AppSyncDynamoDBServiceRole, Arn] }
    - type: AWS_LAMBDA
      name: findTodolistFn
      config:
        lambdaFunctionArn: ${cf:todoapp-todos-dev.FindTodoListLambdaFunctionQualifiedArn}
        serviceRoleArn: { Fn::GetAtt: [AppSyncLambdaServiceRole, Arn] }
    - type: AWS_LAMBDA
      name: findTodoFn
      config:
        lambdaFunctionArn: ${cf:todoapp-todos-dev.FindTodoLambdaFunctionQualifiedArn}
        serviceRoleArn: { Fn::GetAtt: [AppSyncLambdaServiceRole, Arn] }
    - type: AWS_LAMBDA
      name: addTodoFn
      config:
        lambdaFunctionArn: ${cf:todoapp-todos-dev.AddTodoLambdaFunctionQualifiedArn}
        serviceRoleArn: { Fn::GetAtt: [AppSyncLambdaServiceRole, Arn] }
    - type: AWS_LAMBDA
      name: updateTodoFn
      config:
        lambdaFunctionArn: ${cf:todoapp-todos-dev.UpdateTodoLambdaFunctionQualifiedArn}
        serviceRoleArn: { Fn::GetAtt: [AppSyncLambdaServiceRole, Arn] }
    - type: AWS_LAMBDA
      name: deleteTodoFn
      config:
        lambdaFunctionArn: ${cf:todoapp-todos-dev.DeleteTodoLambdaFunctionQualifiedArn}
        serviceRoleArn: { Fn::GetAtt: [AppSyncLambdaServiceRole, Arn] }
  xrayEnabled: true

定義のポイント

定義のポイントをいくつか説明します。

  schema:
    - ./schemas/todos.graphql

GraphQLのスキーマ定義です。複数指定することも可能です。

  userPoolConfig:
    defaultAction: ALLOW
    awsRegion: ${self:custom.deployRegion}
    userPoolId: ${self:custom.cognito.userPoolId}

GraphQLのAPIの認証に、Cognitoのユーザープールを利用するので、その指定をしています。

  defaultMappingTemplates:
    request: false
    response: false

GraphQLのリゾルバーのマッピングテンプレートの指定です。
個々のリゾルバーのマッピング(mappingTemplates)でも指定可能ですが、今回、Lambdaを利用するので、デフォルトで false を指定しています。
AppSyncから、Lambdaを直接呼び出す場合は、 request/response 共に false を指定してください。

  mappingTemplates:
    # タスク情報を複数件取得する
    - dataSource: findTodolistFn
      type: Query
      field: listTodos

個々のリゾルバーのマッピングテンプレートです。
dataSource には、後述のデータソースの名前を指定してください。
typeQueryMutationSubscription を指定します。 field は関数に応じたフィールド名を指定します。

  dataSources:
    - type: AWS_LAMBDA
      name: findTodolistFn
      config:
        lambdaFunctionArn: ${cf:todoapp-todos-dev.FindTodoListLambdaFunctionQualifiedArn}
        serviceRoleArn: { Fn::GetAtt: [AppSyncLambdaServiceRole, Arn] }

リゾルバーから指定されるAppSyncのデータソースです。
Lambdaの場合は typeAWS_LAMBDA を指定してください。
name は、マッピングテンプレートで指定されるデータソースの名称です。config には、LambdaのARN、および、それを実行するためのロールを指定します。

${cf:todoapp-todos-dev.FindTodoListLambdaFunctionQualifiedArn} という定義は、ServerlessFrameworkでデプロイした際に、別にデプロイされたCloudFormationのLambdaのARNを参照します。

ServerlessFrameworkを利用して、Lambda関数のデプロイをした上で、
AWSコンソールから、CloudFormationのスタック情報を確認すると、以下のようなキーで登録されていることが分かります。

lambda-cfn.jpg

上記の形式ではなく、以下のようなARNを指定することも可能です。
${cf:ARNキー} で指定した場合は、バージョンまで自動で指定されますが、以下の場合はバージョンが含まれないため、常に最新のデプロイバージョンのLambdaが適用されます。

lambdaFunctionArn: arn:aws:lambda:${リージョン}:${アカウtのID}:function:todoapp-todos-dev-{Lambdaの関数名}

また、{ Fn::GetAtt: [AppSyncLambdaServiceRole, Arn] } というロールのARNは、次で説明するAppSyncの serverless.yml で定義をしています。

appsync.yml は、最終的に serverless.yml に include されるため、その中に定義されているロールの定義を参照しています。

appsync/serverless.yml

AppSyncの内容をデプロイするためのServerlessFrameworkの定義です。
appsync-dev.ymlappsync.yml は、最終的にこのファイルに include されます。

ここでは、AppSyncから、DynamoDB/Lambdaにアクセスするためのロールを定義していますが、今回の内容では、DynamoDB へは直接連携しないので、削除しても問題ありません。

appsync/serverless.yml
custom: ${file(../customs/appsync-${opt:stage, 'dev'}.yml)}

service: todoapp-appsync

provider:
  name: aws
  stage: ${opt:stage, 'dev'}
  region: ${self:custom.deployRegion}
  profile: ${self:custom.profile}

plugins:
  - serverless-appsync-plugin

resources:
  Resources:
    # AppSync が DynamoDB を操作するためのロール
    AppSyncDynamoDBServiceRole:
      Type: "AWS::IAM::Role"
      Properties:
        RoleName: ${self:service}-dynamodb-role-${opt:stage, self:provider.stage}
        AssumeRolePolicyDocument:
          Version: "2012-10-17"
          Statement:
            - Effect: "Allow"
              Principal:
                Service:
                  - "appsync.amazonaws.com"
              Action:
                - "sts:AssumeRole"
        Policies:
          - PolicyName: "${self:service}-dynamo-policy-${opt:stage, self:provider.stage}"
            PolicyDocument:
              Version: "2012-10-17"
              Statement:
                - Effect: "Allow"
                  Action:
                    - "dynamodb:GetItem"
                    - "dynamodb:PutItem"
                    - "dynamodb:UpdateItem"
                    - "dynamodb:DeleteItem"
                    - "dynamodb:BatchGetItem"
                    - "dynamodb:BatchWriteItem"
                    - "dynamodb:Query"
                    # - "dynamodb:Scan"
                  Resource:
                    - "*"
    # AppSync が Lambda を操作するためのロール
    AppSyncLambdaServiceRole:
      Type: "AWS::IAM::Role"
      Properties:
        RoleName: ${self:service}-lambda-role-${opt:stage, self:provider.stage}
        AssumeRolePolicyDocument:
          Version: "2012-10-17"
          Statement:
            - Effect: "Allow"
              Principal:
                Service:
                  - "appsync.amazonaws.com"
              Action:
                - "sts:AssumeRole"
        Policies:
          - PolicyName: "${self:service}-lambda-policy-${opt:stage, self:provider.stage}"
            PolicyDocument:
              Version: "2012-10-17"
              Statement:
                - Effect: "Allow"
                  Action:
                    - "lambda:invokeFunction"
                  Resource:
                    - "*"

AppSyncの内容(GraphQLのスキーマ定義)

GraphQLのスキーマ定義です。

type として、1件のデータを示す Todo と、一覧取得用の TodoList を定義しています。
スキーマの定義内容は、以下が参考になると思います。AppSyncの場合、AWSDataTime などのカスタムスカラー型も利用できます。

Query や Mutaion の内容は、サービスとして利用したい内容に応じて定義してください。
ここでは、基本的なCRUDの内容を定義しており、それぞれの内容がLambda関数と連携されるようになっています。

appsync/schema/todos.graphql
type Todo {
  todo_id: ID!
  title: String!
  description: String
  status: String
  assignee: String
  created_at: AWSDateTime
  updated_at: AWSDateTime
}

type TodoList {
  todos: [Todo]
  next_token: String
}

type Query {
  listTodos(filters: [ModelTodoFilterInput], next_token: String): TodoList
  getTodo(todo_id: ID!): Todo
}

type Mutation {
  addTodo(todo: TodoInput!): Todo
  updateTodo(todo: TodoInput!): Todo
  deleteTodo(todo_id: ID!): Boolean
}

input TodoInput {
  todo_id: ID
  title: String
  description: String
  status: String
  assignee: String
}

input ModelTodoFilterInput {
    title: ModelStringInput
    status: ModelStringInput
    assignee: ModelStringInput
}

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
}

デプロイ

デプロイの仕方はServerlessFrameworkの標準の内容ですが、今回、LambdaとAppSycnを分けてデプロイしているため、実際には、Lambdaをデプロイ → AppSyncをデプロイ、という流れでそれぞれデプロイしています。

sls deploy --stage dev --aws-profile {your-profile} --accountId {your-account-id} --config serverless.yml

テスト

AWSコンソールで、AppSync画面の「クエリ」メニューから、定義したスキーマ/クエリのテストを行うことができます。

この画面から、実行の確認をすることで、デプロイした内容が適切に動作するかどうか、すぐに確認ができます。
Cognitoの認証を設定している場合でも、画面から認証を行ってテストすることができるので、便利ですね。

appsync-query-test.jpg

まとめ

AppSyncで、Lambdaと連携させることで、マッピングテンプレート(VTL)ファイルを書かずにGraphQLの処理を実現してみました。
定義が複数あるので、最初は複雑に感じるかもしれませんが、基本的には毎回同様の定義をするだけなので、一度内容を覚えればそれほど難しさは感じないように思います。

実際のサービス開発では、単にDynamoDBから値を取得するだけではなく、様々な処理が関係してくることも多いので、それらはLambdaで実現し、テスト/デバッグ可能なようにしておくことで、継続した開発もしやすくなります。
また、既存で API Gateway と Lambda を連携していた場合でも、今回の内容を元にすれば、リクエスト/レスポンスのパラメータの処理だけを変更すれば、既存の内容をそのままAppSync対応にすることも可能です。

今回の構成では、すべてLambdaで実現していますが、もちろん、DynamoDBへのアクセスと、Lambdaの呼び出しを混在させることも可能です。
どのような構成にするかは、サービスの内容に応じて適宜検討してください。

7
5
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
7
5