Loading ASP.NET MVC Controllers and Views From an Assembly
Back to MVC land! This time, I wanted to be able to load controllers and views from an assembly other than my application. I know about the extensibility mechanisms that ASP.NET and MVC give provides, such as Virtual Path Providers and Controller Factories, so I thought I could use them.
First things first: we need a controller factory that can load a controller from another assembly:
1: class AssemblyControllerFactory : DefaultControllerFactory
2: {
3: private readonly IDictionary<String, Type> controllerTypes;
4:
5: public AssemblyControllerFactory(Assembly assembly)
6: {
7: this.controllerTypes = assembly.GetExportedTypes().Where(x => (typeof(IController).IsAssignableFrom(x) == true) && (x.IsInterface == false) && (x.IsAbstract == false)).ToDictionary(x => x.Name, x => x);
8: }
9:
10: public override IController CreateController(RequestContext requestContext, String controllerName)
11: {
12: var controller = base.CreateController(requestContext, controllerName);
13:
14: if (controller == null)
15: {
16: var controllerType = this.controllerTypes.Where(x => x.Key == String.Format("{0}Controller", controllerName)).Select(x => x.Value).SingleOrDefault();
17: var controllerActivator = DependencyResolver.Current.GetService(typeof (IControllerActivator)) as IControllerActivator;
18:
19: if (controllerType != null)
20: {
21: if (controllerActivator != null)
22: {
23: controller = controllerActivator.Create(requestContext, controllerType);
24: }
25: else
26: {
27: controller = Activator.CreateInstance(controllerType) as IController;
28: }
29: }
30: }
31:
32: return (controller);
33: }
34: }
I inherited AssemblyControllerFactory from DefaultControllerFactory because this class has most of the logic we need, and I just override its CreateController method.
Next, we need to be able to load view files from an assembly, and a virtual path provider is just what we need for that:
1: public class AssemblyVirtualPathProvider : VirtualPathProvider
2: {
3: private readonly Assembly assembly;
4: private readonly IEnumerable<VirtualPathProvider> providers;
5:
6: public AssemblyVirtualPathProvider(Assembly assembly)
7: {
8: var engines = ViewEngines.Engines.OfType<VirtualPathProviderViewEngine>().ToList();
9:
10: this.providers = engines.Select(x => x.GetType().GetProperty("VirtualPathProvider", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(x, null) as VirtualPathProvider).Distinct().ToList();
11: this.assembly = assembly;
12: }
13:
14: public override CacheDependency GetCacheDependency(String virtualPath, IEnumerable virtualPathDependencies, DateTime utcStart)
15: {
16: if (this.FindFileByPath(this.CorrectFilePath(virtualPath)) != null)
17: {
18: return (new AssemblyCacheDependency(assembly));
19: }
20: else
21: {
22: return (base.GetCacheDependency(virtualPath, virtualPathDependencies, utcStart));
23: }
24: }
25:
26: public override Boolean FileExists(String virtualPath)
27: {
28: foreach (var provider in this.providers)
29: {
30: if (provider.FileExists(virtualPath) == true)
31: {
32: return (true);
33: }
34: }
35:
36: var exists = this.FindFileByPath(this.CorrectFilePath(virtualPath)) != null;
37:
38: return (exists);
39: }
40:
41: public override VirtualFile GetFile(String virtualPath)
42: {
43: var resource = null as Stream;
44:
45: foreach (var provider in this.providers)
46: {
47: var file = provider.GetFile(virtualPath);
48:
49: if (file != null)
50: {
51: try
52: {
53: resource = file.Open();
54: return (file);
55: }
56: catch
57: {
58: }
59: }
60: }
61:
62: var resourceName = this.FindFileByPath(this.CorrectFilePath(virtualPath));
63:
64: return (new AssemblyVirtualFile(virtualPath, this.assembly, resourceName));
65: }
66:
67: protected String FindFileByPath(String virtualPath)
68: {
69: var resourceNames = this.assembly.GetManifestResourceNames();
70:
71: return (resourceNames.SingleOrDefault(r => r.EndsWith(virtualPath, StringComparison.OrdinalIgnoreCase) == true));
72: }
73:
74: protected String CorrectFilePath(String virtualPath)
75: {
76: return (virtualPath.Replace("~", String.Empty).Replace('/', '.'));
77: }
78: }
79:
80: public class AssemblyVirtualFile : VirtualFile
81: {
82: private readonly Assembly assembly;
83: private readonly String resourceName;
84:
85: public AssemblyVirtualFile(String virtualPath, Assembly assembly, String resourceName) : base(virtualPath)
86: {
87: this.assembly = assembly;
88: this.resourceName = resourceName;
89: }
90:
91: public override Stream Open()
92: {
93: return (this.assembly.GetManifestResourceStream(this.resourceName));
94: }
95: }
96:
97: public class AssemblyCacheDependency : CacheDependency
98: {
99: private readonly Assembly assembly;
100:
101: public AssemblyCacheDependency(Assembly assembly)
102: {
103: this.assembly = assembly;
104: this.SetUtcLastModified(File.GetCreationTimeUtc(assembly.Location));
105: }
106: }
These three classes inherit from VirtualPathProvider, VirtualFile and CacheDependency and just override some of its methods. AssemblyVirtualPathProvider first checks with other virtual path providers if a file exists, and only if it doesn’t does it create the AssemblyVirtualFile. This looks up the virtual file name in the assembly’s resources, using a convention that translates slashes (/) per dots (.) and returns it. As for the AssemblyCacheDependency, we need it because otherwise ASP.NET MVC will think that the file exists in a directory and will try to monitor it, and because the directory and file do not exist, it will throw an exception at runtime.
We also need a bootstrapping class for setting up everything:
1: public static class AssemblyRoute
2: {
3: public static void MapRoutes(this RouteCollection routes, Assembly assembly)
4: {
5: ControllerBuilder.Current.SetControllerFactory(new AssemblyControllerFactory(assembly));
6: HostingEnvironment.RegisterVirtualPathProvider(new AssemblyVirtualPathProvider(assembly));
7: }
8: }
Finally, for this to work, we need three things:
- The controller must be public, have a parameterless constructor, and its name must end with Controller (the default convention);
- View files must be compiled as embedded resources in the assembly:
- And finally, we need to set this up in Global.asax.cs or RouteConfig.cs:
1: routes.MapRoutes(typeof(MyController).Assembly);
By the way, the AssemblyVirtualPath provider, AssemblyVirtualFile and AssemblyCacheDependency are pretty generic, so you can use them in other scenarios.
That’s all, folks!