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
- Microsoft: Resolve assembly loads
- Microsoft: Assemblies in .NET