AI事業本部のDynalystという部署にてエンジニアをやっています、中村と申します。

Dynalystでは主にScalaを使って開発を行っているのですが、ScalaがJVMで動く関係上、Javaのライブラリはよく使用します。
今回は、JavaのJoda-Timeの扱いが初心者過ぎてハマった点があったため、それについて気づいたこと、また調査 & 検証した内容についてまとめていきたいと思います。

StringからJoda-TimeのDateTimeへのパース

事の発端はString型のタイムスタンプの文字列をUTCの時間としてパースしたかったところから始まりました。

ホストの環境に依存してしまうパース例

実際に私がやってしまって、ローカルと本番環境で動作が異なってしまった例です。

val df = DateTimeFormat.forPattern("yyyy-MM-dd")
df.parseDateTime("2020-02-04").withZone(DateTimeZone.UTC)

何が良くないかというと、
DateTime型は日時だけでなく timezoneも含んだ型であるという点です。
つまり、parseDateTimeは ホストの環境のtimezoneでパースしてしまいます。
結果、意図しない動作をする可能性があります。

環境のtimezoneがAsia/Tokyoの場合

// パースするStringのフォーマットを決める
scala> val df = DateTimeFormat.forPattern("yyyy-MM-dd")
df: org.joda.time.format.DateTimeFormatter = org.joda.time.format.DateTimeFormatter@25732afd

// DateTime型で2020年2月4日をパースする
scala> val parsedDateTime = df.parseDateTime("2020-02-04")
parsedDateTime: org.joda.time.DateTime = 2020-02-04T00:00:00.000+09:00  // +9時間

// パースした値のtimezoneを取得する
scala> parsedDateTime.getZone
res2: org.joda.time.DateTimeZone = Asia/Tokyo

パースした結果が +9時間と書いてあるとおり、日本時間(JST)の2020年2月4日の0時でパースしました。

環境のtimezoneがUTCの場合

// パースするStringのフォーマットを決める
scala> val df = DateTimeFormat.forPattern("yyyy-MM-dd")
df: org.joda.time.format.DateTimeFormatter = org.joda.time.format.DateTimeFormatter@25732afd

// DateTime型で2020年2月4日をパースする
scala> val parsedDateTime = df.parseDateTime("2020-02-04")
parsedDateTime: org.joda.time.DateTime = 2020-02-04T00:00:00.000Z  // +0時間

// パースした値のtimezoneを取得する
scala> parsedDateTime.getZone
res2: org.joda.time.DateTimeZone = UTC

パースした結果が +0時間と書いてあるとおり、UTCの2020年2月4日の0時でパースしました。

結局どういう違いが出てくるのか

上述のパース例ですが、

val df = DateTimeFormat.forPattern("yyyy-MM-dd")
df.parseDateTime("2020-02-04").withZone(DateTimeZone.UTC)

DateTimeのwithZoneは、現在のtimezone情報のあるDateTimeから指定されたtimezoneのDateTimeに変換してくれます。

つまり、

ホストの環境がAsia/Tokyoの場合

// JSTでDateTimeにパースする
scala> val parsedDateTime = df.parseDateTime("2020-02-04")
parsedDateTime: org.joda.time.DateTime = 2020-02-04T00:00:00.000+09:00

// そのJSTのtimezoneをUTCに変換する
scala> parsedDateTime.withZone(DateTimeZone.UTC)
res10: org.joda.time.DateTime = 2020-02-03T15:00:00.000Z

2020年2月4日0時 JST をwithZoneでUTCにするので、9時間前の 2020年2月3日の15時 UTC となります。

ホストの環境がUTCの場合

// UTCでDateTimeにパースする
scala> val parsedDateTime = df.parseDateTime("2020-02-04")
parsedDateTime: org.joda.time.DateTime = 2020-02-04T00:00:00.000Z

// そのUTCのtimezoneをUTCに変換する = つまり何も変化しない
scala> parsedDateTime.withZone(DateTimeZone.UTC)
res11: org.joda.time.DateTime = 2020-02-04T00:00:00.000Z

2020年2月4日0時 UTCをwithZoneでUTCにするので、そのまま 2020年の2月4日0時 UTC となります。

ホストの環境に依存しないパース

方法1. LocalDateTimeを使う

これを使うと、 timezoneという概念を消してくれます。
逆に言えば、パースした値はtimezoneがない状態なので、 DateTime型にする際にtimezoneを指定してあげる 必要があります。

// toLocalDateTimeでLocalDateTime型にパースする
scala> df.parseDateTime("2020-02-04").toLocalDateTime
res11: org.joda.time.LocalDateTime = 2020-02-04T00:00:00.000

LocalDateTimeをUTCのDateTimeにする

// toDateTimeでtimezone UTCを指定する
scala> df.parseDateTime("2020-02-04").toLocalDateTime.toDateTime(DateTimeZone.UTC)
res13: org.joda.time.DateTime = 2020-02-04T00:00:00.000Z

LocalDateTimeをJSTのDateTimeにする

// toDateTimeでtimezone Asia/Tokyoを指定する (JSTというオブジェクトは用意されてないのでforIDでAsia/Tokyoを指定)
scala> df.parseDateTime("2020-02-04").toLocalDateTime.toDateTime(DateTimeZone.forID("Asia/Tokyo"))
res14: org.joda.time.DateTime = 2020-02-04T00:00:00.000+09:00

方法2. DateTimeFormatterでどのtimezoneでパースするか指定する

パースする前に どのtimezoneでパースするか指定してから使う方法です。

// DateTimeFormatterのwithZoneを使ってどのtimezoneでパースするか指定する
scala> df.withZone(DateTimeZone.UTC) // UTCの場合は、withZoneUTCでも指定可能
res8: org.joda.time.format.DateTimeFormatter = org.joda.time.format.DateTimeFormatter@2fefaa62

UTCのDateTimeFormatでパースする

scala> df.withZone(DateTimeZone.UTC).parseDateTime("2020-02-04")
res6: org.joda.time.DateTime = 2020-02-04T00:00:00.000Z

JSTのDateTimeFormatでパースする

scala> df.withZone(DateTimeZone.forID("Asia/Tokyo")).parseDateTime("2020-02-04")
res7: org.joda.time.DateTime = 2020-02-04T00:00:00.000+09:00

方法1よりも方法2のほうが、一度LocalDateTimeを経由しなくていいのと、どのtimezoneでパースしているか読んでいてわかりやすいので、いいかもしれませんね!

当たり前ですが、基本的にはローカルの環境に依存しないような実装をするべきだと思うので、パースする際には方法1 or 2を使用すべきでしょう。

目的の日時のDateTime型にパースさえできてしまえば、もうあとはやりたい放題。

デフォルトのtimezoneってどこから来るの?

実のところ、上述の問題に直面するまで手元のMacの環境がJSTになっていたことを知らずにいました。
ので、そもそもtimezoneって何をもってデフォルトの値を決めているのか、せっかくなのでライブラリの実装を追ってみました。

DateTimeZone

/**
     * Gets the default time zone.
     * The default time zone is derived from the system property {@code user.timezone}.
     * If that is {@code null} or is not a valid identifier, then the value of the
     * JDK {@code TimeZone} default is converted. If that fails, {@code UTC} is used.
     * NOTE: If the {@code java.util.TimeZone} default is updated after calling this
     * method, then the change will not be picked up here.
     *
     * @return the default datetime zone object
     */
    public static DateTimeZone getDefault() {
        DateTimeZone zone = cDefault.get();
        if (zone == null) {
            try {
                try {
                    String id = System.getProperty("user.timezone");
                    if (id != null) {  // null check avoids stack overflow
                        zone = forID(id);
                    }
                } catch (RuntimeException ex) {
                    // ignored
                }
                if (zone == null) {
                    zone = forTimeZone(TimeZone.getDefault());
                }
            } catch (IllegalArgumentException ex) {
                // ignored
            }
            if (zone == null) {
                zone = UTC;
            }
            if (!cDefault.compareAndSet(null, zone)) {
                zone = cDefault.get();
            }
        }
        return zone;
    }

DateTimeZone.java

コードのコメントにもありますが、簡単に上記のコードを説明すると

1. system propertyのuser.timezoneを参照する
2. 設定がなかったらTimeZoneのデフォルトのtimezoneとする
3. その設定すらなかったらUTCとする

ふむふむ。
では、TimeZoneのデフォルトのtimezoneはどうやって設定されているのでしょうか。

TimeZone

private static synchronized TimeZone setDefaultZone() {
        TimeZone tz;
        // get the time zone ID from the system properties
        String zoneID = AccessController.doPrivileged(
                new GetPropertyAction("user.timezone"));
        // if the time zone ID is not set (yet), perform the
        // platform to Java time zone ID mapping.
        if (zoneID == null || zoneID.isEmpty()) {
            String javaHome = AccessController.doPrivileged(
                    new GetPropertyAction("java.home"));
            try {
                zoneID = getSystemTimeZoneID(javaHome);
                if (zoneID == null) {
                    zoneID = GMT_ID;
                }
            } catch (NullPointerException e) {
                zoneID = GMT_ID;
            }
        }
        // Get the time zone for zoneID. But not fall back to
        // "GMT" here.
        tz = getTimeZone(zoneID, false);
        if (tz == null) {
            // If the given zone ID is unknown in Java, try to
            // get the GMT-offset-based time zone ID,
            // a.k.a. custom time zone ID (e.g., "GMT-08:00").
            String gmtOffsetID = getSystemGMTOffsetID();
            if (gmtOffsetID != null) {
                zoneID = gmtOffsetID;
            }
            tz = getTimeZone(zoneID, true);
        }
        assert tz != null;
        final String id = zoneID;
        AccessController.doPrivileged(new PrivilegedAction() {
            @Override
                public Void run() {
                    System.setProperty("user.timezone", id);
                    return null;
                }
            });
        defaultTimeZone = tz;
        return tz;
    }

TimeZone.java

1. system propertyのuser.timezoneを参照する
2. 設定がない、または空文字だった場合にjava.homeの設定からzoneIDを getSystemTimeZoneID(javaHome) で取得する
3. 取得できなければGMTとする (GMTは厳密にはUTCとは若干違うが、ほぼUTCと同値)
4. 最終的に決まった値でuser.timezoneを更新する

ふむふむふむ。
では、 getSystemTimeZoneID(javaHome)は結局何を見てzoneIDを判断しているんでしょうか。

getSystemTimeZoneID

Java default timezone detection, revisited
この記事がとてもわかりやすかったです。

getSystemTimeZoneIDの実装がなかなか見つからないなーと思ったら、そもそもCで書かれているみたいです。
確かにホスト自体の設定を見に行くと仮定するなら、C言語でシステムコールしてそうですね?

記事の一部抜粋。

The algorithm lies in Java native method Timezone.getSystemTimeZoneID(javaHome), which is implemented in java.base/share/native/libjava/TimeZone.c.

The short description is:

Try reading the /etc/timezone

Next, try /etc/localtime to find the zone ID.

2.1 If it’s a symlink, get the link name and its zone ID part.

2.2 If it’s a regular file, find out the same zoneinfo file that has been copied as /etc/localtime.

If any above step catches, the correct timezone name is returned; otherwise, you get NULL.

コードも確認すると優先順位としては、まず環境変数のTZを見に行っているみたいです。

char *
findJavaTZ_md(const char *java_home_dir)
{
    char *tz;
    char *javatz = NULL;
    char *freetz = NULL;

    tz = getenv("TZ");

    if (tz == NULL || *tz == '\0') {
        tz = getPlatformTimeZoneID();
        freetz = tz;
    }

    // snipped
} 

つまり

1. ホストの環境変数TZを見に行く、設定があればそれを使う
2. /etc/timezoneを見に行く、設定あればそれを使う
3. なければ/etc/localtimeを見に行く、設定あれば(シンボリックリンクでも可)それを使う
4. それもなければnullを返す

timezoneの設定を試しに変更してみる

/etc/localtimeのシンボリックリンクを張り替えてみる

Mac OSの場合デフォルトでは/etc/timezoneはないので、/etc/localtimeを見ました。
バイナリなので、stringsで覗いてみます。

$ strings /etc/localtime
TZif
JCST

JCST、Japan Central Standard Timeでしょうか?

確かにscalaのREPLでsystem propertyのuser.timezoneを見てみたらAsia/Tokyoでした。

scala> System.getProperty("user.timezone")
res0: String = Asia/Tokyo

試しにシンボリックリンクを張り替えてtimezoneをUTCに変更してみます。

ちなみに、各timezoneの設定は/usr/share/zoneinfo/にあります。

$ ls /usr/share/zoneinfo/
+VERSION    Asia        CST6CDT     EST         Europe      GMT+0       Hongkong    Jamaica     MST         Navajo      Portugal    UCT         WET
Africa      Atlantic    Canada      EST5EDT     Factory     GMT-0       Iceland     Japan       MST7MDT     PRC         ROC         US          Zulu
America     Australia   Chile       Egypt       GB          GMT0        Indian      Kwajalein   Mexico      PST8PDT     ROK         UTC         iso3166.tab
Antarctica  Brazil      Cuba        Eire        GB-Eire     Greenwich   Iran        Libya       NZ          Pacific     Singapore   Universal   posixrules
Arctic      CET         EET         Etc         GMT         HST         Israel      MET         NZ-CHAT     Poland      Turkey      W-SU        zone.tab

ので、これを使って/etc/localtimeのシンボリックリンクを張り変えてあげてみます。

#  /etc/localtimeの参照先を/usr/share/zoneinfo/Etc/UTCにする
$ sudo ln -sf /usr/share/zoneinfo/Etc/UTC /etc/localtime 
# パスワード入力
Password: 
# シンボリックリンクの確認
$ ls -l /etc/localtime
lrwxr-xr-x  1 root  wheel  27  2  4 14:56 /etc/localtime -> /usr/share/zoneinfo/Etc/UTC

もう一度/etc/localtimeを確認。

$ strings /etc/localtime
TZif

何もなくなりました。
なにもない = デフォルト = UTCってことでいいのかな。

scala> System.getProperty("user.timezone")
res0: String = Etc/UTC

うまくUTCにできたみたいです。

環境変数のTZを定義してみる

上記の/etc/localtimeの設定をUTCにしたままで、環境変数TZの値をJSTに変更してみます。
(私はzshを使っているので、.zshrcに書き込みますが、bashの方は.bash_profileなどで設定して下さい。)

# 設定ファイル(.zshrc)の末尾に「export TZ="Asia/Tokyo"」を追加
$ echo export TZ="Asia/Tokyo" >> ~/.zshrc
# 上記の設定を反映させる
$ source ~/.zshrc
# 環境変数TZの値の確認
$ echo $TZ
Asia/Tokyo

TZにAsia/Tokyoがちゃんと反映されました!

ではデフォルトのsystem propertyを確認してみます。

scala> System.getProperty("user.timezone")
res0: String = Asia/Tokyo

確かに /etc/localtimeよりもTZの設定が優先されて反映されていますね!

小話

  • 本番環境ではjavaコマンドを叩く際に-Duser.timezone=UTCを指定していたので、JVMのsystem propertiesのtimezoneは常にUTCになっていました。
  • etc/localtimeの設定をUTCにすると、Macのシステム環境設定の時間がUTCに変わるので、注意してください。何故か夜のはずなのに右上の時間表示がお昼になっていて、一瞬ビックリしました。逆に言えばシステム環境設定で再び日本の東京を指定すると、/etc/localtimeがJSCTに直ります。結局時刻の設定はここを裏で書き換えているんですね。
  • まとめ

    文字列をDateTimeにパースするときは環境に依存しないように下記どちらかの方法を使用しましょう。

    1. LocalDateTimeを経由してtimezoneの概念を消してからtoDateTimeでtimezoneを指定する

    scala> System.getProperty("user.timezone")
    // toLocalDateTimeしてからtoDateTimeでtimezone UTCを指定
    scala> df.parseDateTime("2020-02-04").toLocalDateTime.toDateTime(DateTimeZone.UTC)
    res13: org.joda.time.DateTime = 2020-02-04T00:00:00.000Z

    2.DateTimeFormatterでどのtimezoneでパースするか指定する

    // DateTimeFormatterのwithZoneでパース前にtimezoneを指定
    scala> df.withZone(DateTimeZone.UTC).parseDateTime("2020-02-04")
    res6: org.joda.time.DateTime = 2020-02-04T00:00:00.000Z

    Java Joda-TimeのDateTimeZoneのデフォルトのtimezone設定は下図のようなフローになります。