Friday, July 31, 2009

EPiServer Enterprise Virtual Path Provider

This article demonstrates how to create a virtual path provider which can be used to serve site specific files, while still using the same application directory for your sites.

Introduction to EPiServer Enterprise

For those of you who have been able to play with a copy of EPiServer running an enterprise license, you can skip the next couple of paragraphs. For those who haven’t, here’s how it works:

Each site defined in IIS has the same home directory. When a page of one of the sites requested, EPiServer looks at the host header to see which set of pages (site) should be served. Multiple sites can be defined in the EPiServer section of web.config, and the host header(s) and start page for each site are configured.

When pages are created, EPiServer handles the fact that you might want to use the same URLs for pages under different sites. There are also many site settings which allow you to keep control over the way each site works, despite the fact they share the same code. Users, roles, access, workflow, categories, custom properties, etc, can all be administered from within a single interface, but you still have the ability to specify how these work over different sites. One of the main benefits of this architecture is that you can share resources, such as images, CSS, web controls, and page types over all your sites.

The Problem

The problem is, that there is no built in ability in EPiServer, for creating site specific resources for a given URL, or virtual path. Say for instance that you would like to use a common URL for two sites;

‘site1.com/logo.gif’, and
‘site2.com/logo.gif’.

Because these sites share the same application directory, this is impossible, without both sites having the same logo. You could separate all your site specific files into different sub directories, but this would give you URLs like these;

‘site1.com/site1-files/logo.gif’ and
‘site2.com/site2-files/logo.gif’.

This works fine but it’s not very elegant to look at. More importantly than how it looks though, are the implications for using these URLs in your application, and for virtual paths used on the server. Any ASP.NET page or control which wants to use a particular image, would have to be implemented once for each site, or, it would have to get the logo location from somewhere that can change per site, such as page properties, or some other custom site-aware system.

Now consider that you might want something like a page type to be common across multiple sites (because they work in exactly the same way and use the same controls) but you want the layout and the style of the page type to be different on each site. You should be able to specify different master pages for each site, and maybe different css files, without having to create more than one ASPX, and define more than one page type in EPiServer. Unfortunately, if you’re using a common ASPX for your page type, it will have to use the same resources. This is also true of things like web controls.

The Solution

One solution is to create a virtual path provider (VPP) which serves different files depending on which site is being requested. The logic is simple. When a request for a file is received, we check to see if there is a site-specific version of that file available. If there is, then we serve that file. If there isn’t, then we don’t serve anything from the VPP, instead allowing the file to be served by ASP.NET from the main application directory (the shared resources).

Here's the code for the virtual path provider and related classes:


using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.IO;
using System.Web;
using System.Web.Caching;
using System.Web.Hosting;

namespace EPiDave.Web.Hosting
{

/// <summary>
/// Virtual Path Provider designed to expose a file system for a
/// particular directory which contains files specific to a
/// particular website.
/// </summary>
/// <remarks>
/// This class cannot be used with the EPiServer file manager UI.
/// </remarks>

public class SiteSpecificPathProvider : VirtualPathProvider
{

private string _baseVirtualPath;

/// <summary>
/// Constructor - Loads the virtualPath property from supplied config settings
/// as the base virtual path to be used for the lifetime of the class instance.
/// </summary>
/// <param name="str">Name of the VPP (unused but required for construction by EPiServer)</param>
/// <param name="nvc">Collection of config properties for the VPP (from web.config)</param>

public SiteSpecificPathProvider(string name, NameValueCollection configProperties)
: base()
{

string virtualPath = configProperties["VirtualPath"];

if (String.IsNullOrEmpty(virtualPath))
_baseVirtualPath = "/";
else
_baseVirtualPath = VirtualPathUtility.AppendTrailingSlash(VirtualPathUtility.ToAbsolute(virtualPath));

}

/// <summary>
/// IsVirtual tests to see if the virtual path provided is one this VPP can serve
/// based on the loaded base virtual path.
/// </summary>
/// <param name="virtualPath">The virtual path requested</param>
/// <returns>True or false</returns>
/// <example>
/// Should return true if supplied path is under
/// "/base/" or is "/base" directory itself.
/// </example>

private bool IsVirtual(string virtualPath)
{

if (virtualPath.StartsWith(_baseVirtualPath, StringComparison.InvariantCultureIgnoreCase) ||
virtualPath.Equals(VirtualPathUtility.RemoveTrailingSlash(_baseVirtualPath), StringComparison.InvariantCultureIgnoreCase))
return true;
else
return false;

}

/// <summary>
/// ConvertVirtualToReal converts a virtual path to a real file system path
/// </summary>
/// <param name="virtualPath">The virtual path</param>
/// <param name="SiteResourceMapping">The mapped directory</param>
/// <returns>Server file path</returns>
/// <example>
/// Converting "~/Resources/logo.gif" with a directory mapping of "~/SiteResources/Site1/"
/// will result in "C:\ApplicationPath\SiteResources\Site1\Resources\logo.gif"
/// </example>

internal string ConvertVirtualToReal(string virtualPath)
{

string hostName = HttpContext.Current.Request.ServerVariables["HTTP_HOST"];

string path = "SiteSpecific/" + hostName + VirtualPathUtility.ToAbsolute(virtualPath);
path = HttpContext.Current.Server.MapPath(path);

return path;

}

internal bool FileAvailable(string virtualPath)
{
if (IsVirtual(virtualPath))
{
if (File.Exists(ConvertVirtualToReal(virtualPath)))
return true;
}
return false;
}

internal bool DirectoryAvailable(string virtualPath)
{
if (IsVirtual(virtualPath))
{
if (Directory.Exists(ConvertVirtualToReal(virtualPath)))
return true;
}
return false;
}

/// <summary>
/// FileExists tests to see whether or not a file exists at
/// a particular virtual path.
/// </summary>
/// <param name="virtualPath">The virtual path of the file</param>
/// <returns>True / false</returns>

public override bool FileExists(string virtualPath)
{

// If the file exists return true,
// else pass to the next provider in the list.
if (FileAvailable(virtualPath))
return true;

return Previous.FileExists(virtualPath);

}

/// <summary>
/// DirectoryExists tests to see whether or not a directory exists at
/// a particular virtual path.
/// </summary>
/// <param name="virtualDir">The virtual path of the directory</param>
/// <returns>True / false</returns>

public override bool DirectoryExists(string virtualDir)
{

// If the directory exists return true,
// else pass to the next provider in the list.
if (DirectoryAvailable(virtualDir))
return true;

return Previous.DirectoryExists(virtualDir);

}

/// <summary>
/// GetFile gets the specified file as SiteResourceFile object.
/// </summary>
/// <param name="virtualPath">The virtual path of the file</param>
/// <returns>SiteResourceFile</returns>

public override VirtualFile GetFile(string virtualPath)
{

// If the file exists return it,
// else pass to the next provider in the list.
if (FileAvailable(virtualPath))
return new SiteResourceFile(virtualPath, this);

return Previous.GetFile(virtualPath);

}

/// <summary>
/// GetDirectory gets the specified directory as VirtualDirectory object.
/// </summary>
/// <param name="virtualDir">The virtual path of the directory</param>
/// <returns>SiteResourceDirectory</returns>

public override VirtualDirectory GetDirectory(string virtualDir)
{

// If the directory exists return it,
// else pass to the next provider in the list.
if (DirectoryAvailable(virtualDir))
return new SiteResourceDirectory(virtualDir, this);

return Previous.GetDirectory(virtualDir);

}

/// <summary>
/// GetFileHash provides a hash (including version) of the path that should be used now.
/// If this hash is different to that in memory, the cache will be invalidated.
/// </summary>
/// <remarks>
/// The reason we do not use a cache dependency is:
/// a) There is no clear event that can be used to fire the notification of invalidation.
/// b) The site specific directory needs to be checked despite a core cache that may still be valid.
/// </remarks>
/// <param name="virtualPath">The virtual path of the file or directory</param>
/// <param name="virtualPathDependencies">The virtual path dependencies already known</param>
/// <returns>File or directory hash</returns>

public override string GetFileHash(string virtualPath, IEnumerable virtualPathDependencies)
{
if (IsVirtual(virtualPath))
{
string timestamp;
string realpath;

// Try to find a file by that name
if (FileExists(virtualPath))
{
// If we find a file, return the hash of the latest version.
realpath = ConvertVirtualToReal(virtualPath);
timestamp = File.GetLastWriteTimeUtc(realpath).ToString();
return realpath + timestamp;
}

// Finding file has failed. Try to find a directory by that name
if (DirectoryExists(virtualPath))
{
// If we find a directory, return the hash of the latest version.
realpath = ConvertVirtualToReal(virtualPath);
timestamp = Directory.GetLastWriteTimeUtc(realpath).ToString();
return realpath + timestamp;
}

// No file or directory exists, so return null.
return null;
}
return Previous.GetFileHash(virtualPath, virtualPathDependencies);
}

/// <summary>
/// GetCacheDependency returns null for paths within our virtual path,
/// since we're using the GetFileHash function for invalidating our cache.
/// </summary>
/// <param name="virtualPath">The virtual path</param>
/// <param name="virtualPathDependencies">The virtual path dependencies already known</param>
/// <param name="utcStart">The date and time</param>
/// <returns>null</returns>

public override CacheDependency GetCacheDependency(string virtualPath, IEnumerable virtualPathDependencies, DateTime utcStart)
{
if (IsVirtual(virtualPath))
return null;

return Previous.GetCacheDependency(virtualPath, virtualPathDependencies, utcStart);
}

}

/// <summary>
/// SiteResourceDirectory - Class representing a virtual directory
/// </summary>

public class SiteResourceDirectory : VirtualDirectory
{

string _virtualPath;
SiteSpecificPathProvider _provider;

/// <summary>
/// Constructor
/// </summary>
/// <param name="virtualPath">The virtual path requested</param>
/// <param name="provider">The SiteSpecificPathProvider</param>

public SiteResourceDirectory(string virtualPath, SiteSpecificPathProvider provider)
: base(virtualPath)
{
_virtualPath = virtualPath;
_provider = provider;
}

/// <summary>
/// Children property - Returns the complete list
/// of files and sub directories within this directory
/// </summary>

public override IEnumerable Children
{
get
{
List<object> children = new List<object>();
foreach (object dir in Directories)
{
children.Add(dir);
}
foreach (object file in Files)
{
children.Add(file);
}
return children;
}
}

/// <summary>
/// Directories property - Returns a list of sub
/// directories within this directory
/// </summary>

public override IEnumerable Directories
{
get
{
// Create a new list of directories
IList<SiteResourceDirectory> directories = new List<SiteResourceDirectory>();

// Get REAL path from virtual path
string realDir = _provider.ConvertVirtualToReal(_virtualPath);

// If current directory exists, get sub directories
if (realDir != null)
{

// Find directories in this directory
string[] subDirectoryEntries = Directory.GetDirectories(realDir);
foreach (string subDirectory in subDirectoryEntries)
{
string s = subDirectory.Replace(HttpRuntime.AppDomainAppPath, "~/").Replace(Path.DirectorySeparatorChar, '/');
directories.Add(new SiteResourceDirectory(VirtualPathUtility.AppendTrailingSlash(VirtualPathUtility.Combine(_virtualPath, s)), _provider));
}

}

return directories;
}
}

/// <summary>
/// Files property - Returns a list of files
/// within this directory
/// </summary>

public override IEnumerable Files
{
get
{
// Create a new list of files
IList<SiteResourceFile> files = new List<SiteResourceFile>();

// Get REAL path from virtual path
string realDir = _provider.ConvertVirtualToReal(_virtualPath);

// If current directory exists, get files
if (Directory.Exists(realDir))
{

// Find files in this directory
string[] fileEntries = Directory.GetFiles(realDir);
foreach (string file in fileEntries)
{
string f = file.Replace(HttpRuntime.AppDomainAppPath, "~/").Replace(Path.DirectorySeparatorChar, '/');
files.Add(new SiteResourceFile(VirtualPathUtility.Combine(VirtualPathUtility.AppendTrailingSlash(_virtualPath), f), _provider));
}

}

return files;
}
}

}

/// <summary>
/// SiteResourceFile - Class representing a virtual file
/// </summary>

public class SiteResourceFile : VirtualFile
{

string _virtualPath;
SiteSpecificPathProvider _provider;

/// <summary>
/// Constructor
/// </summary>
/// <param name="virtualPath">The virtual path of the file</param>
/// <param name="provider">The SiteSpecificPathProvider</param>

public SiteResourceFile(string virtualPath, SiteSpecificPathProvider provider)
: base(virtualPath)
{
_virtualPath = virtualPath;
_provider = provider;
}

/// <summary>
/// Exposes a stream to the file in our virtual file system
/// </summary>
/// <returns>Stream</returns>

public override Stream Open()
{

string realPath = _provider.ConvertVirtualToReal(_virtualPath);

// If the file exists put the content on the stream.
if (File.Exists(realPath))
{
return new FileStream(realPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
}
return null;

}

}

}
EPiServer kindly gives us the ability to register this virtual path provider in the web.config.




<add name="SiteSpecificFiles" virtualpath="~/Resources/" type="EPiDave.Web.Hosting.SiteSpecificPathProvider, EPiDave" />


Of course, there are other solutions to this problem, but I find this one is very easy to work with. With images and other binary files, it means you don't need any special http handlers. With ASPXs and ASCXs etc, it means that you can have clean markup. You don't have to implement some site specific logic behind each part of your page or control. All you need to do, is create a different resource, and put it in the correct directory on the server.

No comments:

Post a Comment