【Date and Time API】Java 8徹底再入門【ラムダ式ハンズオン】(大阪,7/11) - connpass で Date and Time API について学んだので復習。
ラムダはハンズオン形式で実際に手を動かせていたけれど、 Date and Time API の方は講義形式だったので、話を聞いて気になったところとかを実際に触ってみる。
要点整理
- Date and Time API (JSR310)は ISO 8601 をモデリングして作られたもの。
- ISO 8601 は、コンピュータ間でデータのやりとりをする際の、日付と時刻の書式に関する国際規格。
- ISO 8601 について知らないと、 Date and Time API を正しく扱えないことがある。
- といっても、 ISO 8601 について知っておくべきことは、そんなに多くない。
- 発表スライドに挙げられている表記法くらいを理解していれば大丈夫。
- ISO 8601 は固定長の電文で使われることを想定しているので、桁数に対して厳格。
-
2014-7-2
みたいな省略はダメ。 - 区切り文字(
:
や-
)は省略可(むしろ、無いのが標準)。 - 他システムとの連携を考えた場合、標準に従う方がベター。
-
- 年月日の書式は、
yyyy-MM-dd
(2015-07-18
)。- 時分秒の書式は、
HH:mm:ss
(12:11:02
)。 - 年月日の表記と時分秒以下の表記を結合するときは、
T
で連結する(2015-07-13T23:50:00
)。
- 時分秒の書式は、
- ISO 8601 では、タイムゾーンの表現にオフセットを使う。
- オフセットは、協定世界時からの差(
±HH:mm
)を時刻の後ろに付けることで表現する。 - 日本は、協定世界時から9時間早いので、
+09:00
と表記する(2015-07-13T23:50:00+09:00
)。 - 協定世界時の場合は、
Z
を付ける(2015-07-13T23:50:00Z
)。
- オフセットは、協定世界時からの差(
-
tz databse というものがあり、世界中のタイムゾーンがデータベース化されている。
-
tz database
では、タイムゾーンにAsia/Tokyo
のような名前を割り当てて管理している。 - 夏時間も考慮されている。
- 夏時間の期間は変更されることがあるので、データベースの内容は、しばしば更新されることがある。
-
- Date and Time API には、時間を表すクラスが大きく3種類存在する。
クラス | タイムゾーン | 夏時間 | ISO 8601 |
---|---|---|---|
Local* |
なし | 非サポート | 準拠 |
Offset* |
オフセットで保持 | 非サポート | 準拠 |
ZonedDateTime |
tz database て定義されている名称(Asia/Tokyo など)で保持 |
サポート | 非準拠 |
-
Local*
(LocalDateTime
,LocalDate
,LocalTime
)は、タイムゾーンが必要ないときに利用する。 -
Offset*
(OffsetDateTime
,OffsetTime
) は、タイムゾーンが必要なときに利用する。ISO 8601 に準拠している。夏時間の考慮はされない。 -
ZonedDateTime
は、同じくタイムゾーンを持ち、夏時間が考慮される。
ただし、 tz database が更新されたら、それに追随して JDK のバージョンも上げる必要がある。また、 ISO 8601 には準拠していない。 - JDK のバージョンをおいそれと上げられない環境では、
ZonedDateTime
の利用はリスキー。 - 各クラスは、簡単に相互変換できる。
- Date and Time API が提供するクラスはイミュータブル(不変)で設計されている。
-
withYear()
などのメソッドは、指定した値でフィールドを書き換えた新しいインスタンスを返す。
-
Hello World
package sample.dta;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.OffsetDateTime;
import java.time.OffsetTime;
import java.time.ZonedDateTime;
public class Main {
public static void main(String[] args) {
String msg =
"LocalDate : " + LocalDate.now() + "\n" +
"LocalTime : " + LocalTime.now() + "\n" +
"LocalDateTime : " + LocalDateTime.now() + "\n" +
"OffsetTime : " + OffsetTime.now() + "\n" +
"OffsetDateTime : " + OffsetDateTime.now() + "\n" +
"ZonedDateTime : " + ZonedDateTime.now()
;
System.out.println(msg);
}
}
実行結果
LocalDate : 2015-07-14
LocalTime : 22:48:39.679
LocalDateTime : 2015-07-14T22:48:39.679
OffsetTime : 22:48:39.679+09:00
OffsetDateTime : 2015-07-14T22:48:39.679+09:00
ZonedDateTime : 2015-07-14T22:48:39.680+09:00[Asia/Tokyo]
-
now()
という static なファクトリメソッドが用意されており、現在時刻に対応するインスタンスを取得できる。 -
Local*
とOffset*
クラスのインスタンスは、toString()
すると ISO 8601 に準拠した書式で文字列を返す。
日付文字列からインスタンスを生成する
package sample.dta;
import java.time.LocalDate;
import java.time.OffsetDateTime;
public class Main {
public static void main(String[] args) {
LocalDate localDate = LocalDate.parse("2015-07-15");
System.out.println(localDate);
OffsetDateTime offsetDateTime = OffsetDateTime.parse("2015-07-15T22:50:00+09:00");
System.out.println(offsetDateTime);
}
}
実行結果
2015-07-15
2015-07-15T22:50+09:00
-
parse()
という static なメソッドが用意されており、 ISO 8601 の書式で書かれた日付文字列からインスタンスを生成できる。
厳密にフォーマットをチェックする
package sample.dta;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.ResolverStyle;
public class Main {
public static void main(String[] args) {
DateTimeFormatter formatter = DateTimeFormatter
.ofPattern("yyyy-MM-dd")
.withResolverStyle(ResolverStyle.STRICT);
System.out.println(LocalDate.parse("2015-12-03", formatter));
}
}
実行結果
Exception in thread "main" java.time.format.DateTimeParseException: Text '2015-12-03' could not be parsed: Unable to obtain LocalDate from TemporalAccessor: {MonthOfYear=12, DayOfMonth=3, YearOfEra=2015},ISO of type java.time.format.Parsed
at java.time.format.DateTimeFormatter.createError(DateTimeFormatter.java:1918)
at java.time.format.DateTimeFormatter.parse(DateTimeFormatter.java:1853)
at java.time.LocalDate.parse(LocalDate.java:400)
at sample.dta.Main.main(Main.java:12)
Caused by: java.time.DateTimeException: Unable to obtain LocalDate from TemporalAccessor: {MonthOfYear=12, DayOfMonth=3, YearOfEra=2015},ISO of type java.time.format.Parsed
at java.time.LocalDate.from(LocalDate.java:368)
at java.time.LocalDate$$Lambda$7/1023892928.queryFrom(Unknown Source)
at java.time.format.Parsed.query(Parsed.java:226)
at java.time.format.DateTimeFormatter.parse(DateTimeFormatter.java:1849)
... 2 more
-
DateTimeFormatter
を作り、parse()
メソッドの第二引数に渡す。 -
DateTimeFormatter
は、withResolverStyle(ResolverStyle.STRICT)
で厳密なチェックを行うように指定できる。-
DateTimeFormatter
もイミュータブルなので、新しいフォーマッターが return される。
-
- 厳密なフォーマットの場合、
yyyy
という書式指定は間違いで、Gyyyy
が正しい。
Gyyyyを指定した場合
package sample.dta;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.ResolverStyle;
public class Main {
public static void main(String[] args) {
DateTimeFormatter formatter = DateTimeFormatter
.ofPattern("Gyyyy-MM-dd")
.withResolverStyle(ResolverStyle.STRICT);
System.out.println(LocalDate.parse("西暦2015-12-03", formatter));
}
}
実行結果
2015-12-03
- 厳密なフォーマットの場合、西暦を単純な数値として扱うには
uuuu
とするのが正しい。
uuuuを指定した場合
package sample.dta;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.ResolverStyle;
public class Main {
public static void main(String[] args) {
DateTimeFormatter formatter = DateTimeFormatter
.ofPattern("uuuu-MM-dd")
.withResolverStyle(ResolverStyle.STRICT);
System.out.println(LocalDate.parse("2015-12-03", formatter));
}
}
実行結果
2015-12-03
ビルトインのフォーマッター
package sample.dta;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
public class Main {
public static void main(String[] args) {
LocalDateTime localDateTime = LocalDateTime.parse("2015-07-15T10:00:00", DateTimeFormatter.ISO_DATE_TIME);
System.out.println(localDateTime);
}
}
-
DateTimeFormatter
には、 static フィールドで複数のフォーマッターが定義されている。 -
DateTimeFormatter
はSimpleDateFormat
とは違いイミュータブルでスレッドセーフなので、同じインスタンスを共有できる。
フォーマットを指定して文字列に変換する
package sample.dta;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
public class Main {
public static void main(String[] args) {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy/MM/dd");
System.out.println(LocalDate.now().format(formatter));
}
}
実行結果
2015/07/14
-
DateTimeFormatter.ofPattern()
を使えば、書式指定でフォーマッターを作ることができる。 -
format()
メソッドの引数にフォーマッターを渡すことで、日付オブジェクトを任意の書式の文字列に変換できる。
同時刻(日付)の比較
package sample.dta;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.chrono.JapaneseDate;
import java.time.chrono.JapaneseEra;
public class Main {
public static void main(String[] args) {
checkLocalDate();
checkOffsetDateTime();
}
private static void checkLocalDate() {
System.out.println("### checkLocalDate()");
LocalDate a = LocalDate.parse("2015-07-01");
LocalDate b = LocalDate.parse("2015-07-01");
JapaneseDate jp = JapaneseDate.of(JapaneseEra.HEISEI, 27, 7, 1);
System.out.println("a.isEqual(b) : " + a.isEqual(b));
System.out.println("a.equals(b) : " + a.equals(b));
System.out.println("a.isEqual(jp) : " + a.isEqual(jp));
System.out.println("a.equals(jp) : " + a.equals(jp));
}
private static void checkOffsetDateTime() {
System.out.println("### checkOffsetDateTime()");
OffsetDateTime a = OffsetDateTime.parse("2015-07-01T09:00:00+09:00");
OffsetDateTime b = OffsetDateTime.parse("2015-07-01T04:00:00+04:00");
OffsetDateTime c = OffsetDateTime.parse("2015-07-01T04:00:00+04:00");
System.out.println("a.isEqual(b) : " + a.isEqual(b));
System.out.println("a.equals(b) : " + a.equals(b));
System.out.println("b.equals(c) : " + b.equals(c));
}
}
実行結果
### checkLocalDate()
a.isEqual(b) : true
a.equals(b) : true
a.isEqual(jp) : true
a.equals(jp) : false
### checkOffsetDateTime()
a.isEqual(b) : true
a.equals(b) : false
b.equals(c) : true
-
isEqual()
メソッドは、暦の種類(西暦や和暦)やタイムゾーンに関係なく、時間軸上の同じ日時を表しているかどうかをチェックする。 -
equals()
メソッドは、Local*
クラス同士なら、同じ日時かどうか比較できる。- しかし、クラスが異なっていたりタイムゾーンが異なっていたりすると、時間軸上は同じ時点を指していても
false
判定される。 - 詳しくは、各々の
equals()
メソッドの API ドキュメントを参照。 - LocalDateTime#equals(), OffsetDateTime#equals(), ZonedDateTime#equals()
- しかし、クラスが異なっていたりタイムゾーンが異なっていたりすると、時間軸上は同じ時点を指していても
- 要は、同じ日時かどうかチェックしたい場合は
isEqual()
メソッドを使う。
Date との相互変換
Date -> LocalDateTime
package sample.dta;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.Date;
public class Main {
public static void main(String[] args) {
Date date = new Date();
Instant instant = date.toInstant();
LocalDateTime localDateTime = LocalDateTime.ofInstant(instant, ZoneOffset.ofHours(9));
System.out.println(localDateTime);
}
}
実行結果
2015-07-15T22:35:07.354
LocalDateTime -> Date
package sample.dta;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.Date;
public class Main {
public static void main(String[] args) {
LocalDateTime localDateTime = LocalDateTime.now();
Instant instant = localDateTime.toInstant(ZoneOffset.ofHours(9));
Date date = Date.from(instant);
System.out.println(date);
}
}
実行結果
Wed Jul 15 22:36:49 JST 2015
- Date and Time API には、
Date
との直接的な変換方法が用意されていない。 - 代わりに、
Date
の方に Date and Time API が提供するInstant
と相互変換するためのメソッド(from(Instant)
とtoInstant()
)が追加されている。 -
Instant
とは、時間軸上のある時点を表すクラスで、 1970年1月1日の0時0分からの経過時間(エポック秒)をナノ秒の精度で持つ。 - 一方、
Date
は 1970年1月1日の0時0分からの経過時間をミリ秒の精度で持つ。 -
Instant
が最もDate
に似ており、このInstant
を経由して変換を行う。
TemporalAdjuster
package sample.dta;
import java.time.LocalDate;
import java.time.temporal.Temporal;
import java.time.temporal.TemporalAdjusters;
public class Main {
public static void main(String[] args) {
Temporal temporal = LocalDate.now();
System.out.println(temporal);
Temporal lastDayOfMonth = temporal.with(TemporalAdjusters.lastDayOfMonth());
System.out.println(lastDayOfMonth);
}
}
実行結果
2015-07-15
2015-07-31
-
TemporalAdjuster
は、あるTemporal
(時間的オブジェクト)から別のTemporal
を取得(生成)するための方法(Strategy)を提供するためのインターフェース。 - ストラテジーパターンの、ストラテジーに当たる。
- よく使う
TemporalAdjuster
(月の最終日を取得する)は、TemporalAdjusters
の static メソッドで取得できる。
自作する
package sample.dta;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.time.temporal.Temporal;
public class Main {
public static void main(String[] args) {
Temporal temporal = LocalDate.now();
System.out.println(temporal);
Temporal nextDay = temporal.with(temp -> {
return temp.plus(1, ChronoUnit.DAYS);
});
System.out.println(nextDay);
}
}
実行結果
2015-07-15
2015-07-16
- 翌日を取得する
TemporalAdjuster
を自作してみている。 -
TemporalAdjuster
は関数型インターフェースなので、ラムダ式で宣言できる。
夏時間に対応させる
担当するシステムが四半期に一度Javaとtzdbをきちんとアップデートするようスケジューリングできるか?またそのスケジューリングでシステムの挙動に支障はないか?ということ。このいずれかが「否」であればZonedDateTimeは使用すべきではないです。
対案としては、OffsetDateTimeを採用した上で、外部から夏時間等のシフト情報を供給されるTemporalAdjuster実装を作成し、それを適用することになるでしょう。
ということで、夏時間に対応した TemporalAdjuster
を作ってみる。
EastAmericaSummerTimeAdjuster.java
package sample.dta;
import java.time.DayOfWeek;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.temporal.ChronoField;
import java.time.temporal.ChronoUnit;
import java.time.temporal.Temporal;
import java.time.temporal.TemporalAdjuster;
import java.time.temporal.TemporalAdjusters;
/**
* アメリカ東部のサマータイムを取得するためのアジャスター。
*/
public class EastAmericaSummerTimeAdjuster implements TemporalAdjuster {
private static final int NORMAL_OFFSET = -5;
private static final int SUMMER_TIME_OFFSET = -4;
@Override
public Temporal adjustInto(Temporal temporal) {
if (!(temporal instanceof OffsetDateTime)) {
throw new IllegalArgumentException("temporal is not OffsetDateTime.");
}
OffsetDateTime offset = (OffsetDateTime)temporal;
if (this.isInSummerTime(offset)) {
return offset.withOffsetSameInstant(ZoneOffset.ofHours(SUMMER_TIME_OFFSET));
} else {
return offset.withOffsetSameInstant(ZoneOffset.ofHours(NORMAL_OFFSET));
}
}
/**
* 夏時間の期間中であることをチェックする。
*/
private boolean isInSummerTime(OffsetDateTime offset) {
OffsetDateTime start = this.toStartDateTimeOfSummerTime(offset);
OffsetDateTime end = this.toEndDateTimeOfSummerTime(offset);
return (offset.isEqual(start) || offset.isAfter(start)) && offset.isBefore(end);
}
/**
* 夏時間の開始日時を取得する。
*/
private OffsetDateTime toStartDateTimeOfSummerTime(OffsetDateTime offset) {
return offset.withOffsetSameInstant(ZoneOffset.ofHours(NORMAL_OFFSET))
.with(ChronoField.MONTH_OF_YEAR, 3) // 3月の
.with(TemporalAdjusters.firstInMonth(DayOfWeek.SUNDAY))
.with(TemporalAdjusters.next(DayOfWeek.SUNDAY)) // 第2日曜日の
.truncatedTo(ChronoUnit.DAYS)
.with(ChronoField.HOUR_OF_DAY, 2); // 午前2時
}
/**
* 夏時間の終了日時を取得する。
*/
private OffsetDateTime toEndDateTimeOfSummerTime(OffsetDateTime offset) {
return offset.withOffsetSameInstant(ZoneOffset.ofHours(SUMMER_TIME_OFFSET))
.with(ChronoField.MONTH_OF_YEAR, 11) // 11月の
.with(TemporalAdjusters.firstInMonth(DayOfWeek.SUNDAY)) // 第1日曜日の
.truncatedTo(ChronoUnit.DAYS)
.with(ChronoField.HOUR_OF_DAY, 2); // 午前2時
}
}
Main.java
package sample.dta;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.temporal.TemporalAdjuster;
public class Main {
public static void main(String[] args) {
TemporalAdjuster adjuster = new EastAmericaSummerTimeAdjuster();
OffsetDateTime a = OffsetDateTime.parse("2015-07-17T01:00:00+09:00");
OffsetDateTime b = OffsetDateTime.parse("2015-11-30T05:00:00+09:00");
System.out.println(a + " -> " + a.with(adjuster));
System.out.println(b + " -> " + b.with(adjuster));
ZonedDateTime A = a.toZonedDateTime();
ZonedDateTime B = b.toZonedDateTime();
System.out.println(A + " -> " + A.withZoneSameInstant(ZoneId.of("America/New_York")));
System.out.println(B + " -> " + B.withZoneSameInstant(ZoneId.of("America/New_York")));
}
}
実行結果
2015-07-17T01:00+09:00 -> 2015-07-16T12:00-04:00
2015-11-30T05:00+09:00 -> 2015-11-29T15:00-05:00
2015-07-17T01:00+09:00 -> 2015-07-16T12:00-04:00[America/New_York]
2015-11-30T05:00+09:00 -> 2015-11-29T15:00-05:00[America/New_York]
-
OffsetDateTime
に対して自作したEastAmericaSummerTimeAdjuster
を適用すると、夏時間を考慮したアメリカ東部での時刻が取得できる。 - アメリカの夏時間は、「3月の第2日曜日の午前2時」から「11月の第1日曜日の午前2時」までが対象となる。
- 厳密には、 2007 年からがこの期間で、それより前の年は夏時間の期間が異なる。
- 今回は、とりあえず 2015 年現在の夏時間に対応させている。
- 夏時間中は、オフセットが
-04:00
となり、通常時は-05:00
になる。 -
EastAmericaSummerTimeAdjuster
は、適用されたOffsetDateTime
の日付が夏時間の期間内かどうかをチェックし、結果に合わせてオフセットを調整したOffsetDateTime
を返すようにしている。 - 今回は、アジャスターの実装がどんな感じになるのかを試すのが目的なので、夏時間の開始・終了日時はハードコーディングしている。
- しかし実際は、設定ファイルなどに期間の定義を保存しておき、動的に開始日時と終了日時を生成できるようにしておくことになると思う。
Clock で時間を固定する
Main.java
package sample.dta;
import java.time.Clock;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
public class Main {
public static void main(String[] args) {
test("systemDefaultZone()", Clock.systemDefaultZone());
test("fixed()", Clock.fixed(OffsetDateTime.now().toInstant(), ZoneOffset.ofHours(9)));
}
private static void test(String tag, Clock clock) {
System.out.println(tag + " : clock.class = " + clock.getClass().getSimpleName());
System.out.println(LocalDateTime.now(clock));
sleep(1000);
System.out.println(LocalDateTime.now(clock));
sleep(1000);
System.out.println(LocalDateTime.now(clock));
}
private static void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {}
}
}
実行結果
systemDefaultZone() : clock.class = SystemClock
2015-07-19T08:54:14.292
2015-07-19T08:54:15.293
2015-07-19T08:54:16.294
fixed() : clock.class = FixedClock
2015-07-19T08:54:16.294
2015-07-19T08:54:16.294
2015-07-19T08:54:16.294
-
now()
にClock
のインスタンスを渡すと、Clock
から現在時刻が取得される。 -
Clock#systemDefaultZone()
を使うと、システム時間を取得できるClock
インスタンスが返される。 -
Clock#fixed()
を使うと、常に指定した時刻を返すClock
インスタンスを取得できる。 - プロダクション環境では
systemDefaultZone()
を使うようにして、テストのときはfixed()
を使うように切り替えることで、テストがしやすくなる。 - 切り替えは、例えば Java EE を使っているなら CDI の Provider を使えば簡単に実現できる。
参考
- Date and Time APIを理解する為には、ISO 8601に踏み込みましょう! - Togetterまとめ
- Introduction to Date and Time API, 3rd Ed.
- Introduction to Date and Time API, 2nd edition
- 「Java 8徹底再入門」に登壇しました。 - Programming Studio
- ISO 8601 - Wikipedia
- tz database - Wikipedia
- サマータイム(夏時間)について アメリカ/ロスアンゼルス特派員ブログ | 地球の歩き方
- アメリカの時差と現在時刻 - Time-j.net
- java.time (Java Platform SE 8 )
- JSR 310 - TemporalAdjuster
所感等
- ISO 8601 と tz database の説明が先にあったため、
Local*
とOffset*
とZonedDateTime
の違い・使い分けがすんなりと理解できました。 - 自分も人に説明するときは、 ISO 8601 と tz database の話から説明するようにしたいと思います。
- アジャスターを実際に触ってみた感じ、かなり柔軟に日付の操作ができるようになっているのだなぁと感じた。
- Date and Time API いいね!