Содержание урока · 9 разделов
- Cron AddJob и интерфейс Job: контракт из одного метода Run
- Зависимости через поля: конструктор вместо лабиринта замыканий
- Тестирование задач: Run без ожидания тика
- Что живёт в Run, а что рядом
- Признаки того, что задаче пора в структуру
- AddFunc и AddJob рядом
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Пять задач на замыканиях живут мирно ровно до первой аналитики. Напоминаниям понадобились база и почтовый клиент, чистке — конфиг с лимитом возраста записей, отчёту — логгер, чтобы падения были видны. Замыкания обрастают захваченными переменными, регистрации в 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 выдаются одинаковые, а выражения парсятся одним парсером.
Первая задача-структура
GOtype 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-боте: там тикер-цикл получал стор и отправителя параметрами, здесь то же самое делает полями.
Задача с тремя зависимостями и таймаутом
GOtype 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 за миллисекунды
GOfunc 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{} без амперсанда — нет?
Если Run объявлен с value-receiver, каждый вызов получает копию задачи, и изменения полей умирают вместе с копией: счётчик запусков прибавится и сбросится. Компилятор при этом молчит — CounterJob{} честно удовлетворяет Job, задача регистрируется и работает, просто не так, как думал автор. Правило простое: у задач с состоянием Run пишется по указателю, а в AddJob уходит &T{}. Хотите страховку на ревью — сделайте поле, которое мутируется, указателем уже в объявлении структуры: тогда value-копия всё равно изменит общее значение, и расхождение между ожиданиями и поведением исчезнет, хотя мьютекс для гонок никто не отменял.
Что напечатает счётчик за два тика?
Прочитайте код и выберите, что он выведет:
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 рядом#
| Критерий | AddFunc | AddJob |
|---|---|---|
| Аргумент | функция func() | значение с методом Run() |
| Данные для задачи | замыкание, захват при регистрации | поля структуры, собирает конструктор |
| Подмена в тесте | через переменные замыкания | обычные фейки по интерфейсам |
| Типовое применение | пара простых задач без состояния | сервис с задачами, зависимостями и тестами |
Соберите регистрацию задачи-структуры
Впишите имя метода контракта, метод регистрации и корректное сравнение ошибки.
Соберите задачу-структуру по шагам
Расставьте строки в порядке файла: тип, метод, сборка, регистрация, проверка ошибки и запуск планировщика.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: AddJob и Job
Задачи-структуры — обычный Go: типы, методы, конструкторы и тесты из этого урока выполняются локально из модуля второго урока обычным go test. Песочница сайта понимает подмножество стандартной библиотеки — fmt, strings, strconv, math, sort, errors и time — структуры и методы в ней работают, но стороннего пакета cron там нет, поэтому планирование проверяйте локально, а механику value- и pointer-receiver можно прочувствовать прямо на странице.
Интерфейс 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 — после этого верните амперсанд и добавьте в структуру счётчик запусков, убедившись, что он не сбрасывается между тиками.
Личные заметкиПусто
Вопросы и ответы по уроку
Можно ли зарегистрировать одну и ту же структуру дважды?
Да: каждый вызов AddJob создаст отдельную запись расписания со своим EntryID, а значение задачи у них будет общим. Если задача хранит состояние, оба расписания будут мутировать одни и те же поля — позаботьтесь о мьютексе или регистрируйте независимые экземпляры конструктором.
Run должен быть публичным методом?
Да, Run входит в интерфейс Job и обязан быть экспортируемым — иначе тип не реализует интерфейс за пределами своего пакета. Вся остальная логика может жить в приватных методах задачи: deliver, fetch, cleanup — наружу торчит только точка входа.
Где размещать задачи-структуры в проекте?
Обычный выбор — отдельный пакет jobs или scheduler: типы задач, их конструкторы и интерфейсы зависимостей. main() собирает зависимости и регистрирует задачи, а тесты лежат рядом с задачами. Так задачи не тянут за собой весь сервис и их можно переиспользовать из нескольких точек входа.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- Часовые пояса: Timezone и локальное время
- Секунды: cron.NewWithSeconds
- Start, Stop и жизненный цикл планировщика
- Контекст в задачах: отмена и дедлайны
- AddFunc: задачи на функциях
- Спецсимволы: звёздочка, слэш, диапазоны и @daily
- Синтаксис cron-выражений: пять полей
- Установка robfig/cron и первый планировщик
- Все курсы по Go
Следующая ступень учебного пути
Логирование в Go: структурные логи на slog
Курс по slog в Go: уровни и атрибуты, JSON-логи, свой Handler, производительность, секреты и прод. 25 уроков с примерами и проектом.
- Логировать структурированно: уровни, ключи-атрибуты, группы
- Выбирать формат вывода: TextHandler, JSONHandler, свой Handler
- Мигрировать со старого пакета log без переписывания всей кодовой базы
- Держать производительность: Enabled, предвычисление, горячий путь