0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Java ZonedDateTimeの注意点

Last updated at Posted at 2024-04-15

Javaランタイムには IANA Time Zone Database (以下tzdata)が組み込まれており、標準APIのZonedDateTime を使えば、tzdataを用いたタイムゾーンの取り扱いが便利にできる。

タイムゾーンは相応に複雑なものであり、気軽にZonedDateTimeを使うと怪我をする。java.timeパッケージJavaDocのDesign notesでも、可能であれば ZonedDateTimeの使用を避けて、LocalDateTime 等のタイムゾーンの複雑さを回避できるオブジェクトの使用を推奨している。

しかし、サマータイムや複数の地域ベースタイムゾーンを扱わなければならないなど、ZonedDateTimeを避けられないケースも多々ある。本稿ではZonedDateTimeを使う上での注意事項をまとめた。

ofメソッド

of(LocalDateTime localDateTime, ZoneId zone)は、UTCオフセットを指定する必要が無くカジュアルに使用できる。しかしながら、サマータイム切り替わり等UTCオフセットが変化する日における、wall clock(ローカル日時)上存在しない時間 (API仕様書では「ギャップ」(Gap)と呼称)や、二度繰り返される時間 (API仕様書では「重複」(Overlap)と呼称) の取り扱いにあたり、偏った挙動をするので要注意である。

ギャップに該当する時刻を指定した場合、ギャップ長 (一般的な1時間シフトの夏時間に当たる場合は1時間) だけwall clockを進めてZonedDateTimeオブジェクトが生成される。

// 2024-03-31 02:30 (中央ヨーロッパ時間)のつもりだが、その時刻は存在せず、
// 2024-03-31 03:30となる。
ZonedDateTime.of(
  LocalDateTime.parse("2024-03-31T02:30"),
  ZoneId.of("Europe/Berlin")
);
// => 2024-03-31T03:30+02:00[Europe/Berlin]

// 進められるのはあくまでギャップ長であり、1時間とは限らない。
// オーストラリアのロードハウ島のタイムゾーンではサマータイムが30分シフトなのでギャップ超は30分となる:
// 2023-10-01 02:15のつもりだが、
// 2023-10-01 02:45となる。
ZonedDateTime.of(
  LocalDateTime.parse("2023-10-01T02:15"),
  ZoneId.of("Australia/Lord_Howe")
);
// => 2023-10-01T02:45+11:00[Australia/Lord_Howe]

重複ケース (複数のUTCオフセットがあり得る曖昧なケース) では、UTCオフセット値の大きい方 (通常、夏時間) が使用される。

// 中央ヨーロッパ時間では、2023-10-29 02:30のUTCオフセットは、
// +01:00 (標準時) と +02:00 (夏時間) どちらか曖昧だが、+02:00がサイレントに使用される。
ZonedDateTime.of(
  LocalDateTime.parse("2023-10-29T02:30"),
  ZoneId.of("Europe/Berlin")
);
// => 2023-10-29T02:30+02:00[Europe/Berlin]

// USA太平洋時間では、2023-11-05 01:30のUTCオフセットは、
// -08:00 (標準時) と -07:00 (夏時間) どちらか曖昧だが、-07:00がサイレントに使用される。
ZonedDateTime.of(
  LocalDateTime.parse("2023-11-05T01:30"),
  ZoneId.of("America/Los_Angeles")
);
// => 2023-11-05T01:30-07:00[America/Los_Angeles]

なお、parseメソッドもこれと同様の振る舞いをする (後述)。

ofStrictメソッド

ofStrict(LocalDateTime localDate, ZoneOffset offset, ZoneId zone) メソッドは、その名が示す通り時刻を厳密に取り扱う。

ギャップに該当する存在しない時刻を入力した場合、不正な入力として例外が発生する。

重複ケースについては、UTCオフセットとしてZoneOffset offset、および地域ベースタイムゾーン等として ZoneId zone を引数で指定しなければならないので、そもそも曖昧ケースというものが無い。ただし、指定内容に矛盾がある場合 (例えば、wall clockとして標準時しかありえない時刻に夏時間オフセットを指定した場合等)は例外が発生する。

意図せぬ不正なケースケースを例外で検出できるという点では、ofStrictは最も安全である。ただし、UTCオフセット、タイムゾーン両方の指定が必須であり柔軟な使用はできない。UTCオフセット、タイムゾーンともに分かっている永続化データの復元等に適している。

// 存在しないギャップ時刻は、標準時、夏時間どちらも例外となる
ZonedDateTime.ofStrict(
  LocalDateTime.parse("2024-03-31T02:30"),
  ZoneOffset.of("+01:00"), // 標準時
  ZoneId.of("Europe/Berlin")
);
// => java.time.DateTimeException: LocalDateTime '2024-03-31T02:30' does not exist in zone 'Europe/Berlin' due to a gap in the local time-line, typically caused by daylight savings

ZonedDateTime.ofStrict(
  LocalDateTime.parse("2024-03-31T02:30"),
  ZoneOffset.of("+02:00"), // 夏時間
  ZoneId.of("Europe/Berlin")
);
// => java.time.DateTimeException: LocalDateTime '2024-03-31T02:30' does not exist in zone 'Europe/Berlin' due to a gap in the local time-line, typically caused by daylight savings

// 重複ケースは指定オフセットに基づいて生成される
ZonedDateTime.ofStrict(
  LocalDateTime.parse("2023-10-29T02:30"),
  ZoneOffset.of("+01:00"), // 標準時
  ZoneId.of("Europe/Berlin")
); // => 2023-10-29T02:30+01:00[Europe/Berlin]

ZonedDateTime.ofStrict(
  LocalDateTime.parse("2023-10-29T02:30"),
  ZoneOffset.of("+02:00"), // 夏時間
  ZoneId.of("Europe/Berlin")
); // => 2023-10-29T02:30+02:00[Europe/Berlin]

// 入力不正の場合は例外となる
// 中央ヨーロッパ時間では、2023-10-29 03:00は夏時間から標準時への切り替わりが完了しており、夏時間オフセット(+02:00)はありえないので無効
ZonedDateTime.ofStrict(
  LocalDateTime.parse("2023-10-29T03:00"),
  ZoneOffset.of("+02:00"), // 夏時間 (ありえない)
  ZoneId.of("Europe/Berlin")
); // => java.time.DateTimeException: ZoneOffset '+02:00' is not valid for LocalDateTime '2023-10-29T03:00' in zone 'Europe/Berlin'

ofLocalメソッド

ofLocal(LocalDateTime localDateTime, ZoneId zone, ZoneOffset preferredOffset)メソッドは、曖昧な重複ケースに対して、どのUTCオフセットを適用するか指定したいときに利用できる。

ofLocalは、ZoneIdとUTCオフセットをともに指定し、重複ケースの時刻に対しては、ユーザーが指定したUTCオフセットを適用する。タイムゾーン上適用できないオフセットが指定された場合は、ofと同様に、有効なオフセットのうち、UTCオフセットの大きい方が適用される。

// ofLocalはユーザー指定のオフセットが適用される

ZonedDateTime.ofLocal(
  LocalDateTime.parse("2023-10-29T02:30"),
  ZoneId.of("Europe/Berlin"),
  ZoneOffset.of("+01:00") // 標準時
);
// => 2023-10-29T02:30+01:00[Europe/Berlin]

ZonedDateTime.ofLocal(
  LocalDateTime.parse("2023-10-29T02:30"),
  ZoneId.of("Europe/Berlin"),
  ZoneOffset.of("+02:00") // 夏時間
);
// => 2023-10-29T02:30+02:00[Europe/Berlin]

// 同wall clockに対して+03:00(不正)を指定すると、+02:00がサイレントに適用される
ZonedDateTime.ofLocal(
  LocalDateTime.parse("2023-10-29T02:30"),
  ZoneId.of("Europe/Berlin"),
  ZoneOffset.of("+03:00") // ありえない
);
// => 2023-10-29T02:30+02:00[Europe/Berlin]

ギャップ時刻を指定した場合は、of と同じ挙動となる。

ZonedDateTime.ofLocal(
  LocalDateTime.parse("2024-03-31T02:30"),
  ZoneId.of("Europe/Berlin"),
  ZoneOffset.of("+01:00")
);
// => 2024-03-31T03:30+02:00[Europe/Berlin]

ZonedDateTime.ofLocal(
  LocalDateTime.parse("2024-03-31T02:30"),
  ZoneId.of("Europe/Berlin"),
  ZoneOffset.of("+02:00")
);
// => 2024-03-31T03:30+02:00[Europe/Berlin]

ofInstantメソッド

ofInstant(LocalDateTime localDateTime, ZoneOffset offset, ZoneId zone)は、localDateTimeoffset を組み合わせてタイムライン上の時点 (Instant)を確定する。そのInstantを zone 引数で示されるタイムゾーンを持つ ZonedDateTimeオブジェクトを生成する。

Instantを特定してからタイムゾーン情報を付与するので、ギャップや重複等の不整合や曖昧さが排除される。さらにはoffsetzoneの整合性すら不要である。UTCオフセットはあくまでInstantを特定するために使用されるので、生成されたZonedDateTimeオブジェクトがそのオフセットを持つことは保証されない。

より直接的な ofInstant(Instant instant, ZoneId zone)もある。

ZonedDateTime.ofInstant(
  LocalDateTime.parse("2023-10-29T02:30"),
  ZoneOffset.of("+01:00"), // 標準時
  ZoneId.of("Europe/Berlin")
);
// => 2023-10-29T02:30+01:00[Europe/Berlin]

ZonedDateTime.ofInstant(
  LocalDateTime.parse("2023-10-29T02:30"),
  ZoneOffset.of("+02:00"), // 夏時間
  ZoneId.of("Europe/Berlin")
);
// => 2023-10-29T02:30+02:00[Europe/Berlin]

ZonedDateTime.ofInstant(
  LocalDateTime.parse("2023-10-29T02:30"),
  ZoneOffset.of("+03:00"), // 本来ありえないが、Instantを先に特定するので問題無い
  ZoneId.of("Europe/Berlin")
);
// => 2023-10-29T01:30+02:00[Europe/Berlin]

// 次の例は一つ上と意味的に同義である
// ZoneOffsetとZoneIdが一貫性していなくてよい理由が明確である
Instant instant = LocalDateTime.parse("2023-10-29T02:30")
    .toInstant(ZoneOffset.of("+03:00"));
instant.atZone(ZoneId.of("Europe/Berlin"));
// => 2023-10-29T01:30+02:00[Europe/Berlin]

// ギャップ
// オフセットの入力と出力が逆転して見えるが、Instantベースでは間違っていない
ZonedDateTime.ofInstant(
  LocalDateTime.parse("2024-03-31T02:30"),
  ZoneOffset.of("+01:00"), // 標準時
  ZoneId.of("Europe/Berlin")
);
// => 2024-03-31T03:30+02:00[Europe/Berlin]

ZonedDateTime.ofInstant(
  LocalDateTime.parse("2024-03-31T02:30"),
  ZoneOffset.of("+02:00"), // 夏時間
  ZoneId.of("Europe/Berlin")
);
// => 2024-03-31T01:30+01:00[Europe/Berlin]

parseメソッド

デフォルトのparseメソッドは、DateTimeFormatter.ISO_ZONED_DATE_TIME の書式をパースでき、UTCオフセットおよび地域ベースタイムゾーンを両方指定できるが、UTCオフセットは無視され、ofと同様に振る舞う。ResolverStyle にはデフォルトでSTRICTが用いられるが、UTCオフセットの取り扱いは ofStrict のような厳格さは無い (日付レベルの矛盾は例外を上げられる。例えば非うるう年で2月29日を指定した場合等)。

解析対象の書式を明示的に指定したい場合は、parse(CharSequence text, DateTimeFormatter formatter)が利用できる。

// 重複ケース: 指定UTCオフセットに関係なく全て夏時間となる
// 標準時
ZonedDateTime.parse("2023-10-29T02:30:00+01:00[Europe/Berlin]");
// => 2023-10-29T02:30+02:00[Europe/Berlin]

// 夏時間
ZonedDateTime.parse("2023-10-29T02:30:00+02:00[Europe/Berlin]");
// => 2023-10-29T02:30+02:00[Europe/Berlin]

// ありえないオフセット
ZonedDateTime.parse("2023-10-29T02:30:00+03:00[Europe/Berlin]");
// => 2023-10-29T02:30+02:00[Europe/Berlin]

// ギャップケース: wall clockが ギャップ長の1時間加算され、UTCオフセットは無視される
// 標準時
ZonedDateTime.parse("2024-03-31T02:30:00+01:00[Europe/Berlin]");
// => 2024-03-31T03:30+02:00[Europe/Berlin]

// 夏時間
ZonedDateTime.parse("2024-03-31T02:30:00+02:00[Europe/Berlin]");
// => 2024-03-31T03:30+02:00[Europe/Berlin]

// ありえないオフセット
ZonedDateTime.parse("2024-03-31T02:30:00+03:00[Europe/Berlin]");
// => 2024-03-31T03:30+02:00[Europe/Berlin]

plusXxx/minusXxxメソッド

1日 = 24時間 という思い込みに注意

地域ベースタイムゾーンを持つZonedDateTimeを扱う限りで1日が24時間である保証はない。サマータイムの切り替わりなど時差の変更が発生するからである。

wall clock上翌日の同時刻を取得するつもりで zdt.plusHours(24) を実行しても期待する結果が得られるとは限らない。その逆に24時間後のつもりで、zdt.plusDays(1) と実行すると、25時間後や23時間後が得られるかもしれない。

システム仕様やビジネス要求が「1日」や「24時間」と言った場合、それが本当に「1日」や「24時間」を意図しているのか明確だろうか。たとえば「キャッシュの有効期限を1日とする」と言った場合、それは「1日」ではなく「24時間」を意図していることが多いだろう。一方で、ロンドン時間ベースで毎日9時に重要顧客との会議をスケジュールする場合、もし24時間周期でスケジュールを組んでしまえば大事なビジネスチャンスを逃してしまうかもしれない。

24時間後のつもりで1日足してしまった
ZonedDateTime aDay = ZonedDateTime.of(
  LocalDateTime.parse("2024-03-30T09:00"),
  ZoneId.of("Europe/London")
);
// わかりやすさのためInstant化
System.out.println(aDay.toInstant());
// => 2024-03-30T09:00:00Z

// 24時間後のつもりで1日足したが、サマータイムを跨いだので23時間後になってしまった
System.out.println(aDay.plusDays(1).toInstant());
// => 2024-03-31T08:00:00Z
1日後のつもりで24時間足してしまった
ZonedDateTime aDay = ZonedDateTime.of(
  LocalDateTime.parse("2024-03-30T09:00"),
  ZoneId.of("Europe/London")
);
// => 2024-03-30T09:00Z[Europe/London] (※Z == +00:00)

// 翌日の同時刻のつもりで24h足したが、サマータイムを跨いだので実際の時計は予定より1時間ずれてしまった
ZonedDateTime nextDay = aDay.plusHours(24);
// => 2024-03-31T10:00+01:00[Europe/London]

以下は少し次元の違うケースではあるが、1時間経つとカレンダー上2日進むという、1日 ≠ 24時間の極端な実例である。

南太平洋の日付変更線境界に位置するサモアおよびトケラウでは、それぞれ現地時間2011-12-29からその翌日への日付変更の瞬間をもって標準時のUTCオフセットを-11:00から+13:00へ24時間進める変更を実施した。24時間進んだ結果12月30日の丸一日がスキップされることとなった1。というわけで12月29日から日付を跨ぐ時間 (1時間でも1秒でも) 加算すると、カレンダー上2日後の12月31になってしまう。もちろん曜日も木曜日から土曜日へ一足飛びに変わり、金曜日が存在しない。なおサモアは時差変更発生当時サマータイム期間中だったので、その時点のUTCオフセット変更は-10:00 → +14:00である (トケラウはサマータイムを採用していないので-11:00 → +13:00)。それ以前にも、キリバスで1994年から1995年切り替わりのタイミングで同様にUTCオフセットを24時間進める変更が行われたことがある2。もちろんこれら全てIANA tzdataで管理されているので、ZonedDateTimeを使う以上は「存在しない日があるかもしれない」問題を避けて通ることはできない (将来どんなタイムゾーン変更があるかは予測困難である)。

サモアの例
ZonedDateTime aDay = ZonedDateTime.of(
  LocalDateTime.parse("2011-12-29T23:00"),
  ZoneId.of("Pacific/Apia")
);
System.out.printf("%s %s\n", aDay, aDay.getDayOfWeek());
// => 2011-12-29T23:00-10:00[Pacific/Apia] THURSDAY

// 1時間後が2日後になる。曜日も金曜日がスキップされる
ZonedDateTime plus1h = aDay.plusHours(1);
System.out.printf("%s %s\n", plus1h, plus1h.getDayOfWeek());
// => 2011-12-31T00:00+14:00[Pacific/Apia] SATURDAY

PeriodとDurationの混同に注意

PeriodクラスもDurationクラスも、どちらもTemporalAmountインターフェースを実装した、一定期間 (時間量) を表すことができるクラスである。ともに ZonedDateTime の時間演算に使用できる。しかしながら、PeriodDurationとは、ベースとなる時間量の単位が異なるので、理解せずに使用すると、思わぬ事故に遭う可能性がある。

Periodは、date(日)基準の時間量を持ち、Durationは、time(時分秒)基準の時間量を持つ (最小単位はナノ秒)。したがって、Periodベースの翌日は、カレンダー上の翌日しか意味せず、上記のサマータイムを跨げば、それは23時間後かもしれないし、25時間後かもしれない。 一方でDuration ベースで1日を表す Duration.ofDays(1) は、サマータイムに関係なく正確に24時間を意味する。Duration.ofDays(1).equals(Duration.ofHours(24)) // => true となる。したがて、Period.ofDays(1)Duration.ofDays(1)は同じ1日ではない。混同して使用すると事故につながる

ZonedDateTime aDay = ZonedDateTime.of(
  LocalDateTime.parse("2024-03-30T09:00"),
  ZoneId.of("Europe/Berlin")
);
// => 2024-03-30T09:00+01:00[Europe/Berlin]

// 翌日の同時刻のつもりでDuration.ofDays(1)を加算したが、1時間ずれてしまった
aDay.plus(Duration.ofDays(1));
// => 2024-03-31T10:00+02:00[Europe/Berlin]

// Period.ofDays(1)なら期待通り同時刻となる。
aDay.plus(Period.ofDays(1));
// => 2024-03-31T09:00+02:00[Europe/Berlin]
  1. Samoa and Tokelau skip a day for dateline change (BBC News, 30 December 2011)

  2. How does a country change its time zone? (BBC News, 10 May 2011)

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?