Go 语言中时间与时区解析的一个问题

陪她去流浪 桃子 2021年12月13日 编辑 阅读次数:3384

几乎所有关于 Go 语言中时间解析的例子都会拿完整的包含日期在内的时间来解析,官方例子也不例外。 但是却很少有人以下面这段代码作为例子解释其中的行为,直到使用者出错的那一天。

问题背景

想让用户从终端输入一个可读的时间作为输入,但是我发现我解析出来的输入不对。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package main

import (
	"fmt"
	"time"
)

func main() {
	now := time.Now()
	fmt.Println(now)
	layout, input := `15:04`, `02:27`
	t, _ := time.ParseInLocation(layout, input, now.Location())
	fmt.Println(t)
}

很简单一段代码(忽略错误处理),以当前位置(Location)解析一个输入的部分时间,下面看看执行结果:

1
2
3
$ go run main.go
2021-12-13 02:27:40.930331 +0800 CST m=+0.000177157
0000-01-01 02:27:00 +0805 LMT

时、分都是正确解析的,年、月、日、秒、纳秒也是正确的默认值。哪里不对?时区。

是不是挺奇怪的呢,一个是 CST(China Standard Time,中国标准时间,+0800),一个 LMT(Local Mean Time?,+0805)。 哎喂,我明明就是拿 now 的 Location 去 Parse 的,为什么 Parse 出来的时区/位置却不一样呢?甚是奇怪!

从源代码中找答案

调用栈:Time.StringTime.FormatTime.AppendFormatTime.locabsLocation.lookup

1
2
3
4
5
// AppendFormat is like Format but appends the textual
// representation to b and returns the extended buffer.
func (t Time) AppendFormat(b []byte, layout string) []byte {
	var (
		name, offset, abs = t.locabs()

这个 locabs 就是我要关注的点。其中的 name 即是时区的名字,offset 即是时区的偏移。问题就出在这里,它为什么算“错”了?

locabs 会调用 lookup,以下是其源代码(有删减)。(注:这周末两天整个地被 log4j 的远程代码执行漏洞支配了,当看到 lookup 时,有点 PTSD 了。一个日志库,你 lookup 这 lookup 那地干嘛呢!)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// lookup returns information about the time zone in use at an
// instant in time expressed as seconds since January 1, 1970 00:00:00 UTC.
//
// The returned information gives the name of the zone (such as "CET"),
// the start and end times bracketing sec when that zone is in effect,
// the offset in seconds east of UTC (such as -5*60*60), and whether
// the daylight savings is being observed at that time.
func (l *Location) lookup(sec int64) (name string, offset int, start, end int64) {
	l = l.get()

	// ... cut off ...

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

	// ... cut off ...

	// Binary search for entry with largest time <= sec.
	// Not using sort.Search to avoid dependencies.
	tx := l.tx
	end = omega
	lo := 0
	hi := len(tx)
	for hi-lo > 1 {
		m := lo + (hi-lo)/2
		lim := tx[m].when
		if sec < lim {
			end = lim
			hi = m
		} else {
			lo = m
		}
	}
	zone := &l.zone[tx[lo].index]
	name = zone.name
	offset = zone.offset
	start = tx[lo].when

	// ... cut off ...

	return
}

在解读这段代码前,先来看看 Location 相关的定义:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// A Location maps time instants to the zone in use at that time.
// Typically, the Location represents the collection of time offsets
// in use in a geographical area. For many Locations the time offset varies
// depending on whether daylight savings time is in use at the time instant.
type Location struct {
	name string
	zone []zone
	tx   []zoneTrans

	// ... cut off ...
}

// A zone represents a single time zone such as CET.
type zone struct {
	name   string // abbreviated name, "CET"
	offset int    // seconds east of UTC
	isDST  bool   // is this zone Daylight Savings Time?
}

// A zoneTrans represents a single time zone transition.
type zoneTrans struct {
	when         int64 // transition time, in seconds since 1970 GMT
	index        uint8 // the index of the zone that goes into effect at that time
	isstd, isutc bool  // ignored - no idea what these mean
}

从注释以及定义来看,问题开始变得有迹可循了: 一个 位置(Location) 包含多个 时区(Zone) 和多个 时区变迁(Transition) 。 所以 lookup 是在干嘛? 它在查找输入的参数 sec(Unix 时间戳) 该使用此位置的哪个时区

对于有点地理盲和历史盲的我来说,对于这些并不是太了解。于是去查找了一些相关资料(维斯百科 - 中国时区):

中国幅员辽阔,按国际通行时区划分标准可划分为东五区、东六区、东七区、东八区、东九区5个时区。 中华民国大陆时期依据国际标准,将全国时区划分为昆仑时区、回藏时区(后改称新藏时区)、陇蜀时区、中原时区、以及长白时区。 1949年中华人民共和国成立后,改将中国大陆全境统一划为东八区(UTC+8),同时采用北京时间做为全国唯一的标准时间。

简单地说,同一个地理位置的时区,在不同的历史上(国家灭亡、国家建立、政权更替等)是会发生改变的。 这些信息被维护整理到时区信息数据库中。

再回到位置(Location) 的定义中,zone 记录了这个位置所有出现过的时区,而 tx 则记录了一个时区是何时变迁到另一个时区的。

现在可以明明白白 lookup 的所作所为了。

附:我示例中的 Location 的 Zone 内容:

1
2
3
4
5
6
7
8
9
- Name:LMT
  Offset:29143
  IsDST:false
- Name:CDT
  Offset:32400
  IsDST:true
- Name:CST
  Offset:28800
  IsDST:false

CST 是我们目前使用的 +8 (8*60*60=28800)时区。

Tx 我就不附了,有 29 个之多。

答案解答

我让用户的输入只包含了时和分,那么默认的年是 0000 年,转换成 Unix 时间戳(相对于 1970 年来说是个负数)后通过 Tx 找到对应的 Zone 确实是 LMT。没毛病。

那怎么让输入值解析正确呢?用 FixedZone

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// FixedZone returns a Location that always uses
// the given zone name and offset (seconds east of UTC).
func FixedZone(name string, offset int) *Location {
	l := &Location{
		name:       name,
		zone:       []zone{{name, offset, false}},
		tx:         []zoneTrans{{alpha, 0, false, false}},
		cacheStart: alpha,
		cacheEnd:   omega,
	}
	l.cacheZone = &l.zone[0]
	return l
}

这个 Zone 构成的 Location 只有一个 Zone,一个 Transition。

再来修改一下示例代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package main

import (
	"fmt"
	"time"
)

func main() {
	now := time.Now()
	fmt.Println(now)
	layout, input := `15:04`, `02:31`
	loc := time.FixedZone(now.Zone())
	t, _ := time.ParseInLocation(layout, input, loc)
	fmt.Println(t)
}

现在可以得到期望的答案:

1
2
3
$ go run main.go
2021-12-13 02:31:00.516906 +0800 CST m=+0.000260175
0000-01-01 02:31:00 +0800 CST

最后

虽然是个小问题,但经过查源代码与相关资料,收获不少。

标签:时区 · Go