Shanraq.org Shanraq.org
Ошибки и журнал в Go: увидеть, что сломалось
IT

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 — и на своей машине увидите строки в терминале, а на сервере они попадут туда, куда настроено, без единой правки в коде.

Карта урока

Карта урока: панику ловят, и читатель получает код и номер

Скажите своими словами

Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.

  1. Обработчик упал с паникой, а прослойки с recover нет. Что увидит читатель?
  2. Почему читателю нельзя показывать текст ошибки?
  3. Почему ненайденная статья пишется уровнем Info, а не Error?

Задание

Обязательное. Верните в программу шаблоны из урока про шаблоны и сделайте страницу 500 отдельным шаблоном — с номером обращения на ней. Собирайте её в буфер. Проверьте, что при панике приходит именно 500, а номер на странице совпадает с номером в журнале.

По желанию.

  • Замените NewTextHandler на NewJSONHandler и посмотрите на ту же строку.
  • Добавьте прослойку, которая пишет одну строку на каждый запрос: метод, путь, код и время. Код придётся запомнить самому — как в уроке про middleware. Поставьте её снаружи recoverPanic: иначе паника пройдёт мимо неё, и записи о запросе не будет вовсе.
  • Сделайте обработчик, который пишет много строк и потом паникует, и убедитесь, что кода 500 в ответе нет.

Куда это встанет в блоге

Блог перестал молчать при поломке. Дальше — конфигурация: адрес, порт и пути перестанут быть вписанными в код.

Долги. Номер обращения живёт в заголовке, а правильное место для него — контекст запроса; это будет вместе с уроком про параллельность. Строка на каждый запрос пока пишется отдельной прослойкой, а хочется одну на всё. И debug.Stack() в журнале — вещь тяжёлая: на настоящем сайте её пишут только для настоящих паник, а не для любой ошибки.

Ответы

Показать ответы
  1. Ничего. Соединение закроется без ответа: curl скажет Empty reply from server, браузер покажет свою страницу о недоступности. Кода ответа не будет вовсе. Сервер при этом продолжит работать, а стек попадёт в его собственный журнал — то есть у вас информация есть, а у читателя нет.
  2. Потому что текст ошибки — это внутренности программы: имена таблиц, пути, куски запросов. Читателю это ничем не поможет, а тому, кто ищет уязвимости, подскажет, как устроен сервер. Поэтому читателю — общая фраза и номер обращения, а подробности — в журнал.
  3. Потому что это не поломка: программа отработала правильно, просто такой статьи нет. Error — это обещание, что человеку надо вмешаться. Если помечать так каждый неверный адрес, настоящие ошибки утонут среди тысяч ложных.

Источники

Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом

Проверить задание

Сначала решите и запустите в VS Code — редактор покажет ошибку на месте. Готовое решение вставьте сюда. Проверяет модель: она укажет на ошибку, но не даст готовый ответ.

Чтобы проверить, нужно войти. Войти

Комментарии (0)

Пока нет комментариев. Будьте первым.