AWS AppSyncのAWSDateTime型のソートがキツくてAWSTimestamp型に乗り換えたときの検証

2019.06.25

はじめに

AWS AppSyncを使ってモバイルアプリのバックエンドAPIを構築している。

アプリケーションの要件で日時を扱う必要が出てきたが、AppSyncで用意されているスカラー型には AWSDateTimeAWSTimestamp がある。

開発段階では選定基準に関する知見がなかったのでとりあえず人が見てわかりやすい拡張ISO8601形式である AWSDateTime 型を使っていた。

しかし、DynamoDBのソートキーに指定されたカラムにこの AWSDateTime 型が許容する文字列を格納すると意図通りにソートできない可能性があるとわかった。

そこで、今回は AWSTimestamp 型について、どのように使うのかと日付を扱うメインの型として使えるかを検証する。

AWSDateTime型では困るケース

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桁を受け入れられる
  • ナノ秒を指定するためには秒を指定する必要がある
  • タイムゾーン情報が必須

ということらしい。

問題は「省略可能」である点と「1〜9桁を受け入れる」という柔軟性にある。

秒のフィールドが省略された場合を考える。

UTCの2つの日時

  • 日時1:2019/6/24 12:00:00
  • 日時2:2019/6/24 12:00:01

を考える。

これらをAppSyncのAWSDateTime型に許容される形で格納すると、以下のような状態が存在することになる。

  • 日時1:2019/6/24 12:00:00 => 2019-06-24T12:00Z
  • 日時2:2019/6/24 12:00:01 => 2019-06-24T12:00:01Z

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型の仕様は以下の通り。

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型を検証してみる

以下のような素朴なアプリケーションのバックエンドをイメージして、AWSTimestamp型の動作を検証してみる。

  • 投稿者名・本文・作成日時を持つ投稿アプリ
  • GraphQLサーバーにAppSyncを利用
  • データソースにDynamoDBを利用

AppSync APIを作成

AWSマネジメントコンソールからAppSync APIを新たに作成する。

シンプルなAPIの場合、ウィザードを使うとDynamoDBテーブルまで作ってくれて便利。

操作画像

モデル名はPostにして、フィールドはauthor, createdAt, content としてみた。

操作画像

API名を指定して作成実行。

操作画像

これだけで自動でDynamoDBテーブルや、AppSyncがDynamoDBテーブルにアクセスするためのIAMロールが生成される。

操作画像

無事作成できた。

操作画像

GraphQLスキーマを修正する

ウィザードで作成したスキーマでは、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の検証エリアを使って試してみる

AppSyncの検証エリアを使ってcreatePost Mutationを実行してみた結果は以下の通り。

引数ではcreatedAtは指定していないのに、結果にはcreatedAtが含まれている。

操作画像

格納されたタイムスタンプを検証する

今回はこちらのツールを使って検証してみた。

https://tool.konisimple.net/date/unixtime?input=1561394056

作成時刻を正しく格納できている様子。

操作画像

moment.jsでAWSTimestamp型をパースする

普段のアプリケーション開発は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型に変換する

逆にmoment.jsのインスタンスからAWSTimestamp型の数値を取得するには、.unix()メソッドを使えば良い。

https://momentjs.com/docs/#/displaying/unix-timestamp/

操作画像

まとめ

AWS AppSyncで時刻を安全に扱うには、結局のところAWSTimestamp型が良さそう。

時刻は全てUTCでデータベースに保管して、フロントエンドで各タイムゾーンに合わせて処理するというスタイルでしばらく運用してみようと思う。

何かアドバイス等ありましたらTwitterよりお寄せいただけますと幸いです。