Friday, January 11, 2008

Auto generate strong typed navigation class for all user controls in ASP.NET web application

Hardcoding is bad. This is known fact. In spite of that all of us are practicing this in our daily work.

In this article I want to talk about hardcoded strings that are used as paths in our ASP.NET web applications.

Navigation patterns.

Pattern of navigation framework differs from website to website. I will list just most known of them (at least the patterns I use and I know about).

1) All Navigation data is stored in the database, access to the pages is performed through "pageID" - some identity column in the navigation data table. This scenario is used when new pages/contents have to be added dynamically by site admin, but not software developer.

2) All navigation data is stored in some file on disk, most of all in XML format. This is what we are told is the best scenario. To access to the page data we don't need to make database calls. XML file is cached into memory and is available at any time.

3) Some data is stored in XML files (the pages that don't have to be changed later, pages representing the core of the application, without them it will not run), and some in database(the pages being added dynamically, they may be joined with another tables, such as user table for example to provide access security features)

The problem

In all three cases we need to hardcode some URLs.

For example most of web applications have Login page, Register page, Error404 page, Contact etc.

So what we do:

Response.Redirect("~/Register.aspx");

or

HyperLink1.NavigateUrl = "~/Register.aspx";

or

<a href="Register.aspx">Register</a>

It does not matter actually how we write the code, in any of these cases we have hardcoded the path!

This means if we have to access this page we need to write the same page path in many places. What if we have hundreds of static pages. Pages that are developed and used by software developer. Pages that are not to be created run-time? Some huge systems contain thousands of pages and user controls that are loaded depending on the process flow in the system. What if some pages were renamed, or configuration for the page was changed(id for example), or page was moved to another folder. Refactoring does not work here. We have to search/modify strings or integers(pageID) or GUIDs ourselves (pageID may be used to access UserControl path from XML or Database storage).

Actually I have a quite big project right now and I see problem here. I want all my data to be strong typed.

Solution

One solution is to centralize all navigation data in some dedicated class/namespace and access this data from every bit of code. Only in this case we are guaranteed that in case of page move or rename operation that class will be the only place we have to change something. But with this solution problem remains. What if we forgot to make this changes? - Solution will compile just fine, but later website will throw unhandled exceptions trying to find non-existing page or control.

My solution is to use code generation tools to solve this problem.

Fortunately it's quite easy with Visual studio 2008. This version contains built-in template code generation framework.

To use it just create file in Class Library project and name it <name>.tt . This extension is resolved by Visual studio as code generation template file.

I created file named Pages.tt. The file contains the following code:

<#@ import namespace="System.IO" #>
<#@ import namespace="System" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Collections" #>
<#@ import namespace="System.Text.RegularExpressions" #>
<#@ template hostspecific="True" #> 
namespace Devarchive_Net.Navigation
{
<# 
string BaseDir = Host.ResolvePath(@"..\Auto_generating_strong_typed_navigation_class"); 
// Thanks Oleg Sych for suggestion - how to resolve relative paths and not to hardcode Base Directory path for code generation
#>
<#=GetPagesClass(BaseDir)#>
}

<#+
        public static string GetPagesClass(string BaseDir)
        {
            StringBuilder sb = new StringBuilder();
            sb.AppendLine("\tpublic static class Pages");
            sb.AppendLine("\t{");
            DirectoryInfo di = new DirectoryInfo(BaseDir);
            GetClasses(di, sb, 2, "~/");
            sb.AppendLine("\t}");
            return sb.ToString();
        }

        public static void GetClasses(DirectoryInfo di, StringBuilder sb, int ident, string curDir)
        {
            string identString = "";
            for (int i = 0; i < ident; i++) identString += "\t";
            if (di.GetFiles("*.ascx", SearchOption.AllDirectories).Length > 0)
            {
                foreach (DirectoryInfo diCh in di.GetDirectories())
                {
                    if (diCh.GetFiles("*.ascx", SearchOption.AllDirectories).Length > 0)
                    {
                        int nextIdent = ident + 1;
                        string nextDir = String.Concat(curDir, diCh.Name.ToLower(), "/");
                        sb.Append(identString);
                        sb.AppendLine(String.Format("public static class {0}", diCh.Name));
                        sb.Append(identString);
                        sb.AppendLine("{");
                        foreach (FileInfo fi in diCh.GetFiles("*.ascx", SearchOption.TopDirectoryOnly))
                        {
                            sb.Append(identString);
                            bool NAV = true;
                            using (StreamReader sr = new StreamReader(fi.FullName))
                            {
                                while (!sr.EndOfStream)
                                {
                                    string line = sr.ReadLine();
                                    if (line.Contains("<%--NONAV--%>"))
                                    {
                                        NAV = false;
                                        break;
                                    }
                                }
                            }
                            if(NAV)
                            {
                                sb.AppendLine(String.Format("\tpublic const string {0} = \"{1}\";", Path.GetFileNameWithoutExtension(fi.Name), String.Concat(nextDir, fi.Name)));                            
                            }
                        }
                        GetClasses(diCh, sb, nextIdent, nextDir);
                        sb.Append(identString);
                        sb.AppendLine("}");
                    }
                }
            }
        }
#>

The following line resolves relative path that points to web directory where user controls should be searched:

string BaseDir = Host.ResolvePath(@"..\Auto_generating_strong_typed_navigation_class");

In this case The directory name is Auto_generating_strong_typed_navigation_class, and it resides one level above the directory where template file itself is located.

So if the solution is handed to another developer and he is running solution in different directory, he does not have to change anything, because relative structure will be the same.

Ok lets create some directory structure with user controls inside it:

image

Now run code generation tool clicking on the "Run Custom Tool" menu item:

image

and now lets see what we have

Class diagram:

image

and code:

namespace Devarchive_Net.Navigation
{
    public static class Pages
    {
        public static class Controls
        {
            public static class Common
            {
                public const string About = "~/controls/common/About.ascx";
                public const string ContactUs = "~/controls/common/ContactUs.ascx";
                public const string LeftSideMenu = "~/controls/common/LeftSideMenu.ascx";
                public const string MainMenu = "~/controls/common/MainMenu.ascx";
            }
            public static class Modules
            {
                public static class Admin
                {
                    public const string SomeControl = "~/controls/modules/admin/SomeControl.ascx";
                    public const string UserGroupManagement = "~/controls/modules/admin/UserGroupManagement.ascx";
                    public const string UserManagement = "~/controls/modules/admin/UserManagement.ascx";
                }
                public static class DataAnalysis
                {
                    public const string DataConfiguration = "~/controls/modules/dataanalysis/DataConfiguration.ascx";
                }
                public static class Profile
                {
                    public const string UserProfile = "~/controls/modules/profile/UserProfile.ascx";
                }
            }
        }
    }

}

Now we can access all the information the following way:

image

image

So isn't this great !

Now let's summarize, what if we moved the page or renamed it ? - After re-running code generation we will not be able to compile the solution, this is what we need - not to be informed about exceptions at run-time.

Is this code :

MenuItem childItem = GetItem(
    Resources.MainMenuItems.ChangePassword,
    UrlBuilder.GetOpenTabLink(
Resources.MainMenuItems.ChangePassword,
Pages.Modules.Admin.Windows.ChangePasswordDialog, false, true),
    "",
    "m_3_4");
item.ChildItems.Add(childItem);

Better then this code:

MenuItem childItem = GetItem(
    "Change Password",
    UrlBuilder.GetOpenTabLink(
"Change Password", 
"~/Modules/Admin/Windows/ChangePasswordDialog.ascx", false, true),
    "",
    "m_3_4");
item.ChildItems.Add(childItem);

I definitely think yes.

One more thing to mention is if by some reason you don't want to include path of some ascx file into Navigation class, just paste anywhere in the control the folloving marker:

<%--NONAV--%>

you can see from template code that it parses the control's texts and if finds the marker, ignores and does not generate constant for it.

 

Of course you can go farther and create full blown navigation framework generator, I just thought I needed just this in my case. Later I added the functionality to update control definitions also in database.

I used BasePath variable to find and load web.config file, retrieve ConnectionString, connect to the database, and update/delete entries in it based on NONAV marker. Also in my solution I changed sample code demonstrated here to return page identifier (integer constant) rather then string path which I am writing to db. I also made the GetClasses method more generic and changed it to accept BaseClassName, ReturnType, Extension and some more parameters, that affect what is name of generated root class, what is return type(string, guid, integer, string virtual path), what SP is responsible for DB updates, what is the extension of files we are generating class for. So I am quite happy - JS, HTML, ASPX, ASCX files have corresponding classes with strong-typed data. The sample code does not contain that later changes, but I am sure you got the idea, so additional changes is up to you.

You can download source code on this link.

Technorati Tags: ,

5 comments:

Oleg Sych said...

Thanks for a great post, Kirill.

With a little tweak, you can also eliminate your last hard-coded path:

<#@ template hostspecific="True" #>
<#
string BaseDir = Host.ResolvePath("RelativePath");
#>

Here RelativePath is a path relative to the location of the template file. More on this here

Kirill Chilingarashvili said...

Wow thanks Oleg for a great suggestion, this is what I was searching for.
I will give it a try!

Kirill Chilingarashvili said...

I updated the post to reflect changes for resolving relative path for BaseDir variable.
Thanks.

Dave Transom said...

That's pretty nice Kirill. I actually posted something similar using a BuildProvider on my blog yesterday, _then_ saw your article on dotnetkicks :)

I'm going to have a bit of a play and see what I like better.

Good work!

Kirill Chilingarashvili said...

Thank you Dave,

I read your blog post and it was quite useful and interesting for me because I have not tried Build Providers yet, and definitely will star the article. The fact that we wrote the articles with similar goal in mind proves that this approach is needed and desired for some scenarios.

Actually I am very interesting about navigation patterns and frameworks for ASP.NET web apps. I tried a lot of approaches, using differnet DB, XML, sitemap-based navigation. With or without sitemap providers. I even was exited one time with workflow-based page flow in Web Client Software Factory, and tried to create something like this with more generic usage. After I saw first time channel9 video about DSL Tools, I was really exited, I supposed how it can be used for quickly assemblying navigation model with flows, permissions etc. Who said that all navigation frameworks should be dynamic > DSL could help create static definitions for pages with transitions between them. But this is still my future wish :) - to create complex code generator, first we have to own a set of reusable and time-proven API to automate its generation.