В то время, как обсуждается возможный новый дизайн обработки ошибок и ведутся споры о преимуществах явной обработки ошибок, предлагаю рассмотреть некоторые особенности ошибок, паник и их восстановления в Go, которые будут полезны на практике.
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: %s\n", pe.Err)
fmt.Printf("Op: %s\n", pe.Op)
fmt.Printf("Path: %s\n", pe.Path)
}
Err: The system cannot find the file specified.
Op: open
Path: ---
Этот же подход можно применять если функция может вернуть несколько различных типов ошибок. play
Объявление нескольких типов ошибок, каждая имеет свои данные:
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() }
Функция которая может вернуть эти ошибки:
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
}
Обработка ошибок через приведения типов:
func main(){
err := proc(11)
if err != nil {
switch e := err.(type) {
case *ErrTimeout:
fmt.Printf("Timeout: %s\n", e.Time.String())
fmt.Printf("Error: %s\n", e.Err)
case *ErrPermission:
fmt.Printf("Status: %s\n", e.Status)
fmt.Printf("Error: %s\n", 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 будет записано такая информация:
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 %s\n", 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 %s\n", buf)
}
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 %s\n", e.Err)
fmt.Fprintf(&buf, "Trace:\n %s\n", 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
}
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 %s\n", st.Err)
fmt.Printf("Trace:\n %s\n", 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")
}
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, в стеке ищется 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
Возможно в будущем нас ждут изменения в обработки ошибок. Ознакомится с ними можно по ссылкам:
Комментарии в Telegram-группе!