nuits.jp blog

C#, Xamarin, WPFを中心に書いています。Microsoft MVP for Development Technologies。

XamarinでもAOPしたい! Mono.Cecil編

さて前回、Cauldron.Interception.Fodyを利用すればXamarinでもAOPできるんだけど、仕様が好みじゃないので作るしかないな!というお話をしました。

IL弄ってアスペクトを織り込むアドインを作成するわけですが、ILを弄ると言うと黒魔術のように感じるかもしれません。私自身そう思っていました。
しかし今回実際に触ってみて思ったのですが、技術的な難易度的には黒魔術というほどでもないかな?と、感じることができました。これはもちろん、ILを編集するための基盤を過去の偉人が構築してくれていたことも大きいです。
ただ通常の開発手段として気軽に使うべきではないという意味では、いかに容易に扱えるフレームワークがあると言っても黒魔術であることは変わりないでしょう。

という訳で、今回から2回にわたってXamarinで静的AOPを実装するためのHello, Worldレベルの解説をしたいと思います。
これらのエントリーでILを操作するする雰囲気をつかんでいただけたらと思います。
それでは本題に入りましょう。

静的AOPのアプローチ

通常の実行時にReflection.Emitなどを使って動的コード生成を利用するAOPに対して、アプリケーションの実行前、コンパイル時にアスペクトを織り込んでおくアプローチのため、仮に静的AOPと呼ぶこととします。異論は受け付けますというか、適切な名称があるのであれば指摘ください。

さてこれまで、PropertyChanged.FodyやCauldron.Interception.Fodyを紹介してきましたが、実のところ静的なAOPを実現するためのコアテクノロジーは二つあります。

  1. Fody
  2. Mono.Cecil(実際には4種類程のNuGetパッケージに分割されています)

このうち、実際にILの編集を行う機能を提供するのはMono.Cecilになります。
FodyはMono.Cecilを利用してILを編集する機能を実装したアドインを、MSBuildと協調してコンパイル時に制御することで、静的AOPを容易に活用する仕組みを提供するものになります。

全2回による解説の概略

という訳で、静的AOPのHello, World.を解説するにあたり、今回と次回の2回にわたって次のような手順で解説していきます。

今回:Mono.Cecilを利用し既存アセンブリに任意のILを織り込む方法の解説
次回:Mono.Cecilで作成したAOPアドインをFodyを使って適用する方法の解説

今回のゴール

ビルド済みのアセンブリ内の、指定のクラスの指定のメソッド呼び出し時に、ログ出力するコードを織り込む。
今回は.NET Standardではなく、すべて.NET Frameworkで実装します。

参考情報

さて私自身、先にも書いた通りIL読んだり書いたりするのは今回が初めてで、ぶっちゃけちゃんと説明できません。
という訳で、参考にさせてもらった文献を紹介しておきたいと思います。

手順概略

ILを編集してログ出力するにあたり、今回は次の手順で進めます。

  1. IL編集前のコードを実装し、ILSpyを利用してILを確認する
  2. ログ出力コードをC#で普通埋め込み、ILSpyを利用して差分を解析する
  3. 既存アセンブリにログを出力するILを埋め込むコンソールアプリを作成する
  4. 3.で編集したアセンブリを実行し動作を確認する
  5. 3.で編集したアセンブリをILSpyを利用して確認する

IL編集前のコードを実装する

それでは実際に始めましょう。まずはILを編集対象のプロジェクトを作成しましょう。
WeaveTargetという名称のコンソールプロジェクトを作成します。

f:id:nuitsjp:20171211132308p:plain

そしてProgram.csを開いて次のようなコードを記載しビルドします。

class Program
{
    static void Main(string[] args)
    {
        SayHello();
        Console.ReadLine();
    }

    private static void SayHello()
    {
        Console.WriteLine("Hello, AOP!");
    }
}

このコードをビルドしてILを覗いてみます。覗くのにはILSpyを利用します。
ILSpyを開くと次のような画面が開かれます。

f:id:nuitsjp:20171211132914p:plain

ここに先にビルドしたWeaveTarget.exeをドラッグ&ドロップし、開かれた中から前述のSayHelloメソッドを覗いて見ましょう。

f:id:nuitsjp:20171211230510p:plain

デフォルトではILから逆コンパイルされたC#のコードが表示されますが、今回見たいのはILです。という訳でドロップダウンをC#からILに切り替えてILを確認して見ましょう。

f:id:nuitsjp:20171211230523p:plain

抜粋したILのコードが次の通りです。

IL_0000: nop
IL_0001: ldstr "Hello, AOP!"
IL_0006: call void [mscorlib]System.Console::WriteLine(string)
IL_000b: nop
IL_000c: ret

さて、今回はILの読み方そのものは解説しません。興味ある方は次の二つを参考にされると理解が進むでしょう。

ログ出力コードをC#で普通埋め込み、ILSpyを利用して差分を解析する

さて、先ほどのSayHelloメソッドをもう一度見て見ましょう。

private static void SayHello()
{
    Console.WriteLine("Hello, AOP!");
}

今回のゴールは「指定のクラスの指定のメソッド呼び出し時に、ログ出力するコードを織り込む。」でしたよね?という訳でSayHello()メソッド編集してつぎのコードと同義のコードを生成することを目指します。
が、ひとまずは実際のコードを修正してログ出力コードを埋め込んでビルドし、ILがどう変わるか確認してみます。

private static void SayHello()
{
    Console.WriteLine("Program#SayHello()");
    Console.WriteLine("Hello, AOP!");
}

そして再びビルドしたアセンブリをILSpyで覗いてみましょう。ILは次のように変わっています。

IL_0000: nop
IL_0001: ldstr "Program#SayHello()"
IL_0006: call void [mscorlib]System.Console::WriteLine(string)
IL_000b: nop
IL_000c: ldstr "Hello, Fody!"
IL_0011: call void [mscorlib]System.Console::WriteLine(string)
IL_0016: nop
IL_0017: ret

IL_0000、IL_0001、IL_0006が新たに追加されており、IL_000b以降は変化がありません。
つまり、既存のILに対してIL_0000〜IL_0006の内容を挿入してあげれば、同等の振る舞いが追加できるはずだ、と言うことになります。

ILを自力で記述する場合は、このように

  1. 実現したいコードを書いて見てそのILをチェックする
  2. ILを吐き出すコードを記載する
  3. 吐き出されたILをチェックし、また逆アセンブルしてC#コードをチェクする

これを繰り返して行くのが一般的な開発スタイルのようです(私も始めたばかりなので間違ってるかもしれませんが)。

さて、ここでログ出力のC#コードはいったん削除してリビルドしておきましょう。

既存アセンブリにログを出力するILを埋め込むコンソールアプリを作成する

それでは実際にILを編集していきます。
まずはILを編集するコンソールアプリのひな型を作成しましょう。

WeaveLogOutputという名前のコンソールアプリを作成し、NuGetからFodyCecilパッケージを適用しましょう。

f:id:nuitsjp:20171211231301p:plain

すると実際にはFodyCecilライブラリの参照は追加されず、Mono.Cecil系のライブラリが4つ参照に追加されます。
FodyCecilは実体がなく、依存するライブラリが定義されているだけです(こういうライブラリを何て言うんでしたっけ?)。

それではここにILを編集するコードを記述していきましょう。 まずは編集対象のアセンブリを読み込み、対象のメソッドを特定します。

static void Main(string[] args)
{
    var path = @"..\..\..\WeaveTarget\bin\Debug";
    var module = ModuleDefinition.ReadModule(Path.Combine(path, "WeaveTarget.exe"));
    var type = module.Types.Single(x => x.Name == "Program");
    var method = type.Methods.Single(x => x.Name == "SayHello");
}

そしてILを編集するProcessorと、SayHelloメソッドの先頭行を取得します。

static void Main(string[] args)
{
    ...
    var processor = method.Body.GetILProcessor();
    var first = method.Body.Instructions.First();
}

ここからfirstの前にログ出力のILを埋め込んでいきます。
改めて織り込むILを確認します。

IL_0000: nop
IL_0001: ldstr "Program#SayHello()"
IL_0006: call void [System]System.Diagnostics.Debug::WriteLine(string)

1行目と2行目は簡単ですね。次のようにしてfirstの前にILを織り込みます。

static void Main(string[] args)
{
    ...
    processor.InsertBefore(first, Instruction.Create(OpCodes.Nop));
    processor.InsertBefore(first, Instruction.Create(OpCodes.Ldstr, $"{type.Name}#{method.Name}()"));
}

3行目は少しだけ面倒です。少しだけね。
ConsoleクラスのWriteLineメソッドのMethodInfoを取得した上でILを挿入してあげます。

static void Main(string[] args)
{
    ...
    var consoleWriteLine = typeof(Console)
        .GetTypeInfo()
        .DeclaredMethods
        .Where(x => x.Name == nameof(Console.WriteLine))
        .Single(x =>
        {
            var parameters = x.GetParameters();
            return parameters.Length == 1 &&
                    parameters[0].ParameterType == typeof(string);
        });
    processor.InsertBefore(first, Instruction.Create(OpCodes.Call, module.ImportReference(consoleWriteLine)));
}

そして最後に編集したモジュールをかき出します。

static void Main(string[] args)
{
    ...
    module.Write("WeavedTarget.exe");
}

読み込んだ編集対象のアセンブリは「WeaveTarget.exe」でしたが、書き出したあとは「WeavedTarget.exe」に名前を変えています。

編集したアセンブリを実行し動作を確認する

それではWeavedTarget.exeを実行してみましょう。

f:id:nuitsjp:20171211232934p:plain

正しくログが織り込まれていることが確認できます。

編集したアセンブリをILSpyを利用して確認する

続いてWeavedTarget.exeをILSpyで覗いてみましょう。

f:id:nuitsjp:20171211233120p:plain

想定通りのILが書き出されていることが見て取れます。C#コードにリバースした結果も見てみましょう。

f:id:nuitsjp:20171211233209p:plain

完璧に想定通りのコードですね。
これで既存のアセンブリへ、ILを織り込むことができました。
今回は手動で織り込みを実行しましたが、次回はFodyを利用して自動的にコンパイル時に織り込まれるようFodyのアドイン化を解説したいと思います。

という訳で、今回はここまで! 近いうちに続きでお会いしましょう!