Springe zum Inhalt
griechisches lambda

JavaScript mit GraalVM aus Java aufrufen

Von Tim Zöller, 10.09.2023

Bei der Entwicklung unserer Webseite und dieses Blogs war es uns wichtig, dass sie auch ohne JavaScript funktionsfähig sind. Eine Herausforderung stellte für uns dabei das Syntax-Highlighting für Quellcode Blöcke in Blog-Beiträgen dar – gängige Bibliotheken für diesen Anwendungsfall existieren hauptsächlich in JavaScript. In diesem Artikel zeigen wir, wie wir diese Bibliothek im Backend mit den polyglotten Features von GraalVM ausführen.

Inhaltsverzeichnis

  1. Die Problemstellung
  2. Was ist GraalVM?
  3. Einrichten von VM und Umgebung
  4. Die Bibliothek aufrufen
  5. Vermeiden paralleler Zugriffe
  6. Den Code in der HTML-Quelle mit Clojure dekorieren
  7. Diskussion 

Die Problemstellung

Unsere Kernkompetenz liegt in Java und weiteren JVM Sprachen wie Clojure oder Kotlin. Daher ist uns die Entscheidung, unsere Webseite mit Clojure umzusetzen und auf einer JVM zu betreiben leicht gefallen. Ein vollwertiges Content Management System (CMS) einzusetzen hätte für uns einen höheren Wartungs- und Pflegeaufwand mit sich gebracht, da unsere Webseite lediglich Kerninformationen zu unserem Unternehmen darstellen soll. Die Entscheidung für serverseitiges Rendern der HTML Dateien fiel ebenfalls aus Gründen der Einfachheit: Die meist statischen Seiten erfordern keine dynamischen Inhalte im Browser, weshalb es uns nicht gerechtfertigt erscheint eine größere Menge an Javascript Dateien an den Browser der Betrachter:innen zu senden, welche die Geschwindigkeit des Seitenaufbaus reduzieren. 

Aus diesen Gründen lässt sich aber noch nicht ableiten, wieso unsere Webseite komplett ohne JavaScript funktionieren sollte. Schließlich kann JavaScript auf der Webseite zu einer besseren Erfahrung für Nutzer:innen führen. Gute Gründe für diese Entscheidung liefert Nathaniel in seinem (englischen) Blog Post "Why your website should work without JavaScript". Zahlen aus den letzten drei Jahren gehen davon aus, dass 1% aller Endgeräte welche eine Webseite besuchen kein JavaScript ausführen. Dies ist jedoch nur bei einem Fünftel dieser Endgeräte Absicht, etwa weil die Nutzer:innen JavaScript deaktiviert haben oder eine Browsererweiterung mit NoScript nutzen. Die anderen 0,8% aller Endgeräte führen JavaScript durch Fehler nicht aus: Veraltete Browser, fehlkonfigurierte Browser-Erweiterungen, Sicherheitseinstellungen auf beruflichen Geräten. Webseiten welche IT-Inhalte anbieten sollten davon ausgehen, dass die Zahl von Endgeräten mit absichtlich deaktiviertem JavaScript höher liegt als bei 0,2% aller Geräte.

Dieser Blog soll hauptsächlich ein technischer Blog sein. Wir werden Artikel über Softwareentwicklung und -architektur schreiben und diese mit ausführlichen Codebeispielen ergänzen. Syntax-Highlighting ist hierbei für eine gute Lesbarkeit dieses Codes hilfreich. Dies wird am nachfolgenden Codeblock ersichtlich, der Methode read aus der Klasse java.io.CharArrayReader:

@Override
public int read(CharBuffer target) throws IOException {
    synchronized (lock) {
        ensureOpen();

        if (pos >= count) {
            return -1;
        }

        int avail = count - pos;
        int len = Math.min(avail, target.remaining());
        target.put(buf, pos, len);
        pos += len;
        return len;
    }
}

Die Farbgebung hilft dem Auge sofort dabei die Struktur des Codebeispiels zu erfassen. Keywords der Sprache werden in einer gemeinsamen Farbe markiert, Klassen- und Methodennamen ebenfalls. Dies orientiert sich am Highlighting von Entwicklungsumgebungen, mit welchen Softwareentwickler den Großteil ihrer Arbeit verrichten. Ein solches Highlighting erfolgt üblicherweise im Frontend, etwa mit Prism.js oder highlight.js. Diese sind leichtgewichtig und wären ohnen großen Aufwand in diese Webseite einzubinden, fügen das Syntax-Highlighting jedoch erst nach dem Laden der HTML-Ressource hinzu, indem sie nach <code> Tags suchen und auf deren Inhalt ihre Logik anwenden. Neben einer Verzögerung beim Rendering bedeutet dies aber auch, dass das Syntax-Highlighting bei Browsern mit deaktiviertem oder nicht funktionierendem JavaScript nicht angewandt würde und die Benutzbarkeit der Webseite auf betroffenen Endgeräten sich verschlechtert.

Aus diesen Gründen möchten wir also das Syntax-Highlighting bereits auf dem Server vornehmen. Die Recherche nach geeigneten Bibliotheken für Java war jedoch ernüchternd: Neben vielen toten Links zu dev.java.net und Google Code blieben noch ein paar kleinere Bibliotheken mit Unterstützung für wenige Sprachen und sehr geringer Verbreitung übrig. Da wir eine Lösung benötigten, welche auch in Zukunft noch mit Unterstützung für neue Programmiersprachen versorgt wird, entschieden wir uns dazu das JavaScript serverseitig mit Truffle auszuführen.

Was ist GraalVM?

GraalVM ist eine universelle virtuelle Maschine, die von Oracle entwickelt wurde. Sie wurde entwickelt, um verschiedene Programmiersprachen effizient auszuführen und miteinander zu integrieren. GraalVM unterstützt eine breite Palette von Sprachen, darunter Java, JavaScript, Python, Ruby, R, C, C++, und WebAssembly. Es ist darauf ausgerichtet, die Leistung von Sprachen auf einer gemeinsamen Plattform zu verbessern und Entwicklern die Möglichkeit zu bieten, verschiedene Sprachen nahtlos miteinander zu kombinieren. Diese Fähigkeit möchten wir nutzen, um prism.js aus Java (bzw. Clojure) heraus aufzurufen. Unter Java-Entwickler:innen ist GraalVM jedoch nicht in erster Linie für ihre polyglotten Eigenschaften bekannt, sondern für ihre Fähigkeit Java Applikationen mit Native Image zu nativen Binaries zu kompilieren. 

Einrichten von VM und Umgebung

Das folgende Beispiel setzt die GraalVM Community Edition in Version 20.0.2 ein, sie sollte als JVM im Kontext (IDE oder $PATH) eingerichtet sein. Da die Laufzeitumgebung für JavaScript nicht standardmäßig installiert ist, muss diese zunächst mit dem GraalVM Updater installiert werden:

> gu install js

Downloading: Component catalog from www.graalvm.org
Processing Component: Graal.js
Processing Component: ICU4J
Processing Component: TRegex
Additional Components are required:
    ICU4J (org.graalvm.icu4j, version 23.0.1), required by: Graal.js (org.graalvm.js)
    TRegex (org.graalvm.regex, version 23.0.1), required by: Graal.js (org.graalvm.js)
Downloading: Component js: Graal.js from github.com
Downloading: Component org.graalvm.icu4j: ICU4J from github.com
Downloading: Component org.graalvm.regex: TRegex from github.com
Installing new component: TRegex (org.graalvm.regex, version 23.0.1)
Installing new component: ICU4J (org.graalvm.icu4j, version 23.0.1)
Installing new component: Graal.js (org.graalvm.js, version 23.0.1)
Refreshed alternative links in /usr/bin/

Da wir unsere Applikation containerisiert ausliefern, ist dieser Schritt auch im Dockerfile nötig:

FROM ghcr.io/graalvm/graalvm-community:20.0.2
COPY target/uberjar/website.jar /website/app.jar
EXPOSE 3000
RUN ["gu", "install", "js"]
CMD ["java", "-jar", "/website/app.jar"]

Erwähnt sei an dieser Stelle noch, dass es nicht zwangsläufig nötig ist die gesamte Applikation auf GraalVM laufen zu lassen. Wie hier beschrieben ist es auch möglich die JavaScript Runtime in einer "herkömmlichen" JVM auszuführen.

Die Bibliothek aufrufen

Nach der langen Einleitung stellt sich der eigentliche Aufruf des Codes recht kurz dar. Zunächst erzeugen wir einen neuen Context, mit Hilfe eines Builder Patterns. Dieser stellt uns eine JavaScript Laufzeitumgebung bereit:

import org.graalvm.polyglot.Context;
import org.graalvm.ployglot.Source;

private Context buildContext() {
  return Context.newBuilder("js").build();
}

Es empfiehlt sich, den Kontext einmalig zu initialisieren und danach wiederzuverwenden. Da die buildContext Methode also unser Einstiegs- und Initialisierungspunkt ist, können wir an dieser Stelle auch prism.js aus einer Datei (oder für die produktive Applikation besser einer Ressource) laden:

import org.graalvm.polyglot.Context;
import org.graalvm.ployglot.Source;

private Context buildContext() {
  var context = Context.newBuilder("js").build();
  context.eval(Source.newBuilder("js", new File("/path/to/prism.js")).build());
  return context;
}

Mit diesem initialisierten Kontext können wir nun Arbeiten um einen Text um Syntax-Highlighting für die Anzeige in HTML zu ergänzen:

private String highlightSyntax(Context context, String language, String content) {
  var command = "Prism.highlight('%s', Prism.languages.%s, '%s');".formatted(content, language, language);
  return context.eval("js", command).asString();
}

Wir benutzen den erzeugten Kontext um den formatierten JavaScript Code auszuführen und geben das Ergebnis der Anweisung als Java String zurück. Um den Code hervorzuheben, nutzen wir die Funktion highlight, deren Dokumentation sich hier finden lässt. Hierbei sticht schnell ins Auge, dass wir den Script-Befehl mit String-Manipulation erzeugen und hier eine potenzielle Türe für Script-Injection öffnen. Die Eingaben sollten sich deshalb (wie in unserem Fall) aus Daten zusammensetzen welche wir selbst kontrollieren, oder extrem gut validiert werden.

Durch einen kurzen Test können wir überprüfen, dass unser Syntax-Highlighting wie gewünscht funktioniert:

String javaCode = "private static final Integer BEST_NUMBER = 42;";
var highlightedCode = highlightSyntax(context, "java", javaCode);
System.out.println(highlightedCode);
<span class="token keyword">private</span> 
<span class="token keyword">static</span> 
<span class="token keyword">final</span> 
<span class="token class-name">Integer</span> 
<span class="token constant">BEST_NUMBER</span> 
<span class="token operator">=</span> 
<span class="token number">42</span>
<span class="token punctuation">;</span>

Die Symbole sind mit CSS Klassen versehen worden und können nun mit CSS hervorgehoben werden

Vermeiden paralleler Zugriffe

Der Code sieht simpel aus, doch gerade im Backend einer Webseite, welche wünschenswerterweise viele gleichzeitige Anfragen ausliefert, birgt er Gefahren. Bei der Simulation mehrerer hundert paralleler Usersessions wurde in unserem Code folgende Exception ausgelöst:

java.lang.IllegalStateException: Multi-threaded access requested by thread Thread[http-nio-8778-exec-1,5,main] but is not allowed for language(s) js.

Die JavaScript Runtime von GraalVM unterstützt (wie so viele) kein Multithreading. Greift unsere Applikation aus zwei verschiedenen Threads auf den context zu, kommt es zum oben genannten Fehler. GraalVM bietet hier ein umfangreiches Toolset an, um diese Zugriffe zu synchronisieren. 

Den Code in der HTML-Quelle mit Clojure dekorieren

Wir verfassen unsere Blog-Einträge in einem Headless CMS. An unser Frontend, welches Sie, liebe Leser:innen gerade betrachten, wird der Inhalt als HTML geliefert. Dies bedeutet, dass wir keinen einfachen Zugriff auf die reinen Quelltexte im Artikel haben, sondern Prism.js manuell auf diese anwenden müssen. Haben wir bisher die Nutzung von JavaScript auf GraalVM mit Java Code verdeutlicht, zeigen wir alle weiteren Schritte nun am Clojure Code, welchen wir für diesen Blog auch so (oder so ähnlich) einsetzen. Als erstes müssen wir wieder die Initialisierung der Script-Engine mit prism.js vornehmen. Diesen Kontext erzeugen wir im entsprechenden Namensraum mit defonce, und delay. Mit delay stellen wir sicher, dass die Engine erst erzeugt wird, wenn sie auch wirklich benutzt wird, also nach einem Applikationsneustart zum ersten Mal ein Syntax Highlighting erfolgt. Damit stellen wir sicher, dass die Initialisierung nicht während des Applikationsstarts erfolgt und hier blockiert und nehmen dabei inkauf, dass beim allerersten Aufruf eines Renderings 200-300ms länger verstreichen.  

(defonce js-context
         (delay (doto
                  (.build (Context/newBuilder (into-array ["js"])))
                  (.eval
                    (.build
                      (Source/newBuilder "js"
                                         (clojure.java.io/resource "prism.js")))))))

Der Code, leider gespickt mit Java Interop Syntax, um aus Clojure heraus mit Java Syntax zu arbeiten, funktioniert hierbei genau so wie im Java-Beispiel oben.  

Ebenfalls keine großen Überraschungen erwarten uns beim Ausführen der eigentlichen JavaScript Syntax:

(defn highlight-code [language code]
  (let [context @js-context]
    (locking context
      (.asString
        (.eval context
               "js"
               (format "Prism.highlight('%s', Prism.languages.%s, '%s');" 
                       code 
                       language 
                       language))))))

Auch hier kommt uns der Code sehr bekannt vor. Bemerkenswert sind drei Dinge: Das @ vor js-context dereferenziert unseren lazy initialisierten Kontext. Ist dies bis hierhin noch nicht geschehen, wird er erst an dieser Stelle initialisiert. Weiterhin nutzen wir Clojures locking Funktion um parallelen Zugriff auf den js-contextzu verhindern. Zum anderen möchten wir uns hier vielleicht doch ein bisschen am Kopf kratzen: Wir nutzen Java-Interop Code aus Clojure heraus um am Ende JavaScript auszuführen, das darf man gerne auch amüsant finden.

Um nun abschließend unser Syntax-Highlighting auf allen Codeblöcken in unseren Artikeln anzuwenden, gilt es diese im HTML zu finden, zu extrahieren und die Stellen mit dem neuen, annotierten Code zu ersetzen. Glücklicherweise müssen wir dies nicht von Hand erledigen, sondern können auf enlive zurückgreifen, eine Clojure Bibliothek welche auf JSoup basiert:

(defn highlight-code-in-article [content]
  (apply str
         (-> content
             (html/html-snippet)
             (html/transform [:code]
                             (fn [selected-node]
                               (update-in selected-node [:content]
                                          (fn [[content]]
                                            (if-let [language-class (get-in selected-node [:attrs :class])]
                                              (html/html-snippet (highlight-code (clojure.string/replace language-class #"language-" "") content))
                                              content)))))
             (html/emit*))))

Der Funktion highlight-code-in-article wird der gesamte Artikeltext übergeben. Dieser Text wird mit html/html-snippet in enlive eingeladen und in eine Clojure Datenstruktur überführt. Die Funktion html/transform sucht mit dem Selektor :code nun alle <code> Elemente in dieser Datenstruktur und wendet auf jeden eine anonyme Funktion an, welche wir uns für eine bessere Lesbarkeit noch einmal extrahieren: 

(fn [selected-node]
  (update-in selected-node [:content]
             (fn [[content]]
               (if-let [language-class (get-in selected-node [:attrs :class])]
                 (html/html-snippet (highlight-code (clojure.string/replace language-class #"language-" "") content))
                 content))))

Die Teildatenstruktur welche dem <code> Element entspricht wird als Parameter selected-node übergeben. Enlive wird diese Datenstruktur mit dem Rückgabewert der Funktion ersetzen. Innerhalb dieser Funktion aktualisieren wir den Inhalt des Elements, welcher sich hinter dem :content Keyword befindet, indem wir unsere highlight-code Funktion darauf anwenden. Um welche Sprache es sich bei dem Snippet handelt extrahieren wir aus dem Klassennamen mit Regex. Wäre der Klassenname hier language-java  würden wir java an prism.js übergeben. Dies geschieht aber nur, wenn überhaupt eine Sprachklasse am Element existiert.

Da prism.js uns einen String zurückliefert, müssen wir diesen mit enlive und html/html-snippet wiederum in eine Clojure Datenstruktur überführen, bevor wir sie ins HTML einhängen können. Das Ergebnis lässt sich exakt auf dieser Seite sehen: Der Code wird vom Server bereits fertig mit CSS Klassen dekoriert. 

Diskussion 

Ist das Ergebnis den Aufwand wert? Unserer Meinung nach schon. Wir haben unser Ziel erreicht, uns der Flexibilität einer ausgereiften Syntax-Highlighting Bibliothek im Backend bedienen zu können. Dabei nutzen wir Java-, beziehungsweise JVM-Bordmittel und die Bibliothek selbst. Zu erwähnen sei hier noch, dass die Ausführungsgeschwindigkeit dieser Lösung nicht unbedingt hoch ist. Bei 5 bis 6 Codeblöcken können bei vorher initialisiertem JavaScript Kontext schon einmal 200-300ms extra aufgeschlagen werden. Dies ist in unserem Anwendungsfall nicht weiter schlimm, da wir die Artikel ohnehin cachen und nicht jedes Mal erneut beim Ausliefern verarbeiten müssen. Würde sich dies anders verhalten, würden wir hier vermutlich nach einem performanteren Weg suchen.

Die Anwendungsfälle für die polyglotten Features von GraalVM sind auf jeden Fall zahlreich. Neben JavaScript sind vor allem Ruby, Python und R interessant. Die Möglichkeit, diese Sprachen mit Java auf der selben VM operieren zu lassen sind interessant und könnten vor allem im Bereich statistischer Auswertungen neue Möglichkeiten eröffnen.

Über den Autor

Tim Zöller

Tim Zöller aus Freiburg ist der Gründer der lambdaschmiede GmbH und arbeitet als Softwareentwickler und Berater. Er hält Vorträge auf Java-Konferenzen und verfasst Artikel für das JavaMagazin.