👏

JavaからRustをJNA(Java Native Access)で実行する

2021/04/20に公開

はじめに

動機

システムプログラミング言語(?)であるRustの話を聞いていると、FFI(Foreign function interface)の話が少なからず出てきます。
ということで普段書いているJavaから呼び出します。

対象読者

JavaとRustが多少読め、MavenというJava用のプロジェクト管理用ツールが存在していることを知っている方。
難しいことはしておらず、プロジェクトの雛形を作成したレベルの話です。

環境

開発環境はVSCodeを使用します。
RustとJavaの実行環境はDockerで構築します。
DockerFiile
ベースはMicrosoft提供のJavaの開発環境のサンプル(Debean 10)を、フォークして自分用に弄ったものを使用しています。変更箇所としてはJavaのversionを14→11に変更した程度です。
弄ったもの

そこにRustのコンパイラをインストールします。以下を追記。

DockerFiile
ENV RUSTUP_HOME=/usr/local/rustup \
    CARGO_HOME=/usr/local/cargo \
    PATH=/usr/local/cargo/bin:$PATH

RUN set -eux; \
    \
    url="https://static.rust-lang.org/rustup/dist/x86_64-unknown-linux-gnu/rustup-init"; \
    wget "$url"; \
    chmod +x rustup-init; \
    ./rustup-init -y --no-modify-path --default-toolchain nightly; \
    rm rustup-init; \
    chmod -R a+w $RUSTUP_HOME $CARGO_HOME; \
    rustup --version; \
    cargo --version; \
    rustc --version;

RUN apt-get update &&  apt-get install -y lldb python3-minimal libpython3.7 python3-dev gcc \
    && apt-get autoremove -y && apt-get clean -y && rm -rf /var/lib/apt/lists/* /tmp/library-scripts

内容としては

  • 環境変数の追加
  • 必要なrustのコンポーネントをインストール
  • Rustで必要になるデバッガ、python、gccのインストール
    を行っています

インストールするRustをここでnightlyとしている理由は後述します。

Rust

今回Rust側で作成したファイルは以下です

tree
workspace
│  Cargo.toml
│
├─sample-jna
│  │  Cargo.toml
│  │
│  └─src
│          lib.rs
│
└─scripts
       cargo-build.sh

workspaceのトップレベルでcargoのコマンドが使いたかったため、このような構成となりました。

以下解説

Cargo.toml

Cargo.toml
[workspace]
members = ["sample-jna"]

[profile.release]
lto = true

上2行でworkspace内のsample-jnaディレクトリをプロジェクトとして認識しています。
lto = trueはbuild時のファイルサイズ削減用のオプションです。

sample-jna/Cargo.toml

Cargo.toml
[package]
name = "sample-jna"
version = "0.1.0"
authors = ["uesugi6111 <59960488+aburaya6111@users.noreply.github.com>"]
edition = "2018"

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

[package]cargo new で作成されるもので問題ありません。
[lib]crate-typeがコンパイル後のタイプとなります。別言語から呼び出す想定のダイナミックライブラリは、cdylibを指定するようリファレンスに書かれているので、それに従います。

lib.rs

ここがライブラリ本体となります。今回は以前書いて残していたエラトステネスの篩に似たアルゴリズムで引数までの素数を列挙し、その個数を返すだけのプログラムを用意しました。

lib.rs
#[no_mangle]
pub extern fn sieve_liner(n: i32) -> i32{
    let mut primes = vec![];
    let mut d = vec![0i32; n as usize + 1];
    
    for i in 2..n + 1 {
        if d[i as usize] == 0 {
            primes.push(i);
            d[i as usize] = i;
        }
        for p in &primes {
            if p * i > n {
                break;
            } 
            d[(*p * i) as usize] = *p;
        }
    }
    
    primes.len() as i32
}

通常のコンパイルでは関数名は他の名称に変換されてしまい、ほかプログラムなどから呼び出す際に、名前がわからなくなってしまいます。それを防ぐために**#[no_mangle]**(直訳:切り刻み無し)を関数に付与します。

cargo-build.sh

cargo-build.sh
#!/bin/bash
cargo build --release -Z unstable-options --out-dir ./src/main/resources

ライブラリのbuildスクリプトになります。
--release
releaseオプションでのbuildを指定します。
-Z unstable-options --out-dir ./src/main/resources
build後に出力するディレクトリを指定するオプションとなっています。しかしこのオプションが使えるのはnightlyのみとなっています。
そのためDockerで構築する環境へのインストールは、nightly指定にしています。

ディレクトリの指定先はJava側でコンパイルされたときにjarファイル内に配置される場所に設定しました。

Java

Java側で作成したファイルは以下になります。

bash
workspace
│  pom.xml
└─src
   └─main
      ├─java
      │  └─com
      │      └─mycompany
      │          └─app
      │                  App.java
      │
      └─resources

やけにディレクトリが深いですが、特に意味はありません。

pom.xml

以下を**<dependencies>**へ追記します

pom.xml
    <dependency>
        <groupId>net.java.dev.jna</groupId>
        <artifactId>jna</artifactId>
        <version>5.6.0</version>
    </dependency>

App.java

App.java

package com.mycompany.app;

import java.util.ArrayList;
import java.util.List;

import com.sun.jna.Library;
import com.sun.jna.Native;

public class App {
    private static final int N = 100000000;

    public interface SampleJna extends Library {
        SampleJna INSTANCE = Native.load("/libsample_jna.so", SampleJna.class);

        int sieve_liner(int value);
    };

    public static void main(String[] args) {
        System.out.println("N = " + N);
        System.out.println("FFI  :" + executeFFI(N) + "ms");
        System.out.println("Java :" + executeJava(N) + "ms");

    }

    public static long executeFFI(int n) {
        long startTime = System.currentTimeMillis();
        SampleJna.INSTANCE.sieve_liner(n);
        return System.currentTimeMillis() - startTime;

    }

    public static long executeJava(int n) {
        long startTime = System.currentTimeMillis();
        sieveLiner(n);
        return System.currentTimeMillis() - startTime;

    }

    public static int sieveLiner(int n) {
        List<Integer> primes = new ArrayList<>();

        int d[] = new int[n + 1];
        for (int i = 2; i < n + 1; ++i) {

            if (d[i] == 0) {
                primes.add(i);
                d[i] = i;
            }
            for (int p : primes) {
                if (p * i > n) {
                    break;
                }
                d[p * i] = p;
            }
        }

        return primes.size();
    }

}

Rust側に実装したロジックと同様のものを実装し、実行時間を比較します。
ライブラリの呼び出しは
SampleJna INSTANCE = Native.load("/libsample_jna.so", SampleJna.class);
で行っています。
Native.load(ライブラリのPath,ライブラリを定義したのinterface)という形で記述するようです。
今回ライブラリはmain/resources直下に配置する予定なので絶対パス(?)で表記しています。

Maven

ここまでで本来動作確認はできるのですが、jarにすることを考えた際の設定もしてみました。
jarを作成するまでの流れ

  • Rust をコンパイルしてJava側のresourcesディレクトリに配置
  • Java側のコンパイル

これをMavenの機能を利用し、ワンアクションで行います。

maven-assembly-plugin

jarに依存ライブラリを含める

maven-assembly-plugin
      <plugin>
        <artifactId>maven-assembly-plugin</artifactId>
        <version>3.3.0</version>
        <configuration>
          <archive>
            <manifest>
              <addClasspath>true</addClasspath>
              <classpathPrefix>/</classpathPrefix>
              <mainClass>com.mycompany.app.App</mainClass>
            </manifest>
          </archive>
          <descriptorRefs>
            <descriptorRef>jar-with-dependencies</descriptorRef>
          </descriptorRefs>
        </configuration>
        <executions>
          <execution>
            <id>make-assembly</id>
            <phase>package</phase>
            <goals>
              <goal>single</goal>
            </goals>
          </execution>
        </executions>
      </plugin>

exec-maven-plugin

maven の処理中でシェルスクリプトを実行するために必要となります。

exec-maven-plugin
      <plugin>
        <groupId>org.codehaus.mojo</groupId>
        <artifactId>exec-maven-plugin</artifactId>
        <version>1.3.2</version>
        <executions>
          <execution>
            <id>dependencies</id>
            <phase>generate-resources</phase>
            <goals>
              <goal>exec</goal>
            </goals>
            <configuration>
              <workingDirectory>${project.basedir}</workingDirectory>
              <executable>${project.basedir}/scripts/cargo-build.sh </executable>
            </configuration>
          </execution>
        </executions>
      </plugin>

少し解説
phase
シェルスクリプトを実行するタイミングを設定します。Mavenではライフサイクルという概念が存在するので、実行したいタイミングに合ったものを指定します。リファレンス
executable
ここで実行したい対象を指定します。

pom.xml

ここまで適応し終えたファイル

pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.mycompany.app</groupId>
  <artifactId>my-app</artifactId>
  <packaging>jar</packaging>
  <version>1.0-SNAPSHOT</version>
  <name>my-app</name>
  <url>http://maven.apache.org</url>
  <dependencies>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter-api</artifactId>
      <version>5.7.0</version>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>net.java.dev.jna</groupId>
      <artifactId>jna</artifactId>
      <version>5.6.0</version>
    </dependency>
  </dependencies>
  <properties>
    <jdk.version>11</jdk.version>
    <maven.compiler.source>11</maven.compiler.source>
    <maven.compiler.target>11</maven.compiler.target>
  </properties>
  <build>
    <plugins>
      <plugin>
        <artifactId>maven-assembly-plugin</artifactId>
        <version>3.3.0</version>
        <configuration>
          <archive>
            <manifest>
              <addClasspath>true</addClasspath>
              <classpathPrefix>/</classpathPrefix>
              <mainClass>com.mycompany.app.App</mainClass>
            </manifest>
          </archive>
          <descriptorRefs>
            <descriptorRef>jar-with-dependencies</descriptorRef>
          </descriptorRefs>
        </configuration>
        <executions>
          <execution>
            <id>make-assembly</id>
            <phase>package</phase>
            <goals>
              <goal>single</goal>
            </goals>
          </execution>
        </executions>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>3.0.0-M3</version>
      </plugin>
      <plugin>
        <groupId>org.codehaus.mojo</groupId>
        <artifactId>exec-maven-plugin</artifactId>
        <version>1.3.2</version>
        <executions>
          <execution>
            <id>dependencies</id>
            <phase>generate-resources</phase>
            <goals>
              <goal>exec</goal>
            </goals>
            <configuration>
              <workingDirectory>${project.basedir}</workingDirectory>
              <executable>${project.basedir}/scripts/cargo-build.sh </executable>
            </configuration>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build>
</project>

実行

workspaceのルートで以下を実行

mvn package

すると

[INFO] --- maven-assembly-plugin:3.3.0:single (make-assembly) @ my-app ---
[INFO] Building jar: /workspace/target/my-app-1.0-SNAPSHOT-jar-with-dependencies.jar
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------

のようなログが出力されてコンパイルが完了します。

あとは表示されたパスに出力されているjarを実行してください。

java -jar ./target/my-app-1.0-SNAPSHOT-jar-with-dependencies.jar

出力

N = 100000000
FFI  :1668ms
Java :3663ms

10^8までの素数の数のカウントでかかった、JavaとFFI(Rust)の時間(ms)が出力されました。
Nを小さくするとJavaの方が早くなるため、大きなオーバーヘッドがあるのでしょうかわかりません。

さいご

とりあえずは動いたということで一旦完了とします。
使ったソースになります
https://github.com/uesugi6111/java-rust

普段触らない部分の話が多くまだわからないことが多いですがぼちぼち調べていきます。

  • JNA以外のJavaからの呼び出し方法
  • Rustの関数での単純な数値以外の返し方
GitHubで編集を提案

Discussion