JavaRush /Курсы /Go SELF /Ошибки: текст, wrapping, сообщения пользователю

Ошибки: текст, wrapping, сообщения пользователю

Go SELF
37 уровень , 2 лекция
Открыта

1. Две аудитории ошибок

Если смотреть на код глазами новичка, может показаться, что ошибка — это просто строка, которую мы показываем на экране. Но в Go ошибка — значение, и в ней обычно живут сразу две вещи: человекочитаемое объяснение и техническая причина, по которой программа не смогла продолжить. Именно поэтому одна и та же ошибка может быть важна и для человека (“почему не получилось?”), и для программы (“что именно сломалось, можно ли повторить, можно ли обработать по-особому?”).

В реальной разработке эти две аудитории часто конфликтуют. Пользователь не должен читать простыню из внутренних деталей (пути, SQL, адреса, внутренние типы). А разработчику, наоборот, эти детали — золото, потому что по ним он понимает, где и что произошло. Наша задача — научиться держать обе потребности одновременно, но аккуратно.

Для ориентира удобно держать в голове простую картинку:

flowchart TD
    A[Низкий уровень: парсинг, работа с данными] -->|возвращает err| B[Бизнес-логика]
    B -->|wrap: добавляет контекст| C[Граница приложения: main/Run]
    C -->|user message| D[Пользователь]
    C -->|reason chain| E[Логи/диагностика]

Ошибки путешествуют снизу вверх, обрастают контекстом, а на границе приложения мы решаем: что сказать пользователю и что оставить “внутри”.

2. Текст ошибки и контекст операции

Стиль текста ошибки

Текст ошибки — это то, что чаще всего увидит человек. Поэтому он должен быть коротким, понятным и “склеиваемым” в цепочку, если ошибка поднимается вверх по стеку вызовов. В Go принято писать сообщения ошибок строчными буквами, без точки в конце и без префиксов вроде Error: — потому что ошибки часто складываются в цепочку, и тогда текст должен читаться естественно.

Давайте начнём с простого примера из нашего учебного приложения “todo”. Пусть мы нормализуем заголовок задачи:

package todo

import (
	"errors"
	"strings"
)

var ErrEmptyTitle = errors.New("title is empty")

func NormalizeTitle(title string) (string, error) {
	title = strings.TrimSpace(title)
	if title == "" {
		return "", ErrEmptyTitle
	}
	return title, nil
}

Здесь важно, что ErrEmptyTitle — не “сообщение пользователю”, а причина. Её текст всё равно должен быть человеческим, потому что его увидит разработчик, но она прежде всего служит для распознавания и диагностики.

Если мы хотим добавить контекст, мы обычно делаем это выше уровнем:

package todo

import "fmt"

func AddTask(rawTitle string) error {
	title, err := NormalizeTitle(rawTitle)
	if err != nil {
		return fmt.Errorf("add task: %w", err)
	}

	_ = title // тут позже будет сохранение
	return nil
}

Теперь, если заголовок пустой, человек (разработчик) увидит цепочку вроде add task: title is empty. Это читается как маршрут: “на этапе добавления задачи — заголовок пустой”.

Контекст операции как маршрут по коду

Одна из самых обидных ошибок новичка — вернуть “голую” причину без контекста. Например, где-то глубоко упал strconv.Atoi, и наверх прилетело просто invalid syntax. Формально это правда, но практически бесполезно: что именно пытались распарсить и где?

Поэтому хороший стиль — добавлять контекст операции по мере подъёма ошибки. Причём контекст — это не “ещё 200 символов”, а короткая подпись, которая отвечает на вопрос: “что мы делали, когда это сломалось?”

Сравним два подхода на примере парсинга id задачи.

Вариант без контекста (плохой для диагностики):

package todo

import "strconv"

func ParseID(s string) (int, error) {
	return strconv.Atoi(s)
}

Вариант с контекстом (обычно лучше):

package todo

import (
	"fmt"
	"strconv"
)

func ParseID(s string) (int, error) {
	id, err := strconv.Atoi(s)
	if err != nil {
		return 0, fmt.Errorf("parse id %q: %w", s, err)
	}
	return id, nil
}

Если s = "abc", мы получим ошибку, которая одновременно полезна человеку (“мы пытались распарсить id и вот какое значение было”) и полезна коду (потому что причина сохранена через %w).

3. Wrapping и цепочка причин

Как работает %w и зачем он нужен

Важный поворот: оборачивать ошибку (wrapping) — это не то же самое, что “добавить текст”. В Go 1.13 в fmt.Errorf появился спецификатор %w, который делает возвращаемую ошибку “обёрткой” над исходной: создаётся цепочка причин, доступная через errors.Is, errors.As и errors.Unwrap.

Пример, который стоит буквально выучить руками:

package todo

import (
	"errors"
	"fmt"
)

var ErrNotFound = errors.New("not found")

func LoadTask(id int) error {
	// представим, что в хранилище не нашли задачу
	return fmt.Errorf("load task %d: %w", id, ErrNotFound)
}

Почему это важно? Потому что теперь внешний код может не парсить строку, а проверять причину правильно:

package main

import (
	"errors"
	"fmt"

	"example/todo"
)

func main() {
	err := todo.LoadTask(10)
	fmt.Println(err) // load task 10: not found

	if errors.Is(err, todo.ErrNotFound) {
		fmt.Println("задача не найдена") // задача не найдена
	}
}

errors.Is умеет “спускаться” по цепочке обёрток и искать совпадение причины. В простом случае это аналог сравнения с sentinel-ошибкой, но уже wrapper-aware.

Похожая история с errors.As: это “wrapper-aware type assertion”, то есть способ достать типизированную ошибку из цепочки. Даже если сейчас вы используете typed errors редко, важно понимать идею: %w — это не про красоту текста, а про доступность причины для кода.

Когда wrapping делать не стоит

Вот здесь начинается взрослая жизнь. Wrapping — это решение не “про удобство автора”, а “про контракт”. Если вы оборачиваете ошибку через %w, вы даёте вызывающему коду возможность зависеть от конкретной причины (например, от sql.ErrNoRows или от внутренней ошибки файловой системы). А значит — вы почти подписываете договор: “я и дальше буду возвращать эту причину”.

Классический пример из обсуждений Go: если ваш пакет использует database/sql как внутреннюю деталь, и вы возвращаете наружу ошибку так, что её можно распознать как sql.ErrNoRows, то пользователи вашего пакета начнут писать errors.Is(err, sql.ErrNoRows). Потом вы захотите заменить библиотеку — и внезапно это станет breaking change.

Важно сформулировать это так: wrap или не wrap — это API-решение, похожее на решение “экспортировать поле структуры или нет”. То есть иногда нужно оборачивать, чтобы код мог реагировать (например, not found), а иногда нужно спрятать внутренности и вернуть ошибку с тем же текстом, но без возможности unwrap.

Чтобы почувствовать разницу, сравним два варианта:

// Вариант A: сохраняем причину для кода (wrap)
return fmt.Errorf("load task: %w", err)
// Вариант B: сохраняем только текст (не wrap)
return fmt.Errorf("load task: %v", err)

Снаружи для человека они выглядят одинаково (текст тот же), но для программы это разные миры: в варианте A причина доступна через errors.Is/errors.As, в варианте B — нет.

4. Сообщение пользователю и причина для логов

На этом месте обычно рождается вечный вопрос: “Так что, пользователю показывать err.Error() или нет?” Ответ зависит от того, кто пользователь и какой слой приложения. Внутри бизнес-логики мы возвращаем ошибки как причины и контекст, и это почти всегда технический язык. А на границе приложения мы превращаем причины в короткие, стабильные и дружелюбные сообщения.

Важная идея: сообщение пользователю должно быть стабильным, потому что это часть UX. А “причина” (цепочка errors) должна быть богатой, потому что это часть диагностики. И эти вещи лучше не смешивать в одном и том же тексте.

Представим, что у нас есть три типовые причины:

  • задача не найдена,
  • ввод некорректный,
  • внутренняя ошибка (непредвиденное).

Мы можем держать это как таблицу соответствий (это проще, чем пытаться “угадать по строке”):

Причина (для кода) Что сказать пользователю
ErrNotFound
не найдено
ErrEmptyTitle
заголовок не должен быть пустым
всё остальное
внутренняя ошибка

Реализуем функцию “переводчика” в нашем пакете todo. Обратите внимание: мы используем errors.Is, потому что ошибка может быть обёрнута много раз.

package todo

import "errors"

func UserMessage(err error) string {
	switch {
	case err == nil:
		return "ok"
	case errors.Is(err, ErrNotFound):
		return "не найдено"
	case errors.Is(err, ErrEmptyTitle):
		return "заголовок не должен быть пустым"
	default:
		return "внутренняя ошибка"
	}
}

Теперь у нас появляется красивый контракт: внутренние функции возвращают диагностируемые ошибки, а внешний слой решает, что именно показывать пользователю.

5. Пример mini-todo

Сейчас мы соберём маленький, но жизненный кусочек приложения, где видно сразу всё: стиль текста, wrapping, и разделение “для пользователя / для диагностики”. Важно: примеры маленькие, как кубики LEGO. В жизни они окажутся в разных файлах, но смысл один — ошибка должна быть полезной на каждом уровне.

Начнём с модели-хранилища “в памяти” и ошибки “не найдено”:

package todo

import "errors"

var ErrNotFound = errors.New("not found")

type MemoryStore struct {
	titles map[int]string
}

func NewMemoryStore() MemoryStore {
	return MemoryStore{titles: make(map[int]string)}
}

Добавим метод чтения, который возвращает причину (в данном случае sentinel):

package todo

func (s MemoryStore) TitleByID(id int) (string, error) {
	title, ok := s.titles[id]
	if !ok {
		return "", ErrNotFound
	}
	return title, nil
}

Теперь добавим слой “сервиса”, который даёт контекст операции. Тут как раз место для wrapping:

package todo

import "fmt"

type Service struct {
	store MemoryStore
}

func NewService(store MemoryStore) Service {
	return Service{store: store}
}

func (s Service) GetTitle(id int) (string, error) {
	title, err := s.store.TitleByID(id)
	if err != nil {
		return "", fmt.Errorf("get title %d: %w", id, err)
	}
	return title, nil
}

Если задача не найдена, ошибка станет get title 5: not found. Для диагностики это полезнее, чем просто not found, потому что теперь видно, на каком шаге мы были.

Осталось показать “границу приложения”. Пусть у нас есть Run() (условный верхний уровень), который печатает пользователю одно, а себе оставляет другое. Здесь мы для простоты выводим “диагностику” в stdout, но мысль такая: для пользователя — коротко, для разработчика — детально.

package main

import (
	"fmt"

	"example/todo"
)

func main() {
	store := todo.NewMemoryStore()
	svc := todo.NewService(store)

	_, err := svc.GetTitle(5)
	if err != nil {
		fmt.Println(todo.UserMessage(err)) // не найдено
		fmt.Printf("debug: %v\n", err)     // debug: get title 5: not found
		return
	}

	fmt.Println("ok") // ok
}

В этом мини-примере уже видна правильная архитектурная привычка: мы не заставляем пользователя читать “get title 5”, но при этом не теряем информацию для диагностики.

6. Типичные ошибки

Ошибка №1: возвращать ошибки “без маршрута”.
Когда функция возвращает только первопричину вроде invalid syntax, без контекста операции, вы сами себе закладываете мину на будущее: через неделю вы увидите этот текст и будете гадать, что именно парсили и откуда пришло значение. Обычно достаточно одного аккуратного fmt.Errorf("parse id %q: %w", s, err), чтобы ошибка стала в разы полезнее.

Ошибка №2: думать, что %w — это “красивее печатает”.
%w нужен не для красоты текста, а для сохранения причины в цепочке, чтобы errors.Is/errors.As работали корректно. Если вам не нужно, чтобы внешний код мог распознать исходную причину, иногда правильнее использовать %v, оставив только текст.

Ошибка №3: wrap-нуть всё подряд и случайно выдать наружу внутренности.
Wrapping — это API-решение: если вы отдаёте наружу unwrap-доступ к ошибке другого пакета, вы позволяете пользователям зависеть от неё. Потом вы можете оказаться в ловушке совместимости, когда менять реализацию станет больно. В таких местах лучше явно решить: это часть контракта или внутренняя деталь?

Ошибка №4: показывать пользователю err.Error() как есть.
Техническая ошибка часто содержит лишние детали, пугающие формулировки или просто “мусор” с точки зрения UX. Гораздо надёжнее сделать маленький слой маппинга причин в короткие сообщения, а err (с контекстом и цепочкой) оставить для диагностики на границе приложения.

Ошибка №5: сравнивать ошибки через ==, когда уже есть wrapping.
Как только в проекте появился %w, прямое сравнение err == ErrNotFound перестаёт работать ожидаемо, потому что err уже не равен маркеру, а лишь содержит его в цепочке. В таких случаях почти всегда нужно переходить на errors.Is(err, ErrNotFound).

1
Задача
Go SELF, 37 уровень, 2 лекция
Недоступна
Счётчик задач
Счётчик задач
1
Задача
Go SELF, 37 уровень, 2 лекция
Недоступна
Парсинг ввода
Парсинг ввода
1
Задача
Go SELF, 37 уровень, 2 лекция
Недоступна
Сообщение и причина
Сообщение и причина
1
Задача
Go SELF, 37 уровень, 2 лекция
Недоступна
Два похожих текста
Два похожих текста
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ