【VB.NET】【WPF】参照している DLL が見つからないときにエラーを表示するには

何もエラーを出してくれないと困る

開発者が作成したアプリケーション(EXE、DLL 等の組み合わせ)をユーザーに渡すと、ユーザーによっては DLL を無視して EXE だけを自分のフォルダにコピーしたり、一部の DLL をコピーし損なったりすることがあります。

そんな状態の EXE をダブルクリックした後のユーザーの怒りは開発者に向けられることになります。

ユーザーA

動かない! うんともすんとも言わない!
ヽ(`Д´#)ノ

どうなってんのヨ!ポンコツアプリ!
(キ´゜皿゜)

ユーザーB

あと少しで処理が終わると思ったら突然アプリケーションが終了した!
(# ゚Д゚)

どうしてくれんのヨ! 時間を返せ!
(キ´゜皿゜)

・・・おそろしい。。。
((((;゚Д゚))))ガクガクブルブル

VB.NET(っていうか .NET Framework?)のアプリケーションって、存在しない DLL をロードしようとした瞬間、何も言わずに黙って終了しちゃうことが多いんですよね。もともとこうでしたっけ?

なにか問題があるならちゃんと伝えてくれないと困るんですよね

処理の途中までは「まったく問題ありません。安心して任せてください。( ー`дー´)キリッ」って顔してたくせに、最後の最後になって黙ってトンズラってどういうこと!? お母さんはそんな子に育てた覚えはありませんヨ!
( ゚д゚ )クワッ!!

 

ということで、アプリケーションが参照(事前バインディング)している DLL が存在するか(ロードできるか)をアプリケーション開始時にチェックして、存在しない(ロードできない)ときはエラーを表示する方法を検討してみました。

エラーを補足する構文・イベントは?

本題に入る前に、まず、エラーを補足するための構文・イベントをいくつか試してみて、その中から良さそうなものを採用したいと思います。

MEMO

先に言ってしまいますが、この試行は、結果的に、この記事の本題とあまり関係ない内容になりました。

しかし、この情報は選択肢の幅を広げてくれる可能性があるので、削除せずに残しておきたいと思います。

Try/Catch

エラーの補足と言えば Try/Catch ですね。簡単にエラーを補足できるだろうと思いきや・・・あれれ?

Try/Catch でエラーを補足できない?

たとえばデスクトップアプリケーションに ClosedXML(Excel ファイルの読み書きを行うライブラリ)の参照を追加して、Loaded イベントに下記のコードを記載します。

'Imports ClosedXML.Excel

Try
    Dim wb As XLWorkbook = New XLWorkbook
Catch ex As Exception
    MsgBox(ex.Message)
    System.Environment.Exit(1)
End Try

そして、ビルドした後、わざとエラーを出すために、ビルド出力パスに出力された ClosedXML.dll を削除してからアプリケーションを実行してみます。

しかし・・・アプリケーションはエラーを表示することもなく、寡黙に・・・強制終了してしまいます。

なじぇ?!

Try/Catch でエラーを補足するには

Try/Catch でエラーを補足できない理由を調べたところ、Stack Overflow でその解説をしている投稿を見つけました。ちょっと意訳しますが、こんなことが書いてありました。

.NET のアセンブリは CLR(Common Lungage Runtime = 共通言語ランタイム)によってオンデマンドでロードされます。つまり、通常、アセンブリは、そのアセンブリの型を使用するメソッドが JIT(実行時コンパイル)されるまではロードされません。

メインメソッドの Try/Catch ブロックでアセンブリのロードエラーを補足できないとしたら、おそらくその Try/Catch の中でアセンブリの型を使用しているからだと思われます。この場合、メインメソッドが実際に実行される前に例外が発生しているわけです。

アセンブリの型を使用するコードをメインメソッドから独立した関数に移して、メインメソッドの Try/Catch の中からその関数を呼び出せば、例外を補足できるはずです。

Stack Overflow - Can I catch a missing dll error during application load in C#?

前述の投稿を参考にして、DLL にアクセスするコードを Try/Catch の外に出してみました。こんな感じです。

Try
    Call CreateWorkbook
Catch ex As Exception
    MsgBox(ex.Message)
    System.Environment.Exit(1)
End Try
Sub CreateWorkbook
    Dim wb As XLWorkbook = New XLWorkbook
End Sub

これをビルドして、ビルド出力パスに出力された ClosedXML.dll を削除してからアプリケーションを実行してみると・・・。

おお。今度は例外を補足してエラーを表示することができました。

AssemblyResolve イベント

今度は Try/Catch を使わず、AppDomain.AssemblyResolve イベントを試してみます。これはアセンブリの解決が失敗したときに発生するイベントです。

本来、AppDomain.AssemblyResolve イベントは、アセンブリの柔軟なロードを実現するためのもの(ロードに失敗したアセンブリを指定の場所からダウンロードする等)らしいので、このような使い方が適切かどうか分かりませんが。。。

AddHandler AppDomain.CurrentDomain.AssemblyResolve, AddressOf CurrentDomain_AssemblyResolve

Call CreateWorkbook
Function CurrentDomain_AssemblyResolve(sender As Object, e As ResolveEventArgs) As Assembly
    
    MsgBox("下記の DLL が見つかりません。" & vbCrLf & vbCrLf & e.Name)
    System.Environment.Exit(1)

    Return Assembly.Load(e.Name)

End Function
Sub CreateWorkbook
    Dim wb As XLWorkbook = New XLWorkbook
End Sub

これをビルドして、ビルド出力パスに出力された ClosedXML.dll を削除してからアプリケーションを実行してみると下図のエラーが表示されます。

UnhandledException イベント

今度は UnhandledException イベントを試してみます。これは補足されていない例外があるときに発生するイベントです。

AddHandler AppDomain.CurrentDomain.UnhandledException, AddressOf CurrentDomain_UnhandledException

Call CreateWorkbook
Sub CurrentDomain_UnhandledException(sender As Object, e As UnhandledExceptionEventArgs)
    Try
        Dim ex As Exception = DirectCast(e.ExceptionObject, Exception)
        MsgBox(ex.Message)
    Finally
        System.Environment.Exit(1)
    End Try
End Sub
Sub CreateWorkbook
    Dim wb As XLWorkbook = New XLWorkbook
End Sub

これをビルドして、ビルド出力パスに出力された ClosedXML.dll を削除してからアプリケーションを実行してみると下図のエラーが表示されます。

FirstChanceException イベント

今度は FirstChanceException イベントを試してみます。Microsoft Docs に記載されている FirstChanceException イベントの説明には下記のように書いてあります。

アプリケーション ドメイン内の例外ハンドラーに対する呼び出し履歴をランタイムが検索する前に、マネージド コード内で例外がスローされた場合に発生します。

ん? 分かったような分からないような気がしたので、さらに調べてみたところ、このイベントは

アプリケーション内のあらゆる例外を補足するイベント

だそうです。

なんか便利そうな気がしますが、しかし、それはつまり、多くのノイズを拾ってしまうということでもあるようです。従ってこのイベントは製品版のアプリケーション内では使用しないようにして、開発時やデバッグ時のみに使用するのが良いようです。

とりあえず試してみようと思って、下記のコードを書いてみました(イベント登録にラムダ式を使用してみました)。

AddHandler AppDomain.CurrentDomain.FirstChanceException,
    Sub(_sender, _e)
        MsgBox(_e.Exception.ToString)
        System.Environment.Exit(1)
    End Sub

Call CreateWorkbook
Sub CreateWorkbook
    Dim wb As XLWorkbook = New XLWorkbook
End Sub

これをビルドして、ビルド出力パスに出力された ClosedXML.dll を削除してからアプリケーションを実行してみると下図のエラーが表示されます。

ドンブリ勘定ならぬドンブリエラー情報みたいな・・・。

ここで[OK]をクリックすると、続けて次のメッセージが表示されます。

これは System.Environment.Exit(1) に対するイベントみたいですね。なるほど、ノイズが多そうです。

このダイアログは表示されると(OK をクリックしてないのに)すぐに自動的に閉じました。

DLL が見つからないときにエラーを表示するには

エラーを補足する構文・イベントをいくつか試したところで本題に戻りましょう。

DLL が見つからないときにエラーを表示するには・・・?

(1)汎用的でない方法

単に、特定の DLL が EXE と同じ場所にあるかどうかを調べるだけなら System.IO.File.Exists を使って調べれば良いですね。

'Imports System.IO
'Imports System.Reflection.Assembly

Dim appFolder As String = Path.GetDirectoryName(GetExecutingAssembly.Location)
Dim dllList As New List(Of String) From {"A.Dll", "B.DLL"}

For Each dll As String In dllList
    Dim dllFullName As String = appFolder & "\" & dll
    If File.Exists(dllFullName) = False Then
        MsgBox("下記の DLL が見つかりません。" & vbCrLf & vbCrLf & dllFullName)
        System.Environment.Exit(1)
    End If
Next

とても簡単です。でもこのコードでは全然汎用的に使えません。。。

(2)参照している DLL を取得してロードしてみる方法

参照しているアセンブリを自動的に取得する

というわけで、今度は、プロジェクトから参照しているアセンブリを自動的に取得してみましょう。

'Imports System.Reflection

Dim oExecutingAssembly As Assembly = Assembly.GetExecutingAssembly

For Each oReferencedAssembly as AssemblyName In oExecutingAssembly.GetReferencedAssemblies

    MsgBox(oReferencedAssembly.FullName)

Next

でも上記コードの型 AssemblyName の .FullName プロパティはファイルのフルパスではなく、下記のようなアセンブリのフルネーム(完全名、表示名)なので、前述の System.IO.File.Exists と組み合わせて利用することはできません。

ClosedXML, Version=0.94.2.0, Culture=neutral, PublickeyToken=null

プロジェクトから参照している DLL って、ロードが完了するまではロード元の場所が確定しないんですよね。

参照しているアセンブリをロードしてみる

System.IO.File.Exists で DLL の存在の有無を調べることができないなら、実際に DLL のロードを試行してロードできたかどうかを調べれば良さそうです。

下記のようなコードになります。

Dim oExecutingAssembly As Assembly = Assembly.GetExecutingAssembly

For Each oReferencedAssembly as AssemblyName In oExecutingAssembly.GetReferencedAssemblies

    Try
        Dim oAssembly As Assembly = Assembly.Load(oReferencedAssembly.FullName)
    Catch ex As Exception
        MsgBox("下記の DLL をロードできませんでした。" & vbCrLf & vbCrLf & oReferencedAssembly.FullName)
        System.Environment.Exit(1)
    End Try

Next

これをビルドして、ビルド出力パスに出力された ClosedXML.dll を削除してからアプリケーションを実行してみると下図のエラーが表示されます。

まぁ、こんな感じでイイんじゃね? と私的には思いましたが・・・まだ下記の2つの問題があることに気付きました。

  • UI(Window の xaml)にタグを埋め込む DLL の場合、このままでは機能しない。
    (あいかわらずエラーメッセージが表示される前にアプリケーションが終了してしまう)
  • 実際に使用するタイミングではない DLL をロードしたままで良いかという問題

これらの課題についてちょっと考えてみましょう。

課題(1)UI(Window の xaml)にタグを埋め込む DLL への対応

まず Extended WPF Toolkit の ColorPicker について簡単に説明させてください。

Extended WPF Toolkit というコントロール群に含まれる ColorPicker を使用するには、Window の xaml に下記のようなタグを挿入します。(ちなみに話はズレますが、ColorPicker の使い方の詳細はこちらを参照してください)

<xctk:ColorPicker />

プロジェクトのビルド後にタグを埋め込んだウィンドウを表示すると、そのウィンドウに下図のようなカラーピッカーの UI が表示されます。

このように、タグを使用する DLL の場合、ウィンドウの Loaded イベントや Initialized イベントに DLL の存在をチェックするコードを書いても役に立たないんですよね。

なぜなら、Loaded イベントや Initialized イベントに書いたコードが実行される前にその DLL へのアクセスが発生するため、(その DLL をロードできなかった場合は)エラーメッセージが表示される前にアプリケーションが終了してしまうからです。

ではどうするかというと・・・Application クラスの Startup イベントに記述すれば良いですね。

課題(2)ロードした DLL を解放するには

実際に使用するタイミングでもないのに、ロードできるかどうかをテストするためだけに DLL をロードした場合、メモリを無駄使いしてないかとか、予想外の変な問題が起きたりしないか気になりますよね(なりません?)。

余計なトラブルを避けるために、テスト用にロードした DLL はテスト後に解放しておきたいところです。それには次の手順を踏みます。

  1. 新規アプリケーションドメインを作成する
  2. 作成したアプリケーションドメイン内にテストする DLL をロードする
  3. テストが終了したらそのアプリケーションドメインを閉じる

完成形

課題(1)と課題(2)の対策を反映したコードは下記のようになります。

Class Application

    Private Sub Application_Startup(sender As Object, e As StartupEventArgs) Handles Me.Startup

        Dim oDllCheckDomain As AppDomain = appDomain.CreateDomain("DllCheckDomain")
        Dim oTypeOfDllCheckClass As Type = GetType(DllCheckClass)
        Dim oDllCheckClass As DllCheckClass = oDllCheckDomain.CreateInstanceAndUnwrap(Assembly.GetExecutingAssembly.FullName, oTypeOfDllCheckClass.FullName)
        Dim DllsNotLoadable As List(Of String) = oDllCheckClass.DoDllCheck

        'MsgBox("(1) チェック用ドメインのアンロード前")

        AppDomain.Unload(oDllCheckDomain)

        'MsgBox("(2) チェック用ドメインのアンロード後")

        If DllsNotLoadable.Count > 0 Then
            MsgBox("下記のアセンブリをロードできませんでした。" & vbCrLf & vbCrLf & Join(DllsNotLoadable.ToArray, vbCrLf), MsgBoxStyle.Exclamation, My.Application.Info.ProductName)
            System.Environment.Exit(1)
        Else
            MsgBox("DLL チェック問題なし!")
        End If

    End Sub

End Class
'Imports System.Reflection

Class DllCheckClass
    Inherits MarshalByRefObject

    Public Function DoDllCheck As List(Of String)

        Dim DllsNotLoadable As New List(Of String)

        Dim oExecutingAssembly As Assembly = Assembly.GetExecutingAssembly

        For Each oReferencedAssembly as AssemblyName In oExecutingAssembly.GetReferencedAssemblies
            Try
                Dim oAssembly As Assembly = Assembly.Load(oReferencedAssembly.FullName)
                'MsgBox($"下記のアセンブリをロードしました。{vbCrLf & vbCrLf & oAssembly.Location}")
            Catch ex As Exception
                DllsNotLoadable.Add(oReferencedAssembly.Name)
            End Try
        Next

        Return DllsNotLoadable

    End Function

End Class

これをビルドして、ビルド出力パスに出力された ClosedXML.dll を削除してからアプリケーションを実行してみると下図のエラーが表示されます。

MEMO

ちなみに、下記のようにすると、いったんロードした DLL が解放されたかどうかを確認できます。

  1. AppDomain.Unload の直前でディスク上のロード済み DLL を選択して Delete キーを押す。
    → DLL がロード中なので削除できないことを確認。

  2. AppDomain.Unload の直後で先ほど削除できなかった DLL を選択して Delete キーを押す。
    → DLL が解放されたので削除できることを確認。

自分としてはこんな感じで・・・いいかな。(*´ω`*)

コメントの投稿

avatar
  購読する  
通知を受け取る対象