Java から FFM API で Rust を呼ぶ(1/4)


はじめに

JDK 22 で正式導入された Project Panama の FFM API により、JVM 外のネイティブコードの呼び出しが容易になりました。

簡単な例で、段階的に FFM API の使い方を見ていきます。


プロジェクト作成

Gradle でプロジェクト作成します(テストクラスは削除しておきます)。

mkdir ffm-api
cd ffm-api
gradle init --type java-application --java-version 25 --no-comments --use-defaults
rm app/src/test/java/org/example/AppTest.java

Rust プロジェクトも作成します。app/src/main/rust の中に作っておきます。

mkdir -p app/src/main/rust/math_lib
cd app/src/main/rust/math_lib
cargo init --lib

こんな感じの構成になります。


Rust側の実装

最初に Rust 側の実装として、単なる足し算を行う関数を作成します。

共有ライブラリを出力するために cdylib を指定します。

[package]
name = "math_lib"
version = "0.1.0"
edition = "2024"

[lib]
crate-type = ["cdylib"]

src/lib.rc を以下のようにします。

#[unsafe(no_mangle)]
pub extern "C" fn add(a: i32, b: i32) -> i32 {
    a + b
}

マングリングを無効化するために no_mangle を指定し、extern "C" でC言語のABI(Application Binary Interface)を指定するしています。


Java側の実装

FFM API(Foreign Function & Memory API) でRustで作成した関数を呼び出します。

テンプレート作成された App.java を以下のように編集します。

package org.example;

import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;

public class App {
    
    static void main(String[] args) throws Throwable {

        Arena arena = Arena.global();
        SymbolLookup mathLib = SymbolLookup.libraryLookup(System.mapLibraryName("math_lib"), arena);

        MemorySegment addNumbersSymbol = mathLib.find("add")
            .orElseThrow(RuntimeException::new);

        MethodHandle addNumbers = Linker.nativeLinker().downcallHandle(
            addNumbersSymbol,
            FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.JAVA_INT, ValueLayout.JAVA_INT)
        );

        int x = 15, y = 27;
        int result = (int) addNumbers.invoke(x, y);
        System.out.println(x + " + " + y + " = " + result);
    }
}

System.mapLibraryName("math_lib") でプラットフォームに応じたライブラリ名が得られます。macOS の場合は libmath_lib.dylib"、Linux の場合はlibmath_lib.so、Windows の場合はmath_lib.dll` のようになります。

SymbolLookup を作成し、find("add") により作成した add 関数のハンドルを取得し、これを実行する形となります。


Gradle から Rust をコンパイルする

まず、Gradle から Rust のコンパイルを行えるようにします。

以下のような cargoBuild タスクとして登録します。

val cargoExecutable = System.getProperty("user.home") + "/.cargo/bin/cargo"
val cargoTargetDir = layout.buildDirectory.dir("rust/math_lib/").get().asFile

tasks.register<Exec>("cargoBuild") {
    description = "Builds the Rust library using Cargo"
    group = "rust"
    workingDir = layout.projectDirectory.dir("src/main/rust/math_lib").asFile

    inputs.dir("$workingDir/src").withPropertyName("rustSourceDir")
    inputs.files("$workingDir/Cargo.toml", "$workingDir/Cargo.lock").withPropertyName("cargoToml")
    outputs.dir(cargoTargetDir).withPropertyName("cargoTargetDir")

    commandLine = listOf(cargoExecutable, "build", "--release",
        "--target-dir", cargoTargetDir.absolutePath)
}

Gradle からのビルド時には、cargo のパスが通っていないので、cargoExecutablecargo の実行ファイルへのパスを取得しています。

cargo のビルド結果は、Gradle の流儀に従い、Gradle のビルドディレクトリに生成するようにしました。cargo の引数で --target-dir として指定しています。

これで、Rust 側のビルドは以下で行えるようになります。

./gradlew cargoBuild


Gradle から 実行準備

これで準備が整いましたが、Java 側に作成したライブラリのパスを知らせる必要があります。

JNI を使う場合は、Java の起動時に "-Djava.library.path=$rustLibDir" のように指定して System.loadLibrary で読み込む形となりますが、FFM API ではプラットフォーム別の環境変数に応じて読み込みが行われます。

macOS の場合は DYLD_LIBRARY_PATH"、Linux の場合はLD_LIBRARY_PATH、Windows の場合はPATHl` などから検索されるので、実行時に環境変数を設定します。

import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform

tasks.named<JavaExec>("run") {
    dependsOn("cargoBuild")
    val rustLibDir = File(cargoTargetDir, "release").absolutePath
    val os = DefaultNativePlatform.getCurrentOperatingSystem()
    if (os.isMacOsX) {
        environment("DYLD_LIBRARY_PATH", rustLibDir)
    } else if (os.isWindows) {
        environment("PATH", rustLibDir)
    } else {
        environment("LD_LIBRARY_PATH", rustLibDir)
    }
}

また、FFM APIによるネイティブアクセスを許可するために --enable-native-access=ALL-UNNAMED を起動引数に追加します。

application {
    mainClass = "org.example.App"
    applicationDefaultJvmArgs = listOf("--enable-native-access=ALL-UNNAMED")
}


実行

build.gradle.kts 全体は以下のようになりました。

import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform

plugins {
    application
}

repositories {  mavenCentral() }

java.toolchain {
    languageVersion = JavaLanguageVersion.of(25)
}

application {
    mainClass = "org.example.App"
    applicationDefaultJvmArgs = listOf("--enable-native-access=ALL-UNNAMED")
}

val cargoExecutable = System.getProperty("user.home") + "/.cargo/bin/cargo"
val cargoTargetDir = layout.buildDirectory.dir("rust/math_lib/").get().asFile

tasks.register<Exec>("cargoBuild") {
    description = "Builds the Rust library using Cargo"
    group = "rust"
    workingDir = layout.projectDirectory.dir("src/main/rust/math_lib").asFile

    inputs.dir("$workingDir/src").withPropertyName("rustSourceDir")
    inputs.files("$workingDir/Cargo.toml", "$workingDir/Cargo.lock").withPropertyName("cargoToml")
    outputs.dir(cargoTargetDir).withPropertyName("cargoTargetDir")

    commandLine = listOf(cargoExecutable, "build", "--release",
        "--target-dir", cargoTargetDir.absolutePath)
}


tasks.named<JavaExec>("run") {
    dependsOn("cargoBuild")
    val rustLibDir = File(cargoTargetDir, "release").absolutePath
    val os = DefaultNativePlatform.getCurrentOperatingSystem()
    if (os.isMacOsX) {
        environment("DYLD_LIBRARY_PATH", rustLibDir)
    } else if (os.isWindows) {
        environment("PATH", rustLibDir)
    } else {
        environment("LD_LIBRARY_PATH", rustLibDir)
    }
}

実行してみましょう。

$ ./gradlew run
Reusing configuration cache.

> Task :app:run
15 + 27 = 42

Rust で書いた add() 関数を呼び出すことができました。


まとめ

Rust で書いた add() 関数を Java から呼び出すことができました。

しかし、Java 側の実装は、MethodHandle の取得がいかにも泥臭く面倒です。

これらを解消する jextract というツールが JDK チームから提供されており、これを利用することで、外部関数の呼び出しは驚くほど簡単になります。

次回は jextract の導入手順を紹介します。