Viele moderne Applikationen bieten die Möglichkeit, die Funktionalität der Anwendung anzupassen oder zu erweitern. Das können Extensions für eine Entwicklungsumgebung sein oder Integrationen zusätzlicher Anbieter in einem E-Mail-Programm.
Wenn man nun selbst eine Erweiterung für ein Programm schreiben will, kann man schnell auf diverse Probleme stossen, welche bereits mit der Programmiersprache starten: Möglicherweise beherrscht man die vorgegebene Sprache nicht, die Funktionen, die man implementieren möchte, sind nur in einer anderen Sprache verfügbar oder die zu verwendende Skriptsprache, ist für den gewünschten Zweck schlicht nicht performant genug.
Will man selbst ein Plugin-System für seine Applikation implementieren, gibt es verschiedene Ansätze mit unterschiedlichen Vor- und Nachteilen und muss oft einen Kompromiss eingehen. Will man es Plugin-Entwicklern möglichst einfach machen, kann man eine weit verbreitete Skriptsprache wie Lua oder JavaScript einbetten. Will man die bestmögliche Leistung aus Plugins herausholen, greift man besser auf DLLs (Dynamic Link Library) in der Sprache des zu erweiternden Programms zurück. Dadurch kann jedoch die Zugänglichkeit leiden, etwa wenn diese Sprache schwierig zu Meistern ist.
In diesem Blogpost wollen wir ein Plugin-System entwickeln, welches es Entwicklern von Plugins erlaubt frei zu wählen, in welcher Sprache sie diese schreiben wollen. Als Grundlage für dieses Plugin-System werden wir Webassembly verwenden. Webassembly ermöglicht es Code geschrieben in diversen Sprachen wie C#, Go oder Rust im Webbrowser auszuführen und bietet dabei wesentlich höhere Leistung als JavaScript. Ein bekanntes Beispiel für den Einsatz von Webassembly ist Dotnet Blazor, ein Framework womit Webapps in C# werden können. Da es sich bei der Technologie um ein binäres Instruktionsformat handelt, welches von einer Virtual Machine ausgeführt wird, kann Webassembly auch ausserhalb des Browsers, beispielsweise eingebettet in eine andere Applikation, verwendet werden. Somit eignet es sich hervorragend, um unser Plugin-System zu bauen. In diesem Post werden wir eine Adressbuch-Applikation schreiben, wobei das Adressbuch von Webassembly-basierten Plugins gelesen und geschrieben werden kann. Um das Konzept zu testen, werden wir zudem zwei Plugins in zwei verschiedenen Programmiersprachen entwickeln.
Webassembly (WASM) ist ein binäres Instruktionsformat, welches dazu entwickelt wurde, nicht-JavaScript Code im Webbrowser auszuführen und erhöht die Leistung von Webapplikationen enorm. Es ist in allen modernen Browsern verfügbar und ist in diesem Umfeld bereits weit verbreitet.
WASM ist nicht an eine Programmiersprache gebunden. Verschiedene Sprachen können auf Webassemby kompiliert, und die resultierenden Module im Browser oder einer anderen WASM VM ausgeführt werden. Dies macht Webassembly sehr portabel und flexibel. Einmal kompiliert kann ein Modul auf jeder Plattform, welche eine WASM VM bereitstellt, ausgeführt werden.
Um die Anwendung ausserhalb des Browsers zu stärken, werden aktuell zwei offene Standards entwickelt: das Webassembly Component Model und Webassembly System Interface (WASI).
Das Component Model ist eine Architektur zum Bauen von untereinander interoperablen Webassembly Bibliotheken, Applikationen und Laufzeitumgebungen. Ein wichtiger Teil davon ist WIT. WIT steht für Webassembly Interface Types und ist ein Format zur Definition von Datentypen und Schnittstellen, worüber WASM-Module untereinander und mit anderen Systemen kommunizieren können.
Bei WASI, demWebassembly System Interface handelt es sich um eine Sammlung an APIs, spezifiziert in WIT, über welche WASM Module Zugriff auf System-Ressourcen wie das Netzwerk oder das Filesystem bekommen. Die WASI-Schnittstellen müssen dabei von der WASM-Laufzeitumgebung implementiert und bereitgestellt werden.
WASM zusammen mit WIT und WASI bietet uns alles, was nötig ist, um ein Plugin System zu verwirklichen. Mit WIT lässt sich eine API definieren, welche beinhaltet, was ein Plugin implementieren muss und welche Schnittstellen dem Plugin zur Verfügung stehen. Mit WASI können wir bei Bedarf Zugang zu ausgewählten Betriebssystemfunktionen geben. Ein Plugin kann nun in einer beliebigen Sprache geschrieben und zu einem WASM Component kompiliert werden. In der Applikation welche die Plugins konsumiert, muss eine WASM-Laufzeitumgebung integriert und die hostseitigen Schnittstellen der API implementiert werden und schon steht das Plugin System. Die Architektur des Plugin-Systems sieht dann etwa so aus:

Basierend auf diesem Konzep werden wir jetzt, wie angekündigt, eine Adressbuch-Applikation mit einer WIT-Schnittstelle bauen und zwei Plugins implementieren, welche das Adressbuch lesen und bearbeiten können.
Wir beginnen mit der Definition der Plugin-API. Unsere Plugins sollen im Adressbuch Kontakte lesen und schreiben können. Dazu stellen wir dem Plugin zwei Schnittstellen zur Verfügung: find-contact und insert-contact.
Wir erstellen ein File plugin.wit und fügen unsere Schnittstellendefinition hinzu. Mit dem import Block beschreiben wir Funktionen, welche von der Hostapplikation zur Verfügung gestellt werden. Mit export deklarieren wir welche Schnittstellen von Plugins implementieren müssen, hier lediglich die Funktion run: func().
package app:plugin;
world contacts {
include wasi:cli/[email protected];
import host: interface {
record person {
id: u32,
name: string,
address: string
}
find-contact: func(name: string) -> option<person>;
insert-contact: func(name: string, address: string);
}
export run: func();
}
Wir implementieren im Folgenden zwei Plugins, eines in Rust und eines in Go. Beide Sprachen haben starkes Tooling mit guter Dokumentation für Webassembly. Dieses Tooling ermöglicht es uns, Bindings zu Schnittstellen und Datentypen aus einem WIT-File automatisch zu generieren.
Als erstes installieren wir die Rust Toolchain gemäss Anleitung, cargo-component und die nötigen Targets für rustc.
rustc target add wasm23-wasip1 wasm32-wasip2 cargo install --locked cargo-component
Mit cargo-component setzten wir unser Plugin-Projekt auf:
cargo component new rust-plugin --lib && cd rust-plugin
In dem dadurch erstellten Ordner platzieren wir das zuvor definierte WIT-File unter ./wit/plguin.wit. Dann schreiben wir folgende Konfiguration in das File Cargo.toml:
[package.metadata.component] package = "app:plugin"
Als nächstes generieren wir die Schnittstellen zwischen Host und Plugin:
cargo component bindings
Jetzt können wir unser Plugin schreiben. Dazu öffnen wir src/lib.rs und fügen folgenden Code ein:
#[allow(warnings)]
mod bindings;
use bindings::Guest;
struct Plugin;
impl Guest for Plugin {
fn run() {
let max = bindings::host::find_contact("Max Muster");
if max.is_none() {
bindings::host::insert_contact("Max Muster", "Ferris Street");
};
let lina = bindings::host::find_contact("Lina Example");
if lina.is_none() {
bindings::host::insert_contact("Lina Example", "Rust Land");
};
}
}
bindings::export!(Plugin with_types_in bindings);
cargo component bindings hat uns das Modul bindings erstellt. Darin finden wir den Trait Guest. Diesen müssen wir für das struct Plugin implementieren.
Die Funktion run() befüllt das Adressbuch mit zwei Kontakten. Die Kontakte werden aber nur dann erstellt, wenn noch kein Kontakt mit dem entsprechenden Namen im Adressbuch erfasst ist. Zum Schluss exportieren wir unser Plugin mit bindings::export!(Plugin with_types_in bindings).
Nun müssen wir das Plugin nur noch auf Webassembly kompilieren und schon kann es verwendet werden:
cargo component build --release
Wir bauen nun dasselbe Plugin in Go. Dazu brauchen wir den TinyGo Compiler zusätzlich zum regulären Go. Siehe hier für Installationsanweisungen. Auch benötigen wir das Tool wkp sowie wasm-tools. Beides kann direkt mit Cargo installiert werden (cargo install wkp und cargo install wasm-tools).
Mit diesen Tools installiert erstellen wir das Projekt:
mkdir go-plugin && cd go-plugin go mod init goplugin
In go.mod fügen wir folgende Zeile hinzu:
tool go.bytecodealliance.org/cmd/wit-bindgen-go
Auch hier legen wir das WIT-File unter ./wit/plugin.wit ab und generieren die Schnittstellen:
wkg wit fetch wkg wit build -d wit -o go-plugin.wasm go tool wit-bindgen-go generate --world contacts --out internal plugin.wasm
In main.go implementieren wir das Plugin:
package main
import (
"guest/internal/app/plugin/contacts"
"guest/internal/app/plugin/contacts/host"
"go.bytecodealliance.org/cm"
)
func init() {
contacts.Exports.Run = func() {
max := host.FindContact("Max Muster")
if max.Some() == nil {
host.InsertContact("Max Muster", "Gopher Lane")
}
vivian := host.FindContact("Vivian Fancy")
if vivian.Some() != nil {
host.InsertContact("Vivian Fancy", "Go Town")
}
}
}
func main() {}
Im import Block importieren wir die generierten Schnittstellen, sowie nötige alle nötigen Datentypen.
Unsere Plugin-Schnittstellen implementieren wir in der init() Funktion indem wir die entsprechende Funktion aus contacts.Exports überschreiben.
Die Run() Funktion implementieren wir analog zum Plugin in Rust, ändern die Kontakte aber etwas ab.
Nun können wir auch das Go-Plugin als Webassembly Component kompilieren:
tinygo build -target=wasip2 -o go-plugin.wasm --wit-package plugin.wasm --wit-world contacts main.go
Unsere Plugins implementieren eine vordefinierte API. Wir bauen nun die Hostapplikation für die Plugins, also das Adressbuch, welches diese Plugins laden und laufen lassen kann und dazu die nötigen imports bereitstellt. Das Adressbuch schreiben wir in Rust und verwenden die WASM Virtual Machine Wasmtime. Wir wählen Rust, weil diese Sprache sehr guten support für Webassembly hat und die Dokumentation zum Component Model die besten Beispiele ebenfalls in Rust implementiert.
Wir beginnen mit einem neuen Rust Projekt, und fügen die nötigen Abhängigkeiten hinzu:
cargo new wasm-plugin-host && cd wasm-plugin-host cargo add anyhow wasmtime wasmtime-wasi cargo add rusqlite -F bundled
Den Code schreiben wir in src/main.rs. Wir beginnen mit der Definition des geteilten Datentyps Person:
#[derive(Debug, ComponentType)]
#[component(record)]
#[derive(Lower, Lift)]
struct Person {
id: u32,
name: String,
address: String,
}
Als nächstes starten wir mit der main Funktion und setzen eine Datenbank für das Adressbuch auf:
fn main() -> Result<()> {
let conn = Connection::open_in_memory()?;
conn.execute(
"CREATE TABLE IF NOT EXISTS person (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
address TEXT NOT NULL
)",
(), // empty list of parameters.
)?;
conn.execute("insert into person (name, address) values('John Doe', 'Chur')", ())?;
let shared_conn = Arc::new(Mutex::new(conn));
// -- Snip ---
}
Im nächsten Schritt bereiten wir die WASM-Runtime vor. Diese benötigt einen Kontext, welcher unter anderem geteilte Ressourcen beinhaltet. In unserem Fall die soeben erstellte Datenbank-Verbindung.
struct PluginContext {
database: Arc<Mutex<rusqlite::Connection>>,
table: ResourceTable,
wasi: WasiCtx,
}
impl WasiView for PluginContext {
fn ctx(&mut self) -> WasiCtxView<'_> {
WasiCtxView { ctx: &mut self.wasi, table: &mut self.table }
}
}
Auf PluginContext implementieren wir auch die Hostfunktionen find-contact() und insert-contact(), welche wir gemäss WIT-File den Plugins zur Verfügung stellen müssen:
impl PluginContext {
fn find_contact(&self, name: &str) -> Option<Person> {
let conn = self.database.lock().unwrap();
let result = conn.query_row(
"select id, name, address from person where name=?",
(name,),
|row| {
Ok(Person {
id: row.get(0)?,
name: row.get(1)?,
address: row.get(2)?,
})
},
);
match result {
Ok(person) => Some(person),
_ => None,
}
}
fn insert_contact(&mut self, name: String, address: String) {
let conn = self.database.lock().unwrap();
let result = conn.execute(
"insert into person (name, address) values (?, ?)",
(name, address),
);
if let Err(err) = result {
println!("{:?}", err)
}
}
}
Die Hostfunktionen und der Plugin-Kontext sind nun vorbereitet. Wir können jetzt Wasmtime initialisieren und die Hostfunktionen für die Plugins bereitstellen.
// struct Person {} hier
// struct PluginContext {} + impl hier
fn main() -> Result<()> {
// -- Snip --
let engine = Engine::new(Config::new().wasm_component_model(true))?;
let mut linker: Linker<PluginContext> = Linker::new(&engine);
wasmtime_wasi::p2::add_to_linker_sync(&mut linker)?; // add wasi interfaces
let mut inst = linker.instance("host").unwrap();
inst.func_wrap("find-contact", |state, (name,): (String,)| {
let result = state.data().find_contact(&name);
Ok((result,))
})?;
inst.func_wrap("insert-contact", |mut state, (name, address)| {
state.data_mut().insert_contact(name, address);
Ok(())
})?;
// -- Snip --
}
Jetzt können wir unsere Plugins laden und die run() Funktion für die Hostapplikation verfügbar machen. In dieser Beispielapplikation sind die Plugins zwar sehr statisch eingebunden, doch sie könnten auch genau so gut dynamisch zur Laufzeit detektiert oder auf Knopfdruck geladen werden.
fn main() -> Result<()> {
// -- Snip --
// load components form file
let rust_plugin = Component::from_file(&engine, "path/to/rust-plugin/target/release/rust-plugin.wasm")?;
let go_plugin = Component::from_file(&engine, "path/to/rust-plugin/go-plugin.wasm")?;
// instanciate rust plugin
let rust_context = PluginContext {
database: Arc::clone(&shared_conn),
table: ResourceTable::new(),
wasi: WasiCtxBuilder::new().build(),
};
let mut rust_store = Store::new(&engine, rust_context);
let rust_instance = linker.instantiate(&mut rust_store, &rust_plugin)?;
// instanciate go plugin
let go_context = PluginContext {
database: Arc::clone(&shared_conn),
table: ResourceTable::new(),
wasi: WasiCtxBuilder::new().build(),
};
let mut go_store = Store::new(&engine, go_context);
let go_instance = linker.instantiate(&mut go_store, &go_plugin)?;
// bind to `run` functions
let rust_run: TypedFunc<(), ()> = rust_instance.get_typed_func(&mut rust_store, "run")?;
let go_run: TypedFunc<(), ()> = go_instance.get_typed_func(&mut go_store, "run")?;
// -- Snip --
}
Damit ist die Hostapplikation fertig und kann mit cargo run gestartet werden. Das Resultat sollte wie folgt aussehen:
Ok(Person { id: 1, name: "John Doe", address: "Chur" })
Ok(Person { id: 2, name: "Max Muster", address: "Ferris Street" })
Ok(Person { id: 3, name: "Lina Example", address: "Rust Land" })
Ok(Person { id: 4, name: "Vivian Fancy", address: "Go Town" })
Der komplette Code aller Beispiele und zusätzliche Plugins in C# und C++ findest du in diesem Gitlab Repo. Das Repo beinhaltet zudem ein Devcontainer, mit welchem alle Plugins und die Hostapp gebaut und getestet werden können, ohne Go, TinyGo oder Rust installieren zu müssen.
Mithilfe von Webassembly konnten wir ein Plugin-System bauen, welches es Autoren von Plugins erlaubt, in ihrer Sprache der Wahl zu Entwickeln. Wir stellen Entwicklern lediglich ein WIT-File mit der Schnittstellendefinition bereit.
Weiter konnten wir zwei Plugins für unser Adressbuch in zwei unterschiedlichen Programmiersprachen schreiben. Das Entwickeln dieser Plugins in Form von Webassembly Components stellte sich zudem als erstaunlich einfach heraus. Das liegt wohl auch an dem guten Webassembly-Support der gewählten Sprachen Rust und Go. Eine grosse Hilfe war zudem das automatische Generieren von Schnittstellen-Bindungen.
Die Hostapplikation zu schreiben war etwas involvierter, da hier weniger automatisiert gemacht werden konnte. So musste etwa die run() Funktionen von Hand aus den Plugins extrahiert und korrekt typisiert werden
Die hier verwendete Technologie, das Webassembly Component Model, ist noch immer in aktiver Entwlicklung und erst als Preview-Version verfügbar. Dem entsprechend ist die Technologie auch noch nicht weit verbreitet und der Support fehlt in vielen Sprachen und Toolchains. Ob sich die Technologie durchsetzen kann, muss sich noch zeigen, doch Potential hat sie bereits jetzt.
Schreiben Sie einen Kommentar