领域驱动设计对软件复杂度的应对

不管是因为规模与结构制造的理解力障碍,还是因为变化带来的预测能力问题,最终的决定因素还是因为需求。Eric Evans认为“很多应用程序最主要的复杂性并不在技术上,而是来自领域本身、用户的活动或业务”。因而,领域驱动设计关注的焦点在于领域和领域逻辑,因为软件系统的本质其实是给客户(用户)提供具有业务价值的领域功能。

需求引起的软件复杂度

需求分为业务需求与质量属性需求,因而需求引起的复杂度可以分为两个方面:技术复杂度业务复杂度

技术复杂度来自需求的质量属性,诸如安全、高性能、高并发、高可用性等需求,为软件设计带来了极大的挑战。让人难受的是这些因素彼此之间又可能互相矛盾互相影响。例如,系统安全性要求对访问进行控制,无论是增加防火墙,还是对传递的消息进行加密,又或者对访问请求进行认证和授权,都需要为整个系统架构添加额外的间接层。这不可避免会对访问的低延迟产生影响,拖慢了系统的整体性能。又例如为了满足系统的高并发访问,我们需要对应用服务进行物理分解,通过横向增加更多的机器来分散访问负载;同时,我们还可以将一个同步的访问请求拆分为多级步骤的异步请求,再通过引入消息中间件对这些请求进行整合和分散处理。这种分离一方面增加了系统架构的复杂性,另一方面也因为引入了更多的资源,使得系统的高可用面临挑战,并增加了维护数据一致性的难度。

业务复杂度对应了客户的业务需求,因而这种复杂度往往会随着需求规模的增大而增加。由于需求不可能做到完全独立,一旦规模扩大到一定程度,不仅产生了功能数量的增加,还会因为功能互相之间的依赖与影响使得这种复杂度产生叠加,进而影响到整个系统的质量属性,例如系统的可维护性与可扩展性。在考虑系统的业务需求时,还会因为沟通不畅、客户需求不清晰等多种局外因素带来需求的变更和修改。如果不能很好地控制这种变更,就可能因为多次修改导致业务逻辑纠缠不清,系统可能开始慢慢腐烂,变得不可维护,最终形成一种如Brian Foote和Joseph Yoder所说的“大泥球”系统。

以电商系统的促销规则为例。针对不同类型的顾客与产品,商家会提供不同的促销力度;促销的形式多种多样,包括赠送积分、红包、优惠券、礼品;促销的周期需要支持定制,既可以是特定的日期,例如双十一促销,也可以是节假日的固定促销模式。如果我们在设计时没有充分考虑促销规则的复杂度,并处理好促销规则与商品、顾客、卖家与支付乃至于物流、仓储之间的关系,开发过程就会变得踉踉跄跄,举步维艰。

技术复杂度与业务复杂度并非完全独立,二者混合在一起产生的化合作用更让系统的复杂度变得不可预期,难以掌控。同时,技术的变化维度与业务的变化维度并不相同,产生变化的原因也不一致,倘若未能很好地界定二者之间的关系,系统架构缺乏清晰边界,会变得难以梳理。复杂度一旦增加,团队规模也将随之扩大,再揉以严峻的交付周期、人员流动等诸多因素,就好似将各种不稳定的易燃易爆气体混合在一个不可逃逸的密闭容器中一般,随时都可能爆炸:

随着业务需求的增加与变化,以及对质量属性的高标准要求,自然也引起了软件系统规模的增大与结构的繁杂,至于变化,则是软件开发绕不开的话题。因此,当我们面对一个相对复杂的软件系统时,通常面临的问题在于:

  • 问题域过于庞大而复杂,使得从问题域中寻求解决方案的挑战增加。该问题与软件系统的规模有关。
  • 开发人员将业务逻辑的复杂度与技术实现的复杂度混淆在一起。该问题与软件系统的结构有关。
  • 随着需求的增长和变化,无法控制业务复杂度和技术复杂度。该问题与软件系统的变化有关。

针对这三个问题,领域驱动设计都给出了自己的应对措施。

领域驱动设计的应对措施

隔离业务复杂度与技术复杂度

要避免业务逻辑的复杂度与技术实现的复杂度混淆在一起,首要任务就是确定业务逻辑与技术实现的边界,从而隔离各自的复杂度。这种隔离也是题中应有之义,毕竟技术与业务的关注点完全不同。例如在电商的领域逻辑中,订单业务关注的业务规则包括验证订单有效性,计算订单总额,提交和审核订单的流程等;技术关注点则从实现层面保障这些业务能够正确地完成,包括确保分布式系统之间的数据一致性,确保服务之间通信的正确性等。

业务逻辑并不关心技术是如何实现的。无论采用何种技术,只要业务需求不变,业务规则就不会变化。换言之,理想状态下,我们应该保证业务规则与技术实现是正交的

领域驱动设计通过分层架构六边形架构确保业务逻辑与技术实现的隔离。

分层架构的关注点分离

分层架构遵循了“关注点分离”原则,将属于业务逻辑的关注点放到领域层(Domain Layer)中,而将支撑业务逻辑的技术实现放到基础设施层(Infrastructure Layer)中。同时,领域驱动设计又颇具创见的引入了应用层(Application Layer)。应用层扮演了双重角色。一方面它作为业务逻辑的外观(Facade),暴露了能够体现业务用例的应用服务接口;另一方面它又是业务逻辑与技术实现的粘合剂,实现二者之间的协作。

下图展现的就是一个典型的领域驱动设计分层架构。蓝色区域的内容与业务逻辑有关,灰色区域的内容与技术实现有关,二者泾渭分明,然后汇合在应用层。应用层确定了业务逻辑与技术实现的边界,通过直接依赖或者依赖注入(DI,Dependency Injection)的方式将二者结合起来:

六边形架构的内外分离

由Cockburn提出的六边形架构则以“内外分离”的方式,更加清晰地勾勒出业务逻辑与技术实现的边界,且将业务逻辑放在了架构的核心位置。这种架构模式改变了我们观察系统架构的视角:

体现业务逻辑的应用层与领域层处于六边形架构的内核,并通过内部的六边形边界与基础设施的模块隔离开。当我们在进行软件开发时,只要恪守架构上的六边形边界,就不会让技术实现的复杂度污染到业务逻辑,保证了领域的整洁。边界还隔离了变化产生的影响。如果我们在领域层或应用层抽象了技术实现的接口,再通过依赖注入将控制的方向倒转,业务内核就会变得更加的稳定,不会因为技术选型或其他决策的变化而导致领域代码的修改。

案例:隔离数据库与缓存的访问

领域驱动设计建议我们在领域层建立资源库(Repository)的抽象,它的实现则被放在基础设施层,然后采用依赖注入在运行时为业务逻辑注入具体的资源库实现。那么,对于处于内核之外的Repositories模块而言,即使选择从MyBatis迁移到Sprint Data,领域代码都不会受到牵连:

package practiceddd.ecommerce.ordercontext.application;

@Transaction
public class OrderAppService {
@Service
private PlaceOrderService placeOrder;

public void placeOrder(Identity buyerId, List<OrderItem> items, ShippingAddress shipping, BillingAddress billing) {
try {
palceOrder.execute(buyerId, items, shipping, billing);
} catch (OrderRepositoryException | InvalidOrderException | Exception ex) {
ex.printStackTrace();
logger.error(ex.getMessage());
}
}
}

package practiceddd.ecommerce.ordercontext.domain;

public interface OrderRepository {
List<Order> forBuyerId(Identity buyerId);
void add(Order order);
}

public class PlaceOrderService {
@Repository
private OrderRepository orderRepository;

@Service
private OrderValidator orderValidator;

public void execute(Identity buyerId, List<OrderItem> items, ShippingAddress shipping, BillingAddress billing) {
Order order = Order.create(buyerId, items, shipping, billing);
if (orderValidator.isValid(order)) {
orderRepository.add(order);
} else {
throw new InvalidOrderException(String.format("the order which placed by buyer with %s is invalid.", buyerId));
}
}
}

package practiceddd.ecommerce.ordercontext.infrastructure.db;

public class OrderMybatisRepository implements OrderRepository {}
public class OrderSprintDataRepository implements OrderRepository {}

对缓存的处理可以如法炮制,但它与资源库稍有不同之处。资源库作为访问领域模型对象的入口,其本身提供的增删改查功能,在抽象层面上是对领域资源的访问。因此在领域驱动设计中,我们通常将资源库的抽象归属到领域层。对缓存的访问则不相同,它的逻辑就是对key和value的操作,与具体的领域无关。倘若要为缓存的访问方法定义抽象接口,在分层的归属上应该属于应用层,至于实现则属于技术范畴,应该放在基础设施层:

package practiceddd.ecommerce.ordercontext.application;

@Transaction
public class OrderAppService {
@Repository
private OrderRepository orderRepository;

@Service
private CacheClient<List<Order>> cacheClient;

public List<Order> findBy(Identity buyerId) {
Optional<List<Order>> cachedOrders = cacheClient.get(buyerId.value());
if (cachedOrders.isPresent()) {
return orders.get();
}
List<Order> orders = orderRepository.forBuyerId(buyerId);
if (!orders.isEmpty()) {
cacheClient.put(buyerId.value(), orders);
}
return orders;
}
}

package practiceddd.ecommerce.ordercontext.application.cache;

public interface CacheClient<T> {
Optional<T> get(String key);
void put(String key, T value);
}

package practiceddd.ecommerce.ordercontext.infrastructure.cache;

public class RedisCacheClient<T> implements CacheClient<T> {}

限界上下文的分而治之

在前面分析缓存访问接口的归属时,我们将接口放在了系统的应用层。从层次的职责来看,这样的设计是合理的,但它却使得系统的应用层变得更加臃肿,职责也变得不够单一了。这是分层架构与六边形架构的局限所在,因为这两种架构模式仅仅体现了一个软件系统的逻辑划分。倘若我们将一个软件系统视为一个纵横交错的魔方,前述的逻辑划分仅仅是一种水平方向的划分。至于垂直方向的划分,则是面向垂直业务的切割。这种方式更利于控制软件系统的规模,将一个庞大的软件系统划分为松散耦合的多个小系统的组合。

针对前述案例,我们可以将缓存视为一个独立的子系统。它同样拥有自己的业务逻辑和技术实现,因而也可以为其建立属于缓存领域的分层架构。在架构的宏观视角,这个缓存子系统与订单子系统处于同一个抽象层次,这一概念在领域驱动设计中,被称之为限界上下文(Bounded Context)。

针对庞大而复杂的问题域,限界上下文采用了“分而治之”的思想对问题域进行了分解,有效地控制了问题域的规模,进而控制了整个系统的规模。一旦规模减小,无论业务复杂度还是技术复杂度,都会得到显著的降低,在对领域进行分析以及建模时,也能变得更容易。如果说分层架构与六边形架构确保了业务逻辑与技术实现的隔离,则限界上下文对整个系统进行了划分,将一个大系统拆分为一个个小系统后,我们再利用分层架构与六边形架构思想对其进行逻辑分层,设计会变得更易于把控,系统的架构也会变得更加的清晰。

案例:限界上下文帮助架构的演进

国际报税系统是为跨国公司的驻外出差雇员(系统中被称之为Assignee)提供方便一体化的税收信息填报平台。客户是一家会计师事务所,该事务所的专员(Admin)通过该平台可以收集雇员提交的报税信息,然后对这些信息进行税务评审。如果Admin评审出信息有问题,则返回给Assignee重新修改和填报。一旦信息确认无误,则进行税收分析和计算,并获得最终的税务报告提交给当地政府以及雇员本人。

系统主要涉及的功能包括:

  • 驻外出差雇员的薪酬与福利
  • 税收计划与合规评审
  • 对税收评审的分配管理
  • 税收策略设计与评审
  • 对驻外出差雇员的税收合规评审
  • 全球的Visa服务

主要涉及的用户角色包括:

  • Assignee:驻外出差雇员
  • Admin:税务专员
  • Client:出差雇员的雇主

在早期的架构设计时,架构师并没有对整个系统的问题域进行拆分,而是基于用户角色对系统进行了简单粗暴的划分,分为两个相对独立的子系统:Frond End与Office End。这两个子系统单独部署,分别面向Assignee与Admin。系统之间的集成则通过消息和Web Service进行通信。两个子系统的开发分属不同的团队,Frond End由美国的团队负责开发与维护,而Office End则由印度的团队负责。整个架构如下图所示:

采用这种架构面临如下问题:

  • 庞大的代码库:整个Front End和Office End都没有做物理分解,随着需求的增多,代码库变得格外庞大
  • 分散的逻辑:系统分解的边界是不合理的,没有按照业务分解,而是按照用户的角色进行分解,导致大量相似的逻辑分散在两个不同的子系统中
  • 重复的数据:两个子系统中存在业务重叠,因而也导致了部分数据的重复
  • 复杂的集成:Front End与Office End因为某些相关的业务需要彼此通信,这种集成关系是双向的,且由两个不同的团队开发,导致集成的接口混乱,消息协议多样化
  • 知识未形成共享:两个团队完全独立开发,没有掌握端对端的整体流程,团队之间没有形成知识的共享
  • 无法应对需求变化: 新增需求包括对国际旅游、Visa的支持,现有系统的架构无法很好地支持这些变化

采用领域驱动设计,我们将架构的主要关注点放在了“领域”,与客户进行了充分的需求沟通和交流。通过分析已有系统的问题域,结合客户提出的新需求,对整个问题域进行了梳理,并利用限界上下文对问题域进行了分解,获得了如下限界上下文:

  • Account Management:管理用户的身份与配置信息
  • Calendar Management:管理用户的日程与旅行足迹

之后,客户希望能改进需求,做到全球范围内的工作指派与管理,目的在于提高公司的运营效率。通过对领域的分析,我们又识别出两个限界上下文。在原有的系统架构中,这两个限界上下文同时处于Front End与Office End之中,属于重复开发的业务逻辑:

  • Work Record Management:实现工作的分配与任务的跟踪
  • File Sharing:目的是实现客户与会计师事务所之间的文件交换

随着我们对领域知识的逐渐深入理解与分析,又随之识别出如下限界上下文:

  • Consent:管理合法的遵守法规的状态
  • Notification:管理系统与客户之间的交流
  • Questionnaire:对问卷调查的数据收集

这个领域分析的过程实际上就是通过对领域的分析,引入限界上下文对问题域进行分解,通过降低规模的方式降低问题域的复杂度;同时,通过为模型确定清晰的边界,使得系统的结构变得更加的清晰,保证了领域逻辑的一致性。一旦确定了清晰的领域模型,就能够帮助我们更加容易地发现系统的可重用点与可扩展点,并遵循“高内聚松耦合”原则对系统职责进行合理分配,再辅以分层架构划分逻辑边界,如下图所示:

我们将识别出来的限界上下文定义为微服务,并对外公开REST服务接口。UI Applications是一个薄薄的展现层,它会调用后端的RESTful服务,也使得服务在保证接口不变的前提下能够单独演化。每个服务都是独立的,可以单独部署,因而可以针对服务建立单独的代码库和对应的特性团队(Feature Team)。服务的重用性和可扩展性也有了更好的保障,服务与UI之间的集成变得更简单,整个架构更加清晰了。

领域模型对领域知识的抽象

领域模型是对业务需求的一种抽象,表达了领域概念、领域规则以及领域概念之间的关系。一个好的领域模型是对统一语言的可视化表示,通过它可以减少需求沟通可能出现的歧义;通过提炼领域知识,并运用抽象的领域模型去表达,就可以达到对领域逻辑的化繁为简。模型是封装,实现了对业务细节的隐藏;模型是抽象,提取了领域知识的共同特征,保留了面对变化时能够良好扩展的可能性。

案例:项目管理系统的领域模型

我们开发的项目管理系统需要支持多种软件项目管理流程,例如瀑布、RUP、XP或者Scrum。这些项目管理流程是迥然不同的,如果需要各自提供不同的解决方案,就会使得系统的模型变得非常复杂,也可能引入许多不必要的重复。通过领域建模,我们可以对项目管理领域的知识进行抽象,寻找具有共同特征的领域概念。这就需要分析各种项目管理流程的主要特征与表现,才能从中提炼出领域模型。

瀑布式软件开发由需求、分析、设计、编码、测试、验收六个阶段构成,每个阶段都由不同的活动构成,这些活动可能是设计或开发任务,也可能是召开评审会。流程如下图所示:

RUP清晰地划分了四个阶段:先启阶段、细化阶段、构造阶段与交付阶段。每个阶段可以包含一到多个迭代,每个迭代有不同的工作,例如业务建模、分析设计、配置与变更管理等。RUP的流程如下图所示:

XP作为一种敏捷方法,采用了迭代的增量式开发,提倡为客户交付具有业务价值的可运行软件。在执行交付计划之前,XP要求团队对系统的架构做一次预研(Architectual Spike,又被译为架构穿刺)。当架构的初始方案确定后,就可以进入每次小版本的交付。每个小版本交付又被划分为多个周期相同的迭代。在迭代过程中,要求执行一些必须的活动,如编写用户故事、故事点估算、验收测试等。XP的流程如下图所示:

Scrum同样是迭代的增量开发过程。项目在开始之初,需要在准备阶段确定系统愿景、梳理业务用例、确定产品待办项(product backlog)、制定发布计划以及组建团队。一旦在确定了产品待办项以及发布计划之后,就进入sprint迭代阶段。sprint迭代过程是一个固定时长的项目过程,在这个过程中,整个团队需要召开计划会议、每日站会、评审会议和回顾会议。Scrum的流程如下图所示:

不同的项目管理流程具有不同的业务概念。例如瀑布式开发分为了六个阶段,但却没有发布和迭代的概念。RUP没有发布的概念,而Scrum又为迭代引入了sprint的概念。

不同的项目管理流程具有不同的业务规则。例如RUP的四个阶段会包含多个迭代周期,每个迭代周期都需要完成对应的工作,只是不同的工作在不同阶段所占的比重不同。XP需要在进入发布阶段之前,进行架构预研,而在每次小版本发布之前,都需要进行验收测试和客户验收。Scrum的sprint是一个基本固定的流程,每个迭代召开的四会(计划会议、评审会议、回顾会议与每日站会)都有明确的目标。

领域建模就是要从这些纷繁复杂的领域逻辑中寻找到能够表示项目管理领域的概念,并利用面向对象建模范式或其他范式对概念进行抽象,并确定它们之间的关系。经过对这些项目管理流程的分析,我们虽然发现在业务概念和规则上确有不同之处,但由于它们都归属于软件开发领域,我们自然也能寻找到某些共同特征的蛛丝马迹。

首先,从项目管理系统的角度看,无论针对何种项目管理流程,我们的主题需求是不变的,就是要为这些管理流程制定软件开发计划(Plan)。不同之处在于,计划可以由多个阶段(Phase)组成,也可以由多个发布(Release)组成。一些项目管理流程没有发布的概念,我们可以认为是一个发布。那么,到底是发布包含了多个阶段,还是阶段包含了多个发布呢?我们发现在XP中,明显地划分了两个阶段:Architecture Spike与Release Planning,而发布只属于Release Planning阶段。因而从概念内涵上,我们可以认为是阶段(Phase)包含了发布(Release)。每个发布又包含了一到多个迭代(Iteration),至于Scrum的sprint概念其实可以看做是迭代的一种特例。每个迭代可以开展多种不同的活动(Activity),这些活动可以是整个团队参与的会议,也可以是部分成员或特定角色执行的实践。对于计划而言,我们还需要跟踪任务(Task)。与活动不同,任务具有明确的计划起止时间、实际起止时间、工作量、优先级与承担人。

于是,我们提炼出如下的统一领域模型:

为了项目管理者更加方便地制定项目计划,产品经理提出了计划模板功能。当管理者选择对应的项目管理生命周期类型后,系统会自动创建满足其规则的初始计划。基于该需求,我们更新了之前的领域模型:

在增加的领域模型中,LifeCycleSpecification是一个隐含的概念,遵循领域驱动设计提出的规格(Specification)模式,封装了项目开发生命周期的约束规则。

领域模型以可视化的方式清晰地表达了业务含义,我们可以根据这个模型来指导后面的程序设计与编码实现。当增加新的需求或者需求发生变化时,我们能够敏锐地捕捉到现有模型的不匹配之处,并对其进行更新。领域模型传递了知识,可以作为交流的载体,符合人们的心智模型,有利于让开发人员从纷繁复杂的业务中解脱出来。这是领域驱动设计对于前述第三个问题——控制业务复杂度的解答。

您的赞赏是我创作的动力!