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

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

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

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

Первая задача: отправить письмо

Первая задача в asynq собирается из трёх частей: конструктор NewTask с типом и payload, клиентский Enqueue и хендлер ProcessTask с сигнатурой func(context.Context, *asynq.Task) error. В этом уроке соберём связку целиком на примере welcome-письма: от json.Marshal payload до строки «письмо отправлено» в логах воркера.

Содержание урока · 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, конструктор

GO
package 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 и бизнес-логика

GO
func 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: постановка и выполнение в одном файле

GO
func 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строка-маршрут от постановки до хендлера
PayloadWelcomeEmailPayloadданные, переживающие сериализацию в байты
Клиентclient.Enqueue(task)синхронная запись задачи в Redis
Redisopt в RedisClientOptхранение задач между процессами и рестартами
ХендлерhandleWelcomeEmail + muxразбор payload и сама работа

Шесть шагов первой задачи#

  1. Объявить константу типа: email:welcome по конвенции домен:действие.
  2. Описать payload-структуру с json-тегами в общем пакете tasks.
  3. Написать конструктор: Marshal внутри, NewTask снаружи, ошибка наверх.
  4. Поставить задачу: client.Enqueue с проверкой ошибки и логом TaskInfo.
  5. Написать хендлер: Unmarshal, работа, nil при успехе и ошибка при сбое.
  6. Зарегистрировать хендлер в mux и запустить srv.Run.

Проверка: сигнатура хендлера

Какая сигнатура у хендлера задачи asynq?

Проверка: что значит nil

Хендлер вернул nil. Что сделает asynq с задачей?

Что будет с задачей после ошибки Unmarshal?

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

if err := json.Unmarshal(t.Payload(), &p); err != nil {
    return fmt.Errorf("не разобрать payload: %v", err)
}

Соберите хендлер по скелету

Восстановите сигнатуру, разбор payload и обращение к полю.

func handleWelcomeEmail(ctx , t *asynq.Task) error { var p tasks.WelcomeEmailPayload if err := json.(t.(), &p); err != nil { return fmt.Errorf("не разобрать payload: %v", err) } log.Printf("письмо для %d", p.) return nil }

Соберите жизнь одной задачи

Расставьте строки в порядке событий: от константы типа до блокирующего запуска воркера.

Строки

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

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

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

Термин

Тип задачи

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

Строка-маршрут вроде email:welcome, по которой ServeMux выбирает хендлер. Живёт константой в общем пакете клиента и воркера.

1 / 6

Связка собрана и работает: константа, 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.

Личные заметкиПусто
Хранятся только в вашем браузере (Asynq · Первая задача: отправить письмо)

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

Можно ли ставить задачу без payload?

Да: asynq.NewTask принимает nil как []byte, и для задач без параметров этого достаточно. Практика всё же советует минимум один идентификатор в payload — без него задачу нельзя трассировать, а хендлер становится одноразовым.

Клиент и воркер в одном main — нормальная практика?

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

Что если хендлер с типом зарегистрирован дважды?

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

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

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

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

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

Kafka в Go: продюсеры и консьюмеры на kafka-go

Курс Kafka для Go: топики и партиции, producer и consumer, группы, оффсеты, гарантии доставки, rebalance, Admin API и мониторинг. 25 уроков.

  • Понимать топики, партиции, оффсеты и группы консьюмеров
  • Писать продюсеры с ключами, батчами и ретраями
  • Читать сообщения группой с коммитами оффсетов
  • Выбирать гарантии: at-most-once, at-least-once