简介
在笔者的项目中,有通过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一直有效,需要显式创建和释放LocalRefNative方法结束后释放,并且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
}
}
注意这里我们使用的是AtomicLong和ConcurrentHashMap,原因也很简单,因为可能进行多线程调用。
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规范的,无法进行序列化。