java.time: LocalDate、ZonedDateTime、Duration の使い分け

1. 概要

日時処理はバグの温床です:請求書が1日ずれる、会議の時刻がタイムゾーン違いで間違う、サマータイムで処理が2回実行される。Java 8 以降の java.time パッケージがこれらの問題をほぼ解決しますが、正しいデータ型を選ぶことが前提です。

この記事では最もよく使われる3つの型に焦点を当てます:LocalDate、ZonedDateTime、Duration です。読み終わるまでに、いつどの型を使うか、タイムゾーンの変換やサマータイム(DST)の処理方法、実行時刻に依存しないテストの書き方がわかるようになります。

2. java.time を使うべき理由

java.util.Date と Calendar にはたくさんの問題があります。ミュータブルなためスレッドセーフでなく、月が0から始まり、Date という名前が誤解を招きます(実は時点を表しています)。java.time はこれらの問題を解決しています:

  • すべてのオブジェクトが イミュータブル で スレッドセーフ です。plusDays() のようなメソッドは常に新しいオブジェクトを返します。
  • 各概念に対応した専用の型があります:
型 意味 例
LocalDate 日付(時刻なし、タイムゾーンなし) 生年月日、期限日
LocalDateTime 日付 + 時刻(タイムゾーンなし) カレンダー上の時刻(場所未定)
ZonedDateTime 日付 + 時刻 + ZoneId ハノイで午前9時の会議
Instant タイムライン上の1つの時点(UTC) DB に保存するタイムスタンプ、ログ
Duration 秒/ナノ秒単位の時間間隔 30秒のタイムアウト
Period 年/月/日単位の時間間隔 3ヶ月間のプラン

追加の依存関係は不要で、すべて JDK に含まれています。テストの例を実行するには JUnit 5 と AssertJ が必要です(spring-boot-starter-test を使えばすでに含まれています):

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.10.2</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.25.3</version>
    <scope>test</scope>
</dependency>

3. LocalDate: 日付を扱う

3.1. 作成とパース

LocalDate dueDate = LocalDate.of(2024, 5, 15);
LocalDate parsed = LocalDate.parse("2024-02-29");

DateTimeFormatter vnFormat = DateTimeFormatter.ofPattern("dd/MM/yyyy");
LocalDate fromForm = LocalDate.parse("15/05/2024", vnFormat);

System.out.println(dueDate);                     
System.out.println(fromForm.equals(dueDate));    
System.out.println(dueDate.format(vnFormat));    
2024-05-15
true
15/05/2024

LocalDate.of() は1〜12の月を受け取ります。可読性のため Month.MAY も渡せます。フォーマッターなしで parse() するとISO-8601形式(yyyy-MM-dd)を使用します。別の形式にはカスタム DateTimeFormatter が必要で、フォーマッターはスレッドセーフなので static final で宣言すべきです。

注意: yyyy の代わりに YYYY を使わないでください。YYYY は 週ベースの年 です。2024年12月30日を YYYY でフォーマットすると 2025 年になります。このバグは年末までまず見つかりません。

3.2. 加算・減算と月末の扱い

LocalDate orderDate = LocalDate.of(2024, 1, 31);

System.out.println(orderDate.plusDays(10));     
System.out.println(orderDate.plusMonths(1));    
System.out.println(orderDate.plusMonths(1).plusMonths(1)); 
System.out.println(orderDate.plusMonths(2));    
2024-02-10
2024-02-29
2024-03-29
2024-03-31

2月には31日がないので、plusMonths(1) は有効な最後の日である29日に丸まります。結果として2回1ヶ月ずつ加えることは、1回2ヶ月加えることと 異なります。月次請求サイクルを計算する場合は、常に基準日から計算してください(例:start.plusMonths(n))。前の期間の結果に累積して加えないでください。

3.3. TemporalAdjusters

「月末の日」「次の月曜日」といった質問への答えが adjuster で用意されています:

LocalDate today = LocalDate.of(2024, 5, 15); // 水曜日

LocalDate endOfMonth = today.with(TemporalAdjusters.lastDayOfMonth());
LocalDate nextMonday = today.with(TemporalAdjusters.next(DayOfWeek.MONDAY));
LocalDate firstOfNextMonth = today.with(TemporalAdjusters.firstDayOfNextMonth());

System.out.println(endOfMonth + " " + nextMonday + " " + firstOfNextMonth);
2024-05-31 2024-05-20 2024-06-01

3.4. 2つの日付間の距離:Period と ChronoUnit

LocalDate start = LocalDate.of(2024, 1, 15);
LocalDate end = LocalDate.of(2024, 3, 10);

Period period = Period.between(start, end);
long days = ChronoUnit.DAYS.between(start, end);

System.out.println(period);              
System.out.println(period.getDays());    
System.out.println(days);                
P1M24D
24
55

これは非常によく起こるバグです。period.getDays() は「1ヶ月24日」の 日の部分だけ(24)を返し、総日数ではありません。総日数を得るには ChronoUnit.DAYS.between() を使います。

4. ZonedDateTime: タイムゾーンが紐付いた日時

4.1. 作成とタイムゾーン変換

ZoneId hcm = ZoneId.of("Asia/Ho_Chi_Minh");
ZoneId newYork = ZoneId.of("America/New_York");

ZonedDateTime meetingHcm = ZonedDateTime.of(
        LocalDateTime.of(2024, 6, 1, 9, 0), hcm);

ZonedDateTime sameMoment = meetingHcm.withZoneSameInstant(newYork);
ZonedDateTime sameClock = meetingHcm.withZoneSameLocal(newYork);

System.out.println(meetingHcm);
System.out.println(sameMoment);
System.out.println(sameClock);
2024-06-01T09:00+07:00[Asia/Ho_Chi_Minh]
2024-06-01T09:00-04:00[America/New_York]

待ってください。これは完全な出力ではありません。正しい出力は:

2024-06-01T09:00+07:00[Asia/Ho_Chi_Minh]
2024-05-31T22:00-04:00[America/New_York]
2024-06-01T09:00-04:00[America/New_York]

この2つのメソッドの違いは次の通りです:

  • withZoneSameInstant() は 時点 を保持します。ホーチミンの朝9時はニューヨークの前夜22時(現在サマータイム中、UTC-4)です。これが95%のケースで必要なメソッドで、例えば別のタイムゾーンにいる人に会議を表示する場合です。
  • withZoneSameLocal() は 時計上の時刻 を保持し、別の時点にします。このメソッドはデータが誤ったゾーンに割り当てられた場合など、意図的にタイムゾーンを再割り当てするときだけ使用します。

常に Europe/Berlin のような 地域/都市 形式の ID を使ってください。EST や ICT のような省略形は曖昧です。+07:00 のような固定オフセットもサマータイムのルールについて何も知りません。

4.2. サマータイム:存在しない時間帯

2024年3月31日、ドイツでは時計が02:00から直接03:00へ跳ねます。つまり02:30はその日存在しません:

ZoneId berlin = ZoneId.of("Europe/Berlin");
ZonedDateTime inGap = ZonedDateTime.of(
        LocalDateTime.of(2024, 3, 31, 2, 30), berlin);

System.out.println(inGap);
2024-03-31T03:30+02:00[Europe/Berlin]

java.time は例外をスローせず、時間をギャップの長さ(1時間)だけ後ろにシフトします。逆方向では、サマータイムが終わるとき、時計上の1時間が2回出現します。デフォルトで java.time は早いオフセットを選びます。withLaterOffsetAtOverlap() で変更できます。

4.3. Instant:データベースに保存すべきもの

「何かが起こった時刻」を記録するとき、Instant に変換してください(またはDB の TIMESTAMP WITH TIME ZONE 型):

Instant createdAt = meetingHcm.toInstant();
System.out.println(createdAt);                      
System.out.println(createdAt.atZone(newYork));      
2024-06-01T02:00:00Z
2024-05-31T22:00-04:00[America/New_York]

一般的なルールは Instant を保存し、ユーザーのタイムゾーンで表示する場合だけ ZonedDateTime に変換する です。

5. Duration:時間経過を正確に測る

5.1. 作成と読取

Duration timeout = Duration.ofSeconds(30);
Duration delivery = Duration.ofMinutes(135);

System.out.println(delivery);                   
System.out.println(delivery.toMinutes());       
System.out.printf("%d時間%d分%n",
        delivery.toHoursPart(), delivery.toMinutesPart());

Instant start = Instant.parse("2024-05-15T08:00:00Z");
Instant end = Instant.parse("2024-05-15T08:00:42.5Z");
System.out.println(Duration.between(start, end).toMillis()); 
PT2H15M
135
2時間15分
42500

toMinutes() は 総 分数を返します。toHoursPart() と toMinutesPart()(Java 9+)は表示用に各部分を分割します。これはセクション3.4の Period.getDays() と同じ落とし穴です。

5.2. Duration と Period:「1日」が常に24時間とは限らない

これが記事で最も重要な違いです:

ZoneId berlin = ZoneId.of("Europe/Berlin");
ZonedDateTime beforeDst = ZonedDateTime.of(
        LocalDateTime.of(2024, 3, 30, 12, 0), berlin);

ZonedDateTime calendarDay = beforeDst.plusDays(1);              
ZonedDateTime exact24h = beforeDst.plus(Duration.ofDays(1));    

System.out.println(calendarDay);
System.out.println(exact24h);
System.out.println(Duration.between(beforeDst, calendarDay));
2024-03-31T12:00+02:00[Europe/Berlin]
2024-03-31T13:00+02:00[Europe/Berlin]
PT23H

上記の結果は3つのことを示しています:

  • plusDays(1) は カレンダー 単位で加算し、翌日の同じ時刻(12:00)を保ちます。実際には23時間しかありません。
  • plus(Duration.ofDays(1)) は 正確に24時間 加算し、13:00に到達します。
  • Duration.between() は実際の間隔が23時間であることを確認しています。

型を選ぶルールは非常にシンプルです。ユーザーがカレンダーで考える場合(「毎日朝9時に通知」「1ヶ月間プラン」)は Period または plusDays/plusMonths を使います。システムが実経過時間を測る場合(タイムアウト、24時間SLA、レート制限)は Duration を使います。

注意: Duration を LocalDate に加算することはできません。LocalDate.plus(Duration.ofHours(5)) は UnsupportedTemporalTypeException をスローします。LocalDate には時間がないため、日より小さい単位は意味がありません。

6. Clock でテスト可能なコードを書く

ビジネスロジック内で直接 LocalDate.now() を呼び出すと、テストが実行日に依存してしまいます。正しい方法は Clock をインジェクトすることです:

public class SubscriptionService {

    private static final int TRIAL_DAYS = 14;
    private final Clock clock;

    public SubscriptionService(Clock clock) {
        this.clock = clock;
    }

    public LocalDate trialEndDate() {
        return LocalDate.now(clock).plusDays(TRIAL_DAYS);
    }

    public boolean isExpired(LocalDate expiryDate) {
        return LocalDate.now(clock).isAfter(expiryDate);
    }

    public Duration timeUntil(Instant deadline) {
        return Duration.between(Instant.now(clock), deadline);
    }
}

java.time のすべての now() メソッドは Clock パラメータを受け取るオーバーロードを持ちます。Spring Boot 3.x では bean として宣言します:

@Configuration
public class TimeConfig {

    @Bean
    public Clock clock() {
        return Clock.system(ZoneId.of("Asia/Ho_Chi_Minh"));
    }
}

systemDefaultZone() ではなく、明示的にゾーンを指定して Clock.system(...) を使ってください。これにより、サーバーがUTCで実行され、開発マシンがベトナム時刻で実行される場合でも結果が変わりません。

テストでは Clock.fixed() で時間を固定します:

class SubscriptionServiceTest {

    private static final ZoneId HCM = ZoneId.of("Asia/Ho_Chi_Minh");
    // 03:00 UTC = 2024年5月15日朝10:00 ホーチミン時間
    private final Clock fixedClock =
            Clock.fixed(Instant.parse("2024-05-15T03:00:00Z"), HCM);
    private final SubscriptionService service = new SubscriptionService(fixedClock);

    @Test
    void trialEndsAfterFourteenDays() {
        assertThat(service.trialEndDate()).isEqualTo(LocalDate.of(2024, 5, 29));
    }

    @Test
    void expiredOnlyAfterExpiryDate() {
        assertThat(service.isExpired(LocalDate.of(2024, 5, 15))).isFalse();
        assertThat(service.isExpired(LocalDate.of(2024, 5, 14))).isTrue();
    }

    @Test
    void calculatesRemainingTime() {
        Instant deadline = Instant.parse("2024-05-15T05:30:00Z");
        assertThat(service.timeUntil(deadline)).isEqualTo(Duration.ofMinutes(150));
    }

    @Test
    void calendarDayAcrossDstIsTwentyThreeHours() {
        ZonedDateTime start = ZonedDateTime.of(
                LocalDateTime.of(2024, 3, 30, 12, 0), ZoneId.of("Europe/Berlin"));
        assertThat(Duration.between(start, start.plusDays(1))).isEqualTo(Duration.ofHours(23));
    }
}

2番目のテストは境界をチェック:5月15日に有効期限の場合、5月15日当日は有効期限内です。最後のテストはセクション5.2のDSTの動作を「ロック」します。誰かが plusDays を plus(Duration.ofDays(1)) に変更した場合、テストがすぐに報告します。

見落としやすい細部があります。Instant.parse("2024-05-14T18:00:00Z") を固定時点として使う場合、ホーチミン時間の LocalDate.now(clock) は 5月15日(朝1時)です。UTCではまだ5月14日です。したがって、Clock のゾーンが「今日」が何日かを決定します。

7. よくあるバグと使ってはいけない場合

  • イベントのタイムスタンプに LocalDateTime を使う。 LocalDateTime はタイムゾーンを知らないため、後で読む時に「10:00」がどこの時間か不明です。イベントには Instant または OffsetDateTime/ZonedDateTime を使ってください。
  • 単なる日付に ZonedDateTime を使う。 生年月日や祝日は LocalDate です。タイムゾーンを付けるとゾーンを変更するときに日付が「流れて」しまう可能性があります。
  • ZoneId をオフセットで保存する。 +07:00 はサマータイムに応じて変わりませんが、Europe/Berlin は変わります。将来の予定については、LocalDateTime と ZoneId(文字列)を一緒に保存してください。ルールは約定日前に変わる可能性があり、この保存方法で正確に再計算できます。
  • getDays() / toMinutesPart() を合計と混同する。 ChronoUnit.X.between() または toMinutes() を使ってください。
  • 異なるゾーンの2つの ZonedDateTime を equals() で比較する。 equals() はゾーン込みで比較するため、同じ時点でもゾーンが違えば false です。時点で比較するには isEqual()、isBefore()、isAfter() を使ってください。
  • ビジネスロジックで Clock なしで now() を呼び出す。 テストが実行日時に依存して不安定になります。

注意: java.time はビジネスロジック用カレンダーの置き換えではありません。営業日、ベトナムの祝日、旧暦には専用のロジックが必要です。自作の TemporalAdjuster がそれをカプセル化するのに最適な場所です。

8. まとめ

正しいデータ型を選ぶことが最も重要なステップです。カレンダー上の日付には LocalDate を、ある場所に紐付いた時刻には ZonedDateTime を、時点を保存するには Instant を使ってください。時間間隔では Period はカレンダーに従い、Duration は時計に従い、セクション5.2で見たように、サマータイム遭遇時に異なる結果をもたらします。

また、Clock をサービスにインジェクトし、明示的にゾーンを宣言してください。月末、時間帯の変更、UTC真夜中などの境界ケースをテストで書いてください。これらを実践すれば、ほとんどの日時バグは本番稼働前にブロックされます。

javajava-timedatetimetimezonetesting