Fazendo o bem fazendo o mal: escrevendo código mal com Go, Parte 1

Dicas ruins para um programador Go


imagem

Após décadas de programação em Java, nos últimos anos trabalhei principalmente no Go. Trabalhar com o Go é ótimo, principalmente porque o código é muito fácil de seguir. Java simplificou o modelo de programação C ++, removendo herança múltipla, gerenciamento manual de memória e sobrecarga do operador. O Go faz o mesmo, continuando a avançar para um estilo de programação simples e direto, removendo completamente a herança e a sobrecarga de funções. Código simples é um código legível e código legível é um código suportado. E isso é ótimo para a empresa e meus funcionários.

Como em todas as culturas, o desenvolvimento de software tem suas próprias lendas, histórias que são recontadas pelo bebedouro. Todos nós ouvimos falar de desenvolvedores que, em vez de se concentrarem em criar um produto de qualidade, se concentram em proteger seu próprio trabalho de pessoas de fora. Eles não precisam de código suportado, porque isso significa que outras pessoas poderão entendê-lo e modificá-lo. É possível no Go? É possível tornar o código Go tão complicado? Eu direi imediatamente - essa não é uma tarefa fácil. Vejamos as opções possíveis.

Você pensa: “ Quanto você pode corroer o código em uma linguagem de programação? É possível escrever um código tão terrível no Go que seu autor se torne indispensável na empresa? »Não se preocupe. Quando eu era estudante, tinha um projeto no qual apoiava o código Lisp-e de outra pessoa, escrito por um estudante de graduação. De fato, ele conseguiu escrever o código Fortran-e usando Lisp. O código tinha algo parecido com isto:

(defun add-mult-pi (in1 in2) (setq a in1) (setq b in2) (setq c (+ ab)) (setq d (* 3.1415 c) d ) 

Havia dezenas de arquivos desse código. Ele era absolutamente terrível e absolutamente brilhante ao mesmo tempo. Passei meses tentando descobrir. Comparado a isso, escrever código incorreto no Go é apenas um cuspe.

Existem muitas maneiras diferentes de tornar seu código sem suporte, mas analisaremos apenas algumas. Para fazer o mal, você deve primeiro aprender a fazer o bem. Portanto, primeiro examinamos como os programadores "bons" do Go escrevem e depois examinamos como fazer o oposto.

Embalagem ruim


Pacotes são um tópico útil para começar. Como a organização de códigos pode prejudicar a legibilidade?

No Go, o nome do pacote é usado para se referir à entidade exportada (por exemplo, ` fmt.Println` ou` http.RegisterFunc` ). Como podemos ver o nome do pacote, os programadores "bons" Go garantem que esse nome descreva quais são as entidades exportadas. Não devemos ter pacotes util, porque nomes como ` util.JSONMarshal` não funcionarão para nós - precisamos do` json.Marshal` .

Os desenvolvedores "bons" do Go também não criam um pacote separado para o DAO ou modelo. Para quem não conhece este termo, um DAO é um " objeto de acesso a dados " - uma camada de código que interage com o banco de dados. Eu trabalhava para uma empresa em que 6 serviços Java importavam a mesma biblioteca DAO para acessar o mesmo banco de dados, que eles compartilhavam, porque " ... bem, você sabe, os microsserviços são os mesmos ... ".

Se você tiver um pacote separado com todos os seus DAOs, é mais provável que você obtenha uma dependência circular entre pacotes, o que é proibido no Go. E se você tiver vários serviços que conectam este pacote DAO como uma biblioteca, também poderá encontrar uma situação em que uma alteração em um serviço exija a atualização de todos os seus serviços; caso contrário, algo será interrompido. Isso é chamado de monólito distribuído e é incrivelmente difícil de atualizar.

Quando você sabe como a embalagem deve funcionar e o que a torna pior, "começar a servir ao mal" se torna simples. Organize mal o seu código e dê nomes ruins aos seus pacotes. Divida seu código em pacotes como model , util e dao . Se você realmente quer começar a criar o caos, tente criar pacotes em homenagem ao seu gato ou à sua cor favorita. Quando as pessoas se deparam com dependências cíclicas ou monólitos distribuídos devido à tentativa de usar seu código, você precisa suspirar, revirar os olhos e dizer a eles que eles simplesmente agem errado ...

Interfaces inadequadas


Agora que todos os nossos pacotes estão corrompidos, podemos seguir para as interfaces. As interfaces no Go não são como interfaces em outros idiomas. O fato de você não declarar explicitamente que esse tipo implementa a interface a princípio parece insignificante, mas, na verdade, reverte completamente o conceito de interface.

Na maioria dos idiomas com tipos abstratos, uma interface é definida antes ou ao mesmo tempo da implementação. Você terá que fazer isso pelo menos para testar. Se você não criar a interface antecipadamente, não poderá inseri-la posteriormente sem quebrar todo o código que usa essa classe. Porque você precisa reescrevê-lo com um link para a interface em vez de um tipo específico.

Por esse motivo, o código Java geralmente possui interfaces de serviço gigantescas com muitos métodos. As classes que implementam essas interfaces usam os métodos necessários e ignoram o restante. É possível escrever testes, mas você adiciona um nível adicional de abstração e, ao escrever testes, geralmente recorre ao uso de ferramentas para gerar implementações desses métodos que você não precisa.

No Go, interfaces implícitas determinam quais métodos você precisa usar. O código possui uma interface, e não o contrário. Mesmo se você usar um tipo com muitos métodos definidos, poderá especificar uma interface que inclua apenas os métodos necessários. Outro código usando campos separados do mesmo tipo definirá outras interfaces que cobrem apenas a funcionalidade necessária. Normalmente, essas interfaces têm apenas alguns métodos.

Isso facilita a compreensão do seu código, porque uma declaração de método não apenas determina quais dados ele precisa, mas também indica com precisão que funcionalidade será usada. Essa é uma das razões pelas quais bons desenvolvedores Go seguem os conselhos: " Aceite interfaces, retorne estruturas ".

Mas apenas porque essa é uma boa prática, não significa que você deve fazer isso ...
A melhor maneira de tornar suas interfaces "ruins" é retornar aos princípios de uso de interfaces de outros idiomas, ou seja, Defina interfaces com antecedência como parte do código chamado. Defina interfaces enormes com muitos métodos usados ​​por todos os clientes de serviço. Torna-se claro que métodos são realmente necessários. Isso complica o código, e a complicação, como você sabe, é o melhor amigo de um programador "maligno".

Passar ponteiros de heap


Antes de explicar o que isso significa, você precisa filosofar um pouco. Se você se distrair e pensar, cada programa escrito faz a mesma coisa. Ele recebe, processa e envia os dados processados ​​para outro local. É assim, independentemente de você escrever um sistema de folha de pagamento, aceitar solicitações HTTP e retornar páginas da Web ou até mesmo verificar o joystick para rastrear um clique no botão - os programas processam os dados.

Se observarmos os programas dessa maneira, a coisa mais importante a fazer é garantir que seja fácil entender como os dados são convertidos. Portanto, é uma boa prática manter os dados inalterados pelo maior tempo possível durante o programa. Porque os dados que não são alterados são fáceis de rastrear.

No Go, temos tipos de referência e tipos de valor. A diferença entre os dois é se a variável se refere a uma cópia dos dados ou ao local dos dados na memória. Ponteiros, fatias, mapas, canais, interfaces e funções são tipos de referência e todo o resto é um tipo de valor. Se você atribuir uma variável de tipo de valor a outra variável, ela criará uma cópia do valor; alterar uma variável não altera o valor de outra.

Atribuir uma variável de um tipo de referência a outra variável de um tipo de referência significa que os dois compartilham a mesma área de memória; portanto, se você alterar os dados apontados pelo primeiro, altere os dados apontados pelo segundo. Isso vale para variáveis ​​locais e parâmetros de função.

 func main() { //  a := 1 b := a b = 2 fmt.Println(a, b) // prints 1 2 //  c := &a *c = 3 fmt.Println(a, b, *c) // prints 3 2 3 } 

Os desenvolvedores do Kind Go querem facilitar a compreensão de como os dados são coletados. Eles tentam usar o tipo de valores como parâmetros de funções o mais rápido possível. Não há como ir para marcar campos em estruturas ou parâmetros de função como finais. Se uma função usar parâmetros de valor, alterar os parâmetros não alterará as variáveis ​​na função de chamada. Tudo o que a função chamada pode fazer é retornar o valor para a função de chamada. Portanto, se você preencher uma estrutura chamando uma função com parâmetros de valor, não poderá ter medo de transferir dados para a estrutura, porque entende de onde vieram cada campo na estrutura.

 type Foo struct { A int B string } func getA() int { return 20 } func getB(i int) string { return fmt.Sprintf("%d",i*2) } func main() { f := Foo{} fA = getA() fB = getB(fA) //  ,    f fmt.Println(f) } 

Bem, como nos tornamos "maus"? Muito simples - virar esse modelo.

Em vez de chamar funções que retornam os valores desejados, você passa um ponteiro para a estrutura na função e permite que eles façam alterações na estrutura. Como cada função tem sua própria estrutura, a única maneira de descobrir quais campos estão mudando é examinar o código inteiro. Você também pode ter dependências implícitas entre funções - a 1ª função transfere os dados necessários para a 2ª função. Mas no próprio código, nada indica que você deve primeiro chamar a 1ª função. Se você criar suas estruturas de dados dessa maneira, pode ter certeza de que ninguém entenderá o que seu código está fazendo.

 type Foo struct { A int B string } func setA(f *Foo) { fA = 20 } //   fA! func setB(f *Foo) { fB = fmt.Sprintf("%d", fA*2) } func main() { f := Foo{} setA(&f) setB(&f) // ,  setA  setB //    ? fmt.Println(f) } 

Superfície de pânico


Agora estamos começando a lidar com erros. Você provavelmente acha que é ruim escrever programas que lidam com erros em cerca de 75% e não vou dizer que você está errado. O código Go geralmente é preenchido com o tratamento de erros da cabeça aos pés. E, claro, seria conveniente processá-los de maneira não tão direta. Erros acontecem, e lidar com eles é o que diferencia os profissionais dos iniciantes. A manipulação de erros arrastada leva a programas instáveis ​​que são difíceis de depurar e difíceis de manter. Às vezes, ser um “bom” programador significa “sobrecarregar”.

 func (dus DBUserService) Load(id int) (User, error) { rows, err := dus.DB.Query("SELECT name FROM USERS WHERE ID = ?", id) if err != nil { return User{}, err } if !rows.Next() { return User{}, fmt.Errorf("no user for id %d", id) } var name string err = rows.Scan(&name) if err != nil { return User{}, err } err = rows.Close() if err != nil { return User{}, err } return User{Id: id, Name: name}, nil } 

Muitas linguagens, como C ++, Python, Ruby e Java, usam exceções para manipular erros. Se algo der errado, os desenvolvedores nessas linguagens lançam ou lançam uma exceção, esperando que haja algum código para lidar com isso. Obviamente, o programa espera que o cliente esteja ciente de um possível erro sendo lançado em um determinado local, para que seja possível lançar uma exceção. Como, exceto (sem trocadilhos) as exceções verificadas pelo Java, não há nada na assinatura do método nas linguagens ou funções para indicar que uma exceção pode ocorrer. Então, como os desenvolvedores sabem com quais exceções se preocupar? Eles têm duas opções:

  • Em primeiro lugar, eles podem ler todo o código-fonte de todas as bibliotecas que seu código chama e todas as bibliotecas que chamam as bibliotecas chamadas, etc.
  • Em segundo lugar, eles podem confiar na documentação. Posso ser tendencioso, mas a experiência pessoal não me permite confiar totalmente na documentação.

Então, como levamos esse mal para ir? Abuso de pânico ( pânico ) e recuperação ( recuperação ), é claro! O pânico foi criado para situações como "a unidade caiu" ou "a placa de rede explodiu". Mas não para isso - "alguém passou a string em vez de int".

Infelizmente, outros "desenvolvedores menos esclarecidos" retornarão erros de seu código. Portanto, aqui está uma pequena função auxiliar do PanicIfErr. Use-o para transformar os erros de outros desenvolvedores em pânico.

 func PanicIfErr(err error) { if err != nil { panic(err) } } 

Você pode usar o PanicIfErr para ocultar os erros de outras pessoas, compactar o código. Não há mais tratamento de erros feio! Qualquer erro agora é um pânico. É tão produtivo!

 func (dus DBUserService) LoadEvil(id int) User { rows, err := dus.DB.Query( "SELECT name FROM USERS WHERE ID = ?", id) PanicIfErr(err) if !rows.Next() { panic(fmt.Sprintf("no user for id %d", id)) } var name string PanicIfErr(rows.Scan(&name)) PanicIfErr(rows.Close()) return User{Id: id, Name: name} } 

Você pode colocar a recuperação em algum lugar mais próximo do início do programa, talvez em seu próprio middleware . E depois diga que você não apenas processa erros, mas também torna o código de outra pessoa mais limpo. Fazer o mal fazendo o bem é o melhor tipo de mal.

 func PanicMiddleware(h http.Handler) http.Handler { return http.HandlerFunc( func(rw http.ResponseWriter, req *http.Request){ defer func() { if r := recover(); r != nil { fmt.Println(", - .") } }() h.ServeHTTP(rw, req) } ) } 

Definir efeitos colaterais


Em seguida, criaremos um efeito colateral. Lembre-se de que o desenvolvedor "bom" do Go quer entender como os dados passam pelo programa. A melhor maneira de saber pelo que os dados passam é configurar dependências explícitas no aplicativo. Mesmo as entidades que correspondem à mesma interface podem variar bastante de comportamento. Por exemplo, um código que armazena dados na memória e um código que acessa o banco de dados para o mesmo trabalho. No entanto, existem maneiras de instalar dependências no Go sem chamadas explícitas.

Como muitos outros idiomas, o Go tem uma maneira de executar código magicamente sem invocá-lo diretamente. Se você criar uma função chamada init sem parâmetros, ela será iniciada automaticamente quando o pacote for carregado. E, para confundir ainda mais, se em um arquivo houver várias funções com o nome init ou vários arquivos em um pacote, todas elas serão iniciadas.

 package account type Account struct{ Id int UserId int } func init() { fmt.Println("  !") } func init() { fmt.Println("   ,     init()") } 

As funções de inicialização geralmente são associadas a importações vazias. O Go tem uma maneira especial de declarar importações, que se parece com `import _“ github.com / lib / pq`. Quando você define um identificador de nome vazio para um pacote importado, o método init é executado nele, mas não mostra nenhum dos identificadores de pacote. Para algumas bibliotecas Go - como drivers de banco de dados ou formatos de imagem - você deve carregá-las ativando a importação de pacotes vazios, apenas para chamar a função init para que o pacote possa registrar seu código.

 package main import _ "github.com/lib/pq" func main() { db, err := sql.Open( "postgres", "postgres://jon@localhost/evil?sslmode=disable") } 

E esta é claramente uma opção "má". Quando você usa a inicialização, o código que funciona magicamente fica completamente fora do controle do desenvolvedor. As práticas recomendadas não recomendam o uso das funções de inicialização - esses são recursos não óbvios, confundem o código e são fáceis de ocultar na biblioteca.

Em outras palavras, as funções init são ideais para nossos propósitos malignos. Em vez de configurar ou registrar explicitamente entidades em pacotes, você pode usar as funções de inicialização e importação vazia para configurar o estado do seu aplicativo. Neste exemplo, disponibilizamos a conta para o restante do aplicativo por meio do registro, e o próprio pacote é colocado no registro usando a função init.

 package account import ( "fmt" "github.com/evil-go/example/registry" ) type StubAccountService struct {} func (a StubAccountService) GetBalance(accountId int) int { return 1000000 } func init() { registry.Register("account", StubAccountService{}) } 

Se você deseja usar uma conta, coloque uma importação vazia no seu programa. Ele não precisa ser o código principal ou relacionado - apenas precisa estar "em algum lugar". Isso é mágico!

 package main import ( _ "github.com/evil-go/example/account" "github.com/evil-go/example/registry" ) type Balancer interface { GetBalance(int) int } func main() { a := registry.Get("account").(Balancer) money := a.GetBalance(12345) } 

Se você usar o inits em suas bibliotecas para configurar dependências, verá imediatamente que outros desenvolvedores estão intrigados sobre como essas dependências foram instaladas e como alterá-las. E ninguém será mais sábio que você.

Configuração complicada


Ainda há muito que podemos fazer com a configuração. Se você é um desenvolvedor Go "bom", você deseja isolar a configuração do restante do programa. Na função main (), você obtém variáveis ​​do ambiente e as converte nos valores necessários para componentes explicitamente relacionados entre si. Seus componentes não sabem nada sobre arquivos de configuração ou como são denominadas suas propriedades. Para componentes simples, você define propriedades públicas e, para outras mais complexas, pode criar uma função de fábrica que recebe informações de configuração e retorna um componente configurado corretamente.

 func main() { b, err := ioutil.ReadFile("account.json") if err != nil { fmt.Errorf("error reading config file: %v", err) os.Exit(1) } m := map[string]interface{}{} json.Unmarshal(b, &m) prefix := m["account.prefix"].(string) maker := account.NewMaker(prefix) } type Maker struct { prefix string } func (m Maker) NewAccount(name string) Account { return Account{Name: name, Id: m.prefix + "-12345"} } func NewMaker(prefix string) Maker { return Maker{prefix: prefix} } 

Mas os desenvolvedores "maus" sabem que é melhor espalhar as informações sobre a configuração por todo o programa. Em vez de ter uma função em um pacote que define os nomes e os tipos de valor para o seu pacote, use uma função que aceite a configuração e a converta por conta própria.

Se isso parecer "muito ruim", use a função init para carregar o arquivo de propriedades de dentro do seu pacote e defina os valores você mesmo. Pode parecer que você facilitou a vida de outros desenvolvedores, mas você e eu sabemos ...

Usando a função init, você pode definir novas propriedades na parte de trás do código, e ninguém as encontrará até que entrem em produção e tudo caia, porque algo não entrará em uma das dezenas de arquivos de propriedades necessários para executar. Se você quer ainda mais "poder maligno", pode sugerir a criação de um wiki para acompanhar todas as propriedades em todas as bibliotecas e "esquecer" periodicamente e adicionar novas. Como detentor de propriedades, você se torna a única pessoa que pode executar o software.

 func (m maker) NewAccount(name string) Account { return Account{Name: name, Id: m.prefix + "-12345"} } var Maker maker func init() { b, _ := ioutil.ReadFile("account.json") m := map[string]interface{}{} json.Unmarshal(b, &m) Maker.prefix = m["account.prefix"].(string) } 

Estruturas de funcionalidade


Finalmente, chegamos ao tópico de estruturas versus bibliotecas. A diferença é muito sutil. Não é apenas sobre tamanho; você pode ter bibliotecas grandes e estruturas pequenas. A estrutura chama seu código enquanto você mesmo chama o código da biblioteca. As estruturas exigem que você escreva seu código de uma certa maneira, seja nomeando seus métodos de acordo com regras específicas ou que correspondam a determinadas interfaces ou forçando você a registrar seu código na estrutura. As estruturas têm seus próprios requisitos para todo o seu código. Ou seja, em geral, as estruturas comandam você.

O Go incentiva o uso de bibliotecas porque as bibliotecas estão vinculadas. Embora, é claro, cada biblioteca espere que os dados sejam transmitidos em um formato específico, você pode escrever algum código de conexão para converter a saída de uma biblioteca em entrada para outra.
É difícil fazer com que as estruturas funcionem juntas sem problemas, porque todas as estruturas desejam controle completo sobre o ciclo de vida do código. Geralmente, a única maneira de fazer com que as estruturas funcionem juntas é que os autores da estrutura se reúnam e organizem claramente o apoio mútuo. E a melhor maneira de usar as "estruturas malignas" para obter poder a longo prazo é escrever sua própria estrutura, que é usada apenas dentro da empresa.

Mal atual e futuro


Tendo dominado esses truques, você sempre embarcará no caminho do mal. Na segunda parte, mostrarei como implantar todo esse "mal" e como transformar corretamente o código do "bom" em "mal".

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


All Articles