Go 语言中时间与时区解析的一个问题
几乎所有关于 Go 语言中时间解析的例子都会拿完整的包含日期在内的时间来解析,官方例子也不例外。 但是却很少有人以下面这段代码作为例子解释其中的行为,直到使用者出错的那一天。
问题背景
想让用户从终端输入一个可读的时间作为输入,但是我发现我解析出来的输入不对。
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)解析一个输入的部分时间,下面看看执行结果:
$ 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.String
→ Time.Format
→ Time.AppendFormat
→ Time.locabs
→ Location.lookup
。
// 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 那地干嘛呢!)
// 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
相关的定义:
// 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 内容:
- 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
。
// 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。
再来修改一下示例代码:
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)
}
现在可以得到期望的答案:
$ go run main.go
2021-12-13 02:31:00.516906 +0800 CST m=+0.000260175
0000-01-01 02:31:00 +0800 CST
最后
虽然是个小问题,但经过查源代码与相关资料,收获不少。