「NuGetでプラットフォーム毎にアセンブリを展開する方法」と「良い感じにコードを共有してプラットフォーム別のアセンブリを作る方法」

2016-11-24

いろいろと思ったことを忘れないように書いておこうと思います。

最近は.NET Standardとかいろいろありますが、もちろんすべての状況においてすべてのコードが共通化できるわけではありません。というわけで、この投稿では

  1. NuGetでプラットフォームごとに異なるアセンブリを展開する方法
  2. CoreFXを覗いてちょっと真似てみた、良い感じにコードを共有してプラットフォーム別のアセンブリを作る方法

をご紹介します。あまり詳しくないので、間違っていたらご指摘お願いします。

NuGetでプラットフォームごとに異なるアセンブリを展開する方法

まずはこの話題についてお話しします。

CoreFXを例にとって話を進めていきましょう。 皆さんもご存知の通り、CoreFXは.NET Coreなどで使われるBCL(基本クラスライブラリ群)を提供する、という重要な立ち位置を担っている存在です。 CoreFX自体はC#で書かれていて、モジュールごとに細かく分かれた状態でNuGetから入手することができます。

私たちはプラットフォームを気にせず、簡単にそれらを使うことができますが、CoreFXを開発する側は「コードを1つ書いてコンパイルして、はい終わり」で済むはずがありません。 例えばSystem.IOなどを考えてみると、答えは明らかでしょう。 パスの区切り文字や使える文字種も違いますし、そもそもWindowsとLinuxで同じシステムコールやAPIを呼び出せるわけではありません。 レイヤーが低くなるにつれて、少なくともプラットフォーム依存なコードを書く必要が出てくるでしょう。

「じゃあ1つのアセンブリを提供して、プラットフォーム依存の部分は条件分岐とかで切り替えて処理を行っているの?」と聞かれたら、そういうわけでもありません。 それは単純に分岐処理分の実行時間が無駄になりますし、バグ修正や事前コンパイルのコストが大きくなってしまうでしょう。

ということで、どのようなことをやっているかというと、NuGetからリストアするときにランタイムごとに異なるdllを引っ張ってきています。 実際にNuGet Package Explorerとかで探してみるとわかりやすいでしょう。例えば”System.IO.FileSystem”を検索してみると、下の画像のようになります。

お目当てのSystem.IO.FileSystemの他にも、runtime.linux.System.IO.FileSystemやruntime.osx.10.10.System.IO.FileSystemなどが見えると思います。

NPEでSystem.IO.FileSystemを検索した結果

NPEでSystem.IO.FileSystemを検索した結果

「これはどういうことなんだ…」そう思いながら、次は(System.IO.FileSystemの依存関係にもある)”Microsoft.NETCore.Targets”を検索し、openしてみましょう。 この中のruntime.jsonを見ると、すっきりすると思います。(少なくとも私はしました)

Microsoft.NETCore.Targetsのruntime.jsonの中身

Microsoft.NETCore.Targetsのruntime.jsonの中身

runtimesの中に”win”だったり”osx”だったりといろいろありますね。つまり実行するランタイムによって、落としてくるNuGetライブラリの中身を変えていることが分かります。 これは実際にローカルにあるNuGet Cacheを見るとはっきりするかと思います。

「じゃあ、いつも参照にいれてる”System.IO.FileSystem”って何なのさ!?」と思って、再度System.IO.FileSystemの中身を開いてみましょう。 NuGet Package Explorerで開くと以下の画像のようになっていると思います。

System.IO.FileSystemの中身

System.IO.FileSystemの中身

結論から言うと、.NET Standardにおける、このパッケージの最も大きな役割(の1つ)は”開発時のインターフェースを提供すること”です。 どういうことかというのは、CoreFXのSystem.IO.FileSystemのリポジトリを見に行ってみるとわかります。

corefx/src/System.IO.FileSystem at master · dotnet/corefx

refというフォルダの中にはSystem.IO.FileSystem.csというファイルが1つだけあり、さらにその中身は、シンボル一覧みたいなものでしかありません。 で、実装はどこにあるのかというと、これはsrcの中に入っています。 これらをビルドした後、前者が”System.IO.FileSystem”、後者が各ランタイムとしてNuGetに公開されています。

嘘だと思うなら、dotPeekとかを使って、NuGetから落としてきたそれぞれのdllの中身を覗いてみましょう。前者はシンボルはあるものの、すっからかんのはずです。

さて、こんな感じのことを実現するNuGetパッケージを作る方法ですが、答えは非常に簡単で、CoreFXと同じようなディレクトリ構造を作るだけで実現できます。 と言っても、あまりに大規模でなければ、CoreFXのように複数のランタイムパッケージを公開せず、1つのパッケージだけで完結したほうが楽です。

1つのパッケージで異なるランタイムを対象とした複数のアセンブリを展開するには、以下のようなディレクトリ構成にします。

NuGetのディレクトリ構成例
├─ref
│  ├─net46
│  │      FooLibrary.dll
│  │
│  └─netstandard1.5
│          FooLibrary.dll
│
└─runtimes
    ├─linux
    │  └─lib
    │      └─netstandard1.5
    │              FooLibrary.dll
    │
    └─win
        └─lib
            ├─net46
            │      FooLibrary.dll
            │
            └─netstandard1.5
                    FooLibrary.dll

このようなディレクトリ構成にすると、Visual Studioなどで開発しているときはrefのほうのdllを参照し、実際に動作させるときやデプロイを行うときは、runtimes以下の各プラットフォームのdllを使うようになります。

ここで、linuxやwinは_RIDのカタログ_の中から指定しています。 このRIDは階層構造を持っているので、例えば対象のランタイムが狭い順に…

ubuntu.14.04-x64 < ubuntu14.04 < ubuntu < debian < linux < unix < any

となっています。最新のカタログはcorefx/runtime.json at master · dotnet/corefxから参照することができます。

またnet46やnetstandardの部分は、NuGetのドキュメントにあるTarget Frameworksから適切なものを引っ張ってきましょう。

で、ここでやっぱり疑問に思うわけです。

_「どうやって良い感じにコードを共有してプラットフォーム別のアセンブリを作れば良いんだ…」_と。

というわけで、次の章に続きます。

良い感じにコードを共有してプラットフォーム別のアセンブリを作る方法

ここではCoreFXを参考にして、良い感じにコードを共有する方法を考えてみましょう。何度も言いますが、あまり詳しくないので、間違っていたらご指摘お願いします。

結論から言うと、System.IO.FileSystemを把握する上で注目すべきファイルは以下の3つだと思います。

分かりやすくするために、簡単なプロジェクトを作ってみました。

bonprosoft/FooLibrary: 良い感じにコードを共有してプラットフォーム別アセンブリを作りたい

ざっと解説していきます。

ref/FooLibrary

いつも通りプロジェクトを作った後、外部に公開するシンボルを記述していきます。

ちなみに、今回はSayHello関数とSayHelloFromYourEnvironment関数を公開して、前者をプラットフォームによらず同じメッセージを出力、後者をプラットフォームごとに違うメッセージを出力する仕様にしてみます。

src/FooLibrary/Greeting.cs

どちらのプラットフォームでも動作するようなコードを記述していきます。ここではSayHello関数の実装を行っています。

なお、このGreetingクラスには、残りのプラットフォーム依存の関数を実装する必要があります。そこで、_クラスにpartialキーワードをつけてあげる_のを忘れないようにしてください。

src/FooLibrary/Greeting.Linux.cs , src/FooLibrary/Greeting.Windows.cs

プラットフォーム依存のコードを記述していきます。ここで同様にpartialなクラスにコードを記述していきますが、Windows側とLinux側のソースコードには同仕様の同名の関数を定義したため、“同じ型のパラメータで既に定義しています"と怒られるはずです。

そこで次にcsprojとslnを書き換えていきます。いったんVisual Studioを閉じるか、ソリューションをアンロードしてください。

src/FooLibrary/FooLibrary.csproj

src/FooLibrary/FooLibrary.csproj
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
  <Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" />
  <PropertyGroup>
    <TargetFramework>netstandard1.5</TargetFramework>
  </PropertyGroup>
  <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Linux_Debug|AnyCPU'" />
  <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Linux_Release|AnyCPU'" />
  <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Windows_Debug|AnyCPU'" />
  <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Windows_Release|AnyCPU'" />
  <ItemGroup Condition="$(Configuration.StartsWith('Linux'))">
    <Compile Include="Greeting.cs" />
    <Compile Include="Greeting.Linux.cs" />
    <EmbeddedResource Include="**\*.resx" />
  </ItemGroup>
  <ItemGroup Condition="$(Configuration.StartsWith('Windows'))">
    <Compile Include="Greeting.cs" />
    <Compile Include="Greeting.Windows.cs" />
    <EmbeddedResource Include="**\*.resx" />
  </ItemGroup>
  <ItemGroup>
    <PackageReference Include="NETStandard.Library">
   <!-- 以下略 -->

L6 - L9

プロジェクトのビルド構成をVisual Studioに認識させています。別に書かなくても動くと思いますが、CoreFXのリポジトリではそう書かれているので念のため…。(すみません)

L10 - L14, L15 - L19

Linux向けとWindows向けでコンパイル対象となるソースコードを変えています。特に12行目と17行目に着目すると良いでしょう。

なおCoreFXでは、TargetsUnixのような便利変数をいろいろ用意していたりします。 これらはCoreFXのトップディレクトリにあるdir.propsで定義していて、「各ソリューションごとに用意するのがめんどくさいので、各階層のdir.propsを再帰的にImportするようにして、設定やフラグも一括で管理できるようにした」という感じです。

特にソリューションが複数あるような大規模な開発をされている方は、参考にすると良いと思います。

FooLibrary.sln

FooLibrary.sln(例)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<!-- 前略 -->
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ref", "ref", "{723493A6-E8FC-4674-BAAB-F2232BA77309}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{33873D53-3A6D-4CFC-8157-E356B37CC705}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FooLibrary", "src\FooLibrary\FooLibrary.csproj", "{55B625BD-321A-4D75-A864-3B6758E32524}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FooLibrary", "ref\FooLibrary\FooLibrary.csproj", "{06B68D1C-873D-4F90-9AB7-AA8D51FAA5CD}"
EndProject
Global
	GlobalSection(SolutionConfigurationPlatforms) = preSolution
		Linux_Debug|Any CPU = Linux_Debug|Any CPU
		Linux_Release|Any CPU = Linux_Release|Any CPU
		Windows_Debug|Any CPU = Windows_Debug|Any CPU
		Windows_Release|Any CPU = Windows_Release|Any CPU
	EndGlobalSection
	GlobalSection(ProjectConfigurationPlatforms) = postSolution
		{55B625BD-321A-4D75-A864-3B6758E32524}.Windows_Debug|Any CPU.ActiveCfg = Windows_Debug|Any CPU
		{55B625BD-321A-4D75-A864-3B6758E32524}.Windows_Debug|Any CPU.Build.0 = Windows_Debug|Any CPU
		{55B625BD-321A-4D75-A864-3B6758E32524}.Windows_Release|Any CPU.ActiveCfg = Windows_Release|Any CPU
		{55B625BD-321A-4D75-A864-3B6758E32524}.Windows_Release|Any CPU.Build.0 = Windows_Release|Any CPU
		{55B625BD-321A-4D75-A864-3B6758E32524}.Linux_Debug|Any CPU.ActiveCfg = Linux_Debug|Any CPU
		{55B625BD-321A-4D75-A864-3B6758E32524}.Linux_Debug|Any CPU.Build.0 = Linux_Debug|Any CPU
		{55B625BD-321A-4D75-A864-3B6758E32524}.Linux_Release|Any CPU.ActiveCfg = Linux_Release|Any CPU
		{55B625BD-321A-4D75-A864-3B6758E32524}.Linux_Release|Any CPU.Build.0 = Linux_Release|Any CPU
		{06B68D1C-873D-4F90-9AB7-AA8D51FAA5CD}.Windows_Debug|Any CPU.ActiveCfg = Debug|Any CPU
		{06B68D1C-873D-4F90-9AB7-AA8D51FAA5CD}.Windows_Debug|Any CPU.Build.0 = Debug|Any CPU
		{06B68D1C-873D-4F90-9AB7-AA8D51FAA5CD}.Windows_Release|Any CPU.ActiveCfg = Release|Any CPU
		{06B68D1C-873D-4F90-9AB7-AA8D51FAA5CD}.Windows_Release|Any CPU.Build.0 = Release|Any CPU
		{06B68D1C-873D-4F90-9AB7-AA8D51FAA5CD}.Linux_Debug|Any CPU.ActiveCfg = Debug|Any CPU
		{06B68D1C-873D-4F90-9AB7-AA8D51FAA5CD}.Linux_Debug|Any CPU.Build.0 = Debug|Any CPU
		{06B68D1C-873D-4F90-9AB7-AA8D51FAA5CD}.Linux_Release|Any CPU.ActiveCfg = Release|Any CPU
		{06B68D1C-873D-4F90-9AB7-AA8D51FAA5CD}.Linux_Release|Any CPU.Build.0 = Release|Any CPU
	EndGlobalSection
	GlobalSection(SolutionProperties) = preSolution
		HideSolutionNode = FALSE
	EndGlobalSection
	GlobalSection(NestedProjects) = preSolution
		{55B625BD-321A-4D75-A864-3B6758E32524} = {33873D53-3A6D-4CFC-8157-E356B37CC705}
		{06B68D1C-873D-4F90-9AB7-AA8D51FAA5CD} = {723493A6-E8FC-4674-BAAB-F2232BA77309}
	EndGlobalSection
EndGlobal

L6, L8

まずはプロジェクトのGUIDをメモしておきます。src/FooLibraryは55B625BD-321A-4D75-A864-3B6758E32524、ref/FooLibraryは06B68D1C-873D-4F90-9AB7-AA8D51FAA5CDですね。

L11-L16

ソリューションのビルド構成を定義します。ここでは"Linux_Debug”,“Linux_Release”,“Windows_Debug”,“Windows_Release"の4つの構成を定義しています。

L17-L34

L6, L8でメモした、それぞれのプロジェクトGUIDを使って、ソリューションのビルド構成とプロジェクトのビルド構成の対応付けを行っています。

例えば、src/FooLibrary(L18-L25)であれば、それぞれソリューションのビルド構成と対応した名前のビルド構成を適用するように設定しています。

しかし、ref/FooLibrary(L26-L22)は、そもそもLinuxやWindowsの区別はありませんので、ソリューションのビルド構成によらず、DebugかReleaseのどちらかに対応付けています。

ここまでやって、再度Visual Studioでソリューションをロードすると、良い感じにビルド構成が読み込まれているはずです。

ちなみに、Visual Studio 2017 RCでは、ツールバーからビルド構成を変更すると、ソリューションエクスプローラで表示されるファイルも良い感じに連動して変更されたのが、とても印象的でした。

VS2017 RCでソリューションを開く

VS2017 RCでソリューションを開く

実際にビルド&パッケージ化

“Windows_Release”, “Linux_Release"でそれぞれビルドを行うと、それぞれ良い感じの出力ディレクトリにdllを吐き出してくれますので、これらをNuGetパッケージにまとめましょう。

nuspec, nupkgともに、NuGet Package Explorerを使うと簡単に作成することができます。参考までに構成のスクリーンショットを載せておきます。

NuGetパッケージの構成例

NuGetパッケージの構成例

実際にパッケージを使ってみる

作成した.nupkgを適当なディレクトリに入れて、NuGetのパッケージソースに追加しましょう。

Visual Studioであればオプションダイアログから操作するのが最も簡単です。

NuGetのパッケージソースとしてローカルディレクトリを追加する

NuGetのパッケージソースとしてローカルディレクトリを追加する

LinuxとMacであれば ~/.nuget/NuGet/NuGet.config に、以下のような行を追加するのが良いでしょう。

NuGet.Config
1
2
3
4
5
6
7
<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <packageSources>
    <add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
    <add key="local-nugets" value="/path/to/directory/of/nuget/repository" />
  </packageSources>
</configuration>

あとはFooLibraryへの参照をNuGetパッケージマネージャーを使うか、csprojもしくはproject.jsonを直接変更して追加した後、コードを記述していきます。

例として以下のようなコードを記述します。

Program.cs
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
using System;
using FooLibrary;

class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine("Hello World!");
        FooLibrary.Greeting.SayHello();
        FooLibrary.Greeting.SayHelloFromYourEnvironment();
    }
}

実行してみましょう。

Windowsで実行した場合は以下のようになります。

Windowsで実行した場合

Windowsで実行した場合

Linuxで実行した場合は以下のようになります。

Linuxで実行した場合

Linuxで実行した場合

このように、最終行のメッセージがプラットフォームによって変わっていることが分かりますね!

Framework-dependent DeploymentとSelf-contained Deployment

「ソースコードからビルドしたときはちゃんと動くことは分かった、じゃあデプロイしたアプリケーションの動作するプラットフォームが変わったらどうなるの?」

ということで、最後は発行に関するお話をしたいと思います。

.NET Coreの発行には、ポータブルアプリケーション(アプリケーションのアセンブリと依存ライブラリのみを固めたもの)とSelf-containedなアプリケーション(アプリケーションと依存ライブラリのほかに、実行ランタイムも含めて固めたもの)があります。参考1 参考2

Self-containedなアプリケーションの場合には、あらかじめproject.jsonなどでruntimesを指定するので大丈夫そうですが、ポータブルアプリケーションの場合はどうでしょうか?

実際にやってみましょう。

先ほどのサンプルプロジェクトで dotnet publish --configuration Releaseしてみます。

すると、以下のようなディレクトリ構成を持つpublishディレクトリが出力されました。

ポータブルアプリケーションの出力ディレクトリ構成
│  SampleConsoleApp.deps.json
│  SampleConsoleApp.dll
│  SampleConsoleApp.pdb
│  SampleConsoleApp.runtimeconfig.json
│
└─runtimes
    ├─linux
    │  └─lib
    │      └─netstandard1.5
    │              FooLibrary.dll
    │
    └─win
        └─lib
            └─netstandard1.5
                    FooLibrary.dll

つまり、ちゃんとWindowsでもLinuxでも動くように、runtimesディレクトリが分かれてくれています。

dotnet publishしたものを、WindowsとLinuxそれぞれで動かしてみます。

WindowsでのPortableAppの実行結果

WindowsでのPortableAppの実行結果

LinuxでのPortableAppの実行結果

LinuxでのPortableAppの実行結果

はい、ちゃんと動いていますね!

おわりに

というわけで、いかがでしたでしょうか。

さらっと紹介しましたけど、はまりポイントはいくつかあって、その最も大きなポイントが

refとsrcで外部に公開しているシンボルの不一致が発生する

です。

Visual Studioやコンパイラが参照するのはrefのdllなので、コンパイル時にはエラーは発生しませんが、実行時に「シンボルが見つからない」というエラーが発生します。

これはソリューションの規模が大きくなればなるほど、非常に大きな問題となるでしょう。(ちなみに私はこの程度の規模のライブラリでそれをやらかしました)

CoreFXでは、これに対応するために様々なツールが用意されています。

テストを書いて実行しているのはもちろんですが、それ以外にもApiCompatなどのツールを使って、refで定義されたシンボルの実装が正しく行われているか確認しています。

もっと良い方法があるかもしれませんので、この投稿へのコメントか、Twitter @bonprosoft へリプライいただければ幸いです。

Acknowledgement

今回の投稿の内容を調べるにあたって、グロサミにて、岩永さんにいろいろとキーワードやきっかけをいただきました。ありがとうございました。

ここで書かれている内容などに関しては、私の見解ですので、この内容が必ずしも正しいというわけではありませんので、ご了承ください。

ちょっとだけ宣伝を…

いよいよC#たんのぬいぐるみがいよいよ発売されます!

業者と何度もやり取りして、クオリティをあげるのに、かなり頑張りました!

通常版は、なるべく多くの皆さんに手にとっていただけるよう、_量産時の原価(型紙代などは含めてない)で販売_しています…!

(製作前に何社にも見積もりを出しましたが、ロット数の少ないぬいぐるみの量産は、これが最安値でした…!)

12月中旬に発送を予定しています。ぜひご検討ください!

詳細はこちら

通常版ぬいぐるみ

通常版ぬいぐるみ

C#たんをサポートしてくださる方向けの商品です。通常料金より高めに価格が設定されています。 差額はC#たんの型紙代等の支払いに充てられます。 サポーターの皆様への感謝の気持ちとして、 特別限定版キーホルダーをプレゼントいたします。

C#たんをサポートしてくださる方向けの商品です。通常料金より高めに価格が設定されています。 差額はC#たんの型紙代等の支払いに充てられます。 サポーターの皆様への感謝の気持ちとして、 特別限定版キーホルダーをプレゼントいたします。

このエントリーをはてなブックマークに追加
« Microsoft MVP Awardを受賞しました Visual Studio Code向けの簡易スペルチェッカーを実装してみる »