Не без паники в Go

в 8:58, , рубрики: Go, golang, Программирование

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

error

error это интерфейс. И как большинство интерфейсов в Go, определение error краткое и простое:

type error interface {
    Error() string
}

Получается любой тип у которого есть метод Error может быть использован как ошибка. Как учил Роб Пайк Ошибки это значения, а значениями можно оперировать и программировать различную логику.

В стандартной библиотеки Go имеются две функции, которые удобно использовать для создания ошибок. Функция errors.New хорошо подходит для создания простых ошибок. Функция fmt.Errorf позволяет использовать стандартное форматирования.

err := errors.New("emit macho dwarf: elf header corrupted")

const name, id = "bimmler", 17
err := fmt.Errorf("user %q (id %d) not found", name, id)

Обычно для работы с ошибками достаточно типа error. Но иногда может потребоваться передавать с ошибкой дополнительную информацию, в таких случаях можно добавить свой тип ошибок.
Неплохой пример это тип PathError из пакета os

// PathError records an error and the operation and file path that caused it.
type PathError struct {
    Op   string
    Path string
    Err  error
}

func (e *PathError) Error() string { return e.Op + " " + e.Path + ": " + e.Err.Error() }

Значение такой ошибки будет содержать операцию, путь и ошибку.

Инициализируются они таким образом:

...
return nil, &PathError{"open", name, syscall.ENOENT}
...
return nil, &PathError{"close", file.name, e}

Обработка может иметь стандартный вид:

_, err := os.Open("---")
if err != nil{
    fmt.Println(err)
}
// open ---: The system cannot find the file specified.

А вот если есть необходимость получить дополнительную информацию, то можно распаковать error в *os.PathError:

_, err := os.Open("---")
if pe, ok := err.(*os.PathError);ok{
    fmt.Printf("Err: %sn", pe.Err)
    fmt.Printf("Op: %sn", pe.Op)
    fmt.Printf("Path: %sn", pe.Path)
}
// Err: The system cannot find the file specified.
// Op: open
// Path: ---

Этот же подход можно применять если функция может вернуть несколько различных типов ошибок.
play

Объявление нескольких типов ошибок, каждая имеет свои данные:

code

type ErrTimeout struct {
    Time time.Duration
    Err  error
}
func (e *ErrTimeout) Error() string { return e.Time.String() + ": " + e.Err.Error() }

type ErrPermission struct {
    Status string
    Err  error
}
func (e *ErrPermission) Error() string { return e.Status + ": " + e.Err.Error() }

Функция которая может вернуть эти ошибки:

code

func proc(n int) error {
    if n <= 10 {
        return &ErrTimeout{Time: time.Second * 10, Err: errors.New("timeout error")}
    } else if n >= 10 {
        return &ErrPermission{Status: "access_denied", Err: errors.New("permission denied")}
    }
    return nil
}

Обработка ошибок через приведения типов:

code

func main(){
    err := proc(11)
    if err != nil {
        switch e := err.(type) {
        case *ErrTimeout:
            fmt.Printf("Timeout: %sn", e.Time.String())
            fmt.Printf("Error: %sn", e.Err)
        case *ErrPermission:
            fmt.Printf("Status: %sn", e.Status)
            fmt.Printf("Error: %sn", e.Err)
        default:
            fmt.Println("hm?")
            os.Exit(1)
        }
    }
}

В случае когда ошибкам не нужны специальные свойства, в Go хорошей практикой считается создавать переменные для хранения ошибок на уровне пакетов. Примером может служить такие ошибки как io.EOF, io.ErrNoProgress и проч.

В примере ниже, прерываем чтение и продолжаем работу приложения, когда ошибка равна io.EOF или закрываем приложения при любых других ошибках.

func main(){
    reader := strings.NewReader("hello world")
    p := make([]byte, 2)

    for {
        _, err := reader.Read(p)
        if err != nil{
            if err == io.EOF {
                break
            }
            log.Fatal(err)
        }
    }
}

Это эффективно, поскольку ошибки создаются только один раз и используются многократно.

stack trace

Список функций, вызванных в момент захвата стека. Трассировка стека помогает получить более полное представление о происходящем в системе. Сохранение трассировки в логах может серьезно помочь при отладки.

Наличие этой информации в ошибке у Go часто не хватает, но к счастью получить дампа стека в Go не сложно.

Для вывода трассировки в стандартный выводов можно воспользоваться debug.PrintStack():

func main(){
    foo()
}

func foo(){
    bar()
}
func bar(){
    debug.PrintStack()
}

Как результат в Stderr будет записано такая информация:

stack

goroutine 1 [running]:
runtime/debug.Stack(0x1, 0x7, 0xc04207ff78)
        .../Go/src/runtime/debug/stack.go:24 +0xae
runtime/debug.PrintStack()
        .../Go/src/runtime/debug/stack.go:16 +0x29
main.bar()
        .../main.go:13 +0x27
main.foo()
        .../main.go:10 +0x27
main.main()
        .../main.go:6 +0x27

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

b := debug.Stack()
fmt.Printf("Trace:n %sn", b)

Есть еще один момент, если мы сделаем вот так:

go bar()

то на выходе получим такую информацию:

main.bar()
        .../main.go:19 +0x2d
created by main.foo
        .../main.go:14 +0x3c

У каждой горутины отдельный стек, соответственно, мы получаем только его дамп. Кстати, о своих стеках у горутин, с этим еще связана работа recover, но об этом чуть позже.
И так, что бы увидеть информацию по всем горутинам, можно воспользоваться runtime.Stack() и передать вторым аргументом true.

func bar(){
    buf := make([]byte, 1024)
    for {
        n := runtime.Stack(buf, true)
        if n < len(buf) {
            break
        }
        buf = make([]byte, 2*len(buf))
    }
    fmt.Printf("Trace:n %sn", buf)
}

stack

Trace:
 goroutine 5 [running]:
main.bar()
        .../main.go:21 +0xbc
created by main.foo
        .../main.go:14 +0x3c

goroutine 1 [sleep]:
time.Sleep(0x77359400)
        .../Go/src/runtime/time.go:102 +0x17b
main.foo()
        .../main.go:16 +0x49
main.main()
        .../main.go:10 +0x27

Добавим в ошибку эту информацию и тем самым сильно повысим ее информативность.
Например так:

type ErrStack struct {
    StackTrace []byte
    Err  error
}
func (e *ErrStack) Error() string {
    var buf bytes.Buffer
    fmt.Fprintf(&buf, "Error:n %sn", e.Err)
    fmt.Fprintf(&buf, "Trace:n %sn", e.StackTrace)
    return buf.String()
}

Можно добавить функцию для создания этой ошибки:

func NewErrStack(msg string) *ErrStack {
    buf := make([]byte, 1024)
    for {
        n := runtime.Stack(buf, true)
        if n < len(buf) {
            break
        }
        buf = make([]byte, 2*len(buf))
    }
    return &ErrStack{StackTrace: buf, Err: errors.New(msg)}
}

Дальше с этим уже можно работать:

func main() {
    err := foo()
    if err != nil {
        fmt.Println(err)
    }
}

func foo() error{
    return bar()
}
func bar() error{
    err := NewErrStack("error")
    return err
}

stack

Error:
 error
Trace:
 goroutine 1 [running]:
main.NewErrStack(0x4c021f, 0x5, 0x4a92e0)
        .../main.go:41 +0xae
main.bar(0xc04207ff38, 0xc04207ff78)
        .../main.go:24 +0x3d
main.foo(0x0, 0x48ebff)
        .../main.go:21 +0x29
main.main()
        .../main.go:11 +0x29

Соответственно ошибку и трейс можно рзаделить:

func main(){
    err := foo()

    if st, ok := err.(*ErrStack);ok{
        fmt.Printf("Error:n %sn", st.Err)
        fmt.Printf("Trace:n %sn", st.StackTrace)
    }
}

И конечно уже есть готовые решение. Одно из них, это пакет https://github.com/pkg/errors. Он позволяет создавать новую ошибку, которая уже будет содержать стек трейс, а можно добавлять трейс и/или дополнительное сообщения к уже существующей ошибке. Плюс удобное форматирование вывода.

import (
    "fmt"
    "github.com/pkg/errors"
)

func main(){
    err := foo()
    if err != nil {
        fmt.Printf("%+v", err)
    }
}

func foo() error{
    err := bar()
    return errors.Wrap(err, "error2")
}
func bar() error{
    return errors.New("error")
}

stack

error
main.bar
        .../main.go:20
main.foo
        .../main.go:16
main.main
        .../main.go:9
runtime.main
        .../Go/src/runtime/proc.go:198
runtime.goexit
        .../Go/src/runtime/asm_amd64.s:2361
error2
main.foo
        .../main.go:17
main.main
        .../main.go:9
runtime.main
        .../Go/src/runtime/proc.go:198
runtime.goexit
        .../Go/src/runtime/asm_amd64.s:2361

%v выведет только сообщения

error2: error

panic/recover

Паника(aka авария, aka panic), как правило, сигнализирует о наличии неполадок, из-за которых система (или конкретная подсистема) не может продолжать функционировать. В случае вызова panic среда выполнения Go просматривает стек, пытаясь найти для нее обработчик.

Необработанные паники прекращают работу приложения. Это принципиально отличает их от ошибок, которые позволяют не обрабатывать себя.

В вызов функции panic можно передать любой аргумент.

panic(v interface{})

Удобно в panic передать ошибку, того типа который упростит восстановления и поможет отладки.

panic(errors.New("error"))

Восстановление после аварии в Go основывается на отложенном вызове функций, он же defer. Такая функция гарантировано будет выполнена в момент возврата из родительской функции. Не зависимо от причины — оператор return, конец функции или паника.

А вот уже функция recover дает возможность получить информацию об аварии и остановить раскручивание стека вызовов.
Типичный пример вызова panic и обработчик:

func main(){
    defer func() {
        if err := recover(); err != nil{
            fmt.Printf("panic: %s", err)
        }
    }()
    foo()
}

func foo(){
    panic(errors.New("error"))
}

recover возвращает interface{} (тот самый который передаем в panic) или nil, если не было вызова panic.

Рассмотрим еще один пример обработки аварийных ситуаций. У нас есть некоторая функция в которую мы передаем например ресурс и которая в теории может вызвать панику.

func bar(f *os.File) {
    panic(errors.New("error"))
}

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

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

Такую задачу можно решить с помощью defer, recover и замыкания:

func foo()(err error) {
    file, _ := os.Open("file")
    defer func() {
        if r := recover(); r != nil {
            err = r.(error) // обрабатываем аварийную ситуацию, распаковываем если знаем, что в панике ошибка
            // err := errors.New("trapped panic: %s (%T)", r, r) // или создаем свою ошибку
        }
        file.Close() // закрываем файл
    }()

    bar(file)

    return err
}

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

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

В подобных случаях добавляют функцию обертку в которой вызывается целевая функция и в случае ошибки вызывается panic.

В Go обычно такие функции с префиксом Must:

// MustCompile is like Compile but panics if the expression cannot be parsed.
// It simplifies safe initialization of global variables holding compiled regular
// expressions.
func MustCompile(str string) *Regexp {
    regexp, error := Compile(str)
    if error != nil {
        panic(`regexp: Compile(` + quote(str) + `): ` + error.Error())
    }
    return regexp
}

// Must is a helper that wraps a call to a function returning (*Template, error)
// and panics if the error is non-nil. It is intended for use in variable initializations
// such as
//  var t = template.Must(template.New("name").Parse("html"))
func Must(t *Template, err error) *Template {
    if err != nil {
        panic(err)
    }
    return t
}

Стоит помнить еще про один момент, связанный с panic и горутинами.

Часть тезисов из того что обсудили выше:

  • Для каждой горутины выделяется отдельный стек.
  • При вызове panic, в стеке ищется recover.
  • В случае, когда recover не найдет, завершается все приложение.

Обработчик в main не перехватит панику из foo и программа аварийно завершится:

func main(){
    defer func() {
        if err := recover(); err != nil{
            fmt.Printf("panic: %s", err)
        }
    }()

    go foo()

    time.Sleep(time.Minute)
}
func foo(){
    panic(errors.New("error"))
}

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

type f func()

func Def(fn f) {
    go func() {
        defer func() {
            if err := recover(); err != nil {
                log.Println("panic")
            }
        }()

        fn()
    }()
}

func main() {
    Def(foo)

    time.Sleep(time.Minute)
}

func foo() {
    panic(errors.New("error"))
}

handle/check

Возможно в будущем нас ждут изменения в обработки ошибок. Ознакомится с ними можно по ссылкам:
go2draft
Обработка ошибок в Go 2

На сегодня все. Спасибо!

Автор: justwack

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js