Содержание урока · 13 разделов
- echo middleware: зачем обёртка над обработчиком
- Сигнатура MiddlewareFunc и миф про c.Next
- e.Use: порядок подключения — это порядок обёрток
- Луковица: до next(c) — запрос, после — ответ
- c.Set и c.Get: память на один запрос
- Куда встаёт middleware: уровни и момент
- e.Pre: middleware до маршрутизации
- Прерывание цепочки: return без next(c)
- Порядок, который нельзя путать: логгер раньше recovery
- Что дальше: встроенные middleware и свой слой
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
echo middleware: зачем обёртка над обработчиком#
В конце прошлого урока в каждом обработчике появилась одна и та же церемония: c.Bind, нормализация, c.Validate. А теперь представьте, что к этому добавилось логирование каждого запроса, замер времени ответа и проверка авторизации. Три if-а и два лога в каждом из двадцати обработчиков — и вот уже половина кода API это копипаста, которая расходится между обработчиками по мере правок.
Механизм echo middleware решает это структурно: слой — функция, которая принимает следующий обработчик цепочки и возвращает новый. Вы пишете логику один раз и подключаете её к маршруту, группе или всему серверу — а Echo сама проводит каждый запрос через все слои по очереди. Такая конструкция называется цепочка middleware: запрос проходит через слои снаружи внутрь, обработчик формирует ответ, и ответ идёт обратно через те же слои. Роутинг мы собирали в уроке про маршрутизацию, конвейер bind-validate — в уроке про привязку и валидацию; middleware — это третий слой архитектуры Echo, который оборачивает их все.
Сигнатура MiddlewareFunc и миф про c.Next#
В echo.go всё описано двумя строчками. type HandlerFunc func(c Context) error — так выглядят обработчики, к которым вы привыкли. А type MiddlewareFunc func(next HandlerFunc) HandlerFunc — middleware принимает следующий обработчик и возвращает новый. Смотрите, что здесь спрятано: в Echo следующий слой приходит вам аргументом next, и вы вызываете его как обычную функцию — next(c). Никакого метода c.Next() в Echo нет — ни в четвёртой версии, ни в пятой. c.Next() — это API Gin и Fiber, и его же пишут в старых постах про Echo по памяти о других фреймворках. Если такое встретите в рецепте про echo — рецепт либо устарел, либо скопирован из чужого фреймворка.
Второе, что стоит разглядеть в сигнатуре: MiddlewareFunc — это просто тип функции, и его удобно возвращать из фабрики. middleware.Logger() возвращает echo.MiddlewareFunc: сначала конфигурация, потом готовый слой. Ваши middleware будут строиться так же — либо голой функцией вида func(next echo.HandlerFunc) echo.HandlerFunc, либо фабрикой с настройками, которая возвращает такой же тип. Для совместимости с net/http-стеками в echo.go есть WrapMiddleware — он оборачивает классическую func(http.Handler) http.Handler в echo.MiddlewareFunc, но это уже мост в мир стандартной библиотеки, а не наш главный путь.
Логгер-таймер: код до next(c) и после
GOfunc loggerTimer(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
start := time.Now() // фаза запроса: до прохода по цепочке
err := next(c) // дальше по цепочке — к обработчику
if err != nil {
c.Error(err) // глобальный обработчик сформирует ответ
}
stop := time.Now() // фаза ответа: всё уже отработало
fmt.Printf("%s %s -> %d %s\n",
c.Request().Method, c.Path(),
c.Response().Status, stop.Sub(start))
return err
}
}
// подключение в main():
e.Use(loggerTimer)Три обязательные детали. Первая: return func(c echo.Context) error — middleware возвращает замыкание, и именно оно исполняется на каждый запрос; внешняя функция вызывается один раз при подключении. Вторая: err := next(c) — мы сохраняем ошибку, которую вернули внутренние слои, чтобы вернуть её наверх дальше. c.Error(err) зовёт глобальный HTTPErrorHandler прямо здесь, как это делает штатный middleware.Logger в исходниках Echo. Третья: строки после next(c) исполняются, когда внутренние слои уже закончили — c.Response().Status к этому моменту содержит реальный статус ответа, а stop.Sub(start) — честное время обработки. Импорты: fmt, time, net/http для полного файла. Про песочницу: Echo не входит в браузерный движок сайта, поэтому весь код урока запускается локально — go run и curl, как и в прошлых уроках курса.
e.Use: порядок подключения — это порядок обёрток#
e.Use добавляет middleware в общий стек сервера. Сам метод тривиален: e.middleware = append(e.middleware, middleware...) — всё, что вы передали (а это variadic, можно перечислить несколько через запятую), дописывается в конец среза. Но интересное — в том, как Echo из этого среза собирает цепочку. Внутри ServeHTTP сработает applyMiddleware: цикл идёт с конца среза к началу, и каждый элемент оборачивает текущий обработчик. Результат: последний добавленный слой оказывается ближайшим к обработчику, а самый первый добавленный — самым внешним.
Поэтому порядок вызовов e.Use — это не декорация, а архитектурное решение. e.Use(loggerTimer), затем e.Use(authStub) означает: логгер видит запрос первым и провожает его последним, аутентификация — второй и её строка «после» сработает раньше логгеровой. Если поменять вызовы местами — изменится всё: кто первый поставил метку времени, кто последним видит статус ответа, кто обрабатывает ошибки соседей. Док-комментарий к e.Use в echo.go лаконичен: adds middleware to the chain which is run after router — после маршрутизатора. Это не про порядок соседей, а про то, что маршрутизация уже случилась, когда слои начинают работать. Про то, что бывает до маршрутизатора, — отдельная история ниже.
Какой порядок строк напечатает сервер
Прочитайте код и выберите, что он выведет:
// два middleware и обработчик:
func a(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
fmt.Println("A: до")
err := next(c)
fmt.Println("A: после")
return err
}
}
// b — то же самое, печатает "B: до" и "B: после"
// в main():
e.Use(a)
e.Use(b)
e.GET("/", func(c echo.Context) error {
fmt.Println("H: обработчик")
return c.NoContent(http.StatusOK)
})Луковица: до next(c) — запрос, после — ответ#
Картину из predict-а называют луковицей: запрос проходит сквозь слои снаружи внутрь, обработчик сидит в центре, ответ выходит обратно сквозь те же слои. У каждого middleware поэтому две половины жизни. Код до next(c) работает в фазе запроса: здесь можно померить start, проверить аутентификацию, дополнить запрос данными, отклонить его совсем. Код после next(c) работает в фазе ответа: здесь есть итоговый статус, время обработки, и здесь можно дописать заголовки — при условии, что ответ ещё не отправлен клиенту.
Именно на этой модели стоят почти все штатные middleware Echo: таймер и статус у Logger-а живут по обе стороны next(c), у Recover-а — отложенный recover(), который ловит панику из внутренних слоёв, пока кадры unwind-ятся назад через цепочку. Отсюда же практическое правило: тот, кто должен видеть всё, добавляется первым. Логгер, добавленный последним, увидит только то, что не съели соседние слои. А тот, кто отклоняет запросы, ставится после логгера — тогда каждый отказ тоже попадёт в лог. Порядок в e.Use читайте как маршрут запроса снаружи внутрь.
Аутентификация-заглушка на Basic Auth
GO// заглушка: в реальных проектах пары берут из базы,
// а вместо Basic Auth ставят JWT или сессии
func authStub(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
user, pass, ok := c.Request().BasicAuth()
if !ok || user != "alice" || pass != "secret123" {
// прерывание цепочки: обработчик запрос не увидит
return echo.NewHTTPError(
http.StatusUnauthorized,
"нужна basic-аутентификация",
)
}
c.Set("username", user) // данные для звеньев ниже
return next(c)
}
}Слева от if — фаза запроса: разбор заголовка Authorization методом BasicAuth() обычного *http.Request (Echo не нужна своя обёртка, чтобы достать заголовок). Не прошли проверку — возвращаем echo.NewHTTPError(http.StatusUnauthorized, ...) и next(c) не вызываем: это прерывание цепочки, обработчик и все слои ниже не исполнятся. Прошли — кладём имя пользователя в контекст методом c.Set и пропускаем запрос дальше. Возвращаемое из next(c) значение обязано уйти наверх без изменений: снаружи сидят другие слои, например наш loggerTimer, и они ждут эту ошибку.
c.Set и c.Get: память на один запрос#
Аутентификация выяснила, кто стучится, — как об этом узнает обработчик? Для таких передач между звеньями у echo.Context есть пара c.Set(key string, val any) и c.Get(key string) any. Внутри — карта Map (в echo.go это type Map map[string]interface{}), которая создаётся лениво при первом c.Set и лежит прямо в контексте запроса. Больше того, Echo хранит контексты в пуле и переиспользует их между запросами — c.Reset чистит хранилище, поэтому данные, положенные в c.Set, живут ровно один запрос и никогда не перетекут в соседний.
Важно читать контракт типов: c.Get возвращает any, а не строку. Доставать значение надо type assertion-ом, лучше ok-формой: name, ok := c.Get("username").(string). Без неё assert на неожиданном типе роняет программу паникой. Ключи — строки, и на реальном проекте их держат в константах (ctxUsername = "username"), чтобы опечатка в двух местах не превратилась в баг, который молча отдаст пустую строку. Примеры из самих исходников Echo: CSRF-middleware кладёт токен через c.Set("csrf", token), а роутер при 405 через c.Get достаёт заголовок Allow. Это штатный способ нести данные между звеньями, а не хак.
Проверка: как достать то, что положили в c.Set
В authStub выполнено c.Set("username", "alice"). В обработчике нужно получить имя. Какая строка корректна?
Защищённая группа и обработчик с c.Get
GOfunc listUsers(c echo.Context) error {
// ok-форма: ключ могли не установить выше
name, ok := c.Get("username").(string)
if !ok {
return echo.NewHTTPError(
http.StatusInternalServerError,
"authStub не сработал: ключ username отсутствует в контексте",
)
}
return c.JSON(http.StatusOK, echo.Map{
"message": "служебный список пользователей",
"from": name,
})
}listUsers читает из контекста то, что положила аутентификация, и отвечает JSON-ом. echo.Map — сокращение из echo.go для map[string]interface{}: удобно для сборных ответов, хотя для стабильных контрактов, как мы решили в уроке про ответы, лучше структуры. Проверка ok — это страховка на случай, когда middleware забыли подключить: маршруты группы без authStub дойдут сюда с пустым контекстом, и лучше получить явную ошибку в логе, чем поле from без значения. Внутренние кавычки в сообщении ошибки — обычные, Go-строка в двойных кавычках.
main(): глобальный слой и защищённая группа
GOfunc main() {
e := echo.New()
// глобальный слой: каждый запрос, включая 404
e.Use(loggerTimer)
// группа с middleware: authStub получит только /admin/*
admin := e.Group("/admin", authStub)
admin.GET("/users", listUsers)
// публичные маршруты: аутентификации нет
e.GET("/todos", listTodos)
e.POST("/todos", createTodo)
e.Logger.Fatal(e.Start(":8080"))
}Здесь встречаются три уровня подключения. e.Use(loggerTimer) — глобально: слой увидит вообще все запросы, включая те, что кончатся 404 — маршрутизатор в этом случае подставляет штатный NotFoundHandler, и цепочка всё равно проходит через глобальные слои. e.Group("/admin", authStub) — группа: middleware получает только маршруты, зарегистрированные через admin.GET/admin.POST и так далее; на /todos он не сработает. Третий уровень — route-level: e.GET(path, handler, authStub) — middleware для одного-единственного маршрута; он встаёт ближе всех к обработчику. Внутри группы порядок такой: глобальные слои снаружи, затем group-middleware, затем route-middleware, затем обработчик.
Куда встаёт middleware: уровни и момент#
| Способ | Момент в цепочке | Видит ли 404 | Типичное дело |
|---|---|---|---|
| e.Pre(mw) | до маршрутизации | да, и может изменить путь | переписать URL, добавить или убрать слэш |
| e.Use(mw) | после маршрутизации, снаружи всех | да — обработчиком станет NotFoundHandler | логи, таймеры, recovery, метрики |
| e.Group("/admin", mw) или g.Use(mw) | после глобальных, только для маршрутов группы | только внутри префикса: группа ставит catch-all | аутентификация, права доступа |
| e.GET(path, h, mw) | ближе всех к обработчику | нет | точечная логика одного маршрута |
e.Pre: middleware до маршрутизации#
e.Use-слои начинают работу после того, как роутер нашёл маршрут: в ServeHTTP сначала вызывается Find по дереву маршрутов, и только потом обработчик оборачивается стеком e.middleware. Значит, повлиять на сам выбор маршрута из них уже нельзя. Для этого есть e.Pre — premiddleware. Тот же тип MiddlewareFunc, та же луковица, но вставлена она раньше: premiddleware обёртывает не найденный обработчик, а замыкание, внутри которого происходит маршрутизация. Док-комментарий: adds middleware to the chain which is run before router.
Практическое применение у Pre узкое и честное: всё, что должно случиться до выбора маршрута. Штатные примеры — middleware.Rewrite, AddTrailingSlash и RemoveTrailingSlash: они правят r.URL.Path до того, как роутер сверится с таблицей, поэтому /admin/users/ и /admin/users попадают на один маршрут вместо 404 у одного из них. Всё остальное — логи, таймеры, аутентификация — живёт в e.Use: там уже известно, какой маршрут выбран, есть c.Path() и есть доступ к данным маршрута. Забавное следствие устройства: при несуществующем пути premiddleware и e.Use-слои всё равно исполнятся — 404 в Echo это тоже обработчик, просто штатный, а не ваш.
Прерывание цепочки: return без next(c)#
Пока каждый слой зовёт next(c), запрос движется вглубь. Но middleware имеет полное право этого не делать — и это главный рычаг контроля в echo middleware. Прерывание выглядит как обычный return. Вариант первый: return err — вернуть ошибку, не дойдя до обработчика. Ошибка начинает раскручивать цепочку обратно: каждый внешний слой получает её как результат next(c), может что-то сделать (залогировать, обернуть) и возвращает дальше. В самом конце ServeHTTP видит err != nil и зовёт глобальный HTTPErrorHandler: для *echo.HTTPError клиент получит её код и message в JSON, для любой другой ошибки — 500 Internal Server Error.
Вариант второй: написать ответ самому и вернуть nil. c.JSON(http.StatusUnauthorized, ...) прямо в middleware, return nil — клиент получит ровно ваш ответ, глобальный обработчик не вызовется, потому что ошибка не родилась. Оба варианта легальны, но первый лучше сочетается с остальной системой: HTTPError несёт статус и сообщение, обработчик оформит ответ единообразно, а логгер снаружи увидит ошибку в возвращаемом значении. Общее у обоих вариантов одно: звенья ниже не исполняются. Обработчик даже не узнает, что к нему кто-то стучался.
Проверка: next(c) забыли, вернули nil
В middleware никто не вызвал next(c) и не записал ничего в ответ, функция вернула nil. Что увидит клиент?
Что покажет curl -i без аутентификации
Прочитайте код и выберите, что он выведет:
// цепочка из main() выше:
// e.Use(loggerTimer); admin := e.Group("/admin", authStub)
// curl -i localhost:8080/admin/users
// без заголовка AuthorizationПорядок, который нельзя путать: логгер раньше recovery#
Луковица делает некоторые порядки принципиально неправильными. Возьмём recovery — middleware с отложенным recover(), превращающий панику в ошибку 500. Паника раскручивает стек кадров по одному, и в каждом middleware код после next(c) просто не исполняется — кадр убит. Если recovery добавить раньше логгера (то есть recovery окажется снаружи), то паника из обработчика пролетит сквозь логгер, не дав ему напечатать строку, и остановится на recovery. Запрос уйдёт с 500, а в логе — ничего.
Именно поэтому в примере из доккомментария самого пакета echo порядок такой: e.Use(middleware.Logger()), затем e.Use(middleware.Recover()) — логгер снаружи, recovery внутри. Логгер печатает строку на каждый запрос, включая паникующий; recovery конвертирует панику в ошибку и возвращает её, логгер фиксирует статус 500. Тот же принцип для ваших слоёв: сначала то, что должно видеть всё, потом то, что может отклонить или уронить. И ещё один порядок в голове: c.Error(err) внутри middleware уже фиксирует ответ — после него писать в ответ поздно, Response становится committed, и любая следующая попытка WriteHeader даст в логе net/http предупреждение о superfluous WriteHeader.
next — обычная функция, и никто не запретит вызвать её два раза: обработчик исполнится дважды, оба раза честно. Скажем, дважды вставится задача в хранилище. Той же природы и обратная ошибка: записать ответ в middleware, вернуть err наверх и позволить обработчику ошибок попытаться дописать — в лог прилетит superfluous response.WriteHeader, а клиенту уйдёт то, что успело отправиться первым. Дисциплина: один next(c) на ветку исполнения; записали ответ — возвращайте nil.
Штатные middleware Echo не жёсткие: у каждого конфига есть поле Skipper — функция func(c echo.Context) bool, и если она вернула true, слой молча делает return next(c) (в middleware/middleware.go описан и DefaultSkipper, который всегда возвращает false). Вашим middleware можно дать такую же опцию: фабрика с конфигом, в конфиге Skipper, в начале замыкания проверка. Это спасает, когда health-check-запросы замусоривают лог или аутентификация не нужна на /healthz.
Восстановите middleware аутентификации
Соберите authStub: типы middleware, доступ к запросу, статус ошибки и кладка данных в контекст.
Соберите main() с цепочкой
Расставьте строки сборки сервера в правильном порядке: глобальный слой, защищённая группа, публичные маршруты, запуск.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Что дальше: встроенные middleware и свой слой#
Мы собрали руками то, что Echo даёт готовым: у пакета middleware есть Logger с конфигурируемым форматом, Recover с печатью стека, RequestID, Timeout, Gzip и ещё дюжина слоёв на все случаи — от CORS до rate limit; их разбор — тема следующего урока про встроенные middleware. Ваша связка логгер-таймер плюс authStub — не учебная игрушка: замена fmt.Printf на структурный логгер и заглушки на настоящую проверку паролей превращает её в боевой каркас.
И вернёмся к конвейеру из прошлого урока: bind → normalize → validate — три одинаковые строки в каждом обработчике. Это классический кандидат на обёртку: middleware, который прогоняет валидацию до того, как обработчик получит управление, — с Skipper-ом и передачей результата через c.Set. Попробуйте сделать это домашним упражнением — в задании ниже есть каркас. Навигация по курсу — на странице курса Echo; устройство ответов и статусов, вокруг которых всё это крутится, разобрано в уроке про запросы и ответы, а конвейер валидации, который теперь можно обернуть, — в уроке про привязку и валидацию.
Карточки: middleware и цепочки
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- echo.MiddlewareFunc — это func(next echo.HandlerFunc) echo.HandlerFunc: следующий слой приходит аргументом и зовётся next(c); c.Next() — API Gin и Fiber, в Echo такого метода нет.
- e.Use добавляет middleware в глобальный стек (после маршрутизации), e.Pre — в premiddleware (до): только Pre может повлиять на выбор маршрута, например переписать путь.
- Порядок подключения = порядок обёрток: последний добавленный слой ближе всех к обработчику; Logger добавляют раньше Recover, чтобы паникующие запросы попадали в лог.
- У middleware две половины: до next(c) — фаза запроса, после — фаза ответа с финальным статусом и временем; рядом с ответом следите за committed.
- c.Set/c.Get — карта на один запрос для передачи данных между звеньями; c.Get возвращает any, доставайте ok-формой type assertion.
- Прерывание цепочки: return HTTPError — ответ оформит глобальный обработчик; свой ответ плюс return nil — клиент получит ровно ваш вывод; в обоих случаях обработчик не исполняется.
- Три уровня: e.Use глобально, e.Group/g.Use для префикса, route-level middleware для одного маршрута; у штатных слоёв есть Skipper, чтобы пропускать ненужные пути.
Домашнее задание
Разверните пример урока: добавьте middleware timingMetric, который печатает только запросы дольше 100 миллисекунд, и поставьте его между loggerTimer и группой — проверьте через curl, что он видит статус и время. Затем напишите validate-middleware: он берёт из контекста структуру, положенную туда до вызова next(c), прогоняет через c.Validate и на ошибку возвращает 400, не доходя до обработчика. Проверьте оба случая: аутентифицированный запрос к /admin/users и запрос без заголовка Authorization — в логе должны быть строки со статусами 200 и 401.
Личные заметкиПусто
Вопросы и ответы по уроку
Чем e.Use отличается от e.Pre в Echo?
e.Use добавляет middleware, который исполняется после маршрутизации: уже известен выбранный маршрут, и слой обрабатывает все запросы, включая 404. e.Pre — premiddleware до роутера: он может изменить путь запроса (Rewrite, trailing slash) и тем самым повлиять на выбор маршрута, но не знает, какой маршрут совпадёт.
Почему в коде Echo нет c.Next()?
В Echo v4 и v5 middleware — это echo.MiddlewareFunc: функция, которая получает следующий обработчик аргументом next и возвращает новый HandlerFunc. Вызов идёт как next(c). Метод c.Next() есть в Gin и Fiber, и его ошибочно переносят в рецепты про Echo — рабочим такой код не будет.
Как остановить цепочку middleware и отдать ошибку?
Верните из middleware ошибку, не вызывая next(c): echo.NewHTTPError(http.StatusUnauthorized, "...") даст клиенту 401 с JSON-телом через глобальный обработчик ошибок, любая другая ошибка станет 500. Если нужен полностью свой ответ — напишите его через c.JSON и верните nil.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
GORM: ORM-библиотека для Go
Курс GORM: модели, CRUD, ассоциации, транзакции, хуки, пагинация, оптимизация запросов. 25 уроков с примерами и проектом слоя данных.
- Подключать БД и настраивать пул соединений
- Описывать модели и связи: 1:1, 1:N, M:N
- Писать CRUD и сложные выборки chainable-стилем
- Контролировать N+1 через Preload