Semantische Versionierung von Go-Modulen

Semantische Versionierung von Go-Modulen

Das Kompatibilitätsversprechen von Go/Golang deckt hauptsächlich die Kernsprache und Standardbibliothek ab. Die Versionierung externer Abhängigkeiten, die über import-Anweisungen eingebunden werden, unterliegen der Versionierung der jeweiligen Bibliothek und fallen strenggenommen nicht unter dieses Versprechen. Eine Aktualisierung kann dementsprechend zu sogenannten Breaking Changes führen, bei denen der eigene Code angepasst werden muss.

Zum Glück orientieren sich allerdings die meisten Bibliotheken ebenfalls am Kompatibilitätsversprechen des Go-Teams und setzen auf Semantic-Versioning und der Kompatibilität ihrer API.

Dieser Artikel gibt Einblicke in die Funktionsweise von Go und zeigt dir, wie du diese Erkenntnisse in deine eigenen Projekte, sowohl Bibliotheken als auch Anwendungen, integrieren kannst.

Go-Programme, die für eine bestimmte Version der Sprache geschrieben wurden, sollen durch das Kompatibilitätsversprechen ohne Anpassung auch mit späteren Versionen der Sprache weiterhin funktionieren.

Kompatibilität

Kompatibilität

Wie werden Bibliotheken überhaupt eingebunden?

Mit der Einführung von Go-Modulen in Go 1.11 verwalten Anwendungen und Bibliotheken ihre Abhängigkeiten explizit in einer go.mod-Datei. Diese Datei enthält neben den Abhängigkeiten auch den eindeutigen Modulnamen.

Dieser Modulname wird als Präfix in import-Anweisungen dazu verwendet, um Pakete dieses Moduls zu importieren. Der Name setzt sich dabei aus zwei Komponenten zusammen:

  • Modulpfad - URL unter der das Modul gefunden werden kann
  • optionaler Versionsangabe

Folgende Datei zeigt als Beispiel eine go.mod Datei einer Bibliothek ohne Abhängigkeit:

module github.com/sourcefellows/mylib

go 1.23.6

Soll diese Bibliothek in eine Anwendung eingebunden werden, kann diese über ein entsprechendes import-Statement eingebunden werden:

import "github.com/sourcefellows/mylib"

Die Implementierung der Bibliothek sieht so aus:

// Verbesserungsfähiges Beispiel!! 
// bessere Version kommt weiter unten
package mylib

import "regexp"

func HideDigits(text string) (string, error) {

	compile, err := regexp.Compile("\\d")
	if err != nil {
		return "", err
	}
	return compile.ReplaceAllLiteralString(text, "X"), nil

}

In der Anwendung sieht das Ganze dann so aus:

package main

import (
	"fmt"
	"log"

	"github.com/sourcefellows/mylib"
)

func main() {

	result, err := mylib.HideDigits("meine12345Nummer")
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println(result)

}

Signaturänderung führt zu Breaking-Change

In der obigen Anwendung bzw. der Bibliothek sieht man wahrscheinlich recht schnell, dass der Code nicht ideal ist und bei jedem Aufruf der Bibliothek der reguläre Ausdruck erneut kompiliert wird.

Durch ein kleines Refactoring kann dieses Problem einfach gelöst werden:

// Noch nicht perfekt, aber besser.
package mylib

import "regexp"

var compile = regexp.MustCompile("\\d")

func HideDigits(text string) string {
	return compile.ReplaceAllLiteralString(text, "X")
}

Allerdings hat sich mit dieser kleinen Änderung ein weitaus größeres Problem eingeschlichen. Die API der Bibliothek ist nicht mehr kompatibel, da sich in diesem Zuge auch die Signatur der Funktion verändert hat.

Statt einem String und einem Fehler-Objekt, liefert die Funktion ab sofort nur noch einen String-Wert zurück. Ein Breaking Change!

Open-Closed-Principle für API

Mit der oben beschriebenen inkompatiblen Änderung wird das Kompatibilitätsversprechen gebrochen. Für Packages besagt es, dass:

Packages dürfen nur API-kompatibel und abwärtskompatibel geändert werden.

Das heißt, solange das import-Statement gleich bleibt muss ein Update der Bibliothek abwärtskompatibel sein.

Open-Close-Principle - Das Open-Close-Principle besagt, dass Softwareentitäten (wie Klassen, Module oder Funktionen) offen für Erweiterungen, aber geschlossen für Modifikationen sein sollten.

Im Beispiel hätte man die API erweitern können und eine neue Funktion hinzufügen können:

//alte Implementierung
func HideDigits(text string) (string, error) {
    ...
}
//neue Implementierung
func MustHideDigits(text string) (string) {
    ...
}

Somit hätten alte Clients der Bibliothek ohne Anpassung weiter eingesetzt werden können und neue Clients hätten die neue API verwenden können.

Unterschiedliche Signaturen

Unterschiedliche Signaturen

Module versionieren

Möchte man stattdessen nur noch die neue Version der API anbieten, kann auch das Bibliotheks-Modul als Ganzes versioniert und aktualisiert werden.

Wie bereits erwähnt kann der Modulname eine optionale Version enthalten. Ein Update würde innerhalb der go.mod dann so aussehen:

module github.com/sourcefellows/mylib/v2

go 1.23.6

Wichtig ist hier die Angabe v2 am Ende des Modulnamens.

Innerhalb der aufrufenden Anwendung kann jetzt durch einen Update des import-Statements die neue Version eingesetzt werden. Wird das import-Statement nicht angepasst, wird weiterhin die alte Version verwendet.

package main

import (
	"fmt"

	"github.com/sourcefellows/mylib/v2"
)

func main() {

	result := mylib.HideDigits("meine12345Nummer")
	fmt.Println(result)

}

Jede Anwendung kann nun explizit entscheiden mit welcher Version gearbeitet werden soll. Und was auch funktioniert…

Die Bibliotheksversionen werden bei Go als Git Tags im Repository abgelegt. Für das Beispiel ist für Version 1 das Tag v1.0.0 angelegt. Version zwei gibt es entweder im main Branch oder unter v2.0.0.

Das komplette Beispiel kann auch in GitHub gefunden werden. MyLib und MyLibApplication

Zwei Versionen in einer Anwendung

In Go ist es ebenfalls möglich zwei Versionen einer Bibliothek gleichzeitig zu nutzen:

Die eindeutigen import-Statements machen das möglich!

Erstaunter Programmierer

Erstaunter Programmierer

package main

import (
	"fmt"
	"log"

	"github.com/sourcefellows/mylib"
	newver "github.com/sourcefellows/mylib/v2"
)

func main() {

	result, err := mylib.HideDigits("meine12345Nummer")
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println(result)

	result = newver.HideDigits("123")
	fmt.Println(result)

}

Das komplette Beispiel kann auch in GitHub gefunden werden. MyLib und MyLibApplication

Versionierung von eigenen Bibliotheken

Eigene Bibliotheken sollten immer nach dem semantic Versioniing Prinzip versioniert werden. Breaking changes der API sollten immer zu einer neuen MAJOR Version führen und in Go dementsprechend zu einem Modul-Update. So bricht kein Code bei alten Clients und diese können weiter arbeiten.

Ein Tool was hier übrigens super hilft ist mod. Damit lässt sich die Version von Bibliotheken sehr einfach verwalten und aktualisieren. Die Installation läuft über:

go install github.com/marwan-at-work/mod/cmd/mod@latest

Danach kann man eigene Bibliotheken mit einem einzigen Kommando aktualisieren bzw. ein “Versionsupdate” durchführen. Also den Modulnamen anpassen und dazu alle import-Statements im Code entsprechend anpassen.

mod upgrade

Viel Spaß beim aktualisieren!

05.02.2025

 

Der Author auf LinkedIn: Kristian Köhler und Mastodon: @kkoehler@mastodontech.de

Kennen Sie schon das Buch zum Thema?

Der praktische Soforteinstieg für Developer und Softwarearchitekten, die direkt mit Go produktiv werden wollen.

  • Von den Sprachgrundlagen bis zur Qualitätssicherung
  • Architekturstil verstehen und direkt anwenden
  • Idiomatic Go, gRPC, Go Cloud Development Kit
  • Cloud-native Anwendungen erstellen
Microservices mit Go Buch

zur Buchseite beim Rheinwerk Verlag Rheinwerk Computing, ISBN 978-3-8362-7559-0 (als PDF, EPUB, MOBI und Papier)

Kontakt

Source Fellows GmbH

Source Fellows GmbH Logo

Lerchenstraße 31

72762 Reutlingen

Telefon: (0049) 07121 6969 802

E-Mail: info@source-fellows.com