4

golang time 包的坑

 2 years ago
source link: https://blog.wolfogre.com/posts/trap-of-golang-time/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

golang time 包的坑

2017/12/01.

Golang , Docker 1.5k+ 3

为了不独享被炸断腿的懵逼,此处来一波无奖竞猜。

请不要编译运行下列代码,人肉计算程序运行结果:

package main

import "time"

func main() {
	format := "20060102150405"

	// t1 没有写 time.Now() 是为了避免秒以下单位的时间的影响
	// 除此之外和写 time.Now() 是一样的
	t1 := time.Date(2017, time.November, 30, 0, 0, 0, 0, time.Local)

	t2, _ := time.Parse(format, t1.Format(format))
	println("1-1 ", t1.Format(format) == t2.Format(format))
	println("1-2 ", t1 == t2)
	println("1-3 ", t1.Equal(t2))

	t2, _ = time.ParseInLocation(format, t1.Format(format), time.Local)
	println("2-1 ", t1.Format(format) == t2.Format(format))
	println("2-2 ", t1 == t2)
	println("2-3 ", t1.Equal(t2))

	t2 = t2.UTC()
	println("3-1 ", t1.Format(format) == t2.Format(format))
	println("3-2 ", t1 == t2)
	println("3-3 ", t1.Equal(t2))
}

请先把答案写在纸上再往下看。

不检查下?

1-1  true
1-2  false
1-3  false
2-1  true
2-2  true
2-3  true
3-1  false
3-2  false
3-3  true

如果你写对了,恭喜,你完全理解 time 包,请回吧,恭送大神~

如果没有写对,来吧,听我叨逼叨。

精巧的 time

不同于 java 饱受诟病的各种乱七八糟的时间处理相关的包,golang 的 time 包的设计可谓相当精巧。

time 包用来描述“时刻”的类型为 Time,其定义如下:

type Time struct {
	// sec gives the number of seconds elapsed since
	// January 1, year 1 00:00:00 UTC.
	sec int64

	// nsec specifies a non-negative nanosecond
	// offset within the second named by Seconds.
	// It must be in the range [0, 999999999].
	nsec int32

	// loc specifies the Location that should be used to
	// determine the minute, hour, month, day, and year
	// that correspond to this Time.
	// The nil location means UTC.
	// All UTC times are represented with loc==nil, never loc==&utcLoc.
	loc *Location
}

如其注释所述,sec 记录其距离 UTC 时间0年1月1日0时的秒数, nsec 记录一个 0~999999999 的纳秒数,loc 记录所在时区。事实上,仅需要 secnsec 就完全可以描述一个时间点了——“该时间点是距离 UTC 时间0年1月1日0时 sec 秒、nsec 纳秒的时间点”——非常准确且非常不容易引起歧义,但这并不符合人们日常生活中描述时间点的方式,我们只会说是某年某月某日,几点几分几秒,然而,一旦要这样说,事实上就涉及到时区了。

当一个 golang 的 Time 实例被程序员问:你记录的是几时几分?这个实例可以说是相当无语的,因为又没说清是在哪个时区下的几点几分,我 TM 怎么知道是几点几分?然而程序员并不买账,因为一个时间点能准确得说出自己是几点几分似乎是天经地义的事,于是 Time 类不得不自己记一个时区,默认就是这个无脑的程序员所在地的时区(这个值可以向操作系统索要),当再次被问几点几分的时候,便可以作答了,而记的地方便是 loc 字段。

站在计算机冰冷的角度来看,“某时区某年某月某日几点几分几秒”是对时间点的人性化描述,而“距离一个众所周知的时间点多少秒、多少纳秒”才是对时间点的准确记录。这一点,在 Time 类型的实现中展现的淋漓尽致。

所以,基于对 Time 类型的了解,我们反观一下对时间的一些操作,看看时区在影响着哪些。

时间的比较、求差操作,很明显这类操作是与时区无关的,无论 loc 记录的是什么,只要对 secnsec 进行比较、求差,就能得出正确的结果。

时间的取时、取分操作,不用说了,肯定是需要时区信息参与的。

时间的 format 操作,这里仅指 format 成年月日时分秒的形式,显然也是需要时区参与的。

时间的 parse 操作,即 format 的逆向操作,同样需要时区参与。

而坑点就在这里,一方面,format 操作使用 Time 实例记录的时区,大多数情况下是本地时区;另一方面,parse 操作在并不会默认使用本地时区。

time.Parse() 会尝试从参数字符串里读出时区信息,当且仅当:参数里有指明时区信息、时区信息以 zone offset 形式(如+0800)表示、表示结果与本地时区等价时,才会使用本地时区,否则使用读出的时区。若参数里没有指明时区信息,则使用 UTC 时间。这便是第一个坑点。

相比之下,第二个坑点便算不上什么大事了——不要使用 == 去比较时间是否相等。golang 可没有什么重载运算符的说法,使用 == 比较两个 Time 实例时,事实上就是比较 secnsecloc 三个字段是否都相等。然而如我所述,仅需要 secnsec 就完全可以描述一个时间点了,所以只要这两个字段相等,两个 Time 实例就是指的同一个时间点。而仅因为 loc 值的不同,便判定两个 Time 实例不相等,这是非常荒谬的。这就是为什么应该使用 Equal 比较时间点是否相等的原因。

至此,我们回顾一下开头分享的代码,相关的说明已用注释补加。

package main

import "time"

func main() {
	// format 字符串为 年月日时分秒,没有时区信息
	format := "20060102150405"

	// t1 没有写 time.Now() 是为了避免秒以下单位的时间的影响
	// 除此之外和写 time.Now() 是一样的
	t1 := time.Date(2017, time.November, 30, 0, 0, 0, 0, time.Local)

	// t1 使用本地时区进行 format,结果是 "20171130000000"
	// 由进行 parse,由于没有指定时区,结果是 UTC 时间 2017/11/30 00:00:00
	t2, _ := time.Parse(format, t1.Format(format))
	
	// t1 使用本地时区进行 format,结果是 "20171130000000"
	// t2 使用 UTC 时间进行 format,结果是 "20171130000000"
	// 所以输出 true
	println("1-1 ", t1.Format(format) == t2.Format(format))
	
	// 很显然不相等,既不是指同一个时间点,时区信息也不一样,所以输出 false
	println("1-2 ", t1 == t2)
	
	// 显然不相等,t1 和 t2 不是指同一个时间点,所以输出 false
	println("1-3 ", t1.Equal(t2))

	// t1 使用本地时区进行 format,结果是 "20171130000000"
	// 由进行 parse,指定了本地时区,结果是本地时间 2017/11/30 00:00:00
	t2, _ = time.ParseInLocation(format, t1.Format(format), time.Local)
	
	// 显然相等,输出 true
	println("2-1 ", t1.Format(format) == t2.Format(format))
	// 既指同一个时间点,时区信息也一样,输出 true
	println("2-2 ", t1 == t2)
	// 显然相等,输出 true
	println("2-3 ", t1.Equal(t2))

	// 原本 t2 与 t1 完全相等,现在将 t2 改为 UTC 时间 
	t2 = t2.UTC()
	
	// t1 使用本地时区进行 format,结果是 "20171130000000"
	// t2 使用 UTC 时间进行 format,结果是 "20171129160000"
	// 所以输出 false
	println("3-1 ", t1.Format(format) == t2.Format(format))
	
	// t1 和 t2 表示了相同的时间点,但各自时区信息不同,所以输出 false
	println("3-2 ", t1 == t2)
	
	// 由于 t1 和 t2 表示了相同的时间点,所以输出 true
	println("3-3 ", t1.Equal(t2))
}

docker 中

很明显,若要避免不必要的麻烦,就要正确地使用 time 包——而这句话的大前提是操作系统的时区设置是正确的,否则一切都是空谈。

显然绝大多数的 PC、服务器的时区设置肯定是正确(是吧?要不你检查下?)。需要提高警惕的是 docker 用户,docker 在编译镜像、启动容器时均不会继承宿主机的时区设置。如果容器内的服务对时间不敏感,可能仅是输出日志的时间不是本地时间的问题,而如果服务对时间敏感,比如每天早上九点执行某任务,可能就要出错了。

以设为上海时区为例,解决方法有两个,可视情况取舍。

要么在镜像编译时指定好时区:

...
RUN rm /etc/localtime && ln -s /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
...

要么在容器启动时指定好时区:

docker run -e TZ="Asia/Shanghai" -v /etc/localtime:/etc/localtime:ro ...

这个问题希望我阐述清楚了,如果没有,那好歹记住一个总结:

  • 不要使用 time.Parse(),而使用 time.ParseInLocation()
  • 不要使用 == 去比较时间是否相等,而使用 Equal
  • 运行 docker 容器确认时区是否设置正确。

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK