在 ASP.NET MVC 解决方案中从我的域对象重构表示代码的最佳方法是什么?

What is the best way to refactor presentation code out of my domain objects in an ASP.NET MVC solution?

我刚刚接手了一个 ASP.NET MVC 项目,需要进行一些重构,但我想获得一些关于最佳实践的想法/建议。

该站点有一个 SQL 服务器后端,这里是对解决方案中项目的回顾:

我看到的第一个 "issue" 是虽然域对象 classes 几乎 POCO 在计算字段周围有一些额外的 "get" 属性,但有一些域对象中的表示代码。例如,在 DomainObjects 项目中,有一个 Person 对象,我在 class:

上看到了这个 属性
 public class Person
 {

    public virtual string NameIdHTML
    {
        get
        {
           return "<a href='/People/Detail/" + Id + "'>" + Name + "</a> (" + Id + ")";
        }
    }
 }

很明显,在域对象中包含 HTML 生成的内容似乎是错误的。

重构方法:

  1. 我的第一直觉是将它移到 MVC 项目中的 ViewModel class,但是我看到有很多视图都命中了这段代码,所以我不想在每个视图模型中复制代码。

  2. 第二个想法是创建 PersonHTML class 是:

    2a。在构造函数中接受 Person 的包装器 or

    2b。 class 继承自 Person,然后拥有所有这些 HTML 渲染方法。

    视图模型会将任何 Person 对象转换为 PersonHTML 对象并将其用于所有呈现代码。

我只是想看看:

  1. 如果这里有最佳实践,因为这似乎是出现的常见问题/模式

  2. 当前状态有多糟糕,因为除了感觉不对之外,它并没有真正导致理解代码或创建任何不良依赖项的任何重大问题。从真正的实际意义(相对于关注点分离的理论上的争论)来帮助描述为什么将代码留在这种状态是不好的任何论点都会有所帮助,并且团队中也存在争论是否值得改变。

我喜欢 TBD 的评论。这是错误的,因为您将领域问题与 UI 问题混为一谈。这会导致您可以避免的耦合。

至于你建议的解决方案,我不太喜欢。

  1. 引入视图模型。是的,我们应该使用视图模型,但是我们 不想用 HTML 代码污染它们。所以使用一个例子 视图将是如果你有一个父对象,人类型,并且你 想在屏幕上显示人物类型。你会填满视图 具有人物类型名称的模型,而不是完整的人物类型对象 因为你只需要屏幕上的人名。或者如果 您的域模型的名字和姓氏分开,但是您的视图 调用 FullName,您将填充视图模型的 FullName 和 return 到视图。

  2. PersonH​​tml class。我什至不确定那会做什么。视图代表 ASP.NET MVC 应用程序中的 HTML。您在这里有两个选择:

    一个。您可以为您的模型创建一个显示模板。这是一个 link 的 Stack Overflow 问题,用于显示模板,How to make display template in MVC 4 project

    b。您还可以编写一个 HtmlHelper 方法来为您生成正确的 HTML。 @Html.DisplayNameLink(...) 之类的东西将是您的最佳选择。这里有一个link用于理解HtmlHelpershttps://download.microsoft.com/download/1/1/f/11f721aa-d749-4ed7-bb89-a681b68894e6/ASPNET_MVC_Tutorial_9_CS.pdf

要为这个问题提供完美的答案并不容易。虽然层的完全分离是可取的,但它往往会导致很多无用的工程问题。

虽然每个人都同意业务层对 presentation/UI 层了解不多这一事实,但我认为它知道这些层确实存在是可以接受的,当然没有太多细节。

一旦你声明了它,你就可以使用一个未被充分利用的接口:IFormattable. This is the interface that string.Format uses.

因此,例如,您可以先这样定义您的人 class:

public class Person : IFormattable
{
    public string Id { get; set; }
    public string Name { get; set; }

    public override string ToString()
    {
        // reroute standard method to IFormattable one
        return ToString(null, null);
    }

    public virtual string ToString(string format, IFormatProvider formatProvider)
    {
        if (format == null)
            return Name;

        if (format == "I")
            return Id;

        // note WebUtility is now defined in System.Net so you don't need a reference on "web" oriented assemblies
        if (format == "A")
            return string.Format(formatProvider, "<a href='/People/Detail/{0}'>{1}</a>", WebUtility.UrlEncode(Id), WebUtility.HtmlDecode(Name));

        // implement other smart formats

        return Name;
    }
}

这并不完美,但至少,您将能够避免定义数百个指定属性并将演示详细信息保存在专门用于演示详细信息的 ToString 方法中。

在调用代码中,您可以像这样使用它:

string.Format("{0:A}", myPerson);

或使用 MVC 的 HtmlHelper.FormatValue。 .NET 中有 lot 个 classes 支持 IFormattable(例如 StringBuilder)。

您可以完善系统,改为这样做:

    public virtual string ToString(string format, IFormatProvider formatProvider)
    {
        ...
        if (format.StartsWith("A"))
        {
            string url = format.Substring(1);
            return string.Format(formatProvider, "<a href='{0}{1}'>{2}</a>", url, WebUtility.UrlEncode(Id), WebUtility.HtmlDecode(Name));
        }
        ...
        return Name;
    }

你会这样使用它:

string.Format("{0:A/People/Detail/}", person)

所以你不要在业务层硬编码url。将 Web 作为表示层时,您通常必须在格式中传递 CSS class 名称以避免业务层中的硬编码样式。事实上,您可以想出相当复杂的格式。毕竟,如果您考虑一下,这就是 DateTime 等对象所做的事情。

您甚至可以更进一步,使用一些 ambiant/static 属性 来告诉您您是否 运行 在网络上下文中,以便它自动运行,如下所示:

public class Address : IFormattable
{
    public string Recipient { get; set; }
    public string Line1 { get; set; }
    public string Line2 { get; set; }
    public string ZipCode { get; set; }
    public string City { get; set; }
    public string Country { get; set; }

    ....

    public virtual string ToString(string format, IFormatProvider formatProvider)
    {
        // 
        if ((format == null && InWebContext) || format == "H")
            return string.Join("<br/>", Recipient, Line1, Line2, ZipCode + " " + City, Country);

        return string.Join(Environment.NewLine, Recipient, Line1, Line2, ZipCode + " " + City, Country);
    }
}

我自己也遇到过这个问题。当我在视图中的代码比 HTML 更基​​于逻辑时,我创建了 HtmlBuilder 的增强版本。我扩展了某些域对象以自动打印出这个助手,它的内容基于域函数,然后可以将其打印到视图上。然而,代码变得非常混乱和不可读(尤其是当你试图找出它的来源时);出于这些原因,我建议尽可能多地从域中删除 presentation/view 逻辑。

然而,在那之后我决定再看看显示和编辑器模板。而且我越来越欣赏它们,尤其是与 T4MVC、FluentValidation 和自定义元数据提供程序等结合使用时。我发现使用 HtmlHelpers 并将元数据或路由 table 扩展为更简洁的做事方式,但您也开始使用记录较少的系统。不过这个案例比较简单。

所以,首先,我会确保您为该实体定义了一个路由,这看起来就像您使用默认 MVC 路由一样,因此您可以简单地在视图中执行此操作:

//somewhere in the view, set the values to the desired value for the person you have
@{
    var id = 10; //random id
    var name = "random name";
}
//later:
<a href="@Url.Action("People", "Detail", new { id = id })"> @name  ( @id )</a>

或者,T4MVC

<a href="@Url.Action(MVC.People.Detail(id))"> @name ( @id )</a>

这意味着,关于 views/viewmodels,它们唯一的依赖项是 Personidname,我认为您现有的视图模型应该有(从上面删除那个丑陋的 var id = x):

<a href="@Url.Action("People", "Detail", new { id = Model.PersonId } )"> 
    @Model.Name ( @Model.PersonId )
</a>

或者,使用 T4MVC:

<a href="@Url.Action( MVC.People.Detail( Model.PersonId ) )"> 
    @Model.Name ( @Model.PersonId )
</a>

现在,如您所说,多个视图使用此代码,因此您需要更改视图以符合上述要求。还有其他方法可以做到这一点,但我提出的每一个建议都需要改变观点,我相信这是最干净的方法。这也有一个使用路由 table 的特性,这意味着如果路由系统被更新,那么更新后的 url 将毫无顾虑地打印出来,而不是在域对象中硬编码它作为a url(这取决于以特定方式设置的路由系统才能使 url 正常工作)。

我的其他建议之一是构建一个 Html Helper,称为 Html.LinkFor( c => model ) 或类似的东西,但是,除非您希望它动态确定 controller/action基于类型,这是不必要的。

理想情况下,您需要重构代码以使用视图模型。视图模型可以具有用于简单字符串格式化的实用方法,例如

public string FullName => $"{FirstName} {LastName}"

但绝对不 HTML! (做个好公民 :D)

然后您可以在以下目录中创建各种 Editor/Display 模板:

Views/Shared/EditorTemplates
Views/Shared/DisplayTemplates

以模型对象类型命名模板,例如

AddressViewModel.cshtml

然后您可以使用以下内容来呈现 display/editor 模板:

@Html.DisplayFor(m => m.Address)
@Html.EditorFor(m => m.Address)

如果 属性 类型是 AddressViewModel,则将使用 EditorTemplates 或 DisplayTemplates 目录中的 AddressViewModel.cshtml。

您可以通过将选项传递给模板来进一步控制渲染,如下所示:

@Html.DisplayFor(m => m.Address, new { show_property_name = false, ... })

您可以像这样在模板 cshtml 文件中访问这些值:

@ {
    var showPropertyName = ViewData.ContainsKey("show-property-name") ? (bool)ViewData["show-property-name] : true;
    ...
}

@if(showPropertyName)
{
    @Html.TextBoxFor(m => m.PropertyName)
}

这提供了很大的灵活性,而且还能够覆盖通过将 UIHint 属性应用于 属性 所使用的模板,如下所示:

[UIHint("PostalAddress")]
public AddressViewModel Address { get; set; }

DisplayFor/EditorFor 方法现在将查找 'PostalAddress.cshtml' 模板文件,它只是另一个模板文件,如 AddressViewModel.cshtml.

对于我从事的项目,我总是将 UI 分解成这样的模板,因为您可以通过 nuget 打包它们并在其他项目中使用它们。

此外,您还可以将它们添加到一个新的 class 库项目中,并将它们编译成一个 dll,您可以在 MVC 项目中引用它。我以前使用 RazorFileGenerator 来执行此操作 (http://blog.davidebbo.com/2011/06/precompile-your-mvc-views-using.html),但现在更喜欢使用 nuget 包,因为它允许对视图进行版本控制。

How bad is this current state considered because besides feeling wrong, it not really causing any major problems understanding the code or creating any bad dependencies.

当前状态非常不好,不仅因为UI代码包含在域代码中。那已经是 相当 糟糕了,但这更糟。 NameIdHTML 属性 returns 一个 硬编码 link 到此人的 UI 页面。即使在 UI 代码中,您也不应该对这些 link 进行硬编码。这就是 LinkExtensions.ActionLinkUrlHelper.Action 的目的。

如果您更改控制器或路线,link 将会更改。 LinkExtensionsUrlHelper 知道这一点,您不需要任何进一步的更改。当您使用硬编码的 link 时,您需要在您的代码中找到 所有 的地方,其中这样的 link 是硬编码的(并且您需要知道那些地方存在)。更糟糕的是,您需要更改的代码位于 依赖链相反方向 的业务逻辑中。这是维护的噩梦,也是错误的主要来源。你需要改变这个。

If there is a best practice here as it seems like this is a common problem / pattern that comes up.

是的,有一个最佳实践,就是在您需要 link 到控制器操作返回的页面时使用提到的 LinkExtensions.ActionLinkUrlHelper.Action 方法。坏消息是,这意味着您的解决方案中的多个位置都会发生变化。好消息是很容易找到这些点:只需删除 NameIdHTML 属性 错误就会弹出。除非您通过反射访问 属性 。在这种情况下,您需要进行更仔细的代码搜索。

您需要用使用 LinkExtensions.ActionLinkUrlHelper.Action 的代码替换 NameIdHTML 来创建 link。我假设只要此人显示在 HTML 页面上,就应该使用 NameIdHTML returns HTML 代码。我还假设这是您代码中的常见模式。如果我的假设是正确的,您可以创建一个助手 class 将业务对象转换为其 HTML 表示。您可以向 class 添加扩展方法,这将提供对象的 HTML 表示。为了阐明我的观点,我假设(假设)您有一个 Department class,它也有 NameId,并且具有类似的 HTML 表示.然后你可以重载你的转换方法:

public static class BusinessToHtmlHelper {
    public static MvcHtmlString FromBusinessObject( this HtmlHelper html, Person person) {
        string personLink = html.ActionLink(person.Name, "Detail", "People",
            new { id = person.Id }, null).ToHtmlString();
        return new MvcHtmlString(personLink + " (" + person.Id + ")");
    }

    public static MvcHtmlString FromBusinessObject( this HtmlHelper html,
        Department department) {

        string departmentLink = html.ActionLink(department.Name, "Detail", "Departments",
            new { id = department.Id }, null).ToHtmlString();
        return new MvcHtmlString(departmentLink + " (" + department.Id + ")");
    }
}

在您的视图中,您需要通过调用此辅助方法来替换 NameIdHTML。例如这段代码...

@person.NameIdHTML

...需要替换为:

@Html.FromBusinessObject(person)

这也会使您的视图保持整洁,如果您决定更改 Person 的视觉表示,您可以轻松更改 BusinessToHtmlHelper.FromBusinessObject 而无需更改任何视图。此外,对路线或控制器的更改将自动反映在生成的 link 中。 UI 逻辑保留在 UI 代码中,而业务代码保持干净。

如果你想让你的代码完全不受 HTML 的影响,你可以为你的人创建一个显示模板。优点是您的所有 HTML 都带有视图,缺点是需要为您要创建的每种类型的 HTML link 显示模板。对于 Person,显示模板将如下所示:

@model Person

@Html.ActionLink(Model.Name, "Detail", "People", new { id = Model.Id }, null) ( @Html.DisplayFor(p => p.Id) )

您必须用此代码替换对 person.NameIdHTML 的引用(假设您的模型包含类型为 PersonPerson 属性):

@Html.DisplayFor(m => m.Person)

您也可以稍后添加显示模板。您可以先创建 BusinessToHtmlHelper,作为将来的第二个重构步骤,您可以在引入显示模板(如上面的模板)后更改助手 class:

public static class BusinessToHtmlHelper {
    public static MvcHtmlString FromBusinessObject<T>( this HtmlHelper<T> html, Person person) {
        return html.DisplayFor( m => person );
    }
    //...
}

如果您小心只使用 BusinessToHtmlHelper 创建的 link,则不需要对您的视图进行进一步更改。

我想你在改变之前需要有一个计划。是的,你提到的项目听起来不正确,但这并不意味着新计划更好。

首先,现有项目(这将帮助您了解应避免什么):

包含数据库表的域对象?听起来像 DAL。我假设这些对象实际上存储在数据库中(例如,如果它们是 entity framework 类)而不是从它们映射(例如使用 entity framework 然后将结果映射回这些对象),否则你的映射太多(1 个从 EF 到数据对象,2 个从数据对象到模型)。我已经看到这样做了,这是分层中非常典型的错误。因此,如果您有,请不要重复。此外,不要将包含数据行对象的项目命名为 DomainObjects。域表示模型。

DomainORM - 好的,但我会把它与数据行对象结合起来。如果地图项目与数据对象紧密耦合,那么将地图项目分开是没有意义的。这就像假装你可以用一个代替另一个。

Models - 好名字,它也可以提到 Domain,这样就没有人会用这个非常重要的词来命名其他项目。

NameIdHTML 属性 - 关于业务对象的坏主意。但这是一个小的重构 - 将其移动到一个留在其他地方的方法中,而不是在您的业务逻辑中。

看起来像 DTO 的业务对象 - 也是个坏主意。那么业务逻辑的意义何在?我自己的文章:How to Design Business Objects

现在,您需要定位什么(如果您准备重构):

业务逻辑托管项目应该独立于平台 - 没有提及 HTML、HTTP 或与具体平台相关的任何内容。

DAL - 应该引用业务逻辑(而不是其他方式),并且应该负责映射和保存数据对象。

MVC - 通过将逻辑移出业务逻辑(逻辑实际上是业务逻辑)或所谓的服务层(a.k.a)来保持瘦控制器。应用程序逻辑层 - 可选,必要时存在将特定于应用程序的代码放在控制器之外)。

我自己关于分层的文章:Layering Software Architecture

这样做的真正原因:

可在多个平台上重复使用的业务逻辑(今天你只是网络,明天你可以是网络和服务,也可以是桌面)。理想情况下,所有不同的平台都应该使用相同的业务逻辑,如果它们属于相同的限界上下文。

可控的复杂性长 运行,这是选择像 DDD(领域驱动)与 data-driven 设计之类的东西的众所周知的因素。它带有学习曲线,因此您最初会对其进行投资。 Long-run,你保持低可维护性,就像永久接收保费一样。当心你的对手,他们会争辩说这与他们一直在做的完全不同,而且对他们来说这看起来很复杂(由于学习曲线和迭代思考以保持良好的设计很长时间 运行)。

首先考虑您的目标和 Kent Beck 关于软件开发经济学的观点。也许,您的软件的目标是提供价值,您应该花时间做一些有价值的事情。

其次,戴上软件架构师的帽子,进行某种计算。这就是您支持选择将资源花在这上面还是花在其他东西上的方式

让代码处于那种状态是不好的,如果在接下来的 2 年内它会:

  • 增加不满意的客户数量
  • 减少贵公司的收入
  • 增加软件数量failure/bugs/crashes
  • 增加维护或更改代码的成本
  • 让开发人员感到惊讶,导致他们在误解中浪费数小时的时间
  • 增加新开发人员的入职成本

如果代码不太可能发生这些事情,那么不要把团队的生命浪费在矫直铅笔上。如果您无法识别代码的真正负面 cost-consequence,那么代码可能没问题,应该改变的是您的理论。

我的猜测是“更改此代码的成本可能高于它导致的问题的成本。”但是您更适合猜测问题的实际成本。在您的示例中,更改成本可能非常低。将此添加到您的选项 2 重构列表:

————————————————————————————————————

2c。使用 MVC 应用程序中的扩展方法以最少的代码向域对象添加表示 know-how。

public static class PersonViewExtensions
{
    const string NameAndOnePhoneFormat="{0} ({1})";
    public static string NameAndOnePhone(this Person person)
    {
        var phone = person.MobilePhone ?? person.HomePhone ?? person.WorkPhone;
        return string.Format(NameAndOnePhoneFormat, person.Name, phone);
    }
}

在您嵌入 HTML 的地方,@Sefe 的答案中的代码 — 在 HtmlHelper class 上使用 扩展方法 — 非常漂亮我会做什么。这样做是 Asp.NetMVC

的一大特色

——————————————————————————————————————

但这种做法应该是整个团队的学习习惯。 不要向老板索要重构预算。向老板询问学习预算:书籍、编写代码的时间、带团队参加开发人员聚会的预算。

无论你做什么,都不要 amateur-software-architecture-thing 想着“这个代码不符合 X,因此我们必须花时间和金钱来改变即使我们无法显示这笔费用的具体价值。”

最终,您的目标是增加价值。花钱学习会增值;花钱交付新功能或消除错误可能会增加价值;花钱重写工作代码只会增加价值,前提是你真的在消除缺陷。