Практическое руководство по интеграции JNI + полный пример

Введение
Сегодня и Rust, и Java широко используются, но каждый из них по-своему хорош в своей области. В реалистичных сценариях часто бывает полезно комбинировать Rust и Java, чтобы одновременно повысить эффективность и системного кода, и кода приложений:
В приложении на Java может понадобиться обойтись без помощи сборщика мусора (GC) и вручную управлять памятью в тех областях программы, где всё зависит от высокой производительности.
Можно попробовать портировать на Rust алгоритм, требующий высокой производительности. Также это поможет, если нужно скрыть реализацию.
С другой стороны, в приложении, написанном на Rust, может потребоваться просто предоставить определённую возможность из Rust экосистеме Java, просто упаковав эту фичу в виде JAR.
В этом посте будет подробно рассказано, как организовать и интегрировать Rust и Java в рамках одного и того же проекта. Здесь мы делаем акцент на практике, разберём конкретные примеры кода и приведём пошаговые объяснения. Дочитав пост, вы узнаете, как написать межъязыковое приложение, в котором бесшовно взаимодействуют Rust и Java.
Контекст: понятие о JNI и об управлении памятью в Java
Нативный интерфейс Java (JNI) служит мостом, соединяющим Java и нативный код, написанный на C/C++ или Rust. Притом, что в синтаксическом отношении JNI относительно прост, известно, насколько он нетривиален на практике. Всё дело в заложенных в нём неявных правилах управления памятью и потоками.
Сегменты памяти в среде выполнения Java
Куча Java: Здесь располагаются объекты Java. Кучей автоматически управляет сборщик мусора.
Нативная память: это память, выделяемая нативным кодом (например, на Rust). Сборщик мусора ею непосредственно не управляет, поэтому при работе с ней требуется специально не допускать возникновения утечек.
Прочие: различные сегменты, например, кэши для кода и метаданные для скомпилированных классов. Понимать эти границы принципиально важно, чтобы писать высокопроизводительный межъязыковой код, безопасно работающий с памятью.
Практическая интеграция: проект rust-java-demo
Разберём реальный пример. В нашем открытом репозитории rust-java-demo продемонстрировано, как бесшовно интегрировать код Rust в приложения, написанные на Java.
Упаковываем платформо-специфичные библиотеки Rust в отдельный архив JAR
Байт-код Java не зависит от конкретной платформы, а двоичные файлы Rust — зависят. Если внедрить динамическую библиотеку Rust в архив JAR, возникает зависимость от платформы. Притом, что можно собрать отдельный файл JAR для каждой архитектуры, это осложнит распространение и развёртывание программы.
Есть лучший выход: упаковать платформо-специфичные библиотеки Rust в разные каталоги в пределах одного архива JAR, а затем динамически загружать нужную библиотеку во время выполнения.
Распаковав мультиплатформенный архив JAR (jar xf rust-java-demo-2c59460-multi-platform.jar), найдёте в нём такую структуру каталогов:

При помощи простой утилиты мы, ориентируясь на платформу хоста, загружаем нужную библиотеку:
static { JarJniLoader.loadLib( RustJavaDemo.class, "/io/greptime/demo/rust/libs", "demo" ); }
При таком подходе можно гибко работать с платформами, не мешая разработчику и не жертвуя удобством эксплуатации.
Унификация логов в пределах Rust и Java
Отладка межъязыковых проектов может быстро превратиться в кошмар, если не позаботиться об унификации логирования. Решая эту проблему, мы как через воронку пропустили все логи — на Rust и на Java — через один и тот же бэкенд SLF4J.
На стороне Java определим простую обёртку Logger:
public class Logger { private final org.slf4j.Logger inner; public Logger(org.slf4j.Logger inner) { this.inner = inner; } public void error(String msg) { inner.error(msg); } public void info(String msg) { inner.info(msg); } public void debug(String msg) { inner.debug(msg); } // ... }
После этого Rust будет вызывать данный логгер через интерфейс JNI. Вот упрощённая реализация на Rust:
impl log::Log for Logger { fn log(&self, record: &log::Record) { let env = ...; // получаем среду JNI let java_logger = find_java_side_logger(); let logger_method = java_logger.methods.find_method(record.level()); unsafe { env.call_method_unchecked( java_logger, logger_method, ReturnType::Primitive(Primitive::Void), &[JValue::from(format_msg(record)).as_jni()] ); } } }
Затем регистрируем его как глобальный логгер:
log::set_logger(&LOGGER).expect("Failed to set global logger");
Теперь логи из обоих языков выводятся в рамках одного и того же потока. Это упрощает диагностику и мониторинг.
Вызов асинхронных функций Rust из Java
Одна из выдающихся черт Rust — его мощная асинхронная среда выполнения. К сожалению, методы JNI нельзя объявлять async, поэтому вызывать асинхронный код Rust из Java не так просто:
#[no_mangle] pub extern "system" fn Java_io_greptime_demo_RustJavaDemo_hello(...) { // ❌ Это не будет компилироваться foo().await; } async fn foo() { ... }
Но block_on() блокирует актуальный поток, даже, если это поток Java. Вместо этого воспользуемся более идиоматичным подходом: скомбинируем асинхронное порождение задач с CompletableFuture на стороне Java, обеспечив таким образом неблокирующую интеграцию.
На стороне Java:
public class AsyncRegistry { private static final AtomicLong FUTURE_ID = new AtomicLong(); private static final Map<Long, CompletableFuture<?>> FUTURE_REGISTRY = new ConcurrentHashMap<>(); } public CompletableFuture<Integer> add_one(int x) { long futureId = native_add_one(x); // Call Rust return AsyncRegistry.take(futureId); // Get CompletableFuture }
Этот паттерн, используемый в Apache OpenDAL , позволяет Java-разработчикам решать, когда блокировать код и блокировать ли вообще. Благодаря этому интеграция получается более гибкой.
Отображение ошибок Rust на исключения Java
Чтобы унифицировать обработку исключений в обоих языках, преобразуем Result::Err Rust в RuntimeException Java :
fn throw_runtime_exception(env: &mut JNIEnv, msg: String) { let msg = if let Some(ex) = env.exception_occurred() { env.exception_clear(); let exception_info = ...; // Извлекаем класс исключения + сообщение format!("{}. Java exception occurred: {}", msg, exception_info) } else { msg }; env.throw_new("java/lang/RuntimeException", &msg); }
Так мы гарантируем, что код Java сможет единообразно обрабатывать все исключения, независимо от того, откуда они поступают — из Rust или из Java.
Заключение
В этой статье мы исследовали ключевые аспекты взаимодействия между Rust и Java:
Упаковку платформо-специфичных нативных библиотек в единый файл JAR.
Унификацию логов между Rust и Java.
Наладку взаимодействия между асинхронными функциями Rust и CompletableFuture из Java.
Отображение ошибок Rust на исключения Java.
Более подробные объяснения и полнофункциональный пример выложены в нашем открытом репозитории.
