Sunday, December 19, 2010

Cross-site and cross-site collection navigation in Sharepoint - part 2: publishing sites

In my previous post I described how you can implement consistent cross-site navigation for non-publishing sites (i.e. those sites which are created using WSS site templates). As statistics shows mentioned post is very popular. So I decided to postpone other themes and finish this series as they are so interesting for people. Before to continue I recommend you also see my post The basics of navigation in Sharepoint where I describe basic components of the navigation architecture in Sharepoint.

I will repeat the task here for convenience: suppose that we have site collection (SPSite) with several sub sites (SPWeb). We want to keep the same global navigation (top navigation) for all sites (probably for all site collections within our web application – as you will see below it is possible as well. More over with technique I’m going to describe here it is also possible to use navigation from completely separate Sharepoint site which can be located on another web server). E.g. we have one SPWeb web site with configured navigation items and we want to use navigation from this particular web sites on all another sites.

As I wrote in previous part there is a problem with implementing cross site navigation for publishing sites. If you remember from previous part, SPNavigationProvider has public virtual property Web:

   1: public class SPNavigationProvider : SiteMapProvider
   2: {
   3:     // ...
   4:     protected virtual SPWeb Web { get; }
   5: }

Also SPNavigationProvider is not sealed so we can inherit it and override its Web property (always return our navigation source web site) – see part1. But with publishing sites this approach can not be used because PortalSiteMapProvider, which is used in publishing sites, doesn’t have any virtual property which returns SPWeb web site which should be a source for navigation data. If we will investigate its code in Reflector we will see that actually it has CurrentSite and CurrentWeb properties which are very similar to those we are looking for:

   1: public class PortalSiteMapProvider : SiteMapProvider
   2: {
   3:     // ...
   4:     public SPSite CurrentSite { get; set; }
   5:     public SPWeb CurrentWeb { get; set; }
   6:     // ...
   7: }

Although PortalSiteMapProvider is also not sealed (i.e. we can inherit from it), mentioned properties are not virtual. So unfortunately we can’t just override them in the custom inheritor of PortalSiteMapProvider class because OTB Sharepoint functionality which has only reference on PortalSiteMapProvider will use its implementation instead of ours. Is there a way to solve this problem without inheriting? Yes – solution exists, but it is not easy.

We will need to implement our own custom navigation provider by ourselves. As a base class we can use standard ASP.Net SiteMapProvider. But in our case better option will be StaticSiteMapProvider class, which has already implementation of several methods so less work will be required with it (e.g. StaticSiteMapProvider is used as a base class for standard XmlSiteMapProvider). Documentation of this class says:

The StaticSiteMapProvider class is a partial implementation of the abstract SiteMapProvider class and supplies two additional methods: AddNode and RemoveNode, as well as the abstract BuildSiteMap and protected Clear methods.

The StaticSiteMapProvider class supports writing a site map provider (for example, an XmlSiteMapProvider) that translates a site map that is stored in persistent storage to one that is stored in memory. The StaticSiteMapProvider class provides basic implementations for storing and retrieving SiteMapNode objects.

If you are extending the StaticSiteMapProvider class, the three most important methods are the GetRootNodeCore, Initialize, and BuildSiteMap methods. The Clear and FindSiteMapNode methods have default implementations that are sufficient for most custom site map provider implementations.

It is very similar to our case because we can treat SPWeb site as a “persistent storage” for our site map. The remaining question is how to retrieve navigation data (i.e. collection of PortalSiteMapNode) from existing publishing site? Obvious answer is to use PortalSiteMapProvider – but we need to call methods of this provider in context of navigation source site. We can make it using web services, i.e. we will use the following schema:

image

We need to register and use our custom site map provider on all sites where we want to show navigation from navigation source site. Our custom provider then will call custom web service Navigation.asmx (which is located in 12/Templates/Layouts/Custom folder on the file system) in context of navigation source site. E.g. if we have 2 sites http://example.com/site1 and http://example.com/site2 where site1 is navigation source, we need to call Navigation.asmx web service from site2 using the following URL: http://example.com/site1/_layout/Custom/Navigation.asmx. As result codebehind of Navigation.asmx will be executed in context of site1, so we will be able to use OTB PortalSiteMapProvider in order to retrieve site map nodes from site1. Simple, isn’t it?

Now when I’ve described the basic idea lets looks a bit on actual implementation. First of all we need to implement custom site map provider – inheritor of StaticSiteMapProvider, which will call external web service Navigation.asmx. The basic implementation is shown below:

   1: public class CustomNavigationProvider : StaticSiteMapProvider
   2: {
   3:     private const string SITE_MAP_SESSION_KEY = "CustomNavigationMap";
   4:  
   5:     private SPWeb getNavigationContextWebUrl()
   6:     {
   7:         // instead of hardcoding site url you can use your own logic here
   8:         using (var web = SPContext.Current.Site.OpenWeb("/site1"))
   9:         {
  10:             return web.Url;
  11:         }
  12:     }
  13:  
  14:     private NavigationService initWebService(string contextWebUrl)
  15:     {
  16:         var proxy = new NavigationService();
  17:         proxy.Url = SPUrlUtility.CombineUrl(contextWebUrl, "/_layouts/Custom/Navigation.asmx");
  18:         // use another credentials if required instead of DefaultCredentials
  19:         proxy.Credentials = CredentialCache.DefaultCredentials;
  20:         return proxy;
  21:     }
  22:  
  23:     public override void Initialize(string name, NameValueCollection attributes)
  24:     {
  25:         base.Initialize(name, attributes);
  26:         // here you can add your initialization logic, e.g. initialize web service URL
  27:     }
  28:     
  29:     public override SiteMapNode BuildSiteMap()
  30:     {
  31:         SiteMapNode node;
  32:         if (HttpContext.Current.Session[SITE_MAP_SESSION_KEY] == null)
  33:         {
  34:             node = tryGetNavigationNodesFromContextWeb();
  35:             HttpContext.Current.Session[SITE_MAP_SESSION_KEY] = node;
  36:         }
  37:         node = HttpContext.Current.Session[SITE_MAP_SESSION_KEY] as SiteMapNode;
  38:         return node;
  39:     }
  40:  
  41:     private SiteMapNode tryGetNavigationNodesFromContextWeb()
  42:     {
  43:         try
  44:         {
  45:             string webUrl = this.getNavigationContextWebUrl();
  46:             var proxy = this.initWebService(webUrl);
  47:             var doc = proxy.GetMenuItems();
  48:             var collection = ConvertHelper.BuildNodesFromXml(this, doc);
  49:             if (collection == null)
  50:                 return null;
  51:             if (collection.Count == 0)
  52:                 return null;
  53:             return collection[0];
  54:         }
  55:         catch(Exception x)
  56:         {
  57:             return new SiteMapNode(this, "/", "/", "");
  58:         }
  59:     }
  60:  
  61:     protected override SiteMapNode GetRootNodeCore()
  62:     {
  63:         SiteMapNode node = null;
  64:         node = BuildSiteMap();
  65:         return node;
  66:     }
  67: }

I removed many real-life stuff from this code in order to keep only valuable places. So we override 3 methods of StaticSiteMapProvider as was said in documentation: GetRootNodeCore(), Initialize() and BuildSiteMap(). Also you will need to add a web reference to your web service in order to be able to use proxy in Visual Studio. As we don’t want to perform web service call on each request to site2 (it will be very slow) I added simple caching logic using Session (as you know session state in Sharepoint is stored in SQL Server so described approach will work on multiple WFE environments).

Now lets see implementation details of the Navigation.asmx web service (its codebehind to be more clear):

   1: [WebService(Namespace = "http://example.com")]
   2: [WebServiceBinding(ConformsTo = WsiProfiles.BasicProfile1_1)]
   3: [ToolboxItem(false)]
   4: public class NavigationService : WebService
   5: {
   6:     [WebMethod]
   7:     public XmlDocument GetMenuItems()
   8:     {
   9:         PortalSiteMapDataSource ds = new PortalSiteMapDataSource();
  10:         ds.SiteMapProvider = "CombinedNavSiteMapProvider";
  11:         ds.EnableViewState = false;
  12:         ds.StartFromCurrentNode = true;
  13:         ds.StartingNodeOffset = 0;
  14:         ds.ShowStartingNode = true;
  15:         ds.TreatStartingNodeAsCurrent = true;
  16:         ds.TrimNonCurrentTypes = NodeTypes.Heading;
  17:         
  18:         AspMenu m = new AspMenu();
  19:         m.DataSource = ds;
  20:         m.EnableViewState = false;
  21:         m.Orientation = Orientation.Horizontal;
  22:         m.StaticDisplayLevels = 2;
  23:         m.MaximumDynamicDisplayLevels = 1;
  24:         m.DynamicHorizontalOffset = 0;
  25:         m.StaticPopOutImageTextFormatString = "";
  26:         m.StaticSubMenuIndent = 0;
  27:         m.DataBind();
  28:         
  29:         var doc = ConvertHelper.BuildXmlFromMenuItem(m.Items);
  30:         return doc;
  31:     }
  32: }

There is one web method GetMenuItems() which is called from custom site map provider via proxy (see above). I used a little trick here: instead of using PortalSiteMapProvider I used PortalSiteMapDataSource and AspMenu classes in order to return exactly the same navigation items which are shown on the navigation source site (site1). There is a difference between site map nodes which exist for particular site and site map nodes which are actually displayed here. As I wrote in The basics of navigation in Sharepoint article navigation items appearance is controlled via site map data source and AspMenu controls (which are located on masterpage in most cases). Of course you can use PortalSiteMapProvider  and return all navigation items from it as well.

The remaining thing which should be described is helper class ConverterHelper which is used for the 2 following purposes:

  1. in order to convert in-memory representation of navigation items into xml in order to send it via web service;
  2. and opposite: build in-memory collection of navigation items from xml in custom site map provider.

Here is implementation of ConverterHelper class:

   1: public static class ConvertHelper
   2: {
   3:     public const string TAG_ROOT = "root";
   4:     public const string TAG_NODE = "node";
   5:     public const string ATTR_PATH = "path";
   6:     public const string ATTR_URL = "url";
   7:     public const string ATTR_TITLE = "title";
   8:  
   9:     public static SiteMapNodeCollection BuildNodesFromXml(SiteMapProvider provider, XmlNode doc)
  10:     {
  11:         try
  12:         {
  13:             var collection = new SiteMapNodeCollection();
  14:             if (doc.ChildNodes.Count == 1 && doc.ChildNodes[0].Name == TAG_ROOT)
  15:             {
  16:                 doc = doc.ChildNodes[0];
  17:             }
  18:  
  19:             buildNodesFromXml(provider, doc, collection);
  20:             return collection;
  21:         }
  22:         catch (Exception x)
  23:         {
  24:             return null;
  25:         }
  26:     }
  27:  
  28:     private static void buildNodesFromXml(SiteMapProvider provider, XmlNode parentNode, SiteMapNodeCollection collection)
  29:     {
  30:         foreach (XmlNode xmlNode in parentNode.ChildNodes)
  31:         {
  32:             if (xmlNode.Name == TAG_NODE)
  33:             {
  34:                 var node = new SiteMapNode(provider, xmlNode.Attributes[ATTR_PATH].Value,
  35:                                            xmlNode.Attributes[ATTR_URL].Value,
  36:                                            xmlNode.Attributes[ATTR_TITLE].Value);
  37:  
  38:                 if (xmlNode.HasChildNodes)
  39:                 {
  40:                     var childNodes = new SiteMapNodeCollection();
  41:                     buildNodesFromXml(provider, xmlNode, childNodes);
  42:                     node.ChildNodes = childNodes;
  43:                 }
  44:  
  45:                 collection.Add(node);
  46:             }
  47:         }
  48:     }
  49:  
  50:     public static XmlDocument BuildXmlFromMenuItem(MenuItemCollection collection)
  51:     {
  52:         if (collection == null || collection.Count == 0)
  53:         {
  54:             return null;
  55:         }
  56:  
  57:         var doc = new XmlDocument();
  58:  
  59:         var element = doc.CreateElement(TAG_ROOT);
  60:         doc.AppendChild(element);
  61:  
  62:         foreach (MenuItem item in collection)
  63:         {
  64:             buildXmlFromMenuItem(item, doc, element);
  65:         }
  66:  
  67:         return doc;
  68:     }
  69:  
  70:     private static void buildXmlFromMenuItem(MenuItem item, XmlDocument doc, XmlNode xml)
  71:     {
  72:         if (item == null)
  73:             return;
  74:  
  75:         XmlElement element = doc.CreateElement(TAG_NODE);
  76:         element.SetAttribute(ATTR_PATH, item.DataPath);
  77:         element.SetAttribute(ATTR_TITLE, item.Text);
  78:         element.SetAttribute(ATTR_URL, item.NavigateUrl);
  79:  
  80:         xml.AppendChild(element);
  81:  
  82:         foreach (MenuItem childItem in item.ChildItems)
  83:         {
  84:             buildXmlFromMenuItem(childItem, doc, element);
  85:         }
  86:     }
  87: }

The last action you need to perform is to configure your masterpage to use your custom site map provider. I will not repeat it here – you can see it in the part1.

These are the components which you need to implement in order to use web service based approach. One of advantages of the this approach – is that you are not limited by single site collection or web application. Actually you are not limited even by single web server. You can call external web server from separate web server and use navigation data from it (although currently I hardly can imagine the useful application of this abaility :) ). But from other side you should be very careful with performance: you should check that you don’t perform web service call each time when your site is requested, because site map provider is called very frequently during requests to the masterpage for example (i.e. to the pages which use masterpage where you use your custom site map provider).

In examples above I removed many non important parts and tried to make it as much self descriptive as possible. So you can use it as direction for your work (instead of treating it as final solution).

12 comments:

  1. "You can call external web server from separate web server and use navigation data from it (although currently I hardly can imagine the useful application of this abaility :) )"

    a real life application of this is a project I worked on. The customer has near 250 portals (for each branch, geographic site, HQ, business type, etc.).
    Each of these portals and a "global" portal share same navigation.
    A first level of worldwide area, and a second level specific to each portal.

    Typically, as the customer has several datacenters, each with their own infrastructure, the portal connect each others when required to grab the navigation (actually the global navigation).

    hth

    ReplyDelete
  2. Steve,
    thanks for sharing real life case )

    ReplyDelete
  3. "But from other side you should be very careful with performance: you should check that you don’t perform web service call each time when your site is requested."

    How exactly would you go about limiting the calls to the web service?

    ReplyDelete
  4. Jonny,
    I would use standard ASP.Net cache for that

    ReplyDelete
  5. Why not to use default sharepoint API. For what reason you added this webservice?

    ReplyDelete
  6. EC,
    as it said in the post, with standard API you can override navigation behavior for non-publishing sites. For publishing sites you can't do it with API. The reasons are described above.

    ReplyDelete
  7. Hello.
    Alex, why we cannot use web.navigation.globalnodes ?

    http://msdn.microsoft.com/en-us/library/microsoft.sharepoint.navigation.spnavigation.globalnodes.aspx


    Why we cannot just create proper web and use web.Navigation.GlobalNodes?
    Do you know any restrictions why not to use this property?

    ReplyDelete
  8. EC,
    I don't know your case, but as I already wrote, note that SPWeb.Navigation which your are referring supposed to be used with non-publishing sites. For publishing sites there is another property PublishingSite.Navigation which returns instance of PortalNavigation (not SPNavigation which you mentioned). Also note that navigation is publishing sites is more complicated. Technically you can try to use PublishingWeb.Web.Navigation (which returns SPNavigation), but you may face with many limitations which will prevent you to use it in your particular case.

    For example there are several navigation providers used in publishing sites for global navigation:
    - GlobalNavSiteMapProvider
    - CombinedNavSiteMapProvider
    - GlobalNavigation
    (they are defined in web.config of the publishing site's web application). What navigation provider will be used in case of SPWeb.Navigation.GlobalNodes?

    Approach with custom navigation provider is more flexible in this sense.

    ReplyDelete
  9. Hey Alex,
    really cool idea and thanks for posting it, but I'm getting an error.
    System.Runtime.Serialization.SerializationException: Type 'System.Web.SiteMapNode' in Assembly 'System.Web, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a' is not marked as serializable.
    Any ideas?
    Thanks,
    Mike

    ReplyDelete
  10. Hey Alex,
    It was the BuildSiteMapCode that was the problem. See below. When I removed the session caching, it works. Guess this will be a significant performance hit. Maybe there is another way to persist, but for some reason SiteMapNode does not like to be serialized... Maybe JSON is the way.
    Thanks again for the idea and post!
    Mike

    public override SiteMapNode BuildSiteMap()
    {
    SiteMapNode node = tryGetNavigationNodesFromContextWeb();
    return node;
    //SiteMapNode node;

    //if (HttpContext.Current.Session[SITE_MAP_SESSION_KEY] == null)
    //{
    // node = tryGetNavigationNodesFromContextWeb();
    // HttpContext.Current.Session[SITE_MAP_SESSION_KEY] = node;
    //}
    //node = HttpContext.Current.Session[SITE_MAP_SESSION_KEY] as SiteMapNode;
    //return node;
    }

    ReplyDelete
  11. Hi Mike,
    thanks for noticing this problem. Probably instead of Session I used Cache or some custom serialization mechanism in the application - don't remember now. But yes, you should definitely add some caching here in order to avoid performance impact. You can check by yourself - add breakpoint to the BuildSiteMap() method and check how many times it is called on each postback. I checked it for Sharepoint 2007, may be for Sharepoint 2010 situation is changed, but I hardly believe in it ).

    ReplyDelete
  12. Cache can't be used here because otherwise it will affect all users, while different users may have different permissions and nodes will be trimmed differently. That's why we need to use some user specific storage here.

    ReplyDelete