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;
}
コードのコメントにもありますが、簡単に上記のコードを説明すると
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;
}
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の設定が優先されて反映されていますね!
小話
まとめ
文字列を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