3K整合系列(四) 使用 JNI 和脚本

Posted by rarnu on 09-01,2023

3K = Kotlin + Ktor + Ktorm,不要记错了哦

在上一篇里,我们讲了如何为 Ktor 开发一个插件,采用 AOP 的方式来为路由增加请求校验。在实际应用中,我们通常会遇到需要高性能,使用现成的底层库(特别是视频编解码或是类似场景),或者是需要动态插入一些功能的场景。

先前在查阅资料时,发现有不少人都对 Ktor 加载 JNI 表示了疑问,其实不只是 Ktor,传统的 Java 开发也都会遇到这类问题,也有不少小伙伴表示自己以前是从 Android 转来做后端的,Android 下要搞 JNI 很方便呀,怎么到了 Java 这,就有很多麻烦呢。其实最根本的原因是,Android 的 APK 在安装时,会将其中的 JNI 依架构取出,并放到特定的目录里去,而这个目录被设置成了 LIBRARY_PATH,即是说程序可以从该目录搜索 JNI 库,并提取其中的函数。而在 Java 的开发中,我们需要手动的去做这方面的管理而已,如果仅是单一架构,那么连这方面的管理都不用做。

下面展示了一个最简单的 JNI 库的制作方式,首先是在 CLion 里面新建一个 Library 项目,随后修改其 CMakeLists.txt

cmake_minimum_required(VERSION 3.26)
project(demo)

set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_FLAGS "-Wall -Wextra")
set(CMAKE_CXX_FLAGS_DEBUG "-g")
set(CMAKE_CXX_FLAGS_RELEASE "-O3")


# JNI
include_directories("$ENV{JAVA_HOME}/include")
link_directories("$ENV{JAVA_HOME}/lib/server")
if (APPLE)
    # mac
    include_directories(/opt/homebrew/include)
    link_directories(/opt/homebrew/lib)
    # JNI
    include_directories("$ENV{JAVA_HOME}/include/darwin")
else()
    # linux
    include_directories(/usr/include/jsoncpp)
    # JNI
    include_directories("$ENV{JAVA_HOME}/include/linux")
endif ()

add_library(demo SHARED library.cpp)

有了 cmake 之后,要搜索并引入 JNI 就很轻松了,写一些简单的代码即可:

#ifndef JNI_LIBRARY_H
#define JNI_LIBRARY_H
#include <jni.h>
extern "C" {
JNIEXPORT jstring JNICALL Java_com_isyscore_wh_native_Demo_sayHello(JNIEnv* env, jobject obj, jstring name);
};
#endif //JNI_LIBRARY_H
#include "library.h"
#include <iostream>
using namespace std;

JNIEXPORT jstring JNICALL Java_com_isyscore_wh_native_Demo_sayHello(JNIEnv* env, jobject obj, jstring name) {
    auto str = string (env->GetStringUTFChars(name, nullptr));
    str = "Hello " + str + "!";
    return env->NewStringUTF(str.c_str());
}

最后就是编译,可以用标准的 cmake 编译方式来进行:

$ mkdir build && cd build
$ cmake ..
$ make

现在我们可以得到一个 libdemo.dylib (由于我使用 Mac,因此编译得到的是 dylib,最终编译得到的动态库依操作系统而不同),把这个dylib 放到 Ktor 项目的根目录下就可以了,因为 Java 运行时,当前目录默认在搜索目录内,因此可以直接找到动态库。

现在我们就可以写一段很简单的代码来完成调用,顺手完成一个简单的请求:

package com.isyscore.wh.native

object Demo {
    init {
        try {
            System.loadLibrary("demo")
        } catch (th: Throwable) {
            println("load library error: $th")
        }
    }
    external fun sayHello(name: String): String
}
get("/hello") {
    val name = call.requestParameters()["name"] ?: "unknown"
    val helloName = Demo.sayHello(name)
    call.respond(AjaxResult.success(obj = helloName))
}

最后就是运行代码并执行测试了:

$ curl 'http://0.0.0.0:8080/hello?name=rarnu'
{
  "code" : 200,
  "msg" : "操作成功",
  "data" : "Hello rarnu!"
}

如果要实现像 Android 那样的区分操作系统和区分架构的 JNI 加载,其方法也很简单,只需要编译各平台各架构的动态库,按一定的规则放置在 resources 目录内即可,比如说放置为 resources/linux-x86_64/libdemo.so
然后我们只需要用 System.getProperty("os.name")System.getProperty("os.arch") 来获取操作系统的名称和架构,通过这两个值,就可以找到唯一匹配的一个动态库。当然了,由于这种方法是将动态库放在 resources 内,所以使用之前需要先将其保存到文件,然后使用 System.load("完整路径") 来进行加载。


刚才说的是采用 C++ 来编写 JNI 库(这也是大部分 JNI 库的开发方式),但是我们同样可以使用 Kotlin 自身来开发 JNI 库,并且也不难。这里我们需要使用 Kotlin/Native,如果你对 C++ 很熟悉的话,上手 Kotlin/Native 应该也是非常快的(当然了我目前完全不建议大家把 Kotlin/Native 用在生产上,下面的内容你也可以当成是一种炫技,只是做简单的事情确实非常香罢了)。

好了,打开 Idea 然后新建一个 KMP 工程,然后删掉我们不需要的内容(像 JVM,JS 这类的)。曾经我在写 Kotlin/Native 相关的文章时提到过,至今为止 Kotlin/Native 都没有自己的库,所有的东西都是用 C 的,因此我们必须使用 cinterops 来导入 C 的库,当然如果非要自我安慰的话,这里的好处也显而易见,就是刚才所说的,只要你熟悉 C++,就能很快上手 Kotlin/Native。

那么下面用 Gradle 来完成导入工作:

plugins {
    kotlin("multiplatform") version "1.9.0"
}

group = "com.isyscore.wh.native"
version = "1.0"

repositories {
    mavenCentral()
}

kotlin {
    macosArm64("native") {
        binaries {
            sharedLib()
        }
        compilations["main"].cinterops.create("jni") {
            val javaHome = File(System.getProperty("java.home")!!)
            packageName = "com.isyscore.jni"
            includeDirs(
                Callable { File(javaHome, "include") },
                Callable { File(javaHome, "include/darwin") },
            )
        }
    }

    sourceSets {
        val commonMain by getting
        val commonTest by getting {
            dependencies {
                implementation(kotlin("test"))
            }
        }
        val nativeMain by getting
        val nativeTest by getting
    }
}

在这里的 compilations["main"].cinterops.create("jni") 即指出了要为 JNI 创建相应的 kotlin wrapper,为了使这段代码能正常工作,我们还需要手动编写一个 jni.def,它的内容如下:

headers = jni.h

jni.def 必须放置到 KMP 项目src 目录下的 nativeInterop/cinterop 内,这是 Kotlin/Native 约定俗成的放置方法,不需要再额外进行配置。当我们完成这些配置,刷新 Gradle 之后,就可以在代码中引用 JNI 相关的内容了。

不同于配置的复杂,实际上的 JNI 代码写起来相当简单,如下即可:

import com.isyscore.jni.*
import kotlinx.cinterop.CPointer

@CName("Java_com_isyscore_wh_native_Demo_sayHello")
fun sayHello(env: CPointer<JNIEnvVar>, thiz: jobject, name: jstring): jstring = jniWith(env) {
    "hello ${name.asKString()}".asJString()
}

编写完成后使用 Gradle 来编译,就能得到与上面 C++ 代码一样的 libdemo.dylib

$ gradle clean build -x test

再往下的使用方法与之前完全一样,在此也就不再赘述。


下面要说的就是脚本了,在很多动态需求的场景下,我们都会使用脚本来解决问题。但是传统的内嵌脚本会带来很多问题,比如说我们之前在 Go 项目里使用 JS,这里会遇到 Go 的对象和 JS 对象不一致,需要转换的问题,在转换的过程中又会遇到成员变量命名的问题。在 Kotlin 的场景下也是如此,很多情况下我们会更加希望在 Kotlin 代码内直接嵌入 Kotlin script(kts),以避免类型不一致,甚至是开发习惯也希望统一。

当然我们现在跟本无须去担心这样的问题,因为它太容易被满足了,JetBrains 在发布 Kotlin 的时候,已经为我们考虑到了可能会将 Kotlin 用作脚本的场景,它就是 kotlin-script。在 JVM 环境下,直接在 Gradle 内引入它即可:

implementation("org.jetbrains.kotlin:kotlin-scripting-jsr223:$kotlin_version")

从这个命名我们可以看到,这个 Kotlin script 甚至还符合 jsr223 标准,符合这个标准即是说它是以 JVM 下的标准脚本的形式进行工作,用法进一步被简化,以下代码就可以轻松运行起一份脚本代码:

val code = """
    println(pair.first)
    println(pair.second)
    data class Ret(val a: Int, val b: String)
    Ret(6666, "abc")
"""
val engine = ScriptEngineManager().getEngineByExtension("kts")
engine.put("pair", Pair(2333, "xyz"))
val ret = engine.eval(code)
println(ret)

在这里我们可以很清晰的看到 Kotlin script 的用法,可以将对象等注入脚本中使用,也可以从脚本中返回对象,这段代码运行之后的打印如下:

2333
xyz
Ret(a=6666, b=abc)

在脚本里面定义的类型,可以被正确的传递到脚本外,它也是一个标准的 Kotlin 类,这使得我们对脚本的使用可以拥有非常多的不受限场景,甚至完全把脚本当成业务代码来写。通过跟踪变量可以发现,在 Kotlin 代码中得到的 Ret 对象,它的真实类型是 ScriptingHost<context id>_Line_0$Ret,这个类型显然不能被 Kotlin 代码直接识别和调用,我们无法在取值时使用诸如 ret.a 这样的代码,直接调用是不可能的。

那么在这里就要用到反射了,为了方便起见,我们还是写一个扩展函数:

inline fun <reified T> Any.getValue(field: String): T? =
    this::class.java.getDeclaredField(field).apply { isAccessible = true }.get(this) as? T

这样就可以方便的取值了,例如:

val a = ret.getValue<Int>("a")
val b = ret.getValue<String>("b")

对于脚本来说,有些时候性能会成为瓶颈,解释型的脚本在性能上并不算好,在不少时候,我们都需要对脚本进行预编译。在上面的代码中,使用 ScriptEngineManager 获取到的脚本引擎实例,是没有编译功能的,那我们要怎么样才能实现对脚本进行编译呢,其实 JetBrains 也早就为我们想好了,在这里我们需要引入完整的 Kotlin script。

implementation("org.jetbrains.kotlin:kotlin-scripting-jsr223:$kotlin_version")
implementation("org.jetbrains.kotlin:kotlin-scripting-jvm-host:$kotlin_version")
implementation("org.jetbrains.kotlin:kotlin-compiler-embeddable:$kotlin_version")
val code = """
    println(pair.first)
    println(pair.second)
    data class Ret(val a: Int, val b: String)
    Ret(6666, "abc")
"""
    val engine = ScriptEngineManager().getEngineByExtension("kts") as KotlinJsr223ScriptEngineImpl
    engine.put("pair", Pair(2333, "xyz"))
    val cs = engine.compile(code)
    val ret = cs.eval()
    val a = ret.getValue<Int>("a")
    val b = ret.getValue<String>("b")

把上面的代码中,直接执行代码的部分,修改为先编译再执行就可以了。

需要注意的是,由于 Kotlin script 是真实编译的,因此在编译时就要保证代码的完整性,比如说在这里的脚本中引用到的 pair 对象,必须先 put 到脚本引擎里,然后才能编译。

Kotlin script 由于其易于使用,与 Kotlin 无缝结合,且没有类型转换的问题,它应当是在 Kotlin/Ktor 场景下最优选的脚本引擎。

本篇的内容到这里就结束啦,到目前为止,我们讲的都是纯粹的 Kotlin 以及 Ktor 的技术栈,但是也有一些小伙伴是这样说的,我们之前用的是 Springboot,不可能直接把所有的代码都用 Ktor 重写呀,我只想新的接口用 Ktor 来编写,是否可以呢?答案当然是可以,敬请期待下一篇,我将讲述如何在 Springboot 中使用 Ktor。