Wednesday, April 30, 2008

SiteMapResolve and dynamic site map paths

One of the issues I faced recently was to build a dynamic site map path for given page. At first this seems like a piece of cake, the MSDN contains an article regarding the SiteMapResolve event which is perfectly suited for this task.

Alas, this would be too simple. Do you see why the MSDN example is completely broken and will not work at all?

Well, the answer is here:

private void Page_Load(object sender, EventArgs e)
{
    // The ExpandForumPaths method is called to handle
    // the SiteMapResolve event.
    SiteMap.SiteMapResolve +=
      new SiteMapResolveEventHandler(this.ExpandForumPaths);
}

You see, the SiteMapResolve is a static event which means that every time the page is loaded it will add yet another handler for this event! After few thousands of requests, few thousands instances of the same handler will be attached to the the event! And guess what? Since all handlers return a value and the event expects a single value, the value from the handler which was attached as the first one will always be returned.


A complete disaster!


There's one way to overcome this mess - you have to attach an event and then unattach it. This approach is described in the CodeProject's A Better SiteMapResolve article.


However, I think that this approach has two major disadvantages:



  • your page has to inherit from specific BasePage class

  • even though handlers are attached and then unattached, threading issues still can occur since it is possible that for a period of time more than one handler is attached to the event

How to solve this issue then once and for all?


Well, let's have a single event handler, attached once in the Application.Start event. Then, when the handler fires, we'll check whether current handler (page) implements our specific interface and if this is so, we'll redirect the execution to the handler.


Let's start with the definition of the interface:



public interface ISiteMapResolver
{
    SiteMapNode SiteMapResolve( object sender, SiteMapResolveEventArgs e );
}

Let's also modify the GlobalApplication class to add a single handler for the SiteMapResolve:



public class GlobalApplication : System.Web.HttpApplication
 {
     protected void Application_Start( object sender, EventArgs e )
     {
         /* SiteMap resolve */
         SiteMap.SiteMapResolve += new SiteMapResolveEventHandler( Provider_SiteMapResolve );
     }
 
     SiteMapNode Provider_SiteMapResolve( object sender, SiteMapResolveEventArgs e )
     {
         if ( e.Context.CurrentHandler is ISiteMapResolver )
             return ( (ISiteMapResolver)e.Context.CurrentHandler ).SiteMapResolve( sender, e );
         else
             return null;
     }
 
     ...

and then let's implement the interface on a specific page:



public partial class TheSpecificPage : Page, ISiteMapResolver
{
 ...
 
 public SiteMapNode SiteMapResolve( object sender, SiteMapResolveEventArgs e )
 {
     // build a dynamic, page specific site map path to show it 
     // in the SiteMapPath control
     SiteMapNode parent = new SiteMapNode( e.Provider, "1", "~/TheStartPage.aspx", "Start page" );
     SiteMapNode child  = new SiteMapNode( e.Provider, "2", "~/TheSpecificPage.aspx", "Specific page" );
     child.ParentNode = parent;
 
     return child;
 }
}
That's should be it. We've observed no threading or any other issues. Please share your experiences on this approach.

27 comments:

Anonymous said...

Hi cheers, if this works you will be an absolute life saver. I will report back on my progress tomorrow morning.

Anonymous said...

Definately looks good. I have had this running now for a day on a busy website and it is performing really well. Thanks again for this.

Wiktor Zychla said...

thanks for your feedback. please drop a note of any issues if they occur.

Anonymous said...

Hi, this seems to work fine when i have a static site map (from an xml file) but i have just implemented this solution on a site which has a dynamic site map and have some problems.

The build in provider model is kinda limited if the number of dynamic pages grows, therefore i implemented the solution found at:

http://www.codeproject.com/KB/aspnet/dynamicsitemap.aspx

My SiteMapProvider contains the following methods:

protected override SiteMapNode GetRootNodeCore()
{
this.Clear();
return this.BuildSiteMap();
}

public override SiteMapNode BuildSiteMap()
{
lock (this)
{
if (_root != null)
return _root;

_root = new SiteMapNode(this, "/", "/", "Home");
this.AddNode(_root);

return _root;
}
}

public SiteMapNode Stack(SiteMapNode node)
{
return this.Stack(node, _root);
}

public SiteMapNode Stack(SiteMapNode node, SiteMapNode parent)
{
lock (this)
{
node.ParentNode = parent;
this.AddNode(node);
return node;
}
}

public void Stack(List<SiteMapNode> nodes)
{
SiteMapNode parent = this.RootNode;

foreach (SiteMapNode node in nodes)
{
parent = this.Stack(node, parent);
}
}

protected override void Clear()
{
lock (this)
{
_root = null;
base.Clear();
}
}

And all my pages inherit from a base page which stacks the nodes and builds the site map (for that page based on the url). However when using the SiteMapResolve event i have found that every once in a while SiteMap.CurrentNode returns null and throws an exception in my code. I can bypass this by building the whole path in the SiteMapResolve but this seems more of a hack.

Please note however that the problem existed with the better site map resolve approach described on codeplex. So your approach is still an improvement.

Anonymous said...

Hi,
i'm a newbie in C# and .NET, so maybe it is a stupid question but

I'm trying to use a sitemappath in a masterpage, but i want to be able to change programmatically the text dysplayed upon the querystring value.
I'd really like to implement your solution.

so the question is...
Where do i have to declare the interface?

Thanks

Anonymous said...

Hi,
this is a very useful hint.
however i have a problem implementing this in a master page. the application never call the SiteMapResolve method. if i put this in the defalut.aspx it works properly, but this would mean that i have to add this method to all the pages of the website. do you have any suggestion?
thanks,

nabeel said...

I also have the same MasterPage problem.My website has two master pages.The first MasterPage send the query string to the second one which has the navigation menu which has the links that should be modified to include the querystring.I hove someone help us soon to solve this problem...

nabeel said...

Even when I try to write the handler code in each page or the base page The current node becomes null and an exception occur.Could any one help today?!

All Within My Hands said...

With a master page, try adding an Import for System.Web.UI.Page. I had to do this to use some features of the Login control and it now works fine. I don't have time to test it with a master page, but if it works please comment back here so I will know for the future!

Andrea said...

an import? how? can you tell me the syntax?

All Within My Hands said...

You would enter it at the top of the page, outside and above your partial class. With this example's code it would be above the partial class TheSpecificPage:

import System.Web.UI.Page;

public partial class TheSpecificPage : Page, ISiteMapResolver
{
// ...Page code here...
}

kchen said...

if you're building the sitemap dynamically via a custom sitemap provider, then just set the provider property of your sitemappath to your custom sitemap provider.

rball said...

If you have multiple sitemap providers you'll need to use this instead to bind the event:

SiteMap.Providers[currentProvider].SiteMapResolve += new SiteMapResolveEventHandler(Provider_SiteMapResolve);

Otherwise, seems to be working fine. Thanks!

Anonymous said...

hi brother
Can i use this solution on the user control? My website built on portal.

Anonymous said...

hi brother
Can i use this solution on the user control? My website built on portal.

Kocha said...

Thanks a lot, works great!

みう said...

It was very helpful. thanks

Anonymous said...

Great post! Thanks Wiktor.

I have an app with dynamically added Master Pages containing a SiteMapPath control, and multiple sitemap providers specified in the web.config (http://msdn.microsoft.com/en-us/library/ms178426(VS.80).aspx).

In order to fire events in the other providers I had to add events to all providers in the global.asax and dynamically set the provider in the masterpage.

Don't implement the interface in the master page, just do so on those content pages where you want to do something with the sitemap.

void Application_Start(object sender, EventArgs e)
{
// Code that runs on application startup
SiteMapProviderCollection providers = SiteMap.Providers;
foreach (SiteMapProvider provider in providers)
{
SiteMap.Providers[provider.Name].SiteMapResolve += new SiteMapResolveEventHandler(Provider_SiteMapResolve);
}

}

// In masterpage. I chucked this in the Init as I thought best
// to do this early in life-cycle. Didn't try elsewhere as it worked.
protected void Page_Init(object sender, EventArgs e)
{

SiteMapPath1.SiteMapProvider = "yourNamedProviderInTheWebConfig";
}

Anonymous said...

Nice clean solution, thanks!

Anonymous said...

Can some one explain what the "1" and "2" stands for in creating the SiteMap object?

Thanks

Piegus said...

Good post. And unfortunately still actual.

Kevin said...

My SiteMapProvider also is defined in a MasterPage.

To overcome the problem I used the following approach in Global.asax.cs:

SiteMapNode SiteMap_SiteMapResolve(object sender, SiteMapResolveEventArgs e)
{
if (e.Context.CurrentHandler is ISiteMapResolver)
{
return ((ISiteMapResolver)e.Context.CurrentHandler).SiteMapResolve(sender, e);
}


var requestedPage = e.Context.CurrentHandler as Page;
if (requestedPage != null)
{
MasterPage currentMasterPage = requestedPage.Master;
while (currentMasterPage != null)
{
if(currentMasterPage is ISiteMapResolver)
{
return ((ISiteMapResolver) currentMasterPage).SiteMapResolve(sender, e);
}

currentMasterPage = currentMasterPage.Master;
}
}

return null;
}

BR,
Kevin

Mairusz, Gorzoch said...

You are briliant!

Saif said...

I've implemented multiple provider with みう solution but only default provider is working. What's wrong...?

Derek Williams said...

I was seeing a memory leak in one of my Production applications, at first I thought it might be the session transfer I had between Classic ASP and APS.Net. However one of our other developers, utilizing a script to reload an offensive page 1000 times he was able to determine that it was the attachment of the static SiteMapResolveEventHandler on every page load that was causing the memory leak. Thanks to this article I was able to fix the issue and the memory leak is gone.

First thing I would like to add is in the first section of the article the Interface section is a little vague. I simply added a class file to my App_Code directory (right click App_Code->Add new Item), select Class and call it Interfaces.cs. Copy the Interface code in the first section of the article into this file, remove everything but the Using statements though so that the Interface is not inside another Class.

I also had to had to add a Global.asax page and connecting the code behind is kind of confusing. After adding the .asax file to your solution, right click App_Code and add another class called GlobalApplication to match the above example, be sure to make it inherit : System.Web.HttpApplication, then copy the contents between the tags in the .asax tags into Class in the GlobalApplication.cs file. Erase ever line in the .asax file except for the first line, and add to the first line in the .asax file, Inherits="GlobalApplication".

Now if you need to use this in the Master Page follow what Kevin did in the post above me. Keep in mind he changed the name of the the SiteMap, so update the reference in your Application_Start event.

You should now be able to add the ISiteMapResolver interface to the Inheritance list of your MasterPage.master.cs file. Be sure to name your SiteMapNode on the Master page the same name as the name of the SiteMapNode in your Interfaces.cs file.

Paul Clement said...

Thanks a lot :)

Us Floyd said...

Thanks so much! Bacon saved!