VB.NETでもDLLをアンロードしたい

はじめに

この記事はVisual Basic Advent Calendar 2015の6日目のエントリです。

今回はちょっとした理由で自前でプラグインっぽい何かの仕組みを構築する都合があったので、それについての記事です。

やりたい事

今回の仕組みの前提として大体以下のような感じです。

  • プラグインをホストするアプリケーションは常駐する
  • 必要な機能をプラグインとして実装して実行中に動的にロードする
  • 1つの機能を1つのクラスとして表現し、機能を表すクラスは属性でマークする
    • よって、ホスト側は実装時にはプラグインのクラス名は分からない
    • 実行するクラスは属性から決定する
  • ホストが実行中に機能をdllごと更新できるように使い終わったdllはアンロードしたい
  • ホストからプラグインに情報を渡したい

環境

弊社での検証結果は以下の通りとなっています。

AppDomain

.NETではアセンブリ*1AppDomainごとにロードされ、アンロードする場合はAppDomainごとアンロードする必要があります。

アセンブリ単独ではアンロード出来ません。 ほいほいアセンブリをアンロード出来たらプログラマのメンタルアセンブリをまたがるクラス間の依存関係が壊れてしまいますからね。

その為、アセンブリをアンロードするためにアプリケーションが実行されるドメインプラグインが実行するドメインを分けて管理します。

アセンブリ構成

今回のプラグインっぽいアレでは、こんな感じにアセンブリを分割しました。

アセンブリ間の依存とアセンブリがロードされるドメインは以下のような感じです。

f:id:jyuch:20151119210424p:plain

実装

アセンブリの依存順にコードを見てみましょう。

' PluginMarker.dll

Public Interface IExecutablePlugin
    Function Execute(arg As String) As String
End Interface

<AttributeUsage(AttributeTargets.Class, AllowMultiple:=False)>
Public Class PluginAttribute
    Inherits Attribute
End Class

PluginMarker.dllではプラグインが実装すべきインターフェースとプラグインを表すクラスを示す属性が定義されています。 それだけです。

' PluginProxy.dll

Imports System.Reflection
Imports System.Text
Imports PluginMarker

Public Class Proxy
    Inherits MarshalByRefObject

    Private targets As IEnumerable(Of Type)

    Public Sub LoadDLL(dllAbsolutePath As String)
        Dim asm As Assembly = Assembly.LoadFile(dllAbsolutePath)
        targets = asm.GetTypes().
            Where(Function(it) it.GetCustomAttribute(GetType(PluginAttribute)) IsNot Nothing)
    End Sub

    Public Function Execute(arg As String) As String
        Dim sb As StringBuilder = New StringBuilder()
        For Each it As Type In targets
            Dim i As IExecutablePlugin =
                CType(Activator.CreateInstance(it), IExecutablePlugin)
            sb.Append(i.Execute(arg)).AppendLine()
        Next
        Return sb.ToString()
    End Function

End Class

PluginProxy.dllプラグインドメイン内でプラグインアセンブリからクラスを検索し実行する役割を担います。 まぁ、仰々しい事を書いていますが、コードを見てもらえば分かりますがかなり単純なコードとなっています。

大きな流れとしては

  1. 指定されたパスのアセンブリをロード
  2. アセンブリ内のクラスの中で属性が付与されたクラスをフィルタリング
  3. インスタンスを生成して実行

といった感じです。

また、注意点としてドメインの境界を越えるクラスはMarshalByRefObjectを継承するかSerializable属性が付与されている(=シリアル化が可能)なクラスでなくてはなりません。

ProxyクラスはMarshalByRefObjectを継承し、パラメータのStringSerializable属性が付与されているのでokですね。

' UnloadablePlugin.exe

Imports PluginProxy
Imports System.Reflection
Imports System.IO

Module Module1

    Sub Main()
        Dim pluginDLLPath As String = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly.Location), "Assembly", "Plugin.dll")

        While (File.Exists(pluginDLLPath))
            Dim pluginProxyPath As String = Path.Combine(
                        Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location),
                        "PluginProxy.dll")
            Dim pluginProxyAssm As Assembly = Assembly.LoadFile(pluginProxyPath)
            Dim pluginDomain As AppDomain = AppDomain.CreateDomain("PluginDomain")
            Dim pluginProxy As Proxy = CType(pluginDomain.CreateInstanceAndUnwrap(
                pluginProxyAssm.FullName,
                GetType(Proxy).FullName), Proxy)

            pluginProxy.LoadDLL(pluginDLLPath)
            Dim returnMessage As String = pluginProxy.Execute("あなたはだあれ?")
            Console.WriteLine(">>>")
            Console.WriteLine(returnMessage.Trim)
            Console.WriteLine("<<<")
            Console.WriteLine()
            AppDomain.Unload(pluginDomain)
            Threading.Thread.Sleep(5000)
        End While

    End Sub

End Module

最後にUnloadablePlugin.exeですが、5秒ごとにあらかじめ決められたパスのdllが存在しているか確認を行い、存在したらProxy経由でロードして実行を行います。

  1. PluginProxyアセンブリを読み込む
  2. プラグインドメインを作成する
  3. プラグインドメイン内でProxyインスタンスを生成する
  4. Proxy経由でプラグインに値を渡し、受け取った値をコンソールに表示
  5. プラグインをプロキシごとアンロード

って感じでやってます。 UnloadablePlugin.exe(デフォルトドメイン)からはPluginを直接触っていないのでドメインにロードもされていません。

今回はプログラム中で固定でdllパスを参照していますが、特定のディレクトリをスキャンしてdllを読み込むという事も出来ますね。

'Plugin.dll

Imports PluginMarker
Imports System.Reflection

<Plugin>
Public Class Plugin1
    Implements IExecutablePlugin

    Public Function Execute(arg As String) As String Implements IExecutablePlugin.Execute
        Console.WriteLine($"PluginClass={NameOf(Plugin1)}")
        Console.WriteLine($"arg={arg}")
        Console.WriteLine($"Domain={AppDomain.CurrentDomain.FriendlyName}")
        Console.WriteLine($"Assembly={Assembly.GetCallingAssembly.FullName}")
        Console.WriteLine()
        Return New String(arg.Reverse.ToArray)
    End Function
End Class

<Plugin>
Public Class Plugin2
    Implements IExecutablePlugin

    Public Function Execute(arg As String) As String Implements IExecutablePlugin.Execute
        Console.WriteLine($"PluginClass={NameOf(Plugin2)}")
        Console.WriteLine($"arg={arg}")
        Console.WriteLine($"Domain={AppDomain.CurrentDomain.FriendlyName}")
        Console.WriteLine($"Assembly={Assembly.GetCallingAssembly.FullName}")
        Console.WriteLine()
        Return arg + arg
    End Function
End Class

Public Class Plugin3
    Implements IExecutablePlugin

    Public Function Execute(arg As String) As String Implements IExecutablePlugin.Execute
        Console.WriteLine($"PluginClass={NameOf(Plugin3)}")
        Console.WriteLine($"arg={arg}")
        Console.WriteLine($"Domain={AppDomain.CurrentDomain.FriendlyName}")
        Console.WriteLine($"Assembly={Assembly.GetCallingAssembly.FullName}")
        Return New String(arg.Reverse.ToArray) + New String(arg.Reverse.ToArray)
    End Function
End Class

プラグインの方は特に意味はありません。 パラメータとして受け取った文字列を適当に加工して返しているだけです。

実行結果では、Plugin属性を付与していないPlugin3は実行されていません。 また、実行ドメインPluginDomainとなっている事が分かります。

PluginClass=Plugin1
arg=あなたはだあれ?
Domain=PluginDomain
Assembly=PluginProxy, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null

PluginClass=Plugin2
arg=あなたはだあれ?
Domain=PluginDomain
Assembly=PluginProxy, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null

>>>
?れあだはたなあ
あなたはだあれ?あなたはだあれ?
<<<

リフレクションの代償

正直なところ、VBの主戦場たる生産性アプリケーションの領域ではまず使わない手法ですね。 まぁ、正直かなり面倒だしこの手法が必要になる場面が絶対的に少ないですしおすし。

また、動的に型を扱うということは本来ならコンパイラが面倒を見てくれる型の検証などを全て自分で行わなくてはならず、うっかりミスで実行時に死ぬなんてことがザラにあるのでその辺も気をつける必要があります。

_人人人人人人_
> 突然の死 <
 ̄Y^Y^Y^Y^Y^Y^ ̄

また、Static(Shared)おじさんばりに都市伝説かもしれませんが、リフレクションを例外無しに全面的に禁止しているコーディング規則もあるらしいのでそういうところでは使えませんね。

おわりに

とりあえずリフレクションを使えるようにして失った感情の輝きを取り戻したいですね。いや、こっちのお話です。

まぁ、使うことはあまりないと思いますが、こういうことが出来るんだな〜程度に覚えておくといつか役に立つ日がくるといいな〜と願いつつVisual Basic Advent Calendar 2015の6日目のエントリとさせて頂きます。

github.com

おつかれさまでした。

*1:dllとかexeのアレ