第3章 处理日期和时间
# 第3章 处理日期和时间
在任何编程语言中,处理日期和时间都可能颇具难度。Go语言的标准库提供了易于使用的工具来处理日期和时间相关的操作。这些工具可能与许多人习惯的有所不同。例如,不同语言的一些库会区分时间类型(time type)和日期类型(date type),而Go语言的标准库仅包含time.Time
这一种类型。这可能会让你在使用Go语言处理时间时感到有些困惑。
我认为Go语言对日期/时间的处理方式降低了产生潜在bug的可能性。你看,当你谈论时间时,必须非常谨慎且明确你的意思:你说的是某个时间点还是一个时间段?日期实际上是一个时间段(例如,2024年8月1日从2024年8月1日00:00:00开始,一直持续到2024年8月1日23:59:59),尽管通常人们并非这样想。一个具体的日期/时间值还取决于你测量时间的地点。美国科罗拉多州丹佛市的2023年11月5日08:00,与德国柏林的2023年11月5日08:00是不同的。时间总是向前推进,但日期/时间可能会跳过或倒退:在美国科罗拉多州丹佛市,2023年11月5日02:59之后,时间会倒退回2023年11月5日02:00,因为这是科罗拉多州夏令时结束的时间。所以,实际上2023年11月5日02:10:10有两个时间实例,一个是山区夏令时,另一个是山区标准时间。
如今,在生产环境中有许多软件bug是由于时间处理不当导致的。例如,如果你要计算客户订阅的到期时间,就必须考虑客户所在的位置以及订阅到期的具体时间,否则,在订阅的最后一天,他们的订阅可能会提前(或延迟)到期。
本章包含以下正确处理日期/时间的方法:
- 处理Unix时间
- 日期/时间的组成部分
- 日期/时间运算
- 日期/时间的格式化与解析
- 处理时区
- 定时器(Timers)
- 间隔定时器(Tickers)
- 存储时间信息
# 处理Unix时间
Unix时间是指从1970年1月1日协调世界时(UTC,即纪元时间)开始经过的秒数(或毫秒数、微秒数、纳秒数 )。Go语言使用int64
类型来表示这些值,所以以秒为单位的Unix时间可以表示过去或未来数十亿年的时间。以纳秒为单位的Unix时间可以表示1678年到2262年之间的日期值。Unix时间是对某个时间实例的绝对度量,即从纪元时间开始(或到纪元时间)的持续时间。它与地点无关,所以对于两个Unix时间s
和t
,如果s < t
,那么无论在什么地点,s
都发生在t
之前。由于这些特性,Unix时间通常用作时间戳(timestamp),用于标记事件发生的时间(例如写入日志、插入记录等)。
# 操作方法……
- 要获取当前的Unix时间,可使用以下方法:
time.Now().Unix() int64
:以秒为单位的Unix时间time.Now().UnixMilli() int64
:以毫秒为单位的Unix时间time.Now().UnixMicro() int64
:以微秒为单位的Unix时间time.Now().UnixNano() int64
:以纳秒为单位的Unix时间
- 给定一个Unix时间,可使用以下方法将其转换为
time.Time
类型:time.Unix(sec, nanosec int64) time.Time
:将以秒和/或纳秒为单位的Unix时间转换为time.Time
类型time.UnixMilli(int64) time.Time
:将以毫秒为单位的Unix时间转换为time.Time
类型time.UnixMicro(int64) time.Time
:将以微秒为单位的Unix时间转换为time.Time
类型
- 要将Unix时间转换为本地时间,可使用
localTime := time.Unix(unixTimeSeconds,0).In(location)
,其中location
是一个*time.Location
类型,用于指定解释Unix时间的地点。
# 日期/时间的组成部分
在处理日期值时,你经常需要根据其组成部分构建一个日期/时间,或者需要访问一个日期/时间值的各个组成部分。本方法展示了如何实现这些操作。
# 操作方法……
- 要根据各个部分构建一个日期/时间值,可使用
time.Date
函数。 - 要获取一个日期/时间值的各个部分,可使用
time.Time
的方法:time.Day() int
time.Month() time.Month
time.Year() int
time.Date() (year, month, day int)
time.Hour() int
time.Minute() int
time.Second() int
time.Nanosecond() int
time.Zone() (name string,offset int)
time.Location() *time.Location
time.Date
函数会根据传入的组成部分创建一个时间值:
d := time.Date(2020, 3, 31, 15, 30, 0, 0, time.UTC)
fmt.Println(d)
// 2020-03-31 15:30:00 +0000 UTC
2
3
输出结果会进行规范化处理,如下所示:
d := time.Date(2020, 3, 0, 15, 30, 0, 0, time.UTC)
fmt.Println(d)
// 2020-02-29 15:30:00 +0000 UTC
2
3
由于月份中的日期是从1开始计数的,创建一个日期时如果将日期设为0,结果会是上一个月的最后一天。
一旦有了一个time.Time
类型的值,就可以获取它的各个组成部分:
d := time.Date(2020, 3, 0, 15, 30, 0, 0, time.UTC)
fmt.Println(d.Day())
// 29
2
3
同样,time.Date
函数会对日期值进行规范化处理,所以d.Day()
会返回29。
# 日期/时间运算
在处理诸如以下的问题时,日期/时间运算是必要的:
- 完成一项操作花了多长时间?
- 5分钟后是几点?
- 距离下个月还有多少天?
本方法展示了如何使用time
包来回答这些问题。
# 操作方法……
- 要计算两个时间实例之间经过的时间,可使用
Time.Sub
方法进行相减。 - 要计算从现在到某个未来时间的持续时间,可使用
time.Until(laterTime)
。 - 要计算从某个给定时间开始已经过去的时间,可使用
time.Since(beforeTime)
。 - 要计算经过一定持续时间后的时间,可使用
Time.Add
方法。使用负的持续时间可以计算在某个持续时间之前的时间。 - 要对某个时间增加/减少年、月或日,可使用
Time.AddDate
方法。 - 要比较两个
time.Time
类型的值,可使用以下方法:Time.Equal
:用于检查两个时间值是否表示同一个时间实例。Time.Before
或Time.After
:用于检查一个时间值是在给定时间值之前还是之后。
# 工作原理……
time.Duration
类型表示两个时间实例之间经过的时间,以纳秒为单位,用int64
类型的值表示。换句话说,如果你用一个time.Time
类型的值减去另一个,就会得到一个time.Duration
类型的值:
dur := tm1.Sub(tm2)
由于Duration
是一个表示纳秒数的int64
类型,所以可以进行持续时间的运算:
// 给持续时间加上1天
dur += time.Hour * 24
2
注意,前面代码中的最后一个操作还涉及乘法运算,因为time.Hour
本身就是time.Duration
类型。
可以将一个持续时间值加到一个time.Time
类型的值上:
now := time.Now()
then := now.Add(dur)
2
提示Duration 是int64 类型,这意味着一个time.Duration 类型的值大约限制在290年左右。对于大多数实际情况来说,这应该足够了。然而,如果对你来说不够,你需要自己构建一个解决方案,或者找一个第三方库。 |
---|
可以通过加上一个负的持续时间值,从一个time.Time
类型的值中减去这个持续时间:
fmt.Println(then.Add(-dur).Equal(now))
注意这里使用了Time.Equal
方法。这个方法在比较两个时间实例时会考虑它们的时区,而时区可能是不同的。例如,对于2024年1月9日09:00 MST(山地标准时间)和2024年1月9日08:00 PST(太平洋标准时间),Time.Equal
会返回true
。
使用Time.Before
和Time.After
来比较时间值。例如,你可以通过以下方式检查一个有过期日期的对象是否已经过期:
if object.Expiration.After(time.Now()) {
// Object expired
}
2
3
也可以对给定的日期增加/减少年/月/日:
t := time.Now()
// 从现在减去1年,得到去年此时
lastYear := t.AddDate(-1, 0, 0)
// 增加1天,得到明天同一时间
tomorrow := t.AddDate(0, 0, 1)
// 增加1个月,得到下个月
nextMonth := t.AddDate(0, 1, 0)
2
3
4
5
6
7
这些操作的结果会进行规范化处理。例如,如果你从2020年2月29日减去1年,会得到2019年3月1日。当你处理月末的日期并且需要增加/减少月份值时,这可能会导致问题。对2020年3月31日增加一个月两次,结果会是2020年6月1日,但直接增加两个月,结果会是2020年5月31日:
d := time.Date(2020, 3, 31, 0, 0, 0, 0, time.UTC)
fmt.Println(d.AddDate(0, 1, 0).AddDate(0, 1, 0))
// 2020-06-01 00:00:00 +0000 UTC
fmt.Println(d.AddDate(0, 2, 0))
// 2020-05-31 00:00:00 +0000 UTC
2
3
4
5
# 日期/时间的格式化与解析
Go语言使用一种有趣且有些争议的日期/时间格式化方案。日期/时间格式通过一个特定的时间点来表示,并进行了调整,使得日期/时间的每个组成部分都是一个唯一的数字:
- 1表示月份:“Jan”“January”“01”“1”
- 2表示月份中的日期:“2”“_2”“02”
- 3表示12小时制的小时数:“15”“3”“03”
- 15表示24小时制的小时数
- 4表示分钟数:“4”“04”
- 5表示秒数:“5”“05”
- 6表示年份:“2006”“06”
- MST表示时区:“-0700”“-07:00”“-07”“-070000”“-07:00:00”“MST”
- 0表示用0填充的毫秒数:“0”“000”
- 9表示未填充的毫秒数:“9”“999”
# 操作方法……
- 使用
time.Parse
并传入合适的格式来解析日期/时间。格式中未指定的日期/时间部分将初始化为其零值,月份的零值是1月,年份的零值是1,月份中日期的零值是1,其他部分的零值是0。如果缺少时区信息,解析后的日期/时间将是UTC时间。 - 使用
time.ParseInLocation
在给定的地点解析日期/时间。时区将根据日期值和地点来确定。 - 使用
Format()
方法来格式化一个日期/时间值。
func main() {
t := time.Date(2024, 3, 8, 18, 2, 13, 500, time.UTC)
fmt.Println("Date in yyyy/mm/dd format", t.Format("2006/01/02"))
// Date in yyyy/mm/dd format 2024/03/08
fmt.Println("Date in yyyy/m/d format", t.Format("2006/1/2"))
// Date in yyyy/m/d format 2024/3/8
fmt.Println("Date in yy/m/d format", t.Format("06/1/2"))
// Date in yy/m/d format 24/3/8
fmt.Println("Time in hh:mm format (12 hr)", t.Format("03:04"))
// Time in hh:mm format (12 hr) 06:02
fmt.Println("Time in hh:m format (24 hr)", t.Format("15:4"))
// Time in hh:m format (24 hr) 18:2
fmt.Println("Date-time with time zone", t.Format("2006-01-02 13:04:05 -07:00"))
// Date-time with time zone 2024-03-08 18:02:13 +00:00
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
时区会因地点和日期的不同而变化。在下面的示例中,尽管使用相同的地点来解析日期,但时区发生了变化,因为7月9日是山地夏令时,而1月9日是山地标准时间:
loc, _ := time.LoadLocation("America/Denver")
const format = "Jan 2, 2006 at 3:04pm"
str, _ := time.ParseInLocation(format, "Jul 9, 2012 at 5:02am", loc)
fmt.Println(str)
// 2012-07-09 05:02:00 -0600 MDT
str, _ = time.ParseInLocation(format, "Jan 9, 2012 at 5:02am", loc)
fmt.Println(str)
// 2012-01-09 05:02:00 -0700 MST
2
3
4
5
6
7
8
# 处理时区
Go语言的time.Time
类型的值包含time.Location
,它可以是以下两种情况之一:
- 一个地区标识,如“America/Denver”。在这种情况下,实际的时区取决于时间值。对于丹佛,根据实际时间值,时区可能是MDT(山地夏令时)或MST(山地标准时间)。
- 一个固定的时区,表示偏移量。
有些应用程序处理本地时间(local time)。本地时间是在特定地点获取的日期/时间值,并且在各地都被解释为相同的值,而不是被解释为同一个时间点。生日(以及由此计算的年龄)通常是按照本地时间来解释的。也就是说,如果你出生于2005年7月14日,在2007年7月14日00:00,在纽约(东部时区)你会被认为2岁,但在同一时刻,在洛杉矶(太平洋时区),时间是2007年7月13日21:00,你仍被认为1岁。
# 操作方法……
如果你处理的是时间点,始终要捕获带有相关地点信息的日期/时间值。这样的日期/时间值可以很容易地转换到其他时区。
如果你在多个时区处理本地时间,可以在新的地点或时区重新创建time.Time
类型的值来进行转换。
# 它的工作原理……
当你创建一个time.Time
类型的变量时,它总是与一个地点(时区)相关联:
// 使用本地时区创建一个新时间
t := time.Date(2021,12,31,15,0,0,0, time.Local)
// 2021-12-31 15:00:00 -0700 MST
2
3
一旦你有了一个time.Time
类型的变量,你可以获取同一时刻在不同时区的表示:
utcTime := t.In(time.UTC)
fmt.Println(utcTime)
// 2021-12-31 22:00:00 +0000 UTC
ny,err:=time.LoadLocation("America/New_York")
if err != nil {
panic(err)
}
nyTime := t.In(ny)
fmt.Println(nyTime)
// 2021-12-31 17:00:00 -0500 EST
2
3
4
5
6
7
8
9
10
11
这些是同一时刻在不同时区的不同表示形式。你还可以创建一个自定义时区:
zone30 := time.FixedZone("30min", 30)
fmt.Println(t.In(zone30))
// 2021-12-31 22:00:30 +0000 30min
2
3
当你处理本地时间时,你会丢弃地点和时区信息:
// 创建一个本地时间,UTC时区
t := time.Date(2021,12,31,15,0,0,0, time.UTC)
// 2021-12-31 15:00:00 +0000 UTC
2
3
要在纽约(时区)获取相同的时间值,可以使用以下方法:
ny,err: = time.LoadLocation("America/New_York")
if err != nil {
panic(err)
}
nyTime := time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), ny)
fmt.Println(nyTime)
// 2021-12-31 15:00:00 -0500 EST
2
3
4
5
6
7
8
# 存储时间信息
一个常见的问题是以可移植的方式在数据库、文件等中存储日期/时间信息,以便能够正确地解释它。
# 如何操作……
你应该首先确定确切的需求:你需要存储某个时刻(instant of time)还是一天中的某个时间(time of day)?
- 要存储某个时刻,可以执行以下操作之一:
- 以所需的粒度存储Unix时间(即,
time.Unix
用于秒,time.UnixMilli
用于毫秒等)。 - 存储协调世界时(UTC,
time.UTC()
)。
- 以所需的粒度存储Unix时间(即,
- 要存储一天中的某个时间,可以存储
time.Duration
类型的值,该值表示当天中的某个时刻。以下函数将当天中的某个时刻计算为time.Duration
类型的值:
func GetTimeOfDay(t time.Time) time.Duration {
beginningOfDay: = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
return t.Sub(beginningOfDay)
}
2
3
4
- 要存储日期值,可以清除
time.Time
类型变量的时间部分:
date: = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
请注意,以这种方式存储的日期进行比较可能会有问题,因为在不同的时区,每一天会被解释为不同的时刻。
# 定时器(Timers)
使用time.Timer
安排在未来某个时间执行某些任务。当定时器到期时,你将从一个通道接收到一个信号。你可以使用定时器稍后运行一个函数,或者取消运行时间过长的进程。
# 如何操作……
你可以通过以下两种方式之一创建定时器:
- 使用
time.NewTimer
或time.After
。定时器到期时会通过通道发送一个信号。使用select
语句,或从通道读取以接收定时器到期信号。 - 使用
time.AfterFunc
在定时器到期时调用一个函数。
# 它的工作原理……
time.Timer
类型的定时器通过time.Duration
类型的值来创建:
// 创建一个10秒的定时器
timer := time.NewTimer(time.Second*10)
2
定时器包含一个通道,10秒过后,该通道将接收到当前时间戳。定时器创建时通道容量为1,因此定时器运行时总能向该通道写入数据并停止定时器。换句话说,如果你没有从定时器读取数据,它不会泄漏;最终它会被垃圾回收。
定时器可用于停止长时间运行的进程:
func longProcess() {
timer := time.NewTimer(time.Second*10)
for {
processData()
select {
case <-timer.C:
// 2秒过去了
return
default:
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
以下示例展示了如何使用定时器限制函数返回所需的时间。如果计算在一秒内完成,则返回响应。如果计算时间较长,该函数会返回一个通道,调用者可以使用该通道接收结果。这个函数还展示了如何停止定时器:
func longComputation() (concurrent chan Result, result Result) {
timer: = time.NewTimer(time.Second)
concurrent = make(chan Result)
// 启动并发计算。其结果将从通道接收
go func() {
concurrent <- processData()
}()
// 等待结果可用,或定时器到期
select {
case result:=<-concurrent:
// 结果很快可用。停止定时器并返回结果。
timer.Stop()
return nil,result
case <-timer.C:
// 定时器在计算出结果之前到期。返回通道
return concurrent,Result{}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
请注意,定时器可能在调用timer.Stop()
之前刚刚到期。这没关系。定时器最终会到期并被垃圾回收。调用timer.Stop()
只是为了防止定时器在不必要的情况下继续保持活动状态。
提示 当另一个goroutine正在从定时器读取数据时,你不能并发调用 Timer.Stop 。因此,如果你必须调用Timer.Stop ,请从监听定时器通道的同一个goroutine中调用它。 |
---|
使用time.After
也可以实现同样的功能:
concurrent = make(chan Result)
// 启动并发计算。其结果将从通道接收
go func() {
concurrent <- processData()
}()
select {
case result:=<-concurrent:
return nil,result
case <-time.After(time.Second):
return concurrent,Result{}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
# 时间 ticker(Tickers)
使用time.Ticker
定期执行一项任务。你将定期通过通道接收一个信号。与time.Timer
不同,你必须注意如何处理time.Ticker
。如果你忘记停止time.Ticker
,当它超出作用域时,它不会被垃圾回收,这会导致泄漏。
# 如何操作……
- 使用
time.Ticker
创建一个新的time.Ticker
。 - 从
time.Ticker
的通道读取数据,以接收定期的“tick”信号。 - 当你使用完
time.Ticker
后,停止它。你不需要清空time.Ticker
的通道。
# 它的工作原理……
time.Ticker
用于周期性事件。常见的模式如下:
func poorMansClock(done chan struct{}) {
// 创建一个周期为1秒的新time.Ticker
ticker: = time.NewTicker(time.Second)
// 一旦完成,停止time.Ticker
defer ticker.Stop()
for {
select {
case <-done:
return
case <-ticker.C:
fmt.Println(time.Now())
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
如果你错过了“tick”信号会发生什么?如果你运行一个长时间的进程,导致你无法监听time.Ticker
的通道,就可能出现这种情况。当你再次开始监听时,time.Ticker
会发送大量的“tick”信号吗?
与time.Timer
类似,time.Ticker
使用容量为1的通道。因此,如果你不从通道读取数据,它最多只能存储一个“tick”信号。当你再次开始从通道监听时,你会立即收到错过的“tick”信号,当下一个周期到期时,你会收到下一个“tick”信号。例如,考虑以下程序,它每秒调用一次给定的函数:
func everySecond(f func(), done chan struct{}) {
// 创建一个周期为1秒的新`time.Ticker`
ticker:=time.NewTicker(time.Second)
start:=time.Now()
// 一旦完成,停止`time.Ticker`
defer ticker.Stop()
for {
select {
case <-done:
return
case <-ticker.C:
fmt.Println(time.Since(start).Milliseconds())
// 调用函数
f()
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
假设对f()
的第一次调用运行了10毫秒,但第二次调用运行了1.5秒。在f()
运行时,没有人从time.Ticker
的通道读取数据,因此会错过一个“tick”信号。一旦f()
返回,select
语句将立即读取这个错过的“tick”信号,500毫秒后,它将收到下一个“tick”信号。输出如下:
1000
2000
3500
4000
5000
2
3
4
5
**提示:**与
time.Timer
不同,你可以在从time.Ticker
的通道读取数据时并发停止它。