[Java] システム日付ってアプリケーションレイヤで固定すればよくない?って話

2023/11/24に公開

概要

  • 業務システムを作っていると特定の日時で起動するバッチ等々、日付や日時の境界線が絡む要件が出てくることがまぁまぁある
  • でもシステム日付を固定したいがAWS等のクラウドサービスを使っていると簡単に出来ないことが多い(オンプレの頃はよかったんだけど)
  • それならアプリケーションレイヤで擬似的にシステム日付を固定する仕組みを作ればいいじゃないか

といったお話。

デモコード

以下に置いときました。
解説とかどうでもいいわという気の短い早漏野郎はコードだけどうぞ。

https://github.com/i-zacky/demo-fixed-clock

本題

固定する日時情報をデータとして保持しておく

固定しておきたい日時情報をテーブルのデータとして管理しておくために、テーブルを作っておきましょう。
今回はデモのためDBはH2を使用していますが、その辺はまぁご自身で上手いことなんとかしてください。

schema.sql
CREATE TABLE IF NOT EXISTS application_clock (
    time_zone VARCHAR(20) NOT NULL,
    base_time DATETIME NOT NULL,
    PRIMARY KEY (time_zone, clock)
);

Spring-bootを使っているのでアプリケーションの起動時についでにデータも入れておきましょう。

data.sql
INSERT INTO application_clock (time_zone, base_time) VALUES ('Asia/Tokyo', '2023-11-23 18:00:00.000');

適当にエンティティクラス・リポジトリクラスを作る

上で作った application_clock テーブルに相当するエンティティクラスと、それを操作するリポジトリクラスを作っておきましょう。
これも今回はデモのためspring-boot-starter-data-jpaを使っていて、エンティティクラスとリポジトリクラスを手で作っていますが、その辺はまぁご自身で上手いことなんとかしてください。(2回目
ちなみに個人的にエンティティクラスを手で作るの死ぬほど嫌いなのと、JPAのメソッド名の命名規則で勝手にSQL組まれるのも嫌いなので普段は使ってません。

ApplicationClockPK.java
@Data
@FieldDefaults(level = AccessLevel.PRIVATE)
public class ApplicationClockPK implements Serializable {

  String timeZone;

  LocalDateTime baseTime;
}
ApplicationClock.java
@Entity
@Table(name = "application_clock")
@IdClass(ApplicationClock.class)
@Data
@FieldDefaults(level = AccessLevel.PRIVATE)
public class ApplicationClock implements Serializable {

  @Id
  @Column(name = "time_zone")
  String timeZone;

  @Id
  @Column(name = "base_time")
  LocalDateTime baseTime;
}
ApplicationClockRepository.java
@Repository
public interface ApplicationClockRepository extends JpaRepository<ApplicationClock, ApplicationClockPK> {

  List<ApplicationClock> findAll();
}

システム日付を固定してやろうじゃないか

みなさんお待たせしました。お待たせしすぎたかもしれません。
ここが肝になる実装です。

デフォルトのシステム日付と固定日付とモード選択できるようにしておく

常にシステム日付が固定されるとそれはそれで鬱陶しいのでモードを用意しておきましょう。

  • SYSTEM : 日付を固定しないデフォルトのシステム日付モード
  • FIXED : 日付を固定するモード
ClockMode.java
public enum ClockMode {
  SYSTEM,
  FIXED
}

application.ymlにモードの設定を外部化しておきます。
ちなみにここのモードを環境変数からインジェクションするような形にしておくと、Dockerイメージを使った運用をする際にアプリケーションに手を加えずとも、環境変数を変えてリブートなりリランするだけで良くなるのでハッピーになります。

application.yml
application:
  clock:
    mode: FIXED # or SYSTEM

システム日付のデータをロードする仕掛けを作る

Spring Bootにある @Scheduled というアノテーションを使えば、定期実行の処理を簡単に実装することが出来るのでこいつを利用します。
なおこの @Scheduled アノテーションを使う場合、Spring Bootの起動エンドポイントになるメインクラスに @EnableScheduling アノテーションをはることをお忘れなく。
@EnableScheduling がないと実行してくれないので意味がありません。

DateTimeHelper.java
@Component
@RequiredArgsConstructor
@Slf4j
public class DateTimeHelper {

  private final ApplicationClockRepository applicationClockRepository;

  @Value("${application.clock.mode:SYSTEM}")
  private ClockMode clockMode;

  private static Clock clock = Clock.systemDefaultZone();

  @Scheduled(fixedDelay = 30, timeUnit = TimeUnit.SECONDS)
  private void reloadClock() {
    if (ClockMode.SYSTEM == clockMode) {
      return;
    }

    applicationClockRepository.findAll().stream()
        .findFirst()
        .ifPresentOrElse(
            v -> {
              try {
                var zone = ZoneId.of(v.getTimeZone());
                var instant = v.getBaseTime().atZone(zone).toInstant();
                var changed = Clock.fixed(instant, zone);

                if (!Objects.equals(clock, changed)) {
                  log.info(
                      "load application_clock: time_zone={}, base_time={}",
                      v.getTimeZone(),
                      v.getBaseTime());
                  clock = Clock.fixed(instant, zone);
                }
              } catch (Exception e) {
                log.error("failed to setting application clock. see application_clock rows.", e);
                clock = Clock.systemDefaultZone();
              }
            },
            () -> clock = Clock.systemDefaultZone());
  }
}

これによって DateTimeHelper が内部的に保持しているstaticな Clock オブジェクトを設定したモードに応じてデフォルトのシステム日付、もしくは application_clock テーブルのデータに基づく固定日付に30秒おきに更新します。

現在日付・現在日時を返すラッパーメソッドを用意しておく

あとは DateTimeHelper が持っている Clock オブジェクトを使って、現在日付・現在日時を返すラッパーメソッドを用意しておけば完成です。

DateTimeHelper.java
@Component
@RequiredArgsConstructor
@Slf4j
public class DateTimeHelper {

  private static Clock clock = Clock.systemDefaultZone();

  public static LocalDate today() {
    return LocalDate.now(clock);
  }

  public static LocalDateTime now() {
    return LocalDateTime.now(clock);
  }

アプリケーション上で LocalDate#nowLocalDateTime#now を直接使うのではなく、この DateTimeHelper#todayDateTimeHelper#now を使うようにしてあげれば、擬似的にシステム日付を固定することが出来るというわけです。

こうすると何が嬉しいのか

日付の境界値テストがやりやすくなる

業務システムなんかだとよくある月次バッチや年次バッチ。
こいつらがシンプルに毎月XX日とか毎年XX月YY日みたいなシンプルな要件だったら大した問題ではありません。cronなり書けばいいだけですし。

でも大抵は毎月第XX営業日だとか、毎月XX日だけどとあるデータ条件によっては動かさないだとか、結局バッチ自体は毎日起動するけど、特定の条件に基づいてスキップさせるみたいなのが多いわけですよ。これがまた。
営業日なんかは各会社で違うからどうせ営業日カレンダーテーブルみたいなの持ってるし。

それがこんな実装になっていたら、がっつりサーバーの日付をいじらないとテストできないわけです。
インフラ屋さんにテストの度になんとかしてもらうしかありません。

var today = LocalDate.now();

if (isBusinessDate(today)) {
  // 何かやる
}

でもアプリケーションレイヤでシステム日付を固定できていればどうでしょう?
インフラ屋さんになんとかしてもらう必要もありませんし、テーブルのデータ次第でいくらでもテストができるのでなんならユニットテストでコードの妥当性が担保できます。

// テーブルのデータ次第でいくらでも境界値テストができる
var today = DateTimeHelper.today();

if (isBusinessDate(today)) {
  // 何かやる
}

プロセスの実行中にシステム日付を変えられる

@Scheduled アノテーションによる定期実行のため完全なリアルタイムとは言えませんが、まぁそれなりにリアルタイムに近い時間でシステム日付を変えられるということです。
つまりプロセスの実行中でさえシステム日付を容易に変えられることになります。

DateTimeHelper の実装を見てもらえれば分かることですが、 application_clock テーブルのデータがあればそのデータでシステム日付を固定するし、なければデフォルトのシステム日付になります。

つまりテスト環境なんかでは ClockMode.FIXED の状態でとりあえずプロセスを起動しておいて、システム日付を固定したければ application_clock テーブルにデータを入れておけばいいし、その必要がなくなれば(デフォルトのシステム日付で動かしたければ)、データを削除しておけばいいだけになります。

こうすることでシステム日付を固定したテストケースの際にプロセス落として、サーバーの設定変更して、またプロセス上げ直してテストして、終わったらまた戻して・・・みたいな一連のスーパー面倒くさい作業をすっ飛ばすことができます。

まとめ

  • システム日付の固定はアプリケーションレイヤで仕組みを作っておくとテストがラクになってハッピー
  • Spring Bootは定期実行プロセスの実装が簡単に出来て便利
  • 本番は当然だけど ClockMode.SYSTEM で運用してね

Discussion