AWS AppSyncでサーバーレスなリアルタイムデータ同期
この記事は「Eureka Advent Calendar」 20 日目の記事です。
こんにちは。今年2月にeurekaにJoinしたBackend チームの金井です。入社からあっという間に時が過ぎ、あと 2 月ほどで入社から 1 年経つんだなあと思うと感慨深いです。
昨日の記事はBackendチームの山下さんによる「Goでのオススメエラーハンドリング手法」でしたが、本記事では AWS AppSync(以下 AppSync)について紹介します。自分はPairs の新しい機能実装に向けていくつか技術調査をしているのですが、最近ではAppSync 周りを調べる機会が多かったので、Advent Calendar の題材として記事を書いてみることにしました。
AWS AppSync
フルマネージド型の GraphQL サービスです。GraphQL API の開発を色々と自動化してくれます。GraphQL クライアントからのリクエストのパースや変換・データの取得・変更先(データソース)へのつなぎ込み処理などを簡単に実装することができます。フルマネージド型のサービスのためコンピューティングリソースを直接ユーザーが管理する必要はなく、認証や運用後のメトリクス・ログ収集、キャッシュの仕組みもサポートされています。
Subscription
AppSyncのリアルタイムデータ同期機能は、GraphQL の Subscription 実装の形で提供されます。GraphQL は Query,Mutation,Subscription の 3 つの操作をサポートしますが、この内 Subscription はある GraphQL クライアントによる Mutation 操作に対する、別のGraphQLクライアントの購読(subscribe)処理になります。この subscribe によってクライアントはローカルのデータを更新し、常にリモート上のデータとの同期を図ることができます。
AppSync では GraphQL スキーマ上で subscription を定義するとき、subscription directive (@aws_subscribe
)を使用して対応する mutation 処理を指定することができます。これにより、subscription がどの mutation 処理を subscribe するのかを複雑な実装なしに定義することができます。
type Subscription {
addedEvent: Event
@aws_subscribe(mutations: ["addEvent"])
}type Mutation {
addEvent(title: String, category: Category): Event
}enum Category {...}
上記スキーマ例ではSubscription
の addedEvent
が Mutation であるaddEvent
に対応しています。 ある GraphQL クライアントが addEvent
を実行したとき、 addedEvent
実行中のクライアントは追加された Event
を subscribe できます。
Subscription
のフィールドに引数を渡すことで、subscribe する Event
を制御することもできます。以下の addEvent
は、特定の category
を含む Event
のみを subscribe します。
type Subscription {
addedEvent(category: Category): Event
@aws_subscribe(mutations: ["addEvent"])
}
接続時は以下のようになります。
subscription {
addedEvent(category: HEALTH) {
id
title
category
}
}
特定の条件を満たす Event
を subscribe する以前に、subscribe 接続可能なクライアントを制御したい場合には、後述するAppSyncの認証機能、リゾルバ、およびリゾルバに紐づくマッピングテンプレートを利用できます。
WebSocket
AppSync の Subscription 機能は WebSocket 経由で提供されます。実際には以下の 2 つのプロトコルのうちいずれかを選択することになります。
- MQTT over WebSocket
- pure WebSocket
以前の AppSync の Subscription は MQTT over WebSocket のみをサポートしていましたが、 昨年よりpure WebSocket も使用できるようになりました。これによりペイロードサイズの増加・CloudWatch メトリクスへの対応が可能になっており、pure WebSocket が今は推奨となっています。一方で、MQTTから変更した場合一部の subscription 仕様に変更が発生するので注意が必要です[¹]。
[¹]: subscriber が変更を subscribe したとき、接続時に指定した選択セットのフィールドのみが返戻されるようになります。MQTT over WebSocket では、mutator が指定した選択セットのフィールドが subscriber の選択セットに関わらず返ってくるという仕様でした。
リゾルバ
リゾルバは GraphQL スキーマで定義したフィールドの処理実装になります。本来であればGraphQLサーバー上でリゾルバの実装を行う必要がありますが、AppSync では基本的に後述するマッピングテンプレート・データソースを設定するだけで実装できます。
AppSync の Subscription を使用する場合、対応する Mutation フィールドにリゾルバを設定します。どのデータソースに対して変更操作を行うのか、データをどのように変換するか、入出力データの操作・変換について指定します。この指定はマッピングテンプレート内で行います。
リゾルバは通常単一のリクエスト/レスポンスマッピングテンプレートと単一のデータソースのみ設定できますが、パイプラインリゾルバと呼ばれるタイプのリゾルバを使用すればリクエスト/レスポンスマッピングテンプレート及びデータソースを「関数(function)」として扱い、これを数珠つなぎにして使用できます。これにより、複数データソースに対する操作を実施したり、データソース操作の前後に処理を挟み込むことが可能になります。
マッピングテンプレート
マッピングテンプレートは単一のリゾルバのリクエスト/レスポンスに一つずつ、あるいはパイプラインリゾルバの各関数リクエスト/レスポンスに一つずつ定義されます。リクエストマッピングテンプレートは入力されたフィールドの引数を受け取り、所望の変換を実行した上でデータソースに引き渡します。以下は addEvent
Mutation に紐づくリゾルバに定義したリクエストマッピングテンプレートです。このテンプレートでは DynamoDB テーブルに対して PutItem
を実行します。
{
"version" : "2017-02-28",
"operation" : "PutItem",
"key" : {
"id": $util.dynamodb.toDynamoDBJson($util.autoId()),
},
"attributeValues" : $util.dynamodb.toMapValuesJson($ctx.args)
}
上記の例では DynamoDB API の PutItem
を operation
として呼び出します。 テーブルのキーを key
に、保存する属性を attributes
に指定しています。DynamoDB API 互換のリクエストに変換するため、 ヘルパー関数$util.dynamodb.toDynamoDBJson()
を使用しています。この関数の返戻値は型キーと値からなるオブジェクトになります。例えば $util.dynamodb.toDynamoDBJson("hoge")
の返戻値は { "S": "hoge" }
です。
次にレスポンスマッピングテンプレート例になります。ここで記述した JSON がそのままレスポンスとなります。
$util.toJson($ctx.result);
$util.toJson
はオブジェクトを受け取り、文字列化されたオブジェクトの JSON 表現を返します。
このように、マッピングテンプレート上で使用可能な様々なヘルパー関数を使用して、クライアントからの入力・データソースからの出力を制御することができます。
データソース
GraphQL で操作する対象のデータのソースには下記を選択することができます。
- Amazon DynamoDB
- AWS Lambda
- Amazon Elasticsearch Service
- HTTP endpoint
- Amazon Aurora Serverless
データソースはリゾルバ、及びパイプラインリゾルバの関数に設定でき、リゾルバのマッピングテンプレート上で接続するデータソースごとに必要な変換を行います。
個人的に使いやすいと思うのがDynamoDBとLambdaです。データソースを DynamoDB とする場合、GraphQL スキーマ に基づいて table を生成したり、逆に DynamoDB table の定義から GraphQL スキーマ を作成することができます。一方でクエリ要件が増えてくると table のインデックスも増やす必要が出てくるので、データ複製によるコストには注意が必要です。 Lambda も上記以外のデータソースと接続したい場合や、別サービスとの連携処理に便利です。基本的にLambdaをデータソースとしてつないでおけば、関数ロジック内で色々できるので用途は多いと思います。ただしLambda自体のクォータや、Lambdaと組み合わせたサービスやシステムの制約には気をつける必要はあります[²]。
[²]例えばRDBをLambdaを介してデータソースにしたとき、Lambda インスタンスにつき 1 コネクション消費するので、コネクションの問題に対処する必要がある…などです。RDS Proxy を使用すればコネクションプールの機構を設けることができますが、Aurora クラスターを使用していると、レプリカへの紐付けはできない(書き込み DB インスタンスに対してのみプロキシを紐付けられる)などの問題が発生するのでそこもまた注意が必要です。
認証
AppSync は GraphQL API endpoint へ接続するクライアントを制御するための、以下の 4 つのデフォルト認証モードをサポートしています。
API_KEY
API key による認証
AWS_IAM
IAM による認証
OPENID_CONNECT
OpenID Connect プロバイダーを接続して認証
AMAZON_COGNITO_USER_POOLS
Amazon Cognit ユーザープール上のユーザー情報を使用して認証
また endpoint 自体にではなく、スキーマの個々のフィールドに対して承認設定を追加したい場合、以下のディレクティブが使用できます。 @aws_subscribe
同様このディレクトティブをスキーマ上のフィールドに付与することで有効になります。
- @aws_api_key
- @aws_iam
- @aws_oidc
- @aws_cognito_user_pools
type Mutation {
addEvent(title: String, category: Category): Event
@aws_api_key
}
type Event @aws_iam{
id: ID!
title: String
category: Category
}
enum Category {
FOOD
VACATION
HEALTH
}
その他のアクセス制御手段として、例えばパイプラインリゾルバーを利用してデータ操作用関数の前段に認可用関数を用意し、認可処理がうまく行った場合のみ後段の関数を実行して成功結果を返す、といった方法があります。
Example
ここまで一通り AppSync の機能の概要を簡単ですが紹介しました。次は実際に AppSync を使用した簡単な GraphQL API とそのクライアントを作成してみます(ベースとなる実装はこちら)。
次に示すのは、subscription によるリアルタイムデータ動機機能を使用したイベント通知の例になります。あるクライアントが Mutation 処理を実施したとき、その通知Event
を他のクライアントが受け取ります。ここではIAMによるAppSyncの認証機能を使用している前提ですが、本番環境では実運用を鑑みた認証・認可の設定を行ってください。
GraphQL スキーマの定義は以下になります。
この GraphQL API ではイベント情報 Event
を扱います。 getEvent
は単一の Event
を id
をキーに取得します。 addEvent
は単一の Event
を作成し、データソース上に保存します。今回は DynamoDB table をデータソースとして使用します。イベントの作成が成功し保存された結果が返りますが、このとき addedEvent
によって subscribe しているクライアントも同様の結果を受け取ります。
データソース(DynamoDB, tf)
今回はDynamo DBテーブル AppSyncEvent
を使用します。
resource "aws_dynamodb_table" "appsync_example_event" {
name = "AppSyncEvent"
read_capacity = 1
write_capacity = 1hash_key = "id"attribute {
name = "id"
type = "S"
}
}
リゾルバ
getEvent
getEvent
は引数の id
をキーに DynamoDB table AppSyncEvent
から単一の item を取得します。引数情報には $ctx.args
でアクセスできます。
リクエストマッピングテンプレート
{
"version": "2017-02-28",
"operation": "GetItem",
"key": {
"id": $util.dynamodb.toDynamoDBJson($ctx.args.id),
}
}
レスポンスマッピングテンプレート
$util.toJson($ctx.result)
addEvent
addEvent
は AppSyncEvent
上に単一の item を生成します。 key
となる ID は $util.autoId()
で自動生成されます。これはランダムに生成された 128 ビットの UUID です。 key
以外の属性設定には引数情報を使用します。 $util.dynamodb.toMapValuesJson
は引数に渡したオブジェクトを DynamoDB API フォーマットの JSON 文字列に変換します。
リクエストマッピングテンプレート
{
"version" : "2017-02-28",
"operation" : "PutItem",
"key" : {
"id": $util.dynamodb.toDynamoDBJson($util.autoId()),
},
"attributeValues" : $util.dynamodb.toMapValuesJson($ctx.args)
}
レスポンスマッピングテンプレート
$util.toJson($ctx.result)
addedEvent
@aws_subscribe
があるので、リゾルバを設定しなくても自動で addEvent
を subscribe してくれます。引数の category
を指定すると特定のカテゴリの Event
を、指定しなければ任意の Event
を subscribe します。
GraphQL クライアント
GraphQL クライアントを用意して実際に API を叩いてみましょう。NodeJS をとaws-appsync
SDK を使用した簡単なクライアントを用意しました。Apollo Client を直接利用しても良いですが、AWS SDK を使用すると IAM 認証情報などの付与が簡単になります。
├── aws-exports.js
├── index.js
├── node_modules
├── package-lock.json
└── package.json
必要なパッケージは以下になります。
package.json
{
...
"dependencies": {
"apollo-cache-inmemory": "^1.1.0",
"apollo-client": "^2.0.3",
"apollo-link": "^1.0.3",
"apollo-link-http": "^1.2.0",
"aws-sdk": "^2.141.0",
"aws-appsync": "^1.0.0",
"es6-promise": "^4.1.1",
"graphql": "^0.11.7",
"graphql-tag": "^2.5.0",
"isomorphic-fetch": "^2.2.1",
"ws": "^3.3.1"
}
}
aws-exports.js
にGraphQL API の endpoint 情報に加え、IAM 認証を使用するので、用意した endpoint への読み込み・書き込みを許可したポリシーを紐付けた IAM 認証情報を設定します。[AWS_...]
の部分は環境変数を埋め込むなどして補完してください)。
aws-exports.js
index.js
mutator となるクライアントは以下の mutation を実行します。
mutation MyMutation ($title: String, $category: Category){
addEvent(title: $title, category: $category) {
id
title
category
}
}
成功すると Event オブジェクトが返戻されます。id
はmutation リクエストに含めてはいませんが、 $util.autoId()
で自動生成されます。
{
data: {
addEvent: {
id: '2e6d8fb1-9f6d-4ebb-b8f7-5b27e5f51b37',
title: 'test title',
category: 'HEALTH',
__typename: 'Event'
}
}
}
subscriber クライアントはこのとき、更新されたオブジェクトの情報を受け取ります。
subscription MySubscription {
addedEvent {
id
title
category
}
}
mutation処理と同様のEvent オブジェクトが返戻されることがわかります。
{
data: {
addedEvent: {
id: '2e6d8fb1-9f6d-4ebb-b8f7-5b27e5f51b37',
title: 'test title',
category: 'HEALTH',
__typename: 'Event'
}
}
}
ここで addedEvent
の引数に category
を指定していると、指定カテゴリの Event
更新だけが subscribe されます。 例えば以下の subscription では category: FOOD
の Event
更新時のみコンソール出力されます。
subscription MySubscription {
addedEvent(category: FOOD) {
id
title
category
}
}
これでDynamoDB table上で管理された最新のデータをクライアントが受け取ることができました。
おわりに
subscription 機能を中心に AppSync の機能を紹介しました。AppSync を GraphQL を持つサービスとしてだけでなく、データ同期・イベント通知としてのサービスとして利用できると考えると意外な使い方ができそうです。AppSync のバックエンドも少しずつ強化されているようなので、今後の新機能追加も楽しみです。