Hanselminutes.com: Rewriting a 5 year old VB.NET WebForms application as an ASP.NET Web Pages application with Razor
I'm planning on updating the Hanselminutes.com website to look more modern and look more like this newly redesigned blog. Until then, one of the things I wanted to try was to update the application with some simpler code. The Hanselminutes podcast website is running the same VB.NET WebForms application it was on the day I started the show in January of 2006 (almost 300 episodes ago!)
Sure, the app runs fine and there's no real reason to update it, but I wanted to see how quickly I could do it and if the code felt cleaner. I'll probably do a post where I update this .NET 1.1 WebForms app to .NET 4 and show how to maintain it. In this case, I'm updating it to Razor just because it sounded like fun. No other reason.
There's a few requirements I need to make sure I don't miss:
- I'd like a new URL scheme. Currently showid is the database, not the logical "show id." Rather than
- http://www.hanselminutes.com/default.aspx?showID=2
I'd like to see
http://www.hanselminutes.com/1 where 1 is what folks think of as Show #1.
- http://www.hanselminutes.com/default.aspx?showID=2
- I can't break any existing links, so I want to HTTP 301 redirect from the old style to the new style.
- I'll keep the look and feel now, but update it after.
- Folks shouldn't notice the difference.
- I need to use the existing wacky 2004 database, it's tables and stored procedures because all of Carl's existing backend administration systems feed into it.
But, it's such a basic application, it's basically a bunch of for loops. So, here's what I did tonight.
UPDATE: Check out the updates below as I was using inline SQL when there was no reason to! Very cool stuff in the Massive micro-ORM that I wasn't taking advantage of. Rob Conery has done a post as a result of my foolishness.
Importing the layout
I started in WebMatrix, although I could have just as easily used Visual Studio. I created a new "Starter Site." I took the existing ASP.NET WebForms application's Hanselminutes.master and copy/pasted it into the new ASP.NET Web Pages _SiteLayout.cshtml.
I then added a RenderBody() call where my WebForms application's master page had a ContentPlaceholder:
<asp:ContentPlaceHolder ID="ContentPlaceHolder1" runat="server">
</asp:ContentPlaceHolder>
I added in the existing CSS and Scripts and that got me here. A "hello world" equivalent:
Then, I copied the Default.aspx ASP.NET WebForms code into Default.cshtml. This ASPX file has lots of server side WebForms controls like this:
<asp:HyperLink ID="hPermalink" Font-Bold="True" Text="Permalink" runat="server"/>
But since Web Pages doesn't process any of those, they are literally ignored and sent across as if they were HTML markup. They aren't valid tags so the browser ignores them. That gets me here. More is laid out but there's no data behind it, of course.
Next, I need to get to the data.
Getting at the Data
I can get at the data in a number of ways, but I like lightweight and easy so I'm using Rob Conery's "Massive" micro-orm. It's 500 lines of code, so that's nice.
UPDATE: Rob Conery pointed out that everyone thinks that Massive and micro-ORMs like it require inline SQL. That they just turn table rows into objects. Turns out I was using Massive in an OK way, but not the easiest way. Note below as I've added before and after details.
There's a good discussion of WebMatrix.Data, WebMatrix.Data.StronglyTyped, Massive, and Simple.Data over at Mikesdotnetting. They are all very similar. Use the one that makes you happy. Also look at Dapper from Sam Saffron and friends and check out my Massive vs. Dapper Podcast.
The easiest page to do first is the Archives page. It's a for loop of links, so a good place to get started.
In WebForms the archives uses a DataGrid like this:
<asp:DataGrid ID="dgArchive" ForeColor="Black" GridLines="None" CellPadding="2" BackColor="#EFEFDA"
BorderWidth="1px" BorderColor="Khaki" Width="100%" runat="server" PagerStyle-Visible="false"
AutoGenerateColumns="False" ShowFooter="false" DataKeyField="ShowID" ShowHeader="False"
PageSize="999">
<FooterStyle BackColor="Tan"></FooterStyle>
<AlternatingItemStyle BackColor="#F4F7E0"></AlternatingItemStyle>
<Columns>
<asp:BoundColumn DataField="ShowNumber" Visible="True"></asp:BoundColumn>
<asp:HyperLinkColumn ItemStyle-ForeColor="Black" DataNavigateUrlField="ShowID" DataNavigateUrlFormatString="default.aspx?showID={0}" DataTextField="ShowTitle"/>
<asp:BoundColumn DataField="ShowID" Visible="False"></asp:BoundColumn>
<asp:BoundColumn DataField="DatePublished" HeaderText="Date" DataFormatString="{0:yyyy-MMM-dd}">
</asp:BoundColumn>
</Columns>
</asp:DataGrid>
There's some color alternating going on, hard-coded colors, kind of cheesy, and some columns for the data. It's a half-decade old. I didn't write it. But as developers we inherit old code all the time.
The Massive ORM needs a connection string in the web.config, so I'll put one there.
<connectionStrings>
<add name="hanselminutes"
connectionString="Data Source=yada yada yada"
providerName="System.Data.SqlClient" />
</connectionStrings>
We've got a table called "Shows," so I need to let Massive know about it.
using System;
using System.Collections.Generic;
using System.Web;
public class Shows : DynamicModel
{
//you don't have to specify the connection - Massive will use the first one it finds in your config
public Shows():base("hanselminutes")
{
PrimaryKeyField = "ShowID";
}
}
Next, I'll reuse the same SQL Query used in 2006, except now with Massive. I'll just see if I can get the titles first with inline SQL.
@{
dynamic tbl = new Shows();
var shows = tbl.Query(@"SELECT ShowID, DatePublished, ShowTitle, Description, Enabled, ShowNumber
FROM Shows
WHERE Enabled = 1
ORDER BY DatePublished DESC");
foreach(var show in shows) {
@show.ShowTitle<br/>
}
}
Cool, that works. But I've got inline SQL here and it's, well, inline SQL. Rob points out that Massive will let you use the Jon Skeet-designed named parameter syntax such that I can just do this and the same result as the inline SQL! I'm such a goof for forgetting this.
show = tbl.Find(Enabled: 1, orderby: "DatePublished DESC");
Ok, just to be clear, here is the whole thing again, except with smarter use of Massive.
@{
dynamic tbl = new Shows();
show = tbl.Find(Enabled: 1, orderby: "DatePublished DESC");
forch(var show in shows)
{
@show.ShowTitle<br/>
}
}
Is nice, yes? I'll flesh out the table. Yes, a <table/>. Cause, it's, like, a table.
<table id="archives">
@foreach(var show in shows) {
<tr>
<td>@show.ShowID</td>
<td><a href="/@show.ShowNumber">@show.ShowTitle</a></td>
<td>@show.DatePublished.ToString("yyyy-MMM-dd")</td>
</tr>
}
</table>
Notice the href="" for the show is the ShowNumber, not the ShowID. It doesn't point anywhere yet, but I'll figure it out later.
Before they were doing some server side alternating color stuff. I need to update the CSS at some point, but since I'm redesigning soon, I'll just do it with jQuery on the client side, it's easier then the server-side if and gets the same result. Again, I'll change the template when I do the redesign.
<script type="text/javascript">
$(function() {
$('#archives tr:odd').css('backgroundColor','#EFEFDA');
$('#archives tr:even').css('backgroundColor','#F4F7E0');
});
</script>
Now I've got this page done, without the links hooked up. Nice and easy.
Here is the whole Archives page so far:
@{
Layout = "~/_SiteLayout.cshtml";
Page.Title = "The complete Hanselminutes podcast archive";
dynamic tbl = new Shows();
var shows = shows = tbl.Find(Enabled: 1, orderby: "DatePublished DESC");
}
<table id="archiveTable" width="100%">
@foreach(var show in shows) {
<tr>
<td>@show.ShowNumber</td>
<td><a href="/@show.ShowNumber">@show.ShowTitle</a></td>
<td>@show.DatePublished.ToString("yyyy-MMM-dd")</td>
</tr>
}
</table>
<script type="text/javascript">
$(document).ready(function() {
$('#archiveTable tr:odd').css('backgroundColor','#EFEFDA');
$('#archiveTable tr:even').css('backgroundColor','#F4F7E0');
});
</script>
I can refactor the data access into a helper class and generally tidy up, but you get the idea.
On the home page I want to get the most recent show first using using inline SQL, that is, the LAST show:
lastShow = tbl.Single(@"SELECT Top 1 ShowID, DatePublished, ShowTitle, Description, Enabled, ShowNumber
FROM Shows
WHERE Enabled = 1
ORDER BY DatePublished DESC").First();
And then again using Massive's syntax:
lastShow = tbl.Single(Enabled: 1, orderby: "DatePublished DESC");
At this point, it's easy to setup the home page by just sprinkling some Razor could around the original HTML template. I'm replacing <asp:Label> and <asp:Literal> with @lastShow.ShowID and things like this. Here I'm calling an existing Stored Proc and getting the filename and location then building an HTML5 <audio> element for all the MP3s.
@{
var files = tbl.Query("GetFilesByShowID @0", lastShowID);
foreach(var file in files) {
var filename = file.WebFilePath + file.FileName;
if (filename.EndsWith(".mp3")) {
<audio width='200' height='30' controls='controls' preload='auto'>
<source type='audio/mp3' src='@filename' />
<!-- Flash fallback for non-HTML5 browsers without JavaScript -->
<object width='320' height='240' type='application/x-shockwave-flash' data='flashmediaelement.swf'>
<param name='movie' value='flashmediaelement.swf' />
<param name='flashvars' value='controls=true&file=@filename' />
</object>
</audio>
}
}
}
Then, just 20 minutes of changing a bunch of asp:Controls to @lastShow.Something syntax and I was on my way. It's not pretty, but it's identical in functionality to the existing WebForms version.
Changing URLs
The only thing left is to make it so my URLs look like http://hanselminutes.com/1 instead of http://www.hanselminutes.com/default.aspx?showID=2.
There's lots of ways to do this, but the simplest is to take advantage of the URL rewriting that's built into IIS7. While i'm in there, I'll add a total of three rules.
Tiny URLs with just Number
This rule will take /123 and rewrite (not redirect) to /?showNumber=123. Because I'm redirecting such a "greedy" URL, it's important that I used the constraint of \d+ to just get numbers.
<rule name="number">
<match url="(\d+)" />
<action type="Rewrite" url="/?showNumber={R:0}" appendQueryString="false" />
<conditions logicalGrouping="MatchAny">
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
</conditions>
</rule>
Default Document to /
This rule will take /default.aspx and make it /.
<rule name="Default Document" stopProcessing="true">
<match url="(.*?)/?Default\.aspx$" />
<action type="Redirect" url="{R:1}/" />
</rule>
Remove .ASPX extensions
This rule will change things like /archives.aspx to just /archives.
<rule name="RewriteASPX">
<match url="(.*).aspx" />
<conditions logicalGrouping="MatchAll">
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
<add input="{REQUEST_FILENAME}" matchType="IsDirectory" negate="true" />
</conditions>
<action type="Redirect" url="{R:1}" />
</rule>
But what if someone visits a link with a database ID, NOT a ShowID, like
http://hanselminutes.com/default.aspx?showID=300
Given a URL like this, I'll look up the show by showID, get the correct show number and then just redirect to /280 (the right number, given an old database ID of 300):
Response.RedirectPermanent("/" + lastShow.ShowNumber);
With these small changes in the web.config, plus one line of code, I'll have the same URL structure as before with 301 Permanent Redirects to the new structure that the rest of the site uses.
I'll also add one last rule to remove www. from the front of the URL which will make tweeting shows by ID nicer:
Remove www. and canonicalize domain name
<rule name="Cannonical Hostname">
<match url="(.*)" />
<conditions logicalGrouping="MatchAll" trackAllCaptures="false">
<add input="{HTTP_HOST}" pattern="^hanselminutes\.com$" negate="true" />
</conditions>
<action type="Redirect" url="http://hanselminutes.com/{R:1}" />
</rule>
So when this is up, the current (as of this writing) show is http://hanselminutes.com/285, nice and clean.
I haven't put the new site live yet, but I'll do some more testing and do it this week. It took about two hours while watching Breaking Bad on NetFlix to convert this five page VB.NET ASP.NET WebForms application to ASP.NET Web Pages, and I'm well positioned now to make database changes as well as change the template for a redesign. More importantly, I had fun.
About Scott
Scott Hanselman is a former professor, former Chief Architect in finance, now speaker, consultant, father, diabetic, and Microsoft employee. He is a failed stand-up comic, a cornrower, and a book author.
About Newsletter
I think you're going to run into trouble with with sql
lastShow = tbl.Query(@"SELECT ShowID, DatePublished, ShowTitle, Description, Enabled, ShowNumber
FROM Shows
WHERE Enabled = 1 and ShowID=IDENT_CURRENT('Shows')
ORDER BY DatePublished DESC").First()
If the last show to be inserted is not Enabled (not published yet?) this query won't return anything. I think you're better to filter by Enabled=1 and order desc by DatePublished and take the top 1
With regards the following query:
lastShow = tbl.Query(@"SELECT ShowID, DatePublished, ShowTitle, Description, Enabled, ShowNumber FROM Shows WHERE Enabled = 1 and ShowID=IDENT_CURRENT('Shows') ORDER BY DatePublished DESC").First();
Would you not be better selecting the top 1 in the query. Eg:
lastShow = tbl.Query(@"SELECT TOP (1) ShowID, DatePublished, ShowTitle, Description, Enabled, ShowNumber FROM Shows WHERE Enabled = 1 and ShowID=IDENT_CURRENT('Shows') ORDER BY DatePublished DESC").First();
Keep up the good work
Also, great point on the SQL. If there was a disabled show that was newer that WOULD be a problem. I need the LAST show that is also ENABLED.
Steve - Sure, as I point out I can easily pull that into a model class. The point is that now I'm positioned to move this into MVC if I like. Perhaps I'll also see if I can take the same application from WebForms 1.1 and move it to HTML5 and WebForms 4.x.
How many rows do you expect to return from this?
tbl.Query(@"SELECT Top 1 ShowID, DatePublished, ShowTitle, Description, Enabled, ShowNumber
FROM Shows
WHERE Enabled = 1
ORDER BY DatePublished DESC")
Shoudln't you have written it like this?
tbl.Query(@"SELECT Top 1 ShowID, DatePublished, ShowTitle, Description, Enabled, ShowNumber
FROM Shows
WHERE Enabled = 1
ORDER BY DatePublished DESC").Single()
I'm guessing you would prefer to redirect to a home page or some other 404 in this case.
Marcel - Also a good point. I now check the single show access for a valid show and redirect home if none is found.
Jonathan - I'll do that. What should it look like?
"?showID=XXX" appears to be broken currently. Doesn't matter what number I put in there, it always redirects to the home page.
I feel the update pain also. I'm a consultant recently assigned to maintain and update (as possible) a clients .Net Web App that was built 10 years ago using 1.something. It was built by a classic asp developer who was brand new to .Net. Imagine my surprise when I looked at the Onload procedure for the first page in the app and found 3000 lines of code surrounded by one Try...Catch. That was it, all the code was in the Onload. Ouch!
Randy
I need to use the existing wacky 2004 database, it's tables and stored procedures...
OK, English nit-picking time: Did you really mean to say "it is tables and stored procedures"?
Or were you trying to say "its tables and stored procedures"? :)
It looks to me like the canonical hostname rewrite rule will remove any and all domain prefixes (i.e. not just www.mydomain.com but mobile.mydomain.com, ws.mydomain.com, etc.). But since I have not spent much hands-on time with IIS rewriting, I'm not certain of that. Can you confirm one way or the other?
Thanks!
Comments are closed.