Comment vaincre le dragon: réécrivez votre programme sur Golang

Il se trouve que votre programme a été écrit dans un langage de script - par exemple, en Ruby - et qu'il était nécessaire de le réécrire dans Golang.


Une question raisonnable: pourquoi pourriez-vous avoir besoin de réécrire un programme qui a déjà été écrit et qui fonctionne bien?



Tout d'abord, disons que le programme est associé à un écosystème spécifique - dans notre cas, ce sont Docker et Kubernetes. L'ensemble de l'infrastructure de ces projets est écrit en Golang. Cela ouvre l'accès aux bibliothèques qui utilisent Docker, Kubernetes et autres. Du point de vue du support, du développement et du raffinement de votre programme, il est plus rentable d'utiliser la même infrastructure que les principaux produits. Dans ce cas, toutes les nouvelles fonctionnalités seront disponibles immédiatement et vous n'aurez pas à les réimplémenter dans une autre langue. Seule cette condition dans notre situation spécifique était suffisante pour prendre une décision à la fois sur la nécessité de changer la langue en principe et sur le type de langue qu'elle devrait être. Il y a cependant d'autres avantages ...


Deuxièmement, la facilité d'installation des applications sur Golang. Vous n'avez pas besoin d'installer Rvm, Ruby, un ensemble de gemmes, etc. dans le système. Vous devez télécharger un fichier binaire statique et l'utiliser.


Troisièmement, la vitesse des programmes sur Golang est plus élevée. Il ne s'agit pas d'une augmentation systémique significative de la vitesse, qui est obtenue en utilisant la bonne architecture et les bons algorithmes dans n'importe quel langage. Mais c'est une telle augmentation qui se ressent lorsque vous lancez votre programme à partir de la console. Par exemple, --help dans Ruby peut fonctionner en 0,8 sec, et sur Golang - 0,02 sec. Il améliore simplement sensiblement l'expérience utilisateur de l'utilisation du programme.


NB : Comme les lecteurs réguliers de notre blog auraient pu le deviner, l'article est basé sur l'expérience de la réécriture de notre produit dapp , qui est maintenant - pas encore tout à fait officiellement (!) - connu sous le nom de werf . Voir la fin de l'article pour plus de détails à ce sujet.


Bon: vous pouvez simplement récupérer et écrire un nouveau code qui est complètement isolé de l'ancien code de script. Mais immédiatement quelques difficultés et limitations sur les ressources et le temps alloué au développement émergent:


  • La version actuelle du programme dans Ruby a constamment besoin d'améliorations et de corrections:
    • Les bogues se produisent lorsqu'ils sont utilisés et doivent être corrigés rapidement;
    • Vous ne pouvez pas suspendre l'ajout de nouvelles fonctionnalités pendant six mois, car Ces fonctionnalités sont souvent requises par les clients / utilisateurs.
  • La maintenance de 2 bases de code en même temps est difficile et coûteuse:
    • Il y a peu d'équipes de 2-3 personnes, étant donné la présence d'autres projets en plus de ce programme Ruby.
  • Introduction de la nouvelle version:
    • Il ne devrait pas y avoir de dégradation significative de la fonction;
    • Idéalement, cela devrait être transparent et transparent.

Un processus de portage continu est requis. Mais comment puis-je faire cela si la version de Golang est développée en tant que programme autonome?


Nous écrivons en deux langues à la fois


Mais que se passe-t-il si vous transférez des composants vers Golang de bas en haut? Nous commençons par des choses de bas niveau, puis remontons les abstractions.


Imaginez que votre programme se compose des composants suivants:


 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 

Composant de port avec fonctionnalités


Un cas simple. Nous prenons un composant existant qui est assez isolé du reste - par exemple, config ( lib/config.rb ). Dans ce composant, seule la fonction Config::parse est définie, qui prend le chemin d'accès à la configuration, la lit et produit une structure remplie. Un binaire séparé sur la config Golang et la config package correspondant sera responsable de sa mise en œuvre:


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

Le binaire de Golang reçoit les arguments du fichier JSON et renvoie le résultat dans le fichier JSON.


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

On config que config peut sortir des messages vers stdout / stderr (dans notre programme Ruby, la sortie va toujours vers stdout / stderr, donc cette fonctionnalité n'est pas paramétrée).


Appeler le binaire de config équivaut à appeler une fonction du composant de config . Les arguments du fichier args.json indiquent le nom de la fonction et ses paramètres. En sortie via le fichier res.json , res.json obtenons le résultat de la fonction. Si la fonction doit renvoyer un objet d'une classe, les données de l' objet de cette classe sont renvoyées sous forme sérialisée JSON.


Par exemple, pour appeler la fonction Config::parse , spécifiez les args.json suivants:


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

Nous res.json résultat dans res.json :


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

Dans le champ config , nous obtenons l'état de l'objet Config::Config sérialisé en JSON. À partir de cet état, sur l'appelant dans Ruby, vous devez construire un objet Config::Config .


En cas d'erreur fournie , le binaire peut renvoyer le JSON suivant:


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

Le champ d' error doit être géré par l'appelant.


Appel à Golang depuis Ruby


Du côté Ruby, nous transformons la fonction Config::parse(config_path) en un wrapper qui appelle notre config , obtient le résultat, traite toutes les erreurs possibles. Voici un exemple de pseudocode Ruby avec des simplifications:


 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 

Le binaire peut se bloquer avec un code non nul et inattendu - c'est une situation exceptionnelle. Ou avec les codes fournis - dans ce cas, nous regardons le fichier res.json pour la présence des champs d' error et de config et, par conséquent, nous Config::Config objet Config::Config du champ de config sérialisé.


Du point de vue de l'utilisateur, la fonction Config::Parse n'a pas changé.


Classe de composants de port


Par exemple, prenez la hiérarchie des classes lib/git_repo . Il existe 2 classes: GitRepo::Local et GitRepo::Remote . Il est logique de combiner leur implémentation dans un seul binaire git_repo et, par conséquent, de git_repo dans Golang.


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

Un appel au binaire git_repo correspond à un appel à une méthode de l'objet GitRepo::Local ou GitRepo::Remote . L'objet a un état et il peut changer après un appel de méthode. Par conséquent, dans les arguments, nous passons l'état actuel sérialisé en JSON. Et à la sortie, nous obtenons toujours le nouvel état de l'objet - également en JSON.


Par exemple, pour appeler la local_repo.commit_exists?(commit) , nous args.json suivant:


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

La sortie est res.json :


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

Dans le champ localGitRepo , un nouvel état de l'objet est reçu (qui ne peut pas changer). Nous devons quand même mettre cet état dans l'objet Ruby- local_git_repo .


Appel à Golang depuis Ruby


Du côté Ruby, nous transformons chaque méthode des GitRepo::Base , GitRepo::Local , GitRepo::Remote en wrappers qui appellent notre git_repo , obtenons le résultat, définissons le nouvel état de l'objet de la GitRepo::Local ou GitRepo::Remote .


Sinon, tout est similaire à l'appel d'une fonction simple.


Comment gérer le polymorphisme et les classes de base


Le moyen le plus simple est de ne pas supporter le polymorphisme de Golang. C'est-à-dire assurez-vous que les appels au binaire git_repo toujours explicitement adressés à une implémentation spécifique (si localGitRepo spécifié dans les arguments, alors l'appel est venu d'un objet de classe GitRepo::Local ; si remoteGitRepo spécifié - puis de GitRepo::Remote ) et obtenez en copiant une petite quantité de passe-partout - code en cmd. Après tout, de toute façon, ce code sera jeté dès que le déménagement à Golang sera terminé.


Comment changer l'état d'un autre objet


Il existe des situations où un objet reçoit un autre objet en tant que paramètre et appelle une méthode qui modifie implicitement l'état de ce deuxième objet.


Dans ce cas, vous devez:


  1. Lorsqu'un binaire est appelé, en plus de l'état sérialisé de l'objet auquel la méthode est appelée, transmettez l'état sérialisé de tous les objets paramètres.
  2. Après l'appel, réinitialisez l'état de l'objet auquel la méthode a été appelée et réinitialisez également l'état de tous les objets passés en paramètre.

Sinon, tout est similaire.


Qu'est ce que c'est?


Nous prenons un composant, portons sur Golang, sortons une nouvelle version.


Dans le cas où les composants sous-jacents sont déjà portés et qu'un composant de niveau supérieur qui les utilise est transféré, ce composant peut «intégrer» ces composants sous-jacents . Dans ce cas, les binaires supplémentaires correspondants peuvent déjà être supprimés car inutiles.


Et cela continue jusqu'à ce que nous arrivions à la couche supérieure, qui colle toutes les abstractions sous-jacentes . Cela terminera la première étape du portage. La couche supérieure est la CLI. Il peut encore vivre sur Ruby pendant un certain temps avant de passer complètement à Golang.


Comment distribuer ce monstre?


Bon: nous avons maintenant une approche pour porter progressivement tous les composants. Question: comment diffuser un tel programme en 2 langues?


Dans le cas de Ruby, le programme est toujours installé en tant que Gem. Dès qu'il s'agit d'appeler le binaire, il peut télécharger cette dépendance vers une URL spécifique (elle est codée en dur) et la mettre en cache localement dans le système (quelque part dans les fichiers de service).


Lorsque nous faisons une nouvelle version de notre programme en 2 langues, nous devons:


  1. Collectez et téléchargez toutes les dépendances binaires sur un certain hébergement.
  2. Créez une nouvelle version de Ruby Gem.

Les fichiers binaires pour chaque version ultérieure sont collectés séparément, même si certains composants n'ont pas changé. On pourrait faire un versioning séparé de tous les binaires dépendants. Ensuite, il ne serait pas nécessaire de collecter de nouveaux binaires pour chaque nouvelle version du programme. Mais dans notre cas, nous sommes partis du fait que nous n'avons pas le temps de faire quelque chose de super compliqué et d'optimiser le time code, donc pour plus de simplicité nous avons collecté des binaires séparés pour chaque version du programme au détriment de l'économie d'espace et de temps pour le téléchargement.


Inconvénients de l'approche


De toute évidence, le surcoût d'appeler constamment des programmes externes via le system / exec .


Il est difficile de mettre en cache des données globales au niveau de Golang - après tout, toutes les données de Golang (par exemple, les variables de package) sont créées lorsqu'une méthode est appelée et meurent après la fin. Il faut toujours garder cela à l'esprit. Cependant, la mise en cache est toujours possible au niveau de l'instance de classe ou en passant explicitement des paramètres à un composant externe.


Nous ne devons pas oublier de transférer l' état des objets à Golang et de le restaurer correctement après un appel.


Les dépendances binaires sur Golang prennent beaucoup de place . C'est une chose quand il n'y a qu'un seul binaire de 30 Mo - un programme sur Golang. Une autre chose, lorsque vous avez porté environ 10 composants, chacun pesant 30 Mo, nous obtenons 300 fichiers MB pour chaque version . Pour cette raison, l'espace sur l'hébergement binaire et sur la machine hôte, où votre programme fonctionne et est constamment mis à jour, quitte rapidement. Cependant, le problème n'est pas significatif si vous supprimez périodiquement les anciennes versions.


Notez également qu'à chaque mise à jour du programme, le téléchargement des dépendances binaires prendra un certain temps.


Avantages de l'approche


Malgré tous les inconvénients mentionnés, cette approche vous permet d'organiser un processus continu de portage vers une autre langue et de vous débrouiller avec une seule équipe de développement.


L'avantage le plus important est la possibilité d'obtenir un retour rapide sur le nouveau code, de le tester et de le stabiliser.


Dans ce cas, vous pouvez, entre autres, ajouter de nouvelles fonctionnalités à votre programme, corriger des bugs dans la version actuelle.


Comment faire un coup d'État final sur Golang


Au moment où tous les composants principaux sont tournés vers Golang et ont déjà été testés en production, il ne reste plus qu'à réécrire l'interface supérieure de votre programme (CLI) sur Golang et à jeter tout l'ancien code Ruby.


A ce stade, il ne reste plus qu'à résoudre les problèmes de compatibilité de votre nouvelle CLI avec l'ancienne.


Hourra, camarades! La révolution est devenue réalité.


Comment nous avons réécrit Dapp sur Golang


Dapp est un utilitaire développé par Flant pour organiser le processus CI / CD. Il a été écrit en Ruby pour des raisons historiques:


  • Vaste expérience dans le développement de programmes en Ruby.
  • Chef usagé (les recettes sont écrites en Ruby).
  • Inertie, résistance à utiliser un nouveau langage pour nous pour quelque chose de sérieux.

L'approche décrite dans l'article a été appliquée pour réécrire dapp sur Golang. Le graphique ci-dessous montre la chronologie de la lutte entre le bien (Golang, bleu) et le mal (Ruby, rouge):



Quantité de code dans un projet dapp / werf en Ruby vs langages Golang au fil des sorties


Pour le moment, vous pouvez télécharger la version alpha 1.0 , qui n'a pas Ruby. Nous avons également renommé dapp en werf, mais c'est une autre histoire ... Attendez la sortie complète de werf 1.0 bientôt!


Comme avantages supplémentaires de cette migration et illustration de l'intégration avec l'écosystème notoire de Kubernetes, nous notons que la réécriture de dapp sur Golang nous a donné l'opportunité de créer un autre projet - kubedog . Nous avons donc pu séparer le code de suivi des ressources K8 dans un projet distinct, qui peut être utile non seulement dans werf, mais aussi dans d'autres projets. Il existe d'autres solutions pour la même tâche (voir notre récente annonce pour plus de détails) , mais «les concurrencer» (en termes de popularité) sans Go car sa base n'aurait guère été possible.


PS


Lisez aussi dans notre blog:


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


All Articles