Содержание урока · 10 разделов
У сервиса уже шесть типов задач, и в main.go назревает свитч с шестью ветками — знакомая боль каждого, кто писал HTTP-сервер вручную. В net/http эту проблему решили давным-давно: ServeMux сопоставляет путь запроса с зарегистрированным хендлером. Asynq повторяет проверенный ход под своим предметом: ServeMux сопоставляет тип задачи с хендлером, и вместо свитча вы регистрируете пары «паттерн — обработчик». Привычка из net/http тут переносится почти дословно — разве что вместо пути запроса тут тип задачи. В третьем уроке мы уже регистрировали один хендлер, чтобы связка заработала; теперь разберём маршрутизацию как инструмент: правила выбора хендлера, поведение при отсутствии совпадений и middleware — общий код, который не хочется копировать в каждый хендлер. Компонент маленький, но через него проходят все задачи воркера: что вы повесите на этот слой, то увидите в логах каждой задачи — и в метриках, и в стектрейсах. Всё это локальная работа: нужен Go и Redis из Docker, песочница сайта показывает только тематическую программу на чистом Go.
Asynq servemux: HandleFunc и Handle#
Asynq servemux создаётся конструктором asynq.NewServeMux() и передаётся в srv.Run(mux) — это и есть тот хендлер, который сервер вызывает для каждой задачи. Регистрация идёт двумя способами. HandleFunc принимает функцию с сигнатурой хендлера: mux.HandleFunc("email:welcome", handleWelcomeEmail) — самый короткий путь для простых случаев. Handle принимает значение, реализующее интерфейс asynq.Handler: структурный хендлер с зависимостями — базой, mailer-ом, конфигом — регистрируется именно так. Паттерн регистрации — тот же тип задачи, что вы передаёте в NewTask: строка вроде email:welcome, которую мы с четвёртого урока держим в константах общего пакета. Две паники стоят запомнить: повторная регистрация того же паттерна валит процесс при старте с «multiple registrations», а пустой паттерн — при любой попытке зарегистрировать пустую строку. Обе паники — друзья: конфликт паттернов лучше поймать на деплое, чем в три часа ночи. Для полноты: ServeMux потокобезопасен — регистрации и поиск идут под мьютексом, — а метод Handler(t) вернёт пару «хендлер, паттерн» для любой задачи, если однажды понадобится отладить совпадение руками. В повседневном коде он не нужен: mux собирают при старте и дальше он только диспетчеризует.
Шесть типов задач без одного свитча
GOmux := asynq.NewServeMux()
mux.HandleFunc("email:welcome", handleWelcomeEmail)
mux.HandleFunc("email:digest", handleDigest)
// структурный хендлер с зависимостями
reportHandler := &ReportHandler{DB: db, Store: store}
mux.Handle("report:daily", reportHandler)
srv.Run(mux)HandleFunc принимает функцию с сигнатурой хендлера, Handle — значение, реализующее asynq.Handler: у ReportHandler это метод ProcessTask. Паттерн — та же строка типа задачи, что уходит в NewTask. Повторная регистрация одного паттерна паникует при старте, поэтому конфликт регистрация вскрывает сразу, а не в проде. Оба способа сводятся к одному интерфейсу: HandleFunc — для замыканий, Handle — для структур с зависимостями.
Префиксные паттерны: точное совпадение важнее#
Правило выбора хендлера состоит из двух шагов. Сначала mux ищет точное совпадение типа задачи с зарегистрированным паттерном — словарный поиск за константное время. Если точного нет, включается префиксное правило: среди всех паттернов, являющихся началом типа задачи, выбирается самый длинный. Зарегистрированы email и email:welcome — задача email:welcome уходит точному хендлеру, задача email:digest попадает префиксному email. Это та же логика, что у net/http до явных wildcard-ов: длинный паттерн всегда перекрывает короткий, порядок регистрации не важен. Механически внутри паттерны хранятся отсортированными по длине, и префиксный поиск идёт от самого длинного к короткому, останавливаясь на первом совпадении, — поэтому «самый длинный» здесь не метафора, а буквально первый в списке. Полезно знать границу: префикс — это строковое начало, а не сегменты через двоеточие. Паттерн email поймает и email:digest, и email-welcome, и emailextra — asynq сравнивает начало строки без правил про разделители. Поэтому конвенция с двоеточием — дисциплина команды, а не механика библиотеки: держите иерархию типов честной, и префиксные регистрации будут означать то, что вы имеете в виду.
Проверка: выбор хендлера
Зарегистрированы паттерны email и email:welcome. Какой хендлер обработает задачу типа email:welcome?
NotFound: задача без хендлера#
Если ни точного, ни префиксного совпадения не нашлось, задачу получает NotFoundHandler — встроенный хендлер, который возвращает ошибку, оборачивающую asynq.ErrHandlerNotFound, с типом задачи в сообщении. Дальше включается уже знакомая механика шестого урока: ошибка считается сбоем, задача идёт в повторы с экспонентой и, исчерпав попытки, оседает в архиве с этой ошибкой. Реальный сценарий, где это случается: клиент и воркер деплоятся раздельно, клиент уехал вперёд и начал ставить задачи нового типа, а воркеры ещё его не знают. Симптом заметный: в архиве оседают задачи с ErrHandlerNotFound при здоровом Redis и живых воркерах. Разбору помогает то, что в текст ошибки подставлен тип задачи: в LastErr архива будет строка вида handler not found for task "report:new" — сразу ясно, какая константа доехала до воркера раньше него. Лечение процессное, а не кодовое: типы задач появляются в общем пакете констант, воркеры катятся раньше клиентов или вместе с ними, и постановка нового типа включается фича-флагом после раскатки. Разница между «воркеры раньше клиентов» и «фича-флаг после раскатки» — в цене: первый вариант ничего не стоит, кроме дисциплины пайплайна, второй спасает, когда раскатки независимы. Проверка соответствия констант и паттернов — хороший юнит-тест, о котором подробнее говорит двадцать второй урок.
Клиент ставит задачу с константой tasks.TypeWelcome, а в mux регистрация написана строкой — и в ней опечатка: email:welcom. Компилятор молчит: это просто две разные строки. Задача уходит, воркер отвечает ErrHandlerNotFound, через 25 попыток архив, а первый тикет приходит от пользователя, не дождавшегося письма. Правило: строковые литералы типов живут только в общем пакете констант, и ни клиент, ни mux не пишут типы руками. Бонус такой дисциплины: тест, который регистрирует каждую константу из пакета задач в пустой mux, ловит и опечатки, и конфликты паттернов до деплоя.
Middleware для задач: mux.Use#
Middleware для задач — функция вида func(Handler) Handler: она получает следующий хендлер и возвращает обёртку, которая делает что-то до и после вызова. Регистрируются обёртки через mux.Use(logging, metrics) — сколько хотите, применяются в порядке записи. Из контекста задачи обёртки достают GetTaskID, GetQueueName и GetRetryCount — так одна строка лога получает идентификатор, очередь и номер попытки без правок хендлеров. Держите middleware лёгким: он живёт в горячем пути каждой задачи, и тяжёлая логика там умножается на весь поток. Важное следствие: middleware оборачивают каждый хендлер, найденный по паттерну, включая NotFoundHandler, — поэтому общий код пишется один раз, а не шесть раз по хендлерам. Содержимое обёртки — всё, что не относится к конкретной задаче: логирование типа и длительности, метрики по исходам, восстановление после паники, обогащение контекста. Если вы писали цепочки middleware в Gin, механика покажется родной — та же идея обёртки вокруг следующего хендлера, только вместо запроса и ответа здесь задача и ошибка. Единственная привычка, которую придётся перебороть: у задачи нет ответа с кодом — исход выражается ошибкой, и middleware-логика строится вокруг значения, которое вернул хендлер.
Middleware логирования в десять строк
GOfunc logging(h asynq.Handler) asynq.Handler {
return asynq.HandlerFunc(func(ctx context.Context, t *asynq.Task) error {
start := time.Now()
err := h.ProcessTask(ctx, t)
log.Printf("task=%s dur=%s err=%v", t.Type(), time.Since(start), err)
return err
})
}
mux.Use(logging)Middleware — функция вида func(Handler) Handler: она принимает следующий хендлер и возвращает обёртку. Внутри — любой код до и после вызова: здесь тип задачи, длительность и исход уходят в лог. HandlerFunc адаптирует замыкание к интерфейсу Handler. mux.Use применяет обёртки в порядке записи, и оборачивают они каждый найденный по паттерну хендлер — вместе с перехватом паники это заменяет самодельные recover в каждом хендлере.
Порядок строк mux.Use и mux.Handle не связан: обёртки применяются в момент диспетчеризации, а не регистрации, поэтому Use можно вызвать и после всех Handle — результат тот же. Условие одно — успеть до srv.Run: сервер начнёт выполнять задачи, и всё, что поставлено до вызова Use, пройдёт без обёртки. Живой mux технически позволяет дописывать middleware и на работающем процессе, но делать так не стоит — раскатка middleware должна быть таким же деплоем, как и код хендлеров.
Интерфейс asynq.Handler и структурные хендлеры#
Весь механизм держится на одном интерфейсе: type Handler interface { ProcessTask(context.Context, *asynq.Task) error }. Функции подходят благодаря адаптеру HandlerFunc — поэтому HandleFunc и Handle в конечном счёте делают одно и то же. Но структурный хендлер — это не синтаксическая игра: у него есть поля с зависимостями, и одна такая структура обслуживает все постановки своего типа, без замыканий на глобальные переменные. Конструктор принимает базу, mailer, конфиг; mux.Handle("report:daily", reportHandler) регистрирует готовое значение. Только помните: одна структура обслуживает все параллельные задачи своего типа — ProcessTask вызывается одновременно из нескольких горутин, и изменяемые поля без синхронизации станут гонкой. Состояние держите в зависимостях — базе, кэше, конфиге, — а не в самом хендлере. Такой хендлер тривиально тестируется: подставьте фейковые зависимости и вызовите ProcessTask руками. Отдельный вопрос — как доставить зависимости в контекст каждой задачи, если вы предпочитаете контекстный стиль: это тема семнадцатого урока, где сравниваются структурные хендлеры и обёртки с BaseContext. Мой ориентир простой: пока зависимостей меньше трёх, структурный хендлер выигрывает у контекстной магии читаемостью стектрейсов.
Проверка: сигнатура хендлера
Какую сигнатуру обязан иметь метод ProcessTask интерфейса asynq.Handler?
В каком порядке сработают обёртки?
Прочитайте код и выберите, что он выведет:
mux.Use(first, second)
// first и second печатают своё имя до и после вызова хендлераЧто прячут middleware задач#
- Логирование: тип задачи, длительность, номер попытки из контекста и исход с ошибкой.
- Метрики: счётчики обработанных и упавших, гистограммы длительности по типам задач.
- Recovery: ловля паники хендлера с превращением в ошибку и записью стектрейса.
- Обогащение контекста: идентификатор воркера, окружение, трассировочный ID из payload.
- Троттлинг: общий лимит на дорогие внешние вызовы для всех хендлеров разом.
- Нулевой повтор: всё это пишется один раз, а не копируется в шесть хендлеров.
Три способа получить обработчик#
| Способ | Что принимает | Когда выбирать |
|---|---|---|
| HandleFunc | функцию func(ctx, task) error | простой хендлер без зависимостей |
| Handle | значение с методом ProcessTask | структурный хендлер с полями: база, mailer, конфиг |
| NotFoundHandler | ничего — это дефолт mux | задача не совпала ни с одним паттерном |
Соберите регистрацию и запуск
Допишите конструктор mux, регистрацию функции-хендлера и запуск сервера с маршрутизацией.
Соберите сервер с middleware и двумя хендлерами
Расставьте строки в порядке сборки: mux, middleware, регистрации, сервер, запуск.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: ServeMux
Маршрутизация собрана: HandleFunc для функций, Handle для структурных хендлеров, точное совпадение поверх префиксного, ErrHandlerNotFound как честный сигнал рассинхрона, middleware как место для общего кода. Вместе с очередями из предыдущего урока это полная адресация: очередь говорит, где и когда задача будет исполнена, тип — кто её исполнит. Это разделение держится весь курс: новые главы добавляют измерения — время, уникальность, группы — не трогая оба освоенных. Дальше по курсу: девятый урок добавляет измерение времени — периодические задачи по cron-выражениям; десятый — группировку мелких задач в пачки. Мини-эксперимент: зарегистрируйте префиксный паттерн email и точный email:welcome, поставьте задачи обоих типов плюс один emailextra — и убедитесь, что третий уехал в архив с ErrHandlerNotFound, хотя строка начинается с email. Одна минута практики закрепит правило префикса лучше любого пересказа. Добавьте в эксперимент middleware-лог и посмотрите, как обёртка печатает тип даже для задачи, которая потом уйдёт в NotFound: обёртки оборачивают и его.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- ServeMux маршрутизирует по типу задачи: HandleFunc для функций, Handle для asynq.Handler.
- Точное совпадение паттерна выбирается первым; без него — самый длинный префикс типа.
- Повторная регистрация паттерна паникует при старте — конфликт ловится деплоем, не инцидентом.
- Задача без совпадений получает ErrHandlerNotFound и уходит в ретраи и архив.
- mux.Use оборачивает все найденные хендлеры: логи, метрики и recovery пишутся один раз.
- Middleware выполняются в порядке применения: первый Use — внешняя обёртка.
- Префикс — строковое начало без правил про разделители: держите иерархию типов честной.
- Типы задач живут в константах общего пакета — опечатка в паттерне иначе доживает до рантайма.
Домашнее задание
Соберите воркер с тремя типами задач и общим middleware: лог-обёртка печатает тип, длительность и исход. Добавьте префиксную регистрацию и убедитесь, что точный паттерн перекрывает её, а незнакомый тип с подходящим началом оседает в архиве с ErrHandlerNotFound. Затем вынесите строковые типы в общий пакет констант и замените литералы в регистрации — опечатки больше не компилируются. Всё локально: Go и Redis из Docker; песочница сайта покажет только тематическую программу на чистом Go.
Личные заметкиПусто
Вопросы и ответы по уроку
Можно ли менять регистрации после старта сервера?
Регистрации рассчитаны на инициализацию: mux не для горячего обновления в живом процессе. На практике это не ограничение — новый тип задачи появляется вместе с деплоем воркера, а Handle покрывает все типы одним вызовом при старте. Динамическое поведение делается внутри хендлера ветвлением, а не перерегистрацией.
Почему задача с незнакомым типом не падает сразу?
Постановка и маршрутизация разделены: клиент кладёт задачу в Redis, не зная о конфигурации воркеров. Незнакомый тип доходит до NotFoundHandler, возвращает ErrHandlerNotFound и проходит обычный путь ретраев в архив. Это честнее мгновенной ошибки: задача сохранена, и после раскатки воркера её можно запустить из архива. Помните про потолок попыток: если раскатка воркера задержится на дни, задачи с умолчанием в 25 попыток разойдутся по архиву с экспоненциальными паузами — раскатку воркеров не стоит сильно отставать от клиентов.
Один mux или отдельные серверы под разные очереди?
Начинайте с одного: mux маршрутизирует типы, сервер — очереди, и один процесс с одной регистрацией проще сопровождать. Отдельные серверы оправданы при разделённых деплоях, когда критические задачи обслуживают собственные реплики; регистрация при этом дублируется или выносится в общий пакет, потому что типы одни и те же.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Kafka в Go: продюсеры и консьюмеры на kafka-go
Курс Kafka для Go: топики и партиции, producer и consumer, группы, оффсеты, гарантии доставки, rebalance, Admin API и мониторинг. 25 уроков.
- Понимать топики, партиции, оффсеты и группы консьюмеров
- Писать продюсеры с ключами, батчами и ретраями
- Читать сообщения группой с коммитами оффсетов
- Выбирать гарантии: at-most-once, at-least-once