[C#の黒魔術 IL Weaving] Unity で AOP(アスペクト指向プログラミング) をしよう
お久しぶりです。Aiming の土井です。
今回は、Unity でアスペクト指向プログラミングというテーマでブログを書きたいと思います。
アスペクト指向プログラミングとは
ログの出力や、プロファイリングのためのコードは、プログラム全体に広く記述されますが、これらは共通したロジックであるにも関わらず、同じようなコードパターンが関数毎に記述されています。
コードが大きくなっていくと、個別に記述された似たようなコードを一気に変更したり、追加・削除するのが大変になっていきます。また、本来関数が持つべき振る舞いとは無関係なコードが混ざることになるため、コードの見通しも悪くなります。
アスペクト指向プログラミングは、これらプログラム全体の構造とは別に共通して現れるパターンを、横断的な関心として扱い記述できるようにするためのプログラミングパラダイムです。
英語では、Aspect Oriented Programming の頭文字を取って AOP と略されます。
Unity と アスペクト指向
Unity を使っていて普段意識することのないアスペクト指向プログラミング(以下AOP)ですが、AOP を使って実現されているライブラリがいくつか存在します。僕の知っているものだと、以下の SDK が AOP を使って実装されています。
- UNET / MLAPI
- Photon SDK
Unity 公式のネットワークライブラリである UNET やその後継である MLAPI、また、Exit Games 社が提供しているネットワークSDKである Photon は、AOP によって関数を RPC 化したり、プロパティを同期したりしています。
UNET で [ClientRpc]
という Attribute を関数に添えるだけで、ただの関数が RPC として振る舞うようになるのは、まさに AOP の横断的な関心による機能追加を実現したものとなります。
C# における AOP の実現方法
C# では、横断的な関心による機能注入を Attribute によって行うのが一般的なやり方のようです。
しかし、これらのライブラリは「既存の実装を書き換えずに機能を追加する」という魔法のようなことを、どのように実現しているのでしょう。
答えは … 本記事のタイトルにもなっている IL Weaving という手法です。
それでは C# の黒魔術とも言える IL Weaving の世界にもう少し深く潜り込んでいきましょう。
IL Weaving とは
C# で書かれたコードは .net の IL(Intermediate Language) という言語に変換されます。Unity Editor 上で C# のプログラムを書くとコンパイルが走り、IL に変換されたプログラムが、Library/ScriptAssemblies/Assembly-CSharp.dll というファイルに書き込まれます。
「この IL に変換されたバイナリファイルを、後から書き換えてしまおう」 というのが IL Weaving です。
なんと強引なやり方でしょう、正直、ドン引きです!
英語の Weave には「織る」「編む」といった意味があります。記述されたコードを縦糸とするなら、後から横糸となるコードを織り込んでいく様子をイメージするとわかりやすいですね。dll の奥の方から中島みゆきさんの歌声が聴こえてきたのではないでしょうか。
では、実際に IL Weaving してみましょう。幸い、Mono.Cecil という dll を直接書き換えるためのライブラリがあるので、これを使っていきます。
IL Weaving を行うプログラムと、書き換えられる側のプログラム。
2つのシンプルなコンソールプログラムを作っていきます。
IL Weaving を行うプログラムの方には、NuGet などで Mono.Cecil をインストールしておきます。
IL Weaving により書き換えられるプログラム
public class Test { public static void Main() { new Test().DoSomething(); } public void DoSomething() { } }
Do Something とか言っておきながら何もしない、口だけの Test さん。
この子には、少し世間の厳しさを叩き込んであげる必要がありそうですね。
口だけ Test さんには、しばらく正座で待っていてもらいましょう。
このプログラムはビルドすると Test.dll というファイルを出力するとします。
IL Weaving をするプログラム
using System; using System.IO; using System.Linq; using Mono.Cecil; using Mono.Cecil.Cil; using Mono.Cecil.Pdb; internal static class Program { private static void Main(string[] args) { InjectCodeSample(); } private static void InjectCodeSample() { const string dllPath = @"Path\To\Test.dll"; // Test.dll を書き込みモードで開く using (var assemblyStream = new FileStream(dllPath, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite)) { // Test.dll のモジュール定義を読み込む using (var moduleDefinition = ModuleDefinition.ReadModule(assemblyStream, new ReaderParameters { ReadingMode = ReadingMode.Immediate, ReadWrite = true, // dll を書き換える場合は true ReadSymbols = true, SymbolReaderProvider = new PdbReaderProvider() })) { foreach (var typeDefinition in moduleDefinition.Types) foreach (var methodDefinition in typeDefinition.Methods) { // DoSomething という名前をもつメソッドが見つかったらコードを書き換える if (methodDefinition.Name == "DoSomething") Inject(moduleDefinition, methodDefinition); } // 書き換えられた dll を上書き保存する moduleDefinition.Write(new WriterParameters() { WriteSymbols = true, SymbolWriterProvider = new PdbWriterProvider() }); } } } // IL を変更する private static void Inject(ModuleDefinition moduleDefinition, MethodDefinition methodDefinition) { var processor = methodDefinition.Body.GetILProcessor(); var injectionPoint = methodDefinition.Body.Instructions.First(); var methodInfo = ((Action<string>)Console.WriteLine).Method; var methodRef = moduleDefinition.ImportReference(methodInfo); // 以下、 Console.WriteLine("Brain hacked! Thank you!") をメソッドの先頭に足す処理 processor.InsertBefore(injectionPoint, Instruction.Create(OpCodes.Ldstr, "Brain hacked! Thank you!")); processor.InsertBefore(injectionPoint, Instruction.Create(OpCodes.Call, methodRef)); } }
ちょっと長いです。ざっくりと
- dll を読み込む
- dll に含まれる型・関数の定義を調べ上げて DoSomething という名前のメソッドを探す
- Inject() メソッドで、関数の先頭に
Console.WriteLine("Brain hacked! Thank you!")
というコードを書き加える - 書き換えられた内容で dll を上書き保存する
こんな流れで処理を行っています。
processor.InsertBefore(injectionPoint, Instruction.Create(OpCodes.Ldstr, "Brain hacked! Thank you!")); processor.InsertBefore(injectionPoint, Instruction.Create(OpCodes.Call, methodRef));
この部分が生の IL 命令を書き加える処理になります。一見難しそうですが、C# で注入したいコードを書いてから、ILSpy や、Rider(C#のIDEです)の IL Viewer などを使って、生の IL を確認するとわかりやすいと思います。
実際に Console.WriteLine("Brain hacked! Thank you!")
を IL Viewer で確認すると以下のような IL のコードが確認できます。
IL_0001: ldstr "Brain hacked! Thank you!" IL_0006: call void [System.Console]System.Console::WriteLine(string)
IL Weaving を行ったあと、Test.dll を実行してみると、
> Brain hacked! Thank you!
とコンソールに表示されました。
Do Something とか言いながら何もしない口だけの Test さんが、白目で喜んでいらっしゃる様子が確認できました。
更生大成功です!
Unity で IL Weaving をする
IL の書き換え方がわかったところで、実際に Unity のプログラムでも IL Weaving をしていきたいのですが、これが、なかなか一筋縄にはいきません。
Unity Editor 実行中は基本的にすべての dll をロードした状態なので、特定のタイミングでしか dll の上書きができないという問題にぶち当たります。
また、Editor 上で行われるビルドと、アプリケーションのビルド時に生成される dll の場所が異なるなど、IL Weaving できるタイミングと場所を注意深く確認しないとなりません。
そこで、Unity で IL Weaving をお手軽にできるライブラリ MewWeaver を作りました。
MewWeaver のインストール
Unity Package Manager の Add package from git URL… メニューにて https://github.com/mewlist/MewWeaver.git?path=Assets/MewWeaver を指定することでパッケージとしてプロジェクトに追加されます。
インジェクトするコードをつくる
IL Weaving によって呼び出したい処理を作ります。メソッドは static でなければなりません。
using System; using UnityEngine; // インジェクトするコードをつくる public class CodeToInject { public static void SomethingDo() { Debug.Log("Injected!"); } }
IL Weaving するメソッドを指定するための Attribute を作る
ここでは、 [InjectCode] という Attribute が指定されたメソッドを書き換えるようにしたいので、InjectCodeAttribute を作ります。
// インジェクションポイントを指定するための Attribute を作る [AttributeUsage(AttributeTargets.Method)] public class InjectCodeAttribute : Attribute { public InjectCodeAttribute() { } }
IL Weaving のルールを定義する
最後に、ルールを定義します。このクラスは Editor フォルダ以下に定義する必要があります。IWeaver を必ず継承してください。
using Mewlist.Weaver; // IL Weaver を定義する public class SampleWeaver : IWeaver { public void Weave(AssemblyInjector assemblyInjector) { assemblyInjector .OnMainAssembly() .OnAttribute<InjectCodeAttribute>() .BeforeDo(CodeToInject.SomethingDo) .Inject(); } }
定義を説明をすると、
.OnAttribute<InjectCodeAttribute>()
.BeforeDo(CodeToInject.SomethingDo)
.Inject()
こんな内容になっています。
Attribute を指定する
既存のコードに [InjectCode] Attribute を加えます。ここでは、適当な MonoBehaviour の Start メソッドに指定してみます。
このコンポーネントをシーンに配置して Start メソッドが呼び出されるようにします。
using UnityEngine; public class MyBehaviour : MonoBehaviour { [InjectCode] void Start() { } }
実行する
実行すると…あら不思議。ロジックの一切ない Start メソッドですが、コンソールに Injected! と表示されました。
これで、「メソッドのロジックを変更せずに実行時間の計測がしたい!」「ロジックはそのままで、デバッグ実行ログを取りたい!」といった、わがままに応えることができそうですね!
もうちょっと面白い応用は無いかと考えたんですが、僕にはあまり思いつかないので、面白い使い方があったらぜひシェアしてもらえると嬉しいです!
エンジニア積極採用中
TWILO(第一事業部) では、エンジニアを積極採用中です!
Unity、C# が大好きなエンジニア仲間たちに囲まれながらともに成長していきましょう!
詳しくは採用ページと事業部紹介ページをご覧ください!
■採用ページ
https://recruit.aiming-inc.com/career/
■事業部紹介ページ
https://recruit.aiming-inc.com/twilo/
-
前の記事
仕様を書く上での心掛け 2022.09.04
-
次の記事
TGS参加しました! 2022.10.11