Обработка ошибок в Go концептуально прозрачна но вызывает массу вопросов и негодования у людей переходящих на Go с ООП языков. И первое с чем такие люди сталкиваются это отсутствие вложенности ошибок. Чтобы как-то смягчить эту боль в заднице Dave Cheney написал библиотеку для оборачивания ошибок и реализуя некое подобие наследования.
К версии 1.13 разработчики устали слушать нытье и насыпали немного синтаксического сахара добавив новую структуру для оборачивания ошибок и две новых функции для работы с ошибками. Вот как это работает под капотом. Когда мы создаем обычную ошибку, то фактически инициализируем следующую структуру.
type errorString struct {
s string
}
func (e *errorString) Error() string {
return e.s
}
А если оборачиваем ошибку используя вызов fmt.Errorf("%w", err)
, то возвращаемая ошибка получит уже другую структуру, содержащую указатель на оборачиваемую ошибку и метод Unwrap()
для доступа к ней.
type wrapError struct {
msg string
err error
}
func (e *wrapError) Error() string {
return e.msg
}
func (e *wrapError) Unwrap() error {
return e.err
}
С такими ошибками можно делать ровно два действия, сравнить со значением и с типом (не совсем). Понять как это работает проще всего на примере. Возьмем абстрактный микросервис, которых ходит в другой микросервис через RPC клиент по http.
Handler → Middleware → RPC Client → HTTP Client
Ошибка может произойти как на уровне http клиента, так и в постобработке полученного ответа. При этом логирование ошибок происходит на уровне handler’а, а остальные уровни только оборачивают ошибки и пробрасывают наверх. Хватит теории, давай код.
package main
import (
"fmt"
"net/http"
)
type httpError struct {
Code int
Message string
}
func (e *httpError) Error() string {
return fmt.Sprintf("%d: %s", e.Code, e.Message)
}
type rpcError struct {
Method string
err error
}
func (e *rpcError) Error() string {
return fmt.Sprintf("%s error: %s", e.Method, e.err.Error())
}
func (e *rpcError) Unwrap() error {
return e.err
}
func main() {
httpErr := &httpError{
Code: http.StatusInternalServerError,
Message: http.StatusText(http.StatusInternalServerError),
}
rpcErr := &rpcError{
Method: "getEnterpriseResourceV1",
err: httpErr,
}
middlewareErr := fmt.Errorf("middleware: %w", rpcErr)
handlerErr := fmt.Errorf("handler: %w", middlewareErr)
fmt.Printf("%q\n", handlerErr)
// handler: middleware: getEnterpriseResourceV1 error: 500: Internal Server Error
}
Тут происходит имитация ошибки на уровне http клиента с последующим оборачиванием в rpc ошибку, а потом в middleware и handler. Конечная ошибка выглядит как набор вложенных друг в друга структур.
fmt.wrapError{
msg: "handler: middleware: getEnterpriseResourceV1 error: 500: Internal Server Error",
err: &fmt.wrapError{
msg: "middleware: getEnterpriseResourceV1 error: 500: Internal Server Error",
err: &rpcError{
Method: "getEnterpriseResourceV1",
err: &httpError{
Code: 500,
Message: "Internal Server Error",
},
},
},
}
Имея такую матрешку попробуем выяснить какая ошибка была изначально и какие этапы оборачивания она прошла. Для этого воспользуемся функцией сравнения ошибок по значению errors.Is(err, target error)
. Это функция рекурсивно сравнивает значение ошибки с target
вызывая .Unwrap()
для продвижения вглубь матрешки.
errors.Is(handlerErr, handlerErr)
// true т.к эквивалент ==
errors.Is(handlerErr, middlewareErr)
// true т.к handlerErr.Unwrap() == middlewareErr
errors.Is(handlerErr, rpcErr)
// true т.к handlerErr.Unwrap().Unwrap() == rpcErr
errors.Is(handlerErr, httpErr)
// true т.к handlerErr.Unwrap().Unwrap().Unwrap() == httpErr
errors.Is(handlerErr, fmt.Errorf("generic")) // false
errors.Is(handlerErr, &unknownError{}) // false
errors.Is(handlerErr, &rpcError{}) // false т.к совпадает тип, но не значение
errors.Is(handlerErr, &httpError{}) // false т.к совпадает тип, но не значение
Выглядит логично и удобно, но в реальном мире это не будет работать. Переменные вложенных ошибок будут в другом скоупе и их невозможно будет достать чтобы сравнить. Правда можно добавить немного магии, реализовав метод Is(target error)
у httpError
.
func (e *httpError) Is(target error) bool {
return reflect.ValueOf(target).Elem().Type() == reflect.TypeOf(httpError{})
}
В таком случае сработает проверка по-типу даже если значения не будут совпадать.
errors.Is(handlerErr, &httpError{})
// true
Но это хак, т.к для проверки по типу в Go добавили вторую функцию errors.As(err error, target interface{})
. Она работает аналогично функции errors.Is
, рекурсивно обходя матрешку, но сравнивает не значения, а тип target
и вызывая .Unwrap()
для продвижения вглубь. Если же совпадение найдено, но значения типа совпавшей ошибки копируется в target
.
var s *rpcError
if errors.As(handlerErr, &s) {
fmt.Printf("handlerErr as rpcError: %s\n", s.Method)
}
// handlerErr as rpcError: getEnterpriseResourceV1
var h *httpError
if errors.As(handlerErr, &h) {
fmt.Printf("handlerErr as httpError: %d\n", h.Code)
}
// handlerErr as httpError: 500
var e error
if errors.As(handlerErr, &e) {
fmt.Printf("handlerErr as error: %s\n", e)
}
// handlerErr as error: handler: middleware: getEnterpriseResourceV1 error: 500: Internal Server Error
var u *unknownError
if errors.As(handlerErr, &u) {
fmt.Printf("handlerErr as unknownError: %v\n", u)
}
// не выведет ничего
У такого подхода один очевидный плюс, разработчики убили сразу двух зайцев, и сравнение по типу и распаковка ошибки. Но есть и минусы, в ситуации когда нужно просто понять что за тип ошибки нет варианта использовать конструкцию switch, как это можно сделать используя пакет pkg/errors.
В целом новый механизм работы с ошибками решает часть болей и потенциально помогает отказаться от еще одной внешней зависимости в проекте.