Как не выстрелить себе в ногу, обрабатывая ошибки в голанге

Kate

Administrator
Команда форума
А пока мы все сидим и ждём выхода 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 функции. Все переменные доступны и видны, и вы можете до них достучаться и разобраться, перед тем как окончательно возвращать значение.

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


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

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

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

n6furp0p92ceyriqp3mgiilgoiu.png


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

5clcm1x3a8trdp46z6xt6rrlvwi.png


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

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

 
Сверху