In Drei Rewrites stand ein Satz, über den ich schnell hinweggegangen bin:

Hooks vor und nach jeder Action — Logging, Dry-Run, Fehlerkontext, alles ohne den Core anzufassen.

Ein Halbsatz. Aber genau da steckt das, worauf der dritte Versuch hinausgelaufen ist. Heute will ich diese eine Zeile auspacken. Es wird länger.

Was ein Hook ist

Ein Hook ist eine Funktion, die ich vor oder nach einer Aktion dazwischenschiebe. ensure, delete, start, stop — jede Aktion, die incus-compose auf einer Ressource ausführt, lässt sich abfangen. Vorher und nachher.

Die Signatur sieht so aus:

func(ctx context.Context, action Action, r Resource, args Options, err error) error

Schau dir den letzten Parameter an. err error. Ein Hook bekommt einen Fehler herein und gibt einen Fehler heraus. Das ist der eigentliche Trick, und es hat eine Weile gedauert, bis ich verstanden habe, wie viel darin steckt.

Der Fehler fließt durch die ganze Kette. Jeder Hook darf ihn anschauen, durchreichen, anreichern — oder einen neuen erfinden und damit alles abbrechen.

Der Fehler, der durchfließt

Ein Before-Hook, der abbricht, sagt einfach: hier ist ein Fehler.

client.AddHookBefore(func(_ context.Context, action Action, r Resource, args Options, err error) error {
    if action == ActionDelete && !confirmed {
        return errors.New("Löschen nicht bestätigt")
    }
    return err
})

Gibt ein Before-Hook einen Fehler zurück, wird die Aktion nicht ausgeführt. Aus. So baut man Dry-Run, Bestätigungen, Vorbedingungen — ohne dass der Code, der löscht, je davon erfährt.

Ein After-Hook darf das Gegenteil: einen Fehler schlucken.

client.AddHookAfter(func(_ context.Context, action Action, r Resource, args Options, err error) error {
    if errors.Is(err, ErrNotFound) && action == ActionDelete {
        // schon weg — kein Fehler
        return nil
    }
    return err
})

Eine Ressource löschen, die es nicht mehr gibt, ist kein Problem. Es ist genau das, was ich wollte. Der Core meldet trotzig „not found". Der Hook nickt und sagt: passt schon.

Und dazwischen, der häufigste Fall: den Fehler anreichern statt ersetzen.

client.AddHookAfter(func(_ context.Context, action Action, r Resource, args Options, err error) error {
		if err == nil {
			return nil
		}

		cError := &Error{}
		if ok := errors.As(err, &cError); ok {
			return cError.WithResource(r)
		}

		return ErrUnknown.WithResource(r).Wrap(err)
})

Kein Err....WithResource(r) mehr an hundert Stellen im Core verstreut. Eine Stelle. Jeder Fehler trägt am Ende, durch wessen Hände er gegangen ist.

Die ganze Hook-Kette ist im Grunde eine einzige Fehler-Pipeline. Der err ist das Wasser, das durchläuft, und jeder Hook ist ein Ventil.

FIFO und LIFO

Jetzt kommt das Stück, das ich schön finde.

Before-Hooks laufen in der Reihenfolge, in der ich sie registriert habe. First in, first out.

client.AddHookBefore(checkPermissions)  // läuft als 1.
client.AddHookBefore(validateConfig)    // läuft als 2.
client.AddHookBefore(acquireLock)       // läuft als 3.

After-Hooks laufen rückwärts. Last in, first out.

client.AddHookAfter(logBasicInfo)      // läuft als 3. (innen)
client.AddHookAfter(wrapWithContext)   // läuft als 2. (Mitte)
client.AddHookAfter(sendToMonitoring)  // läuft als 1. (außen)

Das sieht erst aus wie eine willkürliche Entscheidung. Ist es nicht. Es ist die einzige, die Sinn ergibt.

Stell dir das Anziehen vor. Erst Socken, dann Schuhe. Beim Ausziehen erst die Schuhe, dann die Socken. Niemand zieht zuerst die Socken aus. Wer zuletzt etwas aufbaut, baut es als Erstes wieder ab.

Genau das machen die Hooks. Wer als Letztes etwas vorbereitet, räumt als Erstes wieder auf. Die Klammern schließen sich von innen nach außen. Wer Go schreibt, kennt das von defer — die aufgeschobenen Aufrufe feuern in umgekehrter Reihenfolge, aus demselben Grund.

checkPermissions  →  validateConfig  →  acquireLock
                                            │
                                       [ Aktion ]
                                            │
sendToMonitoring  ←  wrapWithContext  ←  logBasicInfo

Eine Zwiebel. Außen das Allgemeine, innen das Spezielle. Auf dem Hinweg von außen nach innen, auf dem Rückweg von innen nach außen. Der Lock, den ich zuletzt geholt habe, wird zuerst freigegeben. Das Monitoring, das ich als Erstes angemeldet habe, kriegt das Ergebnis als Letztes — und sieht damit den fertig angereicherten Fehler, nicht den rohen.

Ich musste mir das nicht ausdenken. Ich musste nur aufhören, dagegen zu arbeiten.

Eine größere Klammer

Vor und nach jeder Aktion — das ist die kleine Klammer. Es gibt noch eine große, um den ganzen Lauf herum.

if err := client.Open(); err != nil {
    return err
}
defer func() { _ = client.Done() }()

Open() feuert die Connected-Hooks, einmal, bevor irgendeine Aktion läuft. Done() feuert die Done-Hooks, einmal, wenn alles vorbei ist. Setup und Teardown für den gesamten Client.

Dieselben Regeln, eine Etage höher. Connected läuft FIFO, Done läuft LIFO. Connected darf abbrechen — gibt ein Connected-Hook einen Fehler zurück, ist Open() gescheitert und keine Aktion läuft. Done bricht nie ab. Jeder Done-Hook läuft, immer, egal was vorher schiefging. Aufräumen darf man nicht überspringen.

Da starte ich den Progress-Renderer:

client.AddHookConnected(func(err error) error {
    return progress.Start()
})

client.AddHookDone(func(err error) error {
    progress.Stop()
    return err
})

Anmachen, wenn der Client aufgeht. Ausmachen, wenn er fertig ist. Dazwischen feuern die kleinen Klammern bei jeder Ressource und melden ihre Zeilen als erledigt. Drei Ebenen, dasselbe Muster: vorher, dazwischen, nachher.

Worum es eigentlich geht

Jetzt zurück zu dem Halbsatz. Alles ohne den Core anzufassen.

Logging, Dry-Run, Fortschrittsanzeige, Fehlerkontext, Bestätigungen, das Schlucken erwarteter Fehler — nichts davon steht im Core. Der Core weiß nicht, dass es eine Fortschrittsanzeige gibt. Er weiß nicht, ob gerade ein Dry-Run läuft. Er weiß nicht mal, dass jemand mitloggt.

Er führt eine Aktion auf einer Ressource aus. Vorher ruft er die Before-Hooks, nachher die After-Hooks. Was die tun, ist nicht seine Sache.

Das nennt man Querschnittsbelange — die Dinge, die überall gebraucht werden und sich deshalb sonst überall hineinfressen. Logging ist der Klassiker. Man fängt mit einem log.Println an und sechs Monate später steht in jeder zweiten Zeile ein Log-Aufruf und keiner traut sich mehr, ihn rauszunehmen. Die Hooks geben diesen Dingen einen Ort. Einen einzigen. Außerhalb.

Der Core bleibt dumm. Und dumm ist hier ein Kompliment. Ein dummer Core ist ein Core, den ich verstehe, ohne fünf andere Dateien aufzumachen. Einer, bei dem ich eine neue Ressource hinzufügen kann, ohne an zehn Stellen ans Logging zu denken.

Wo die Poesie aufhört

Damit es ehrlich bleibt: Es ist nicht alles elegant.

Die Before- und After-Hooks laufen in einem Worker-Pool. Mehrere Ressourcen werden gleichzeitig bearbeitet, also feuern mehrere Hooks gleichzeitig. Wer in einem Hook gemeinsamen Zustand anfasst — einen Zähler, eine Map — braucht ein Mutex.

var mu sync.Mutex
counts := map[string]int{}

client.AddHookAfter(func(_ context.Context, action Action, r Resource, _ Options, err error) error {
    mu.Lock()
    defer mu.Unlock()
    counts[r.Kind()]++
    return err
})

Das ist die Stelle, an der die schöne Zwiebel auf die Wirklichkeit der Nebenläufigkeit trifft. Die Connected- und Done-Hooks sind davon ausgenommen, die feuern einmal und auf einem Thread. Aber die kleinen Klammern muss man mit dieser Vorsicht schreiben. Eleganz und race conditions schließen sich nicht aus, man muss nur dran denken.

Ich hab das nicht erfunden

Irgendwann beim Schreiben ist mir aufgefallen, was ich da eigentlich gebaut hatte. Das ist Middleware. Dasselbe Muster wie eine Kette von HTTP-Handlern, die einen Request durchreichen, jeder darf vorher und nachher ran, und das Innerste macht die eigentliche Arbeit. Dasselbe, was hinter defer steckt. Dasselbe wie ein Stapel Teller.

Ich hab es nicht erfunden. Ich bin nur, nach zwei verkorksten Versuchen und drei Jahren go-orb davor, an einem Punkt angekommen, an dem ich es wiedererkannt habe. Das ist vielleicht der ehrlichste Teil der ganzen Geschichte. Die guten Strukturen warten schon. Man muss nur lange genug das Falsche bauen, um das Richtige zu erkennen, wenn es einem über den Weg läuft.

Eine neue Ressource passt jetzt einfach in den Stack. Ein neuer Querschnittsbelang wird ein Hook und fasst den Core nicht an. Vorher, dazwischen, nachher.

Boring. Schon wieder. Genau so soll das sein.


Von René Jochum und Claude (Anthropic). Lizenz: CC-BY-4.0.