はじめに
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 を利用できるようにしています。
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から参照される名前になりますので、わかりやすい名前をつけておくと良いでしょう。
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に対して、動的にフィルター条件を指定できるように工夫していたりします。
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に関する定義の構成上、同一のファイルにしにくかったので、ファイルを分けています。
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 されます。
- 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
には、後述のデータソースの名前を指定してください。
type
は Query
か Mutation
、Subscription
を指定します。 field
は関数に応じたフィールド名を指定します。
dataSources:
- type: AWS_LAMBDA
name: findTodolistFn
config:
lambdaFunctionArn: ${cf:todoapp-todos-dev.FindTodoListLambdaFunctionQualifiedArn}
serviceRoleArn: { Fn::GetAtt: [AppSyncLambdaServiceRole, Arn] }
リゾルバーから指定されるAppSyncのデータソースです。
Lambdaの場合は type
に AWS_LAMBDA
を指定してください。
name
は、マッピングテンプレートで指定されるデータソースの名称です。config
には、LambdaのARN、および、それを実行するためのロールを指定します。
${cf:todoapp-todos-dev.FindTodoListLambdaFunctionQualifiedArn}
という定義は、ServerlessFrameworkでデプロイした際に、別にデプロイされたCloudFormationのLambdaのARNを参照します。
ServerlessFrameworkを利用して、Lambda関数のデプロイをした上で、
AWSコンソールから、CloudFormationのスタック情報を確認すると、以下のようなキーで登録されていることが分かります。
上記の形式ではなく、以下のような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.yml
、appsync.yml
は、最終的にこのファイルに include されます。
ここでは、AppSyncから、DynamoDB/Lambdaにアクセスするためのロールを定義していますが、今回の内容では、DynamoDB へは直接連携しないので、削除しても問題ありません。
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関数と連携されるようになっています。
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で、Lambdaと連携させることで、マッピングテンプレート(VTL)ファイルを書かずにGraphQLの処理を実現してみました。
定義が複数あるので、最初は複雑に感じるかもしれませんが、基本的には毎回同様の定義をするだけなので、一度内容を覚えればそれほど難しさは感じないように思います。
実際のサービス開発では、単にDynamoDBから値を取得するだけではなく、様々な処理が関係してくることも多いので、それらはLambdaで実現し、テスト/デバッグ可能なようにしておくことで、継続した開発もしやすくなります。
また、既存で API Gateway と Lambda を連携していた場合でも、今回の内容を元にすれば、リクエスト/レスポンスのパラメータの処理だけを変更すれば、既存の内容をそのままAppSync対応にすることも可能です。
今回の構成では、すべてLambdaで実現していますが、もちろん、DynamoDBへのアクセスと、Lambdaの呼び出しを混在させることも可能です。
どのような構成にするかは、サービスの内容に応じて適宜検討してください。