Prism: Dynamically Discover and Load Modules at Runtime

Brian Lagunas / Tuesday, August 6, 2013

If you develop WPF applications with Prism, then you are probably already aware of the many ways in which you can load a module.  Loading a module starts with what is called a ModuleCatalog.  You can’t load a module unless it has been added to a ModuleCatalog.  Once the module has been added to a ModuleCatalog, Prism will then take care of loading the module assembly for you.  Prism even comes with a handful of module catalogs to give you flexibility in how you register your modules with your Prism application.  You can populate a module catalog from code, from XAML, with XML in an app.config, or from a directory.  Heck, you can even use a combination of all these options to populate your module catalog.

When I am giving a Prism talk at a public event or an internal lunch and learn at a company, I am sure to explain all the different ways of loading your modules and which catalog to use.  This is about the time where the questions really start getting interesting.  Of these questions, the most common is about the DirectoryModuleCatalog.  This particular catalog allows you to specify a folder path to load your modules from.  Now the interesting question… “but, what happens when a new module assembly is dropped into the folder?  Will it automatically be loaded into the app while it is running?”  That is a great question, and the answer is NO.  The DirectoryModuleCatalog does a one time scan of the directory and then loads all the modules that it finds.  If you drop a new module assembly into the directory, it will not be loaded until the application is restarted.  Now the follow-up question… “well, is it possible to dynamically discover the modules and load them from the directory as well?”  Answer; well of course it is.  If you’re using MEF, it’s easy.  If you’re using a container such as Unity, you will need to write the code to handle it yourself.  “Well, we don’t use MEF, so can you show us how?”  This is where my reply is always the same, “a simple web search (Google or Bing) should help you find what you are looking for”.

Well, it turns out, that’s not the case.  It seems that no one has blogged about or shared any code that handles the dynamic discovery and loading of modules using a DI container such as Unity.  Not that I could find, nor anyone who is asking me to show them could find.  Which leads me to this post.  I am going to show you an approach that I have used to support such a scenario.  I am actually going to give you two approaches.  One is the “Quick and Dirty” way.  Basically, I will throw together the simplest sample to achieve the goal.  Then I will show you “A Better Way” in which we will encapsulate this functionality into a custom ModuleCatalog that will handle everything for us.

Here is the Prism app we are using to test our code.

Dynamically Discover and Load Modules at Runtime - Solution

It’s a Prism application that contains a Shell with a single region and one module that contains a single view.  When the module is loaded properly, this will be the final result.

Dynamically Discover and Load Modules at Runtime - Result

Quick and Dirty

The “Quick and Dirty” way, is well…. quick and dirty.  First we need to determine what mechanism we are going to use to detect when a new module assembly has been added to our modules directory.  This is a no brainer.  We will be using the FileSystemWatcher class.  The FileSystemWatcher monitors a given directory for changes and notifies us via events that a change has occurred.  Such as a file being added to the directory.  Let’s create an instance of this class in our Bootstrapper constructor and listen for it’s Created event.

public Bootstrapper()
{
    // we need to watch our folder for newly added modules
    FileSystemWatcher fileWatcher = new FileSystemWatcher(Path.Combine(System.AppDomain.CurrentDomain.BaseDirectory, "Modules"), "*.dll");
    fileWatcher.Created += fileWatcher_Created;
    fileWatcher.EnableRaisingEvents = true;
}

Notice that in the constructor of the FileSystemWatcher, it takes the location of the directory we want to monitor, as well as a second parameter which allows us to specify a filter.  In this case we only care about DLLs.  We also need to set the FileSystemWatcher.EnableRaisingEvents = true in order to start the monitoring of the directory.  Now anytime a new DLL is added to our directory, our event handler will execute.  Time to check out our event handler

void fileWatcher_Created(object sender, FileSystemEventArgs e)
{
    if (e.ChangeType == WatcherChangeTypes.Created)
    {
        //get the Prism assembly that IModule is defined in
        Assembly moduleAssembly = AppDomain.CurrentDomain.GetAssemblies().First(asm => asm.FullName == typeof(IModule).Assembly.FullName);
        Type IModuleType = moduleAssembly.GetType(typeof(IModule).FullName);

        //load our newly added assembly
        Assembly assembly = Assembly.LoadFile(e.FullPath);

        //look for all the classes that implement IModule in our assembly and create a ModuleInfo class from it
        var moduleInfos = assembly.GetExportedTypes()
            .Where(IModuleType.IsAssignableFrom)
            .Where(t => t != IModuleType)
            .Where(t => !t.IsAbstract).Select(t => CreateModuleInfo(t));


        //create an instance of our module manager
        var moduleManager = Container.Resolve<IModuleManager>();

        foreach (var moduleInfo in moduleInfos)
        {
            //add the ModuleInfo to the catalog so it can be loaded
            ModuleCatalog.AddModule(moduleInfo);

            //now load the module using the Dispatcher because the FileSystemWatcher.Created even occurs on a separate thread
            //and we need to load our module into the main thread.
            var d = Application.Current.Dispatcher;
            if (d.CheckAccess())
                moduleManager.LoadModule(moduleInfo.ModuleName);
            else
                d.BeginInvoke((Action)delegate { moduleManager.LoadModule(moduleInfo.ModuleName); });
        }
    }
}

private static ModuleInfo CreateModuleInfo(Type type)
{
    string moduleName = type.Name;

    var moduleAttribute = CustomAttributeData.GetCustomAttributes(type).FirstOrDefault(cad => cad.Constructor.DeclaringType.FullName == typeof(ModuleAttribute).FullName);

    if (moduleAttribute != null)
    {
        foreach (CustomAttributeNamedArgument argument in moduleAttribute.NamedArguments)
        {
            string argumentName = argument.MemberInfo.Name;
            if (argumentName == "ModuleName")
            {
                moduleName = (string)argument.TypedValue.Value;
                break;
            }
        }
    }

    ModuleInfo moduleInfo = new ModuleInfo(moduleName, type.AssemblyQualifiedName)
    {
        InitializationMode = InitializationMode.OnDemand,
        Ref = type.Assembly.CodeBase,
    };

    return moduleInfo;
}

This code takes the newly added assembly and loads it into our application.  Next we search the assembly for all classes that implement IModule, which is the interface that specifics it represents a Prism module.  Next we loop through all the found modules and add them to the ModuleCatalog.  We have to do this because we can’t load a module that is not registered in the module catalog.  Now we use the IModuleManager to load the module using the Dispatcher.  We have to use the Dispatcher because the FileSystemWatcher.Created event listens on a separate thread, and we need to load the modules on the main thread.  The Dispatchers allows us to push the modules from a different thread to the main thread.  Now lets go ahead and run the application and copy the ModuleA.DLL into the application's Modules folder directory and see what happens.

Before:

Dynamically Discover and Load Modules at Runtime - Before

Run the application and open the application’s /Modules directory location and the ModuleA’s Bin/Debug/ModuleA.dll file location.  As you can see, there are no modules loaded for the application and the Prism application is showing an empty shell.

After:

Dynamically Discover and Load Modules at Runtime - After

Now, copy the ModuleA.dll from the module’s Bin/Debug directory into the Prism application’s /Modules directory.  As soon as the copy operation has completed, the ModuleA.dll assembly is loaded, and the ModuleAView is injected into the Shell;s region.  All while the app is running.  No need to shutdown and restart the app.

So that was the quick and dirty way.  Now let’s look at how we can make a custom ModuleCatalog that will not only load modules from a directory just like the default Prism DirectoryModuleCatalog does, but also allow us to monitor the directory for newly added modules at runtime.

A Better Way

We just saw how, with a few lines of code, we could dynamically discover and load modules.  Now, let’s create a custom ModuleCatalog class that will not only register and load existing modules from a directory, but also monitor that same directory for newly added modules at runtime.  This class should be a little more stable and do proper app domain and evidence creations and in memory reflection without loading the assemblies into the main app domain until actually needed.  We are also going to remove the dependency on the Dispatcher and instead use the SynchronizationContext class.  I am not going to walk through all of the code.  I am just going to provide the code and you can read through it.

public class DynamicDirectoryModuleCatalog : ModuleCatalog
{
    SynchronizationContext _context;

    /// <summary>
    /// Directory containing modules to search for.
    /// </summary>
    public string ModulePath { get; set; }

    public DynamicDirectoryModuleCatalog(string modulePath)
    {
        _context = SynchronizationContext.Current;

        ModulePath = modulePath;

        // we need to watch our folder for newly added modules
        FileSystemWatcher fileWatcher = new FileSystemWatcher(ModulePath, "*.dll");
        fileWatcher.Created += FileWatcher_Created;
        fileWatcher.EnableRaisingEvents = true;
    }

    /// <summary>
    /// Rasied when a new file is added to the ModulePath directory
    /// </summary>
    void FileWatcher_Created(object sender, FileSystemEventArgs e)
    {
        if (e.ChangeType == WatcherChangeTypes.Created)
        {
            LoadModuleCatalog(e.FullPath, true);
        }
    }

    /// <summary>
    /// Drives the main logic of building the child domain and searching for the assemblies.
    /// </summary>
    protected override void InnerLoad()
    {
        LoadModuleCatalog(ModulePath);
    }

    void LoadModuleCatalog(string path, bool isFile = false)
    {
        if (string.IsNullOrEmpty(path))
            throw new InvalidOperationException("Path cannot be null.");

        if (isFile)
        {
            if (!File.Exists(path))
                throw new InvalidOperationException(string.Format("File {0} could not be found.", path));
        }
        else
        {
            if (!Directory.Exists(path))
                throw new InvalidOperationException(string.Format("Directory {0} could not be found.", path));
        }

        AppDomain childDomain = this.BuildChildDomain(AppDomain.CurrentDomain);

        try
        {
            List<string> loadedAssemblies = new List<string>();

            var assemblies = (
                                 from Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()
                                 where !(assembly is System.Reflection.Emit.AssemblyBuilder)
                                    && assembly.GetType().FullName != "System.Reflection.Emit.InternalAssemblyBuilder"
                                    && !String.IsNullOrEmpty(assembly.Location)
                                 select assembly.Location
                             );

            loadedAssemblies.AddRange(assemblies);

            Type loaderType = typeof(InnerModuleInfoLoader);
            if (loaderType.Assembly != null)
            {
                var loader = (InnerModuleInfoLoader)childDomain.CreateInstanceFrom(loaderType.Assembly.Location, loaderType.FullName).Unwrap();
                loader.LoadAssemblies(loadedAssemblies);

                //get all the ModuleInfos
                ModuleInfo[] modules = loader.GetModuleInfos(path, isFile);

                //add modules to catalog
                this.Items.AddRange(modules);

                //we are dealing with a file from our file watcher, so let's notify that it needs to be loaded
                if (isFile)
                {
                    LoadModules(modules);
                }
            }
        }
        finally
        {
            AppDomain.Unload(childDomain);
        }
    }

    /// <summary>
    /// Uses the IModuleManager to load the modules into memory
    /// </summary>
    /// <param name="modules"></param>
    private void LoadModules(ModuleInfo[] modules)
    {
        if (_context == null)
            return;

        IModuleManager manager = ServiceLocator.Current.GetInstance<IModuleManager>();

        _context.Send(new SendOrPostCallback(delegate(object state)
        {
            foreach (var module in modules)
            {
                manager.LoadModule(module.ModuleName);
            }
        }), null);
    }

    /// <summary>
    /// Creates a new child domain and copies the evidence from a parent domain.
    /// </summary>
    /// <param name="parentDomain">The parent domain.</param>
    /// <returns>The new child domain.</returns>
    /// <remarks>
    /// Grabs the <paramref name="parentDomain"/> evidence and uses it to construct the new
    /// <see cref="AppDomain"/> because in a ClickOnce execution environment, creating an
    /// <see cref="AppDomain"/> will by default pick up the partial trust environment of
    /// the AppLaunch.exe, which was the root executable. The AppLaunch.exe does a
    /// create domain and applies the evidence from the ClickOnce manifests to
    /// create the domain that the application is actually executing in. This will
    /// need to be Full Trust for Composite Application Library applications.
    /// </remarks>
    /// <exception cref="ArgumentNullException">An <see cref="ArgumentNullException"/> is thrown if <paramref name="parentDomain"/> is null.</exception>
    protected virtual AppDomain BuildChildDomain(AppDomain parentDomain)
    {
        if (parentDomain == null) throw new System.ArgumentNullException("parentDomain");

        Evidence evidence = new Evidence(parentDomain.Evidence);
        AppDomainSetup setup = parentDomain.SetupInformation;
        return AppDomain.CreateDomain("DiscoveryRegion", evidence, setup);
    }

    private class InnerModuleInfoLoader : MarshalByRefObject
    {
        [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Performance", "CA1822:MarkMembersAsStatic")]
        internal ModuleInfo[] GetModuleInfos(string path, bool isFile = false)
        {
            Assembly moduleReflectionOnlyAssembly =
                AppDomain.CurrentDomain.ReflectionOnlyGetAssemblies().First(
                    asm => asm.FullName == typeof(IModule).Assembly.FullName);

            Type IModuleType = moduleReflectionOnlyAssembly.GetType(typeof(IModule).FullName);

            FileSystemInfo info = null;
            if (isFile)
                info = new FileInfo(path);
            else
                info = new DirectoryInfo(path);

            ResolveEventHandler resolveEventHandler = delegate(object sender, ResolveEventArgs args) { return OnReflectionOnlyResolve(args, info); };
            AppDomain.CurrentDomain.ReflectionOnlyAssemblyResolve += resolveEventHandler;
            IEnumerable<ModuleInfo> modules = GetNotAllreadyLoadedModuleInfos(info, IModuleType);
            AppDomain.CurrentDomain.ReflectionOnlyAssemblyResolve -= resolveEventHandler;

            return modules.ToArray();
        }

        private static IEnumerable<ModuleInfo> GetNotAllreadyLoadedModuleInfos(FileSystemInfo info, Type IModuleType)
        {
            List<FileInfo> validAssemblies = new List<FileInfo>();
            Assembly[] alreadyLoadedAssemblies = AppDomain.CurrentDomain.ReflectionOnlyGetAssemblies();

            FileInfo fileInfo = info as FileInfo;
            if (fileInfo != null)
            {
                if (alreadyLoadedAssemblies.FirstOrDefault(assembly => String.Compare(Path.GetFileName(assembly.Location), fileInfo.Name, StringComparison.OrdinalIgnoreCase) == 0) == null)
                {
                    var moduleInfos = Assembly.ReflectionOnlyLoadFrom(fileInfo.FullName).GetExportedTypes()
                    .Where(IModuleType.IsAssignableFrom)
                    .Where(t => t != IModuleType)
                    .Where(t => !t.IsAbstract).Select(t => CreateModuleInfo(t));

                    return moduleInfos;
                }
            }

            DirectoryInfo directory = info as DirectoryInfo;

            var files = directory.GetFiles("*.dll").Where(file => alreadyLoadedAssemblies.
                FirstOrDefault(assembly => String.Compare(Path.GetFileName(assembly.Location), file.Name, StringComparison.OrdinalIgnoreCase) == 0) == null);

            foreach (FileInfo file in files)
            {
                try
                {
                    Assembly.ReflectionOnlyLoadFrom(file.FullName);
                    validAssemblies.Add(file);
                }
                catch (BadImageFormatException)
                {
                    // skip non-.NET Dlls
                }
            }

            return validAssemblies.SelectMany(file => Assembly.ReflectionOnlyLoadFrom(file.FullName)
                                        .GetExportedTypes()
                                        .Where(IModuleType.IsAssignableFrom)
                                        .Where(t => t != IModuleType)
                                        .Where(t => !t.IsAbstract)
                                        .Select(type => CreateModuleInfo(type)));
        }


        private static Assembly OnReflectionOnlyResolve(ResolveEventArgs args, FileSystemInfo info)
        {
            Assembly loadedAssembly = AppDomain.CurrentDomain.ReflectionOnlyGetAssemblies().FirstOrDefault(
                asm => string.Equals(asm.FullName, args.Name, StringComparison.OrdinalIgnoreCase));
            if (loadedAssembly != null)
            {
                return loadedAssembly;
            }

            DirectoryInfo directory = info as DirectoryInfo;
            if (directory != null)
            {
                AssemblyName assemblyName = new AssemblyName(args.Name);
                string dependentAssemblyFilename = Path.Combine(directory.FullName, assemblyName.Name + ".dll");
                if (File.Exists(dependentAssemblyFilename))
                {
                    return Assembly.ReflectionOnlyLoadFrom(dependentAssemblyFilename);
                }
            }

            return Assembly.ReflectionOnlyLoad(args.Name);
        }

        [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Performance", "CA1822:MarkMembersAsStatic")]
        internal void LoadAssemblies(IEnumerable<string> assemblies)
        {
            foreach (string assemblyPath in assemblies)
            {
                try
                {
                    Assembly.ReflectionOnlyLoadFrom(assemblyPath);
                }
                catch (FileNotFoundException)
                {
                    // Continue loading assemblies even if an assembly can not be loaded in the new AppDomain
                }
            }
        }

        private static ModuleInfo CreateModuleInfo(Type type)
        {
            string moduleName = type.Name;
            List<string> dependsOn = new List<string>();
            bool onDemand = false;
            var moduleAttribute = CustomAttributeData.GetCustomAttributes(type).FirstOrDefault(cad => cad.Constructor.DeclaringType.FullName == typeof(ModuleAttribute).FullName);

            if (moduleAttribute != null)
            {
                foreach (CustomAttributeNamedArgument argument in moduleAttribute.NamedArguments)
                {
                    string argumentName = argument.MemberInfo.Name;
                    switch (argumentName)
                    {
                        case "ModuleName":
                            moduleName = (string)argument.TypedValue.Value;
                            break;

                        case "OnDemand":
                            onDemand = (bool)argument.TypedValue.Value;
                            break;

                        case "StartupLoaded":
                            onDemand = !((bool)argument.TypedValue.Value);
                            break;
                    }
                }
            }

            var moduleDependencyAttributes = CustomAttributeData.GetCustomAttributes(type).Where(cad => cad.Constructor.DeclaringType.FullName == typeof(ModuleDependencyAttribute).FullName);
            foreach (CustomAttributeData cad in moduleDependencyAttributes)
            {
                dependsOn.Add((string)cad.ConstructorArguments[0].Value);
            }

            ModuleInfo moduleInfo = new ModuleInfo(moduleName, type.AssemblyQualifiedName)
            {
                InitializationMode =
                    onDemand
                        ? InitializationMode.OnDemand
                        : InitializationMode.WhenAvailable,
                Ref = type.Assembly.CodeBase,
            };
            moduleInfo.DependsOn.AddRange(dependsOn);
            return moduleInfo;
        }
    }
}

/// <summary>
/// Class that provides extension methods to Collection
/// </summary>
public static class CollectionExtensions
{
    /// <summary>
    /// Add a range of items to a collection.
    /// </summary>
    /// <typeparam name="T">Type of objects within the collection.</typeparam>
    /// <param name="collection">The collection to add items to.</param>
    /// <param name="items">The items to add to the collection.</param>
    /// <returns>The collection.</returns>
    /// <exception cref="System.ArgumentNullException">An <see cref="System.ArgumentNullException"/> is thrown if <paramref name="collection"/> or <paramref name="items"/> is <see langword="null"/>.</exception>
    public static Collection<T> AddRange<T>(this Collection<T> collection, IEnumerable<T> items)
    {
        if (collection == null) throw new System.ArgumentNullException("collection");
        if (items == null) throw new System.ArgumentNullException("items");

        foreach (var each in items)
        {
            collection.Add(each);
        }

        return collection;
    }
}

This is how you would use the newly created DynamicDirectoryModuleCatalog in our Prism Bootstrapper.

protected override IModuleCatalog CreateModuleCatalog()
{
    DynamicDirectoryModuleCatalog catalog = new DynamicDirectoryModuleCatalog(Path.Combine(System.AppDomain.CurrentDomain.BaseDirectory, "Modules"));
    return catalog;
}

You might not know this, but you can even have multiple instances of your Prism application monitor the same directory and load the same modules.

Dynamically Discover and Load Modules at Runtime - Multiple Apps

Pretty cool huh?  You can now dynamically discover and load your Prism modules at runtime. 

Download the source

As always, feel free contact me on my blog, connect with me on Twitter (@brianlagunas), or leave a comment below for any questions or comments you may have.