[Study Note] Maintainable MVC Series

注:随笔是2010年3月份写的,当时不知道怎么就保存成了草稿而一直没有发布。并没有完成整个系列,回过头来,我似乎也看不太明白了,现在(2013年6月)发布一下,算是纪念吧,不过,我把发布时间改成了2012年

[Maintainable MVC Series: Introduction]

这篇Introduction中最吸引我的是关于 web application 的架构。

PRESENTATION: Views, Controllers, Form Model, View Model, Handlers, Mappers, (Domain Model)

DOMAIN: Services, (Domain Model)

INFRASTRUCTURE: Domain Model, Repositories, Mappers, Data storage

如此的架构比我之前的那种 Model, DAL, BLL, WebUI——所谓“三层架构”要漂亮许多,相比之下,现在所写的代码似乎如三叶虫般丑陋并且低级。

 

2010-03-29 17:29

[Maintainable MVC Series: Inversion of Control Container - StructureMap]

其实之前一直没有搞明白什么是 Inversion of Control, IOC,中文似乎翻译做“反转控制”。至于 StructureMap,也只是听说。其实最早看到这个系列,似乎也正是因为这篇 StructureMap 的文章。

不过这篇文章主要是讲如何将 StructureMap 和 MVC 相融合,似乎并不是我想要的,顺手捎带着去看了一些关于 StructureMap 的内容。

看完这一篇之后,加上后续的目录,估计这一系列文章主要是介绍一些 ASP.NET Web Application 架构方面的内容,对我来说也算是一次系统的学习吧。

 

2010-03-30 23:20

[Maintainable MVC Series: View hierarchy]

Master view

mvc-view-hierarchy “无图无真相”,此图一出,解决了我之前对于 Master page 和其他页面之间,对于 css, javascript 一类文件引用的处理。在手头的项目里面,因为重复的引用,所以页面加载的时候往往会出现多个 css 或者是 javascript 文件。虽然似乎对页面的显示没有什么特别的副作用,但是想来会对加载的速度产生负面影响。

// 此处需要插入代码着色

<%@ Master Language="C#" Inherits="System.Web.Mvc.ViewMasterPage" %>
<%@ Import Namespace="ClientX.Website.Models"%>

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>Site title - <asp:ContentPlaceHolder ID="TitleContent" runat="server" /></title>
<% #if DEBUG %>
    <% Html.RenderPartial("CssDebug", ViewData.Eval("DataForCss")); %>
<% #else %>
    <% Html.RenderPartial("CssRelease", ViewData.Eval("DataForCss")); %>
<% #endif %>
</head>
<body>

    <% Html.RenderPartial("Header", ViewData.Eval("DataForHeader")); %>

    <% Html.RenderPartial("PostBackForm", ViewData.Eval("DataForPostBackForm")); %>

    <% Html.RenderPartial("Messages", ViewData.Eval("DataForMessages")); %>

    <div class="content">

        <asp:ContentPlaceHolder ID="MainContent" runat="server" />

    </div><!-- /content -->

    <% Html.RenderPartial("Footer", ViewData.Eval("DataForFooter")); %>

<% #if DEBUG %>
    <% Html.RenderPartial("JavascriptDebug", ViewData.Eval("DataForJavascript")); %>
<% #else %>
    <% Html.RenderPartial("JavascriptRelease", ViewData.Eval("DataForJavascript")); %>
<% #endif %>

</body>
</html>

 

If your site use multiple different layouts (1, 2 or 3 columns for example) it is advisable to have nested master pages. The top one still keeps the css, javascript and navigation, where the nested master pages will only contain the html that differs between the different layouts.

Page View

mvc-page-and-partial-views // 此处需要插入代码着色

<%@ Page Title="" Language="C#" MasterPageFile="~/Views/Shared/Site.Master" Inherits="System.Web.Mvc.ViewPage<OverviewViewModel>" %>
<%@ Import Namespace="ClientX.Website.Models"%>

<asp:Content ID="TitleContentPlaceHolder" ContentPlaceHolderID="TitleContent" runat="server">
    Page title
</asp:Content>

<asp:Content ID="MainContentPlaceHolder" ContentPlaceHolderID="MainContent" runat="server">

    <% Html.RenderPartial("Heading", Model.Heading); %>

    <div>
        <% Html.RenderPartial("OverviewSearchForm", Model.OverviewSearchForm); %>

        <% Html.RenderPartial("Pager", Model.Pager); %>

        <table>
            <% Html.RenderPartial("TableHeader", Model.TableHeader); %>
            <%foreach (TableRowViewModel item in Model.ItemList){
                Html.RenderPartial("TableRow", item);
            } %>
        </table>

        <% Html.RenderPartial("Pager", Model.Pager); %>
    </div>
</asp:Content>

 

strongly typed

Never to share a view model between multiple views. Each view has it’s own tailor made view model.

 

[Maintainable MVC Series: View Model and Form Model]

Every bit of data displayed in the view has a corresponding property in the View Model.

View Model mapper

public ActionResult ShowItem(int id)
{
    DomainModel item = repository.GetItem(id);

    ViewModel viewModel = mapper.MapToViewModel(item);

    return View("ShowItem", viewModel);
}

public ViewModel MapToViewModel(DomainModel domainModel)
{
    ViewModel viewModel = new ViewModel
    {
        Id = domainModel.Id,
        Name = domainModel.Name,
        Description = domainModel.Description
    };

    return viewModel;
}

AutoMapper

AutoMapper is a liberary with the purpose of mapping properties of a complex type to simple type like a view model.

Besides saving you of writing lots of code it also has test facilities to warn you if your target model (the view model) has new properties which have no counterpart in the source model (the domain model).

public ViewModel MapToViewModel(DomainModel domainModel)
{
    Mapper.CreateMap<DomainModel, ViewModel>();

    ViewModel viewModel = Mapper.Map<DomainModel, ViewModel>(domainModel);

    return viewModel;
}

Form Model

to keep things clear we only post form models.

 

public class ViewModel {

    public FormModel Data { get; set; }

    public string NameLabel { get; set; }

    public string DescriptionLabel { get; set; }

    public string CountryLabel { get; set; }

    public SelectList Countries { get; set; }

    public string CountryChooseOption { get; set; }

}

 

<% using(Html.BeginForm()) {%>
    <%= Html.AntiForgeryToken() %>
    <label>
        <%= Html.Encode(Model.NameLabel) %>:
        <%= Html.TextArea("Name", Model.Data.Name) %>
    </label>
    <label>
        <%= Html.Encode(Model.DescriptionLabel) %>:
        <%= Html.TextArea("Description", Model.Data.Description) %>
    </label>
    <label>
        <%= Html.Encode(Model.CountryLabel) %>:
        <%= Html.DropDownList("Country", Model.Countries, Model.CountryChooseOption) %>
    </label>
    <input type="submit" value="Save">
<% } %>

 

As you can see (?) the values of the input fields are populated from the form model, while representation data like the dropdown list items and labels come from the view model.

Posting the form model

A bonus of having all properties correspond to a form field is that all of them will be binded automatically by MVC. No need for custom binding here.

 

[AcceptVerbs(HttpVerbs.Post), ValidateAntiForgeryToken]
public ActionResult UpdateItem(int id, FormModel formModel)
{
    DomainModel item = repository.GetItem(id);

    item.Name = formModel.Name;
    item.Description = formModel.Description;

    repository.SaveItem(item);

    return RedirectToAction("UpdateItem", new{ id });
}

 

Form handler

The form handler handles updating the domain model from the form model.

CQRS (Command Query Responsibility Segregation)

View models and mapping for querying, form model and form handlers for command handling.

Validation

form model validation to make sure the posted data is what we want in terms of being required.

client-side validation is done easily.

Handling the first level protection of validating the form model can be done in the controller or in the form handler. Additional levels of validation are fed back to the controller by the form handler.

 

2010-03-31 19:41

[Maintainable MVC Series: Poor man’s RenderAction]

Html.RenderAction and Html.Action

Both of these methods allow you to call into an action method from a view and output the result of the action in place within the view.

Html.RenderAction will render the result directly to the Response (which is more efficient if the action returns a large amount of HTML)

Html.Action returns a string with the result

 

[ChildActionOnly] ChildActionOnlyAttribute indicates that this action should not be callable directly via the URL. It’s not required for an action to be callable via RenderAction.

  • Passing Values With RenderAction
  • Cooperating with the ActionName attribute
  • Cooperating with Output Caching

<%= Html.Action(“Menu”) %>

 

[ChildActionOnly]
public ActionResult Menu() {

    NavigationData navigationData = navigationService.GetNavigationData();
    MenuViewModel menuViewModel = mapper.GetMenuViewModel(navigationData);

    return PartialView(menuViewModel);
}

 

<% Html.RenderPartial(“Menu”, ViewData.Eval(“DataForMenu”)); %>

The attribute

[AttributeUsage(AttributeTarget.Class |AttributeTargets.Method, AllowMultiple = false)]

public sealed class DataForMenuAttribute : ActionFilterAttribute

{

    private readonly INavigationService navigationService;

    private readonly IViewModelMapper mapper;

 

    public DataForMenuAttribute() : this(ObjectFactory.GetInstance<INavigationService>(), ObjectFactory.GetInstance<IViewModelService>())

    {

    }

 

    public DataForMenuAttribute(INavigationService navigationService, IViewModelMapper mapper)

    {

        this.NavigationService = navigationService;

        this.mapper = mapper;

    }

    …

    public override void OnActionExecuting(ActionExecutingContext filterContext)

    {

        // Work only for GET request

        if ( filterContext.Request.Context.HttpContext.Request.RequestType != “GET” )

            return;

        // Do not work with AjaxRequests

        if ( filterContext.RequestContext.HttpContext.Request.IsAjaxRequest() )

            return;

   

        NavigationData navigationData = navigationService.GetNavigationData();

        MenuViewModel menuViewModel = mapper.GetMenuViewModel(navigationData);

 

        filterContext.Controller.ViewData[“DataForMenu”] = menuViewModel;

    }

}

Unit testing the attribute

[TestFixture]

public class DataForMenuAttributeTests

{

    private RhinoAutoMocker<DataForMenuAttribute> autoMocker;

    private DataForMenuAttribute attribute;

   

    private SomeController controller;

    private ActionExecutingContext context;

    private HttpRequestBase httpRequestMock;

    private HttpContextBase httpContextMock;

 

    [SetUp]

    public void SetUp()

    {

        StructureMapBootstrapper.Restart();

       

        autoMocker = new RhinoAutoMocker<DataFormenuAttribte>(MockMode.AAA);

        attribute = autoMocker.ClassUnderTest;

 

        controller = new SomeController();

        httpRequestMock = MockRepository.GenerateMock<HttpRequestBase>();

        httpContextMock = MockRepository.GenerateMock<HttpContextBase>();

        httpContextMock.Expect(x => x.Request).Repeat.Any().Return(httpRequestMock);

 

        context = new ActionExecutingContext(

                new ControllerContext(httpContextMock, new RouteData(), Controller),

                MockRepository.GenerateMock<ActionDescriptor>(),

                new Dictionary<string, object>() );

    }

 

    [Test]

    public void DataForMenuAttributeShouldNotSetViewDataForPostRequest()

    {

        // Arrange

        httpRequestMock.Expect(r => r.RequestType).Return(“POST”);

        // ACT

        attribute.OnActionExecting(context);

        // Assert

        Assert.That(controller.ViewData[“DataForMenu”], Is.Null);

    }

 

    [Test]

    public void DataForMenuAttributeShouldCallGetNavigationData()

    {

        // Arrange

        httpRequestMock.Expect(r => r.RequestType).Return(“GET”);

        httpRequestMock.Expect(r => r[“X-Requested-With”].Return(string.Empty));

 

        NavigationData navigationData = new NavigationData();

        autoMocker.Get<INavigationService>().Expect(x => x.GetNavigationData()).Return(navigationData);

 

        // Act

        attribute.OnActionExecuting(context);

       

        // Assert

        autoMocker.Get<INavigationService>().VerifyAllExpectations();

    }

 

    [Test]

    public void DataForMenuAttributeShouldCallGetMenuViewMode()

    {

        // Arrange

        httpRequestMock.Expect(r => r.RequestType).Return(“GET”);

        httpRequestMock.Expect(r => r[“X-Requested-With”]).Return(string.Empty);

       

        NavigationData navigationData = new NavigationData();

        MenuViewModel menuViewModel = new MenuViewModel();

        autoMocker.Get<INavigationService>().Expect(x => x.GetNavigationData()).Return(navigationData);       

        autoMocker.Get<IViewModelMapper>().Expect(x => x.GetMenuViewModel(navigationData)).Return(menuViewModel);

       

        // Act

        attribute.OnActionExecuting(context);

       

        // Assert

        autoMocker.Get<IViewModelMapper>().VerifyAllExpectations();

    }

 

    [Test]

    public void DataForMenuAttributeShouldSetMenuViewDataToGetMenuViewModelResult()

    {

        // Arrange

        httpRequestMock.Expect(r => r.RequestType).Return(“GET”);

        httpRequestMock.Expect(r => r[“X-Requested-With”]).Return(string.Empty);

 

        MenuViewModel menuViewModel = new MenuViewModel();

        autoMocker.Get<IViewModelMapper>().Expect(x => x.GetMenuViewModel(Arg<NavigationData>.Is.Anything)).Return(menuViewModel);

 

        // Act

        attribute.OnActionExecuting(context);

 

        // Assert

        Assert.That(controller.ViewData[“DataForMenu”], Is.EqualTo(menuViewModel));

    }

 

    [Test]

    public void ExampleControllerShouldHaveDataForMenuAttribute()

    {

        Assert.That(Attribute.GetCustomAttribute(typeof(ExamplerController), typeof(DataForMenuAttribute)), Is.Not.Null);

    }

}

 

Make sure you have this test for every controller that needs the attribute!

 

[Maintainable MVC Series: Post-Redirect-Get pattern]

Post-Redirect-Get pattern, PRG

ModelStateToTempDataAttribute

public class ModelStateToTempDataAttribute : ActionFilterAttribute

{

    public const string TempDataKey = “_MvcContrib_ValidationFailures_”;

   

    public override void OnActionExecuted(ActionExecutedContext filterContext)

    {

        ModelStateDictionary modelState = filterContext.Controller.ViewData.ModelState;

       

        ControllerBase controller = filterContext.Controller;

 

        if (filterContext.Result is ViewResult)

        {

           // If there are failures in tempdata, copy them to the modelstate

            CopyTempDataToModelState(controller.ViewData.ModelState, controller.TempData);

            return;

        }

 

        // If we’re redirecting and there are errors, put them in tempdata instead

       // (so they can later be copied back to modelstate)

        if ((filterContext.Result is RedirectToRouteResult || filterContext.Result is RedirectResult) && !modelState.IsValid)

        {

            CopyModelStateToTempData(controller.ViewData.ModelState, controller.TempData);

        }

    }

 

    private void CopyTempDataToModelState(ModelStateDictionary modelState, TempDataDictionary tempData)

    {

        if(!tempData.ContainsKey(TempDataKey))

        {

            return;

        }

       

        ModelStateDictionary fromTempData = tempData[TempDataKey] as ModelStateDictionary;

 

        if (fromTempData == null)

        {

            return;

        }

 

        foreach(keyValuePair<string, ModelState> pair in fromTempData)

        {

            if (modelState.ContainsKey(pair.Key))

            {

                modelState[pair.Key].Value = pair.Value.Value;

                foreach(ModelError error in pair.Value.Errors)

                {

                    modelState[pair.Key].Errors.Add(error);

                }

            }

            else

            {

                modelState.Add(pair.Key, pair.Value)

            }

        }

    }

 

    private static void CopyModelStateToTempData(ModelStateDictionary modelState, TempDataDictionary tempData)

    {

        tempData[TempDataKey] = modelState;

    }

}

The attribute only saves ModelState to TempData if there are validation errors. And if the next action returns a view, the ModelState is retrieved from TempData. TempData itself is a wrapper around Session-State in which objects are only present until the next request.

 

storing Session-State

  • InProc
  • StateServer
  • SQLServer
  • Custom
  • Off

 

[Maintainable MVC Series: Binding]

SmartBinder

 

public interface IFlteredModelBinder : IModelBinder

{

    bool IsMatch(Type modelType);

    new BindResult BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext);

}

 

public class SmartBinder : DefaultModelBinder

{

    private readonly IFilteredModelBinder[] filteredModelBinders;

 

    public SmartBinder(IFilteredModelBinder[] filteredModelBinders)

    {

        this.filteredModelBinders = filteredModelBinders;

    }

 

    public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)

    {

        foreach (var filteredModelBinder in filteredModelBinders)

        {

            if (filteredModelBinder.IsMatch(bindingContext.ModelType))

            {

                BindResult result = filteredModelBinder.BindModel(controllerContext, bindingContext);

                bindingContext.ModelState.SetModelValue(bindingContext.ModelName, result.ValueProviderResult);

                return result.Value;

            }

        }

        return base.BindModel(controllerContext, bindingcontext);

    }

}

 

public class BindResult

{

    public object Value { get; private set; }

    public ValueProviderResult ValueProviderResult { get; private set; }

    public BindResult(object value, ValueProviderResult valueProviderResult)

    {

        this.Value = value;

        this.ValueProviderResult = valueProviderResult ?? new ValueProviderResult(null, string.Empty, CultureInfo.CurrentCulture);

    }

}

Setting it up with StructureMap

// global.asax.cs

ModelBinders.Binders.DefaultBinder = ObjectFactory.GetInstance<SmaterBinder>();

 

public class BinderRegistry : Registry

{

    public BinderRegistry()

    {

        For<IFilteredModelBinder>().Add<EnumBinder<SomeEnumeration>>().Ctor<SomeEnumeration>().Is(SomeEnumeration.FirstValue);

        For<IFilteredModerBinder>().Add<EnumBinder<AnotherEnumeration>>().Ctor<AnotherEnumeration>().Is(AnotherEnumeration.FifthValue);

    }  

}

EnumBinder

public class EnumBinder<T> : DefaultModelBinder, IFilteredModelBinder

{

    private readonly T defaultValue;

    public EnumBinder(T defaultValue)

    {

        this.defaultValue = defaultValue;

    }

 

    #region IFilteredModelBinder mebers

    public bool IsMatch(Type modelType)

    {

        return modelType == typeof(T);

    }

    BindResult IFilteredModelBinder.BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)

    {

        T result = bindingContext.ValueProvider[bindingContext.ModelName] == null ? defaultValue : GetEnumValue(defaultValue, bindingContext.ValueProvider[bindingContext.Modelname].AttemptedValue);

        return new BindResult(result, null);

    }

    #endregion

 

    private static T GetEnumValue(T defaultValue, string value)

    {

        T enumType = defaultValue;

        if ( (!String.IsNullOrEmpty(value)) && (Contains(typeof(T), value)))

        {

            enumType = (T)Enum.Parse(typeof(T), value, true);

        }

        return enumType;

    }

 

    private static bool Contains(Type enumType, string value)

    {

        return Enum.GetName(enumType).Contains(value, StringComparer.OrdinalIgnoreCase);

    }

}

Testing the binder

 

public enum TestEnum

{

    ValueA,

    ValueB

}

[TextFixture]

public class EnumBinderTests

{

    private IFilteredModelBinder binder;

 

    [SetUp]

    public void SetUp()

    {

        binder = new EnumBinder<TestEnum>(TestEnum.ValueB)

    }

 

    [Test]

    public void IsMatchShouldReturnTrueIfTypeIsSameAsGenericType()

    {

        // Act

        bool isMatch = binder.IsMatch(typeof(TestEnum));

        // Assert

        Assert.That(isMatch, Is.True);

    }

 

    [Test]

    public void IsMatchShouldReturnFalseIfTypeIsNotSameAsGenericType()

    {

        // Act

        bool isMatch = binder.IsMatch(typeof(string));

        // Assert

        Assert.That(isMatch, Is.False);

    }

 

    [Test]

    public void BindModelShouldReturnEnumValueForWhichValueAsStringIsPosted()

    {

        // Arrange

        ControllerContext controllerContext = GetControllerContext();

        ModelBindingContext bindingContext = GetModelBindingContext(new ValueProviderResult(null, “ValueA”, null));

        // ACT

        BindResult bindResult = binder.BindModel(controllerContext, bindingContext);

        // Assert

        Assert.That(bindResult.Value, Is.EqualTo(TestEnum.ValueA));

    }

 

    [Test]

    public void BindModelShouldReturnDefaultValueIfNoValueIsPosted()

    {

        // Arrange

        ControllerContext controllerContext = GetControllerContext();

        ModelBindingContext bindingContext = GetModelBindingContext(null);

        // Act

        BindResult bindResult = binder.BindModle(controllerContext, bindingContext);

        // Assert

        Assert.That(bindResult.Value, Is.EqualTo(TestEnum.ValueB));

    }

 

    [Test]

    public void BindModelShouldReturnDefaultValueIfUnknowValueIsPosted()

    {

        // Arrange

        ControllerContext controllerContext = GetControllerContext();

        ModelBindingContext bindingContext = GetModelBindingContext(new ValueProviderResult(ull, “Unknow”, null));

        // Act

        BindResult bindResult = binder.BindModel(controllerContext, bindingContext);

        // Assert

        Assert.That(bindResult.Value, Is.EqualTo(TestEnum.ValueB));

    }

 

    [Test]

    public void BindModelShouldReturnDefaultValueIfDefaultValueAsStringIsPosted()

    {

        // Arrange

        ControllerContext controllerContext = GetControllerContext();

        ModelBindingContext bindingContext = GetModelBindingContext(new ValueProviderResult(null, “ValueB”, null));

        // Act

        BindResult bindResult = binder.BindModel(controllerContext, bindingContext);

        // Assert

        Assert.That(bindResult.Value, Is.EqualTo(TestEnum.ValueB));

    }

 

    private static ControllerContext GetControllerContext()

    {

        return new ControllerContext {

HttpContext = MockRepository.GenerateMock<HttpContextBase>()};

    }

 

    private static ModelBindingContext GetModelBindingContext(ValueProviderResult valueProviderResult)

    {

        ValueProviderDictionary dictionary = new ValueProviderDictionary(null)

        { {“enum”, valueProviderResult} };

        return new ModelBindingContext { ModelName = “enum”, ValueProvider = dictionary };

    }

}

 

[Maintainable MVC Series: Routing]

 

[Maintainable MVC Series: Providers]

 

[Maintainable MVC Series: Attributes]

 

[Maintainable MVC Series: Handling sessions]

 

[Maintainable MVC Series: Passing error and success message with TempData]

 

[Maintainable MVC Series: Testable and reusable Javascript]

posted on 2010-03-29 19:32  zhaorui  阅读(212)  评论(0编辑  收藏  举报

导航