А пока мы все сидим и ждём выхода Go 2 с его новой схемой обработки ошибок, программы писать надо прямо сейчас. Так что от обработки ошибок никуда не деться.

У меня в руках реальный проприетарный проект, который работает на одной из моих серверных ферм. Всё запущено и крутится на golang от начала и до конца. В этой статье я собрал и описал большое количество вариантов обработки ошибок, с которыми столкнулся в проде.

Итак, поехали.


Философия Go


Golang принципиально отличается от других языков. В основном тем, как бережно go относится к старому коду. Жесточайшие конвенции к стилю программы, которые внедряются встроенными утилитами, отсутствие многих (возможно) полезных фич и полное отрицание большого количества синтаксического сахара, которым обросли другие языки.

Кому-то такой подход может показаться немного странным и некрасивым, особенно если вы привыкли к работе с Node.js. Но по факту, когда вы вернётесь к проекту, написанному 3 года назад, и поймёте, что вам не придётся ломать голову, пытаясь вспомнить, как работает Redux (он же был популярен в 2018, так ведь?), вы скажете спасибо и самому себе, и создателям языка.

Подобные решения среди разработчиков языка позволили создать быстрый компилятор. В мире, где большая часть кода компилируется с помощью LLVM и GNU, а, помимо этого, компиляторами владеют только такие монстры, как Microsoft и Apple, написание собственного компилятора могло бы показаться странной затеей. Но она удалась. У нас в руках есть очень быстрый компилятор. Если хотите узнать об этом побольше, то вот здесь есть много ответов.

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

Стиль работы с golang существенно отличается от других языков, и в отличие от других языков, этот стиль очень хорошо, но немногословно описан в документации на сайте go.dev.

В этой статье я буду обращаться только к докам в go.dev, поскольку за пределами этой документации существует чрезмерно много мнений. Мой вам совет, когда сомневаетесь — всегда верьте докам на go.dev.

Panic или Don’t Panic


Итак, после того как вы прочитаете какой-нибудь базовый мануал по golang, вы обнаружите, что практически все примеры показывают, что при возникновении ошибки вы просто делаете log.Fatal(). При этом ваша программа завершается, и мы остаёмся сидеть перед разбитым корытом.

Естественно, в проде такое не прокатит. Надо восстанавливаться как можно чаще. Но ещё лучше — не падать с panic вообще.

Сначала давайте посмотрим на то, что говорится о Panic в Effective Go:
Это — всего лишь пример, в реальности функции должны избегать использования panic. Если проблема может быть улажена, скрыта или её можно обойти, то всегда лучше продолжать исполнение, завершив программу целиком. Контрпримером может служить инициализация: если библиотека не может себя инициализировать, то, образно говоря, время для паники.
Итак, давайте посмотрим в наш метод main.

func main() {
    // {...}
c, err := HandleConfig()
    if err != nil {
   	 log.Panicf("Problem with a config file, %w", err)
    }

    // {...}
    if c.StoragePartitionConfig.Enabled {
   	 spManager = sp.NewManager(&c.StorageConfig, ...)
   	 err := storagePartitionManager.Startup()
   	 if err != nil {
   		 log.Panicf("Storage system is not loaded, %v", err)
   	 }
    }

    if c.VirtualPartitionConfig.Enabled {
   	 vpManager = vp.NewManager(&c.PartitionConfig, ...)
   	 err := vpManager.Startup()
   	 if err != nil {
   		 log.Panicf("NVME system is not loaded, %v", err)
   	 }
    }

    rtr := SetupRouting(&c)
    log.Fatal(http.ListenAndServe(c.APIConfig.Address, rtr))
}

Как я уже сказал, код немного урезан. Но главное, видно вот что — единственные ошибки обрабатываются через panic и log.Fatal только в методе main.

Мы попытались подняться, но не смогли. В таком случае можно ложиться. Почему? Потому что, если сисадмин запускает серверную утилиту, он либо сразу увидит, что она легла, либо будет ожидать, что она будет работать. Другого не дано.

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

Проброс ошибок


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

Поэтому

func (s *StorageHandler) DeleteStorage(...) error {
    err := s.SPM.DeleteDrive(...)
    if err != nil {
   	 return err
    	}
}


func (p *Manager) DeleteDrive(...) error {
    err = p.l.Remove(...)
    if err != nil {
   	 return err
    }
}

func (l *Manager) Remove(...) error {
    return execComm(...)
}

func execComm(...) error {
    err := cmd.CombinedOutput()
    if err != nil {
   	 return fmt.Errorf("execComm error while executing: %s; %w", command, err)
    }
}

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

Для этого важно передавать err в fmt.Errorf через %w. Эта фича была добавлена в go версии 1.13. При этом если вы запихиваете значение какой-то конкретной err в %w, то получатель этой ошибки сможет получить оригинальное сообщение об ошибке, используя метод Unwrap.

Опять же, этот параграф взят из документации:
Во всём остальном %w это %v. Так что как минимум — вы не “съедите” сообщение об ошибке и сможете увидеть полный стек.

Обрабатывать ошибки в го это слишком занудно


Когда я начинаю обучать людей программированию на голанге, то при наличии более-менее реального проекта это становится одной из главных “проблем”. Ведь при написании кода для тренировки мы практически всегда избегаем большого количества отлова ошибок. А в реальности почти каждая строка становится потенциальным местом возникновения ошибки.

Ну так вот, справиться с этим очень просто. Ибо, возможно, вы не поверите, но это самый удобный способ обработки ошибок.

Да, в C#, например, можно запихнуть всё в один большой try … catch … блок и быть счастливым по этому поводу. При этом вся логика обработки ошибок будет жить в одном или нескольких catch блоках.

По факту же, когда вы пытаетесь прочитать такой код, вы понимаете, что это не очень удобно для восприятия. Дела в го идут намного лучше. Вот строка, в ней может произойти ошибка. Логика обработки прямо под этой же строкой. Вам не надо искать “где её ловят”. Её ловят тут.

Но что если мне всё равно хочется по-другому


Ок, вот пара примеров того, что вы можете сделать, чтобы облегчить себе жизнь.

Первое — добавьте код сниппет, который будет автоматически добавлять if err != nil после строчки с err. Удивительно, но это работает.

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

Например, вы пишите API. И в обработчике запросов вместо того, чтобы каждый раз писать:

if err!=nil {
    w.Header().Set("Content-Type", "text/plain")
    w.WriteHeader(500)
_, ret = fmt.Fprint(w, err)
}

вы можете пойти другим путём. Например, вы можете написать следующую функцию:

func WriteError(w http.ResponseWriter, err error) error {
    if err == nil {
   	 return nil
    }
    log.Printf("Error: %s", err.Error())
w.Header().Set("Content-Type", "text/plain")
    w.WriteHeader(500)
    _, ret = fmt.Fprint(w, err)
    return ret
}


После этого в самом обработчике запросов вы можете написать следующее:

func (s *VirtualPartitionHandler) CreateVirtualPartition(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {

WriteError(w, func() error {
    var reqParam sqlstore.VP
    err := json.NewDecoder(r.Body).Decode(&reqParam)
    if err != nil {
   	 return err
    }
    err = s.SQL.RunTxx(r.Context(), func(ctx context.Context) error {
   	 err := s…..Create(&reqParam)
   	 if err != nil {
   		 return err
   	 }
   	 return nil
    })
    if err != nil {
   	 return err
    }
    w.Header().Set("content-type", "application/json")
    return json.NewEncoder(w).Encode(reqParam)
}())
}

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

Конечно, вы можете написать простую

func checkerr(e error) {
    …
}

Но при этом вам придётся переписывать уже имеющийся код и заменять все проверки err != nil на checkerr. В случае с WriteError вы можете просто добавить одну линию кода в начало и конец функции, и вам не нужно будет переписывать стандартные проверки на ошибки. При этом если в будущем вы решитесь сделать более серьёзную систему обработки ошибок, то вы сможете с лёгкостью использовать уже существующие решения.

Откат действий


А что если нам нужно сделать что-то в виде транзакции?

Например, мы пытаемся выполнить пять последовательных действий в одной функции и при падении действия номер четыре должны будем отменить действия 1-3.

Для этого у нас есть очень простой механизм.

func (m *Manager) Create(v *sqlstore.VP) (reterr error) {

    var err error

    m.mu.Lock()
    defer func() {
   	 m.mu.Unlock()
   	 if reterr != nil {
   		 if action1Result != nil {
   			 undoAction1();
}
   		 if action2Result != nil {
   			 undoAction2();
}
if action3Result != nil {
   			 undoAction3();
}
if action4Result != nil {
   			 undoAction4();
}
if action5Result != nil {
   			 undoAction5();
}
   	 }
    }()

    action1Result := doAction1();
    …
}

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

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

Не заморачивайтесь


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

ПО в современном мире развивается бешеными темпами в абсолютно бешеном направлении.

Давайте просто для внутреннего спокойствия вставим сюда картинку про пятьсот дрелей. Куда же без неё?



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



Я не знаю как, но golang умудрился избавиться от этой идеи абсолютно бесконечного порочного цикла “улучшения чего-то, что и так работает”. Посему перестаньте бояться работы с ошибками в go и пользуйтесь описанными выше инструментами. Не надо сливать кучу библиотек, чтобы начать проект.

Более того, я показал именно приёмы перехвата и передачи error. В документации выше описана система обработки этих ошибок.

И ничего не поменяется?


Ну нет, на самом деле в мире го есть предложения о том, что работу над ошибками можно упростить. (Вот здесь в упрощённом виде.)

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

Но по факту я бы советовал вам не особо беспокоиться. Сколько лет народ ждёт generics, но, к счастью, этот ужас ещё не добрался до golang. И мы будем продолжать ждать.

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


НЛО прилетело и оставило здесь промокоды для читателей нашего блога:

15% на все тарифы VDS (кроме тарифа Прогрев) — HABRFIRSTVDS.

20% на выделенные серверы AMD Ryzen и Intel Core HABRFIRSTDEDIC.

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


  1. DmitryKoterov
    16.02.2022 11:29
    +4

    Сколько лет народ ждёт generics, но, к счастью, этот ужас ещё не добрался до golang. И мы будем продолжать ждать.

    В этот момент вспоминается фрагмент из замечательного фильма "6-е чувство":

    — Я хочу открыть Вам свою тайну.
    — Хорошо.
    — Я вижу умерших людей...
    — Во сне? Что, наяву? Они лежат в могилах? В гробах?
    — Они везде ходят, как обычные люди. Они не видят друг друга. Они видят только то, что хотят. Они не знают, что умерли.
    — Ты часто их видишь?
    — Всё время. Они повсюду.


    1. AlonsoDelToro
      18.02.2022 09:48

      Забыли ещё цитаты про прекрасное решение - if err != nil { ... }. )

      Да, в C#, например, можно запихнуть всё в один большой try … catch … блок и быть счастливым по этому поводу. При этом вся логика обработки ошибок будет жить в одном или нескольких catch блоках.

      Пишу переодически на Go в прод, и понимаю что как хоршо когда ты все же можешь быть счастлив.))


  1. xakep666
    16.02.2022 12:04
    +5

    Сколько лет народ ждёт generics, но, к счастью, этот ужас ещё не добрался до golang. И мы будем продолжать ждать.

    Осталось ждать недолго, в марте новый релиз с ними обещают.


    1. batyrmastyr
      16.02.2022 21:19

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


  1. sedyh
    16.02.2022 12:55
    +1

    Основная проблема пакета errors в том, что вы не можете скрыть несколько частных ошибок за одной более абстрактной и проверить любую ошибку из цепочки (как с исключениями). За %w можно скрыть только одну ошибку. Т.е. правильным способом обработки ошибки из функции сейчас будет отлавливать огромную кучу мелких ошибок на самом низком уровне из каждой вложенной функции. Либо вы можете оставить только самую верхнюю ошибку с потерей предыдущего контекста.


    1. THQSql
      16.02.2022 14:18
      +2

      Основная проблема пакета errors в том, что вы не можете скрыть несколько
      частных ошибок за одной более абстрактной и проверить любую ошибку из
      цепочки (как с исключениями).

      https://pkg.go.dev/go.uber.org/multierr#section-readme

      Остальные ищите тут: https://awesome-go.com/error-handling/

      Покрытые тестами и используемые в проде многими крупными компаниями.


      1. kostyaBro
        17.02.2022 22:11
        +2

        Хотел быть тем кто напишет про multierr)

        Вообще если все ошибки врапать, то вы получаете прекрасный, человекочитаемый, аналог стектрейса. Multierr отлично скрывает маленькие ошибки за одной большой и также, помогает собрать стектрейс.

        Когда логируется ошибка мы видим что-то на подобие "ошибка при обработке такого то гет запроса: сервис вернул ошибку: база данных вернула ошибку: неожиданный символ в запросе на таком то месте, запрос такойто".

        Имхо, удобно и прекрасно


  1. andreyiq
    16.02.2022 16:35
    +5

    Да, в C#, например, можно запихнуть всё в один большой try … catch … блок и быть счастливым по этому поводу. При этом вся логика обработки ошибок будет жить в одном или нескольких catch блоках.

    По факту же, когда вы пытаетесь прочитать такой код, вы понимаете, что это не очень удобно для восприятия

    Чем Ваш вариант с WriteError и проверкой if err != nil {return err} после каждой строки лучше try...catch?


    1. kot_review Автор
      17.02.2022 16:33
      +1

      Тут я сказал, что этот вариант сделан для того, что если уж очень нужно запихнуть всё в один большой блок, то можно сделать так.

      Например, у меня в проде это дело стоит на всех обработчиках handler для возврата JSON, где этот JSON возвращается клиенту. Но «клиент» это air-gapped API.

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


  1. makarychev_13
    16.02.2022 17:20
    +1

    Сколько лет народ ждёт generics, но, к счастью, этот ужас ещё не добрался до golang. И мы будем продолжать ждать.

    Объясните мне кто-нибудь, что движет этими людьми, которые настолько против дженериков.


    1. cololoster
      16.02.2022 22:22
      +1

      сегодня дженерики, завтра exception-ы, классы и вот это вот все.
      не хочется в эту сторону, go прекрасен. пожалуйста, остановитесь, не надо делать из него еще один c++ :)


      1. makarychev_13
        17.02.2022 00:37
        +1

        Никто не будет делать ни классы (потому что они бессмысленны в го), ни эксепшены (потому что есть ошибки и паники).

        Ну и вы явно лукавите. В статье автор явно говорит, что ему дженерики сами по себе не нравятся, а не потому что из-за них ещё могут много чего добавить в язык.


    1. Nurked
      17.02.2022 16:04
      +1

      Я вот учу народ в Отусе. Преподаю C#, потому что я на нём писал последние 17 лет. Но сам пишу на Golang. Selection Statements превратили такую простую и незамороченную вещь как Switch Case в полный ужас. Хотя в тех же самых гайдлайнах по рефакторингу ООП написано, что если у вас используется большой и сложный Switch Case, то вам нужно переработать логику, скорее всего, вы где-то упустили классы. Логика не должна так работать.

      В сам C# стали намешивать популярные фитчи из популярных языков (ну конечно, мы же берём пример с молодого, быстроразвивающегося и эффективного JavaScript). После этого в некоторых местах код начинает быть похожим на Perl.

      Вот, скажите, нахрена нам нужно определять переменные при создании кейсов? Откуда берётся эта N? Кто её создаёт? Зачем?

      switch (mark)
      {
          case int n when n >= 80:
              Console.WriteLine("Grade is A");
              break;
      
          case int n when n >= 60:
              Console.WriteLine("Grade is B");
              break;
      
          case int n when n >= 40:
              Console.WriteLine("Grade is C");
              break;
      
          default:
              Console.WriteLine("Grade is D");
              break;
      }

      Всё это синтаксический сахар, без которого мы бы отлично прожили в C#. Потому что C# - это ООП. А в ООП про switch-case вообще говорят, что это "с запашком".

      Что дальше? Мы все внезапно осознаем, что нам нужен goto? Потому что это единственный вариант выхода из трижды вложенных циклов?

      Я уже не говорю по эту распаковку объектов в JS. Замечательно. У нас везде есть оператор ..., чтобы мы быстрее могли передавать ошибки из одного нетипизированного объекта в другой.

      const { preserveWhiteSpace, noBreaks, ...restOfKeys } = config;

      Выглядит замечательно. Потом когда начинаешь пользоваться, пытаешься выяснить, нахрена? Что мы делаем в этой строке? Мы создаём константу как? Мы что щас делаем?

      А уже когда добираешься до современных React, где подлежащая система хранения стейта — это вопрос постоянного холивара на StackOverflow последние 5 лет. У тебя нет смысла чего-то учить. Всё-равно ведь потеряешь эти данные, потому что они падут жертвой очередного дуновения комментов. Я как-то в 2017 году сел и выучил Redux. Только чтобы узнать, что это уже бесполезная фитча.


  1. dikey_0ficial
    16.02.2022 23:16
    +1

    if reterr != nil { /* ... */ }

    код внутри можно заменить на ещё более элегантный вариант, ведь, если произошла одна ошибка, то надо откатить всё)

    switch {
    case act5err != nil:
      undoAct5()
      falltrough
    case act4err != nil: /* ... */
    }


  1. edo1h
    17.02.2022 03:33
    +1

    Да, в C#, например, можно запихнуть всё в один большой try … catch … блок и быть счастливым по этому поводу. При этом вся логика обработки ошибок будет жить в одном или нескольких catch блоках.

    По факту же, когда вы пытаетесь прочитать такой код, вы понимаете, что это не очень удобно для восприятия. Дела в го идут намного лучше.

    лучше ли? если не пренебрегать обработкой ошибок, то половина строчек это


    if err != nil {


    1. THQSql
      18.02.2022 06:07

      Половина строчек это:

      if err != nil {
        return ErrFooType("что-то пошло не так при вызове такой-то штуки", ERROR_FOO_SOME_CODE, err)
      }

      Или такой

      if err != nil {
        logger.Warn("что-то произошло незначительное, но нужно знать", zap.Error(err))
      }

      Но чаще пишут так, что не очень хорошо:

      if err != nil { return err }

      Конструкцию if можно писать таким макаром:

      if err := foo(); err != nil {
      	return err
      }

      При этом err будет доступен только внутри фигурных скобок.


      1. edo1h
        18.02.2022 09:31

        один из плюсов голанга — читаемость текстов. это всё слишком многословно и потому делает программу менее читаемой