Introduction
In the company I work for, we have a large web application, containing over 250 pages. Every time one of our customers has a new feature on his mind we figure out a price for it and develop. However, after developing we add the new feature to our common code-base and make it available to all of our customers, not only the on requested, and paid for it.
A few months ago a potential customer came to us and said that he wants to add another layer to this development-distribution schema. He believes he has some secret and powerful solutions, others have not. He wants a platform to add these solutions to our application by replacing certain pages. He also wants to keep these gems only for himself.
As today the only way, in our application, to replace certain pages is – to replace it. For obvious reasons of maintenance we do not want to keep two (or more) different applications, so I looked for a solution that can fit in an existing application.
After learning the issue I came up with some pre-requests:
- Single file deployment – to replace any page the customer will put a single file into a predefined folder
- To declare a page to be replaceable, we should do the minimum changes to our existing code
- We should declare a simple and straight way to the customer's developer to declare a page to replace existing one (mostly to save us from debugging others code)
Note: When reading this article, remember that the solution made to fit into
an existing application, and for that reason it's not an one-for-all, but rather a case-study.
In the rest of this article I will try to show and explain the solution I came up with…
Using the code
The code presented in the text body will not make a complete solution. To see a more complete implementation of the ideas
in this article download and study the attached demo.
Points of Interest
The solution in this article uses two little gems of the ASP.NET
jewelry.
MEF
"The Managed Extensibility Framework (MEF) is a composition layer for .NET that improves the flexibility, maintainability and testability of large applications. MEF can be used for third-party plugin extensibility, or it can bring the benefits of a loosely-coupled plugin-like architecture to regular applications." (http://mef.codeplex.com)
VirtualPathProvider
"The VirtualPathProvider class provides a set of methods for implementing a virtual file system for a Web application. In a virtual file system, the files and directories are managed by a data store other than the file system provided by the server's operating system. For example, you can use a virtual file system to store content in a SQL Server database." (http://msdn.microsoft.com/en-us/library/system.web.hosting.virtualpathprovider.aspx)
Both these topics are worth study by on there own...
The Steps
There are four main steps involved in the solution
- Create an extension point, means to declare an existing page in an application as one can be replaced by plugin.
- Create a plugin page. This page the one can replace the original.
- Load and select the plugin for a selected extension point.
- Render the loaded page to the client.
Extension Point
public class ExtensiblePage : Page
{
public virtual string ExtensionPoint
{
get;
set;
}
}
public partial class Login : ExtensiblePage
{
public override string ExtensionPoint
{
get
{
return ( "Login" );
}
}
}
Using these few line of codes I declare the Login
page as one can be replaced by plugin. The string itself will serve me to find the plugin pages intent to replace this one...
Plugin Page
[InheritedExport]
public interface IPagePlugin
{
Assembly Assembly
{
get;
}
string ResourceID
{
get;
}
}
public interface IPagePluginMetadata
{
string ExtensionPoint
{
get;
}
string PluginName
{
get;
}
}
[MetadataAttribute]
[AttributeUsage( AttributeTargets.Class |
AttributeTargets.Interface, AllowMultiple = false, Inherited = true )]
public class PagePluginMetadataAttribute : ExportAttribute, IPagePluginMetadata
{
public PagePluginMetadataAttribute ( )
: base( typeof( IPagePlugin ) )
{
}
#region IPagePluginMetadata Members
public string ExtensionPoint
{
get;
set;
}
public string PluginName
{
get;
set;
}
#endregion
}
The IPagePlugin
interface has two purposes. One to declare the page implements it as loadable by MEF engine (InheritedExport
attribute does it). The other is to provide information about this plugin, namely the assembly and resource info used to load the embedded page.
The IPagePluginMetadata
, implemented by PagePluginMetadata
attribute used to add metadata information to the plugin. In my case the info declares the extension point and adds a name - that can be used in configuration - to the plugin.
Now let see, how to use these interfaces...
[PagePluginMetadata( ExtensionPoint = "Login", PluginName = "LoginExtension" )]
public partial class LoginExtension : Page, IPagePlugin
{
#region IPluginPage Members
public Assembly Assembly
{
get
{
return ( GetType ( ).Assembly );
}
}
public string ResourceID
{
get
{
return ( string.Format ( "{0}.aspx", GetType ( ).FullName ) );
}
}
#endregion
}
This sample declares the LoginExension
page as one can replace the extensible page with extension point "Login".
Assembly
and ResourceID
properties return values can identify the page itself as embedded resource.
NOTE: I always speak about embedded resource. It's not part of the code provided in the text body (it's can be set in the IDE) but its important to remember that the plugin page is declared as embedded resource, and that is to fulfil the first pre-request I made at the beginning...
Load Plugin Pages
MEF ways to load extensions is extremely powerful and makes it really fast-forward to get a list of available classes. MEF does it by scanning a specific (or more) folder for assemblies have classes decorated with selected interfaces in it. In my case the class must inherit the IPluginPage
interface and be decorated by PagePluginMetadata
.
public class PagePluginCollection
{
[ImportMany( typeof( IPagePlugin ) )]
public List<Lazy<IPagePlugin, IPagePluginMetadata>> Items
{
get;
set;
}
}
PagePluginCollection oPagePluginCollection = new PagePluginCollection( );
AggregateCatalog oAggregateCatalog = new AggregateCatalog( );
Uri oUri = new Uri( Assembly.GetExecutingAssembly( ).CodeBase );
string szPath = Path.Combine( Path.GetDirectoryName( oUri.LocalPath ), "Plugin" );
if ( Directory.Exists( szPath ) )
{
oAggregateCatalog.Catalogs.Add( new DirectoryCatalog( szPath ) );
}
CompositionContainer oCompositionContainer = new CompositionContainer( oAggregateCatalog );
oCompositionContainer.ComposeParts( oPagePluginCollection );
AggregateCatalog
(from MEF) loads the assemblies from the folder specified. CompositionContainer
used to holds all the composable parts found in those assemblies, including its dependencies. In the last line the ComposeParts
method loads the composable parts into the PagePluginCollection
list. I'm using here Lazy
to defer the initialization of the actual object, and that for save resources in case no plugins will be used (without the Lazy
part, the last step would create real objects form the loaded composable parts).
Find the Plugin
It's really easy. I used LINQ here, but any way to find a item in a list will do it. One thing we can learn is how we can access those metadata bit we added to our plugin page (MEF does it a-piece-of-cake).
oPagePluginCollection.Items.Find(
( oPlugin ) =>
{
return ( ( oPlugin.Metadata.ExtensionPoint == "Login" ) && ( oPlugin.Metadata.PluginName == "LoginExtension" ) );
}
);
At this point I have the plugin page in our hand and can get into the rendering, but just before that an other interesting point. In the code sample I used some literal values to find the plugin page but you may see the opportunity to use variables that came from the extensible page (ExtensionPoint
) and from some configuration (PluginName
). With variables it is possible that you have numerous plugin pages for the same extension page and you may choose to load a different one every time.
VirtualPathProvider
To make it simple I will start this part from the outside, that how to use a virtual path provider of your own. The code below goes into the Global.asax.cs
...
public class Global : HttpApplication
{
protected void Application_Start ( object sender, EventArgs e )
{
HostingEnvironment.RegisterVirtualPathProvider( new EmbeddedPageProvider( ) );
ExtensionUtils.InitPlugins( Application );
}
}
RegisterVirtualPathProvider
adds my home-made provider to the ASP.NET compilation system. It lets me to provide a different - virtual - file, instead the one the default ASP.NET provider presents (The InitPlugins method - that not presented here - used to load all the plugins into the application. Its code almost identical to the one presented in Load Plugin Pages section).
The provider itself is the most complicated code of the solution...
public class ExtensionVirtualFile : VirtualFile
{
PagePluginItem _Plugin;
public ExtensionVirtualFile ( string VirtualPath, PagePluginItem Plugin )
: base( VirtualPath )
{
_Plugin = Plugin;
}
public override Stream Open ( )
{
return ( _Plugin.Value.Assembly.GetManifestResourceStream( _Plugin.Value.ResourceID ) );
}
}
public class EmbeddedPageProvider : VirtualPathProvider
{
public override bool FileExists ( string VirtualPath )
{
if ( HttpContext.Current.CurrentHandler is ExtensiblePage )
{
return ( true );
}
return ( base.FileExists( VirtualPath ) );
}
public override VirtualFile GetFile ( string VirtualPath )
{
if ( HttpContext.Current.CurrentHandler is ExtensiblePage )
{
string szExtensionPoint =
( ( ExtensiblePage )HttpContext.Current.CurrentHandler ).ExtensionPoint;
string szName = ExtensionUtils.GetConfigValue(
HttpContext.Current.Application, szExtensionPoint );
PagePluginItem oActivePlugin = ExtensionUtils.GetActivePlugin(
HttpContext.Current.Application, szExtensionPoint, szName );
return ( new ExtensionVirtualFile( VirtualPath, oActivePlugin ) );
}
return ( base.GetFile( VirtualPath ) );
}
public override CacheDependency GetCacheDependency (
string VirtualPath, IEnumerable VirtualPathDependencies, DateTime UTCStart )
{
if ( HttpContext.Current.CurrentHandler is ExtensiblePage )
{
return ( null );
}
return ( base.GetCacheDependency( VirtualPath, VirtualPathDependencies, UTCStart ) );
}
}
FileExist
checks if the requested path is one of mine to handle, if it returns true the ASP.NET rendering will call the GetFile
method to retrieve an VirtualFile
that in his turn will supply a Stream with
the files content.
Render the Plugin Page
Just before the ASP.NET rendering engine start to handle the page I have to redirect it to the replacement page, if any. For that I'm using the Application_PreRequestHandlerExecute
method in
Global.asax.cs...
protected void Application_PreRequestHandlerExecute ( object sender, EventArgs e )
{
if ( HttpContext.Current.CurrentHandler is ExtensiblePage )
{
ExtensiblePage oExtensiblePage = ( ExtensiblePage )HttpContext.Current.CurrentHandler;
string szExtensionPoint = oExtensiblePage.ExtensionPoint;
PagePluginCollection oPlugins = ( PagePluginCollection )Application[ _PluginRoot ];
string szConfigValue = GetConfigValue( Application, szExtensionPoint );
if ( oPlugins != null )
{
PagePluginItem oActivePlugin = oPlugins.Items.Find(
( oPlugin ) =>
{
return ( ( oPlugin.Metadata.ExtensionPoint == szExtensionPoint ) &&
( oPlugin.Metadata.PluginName == szConfigValue ) );
}
);
if ( oActivePlugin != null )
{
Server.Transfer( string.Format( "{0}/{1}/{2}/{3}", _PluginPageRoot,
oActivePlugin.Metadata.ExtensionPoint,
oActivePlugin.Metadata.PluginName, oActivePlugin.Value.ResourceID ), true );
}
}
}
}
In a very simple way I do Server.Transfer
to some made-up path, that identifies the plugin page. I found two advantages to this solution:
- No URL change on client side
- The request's data preserved and available to the new page
That summarizes the code part of the idea I had, but there are some more technical issues to take care...
The Plugin is Embedded
As one of the pre-request was to do single file deployment, the markup have to be embedded inside the DLL, together with the code. To do that you must select page properties in Visual Studio and change Build Action
to Embedded Resource
.
An other part of this embedded story is in the markup. When Visual Studio generates your basic markup it starts every page wit a line like this:
<%@ Page Language="C#" CodeBehind="Default.aspx.cs" Inherits="Default,Plugin" %>
When your page embedded the Inherits property - as is - will not find the code behind, so you must change it by adding namespace, like this:
<%@ Page Language="C#" CodeBehind="Default.aspx.cs" Inherits="Plugin.Default,Plugin" %>
Where My Plugin Is
By default all binary files for a site sit in the \bin
folder, in this solution I decided to put all the plugin files in a sub-folder - for better maintain. However ASP.NET will not find those assemblies without a minor modification of the web.config (the binary folder not a fixed values of IIS/ASP.NET but a configuration option).
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<probing privatePath="bin;bin\Plugins" />
</assemblyBinding>
</runtime>
This change will tell ASP.NET to look for assemblies also in the sub-folder Plugins.
AJAX Problem
The action attribute of the form element used by AJAX as a target for posting-back in partial rendering. In our case the URL displayed to the user isn't changing even a plugin activated, but the real content of the page does reflect those changes. One of those changes is the value of the action attributes that will be the path used by Server.Transfer
. The problem is that the property changes on post back because of the virtual nature of the page. This change will cause an error (404) on the second AJAX post-back. To solve I added OnLoad
override to the plugin page...
protected override void OnLoad ( EventArgs e )
{
base.OnLoad( e );
if ( !IsPostBack )
{
Form.Action = ResolveClientUrl( Request.AppRelativeCurrentExecutionFilePath );
}
}
Demo
The demo solution attached show a case to replace the original login page. In the demo I added all the bits and rounded up with some configuration handling...
Download Demo
Last Words
At this point, we (the company), not yet have the new customer, but I already learned a lot of useful and powerful things. If you do so please take a moment and let me know how you used the ideas I got...