Обработка ошибок в Go
Александр Тихоненко
Ведущий разработчик трайба «Автоматизация бизнес-процессов» МТС Диджитал
Механизм обработки ошибок в Go отличается от обработки исключений в большинстве языков программирования, ведь в Golang ошибки исключениями не являются. Если говорить в целом, то ошибка в Go — это возвращаемое значение с типомerror, которое демонстрирует сбой. А с точки зрения кода — интерфейс. В качестве ошибки может выступать любой объект, который этому интерфейсу удовлетворяет.
Выглядит это так:
type error interface {  
    Error() string
}
        В данной статье мы рассмотрим наиболее популярные способы работы с ошибками в Golang.
- Как обрабатывать ошибки в Go?
 - Создание ошибок
 - Оборачивание ошибок
 - Проверка типов с Is и As
 - Сторонние пакеты по работе с ошибками в Go
 - Defer, panic and recover
 - После изложенного
 
Как обрабатывать ошибки в Go?
Чтобы обработать ошибку в Golang, необходимо сперва вернуть из функции переменную с объявленным типом error и проверить её на nil:
if err != nil {
    return err
}
        Если метод возвращает ошибку, значит, потенциально в его работе может возникнуть проблема, которую нужно обработать. В качестве реализации обработчика может выступать логирование ошибки или более сложные сценарии. Например, переоткрытие установленного сетевого соединения, повторный вызов метода и тому подобные операции.
Если метод возвращает разные типы ошибок, то их нужно проверять отдельно. То есть сначала происходит определение ошибки, а потом для каждого типа пишется свой обработчик.
В Go ошибки возвращаются и проверяются явно. Разработчик сам определяет, какие ошибки метод может вернуть, и реализовать их обработку на вызывающей стороне.
Создание ошибок
Перед тем как обработать ошибку, нужно её создать. В стандартной библиотеке для этого есть две встроенные функции — обе позволяют указывать и отображать сообщение об ошибке:
Метод errors.New() создаёт ошибку, принимая в качестве параметра текстовое сообщение.
package main
import (
    "errors"
    "fmt"
)
func main() {
    err := errors.New("emit macho dwarf: elf header corrupted")
    fmt.Print(err)
}
        С помощью метода fmt.Errorf можно добавить дополнительную информацию об ошибке. Данные будут храниться внутри одной конкретной строки.
package main
import (
    "fmt"
)
func main() {
    const name, id = "bueller", 17
    err := fmt.Errorf("user %q (id %d) not found", name, id)
    fmt.Print(err)
}
        Такой способ подходит, если эта дополнительная информация нужна только для логирования на вызывающей стороне. Если же с ней предстоит работать, можно воспользоваться другими механизмами.
Оборачивание ошибок
Поскольку Error — это интерфейс, можно создать удовлетворяющую ему структуру с собственными полями. Тогда на вызывающей стороне этими самыми полями можно будет оперировать.
package main
import (
  "fmt"
)
type NotFoundError struct {
  UserId int
}
func (err NotFoundError) Error() string {
  return fmt.Sprintf("user with id %d not found", err.UserId)
}
func SearchUser(id int) error {
  // some logic for search
  // ...
  // if not found
  var err NotFoundError
  err.UserId = id
  return err
}
func main() {
  const id = 17
  err := SearchUser(id)
  if err != nil {
     fmt.Println(err)
     //type error checking
     notFoundErr, ok := err.(NotFoundError)
     if ok {
        fmt.Println(notFoundErr.UserId)
     }
  }
}
        Представим другую ситуацию. У нас есть метод, который вызывает внутри себя ещё один метод. В каждом из них проверяется своя ошибка. Иногда требуется в метод верхнего уровня передать сразу обе эти ошибки.
В Go есть соглашение о том, что ошибка, которая содержит внутри себя другую ошибку, может реализовать метод Unwrap, который будет возвращать исходную ошибку.
Также для оборачивания ошибок в fmt.Errorf есть плейсхолдер %w, который и позволяет произвести такую упаковку.:
package main
import (
    "errors"
    "fmt"
    "os"
)
func main() {
    err := openFile("non-existing")
    if err != nil {
        fmt.Println(err.Error())
        // get internal error
        fmt.Println(errors.Unwrap(err))
    }
}
func openFile(filename string) error {
    if _, err := os.Open(filename); err != nil {
        return fmt.Errorf("error opening %s: %w", filename, err)
    }
    return nil
}
        Проверка типов с Is и As
В Go 1.13 в пакете Errors появились две функции, которые позволяют определить тип ошибки — чтобы написать тот или иной обработчик:
Метод errors.Is, по сути, сравнивает текущую ошибку с заранее заданным значением ошибки:
package main
import (
    "errors"
    "fmt"
    "io/fs"
    "os"
)
func main() {
    if _, err := os.Open("non-existing"); err != nil {
        if errors.Is(err, fs.ErrNotExist) {
            fmt.Println("file does not exist")
        } else {
            fmt.Println(err)
        }
    }
}
        Если это будет та же самая ошибка, то функция вернёт true, если нет — false.
errors.As проверяет, относится ли ошибка к конкретному типу (раньше надо было явно приводить тип ошибки к тому типу, который хотим проверить):
package main
    import (
    "errors"
    "fmt"
    "io/fs"
    "os"
)
func main() {
    if _, err := os.Open("non-existing"); err != nil {
        var pathError *fs.PathError
        if errors.As(err, &pathError) {
            fmt.Println("Failed at path:", pathError.Path)
        } else {
            fmt.Println(err)
        }
    }
}
        Помимо прочего, эти методы удобны тем, что упрощают работу с упакованными ошибками, позволяя проверить каждую из них за один вызов.
Сторонние пакеты по работе с ошибками в Go
Помимо стандартного пакета Go, есть различные внешние библиотеки, которые расширяют функционал. При принятии решения об их использовании следует отталкиваться от задачи — использование может привести к падению производительности.
В качестве примера можно посмотреть на пакет pkg/errors. Одной из его способностей является логирование stack trace:
package main
import (
    "fmt"
    "github.com/pkg/errors"
)
func main() {
    err := errors.Errorf("whoops: %s", "foo")
    fmt.Printf("%+v", err)
}
    // Example output:
    // whoops: foo
    // github.com/pkg/errors_test.ExampleErrorf
    //         /home/dfc/src/github.com/pkg/errors/example_test.go:101
    // testing.runExample
    //         /home/dfc/go/src/testing/example.go:114
    // testing.RunExamples
    //         /home/dfc/go/src/testing/example.go:38
    // testing.(*M).Run
    //         /home/dfc/go/src/testing/testing.go:744
    // main.main
    //         /github.com/pkg/errors/_test/_testmain.go:102
    // runtime.main
    //         /home/dfc/go/src/runtime/proc.go:183
    // runtime.goexit
    //         /home/dfc/go/src/runtime/asm_amd64.s:2059
        Defer, panic and recover
Помимо ошибок, о которых позаботился разработчик, в Go существуют аварии (похожи на исключительные ситуации, например, в Java). По сути, это те ошибки, которые разработчик не предусмотрел.
При возникновении таких ошибок Go останавливает выполнение программы и начинает раскручивать стек вызовов до тех пор, пока не завершит работу приложения или не найдёт функцию обработки аварии.
Для работы с такими ошибками существует механизм «defer, panic, recover»
Defer
Defer помещает все вызовы функции в стек приложения. При этом отложенные функции выполняются в обратном порядке — независимо от того, вызвана паника или нет. Это бывает полезно при очистке ресурсов:
package main
import (
    "fmt"
    "os"
)
func main() {
    f := createFile("/tmp/defer.txt")
    defer closeFile(f)
    writeFile(f)
}
func createFile(p string) *os.File {
    fmt.Println("creating")
    f, err := os.Create(p)
    if err != nil {
        panic(err)
    }
    return f
}
func writeFile(f *os.File) {
    fmt.Println("writing")
    fmt.Fprintln(f, "data")
}
func closeFile(f *os.File) {
    fmt.Println("closing")
    err := f.Close()
    if err != nil {
        fmt.Fprintf(os.Stderr, "error: %v\n", err)
        os.Exit(1)
    }
}
        Panic
Panic сигнализирует о том, что код не может решить текущую проблему, и останавливает выполнение приложения. После вызова оператора выполняются все отложенные функции, и программа завершается с сообщением о причине паники и трассировки стека.
Например, Golang будет «паниковать», когда число делится на ноль:
panic: runtime error: integer divide by zero
goroutine 1 [running]:
main.divide(0x0)
        C:/Users/gabriel/articles/Golang Error handling/Code/panic/main.go:16 +0xe6
main.divide(0x1)
        C:/Users/gabriel/articles/Golang Error handling/Code/panic/main.go:17 +0xd6
main.divide(0x2)
        C:/Users/gabriel/articles/Golang Error handling/Code/panic/main.go:17 +0xd6
main.divide(0x3)
        C:/Users/gabriel/articles/Golang Error handling/Code/panic/main.go:17 +0xd6
main.divide(0x4)
        C:/Users/gabriel/articles/Golang Error handling/Code/panic/main.go:17 +0xd6
main.divide(0x5)
        C:/Users/gabriel/articles/Golang Error handling/Code/panic/main.go:17 +0xd6
main.main()
        C:/Users/gabriel/articles/Golang Error handling/Code/panic/main.go:11 +0x31
exit status 2
        Также панику можно вызвать явно с помощью метода panic(). Обычно его используют на этапе разработки и тестирования кода — а в конечном варианте убирают.
Recover
Эта функция нужна, чтобы вернуть контроль при панике. В таком случае работа приложения не прекращается, а восстанавливается и продолжается в нормальном режиме.
Recover всегда должна вызываться в функции defer. Чтобы сообщить об ошибке как возвращаемом значении, вы должны вызвать функцию recover в той же горутине, что и паника, получить структуру ошибки из функции восстановления и передать её в переменную:
package main
import (
    "errors"
    "fmt"
)
func A() {
    defer fmt.Println("Then we can't save the earth!")
    defer func() {
        if x := recover(); x != nil {
            fmt.Printf("Panic: %+v\n", x)
        }
    }()
    B()
}
func B() {
    defer fmt.Println("And if it keeps getting hotter...")
    C()
}
func C() {
    defer fmt.Println("Turn on the air conditioner...")
    Break()
}
func Break() {
    defer fmt.Println("If it's more than 30 degrees...")
    panic(errors.New("Global Warming!!!"))
}
func main() {
    A()
}
        После изложенного
Можно ли игнорировать ошибки? В теории — да. Но делать это нежелательно. Во-первых, наличие ошибки позволяет узнать, успешно ли выполнился метод. Во-вторых, если метод возвращает полезное значение и ошибку, то, не проверив её, нельзя утверждать, что полезное значение корректно.
Надеемся, приведённые методы обработки ошибок в Go будут вам полезны. Читайте также статью о 5 главных ошибках Junior-разработчика, чтобы не допускать их в начале своего карьерного пути.