4
\$\begingroup\$

I'd like one of my RESTful services to support plugins. Currently it uses references hardwired at compile-time.

The Application Parts seem to be THE solution so I gave it a try and created a small proof-of-concept service.

It loads plugins located in the ext subdirectory. Each plugin is located in its own subdirectory named after the plugin itself and it may contain its own dependencies e.g.:

ext\PluginTest.HalloWord\PluginTest.HalloWorld.dll
ext\PluginTest.HalloWord\PluginTest.HalloWorldHelper.dll

I implemented it by loading plugins with the ConfigureApplicationPartManager and later if plugin dependencies are requested I try to resolve them in the plugin's directory with the AssemblyResolve event handler. Additionaly each assembly can also contain embedded views. The Razor view engine is able to find them via EmbeddedFileProvider for each loaded plugin.

public class Startup
{
    private const string PluginsDirectoryName = "ext";

    public Startup(IConfiguration configuration, IHostingEnvironment hostingEnvironment)
    {
        Configuration = configuration;
        HostingEnvironment = hostingEnvironment;
    }

    public IConfiguration Configuration { get; }

    public IHostingEnvironment HostingEnvironment { get; }

    public void ConfigureServices(IServiceCollection services)
    {
        AppDomain.CurrentDomain.AssemblyResolve += (sender, e) =>
        {
            var pluginName = e.RequestingAssembly.GetName().Name;

            // Extract dependency name from the full assembly name:
            // PluginTest.HalloWorldHelper, Version = 1.0.0.0, Culture = neutral, PublicKeyToken = null
            var pluginDependencyName = e.Name.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).First();

            var pluginDependencyFullName = 
                Path.Combine(
                    HostingEnvironment.ContentRootPath, 
                    PluginsDirectoryName, 
                    pluginName, 
                    $"{pluginDependencyName}.dll"
                );

            return
                File.Exists(pluginDependencyFullName)
                    ? Assembly.LoadFile(pluginDependencyFullName)
                    : null;
        };

        var pluginAssemblies = 
            GetPluginAssemblies(HostingEnvironment)
                .ToList();

        services
            .AddMvc()
            .ConfigureApplicationPartManager(apm =>
            {
                foreach (var pluginAssembly in pluginAssemblies)
                {
                    apm.ApplicationParts.Add(new AssemblyPart(pluginAssembly));
                }
            });

        // Views are embeded in plugins so add a resolver so that the Razor view engine can find them.
        services.Configure<RazorViewEngineOptions>(options =>
        {
            foreach (var pluginAssembly in pluginAssemblies)
            {
                options
                    .FileProviders
                    .Add(new EmbeddedFileProvider(pluginAssembly));
            }                
        });
    }

    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }

        app.UseMvc();
    }

    private static IEnumerable<Assembly> GetPluginAssemblies(IHostingEnvironment hostingEnvironment)
    {
        var pluginDirectoryName = Path.Combine(hostingEnvironment.ContentRootPath, PluginsDirectoryName);

        if (!Directory.Exists(pluginDirectoryName))
        {
            yield break;
        }

        var pluginDirectories = Directory.GetDirectories(pluginDirectoryName);
        foreach (var pluginDirectory in pluginDirectories)
        {
            var pluginFullName =
                Path.Combine(
                    hostingEnvironment.ContentRootPath,
                    pluginDirectory,
                    $"{Path.GetFileName(pluginDirectory)}.dll"
                );

            if (File.Exists(pluginFullName))
            {
                yield return Assembly.LoadFile(pluginFullName);
            }
        }
    }
}

My PoC works, loads plugins, their dependencies and correctly executes controllers that were loaded dynamically.

I was wondering whether there is anything about this simple solution that could be done better?

\$\endgroup\$
  • \$\begingroup\$ Apart from converting the service configuration to extension methods, There is not much else I would change in the current code based on my initial look at it. I'm still reviewing it over though so I may come up with something. \$\endgroup\$ – Nkosi Mar 19 '18 at 13:35
  • \$\begingroup\$ missing where _hostingEnvironment is coming from, but assuming that was a typo while copying over the code? \$\endgroup\$ – Nkosi Mar 19 '18 at 13:37
  • \$\begingroup\$ Does AddMvc call AddOptions internally? Not seeing where that is enabled as well. \$\endgroup\$ – Nkosi Mar 19 '18 at 13:40
  • \$\begingroup\$ @Nkosi yes, sorry, it was a copy/pase error, it should be the property. I mistakenly copied code from another solution where I use fields instead of properties :-/ about AddOptions - I don't think I'm using this one... or do you mean this services.Configure<RazorViewEngineOptions>(options =>? I didn't have to activate anything else for this to work. It's the exact code I'm using. No AddOptions anywhere. \$\endgroup\$ – t3chb0t Mar 19 '18 at 15:56
  • \$\begingroup\$ ok, drafting up some refactors based on some repeated code. \$\endgroup\$ – Nkosi Mar 19 '18 at 15:58
1
\$\begingroup\$

Apart from converting the service configuration to extension methods, There is not much else I would change in the current code.

There is repeated code that can be refactored out into their own concerns.

public static class PluginConfigurationExtensions {

    public IServiceCollection AddMvcPlugins(IServiceCollection services, string pluginsRootPath) {
        AppDomain.CurrentDomain.ConfigureAssemblyResolve(pluginsRootPath);

        var pluginAssemblies = GetPluginAssemblies(pluginsRootPath).ToList();

        // Setup options with DI
        services.AddOptions();

        services
            .AddMvc()
            .ConfigureApplicationPartManager(apm => {
                foreach (var pluginAssembly in pluginAssemblies) {
                    apm.ApplicationParts.Add(new AssemblyPart(pluginAssembly));
                }
            });

        // Views are embedded in plugins so add a resolver so that the Razor view engine can find them.
        services.Configure<RazorViewEngineOptions>(_ => {
            foreach (var pluginAssembly in pluginAssemblies) {
                _
                    .FileProviders
                    .Add(new EmbeddedFileProvider(pluginAssembly));
            }                
        });
        return services;
    }

    private static void ConfigureAssemblyResolve(this AppDomain appDomain, string pluginsRootPath) {
        appDomain.AssemblyResolve += (sender, e) => {
            var pluginName = e.RequestingAssembly.GetName().Name;

            // Extract dependency name from the full assembly name:
            // PluginTest.HalloWorldHelper, Version = 1.0.0.0, Culture = neutral, PublicKeyToken = null
            var pluginDependencyName = e.Name.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).First();

            var pluginDependencyFullName = Path.Combine(
                    pluginsRootPath,
                    pluginName,
                    $"{pluginDependencyName}.dll"
                );

            return
                File.Exists(pluginDependencyFullName)
                    ? Assembly.LoadFile(pluginDependencyFullName)
                    : null;
        };
    }

    private static IEnumerable<Assembly> GetPluginAssemblies(string pluginsRootPath) {
        if (!Directory.Exists(pluginsRootPath)) {
            yield break;
        }

        var pluginDirectories = Directory.GetDirectories(pluginsRootPath);
        foreach (var pluginDirectory in pluginDirectories) {
            var pluginFullName =
                Path.Combine(
                    pluginsRootPath,
                    $"{Path.GetFileName(pluginDirectory)}.dll"
                );

            if (File.Exists(pluginFullName)) {
                yield return Assembly.LoadFile(pluginFullName);
            }
        }
    }
}

This reduces the ConfigureServices to

public void ConfigureServices(IServiceCollection services) {
    var pluginsRootPath = Path.Combine(HostingEnvironment.ContentRootPath, PluginsDirectoryName);    

    services.AddMvcPlugins(pluginsRootPath);

    //...
}

You could consider putting the PluginsDirectoryName in the appsetting.json and extracting it via IOptions instead of hard-coding it in Startup.

\$\endgroup\$
  • \$\begingroup\$ This looks good and I like the new names of some of the variables. \$\endgroup\$ – t3chb0t Mar 19 '18 at 16:20
  • \$\begingroup\$ Ended up going with the one line config. Less room for error. \$\endgroup\$ – Nkosi Mar 19 '18 at 16:35
  • \$\begingroup\$ I love asp.net-core. You can override virtually everything. If something doesn't work the way you'd like it to... there is for sure an interface you can use to implement it your way ;-D \$\endgroup\$ – t3chb0t Mar 19 '18 at 18:33
  • \$\begingroup\$ Well one of the biggest selling points of core was its modularity which allowed more extensibility. \$\endgroup\$ – Nkosi Mar 19 '18 at 18:37
1
\$\begingroup\$

I followed most of @Nkosi suggestions and reorganized the code for registering plugins.


I changed the main extension to work on the IMvcBuilder to avoid calling AddMvc inside it.

This gives me access to the IMvcBuilder.Services property that can be used to get other services that this exension requires. This way it's not necessary to pass any other arguments to it. The two directory names are now stored in the appsettings.json file.

I also slightly changed how the extensions are loaded because I didn't like the embedded views. I prefer to be able to change the Text/HTML without deploying a new *.dll. This means that views are resolved now from the src\Views directory instead.

The new directory structure is:

\ext
    \PluginX
        \bin
            PluginX.dll
        \src
            \Views
                Index.cshtml

(The Razor view engine knows about the src folder because I override the resolution with a custom IViewLocationExpander.)

public static class MvcBuilderPluginExtensions
{
    // Adds plugins located in \{Root}\Plugin\{Binary}\Plugin.dll
    // Example: \ext\Plugin\bin\Plugin.dll    
    public static IMvcBuilder AddPlugins(this IMvcBuilder mvc)
    {
        var serviceProvider = mvc.Services.BuildServiceProvider();
        var configuration = serviceProvider.GetService<IConfiguration>();
        var hostingEnvironment = serviceProvider.GetService<IHostingEnvironment>();
        var logger = serviceProvider.GetService<ILoggerFactory>().CreateLogger<Startup>();

        var pluginsRootPath = Path.Combine(hostingEnvironment.ContentRootPath, configuration["PluginDirectory:Root"]);
        var pluginAssemblies = GetPluginAssemblies(pluginsRootPath, configuration["PluginDirectory:Binary"]).ToList();

        logger.Log(Abstraction.Layer.Infrastructure().Data().Variable(new { pluginAssemblies = pluginAssemblies.Select(x => x.FullName) }));

        mvc
            .ConfigureApplicationPartManager(apm =>
            {
                foreach (var pluginAssembly in pluginAssemblies)
                {
                    logger.Log(Abstraction.Layer.Infrastructure().Data().Object(new { pluginAssembly = new { pluginAssembly.FullName } }));
                    apm.ApplicationParts.Add(new AssemblyPart(pluginAssembly));
                }
            });

        mvc
            .Services
            .ConfigureRazorViewEngine(hostingEnvironment, pluginAssemblies, pluginsRootPath);

        ConfigureAssemblyResolve(logger, pluginsRootPath, configuration["PluginDirectory:Binary"]);

        return mvc;
    }

    private static IEnumerable<Assembly> GetPluginAssemblies(string pluginsRootPath, string binDirectoryName)
    {
        if (!Directory.Exists(pluginsRootPath))
        {
            yield break;
        }

        var pluginDirectories = Directory.GetDirectories(pluginsRootPath);
        foreach (var pluginDirectory in pluginDirectories)
        {
            // C:\..\ext\Plugin\bin\Plugin.dll
            var pluginFullName =
                Path.Combine(
                    pluginDirectory,
                    binDirectoryName,
                    $"{Path.GetFileName(pluginDirectory)}.dll"
                );

            if (File.Exists(pluginFullName))
            {
                yield return Assembly.LoadFile(pluginFullName);
            }
        }
    }

    private static void ConfigureAssemblyResolve(ILogger logger, string pluginsRootPath, string binDirectoryName)
    {
        AppDomain.CurrentDomain.AssemblyResolve += (sender, e) =>
        {
            // Extract dependency name from the full assembly name:
            // FooPlugin.FooClass, Version = 1.0.0.0, Culture = neutral, PublicKeyToken = null
            var pluginDependencyName = e.Name.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).First();

            // C:\..\ext\Plugin\bin\PluginDependency.dll
            var pluginDependencyFullName =
                Path.Combine(
                    pluginsRootPath,
                    pluginDependencyName,
                    binDirectoryName,
                    $"{pluginDependencyName}.dll"
                );

            logger.Log(Abstraction.Layer.Infrastructure().Data().Variable(new { pluginDependencyFullName }));

            return
                File.Exists(pluginDependencyFullName)
                    ? Assembly.LoadFile(pluginDependencyFullName)
                    : null;
        };
    }

    // Adds plugin directory to Razor view engine so that it can resolve plugin's views e.g. \ext\Plugin
    private static void ConfigureRazorViewEngine(this IServiceCollection services, IHostingEnvironment hostingEnvironment, IEnumerable<Assembly> pluginAssemblies, string pluginsRootPath)
    {
        services.Configure<RazorViewEngineOptions>(options =>
        {
            foreach (var pluginAssembly in pluginAssemblies)
            {
                var pluginRootPath =
                    Path.Combine(
                        pluginsRootPath,
                        pluginAssembly.GetName().Name
                    );

                options
                    .FileProviders
                    .Add(new PhysicalFileProvider(pluginRootPath));
            }

            // Extension development does not use plugins so we have to look for it in the current directory parent 
            // because the service is "installed" as a submodule which is a subdirectory.
            if (hostingEnvironment.IsDevelopment("Extension"))
            {
                // ContentRootPath is the path of the *.csproj, we have to go back two levels to reach the extension directory.
                var extensionDirectory = new DirectoryInfo(hostingEnvironment.ContentRootPath).Parent?.Parent;

                if (extensionDirectory is null)
                {
                    throw new DirectoryNotFoundException("Could not find extension directory.");
                }

                options
                    .FileProviders
                    .Add(new PhysicalFileProvider(Path.Combine(extensionDirectory.FullName, extensionDirectory.Name)));
            }
        });
    }
}

Registering the plugin engine has now become a one-liner:

public void ConfigureServices(IServiceCollection services)
{

    //..

    services
        .AddMvc()
        .AddPlugins();

    //..
}
\$\endgroup\$
  • 1
    \$\begingroup\$ t3chb0t this solution seems really cool. I tried to use your framework but without success. I wonder if you have a github example or simular that gives an overview over the structure? Plugins seems to load, but are not published to the router. \$\endgroup\$ – Large Dec 14 '18 at 22:49
  • \$\begingroup\$ Also the Abstraction.Layer.Infrastructure().Data() variables are unknowns, so it doesn't show what plugin was loaded. \$\endgroup\$ – Large Dec 14 '18 at 22:50
  • \$\begingroup\$ @Large yes, it's on GitHub. You can find the latest version here - let me know if you have any questions ;-) \$\endgroup\$ – t3chb0t Dec 14 '18 at 22:55
  • \$\begingroup\$ thank you for a very quick reply :) I will check out the mailr repro \$\endgroup\$ – Large Dec 14 '18 at 23:13

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.