[转载]ASP.NET MVC Best Practices (Part 1)

[转载]ASP.NET MVC Best Practices (Part 1) – Kazi Manzur Rashid’s Blog.

In this post, I will share some of the best practices/guideline in developing ASP.NET MVC applications which I have learned in the hard way. I will not tell you to use DI or Unit Test instead I will assume you are already doing it and you prefer craftsmanship over anything.

1. Create Extension methods of UrlHelper to generate your url from Route

Avoid passing the controller, action or route name as string, create extension methods of UrlHelper which encapsulates it, for example:

01 public static class UrlHelperExtension
02 {
03 public static string Home(this UrlHelper helper)
04 {
05 return helper.Content("~/");
06 }
07
08 public static string SignUp(this UrlHelper helper)
09 {
10 return helper.RouteUrl("Signup");
11 }
12
13 public static string Dashboard(this UrlHelper helper)
14 {
15 return Dashboard(helper, StoryListTab.Unread);
16 }
17
18 public static string Dashboard(this UrlHelper helper, StoryListTab tab)
19 {
20 return Dashboard(helper, tab, OrderBy.CreatedAtDescending, 1);
21 }
22
23 public static string Dashboard(this UrlHelper helper, StoryListTab tab, OrderBy orderBy, int page)
24 {
25 return helper.RouteUrl("Dashboard", new { tab = tab.ToString(), orderBy = orderBy.ToString(), page });
26 }
27
28 public static string Update(this UrlHelper helper)
29 {
30 return helper.RouteUrl("Update");
31 }
32
33 public static string Submit(this UrlHelper helper)
34 {
35 return helper.RouteUrl("Submit");
36 }
37 }

Now, You can use the following in your view:

1 <a href="<%= Url.Dashboard() %>">Dashboard</a>
2 <a href="<%= Url.Profile() %>">Profile</a>

Instead of:

1 <%= Html.ActionLink("Dashboard", "Dashboard", "Story") %>
2 <a href="<%= Url.RouteUrl("Profile")%>">Profile</a>

And in Controller I can use:

1 return Redirect(Url.Dashboard(StoryListTab.Favorite, OrderBy.CreatedAtAscending, 1))

Instead of:

1 return RedirectToAction("Dashboard", "Story", new { tab = StoryListTab.Favorite, orderBy = OrderBy.CreatedAtAscending, page = 1 });

Of course you can use the strongly typed version which takes the controller, method and the parameters of the future assembly or create your own to avoid the future refactoring pain, but remember it is not officially supported and might change in the future. You can also use the above with the strongly typed version, certainly “adding another layer of indirection” (Scott Ha favorite quote) has some benefits. Another benefit when writing Unit Test you will only deal with the RedirectResult rather than RediretResult and RedirectToRouteResult.

2. Create Extension Method of UrlHelper to map your JavaScript, Stylesheet and Image Folder

By default ASP.NET MVC creates Content, Scripts folder for these things, which I do not like, Instead I like the following folder structure so that I can only set static file caching  on the Assets folder in IIS instead of going to multiple folders:

Assets
+images
+scripts
+stylesheets

No matter what the structure is, create some extension method of UrlHelper to map these folders, so that you can easily refer it in your view and later on if you need to change the structure, you do not have to do massive find/replace. I would also recommend to create extension methods for those assets which are often refereed in your views. For example:

01 public static string Image(this UrlHelper helper, string fileName)
02 {
03 return helper.Content("~/assets/images/{0}".FormatWith(fileName));
04 }
05
06 public static string Stylesheet(this UrlHelper helper, string fileName)
07 {
08 return helper.Content("~/assets/stylesheets/{0}".FormatWith(fileName));
09 }
10
11 public static string NoIcon(this UrlHelper helper)
12 {
13 return Image(helper, "noIcon.png");
14 }

And when referring the assets you can use:

1 <link href="<%= Url.Stylesheet("site.css")%>" rel="stylesheet" type="text/css"/>

Instead of the default:

1 <link href="../../Content/Site.css" rel="stylesheet" type="text/css" />

3. Use Bootstrapper in Global.asax

I have already covered it in the past, basically if you are doing a lot things in Application_Start of global.asax e.g. Registering Routes, Registering Controller Factory, Model Binders, View Engine, starting application specific background services, create individual task for specific part, then do use Bootstrapper to execute those. This make your code lot more clean and testable. This will be also helpful when developing a portal kind of app in asp.net mvc where each module can have some startup initialization without affecting others. But if you are developing a small app where the above things will never be an issue you can surly go ahead with the default global.asax.

4. Do not make any hard dependency on the DI Container, use Common Service Locator

Do not clutter your code with any specific DI reference, instead use the Common Service Locator, it is an abstraction over the underlying DI and it has the support for all of the popular DI containers, so that you can replace the underlying DI without modifying your application code as each DI Container has some unique features over the others. Tim Barcz recently wrote a excellent post on this subject, how much obsessed we are with our favorite DI Container but I am not sure why he did not mention about it. The Common Service Locator has the support for most of the regular scenarios, but for specific case for example injecting dependency in already instantiated object which as per my knowledge StructureMap, Ninject and Unity supports you can call the static ServiceLocator.Current.GetInstance in the constructor instead of calling the underlying DI. And for those who do know, Common Service Locator is a joint effort of the DI Containers creators initiated by Jeremy D Miller.

Creating Controller Factory with Common Service Locator is very easy:

1 public class CommonServiceLocatorControllerFactory : DefaultControllerFactory
2 {
3 protected override IController GetControllerInstance(Type controllerType)
4 {
5 return (controllerType == null) ? base.GetControllerInstance(controllerType) : ServiceLocator.Current.GetInstance(controllerType) as IController;
6 }
7 }

I hope the MVCContrib guys will follow the same instead of creating separate Controller Factory for each Container.

5. Decorate your Action Methods with Proper AcceptVerbs Attribute

ASP.NET MVC is much more vulnerable comparing to Web Forms. Make sure the action methods that modifies the data only accepts HttpVerbs.Post. If security is too much concern use the ValidateAntiForgeryToken or you can use Captcha. Derik Whittaker has an excellent post as well as a screen cast on how to integrate reCaptcha with ASP.NET MVC application, which I highly recommend. (Side Note: Do not miss a single episode of DimeCasts.net, I have learnt a lot form those short screen casts). My rule of thumb is use HttpVerbs.Post for all data modification actions and HttpVerbs.Get for data reading operations.

6. Decorate your most frequent Action Methods with OutputCache Attribute

Use OutputCache attribute when you are returning the less frequent updated data, prime candidate may be your home page, feed etc etc. You can use it for both Html and Json data types. When using it, only specify the Cache Profile name, do not not specify any other thing, use the web.config output cache section to fine tune it. For example:

1 [AcceptVerbs(HttpVerbs.Get), OutputCache(CacheProfile = "Dashboard")]
2 public ActionResult Dashboard(string userName, StoryListTab tab, OrderBy orderBy, int? page)
3 {
4 }

And in web.config:

01 <system.web>
02 <caching>
03 <outputCacheSettings>
04 <outputCacheProfiles>
05 <clear/>
06 <!-- 15 Seconds -->
07 <add
08 name="Dashboard"
09 duration="15"
10 varyByParam="*"
11 location="Client"
12 />
13 </outputCacheProfiles>
14 </outputCacheSettings>
15 </caching>
16 </system.web>

7. Keep your controller free from HttpContext and its tail

Make sure your controller does not have to refer the HttpContext and its tail. it will make your life easier when unit testing your Controller. If you need to access anything from HttpContext like User, QueryString, Cookie etc use custom action filter or create some interface and wrapper and pass it in the constructor. For example, for the following Route:

1 _routes.MapRoute("Dashboard", "Dashboard/{tab}/{orderBy}/{page}", new { controller = "Story", action = "Dashboard", tab = StoryListTab.Unread.ToString(), orderBy = OrderBy.CreatedAtDescending.ToString(), page = 1 });

But the controller action methods is declared as:

1 [AcceptVerbs(HttpVerbs.Get), OutputCache(CacheProfile = "Dashboard"), UserNameFilter]
2 public ActionResult Dashboard(string userName, StoryListTab tab, OrderBy orderBy, int? page)
3 {
4 }

The UserNameFilter is responsible for passing the UserName:

01 public class UserNameFilter : ActionFilterAttribute
02 {
03 public override void OnActionExecuting(ActionExecutingContext filterContext)
04 {
05 const string Key = "userName";
06
07 if (filterContext.ActionParameters.ContainsKey(Key))
08 {
09 if (filterContext.HttpContext.User.Identity.IsAuthenticated)
10 {
11 filterContext.ActionParameters[Key] = filterContext.HttpContext.User.Identity.Name;
12 }
13 }
14
15 base.OnActionExecuting(filterContext);
16 }
17 }

[Update: Make sure you have decorate either the Action or the Controller with Authorize attribute, check the comments]

8. Use Action Filter to Convert to compatible Action Methods parameters

Use Action Filter to convert incoming values to your controller action method parameters, again consider the Dashboard method, we are accepting tab and orderBy as Enum.

1 [AcceptVerbs(HttpVerbs.Get), OutputCache(CacheProfile = "Dashboard"), StoryListFilter]
2 public ActionResult Dashboard(string userName, StoryListTab tab, OrderBy orderBy, int? page)
3 {
4 }

The StoryListFilter will be responsible to convert it to proper data type from route values/querystrings.

01 public class StoryListFilter : ActionFilterAttribute
02 {
03 public override void OnActionExecuting(ActionExecutingContext filterContext)
04 {
05 const string TabKey = "tab";
06 const string OrderByKey = "orderBy";
07
08 NameValueCollection queryString = filterContext.HttpContext.Request.QueryString;
09
10 StoryListTab tab = string.IsNullOrEmpty(queryString[TabKey]) ?
11 filterContext.RouteData.Values[TabKey].ToString().ToEnum(StoryListTab.Unread) :
12 queryString[TabKey].ToEnum(StoryListTab.Unread);
13
14 filterContext.ActionParameters[TabKey] = tab;
15
16 OrderBy orderBy = string.IsNullOrEmpty(queryString[OrderByKey]) ?
17 filterContext.RouteData.Values[OrderByKey].ToString().ToEnum(OrderBy.CreatedAtDescending) :
18 queryString[OrderByKey].ToEnum(OrderBy.CreatedAtDescending);
19
20 filterContext.ActionParameters[OrderByKey] = orderBy;
21
22 base.OnActionExecuting(filterContext);
23 }
24 }

You can also use the custom Model Binder for the same purpose. In that case you will have to create two custom Model Binders for each Enum instead of one action filter. Another issue with the Model Binder is once it is registered for a type it will always come into action, but action filter can be selectively applied.

9. Action Filter Location

If you need the same action filter to all of your controller action methods,  put it in the controller rather than each action method. If you want to apply the same action filter to all of your controller create a base controller and inherit from that base controller, for example the story controller should be only used when user is logged in and we need to pass the current user name in its methods, also the StoryController should compress the data when returning:

1 [Authorize, UserNameFilter]
2 public class StoryController : BaseController
3 {
4 }
5
6 [CompressFilter]
7 public class BaseController : Controller
8 {
9 }

But if the inheritance hierarchy is going more than 2 /3 level deep, find another way to apply the filters. The latest Oxite code has some excellent technique applying filters dynamically which I highly recommend to check.

10. Use UpdateModel Carefully

I do not want to repeat what Justin Etheredge has mentioned in his post, be careful and do not fall into that trap.

11.Controller will not contain any Domain logic

Controller should be only responsible for:

  • Validating Input
  • Calling Model to prepare the view
  • Return the view or redirect to another action

If you are doing any other thing you are doing it in a wrong place, it is rather the Model responsibility which you are doing in Controller. If you follow this rule your action method will not be more than 20 – 25 lines of code. Ian Cooper has an excellent post Skinny Controller Fat Model, do read it.

12. Avoid ViewData, use ViewData.Model

Depending upon the dictionary key will not only make your code hard to refactor, also you will have to write the casting code in your view. It is completely okay even you end up per class for each action method of your controller.  If you think, creating these kind of classes is a tedious job, you can use the ViewDataExtensions of the MVCContrib project, it has some nice extension for returning strongly typed objects, though you still have to depend upon the string key if you have more than one data type in ViewData Dictionary.

13. Use PRG Pattern for Data Modification

Tim Barcz, Matt Hawley, Stephen Walther and even The Gu has blogged this over here, here, here and here. One of the issue with this pattern is when a validation fails or any  exception occurs you have to copy the ModelState into TempData. If you are doing it manually, please stop it, you can do this automatically with Action Filters, like the following:

01 [AcceptVerbs(HttpVerbs.Get), OutputCache(CacheProfile = "Dashboard"), StoryListFilter, ImportModelStateFromTempData]
02 public ActionResult Dashboard(string userName, StoryListTab tab, OrderBy orderBy, int? page)
03 {
04 //Other Codes
05 return View();
06 }
07
08 [AcceptVerbs(HttpVerbs.Post), ExportModelStateToTempData]
09 public ActionResult Submit(string userName, string url)
10 {
11 if (ValidateSubmit(url))
12 {
13 try
14 {
15 _storyService.Submit(userName, url);
16 }
17 catch (Exception e)
18 {
19 ModelState.AddModelError(ModelStateException, e);
20 }
21 }
22
23 return Redirect(Url.Dashboard());
24 }

And the Action Filers

01 public abstract class ModelStateTempDataTransfer : ActionFilterAttribute
02 {
03 protected static readonly string Key = typeof(ModelStateTempDataTransfer).FullName;
04 }
05
06 public class ExportModelStateToTempData : ModelStateTempDataTransfer
07 {
08 public override void OnActionExecuted(ActionExecutedContext filterContext)
09 {
10 //Only export when ModelState is not valid
11 if (!filterContext.Controller.ViewData.ModelState.IsValid)
12 {
13 //Export if we are redirecting
14 if ((filterContext.Result is RedirectResult) || (filterContext.Result is RedirectToRouteResult))
15 {
16 filterContext.Controller.TempData[Key] = filterContext.Controller.ViewData.ModelState;
17 }
18 }
19
20 base.OnActionExecuted(filterContext);
21 }
22 }
23
24 public class ImportModelStateFromTempData : ModelStateTempDataTransfer
25 {
26 public override void OnActionExecuted(ActionExecutedContext filterContext)
27 {
28 ModelStateDictionary modelState = filterContext.Controller.TempData[Key] as ModelStateDictionary;
29
30 if (modelState != null)
31 {
32 //Only Import if we are viewing
33 if (filterContext.Result is ViewResult)
34 {
35 filterContext.Controller.ViewData.ModelState.Merge(modelState);
36 }
37 else
38 {
39 //Otherwise remove it.
40 filterContext.Controller.TempData.Remove(Key);
41 }
42 }
43
44 base.OnActionExecuted(filterContext);
45 }
46 }

The MVCContrib project also has this feature but they are doing it in a single class which I do not like, I would like to have more control which method to export and which to import.

14. Create Layer Super Type for your ViewModel and Use Action Filter to populate common parts.

Create a layer super type for your view model classes and use action filter to populate common things into it . For example the tiny little application that I am developing I need to know the User Name and whether the User is authenticated.

01 public class ViewModel
02 {
03 public bool IsUserAuthenticated
04 {
05 get;
06 set;
07 }
08
09 public string UserName
10 {
11 get;
12 set;
13 }
14 }

and the action filter:

01 public class ViewModelUserFilter : ActionFilterAttribute
02 {
03 public override void OnActionExecuted(ActionExecutedContext filterContext)
04 {
05 ViewModel model;
06
07 if (filterContext.Controller.ViewData.Model == null)
08 {
09 model = new ViewModel();
10 filterContext.Controller.ViewData.Model = model;
11 }
12 else
13 {
14 model = filterContext.Controller.ViewData.Model as ViewModel;
15 }
16
17 if (model != null)
18 {
19 model.IsUserAuthenticated = filterContext.HttpContext.User.Identity.IsAuthenticated;
20
21 if (model.IsUserAuthenticated)
22 {
23 model.UserName = filterContext.HttpContext.User.Identity.Name;
24 }
25 }
26
27 base.OnActionExecuted(filterContext);
28 }
29 }

As you can see that it not replacing the model, if it is previously set in the controller, rather it populates the common part if it finds it compatible. Other benefit is, the views that only depends the layer super type you can simply return View() instead of creating the model.

That’s it for today, I will post rest of the items tomorrow.

Stay tuned.

赞(0) 打赏
分享到: 更多 (0)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏