倭マン's BLOG

くだらない日々の日記書いてます。 たまにプログラミング関連の記事書いてます。 書いてます。

GDK に Java8 で導入された日時 API の拡張が追加されてたので試してみた

Java8 の目玉機能はラムダ式ですが、その次によく取り上げられるのが新しい日時 API である Date and Time API(JSR-310) でしょう。 ただ、この API はナカナカに取っつきにくいという非難の声が。 まぁ、日時の扱い自体が面倒なので仕方がないという擁護の声もありますが。

で、Java で扱いにくい API を似たような文法を保ちつつ扱い易くしよう!ってのが Groovy による JDK の拡張である GDK です。 この GDK に早くも新しい日時 API に対する拡張が追加されてたのでちょっくらイジってみました。 ただし、そもそもこの日時 API 自体にいまいち馴染んでいないので、GDK には関係なく単に JavaAPI を Groovy 上で動かしてるだけの箇所もあります。

この記事では日付 (Date) と時間 (Time) をともに扱う XxxxDateTime のみを扱い、昔ながらの java.util.Date に変換する箇所以外は、タイムゾーンを考慮しない LocalDateTime を見ていきます。 Date に変換する箇所では ZonedDateTime を扱います。

日時(~日付+時間) 日付 時間
タイムゾーンを考慮しない LocalDateTime LocalDate LocalTime
オフセット云々 OffsetDateTime - OffsetTime
タイムゾーンを考慮する ZonedDateTime - -

ちなみに使用するバージョンは、Java はもちろん8、Groovy は 4.1.2014 とします。

概要

  1. 日時の作成
  2. 文字列からの読み取り/への書き出し(パース/フォーマット)
  3. Date への/からの変換
  4. Groovy による演算子オーバーロード
  5. その他

参考

日時の作成

まずは日時の作成方法、つまり LocalDateTime オブジェクトの取得方法から見ていきましょう。 次節で見る文字列からの読み取りもよく使いますが、とりあえずそれ以外の方法のものを。 GDK は全く関係ありません。 次節以降のための準備です。 現在時刻は LocalDateTime#now() メソッドを使って取得します(コードは Groovy):

import java.time.*

def ldt1 = LocalDateTime.now()
println "It is $ldt1 now."
// 現在の日付と時刻 「It is 2014-04-01T01:23:45.678 now.」などと表示

日時の数値を指定してインスタンス生成するには LocalDateTime#of() メソッドを使います:

def ldt2 = LocalDateTime.of(2014, 4, 1, 1, 23, 45, 678000000)
assert( ldt2.toString() == '2014-04-01T01:23:45.678' )

def ldt3 = LocalDateTime.of(2014, Month.APRIL, 1, 1, 23, 45)
assert( ldt3.toString() == '2014-04-01T01:23:45' )

2つ目では月の指定に Month 定数 (Enum) を使い、ナノ秒部分を省略しました。 Enum 使えるの今どきw

次に、後で使う ZonedDateTime オブジェクトの取得方法を見てみましょう。 ZonedDateTime クラスは LocalDateTime にタイムゾーン(対応するクラスは java.time.ZoneId)の情報を付加したようなクラスです。 タイムゾーンとは 'Asia/Tokyo'*1 とか 'Greenwich' とか 'US/Hawaii' とかいうものです。  ZonedDateTime クラスの生成方法はいろいろあります。 LocalDateTime#atZone() メソッドを使うのあたりが、知らないと気づかなさそう:

//ZonedDateTime (≒ LocalDateTime + ZoneId) のインスタンス生成
// その1
def zdt1 = ZonedDateTime.now(ZoneId.systemDefault())

// その2
def zdt2 = ZonedDateTime.of(2014, 4, 1, 0, 0, 0, ZoneId.systemDefault())
assert( zdt1.toString() == '2014-04-01T00:00+09:00[Asia/Tokyo]')

// LocalDateTime オブジェクトを用意
def localDateTime = LocalDateTime.of(2014, 4, 1, 0, 0, 0)

// その3
def zdt3 = ZonedDateTime.of(localDateTime, ZoneId.systemDefault())

// その4
def zdt4 = localDateTime.atZone(ZoneId.systemDefault())

その他の方法は ZonedDateTime の JavaDoc でもご覧下さい。 どの方法でも ZoneId オブジェクトを何とかして取得しないといけないんですが、デフォルトのものを使うなら ZoneId#systemDefault() メソッドで取得できます。 その他のものは ZoneId#of() メソッドで取得できます。 このメソッドの引数にする利用可能な文字列一覧は ZoneId#getAvailableZoneIds() で取得できます:

// 利用可能な ZoneID (を表す文字列)の一覧を表示
ZoneId.availableZoneIds.sort{ it }.each{ println it }

文字列からの読み取り/への書き出し

次は文字列を読み取って LocalDateTime オブジェクトを構築したり、逆にフォーマットを指定して文字列に書き出す方法。 文字列から読み取るには LocalDateTime#parse() メソッドを使います:

def ldt4 = LocalDateTime.parse('2014-04-01T01:23:45')
assert( ldt4 == ldt3 )    // ldt3 は前節で作成したオブジェクト

読み取る文字列の形式は決まっていて、「yyyy-MM-ddTHH:mm:ss.nnnnnnnnn」のようにしないといけません(DateTimeFormatter#ofPattern() メソッドで他のパターンを使用可)。

  • d と H の間の T は文字
  • n はナノ秒で最大9桁
  • n 以外は桁を省略できない、つまり1時は「01」と2桁で書かないといけない
  • ナノ秒を省略したり(「yyyy-MM-ddTHH:mm:ss」)、ナノ秒と秒を省略したり(「yyyy-MM-ddTHH:mm」)できる。 分以上は省略不可っぽい

などの決まりがあります。 LocalDateTime オブジェクトの toString() メソッドはこの形式の文字列を返します。 別の形式の文字列に整形したい場合は LocalDateTime#format() メソッドと java.time.fomat.DateTimeFormatter クラスを使います:

import java.time.format.DateTimeFormatter as Formatter

assert( ldt4.format(Formatter.BASIC_ISO_DATE) == '20140401' )

独自フォーマットの DateTimeFormatter を作るのは結構大変っぽいので(「続・今日から始めるJava8 - JSR-310 Date and Time API」参照)、DateTimeFormatter の static 定数として定義されてるオブジェクトを使い回すのが無難。 ちなみに、頑張って独自の DateTimeFormatter オブジェクトを作っても、読み取りには使えなさそう DateTimeFormatter#ofPattern() メソッドを使うとできるようです。

GDK!
GDK は Date クラスに対して

def date = Date.parse('yyyy/MM/dd', '2014/04/01')
assert( date.format('yyyy/MM/dd') == '2014/04/01' )

のように、フォーマットとそれにマッチする文字列を指定して Date オブジェクトを読み取る parse() メソッドと、フォーマットを文字列で指定して書き出す format() メソッドが定義されていました。 GDK は LocalDateTime クラスにも同様のメソッド parse()/format() メソッドを追加しています:

// GDK!
// parse()
def ldt5 = LocalDateTime.parse('yyyy/MM/dd', '2014/04/01')
assert( ldt5.toString() == '2014-04-01T00:00')

// format()
assert( ldt5.format('yyyy/MM/dd') == '2014/04/01' )

Date への/からの変換

新しい日時 API で結構不評っぽいのが、昔ながらの Date オブジェクトへの変換が結構面倒だ、という点です。 まずは通常の Java API を使って変換する方法を見ていきましょう。 今まではタイムゾーンを気にしてませんでしたが、Date オブジェクトに変換するにはこれを扱う必要があるので、java.time.ZonedDateTime を考えます。 さて、まずはデフォルトの ZoneId を使うことにして ZonedDateTime と Date との変換を行ってみましょう。 この変換に際しては、

  • java.time.Instant オブジェクトを介して行う
  • java.util.Date クラスの from()/toInstant() メソッドを使う

という点に注意。 ZonedDateTime の APIjava.util.Date などの旧式のクラスに汚染されていません(作成者の意向らしいですが)。 ZonedDateTime から Date への変換は Date#from() メソッドを使います。 ZonedDateTime オブジェクトをいったん Instant オブジェクトに変換する必要があります:

def zdt1 = ZonedDateTime.of(2014, 4, 1, 0, 0, 0, ZoneId.systemDefault())

// ZonedDateTime -> Date
def date1 = Date.from(zdt1.toInstant())
assert( date1.toString() == 'Tue Apr 01 00:00:00 JST 2014' )

案外簡単。 次は逆に Date から ZonedDateTime へ変換する方法。 こちらは Date#toInstant() メソッドによって Instant オブジェクトを取得し、Instant#atZone() メソッドによって ZoneDateTime オブジェクトを取得します:

// Date -> ZonedDateTime
def zdt2 = date1.toInstant().atZone(ZoneId.systemDefault())
// もしくは
// def zdt2 = ZonedDateTime.ofInstant(date1.toInstant(), ZoneId.systemDefault())
assert( zdt2.toString() == '2014-04-01T00:00+09:00[Asia/Tokyo]')

さて、ちょっと別のタイムゾーンに変換してみましょうか。

def date = Date.parse('yyyy/MM/dd hh:mm:ss', '2014/04/01 00:00:00')

// Date -> ZonedDateTime で別の ZoneId に変換
def zdt3 = date.toInstant().atZone(ZoneId.of('Greenwich'))
assert( zdt3.toString() == '2014-03-31T15:00Z[Greenwich]' )

時差のせいで日付と時間がズレてますね。

GDK!
GDK はこれらの変換を簡単にするメソッドを追加しています:

def zdt1 = ZonedDateTime.of(2014, 4, 1, 0, 0, 0, ZoneId.systemDefault())

// ZonedDateTime -> Date
def date = zdt1.toDate()
assert( date.toString() == 'Tue Apr 01 00:00:00 JST 2014' )

// Date -> ZonedDateTime その1
def zdt2 = date.toZonedDateTime()
assert( zdt2.toString() == '2014-04-01T00:00+09:00[Asia/Tokyo]')

// Date -> ZonedDateTime その2
def zdt3 = date.toZonedDateTime(ZoneId.of('Greenwich'))
assert( zdt2.toString() == '2014-03-31T15:00Z[Greenwich]')

ZonedDateTime#toDate() は引数なしのものだけ。 Date#toZonedDateTime() は ZoneId を指定して変換可能。 引数を省略した場合は、システムデフォルトのタイムゾーンが使われるもよう。 タイムゾーンを指定して Date オブジェクトに変換する方法はサポートされてないようですね。 そう言えば、旧式の日付 APIjava.util.TimeZone クラスというのがありますが、ZoneId と TimeZone との変換が面倒なためか GDK が追加するメソッドでは Date オブジェクトはデフォルトのタイムゾーンになるものしか使えなさそう。 GDK がどうこう以前に Java API のレベルでタイムゾーンを変換する方法があんまりよくわからない(できるかどうか不明)なので、これは GDK のせいではないような気がします。

Groovy による演算子オーバーロード

GDK は Date クラスに対して

  • previous()/next() ・・・ ++/-- 演算子
  • plus()/minus() ・・・ +/- 演算子
  • upto()/downto() ・・・ for 文のような列挙

のようなメソッドを追加し、演算や制御構造を簡単に書けるようになってまいした:

def date1 = Date.parse('yyyy/MM/dd hh:mm:ss', '2014/04/01 00:00:00')

// previous()/next()
date1++
assert( date1.toString() == 'Wed Apr 02 00:00:00 JST 2014' )

// plus()/minus()
def date2 = date1 + 7
assert( date3.toString() == 'Wed Apr 09 00:00:00 JST 2014' )

// upto(), downto()
date1.upto(date3){ d ->
    println d
}

LocalDateTime クラスについてもほぼ同じメソッドが追加されています:

def ldt1 = LocalDateTime.parse('yyyy/MM/dd hh:mm:ss', '2014/04/01 01:23:45')

// previous()/next()
ldt1++
assert( ldt1.toString() == '2014-04-01T01:23:45' )

// plus()/minus()
def ldt2 = ldt1 + 7
assert( ldt3.toString() == '2014-04-08T01:23:45' )

// upto(), downto()
ldt1.upto(ldt3){ d ->
    println d
}

Date の場合と同じく、日時に整数を足したり引いたりする場合は日 (day) の値として計算されます。 インクリメント/ディクリメント(++/--)の場合も同じ。

ところで、LocalDateTime (や ZonedDateTime)には GDK に関係なく plus()/minus() メソッドが定義されています。 たとえば plus() メソッドの場合

  • plus(long, TemporalUnit)
  • plus(TemporalAmount)

という2つのオーバーロードがありますが、このうち、2番目の引数が1つの方は GDK によらずに「+」演算子で書くことが出来ます。 ただし、TemporalAmount オブジェクトはいまいちどこか生成・取得すればいいのかよく分かりません。 また、日 (day) 以外にも年や分を加算するメソッド plusYears() や plusMinutes() などのメソッドもあるんですが、これらは残念ながら「+」演算子で使うことはできません。 さぁ、そんな時こそ GDK!

GDK!
そんな時こそ GDK! とか言いつつ、ちょっと GDK の機能というわけでもないのですが、Groovy のチカラを使ってこういった演算を式っぽく書くことがでいます。 これには TemporalCategory というカテゴリクラスを使って、以下のように DSL っぽく書きます:

def ldt1 = LocalDateTime.parse('yyyy-MM-dd hh:mm:ss', '2014-04-01 01:23:45')

use(TemporalCateogry){
    def ldt2 = lat1 + 1.year + 7.day - 1.second
    assert( ldt2.toString() == '2015-04-08T01:23:44' )
}

TemporalCategory カテゴリが1や7のような int 値に getYear() などのメソッド(year プロパティのように書ける)を追加して、TemporalAmout オブジェクトを生成するようにしています。 いちいちカテゴリを使わないといけないのは、こういった int 値に対するメソッドをグローバルに付加するわけにはいかないからでしょう。 ちなみに、このカテゴリは upto()/downto() の拡張版で、ステップ間隔を指定できる step() メソッドにも使えます:

use(TemporalCategory){
    ldt1.step(ldt2, 12.hour){ d ->
        println d
    }
}

// c.f.
ldt1.step(ldt2, 2){ d -> // 間隔が整数なら日(day)指定
    println d
}

残念ながら previous()/next() には日(day)以外を指定できないので、秒のインクリメントなどはできません。

あと、ちょっと細かい部分ですが、Date 同士の引き算は日数を表す int が返されますが、LocalDateTime 同士の引き算は時間(間隔)を与える java.time.Duration オブジェクトになるようです。

その他

後に残ってるのは copyWith() メソッド。 これは LocalDateTime クラスに定義されている withXxxx() メソッドの拡張版です。 LocalDateTime クラスは Date と違ってイミュータブルなので、月や分だけを変更するといったことは出来ませんが、それらだけを変更した別のオブジェクトを生成する withXxxx() メソッドというものが定義されています。 Groovy には @Immutable アノテーションによってイミュータブルにしたクラスには同様のことを行う copyWith() メソッドが生成されるのですが、GDK はこれを模して LocalDataTime に copyWith() メソッドを追加しています。 引数が Map になっていて、使い方は withXxxx() メソッドより楽だと思います(Groovy 上では)

def ldt1 = LocalDateTime.parse('yyyy-MM-dd hh:mm:ss', '2014-04-01 01:23:45')

def ldt2 = ldt1.copyWith(year:2015, second:56)
assert( ldt2.toString() == '2015-04-01T01:23:56' )

手軽ですね。

まとめ

大雑把に GDK が LocalDateTime, ZonedDateTime に追加しているメソッドを見てきましたが、大体のものは Date に追加されたものの焼き直しです。 また、拙者があまり新しい日時 API に精通してるわけでもないため(昔ながらの日付 API にも詳しくないですが)、日時の取り扱いで気をつけるべき部分などをおろそかにしてそうですが(特にタイムゾーンが絡んでるあたり)、Java API の下地になっている概念をきちんと理解した上で、それらを簡単・簡潔に扱うために GDK を活用していくのがいいんじゃないでしょうか。 

Happy April 1st !

日経ソフトウエア 2014年 03月号

日経ソフトウエア 2014年 03月号

プログラミングGROOVY

プログラミングGROOVY

*1:'Asia/Tokyo' 以外に 'Japan' というのがあるのが謎なんですけど・・・