Befehlszeilen-Tool-Entwicklung: Vergleich von Go und Rust

In diesem Artikel wird mein Experiment zum Schreiben eines kleinen Befehlszeilentools in zwei Sprachen erläutert, in denen ich nicht viel Programmiererfahrung habe. Es geht um Go and Rust. Wenn Sie es kaum erwarten können, den Code zu sehen und eine Version meines Programms unabhängig mit einer anderen zu vergleichen, finden Sie hier das Repository der Go-Version des Projekts und hier das Repository der in Rust geschriebenen Version.











Projektübersicht



Ich habe ein Heimprojekt namens Hashtrack. Dies ist eine kleine Site, eine Full-Stack-Anwendung, die ich für ein technisches Interview geschrieben habe. Es ist sehr einfach, damit zu arbeiten:



  1. Der Benutzer wird authentifiziert (vorausgesetzt, er hat bereits ein Konto für sich selbst erstellt).
  2. Er stellt Hashtags vor, die er auf Twitter sehen möchte.
  3. Er wartet darauf, dass die gefundenen Tweets mit dem angegebenen Hashtag auf dem Bildschirm angezeigt werden.


Sie können Hashtrack hier ausprobieren .



Nach Abschluss des Interviews arbeitete ich aus sportlichem Interesse weiter an dem Projekt und stellte fest, dass es eine großartige Plattform sein könnte, auf der ich meine Kenntnisse und Fähigkeiten im Bereich der Entwicklung von Befehlszeilentools testen kann. Ich hatte bereits einen Server, daher musste ich nur eine Sprache auswählen, in der ich eine kleine Reihe von Funktionen in der API meines Projekts implementieren würde.



Funktionen des Befehlszeilen-Tools



Hier finden Sie eine Beschreibung der Hauptfunktionen, insbesondere der Befehle, die ich in meinem Befehlszeilentool implementieren wollte.



  • hashtrack login - sich beim System anmelden, dh ein Sitzungstoken erstellen und im lokalen Dateisystem in der Konfigurationsdatei speichern.
  • hashtrack logout — , — , .
  • hashtrack track <hashtag> [...] — .
  • hashtrack untrack <hashtag> [...] — .
  • hashtrack tracks — , .
  • hashtrack list — 50 .
  • hashtrack watch — .
  • hashtrack status — , .
  • --endpoint, .
  • --config, .
  • endpoint.


Hier sind einige wichtige Dinge, die Sie bei meinem Tool beachten sollten, bevor Sie mit der Arbeit beginnen:



  • Es sollte die Projekt-API verwenden, die GraphQL, HTTP und WebSocket verwendet.
  • Es muss das Dateisystem zum Speichern der Konfigurationsdatei verwenden.
  • Es sollte in der Lage sein, Positionsargumente und Befehlszeilenflags zu analysieren.


Warum habe ich mich für Go and Rust entschieden?



Es gibt viele Sprachen, in denen Sie Befehlszeilentools schreiben können.



In diesem Fall wollte ich eine Sprache wählen, mit der ich keine Erfahrung hatte, oder eine Sprache, mit der ich nur sehr wenig Erfahrung hatte. Außerdem wollte ich etwas finden, das sich leicht in Maschinencode kompilieren lässt, da dies ein zusätzliches Plus für ein Befehlszeilenprogramm ist.



Die erste Sprache, die mir klar ist, kam mir in den Sinn. Dies liegt wahrscheinlich daran, dass viele der von mir verwendeten Befehlszeilentools in Go geschrieben sind. Ich hatte aber auch ein wenig Erfahrung in der Rust-Programmierung, und es schien mir, dass diese Sprache auch für mein Projekt gut geeignet wäre.



Als ich über Go and Rust nachdachte, dachte ich, dass Sie beide Sprachen wählen können. Da mein Hauptziel das Selbststudium war, würde mir ein solcher Schritt eine hervorragende Gelegenheit bieten, das Projekt zweimal umzusetzen und die Vor- und Nachteile jeder Sprache unabhängig voneinander herauszufinden.



Hier möchte ich die Sprachen Crystal und Nim erwähnen . Sie sehen vielversprechend aus. Ich freue mich auf die Gelegenheit, sie in meinem nächsten Projekt zu testen.



Unmittelbare Umgebung



Bevor ich neue Tools verwende, bin ich immer an deren Benutzerfreundlichkeit interessiert. Nämlich, ob ich eine Art Paketmanager verwenden muss, um Programme global auf dem System zu installieren. Oder, was mir eine viel bequemere Lösung erscheint, ob es möglich sein wird, alles basierend auf dem Benutzerkonto zu installieren. Wir sprechen von Versionsmanagern, die unser Leben vereinfachen und sich darauf konzentrieren, Programme auf Benutzern und nicht auf dem gesamten System zu installieren. In der Node.js-Umgebung macht NVM dies sehr gut .



Wenn Sie mit Go arbeiten, können Sie die GVM für denselben Zweck verwenden . Dieses Projekt ist für die lokale Softwareinstallation und Versionskontrolle verantwortlich. Die Installation ist sehr einfach:



gvm install go1.14 -B
gvm use go1.14


Wenn Sie eine Entwicklungsumgebung in Go vorbereiten, müssen Sie sich der Existenz von zwei Umgebungsvariablen bewusst sein - GOROOTund GOPATH. Mehr darüber können Sie hier lesen .



Das erste Problem, mit dem ich bei der Verwendung von Go konfrontiert war, war das folgende. Als ich zu verstehen versuchte, wie das Modulauflösungssystem funktioniert und wie es angewendet wird GOPATH, war es für mich ziemlich schwierig, eine Projektstruktur mit einer funktionierenden lokalen Entwicklungsumgebung einzurichten.



Am Ende habe ich nur das Projektverzeichnis verwendet GOPATH=$(pwd). Das Hauptvorteil davon war, dass mir ein System zur Arbeit mit Abhängigkeiten zur Verfügung stand, das durch den Rahmen eines separaten Projekts begrenzt war, so etwas wie node_modules. Dieses System hat sich gut bewährt.



Nachdem ich mit der Arbeit an meinem Tool fertig war, stellte ich fest, dass es ein virtualgo- Projekt gab , mit dem ich meine Probleme lösen konnte GOPATH.



Rust hat ein offizielles Rustup- Installationsprogramm , das das für die Verwendung von Rust erforderliche Toolkit installiert. Rust kann mit buchstäblich einem Befehl installiert werden. Darüber hinaus rustuphaben wir bei Verwendung Zugriff auf zusätzliche Komponenten wie den rls- Server und den rustfmt- Code- Formatierer . Viele Projekte erfordern nächtliche Builds der Rust-Toolbox. Dank der Anwendung rustuphatte ich kein Problem zwischen den Versionen zu wechseln.



Editor-Unterstützung



Ich verwende VS Code und konnte Erweiterungen für Go und Rust finden. Beide Sprachen werden im Editor perfekt unterstützt.



Um Rust-Code zu debuggen , musste ich nach diesem Tutorial die CodeLLDB- Erweiterung installieren .



Paketverwaltung



Das Go-Ökosystem verfügt weder über einen Paketmanager noch über eine offizielle Registrierung. Hier basiert das Modulauflösungssystem auf dem Importieren von Modulen von externen URLs.



Rust verwendet den Cargo-Paketmanager zum Verwalten von Abhängigkeiten, der Pakete von crates.io aus der offiziellen Rust- Paketregistrierung herunterlädt . In Paketen von Ökosystemkisten können Dokumentationen auf docs.rs veröffentlicht werden .



Bibliotheken



Mein erstes Ziel bei der Erforschung neuer Sprachen war es herauszufinden, wie schwierig es sein würde, eine einfache HTTP-Kommunikation mit einem GraphQL-Server mithilfe von Anforderungen und Mutationen zu implementieren.



Apropos Go, ich habe es geschafft, mehrere Bibliotheken wie machinebox / graphql und shurcooL / graphql zu finden . Der zweite verwendet Strukturen zum Sammeln und Aufheben von Daten. Deshalb habe ich sie gewählt.



Ich habe shurcooL / graphql gegabelt, als ich den Header auf dem Client anpassen musste Authorization. Änderungen werden von dieser PR eingereicht .



Hier ist ein Beispiel für das Aufrufen einer in Go geschriebenen GraphQL-Mutation:



type creationMutation struct {
    CreateSession struct {
        Token graphql.String
    } `graphql:"createSession(email: $email, password: $password)"`
}

type CreationPayload struct {
    Email    string
    Password string
}

func Create(client *graphql.Client, payload CreationPayload) (string, error) {
    var mutation creationMutation
    variables := map[string]interface{}{
        "email":    graphql.String(payload.Email),
        "password": graphql.String(payload.Password),
    }
    err := client.Mutate(context.Background(), &mutation, variables)

    return string(mutation.CreateSession.Token), err
}


Bei der Verwendung von Rust musste ich zwei Bibliotheken verwenden, um GraphQL-Abfragen auszuführen. Der Punkt hier ist, dass die Bibliothek graphql_clientprotokollunabhängig ist und darauf abzielt, Code zum Serialisieren und Deserialisieren von Daten zu generieren. Daher brauchte ich eine zweite Bibliothek ( reqwest), mit der ich die Arbeit mit HTTP-Anfragen organisierte.



#[derive(GraphQLQuery)]
#[graphql(
    schema_path = "graphql/schema.graphql",
    query_path = "graphql/createSession.graphql"
)]
struct CreateSession;

pub struct Session {
    pub token: String,
}

pub type Creation = create_session::Variables;

pub async fn create(context: &Context, creation: Creation) -> Result<Session, api::Error> {
    let res = api::build_base_request(context)
        .json(&CreateSession::build_query(creation))
        .send()
        .await?
        .json::<Response<create_session::ResponseData>>()
        .await?;
    match res.data {
        Some(data) => Ok(Session {
            token: data.create_session.token,
        }),
        _ => Err(api::Error(api::get_error_message(res).to_string())),
    }
}


Keine der Go- und Rust-Bibliotheken unterstützte GraphQL über das WebSocket-Protokoll.



Tatsächlich unterstützt die Bibliothek graphql_clientAbonnements, aber da sie protokollunabhängig ist, musste ich die WebSocket-Interaktionsmechanismen mit GraphQL selbst implementieren.



Um WebSocket in der Go-Version der Anwendung verwenden zu können, musste die Bibliothek geändert werden. Da ich bereits einen Zweig der Bibliothek benutzt habe, wollte ich das nicht tun. Stattdessen habe ich eine vereinfachte Methode zum "Anschauen" neuer Tweets verwendet. Um Tweets zu erhalten, habe ich nämlich alle 5 Sekunden Anfragen an die API gesendet. Ich bin nicht stolz darauf, dass ich genau das getan habe .



Wenn Sie Programme in Go schreiben, können Sie das Schlüsselwort verwendengoleichte Streams namens Goroutinen laufen zu lassen. Rust verwendet Betriebssystem-Threads. Dies erfolgt durch Aufrufen Thread::spawn. Kanäle werden verwendet, um Daten zwischen Streams und dort und dort zu übertragen.



Fehlerverarbeitung



Go behandelt Fehler genauso wie jeden anderen Wert. Der übliche Weg, um Fehler in Go zu behandeln, besteht darin, nach Fehlern zu suchen:



func (config *Config) Save() error {
    contents, err := json.MarshalIndent(config, "", "    ")
    if err != nil {
        return err
    }

    err = ioutil.WriteFile(config.path, contents, 0o644)
    if err != nil {
        return err
    }

    return nil
}


Rust hat eine Aufzählung Result<T, E>, die Werte enthält, die Erfolg oder Misserfolg anzeigen. Dies jeweils Ok(T)und Err(E). Hier gibt es eine weitere Aufzählung Option<T>, die die Werte Some(T)und enthält None. Wenn Sie mit Haskell vertraut sind, können Sie die Monaden Eitherund in diesen Bedeutungen erkennen Maybe.



Es gibt auch "syntaktischen Zucker" im Zusammenhang mit der Fehlerausbreitung (Operator ?), der den Wert der Struktur Resultentweder auflöst Optionund automatisch zurückgibt Err(...)oder Nonewenn etwas schief geht.



pub fn save(&mut self) -> io::Result<()> {
    let json = serde_json::to_string(&self.contents)?;
    let mut file = File::create(&self.path)?;
    file.write_all(json.as_bytes())
}


Dieser Code entspricht dem folgenden Code:



pub fn save(&mut self) -> io::Result<()> {
    let json = match serde_json::to_string(&self.contents) {
        Ok(json) => json,
        Err(e) => return Err(e.into())
    };
    let mut file = match File::create(&self.path) {
        Ok(file) => file,
        Err(e) => return Err(e.into())
    };
    file.write_all(json.as_bytes())
}


Rust hat also Folgendes:



  • Monadische Struktur ( Optionund Result).
  • Bedienerunterstützung ?.
  • Ein Merkmal From, mit dem Fehler automatisch konvertiert werden, wenn sie sich verbreiten.


Die Kombination der oben genannten drei Funktionen gibt uns ein Fehlerbehandlungssystem, das ich als das Beste bezeichnen würde, das ich je gesehen habe. Es ist einfach und rationalisiert, und der damit geschriebene Code ist leicht zu pflegen.



Kompilierungszeit



Go ist eine Sprache, die mit der Idee erstellt wurde, dass der darin geschriebene Code so schnell wie möglich kompiliert wird. Lassen Sie uns diese Frage untersuchen:



> time go get hashtrack #  
go get hashtrack  1,39s user 0,41s system 43% cpu 4,122 total

> time go build -o hashtrack hashtrack #  
go build -o hashtrack hashtrack  0,80s user 0,12s system 152% cpu 0,603 total

> time go build -o hashtrack hashtrack #  
go build -o hashtrack hashtrack  0,19s user 0,07s system 400% cpu 0,065 total

> time go build -o hashtrack hashtrack #      
go build -o hashtrack hashtrack  0,94s user 0,13s system 169% cpu 0,629 total


Beeindruckend. Nun wollen wir sehen, was Rust uns zeigen wird:



> time cargo build
   Compiling libc v0.2.67
   Compiling cfg-if v0.1.10
   Compiling autocfg v1.0.0
   ...
   ...
   ...
   Compiling hashtrack v0.1.0 (/home/paulo/code/cuchi/hashtrack/cli-rust)
    Finished dev [unoptimized + debuginfo] target(s) in 1m 44s
cargo build  363,80s user 17,05s system 365% cpu 1:44,09 total


Hier werden alle Abhängigkeiten kompiliert, das sind 214 Module. Wenn Sie die Kompilierung neu starten, ist bereits alles vorbereitet, sodass diese Aufgabe fast sofort ausgeführt wird:



> time cargo build #  
    Finished dev [unoptimized + debuginfo] target(s) in 0.08s
cargo build  0,07s user 0,03s system 104% cpu 0,094 total

> time cargo build #      
   Compiling hashtrack v0.1.0 (/home/paulo/code/cuchi/hashtrack/cli-rust)
    Finished dev [unoptimized + debuginfo] target(s) in 3.15s
cargo build  3,01s user 0,52s system 111% cpu 3,162 total


Wie Sie sehen können, verwendet Rust ein inkrementelles Kompilierungsmodell. Eine teilweise Neukompilierung des Abhängigkeitsbaums wird durchgeführt, beginnend mit dem geänderten Modul und endend mit den Modulen, die davon abhängen.



Der Release-Build des Projekts benötigt mehr Zeit, was durchaus zu erwarten ist, da der Compiler den Code in diesem Fall optimiert:



> time cargo build --release
   Compiling libc v0.2.67
   Compiling cfg-if v0.1.10
   Compiling autocfg v1.0.0
   ...
   ...
   ...
   Compiling hashtrack v0.1.0 (/home/paulo/code/cuchi/hashtrack/cli-rust)
    Finished release [optimized] target(s) in 2m 42s
cargo build --release  1067,72s user 16,95s system 667% cpu 2:42,45 total


Kontinuierliche Integration



Die oben identifizierten Funktionen zum Kompilieren von Projekten in Go und Rust erscheinen, wie zu erwarten ist, im kontinuierlichen Integrationssystem.





Gehen Sie zur Projektbearbeitung





Bearbeitung eines Rust-Projekts



Speicherverbrauch



Um den Speicherverbrauch verschiedener Versionen meines Befehlszeilentools zu analysieren, habe ich den folgenden Befehl verwendet:



/usr/bin/time -v ./hashtrack list


Der Befehl time -vzeigt viele interessante Informationen an, aber ich war an der Prozessmetrik interessiert Maximum resident set size, bei der es sich um die maximale Menge an physischem Speicher handelt, die einem Programm während seiner Ausführung zugewiesen wurde.



Hier ist der Code, mit dem ich Speicherverbrauchsdaten für verschiedene Versionen des Programms erfasst habe:



for n in {1..5}; do
    /usr/bin/time -v ./hashtrack list > /dev/null 2>> time.log
done
grep 'Maximum resident set size' time.log


Hier sind die Ergebnisse für die Go-Version:



Maximum resident set size (kbytes): 13632
Maximum resident set size (kbytes): 14016
Maximum resident set size (kbytes): 14244
Maximum resident set size (kbytes): 13648
Maximum resident set size (kbytes): 14500


Hier ist der Speicherverbrauch der Rust-Version des Programms:



Maximum resident set size (kbytes): 9840
Maximum resident set size (kbytes): 10068
Maximum resident set size (kbytes): 9972
Maximum resident set size (kbytes): 10032
Maximum resident set size (kbytes): 10072


Dieser Speicher wird während der folgenden Aufgaben zugewiesen:



  • Interpretation von Systemargumenten.
  • Laden und Parsen der Konfigurationsdatei aus dem Dateisystem.
  • Zugriff auf GraphQL über HTTP mit TLS.
  • Analysieren der JSON-Antwort.
  • Schreiben formatierter Daten in stdout.


Go und Rust haben verschiedene Möglichkeiten, den Speicher zu verwalten.



Go verfügt über einen Garbage Collector, mit dem nicht verwendeter Speicher erkannt und zurückgefordert wird. Dadurch wird der Programmierer von diesen Aufgaben nicht abgelenkt. Da der Garbage Collector auf heuristischen Algorithmen basiert, bedeutet die Verwendung immer, Kompromisse einzugehen. In der Regel zwischen der Leistung und der von der Anwendung verwendeten Speichermenge.



Das Speicherverwaltungsmodell von Rust umfasst Konzepte wie Eigentum, Ausleihe und Lebensdauer. Dies trägt nicht nur zur sicheren Speicherverwaltung bei, sondern gewährleistet auch die vollständige Kontrolle über den auf dem Heap zugewiesenen Speicher, ohne dass eine manuelle Speicherverwaltung oder Speicherbereinigung erforderlich ist.



Schauen wir uns zum Vergleich andere Programme an, die ein ähnliches Problem wie ich lösen.



Befehl Maximale Größe des residenten Satzes (KB)
heroku apps 56436
gh pr list 26456
git ls-remote (mit SSH-Zugang) 6448
git ls-remote (mit HTTP-Zugang) 23488


Gründe, warum ich Go wählen würde



Ich würde Go für ein Projekt aus folgenden Gründen wählen:



  • Wenn ich eine Sprache brauchte, die für meine Teammitglieder leicht zu lernen wäre.
  • Wenn ich einfachen Code auf Kosten einer geringeren Flexibilität der Sprache schreiben wollte.
  • Wenn ich Software nur für Linux entwickelte oder wenn Linux das Betriebssystem war, das mich am meisten interessierte.
  • Wenn die Kompilierungszeit von Projekten wichtig war.
  • Wenn ich ausgereifte Mechanismen für die asynchrone Codeausführung benötigte.


Gründe, warum ich mich für Rust entscheiden würde



Hier sind die Gründe, die mich dazu bringen könnten, Rust für ein Projekt zu wählen:



  • Wenn ich ein erweitertes Fehlerbehandlungssystem brauchte.
  • Wenn ich in einer Multi-Paradigmen-Sprache schreiben wollte, die es mir ermöglicht, ausdrucksstärkeren Code zu schreiben, als ich mit anderen Sprachen erstellen könnte.
  • Wenn mein Projekt sehr hohe Sicherheitsanforderungen hätte.
  • Wenn hohe Leistung für das Projekt entscheidend war.
  • Wenn das Projekt auf viele Betriebssysteme ausgerichtet wäre und ich eine wirklich plattformübergreifende Codebasis haben möchte.


Allgemeine Bemerkungen



Go and Rust haben einige Macken, die mich immer noch verfolgen. Dies sind die folgenden:



  • Go ist so auf Einfachheit ausgerichtet, dass dieses Streben manchmal den gegenteiligen Effekt hat (zum Beispiel wie in den Fällen mit GOROOTund GOPATH).
  • Ich verstehe das Konzept der "Lebenszeit" in Rust immer noch nicht wirklich. Selbst Versuche, mit den entsprechenden Sprachmechanismen zu arbeiten, bringen mich aus dem Gleichgewicht.


Ja, ich möchte darauf hinweisen, dass das Arbeiten mit Go in neueren Versionen von Go GOPATHkeine Probleme mehr verursacht. Daher sollte ich mein Projekt auf eine neuere Version von Go übertragen.



Ich kann sagen, dass sowohl Go als auch Rust Sprachen sind, deren Erlernen sehr interessant war. Ich finde, dass sie eine großartige Ergänzung zu den Fähigkeiten der C / C ++ - Programmierwelt sind. Mit ihnen können Sie Anwendungen für eine Vielzahl von Zwecken erstellen. Zum Beispiel Webdienste und dank WebAssembly sogar clientseitige Webanwendungen .



Ergebnis



Go und Rust sind großartige Tools, die sich gut für die Entwicklung von Befehlszeilen-Tools eignen. Aber natürlich ließen sich ihre Schöpfer von unterschiedlichen Prioritäten leiten. Eine Sprache zielt darauf ab, die Softwareentwicklung einfach und zugänglich zu machen, damit der in dieser Sprache geschriebene Code gewartet werden kann. Die Prioritäten der anderen Sprache sind Rationalität, Sicherheit und Leistung.



Wenn Sie mehr über den Go and Rust-Vergleich erfahren möchten, lesen Sie diesen Artikel. Dies wirft unter anderem ein Problem hinsichtlich schwerwiegender Probleme bei der plattformübergreifenden Kompatibilität von Programmen auf.



Mit welcher Sprache würden Sie ein Befehlszeilentool entwickeln?






All Articles