Trade-offs have to be made when moving to Cloud platforms, that’s a fact, the question is how much of a comprise do you have to make. Performance and flexibility are two such entities that are intertwined. In this article I want to talk about how you can build a plugin based architecture in Azure so you can easily and flexibly alter your service to cope with new situations.
How does C# on the Desktop do plugins?
In C# there are known frameworks or coding paradigm when it comes to plugins, MAF and MEF which are the common ways; you create an interface and then create plugins which implement that interface. You then point to where the assemblies housing those implementations are and bosh, you have a plugin architecture.
In Azure things happen a little differently, where do you host assemblies, how do you load dependencies?
Note: If you are after performance, you should always load plugins during the initialisation phase of your service, if you have to do it during runtime make sure its in a separate thread to stop or minimise blocking to the plugin manager.
Think Blobs and Containers, not Files and Folders
Azure and other PaaS providers move away from the concept of Files and Folders and instead have the concept of high availability replicated binary blobs. Plugins are great for blobs as they are effectively static content; they may get a minor version bump but are not dynamically changing. Blobs are stored in virtual containers whose name can be configured to represent virtual paths.
Do: Store your plugin libraries in a separate container so that they do not interfere with your working file set, or vice versa.
The first step is to create a plugin library, this step is exactly the same as regular desktop C#.
Example Plugin Interface (stored in main service library)
namespace CloudExample.Plugins { public interface IWorkerOutlet { string ModuleName { get; } } }
Example Plugin Class (stored in an external library)
namespace CloudExample { public class ExamplePlugin : IWorkerOutlet { #region Public Properties public string ModuleName { get { return "ExamplePlugin"; } } #endregion } }
You can create a new container via code or in the Azure management console, the same thing goes with uploading the Plugin library to the folder. I won’t explain that here as if you are thinking about plugins, I think you should be able to push files to containers.
Loading Plugin Assemblies
There are two parts to using plugins successfully, firstly loading the initial plugin assembly, and secondly determining and validating any required dependency assemblies.
Loading plugins is as simple as scanning the container for *.dll files (blobs) and downloading them into memory. Once this is done they can be handled much the same way as on the desktop but you are loading an assembly from a binary array instead of a direct file path.
Do: Make sure you perform the usual checks for versioning and scanning for the correct interface implementation otherwise your host service may get unhandled exceptions.
Do: If you only care about loading plugins at initialisation, cache the query from the container search for use when checking dependencies to reduce IO traffic.
private static CloudBlobContainer assemblyContainer; private static Dictionary<string, IWorkerOutlet> availablePlugins = new Dictionary<string, IWorkerOutlet>(); public static void InitialisePlugins() { string connectionString = CloudConfigurationManager.GetSetting("Example.Storage.ConnectionString"); CloudStorageAccount blobStorageAccount = CloudStorageAccount.Parse(connectionString); CloudBlobClient blobClient = blobStorageAccount.CreateCloudBlobClient(); assemblyContainer = blobClient.GetContainerReference(PluginContainer); assemblyContainer.CreateIfNotExists(); // Set the permissions of the container so that other instances and the website can access it and the blogs within. assemblyContainer.SetPermissions(new BlobContainerPermissions() { PublicAccess = BlobContainerPublicAccessType.Container }); AppDomain.CurrentDomain.AssemblyResolve += new ResolveEventHandler(CurrentDomain_AssemblyResolve); LoadPlugins(); } /// <summary> /// Iterate through the list of available plugins, download and attempt /// to initialise them. /// </summary> private static void LoadPlugins() { if (!assemblyContainer.Exists()) { // There is no assembly container, thus no plugins can be available return; } foreach (IListBlobItem blobItem in assemblyContainer.ListBlobs()) { // Download the plugin using (MemoryStream blobStream = new MemoryStream()) { try { var blob = new CloudBlockBlob(blobItem.Uri); if (blob.Exists()) { blob.DownloadToStream(blobStream); byte[] assemblyBytes = blobStream.ToArray(); Assembly asm = Assembly.Load(assemblyBytes); AddPluginAssembly(asm); } } catch (StorageException) { // Unable to download the file } } } } /// <summary> /// Validates and if successful loads the corresponding assembly into the plugin architecture /// </summary> /// <param name="asm"></param> private static void AddPluginAssembly(Assembly caller) { // Iterate through the types available in the Assembly to see if any inherit // from the IWorkerOutlet interface but not equals to. These can be instantiated as plugins foreach (Type t in caller.GetTypes()) { // Checking non-equality allows the project assembly to be scanned as well as a caller. if (typeof(IWorkerOutlet).IsAssignableFrom(t) && !t.Equals(typeof(IWorkerOutlet))) { try { IWorkerOutlet plugin = (IWorkerOutlet)caller.CreateInstance(t.ToString()); // Note: Additional logic can be done here rather than just storing the plugin availablePlugins.Add(plugin.ModuleName, plugin); } catch { // Invocation failure on the Plugin, this is to be expected. } } } }
This is great, you can load assemblies but some may fail, or cause exceptions, why? -because they are missing dependencies that are not part of your host service.
Do: Make sure you note which references you use in your plugin library, and compare them to your host service, deltas between the two may cause issues.
An example would be where your plugin library interfaces with a 3rd party library, but your main service doesn’t. Even if you as part of the first phase uploaded both libraries, only the first would be stored as its implementing the required interface, the second would be ignored.
Resolving Dependencies
When an application fails to resolve a dependency the AppDomain.CurrentDomain.AssemblyResolve event is fired but is unhandled by default. You can hook up to this event to see when internally the CLR requires referencing to a new library.
By not returning null, the CLR will attempt to resolve the specified assembly rather than performing internal checks. This can be useful in general for swapping out real libraries for test versions or in this case, helping point the way to where libraries are stored. MSDN contains information about the ordering sequence of how internal resolving occurs, but this doesn’t work in the Cloud due to the differences in file structure.
By hooking into this event, we can check the assemblies container to determine if the required assembly is present, if so this file can be downloaded and used. Sometimes we don’t need to host all assemblies – as I’ve mentioned the host service may already have them as references which can be internally resolved.
Do not: There is no need to upload all assemblies thrown in the AssemblyResolve event, most of these will be triggered by the host service internally and thus will internally resolve.
Do: Keep a flag of when you are loading plugins, AssemblyResolve will fire in two phases, first during the initial startup of your host service when it is loading its own dependencies, then while it is loading plugin dependencies. By seperating the two phases you can reduce the requests to Blob Storage.
private static Assembly CurrentDomain_AssemblyResolve(object sender, ResolveEventArgs args) { string assemblyPath = GetAssemblyName(args) + ".dll"; // Download the missing assembly if available using (MemoryStream blobStream = new MemoryStream()) { try { // Note: Use precache of where the assembly container is CloudBlockBlob blob = assemblyContainer.GetBlockBlobReference(assemblyPath); if (blob.Exists()) { blob.DownloadToStream(blobStream); byte[] assemblyBytes = blobStream.ToArray(); Assembly asm = Assembly.Load(assemblyBytes); // Check to see if this assembly actually matches the fully // qualified name. if (asm.FullName == args.Name) { return asm; } } } catch (StorageException) { } } // Failed to resolve the plugin DLL. return null; } /// <summary> /// Determines the assembly name of a fully qualified assembly name /// </summary> /// <param name="args">Assembly Arguments to resolve</param> /// <returns>Returns a value indicating the assembly name</returns> private static string GetAssemblyName(ResolveEventArgs args) { string name = string.Empty; if (args.Name.IndexOf(",") > -1) { name = args.Name.Substring(0, args.Name.IndexOf(",")); } else { name = args.Name; } return name; }
What I usually do is store dependencies that I could not resolve to a serialised XML file in blob storage, to a debug log, or to a dependency table in a DB so I can check them at a later date.
Why use plugins?
Its pretty easy to implement plugins in C#, even if in Azure by utilising Blob storage. You should always be careful using this technique though due to the costs of reflection.
Do Not: Use this code in production, there’s very little validation, error checking or concurrency checks!