So kam es, dass Ihr Programm in einer Skriptsprache geschrieben wurde - zum Beispiel in Ruby - und dass es in Golang neu geschrieben werden musste.
Eine vernünftige Frage: Warum müssen Sie möglicherweise ein Programm schreiben, das bereits geschrieben wurde und einwandfrei funktioniert?

Nehmen wir zunächst an, das Programm ist einem bestimmten Ökosystem zugeordnet - in unserem Fall sind dies Docker und Kubernetes. Die gesamte Infrastruktur dieser Projekte ist in Golang geschrieben. Dies eröffnet den Zugriff auf Bibliotheken, die Docker, Kubernetes und andere verwenden. Unter dem Gesichtspunkt der Unterstützung, Entwicklung und Verfeinerung Ihres Programms ist es rentabler, dieselbe Infrastruktur zu verwenden, die die Hauptprodukte verwenden. In diesem Fall sind alle neuen Funktionen sofort verfügbar und Sie müssen sie nicht erneut in einer anderen Sprache implementieren. Nur diese Bedingung in unserer spezifischen Situation reichte aus, um eine Entscheidung sowohl über die Notwendigkeit einer grundsätzlichen Änderung der Sprache als auch über die Art der Sprache zu treffen. Es gibt jedoch noch andere Vorteile ...
Zweitens die einfache Installation von Anwendungen auf Golang. Sie müssen Rvm, Ruby, eine Reihe von Edelsteinen usw. nicht im System installieren. Sie müssen eine statische Binärdatei herunterladen und verwenden.
Drittens ist die Geschwindigkeit der Programme auf Golang höher. Dies ist keine signifikante systemische Geschwindigkeitssteigerung, die durch die Verwendung der richtigen Architektur und Algorithmen in einer beliebigen Sprache erzielt wird. Dies ist jedoch eine solche Zunahme, die sich bemerkbar macht, wenn Sie Ihr Programm von der Konsole aus starten. Zum Beispiel kann --help
in Ruby in 0,8 Sekunden und in Golang in 0,02 Sekunden funktionieren. Es verbessert die Benutzererfahrung bei der Verwendung des Programms nur merklich.
NB : Wie die regelmäßigen Leser unseres Blogs hätten erraten können, basiert der Artikel auf der Erfahrung beim Umschreiben unseres dapp- Produkts, das jetzt - noch nicht ganz offiziell (!) - als werf bekannt ist . Weitere Informationen hierzu finden Sie am Ende des Artikels.
Gut: Sie können einfach einen neuen Code aufnehmen und schreiben, der vollständig vom alten Skriptcode isoliert ist. Es treten jedoch sofort einige Schwierigkeiten und Einschränkungen bei den für die Entwicklung bereitgestellten Ressourcen und Zeit auf:
- Die aktuelle Version des Programms in Ruby benötigt ständig Verbesserungen und Korrekturen:
- Fehler treten bei der Verwendung auf und sollten umgehend behoben werden.
- Sie können das Hinzufügen neuer Funktionen sechs Monate lang nicht einfrieren, da Diese Funktionen werden häufig von Clients / Benutzern benötigt.
- Die gleichzeitige Pflege von 2 Codebasen ist schwierig und teuer:
- Es gibt nur wenige Teams mit 2-3 Personen, da neben diesem Ruby-Programm noch andere Projekte vorhanden sind.
- Einführung der neuen Version:
- Es sollte keine signifikante Verschlechterung der Funktion geben;
- Idealerweise sollte dies nahtlos und nahtlos sein.
Ein kontinuierlicher Portierungsprozess ist erforderlich. Aber wie kann ich das tun, wenn die Golang-Version als eigenständiges Programm entwickelt wird?
Wir schreiben in zwei Sprachen gleichzeitig
Aber was ist, wenn Sie Komponenten von unten nach oben auf Golang übertragen? Wir beginnen mit einfachen Dingen und gehen dann die Abstraktionen hoch.
Stellen Sie sich vor, Ihr Programm besteht aus folgenden Komponenten:
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
Portkomponente mit Funktionen
Ein einfacher Fall. Wir nehmen eine vorhandene Komponente, die vom Rest ziemlich isoliert ist - zum Beispiel config
( lib/config.rb
). In dieser Komponente ist nur die Funktion Config::parse
definiert, die den Pfad zur Konfiguration übernimmt, ihn liest und eine aufgefüllte Struktur erstellt. Eine separate Binärdatei in der Golang- config
und der entsprechenden Paketkonfiguration ist für die Implementierung verantwortlich:
cmd/ config/ main.go pkg/ config/ config.go
Die Golang-Binärdatei empfängt die Argumente aus der JSON-Datei und gibt das Ergebnis in die JSON-Datei aus.
config -args-from-file args.json -res-to-file res.json
Es wird config
dass config
Nachrichten an stdout / stderr ausgeben kann (in unserem Ruby-Programm geht die Ausgabe immer an stdout / stderr, daher ist diese Funktion nicht parametrisiert).
Das Aufrufen der config
entspricht dem Aufrufen einer Funktion der config
. Die Argumente in der Datei args.json
geben den Namen der Funktion und ihre Parameter an. Bei der Ausgabe über die Datei res.json
das Ergebnis der Funktion. Wenn die Funktion ein Objekt einer Klasse zurückgeben soll, werden die Daten des Objekts dieser Klasse in serialisierter JSON-Form zurückgegeben.
args.json
Sie beispielsweise die folgende args.json
, um die Funktion Config::parse
args.json
:
{ "command": "Parse", "configPath": "path-to-config.yaml" }
Wir res.json
Ergebnis in res.json
:
{ "config": { "Images": [{"Name": "nginx"}, {"Name": "rails"}], "From": "ubuntu:16.04" }, }
Im config
wir den Status des in JSON serialisierten Config::Config
Objekts. Ab diesem Status müssen Sie auf dem Aufrufer in Ruby ein Config::Config
Objekt Config::Config
.
Im Falle des bereitgestellten Fehlers kann die Binärdatei den folgenden JSON zurückgeben:
{ "error": "no such file path-to-config.yaml" }
Das error
muss vom Anrufer behandelt werden.
Golang von Ruby aus anrufen
Auf der Ruby-Seite verwandeln wir die Funktion Config::parse(config_path)
in einen Wrapper, der unsere config
aufruft, das Ergebnis erhält und alle möglichen Fehler verarbeitet. Hier ist ein Beispiel für einen Ruby-Pseudocode mit Vereinfachungen:
module Config def parse(config_path) call_id = get_random_number args_file = "
Die Binärdatei kann mit unerwartetem Code ungleich Null abstürzen - dies ist eine Ausnahmesituation. Oder mit den bereitgestellten Codes - in diesem Fall res.json
wir die Datei res.json
auf das Vorhandensein der error
und config
und geben als Ergebnis das Config::Config
Objekt aus dem serialisierten config
zurück.
Aus Sicht des Benutzers hat sich die Funktion Config::Parse
nicht geändert.
Port-Komponentenklasse
Nehmen Sie zum Beispiel die Klassenhierarchie lib/git_repo
. Es gibt 2 Klassen: GitRepo::Local
und GitRepo::Remote
. Es ist sinnvoll, ihre Implementierung in einer einzigen git_repo
Binärdatei zu kombinieren und dementsprechend git_repo
in Golang zu verpacken.
cmd/ git_repo/ main.go pkg/ git_repo/ base.go local.go remote.go
Ein Aufruf der Binärdatei git_repo
entspricht einem Aufruf einer Methode des GitRepo::Local
GitRepo::Remote
oder GitRepo::Remote
Objekts. Das Objekt hat einen Status und kann sich nach einem Methodenaufruf ändern. Daher übergeben wir in den Argumenten den in JSON serialisierten aktuellen Status. Und bei der Ausgabe erhalten wir immer den neuen Status des Objekts - auch in JSON.
Um beispielsweise die Methode local_repo.commit_exists?(commit)
, geben Sie die folgenden args.json
:
{ "localGitRepo": { "name": "my_local_git_repo", "path": "path/to/git" }, "method": "IsCommitExists", "commit": "e43b1336d37478282693419e2c3f2d03a482c578" }
Die Ausgabe ist res.json
:
{ "localGitRepo": { "name": "my_local_git_repo", "path": "path/to/git" }, "result": true, }
Im Feld localGitRepo
wird ein neuer Status des Objekts empfangen (der sich möglicherweise nicht ändert). Wir müssen diesen Zustand sowieso in das aktuelle Ruby-Objekt local_git_repo
.
Golang von Ruby aus anrufen
Auf der Ruby-Seite GitRepo::Base
wir jede Methode der GitRepo::Base
, GitRepo::Local
, GitRepo::Remote
in Wrapper um, die unser git_repo
, das Ergebnis git_repo
und den neuen Status des Objekts der Klasse GitRepo::Local
oder GitRepo::Remote
.
Ansonsten ähnelt alles dem Aufrufen einer einfachen Funktion.
Umgang mit Polymorphismus und Basisklassen
Der einfachste Weg ist, den Polymorphismus von Golang nicht zu unterstützen. Das heißt, git_repo
sicher, dass die Aufrufe der Binärdatei git_repo
immer explizit an eine bestimmte Implementierung adressiert sind (wenn localGitRepo
in den Argumenten angegeben wurde, kam der Aufruf von einem GitRepo::Local
Klassenobjekt; wenn remoteGitRepo
angegeben wurde - dann von GitRepo::Remote
) und kopieren Sie eine kleine Menge von boilerplate- Code in cmd. Schließlich wird dieser Code ohnehin verworfen , sobald der Umzug nach Golang abgeschlossen ist.
So ändern Sie den Status eines anderen Objekts
Es gibt Situationen, in denen ein Objekt ein anderes Objekt als Parameter empfängt und eine Methode aufruft, die implizit den Status dieses zweiten Objekts ändert.
In diesem Fall müssen Sie:
- Wenn eine Binärdatei aufgerufen wird, übertragen Sie zusätzlich zum serialisierten Status des Objekts, zu dem die Methode aufgerufen wird, den serialisierten Status aller Parameterobjekte.
- Setzen Sie nach dem Aufruf den Status des Objekts zurück, an das die Methode aufgerufen wurde, und setzen Sie auch den Status aller Objekte zurück, die als Parameter übergeben wurden.
Ansonsten ist alles ähnlich.
Was ist das
Wir nehmen eine Komponente, portieren nach Golang, veröffentlichen eine neue Version.
Wenn die zugrunde liegenden Komponenten bereits portiert sind und eine übergeordnete Komponente, die sie verwendet, übertragen wird, kann diese Komponente diese zugrunde liegenden Komponenten „aufnehmen“ . In diesem Fall werden die entsprechenden zusätzlichen Binärdateien möglicherweise bereits als unnötig gelöscht.
Und das geht so weiter, bis wir zur obersten Ebene gelangen, die alle zugrunde liegenden Abstraktionen zusammenklebt . Damit ist die erste Phase der Portierung abgeschlossen. Die oberste Ebene ist die CLI. Er kann noch eine Weile von Ruby leben, bevor er komplett zu Golang wechselt.
Wie verteile ich dieses Monster?
Gut: Jetzt haben wir einen Ansatz, um alle Komponenten schrittweise zu portieren. Frage: Wie verteilt man ein solches Programm in zwei Sprachen?
Im Fall von Ruby ist das Programm weiterhin als Gem installiert. Sobald die Binärdatei aufgerufen wird, kann sie diese Abhängigkeit auf eine bestimmte URL herunterladen (sie ist fest codiert) und lokal im System (irgendwo in den Servicedateien) zwischenspeichern.
Wenn wir unser Programm in zwei Sprachen neu veröffentlichen, müssen wir:
- Sammeln Sie alle binären Abhängigkeiten und laden Sie sie auf ein bestimmtes Hosting hoch.
- Erstellen Sie eine neue Ruby Gem-Version.
Die Binärdateien für jede nachfolgende Version werden separat erfasst, auch wenn sich eine Komponente nicht geändert hat. Man könnte eine separate Versionierung aller abhängigen Binärdateien vornehmen. Dann wäre es nicht notwendig, für jede neue Version des Programms neue Binärdateien zu sammeln. In unserem Fall gingen wir jedoch davon aus, dass wir keine Zeit haben, etwas extrem Kompliziertes zu tun und den Zeitcode zu optimieren. Der Einfachheit halber haben wir für jede Version des Programms separate Binärdateien gesammelt, um Platz und Zeit für das Herunterladen zu sparen.
Nachteile des Ansatzes
Offensichtlich entsteht der Aufwand für das ständige Aufrufen externer Programme über system
/ exec
.
Es ist schwierig, globale Daten auf Golang-Ebene zwischenzuspeichern. Schließlich werden alle Daten in Golang (z. B. Paketvariablen) erstellt, wenn eine Methode aufgerufen wird, und sterben nach Abschluss ab. Dies muss immer berücksichtigt werden. Das Caching ist jedoch weiterhin auf Klasseninstanzebene oder durch explizite Übergabe von Parametern an eine externe Komponente möglich.
Wir dürfen nicht vergessen, den Status von Objekten nach Golang zu übertragen und ihn nach einem Aufruf korrekt wiederherzustellen.
Binäre Abhängigkeiten von Golang nehmen viel Platz ein . Es ist eine Sache, wenn es eine einzelne 30-MB-Binärdatei gibt - ein Programm auf Golang. Eine andere Sache, wenn Sie ~ 10 Komponenten portiert haben, von denen jede 30 MB wiegt, erhalten wir 300 MB Dateien für jede Version . Aus diesem Grund wird der Speicherplatz auf dem Binärhosting und auf dem Hostcomputer, auf dem Ihr Programm funktioniert und ständig aktualisiert wird, schnell verlassen. Das Problem ist jedoch nicht signifikant, wenn Sie regelmäßig alte Versionen löschen.
Beachten Sie auch, dass das Herunterladen von binären Abhängigkeiten bei jedem Update des Programms einige Zeit in Anspruch nimmt.
Vorteile des Ansatzes
Trotz aller genannten Nachteile können Sie mit diesem Ansatz einen kontinuierlichen Portierungsprozess in eine andere Sprache organisieren und mit einem Entwicklungsteam auskommen.
Der wichtigste Vorteil ist die Möglichkeit, schnelles Feedback zum neuen Code zu erhalten, ihn zu testen und zu stabilisieren.
In diesem Fall können Sie Ihrem Programm unter anderem neue Funktionen hinzufügen und Fehler in der aktuellen Version beheben.
Wie man einen letzten Coup auf Golang macht
Zu dem Zeitpunkt, an dem alle Hauptkomponenten an Golang übergeben und bereits in der Produktion getestet werden, müssen Sie nur noch die obere Schnittstelle Ihres Programms (CLI) in Golang umschreiben und den gesamten alten Ruby-Code wegwerfen.
Derzeit müssen nur noch die Kompatibilitätsprobleme Ihrer neuen CLI mit der alten CLI gelöst werden.
Hurra, Genossen! Die Revolution ist wahr geworden.
Wie wir dapp auf Golang umgeschrieben haben
Dapp ist ein von Flant entwickeltes Dienstprogramm zur Organisation des CI / CD-Prozesses. Es wurde aus historischen Gründen in Ruby geschrieben:
- Umfangreiche Erfahrung in der Entwicklung von Programmen in Ruby.
- Gebrauchter Koch (Rezepte dafür sind in Ruby geschrieben).
- Trägheit, Widerstand gegen die Verwendung einer neuen Sprache für uns für etwas Ernstes.
Der im Artikel beschriebene Ansatz wurde angewendet, um dapp auf Golang neu zu schreiben. Die folgende Grafik zeigt die Chronologie des Kampfes zwischen Gut (Golang, Blau) und Böse (Rubin, Rot):

Codemenge in einem dapp / werf-Projekt in Ruby vs. Sprachen Golang im Laufe der Veröffentlichungen
Im Moment können Sie die Alpha-Version 1.0 herunterladen , die Ruby nicht enthält. Wir haben auch dapp in werf umbenannt, aber das ist eine andere Geschichte ... Warten Sie auf die vollständige Veröffentlichung von werf 1.0 bald!
Als zusätzliche Vorteile dieser Migration und als Beispiel für die Integration in das berüchtigte Kubernetes-Ökosystem stellen wir fest, dass wir durch das Umschreiben von dapp auf Golang die Möglichkeit hatten, ein weiteres Projekt zu erstellen - kubedog . So konnten wir den Code für die Verfolgung der K8-Ressourcen in ein separates Projekt aufteilen, was nicht nur in werf, sondern auch in anderen Projekten nützlich sein kann. Es gibt andere Lösungen für dieselbe Aufgabe (siehe unsere jüngste Ankündigung für Details) , aber mit ihnen (in Bezug auf die Popularität) ohne Go zu „konkurrieren“, da ihre Grundlage kaum möglich geworden wäre.
PS
Lesen Sie auch in unserem Blog: