模型-视图-提供器 模式

出处:http://msdn.microsoft.com/en-us/magazine/cc188690.aspx

引言

随着像Asp.Net和Windows窗体这样的用户界面创建技术越来越强大,让用户界面层做多于它本应做的事是很常见的。没有一个清晰的职责划分,UI层经常沦为一个包含实际上应属于程序其他层的逻辑的容器。有一个称为 模型(Model)-视图(View)-提供器(Presenter)(MVP)的设计模式,特别适合解决这个问题。为了表明我的观点,我将为Northwind数据库中的客户建一个遵循MVP模式的显示屏幕(display screen)。

为什么在UI层包含太多的逻辑是很糟糕的?在既不手动运行应用程序,也不维护丑陋的自动执行UI组件的UI运行者脚本(runner script)的情况下,位于应用程序UI层中的代码是非常难于调试的。虽然这本身就是一个很大的问题,一个更大的问题是在应用程序的公共视图之间会有大量的重复代码。当执行某一特定业务的功能在UI层的不同部分之间拷贝,通常很难找到好的可选重构方法。MVP设计模式使得将UI层中的逻辑和代码 重构为 更加易于测试的新型的、可重用的代码 更加容易。

图1演示了组成一个范例应用程序的主要层。注意对于UI和表现(Pesentation)有着各自的包(Package)。你可能会想它们是一样的,但是实际上项目中的UI层应该只包含各种不同的UI元素――窗体和控件。典型地,在一个Web窗体项目中是Asp.Net Web窗体、用户控件、服务器控件的集合;在Windows项目中,它是Windows 窗体、用户控件以及第三方库(Libraries)的集合。这一额外的层就是将显示和逻辑分隔开的层。在表现层,你拥有实际上实现UI行为的对象――诸如验证显示,从UI层收集用户输入 等等。

图1.应用程序构架

遵循MVP

如同你在 图2 中所见的,这个项目的UI相当标准。当页面加载时,屏幕将会显示一个包含Northwind数据库中所有客户的下拉框。如果你从下拉框中选择一个客户,页面会更新为这个客户的信息。通过遵循MVP设计模式,你可以从UI中将行为(Behavior)重构到它们自己的类中。图3显示了一个类图,它说明了参与其中的各个不同类之间的联系。

图2. 用户信息

图3. MVP类图

注意到提供器对于应用程序实际的UI层一无所知非常重要。它知道它可以同接口对话,但是它不知道也不关心接口的实现是什么。这提升了在完全不同的UI技术间提供器的重用。

我将使用测试驱动开发(TDD)创建客户界面的功能。代码4 演示了第一次测试的细节,我将通过这个测试来描述我期望在页面加载时观察到的行为。TDD让我每次关注于一个问题,仅编写可以让测试通过的代码,然后继续进行下面的工作。在测试中,我将会利用一个称为NMork2 的伪对象框架,它允许我创建接口的伪实现(mock implementation)。

代码4.第一个测试

[Test]
public void ShouldLoadListOfCustomersOnInitialize()
{
    mockery = new Mockery();
    ICustomerTask  mockCustomerTask = mockery.NewMock<ICustomerTask>();
    IViewCustomerView  mockViewCustomerView =
        mockery.NewMock<IViewCustomerView>();
    ILookupList  mockCustomerLookupList = mockery.NewMock<ILookupList>();

    ViewCustomerPresenter presenter =
        new ViewCustomerPresenter(mockViewCustomerView,mockCustomerTask);
           
    ILookupCollection mockLookupCollection =
        mockery.NewMock<ILookupCollection>(); 
    
    Expect.Once.On(mockCustomerTask).Method(
        "GetCustomerList").Will(Return.Value(mockLookupCollection));
    Expect.Once.On(mockViewCustomerView).GetProperty(
        "CustomerList").Will(Return.Value(mockCustomerLookupList));
    Expect.Once.On(mockLookupCollection).Method(
        "BindTo").With(mockCustomerLookupList);
                       
    presenter.Initialize();
}

在我的MVP实现中,我决定提供器将作为视图所要与之工作的依赖。通常,创建对象使之处于可以立刻进行工作的状态是一种好的做法。在这个应用程序中,表现层依赖于服务层,实际上由服务层调用领域功能(domain functionality)。因为这个需求,创建一个含有可以与服务类对话的接口的提供器也是有意义的。这样确保了一旦提供器创建好了,它就已经准备好做它需要做的所有工作了。我以创建两个特定的mocks作为开始:一个用于服务层,一个用于提供器将与协作的视图。

为什么使用mocks?单元测试的一个规则就是尽可能地隔离测试以便集中于某一特定的对象。在这个测试中,我只关心提供器所期待的行为。目前我并不关心view接口或者service接口的实际实现。我信任由这些接口定义的契约(contract),并且设置mocks去相应运作(behave)。这样确保了我的测试仅仅围绕着我对提供器所期望的行为,而不是它所依赖的任何东西。我期望的,在提供器的初始化方法被调用后所表现的行为如下:

首先,提供器应该调用一次服务层ICustomerTask对象(已经在测试中Mock了)的GetCustomerList方法。注意通过使用NMock,我可以模拟Mock的行为。以服务层来说,我想要返回一个ILookupCollection给提供器。然后,在提供器从服务层收到ILookupCollection以后,它可以调用集合的BindTo方法 并且向方法传递一个ILookupList方法的实现。通过使用NMockExpect。一旦我可以确定方法,如果提供器没有调用这个方法一次并且只一次,那么测试将会失败。

写完测试以后,我处于一个完全不可编译的状态。我将要做一些可能的最简单的事让测试通过。

让第一个测试通过

先写一个测试的好处之一是我现在有了一个我可以遵循的使得测试编译并最终通过的蓝图(这个测试)。第一个测试还有两个尚不存在的接口。这些接口是代码正确通过编译的第一个先决条件。我们将以IViewCustomerView的代码作为开始:

public interface IViewCustomerView {
    ILookupList CustomerList { get; }
}

这个接口暴露一个返回ILookupList接口实现的属性。我还没有ILookupList接口或是它的一个实现,就此而言。出于使测试通过的目的,我不需要一个显示的实现,所以我可以这样去创建ILookupList接口:

public interface ILookupList { }

ILookupList接口现在看上去相当的没用。我的目标是使得测试编译并且通过,并且这些接口满足测试的需要。现在是时候将焦点转移到我们实际要进行测试的对象上了――ViewCustomerPresenter。这个类现在还不存在,但是看下测试,你可以发现关于它的两个要点:它有一个既需要视图实现也需要服务实现作为依赖的构造函数,并且它有一个无返回值的初始化方法。代码5 演示了如何使测试通过编译:

代码5. 编译这个测试

public class ViewCustomerPresenter
{
    private readonly IViewCustomerView view;
    private readonly ICustomerTask task;

    public ViewCustomerPresenter(
        IViewCustomerView view, ICustomerTask task)
    {
        this.view = view;
        this.task = task;
    }

    public void Initialize()
    {
        throw new NotImplementedException();
    }
}

应该记得,为了让提供器有意义地工作,它需要获得它的所有依赖;这就是为什么传递视图和服务进去。我没有实现初始化方法,所以如果我运行这个测试我会得到一个NotImplementedException 异常。

如果我已经提到的,我不会盲目地对提供器进行编码;我已经知道,通过观察这个测试,在初始化方法被调用时,提供器应该显示出什么样的行为。这个行为的实现如下所示:

public void Initialize() {
    task.GetCustomerList().BindTo(view.CustomerList);
}

在这篇文章所附带的源代码中,在CustomerTask类(它实现了ICustomerTask接口)中有GetCustomerList方法的完整实现。然而,从实现和测试提供器的角度来说,我不需要知道是否有一个可以工作的实现。正是这种级别的抽象允许我在提供器类的测试中穿行。第一个测试现在处于可以编译并运行的状态。这证明了当提供器的初始化方法被调用,它将会以一种我在测试中所指定的方式与它所依赖的类型进行交互,并且最终,当这些依赖的具体实现注入到提供器中,我可以确定结果视图(ASPX页面)将会由客户列表所填充。

填充 DropDownList

迄今为止,我主要在处理接口以便将实际的实现细节抽象出来、将注意力集中在提供器上。现在是时候通过一种可测试的方式创建一些底层代码(plumbing),这些底层代码将最终允许提供器在Web页面上填充一个列表。完成这个工作的关键是将发生在LookupCollection类的BindTo方法中的交互。如果你看下 代码6 中LookupCollection类的实现,你将注意到它实现了IlookupCollection接口。这篇文章的源码含有附带的测试,用于创建LookupCollection类的功能。

代码6. LookupCollection类

public class LookupCollection : ILookupCollection
{
    private IList<ILookupDTO> items;

    public LookupCollection(IEnumerable<ILookupDTO> items)
    {
        this.items = new List<ILookupDTO>(items);
    }

    public int Count { get { return items.Count; } }

    public void BindTo(ILookupList list)
    {
        list.Clear();
        foreach (ILookupDTO dto in items) list.Add(dto);
    }
}

BindTo方法的实现值得特别注意。注意到在这个方法中,集合遍历了它自己的私有ILookupDTO 列表的实现。ILookupDTO是一个接口,它迎合了UI层的绑定下拉框。

public interface ILookupDTO {
    string Value { get; }  
    string Text { get; }
}

代码7 演示了测试lookup集合的BindTo方法的代码,这有助于解释LookupCollection和IlookupList之间所期望的交互。最后一行值得特别注意。在这个测试中,我期望在试图添加项目到列表之前,LookupCollection将会调用IlookupList实现的Clear方法。然后我期望Add方法在IlookupList上调用10次,并且LookupCollection将传递一个实现了ILookupDTO接口的对象,作为Add方法的一个参数。为了能够实际工作在一个Web项目中的控件上(比如一个下拉列表),你将需要创建一个IlookupList的实现,它知道如何与Web项目中的控件工作。

代码7 一个描述行为的测试

[Test]
public void ShouldBeAbleToBindToLookupList()
{
    IList<ILookupDTO> dtos = new IList;
    ILookupList mockLookupList = mockery.NewMock<ILookupList>();
           
    Expect.Once.On(mockLookupList).Method("Clear");
           
    for (int i = 0; i < 10; i++)
    {
        SimpleLookupDTO dto =
            new SimpleLookupDTO(i.ToString(),i.ToString());
        dtos.Add(dto);
        Expect.Once.On(mockLookupList).Method("Add").With(dto);
    }
           
    new LookupCollection(dtos).BindTo(mockLookupList);
}

这篇文章附带的源码中包含一个名为MVP.Web.Controls的项目。这个项目包含了我选择创建的用于完成解决方案的任何基于Web的控件或者类。为什么我要把代码放到这个项目中,而没有放在App_Code目录或者Web项目本身中?易测性。在没有手动运行应用程序或者使用某种类型的测试机器人自动操作UI的情况下,居于Web项目中的任何东西都是难于直接测试的。MVP模式允许我在一个较高的层次上考虑抽象,并且测试核心接口(IlookupList和ILookupCollection)的实现,而不需要手动地运行程序。我将在Web.Controls项目中添加一个新类,一个WebLookupList控件。代码8 演示了这个类的第一次测试:

代码8. WebLookupList 控件的第一次测试

[Test]
public void ShouldAddItemToUnderlyingList()
{
    ListControl webList = new DropDownList();           
    ILookupList list = new WebLookupList(webList);

    SimpleLookupDTO dto = new SimpleLookupDTO("1","1");
    list.Add(dto);
   
    Assert.AreEqual(1, webList.Items.Count);
    Assert.AreEqual(dto.Value, webList.Items[0].Value);
    Assert.AreEqual(dto.Text, webList.Items[0].Text);
}

测试中关键的部分在代码8中显示了。这个测试项目显然需要System.Web库的一个引用,以便它可以初始化DropDownList Web控件。看下这个测试,你应该看到WebLookupList类将会实现IlookupList接口。它也将把ListControl作为一个依赖。在System.Web.UI.WebControls命名空间中的两个最常见的ListControl的实现就是DropDownList和ListBox类了。代码8中的一个关键特色就是我确信WebLookupList正确的更新了Web ListControl的状态,它将职责委托给了这个Web ListControl。图9 显示了参与WebLookupList实现的类的类图。通过代码10,我可以满足WebLookupList控件第一次测试的需求。

代码10 WebLookupList 控件

public class WebLookupList : ILookupList
{
    private ListControl underlyingList;

    public WebLookupList(ListControl underlyingList) {
        this.underlyingList = underlyingList;
    }

    public void Add(ILookupDTO dto) {
        underlyingList.Items.Add(new ListItem(dto.Text, dto.Value));
    }
}

图9. WebLookupList 类

记得,MVP模式的要点之一是通过view接口的创建引入了各层之间的分离。提供器不知道某一视图的具体实现,以及它所要交互的IlookupList;它只知道它可以调用由这些接口所定义的任何方法。最终,WebLookupList是一个包装了ListControl并且将职责委托给了ListControl(一些定义在System.Web.UI.WebControls项目中的ListControls的基类)的类。在这些代码完成好了以后,我现在可以编译并运行WebLookupList控件的测试了,它应该可以通过。我可以为WebLookupList控件再添加一个测试来测试Clear方法的实际行为。

[Test]
public void ShouldClearUnderlyingList(){
    ListControl webList = new DropDownList();
    ILookupList list = new WebLookupList(webList);
   
    webList.Items.Add(new ListItem("1", "1"));
   
    list.Clear();
   
    Assert.AreEqual(0, webList.Items.Count);
}

我再次测试到,当WebLookupList类本身的方法被调用时,实际上会改变它底层的ListControl(DropDownList)的状态。WebLookupList现在完全拥有完成填充Web表单上一个DropDownList的特色了。现在是时候让我把所有东西都结合到一起,然后让客户列表填充这个Web页面的下拉框了。

实现View接口

因为我正在创建一个Web窗体前端(界面),将IViewCustomerView接口实现为一个Web窗体或者用户控件将是有意义的。出于这个专栏的目的,我将创建一个Web窗体。如同你在 图2 中所见到的,这个页面大概的样子已经创建好了。现在我只需要实现View接口。切换到ViewCustomers.aspx页面的后置代码中,我可以添加下面的代码,表示这个页面需要实现IViewCustomersView接口:

public partial class ViewCustomers : Page,IViewCustomerView

如果你看一下代码范例,你将会注意到Web项目和表现(Presentation)是两个完全不同的程序集。同样,表现项目没有包含对Web.UI项目的任何引用,进一步维持着层的分隔。另一方面,Web.UI项目必须包含一个对表现项目的一个引用,因为它包含了View接口和提供器。

通过选择实现IViewCustomerView接口,我们的Web页面现在需要实现由那个接口所定义的任何方法和属性。现在IViewCustomerView接口只有一个属性,这是一个返回任何实现了ILookupList接口的只读属性。我添加了一个对Web.Controls项目的引用,以便我可以初始化WebLookupListControl。这样做是因为WebLookupListControl实现了ILookupList接口,并且它知道如何(将工作)委托给实际的Asp.Net中的WebControls。看一下ViewCustomer页面的Aspx文件,你将会看到客户列表仅仅是一个简单的asp:DropDownList控件:

<td>Customers:</td>
<td><asp:DropDownList id="customerDropDownList" AutoPostBack="true"
        runat="server" Width="308px"></asp:DropDownList></td>
</tr>

这些已经就位了,我们可以立刻继续去实现满足IViewCustomerView接口实现的代码了:

public ILookupList CustomerList {
    get { return new WebLookupList(this.customerDropDownList);}
}

我现在需要在提供器上调用初始化方法,这个方法将触发它去做些实际的工作。为了完成这个,视图需要能够初始化提供器,以便它的方法可以被调用。如果你回头看下提供器,你将会记得它需要与视图和服务工作。ICustomerTask代表一个居于应用程序服务层中的一个接口。典型地服务层负责监管领域对象间的交互,以及将这些交互的结果转换成数据传递对象(DTOs),然后这些DTO对象从服务层传递到表现层,接着传递到UI层。然而,这里有一个问题,我规定提供器需要视图和服务的实现才能创建。

提供器实际的初始化将发生在Web页面的后置代码中。这是一个问题,因为UI项目不包含对服务层项目的引用。然而,表现层项目包含,它有一个对服务层项目的引用。这允许我通过在ViewCustomverPresenter中添加一个重载的构造函数来解决这个问题:

public ViewCustomerPresenter(IViewCustomerView view) : this(view, new CustomerTask()) {}

新的构造函数满足了提供器的需求:同时拥有视图和服务的实现,并且保持将UI层从服务层中分离出来。现在完成后置代码是很轻易的事情了:

protected override void OnInit(EventArgs e){
    base.OnInit(e);
    presenter = new ViewCustomerPresenter(this);
}

protected void Page_Load(object sender, EventArgs e){
    if (!IsPostBack) presenter.Initialize();
}

注意到初始化提供器的关键是:我利用了我新创建的重载构造函数,并且Web窗体将它本身作为一个实现了View接口的对象进行传递!

后置代码已经实现,现在我可以生成并运行应用程序了。Web页面上的DropDownList现在填充了客户名称列表,而在后置代码中不需要任何的数据绑定代码。不仅如此,曾经运行的各个小部分的测试最终协同工作了,确保了表现层构架将会如期望般运作。

我将通过演示显示一个在DropDownList中选中的客户信息,把我关于MVP的讨论联系起来。再一次,我通过写一个描述了我希望观察到的行为的测试作为开始(看 代码11)。

代码11. 最后一个测试

[Test]
public void ShouldDisplayCustomerDetails()
{
    SimpleLookupDTO lookupDTO = new SimpleLookupDTO("1","JPBOO");

    CustomerDTO dto = new CustomerDTO("BLAH", "BLAHCOMPNAME",
        "BLAHCONTACTNAME", "BLAHCONTACTTILE", "ADDRESS", "CITY",
        "REGION", "POSTALCODE", Country.CANADA, "4444444", "4444444");

    Expect.Once.On(mockViewCustomerView).GetProperty(
        "CustomerList").Will(Return.Value(mockCustomerLookupList));
    Expect.Once.On(mockCustomerLookupList).GetProperty(
        "SelectedItem").Will(Return.Value(lookupDTO));
    Expect.Once.On(mockCustomerTask).Method(
        "GetDetailsForCustomer").With(1).Will(Return.Value(dto));
    Expect.Once.On(mockViewCustomerView).SetProperty(
        "CompanyName").To(dto.CompanyName);
    Expect.Once.On(mockViewCustomerView).SetProperty(
        "ContactName").To(dto.ContactName);
    Expect.Once.On(mockViewCustomerView).SetProperty(
        "ContactTitle").To(dto.ContactTitle);
    Expect.Once.On(mockViewCustomerView).SetProperty(
        "Address").To(dto.Address);
    Expect.Once.On(mockViewCustomerView).SetProperty(
        "City").To(dto.City);
    Expect.Once.On(mockViewCustomerView).SetProperty(
        "Region").To(dto.Region);
    Expect.Once.On(mockViewCustomerView).SetProperty(
        "PostalCode").To(dto.PostalCode);
    Expect.Once.On(mockViewCustomerView).SetProperty(
        "Country").To(dto.CountryOfResidence.Name);
    Expect.Once.On(mockViewCustomerView).SetProperty(
        "Phone").To(dto.Phone);
    Expect.Once.On(mockViewCustomerView).SetProperty("Fax").To(dto.Fax);

    presenter.DisplayCustomerDetails();
}

和前面一样,我利用NMock库创建task和View接口的Mocks。这个特定的测试通过向服务层请求一个代表某一特定客户的DTO,验证了提供器的行为。一旦提供器从服务层获得DTO,它将直接更新视图的属性,这就避免了视图需要知道如何正确地显示来自对象的信息。为了简洁,我不打算去讨论WebLookupList控件的SeletedItem属性的实现;然而,我将把它留给你,通过检查源代码来查看实现的细节。这个测试真正演示的是当提供器从服务层收到一个Customer DTO 时发生在提供器和视图之间的交互。如果我现在试图运行这个测试,我将会处于一个严重的错误状态,因为很多的属性view接口没有定义。所以我将继续为IViewCustomerView接口添加必要的成员,如同你在 代码12 看到的:

代码12. 完成 IViewCustomerView 接口

public interface IViewCustomerView
{
    ILookupList CustomerList{get;}
    string CompanyName{set;}
    string ContactName{set;}
    string ContactTitle{set;}
    string Address{set;}
    string City{set;}
    string Region{set;}
    string PostalCode{set;}
    string Country{set;}
    string Phone{set;}
    string Fax{set;}
}

刚添加完这些接口成员,我的Web窗体就开始抱怨了,因为它不再满足接口的定义,所以我不得不回头看下我的Web窗体的后置代码,并且实现那些剩下的成员。如同前面所陈述的,Web页面的整个标记都已经创建了,并让那些标记了“runat=server”的表格单元格根据将在它中所要显示的信息来为它命名。这将使实现接口成员的代码非常的轻易:

public string CompanyName{
    set { this.companyNameLabel.InnerText = value; }
}
public string ContactName{
    set { this.contactNameLabel.InnerText = value; }
}
...

实现了Set属性访问器,还剩下一件事需要做。我需要有一种方式通知提供器,以便显示选中客户的信息。回头看下测试,你可以看到这个行为的实现位于提供器的DisplayCustomerDetails方法上。然而,这个方法不会接受任何参数。当调用时,提供器将会回头找视图,从它中拖出任何所需要的信息(它通过使用ILookupList获取),然后使用这些信息获取所请求的客户的详细内容。从UI的角度来看,我需要做的全部就是将DropDownList的AutoPostBack属性设为True,我也需要添加下面的事件处理程序,和Page的OnInit方法挂接起来。

protected override void OnInit(EventArgs e)
{
    base.OnInit(e);
    presenter = new ViewCustomerPresenter(this);
    this.customerDropDownList.SelectedIndexChanged += delegate{
        presenter.DisplayCustomerDetails();
    };
}

这个事件处理程序确保,无论什么时候下拉框中的一个新的客户被选中,视图将会请求提供器显示客户的细节。

注意到这是一个典型的行为很重要。当一个视图请求提供器做一些事情,它不提供任何的特定细节,而是由提供器去访问视图,通过view接口获取它所需要的任何信息。代码13 显示了实现提供器的行为所需要的代码。

代码13 完成提供器

public void DisplayCustomerDetails() {
    int? customerId = SelectedCustomerId;
    if (customerId.HasValue)
    {
        CustomerDTO customer =
            task.GetDetailsForCustomer(customerId.Value);
        UpdateViewFrom(customer);
    }
}
   
private int? SelectedCustomerId{
    get {
        string selectedId = view.CustomerList.SelectedItem.Value;
           
        if (String.IsNullOrEmpty(selectedId)) return null;

        int? id = null;

        try {
            id = int.Parse(selectedId.Trim());
        }
        catch (FormatException) {}

        return id;
    }
}

private void UpdateViewFrom(CustomerDTO customer){
    view.CompanyName = customer.CompanyName;
    view.ContactName = customer.ContactName;
    view.ContactTitle = customer.ContactTitle;
    view.Address = customer.Address;
    view.City = customer.City;
    view.Region = customer.Region;
    view.Country = customer.CountryOfResidence.Name;
    view.Phone = customer.Phone;
    view.Fax = customer.Fax;
    view.PostalCode = customer.PostalCode;
}

希望你现在已经明白了添加提供器层的价值。试图获取一个客户Id并且显示它的详细信息都是提供器的责任。这段代码通常都是实现在后置代码中,但是现在它位于一个类中,这样我就可以在任何的表现层技术之外,对它进行完全的测试和演练(译注:同一段代码可以应用于WinForm和WebForm,让窗体都去实现view接口就可以了)。

在提供器从视图中获得一个正确的客户Id的事件中,它转向服务层并请求一个DTO,这个DTO代表了客户的细节。一旦提供器拥有了DTO,它使用包含在DTO中的信息更新视图。注意到一个关键点就是View接口很简洁;伴于ILookupList接口,view接口只包含了String 类型。正确地转换并且格式化由DTO获取的信息,以便它可以以字符串形式提交给视图,最终都是提供器的责任。虽然在范例中没有演示,提供器也应该负责从视图中读取信息,并且将它转变为服务层所期望的必要类型。

所有的小部分都已经就位,现在我可以运行应用程序了。当页面第一次加载,我获取了客户的一个列表,并且第一个客户(未选择)显示在DropDownList中。如果我选择一个客户,产生一个PostBack,视图和提供器发生交互,使用相关的客户信息更新页面。

接下来是什么?

模型-视图-提供器模式实际上仅仅是许多开发者已经熟悉的 模型-视图-控制器 模式的更新。关键的变化是MVP完全将UI从应用程序的 领域/服务层分离出来。尽管从需求角度来看,这个例子相当的简单,但它可以帮助你从你的应用程序中将UI层与其他层的交互抽象出来。当你深入钻研到MVP模式中,我希望你可以找到其他方法将尽可能多的格式化和条件判断逻辑从你的后置代码中分离出来,并将它们置于可测试的 视图/提供器 交互模型中。