Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

[インデックス 18390] ファイルの概要

このコミットは、Go言語の標準ライブラリ time パッケージにおけるタイムゾーン処理の改善に関するものです。具体的には、特定のタイムゾーンにおいて、記録されている最初のタイムゾーン移行時刻よりも前の時刻を扱う際の挙動を修正し、IANAタイムゾーンデータベースの参照実装(tzcodelocaltime.c)に準拠させることを目的としています。

コミット

commit 52a73239bbf848ee65053a37e6382639bb3bf238
Author: Ian Lance Taylor <iant@golang.org>
Date:   Fri Jan 31 14:40:13 2014 -0800

    time: correctly handle timezone before first transition time
    
    LGTM=r
    R=golang-codereviews, r, arnehormann, bradfitz
    CC=golang-codereviews
    https://golang.org/cl/58450043

GitHub上でのコミットページへのリンク

https://github.com/golang/go/commit/52a73239bbf848ee65053a37e6382639bb3bf238

元コミット内容

    time: correctly handle timezone before first transition time
    
    LGTM=r
    R=golang-codereviews, r, arnehormann, bradfitz
    CC=golang-codereviews
    https://golang.org/cl/58450043

変更の背景

Go言語のtimeパッケージは、システムまたは埋め込みのzoneinfoデータを使用してタイムゾーン情報を解決します。タイムゾーンのルールは歴史的に複雑で、夏時間(DST)の導入・廃止、標準時の変更、さらには日付変更線の移動など、様々な移行(transition)が発生します。これらの移行情報は、IANAタイムゾーンデータベースに記録されています。

しかし、IANAデータベースに記録されている移行情報には開始時期があります。例えば、あるタイムゾーンのデータが1970年からしか存在しない場合、それ以前の時刻(例:1900年)に対してtime.In(location)のような操作を行った際に、どのタイムゾーンルールを適用すべきかという問題が生じます。

このコミット以前のGoのtimeパッケージは、この「最初の移行時刻よりも前の時刻」に対するタイムゾーンの決定ロジックが不完全であったか、またはIANAの参照実装と異なる挙動をしていました。その結果、非常に古い日付を扱う際に、誤ったタイムゾーン情報(オフセットや略称)が返される可能性がありました。このコミットは、この挙動をIANAのtzcode(特にlocaltime.c)のアルゴリズムに厳密に準拠させることで、正確性を向上させることを目的としています。

前提知識の解説

タイムゾーンデータベース (IANA tzdata)

世界中のタイムゾーンルールは、IANA (Internet Assigned Numbers Authority) が管理するタイムゾーンデータベース(通称 tzdata または zoneinfo)によって定義されています。このデータベースには、各地域の標準時オフセット、夏時間ルール、およびそれらの歴史的な変更履歴が詳細に記録されています。Goを含む多くのオペレーティングシステムやプログラミング言語は、このデータベースの情報を利用してタイムゾーン処理を行っています。

タイムゾーンの移行 (Time Zone Transitions)

タイムゾーンのルールは固定ではありません。歴史的に、多くの地域で標準時オフセットの変更、夏時間の導入・廃止、夏時間の開始・終了日の変更などが行われてきました。これらの変更を「タイムゾーンの移行(transition)」と呼びます。tzdataは、これらの移行が発生した正確なUTC時刻と、その移行によって適用される新しいタイムゾーンルール(オフセット、夏時間フラグ、略称など)を記録しています。

zoneinfo.zip

Go言語のtimeパッケージは、システムにzoneinfoデータが存在しない場合や、クロスプラットフォームでの一貫性を保つために、コンパイル時にzoneinfo.zipというファイルに埋め込まれたタイムゾーンデータを使用することがあります。これは、IANA tzdataのサブセットをGoのバイナリに含めることで、実行環境に依存せずにタイムゾーン処理を可能にする仕組みです。テストにおいては、time.ForceZipFileForTesting(true)のような関数を使って、この埋め込みデータを使用するように強制することで、テストの再現性を高めることができます。

localtime.cのアルゴリズム

IANA tzcodeパッケージに含まれるlocaltime.cは、タイムゾーンデータベースのデータを解釈し、特定の時刻に対するタイムゾーン情報を決定するための参照実装です。特に、記録されている最初の移行時刻よりも前の時刻を扱う際のアルゴリズムは、以下の4つのケースを考慮して定義されています。

  1. 最初のゾーンがどの移行にも使用されていない場合: その最初のゾーンを使用する。
  2. 移行が存在し、最初の移行が夏時間ゾーンへのものである場合: 最初の移行ゾーンよりも前で、最も近い非夏時間ゾーンを探して使用する。
  3. 上記以外で、非夏時間ゾーンが存在する場合: 最初の非夏時間ゾーンを使用する。
  4. 上記すべてに該当しない場合: 最初のゾーンを使用する。

このアルゴリズムは、歴史的なタイムゾーンデータが完全ではない場合でも、最も妥当なタイムゾーン情報を決定するためのヒューリスティックを提供します。

技術的詳細

このコミットの主要な変更点は、timeパッケージのLocation型が持つlookupメソッドに、最初のタイムゾーン移行時刻よりも前の時刻を処理するロジックを追加したことです。

Location構造体は、タイムゾーンに関する情報を保持しており、特に以下のフィールドが重要です。

  • zone: zone構造体のスライスで、各タイムゾーンの定義(名前、オフセット、夏時間フラグなど)を保持します。
  • tx: zoneTrans構造体のスライスで、タイムゾーンの移行情報(移行時刻、適用されるzoneのインデックスなど)を保持します。tx[0].whenは最初の移行時刻を示します。

変更前は、lookupメソッドがl.tx(移行情報)が空の場合のみ特別な処理を行っていましたが、l.txが存在しても、要求された時刻secl.tx[0].when(最初の移行時刻)よりも古い場合には、適切なタイムゾーン情報が決定できませんでした。

このコミットでは、lookupメソッドの冒頭に以下の条件を追加しました。

if len(l.tx) == 0 || sec < l.tx[0].when {
    zone := &l.zone[l.lookupFirstZone()]
    name = zone.name
    offset = zone.offset
    isDST = zone.isDST
    start = -1 << 63
    if len(l.tx) > 0 {
        end = l.tx[0].when
    } else {
        end = 1<<63 - 1
    }
    return
}

このコードブロックは、以下の2つのシナリオを処理します。

  1. len(l.tx) == 0: タイムゾーンの移行情報が全く存在しない場合。この場合、そのタイムゾーンは常に単一のルールで運用されていると見なされます。
  2. sec < l.tx[0].when: 要求された時刻が、記録されている最初のタイムゾーン移行時刻よりも古い場合。

これらのシナリオでは、新しく導入されたl.lookupFirstZone()メソッドが呼び出され、IANA tzcodeのアルゴリズムに基づいて、どのzone情報を使用すべきかのインデックスが返されます。取得したzone情報(name, offset, isDST)が結果として使用されます。start時刻はint64の最小値に設定され、end時刻は最初の移行時刻(またはint64の最大値)に設定されることで、この「最初のゾーン」が過去のすべての時刻をカバーするようにします。

lookupFirstZone() メソッド

このメソッドは、前述のlocaltime.cの4つのケースのアルゴリズムをGoで実装しています。

  • Case 1: firstZoneUsed()ヘルパー関数を呼び出し、l.zone[0](最初のゾーン定義)が既存のどの移行にも使用されていないかをチェックします。使用されていなければ、インデックス0を返します。
  • Case 2: 移行が存在し、かつ最初の移行が夏時間ゾーン(l.zone[l.tx[0].index].isDSTtrue)へのものである場合、最初の移行ゾーンのインデックスから逆順にzoneスライスを探索し、最初の非夏時間ゾーンのインデックスを返します。
  • Case 3: 上記のケースに該当しない場合、zoneスライス全体を探索し、最初の非夏時間ゾーンのインデックスを返します。
  • Case 4: 上記すべてのケースに該当しない場合(例えば、すべてのゾーンが夏時間である場合など)、デフォルトとしてインデックス0(最初のゾーン)を返します。

firstZoneUsed() メソッド

このシンプルなヘルパーメソッドは、l.txスライスをイテレートし、いずれかの移行がl.zone[0]を参照しているかどうかをチェックします。これはlookupFirstZone()のCase 1で使用されます。

テストの追加 (src/pkg/time/zoneinfo_test.go)

新しいテストファイルzoneinfo_test.goが追加され、TestFirstZone関数が定義されました。このテストは、time.ForceZipFileForTesting(true)を使用して、システムではなく埋め込みのzoneinfoデータを使用するように強制します。これにより、テストが実行される環境に依存せず、再現可能なテストが可能になります。

テストケースには、以下の2つのシナリオが含まれています。

  • "PST8PDT": 1918年3月31日のPSTからPDTへの移行をテストします。unixタイムスタンプは移行の1秒前に設定され、その時刻と1秒後の時刻のタイムゾーン情報が正しく解決されることを確認します。これは、歴史的な夏時間移行の直前の時刻が正しく処理されることを検証します。
  • "Pacific/Fakaofo": 2011年12月30日をスキップして日付変更線を移動したファカオフォのケースをテストします。これは、極端なタイムゾーンの変更(日付のスキップ)が、その変更以前の時刻に対して正しく適用されることを検証します。

これらのテストは、新しいロジックが意図した通りに、最初の移行時刻よりも前の時刻に対して正確なタイムゾーン情報を提供するようになったことを確認します。

コアとなるコードの変更箇所

src/pkg/time/zoneinfo.go

--- a/src/pkg/time/zoneinfo.go
+++ b/src/pkg/time/zoneinfo.go
@@ -101,7 +101,7 @@ func FixedZone(name string, offset int) *Location {
 func (l *Location) lookup(sec int64) (name string, offset int, isDST bool, start, end int64) {
 	l = l.get()
 
-	if len(l.tx) == 0 {
+	if len(l.zone) == 0 {
 		name = "UTC"
 		offset = 0
 		isDST = false
@@ -119,6 +119,20 @@ func (l *Location) lookup(sec int64) (name string, offset int, isDST bool, start
 		return
 	}
 
+	if len(l.tx) == 0 || sec < l.tx[0].when {
+		zone := &l.zone[l.lookupFirstZone()]
+		name = zone.name
+		offset = zone.offset
+		isDST = zone.isDST
+		start = -1 << 63
+		if len(l.tx) > 0 {
+			end = l.tx[0].when
+		} else {
+			end = 1<<63 - 1
+		}
+		return
+	}
+
 	// Binary search for entry with largest time <= sec.
 	// Not using sort.Search to avoid dependencies.
 	tx := l.tx
@@ -144,6 +158,58 @@ func (l *Location) lookup(sec int64) (name string, offset int, isDST bool, start
 	return
 }
 
+// lookupFirstZone returns the index of the time zone to use for times
+// before the first transition time, or when there are no transition
+// times.
+//
+// The reference implementation in localtime.c from
+// http://www.iana.org/time-zones/repository/releases/tzcode2013g.tar.gz
+// implements the following algorithm for these cases:
+// 1) If the first zone is unused by the transitions, use it.
+// 2) Otherwise, if there are transition times, and the first
+//    transition is to a zone in daylight time, find the first
+//    non-daylight-time zone before and closest to the first transition
+//    zone.
+// 3) Otherwise, use the first zone that is not daylight time, if
+//    there is one.
+// 4) Otherwise, use the first zone.
+func (l *Location) lookupFirstZone() int {
+	// Case 1.
+	if !l.firstZoneUsed() {
+		return 0
+	}
+
+	// Case 2.
+	if len(l.tx) > 0 && l.zone[l.tx[0].index].isDST {
+		for zi := int(l.tx[0].index) - 1; zi >= 0; zi-- {
+			if !l.zone[zi].isDST {
+				return zi
+			}
+		}
+	}
+
+	// Case 3.
+	for zi := range l.zone {
+		if !l.zone[zi].isDST {
+			return zi
+		}
+	}
+
+	// Case 4.
+	return 0
+}
+
+// firstZoneUsed returns whether the first zone is used by some
+// transition.
+func (l *Location) firstZoneUsed() bool {
+	for _, tx := range l.tx {
+		if tx.index == 0 {
+			return true
+		}
+	}
+	return false
+}
+
 // lookupName returns information about the time zone with
 // the given name (such as "EST") at the given pseudo-Unix time
 // (what the given time of day would be in UTC).

src/pkg/time/zoneinfo_test.go (新規ファイル)

--- /dev/null
+++ b/src/pkg/time/zoneinfo_test.go
@@ -0,0 +1,54 @@
+// Copyright 2014 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package time_test
+
+import (
+	"testing"
+	"time"
+)
+
+// Test that we get the correct results for times before the first
+// transition time.  To do this we explicitly check early dates in a
+// couple of specific timezones.
+func TestFirstZone(t *testing.T) {
+	time.ForceZipFileForTesting(true)
+	defer time.ForceZipFileForTesting(false)
+
+	const format = "Mon, 02 Jan 2006 15:04:05 -0700 (MST)"
+	var tests = []struct {
+		zone  string
+		unix  int64
+		want1 string
+		want2 string
+	}{
+		{
+			"PST8PDT",
+			-1633269601,
+			"Sun, 31 Mar 1918 01:59:59 -0800 (PST)",
+			"Sun, 31 Mar 1918 03:00:00 -0700 (PDT)",
+		},
+		{
+			"Pacific/Fakaofo",
+			1325242799,
+			"Thu, 29 Dec 2011 23:59:59 -1100 (TKT)",
+			"Sat, 31 Dec 2011 00:00:00 +1300 (TKT)",
+		},
+	}
+
+	for _, test := range tests {
+		z, err := time.LoadLocation(test.zone)
+		if err != nil {
+			t.Fatal(err)
+		}
+		s := time.Unix(test.unix, 0).In(z).Format(format)
+		if s != test.want1 {
+			t.Errorf("for %s %d got %q want %q", test.zone, test.unix, s, test.want1)
+		}
+		s = time.Unix(test.unix+1, 0).In(z).Format(format)
+		if s != test.want2 {
+			t.Errorf("for %s %d got %q want %q", test.zone, test.unix, s, test.want2)
+		}
+	}
+}

コアとなるコードの解説

src/pkg/time/zoneinfo.go

  • lookup関数の変更:

    • if len(l.tx) == 0 の条件が if len(l.zone) == 0 に変更されました。これは、タイムゾーンの移行情報(l.tx)がない場合ではなく、タイムゾーン定義自体(l.zone)がない場合にUTCを返すように修正されたことを示唆しています。
    • 最も重要な変更は、if len(l.tx) == 0 || sec < l.tx[0].when という新しい条件ブロックが追加されたことです。
      • len(l.tx) == 0: タイムゾーンの移行が全く定義されていない場合。
      • sec < l.tx[0].when: 要求された時刻secが、そのタイムゾーンで記録されている最初の移行時刻l.tx[0].whenよりも古い場合。 これらのケースでは、l.lookupFirstZone()が呼び出され、適切な初期タイムゾーンのインデックスが決定されます。そのインデックスに対応するzone情報が取得され、name, offset, isDSTが設定されます。startint64の最小値に、endは最初の移行時刻(またはint64の最大値)に設定され、この初期ゾーンが過去のすべての時刻をカバーするようにします。
  • lookupFirstZone()関数の追加:

    • この関数は、IANA tzcodelocaltime.cで定義されている、最初の移行時刻よりも前の時刻に対するタイムゾーン決定アルゴリズムを実装しています。
    • Case 1 (if !l.firstZoneUsed()): 最初のゾーン定義(l.zone[0])がどのタイムゾーン移行にも使用されていない場合、その最初のゾーン(インデックス0)が返されます。これは、そのゾーンがデフォルトまたは初期のルールとして機能することを示唆します。
    • Case 2 (if len(l.tx) > 0 && l.zone[l.tx[0].index].isDST): 移行が存在し、かつ最初の移行が夏時間(DST)を導入するものである場合、最初の移行ゾーンのインデックスから逆方向にzoneスライスを探索し、最初の非夏時間ゾーンを見つけてそのインデックスを返します。これは、過去の時刻には夏時間ではないルールを適用するという一般的な慣習に基づいています。
    • Case 3 (for zi := range l.zone): 上記のケースに該当しない場合、zoneスライス全体を探索し、最初の非夏時間ゾーンのインデックスを返します。
    • Case 4 (return 0): 上記すべてのケースに該当しない場合(例えば、すべてのゾーンが夏時間であると定義されている場合など)、デフォルトとしてインデックス0が返されます。
  • firstZoneUsed()関数の追加:

    • このシンプルなヘルパー関数は、l.txスライス内のすべての移行をチェックし、いずれかの移行がl.zone[0]を参照しているかどうかをブール値で返します。これはlookupFirstZone()のCase 1で使用され、最初のゾーンが「未使用」であるかどうかを判断します。

src/pkg/time/zoneinfo_test.go

  • TestFirstZone関数の追加:
    • このテスト関数は、time.ForceZipFileForTesting(true)を呼び出すことで、テストがシステムにインストールされているzoneinfoデータではなく、Goのバイナリに埋め込まれたzoneinfo.zipデータを使用するように強制します。これにより、テストの実行環境に依存しない一貫した結果が得られます。
    • testsスライスには、特定のタイムゾーン(zone)、テスト対象のUnixタイムスタンプ(unix)、および期待されるフォーマット済み文字列(want1, want2)のペアが定義されています。
    • テストは、time.Unix(test.unix, 0).In(z).Format(format)time.Unix(test.unix+1, 0).In(z).Format(format)を呼び出し、それぞれが期待される出力と一致するかを検証します。
    • PST8PDTのテストケースは、1918年の夏時間移行の直前と直後の時刻を検証し、古い日付における夏時間ルールの適用が正しいことを確認します。
    • Pacific/Fakaofoのテストケースは、2011年の日付変更線移動(日付のスキップ)という極端なケースを検証し、このような歴史的な変更が、その変更以前の時刻に影響を与えないことを確認します。

これらの変更により、Goのtimeパッケージは、IANAタイムゾーンデータベースの参照実装にさらに厳密に準拠し、特に歴史的な日付や最初の移行時刻よりも前の時刻に対するタイムゾーンの解決において、より正確で堅牢な挙動を提供するようになりました。

関連リンク

参考にした情報源リンク