Программирование GoОбработка ошибокРазработчик серверных приложений на Go

Когда пользовательскому типу ошибки стоит реализовать fmt.Formatter, а не помещать подробности в Error ?

Когда пользовательскому типу ошибки стоит реализовать fmt.Formatter, а не помещать подробности в Error()?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

fmt.Formatter стоит реализовать, когда одной ошибки нужны разные представления: короткое стабильное сообщение для обычного вывода и расширенная диагностика для специального форматирования, например %+v. Метод Error() при этом должен оставаться компактным, предсказуемым и пригодным для обычного журналирования.

Реализация fmt.Formatter меняет только формат представления. Она не влияет на сопоставление через errors.Is, извлечение через errors.As или цепочку причин через Unwrap.

Исторический контекст

В Go ошибка определяется небольшим интерфейсом с методом Error() string. Такой подход отделяет передачу ошибки от её конкретного типа и позволяет использовать одно значение в разных слоях приложения.

Пакет fmt дополняет базовый механизм интерфейсом форматирования. Это решает практическую проблему, когда краткое сообщение ошибки недостаточно для диагностики, но добавление всех подробностей в Error() ухудшает читаемость логов и делает текст нестабильным.

Постановка проблемы

Если безусловно помещать стек вызовов, внутренние идентификаторы или технические поля в Error(), одно и то же сообщение начинает использоваться сразу как пользовательский текст, лог и диагностическая информация. Это усложняет чтение логов и может превратить формат сообщения в неявный контракт.

Если же менять состояние ошибки во время форматирования или не обрабатывать разные варианты форматирования, повторный вывод может давать разные результаты, а диагностика — зависеть от конкретного вызова fmt.

Подробное решение

Обычно Error() возвращает короткое описание, достаточное для общего контекста. Format может добавить детали при явно выбранном формате, например при %+v, а для остальных случаев должен выдавать понятное базовое представление.

package main import ( "fmt" "io" ) type opError struct{ msg, detail string } func (e opError) Error() string { return e.msg } func (e opError) Format(s fmt.State, v rune) { if v == 'v' && s.Flag('+') { fmt.Fprintf(s, "%s: %s", e.msg, e.detail) return } io.WriteString(s, e.msg) } func main() { fmt.Printf("%v %+v ", opError{"ошибка операции", "тайм-аут чтения"}) }

В примере %v даёт краткое сообщение, а %+v — сообщение с диагностической деталью. Форматтер не должен вызывать форматирование того же значения через fmt, иначе легко получить бесконечную рекурсию.

Важно обрабатывать форматтер как часть поведения типа: он должен быть детерминированным, не менять поля ошибки и не выполнять побочных действий. Подробное представление не является механизмом защиты данных: если оно может содержать секреты, доступ к нему нужно контролировать отдельно.

Если задача состоит только в добавлении контекста к причине, предпочтительнее wrapping через %w, а не fmt.Formatter. Форматтер отвечает за вид вывода, тогда как wrapping сохраняет причинно-следственную связь для errors.Is и errors.As.

Ситуация из практики

Сервис возвращает ошибку сбоя внешнего API. Для клиента нужно короткое сообщение, для технического журнала — код операции, адрес узла и диагностическая причина.

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

Выбран вариант с коротким Error() и fmt.Formatter для явно запрошенного расширенного представления. Он сохраняет единый тип ошибки, не меняет семантику errors.Is и errors.As, а диагностический вывод доступен только в местах, где он действительно нужен. При этом чувствительные данные не включаются в форматтер без дополнительной проверки.

Что кандидаты часто упускают

  1. Меняет ли fmt.Formatter возможность обнаружить исходную ошибку?

Нет. fmt.Formatter определяет только способ отображения значения. Проверки errors.Is и errors.As используют методы сопоставления, тип ошибки и цепочку, возвращаемую Unwrap, поэтому форматирование не должно использоваться вместо проектирования причин ошибки.

  1. Можно ли считать %+v безопасным способом скрыть детали от пользователя?

Нет. Это лишь соглашение о формате вывода. Любой код, имеющий значение ошибки, может явно выбрать расширенное форматирование, а лог или трассировка могут попасть в неподходящее место. Безопасность достигается разделением публичных и внутренних данных, контролем логирования и редактированием чувствительных значений, а не самим форматом %+v.

  1. Что произойдёт, если Format реализован неполно?

Тип всё равно будет форматироваться через этот метод, поэтому пропущенные варианты, например %s, %q или обычный %v, могут дать неожиданный или неполный результат. Форматтеру следует определить разумное базовое поведение для неподдерживаемых сочетаний и не полагаться на то, что Error() будет автоматически вызван вместо него.