Содержание урока · 10 разделов
Окружение из предыдущего урока живёт: Redis в Docker отвечает PONG, модуль с двумя точками входа собран. Теперь первый настоящий сценарий: новый пользователь зарегистрировался, и API должен отправить ему welcome-письмо. Вы уже знаете, почему это не вызов SMTP прямо в хендлере, — значит, пишем задачу. План такой: объявляем тип задачи, описываем данные в payload-структуре, пишем конструктор, ставим задачу через Enqueue и встречаем её хендлером на воркере. Всё это — около тридцати строк кода, и каждая строка здесь сохранит смысл до конца курса: крупные сценарии из следующих уроков будут отличаться от этой связки только опциями. Код запускается локально — Redis, клиент и воркер должны быть живы; песочница сайта, как всегда в этом курсе, показывает только тематическую программу на чистом Go.
Asynq первая задача: тип и конструктор#
Asynq первая задача начинается не с кода, а с имени: у каждой задачи есть тип — строка, по которой воркер поймёт, какой хендлер звать. Возьмите конвенцию «домен:действие» — email:welcome, report:daily, webhook:ping; она читается в дашборде и логах лучше любых комментариев. Тип объявляется константой в общем пакете tasks, который импортируют обе стороны связки: клиент кладёт задачу с этим типом, воркер регистрирует на него хендлер, и опечатка здесь равна потере письма. Сама задача — это значение asynq.Task: строка типа плюс байты данных, и конструктор asynq.NewTask принимает ровно два аргумента. Я всегда оборачиваю NewTask в функцию NewWelcomeEmailTask(userID int, email string): она прячет сериализацию, даёт типизированный вызов вместо сырых байтов и делает код постановки задачи читаемым. Ошибку Marshal возвращаем наверх — и хотя на обычных структурах она почти невозможна, привычка проверять всё, что возвращает ошибку, в Go не обсуждается. Константа вместо сырой строки окупается при переименованиях: компилятор найдёт все точки использования, тогда как опечатка в литерале живёт до первой жалобы пользователя. А префикс домена в имени типа превращает дашборд в фильтруемый список: email:, report:, webhook:* группируются глазами, без таблицы соответствий.
Пакет tasks: тип, payload, конструктор
GOpackage tasks
import (
"encoding/json"
"github.com/hibiken/asynq"
)
const TypeWelcomeEmail = "email:welcome"
type WelcomeEmailPayload struct {
UserID int `json:"user_id"`
Email string `json:"email"`
}
func NewWelcomeEmailTask(userID int, email string) (*asynq.Task, error) {
payload, err := json.Marshal(WelcomeEmailPayload{UserID: userID, Email: email})
if err != nil {
return nil, err
}
return asynq.NewTask(TypeWelcomeEmail, payload), nil
}Три артефакта первой задачи: константа типа, payload-структура и конструктор. NewTask принимает строку-тип и байты данных — это весь payload-протокол asynq. Marshal почти не ошибается на простых структурах, но возврат ошибки сохраняет сигнатуру честной. Тип-константа импортируется обеими сторонами: клиент и воркер физически не могут разойтись в строке.
Payload-структура как контракт двух процессов#
Обратите внимание на payload-структуру: она живёт в общем пакете не из педантизма, а потому что это контракт между двумя процессами. Клиент маршалит поля, воркер анмаршалит те же поля, и одна структура описывает обе стороны. Теги json задают имена ключей в сериализованных байтах — user_id вместо UserID читается в дашборде и в redis-cli без привычки к горбикам. Отсюда два технических следствия, о которых узнают по нулевым значениям, если пропустить. Первое: поля структуры обязаны быть экспортированными — json.Marshal молча пропускает неэкспортированные, ошибки компиляции не будет, и вы просто найдёте UserID равным нулю в воркере. Второе: json не переварит каналы и функции — Marshal вернёт UnsupportedTypeError ещё на клиенте, и это ещё один довод проверять ошибку конструктора, а не считать формальностью. В четвёртом уроке мы разберём payload во всей красе: вложенные структуры, срезы, обратную совместимость при изменении полей. Пока важно другое: данные задачи — это байты, а не живые указатели на ваши объекты. Между постановкой и выполнением письмо-задача пролежит в Redis, возможно, минуты, а возможно, и дни: в неё нельзя упаковать открытое соединение с базой или объект io.Writer, только данные, которые переживут сериализацию.
Enqueue: клиент отправляет задачу#
Постановка задачи — одна строка с двумя проверками. client.Enqueue(task) синхронно пишет задачу в Redis и возвращает два значения: *asynq.TaskInfo и ошибку. В info лежит всё, что стоит залогировать: ID задачи — строка, которую asynq сгенерировала сама, имя очереди, куда попала задача, и время запуска. Ошибка здесь — не формальность: Redis мог отвалиться, соединение могло разорваться, и молча потерять постановку — худший сценарий для кода, который только что обещал пользователю письмо. Без опций Enqueue кладёт задачу в очередь default с настройками по умолчанию — до 25 попыток при ошибках и запуск немедленно; обе договорённости мы будем менять опциями в следующих уроках. Клиент при этом ничего не знает о воркерах: не проверяет их доступность и не ждёт выполнения. Он сделал запись в Redis — и вернулся к своим делам. Запись в локальный Redis стоит доли миллисекунды, так что латентность ответа постановка не проедает — но и её лучше держать подальше от горячего пути: тяжёлые пакетные сценарии собирают задачи и ставят пачкой из фонового процесса. Если Enqueue вернул ошибку, у приложения есть выбор: повторить постановку пару раз с короткой паузой, переживая сетевой пик, или сразу отдать 5xx и оставить повтор пользователю. Я выбираю первое для писем и второе для платежей, где двойная постановка хуже пропавшей; как сделать повтор постановки безопасным, покажет четвёртый урок с его TaskID.
Хендлер ProcessTask: сигнатура и разбор payload#
Хендлер — обычная функция с обязательной сигнатурой: func(context.Context, *asynq.Task) error. Первый аргумент — контекст, и это не пустышка, а рабочая ручка: опция Timeout при постановке ставит его дедлайн, и долгие операции внутри хендлера обязаны на отмену реагировать. Через context.WithValue сюда же добираются значения, которые вы решили не тащить в payload, — trace-id, код локали. Второй — сама задача: метод t.Payload() возвращает те самые байты, которые клиент упаковал Marshal-ом, и первым делом их надо разобрать в payload-структуру через json.Unmarshal. Возвращаемое значение — главная ручка управления: nil означает «задача выполнена», и asynq снимает её с учёта; ошибка означает «не вышло», и задача уйдёт в повтор с растущей задержкой — до тех пор, пока не кончатся попытки. Сигнатура носит разговорное имя ProcessTask — в примерах документации хендлеры часто так и называются, хотя asynq принимает любую функцию этого вида. Какие ошибки стоит повторять, а какие — приговор задаче без повтора, разберём в уроке про ошибки; пока правило простое: вернули ошибку — готовьтесь к ретраю. Хендлер при этом не обязан тащить бизнес-логику внутри себя: здоровая привычка — распаковать payload и передать работу сервисному слою, протестированному отдельно от asynq. Тогда хендлер остаётся тонкой обёрткой — разбор, вызов, ошибка наверх, — и его легко покрыть тестом без Redis.
Хендлер: Unmarshal и бизнес-логика
GOfunc handleWelcomeEmail(ctx context.Context, t *asynq.Task) error {
var p tasks.WelcomeEmailPayload
if err := json.Unmarshal(t.Payload(), &p); err != nil {
return fmt.Errorf("не разобрать payload: %v", err)
}
log.Printf("отправляю welcome-письмо %d на %s", p.UserID, p.Email)
// здесь был бы вызов SMTP-клиента
return nil
}Скелет любого хендлера asynq: контекст, задача, разбор payload, работа, возврат. Unmarshal в shared-структуру из пакета tasks гарантирует, что поля совпали с тем, что маршалил клиент. Ошибка декода возвращается наверх — asynq повторит задачу, а значит, сломанный payload нельзя молча игнорировать. На месте комментария в реальном сервисе стоит вызов SMTP с обработкой его ошибок.
Регистрация хендлера в mux и запуск#
Хендлер написан, но воркер о нём не знает: функции нужно зарегистрировать в ServeMux по строке-типу. mux.HandleFunc(tasks.TypeWelcomeEmail, handleWelcomeEmail) — и связь установлена: задача с типом email:welcome попадёт именно в эту функцию. После регистрации остаётся запустить srv.Run(mux), и цикл обработки закрутится. Порядок запуска для проверки: сначала Redis, потом воркер, потом клиент — хотя asynq прощает любой порядок, задачи просто дождутся воркера в очереди. Функция — не единственная форма хендлера: mux принимает и значение, реализующее интерфейс asynq.Handler с методом ProcessTask(context.Context, *asynq.Task) error. Структурный хендлер удобен, когда логике нужны зависимости — SMTP-клиент, база, конфиг: соберите структуру с полями при старте и зарегистрируйте её метод, и глобальные переменные не понадобятся. На один mux вешают сколько угодно типов: HandleFunc вызывается для каждого, и воркер одного процесса обслуживает и письма, и отчёты. В логах вы увидите два события: клиент напечатает ID задачи и имя очереди, воркер — строку про письмо. Между ними пройдёт меньше секунды: воркер вынимает задачи из Redis практически мгновенно. Напомню, что в этом уроке клиент и воркер живут в одном main.go — так проще всего увидеть связку целиком; в продакшене их разведут по уроку про топологию.
main.go: постановка и выполнение в одном файле
GOfunc main() {
client := asynq.NewClient(asynq.RedisClientOpt{Addr: "localhost:6379"})
defer client.Close()
task, err := tasks.NewWelcomeEmailTask(42, "anna@example.com")
if err != nil {
log.Fatalf("не собрать задачу: %v", err)
}
info, err := client.Enqueue(task)
if err != nil {
log.Fatalf("не поставить задачу: %v", err)
}
log.Printf("задача %s в очереди %s", info.ID, info.Queue)
srv := asynq.NewServer(asynq.RedisClientOpt{Addr: "localhost:6379"}, asynq.Config{Concurrency: 10})
mux := asynq.NewServeMux()
mux.HandleFunc(tasks.TypeWelcomeEmail, handleWelcomeEmail)
log.Fatal(srv.Run(mux))
}Монолит для разработки: клиент ставит задачу, воркер тут же её исполняет. Две проверки ошибок — на сборке и на постановке; обе обязательны. info.ID и info.Queue — первые поля TaskInfo, которые стоит логировать: по ID задачу потом находят в дашборде и Inspector-е. Run блокирует main до сигнала остановки.
Пять участников связки#
| Участник | Код урока | Ответственность |
|---|---|---|
| Тип задачи | const TypeWelcomeEmail | строка-маршрут от постановки до хендлера |
| Payload | WelcomeEmailPayload | данные, переживающие сериализацию в байты |
| Клиент | client.Enqueue(task) | синхронная запись задачи в Redis |
| Redis | opt в RedisClientOpt | хранение задач между процессами и рестартами |
| Хендлер | handleWelcomeEmail + mux | разбор payload и сама работа |
Шесть шагов первой задачи#
- Объявить константу типа: email:welcome по конвенции домен:действие.
- Описать payload-структуру с json-тегами в общем пакете tasks.
- Написать конструктор: Marshal внутри, NewTask снаружи, ошибка наверх.
- Поставить задачу: client.Enqueue с проверкой ошибки и логом TaskInfo.
- Написать хендлер: Unmarshal, работа, nil при успехе и ошибка при сбое.
- Зарегистрировать хендлер в mux и запустить srv.Run.
Забыли mux.HandleFunc — и связка выглядит живой: клиент ставит, воркер берёт. Но воркер не находит хендлер для типа, asynq считает это ошибкой и повторяет задачу, пока попытки не кончатся, — письмо не уйдёт никогда. Что сломалось бы: половина рассылок, если тип в конструкторе написали строкой с опечаткой. Лечение из этого урока — константа типа в общем пакете; диагностика — лог воркера про отсутствие хендлера.
info.ID — единственный ключ, которым задача потом находится в Inspector и asynqmon. Пишите его в лог постановки и передавайте в метрики: когда через неделю придёт вопрос «а ушло ли письмо пользователю 42», ID сократит поиск от минут к секунде.
Проверка: сигнатура хендлера
Какая сигнатура у хендлера задачи asynq?
Проверка: что значит nil
Хендлер вернул nil. Что сделает asynq с задачей?
Что будет с задачей после ошибки Unmarshal?
Прочитайте код и выберите, что он выведет:
if err := json.Unmarshal(t.Payload(), &p); err != nil {
return fmt.Errorf("не разобрать payload: %v", err)
}Соберите хендлер по скелету
Восстановите сигнатуру, разбор payload и обращение к полю.
Соберите жизнь одной задачи
Расставьте строки в порядке событий: от константы типа до блокирующего запуска воркера.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: первая задача
Связка собрана и работает: константа, payload, конструктор, Enqueue, хендлер, mux — шесть шагов, которые останутся каркасом на весь курс. Дальше двигаемся по глубине: в четвёртом уроке разберём payload и собственные ID задач — там же покажем, почему поля структуры нельзя менять без плана миграции. В пятом — отложенные задачи: то же Enqueue, но с ProcessIn и ProcessAt, когда письмо надо отправить не сейчас, а через час. Маршрутизацию в mux, включая middleware для задач, отложим до восьмого урока, а тонкости ошибок — до четырнадцатого. Пока запустите монолит из урока несколько раз подряд и посмотрите в логи: каждая постановка создаёт новую задачу с новым ID — и это подведение к вопросу о дубликатах, которым мы займёмся в уроке про уникальность.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Тип задачи — строка «домен:действие» вроде email:welcome; объявляется константой в общем пакете.
- Payload — структура с json-тегами: клиент маршалит, воркер анмаршалит, данные обязаны переживать сериализацию.
- Enqueue синхронно пишет задачу в Redis и возвращает TaskInfo: ID, очередь, время запуска.
- Хендлер — func(context.Context, *asynq.Task) error: nil завершает задачу, ошибка запускает ретрай.
- Хендлер регистрируется в ServeMux по строке-типу: mux.HandleFunc(TypeWelcomeEmail, handleWelcomeEmail).
- Задача без зарегистрированного хендлера повторяется до исчерпания попыток — письмо не уйдёт.
- Хендлер — тонкая обёртка: разбор payload, вызов сервисного слоя, ошибка наверх; зависимости живут в полях структуры.
- info.ID — ключ для поиска задачи в дашборде: логируйте его при каждой постановке.
Домашнее задание
Добавьте вторую задачу: тип email:reset-password с payload из двух полей — UserID и Email. Напишите конструктор и хендлер по образцу урока, зарегистрируйте его в том же mux и отправьте обе задачи подряд в одном main. Убедитесь, что воркер исполнил обе, и найдите их ID в логе постановки. Затем спровоцируйте сбой: закомментируйте регистрацию второго хендлера и посмотрите в логах воркера, как задача без хендлера уходит в повтор. Запуск — только локально с живым Redis.
Личные заметкиПусто
Вопросы и ответы по уроку
Можно ли ставить задачу без payload?
Да: asynq.NewTask принимает nil как []byte, и для задач без параметров этого достаточно. Практика всё же советует минимум один идентификатор в payload — без него задачу нельзя трассировать, а хендлер становится одноразовым.
Клиент и воркер в одном main — нормальная практика?
Для разработки и учебных примеров — да, так проще всего увидеть связку. В продакшене их разделяют: постановка принадлежит API, выполнение — воркерам с собственной репликой; схему разделения разбирает урок про топологию. Разделяйте, когда у постановки и исполнения разный профиль нагрузки или разный график релизов.
Что если хендлер с типом зарегистрирован дважды?
Второй вызов HandleFunc на тот же тип перезапишет первый: в mux по типу живёт ровно один хендлер. Такое случается при случайном дублировании регистрации в разных файлах — еще одна причина держать все регистрации в одном месте рядом с Run. Если два хендлера на один тип всё же нужны, различайте их разными типами задач, а не перезаписью регистраций.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Kafka в Go: продюсеры и консьюмеры на kafka-go
Курс Kafka для Go: топики и партиции, producer и consumer, группы, оффсеты, гарантии доставки, rebalance, Admin API и мониторинг. 25 уроков.
- Понимать топики, партиции, оффсеты и группы консьюмеров
- Писать продюсеры с ключами, батчами и ретраями
- Читать сообщения группой с коммитами оффсетов
- Выбирать гарантии: at-most-once, at-least-once