Run WASM TinyGo plugins from Java with zero dependencies, thanks to Chicory.
Today, if you want to run WASM programs from Java, you can find several solutions:
Wasmer Java is a WebAssembly runtime for Java from Wasmer. It's probably one of the oldest in the history of the Java WASM runtimes. But there has been no activity on this project for years (and years in the WASM topic; it's huge).
GraalWasm is an official wasm runtime from the GraalVM project. It has existed for a long time; it is the same age as Wasmer. I didn't see a lot of activity on this project except in the last months; that's certainly a good thing. However, the documentation is minimalistic, and if you are a noob and you want to go further than running a function with numerical arguments, you will have a hard time: WASM has some limitations (but it will evolve) right now. Still, you cannot pass directly string arguments to a Wasm function, and you need to do some plumbing to achieve this - I didn't find an example in the project explaining this plumbing.
Extism Java SDK (from Dylibso) is a more recent project. It's a Java SDK for the Extism WebAssembly Plugin-System. You will gain both a Java SDK on the host side and a WASM PDK on the WASM side. The Extism project provides a Rust library (LibExtism) that embeds the Wasmtime runtime. Then, every Extism SDK (you can use languages other than Java) provides a wrapper for LibExtism.
That means you will need a shared library (there is an exception: the Extism Go SDK, which uses the Wazero project: "the zero dependency WebAssembly runtime for Go developers"). If you need an example of how to use the Extism SDK with Java, you can read a previous blog post: Run WASM functions from Vert-x & Kotlin thanks to Extism.
The Extism Java SDK project is an active project.
However, a new alternative has emerged recently, the Chicory project (also developed by Dylibso), which Wazero strongly inspires. So, Chicory is a native JVM WebAssembly runtime (== no dependency!).
Baby steps with Chicory.
First, let's talk about the "plumbing."
Developing a Java host application with Chicory is pretty straightforward. The README.md file and the WASM examples are enough. Today, I want to discuss "the plumbing": how do I use a string as a parameter of a WASM function?
Remember, I said that WASM has some limitations. One of them is that you can only use numbers to pass arguments to a function. A solution is to use the Shared Memory Buffer, a piece of memory shared between the host application and the instance of a running WASM plugin. The WASM program and the host application will just use it to exchange data.
Btw, a WASM function can only return numbers, in fact, only one number...🙀
Let me try to explain this. I want to pass a string to the Hello WASM function from the Java host application. These are the steps of this mechanism:
The host application will copy the string ("Jane") into the shared memory
It will get the position of the string in the memory and its size
Then, it will be able to call the WASM Hello function, using the position and size of the string in the shared memory as parameters.
- Then the wasm function (Hello) can read the memory to get the value of the string ("Jane") into the shared memory thanks to its position and size:
The Hello function can build its message “hello Jane”
Then, it will copy the message into the shared memory
And it will return the new size and position... 🤔
But you can only return one value !!! 😵💫
So you must use bit manipulation to put 2 values into only one and return it (shift left OR operation)
- Finally, from this value, the Java host application will be able to extract the position and size of the message again with bit manipulation: a shift right operation to get the position (of the string) and an AND mask operation to get the size. Then, it can decode the buffer to get the string 🎉.
Okay, the first time, it could be surprising 🤭. However, the people working on the Chicory project are pretty cool, and they provided some usable examples that are easy to read.
Java talks to Go thanks to TinyGo and Chicory.
Let's apply the previous paragraph with real code.
Initialize the WASM module from the Java host application.
We want to load a WASM plugin (demo.wasm
) and call the hello
function of this module. We will use TinyGo to build the WASM file from the Go source code.
So, we need:
A logger to display messages from the WASM program (
var logger
)Some options (
var options
)A WASI runtime. There are currently 2 versions of WASI, preview1 and preview2. Chicory currently supports the preview1. WASI is a set of standard API specifications defining how to run WASM everywhere, not only in a browser (
var wasi
).
Then, now, we can
- Enable WASI (
var imports
).
Create the WASM module with the host imports (
var module
)And finally, instantiate the module (
var instance
).
var wasmFileLocalLocation = "./demo-plugin/demo.wasm";
var wasmFunctionName = "hello";
// logger allows the WASM module to display messages
var logger = new SystemLogger();
// let's just use the default options for now
var options = WasiOptions.builder().build();
// create our instance of wasip1
var wasi = new WasiPreview1(logger, WasiOptions.builder().build());
// turn those into host imports.
// Here we could add any other custom imports we have
var imports = new HostImports(wasi.toHostFunctions());
// create the module
var module = Module.builder(new File(wasmFileLocalLocation))
.build().withHostImports(imports);
// instantiate and connect our imports, this will execute the module
var instance = module.instantiate();
To "put" a value into the shared memory, we need to use two exported wasm functions: malloc
and free
to reserve a place in memory and to clean the memory once the value is used. These two functions are automatically exported by the TinyGo compilation (you don't need to implement malloc
and free
- If you use Rust, you must implement these functions (*)).
To call the function of the WASM plugin, we need to use the pluginFunc
exported function (ExportFunction pluginFunc
and wasmFunctionName=="hello"
):
// automatically exported by TinyGo
ExportFunction malloc = instance.export("malloc");
ExportFunction free = instance.export("free");
ExportFunction pluginFunc = instance.export(wasmFunctionName);
(*): see the Rust WASM example: https://github.com/dylibso/chicory/blob/main/wasm-corpus/src/main/resources/rust/count_vowels.rs
Now, we can copy the string parameter (var param = "Bob Morane"
) in the shared memory:
First, to access the shared memory, we need to instantiate a memory object (
Memory memory
).Secondly, we allocate a place in the memory with the size of the parameter and the
malloc
The function will return a pointer to this value in memory (int ptr
) (similar to the position of the value in the memory buffer).Finally, we can copy the value to the memory with the
writeString
method.
Memory memory = instance.memory();
var param = "Bob Morane";
int len = param.getBytes().length;
// allocate {len} bytes of memory,
// this returns a pointer to that memory
int ptr = malloc.apply(Value.i32(len))[0].asInt();
// We can now write the message to the module's memory:
memory.writeString(ptr, param);
Now, we can call the pluginFunc
function. The function's arguments are the string value's position in the shared memory and its size.
The hello
WASM function (pluginFunc
) will return a numerical value (remember: a WASM function can only return one value, and it's a number. This number contains both the position (valuePosition
) and the size (valueSize
) of the return value. To extract these two values from the result, we will use a shift right operation (for the position) and an AND mask operation (for the size). Finally, with the size and the position, we can read the return string value in the shared memory (memory.readBytes(valuePosition, valueSize)
):
// Call the wasm function
Value result = pluginFunc.apply(Value.i32(ptr), Value.i32(len))[0];
// Clean the memory
free.apply(Value.i32(ptr), Value.i32(len));
// Extract position and size from the result
int valuePosition = (int) ((result.asLong() >>> 32) & 0xFFFFFFFFL);
int valueSize = (int) (result.asLong() & 0xFFFFFFFFL);
byte[] bytes = memory.readBytes(valuePosition, valueSize);
String strResult = new String(bytes, StandardCharsets.UTF_8);
System.out.println(strResult);
Now, let's see how to create the WASM plugin.
Create a WASM plugin with TinyGo.
To make the hello function "visible" (or exportable) from the Java host application, you need to add this comment before the function definition: //export hello
(it's mandatory). The readBufferFromMemory
function allows to read the value of the string argument thanks to the valuePosition
and length parameters
and the copyBufferToMemory
function allows the copy of the string result in the memory.
Create a main.go
file (don't forget the go mod init
command to create the go.mod
file):
package main
//export hello
func hello(valuePosition *uint32, length uint32) uint64 {
// read the memory to get the argument(s)
valueBytes := readBufferFromMemory(valuePosition, length)
message := "👋 Hello " + string(valueBytes) + " 😃"
// copy the value to memory
// get the position and the size of the buffer (in memory)
posSize := copyBufferToMemory([]byte(message))
// return the position and size
return posSize
}
func main() {}
But it's not finished! A little bit more plumbing 😵💫
We need to implement the readBufferFromMemory
and copyBufferToMemory
functions (but we will be able to reuse these functions in other projects):
package main
import "unsafe"
// Read memory
func readBufferFromMemory(bufferPosition *uint32, length uint32) []byte {
subjectBuffer := make([]byte, length)
pointer := uintptr(unsafe.Pointer(bufferPosition))
for i := 0; i < int(length); i++ {
s := *(*int32)(unsafe.Pointer(pointer + uintptr(i)))
subjectBuffer[i] = byte(s)
}
return subjectBuffer
}
// Copy data to memory
func copyBufferToMemory(buffer []byte) uint64 {
bufferPtr := &buffer[0]
unsafePtr := uintptr(unsafe.Pointer(bufferPtr))
pos := uint32(unsafePtr)
size := uint32(len(buffer))
return (uint64(pos) << uint64(32)) | uint64(size)
}
This line is the magic one: return (uint64(pos) << uint64(32)) | uint64(size)
This allows you to "pack" the position and size of the return value into only one value 🎉.
And, if you remember well, we will extract the size and the position from Java like this:
// Extract position and size from the result
int valuePosition = (int) ((result.asLong() >>> 32) & 0xFFFFFFFFL);
int valueSize = (int) (result.asLong() & 0xFFFFFFFFL);
To build the WASM plugin, use the following command:
tinygo build -scheduler=none --no-debug \
-o demo.wasm \
-target=wasi -panic=trap -scheduler=none main.go
Now, you can execute the WASM plugin function from Java.
The entire source code (Java and Go) related to this blog post is available here: https://github.com/world-wide-wasm/chicory-starter-kit.
Next time, I will reuse all these pieces of code to create microservices with WASM plugins and Vert-x.
If you want to learn more about all these topics, I wrote some blog post series: