Practical Go: Dicas para escrever programas suportados no mundo real

Este artigo se concentra nas práticas recomendadas para escrever código Go. É composto no estilo de apresentação, mas sem os slides usuais. Vamos tentar brevemente e claramente percorrer cada item.

Primeiro, você precisa concordar sobre o significado das melhores práticas para uma linguagem de programação. Aqui você pode se lembrar das palavras de Russ Cox, diretor técnico da Go:

Engenharia de software é o que acontece com a programação, se você adicionar o fator tempo e outros programadores.

Assim, Russ distingue entre os conceitos de programação e engenharia de software . No primeiro caso, você escreve um programa para si mesmo; no segundo, cria um produto no qual outros programadores trabalharão com o tempo. Engenheiros vêm e vão. As equipes crescem ou encolhem. Novos recursos são adicionados e os bugs são corrigidos. Essa é a natureza do desenvolvimento de software.

Conteúdo



1. Princípios fundamentais


Talvez eu seja um dos primeiros usuários do Go entre vocês, mas essa não é minha opinião pessoal. Estes princípios básicos estão subjacentes ao próprio Go:

  1. Simplicidade
  2. Legibilidade
  3. Produtividade

Nota Observe que eu não mencionei "desempenho" ou "simultaneidade". Existem idiomas mais rápidos que o Go, mas certamente não podem ser comparados com simplicidade. Existem linguagens que colocam o paralelismo como a principal prioridade, mas elas não podem ser comparadas em termos de legibilidade ou produtividade de programação.

Desempenho e simultaneidade são atributos importantes, mas não tão importantes quanto simplicidade, legibilidade e produtividade.

Simplicidade


“Simplicidade é um pré-requisito para a confiabilidade” - Edsger Dijkstra

Por que lutar pela simplicidade? Por que é importante que os programas Go sejam simples?

Cada um de nós se deparou com um código incompreensível, certo? Quando você tem medo de fazer uma alteração, porque isso interrompe outra parte do programa que você não entende e não sabe como consertar. Essa é a dificuldade.

“Existem duas maneiras de projetar software: a primeira é torná-lo tão simples que não há falhas óbvias, e a segunda é torná-lo tão complexo que não há falhas óbvias. O primeiro é muito mais difícil. ” - C.E. R. Hoar

Complexidade transforma software confiável em não confiável. Complexidade é o que mata projetos de software. Portanto, a simplicidade é o objetivo final da Go. Quaisquer que sejam os programas que escrevemos, eles devem ser simples.

1.2 Legibilidade


“A legibilidade é parte integrante da manutenção” - Mark Reinhold, JVM Conference, 2018

Por que é importante que o código seja legível? Por que devemos nos esforçar para facilitar a leitura?

“Os programas devem ser escritos para as pessoas e as máquinas apenas os executam” - Hal Abelson e Gerald Sassman, “Estrutura e interpretação de programas de computador”

Não apenas os programas Go, mas geralmente todo o software é escrito por pessoas para pessoas. O fato de as máquinas também processarem código é secundário.

Uma vez que o código escrito será lido repetidamente pelas pessoas: centenas, se não milhares de vezes.

“A habilidade mais importante para um programador é a capacidade de comunicar idéias de maneira eficaz.” - Gaston Horker

A legibilidade é a chave para entender o que um programa faz. Se você não consegue entender o código, como mantê-lo? Se o software não puder ser suportado, ele será reescrito; e essa pode ser a última vez que sua empresa usa o Go.

Se você está escrevendo um programa para si mesmo, faça o que funciona para você. Mas se isso faz parte de um projeto conjunto ou o programa for usado por tempo suficiente para alterar os requisitos, funções ou o ambiente em que ele trabalha, seu objetivo é tornar o programa sustentável.

O primeiro passo para escrever o software suportado é garantir que o código esteja claro.

1.3 Produtividade


“O design é a arte de organizar o código para que ele funcione hoje, mas sempre suporte mudanças.” - Sandy Mets

Como último princípio básico, quero nomear a produtividade do desenvolvedor. Esse é um tópico importante, mas tudo se resume à proporção: quanto tempo você gasta em trabalho útil e quanto - aguardando uma resposta de ferramentas ou andanças sem esperança em uma base de código incompreensível. Os programadores de Go devem sentir que podem lidar com muito trabalho.

É uma piada que a linguagem Go tenha sido desenvolvida enquanto o programa C ++ estava compilando. A compilação rápida é um recurso essencial do Go e um fator essencial para atrair novos desenvolvedores. Embora os compiladores estejam sendo aprimorados, em geral, a compilação de minutos em outros idiomas leva alguns segundos no Go. Os desenvolvedores do So Go se sentem tão produtivos quanto os programadores em linguagens dinâmicas, mas sem problemas com a confiabilidade dessas linguagens.

Se falamos fundamentalmente sobre a produtividade dos desenvolvedores, os programadores do Go entendem que a leitura do código é essencialmente mais importante do que a sua criação. Nessa lógica, o Go chega ao ponto de usar as ferramentas para formatar todo o código em um determinado estilo. Isso elimina a menor dificuldade em aprender o dialeto específico de um projeto específico e ajuda a identificar erros, porque eles simplesmente parecem errados em comparação com o código regular.

Os programadores da Go não passam dias depurando erros estranhos de compilação, scripts de compilação complexos ou implementando código em um ambiente de produção. E o mais importante, eles não perdem tempo tentando entender o que um colega escreveu.

Quando os desenvolvedores do Go falam sobre escalabilidade , eles significam produtividade.

2. Identificadores


O primeiro tópico que discutiremos - identificadores , é sinônimo de nomes : nomes de variáveis, funções, métodos, tipos, pacotes e assim por diante.

“O mau nome é um sintoma de mau design” - Dave Cheney

Dada a sintaxe limitada do Go, os nomes de objetos têm um enorme impacto na legibilidade do programa. A legibilidade é um fator chave no bom código, portanto, a escolha de bons nomes é crucial.

2.1 Identificadores de nome com base na clareza e não na brevidade


“É importante que o código seja óbvio. O que você pode fazer em uma linha, você deve fazer em três. ” - Ukia Smith

O Go não é otimizado para one-liners complicados ou o número mínimo de linhas em um programa. Não otimizamos o tamanho do código fonte no disco, nem o tempo necessário para digitar o programa no editor.

“Um bom nome é como uma boa piada. Se você precisar explicar, não será mais engraçado. ” - Dave Cheney

A chave da máxima clareza são os nomes que escolhemos para identificar programas. Que qualidades são inerentes a um bom nome?

  • Um bom nome é conciso . Não precisa ser o mais curto, mas não contém excesso. Possui uma alta relação sinal / ruído.
  • Um bom nome é descritivo . Descreve o uso de uma variável ou constante, não o conteúdo. Um bom nome descreve o resultado de uma função ou o comportamento de um método, não uma implementação. O objetivo do pacote, não seu conteúdo. Quanto mais precisamente o nome descreve o que identifica, melhor.
  • Um bom nome é previsível . Por um nome, você deve entender como o objeto será usado. Os nomes devem ser descritivos, mas também é importante seguir a tradição. É isso que os programadores do Go querem dizer quando dizem "idiomático" .

Vamos considerar em mais detalhes cada uma dessas propriedades.

2.2 Comprimento do ID


Às vezes, o estilo de Go é criticado por nomes curtos de variáveis. Como disse Rob Pike, "os programadores da Go querem identificadores do tamanho correto ".

Andrew Gerrand oferece identificadores mais longos para indicar importância.

“Quanto maior a distância entre a declaração de um nome e o uso de um objeto, maior deve ser o nome” - Andrew Gerrand

Assim, algumas recomendações podem ser feitas:

  • Nomes curtos de variáveis ​​são bons se a distância entre a declaração e o último uso for pequena.
  • Nomes de variáveis ​​longos devem se justificar; quanto mais longos, mais importantes devem ser. Os títulos detalhados contêm pouco sinal em relação ao peso na página.
  • Não inclua o nome do tipo no nome da variável.
  • Nomes constantes devem descrever o valor interno, não como o valor é usado.
  • Prefira variáveis ​​de letra única para loops e ramificações, palavras separadas para parâmetros e valores de retorno, várias palavras para funções e declarações no nível do pacote.
  • Prefira palavras simples para métodos, interfaces e pacotes.
  • Lembre-se de que o nome do pacote faz parte do nome que o chamador usa para referência.

Considere um exemplo.

type Person struct { Name string Age int } // AverageAge returns the average age of people. func AverageAge(people []Person) int { if len(people) == 0 { return 0 } var count, sum int for _, p := range people { sum += p.Age count += 1 } return sum / count } 

Na décima linha, uma variável do intervalo p declarada e é chamada apenas uma vez da próxima linha. Ou seja, a variável permanece na página por um período muito curto. Se o leitor estiver interessado no papel de p no programa, ele só precisará ler duas linhas.

Para comparação, people declaradas em parâmetros de função e sete linhas ao vivo. O mesmo vale para sum e count , para que justifiquem seus nomes mais longos. O leitor precisa digitalizar mais códigos para encontrá-los: isso justifica os nomes mais distintos.

Você pode escolher s para sum c (ou n ) para count , mas isso reduz a importância de todas as variáveis ​​no programa para o mesmo nível. Você pode substituir as people por p , mas haverá um problema, o que chamar de variável de iteração for ... range . Uma única person parecerá estranha, porque uma variável de iteração de curta duração recebe um nome mais longo do que vários valores dos quais é derivada.

Dica . Separe o fluxo de funções com linhas vazias, pois as linhas vazias entre parágrafos interrompem o fluxo de texto. Na AverageAge , temos três operações consecutivas. Primeiro, verificando a divisão por zero, depois a conclusão da idade total e número de pessoas e a última - o cálculo da idade média.

2.2.1 O principal é o contexto


É importante entender que a maioria das dicas de nomenclatura é específica ao contexto. Eu gosto de dizer que isso é um princípio, não uma regra.

Qual é a diferença entre i e index ? Por exemplo, você não pode dizer inequivocamente que esse código

 for index := 0; index < len(s); index++ { // } 

fundamentalmente mais legível do que

 for i := 0; i < len(s); i++ { // } 

Acredito que a segunda opção não é pior, porque nesse caso a região i ou o index limitado pelo corpo do loop for , e a verbosidade adicional acrescenta pouco ao entendimento do programa.

Mas qual dessas funções é mais legível?

 func (s *SNMP) Fetch(oid []int, index int) (int, error) 

ou

 func (s *SNMP) Fetch(o []int, i int) (int, error) 

Neste exemplo, oid é uma abreviação de SNMP Object ID e a abreviação adicional para o força ao ler código para alternar de uma notação documentada para uma notação mais curta no código. Da mesma forma, reduzir o index para i torna mais difícil entender, porque nas mensagens SNMP, o sub-valor de cada OID é chamado de índice.

Dica . Não combine parâmetros formais longos e curtos em um anúncio.

2.3 Não nomeie variáveis ​​por tipo


Você não chama seus animais de estimação de "cachorro" e "gato", certo? Pelo mesmo motivo, você não deve incluir o nome do tipo no nome da variável. Ele deve descrever o conteúdo, não seu tipo. Considere um exemplo:

 var usersMap map[string]*User 

Que bom é esse anúncio? Vemos que este é um mapa e tem algo a ver com o *User Tipo de *User : isso provavelmente é bom. Mas usersMap é realmente um mapa, e Go, como uma linguagem de tipo estaticamente, não permitirá o uso acidental de um nome como esse quando uma variável escalar for necessária, portanto o sufixo do Map é redundante.

Considere uma situação em que outras variáveis ​​são adicionadas:

 var ( companiesMap map[string]*Company productsMap map[string]*Products ) 

Agora, temos três variáveis ​​do tipo mapa: usersMap , companiesMap e productsMap , e todas as linhas são mapeadas para tipos diferentes. Sabemos que esses são mapas e também sabemos que o compilador lançará um erro se tentarmos usar companiesMap onde o código espera map[string]*User . Nessa situação, fica claro que o sufixo Map não melhora a clareza do código, são apenas caracteres extras.

Sugiro evitar sufixos semelhantes ao tipo de uma variável.

Dica . Se o nome de users não descrever a essência com clareza suficiente, o usersMap também.

Esta dica também se aplica aos parâmetros de função. Por exemplo:

 type Config struct { // } func WriteConfig(w io.Writer, config *Config) 

O nome da config para o parâmetro *Config é redundante. Já sabemos que este é *Config ; ele é imediatamente escrito ao lado.

Nesse caso, considere conf ou c se a vida útil da variável for curta o suficiente.

Se em algum momento da nossa área houver mais de um *Config , os nomes conf1 e conf2 menos significativos que o original e updated , pois os últimos são mais difíceis de serem misturados.

Nota Não permita que nomes de pacotes roubem bons nomes de variáveis.

O nome do identificador importado contém o nome do pacote. Por exemplo, o tipo de context pacote de context será chamado context.Context . Isso impossibilita o uso de uma variável ou tipo de context no seu pacote.

 func WriteLog(context context.Context, message string) 

Isso não será compilado. É por isso que, ao declarar o context.Context Tipos de context.Context localmente, por exemplo, nomes como ctx são tradicionalmente usados.

 func WriteLog(ctx context.Context, message string) 

2.4 Use um único estilo de nomeação


Outra propriedade de um bom nome é que deve ser previsível. O leitor deve entendê-lo imediatamente. Se esse é um nome comum , o leitor tem o direito de assumir que não mudou o significado da época anterior.

Por exemplo, se o código contornar o descritor do banco de dados, sempre que o parâmetro for exibido, ele deverá ter o mesmo nome. Em vez de todos os tipos de combinações como d *sql.DB , dbase *sql.DB , DB *sql.DB e database *sql.DB , é melhor usar uma coisa:

 db *sql.DB 

É mais fácil entender o código. Se você *sql.DB db , saberá que é *sql.DB e é declarado localmente ou fornecido pelo chamador.

Conselho semelhante em relação aos destinatários de um método; use o mesmo nome de destinatário para cada método desse tipo. Isso tornará mais fácil para o leitor entender o uso do receptor entre os vários métodos desse tipo.

Nota O Contrato de Nome Curto do Destinatário da Go contradiz as recomendações expressas anteriormente. Esse é um daqueles casos em que a escolha feita em um estágio inicial se torna o estilo padrão, como usar o CamelCase vez de snake_case .

Dica . O estilo Ir aponta para nomes de uma letra ou abreviações para destinatários derivados de seu tipo. Pode acontecer que o nome do destinatário às vezes esteja em conflito com o nome do parâmetro no método. Nesse caso, é recomendável aumentar um pouco o nome do parâmetro e não se esqueça de usá-lo sequencialmente.

Finalmente, algumas variáveis ​​de uma letra são tradicionalmente associadas a loops e contagem. Por exemplo, i , j e k são geralmente variáveis ​​indutivas em loops, n geralmente associado a um contador ou somador acumulador, v é uma abreviação típica de valor em uma função de codificação, k geralmente usado para uma chave de mapa e s frequentemente usado como uma abreviação de parâmetros do tipo string .

Como no exemplo db acima, os programadores esperam que i seja uma variável indutiva. Se o virem no código, esperam ver um loop em breve.

Dica . Se você tiver tantos loops aninhados que esgotou as variáveis i , j k , poderá dividir a função em unidades menores.

2.5 Use um único estilo de declaração


Go tem pelo menos seis maneiras diferentes de declarar uma variável.

  •  var x int = 1 
  •  var x = 1 
  •  var x int; x = 1 
  •  var x = int(1) 
  •  x := 1 

Tenho certeza de que ainda não lembrei de tudo. Os desenvolvedores do Go provavelmente consideram isso um erro, mas é tarde demais para mudar qualquer coisa. Com esta escolha, como garantir um estilo uniforme?

Quero propor um estilo de declarar variáveis ​​que eu mesmo tento usar sempre que possível.

  • Ao declarar uma variável sem inicialização, use var .

     var players int // 0 var things []Thing // an empty slice of Things var thing Thing // empty Thing struct json.Unmarshall(reader, &thing) 

    var atua como uma dica de que essa variável é declarada intencionalmente como um valor nulo do tipo especificado. Isso é consistente com o requisito de declarar variáveis ​​no nível do pacote com var em oposição à sintaxe da declaração curta, embora eu argumente mais tarde que as variáveis ​​no nível do pacote não devem ser usadas.
  • Ao declarar com inicialização, use := . Isso deixa claro para o leitor que a variável à esquerda de := intencionalmente inicializada.

    Para explicar o porquê, vejamos o exemplo anterior, mas desta vez inicializamos especialmente cada variável:

     var players int = 0 var things []Thing = nil var thing *Thing = new(Thing) json.Unmarshall(reader, thing) 

Como o Go não possui conversões automáticas de um tipo para outro, no primeiro e terceiro exemplos, o tipo no lado esquerdo do operador de atribuição deve ser idêntico ao tipo no lado direito. O compilador pode inferir o tipo da variável declarada do tipo à direita, para que o exemplo possa ser escrito de forma mais concisa:

 var players = 0 var things []Thing = nil var thing = new(Thing) json.Unmarshall(reader, thing) 

Aqui, os players inicializados explicitamente em 0 , o que é redundante, porque o valor inicial dos players é zero em qualquer caso. Portanto, é melhor deixar claro que queremos usar um valor nulo:

 var players int 

E o segundo operador? Não podemos determinar o tipo e escrever

 var things = nil 

Porque nil não nil tipo . Em vez disso, temos uma escolha: ou usamos um valor zero para cortar ...

 var things []Thing 

... ou criar uma fatia com zero elementos?

 var things = make([]Thing, 0) 

No segundo caso, o valor da fatia não é zero e deixamos claro para o leitor usando uma forma abreviada de declaração:

 things := make([]Thing, 0) 

Isso diz ao leitor que decidimos inicializar explicitamente as things .

Então chegamos à terceira declaração:

 var thing = new(Thing) 

Aqui, a inicialização explícita da variável e a introdução da palavra-chave “única” new , que alguns programadores do Go não gostam. O uso da sintaxe curta recomendada gera

 thing := new(Thing) 

Isso deixa claro que thing explicitamente inicializado no resultado de new(Thing) , mas ainda deixa um new atípico. O problema pode ser resolvido usando um literal:

 thing := &Thing{} 

O que é semelhante ao new(Thing) , e essa duplicação perturba alguns programadores do Go. No entanto, isso significa que inicializamos explicitamente a thing com um ponteiro para Thing{} e um valor Thing de zero.

Mas é melhor levar em consideração o fato de que thing declarado com um valor zero e usar o endereço do operador para passar o endereço de thing em json.Unmarshall :

 var thing Thing json.Unmarshall(reader, &thing) 

Nota Obviamente, há exceções a qualquer regra. Por exemplo, às vezes duas variáveis ​​estão intimamente relacionadas, por isso será estranho escrever

 var min int max := 1000 

Declaração mais legível:

 min, max := 0, 1000 

Para resumir:

  • Ao declarar uma variável sem inicialização, use a sintaxe var .
  • Ao declarar e inicializar explicitamente uma variável, use := .

Dica . Explique explicitamente coisas complexas.

 var length uint32 = 0x80 

Aqui, o length pode ser usado com a biblioteca, que requer um tipo numérico específico, e esta opção indica mais claramente que o comprimento do tipo é especificamente selecionado como uint32 do que na declaração curta:

 length := uint32(0x80) 

No primeiro exemplo, eu intencionalmente quebrei minha regra usando a declaração var com inicialização explícita. Um afastamento do padrão faz com que o leitor entenda que algo incomum está acontecendo.

2.6 Trabalho para a equipe


Eu já disse que a essência do desenvolvimento de software é a criação de código legível e suportado. A maior parte da sua carreira provavelmente funcionará em projetos conjuntos. Meu conselho nessa situação: siga o estilo adotado na equipe.

Alterar estilos no meio do arquivo é irritante. A consistência é importante, embora em detrimento da preferência pessoal. Minha regra geral é: se o código se encaixa no gofmt , o problema geralmente não vale a pena ser discutido.

Dica . Se você deseja renomear em toda a base de código, não misture isso com outras alterações. Se alguém usa git bisect, ele não gosta de percorrer milhares de renomeações para encontrar outro código modificado.

3. Comentários


Antes de passarmos a pontos mais importantes, quero dedicar alguns minutos para comentar.

“Um bom código tem muitos comentários e um código ruim precisa de muitos comentários.” - Dave Thomas e Andrew Hunt, programador pragmático

Os comentários são muito importantes para a legibilidade do programa. Cada comentário deve fazer uma - e apenas uma - de três coisas:

  1. Explique o que o código faz.
  2. Explique como ele faz isso.
  3. Explique o porquê .

O primeiro formulário é ideal para comentar caracteres públicos:

 // Open     . //           . 

O segundo é ideal para comentários dentro de um método:

 //     var results []chan error for _, dep := range a.Deps { results = append(results, execute(seen, dep)) } 

A terceira forma ("por quê") é única, pois não substitui nem substitui as duas primeiras. Tais comentários explicam os fatores externos que levaram à escrita do código em sua forma atual. Muitas vezes, sem esse contexto, é difícil entender por que o código é escrito dessa maneira.

 return &v2.Cluster_CommonLbConfig{ //  HealthyPanicThreshold HealthyPanicThreshold: &envoy_type.Percent{ Value: 0, }, } 

Neste exemplo, pode não ficar claro imediatamente o que acontece quando HealthyPanicThreshold é definido como zero por cento. O comentário pretende esclarecer que um valor 0 desativa o limite de pânico.

3.1 Comentários em variáveis ​​e constantes devem descrever seu conteúdo, não o objetivo


Eu disse anteriormente que o nome de uma variável ou constante deve descrever seu propósito. Mas um comentário sobre uma variável ou constante deve descrever exatamente o conteúdo , não o objetivo .

 const randomNumber = 6 //     

Neste exemplo, um comentário descreve por que randomNumber como 6 e de onde veio. O comentário não descreve onde o randomNumber será usado. Aqui estão mais alguns exemplos:

 const ( StatusContinue = 100 // RFC 7231, 6.2.1 StatusSwitchingProtocols = 101 // RFC 7231, 6.2.2 StatusProcessing = 102 // RFC 2518, 10.1 StatusOK = 200 // RFC 7231, 6.3.1 

No contexto do HTTP, o número 100 conhecido como StatusContinue , conforme definido na RFC 7231, seção 6.2.1.

Dica . Para variáveis ​​sem valor inicial, o comentário deve descrever quem é responsável por inicializar essa variável.

 // sizeCalculationDisabled ,   //     . . dowidth. var sizeCalculationDisabled bool 

Aqui, um comentário informa ao leitor que a função dowidth responsável por manter o estado de sizeCalculationDisabled .

Dica . Esconder à vista. Este é o conselho de Kate Gregory . Às vezes, o melhor nome para uma variável está oculto nos comentários.

 //   SQL var registry = make(map[string]*sql.Driver) 

Um comentário foi adicionado pelo autor porque o registry nomes não explica suficientemente sua finalidade - este é um registro, mas qual é o registro?

Se você renomear uma variável como sqlDrivers, fica claro que ela contém drivers SQL.

 var sqlDrivers = make(map[string]*sql.Driver) 

Agora o comentário tornou-se redundante e pode ser excluído.

3.2 Sempre documente caracteres publicamente disponíveis


A documentação do seu pacote é gerada pelo godoc, portanto, você deve adicionar um comentário a cada caractere público declarado no pacote: uma variável, constante, função e método.

Aqui estão duas diretrizes do Guia de estilos do Google:

  • Qualquer função pública que não seja óbvia nem concisa deve ser comentada.
  • Qualquer função na biblioteca deve ser comentada, independentemente do tamanho ou complexidade.


 package ioutil // ReadAll   r      (EOF)   // ..    err == nil, not err == EOF. //  ReadAll     ,     //  . func ReadAll(r io.Reader) ([]byte, error) 

Há uma exceção a esta regra: você não precisa documentar métodos que implementam a interface. Especificamente, não faça isso:

 // Read   io.Reader func (r *FileReader) Read(buf []byte) (int, error) 

Este comentário não significa nada. Ele não diz o que o método faz: pior, ele envia para algum lugar para procurar documentação. Nesta situação, proponho excluir completamente o comentário.

Aqui está um exemplo do pacote io .

 // LimitReader  Reader,    r, //    EOF  n . //   *LimitedReader. func LimitReader(r Reader, n int64) Reader { return &LimitedReader{r, n} } // LimitedReader   R,     //   N .   Read  N  //    . // Read  EOF,  N <= 0    R  EOF. type LimitedReader struct { R Reader // underlying reader N int64 // max bytes remaining } func (l *LimitedReader) Read(p []byte) (n int, err error) { if lN <= 0 { return 0, EOF } if int64(len(p)) > lN { p = p[0:lN] } n, err = lRRead(p) lN -= int64(n) return } 

Observe que a declaração LimitedReader é imediatamente precedida pela função que a usa e a declaração LimitedReader.Read segue a declaração do LimitedReader . Embora o LimitedReader.Read si não esteja documentado, é possível entender que essa é uma implementação do io.Reader .

Dica . Antes de escrever uma função, escreva um comentário descrevendo-a. Se você acha difícil escrever um comentário, isso é um sinal de que o código que você está prestes a escrever será difícil de entender.

3.2.1 Não comente sobre código incorreto, reescreva-o


“Não comente código incorreto - reescreva-o” - Brian Kernighan

Não basta indicar nos comentários a dificuldade do fragmento de código. Se você se deparar com um desses comentários, inicie um ticket com um lembrete de refatoração. Você pode viver com dívida técnica, desde que seu valor seja conhecido.

Na biblioteca padrão, é habitual deixar comentários no estilo TODO com o nome do usuário que notou o problema.

 // TODO(dfc)  O(N^2),     . 

Isso não é uma obrigação para corrigir o problema, mas o usuário indicado pode ser a melhor pessoa para fazer uma pergunta. Outros projetos acompanham o TODO com uma data ou número do ticket.

3.2.2 Em vez de comentar o código, refatore-o


“Bom código é a melhor documentação. Quando você estiver prestes a adicionar um comentário, faça a si mesmo a pergunta: “Como melhorar o código para que este comentário não seja necessário?” Refatore e deixe um comentário para torná-lo ainda mais claro. ” - Steve McConnell

As funções devem executar apenas uma tarefa. Se você deseja escrever um comentário porque algum fragmento não está relacionado ao restante da função, considere extraí-lo em uma função separada.

Recursos menores não são apenas mais claros, mas mais fáceis de testar separadamente. Quando você isolou o código em uma função separada, seu nome pode substituir um comentário.

4. Estrutura do pacote


“Escreva um código modesto: módulos que não mostram nada supérfluo para outros módulos e que não dependem da implementação de outros módulos” - Dave Thomas

Cada pacote é essencialmente um pequeno programa Go separado. Assim como a implementação de uma função ou método não importa para quem chama, a implementação das funções, métodos e tipos que compõem a API pública do seu pacote não importa.

Um bom pacote Go busca uma conectividade mínima com outros pacotes no nível do código-fonte, para que, à medida que o projeto cresça, as alterações em um pacote não sejam distribuídas em cascata por toda a base de código. Tais situações inibem muito os programadores que trabalham nessa base de código.

Nesta seção, falaremos sobre o design de pacotes, incluindo seu nome e dicas para escrever métodos e funções.

4.1 Um bom pacote começa com um bom nome


Um bom pacote Go começa com um nome de qualidade. Pense nisso como uma breve apresentação limitada a apenas uma palavra.

Como os nomes das variáveis ​​na seção anterior, o nome do pacote é muito importante. Não há necessidade de pensar nos tipos de dados deste pacote; é melhor fazer a pergunta: "Que serviço esse pacote fornece?" Normalmente, a resposta não é "Este pacote fornece o tipo X", mas "Este pacote permite que você se conecte via HTTP".

Dica . Escolha um nome de pacote por sua funcionalidade, não por seu conteúdo.

4.1.1 Os bons nomes de pacotes devem ser exclusivos


Cada pacote tem um nome exclusivo no projeto. Não há dificuldade se você seguiu o conselho de dar nomes para os fins dos pacotes. Se os dois pacotes tiverem o mesmo nome, provavelmente:

  1. .
  2. . , .

4.2 base , common util


Um motivo comum para nomes incorretos são os chamados pacotes de serviços , onde, com o tempo, vários ajudantes e códigos de serviço se acumulam. Como é difícil encontrar um nome único lá. Isso geralmente leva ao fato de que o nome do pacote é derivado do que ele contém : utilitários.

Nomes como utilsou helperssão geralmente encontrados em grandes projetos, nos quais uma hierarquia profunda de pacotes está enraizada e as funções auxiliares são compartilhadas. Se você extrair alguma função em um novo pacote, a importação será interrompida. Nesse caso, o nome do pacote não reflete o objetivo do pacote, mas apenas o fato de a função de importação falhar devido à organização incorreta do projeto.

Em tais situações, recomendo analisar de onde os pacotes são chamados.utils helperse, se possível, mova as funções correspondentes para o pacote de chamada. Mesmo que isso implique duplicação de algum código auxiliar, é melhor do que introduzir uma dependência de importação entre dois pacotes.

“[Um pouco] duplicação é muito mais barata que uma abstração errada” - Sandy Mets

Se funções utilitárias são usadas em muitos lugares, em vez de um pacote monolítico com funções utilitárias, é melhor criar vários pacotes, cada um dos quais se concentra em um aspecto.

Dica . Use o plural para pacotes de serviços. Por exemplo, stringspara utilitários de processamento de cadeia.

Pacotes com nomes como baseou commonsão frequentemente encontrados quando uma certa funcionalidade comum de duas ou mais implementações ou tipos comuns para um cliente e um servidor é mesclada em um pacote separado. Acredito que, nesses casos, é necessário reduzir o número de pacotes combinando cliente, servidor e código comum em um pacote com um nome que corresponda à sua função.

Por exemplo, para net/httpnão fazer os pacotes individuais cliente server, em vez disso, existem arquivos client.goe server.gocom os tipos de dados correspondentes, bem como transport.gopara o transporte total.

Dica . É importante lembrar que o nome do identificador inclui o nome do pacote.

  • Uma função Getde um pacote net/httpse torna um http.Getlink de outro pacote.
  • Um tipo Readerde um pacote é stringstransformado quando importado para outros pacotes strings.Reader.
  • A interface Errordo pacote está netclaramente associada a erros de rede.

4.3 Volte rapidamente sem mergulhar fundo


Como o Go não usa exceções no fluxo de controle, não há necessidade de se aprofundar no código para fornecer uma estrutura trye blocos de nível superior catch. Em vez de uma hierarquia multinível, o código Go desce a tela à medida que a função progride. Meu amigo Matt Ryer chama essa prática de "linha de visão" .

Isso é conseguido usando operadores de limite : blocos condicionais com uma pré-condição na entrada da função. Aqui está um exemplo do pacote bytes:

 func (b *Buffer) UnreadRune() error { if b.lastRead <= opInvalid { return errors.New("bytes.Buffer: UnreadRune: previous operation was not a successful ReadRune") } if b.off >= int(b.lastRead) { b.off -= int(b.lastRead) } b.lastRead = opInvalid return nil } 

Ao entrar na função UnreadRune, o estado é verificado b.lastReade, se a operação anterior não foi ReadRune, um erro é retornado imediatamente. O restante da função funciona com base no que é b.lastReadmaior que opInvalid.

Compare com a mesma função, mas sem o operador de limite:

 func (b *Buffer) UnreadRune() error { if b.lastRead > opInvalid { if b.off >= int(b.lastRead) { b.off -= int(b.lastRead) } b.lastRead = opInvalid return nil } return errors.New("bytes.Buffer: UnreadRune: previous operation was not a successful ReadRune") } 

O corpo de uma ramificação bem-sucedida mais provável é incorporado na primeira condição ife a condição para uma saída bem-sucedida return nildeve ser descoberta combinando cuidadosamente os colchetes de fechamento . A última linha da função agora retorna um erro e você precisa acompanhar a execução da função no colchete de abertura correspondente para descobrir como chegar a esse ponto.

Essa opção é mais difícil de ler, o que diminui a qualidade da programação e do suporte ao código; portanto, o Go prefere usar operadores de limite e retornar erros logo no início.

4.4 Tornar o valor nulo útil


Cada declaração de variável, assumindo a ausência de um inicializador explícito, será automaticamente inicializada com um valor correspondente ao conteúdo da memória zerada, ou seja, zero . O tipo de valor é determinado por uma das opções: para tipos numéricos - zero, para tipos de ponteiros - zero, o mesmo para fatias, mapas e canais.

A capacidade de sempre definir um valor padrão conhecido é importante para a segurança e a correção do seu programa e pode tornar seus programas Go mais fáceis e compactos. É isso que os programadores do Go têm em mente quando dizem: "Dê às estruturas um valor zero útil".

Considere um tipo sync.Mutexque contém dois campos inteiros que representam o estado interno do mutex. Esses campos são automaticamente nulos em qualquer declaração.sync.Mutex. Esse fato é levado em consideração no código, portanto, o tipo é adequado para uso sem inicialização explícita.

 type MyInt struct { mu sync.Mutex val int } func main() { var i MyInt // i.mu is usable without explicit initialisation. i.mu.Lock() i.val++ i.mu.Unlock() } 

Outro exemplo de um tipo com um valor nulo útil é bytes.Buffer. Você pode declarar e começar a escrever nele sem inicialização explícita.

 func main() { var b bytes.Buffer b.WriteString("Hello, world!\n") io.Copy(os.Stdout, &b) } 

O valor zero dessa estrutura significa que lenambos capsão iguais 0, y array, o ponteiro para a memória com o conteúdo da matriz de fatia de backup, valor nil. Isso significa que você não precisa cortar explicitamente, você pode simplesmente declarar.

 func main() { // s := make([]string, 0) // s := []string{} var s []string s = append(s, "Hello") s = append(s, "world") fmt.Println(strings.Join(s, " ")) } 

Nota . var s []stringsemelhante às duas linhas comentadas no topo, mas não idênticas a elas. Há uma diferença entre um valor de fatia nulo e um valor de fatia de comprimento zero. O código a seguir será impresso como falso.

 func main() { var s1 = []string{} var s2 []string fmt.Println(reflect.DeepEqual(s1, s2)) } 

Uma propriedade útil, embora inesperada, de variáveis ​​de ponteiro não inicializadas - ponteiros nulos - é a capacidade de chamar métodos em tipos que são nulos. Isso pode ser usado para fornecer facilmente valores padrão.

 type Config struct { path string } func (c *Config) Path() string { if c == nil { return "/usr/home" } return c.path } func main() { var c1 *Config var c2 = &Config{ path: "/export", } fmt.Println(c1.Path(), c2.Path()) } 

4.5 Evitar estado do nível do pacote


A chave para escrever programas de fácil suporte que estão fracamente conectados é que a alteração de um pacote deve ter uma baixa probabilidade de afetar outro pacote que não depende diretamente do primeiro.

Existem duas maneiras excelentes de obter conectividade fraca no Go:

  1. Use interfaces para descrever o comportamento exigido por funções ou métodos.
  2. Evite status global.

No Go, podemos declarar variáveis ​​no escopo de uma função ou método, bem como no escopo de um pacote. Quando uma variável está disponível publicamente, com um identificador com letra maiúscula, seu escopo é realmente global para todo o programa: qualquer pacote a qualquer momento vê o tipo e o conteúdo dessa variável.

O estado global mutável fornece uma estreita relação entre as partes independentes do programa, pois as variáveis ​​globais se tornam um parâmetro invisível para cada função no programa! Qualquer função que depende de uma variável global pode ser violada quando o tipo dessa variável é alterado. Qualquer função que depende do estado de uma variável global pode ser violada se outra parte do programa alterar essa variável.

Como reduzir a conectividade que uma variável global cria:

  1. Mova as variáveis ​​correspondentes como campos para as estruturas que precisam delas.
  2. Use interfaces para reduzir a conexão entre o comportamento e a implementação desse comportamento.

5. Estrutura do projeto


Vamos falar sobre como os pacotes são combinados em um projeto. Geralmente é um único repositório Git.

Como o pacote, cada projeto deve ter um objetivo claro. Se for uma biblioteca, deve fazer uma coisa, por exemplo, análise XML ou registro no diário. Você não deve combinar vários objetivos em um projeto, isso ajudará a evitar uma biblioteca assustadora common.

Dica . Na minha experiência, o repositório commonestá intimamente associado ao maior consumidor, e isso dificulta a correção de versões anteriores (correções de porta traseira) sem atualizar commono consumidor e o consumidor no estágio de bloqueio, o que leva a muitas mudanças não relacionadas, além de serem interrompidas pelo caminho API

Se você possui um aplicativo (aplicativo Web, controlador Kubernetes, etc.), o projeto pode ter um ou mais pacotes principais. Por exemplo, no meu controlador Kubernetes, há um pacote cmd/contourque serve como um servidor implantado em um cluster Kubernetes e como um cliente de depuração.

5.1 Menos pacotes, mas maiores


Na revisão de código, notei um dos erros típicos dos programadores que mudaram para o Go de outros idiomas: eles tendem a abusar de pacotes.

GO não fornecer o elaborado sistema de visibilidade: a linguagem não é suficiente modificadores de acesso, como no Java ( public, protected, privatee implícita default). Não há análogo de classes amigáveis ​​do C ++.

No Go, temos apenas dois modificadores de acesso: são identificadores públicos e privados, indicados pela primeira letra do identificador (maiúscula / minúscula). Se o identificador for público, seu nome começará com uma letra maiúscula e poderá ser referenciado por qualquer outro pacote Go.

Nota . Você pode ouvir as palavras "exportado" ou "não exportado" como sinônimos para público e privado.

Dado os recursos limitados de controle de acesso, quais métodos podem ser usados ​​para evitar hierarquias de pacotes excessivamente complexas?

Dica . Em cada pacote, além cmd/e internal/deve estar presente o código fonte.

Eu já disse várias vezes que é melhor preferir menos pacotes maiores. Sua posição padrão deve ser não criar um novo pacote. Isso faz com que muitos tipos se tornem públicos, criando um escopo amplo e pequeno da API disponível. Abaixo, consideramos esta tese em mais detalhes.

Dica . Veio de Java?

Se você é do mundo Java ou C #, lembre-se da regra tácita: um pacote Java é equivalente a um único arquivo de origem .go. O pacote Go é equivalente a todo o módulo Maven ou assembly .NET.

5.1.1 Classificando o código por arquivo usando as instruções de importação


Se você organizar pacotes por serviço, faça o mesmo com os arquivos no pacote? Como saber quando dividir um arquivo .goem vários? Como você sabe se você foi longe demais e precisa pensar em mesclar arquivos?

Aqui estão as recomendações que eu uso:

  • Inicie cada pacote com um arquivo .go. Atribua a esse arquivo o mesmo nome que o diretório. Por exemplo, o pacote httpdeve estar no arquivo http.gono diretório http.
  • Conforme o pacote cresce, você pode dividir as várias funções em vários arquivos. Por exemplo, o arquivo messages.goconterá tipos Requeste Response, client.gotipo de Clientarquivo, server.goservidor de tipo de arquivo .
  • , . , .
  • . , messages.go HTTP- , http.go , client.go server.go — HTTP .

. .

. Go . ( — Go). .

5.1.2 Prefira testes internos a externos


A ferramenta gosuporta o pacote testingem dois lugares. Se você possui um pacote http2, pode escrever um arquivo http2_test.goe usar a declaração do pacote http2. Ele compila o código http2_test.go, como é parte do pacote http2. No discurso coloquial, esse teste é chamado interno.

A ferramenta gotambém suporta uma declaração de pacote especial que termina com teste , ou seja http_test. Isso permite que os arquivos de teste residam no mesmo pacote com o código, mas quando esses testes são compilados, eles não fazem parte do código do seu pacote, mas vivem em seu próprio pacote. Isso permite que você escreva testes como se outro pacote estivesse invocando seu código. Tais testes são chamados externos.

Eu recomendo usar testes internos para testes de unidade. Isso permite que você teste cada função ou método diretamente, evitando a burocracia de testes externos.

Mas é necessário colocar exemplos de funções de teste ( Example) em um arquivo de teste externo . Isso garante que, quando visualizados no godoc, os exemplos recebam o prefixo do pacote apropriado e possam ser facilmente copiados.

. , .

, , Go go . , net/http net .

.go , , .

5.1.3. , API


Se o seu projeto tiver vários pacotes, você poderá encontrar funções exportadas que devem ser usadas por outros pacotes, mas não para a API pública. Em tal situação, a ferramenta goreconhece um nome de pasta especial internal/que pode ser usado para colocar o código aberto para o seu projeto, mas fechado para outros.

Para criar esse pacote, coloque-o em um diretório com um nome internal/ou em seu subdiretório. Quando a equipe govê a importação do pacote com o caminho internal, verifica o local do pacote de chamada em um diretório ou subdiretório internal/.

Por exemplo, um pacote .../a/b/c/internal/d/e/fpode importar apenas um pacote de uma árvore de diretórios .../a/b/c, mas não de todo .../a/b/gou qualquer outro repositório (consultedocumentação ).

5.2 O menor pacote principal


Uma função maine um pacote maindevem ter funcionalidade mínima, porque main.mainage como um singleton: um programa pode ter apenas uma função main, incluindo testes.

Como main.mainé um singleton, há muitas restrições nos objetos chamados: eles são chamados apenas durante main.mainou main.inite apenas uma vez . Isso dificulta a escrita de testes de código main.main. Portanto, você precisa se esforçar para derivar o máximo de lógica possível da função principal e, idealmente, do pacote principal.

Dica . func main()deve analisar sinalizadores, abrir conexões com bancos de dados, registradores etc. e depois transferir a execução para um objeto de alto nível.

6. estrutura da API


O último conselho de design do projeto que considero o mais importante.

Todas as frases anteriores são, em princípio, não vinculativas. Estas são apenas recomendações baseadas na experiência pessoal. Eu não forço muito essas recomendações em uma revisão de código.

A API é outra questão: aqui, os erros são levados mais a sério, porque todo o resto pode ser corrigido sem quebrar a compatibilidade com versões anteriores: na maior parte, esses são apenas detalhes de implementação.

Quando se trata de APIs públicas, vale a pena considerar seriamente a estrutura desde o início, porque as alterações subsequentes serão destrutivas para os usuários.

6.1 APIs de design difíceis de abusar por design


“As APIs devem ser simples para uso adequado e difíceis para incorretas” - Josh Bloch

O conselho de Josh Bloch é talvez o mais valioso neste artigo. Se for difícil usar a API para coisas simples, todas as chamadas à API serão mais complicadas do que o necessário. Quando uma chamada de API é complexa e não óbvia, é provável que seja ignorada.

6.1.1 Cuidado com as funções que aceitam vários parâmetros do mesmo tipo.


Um bom exemplo de uma API simples à primeira vista, mas difícil de usar, é quando ela requer dois ou mais parâmetros do mesmo tipo. Compare duas assinaturas de função:

 func Max(a, b int) int func CopyFile(to, from string) error 

Qual é a diferença entre essas duas funções? Obviamente, um retorna no máximo dois números e o outro copia o arquivo. Mas este não é o ponto.

 Max(8, 10) // 10 Max(10, 8) // 10 

Max é comutativo : a ordem dos parâmetros não importa. Um máximo de oito e dez é dez, independentemente de oito e dez ou dez e oito serem comparados.

Mas no caso do CopyFile, não é assim.

 CopyFile("/tmp/backup", "presentation.md") CopyFile("presentation.md", "/tmp/backup") 

Quais desses operadores farão backup da sua apresentação e quais serão substituídos pela versão da semana passada? Você não pode saber até verificar a documentação. No curso da revisão de código, não está claro se a ordem dos argumentos está correta ou não. Mais uma vez, veja a documentação.

Uma solução possível é a introdução de um tipo auxiliar responsável pela chamada correta CopyFile.

 type Source string func (src Source) CopyTo(dest string) error { return CopyFile(dest, string(src)) } func main() { var from Source = "presentation.md" from.CopyTo("/tmp/backup") } 

É CopyFilesempre chamado corretamente aqui - isso pode ser declarado usando um teste de unidade - e pode ser feito em particular, o que reduz ainda mais a probabilidade de uso incorreto.

Dica . Uma API com vários parâmetros do mesmo tipo é difícil de usar corretamente.

6.2 Projetar uma API para um caso de uso básico


Alguns anos atrás, fiz uma apresentação sobre o uso de opções funcionais para facilitar a API por padrão.

A essência da apresentação foi que você deveria desenvolver uma API para o caso de uso principal. Em outras palavras, a API não deve exigir que o usuário forneça parâmetros extras que não lhe interessam.

6.2.1 O uso de nil como parâmetro não é recomendado


Comecei dizendo que você não deve forçar o usuário a fornecer parâmetros de API que não lhe interessam. Isso significa projetar as APIs para o caso de uso principal (opção padrão).

Aqui está um exemplo do pacote net / http.

 package http // ListenAndServe listens on the TCP network address addr and then calls // Serve with handler to handle requests on incoming connections. // Accepted connections are configured to enable TCP keep-alives. // // The handler is typically nil, in which case the DefaultServeMux is used. // // ListenAndServe always returns a non-nil error. func ListenAndServe(addr string, handler Handler) error { 

ListenAndServeaceita dois parâmetros: um endereço TCP para escutar nas conexões recebidas e http.Handlerpara processar uma solicitação HTTP recebida. Servepermite que o segundo parâmetro seja nil. Nos comentários, note-se que geralmente o objeto de chamada realmente passa nil, indicando um desejo de usá-lo http.DefaultServeMuxcomo um parâmetro implícito.

Agora o chamador Servetem duas maneiras de fazer o mesmo.

 http.ListenAndServe("0.0.0.0:8080", nil) http.ListenAndServe("0.0.0.0:8080", http.DefaultServeMux) 

Ambas as opções fazem a mesma coisa.

Este aplicativo se nilespalha como um vírus. O pacote também httppossui um auxiliar http.Serve, para que você possa imaginar a estrutura da função ListenAndServe:

 func ListenAndServe(addr string, handler Handler) error { l, err := net.Listen("tcp", addr) if err != nil { return err } defer l.Close() return Serve(l, handler) } 

Como ListenAndServeele permite que o chamador passe nilpara o segundo parâmetro, http.Servetambém suporta esse comportamento. De fato, está na http.Servelógica implementada "se o manipulador for igual nil, use DefaultServeMux". A aceitação nilde um parâmetro pode levar o chamador a pensar que pode ser passado nilpara ambos os parâmetros. Mas talServe

 http.Serve(nil, nil) 

leva a um pânico terrível.

Dica . Não misture parâmetros na mesma assinatura de função nile não nil.

O autor http.ListenAndServetentou simplificar a vida dos usuários da API para o caso padrão, mas a segurança foi afetada.

Na presença, nilnão há diferença no número de linhas entre uso explícito e indireto DefaultServeMux.

  const root = http.Dir("/htdocs") http.Handle("/", http.FileServer(root)) http.ListenAndServe("0.0.0.0:8080", nil) 

comparado com

  const root = http.Dir("/htdocs") http.Handle("/", http.FileServer(root)) http.ListenAndServe("0.0.0.0:8080", http.DefaultServeMux) 

Valeu a confusão manter uma linha?

  const root = http.Dir("/htdocs") mux := http.NewServeMux() mux.Handle("/", http.FileServer(root)) http.ListenAndServe("0.0.0.0:8080", mux) 

Dica . Pense seriamente em quanto tempo as funções auxiliares salvarão o programador. Clareza é melhor que concisão.

Dica . Evite APIs públicas com parâmetros que somente os testes precisam. Evite exportar APIs com parâmetros cujos valores diferem apenas durante o teste. Em vez disso, as funções do wrapper de exportação que ocultam a transferência desses parâmetros e nos testes usam funções auxiliares semelhantes que passam os valores necessários para o teste.

6.2.2 Use argumentos de comprimento variável em vez de [] T


Muitas vezes, uma função ou método usa uma fatia dos valores.

 func ShutdownVMs(ids []string) error 

Este é apenas um exemplo inventado, mas isso é muito comum. O problema é que essas assinaturas assumem que serão chamadas com mais de um registro. Como mostra a experiência, eles geralmente são chamados com apenas um argumento, que deve ser "empacotado" dentro da fatia para atender aos requisitos da assinatura da função.

Além disso, como o parâmetro idsé uma fatia, você pode passar uma fatia vazia ou zero para a função e o compilador ficará feliz. Isso adiciona uma carga extra de teste, pois o teste deve cobrir esses casos.

Para dar um exemplo dessa classe de API, refatorei recentemente a lógica que exigia a instalação de alguns campos adicionais se pelo menos um dos parâmetros fosse diferente de zero. A lógica era algo assim:

 if svc.MaxConnections > 0 || svc.MaxPendingRequests > 0 || svc.MaxRequests > 0 || svc.MaxRetries > 0 { // apply the non zero parameters } 

Como o operador estava ifficando muito longo, eu queria colocar a lógica de validação em uma função separada. Aqui está o que eu vim com:

 // anyPostive indicates if any value is greater than zero. func anyPositive(values ...int) bool { for _, v := range values { if v > 0 { return true } } return false } 

Isso tornou possível indicar claramente a condição sob a qual a unidade interna será executada:

 if anyPositive(svc.MaxConnections, svc.MaxPendingRequests, svc.MaxRequests, svc.MaxRetries) { // apply the non zero parameters } 

No entanto, há um problema com anyPositive, alguém poderia acidentalmente chamá-lo assim:

 if anyPositive() { ... } 

Nesse caso, anyPositiveretornará false. Esta não é a pior opção. Pior se anyPositiveretornado truena ausência de argumentos.

No entanto, seria melhor poder alterar a assinatura de anyPositive para garantir que pelo menos um argumento seja passado ao chamador. Isso pode ser feito combinando parâmetros para argumentos normais e argumentos de comprimento variável (varargs):

 // anyPostive indicates if any value is greater than zero. func anyPositive(first int, rest ...int) bool { if first > 0 { return true } for _, v := range rest { if v > 0 { return true } } return false } 

Agora anyPositivevocê não pode chamar com menos de um argumento.

6.3 Deixe as funções determinarem o comportamento desejado.


Suponha que me foi atribuída a tarefa de escrever uma função que preserva a estrutura Documentno disco.

 // Save      f. func Save(f *os.File, doc *Document) error 

Eu poderia escrever uma função Saveque grava Documentem um arquivo *os.File. Mas existem alguns problemas.

A assinatura Saveelimina a possibilidade de gravar dados pela rede. Se esse requisito aparecer no futuro, a assinatura da função precisará ser alterada, o que afetará todos os objetos de chamada.

SaveTambém é desagradável para o teste, uma vez que trabalha diretamente com os arquivos no disco. Portanto, para verificar seu funcionamento, o teste deve ler o conteúdo do arquivo após a gravação.

E eu tenho que garantir que ele seja fgravado em uma pasta temporária e posteriormente excluído.

*os.Filetambém define muitos métodos que não estão relacionados a Save, por exemplo, ler diretórios e verificar se um caminho é um link simbólico. Bem, se a assinaturaSavedescreveu apenas as partes relevantes *os.File.

O que pode ser feito?

 // Save      // ReadWriterCloser. func Save(rwc io.ReadWriteCloser, doc *Document) error 

Com a ajuda io.ReadWriteCloserdele, você pode aplicar o princípio de separação da interface - e redefini-lo Saveem uma interface que descreva as propriedades mais gerais do arquivo.

Após essa alteração, qualquer tipo que implemente a interface io.ReadWriteCloserpode ser substituído pelo anterior *os.File.

Isso expande simultaneamente o escopo Savee esclarece ao chamador quais métodos de tipo *os.Fileestão relacionados à sua operação.

E o autor Savenão pode mais chamar esses métodos não relacionados *os.File, porque ele está oculto por trás da interface io.ReadWriteCloser.

Mas podemos estender o princípio da separação de interface ainda mais.

Em primeiro lugar seSave segue o princípio da responsabilidade única, é improvável que ele leia o arquivo que acabou de escrever para verificar seu conteúdo - outro código deve fazer isso.

 // Save      // WriteCloser. func Save(wc io.WriteCloser, doc *Document) error 

Portanto, você pode restringir as especificações da interface para Saveapenas escrever e fechar.

Em segundo lugar, o mecanismo de fechamento do encadeamento y Saveé um legado do tempo em que trabalhou com o arquivo. A questão é: em que circunstâncias wcele será fechado.

Se a Savecausa Closeincondicionalmente, quer no caso de sucesso.

Isso apresenta um problema para o chamador, pois ele pode querer adicionar dados ao fluxo após a gravação do documento.

 // Save      // Writer. func Save(w io.Writer, doc *Document) error 

A melhor opção é redefinir Salvar para trabalhar apenas io.Writer, salvando o operador de todas as outras funcionalidades, exceto para gravar dados no fluxo.

Depois de aplicar o princípio de separação de interface, a função tornou-se ao mesmo tempo mais específica em termos de requisitos (precisa apenas de um objeto onde possa ser gravada) e mais geral em termos de funcionalidade, já que agora podemos usá-la Savepara salvar dados onde quer que seja implementada io.Writer.

7. Tratamento de erros


Fiz várias apresentações e escrevi muito sobre esse tópico no blog, então não vou repeti-lo. Em vez disso, quero cobrir duas outras áreas relacionadas ao tratamento de erros.



7.1 Elimine a necessidade de tratamento de erros removendo os próprios erros


Fiz muitas sugestões para melhorar a sintaxe de manipulação de erros, mas a melhor opção é não lidar com elas.

Nota . Não digo "excluir tratamento de erros". Sugiro alterar o código para que não haja erros no processamento.

O recente livro de filosofia de desenvolvimento de software de John Osterhout me inspirou a fazer essa sugestão . Um dos capítulos é intitulado "Eliminar erros da realidade". Vamos tentar aplicar este conselho.

7.1.1 Contagem de linhas


Escreveremos uma função para contar o número de linhas em um arquivo.

 func CountLines(r io.Reader) (int, error) { var ( br = bufio.NewReader(r) lines int err error ) for { _, err = br.ReadString('\n') lines++ if err != nil { break } } if err != io.EOF { return 0, err } return lines, nil } 

Conforme seguimos o conselho das seções anteriores, CountLinesaceita io.Reader, não *os.File; já é tarefa do chamador fornecer io.Readercujo conteúdo queremos contar.

Criamos bufio.Readere, em seguida, chamamos o método em um loop ReadString, aumentando o contador, até chegarmos ao final do arquivo e retornamos o número de linhas lidas.

Pelo menos queremos escrever esse código, mas a função está sobrecarregada com o tratamento de erros. Por exemplo, há uma construção tão estranha:

  _, err = br.ReadString('\n') lines++ if err != nil { break } 

Aumentamos o número de linhas antes de verificar se há erros - isso parece estranho.

A razão pela qual devemos escrever dessa maneira é porque ele ReadStringretornará um erro se encontrar o final do arquivo antes do caractere de nova linha. Isso pode acontecer se não houver nova linha no final do arquivo.

Para tentar corrigir isso, altere a lógica do contador de linhas e veja se precisamos sair do loop.

Nota . Essa lógica ainda não é perfeita, você pode encontrar um erro?

Mas ainda não terminamos de verificar se há erros. ReadStringretornará io.EOFquando encontrar o final do arquivo. Essa é a situação esperada, portanto, para ReadStringvocê precisar fazer alguma maneira de dizer "pare, não há mais nada para ler". Portanto, antes de retornar o erro ao objeto de chamada CountLine, é necessário verificar se o erro não está relacionado io.EOFe depois transmiti-lo; caso contrário, retornamos nile dizemos que está tudo bem.

Penso que este é um bom exemplo da tese de Russ Cox sobre como o tratamento de erros pode ocultar a função. Vejamos a versão melhorada.

 func CountLines(r io.Reader) (int, error) { sc := bufio.NewScanner(r) lines := 0 for sc.Scan() { lines++ } return lines, sc.Err() } 

Esta versão aprimorada usa em seu bufio.Scannerlugar bufio.Reader.

Sob o capô bufio.Scannerusa bufio.Reader, mas adiciona um bom nível de abstração, o que ajuda a remover o tratamento de erros.

. bufio.Scanner , .

O método sc.Scan()retornará um valor truese o scanner encontrou uma sequência e não encontrou um erro. Portanto, o corpo do loop é forchamado apenas se houver uma linha de texto no buffer do scanner. Isso significa que o novo CountLineslida com casos quando não há nova linha ou quando o arquivo está vazio.

Em segundo lugar, como sc.Scanretorna falsequando um erro é detectado, o ciclo fortermina quando atinge o final do arquivo ou um erro é detectado. O tipo bufio.Scannerlembra o primeiro erro encontrado e, usando o método sc.Err(), podemos restaurar esse erro assim que sairmos do loop.

Por fim, ele sc.Err()cuida do processamento io.EOFe o converte para nilse o final do arquivo for alcançado sem erros.

Dica . Se você encontrar um tratamento excessivo de erros, tente extrair algumas operações para um tipo auxiliar.

7.1.2 Writeresponse


Meu segundo exemplo é inspirado no post "Erros são valores" .

Anteriormente, vimos exemplos de como um arquivo é aberto, gravado e fechado. Há manipulação de erros, mas não é demais, porque as operações podem ser encapsuladas em auxiliares, como ioutil.ReadFilee ioutil.WriteFile. Porém, ao trabalhar com protocolos de rede de baixo nível, é necessário criar uma resposta diretamente usando as primitivas de E / S. Nesse caso, a manipulação de erros pode se tornar intrusiva. Considere um fragmento de um servidor HTTP que cria uma resposta HTTP.

 type Header struct { Key, Value string } type Status struct { Code int Reason string } func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error { _, err := fmt.Fprintf(w, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason) if err != nil { return err } for _, h := range headers { _, err := fmt.Fprintf(w, "%s: %s\r\n", h.Key, h.Value) if err != nil { return err } } if _, err := fmt.Fprint(w, "\r\n"); err != nil { return err } _, err = io.Copy(w, body) return err } 

Primeiro, construa a barra de status fmt.Fprintfe verifique o erro. Então, para cada cabeçalho, escrevemos um valor de chave e cabeçalho, sempre verificando um erro. Por fim, concluímos a seção do cabeçalho com outra \r\n, verificamos o erro e copiamos o corpo da resposta para o cliente. Finalmente, embora não seja necessário verificar o erro io.Copy, precisamos convertê-lo de dois valores de retorno para o único que retornar WriteResponse.

Isso é muito trabalho monótono. Mas você pode facilitar sua tarefa aplicando um pequeno tipo de wrapper errWriter.

errWritersatisfaz o contrato io.Writer, para que possa ser usado como invólucro. errWriterpassa registros pela função até que um erro seja detectado. Nesse caso, ele rejeita as entradas e retorna o erro anterior.

 type errWriter struct { io.Writer err error } func (e *errWriter) Write(buf []byte) (int, error) { if e.err != nil { return 0, e.err } var n int n, e.err = e.Writer.Write(buf) return n, nil } func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error { ew := &errWriter{Writer: w} fmt.Fprintf(ew, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason) for _, h := range headers { fmt.Fprintf(ew, "%s: %s\r\n", h.Key, h.Value) } fmt.Fprint(ew, "\r\n") io.Copy(ew, body) return ew.err } 

Se você aplicar errWriterpara WriteResponse, a clareza de código melhorou significativamente. Você não precisa mais verificar se há erros em cada operação individual. A mensagem de erro é movida para o final da função como uma verificação de campo ew.err, evitando a tradução irritante dos valores io.Copy retornados.

7.2 Manipule o erro apenas uma vez


Finalmente, quero observar que os erros devem ser tratados apenas uma vez. Processar significa verificar o significado do erro e tomar uma única decisão.

 // WriteAll writes the contents of buf to the supplied writer. func WriteAll(w io.Writer, buf []byte) { w.Write(buf) } 

Se você tomar menos de uma decisão, ignorará o erro. Como vemos aqui, o erro de é w.WriteAllignorado.

Mas tomar mais de uma decisão em resposta a um erro também está errado. Abaixo está o código que geralmente encontro.

 func WriteAll(w io.Writer, buf []byte) error { _, err := w.Write(buf) if err != nil { log.Println("unable to write:", err) // annotated error goes to log file return err // unannotated error returned to caller } return nil } 

Neste exemplo, se ocorrer um erro durante o tempo w.Write, a linha será gravada no log e também retornada ao chamador, que também poderá registrá-lo e transmiti-lo, até o nível superior do programa.

Provavelmente, o chamador faz o mesmo:

 func WriteConfig(w io.Writer, conf *Config) error { buf, err := json.Marshal(conf) if err != nil { log.Printf("could not marshal config: %v", err) return err } if err := WriteAll(w, buf); err != nil { log.Println("could not write config: %v", err) return err } return nil } 

Assim, uma pilha de linhas repetidas é criada no log.

 unable to write: io.EOF could not write config: io.EOF 

Mas na parte superior do programa você recebe um erro original sem nenhum contexto.

 err := WriteConfig(f, &conf) fmt.Println(err) // io.EOF 

Quero analisar este tópico com mais detalhes, porque não considero o problema de retornar um erro simultaneamente e registrar minhas preferências pessoais.

 func WriteConfig(w io.Writer, conf *Config) error { buf, err := json.Marshal(conf) if err != nil { log.Printf("could not marshal config: %v", err) // oops, forgot to return } if err := WriteAll(w, buf); err != nil { log.Println("could not write config: %v", err) return err } return nil } 

Costumo encontrar um problema que um programador esquece de retornar de um erro. Como dissemos anteriormente, o estilo de Go é usar operadores de limite, verificar os pré-requisitos à medida que a função é executada e retornar mais cedo.

Neste exemplo, o autor verificou o erro, registrou-o, mas esqueceu de retornar. Por isso, surge um problema sutil.

O contrato de tratamento de erros Go diz que, na presença de um erro, nenhuma suposição pode ser feita sobre o conteúdo de outros valores de retorno. Como o empacotamento JSON falhou, o conteúdo é bufdesconhecido: ele pode conter nada, mas pior, pode conter um fragmento JSON semi-escrito.

Como o programador esqueceu de retornar após verificar e registrar o erro, o buffer danificado será transferido WriteAll. É provável que a operação tenha êxito e, portanto, o arquivo de configuração não será gravado corretamente. No entanto, a função é concluída normalmente, e o único sinal de que ocorreu um problema é uma linha no log em que o empacotamento JSON falhou e não uma falha no registro de configuração.

7.2.1 Adicionando contexto a erros


Ocorreu um erro porque o autor estava tentando adicionar contexto à mensagem de erro. Ele tentou deixar uma marca para indicar a fonte do erro.

Vejamos outra maneira de fazer o mesmo fmt.Errorf.

 func WriteConfig(w io.Writer, conf *Config) error { buf, err := json.Marshal(conf) if err != nil { return fmt.Errorf("could not marshal config: %v", err) } if err := WriteAll(w, buf); err != nil { return fmt.Errorf("could not write config: %v", err) } return nil } func WriteAll(w io.Writer, buf []byte) error { _, err := w.Write(buf) if err != nil { return fmt.Errorf("write failed: %v", err) } return nil } 

Se você combinar o registro de erro com o retorno em uma linha, é mais difícil esquecer de retornar e evitar a continuação acidental.

Se ocorrer um erro de E / S durante a gravação do arquivo, o método Error()produzirá algo como isto:

 could not write config: write failed: input/output error 

7.2.2 Erro ao agrupar com github.com/pkg/errors


O padrão fmt.Errorffunciona bem para registrar mensagens de erro, mas o tipo de erro segue o caminho. Argumentei que o tratamento de erros como valores opacos é importante para projetos fracamente acoplados ; portanto, o tipo de erro de origem não deve importar se precisamos apenas trabalhar com seu valor:

  1. Verifique se não é zero.
  2. Exibi-lo na tela ou registrá-lo.

No entanto, acontece que você precisa restaurar o erro original. Para anotar esses erros, você pode usar algo como o meu pacote errors:

 func ReadFile(path string) ([]byte, error) { f, err := os.Open(path) if err != nil { return nil, errors.Wrap(err, "open failed") } defer f.Close() buf, err := ioutil.ReadAll(f) if err != nil { return nil, errors.Wrap(err, "read failed") } return buf, nil } func ReadConfig() ([]byte, error) { home := os.Getenv("HOME") config, err := ReadFile(filepath.Join(home, ".settings.xml")) return config, errors.WithMessage(err, "could not read config") } func main() { _, err := ReadConfig() if err != nil { fmt.Println(err) os.Exit(1) } } 

Agora a mensagem se torna um belo bug no estilo K & D:

 could not read config: open failed: open /Users/dfc/.settings.xml: no such file or directory 

e seu valor contém um link para o motivo original.

 func main() { _, err := ReadConfig() if err != nil { fmt.Printf("original error: %T %v\n", errors.Cause(err), errors.Cause(err)) fmt.Printf("stack trace:\n%+v\n", err) os.Exit(1) } } 

Assim, você pode restaurar o erro original e exibir o rastreamento da pilha:

 original error: *os.PathError open /Users/dfc/.settings.xml: no such file or directory stack trace: open /Users/dfc/.settings.xml: no such file or directory open failed main.ReadFile /Users/dfc/devel/practical-go/src/errors/readfile2.go:16 main.ReadConfig /Users/dfc/devel/practical-go/src/errors/readfile2.go:29 main.main /Users/dfc/devel/practical-go/src/errors/readfile2.go:35 runtime.main /Users/dfc/go/src/runtime/proc.go:201 runtime.goexit /Users/dfc/go/src/runtime/asm_amd64.s:1333 could not read config 

O pacote errorspermite adicionar contexto aos valores de erro em um formato conveniente para uma pessoa e uma máquina. Em uma apresentação recente, eu lhe disse que no próximo lançamento do Go, esse invólucro aparecerá na biblioteca padrão.

8. Concorrência


O Go geralmente é escolhido por causa de seus recursos de concorrência. Os desenvolvedores fizeram muito para aumentar sua eficiência (em termos de recursos de hardware) e desempenho, mas as funções de paralelismo da Go podem ser usadas para escrever código que não é produtivo nem confiável. No final do artigo, quero dar algumas dicas sobre como evitar algumas das armadilhas das funções simultâneas do Go.

O suporte de simultaneidade de primeira linha da Go é fornecido pelos canais, bem como instruções selectego. Se você estudou a teoria do Go em livros didáticos ou em uma universidade, deve ter notado que a seção paralelismo é sempre uma das últimas do curso. Nosso artigo não é diferente: decidi falar sobre paralelismo no final, como algo adicional às habilidades usuais que o programador do Go deve aprender.

Há uma certa dicotomia aqui, porque a principal característica do Go é o nosso modelo simples e fácil de paralelismo. Como produto, nossa linguagem se vende à custa de quase essa função. Por outro lado, a concorrência não é realmente tão fácil de usar; caso contrário, os autores não o tornariam o último capítulo de seus livros e não teríamos visto com pesar nosso código.

Esta seção discute algumas das armadilhas do uso ingênuo das funções de simultaneidade Go.

8.1 Faça algum trabalho o tempo todo.


Qual é o problema com este programa?

 package main import ( "fmt" "log" "net/http" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hello, GopherCon SG") }) go func() { if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatal(err) } }() for { } } 

O programa faz o que pretendemos: serve um servidor web simples. Ao mesmo tempo, gasta o tempo da CPU em um loop infinito, porque for{}na última linha ele mainbloqueia o gorutin main, sem executar nenhuma E / S, não há espera para bloquear, enviar ou receber mensagens ou algum tipo de conexão com o sheduler.

Como o tempo de execução do Go geralmente é atendido por um sheduler, esse programa é executado sem sentido no processador e pode terminar em um bloqueio ativo (live-lock).

Como consertar isso? Aqui está uma opção.

 package main import ( "fmt" "log" "net/http" "runtime" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hello, GopherCon SG") }) go func() { if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatal(err) } }() for { runtime.Gosched() } } 

Pode parecer bobagem, mas essa é uma solução comum que me ocorre na vida real. Este é um sintoma de um mal-entendido do problema subjacente.

Se você é um pouco mais experiente com o Go, pode escrever algo assim.

 package main import ( "fmt" "log" "net/http" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hello, GopherCon SG") }) go func() { if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatal(err) } }() select {} } 

Uma declaração vazia é selectbloqueada para sempre. Isso é útil, porque agora não giramos o processador inteiro apenas para uma chamada runtime.GoSched(). No entanto, tratamos apenas o sintoma, não a causa.

Quero lhe mostrar outra solução que, espero, já tenha ocorrido a você. Em vez de executar http.ListenAndServena goroutine, deixando o principal problema da goroutine, basta executar http.ListenAndServena goroutine principal.

Dica . Se você sair da função main.main, o programa Go será encerrado incondicionalmente, independentemente do que outras goroutines em execução durante a execução do programa façam.

 package main import ( "fmt" "log" "net/http" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hello, GopherCon SG") }) if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatal(err) } } 

Portanto, este é o meu primeiro conselho: se a goroutine não puder progredir até que ele receba um resultado de outro, então é mais fácil fazer o trabalho sozinho, do que delegá-lo.

Isso geralmente elimina muitos rastreamentos de estados e manipulação de canais necessários para transferir o resultado da goroutina para o iniciador do processo.

Dica . Muitos programadores do Go abusam de goroutines, especialmente no início. Como tudo na vida, a chave do sucesso é a moderação.

8.2 Deixe o paralelismo para o chamador


Qual é a diferença entre as duas APIs?

 // ListDirectory returns the contents of dir. func ListDirectory(dir string) ([]string, error) 

 // ListDirectory returns a channel over which // directory entries will be published. When the list // of entries is exhausted, the channel will be closed. func ListDirectory(dir string) chan string 

Mencionamos as diferenças óbvias: o primeiro exemplo lê o diretório em uma fatia e, em seguida, retorna a fatia ou o erro inteiro se algo der errado. Isso acontece de forma síncrona, o chamador bloqueia ListDirectoryaté que todas as entradas do diretório sejam lidas. Dependendo do tamanho do diretório, pode levar muito tempo e potencialmente muita memória.

Considere o segundo exemplo. É um pouco mais como a programação Go clássica, aqui ListDirectoryretorna o canal através do qual as entradas do diretório serão transmitidas. Quando o canal está fechado, isso é um sinal de que não há mais entradas no catálogo. Como o preenchimento do canal ocorre após o retorno ListDirectory, pode-se assumir que as goroutines começam a preencher o canal.

Nota . Na segunda opção, não é necessário usar a goroutine: você pode selecionar um canal suficiente para armazenar todas as entradas do diretório sem bloquear, preenchê-lo, fechá-lo e devolvê-lo ao chamador. Mas isso é improvável, pois nesse caso os mesmos problemas surgirão ao usar uma grande quantidade de memória para armazenar em buffer todos os resultados no canal.

A versão do ListDirectorycanal tem mais dois problemas:

  • O uso de um canal fechado como um sinal que não há mais elementos a serem processados ListDirectorynão pode informar o chamador de um conjunto incompleto de elementos devido a um erro. O chamador não tem como transmitir a diferença entre um diretório vazio e um erro. Nos dois casos, parece que o canal será imediatamente fechado.
  • O chamador deve continuar lendo o canal quando estiver fechado, porque esta é a única maneira de entender que a goroutina de preenchimento do canal parou de funcionar. Essa é uma restrição séria ao uso ListDirectory: o chamador passa um tempo lendo do canal, mesmo que tenha recebido todos os dados necessários. Isso provavelmente é mais eficiente em termos de uso de memória para diretórios médios e grandes, mas o método não é mais rápido que o método baseado em fatia original.

Nos dois casos, a solução é usar um retorno de chamada: uma função que é chamada no contexto de cada entrada de diretório enquanto é executada.

 func ListDirectory(dir string, fn func(string)) 

Sem surpresa, a função filepath.WalkDirfunciona dessa maneira.

Dica . Se sua função iniciar a goroutine, você deverá fornecer ao chamador uma maneira de interromper explicitamente essa rotina. Geralmente, é mais fácil deixar o modo de execução assíncrona no chamador.

8.3 Nunca execute a goroutine sem saber quando vai parar


No exemplo anterior, a goroutine foi usada desnecessariamente. Mas um dos principais pontos fortes de Go são seus recursos de simultaneidade de primeira classe. De fato, em muitos casos, o trabalho paralelo é bastante apropriado e é necessário o uso de goroutines.

Esse aplicativo simples atende o tráfego http em duas portas diferentes: porta 8080 para tráfego de aplicativos e porta 8001 para acesso ao terminal /debug/pprof.

 package main import ( "fmt" "net/http" _ "net/http/pprof" ) func main() { mux := http.NewServeMux() mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { fmt.Fprintln(resp, "Hello, QCon!") }) go http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux) // debug http.ListenAndServe("0.0.0.0:8080", mux) // app traffic } 

Embora o programa seja simples, é a base de um aplicativo real.

O aplicativo em sua forma atual tem vários problemas que aparecerão à medida que crescem, então vamos examinar imediatamente alguns deles.

 func serveApp() { mux := http.NewServeMux() mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { fmt.Fprintln(resp, "Hello, QCon!") }) http.ListenAndServe("0.0.0.0:8080", mux) } func serveDebug() { http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux) } func main() { go serveDebug() serveApp() } 

manipuladores de quebra serveAppe serveDebugde funções distintas, temos os separou main.main. Nós também seguiu o conselho anterior e fez com que serveAppe serveDebugdeixar a tarefa para garantir o paralelismo do chamador.

Mas existem alguns problemas com o desempenho desse programa. Se sairmos serveAppe sairmos main.main, o programa será encerrado e será reiniciado pelo gerenciador de processos.

Dica . Assim como as funções em Go deixam paralelismo para o chamador, os aplicativos devem parar de monitorar seu estado e reiniciar o programa que os chamou. Não responsabilize seus aplicativos por reiniciarem: este procedimento é melhor manipulado de fora do aplicativo.

No entanto, ele serveDebuginicia em uma goroutina separada e, no caso de seu lançamento, a goroutine termina, enquanto o restante do programa continua. Seus desenvolvedores não gostarão do fato de que você não pode obter estatísticas de aplicativos porque o manipulador /debugparou de funcionar.

Precisamos garantir que o aplicativo seja fechado se alguma goroutina que o servir parar .

 func serveApp() { mux := http.NewServeMux() mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { fmt.Fprintln(resp, "Hello, QCon!") }) if err := http.ListenAndServe("0.0.0.0:8080", mux); err != nil { log.Fatal(err) } } func serveDebug() { if err := http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux); err != nil { log.Fatal(err) } } func main() { go serveDebug() go serveApp() select {} } 

Agora serverAppeles serveDebugverificam os erros ListenAndServee, se necessário, os chamam log.Fatal. Como os dois manipuladores trabalham em goroutines, traçamos a rotina principal em select{}.

Essa abordagem tem vários problemas:

  1. Se ele ListenAndServeretornar com um erro nil, não haverá chamada log.Fatale o serviço HTTP nessa porta será encerrado sem parar o aplicativo.
  2. log.Fatalchama os.Exitque sai incondicionalmente do programa; chamadas adiadas não funcionarão, outras goroutines não serão notificadas do fechamento, o programa simplesmente parará. Isso dificulta a gravação de testes para essas funções.

Dica . Use apenas log.Fatalem funções main.mainou init.

De fato, queremos transmitir qualquer erro que ocorra ao criador da goroutina, para que ele possa descobrir por que ela parou e concluiu o processo de maneira limpa.

 func serveApp() error { mux := http.NewServeMux() mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { fmt.Fprintln(resp, "Hello, QCon!") }) return http.ListenAndServe("0.0.0.0:8080", mux) } func serveDebug() error { return http.ListenAndServe("127.0.0.1:8001", http.DefaultServeMux) } func main() { done := make(chan error, 2) go func() { done <- serveDebug() }() go func() { done <- serveApp() }() for i := 0; i < cap(done); i++ { if err := <-done; err != nil { fmt.Println("error: %v", err) } } } 

O status de retorno da Goroutine pode ser obtido através do canal. O tamanho do canal é igual ao número de goroutines que queremos controlar, portanto, o envio ao canal donenão será bloqueado, pois isso bloqueará o desligamento das goroutines e causará um vazamento.

Como o canal donenão pode ser fechado com segurança, não podemos usar o idioma para o ciclo do canal for rangeaté que todas as goroutines tenham sido relatadas. Em vez disso, executamos todas as goroutines em execução em um ciclo, que é igual à capacidade do canal.

Agora temos uma maneira de sair corretamente de todas as goroutines e corrigir todos os erros que encontrarem. Resta apenas enviar um sinal para concluir o trabalho da primeira goroutina para todos os outros.

O apelo ahttp.Serversobre a conclusão, envolvi essa lógica em uma função auxiliar. O ajudante serveaceita o endereço e http.Handler, da mesma forma http.ListenAndServe, o canal stopque usamos para executar o método Shutdown.

 func serve(addr string, handler http.Handler, stop <-chan struct{}) error { s := http.Server{ Addr: addr, Handler: handler, } go func() { <-stop // wait for stop signal s.Shutdown(context.Background()) }() return s.ListenAndServe() } func serveApp(stop <-chan struct{}) error { mux := http.NewServeMux() mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { fmt.Fprintln(resp, "Hello, QCon!") }) return serve("0.0.0.0:8080", mux, stop) } func serveDebug(stop <-chan struct{}) error { return serve("127.0.0.1:8001", http.DefaultServeMux, stop) } func main() { done := make(chan error, 2) stop := make(chan struct{}) go func() { done <- serveDebug(stop) }() go func() { done <- serveApp(stop) }() var stopped bool for i := 0; i < cap(done); i++ { if err := <-done; err != nil { fmt.Println("error: %v", err) } if !stopped { stopped = true close(stop) } } } 

Agora, para cada valor no canal done, fechamos o canal stop, o que faz com que cada gorutina desse canal se feche http.Server. Por sua vez, isso leva a um retorno de todas as goroutinas restantes ListenAndServe. Quando todas as gorutinas em execução são interrompidas, main.maino processo termina e o processo é interrompido corretamente.

Dica . Escrever essa lógica por conta própria é um trabalho repetitivo e o risco de erros. Veja algo como este pacote que fará a maior parte do trabalho para você.

Source: https://habr.com/ru/post/pt441842/


All Articles