はじめに
AWS AppSync では GraphQL の Resolver として Velocity によるマッピングテンプレートを書くことで、プログラムを書くことなく 1 入力を成形してデータソースにデータを渡すことができる。
ここではデータソースに DynamoDB を指定した場合、ある程度汎用的に使えそうな自己流のメタデータ管理方法をメモ書きする。
- 2021/10/13追記: TTL管理の方法を追加
リゾルバーのマッピングテンプレートの書き方
こちらの DynamoDB Resolver のサンプルの書き方を参考にしつつ
ユーティリティなどは同じカテゴリーの別ジャンルにまとめられているので、こちらを見つつ作業していく。
また、Management Console のエディタを使うと、マッピングテンプレートがどのような形になるかをテストできるので、トライアンドエラーでマッピングテンプレートを記載する場合はこちらで始めると分かりやすかった。
AppSync API を選択 > スキーマ > Resolver を選択、の順でブラウザ上での編集が行える。 以下は編集後に更新に使われるデータをテスト用に準備し、テストを実行した場合、最終的にどのように Velocity が解決されて処理されるかをテストした例。
今回実施したいこと
管理用メタデータの処理をリゾルバーレベルで処理したい。
- DynamoDB へのデータ登録時にメタデータを自動的に挿入したい
- Put 時にデータの生成時刻
created
項目を自動で追加する - Put 時/ Update 時に更新時刻
updated
項目を自動で追加する
- Put 時にデータの生成時刻
- DynamoDB のアイテムに TTL を設置し、自動的にデータが消去される仕組みを使いたい
- DynamoDB の TTL 機能を使うことで期限切れになったデータを自動削除することができる (※削除時間は厳密ではない)
- しかし、クライアント側の時計がこちらの想定した時計になっていない場合もあるため、基準時刻はGraphQL API側のものを利用したい
- 今回用意したテーブルでは
ttl
カラムに格納された値を元にデータを消去する設定を行っている
- 楽観ロックを行う
- 新規登録時、キーが衝突する場合は新規登録に失敗する
- 更新時、 テーブル上の
version
項目が更新されている場合は更新に失敗する
今回データソースに使うテーブルのキーはパーティションキーのみであり、そのキー名は key
とする。
また、テスト用に用意した GraphQL のスキーマは以下の通り。
# TTL 設置用。 上から優先的に適用
input TTLArgs {
# 有効期限を unixtime で指定
expired: Int
# 有効期限を ISOFormat の時刻表現で指定
expiredISO: String
# 現在時刻からの相対時間で指定 (単位: 秒)
delta: Int
# TTL を撤廃する (更新専用)
remove: Boolean
}
interface Meta {
version: Int!
created: String!
updated: String!
ttl: Int
}
type User implements Meta {
key: String!
name: String!
age: Int!
hobby: String
version: Int!
created: String!
updated: String!
ttl: Int
}
type Query {
hello: String
getUsers: [ User! ]
}
type Mutation {
createUser(
key: String!, name: String!, age: Int!, hobby: String
ttlArgs: TTLArgs
): User
updateUser(
key: String!,
name: String, age: Int, hobby: String,
ttlArgs: TTLArgs, version: Int!
): User
}
新規データ登録
公式ドキュメントの以下の例を参考に、登録処理を記載。
{
"version" : "2018-05-29",
"operation" : "PutItem",
"key" : {
"key": $util.dynamodb.toDynamoDBJson($ctx.args.key)
},
#set( $attr = $util.dynamodb.toMapValues($ctx.args) )
## Set MetaData automatically
#set( $attr.version = $util.dynamodb.toNumber(1) )
#set( $attr.created = $util.dynamodb.toString($util.time.nowISO8601()) )
#set( $attr.updated = $attr.created )
## Set Item TTL
#if ($!{attr.ttlArgs})
$util.qr($!{attr.remove('ttlArgs')})
#if ("$!{ctx.args.ttlArgs.expired}" != "")
#set( $attr.ttl = $util.dynamodb.toNumber(${ctx.args.ttlArgs.expired}) )
#elseif ("$!{ctx.args.ttlArgs.expiredISO}" != "")
#set( $ttl = $util.time.parseISO8601ToEpochMilliSeconds(${ctx.args.ttlArgs.expiredISO}) / 1000 )
#set( $attr.ttl = $util.dynamodb.toNumber($ttl) )
#elseif ("$!{ctx.args.ttlArgs.delta}" != "")
#set( $ttl = $util.time.nowEpochSeconds() + ${ctx.args.ttlArgs.delta} )
#set( $attr.ttl = $util.dynamodb.toNumber($ttl) )
#end
#end
"attributeValues": $util.toJson($attr),
"condition": {
"expression": "attribute_not_exists(#id)",
"expressionNames": {
"#id": "key",
},
}
}
このマッピングテンプレートでは、API 経由で渡ってきた全ての項目をそのまま DynamoDB テーブルの項目として登録する。 その登録内容に version
(登録時なので1で固定)、created
= updated
(登録・更新日時は$util.time
モジュールを用いて取得し、値を設定する) を注入する。
また、テーブルの ttl
項目に ttlArgs
で指定された方法で値を登録する。 expired: Int
は unixtime で有効期限を指定、expiredISO: String
はISOフォーマットの日付文字列表現で期限を指定、delta: Int
は現在時刻に delta
の値を加えた値を自動的に生存期限として設定する。
これらの値を Resolver で解決した後、DynamoDB の PutItem を呼び出すようにしている。 この時 condition
部分で既に同じキーを持つ項目が存在している場合、エラーとなるようにしている。
ここで記載したものは、サンプルとバージョンが異なる。 バージョンの違いはこちら に記載されているが、大きいものはエラーが発生してもエラーにならずに null が返ってくるというところがある。 そのため、レスポンスマッピングテンプレートで自明にエラーを処理するコードを書く必要がある。 具体的には、以下の通り。
# if($ctx.error)
## 何らかのエラーがある場合、エラーレスポンスを明示的に返す
$utils.error($ctx.error.message, $ctx.error.type)
# end
$util.toJson($ctx.result)
これらのリゾルバーを実際に登録して createUser
の mutation を行うと以下の様な挙動となり、メタデータが自動生成されて入力されていることが分かる。 DynamoDB のテーブルにもこれらの値はちゃんと登録されている。
mutation MyMutation {
createUser(age: 10, key: "test", name: "testuser", ttlArgs: {delta: 3600}) {
key, created, updated, version, ttl
}
}
{
"data": {
"createUser": {
"key": "test",
"created": "2021-10-13T03:47:17.146Z",
"updated": "2021-10-13T03:47:17.146Z",
"version": 1,
"ttl": 1634100437
}
}
}
同じクエリを2度発行すると、既に同一キーでデータが登録されているので、以下の様なエラーとなる。
{
"data": {
"createUser": null
},
"errors": [
{
"path": [
"createUser"
],
"data": null,
"errorType": "DynamoDB:ConditionalCheckFailedException",
"errorInfo": null,
"locations": [
{
"line": 2,
"column": 3,
"sourceName": null
}
],
"message": "The conditional request failed (Service: DynamoDb, Status Code: 400, Request ID: AQ1HCHTEEA9S3CQS5IGHNEMHIBVV4KQNSO5AEMVJF66Q9ASUAAJG, Extended Request ID: null)"
}
]
}
データ更新
公式ドキュメントの以下の例を参考に、更新処理を記載。 引数で渡した値だけ更新したかったので、PutItem ではなく UpdateItem を実施する。
{
"version" : "2018-05-29",
"operation" : "UpdateItem",
"key" : {
"key": $util.dynamodb.toDynamoDBJson($ctx.args.key)
},
## Set MetaData automatically
#set( $nextVersion = $ctx.args.version + 1 )
#set( $updated = $util.time.nowISO8601() )
## update 用の expression を動的に構築する
#set( $expNames = {} )
#set( $expValues = {} )
#set( $expSet = {} )
#set( $expRemove = [] )
## 更新用のキーを取得 (主キー系を除く)
#foreach( $entry in $ctx.args.entrySet() )
#if( $entry.key != "key" && $entry.key != "version" && $entry.key != "ttlArgs" )
$!{expNames.put("#${entry.key}", "${entry.key}")}
#if( (!$entry.value) && ("$!{entry.value}" == "") )
## null 値が渡された場合、該当項目は Item から削除
$util.qr($!{expRemove.add("${entry.key}")})
#else
## 非 null 値が渡された場合、該当項目を追加・更新
$!{expSet.put("${entry.key}", ":${entry.key}")}
$!{expValues.put(":${entry.key}", $entry.value)}
#end
#end
#end
## TTL の更新対応
#if ($!{ctx.args.ttlArgs})
#if ("$!{ctx.args.ttlArgs.expired}" != "")
$!{expNames.put("#ttl", "ttl")}
$!{expSet.put("ttl", ":ttl")}
$!{expValues.put(":ttl", ${ctx.args.ttlArgs.expired})}
#elseif ("$!{ctx.args.ttlArgs.expiredISO}" != "")
#set( $ttl = $util.time.parseISO8601ToEpochMilliSeconds(${ctx.args.ttlArgs.expiredISO}) / 1000 )
$!{expNames.put("#ttl", "ttl")}
$!{expSet.put("ttl", ":ttl")}
$!{expValues.put(":ttl", $ttl)}
#elseif ("$!{ctx.args.ttlArgs.delta}" != "")
#set( $ttl = $util.time.nowEpochSeconds() + ${ctx.args.ttlArgs.delta} )
$!{expNames.put("#ttl", "ttl")}
$!{expSet.put("ttl", ":ttl")}
$!{expValues.put(":ttl", $ttl)}
#elseif ("$!{ctx.args.ttlArgs.remove}" != "" && $!{ctx.args.ttlArgs.remove})
$!{expNames.put("#ttl", "ttl")}
$util.qr($!{expRemove.add("ttl")})
#end
#end
## 更新用固定値 (version は +1 に上書き)
$util.qr($!{expValues.put(":version", $nextVersion)})
$util.qr($!{expValues.put(":updated", $updated)})
## 更新用の expression を構築
#set( $expression = "SET version = :version, updated = :updated" )
## 値の追加・更新を実施
#foreach( $entry in $expSet.entrySet() )
#set( $expression = "${expression}, #${entry.key} = :${entry.key}" )
#end
## 値の削除を実施
#if( !${expRemove.isEmpty()} )
#set( $expression = "${expression} REMOVE" )
#foreach( $entry in $expRemove )
#set( $expression = "${expression} #${entry}" )
#if ( $foreach.hasNext )
#set( $expression = "${expression}," )
#end
#end
#end
## 構築した SET, REMOVE で値を更新する
"update": {
"expression" : "${expression}",
"expressionNames": $util.toJson(${expNames}),
"expressionValues" : $util.dynamodb.toMapValuesJson(${expValues})
},
## 更新条件は取得時と同一のバージョンであること (楽観ロック)
"condition": {
"expression" : "version = :expectedVersion",
"expressionValues" : {
":expectedVersion" : $util.dynamodb.toDynamoDBJson($ctx.args.version)
}
}
}
複雑だが、やっていることは Velocity で expression
部分を動的に生成しているだけである。 expressionNames
と expressionValues
を使っているので、インジェクションなどへの対処もしている。
実際に利用する場合は以下のようになる。
mutation MyMutation {
updateUser(key: "test", hobby: "coffee", ttlArgs: {expiredISO: "2022-01-01T00:00:00Z"}, version: 1) {
key, hobby, created, updated, version, ttl
}
}
# {
# "data": {
# "updateUser": {
# "key": "test",
# "hobby": "coffee",
# "created": "2021-10-13T03:47:17.146Z",
# "updated": "2021-10-13T03:49:56.452Z",
# "version": 2,
# "ttl": 1640995200
# }
# }
# }
と、このように
- 引数で渡した値
hobby
は追加されている -
version
,updated
,ttl
が更新されている (created
は登録時のまま)
ということが確認できる。
更新後、もう一度同じ mutation を行おうとすると、保存されている version と値が異なるため、以下のようなエラーになる。
{
"data": {
"updateUser": null
},
"errors": [
{
"path": [
"updateUser"
],
"data": null,
"errorType": "DynamoDB:ConditionalCheckFailedException",
"errorInfo": null,
"locations": [
{
"line": 2,
"column": 3,
"sourceName": null
}
],
"message": "The conditional request failed (Service: DynamoDb, Status Code: 400, Request ID: J8HR2GLEFF0F46E7E3J4NFC63NVV4KQNSO5AEMVJF66Q9ASUAAJG, Extended Request ID: null)"
}
]
}
登録した hobby
や ttl
を削除する場合、以下の様になる。
mutation MyMutation {
updateUser(key: "test", hobby: null, ttlArgs: {remove: true}, version: 2) {
key, hobby, created, updated, version, ttl
}
}
{
"data": {
"updateUser": {
"key": "test",
"hobby": null,
"created": "2021-10-13T03:47:17.146Z",
"updated": "2021-10-13T03:54:33.230Z",
"version": 3,
"ttl": null
}
}
}
これで、DynamoDB 上からも hobby
, ttl
の項目は削除される。
まとめ
マッピングテンプレートは割と癖があるが、マニュアルを見つつトライアンドエラーを繰り返すことでかなり高度なことができることが分かった。 個人的にはこの内容は汎用性があると考えているため、メモ書きとして残しておく。
-
Velocity 自体に if やループ構文があるため、Velocityによるプログラミングをしているのだが、ここではLambdaなどの外部プログラムを使わない、という意味でプログラムを書くことなく、という表現を使った。 ↩