Ошибки в Go: проблема и элегантное решение с библиотекой try
- среда, 16 октября 2024 г. в 00:00:13
Все мы знаем: Go — это классный язык программирования.
Простота, ясность, скорость компиляции — мечта разработчика.
Но вот одна вещь может довести до белого каления — это обработка ошибок.
В отличие от языков вроде Java или Python, где ошибки обрабатываются с помощью конструкции try-catch
, Go предпочитает явный подход: большинство функций возвращают ошибку в виде второго значения, и разработчик обязан проверять её после каждого вызова.
Это выглядит чисто и прозрачно, но на практике такие проверки приводят к громоздким и повторяющимся конструкциям. Вместо того чтобы писать код, который решает задачи, мы погружаемся в бесконечные проверки:
if err != nil {
return err
}
Сначала это кажется не таким уж страшным. Но когда проект становится больше, эти проверки начинают заполнять каждую строчку кода. Программисты вынуждены постоянно проверять ошибки, что замедляет разработку и делает код менее читаемым.
Более того, с использованием сторонних библиотек бывают случаи, когда ошибки не просто возвращаются, а выбрасываются в виде паники, что ещё больше усложняет обработку.
Короче говоря, обработка ошибок в Go может быстро превратиться в настоящий хаос.
Вместо красивой бизнес-логики мы получаем тонны условных конструкций, которые заставляют нас думать не о том, что делает программа, а о том, как она справляется с ошибками.
if err != nil
?Самая большая сложность в Go — это обилие рутинных проверок на ошибки. Каждая операция, будь то HTTP-запрос, чтение файла или парсинг данных, требует проверки на ошибку. Программисты вынуждены снова и снова добавлять одни и те же конструкции, что утяжеляет код.
Рассмотрим простой пример:
func LoadJSON(rawURL string) (map[string]any, error) {
resp, err := http.Get(rawURL)
if err != nil {
return nil, fmt.Errorf("failed to fetch data: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
var result map[string]any
if err = json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to unmarshal response body: %w", err)
}
return result, nil
}
Этот код типичен для Go: куча проверок ошибок, и лишь малая часть занимается реальной задачей — загрузкой JSON. Бесконечные условия отвлекают от сути. А если ещё и нужно обрабатывать паники, код становится совсем громоздким.
Но выход есть: предлагаю вашему вниманию библиотеку try, которая помогает упростить код и значительно уменьшить количество повторяющегося кода.
Чтобы упростить обработку ошибок и сделать код чище, мной была создана библиотека try.
Она предлагает более лаконичный и гибкий способ управления ошибками и паниками. Главная идея состоит в том, чтобы уменьшить количество рутинных проверок и предоставить механизм для автоматического перехвата ошибок.
Библиотека try
избавляет от необходимости писать одинаковые проверки на ошибки после каждого вызова функции. С её помощью код становится проще и легче для понимания. Вместо множества проверок, вы пишете логику программы, как будто всё идёт по плану, а try
сам обработает возможные ошибки.
Посмотрим, как можно переписать наш пример с использованием библиотеки try
:
func LoadJSON(rawURL string) (result map[string]any, err error) {
defer try.Catch(&err)
resp := try.Val(http.Get(rawURL))
defer resp.Body.Close()
try.Require(resp.StatusCode == http.StatusOK, "unexpected status code")
data := try.Val(io.ReadAll(resp.Body))
try.Check(json.Unmarshal(data, &result))
return
}
В этом коде больше не нужно вручную проверять ошибки. Вместо этого мы используем try.Val
, try.Check
и try.Require
. Если одна из этих функций обнаружит ошибку, она вызовет панику, которая будет перехвачена try.Catch
, и ошибка вернётся в качестве обычного результата. Это избавляет нас от постоянных проверок if err != nil
и делает код проще и компактнее.
try.Catch(*error)
catch
в других языках. Используется вместе с defer
. Например:func Foo() (err error) {
defer try.Catch(&err)
// ...
}
Если возникнет паника, она будет перехвачена, преобразована в ошибку и записана в переменную err
, которую можно вернуть.
try.Check(err error)
или try.OK(err error)
nil
, вызывает панику.try.Check(json.Unmarshal(data, &v))
try.Val(value T, err error) T
resp := try.Val(http.Get(rawURL))
Если http.Get
вернёт ошибку, try.Val
бросит панику, избавляя вас от необходимости проверять результат вручную.
Название Val в данном случае это что-то среднее от слова value и validate: "Вернуть значение и провалидировать на ошибку".
try.Val2(v1 T1, v2 T2, err error) (T1, T2)
try.Val3(v1 T1, v2 T2, v3 T3, err error) (T1, T2, T3)
try.Val
, но для функций, которые возвращают два или три значения и ошибку.Например:
line, isPrefix := try.Val2(buf.ReadLine())
try.Require(ok bool, err any)
try.Require(resp.StatusCode == 200, "Bad request")
Важно: Функции try.Check
, try.Val
, try.Val2
, try.Val3
и try.Require
автоматически добавляют контекст выполнения при вызове паники. В случае ошибки они не просто инициируют панику, но и добавляют информацию о файле и номере строки, где произошла ошибка, что значительно упрощает отладку и анализ кода. Однако стоит учитывать, что это делает панику более "тяжёлой" операцией, что может повлиять на производительность, особенно если ошибки возникают часто, и такие дополнительные вычисления могут оказаться избыточными.
try.Handle(errorHandler func(error))
Позволяет задать функцию для обработки паники, например для логирования ошибок. Аналогично конструкции try.Catch
используется вместе с defer
.
defer try.Handle(func(err error) {
log.Printf("An error occurred: %v", err)
})
try.Mute()
Используется в комбинации с defer
для того, чтобы игнорировать любые паники. Полезна, если нужно, чтобы код работал, даже если возникла ошибка, и не завершался аварийно.
func foo() {
defer try.Mute()
// код, который может вызвать панику, но вы хотите её игнорировать
}
try.Call(fn func()) error
Выполняет любую функцию и возвращает ошибку, если внутри произошла паника.
err := try.Call(func() {
// Ваш код
})
if err != nil {
log.Printf("Error: %v", err)
}
try.Go(fn func())
Безопасно запускает горутину. Если в горутине возникает паника, программа не крашится.
try.Go(func() {
// код в горутине
})
try.Async(fn ...func()) error
Выполняет несколько функций параллельно и ожидает их завершения. Возвращает ошибку (или совокупность ошибок), если произошла паника в одной или нескольких функциях.
err := try.Async(loadData1, loadData2, loadData3)
if err != nil {
log.Printf("Errors occurred: %v", err)
}
Несмотря на все преимущества, библиотека try
не всегда является идеальным решением. Важно понимать, что паника — это довольно тяжёлая операция, и её использование оправдано только в тех случаях, когда ошибка является редким и непредвиденным событием. Например, в случаях работы с внешними API, запросов к сети или парсинга данных, когда ошибки нечасты, try
отлично справляется.
Однако, если ошибки возникают регулярно (например, при валидации данных), или если ваш код работает в высоконагруженной среде, где критична производительность, стандартный подход с проверкой if err != nil
будет предпочтительнее. В таких случаях паники могут стать слишком дорогими для производительности и привести к замедлению работы приложения.
Таким образом, библиотека try
идеально подходит для случаев, когда обрабатываются редкие или исключительные ошибки. А там, где ошибки — это часть обычного рабочего процесса, лучше придерживаться стандартного подхода Go.
Как-то раз в одном из проектов я решил провести рефакторинг кода, который буквально утопал в проверках ошибок. Каждая операция — будь то чтение из базы данных, выполнение HTTP-запроса или десериализация данных — сопровождалась этими вечными строками if err != nil
. Логика программы терялась среди многочисленных проверок, и это вызывало не только раздражение, но и затрудняло сопровождение кода.
После внедрения библиотеки try
, мне удалось сократить количество кода на 23%. Часто получение и передача значений из функции в функцию, которые раньше записывались в несколько строк, теперь удавалось свести к одной строке. Сложные выражения легко заменялись на что-то вроде return try.Val(...)
, без создания дополнительных переменных и проверок ошибок.
Например, код вида:
a, err := fn1(...)
if err != nil {
// обработка ошибки
}
b, err := fn2(a)
if err != nil {
// обработка ошибки
}
return b
Легко заменялся на однострочное выражение:
return try.Val(fn2(try.Val(fn1(...))))
Это позволило значительно упростить код, сделав его более компактным и понятным, при этом сохранив контроль над ошибками.
Скачать и ознакомиться с пакетом можно на гитхабе: https://github.com/goldic/try.
go get github.com/goldic/try
Пакет очень простой и не использует зависимостей. Буду рад любым замечаниям и предложениям.
Библиотека try
(https://github.com/goldic/try) значительно упрощает работу с ошибками в Go, делая код чище и избавляя от рутинных проверок. Она даёт программисту возможность писать код более линейно и логично, при этом сохраняя возможность перехватывать паники и аккуратно обрабатывать ошибки.
Тем не менее, библиотека лучше всего подходит для случаев, когда ошибки — это редкие или неожиданные события. Там, где ошибки являются частью обычного потока, традиционный подход может оказаться более эффективным.
Используя try
, вы сможете сфокусироваться на логике приложения, не отвлекаясь на мелочи вроде проверок на ошибки. С try
ваш код станет проще и элегантнее, а жизнь разработчика — чуточку легче!