在Neo4j中嵌入Wasm:构建此前并不存在的可行实例
在最近一篇 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 是最安全的方法:
然后将 Maven 添加到你的 PATH,并使其在终端会话之间持久生效:
- 在 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 版本:
然后添加 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 会自动为你的平台拉取正确的原生库。
版本摘要
供参考,以下是本文中使用的所有组件版本:
获取代码
在跟着操作之前,请先克隆仓库。所有源文件都已提供,因此你无需手动创建它们。
项目结构
在创建任何文件之前,下面是我们要构建的最终布局。有两个独立的项目:
- 一个编译为 Wasm 的 Rust crate。
- 一个承载 Neo4j UDF 的 Maven 项目。
首先是 Rust crate:
其次是 Maven 项目:
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 策略取决于类型,我们将在检查生成的二进制文件时看到这一点。
有了这三部分,调用链看起来像这样:
图 2 也以图形方式展示了调用链。
[LOADING...]
图 2. 调用链
我们通过两个更简单的过渡案例逐步构建到这一步:
- 案例 1:一个简单的整数加法,用于证明调用链可行。
- 案例 2:返回单个 compound 分数,以引入字符串传递和 WASI。
这两个案例的完整讲解,包括 WasmUDF.java 实现,都在 GitHub 仓库的一份技术报告中。
Maven 项目
这里有两点值得注意:
org.neo4j:neo4j被声明为provided作用域——Neo4j 在运行时已经存在于数据库 JVM 中,因此我们将其从打包的 JAR 中排除。- 我们使用
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 值的元组:
我们使用元组而不是命名记录。两者都可以,但元组在 Java 侧更简单——我们从内存中已知偏移处读取四个连续的 f32 值,而无需解码字段名。
lib.rs 文件
构建:
检查二进制文件
我们先检查导出:
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 文件
返回类型是 Map,null 保护返回中性映射,并且从结果指针开始的连续 4 字节偏移处进行四次 getFloat() 读取。
构建与部署
复制 Wasm 二进制文件,构建并部署:
停止 Neo4j,重新启动它,然后运行验证查询。
正面句子:
RETURN com.example.wasm.sentiment('The movie was great') AS scores;
结果:
大小写测试:
RETURN com.example.wasm.sentiment('The movie was GREAT!') AS scores;
结果:
空字符串保护:
RETURN com.example.wasm.sentiment('') AS scores;
结果:
所有三种情况都表现正确。
总结
我们着手构建上一篇文章称不存在的可运行示例。以下是我们展示的内容。
我们将一个编译为 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 贡献者表达的观点仅代表他们自己。