---
title: "Встроенные middleware в Echo: Logger, Recover, RequestID, Timeout, Gzip"
description: "Каталог встроенных middleware Echo: Logger и теги fasttemplate, Recover и стек паники, RequestID, Timeout и ContextTimeout, Gzip, Static и Skipper."
course: "Echo: минималистичный быстрый фреймворк"
url: "https://golangmaster.ru/courses/echo/lessons/07-vstroennye-middleware/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Встроенные middleware в Echo: Logger, Recover, RequestID, Timeout, Gzip

*Курс «Echo: минималистичный быстрый фреймворк», урок 7 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/echo/lessons/07-vstroennye-middleware/*

echo logger — это не одна функция, а целый каталог: пакет middleware в Echo даёт готовые слои на каждый сквозной процесс — логи, восстановление после паники, идентификатор запроса, таймауты, сжатие, статику. Разберём Logger и его fasttemplate-формат, RecoverConfig, RequestID, историю Timeout и замену ContextTimeout, Gzip с порогом MinLength, Static и общий механизм Skipper для выключения слоя на отдельных путях.

### echo logger: каталог штатных middleware

В прошлом уроке мы собрали loggerTimer руками: метка start, err := next(c), c.Error(err) на ошибку, печать статуса и времени после. У Echo всё это уже есть в коробке: пакет github.com/labstack/echo/v4/middleware — каталог готовых слоёв, [обёрнутых вокруг next(c)](/courses/echo/lessons/06-middleware/) той же сигнатурой echo.MiddlewareFunc. Двадцать с лишним файлов: Logger и RequestLogger, Recover, RequestID, Timeout и ContextTimeout, Gzip, Static, плюс CORS, CSRF, BasicAuth, KeyAuth, RateLimiter, BodyLimit, Redirect, Proxy и другие. Единственное заметное отсутствие — JWT: в четвёртой версии его вынесли в отдельный модуль labstack/echo-jwt, и это скорее плюс — модуль живёт своим циклом обновлений.

Все встроенные слои устроены по одному шаблону, и его стоит выучить как таблицу умножения: структура конфига XConfig с полем Skipper, пакетная переменная DefaultXConfig с дефолтами, две фабрики — X() с настройками по умолчанию и XWithConfig(config) для кастомизации. Внутри каждой фабрики — те же две фазы, что вы писали сами: код до next(c) и код после. В этом уроке разберём echo logger и echo recover под микроскопом, затем RequestID, семейство таймаутов, Gzip и Static, а механизм Skipper — общий для всех конфигов — покажем как штатный способ выключать слой для отдельных путей.

### Минимум, который стоит ставить всегда

```go
package main

import (
        "net/http"

        "github.com/labstack/echo/v4"
        "github.com/labstack/echo/v4/middleware"
)

func main() {
        e := echo.New()

        // id запроса: Echo поставит заголовок X-Request-Id в ответ
        e.Use(middleware.RequestID())
        // строка лога на каждый запрос
        e.Use(middleware.Logger())
        // паника в любом звене цепочки превратится в ошибку 500
        e.Use(middleware.Recover())

        e.GET("/ping", func(c echo.Context) error {
                return c.String(http.StatusOK, "pong")
        })

        e.Logger.Fatal(e.Start(":1323"))
}
```

Три слоя из каталога — и сервер уже ведёт себя как взрослый. Порядок не случайный: RequestID раньше Logger, чтобы идентификатор успел попасть в строку лога через тег ${id}; Logger раньше Recover — правило прошлого урока, иначе паникующий запрос уйдёт из лога. Пример — расширенный вариант доккомментария самого echo.go, где стоят Logger и Recover, а порт 1323 — фирменный из документации Echo. Пакет подключается отдельным импортом и сам себя не «оживляет»: каждый слой добавляется явным e.Use. Про песочницу: как и весь курс Echo, этот урок исполняется локально — нужен go mod init и go get github.com/labstack/echo/v4, дальше go run и curl: браузерный движок сайта сторонние модули не исполняет.

### echo logger: анатомия формата Logger

LoggerConfig — это Skipper, Format, CustomTimeFormat, CustomTagFunc и Output. Всю работу делает Format: строка-шаблон в синтаксисе fasttemplate, где теги записываются как ${имя} и компилируются вызовом fasttemplate.New(Format, "${", "}"). Дефолтный Format в DefaultLoggerConfig — однострочный JSON (здесь сокращён): {"time":"${time_rfc3339_nano}","id":"${id}","remote_ip":"${remote_ip}","method":"${method}","uri":"${uri}","status":${status},"latency_human":"${latency_human}"} и так далее.

Каталог тегов стоит один раз пробежать глазами. Время: time_unix, time_rfc3339_nano и time_custom, который берёт шаблон из CustomTimeFormat (дефолт — "2006-01-02 15:04:05.00000"). Запрос: id — это как раз X-Request-Id; remote_ip идёт через c.RealIP; дальше method, uri, path, route — шаблон маршрута вида /users/:id, protocol, referer, user_agent. Ответ: status (в терминале окрашивается: 2xx зелёным, 3xx бирюзовым, 4xx жёлтым, 5xx красным), error, latency в наносекундах и latency_human для людей, bytes_out из размера ответа. Отдельная группа — динамические теги: header:Authorization, query:user_id, form:action, cookie:session_id — значение берётся прямо из запроса.

Две детали, которые легко упустить. bytes_in — не измеренный размер тела, а заголовок Content-Length запроса: его может не быть вовсе, тогда в лог уйдёт 0. А скелет Logger-а — это ваш loggerTimer до строчки: err = next(c), при ошибке c.Error(err), замер stop.Sub(start) — только вместо fmt.Printf здесь буфер из sync.Pool и запись через Output, по умолчанию в вывод логгера Echo.

### Свой формат: LoggerWithConfig

```go
// человекочитаемый формат вместо дефолтного JSON:
// время, статус, метод, шаблон маршрута, задержка
e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
        Format:           "${time_custom} ${status} ${method} ${route} (${latency_human})
",
        CustomTimeFormat: "2006-01-02 15:04:05",
}))
```

route вместо uri удобнее для API: в логе будет шаблон /users/:id, а не конкретный /users/42 — одинаковые маршруты склеиваются глазами мгновенно. time_custom берёт верстку времени из CustomTimeFormat — это обычная Go-маска из пакета time с эталонной датой 2006-01-02. \n в конце обязателен: Echo не дописывает перевод строки за вас. И одна предосторожность из исходников: если Format начинается с фигурной скобки, Echo включает JSON-экранирование строк — в logger.go для этого отдельная функция writeJSONSafeString, чтобы самодельный JSON-формат не развалился от кавычки в uri или в тексте ошибки.

### echo recover: конфиг, стек и LogLevel

echo recover из коробки — это Recover() без аргументов, и для большинства проектов этого достаточно: отложенный recover() ловит панику в любом звене цепочки, пишет стек в лог и отдаёт ошибку глобальному обработчику — клиент получает 500 {"message":"Internal Server Error"}. Но RecoverConfig стоит знать подробнее. StackSize — размер буфера под runtime.Stack, по умолчанию 4 KB (в исходнике записано как 4 << 10): можно урезать для коротких стеков или поднять для глубоких. DisableStackAll — по умолчанию печатается стек всех горутин программы; true оставит только текущую. DisablePrintStack — вообще без стека. LogLevel — уровень, которым уходит сообщение вида [PANIC RECOVER] ошибка стек: по умолчанию Print, доступны DEBUG, INFO, WARN, ERROR и OFF (тишина). Если значение паники не реализует error, Echo аккуратно заворачивает его в fmt.Errorf("%v", r) — паника строкой не потеряет текст.

И два крючка для своих нужд. LogErrorFunc func(c echo.Context, err error, stack []byte) error заменяет штатную печать: шлите стек в трекер ошибок или структурный логгер; тонкость в том, что если ваша функция вернёт nil, глобальный обработчик ошибок вызван не будет — обработка целиком на вас. DisableErrorHandler, наоборот, выключает c.Error и возвращает ошибку из middleware наверх по цепочке, как будто Recover-а и не было.

### Проверка: паника со значением ErrAbortHandler

**Вопрос:** В обработчике выполнено panic(http.ErrAbortHandler). Что сделает штатный middleware.Recover()?

- Поймает панику и вернёт клиенту 500
- ✅ Поймает и сразу вызовет panic(r) повторно: ErrAbortHandler — служебный сигнал net/http, его перехватывать нельзя
- Проигнорирует: значение не реализует error
- Вернёт клиенту 503 Service Unavailable

Первая же проверка в recover.go — if r == http.ErrAbortHandler { panic(r) }: стандартная библиотека использует эту панику как тихий обрыв соединения, и Recover не имеет права её глотать. Любое другое значение идёт дальше по конвейеру: тип-assert к error, при неудаче fmt.Errorf("%v", r), затем стек и c.Error(err) — глобальный обработчик ответит 500.

### RequestID: сквозной идентификатор запроса

RequestID решает задачу, которая звучит скромно, а по цене исполнения дороже половины каталога: одна жалоба пользователя — и нужно найти в логах все её следы. RequestIDConfig включает Skipper, Generator, RequestIDHandler и TargetHeader. Механика в четыре строки: идентификатор читается из заголовка запроса; если клиент ничего не прислал — вызывается Generator; заголовок ставится в ответ до вызова next(c); если задан RequestIDHandler, он получает (c, id) — удобное место, чтобы положить id в контекст или метрики.

Генератор по умолчанию — randomString(32): строка из 32 символов, набранных из 52 латинских букв верхнего и нижнего регистра, источник — криптогенератор crypto/rand. Не hex и не UUID — именно буквы; для склейки логов этого достаточно. TargetHeader по умолчанию — echo.HeaderXRequestID, и значение этой константы в исходниках записано как X-Request-Id, таково каноническое написание Echo. Читать можно в любом регистре: Header.Get приводит имя к канонической форме, поэтому curl -H "X-Request-ID: ..." сработает как надо. Сквозная трассировка выходит сама собой: шлюз присылает свой X-Request-Id — Echo переиспользует его и возвращает в ответе, а тег ${id} у Logger-а читает заголовок из запроса и, на случай другого порядка слоёв, из заголовка ответа.

### Клиент прислал свой X-Request-Id

```go
e.Use(middleware.RequestID())

e.GET("/ping", func(c echo.Context) error {
        return c.String(http.StatusOK, "pong")
})

// запрос и ответ:
// curl -i -H "X-Request-Id: trace-42" localhost:1323/ping
```

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

- ✅ В ответе заголовок X-Request-Id: trace-42 — Echo переиспользует присланный идентификатор, а не генерирует новый
- В ответе случайный X-Request-Id из 32 букв: Echo всегда генерирует свой
- 400 Bad Request: заголовок X-Request-Id может ставить только сервер
- Ответ без X-Request-Id: заголовок ставится лишь когда Echo сгенерировала id сама

Сначала читается заголовок запроса: rid := req.Header.Get(config.TargetHeader), и только при пустом значении вызывается Generator. Непустой идентификатор клиента проходит насквозь: ставится в заголовок ответа до next(c) и попадает в лог через тег ${id}. Именно так работает сквозная трассировка между сервисами: прокидывайте заголовок дальше — и строки лога разных программ склеиваются по одному значению.

### Skipper: один механизм выключения для всех

Каждый конфиг каталога начинается с одного и того же поля: Skipper Skipper — тип из middleware.go: type Skipper func(c echo.Context) bool. Вернули true — слой пропускается: первая же строка замыкания делает return next(c) без всякой работы. Дефолт — DefaultSkipper, функция, которая всегда возвращает false: «никого не пропускать». Рядом в том же файле описан BeforeFunc func(c echo.Context) — крючок, вызываемый прямо перед слоем; встречается реже, но это часть того же общего словаря пакета.

Практика скиперов — список мест, где слой не нужен. Health-чеки и метрики не должны замусоривать лог; мелкую статику незачем сжимать; аутентификация не нужна на публичных путях. Функция скипера — обычный код: сравните c.Request().URL.Path с "/healthz" или возьмите c.Path() — шаблон маршрута, который вы видели в теге ${route}. Так сделано в доккомментарии самого Logger-а: Skipper: func(c echo.Context) bool { return c.Request().URL.Path == "/health" }. Тот же паттерн стоит копировать в своих слоях из прошлого урока: поле Skipper в конфиге, проверка первой строкой. Слой, который нельзя выключить, быстро начинают обходить стороной.

### Timeout: что случилось с middleware.Timeout

Timeout — самый поучительный экспонат каталога, потому что он умер в чести. Появился в Echo v4.2.0 (начало 2021-го) как обёртка над http.TimeoutHandler из стандартной библиотеки. TimeoutConfig нёс поля Skipper, Timeout (0 — без таймаута), ErrorMessage и OnTimeoutRouteErrorHandler. Поведение такое: клиент, не дождавшийся ответа за Timeout, получает 503 Service Unavailable с ErrorMessage в теле. Но в доккомментарии заглавными буквами напечатан нюанс: NB: timeout does not stop handler execution — обработчик продолжает дорабатывать. 503 — это не отмена работы, а лишь сообщение о её задержке.

Свежие версии v4 пошли дальше: Timeout и TimeoutWithConfig помечены Deprecated, а файл timeout.go открывается многострочным WARNING: middleware causes more problems than it solves. Причина — подмена response writer между горутинами: слой обязан стоять первым в цепочке, иначе возможна data race. Официальная замена — ContextTimeout: context.WithTimeout от контекста запроса плюс c.SetRequest с новым контекстом. Гонок нет, позиция в цепочке любая; 503 возникает, когда внутренняя операция уважает контекст и вернёт context.DeadlineExceeded — ErrorHandler по умолчанию превращает её в echo.ErrServiceUnavailable, пряча внутреннюю ошибку от клиента, но оставляя её вашему обработчику ошибок.

### ContextTimeout: дедлайн через контекст

```go
// дедлайн на весь запрос: 2 секунды
e.Use(middleware.ContextTimeout(2 * time.Second))

e.GET("/report", func(c echo.Context) error {
        // контекст с дедлайном достаётся из запроса
        ctx := c.Request().Context()

        // тяжёлая работа обязана принимать ctx:
        // так умеют sql, http-клиенты и драйверы
        rows, err := db.QueryContext(ctx, "SELECT ...")
        if err != nil {
                // дедлайн истёк -> 503 Service Unavailable
                if errors.Is(err, context.DeadlineExceeded) {
                        return echo.NewHTTPError(
                                http.StatusServiceUnavailable, "timeout")
                }
                return err
        }
        defer rows.Close()
        return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
})
```

ContextTimeout сам ничего не пишет клиенту — он подменяет контекст запроса и ждёт, что внутренние операции отреагируют на дедлайн. Дедлайн «долетает» до драйвера БД и HTTP-клиентов, потому что они принимают ctx первым аргументом; не принимает — дедлайн повиснет в воздухе, и 503 никто не вернёт. В конфиге ContextTimeoutConfig нулевой Timeout — ошибка конфигурации: фабрика вернёт ошибку, и ContextTimeoutWithConfig запаникует на старте. У старого Timeout ноль означал «выключен» — ещё одно различие, о котором молчат старые статьи.

### Проверка: поведение старого Timeout

**Вопрос:** В цепочке стоит e.Use(middleware.TimeoutWithConfig(middleware.TimeoutConfig{Timeout: 100 * time.Millisecond, ErrorMessage: "later"})). Обработчик выполняется 3 секунды. Что увидит клиент и что станет с обработчиком?

- ✅ 503 с телом later уже через 100 мс; горутина обработчика продолжит дорабатывать
- 503 с телом later через 100 мс; обработчик будет принудительно остановлен
- Соединение закроется без ответа: Timeout молчит
- Обработчик успеет: Timeout дождётся его завершения

http.TimeoutHandler запускает цепочку в отдельной горутине и по дедлайну сам пишет клиенту 503 с ErrorMessage — доккомментарий прямо говорит: NB: timeout does not stop handler execution. Отмены работы нет: «убежавший» запрос продолжает жечь память и процессор после того, как клиент ушёл. Прервать тяжёлую работу может только контекст — потому официальная замена и называется ContextTimeout.

> **Timeout в свежих v4 — Deprecated: не тащите в новые проекты.** Файл timeout.go начинается с многострочного WARNING: этот middleware «causes more problems than it solves», обязан быть первым в цепочке и способен дать data race на response writer, если стоит не первым. Тот же файл показывает честную альтернативу — свой слой на context.WithTimeout плюс c.SetRequest: это и есть ContextTimeout внутри. Пример миграции из доккомментария: было e.Use(middleware.Timeout()) — стало e.Use(middleware.ContextTimeout(30 * time.Second)). В существующем коде пометьте миграцию в бэклоге: старый слой работает, но с известными граблями.

### Gzip: сжатие с буфером и порогом

Gzip из файла compress.go показывает, сколько механики может прятаться за одним e.Use. Срабатывание честное: заголовок Accept-Encoding запроса должен содержать gzip (в исходнике константа gzipScheme = "gzip"); а заранее, до всякой проверки, в ответ добавляется Vary: Accept-Encoding — кэшам объявлено, что ответ зависит от этого заголовка. Дальше интереснее: Echo подменяет writer ответа обёрткой gzipResponseWriter, сами gzip.Writer переиспользуются из sync.Pool, WriteHeader откладывается до первой записи тела, Content-Length удаляется — заранее длину сжатого ответа знать невозможно, — а заголовок Content-Encoding: gzip ставится в момент, когда буфер перерастает порог MinLength.

MinLength — единственное поле, которое стоит настраивать руками. Ответ короче порога буферизуется и уходит как есть, без Content-Encoding и с исходной длиной (возврат к чистому состоянию отмечен в исходнике issues #424 и #407); вызов Flush принудительно включает сжатие, поэтому стриминг не ломается. Level — уровень сжатия: дефолт -1, то есть gzip.DefaultCompression, шкала от gzip.BestSpeed до gzip.BestCompression. И гост-ловушка: gzip.NoCompression — это 0, но ноль в int-конфиге Echo неотличим от «поле не задано», поэтому GzipConfig{Level: 0} молча станет дефолтными -1. Отключать сжатие для отдельных путей положено Skipper-ом, а не уровнем.

### Ответ короче порога MinLength

```go
e.Use(middleware.GzipWithConfig(middleware.GzipConfig{
        Level:     6,
        MinLength: 128,
}))

e.GET("/status", func(c echo.Context) error {
        return c.String(http.StatusOK, "ok")
})

// запрос:
// curl -i -H "Accept-Encoding: gzip" localhost:1323/status
```

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

- Тело сжато: Content-Encoding: gzip и бинарный поток
- ✅ Тело ok без Content-Encoding: ответ не дотянул до порога и ушёл как есть
- 500 Internal Server Error: буфер не может вернуть несжатый ответ
- Тело ok, но с Content-Encoding: gzip — заголовок уже нельзя отозвать

Первые байты пишутся в буфер, и порог 128 не превышен: обёртка отдаёт несжатое содержимое, Content-Encoding вообще не ставится, а Content-Length считается честно. Единственный след Gzip-а в ответе — заголовок Vary: Accept-Encoding, который добавлен заранее для кэшей. Поставьте MinLength: 0 — и тот же двухбайтовый ответ уйдёт сжатым, потому что дефолтный порог равен нулю.

### Проверка: GzipConfig{Level: 0}

**Вопрос:** Вы поставили middleware.GzipConfig{Level: 0} в надежде отключить сжатие — в compress/gzip значение 0 значит NoCompression. Что произойдёт?

- Сжатия не будет: уровень 0 дойдёт до gzip.NewWriterLevel
- ✅ Уровень молча заменится на -1 (DefaultCompression): ноль в конфиге неотличим от «не задано» и перезаписывается дефолтом
- Конфигурация вызовет ошибку при старте сервера
- Gzip сам выберет уровень по размеру каждого ответа

В GzipWithConfig стоит защита дефолтов: if config.Level == 0 { config.Level = DefaultGzipConfig.Level }, а DefaultGzipConfig.Level — это -1. Любой нулевой int в конфигах Echo читается как «поле не задано», и это тот случай, когда правило бьёт по пальцам. Не нужен сжатый ответ на части путей — не подключайте Gzip вовсе или дайте ему Skipper.

### Static: файлы, SPA-фоллбэк и Browse

StaticConfig требует только Root — каталог, из которого раздаётся статика; пустое значение заменяется на "." с комментарием про безопасность прямо в исходнике. Index задаёт индексный файл каталога (по умолчанию index.html), Browse разрешает листинг — HTML-страница собирается из шаблона, встроенного прямо в static.go, — а IgnoreBase пригождается при подключении к группе с префиксом: без него путь на диске задваивается. Файловая система по умолчанию — http.Dir(config.Root), отдача идёт через http.ServeContent: поддержка Range и Last-Modified достаётся бесплатно.

Самое важное в Static — поведение промаха. Файла нет? Слой вызывает next(c): API-маршруты продолжают жить, будто Static-а нет вовсе. Режим HTML5 — фоллбэк для одностраничных приложений: если next(c) вернул 404, Static отдаёт index.html, и маршрутизацию подхватывает клиентский фреймворк. Отсюда дисциплина: HTML5: true включайте на группе, которая обслуживает фронтенд, а не глобально — иначе собственный API-404 начнёт приходить клиенту в виде index.html. И историческая справка: метод e.Static(prefix, root), существовавший с v4.0.0, удалён в v4.7.0 — рецепты с ним не компилируются; современный путь — e.Use(middleware.Static("public")). Наконец, EnablePathUnescaping по умолчанию false: закодированные слэши %2f не декодируются — подменой пути нельзя обойти маршруты с проверкой доступа.

### Сжатие и статика вместе

```go
// Gzip раньше Static: статика тоже уйдёт сжатой
e.Use(middleware.GzipWithConfig(middleware.GzipConfig{
        Level:     6,      // середина шкалы gzip
        MinLength: 256,    // короче — отдать без сжатия
}))

// статика из каталога public;
// файл не найден — запрос идёт дальше по цепочке
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
        Root:  "public",
        Index: "index.html",
}))
```

Порядок здесь смысловой: Gzip подключён раньше Static, то есть снаружи — writer уже подменён сжимающей обёрткой, и найденные файлы уходят клиенту сжатыми. Поменяете местами — Static станет отдавать файлы напрямую в оригинальный writer, минуя сжатие. MinLength 256 отсекает мелочь, на которой сжатие проигрывает накладным расходам. Проверяется двумя curl: curl -i localhost:1323/ покажет заголовки отдачи файла, а curl --compressed localhost:1323/ — распаковку на клиенте. Если отдаёте SPA, добавьте HTML5: true на группу фронта — не глобально, помните про API-404.

### Каталог пакета middleware Echo v4

| Middleware | Что делает | Ключевые поля конфига |
| --- | --- | --- |
| Logger | строка лога на каждый запрос: формат из тегов fasttemplate, окраска статуса, вывод в io.Writer | Format, CustomTimeFormat, CustomTagFunc, Output |
| RequestLogger | тот же лог новым API: поля включаются флагами Log*, значения собирает ваш LogValuesFunc | LogValuesFunc, LogLatency, LogRequestID, LogStatus |
| Recover | паника внутри цепочки: стек в лог, ошибка в c.Error -> 500 | StackSize, DisableStackAll, DisablePrintStack, LogLevel |
| RequestID | X-Request-Id: чужой из запроса или случайные 32 буквы; ставит заголовок до next(c) | Generator, TargetHeader, RequestIDHandler |
| Timeout | 503 при превышении дедлайна; не останавливает обработчик; Deprecated в свежих v4 | Timeout, ErrorMessage |
| ContextTimeout | контекст с дедлайном без гонок: DeadlineExceeded -> 503 | Timeout, ErrorHandler |
| Gzip | сжатие ответа при Accept-Encoding: gzip, буфер с порогом | Level, MinLength |
| Decompress | распаковка сжатого тела запроса | DecompressConfig |
| Static | файлы из каталога: индекс, SPA-фоллбэк, листинг | Root, Index, HTML5, Browse, IgnoreBase |
| CORS | заголовки cross-origin по списку источников | AllowOrigins, AllowMethods, AllowCredentials |
| CSRF | токен формы: кладёт его через c.Set("csrf", ...) — вы видели в уроке про c.Set | TokenLookup, ContextKey |
| BasicAuth | пара логин-пароль из Authorization, проверяет ваш Validator | Validator, Realm |
| KeyAuth | API-ключ в заголовке или query | KeyLookup, Validator |
| RateLimiter | ограничение частоты: считает запросы через ваш Store | Store, IdentifierExtractor |
| BodyLimit | отсечка размера тела запроса | Limit |
| Secure | защитные заголовки: XSS, nosniff, HSTS | XSSProtection, HSTSMaxAge |
| Redirect | редиректы http->https, www->апекс, слэши | HTTPSRedirect, WWWRedirect, TrailingSlash |
| MethodOverride | X-HTTP-Method-Override для форм без PUT/DELETE | Getter |
| Proxy | reverse proxy на пул бэкендов с балансировщиком | Target, Balancer |

> **CustomTagFunc: докиньте в лог пользователя из c.Get.** Тег ${custom} вызывает CustomTagFunc func(c echo.Context, buf *bytes.Buffer) (int, error). Внутри доступен контекст: достаньте username, который authStub из прошлого урока положил через c.Set("username", user), и допишите его в буфер. Учтите две вещи: если CustomTagFunc не задана, тег ${custom} просто исчезает из строки лога (проверка на nil внутри Logger-а), а в JSON-формате вы пишете сырой кусок строки — кавычки и экранирование на вашей совести, Echo проверит валидность только базово.

### Соберите Logger со скипером health-чека

Заполните пропуск: доступ к запросу в скипере, фабрика логгера, тег времени и поле Skipper в конфиге.

```go
// скипер: health-check не попадает в лог
health := func(c echo.Context) bool {
        return c.___().URL.___ == "/healthz"
}

e.Use(middleware.___WithConfig(middleware.LoggerConfig{
        Format:           "___ ${status} ${method} ${uri} ${latency_human}
",
        CustomTimeFormat: "15:04:05.000",
        ___:              health,
}))
```

Подсказка: Скипер — обычный echo.Context: запрос достаётся c.Request(), путь — URL.Path. Фабрика кастомного логгера — LoggerWithConfig, а time_custom берёт шаблон из CustomTimeFormat.

Правильные ответы в пропусках: `Request`, `Path`, `Logger`, `${time_custom}`, `Skipper`

### Соберите боевой минимум из каталога

Расставьте строки main() в правильном порядке: идентификатор запроса, логи, recovery, сжатие — и запуск. Помните правило прошлого урока: Logger раньше Recover.

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

1. `func main() {`
2. `  e := echo.New()`
3. `  e.Use(middleware.RequestID())`
4. `  e.Use(middleware.Logger())`
5. `  e.Use(middleware.Recover())`
6. `  e.Use(middleware.Gzip())`
7. `  e.GET("/api/todos", listTodos)`
8. `  e.Logger.Fatal(e.Start(":1323"))`
9. `}`

### Что дальше: свои слои поверх каталога

Каталог закрывает типовые задачи, но все его слои — те же echo.MiddlewareFunc, что вы писали руками в [прошлом уроке](/courses/echo/lessons/06-middleware/): конфиг, Skipper, фаза запроса до next(c), фаза ответа после. Соберите боевой минимум из order-задания, прогоните curl -i и найдите X-Request-Id в ответе и ${id} в строке лога — после этого встроенные middleware перестанут быть магией.

Следующий урок — про кастомные middleware: когда слой нужен свой — аутентификация по своим токенам, метрики, сквозные функции — и как оформить его фабрикой с конфигом, не повторив чужих граблей. А конвейер привязки и валидации из [урока про bind и валидацию](/courses/echo/lessons/05-bind-validaciya/) — готовый кандидат на собственный слой со скипером: ошибки валидации должны приходить к клиенту в едином формате, и оформление такого формата — тоже middleware. Навигация по курсу — на [странице курса Echo](/courses/echo/).

### Карточки: встроенные middleware

- **middleware.Logger()** — Формат — шаблон fasttemplate с тегами ${...}; дефолтный Format — однострочный JSON. ${id} читает X-Request-Id из запроса и ответа; ${bytes_in} — это Content-Length запроса, не размер тела. Скелет тот же, что у самодельного логгера: err = next(c), при ошибке c.Error(err). В v4.15 помечен Deprecated — новое API RequestLogger с флагами Log* и вашим LogValuesFunc.
- **RecoverConfig** — StackSize 4 KB (4 << 10), DisableStackAll, DisablePrintStack, LogLevel (дефолт Print). Паника без error заворачивается в fmt.Errorf("%v", r); сообщение вида [PANIC RECOVER]. http.ErrAbortHandler перевызывается panic(r) — его перехватывать нельзя. LogErrorFunc заменяет печать; вернули nil — глобальный обработчик не зовётся.
- **RequestID** — Чужой X-Request-Id переиспользуется, иначе Generator — randomString(32): 32 буквы из 52, crypto/rand. Заголовок ставится в ответ ДО next(c); RequestIDHandler получает (c, id). Каноническое значение константы — X-Request-Id; Get-ы регистронезависимы. Тег ${id} у Logger-а берёт заголовок из запроса, при пустоте — из ответа.
- **Timeout vs ContextTimeout** — Timeout (появился в v4.2.0, обёртка над http.TimeoutHandler): 503 + ErrorMessage сразу, но handler продолжает работать; в свежих v4 Deprecated из-за гонок на response writer. ContextTimeout: context.WithTimeout + c.SetRequest, гонок нет, позиция любая; 503 вернётся, когда операция вернёт context.DeadlineExceeded.
- **GzipConfig** — Level -1 = gzip.DefaultCompression; Level: 0 неотличим от «не задано» и заменится на -1. MinLength буферизует: короче порога — ответ уходит как есть без Content-Encoding. Заранее ставится Vary: Accept-Encoding; Content-Length удаляется; Flush включает сжатие принудительно.
- **Skipper** — type Skipper func(c echo.Context) bool из middleware.go: true — слой делает return next(c) без работы. DefaultSkipper всегда false. Поле есть у каждого штатного конфига — общий способ выключать слой для путей (health, метрики, статика). Рядом описан BeforeFunc — крючок прямо перед слоем; в своих middleware копируйте тот же паттерн.

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

- Пакет github.com/labstack/echo/v4/middleware — каталог готовых слоёв: Logger, Recover, RequestID, Timeout и ContextTimeout, Gzip, Static, CORS, CSRF, RateLimiter и другие; JWT вынесен в отдельный модуль labstack/echo-jwt.
- Все встроенные middleware устроены одинаково: конфиг XConfig с полем Skipper, DefaultXConfig с дефолтами, фабрики X() и XWithConfig; внутри — те же фазы до next(c) и после, что у самодельного слоя.
- echo logger: формат — шаблон fasttemplate с тегами ${...}, дефолт — JSON; свой формат задаётся в LoggerWithConfig; в v4.15 старый Logger помечен Deprecated, новое API — RequestLogger с LogValuesFunc.
- echo recover: паника -> стек в лог и 500 через c.Error; StackSize 4 KB, LogLevel; http.ErrAbortHandler перевызывается и не подлежит перехвату.
- RequestID переиспользует присланный X-Request-Id или генерирует 32 буквы через crypto/rand и ставит заголовок в ответ до next(c) — сквозная трассировка выходит сама собой.
- Timeout (v4.2.0) отдаёт 503 сразу, но не останавливает обработчик и в свежих v4 Deprecated из-за гонок; замена — ContextTimeout на context.WithTimeout.
- Gzip сжимает ответы с Accept-Encoding: gzip и уважает MinLength; Level: 0 неотличим от «не задано» и становится -1; Static умеет SPA-фоллбэк HTML5 и пропускает несуществующие файлы дальше по цепочке.

## Домашнее задание

Соберите конвейер: RequestID, Logger с форматом "${time_custom} ${id} ${status} ${uri} ${latency_human}", Recover и Gzip с MinLength 512; добавьте скипер, пропускающий /healthz у всех слоёв сразу. Затем временно поставьте в обработчик panic("boom") и проверьте curl -i: клиент должен получить 500, а в выводе сервера — строку [PANIC RECOVER]. Уберите панику, отправьте запрос с заголовком -H "X-Request-Id: lesson-07" и найдите это значение и в ответе, и в логе. Наконец, сравните размеры ответа в curl и curl --compressed на JSON в несколько килобайт и объясните, зачем в таблице тегов у Logger-а есть bytes_in и bytes_out.

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

### Как отключить встроенный middleware для отдельных путей?

Полем Skipper в конфиге: middleware.LoggerWithConfig(middleware.LoggerConfig{Skipper: func(c echo.Context) bool { return c.Request().URL.Path == "/healthz" }}). Первая строка каждого слоя проверяет Skipper и при true делает return next(c) без работы. Механизм общий: Skipper есть у Logger, Recover, Gzip, RequestID и остальных конфигов каталога; DefaultSkipper всегда возвращает false.

### Что использовать вместо middleware.Timeout?

ContextTimeout: e.Use(middleware.ContextTimeout(30 * time.Second)). Он подменяет контекст запроса дедлайном через context.WithTimeout и c.SetRequest, не трогает response writer и не создаёт гонок, позиция в цепочке любая. Клиент получит 503, когда внутренняя операция вернёт context.DeadlineExceeded — для этого запросы к БД и HTTP-клиенты должны принимать ctx. Старый Timeout не останавливает обработчик и обязан стоять первым слоем.

### Почему в JSON-логе Echo поле id пустое?

Тег ${id} берёт значение из заголовка X-Request-Id запроса, а при пустоте — из заголовка ответа. Пусто бывает, когда клиент не прислал свой идентификатор, а middleware.RequestID вообще не подключён. Добавьте e.Use(middleware.RequestID()) до остальных слоёв — каждая строка лога получит id: чужой или сгенерированный из 32 букв через crypto/rand.
