65.9K
CodeProject 正在变化。 阅读更多。
Home

PseudoCQRS:查询端

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.93/5 (5投票s)

2014年3月16日

CPOL

14分钟阅读

viewsIcon

13948

关于PseudoCQRS系列的第二篇文章。本文描述了PseudoCQRS的查询端。

引言

本文是关于PseudoCQRS系列的第二篇文章。PseudoCQRS是 CQRS 模式 的部分实现,该模式最初由 Greg Young 描述。第一篇文章,即框架和模式的摘要,可以在这里找到

CQRS的查询端主要关注如何向用户展示应用程序的当前状态。这可能包括某个特定客户的详细信息、该客户可能下的订单列表、可供购买的产品列表等。在所有这些例子中,要显示视图所需的数据并不只存在于一个实体中——要显示客户的当前详细信息,您可能需要来自国家表、客户类别表的数据,或者您可能想显示他所有订单的总和。这些数据来自多个地方,而不仅仅是一个实体。

这几乎适用于系统内的所有“视图”。在此上下文中,我使用“视图”一词来指代用户在应用程序中看到的特定屏幕、他们运行的报告,甚至是通过API暴露数据使用的模型。

过去,我们(在Liquid Thinking)可能会尝试设计我们的实体,以便它们可以被各种视图重用。例如,我们可能会在Customer实体中添加一个字符串类型的Country属性,以便我们可以在详细信息页面上显示他们的国家,或者在客户列表中显示他们的国家,这样我们就无需通过Customer实体上的Country类型属性来懒惰加载该数据。这在一定程度上符合我们的“N层”架构——表示层(当时是Asp.Net WebForms)、服务层和数据访问层。

然后,该架构演变为用于MVC,其中我们在表示层有控制器、视图和视图模型。服务层会从数据访问层获取实体,然后将其映射到传递给控制器的 DTO(数据传输对象),控制器再将这些 DTO 映射到 ViewModel。大量的映射。当视图中的某些内容需要更改时,会带来大量的痛苦。这种情况经常发生。即使使用AutoMapper这样的工具,更改一个屏幕也需要更改视图、控制器、ViewModel、DTO和服务。如果我们试图为多个视图重用服务的一部分,导致这些部分发生更改的因素会增加。

这感觉总是不太对劲。

CQRS认识到,在标准的基于数据的应用程序中有两个方面——查询端,用户通过它来检查应用程序的当前状态;命令端,用户通过它来修改应用程序的状态。我们也逐渐认识到,视图和视图模型是相互依赖的,为某个视图设计的视图模型不能(如果严格遵守单一职责原则,实际上也不应该)用于任何其他视图(除了可能同一屏幕的移动版本)。

我们编写PseudoCQRS框架是因为我们喜欢CQRS模式,但我们有一个现有的“N层”应用程序,我们想将其迁移到更接近CQRS的架构。我们无法完全采用事件溯源,但我们可以获得CQRS在更好地分离应用程序的命令端和查询端方面带来的优势。

本文描述了PseudoCQRS的查询端。

PseudoCQRS架构

下图显示了PseudoCQRS查询端涉及的对象。

当您想在应用程序中编写一个新屏幕时,您所要做的就是设计视图,创建ViewModel、ViewModelProvider和ViewModelArguments对象(以及可选的任何Precheckers,它们必须运行以防止安全漏洞等)。这些是每个屏幕都不同的东西——其余的功能对每个屏幕都是相同的,而PseudoCQRS消除了重复编写这些代码的需要。

为了更好地解释架构,让我们看一个具体的例子。

在GitHub上的 PseudoCQRS 仓库 中,有一个名为PseudoCQRS.Examples.NerdDinner的示例项目。该项目展示了在PseudoCQRS架构下实现的经典NerdDinner MVC示例应用程序。

此应用程序允许用户创建聚餐,并查看当前可用聚餐的列表。

列出可用聚餐是我们将在此示例中关注的功能垂直切片。这是我们将要创建的页面。

如上所述,我们需要创建View、ViewModel、ViewModelProvider和ViewModelArguments,以便实现此功能。

ViewModel

ViewModel是一个包含显示视图所需的所有数据的对象。在这种情况下,它是一个可用聚餐的列表,加上所有主机列表,以便我们可以按主机进行筛选,因此我们的ViewModel及其相关对象如下所示:

public class DinnerListViewModel
{
    public int HostedByUserId { get; set; }
    public Dictionary<int, string> Hosts { get; set; }
    public List<DinnerViewModel> Dinners { get; set; }
}

public class DinnerViewModel
{
    public int Id { get; set; }
    public string Title { get; set; }
    public string DateTime { get; set; }
    public string HostedBy { get; set; }
} 

在我们的列表页面上,我们想显示所有聚餐的列表,包括聚餐的标题、日期以及由谁主办。我们还需要其ID,以便创建指向详细信息页面的链接,或者进行注册。我们还希望能够按主机进行筛选,因此我们需要显示一个包含所有主机名称的下拉列表。我们的ViewModel恰好包含了显示该页面所需的所有数据。

ViewModel 参数

ViewModelArguments对象是包含确定如何构建ViewModel所需的所有数据的对象。对于我们示例中的聚餐列表,我们可以显示所有即将举行的聚餐,也可以将其筛选为仅列出由特定人员主办的聚餐。因此,对于我们的参数,我们只需要一个属性,即主办用户ID。

public class DinnerListArguments
{
    public int HostedByUserId { get; set; }
} 

ViewModelProvider

ViewModelProvider对象是实际从数据库(或文件系统、RSS feed等)提取数据的对象。在我们的示例中,我们将使用ORM从数据库中提取Dinner实体对象的列表,并将其筛选为仅包含即将举行的聚餐,并且如果需要,仅包含由特定人员主办的聚餐。

public class DinnerListViewModelProvider 
    : IViewModelProvider<DinnerListViewModel, DinnerListArguments>
{
    private readonly IRepository _repository;

    public DinnerListViewModelProvider( IRepository repository )
    {
        _repository = repository;
    }

    public DinnerListViewModel GetViewModel( DinnerListArguments arguments )
    {
        return new DinnerListViewModel
        {
            Dinners = GetDinners( arguments ),
            Hosts = GetHosts(),
            HostedByUserId = arguments.HostedByUserId
        };
    }

    private Dictionary<int, string> GetHosts()
    {
        return _repository.GetAll<User>().ToDictionary( x => x.Id, x => x.Name );
    }

    private List<DinnerViewModel> GetDinners( DinnerListArguments arguments )
    {
        return _repository
            .GetAll<Dinner>()
            .Where( dinner => DinnerShouldBeIncluded( arguments, dinner ) )
            .Select( dinner => new DinnerViewModel()
            {
                Id = dinner.Id,
                Title = dinner.Title,
                DateTime = dinner.EventDate.ToString( "dd/MM/yyyy" ),
                HostedBy = dinner.HostedBy.Name
            } ).ToList();
    }

    private static bool DinnerShouldBeIncluded( 
        DinnerListArguments arguments, 
        Dinner dinner )
    {
        return 
            dinner.EventDate >= DateTime.Now.Date
            && 
            ( 
                arguments.HostedByUserId == 0 
                || 
                dinner.HostedBy.Id == arguments.HostedByUserId 
            );
    }
} 

您可以看到我们允许按主机筛选聚餐列表。因此,“DinnerShouldBeIncluded”方法用于确定是否应将该聚餐包含在列表中。如果其数据是今天或之后,并且其主办方与我们正在筛选的主办方相同,或者我们根本没有进行筛选,则应将其包含在内。

在这个简单的例子中,我们使用ORM来提取ViewModel所需的所有数据。对于更复杂的视图,我们通常的做法是编写一个自定义SQL语句,以非常高效的方式提取数据,并且只从数据库中提取完全必要的数据,然后使用 SqlObjectHydrator 包将DataReader转换为ViewModel或ViewModel对象列表。这样,我们的查询端就可以非常快速。并且由于每个视图都有一个ViewModel和一个ViewModelProvider,您可以确信,如果您修改了任何代码,只会影响一个视图。

View

视图仅显示ViewModelProvider中的数据。

@model PseudoCQRS.Examples.NerdDinner.Modules.DinnerList.DinnerListViewModel
@{
    ViewBag.Title = "List";
    Layout = "~/Views/Shared/_Layout.cshtml";
}
<h1>List of Dinners</h1>
<div class="well">
    <div>
        Filter by Host:
        @Html.DropDownListFor(  x => x.HostedByUserId, new SelectList( Model.Hosts, "Key", "Value"), "Select host", new { onchange = "location.href='?HostedByUserId=' + this.value"} )
    </div>

</div>
@foreach ( var dinner in Model.Dinners )
{
    <div class="panel panel-default">
        <div class="panel-body">
            <h4>@dinner.Title</h4>
            <div>Date: @dinner.DateTime</div>
            <div>Hosted By:@dinner.HostedBy</div>
        </div>
    </div>
}
<button class="btn btn-primary" onclick=" location.href = '/DinnerCreate/Execute' ">Create a dinner</button>

同样,这是一个非常简单的例子——它只是显示了一个用于主机筛选的下拉列表,并列出了由ViewModelProvider返回的每个聚餐的详细信息。在这种情况下,我们甚至没有使用ID链接到聚餐的详细信息页面,但您可以轻松地看到如何实现这一点。

控制器

最后,我们只需要编写一个控制器来连接所有这些部分并将HTML返回给浏览器。

public class DinnerListController : BaseReadController<DinnerListViewModel, DinnerListArguments>
{
    public override string ViewPath
    {
        get { return "Dinner/List"; }
    }
} 

您可以看到我们为每个操作使用一个控制器,在这种情况下,控制器继承自我们的BaseReadController对象,它是一个通用对象,需要ViewModel的类型和ViewModel参数作为其泛型参数。然后,我们只需要告诉它该屏幕的实际视图存储在哪里(“ViewPath”覆盖),我们就完成了!

因此,对于您应用程序中的每个新屏幕,以上是您需要创建的唯一项;一个Controller、一个View、一个ViewModel、一个ViewModelProvider和一个用于保存ViewModelProvider参数的对象。这些对象中的每一个都只有一个职责,如果屏幕需要更改,这些将是您需要修改的唯一项,并且如果您修改它们,您可以确信您没有影响应用程序中的任何其他功能。(当然,您会希望有一套单元测试和验收测试来证明这一点……)

PseudoCQRS组件使用上述对象来显示数据,如下所示:

BaseReadController

对于PseudoCQRS的查询端,我们提供了两个基类控制器,它们可以自动完成所有必要的连接工作。它们是BaseReadController和BaseReadExecuteController。BaseReadExecuteController将在后续文章中讨论,但BaseReadController列于下文:

[DbSessionManager]
public abstract class BaseReadController<TViewModel, TArgs> : Controller, IViewPath
    where TViewModel : class
    where TArgs : new()
{
    private readonly IViewModelFactory<TViewModel, TArgs> _viewModelFactory;

    public abstract string ViewPath { get; }

    protected BaseReadController( IViewModelFactory<TViewModel, TArgs> viewModelFactory )
    {
        _viewModelFactory = viewModelFactory;
    }

    public BaseReadController()
        : this( ServiceLocator.Current.GetInstance<IViewModelFactory<TViewModel, TArgs>>() ) {}

    public virtual ActionResult Execute()
    {
        return GetActionResult( _viewModelFactory.GetViewModel() );
    }

    protected virtual ViewResult GetViewResult( TViewModel viewModel )
    {
        return View( this.GetView(), viewModel );
    }

    protected virtual ActionResult GetActionResult( TViewModel viewModel )
    {
        return GetViewResult( viewModel );
    }
} 

这个对象是一个通用的基类对象,它将ViewModel对象的类型和ViewModelArguments对象的类型作为其泛型参数。控制器被注入一个相同泛型类型的IViewModelFactory对象。每个控制器只有一个操作——一个“Execute”操作。该操作调用ViewModelFactory来获取ViewModel,然后返回适合该视图的操作。默认情况下,它只是返回一个MVC ViewResult,使用ViewModel作为其模型,但GetViewResult和GetActionResult这两个方法被标记为虚拟,因此可以被覆盖以返回JSONResults或ContentResults等。

ViewModelFactory

ViewModelFactory负责获取ViewModel参数对象,将其传递给ViewModelProvider,并将ViewModel返回给控制器。其代码列表如下:

public class ViewModelFactory<TViewModel, TArg> : IViewModelFactory<TViewModel, TArg>
    where TViewModel : class
    where TArg : new()
{
    private readonly IViewModelProvider<TViewModel, TArg> _viewModelProvider;
    private readonly IViewModelProviderArgumentsProvider _argumentsProvider;
    private readonly IPrerequisitesChecker _prerequisitesChecker;

    public ViewModelFactory(
        IViewModelProvider<TViewModel, TArg> viewModelProvider,
        IViewModelProviderArgumentsProvider argumentsProvider,
        IPrerequisitesChecker prerequisitesChecker )
    {
        _viewModelProvider = viewModelProvider;
        _argumentsProvider = argumentsProvider;
        _prerequisitesChecker = prerequisitesChecker;
    }

    public TViewModel GetViewModel()
    {
        var args = _argumentsProvider.GetArguments<TArg>();
        var checkResult = _prerequisitesChecker.Check( args );
        if ( checkResult.ContainsError )
            throw new ArgumentException( checkResult.Message );

        return _viewModelProvider.GetViewModel( args );
    }
}     

题外话:与PseudoCQRS内的所有对象一样,ViewModelFactory实现了一个接口(在此例中为IViewModelFactory),如果您希望工厂以不同的方式工作,您可以自己实现该接口。然后,您只需相应地配置您的IoC容器,将其指向您的实现而不是PseudoCQRS的实现。所有PseudoCQRS组件都可以以这种方式替换,如果需要的话。

ViewModelFactory将ViewModelProvider(在我们示例中是DinnerListViewModelProvider)、IViewModelProviderArgumentsProvider和IPrerequisitesChecker对象注入。它使用这些对象如下:

1) 它调用IViewModelProviderArgumentsProvider对象来获取传递给ViewModelProvider所需的参数。

2) 它将该参数对象发送给IPrerequisitesChecker。该对象可以执行诸如确保用户有权访问由参数指定的对象的权限,或检查参数是否格式正确等操作。

3) 如果IPrerequisitesChecker认为一切正常,它就会调用IViewModelProvider的GetViewModel方法并传入参数,然后返回结果。

ViewModelProviderArgumentsProvider

ViewModelProviderArgumentsProvider负责构建发送给ViewModelProvider的参数对象。

public class ViewModelProviderArgumentsProvider : IViewModelProviderArgumentsProvider
{
    private readonly IPropertyValueProviderFactory _propertyValueProviderFactory;

    public ViewModelProviderArgumentsProvider( IPropertyValueProviderFactory propertyValueProviderFactory )
    {
        _propertyValueProviderFactory = propertyValueProviderFactory;
    }

    private readonly PropertyInfoCacher _propertyInfoCacher = new PropertyInfoCacher();

    public TArg GetArguments<TArg>() where TArg : new()
    {
        var retVal = new TArg();
        var properties = _propertyInfoCacher.GetProperties( typeof ( TArg ) );
        var propertyValueProviders = _propertyValueProviderFactory.GetPropertyValueProviders();
        foreach ( var propertyValueProvider in propertyValueProviders )
        {
            foreach ( var kvp in properties )
            {
                if ( propertyValueProvider.HasValue<TArg>( kvp.Key ) )
                    kvp.Value.SetValue( retVal, propertyValueProvider.GetValue<TArg>( kvp.Key, kvp.Value.PropertyType ), null );
            }
        }
        return retVal;
    }

    public void Persist<TArg>() where TArg : new()
    {
        Persist( GetArguments<TArg>() );
    }

    internal void Persist<TArg>( TArg arguments )
    {
        foreach ( var property in typeof ( TArg ).GetProperties().Where( x => Attribute.IsDefined( x, typeof ( PersistAttribute ) ) ) )
        {
            var persistLocation = ( Attribute.GetCustomAttribute( property, typeof ( PersistAttribute ) ) as PersistAttribute ).PersistanceLocation;
            var propertyValueProvider = _propertyValueProviderFactory.GetPersistablePropertyValueProvider( persistLocation );
            var propertyValue = property.GetValue( arguments, null );
            if ( propertyValue != null )
                propertyValueProvider.SetValue<TArg>( property.Name, propertyValue );
        }
    }
} 

此对象的工作方式与标准的MVC ModelBinders pretty much相同——它在Querystring、Form、RouteData、Session和Cookie对象中查找与它正在构造的对象属性名称匹配的条目,如果找到任何此类条目,它会相应地设置对象上的属性。

GetArguments方法的工作方式如下:

1) 它创建一个TArgs类型的新对象。在我们示例中,这意味着它创建一个新的DinnerListArguments对象。

2) 它调用PropertyInfoCacher对象来获取该对象上所有属性设置方法的列表。PropertyInfoCacher使用反射来获取此设置属性方法列表,并且一旦为特定类型查找了这些方法,它就会缓存它们,以便下次可以更快地检索。

3) 它调用IPropertyValueProviderFactory来获取一个PropertyValueProvider对象列表,用于尝试设置参数对象上的属性。PseudoCQRS中IPropertyValueProviderFactory的实现返回PropertyValueProvider,它们在以下位置查找属性值(并按以下顺序):

  • Cookie
  • Session
  • RouteData
  • 查询字符串(QueryString)
  • 表单

4) 它遍历这些PropertyValueProvider中的每一个,询问它们是否可以为对象上的每个属性提供值,如果可以,它就相应地设置该属性上的值。

5) 最后,它返回参数对象。

对于我们的示例,DinnerListArguments仅包含一个属性,即HostedByUserId属性。因此,ViewModelProviderArgumentsProvider首先检查Cookie对象中是否有名为“HostedByUserId”的条目,如果存在,它会尝试将属性值设置为Cookie的值(如果可以转换为整数。如果不能,则忽略)。然后,它在Session中检查名为“HostedByUserId”的条目。然后是RouteData。然后是QueryString,最后是Form。在所有情况下,如果找到条目,它都会相应地设置值,因此Form条目将覆盖QueryString条目,后者将覆盖RouteData条目等。

在我们的示例中,当您更改“按主机筛选”下拉列表时,它会导致一个带有HostedByUserId条目的GET请求在Querystring中。因此,当ViewModelProviderArgumentsProvider返回DinnerListArguments对象时,“HostedByUserId”的值将是它在Querystring中找到的值。

关于ViewModelProviderArgumentsProvider对象的另一件值得注意的事情是,它还有一个Persist方法。此方法可用于将参数对象中的任何您希望存储在可持久化格式中的值持久化——即,存储到Session或Cookies(或者如果您自己编写PropertyValueProvider对象,则存储到任何其他持久化机制)。此方法将查找您参数对象上带有“Persist”属性的任何属性,并为这些属性调用相应PropertyValueProvider上的SetValue方法,该方法应实现持久化机制。当您返回屏幕时,希望以某种方式持久化屏幕状态的情况(例如,分页列表中的相同数据页)下,此功能非常有用。

基本上就是这样!一旦参数对象被创建,它就会被发送到我们编写的ViewModelProvider,该对象返回一个ViewModel给ViewModelFactory,ViewModelFactory再将其返回给Controller,Controller接着选择用于显示数据的视图,并将其相应地返回给调用者。

结论

PseudoCQRS负责处理您在创建应用程序的查询屏幕时可能不得不编写的所有样板代码。您需要编写的唯一内容是与实际屏幕相关的特定内容(视图、ViewModel、ViewModelProvider等)。

在以后的文章中,我将介绍该架构的命令端,用户可以在其中实际修改其数据的状态。

框架代码可以在 GitHub上的PseudoCQRS仓库 中找到。

您还可以通过 NuGet 安装PseudoCQRS(也提供MVC4和MVC5版本)。

一些注释

我并未详尽地介绍查询过程中涉及的所有对象。例如,您会注意到BaseReadController带有“DbSessionManager”属性,该属性实际上负责打开和关闭到我们数据库的连接,以便ORM查询能够正常工作。同样,我也没有完全解释PrerequisitesChecker的工作原理,或者所有对象是如何注入到依赖它们的对象中的。这些主题超出了本文的范围,但在GitHub仓库的示例应用程序中,您可以通过调试器跟踪代码来确切了解所有内容的功能,或者阅读有关NHibernate会话、控制反转容器、AOP概念与属性相关的知识等以了解更多信息。

我们还创建了一个 PseudoCQRS Bootstrap项目,它将执行所有必要的引导工作,以使项目能够与PseudoCQRS以及以下技术一起工作:Unity、AutoMapper、FluentValidation和NHibernate。同样,这些项目也超出了本文的范围,但如果您有任何疑问,请随时在评论区提问。

© . All rights reserved.