---
title: "AddJob: задачи-структуры и интерфейс Job"
description: "Cron AddJob и интерфейс Job: задачи-структуры с методом Run, зависимости в полях вместо замыканий, value и pointer receiver, тесты задач без ожидания тика."
course: "Фоновые задачи в Go: планировщик на robfig/cron"
url: "https://golangmaster.ru/courses/cron/lessons/06-addjob-structura/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

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

*Курс «Фоновые задачи в Go: планировщик на robfig/cron», урок 6 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/cron/lessons/06-addjob-structura/*

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

Пять задач на замыканиях живут мирно ровно до первой аналитики. Напоминаниям понадобились база и почтовый клиент, чистке — конфиг с лимитом возраста записей, отчёту — логгер, чтобы падения были видны. Замыкания обрастают захваченными переменными, регистрации в 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](/courses/cron/lessons/16-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-боте](/courses/telegram-bot/lessons/19-napominaniya/): там тикер-цикл получал стор и отправителя параметрами, здесь то же самое делает полями.

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

```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)?

- Планировщик хранит функцию напрямую, минуя интерфейс Job
- ✅ Функция заворачивается в адаптер FuncJob и идёт через тот же AddJob
- AddFunc запускает функцию немедленно, не регистрируя в расписании
- AddFunc и AddJob — два независимых кодовых пути библиотеки

В исходниках v3 type FuncJob func() с методом Run, который вызывает саму функцию, а AddFunc — это одна строка: return c.AddJob(spec, FuncJob(cmd)). Оба пути регистрируют значение интерфейса Job, поэтому обёртки WithChain, идентификаторы EntryID и правила парсинга выражений у них общие.

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

Задача-структура — обычный метод, и тестировать его надо как обычный метод: создать объект с фейковыми зависимостями и вызвать Run. Никаких ожиданий полуночи, никакой проверки выражения в тесте — расписание тестируется отдельно и вообще редко, а логика — всегда. Подмена зависимостей делается через те же интерфейсы, что и в проде: стор подменяется структурой с картой в памяти, отправитель — фейком, который складывает вызовы в срез. Такой тест проходит за миллисекунды и запускается в каждом коммите; для него неважно, стоит задача на девять утра или на каждую секунду. Если логика задачи ветвится по типам данных — оформляйте её table-driven тестами, как в [уроке про тестирование в Go](/courses/go-advanced/lessons/16-testirovanie/). А про фейковое время и ускоренные расписания в интеграционных тестах есть отдельный [урок про тесты задач](/courses/cron/lessons/22-testy-zadach/).

### Тест 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{} без амперсанда — нет?

- AddJob по правилам библиотеки принимает только указатели
- ✅ Метод с pointer-receiver не входит в method set типа-значения, поэтому DigestJob не реализует Job
- Указатели быстрее копируются, поэтому компилятор отдаёт им предпочтение
- Job требует обязательной реализации через new(T)

В Go method set типа-значения содержит методы с value-receiver, а method set указателя — и value-, и pointer-receiver. Run объявлен по указателю — значит, интерфейсу удовлетворяет только *DigestJob, и компилятор прямо скажет: DigestJob does not implement Job (Run method has pointer receiver). Лечится либо амперсандом, либо переводом Run на value-receiver.

> **Задача-значение: мутации теряются между тиками.** Если Run объявлен с value-receiver, каждый вызов получает копию задачи, и изменения полей умирают вместе с копией: счётчик запусков прибавится и сбросится. Компилятор при этом молчит — CounterJob{} честно удовлетворяет Job, задача регистрируется и работает, просто не так, как думал автор. Правило простое: у задач с состоянием Run пишется по указателю, а в AddJob уходит &T{}. Хотите страховку на ревью — сделайте поле, которое мутируется, указателем уже в объявлении структуры: тогда value-копия всё равно изменит общее значение, и расхождение между ожиданиями и поведением исчезнет, хотя мьютекс для гонок никто не отменял.

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

```go
type CounterJob struct {
    n int
}

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

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

**Что выведет программа?**

- запуск номер 1, затем запуск номер 2
- ✅ запуск номер 1, затем снова запуск номер 1
- запуск номер 2, затем запуск номер 2
- Ошибка компиляции: CounterJob не реализует Job

Run с value-receiver получает копию структуры при каждом вызове: инкремент живёт до конца метода, поле n в исходном значении не трогается. Оба тика печатают единицу. Интерфейс при этом удовлетворён — CounterJob реализует Job value-методом, поэтому компиляция проходит: именно так это ловушка и работает. Для накапливающего состояния нужен pointer-receiver и защита от параллельных тиков.

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

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

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

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

### AddFunc и AddJob рядом

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

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

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

```go
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)
}
```

Подсказка: Интерфейс Job требует метод Run; регистрирует структуры AddJob; при успехе err равна nil.

Правильные ответы в пропусках: `Run`, `AddJob`, `nil`

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

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

Правильный порядок строк:

1. `type BackupJob struct{ dir string }`
2. `func (j *BackupJob) Run() { archive(j.dir) }`
3. `backup := NewBackupJob("/var/data")`
4. `_, err := c.AddJob("30 3 * * *", backup)`
5. `if err != nil { log.Fatal(err) }`
6. `c.Start()`

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

- **Интерфейс Job** — type Job interface { Run() }: любой тип с методом Run без параметров и возврата — задача. AddFunc внутри v3 заворачивает функцию в FuncJob и вызывает тот же AddJob.
- **Сигнатура AddJob** — func (c *Cron) AddJob(spec string, cmd Job) (EntryID, error): выражение, значение-задача; обратно идентификатор и ошибка парсинга выражения.
- **Зависимости задачи** — Живут в полях структуры, собирает их конструктор NewSomething. Регистрация в main сжимается до двух строк, зависимости видны в сигнатуре конструктора.
- **Value и pointer receiver** — Value-receiver копирует задачу на каждый запуск — мутации полей теряются. Для состояния нужен pointer-receiver и регистрация по адресу: c.AddJob(spec, &T{}).
- **Тест задачи** — Вызов Run напрямую с фейковыми зависимостями: без ожидания тика, за миллисекунды. Расписание в юнит-тесте не участвует — его проверяет компилятор выражения на старте.
- **Обёртки и Job** — Цепочки WithChain из урока про wrappers принимают Job: Recover, DelayIfStillRunning и SkipIfStillRunning оборачивают и функции, и структуры одинаково.

> **Где запускать примеры.** Задачи-структуры — обычный Go: типы, методы, конструкторы и тесты из этого урока выполняются локально из модуля второго урока обычным go test. Песочница сайта понимает подмножество стандартной библиотеки — fmt, strings, strconv, math, sort, errors и time — структуры и методы в ней работают, но стороннего пакета cron там нет, поэтому планирование проверяйте локально, а механику value- и pointer-receiver можно прочувствовать прямо на странице.

Интерфейс Job из одной строки оказывается удобнее, чем кажется: он уравнивает функции и структуры в правах, позволяет обёрткам работать с обоими и превращает задачу в тестируемый тип с явными зависимостями. Следующий шаг — [часовые пояса](/courses/cron/lessons/07-chasovye-poyasa/): задача-структура на девять утра по-московски на сервере в UTC требует понимания Location и CRON_TZ. Дальше по маршруту: [Start и Stop](/courses/cron/lessons/09-start-stop/) — жизненный цикл и идентификаторы EntryID из этого урока, [контекст в задачах](/courses/cron/lessons/10-kontekst-zadachi/) — та самая строка context.WithTimeout из примера, разобранная по косточкам. Если возвращались с пропуска — [задачи на функциях](/courses/cron/lessons/05-addfunc/) объясняют, когда замыканий ещё достаточно. Полный план — [на странице курса](/courses/cron/). В домашнем задании — перевод задач с замыканий на структуры с сохранением тестов.

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

- Интерфейс 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() собирает зависимости и регистрирует задачи, а тесты лежат рядом с задачами. Так задачи не тянут за собой весь сервис и их можно переиспользовать из нескольких точек входа.
