首發公眾號地址:https://mp.weixin.qq.com/s/bCgGnw9QYTTBuoEktTYZcw
如果你曾經在 Go 中實現過定時任務,可能會發現,原生的 time.Timer 或 time.Ticker 雖然簡單易用,但在複雜的場景下(如多任務調度、時區處理、任務失敗重試等)往往顯得力不從心。這時,一個功能強大且靈活的定時任務庫就顯得尤為重要。
github.com/robfig/cron 正是為此而生。它不僅支持標準的 crontab 表達式,還提供了秒級精度、時區設置、任務鏈(Job Wrappers)等高級功能,能夠輕鬆應對各種複雜的定時任務場景。
本文就來帶大家見識下 cron 的用法。
快速開始
我們可以使用如下命令在 Go 項目中安裝 cron:
$ go get github.com/robfig/cron/v3@master
接着我們來一起快速入門 cron 的使用,示例如下:
https://github.com/jianghushinian/blog-go-example/tree/main/cron/main.go
package main
import (
"fmt"
"log"
"time"
"github.com/robfig/cron/v3"
)
// NOTE: 基礎用法
func main() {
// 創建一個新的 Cron 實例
c := cron.New(cron.WithSeconds())
// 添加一個每秒執行的任務
_, err := c.AddFunc("* * * * * *", func() {
fmt.Println("每秒執行的任務:", time.Now().Format("2006-01-02 15:04:05"))
})
if err != nil {
log.Fatalf("添加任務失敗: %v", err)
}
// 添加一個每 5 秒執行的任務
_, err = c.AddFunc("*/5 * * * * *", func() {
fmt.Println("每 5 秒執行的任務:", time.Now().Format("2006-01-02 15:04:05"))
})
if err != nil {
log.Fatalf("添加任務失敗: %v", err)
}
// 啓動 Cron
c.Start()
defer c.Stop() // 確保程序退出時停止 Cron
// 主程序等待 10 秒,以便觀察任務執行
time.Sleep(10 * time.Second)
fmt.Println("主程序結束")
}
這個示例展示了 cron 的基礎用法,註冊了兩個定時任務到 cron 中,並在程序啓動 10 秒後退出。
使用 cron.New 方法可以創建一個 cron 對象,cron.WithSeconds() 參數可以擴展 crontab 表達式語法支持到秒級別,語法規則不變。
cron 對象的 AddFunc 方法可以添加一個任務到 cron 中,它接收兩個參數,第一個字符串類型的參數用來定義 crontab 表達式,* * * * * * 表示每秒執行一次,第二個參數 func() 就是我們要添加的任務。這裏為 cron 對象添加了兩個任務。
cron 對象的 Start 方法內部會創建一個新的 goroutine 並啓動 cron 調度器,調度器可以控制所有註冊進來的任務的執行,在任務的執行計劃到期時運行任務。
cron 對象的 Stop 方法可以停止調度器。
執行上述示例,得到輸出如下:
$ go run main.go
每秒執行的任務: 2025-02-23 16:02:41
每秒執行的任務: 2025-02-23 16:02:42
每秒執行的任務: 2025-02-23 16:02:43
每秒執行的任務: 2025-02-23 16:02:44
每秒執行的任務: 2025-02-23 16:02:45
每 5 秒執行的任務: 2025-02-23 16:02:45
每秒執行的任務: 2025-02-23 16:02:46
每秒執行的任務: 2025-02-23 16:02:47
每秒執行的任務: 2025-02-23 16:02:48
每秒執行的任務: 2025-02-23 16:02:49
每秒執行的任務: 2025-02-23 16:02:50
每 5 秒執行的任務: 2025-02-23 16:02:50
主程序結束
可以發現,我們註冊到 cron 中的兩個任務都生效了,第一個任務每秒執行一次,第二個任務每 5 秒執行一次,10 秒後程序結束並退出。
進階用法
上面示例中,我們快速入門了 cron 的用法,下面我再將 cron 的常用功能進行進一步講解。
執行計劃時間格式
cron 包支持標準的 crontab 表達式,並且可以使用 cron.WithSeconds() 將其精度擴展到秒級別。
並且,cron 還支持另一種表達式:
@every <duration>
這是一種固定時間間隔的表達式,@every 是固定寫法,中間一個空格,然後是 <duration>,<duration> 可以是任意 time.ParseDuration 可解析的字符串。比如 @every 1h30m10s 表示任務每隔 1 小時 30 分 10 秒執行一次,@every 5s 表示任務每隔 5 秒執行一次。
任務對象
cron 包不僅支持註冊函數類型的任務,其實我們可以註冊任意自定義類型的任務,只需要實現 cron.Job 接口即可:
type Job interface {
Run()
}
比如我們自定義了 Job 類型作為一個任務對象:
// Job 作業對象
type Job struct {
name string
count int
}
func (j *Job) Run() {
j.count++
if j.count == 2 {
panic("第 2 次執行觸發 panic")
}
if j.count == 4 {
time.Sleep(6 * time.Second)
log.Println("第 4 次執行耗時 6s")
}
fmt.Printf("%s: 每 5 秒執行的任務, count: %d\n", j.name, j.count)
}
可以使用如下方式,將其註冊到 cron 中:
var (
spec = "@every 5s"
job = &Job{name: "江湖十年"}
)
c := cron.New(cron.WithSeconds())
c.AddJob(spec, job)
cron 對象的調度器會每隔 5 秒執行一次 job.Run 方法。
自定義日誌
cron 支持註冊自定義的日誌對象,這樣 cron 運行期間產生的日誌都會輸出到我們自定義的日誌中。
自定義日誌之需要實現 cron.Logger 接口即可:
type Logger interface {
// Info logs routine messages about cron's operation.
Info(msg string, keysAndValues ...interface{})
// Error logs an error condition.
Error(err error, msg string, keysAndValues ...interface{})
}
如下是我們自定義的日誌對象:
// 自定義 logger
type cronLogger struct{}
func newCronLogger() *cronLogger {
return &cronLogger{}
}
// Info implements the cron.Logger interface.
func (l *cronLogger) Info(msg string, keysAndValues ...any) {
slog.Info(msg, keysAndValues...)
}
// Error implements the cron.Logger interface.
func (l *cronLogger) Error(err error, msg string, keysAndValues ...any) {
slog.Error(msg, append(keysAndValues, "err", err)...)
}
這裏為了演示,僅將日誌轉發給 slog 進行輸出,你可以定製更多高級功能。
可以這樣使用日誌對象:
// 創建自定義日誌對象
logger := &cronLogger{}
// 創建一個新的 Cron 實例
c := cron.New(
cron.WithSeconds(), // 增加秒解析
cron.WithLogger(logger), // 自定義日誌
)
任務裝飾器
我們可以在初始化 cron 時,為任務定義一系列的裝飾器,這樣當有新的任務註冊進來,就能自動為其附加裝飾器的所有功能。
cron 為我們提供了幾個自帶的裝飾器:
cron.Recover:恢復任務執行過程中產生的panic,不要讓cron調度器退出。cron.DelayIfStillRunning:如果上一次任務還未完成,那麼延遲此次任務的執行時間,只有上一次任務執行完成後,才會執行下一次任務。cron.SkipIfStillRunning:如果上一次任務還未完成,那麼此次此次任務的執行。
我們可以像這樣使用任務裝飾器:
// 創建自定義日誌對象
logger := &cronLogger{}
// 創建一個新的 Cron 實例
c := cron.New(
cron.WithSeconds(), // 增加秒解析
cron.WithLogger(logger), // 自定義日誌
cron.WithChain( // chain 是順序敏感的
cron.SkipIfStillRunning(logger), // 如果作業仍在運行,則跳過此次運行
cron.Recover(logger), // 恢復 panic
),
)
cron.WithChain 會將所有裝飾器串聯起來,使其成為一條任務鏈,比如 cron.WithChain(m1, m2),那麼最終任務執行時會這樣調用:m1(m2(job))。
我們也可以實現自定義的裝飾器,其函數定義格式為:func(cron.Job) cron.Job,你可以試着自己實現一個。
進階示例
學習了 cron 的進階使用,我們可以編寫一個示例,體驗下這些功能的用法:
https://github.com/jianghushinian/blog-go-example/tree/main/cron/main.go
// 自定義 logger
type cronLogger struct{}
func newCronLogger() *cronLogger {
return &cronLogger{}
}
// Info implements the cron.Logger interface.
func (l *cronLogger) Info(msg string, keysAndValues ...any) {
slog.Info(msg, keysAndValues...)
}
// Error implements the cron.Logger interface.
func (l *cronLogger) Error(err error, msg string, keysAndValues ...any) {
slog.Error(msg, append(keysAndValues, "err", err)...)
}
// Job 作業對象
type Job struct {
name string
count int
}
func (j *Job) Run() {
j.count++
if j.count == 2 {
panic("第 2 次執行觸發 panic")
}
if j.count == 4 {
time.Sleep(6 * time.Second)
log.Println("第 4 次執行耗時 6s")
}
fmt.Printf("%s: 每 5 秒執行的任務, count: %d\n", j.name, j.count)
}
func main() {
log.Println("cron start")
// 創建自定義日誌對象
logger := &cronLogger{}
// 創建一個新的 Cron 實例
c := cron.New(
cron.WithSeconds(), // 增加秒解析
cron.WithLogger(logger), // 自定義日誌
cron.WithChain( // chain 是順序敏感的
cron.SkipIfStillRunning(logger), // 如果作業仍在運行,則跳過此次運行
cron.Recover(logger), // 恢復 panic
),
)
var (
spec = "@every 5s"
job = &Job{name: "江湖十年"}
)
// 添加一個每 5 秒執行的任務
id, err := c.AddJob(spec, job)
if err != nil {
log.Fatalf("添加任務失敗: %v", err)
}
log.Println("任務 ID:", id)
// 啓動 Cron
c.Start()
defer c.Stop() // 確保程序退出時停止 Cron
time.Sleep(34 * time.Second) // 確保 job 能執行 6 次
c.Remove(id) // 從調度器中移除 job
time.Sleep(10 * time.Second) // job 不會再次執行
log.Println("cron done")
}
這個示例完整的展示了進階用法的幾項功能。
執行上述示例,得到輸出如下:
$ go run main.go
2025/02/23 16:03:47 cron start
2025/02/23 16:03:47 任務 ID: 1
2025/02/23 16:03:47 INFO start
2025/02/23 16:03:47 INFO schedule now=2025-02-23T16:03:47.035+08:00 entry=1 next=2025-02-23T16:03:52.000+08:00
2025/02/23 16:03:52 INFO wake now=2025-02-23T16:03:52.000+08:00
2025/02/23 16:03:52 INFO run now=2025-02-23T16:03:52.000+08:00 entry=1 next=2025-02-23T16:03:57.000+08:00
江湖十年: 每 5 秒執行的任務, count: 1
2025/02/23 16:03:57 INFO wake now=2025-02-23T16:03:57.001+08:00
2025/02/23 16:03:57 INFO run now=2025-02-23T16:03:57.001+08:00 entry=1 next=2025-02-23T16:04:02.000+08:00
2025/02/23 16:03:57 ERROR panic stack="..." err="第 2 次執行觸發 panic"
2025/02/23 16:04:02 INFO wake now=2025-02-23T16:04:02.000+08:00
2025/02/23 16:04:02 INFO run now=2025-02-23T16:04:02.000+08:00 entry=1 next=2025-02-23T16:04:07.000+08:00
江湖十年: 每 5 秒執行的任務, count: 3
2025/02/23 16:04:07 INFO wake now=2025-02-23T16:04:07.000+08:00
2025/02/23 16:04:07 INFO run now=2025-02-23T16:04:07.000+08:00 entry=1 next=2025-02-23T16:04:12.000+08:00
2025/02/23 16:04:12 INFO wake now=2025-02-23T16:04:12.000+08:00
2025/02/23 16:04:12 INFO run now=2025-02-23T16:04:12.000+08:00 entry=1 next=2025-02-23T16:04:17.000+08:00
2025/02/23 16:04:12 INFO skip
2025/02/23 16:04:13 第 4 次執行耗時 6s
江湖十年: 每 5 秒執行的任務, count: 4
2025/02/23 16:04:17 INFO wake now=2025-02-23T16:04:17.001+08:00
江湖十年: 每 5 秒執行的任務, count: 5
2025/02/23 16:04:17 INFO run now=2025-02-23T16:04:17.001+08:00 entry=1 next=2025-02-23T16:04:22.000+08:00
2025/02/23 16:04:21 INFO removed entry=1
2025/02/23 16:04:31 cron done
這裏日誌比較多,不過如果你耐心梳理一下,還是比較清晰的。
可以看到日誌中輸出了 第 2 次執行觸發 panic(日誌行省略了 stack 信息),程序並沒有退出,説明我們使用的 cron.Recover(logger) 裝飾器生效了。
並且,在打印 第 4 次執行耗時 6s 之前,上一行日誌輸出了 INFO skip,表示跳過了一次任務執行,説明 cron.SkipIfStillRunning(logger) 裝飾器生效了。
我們使用 c.Remove(id) 從調度器中移除了註冊的任務後,任務沒再執行。
好了,cron 的使用示例就講解到這裏。
我需要額外強調的一點是,其實 cron 項目存在一個 Bug,在作者的最後一次 commit 中被修復。不過作者並沒有為其打上新的 Tag,所以這也是為什麼安裝時我們需要指定 @master 來下載最新版本。
這個 Bug 修復如下:
如果你在安裝 cron 時沒有指定 @master,你可以嘗試修改下 cron.SkipIfStillRunning 和 cron.Recover 的順序,來看看程序執行效果。
原理剖析
cron 的常見用法我們都講解完成了,如果你想更深入的瞭解 cron,那麼我這裏為你簡單梳理下 cron 的整體設計,方便你進一步學習。
首先,我為你畫了一張思維導圖,展示了 cron 對象提供的所有 exported 方法:
我們最需要關注的就是創建、啓動、添加任務(圖中寫為“作業”)和停止功能。
其中 Cron 是一個結構體,用於管理和調度註冊進來的任務,其定義如下:
// Cron 核心結構體,用於調度註冊進來的作業
// 記錄作業列表(entries),可以啓動、停止,並且檢查運行中的作業狀態
type Cron struct {
entries []*Entry // 作業對象列表
chain Chain // 裝飾器鏈
stop chan struct{} // 停止信號
add chan *Entry // Cron 運行時,增加作業的 channel
remove chan EntryID // 移除指定 ID 作業的 channel
snapshot chan chan []Entry // 獲取當前作業列表快照的 channel
running bool // 標識 Cron 是否正在運行
logger Logger // 日誌對象,Cron 會將運行的日誌內容輸出到 logger
runningMu sync.Mutex // 當 Cron 運行時,保護併發操作的鎖
location *time.Location // 本地時區,Cron 根據此時區計算任務執行計劃
parser ScheduleParser // 任務執行計劃解析器
nextID EntryID // 下一個要執行的作業 ID
jobWaiter sync.WaitGroup // 使用 wg 等待作業完成
}
所有添加到 Cron 中的任務都會保存在 entries 列表中。
Entry 結構體表示一個任務對象,定義如下:
// EntryID 標識 Cron 實例中的一個作業
type EntryID int
// Entry 作業實體對象,表示一個被註冊進 Cron 執行器中的作業
type Entry struct {
// ID 是作業的唯一 ID,可用於查找快照或將其刪除
ID EntryID
// Schedule 作業的執行計劃,應該按照此計劃來執行作業
Schedule Schedule
// Next 下次運行作業的時間,如果 Cron 尚未啓動或無法滿足此作業的執行計劃,則為 zero time
Next time.Time
// Prev 是此作業的最後一次運行時間,如果從未運行,則為 zero time
Prev time.Time
// WrappedJob 作業裝飾器,為作業增加新的功能,會在 Schedule 被激活時運行
WrappedJob Job
// Job 提交到 Cron 中的作業
Job Job
}
EntryID 就是我們調用 c.Remove(id) 時使用的 id。
Cron 最核心的邏輯當然是調度邏輯,其功能主要由 for...select 實現:
for {
select {
case now = <-timer.C:
// 運行下一次執行時間小於當前時間的所有作業
case newEntry := <-c.add:
// 有新的作業加入進來,加入調度
case replyChan := <-c.snapshot:
// 獲取當前作業列表
case <-c.stop:
// 停止調度
case id := <-c.remove:
// 移除指定 ID 的作業
}
這裏是調度器主要邏輯,for...select 能夠實現高效調度,並且結合 exported 方法中的互斥鎖,cron 能夠支持併發操作。
以上,就是 cron 的設計原理。當然其具體內部實現還需要你自行研究,你可以參考我寫好了中文註釋的源碼 https://github.com/jianghushinian/blog-go-example/tree/main/cron/sourcecode/cron。
總結
github.com/robfig/cron 解決了複雜場景下在 Go 中執行定時任務的需求。
cron 包非常強大,它擴展了 crontab 表達式,支持秒級精度。任務鏈的功能可以為任務附加功能,這個設計跟 Gin 框架的中間件非常相似,你可以類比學習。此外我認為 cron 包設計比較好的一點是支持自定義日誌包,這樣我們可以按照自己的方式收集 cron 的執行日誌。想要更深入的學習 cron 包,可以參考我畫的思維導圖進一步閲讀其源碼。
本文示例源碼我都放在了 GitHub 中,歡迎點擊查看。
希望此文能對你有所啓發。
聯繫我
- 公眾號:Go編程世界
- 微信:jianghushinian
- 郵箱:jianghushinian007@outlook.com
- 博客:https://jianghushinian.cn
- GitHub:https://github.com/jianghushinian