Lambdaカクテル

京都在住Webエンジニアの日記です

Invite link for Scalaわいわいランド

ScalaでJARファイルにネイティブライブラリをバインディングする

ScalaでJARファイルにネイティブライブラリをバインディグして使いたいことがある時、いくつかの点を考える必要がある。

  • どういう仕組みを使うのか?
  • どうやって呼び出すのか?
  • どうやって同梱するのか?
  • どうやってビルドするのか?
  • どうやって展開するのか?

この記事では、以上の問いに答える。初歩的な内容しか含まれていないが、インターネット上にはろくすっぽ知見が転がっていないので、何度も転びながら得た知見である。

今回はサンプルとしてncursesをC APIを通じて呼び出す。複雑な例ではVOICEVOX CoreのAPIを題材とする。

どういう仕組みを使うのか?

Scala/Java、というかJVMにはJNIという仕組みがあり、C言語用のライブラリを呼び出せるようになっている。

ja.wikipedia.org

しかしJNIは非常に細かい調整や定義が必要で、使いにくい。それだけ柔軟な利用ができるということなのだが、たいていの場合はそこまでの粒度での調整は必要ない。

そこで、今回はJNAと呼ばれる仕組みをおすすめする。

github.com

JNAはJava Native Accessの略であり、現在は外部ライブラリとして提供されている。現在の最新バージョンは5.12.1だ。

libraryDependencies ++= Seq(
  "net.java.dev.jna" % "jna" % "5.12.1",
  "net.java.dev.jna" % "jna-platform" % "5.12.1",
)

JNAによるライブラリの定義

JNAでは、バインディグする対象となるCライブラリとのインターフェイスとなるinterfaceをまず定義する。Scalaの場合はtraitを使えばよい。

インターフェイスとなるLibraryは、com.sun.jna.Libraryを継承させることで定義する。

import com.sun.jna.Library

trait Ncurses extends Library {
  
}

関数定義

関数は、通常のメソッド定義と同じように行う。今回はNCursesのチュートリアルを動かす最小限の関数を定義する。

trait Ncurses extends Library {
  def initscr(): Unit
  def printw(s: String): Int
  def refresh(): Int
  def getch(): Int
  def endwin(): Int
}

関数名はas-isで書く必要がある。何の変換も行われないので、C側でスネークケースで書かれている場合にScalaでキャメルケースで書いても動かない。

関数定義は/usr/include/ncurses.hなどで確認できる。ヘッダファイルに忠実に書くのが大事だが、Unitにして返り値を握り潰してもバチは当たらないようだ。

ライブラリのロード

ScalaでJNAを使う場合、以下のようにコンパニオンオブジェクトを使ってライブラリを読み込ませると収まりが良い。

object Ncurses {
  private val libName = "ncurses" // lib**.soの**の部分を正確に書く
  private lazy val INSTANCE: Ncurses = {
    val ncurses = Native.load(
      libName,
      classOf[Ncurses]
    )
    ncurses
  }
  def apply(): Ncurses = INSTANCE
}

apply()を定義しているので、Ncurses()と書くだけでライブラリが手に入る。

どうやって呼び出すのか?

JNAは引数の型などをほぼ自動で透過的に交換してくれる。

mkguytone.github.io

github.com

特に以下の点について留意しておくと良い。

  • Stringはほぼ透過的にconst char*に変換されるのでそのまま使って良い
  • byte*int*といったポインタ型は、JNAからはArray[Byte] / Array[Int]で表現することもできるし、com.sun.jna.ptr.ByteByReference / com.sun.jna.ptr.IntByReferenceといったポインタ型を使っても良い

NCursesのチュートリアル通りに、Hello Worldを実装してみよう:

object Hello extends App {
  val nc = Ncurses()
  nc.initscr() /* Start curses mode          */
  nc.printw("Hello World !!!") /* Print Hello World       */
  nc.refresh() /* Print it on to the real screen */
  nc.getch() /* Wait for user input */
  nc.endwin()
}

実行すると、コードが正しく実装されていれば、画面がクリアされてHello World !!!と表示され、待機状態になるはずだ。

何かキーを押すとプログラムは終了する。

おめでとう!ScalaからCライブラリを呼び出すことに成功だ。

これは簡単な例だったが、より複雑なライブラリを呼び出す必要があるかもしれないので、いくつかのTipsを残しておく。これはVOICEVOX Coreを呼び出したときに学んだ知見だ。

blog.3qe.us

ポインタのやりとり

JNAでは、Cポインタに2つのアプローチで接近できるようになっている。Arrayを経由する方法と、com.sun.jna.ptr以下のポインタ型を使う方法である。

Arrayを経由する方法

JNAは、関数にArray[A]を渡した場合、これをそのままAに対応する型のポインタに変換する。例えば、Array[Int]は自動的にint*に変換される。言い換えると、int*を渡す必要がある場合はArray[Int]をそのまま渡してよい。このアプローチは、Arrayの中身がスカラ値である場合はおおむね成功する。

com.sun.jna.ptr以下のポインタ型を使う方法

Arrayを使うのがそぐわない局面もある。実際問題、「それが配列ではない」ケースなどがそれである。例えば、関数がある計算を行い、バッファに値を詰めてくれるとき、いっしょに長さの情報を返してくるといったケースだ。

void super_computing(byte** buf, int* len)

このとき、lenの型はint*だが、ユースケースとしては「値を埋めてもらうためのポインタ」である。このような場合にArray[Int]を使うのは(長さ1の配列を使えば普通に動くのだが)不適当である。

com.sun.jna.ptr.IntByRefecenceを使うことで、うまくこの種のポインタを表現できる:

val intStar = new IntByReference() // int*、Array[Int]と等価

int*を渡す必要がある局面では、IntByReferenceを渡すことができる:

super_computing(buf, intStar)

IntByReferenceは、getValue()によってその殻を割り、ポインタの先のintを得られる:

intStar.getValue() // => 42

com.sun.jna.ptrには、他にも以下のようなポインタ型が定義されている:

  • ByReference
  • ByteByReference
  • DoubleByReference
  • FloatByReference
  • IntByReference
  • LongByReference
  • NativeLongByReference
  • PointerByReference (後述)
  • ShortByReference

複雑なポインタ

さらに複雑なポインタが必要な場合がある。

計算系のライブラリでは、直接計算結果を返すのではなく、そのポインタを返してもらうことがある。例えば計算結果がバイト配列の場合、関数がbyte**を返すことになる。

  • byte バイト
  • byte* バイト配列
  • byte** バイト配列へのポインタ

他も、関数の返り値がエラーコードに占用されているので、引数を通じて計算結果をやりとりする必要がある場合もある。この場合、関数にbyte**を渡し、関数内でメモリを確保してもらい、そこに計算結果を入れてもらい、渡したポインタ引数にbyte*への参照を埋めてもらう、といったパターンが頻出する。

例えば、VOICEVOX Coreには以下のような関数がある:

VoicevoxResultCode voicevox_synthesis
(
const char *  audio_query_json,
uint32_t   speaker_id,
struct VoicevoxSynthesisOptions    options,
uintptr_t *    output_wav_length,
uint8_t **     output_wav 
)   

ポインタのポインタを表現する型はJNAでは提供されていないが、汎用的なPointer型を経由することで、ポインタを経由した配列のやりとりが可能になる。

上掲の関数は、JNAでは以下のように表現する:

def voicevox_tts(
    text: String,
    speaker_id: Int,
    options: /* 後述 */,
    output_wav_length: IntByReference,
    output_wav: PointerByReference
): Int

「ポインタのポインタ」が必要な場合は、com.sun.jna.ptr.PointerByReferenceを使うことができる。ただし、ポインタのポインタに何が入っているかは型からは見えなくなるため、利用者が型を覚えておく必要がある。

PointerByReferenceも、他の**ByReference型と同様、getValue()によって殻を割ることができる。この場合得られるのは、汎用的なPointer型である。

val pbr = new PointerByReference()
voicevox_tts(..., pbr)
val resultPtr = pbr.getValue() // => Pointer

Pointerのままでは実際のところ何を表現しているか不明なので、さらに変換メソッドを呼ぶことで望みの型に変形する。

例えばbyte*に変換したければ、getByteArrayを呼び出す。ただし配列の長さの情報はポインタからは喪失しているので、別途用意しておく必要がある。

val length = ...
val resultArray = resultPtr.getByteArray(0, length)
val fs = new FileOutputStream("./result.wav")
fs.write(resultArray)
fs.close()

まとめ

ポインタの扱いをまとめると、以下のようになる:

  • 配列はそのままArrayを経由すればよい
  • com.sun.jna.ptr.**ByReferenceを使うことで、プリミティブな型のポインタ型を作れる
  • com.sun.jna.ptr.PointerByReferenceを使うことで、ポインタのポインタを表現できる
  • com.sun.jna.ptr.PointerByReferencegetValue()し、さらに変換メソッドを呼ぶことで配列などが得られる
  • 苦しめ

構造体

APIによっては、構造体をやりとりする必要がある。結論から言うとScala側で構造体を定義するのはうまくいかないのでJavaファイルを書いて頑張って定義する必要がある。

構造体の定義には、Libraryの中で、com.sun.jna.Structureを継承したstatic classを定義すればよい。

VOICEVOX Coreを呼び出している実際の例を示す:

public interface CoreJ extends Library {
    public static class VoicevoxInitializeOptions extends Structure {
        // 値渡しできるようにするにはここを書く
        public static class ByValue extends VoicevoxInitializeOptions implements Structure.ByValue { }
        // 参照渡しできるようにするにはここを書く
        public static class ByReference extends VoicevoxInitializeOptions implements Structure.ByReference { }

        // フィールドを記述する。ここでは順番はどうでもよい
        public short acceleration_mode; // enum
        public short cpu_num_threads; // uint16
        public boolean load_all_models;
        public String open_jtalk_dict_dir;

        // JVMの仕様上、フィールドの記述順序は内部的に不定なので、構造体上の順序を明示的に定義しなければならない。
        @Override
        protected List<String> getFieldOrder() {
            return Arrays.asList("acceleration_mode", "cpu_num_threads", "load_all_models", "open_jtalk_dict_dir");
        }
    }
}

構造体を利用するには、普通にnewすればよい:

val structure = new CoreJ.VoicevoxInitializeOptions.ByValue()
structure.loaod_all_models = true // 普通にsetterやgetterが動く

構造体を関数の引数にする場合は、ByValueByReferenceeのどちらかを指定する:

def voicevox_initialize(
    options: CoreJ.VoicevoxInitializeOptions.ByValue
): Int

/* ... */

voicevox_initialize(structure)

構造体はかなりキツかったっス。全部Scalaで行けるかと思ったけど、static classに該当するscalaの構文が無さげだった。

関数名の衝突

運悪く、C側の関数名がScala/Javaの予約語とぶつかってしまうことがある。finalizeとかがよくぶつかる。

こういう場合はNative.loadするときに追加の引数を指定することで関数名のマッピングに介入できる。

import java.lang.reflect.Method
import scala.collection.JavaConverters._
import com.sun.jna.Native
import com.sun.jna.NativeLibrary
import com.sun.jna.Library.OPTION_FUNCTION_MAPPER

object Core {
  private val functionMap = new com.sun.jna.FunctionMapper {
    def getFunctionName(library: NativeLibrary, method: Method): String =
      method.getName() match {
        // ここで変換する。Scala側のメソッド名が渡ってくるので、C側の名前を返してやる。
        case otherwise => otherwise
      }
  }

  private lazy val INSTANCE: Core = {
    Native.load(
      "voicevox_core",
      classOf[Core],
      Map(OPTION_FUNCTION_MAPPER -> functionMap).asJava // ここでMapを渡す
    )
  }
  def apply(): Core = INSTANCE
}

二重ロードの回避

JNAでは二重ロードを回避する仕組みがないようで、二回同じライブラリをロードしようとするとクラッシュしてしまう・・・。

ライブラリをロードするような処理は、objectに隔離して一回だけ動作させるのが良い。

object Libs {
  がんばってロード()
}

どうやって同梱するのか?

面白いことに、JARファイルは大抵のファイルをリソースとして突っ込むことができる。ネイティブライブラリもその例外ではない。

unmanagedResourceDirectoriesunmanagedResourcesにパスやファイルを追加すると、それがそのままリソースファイルとしてJARファイルに格納される。前者はディレクトリを、後者はファイル単体を指定する。ただし前者を使った場合、ディレクトリそのものは格納されず、その中身が格納されるという扱いになるので注意する。

(project in file(".")).settings(
  Compile / unmanagedResourceDirectories ++= { Seq(
      baseDirectory.value / "open_jtalk_dic_utf_8-1.11",
      baseDirectory.value / "voicevox_core-linux-x64-cpu-0.14.1/voicevox_core-linux-x64-cpu-0.14.1/model"
  ) },
  Compile / unmanagedResources ++= { Seq(
      file("voicevox_core-linux-x64-cpu-0.14.1/voicevox_core-linux-x64-cpu-0.14.1/libvoicevox_core.so"),
      file("voicevox_core-linux-x64-cpu-0.14.1/voicevox_core-linux-x64-cpu-0.14.1/libonnxruntime.so.1.13.1"),
  ) },
)

展開方法はあとで紹介する。

どうやってビルドするのか?

sbt-assemblyを使うと普通にJARファイルを作成できる。

github.com

sbt assemblyするとJARが作られる。

どうやって展開するのか?

ありがたいことに、JNAは「リソースとして格納したネイティブライブラリをユーザのファイルシステムに展開する」という機能を用意している。前提として、「OSはJAR内に格納されたネイティブライブラリを直接ロードできない」ということを踏まえておくと、この機能の意味が分かるはずだ。 一度ファイルシステムに展開する必要があるのだ。

以下のようにして、任意の場所にネイティブライブラリを展開し、ロードできる:

まず展開先を指定する。未指定だとJNAが勝手に場所を決めてしまうが、あまり嬉しくないのでやめたほうがいい。

System.setProperty("jna.tmpdir", "path/to/extract/native/libs")

次に、com.sun.jna.Native.extractFromResourcePathを呼び出し、リソース内のパスを指定してライブラリを展開させる。

val libfoobar =
      com.sun.jna.Native.extractFromResourcePath("/libfoobar.so") // リソースパスなので頭にスラッシュが必要

次に、ライブラリをリネームする。この手順は飛ばしても動きそうだが、デフォルトではJNAがランダムなファイル名を当ててしまって分かりにくいので、わざわざリネームする。

val targetLibfoobar = new File(libfoobar.getParentFile(), "libfoobar.so")
    libfoobar.renameTo(targetLibfoobar)

最後にパスを使ってNative.loadを呼ぶとライブラリがロードされる。ロードタイミングはお好みで。

System.load(targetLibfoobar.getAbsolutePath())

まとめ

疲れた これが僕の全てです

★記事をRTしてもらえると喜びます
Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?