使用 Rust 語言編寫 Java JNI 實現

srcres258發表於2024-07-07

前言

Rust 語言是近幾年來程式語言界的新秀之子,因其嚴格的記憶體安全保障機制而備受眾多程式設計師的青睞與推崇。而 Rust 語言除了可用於編寫獨立執行的二進位制程式以外,亦可用於編寫動態連結庫並被第三方程式動態載入呼叫。筆者趁 Rust 學習途中就動手藉助 jni crate 從而使用 Rust 語言透過 JNI 實現 Java 程式中的本地方法,並將此練手專案以及其編寫過程一字不落地記錄於此。

前置:相關環境的設定與必要軟體的安裝

JDK

JDK 是編寫 Java 程式必要的開發元件。筆者使用的是 AdoptOpenJDK 版本 21 ,可在此處下載與你作業系統與架構相匹配的 AdoptOpenJDK 。詳細安裝過程受限於文章篇幅故略去,請自行檢索安裝方法。

筆者在此處貼出自己的 Java 版本資訊以供讀者對照:

$ /opt/adoptopenjdk-21.0.2+13/bin/java -version 
openjdk version "21.0.2" 2024-01-16 LTS
OpenJDK Runtime Environment Temurin-21.0.2+13 (build 21.0.2+13-LTS)
OpenJDK 64-Bit Server VM Temurin-21.0.2+13 (build 21.0.2+13-LTS, mixed mode, sharing)

Rust

既然要編寫 Rust 程式碼,首先需要設定好 Rust 開發環境。筆者使用 Rustup 工具安裝 Rust ,前往該連結可獲取與你作業系統相符的安裝工具或 shell 命令列。

筆者在此處貼出自己的 Rustup 版本資訊以供讀者對照:

$ rustup --version
rustup 1.27.0 (bbb9276d2 2024-03-08)
info: This is the version for the rustup toolchain manager, not the rustc compiler.
info: The currently active `rustc` version is `rustc 1.76.0 (07dca489a 2024-02-04)`

Gradle

由於筆者使用 Gradle 作為 Java 程式碼的構建工具,故需先安裝 Gradle 。 Gradle 已在其官方文件中給出不同作業系統上的安裝方法,不過是英文版。考慮到讀者的閱讀需要,故記錄一下自己的安裝方法(Linux 系統上)。

Gradle 官方推薦採用 SDKMAN! 安裝 Gradle ,筆者亦使用該方式來安裝。前往該連結獲取安裝 shell 命令並執行(無需 sudo ,安裝到使用者主資料夾下):

$ curl -s "https://get.sdkman.io" | bash

等待 SDKMAN! 安裝完成後,重啟電腦或重新開啟 shell ,執行:

$ sdk install gradle

稍等片刻, SDKMAN! 就自動安裝好 Gradle 了。筆者在此處貼出自己的 Gradle 版本資訊以供讀者對照:

$ gradle --version                                  

------------------------------------------------------------
Gradle 8.7
------------------------------------------------------------

Build time:   2024-03-22 15:52:46 UTC
Revision:     650af14d7653aa949fce5e886e685efc9cf97c10

Kotlin:       1.9.22
Groovy:       3.0.17
Ant:          Apache Ant(TM) version 1.10.13 compiled on January 4 2023
JVM:          17.0.10 (Oracle Corporation 17.0.10+11-LTS-240)
OS:           Linux 6.8.2-zen2-1-zen amd64

準備:Java 程式的編寫

新起一個資料夾作為專案的根資料夾,筆者起名為 rust-jni-demo

$ mkdir rust-jni-demo
$ cd rust-jni-demo

在專案根資料夾下另起一資料夾 java 作為 Java 程式碼部分的根目錄:

$ mkdir java
$ cd java

我們對此專案的 Java 程式碼採用 Gradle 構建工具。在該目錄中初始化 Gradle 的相關配置:

$ gradle init --use-defaults --type java-application

等待 Gradle 建立 Gradle Wrapper 與相關專案初始檔案。完成後使用 Java IDE (筆者使用 IntelliJ IDEA )進入 app 目錄並修改相關示例程式碼檔案,編輯主類 App

/*
 * This source file was generated by the Gradle 'init' task
 */
package top.srcres.apps.rustjnidemo;

import java.io.File;
import java.nio.file.Path;

public class App {
    static void loadRustLibrary() {
        System.out.println(System.getProperty("java.library.path"));
        System.loadLibrary("rust_jni_demo");
    }

    static native String hello(String input);

    public static void main(String[] args) {
        loadRustLibrary();

        String output = hello("string from Java");
        System.out.println(output);
    }
}

其中 hello 方法即為我們要在 Rust 程式碼中實現的 native 方法。切回 Gradle 專案根目錄並執行 ./gradlew build 先構建一下專案。

接下來需要編輯 app 目錄下的 build.gradle.kts 檔案,為 Gradle 新增生成 JNI 標頭檔案的 Task ,在檔案末尾加入:

val generateJniHeaders: Task by tasks.creating {
    val jniHeaderDir = file("src/main/generated/jni")

    group = "build"
    dependsOn(tasks.getByName("compileJava"))

    inputs.dir("src/main/java")
    outputs.dir(jniHeaderDir)

    doLast {
        val javaHome = Jvm.current().javaHome
        val javap = javaHome.resolve("bin").walk().firstOrNull { it.name.startsWith("javap") }?.absolutePath ?: error("javap not found")
        val javac = javaHome.resolve("bin").walk().firstOrNull { it.name.startsWith("javac") }?.absolutePath ?: error("javac not found")
        val buildDir = file("build/classes/java/main")
        val tmpDir = file("build/tmp/jvmJni").apply { mkdirs() }

        val bodyExtractingRegex = """^.+\Rpublic \w* ?class ([^\s]+).*\{\R((?s:.+))\}\R$""".toRegex()
        val nativeMethodExtractingRegex = """.*\bnative\b.*""".toRegex()

        println("Beginning to generate JNI headers.")
        println("javaHome is ${javaHome.absolutePath}")
        println("javap is $javap")
        println("javac is $javac")

        buildDir.walkTopDown()
                .filter { "META" !in it.absolutePath }
                .forEach { file ->
                    if (!file.isFile) return@forEach

                    val output = ByteArrayOutputStream().use {
                        project.exec {
                            commandLine(javap, "-private", "-cp", buildDir.absolutePath, file.absolutePath)
                            standardOutput = it
                        }.assertNormalExitValue()
                        it.toString()
                    }

                    val (qualifiedName, methodInfo) = bodyExtractingRegex.find(output)?.destructured ?: return@forEach

                    val lastDot = qualifiedName.lastIndexOf('.')
                    val packageName = qualifiedName.substring(0, lastDot)
                    val className = qualifiedName.substring(lastDot+1, qualifiedName.length)

                    val nativeMethods =
                            nativeMethodExtractingRegex.findAll(methodInfo).mapNotNull { it.groups }.flatMap { it.asSequence().mapNotNull { group -> group?.value } }.toList()
                    if (nativeMethods.isEmpty()) return@forEach

                    val source = buildString {
                        appendln("package $packageName;")
                        appendln("public class $className {")
                        for (method in nativeMethods) {
                            if ("()" in method) appendln(method)
                            else {
                                val updatedMethod = StringBuilder(method).apply {
                                    var count = 0
                                    var i = 0
                                    while (i < length) {
                                        if (this[i] == ',' || this[i] == ')') insert(i, " arg${count++}".also { i += it.length + 1 })
                                        else i++
                                    }
                                }
                                appendln(updatedMethod)
                            }
                        }
                        appendln("}")
                    }
                    val outputFile = tmpDir.resolve(packageName.replace(".", "/")).apply { mkdirs() }.resolve("$className.java").apply { delete() }.apply { createNewFile() }
                    outputFile.writeText(source)

                    println("Generating for ${outputFile.absolutePath} into ${jniHeaderDir.absolutePath}")
                    project.exec {
                        commandLine(javac, "-h", jniHeaderDir.absolutePath, outputFile.absolutePath)
                    }.assertNormalExitValue()
                }
    }
}

儲存檔案後切回 Gradle 專案根目錄,執行剛才新新增的 Task , ./gradlew generateJniHeaders 。完成後可在 app/src/main/generated/jni 目錄下找到生成的 JNI 標頭檔案,應該能在其中看到如下內容:

/*
 * Class:     top_srcres_apps_rustjnidemo_App
 * Method:    hello
 * Signature: (Ljava/lang/String;)Ljava/lang/String;
 */
JNIEXPORT jstring JNICALL Java_top_srcres_apps_rustjnidemo_App_hello
  (JNIEnv *, jclass, jstring);

自此 Java 程式碼部分編寫完畢,接下來使用 Rust 程式碼實現這個預留的 native 方法。

實現:Rust 動態連結庫的編寫

切回到整體專案的根目錄( java 的父目錄),另起一目錄用以存放 Rust 程式碼:

$ mkdir rust
$ cargo new --name rust-jni-demo rust

注意此處我們用 Rust 自帶的包管理器 Cargo 建立了 rust 目錄並自動地完成了相應的初始化工作,並將這個 Rust Crate 名稱指定為 rust-jni-demo 。修改該目錄中的 Cargo.toml 檔案,新增依賴 cargo 並指定 Crate 型別為動態連結庫:

[dependencies]
jni = "0.21.1"

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

進入 src 目錄,刪掉預設的 main.rs 檔案,新建 lib.rs 檔案,在此檔案中編寫 native 方法的實現:

use jni::JNIEnv;
use jni::objects::{JClass, JObject, JString, JValue};
use jni::sys::{jint, jlong, jstring};
use std::thread;
use std::time::Duration;

fn create_rust_string(src: &str) -> String {
    format!("Rust-created string, {}", src)
}

#[allow(non_snake_case)]
#[no_mangle]
pub extern "system" fn Java_top_srcres_apps_rustjnidemo_App_hello<'a>(
    mut env: JNIEnv<'a>,
    _: JClass<'a>,
    input: JString<'a>
) -> jstring {
    let input: String = env.get_string(&input).expect("Failed to get Java string.").into();
    let output = env.new_string(create_rust_string(&input)).expect("Failed to create Rust string.");
    output.into_raw()
}

回到 Rust Cargo 的根目錄,構建 release 版本的 Cargo :

$ cargo build --release

構建完成後,在 target/release 目錄下應能找到構建生成的動態連結庫(筆者在 Linux 系統上構建生成 librust_jni_demo.so )。接下來需要讓 Java 程式載入這個動態連結庫從而呼叫其對於 native 方法的實現。

回到 Java 程式碼目錄中,編輯 app/build.gradle.kts 檔案,在 application 塊中加入執行 Java 程式的 JVM 引數:

application {
    // Define the main class for the application.
    mainClass = "top.srcres.apps.rustjnidemo.App"
    applicationDefaultJvmArgs = listOf(
            "-Djava.library.path=../../rust/target/release/"
    )
}

執行 ./gradlew run , Java 程式出現以下輸出,代表成功呼叫 Rust 程式碼所編寫的 native 實現。

Rust-created string, string from Java

後續:新增更多不同功能的 native 方法並實現

我們為 Java 程式主類 App 新增更多的靜態 native 方法:

    static int testInt;
    static String testString;
    static String testStringFromRust;

    static native String hello(String input);
    static native int helloInt(int input);
    static native int helloFromTestIntField();
    static native String helloFromTestStringField();
    static native void modifyTestStringFromRust(String input);
    static String callFromRust(String input) {
        System.out.println("Method callFromRust was invoked!");
        return "Java-side received: " + input;
    }
    static native String actCallFromRust(String input);
    static native void delayInRust(long timeMillis);

並在 main 方法中予以呼叫:

    public static void main(String[] args) {
        loadRustLibrary();

        String output = hello("string from Java");
        System.out.println(output);

        int outputInt = helloInt(114514);
        System.out.println(outputInt);

        testInt = 514;
        System.out.println(helloFromTestIntField());

        testString = "String static field from Java";
        System.out.println(helloFromTestStringField());

        modifyTestStringFromRust("string from Java #2");
        System.out.println(testStringFromRust);

        System.out.println("actCallFromRust result: " + actCallFromRust("string from Java #3"));

        System.out.println("Delay in Rust for 2 seconds...");
        delayInRust(2000);
        System.out.println("Delay done.");
    }

轉到 Rust 程式碼中實現這些 native 方法:

#[allow(non_snake_case)]
#[no_mangle]
pub extern "system" fn Java_top_srcres_apps_rustjnidemo_App_helloInt<'a>(
    _: JNIEnv<'a>,
    _: JClass<'a>,
    input: jint
) -> jint {
    input + 1919810
}

#[allow(non_snake_case)]
#[no_mangle]
pub extern "system" fn Java_top_srcres_apps_rustjnidemo_App_helloFromTestIntField<'a>(
    mut env: JNIEnv<'a>,
    class: JClass<'a>
) -> jint {
    let testInt = env.get_static_field(&class, "testInt", "I")
        .expect("Failed to get static field testInt")
        .i()
        .expect("Failed to convert testInt into jint");
    testInt + 114
}

#[allow(non_snake_case)]
#[no_mangle]
pub extern "system" fn Java_top_srcres_apps_rustjnidemo_App_helloFromTestStringField<'a>(
    mut env: JNIEnv<'a>,
    class: JClass<'a>
) -> jstring {
    let testStringObj = env.get_static_field(&class, "testString", "Ljava/lang/String;")
        .expect("Failed to get static field testString")
        .l()
        .expect("Failed to convert testString into JObject");
    let testString: String = env.get_string(&JString::from(testStringObj))
        .expect("Failed to get the value of testString")
        .into();
    let output = env.new_string(create_rust_string(&testString)).expect("Failed to create Rust string.");
    output.into_raw()
}

#[allow(non_snake_case)]
#[no_mangle]
pub extern "system" fn Java_top_srcres_apps_rustjnidemo_App_modifyTestStringFromRust<'a>(
    mut env: JNIEnv<'a>,
    class: JClass<'a>,
    input: JString<'a>
) {
    let inputStr: String = env.get_string(&input).expect("Failed to receive the argument: input").into();
    let testStringFromRust = env.new_string(create_rust_string(&inputStr)).expect("Failed to create Rust string.");
    let testStringFromRustObj = JObject::from(testStringFromRust);
    let testStringFromRustId = env.get_static_field_id(&class, "testStringFromRust", "Ljava/lang/String;")
        .expect("Failed to get the ID of static field testStringFromRust");
    env.set_static_field(&class, &testStringFromRustId, JValue::from(&testStringFromRustObj))
        .expect("Failed to set static field testStringFromRust");
}

#[allow(non_snake_case)]
#[no_mangle]
pub extern "system" fn Java_top_srcres_apps_rustjnidemo_App_actCallFromRust<'a>(
    mut env: JNIEnv<'a>,
    class: JClass<'a>,
    input: JString<'a>
) -> jstring {
    let inputStr: String = env.get_string(&input).expect("Failed to receive the argument: input").into();
    let testStringFromRust = env.new_string(create_rust_string(&inputStr)).expect("Failed to create Rust string.");
    let testStringFromRustObj = JObject::from(testStringFromRust);
    let callFromRustResult = env.call_static_method(&class, "callFromRust", "(Ljava/lang/String;)Ljava/lang/String;", &[JValue::from(&testStringFromRustObj)])
        .expect("Failed to invoke static method callFromRust");
    let callFromRustResultObj = callFromRustResult.l().expect("Failed to convert the method result into JObject.");
    JString::from(callFromRustResultObj).into_raw()
}

#[allow(non_snake_case)]
#[no_mangle]
pub extern "system" fn Java_top_srcres_apps_rustjnidemo_App_delayInRust<'a>(
    _: JNIEnv<'a>,
    _: JClass<'a>,
    input: jlong
) {
    let inputU = u64::try_from(input).expect("Attempting to call delayInRust with negative millisecond duration.");
    thread::sleep(Duration::from_millis(inputU));
}

先構建 Rust 動態連結庫 cargo build --release ,再構建並執行 Java 程式 ./gradlew run 。將會得到如下輸出:

../../rust/target/release/
Rust-created string, string from Java
2034324
628
Rust-created string, String static field from Java
Rust-created string, string from Java #2
Method callFromRust was invoked!
actCallFromRust result: Java-side received: Rust-created string, string from Java #3
Delay in Rust for 2 seconds...
Delay done.

完整專案

完整的專案原始碼已上傳至 GitHub 倉庫 ,使用 MIT 協議開源。由於時間倉促沒能就程式碼細節具體解釋實現原理,讀者可自行前往閱讀研究。

相關文章