😸

.netstandardなアセンブリの中で.NET Frameworkや.NETのAPIをフルに使用する

2023/05/01に公開

.netstandardなアセンブリの中で.NET Frameworkや.NETのAPIをフルに使用する

かなり無理やりな手法ではありますが、ターゲットフレームワークに.netstandardを指定してビルドしたアセンブリの中の処理から.netstandardの制限に縛られない.NET Frameworkや.NET(.Net Core)をAPIをフルに使用する方法を紹介します。

この手法についての注意

.netstandardのAPIは.NET Frameworkと.NETで共通に使用できるAPIのサブセットとからなる仕様です(https://learn.microsoft.com/ja-jp/dotnet/standard/net-standard)。.netstandardをターゲットフレームワークにしてビルドしたアセンブリは.NET Frameworkと.NETに存在するAPIの一部が利用できない代わりに、.netstandardのバージョンに対応する.NET Frameworkのアセンブリからも.NETのアセンブリからも参照して実行することができます。
ここで紹介する手法は、使用可能なAPIに制限をかけることによってAPIセットが異なる2つのランタイムで安全に参照・使用することができるようにしたアセンブリの中で、あえてその制限を破るものであり、通常においてこのようなことを行う必要はまったくありません。
通常の方法で.NET Frameworkや.NETのAPIをフルに使用するアセンブリを用意する場合は、csproj等の中でターゲットフレームワークを指定にTargetFrameworksを使用して一つのcsprojから複数のターゲットフレームワーク別のアセンブリを同時にビルドして別プロジェクトから参照させるようにするのが正しい方法です。

  <PropertyGroup>
    <TargetFrameworks>net6.0;net472</TargetFrameworks>
  </PropertyGroup>

この手法が役に立つケース

ここで紹介する手法が例外的に役に立つのは、なんだかの理由でRoslynアナライザやソースジェネレータの中で.NET Frameworkおよび.NETの固有のAPIを使用した処理を実装したいケースなどです。現在のRoslynコンパイラはVisual Studioのプロセス内でのインクリメンタルコンパイルで使用される場合は.NET Frameworkで動作し、dotnetコマンド等からのビルドで使用される場合は.NETで動作するため、Roslynアナライザとソースジェネレータはどちらのランタイムでも動作するようにnetstadard2.0をターゲットフレームワークとしてビルドしたアセンブリである必要があります。
もし特別なことをせずに.NET Frameworkや.NETをターゲットフレームワークとしたアセンブリとしてRoslynアナライザとソースジェネレータを実装してそれをそのまま利用してしまうと、Visual Studioの編集では正常に動作するがビルドはクラッシュする。逆にdotnetコマンドでのビルドはできるが、当該プロジェクトをVisual Studioで開くとVisual Studioに異常が発生したりクラッシュするなどの問題が発生する可能性があります。
私がこの手法をひねり出したのはデザイン時のT4テンプレートのソースジェネレータ版が実装できないか試していた際にテンプレートファイルから動的に生成して使用したアセンブリをアンロードするためにソースジェネレータの中でAppDomain.Unload(.NET Framewarkのみ)および、AssemblyLoadContext(.NETのみ)が使用できないか挑戦してみた結果です。
改めて記載しますが、一般的なケースで使用するような手法ではないことにご注意ください。

この手法の概要

この手法の概要を簡単にまとめると以下の通りです。

  1. .NET Frameworkおよび.NETの固有APIを含むAPIを使用して実装するアセンブリは通常の場合と同様にTargetFrameworksを使用して<TargetFrameworks>net6.0;net472</TargetFrameworks>のように対象のラインタイムを指定して実装します。
  2. TargetFrameworknetstandard2.0を指定するプロジェクトを作成し、こちらでは実行時に実行中のランタイムを判別して1の実装アセンブリのうち実行中のランタイムに適合するターゲットフレームワークでビルドされているアセンブリをロードして、処理を委譲するスタブを実装します。
  3. Roslynアナライザやソースジェネレータとして参照させるのは2のプロジェクトからビルドされるアセンブリとします。

以上がこの手法の概要です。2のアセンブリの中で実行中のランタイムに適合する実装アセンブリに処理を委譲することで`netstandard2.0'でビルドされたアセンブリを介して.NET Frameworkまたは.NETのAPIをフルに使用したアセンブリが実際の処理を担うことができるようになります。

.NET Frameworkや.NETのAPIを実際に使用する実装アセンブリの作り方

概要に記載した通り、実装アセンブリには特別なことは特にありません。TargetFrameworkでなくTargetFrameworksによって対象とするターゲットフレームワークを複数指定したプロジェクトを作成し、実装します。必要に応じてさらに別のプロジェクトを参照したり、Nugetパッケージを参照してもOKです。ただし、指定するターゲットフレームワークのバージョンは実際の実行で使用されるランタイムのバージョンを確認し、それと同じかそれ以下のバージョンを指定しなければならないことに注意してください。

例えば、Visual Studio 2022を想定したRoslynアナライザ等に適用する場合は.NET Frameworkと.NETのバージョンを以下のようにするとよいです。

ランタイム バージョン
.NET Framewark 4.7.2
.NET 6.0

ただし、global.json等を配置してビルド時のSDKバージョン等を制御している場合などでは上記のバージョンでは問題を起こす可能性がありますのでご注意ください。

実際のプロジェクトファイルの例は以下の様になります。

MultipleFrameworkAssembly.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFrameworks>net472;net6.0</TargetFrameworks>
  </PropertyGroup>
  <ItemGroup>
    <!-- 必要に応じて別のプロジェクト参照やNuGet参照があっても可 -->
    <ProjectReference Include="..\CommonLib\CommonLib.csproj" />
  </ItemGroup>
</Project>

このプロジェクトのソースは.NET Framework版と.NET版が同じソースからビルドされるので、それぞれに固有のAPIを使用する箇所はプリプロセッサディレクティブを使用してターゲットフレームワークを指定した条件付きコンパイルなどを使用して実装します。以下のサンプルは.NET Frameworkをターゲットフレームワークとしてビルドされた場合はNetDataContractSerializerを使用してオブジェクトをXML化した結果を返し、.NETをターゲットフレームワークとしてビルドされた場合はJsonSerializerを使用してオブジェクトをJSON化した結果を返します。JsonSerializerSystem.Text.JsonをNuGetから参照すれば.NET Frameworkでも使用可能ですが、この記事用の良いサンプルが思いつかなかったのでご容赦ください。

ClassInCurrentFrameworkAssemby.cs
using System;
using System.Reflection;
using System.Text;
using System.Xml;

#if NET472
using System.Runtime.Serialization;
#elif NET6_0
using System.Text.Json;
#endif


namespace MultipleFrameworkAssembly
{
    public class ClassInCurrentFrameworkAssemby
    {
#if NET472
        // NetDataContractSerializerのシリアライズ用のクラス定義
        [DataContract]
        public class Person
        {
            [DataMember]
            public string? Name { get; set; }
            [DataMember]
            public int Age { get; set; }
        }
#endif

        public static string Run()
        {
#if NET472
            // .NET Framework用にビルドされたアセンブリはNetDataContractSerializerを使用してシリアライズしたXMLの文字列を返す
            StringBuilder stringBuilder = new StringBuilder();
            NetDataContractSerializer serializer = new NetDataContractSerializer();
            using (XmlWriter writer = XmlWriter.Create(stringBuilder))
            {
                serializer.WriteObject(writer, new Person
                {
                    Name = "Yamada",
                    Age = 21,
                });
            }
            return stringBuilder.ToString();
#elif NET6_0
            // .NET用にビルドされたアセンブリはJsonSerializerを使用してシリアライズしたJSONの文字列を返す
            return JsonSerializer.Serialize(new { Name = "Yamada", Age = 21 });
#endif
        }
    }
}

.netstandard2.0でビルドするスタブアセンブリの作り方

この手法の中心となる.netstandard2.0としてビルドされ、実処理を.NET Framworkや.NETでビルドされるアセンブリに委譲するスタブアセンブリの作り方を解説します。

プロジェクトファイルの構成

スタブアセンブリのプロジェクトファイルでは以下のことを実施します。

  • TargetFrameworkでターゲットフレームワークにnetstandard2.0を指定
  • NoWarnの対象にNU1702を追加
  • ProjectReferenceで実装アセンブリのプロジェクトのcsprojファイルをプライベートかつ出力ファイルの参照なしで参照
  • BeforeBuildターゲットの前に以下のタスクを実行するターゲットを定義する
    1. MultipleFrameworkAssembly.csprojを全てターゲットフレームワークに対するビルドを実行
    2. MultipleFrameworkAssembly.csprojのターゲットフレームワーク別の出力フォルダをzipファイル化
  • MultipleFrameworkAssembly.csprojの出力フォルダから作成されたzipを埋め込みリソースに追加

実際のファイル例は以下の通りです。

NetStandardStubAssembly.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <NoWarn>NU1702;$(NoWarn)</NoWarn>
  </PropertyGroup>

  <ItemGroup>
    <ProjectReference Include="..\MultipleFrameworkAssembly\MultipleFrameworkAssembly.csproj" Private="true" ReferenceOutputAssembly="false" />
  </ItemGroup>

  <Target Name="BuildImplAssemblies" BeforeTargets="BeforeBuild">
    <MSBuild Projects="..\MultipleFrameworkAssembly\MultipleFrameworkAssembly.csproj" Properties="TargetFramework=" />
    <ZipDirectory SourceDirectory="..\MultipleFrameworkAssembly\bin\$(Configuration)\net6.0" DestinationFile="..\MultipleFrameworkAssembly\bin\$(Configuration)\net6.0.zip" Overwrite="true" />
    <ZipDirectory SourceDirectory="..\MultipleFrameworkAssembly\bin\$(Configuration)\net472" DestinationFile="..\MultipleFrameworkAssembly\bin\$(Configuration)\net472.zip" Overwrite="true" />
  </Target>
  
  <ItemGroup>
    <EmbeddedResource Include="..\MultipleFrameworkAssembly\bin\$(Configuration)\net6.0.zip" LogicalName="net6.zip" Visible="false" />
    <EmbeddedResource Include="..\MultipleFrameworkAssembly\bin\$(Configuration)\net472.zip" LogicalName="net472.zip" Visible="false" />
  </ItemGroup>
</Project>

このcsprojでは、スタブアセンブリ(NetStandardStubAssembly)のビルドに先行して実装アセンブリ(MultipleFrameworkAssembly)の全てのターゲットフレームワークのビルドを実行させます。NoWarnNU1702を追加するのは参照関係にターゲットフレームワークの互換性がないものが含まれることについての警告を抑止するためです。
ReferenceOutputAssemblyfalseに設定したProjectReferenceによって実装アセンブリ(MultipleFrameworkAssembly)を直接参照せずに、ビルド時の依存関係だけがあることを宣言しておきます。必須ではありませんが、MultipleFrameworkAssemblyのソースだけを変更して全体をビルドした場合にも通常の参照関係にあるプロジェクトの様にNetStandardStubAssemblyがMultipleFrameworkAssemblyの変更に連動してビルドされるようになるため便利です。
実際のMultipleFrameworkAssembly.csprojのビルド結果(実装アセンブリとその参照アセンブリ)はターゲットフレームワーク別にzip化し、埋め込みリソースとしてスタブアセンブリ(NetStandardStubAssembly)に内蔵させます。
このサンプルは実装アセンブリ側に参照アセンブリなどがあっても問題ないように実装アセンブリ側の出力フォルダをまるごとzip化していますが、実装アセンブリが参照アセンブリなどを持たない場合は実装アセンブリだけを直接埋め込みリソースとしても問題ありません。

なお、そもそもなぜ実装アセンブリをスタブアセンブリに埋め込んでいるのかを疑問に思われると思いますが、それはこの手法がRoslynアナライザなどに適用することを考えているためです。現在のRoslynコンパイラはRoslynアナライザのアセンブリを元の場所でロードするのではなく、一時フォルダにコピーを作って一時フォルダのコピーをロードする場合があります(この動作はRoslynコンパイラがcscコマンドとして実行されるかVSのインクリメンタルコンパイルで実行されるかdotnetコマンドで実行されるかなどで異なり、配置場所から直接ロードされる場合もあります)。このときコピー元のフォルダにあった参照関係にないファイル等はコピーされないため、必要なファイルはアセンブリ自身に内蔵させるなどの対策をとらないと、動的ロードで自分の相対パスを基準に.NET Framework版や.NET版のアセンブリを読み込もうとし、読み込めずエラーとなってしまいます。これを解決するには.NET Framework版や.NET版のアセンブリをどこかの絶対パスで参照できるように配置する方法などもありますが、本手法ではスタブアセンブリの埋め込みリソースとしてスタブアセンブリと一体化させることにしています。

実装アセンブリのロードと実行

スタブアセンブリの中で実装アセンブリをロードし実行する方法について解説します。この手法ではターゲットフレームワーク別にzip化した実装アセンブリ(と実装アセンブリの参照アセンブリ)を埋め込みリソースとしてスタブアセンブリに中に埋め込んでいますので、以下の手順でアセンブリのロードを行います。

  1. 実行中のランタイムを判別
  2. 実行中のランタイムに適合する実装アセンブリのzipの埋め込みリソースのストリームを取得
  3. 実装アセンブリのzipを一時フォルダに展開
  4. 実装アセンブリをロード

実装アセンブリの実行(メソッド呼び出し)は、普通にリフレクション経由で行います。

実行中のランタイムを判別

実行中のランライムの情報はSystem.Runtime.InteropServices.RuntimeInformationクラスのFrameworkDescriptionプロパティから参照することができます。
このプロパティには実行中の.NET実装の名前とバージョンの示す文字列が設定されます。例えば、.NET Frameworkの場合は".NET Framework 4.8.9139.0"のような文字列となるので、.NET Frameworkか.NETのどちらかを判別する場合はこのプロパティの文字列が".NET Framework"で開始するか否かで判別することができます。


if (RuntimeInformation.FrameworkDescription.StartsWith(".NET Framework"))
{
    // 実行中のランタイムは.NET Framework
}
else
{
    // 実行中のランタイムは.NET(.NET Framework以外)
}

実行中のランタイムに適合する実装アセンブリのzipの埋め込みリソースのストリームを取得

実行中のアセンブリの埋め込みリソースはSystem.Reflection.AssemblyクラスのGetManifestResourceStream(string name)メソッドで埋め込みリソースの内容を読み込むことができるストリームが取得できます。この時のname引数にはcsprojの<EmbeddedResource>に指定したLogicalName属性の値が対応します。

// ClassOfTargetAssembly: 埋め込みリソースが存在するアセンブリに含まれるクラスのクラス名
// resourceName: "net472.zip" or "net6.zip"
var assembly = typeof(ClassOfTargetAssembly);
var resourceStream = assembly.GetManifestResourceStream(resourceName)

実装アセンブリのzipを一時フォルダに展開

System.IO.Compression.ZipArchiveクラスなどを利用して埋め込みリソースのzipのデータを一時フォルダなどに展開します。簡単な例としては以下のようにします。

var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
using (var zipArchive = new ZipArchive(resourceStream, ZipArchiveMode.Read))
{
    zipArchive.ExtractToDirectory(dir);
}

上記の例では一時フォルダの直下にGUIDでユニークなフォルダを作ってから展開するようにしています。なぜ、固定のフォルダでなくGUIDとしているかというと、実行ごとに同じフォルダとしてしまうと、この仕組みを利用したプログラムが2つ以上起動したときに先に起動したプログラムが実装アセンブリをロックすることで後から起動したプログラムが実装アセンブリの展開に失敗してしまうことを回避するためです。
ただし、このように実行ごとに常に異なるフォルダに展開してしまうと一時フォルダがむやみに肥大化する要因ともなってしまうため、できれば実装アセンブリが同一であるとみなせる場合には最初の一回のみ展開し、2回目以降は展開を省略するようにしたほうが良いです。後述のソースコード全体で示すサンプルでは更新日時が同一の実装アセンブリがすでに展開されている場合は展開を省略するような実装をしています。

実装アセンブリをロード

アセンブリはSystem.Reflection.AssemblyクラスのLoadFrom(string assemblyFile)メソッドでロードとします。

var assemblyPath = Path.Combine(dir, "MultipleFrameworkAssembly.dll");
var assembly = Assembly.LoadFrom(assemblyPath);

この時アセンブリをロードするAPIとして、Assembly.LoadFileというAPIもありますが、このサンプルではAssembly.LoadFromを使用してロードすることが重要です。Assembly.LoadFromを使用して実装アセンブリを読み込むと実装アセンブリが読み込み元コンテキストでロードされるため、同じフォルダに置かれているアセンブリがベースディレクトリのアセンブリと同じように自動的にロードされるようになります(実装アセンブリおよび実装アセンブリと同じ読み込み元コンテキストのアセンブリから参照された場合)。
Assembly.LoadFileを使用する場合は参照アセンブリのロードでFileNotFoundExceptionが発生するようになるため、AppDomain.AssemblyResolveイベントをハンドリングして参照アセンブリのロードを適切に行う必要があります。

実装アセンブリの実行(メソッド呼び出し)

実装アセンブリ側のメソッドは普通にリフレクション等を利用して呼び出します。

var classInCurrentFrameworkAssemby = assembly.GetType("MultipleFrameworkAssembly.ClassInCurrentFrameworkAssemby");
var resultValue = (string)classInCurrentFrameworkAssemby!.GetMethod("Run")!.Invoke(null, null);

スタブアセンブリ側のサンプルソース

ここまでに解説した内容をまとめて、スタブアセンブリ側に実装アセンブリのロードとメソッド呼び出しを行うクラスを実装した例を以下に示します。以下の例では実装アセンブリのロードのロードをClassInNetStandard20Assemblyクラスのstaticコンストラクタ内で行い、ClassInNetStandard20AssemblyクラスのRunメソッドを呼び出したときに実装アセンブリ側のClassInCurrentFrameworkAssembyクラスのRunメソッドが呼び出されるようにしています。

ClassInNetStandard20Assembly.cs
namespace NetStandard20Assembly
{
    public class ClassInNetStandard20Assembly
    {
        private static Type s_ClassInCurrentFrameworkAssemby;

        static ClassInNetStandard20Assembly()
        {
            var selfAssembly = typeof(ClassInNetStandard20Assembly).Assembly;

            var assemblyName = "MultipleFrameworkAssembly";

            string resourceName;

            if (RuntimeInformation.FrameworkDescription.StartsWith(".NET Framework"))
            {
                resourceName = $"net472.zip";
            }
            else
            {
                resourceName = $"net6.zip";
            }

            string dir;

            using (var resourceStream = selfAssembly.GetManifestResourceStream(resourceName))
            using (var zipArchive = new ZipArchive(resourceStream, ZipArchiveMode.Read))
            {
                var archiveEntryOfDll = zipArchive.Entries.FirstOrDefault(v => v.Name == $"{assemblyName}.dll");

                if (archiveEntryOfDll is null)
                {
                    throw new FileNotFoundException($"内部リソースの{resourceName}の中に{assemblyName}.dllが見つかりません。", $"{assemblyName}.dll");
                }

                var timestamp = archiveEntryOfDll.LastWriteTime.ToString("yyyyMMddHHmmss");

                dir = Path.Combine(Path.GetTempPath(), resourceName, timestamp);

                foreach (var entry in zipArchive.Entries)
                {
                    switch(entry.FullName[entry.FullName.Length - 1])
                    {
                        case '\\':
                        case '/':
                            continue;
                    }

                    var destinationFile = Path.Combine(dir, entry.FullName);

                    var destinationFileInfo = new FileInfo(destinationFile);
                    if (destinationFileInfo.Exists && destinationFileInfo.Length == entry.Length)
                    {
                        continue;
                    }

                    var destinationParentDir = Path.GetDirectoryName(destinationFile);
                    Directory.CreateDirectory(destinationParentDir);

                    entry.ExtractToFile(destinationFile, overwrite: true);
                }
            }

            var assemblyPath = Path.Combine(dir, $"{assemblyName}.dll");

            var assembly = Assembly.LoadFile(assemblyPath);

            s_ClassInCurrentFrameworkAssemby = assembly.GetType("MultipleFrameworkAssembly.ClassInCurrentFrameworkAssemby");
        }

        public static string Run()
        {
            return (string)s_ClassInCurrentFrameworkAssemby!.GetMethod("Run")!.Invoke(null, null);
        }
    }
}

まとめ

ターゲットフレームワークに.netstandard指定してビルドしたアセンブリの中の処理から.netstandardの制限に縛られない.NET Frameworkや.NET(.Net Core)をAPIをフルに使用する方法として、ターゲットフレームワーク別にビルドしたアセンブリを.netstandardとしてビルドしたアセンブリに内蔵し、実行時に実行中のターゲットフレームワークを識別して動的にロードして利用する方法を紹介しました。
冒頭の注意事項に記載した通り、一般的なケースで使用する手法ではありませんが、特殊なケースで役に立つこともあるほか、もしかしたらここで紹介した手法の一部が別のことに役立つこともあるかもしれませんので、参考としてご紹介しました。
比較的長い記事になってしまいましたが、ここまでお読みいただけていたらありがとうございます。

Discussion