Artikel

Bilder im Bot erzeugen, ohne den Bot einzufrieren

Ranglisten-Karten, Willkommensbilder, Profilgrafiken: Wie man in Node.js Bilder zeichnet — und warum meine erste Fassung den ganzen Bot für Sekunden anhielt.

BlackZackBlackzack

1004 Wörter · 5 Min. Lesezeit

  • yurna
  • javascript
  • leistung

Irgendwann will jeder Discord-Bot Bilder. Eine Profilkarte mit Avatar und Fortschrittsbalken, ein Willkommensbild mit Namen, eine Rangliste als Grafik statt als Textblock. Das sieht besser aus als jede Einbettung, und es ist der Punkt, an dem man das erste Mal merkt, dass Node.js für manche Aufgaben nicht gebaut ist.

Wie man in Node.js zeichnet

Der übliche Weg ist eine Canvas-Bibliothek, die dieselbe Schnittstelle anbietet wie das Canvas-Element im Browser. Man erzeugt eine Zeichenfläche, malt darauf und holt sich am Ende einen Puffer, den man verschicken kann.

const flaeche = erzeugeFlaeche(900, 280);
const stift = flaeche.getContext("2d");
 
stift.fillStyle = "#12101a";
stift.fillRect(0, 0, 900, 280);
 
stift.font = "bold 34px Sans";
stift.fillStyle = "#ffffff";
stift.fillText(anzeigename, 220, 90);
 
// Fortschrittsbalken
stift.fillStyle = "#2a2536";
stift.fillRect(220, 150, 620, 34);
stift.fillStyle = "#7c5cff";
stift.fillRect(220, 150, 620 * anteil, 34);
 
const bild = await ladeBild(avatarUrl);
stift.drawImage(bild, 40, 60, 160, 160);

Das ist unspektakulär und funktioniert. Die Probleme kommen woanders her.

Problem eins: Alles steht still

Zeichnen ist Rechenarbeit, und Rechenarbeit läuft in Node.js im selben Faden wie alles andere. Solange eine Grafik entsteht, verarbeitet der Bot keine Nachrichten, keine Befehle, keine Ereignisse. Bei einer einfachen Karte sind das vielleicht 80 Millisekunden — unangenehm, aber nicht dramatisch.

Meine Rangliste mit zehn Einträgen, zehn geladenen Avataren und Text war bei 900 Millisekunden. Fast eine Sekunde, in der der Bot für alle Server, auf denen er läuft, taub ist. Und wenn drei Leute den Befehl gleichzeitig benutzen, sind es drei Sekunden.

Die Lösung ist keine schnellere Zeichenbibliothek, sondern die Arbeit aus dem Hauptfaden herauszunehmen. In Node.js gibt es dafür Worker-Threads: ein zweiter Faden, der die Grafik baut und das Ergebnis zurückgibt.

const { Worker } = require("node:worker_threads");
 
function karteErzeugen(daten) {
  return new Promise((erfuellen, ablehnen) => {
    const arbeiter = new Worker("./grafik/karte-worker.js", { workerData: daten });
    arbeiter.on("message", erfuellen);
    arbeiter.on("error", ablehnen);
  });
}

Danach war die Antwortzeit für den Nutzer unverändert — die Karte braucht ja weiterhin ihre Zeit — aber der Bot blieb währenddessen ansprechbar. Das ist der Unterschied zwischen „ein Nutzer wartet" und „alle warten".

Problem zwei: Fremde Bilder

Avatare kommen von Discords CDN. Das heißt: ein Netzwerkaufruf pro Bild, und Netzwerkaufrufe scheitern. Bei einer Rangliste mit zehn Avataren ist die Wahrscheinlichkeit, dass einer davon hakt, zehnmal so hoch wie bei einem.

Drei Dinge haben das entschärft:

  • Zeitgrenze pro Bild. Wer nach zwei Sekunden nicht da ist, wird durch einen Platzhalter ersetzt. Ein fehlendes Avatar ist ein kosmetisches Problem, ein hängender Befehl ein echtes.
  • Alle Bilder parallel laden, nicht nacheinander. Promise.all statt einer Schleife mit await.
  • Zwischenspeicher. Avatare ändern sich selten. Ein einfacher Speicher mit Ablaufzeit spart bei einer Rangliste, die mehrmals am Tag aufgerufen wird, fast alle Aufrufe.

Warnung

Beim Laden fremder Bilder gilt dasselbe wie bei jedem Aufruf nach außen: Die URL muss geprüft werden. Wenn ein Nutzer irgendwo eine beliebige Adresse hinterlegen kann und der Bot sie ungeprüft abruft, kann er dazu gebracht werden, interne Adressen anzusprechen. Das ist die klassische serverseitige Anfragefälschung, und sie kommt in erstaunlich vielen Bots vor. Eine Positivliste erlaubter Hosts löst das in fünf Zeilen.

Problem drei: Text ist unberechenbar

Der Teil, der mich am meisten Zeit gekostet hat, war nicht Grafik, sondern Typografie.

Namen haben beliebige Längen. Ein Anzeigename kann zwei Zeichen lang sein oder zweiunddreißig. Wer eine feste Schriftgröße setzt, bekommt Text, der aus dem Bild läuft. Die Lösung ist, die Breite zu messen und die Größe zu verkleinern, bis es passt:

function schriftPassend(stift, text, maxBreite, startGroesse) {
  let groesse = startGroesse;
  do {
    stift.font = `bold ${groesse}px Sans`;
    groesse -= 2;
  } while (stift.measureText(text).width > maxBreite && groesse > 12);
  return stift.font;
}

Namen haben beliebige Zeichen. Emoji, kyrillische Buchstaben, japanische Schriftzeichen, arabische Schrift mit Leserichtung von rechts nach links. Auf einem Server ohne die passenden Schriftarten wird daraus ein Rechteck. Das ist kein Fehler im Code, sondern eine fehlende Schriftart im System — und es ist der Grund, warum ich seitdem bei jedem Server, der Bilder erzeugt, als Erstes einen vernünftigen Satz Schriftarten mit installiere.

Der Zwischenspeicher, den ich zu spät gebaut habe

Die Rangliste wurde auf einem aktiven Server mehrmals pro Stunde aufgerufen, und jedes Mal wurde dasselbe Bild neu gezeichnet — mit denselben zehn Avataren, denselben Zahlen, demselben Hintergrund.

Ein Zwischenspeicher mit einer Gültigkeit von fünf Minuten hat die Rechenlast für diesen Befehl praktisch beseitigt. Die Änderung ist zwanzig Zeilen lang, und ich habe ein Jahr gebraucht, um darauf zu kommen.

Der Grund, warum ich nicht früher darauf kam, ist lehrreich: Ich habe in „Befehl kommt, Bild entsteht" gedacht. Der Gedanke „Bild existiert, Befehl schickt es" verlangt eine andere Perspektive — nämlich die Frage, wie aktuell das Ergebnis überhaupt sein muss.

Diese Frage stelle ich mir heute bei jeder teuren Berechnung: Wie alt darf die Antwort sein? Bei einer Rangliste: Minuten. Bei einem Kontostand: Sekunden. Bei einer Statistikübersicht: Stunden. Jede Antwort außer „muss aktuell sein" erlaubt einen Zwischenspeicher, und die Antwort ist überraschend selten „muss aktuell sein".

Der Sonderfall, der mich erwischt hat

Ein Fehler aus dieser Zeit, der mich einen Abend gekostet hat und den ich niemandem wünsche.

Ein Nutzer hatte einen Anzeigenamen, der ausschließlich aus Zeichen bestand, die auf dem Server nicht dargestellt werden konnten. Die Breitenmessung ergab dadurch praktisch null, meine Schleife zur Schriftgrößenanpassung lief bis zur Untergrenze, und das Ergebnis war ein Bild mit einem leeren Feld an der Stelle, an der ein Name stehen sollte.

Kein Absturz, keine Fehlermeldung — nur ein Bild, das falsch aussah. Und weil der Nutzer der einzige mit diesem Namen war, trat es nur bei ihm auf.

Die Lehre daraus ist allgemein: Bei allem, was Text von Nutzern verarbeitet, ist der Sonderfall der Normalfall. Emoji, Schriftrichtung von rechts nach links, kombinierende Zeichen, unsichtbare Steuerzeichen, extrem lange Namen. Jeder dieser Fälle existiert real, und man findet sie nicht durch Nachdenken, sondern indem man Namen aus der echten Nutzerbasis durch den eigenen Code laufen lässt.

Was ich heute anders machen würde

Weniger Bilder. Ehrlich gesagt: Ein großer Teil dieser Grafiken hat kaum jemanden interessiert. Der Profilbefehl wurde benutzt, die Rangliste auch, aber die Hälfte der Bilder, die ich damals gebaut habe, waren Selbstzweck. Rechenzeit für Optik, die niemand vermisst hätte.

Vorberechnen statt bei jedem Aufruf zeichnen. Ein Ranglistenbild ändert sich pro Minute kaum. Es einmal zu erzeugen und ein paar Minuten aufzuheben, wäre trivial gewesen — ich bin einfach nicht auf die Idee gekommen, weil ich in „Befehl kommt, Bild entsteht" gedacht habe statt in „Bild existiert, Befehl schickt es".

Diese zweite Erkenntnis ist mir seitdem oft begegnet, in ganz anderem Zusammenhang. Der Unterschied zwischen einem System, das auf Anfrage rechnet, und einem, das ein fertiges Ergebnis bereithält, ist einer der wirksamsten Hebel überhaupt — und er kostet meistens nur ein bisschen Nachdenken darüber, wie aktuell etwas wirklich sein muss.