问题描述
我将以 Airbnb 为例。
注册爱彼迎账户后,您可以通过创建房源成为房东。要创建房源,Airbnb UI 将指导您分多个步骤完成创建新房源的过程:
它还会记住你走过的最远的一步,所以下次当你想继续这个过程时,它会重定向到你离开的地方。
我一直在努力决定是否应该将列表作为聚合根,并将方法定义为可用步骤,还是将每个步骤视为它们自己的聚合根,以便它们很小?
列为聚合根
public sealed class Listing : AggregateRoot
{
private List<Photo> _photos;
public Host Host { get; private set; }
public PropertyAddress PropertyAddress { get; private set; }
public Geolocation Geolocation { get; private set; }
public Pricing Pricing { get; private set; }
public IReadonlyList Photos => _photos.AsReadOnly();
public ListingStep LastStep { get; private set; }
public ListingStatus Status { get; private set; }
private Listing(Host host,PropertyAddress propertyAddress)
{
this.Host = host;
this.PropertyAddress = propertyAddress;
this.LastStep = ListingStep.GeolocationAdjustment;
this.Status = ListingStatus.Draft;
_photos = new List<Photo>();
}
public static Listing Create(Host host,PropertyAddress propertyAddress)
{
// validations
// ...
return new Listing(host,propertyAddress);
}
public void AdjustLocation(Geolocation newGeolocation)
{
// validations
// ...
if (this.Status != ListingStatus.Draft || this.LastStep < ListingStep.GeolocationAdjustment)
{
throw new InvalidOperationException();
}
this.Geolocation = newGeolocation;
}
...
}
聚合根中的大多数复杂类只是值对象,而 ListingStatus
只是一个简单的枚举:
public enum ListingStatus : int
{
Draft = 1,Published = 2,Unlisted = 3,Deleted = 4
}
但是 ListingStep
可以是一个枚举类,用于存储当前步骤可以前进的下一步:
using Ardalis.Smartenum;
public abstract class ListingStep : Smartenum<ListingStep>
{
public static readonly ListingStep GeolocationAdjustment = new GeolocationAdjustmentStep();
public static readonly ListingStep Amenities = new Amenitiesstep();
...
private ListingStep(string name,int value) : base(name,value) { }
public abstract ListingStep Next();
private sealed class GeolocationAdjustmentStep : ListingStep
{
public GeolocationAdjustmentStep() :base("Geolocation Adjustment",1) { }
public override ListingStep Next()
{
return ListingStep.Amenities;
}
}
private sealed class Amenitiesstep : ListingStep
{
public Amenitiesstep () :base("Amenities",2) { }
public override ListingStep Next()
{
return ListingStep.Photos;
}
}
...
}
将所有内容都放在列表聚合根中的好处是可以确保所有内容都具有事务一致性。并且这些步骤被定义为领域关注点之一。
缺点是聚合根很大。在每个步骤中,为了调用列表操作,您必须加载包含所有内容的列表聚合根。
对我来说,听起来除了地理位置调整可能取决于房产地址,其他步骤不相互依赖。例如,listing 的标题和描述与您上传的照片无关。
所以我在想我是否可以将每个步骤视为它们自己的聚合根?
每一步都是自己的聚合根
public sealed class Listing : AggregateRoot
{
public Host Host { get; private set; }
public PropertyAddress PropertyAddress { get; private set; }
private Listing(Host host,PropertyAddress propertyAddress)
{
this.Host = host;
this.PropertyAddress = propertyAddress;
}
public static Listing Create(Host host,PropertyAddress propertyAddress)
{
// Validations
// ...
return new Listing(host,propertyAddress);
}
}
public sealed class ListingGeolocation : AggregateRoot
{
public Guid ListingId { get; private set; }
public Geolocation Geolocation { get; private set; }
private ListingGeolocation(Guid listingId,Geolocation geolocation)
{
this.ListingId = listingId;
this.Geolocation = geolocation;
}
public static ListingGeolocation Create(Guid listingId,Geolocation geolocation)
{
// Validations
// ...
return new ListingGeolocation(listingId,geolocation);
}
}
...
将每个步骤作为自己的聚合根的好处是它使聚合根变小(对于某些扩展,我什至觉得它们太小了!)所以当它们被持久化回数据存储时,性能应该是更快。
缺点是我失去了列表聚合的事务一致性。例如,列表地理位置聚合仅通过 Id 引用列表。我不知道我是否应该在那里放置一个列表值对象,以便我可以在上下文中获得更多有用的信息,例如最后一步、列表状态等。
以意见为基础关闭?
我在网上找不到任何示例来说明如何在 DDD 中对这种类似向导的样式进行建模。此外,我发现的关于将一个巨大的聚合根拆分为多个较小的聚合根的大多数示例都是关于一对多关系的,但我这里的示例主要是关于一对一关系(可能除了照片)。
我认为我的问题不会基于意见,因为
您可以建议我采用哪种方法以及为什么,或者与我列出的两种方法不同的其他方法以及原因。
解决方法
让我们讨论一下拆分大型集群聚合的几个原因:
- 多用户环境中的交易问题。
在我们的例子中,只有一个
Host
管理Listing
。其他用户只能发表评论。将Review
建模为单独的聚合允许根Listing
上的事务一致性。 - 性能和可扩展性。
与往常一样,这取决于您的特定用例和需求。不过,一旦创建了
Listing
,您通常会查询整个列表以将其呈现给用户(可能折叠的评论部分除外)。
现在让我们看看值对象的候选者(不需要身份):
- 位置
- 设施
- 描述和标题
- 设置
- 可用性
- 价格
请记住,将内部部件限制为值对象是有好处的。一方面,它大大降低了整体复杂性。
至于向导部分,关键是需要记住当前步骤:
...,所以下次当你想继续这个过程时,它会重定向到你离开的地方。
由于聚合在概念上是一个持久性单元,因此从上次中断的地方恢复将需要我们保留部分水合的聚合。您确实可以在聚合上存储 ListingStep
,但从域的角度来看,这真的有意义吗?在 Amenities
和 Description
之前是否需要指定 Title
?这真的是 Listing
聚合的问题,还是可以将其移至服务?如果所有 Listing
都是通过使用同一服务创建的,则此服务可以轻松确定上次停止的位置。
将这种向导方法引入域模型感觉就像违反了关注点分离原则。 B&B 领域专家很可能对向导流程漠不关心。
考虑到以上所有因素,Listing
作为聚合根似乎是一个不错的起点。
更新
我认为向导是 UI 的概念,而不是域的概念,因为理论上,由于每个步骤不依赖于其他步骤,因此您可以按任何顺序完成任何步骤。
事实上,独立的步骤清楚地表明,在输入数据的顺序上没有由聚合构成的真正不变量。在这种情况下,它甚至不是域问题。
我可以将这些步骤建模为它们自己的聚合根,并让 UI 确定上次停止的位置。
向导步骤(页面)不应映射到它们自己的聚合。在 DDD 之后,用户操作通常会被转发到应用程序 API/服务,后者又可以将工作委托给域对象和服务。应用服务只关心技术/基础设施的东西(例如持久性),因为领域对象和服务拥有丰富的领域逻辑和知识。这通常被称为洋葱或六边形架构。请注意,依赖项指向内部,因此域模型不依赖任何其他内容,并且不知道其他任何内容。
另一种思考向导的方式是,它们基本上是数据收集器。通常在最后一步完成某种处理,但在此之前的所有步骤通常只是收集数据。您可以使用此功能在用户关闭向导时(过早地)包装所有数据,将其发送到应用程序 API,然后将聚合水合并持久保存,直到用户下次访问。这样你只需要对页面进行基本的验证,不涉及真正的领域逻辑。
我对这种方法的唯一担忧是,当所有步骤都完成并且列表准备好进行审查和发布时,谁来负责?我考虑过列表汇总,但它没有所有信息。
这就是作为工作委托者的应用服务发挥作用的地方。它本身没有真正的领域知识,但它“知道”所有涉及的参与者并且可以将工作委托给他们。它不是一个未绑定的上下文(没有双关语),因为您希望将事务范围一次限制为一个聚合。如果没有,您将不得不求助于两阶段提交,但那是另一回事了。
总而言之,您可以将 ListingStatus
存储在 Listing
上,并使其背后的不变量成为根聚合的责任。因此,它应该拥有或提供所有信息以相应地更新 ListingStatus
。换句话说,这与向导步骤无关,而与描述聚合背后过程的名词和动词有关。在这种情况下,输入保护所有数据的不变量,并且它当前处于要发布的正确状态。从那时起,返回并保留只有部分状态或不连贯的聚合是非法的。
像任何其他聚合一样。它不应该关心您是在多步骤向导中还是仅在一个屏幕中收集所需的数据。这是一个 UI 问题,在向导结束时收集数据并将其传递给域。
,您正在尝试根据 UI 设计系统(向导步骤)!
在领域驱动设计中,您不应该真正关心 UI(这是一个技术细节),
你应该寻找有界上下文、不变量等。
例如: Listing bound-context:属性和客人、位置、设施、描述和标题 预订有界上下文:预订设置、日历和可用性、定价 查看有界上下文:
列表不必是全局列表,
您可以显示您拥有“列表上下文”中的所有必需信息并且在搜索期间等可用的列表。
根据我的经验,DDD 是一种设计方法,它源自我们现在称为 Java 后端数据建模的文化。从那时起,现代 Web 开发已经成熟并发展了很多,Angular/React/Vue 框架都有自己的数据建模范式。来自 UX 背景,我将详细说明如何构建与 DDD 模型集成的 UI 组件。
从演示文稿中分离数据
MVC 设计在这里有效。天真地,此工作流的最终结果是构建 Listing
域模型。但是,我确信 AirBnB 的列表域模型要复杂得多。让我们通过将每个“步骤”视为构建独立模型的形式来近似。为简化起见,我们只考虑 Photo
和 Location.
Class Photo: Class Location:
id guid
src geolocation
为每个模型提供一个视图
将这些 UI 组件视为应该在向导上下文之外工作的“表单”模型。它们的所有字段都可以为空,代表不完整的步骤。作为不变量,视图是有效的,只要它可以构造关联模型的有效实例。
Class PhotoView: Class LocationView:
id guid
src geolocation
valid { get } valid { get }
定义控制器
现在,考虑使用视图模型 WizardView
来帮助将独立视图编排为“向导”行为。我们已经有了处理“有效/无效”状态的独立意见。现在我们只需要了解“当前”步骤。在 AirBnb UX 中,“当前”步骤似乎更像是“选定”状态,其中列表项已展开,所有其他项均已折叠。无论哪种方式,整页转换或“选定”都表示“此步骤处于活动状态 所有其他步骤均处于非活动状态”的相同状态。如果 _selected
为 null,则遍历 steps[]
为第一个无效步骤,否则为 null 全部有效。
StepView 可以显示整个页面,或者在 AirBnb 的情况下,可以显示单个列表项,其中 status == view.valid
。
Class WizardView: Class StepView:
steps[] title
_selected view
selected { get set } status { get }
addStep(StepView)
submit()
submit()
表示当所有步骤都有效并且可以构建域模型时要触发的任何处理。请注意我是如何推迟任何真实域模型的实际创建的,并且只在视图中维护“表单”或“草稿”数据结构。只有在 submit(),
按钮按下或作为“所有有效”事件发生时的回调时,这些视图才会冒泡数据,最有可能发出服务器请求。您可以在此处构建更高级别的 Listing
模型并将其设为您的请求负载。但是,与后端通信不是向导的工作。它只是将所有数据汇集在一起以供适当的处理程序来构建有效的请求。
为什么?理想情况下,前端应该使用与后端相同的域模型。至少您的 UX 模型应该一对一地匹配高级聚合。前端的想法是与后端不太可能改变的高级抽象层接口,同时让它自由地在它需要的任何内部域中分解和重组数据。在实践中,前端和后端域不同步,因此最好在请求级别留出一个数据处理层,以便用户体验在内部保持一致和连贯。