---
title: "ServeMux: маршрутизация задач"
description: "Asynq servemux и маршрутизация задач: HandleFunc и Handle по типу, префиксные паттерны, NotFound с ErrHandlerNotFound и middleware для задач через mux.Use."
course: "Asynq в Go: очередь фоновых задач на Redis"
url: "https://golangmaster.ru/courses/asynq/lessons/08-mux-marshruty/"
published: "2026-09-19"
updated: "2026-09-23"
minutes: 16
---

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

*Курс «Asynq в Go: очередь фоновых задач на Redis», урок 8 из 25. ~16 минут. Обновлено 23.09.2026. Источник: https://golangmaster.ru/courses/asynq/lessons/08-mux-marshruty/*

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

У сервиса уже шесть типов задач, и в 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?

- email: точное совпадение проигрывает более длинному паттерну
- ✅ email:welcome: точное совпадение выбирается первым
- Оба хендлера по очереди
- Ошибка: паттерны конфликтуют между собой

Mux сначала ищет точное совпадение и возвращает его сразу. Префиксный поиск — второй шаг для типов без точного совпадения: задача email:digest уйдёт бы паттерну email, но у welcome есть собственная регистрация.

### NotFound: задача без хендлера

Если ни точного, ни префиксного совпадения не нашлось, задачу получает NotFoundHandler — встроенный хендлер, который возвращает ошибку, оборачивающую asynq.ErrHandlerNotFound, с типом задачи в сообщении. Дальше включается уже знакомая механика шестого урока: ошибка считается сбоем, задача идёт в повторы с экспонентой и, исчерпав попытки, оседает в архиве с этой ошибкой. Реальный сценарий, где это случается: клиент и воркер деплоятся раздельно, клиент уехал вперёд и начал ставить задачи нового типа, а воркеры ещё его не знают. Симптом заметный: в архиве оседают задачи с ErrHandlerNotFound при здоровом Redis и живых воркерах. Разбору помогает то, что в текст ошибки подставлен тип задачи: в LastErr архива будет строка вида handler not found for task "report:new" — сразу ясно, какая константа доехала до воркера раньше него. Лечение процессное, а не кодовое: типы задач появляются в общем пакете констант, воркеры катятся раньше клиентов или вместе с ними, и постановка нового типа включается фича-флагом после раскатки. Разница между «воркеры раньше клиентов» и «фича-флаг после раскатки» — в цене: первый вариант ничего не стоит, кроме дисциплины пайплайна, второй спасает, когда раскатки независимы. Проверка соответствия констант и паттернов — хороший юнит-тест, о котором подробнее говорит [двадцать второй урок](/courses/asynq/lessons/22-testy-zadach/).

> **Живое место: опечатка в паттерне ловится рантаймом.** Клиент ставит задачу с константой 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 логирования в десять строк

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

> **Use до регистраций или после — без разницы.** Порядок строк 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 руками. Отдельный вопрос — как доставить зависимости в контекст каждой задачи, если вы предпочитаете контекстный стиль: это тема [семнадцатого урока](/courses/asynq/lessons/17-kontekst-v-zadachah/), где сравниваются структурные хендлеры и обёртки с BaseContext. Мой ориентир простой: пока зависимостей меньше трёх, структурный хендлер выигрывает у контекстной магии читаемостью стектрейсов.

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

**Вопрос:** Какую сигнатуру обязан иметь метод ProcessTask интерфейса asynq.Handler?

- ProcessTask(task *asynq.Task) error
- ✅ ProcessTask(ctx context.Context, task *asynq.Task) error
- ProcessTask(ctx context.Context, task *asynq.Task)
- ProcessTask(ctx context.Context, typ string, payload []byte) error

Интерфейс — ProcessTask(context.Context, *asynq.Task) error: контекст приходит от сервера с дедлайном, задача несёт тип и payload, ошибка решает судьбу попытки. Функции с такой же сигнатурой регистрируются через HandleFunc благодаря адаптеру HandlerFunc.

### В каком порядке сработают обёртки?

```go
mux.Use(first, second)
// first и second печатают своё имя до и после вызова хендлера
```

**Что выведет программа?**

- ✅ first до, second до, хендлер, second после, first после
- second до, first до, хендлер, first после, second после
- Только хендлер: обёртки ничего не вызывают
- Порядок не определён и меняется между запусками

Middleware выполняются в порядке применения: первый Use становится внешней обёрткой, второй — внутренней. Внутри библиотеки цепочка собирается с конца списка, поэтому последний применённый сидит ближе всего к хендлеру. Тот же порядок, что и у привычных HTTP-цепочек.

### Что прячут middleware задач

- Логирование: тип задачи, длительность, номер попытки из контекста и исход с ошибкой.
- Метрики: счётчики обработанных и упавших, гистограммы длительности по типам задач.
- Recovery: ловля паники хендлера с превращением в ошибку и записью стектрейса.
- Обогащение контекста: идентификатор воркера, окружение, трассировочный ID из payload.
- Троттлинг: общий лимит на дорогие внешние вызовы для всех хендлеров разом.
- Нулевой повтор: всё это пишется один раз, а не копируется в шесть хендлеров.

### Три способа получить обработчик

| Способ | Что принимает | Когда выбирать |
| --- | --- | --- |
| HandleFunc | функцию func(ctx, task) error | простой хендлер без зависимостей |
| Handle | значение с методом ProcessTask | структурный хендлер с полями: база, mailer, конфиг |
| NotFoundHandler | ничего — это дефолт mux | задача не совпала ни с одним паттерном |

### Соберите регистрацию и запуск

Допишите конструктор mux, регистрацию функции-хендлера и запуск сервера с маршрутизацией.

```go
mux := asynq.___()

mux.___("email:welcome", handleWelcomeEmail)

srv := asynq.NewServer(opt, asynq.Config{Concurrency: 10})
srv.___(mux)
```

Подсказка: Конструктор — NewServeMux, регистрация функции — HandleFunc, сервер запускается методом Run с mux.

Правильные ответы в пропусках: `NewServeMux`, `HandleFunc`, `Run`

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

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

Правильный порядок строк:

1. `mux := asynq.NewServeMux()`
2. `mux.Use(logging)`
3. `mux.HandleFunc("email:welcome", handleWelcomeEmail)`
4. `reportHandler := &ReportHandler{DB: db}`
5. `mux.Handle("report:daily", reportHandler)`
6. `srv := asynq.NewServer(opt, asynq.Config{Concurrency: 10})`
7. `srv.Run(mux)`

### Карточки: ServeMux

- **ServeMux** — Маршрутизатор задач asynq: сопоставляет тип задачи с зарегистрированным хендлером и передаётся в srv.Run.
- **HandleFunc** — Регистрация функции func(ctx, task) error по паттерну-типу задачи. Самый короткий путь для простых хендлеров.
- **Префиксное совпадение** — Второй шаг выбора: если точного совпадения нет, побеждает самый длинный паттерн, являющийся началом типа. Порядок регистрации не важен.
- **ErrHandlerNotFound** — Ошибка NotFoundHandler для задачи без совпадений: уходит в ретраи и архив. Симптом рассинхронизации клиента и воркера.
- **MiddlewareFunc** — Функция func(Handler) Handler: обёртка вокруг следующего хендлера. Регистрируется mux.Use и применяется в порядке записи.
- **Handler** — Интерфейс с методом ProcessTask(context.Context, *asynq.Task) error. Основа и для функций (через HandlerFunc), и для структурных хендлеров.

Маршрутизация собрана: HandleFunc для функций, Handle для структурных хендлеров, точное совпадение поверх префиксного, ErrHandlerNotFound как честный сигнал рассинхрона, middleware как место для общего кода. Вместе с очередями из предыдущего урока это полная адресация: очередь говорит, где и когда задача будет исполнена, тип — кто её исполнит. Это разделение держится весь курс: новые главы добавляют измерения — время, уникальность, группы — не трогая оба освоенных. Дальше по курсу: [девятый урок](/courses/asynq/lessons/09-periodicheskie-zadachi/) добавляет измерение времени — периодические задачи по cron-выражениям; [десятый](/courses/asynq/lessons/10-grupy-agregaciya/) — группировку мелких задач в пачки. Мини-эксперимент: зарегистрируйте префиксный паттерн email и точный email:welcome, поставьте задачи обоих типов плюс один emailextra — и убедитесь, что третий уехал в архив с ErrHandlerNotFound, хотя строка начинается с email. Одна минута практики закрепит правило префикса лучше любого пересказа. Добавьте в эксперимент middleware-лог и посмотрите, как обёртка печатает тип даже для задачи, которая потом уйдёт в NotFound: обёртки оборачивают и его.

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

- 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 маршрутизирует типы, сервер — очереди, и один процесс с одной регистрацией проще сопровождать. Отдельные серверы оправданы при разделённых деплоях, когда критические задачи обслуживают собственные реплики; регистрация при этом дублируется или выносится в общий пакет, потому что типы одни и те же.
