ScalaでJARファイルにネイティブライブラリをバインディグして使いたいことがある時、いくつかの点を考える必要がある。
- どういう仕組みを使うのか?
- どうやって呼び出すのか?
- どうやって同梱するのか?
- どうやってビルドするのか?
- どうやって展開するのか?
この記事では、以上の問いに答える。初歩的な内容しか含まれていないが、インターネット上にはろくすっぽ知見が転がっていないので、何度も転びながら得た知見である。
今回はサンプルとしてncursesをC APIを通じて呼び出す。複雑な例ではVOICEVOX CoreのAPIを題材とする。
どういう仕組みを使うのか?
Scala/Java、というかJVMにはJNIという仕組みがあり、C言語用のライブラリを呼び出せるようになっている。
しかしJNIは非常に細かい調整や定義が必要で、使いにくい。それだけ柔軟な利用ができるということなのだが、たいていの場合はそこまでの粒度での調整は必要ない。
そこで、今回はJNAと呼ばれる仕組みをおすすめする。
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は引数の型などをほぼ自動で透過的に交換してくれる。
特に以下の点について留意しておくと良い。
- 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を呼び出したときに学んだ知見だ。
ポインタのやりとり
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.PointerByReference
をgetValue()
し、さらに変換メソッドを呼ぶことで配列などが得られる- 苦しめ
構造体
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が動く
構造体を関数の引数にする場合は、ByValue
かByReferencee
のどちらかを指定する:
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ファイルは大抵のファイルをリソースとして突っ込むことができる。ネイティブライブラリもその例外ではない。
unmanagedResourceDirectories
やunmanagedResources
にパスやファイルを追加すると、それがそのままリソースファイルとして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ファイルを作成できる。
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())
まとめ
疲れた これが僕の全てです