こちらは C# Advent Calendar 2024の20日目の記事です。
.NET 9のリリースについて
.NET 9が11月12日(現地時間)にリリースされ、特にGCの改善などについて話題になりました。
.NET 9のリリースではGCの改善の話題が大きく取り上げらていた印象ですが、私としては System.Reflection.Emit 生成コードの永続化機能の追加(復活)されたことが特にうれしく思っています。
そもそもSystem.Reflection.Emitって何ぞや
System.Reflection.Emitは動的にCIL(Javaでいうバイトコード)レベルでプログラムを構築・実行・保存が可能なライブラリです。
例えばDynamicMethod
を使用すると動的に軽量のグローバル メソッドを定義し、デリゲートを使用してそのメソッドを実行します。
背景情報
もともと.net frameworkにはAssembleBuilder.Saveという生成したコードをexeとして出力するメソッドが存在しましたが、.NET Core,.NETに移行する際にこのメソッドは廃止されていました。
しかしながらコンパイラ作成などでの需要がそれなりにあり、issueが立てられ議論されていました。
.NET 8リリース時に現実的な実装の話が出ていましたが、見送られ今回の.NET 9でのリリースとなりました。
代替案はなかったのか
issueでも度々話題に上がっていましたが、代替案としては以下のものが示されていました。
- System.Reflection.Metadata.Ecma335(通称 MetadataBuilder)を使用する
- IKVM.Reflection.Emitを使用する
- Mono.Cecilを使う
Reflection.Metadata.Ecma335(MetadataBuilder)を使用する
.NET Core系にはMetadataBuilderというSystem.Reflection.Emitのように実行ファイルをILレベルで生成できるライブラリが存在します。このライブラリはRoslynなどでも使われており最も現実的な大体案であるとも思えますが、Qiitaなどの記事はもちろん公式ドキュメントでも十分と言える状態ではありません。また、System.Reflection.Emitと比べてレイヤが低く、考慮する点が多いの問題があります。
さらに、System.Reflection.Emitは通常のReflectionのクラスを派生して作成されているので多態性のありがたみを享受できます。←ここ重要
Reflectionと同じようなことができるので大変扱いやすいです。
個人的にはMetadataBuilderはSystem.Reflection.Emitに置き換え可能だと思うのですが...
IKVM.Reflection.Emitを使用する
IKVM.Reflection.Emitは有志のブログなどは存在していますが、公式のドキュメントがありません。(探し方が悪かっただけ説もありますが)
また、.net Framework自体の記事はたくさんありますが、.NETの記事が少ないため対応しにくいと考えられます。
Mono.Cecilを使う
Mono.Cecilに関してはILを編集することを目的にしているので全体的に無駄が多いコードになってしまいます。
.net Frameworkと.NETでの実装の違い
.net Frameworkと.NETでは挙動が大きく変わっています。
特に大きな違いを列挙します。
- ディスクにデータを保存する場合AssemblyBuilderではなくPersistedAssemblyBuilderを使う必要がある
- EntryPointを設定しても.dllで吐き出す
- exeはHostWriter(apphost)で作る
- PersistedAssemblyBuilderからMetadataBuilderを作りMetadataBuilder,PEHeaderBuilder,ManagedPEBuilder,BlobBuilderという順序で生成していく
- EntryPointはManagedPEBuilderで指定する
以上の部分が特に大きく変わっています
実際のコード
実際のコードを見てみましょう。
これはHello, World!
と表示するいたってシンプルなプログラムを生成します。
また、これは自己完結のファイルとして生成しています。
using System.Reflection;
using System.Reflection.Emit;
using System.Reflection.Metadata.Ecma335;
using System.Reflection.Metadata;
using System.Reflection.PortableExecutable;
namespace dotnet9Reflection
{
internal class Program
{
static void Main(string[] args)
{
// 出力パスを作成
DirectoryInfo baseDirectory = Directory.CreateDirectory("output");
// ライブラリなどがあるディレクトリを取得
string referencePath = GetVersionDirectory(@"C:\Program Files\dotnet\shared\Microsoft.NETCore.App",9,0)!;
// 保存先パスを取得
string basePath = baseDirectory.FullName;
// sdkのパスを取得
string sdkPath = @"C:\Program Files\dotnet\sdk";
// dllをコピー
CopyDirectory(referencePath, basePath, true);
// hostfxrをコピー
string fxrDirPath = GetVersionDirectory(@"C:\Program Files\dotnet\host\fxr", 9, 0)!;
CopyDirectory(fxrDirPath, basePath, true);
// dllなどをコピーしたのでコピー先のdllを参照するように指定する
referencePath = basePath;
// dllを読み込み
PathAssemblyResolver resolver = new(Directory.GetFiles(referencePath, "*.dll"));
using MetadataLoadContext context = new(resolver);
Assembly coreAssembly = context.CoreAssembly!;
// typeを取得
Type voidType = coreAssembly.GetType(typeof(void).FullName!)!;
Type objectType = coreAssembly.GetType(typeof(object).FullName!)!;
Type stringType = coreAssembly.GetType(typeof(string).FullName!)!;
Type stringArrayType = coreAssembly.GetType(typeof(string[]).FullName!)!;
Type consoleType = coreAssembly.GetType(typeof(Console).FullName!)!;
// PersistedAssemblyBuilderを生成
PersistedAssemblyBuilder assemblyBuilder = new(new AssemblyName("EmitTest"), coreAssembly);
// TypeBuilderの生成
TypeBuilder typeBuilder = assemblyBuilder.DefineDynamicModule("EmitTest").DefineType("Program", TypeAttributes.Public | TypeAttributes.Class, objectType);
// メソッドを生成
MethodBuilder methodBuilder = typeBuilder.DefineMethod("Main", MethodAttributes.Public | MethodAttributes.Static, voidType, [stringArrayType]);
//メソッド内部を生成
ILGenerator ilGenerator = methodBuilder.GetILGenerator();
ilGenerator.Emit(OpCodes.Ldstr, "Hello, World!");
ilGenerator.Emit(OpCodes.Call, consoleType.GetMethod("WriteLine", [stringType])!);
ilGenerator.Emit(OpCodes.Ret);
// EmitTestの型を生成
typeBuilder.CreateType();
// PersistedAssemblyBuilderからMetadataBuilderを生成
MetadataBuilder metadataBuilder = assemblyBuilder.GenerateMetadata(out BlobBuilder ilStream, out BlobBuilder fieldData);
// PEファイル(DLLファイル)のヘッダーを生成
PEHeaderBuilder peHeaderBuilder = new(imageCharacteristics: Characteristics.ExecutableImage);
// CLR上で動くPEファイルの作成
ManagedPEBuilder peBuilder = new(
header: peHeaderBuilder,
metadataRootBuilder: new MetadataRootBuilder(metadataBuilder),
ilStream: ilStream,
mappedFieldData: fieldData,
entryPoint: MetadataTokens.MethodDefinitionHandle(methodBuilder.MetadataToken));
// PEデータからBlobBuilerへ変換
BlobBuilder peBlob = new();
peBuilder.Serialize(peBlob);
// blobからStreamに書き込み(dllの書き込み)
using (FileStream fileStream = new(Path.Combine(basePath, "EmitTest.dll"), FileMode.Create, FileAccess.Write))
{
peBlob.WriteContentTo(fileStream);
}
// HostWriter.CreateAppHostメソッドのMethodInfoを取得
var CreateAppHost = SearchHostWriterMethodInfo(sdkPath, 9, 0);
// ベースとなるapphostのパスを取得
string packPath = @"C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Host.win-x64";
string apphostPath = Path.Combine(GetVersionDirectory(packPath, 9, 0)!, "runtimes", "win-x64", "native", "apphost.exe");
// exeファイルの書き込み
CreateAppHost.Invoke(null, new object[]{
apphostPath,
Path.Combine(basePath, "EmitTest.exe"),
"EmitTest.dll",false,null,false,false,null });
// runtimeconfig.jsonファイルを生成
File.WriteAllText(Path.Combine(basePath, "EmitTest.runtimeconfig.json"), @"{
""runtimeOptions"": {
""tfm"": ""net9.0"",
""includedFrameworks"": [
{
""name"": ""Microsoft.NETCore.App"",
""version"": ""9.0.0""
}
]
}
}");
}
/// <summary>
/// サブフォルダの名前をVersionとみなし条件を満たす最新のversionの名前のフォルダを取得する
/// </summary>
/// <param name="path"></param>
/// <param name="majorVersion"></param>
/// <param name="minorVersion"></param>
/// <param name="revisionVersion"></param>
/// <param name="buildVersion"></param>
/// <returns></returns>
static string? GetVersionDirectory(string path, int majorVersion ,int minorVersion, int revisionVersion = -1,int buildVersion = -1)
{
DirectoryInfo directoryInfo = new DirectoryInfo(path);
DirectoryInfo[] directories = directoryInfo.GetDirectories();
foreach(DirectoryInfo directory in directories.OrderByDescending(x => new Version(x.Name)))
{
Version version = new Version(directory.Name);
if(version.Major != majorVersion)
{
continue;
}
if(version.Minor != minorVersion)
{
continue ;
}
if(version.Revision != revisionVersion && revisionVersion != -1)
{
continue;
}
if(version.Build !=buildVersion && buildVersion != -1)
{
continue;
}
return directory.FullName;
}
return null;
}
/// <summary>
/// Microsoft.NET.HostModel.dll内のHostWriterクラスのCreateAppHostのメソッドをMethodInfoとして取得
/// </summary>
/// <param name="sdkPath">sdkのあるパス</param>
/// <param name="majorVersion">majorVersion</param>
/// <param name="minorVersion">minorVersion</param>
/// <param name="revisionVersion">revisionVersion</param>
/// <param name="buildVersion">buildVersion</param>
/// <returns></returns>
static MethodInfo SearchHostWriterMethodInfo(string sdkPath,int majorVersion, int minorVersion, int revisionVersion = -1, int buildVersion = -1)
{
string hostModelPath = Path.Combine(GetVersionDirectory(sdkPath,majorVersion, minorVersion, revisionVersion, buildVersion)!, "Microsoft.NET.HostModel.dll");
MethodInfo methodInfo = Assembly.LoadFile(hostModelPath).GetType("Microsoft.NET.HostModel.AppHost.HostWriter")!.GetMethod("CreateAppHost")!;
return methodInfo;
}
/// <summary>
/// dllをコピーするメソッド
/// </summary>
/// <param name="sourceDir">コピー元directory</param>
/// <param name="destinationDir">コピー元先</param>
/// <param name="recursive">サブフォルダを含めるか</param>
/// <exception cref="DirectoryNotFoundException"></exception>
static void CopyDirectory(string sourceDir, string destinationDir, bool recursive)
{
var dir = new DirectoryInfo(sourceDir);
if (!dir.Exists)
throw new DirectoryNotFoundException($"Source directory not found: {dir.FullName}");
DirectoryInfo[] dirs = dir.GetDirectories();
Directory.CreateDirectory(destinationDir);
foreach (FileInfo file in dir.GetFiles())
{
string targetFilePath = Path.Combine(destinationDir, file.Name);
if(file.Extension == ".dll"|| file.Extension == ".exe")
{
file.CopyTo(targetFilePath,true);
}
}
if (recursive)
{
foreach (DirectoryInfo subDir in dirs)
{
string newDestinationDir = Path.Combine(destinationDir, subDir.Name);
CopyDirectory(subDir.FullName, newDestinationDir, true);
}
}
}
}
}
解説
基本はコメントの通りなのですが一部注意点があるので解説していきます。
フレームワーク依存にする方法
以下のコピー系の処理の部分ですが、これは自己完結のアプリケーションを作成するための記述です。
// dllをコピー
CopyDirectory(referencePath, basePath, true);
// hostfxrをコピー
string fxrDirPath = GetVersionDirectory(@"C:\Program Files\dotnet\host\fxr", 9, 0)!;
CopyDirectory(fxrDirPath, basePath, true);
// dllなどをコピーしたのでコピー先のdllを参照するように指定する
referencePath = basePath;
自己完結ではなくフレームワーク依存にする場合は不要です。
また、フレームワーク依存にする場合[アプリケーション名].runtimeconfig.json
を書き換える必要があります。
[アプリケーション名].runtimeconfig.json
でフレームワークの構成などを設定しています。
先ほどのプログラムは以下のように記述されいましたが、
File.WriteAllText(Path.Combine(basePath, "EmitTest.runtimeconfig.json"), @"{
""runtimeOptions"": {
""tfm"": ""net9.0"",
""includedFrameworks"": [
{
""name"": ""Microsoft.NETCore.App"",
""version"": ""9.0.0""
}
]
}
}");
以下のように書き換えることでフレームワーク依存に変更できます。
File.WriteAllText(Path.Combine(basePath, "EmitTest.runtimeconfig.json"), @"{
""runtimeOptions"": {
""tfm"": ""net9.0"",
""framework"": {
""name"": ""Microsoft.NETCore.App"",
""version"": ""9.0.0""
}
}")
dllの指定
以下のような記述がありますが、MetadataLoadContext
を使用している理由は実際にdll内のメソッドを実行するわけではなく型やメソッドなどの情報を取得したいだけであるためAssembly.LoadFile
などを使用していません。
PathAssemblyResolver resolver = new(Directory.GetFiles(referencePath, "*.dll"));
using MetadataLoadContext context = new(resolver);
Assembly coreAssembly = context.CoreAssembly!;
Typeの指定
Type voidType = coreAssembly.GetType(typeof(void).FullName!)!;
Type objectType = coreAssembly.GetType(typeof(object).FullName!)!;
Type stringType = coreAssembly.GetType(typeof(string).FullName!)!;
Type stringArrayType = coreAssembly.GetType(typeof(string[]).FullName!)!;
Type consoleType = coreAssembly.GetType(typeof(Console).FullName!)!;
以上のような記述がありますが以下のように書いてはだめかという疑問があると思います
Type voidType = typeof(void);
Type objectType = typeof(object);
Type stringType = typeof(string);
Type stringArrayType = typeof(string[]);
Type consoleType = typeof(Console);
しかしながらこれでは問題があります。
理由としては実行時に参照しているdllが異なることになってしまうのでcoreAssembly変数から取得する必要があります。
HostModel.CreateAppHost
以下のようなコードがあります。
.NET Core系ではマネージドコードは.dll
に書き込みネイティブの.exeがランタイムを実行するようになっています。
そのexeを生成するためにはHostWriter.CreateAppHostメソッドで生成する必要がありますが、静的に参照を追加してしまうとデバイスによってフレームワークのバージョンが異なってしまう可能性があるので、MethodInfoを取得して呼び出しています。
// HostWriter.CreateAppHostメソッドのMethodInfoを取得
var CreateAppHost = SearchHostWriterMethodInfo(sdkPath, 9, 0);
// ベースとなるapphostのパスを取得
string packPath = @"C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Host.win-x64";
string apphostPath = Path.Combine(GetVersionDirectory(packPath, 9, 0)!, "runtimes", "win-x64", "native", "apphost.exe");;
また、apphostPathは[アプリケーション名].exe
のベースとなるapphost.exe
のパスを指定しています。
詳しくはこちらの記事がわかりやすいかと思います。
まとめ
System.Reflection.Emitで実行ファイルを生成する方法についてまとめてみました。
.net Frameworkのに比べて複雑になっている部分も多くありますが、それ以上に細かい部分で指定できるようになっているのはよい点かなと思います。
ただ、標準実装の記述を見つけることが難しいの問題があるのではないかなと思います。
質問事項や間違っている点がありましたらコメントいただければ幸いです。
また、今回Windows x64のみの出力となっております。Mac,Linuxでの対応方法は不明ですので、わかる方がいたら教えていただきたく思います。
GitHubに今回のプログラムを置いているので興味のある方はご確認いただければ思います。
参考文献
[1] Unhandled exception: System.IO.FileNotFoundException: The file or assembly "System.Private.CoreLib" could not be found in custom generated assmbly
[2] HostWriter.cs
[3] PersistedAssemblyBuilder クラス
[4] .NET6 が起動するまでのコードを追ってみよう