针对领域行为产生的依赖,我们可以通过抽象接口来解耦。例如,前面提到订单上下文对促销上下文的调用就通过引入防腐层(ACL)解除了对促销上下文的直接依赖;然而,限界上下文依赖的领域模型呢,又该如何处理?
与领域行为相同,我们首先还是要判断限界上下文是否真正对别的领域模型产生了依赖!例如,要查询客户拥有的所有订单信息,应该像如下代码那样将订单列表当做客户的一个属性吗?
public class Customer {
private List<SaleOrder> saleOrders;
public List<SaleOrder> getSaleOrders() {
return saleOrders;
}
}
如果采取这样的设计,自然就会在客户上下文中产生对 SaleOrder 领域模型的依赖,然而,这种实现并不可取。因为这样的设计会导致在加载 Customer 时,需要加载可能压根儿就用不上的List<SaleOrder>
,影响了性能。虽然通过延迟加载可以在一定程度解决性能问题,但既然存在延迟加载,就说明二者不一定总是同时需要。故而,延迟加载成为了判断领域实体对象设计是否合理的标志。
那么,是否可以用查询方法来替换属性?例如:
public class Customer {
public List<SaleOrder> saleOrders() {
// ...
}
}
在领域驱动设计中,我们通常不会这样设计,而是引入资源库对象来履行查询职责。若要查询订单,则 SaleOrder 会作为聚合根,对应的 SaleOrderRepository 作为资源库被放到订单上下文。在分层架构中,资源库对象可能会被封装到应用服务中,也可能直接暴露给作为适配器的 REST 服务中,例如,定义为:
@Path("/saleorder-context/saleorders/{customerId}")
public class SaleOrderController {
@Autowired
private SaleOrderRepository repository;
public List<SaleOrder> allSaleOrdersBy(CustomerId customerId) {
return repository.allSaleOrdersBy(customerId);
}
}
REST 服务的调用者并非客户上下文,而是前端或第三方服务以及客户端,貌似自然的客户与订单的包含关系,就如此被解开了。客户上下文与订单上下文并没有因为客户与订单的包含关系,使得它们二者产生协作。
如果确实存在跨限界上下文消费领域模型的场景,例如,订单上下文在查询订单时需要获得订单对应的商品信息时,我们该如何设计?存在两种设计决策:
在确定采用何种设计决策时,又会受到限界上下文边界的影响!进程内和进程间边界带来的影响是完全不同的。倘若商品上下文与订单上下文属于两个架构零共享的限界上下文,就不应采用重用领域模型的方式。因为这种模型的重用又导致了二者不再是“零共享”的。之所以采用零共享架构,是希望这两个限界上下文能够独立演化,包括部署与运行的独立性。倘若一个限界上下文重用了另一个限界上下文的领域模型,就意味着二者的代码模型是耦合的,即产生了包之间的依赖,而非服务的依赖。一旦该重用的模型发生了变化,就会导致依赖了该领域模型的服务也要重新部署。零共享架构带来的福利就荡然无存了。
如果二者之间的通信是发生在进程内,又该如何决策呢?
这其实是矛盾的两面,以 Product 领域对象为例:
事实上,在两个不同的限界上下文中为相同或相似的领域概念分别建立独立的领域模型为常见做法。例如,Martin Fowler 在介绍限界上下文时,就给出了如下的设计图:
Sales Context 与 Support Context 都需要客户与商品信息,但它们对客户与商品的关注点是不相同的。销售可能需要了解客户的性别、年龄与职业,以便于他更好地制定推销策略,售后支持则不必关心这些信息,只需要客户的住址与联系方式。正如前面在讲解限界上下文的边界时,我们已经提到了限界上下文作为保持领域概念一致性的业务边界而存在。上图清晰地表达了为两个不同限界上下文分别建立独自的 Customer 与 Product 领域模型对象,而非领域模型的重用。
我个人倾向于分离的领域模型,因为相较于维护相似领域对象的成本,我更担心随着需求变化的不断发生需要殚精竭虑地规避(降低)重用的代价。
当我们选择分离方式时,很多人会担心所谓的“数据同步”问题,其实只要我们正确地进行了领域建模,这个问题是不存在的。大体说来,这种数据同步可能存在以下三种情况:
所谓“数据产生的依赖”,来自于数据库。倘若严格遵循领域驱动设计,通常不会产生这种数据库层面的依赖,因为我们往往会通过领域模型的资源库去访问数据库,与数据库交互的对象也应该是领域模型对象(实体和值对象)。即使有依赖,也应该是领域行为与领域模型导致的。
有时候,出于性能或其他原因的考虑,一个限界上下文去访问属于另外一个限界上下文边界的数据时,有可能跳过领域模型,直接通过 SQL 或存储过程的方式对多张表执行关联查询,CQRS 模式的读模型(Read Model)正是采用了这种形式。而在许多报表分析场景中,这种访问跨限界上下文数据表的方式确实是最高效最简单的实现方式。当然,这一切建立在一个前提:即限界上下文之间至少是数据库共享的。
我们必须警惕这种数据产生的依赖,没有绝对的理由,我们不要轻易做出这种妥协。SQL 乃至存储过程形成的数据表关联,是最难进行解耦的。一旦我们的系统架构需要从单体架构(或数据库共享架构)演进到微服务架构,最大的障碍不是代码层面而是数据库层面的依赖,这其中就包括大量复杂的 SQL 与存储过程。
SQL 与存储过程的问题在于:
如果选择使用 SQL 与存储过程,当数据库自身出现瓶颈时,会陷入进退两难的境地,要么继续保持使用 SQL 与存储过程,但调优空间非常小;要么就采用垂直扩展,直接更换性能更好的机器。但这种应对方法无异于饮鸩止渴,不能解决根本问题。如果想要去掉 SQL 与存储过程,又需要对架构做重大调整,需要耗费较大的架构重构成本,对架构师的能力也要求颇高。在调整架构时,由于需要将 SQL 与存储过程中蕴含的业务逻辑进行迁移,还可能存在迁移业务逻辑时破坏原有功能的风险。选择架构调整,需得管理层具备壮士扼腕的勇气与魄力才行。
无论是零共享架构的分库模式,还是数据库共享模式,我们都需要尽量避免因为在数据库层面引起多个限界上下文的依赖。获取数据有多种方式,除了通过领域模型中聚合根的资源库访问数据之外,我们也可以通过数据同步的方式,对多个限界上下文的数据进行整合。例如,电商网站的推荐系统,它将作为整个系统中一个独立的限界上下文。为了获得更加精准精细的推荐结果,推荐系统需要获取买家的访问日志、浏览与购买的历史记录、评价记录,需要获得商品的类别、价格、销售量、评价分数等属性,需要获取订单的详细记录,是否有退换货等一系列的信息。但这并不意味着推荐上下文会作为客户上下文、商品上下文、订单上下文等的下游客户方,也未必需要在数据库层面对多张表执行关联操作。
推荐算法的数据分析往往是一个大数据量分析,它需要获得的数据通常存储在扮演 OLTP(On-Line Transaction Processing,联机事务处理)角色的业务数据库。业务数据库是支撑系统业务应用的主要阵地,并被各种请求频繁读写。倘若该数据库成为瓶颈,有可能会影响到整个电商系统的运行;倘若推荐系统通过上游限界上下文的服务从各自的数据库中加载相关数据,并存入到内存中进行分析,会大量耗用网络资源和内存资源,影响电商网站的业务系统,也无法保证推荐系统的性能需求。
从数据分析理论来说,作为 OLTP 的业务数据库是面向业务进行数据设计的,这些数据甚至可能独立存在,并未形成数据仓库的主题数据特征,即集成了多个业务数据库,并能全面一致体现历史数据变化。因此,推荐系统需要利用大数据的采集技术,通过离线或实时流处理方式采集来自多数据源的多样化数据,然后可结合数据仓库技术,为其建立主题数据区和集市数据区,为 OLAP(On-Line Analytical Processing,联机分析处理)提供支撑,也为如协同决策这样的推荐算法提供了数据支持。
当然,推荐系统需要“知道”的数据不仅限于单纯的客户数据、商品数据与订单数据,还包括针对客户访问与购买商品的行为数据,如查询商品信息、添加购物车、添加订单、提交评论等行为产生的数据,这些行为数据未必存储在业务数据库中,相反可能会以如下形式存储:
由于推荐系统需要分析的数据已经通过专门的数据采集器完成了多数据源数据的采集,并写入到属于推荐上下文的主题数据库中,因而并不存在与其他业务数据库之间的依赖。从实现看,推荐上下文作为一个独立的限界上下文,与其他限界上下文之间并不存在依赖关系,属于上下文映射的“分离方式”模式。从这个例子获得的经验是:技术方案有时候会影响到我们对上下文映射的识别。
© 2019 - 2023 Liangliang Lee. Powered by gin and hexo-theme-book.