Javascript-Plugins in Go – Mehr als die Summe ihrer Teile

Mehr als die Summe ihrer Teile

Es gibt Projekte, in denen eine Anwendung erstellt werden soll, die von anderen Parteien erweiterbar ist – ohne Zugriff auf den Quellcode oder ein Neu-Compilieren des kompletten Binaries. Dieses Konzept ist heutzutage als Module, Plugins oder Features bekannt. Das kann der Fall sein, wenn du eine Client-Anwendung erstellst (z.B. um Scripting-Funktionalität zu implementieren), aber auch, wenn deine Server-Anwendung für Dritte erweiterbar sein soll.

Inhalt

  1. Hashicorp Plugins
  2. Go plugins Module
  3. Einen anderen Ansatz wählen
  4. Funktionen hinzufügen
  5. Datenaustausch
  6. Echtes Plugin: API Clients
  7. Plugins durch Events auslösen
  8. Nachteile
  9. Zusammenfassung

In einem aktuellen Projekt musste ich die Funktionalität des Daten-Mappings von der grundlegenden Persistenz-Logik trennen.
Während der Speicher identisch sein sollte, unterscheidet sich die Mapping-Logik je nach Eingabedaten. Die Mapping-Logik sollte von anderen Entwicklern erweiterbar sein, ohne Zugriff auf die Quellen der Anwendung oder das erneute Compilieren der gesamten Anwendung.
Ich habe also Zeit investiert und mir die verschiedenen Optionen im Go-Ökosystem angesehen: Wie kann ich ein passendes Plugin-System implementieren.
Ich startete mit den offensichtlichen Kandidaten: das Hashicorp Plugin Model sowie das eingebaute plugins Paket.
Am Ende habe ich Javascript als Plugin DSL gewählt. Der folgende Beitrag zeigt einen Teil meiner Implementierung. Alle Quellen finden sich im entsprechenden GitHub Repository.

Hashicorp Plugins

Wenn du nach „Golang Plugin“ suchst, findest du mit ziemlicher Sicherheit das Hashicorp go-plugin Projekt (hashicorp/go-plugin – Golang plugin system over RPC).

Obwohl diese Funktion in den meisten Hashicorp-Produkten zur Erweiterbarkeit eingesetzt wird, bringt sie einiges an Overhead mit sich. Um diese Bibliothek zu nutzen, brauchst du im Grunde mehrere Binaries: eins für die „hosting“ Hauptanwendung und eines für jedes Plugin, das du ausführen willst. Der Datenaustausch zwischen Host und Plugin passiert über RPC.

Ich habe mit dieser Bibliothek ein kleines Beispiel erstellt.

Der Vertrag zwischen Host und Plugin ist ein spezifisches Interface. In diesem Hello-World-Beispiel sieht es so aus:

type Hello interface {
    Greet() string
}

Das Aufrufen des Plugins sieht identisch zum Aufrufen eines externen Binaries aus, weil es genau das ist:

client := plugin.NewClient(&plugin.ClientConfig{
    HandshakeConfig: handshakeConfig,
    Plugins:         pluginMap,
    Cmd:             exec.Command("./plugin/hello"),
})
defer client.Kill()

Das Objekt handshakeConfig enthält nur eine Struktur, die sicherstellt, dass das aktuelle Plugin in der korrekten Version verwendet wird:

var handshakeConfig = plugin.HandshakeConfig{
    ProtocolVersion:  1,
    MagicCookieKey:   "BASIC_PLUGIN",
    MagicCookieValue: "hello",
}

Obwohl es wie eine reine Go-Sache aussieht, stellt das README klar, dass auch andere Sprachen unterstützt werden:

Cross-language support. Plugins can be written (and consumed) by almost every major language. This library supports serving plugins via gRPC. gRPC-based plugins enable plugins to be written in any language.

Wie du siehst, gibt es eine ziemlich enge Kopplung zwischen Host und Plugin.
Darüber hinaus verlangt das go-plugin Modul von Plugin-Entwicklern auch bestimmte Fähigkeiten (wie Golang Interface und RPC).
Die Frage ist also, ob es möglich ist, etwas anderes zu verwenden, um Plugins mit weniger enger Kopplung, weniger Overhead und weniger Komplexität zu haben.

Go plugins Module

Natürlich gibt es auch das plugins Modul, das ein integraler Bestandteil von Go ist.

In der Theorie musst du das „Hosting“-Binary nicht aktualisieren, weil es keine direkte Verbindung zwischen der Anwendung und einem Plugin gibt.

Das Plugin exportiert sich selbst als Typ. Jede Plugin-Funktion wird an diesen Typ angehängt.

package main

import "fmt"

type greeting string

func (g greeting) Greet() {
    fmt.Println("Hello World!")
}

// Greeter - exported
var Greeter greeting

In der main-Funktion brauchst du nur ein Interface der Plugin-Methoden und löst das spezifische Plugin über den kompletten Pfad zur kompilierten Bibliotheksdatei auf.

type Greeter interface {
    Greet()
}

plug, err := plugin.Open("plugin/implementation.so")
if err != nil {
    log.Println(err)
}

Danach löst du das Symbol auf, castest es auf den Interface-Typ und führst im Anschluss die Greet() Methode aus.

symGreeter, err := plug.Lookup("Greeter")
if err != nil {
    log.Println(err)
}

var greeter Greeter
greeter, ok := symGreeter.(Greeter)
if !ok {
    log.Println("unexpected type from module symbol")
}

greeter.Greet()

Dieses Beispiel basiert auf einem Blog-Post von Domenico Luciani. Diese Methode hat weniger Overhead als die von Hashicorp (z.B. keine RPCs), bringt aber auch einige Einschränkungen mit sich:

  • Du kannst Plugins zur Laufzeit nicht laden, ohne sie vorher für die spezifische Plattform zu kompilieren.
  • Die aktuelle Implementierung unterstützt nur unix-artige Plattformen, wie Linux und macOS.
  • Da sie auch interne Go-Funktionen verwendet (wie Symbol-Auflösung und Typ-Casting), musst du dein Plugin in Go implementieren.

Einen anderen Ansatz wählen

Da JavaScript den meisten Entwicklern gut bekannt ist, denke ich, dass in JavaScript geschriebene Plugins gut passen. Nach einer kurzen Suche fand ich das Projekt Otto VM, das im Wesentlichen ein in Go geschriebener JavaScript-Interpreter ist. Ich habe einige kleine Beispiele erstellt, um zu zeigen, wie dieses Modul in verschiedenen Anwendungsfällen verwendet wird.

Ein einfaches Hello World mit Otto sieht so aus:

package main

import "github.com/robertkrimen/otto"

func main() {

    vm := otto.New()
    vm.Run(`
        console.log("Hello World!");
    `)
}

Wie du siehst, kann die Runtime reines JavaScript interpretieren und ausführen (derzeit begrenzt auf ECMAScript 5).
Das Interessante an Otto ist, dass du die JS API um externe Go-Funktionen erweitern und Daten in beide Richtungen austauschen kannst.

Für diesen Zweck hat Otto ein einfaches Typ-Mapping zwischen JS und Go (und umgekehrt):

Export will attempt to convert the value to a Go representation and return it via an interface{} kind. Export returns an error, but it will always be nil. It is present for backwards compatibility. If a reasonable conversion is not possible, then the original value is returned.

undefined   -> nil
null        -> nil
boolean     -> bool
number      -> A number type (int, float32, uint64, ...)
string      -> string
Array       -> []interface{}
Object      -> map[string]interface{}

Funktionen hinzufügen

Nehmen wir an, wir wollen nicht die console.log(...) Anweisung nutzen, sondern Meldungen aus JS über die Standard-Go-Logging-Funktion loggen. Dafür erstellen wir eine kleine Wrapper-Funktion und mappen sie auf eine JavaScript-Anweisung:

package main

import (
    "log"
    "github.com/robertkrimen/otto"
)

func main() {
    vm := otto.New()

    err := vm.Set("log", logJS)
    if err != nil {
        panic(err)
    }

    vm.Run(`
        console.log("logging with JS!");
        log("logging with Golang!");
    `)
}

func logJS(content string) {
    log.Println(content)
}

Datenaustausch

Daten aus unserem Go-Programm in JavaScript zu injizieren oder ein Ergebnis von einer JavaScript-Funktion zu empfangen, ist ebenfalls recht einfach:

package main

import (
    "log"
    "github.com/robertkrimen/otto"
)

func main() {
    vm := otto.New()

    // jsData contains the result of `date`
    jsDate, err := vm.Run(`
    (function(){
        date = new Date();
        return date;
    })();
    `)

    if err != nil {
        panic(err)
    }
    log.Printf("jsDate: %s", jsDate)

    dataMap := make(map[string]interface{})
    dataMap["foo"] = "bar"
    dataMap["one"] = "1"
    dataMap["two"] = "2"

    err = vm.Set("dataMap", dataMap)
    if err != nil {
        panic(err)
    }

    value, err := vm.Run(`
    (function(){
        var keys = [];
        for(k in dataMap) {
            console.log(k + ": " + dataMap[k]);
            keys.push(k);
        }
        return keys;
    })();
    `)

    keys, err := value.Export()
    if err != nil {
        panic(err)
    }

    keyArray := keys.([]string)
    log.Printf("keys: %s", keyArray)
}

In typischer Go-Manier hat der Aufruf vm.Run(...) zwei Rückgabetypen, einen Wert und ein Error-Objekt. Wenn wir an einer String-Repräsentation interessiert sind, können wir das simple value Objekt verwenden.
Andernfalls müssen wir zuerst die ...Export() Methode aufrufen, um das korrekte Typ-Mapping zu bekommen. Das zeigt das zweite Beispiel.
Hier extrahieren wir die Keys einer Go-Map in unseren JavaScript-Code und extrahieren danach das resultierende Array.

Das war es für die Grundlagen. Für eine echte Plugin-Umgebung brauchen wir aber noch ein paar weitere Teile.

Echtes Plugin: API Clients

Zuerst einmal sollten Plugin-Code und Anwendungs-Code getrennt sein. Aus diesem Grund definieren wir unsere eigene grundlegende Plugin-Struktur.

Ein Plugin ist ein Ordner mit einer info.json Datei (für die Plugin-Metadaten) und einer oder mehreren JavaScript-Dateien:

├── plugin
   ├── client.js
   └── info.json

In unserem nächsten Beispiel wollen wir eine Anwendung für verschiedene APIs implementieren. Ein Plugin kann die Twitter API aufrufen, ein anderes Plugin ruft einen (beliebigen) HTTP Service.
Da jeder Datenaustausch über unsere Anwendung läuft, können wir eine Art Service-Whitelist implementieren (so dass z.B. das HTTP-Plugin nicht die Twitter API aufrufen darf).
Wir bieten einem Plugin außerdem die Option, die Injektion spezifischer Umgebungsvariablen anzufordern (z.B. die Credentials für die Twitter API).
Beide Einschränkungen sind in der info.json Datei definiert. Diese Datei kann auch weitere Werte enthalten, wie Plugin-Version und Name. Sie ist als Vertrag zwischen der Anwendung (und dem Endnutzer) und dem Plugin (und dem Plugin-Entwickler) gedacht.

Wenn die Anwendung das Plugin zum ersten Mal lädt, kann sie dem Nutzer einen Bestätigungsdialog mit allen Eigenschaften zeigen, die ein Plugin für die Nutzung anfordert.
Das ist ein ähnlicher Ansatz wie der Bestätigungsdialog auf Mobilgeräten (Android/iOS), den eine neue App beim ersten Start auslöst.

In unserem Beispiel sieht die info.json Datei des Twitter-Plugins so aus:

{
    "whitelist": [
        "https://api.twitter.com"
    ],
    "env_variables": [
        "TWITTER_API_KEY",
        "TWITTER_API_SECRET_KEY",
        "TWITTER_ACCESS_TOKEN",
        "TWITTER_ACCESS_TOKEN_SECRET"
    ]
}

Das Plugin will sich mit der URI https://api.twitter.com verbinden und nutzt die in env_variables definierten Umgebungsvariablen (in unserem Fall die Credentials für die Twitter API).

Die andere Datei ist die Plugin-Implementierung selbst, client.js:

(function(){
    request = {
        "oauth1": {
            "consumerKey": env("TWITTER_API_KEY"),
            "consumerSecret": env("TWITTER_API_SECRET_KEY"),
            "accessToken": env("TWITTER_ACCESS_TOKEN"),
            "accessSecret": env("TWITTER_ACCESS_TOKEN_SECRET")
        },
        "host":"https://api.twitter.com/1.1/search/tweets.json?q=from%3Atwitterdev&result_type=mixed&count=2"
    }
    response = GET(request);
    var body = response["body"];
    for(i in body["statuses"]) {
        var status = body["statuses"][i];
        console.log(status["created_at"] + " @"+status["user"]["screen_name"] + ": " + status["text"]);
    }
})();

Dieser Aufruf stammt aus dem grundlegenden Beispiel der Twitter Developer Dokumentation. Das Interessante ist, dass der Plugin-Entwickler den oauth1-Request nicht selbst implementieren muss. Die API verwendet automatisch den korrekten Flow, wenn die Werte für einen spezifischen Key (z.B. oauth1) innerhalb des Request-Objekts definiert sind. Wenn du mehr wissen willst, kannst du dir die spezifische Implementierung ansehen.

Das Ausführen des Plugins zeigt uns das erwartete Ergebnis:

connections $ go run main.go

running Plugin twitter-plugin:

2020/03/06 16:06:56 info: map[env_variables:[TWITTER_API_KEY TWITTER_API_SECRET_KEY TWITTER_ACCESS_TOKEN TWITTER_ACCESS_TOKEN_SECRET] whitelist:[https://api.twitter.com]]

2020/03/06 16:06:56 Wed Feb 26 17:32:52 +0000 2020 @TwitterDev: 📄 Finally, @dara_tobi, the developer behind @QuotedReplies, built an app that automatically hides replies that are… https://t.co/m1VLNwCcS8
2020/03/06 16:06:56 Wed Feb 26 17:32:51 +0000 2020 @TwitterDev: In November, we gave people the ability to hide replies to their Tweets. Starting today, we're opening this feature… https://t.co/aN8kan0Lsw

Das zweite Beispiel behandelt das Aufrufen einer URL, die blockiert ist. Die info.json ist:

{
    "whitelist": [
        "http://example.com"
    ]
}

Die Plugin-Implementierung versucht, eine andere URI aufzurufen:

(function(){
    request = {
        "host":"https://google.com"
    }
    response = GET(request);
    LOG("error response: " + JSON.stringify(response));

    request = {
        "host":"http://example.com/api"
    }
    response = GET(request);
    var body = response["body"];
    LOG("response: " + body);
})();

Das Ergebnis ist wie erwartet:

running Plugin basic-plugin:

2020/03/06 16:06:56 info: map[whitelist:[http://example.com]]

2020/03/06 16:06:56 error response: {"error":"accessing https://google.com is blocked"}
2020/03/06 16:06:56 response: <!doctype html>
<html>
<head>
    <title>Example Domain</title>
…
</html>

Auch hier kannst du dir die Implementierung ansehen.

Plugins durch Events auslösen

Manchmal willst du ein Plugin nicht direkt aufrufen, sondern ein internes Event soll das Plugin auslösen.

Das nächste Beispiel demonstriert genau das. Wir haben zwei Plugins:

  • ein creator Plugin, das alle eingehenden Events speichert.
  • ein userUpdater Plugin, das einen User-Eintrag aktualisiert, wenn es ein update oder create Event vom Typ user gibt.

Für Demonstrationszwecke verwendet dieses Beispiel einen Data Generator, der zufällige Events der Typen CREATE, READ, UPDATE und DELETE erzeugt. Entweder für einen user oder ein object.

In der spezifischen info.json Datei registriert sich das Plugin als Listener für bestimmte Events:

{
    "events" : [
        "create",
        "update"
    ]
}

Das Setup der Event-Benachrichtigung passiert beim Anwendungsstart, kann aber auch zur Laufzeit erfolgen.

var listeners map[string][]string
var scripts map[string]string

func init() {
    scripts = make(map[string]string)
    listeners = make(map[string][]string)
    for _, event := range []string{"create", "read", "update", "delete"} {
        listeners[event] = make([]string, 0)
    }
    err := loadPlugins()
    if err != nil {
        panic(err)
    }
}

func loadPlugins() error {
    files, err := ioutil.ReadDir(".")
    if err != nil {
        return err
    }

    for _, file := range files {
        if file.IsDir() {
            info, err := utils.ReadJSON(path.Join(file.Name(), "info.json"))
            if err != nil {
                return err
            }

            script, err := utils.ReadFile(path.Join(file.Name(), "script.js"))
            if err != nil {
                return err
            }
            scripts[file.Name()] = script
            events := info["events"].([]interface{})
            for _, eventEntry := range events {
                event := eventEntry.(string)
                listeners[event] = append(listeners[event], file.Name())
                log.Printf("register %s for event %s", file.Name(), event)
            }
        }
    }
    return nil
}

Danach hast du zwei Maps:

  • eine mit allen Listenern eines Event-Typs
  • die andere mit den Listenern (Script-Namen) und – für bessere Performance – dem Inhalt des Scripts

Es gibt außerdem eine Notifier-Methode, die basierend auf dem Event-Typ das passende Script auslöst:

func notifyListener(data map[string]string) error {
    event := data["event"]
    if listenerScripts, ok := listeners[event]; ok {
        vm := otto.New()
        for _, name := range listenerScripts {
            log.Printf("notify %s about %s event", name, event)
            err := vm.Set("data", data)
            if err != nil {
                return err
            }
            _, err = vm.Run(scripts[name])
            if err != nil {
                return err
            }
        }
    }
    return nil
}

Dieser Notifier wird ausgelöst, wenn die Save-Methode über das creator Plugin aufgerufen wird.

func saveMethod(data map[string]string) error {
    log.Printf("saving some data %s", data)
    return notifyListener(data)
}

Das userUpdate Plugin wird also nur ausgeführt, wenn ein create oder update Event ausgelöst wird. Das Plugin selbst implementiert dann weitere Filterung, um nur für user Typen zu laufen:

(function(){
    console.log("event: " + JSON.stringify(data));
    if(data["type"] === "user") {
        console.log("Ok, I will also update the User DB!");
    }
})();

Der Output ist, wiederum, wie erwartet:

data: {"event":"delete","id":"nmaaHjXSoB7bmKpBDFUHqU","type":"user"}
2020/03/06 16:25:54 saving some data map[event:delete id:nmaaHjXSoB7bmKpBDFUHqU type:user]

data: {"event":"update","id":"KhVQFehukZdaM9kVQHdCr3","type":"object"}
2020/03/06 16:25:54 saving some data map[event:update id:KhVQFehukZdaM9kVQHdCr3 type:object]
2020/03/06 16:25:54 notify userUpdater about update event
event: {"event":"update","id":"KhVQFehukZdaM9kVQHdCr3","type":"object"}

data: {"event":"create","id":"KyPpkkVCYtyLFy3cfGuxd9","type":"user"}
2020/03/06 16:25:54 saving some data map[event:create id:KyPpkkVCYtyLFy3cfGuxd9 type:user]
2020/03/06 16:25:54 notify userUpdater about create event
event: {"event":"create","id":"KyPpkkVCYtyLFy3cfGuxd9","type":"user"}
Ok, I will also update the User DB!


data: {"event":"delete","id":"sAZhYCCL3kkvhF9w3iwQZg","type":"object"}
2020/03/06 16:25:54 saving some data map[event:delete id:sAZhYCCL3kkvhF9w3iwQZg type:object]

Nachteile

Die aktuelle Implementierung des JavaScript-Interpreters hat einige Nachteile.
Das Debugging von Plugins kann ziemlich mühsam sein. Glücklicherweise war einer der jüngsten Beiträge die Integration eines Debugging-Hooks. Jetzt kannst du zumindest sehen, wo im AST-Parsing ein Problem aufgetreten ist.

Ein weiteres großes Thema kann die limitierte JavaScript-Unterstützung sein. Wie bereits erwähnt, versteht der Interpreter derzeit nur ECMAScript v5.
Das bedeutet, dass du die modernsten JS-Pakete nicht verwenden kannst. Irgendwann in deinem Projekt musst du entscheiden, ob die limitierte Funktionalität ausreicht, um die Anwendungsfälle zu erfüllen.

Auf der anderen Seite bringt Otto einen vollwertigen AST-Parser mit. Es gibt bereits einen Versuch, JSX-Parsing mit Otto zu implementieren.

Zusammenfassung

Wie du siehst, ist es möglich, sehr unterschiedliche Anwendungsfälle mit dieser unkomplizierten Technologie zu implementieren.
Es ist recht einfach, fremden Code in einer sandbox-artigen Umgebung auszuführen. Du kannst auch Auditing-Funktionalität implementieren, da du einen klar definierten Datenfluss hast.
Du kannst den Plugin-Lademechanismus auch um zusätzliche Sicherheitsprüfungen erweitern, wie die Signatur-Verifikation von Plugins.

Dieser Artikel wurde ursprünglich auf dem INNOQ Blog veröffentlicht.

Philipp Haußleiter

Version 4.5. Ich arbeite seit etwa 17 Jahren als IT-Berater und Software Engineer. Meine Karriere begann mit vielfältigen Projekten – von iOS-Entwicklung über Microsoft BI Tooling bis hin zum Aufbau größerer Hadoop-Cluster. Heute liegt mein Schwerpunkt auf Software-Architektur, Systemdesign und der Entwicklung robuster, nachhaltiger IT-Lösungen. Dabei verfolge ich konsequent einen First-Principles-Ansatz: Ich hinterfrage bestehende Annahmen und bilde mir durch eigenes Ausprobieren eine fundierte, kritische Meinung zu neuen Technologien – auch zu KI –, um echten Mehrwert von Hype zu unterscheiden. Seit 2003 betreibe ich eigene Server, um einen Ausgleich zum Beratungsgeschäft zu finden und praktische Erfahrungen zu sammeln.

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert