LoginSignup
17
10

More than 5 years have passed since last update.

XamarinでもAOPしたい! 黒魔術で自作編

Last updated at Posted at 2017-12-25

本エントリーはXamarin その1 Advent Calendar 2017の25日目の記事です。普段はブログに書いてるのですが、なんとなく久しぶりにQiitaに投稿しようかなと。

前日は@Takkiii0204さんの実践!Cocoa Bindingでした。

さて、ブログの方で今月何回かにわたってXamarinとAOPの記事を書いてきました。

やっとなんとか動くものができたので、Xamarinでも動作するAOPフレームワークの作成方法について本稿ではまとめたいと思います。

そもそもAOPとは何か?

さて実際の実装に入る前にAOPとは何か?どんな利点があるのか?を簡単に説明したいと思います。

AOPとはAspect Oriented Programmingの略です。日本語だとアスペクト指向プログラミングと言われています。

似た用語に、みなさん大好きオブジェクト指向プログラミング(OOP)がありますが、「AOPを利用する」ということは、けして「OOPを捨てる」という事ではありません。AOPはオブジェクト指向プログラミングとは直行する概念だと言われていて、オブジェクト指向の苦手な部分を補強する概念になります。

「直行する概念」とはIT関連では稀に良く見かけますが、正直分かりにくい表現だと私は思っています。間違ってるかも知れませんが私は次のように理解しています。

依存関係のない独立した概念だが、お互いを邪魔せず共存が可能な概念のこと

ではAOPとOOPはどう共存できるのでしょうか?

誤解を恐れず書くと、一般的にオブジェクト指向は機能やユースケースという側面で関心事を分離していくことが得意だとAOPではよく言われています。例えば

  • 顧客登録機能
  • 商品検索機能
  • 商品購入機能

といった「機能要件」を実現することに主眼が置かれている、ということです(もちろん、それだけだと限定するものではありません)。 しかし、これらの機能間には横断的な関心事がありますよね?

  • トランザクション制御
  • ロギング処理
  • 認証処理
  • 例外処理

おもに非機能要件とされる関心事が多めです。 これらの複数の機能から横断的に必要とされる関心事を「アスペクト」としてとらえ、分離・記述し、必要とされる機能へ織り込む(Weaving)ことで機能に対して非機能を付与する考え方が、アスペクト指向プログラミングです。

例えば、アプリケーションを次のような3層アーキテクチャで実装するとします。

ss02-0031.png

こうした場合、データベースのトランザクション処理をModelの入り口に実装するとした場合、例えば商品検索のメソッドは次のような実装になるでしょう。

public IList<Item> SearchItems()
{
    using (var connection = CreateConnection())
    {
        connection.Open();
        using (var transaction = connection.BeginTransaction())
        {
            try
            {
                // ビジネスロジック
                ...
            }
            catch (Exception)
            {
                transaction.Rollback();
                throw;
            }
            transaction.Commit()
        }
    }
}

「ビジネスロジック」と書かれた部分以外がトランザクション制御用のロジックになります。問題はこれらの「ビジネスロジック以外のコード」が、Model層のpublicメソッドの多くに重複して発生するという事です。

そしてこの問題をOOPで解決する場合、例えばModelのpublicメソッドをすべてCommandパターンで実装するといった、大幅な設計変更が必要になります。

そこで登場するのがアスペクト指向プログラミングです。

トランザクション処理などの、複数の「機能」で必要とされる「横断的な関心事」をアスペクトとして分離してやり、「機能」に対してアスペクトを織り込む(Weaving)することで「個別の関心事」と「横断的な関心事」を、重複コードを最低限に両立を実現しようというのがアスペクト指向プログラミングです。

具体的には次のようなイメージです。

ss02-0032.png

クラス間のメソッド呼び出しをインターセプトして、横断的な関心事を実現するアスペクトを適用するイメージです。

先ほどのコードをAOPで実装した場合、次のようなコードになるでしょう(AOPフレームワークによって実際のコードは異なります)。

[Intercept(typeof(ManageTransaction))]
public IList<Item> SearchItems()
{
    // ビジネスロジック
    ...
}

非常にシンプルですね。

もちろんこれらはOOPでは実現できないという訳ではありません。OOPとAOPを組み合わせることで、よりシンプルに実現できるということです。

XamarinにおけるAOPの課題

さてAOPの魅力は理解いただけたかとおもいます。しかしXamarinの世界ではAOPを実現するにあたって一つの大きな課題が存在します。

それは.NETのAOPフレームワークの多くはReflection.Emitなどを利用した、動的コード生成技術に依存しているという事です。

ご存知の通り(?)iOSでは動的コード生成が禁止されており、当然Xamarin.iOSでもReflection.Emitなどを利用した動的コード生成系のコードは動きません。つまり多くのAOP関連フレームワークがXamarinでは動作しない事になります。

ではXamarinではAOPは諦めないといけないのか?

もちろん、そんな事はありません。動的コード生成ができないなら、静的コード生成を活用して配備前に解決してあげれば問題ないのです。AOPではアスペクトを挿入する個所や、そこに挿入されるアスペクトの種類は、実行時に変動しないことが多いため、配備前の静的コード生成で十分に実現が可能になります。黒魔術(IL生成)の出番ですね!

さて黒魔術といっても何も自力ですべてやる必要はなく、そららをサポートしてくれる魔法の杖と魔術書が世の中には存在します。それがMono.CecilとFodyです。

  1. Mono.Cecil
  2. Fody

Mono.CecilはMonoとついていますが、Mono上だけではなく.NET Frameworkや.NET Core上でも動作する汎用的なIL操作ライブラリです。FodyはMono.Cecilを利用して実装されたIL操作コードをビルド時に透過的?に適用するフレームワークです。

これらを利用するとビルド時にILを操作するアドインを「比較的」簡単に実現することができます。

ところで、これらを利用したXamarinでも動作するAOPフレームワークが実は既に存在します。それがCauldron.Interception.Fodyです。あれ?黒魔術いらなくて?と思いますよね。はい(自力では)必要ありません。

ただCauldron.Interception.Fodyを利用する場合、Interceptorの実装が少々好みと異なります。Cauldron.Interception.FodyでInterceptorを実装する場合、IMethodInterceptorを実装して次のように作成します。

public class InterceptorAttribute : Attribute, IMethodInterceptor
{
    public void OnEnter(Type declaringType, object instance, MethodBase methodbase, object[] values)
    {
    }

    public void OnExit()
    {
    }

    public void OnException(Exception e)
    {
    }
}

これはつまり、Interceptorで例外をキャッチして処理することや、複数用途のInterceptorを用意しておいて、適用個所によって切り替えるといった仕組みがフレームワーク側で提供されていない事を意味します。

個人的にはAOPフレームワークは次のような仕様が好みです。

  1. インターセプターはChain of Responsibility的に複数のインターセプターを連結して適用できる仕組みが欲しい
  2. インターセプトはCastle.CoreのDynamicProxyのような仕組みでインターセプトしたい
  3. 例外をキャッチして後処理したら上には投げないみたいなこともしたい

ちなみに2.については、次のように感じに実装したいのです。

ublic class Interceptor : IInterceptor
{
    public void Intercept(IInvocation invocation)
    {
        using(var connection = new SQLConnection(...))
        {
            connection.Open();
            invocation.Proceed();
        }
    }
}

connectionを、織り込んだ先にどう渡すのか?という解決の容易な課題はありますが、このようにインターセプターでトランザクション管理をして、ちゃんとusing句で記載したいなという思いがあります。
もちろん前処理と後処理を別々のメソッドで呼び出されてもできないわけじゃないんですが、少なくとも単機能のインターセプターを組み合わせて、複数の非機能要件を実現するようなことはCauldron.Interception.Fodyではできません。また3.にも記載したように例外を握りつぶすような実装もできません。

好みのがないってことは、じゃぁ作るしかないですよね!

黒魔術を始める前に

さぁ、Mono.CecilとFodyを使って黒魔術を早速唱えましょう。

と言いたいところなのですが、現在世の中は.NET Standardへの移行の端境期真っただ中です。XamarinもMono.CecilもFodyも同様です。特にFodyはどうにも.NET Standard適用の真っ最中で、現在動くモジュールを作るには、だいぶ怪しい手段をとる必要があります。これは近いうちに変更される気がしますし、そうなったら本稿の手順では動かなくなるかもしれません。

と思っていたら、まさにアップデートがかかって、最新版では動作しなくなってしまいました…orz

最新版での構築方法がまだ不明なため、本稿ではつぎのバージョンに則って実装します。最新の手段が判明したら本稿もアップデートしようと思います。

  • Mono.Cecil - NuGetからFodyCecil 2.2.1を適用し包含されるバージョンを利用する
  • Fody 2.2.1

ちなみに最新は3.5.xで、まさに書いている2時間前にもアップデートされました…なんてこったい。

モジュール設計

さて作成するライブラリですが、Fodyのアドインとして作成します。Fodyでは作成するアドインはすべて「~.Fody」という名称で作成する必要があります。今回は

Nuits.Interception.Fody

という名称で作成したいと思います。

Fodyのアドインを作成する場合、2種類のモジュールに分割して実装する必要があります。今回の場合

  • Nuits.Interception.Fody
  • Nuits.Interception

の二つに分割します。モジュールの依存関係は次のようになります。

ss02-0033.png

Nuits.Interception.Fodyには実際にMono.Cecilを利用してILを操作するコードを実装します。このコードはアスペクトを適用するアプリケーション(MyApplication)からは直接利用されることはありません。Nuits.Interception.FodyがMyApplicationのILを操作してアスペクトを織り込みます。

Nuits.Interceptionはアスペクトを適用するアプリケーションと、Nuits.Interception.Fodyの両者から参照されるクラス群を格納します。今回の場合、Interceptorのインターフェースや、編み込むアスペクトを指定するためのAttributeクラスなどが含まれます。

順番に見ていきましょう。

Nuits.Interception

Nuits.Interceptionは.NET Framework 1.3で実装します。下限はMono.Cecilに依存して決定していますが、最新版は2.0以上である必要があります。Nuits.Interceptionにはつぎのクラス・インターフェースが含まれます。

名称 役割
IInterceptor アスペクトを実装するためのインターフェース。メソッド呼び出しをインターセプトするクラスを実装するためのインターフェース
IInvocation インターセプトしたメソッド呼び出しを表すクラス。メソッドの引数オブジェクトや、処理中のアスペクトの状態管理を行う
InterceptAttribute アスペクトを織り込む対象を明示するためのインターフェース
Invocation IInvocationを実装した抽象クラス。具象クラスはアスペクトを織り込む対象メソッド別に動的生成する。

Nuits.Interception.Fody

Nuits.Interception.Fodyも同様の理由で.NET Framework 1.3で実装します。

Nuits.Interception.Fodyには、namespace未指定(ルート?)でModuleWeaverというクラスを作成する必要があります。これはFodyがアドインを実行する際、「~.Fody」アセンブリ内のModuleWeaverという名称のクラスを取得して実行します。ちょっと命名規則が釈然としない部分がありますが、そういう物だと納得するしかありません。

実際にILを操作するコードは、この場で説明することは困難なためGithubに公開していますので見てください。と言いたいところですが、ぶっちゃけやっと動いただけで推敲もしていないためまだ見ないで欲しいなあなんて…

実装する際は、実装後の想定コードをC#で書いてコンパイルし、コンパイル結果をILSpyなどで確認しつつ、それを再現するIL生成コードを作成することになります。もう少しだけ詳しい手順をこちらに公開しています。

そもそも現在は既存コードを直接書き換えてアスペクトの織り込みを実現していますが、プロキシ型の設計に変更する気がしています。これは、デバッグしたときにメソッド名が呼び出し履歴画面で別名に見えてしまう問題や、同一モジュール内での呼び出しが上手く動かない問題なんかがあるためです。なので現状のコードは参考程度に収めてほしいなと思います。

さてNuits.Interception.FodyはNuGetパッケージ化する必要があります。これはFodyがどうやらFodyのフォルダと相対的にアドインを探しに行くようで、FodyをNuGetから当てる以上アドインもNuGetパッケージにして適用する必要があります。ただし、別にNuGetサービスに公開する必要はなく、ローカルNuGetサーバーやファイルシステム上から適用することも可能です。NuGetパッケージを作成し、それを何らかの方法で適用すれば十分です。

問題はこのNuGetパッケージの中身の構造です。

NuGetパッケージにはNuits.Interception.Fody.dllとNuits.Interception.dllの二つを内包する必要があります。通常、.NET Standard 1.3のライブラリであれば、次のようなフォルダ階層に格納する必要があります。

Root
└lib
 └netstandard1.3
  ├Nuits.Interception.dll
  └Nuits.Interception.Fody.dll

しかし2.2.1のFodyでは上記の通り正しく格納すると動作しません。Fodyで動作するように構築するためには次のようなフォルダ階層で格納する必要があります。

Root
├Nuits.Interception.dll
├Nuits.Interception.Fody.dll
└lib
 └netstandard1.3
  └Nuits.Interception.dll

Fodyがビルド時に動作する場合に、NuGetパッケージの直下にモジュールがあると想定して動作しているため、ビルド時用のモジュールをRoot直下に、アプリケーション動作時に参照するモジュール用として lib/netstandard1.3 の下に上記の通り格納する必要があります。

ぶっちゃけ.NET Standard対応のNuGetパッケージの仕様を満たしていない気がしますが、時間が解決してくれる気がします。ただ今(2.2.1時点で)は、こうじゃないと動作しません。

MyApplication

さぁ、対象のアドインを適用してみましょう。

今回は次のようにアスペクトを織り込んでみます。

  1. 織り込み対象はViewModelからModelを呼び出すメソッドすべて
  2. ModelはView・ViewModelとは別のプロジェクトとして実装する

こんな感じです。

ss02-0035.png

ちなみに2.はよく有りがちな設計です。けして、今のところ作ったモジュールが同一のアセンブリ内の呼び出しだと正しく動作しないことをごまかしている訳ではありません。けして。

作成するアプリケーションは次のような単純な足し算を行うアプリケーションです。

ss02-0036.png

まずはアスペクトはなしに動作するアプリケーションを作成します。

ViewとViewModelに二つの値をもち、Model層のCalculatorクラスを呼び出して足し算を行います。ViewModelのコードの抜粋が次の通りです。

    public class MainPageViewModel : INotifyPropertyChanged
    {
        private readonly Calculator _calculator = new Calculator();

        // 他フィールドとプロパティの定義
        ...

        public ICommand AddCommand => new Command(Add);

        private void Add()
        {
            Result = _calculator.Add(Value1, Value2);
        }
        ...
    }

そしてModel層のCalculatorクラスが次の通りです。

public class Calculator
{
    public int Add(int left, int right)
    {
        return left + right;
    }
}

CalculatorクラスのAddメソッドに呼び出し引数と戻り値をDebug出力するアスペクトを織り込んでいきます。

まずはNuGetから先ほど作成した「Nuits.Interception.Fody.dll」をInterceptionApp.Modelsに適用します。アスペクトを織り込んむ対象のプロジェクトにだけ適用してください。

適用したら、続いてModelsプロジェクトの直下に「FodyWeavers.xml」という名称でFodyの設定ファイルを作成します。

ss02-0037.png

「FodyWeavers.xml」には、つぎのように適用対象のFodyプラグインを宣言します。

<?xml version="1.0" encoding="utf-8" ?>
<Weavers>
  <Nuits.Interception/>
</Weavers>

モジュールの名称から.Fodyを削除したものを設定ファイルに記述するというか、設定ファイルに記述したアドインの名称+.Fodyのモジュールを探すというか、とにかくそんな命名規則に則る必要があります。

続いて、実際に適用するアスペクトを実装します。今回はLoggingInterceptorという名称で作成します。LoggingInterceptorは次のように実装しましょう。

public class LoggingInterceptor : IInterceptor
{
    public object Intercept(IInvocation invocation)
    {
        Debug.Write($"Debug:{invocation.MethodBase.DeclaringType.FullName}#{invocation.MethodBase.Name}() Arguments:{string.Join(", ",invocation.Arguments)}");

        object result;
        try
        {
            result = invocation.Invoke();
        }
        catch (Exception e)
        {
            Debug.Write($"Debug:{invocation.MethodBase.DeclaringType.FullName}#{invocation.MethodBase.Name}() Exception:{e} Message:{e.Message} StackTrace:{e.StackTrace}");
            throw;
        }
        Debug.Write($"Debug:{invocation.MethodBase.DeclaringType.FullName}#{invocation.MethodBase.Name}() Return:{result}");

        return result;
    }
}

これでメソッド呼び出し時に、引数と結果または例外をDebug出力するアスペクト(Interceptor)が完成しました。

つぎはModelにアスペクトを織り込みます。Calculatorのコードを見てみましょう。

public class Calculator
{
    [Intercept(typeof(LoggingInterceptor))]
    public int Add(int left, int right)
    {
        return left + right;
    }
}

InterceptAttributeをアスペクトを織り込みたいメソッドに定義し、そのコンストラクタで適用するInterceptor(ここではLoggingInterceptor)のTypeを設定しています。これでアスペクトの織り込みも完了しました。

これを実行すると、デバッグコンソールに次のように出力されます。

Debug:InterceptionApp.Models.Calculator#Add() Arguments:1, 2
Debug:InterceptionApp.Models.Calculator#Add() Return:3

という訳で、ログ出力するアスペクトの織り込みに成功しました。

ILの操作内容

さて、動いたのは良いのですが実際にどのようなIL操作をしているのでしょうか?ILを書いても「呪文」にしかなりませんので、等価となるC#のコードで、編み込み前と後を例示して、簡単に説明してみたいと思います。

編集対象はCalculatorクラスです。

public class Calculator
{
    [Intercept(typeof(LoggingInterceptor))]
    public int Add(int left, int right)
    {
        return left + right;
    }
}

ここにInterceptorを編み込んでいきます。まず、元のメソッド名をAddInnerに変更し、publicからprivateに変更します(今回の実装は同一モジュール内で正しく動かないのですがこれのせいな気がします...)。こんな感じに変更します。

public class Calculator
{
    [Intercept(typeof(LoggingInterceptor))]
    private int AddInner(int left, int right)
    {
        return left + right;
    }
}

つづいて、AddInnnerを呼び出すInvocationクラスのサブクラスを内部クラスとして作成します。

public class Calculator
{
    ...

    private class AddInnerInvocation : Invocation
    {
        internal Class1 Class1;
        internal int Value1;
        internal int Value2;

        public override object[] Arguments => new object[] { Value1, Value2 };

        internal AddInnerInvocation(Type[] interceptorTypes) : base(interceptorTypes)
        {
        }

        protected override object InvokeEndpoint()
        {
            return Class1.Add2Inner(Value1, Value2);
        }
    }
}

新しく作成したAddInnerInvocationクラスには、Calculatorクラスのインスタンスと、Addメソッドの引数をフィールドに持つInvocationクラスのサブクラスです。

Interceptorを全て実行した後、InvokeEndpointメソッドが呼び出され、元々の実装であるAddInnerメソッドを呼び出し処理結果を返却します。

そしてこれらを利用して全体の制御を行う、新しいAddメソッドを作成します。

public class Calculator
{
    public int Add(int value1, int value2)
    {
        var type = typeof(Class1);
        var methodInfo = type.GetMethod("Add2Inner");
        var interceptorAttribute = methodInfo.GetCustomAttribute<InterceptAttribute>();
        var invocation = new AddInnerInvocation(interceptorAttribute.InterceptorTypes)
        {
            Class1 = this,
            Value1 = value1,
            Value2 = value2
        };
        return (int)invocation.Invoke();
    }

    ...
}

AddInnerメソッドに宣言されているInterceptoAttributeから、適用するIInterceptorのTypeを取得し、AddInnerInvocationをインスタンス化し、Invokeメソッドを呼び出します。

InvokeメソッドはAddInnerInvocationクラスの親である抽象クラスInvocationクラスに実装されており、つぎのような実装になっています。

public abstract class Invocation : IInvocation
{
    private int _currentIndex = 0;
    private readonly List<IInterceptor> _interceptors = new List<IInterceptor>();

    protected Invocation(Type[] interceptorTypes)
    {
        foreach (var interceptorType in interceptorTypes)
        {
            _interceptors.Add((IInterceptor)Activator.CreateInstance(interceptorType));
        }
    }

    public abstract object[] Arguments { get; }

    public object Invoke()
    {
        if (_currentIndex < _interceptors.Count)
        {
            var interceptor = _interceptors[_currentIndex++];
            return interceptor.Intercept(this);
        }
        else
        {
            return InvokeEndpoint();
        }
    }

    protected abstract object InvokeEndpoint();
}

Invocationはコンストラクタで渡されたTypeからIInterceptorのインスタンスを生成し、Invokeメソッドが呼ばれると、それらのIInterceptorをすべて適用した後、サブクラスであるコンクリートクラスのInvokeEndpointメソッドを呼び出し、おおもとの処理を実行します。

例外処理など全く実装されていない為、実践的な実装ではありませんが大筋良さそうに見えますが、この方式には根本的に落とし穴がありました。それは

  1. デバッグ実行時に元のコードも併せてちゃんとトレースできるが、呼び出し階層上のメソッド名がAddからAddInnerに代わってしまう
  2. 異なるアセンブリ間での呼び出しは適切に動作しますが、同一アセンブリ内の呼び出しはエラーになります。どうもAddメソッドではなくAddInnerを直接呼び出していてアクセス違反が発生するようです
  3. 元々のコードを大幅に書き換える為、他にも落とし穴がありそう

特に2番目は致命的で、やはり元のコードを弄繰り回すアプローチは間違っている気がしてきました。

サブクラスを作成し、それをプロキシークラスとして利用するアプローチの方が問題は少ないような気がしてきました。直接コンストラクタを呼び出せないデメリットはありますが、実際のアプリケーションではDependency Injection Containerを利用するでしょうから、コンストラクタを呼び出せないデメリットはそこで回避できるはずです。

まとめ

さて今回の例ではログの出力を編み込みましたが、実際のアプリケーションではこのレベルのログ出力はしない気がします。実際のアプリケーションで有用そうなアスペクトは

  • Model層でトランザクション制御の適用
  • ViewModel層でアプリケーションのイベントトラッキングの適用

といったあたりが便利な使用法に思えます。ただIL生成がわけ分からなくて今回はここが限界でした。今後もう少しフレームワークを昇華しつつ、実用的なレベルの使いこなしに踏み込んでいきたいと思います。

それでは最後にあらためて軽くまとめましょう。

  1. AOPとはOOPを保管する概念で、特に非機能要件の実装にありがちな、横断的な関心事を実装することでOOPのみで実装するのと比較し、重複コードの削減や品質向上に貢献できる
  2. AOPを.NETで実現する場合、既存フレームワークでは動的コード生成を利用しているケースが多い
  3. iOSの制約でXamarin.iOSでは、動的コード生成が利用できない
  4. そのためXamarinでAOPをするためには、静的コード生成などを利用する必要がある
  5. 静的コード生成を容易にするライブラリとしてMono.Cecilが存在し
  6. それを利用してビルド時に静的コード生成を適用するアドインを作成するフレームワークとしてFodyが存在する
  7. 既存のFody対応のAOPライブラリではCauldron.Interception.Fodyなどが存在する
  8. ただCauldron.Interception.Fodyの仕様が好みではないため自作にトライしてみました
  9. 今回の設計は最適ではなく、プロキシーを利用したアプローチのほうが適切かもしれない

という感じです。ちなみに

「IL生成とかいってもやってみたらそんな難しくないね!黒魔術なんて大げさな。役に立つんだから白魔術でしょう!」

と書くつもりでしたが、やってみて思いました。

IL生成とか黒魔術じゃん!辛い!!

以上です。
本稿の公開がやや時間に間に合わずご迷惑おかけいたしました。

17
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
10