AppDomain と動的読み込み

Eric Gunnerson
Microsoft Corporation

May 17, 2002

MSDN Online Code Center から supergraphfiles.exe をダウンロードする。

今月は、ASP .NET カンファレンスからシアトルに戻るところです。 パーム スプリングス国際空港の出発ロビーで搭乗手続きが始まるのを座って待っています。

私の当初の今月の予定では、 先月の SuperGraph アプリケーションの式解析部分について説明するつもりでした。 しかし、ここ数週間で AppDomain 部分のアセンブリのロードとアンロードに関する説明をいつするかを問い合わせるメールを数通受け取りました。 そこで、今月は当初の予定を変えて、 アセンブリのロードとアンロードを中心に説明したいと思います。

アプリケーション アーキテクチャ

コードの説明に入る前に、 ここで行おうとしていることについて少しお話しておきたいと思います。 覚えている方も多いと思いますが、SuperGraph アプリケーションでは一覧から関数を選択しました。 今月は "アドイン アセンブリ" をある特定のディレクトリに配置し、SuperGraph アプリケーションがそれらのアドイン アセンブリを検出して読み込み、 アセンブリに含まれる関数を検索するようにしたいと考えています。

これだけを行うのであれば、 独立した AppDomain は必要ありません。 通常、Assembly.Load() が十分機能します。 残念ながら、アセンブリを個別にアンロードできません。 個別にアンロードできるのは AppDomain のみです。 つまり、サーバー アドインを作成しているときに、 ユーザーがサーバーを起動および停止する必要なくアドインを更新できるようにする場合、 既定の AppDomain を使用してこれを行うことはできません。

これを可能にするには、 すべてのアドイン アセンブリを独立した AppDomain に読み込みます。 ファイルが追加または変更される場合は、 その AppDomain をアンロードし、 新しい AppDomain を作成して、 現在のファイルを新しく作成した AppDomain に読み込みます。 これで正常に機能するようになります。

これをより明確に説明するために、 図 1 に示すような典型的な事例を作成しました。

Dd296853.csharp05162002-fig01(ja-jp,MSDN.10).gif

図 1. AppDomain の典型的な事例

この図では、Loader クラスが、Functions という名前の新しい AppDomain を作成します。 Loader クラスは AppDomain を作成した後に、 新しい AppDomain 内に RemoteLoader を作成します。

アセンブリを読み込むために、RemoteLoader で読み込み関数を呼び出します。 この関数は、新しいアセンブリを開き、 アセンブリ内に存在するすべての関数を検索し、 これらの関数を FunctionList オブジェクトにパッケージ化した後、 そのオブジェクトを Loader クラスに返します。 この FunctionList オブジェクトの Function オブジェクトは、 その後 Graph 関数から使用されます。

AppDomain の作成

最初の作業は、AppDomain の作成です。 AppDomain を正しい方法で作成するには、AppDomain を AppDomainSetup オブジェクトに渡す必要があります。 AppDomainSetup オブジェクトに関するドキュメントは、 すべての作業が機能する方法を理解した後では役に立ちますが、 機能する方法を理解しようとしている段階ではあまり役に立ちません。 このテーマで Google で検索したところ、 先月のコラムがマッチ率の高いページの 1 つとして表示され、 私が問題に巻き込まれることは避けられないのではないかと思いました。

根本的な問題は、 アセンブリがどのようにランタイムに読み込まれるかということです。 既定では、ランタイムはグローバル アセンブリ キャッシュまたは現在のアプリケーション ディレクトリ ツリーのいずれかを検索します。 ここでは、まったく別のディレクトリからアドイン アセンブリ読み込みたいと思います。

AppDomainSetup オブジェクトのドキュメントを見ると、 ApplicationBase プロパティにアセンブリの検索対象となるディレクトリを設定できることがわかります。 残念ながら、RemoteLoader クラスが存在するのはオリジナル プログラム ディレクトリなので、 このディレクトリも参照する必要があります。

AppDomain の作成者はこのことを理解しているので、 アセンブリの検索対象となる別の場所を提供しました。 ここでは ApplicationBaseプロパティを使用してアドイン ディレクトリを参照し、 メイン アプリケーション ディレクトリを指すように PrivateBinPath を設定します。

以下に、この処理を行う Loader クラスからのコードを示します。

                  
AppDomainSetup setup = new AppDomainSetup();
setup.ApplicationBase = functionDirectory;
setup.PrivateBinPath = AppDomain.CurrentDomain.BaseDirectory;
setup.ApplicationName = "Graph";
appDomain = AppDomain.CreateDomain("Functions", null, setup);

remoteLoader = (RemoteLoader)  
    appDomain.CreateInstanceFromAndUnwrap("SuperGraph.exe", 
        "SuperGraphInterface.RemoteLoader");

AppDomain を作成後、 CreateInstanceFromAndUnwrap() 関数を使用して RemoteLoader クラスのインスタンスを新しい AppDomain に作成します。 このクラスが含まれるアセンブリのファイル名およびクラスの完全な名前が必要なことに注意してください。

この呼び出しが実行されると、RemoteLoader クラスのように見えるインスタンスが返されます。 実際には、このインスタンスは別の AppDomain 内の RemoteLoader インスタンスに呼び出しをすべて転送する小さなプロキシ クラスです。 これは、.NET Remoting が使用しているのと同じインフラストラクチャです。

アセンブリ バインディング ログ ビューア

この処理を行うコードを記述する際、間違いを犯すこともあるでしょう。 ドキュメントには、 アプリケーションのデバッグに関する助言がほとんど記載されていません。 しかし、しかるべき人にたずねれば、 アセンブリ バインディング ログ ビューアについて教えてくれるでしょう (読み込みサブシステムが "fusion" と呼ばれているので、このビューアには "fuslogvw.exe" という名前が付けられています)。 このビューアの実行時に、エラーを記録するように指示できます。 その後、アプリケーションを実行したときにアセンブリの読み込みで問題が発生した場合は、 ビューアを更新して、発生している問題の詳細を参照できます。

たとえば、Assembly.Load() はファイル名の末尾に ".dll" を必要としないということがわかれば非常に役に立ちます。 ログ ファイルを参照すると、"f.dll.dll" を読み込もうとしたことが示されており、このことがわかります。

アセンブリの動的読み込み

アプリケーション ドメインの作成は完了しました。 今度は、アセンブリを読み込み、アセンブリから関数を取り出す方法について説明しましょう。 この処理を行うには 2 つの異なる分野のコードが必要になります。 最初のコードは、あるディレクトリ内に存在するファイルを検索し、各ファイルを個別に読み込みます。

                  
void LoadUserAssemblies()
{
    availableFunctions = new FunctionList();
    LoadBuiltInFunctions();

    DirectoryInfo d = new DirectoryInfo(functionAssemblyDirectory);
    foreach (FileInfo file in d.GetFiles("*.dll"))
    {   
        string filename = file.Name.Replace(file.Extension, "");
        FunctionList functionList = loader.LoadAssembly(filename);

        availableFunctions.Merge(functionList);
    }
}

Graph クラスのこの関数は、 アドイン ディレクトリ内のすべての dll ファイルを検索し、 これらのファイルの拡張子を削除後、 これらのファイルを読み込むように loader に指示します。 その結果返される関数の一覧を、現在の関数の一覧にマージします。

2 つ目のコードは、RemoteLoader クラス内にあります。 このコードは、実際にアセンブリを読み込み、関数を検索します。

                  
public FunctionList LoadAssembly(string filename)
{
    FunctionList functionList = new FunctionList();
    Assembly assembly = AppDomain.CurrentDomain.Load(filename);

    foreach (Type t in assembly.GetTypes())
    {
        functionList.AddAllFromType(t);
    }    
    return functionList;
}

このコードは、単純に渡されたファイル名 (実際はアセンブリ名ですが) を指定して Assembly.Load() を呼び出し、 呼び出し元に返すために、役に立つ関数をすべて FunctionList インスタンスに読み込みます。

これで、アプリケーションを起動し、アドイン アセンブリを読み込むことができ、 ユーザーがこれらのアセンブリを参照できるようになります。

アセンブリの再読み込み

次の作業は、要求に応じてこれらのアセンブリを再読み込みできるようにすることです。 最終的には、この処理を自動化したいと考えていますが、 今月はテストを行うために、 アセンブリを再読み込みする [Reload] ボタンをフォームに追加しました。 このボタンのハンドラに単純に Graph.Reload() という名前を付けます。 この関数は、以下の操作を実行する必要があります。

  1. AppDomain をアンロードします。
  2. 新しい AppDomain を作成します。
  3. 新しい AppDomain 内に再度アセンブリを読み込みます。
  4. GraphLine オブジェクトを新しく作成した AppDomain に結び付けます。

GraphLine オブジェクトが古い AppDomain からの Function オブジェクトを保持しているので、 手順 4 が必要になります。 古い AppDomain がアンロードされると、Function オブジェクトは使用できなくなります。

この問題を解決するために、HookupFunctions()GraphLine オブジェクトを変更して、 これらのオブジェクトが現在の AppDomain からの正しい Function オブジェクトを指すようにします。

以下にコードを示します。

                  
loader.Unload();
loader = new Loader(functionAssemblyDirectory);
LoadUserAssemblies();
HookupFunctions();
reloadCount++;

if (this.ReloadCountChanged != null)
    ReloadCountChanged(this, new ReloadEventArgs(reloadCount));

最後の 2 行は、再読み込みの操作が実行されるたびに、イベントを発生させます。 これは、フォーム上の再読み込みカウンタ (Reload Count) を更新するために使用します。

新しいアセンブリの検出

次の作業は、アドイン ディレクトリに表示される新しいアセンブリまたは変更されたアセンブリを検出できるようにすることです。 フレームワークは、この処理を行うために FileSystemWatcher クラスを提供しています。 以下に Graph クラスのコンストラクタに追加したコードを示します。

                  
watcher = new FileSystemWatcher(functionAssemblyDirectory, "*.dll");
watcher.EnableRaisingEvents = true;
watcher.Changed += new FileSystemEventHandler(FunctionFileChanged);
watcher.Created += new FileSystemEventHandler(FunctionFileChanged);
watcher.Deleted += new FileSystemEventHandler(FunctionFileChanged);

FileSystemWatcher クラスを作成するときに、 検索対象のディレクトリと検索するファイルの種類を指示します。 EnableRaisingEvents プロパティは、 このクラスが変更箇所を検出した場合にイベントを送信するかどうかを指定します。 最後の 3 行は、クラス内の関数にイベントを結び付けます。 この関数は、単純に Reload() を呼び出してアセンブリを再度読み込みます。

このアプローチには、非効率な部分があります。 アセンブリが更新されているときに、 新しいバージョンを読み込むにはそのアセンブリをアンロードする必要があります。 ただし、ファイルが追加または削除されている場合は、 アセンブリをアンロードする必要はありません。 ここでは、変更されたアセンブリをすべてアンロードするオーバーヘッドはそれほど高くなく、 このように処理することでコードが簡略化されます。

このコードをビルド後、 アプリケーションを実行して、 新しいアセンブリをアドイン ディレクトリにコピーしました。 予想通りに、ファイル変更イベントが発生して、アセンブリの再読み込みが完了すると、 新しい関数を利用できるようになりました。

残念ながら、既存のアセンブリを更新しようとすると、問題が発生しました。 ランタイムがファイルをロックしているので、 新しいアセンブリをアドイン ディレクトリに追加できず、エラーが返されます。

AppDomain クラスのデザイナは、 この問題が発生することがわかっていたので、 この問題に対処するための優れた方法を提供しています。 ShadowCopyFiles プロパティが true に設定されている場合、 ランタイムはアセンブリをキャッシュ ディレクトリにコピーし、 コピーしたアセンブリを開きます (理由はわかりませんが、設定するのは文字列 true で、ブール型の定数 true ではありません)。 その結果、オリジナルのファイルはロックされず、使用中のアセンブリを更新できるようになります。 ASP .NET はこの機能を使用しています。

この機能を有効にするために、Loader クラスのコンストラクタに次の行を追加しました。

                  
setup.ShadowCopyFiles = "true";

その後、アプリケーションをリビルドしましたが、同じエラーが発生してしまいました。 ShadowCopyDirectories プロパティのドキュメントを参照したところ、 このプロパティが設定されていない場合、ApplicationBase で指定されたディレクトリを含めて、 PrivateBinPath で指定されたすべてのディレクトリがシャドウ コピーされると明記されていました。 このドキュメントのこの分野があまり適切でないということは既にお話したとおりです。

このプロパティに関するドキュメントの記述は、明らかに間違っています。 正確な動作は検証していませんが、ApplicationBase ディレクトリ内のファイルは既定ではシャドウ コピーされないと断言できます。 ディレクトリを明示的に指定することによって、この問題を解決できます。

                  
setup.ShadowCopyDirectories = functionDirectory;

このことを解明するのに少なくとも 30 分はかかりました。

既存のファイルを更新して、正常に読み込めるようになりました。 この問題が解決したと思ったら、別の小さな 1 つの問題にぶつかりました。 フォーム上のボタンをクリックして再読み込み関数を実行すると、 再読み込みが常に描画と同じスレッドで行われていたのです。 つまり、再読み込み処理が行われている間、線の描画がまったく行われませんでした。

そこでファイル変更イベントに切り替えたので、 AppDomain がアンロードされた後、かつ新しい AppDomain が読み込まれる前に描画できます。 しかし、これを行うと、例外が発生します。

これは従来からあるマルチスレッド プログラミングの問題であり、C# の lock ステートメントを使用して簡単に対処できます。 描画関数と再読み込み関数に lock ステートメントを追加しました。 その結果、これらの関数が同時に実行されるようになりました。 これで問題は解決しました。 更新したバージョンのアセンブリを追加すると、 プログラムが自動的に新しいバージョンの関数に切り替わるようになりました。 これは、ちょっと素敵でしょう。

動作について他に 1 つ奇妙な点があります。 ファイルの変更を検出する Win32® の関数は、送信する変更イベントの数が非常に多くなります。 1 つのファイルを一度更新すると、5 つの変更イベントが送信され、アセンブリは 5 回も再読み込みされます。 この問題を解決するには、これらのイベントをグループ化する、より洗練された FileSystemWatcher を作成する必要があります。 しかし、今回は止めておきましょう。

ドラッグ アンド ドロップ

ファイルをディレクトリにコピーするのは、あまり使い勝手が良くありませんね。 そこで、私はアプリケーションにドラッグ アンド ドロップの機能を追加することにしました。 この機能を追加する最初の手順として、 フォームの AllowDrop プロパティを true に設定し、 ドラッグ アンド ドロップのサポートを有効にしました。 次に、DragEnter イベントに 1 つのルーチンを組み込みました。 このルーチンは、ドラッグ アンド ドロップの操作中にカーソルがオブジェクト上に移動されたときに呼び出され、 現在のオブジェクトでドラッグ アンド ドロップがサポートされているかどうかを判断します。

                  
private void Form1_DragEnter(
    object sender, System.Windows.Forms.DragEventArgs e)
{
    object o = e.Data.GetData(DataFormats.FileDrop);
    if (o != null)
    {
        e.Effect = DragDropEffects.Copy;
    }
    string[] formats = e.Data.GetFormats();
}

このハンドラでは、FileDrop データ (つまり、ウィンドウにドラッグされているファイル) を利用できるかどうかを確認します。 データを利用できる場合、効果を Copy に設定します。 この設定により、カーソルが適切に設定され、ユーザーがマウス ボタンを離すと DragDrop イベントが送信されます。 この関数の最後の行は、この操作で利用できる情報を参照するための単なるデバッグ用の行です。

次の作業は、DragDrop イベントのハンドラを記述することです。

                  
private void Form1_DragDrop(
    object sender, System.Windows.Forms.DragEventArgs e)
{
    string[] filenames = (string[]) e.Data.GetData(DataFormats.FileDrop);
    graph.CopyFiles(filenames);
}

このルーチンは、この操作に関連付けられているデータ (ファイル名の配列) を取得し、 取得したデータを Graph 関数に渡します。 この関数は、ファイルをアドイン ディレクトリにコピーします。 その結果、ファイル変更イベントが発生し、これらのファイルが再読み込みされます。

状態

この時点で、アプリケーションを実行して、新しいアセンブリをアプリケーション上にドラッグできます。 アプリケーションはドラッグされたアセンブリを瞬時に読み込み、 アプリケーションは実行し続けます。これもちょっと素敵でしょう。

その他の事項

C# コミュニティ サイト

C# プロダクト チームが、 ユーザーの皆さんとより円滑にコミュニケーションを行うために Visual C#® Community Newsletter を開設しました。 この Newsletter では、 コミュニティ サイト http://www.gotdotnet.com/team/csharp (英語) の新しいコンテンツや、 私たちがカンファレンスやグループ ミーティングに参加するかどうかをお知らせしようと思います。 コミュニティ サイトにサインアップして、この Newsletter をご購読いただけます。

C# Summer Camp

今年の 8 月に、Developmentor と協力して、C# Summer Camp を主催します。 DevelopMentor インストラクタからすばらしい C# のトレーニングを受けたり、 C# プロダクト チームと時間を共にできるチャンスです。 詳細については、「DevelopMentor」の Web サイトをご覧ください。

来月

SuperGraph アプリケーションについてさらに作業を進めることになれば、 無関係な多数のイベントを送信しないバージョンの FileSystemWatcher と式の評価に取り組もうと思います。 また、これらの代わりにお話するかもしれない、小さな別のサンプルもあります。

Eric Gunnerson は、C# コンパイラ チームの QA 担当の責任者で、C# デザイン チームのメンバでもあり、 『A Programmer's Introduction to C#』の著者でもあります。 彼は、8 インチのフロッピー ディスクが何であるかを知っているぐらい昔からプログラミングを行っています。 さらに、かつては簡単にテープを装着できました。