Need Quality Code? Get Silver Backed

Sharing MVC Views

4thMar

0

by Gary H

It's impossible to map out a route to your destination if you don't know where you're starting from.

Suze Orman

MVC is a great tool for building compelling websites but what happens when you want to reuse common views or partials across many sites? We all want to keep our code DRY so rather than copy-paste lumps of code across sites we decided to look at serving views from a shared library.

We started by deciding how we wanted to manage our views. The killer requirements are:

  • Views and Partials should be stored and edited as files on the filesystem
  • All files should support razor syntax highlighting
  • Any views or partials with models should take an interface - the implementing classes should be able to use data annotations for validation that are unique per implementing model

Getting Started

The first requirement denotes our structure. We created a new class library project, added a "Views" folder with a "Shared" subfolder and created our partial view.

Project Structure

So far so good. Next we need to tackle deployment. We want to make using our shared views as easy as referencing an assembly and calling RenderPartial. To do this we will need to ensure that the view itself gets embedded within our assembly as a resource. This means no files need to be copied into the site sharing the component, just an assembly reference is needed. To do this we set the "Build Action" property of our view file to "Embedded Resource".

Embedded Resource Build Action

Sharing the View

With an initial view created we're ready to start surfacing it in other sites. To do this we need to tell the ViewEngine that in addition to checking the usual places on disk, it should ask our shared library if it has the View.

We do this by creating our own VirtualPathProvider (VPP). After a VPP has been registered it will be triggered for all views that are not already satisfied by the default path providers. To create our VPP we inherit from VirtualPathProvider and implement the FileExists and GetFile methods.

public class EmbeddedResourceViewPathProvider : VirtualPathProvider
{
	private readonly Lazy<string[]> _resourceNames = 
		new Lazy<string[]>(() => 
			Assembly.GetExecutingAssembly()
					.GetManifestResourceNames(), 
					LazyThreadSafetyMode.ExecutionAndPublication);

	private bool ResourceFileExists(string virtualPath)
	{
		var resourcename = EmbeddedResourceFile
							.GetResourceName(virtualPath);
		var result = resourcename != null 
			&& _resourceNames.Value.Contains(resourcename);
		
		return result;
	}

	public override bool FileExists(string virtualPath)
	{
		return base.FileExists(virtualPath) 
			|| ResourceFileExists(virtualPath);
	}

	public override VirtualFile GetFile(string virtualPath)
	{
		if (!base.FileExists(virtualPath))
		{
			return new EmbeddedResourceFile(virtualPath);
		}

		return base.GetFile(virtualPath);
	}

}

To complement this we also create an implementation of VirtualFile to represent the View that we will return.

public class EmbeddedResourceFile : VirtualFile
{
	public EmbeddedResourceFile(string virtualPath) : 
		base(virtualPath) { }

	public static string GetResourceName(string virtualPath)
	{
		if (!virtualPath.Contains("/Views/"))
		{
			return null;
		}

		var resourcename = 
			virtualPath
			.Substring(virtualPath.IndexOf("Views/"))
			// NB: Your assembly name here
			.Replace("Views/", "LeapingGorilla.Common.MVC.Views.")
			.Replace("/", ".");

		return resourcename;
	}

	public override Stream Open()
	{
		var assembly = Assembly.GetExecutingAssembly();
		var resourcename = GetResourceName(VirtualPath);
		return assembly.GetManifestResourceStream(resourcename);
	}
}

One important caveat to note: if your shared view has a model, you MUST use a fully qualified namespace to the model itself - no shortcuts. This is so that when the view is returned to the calling site it will know exactly where the model is.

Linking it Up

Virtual Path Provider in hand we are ready to register it in our calling site. To do so we add a line to Global.asax adding our provider after referencing our common assembly.

protected void Application_Start()
{
	HostingEnvironment
		.RegisterVirtualPathProvider(
			new EmbeddedResourceViewPathProvider());
	// ...
}

And to use our view is as simple as calling it like any other partial within our view:

@Html.Partial("Address", Model)

Validation

What we have written so far will be fully functional. It will even support validation through data annotations if you put them on the model in your shared library. What we wanted though was to take it one step further. We wanted to have the partial take an interface as a model and then allow the implementing class to set its own data annotations for validation. This would give us flexibility to change the rules for our partial based on the view that hosts it. For example, one page may allow post codes from any region and another only from a specific area.

To do this we need to create a custom implementation of the DataAnnotationsModelMetadataProvider. The methods that we override need to tell the metadata provider that if we are using an interface, we should pull our metadata from the implementing type. Whilst doing this we also discovered an exciting caveat in the "DisplayName" attribute where having an empty name could cause a crash with no stack trace. We added a check in our metadata provider to also call this out and to add a useful error message.

public class InterfaceModelViewDataMetadataProvider 
	: DataAnnotationsModelMetadataProvider
{
	protected override ModelMetadata 
		CreateMetadata(IEnumerable<Attribute> attributes,
					   Type containerType,
					   Func<object> modelAccessor,
					   Type modelType,
					   string propertyName)
	{
		/* If containerType is an interface, get the actual type 
		 and the attributes of the current property on that 
		 type. */
		if (containerType != null 
			&& containerType.IsInterface)
		{
			var target = modelAccessor.Target;
			var container = target
								.GetType()
								.GetField("container")
								.GetValue(target);
			containerType = container.GetType();

			var propertyDescriptor = 
				GetTypeDescriptor(containerType)
					.GetProperties()[propertyName];
			
			attributes = FilterAttributes(
							containerType, 
							propertyDescriptor, 
							propertyDescriptor
								.Attributes
								.Cast<Attribute>());
		}

		var attribs = attributes as 
				IList<Attribute> ?? attributes.ToList();

		/* Check that we dont have any DisplayName issues in 
		   validation that will cause the site to explode with an 
		   error about DisplayName_Set and no stacktrace */

		var metadata = base.CreateMetadata(attribs, 
										containerType, 
										modelAccessor, 
										modelType, 
										propertyName);

		if (metadata != null 
		 && attribs.Any(a => a is DisplayNameAttribute) 
		 && String.IsNullOrEmpty(metadata.GetDisplayName()) 
		 && !String.IsNullOrEmpty(metadata.PropertyName))
		{
			var ctype = containerType != null ? 
				containerType.ToString() : "UnknownType";

			throw new 
				ValidationAttributeConfigurationException(
					metadata.PropertyName, ctype);
		}

		return metadata;
	}
}

With the metadata provider complete the final step is to register it with our calling application. Another line in the Global.asax completes the binding.

protected void Application_Start()
{
	// ...

	ModelMetadataProviders.Current = 
		new InterfaceModelViewDataMetadataProvider();

	// ...
}

Conclusions

So there we have it. A way of sharing partial views across many sites, each of which can have their own custom validation using data annotations and no need to transfer any files other than the class library itself.

C# , MVC

Comments are Locked for this Post