Java/Groovyでうるう秒を扱う方法

Java言語でうるう秒を扱う方法を調べたのでまとめておきます。Twitterで質問したらたくさんのリプライ、エアリプがありそれらをもとに調べることで達成できました。ありがとうございました。

概要

  1. Java標準APIOracle実装ではうるう秒を扱えない。Calendar, Dateなどの旧型のAPI、ZonedDateTimeなどのDate and Time APIどちらも。
  2. ThreeTen-Extra というライブラリを使うとうるう秒を扱える。

うるう秒を扱えるということに対して期待していること

4年に1度だけ2/29があるうるう年のように、2年から5年に1度くらいの間隔で世界中の時計が1秒だけ増えることがあります。これをうるう秒と言っています。最近だと日本時間で2017/1/1 08:59:59 のつぎが 2017/1/1 08:59:60 となりました。普段はない60秒があり、1分間が61秒になりました。 これはいつ挿入されるかは規則はいまのところありません。(どれくらい前に告知されるのかは決まっていないのかな?)

で、システムにおいてうるう秒においても 08:59:60 のように時刻を正確に記録できるのかが気になりました。

というのも例えば、UNIXTIMEはうるう秒を丸めるという仕様になっています。

具体的には次のような形になります。

  • 東京の時刻
    • UNIXTIME
  • 2012-07-01T08:59:59+09:00[Asia/Tokyo]
    • 1341100799
  • 2012-07-01T08:59:60+09:00[Asia/Tokyo] <= うるう秒挿入
    • 1341100799 <= 1秒前と同じ
  • 2012-07-01T09:00:00+09:00[Asia/Tokyo]
    • 1341100800

ということで、UNIXTIMEを使うとうるう秒時点の扱いを気をつけなければいけません。(保存にしろ、描画にしろ)

ので、流れとして

  1. 「2012-07-01T08:59:60+09:00[Asia/Tokyo]」という時刻を表現できるようにするにはどうしたらいいのだろうか?という視点で調べました。
  2. このシステムはうるう秒が来ても正確に動作するシステムであるといえるようにしたい。
  3. となると、うるう秒を発生させるテストを書かなければいけない。
  4. (例えば)Javaうるう秒を任意に起こすことはできるのか?

という形です。

そこで、「2012-07-01T08:59:59+09:00[Asia/Tokyo]」のオブジェクトを生成し、1秒すすめたときに「2012-07-01T08:59:60+09:00[Asia/Tokyo]」が生成できるのか?ということを調べました。

Java標準のAPIだとどうなるのか

Java標準の時間を扱うクラスというとJava7まではjava.util.Calendar、java.util.Dateを使い、Java8からはDate and Time APIである java.time.ZonedDateTime などを使います。

JavaDocにはOS依存だよーとか、Java実装依存だよーとか書かれていたので、次のような環境で試しました。

ですがいずれの場合でも、「2012-07-01T08:59:59+09:00[Asia/Tokyo]」のオブジェクトを生成し、1秒すすめたときには 60秒にならず09:00:00になってしまいました。

Calendarの場合

def c = Calendar.getInstance(TimeZone.getTimeZone("Asia/Tokyo"))
// 59秒を生成
c.set(1900 + 112, 6, 1, 8, 59, 59)
// うるう秒にすすめる
c.add(Calendar.SECOND, 1)

// fail
assert c.getTime().toString() == "Sun Jul 01 08:59:60 JST 2012"

// うるう秒で生成
def d = new Date(112, 6, 1, 8, 59, 60)
// fail
assert d.toString() == "Sun Jul 01 08:59:60 JST 2012"

ZonedDateTimeの場合

// 59秒を生成
def zdt59 = ZonedDateTime.parse("2012-07-01T08:59:59+09:00[Asia/Tokyo]")

// うるう秒にすすめる
// fail
assert zdt59.plusSeconds(1).toString() == "2012-07-01T08:59:60+09:00[Asia/Tokyo]"

ちなみにZonedDateTimeのparseメソッドはそもそも60秒をパースできませんし、Instantのparseメソッドは60秒をパースしても0秒に丸めてしまいます。

ThreeTen-Extraを使うとどうなるのか

ThreeTen-Extraというライブラリを使うとこれが実は出来ます!(なんだってー!なんのためのJSR310だっt

  1. Stephen Colebourneが「Joda-Time」をつくる
  2. JSR-310としてJoda-Timeのように便利にしようとDate and Time APIが取り込まれる
  3. Stephen ColebourneがJSR-310の拡張ライブラリ「ThreeTen-Extra」をつくる

もう、Stephen Colebourneに足向けて寝られないですね。

UtcInstantというクラスを使うとうるう秒を保持した計算ができるようになっています。なのでタイムゾーンなしの文字である「2012-06-30T23:59:59.000Z」を渡して1秒足してみて60秒になれば成功です。

import java.time.Duration

import org.threeten.extra.scale.UtcInstant

def ui = UtcInstant.parse("2012-06-30T23:59:59.000Z")
def ui2 = ui.plus(Duration.ofSeconds(1)

// success
assert ui2.toString() == "2012-06-30T23:59:60Z"

ということでうるう秒を鑑みて時刻を考えるなら、UtcInstantとして常に計算し、日付、タイムゾーンは別で管理しておくという戦略をとればよさそうです。

ちなみにUtcInstantでは日付はMJD(修正ユリウス日)が使われています。のでだいたい5桁くらいで収まる範囲です。

ただしこれがバグっぽいAPIがある

UtcInstantにはisLeapSecond()というメソッドがあるのですが、これがうるう秒ちょうどになった瞬間はまだうるう秒じゃないという判定をしてしまっているように見えます。

  • 08:59:59.999
  • 08:59:60.000 <= うるう秒 <= ここでisLeapSecondがfalseになってしまう。
  • 08:59:60.001 <= うるう秒 <= ここはtrueを返してくれる
  • ...
  • 09:00:00.000

まだThreeTen-Extraの仕様を読み切ってないのですが、仕様が僕のおもっているとおりならこれはtrueを返すべきところなので、まずかったらIssueをあげようかなーと。

そのほかの話

ここまでで、Javaうるう秒を扱うという方法がわかったわけですが、システム全体で考えるといろいろと面倒です。 まず、Windowsうるう秒をサポートしていません。

次に、Linuxではうるう秒については60秒になった瞬間に59秒をやり直します。(60秒から0秒のあいだまでもう一度59秒になります)

そして、NTPサーバがどのように返答するか次第でOSの挙動は変わります。

ちなみに、GoogleなんかではLeap Smearingといって、うるう秒の1秒を(うるう秒前後の)20時間かけてちょっとずつ時計の進みを遅くすることで60秒という1秒間を分散させています。こうすることで、NTPサーバに問い合わせても60秒という時間が指定されません。つまり、ある20時間だけほんの少しずつだけ時計が遅く進んでいるのです。

クラウドを使っている場合にはインフラのNTPまでを統一するのは難しいので、要件に応じてどこを何に統一するのか考える必要があります。

最後に

情報収集につきあってくださった id:megascus さん、エアリプしてくれた khasunuma さんありがとうございました。めっちゃ勉強になりました。 そしてコードを簡単に共有できるWandbox最高でした。Groovyの最新版うごかしてくれてありがとう。(もう少し言えば、@Grabが動くようになっているともっともっと嬉しいんだなー。)

参考

Java本格入門 ~モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで

Java本格入門 ~モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで

Effective Java (3rd Edition)

Effective Java (3rd Edition)