timezoneに想いを馳せる1日となりました。
環境
REST APIサーバ + JavaScript MVWフレームワーク構成のWebアプリです。
サーバサイド
クライアントサイド
状況
ユーザがアプリの画面で入力した日時を、json形式でAPIサーバに送信するような状況です。
前置き: Date and Time API(JSR310)とは
Java SE 8から導入された日付・時刻を扱うAPIです。
アプリが動作するのがJava8ということもあり、ここではJava8以前からある java.util.Date
は使いたくないので、日付・時刻はすべてこのJSR310で定義されたクラスのオブジェクトとして扱うことにします。
ということで、ユーザが入力した日時はサーバサイドで以下のいずれかのクラスにマッピングされることになります。
java.time.LocalDateTime
: タイムゾーン情報なし日時
java.time.ZonedDateTime
: タイムゾーン情報付き日時
java.time.OffsetDateTime
: タイムゾーン情報付き日時
JacksonでJSR310のクラスにデシリアライズするには
サーバサイドはJavaなので、JavaのJSONパーサライブラリが必要です。上で書いたとおり、ここでは定番のjacksonを使うことにしました。
サーバサイド
依存関係に com.fasterxml.jackson.datatype:jackson-datatype-jsr310
を追加します。jacksonのcoreライブラリにはJSR310のクラスのパーサは実装されていないため、この追加モジュールを加える必要があります。
Jackson support for Java 8 Date & Time API data types is automatically registered when Java 8 is used and jackson-datatype-jsr310
is on the classpath. Joda-Time support is registered as well when jackson-datatype-joda
is part of your project dependencies.
引用: Latest Jackson integration improvements in Spring
私はビルドシステムとしてGradleを使っているので、以下の記述をbuild.gradle
のdependenciesに追加します。
compile("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.5.1")
日付情報を受け取るクラスを決める
先に書いたとおり、JSR310には日時を表現する3つのクラスがあります。
java.time.LocalDateTime
java.time.ZonedDateTime
java.time.OffsetDateTime
今回
- DB(MySQL)に
datetime
型で日付を格納している
- ORマッパー(Doma)がデフォルトで、MySQLの
datetime
型のカラムを LocalDateTime
にマッピングする
ということで、 LocalDateTime
クラスとしてユーザの入力した日時を受け取ることにします。
問題発生
ユーザが入力した日時データをPOSTすると、サーバ側のアプリ上で例外が発生してアプリが停止しました。
原因
jacksonの LocalDateTimeDeserializer
ではデシリアライズできないフォーマットでjsonが送られてきたことが原因でした。
何が起きていたのか
たとえば「2015年1月1日0時0分0秒」という日時データをjsonで送るとき、サーバ側には
{date: "2015-01-01T00:00:00.000Z"}
のようなフォーマットの文字列で送られていました。
このTやらZやらが謎です。ということでぐぐると、ISO-8601形式を知りました。
ISO-8601形式
詳しくはWikipedia先生をどうぞ。
ISO 8601 - Wikipedia
中ほどを見ていくとありました。
- 日付と時刻の間にTを挟んで表記する
- 時刻の末尾にZを加える事で協定標準時(UTC)を表現できる
さらに調べると、JavaScriptにはまんま toISOString()
関数がありました。
Date.prototype.toISOString() - JavaScript | MDN
toISOString(new Date())
みたいなことをすると、確かにISO-8601形式で日時が表示されました。
Jackson Deserializerでデシリアライズ可能なフォーマット
JacksonでjsonをLocalDateTimeにマッピングする時に使われるDeserializerは LocalDateTimeDeserializer
クラス です。このクラスがデシリアライズ可能なフォーマットがわかれば良いと思い、とりあえずソースコードを見ていたら、ありました。
jackson-datatype-jsr310/LocalDateTimeDeserializer.java at master · FasterXML/jackson-datatype-jsr310
private LocalDateTimeDeserializer() {
this(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
}
コンストラクタの引数 DateTimeFormatter.ISO_LOCAL_DATE_TIME
がめちゃくちゃ怪しいですね。
この時点でうすうす原因に気づいた感じがしてきましたが
DateTimeFormatter.ISO_LOCAL_DATE_TIME
はJSR310のクラスなのでリファレンスを読んでみると
フォーマッタ |
説明 |
例 |
ISO_LOCAL_DATE_TIME |
ISOローカル日付および時間 |
'2011-12-03T10:15:30' |
DateTimeFormatter (Java Platform SE 8 )より引用
Zがない
LocalDateTimeDeserializerは文字通りローカルな時刻(=タイムゾーンを持たない時刻)を扱うので、ケツZなぞ知らん、というわけです。
例外発生までの流れ
ユーザが日付を入力する
-> Dateオブジェクトが作られる
-> DateオブジェクトをPOSTする時、Angular内部で toISOString()
関数によりISO-8601形式に変換される(たぶん)
-> ケツZがついたフォーマットでPOSTされる
-> LocalDateTimeDeserializerからみたらデシリアライズできないケツZ
-> \(^o^)/
解決策の前に前提条件
- JavaScriptのDateオブジェクトをAngularJSで送信しようとすると、ISO-8601形式で送信される
- jacksonの
LocalDateTimeDeserializer
ではISO-8601形式の文字列をデシリアライズできない
解決策
クライアントかサーバか、どちらかで頑張るということになります。。。
解決策1: clientで日時のフォーマットを整形してから送信する
AngularJSなら、date
フィルタに処理させる。
$filter('date')(new Date(), "yyyy-MM-dd'T'HH:mm:ss")
これでDateオブジェクトのケツZが除かれた状態で送信されるので、何の問題もなくLocalDateTimeDeserializerでデシリアライズできます。ただし、この対処はタイムゾーンなしデータへの変換作業をclient側に寄せています。この方法ではすべての箇所で変換してから送らないといけないのでだるいです。
解決策2: serverでZonedDateTime型にデシリアライズする
ZonedDateTimeに対応したDeserializerである InstantDeserializer
はタイムゾーン情報付きのISO-8601形式のフォーマットをデシリアライズすることができます。
なので、ユーザの入力を一旦ZonedDateTime型で受け取り、内部でLocalDateTime型に変換します。
DBに格納する前にZonedDateTime型のオブジェクトをLocalDateTime型のオブジェクトに変換すればOKです。
幸い、それ用のAPIが用意されていました。( ZonedDateTime#toLocalDateTime()
)
まとめ
解決策2を採用して、めでたく日時情報をやりとりできるようになりました。おしまい。
参考