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:
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:
- in order to convert in-memory representation of navigation items into xml in order to send it via web service;
- 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).