Skip to content

Tratamento de Erros

Em Go, existem três níveis de exceções:

  • error: erro de fluxo normal, precisa ser tratado. Se ignorado e não tratado, o programa não travará
  • panic: problema muito sério, o programa deve sair imediatamente após tratar o problema
  • fatal: problema extremamente fatal, o programa deve sair imediatamente

Precisamente falando, a linguagem Go não tem exceções. Ela expressa erros através de erros. Da mesma forma, Go também não tem instruções try-catch-finally. Os criadores de Go esperavam tornar os erros controláveis. Eles não queriam que fosse necessário aninhar vários try-catch para fazer qualquer coisa, então na maioria dos casos os erros são retornados como valores de retorno de funções, como no exemplo abaixo:

go
func main() {
  // Abre um arquivo
  if file, err := os.Open("README.txt"); err != nil {
    fmt.Println(err)
        return
  }
    fmt.Println(file.Name())
}

A intenção deste código é clara: abrir um arquivo chamado README.txt. Se a abertura falhar, a função retornará um erro e imprimirá a mensagem de erro. Se o erro for nil, significa que a abertura foi bem-sucedida e o nome do arquivo será impresso.

Parece ser mais conciso que try-catch, mas se houver muitas chamadas de função, haverá instruções de julgamento if err != nil em todos os lugares. Por exemplo, no exemplo abaixo, que é um demo de cálculo de hash de arquivo, a instrução if err != nil aparece três vezes neste pequeno trecho de código.

go
func main() {
  sum, err := checksum("main.go")
  if err != nil {
    fmt.Println(err)
    return
  }
  fmt.Println(sum)
}

func checksum(path string) (string, error) {
  file, err := os.Open(path)
  if err != nil {
    return "", err
  }
  defer file.Close()

  hash := sha256.New()
  _, err = io.Copy(hash, file)
  if err != nil {
    return "", err
  }

  var hexSum [64]byte
  sum := hash.Sum(nil)
  hex.Encode(hexSum[:], sum)

  return string(hexSum[:]), nil
}

Por isso, o ponto mais criticado em Go é o tratamento de erros. No código fonte de Go, if err != nil ocupa uma parte considerável. Rust também retorna valores de erro, mas ninguém critica isso, porque resolveu esse tipo de problema através de açúcar sintático. Comparado com Rust, o açúcar sintático de Go não pode ser considerado muito, pode-se dizer que quase não existe.

No entanto, devemos ver as coisas de forma dialética. Tudo tem seus prós e contras. As vantagens do tratamento de erros em Go são:

  • Baixa carga mental: se há erro, trate; se não tratar, retorne
  • Legibilidade: como a forma de tratamento é muito simples, na maioria dos casos é muito fácil ler o código
  • Fácil de depurar: cada erro é produzido através do valor de retorno da chamada de função, pode-se encontrar camada por camada, raramente aparece um erro repentinamente sem saber de onde veio

Mas também há muitas desvantagens:

  • Não há informação de pilha nos erros (precisa de pacotes de terceiros para resolver ou encapsular por conta própria)
  • Feio, muito código repetitivo (depende da preferência pessoal)
  • Erros personalizados são declarados através de var, é uma variável e não uma constante (realmente não deveria ser)
  • Problema de sombreamento de variáveis

As propostas e discussões da comunidade sobre o tratamento de erros em Go nunca pararam desde o nascimento de Go. Há uma piada: se você consegue aceitar o tratamento de erros de Go, então você é um Gopher qualificado.

TIP

Aqui estão dois artigos da equipe Go sobre tratamento de erros, se interessar pode dar uma olhada

error

error é um tipo de erro de fluxo normal, sua aparição pode ser aceita. Na maioria dos casos deve ser tratado, mas também pode ser ignorado. O nível de severidade do error não é suficiente para parar toda a execução do programa. error em si é uma interface pré-definida, esta interface tem apenas um método Error(), o valor de retorno deste método é string, usado para imprimir a mensagem de erro.

go
type error interface {
   Error() string
}

error também teve grandes mudanças na história. Na versão 1.13, a equipe Go lançou erros encadeados e forneceu um mecanismo de verificação de erros mais completo, que será apresentado a seguir.

Criação

Existem várias formas de criar um error. A primeira é usar a função New do pacote errors.

go
err := errors.New("este é um erro")

A segunda é usar a função Errorf do pacote fmt, que pode obter um error com parâmetros formatados.

go
err := fmt.Errorf("este é o erro do %dº parâmetro formatado", 1)

Abaixo está um exemplo completo

go
func sumPositive(i, j int) (int, error) {
   if i <= 0 || j <= 0 {
      return -1, errors.New("devem ser inteiros positivos")
   }
   return i + j, nil
}

Na maioria dos casos, para melhor manutenção, geralmente não se cria error temporariamente, mas se usa os errors comuns como variáveis globais. Por exemplo, o código abaixo extraído do arquivo os\erros.go

go
var (
  ErrInvalid = fs.ErrInvalid // "invalid argument"

  ErrPermission = fs.ErrPermission // "permission denied"
  ErrExist      = fs.ErrExist      // "file already exists"
  ErrNotExist   = fs.ErrNotExist   // "file does not exist"
  ErrClosed     = fs.ErrClosed     // "file already closed"

  ErrNoDeadline       = errNoDeadline()       // "file type does not support deadline"
  ErrDeadlineExceeded = errDeadlineExceeded() // "i/o timeout"
)

Pode-se ver que todos são variáveis definidas com var

Erro Personalizado

Através da implementação do método Error(), pode-se facilmente personalizar error. Por exemplo, errorString do pacote errors é uma implementação muito simples.

go
func New(text string) error {
   return &errorString{text}
}

// struct errorString
type errorString struct {
   s string
}

func (e *errorString) Error() string {
   return e.s
}

Como a implementação de errorString é simples demais e tem capacidade de expressão insuficiente, muitas bibliotecas open source incluindo a biblioteca oficial escolhem personalizar error para satisfazer diferentes necessidades de erro.

Passagem

Em algumas situações, o chamador chama uma função que retorna um erro, mas o chamador em si não é responsável por tratar o erro, então também retorna o erro como valor de retorno, jogando para o chamador do nível superior. Este processo é chamado de passagem. Durante a passagem, o erro pode ser encapsulado em várias camadas. Quando o chamador do nível superior quer julgar o tipo de erro para fazer tratamentos diferentes, pode não conseguir identificar a categoria do erro ou fazer julgamento errado. Os erros encadeados foram criados exatamente para resolver essa situação.

go
type wrapError struct {
   msg string
   err error
}

func (e *wrapError) Error() string {
   return e.msg
}

func (e *wrapError) Unwrap() error {
   return e.err
}

wrappError também implementa a interface error, e também tem um método adicional Unwrap, usado para retornar a referência interna ao error original. Com encapsulamento em camadas, forma-se uma lista encadeada de erros. Seguindo a lista encadeada para cima, é muito fácil encontrar o erro original. Como esta struct não é exposta externamente, só se pode usar a função fmt.Errorf para criar, por exemplo

go
err := errors.New("este é um erro original")
wrapErr := fmt.Errorf("erro, %w", err)

Ao usar, deve-se usar o verbo de formato %w, e o parâmetro só pode ser um error válido.

Tratamento

O último passo no tratamento de erros é como tratar e verificar erros. O pacote errors fornece várias funções convenientes para tratar erros.

go
func Unwrap(err error) error

A função errors.Unwrap() é usada para desencapsular uma cadeia de erros. Sua implementação interna também é muito simples

go
func Unwrap(err error) error {
   u, ok := err.(interface { // Asserção de tipo, se implementa o método
      Unwrap() error
   })
   if !ok { // Se não implementa, significa que é um error básico
      return nil
   }
   return u.Unwrap() // Caso contrário, chama Unwrap
}

Após desencapsular, retorna o erro encapsulado na cadeia de erros atual. O erro encapsulado ainda pode ser uma cadeia de erros. Se quiser encontrar o valor ou tipo correspondente na cadeia de erros, pode-se fazer busca recursiva e correspondência, mas a biblioteca padrão já fornece funções similares.

go
func Is(err, target error) bool

A função errors.Is serve para julgar se a cadeia de erros contém o erro especificado. Exemplo abaixo

go
var originalErr = errors.New("este é um erro")

func wrap1() error { // Encapsula o erro original
   return fmt.Errorf("wrapp error %w", wrap2())
}

func wrap2() error { // Erro original
   return originalErr
}

func main() {
   err := wrap1()
   if errors.Is(err, originalErr) { // Se usar if err == originalErr será false
      fmt.Println("original")
   }
}

Portanto, ao julgar erros, não se deve usar o operador ==, mas sim errors.Is().

go
func As(err error, target any) bool

A função errors.As() serve para encontrar o primeiro erro com tipo correspondente na cadeia de erros e atribuir o valor ao err passado. Em alguns casos, precisa-se converter o erro do tipo error para um tipo de implementação de erro específico para obter detalhes de erro mais específicos. Usar asserção de tipo em uma cadeia de erros é inválido, porque o erro original está encapsulado em uma struct, e é por isso que a função As é necessária. Exemplo abaixo

go
type TimeError struct { // error personalizado
   Msg  string
   Time time.Time // Registra o momento em que o erro ocorreu
}

func (m TimeError) Error() string {
   return m.Msg
}

func NewMyError(msg string) error {
   return &TimeError{
      Msg:  msg,
      Time: time.Now(),
   }
}

func wrap1() error { // Encapsula o erro original
   return fmt.Errorf("wrapp error %w", wrap2())
}

func wrap2() error { // Erro original
   return NewMyError("original error")
}

func main() {
   var myerr *TimeError
   err := wrap1()
   // Verifica se há erro do tipo *TimeError na cadeia de erros
   if errors.As(err, &myerr) { // Imprime o tempo do TimeError
      fmt.Println("original", myerr.Time)
   }
}

target deve ser um ponteiro para error. Como na criação da struct é retornado um ponteiro de struct, error na verdade é do tipo *TimeError, então target deve ser do tipo **TimeError.

No entanto, o pacote errors fornecido oficialmente não é suficiente, porque não tem informação de pilha e não pode localizar. Geralmente é recomendado usar outro pacote de extensão oficial

github.com/pkg/errors

Exemplo

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

func Do() error {
  return errors.New("error")
}

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

Saída

some unexpected error happened
main.Do
        D:/WorkSpace/Code/GoLeran/golearn/main.go:9
main.main
        D:/WorkSpace/Code/GoLeran/golearn/main.go:13
runtime.main
        D:/WorkSpace/Library/go/root/go1.21.3/src/runtime/proc.go:267
runtime.goexit
        D:/WorkSpace/Library/go/root/go1.21.3/src/runtime/asm_amd64.s:1650

Através da saída formatada, pode-se ver a informação de pilha. Por padrão, a pilha não é impressa. Este pacote é essencialmente uma versão extendida do pacote errors da biblioteca padrão, ambos são escritos oficialmente, não se sabe por que não foi incorporado à biblioteca padrão.

panic

panic traduzido para o chinês significa pânico, indica um problema de programa muito sério. O programa precisa parar imediatamente para tratar o problema, caso contrário o programa para de executar imediatamente e imprime a informação de pilha. panic é a forma de expressão de exceção em tempo de execução em Go, geralmente aparece em operações perigosas, principalmente para limitar as perdas a tempo e evitar consequências mais graves. No entanto, panic fará o trabalho de limpeza do programa antes de sair, e panic também pode ser recuperado para garantir que o programa continue executando.

Abaixo está um exemplo de escrever valor em um map nil, certamente vai disparar panic

go
func main() {
   var dic map[string]int
   dic["a"] = 'a'
}
panic: assignment to entry in nil map

TIP

Quando existem múltiplas goroutines no programa, se qualquer goroutine disparar panic e não for capturado, todo o programa vai travar

Criação

Criar panic explicitamente é muito simples, basta usar a função embutida panic. A assinatura da função é

go
func panic(v any)

A função panic recebe um parâmetro v do tipo any. Ao imprimir a informação de pilha de erro, v também será impresso. Exemplo de uso abaixo

go
func main() {
  initDataBase("", 0)
}

func initDataBase(host string, port int) {
  if len(host) == 0 || port == 0 {
    panic("parâmetros de conexão de dados inválidos")
  }
    // ...outra lógica
}

Quando a inicialização da conexão do banco de dados falha, o programa não deve iniciar, porque sem banco de dados o programa não tem sentido para executar, então aqui deve disparar panic

panic: parâmetros de conexão de dados inválidos

Limpeza

Antes de sair por causa de panic, o programa fará algum trabalho de limpeza, como executar instruções defer.

go
func main() {
   defer fmt.Println("A")
   defer fmt.Println("B")
   fmt.Println("C")
   panic("panic")
   defer fmt.Println("D")
}

A saída é

C
B
A
panic: panic

E as instruções defer das funções upstream também serão executadas. Exemplo abaixo

go
func main() {
   defer fmt.Println("A")
   defer fmt.Println("B")
   fmt.Println("C")
   dangerOp()
   defer fmt.Println("D")
}

func dangerOp() {
   defer fmt.Println(1)
   defer fmt.Println(2)
   panic("panic")
   defer fmt.Println(3)
}

Saída

C
2
1
B
A
panic: panic

defer também pode aninhar panic. Abaixo está um exemplo mais complexo

go
func main() {
  defer fmt.Println("A")
  defer func() {
    func() {
      panic("panicA")
      defer fmt.Println("E")
    }()
  }()
  fmt.Println("C")
  dangerOp()
  defer fmt.Println("D")
}

func dangerOp() {
  defer fmt.Println(1)
  defer fmt.Println(2)
  panic("panicB")
  defer fmt.Println(3)
}

A ordem de execução do panic aninhado em defer permanece a mesma. Quando ocorre panic, a lógica subsequente não poderá ser executada.

C
2
1
A
panic: panicB
        panic: panicA

Em resumo, quando ocorre panic, a função onde está sai imediatamente, executa o trabalho de limpeza da função atual, como defer, depois joga para cima camada por camada. As funções upstream também fazem trabalho de limpeza até o programa parar de executar.

Quando uma goroutine filha dispara panic, o trabalho de limpeza da goroutine atual não é disparado. Se até a goroutine filha sair não houver recuperação do panic, o programa vai parar de executar diretamente.

go
var waitGroup sync.WaitGroup

func main() {
  demo()
}

func demo() {
  waitGroup.Add(1)
  defer func() {
    fmt.Println("A")
  }()
  fmt.Println("C")
  go dangerOp()
  waitGroup.Wait() // Goroutine pai bloqueia esperando goroutine filha terminar
  defer fmt.Println("D")
}
func dangerOp() {
  defer fmt.Println(1)
  defer fmt.Println(2)
  panic("panicB")
  defer fmt.Println(3)
  waitGroup.Done()
}

A saída é

C
2
1
panic: panicB

Pode-se ver que nenhuma das instruções defer em demo() foi executada, o programa saiu diretamente. Note que se não houver waitGroup para bloquear a goroutine pai, a velocidade de execução de demo() pode ser mais rápida que a velocidade de execução da goroutine filha, e o resultado da saída se tornará muito enganoso. Abaixo, modificando ligeiramente o código

go
func main() {
  demo()
}

func demo() {
  defer func() {
    // Trabalho de limpeza da goroutine pai leva 20ms
    time.Sleep(time.Millisecond * 20)
    fmt.Println("A")
  }()
  fmt.Println("C")
  go dangerOp()
  defer fmt.Println("D")
}
func dangerOp() {
  // Goroutine filha precisa executar alguma lógica, leva 1ms
  time.Sleep(time.Millisecond)
  defer fmt.Println(1)
  defer fmt.Println(2)
  panic("panicB")
  defer fmt.Println(3)
}

A saída é

C
D
2
1
panic: panicB

Neste exemplo, quando a goroutine filha dispara panic, a goroutine pai já completou a execução da função e entrou no trabalho de limpeza. Ao executar o último defer, por coincidência encontrou a goroutine filha disparando panic, então o programa saiu diretamente.

Recuperação

Quando ocorre panic, usar a função embutida recover() pode tratar a tempo e garantir que o programa continue executando. Deve ser executado em uma instrução defer. Exemplo de uso abaixo.

go
func main() {
   dangerOp()
   fmt.Println("programa saiu normalmente")
}

func dangerOp() {
   defer func() {
      if err := recover(); err != nil {
         fmt.Println(err)
         fmt.Println("panic recuperado")
      }
   }()
   panic("ocorreu panic")
}

O chamador não sabe nada sobre o panic ocorrido dentro da função dangerOp(). O programa executa a lógica restante e sai normalmente, então a saída é

ocorreu panic
panic recuperado
programa saiu normalmente

Mas na verdade o uso de recover() tem muitas armadilhas implícitas. Por exemplo, usar recover novamente em closure dentro de defer.

go
func main() {
  dangerOp()
  fmt.Println("programa saiu normalmente")
}

func dangerOp() {
  defer func() {
    func() {
      if err := recover(); err != nil {
        fmt.Println(err)
        fmt.Println("panic recuperado")
      }
    }()
  }()
  panic("ocorreu panic")
}

A função closure pode ser vista como chamando uma função. panic é passado para cima e não para baixo, naturalmente a função closure não consegue recuperar o panic, então a saída é

panic: ocorreu panic

Além disso, há uma situação muito extrema, que é quando o parâmetro de panic() é nil.

go
func main() {
   dangerOp()
   fmt.Println("programa saiu normalmente")
}

func dangerOp() {
   defer func() {
      if err := recover(); err != nil {
         fmt.Println(err)
         fmt.Println("panic recuperado")
      }
   }()
   panic(nil)
}

Neste caso, panic realmente será recuperado, mas não imprimirá nenhuma mensagem de erro.

Saída

programa saiu normalmente

Em resumo, a função recover tem alguns pontos de atenção

  1. Deve ser usada em defer
  2. Mesmo usada múltiplas vezes, apenas uma conseguirá recuperar o panic
  3. recover em closure não recuperará nenhum panic da função externa
  4. O parâmetro de panic não pode ser nil

fatal

fatal é um tipo de problema extremamente sério. Quando ocorre fatal, o programa precisa parar de executar imediatamente, não executará nenhum trabalho de limpeza. Geralmente é chamando a função Exit do pacote os para sair do programa, como mostrado abaixo

go
func main() {
  dangerOp("")
}

func dangerOp(str string) {
  if len(str) == 0 {
    fmt.Println("fatal")
    os.Exit(1)
  }
  fmt.Println("lógica normal")
}

Saída

fatal

Problemas de nível fatal geralmente raramente são disparados explicitamente, na maioria dos casos são disparados passivamente.

Golang por www.golangdev.cn edit