Udostępnij za pośrednictwem


A new and improved ASP.NET MVC T4 template

Update: Please read this post for the newest and greatest.

A couple weeks ago, I blogged about using a Build provider and CodeDom to generate strongly typed MVC helpers at runtime.  I followed up a few days later with another version that used T4 templates instead, making it easier to customize.

And now I’m back with yet another post on this topic, but this time with a much simpler and improved approach!  The big difference is that I’m now doing the generation at design time instead of runtime.  As you will see, this has a lot of advantages.

Drawbacks of the previous runtime approach

Before we go and re-invent the wheel, let’s discuss what the issues with the runtime T4 approach were, and how this is solved by this new approach.

Complex configuration: to enable the runtime template, you had to add a DLL to your bin, modify two web.config files, and drop two T4 files in different places.  Not super hard, but also not completely trivial.  By contrast, with this new approach you just drop one .tt file at the root of your app, and that’s basically it.

No partial trust support: because it was processing T4 files at runtime, it needed full trust to run.  Not to mention the fact that using T4 at runtime is not really supported!  But now, by doing it at design time, this becomes a non-issue.

Only works for Views: because only the Views are compiled at runtime, the helpers were only usable there, and the controllers were left out (since they’re built at design time).  With this new approach, Controllers get some love too, because the code generated by the template lives in the same assembly as the controllers!

Let’s try the new T4 template with the Nerd Dinner app

Let’s jump right in and see this new template in action!  We’ll be using the Nerd Dinner app as a test app to try it on.  So to get started, go to https://nerddinner.codeplex.com/, download the app and open it in Visual Studio 2008 SP1.

Then, simply drag the T4 template (the latest one is on CodePlex) into the root of the NerdDinner project in VS.  And that’s it, you’re ready to go and use the generated helpers!

Once you’ve dragged the template, you should see this in your solution explorer:

image

Note how a .cs file was instantly generated from it.  It contains all the cool helpers we’ll be using!  Now let’s take a look at what those helpers let us do.

Using View Name constants

Open the file Views\Dinners\Edit.aspx.  It contains:

 <% Html.RenderPartial("DinnerForm"); %>

This ugly “DinnerForm” literal string needs to go!  Instead, you can now write:

 <% Html.RenderPartial(MVC.Dinners.Views.DinnerForm); %>

Though it’s wordier, note that you get full intellisense when typing it.

Now open Views\Dinners\EditAndDeleteLinks.ascx, where you’ll see:

 <%= Html.ActionLink("Delete Dinner", "Delete", new { id = Model.DinnerID })%>

Here we not only have a hard coded Action Name (“Delete”), but we also have the parameter name ‘id’.  Even though it doesn’t look like a literal string, it very much is one in disguise.  Don’t let those anonymous objects fool you!

But with our cool T4 helpers, you can now change it to:

 <%= Html.ActionLink("Delete Dinner", MVC.Dinners.Delete(Model.DinnerID))%>

Basically, we got rid of the two unwanted literal strings (“Delete” and “Id”), and replaced them by a very natural looking method call to the controller action.  Of course, this is not really calling the controller action, which would be very wrong here.  But it’s capturing the essence of method call, and turning it into the right route values.  And again, you get full intellisense:

 image

By the way, feel free to press F12 on this Delete() method call, and you’ll see exactly how it is defined in the generated .cs file.  The T4 template doesn’t keep any secrets from you!

Likewise, the same thing works for Ajax.ActionLink.  In Views\Dinners\RSVPStatus.ascx, change:

 <%= Ajax.ActionLink( "RSVP for this event",
                     "Register", "RSVP",
                     new { id=Model.DinnerID }, 
                     new AjaxOptions { UpdateTargetId="rsvpmsg", OnSuccess="AnimateRSVPMessage" }) %>

to just:

 <%= Ajax.ActionLink( "RSVP for this event",
                     MVC.RSVP.Register(Model.DinnerID),
                     new AjaxOptions { UpdateTargetId="rsvpmsg", OnSuccess="AnimateRSVPMessage" }) %>

You can also do the same thing for Url.Action().

Even the controller gets a piece of the action

As mentioned earlier, Controllers are no longer left out with this approach.

e.g. in Controllers\DinnersController.cs, you can replace

 return View("InvalidOwner");

by

 return View(MVC.Dinners.Views.InvalidOwner);

But to make things even more useful in the controller, you can let the T4 template generate new members directly into your controller class.  To allow this, you just need to make you controller partial, e.g.

 public partial class DinnersController : Controller {

Note: you now need to tell the T4 template to regenerate its code, by simply opening the .tt file and saving it.  I know, it would ideally be automatic, but I haven’t found a great way to do this yet.

After you do this, you can replace the above statement by the more concise:

 return View(View_InvalidOwner);

You also get to do some cool things like we did in the Views.  e.g. you can replace:

 return RedirectToAction("Details", new { id = dinner.DinnerID });

by

 return RedirectToAction(MVC.Dinners.Details(dinner.DinnerID));

 

How does the T4 template work?

The previous runtime-based T4 template was using reflection to learn about your controllers and actions.  But now that it runs at design time, it can’t rely on the assembly already being built, because the code it generates is part of that very assembly (yes, a chicken and egg problem of sort).

So I had to find an alternative.  Unfortunately, I was totally out of my element, because my expertise is in the runtime ASP.NET compilation system, while I couldn’t make use of any of it here!

Luckily, I connected with a few knowledgeable folks who gave me some good pointers.  I ended up using the VS File Code Model API.  It’s an absolutely horrible API (it’s COM interop based), but I had to make the best of it.

The hard part is that it doesn’t let you do simple things that are easy using reflection.  e.g. you can’t easily find all the controllers in your project assembly.  Instead, you have to ask it to give you the code model for a given source file, and in there you can discover the namespaces, types and methods.

So in order to make this work without having to look at all the files in the projects (which would be quite slow, since it’s a slow API), I made an assumption that the Controller source files would be in the Controllers folder, which is where they normally are.

As for the view, I had to write logic that enumerates the files in the Views folder to discover the available views.

All in all, it’s fairly complex and messy code, which hopefully others won’t have to rewrite from scratch.  Just open the .tt file to look at it, it’s all in there!

In addition to looking at the .tt file, I encourage you to look at the generated .cs file, which will show you all the helpers for your particular project.

Known issues

T4 file must be saved to regenerate the code

This was briefly mentioned above.  The T4 generation is done by VS because there is a custom tool associated with it (the tool is called TextTemplatingFileGenerator – you can see it in the properties).  But VS only runs the file generator when the .tt file changes.  So when you make code changes that would affect the generated code (e.g. add a new Controller), you need to explicitly resave the .tt file to update the generated code.  As an alternative, you can right click on the .tt file and choose “Run Custom Tool”, though that’s not much easier.

Potentially, we could try doing something that reruns the generation as part of a build action or something like that.  I just haven’t had time to play around with this.  Let me know if you find a good solution to this.

No refactoring support

This was also the case with the previous template, but it is worth pointing out.  Because all the code is generated by the T4 template, that code is not directly connected to the code it relates to.

e.g. the MVC.Dinners.Delete() generated method results from the DinnersController.Delete() method, but they are not connected in a way that the refactoring engine can deal with.  So if you rename DinnersController.Delete() to DinnersController.Delete2(), MVC.Dinners.Delete() won’t be refactored to MVC.Dinners.Delete2().

Of course, if you resave the .tt file, it will generate a MVC.Dinners.Delete2() method instead of MVC.Dinners.Delete(), but places in your code that call MVC.Dinners.Delete() won’t be renamed to Delete2.

While certainly a limitation, it is still way superior to what it replaces (literal strings), because it gives you both intellisense and compile time check.  But it’s just not able to take that last step that allows refactoring to work.

It is worth noting that using Lamda expression based helpers instead of T4 generation does solve this refactoring issue, but it comes with a price: less natural syntax, and performance issues.

Final words

It has been pretty interesting for me to explore those various alternative to solve this MVC strongly typed helper issue.  Though I started out feeling good about the runtime approach, I’m now pretty sold on this new design time approach being the way to go.

I’d be interested in hearing what others think, and about possible future directions where we can take this.

Comments

  • Anonymous
    June 17, 2009
    Hmm, maybe you could add a custom action to the compile that simply touches the TT file?

  • Anonymous
    June 17, 2009
    Nice article. Few bits I'll be using for sure. I was using extension methods for Redirect Links, sort of like: RedirectTo<UserController>(u => u.Index()); Same for working out a form url, or building my routes etc... Your way obviously reads more cleanly though. Definitely like the View("Something") fix. I hadn't done anything for that yet. Re the above comment, just add a pre build action: "C:Program FilesCommon FilesMicrosoft SharedTextTemplating1.2TextTransform" "$(SolutionDir)MyTemplate.tt" -out "$(SolutionDir)Output.cs" We also run it in our NAnt build scripts, pre compile.

  • Anonymous
    June 17, 2009
    This is a massive improvement. I'd tried the same but had been failing at the lack of reflection, I was going to look into the Common Compiler Infrastructure API to see if introspection rather than reflection would have been any more use but I don't need to now - so thanks!

  • Anonymous
    June 17, 2009
    Check my blog post for how to set up a pre-build step. http://melgrubb.spaces.live.com/blog/cns!A44BB98A805C8996!256.entry

  • Anonymous
    June 17, 2009
    The comment has been removed

  • Anonymous
    June 17, 2009
    I am glad to see that the design time approach is looking better.  After exploring this myself the design time approach feels better and works with some of the add-ins better than the runtime approach.

  • Anonymous
    June 17, 2009
    A fantastic Article .Is that possible to use the Sql Server for generating the front end and would that solve the problem ?

  • Anonymous
    June 17, 2009
    The design time approach is really cool.

  • Anonymous
    June 18, 2009
    well, isn't that deprecated with the help of strongly typed HTML helpers using lambda syntax out there (from contrib to google)? you can even make some for yourself, I might be wrong but can someone enlighten me why would I use these T4 instead of getting better HTML helpers? thanks

  • Anonymous
    June 18, 2009
    You should be able to right-click a .tt file in the solution manager and "run custom tool" instead of re-saving, to manually trigger regeneration.

  • Anonymous
    June 18, 2009
    cowgaR: Lambda based solutions do provide an alternative, but with several drawbacks:

  • The syntax is more complex. Personally I don't mind it, but I heard that from many people
  • It has some perf issues
  • It has no support for View Names In the end, use what works best for you!
  • Anonymous
    June 18, 2009
    Rick: yes, you can right click instead of Saving. In any case, it's still a manual step that would ideally be automated.

  • Anonymous
    June 18, 2009
    I have added the Mvc-CodeGen.tt file to the root of my MVC project (it is right in between the Global.asax and Web.config) but I keep getting this error: "Running transformation: Could not find the VS Project containing the T4 file. It needs to be at root of the project." I am sure that I am doing or missing something stupid but for the life of me, I can't see what it is. Thanks!

  • Anonymous
    June 18, 2009
    The comment has been removed

  • Anonymous
    June 18, 2009
    The comment has been removed

  • Anonymous
    June 19, 2009
    David, thank you very much for your offer but I think that I have found the problem. The MVC project in my solution is in a Solution Folder which seems to be the source of the problem. I made a copy of the project into a new blank solution, in preparation to send it to you. Of course, the T4 template started working. So I created a solution folder and moved the project to the solution folder. Attempting to run the template again caused the same error that I mentioned before about not being at the root of the project. By the way, I think what you have done is great and I can't wait to get it working.

  • Anonymous
    June 19, 2009
    The comment has been removed

  • Anonymous
    June 21, 2009
    Just to confirm. This T4 methods have same performance that HtmlHelpers? This approach is perfect! I will implement in my MVC applications!! Resave, and no refactor is a little bit. Thx

  • Anonymous
    June 21, 2009
    I founded a bug. If a have a controller AbcController with a method Abc() generated code creates a Abc class, with a method Abc(Member names cannot be the same as their enclosing type)

  • Anonymous
    June 22, 2009
    David, the fix worked and got rid of the project root error when using a solution folder. However, I ran into another issue like the Felipe reported above. I have a controller supertype that is named such that when a class is generated using the name of the supertype minus the word controller, it results in conflicts with the root namespace of the project: MVC Project Namespace: SomeName.Mvc Controller Supertype: SomeNameController Generated Code Contains Class Named: SomeName This causes conflicts elsewhere in the generated code. I can take care of this with renaming or moving the supertype declaration out of the Controllers folder, but I thought that I would mention it just the same. Still love the work - keep it up!

  • Anonymous
    June 22, 2009
    The comment has been removed

  • Anonymous
    June 22, 2009
    I am trying to get this to work in VS2010 Beta 1 + MVC (installed with the OOB MVC Installer). I get this error: "Running transformation: System.Runtime.Serialization.SerializationException: Type 'Microsoft.VisualStudio.CSharp.Services.Language.CodeModel.CEnumerator' in Assembly 'Microsoft.VisualStudio.CSharp.Services.Language, Version=10.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a' is not marked as serializable." After some investigation, the error seems to be propping up in this iteration: "foreach (CodeNamespace ns in GetNamespaces(item.FileCodeModel.CodeElements))" in the ProcessAllControllers method. The iteration simply fails with the above error. I can confirm this is not anything specific to my project. I tried this on NerdDinner and got the same error. Has anyone been able to get this working with VS2010 Beta 1? (I can post a full stack trace if needed.)

  • Anonymous
    June 22, 2009
    Rahul: I had not tried with VS2010, but I just did and I see the same thing as you. It looks like some kind of VS breaking change. I will contact the experts in that area to help investigate.

  • Anonymous
    June 22, 2009
    These are working great, nice work David. One comment on the organization. Is it feasible to organize the classes in a more hierarchal manner? e.g. MVC.Views.Shared.XYZ and MVC.Controllers.Home.Index() and so forth. I kept finding myself typing it this way and it seems more logical to me since the scope narrows after each dot. Just a thought. Either way, I love it so far and look forward to any enhancements you come up with.

  • Anonymous
    June 23, 2009
    John, the reasoning I made is that the controller is the root, in the sense that Actions and Views belong to a Controller (except for Shared views, which break this rule). But granted there are more than one right way of organizing this. I can certainly change it if there is demand :)

  • Anonymous
    June 23, 2009
    I don't see the implementation of the, return RedirectToAction(MVC.Dinners.Details(dinner.DinnerID)); I don't think is in the included template. Regards, Roberto

  • Anonymous
    June 23, 2009
    Roberto, make sure you make your controller class partial to get the RedirectToAction method.  This is mentioned in the post, but I guess it's easy to miss! :)

  • Anonymous
    June 25, 2009
    Thanks for the nice tool. An idea: How about spitting out the fully ~ qualified path to the view rather than just its name? This would help us avoid some mildly annoying (but handled) runtime FileNotFoundExceptions related to the default path resolution strategy when developing.

  • Anonymous
    June 26, 2009
    Hey David, I have a suggestion. When Controller isn´t partial class, we have another options:    public static class Example    {        public static void RedirectToAction(this Controller controller, ControllerActionCallInfo any)        {}    } The can use: this.RedirectToAction(MVC.MyController.Show("1"))

  • Anonymous
    June 26, 2009
    The comment has been removed

  • Anonymous
    June 26, 2009
    Jason: I fixed that character issue and posted the update on CodePlex (now version 2.0.01). I just went with a straight replacement to '_' for now.

  • Anonymous
    June 26, 2009
    The comment has been removed

  • Anonymous
    June 26, 2009
    ah well, judging just by a blog post name I read think I have an answer :)

  • Anonymous
    June 27, 2009
    The enumeration of EnvDTE.CodeElements seems busted in 2010, but the MSDN page for CodeElements shows using a 1-based for loop to access it.  Try changing GetNamespaces to the following: // Return all the CodeNamespaces in the CodeElements collection public static IEnumerable<CodeNamespace> GetNamespaces(CodeElements codeElements) {   List<CodeNamespace> spaces = new List<CodeNamespace>();   for (int i=0; i<codeElements.Count; i++) {   CodeNamespace possibleNamespace = codeElements.Item(i+1) as CodeNamespace;   if (possibleNamespace != null) spaces.Add(possibleNamespace);   }   return spaces; }

  • Anonymous
    June 27, 2009
    Chip: yes, there is a known issue in VS2010. I have changed the template to use a loop as you suggest. This actually applied to several places in the code. Anyway, it should work fine in VS2010 beta now. Please try the new drop on CodePlex (version 2.0.03). Thanks for you help!

  • Anonymous
    June 29, 2009
    The comment has been removed

  • Anonymous
    June 29, 2009
    Sajjad: fixed this issue in new build 2.1.00 (on CodePlex). Thanks!

  • Anonymous
    June 30, 2009
    thanks man, this is awesome stuff.

  • Anonymous
    July 01, 2009
    The comment has been removed

  • Anonymous
    July 13, 2009
    This is awesome...Kudos.  Is it possible to add in an "IgnoreOnRouteCreation" attribute to some parameters.  This would be useful when you are generating the rest of a url in javascript.