歡迎來到Linux教程網
Linux教程網
Linux教程網
Linux教程網
您现在的位置: Linux教程網 >> UnixLinux >  >> Linux基礎 >> 關於Linux

cron表達式解析 + robfig/cron 源碼剖析

robfiig/cron 源碼剖析

Cron 表達式

參考wiki
https://en.wikipedia.org/wiki/Cron

robfiig/cron項目信息

下載地址

https://github.com/robfig/cron.git

文件目錄講解

constantdelay.go      #一個最簡單的秒級別定時系統。與cron無關
constantdelay_test.go #測試
cron.go               #Cron系統。管理一系列的cron定時任務(Schedule Job)
cron_test.go          #測試
doc.go                #說明文檔
LICENSE               #授權書 
parser.go             #解析器,解析cron格式字符串城一個具體的定時器(Schedule)
parser_test.go        #測試
README.md             #README
spec.go               #單個定時器(Schedule)結構體。如何計算自己的下一次觸發時間
spec_test.go          #測試

robfiig/cron 目前實現的需求

doc.go

CRON Expression Format
A cron expression represents a set of times, using 6 space-separated fields.
Field name   | Mandatory? | Allowed values  | Allowed special characters
----------   | ---------- | --------------  | --------------------------
Seconds      | Yes        | 0-59            | * / , -
Minutes      | Yes        | 0-59            | * / , -
Hours        | Yes        | 0-23            | * / , -
Day of month | Yes        | 1-31            | * / , - ?
Month        | Yes        | 1-12 or JAN-DEC | * / , -
Day of week  | Yes        | 0-6 or SUN-SAT  | * / , - ?
Predefined schedules

You may use one of several pre-defined schedules in place of a cron expression.

Entry                  | Description                                | Equivalent To
-----                  | -----------                                | -------------
@yearly (or @annually) | Run once a year, midnight, Jan. 1st        | 0 0 0 1 1 *
@monthly               | Run once a month, midnight, first of month | 0 0 0 1 * *
@weekly                | Run once a week, midnight on Sunday        | 0 0 0 * * 0
@daily (or @midnight)  | Run once a day, midnight                   | 0 0 0 * * *
@hourly                | Run once an hour, beginning of hour        | 0 0 * * * *

可以看到這裡並沒有實現 L , W , # 這些特殊字符。 至於原因,下面將具體實現代碼的時候會給出。

cron表達式解析

cron 的BNF表達式以及引出的解析方式

首先,讓我試著使用EBNF來定義下cron 表達式(不是很嚴謹。。。)


      ::= "1" | "2"  | "3" | "4" | "5" | "6" | "7" | "8" | "9"
           ::=  | "0"
        ::=  {} 
   ::= "*" |   |   "-" |  "/" 
     ::=  | "?" | "L" | "W" |  "#"
          ::=  | ;
         ::= {","}  ;
          ::= " "" "" "" "" "

至此, 如果我打算解析一個cron表達式, 應該遵循以下步驟 :

cron利用空白拆解出獨立的itemsitems利用,拆解出itemitem 利用窮舉法一一檢測( 符合且僅符合下面的一條才能合法) :
是否僅有一個字符是 * 或者 。 是否可以使用/或者-分解為倆數字 。 是否以數字加L或者W結尾。 純數字。 將規則一一描述,完成規則解析

如何表示一個獨立的規則

cron表達式是用來表示一系列時間的,而時間是無法逃脫自己的區間的 , 分,秒 0 - 59 , 時 0 - 23 , 天/月 0 - 31 , 天/周 0 - 6 , 月0 - 11 。 這些本質上都是一個點集合,或者說是一個整數區間。 那麼對於任意的整數區間 , 可以描述cron的如下部分規則。

* | ? 任意 , 對應區間上的所有點。 ( 額外注意 日/周 , 日 / 月 的相互干擾。) 純數字 , 對應一個具體的點。 / 分割的兩個數字 a , b, 區間上符合 a + n * b 的所有點 ( n >= 0 )。 - 分割的兩個數字, 對應這兩個數字決定的區間內的所有點。 L | W 需要對於特定的時間特殊判斷, 無法通用的對應到區間上的點。

至此, robfig/cron為什麼不支持 L | W的原因已經明了了。去除這兩條規則後, 其余的規則其實完全可以使用點的窮舉來通用表示。 考慮到最大的區間也不過是60個點,那麼使用一個uint64的整數的每一位來表示一個點便很合適了。下面是robfig/cron的表示方法 :

/* 
   ------------------------------------------------------------
   第64位標記任意 , 用於 日/周 , 日 / 月 的相互干擾。
   63 - 0 為 表示區間 [63 , 0] 的 每一個點。
   ------------------------------------------------------------ 

   假設區間是 0 - 63 , 則有如下的例子 : 

   比如  0/3 的表示如下 : 
   * / ?       
   +---+--------------------------------------------------------+
   | 0 | 1 0 0 1 0 0 1  ~~  ~~                    1 0 0 1 0 0 1 |
   +---+--------------------------------------------------------+   
        63 ~ ~                                           ~~ 0 

   比如  2-5 的表示如下 : 
   * / ?       
   +---+--------------------------------------------------------+
   | 0 | 0 0 0 0 ~  ~      ~~            ~    0 0 0 1 1 1 1 0 0 |
   +---+--------------------------------------------------------+   
        63 ~ ~                                           ~~ 0 

  比如  * 的表示如下 : 
   * / ?       
   +---+--------------------------------------------------------+
   | 1 | 1 1 1 1 1 ~  ~                  ~    1 1 1 1 1 1 1 1 1 |
   +---+--------------------------------------------------------+   
        63 ~ ~                                           ~~ 0 
*/

定時器的基本功能

一個時間是否符合規則

有一個規則後, 判斷一個時間點是否符合規則其實就是對應位是否為1 。

預判斷下一個符合規則的時間

給定一個時間後, 尋找下一個符合符合規則的時間也很簡單 :

從大到小,依次尋找每個字段的下一個符合條件的值。 對於每一個時間字段,依次加一判斷是否符合條件。

由於區間小,這個遍歷的效率是很高的, 但是要注意, 不要一直查找下去, 最最悲觀的情況大概是閏年的2月29 。 只要對未來的探究超過5年就意味著是個非法的表達式。

robfiig/cron 源碼剖析

讓我將上面的講解一一的對應到具體的代碼中 :

基本數據結構體

#表示cron規則的結構體

spec.go

// 每個時間字段是一個uint64
type SpecSchedule struct {
    Second, Minute, Hour, Dom, Month, Dow uint64
}
//對64位 任意 的標識
const (
    // Set the top bit if a star was included in the expression.
    starBit = 1 << 63
)

各個時間字段的區間 , 名字

spec.go

type bounds struct {
    min, max uint
    names    map[string]uint
}



// The bounds for each field.
var (
    seconds = bounds{0, 59, nil}
    minutes = bounds{0, 59, nil}
    hours   = bounds{0, 23, nil}
    dom     = bounds{1, 31, nil}
    months  = bounds{1, 12, map[string]uint{
        "jan": 1,
        "feb": 2,
        "mar": 3,
        "apr": 4,
        "may": 5,
        "jun": 6,
        "jul": 7,
        "aug": 8,
        "sep": 9,
        "oct": 10,
        "nov": 11,
        "dec": 12,
    }}
    dow = bounds{0, 6, map[string]uint{
        "sun": 0,
        "mon": 1,
        "tue": 2,
        "wed": 3,
        "thu": 4,
        "fri": 5,
        "sat": 6,
    }}
)

將字符串解析為SpecSchedule

parser.go

package cron

import (
    "fmt"
    "log"
    "math"
    "strconv"
    "strings"
    "time"
)

// 將字符串解析成為SpecSchedule 。 SpecSchedule符合Schedule接口
func Parse(spec string) (_ Schedule, err error) {
    // 處理panic 。 不讓成語crash。 而是當作error輸出
    defer func() {
        if recovered := recover(); recovered != nil {
            err = fmt.Errorf("%v", recovered)
        }
    }()
    // 直接處理特殊的特殊的字符串, 
    if spec[0] == '@' {
        return parseDescriptor(spec), nil
    }

    // cron利用空白拆解出獨立的items。 
    fields := strings.Fields(spec)
    if len(fields) != 5 && len(fields) != 6 {
        log.Panicf("Expected 5 or 6 fields, found %d: %s", len(fields), spec)
    }

    // 添加缺省的 日/周
    if len(fields) == 5 {
        fields = append(fields, "*")
    }
    // 按照規則解析每個items
    schedule := &SpecSchedule{
        Second: getField(fields[0], seconds),
        Minute: getField(fields[1], minutes),
        Hour:   getField(fields[2], hours),
        Dom:    getField(fields[3], dom),
        Month:  getField(fields[4], months),
        Dow:    getField(fields[5], dow),
    }

    return schedule, nil
}

// 解析items 。
func getField(field string, r bounds) uint64 {
    var bits uint64
    // items利用 "," 拆解出 item 。
    ranges := strings.FieldsFunc(field, func(r rune) bool { return r == ',' })
    for _, expr := range ranges {
        // 利用窮舉法一一檢測
        bits |= getRange(expr, r)
    }
    return bits
}


// 利用窮舉法一一檢測
func getRange(expr string, r bounds) uint64 {

    var (
        start, end, step uint
        rangeAndStep     = strings.Split(expr, "/")
        lowAndHigh       = strings.Split(rangeAndStep[0], "-")
        singleDigit      = len(lowAndHigh) == 1
    )

    var extra_star uint64
    //是否僅有一個字符是 * 或者 ?。 
    if lowAndHigh[0] == "*" || lowAndHigh[0] == "?" {
        start = r.min
        end = r.max
        extra_star = starBit
    } else {
        //是否可以"-"分解為倆數字 
        start = parseIntOrName(lowAndHigh[0], r.names)
        switch len(lowAndHigh) {
        case 1:
            end = start
        case 2:
            end = parseIntOrName(lowAndHigh[1], r.names)
        default:
            log.Panicf("Too many hyphens: %s", expr)
        }
    }
    //是否可以"/"分解為倆數字 
    switch len(rangeAndStep) {
    case 1:
        step = 1
    case 2:
        step = mustParseInt(rangeAndStep[1])

        // Special handling: "N/step" means "N-max/step".
        if singleDigit {
            end = r.max
        }
    default:
        log.Panicf("Too many slashes: %s", expr)
    }
    //轉化為點 。 
    if start < r.min {
        log.Panicf("Beginning of range (%d) below minimum (%d): %s", start, r.min, expr)
    }
    if end > r.max {
        log.Panicf("End of range (%d) above maximum (%d): %s", end, r.max, expr)
    }
    if start > end {
        log.Panicf("Beginning of range (%d) beyond end of range (%d): %s", start, end, expr)
    }

    return getBits(start, end, step) | extra_star
}

// 輔助函數 。 解析預定義的名字或者數字
func parseIntOrName(expr string, names map[string]uint) uint {
    if names != nil {
        if namedInt, ok := names[strings.ToLower(expr)]; ok {
            return namedInt
        }
    }
    return mustParseInt(expr)
}

//輔助函數 。 字符串 - 數字
func mustParseInt(expr string) uint {
    num, err := strconv.Atoi(expr)
    if err != nil {
        log.Panicf("Failed to parse int from %s: %s", expr, err)
    }
    if num < 0 {
        log.Panicf("Negative number (%d) not allowed: %s", num, expr)
    }

    return uint(num)
}

// 輔助函數 具體的將每個點設置好
func getBits(min, max, step uint) uint64 {
    var bits uint64

    // If step is 1, use shifts.
    if step == 1 {
        return ^(math.MaxUint64 << (max + 1)) & (math.MaxUint64 << min)
    }

    // Else, use a simple loop.
    for i := min; i <= max; i += step {
        bits |= 1 << i
    }
    return bits
}

// 輔助函數 。 設置區間的點 + 任意標志
func all(r bounds) uint64 {
    return getBits(r.min, r.max, 1) | starBit
}

// 解析預定義的名字
func parseDescriptor(spec string) Schedule {
    switch spec {
    case "@yearly", "@annually":
        return &SpecSchedule{
            Second: 1 << seconds.min,
            Minute: 1 << minutes.min,
            Hour:   1 << hours.min,
            Dom:    1 << dom.min,
            Month:  1 << months.min,
            Dow:    all(dow),
        }

    case "@monthly":
        return &SpecSchedule{
            Second: 1 << seconds.min,
            Minute: 1 << minutes.min,
            Hour:   1 << hours.min,
            Dom:    1 << dom.min,
            Month:  all(months),
            Dow:    all(dow),
        }

    case "@weekly":
        return &SpecSchedule{
            Second: 1 << seconds.min,
            Minute: 1 << minutes.min,
            Hour:   1 << hours.min,
            Dom:    all(dom),
            Month:  all(months),
            Dow:    1 << dow.min,
        }

    case "@daily", "@midnight":
        return &SpecSchedule{
            Second: 1 << seconds.min,
            Minute: 1 << minutes.min,
            Hour:   1 << hours.min,
            Dom:    all(dom),
            Month:  all(months),
            Dow:    all(dow),
        }

    case "@hourly":
        return &SpecSchedule{
            Second: 1 << seconds.min,
            Minute: 1 << minutes.min,
            Hour:   all(hours),
            Dom:    all(dom),
            Month:  all(months),
            Dow:    all(dow),
        }
    }

    const every = "@every "
    if strings.HasPrefix(spec, every) {
        duration, err := time.ParseDuration(spec[len(every):])
        if err != nil {
            log.Panicf("Failed to parse duration %s: %s", spec, err)
        }
        return Every(duration)
    }

    log.Panicf("Unrecognized descriptor: %s", spec)
    return nil
}

預判斷下一個符合規則的時間

spec.go

func (s *SpecSchedule) Next(t time.Time) time.Time {
    // 秒級別的取整
    t = t.Add(1*time.Second - time.Duration(t.Nanosecond())*time.Nanosecond)

    // 判斷一個字段是否被累加,如果是, 那麼它的下一級別的字段需要歸 0 。
    added := false

    //到未來的探尋不超過5年
    yearLimit := t.Year() + 5
    // 下一級別的字段累加到重置,需要重新累加上一級別的字段的時候的goto點
    // 比如要找每個月的31號的時候, 4月是符合月份字段的規定的,但是4月的沒有31號。 遍歷盡4月的每一天後,只能請求重新累加月份。
WRAP:
    if t.Year() > yearLimit {
        return time.Time{}
    }
    // 月
    for 1< 0
        dowMatch bool = 1< 0
    )

    if s.Dom&starBit > 0 || s.Dow&starBit > 0 {
        return domMatch && dowMatch
    }
    return domMatch || dowMatch
}

計時任務管理類 cron.go

這裡已經和cron無關了, 僅僅是如何能夠運行一個穩定的, 以維護的, 方便使用的定時任務管理類。

對計時器的抽象

cron.go

type Schedule interface {
    //給定一個時間,給出下一個執行的時間
    Next(time.Time) time.Time
}

對任務的抽象

cron.go

type Job interface {
    Run()
}

一個計時器任務

cron.go

type Entry struct {
    // 、計時器
    Schedule Schedule
    // 下次執行時間
    Next time.Time
    // 上次執行時間
    Prev time.Time
    // 任務
    Job Job
}

管理者

cron.go


type Cron struct {
    entries  []*Entry         // 任務們
    stop     chan struct{}    // 叫停止的途徑
    add      chan *Entry      // 添加新任務的方式
    snapshot chan []*Entry    // 請求獲取任務快照的方式
    running  bool             // 是否運行中
    ErrorLog *log.Logger      // 出錯日志
}

// 開始和結束全部任務
func (c *Cron) Start() {
    c.running = true
    go c.run()
}
func (c *Cron) Stop() {
    if !c.running {
        return
    }
    c.stop <- struct{}{}
    c.running = false
}

//運行接口
func (c *Cron) run() {
    // Figure out the next activation times for each entry.
    now := time.Now().Local()
    for _, entry := range c.entries {
        entry.Next = entry.Schedule.Next(now)
    }
    // 無限循環
    for {
        //通過對下一個執行時間進行排序,判斷那些任務是下一次被執行的,防在隊列的前面
        sort.Sort(byTime(c.entries)) 

        var effective time.Time
        if len(c.entries) == 0 || c.entries[0].Next.IsZero() {
            // If there are no entries yet, just sleep - it still handles new entries
            // and stop requests.
            effective = now.AddDate(10, 0, 0)
        } else {
            effective = c.entries[0].Next
        }

        select {
        case now = <-time.After(effective.Sub(now)):
            // 運行需要執行的任務
            for _, e := range c.entries {
                if e.Next != effective {
                    break
                }
                go c.runWithRecovery(e.Job)
                e.Prev = e.Next
                e.Next = e.Schedule.Next(effective)
            }
            continue

        case newEntry := <-c.add:  //添加新任務
            c.entries = append(c.entries, newEntry)
            newEntry.Next = newEntry.Schedule.Next(time.Now().Local())

        case <-c.snapshot: // 運行中獲取快照
            c.snapshot <- c.entrySnapshot()

        case <-c.stop: //停止
            return
        }

        // 'now' should be updated after newEntry and snapshot cases.
        now = time.Now().Local()
    }
}
// 輔助函數 。 處理panic , 單個任務的panic不會導致全局的crash
func (c *Cron) runWithRecovery(j Job) {
    defer func() {
        if r := recover(); r != nil {
            const size = 64 << 10
            buf := make([]byte, size)
            buf = buf[:runtime.Stack(buf, false)]
            c.logf("cron: panic running job: %v\n%s", r, buf)
        }
    }()
    j.Run()
}

// 排序輔助類。 支持len swap less 
type byTime []*Entry

func (s byTime) Len() int      { return len(s) }
func (s byTime) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
func (s byTime) Less(i, j int) bool {
    // Two zero times should return false.
    // Otherwise, zero is "greater" than any other time.
    // (To sort it at the end of the list.)
    if s[i].Next.IsZero() {
        return false
    }
    if s[j].Next.IsZero() {
        return true
    }
    return s[i].Next.Before(s[j].Next)
}

其他的瑣碎接口就不做講解,至此 , 相信大家可以自己寫出自己的cron計時器任務支持庫了。

Copyright © Linux教程網 All Rights Reserved