Application plugin host with assembly caching and auto reloading

Caching and loading plugins dynamically when plugin library updates

  • Share

Few moths ago I wrote and article about simple way to implement plugin architecture in C# simple plugin host application approach.

This approach is great for desktop applications because when you for example need to update (which means replace the plugin dll) you can easily close the app and start it again and it will pickup new code. If you do not stop the application to replace the dll of plugin, you will not be able to replace the dll. The reason is that when you load assembly from file, it will keep the handle on the file.

First thing you will have to do is to load assembly from file a little bit different. Since you can create assembly instance from byte array, we'll load the dll using streaming. After file is loaded into an assembly instance, you can release loaded file.

        private static Assembly LoadAssemblyFromFileStream(string filePath)
        {
            try
            {
                return Assembly.Load(File.ReadAllBytes(filePath));
            }
            catch (Exception ex)
            {
                Debug.WriteLine(string.Format("Error: {0}", ex.Message));
                return null;
            }
        }
    

As We do not want to load assembly every time from file to improve performances we can keep assemblies loaded in a Hastable where key will be lowercase full path of the assembly file and value will be actual assembly instance. Since this shared collection/hashtable can be accessed by multiple threads we are going to use System.Collections.Concurrent.ConcurrentDictionary.

Because FilesystemWatcher class is known for the issue of raising multiple event when file changed , we are going to store the time of assembly loaded, so that we can skip the next raised event in time span of 3 seconds. This way we'll take only first event for loading assembly and multiple loading for same change will be avoided. It increases performances as reflection is involved only once (for first event of the same change) and thread locking will be performed only once instead of multiple times for one change.

We are going to use simple POCO class which will hold assembly instance ad time of loading from file.

        public LoadedAssembly(DateTime loadTime, Assembly assembly)
        {
            this.assembly = assembly;
            this.loadTime = loadTime;
        }
    

According to articles I found on internet, multiple event raising happpend for each file attribute changed. For my case it was 3 times but it my vary.

private static Lazy<ConcurrentDictionary<string, LoadedAssembly>> assemblies = new Lazy<ConcurrentDictionary<string, LoadedAssembly>>(() => new ConcurrentDictionary<string, LoadedAssembly>());
    

Since we do not need to instantiate hashtable until we need to load some plugin assembly, we can warp it with Lazy statement and instantiate it only the first time we need to store something in it.

Next thing we need on the class level is file system watcher which will monitor for assembly changes

private static FileSystemWatcher watcher = null;
    

Method which loads the assembly from the the file from previous article mentioned above needs to changed a little bit. It needs to cache loaded assembly and instantiate file watcher which will reload assembly cache

 public static ApplicationPlugins.IPlugin GetPlugin(string pluginName)
        {
            string pluginPath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "Plugins", string.Format("{0}{1}", pluginName, ".dll")).Trim().ToLower().ToString();
            LoadedAssembly loadedAssembly = null;

            if (!assemblies.Value.ContainsKey(pluginPath))
            {
                loadedAssembly = new LoadedAssembly(DateTime.Now, LoadAssemblyFromFileStream(pluginPath));
                assemblies.Value.TryAdd(pluginPath, loadedAssembly);
                if (watcher == null)
                {
                    watcher = new FileSystemWatcher(Path.GetDirectoryName(pluginPath), "*.dll")
                    {
                        EnableRaisingEvents = true
                    };
                    watcher.Changed += watcher_Changed;
                    watcher.Deleted += watcher_Deleted;
                }
            }
            else
            {
                assemblies.Value.TryGetValue(pluginPath, out loadedAssembly);
            }

            Type[] types = loadedAssembly.Assembly.GetExportedTypes();
            foreach (var type in types)
            {
                //If Type is a class and implements IPlugin interface
                if (type.IsClass && (type.GetInterface(typeof(ApplicationPlugins.IPlugin).FullName) != null))
                {
                    var ctor = type.GetConstructor(new Type[] { typeof(string) });
                    var plugin = ctor.Invoke(new object[] { pluginPath }) as ApplicationPlugins.IPlugin;
                    return plugin;
                }
            }
            return null;
        }

        private static Assembly LoadAssemblyFromFileStream(string filePath)
        {
            try
            {
                return Assembly.Load(File.ReadAllBytes(filePath));
            }
            catch (Exception ex)
            {
                Debug.WriteLine(string.Format("Error: {0}", ex.Message));
                return null;
            }
        }
    

Now file system watcher needs to handle file change and to update assembly cache only for the file already loaded in the cache. If changed file is not in the cache, it means it was not used so we do not want to use memory space for no purpose


        static void watcher_Changed(object sender, FileSystemEventArgs e)
        {
            LoadedAssembly loadedAssembly = null;
            LoadedAssembly cachedAssembly = null;
            if (e.ChangeType == WatcherChangeTypes.Changed)
            {
                //Update in cache
                lock (assemblies.Value)
                {
                    if (assemblies.Value.ContainsKey(e.FullPath.Trim().ToLower()))
                    {
                        assemblies.Value.TryGetValue(e.FullPath.Trim().ToLower(), out cachedAssembly);

                        //Realod assembly if file is older than 10 second to avoid issue with FileSystemWatcher multiple events
                        if (cachedAssembly.LoadTime.AddSeconds(3) < DateTime.Now)
                        {
                            Debug.WriteLine("TIMES:{0} - {1}", cachedAssembly.LoadTime.AddSeconds(3).ToString("hh:mm:ss:fff"), DateTime.Now.ToString("hh:mm:ss:fff"));
                            Assembly loadedPlugin = LoadAssemblyFromFileStream(e.FullPath.Trim().ToLower());
                            if (loadedPlugin != null)
                            {
                                loadedAssembly = new LoadedAssembly(DateTime.Now, loadedPlugin);
                                assemblies.Value.TryUpdate(e.FullPath.Trim().ToLower(), loadedAssembly, cachedAssembly);
                            }
                        }
                    }
                }
            }

        }

        static void watcher_Deleted(object sender, FileSystemEventArgs e)
        {
            lock (assemblies.Value)
            {
                LoadedAssembly cachedAssembly = null;
                if (e.ChangeType == WatcherChangeTypes.Deleted)
                {
                    //Remove from cache
                    if (assemblies.Value.ContainsKey(e.FullPath.Trim().ToLower()))
                    {
                        assemblies.Value.TryRemove(e.FullPath.Trim().ToLower(), out cachedAssembly);
                    }
                }
            }
        }
    

Since this scenario is suitable to windows service applications with heavy load, I used simple console application for testing this which code you can download from this article.

Now to create a various plugins, we just need to inherit Plugin class from the library containing the factory.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace Plugin1
{
    public class Plugin : ApplicationPlugins.Plugin
    {
        public override ConsoleColor TextColor
        {
            get
            {
                return ConsoleColor.DarkYellow;
            }
        }
        public Plugin(string path = null)
            : base(path)
        {
            Console.ForegroundColor = this.TextColor;
        }

        public override void DoSomeWork()
        {
            Console.WriteLine(this.PluginConfiguration.AppSettings.Settings["appname"].Value);
        }
    }
}

    

For testing purposes, what I did is that I loaded plugin1 and execute DoWork method. Then, by keeping application still up, I changed the code to show different color text. As you can see the result is different text color in the same instance of application

static void Main(string[] args)
        {

ApplicationPlugins.IPlugin plugin;

plugin = Factory.GetPlugin("Plugin1");
plugin.DoSomeWork();
Console.WriteLine("Update dll and hit enter");
Console.ReadLine();

plugin = Factory.GetPlugin("Plugin1");
plugin.DoSomeWork();
Console.WriteLine("Press any key to end");
Console.ReadLine();

        }
    

Advanced Plugin

I wanted to make sure that it's working safe in mutlithread environment so I decided to put it on a stress test with multiple threads loading the assembly and invoking it's method.

 static void Main(string[] args) { //MULTITHREAD TEST for (int i = 0; i < 10; i++) { new Thread(() => { while (true) { ApplicationPlugins.IPlugin plugin; plugin = Factory.GetPlugin("Plugin1"); if (plugin != null) { plugin.DoSomeWork();Console.WriteLine(string.Format("Plugin loaded {0}", DateTime.Now.ToString("HH:mm:ss:fff"))); } } }).Start(); } } 

  • Share

Disclaimer

Purpose of the code contained in snippets or available for download in this article is solely for learning and demo purposes. Author will not be held responsible for any failure or damages caused due to any other usage.

Comments for this article

comments powered by Disqus