Sentinel и свои типы ошибок в Go
Пользователю нужно отличить «записи нет» от «нет прав» - в первом случае показать пустую форму, во втором отправить на страницу входа. Разбирать текст ошибки строкой нельзя: он поменяется на следующем рефакторинге. Значит пакет обязан дать способ различать причины программно, и способов ровно два.
Стержень: sentinel - одно известное значение для факта, свой тип - для факта вместе с деталями.
// Формулировки: «что такое sentinel-ошибка?», «когда нужен свой тип?», «как отличить not found от отказа в доступе?»
Sentinel: одно значение на пакет
Sentinel - переменная уровня пакета, созданная через errors.New один раз при запуске: var ErrNotFound = errors.New("не найдено"). Она сравнивается по идентичности, поэтому важна именно та самая переменная, а не другая с тем же текстом.
Проверяют через errors.Is - он пройдёт цепочку обёрток и найдёт эталон на любой глубине. Именно так устроены sql.ErrNoRows, io.EOF (end of file, признак конца потока), os.ErrNotExist: ты сравниваешь с известным значением и точно знаешь, что случилось.
Ограничение очевидно из устройства: sentinel несёт только факт. Какое поле не прошло проверку, какой идентификатор не нашёлся, сколько осталось попыток - всё это в него не помещается.
// Объявляй sentinel-ошибки экспортируемыми и с префиксом Err - это устоявшаяся конвенция, по которой их узнают. И помни, что публичный sentinel - часть контракта пакета: убрать его потом нельзя, кто-то уже написал на него проверку.
var ErrNotFound = errors.New("не найдено")
// в глубине пакета
return fmt.Errorf("пользователь %d: %w", id, ErrNotFound)
// у вызывающего
if errors.Is(err, ErrNotFound) { return http.StatusNotFound }Свой тип: факт плюс детали
Когда нужны подробности, объявляют структуру с методом Error() string. Внутри - всё, что понадобится вызывающему: поле, значение, код ответа, время до следующей попытки.
Достают её через errors.As: передаёшь указатель на переменную своего типа, и если такое звено в цепочке есть, оно окажется в переменной. Дальше это обычная структура с полями, и никакого разбора строк.
Одна деталь ловит новичков: метод Error объявляют на указателе, значит и в цепочке лежит *MyErr, и в As надо передавать указатель на переменную типа *MyErr. Смешение получателя-значения и получателя-указателя тут даёт «ошибка не находится», хотя она есть.
// Если типов ошибок в пакете много, их часто объединяют одним интерфейсом с дополнительным методом - например Temporary() bool или Code() int. Тогда вызывающий проверяет через As по интерфейсу и не зависит от конкретных структур.
type ValidationError struct {
Field string
Value any
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("поле %s: недопустимое значение %v", e.Field, e.Value)
}
var ve *ValidationError
if errors.As(err, &ve) {
highlight(ve.Field) // подсветить именно это поле формы
}Что выбрать
Правило простое: нужен только факт - sentinel, нужны детали - свой тип. «Записи нет» это чистый факт, тут sentinel. «Поле email не прошло проверку» это факт с деталями, тут структура.
Не плоди сущности заранее. Три десятка sentinel-значений на пакет - признак того, что автор пытался предусмотреть каждый случай; пользователи всё равно проверяют два-три из них. Начинай с одного-двух и добавляй по реальному спросу.
И не превращай в контракт то, что им быть не должно. Внутренние ошибки, интересные только тебе, наружу не выставляют: их заворачивают в свою ошибку через %v, чтобы пользователи не начали на них завязываться.
// Отдельный частый случай - временные ошибки. Если вызывающему нужно решать, повторять ли запрос, дай ему способ это узнать: свой тип с методом или отдельный sentinel. Иначе он будет повторять всё подряд, включая ошибки валидации, которые не исправятся никогда.
Как отвечать: «Sentinel или свой тип ошибки - что выбрать?»
Смотрю, нужен ли вызывающему только факт или ещё и подробности. Если факт - беру sentinel: переменная уровня пакета через errors.New, экспортируемая, с префиксом Err, проверяется через errors.Is. Так устроены sql.ErrNoRows и io.EOF, и это самый дешёвый вариант. Если нужны детали - какое поле не прошло, какой код вернул сервис, через сколько повторять - делаю структуру с методом Error и достаю её через errors.As. Тогда у вызывающего обычные поля, а не разбор текста. Метод Error я объявляю на указателе и помню, что в As надо передавать указатель на переменную типа указателя, иначе ошибка просто не найдётся. Чего стараюсь избегать: тридцати sentinel-значений на пакет заранее - реально проверяют два-три, остальные превращаются в контракт, который придётся тащить. И внутренние ошибки наружу через %w не пробрасываю: заворачиваю в свою, чтобы никто не завязался на детали реализации.
Критерий выбора сформулирован одним вопросом, названы конвенции и способ проверки для обоих вариантов, и упомянута ловушка с получателем-указателем в As.
На чём валят
- −Сравнивают ошибки по тексту вместо sentinel или своего типа.
- −Объявляют sentinel внутри функции, и каждое обращение создаёт новое значение.
- −Берут sentinel там, где вызывающему нужны детали, и заставляют его парсить строку.
- −Путают получатель метода Error и передают в As указатель не того уровня.
- −Заводят три десятка ошибок заранее и превращают их все в публичный контракт.
- −Не дают способа отличить временную ошибку от постоянной - и клиент повторяет всё подряд.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 6, остальные разбираются в тренажёре.
- Нужно вернуть ошибку с полем Code int и прочитать его у вызывающего. Как правильно?A)Закодировать код прямо в текст сообщения и парсить эту строку у вызывающегоB)Хранить код в глобальной переменной рядом с sentinel-ошибкойC)Свой тип с полем Code, реализующий error; доставать через errors.AsD)Возвращать пару (error, int), чтобы код шёл отдельным значением рядом
показать ответ и разбор
+C)Свой тип с полем Code, реализующий error; доставать через errors.As// разбор: Для ошибки с данными заводят свой ТИП: type APIError struct{ Code int; ... } с методом Error(). Вернув его (в т.ч. обёрнутым через %w), вызывающий достаёт его из цепочки через errors.As(err, &apiErr) и читает apiErr.Code. Это типобезопасно и переживает оборачивание. Парсить текст или держать код в глобали — хрупко; лишний int в сигнатуре ломает единый error-контракт.
- Как правильно вызвать errors.As, чтобы достать свою ошибку типа *MyErr?A)Передать строку с именем типа для сравнения через рефлексиюB)Передать указатель на переменную: var e *MyErr; errors.As(err, &e)C)Передать сам тип: errors.As(err, (*MyErr)(nil))D)Передать нулевое значение типа: errors.As(err, MyErr{})
показать ответ и разбор
+B)Передать указатель на переменную: var e *MyErr; errors.As(err, &e)// разбор: As должен куда-то записать найденную ошибку, поэтому вторым аргументом идёт указатель на переменную нужного типа. Передадите не указатель — получите панику про «target must be a non-nil pointer». Разница с Is по смыслу: Is отвечает «это та самая ошибка?», As — «достань мне её как конкретный тип, чтобы прочитать поля».
- Метод Error() объявлен на указателе (func (e *MyErr) Error() string). Что из этого следует?A)Интерфейс error реализует только *MyErr, значение MyErr — нетB)Ошибку не получится обернуть через fmt.Errorf с глаголом %wC)errors.Is перестанет находить такую ошибку в цепочке обёртокD)Компилятор потребует объявить и метод String() для парного вывода
показать ответ и разбор
+A)Интерфейс error реализует только *MyErr, значение MyErr — нет// разбор: Тот же набор методов, что и у любого типа: метод на указателе входит только в набор *MyErr. Возврат значения MyErr там, где ожидается error, просто не соберётся, и вернуть придётся &MyErr{...}. Отсюда же общее правило по типам-ошибкам: работать с ними через указатель и в errors.As объявлять переменную как *MyErr.
- Почему sentinel-ошибки объявляют как переменные уровня пакета, а не создают заново при каждом возврате?A)Так экономится память: одно значение вместо аллокации на каждый возвратB)Сравнение идёт по идентичности значения, и новый экземпляр ему не равенC)Локальные переменные ошибок не видны снаружи пакета из-за области видимостиD)Компилятор запрещает возвращать значение errors.New напрямую из функции
показать ответ и разбор
+B)Сравнение идёт по идентичности значения, и новый экземпляр ему не равен// разбор: errors.New каждый раз возвращает новое значение, и сравнение с ним через == или errors.Is даст ложь, даже если текст совпал дословно. Поэтому sentinel заводят один раз (var ErrNotFound = errors.New("not found")) и все возвраты ссылаются на него. Плата за такой контракт — жёсткая связанность: sentinel становится частью публичного API пакета и удалить его потом непросто.
- Что нужно своему типу, чтобы его можно было вернуть как error?A)Поле с именем MessageB)Встраивание типа errors.BaseC)Метод Error() stringD)Регистрация типа через errors.Register
показать ответ и разбор
+C)Метод Error() string// разбор: Интерфейс error состоит из единственного метода Error() string, и любой тип с ним автоматически ему удовлетворяет — объявлять это явно не нужно. Отсюда и типовая ловушка: объявишь метод на указателе — и интерфейс станет реализовывать только *MyErr.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.