Kotlin通过JNI调用Lua实践

860 阅读8分钟

简介

在笔者的项目中,有通过Kotlin调用Lua的需求(游戏项目),所以使用了一个开源库Luaj,这个库是Lua的Java实现。

但是这个库已经很久没有更新过了,基于Lua52实现的,和客户端的Lua版本并不匹配,不能进行断点调试,并且在使用LuaJC编译的时候,对于大文件的Lua会报错,并且启动的时候会长时间的执行编译任务,消耗CPU。在不使用LuaJC编译的时候,Lua的调用栈会显示不完整,只会显示报错的那一行,并且还有信息缺失。

基于以上问题,笔者尝试通过JNI的方式直接调用C Lua解决上述问题。

JNI

本篇文章不会详细介绍JNI的相关规范,只会说一些关键的点。

Reference

  • Java中的基本类型在Java和Native之间采用值传递
  • Java对象在Java和Native之间采用引用传递

在JNI中使用的引用分为三种:GlobalRef(全局引用),LocalRef(局部引用),WeakRef(弱全局引用)。

  • GlobalRef 一直有效,需要显式创建和释放
  • LocalRef Native方法结束后释放,并且Native方法传递的引用为LocalRef,在Native方法中创建LocalRef的数量有限制,所以如果要在Native中创建大量的LocalRef,需要在不使用的时候主动释放
  • WeakRef 可以被JVM回收,所以需要在使用时检查是否被回收

基于以上几点,我们可以知道LocalRef只在其创建的线程中有效,不能在Native方法中跨线程传递LocalRef

Native实现

在实现Native代码部分,我们选择使用Rust,因为Rust对于新手来说也不容易写出有问题的代码(根本编译不过去好吧),有包管理工具,并且跨平台编译方便。所以我们直接将Rust代码编译成lib给Kotlin调用就好了。

mlua

mlua是用于Rust的Lua编程语言的绑定,其目标是提供安全(尽可能)、高级、易于使用、实用和灵活的API。我们会使用这个库来调用Lua代码。

jni-rs

这个是JNI的Rust绑定,帮我们做了一层封装,更容易使用。

例如我们写一个简单的Native实现,然后使用Kotlin进行调用:

lib.rs

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

#[no_mangle]
pub extern "system" fn Java_JniDemo_sayHello<'a>(
    mut env: JNIEnv<'a>,
    _class: JClass<'a>,
) -> JString<'a> {
    env.new_string("Hello from Rust!").unwrap()
}

Cargo.toml

[package]
name = "jni_demo"
version = "0.1.0"
edition = "2021"

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

[dependencies]
jni = "0.21.1"

JniDemo.kt

class JniDemo {
    init {
        System.load("F:/CLionProjects/jni_demo/target/release/jni_demo.dll")
    }

    external fun sayHello(): String
}

fun main() {
    val jniDemo = JniDemo()
    println(jniDemo.sayHello())
}

异步回调

由于Native方法可以进行多线程调用,而我们的LuaVM是有状态的,我们不能在每次调用的时候去初始化其状态,然后再调用,这样会非常慢,并且LuaVM不是线程安全的。所以我们需要在JNI load的时候,就启动一组LuaVM,然后在JNI调用的时候,把数据直接传递给LuaVM,这个时候就直接返回了,在Kotlin这边我们拿到一个CompletableFuture,在Lua代码执行完毕的时候,会进行回调使这个CompletableFuture完成,通过异步回调的方式,可以使我们的接口具有一个较高的并发。异步回调的方式参考Apache OpenDAL中的Java Binding部分。

其中异步回调就会涉及如何找到CompletableFuture对象的问题,一种方式就是将CompletableFuture对象提升为GlobalRef,这样就可以跨线程使用,缺点是GlobalRef的限制为65535个,那么就意味着这个程序的并发一定不能超过这个数值,其实对于大部分项目来说也够用了,但也不是没有开销的。

另外一种方式就是在每次JNI调用时,Native层拿到一个Token,这个Token对应着一个CompletableFuture,当Natvie中的异步代码结束之后,根据这个Token从JVM拿到对应的CompletableFuture进行回调。这其实又会遇到另外一个问题,JNI调用中JNIEnv不能在任意线程中使用,必须在发起JNI调用的那个线程中使用,所以我们如何在Rust线程中的调用完成之后拿到JNIEnv进行回调?JNI提供了一个AttachCurrentThreadAsDaemon函数,可以让Native线程attach到JVM环境中,这样就可以拿到JNIEnv了。

static SENDER: OnceCell<Sender<Message>> = OnceCell::new();

static BROADCAST_SENDERS: OnceCell<Vec<Sender<BroadcastMessage>>> = OnceCell::new();

#[no_mangle]
pub unsafe extern "system" fn JNI_OnLoad(vm: JavaVM, _: *mut c_void) -> jint {
    println!("JNI_OnLoad:jni_lua");
    let (sender, broadcast_senders) = spawn_workers(vm);
    SENDER.set(sender).expect("sender already initialized");
    BROADCAST_SENDERS.set(broadcast_senders).expect("broadcast senders already initialized");
    JNI_VERSION_1_8
}

上面的代码我们声明了两个全局变量,通过channel的方式来进行线程之间的数据传递,SENDER是用来进行单次Lua调用的,会随机将调用发给某个LuaVM,BROADCAST_SENDERS则是会将调用分发给所有的LuaVM,通常是做配置初始化或者配置更新的时候用的。我们在JNI_OnLoad的时候就启动固定数量的LuaVM,然后将LuaVM的线程attach到JVM。

我们选择使用第二种方式来进行实现:

pub(crate) fn spawn_workers(vm: JavaVM) -> (Sender<Message>, Vec<Sender<BroadcastMessage>>) {
    let vm = Arc::new(vm);
    let num_cpus = num_cpus::get();
    let (tx, rx) = unbounded();
    let mut senders = Vec::with_capacity(num_cpus);
    for i in 0..num_cpus {
        let (btx, brx) = unbounded();
        senders.push(btx);
        let rx = rx.clone();
        let vm = vm.clone();
        std::thread::Builder::new()
            .name(format!("lua-worker-{}", i))
            .spawn(
                move || {
                    println!("lua-worker-{} started", i);
                    let mut env = vm.attach_current_thread_as_daemon().expect("failed to attach thread");
                    let mut lua = unsafe { Lua::unsafe_new() };
                    loop {
                        select! {
                            recv(rx) -> message => {
                                //省略
                            }
                            recv(brx) -> message => {
                                //省略
                            }
                        }
                    }
                }
            ).expect("failed to spawn worker thread");
    }
    (tx, senders)
}

在每个Lua线程中,我们接收channel中的消息进行处理就可以了。

异步回调部分需要我们在JVM那边维护一个Map<Long,CompletableFuture>,这样每次调用的时候,生成一个id与之对应的CompletableFuture,把id传到Native层,回调的时候就可以根据id找到对应的CompletableFuture了。

Kotlin代码部分:

class JniBattleService {

    companion object {
        private val id = AtomicLong(0)

        private val registry: ConcurrentHashMap<Long, CompletableFuture<String>> = ConcurrentHashMap()

        @JvmStatic
        fun nextId(): Long {
            return id.incrementAndGet()
        }

        @JvmStatic
        fun get(id: Long): CompletableFuture<String>? {
            return registry[id]
        }

        @JvmStatic
        fun take(id: Long): CompletableFuture<String>? {
            return registry.remove(id)
        }
    }

    private external fun methodCall(method: String, arg: String, id: Long)

    fun call(method: String, arg: String): CompletableFuture<String> {
        val id = nextId()
        val future = CompletableFuture<String>()
        registry[id] = future
        methodCall(method, arg, id)
        return future
    }
}

注意这里我们使用的是AtomicLongConcurrentHashMap,原因也很简单,因为可能进行多线程调用。

Rust代码部分:

#[no_mangle]
pub extern "system" fn Java_JniBattleService_methodCall(
    mut env: JNIEnv,
    _class: JClass,
    method: JString,
    arg: JString,
    id: jlong,
) {
    if let Err(err) = method_call(&mut env, method, arg, id) {
        err.throw(&mut env);
    }
}

fn method_call(env: &mut JNIEnv, method: JString, arg: JString, id: jlong) -> anyhow::Result<()> {
    let method = env.get_string(&method)?.into();
    let arg = env.get_string(&arg)?.into();
    let sender = sender();
    let send_result = sender.send(Message::MethodCall(method, arg, id));
    if send_result.is_err() {
        complete_future(env, id, Err(anyhow!("failed to send message, receiver dropped")));
    }
    Ok(())
}
pub(crate) fn complete_future(env: &mut JNIEnv, id: jlong, result: anyhow::Result<String>) {
    let future = take_future(env, id);
    let future = match future {
        Ok(future) => {
            if future.is_null() {
                eprintln!("future {} is null", id);
                return;
            }
            future
        }
        Err(error) => {
            eprintln!("failed to take future: {:?}", error);
            return;
        }
    };
    match result {
        Ok(result) => {
            let result = env.new_string(result).expect("failed to create java String");
            env.call_method(
                future,
                "complete",
                "(Ljava/lang/Object;)Z",
                &[JValue::Object(&result)],
            ).expect("failed to call CompletableFuture.complete");
            env.delete_local_ref(result).expect("failed to delete local ref");
            env.delete_local_ref(future).expect("failed to delete local ref");
        }
        Err(error) => {
            let exception = error.to_jni_exception(env).expect("failed to create JniLuaException");
            env.call_method(
                future,
                "completeExceptionally",
                "(Ljava/lang/Throwable;)Z",
                &[JValue::Object(&exception)],
            ).expect("failed to call CompletableFuture.completeExceptionally");
            env.delete_local_ref(exception).expect("failed to delete local ref");
            env.delete_local_ref(future).expect("failed to delete local ref");
        }
    };
}

fn take_future<'a>(env: &mut JNIEnv<'a>, id: jlong) -> anyhow::Result<JObject<'a>> {
    Ok(env
        .call_static_method(
            "JniBattleService",
            "take",
            "(J)Ljava/util/concurrent/CompletableFuture;",
            &[JValue::Long(id)],
        )?
        .l()?)
}

在Native的Lua线程中,我们通过JNIEnv调用JniBattleService中的take静态方法,就可以获取对应Token的CompletableFuture对象然后进行回调。

这里需要注意的是一定要及时释放异步线程中创建的Java对象,否则会造成内存泄漏。

错误处理

在JNI调用中,我们会尽可能多的将错误抛出给JVM去处理,而不是panic,需要panic的情况一般都是程序遇到严重错误,不可恢复的情况,这个时候抛出去也没有用。

在Kotlin这边我们直接定义一个异常对象,提供给Native层进行抛出:

class JniLuaException(override val message: String?) : Exception()

然后将逻辑错误转换成JniLuaException进行抛出。

pub(crate) trait ErrorExt {
    fn throw(&self, env: &mut JNIEnv);

    fn throw_inner(&self, env: &mut JNIEnv) -> anyhow::Result<()> {
        let exception = self.to_jni_exception(env)?;
        env.throw(exception)?;
        Ok(())
    }

    fn to_jni_exception<'a>(&self, env: &mut JNIEnv<'a>) -> anyhow::Result<JThrowable<'a>>;
}

impl ErrorExt for anyhow::Error {
    fn throw(&self, env: &mut JNIEnv) {
        if let Err(err) = self.throw_inner(env) {
            env.fatal_error(err.to_string());
        }
    }

    fn to_jni_exception<'a>(&self, env: &mut JNIEnv<'a>) -> anyhow::Result<JThrowable<'a>> {
        let class = env.find_class("JniLuaException")?;
        let message = env.new_string(self.to_string())?;
        let exception =
            env.new_object(class, "(Ljava/lang/String;)V", &[JValue::Object(&message)])?;
        env.delete_local_ref(message)?;
        Ok(JThrowable::from(exception))
    }
}

参数传递

关于这部分,如果你熟悉Rust和mlua的话,可以写出更定制化的参数传递方案,在本文中,我们假设这个lib是一个中间层,我们尽可能少的去改动中间层的代码,因此我们的参数传递方式为方法名以及一个Json参数,我们会直接把Json参数转换成Lua Table,然后根据方法名进行调用,这样我们几乎可以不用动这一层的代码。

fn handle_method_call(lua: &Lua, method: String, arg: String) -> anyhow::Result<String> {
    let arg: serde_json::Value = serde_json::from_str(&arg).context("failed to parse method arg")?;
    let arg = mlua::ErrorContext::context(lua.to_value(&arg), "failed to convert method arg to lua table")?;
    let method: LuaFunction = lua.globals().get(method)?;
    let result: Value = method.call(arg)?;
    let result: serde_json::Value = mlua::ErrorContext::context(lua.from_value(result), "failed to parse lua table to json")?;
    let result = serde_json::to_string(&result)?;
    Ok(result)
}

对于Lua Table序列化成Json,需要注意的是Table的组成必须是单一的,要么就是Key Value的形式,要么就是数组的形式,且不能是稀疏数组,混合Key Value和数组形式的Table或者稀疏数组的Table是不符合Json规范的,无法进行序列化。