다음을 통해 공유


VB.NET: Dynamically load assemblies

Introduction

Most .NET solutions there is a main assembly (for instance a Windows Form project) which produces an executable. Other times when a solution is made up of the main assembly and one or more class projects these binaries of the class projects will be default reside in the same folder as the main assembly. Other times a requirement may be to place supporting assemblies in a folder beneath the main executable folder or in another folder completely removed from the main assembly so other assemblies may use these binaries verses using the GAC (Global Assembly Cache). Once you need to dynamically load assemblies and load them out of different folders things start getting complicated, this article will provide base code to place supporting binaries in a folder beneath the main assembly folder or in another folder completely removed from the main assembly.

Storing support assemblies below the main assembly folder

In this scenario there is a Class project named ConcreteClasses referenced by a Windows Form project, SampleApplicationProbingPrivatePath. In SampleApplicationProbingPrivatePath application configuration file (app.config) the following section is added which 

<runtime>
  <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
    <probing privatePath="Libraries" />
  </assemblyBinding>
</runtime>

The probing element specifies application base subdirectories for the common language runtime to search when loading assemblies. Note subdirectories means a folder below the main assembly (executable), not above the main assembly folder.

Suppose there were language specific versions the following provides the ability to have probing done on other folders. The first instance found under, in this case Libraries will be used.

<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
  <probing privatePath="Libraries/en;Libraries/de;Libraries/fr"/>
</assemblyBinding>

Placing DLL files in the appropriate folder can be done via Post Build events under project properties, Compile tab, Build Event button, "Post build event command line" where there is a button "edit post build" for predefined macros (variables for folder names. Create a simple batch command e.g.

copy /B /Y "$(TargetDir)*.dll" "$(SolutionDir)bin\Release\Library"

If working with .NET Core, see the following blog "Deep-dive into .NET Core primitives", section deps.json.

Storing support assemblies in other folders

When the GAC is not an option the alternative is storing support assemblies in a folder such as C:\Program Files (x86)\MyCompanyName this can not be done using probing, instead this is done by subscribing to AppDomain.CurrentDomain.AssemblyResolve event.

Add a new code module to the main assembly with the following code which is responsible for handling a failed binding to an assembly. If the assembly is resolved any other calls will not need to revisit this method, if there is no resolution the method returns Nothing/null.

Imports System.IO
Imports System.Reflection
 
Namespace Modules
    Module Resolver
        Function ResolveEventHandler(sender As Object, args As  ResolveEventArgs) As  Assembly
 
            Dim executingAssemblies As Assembly  = Assembly.GetExecutingAssembly()
            Dim referencedAssembliesNames() As AssemblyName = executingAssemblies.GetReferencedAssemblies()
            Dim assemblyName As AssemblyName
            Dim dllAssembly As Assembly  = Nothing
 
            For Each  assemblyName In  referencedAssembliesNames
 
                'Look for the assembly names that have raised the "AssemblyResolve" event.
                If (assemblyName.FullName.Substring(0, assemblyName.FullName.IndexOf(",", StringComparison.Ordinal)) = args.Name.Substring(0, args.Name.IndexOf(",", StringComparison.Ordinal))) Then
 
                    ' Build path to place DLL
                    Dim tempAssemblyPath As String  = Path.Combine(My.Application.DllFolder, args.Name.Substring(0, args.Name.IndexOf(",", StringComparison.Ordinal)) & ".dll")
                    dllAssembly = Assembly.LoadFrom(tempAssemblyPath)
 
                    Exit For
 
                End If
            Next
 
            Return dllAssembly
 
        End Function
    End Module
End Namespace

To subscribe to this event, under project properties, Application tab, click "View Application Events" which opens a new code window, replace its contents with the following.

Required reference: Add a reference to System.Configuration for working with app setting.

Imports Microsoft.VisualBasic.ApplicationServices
Imports System.Configuration.ConfigurationManager
 
Namespace My
    Partial Friend  Class MyApplication
        Private Sub  MyApplication_Startup(sender As Object, e As  StartupEventArgs) Handles Me.Startup
 
            AddHandler AppDomain.CurrentDomain.AssemblyResolve,
                AddressOf Modules.ResolveEventHandler
 
        End Sub
 
    End Class
End Namespace

Next step is to modify app.config, add an entry for the folder which contains the DLL and a second entry for the DLL name.

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
    <startup>
        <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2" />
    </startup>
  <appSettings>
    <add key="DllFolder" value="C:\Program Files (x86)\Payne1" />
    <add key="DllName" value="ConcreteClasses.dll" />
  </appSettings>
</configuration>

In the file above, the DLL name is in the key DllName, the path is in the key DllFolder. In the provided source code the following class resides in ConcreteClasses.dll.

Public Class Person
    Public Property Identifier() As Integer
    Public Property FirstName() As String
    Public Property LastName() As String
 
    Public Overrides Function ToString() As String
        Return $"{Identifier} {FirstName} {LastName} "
    End Function
End Class

In the Windows form project there is one form, one button to create an instance of a Person.

Imports ConcreteClasses
 
Public Class  Form1
 
    Private Sub  GetPersonButton_Click(sender As Object, e As  EventArgs) Handles  GetPersonButton.Click
 
        Dim person As New  Person With  {.Identifier = 1, .FirstName = "Karen", .LastName = "Payne"}
        MessageBox.Show(person.ToString())
 
    End Sub
End Class

Compile both projects, in the main assembly folder copy the class dll files to the path in the app.config file followed by removing them from the main assembly folder. Next, double click on the windows form assemble/executable, press the button which will trigger the resolver to fire off ResolveEventHandler and resolve the DLL.

To ensure this worked properly, go back into the Person class and change the position of LastName in .ToString.

Public Class  Person
    Public Property  Identifier() As  Integer
    Public Property  FirstName() As  String
    Public Property  LastName() As  String
 
    Public Overrides  Function ToString() As String
        Return $"{Identifier} {LastName} {FirstName}"
    End Function
End Class

Change the path in app.config to another folder, rebuild the solution, copy the DLL to the new folder, delete the DLL from the main assembly folder. Rerun the project, press the button to verify all worked as expected.

Handling runtime exceptions

What happens if the supporting library is deleted or the folder is renamed on a client's computer? Without asserting for this the program will throw an embarrassing runtime exception. 

Since Visual Basic application class provides an event for unhandled exception subscribe to this event. In the following example little is done (more on this shortly) other than presenting a error message which once closed the application will close.

Imports Microsoft.VisualBasic.ApplicationServices
Imports System.Configuration.ConfigurationManager
 
Namespace My
    Partial Friend  Class MyApplication
        Private Sub  MyApplication_UnhandledException(sender As Object, e As  UnhandledExceptionEventArgs) _
            Handles Me.UnhandledException
 
            If e.Exception.Message.Contains("Could not load") Then
                MessageBox.Show("Please run setup, press OK to close this application")
            Else
                MessageBox.Show(e.Exception.Message)
            End If
        End Sub
        Private Sub  MyApplication_Startup(sender As Object, e As  StartupEventArgs) Handles Me.Startup
 
            AddHandler AppDomain.CurrentDomain.AssemblyResolve,
                AddressOf Modules.ResolveEventHandler
 
        End Sub
 
    End Class
End Namespace

 MyApplication_UnhandledException is only available by running outside of the IDE.

 There are many ways to enhance what happens in UnhandledException event, send an email to the author or log to an event log. For this article a TraceListener will be used. There are built in access to these listeners under the My namespace, here a simple singleton class will be used. The source for the library is here.

The listener is thread safe, provides easy access to create, write and close.

Option Infer On
Imports System.IO
Imports System.Runtime.CompilerServices
 
''' <summary>
''' Provide the ability to write to a log file across assemblies
''' </summary>
Public NotInheritable  Class BasicTraceListener
 
    Private Shared  ReadOnly Lazy As New  Lazy(Of BasicTraceListener) _
        (Function() New  BasicTraceListener())
 
    Public Shared  ReadOnly Property  Instance() As  BasicTraceListener
        Get
            Return Lazy.Value
        End Get
    End Property
 
    Private Shared  _textWriterTraceListener As TextWriterTraceListener
    ''' <summary>
    ''' Create new instance of trace listener
    ''' </summary>
    ''' <param name="fileName">From startup project app.config file to write too</param>
    ''' <param name="listenerName">From startup project app.config unique name of listener</param>
    Public Sub  CreateLog(fileName As String, listenerName As  String)
        _textWriterTraceListener = New  TextWriterTraceListener(fileName, listenerName)
        Trace.Listeners.Add(_textWriterTraceListener)
    End Sub
    ''' <summary>
    ''' Get file name and full path to log file
    ''' </summary>
    Public Function  ListenerLogFileName() As String
 
        If _textWriterTraceListener Is Nothing  Then
            Return ""
        End If
 
        Dim writer = CType(_textWriterTraceListener.Writer, StreamWriter)
        Dim stream = CType(writer.BaseStream, FileStream)
 
        Return stream.Name
 
    End Function
    ''' <summary>
    ''' Get listener name
    ''' </summary>
    ''' <returns></returns>
    Public Function  ListenerName() As  String
        If _textWriterTraceListener Is Nothing  Then
            Return ""
        End If
 
        Return _textWriterTraceListener.Name
    End Function
    ''' <summary>
    ''' Write information to disk without closing
    ''' </summary>
    Public Sub  Flush()
        If _textWriterTraceListener Is Nothing  Then
            Return
        End If
 
        If WriteToTraceFile Then
            _textWriterTraceListener.Flush()
        End If
 
    End Sub
 
    ''' <summary>
    ''' Write trace information to disk
    ''' </summary>
    Public Sub  Close()
        If _textWriterTraceListener Is Nothing  Then
            Return
        End If
 
        If WriteToTraceFile Then
            _textWriterTraceListener.Flush()
            _textWriterTraceListener.Close()
        End If
 
    End Sub
    Public Sub  Exception(message As  String, <CallerMemberName> Optional ByVal  callerName As  String = Nothing)
        WriteEntry(message, "error", callerName)
    End Sub
    Public Sub  Exception(ex As  Exception, <CallerMemberName> Optional ByVal  callerName As  String = Nothing)
        WriteEntry(ex.Message, "error", callerName)
    End Sub
    Public Sub  Warning(message As  String, <CallerMemberName> Optional ByVal  callerName As  String = Nothing)
        WriteEntry(message, "warning", callerName)
    End Sub
    Public Sub  Info(message As  String, <CallerMemberName> Optional ByVal  callerName As  String = Nothing)
        WriteEntry(message, "info", callerName)
    End Sub
    Public Sub  EmptyLine()
        _textWriterTraceListener.WriteLine("")
    End Sub
    ''' <summary>
    ''' Provides the ability to turn off logging
    ''' </summary>
    ''' <returns></returns>
    Public Property  WriteToTraceFile() As Boolean
    Private Sub  WriteEntry(ByVal  message As  String, ByVal type As String, ByVal  callerName As  String)
 
        If _textWriterTraceListener Is Nothing  Then
            Return
        End If
        If Not  WriteToTraceFile Then
            Return
        End If
 
        _textWriterTraceListener.Flush()
        _textWriterTraceListener.WriteLine($"{DateTime.Now:yyyy-MM-dd HH:mm:ss},{type},{callerName},{message}")
 
    End Sub
End Class

To use the listener, in application startup get the log file name from app.config along with the required listener name.

Last two keys are log file name (will reside in the main executable folder).

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
    <startup>
        <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2" />
    </startup>
  <appSettings>
    <add key="DllFolder" value="C:\Program Files (x86)\Payne1" />
    <add key="DllName" value="ConcreteClasses.dll" />
    <add key="AppListenerName" value="PayneListener" />
    <add key="AppListenerLogName" value="applicationLog.txt" />
    <add key="AppListenerWriteEnabled" value="True" />
  </appSettings>
</configuration>

Updated ApplicationEvents.vb file.

Imports LogLibrary
Imports Microsoft.VisualBasic.ApplicationServices
Imports System.Configuration.ConfigurationManager
 
Namespace My
    Partial Friend  Class MyApplication
        Private Property  NormalShutDown() As  Boolean
        Private Sub  MyApplication_UnhandledException(sender As Object, e As  UnhandledExceptionEventArgs) _
            Handles Me.UnhandledException
 
            NormalShutDown = False
 
            If e.Exception.Message.Contains("Could not load") Then
                MessageBox.Show("Please run setup, press OK to close this application")
                BasicTraceListener.Instance.Exception(e.Exception.Message)
            Else
                BasicTraceListener.Instance.Exception(e.Exception.Message)
                MessageBox.Show(e.Exception.Message)
            End If
 
            BasicTraceListener.Instance.Exception("Shutting down abnormally")
            BasicTraceListener.Instance.Close()
 
        End Sub
 
        Private Sub  MyApplication_Startup(sender As Object, e As  StartupEventArgs) Handles Me.Startup
 
            AddHandler AppDomain.CurrentDomain.AssemblyResolve,
                AddressOf Modules.ResolveEventHandler
 
            NormalShutDown = True
            '
            ' Configure Listener
            '
            BasicTraceListener.Instance.CreateLog(AppSettings("AppListenerLogName"), AppSettings("AppListenerName"))
            BasicTraceListener.Instance.WriteToTraceFile = CType(AppSettings("AppListenerWriteEnabled"), Boolean)
 
        End Sub
 
        Private Sub  MyApplication_Shutdown(sender As Object, e As  EventArgs) Handles  Me.Shutdown
            If NormalShutDown Then
 
                BasicTraceListener.Instance.Info("Shutting down")
                BasicTraceListener.Instance.Close()
 
            End If
        End Sub
    End Class
End Namespace

The private variable NormalShutDown is a flag to determine if the log file needs to be closed, if set to True this is done in MyApplication_ShutDown, if False the log file is closed in MyApplication_UnhandledException event.

The following line creates the listener.

BasicTraceListener.Instance.CreateLog(AppSettings("AppListenerLogName"), AppSettings("AppListenerName"))

This line enables logging.

BasicTraceListener.Instance.WriteToTraceFile = CType(AppSettings("AppListenerWriteEnabled"), Boolean)

Back to recording when a DLL is not resolved. In MyApplication_UnhandledException the following if statement checks to see if the problem was failing to load a DLL, if this is the case write the exception message to the log, for more information change e.Exception.Message to e.Exception.ToString().  After the if statement close the log and the application will terminate.

Running code samples from GitHub repository

  • Project SampleApplicationAssemblyResolve
    • Edit app.config in the project SampleApplicationAssemblyResolve, change the key DllFolder to an existing folder outside of the project folder.
    • Build the project, open the Bin\debug folder, copy the DLL file and debug file to the path in DllFolder.
    • Run the project from Windows Explorer.
    • Edit application configuration file in the Bin\Debug folder, change DllFolder path to an non-existing folder, run the application in Windows Explorer to trigger an unresolved result.
  • Project SampleApplicationProbingPrivatePath
    • Build the project,
    • In Windows Explorer, open Bin\debug
    • Create a folder ClassLibraries
    • Copy ConcreteClasses dll to ClassLibraries folder.
    • Delete ConcreteClasses from the debug folder
    • Run the project. (note there is no unhandledexception handler here).

Real life

The information provided is lacking with how to get DLL files into specific folders, this is intentional. For a real solution these files would be properly setup either by post build events or by using a free or paid for installer which is beyond the scope of this article as each developer will have their own idea for pushing support assemblies to their proper location, in other situations support assemblies may be pushed out via company service desk processes when a user logs into a network.

Summary

This article has provided two distinct ways to load assemblies outside the normal way of placing support assemblies into different folders than the main assembly folder. Simple use of a customer trace listener has been presented with an example for use in logging runtime exception.

Seel also

C#: Generic Type Parameters And Dynamic Types

Resources

Source code

https://karenpayneoregon.github.io/DebuggingVisualBasic/