corn 是一个用于管理定时任务的库,用 Go 实现 Linux 中 crontab 这个命令的效果。除了 cron 以外,Go语言中还有另一个比较小巧、灵活的定时任务库,可以执行定时的、周期性的任务。但是它功能相对简单些,并且已经不维护了。如果有定时任务需求,还是建议使用cron

简单使用

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

import (
"fmt"
"time"

"github.com/robfig/cron/v3"
)

func main() {
c := cron.New()

c.AddFunc("@every 1s", func() {
fmt.Println("tick every 1 second")
})

c.Start()
time.Sleep(time.Second * 5)
}

使用非常简单,创建cron对象,这个对象用于管理定时任务。

调用cron对象的AddFunc()方法向管理器中添加定时任务。AddFunc()接受两个参数,参数 1 以字符串形式指定触发时间规则,参数 2 是一个无参的函数,每次触发时调用。@every 1s表示每秒触发一次,@every后加一个时间间隔,表示每隔多长时间触发一次。例如@every 1h表示每小时触发一次,@every 1m2s表示每隔 1 分 2 秒触发一次。time.ParseDuration()支持的格式都可以用在这里。

调用c.Start()启动定时循环。

注意一点,因为c.Start()启动一个新的 goroutine 做循环检测,我们在代码最后加了一行time.Sleep(time.Second * 5)防止主 goroutine 退出。

运行效果,每隔 1s 输出一行字符串:

1
2
3
4
5
6
$ go run main.go 
tick every 1 second
tick every 1 second
tick every 1 second
tick every 1 second
tick every 1 second

时间格式

与Linux 中crontab命令相似,cron库支持用 5 个空格分隔的域来表示时间。这 5 个域含义依次为:

  • Minutes:分钟,取值范围[0-59],支持特殊字符* / , -
  • Hours:小时,取值范围[0-23],支持特殊字符* / , -
  • Day of month:每月的第几天,取值范围[1-31],支持特殊字符* / , - ?
  • Month:月,取值范围[1-12]或者使用月份名字缩写[JAN-DEC],支持特殊字符* / , -
  • Day of week:周历,取值范围[0-6]或名字缩写[JUN-SAT],支持特殊字符* / , - ?

注意,月份和周历名称都是不区分大小写的。

特殊字符含义如下:

  • *:使用*的域可以匹配任何值,例如将月份域(第 4 个)设置为*,表示每个月;
  • /:用来指定范围的步长,例如将小时域(第 2 个)设置为3-59/15表示第 3 分钟触发,以后每隔 15 分钟触发一次,因此第 2 次触发为第 18 分钟,第 3 次为 33 分钟。。。直到分钟大于 59;
  • ,:用来列举一些离散的值和多个范围,例如将周历的域(第 5 个)设置为MON,WED,FRI表示周一、三和五;
  • -:用来表示范围,例如将小时的域(第 1 个)设置为9-17表示上午 9 点到下午 17 点(包括 9 和 17);
  • ?:只能用在月历和周历的域中,用来代替*,表示每月/周的任意一天。

了解规则之后,我们可以定义任意时间:

  • 30 * * * *:分钟域为 30,其他域都是*表示任意。每小时的 30 分触发;
  • 30 3-6,20-23 * * *:分钟域为 30,小时域的3-6,20-23表示 3 点到 6 点和 20 点到 23 点。3,4,5,6,20,21,22,23 时的 30 分触发;
  • 0 0 1 1 *:1(第 4 个) 月 1(第 3 个) 号的 0(第 2 个) 时 0(第 1 个) 分触发。

预定义时间规则

为了方便使用,cron预定义了一些时间规则:

  • @yearly:也可以写作@annually,表示每年第一天的 0 点。等价于0 0 1 1 *
  • @monthly:表示每月第一天的 0 点。等价于0 0 1 * *
  • @weekly:表示每周第一天的 0 点,注意第一天为周日,即周六结束,周日开始的那个 0 点。等价于0 0 * * 0
  • @daily:也可以写作@midnight,表示每天 0 点。等价于0 0 * * *
  • @hourly:表示每小时的开始。等价于0 * * * *

固定时间间隔

cron支持固定时间间隔,格式为:

1
@every <duration>

含义为每隔duration触发一次。<duration>会调用time.ParseDuration()函数解析,所以ParseDuration支持的格式都可以。

时区

默认情况下,所有时间都是基于当前时区的。当然我们也可以指定时区,有 2 两种方式:

  • 在时间字符串前面添加一个CRON_TZ= + 具体时区,东京时区为Asia/Tokyo,纽约时区为America/New_York
  • 创建cron对象时增加一个时区选项cron.WithLocation(location)locationtime.LoadLocation(zone)加载的时区对象,zone为具体的时区格式。或者调用已创建好的cron对象的SetLocation()方法设置时区。

使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func main() {
nyc, _ := time.LoadLocation("America/New_York")
c := cron.New(cron.WithLocation(nyc))
c.AddFunc("0 6 * * ?", func() {
fmt.Println("Every 6 o'clock at New York")
})

c.AddFunc("CRON_TZ=Asia/Tokyo 0 6 * * ?", func() {
fmt.Println("Every 6 o'clock at Tokyo")
})

c.Start()

for {
time.Sleep(time.Second)
}
}

Job接口

除了直接将无参函数作为回调外,cron还支持Job接口

1
2
3
4
// cron.go
type Job interface {
Run()
}

我们定义一个实现接口Job的结构:

1
2
3
4
5
6
7
type GreetingJob struct {
Name string
}

func (g GreetingJob) Run() {
fmt.Println("Hello ", g.Name)
}

调用cron对象的AddJob()方法将GreetingJob对象添加到定时管理器中:

1
2
3
4
5
6
7
func main() {
c := cron.New()
c.AddJob("@every 1s", GreetingJob{"dj"})
c.Start()

time.Sleep(5 * time.Second)
}

运行效果:

1
2
3
4
5
6
$ go run main.go 
Hello dj
Hello dj
Hello dj
Hello dj
Hello dj

实际上AddFunc()方法内部也调用了AddJob()方法。首先,cron基于func()类型定义一个新的类型FuncJob并实现Job接口:

1
2
3
4
5
6
// cron.go
type FuncJob func()

func (f FuncJob) Run() {
f()
}

AddFunc()方法中,将传入的回调转为FuncJob类型,然后调用AddJob()方法:

1
2
3
func (c *Cron) AddFunc(spec string, cmd func()) (EntryID, error) {
return c.AddJob(spec, FuncJob(cmd))
}

线程安全

cron会创建一个新的 goroutine 来执行触发回调。如果这些回调需要并发访问一些资源、数据,我们需要显式地做同步。

自定义时间格式

cron支持灵活的时间格式,如果默认的格式不能满足要求,我们可以自己定义时间格式。时间规则字符串需要cron.Parser对象来解析。

我们先来看看默认的解析器是如何工作的。

首先定义各个域:

1
2
3
4
5
6
7
8
9
10
11
12
// parser.go
const (
Second ParseOption = 1 << iota
SecondOptional
Minute
Hour
Dom
Month
Dow
DowOptional
Descriptor
)

除了Minute/Hour/Dom(Day of month)/Month/Dow(Day of week)外,还可以支持Second。相对顺序都是固定的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// parser.go
var places = []ParseOption{
Second,
Minute,
Hour,
Dom,
Month,
Dow,
}

var defaults = []string{
"0",
"0",
"0",
"*",
"*",
"*",
}

默认的时间格式使用 5 个域。

我们可以调用cron.NewParser()创建自己的Parser对象,以位格式传入使用哪些域,例如下面的Parser使用 6 个域,支持Second(秒):

1
2
3
parser := cron.NewParser(
cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor,
)

调用cron.WithParser(parser)创建一个选项传入构造函数cron.New(),使用时就可以指定秒了:

1
2
3
4
5
c := cron.New(cron.WithParser(parser))
c.AddFunc("1 * * * * *", func () {
fmt.Println("every 1 second")
})
c.Start()

这里时间格式必须使用 6 个域,顺序与上面的const定义一致。

因为上面的时间格式太常见了,cron定义了一个便捷的函数:

1
2
3
4
5
6
// option.go
func WithSeconds() Option {
return WithParser(NewParser(
Second | Minute | Hour | Dom | Month | Dow | Descriptor,
))
}

注意Descriptor表示对@every/@hour等的支持。有了WithSeconds(),我们不用手动创建Parser对象了:

1
c := cron.New(cron.WithSeconds())

选项

cron对象创建使用了选项模式,我们前面已经介绍了 3 个选项:

  • WithLocation:指定时区;
  • WithParser:使用自定义的解析器;
  • WithSeconds:让时间格式支持秒,实际上内部调用了WithParser

cron还提供了另外两种选项:

  • WithLogger:自定义Logger
  • WithChain:Job 包装器。

WithLogger

WithLogger可以设置cron内部使用我们自定义的Logger

1
2
3
4
5
6
7
8
9
10
11
func main() {
c := cron.New(
cron.WithLogger(
cron.VerbosePrintfLogger(log.New(os.Stdout, "cron: ", log.LstdFlags))))
c.AddFunc("@every 1s", func() {
fmt.Println("hello world")
})
c.Start()

time.Sleep(5 * time.Second)
}

上面调用cron.VerbosPrintfLogger()包装log.Logger,这个logger会详细记录cron内部的调度过程。

默认的Logger是什么样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// logger.go
var DefaultLogger Logger = PrintfLogger(log.New(os.Stdout, "cron: ", log.LstdFlags))

func PrintfLogger(l interface{ Printf(string, ...interface{}) }) Logger {
return printfLogger{l, false}
}

func VerbosePrintfLogger(l interface{ Printf(string, ...interface{}) }) Logger {
return printfLogger{l, true}
}

type printfLogger struct {
logger interface{ Printf(string, ...interface{}) }
logInfo bool
}

WithChain

Job 包装器可以在执行实际的Job前后添加一些逻辑:

  • 捕获panic
  • 如果Job上次运行还未结束,推迟本次执行;
  • 如果Job上次运行还未介绍,跳过本次执行;
  • 记录每个Job的执行情况。

我们可以将Chain类比为 Web 处理器的中间件。实际上就是在Job的执行逻辑外在封装一层逻辑。我们的封装逻辑需要写成一个函数,传入一个Job类型,返回封装后的Jobcron为这种函数定义了一个类型JobWrapper

1
2
// chain.go
type JobWrapper func(Job) Job

然后使用一个Chain对象将这些JobWrapper组合到一起:

1
2
3
4
5
6
7
type Chain struct {
wrappers []JobWrapper
}

func NewChain(c ...JobWrapper) Chain {
return Chain{c}
}

调用Chain对象的Then(job)方法应用这些JobWrapper,返回最终的Job

1
2
3
4
5
6
func (c Chain) Then(j Job) Job {
for i := range c.wrappers {
j = c.wrappers[len(c.wrappers)-i-1](j)
}
return j
}

注意应用JobWrapper的顺序。

总结

在本项目中其实并没有用到这么多与之相关的东西,当然还有更加详细的内容本文并没有记录。

本文就不放参考资料了,因为整篇博客都是照抄的别人的,原因是我破防了。