Este post é o principal dos erros mais comuns que encontrei nos projetos Go. Ordem não importa.

Valor desconhecido de Enum
Vamos dar uma olhada em um exemplo simples:
type Status uint32 const ( StatusOpen Status = iota StatusClosed StatusUnknown )
Aqui, criamos um enumerador usando iota, o que levará a este estado:
StatusOpen = 0 StatusClosed = 1 StatusUnknown = 2
Agora vamos imaginar que esse tipo de status faça parte da solicitação JSON que será compactada / descompactada. Podemos projetar a seguinte estrutura:
type Request struct { ID int `json:"Id"` Timestamp int `json:"Timestamp"` Status Status `json:"Status"` }
Então obtemos este resultado da consulta:
{ "Id": 1234, "Timestamp": 1563362390, "Status": 0 }
Em geral, nada de especial - o status será descompactado no StatusOpen.
Agora, vamos obter outra resposta na qual o valor do status não está definido:
{ "Id": 1235, "Timestamp": 1563362390 }
Nesse caso, o campo Status da estrutura da solicitação será inicializado como zero (para uint32 é 0). Portanto, novamente obtemos StatusOpen em vez de StatusUnknown.
Nesse caso, é melhor definir o valor desconhecido do enumerador primeiro - ou seja, 0:
type Status uint32 const ( StatusUnknown Status = iota StatusOpen StatusClosed )
Se o status não fizer parte da solicitação JSON, será inicializado em StatusUnknown, como esperamos.
Benchmarking
O benchmarking correto é bastante difícil. Muitos fatores podem influenciar o resultado.
Um erro comum está sendo enganado pelas otimizações do compilador. Vamos ver um exemplo específico da
biblioteca teivah / bitvector :
func clear(n uint64, i, j uint8) uint64 { return (math.MaxUint64<<j | ((1 << i) - 1)) & n }
Esta função limpa bits em um determinado intervalo. Podemos testar o desempenho desta maneira:
func BenchmarkWrong(b *testing.B) { for i := 0; i < bN; i++ { clear(1221892080809121, 10, 63) } }
Neste teste, o compilador notará que clear não chama nenhuma outra função, portanto, simplesmente a incorpora como está. Uma vez incorporado, o compilador verá que nenhum efeito colateral ocorre. Assim, a chamada clara será simplesmente excluída, o que levará a resultados imprecisos.
Uma solução pode ser definir o resultado para uma variável global, como esta:
var result uint64 func BenchmarkCorrect(b *testing.B) { var r uint64 for i := 0; i < bN; i++ { r = clear(1221892080809121, 10, 63) } result = r }
Aqui, o compilador não saberá se a chamada cria um efeito colateral. Portanto, a referência será precisa.
Ponteiros! Ponteiros estão por toda parte!
Passar uma variável por valor criará uma cópia dessa variável. Ao passar pelo ponteiro, basta copiar o endereço na memória.
Conseqüentemente, passar um ponteiro sempre será mais rápido, certo?
Se você pensa assim, dê uma olhada
neste exemplo . Esta é uma referência para uma estrutura de dados de 0,3 KB que primeiro transmitimos e recebemos por ponteiro e depois por valor. 0,3 KB é um pouco - sobre as estruturas de dados comuns com as quais trabalhamos todos os dias ocupam muito disso.
Quando executo esses testes em um ambiente local, a transmissão valor por valor é mais de 4 vezes mais rápida. Muito inesperado, certo?
A explicação desse resultado está relacionada ao entendimento de como o gerenciamento de memória ocorre no Go. Não posso explicar isso de maneira tão brilhante quanto
William Kennedy , mas vamos tentar resumir em poucas palavras.
Uma variável pode ser colocada na pilha ou pilha:
- A pilha contém as variáveis atuais deste programa. Assim que a função retornar, as variáveis serão salvas da pilha.
- O heap contém variáveis comuns (variáveis globais, etc.).
Vejamos um exemplo simples em que retornamos um valor:
func getFooValue() foo { var result foo
Aqui a variável resultante é criada pela goroutine atual. Esta variável é enviada para a pilha atual. Assim que a função retornar, o cliente receberá uma cópia dessa variável. A variável em si é removida da pilha. Ele ainda existe na memória até que outra variável seja substituída, mas não pode mais ser acessada.
Agora o mesmo exemplo, mas com um ponteiro:
func getFooPointer() *foo { var result foo
A variável resultante ainda é criada pela goroutine atual, mas o cliente receberá um ponteiro (uma cópia do endereço da variável). Se a variável de resultado foi removida da pilha, o cliente desta função não poderá acessá-la.
Nesse cenário, o compilador Go exibirá a variável de resultado para onde as variáveis podem ser compartilhadas, ou seja, em um monte.
Outro script para passar ponteiros:
func main() { p := &foo{} f(p) }
Como chamamos f no mesmo programa, a variável p não precisa ser empilhada. É simplesmente empurrado para a pilha e uma subfunção pode acessá-la.
Por exemplo, dessa maneira, uma fatia é obtida no método Read do io.Reader. Retornar uma fatia (que é um ponteiro) coloca-a em uma pilha.
Por que a pilha é tão rápida? Existem dois motivos:
- Não é necessário usar o coletor de lixo na pilha. Como já dissemos, uma variável é simplesmente pressionada após ser criada e, em seguida, removida da pilha quando a função retorna. Não é necessário agitar um processo complicado para retornar variáveis não utilizadas, etc.
- A pilha pertence a uma goroutine; portanto, o armazenamento da variável não precisa ser sincronizado, como acontece com o armazenamento no heap, o que também leva a um aumento no desempenho.
Concluindo, quando criamos uma função, nossa ação padrão deve ser usar valores em vez de ponteiros. Um ponteiro só deve ser usado se quisermos compartilhar uma variável.
Além disso, se sofrermos de problemas de desempenho, uma das possíveis otimizações é verificar se os indicadores ajudam em situações específicas? Se o compilador gera uma variável para o heap pode ser encontrado com o seguinte comando:
go build -gcflags "-m -m"
.
Mas, novamente, para a maioria de nossas tarefas diárias, é melhor usar valores.
Interrompendo / alternar ou selecionar /
O que acontece no exemplo a seguir se f retornar verdadeiro?
for { switch f() { case true: break case false:
Nós chamamos de pausa. Somente essa interrupção interrompe o interruptor, não o loop for.
O mesmo problema aqui:
for { select { case <-ch:
A quebra está associada a uma instrução select, não a um loop for.
Uma solução possível para interromper / alternar ou selecionar / é usar um rótulo:
loop: for { select { case <-ch:
Tratamento de erros
Go ainda é jovem, especialmente na área de tratamento de erros. Superar essa falha é uma das inovações mais esperadas no Go 2.
A biblioteca padrão atual (anterior ao Go 1.13) oferece apenas funções para construir erros. Portanto, será interessante dar uma olhada no
pacote pkg / errors .
Esta biblioteca é uma boa maneira de seguir uma regra que nem sempre é respeitada:
O erro deve ser processado apenas uma vez. O registro de erros é o tratamento de erros
. Assim, o erro deve ser registrado ou lançado mais alto.
Na biblioteca padrão atual, esse princípio é difícil de observar, pois podemos adicionar contexto ao erro e ter algum tipo de hierarquia.
Vejamos um exemplo com uma chamada REST que leva a um erro no banco de dados:
unable to server HTTP POST request for customer 1234 |_ unable to insert customer contract abcd |_ unable to commit transaction
Se usarmos pkg / errors, podemos fazer o seguinte:
func postHandler(customer Customer) Status { err := insert(customer.Contract) if err != nil { log.WithError(err).Errorf("unable to server HTTP POST request for customer %s", customer.ID) return Status{ok: false} } return Status{ok: true} } func insert(contract Contract) error { err := dbQuery(contract) if err != nil { return errors.Wrapf(err, "unable to insert customer contract %s", contract.ID) } return nil } func dbQuery(contract Contract) error {
O erro inicial (se não for retornado pela biblioteca externa) pode ser criado usando errors.New. A camada do meio, inserção, envolve esse erro, adicionando mais contexto a ele. Em seguida, o pai registra. Assim, cada nível retorna ou processa um erro.
Também podemos querer encontrar a causa do erro, por exemplo, para retornar a ligação. Suponha que tenhamos um pacote db de uma biblioteca externa que tenha acesso a um banco de dados. Esta biblioteca pode retornar um erro temporário chamado db.DBError. Para determinar se precisamos tentar novamente, devemos estabelecer a causa do erro:
func postHandler(customer Customer) Status { err := insert(customer.Contract) if err != nil { switch errors.Cause(err).(type) { default: log.WithError(err).Errorf("unable to server HTTP POST request for customer %s", customer.ID) return Status{ok: false} case *db.DBError: return retry(customer) } } return Status{ok: true} } func insert(contract Contract) error { err := db.dbQuery(contract) if err != nil { return errors.Wrapf(err, "unable to insert customer contract %s", contract.ID) } return nil }
Isso é feito usando errors.Cause, que também está incluído no
pkg / errors :
Um dos erros comuns que encontrei foi o uso de
pkg / errors apenas parcialmente. Uma verificação de erro, por exemplo, foi realizada da seguinte maneira:
switch err.(type) { default: log.WithError(err).Errorf("unable to server HTTP POST request for customer %s", customer.ID) return Status{ok: false} case *db.DBError: return retry(customer) }
Neste exemplo, se o db.DBError estiver quebrado, ele nunca fará uma segunda chamada.
Inicialização de fatia
Às vezes, sabemos qual será o comprimento final da fatia. Por exemplo, suponha que desejamos converter uma fatia de Foo em uma fatia de barra, o que significa que essas duas fatias terão o mesmo comprimento.
Costumo encontrar fatias inicializadas desta maneira:
var bars []Bar bars := make([]Bar, 0)
Fatia não é uma estrutura mágica. Sob o capô, ele implementa uma estratégia para aumentar o tamanho se não houver mais espaço livre. Nesse caso, uma nova matriz é criada automaticamente (com uma capacidade maior) e todos os elementos são copiados para ela.
Agora vamos imaginar que precisamos repetir essa operação para aumentar o tamanho várias vezes, pois nosso [] Foo contém milhares de elementos. A complexidade do algoritmo de inserção permanecerá O (1), mas na prática isso afetará o desempenho.
Portanto, se sabemos o comprimento final, podemos:
- Inicialize-o com um comprimento predefinido:
func convert(foos []Foo) []Bar { bars := make([]Bar, len(foos)) for i, foo := range foos { bars[i] = fooToBar(foo) } return bars }
- Ou inicialize-o com um comprimento de 0 e uma capacidade predeterminada:
func convert(foos []Foo) []Bar { bars := make([]Bar, 0, len(foos)) for _, foo := range foos { bars = append(bars, fooToBar(foo)) } return bars }
Qual é a melhor opção? O primeiro é um pouco mais rápido. No entanto, você pode preferir o último porque é mais consistente: independentemente de sabermos o tamanho inicial, a adição de um elemento no final da fatia é feita usando o acréscimo.
Gerenciamento de contexto
context.Context geralmente é mal interpretado pelos desenvolvedores. De acordo com a documentação oficial:
O contexto transporta o prazo final, o sinal de cancelamento e outros valores através dos limites da API.
Esta descrição é bastante geral, portanto, pode confundir o programador como usá-lo corretamente.
Vamos tentar descobrir. O contexto pode conter:
- Prazo - significa a duração (por exemplo, 250 ms) ou a data e hora (por exemplo, 08-01-2019 01:00:00), segundo a qual acreditamos que, se for atingida, a ação atual deverá ser cancelada (solicitação de E / S ), aguardando a entrada do canal etc.).
- Cancele o sinal (basicamente <-chan struct {}). Aqui o comportamento é semelhante. Assim que recebermos um sinal, devemos parar o trabalho atual. Por exemplo, digamos que recebemos dois pedidos. Um para inserir dados e o outro para cancelar a primeira solicitação (porque não é mais relevante, por exemplo). Isso pode ser conseguido usando o contexto cancelado na primeira chamada, que será cancelada assim que recebermos a segunda solicitação.
- Lista de chave / valor (ambos baseados no tipo de interface {}).
Mais dois pontos. Primeiro, o contexto é compostável. Portanto, podemos ter um contexto que contenha o prazo e a lista de chave / valor, por exemplo. Além disso, várias goroutines podem compartilhar o mesmo contexto, portanto, um sinal de cancelamento pode potencialmente interromper vários trabalhos.
Voltando ao nosso tópico, aqui está um erro que eu encontrei.
O aplicativo Go foi baseado no
urfave / cli (se você não souber, é uma boa biblioteca para criar aplicativos de linha de comando no Go). Uma vez iniciado, o desenvolvedor herda um tipo de contexto de aplicativo. Isso significa que, quando o aplicativo for parado, a biblioteca usará o contexto para enviar um sinal de cancelamento.
Percebi que esse contexto foi transmitido diretamente, por exemplo, quando um ponto final de gRPC foi chamado. Não é disso que precisamos.
Em vez disso, queremos informar à biblioteca gRPC: cancele a solicitação quando o aplicativo for parado ou após 100 ms, por exemplo.
Para conseguir isso, podemos simplesmente criar um contexto composto. Se pai é o nome do contexto do aplicativo (criado por
urfave / cli ), podemos simplesmente fazer isso:
ctx, cancel := context.WithTimeout(parent, 100 * time.Millisecond) response, err := grpcClient.Send(ctx, request)
Os contextos não são tão difíceis de entender e, na minha opinião, esse é um dos melhores recursos da linguagem.
Não está usando a opção -race
Testar um aplicativo Go sem a opção -race é um bug que eu sempre encontro.
Conforme escrito
neste artigo , embora o Go tenha sido "
projetado para tornar a programação paralela mais simples e menos propensa a erros ", ainda sofremos muito com problemas de simultaneidade.
Obviamente, o detector de corrida Go não ajudará em nenhum problema. No entanto, é uma ferramenta valiosa, e devemos sempre incluí-la ao testar nossos aplicativos.
Usando o nome do arquivo como entrada
Outro erro comum é passar o nome do arquivo para uma função.
Suponha que precisamos implementar uma função para contar o número de linhas vazias em um arquivo. A implementação mais natural seria algo como isto:
func count(filename string) (int, error) { file, err := os.Open(filename) if err != nil { return 0, errors.Wrapf(err, "unable to open %s", filename) } defer file.Close() scanner := bufio.NewScanner(file) count := 0 for scanner.Scan() { if scanner.Text() == "" { count++ } } return count, nil }
O nome do arquivo é definido como uma entrada; portanto, abrimos e implementamos nossa lógica, certo?
Agora, suponha que desejamos cobrir essa função com testes de unidade. Testaremos com um arquivo comum, um arquivo vazio, um arquivo com um tipo diferente de codificação, etc. Pode ser muito difícil gerenciá-lo.
Além disso, se quisermos implementar a mesma lógica, por exemplo, para o corpo HTTP, precisaremos criar outra função para isso.
Go vem com duas grandes abstrações: io.Reader e io.Writer. Em vez de passar o nome do arquivo, podemos simplesmente passar io.Reader, que abstrairá a fonte de dados.
Isso é um arquivo? Corpo HTTP? Buffer de bytes? Não importa, pois ainda usaremos o mesmo método de leitura.
No nosso caso, podemos até armazenar em buffer a entrada para lê-la linha por linha. Para fazer isso, você pode usar o bufio.Reader e seu método ReadLine:
func count(reader *bufio.Reader) (int, error) { count := 0 for { line, _, err := reader.ReadLine() if err != nil { switch err { default: return 0, errors.Wrapf(err, "unable to read") case io.EOF: return count, nil } } if len(line) == 0 { count++ } } }
Agora, a responsabilidade de abrir o arquivo foi delegada ao cliente de contagem:
file, err := os.Open(filename) if err != nil { return errors.Wrapf(err, "unable to open %s", filename) } defer file.Close() count, err := count(bufio.NewReader(file))
Em uma segunda implementação, uma função pode ser chamada independentemente da fonte de dados real. Enquanto isso, isso facilitará nossos testes de unidade, pois podemos simplesmente criar o bufio.Reader a partir da linha:
count, err := count(bufio.NewReader(strings.NewReader("input")))
Goroutines e variáveis de ciclo
O último erro comum que encontrei foi ao usar goroutines com variáveis de loop.
Qual será a conclusão do exemplo a seguir?
ints := []int{1, 2, 3} for _, i := range ints { go func() { fmt.Printf("%v\n", i) }() }
1 2 3 aleatoriamente? Não.
Neste exemplo, cada goroutine usa a mesma instância de uma variável e, portanto, gera 3 3 3 (provavelmente).
Existem duas soluções para esse problema. O primeiro é passar o valor da variável i para o fechamento (função interna):
ints := []int{1, 2, 3} for _, i := range ints { go func(i int) { fmt.Printf("%v\n", i) }(i) }
O segundo é criar outra variável dentro do loop for:
ints := []int{1, 2, 3} for _, i := range ints { i := i go func() { fmt.Printf("%v\n", i) }() }
Atribuir i: = i pode parecer um pouco estranho, mas esse design é perfeitamente válido. Estar em loop significa estar em um escopo diferente. Portanto, i: = i cria outra instância da variável i. Claro, podemos chamá-lo com um nome diferente para facilitar a leitura.
Se você conhece outros erros comuns, sinta-se à vontade para escrever sobre eles nos comentários.