Artikel

Vom if-Wald zum Command-Handler

Wie aus einer 400-Zeilen-Verzweigung ein System aus einzelnen Dateien wurde — und warum diese Umstellung die wichtigste des ganzen Jahres war.

BlackZackBlackzack

987 Wörter · 5 Min. Lesezeit

  • yurna
  • javascript
  • architektur

Wenn ich ein einziges Refactoring aus meiner Anfangszeit nennen müsste, das wirklich etwas verändert hat, dann dieses. Nicht, weil es technisch anspruchsvoll war — es ist eher simpel —, sondern weil danach eine ganze Klasse von Problemen verschwunden ist.

Der Ausgangszustand war eine Funktion mit einer Verzweigungskette. Jeder neue Befehl war ein weiteres else if. Und jeder neue Befehl machte die Datei ein Stück schwerer zu bearbeiten.

Woran man merkt, dass es Zeit wird

Bei mir waren es drei Symptome, die ich damals nicht als Symptome erkannt habe:

Ich habe Angst vor Änderungen bekommen. Nicht große Angst, aber diese kleine Unsicherheit, ob man beim Bearbeiten von Befehl 14 nicht versehentlich Befehl 9 kaputtmacht. Genau dieses Gefühl ist das zuverlässigste Warnsignal für schlechte Struktur.

Ich habe Code kopiert statt wiederverwendet. Jeder Befehl, der prüfte, ob jemand Moderator ist, hatte seine eigene Kopie dieser Prüfung. Als ich die Prüfung ändern musste, habe ich elf Stellen gesucht und zwei übersehen.

Ich habe aufgehört, Dinge zu bauen, die mir eingefallen sind. Weil der Aufwand nicht mehr im Verhältnis stand. Das ist der teuerste Punkt: schlechte Struktur kostet nicht nur Zeit, sie kostet Ideen.

Was ich daraus gemacht habe

Der Kern ist eine Datei pro Befehl, und jede exportiert dasselbe Objekt:

// befehle/allgemein/wuerfel.js
module.exports = {
  name: "würfel",
  aliase: ["dice", "w"],
  beschreibung: "Würfelt eine Zahl zwischen 1 und N.",
  benutzung: "würfel [seiten]",
  nurGilde: true,
  rechte: [],
  async ausfuehren(nachricht, args) {
    const seiten = Number.parseInt(args[0], 10) || 6;
    if (seiten < 2 || seiten > 1000) {
      return nachricht.reply("Bitte eine Zahl zwischen 2 und 1000.");
    }
    const ergebnis = 1 + Math.floor(Math.random() * seiten);
    return nachricht.reply(`🎲 ${ergebnis} (von ${seiten})`);
  },
};

Und ein Loader, der beim Start alle diese Dateien einsammelt:

const fs = require("node:fs");
const pfad = require("node:path");
 
client.befehle = new Map();
 
for (const kategorie of fs.readdirSync("./befehle")) {
  for (const datei of fs.readdirSync(`./befehle/${kategorie}`)) {
    if (!datei.endsWith(".js")) continue;
    const befehl = require(pfad.resolve(`./befehle/${kategorie}/${datei}`));
    befehl.kategorie = kategorie;
    client.befehle.set(befehl.name, befehl);
    for (const alias of befehl.aliase ?? []) {
      client.aliase.set(alias, befehl.name);
    }
  }
}

Der Nachrichtenempfang schrumpft damit auf ein paar Zeilen, in denen kein einziger Befehl mehr vorkommt:

const name = client.aliase.get(befehlsname) ?? befehlsname;
const befehl = client.befehle.get(name);
if (!befehl) return;
 
if (befehl.nurGilde && !nachricht.guild) {
  return nachricht.reply("Das geht nur auf einem Server.");
}
if (!hatRechte(nachricht.member, befehl.rechte)) {
  return nachricht.reply("Dafür fehlen dir die Rechte.");
}
 
try {
  await befehl.ausfuehren(nachricht, args);
} catch (fehler) {
  console.error(`Befehl ${name} fehlgeschlagen:`, fehler);
  await nachricht.reply("Da ist etwas schiefgelaufen.");
}

Die drei Dinge, die dadurch geschenkt dazukamen

Die Hilfe schreibt sich selbst. Vorher war !hilfe eine handgepflegte Liste, die immer veraltet war. Jetzt geht sie durch die geladenen Befehle, gruppiert nach Kategorie, und zeigt Beschreibung und Benutzung. Die Hilfe kann gar nicht mehr falsch sein, weil sie aus derselben Quelle kommt wie die Befehle selbst.

Rechte und Prüfungen liegen an einer Stelle. Ein Befehl deklariert, was er braucht. Ob und wie geprüft wird, entscheidet der Handler. Als ich später ein Rechtesystem mit eigenen Rollen wollte, musste ich genau eine Funktion ändern.

Fehler bringen nicht mehr den ganzen Bot um. Das try/catch um den Aufruf ist der unscheinbarste Teil und war der wichtigste. Vorher hat ein Fehler in einem einzigen Befehl den Prozess beendet — bei einem Bot, der auf mehreren Servern läuft, heißt das: Ein Nutzer tippt Unsinn, und für alle anderen ist der Bot weg.

Tipp

Wer diese Umstellung vor sich hat: Nicht alles auf einmal umbauen. Ich habe den Loader gebaut, die neue Struktur danebengelegt und den alten Block als Rückfallebene behalten. Erst als alle Befehle umgezogen waren, ist der alte Block geflogen. Das dauert zwei Abende länger und man hat nie einen Zustand, in dem gar nichts läuft.

Was ich dabei über Struktur gelernt habe

Der eigentliche Gewinn ist nicht die Ordnerstruktur. Es ist eine Trennung, die ich vorher nicht gesehen hatte: Was ein Befehl tut, und wie ein Befehl aufgerufen wird, sind zwei verschiedene Sachen.

Solange beides in derselben Verzweigung steht, kann man das eine nicht ändern, ohne das andere anzufassen. Sobald es getrennt ist, wird vieles fast beiläufig möglich. Ein Beispiel: Als Discord später Slash-Commands einführte, war die Frage nicht „wie schreibe ich hundert Befehle neu", sondern „wie rufe ich dieselben Befehle über einen anderen Weg auf". Ohne diese Trennung wäre die Umstellung ein Neuschreiben geworden.

Ich habe dieselbe Sache seitdem in ganz anderen Zusammenhängen wiedergefunden. Eine HTTP-Route ist ein Aufrufweg. Ein Minecraft-Befehl ist ein Aufrufweg. Eine Zeitplanung, die etwas nachts ausführt, ist ein Aufrufweg. Was danach passiert, sollte davon nichts wissen müssen.

Die Fehler, die ich beim Umbau gemacht habe

Der Umbau selbst war unspektakulär. Interessanter sind die drei Sachen, die ich dabei falsch gemacht habe.

Ich habe zu viel auf einmal umgestellt. Mein erster Versuch war, alle dreiundzwanzig Befehle an einem Abend zu verschieben. Nach zwei Stunden hatte ich einen Zustand, in dem nichts mehr lief und ich nicht mehr wusste, welche Änderung was verursacht hatte. Ich habe alles verworfen und am nächsten Tag von vorne angefangen — diesmal mit einem Befehl pro Schritt und einem Test dazwischen.

Ich habe die Struktur zu früh verfeinert. In der zweiten Fassung hatte jeder Befehl nicht nur eine Ausführungsfunktion, sondern auch Haken für Vorprüfung, Nachbereitung und Fehlerbehandlung. Benutzt habe ich davon genau einen. Der Rest war Gerüst für Fälle, die nie eintraten.

Ich habe das Laden zu clever gemacht. Meine erste Loader-Fassung durchsuchte Verzeichnisse rekursiv, unterstützte mehrere Dateiformate und konnte Befehle zur Laufzeit nachladen. Das Nachladen hat nie zuverlässig funktioniert, weil die Modulzwischenspeicherung dazwischenfunkt. Die einfache Variante — Neustart, dauert zwei Sekunden — war die bessere Lösung.

Was sich nicht geändert hat

Das Muster von damals steckt bis heute in allem, was ich baue. Nicht als Code, sondern als Frage: Was ist hier der Aufrufweg, und was ist die Sache selbst?

Bei einem Web-Endpunkt ist der Aufrufweg HTTP und die Sache die Fachlichkeit dahinter. Bei einem Minecraft-Plugin ist der Aufrufweg ein Befehl oder ein Ereignis. Bei einer geplanten Aufgabe ist es ein Zeitpunkt.

Die Sache selbst sollte von all dem nichts wissen. Sie bekommt Werte und gibt ein Ergebnis zurück. Wenn das gelingt, ist sie prüfbar, wiederverwendbar und übersteht Umstellungen der Umgebung.

Wenn es nicht gelingt, merkt man es sofort: Man kann die Funktion nicht aufrufen, ohne die halbe Umgebung nachzubauen. Das ist der zuverlässigste Hinweis auf eine Vermischung, den ich kenne — und er kostet nichts, weil man ihn beim ersten Testversuch bekommt.

Was ich heute anders machen würde

Zwei Dinge.

Ich würde die Befehlsbeschreibung von Anfang an als Daten behandeln. Bei mir standen Name, Beschreibung und Benutzung in derselben Datei wie die Logik — was solange gut geht, wie es eine Sprache gibt. Als ich zwei Sprachen unterstützen wollte, musste ich alle Texte herausziehen.

Ich würde früher Tests schreiben. Nicht viele, aber welche. Ein Befehl, der eine Zahl entgegennimmt und ein Ergebnis liefert, lässt sich prüfen, ohne Discord zu befragen. Ich habe stattdessen jahrelang von Hand getestet, indem ich Befehle in einem Testserver eingetippt habe. Das funktioniert, bis man vierzig Befehle hat.