Welcome to CSharp Labs

Using Assembly Plugins through Application Domains

Monday, August 12, 2013

Assemblies can be loaded at runtime, through reflection, using the System.Reflection.Assembly class into the current application domain. Unfortunately, there is no way to unload an assembly without unloading the entire application domain that contains it. To allow loading and unloading an assembly, I have created the AssemblyPlugin class which loads and unloads assemblies in an isolated application domain through remoting.

How it Works

When the AssemblyPlugin class is initialized, a dedicated application domain is created to load assembly references, create entities and class instances as well as instantiate an instance of PrivateRemoteAssembly in the new application domain (more on this later):

        /// <summary>
        /// Initializes the plugin from an assembly given it's file name or path.
        /// </summary>
        /// <exception cref="System.ArgumentNullException">assemblyFile is null.</exception>
        /// <exception cref="System.IO.FileNotFoundException">assemblyFile is not found, or the module you are trying to load does not specify a filename extension.</exception>
        /// <exception cref="System.IO.FileLoadException">A file that was found could not be loaded.</exception>
        /// <exception cref="System.BadImageFormatException">assemblyFile is not a valid assembly.</exception>
        /// <exception cref="System.ArgumentException">The assemblyFile parameter is an empty string ("").</exception>
        /// <exception cref="System.IO.PathTooLongException">The assembly name is too long.</exception>
        /// <exception cref="System.IO.FileNotFoundException">An assembly was not found.</exception>
        public AssemblyPlugin(string assemblyFile)
        {
            if (assemblyFile == null)
                throw new ArgumentNullException("assemblyFile");

            //get current domain
            AppDomain current = AppDomain.CurrentDomain;
            //get existing setup
            AppDomainSetup currentSetup = current.SetupInformation;
            //setup domain settings from existing setup
            AppDomainSetup setup = new AppDomainSetup
            {
                ApplicationBase = currentSetup.ApplicationBase,
                PrivateBinPath = currentSetup.PrivateBinPath,
                PrivateBinPathProbe = currentSetup.PrivateBinPathProbe
            };

            //create the new domain with a random yet relevant FriendlyName
            _Domain = AppDomain.CreateDomain(
                string.Format("{0} - AssemblyPluginContainer[{1}]", current.FriendlyName, Guid.NewGuid()),
                null,
                setup);

            try
            {
                //attempts to create the plugin
                _Plugin = CreateEntity<PrivateRemoteAssembly>(assemblyFile);
            }
            catch
            {
                //if the assembly failed to load, unload the domain
                AppDomain.Unload(_Domain);
                //rethrow the exception
                throw;
            }
        }

To pass a type between application domains, the type must be serializable. To communicate between application domains, a type must derive from MarshalByRefObject. Types are initially created in the new AppDomain through the CreateEntity method to enable access across application domain boundaries:

            /// <summary>
            /// Locates the specified type from this assembly and creates an instance of
            /// it using the system activator, with optional case-sensitive search and having
            /// the specified arguments.
            /// </summary>
            /// <typeparam name="TReturn">The type of object to return where TReturn : MarshalByRefObject.</typeparam>
            /// <param name="typeName">The <see cref="System.Type.FullName"/> of the type to locate.</param>
            /// <param name="args">An array that contains the arguments to be passed to the constructor. This
            /// array of arguments must match in number, order, and type the parameters of
            /// the constructor to be invoked. If the default constructor is desired, args
            /// must be an empty array or null.</param>
            /// <returns>An instance of the specified type, or null if typeName is not found. The
            /// supplied arguments are used to resolve the type, and to bind the constructor
            /// that is used to create the instance.</returns>
            /// <exception cref="System.ArgumentException">typeName is an empty string ("") or a string beginning with a null character.
            /// -or-The current assembly was loaded into the reflection-only context.</exception>
            /// <exception cref="System.ArgumentNullException">typeName is null.</exception>
            /// <exception cref="System.MissingMethodException">No matching constructor was found.</exception>
            /// <exception cref="System.NotSupportedException">A non-empty activation attributes array is passed to a type that does not
            /// inherit from System.MarshalByRefObject.</exception>
            /// <exception cref="System.IO.FileNotFoundException">typeName requires a dependent assembly that could not be found.</exception>
            /// <exception cref="System.IO.FileLoadException">typeName requires a dependent assembly that was found but could not be loaded.-or-The
            /// current assembly was loaded into the reflection-only context, and typeName
            /// requires a dependent assembly that was not preloaded.</exception>
            /// <exception cref="System.BadImageFormatException">typeName requires a dependent assembly, but the file is not a valid assembly.
            /// -or-typeName requires a dependent assembly which was compiled for a version
            /// of the runtime later than the currently loaded version.</exception>
            public TReturn CreateInstance<TReturn>(string typeName, params object[] args) where TReturn : MarshalByRefObject
            {
                //create and return instance
                return (TReturn)_Assembly.CreateInstance(
                    typeName,
                    false,
                    BindingFlags.CreateInstance,
                    null,
                    args,
                    null,
                    null);
            }

An instance of PrivateRemoteAssembly is created in the new application domain and is responsible for communicating between application domains. When PrivateRemoteAssembly is initialized, the PrivateRemoteAssemblyLoader is initialized for loading the assembly and all references in the new application domain.

To load all references, a collection of loaded assembly names or paths and assemblies are maintained. The target assembly is loaded and all references are recursively loaded. If any reference fails to load, the AppDomain.CurrentDomain.AssemblyResolve event is fired and the assembly is loaded from file:

                /// <summary>
                /// Resolves the assembly reference.
                /// </summary>
                /// <param name="sender"></param>
                /// <param name="args"></param>
                /// <returns>The loaded assembly.</returns>
                private Assembly CurrentDomain_AssemblyResolve(object sender, ResolveEventArgs args)
                {
                    //get AssemblyName
                    AssemblyName assemblyName = new AssemblyName(args.Name);
                    //get path to assembly
                    string assemblyPath = Path.Combine(_CurrentAssemblyDirectory, assemblyName.Name + ".dll");

                    //check if assembly exists
                    if (File.Exists(assemblyPath))
                        //load assembly
                        return LoadAssembly(assemblyPath);

                    //could not load assembly
                    return null;
                }

Actual loading of assemblies is facilitated through two loading methods, the first that loads an assembly from file and the other which loads from the AssemblyName:

                /// <summary>
                /// Loads an assembly given it's file name or path.
                /// </summary>
                /// <param name="assemblyFile">The name or path of the file that contains the manifest of the assembly.</param>
                /// <returns>The loaded assembly.</returns>
                public Assembly LoadAssembly(string assemblyFile)
                {
                    //get the directory the assembly is located in
                    string assemblyDirectory = Path.GetDirectoryName(assemblyFile);

                    //load the assembly
                    Assembly assembly = Assembly.LoadFrom(assemblyFile);

                    //check if new assembly
                    if (!_Assemblies.ContainsKey(assembly.FullName))
                    {
                        //add new assembly to cache
                        _Assemblies.Add(assembly.FullName, assembly);

                        //new assembly, load all references
                        foreach (AssemblyName reference in assembly.GetReferencedAssemblies())
                        {
                            //temp the current directory
                            string lastAssemblyFile = _CurrentAssemblyDirectory;

                            //set new current directory
                            _CurrentAssemblyDirectory = assemblyDirectory;

                            try
                            {
                                //load assembly and assembly references
                                LoadAssemblyWithReferences(reference);
                            }
                            finally
                            {
                                //reset current directory
                                _CurrentAssemblyDirectory = lastAssemblyFile;
                            }
                        }

                        //return new reference
                        return assembly;
                    }
                    else
                        //assembly already added, return reference
                        return _Assemblies[assembly.FullName];
                }

                /// <summary>
                /// Loads an assembly given it's <see cref="System.Reflection.AssemblyName"/>.
                /// </summary>
                /// <param name="assemblyFile">The name or path of the file that contains the manifest of the assembly.</param>
                /// <returns>The loaded assembly.</returns>
                public void LoadAssemblyWithReferences(AssemblyName assemblyReference)
                {
                    //loads the assembly
                    Assembly assembly = Assembly.Load(assemblyReference);

                    //check if new assembly
                    if (!_Assemblies.ContainsKey(assemblyReference.FullName))
                    {
                        //add new assembly to cache
                        _Assemblies.Add(assemblyReference.FullName, assembly);

                        //new assembly, load all references
                        foreach (AssemblyName reference in assembly.GetReferencedAssemblies())
                            LoadAssemblyWithReferences(reference);
                    }
                }

After all assemblies have been successfully loaded, instances can be created through the AssemblyPlugin.CreateInstance method which reaches across the application domains to return a type that derives from MarshalByRefObject coverted to a specified base type.

Available plugin types can be located using the GetTypes method which searches for any type that derives from a specified base type:

            /// <summary>
            /// Searches the assembly for types that derive from <typeparamref name="TBase"/>.
            /// </summary>
            /// <typeparam name="TBase">The type of objects to locate where TReturn : MarshalByRefObject.</typeparam>
            /// <returns>An array of identifiers that derive from <typeparamref name="TBase"/>.</returns>
            public PluginTypeIdentifier[] GetTypes<TBase>() where TBase : MarshalByRefObject
            {
                List<PluginTypeIdentifier> plugins = new List<PluginTypeIdentifier>();

                Type tbase = typeof(TBase);

                //enumerate all types
                foreach (Type t in _Assembly.GetTypes())
                {
                    //look for type that derives from tbase
                    if (t.IsSubclassOf(tbase))
                        //add type
                        plugins.Add(new PluginTypeIdentifier(t, tbase));
                }

                //return array of identifiers
                return plugins.ToArray();
            }

When the AssemblyPlugin class is disposed, the application domain is unloaded causing the assembly and all references to be unloaded.

Using

Create a base type that derives from MarshalByRefObject and is located in a separate assembly:

    /// <summary>
    /// Base class for class instances in a plugin.
    /// </summary>
    public abstract class PluginBaseClass : MarshalByRefObject
    {
        public abstract object Process(params object[] args);
    }

The plugin base assembly should be referenced by both the application that will instantiate AssemblyPlugin and a plugin assembly. In the plugin assembly, create a type that derives from PluginBaseClass that overrides the Process method:

    [DisplayName("Simple Concatenation")]
    [Description("This class simply concatenates string arguments.")]
    public class StringConcat : PluginBaseClass
    {
        public override object Process(params object[] args)
        {
            //simply concatenates the arguments
            return string.Concat(args);
        }
    }

This allows the AssemblyPlugin class to create and return an instance of a type, converted to the base type that is loaded in both application domains.

All types that are passed between application domains are expected to be marked Serializable or implement ISerializable.

To use the StringConcat type across application domains, initialize the AssemblyPlugin and call the CreateInstance method to return the PluginBaseClass type:

            //loads the demo plugin library
            using (AssemblyPlugin plugin = new AssemblyPlugin("[Your Plugin Assembly].dll"))
            {
                //creates instance of [Your Plugin Namespace].StringConcat converted to base-type:
                PluginBaseClass instance = plugin.CreateInstance<PluginBaseClass>("[Your Plugin Namespace].StringConcat");
                //calls the StringConcat.Process method which concatenates the strings:
                Console.WriteLine(string.Format("Result: {0}", instance.Process("Hello", " ", "World", "!")));
            }

All types that derive from a base type in a plugin can be located using the GetTypes method:

                PluginTypeIdentifier[] types = plugin.GetTypes<PluginBaseClass>();

The PluginTypeIdentifier class provides BaseType, FullName, Name, Description and DisplayName properties to identify available plugin types.

Source Code

Download AssemblyPlugin, Supporting Classes and Example Solution

Comments