Что такое Errorx и чем он полезен


Errorx — это библиотека для работы с ошибками в Go. Она предоставляет инструменты для решения проблем, связанных с механизмом ошибок в больших проектах, и единый синтаксис для работы с ними.


image


Большинство серверных компонентов Joom пишутся на Go с момента основания компании. Этот выбор оправдал себя на начальных этапах разработки и жизни сервиса, и в свете объявлений о перспективах Go 2 мы уверены, что не пожалеем о нем и в будущем. Одна из главных добродетелей Go — простота, и подход к ошибкам как ничто другое демонстрирует этот принцип. Далеко не всякий проект достигает достаточных масштабов, чтобы возможностей стандартной библиотеки стало не хватать, побуждая искать собственные решения в этой сфере. Нам довелось пройти некоторую эволюцию в подходах к работе с ошибками, и библиотека errorx отражает итог этой эволюции. Мы убеждены, что она может оказаться полезна многим — в том числе и тем, кто пока не испытывает сильного дискомфорта в работе с ошибками на своих проектах.


Ошибки в Go


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


type error interface {
   Error() string
}

Очень просто, не правда ли? На практике реализация часто действительно не несет с собой ничего, кроме строкового описания ошибки. Такой минимализм связан с подходом, согласно которому ошибка вовсе не обязательно значит что-то "исключительное". Наиболее часто использующийся errors.New() из стандартной библиотеки верен этой идее:


func New(text string) error {
    return &errorString{text}
}

Если вспомнить, что ошибки в языке не имеют никакого особого статуса и являются обычными объектами, возникает вопрос: в чем же особенность работы с ними?


Ошибки — это не исключения. Не секрет, что многие, знакомясь с Go, встречают это различие с некоторым сопротивлением. Существует множество публикаций, как объясняющих и поддерживающих, так и критикующих выбранный в Go подход. Так или иначе, ошибки в Go служат многим целям, и как минимум одна из них в точности такая же, как у исключений в некоторых других языках: диагностика неполадок. Как следствие, естественно ожидать от них той же выразительной силы, даже если подход и синтаксис, связанный с их использованием, сильно отличается.


Что не так?


Многие проекты пользуются ошибками в Go, как они есть, и не испытывают по этому поводу ни малейших затруднений. Однако по мере того, как сложность системы растет, начинает проявляться ряд проблем, которые привлекают к себе внимание даже в отсутствие завышенных ожиданий. Хорошей их иллюстрацией может служить подобная строка в логе вашего сервиса:


Error: duplicate key


Здесь сразу же становится очевидна первая проблема: если не позаботиться об этом специально, то в сколько-нибудь большой системе понять, что пошло не так, только лишь по исходному сообщению практически невозможно. В этом сообщении не хватает подробностей и более широкого контекста проблемы. Это ошибка программиста, но случается она слишком часто, чтобы этим пренебрегать. Код, посвященный "позитивным" ветвям графа управления, на практике всегда заслуживает больше внимания и лучше покрыт тестами, чем код "негативный", связанный с прерыванием исполнения или внешними проблемами. То, насколько часто мантра if err != nil {return err} повторяется в программах на Go, делает эту оплошность еще более вероятной.


В качестве маленького отступления рассмотрим такой пример:


func (m *Manager) ApplyToUsers(action func(User) (*Data, error), ids []UserID) error {
    users, err := m.LoadUsers(ids)
    if err != nil {
        return err
    }

    var actionData []*Data
    for _, user := range users {
        data, err := action(user)
        if err != nil {
            return err
        }

        ok, err := m.validateData(data)
        if err != nil {
            return nil
        }

        if !ok {
            log.Error("Validation failed for %v", data)
            continue
        }

        actionData = append(actionData, data)
    }

    return m.Apply(actionData)
}

Насколько быстро вы увидели в этом коде ошибку? А ведь ее совершал хотя бы раз, наверное, любой программист на Go. Подсказка: ошибка в выражении if err != nil { return nil }.


Если же вернуться к проблеме с невнятным сообщением в логе, то в такой ситуации, конечно, тоже бывал каждый. Начинать исправлять код обработки ошибок уже в момент наступления проблемы очень неприятно; кроме того, по исходным данным из лога совершенно не ясно, с какой стороны начинать поиски той части кода, которую, собственно, нужно улучшать. Это может показаться надуманной сложностью для проектов, небольших по объему кода и числу внешних зависимостей. Однако для масштабных проектов это совершенно реальная и болезненная проблема.


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


func InsertUser(u *User) error {
    err := usersTable.Insert(u)
    if err != nil {
        return errors.New(fmt.Sprintf("failed to insert user %s: %v", u.Name, err)
    }

    return nil
}

Стало лучше. Более широкий контекст все еще неясен, зато теперь куда проще найти хотя бы, в каком коде случилась ошибка. Однако, решив одну проблему, мы неумышленно создали другую. Ошибка, созданная здесь, сохранила диагностическое сообщение исходной, но все остальное, включая ее тип и дополнительное содержимое, оказалось потеряно.


Чтобы увидеть, чем это опасно, рассмотрим подобный код в драйвере базы данных:


var ErrDuplicateKey = errors.New("duplicate key")

func (t *Table) Insert(entity interface{}) error { 
    // returns ErrDuplicateKey if a unique constraint is violated by insert 
}  

func IsDuplicateKeyError(err error) bool {
    return err == ErrDuplicateKey
}

Теперь проверка IsDuplicateKeyError() разрушена, хотя в момент, когда мы добавляли свой текст к ошибке, мы не имели намерения изменить ее семантику. Это, в свою очередь, сломает код, который полагается на эту проверку:


func RegisterUser(u *User) error {
    err := InsertUser(u)
    if db.IsDuplicateKeyError(err) {
        // find existing user, handle conflict
    } else {
        return err
    }
}

Если мы захотим поступить умнее и добавить свой тип ошибки, который будет хранить исходную ошибку и уметь возвращать ее, скажем, через метод Cause() error, то тоже решим проблему лишь отчасти.


  1. Теперь в месте обработки ошибки нужно знать, что истинная причина лежит в Cause()
  2. Нет никакого способа обучить внешние библиотеки этому знанию, и helper-функции, написанные в них, останутся бесполезны
  3. Наша реализация может ожидать, что Cause() возвращает непосредственную причину ошибки (или nil, если ее нет), в то время как реализация в другой библиотеке будет ожидать, что метод вернет non-nil корневую причину; отсутствие стандартных средств или общепринятого контракта грозит очень неприятными сюрпризами

Тем не менее, такое частичное решение используется во многих библиотеках, посвященных ошибкам, включая, в некоторой степени, и нашу. В Go 2 существуют планы популяризовать этот подход — если это произойдет, бороться с описанными выше проблемами должно стать легче.


Errorx


Ниже мы расскажем о том, какие решения предлагает errorx, но сперва попытаемся сформулировать соображения, которые лежат в основе библиотеки.


  • Диагностика важнее экономии ресурсов. Производительность создания и отображения ошибок важна. Тем не менее, они представляют негативный, а не позитивный путь, и в большинстве случаев служат сигналом о проблеме, поэтому наличие в ошибке диагностической информации еще важнее.
  • Stack trace по умолчанию. Для того, чтобы ошибка ушла со всей полнотой диагностики, не должно требоваться усилий. Напротив, именно для исключения части информации (ради краткости или из соображений производительности) могут требоваться дополнительные действия.
  • Семантика ошибок. Должен существовать простой и надежный способ проверки смысла ошибки: ее типа, разновидности, свойств.
  • Легкость дополнения. Добавление диагностической информации в пролетающую ошибку должно быть простым, и оно не должно разрушать проверку ее семантики.
  • Простота. Код, посвященный ошибкам, пишется часто и рутинно, поэтому синтаксис базовых манипуляций с ними должен быть прост и лаконичен. Это уменьшает число багов и облегчает чтение.
  • Less is more. Понятность и единообразие кода важнее необязательных фичей и возможностей для расширения (которыми, возможно, никто и не воспользуется).
  • Семантика ошибок — это часть API. Ошибки, требующие отдельной обработки в вызывающем коде, де-факто являются частью публичного API пакета. Не нужно пытаться это скрыть или сделать менее явным, но можно сделать обработку более удобной, а внешние зависимости — менее хрупкими.
  • Большинство ошибок непрозрачны. Чем больше видов ошибок для внешнего пользователя неотличимо друг от друга, тем лучше. Нагруженность API видами ошибок, требующих особой обработки, как и нагруженность самих ошибок данными, необходимыми для их обработки — дефект дизайна, которого следует избегать.

Наиболее непростым для нас был вопрос, касающийся расширяемости: должен ли errorx предоставлять примитивы для заведения произвольно различных по поведению пользовательских типов ошибок, или же реализацию, позволяющую получить все необходимое из коробки? Мы выбрали второй вариант. Во-первых, errorx решает вполне практическую проблему — и наш опыт его использования показывает, что для этой цели лучше иметь решение, а не запчасти для его создания. Во-вторых, очень весомо соображение, касающееся простоты: поскольку ошибкам уделяется меньше внимания, код должен быть устроен так, чтобы допустить баг в работе с ними было сложнее. Практика показала, что для этого важно, чтобы весь такой код выглядел и работал одинаково.


TL;DR по основным фичам библиотеки:


  • Stack trace места создания во всех ошибках по умолчанию
  • Type checks на ошибках, нескольких разновидностей
  • Возможность добавить информацию к существующей ошибке, ничего не сломав
  • Управление type visibility, если хочется скрыть исходную причину от caller-а
  • Механизм обобщения кода обработки ошибок (иерархия типов, traits)
  • Кастомизация ошибок динамическими properties
  • Стандартные типы ошибок
  • Синтаксические утилиты для повышения читаемости кода обработки ошибок

Введение


Если переработать пример, который мы разбирали выше, с использованием errorx, получится следующее:


var (
   DBErrors        = errorx.NewNamespace("db")
   ErrDuplicateKey = DBErrors.NewType("duplicate_key")
)

func (t *Table) Insert(entity interface{}) error {
   // ...
   return ErrDuplicateKey.New("violated constraint %s", details)
}

func IsDuplicateKeyError(err error) bool {
   return errorx.IsOfType(err, ErrDuplicateKey)
}

func InsertUser(u *User) error {
   err := usersTable.Insert(u)
   if err != nil {
      return errorx.Decorate(err, "failed to insert user %s", u.Name)
   }

   return nil
}

Вызывающий код, использующий IsDuplicateKeyError(), никак не поменяется.


Что изменилось в этом примере?


  • ErrDuplicateKey стал типом, а не экземпляром ошибки; проверка на него устойчива к копированию ошибки, нет хрупкой зависимости на точное равенство
  • Появился namespace для database ошибок; в нем, скорее всего, будут и другие ошибки, и такая группировка полезна для читаемости и в некоторых случаях может быть использована в коде
  • Insert возвращает новую ошибку на каждый вызов:
    • Ошибка содержит больше подробностей; это, конечно, возможно и без errorx, но невозможно, если один и тот же экземпляр ошибки возвращается каждый раз, что раньше требовалось для IsDuplicateKeyError()
    • Эти ошибки могут нести с собой разный stack trace, что полезно, т.к. далеко не для всех вызовов функции Insert такая ситуация допустима
  • InsertUser() дополняет текст ошибки, но прикладывает оригинальный error, который сохраняется во всей полноте для последующих операций
  • IsDuplicateKeyError() теперь работает: его нельзя испортить ни копированием ошибки, ни сколькими угодно слоями Decorate()

Не обязательно всегда следовать именно такой схеме:


  • Тип ошибки далеко не всегда уникален: одни и те же типы могут использоваться во многих местах
  • При желании сбор stack trace можно отключить, а также не создавать новую ошибку каждый раз, а возвращать одну и ту же, как в исходном примере; это так называемые sentinel ошибки, и мы не рекомендуем их использование, но это может быть полезно, если ошибка используется только как маркер в коде, и хочется сэкономить на создании объектов
  • Есть способ сделать так, чтобы проверка errorx.IsOfType(err, ErrDuplicateKey) перестала работать, если хочется скрыть семантику первопричины от чужих глаз
  • Для самой проверки типов есть и другие способы, кроме сравнения на точный тип

Godoc содержит подробную информацию обо всем этом. Ниже мы немного подробнее пройдемся по основным фичам, которых вполне достаточно для ежедневной работы.


Типы


Любая errorx ошибка принадлежит какому-то типу. Тип имеет значение, т.к. через него могут передаваться наследуемые свойства ошибки; именно через него или его traits будет при необходимости делаться проверка семантики. Кроме того, выразительное имя типа дополняет сообщение об ошибке и может в некоторых случаях его заменять.


AuthErrors = errorx.NewNamespace("auth")
ErrInvalidToken    = AuthErrors.NewType("invalid_token")

return ErrInvalidToken.NewWithNoMessage()

Сообщение об ошибке будет содержать auth.invalid_token. Декларация ошибки могла бы выглядеть иначе:


ErrInvalidToken    = AuthErrors.NewType("invalid_token").ApplyModifiers(errorx.TypeModifierOmitStackTrace)

В этом варианте, используя модификатор типа, отключен сбор stack trace. Ошибка обладает маркерной семантикой: ее тип отдается внешнему пользователю сервиса, а call stack в логах не приносил бы пользы, т.к. это не проблема, которую требуется чинить.


Здесь можно оговориться, что ошибки обладают двойственной природой в нескольких своих аспектах. Содержимое ошибки используется и для диагностики и, иногда, в качестве информации для внешнего пользователя: клиента API, пользователя библиотеки и т.п. В коде ошибки используются и как средство донесения семантики произошедшего, и как механизм передачи управления. При использовании типов ошибок это следует иметь в виду.


Создание ошибки


return MyType.New("fail")

Заводить собственный тип на каждую ошибку совершенно не обязательно. Любой проект может иметь свой пакет ошибок общего назначения, а некоторый набор поставляется в составе common namespace вместе с errorx. Там собраны ошибки, которые в большинстве случаев не предполагают обработки в коде и подходят для "исключительных" ситуаций, когда что-то пошло не так.


return errorx.IllegalArgument.New("negative value %d", value)

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


return errorx.Decorate(err, "failed to upload '%s' to '%s'", filename, location)

Текст, добавленный к ошибке, появится в логе, но не помешает проверить тип исходной ошибки.


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


return service.ErrBadRequest.Wrap(err, "failed to load user data")

Важное отличие, делающее Wrap предпочтительной альтернативой New, заключается в том, что исходная ошибка полностью найдет свое отражение в логах. И, в частности, принесет с собой полезный изначальный call stack.


Другой полезный прием, позволяющий сохранить всю возможную информацию о стеке вызовов, выглядит так:


return errorx.EnhanceStackTrace(err, "operation fail")

Если исходная ошибка пришла из другой горутины, результат такого вызова будет содержать stack trace обеих горутин, что необычайно повышает его полезность. Необходимость делать такой вызов явно обусловлена вопросами производительности: этот случай относительно редкий, а эргономика, которая обнаруживала бы его сама, замедляла бы обычный Wrap, где она совершенно не требуется.


Godoc содержит больше информации, а также описывает дополнительные функции, такие как DecorateMany.


Обработка ошибок


Лучше всего, если обработка ошибок сводится к следующему:


log.Error("Error: %+v", err)

Чем меньше с ошибкой требуется сделать, кроме как напечатать ее в лог на системном слое проекта, тем лучше. В реальности этого иногда мало, и приходится делать так:


if errorx.IsOfType(err, MyType) { /* handle */ }

Эта проверка пройдет успешно как на ошибке типа MyType, так и на ее дочерних типах, и она устойчива к errorx.Decorate(). Здесь, однако, есть прямая зависимость на тип ошибки, что вполне нормально в пределах пакета, но может быть неприятно, если используется за его пределами. В некоторых случаях тип такой ошибки является частью стабильного внешнего API, а иногда эту проверку хотелось бы заменить на проверку свойства, а не точного типа ошибки.


В классических ошибках Go это делалось бы через интерфейс, type cast на котором служил бы индикатором разновидности ошибки. Errorx типы не поддерживают подобное расширение, но вместо него можно использовать механизм Trait. Например:


func IsTemporary(err error) bool {
   return HasTrait(err, Temporary())
}

Эта встроенная в errorx функция проверяет, обладает ли ошибка стандартным свойством Temporary, т.е. носит ли она временный характер. Размечать типы ошибок при помощи traits — ответственность источника ошибки, и через них он может передавать полезный сигнал, не делая при этом частью внешнего API конкретные внутренние типы.


return errorx.IgnoreWithTrait(err, errorx.NotFound())

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


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


  • Код, получивший ошибку, всегда должен логировать ее во всей полноте; если часть информации лишняя, пусть об этом заботится код, производящий ошибку
  • Никогда нельзя использовать текст ошибки или результат функции Error() для того, чтобы обрабатывать ее в коде; для этого пригодны только type/trait checks, либо же type assertion в случае не-errorx ошибок
  • Пользовательский код не должен ломаться от того, что какая-то разновидность ошибки не обработана особым образом, даже если такая обработка возможна и дает ему дополнительные возможности
  • Ошибки, которые проверяются по свойствам, лучше так называемых sentinel ошибок, т.к. такие проверки менее хрупки

Вне errorx


Здесь мы описали то, что доступно пользователю библиотеки из коробки, но в Joom проникновение кода, связанного с ошибками, очень велико. Модуль логирования явно принимает ошибки в своей сигнатуре и сам занимается их распечаткой, чтобы исключить возможность неверного форматирования, а также достать из цепочки ошибок опционально доступную контекстную информацию. Модуль, отвечающий за panic-безопасную работу с горутинами, распаковывает ошибку, если она прилетела вместе с паникой, а также умеет презентовать панику с помощью синтаксиса ошибок, не потеряв исходного stack trace. Что-то из этого, возможно, мы также опубликуем.


Вопросы совместимости


Несмотря на то, что мы очень довольны тем, как errorx позволяет нам работать с ошибками, ситуация с библиотечным кодом, посвященным этой теме, далека от идеала. Мы в Joom решаем с помощью errorx вполне конкретные практические задачи, но с точки зрения экосистемы Go предпочтительнее было бы иметь весь этот набор средств в стандартной библиотеке. Ошибку, источник которой фактически или потенциально принадлежит другой парадигме, приходится рассматривать как чужеродную, т.е. потенциально не несущую информации в том виде, как это принято в проекте.


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


Формат '%+v' используется для распечатки ошибки вместе со stack trace, если он присутствует. Это является де-факто стандартом в экосистеме Go и даже включено в draft дизайн, касающийся Go 2.


Метод Cause() error делает errorx ошибки, в теории, совместимыми с библиотеками, опирающимися на интерфейс Causer, хоть их подход и нарушает контракт errorx про возможность непрозрачного оборачивания через Wrap().


Будущее


Недавний анонс рассказал об изменениях, которые планируются в Go 2, и тема ошибок обширно там представлена. Описание проблем с проверками типов может служить полезным дополнением к данной статье.


Оговоримся, что текущее состояние errorx отражает статус ошибок в Go 1. Мы не исключаем появление версии библиотеки, более дружелюбной к Go 2, когда изменения в синтаксисе произойдут. На первый взгляд отличия в этой версии, хоть и значительные с точки зрения каждодневной работы с ошибками в коде, не конфликтуют с экологической нишей и фичами errorx.


Check-handle идиома никоим образом не противоречит тому, как используется errorx сегодня, a Unwrap() error может быть поддержан как с сохранением Wrap() семантики errorx (т.е. так, чтобы отказываться разворачивать цепочку ошибок ниже той точки, где был сделан Wrap), так и без нее. На данный момент это решение, как и решение о сопутствующем синтаксисе, представляется преждевременным.


Если синтаксис из текущего design draft сохранится в Go 2, будет уместным добавить errorx.Is() и errorx.As() с аналогичной семантикой, если использования стандартных функций из errors пакета окажется недостаточно.


Заключение


Мы приглашаем всех, кому близки проблемы, описанные в статье, и кто нашел что-то из описанного здесь полезным для себя, к знакомству с библиотекой и к ее использованию. Мы открыты для предложений и изменений, в силу чего текущая версия API не может считаться окончательно стабильной: не исключено, что возникнут предложения, которые убедят нас пересмотреть некоторые особенности синтаксиса. Версию 1.0 можно ожидать в течение нескольких месяцев, однако текущая версия прошла достаточно длительную эксплуатацию и полировку внутри Joom. Маловероятно, что какие-то из имеющихся сейчас фичей мы захотим убрать.


Репозиторий: https://github.com/joomcode/errorx


Спасибо за внимание, и всегда обрабатывайте ошибки!


image

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


  1. TonyLorencio
    13.11.2018 12:20
    +2

    Как множатся стандарты

    image


  1. UncleAndy
    13.11.2018 14:03

    Очень хорошо! Думаю, буду использовать.

    В своем проекте решал с ошибками еще две проблемы, которые здесь не упомянуты:
    1. Локализация текста ошибок;
    2. При возникновении некоторых ошибок отправка нотификации о них на email.


    1. ptrivanov Автор
      13.11.2018 15:46

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

      Что же до локализации, то это часть вопроса представления ошибок, скажем, в API, который сам по себе сложный и довольно неприятный. С одной стороны, бизнес логика, откуда ошибки вылетают, не должна знать о таких вещах, как внешний API, в котором эти ошибки должны как-то трансформироваться и предоставляться наружу. С другой, работа с ошибками во внешнем API не должна быть хрупкой к изменениям на слое бизнес логики.
      Есть примеры решений, когда, скажем, ошибка несет с собой HTTP status, который будет подложен в ответ. Мы считаем такое суровым нарушением абстракции и не допускаем, но, к сожеланию, эту проблему все равно приходится решать на каком-то уровне.


      1. UncleAndy
        13.11.2018 15:53
        +1

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

        На счет локализации то-же самое. Думаю, при таком построении механизма ошибок имеет смысл ее сделать интегрированно. Т.к., на мой взгляд, локализация тут впишется довольно гармонично.

        Я вижу у вас лицензия MIT, так что я, вероятно, форкну ваш проект и добавлю все эти механизмы для собственного использования.


  1. x-foby
    13.11.2018 23:34
    -1

    Подходов много, мне наиболее удобным и практичным показался такой вариант: создаём модуль/пакет debug с методом mypanic, принимающим error в качестве аргумента;
    Данный метод через stdlib/reflect тянет актуальный stacktrace и выводит его с текстом error в stderr и/или пишет в лог.

    Далее заменяем все panic на наш debug.panic и всё.

    Подобный подход абсолютно идиоматичен для Go, так как ошибки остаются значениями, которые программист обязан обработать, при этом мы имеем минимум лишнего кода.


  1. thebestzorro
    14.11.2018 16:58

    Немного странный пример, есть же fmt.Errorf

    return errors.New(fmt.Sprintf("failed to insert user %s: %v", u.Name, err)
    



    1. ptrivanov Автор
      14.11.2018 19:02

      Согласен, но суть и проблема в нем те же.