How can I deal with modules with different versions of the same dependencies in MEF?
Asked Answered
M

1

9

At the moment, I have a module folder configured, and all my module assemblies and their dependencies live there. I worry that in six months time, someone builds a new module, and its dependencies overwrite the older versions of the dependencies.

Should I maybe develop some sort of module registry, where a developer registers a new module, and assigns it a sub-folder name in the modules folder? This kind of dampens the convenience of using a DirectoryCatalog though, if I have to tell the host about the modules.

Midwife answered 28/1, 2014 at 7:17 Comment(8)
I might have missunderstood your question, but isn't it just easier to force new modules to live within their own dll only?Acidulant
What about their dependencies? Surely when Assembly.Load loads an assembly, it checks for dependencies and tries to load them from the same location?Midwife
Yes, but you can use ILMerge (microsoft.com/download/en/…) to merge dlls into one, have a look at msdn.microsoft.com/en-us/library/dd409610.aspx for embedding type info. In my experience dynamically loaded modules should never be dependent on anything else than standard libraries.Acidulant
ILMerge doesn't work with WPF assemblies. I didn't tag the question WPF because I didn't think it relevant until now.Midwife
Your concern is very valid. And as far as I know, there is no solution, except telling developers not to do that. If it hurts when you overwrite dependencies, don't overwrite them.Parsonage
It's not enough to assign a sub-folder for each of the modules, you will also have to load each module in a separate AppDomain. In this case, each module will use its own dependency library, but you will have to use a separate MEF setup for each AppDomain and will surely have issues with objects crossing the AppDomain borders.Unquiet
Instead of depending on Dlls, could you depend on strongly named assemblies (possibly even loaded into the GAC)?Sociometry
I think strong named assemblies will work, but more outside the GAC. I don't want to go polluting the client's GAC with small, frequently released assemblies.Midwife
A
4

I've had a similar problem in the past. Below I present my solution, which I think is similar to what you are trying to accomplish.

Using MEF like this is really fascinating, but here are my words of caution:

  • It gets complicated quick
  • You have to make a couple compromises like inheriting MarshalByRefObject and plugins not building with solution
  • And, as I decided, simpler is better! Other non-MEF designs may be a better choice.

Ok, disclaimers out of the way...

.NET allows you to load multiple versions of the same assembly into memory, but not to unload them. This is why my approach will require an AppDomain to allow you to unload modules when a new version becomes available.

The solution below allows you to copy plugin dlls into a 'plugins' folder in the bin directory at runtime. As new plugins are added and old ones are overwritten, the old will be unloaded and the new will be loaded without having to re-start your application. If you have multiple dlls with different versions in your directory at the same time, you may want to modify the PluginHost to read the assembly version through the file's properties and act accordingly.

There are three projects:

  • ConsoleApplication.dll (References Integration.dll only)
  • Integration.dll
  • TestPlugin.dll (References Integration.dll, must be copied to ConsoleApplication bin/Debug/plugins)

ConsoleApplication.dll

class Program
{
    static void Main(string[] args)
    {
        var pluginHost = new PluginHost();
        //Console.WriteLine("\r\nProgram:\r\n" + string.Join("\r\n", AppDomain.CurrentDomain.GetAssemblies().Select(a => a.GetName().Name)));
        pluginHost.CallEach<ITestPlugin>(testPlugin => testPlugin.DoSomething());
        //Console.ReadLine();
    }
}

Integration.dll

PluginHost allows you to communicate with plugins. There should only ever be one instance of PluginHost. This also acts as a polling DirectoryCatalog.

public class PluginHost
{
    public const string PluginRelativePath = @"plugins";
    private static readonly object SyncRoot = new object();
    private readonly string _pluginDirectory;
    private const string PluginDomainName = "Plugins";
    private readonly Dictionary<string, DateTime> _pluginModifiedDateDictionary = new Dictionary<string, DateTime>();
    private PluginDomain _domain;

    public PluginHost()
    {
        _pluginDirectory = AppDomain.CurrentDomain.BaseDirectory + PluginRelativePath;
        CreatePluginDomain(PluginDomainName, _pluginDirectory);
        Task.Factory.StartNew(() => CheckForPluginUpdatesForever(PluginDomainName, _pluginDirectory));
    }

    private void CreatePluginDomain(string pluginDomainName, string pluginDirectory)
    {
        _domain = new PluginDomain(pluginDomainName, pluginDirectory);
        var files = GetPluginFiles(pluginDirectory);
        _pluginModifiedDateDictionary.Clear();
        foreach (var file in files)
        {
            _pluginModifiedDateDictionary[file] = File.GetLastWriteTime(file);
        }
    }
    public void CallEach<T>(Action<T> call) where T : IPlugin
    {
        lock (SyncRoot)
        {
            var plugins = _domain.Resolve<IEnumerable<T>>();
            if (plugins == null)
                return;
            foreach (var plugin in plugins)
            {
                call(plugin);
            }
        }
    }

    private void CheckForPluginUpdatesForever(string pluginDomainName, string pluginDirectory)
    {
        TryCheckForPluginUpdates(pluginDomainName, pluginDirectory);
        Task.Delay(5000).ContinueWith(task => CheckForPluginUpdatesForever(pluginDomainName, pluginDirectory));
    }

    private void TryCheckForPluginUpdates(string pluginDomainName, string pluginDirectory)
    {
        try
        {
            CheckForPluginUpdates(pluginDomainName, pluginDirectory);
        }
        catch (Exception ex)
        {
            throw new Exception("Failed to check for plugin updates.", ex);
        }
    }

    private void CheckForPluginUpdates(string pluginDomainName, string pluginDirectory)
    {
        var arePluginsUpdated = ArePluginsUpdated(pluginDirectory);
        if (arePluginsUpdated)
            RecreatePluginDomain(pluginDomainName, pluginDirectory);
    }

    private bool ArePluginsUpdated(string pluginDirectory)
    {
        var files = GetPluginFiles(pluginDirectory);
        if (IsFileCountChanged(files))
            return true;
        return AreModifiedDatesChanged(files);
    }

    private static List<string> GetPluginFiles(string pluginDirectory)
    {
        if (!Directory.Exists(pluginDirectory))
            return new List<string>();
        return Directory.GetFiles(pluginDirectory, "*.dll").ToList();
    }

    private bool IsFileCountChanged(List<string> files)
    {
        return files.Count > _pluginModifiedDateDictionary.Count || files.Count < _pluginModifiedDateDictionary.Count;
    }

    private bool AreModifiedDatesChanged(List<string> files)
    {
        return files.Any(IsModifiedDateChanged);
    }

    private bool IsModifiedDateChanged(string file)
    {
        DateTime oldModifiedDate;
        if (!_pluginModifiedDateDictionary.TryGetValue(file, out oldModifiedDate))
            return true;
        var newModifiedDate = File.GetLastWriteTime(file);
        return oldModifiedDate != newModifiedDate;
    }

    private void RecreatePluginDomain(string pluginDomainName, string pluginDirectory)
    {
        lock (SyncRoot)
        {
            DestroyPluginDomain();
            CreatePluginDomain(pluginDomainName, pluginDirectory);
        }
    }

    private void DestroyPluginDomain()
    {
        if (_domain != null)
            _domain.Dispose();
    }
}

Autofac is a required dependency of this code. The PluginDomainDependencyResolver is instantiated in the plugin AppDomain.

[Serializable]
internal class PluginDomainDependencyResolver : MarshalByRefObject
{
    private readonly IContainer _container;
    private readonly List<string> _typesThatFailedToResolve = new List<string>();

    public PluginDomainDependencyResolver()
    {
        _container = BuildContainer();
    }

    public T Resolve<T>() where T : class
    {
        var typeName = typeof(T).FullName;
        var resolveWillFail = _typesThatFailedToResolve.Contains(typeName);
        if (resolveWillFail)
            return null;
        var instance = ResolveIfExists<T>();
        if (instance != null)
            return instance;
        _typesThatFailedToResolve.Add(typeName);
        return null;
    }

    private T ResolveIfExists<T>() where T : class
    {
        T instance;
        _container.TryResolve(out instance);
        return instance;
    }

    private static IContainer BuildContainer()
    {
        var builder = new ContainerBuilder();

        var assemblies = LoadAssemblies();
        builder.RegisterAssemblyModules(assemblies); // Should we allow plugins to load dependencies in the Autofac container?
        builder.RegisterAssemblyTypes(assemblies)
            .Where(t => typeof(ITestPlugin).IsAssignableFrom(t))
            .As<ITestPlugin>()
            .SingleInstance();

        return builder.Build();
    }

    private static Assembly[] LoadAssemblies()
    {
        var path = AppDomain.CurrentDomain.BaseDirectory + PluginHost.PluginRelativePath;
        if (!Directory.Exists(path))
            return new Assembly[]{};
        var dlls = Directory.GetFiles(path, "*.dll").ToList();
        dlls = GetAllDllsThatAreNotAlreadyLoaded(dlls);
        var assemblies = dlls.Select(LoadAssembly).ToArray();
        return assemblies;
    }

    private static List<string> GetAllDllsThatAreNotAlreadyLoaded(List<string> dlls)
    {
        var alreadyLoadedDllNames = GetAppDomainLoadedAssemblyNames();
        return dlls.Where(dll => !IsAlreadyLoaded(alreadyLoadedDllNames, dll)).ToList();
    }

    private static List<string> GetAppDomainLoadedAssemblyNames()
    {
        var assemblies = AppDomain.CurrentDomain.GetAssemblies();
        return assemblies.Select(a => a.GetName().Name).ToList();
    }

    private static bool IsAlreadyLoaded(List<string> alreadyLoadedDllNames, string file)
    {
        var fileInfo = new FileInfo(file);
        var name = fileInfo.Name.Replace(fileInfo.Extension, string.Empty);
        return alreadyLoadedDllNames.Any(dll => dll == name);
    }

    private static Assembly LoadAssembly(string path)
    {
        return Assembly.Load(File.ReadAllBytes(path));
    }
}

This class represents the actual Plugin AppDomain. Assemblies resolved into this domain should load any dependencies they require from the bin/plugins folder first, followed by the bin folder, since it is part of the parent AppDomain.

internal class PluginDomain : IDisposable
{
    private readonly string _name;
    private readonly string _pluginDllPath;
    private readonly AppDomain _domain;
    private readonly PluginDomainDependencyResolver _container;

    public PluginDomain(string name, string pluginDllPath)
    {
        _name = name;
        _pluginDllPath = pluginDllPath;
        _domain = CreateAppDomain();
        _container = CreateInstance<PluginDomainDependencyResolver>();
    }

    public AppDomain CreateAppDomain()
    {
        var domaininfo = new AppDomainSetup
        {
            PrivateBinPath = _pluginDllPath
        };
        var evidence = AppDomain.CurrentDomain.Evidence;
        return AppDomain.CreateDomain(_name, evidence, domaininfo);
    }

    private T CreateInstance<T>()
    {
        var assemblyName = typeof(T).Assembly.GetName().Name + ".dll";
        var typeName = typeof(T).FullName;
        if (typeName == null)
            throw new Exception(string.Format("Type {0} had a null name.", typeof(T).FullName));
        return (T)_domain.CreateInstanceFromAndUnwrap(assemblyName, typeName);
    }

    public T Resolve<T>() where T : class
    {
        return _container.Resolve<T>();
    }

    public void Dispose()
    {
        DestroyAppDomain();
    }

    private void DestroyAppDomain()
    {
        AppDomain.Unload(_domain);
    }
}

Finally your plugin interfaces.

public interface IPlugin
{
    // Marker Interface
}

The main application needs to know about each plugin so an interface is required. They must inherit IPlugin and be registered in the PluginHost BuildContainer method

public interface ITestPlugin : IPlugin
{
    void DoSomething();
}

TestPlugin.dll

[Serializable]
public class TestPlugin : MarshalByRefObject, ITestPlugin
{
    public void DoSomething()
    {
        //Console.WriteLine("\r\nTestPlugin:\r\n" + string.Join("\r\n", AppDomain.CurrentDomain.GetAssemblies().Select(a => a.GetName().Name)));
    }
}

Final thoughts...

One reason that this solution worked for me is that my plugin instances from the AppDomain had a very short lifetime. However, I believe that modifications could be made to support plugin objects with a longer lifetime. This would likely require some compromises like a more advanced plugin wrapper that could perhaps recreate the object when the AppDomain is reloaded (see CallEach).

Ado answered 28/2, 2014 at 3:6 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.