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

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

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

Урок 8 из 25 ~16 мин +50 XP

ServeMux: маршрутизация задач

Маршрутизация задач — вопрос «кто обработает этот тип»: asynq servemux сопоставляет тип задачи с хендлером так же, как net/http сопоставляет путь запроса. Разберём HandleFunc и Handle, префиксные паттерны, NotFound и middleware для задач через mux.Use.

Содержание урока · 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 собирают при старте и дальше он только диспетчеризует.

Шесть типов задач без одного свитча

GO
mux := 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" — сразу ясно, какая константа доехала до воркера раньше него. Лечение процессное, а не кодовое: типы задач появляются в общем пакете констант, воркеры катятся раньше клиентов или вместе с ними, и постановка нового типа включается фича-флагом после раскатки. Разница между «воркеры раньше клиентов» и «фича-флаг после раскатки» — в цене: первый вариант ничего не стоит, кроме дисциплины пайплайна, второй спасает, когда раскатки независимы. Проверка соответствия констант и паттернов — хороший юнит-тест, о котором подробнее говорит двадцать второй урок.

Middleware для задач: mux.Use#

Middleware для задач — функция вида func(Handler) Handler: она получает следующий хендлер и возвращает обёртку, которая делает что-то до и после вызова. Регистрируются обёртки через mux.Use(logging, metrics) — сколько хотите, применяются в порядке записи. Из контекста задачи обёртки достают GetTaskID, GetQueueName и GetRetryCount — так одна строка лога получает идентификатор, очередь и номер попытки без правок хендлеров. Держите middleware лёгким: он живёт в горячем пути каждой задачи, и тяжёлая логика там умножается на весь поток. Важное следствие: middleware оборачивают каждый хендлер, найденный по паттерну, включая NotFoundHandler, — поэтому общий код пишется один раз, а не шесть раз по хендлерам. Содержимое обёртки — всё, что не относится к конкретной задаче: логирование типа и длительности, метрики по исходам, восстановление после паники, обогащение контекста. Если вы писали цепочки middleware в Gin, механика покажется родной — та же идея обёртки вокруг следующего хендлера, только вместо запроса и ответа здесь задача и ошибка. Единственная привычка, которую придётся перебороть: у задачи нет ответа с кодом — исход выражается ошибкой, и middleware-логика строится вокруг значения, которое вернул хендлер.

Middleware логирования в десять строк

GO
func 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 в каждом хендлере.

Интерфейс 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, регистрацию функции-хендлера и запуск сервера с маршрутизацией.

mux := asynq.() mux.("email:welcome", handleWelcomeEmail) srv := asynq.NewServer(opt, asynq.Config{Concurrency: 10}) srv.(mux)

Соберите сервер с middleware и двумя хендлерами

Расставьте строки в порядке сборки: mux, middleware, регистрации, сервер, запуск.

Строки

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

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

Карточки: ServeMux

Термин

ServeMux

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

Маршрутизатор задач asynq: сопоставляет тип задачи с зарегистрированным хендлером и передаётся в srv.Run.

1 / 6

Маршрутизация собрана: 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.

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

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

Можно ли менять регистрации после старта сервера?

Регистрации рассчитаны на инициализацию: mux не для горячего обновления в живом процессе. На практике это не ограничение — новый тип задачи появляется вместе с деплоем воркера, а Handle покрывает все типы одним вызовом при старте. Динамическое поведение делается внутри хендлера ветвлением, а не перерегистрацией.

Почему задача с незнакомым типом не падает сразу?

Постановка и маршрутизация разделены: клиент кладёт задачу в Redis, не зная о конфигурации воркеров. Незнакомый тип доходит до NotFoundHandler, возвращает ErrHandlerNotFound и проходит обычный путь ретраев в архив. Это честнее мгновенной ошибки: задача сохранена, и после раскатки воркера её можно запустить из архива. Помните про потолок попыток: если раскатка воркера задержится на дни, задачи с умолчанием в 25 попыток разойдутся по архиву с экспоненциальными паузами — раскатку воркеров не стоит сильно отставать от клиентов.

Один mux или отдельные серверы под разные очереди?

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

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

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

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

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

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

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

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