Sunday, September 23, 2012

Custom ASP.NET SiteMapProvider combining a database, the file system and static content

Hi, I had some trouble while creating a custom SiteMapProvider, at first and after reading several blogs, it appeared that this wasn’t so easy.... but it turns out that it’s really simple once you know the basis.


The SiteMapProvider I want to create, is one that combines data from a database, files in the file system and static nodes defined in the XML sitemap file.


Note: This is not about the Sitemap.xml file used by search engine crawlers, the site map I’m going to build is the sitemap provider used in ASP.Net to ptovide navigation.


Let’s begin.


First thing first. In ASP.Net there’s a default sitemap provider already registered in the web.config file located in: %windir%\Microsoft.NET\Framework\%version%\Config\web.config


And looks like:


    <siteMap>
        <providers>
            <add siteMapFile="web.sitemap" name="AspNetXmlSiteMapProvider"
                type="System.Web.XmlSiteMapProvider, System.Web, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a" />
        </providers>
    </siteMap>

The XmlSiteMapProvider inherits from: StaticSiteMapProvider


Most of the blogs will tell you that you must inherit StaticSiteMapProvider and implement your own provider almost from scratch. But it turns out that you can inherit XmlSiteMapProvider and customize it. I have not found a problem so far, if you know a problem with this approach please let me know.


So lets create our solution. (I am working with Visual Studio 2012 Express for Web V.11.0.50727.1 RTMREL)


Adding static content


  • Create an empty Web Application called MixedSiteMapProvider in Visual Studio
  • Create a class right under the project root called: CustomSitemapProvider and inherit from XmlSiteMapProvider
  • Register the custom sitemap provider in the web.config file:

        <system.web>
          <siteMap defaultProvider="CustomProvider">
            <providers>
              <add name="CustomProvider" type="MixedSiteMapProvider.CustomSitemapProvider" siteMapFile="Web.sitemap" />
            </providers>
          </siteMap>
        </system.web>

  • Create a sitemap file called Web.sitemap right under the root project and add the following content:

        <?xml version="1.0" encoding="utf-8" ?>
        <siteMap xmlns="http://schemas.microsoft.com/AspNet/SiteMap-File-1.0" >
            <siteMapNode url="~/" title="Home"  description="Static file">
              <siteMapNode url="~/Default2.aspx" title="Default2" description="Another static file">
              </siteMapNode>
              <siteMapNode title="My File System Content" url="" description="">
              </siteMapNode>
            </siteMapNode>
        </siteMap>

  • Create a MasterPage called Site.master right under the project root and add the following code:

        <%@ Master Language="C#" AutoEventWireup="true" CodeBehind="Site.master.cs" Inherits="MixedSiteMapProvider.Site" %>

        <!DOCTYPE html>

        <html xmlns="http://www.w3.org/1999/xhtml">
        <head runat="server">
            <title></title>
            <asp:ContentPlaceHolder ID="head" runat="server">
            </asp:ContentPlaceHolder>
        </head>
        <body>
            <form id="form1" runat="server">
                <asp:SiteMapDataSource runat="server" ID="smds" ShowStartingNode="true" />
                <div>
                    <asp:SiteMapPath runat="server" ID="siteMapPath1">
                    </asp:SiteMapPath>
                </div>
                <div>
                    <asp:Menu runat="server" ID="menu" DataSourceID="smds">
                    </asp:Menu>
                </div>
                <div>
                    <asp:TreeView runat="server" ID="tv" DataSourceID="smds">
                    </asp:TreeView>
                </div>
                <div>
                    <asp:ContentPlaceHolder ID="ContentPlaceHolder1" runat="server">
                    </asp:ContentPlaceHolder>
                </div>
            </form>
        </body>
        </html>

  • Create a content ASPX file called Default.aspx right under the project root and select Site.master as its MasterPage

  • Create a content ASPX file called Default2.aspx right under the project root and select Site.master as its MasterPage

  • Run the application and you will see the sitemap provider works as expected reading the content of the Web.sitemap file


Adding File System content


Now we are going to discover the files under a custom path to add them as part of our SiteMapProvider


The StaticSiteMapProvider and the XmlSiteMapProvider works similar. There’s a method called BuildSiteMap in charge to build the nodes structure. The tricky part is to know that this method is actually going to be called on many internal operations of the base class. For example when adding a child node (AddNode method), when finding a node (FindSiteMapNode and FindSiteMapNodeFromKey method), etc.


This is important to keep it in mind because this means that if you do not write taking this consideration you could get a StackOverFlowException. The easiest way to solve this, is creating a flag at instance level (more about this in a minute)


ASP.Net keeps in memory each sitemap provider instance as long as the application is not restarted. This means that by default we get cached our custom sitemap provider. Since we already saw that the BuildSiteMap method is going to be called several times on each page for every concurrent user, we need to build our implementation to be thread-safe. The easiest way to do this is by locking each action with lock


OK, lets roll and create our custom sitemap nodes from the file system.


  • Create a subfolder called Topics right under the project root and add three ASPX files, name them Page1.aspx, Page2.aspx and Page3.aspx

  • Update our CustomSitemapProvider as follows:


using System;
using System.IO;
using System.Linq;
using System.Web;

namespace MixedSiteMapProvider
{
    public class CustomSitemapProvider : XmlSiteMapProvider
    {
        private const string FileSystemContentNodeTitle = "My File System Content";
        private readonly object LockObject = new Object();
        private SiteMapNode WorkingNode { get; set; }
        private bool BuildingNodes { get; set; }

        // this method has to be overriden in order to create the sitemap nodes
        public override SiteMapNode BuildSiteMap()
        {
            // we block the method to make it thread-safe
            lock (LockObject)
            {
                // this condition is the KEY, we need to ensure that this method is executed
                // only once. The problem is that internally, the SiteMapProvider class calls
                // this method several times. If we do not use this condition, we would get a
                // StackOverflowException
                if (this.BuildingNodes)
                {
                    return this.WorkingNode;
                }

                // we call the base BuildSiteMap method to get all the static nodes registered
                // statically in the Web.sitemap file. From here, we will configure this SiteMapNode
                // collection to add our custom nodes
                this.WorkingNode = base.BuildSiteMap();
                this.BuildingNodes = true;

                var fileSystemNode = 
                    this.WorkingNode.ChildNodes.OfType<SiteMapNode>().FirstOrDefault(x => x.Title.Equals(FileSystemContentNodeTitle, StringComparison.InvariantCultureIgnoreCase));

                if (fileSystemNode == null)
                {
                    // if we didn't find a node to explicitly add our content from the file system
                    // we will create a custom node
                    fileSystemNode = new SiteMapNode(this, "FileSystemNode", string.Empty, FileSystemContentNodeTitle);
                    this.AddNode(fileSystemNode, this.WorkingNode);
                }

                // we iterate through all the files contained in the filesystem folder
                foreach (var file in Directory.GetFiles(HttpContext.Current.Server.MapPath("~/Topics/"), "*.aspx"))
                {
                    this.AddNode(
                        new SiteMapNode(this, file, VirtualPathUtility.ToAbsolute("~/Topics/") + Path.GetFileName(file), Path.GetFileNameWithoutExtension(file)), 
                        fileSystemNode);
                }

                return this.WorkingNode;
            }
        }
    }
}

I think the code is self explanatory (along the comments).


I just want to emphasize the importance of the lock process lock (LockObject) to make the method thread-safe and the flag condition if (this.BuildingNodes) to prevent the execution of the method more than once.


If you run your application you will see something like:






Adding database records


The last step is to complement our dynamic sitemap with records from the database.


Since we already created the most difficult part, this should be really simple.


I’m going to use the PUBS database, and let say that we want to show in our navigation all the job descriptions that exist in the jobs table and inside each one of these categories we want to list all its employees.


  • Get the PUBS database from here and install it

  • Add the following connection string to your web.config file


  <connectionStrings>
    <add name="Pubs" providerName="System.Data.SqlClient" connectionString="Data Source=.\sqlexpress;Initial Catalog=pubs;Integrated Security=True" />
  </connectionStrings>


  • Create a new LINQ To SQL class named Pubs right under the root of your project

  • Connect to the database and drag and drop the jobs and employee tables to de PubsDataContext designer. Save the file and close it

  • Add three content ASPX files choosing the Site.master master page right under the root of your project and name them: JobsList.aspx, JobDetails.aspx and EmployeeDetails.aspx

  • Add the following markup to the JobsList.aspx file



<%@ Page Title="" Language="C#" MasterPageFile="~/Site.master" AutoEventWireup="true" CodeBehind="JobsList.aspx.cs" Inherits="MixedSiteMapProvider.JobsList" %>
<asp:Content ID="Content1" ContentPlaceHolderID="head" runat="server">
</asp:Content>
<asp:Content ID="Content2" ContentPlaceHolderID="ContentPlaceHolder1" runat="server">
    <asp:LinqDataSource runat="server" ID="lds"
        TableName="jobs" ContextTypeName="MixedSiteMapProvider.PubsDataContext">
    </asp:LinqDataSource>
    <asp:GridView runat="server" ID="gv" DataSourceID="lds" AutoGenerateColumns="false">
        <Columns>
            <asp:HyperLinkField HeaderText="Job description" DataTextField="job_desc" DataNavigateUrlFormatString="~/JobDetails.aspx?id={0}" DataNavigateUrlFields="job_id" />
        </Columns>
    </asp:GridView>
</asp:Content>


  • Add the following markup to JobDetails.aspx


    <asp:LinqDataSource runat="server" ID="lds"
        TableName="jobs"
        ContextTypeName="MixedSiteMapProvider.PubsDataContext">
    </asp:LinqDataSource>
    <asp:QueryExtender runat="server" ID="qeJob" TargetControlID="lds">
        <asp:PropertyExpression>
            <asp:QueryStringParameter Name="job_id" Type="Int16" ValidateInput="true" QueryStringField="id" />
        </asp:PropertyExpression>
    </asp:QueryExtender>
    <asp:DetailsView runat="server" DataSourceID="lds" />
    <hr />
    <asp:LinqDataSource runat="server" ID="ldsempl"
        TableName="employee"
        ContextTypeName="MixedSiteMapProvider.PubsDataContext">
    </asp:LinqDataSource>
    <asp:QueryExtender runat="server" ID="qeEmpl" TargetControlID="ldsempl">
        <asp:PropertyExpression>
            <asp:QueryStringParameter QueryStringField="id" Type="Int16" ValidateInput="true" Name="job_id" />
        </asp:PropertyExpression>
    </asp:QueryExtender>
    <asp:GridView runat="server" ID="gv" DataSourceID="ldsempl" AutoGenerateColumns="false" ItemType="MixedSiteMapProvider.employee">
        <Columns>
            <asp:TemplateField HeaderText="Employee name">
                <ItemTemplate>
                    <asp:HyperLink NavigateUrl='<%# "~/EmployeeDetails.aspx?id=" + Item.emp_id %>' runat="server" Text='<%# Item.fname + " " + Item.lname %>' />
                </ItemTemplate>
            </asp:TemplateField>
        </Columns>
    </asp:GridView>


  • Add the following code to EmployeeDetails.aspx


    <asp:LinqDataSource runat="server" ID="lds"
        TableName="employee" ContextTypeName="MixedSiteMapProvider.PubsDataContext">
    </asp:LinqDataSource>
    <asp:QueryExtender runat="server" ID="qeEmployee" TargetControlID="lds">
        <asp:PropertyExpression>
            <asp:QueryStringParameter Name="emp_id" Type="String" ValidateInput="true" QueryStringField="id" />
        </asp:PropertyExpression>
    </asp:QueryExtender>
    <asp:DetailsView runat="server" DataSourceID="lds" />


  • Add the following node to the Web.sitemap file


      <siteMapNode title="PUBS Jobs">
      </siteMapNode>


  • Add the following code to the CustomSitemapProvider class


private const string PubsContentNodeTitle = "PUBS Jobs";

  • Add the following code to the end of the BuildSiteMap method in the CustomSitemapProvider class, just before the return this.WorkingNode; line


        // adding the jobs and employees from the database to the sitemap
        var pubsNode = this.WorkingNode.ChildNodes.OfType<SiteMapNode>().FirstOrDefault(x => x.Title.Equals(PubsContentNodeTitle, StringComparison.InvariantCultureIgnoreCase));

        // if the node does not exists, we will create a new node to serve as the base
        // for our database nodes
        if (pubsNode == null)
        {
            pubsNode = new SiteMapNode(this, PubsContentNodeTitle, VirtualPathUtility.ToAbsolute("~/JobsList.aspx"), PubsContentNodeTitle);
            this.AddNode(pubsNode, this.WorkingNode);
        }

        using (var ctx = new PubsDataContext())
        {
            foreach (var empl in ctx.employee)
            {
                var job = empl.jobs;
                var jobNode = this.FindSiteMapNodeFromKey(string.Format("Job:{0}", job.job_desc));

                // if the job node has not been created yet, we will create it
                if (jobNode == null)
                {
                    jobNode = new SiteMapNode(this, string.Format("Job:{0}", job.job_desc), VirtualPathUtility.ToAbsolute("~/JobDetails.aspx?id=" + job.job_id.ToString()), job.job_desc);
                    this.AddNode(jobNode, pubsNode);
                }

                // we add the employee node
                this.AddNode(
                    new SiteMapNode(this, "Employee:" + empl.emp_id, VirtualPathUtility.ToAbsolute("~/EmployeeDetails.aspx?id=" + empl.emp_id), empl.fname + " " + empl.lname), 
                    jobNode);
            }
        }


That’s it.... wow this was a really loong post... sorry about that. There was a lot of code for a simple task =(


If you run the application you will something like this: (after applying some style)




Download the code of this article

Browse the full code in Github

1 comment:

  1. Pose certain inquiries. Your bestcustomessay.org/essays educator is simply evaluating all yields of the paper written work assignments that he or she appoints to you.

    ReplyDelete

If you found this post useful please leave your comments, I will be happy to hear from you.