Содержание урока · 13 разделов
- gin обработка ошибок: четыре источника и один конверт
- Сентинелы и %w: ошибки, которые переживают слои
- Единый формат: статус решает транспорт, код — словарь
- Словарь ошибок трекера
- handleError: один преобразователь вместо сотни if
- Валидация в общем потоке: ValidationErrors и Details
- c.Errors: копилка между звеньями
- Кастомный Recovery: паники — не ошибки
- errgroup: параллельные ходы и первая ошибка
- Семь правил обработки ошибок в gin
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
gin обработка ошибок: четыре источника и один конверт#
В Go ошибка — это значение, а не исключение: её носят в руках, заворачивают и разбирают. Но у веб-сервера у каждой ошибки есть финал — HTTP-ответ: статус и тело. И здесь начинается главное: если каждый хендлер решает сам, клиент получает зоопарк — то gin.H с полем error, как в уроке про файлы, то честный конверт, то голый текст. У ошибок gin четыре источника, и все мы уже встречали. Первый — транспорт тела: ShouldBindJSON падает на EOF у пустого тела и на invalid character у битого JSON — ветка из урока про привязку. Второй — валидация: validator.ValidationErrors со своими Field() и Tag() из урока про validator. Третий — инфраструктура: диск не пишет, база лежит, upstream не отвечает. Четвёртый — доменная логика: задача не найдена, повторное создание запрещено. Отдельным классом идут паники — это не ошибка, а обрыв потока исполнения, и им нужен свой этаж обороны. Конверт у нас уже есть: Envelope с полем Error *APIError{Code, Message, Details}. Задача урока — протянуть от всех четырёх источников до этого конверта один маршрут: сентинелы в домене, обёртки между слоями, центральный handleError у хендлера и Recovery на самом дне.
Сентинелы и %w: ошибки, которые переживают слои#
Сентинел — заранее объявленная ошибка-эталон: var ErrNotFound = errors.New("todo not found"). Слой, который обнаружил проблему, возвращает её как есть; промежуточные слои добавляют контекст обёрткой fmt.Errorf("get todo %d: %w", id, ErrNotFound) — глагол %w заворачивает оригинал внутрь новой ошибки, сохраняя цепочку. Разматывать цепочку умеют два глагола из пакета errors. errors.Is(err, ErrNotFound) идёт по всей лестнице разворотов и отвечает «да», если внутри сидит именно этот сентинел: сравнение по идентичности, не по тексту. errors.As(err, &target) ищет не значение, а тип: поймает validator.ValidationErrors или свой доменный тип и положит в target. Семантическая граница проста: Is — «это та же проблема», As — «это проблема такого класса». Ошибка, обёрнутая через %v, цепочку рвёт: err.Error() ещё содержит текст, но Is уже не найдёт сентинел — самая частая дорогая опечатка в Go. Для пачки в Go 1.20 появился errors.Join: склеивает несколько в одну, и Is с As проходят по всем веткам дерева. Правило проекта: репозиторий возвращает сентинел, сервис оборачивает с контекстом операции, хендлер не разбирает тексты, а принимает решение по Is и As.
Три этажа: сентинел, обёртки, решение
GO// Домен объявляет эталоны — их знают все слои.
var (
ErrNotFound = errors.New("todo not found")
ErrConflict = errors.New("todo already exists")
)
// Репозиторий: обнаружил — вернул сентинел.
func (s *MemStore) Get(id int64) (Todo, error) {
todo, ok := s.items[id]
if !ok {
return Todo{}, fmt.Errorf("get todo %d: %w", id, ErrNotFound)
}
return todo, nil
}
// Сервис: обёртка с контекстом, сентинел жив внутри.
func (t *Tracker) Rename(id int64, title string) (Todo, error) {
todo, err := t.store.Get(id)
if err != nil {
return Todo{}, fmt.Errorf("rename: %w", err)
}
if todo.Done {
return Todo{}, ErrConflict
}
return t.store.Update(id, title)
}
// Хендлер: решение по Is, а не по тексту ошибки.
if todo, err := tracker.Rename(id, in.Title); err != nil {
if errors.Is(err, ErrConflict) {
respondError(c, http.StatusConflict, "CONFLICT",
"задача уже выполнена", nil)
return
}
handleError(c, err) // всё остальное — по общей лестнице
return
}Цепочка: Get возвращает fmt.Errorf с %w, Rename добавляет свой контекст вторым %w, а хендлер спрашивает errors.Is — и тот находит ErrConflict через два разворота. Обратите внимание: ErrConflict вернулся без обёртки, и Is работает с ним напрямую. Единая точка handleError появится через два блока — там же станет ясно, зачем отдавать ему всё, что не совпало с узкими ветками.
Единый формат: статус решает транспорт, код — словарь#
Конверт готов, но без словаря он бесполезен: клиент должен уметь по коду принимать решения, а не по строке message, которую вы завтра переформулируете. Договор такой. Код — SCREAMING_SNAKE-константа, часть публичного контракта: VALIDATION_FAILED, NOT_FOUND, CONFLICT, INTERNAL; меняется только вместе с версией API. Статус — транспортная семантика: 4хх «виноват клиент», 5хх «виноват сервер»; мониторинг и кэши читают его первым, поэтому «ошибку со статусом 200» мы не делаем никогда — это ломает и метрики, и браузерные прокси. Message — человекочитаемый текст, на языке вашего фронтенда; его можно свободно править, на него нельзя опираться. Details — массив строк для полевых подсказок: какое поле и какой тег споткнулись. И железное правило безопасности: во внутренние ошибки клиент не видит ничего — ни SQL, ни путей, ни стектрейса; все подробности уходят в лог, наружу — обобщённая фраза. Коды 401 и 403 не путаем: первый — «не представился», второй — «представился, но прав нет»; эта пара уже была в звене-гварде из урока про кастомные middleware.
Словарь ошибок трекера#
| Код | Статус | Когда возникает |
|---|---|---|
| VALIDATION_FAILED | 400 Bad Request | не прошли теги binding — errors.As поймал validator.ValidationErrors |
| BAD_REQUEST | 400 Bad Request | битое тело: EOF, invalid character, не тот Content-Type |
| UNAUTHORIZED | 401 Unauthorized | нет учётных данных или они просрочены |
| FORBIDDEN | 403 Forbidden | учётные данные верные, но прав не хватает |
| NOT_FOUND | 404 Not Found | ресурса нет — errors.Is нашёл ErrNotFound |
| CONFLICT | 409 Conflict | повторное создание, конфликт версий или состояния |
| TOO_LARGE | 413 Content Too Large | файл или тело больше лимита из урока про загрузку |
| RATE_LIMITED | 429 Too Many Requests | сработал лимит — построим его в следующем уроке |
| BAD_GATEWAY | 502 Bad Gateway | upstream недоступен — errgroup вернул ErrUpstream |
| INTERNAL | 500 Internal Server Error | всё остальное; деталей клиенту не показываем |
handleError: один преобразователь вместо сотни if#
Теперь маршрут. Вместо того чтобы в каждом хендлере вручную перебирать сентинелы и типы валидатора, заводим одну функцию handleError(c *gin.Context, err error) — единый перевод error → HTTP-ответ. Внутри switch без выражения: каждая case — отдельная проверка errors.As или errors.Is, порядок — от частного к общему. Первой идёт валидация: errors.As на validator.ValidationErrors — самый узкий тип, который иначе потеряется среди прочих. Дальше сентинелы домена: NOT_FOUND, CONFLICT. Потом инфраструктурные: ErrUpstream для 502. И в конце default: то, что мы не классифицировали, — это всегда 500 INTERNAL, слог с полной ошибкой и c.Error в копилку, о которой поговорим ниже. Порядок веток — не вкус, а логика: errors.As на конкретный тип не сработает после broad-ветки по замыслу switch, но читаться код должен от частного к общему, как и в истории со SliceValidationError из урока про валидацию. Хендлер после этого вырождается: получил err — вызвал handleError и вернулся. Один вызов вместо пяти if — и ноль шансов, что два разработчика ответят на одну и ту же ошибку разными кодами.
handleError целиком
GO// handleError — единая точка превращения error в HTTP-ответ.
func handleError(c *gin.Context, err error) {
c.Error(err) // копилка: причина уедет в ErrorMessage access-лога
var verrs validator.ValidationErrors
switch {
case errors.As(err, &verrs):
respondError(c, http.StatusBadRequest, "VALIDATION_FAILED",
"проверьте поля формы", detailsFrom(verrs))
case errors.Is(err, ErrNotFound):
respondError(c, http.StatusNotFound, "NOT_FOUND",
"задача не найдена", nil)
case errors.Is(err, ErrConflict):
respondError(c, http.StatusConflict, "CONFLICT",
"задача уже выполнена", nil)
case errors.Is(err, ErrUpstream):
respondError(c, http.StatusBadGateway, "BAD_GATEWAY",
"вышестоящий сервис недоступен", nil)
default:
slog.Error("unhandled error", "err", err,
"path", c.Request.URL.Path)
respondError(c, http.StatusInternalServerError, "INTERNAL",
"внутренняя ошибка сервера", nil)
}
}
// detailsFrom — подсказки по полям для фронтенда.
func detailsFrom(verrs validator.ValidationErrors) []string {
out := make([]string, 0, len(verrs))
for _, e := range verrs {
out = append(out, e.Field()+": "+e.Tag())
}
return out
}respondError взят из урока про JSON-ответы: он кладёт APIError{Code, Message, Details} в Envelope и отвечает c.JSON. c.Error первым же действием — причина отказа всегда попадёт в [GIN]-строку, даже если дальше сработает самая общая ветка. default — единственное место с slog.Error: сюда долетает всё, что мы забыли классифицировать, поэтому запись здесь — рабочий сигнал дополнить словарь, а не мусор в журнале.
Проверка: цепочка из двух обёрток
handleError получил err, собранный как fmt.Errorf("rename: %w", fmt.Errorf("get todo %d: %w", 42, ErrNotFound)). Что вернёт клиенту?
Валидация в общем потоке: ValidationErrors и Details#
Где теперь живут respondBindError из урока про привязку и respondInvalid из урока про валидацию? Растворились в handleError: первая ветка errors.As — это их объединение. На выходе — тот же формат: 400, VALIDATION_FAILED, message общей фразой и details по полям. Зачем Details отдельным массивом, а не простынёй в message? Фронтенду нужно подсветить конкретные инпуты: строка вида "Title: required" парсится тривиально, а техническая простыня Key: 'TodoInput.Title' Error:Field validation... — нет; мы её видели в 04 и договорились клиенту её не показывать. Нюанс, который мы разобрали в валидационном уроке, остаётся: для массива верхнего уровня []TodoInput движок возвращает binding.SliceValidationError, а не validator.ValidationErrors, поэтому в handleError честная вторая As-ветка с *binding.SliceValidationError — идите по элементам и собирайте те же details с индексами. Тексты для каждого тега удобно выпускать из validationMessage: required — «поле обязательно», min — «минимум N», oneof — «допустимые значения». Важно и обратное: битый JSON до валидатора не доезжает, As на ValidationErrors промахнётся, и запрос уйдёт в ветку 400 BAD_REQUEST — не пытайтесь «спасти» невалидное тело валидационным кодом.
%v вместо %w: чем ответит handleError
Прочитайте код и выберите, что он выведет:
func (s *MemStore) Get(id int64) (Todo, error) {
todo, ok := s.items[id]
if !ok {
// Ревью пропустило: %v вместо %w
return Todo{}, fmt.Errorf("get todo %d: %v", id, ErrNotFound)
}
return todo, nil
}
// handleError из прошлого блока:
// case errors.Is(err, ErrNotFound): → 404 NOT_FOUND
// default: → 500 INTERNALc.Errors: копилка между звеньями#
Контекст — не только ответ, но и блокнот. c.Error(err) создаёт gin.Error{Err, Type, Meta} и дописывает в срез c.Errors; возврат — тот же gin.Error, так что можно проверить, что добавление случилось. Типы два: ErrorTypePrivate — по умолчанию, для ваших логов, и ErrorTypePublic — то, что не жалко показать клиенту, если захотите отдавать копилку в ответе. На практике копилка решает одну задачу — развязку «клиент получает конверт, лог получает причину». Помните LogFormatterParams из урока про логирование? Поле ErrorMessage там собирается ровно одной строкой: c.Errors.ByType(gin.ErrorTypePrivate).String() — приватные ошибки копилки склеиваются и печатаются в хвосте [GIN]-строки. Отсюда дисциплина: перед формированием ответа зовите c.Error(err) — и статус 400 в логе сразу будет с причиной рядом, без единого запроса в базу «а что там случилось в 15:42». Методы есть и удобные: c.Errors — весь срез, ByType — фильтр, а String() вызывает Error() каждого элемента через точку с запятой. В handleError мы положили c.Error первым действием — теперь каждая ошибка трекера оставляет след в access-логе автоматически.
Проверка: пустое поле в логе
Хендлер ответил c.JSON(http.StatusBadRequest, Envelope{...}) без единого вызова c.Error. Что увидит gin.Logger в поле ErrorMessage?
Кастомный Recovery: паники — не ошибки#
Паника не проходит через handleError — она ломает поток исполнения и раскручивает стек вверх, мимо всех if err != nil. Для неё второй этаж: звено Recovery, мы собирали его в уроке про логирование через gin.CustomRecovery(panicToEnvelope). Теперь три факта глубже. Первый: внутри CustomRecoveryWithWriter ядро gin само разбирает сломанные соединения — значение паники проверяется на net.OpError, внутри которого ищется os.SyscallError с «broken pipe» или «connection reset by peer»; для такого случая gin зовёт c.Error + c.Abort и не пытается писать статус — клиент уже ушёл, ответ он не прочитает. Второй: net/http имеет конвенцию http.ErrAbortHandler — паникуешь этим значением сам, и стандартный сервер глушит стектрейс; но Recovery в gin перехватывает панику раньше net/http, поэтому трассировка всё равно напечатается его средствами — не потеряется, но и «вежливого» глушения не ждите. Третий: стек собирает ядро — в вашей RecoveryFunc стека уже нет, есть только значение паники; поэтому функция занимается классификацией, метрикой и ответом, а трассировку печатает gin в DefaultErrorWriter. Повторная паника (re-panic) внутри обработчика — легальный ход, если платформа сама перезапускает процесс и вы хотите упасть по-настоящему.
RecoveryFunc с метрикой и классификацией
GO// panics — счётчик для алертов: рост после релиза = баг в коде.
var panics atomic.Int64
// recoveryEnvelope — RecoveryFunc: классифицируем и отвечаем конвертом.
func recoveryEnvelope(c *gin.Context, v any) {
panics.Add(1)
var err error
if e, ok := v.(error); ok {
err = e // паниковали error-ом: nil map, index out of range
} else {
err = fmt.Errorf("%v", v) // паниковали строкой или структурой
}
slog.Error("паника в хендлере",
"err", err,
"path", c.Request.URL.Path,
"total", panics.Load())
// Стек в stderr уже напечатал CustomRecoveryWithWriter.
c.AbortWithStatusJSON(http.StatusInternalServerError, Envelope{
Error: &APIError{Code: "INTERNAL",
Message: "внутренняя ошибка сервера"},
})
}
func main() {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(gin.Logger())
r.Use(gin.CustomRecoveryWithWriter(gin.DefaultErrorWriter, recoveryEnvelope))
r.GET("/boom", func(c *gin.Context) {
var m map[string]int
m["x"] = 1 // runtime-паника: assignment to entry in nil map
})
_ = r.Run(":8080")
}Сигнатура задана типом RecoveryFunc: func(c *Context, err any) — второй аргумент any, потому что panic() принимает любое значение. Счётчик atomic.Int64 беззаботно читается алертами, а слог кладёт тип паники и путь — по паре path+err локализуется мгновенно. В отличие от panicToEnvelope из урока 08, здесь классификация: если когда-нибудь паникуете своим доменным значением, ветка с type switch ляжет сюда же.
Паника после записи ответа
Прочитайте код и выберите, что он выведет:
r.GET("/flaky", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"}) // ответ записан
var m map[string]int
m["x"] = 1 // паника уже после ответа
})
// Цепочка: Logger, CustomRecoveryWithWriter(..., recoveryEnvelope)errgroup: параллельные ходы и первая ошибка#
Хендлер, который последовательно ходит в два сервиса, платит суммой задержек; параллельный — максимумом. Стандартная библиотека отвечает горутинами, но у неё нет словаря ошибок: кто вернёт первую, как отменить остальных? Ответ — golang.org/x/sync/errgroup, крошечный пакет, обзор которого обязателен для зрелой обработки ошибок. API — три слова. var g errgroup.Group — сама группа; g.Go(func() error) — запустить шаг в горутине; err := g.Wait() — дождаться всех и получить первую не-nil ошибку, остальные молча отбрасываются. g, ctx := errgroup.WithContext(parent) — расширенная пара: при первой ошибке ctx закрывается, и остальные горутины, уважающие ctx, выходят сами. g.SetLimit(n) — потолок параллелизма: Go сверх лимита блокируется, пока не освободится слот, — готовый троттлинг для пачки. В хендлере gin это выглядит так: ctx от c.Request.Context(), два g.Go к сервисам, Wait → handleError. Ошибки upstream заворачивайте в ErrUpstream — получите 502; ErrNotFound из профиля честно станет 404. И приятный штрих свежих версий x/sync: паника в горутине g.Go не теряется — Wait перепаникует тем же значением, и её подхватит Recovery, как будто паниковал сам хендлер.
Dashboard двумя ходами
GOfunc dashboard(c *gin.Context) {
// ctx отменится при первой ошибке или уходе клиента.
g, ctx := errgroup.WithContext(c.Request.Context())
var profile Profile
var todos []Todo
g.Go(func() error {
p, err := userService.Profile(ctx, c.Param("id"))
profile = p
return err
})
g.Go(func() error {
t, err := todoService.List(ctx, c.Param("id"))
todos = t
return err
})
if err := g.Wait(); err != nil {
handleError(c, fmt.Errorf("dashboard: %w", err))
return
}
c.JSON(http.StatusOK, Envelope{Data: gin.H{
"profile": profile,
"todos": todos,
}})
}Две горутины идут параллельно: латентность — max(a, b), а не a + b. Запись в локальные переменные profile и todos из горутин безопасна: Wait устанавливает happens-before, читаем мы только после него. Возврат — через handleError: ошибка upstream даст 502 BAD_GATEWAY, «не найден» — 404, и всё это без единого if в самом хендлере. Ушли клиенты тоже учтены: Request.Context() закроется — и WAIT вернёт context.Canceled.
Проверка: кто победил в errgroup
Три g.Go: первый вернул ErrUpstream, второй ещё читает из базы, третий вернёт ErrNotFound. Что вернёт g.Wait()?
Семь правил обработки ошибок в gin#
- Ошибка — значение: репозиторий возвращает сентинел, сервис оборачивает %w с контекстом, хендлер решает по errors.Is и errors.As, а не по подстрокам err.Error().
- %v вместо %w — разорванная цепочка: Is промахнётся, и клиент получит 500 вместо 404; поиск fmt.Errorf без %w — обязательный пункт ревью.
- Один handleError на проект: switch от частного к общему — сначала As на validator.ValidationErrors, потом Is на сентинелы, default — 500 INTERNAL со slog.Error.
- Код ошибки — контракт: статус для транспорта и мониторинга, SCREAMING_SNAKE для словаря клиента, Details для полевых подсказок; внутренности — только в лог.
- c.Error(err) перед ответом: ErrorMessage в [GIN]-строке — единственное место, где причина стоит рядом со статусом; конверт клиенту, причина — копилке.
- Recovery — второй этаж, а не замена handleError: ловит обрывы потока, печатает стек в stderr, отдаёт конверт INTERNAL; уже записанный ответ он не перепишет.
- errgroup для fan-out: WithContext от Request.Context(), SetLimit против шторма горутин, Wait возвращает первую ошибку прямо в handleError.
Соберите handleError
Допишите обёртку с сохранением цепочки и три ветки handleError: копилка, валидация и «не найдено».
Соберите путь запроса с ошибкой валидации
POST /todos с пустым title: разложите события от curl до [GIN]-строки.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: обработка ошибок
Соберите handleError и recoveryEnvelope и устройте разгром: curl -d '{"title":""}' -H "Content-Type: application/json" localhost:8080/todos — 400 с VALIDATION_FAILED и details по полям; curl localhost:8080/todos/999 — 404 с NOT_FOUND; замените %w на %v в Get — и 404 превратится в 500: Is больше не находит сентинел; добавьте GET /boom с записью в nil-карту — 500 с конвертом INTERNAL, а в stderr уйдёт [Recovery]-трассировка и slog-строка с total. Посмотрите [GIN]-строку: ErrorMessage теперь называет причину, а не молчит. В браузерной песочнице сайта сетевой код не исполняется, поэтому интерактивы здесь quiz, predict, fill, order и флешкарты, а примеры живут в локальном go run, как и весь курс gin. Дальше — 18-rate-limit: токенное ведро, x/time/rate и per-IP звено с 429 и Retry-After — обработка ошибок готова, пора ограничивать и тех, кто не ошибается, а просто стучится слишком часто.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- У ошибок gin четыре источника — тело, валидация, инфраструктура, домен — и один финал: Envelope с APIError{Code, Message, Details} из урока про JSON-ответы.
- Сентинелы домена переживают слои только через %w: errors.Is идёт по цепочке разворотов, errors.As достаёт типы вроде validator.ValidationErrors.
- handleError — один switch от частного к общему: As на валидацию, Is на сентинелы, default — 500 INTERNAL со slog; Details для полей, конверт для клиента.
- c.Error кладёт *gin.Error в копилку: ErrorMessage в [GIN]-строке берётся именно оттуда — без c.Error лог молчит о причине отказа.
- Паники — отдельный этаж: RecoveryFunc со счётчиком atomic и конвертом INTERNAL; broken pipe gin разбирает сам, а уже записанный ответ статус не перепишет.
- errgroup: Go параллелит, Wait возвращает первую ошибку, WithContext отменяет остальных; в свежих x/sync паника из горутины перепаникуется в Wait.
Домашнее задание
Соберите этаж ошибок трекера целиком. Сентинелы ErrNotFound и ErrConflict, обёртки %w в репозитории и сервисе, handleError с ветками ValidationErrors (Details по полям через Field и Tag), NOT_FOUND, CONFLICT, BAD_GATEWAY и INTERNAL. Вставьте c.Error первым действием и убедитесь, что ErrorMessage появилась в [GIN]-строке. Замените встроенный Recovery на CustomRecoveryWithWriter со своим recoveryEnvelope: счётчик atomic.Int64, слог с path и total, конверт 500. Сломайте цепочку %v-обёрткой в репозитории и понаблюдайте деградацию 404 → 500, потом почините. Добавьте GET /dashboard/:id с errgroup: два параллельных источника данных, WithContext от Request.Context(), первая ошибка отменяет второго, Wait идёт в handleError. Прогоните curl-ом пять сценариев: пустой title, несуществующий id, битый JSON, /boom и dashboard по живому id.
Личные заметкиПусто
Вопросы и ответы по уроку
Чем c.Error отличается от respondError?
c.Error только копит: *gin.Error ложится в c.Errors и попадает в ErrorMessage access-лога. respondError (и handleError) формирует ответ клиенту. Норма — c.Error(err) внутри handleError: лог получает причину, клиент — конверт, и одно другому не мешает.
Почему после паники клиент получил 200, а не 500?
Если хендлер успел записать ответ, WriteHeader(500) внутри Recovery игнорируется — статус отправляется один раз. Возможен и приписанный конверт в теле. Recovery страхует обрывы до записи; «записал и упал» ловится только логами и тестами.
errgroup или каналы для параллельных запросов?
Для «собрать N ответов, вернуть первую ошибку» — errgroup: Wait, отмена через WithContext, потолок SetLimit. Каналы берут, когда результаты нужно обрабатывать по мере готовности, как поток. В хендлере gin почти всегда достаточно errgroup.
Куда девать стектрейс паники?
Его печатает ядро Recovery в gin.DefaultErrorWriter — stderr или файл из урока про логирование. Внутри RecoveryFunc стека уже нет, только значение паники: логируйте путь, счётчик и значение, а трассировку доверяйте ядру.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Echo: минималистичный быстрый фреймворк
Курс Echo для Go: маршруты, middleware, валидация, JWT, SSE, тестирование и деплой. 25 уроков с примерами и итоговым API-проектом.
- Строить REST API на Echo
- Использовать типизированные запросы/ответы (Request/Response)
- Управлять ошибками через echo.HTTPError
- Писать middleware, включая JWT-аутентификацию