読者です 読者をやめる 読者になる 読者になる

Timezone情報の有無によるJackson Deserializerの挙動変化でハマる

timezoneに想いを馳せる1日となりました。

環境

REST APIサーバ + JavaScript MVWフレームワーク構成のWebアプリです。

サーバサイド

クライアントサイド

  • AngularJS 1.3.14

状況

ユーザがアプリの画面で入力した日時を、json形式でAPIサーバに送信するような状況です。

前置き: Date and Time API(JSR310)とは

Java SE 8から導入された日付・時刻を扱うAPIです。

アプリが動作するのがJava8ということもあり、ここではJava8以前からある java.util.Date は使いたくないので、日付・時刻はすべてこのJSR310で定義されたクラスのオブジェクトとして扱うことにします。

ということで、ユーザが入力した日時はサーバサイドで以下のいずれかのクラスにマッピングされることになります。

JacksonでJSR310のクラスにデシリアライズするには

サーバサイドはJavaなので、JavaJSONパーサライブラリが必要です。上で書いたとおり、ここでは定番の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)がデフォルトで、MySQLdatetime 型のカラムを 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を採用して、めでたく日時情報をやりとりできるようになりました。おしまい。

参考