Go: с нуля до своего блога Урок 25 из 50
Ошибки и журнал в Go: увидеть, что сломалось
Двадцать пятый урок курса по Go. Паника в обработчике не роняет сервер — она роняет ответ, и читатель не получает вообще ничего. recover одной прослойкой, номер обращения в странице и в журнале, log/slog вместо печати в консоль и почему после уже ушедшего ответа спасать поздно.
Зачем это нужно
Пока всё работает, журнал кажется лишним. Он нужен ровно в тот день, когда читатель пишет: «у вас сайт не открывается» — и больше ничего.
Сегодня разберём два вопроса. Что видит читатель, когда программа сломалась. И что должно остаться у вас, чтобы понять, почему.
Сразу целиком
Новая папка, go mod init sabaq23, main.go:
package main
import (
"errors"
"fmt"
"log/slog"
"math/rand/v2"
"net/http"
"os"
"runtime/debug"
)
var log = slog.New(slog.NewTextHandler(os.Stdout, nil))
// Номер обращения читаем обратно из заголовка, который сами и поставили.
func reqID(w http.ResponseWriter) string { return w.Header().Get("X-Request-Id") }
func withID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Request-Id", fmt.Sprintf("%08x", rand.Uint32()))
next.ServeHTTP(w, r)
})
}
func recoverPanic(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if v := recover(); v != nil {
w.Header().Set("Connection", "close")
log.Error("паника", "id", reqID(w), "путь", r.URL.Path,
"причина", fmt.Sprint(v), "стек", string(debug.Stack()))
serverError(w)
}
}()
next.ServeHTTP(w, r)
})
}
// Всем читателям — одна и та же фраза, подробности остаются в журнале.
func serverError(w http.ResponseWriter) {
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, "Что-то сломалось на нашей стороне. Номер обращения: %s\n", reqID(w))
}
var errNotFound = errors.New("статья не найдена")
func find(slug string) (string, error) {
titles := map[string]string{"dala": "О степи", "shanyraq": "Что такое шанырак"}
if t, ok := titles[slug]; ok {
return t, nil
}
return "", fmt.Errorf("find %q: %w", slug, errNotFound)
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Мой блог")
})
mux.HandleFunc("GET /read/{slug}", func(w http.ResponseWriter, r *http.Request) {
title, err := find(r.PathValue("slug"))
switch {
case errors.Is(err, errNotFound):
log.Info("статьи нет", "id", reqID(w), "адрес", r.PathValue("slug"))
http.NotFound(w, r)
return
case err != nil:
log.Error("не смогли прочитать", "id", reqID(w), "ошибка", err)
serverError(w)
return
}
fmt.Fprintln(w, title)
})
mux.HandleFunc("GET /panic", func(w http.ResponseWriter, r *http.Request) {
var titles map[string]string
titles["dala"] = "О степи" // запись в nil-словарь
})
handler := withID(recoverPanic(mux))
log.Info("сервер запущен", "адрес", "http://localhost:8080")
if err := http.ListenAndServe(":8080", handler); err != nil {
log.Error("сервер остановился", "ошибка", err)
os.Exit(1)
}
}
Разбор
Паника не роняет сервер — она роняет ответ
Сначала посмотрим, что бывает без всякой защиты. Уберите на минуту recoverPanic из последней строки и позовите /panic:
curl -i http://localhost:8080/panic
curl: (52) Empty reply from server
Не пятисотая. Не пустая страница. Ничего: соединение закрылось, кода ответа не было вовсе.
При этом сервер жив — попросите / ещё раз, и он ответит. net/http ловит панику сам, на уровне соединения, и в его журнал попадает вот такое:
2026/09/05 16:03:35 http: panic serving [::1]:55036: assignment to entry in nil map
goroutine 4 [running]:
net/http.(*conn).serve.func1()
То есть у вас есть стек, а у читателя — пустота. С его стороны это выглядит как «сайт сломался», и написать он вам ничего не сможет: сообщать нечего.
Образ. Продавец падает в обморок, не сказав ни слова. Магазин работает, следующий покупатель обслужен, а этот стоит перед прилавком и не знает даже, к кому обращаться.
recover — одна прослойка на все страницы
Верните recoverPanic и позовите тот же адрес:
HTTP/1.1 500 Internal Server Error
Connection: close
X-Request-Id: 1a2b3c4d
Date: Sat, 05 Sep 2026 11:03:07 GMT
Content-Length: 103
Content-Type: text/plain; charset=utf-8
Что-то сломалось на нашей стороне. Номер обращения: 1a2b3c4d
Теперь это разговор: есть код, есть страница, есть номер.
Работает это на defer и recover. defer запускает функцию, когда обработчик завершается — неважно, нормально или паникой. recover внутри такой функции останавливает падение и отдаёт то, чем паниковали.
defer func() {
if v := recover(); v != nil {
...
}
}()
recover() имеет смысл только внутри отложенной функции. Вызванный просто так, он вернёт nil и ничего не остановит.
Connection: close мы ставим потому, что соединение после полуотправленного ответа доверия не заслуживает: пусть браузер откроет новое.
И почему это прослойка, а не строчка в каждом обработчике: обработчиков будет тридцать, а забыть можно в одном. Обёртка ставится один раз на весь mux — приём из урока про middleware.
Читателю — номер, в журнал — подробности
Соблазн написать читателю err.Error() велик, и так делать нельзя. Текст ошибки — это ваши внутренности: имя таблицы, путь к файлу, иногда кусок запроса. Постороннему человеку это не поможет, а тому, кто ищет дыры, поможет.
Поэтому фраза одна на всех, а вместо подробностей — номер:
Что-то сломалось на нашей стороне. Номер обращения: 1a2b3c4d
Тот же номер стоит в журнале:
level=ERROR msg=паника id=1a2b3c4d путь=/panic причина="assignment to entry in nil map" стек="goroutine 35 [running..."
В выдержках убрано время, а номер запроса заменён образцом: у вас он будет свой, но в обеих строках — один и тот же.
Читатель присылает восемь знаков — вы находите строку. Без этого номера поиск выглядит как «примерно вчера вечером кто-то открыл что-то».
Номер мы кладём в заголовок X-Request-Id и оттуда же читаем. Взрослый способ — носить его в контексте запроса, но контекст будет позже; заголовок вы уже знаете, и у него есть побочная польза: номер видно в ответе.
Границы приёма: если ответ уже ушёл
Теперь честно про то, чего recover не может.
Обработчик, который написал двести строк и только потом упал, отдаёт вот что:
HTTP/1.1 200 OK
...
строка 199 — наполняем буфер
Что-то сломалось на нашей стороне. Номер обращения: 1a2b3c4d
Код ответа — 200. Не 500: заголовки уехали, когда буфер net/http наполнился, и поменять их уже нельзя. В журнал при этом падает знакомая строка:
superfluous response.WriteHeader call from main.serverError (main.go:41)
Читатель видит страницу, которая выглядит нормально, а в конце — извинение. Поисковик видит 200 и индексирует это.
Отсюда правило, которое мы уже вводили в уроке про шаблоны, а теперь оно окупается: страницу собирают в буфер целиком и отдают одним куском. Тогда падение случается до первого байта, и recover успевает.
log/slog: строки, которые можно искать
fmt.Println в журнале — это текст. slog — это поля.
log.Info("статьи нет", "id", reqID(w), "адрес", r.PathValue("slug"))
level=INFO msg="статьи нет" id=1a2b3c4d адрес=kokek
Пары «имя — значение» идут после сообщения. Разница появляется, когда строк миллион: адрес=kokek можно отобрать, посчитать, сгруппировать, а «не нашли статью kokek» — только прочитать глазами.
Обработчик выбирают при создании:
slog.New(slog.NewTextHandler(os.Stdout, nil)) // человеку
slog.New(slog.NewJSONHandler(os.Stdout, nil)) // машине
Text читается за плечом разработчика, JSON собирается системой хранения логов. Менять можно одной строкой, потому что весь остальной код к этому не привязан.
Уровни — Debug, Info, Warn, Error — нужны, чтобы не читать всё подряд. По умолчанию Debug не печатается.
Ошибка — не всегда 500
Посмотрите на /read/{slug} ещё раз. Несуществующая статья — это не поломка:
case errors.Is(err, errNotFound):
log.Info("статьи нет", ...)
http.NotFound(w, r)
Info, а не Error, и 404, а не 500. Читатель ошибся адресом или статью убрали — программа отработала правильно.
Если писать Error на каждую ненайденную страницу, через неделю в журнале будет десять тысяч «ошибок», среди которых настоящая потеряется. Уровень — это обещание: Error значит «человеку надо посмотреть».
Разделение приходит из урока про ошибки: errors.Is отвечает на вопрос «что это за ошибка», и по ответу выбирается и код, и уровень записи.
Журнал пишут в stdout, а не в файл
slog.NewTextHandler(os.Stdout, nil) — вывод в стандартный поток, и это не упрощение для урока.
Программа, которая пишет в файл сама, обязана думать про путь, права, размер, обрезку и удаление старого. Всё это уже умеет тот, кто её запускает: systemd, docker, любой хостинг. Пишите в stdout — и на своей машине увидите строки в терминале, а на сервере они попадут туда, куда настроено, без единой правки в коде.
Карта урока
Скажите своими словами
Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.
- Обработчик упал с паникой, а прослойки с
recoverнет. Что увидит читатель? - Почему читателю нельзя показывать текст ошибки?
- Почему ненайденная статья пишется уровнем
Info, а неError?
Задание
Обязательное. Верните в программу шаблоны из урока про шаблоны и сделайте страницу 500 отдельным шаблоном — с номером обращения на ней. Собирайте её в буфер. Проверьте, что при панике приходит именно 500, а номер на странице совпадает с номером в журнале.
По желанию.
- Замените
NewTextHandlerнаNewJSONHandlerи посмотрите на ту же строку. - Добавьте прослойку, которая пишет одну строку на каждый запрос: метод, путь, код и время. Код придётся запомнить самому — как в уроке про middleware. Поставьте её снаружи
recoverPanic: иначе паника пройдёт мимо неё, и записи о запросе не будет вовсе. - Сделайте обработчик, который пишет много строк и потом паникует, и убедитесь, что кода
500в ответе нет.
Куда это встанет в блоге
Блог перестал молчать при поломке. Дальше — конфигурация: адрес, порт и пути перестанут быть вписанными в код.
Долги. Номер обращения живёт в заголовке, а правильное место для него — контекст запроса; это будет вместе с уроком про параллельность. Строка на каждый запрос пока пишется отдельной прослойкой, а хочется одну на всё. И debug.Stack() в журнале — вещь тяжёлая: на настоящем сайте её пишут только для настоящих паник, а не для любой ошибки.
Ответы
Показать ответы
- Ничего. Соединение закроется без ответа:
curlскажетEmpty reply from server, браузер покажет свою страницу о недоступности. Кода ответа не будет вовсе. Сервер при этом продолжит работать, а стек попадёт в его собственный журнал — то есть у вас информация есть, а у читателя нет. - Потому что текст ошибки — это внутренности программы: имена таблиц, пути, куски запросов. Читателю это ничем не поможет, а тому, кто ищет уязвимости, подскажет, как устроен сервер. Поэтому читателю — общая фраза и номер обращения, а подробности — в журнал.
- Потому что это не поломка: программа отработала правильно, просто такой статьи нет.
Error— это обещание, что человеку надо вмешаться. Если помечать так каждый неверный адрес, настоящие ошибки утонут среди тысяч ложных.
Источники
Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом
Комментарии (0)
Войдите, чтобы оставить комментарий →
Пока нет комментариев. Будьте первым.