Skip to content

Multiple Sitemaps in One Application

Shad Storhaug edited this page Apr 24, 2018 · 5 revisions

Note: The technique described on this page can only be done using an external DI container.

Tip: There is a downloadable demo of this implementation that you can analyze to see how you might implement this solution in your own application.

There are many reasons why you might want to have more than one sitemap in an application. Some of these reasons include:

  • Multi-tenant websites
  • Different security requirements per user or group
  • Different navigation per localized culture

In general, there are times when you need to ensure that an HTTP request gets mapped to a specific sitemap. With the power of DI, we have built a framework that allows you to do just that without making any assumptions about your specific requirements.

As a tangible example, let's say you want to make a 2-tenant web application that has 2 sitemaps and we want all of the traffic from www.somewhere.com to go to one sitemap and all of the traffic from www.nowhere.com to go to the other sitemap.

ISiteMapCacheKeyGenerator

ISiteMapCacheKeyGenerator creates a key that is used throughout MvcSiteMapProvider to refer to a specific SiteMap instance. You can think of the SiteMapCacheKey as the name of a SiteMap.

First of all, take a look at the default implementation of ISiteMapCacheKeyGenerator.

using System;
using System.Text;
using MvcSiteMapProvider.Web.Mvc;

namespace MvcSiteMapProvider.Caching
{
    /// <summary>
    /// The default cache key generator. This class generates a unique cache key for each 
    /// DnsSafeHost.
    /// </summary>
    public class SiteMapCacheKeyGenerator
        : ISiteMapCacheKeyGenerator
    {
        public SiteMapCacheKeyGenerator(
            IMvcContextFactory mvcContextFactory
            )
        {
            if (mvcContextFactory == null)
                throw new ArgumentNullException("mvcContextFactory");
            this.mvcContextFactory = mvcContextFactory;
        }

        protected readonly IMvcContextFactory mvcContextFactory;

        #region ISiteMapCacheKeyGenerator Members

        public virtual string GenerateKey()
        {
            var context = mvcContextFactory.CreateHttpContext();
            var builder = new StringBuilder();
            builder.Append("sitemap://");
            builder.Append(context.Request.Url.DnsSafeHost);
            builder.Append("/");
            return builder.ToString();
        }

        #endregion
    }
}

This interface controls the number of sitemaps that are cached. Each unique string value that is returned from GenerateKey will cause a new sitemap to be created and cached.

As you can see, the default implementation does exactly what we want - we need 1 sitemap per DnsSafeHost, or domain name. For example, if a request were made to http://www.somewhere.com/some-page, the sitemap cache key would be sitemap://www.somewhere.com/. If a request were made to http://www.nowhere.com/some-other-page, the key would be sitemap://www.nowhere.com/. Furthermore, any request to either of these domains will have a key that is the same as all of the other requests on the domain.

But it is important to understand that you don't necessarily have to map the request this way - you can create a custom implementation of ISiteMapCacheKeyGenerator that contains the necessary logic to meet your requirements, and then inject it via DI. You could add a user group or a specific locale to the string to create sitemaps for other reasons.

ISiteMapBuilderSet

Next, to meet our requirements, we need to create 2 sitemaps using different logic. Let's say for the sake of this example we want to make each sitemap based on a separate XML file, however we could just as well implement ISiteMapNodeProvider and pull the sitemap nodes from a database if we wanted to.

To set this up, we just need to do some DI configuration. Most DI containers have some way to inject a specific instance of a class using a key, name, variable reference, or submodule-based setup. I am using StructureMap in this example, because it has nice neat variables that can be passed around which also enables compile-time checking, but you could just as well use named instances in most any DI container.

First of all, we need to create 2 separate XML files. We won't be getting into that here, but if you need to see how that is done, see Defining Sitemap Nodes in XML.

Caching

Since we will have 2 files, we will set up 2 cache dependencies - one for each file. This ensures if the file is edited, the sitemap corresponding to the file will be rebuilt immediately.

var somewhereCacheDependency = this.For<ICacheDependency>().Use<RuntimeFileCacheDependency>()
   .Ctor<string>("fileName").Is(HostingEnvironment.MapPath("~/somewhere.sitemap"));
   
var nowhereCacheDependency = this.For<ICacheDependency>().Use<RuntimeFileCacheDependency>()
   .Ctor<string>("fileName").Is(HostingEnvironment.MapPath("~/nowhere.sitemap"));

Note that you need to pass in an absolute file path to RuntimeFileCacheDependency, so we are calling the ASP.NET function HostingEnvironment.MapPath().

This example assumes you are using .NET 4.0 or higher. If you are using .NET 3.5, you must replace RuntimeFileCacheDependency with AspNetFileCacheDependency. If you are not using XML to configure your nodes, you can also specify to use no cache dependency by configuring NullCacheDependency.

Now we need to create a ICacheDetails object to tell MvcSiteMapProvider how to cache each SiteMap.

var somewhereCacheDetails = this.For<ICacheDetails>().Use<CacheDetails>()
    .Ctor<TimeSpan>("absoluteCacheExpiration").Is(absoluteCacheExpiration)
    .Ctor<TimeSpan>("slidingCacheExpiration").Is(TimeSpan.MinValue)
    .Ctor<ICacheDependency>().Is(somewhereCacheDependency);
   
var nowhereCacheDetails = this.For<ICacheDetails>().Use<CacheDetails>()
    .Ctor<TimeSpan>("absoluteCacheExpiration").Is(absoluteCacheExpiration)
    .Ctor<TimeSpan>("slidingCacheExpiration").Is(TimeSpan.MinValue)
    .Ctor<ICacheDependency>().Is(nowhereCacheDependency);

This is where we can set up our cache timeouts. We are using the default absoluteCacheExpiration that is set at the top of the file at 5 minutes, but this can be changed if needed.

TimeSpan absoluteCacheExpiration = TimeSpan.FromMinutes(5);

ISiteMapBuilder and ISiteMapNodeProvider

That takes care of caching, so now let's turn back to setting up our separate ISiteMapBuilder instances. We will need to wire up instances of FileXmlSource in our Composition Root for each SiteMap.

var somewhereXmlSource = this.For<IXmlSource>().Use<FileXmlSource>()
   .Ctor<string>("fileName").Is(HostingEnvironment.MapPath("~/somewhere.sitemap"));
   
var nowhereXmlSource = this.For<IXmlSource>().Use<FileXmlSource>()
   .Ctor<string>("fileName").Is(HostingEnvironment.MapPath("~/nowhere.sitemap"));

Note that you need to pass in an absolute file path to FileXmlSource, so we are calling the ASP.NET function HostingEnvironment.MapPath().

Then we need to create our SiteMapNodeProvider instances and pass in our FileXmlSource references we created.

var somewhereNodeProvider = this.For<ISiteMapNodeProvider>().Use<XmlSiteMapNodeProvider>()
    .Ctor<bool>("includeRootNode").Is(true)
    .Ctor<bool>("useNestedDynamicNodeRecursion").Is(false)
    .Ctor<IXmlSource>().Is(somewhereXmlSource);
	
var nowhereNodeProvider = this.For<ISiteMapNodeProvider>().Use<XmlSiteMapNodeProvider>()
    .Ctor<bool>("includeRootNode").Is(true)
    .Ctor<bool>("useNestedDynamicNodeRecursion").Is(false)
    .Ctor<IXmlSource>().Is(nowhereXmlSource);

It is possible for us to split our nodes into separate .sitemap files for organizational purposes only. But a sitemap can have only 1 root node and each .sitemap file must contain a root node. That is the reason why there is a "includeRootNode" argument - you can specify to exclude the root node from your XML files, so more than one file can be used to make a single sitemap.

"useNestedDynamicNodeRecursion" is to re-enable a bug that existed prior to v4.3.0 and you should only set it to true if your legacy application depends on the recursive behavior that existed prior to that version.

Next, we need to create our SiteMapBuilder instances and pass in our ISiteMapNodeProvider references we just created.

var somewhereBuilder = this.For<ISiteMapBuilder>().Use<SiteMapBuilder>()
    .Ctor<ISiteMapNodeProvider>().Is(somewhereNodeProvider);
	
var nowhereBuilder = this.For<ISiteMapBuilder>().Use<SiteMapBuilder>()
    .Ctor<ISiteMapNodeProvider>().Is(nowhereNodeProvider);

Lastly, we need to set up our SiteMapBuilderSet instances and inject them into the SiteMapBuilderSetStrategy constructor.

this.For<ISiteMapBuilderSetStrategy>().Use<SiteMapBuilderSetStrategy>()
    .EnumerableOf<ISiteMapBuilderSet>().Contains(x =>
    {
        x.Type<SiteMapBuilderSet>()
            .Ctor<string>("instanceName").Is("somewhereSet")
            .Ctor<bool>("securityTrimmingEnabled").Is(false)
            .Ctor<bool>("enableLocalization").Is(false)
			.Ctor<bool>("visibilityAffectsDescendants").Is(false)
			.Ctor<bool>("useTitleIfDescriptionNotProvided").Is(false)
            .Ctor<ISiteMapBuilder>().Is(somewhereBuilder)
            .Ctor<ICacheDetails>().Is(somewhereCacheDetails);
        x.Type<SiteMapBuilderSet>()
            .Ctor<string>("instanceName").Is("nowhereSet")
            .Ctor<bool>("securityTrimmingEnabled").Is(false)
            .Ctor<bool>("enableLocalization").Is(false)
			.Ctor<bool>("visibilityAffectsDescendants").Is(false)
			.Ctor<bool>("useTitleIfDescriptionNotProvided").Is(false)
            .Ctor<ISiteMapBuilder>().Is(nowhereBuilder)
            .Ctor<ICacheDetails>().Is(nowhereCacheDetails);
    });

It is important to note that we have given each SiteMapBuilderSet an instance name that will be used later in the setup. SecurityTrimmingEnabled, EnableLocalization, VisibilityAffectsDescendants, and UseTitleIfDescriptionNotProvided are settings that will be passed to the SiteMap during its construction. We pass in our builder and cache details references as well, to ultimately get 2 individual sets of building instructions - 1 set corresponding to each XML file.

ISiteMapCacheKeyToBuilderSetMapper

Now that we have our sitemap cache keys and builders in place, we just need to specify which key belongs to which builder set. ISiteMapCacheKeyToBuilderSetMapper exists because you may wish to create a relationship between cache keys and builders that is not one to one. In our case, one to one is exactly what we want, but the default implementation maps every request to a builder named "default", so we will need to build our own to meet our requirement.

public class CustomSiteMapCacheKeyToBuilderSetMapper
    : ISiteMapCacheKeyToBuilderSetMapper
{
    public virtual string GetBuilderSetName(string cacheKey)
    {
        switch (cacheKey)
        {
            case "sitemap://www.somewhere.com/":
                return "somewhereSet";
            case "sitemap://www.nowhere.com/":
                return "nowhereSet";
            default:
                return "somewhereSet";
        }
    }
}

You could just as well pull this mapping from a configuration file or database, but for our simple example we are hard coding the strings.

Note: This class is for mapping logic only. You must ensure that a given cacheKey always results in the same result so that the same SiteMapBuilder instance is used each time the SiteMap is built.

Now that we have that class implemented, we just need to go back to our Composition Root and add our injection code.

this.For<ISiteMapCacheKeyToBuilderSetMapper>().Use<CustomSiteMapCacheKeyToBuilderSetMapper>();

While not strictly required, you should also remove the default SiteMapCacheKeyToBuilderSetMapper from auto-registration. This will ensure you only have 1 type registered with StructureMap instead of 2.

var excludeTypes = new Type[] { 
	typeof(SiteMapCacheKeyToBuilderSetMapper)
};

XML Validation

If using one of the MvcSiteMapProvider.MVC[x].DI.[ContainerName] NuGet packages, the XML file is validated during application startup in the /App_Start/MvcSiteMapProviderConfig.cs file. This validation step can be eliminated, if desired, but for our example we will be changing it to validate our 2 new .sitemap files.

// Check all configured .sitemap files to ensure they follow the XSD for MvcSiteMapProvider
var validator = container.GetInstance<ISiteMapXmlValidator>();
validator.ValidateXml(HostingEnvironment.MapPath("~/somewhere.sitemap"));
validator.ValidateXml(HostingEnvironment.MapPath("~/nowhere.sitemap"));

That's it! Now each web domain will have its own sitemap that is based on a separate XML file. In turn, each site can have its own navigation structure that is independent of the other, despite the fact they are in the same application.

Changed MvcSiteMapProviderRegistry.cs File from Example

public class MvcSiteMapProviderRegistry
	: Registry
{
	public MvcSiteMapProviderRegistry()
	{
		bool enableLocalization = true;
		string absoluteFileName = HostingEnvironment.MapPath("~/Mvc.sitemap");
		TimeSpan absoluteCacheExpiration = TimeSpan.FromMinutes(5);
		bool visibilityAffectsDescendants = true;
		bool useTitleIfDescriptionNotProvided = true;
		bool securityTrimmingEnabled = false;
		string[] includeAssembliesForScan = new string[] { "MvcSiteMapProvider_2_SiteMaps" };

		var currentAssembly = this.GetType().Assembly;
		var siteMapProviderAssembly = typeof(SiteMaps).Assembly;
		var allAssemblies = new Assembly[] { currentAssembly, siteMapProviderAssembly };
		var excludeTypes = new Type[] { 
			// Use this array to add types you wish to explicitly exclude from convention-based  
			// auto-registration. By default all types that either match I[TypeName] = [TypeName] or 
			// I[TypeName] = [TypeName]Adapter will be automatically wired up as long as they don't 
			// have the [ExcludeFromAutoRegistrationAttribute].
			//
			// If you want to override a type that follows the convention, you should add the name 
			// of either the implementation name or the interface that it inherits to this list and 
			// add your manual registration code below. This will prevent duplicate registrations 
			// of the types from occurring. 

			// Example:
			// typeof(SiteMap),
			// typeof(SiteMapNodeVisibilityProviderStrategy)

			/********************************* New Code *********************************/
			typeof(SiteMapCacheKeyToBuilderSetMapper)
			/****************************** End New Code *********************************/
		};
		var multipleImplementationTypes = new Type[]  { 
			typeof(ISiteMapNodeUrlResolver), 
			typeof(ISiteMapNodeVisibilityProvider), 
			typeof(IDynamicNodeProvider) 
		};

		// Matching type name (I[TypeName] = [TypeName]) or matching type name + suffix Adapter (I[TypeName] = [TypeName]Adapter)
		// and not decorated with the [ExcludeFromAutoRegistrationAttribute].
		CommonConventions.RegisterDefaultConventions(
			(interfaceType, implementationType) => this.For(interfaceType).Singleton().Use(implementationType),
			new Assembly[] { siteMapProviderAssembly },
			allAssemblies,
			excludeTypes,
			string.Empty);

		// Multiple implementations of strategy based extension points (and not decorated with [ExcludeFromAutoRegistrationAttribute]).
		CommonConventions.RegisterAllImplementationsOfInterface(
			(interfaceType, implementationType) => this.For(interfaceType).Singleton().Use(implementationType),
			multipleImplementationTypes,
			allAssemblies,
			excludeTypes,
			string.Empty);

		// Visibility Providers
		this.For<ISiteMapNodeVisibilityProviderStrategy>().Use<SiteMapNodeVisibilityProviderStrategy>()
			.Ctor<string>("defaultProviderName").Is(string.Empty);

		// Pass in the global controllerBuilder reference
		this.For<ControllerBuilder>()
			.Use(x => ControllerBuilder.Current);

		this.For<IControllerTypeResolverFactory>().Use<ControllerTypeResolverFactory>()
			.Ctor<string[]>("areaNamespacesToIgnore").Is(new string[0]);

		// Configure Security
		this.For<IAclModule>().Use<CompositeAclModule>()
			.EnumerableOf<IAclModule>().Contains(x =>
			{
				x.Type<AuthorizeAttributeAclModule>();
				x.Type<XmlRolesAclModule>();
			});

		// Setup cache
		//SmartInstance<CacheDetails> cacheDetails;

		this.For<System.Runtime.Caching.ObjectCache>()
			.Use(s => System.Runtime.Caching.MemoryCache.Default);

		this.For(typeof(ICacheProvider<>)).Use(typeof(RuntimeCacheProvider<>));

		//var cacheDependency =
		//    this.For<ICacheDependency>().Use<RuntimeFileCacheDependency>()
		//        .Ctor<string>("fileName").Is(absoluteFileName);

		//cacheDetails =
		//    this.For<ICacheDetails>().Use<CacheDetails>()
		//        .Ctor<TimeSpan>("absoluteCacheExpiration").Is(absoluteCacheExpiration)
		//        .Ctor<TimeSpan>("slidingCacheExpiration").Is(TimeSpan.MinValue)
		//        .Ctor<ICacheDependency>().Is(cacheDependency);
		
		/********************************* New Code *********************************/
		
		var somewhereCacheDependency = this.For<ICacheDependency>().Use<RuntimeFileCacheDependency>()
			.Ctor<string>("fileName").Is(HostingEnvironment.MapPath("~/somewhere.sitemap"));

		var nowhereCacheDependency = this.For<ICacheDependency>().Use<RuntimeFileCacheDependency>()
			.Ctor<string>("fileName").Is(HostingEnvironment.MapPath("~/nowhere.sitemap"));

		var somewhereCacheDetails = this.For<ICacheDetails>().Use<CacheDetails>()
			.Ctor<TimeSpan>("absoluteCacheExpiration").Is(absoluteCacheExpiration)
			.Ctor<TimeSpan>("slidingCacheExpiration").Is(TimeSpan.MinValue)
			.Ctor<ICacheDependency>().Is(somewhereCacheDependency);

		var nowhereCacheDetails = this.For<ICacheDetails>().Use<CacheDetails>()
			.Ctor<TimeSpan>("absoluteCacheExpiration").Is(absoluteCacheExpiration)
			.Ctor<TimeSpan>("slidingCacheExpiration").Is(TimeSpan.MinValue)
			.Ctor<ICacheDependency>().Is(nowhereCacheDependency);
		
		/****************************** End New Code *********************************/
		

		// Configure the visitors
		this.For<ISiteMapNodeVisitor>()
			.Use<UrlResolvingSiteMapNodeVisitor>();


		// Prepare for our node providers
		var xmlSource = this.For<IXmlSource>().Use<FileXmlSource>()
					   .Ctor<string>("fileName").Is(absoluteFileName);

		this.For<IReservedAttributeNameProvider>().Use<ReservedAttributeNameProvider>()
			.Ctor<IEnumerable<string>>("attributesToIgnore").Is(new string[0]);

		// Register the sitemap node providers
		//var siteMapNodeProvider = this.For<ISiteMapNodeProvider>().Use<CompositeSiteMapNodeProvider>()
		//    .EnumerableOf<ISiteMapNodeProvider>().Contains(x =>
		//    {
		//        x.Type<XmlSiteMapNodeProvider>()
		//            .Ctor<bool>("includeRootNode").Is(true)
		//            .Ctor<bool>("useNestedDynamicNodeRecursion").Is(false)
		//            .Ctor<IXmlSource>().Is(xmlSource);
		//        x.Type<ReflectionSiteMapNodeProvider>()
		//            .Ctor<IEnumerable<string>>("includeAssemblies").Is(includeAssembliesForScan)
		//            .Ctor<IEnumerable<string>>("excludeAssemblies").Is(new string[0]);
		//    });

		/********************************* New Code *********************************/
		
		var somewhereNodeProvider = this.For<ISiteMapNodeProvider>().Use<XmlSiteMapNodeProvider>()
			.Ctor<bool>("includeRootNode").Is(true)
			.Ctor<bool>("useNestedDynamicNodeRecursion").Is(false)
			.Ctor<IXmlSource>().Is(somewhereXmlSource);

		var nowhereNodeProvider = this.For<ISiteMapNodeProvider>().Use<XmlSiteMapNodeProvider>()
			.Ctor<bool>("includeRootNode").Is(true)
			.Ctor<bool>("useNestedDynamicNodeRecursion").Is(false)
			.Ctor<IXmlSource>().Is(nowhereXmlSource);
			
		/****************************** End New Code *********************************/

		// Register the sitemap builders
		//var builder = this.For<ISiteMapBuilder>().Use<SiteMapBuilder>()
			.Ctor<ISiteMapNodeProvider>().Is(siteMapNodeProvider);
		
		/********************************* New Code *********************************/
		
		var somewhereBuilder = this.For<ISiteMapBuilder>().Use<SiteMapBuilder>()
			.Ctor<ISiteMapNodeProvider>().Is(somewhereNodeProvider);

		var nowhereBuilder = this.For<ISiteMapBuilder>().Use<SiteMapBuilder>()
			.Ctor<ISiteMapNodeProvider>().Is(nowhereNodeProvider);
			
		/****************************** End New Code *********************************/


		// Configure the builder sets
		//this.For<ISiteMapBuilderSetStrategy>().Use<SiteMapBuilderSetStrategy>()
		//    .EnumerableOf<ISiteMapBuilderSet>().Contains(x =>
		//    {
		//        x.Type<SiteMapBuilderSet>()
		//            .Ctor<string>("instanceName").Is("default")
		//            .Ctor<bool>("securityTrimmingEnabled").Is(securityTrimmingEnabled)
		//            .Ctor<bool>("enableLocalization").Is(enableLocalization)
		//			  .Ctor<bool>("visibilityAffectsDescendants").Is(visibilityAffectsDescendants)
		//			  .Ctor<bool>("useTitleIfDescriptionNotProvided").Is(useTitleIfDescriptionNotProvided)
		//            .Ctor<ISiteMapBuilder>().Is(builder)
		//            .Ctor<ICacheDetails>().Is(cacheDetails);
		//    });

		/********************************* New Code *********************************/
		
		this.For<ISiteMapBuilderSetStrategy>().Use<SiteMapBuilderSetStrategy>()
			.EnumerableOf<ISiteMapBuilderSet>().Contains(x =>
			{
				x.Type<SiteMapBuilderSet>()
					.Ctor<string>("instanceName").Is("somewhereSet")
					.Ctor<bool>("securityTrimmingEnabled").Is(false)
					.Ctor<bool>("enableLocalization").Is(false)
					.Ctor<bool>("visibilityAffectsDescendants").Is(false)
					.Ctor<bool>("useTitleIfDescriptionNotProvided").Is(false)
					.Ctor<ISiteMapBuilder>().Is(somewhereBuilder)
					.Ctor<ICacheDetails>().Is(somewhereCacheDetails);
				x.Type<SiteMapBuilderSet>()
					.Ctor<string>("instanceName").Is("nowhereSet")
					.Ctor<bool>("securityTrimmingEnabled").Is(false)
					.Ctor<bool>("enableLocalization").Is(false)
					.Ctor<bool>("visibilityAffectsDescendants").Is(false)
					.Ctor<bool>("useTitleIfDescriptionNotProvided").Is(false)
					.Ctor<ISiteMapBuilder>().Is(nowhereBuilder)
					.Ctor<ICacheDetails>().Is(nowhereCacheDetails);
			});

		// Configure our custom builder set mapper
		this.For<ISiteMapCacheKeyToBuilderSetMapper>().Use<CustomSiteMapCacheKeyToBuilderSetMapper>();
		
		/****************************** End New Code *********************************/
	}
}

Referencing a Specific SiteMap

By default, you will get the SiteMap associated with the current HTTP request (which is determined by ISiteMapCacheKeyGenerator). You can reference a specific SiteMap using any HTML helper or the static SiteMaps class by passing the SiteMapCacheKey of the SiteMap you would like to use.

// Referencing the SiteMap associated with the current request in a view
@Html.MvcSiteMap().Menu()

// Referencing the www.nowhere.com sitemap explicitly in a view
@Html.MvcSiteMap("sitemap://www.nowhere.com/").Menu()
// Referencing the sitemap associated with the current request using the static method.
var currentSiteMap = MvcSiteMapProvider.SiteMaps.Current;

// Referencing the www.nowhere.com sitemap explicitly using the static method.
var nowhereSiteMap = MvcSiteMapProvider.SiteMaps.GetSiteMap("sitemap://www.nowhere.com/");

Note: If you pass in a SiteMapCacheKey that doesn't already exist, you will not get an exception. A new SiteMap will be created and cached based on the default SiteMapBuilderSet returned from ISiteMapCacheKeyToBuilderSetMapper. Therefore, you need to be vigilant that the value passed in is correct.


Want to contribute? See our Contributing to MvcSiteMapProvider guide.



Version 3.x Documentation


Unofficial Documentation and Resources

Other places around the web have some documentation that is helpful for getting started and finding answers that are not found here.

Tutorials and Demos

Version 4.x
Version 3.x

Forums and Q & A Sites

Other Blog Posts

Clone this wiki locally