Go: Rückblick nach einem Jahr

von Tobias Kündig

    Das Wichtigste in Kürze

  • Die Standardbibliothek für automatisierte Tests
    Mit entsprechendem Setup reicht die Standardbibliothek von Go für automatisierte Tests gut aus.
  • Integrationstests sind etwas komplexer
    Werden Tests mit einer richtigen Datenbank ausgeführt, wird das Zusammenspiel einiger Bibliotheken benötigt.
  • Das Ökosystem ist gut ausgereift
    Für nahezu jedes Problem gibt es eine stabile Bibliothek, die bei der Problemlösung hilft.

Unser Rückblick nach einem Jahr

Seit rund einem Jahr setzen wir die Programmiersprache «Go» aktiv in unseren Web-Projekten ein. Neben einigen internen Tools, arbeiten wir seit einigen Monaten auch an der Umsetzung einer neuen Version unserer Pflegesoftware «CareSuite», deren Backend vollständig in Go realisiert wird.

Im ersten Teil dieser Blog-Serie sind wir auf die Themen Produktivität und Datenbanken eingegangen.

In diesem zweiten Teil unseres Go-Rückblicks gehen wir auf weitere unerwartete Probleme und Überraschungen ein, die wir in einem Jahr mit der Programmiersprache «Go» erlebt haben. Der Fokus liegt hierbei besonders auf dem Testing und dem Go-Ökosystem.

Testing

Die Standard-Bibliothek reicht (fast) aus

Beim Testing nutzen wir fast ausschliesslich das native testing Package. Dieses bringt alle nötigen Funktionalitäten mit, um Tests in Go zu schreiben. Wir setzen hierbei auf zahlreiche Subtests, was das Setup von Tests vereinfacht. Lagert man die Subtests zudem in separate Funktionen aus, kommen die _test.go Dateien sehr übersichtlich daher und bieten auf einen Blick eine Übersicht aller Testfälle:

type setupFn = func() (*Service, func())

func TestService(t *testing.T) {
    // In der Setup-Funktion werden alle Abhängigkeiten vorbereitet.
    // Zudem wird eine "cleanup" Funktion zurückgegeben, die nach
    // der Durchführung eines Tests das System bereinigt.
    setup := func() (*Service, func()) {
        return NewService(), func() {
            fmt.Println("Cleaning up...")
        }
    }

    // Die Subtests dienen als "Inhaltsverzeichnis" 
    // für die Test-Suite.
    t.Run("Get", get(setup))
    t.Run("Find", find(setup))
    t.Run("Create", create(setup))
    t.Run("Update", update(setup))
    t.Run("Delete", del(setup))
}

// Die Subtests werden für mehr Übersicht 
// in separate Funktionen ausgelagert.
func get(setup setupFn) func(t *testing.T) {
    return func(t *testing.T) {
        service, cleanup := setup()
        defer cleanup()
        t.Logf("Testing with %v...", service)
    }
}

Seit Go 1.14 gibt es alternativ die t.Cleanup() Funktion als native Möglichkeit für Cleanups nach den Tests.

Für die Assertions setzen wir auf das «assert»-Package der Testify-Bibliothek. Dieses vereinfacht die sich immer wiederholenden Assertions am Ende der Tests:

// Anstelle der aufwändigen Assertions...
if got != want {
    t.Errorf("got %d, want %d", got, want)
}

// ... reduziert das assert Package alles auf eine Zeile.
assert.Equal(t, want, got)

Das assert Package formatiert zudem den Output der fehlgeschlagenen Tests automatisch.

Die Zeit als Abhängigkeit

Die Zeit ist eine externe Abhängigkeit, welche abstrahiert werden muss, um zeitabhängige Tests einfacher durchführen zu können. Dies ist auch in Go nicht anders. Leider bietet das time-Package keine native Methode, um zeitabhängige Tests zu vereinfachen.

Als Beispiel dient folgender Testfall, der eine Sperr-Fuktion von Benutzerkonten testet. Ein Benutzerkonto wird dabei für 5-Minuten gesperrt. Nach Ablauf der 5-Minuten sollte das Benutzerkonto automatisch wieder entsperrt sein.

Um das 5-Minuten-Timeout zu testen, müsste ohne saubere Abstraktion der Zeit, effektiv für 5 Minuten gewartet werden:

store := NewUserStore()
user := store.Find(1)

// Ein Benutzerkonto wird für 5 Minuten gesperrt
store.LockAccount(user)
assert.True(t, user.IsLocked()) 

// Wie wird nun getestet, dass das Benutzerkonto in 
// 5 Minuten wieder entsperrt wird? 
time.Sleep(5 * time.Minute) // Wohl eher unpraktisch!

assert.False(t, user.IsLocked()) 

Um dieses Problem zu lösen, kann ein eigenes Clock Package genutzt werden. Dieses wird immer anstelle von time.Now() verwendet. Wird nichts anderes definiert, liefert dieses Package die aktuelle Zeit zurück. Über eine setNow() methode lässt sich jedoch eine bestimmte Uhrzeit forcieren:

store := NewUserStore()
// Verwende die aktuelle Uhrzeit.
store.Clock.FromTime(time.Now()) 

user := store.Find(1)

// Ein Benutzerkonto wird für 5 Minuten gesperrt.
store.LockAccount(user)
assert.True(t, user.IsLocked()) 

// Setze die Uhrzeit neu, 6 Minuten in die Zukunft.
store.Clock.SetTime(time.Now().Add(6 * time.Minute)) 

// Der Test funktioniert jetzt ohne lange Wartezeiten!
assert.False(t, user.IsLocked()) 

Datenbank-Testing ist nicht ganz einfach

Integrations-Tests, die mit einer richtigen Datenbank arbeiten, sind in Go eher umständlich zu realisieren. Diese Aussage trifft vor allem im Vergleich mit anderen Frameworks wie «Laravel» zu, das meiner Meinung nach das Datenbank-Testing nahezu perfektioniert hat.

In Laravel wird jeder einzelne Testfall in einer SQL-Transaktion ausgeführt. Diese Tranksaktion wird am Ende jedes Tests zurückgerollt, was die Datenbank wieder in ihren Ursprungszustand zurückversetzt. So hat jeder Testfall den gleichen Ausgangspunkt.

In Go werden Testfälle standardmässig parallel ausgeführt (1 Test pro CPU-Kern), was dieses Setup zusätzlich erschwert. Diese Einstellung kann zwar überschrieben werden, doch ist gerade die parallele Ausführung der Tests eine der Geheimzutaten, warum Tests in Go so unglaublich schnell ausgeführt werden.

go-txdb bietet für Go eine Möglichkeit, automatisch alle SQL-Abfragen in einer Transaktion auszuführen. Wenn zuvor noch Migrationen und Daten-Seeding ausgeführt werden müssen, benötigt es jedoch einiges an Setup-Code für die Integrationstests. Dennoch ist die resultierende Funktionalität mit der von Laravel vergleichbar.

Für die Migration der Datenbank verwenden wir das golang-migrate Package. Testdaten seeden wir mit polluter.

Eine Setup-Funktion (ohne Error-Checks) für Integrations-Tests könnte dann wie folgt aussehen:

package test

import (
    "fmt"
    "os"
    "testing"

    "github.com/jmoiron/sqlx"
    "github.com/DATA-DOG/go-txdb"
    "github.com/golang-migrate/migrate/v4"
    "github.com/romanyx/polluter"

    // MySQL Support und File-Quellen für Migrationen aktivieren
    _ "github.com/go-sql-driver/mysql"
    _ "github.com/golang-migrate/migrate/v4/source/file"
)

// TestDB liefert eine sqlx Datenbankverbindung zur Testdatenbank
// zurück, in der Testdaten importiert wurden.
func TestDB(t *testing.T) (dbConn *sqlx.DB, cleanup func()) {
    dsn := fmt.Sprintf(
        "%s:%s@(%s:%d)/%s?multiStatements=true&parseTime=true&collation=utf8mb4_general_ci",
        "username",
        "password",
        "localhost",
        3306,
        "database",
    )
    // Überschreibe den "mysql"-Treiber mit der txdb Variante. 
    // Dies stellt sicher, dass alle Queries in einer
    // Transaktion ausgeführt werden.
    txdb.Register("txdb", "mysql", dsn)

    // Datenbank-Verbindung mit dem überschriebenen
    // mysql-Treiber herstellen.
    dbConn, _ := sqlx.Open("mysql", dsn)

    // Die Migrationen laden, alle Tabellen löschen und anschliessend 
    // die Migrationen auf der leeren Datenbank ausführen.
    m, _ := migrate.NewWithDatabaseInstance("file://path/to/migrations", "mysql", dbConn)
    _ = m.migrate.Drop()
    _ = m.migrate.Up()

    // Seed-Daten aus einer YAML-Datei laden.
    seed, _ := os.Open("/path/to/testing-seed.yml")
    defer seed.Close()

    // Die Seed-Daten mit polluter in die Datenbank schreiben.
    p := polluter.New(polluter.MySQLEngine(dbConn))
    _ = p.Pollute(seed)

    // In der cleanUp Funktion kann die Testumgebung bereinigt werden.
    cleanUp := func() {
        _ = dbConn.Close()
    }

    return dbConn, cleanUp
}

Angewendet wird diese Funktion dann wie folgt:

func Test_Integration(t *testing.T) {
    // "db" ist eine Datenbankverbindung auf eine
    // vollständig erstellte Test-Datenbank in der
    // die Daten mittels Transaktionen nach jedem Test
    // wieder zurückgerollt werden.
    db, cleanup := test.TestDB(t)
    defer cleanup()

    t.Run("TestUserCreation", func (t *testing.T) {
        userSvc := user.NewService(db)
        // Der erstellte Benutzer ist während der Ausführung
        // des Tests vorhanden, wird aber nach Abschluss
        // automatisch wieder gelöscht!
        admin := userSvc.Create(&entity.User{
            Username: "admin"
        })

        assert.NotNil(t, userSvc.Find(admin.ID))
    })
}

Ökosystem

Das Ökosystem an Bibliotheken in Go ist gut ausgereift. Bei nahezu jedem Problem lässt sich ein stabiles Package eines Drittanbieters finden.

Die im November 2019 neu veröffentlichte Website pkg.go.dev hilft dabei, populäre Packages zu finden. Dank der Ergänzung von Go Modules seit Version 1.11 lassen sich Abhängigkeiten ähnlich verwalten, wie man es von Composer (PHP) oder npm (JavaScript) kennt.

Alle Projektabhängigkeiten werden dabei in einer go.mod Datei aufgelistet. Mittels dem go get Befehl werden diese dann heruntergeladen und für die Verwendung im eigenen Code vorbereitet.

module caresuite  

go 1.12  

require (  
    github.com/BurntSushi/toml v0.3.1  
    github.com/blang/semver v3.5.1+incompatible  
    github.com/getsentry/sentry-go v0.2.1  
    github.com/magefile/mage v1.9.0  
    github.com/pkg/errors v0.8.1  
    github.com/sirupsen/logrus v1.4.2  
)

Wird eine Abhängigkeit zum Beispiel direkt via go get github.com/BurntSushi/toml installiert, wird sie automatisch in der go.mod Datei ergänzt.

Anders als bei anderen Projekten wird gänzlich auf spezifische Package-Namen verzichtet. Dies weiss man ab der ersten Minute zu schätzen: Möchte man in Go beispielsweise das offizielle statsd/client-Package nutzen, reicht ein import "github.com/statsd/client" Statement bereits aus. Es muss also nicht zuerst herausgefunden werden, welchen Namen das Package in der Paketverwaltung hat (wie bei npm). Dies ist nicht nur einfacher zu handhaben, sondern erhöht auch die Sicherheit.

Im August 2017 wurde beispielsweise entdeckt, dass via npm die crossenv library genutzt wurde, um Malware zu verbreiten. Nutzer, die eigentlich die beliebte cross-env Library installieren wollten, wurden durch den nahezu gleichen Namen in die Irre geführt.

Was im Vergleich zu anderen Ökosystem auch schnell auffällt: In Go gibt es sehr selten neue Major-Versionen bei Abhängigkeiten, also Updates, die nicht mehr rückwärtskompatibel sind und meist einiges an Aufwand für den Entwickler bedeuten.

Dies kommt zum einen davon, dass es in Go meistens nur eine «richtige» Variante gibt, Code zu schreiben, da die Syntax der Sprache kaum Spielraum für Kreativität zulässt. Zudem ist auch Go selbst seit über zehn Jahren komplett Rückwärtskompatibel geblieben. Somit fallen viele Gründe für komplette «Rewrites» eines Projekts weg, die es in anderen Ökosystemen oft zu Genüge gibt.

Entwicklertools

Linting

Als «Linting» bezeichnet man die automatische Überprüfung von Code auf Formatierungs- oder auch Programmierfehler. Für das Linting von Go-Code gibt es eine Vielzahl an eigenständigen «Lintern». Diese kommen meist als einfache Kommandozeilen-Tools daher.

Wer nicht 10 verschiedene Linter für ein Projekt manuell einrichten möchte, kann den grossartigen golangci-lint Linteraggregator verwenden. golangci-lint ermöglicht es, in einer einzelnen Konfigurationsdatei eine Vielzahl an Lintern zu konfigurieren und diese mit einem einzelnen Befehl auszuführen.

Für das Linting während des CI/CD-Prozesses ist dieses Tool für uns unumgänglich.

Live Reloading

Da Go-Binaries für die Ausführung immer kompiliert werden müssen, lohnt es sich ein «Live Reloading» Tool zu verwenden. Dieses überwacht den Code auf Änderungen, kompiliert das Go-Binary und führt es umgehend aus, sobald eine Datei verändert wurde.

Hierfür gibt es einige Projekte, von denen manches mehr, manches weniger aktiv unterhalten wird. Das Tool unserer Wahl ist air. Es bietet diverse Installationsmöglichkeiten (via Binary und Docker) und hat zahlreiche Optionen, die es ermöglichen, den Live-Reload Arbeitsablauf ideal zu konfigurieren.

Magefiles

Bei grösseren Projekten können Go-Befehle zum Testen, Builden und Generieren von Code schnell komplex werden. Auch erhöht sich deren Anzahl mit zunehmender Projektgrösse. Möchten wir beispielsweise ein Binary für den Produktiveinsatz generieren und eine Versionsnummer darin einbetten, braucht es für go build zusätzliche Attribute:

go build -ldflags -X caresuite/internal/app.version=5.0.0-dev -s -w -o output/caresuite

Auch gibt es für verschiedene Test-Szenarien unterschiedliche Befehle:

# Frontend-Tests
yarn --cwd web test:unit
# Backend-Tests
go test ./... -test.short
# Integrations-Tests
go test ./...
# Frontend-Linter
yarn --cwd web lint
# Backend-Linter
golangci-lint run --fix

Damit diese nicht auswendig gelernt werden müssen, werden oft Makefiles verwendet. Makefiles ermöglichen es, komplexen Befehlen einfachere «Aliase» (auch «Targets» genannt) zu geben. Beispielsweise können mit dem Befehl make test Tests ausgeführt oder via make build das Binary generiert werden.

Die dazugehörige Software Make stammt aus dem 70er-Jahren, was sich in der Handhabung bemerkbar macht: Die Syntax von Makefiles ist sehr fehleranfällig und Fehlermeldungen sind oft nichtssagend.

Auf der Suche nach einer moderneren Variante sind wir auf das Mage-Projekt gestossen. Mage funktioniert vom Prinzip her genau gleich wie Make, bietet jedoch die Möglichkeit, die verschiedenen «Targets» in reinem Go-Code zu verfassen:

// +build mage

package main

import (
        "fmt"
        "os"

        "github.com/magefile/mage/sh"
)

// Error-Checks wurden entfernt.
func Build() error {
        var version string
        // Verwende Version aus Umgebungsvariablen, falls vorhanden.
        // Diese wird im CI/CD-Prozess definiert.
        version = os.Getenv("VERSION")
        if version == "" {
                // Wenn keine Version vorhanden ist, 
                // verwende Informationen aus Git.
                version, _ = getGitVersion()
        }

        flags := fmt.Sprintf(`-X main.version=%s -s -w`, version)
        env := map[string]string{
                "CGO_ENABLED": "0",
                "GOOS":        "linux",
                "GOARCH":      "amd64",
        }

        // "go build" mit entsprechenden Parametern ausführen.
        if err := sh.RunWith(env, "go", "build", "-ldflags", flags, "-o", "output/caresuite"); err != nil {
                return err
        }
        return nil
}

// Lade Versionsinformationen aus Git.
func getGitVersion() (string, error) {
        commit, _ := sh.Output("git", "rev-parse", "--short", "HEAD")
        branch, _ := sh.Output("git", "rev-parse", "--abbrev-ref", "HEAD")

        return fmt.Sprintf("%s-%s", commit, branch), nil
}

Ausgeführt werden die Targets dann mit dem mage Binary:

$ mage -v build:backend 

Running target: Build:Backend
exec: git rev-parse --short HEAD
exec: git rev-parse --abbrev-ref HEAD
exec: go build -ldflags -X caresuite/internal/app.version=afb2b3e-develop -s -w -o output/caresuite

Ausblick auf Teil 3

Im dritten und letzen Teil dieser Blog-Serie möchten wir auf eines der schwierigsten Themen für neue Go-Entwickler eingehen: die Applikations-Architektur.

Du findest den dritten Teil in einigen Wochen in unseren Blog.

Der erste Teil dieser Blog-Serie ist seit letzem Dezember online.

Mehr aus dieser Serie
Go

Tutorials, Informationen und Gedanken zur Go-Programmiersprache.

Weitere Beiträge anzeigen »

Los geht's!

Kontaktiere uns noch heute

Für Offerten, technische Anfragen oder einfach nur um Hallo zu sagen.

Wir verwenden Cookies um die Performance unserer Website zu messen. Möchtest du diese Cookies akzeptieren?