窓を作っては壊していた人のブログ

この謎のブログタイトルの由来を知るものはもういないだろう

NuGet でプラットフォームごとにdllimportするネイティブライブラリを同梱してそれを使うラッパーのパッケージを作る方法

非常に自分で挑戦して悩んだことだったので,備忘録として残しておくことにします.

現在大学院で音声研究をしていて,音声信号処理系のライブラリを使うことが多いのですが,その多くが C/C++ で書かれていたりします. 実験レベルではシェルからパイプで繋いだり,スクリプトを書くというような流れていいと思いますが,実際に実験や研究から得られた知見を元にものを作ろうとした場合,C/C++ でなるべく書きたくありません,難しいですし. そのため C/C++ のライブラリを C# でラップして使うことが多く,その中で NuGet のパッケージにすると更に使いまわせることに気づき,このタイトルにあるようなことをやってみました.

はじめにおことわりしておきますが,Githubリポジトリを見てね案件が多いです,手を抜いてしまい申しわけないです.

NuGet のパッケージを作るには

ひとまず今回作ったライブラリをご紹介します.

github.com

World という音声分析合成ツールをラップしたものになります.

ものを見て理解する,という方は DotnetWorldlibrary/nugetディレクトリ内を見ていただければ理解できるかと思います.

さて,タイトルを満たす NuGet のパッケージを作る上で必要になるポイントは以下の4点です.

  • プラットフォーム毎のマネージドなライブラリ(ラッパー)を用意する
  • プラットフォーム毎のネイティブライブラリ(ラップ対象)を用意する
  • nuspec ファイルを用意する(プラットフォーム毎に出し分ける設定とかも)
  • (モバイルも対象にするのであれば)targets ファイルを用意する

基本的にはこの4点を頑張ればネイティブライブラリを含む NuGet パッケージは作れます.

プラットフォーム毎のマネージドなライブラリ(ラッパー)を用意する

ここを本来は頑張って書くべきではあるのですが,実際自分もまだマーシャリングなどを詳しく理解できているわけではないので,実際に今回扱った例を挙げて簡単に紹介いたします.

今回は PInvoke を多用しました.以前は C++/CLI でラップしていたのですが,Windows 以外での環境を想定した場合動作しないという問題があったからです.

PInvoke ってどんなもの?という感じですが,Win32API などを使ったことのある人にとっては見覚えのある DllImport 属性のものとイメージしていただければいいと思います.

例としては

//  http://www.mono-project.com/docs/advanced/pinvoke/#library-names を見直したらworldという名前でDllNameを定義したらLinuxとかをわける必要がありませんでした
if __Linux
        private const string DllName = "libworld.so";
#elif __Win
        private const string DllName = "world.dll";
#endif
        [DllImport(DllName,CallingConvention = CallingConvention.Cdecl)]
        public static extern void CheapTrick([In][MarshalAs(UnmanagedType.LPArray, SizeParamIndex=1)] double[] x,
            int x_length, int fs, [In][MarshalAs(UnmanagedType.LPArray, SizeParamIndex=5)] double[] temporal_positions,
            [In][MarshalAs(UnmanagedType.LPArray, SizeParamIndex=5)] double[] f0, int f0_length, [In] CheapTrickOption option,
            [In][Out] IntPtr[] spectrogram);

こんな感じです. ラップ対象のシンボル名のメソッドを用意して,引数に対して色々とアノテーションしてあげます.

今回の関数は C/C++ では

void CheapTrick(const double *x, int x_length, 
    int fs, const double *temporal_positions, const double *f0, 
    int f0_length, const CheapTrickOption *option, double **spectrogram)

として定義されています.

見ての通り基本的な型に関しては特に何か特別なアノテーションは必要ないのですが,配列や多次元配列,構造体に関してはアノテーションが必要になってきます.

配列に関して見てみると,[In][MarshalAs(UnmanagedType.LPArray, SizeParamIndex=1)] double[] x という引数の定義になっています. これは [In] 属性がついているため,ネイティブ側での変更はなく一方向のメモリのマーシャリングのみでよいということがわかります.また [MarshalAs(UnmanagedType.LPArray, SizeParamIndex=1)] という属性では SizeParamIndex=1 とあるようにこの配列の大きさは 0 始点で,順番的に 1 の引数に与えられた数字の大きさであることを示しています. こうすることでマネージド環境での配列を非マネージド環境にその与えられたサイズだけ転送することが出来ます.

次に多次元配列ですが,こいつが非常に厄介です.正直なところ自分のやり方であっているのか少し自信がないところもあります. 実際探してみたのですが,今回のように double** として渡す方法がなさそうだったので,IntPtr の配列にして渡してしまいます(三次元以上になったらどうなるんだろう...).

以下,extern した関数を呼んでいるマネージドなコードです.

public static void CheapTrick(double[] x, int x_length, int fs, double[] temporal_positions,
            double[] f0, int f0_length, CheapTrickOption option,
            double[,] spectrogram)
{
    int outer = spectrogram.GetLength(0);
    int inner = spectrogram.GetLength(1);

    IntPtr[] ptrs_sp = new IntPtr[outer];

    for (var i = 0; i < outer; i++)
    {
        ptrs_sp[i] = Marshal.AllocHGlobal(inner * Marshal.SizeOf<double>());
    }

    CoreDefinitions.CheapTrick(x, x_length, fs, temporal_positions, f0, f0_length, option, ptrs_sp);

    var tmp_arr = new double[inner];
    
    for (var i = 0; i < spectrogram.GetLength(0); i++) {
        Marshal.Copy(ptrs_sp[i], tmp_arr, 0, inner);
        Buffer.BlockCopy(tmp_arr, 0, spectrogram, i * inner * sizeof(double), inner * sizeof(double));
        Marshal.FreeHGlobal(ptrs_sp[i]);
    }
}

やっていることとしては,二次元配列の大きさを調べて,IntPtr に対象の配列の型の大きさと配列の大きさでメモリを確保して非マネージド側に渡しているだけです. 上記のように予めどれだけのメモリが必要なのかを知っておく必要があるため,いい感じに設計する必要があります.(自信がないからこのあたりで切り上げる)

最後に構造体です.構造体でも簡単出来るものと出来ないものがあるのですが,今回は簡単なものについて触れたいと思います. 上記CheapTrickOption はマネージド側ではクラスで定義されていますが,C/C++ 側では構造体で定義されています.

C# でのクラス定義は

[StructLayout(LayoutKind.Sequential)]
public class CheapTrickOption
{
    public double q1;
    public double f0_floor;
    public int fft_size;
}

C/C++ では

typedef struct {
  double q1;
  double f0_floor;
  int fft_size;
} CheapTrickOption;

となっています. C# 側で[StructLayout(LayoutKind.Sequential)]という属性を付け,型を同じにして順番も揃えてあげることで簡単に構造体をやり取りすることが出来ます. 面倒な構造体は中にポインタが入ったり,よく中身がわからない構造体が含まれているものなのですが,今回はなるべく触らないように逃げた& C/C++ 側の定義をちょっと変更したので述べません. 誰かわかったら教えてください.

と,以上の3つの要素を覚えればある程度のネイティブライブラリはラップできるかと思います. 更に踏み込んだライブラリは自分もラップしたことがないのでこの辺で〆たいと思います.

プラットフォーム毎のネイティブライブラリ(ラップ対象)を用意する

上記のステップでラッパーライブラリが完成したとします. あとは各プラットフォーム毎に共有ライブラリを作っていくだけです. 今回は Makefile が用意されていて単純なフローで作ることの出来るライブラリだったために,コンパイラとオプションをいじるだけで済みました.

以下メモ

  • Windows: cl.exe に /c オプションをつけてオブジェクトを生成し,link.exe に /dll オプションを付けて DLL を作る
  • Mac: -dynamiclib オプションを付けてオブジェクトをリンクする
  • Linux: -shared オプションを付けてオブジェクトをリンクする
  • Android: standalone toolchain をどうにかして頑張る.32bit の arm はオプションが若干変化
  • iOS: xcrun でコンパイラ関係探して流れに沿って行う.最後に lipo でまとめる.

上記の詳しい内容については

GitHub - yamachu/World: A high-quality speech analysis, manipulation and synthesis system

.travis.ymlappveyor.ymlMakefile などにまとまっています.

以上の流れに沿って各プラットフォームで共有ライブラリを作成します(iOSに限っては静的ライブラリ)

nuspec ファイルを用意する(プラットフォーム毎に出し分ける設定とかも)

Microsoft MVP である bonprosoft 氏の投稿の

blog.bonprosoft.com

を参考にしました. その為本記事ではこの内容に関しては割愛します.

またソースとしては DotnetWorld.nuspec を参照.

(モバイルも対象にするのであれば)targets ファイルを用意する

ここが一番意味苦労した部分だと思います. ターゲットがデスクトップ環境であれば runtimes/~/native 以下に置いておけば読んでくれたのですが,モバイル環境は別の操作が必要です.

非常に参考になったのは

realm-dotnet/Realm.nuspec at master · realm/realm-dotnet · GitHub

realm-dotnet/Realm.Database.targets at master · realm/realm-dotnet · GitHub

です(これがなかったら多分解決してなかった).

nuspec の方は特にこれと言ったものはないのですが,途中に targets ファイルを build フォルダに入れている流れが見えると思います. ここが非常に重要で,この targets ファイルは何をしているかというとビルドプロセスの中に入って何かしら決められたタスクをこなしています.

詳しい内容は

www.kekyo.net

が非常に参考になります.

この targets ファイルでビルド時にネイティブライブラリの位置を教えたり,属性はどんなものかなどを指定しています.

iOS を例に取ると

<?xml version="1.0"  encoding="utf-8"?>
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
  <PropertyGroup>
    <_WorldRootPath Condition="'$(_WorldRootPath)' == ''">$(MSBuildThisFileDirectory)..\..\native\</_WorldRootPath>
  </PropertyGroup>
  <ItemGroup Condition="'$(TargetFrameworkIdentifier)' == 'Xamarin.iOS'">
    <NativeReference Include="$(_WorldRootPath)ios\universal\libworld.a">
      <Kind>Static</Kind>
      <SmartLink>True</SmartLink>
      <IsCxx>True</IsCxx>
      <LinkerFlags>-lstdc++</LinkerFlags>
    </NativeReference>
  </ItemGroup>
</Project>

ツリーにするとこんな感じに NuGet のパッケージは構成されていて(だいぶ省略しましたが)

├─build
│  └─Xamarin.iOS10
│          DotnetWorld.targets
│
└─native
    └─ios
       └─universal
          libworld.a

この Xamarin.iOS10/DotnetWorld.targets を始点にどこに同梱するべきネイティブライブラリがあるかを上記targets では示しています.

このようにモバイル環境の場合はリンクするライブラリを示して上げる必要があるので,大変だねってことを言いたいがためにこの記事書いたものでもあります.

パッケージング

nuget pack 頑張って作ったライブラリ.nuspec

を叩いて終わり.やったー

最後に

うっわ,すっごい手抜き記事...という感じがありますが,とりあえず自分がまた読んで思い出せるレベルには書けたかなという感じです. 実際にリポジトリの中身を見て理解するほうが早いと思うので,それと照らし合わせて手を動かしてみてください.

今回の記事でやっとデスクトップで使っていたネイティブライブラリがモバイルでも使えるようになったため,更にプロダクトの幅が広がりそうです.

またこの実験をする上で非常に Travis CI や AppVeyor が役に立ちました. 今度から積極的に使っていきたいです.