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

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

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

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

Итоговый проект: планировщик отчётов

Итоговый cron проект go собирает курс в один сервис задач: ночные отчёты по календарному расписанию, очистка устаревших данных, напоминания с минутным интервалом — всё это живёт в одном планировщике, читает расписание из конфига и останавливается дожидаясь тиков. Проект обзорный: не строка за строкой, а карта решения — какие файлы, какие интерфейсы, какая цепочка обёрток и в каком порядке закрывается процесс. Планировщик задач go в таком виде — это не библиотечный вызов, а каркас с честными зависимостями, дедлайнами и идемпотентностью, который переживает деплой и падение соседа. Разберём устройство по слоям и соберём всё, что изучали двадцать четыре урока.

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

Задание проекта. Сервис ведёт журнал событий и ежедневные сводки. Требования: сводка отправляется в шесть утра по московскому времени; устаревшие события чистятся раз в сутки батчами; напоминания по незавершённым задачам уходят каждую минуту. Сервис запускается в двух экземплярах, расписание меняется без релиза, каждая задача ограничена дедлайном, а остановка сервиса не рвёт работу посреди письма. Три задачи — три разных профиля: календарная редкая с внешним эффектом, тяжёлая внутренняя и частая лёгкая. Именно разность профилей делает проект полезным: каждый приём курса находит здесь своё место, и ни один не остаётся теорией.

Cron-проект: карта сервиса задач#

Карта cron-проекта начинается с разделения: планировщик — инфраструктура, задачи — код с зависимостями, конфиг — источник расписаний. Структура папок минимальна: main.go собирает зависимости и запускает, config.go читает и валидирует файл задач, tasks.go хранит типы задач с методами Run, wrappers.go — цепочка обёрток, store.go — интерфейс хранилища. Интерфейсы ставятся на границах: хранилище и отправитель объявляются полями задач, как в уроке про задачи-структуры, — это делает каждую задачу проверяемой фейками из урока про тесты. Конфиг задач продолжает линию урока про расписание из конфига: имя, строка cron, дедлайн. Один важный шаг добавляется на старте — валидация строк парсером, чтобы битое расписание падало при загрузке, а не в первый тик.

Конфиг задач с валидацией на старте

GO
type TaskConfig struct {
    Name string
    Spec string
}

type ServiceConfig struct {
    Tasks []TaskConfig
}

func Load(path string) (*ServiceConfig, error) {
    raw, err := os.ReadFile(path)
    if err != nil {
        return nil, err
    }
    var cfg ServiceConfig
    if err := json.Unmarshal(raw, &cfg); err != nil {
        return nil, err
    }
    for _, t := range cfg.Tasks {
        if _, err := cron.ParseStandard(t.Spec); err != nil {
            return nil, fmt.Errorf("задача %s: %w", t.Name, err)
        }
    }
    return &cfg, nil
}

Кодировка JSON сопоставляет ключи с полями без тегов, поэтому структура остаётся чистой; в реальном проекте теги добавляются по вкусу. Валидация через ParseStandard повторяет проверку из табличного теста урока про секунды: битая строка останавливает загрузку конфига с именем задачи в ошибке. Смена расписания сводится к правке файла и перезагрузке из урока про изменение расписания.

Проверка: зачем валидировать строки на старте

Строки расписаний валидируются cron.ParseStandard при загрузке конфига. Что это даёт по сравнению с проверкой в момент AddJob?

Цепочка обёрток — общий каркас всех задач и главная точка повторного использования в проекте. Порядок собран из урока про wrappers: снаружи Recover, потому что паника должна превратиться в запись со стеком до того, как её увидят остальные обёртки; следом обёртка логов из урока про логирование с полями job и длительностью; затем метрики из урока про метрики задач — счётчик тиков по статусам и гистограмма; дальше — проектные обёртки: ретраи из урока про ошибки и замок на несколько инстансов из урока про блокировки. Одна цепочка описывается один раз и применяется ко всем задачам через WithChain — индивидуальность задачи живёт в конфиге, а не в коде.

Проверка: порядок цепочки обёрток

В WithChain переданы cron.Recover, обёртка логов, обёртка метрик и обёртка ретраев. Какая из них выполняется первой при тике?

Ночной отчёт: дедлайн, ретрай и идемпотентность#

Задача ночных отчётов собирает три контракта в одном методе. Дедлайн: контекст с таймаутом на весь тик — если внешняя система молчит, отчёт отменяется, а не висит до утра, как в уроке про контекст. Ретрай: попытки и классификация ошибок не вписываются в Run — их держит обёртка из урока про ретраи, поэтому метод остаётся читаемым, а повторы видны метрике. Идемпотентность: ключ на дату проверяется до отправки, как в уроке про блокировки, — повторный тик после сбоя на отметке не отправит второе письмо. Обратите внимание на порядок шагов: проверка ключа, выборка, отправка, отметка. Необратимый шаг стоит предпоследним, и именно поэтому сбой на отметке лечится повтором безнаказанно.

Задача отчёта с тремя контрактами

GO
type ReportJob struct {
    store   Store
    mail    Mailer
    timeout time.Duration
}

func (r *ReportJob) Run() {
    ctx, cancel := context.WithTimeout(
        context.Background(), r.timeout)
    defer cancel()

    date := time.Now().Format("2006-01-02")
    if done, _ := r.store.ReportDone(date); done {
        return // отчёт за эту дату уже ушёл
    }
    rows, err := r.store.Daily(ctx, date)
    if err != nil {
        return // попытки и статусы ведёт обёртка
    }
    r.mail.Send(buildReport(date, rows))
    r.store.MarkReport(date)
}

Контекст с дедлайном покрывает выборку и всё, что после неё. Идемпотентная проверка стоит до отправки: дубль после обрыва на отметке пропускает работу. Отметка выполняется после отправки — сбой между ними сделает повтор, но повтор увидит отметку или отправит отчёт заново только в худшем случае, который ловится ключом уникальности в хранилище.

Сколько партий удалит цикл?

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

total := 950
const batch = 200
parties := 0
for total > 0 {
    n := batch
    if total < n {
        n = total
    }
    total -= n
    parties++
}
fmt.Println("партий:", parties)

Очистка и напоминания: дешёвые задачи#

Две оставшиеся задачи проекта показывают противоположные профили. Очистка — тяжёлая внутренняя работа: удалить события старше месяца одним DELETE означает длинную транзакцию, раздутый журнал репликации и блокировки строк, о которые споткнётся основной сервис. Партиями по двести строк транзакции короткие, а метрика длительности из урока про метрики покажет, укладывается ли чистка в свой дедлайн. Напоминания — частая лёгкая задача: раз в минуту выбрать незавершённые события с наступившим сроком и отправить уведомления. Минутный интервал не оставляет запаса: тик, живущий дольше минуты, наложится на следующий, поэтому в цепочке для этой задачи стоит SkipIfStillRunning из урока про wrappers — перекрытие превращается в пропуск с записью в лог. Внешний эффект здесь тоже есть: отправка уведомления необратима, и ключ идемпотентности на событие защищает от повторных сообщений так же, как у отчёта — ключом на дату.

Реестр задач проекта#

ЗадачаРасписаниеОбёртки сверх базовыхДедлайн
ночной отчётCRON_TZ=Europe/Moscow 0 6 *ретрай, замок инстансов10 минут
очистка30 4 *замок инстансов5 минут
напоминания *SkipIfStillRunning30 секунд
синхронизация справочника@every 6hретрай2 минуты

Метрики, логи и остановка сервиса#

Наблюдаемость проекта собрана заранее: обёртки метрик и логов уже в цепочке, лейбл job сквозит через логи, графики и алерты из урока про здоровье сервиса. Осталась остановка — финальный контракт из урока про graceful shutdown: по сигналу сервис прекращает приём новой работы, вызывает Stop и дожидается тиков с дедлайном через select по контексту Stop. Дедлайн выбирается по худшему задачному таймауту с запасом: при десяти минутах у отчёта дожим не может быть длиннее, чем окно деплоя, поэтому дедлайн ставят в конфиг рядом с расписанием. Задачи, не уложившиеся в дожим, отменяются контекстом — и это штатное завершение, а не авария: отметки и идемпотентные ключи гарантируют, что недоделанное повторится при следующем старте.

Спорное: один планировщик на все задачи#

Спорное решение проекта — один экземпляр Cron на все три задачи. Против него есть честный аргумент: общий планировщик — общий отказ — паника без Recover, забитый воркер или деградация хранилища падают на все задачи сразу, и критичный отчёт делит судьбу с безобидной чисткой. Изоляция экземплярами даёт независимые цепочки, дедлайны и графики загрузки. Но изоляция стоит наблюдаемости: три экземпляра — три конфигурации, три набора метрик, три точки остановки, и для трёх задач сервиса это проигрыш в простоте без выигрыша в живучести. Разумная граница: один планировщик на инстанс и на владельца — задачи одного сервиса с общим хранилищем живут вместе; при выделении команды, чужом хранилище или сильно разной критичности планировщик выделяется со своим конфигом. Наблюдаемость частично изолируется и без разделения: лейбл job в метриках и логах разворачивает общий процесс по задачам.

Чеклист готового сервиса задач#

  • Расписания в конфиге с валидацией ParseStandard на загрузке: смена без релиза.
  • Цепочка обёрток одна: Recover, логи, метрики, ретраи, замок — порядок зафиксирован.
  • Каждая задача ограничена дедлайном через контекст, дедлайны тоже в конфиге.
  • Необратимые шаги защищены ключами идемпотентности до отправки.
  • Тяжёлая работа режется батчами; частые задачи стоят в SkipIfStillRunning.
  • Остановка: Stop, дожим с дедлайном, недоделанное повторяется при старте.

Соберите запуск сервиса

Впишите обёртку паники, её аргумент, регистрацию задачи и старт планировщика.

c := cron.New(cron.WithChain( cron.(), // паника в стек лога wrappers.Metered(metrics), wrappers.Retry(3, time.Second), )) c.(cfg.Spec, job) c.()

Расставьте поток main

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

Строки

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

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

Карточки: проект сервиса задач

Термин

Планировщик задач go

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

Один Cron на инстанс, конфиг расписаний, общая цепочка обёрток, дедлайны в конфиге и дожим при остановке. Задачи — структуры с Run и зависимостями в полях.

1 / 6

Сервис задач из проекта — сумма курса: расписание в конфиге с валидацией на старте, одна цепочка обёрток на все задачи, дедлайны в контекстах, идемпотентные ключи перед необратимыми шагами, батчи для тяжёлой работы, наблюдаемость сквозь логи и метрики, остановка с дожимом. Каждая деталь на своём месте из своего урока — повторить проект можно по реестру задач и чеклисту, подставляя свои хранилище и отправителя. Если возвращались с пропуска: обёртки — точка сборки цепочки, конфиг расписаний — источник задач, graceful shutdown — финал процесса, блокировки и ретраи — защита от дублей и сбоев. Полный маршрут с нуля — на странице курса. В домашнем задании — сборка сервиса целиком в своём окружении.

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

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

Вывод

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

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

  • Планировщик задач go — каркас: конфиг расписаний, общая цепочка обёрток, задачи как структуры с Run.
  • Строки расписаний валидируются ParseStandard на загрузке: битый конфиг падает до старта сервиса.
  • Порядок цепочки: Recover снаружи, затем логи, метрики, ретраи и замки — он же порядок принятия вызова.
  • Контракты отчёта: дедлайн в контексте, ретрай в обёртке, идемпотентный ключ до отправки.
  • Тяжёлая работа режется батчами, частые задачи защищены SkipIfStillRunning от перекрытия.
  • Остановка: Stop и дожим с дедлайном; недоделанное повторяется при старте благодаря идемпотентности.

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

Соберите проект целиком в своём окружении: конфиг из трёх задач с валидацией, цепочка Recover, логов, метрик и ретраев, хранилище-заглушка в памяти. Запустите два экземпляра против одного хранилища и убедитесь, что отчёт выходит одним, а второй инстанс пишет отказ замка. Затем проверьте сценарии отказов: заглушка хранилища падает на отметке — повтор не отправляет второе письмо; заглушка паникует — Recover пишет стек, сервис живёт. Финал — отправьте сервису сигнал остановки в момент тика и убедитесь, что дожим дожидается тика в дедлайн, а не уложившийся тик отменяется контекстом и повторяется при следующем старте.

Личные заметкиПусто
Хранятся только в вашем браузере (Cron · Итоговый проект: планировщик отчётов)

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

Почему дедлайны задач выносятся в конфиг, а не зашиваются в код?

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

Как выбрать дедлайн дожима при остановке сервиса?

Ориентир — худший таймаут задачи с запасом на завершение записи: при десяти минутах у отчёта дожим около десяти-двенадцати минут, если окно деплоя позволяет. Окно деплоя — жёсткая граница: дожим длиннее его приводит к принудительному убийству процесса, которое обнуляет смысл дожима. Поэтому дедлайны задач и дожим настраиваются вместе.

Стоит ли выделять каждую задачу в отдельный планировщик?

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

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

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

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

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

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

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

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