Wie man den Drachen besiegt: Schreiben Sie Ihr Programm auf Golang neu

    Es war also so, dass Ihr Programm in einer Skriptsprache geschrieben wurde - zum Beispiel in Ruby - und es war notwendig, es in Golang neu zu schreiben.


    Eine vernünftige Frage: Warum muss ein Programm, das bereits geschrieben wurde und gut funktioniert, im Allgemeinen neu geschrieben werden?



    Nehmen wir an, ein Programm ist einem bestimmten Ökosystem zugeordnet - in unserem Fall Docker und Kubernetes. Die gesamte Infrastruktur dieser Projekte ist in Golang geschrieben. Dadurch erhalten Sie Zugriff auf Bibliotheken, die Docker, Kubernetes und andere verwenden. Unter dem Gesichtspunkt der Wartung, Entwicklung und Verbesserung Ihres Programms ist es günstiger, dieselbe Infrastruktur zu verwenden, die die Hauptprodukte verwenden. In diesem Fall sind alle neuen Funktionen sofort verfügbar und Sie müssen sie nicht in einer anderen Sprache neu implementieren. Nur diese Bedingung in unserer besonderen Situation reichte aus, um eine Entscheidung darüber zu treffen, ob die Sprache grundsätzlich geändert werden muss und dass dies für die Sprache gelten sollte. Es gibt jedoch andere Pluspunkte ...


    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 in Golang höher. Dies ist keine signifikante systemische Geschwindigkeitssteigerung, die durch die Verwendung der richtigen Architektur und Algorithmen in jeder Sprache erzielt wird. Dies ist jedoch ein Wachstum, das sich bemerkbar macht, wenn Sie Ihr Programm von der Konsole aus starten. Bei --helpRuby kann es zum Beispiel in 0,8 Sekunden und bei Golang - 0,02 Sekunden funktionieren. Dies verbessert lediglich die Benutzererfahrung bei der Verwendung des Programms.


    NB : Wie regelmäßige Leser unseres Blogs vermuten können, basiert der Artikel auf der Erfahrung, unser Produkt dapp neu zu schreiben , das jetzt - auch nicht ganz offiziell (!) - als werf bekannt ist . Kleine Details über ihn finden Sie am Ende des Materials.


    Nun, Sie können sich einfach hinsetzen und einen neuen Code schreiben, der vollständig vom alten Skriptcode isoliert ist. Sofort treten jedoch einige Schwierigkeiten und Einschränkungen bei den Ressourcen und der für die Entwicklung bereitgestellten Zeit auf :


    • Die aktuelle Version des Programms in Ruby muss ständig verbessert und korrigiert werden:
      • Fehler treten auf, wenn sie verwendet werden und müssen umgehend behoben werden.
      • Es ist nicht möglich, das Hinzufügen neuer Funktionen für ein halbes Jahr einzufrieren Diese Funktionen werden häufig von Kunden / Benutzern benötigt.
    • Die gleichzeitige Pflege von 2 Codebasen ist schwierig und teuer:
      • Es gibt nur wenige Teams von 2-3 Personen, da in Ruby neben diesem Programm noch andere Projekte vorhanden sind.
    • Die Einführung der neuen Version:
      • Es sollte keine wesentliche Beeinträchtigung der Funktion geben;
      • Im Idealfall sollte dies nahtlos und nahtlos sein.

    Es ist notwendig, einen kontinuierlichen Portierungsprozess zu organisieren. Aber wie geht das, wenn die Version von Golang als separates Programm entwickelt wird?


    Wir schreiben gleichzeitig in zwei Sprachen


    Und wenn Komponenten auf Golang von unten nach oben übertragen werden? Wir beginnen mit einfachen Dingen und gehen dann durch Abstraktionen.


    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 ausreichend vom Rest isoliert ist - zum Beispiel config( lib/config.rb). In dieser Komponente ist nur die Funktion definiert Config::parse, die den Pfad zur Konfig nimmt, liest und die fertige Struktur ausgibt. Eine separate Binärdatei für Golang configund das entsprechende Paket sind für die Implementierung verantwortlich config:


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

    Die Binärdatei in Golang erhält 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 davon ausgegangen, dass configNachrichten an stdout / stderr gedruckt werden können (in unserem Ruby-Programm wird die Ausgabe immer an stdout / stderr ausgegeben , daher ist diese Funktion nicht parametrisiert).


    Das Aufrufen einer Binärdatei configentspricht dem Aufrufen einer Funktion einer Komponente config. Die Argumente in der Datei args.jsongeben den Namen der Funktion und ihre Parameter an. Bei der Ausgabe über die Datei erhalten res.jsonwir das Ergebnis der Funktion. Wenn die Funktion ein Objekt einer Klasse zurückgeben muss, werden die Daten des Objekts dieser Klasse in der in JSON serialisierten Form zurückgegeben.


    Um beispielsweise eine Funktion aufzurufen, geben Config::parsewir Folgendes an args.json:


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

    Wir bekommen das Ergebnis in res.json:


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

    In dem Feld erhalten configwir den Objektstatus in JSON serialisiert Config::Config. Ab diesem Status müssen Sie beim Aufrufer in Ruby ein Objekt erstellen Config::Config.


    Im Falle eines vorgegebenen binären Fehler kann eine JSON zurückgeben:


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

    Das Feld errormuss vom Anrufer bearbeitet werden.


    Golang von Ruby aus anrufen


    Auf der Ruby-Seite verwandeln wir die Funktion Config::parse(config_path)in einen Wrapper, der unseren verursacht config, das Ergebnis liefert und alle möglichen Fehler behandelt. Hier ist ein Beispiel eines Ruby-Pseudocodes mit Vereinfachungen:


    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

    Ein Binärcode könnte mit einem nicht-unbeabsichtigten Code ungleich Null abstürzen. Oder mit bereitgestellten Code - in diesem Fall sehen Sie die Datei res.jsonauf das Vorhandensein der Felder errorund configam Ende gibt ein Objekt Config::Configaus dem serialisierten Feld config.


    Aus Anwendersicht hat die Funktion Config::Parsenichts geändert.


    Port-Komponentenklasse


    Nehmen Sie zum Beispiel die Klassenhierarchie lib/git_repo. Es gibt 2 Klassen: GitRepo::Localund GitRepo::Remote. Es ist sinnvoll, ihre Implementierung in einer einzigen Binärdatei git_repound dementsprechend dem Paket git_repoin Golang zu kombinieren.


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

    Das Aufrufen einer Binärdatei git_repoentspricht dem Aufruf einer Methode des Objekts GitRepo::Localoder GitRepo::Remote. Ein Objekt hat einen Status und kann sich nach einem Methodenaufruf ändern. Deshalb übergeben wir in den Argumenten den aktuellen Status, der in JSON serialisiert ist. Und an der Ausgabe erhalten wir immer den neuen Zustand des Objekts - auch in JSON.


    Um beispielsweise eine Methode aufzurufen, geben local_repo.commit_exists?(commit)wir Folgendes an args.json:


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

    Am Ausgang erhalten wir res.json:


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

    Der localGitReponeue Status des Objekts (der sich nicht ändern darf) wird im Feld empfangen. Wir sollten diesen Status local_git_repotrotzdem in das aktuelle Ruby-Objekt einfügen.


    Golang von Ruby aus anrufen


    Auf dem Teil von Ruby jede Klasse Methode macht GitRepo::Base, GitRepo::Local, GitRepo::Remotein Umhüllungen , die unser verursachen git_repo, erhält das Ergebnis, stellen Sie den neuen Zustand der Objektklasse GitRepo::Localoder GitRepo::Remote.


    Ansonsten ist alles dasselbe wie das Aufrufen einer einfachen Funktion.


    Umgang mit Polymorphismus und Basisklassen


    Am einfachsten ist es, den Polymorphismus von Golang nicht zu unterstützen. Ie Um sicherzustellen, dass die binären Aufrufe git_repoimmer explizit an eine bestimmte Implementierung adressiert sind (wenn in den Argumenten angegeben localGitRepo, kam der Aufruf vom Klassenobjekt GitRepo::Local; wenn angegeben remoteGitRepo, dann von GitRepo::Remote), und erhalten Sie, indem Sie eine kleine Menge Boilerplate-Code in cmd kopieren. Immerhin alle gleich , wird dieser Code so schnell hinausgeworfen werden , wie die Übertragung Golang abgeschlossen werden.


    So ändern Sie den Status eines anderen Objekts


    Es gibt Situationen, in denen ein Objekt ein anderes Objekt als Parameter empfängt und es eine Methode nennt, die den Status dieses zweiten Objekts implizit ändert.


    In diesem Fall ist es notwendig:


    1. Wenn eine Binärdatei aufgerufen wird, wird zusätzlich zum serialisierten Status des von der Methode aufgerufenen Objekts der serialisierte Status aller Parameterobjekte übertragen.
    2. Installieren Sie nach dem Aufruf den Status des Objekts, für das die Methode aufgerufen wurde, neu, und installieren Sie außerdem den Status aller Objekte, die als Parameter übergeben wurden.

    Ansonsten ist alles gleich.


    Was ist das ergebnis


    Wir nehmen eine Komponente mit, portieren nach Golang und veröffentlichen eine neue Version.


    Wenn die zugrundeliegenden Komponenten bereits portiert sind und die übergeordnete Komponente, die sie verwendet, übertragen wird, kann diese Komponente die zugrunde liegenden Komponenten "aufnehmen" . In diesem Fall werden die entsprechenden zusätzlichen Binärdateien möglicherweise bereits als unnötig gelöscht.


    Und so geht es weiter, bis wir zur obersten Ebene gelangen, die alle darunter liegenden Abstraktionen zusammenhält . Damit ist die erste Portierungsphase abgeschlossen. Die oberste Schicht ist CLI. Er kann noch eine Weile in Ruby leben, bevor er vollständig zu Golang wechselt.


    Wie verteile ich dieses Monster?


    Gut: Nun haben wir einen Ansatz, alle Komponenten schrittweise zu portieren. Frage: Wie verteilt man ein solches Programm in zwei Sprachen?


    Im Fall von Ruby wird das Programm noch als Gem installiert. Sobald es zum Aufruf der Binärdatei kommt, kann es diese Abhängigkeit auf eine bestimmte URL (es ist hartcodiert) herunterladen und lokal im System (irgendwo in den Servicedateien) zwischenspeichern.


    Wenn wir eine neue Version unseres Programms in zwei Sprachen erstellen, müssen wir:


    1. Sammeln und laden Sie alle binären Abhängigkeiten eines Hostings herunter.
    2. Erstellen Sie eine neue Version von Ruby Gem.

    Die Binärdateien für jede nachfolgende Version werden separat erfasst, auch wenn sich eine Komponente nicht geändert hat. Es ist möglich, alle abhängigen Binärdateien separat zu versionieren. Dann ist es nicht erforderlich, für jede neue Version des Programms neue Binärdateien zu sammeln. In unserem Fall sind wir jedoch davon ausgegangen, dass wir keine Zeit haben, etwas Superkomplexes zu tun und den temporären Code zu optimieren. Daher haben wir der Einfachheit halber für jede Version des Programms separate Binärdateien gesammelt, was Zeit und Speicherplatz beim Herunterladen einspart.


    Nachteile Ansatz


    Offensichtlich entstehen Gemeinkosten für einen ständigen Aufruf externer Programme durch system/ exec.


    Es ist schwierig, globale Daten auf Golang-Ebene zwischenzuspeichern. Schließlich werden alle Daten in Golang (z. B. Paketvariablen) erstellt, wenn Sie eine Methode aufrufen und nach Fertigstellung sterben. Dies muss immer berücksichtigt werden. Zwischenspeichern ist jedoch weiterhin auf der Ebene von Klasseninstanzen oder bei der expliziten Übergabe von Parametern an eine externe Komponente möglich.


    Wir dürfen nicht vergessen, den Status von Objekten in Golang zu übertragen und nach dem Aufruf korrekt wiederherzustellen.


    Binäre Abhängigkeiten von Golang nehmen viel Platz in Anspruch . Es ist eine Sache, wenn es eine einzige 30 MB-Binärdatei gibt - ein Programm auf Golang. Eine andere Sache ist, wenn Sie ~ 10 Komponenten portiert haben, von denen jede 30 MB wiegt - wir erhalten 300 MB Dateien für jede Version . Dadurch verschwindet der Platz auf dem Binär-Hosting und auf dem Host-Computer, an dem Ihr Programm ausgeführt wird und ständig aktualisiert wird, schnell. Das Problem ist jedoch nicht signifikant, wenn alte Versionen regelmäßig gelöscht werden.


    Beachten Sie auch, dass es mit jeder Aktualisierung des Programms einige Zeit dauert, bis binäre Abhängigkeiten heruntergeladen werden.


    Vorteile des Ansatzes


    Trotz all dieser Nachteile können Sie mit diesem Ansatz einen kontinuierlichen Prozess der Portierung in eine andere Sprache organisieren und mit einem Entwicklerteam 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 zwischendurch neue Funktionen zu Ihrem Programm hinzufügen und Fehler in der aktuellen Version beheben.


    Wie mache ich einen endgültigen Coup auf Golang?


    In dem Moment, in dem alle Hauptkomponenten in Golang umgewandelt und bereits in der Produktion getestet wurden, müssen Sie lediglich die oberste Benutzeroberfläche Ihres Programms (CLI) in Golang umschreiben und den gesamten alten Ruby-Code verwerfen.


    In diesem Stadium müssen nur noch die Kompatibilitätsprobleme Ihrer neuen CLI mit der alten gelöst werden.


    Prost, Genossen! Die Revolution ist wahr geworden.


    Wie wir dapp auf Golang neu geschrieben 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 für Ruby.
    • Verwendeter Chef (Rezepte dafür sind in Ruby geschrieben).
    • Inertheit, Widerstand gegen die Verwendung einer neuen Sprache für etwas Ernstes.

    Der in diesem Artikel beschriebene Ansatz wurde verwendet, um Dapp in Golang umzuschreiben. Auf der obigen Grafik sehen Sie die Chronologie des Kampfes zwischen Gut (Golang, Blau) und Böse (Ruby, Rot):



    Die Menge an Code im dapp / werf-Projekt in Ruby vs. Golang über Veröffentlichungen


    An dieser Stelle können Sie die Alpha-Version 1.0 herunterladen , die kein Ruby enthält. Wir haben auch dapp in werf umbenannt, aber dies ist eine andere Geschichte ... Warten Sie in Kürze auf die vollständige Veröffentlichung von werf 1.0!


    Als zusätzliche Vorteile dieser Migration und der Veranschaulichung der Integration mit dem berüchtigten Kubernetes-Ökosystem stellen wir fest, dass das Umschreiben von Dapp auf Golang die Gelegenheit bot, ein weiteres Projekt zu erstellen - kubedog . So konnten wir den Code für die Nachverfolgung von K8-Ressourcen in einem separaten Projekt isolieren, was nicht nur in werf, sondern auch in anderen Projekten nützlich sein kann . Für die gleiche Aufgabe gibt es andere Lösungen (weitere Einzelheiten finden Sie in unserer letzten Ankündigung ) , aber es wäre kaum möglich, mit ihnen (im Sinne der Beliebtheit) zu konkurrieren, ohne eine Go-Basis zu haben.


    PS


    Lesen Sie auch in unserem Blog:



    Jetzt auch beliebt: