Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 6 из 25 ~18 мин +20 XP

AddJob: задачи-структуры и интерфейс Job

Cron AddJob — регистрация задачи по расписанию не функцией, а значением с методом Run: интерфейс Job из одной строки, зависимости в полях структуры и тесты, которым не нужен планировщик. В уроке про AddFunc мы упирались в стену: замыкания размножаются, захваченные переменные теряют смысл, а проверить логику без ожидания тика неудобно. Здесь разберём контракт Job, конструкторы задач, тонкость value- и pointer-receiver, которая ловит даже опытных Go-разработчиков, и соберём тест, который проходит за миллисекунды.

Содержание урока · 9 разделов

Пять задач на замыканиях живут мирно ровно до первой аналитики. Напоминаниям понадобились база и почтовый клиент, чистке — конфиг с лимитом возраста записей, отчёту — логгер, чтобы падения были видны. Замыкания обрастают захваченными переменными, регистрации в main() разрастаются до экрана каждая, и вот уже ревьюер просит показать, откуда взялась та самая ссылка на пул соединений. В этот момент задачи стоит развернуть в структуры: каждая задача становится типом с полями-зависимостями и методом Run, а регистрация — двумя строками. У структуры появляется и ещё одна роль, о которой замыкание и мечтать не может: она — место для метаданных задачи, имени для логов и метрик, лимитов и флагов окружения, которые конструктор принимает наравне с зависимостями. Побочный эффект приятный: тест такой задачи не отличается от теста обычного метода, без сети, без снов и без ожидания полуночи. Разберём контракт, который требует библиотека, и подводный камень с приёмниками методов, на котором спотыкаются чаще всего.

Cron AddJob и интерфейс Job: контракт из одного метода Run#

В robfig/cron v3 интерфейс Job объявлен тривиально: type Job interface { Run() }. Любой тип, у которого есть метод Run без параметров и без возврата, — готовая задача. Регистрация выглядит так: c.AddJob(spec string, cmd Job) (EntryID, error) — выражение то же, что и у AddFunc, а обратно приходят идентификатор и ошибка парсинга. Внутри библиотеки разницы между ними почти нет: AddFunc заворачивает вашу функцию в адаптер FuncJob, чей метод Run просто вызывает захваченную функцию, и передаёт его в тот же AddJob. То есть AddFunc — синтаксический сахар над AddJob, а не отдельный механизм. Из этого следует удобное свойство: всё, что работает с задачами-функциями, работает и со структурами — обёртки WithChain из урока про wrappers принимают именно Job, идентификаторы EntryID выдаются одинаковые, а выражения парсятся одним парсером.

Первая задача-структура

GO
type CleanupJob struct {
    store *Store
}

func (j *CleanupJob) Run() {
    n, err := j.store.DeleteExpired(time.Now())
    if err != nil {
        log.Printf("чистка: %v", err)
        return
    }
    log.Printf("чистка: удалено %d записей", n)
}

func main() {
    c := cron.New()
    _, err := c.AddJob("0 4 * * *", &CleanupJob{store: newStore()})
    if err != nil {
        log.Fatal(err)
    }
    c.Start()
}

Задача — тип CleanupJob с единственным полем-зависимостью и методом Run по указателю. Тело Run — один вызов метода стора плюс обработка ошибки: та же дисциплина, что и для замыканий. Ошибка логируется внутри, потому что вернуть её наружу некому. Регистрация не отличается от AddFunc: выражение, значение, проверка err на старте.

Зависимости через поля: конструктор вместо лабиринта замыканий#

Главный выигрыш структур — способ получения зависимостей. Замыкание захватывает переменные из окружения, и чем их больше, тем хуже читается регистрация; структура собирает зависимости в поля, а собирает их конструктор — обычная функция NewSomething, в которой видно, что и откуда пришло. Такой подход называется инъекцией зависимостей, и в Go он делается руками без всяких фреймворков: конструктор принимает стор, отправителя и логгер, кладёт их в поля и возвращает готовую задачу. Регистрация в main() сжимается до двух строк, а иерархия зависимостей перестаёт прятаться в захваченных переменных. Тот же приём уже знаком вам по уроку про напоминания в Telegram-боте: там тикер-цикл получал стор и отправителя параметрами, здесь то же самое делает полями.

Задача с тремя зависимостями и таймаутом

GO
type ReminderJob struct {
    store  *Store
    sender Sender
    log    *zap.Logger
    limit  time.Duration
}

func NewReminderJob(store *Store, sender Sender, log *zap.Logger) *ReminderJob {
    return &ReminderJob{store: store, sender: sender, log: log, limit: 30 * time.Second}
}

func (j *ReminderJob) Run() {
    ctx, cancel := context.WithTimeout(context.Background(), j.limit)
    defer cancel()

    due, err := j.store.DueReminders(ctx)
    if err != nil {
        j.log.Error("напоминания: выборка", zap.Error(err))
        return
    }
    j.deliver(ctx, due)
}

Конструктор NewReminderJob — единственное место, где задача собирается: поля получают стор, интерфейс отправителя, логгер и лимит времени. Sender — интерфейс, объявленный рядом с задачей: в проде под ним сетевой клиент, в тесте — фейк. Тело Run держится маленьким: контекст с таймаутом, выборка, делегирование остального приватному методу deliver. О работе с контекстом внутри задач будет отдельный урок десятый.

Проверка: как устроен AddFunc внутри

Что происходит внутри robfig/cron v3 при вызове c.AddFunc("@daily", f)?

Тестирование задач: Run без ожидания тика#

Задача-структура — обычный метод, и тестировать его надо как обычный метод: создать объект с фейковыми зависимостями и вызвать Run. Никаких ожиданий полуночи, никакой проверки выражения в тесте — расписание тестируется отдельно и вообще редко, а логика — всегда. Подмена зависимостей делается через те же интерфейсы, что и в проде: стор подменяется структурой с картой в памяти, отправитель — фейком, который складывает вызовы в срез. Такой тест проходит за миллисекунды и запускается в каждом коммите; для него неважно, стоит задача на девять утра или на каждую секунду. Если логика задачи ветвится по типам данных — оформляйте её table-driven тестами, как в уроке про тестирование в Go. А про фейковое время и ускоренные расписания в интеграционных тестах есть отдельный урок про тесты задач.

Тест ReminderJob за миллисекунды

GO
func TestReminderJob(t *testing.T) {
    store := newFakeStore()
    store.queue(Reminder{ID: 1}, Reminder{ID: 2})

    sender := &fakeSender{}
    job := NewReminderJob(store, sender, zaptest.NewLogger(t))

    job.Run() // планировщик не нужен: задача — обычный метод

    if got := len(sender.sent); got != 2 {
        t.Fatalf("отправлено %d, хотели 2", got)
    }
}

Фейковый стор наполняется двумя напоминаниями, fakeSender собирает отправленное в срез, задача создаётся через штатный конструктор и запускается прямым вызовом Run. Тест не знает про cron-выражения и не тратит времени на ожидание: он проверяет ровно то, за что отвечает задача, — выборку и доставку. Поломался тест — виновата логика, а не тайминги.

Проверка: указатель или значение

Job объявлен с pointer-receiver: func (j *DigestJob) Run(). Почему &DigestJob{} подходит под интерфейс, а DigestJob{} без амперсанда — нет?

Что напечатает счётчик за два тика?

Прочитайте код и выберите, что он выведет:

type CounterJob struct {
    n int
}

func (j CounterJob) Run() {
    j.n++
    fmt.Println("запуск номер", j.n)
}

// задача зарегистрирована как c.AddJob("* * * * *", CounterJob{})
// и успела сработать дважды

Что живёт в Run, а что рядом#

Структура соблазняет сложить в Run всё: выборку, ретраи, метрики, отправку. Сопротивляйтесь: Run — точка входа, а не хранилище логики. Внутри оставляйте сбор контекста, вызов одного приватного метода и обработку ошибки; повторные попытки — отдельный метод с чётким пределом попыток, метрики — обёртка из урока про wrappers или логгер рядом. Такая анатомия даёт три вещи: Run читается с одного взгляда, приватные методы тестируются по отдельности узкими кейсами, а обёртки декораторов применяются к задаче целиком, не смешиваясь с бизнес-логикой. Второй принцип — идемпотентность: тик может случиться дважды при рестарте сервиса или перекрыться с предыдущим запуском, поэтому задача-структура, как и задача-функция, обязана корректно переживать повторный вызов. Третий — состояние задачи должно быть защищено: два тика могут идти параллельно, и если Run мутирует поля, нужен мьютекс. Отдельный бонус структур — осмысленные имена в логах: в поле задачи кладётся строка вида jobs.cleanup, и логгер из урока про логирование задач проставляет её к каждой записи, вместо того чтобы разбирать адрес функции в трейсе.

Признаки того, что задаче пора в структуру#

  • У задачи больше одной зависимости: база, клиент, логгер, конфиг.
  • Регистрация в main() перестала помещаться на экран из-за захваченных переменных.
  • Две задачи хотят переиспользовать одну и ту же логику.
  • Задачу нужно тестировать без сети, без снов и без ожидания тика.
  • У задачи появилось собственное состояние: курсор, счётчик, кэш последнего запуска.
  • В задаче нужен общий контекст с отменой при graceful shutdown сервиса.

AddFunc и AddJob рядом#

КритерийAddFuncAddJob
Аргументфункция func()значение с методом Run()
Данные для задачизамыкание, захват при регистрацииполя структуры, собирает конструктор
Подмена в тестечерез переменные замыканияобычные фейки по интерфейсам
Типовое применениепара простых задач без состояниясервис с задачами, зависимостями и тестами

Соберите регистрацию задачи-структуры

Впишите имя метода контракта, метод регистрации и корректное сравнение ошибки.

type DigestJob struct{ mailer *Mailer } func (j *DigestJob) () { j.mailer.Send(digestFor(time.Now())) } _, err := c.("0 8 * * *", &DigestJob{mailer: m}) if err != { log.Fatal(err) }

Соберите задачу-структуру по шагам

Расставьте строки в порядке файла: тип, метод, сборка, регистрация, проверка ошибки и запуск планировщика.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: AddJob и Job

Термин

Интерфейс Job

нажмите, чтобы перевернуть
Определение

type Job interface { Run() }: любой тип с методом Run без параметров и возврата — задача. AddFunc внутри v3 заворачивает функцию в FuncJob и вызывает тот же AddJob.

1 / 6

Интерфейс Job из одной строки оказывается удобнее, чем кажется: он уравнивает функции и структуры в правах, позволяет обёрткам работать с обоими и превращает задачу в тестируемый тип с явными зависимостями. Следующий шаг — часовые пояса: задача-структура на девять утра по-московски на сервере в UTC требует понимания Location и CRON_TZ. Дальше по маршруту: Start и Stop — жизненный цикл и идентификаторы EntryID из этого урока, контекст в задачах — та самая строка context.WithTimeout из примера, разобранная по косточкам. Если возвращались с пропуска — задачи на функциях объясняют, когда замыканий ещё достаточно. Полный план — на странице курса. В домашнем задании — перевод задач с замыканий на структуры с сохранением тестов.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • Интерфейс Job — один метод Run(): AddFunc внутри v3 оборачивает функцию в FuncJob и идёт через тот же AddJob.
  • Зависимости живут в полях структуры и собираются конструктором: регистрация сжимается, замыкания исчезают.
  • Задача тестируется прямым вызовом Run с фейками по интерфейсам — без ожидания тика и сети.
  • Value-receiver копирует задачу на каждый запуск: для состояния нужен pointer-receiver и &T{} в AddJob.
  • Run — точка входа, а не хранилище логики: внутри контекст, один вызов метода и обработка ошибки.
  • Задача обязана переживать повторный и параллельный запуск: идемпотентность и мьютекс для состояния.

Домашнее задание

Возьмите две задачи на замыканиях из урока про AddFunc и переведите их на структуры: поля-зависимости, конструктор, Run по указателю. Напишите на каждую тест с фейковыми зависимостями, который проверяет поведение без сети и без ожидания тика. Затем попробуйте зарегистрировать задачу без амперсанда и прочитайте ошибку компилятора про method set — после этого верните амперсанд и добавьте в структуру счётчик запусков, убедившись, что он не сбрасывается между тиками.

Личные заметкиПусто
Хранятся только в вашем браузере (Cron · AddJob: задачи-структуры и интерфейс Job)

Вопросы и ответы по уроку

Можно ли зарегистрировать одну и ту же структуру дважды?

Да: каждый вызов AddJob создаст отдельную запись расписания со своим EntryID, а значение задачи у них будет общим. Если задача хранит состояние, оба расписания будут мутировать одни и те же поля — позаботьтесь о мьютексе или регистрируйте независимые экземпляры конструктором.

Run должен быть публичным методом?

Да, Run входит в интерфейс Job и обязан быть экспортируемым — иначе тип не реализует интерфейс за пределами своего пакета. Вся остальная логика может жить в приватных методах задачи: deliver, fetch, cleanup — наружу торчит только точка входа.

Где размещать задачи-структуры в проекте?

Обычный выбор — отдельный пакет jobs или scheduler: типы задач, их конструкторы и интерфейсы зависимостей. main() собирает зависимости и регистрирует задачи, а тесты лежат рядом с задачами. Так задачи не тянут за собой весь сервис и их можно переиспользовать из нескольких точек входа.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Slog25 уроков~9 чпрактика25 уроков уже доступны

Логирование в Go: структурные логи на slog

Курс по slog в Go: уровни и атрибуты, JSON-логи, свой Handler, производительность, секреты и прод. 25 уроков с примерами и проектом.

  • Логировать структурированно: уровни, ключи-атрибуты, группы
  • Выбирать формат вывода: TextHandler, JSONHandler, свой Handler
  • Мигрировать со старого пакета log без переписывания всей кодовой базы
  • Держать производительность: Enabled, предвычисление, горячий путь