♻️

JNIを使ってScalaをC/C++から呼び出す

2023/12/22に公開

こちらは Scala Advent Calendar 2023 の24日目の記事です。

はじめに

業務で Scala を使い始め、OptionEither、また for式 の言語仕様にすっかりとりこになってしまいました。
厳密な業務フローを記述する以外に、ちょっとしたライブラリを作るのにも書きやすい言語だと感じています。

そんな Scala で作ったライブラリ、どうにかして他の言語で再利用出来ないかとふと思い立ちました。

調べてみると、どうやら Java Native Interface (以後JNI) を使うことで可能になるみたいなのでそれを試してみようと思います。

本記事の執筆に際して、以下の記事を参考にしました。
偉大なる先駆者様に感謝申し上げます。

https://qiita.com/juntaki/items/328d307583f406abc962

この記事を読んでわかること

この記事は完全に入門レベルです。
この記事で取り上げるのは

  • Scala のライブラリをパッケージングする
  • Custom JRE を作成する
  • C/C++ から Custom JRE 経由でパッケージングした Scala ライブラリを呼び出す

の3点です。

成果物は以下のリポジトリで公開しています。

https://github.com/yamachu/scala-as-a-library

環境

  • sbt 1.9.6
  • Scala 2.13.12

ライブラリの作成

簡単のために、今回は2つの int の引数を受け取り、その和を返すライブラリを作成してみます。

以下のページを参考に、簡単なアプリケーションのベースを作成します。

https://www.scala-sbt.org/1.x/docs/ja/Hello.html

プロジェクトを作成した後は、今回はライブラリを作成することが目的なので、Hello.scala の中見を一度削除し、以下のようなコードを追加します。

package example

object Hello {
  def add(x: Int, y: Int): Int = x + y
}

また、このライブラリの実装が正しいかの確認も兼ねて、HelloSpec.scala の中見も以下のように書き換えてみましょう。

package example

class HelloSpec extends munit.FunSuite {
  test("1 + 2 = 3") {
    assertEquals(Hello.add(1, 2), 3)
  }
}

これで足し算を行うライブラリが出来ました。

ライブラリのパッケージング / Custom JRE の作成

それではライブラリをパッケージングしてみましょう。
パッケージングには SBT Native Packager を使用します。

まず初めに、project ディレクトリ以下に plugins.sbt ファイルを作成し、以下の内容を追加します。

addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.9.16")

その後プロジェクト root の build.sbt を編集して、このライブラリを使ってパッケージング出来るようにしていきます。

build.sbt のプロジェクトの settings に対して以下のコードを追加します。
ここで使う JLinkPlugin は、JDK が提供している jlink のラッパーで、Custom JRE を作成するために必要です。

  .enablePlugins(JlinkPlugin)

プラグインの設定も終わったので、実際にパッケージングを行いましょう。

ビルドとパッケージングは以下のコマンドで行います。

$ sbt "compile; stage"

この stage を実行すると target/universal/stage 以下に Custom JRE とパッケージングされたライブラリと Scala ライブラリが展開されます。

C/C++ からライブラリを呼び出す

それでは目的の C/C++ から呼び出すことをやってみましょう。

呼び出すコードはほぼほぼテンプレート通りでほとんどいじっていないものとなっています。
下記コードでは

  • JVM のロード
  • ライブラリからクラスを検索
  • クラスに実装してある静的メソッドの呼び出し

を行っています。

#include <jni.h>

int main(int argc, char **argv)
{
    JNIEnv *env;
    JavaVM *jvm;

    JavaVMInitArgs vm_args;
    vm_args.version = JNI_VERSION_1_8;

    // コマンドラインからオプションを受け取れるようにしている
    JavaVMOption options[argc];
    for (int i = 0; i < argc; i++)
    {
        options[i].optionString = argv[i + 1];
    }
    vm_args.options = options;
    vm_args.nOptions = argc - 1;
    vm_args.ignoreUnrecognized = false;

    JNI_GetDefaultJavaVMInitArgs(&vm_args);

    auto create_jvm_res = JNI_CreateJavaVM(&jvm, (void **)&env, &vm_args);

    if (create_jvm_res != JNI_OK)
    {
        printf("Failed to create JVM\n");
        return 1;
    }

    // 今回作成したライブラリは example package の Hello クラスに実装がある
    auto cls = env->FindClass("example/Hello");
    if (cls == NULL)
    {
        if (env->ExceptionOccurred())
        {
            env->ExceptionDescribe();
        }
        else
        {
            printf("cls is null but no exception was thrown.\n");
        }

        jvm->DestroyJavaVM();
        return 1;
    }

    // add関数を呼び出している。(int, int) -> int なのでシグニチャは (II)I
    auto mid = env->GetStaticMethodID(cls, "add", "(II)I");
    if (mid == NULL)
    {
        if (env->ExceptionOccurred())
        {
            env->ExceptionDescribe();
        }
        else
        {
            printf("mid is null but no exception was thrown.\n");
        }

        jvm->DestroyJavaVM();
        return 1;
    }
    auto result = env->CallStaticIntMethod(cls, mid, 1, 2);

    // addの結果の表示
    printf("Result: %d\n", result);

    jvm->DestroyJavaVM();

    return 0;
}

以上のコードをビルドしてみましょう。

ディレクトリの構成によってパスは異なりますが、以下のようなコマンドでまずはビルドします。
今回は以下のようなディレクトリの構成で行いました。

.
├── cpp
└── scala
# Custom JRE の header を include、mac で行ったため include/darwin になっている
# 他の OS で行った場合は変える必要があることに注意
$ gcc main.cpp -I../scala/target/universal/stage/jre/include/ \
  -I../scala/target/universal/stage/jre/include/darwin/ \
# Custom JRE のランタイムをリンク
  -L../scala/target/universal/stage/jre/lib \
  -L../scala/target/universal/stage/jre/lib/server \
  -ljvm \
# rpath の指定
  -Wl,-rpath,../scala/target/universal/stage/jre/lib/server

それでは、以上のコマンドで生成されたバイナリを実行してみましょう。
アプリケーションは JavaVM のオプションを受け取れるように作ってあります。
適切なオプションを与えてみます。

ここで重要なのは -Djava.class.path オプションです。
どのライブラリを読み込むかをこのオプションで指定する必要があるため、正しく設定しましょう。

OS によって複数指定のための区切りが異なるので、注意しましょう。

macOS や Linux は : で、Windows では ; を使用します。

$ ./a.out "-Djava.class.path=../scala/target/universal/stage/lib/com.example.scala-seed-project-0.1.0-SNAPSHOT.jar:../scala/target/universal/stage/lib/org.scala-lang.scala-library-2.13.12.jar"

実行した結果

Result: 3

は得られたでしょうか?
何かおかしいなと思った場合は、更にオプションで "-verbose:jni" も追加して原因の調査をしてみましょう。

終わりに

本記事では Scala のライブラリを C/C++ から呼び出す初歩の初歩を紹介しました。

実運用をする上では文字列が出てきたり、コールバックが出てきたりと複雑なものがあるでしょう。
まだ私も走り始めたばかりなので、そういった複雑なことは出来ていません。
今後チャレンジする時、以下のページを参考に進めていこうと思います。

https://docs.oracle.com/javase/jp/8/docs/technotes/guides/jni/spec/jniTOC.html

https://qiita.com/juntaki/items/328d307583f406abc962

Discussion