golang

Ошибки в Go: проблема и элегантное решение с библиотекой try

  • среда, 16 октября 2024 г. в 00:00:13
https://habr.com/ru/articles/850464/

Все мы знаем: 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 сам обработает возможные ошибки.


Посмотрим, как можно переписать наш пример с использованием библиотеки 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


try.Catch


  • try.Catch(*error)
    Перехватывает панику и сохраняет её в переданную переменную. Аналогично конструкции catch в других языках. Используется вместе с defer. Например:

func Foo() (err error) {
    defer try.Catch(&err)
    // ...
}

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


try.Check


  • try.Check(err error) или try.OK(err error)
    Проверяет ошибку, и если она не равна nil, вызывает панику.
    try.Check(json.Unmarshal(data, &v))

try.Val


  • try.Val(value T, err error) T
    Возвращает результат функции или вызывает панику, если произошла ошибка.

resp := try.Val(http.Get(rawURL))

Если http.Get вернёт ошибку, try.Val бросит панику, избавляя вас от необходимости проверять результат вручную.
Название Val в данном случае это что-то среднее от слова value и validate: "Вернуть значение и провалидировать на ошибку".


try.Val2, try.Val3


  • 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


  • 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


  • try.Handle(errorHandler func(error))

Позволяет задать функцию для обработки паники, например для логирования ошибок. Аналогично конструкции try.Catch используется вместе с defer.


  • Пример:
    defer try.Handle(func(err error) {
        log.Printf("An error occurred: %v", err)
    })

try.Mute


  • try.Mute()

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


  • Пример:
    func foo() {
        defer try.Mute()
        // код, который может вызвать панику, но вы хотите её игнорировать
    }

try.Call


  • try.Call(fn func()) error

Выполняет любую функцию и возвращает ошибку, если внутри произошла паника.


  • Пример:
    err := try.Call(func() {
      // Ваш код
    })
    if err != nil {
      log.Printf("Error: %v", err)
    }

try.Go


  • try.Go(fn func())

Безопасно запускает горутину. Если в горутине возникает паника, программа не крашится.


  • Пример:
    try.Go(func() {
      // код в горутине
    })

try.Async


  • try.Async(fn ...func()) error

Выполняет несколько функций параллельно и ожидает их завершения. Возвращает ошибку (или совокупность ошибок), если произошла паника в одной или нескольких функциях.


  • Пример:
    err := try.Async(loadData1, loadData2, loadData3)
    if err != nil {
      log.Printf("Errors occurred: %v", err)
    }

Когда стоит использовать try?


Несмотря на все преимущества, библиотека 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 ваш код станет проще и элегантнее, а жизнь разработчика — чуточку легче!