Monday, April 21, 2008

ASP.NET page preprocessing

One of the things we love to do is remove tedium from code. That subject will probably be the topic of this post, and many of our posts in the future I'm sure, as we've done a lot of things in that department. ASP.NET is lacking in a number of areas, as I'm sure most of you know. It's "strong point" is supposed to be its declarative nature--but that often leads to very difficult to test pages. Not only that, but it's declarative syntax leaves much to be desired. My primary complaint is with its verbosity. There's nothing simple about ASP.NET syntax. Here's an example:

<asp:HyperLink runat="server" ID="_lnkForgot" NavigateUrl='~/ForgotPassword.aspx' Text='<%$ Resources: PageStrings, Login_lnkForgot_Text %>' />

Now, let's complain a little. First, we've got that runat="server" tag. Who's idea was that? I mean, I understand why it's there, but how often are you going to want <asp:Anything> to be sent to the client in that form? Wouldn't it make more sense to just ASSUME that <anything:Anything> was runat="server" and just allow us to specify runat="client" and strip that attribute when rendering if we really want to send that to the client?

Next, we've got the ID tag. I don't really have a problem with that, it's implemented just fine. Then there's the NavigateUrl. Notice the ~. I love that. It's something that ASP.NET did right. It makes it a breeze to make pages location agnostic... unless of course you want to use a non-asp control. Then you've got to use this lovely syntax:

<img src='<%= ResolveClientUrl("~/Images/img.gif") %>' />

Ah yes, that just screams beautiful. Now would it have really been that difficult to replace ~ with ApplicationPath and let ~~ by as ~ in attributes?

OK, let's move on to my favorite subject--Globalization/Localization. ASP.NET 2.0 has come a long way on this front. It's much easier to localize your pages the way ASP.NET 2.0 wants you to than the way ASP.NET 1.0 wanted you to, especially with the new meta:resourcekey attribute and automatic generation of resources. Unfortunately, it's still not as simple and elegant as it could be. If you don't want to use the meta:resource attribute (because you have a shared string) then you have to use the lovely Resources: syntax seen up above, and you have to manually create your resx file, and manually name and fill in your default locale value. If you want to localize a literal, you get to use wonderfully verbose <asp:Localize> control.

Another complaint that isn't demonstrated in the example is with Databinding. The databinding syntax is pretty clean, I don't have any complaints about that, but its implementation is a bit poor, mainly because of its use of uncached reflection. It's been a while since I benchmarked it, but if my memory serves me (and often it doesn't) then it took about 30% longer to databind using the reflexive <%# Field %> syntax than it did to use <%# ((Class)Container.DataItem).Field %>. Who wants to write all of the latter, especially with autocomplete in aspx pages being flaky at best?

So how could ASP.NET be less verbose and more elegant? How could WE make it better without being able to extend the many internal and sealed classes that make up the webforms engine?

Preprocessing!

Here's an overview of what you do.
  1. Create a generator that extends Microsoft.CustomTool.BaseCodeGeneratorWithSite for .aspx files and do your preprocessing here.
  2. Create a wizard that extends Microsoft.VisualStudio.TemplateWizard.IWizard to set up a file hiearchy like this:
    • Page.myaspx
      • Page.aspx
        • Page.aspx.cs
        • Page.aspx.designer.cs
  3. Create an ItemTemplate for your item that contains these files and sets your custom tool to the generator you created in step one.
  4. Add a new myaspx to your project and make sure you don't ever modify the aspx directly (this takes some getting used to)
So what are some things you could filter/generate/preprocess?
  • Add runat="server" tags to all <anything:anything> nodes that don't have it.
  • Replace "~/blah" with an appropriate call to ResolveClientUrl
  • Do some localization magic

<asp:HyperLink ID="_lnkForgot" NavigateUrl='~/ForgotPassword.aspx' Text="$:Forgot your Username or Password?" />

This is actually the markup that we wrote to generate the markup at the beginning of this post. See the $:? That tells the generator to create a resource in PageStrings.resx with the format Page_ID_Property that has the value that appears after the $:. You never need to touch that resx file. You can just write your markup, define your strings, and not be interrupted, or even have to name your strings. If you want to name your strings, say something global. Just do "$Name: This is a string" and if you want to use it later just use "$Name".
  • Make databinding strongly typed. This requires a bit of help, as you can't easily infer the type being bound by just inspecting the source. Our solution was to add a DataType="Class" attribute to whatever the repeating control was. For example:


    <asp:Repeater ID="repeater" DataType="BoundClass">
    <ItemTemplate>
    <asp:Label ID="textBox" Text='<%#@Name%>' />
    </ItemTemplate>
    </asp:Repeater>

Notice the @ in the <%#%> tag. That gets replaced with ((BoundClass)Container.DataItem), so you can do things like <%# String.Format("{0} {1}", @FirstName, @LastName) %>

So that's how we made ASP.NET more manageable. Now, there are still several issues with ASP.NET WebForms that caused us to eventually switch to MonoRail, which also benefits from preprocessing, but I'll post on that later.


No comments: