Cómo derrotar al dragón: reescribe tu programa en Golang

Dio la casualidad de que su programa estaba escrito en un lenguaje de secuencias de comandos, por ejemplo, en Ruby, y era necesario reescribirlo en Golang.


Una pregunta razonable: ¿ por qué podría necesitar reescribir un programa que ya se ha escrito y funciona bien?



Primero, digamos que el programa está asociado con un ecosistema específico ; en nuestro caso, estos son Docker y Kubernetes. Toda la infraestructura de estos proyectos está escrita en Golang. Esto abre el acceso a las bibliotecas que usan Docker, Kubernetes y otros. Desde el punto de vista del soporte, desarrollo y refinamiento de su programa, es más rentable usar la misma infraestructura que usan los principales productos. En este caso, todas las nuevas funciones estarán disponibles de inmediato y no tendrá que volver a implementarlas en otro idioma. Solo esta condición en nuestra situación específica fue suficiente para tomar una decisión sobre la necesidad de cambiar el idioma en principio y sobre qué tipo de lenguaje debería ser. Sin embargo, hay otras ventajas ...


En segundo lugar, la facilidad de instalar aplicaciones en Golang. No necesita instalar Rvm, Ruby, un conjunto de gemas, etc. en el sistema. Necesita descargar un archivo binario estático y usarlo.


En tercer lugar, la velocidad de los programas en Golang es mayor. Este no es un aumento sistémico significativo en la velocidad, que se obtiene utilizando la arquitectura y los algoritmos correctos en cualquier idioma. Pero este es un aumento tal que se siente cuando inicia su programa desde la consola. Por ejemplo, --help en Ruby puede funcionar en 0.8 segundos, y en Golang - 0.02 segundos. Simplemente mejora notablemente la experiencia del usuario al usar el programa.


NB : Como podrían haber adivinado los lectores habituales de nuestro blog, el artículo se basa en la experiencia de reescribir nuestro producto dapp , que ahora, aún no oficialmente (!), Se conoce como werf . Consulte el final del artículo para obtener más detalles al respecto.


Bien: puede simplemente recoger y escribir un nuevo código que esté completamente aislado del código de script anterior. Pero de inmediato surgen algunas dificultades y limitaciones en los recursos y el tiempo asignado para el desarrollo:


  • La versión actual del programa en Ruby necesita constantemente mejoras y correcciones:
    • Los errores ocurren a medida que se usan y deben corregirse rápidamente;
    • No puede congelar la adición de nuevas funciones durante seis meses, porque Estas características a menudo son requeridas por los clientes / usuarios.
  • Mantener 2 bases de código al mismo tiempo es difícil y costoso:
    • Hay pocos equipos de 2-3 personas, dada la presencia de otros proyectos además de este programa Ruby.
  • Introducción de la nueva versión:
    • No debe haber degradación significativa en la función;
    • Idealmente, esto debería ser transparente y sin fisuras.

Se requiere un proceso de portabilidad continuo. Pero, ¿cómo puedo hacer esto si la versión de Golang se está desarrollando como un programa independiente?


Escribimos en dos idiomas a la vez.


Pero, ¿qué pasa si transfieres componentes a Golang de abajo hacia arriba? Comenzamos con cosas de bajo nivel, luego subimos las abstracciones.


Imagine que su programa consta de los siguientes 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 puerto con características


Un caso simple. Tomamos un componente existente que está bastante aislado del resto, por ejemplo, config ( lib/config.rb ). En este componente, solo se define la función Config::parse , que toma la ruta a la configuración, la lee y produce una estructura poblada. Un binario separado en la config Golang y la config paquete correspondiente serán responsables de su implementación:


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

El binario de Golang recibe los argumentos del archivo JSON y envía el resultado al archivo JSON.


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

Se config que config puede enviar mensajes a stdout / stderr (en nuestro programa Ruby, la salida siempre va a stdout / stderr, por lo que esta función no está parametrizada).


Llamar al binario de config es equivalente a llamar a alguna función del componente de config . Los argumentos a través del archivo args.json indican el nombre de la función y sus parámetros. En la salida a través del archivo res.json , res.json el resultado de la función. Si la función devuelve un objeto de alguna clase, los datos del objeto de esta clase se devuelven en forma serializada JSON.


Por ejemplo, para llamar a la función Config::parse , especifique el siguiente args.json :


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

Obtenemos res.json resultado en res.json :


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

En el campo config , obtenemos el estado del objeto Config::Config serializado en JSON. Desde este estado, en la persona que llama en Ruby, debe construir un objeto Config::Config .


En caso del error proporcionado , el binario puede devolver el siguiente JSON:


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

El campo de error debe ser manejado por la persona que llama.


Llamar a Golang desde Ruby


En el lado de Ruby, convertimos la función Config::parse(config_path) en un contenedor que llama a nuestra config , obtiene el resultado, procesa todos los errores posibles. Aquí hay un pseudocódigo Ruby de ejemplo con simplificaciones:


 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 

El binario podría bloquearse con un código inesperado distinto de cero: esta es una situación excepcional. O con los códigos proporcionados: en este caso, buscamos en el archivo res.json la presencia de los campos de error y config y, como resultado, devolvemos el objeto Config::Config del campo de config serializado.


Desde el punto de vista del usuario, la función Config::Parse no ha cambiado.


Clase de componente de puerto


Por ejemplo, tome la jerarquía de clases lib/git_repo . Hay 2 clases: GitRepo::Local y GitRepo::Remote . Tiene sentido combinar su implementación en un solo binario git_repo y, en consecuencia, empaquetar git_repo en Golang.


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

Una llamada al binario git_repo corresponde a una llamada a algún método del objeto GitRepo::Local o GitRepo::Remote . El objeto tiene un estado y puede cambiar después de una llamada al método. Por lo tanto, en los argumentos, pasamos el estado actual serializado en JSON. Y en la salida, siempre obtenemos el nuevo estado del objeto, también en JSON.


Por ejemplo, para llamar al local_repo.commit_exists?(commit) , especificamos los siguientes args.json :


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

La salida es res.json :


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

En el campo localGitRepo , se localGitRepo un nuevo estado del objeto (que puede no cambiar). Debemos poner este estado en el objeto Ruby actual local_git_repo todos modos.


Llamar a Golang desde Ruby


En el lado de Ruby, convertimos cada método de las GitRepo::Base , GitRepo::Local , GitRepo::Remote en envoltorios que llaman a nuestro git_repo , obtienen el resultado, establecen el nuevo estado del objeto de la GitRepo::Local o GitRepo::Remote .


De lo contrario, todo es similar a llamar a una función simple.


Cómo lidiar con el polimorfismo y las clases base


La forma más fácil es no admitir el polimorfismo de Golang. Es decir asegúrese de que las llamadas al binario git_repo siempre se dirijan explícitamente a una implementación específica (si localGitRepo especificó en los argumentos, entonces la llamada provino de un objeto de clase GitRepo::Local ; si remoteGitRepo especificó remoteGitRepo , luego de GitRepo::Remote ) y obtenga copiando una pequeña cantidad de repetitivo. código en cmd. Después de todo, de todos modos, este código se eliminará tan pronto como se complete el traslado a Golang.


Cómo cambiar el estado de otro objeto


Hay situaciones en las que un objeto recibe otro objeto como parámetro y llama a un método que cambia implícitamente el estado de este segundo objeto.


En este caso, debes:


  1. Cuando se llama a un binario, además del estado serializado del objeto al que se llama el método, transmite el estado serializado de todos los objetos de parámetros.
  2. Después de la llamada, restablezca el estado del objeto al que se llamó el método y también restablezca el estado de todos los objetos que se pasaron como parámetros.

De lo contrario, todo es similar.


Que es


Tomamos un componente, puerto a Golang, lanzamos una nueva versión.


En el caso de que los componentes subyacentes ya estén portados y se transfiera un componente de nivel superior que los utiliza, este componente puede "incorporar" estos componentes subyacentes . En este caso, los binarios adicionales correspondientes ya pueden eliminarse como innecesarios.


Y esto continúa hasta llegar a la capa superior, que une todas las abstracciones subyacentes . Esto completará la primera etapa de portabilidad. La capa superior es la CLI. Todavía puede vivir en Ruby por un tiempo antes de cambiar completamente a Golang.


¿Cómo distribuir este monstruo?


Bien: ahora tenemos un enfoque para transferir gradualmente todos los componentes. Pregunta: ¿cómo distribuir dicho programa en 2 idiomas?


En el caso de Ruby, el programa todavía está instalado como Gem. Tan pronto como se trata de llamar al binario, puede descargar esta dependencia a una URL específica (está codificada) y almacenarla en caché localmente en el sistema (en algún lugar de los archivos de servicio).


Cuando hacemos una nueva versión de nuestro programa en 2 idiomas, debemos:


  1. Recopile y cargue todas las dependencias binarias a un determinado alojamiento.
  2. Crea una nueva versión de Ruby Gem.

Los binarios para cada versión posterior se recopilan por separado, incluso si algún componente no ha cambiado. Uno podría hacer una versión separada de todos los binarios dependientes. Entonces no sería necesario recopilar nuevos binarios para cada nueva versión del programa. Pero en nuestro caso, procedimos del hecho de que no tenemos tiempo para hacer algo extremadamente complicado y optimizar el código de tiempo, por lo que, por simplicidad, recopilamos archivos binarios separados para cada versión del programa en detrimento de ahorrar espacio y tiempo para la descarga.


Desventajas del enfoque


Obviamente, exec la sobrecarga de llamar constantemente a programas externos a través de system / exec .


Es difícil almacenar en caché los datos globales en el nivel de Golang ; después de todo, todos los datos en Golang (por ejemplo, variables de paquete) se crean cuando se llama a un método y mueren después de completarse. Esto siempre debe tenerse en cuenta. Sin embargo, el almacenamiento en caché todavía es posible en el nivel de instancia de clase o al pasar explícitamente parámetros a un componente externo.


No debemos olvidar transferir el estado de los objetos a Golang y restaurarlo correctamente después de una llamada.


Las dependencias binarias en Golang ocupan mucho espacio . Una cosa es cuando hay un solo binario de 30 MB: un programa en Golang. Otra cosa, cuando portó ~ 10 componentes, cada uno de los cuales pesa 30 MB, obtenemos archivos de 300 MB para cada versión . Debido a esto, el espacio en el alojamiento binario y en la máquina host, donde su programa funciona y se actualiza constantemente, se va rápidamente. Sin embargo, el problema no es significativo si elimina periódicamente versiones antiguas.


También tenga en cuenta que con cada actualización del programa, llevará algún tiempo descargar las dependencias binarias.


Ventajas del enfoque


A pesar de todas las desventajas mencionadas, este enfoque le permite organizar un proceso continuo de portabilidad a otro idioma y sobrevivir con un equipo de desarrollo.


La ventaja más importante es la capacidad de obtener comentarios rápidos sobre el nuevo código, probarlo y estabilizarlo.


En este caso, puede, entre otras cosas, agregar nuevas funciones a su programa, corregir errores en la versión actual.


Cómo hacer un golpe final en Golang


En el momento en que todos los componentes principales se convertirán a Golang y ya se probarán en producción, todo lo que queda es reescribir la interfaz superior de su programa (CLI) a Golang y desechar todo el código antiguo de Ruby.


En esta etapa, solo queda resolver los problemas de compatibilidad de su nueva CLI con la anterior.


¡Hurra, camaradas! La revolución se ha hecho realidad.


Cómo reescribimos Dapp en Golang


Dapp es una utilidad desarrollada por Flant para organizar el proceso de CI / CD. Fue escrito en Ruby por razones históricas:


  • Amplia experiencia en el desarrollo de programas en Ruby.
  • Chef usado (las recetas están escritas en rubí).
  • Inercia, resistencia a usar un nuevo lenguaje para nosotros para algo serio.

El enfoque descrito en el artículo se aplicó para reescribir dapp en Golang. El siguiente gráfico muestra la cronología de la lucha entre el bien (Golang, azul) y el mal (Ruby, rojo):



Cantidad de código en un proyecto dapp / werf en Ruby vs. idiomas Golang en el transcurso de lanzamientos


Por el momento, puede descargar la versión alfa 1.0 , que no tiene Ruby. También cambiamos el nombre de dapp a werf, pero esa es otra historia ... ¡ Espere el lanzamiento completo de werf 1.0 pronto!


Como ventajas adicionales de esta migración e ilustración de integración con el notorio ecosistema de Kubernetes, notamos que reescribir dapp en Golang nos dio la oportunidad de crear otro proyecto: kubedog . Así que pudimos separar el código para rastrear los recursos de K8 en un proyecto separado, lo que puede ser útil no solo en werf, sino también en otros proyectos. Hay otras soluciones para la misma tarea (vea nuestro anuncio reciente para más detalles) , pero “competir” con ellas (en términos de popularidad) sin Go como su base difícilmente hubiera sido posible.


PS


Lea también en nuestro blog:


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


All Articles