[インデックス 18390] ファイルの概要
このコミットは、Go言語の標準ライブラリ time
パッケージにおけるタイムゾーン処理の改善に関するものです。具体的には、特定のタイムゾーンにおいて、記録されている最初のタイムゾーン移行時刻よりも前の時刻を扱う際の挙動を修正し、IANAタイムゾーンデータベースの参照実装(tzcode
のlocaltime.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つのケースを考慮して定義されています。
- 最初のゾーンがどの移行にも使用されていない場合: その最初のゾーンを使用する。
- 移行が存在し、最初の移行が夏時間ゾーンへのものである場合: 最初の移行ゾーンよりも前で、最も近い非夏時間ゾーンを探して使用する。
- 上記以外で、非夏時間ゾーンが存在する場合: 最初の非夏時間ゾーンを使用する。
- 上記すべてに該当しない場合: 最初のゾーンを使用する。
このアルゴリズムは、歴史的なタイムゾーンデータが完全ではない場合でも、最も妥当なタイムゾーン情報を決定するためのヒューリスティックを提供します。
技術的詳細
このコミットの主要な変更点は、time
パッケージのLocation
型が持つlookup
メソッドに、最初のタイムゾーン移行時刻よりも前の時刻を処理するロジックを追加したことです。
Location
構造体は、タイムゾーンに関する情報を保持しており、特に以下のフィールドが重要です。
zone
:zone
構造体のスライスで、各タイムゾーンの定義(名前、オフセット、夏時間フラグなど)を保持します。tx
:zoneTrans
構造体のスライスで、タイムゾーンの移行情報(移行時刻、適用されるzone
のインデックスなど)を保持します。tx[0].when
は最初の移行時刻を示します。
変更前は、lookup
メソッドがl.tx
(移行情報)が空の場合のみ特別な処理を行っていましたが、l.tx
が存在しても、要求された時刻sec
がl.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つのシナリオを処理します。
len(l.tx) == 0
: タイムゾーンの移行情報が全く存在しない場合。この場合、そのタイムゾーンは常に単一のルールで運用されていると見なされます。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].isDST
がtrue
)へのものである場合、最初の移行ゾーンのインデックスから逆順に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
が設定されます。start
はint64
の最小値に、end
は最初の移行時刻(またはint64
の最大値)に設定され、この初期ゾーンが過去のすべての時刻をカバーするようにします。
-
lookupFirstZone()
関数の追加:- この関数は、IANA
tzcode
のlocaltime.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
が返されます。
- この関数は、IANA
-
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タイムゾーンデータベースの参照実装にさらに厳密に準拠し、特に歴史的な日付や最初の移行時刻よりも前の時刻に対するタイムゾーンの解決において、より正確で堅牢な挙動を提供するようになりました。
関連リンク
- Go
time
パッケージのドキュメント: https://pkg.go.dev/time - IANA Time Zone Database: https://www.iana.org/time-zones
- Go CL 58450043 (このコミットに対応するGoのコードレビュー): https://golang.org/cl/58450043
参考にした情報源リンク
- IANA Time Zone Database
tzcode
(特にlocaltime.c
のソースコード): https://www.iana.org/time-zones/repository/releases/ (具体的なバージョンはコミットメッセージに記載のtzcode2013g.tar.gz
を参照) - Go
time
パッケージのソースコード (src/pkg/time/zoneinfo.go
): https://github.com/golang/go/blob/master/src/time/zoneinfo.go - Go
time
パッケージのテストコード (src/pkg/time/zoneinfo_test.go
): https://github.com/golang/go/blob/master/src/time/zoneinfo_test.go