2012年1月30日月曜日

二つの AppDomain の狭間で - I wish we human would be gatekeepers of CLR... -

もっと境界を自由に行き来できるようになりたい!

.NET Framework を使われている方は、もしかしたら聞いたことがあるかもしれない機能、AppDomain。
MSDN では「アプリケーションを分離する利点」として次のような説明がされています。
  1. あるアプリケーションで実行されているコードは、他のアプリケーションのコードに直接アクセスできないよ。
  2. あるアプリケーションで発生したエラーが他のアプリケーションに影響することはないよ。
  3. プロセス全体を停止せずに、各アプリケーションを停止できるよ。
  4. コードの構成情報(読み込むアセンブリのバージョンポリシーや場所)をアプリケーション毎に決められるよ。
  5. コードに与えるアクセス許可をアプリケーション毎に制御できるよ。
プロセスに比べ、その生成コストや相互通信コストが低いため、サーバーに利用すればそのスケーラビリティは飛躍的に向上し、セキュリティレベルを上げたサンドボックスとして利用することで、安全にサードパーティ製のコードを実行でき、自作アプリ/業務アプリ問わず、任意のプログラミング言語で、生産性向上のためのマクロや新しい機能のアドインを作成できる・・・etc
と各所でイチオシされる CLR の中核機能なのですが、利用されてる方が周りにあまりいらっしゃらないのが悲しいところ (ToT)

今回は、最初の一歩と開発時に役立つ小品など、AppDomain をもっと身近に感じられるような情報を共有させていただければというエントリとなります。

同じ仕組みは CreateProcess やら LoadLibrary やらの Platform API を叩いてもできるとは思いますが、それよりだいぶ手軽かと思いますので、もしこれを機に使ってみようという方が増えれば光栄に思いますです <(_ _)>

※文中にあるソリューションは以下のリンクからダウンロードできます。ビルド環境は、Visual Studio C# 2010、あと C# 側の自動テストに NUnit を使っています。環境を整える際は、各ライブラリのインストールをお願いしますです。


こちらのページ/ソフトウェアを参考にさせていただきました。この場を借りてお礼申し上げます<(_ _)>
Download Details - Microsoft Download Center - Shared Source Common Language Infrastructure 2.0 Release
Cecil - Mono
ECMA C# and Common Language Infrastructure Standards
Microsoft.ExtendedReflection
IL rewriting : calli opcode and metadata token for a stand-alone signature
Haibo Luo's weblog[FeedShow RSS reader] Turn MethodInfo to DynamicMethod
DebuggerVisualizer for DynamicMethod (Show me the IL) - Haibo Luo's weblog - Site Home - MSDN Blogs
An Overview of Managed/Unmanaged Code Interoperability
#6238 (has_binary_operator.hpp contains some non-ASCII characters) Boost C++ Libraries
WOW64 and Your Profiler - David Broman's CLR Profiling API Blog - Site Home - MSDN Blogs
表示設定 - システム - PCテクニック - オンラインPC館
Editor Guidelines - Visual Studio Gallery
Profiler Attach and Detach
Joel Pobar's Weblog | SSCLI 2.0 Patch for VS 2010
c# - DynamicMethod and out-parameters? - Stack Overflow
64ビット対応のDLLインジェクション(CreateRemoteThread+LoadLibrary) - SIN@SAPPOROWORKSの覚書
Cross AppDomain Singleton - Blog'A'Little
Cross-AppDomain Singleton in C#
第8回c#ユーザー会-AppDomain
NUnit - Home





目次

何を分離してるし
普段、我々がアプリケーションと言えばプロセスそのものを指しますので、プロセスの中にアプリケーションがいくつもある、って言われてもなかなかイメージが湧かないかもしれません。ここで、CLR で実行される exe や dll について、未実行時の構造と実行時の構造をちょっと比べてみましょう。

【未実行時】
未実行時は左の図のような構造になっています。exe や dll 1 つ 1 つを Assembly と呼びます。この Assembly、未実行だと最上位に位置し、exe や dll 1 つ につき 1 つしか存在しません。Assembly の下に Module がぶら下がり、Module の下に Type が、その下に Method、Field、Property、Event、Type(Nested Type) がぶら下がる格好になります。

【実行時】
対して、実行中は上に階層が伸び、未実行時は exe や dll 1 つにつき 1 つしか存在しなかった Assembly が、Process x AppDomain 分存在することになります(JIT されたメソッド本体など、条件によっては共有できるものもあります)。
Process というのが、普段我々が目にしているプロセスそのものであり、その下の AppDomain が CLR がアプリケーションと呼んでいるものに当たります。
従って、AppDomain というのは、Process に読み込まれる Assembly、所謂コードが読み込まれる領域(メモリ領域)を表す、ということになります。文字通り、アプリケーションのドメイン(領域)を分離しているんですね。

今時の OS や開発言語では、Process 内の処理をある単位で分離して扱える Thread を持っていることがほとんどかと思いますが、CLR ではさらに進めて、それらが扱う領域を分離して扱うことが可能になっているわけです。もちろん 1 つの Thread が複数の AppDomain にある情報を処理して回ることもできれば、1 つの AppDomain にある情報を複数の Thread が処理することもできます。

ちなみに、Java をやられてる方であれば、階層構造や Bytecode Instrumentation が無い ClassLoader をイメージしていただければ大体合っているようです。.NET で細かなカスタマイズをやるには、アンマネージ API が必要になりますので・・・ね・・・(ToT)。




簡単な使い方と制限
アプリケーション毎の領域を分離することが目的なので、その境界を越えるには特別なルールが必要になっています。それは、
  ・異なる AppDomain で情報を共有するためには、値をコピーするか、参照を保持する Proxy を用意しなければならない
というものです。

前者は SerializableAttribute 属性の適用、後者は MarshalByRefObject 型の継承をすることで実現できるようになっています。
以下簡単なサンプルを。まずは、SerializableAttribute 属性の適用からやってみましょう。
#line 1 "NTroll\Urasandesu.NTroll.DomainFree\SClass.cs"
using System;
using System.Runtime.CompilerServices;

namespace Urasandesu.NTroll.DomainFree
{
    [Serializable]
    public class SClass
    {
        public object InstanceMember { get; set; }
        public static object StaticMember { get; set; }
        public void Run()
        {
            Console.WriteLine("AppDomain: {0}",
                              AppDomain.CurrentDomain.FriendlyName);
            Console.WriteLine("Serializable Class Static Member: {0}",
                              RuntimeHelpers.GetHashCode(StaticMember));
            Console.WriteLine("Serializable Class Instance Member: {0}",
                              RuntimeHelpers.GetHashCode(InstanceMember));
        }
    }
}
 
#line 75 "NTroll\Urasandesu.NTroll.DomainFree\Program.cs"
using System;
using System.Runtime.Remoting;

namespace Urasandesu.NTroll.DomainFree
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("Serializable Class Test: " + 
                              "Default AppDomain -> Test Domain"); 
            {
                SClass.StaticMember = new object();
                var o = new SClass();
                Console.WriteLine("o is Transparent Proxy?: {0}", 
                                  RemotingServices.IsTransparentProxy(o));
                o.InstanceMember = new object();
                o.Run();

                var info = AppDomain.CurrentDomain.SetupInformation;
                var testDomain = AppDomain.CreateDomain("Test Domain", null, info);
                testDomain.DoCallBack(new CrossAppDomainDelegate(o.Run));
                AppDomain.Unload(testDomain);
            }
            Console.WriteLine();


            Console.WriteLine("Serializable Class Test: " + 
                              "Default AppDomain <- Test Domain");
            {
                var info = AppDomain.CurrentDomain.SetupInformation;
                var testDomain = AppDomain.CreateDomain("Test Domain", null, info);
                var t = typeof(SClass);
                var o = (SClass)testDomain.CreateInstanceAndUnwrap(t.Assembly.FullName, t.FullName);
                Console.WriteLine("o is Transparent Proxy?: {0}",
                                  RemotingServices.IsTransparentProxy(o));

                SClass.StaticMember = new object();
                o.InstanceMember = new object();
                o.Run();

                testDomain.DoCallBack(new CrossAppDomainDelegate(o.Run));
                AppDomain.Unload(testDomain);
            }
            Console.WriteLine();

            // The example displays the following output:
            // 
            //    Serializable Class Test: Default AppDomain -> Test Domain
            //    o is Transparent Proxy?: False
            //    AppDomain: Urasandesu.NTroll.DomainFree.exe
            //    Serializable Class Static Member: 54267293
            //    Serializable Class Instance Member: 18643596
            //    AppDomain: Test Domain
            //    Serializable Class Static Member: 0
            //    Serializable Class Instance Member: 18796293
            //    
            //    Serializable Class Test: Default AppDomain <- Test Domain
            //    o is Transparent Proxy?: False
            //    AppDomain: Urasandesu.NTroll.DomainFree.exe
            //    Serializable Class Static Member: 12289376
            //    Serializable Class Instance Member: 43495525
            //    AppDomain: Test Domain
            //    Serializable Class Static Member: 0
            //    Serializable Class Instance Member: 43942917
        }
    }
}
 
Process に紐付いた最初の AppDomain は自動的に作成されます。追加で AppDomain を作成したい時ですが、手順は非常に簡単で、AppDomain.CreateDomain を呼べば作成できます(95 行目、106 行目)。DoCallBack で作成した AppDomain にオブジェクトを送り込み、実行することができます(96 行目、116 行目)。作成した AppDomain 内にオブジェクトを生成するには一連の CreateInstance** メソッドが利用できます(108 行目)。必要なくなったら、AppDomain.Unload を呼び、アンロード依頼を出しておきましょう(97 行目、117 行目)。現在の Thread が実行中の AppDomain にアクセスするには、AppDomain.CurrentDomain を使います(14 行目)。あと、AppDomain.FriendlyName で CreateDomain 時に付けた名前が取得できます。デフォルトでは Module 名になります(同じく、14 行目)。

さて、SerializableAttribute なクラスの場合、保持しているメンバの情報もコピーされます。インスタンスメンバはコピーされた結果、新しいオブジェクトになっていることがわかります(127 行目、130 行目、136 行目、139 行目)。静的メンバも最初に書きました通り、AppDomain 毎に領域が取られますので、新しいオブジェクトになります。明示的に初期化していませんので、null になっているのがわかりますね(126 行目⇒129 行目、135 行目⇒138 行目)。

次は、MarshalByRefObject 型の継承を見てみましょう。
#line 1 "NTroll\Urasandesu.NTroll.DomainFree\MClass.cs"
using System;
using System.Runtime.CompilerServices;

namespace Urasandesu.NTroll.DomainFree
{
    public class MClass : MarshalByRefObject
    {
        public object InstanceMember { get; set; }
        public static object StaticMember { get; set; }
        public void Run()
        {
            Console.WriteLine("AppDomain: {0}",
                              AppDomain.CurrentDomain.FriendlyName);
            Console.WriteLine("MarshalByRefObject Class Static Member: {0}",
                              RuntimeHelpers.GetHashCode(StaticMember));
            Console.WriteLine("MarshalByRefObject Class Instance Member: {0}",
                              RuntimeHelpers.GetHashCode(InstanceMember));
        }
    }
}
 
#line 6 "NTroll\Urasandesu.NTroll.DomainFree\Program.cs"
using System;
using System.Runtime.Remoting;

namespace Urasandesu.NTroll.DomainFree
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("MarshalByRefObject Class Test: " +
                              "Default AppDomain -> Test Domain");
            {
                MClass.StaticMember = new object();
                var o = new MClass();
                Console.WriteLine("o is Transparent Proxy?: {0}",
                                  RemotingServices.IsTransparentProxy(o));
                o.InstanceMember = new object();
                o.Run();

                var info = AppDomain.CurrentDomain.SetupInformation;
                var testDomain = AppDomain.CreateDomain("Test Domain", null, info);
                testDomain.DoCallBack(new CrossAppDomainDelegate(o.Run));
                AppDomain.Unload(testDomain);
            }
            Console.WriteLine();


            Console.WriteLine("MarshalByRefObject Class Test: " +
                              "Default AppDomain <- Test Domain");
            {
                var info = AppDomain.CurrentDomain.SetupInformation;
                var testDomain = AppDomain.CreateDomain("Test Domain", null, info);
                var t = typeof(MClass);
                var o = (MClass)testDomain.CreateInstanceAndUnwrap(t.Assembly.FullName, t.FullName);
                Console.WriteLine("o is Transparent Proxy?: {0}",
                                  RemotingServices.IsTransparentProxy(o));

                MClass.StaticMember = new object();
                o.InstanceMember = new object();
                o.Run();

                testDomain.DoCallBack(new CrossAppDomainDelegate(o.Run));
                AppDomain.Unload(testDomain);
            }
            Console.WriteLine();

            // The example displays the following output:
            // 
            //    MarshalByRefObject Class Test: Default AppDomain -> Test Domain
            //    o is Transparent Proxy?: False
            //    AppDomain: Urasandesu.NTroll.DomainFree.exe
            //    MarshalByRefObject Class Static Member: 54267293
            //    MarshalByRefObject Class Instance Member: 18643596
            //    AppDomain: Urasandesu.NTroll.DomainFree.exe
            //    MarshalByRefObject Class Static Member: 54267293
            //    MarshalByRefObject Class Instance Member: 18643596
            //    
            //    MarshalByRefObject Class Test: Default AppDomain <- Test Domain
            //    o is Transparent Proxy?: True
            //    AppDomain: Test Domain
            //    MarshalByRefObject Class Static Member: 0
            //    MarshalByRefObject Class Instance Member: 55915408
            //    AppDomain: Test Domain
            //    MarshalByRefObject Class Static Member: 0
            //    MarshalByRefObject Class Instance Member: 55915408
        }
    }
}
 
MarshalByRefObject なクラスの場合、保持しているメンバは Proxy を通じてアクセスされます。元の AppDomain と全く同じものを指しているようにも見えますが、作成した AppDomain 内で構築したオブジェクトに対して RemotingServices.IsTransparentProxy を呼べば、それが Proxy であることがわかります(21 行目、41 行目)。
異なる AppDomain のオブジェクトをリモート操作できるような使い勝手で、非常に強力なのですが(57 行目~70 行目)、単一継承しか許されない C# のような言語では、あまり気軽にとは行かないかもしれません。

以上が制限になります。結構厳しいかも (^_^;)
コンパイラが自動生成をがんばってしまう C# や VB .NET ですと、気付かないうちに匿名メソッドやラムダ式の中に外部の環境を取り込んでしまい、いつの間にか境界を越えることができなくなってたりしますのでご注意を。いざとなったら、ILSpy のような逆アセンブルツールに掛け、意図していないことになっていないか確認すると良いでしょう。

簡単なサンプルを見たところで、次は実用的なサンプルを見ていきましょう。
「領域を分離すること」というキーワードと開発者が馴染み深いと言えば・・・そう、自動化された単体テストです!



シングルトンと仲良くなる! - Singleton Pattern must die! -
シングルトンパターンとは、GoF による 23 種のデザインパターンの 1 つ。もう説明するまでも無いほどよく知られているとは思いますが、そのクラスのインスタンスが 1 つしか生成されないことを保証するためのデザインパターンです。 この特性から、用途としては、以下のようなことをするクラスに使われていることが多いと思います。
  • パフォーマンス向上のため、DB の接続をプールしたり、ネットワーク通信をキャッシュする。
  • イベントログなど、アプリケーション全体で制御する必要があるファイルへの書き込みを行う。
  • アプリケーションの設定情報やリソースなどを起動時に読み込み、アクセス手段を一元化する。

ところで、何らかの自動化された単体テストを書かれている方々の界隈で有名な書籍に「レガシーコード改善ガイド」というものがあります。「テストがないコードはレガシーコードだ!」の帯が衝撃的ですよね (>_<)
曰く、以下のことを行うテストは単体テストではありません。
  • DB とやりとりやネットワークを介した通信をする。
  • ファイルシステムにアクセスする。
  • 実行に特別の環境設定を必要とする(設定ファイルの編集など)。


うわー・・・なにこの一致・・・


シングルトンパターンが使われているクラスの用途と見事に符合しますね・・・。
実はこのパターン、単体テストしにくいことでも有名なパターンだそうなのですが、なるほど納得です (^_^;)

しかしながら、このような境界面というのはまた、不具合が発生しやすいということでも皆さんの支持を得られるところでしょう。接続した際にうまく動かないなんて時、やりとりするデータの仕様にミスや勘違いがあるのか、はたまた単に機能がバグっているのか。問題の切り分けのためにも、単体での動作は保証しておきたいところなのですが・・・。

さあ、こんな時こそ AppDomain の出番です!論より実装、さっそく例を見ていきましょう。


設定ファイルの読み込み(ConfigurationManager)
前回の具体性に欠ける例で申し訳ないのですが、ここで設定を読み込むのに使われていた ConfigurationManager を取り上げます。あなたはこの ThirdPartyLibrary を提供している会社の開発者の一人ですが、当初 ConfigurationManager を担当していた人間はいない・・・という設定で話を進めてみましょう。

最初は問題がなかったようなのですが、使われ始めてしばらくすると以下のような改善要望が挙がってくるようになりました。

「現地で設定を手修正することもあるのだが、現状だとエラーチェックが厳しすぎる。
本来であれば構成ファイルの設定用ツールがあるべきだとは思うが・・・もう少しなんとかならないだろうか。」

話を聞くと、現状では「Sunday」を「sunday」や「SUNDAY」と入れただけでも例外が発生してしまうとのこと。なるほど確認してみます、とソースコードを覗いてみました。
#line 148 "NTroll\Urasandesu.NTroll.DomainFree\ConfigurationManager.cs"
using System;
using System.Collections.Generic;
using System.Threading;

namespace ThirdPartyLibrary
{
    public class ConfigurationManager
    {
        public static T GetProperty<T>(string key, T defaultValue)
        {
            var value = global::System.Configuration.ConfigurationManager.AppSettings[key];
            var impl = GetPropertyImpl<T>.CreateInstanceWithCache(GetPropertyImpl<T>.CreateInstance);
            if (impl == null)
                throw new NotSupportedException();
            return impl.ToPropertyWithCache(key, value, defaultValue);
        }
        
        protected abstract class GetPropertyImpl<T> // 省略...

        class GetPropertyImplForDayOfWeek : GetPropertyImpl<DayOfWeek>
        {
            protected override DayOfWeek ToPropertyCore(string key, string value, DayOfWeek defaultValue)
            {
                if (string.IsNullOrEmpty(value))
                {
                    return defaultValue;
                }
                else if (Enum.IsDefined(typeof(DayOfWeek), value))
                {
                    return (DayOfWeek)Enum.Parse(typeof(DayOfWeek), value);
                }
                else
                {
                    var fmt = "The value \"{0}\" linked by key \"{1}\" is invalid. " +
                              "It must take one of the following values: \r\n{2}.";
                    var msg = string.Format(fmt, value, key, string.Join(", ", Enum.GetNames(typeof(DayOfWeek))));
                    throw new ArgumentException(msg, "value");
                }
            }
        }

        protected ConfigurationManager() { }
    }
}
 
あー・・・Enum.IsDefined は大文字・小文字を区別するチェックしかできないんでしたっけ(260 行目)。.NET 4 以降になれば、Enum.TryParse<T> メソッドが使えるんでしょうが、レガシーシステムだけあってそれは無理な感じ。取りうる値を、IgnoreCase な正規表現にして並べるのが確実そうです。
さて、やり方も決めたし、とりあえずテストコードを直しますか・・・ConfigurationManagerTest・・・あったこれですね。ちょいちょいっと・・・。
#line 339 "NTroll\Test.Urasandesu.NTroll.DomainFree\ConfigurationManagerTest.cs"
using System;
using System.IO;
using NUnit.Framework;
using ThirdPartyLibrary;
using Urasandesu.NTroll.DomainFree;

namespace Test.Urasandesu.NTroll.DomainFree
{
    [TestFixture]
    public class ConfigurationManagerTest
    {
        [TestFixtureSetUp]
        public void TestFixtureSetUp()
        {
        }

        [TestFixtureTearDown]
        public void TestFixtureTearDown()
        {
        }

        [Test]
        public void GetPropertyTestSuccess01ExistKeyExistValue()
        {
            var holiday = ConfigurationManager.GetProperty("Holiday", DayOfWeek.Sunday);
            Assert.AreEqual(DayOfWeek.Monday, holiday);
        }

        [Test]
        [Ignore("This test cannot be passed through. " +
                "Though it needs modifying App.config, I could not do it " +
                "because the configurations have already been cached " +
                "when passing the success path.")]
        public void GetPropertyTestError01AppConfigNotFound()
        {
            //
        }

        [Test]
        [ExpectedException(typeof(System.Configuration.ConfigurationErrorsException))]
        [Ignore("This test cannot be passed through. " +
                "Though it needs modifying App.config, I could not do it " +
                "because the configurations have already been cached " +
                "when passing the success path.")]
        public void GetPropertyTestError02InvalidAppConfig()
        {
            //
        }

        [Test]
        [Ignore("This test cannot be passed through. " +
                "Though it needs modifying App.config, I could not do it " +
                "because the configurations have already been cached " +
                "when passing the success path.")]
        public void GetPropertyTestError03HolidayPropertyNotFound()
        {
            // 
        }

        [Test]
        [Ignore("This test cannot be passed through. " +
                "Though it needs modifying App.config, I could not do it " +
                "because the configurations have already been cached " +
                "when passing the success path.")]
        public void GetPropertyTestError04HolidayPropertyIsEmpty()
        {
            // 
        }

        [Test]
        [ExpectedException(typeof(ArgumentException))]
        [Ignore("This test cannot be passed through. " +
                "Though it needs modifying App.config, I could not do it " +
                "because the configurations have already been cached " +
                "when passing the success path.")]
        public void GetPropertyTestError05HolidayPropertyIsInvalidIfIgnoredCase()
        {
            // 
        }

        [Test]
        [ExpectedException(typeof(ArgumentException))]
        [Ignore("This test cannot be passed through. " +
                "Though it needs modifying App.config, I could not do it " +
                "because the configurations have already been cached " +
                "when passing the success path.")]
        public void GetPropertyTestError06HolidayPropertyIsInvalidIfCaseSensitive()
        {
            // 
        }
    }
}
 


(´・ω・`)


(´・ω:;.:...


(´:;....::;.:. :::;.. .....


ちょ・・・肝心なところが Ignore になってるじゃん /(^o^)\

Reason を見ると、例外のテストのためにはアプリケーション構成ファイルの書き換えが必要なんですけど、正常系パスを通す時にすでにキャッシュされてしまうのでできませんでした、的な言い訳が書いてあります。
・・・ (´・ω・`)そういうことですか・・・

ダークサイドに堕ちる前に
AppDomain を知らないと「前の人もやってなかったんだから、あなたもやらなくていいんじゃん? Ψ(`∀´)Ψケケケ」という悪魔の誘いに乗ってしまうかもしれません。暗黒面に堕ちてしまう前に、急いでいくつかのクラスを追加します。
#line 8 "NTroll\Urasandesu.NTroll.DomainFree\MarshalByRefRunners.cs"
    public class MarshalByRefAction : MarshalByRefObject
    {
        public Action Action { get; set; }
        public void Run()
        {
            if (Action != null)
                Action();
        }
    }
 
MarshalByRefAction は、指定されたデリゲートを、自身が作成された AppDomain で実行するだけの簡単なユーティリティです。
#line 1 "NTroll\Urasandesu.NTroll.DomainFree\AppDomainMixin.cs"
using System;
using System.Security.Policy;

namespace Urasandesu.NTroll.DomainFree
{
    public static class AppDomainMixin
    {
        public static void RunAtIsolatedDomain(this AppDomain source, Action action)
        {
            if (source == null)
                throw new ArgumentNullException("source");
            RunAtIsolatedDomain(source.Evidence, source.SetupInformation, action);
        }

        public static void RunAtIsolatedDomain(this AppDomain source, Evidence securityInfo, Action action)
        {
            if (source == null)
                throw new ArgumentNullException("source");
            RunAtIsolatedDomain(securityInfo, source.SetupInformation, action);
        }

        public static void RunAtIsolatedDomain(this AppDomain source, AppDomainSetup info, Action action)
        {
            if (source == null)
                throw new ArgumentNullException("source");
            RunAtIsolatedDomain(source.Evidence, info, action);
        }

        public static void RunAtIsolatedDomain(Evidence securityInfo, AppDomainSetup info, Action action)
        {
            if (action == null)
                throw new ArgumentNullException("action");
            if (!action.Method.IsStatic)
                throw new ArgumentException("The parameter must be the reference of a " +
                                            "static method.", "action");

            var domain = default(AppDomain);
            try
            {
                domain = AppDomain.CreateDomain("Domain " + action.Method.ToString(),
                                               securityInfo, info);
                var type = typeof(MarshalByRefAction);
                var runner = (MarshalByRefAction)domain.CreateInstanceAndUnwrap(
                                                  type.Assembly.FullName, type.FullName);
                runner.Action = action;
                runner.Run();
            }
            finally
            {
                try
                {
                    if (domain != null)
                        AppDomain.Unload(domain);
                }
                catch { }
            }
        }
    }
}
 
そして AppDomainMixin。簡単な使い方と制限で説明した処理をひとまとめにし、指定された処理を分離された環境で実行するというものになります(40 行目~46 行目)。

話は逸れますが、このような「リソースのオープン時にクローズも確実にした処理を共通化しておき、ユーザーにリソースを貸し出す」定石をローンパターン(Loan Pattern)と呼ぶそうですが、何を勘違いしたのか、ずっとレントパターン(Rent Pattern)と言っていました(恥。C++ でよく言われる RAII(Resource Acquisition Is Initialization) を R から始まる単語~みたいな感じで覚えてましたので混じっちゃったんですかね (^_^;)

さてさて、話を戻します。これを使って ConfigurationManagerTest で出来ていなかった異常系を書いてみましょう。
#line 201 "NTroll\Test.Urasandesu.NTroll.DomainFree\ConfigurationManagerTest.cs"
        [Test]
        public void GetPropertyTestError01AppConfigNotFound()
        {
            var info = new AppDomainSetup();
            info.ApplicationBase = AppDomain.CurrentDomain.SetupInformation.ApplicationBase;
            info.ShadowCopyFiles = AppDomain.CurrentDomain.SetupInformation.ShadowCopyFiles;
            info.ConfigurationFile = Path.GetTempFileName();
            File.Delete(info.ConfigurationFile);
            AppDomain.CurrentDomain.RunAtIsolatedDomain(info, () =>
            {
                var holiday = ConfigurationManager.GetProperty("Holiday", DayOfWeek.Sunday);
                Assert.AreEqual(holiday, DayOfWeek.Sunday);
            });
        }

        [Test]
        [ExpectedException(typeof(System.Configuration.ConfigurationErrorsException))]
        public void GetPropertyTestError02InvalidAppConfig()
        {
            var info = new AppDomainSetup();
            info.ApplicationBase = AppDomain.CurrentDomain.SetupInformation.ApplicationBase;
            info.ShadowCopyFiles = AppDomain.CurrentDomain.SetupInformation.ShadowCopyFiles;
            info.ConfigurationFile = Path.GetTempFileName();
            using (var sw = new StreamWriter(info.ConfigurationFile))
            {
                sw.Write("Hoge");
            }
            AppDomain.CurrentDomain.RunAtIsolatedDomain(info, () =>
            {
                var holiday = ConfigurationManager.GetProperty("Holiday", DayOfWeek.Sunday);
            });
        }

        [Test]
        public void GetPropertyTestError03HolidayPropertyNotFound()
        {
            var info = new AppDomainSetup();
            info.ApplicationBase = AppDomain.CurrentDomain.SetupInformation.ApplicationBase;
            info.ShadowCopyFiles = AppDomain.CurrentDomain.SetupInformation.ShadowCopyFiles;
            info.ConfigurationFile = Path.GetTempFileName();
            using (var sw = new StreamWriter(info.ConfigurationFile))
            {
                sw.Write(@"<?xml version=""1.0"" encoding=""utf-8"" ?>
<configuration>
  <appSettings>
  </appSettings>
  <startup>
    <supportedRuntime version=""v2.0.50727"" sku=""Client""/>
  </startup>
</configuration>");
            }
            AppDomain.CurrentDomain.RunAtIsolatedDomain(info, () =>
            {
                var holiday = ConfigurationManager.GetProperty("Holiday", DayOfWeek.Sunday);
                Assert.AreEqual(holiday, DayOfWeek.Sunday);
            });
        }

        [Test]
        public void GetPropertyTestError04HolidayPropertyIsEmpty()
        {
            var info = new AppDomainSetup();
            info.ApplicationBase = AppDomain.CurrentDomain.SetupInformation.ApplicationBase;
            info.ShadowCopyFiles = AppDomain.CurrentDomain.SetupInformation.ShadowCopyFiles;
            info.ConfigurationFile = Path.GetTempFileName();
            using (var sw = new StreamWriter(info.ConfigurationFile))
            {
                sw.Write(@"<?xml version=""1.0"" encoding=""utf-8"" ?>
<configuration>
  <appSettings>
    <add key=""Holiday"" value="""" />
  </appSettings>
  <startup>
    <supportedRuntime version=""v2.0.50727"" sku=""Client""/>
  </startup>
</configuration>");
            }
            AppDomain.CurrentDomain.RunAtIsolatedDomain(info, () =>
            {
                var holiday = ConfigurationManager.GetProperty("Holiday", DayOfWeek.Sunday);
                Assert.AreEqual(holiday, DayOfWeek.Sunday);
            });
        }

        [Test]
        [ExpectedException(typeof(ArgumentException))]
        public void GetPropertyTestError05HolidayPropertyIsInvalidIfIgnoredCase()
        {
            var info = new AppDomainSetup();
            info.ApplicationBase = AppDomain.CurrentDomain.SetupInformation.ApplicationBase;
            info.ShadowCopyFiles = AppDomain.CurrentDomain.SetupInformation.ShadowCopyFiles;
            info.ConfigurationFile = Path.GetTempFileName();
            using (var sw = new StreamWriter(info.ConfigurationFile))
            {
                sw.Write(@"<?xml version=""1.0"" encoding=""utf-8"" ?>
<configuration>
  <appSettings>
    <add key=""Holiday"" value=""aaaaaaaaaaaaa"" />
  </appSettings>
  <startup>
    <supportedRuntime version=""v2.0.50727"" sku=""Client""/>
  </startup>
</configuration>");
            }
            AppDomain.CurrentDomain.RunAtIsolatedDomain(info, () =>
            {
                var holiday = ConfigurationManager.GetProperty("Holiday", DayOfWeek.Sunday);
            });
        }

        [Test]
        [ExpectedException(typeof(ArgumentException))]
        public void GetPropertyTestError06HolidayPropertyIsInvalidIfCaseSensitive()
        {
            var info = new AppDomainSetup();
            info.ApplicationBase = AppDomain.CurrentDomain.SetupInformation.ApplicationBase;
            info.ShadowCopyFiles = AppDomain.CurrentDomain.SetupInformation.ShadowCopyFiles;
            info.ConfigurationFile = Path.GetTempFileName();
            using (var sw = new StreamWriter(info.ConfigurationFile))
            {
                sw.Write(@"<?xml version=""1.0"" encoding=""utf-8"" ?>
<configuration>
  <appSettings>
    <add key=""Holiday"" value=""monday"" />
  </appSettings>
  <startup>
    <supportedRuntime version=""v2.0.50727"" sku=""Client""/>
  </startup>
</configuration>");
            }
            AppDomain.CurrentDomain.RunAtIsolatedDomain(info, () =>
            {
                var holiday = ConfigurationManager.GetProperty("Holiday", DayOfWeek.Sunday);
            });
        }
 
それ ほーら もうできた(ちゃちゃちゃ♪

AppDomainSetup クラスは、AppDomain を生成するためのいくつかの情報を指定するためのクラスです。
アプリケーションが格納されたディレクトリを表す ApplicationBase や、シャドウコピーの設定 ShadowCopyFiles は、現在の AppDomain のものをそのまま利用し(205・206 行目、221・222 行目、238・239 行目、263・264 行目、290・291 行目、316・317 行目)、アプリケーションの構成ファイルの読み込み先をテンポラリファイルに変更しています(207 行目、223 行目、240 行目、265 行目、292 行目、318 行目)。

異常系のパターンですが、
  1. アプリケーション構成ファイルが無い。
  2. アプリケーション構成ファイルのフォーマットが不正。
  3. Holiday キーが要素丸々消えている。
  4. Holiday キーの持つ値が空。
  5. Holiday キーの持つ値が異常(大文字・小文字区別しなくても判定できない)
  6. Holiday キーの持つ値が異常(大文字・小文字区別しなければ判定可能)
をやるようにしました。
今回の改修では、6. が正常系になるわけです。そのように書き換え(ExpectedException 属性を外しました)、テストが失敗することを確認します。
#line 146 "NTroll\Test.Urasandesu.NTroll.DomainFree\ConfigurationManagerTest.cs"
        [Test]
        public void GetPropertyTestError06HolidayPropertyIsInvalidIfCaseSensitive()
        {
            var info = new AppDomainSetup();
            info.ApplicationBase = AppDomain.CurrentDomain.SetupInformation.ApplicationBase;
            info.ShadowCopyFiles = AppDomain.CurrentDomain.SetupInformation.ShadowCopyFiles;
            info.ConfigurationFile = Path.GetTempFileName();
            using (var sw = new StreamWriter(info.ConfigurationFile))
            {
                sw.Write(@"<?xml version=""1.0"" encoding=""utf-8"" ?>
<configuration>
  <appSettings>
    <add key=""Holiday"" value=""monday"" />
  </appSettings>
  <startup>
    <supportedRuntime version=""v2.0.50727"" sku=""Client""/>
  </startup>
</configuration>");
            }
            AppDomain.CurrentDomain.RunAtIsolatedDomain(info, () =>
            {
                var holiday = ConfigurationManager.GetProperty("Holiday", DayOfWeek.Sunday);
            });
        }
 
そうしたら、実際のプロダクトコードを修正し、テストが通ることを確認しましょう。
#line 109 "NTroll\Urasandesu.NTroll.DomainFree\ConfigurationManager.cs"
        class GetPropertyImplForDayOfWeek : GetPropertyImpl<DayOfWeek>
        {
            const string DayOfWeekName = "DayOfWeek";
            static readonly string DayOfWeekRegexPattern = 
                                            string.Format(@"^\s*(?<{0}>" + 
                                                                    @"(Sunday)|" + 
                                                                    @"(Monday)|" + 
                                                                    @"(Tuesday)|" + 
                                                                    @"(Wednesday)|" + 
                                                                    @"(Thursday)|" + 
                                                                    @"(Friday)|" + 
                                                                    @"(Saturday)" + 
                                                              @")\s*$", DayOfWeekName);
            static readonly Regex DayOfWeekRegex = new Regex(DayOfWeekRegexPattern, RegexOptions.Compiled | RegexOptions.IgnoreCase);
            protected override DayOfWeek ToPropertyCore(string key, string value, DayOfWeek defaultValue)
            {
                var m = default(Match);
                if (string.IsNullOrEmpty(value))
                {
                    return defaultValue;
                }
                else if ((m = DayOfWeekRegex.Match(value)) != null && m.Success)
                {
                    return (DayOfWeek)Enum.Parse(typeof(DayOfWeek), m.Groups[DayOfWeekName].Value, true);
                }
                else
                {
                    var fmt = "The value \"{0}\" linked by key \"{1}\" is invalid. " +
                              "It must take one of the following values: \r\n{2}.";
                    var msg = string.Format(fmt, value, key, string.Join(", ", Enum.GetNames(typeof(DayOfWeek))));
                    throw new ArgumentException(msg, "value");
                }
            }
        }
 
完全な単体テストを目指すのであれば、さらに System.Configuration.ConfigurationManager を入れ替え可能にするなどのリファクタリングも行えば良いと思います。テストがあるのですから、もう躊躇する必要はないですね!( ^ー゚)b




今回はここまで
今回は AppDomain という、.NET の根幹にあたる機能を取り上げてみました。その目的である「アプリケーションの分離」というものに、興味を持っていただけた方が少しでもいらっしゃれば幸いに思います。
そうそう、私自身、.NET と関わり始めて 7、8 年というところですが、アンマネージ API に足を突っ込んでからのこの半年弱は、それまでできなかった色々な発見ができるようになりました。マネージ API で理解なかった挙動が理解できるようになったり、これまでダークサイドに堕ちてしまっていた問題も解決できるようになったり。なんでもっと早く足突っ込まなかったしと反省しきりです (>_<)
もっとこういう「境界」に当たる部分を議論できるお仲間が増えれば、色々良いことがあるのではないかと儚い夢を見ております。

さて、私事で申し訳ないですが、ついに先日 12/23 で三十路を迎えてしまいました ...( = =)
ショックですが、オヤジギャクが憚られず言える歳になったと、今年も前向きにがんばって行きたい所存です。

境界を越えるついでに三十路を越えました・・・なんちゃって ///


・・・失礼いたしました (^_^;)
最後に 1 つ。実は AppDomain 周りについては、もう 1 つ報告すべきことがあります。
Prig に必要だった、パズルの最後の 1 ピース。近いうちに報告させていただければと思いますです <(_ _)>