Nice code, I'll take it
Okay for those who remember Age Of Empires II taunts: every time I see code I like it reminds of taunt number 21: Nice town, I'll take it.
So that clears up the weird title... next!
Reinventing the wheel
I'm currently working on an assignment where we are creating a modular Asp.Net MVC 5 website. Yes, I know everything is modular these days...
To do this, we initially (ab)used the Area feature of Asp.Net MVC which will allow you to - kinda - build a website inside a website. What we did is we created a new class library where we would reference the MVC libraries and so on.
While developing new "modules", we configured a tool called Free File Sync to real-time watch our folders for changes and copied them to the corresponding folders so that it would look like those modules are Area code. (any DevOps getting nervous already?)
This was working well for us until we decided to test the modular approach and started building a module outside of our primary Solution.
The following problems appeared:
- When module solutions were created using older (shared) dependencies than our shell website we would get in trouble since we were copying all DLL's directly into the /bin folder of the shell site.
- When a developer did not have the module solution on his PC the free file sync script would still try to copy the files to folders that don't exist. Okay admitting here we could have fine tuned the scripts but didn't.
Other drawbacks are that we need the free file sync app running all the time, not that this is a major annoyance but still no ffs? No code deployment simple as that.
So I set out to upgrade our developer experience here.
nopCommerce plugins
In my off-work time, I spend a lot of time in the code repository of nopCommerce. For those who do not know it and run an e-commerce service, you should check it out as it is an excellent shopping cart/webshop solution for free.
Wait a minute
When you look at the code of the PluginManager over at the github repo we find the following line 14:
//Contributor: Umbraco (http://www.umbraco.com). Thanks a lot!
So it seems the good people borrowed it themselves. How about that for code reusability ?!?
How it works
In nopCommerce plugins implement the IPlugin interface or derive from BasePlugin class.
These plugins are regular class library projects which have their output path set to the plugin folder of the Nop.Web project. Each plugin ends up in its dedicated subfolder and when the application is started the plugins are loaded during PreApplicationStartMethod (see Phil Haack 's blog post "Three Hidden Extensibility Gems in ASP.NET 4")
It will copy the necessary dll's either to the bin or the application's dynamic path (asp.net temporary files) depending on the trust the app is running in and bootstrap dependency in the AppDomain.
description.txt
Also, each plugin has a description.txt file which looks like
Group: Promotion feeds
FriendlyName: Google Shopping (formerly Google Product Search)
SystemName: PromotionFeed.Froogle
Version: 1.75
SupportedVersions: 3.90
Author: nopCommerce team
DisplayOrder: 1
FileName: Nop.Plugin.Feed.Froogle.dll
Description: This plugin enables integration with Google Shopping, formerly Google Product Search and Froogle
Example from Nop.Plugin.Feed.Froogle
This file helps the PluginManager to make certain decisions during deployment of the plugin.
Ok, I've told about how it works more than I wanted to. Want to know more go to How to write a nopCommerce plugin.
What we altered
So the original code was thankfully duplicated, and we kept the following parts:
- BasePlugin
- PluginManager
- IPlugin
- PluginFileParser
- PluginDescriptor
- WriteLockDisposable
Step 1: Re-brand it
Since we are talking about the concept of modules instead of plugins let's do a CTRL+H
Plugin => Module which leaves us with the following result:
Step 2: Eliminate the unwanted
When you look back in this post, you saw a sample of the description.txt file. We do not need all this meta-data, so the contents of our description were trimmed down to this:
FriendlyName: Some Awesome Module
SystemName: TheBestEver
FileName: Some.Awesome.Module.dll
Content:css|~/css/modules/awesomemodule;scripts|~/scripts/modules/awesomemodule;views|~/Areas/AwesomeModule/Views
We also ripped out all the corresponding code in the PluginModuleDescriptor to look like this:
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using StructureMap;
namespace ModuleLoader
{
public class ModuleDescriptor : IComparable<ModuleDescriptor>
{
private readonly IContainer _container;
public ModuleDescriptor(IContainer container)
{
_container = container;
}
public ModuleDescriptor(Assembly referencedAssembly, FileInfo originalAssemblyFile,
Type moduleType, IContainer container)
: this(container)
{
ReferencedAssembly = referencedAssembly;
OriginalAssemblyFile = originalAssemblyFile;
ModuleType = moduleType;
}
public virtual string ModuleFileName { get; set; }
public virtual Assembly ReferencedAssembly { get; internal set; }
public virtual FileInfo OriginalAssemblyFile { get; internal set; }
public virtual string FriendlyName { get; set; }
public virtual string SystemName { get; set; }
public virtual string Content { get; set; }
public virtual Dictionary<string, string> ContentItems
{
get { return Content.Split(';').ToDictionary(k => k.Split('|')[0], v => v.Split('|')[1]); }
}
public virtual bool Installed { get; set; }
public Type ModuleType { get; set; }
public int CompareTo(ModuleDescriptor other)
{
return string.Compare(FriendlyName, other.FriendlyName, StringComparison.OrdinalIgnoreCase);
}
public virtual T Instance<T>() where T : class, IModule
{
object instance = _container.TryGetInstance<T>();
if (instance == default(T))
{
var constructors = typeof(T).GetConstructors();
foreach (var constructor in constructors)
{
var parameters = constructor.GetParameters();
var parameterInstances = new List<object>();
foreach (var parameterInfo in parameters)
{
var service = _container.GetInstance(parameterInfo.ParameterType);
if (service == null)
break;
parameterInstances.Add(service);
}
instance = Activator.CreateInstance(typeof(T), parameterInstances.ToArray());
}
}
var typedInstance = instance as T;
if (typedInstance != null)
typedInstance.ModuleDescriptor = this;
return typedInstance;
}
public IModule Instance()
{
return Instance<IModule>();
}
public override string ToString()
{
return FriendlyName;
}
public override bool Equals(object obj)
{
var other = obj as ModuleDescriptor;
return (other != null) &&
(SystemName != null) &&
SystemName.Equals(other.SystemName);
}
public override int GetHashCode()
{
return SystemName.GetHashCode();
}
}
}
Step 3: Enhance
Okay by now you should have noticed a little change to be precise the line:
Content:css|~/css/modules/awesomemodule;scripts|~/scripts/modules/awesomemodule;views|~/Areas/AwesomeModule/Views
Yes, you are right that this did not come from nopCommerce, but remember the beginning of my post where I said we created a modular ASP.Net MVC website (ab)using the Area feature. Well, this is so our Module can keep on using the Area feature.
The content line tells the ModuleManager which content files (CSS, script, cshtml,...) need to be copied where. It's a semicolon separated list of pipe separated items. Come again...?
First we split the string into parts using ';' as a separator so that gives us:
- css|~/css/modules/awesomemodule
- scripts|~/scripts/modules/awesomemodule
- views|~/Areas/AwesomeModule/Views
So then we split each part using the pipe so that the left hand is the folder in the Modules folder and right side is the location where the files in the lefthand need to be copied.
e.g.: all files under /CSS will be copied to ~/CSS/modules/awesomemodule
We could have imported even more from Nop's code because they have HtmlHelpers which can register scripts and CSS files, but we did not want to be too greedy, so we plugged in our solution.
Step 4: Housekeeping
Our current development flow using the FreeFileSync will make sure that whenever a watched file gets created or changed it is being copied to the configured location. But since we've kicked that tool out the only moment when new files are being copied where they need to be is:
- When we build the solution/project as we've set those files to type Content and Copy if newer
- The asp.net application restarts, which does not always happen
No worries, meet the FileSystemWatcher. This handy class that exists since .net version 1.1 allows you to monitor a particular path for file changes (created, modified, deleted,...). Using this and a setting in the web.config to switch this off in the production environment, we've changed the ModuleManager's DirectoryCopy method so that it will create a FileSystemWatcher for each file that gets copied under normal circumstances.
So now when we build the newer files get copied to the modules folder which is being watched and copied again according to the Content line settings.
Maybe not the most elegant solution around, but it works. The only thing we've lost is the fact we used to able to just CTRL+S a content file, and it would get copied without the need for a build.
That's in a nutshell how we did it.
-filip