Best Practices for Assembly Loading for portable applications

Some of you have certainly met with many different aspects of developing applications based on extensions. The following publication raises the question of practices for extensions in portable applications.

On msdn pages you will find well designed in theory and with almost identical title as ours: Best Practices for Assembly Loading . In short - we get a knowledge of the 'pros and cons' of different approaches and although unfortunately article lacks of practical examples it is worth to get familiar with it.



Reflections on Default Context Loading

GAC (Global Assembly Cache) as a place for our extensions in the case of architecture design for portable solutions is certainly not appropriate. The contents of the GAC may vary drastically between different operating systems and as a result - does not guarantee the consistency.

A good solution to prevent overfilling base directory is to put extensions in subdirectories. You can very easily define additional search paths (relative to the base directory) for extensions in the configuration file.

<?xml version="1.0"?>
<configuration>
  <runtime>
    <assemblyBinding xmlns="urn: schemas-microsoft-com:asm.v1">
       <!-- you can set more than one probing path -->
      <probing privateBinPath="Plugins;Plugins2\SubPlugins"/>
    </assemblyBinding>
  </runtime>
</configuration>

Application domain search path can also be easily defined in the code by creating a new, additional AppDomain.

static class Program
{
  static void Main()
  {
    AppDomainSetup pluginsDomainSetup = new AppDomainSetup
    {
      ApplicationBase = AppDomain.CurrentDomain.BaseDirectory,
      PrivateBinPath = @" Plugins1;Plugins2\SubPlugins"
    };
 
    AppDomain pluginsDomain = AppDomain.CreateDomain
      (Guid.NewGuid().ToString(), AppDomain.CurrentDomain.Evidence, pluginsDomainSetup);
 
    IPlugin plugin = (IPlugin)pluginsDomain.CreateInstanceAndUnwrap
      //this call uses full assembly name which is safe
      ("Plugin, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null", "Plugin.Test");
    //actual use of plugin	
    plugin.Use();		
 
    AppDomain.Unload(pluginsDomain);
  }
}

NotInheritable Class Program
  Private Sub New()
  End Sub
  Private Shared Sub Main()
    Dim pluginsDomainSetup As New AppDomainSetup() With { _
      Key .ApplicationBase = AppDomain.CurrentDomain.BaseDirectory, _
      Key .PrivateBinPath = " Plugins1;Plugins2\SubPlugins" _
    }
 
    Dim pluginsDomain As AppDomain = AppDomain.CreateDomain(Guid.NewGuid().ToString(), AppDomain.CurrentDomain.Evidence, pluginsDomainSetup)
 
    'this call uses full assembly name which is safe
    Dim plugin As IPlugin = DirectCast(pluginsDomain.CreateInstanceAndUnwrap("Plugin, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null", "Plugin.Test"), IPlugin)
    'actual use of plugin  
    plugin.Use()
 
    AppDomain.Unload(pluginsDomain)
  End Sub
End Class

Karmian way

As we read in article indicated above:

For assemblies that are loaded without context, the problem can be caused by using the Assembly.LoadFile method to load the same assembly from different paths. The runtime considers two assemblies that are loaded from different paths to be different assemblies, even if their identities are the same.

In addition to type identity problems, multiple versions of an assembly can cause a MissingMethodException if a type that is loaded from one version of the assembly is passed to code that expects that type from a different version. For example, the code might expect a method that was added to the later version.

More subtle errors can occur if the behavior of the type changed between versions. For example, a method might throw an unexpected exception or return an unexpected value.

We do not use multiple application domains and Assembly.Load method as we encountered problems during objects serialization. What we use is Assembly.FileLoad because this way we avoid LoadFromContext exception that is thrown when no valid AppDomain is configured for assembly file path and Assembly.Load method is used. When we load those assemblies we put them in IDictionary collection, where a key is a full name of the loaded assembly. Next, we handle AppDomain.CurrentDomain.AssemblyResolve and if application is looking for a desired assembly, we locate it by its full name so we are sure there is no mistake in PublicKey or assembly version. This pattern gives us also the guarantee that no exact assemblies loaded from different paths are used - our loaded assemblies collection can hold only one assembly under certain key (assembly full name) so the rest is just ignored.

As you can see this pattern is very flexible - code can be easily modified to load extensions from multiple paths (including network sources), dynamic assemblies, streams and other.

using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Windows.Forms;
 
namespace Karmian.Core.Providers
{
  [Serializable]
  internal class CorePluginProvider
  {
    public CorePluginProvider()
    {
      Initialize();
    }
 
    public IDictionary<string, Assembly> Plugins { get; private set; }
 
    public void Initialize()
    {
      Plugins = new SortedDictionary<string, Assembly>();
      foreach (var file in Directory.GetFiles(
         AppDomain.CurrentDomain.BaseDirectory + @"Plugins",
         Application.ProductName + ".Plugin.*.dll", 
         SearchOption.TopDirectoryOnly))
      Plugins.Add(AssemblyName.GetAssemblyName(file).FullName, Assembly.LoadFile(file));
 
      AppDomain.CurrentDomain.AssemblyResolve += CurrentDomain_AssemblyResolve;
    }
 
    private Assembly CurrentDomain_AssemblyResolve(object sender, ResolveEventArgs args)
    {
      if (!Plugins.ContainsKey(args.Name))
        throw new InvalidOperationException(
              String.Format("Could not determine valid assembly in context for assembly name '{0}'.", args.Name));
      return Plugins[args.Name];
    }
  }
}

Imports System.Collections.Generic
Imports System.IO
Imports System.Reflection
Imports System.Windows.Forms
 
Namespace Karmian.Core.Providers
  <Serializable> _
  Friend Class CorePluginProvider
    Public Sub New()
      Initialize()
    End Sub
 
    Public Property Plugins() As IDictionary(Of String, Assembly)
      Get
        Return m_Plugins
      End Get
      Private Set
        m_Plugins = Value
      End Set
    End Property
    Private m_Plugins As IDictionary(Of String, Assembly)
 
    Public Sub Initialize()
      Plugins = New SortedDictionary(Of String, Assembly)()
      For Each file As var In Directory.GetFiles(AppDomain.CurrentDomain.BaseDirectory & "Plugins", Application.ProductName & ".Plugin.*.dll", SearchOption.TopDirectoryOnly)
        Plugins.Add(AssemblyName.GetAssemblyName(file).FullName, Assembly.LoadFile(file))
      Next
 
      AddHandler AppDomain.CurrentDomain.AssemblyResolve, AddressOf CurrentDomain_AssemblyResolve
    End Sub
 
    Private Function CurrentDomain_AssemblyResolve(sender As Object, args As ResolveEventArgs) As Assembly
      If Not Plugins.ContainsKey(args.Name) Then
        Throw New InvalidOperationException([String].Format("Could not determine valid assembly in context for assembly name '{0}'.", args.Name))
      End If
      Return Plugins(args.Name)
    End Function
  End Class
End Namespace

Comments

Post new comment

  • Lines and paragraphs are automatically recognized. The <br /> line break, <p> paragraph and </p> close paragraph tags are inserted automatically. If paragraphs are not recognized simply add a couple blank lines.

More information about formatting options

CAPTCHA
This question is for testing whether you are a human visitor and to prevent automated spam submissions.
2 + 2 =
This simple math problem is designed for BOT - you just have to enter '7'.