Ohhnews

分类导航

$ cd ..
DZone Java原文

在Neo4j中嵌入Wasm:构建此前并不存在的可行实例

#neo4j#webassembly#情感分析#java udf#wasmtime

在最近一篇 DZone 文章 Running Sentiment Analysis Inside Neo4j With a Java Plugin 中,我们探讨了在 Neo4j 数据库引擎内部运行情感分析的几种方法。其中一种方法——在 Java UDF 中嵌入 Wasm 运行时——被描述如下:

理论上,我们可以在 Java UDF 中嵌入诸如 wasmtime 这样的 Wasm 运行时,并从 Neo4j 内部执行 VADER Wasm 模块,从而在 Neo4j 的插件模型中获得 Wasm 的沙箱保证。这在技术上可行,但似乎没有已发布的可运行示例,而且相对于其他替代方案,其复杂度成本很高。这是一个值得关注的有趣想法,但今天还不实用。

本文将构建那个可运行示例。我们将展示如何将编译为 Wasm 的真实 VADER 情感分析器嵌入 Neo4j Java UDF,并返回一个可直接从 Cypher 调用的完整极性分数映射。我们将介绍理解 Wasm 编译器生成的内容所需的工具与检查技术,以及为什么 Java 调用约定会是那种形式。完整源代码可在 GitHub 上获取。

我们要构建什么

我们正在使用 wasmtime-java 将一个 wasmtime Wasm 运行时嵌入 Neo4j Java UDF,wasmtime-java 是 Wasmtime 运行时的社区 JNI 绑定。它不是 Bytecode Alliance 的官方产品,但为所有主流平台提供了预构建的原生库,对于这个概念验证来说已经足够。一个编译为 WebAssembly 的 Rust 函数与 Java 代码一起放在插件 JAR 中。当 Cypher 调用该 UDF 时,Java 会初始化 Wasm 运行时、加载二进制文件并调用 Rust 函数——全部在 Neo4j JVM 进程内完成,没有外部 API 调用,也没有网络往返。

注意: 本文专门针对 wasmtime-java 0.19.0 进行了测试。这里使用的 API 是特定于版本的;更新的版本或替代的 JVM Wasm 运行时可能会暴露不同的接口和调用约定。

先决条件

如果你想跟着操作,需要安装以下内容。我们使用 Apple Silicon (ARM64) 作为开发平台,因此会注明设置在其他平台上的差异。

Java

我们使用 OpenJDK 21(使用 21.0.12.1 测试)。请使用你平台的包管理器安装,或直接从 adoptium.net 下载。

在 macOS 上通过 Homebrew:brew install openjdk@21

在 Ubuntu/Debian 上:sudo apt install openjdk-21-jdk

在 Windows 上,从 Adoptium 下载并运行安装程序。

确认你的 Java 版本:java -version

你应该会看到 Java 21 运行时。如果你使用的是 Apple Silicon,还要确认你运行的是原生 ARM64 JVM:uname -m

你应该会看到 arm64。如果不是在 ARM64 下运行,很可能会破坏 wasmtime-java JNI 库的加载。

Maven

我们使用 Maven3.9.6。在 Apple Silicon 上,通过 Homebrew 安装 Maven 时要小心,因为在撰写本文时,Homebrew 的 Maven formula 会引入 OpenJDK 26 作为依赖项,这与 Java 21 安装冲突。如果你的包管理器在安装 Maven 的同时安装了不兼容的 JDK,请使用 mvn -version 验证运行时,并按需配置 JAVA_HOME。手动安装 Maven 是最安全的方法:

$ bash
cd ~
curl -O https://archive.apache.org/dist/maven/maven-3/3.9.6/binaries/apache-maven-3.9.6-bin.tar.gz
tar xzf apache-maven-3.9.6-bin.tar.gz

然后将 Maven 添加到你的 PATH,并使其在终端会话之间持久生效:

$ bash
echo 'export PATH="$HOME/apache-maven-3.9.6/bin:$PATH"' >> ~/.zshrc
source ~/.zshrc
  • 在 Linux 上,改为将同一行添加到 ~/.bashrc。
  • 在 Windows 上,从 maven.apache.org 下载 zip,并通过系统属性将 bin 文件夹添加到系统 PATH。

确认 Maven 使用的是 Java 21:mvn -version

你应该会在输出中看到 Java version: 21。

Rust

我们使用 Rust 1.96.0。如果尚未安装,请通过 rustup 安装:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

在 Windows 上,从 rustup.rs 下载并运行 rustup-init.exe。

要固定到我们测试过的特定 Rust 版本:

$ bash
rustup toolchain install 1.96.0
rustup default 1.96.0

然后添加 WASI target:rustup target add wasm32-wasip1

该 target 在 macOS、Linux 和 Windows 上的工作方式相同。

WABT

WebAssembly Binary Toolkit 为我们提供了 wasm-objdump,用于检查 Wasm 二进制文件。我们使用版本 1.0.41 进行了测试。

在 macOS 上:brew install wabt

在 Ubuntu/Debian 上:sudo apt install wabt

在 Windows 上,从 github.com/WebAssembly/wabt/releases 下载最新版本。

wit-bindgen

这是 WebAssembly 的接口类型生成器。有两个不同的版本号需要注意:wit-bindgen-cli 命令行工具,以及 Wasm 模块内部作为依赖使用的 wit-bindgen Rust crate。它们可能不同。我们测试时使用的 CLI 版本是 0.59.0,Rust crate 版本是 0.40.0(在 Cargo.toml 中指定)。生成的二进制文件通过导出名称 cabi_realloc_wit_bindgen_0_40_0 标识 crate 版本。在所有平台上通过 Cargo 安装固定版本的 CLI:

cargo install wit-bindgen-cli --version 0.59.0

确认已安装:wit-bindgen --version

Neo4j Desktop

我们使用带有本地数据库实例的 Neo4j Desktop。从 Neo4j for Desktop 下载。本文中的 pom.xml 固定使用 Neo4j 2026.07.0——请更新 neo4j.version 属性,以匹配你自己的 Desktop 安装。

wasmtime-java 平台支持

wasmtime-java 库为以下平台提供预构建的 JNI 原生库:

  • macOS aarch64
  • macOS x86_64
  • Linux aarch64
  • Linux x86_64
  • Windows x86_64

无需额外设置,因为 Maven 会自动为你的平台拉取正确的原生库。

版本摘要

供参考,以下是本文中使用的所有组件版本:

组件版本
OpenJDK21.0.12.1
Maven3.9.6
Rust1.96.0
WABT1.0.41
wit-bindgen CLI0.59.0
wit-bindgen crate0.40.0
vader_sentiment crate0.1.1
wasmtime-java0.19.0
Neo4j2026.07.0

获取代码

在跟着操作之前,请先克隆仓库。所有源文件都已提供,因此你无需手动创建它们。

$ bash
cd ~
git clone --filter=blob:none --sparse https://github.com/VeryFatBoy/neo4j.git
cd neo4j
git sparse-checkout set wasm-udf
mv wasm-udf ../wasm-udf
cd ../wasm-udf

项目结构

在创建任何文件之前,下面是我们要构建的最终布局。有两个独立的项目:

  1. 一个编译为 Wasm 的 Rust crate。
  2. 一个承载 Neo4j UDF 的 Maven 项目。

首先是 Rust crate:

sentimentable/
├── Cargo.toml
├── src/
│   └── lib.rs
└── wit/
    └── sentimentable.wit

其次是 Maven 项目:

neo4j-wasm-udf/
├── pom.xml
└── src/
    └── main/
        ├── java/
        │   └── com/example/
        │       ├── WasmUDF.java
        │       └── SentimentUDF.java
        └── resources/
            ├── add.wat
            ├── add.wasm
            └── sentimentable.wasm

resources/ 中的 Wasm 二进制文件会在构建时打包到插件 JAR 中。Rust crate 和 Maven 项目保持分离,Wasm 二进制文件是它们之间的交接点。项目布局也如图 1 所示。

[LOADING...]

图 1. 两个项目的布局

Wasm 管道如何工作

在深入代码之前,值得先了解使之成为可能的三个层次。

核心 Wasm 与 WASI

WebAssembly 定义了一种可移植的二进制格式和基于栈的执行模型。就其自身而言,它只理解数字,例如整数和浮点数。当 Wasm 模块需要系统能力(如内存分配或 I/O)时,它会使用 WASI(WebAssembly System Interface),这是一组由宿主运行时实现的标准化系统调用。我们的 Rust 代码以 wasm32-wasip1 为目标,这意味着它会编译为带有 WASI preview 1 系统调用的 Wasm。wasmtime 运行时在宿主侧实现这些调用。

wasmtime-java

该库将 wasmtime Wasm 运行时包装在 JNI 绑定中,使其可以从 Java 调用。它为所有主流平台提供预构建的原生库,因此只需将其添加为 Maven 依赖即可——无需单独安装 wasmtime。Java API 允许我们加载 Wasm 二进制文件、设置 WASI 上下文,并直接调用导出的函数。

wit-bindgen 与字符串 ABI

核心 WebAssembly 函数操作的是 Wasm 值类型,例如整数和浮点数。WIT(WebAssembly Interface Types)和组件模型提供了更高级的接口类型,例如字符串、元组和记录;wit-bindgen 生成在 Wasm 边界表示这些类型所需的 lowering 和 lifting 代码。对于字符串,它使用指针加长度的约定:调用方使用生成的 cabi_realloc 函数在 Wasm 模块内部分配内存,将字符串字节写入那里,并将内存地址和字节长度作为两个整数传递。Rust 代码从该地址读取字符串。对于返回值,lowering 策略取决于类型,我们将在检查生成的二进制文件时看到这一点。

有了这三部分,调用链看起来像这样:

Cypher 查询 -> Neo4j 路由到 @UserFunction -> Java 初始化 wasmtime 引擎 + WASI 上下文 -> Java 在 Wasm 内存中分配字符串 -> Java 调用导出的 Wasm 函数 -> Rust 执行 VADER 评分 -> Java 从 Wasm 内存读取结果 -> Java 将 Map 返回给 Neo4j -> Neo4j 将结果返回给 Cypher

图 2 也以图形方式展示了调用链。

[LOADING...]

图 2. 调用链

我们通过两个更简单的过渡案例逐步构建到这一步:

  1. 案例 1:一个简单的整数加法,用于证明调用链可行。
  2. 案例 2:返回单个 compound 分数,以引入字符串传递和 WASI。

这两个案例的完整讲解,包括 WasmUDF.java 实现,都在 GitHub 仓库的一份技术报告中。

Maven 项目

$ xml
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.example</groupId>
    <artifactId>neo4j-wasm-udf</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>jar</packaging>
    <properties>
        <maven.compiler.source>21</maven.compiler.source>
        <maven.compiler.target>21</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <neo4j.version>2026.07.0</neo4j.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.neo4j</groupId>
            <artifactId>neo4j</artifactId>
            <version>${neo4j.version}</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>io.github.kawamuray.wasmtime</groupId>
            <artifactId>wasmtime-java</artifactId>
            <version>0.19.0</version>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <configuration>
                    <source>21</source>
                    <target>21</target>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-shade-plugin</artifactId>
                <version>3.5.1</version>
                <executions>
                    <execution>
                        <phase>package</phase>
                        <goals>
                            <goal>shade</goal>
                        </goals>
                        <configuration>
                            <filters>
                                <filter>
                                    <artifact>org.neo4j:*</artifact>
                                    <excludes>
                                        <exclude>**/*</exclude>
                                    </excludes>
                                </filter>
                            </filters>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>

这里有两点值得注意:

  1. org.neo4j:neo4j 被声明为 provided 作用域——Neo4j 在运行时已经存在于数据库 JVM 中,因此我们将其从打包的 JAR 中排除。
  2. 我们使用 maven-shade-plugin 而不是 maven-jar-plugin 来生成一个 fat JAR,将 wasmtime-java 及其原生库与我们的代码打包在一起。

请更新 neo4j.version 属性,以匹配你自己的 Neo4j Desktop 安装。

案例 3:完整极性映射

VADER 会产生四个分数:compound、positive、negative 和 neutral。在本案例中,我们更新 Rust 函数以返回全部四个分数,并让 Java UDF 将它们作为 Map 返回——与上一篇文章中的 Java VADER UDF 返回形状一致。

sentimentable.wit 文件

我们将返回类型从单个 f32 改为包含四个 f32 值的元组:

package local:sentimentable;

world sentimentable {
  export sentimentable: func(input: string) -> tuple<f32, f32, f32, f32>;
}

我们使用元组而不是命名记录。两者都可以,但元组在 Java 侧更简单——我们从内存中已知偏移处读取四个连续的 f32 值,而无需解码字段名。

lib.rs 文件

$ cargo
wit_bindgen::generate!({
    world: "sentimentable",
});

struct Component;

impl Guest for Component {
    fn sentimentable(input: String) -> (f32, f32, f32, f32) {
        lazy_static::lazy_static! {
            static ref ANALYZER: vader_sentiment::SentimentIntensityAnalyzer<'static> =
                vader_sentiment::SentimentIntensityAnalyzer::new();
        }

        let scores = ANALYZER.polarity_scores(input.as_str());

        (
            *scores.get("compound").unwrap_or(&0.0) as f32,
            *scores.get("pos").unwrap_or(&0.0) as f32,
            *scores.get("neg").unwrap_or(&0.0) as f32,
            *scores.get("neu").unwrap_or(&0.0) as f32,
        )
    }
}

export!(Component);

构建:

$ bash
cd ~/wasm-udf/sentimentable
cargo build --target wasm32-wasip1 --release

检查二进制文件

我们先检查导出:

wasm-objdump -x target/wasm32-wasip1/release/sentimentable.wasm | grep "^Export" -A 6

应该会出现四个导出:memory、sentimentable、cabi_realloc 和 cabi_realloc_wit_bindgen_0_40_0。

现在我们来查找 sentimentable 函数的类型签名。找到 sig 索引:

wasm-objdump -x target/wasm32-wasip1/release/sentimentable.wasm | grep "func\[9\]" | head -1

然后查找它:

wasm-objdump -x target/wasm32-wasip1/release/sentimentable.wasm | grep "type\[9\]"

你应该会看到:

- type[9] (i32, i32) -> i32

签名是 (i32, i32) -> i32。这是返回单个标量与返回元组之间的关键区别:wit-bindgen 对单个值使用直接 f32 返回,但在返回元组时切换为间接结果指针。该指针处写入的是四个 f32 值(16 字节),位于连续的 4 字节偏移处。Java 侧会读取全部四个值。

这说明 WIT 接口定义与生成的核心 Wasm ABI 之间存在重要区别。WIT 签名和 Wasm 层面的签名属于不同层次:wit-bindgen 将 WIT 类型 lowering 为核心 Wasm ABI,而 lowering 策略取决于返回类型。像 f32 这样的单个标量会作为 Wasm 值直接返回。元组则通过线性内存间接返回,调用方会收到一个指向值写入位置的指针。Java 调用代码必须匹配生成的 ABI,而不是 WIT 定义,这就是为什么在编写 Java 包装器之前,必须使用 wasm-objdump 检查二进制文件。图 3 展示了内存布局。

[LOADING...]

图 3. 内存布局。

图 4 比较了案例 2 和案例 3。

[LOADING...]

图 4. 案例 2 与案例 3 的 ABI 比较

SentimentUDF.java 文件

$ java
package com.example;

import io.github.kawamuray.wasmtime.Engine;
import io.github.kawamuray.wasmtime.Func;
import io.github.kawamuray.wasmtime.Linker;
import io.github.kawamuray.wasmtime.Memory;
import io.github.kawamuray.wasmtime.Module;
import io.github.kawamuray.wasmtime.Store;
import io.github.kawamuray.wasmtime.WasmFunctions;
import io.github.kawamuray.wasmtime.WasmValType;
import io.github.kawamuray.wasmtime.wasi.WasiCtx;
import io.github.kawamuray.wasmtime.wasi.WasiCtxBuilder;
import org.neo4j.procedure.Description;
import org.neo4j.procedure.Name;
import org.neo4j.procedure.UserFunction;

import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;

public class SentimentUDF {

    @UserFunction("com.example.wasm.sentiment")
    @Description("Scores text using VADER sentiment analysis compiled to Wasm. Returns compound, positive, negative, neutral.")
    public Map<String, Double> sentiment(@Name("text") String text) throws Exception {
        if (text == null || text.isBlank()) {
            return Map.of("compound", 0.0, "positive", 0.0, "negative", 0.0, "neutral", 1.0);
        }

        byte[] wasmBytes;
        try (InputStream is = SentimentUDF.class.getResourceAsStream("/sentimentable.wasm")) {
            if (is == null) throw new RuntimeException("sentimentable.wasm not found in resources");
            wasmBytes = is.readAllBytes();
        }

        WasiCtx wasi = new WasiCtxBuilder().inheritStdout().inheritStderr().build();
        try (Store<?> store = Store.withoutData(wasi);
             Engine engine = store.engine();
             Module module = Module.fromBinary(engine, wasmBytes);
             Linker linker = new Linker(engine)) {

            WasiCtx.addToLinker(linker);
            linker.module(store, "", module);

            Memory memory = linker.get(store, "", "memory").get().memory();
            Func reallocFn = linker.get(store, "", "cabi_realloc").get().func();
            WasmFunctions.Function4<Integer, Integer, Integer, Integer, Integer> realloc = WasmFunctions.func(
                store, reallocFn,
                WasmValType.I32, WasmValType.I32, WasmValType.I32, WasmValType.I32, WasmValType.I32);

            byte[] inputBytes = text.getBytes(StandardCharsets.UTF_8);
            int len = inputBytes.length;
            int strPtr = realloc.call(0, 0, 1, len);

            ByteBuffer buf = memory.buffer(store);
            buf.position(strPtr);
            buf.put(inputBytes);

            Func sentimentFn = linker.get(store, "", "sentimentable").get().func();
            WasmFunctions.Function2<Integer, Integer, Integer> scoreFn = WasmFunctions.func(
                store, sentimentFn,
                WasmValType.I32, WasmValType.I32, WasmValType.I32);

            int resultPtr = scoreFn.call(strPtr, len);

            // read four f32 values at 4-byte offsets: compound, pos, neg, neu
            ByteBuffer resultBuf = memory.buffer(store);
            resultBuf.order(ByteOrder.LITTLE_ENDIAN);
            float compound = resultBuf.getFloat(resultPtr);
            float positive = resultBuf.getFloat(resultPtr + 4);
            float negative = resultBuf.getFloat(resultPtr + 8);
            float neutral = resultBuf.getFloat(resultPtr + 12);

            Map<String, Double> result = new HashMap<>();
            result.put("compound", (double) compound);
            result.put("positive", (double) positive);
            result.put("negative", (double) negative);
            result.put("neutral", (double) neutral);

            return result;
        }
    }
}

返回类型是 Map,null 保护返回中性映射,并且从结果指针开始的连续 4 字节偏移处进行四次 getFloat() 读取。

构建与部署

复制 Wasm 二进制文件,构建并部署:

$ bash
cp ~/wasm-udf/sentimentable/target/wasm32-wasip1/release/sentimentable.wasm \
  ~/wasm-udf/neo4j-wasm-udf/src/main/resources/
cd ~/wasm-udf/neo4j-wasm-udf
mvn -q clean package
cp target/neo4j-wasm-udf-1.0-SNAPSHOT.jar \
  ~/Library/Application\ Support/neo4j-desktop/Application/Data/dbmss//plugins/

停止 Neo4j,重新启动它,然后运行验证查询。

正面句子:

RETURN com.example.wasm.sentiment('The movie was great') AS scores;

结果:

$ cat
{
  "compound": 0.624893307685852,
  "positive": 0.577464759349823,
  "negative": 0.0,
  "neutral": 0.4225352108478546
}

大小写测试:

RETURN com.example.wasm.sentiment('The movie was GREAT!') AS scores;

结果:

$ cat
{
  "compound": 0.7290259003639221,
  "positive": 0.6307692527770996,
  "negative": 0.0,
  "neutral": 0.3692307770252228
}

空字符串保护:

RETURN com.example.wasm.sentiment('') AS scores;

结果:

$ cat
{
  "compound": 0.0,
  "positive": 0.0,
  "negative": 0.0,
  "neutral": 1.0
}

所有三种情况都表现正确。

总结

我们着手构建上一篇文章称不存在的可运行示例。以下是我们展示的内容。

我们将一个编译为 Wasm 的真实 VADER 情感分析器嵌入 Neo4j Java UDF,返回完整的极性映射——compound、positive、negative 和 neutral——与上一篇文章中的 Java VADER UDF 返回形状一致。wit-bindgen 的元组 ABI 将四个 f32 值写入连续的内存地址;Java 侧使用 LITTLE_ENDIAN 的 ByteBuffer 将它们读回。四个分数都正确,大小写敏感有效,空字符串保护返回合理的中性映射。

结果是一种可行的集成模式,而不是原生 Java 实现的通用替代品。由于这个概念验证中使用了每次调用初始化的方式,该方法最适合低频工作负载,在这些场景中,Wasm 的隔离性和可移植性足以证明额外复杂度的合理性。对于高吞吐量工作负载,自然的下一步是进行基准测试,并复用 Wasmtime 引擎和已编译模块,同时在调用之间适当隔离执行状态。

在下一篇文章中,我们将探讨在 Neo4j 内运行 TypeSafe AI 的 Jev,以进行校准的情感决策。敬请关注!

完整源代码可在 GitHub 上获取。

DZone 贡献者表达的观点仅代表他们自己。