Back
Featured image of post 通过 JNI 实现 Kotlin 调用 Rust

通过 JNI 实现 Kotlin 调用 Rust

似乎 JNI 的使用没有想象中的那么麻烦。

JNI 简述

Java 原生接口(Java Native Interface),亦即 JNI,是 Java(包括其他 JVM 语言)与外部动态库交互的唯一手段。尽管如今有 JNA 之类,但其底层实质也使用 JNI,并通过 libffi 和其他原生库交互,本文就不再赘述了。

JVM 的主要优势是可移植性,JVM 语言编译的结果也是独立于平台的字节码,能够运行在任何有 JVM 实现的设备上。

然而在某些情况下,我们确实需要使用平台特定的原生代码,原因可能是:

  • 要求严苛的性能
  • 所需的库没有 JVM 实现,但又不想重写
  • 安全考量,原生代码往往比字节码难逆向

自然的,JNI 也会带来许多问题:

  • 可移植性降低,需要依赖原生库
  • 开发成本变高,工作流需要改变,测试、编译需要同时覆盖多个平台。
  • Jar 包体积变大,为了可移植性,可能会同时打包多个平台的原生库,而运行时只可能有一个平台。
  • 性能可能不会改善太多,因为原生代码和 Java 代码之间的类型并不直接兼容,可能会导致额外的类型转换和内存分配开销。因此应该做充足的性能测试,以免做无用功。

总之,我建议在必要时才使用 JNI。

Kotlin 侧代码编写

Java 中使用 native 关键字标明 JNI 方法,而 Kotlin 使用 external 关键字,该关键字类似 abstract,不需要在声明处实现方法,而是让外部库完成:

package moe.sdl.r2k

object NativeLib {
  init {
    System.loadLibrary("simplejni")
  }

  external fun helloJni()
  external fun helloJniString(string: String): String
  external fun helloJniArray(arr: ByteArray): ByteArray
}

你可以像上面那样用一个单例 object 用作 JNI 类。在这里,System.loadLibrary("simplejni") 方法的作用就是让 JVM 根据名称在可用路径中查找该动态库,路径可用命令行传参 -Djava.library.path=path/to/dylibDir 手动指定。simplelib 是欲加载动态库的名字,不包含 lib 前缀或 .so .dll .dylib 等后缀,因为这些前后缀是平台特定的。如果你想要加载特定路径下的动态库,则使用 System.load("path/to/dylib")。这两个方法都会在找不到动态库时抛出 UnsatisfiedLinkError,自定义加载策略时,请注意 try-catch

还可以像这样用普通类加伴生对象的方式加载:

package moe.sdl.r2k

class NativeLib {
  external fun helloJni()
  
  companion object {
    init {
      System.loadLibrary("simplejni")
    }
  }
}

或者可以用顶层函数直接写,不过这样就不能自动加载了,需要调用方在首次调用前手动加载动态库:

@file:JvmName("NativeLib")

package moe.sdl.r2k

external fun helloJni()

可以自定义加载逻辑,以下是根据运行时 JVM 属性和环境变量自定义加载动态库的案例:

init {
  val loadFuncs = buildList {
    add { System.loadLibrary("simplejni") }
    arrayOf(
      System.getProperty("simplejni.dylib.path"),
      System.getenv("SIMPLEJNI_DYLIB_PATH"),
    ).forEach {
      add { System.load(it) }
    }
  }
  val exceptions = arrayListOf<Throwable>()
  var success = false
  for (load in loadFuncs) {
    try {
      load()
      success = true
      break
    } catch (e: UnsatisfiedLinkError) {
      exceptions.add(e)
    }
  }
  if (!success) {
    throw UnsatisfiedLinkError("Failed to load native library `simplejni`, errors:\n" +
      exceptions.joinToString("\n") { it.stackTraceToString() })
  }
}

此外,我们常常需要将动态库打包至 Jar 中,以满足单文件运行需求。此时我们不能直接加载 Jar 包中的动态库,而是提取出来,放到临时文件夹中,再调用 deleteOnExit 方法,在结束时删除。这似乎是唯一可行的方式。

生成头文件

其实对于和 Rust 交互而言,有没有头文件都无伤大雅,只是生成后会更为方便。我们在这里只是要生成符合 JNI 命名规范的函数名:

  • 前缀 Java_
  • 完全限定类名
  • 使用下划线 _ 分隔路径
  • 转义后的方法名
  • 对于重载方法,需要在两个下划线 __后,加上转义后的类型签名

也就是说下划线不能直接使用,已经被用作了分隔符,所以,我们有以下转义序列:

  • _0XXXX 其中 XXXX 为 Unicode 字符
  • _1:转义为 _
  • _2:转义为签名中的 ;
  • _3:转义为签名中的 [

然而 Kotlin 暂时还没有自己的类似 javahjavac -h 的工具,所以需要通过转换 class 文件生成。

Gradle 任务如下:

tasks.create("generateJniHeaders") {
  group = "build"
  dependsOn(tasks.getByName("compileKotlin"))

  kotlin.sourceSets.getByName("main").kotlin.srcDirs.filter {
    it.exists()
  }.forEach {
    inputs.dir(it)
  }
  outputs.dir("src/main/generated/jni")

  doLast {
    val javaHome = org.gradle.internal.jvm.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/kotlin/main")
    val tmpDir = file("build/tmp/jvmJni")
    tmpDir.mkdirs()

    buildDir.walk()
      .asSequence()
      .filter { "META" !in it.absolutePath }
      .filter { it.isFile }
      .filter { it.extension == "class" }
      .forEach { file ->
        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).map { it.groups }
            .flatMap { it.asSequence().mapNotNull { group -> group?.value } }.toList()
        if (nativeMethods.isEmpty()) return@forEach

        val generatedCode = buildString {
          appendLine("package $packageName;")
          appendLine("public class $className {")
          nativeMethods.forEach { method ->
            val newMethod = if (method.contains("()")) {
              method
            } else buildString {
              append(method)
              var count = 0
              var i = 0
              while (i < length) {
                if (this[i] == ',' || this[i] == ')') {
                  count++
                  insert(i, " arg$count".also { i += it.length + 1 })
                } else {
                  i++
                }
              }
            }
            appendLine(newMethod)
          }
          appendLine("}")
        }
        val javaFile = tmpDir
          .resolve(packageName.replace(".", "/"))
          .resolve("$className.java")
        javaFile.parentFile.mkdirs()
        if (javaFile.exists()) delete()
        javaFile.createNewFile()
        javaFile.writeText(generatedCode)
        project.exec {
          commandLine(javac, "-h", "src/main/generated/jni", javaFile.absolutePath)
        }.assertNormalExitValue()
      }
  }
}

如下 C 头文件(删除了前后的样板代码):

/*
 * Class:     moe_sdl_r2k_NativeLib
 * Method:    helloJni
 * Signature: ()V
 */
JNIEXPORT void JNICALL Java_moe_sdl_r2k_NativeLib_helloJni
  (JNIEnv *, jobject);

/*
 * Class:     moe_sdl_r2k_NativeLib
 * Method:    helloJniString
 * Signature: (Ljava/lang/String;)Ljava/lang/String;
 */
JNIEXPORT jstring JNICALL Java_moe_sdl_r2k_NativeLib_helloJniString
  (JNIEnv *, jobject, jstring);

/*
 * Class:     moe_sdl_r2k_NativeLib
 * Method:    helloJniArray
 * Signature: ([B)[B
 */
JNIEXPORT jbyteArray JNICALL Java_moe_sdl_r2k_NativeLib_helloJniArray
  (JNIEnv *, jobject, jbyteArray);

Rust 侧代码编写

本文使用 jni 库,在 Cargo.toml 中添加:

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

[dependencies]
jni = "0.20"

cdylib 即为 C ABI 的动态库,这对 JNI 而言是必须的。

首先看一个最简单无参方法:

use jni::{JNIEnv, objects::JClass};

#[no_mangle]
pub extern "system" fn Java_moe_sdl_r2k_NativeLib_helloJni(
  _env: JNIEnv,
  _class: JClass,
) {
  println!("Hello from Rust!");
}

需要标上 #[no_mangle] 以避免 Rust 编译器改变名称,pub extern "system" 表示这要给外部调用,函数名则使用头文件中生成的。此处 _env _class 虽然没用,但是形参的组成部分,所以用下划线起头表示不会用到。

fun main() {
  NativeLib.helloJni()
}

// Hello from Rust!

如果涉及堆内存分配,则需要使用 env 了:

#[no_mangle]
pub extern "system" fn Java_moe_sdl_r2k_NativeLib_helloJniString(
  env: JNIEnv,
  _class: JClass,
  string: JString,
) -> jstring {
  let string: String = env.get_string(string).unwrap().into();
  let output = env
      .new_string(format!("Hello, {}!", string))
      .unwrap();
  output.into_raw()
}

可以看到我们需要使用 get_string 转换字符串并在堆上重新分配。返回时我们也要重新分配两次,一次是 format!() 一次是 env.new_string()

数组的情况类似:

#[no_mangle]
pub extern "system" fn Java_moe_sdl_r2k_NativeLib_helloJniArray(
  env: JNIEnv,
  _class: JClass,
  array: jbyteArray,
) -> jbyteArray {
  let mut array = env.convert_byte_array(array).unwrap();
  array.push(array.last().unwrap().add(1));
  let output = env.byte_array_from_slice(&array).unwrap();
  output
}

在 Kotlin 中调用:

fun main() {
  NativeLib.helloJni()
  println(NativeLib.helloJniString("JNI"))
  println(NativeLib.helloJniArray(byteArrayOf(1, 2, 3, 4, 5)).joinToString())
}
// Hello from Rust!
// Hello, JNI!
// 1, 2, 3, 4, 5, 6
comments powered by Disqus