Como derrotar o dragão: reescreva seu programa no Golang

Aconteceu que seu programa foi escrito em uma linguagem de script - por exemplo, em Ruby - e houve a necessidade de reescrevê-lo em Golang.


Uma pergunta razoável: por que você pode precisar reescrever um programa que já foi escrito e funciona bem?



Primeiro, digamos que o programa esteja associado a um ecossistema específico - no nosso caso, são o Docker e o Kubernetes. Toda a infraestrutura desses projetos está escrita em Golang. Isso abre o acesso a bibliotecas que usam o Docker, Kubernetes e outros. Do ponto de vista do suporte, desenvolvimento e aprimoramento do seu programa, é mais lucrativo usar a mesma infraestrutura que os principais produtos. Nesse caso, todos os novos recursos estarão disponíveis imediatamente e você não precisará reimplementá-los em outro idioma. Somente essa condição em nossa situação específica foi suficiente para tomar uma decisão sobre a necessidade de mudar o idioma em princípio e sobre que tipo de idioma deveria ser. Existem, no entanto, outras vantagens ...


Em segundo lugar, a facilidade de instalar aplicativos no Golang. Você não precisa instalar o Rvm, Ruby, um conjunto de gemas etc. no sistema, precisa baixar um arquivo binário estático e usá-lo.


Em terceiro lugar, a velocidade dos programas no Golang é maior. Este não é um aumento sistêmico significativo na velocidade, obtido através da arquitetura e algoritmos corretos em qualquer idioma. Mas esse aumento é sentido quando você inicia o programa a partir do console. Por exemplo, --help em Ruby pode funcionar em 0,8 s, e em Golang - 0,02 s. Apenas melhora notavelmente a experiência do usuário ao usar o programa.


Nota : Como os leitores regulares do nosso blog poderiam ter adivinhado, o artigo é baseado na experiência de reescrever nosso produto dapp , que agora é - ainda não oficialmente (!) - conhecido como werf . Veja o final do artigo para mais detalhes.


Bom: você pode simplesmente pegar e escrever um novo código completamente isolado do código de script antigo. Mas imediatamente surgem algumas dificuldades e limitações de recursos e tempo alocado para o desenvolvimento:


  • A versão atual do programa em Ruby precisa constantemente de melhorias e correções:
    • Os erros ocorrem à medida que são usados ​​e devem ser corrigidos imediatamente;
    • Você não pode congelar a adição de novos recursos por seis meses, porque Esses recursos geralmente são exigidos por clientes / usuários.
  • Manter duas bases de código ao mesmo tempo é difícil e caro:
    • Existem poucas equipes de 2 a 3 pessoas, devido à presença de outros projetos além deste programa Ruby.
  • Introdução da nova versão:
    • Não deve haver degradação significativa na função;
    • Idealmente, isso deve ser contínuo e contínuo.

É necessário um processo de transferência contínua. Mas como posso fazer isso se a versão Golang estiver sendo desenvolvida como um programa independente?


Escrevemos em dois idiomas ao mesmo tempo


Mas e se você transferir componentes para Golang de baixo para cima? Começamos com coisas de baixo nível, depois subimos as abstrações.


Imagine que seu programa consiste nos seguintes componentes:


 lib/ config.rb build/ image.rb git_repo/ base.rb local.rb remote.rb docker_registry.rb builder/ base.rb shell.rb ansible.rb stage/ base.rb from.rb before_install.rb git.rb install.rb before_setup.rb setup.rb deploy/ kubernetes/ client.rb manager/ base.rb job.rb deployment.rb pod.rb 

Componente de porta com recursos


Um caso simples. Pegamos um componente existente que é bastante isolado do resto - por exemplo, config ( lib/config.rb ). Nesse componente, apenas a função Config::parse é definida, que pega o caminho para a configuração, lê e produz uma estrutura preenchida. Um binário separado na config do Golang e na config pacote correspondente serão responsáveis ​​por sua implementação:


 cmd/ config/ main.go pkg/ config/ config.go 

O binário Golang recebe os argumentos do arquivo JSON e gera o resultado no arquivo JSON.


 config -args-from-file args.json -res-to-file res.json 

Supõe- config que a config possa enviar mensagens para stdout / stderr (em nosso programa Ruby, a saída sempre vai para stdout / stderr, portanto esse recurso não é parametrizado).


Chamar o binário de config é equivalente a chamar alguma função do componente de config . Os argumentos no arquivo args.json indicam o nome da função e seus parâmetros. Na saída através do arquivo res.json , obtemos o resultado da função. Se a função retornar um objeto de alguma classe, os dados do objeto desta classe serão retornados no formato serializado JSON.


Por exemplo, para chamar a função Config::parse , especifique o seguinte args.json :


 { "command": "Parse", "configPath": "path-to-config.yaml" } 

res.json resultado em res.json :


 { "config": { "Images": [{"Name": "nginx"}, {"Name": "rails"}], "From": "ubuntu:16.04" }, } 

No campo config , obtemos o estado do objeto Config::Config serializado em JSON. Nesse estado, no chamador em Ruby, você precisa construir um objeto Config::Config .


No caso do erro fornecido , o binário pode retornar o seguinte JSON:


 { "error": "no such file path-to-config.yaml" } 

O campo de error deve ser tratado pelo chamador.


Chamando Golang de Ruby


No lado do Ruby, transformamos a função Config::parse(config_path) em um invólucro que chama nossa config , obtém o resultado, processa todos os erros possíveis. Aqui está um exemplo de pseudocódigo do Ruby com simplificações:


 module Config def parse(config_path) call_id = get_random_number args_file = "#{get_tmp_dir}/args.#{call_id}.json" res_file = "#{get_tmp_dir}/res.#{call_id}.json" args_file.write(JSON.dump( "command" => "Parse", "configPath" => config_path, )) system("config -args-from-file #{args_file} -res-to-file #{res_file}") raise "config failed with unknown error" if $?.exitstatus != 0 res = JSON.load_file(res_file) raise ParseError, res["error"] if res["error"] return Config.new_from_state(res["config"]) end end 

O binário pode travar com código inesperado diferente de zero - esta é uma situação excepcional. Ou com os códigos fornecidos - nesse caso, observamos o arquivo res.json quanto à presença dos campos de error e config e, como resultado, retornamos o objeto Config::Config partir do campo de config serializado.


Do ponto de vista do usuário, a função Config::Parse não mudou.


Classe de componente de porta


Por exemplo, considere a hierarquia de classes lib/git_repo . Existem 2 classes: GitRepo::Local e GitRepo::Remote . Faz sentido combinar sua implementação em um único binário git_repo e, consequentemente, empacotar git_repo no Golang.


 cmd/ git_repo/ main.go pkg/ git_repo/ base.go local.go remote.go 

Uma chamada para o binário git_repo corresponde a uma chamada para algum método do GitRepo::Local ou GitRepo::Remote . O objeto tem um estado e pode mudar após uma chamada de método. Portanto, nos argumentos, passamos o estado atual serializado em JSON. E na saída, sempre obtemos o novo estado do objeto - também em JSON.


Por exemplo, para chamar o local_repo.commit_exists?(commit) , especificamos o seguinte args.json :


 { "localGitRepo": { "name": "my_local_git_repo", "path": "path/to/git" }, "method": "IsCommitExists", "commit": "e43b1336d37478282693419e2c3f2d03a482c578" } 

A saída é res.json :


 { "localGitRepo": { "name": "my_local_git_repo", "path": "path/to/git" }, "result": true, } 

No campo localGitRepo , um novo estado do objeto é recebido (que pode não ser alterado). Devemos colocar esse estado no objeto Ruby atual local_git_repo qualquer maneira.


Chamando Golang de Ruby


No lado do Ruby, transformamos cada método das GitRepo::Base , GitRepo::Local , GitRepo::Remote em wrappers que chamam nosso git_repo , obtemos o resultado, definimos o novo estado do objeto da GitRepo::Local ou GitRepo::Remote .


Caso contrário, tudo é semelhante a chamar uma função simples.


Como lidar com o polimorfismo e as classes base


A maneira mais fácil é não apoiar o polimorfismo de Golang. I.e. certifique-se de que as chamadas para o binário git_repo sempre git_repo explicitamente endereçadas a uma implementação específica (se localGitRepo especificado nos argumentos, a chamada veio de um objeto de classe GitRepo::Local ; se remoteGitRepo especificado - depois de GitRepo::Remote ) e copie uma pequena quantidade de clichê código em cmd. Afinal, de qualquer maneira, esse código será descartado assim que a mudança para Golang for concluída.


Como alterar o estado de outro objeto


Há situações em que um objeto recebe outro objeto como parâmetro e chama um método que altera implicitamente o estado desse segundo objeto.


Nesse caso, você deve:


  1. Quando um binário é chamado, além do estado serializado do objeto ao qual o método é chamado, transmita o estado serializado de todos os objetos de parâmetro.
  2. Após a chamada, redefina o estado do objeto ao qual o método foi chamado e também redefina o estado de todos os objetos que foram passados ​​como parâmetros.

Caso contrário, tudo é semelhante.


O que é isso?


Pegamos um componente, porta para Golang, lançamos uma nova versão.


No caso em que os componentes subjacentes já estão portados e um componente de nível superior que os utiliza é transferido, esse componente pode "absorver" esses componentes subjacentes . Nesse caso, os binários extras correspondentes já podem ser excluídos como desnecessários.


E isso continua até chegarmos à camada superior, que cola todas as abstrações subjacentes . Isso completará a primeira etapa da transferência. A camada superior é a CLI. Ele ainda pode viver em Ruby por um tempo antes de mudar completamente para Golang.


Como distribuir esse monstro?


Bom: agora temos uma abordagem para portar gradualmente todos os componentes. Pergunta: como distribuir esse programa em 2 idiomas?


No caso do Ruby, o programa ainda está instalado como Gem. Assim que se chama o binário, ele pode baixar essa dependência para um URL específico (codificado permanentemente) e armazená-lo em cache localmente no sistema (em algum lugar dos arquivos de serviço).


Quando fazemos uma nova versão do nosso programa em 2 idiomas, devemos:


  1. Colete e faça o upload de todas as dependências binárias para uma determinada hospedagem.
  2. Crie uma nova versão do Ruby Gem.

Os binários para cada versão subseqüente são coletados separadamente, mesmo que algum componente não tenha sido alterado. Pode-se fazer uma versão separada de todos os binários dependentes. Portanto, não seria necessário coletar novos binários para cada nova versão do programa. Porém, no nosso caso, partimos do fato de que não temos tempo para fazer algo super complicado e otimizar o código de tempo; portanto, por simplicidade, coletamos binários separados para cada versão do programa, em detrimento da economia de espaço e tempo para o download.


Desvantagens da abordagem


Obviamente, exec a sobrecarga de chamar constantemente programas externos por meio de system / exec .


É difícil armazenar em cache quaisquer dados globais no nível Golang - afinal, todos os dados em Golang (por exemplo, variáveis ​​de pacote) são criados quando um método é chamado e morre após a conclusão. Isso sempre deve ser lembrado. No entanto, o armazenamento em cache ainda é possível no nível da instância da classe ou passando explicitamente parâmetros para um componente externo.


Não devemos esquecer de transferir o estado dos objetos para Golang e restaurá-lo corretamente após uma chamada.


Dependências binárias em Golang ocupam muito espaço . Uma coisa é quando existe um único binário de 30 MB - um programa no Golang. Outra coisa, quando você portou ~ 10 componentes, cada um com 30 MB, obtemos arquivos de 300 MB para cada versão . Por esse motivo, o espaço na hospedagem binária e na máquina host, onde seu programa trabalha e é constantemente atualizado, sai rapidamente. No entanto, o problema não é significativo se você excluir periodicamente versões antigas.


Observe também que a cada atualização do programa, leva algum tempo para baixar dependências binárias.


Benefícios da abordagem


Apesar de todas as desvantagens mencionadas, essa abordagem permite organizar um processo contínuo de migração para outro idioma e conviver com uma equipe de desenvolvimento.


A vantagem mais importante é a capacidade de obter feedback rápido sobre o novo código, testar e estabilizá-lo.


Nesse caso, você pode, entre outras coisas, adicionar novos recursos ao seu programa, corrigir erros na versão atual.


Como fazer um golpe final em Golang


No momento em que todos os componentes principais serão voltados para Golang e já testados em produção, resta reescrever a interface principal do seu programa (CLI) para Golang e jogar fora todo o código Ruby antigo.


Nesta fase, resta apenas resolver os problemas de compatibilidade da sua nova CLI com a antiga.


Viva, camaradas! A revolução se tornou realidade.


Como reescrevemos dapp no ​​Golang


Dapp é um utilitário desenvolvido pela Flant para organizar o processo de CI / CD. Foi escrito em Ruby por razões históricas:


  • Vasta experiência no desenvolvimento de programas em Ruby.
  • Chef Usado (as receitas estão escritas em Ruby).
  • Inércia, resistência ao uso de uma nova linguagem para nós para algo sério.

A abordagem descrita no artigo foi aplicada para reescrever o dapp no ​​Golang. O gráfico abaixo mostra a cronologia da luta entre o bem (Golang, azul) e o mal (Ruby, vermelho):



Quantidade de código em um projeto dapp / werf em Ruby vs. idiomas Golang ao longo dos lançamentos


No momento, você pode fazer o download da versão alfa 1.0 , que não possui Ruby. Também renomeamos dapp para werf, mas isso é outra história ... Aguarde o lançamento completo do werf 1.0 em breve!


Como vantagens adicionais dessa migração e ilustração da integração com o notório ecossistema Kubernetes, observamos que reescrever o dapp no ​​Golang nos deu a oportunidade de criar outro projeto - o kubedog . Assim, conseguimos separar o código para rastrear os recursos do K8s em um projeto separado, que pode ser útil não apenas no werf, mas também em outros projetos. Existem outras soluções para a mesma tarefa (consulte o nosso recente anúncio para obter detalhes) , mas “competir” com elas (em termos de popularidade) sem o Go, pois sua base dificilmente seria possível.


PS


Leia também em nosso blog:


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


All Articles