2019.06.25
AWS AppSyncを使ってモバイルアプリのバックエンドAPIを構築している。
アプリケーションの要件で日時を扱う必要が出てきたが、AppSyncで用意されているスカラー型には AWSDateTime
と AWSTimestamp
がある。
開発段階では選定基準に関する知見がなかったのでとりあえず人が見てわかりやすい拡張ISO8601形式である AWSDateTime
型を使っていた。
しかし、DynamoDBのソートキーに指定されたカラムにこの AWSDateTime
型が許容する文字列を格納すると意図通りにソートできない可能性があるとわかった。
そこで、今回は AWSTimestamp
型について、どのように使うのかと日付を扱うメインの型として使えるかを検証する。
AWS公式ドキュメントでの AWSDateTime
型の説明は以下の通り。
AWSDateTime
スカラー型は有効な拡張 ISO 8601 日時文字列です。つまり、このスカラー型はYYYY-MM-DDThh:mm:ss.sssZ
の形式の日時文字列を受け入れます。秒の後ろのフィールドはナノ秒フィールドです。1 ~ 9 桁を受け入れることができます。秒およびナノ秒フィールドは省略可能です (ナノ秒フィールドを使用する場合は 2 番目のフィールドを指定する必要があります)。タイムゾーンオフセットはこのスカラーには必須です。タイムゾーンオフセットはZ
(UTC タイムゾーンを表す)、または±hh:mm:ss
の形式である必要があります。ISO 8601 標準には含まれていませんが、タイムゾーンオフセットの第 2 フィールドは有効と見なされます。https://docs.aws.amazon.com/ja_jp/appsync/latest/devguide/scalars.html より
ポイントをまとめると、
ということらしい。
問題は「省略可能」である点と「1〜9桁を受け入れる」という柔軟性にある。
秒のフィールドが省略された場合を考える。
UTCの2つの日時
を考える。
これらをAppSyncのAWSDateTime
型に許容される形で格納すると、以下のような状態が存在することになる。
DynamoDBのソートキーに指定したカラムでソートする場合、カラムの型がString
型なら大小の比較はUTF-8のバイトの順になる( https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/Query.html )。
つまり、正しくは 日付1 < 日付2
なのが、上記のような格納の仕方をしてしまうと、タイムゾーンを表す Z
と、分と秒を分ける:
の比較となってしまい、Z(= 5A) > :(=3A)
なのでDynamoDBにおけるソート結果としては 日付1 > 日付2
となってしまう。
これでは、範囲を指定してアイテムを絞り込むようなクエリを発行したときに正しくは抽出対象に入っているアイテムが漏れたり、正しくは抽出対象に入っていないアイテムが含まれたりしてしまう。
この状況からわかる問題点としては、任意のフィールドや柔軟な桁数が許容されるために、「UTF-8のバイトの順での大小と実際の大小が対応しなくなるケースが存在してしまう」ということだ。
そもそも、そこはアプリケーション側でルールを決めて、格納の形式を揃えてしまえば問題ない。
しかし、AppSyncで現在時刻をISO8601形式で取得するヘルパー関数 $util.time.nowISO8601()
はナノ秒を3桁つけたUTCの時刻を返すので、フロントエンドからのリクエストもこの形に従う必要がある。
AppSyncのリクエストマッピングテンプレートで形式を揃えることも頑張ればできなくはないだろうが、VTLで込み入った操作を行うのは非常に苦労する。
一方AWSTimestamp
型の仕様は以下の通り。
AWSTimestamp
スカラー型は1970-01-01T00:00Z
から経過した秒数を表します。タイムスタンプは数値としてシリアル化および逆シリアル化されます。負の値も受け入れられ、1970-01-01T00:00Z
までの秒数を表現します。https://docs.aws.amazon.com/ja_jp/appsync/latest/devguide/scalars.html より
こちらはUTC時刻の1970/1/1 0:00:00からの経過秒数を数値で格納する。
ミリ秒は扱うことができない模様。
こちらの型は省略の有無やタイムゾーンの情報がなく非常にシンプルである。
こちらを使わずにAWSDateTime
を使っていた理由としては、デバッグ時に人間が直感的に日時を把握できた方が捗るだろうという安直なものだった。
その利便性は損なわれるものの、前述のソートに関する懸念は解消される。
以下のような素朴なアプリケーションのバックエンドをイメージして、AWSTimestamp
型の動作を検証してみる。
AWSマネジメントコンソールからAppSync APIを新たに作成する。
シンプルなAPIの場合、ウィザードを使うとDynamoDBテーブルまで作ってくれて便利。
モデル名はPost
にして、フィールドはauthor
, createdAt
, content
としてみた。
API名を指定して作成実行。
これだけで自動でDynamoDBテーブルや、AppSyncがDynamoDBテーブルにアクセスするためのIAMロールが生成される。
無事作成できた。
ウィザードで作成したスキーマでは、Postを作成するためのMutationであるcreatePost
の入力はCreatePostInput
型となっている。
このCreatePostInput
型は、createdAt
も受け取るようになっている。
このcreatedAt
については、createPost
が実行された日時を自動的に格納するようにしたいので、CreatePostInput
からcreatedAt
フィールドを削除した。
# Before
input CreatePostInput {
author: String!
createdAt: Int!
content: String
}
# After
input CreatePostInput {
author: String!
content: String
}
続いて、createPost
Mutationをデータソースへの命令に変換するリクエストマッピングテンプレートを編集する。
ウィザードで作成した当初は以下のようになっていた。
createdAt
の保存部分は、
"createdAt": $util.dynamodb.toDynamoDBJson($ctx.args.input.createdAt),
のようになっており、引数からセットするようになっている。
これを、リゾルバーで使えるユーティリティ関数を利用して以下のように書き換えた。
{
"version": "2017-02-28",
"operation": "PutItem",
"key": {
"author": $util.dynamodb.toDynamoDBJson($ctx.args.input.author),
"createdAt": $util.dynamodb.toDynamoDBJson($util.time.nowEpochSeconds()),
},
"attributeValues": $util.dynamodb.toMapValuesJson($ctx.args.input),
"condition": {
"expression": "attribute_not_exists(#author) AND attribute_not_exists(#createdAt)",
"expressionNames": {
"#author": "author",
"#createdAt": "createdAt",
},
},
}
ポイントは $util.time.nowEpochSeconds()
関数を利用して現在時刻のタイムスタンプを取得している部分。
AppSyncの検証エリアを使ってcreatePost
Mutationを実行してみた結果は以下の通り。
引数ではcreatedAt
は指定していないのに、結果にはcreatedAt
が含まれている。
今回はこちらのツールを使って検証してみた。
https://tool.konisimple.net/date/unixtime?input=1561394056
作成時刻を正しく格納できている様子。
普段のアプリケーション開発はJavaScriptで行なっていて、日付の処理はmoment.jsを活用している。
今回検証したAWSTimestamp
型も、フロントエンドのmoment.jsで使えるようにしたい。
サクッと試すために、moment.jsを読み込むだけのHTMLファイルを作成した。
<!doctype html>
<head>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.js" integrity="sha256-H9jAz//QLkDOy/nzE9G4aYijQtkLt9FvGmdUTwBk6gs=" crossorigin="anonymous"></script>
</head>
<body>
</body>
</html>
moment.jsのドキュメントによると、Unixタイムスタンプをパースするには、moment.unix(Number)
でできるらしい。
https://momentjs.com/docs/#/parsing/unix-timestamp/
Chromeのデベロッパーツールを使って検証してみたところ、問題なくパースできた。
逆にmoment.jsのインスタンスからAWSTimestamp
型の数値を取得するには、.unix()
メソッドを使えば良い。
https://momentjs.com/docs/#/displaying/unix-timestamp/
AWS AppSyncで時刻を安全に扱うには、結局のところAWSTimestamp
型が良さそう。
時刻は全てUTCでデータベースに保管して、フロントエンドで各タイムゾーンに合わせて処理するというスタイルでしばらく運用してみようと思う。
何かアドバイス等ありましたらTwitterよりお寄せいただけますと幸いです。