Ben jij de weg kwijt?
Iedere website of webapplicatie heeft tegenwoordig wel een vorm van navigatie. Sinds .NET 2.0 kan dat eenvoudiger door gebruik te maken van een SiteMap, in combinatie met bijvoorbeeld een menu control (asp:Menu) of broodkruimelnavigatie (asp:SiteMapPath). Een SiteMap kan ook gekoppeld worden aan componenten die een IHierarchicalDataSource accepteren. Dit valt echter buiten de scope van dit artikel. We gaan ervan uit, dat je weet wat een SiteMap is, hoe deze aan een menu control gekoppeld kan worden en hoe het provider model van het .NET Framework gebruikt kan worden. Zie voor meer informatie de referenties onder dit artikel.
Dit artikel helpt je licht in de duisternis te brengen
In dit artikel gebruiken we een eenvoudig voorbeeld van een SiteMap met een menu en broodkruimelnavigatie. Deze gebruiken een XmlSiteMapProvider die de SiteMap uit de web.SiteMap leest. Het menu is als volgt opgebouwd:

Fig.1: Het menu
In het voorbeeld zien we dat de menucontrol (asp:Menu) highlighting ondersteunt. De databron gebruikt een provider (asp:SiteMapProvider) voor het lezen van de SiteMap. Voor alle nodes (SiteMapNode) uit de SiteMap wordt een menu-item in het menu aangemaakt.
Probleembeschrijving
Als je een uitgebreide website met vele pagina’s hebt, dan zul je niet altijd alle pagina’s in het menu opnemen. Bij het doorlopen van de website zul je vanuit het menu naar een pagina van een onderdeel gaan, bijvoorbeeld een productlijst. Vanuit de productlijst klik je dan door naar een detailpagina. De detailpagina wil je niet in het menu opnemen, maar je wilt wel zien dat je in het menu nog in het onderdeel producten zit. Dit noemen we ‘highlighting’. In de SiteMap zul je de url van de detailpagina dus opnemen onder de url van de productlijst (zie listing 1).
url="~/PublicPages/Products/ProductsHome.aspx"
title="Products"
description="Product catalog"
isMenuItem="true">
url=
"~/PublicPages/Products/ItemDetails.aspx?TPE=PRD"
title="Product details"/>
Listing 1: voorbeeld van een deel van de web.SiteMap voor het producten-onderdeel.
Met de standaard SiteMap provider zal door de menu control voor de detailpagina ook een menuitem aangemaakt worden. Dit is ongewenst, omdat daardoor het menu te gedetailleerd wordt.
Als je niet alle url’s van pagina’s uit de SiteMap in het menu wilt opnemen, dan kun je meerdere SiteMaps definiëren; een SiteMap met alle url’s en een SiteMap met alleen de url’s uit het menu. Je krijgt dan echter twee SiteMaps, die grotendeels overeenkomen. Het ondersteunen van grotendeels overlappende SiteMaps is ongewenst vanwege de onderhoudbaarheid van de applicatie. Willen we slechts één SiteMap, dan moeten er nodes uit de SiteMap gefilterd worden. In het voorbeeld willen we de productdetail pagina niet opnemen in het menu. Voor het filteren van nodes uit de SiteMap ten behoeve van het menu is geen standaard ondersteuning.
Met het filteren voor het menu, zou je een applicatie verwachten waar je probleemloos doorheen kunt klikken, en bovendien dat je altijd in het menu ziet waar je in de applicatie bevindt. Echter, het bepalen van de positie binnen de SiteMap gaat alleen goed, als de url’s in de SiteMap geen querystrings bevatten. Voor de highlighting van het menu wordt bij het laden van de pagina de huidige node binnen de SiteMap bepaald. Dit gebeurt door de url van het request te vergelijken met de url uit de SiteMap. Als in de SiteMap url’s staan met querystring parameters, dan kan het huidige menuonderdeel niet bepaald worden in de volgende gevallen:
- De volgorde van de querystring parameters in de url van het request wijkt af van de volgorde in de SiteMap
- In de url van de request worden extra parameters opgenomen.
Een manier om de twee genoemde tekortkomingen op te lossen is het schrijven van een eigen SiteMapProvider. Hieronder is per tekortkoming eerst beschreven waardoor het precies veroorzaakt wordt. Vervolgens wordt een mogelijke implementatie van een SiteMapProvider beschreven die de tekortkomingen oplost.
Filtering
Voor het menu dient de SiteMap gefilterd te worden, zodat alleen de tot het menu behorende SiteMapnodes overblijven. Dit wordt niet door de standaard SiteMapProvider ondersteund. Het is dus nodig om te weten hoe de gedefinieerde SiteMap ingelezen wordt.
Als de eerste keer aan de SiteMapProvider om de inhoud van de SiteMap wordt gevraagd, dan wordt deze samengesteld. Met de methode BuildSiteMap uit de class SiteMapProvider wordt de SiteMap ingelezen. Ook de waarden van de gespecificeerde attributen worden ingelezen. Intern wordt voor iedere node uit de SiteMap ook een instantie van de class SiteMapNode gemaakt en toegevoegd aan een verzameling van het type SiteMapNodeCollection. Een referentie naar het begin van de SiteMap wordt bewaard en kan middels de property RootNode gelezen worden. Aangezien de class SiteMapNode het interface IHierarchyData implementeert en de class SiteMapNodeCollection het interface IHierarchicalEnumerable, kan de SiteMap eenvoudig middels een databron van het type HierarchicalDataSource gebruikt worden. Dit type databron is geschikt om een hiërarchische datastructuur van bijvoorbeeld een menu te vullen.
Oplossing voor het filterprobleem
Voor het filteren van de SiteMap gaan we onderscheid maken tussen de nodes die wel tot het menu en nodes die niet tot het menu behoren. Om de nodes die bij het menu horen te kunnen onderscheiden van de overige nodes voegen we een attribuut ‘isMenuItem’ met de waarde “waar” (true) toe aan de betreffende nodes uit de SiteMap. Nu kunnen we eenvoudig testen of een node tot het menu behoort. Maar om vanuit de hele applicatie de positie binnen het menu te bepalen moet dit ook mogelijk zijn met de url’s van de nodes die niet tot het menu behoren.
We maken een nieuwe providerclass MenuSiteMapProvider aan, die afleidt van de class StaticSiteMapProvider. We voegen een methode isMenuItem toe, die met de waarde van het attribuut ‘isMenuItem’ bepaalt of de node tot het menu behoort. Vervolgens voegen we een methode ‘filterNode’toe, die de filtering toepast op een node en alle onderliggende nodes.
private bool isMenuItem(SiteMapNode node)
{
return true.ToString().Equals(node["isMenuItem"], StringComparison.OrdinalIgnoreCase);
}
Listing 2: De methode die bepaalt of de meegegeven node een menu-item is
Nu kunnen we ingrijpen in de methode BuildSiteMap. We lezen de originele SiteMap in via een gekoppelde provider, omdat we deze later nog nodig hebben voor het bepalen van de huidige menu-item node. De gekoppelde provider wordt met de nieuwe property SourceProvider bepaald. We maken een override van de methode BuildSiteMap, waarin we eerst de eigenschap RootNode van de gekoppelde provider opvragen. We maken een kopie van de SiteMap uit de gekoppelde provider, want we willen deze niet aanpassen. Vervolgens roepen we de nieuwe methode filterNode aan. Tenslotte voegen we de gefilterde SiteMap toe aan de nieuwe provider (MenuSiteMapProvider). Om de provider threadsafe te houden gebruiken we locks bij het toevoegen van de gefilterde SiteMap.
private static object lockInstance = new object();
private SiteMapNode _filteredSiteMapNode;
protected SiteMapProvider SourceProvider{get;}
///
/// Building the SiteMap.
///
///
public override SiteMapNode BuildSiteMap()
{ // do it on methode and not on property RootNode
SiteMapNode rootNode = this._filteredSiteMapNode;
if (rootNode != null)
return rootNode; // avoid unnessesary locks
lock (lockInstance)
{
if (this._filteredSiteMapNode == null)
{
// copy SiteMap from source provider
rootNode =
this.SourceProvider.RootNode.Clone();
rootNode.ChildNodes =
this.CloneSiteMapNodeCollection(rootNode.ChildNodes);
// filter the build SiteMap
filterChildNodes(rootNode);
this.Clear();
// clear the current SiteMapNodes
// from our internal SiteMap
this.AddNodeTree(rootNode, null);
// add the new filtered SiteMap to our
//internal SiteMap
this._filteredSiteMapNode = rootNode;
// keep the reference to the root
}
}
return rootNode;
// returns the node of the build part of the
// SiteMap, when filtering this will be
// the root node
}
private void filterChildNodes(SiteMapNode node)
{
// copy references in a list and filter the list
List childNodeList =
this.createSiteMapList(node.ChildNodes);
foreach (SiteMapNode childNode in node.ChildNodes)
{
if (!isMenuItem(childNode))
{ // remove nodes which are no part of the menu
childNodeList.Remove(childNode);
}
else
{ // filter childnodes, too
filterChildNodes(childNode);
}
}
node.ReadOnly = false;
// make changes to node possible
node.ChildNodes = new
SiteMapNodeCollection(childNodeList.ToArray());
// assign filtered list of child nodes
}
Listing 3: De provider voor de oplossing van het filteren van de SiteMap
Door het filteren van de SiteMap hebben we impliciet de functie van het bepalen van het geselecteerde menu-item beperkt tot de url’s uit de gefilterde SiteMap die tot het menu behoren. Willen we het geselecteerde menu-item door de gehele applicatie kunnen bepalen, dan moet de werking van de eigenschap CurrentNode gewijzigd worden. Eerst bepalen we de geselecteerde node in de gekoppelde provider. Vervolgens controleren we of deze voorkomt in de gefilterde SiteMap. Als dat niet zo is, dan nemen we de bovenliggende node. Zo gaan we terug naar de start node, totdat we een node uit de gefilterde SiteMap tegenkomen. Hetzelfde idee voeren we door in de methode FindSiteMapNode. Zowel in de eigenschap CurrentNode als in de methode FindSiteMapNode gebruiken we de base.FindSiteMapNode methode voor het zoeken van de node in de gefilterde SiteMap. Als de node voorkomt in de gefilterde SiteMap, dan zal deze gevonden worden op basis van zijn url.
public override SiteMapNode CurrentNode
{
get
{
// get the current node
SiteMapNode currentNode =
this.SourceProvider.CurrentNode;
// map the SiteMapNode from the source provider
// to a SiteMapNode from the menu SiteMapProvider
// which represent a menu item.
while (currentNode != null &&
base.FindSiteMapNode(currentNode.Url) == null)
{
currentNode = currentNode.ParentNode;
}
return currentNode;
}
}
public override SiteMapNode FindSiteMapNode
(string rawUrl)
{
// search the node in the source provider
SiteMapNode foundNode =
this.SourceProvider.FindSiteMapNode(rawUrl);
// map the SiteMapNode from the source provider
// to a SiteMapNode from the menu SiteMapProvider
// which represent a menu item.
while (foundNode != null &&
base.FindSiteMapNode(foundNode.Url) == null)
{
foundNode = foundNode.ParentNode;
}
return foundNode;
}
Listing 4: De aangepaste functies voor het vinden van een node in de SiteMap
Querystring: alles of niets
Een belangrijke manier om een SiteMapNode in de SiteMap terug te vinden is op basis van de url. De methodes BuildSiteMap en FindSiteMapNode spelen hierin een sleutelrol. De methode BuildSiteMap leest de SiteMapNodes uit een bron en plaatst deze in een HashTable. Hierbij wordt als sleutel de url van de SiteMapNode gebruikt. Zo wordt een gehashte zoektabel samengesteld. De methode FindSiteMapNode zoekt de SiteMapNode met de url uit het ontvangen request in de HashTable op. Er wordt eerst gezocht met de volledige url uit de request (de gehele querystring) en daarna met de absolute url (querystring zonder parameters) uit het request. Als een url van het request uit meerdere querystring parameters bestaat en ze niet allemaal van belang zijn voor de SiteMap, dan wordt de node dus niet gevonden. Hetzelfde geldt als de querystring parameters in een andere volgorde staan.
Ter verduidelijking van het probleem is hieronder een voorbeeld opgenomen van een node in de SiteMap en een aantal url’s waarmee de pagina opgevraagd kan worden. Per request is aangegeven of de standaard provider de url herkent als url van de SiteMap node. Dit voorbeeld is ook in het uitgewerkte voorbeeld “SiteMapExample” terug te vinden, zie de referenties onderaan dit artikel.
url="~/MemberPages/Downloads/
ManualDownloads.aspx?MNLTP=P"
title="Product manuals" roles="SiteMembers"/>
web.SiteMap
http://MyServer/SiteMapExample/MemberPages/Downloads/
ManualDownloads.aspx
[wordt herkend met standaard provider]
http://MyServer/SiteMapExample/MemberPages/Downloads/
ManualDownloads.aspx?MNLTP=P
[wordt herkend met standaard provider]
http://MyServer/SiteMapExample/MemberPages/Downloads/
ManualDownloads.aspx?MNLTP=P&PRFLNG=nl-NL
[wordt niet herkend met standaard provider]
http://MyServer/SiteMapExample/MemberPages/Downloads/
ManualDownloads.aspx? PRFLNG=en-GB&MNLTP=P
[wordt niet herkend met standaard provider]
Voorbeeld url-herkenning
Oplossing voor het querystringprobleem
Voor het oplossen van het querystringprobleem is een verfijning in het opzoeken van nodes in de SiteMap nodig. Om dit te bereiken gaan we de interne gehashte zoektabel anders gebruiken. Nu wordt altijd de gehele url van de SiteMapNode als sleutel gebruikt en de SiteMapNode als waarde. In de oplossing gaan we alleen de absolute url van de node, zonder querystring parameters, als sleutel gebruiken. In plaats van gelijk de node als waarde in de zoektabel op te nemen gaan we een boomstructuur samenstellen. In de boomstructuur nemen we de mogelijke querystring parameters met hun waarde op als nodes.
Bij het zoeken naar een node zoeken we eerst op de absolute url. Wordt deze gevonden in de zoektabel, dan zoeken we met de querystring parameters verder in de bijbehorende boomstructuur. Op basis van de querystring parameters uit de url wordt bepaald via welke node in de boom verder gezocht wordt, totdat we op een eindpunt komen. Op een mogelijk eindpunt in de boomstructuur komen de querystring parameters in de boomstructuur overeen met de url van een node uit de SiteMap. Op het eindpunt van de boomstructuur wordt de verwijzing naar de node opgenomen.
Voor het aanmaken van de nieuwe zoektabel en het zoeken hierin hebben we een class SiteMapNodeHandler gemaakt. Voor het samenstellen van de boomstructuur introduceren we een nieuwe class SitePathNode. De gehashte zoektabel zal als sleutel de absolute url van één of meer nodes uit de SiteMap hebben. Bij de sleutel uit de zoektabel hoort als waarde de verwijzing naar de startnode (SitePathNode) van de boomstructuur.
public static class SiteMapNodeHandler
{
public static void InsertNode(Hashtable searchTable,
SiteMapNode node)
{
// inserts the node in the searchTable ...
}
public static SiteMapNode FindNode
(Hashtable searchTable, string rawUrl)
{
SiteMapNode node = null;
// search the node in the searchTable ...
return node;
}
}
Listing 5: interface van de handler voor het zoeken in een boomstructuur
///
/// Represents a node of the search tree,
/// for searching through the query string parameters.
///
public class SitePathNode
{
private Dictionary _childNodes;
///
/// Possible query string variables with
/// optional value.
///
/// Use as key the hole query string param
/// including =-sign and value or use only param name
/// It's important to sort the child nodes in the same
/// order as they will be searched.
///
public Dictionary ChildNodes
{
get { return _childNodes; }
set { _childNodes = value; }
}
private SiteMapNode _mapNode;
///
/// SiteMapNode related to the path or null.
///
public SiteMapNode MapNode
{
get { return _mapNode; }
set { _mapNode = value; }
}
}
Listing 6: De treenode waarmee de boomstructuur wordt opgebouwd
Hieronder komt de toepassing van de nieuwe zoekmethode in een eigen provider aan bod. Echter, de inhoudelijke behandeling van de werking van de handler (SiteMapNodeHandler) valt buiten de scope van dit artikel. Raadpleeg hiervoor de sourcecode, die gedownload kan worden van de website (zie de referentie bij dit artikel).
Om het navigatieprobleem op te lossen schrijven we een nieuwe provider class AppSiteMapProvider die afgeleid is van de class XmlSiteMapProvider. We herschrijven de methode FindSiteMapNode. Als eerste actie proberen we de SiteMapNode op de standaard manier te vinden. Pas als dat niet lukt gaan we uitgebreid zoeken met behulp van de SiteMapNodeHandler. Voor we kunnen gaan zoeken met de SiteMapNodeHandler, moet de nieuwe zoektabel gevuld zijn. Als dit niet het geval is, vullen we de zoektabel voor het zoeken.
public class AppSiteMapProvider : XmlSiteMapProvider
{
private Hashtable _searchTable;
internal readonly object _lock = new object();
public AppSiteMapProvider()
{
}
public override SiteMapNode FindSiteMapNode
(string rawUrl)
{
SiteMapNode node = base.FindSiteMapNode(rawUrl);
if (node == null)
{ // when not found then use our advanced
// search method
Hashtable searchTable =
this.BuildInternalSearchTable();
// build internal structure when not
// done already.
node = SiteMapNodeHandler.FindNode
(this._searchTable, rawUrl);
}
return this.ReturnNodeIfAccessible(node);
}
private Hashtable BuildInternalSearchTable()
{
Hashtable searchTable = this._searchTable;
if( searchTable != null)
return searchTable; // avoid unnessesary locks
lock (_lock)
{
if (this._searchTable == null)
{
try
{
Hashtable newSearchTable = new Hashtable();
this.AddInternalSiteMapNode
(newSearchTable, this.RootNode);
this._searchTable = newSearchTable;
}
catch
{
this._searchTable = null;
throw;
}
searchTable = this._searchTable;
}
}
return searchTable;
}
private void AddInternalSiteMapNode
(Hashtable searchTable, SiteMapNode node)
{
SiteMapNodeHandler.InsertNode(searchTable, node);
foreach (SiteMapNode childNode in node.ChildNodes)
{
this.AddInternalSiteMapNode
(searchTable, childNode);
}
}
// copied from base class
internal SiteMapNode ReturnNodeIfAccessible
(SiteMapNode node)
{
if ((node != null) &&
node.IsAccessibleToUser(HttpContext.Current))
{
return node;
}
return null;
}
}
Listing 7: de provider voor de oplossing van het querystringprobleem.
Tenslotte
In dit artikel hebben we beschreven hoe je met behulp van het providermodel nieuwe SiteMapProviders kunt schrijven om een aantal tekortkomingen van de standaard SiteMapProvider te omzeilen. Op de website van Qurius kun je de volledig uitgewerkte sourcecode downloaden (zie de referenties onder dit artikel).
Referenties
Dit artikel is geschreven door Frans Harinck en Stefan Onderstal.