Background
本文所有的代码都在 abcdlsj/crone
从零实现一个初级 Cron 表达式解析器,只有以下几个字段规则:
# ┌───────────── minute (0–59)
# │ ┌───────────── hour (0–23)
# │ │ ┌───────────── day of the month (1–31)
# │ │ │ ┌───────────── month (1–12)
# │ │ │ │ ┌───────────── day of the week (0–6) (Sunday to Saturday;
# │ │ │ │ │ 7 is also Sunday on some systems)
# │ │ │ │ │
# │ │ │ │ │
# * * * * * <command to execute>
没有 yearly monthly weekly daily hourly reboot 等特殊规则。
| Entry | Description | Equivalent to |
|---|---|---|
@yearly (or @annually) |
Run once a year at midnight of 1 January | 0 0 1 1 * |
@monthly |
Run once a month at midnight of the first day of the month | 0 0 1 * * |
@weekly |
Run once a week at midnight on Sunday | 0 0 * * 0 |
@daily (or @midnight) |
Run once a day at midnight | 0 0 * * * |
@hourly |
Run once an hour at the beginning of the hour | 0 * * * * |
@reboot |
Run at startup | — |
其中字段规则
| Field | Required | Allowed values | Allowed special characters | Remarks |
|---|---|---|---|---|
| Minutes | Yes | 0–59 | * , - |
|
| Hours | Yes | 0–23 | * , - |
|
| Day of month | Yes | 1–31 | * , - ? L W |
? L W only in some implementations |
| Month | Yes | 1–12 or JAN–DEC | * , - |
|
| Day of week | Yes | 0–6 or SUN–SAT | * , - ? L # |
? L # only in some implementations |
| Year | No | 1970–2099 | * , - |
This field is not supported in standard/default implementations. |
可以直观看到 Cron 表达式字段都有明确的上下限,以及支持的 Special characters 都是 "*" "," "-"(这里只考虑一般规则)。
所以其实可以复用同一套规则,然后分别 Parse 就可以了。
Field definition
首先定义 field,以及实现一个 limit 方法,返回其上下限。
type field int
const (
minute field = iota
hour
day
month
weekday
)
func (f field) limit() (int, int) {
switch f {
case minute:
return 0, 59
case hour:
return 0, 23
case day:
return 1, 31
case month:
return 1, 12
case weekday:
return 0, 6
}
return 0, 0
}
Expr structure
Expr 的结构体该怎么定义呢,它本身应该有一个 expr 代表表达式字符串。
type Cronexpr struct {
expr string
}
这里以我的视角(一个 Cron 库使用者)的角度出发,我期望 Cron 库有下面的功能
New(string): 创建新的CronexprNext(): 获取接下来Cron触发的时间NextN(n): 获取接下来n个Cron触发的时间
这里分别是「创建」和「使用」,因为 Go 提倡使用 Channel 在 Goroutine 之间传递信息,这里还可以加一个方法。
Notify(ctx, outchan)类似于这样的函数定义,ctx用来控制函数的退出,会在Cron触发时发送到outchan
Match time
到目前为止,我都没有写到具体是怎样的思路去实现「获取 Cron 触发的时间」。
「获取」Cron 触发时间关键在于「判断」时间是否符合某个 Cron 表达式。
并且,不管是 weekday or day or month or hour or minute 都是根据规则「枚举」出符合要求的值,解析出枚举过程放在 parse 函数中。
(这里假设已经枚举出符合要求的一系列值)
实现 matches 结构体:
type Matches struct {
minute []int
hour []int
day []int
month []int
weekday []int
}
实现 Match 方法,判断输入 Time 是否符合(触发)
func (m Matches) Match(t time.Time) bool {
contains := func(arr []int, val int) bool {
for _, v := range arr {
if v == val {
return true
}
}
return false
}
return contains(m.minute, t.Minute()) &&
contains(m.hour, t.Hour()) &&
contains(m.day, t.Day()) &&
contains(m.month, int(t.Month())) &&
contains(m.weekday, int(t.Weekday()))
}
很简单的实现,只要 Time 都分别在各种类型枚举值内就代表这个时间符合要求。
Parse rule
parse 函数用于返回枚举值,然后保存在 matches 里,返回 []int(简化了上下限的检查)。
func parse(rule string, f field) ([]int, error) {
if len(rule) == 0 {
return nil, errors.New("empty spec")
}
specs := strings.Split(rule, ",")
matches := make([]int, 0)
low, high := f.limit()
for _, spec := range specs {
if spec == "*" {
for i := low; i < high; i++ {
matches = append(matches, i)
}
} else if strings.Contains(spec, "/") {
...get step...
...check...
for i := low; i < high; i += step {
matches = append(matches, i)
}
} else if strings.Contains(spec, "-") {
...get start & end...
...check...
for i := start; i <= end; i++ {
matches = append(matches, i)
}
} else {
val, err := strconv.Atoi(spec)
...check...
matches = append(matches, val)
}
}
return matches, nil
}
Wrap matches
因为使用表达式解析器的入口是 Cronexpr,所以 matches 应该是 Cronexpr 的字段。
func NewExpr(expr string) *Cronexpr {
return &Cronexpr{
expr: expr,
matches: newMatches(expr),
accurate: time.Minute,
}
}
func newMatches(expr string) Matches {
splits := strings.Split(expr, " ")
if len(splits) != 5 {
return Matches{}
}
mustParse := func(s string, f field) []int {
matches, err := parse(s, f)
if err != nil {
panic(err)
}
return matches
}
return Matches{
minute: mustParse(splits[0], minute),
hour: mustParse(splits[1], hour),
day: mustParse(splits[2], day),
month: mustParse(splits[3], month),
weekday: mustParse(splits[4], weekday),
}
}
这里利用辅助函数简化了代码(ps. MustXxx 在开源项目里很普遍)
Cronexpr 的 accurate 含义等下会解释
Functions
到了使用的入口函数了,Next() or NextN(n) 需要实现这样一个方法 nextN(n)
通过 matches 我们知道了,我们可以判断某个 Time 是否符合 Cronexpr 的触发时间。
那 nexnN(n) 里,我们就需要对「未来」的时间进行枚举,然后通过 matches 判断是否符合。
这里还有一个问题,「未来时间」的间隔应该是多少呢,因为我们表达式里面的最小单位是 minute。
所以枚举时间的间隔应该是 1 * minute,其中 Cronexpr.accurate 就是代表这个。
为什么不写成常量呢?
因为之后还可以实现支持
second字段的Cronexpr
这里还因为枚举未来时间因为需要一个基准(zero time)值,而这个时间对于 nextN() 这样的「最小」函数最好是可以外部传入的,所以加上了 z time.Time。
当然了,对于 Next() 和 NextN() 这样的函数 zero time 也是可以外部传入的,因为 Next() 这个语义并没有明显包括代表当前时间之后的含义。
所以 nextN() 应该是这样:
func (e *Cronexpr) nextN(z time.Time, n int) []time.Time {
ts := make([]time.Time, 0, n)
lt := z
for i := 0; i < n; i++ {
n1 := e.next1(lt)
ts = append(ts, n1)
lt = n1
}
return ts
}
nextN 又去调用 next1,这是 next1 实现(这里 next1 的逻辑做成 nextN 的内部函数我感觉甚至更好)。
func (e *Cronexpr) next1(z time.Time) time.Time {
for t := z.Add(e.accurate); t.Before(END); t = t.Add(e.accurate) {
if e.matches.Match(t) {
return t
}
}
return END
}
这里 for range 的时候初始值 t 加上 accurate 是为了防止如果 z 符合要求函数就会一直直接返回 true,导致 nextN 里的 for 循环就会返回同样的值。
Notify 函数实现主要是多了 ctx deadline 的判断退出,这里可以简单的使用 time ticker 来获取需要检查的时间点。
func (e *Cronexpr) Notify(ctx context.Context, out chan<- time.Time) {
ticker := time.NewTicker(e.accurate)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case t := <-ticker.C:
if e.matches.Match(t) {
out <- t
}
}
}
}
Conclusion
Scheduler 的部分可以看源码,这部分实现比较简单。
实现 Cronexpr 解析器还是很有意思的,这是一个简单的小项目。
感谢阅读!