Xây dựng bộ lập lịch tự động - Cron job với Golang

I. Giới thiệu

Bài viết sẽ gồm 3 phần, giới thiệu, viết cron job cơ bản và cấu trúc một cron job server hoàn chỉnh.

Cron Job là chương trình lập lịch, tự động thực hiện một số công việc nào đó như:

  • Tự động sao lưu dữ liệu.

  • Gửi email, thông báo hằng ngày.

Với Golang, bạn có thể tạo và quản lý cron job bằng cách sử dụng thư viện như robfig/cron hay teambition/rrule-go (dành cho bạn nào muốn build chương trình giống google calendar).

Trong bài viết này mình sẽ dùng thư viện robfig/cron để xây dựng bộ lập lịch thông báo tới người dùng. Bạn có thể xem source code chi tiết tại đây

II. Cấu trúc cơ bản

  1. Một số cú pháp

Các cú pháp của cron job được ký hiệu bằng 5 trường để xác định thời gian. Bạn có thể xem chi tiết và kiểm tra cú pháp trên trang crontab.guru

  1. Tạo cronjob cơ bản

package main
import (
    "fmt"
    "github.com/robfig/cron/v3"
)
func main() {
    c := cron.New()
    // Thêm một công việc vào cron
    c.AddFunc("* 5 * * *", func() {
        fmt.Println("Dậy đi ông cháu ơi")
    })
    // Khởi động cron
    c.Start()
    // Giữ chương trình hoạt động, cho đến khi ai đó tắt server
    var forever chan struct{}
    <-forever
}

III. Thiết kế server

  • Viết như bên trên là đã hoàn thành một cron job cơ bản rồi. Tuy nhiên nó còn sơ sài, nên mình sẽ cấu trúc lại nó thành một server hoàn chỉnh và dễ dàng thêm các job (việc cần làm) vào hơn.
    Mình thiết kế cron-job server với 2 nhiệm vụ chính:

    1. Đọc danh sách công việc được ghi trong file (mỗi lần thêm job bạn chỉ cần thêm vào đây là xong)

    2. Gọi API tới main server để gửi thông báo tới người dùng

Bên trong cron-job server sẽ gồm:

  • Dưới đây là code chi tiết của các file:

main.go

  • File chạy chính của ứng dụng, điểm bắt đầu khi khởi chạy ứng dụng của mình.
package main

import "cron-job/service"

func main() {
    app := service.NewApp()
    app.Run()
}

service/service.go

  • Xử lý logic chính, chịu trách nhiệm đăng ký các job cần làm và run app.
package service

import (
    "cron-job/conf"
    "cron-job/handler"
    "cron-job/model"
    "errors"
    "fmt"
    "github.com/caarlos0/env/v6"
    "github.com/robfig/cron/v3"
)

type App struct {
    cronjob    *cron.Cron // 
    configJobs []*model.JobConfig // các job được đọc từ file sẽ lưu vào đây
}

func NewApp() *App {
    return &App{
        cronjob: cron.New(), // khởi tạo cronjob
    }
}

func (a *App) registerJobs() error {
    for i, jobCfg := range a.configJobs {
        // fill a name if empty
        if jobCfg.Name == "" {
            jobCfg.Name = fmt.Sprintf("job-%d", i)
        }
        handler := handler.NewHandler(jobCfg)
        // tạo ra job lập lịch dựa theo jobCfg.Spec và gọi đến api cần thiết
        _, err := a.cronjob.AddJob(jobCfg.Spec, handler)
        if err != nil {
            return fmt.Errorf("register job error (name: %s): %v", jobCfg.Name, err)
        }
    }
    return nil
}

func (a *App) Run() error {
    config := conf.NewConfig()
    err := env.Parse(config)
    if err != nil {
        return errors.New("error while parsing extra setting: " + err.Error())
    }

    a.configJobs, err = config.LoadConfigJobs()
    if err != nil {
        return fmt.Errorf("load jobs error: %v", err)
    }

    if err = a.registerJobs(); err != nil {
        return fmt.Errorf("failed to register jobs: %v", err)
    }
    a.cronjob.Start()

    // Giữ chương trình hoạt động, cho đến khi ai đó tắt server
    var forever chan struct{}
    <-forever

    return nil
}

handler/handler.go

  • Xử lý yêu cầu từ service và gọi tới api của main server
package handler

import (
    "context"
    "cron-job/model"
    "fmt"
    "net/http"
    "strings"
    "sync"
    "time"
)

type Handler struct {
    client   *http.Client
    cfg      *model.JobConfig
    initOnce sync.Once // Đảm bảo hàm init chỉ được gọi 1 lần dù có nhiều goroutine gọi
}

func NewHandler(cfg *model.JobConfig) *Handler {
    return &Handler{
        cfg: cfg,
    }
}

func (h *Handler) init() {

    if h.client == nil {
        h.client = &http.Client{
            // Không cho điều hướng
            CheckRedirect: func(req *http.Request, via []*http.Request) error {
                return http.ErrUseLastResponse
            },
        }
    }

    if h.cfg.HandlerConfig.TimeoutSeconds == 0 {
        h.cfg.HandlerConfig.TimeoutSeconds = 10
    }
}

func (h *Handler) Run() {
    h.initOnce.Do(h.init)
    cfg := h.cfg.HandlerConfig

    timestamp := time.Now()
    timeout := time.Duration(cfg.TimeoutSeconds) * time.Second

    ctx, cancel := context.WithTimeout(context.Background(), timeout)
    defer cancel()
    req, err := http.NewRequestWithContext(ctx, cfg.Method, cfg.URL, strings.NewReader(cfg.Body))
    if err != nil {
        return
    }

    for k, v := range cfg.Headers {
        req.Header.Set(k, v)
    }
    req.Header.Set("x-time", fmt.Sprintf("%d", timestamp.Unix()))

    _, err = h.client.Do(req)
    if err != nil {
        return
    }
}

config/config.go

  • Đọc các việc cần làm từ file yml, lưu vào model
package conf

import (
    "cron-job/model"
    "fmt"
    "gopkg.in/yaml.v3"
    "io/ioutil"
)

type Config struct {
    ConfigFile string `env:"CONFIG_FILE" envDefault:"config.yml"`
}

func NewConfig() *Config {
    return &Config{}
}

// Đọc các việc cần làm từ file yml
func (c *Config) LoadConfigJobs() ([]*model.JobConfig, error) {
    raw, err := ioutil.ReadFile(c.ConfigFile)
    if err != nil {
        return nil, fmt.Errorf("failed read config file: %v", err)
    }

    var jobs []*model.JobConfig
    err = yaml.Unmarshal(raw, &jobs)
    if err != nil {
        return nil, fmt.Errorf("failed to decode config data: %v", err)
    }

    return jobs, nil
}

config/config.yml

  • Định nghĩa các công việc cần thực hiện gồm: tên, thời gian gọi api, chi tiết api nào sẽ được gọi...
- name: "đón bạn gái"
  spec: "* 11 * * *" # 11h mỗi ngày
  handler:
    method: POST
    url: http://localhost:8000/pick-up-girlfriend
    headers:
      content-type: application/json
    body: |
      {}

model/job.go

  • Định nghĩa struct để lưu các dữ liệu của file yml.
package model

// JobConfig represents a job configuration at a time
type JobConfig struct {
    Name          string       `yaml:"name"`
    Spec          string       `yaml:"spec"`
    HandlerConfig *HandlerHttp `yaml:"handler"`
}

// HandlerHttp represents a http handler configuration
type HandlerHttp struct {
    Method         string            `yaml:"method"`
    URL            string            `yaml:"url"`
    Headers        map[string]string `yaml:"headers"`
    Body           string            `yaml:"body"`
    TimeoutSeconds int               `yaml:"timeout_seconds"`
}

IV. Ngoài lề

  • Tụi mình có một kênh discord về công nghệ, việc làm, phỏng vấn tên là Code club Vietnam. Ai có hứng thú có thể join để cùng thảo luận nha.