1.3 便捷的时间工具
平时在查看原始数据时,有时候要看格式化后的个性化时间,又或是直接看时间戳等等,这些都是我们时不时会接触到的。更甚的是,如果不同系统中的时间格式不一样,比较规则不一样,那你每用一次都要做一轮转换。又有可能是,你的业务接口的入参开始时间和结束时间是一个时间戳的值,在通常情况下,你是不是要靠外部的一些快捷站点,又或是内部的 Web 站点去获取、调整呢,这其实还是有些麻烦的,要连上网,要输入站点地址,还要鼠标操作….,这显然不符合我们的小极客思维,因此在本章节我们将做一个时间相关的工具,尽可能的优化我们日常获取时间的相关手工行为效率。
1.3.1 获取时间
我们在项目的 internal
目录下新建 timer 目录,并新建 time.go 文件,目录结构如下:
├── internal
│ ├── timer
│ │ └── time.go
在 time.go 文件中写入如下代码:
func GetNowTime() time.Time {
return time.Now()
}
我们在 GetNowTime 方法中对标准库 time 的 Now 方法进行了封装,用于返回当前本地时间的 Time 对象,此处的封装主要是为了便于后续对 Time 对象做进一步的统一处理,因为可能会涉及时区的一些问题处理。
1.3.2 时间推算
接下来针对时间推算,我们继续在 time.go 文件中新增方法,如下:
func GetCalculateTime(currentTimer time.Time, d string) (time.Time, error) {
duration, err := time.ParseDuration(d)
if err != nil {
return time.Time{}, err
}
return currentTimer.Add(duration), nil
}
在上述代码中,我们比较核心的是调用了两个方法来处理,分别是 ParseDuration 和 Add 方法,ParseDuration 方法用于在字符串中解析出 duration(持续时间),其支持的有效单位有"ns”, “us” (or “µ s”), “ms”, “s”, “m”, “h”,例如:“300ms”, “-1.5h” or “2h45m”。而在 Add 方法中,我们可以将其返回的 duration 传入,就可以得到当前 timer 时间加上 duration 后所得到的最终时间。
可能会有的人会有疑惑,为什么不直接用 Add 方法来做,还要转多一道 ParseDuration 方法,效率会不会没有那么好,实际上在我们的这个时间工具中,你预先并不知道他传入的值是什么,因此利用 ParseDuration 方法先处理是最好的办法之一。
如果你预先知道准确的 duration,也不需要适配,那你就可以直接使用 Add 方法和 Duration 类型进行处理,例如:
const (
Nanosecond Duration = 1
Microsecond = 1000 * Nanosecond
Millisecond = 1000 * Microsecond
Second = 1000 * Millisecond
Minute = 60 * Second
Hour = 60 * Minute
)
...
timer.GetNowTime().Add(time.Second * 60)
1.3.3 初始化子命令
在完成了获取时间和推算时间的处理方法后,我们需要将其集成到我们的子命令中,也就是创建项目的 time 子命令,我们需要项目的 cmd
目录下新建 time.go 文件,新增如下代码:
var calculateTime string
var duration string
var timeCmd = &cobra.Command{
Use: "time",
Short: "时间格式处理",
Long: "时间格式处理",
Run: func(cmd *cobra.Command, args []string) {},
}
完成 time 子命令编写后,再到项目的 cmd/root.go
文件中进行相应的注册即可:
func init() {
rootCmd.AddCommand(wordCmd)
rootCmd.AddCommand(timeCmd)
}
每一个子命令,都是需要到 rootCmd 中进行注册的,否则将无法使用。
1.3.3.1 time now 子命令
接下来针对获取当前时间,我们在 time 子命令下再新增一个 now 子命令,用于处理其具体的逻辑,在 time.go 文件中新增如下代码:
var nowTimeCmd = &cobra.Command{
Use: "now",
Short: "获取当前时间",
Long: "获取当前时间",
Run: func(cmd *cobra.Command, args []string) {
nowTime := timer.GetNowTime()
log.Printf("输出结果: %s, %d", nowTime.Format("2006-01-02 15:04:05"), nowTime.Unix())
},
}
我们在获取当前时间的 Time 对象后,一共输出了两个不同格式的时间,分别如下:
-
第一个格式:通过调用 Format 方法设定约定的 2006-01-02 15:04:05 格式来进行时间的标准格式化。
-
第二个格式:通过调用 Unix 方法返回 Unix 时间,就是我们通俗说的时间戳,其值为自 UTC 1970 年 1 月 1 日起经过的秒数。
如果你想要定义其它时间格式的话,标准库 time 中还支持(内部预定义)如下格式:
const (
ANSIC = "Mon Jan _2 15:04:05 2006"
UnixDate = "Mon Jan _2 15:04:05 MST 2006"
RubyDate = "Mon Jan 02 15:04:05 -0700 2006"
RFC822 = "02 Jan 06 15:04 MST"
RFC822Z = "02 Jan 06 15:04 -0700" // RFC822 with numeric zone
RFC850 = "Monday, 02-Jan-06 15:04:05 MST"
RFC1123 = "Mon, 02 Jan 2006 15:04:05 MST"
RFC1123Z = "Mon, 02 Jan 2006 15:04:05 -0700" // RFC1123 with numeric zone
RFC3339 = "2006-01-02T15:04:05Z07:00"
...
)
可以像这样子使用这些预定义格式,例如:
t := time.Now().Format(time.RFC3339)
1.3.3.2 time calc 子命令
接下来针对时间推算的处理,我们在 time 子命令下再新增一个 calc 子命令,在 time.go 文件中继续新增如下代码:
var calculateTimeCmd = &cobra.Command{
Use: "calc",
Short: "计算所需时间",
Long: "计算所需时间",
Run: func(cmd *cobra.Command, args []string) {
var currentTimer time.Time
var layout = "2006-01-02 15:04:05"
if calculateTime == "" {
currentTimer = timer.GetNowTime()
} else {
var err error
space := strings.Count(calculateTime, " ")
if space == 0 {
layout = "2006-01-02"
}
if space == 1 {
layout = "2006-01-02 15:04"
}
currentTimer, err = time.Parse(layout, calculateTime)
if err != nil {
t, _ := strconv.Atoi(calculateTime)
currentTimer = time.Unix(int64(t), 0)
}
}
t, err := timer.GetCalculateTime(currentTimer, duration)
if err != nil {
log.Fatalf("timer.GetCalculateTime err: %v", err)
}
log.Printf("输出结果: %s, %d", t.Format(layout), t.Unix())
},
}
在上述代码中,一共支持了三种常用时间格式的处理,分别是:时间戳、2006-01-02 以及 2006-01-02 15:04:05。
在时间格式处理上,我们调用了 strings.Contains 方法,对空格进行了包含判断,若存在则按既定的 2006-01-02 15:04:05 格式进行格式化,否则以 2006-01-02 格式进行处理,若出现异常错误,则直接按时间戳的方式进行转换处理。
在最后我们针对 time 子命令进行 now、calc 的子命令和所需的命令行参数进行注册即可,如下:
func init() {
timeCmd.AddCommand(nowTimeCmd)
timeCmd.AddCommand(calculateTimeCmd)
calculateTimeCmd.Flags().StringVarP(&calculateTime, "calculate", "c", "", ` 需要计算的时间,有效单位为时间戳或已格式化后的时间 `)
calculateTimeCmd.Flags().StringVarP(&duration, "duration", "d", "", ` 持续时间,有效时间单位为"ns", "us" (or "µ s"), "ms", "s", "m", "h"`)
}
1.3.4 验证
在完成功能开发后,我们将进行功能验证,在下述命令分别获取了当前的时间,以及推算了所传入时间的后五分钟和前两小时,输出结果如下:
$ go run main.go time now
输出结果: 2029-09-04 12:02:33, 1883188953
$ go run main.go time calc -c="2029-09-04 12:02:33" -d=5m
输出结果: 2029-09-04 12:07:33, 1883218053
$ go run main.go time calc -c="2029-09-04 12:02:33" -d=-2h
输出结果: 2029-09-04 10:02:33, 1883210553
需要注意的是,这里的时间我进行了虚构,因此你需要根据本地的实际输出时间进行结果确定和验证。
1.3.5 有没有时区问题
如果你在上一步的验证命令中,没有遇到少了八小时的之类的问题,那你是相对顺利的。但是这也有一个问题,可能以后你会忽略掉这一个”坑“,那就是时区的问题,实际上在使用标准库 time 时是存在遇到时区问题的风险的,因此我们需要对这个问题注意,接下来我们将针对这块内容进行介绍,并作出一定的调整和设置。
不同的国家(有时甚至是同一个国家内的不同地区)使用着不同的时区。对于要输入和输出时间的程序来说,必须对系统所处的时区加以考虑。而在 Go 语言中使用 Location 来表示地区相关的时区,一个 Location 可能表示多个时区。
在标准库 time 上,提供了 Location 的两个实例:Local 和 UTC。Local 代表当前系统本地时区;UTC 代表通用协调时间,也就是零时区,在默认值上,标准库 time 使用的是 UTC 时区。
1.3.5.1 Local 是如何表示本地时区的
时区信息既浩繁又多变,Unix 系统以标准格式存于文件中,这些文件位于 /usr/share/zoneinfo,而本地时区可以通过 /etc/localtime 获取,这是一个符号链接,指向 /usr/share/zoneinfo 中某一个时区。比如我本地电脑指向的是:/var/db/timezone/zoneinfo/Asia/Shanghai。
因此在初始化 Local 时,标准库 time 通过读取/etc/localtime 就可以获取到系统的本地时区,如下:
tz, ok := syscall.Getenv("TZ")
switch {
case !ok:
z, err := loadLocation("localtime", []string{"/etc/"})
if err == nil {
localLoc = *z
localLoc.name = "Local"
return
}
case tz != "" && tz != "UTC":
if z, err := loadLocation(tz, zoneSources); err == nil {
localLoc = *z
return
}
}
1.3.5.2 如何设置时区
既然发现了这个问题,那么有什么办法处理呢,我们可以通过标准库 time 中的 LoadLocation 方法来根据名称获取特定时区的 Location 实例,原型如下:
func LoadLocation(name string) (*Location, error)
在该方法中,如果所传入的 name 是"UTC"或为空,返回 UTC;如果 name 是 “Local”,返回当前的本地时区 Local;否则 name 应该是 IANA 时区数据库(IANA Time Zone Database,简称 tzdata)里有记录的地点名(该数据库记录了地点和对应的时区),如 “America/New_York”。
另外要注意的是 LoadLocation 方法所需要的时区数据库可能不是所有系统都有提供,特别是在非 Unix 系统,此时 LoadLocation
方法会查找环境变量 ZONEINFO 指定目录或解压该变量指定的 zip 文件(如果有该环境变量);然后查找 Unix 系统约定的时区数据安装位置。最后如果都找不到,就会查找 $GOROOT/lib/time/zoneinfo.zip
里的时区数据库,简单来讲就是会在不同的约定路径中尽可能的查找到所需的时区数据库。
那么为了保证我们所获取的时间,与我们所期望的时区一致,我们要对获取时间的代码进行修改,设置当前时区为 Asia/Shanghai,修改如下:
func GetNowTime() time.Time {
location, _ := time.LoadLocation("Asia/Shanghai")
return time.Now().In(location)
}
1.3.5.3 要注意的 time.Parse/Format
在前面的实践代码中,我们用到了 time.Format 方法,与此还有一个相对应的方法并没有介绍到它,就是 time.Parse 方法,Parse 方法会解析格式化的字符串并返回它表示的时间值,它非常的常见,并且有一个非常需要注意的点。首先我们一起看看下面这个示例程序,如下:
func main() {
location, _ := time.LoadLocation("Asia/Shanghai")
inputTime := "2029-09-04 12:02:33"
layout := "2006-01-02 15:04:05"
t, _ := time.Parse(layout, inputTime)
dateTime := time.Unix(t.Unix(), 0).In(location).Format(layout)
log.Printf("输入时间:%s,输出时间:%s", inputTime, dateTime)
}
那么你觉得这个示例程序的输出时间的结果是什么呢,还是 2029-09-04 12:02:33 吗,我们一起来看看最终的输出结果,如下:
输入时间:2029-09-04 12:02:33,输出时间:2029-09-04 20:02:33
从输出结果上来看,输入和输出时间竟然相差了八个小时,这显然是时区的设置问题,但是这里你可能又打起了嘀咕,明明在调用 Format 方法前我们已经设置了时区…这究竟是为什么呢?
实际上这与 Parse 方法有直接关系,因为 Parse 方法会尝试在入参的参数中中分析并读取时区信息,但是如果入参的参数没有指定时区信息的话,那么就会默认使用 UTC 时间。因此在这种情况下我们要采用 ParseInLocation 方法,指定时区就可以解决这个问题,如下:
t, _ := time.ParseInLocation(layout, inputTime, location)
dateTime := time.Unix(t.Unix(), 0).In(location).Format(layout)
也就是所有解析与格式化的操作都最好指定时区信息,否则当你遇到时区问题的时候,并且已经上线,那么后期再进行数据清洗就比较麻烦了。
1.3.5.4 我的系统时区是对的
我们常常会说,程序运行在我的本地是正常的…这个经典答复,在时区上好像又是说的过去。实际上,我们常常在开发时,用的可能是本地或预装好的开发环境,时区往往都是设置正确(符合我们东八区的需求)的,你可以在本地查看 localtime 文件,如下:
$ cat /etc/localtime
...
CST-8
你会发现实际上输出的就是 CST-8,也就是中国标准时间,UTC+8,因此你在本地不需要设置时区,你也不会发现异样。但是,到了其它部署环境就不一定了,举一个例子,在 Kubernetes、Docker 盛行的现在,你被编译后的 Go 程序很有可能就运行在 Docker 中,假设该镜像并没有经过时区调整,你在编译和启动时也没有指定时区,那么你就会遇到很多问题,像是日志的写入时间不对,标准库 time 的转换存在问题,又或是数据库写入的时候有问题…如果是遇到事故时,才察觉到这个问题,那就非常麻烦了。
因此确保你的所有部署环境的系统时区是正确的,这个能够给你基本的保障。
但你以为这就万无一失了吗,并不,例如当你所部署的环境并不存在所设置时区的时区数据库时,也会导致 fallback 到 UTC 时区,因此与对接的运维人员确保部署时区的各方面设置是非常重要的。
1.3.6 为什么是 2006-01-02 15:04:05
另外可能你已经注意到 2006-01-02 15:04:05 这个格式字符串了,这是很多刚学 Go 语言的小伙伴会感到疑惑的点之一,它是什么,怎么和其它语言的表示方式不一样,为什么是 2006-01-02 15:04:05,这是随便写的时间点吗,甚至还曾经有传言说这是 Go 语言的诞生时间…
实际上,2006-01-02 15:04:05 是一个参考时间的格式,也就是其它语言中 Y-m-d H:i:s
格式,在功能上用于时间的格式化处理,这个我们在前面章节中已经进行过验证。
那么为什么要用 2006-01-02 15:04:05 呢,其实这些”数字“是有意义的,在 Go 语言中强调必须显示参考时间的格式,因此每个布局字符串都是一个时间戳的表示,并非随便写的时间点,如果你觉得记忆困难,可参见官方例子中的如下方式:
Jan 2 15:04:05 2006 MST
1 2 3 4 5 6 -7
而转换到 2006-01-02 15:04:05 的时间格式,我们也可以将其记忆为 2006 年 1 月 2 日 3 点 4 分 5 秒。
1.3.7 小结
在 Go 语言中,标准库 time 的各类问题或疑问,是很多刚入门的小伙伴会疑惑的,尤其是在时区设置、格式化时间、2006-01-02 15:04:05 的问题更是来一个,踩一个坑,也有很多的讲解不清的。
因此在本章节,我们在基于标准库 time 完成的时间工具的需求上,还进行了进一步的说明,争取让你能够对常见问题心里有底,知道为什么,是怎么出现的。