限界上下文
限界上下文
对一个大型系统,领域模型的完全统一将是不可行的或者不划算的。DDD 的构建块不能盲目地应用在一个无限大的领域模型上,一个无限大的领域模型也无助于我们开发出优质的软件,限界上下文是分解领域模型的关键。限界上下文是一种分而治之的思维,也是一种高层的抽象机制,让人们对领域进行本质思考,简化问题和应对复杂性;它保证领域模型的一致性和完整性,清晰边界的控制力保证了领域的安全和稳定。
我们可以将限界上下文拆解为两个词:限界和上下文。限界就是领域的边界,而上下文则是语义环境。通过领域的限界上下文,我们就可以在统一的领域边界内用统一的语言进行交流。限界上下文用来封装通用语言和领域对象,提供上下文环境,保证在领域之内的一些术语、业务相关对象等(通用语言)有一个确切的含义,没有二义性。这个边界定义了模型的适用范围,使团队所有成员能够明确地知道什么应该在模型中实现,什么不应该在模型中实现。
用一个保险领域的例子来说明下术语的边界。保险业务领域有投保单、保单、批单、赔案等保险术语,它们分别应用于保险的不同业务流程。
- 客户投保时,业务人员记录投保信息,系统对应有投保单实体对象。
- 缴费完成后,业务人员将投保单转为保单,系统对应有保单实体对象,保单实体与投保单实体关联。
- 如客户需要修改保单信息,保单变为批单,系统对应有批单实体对象,批单实体与保单实体关联。
- 如果客户发生理赔,生成赔案,系统对应有报案实体对象,报案实体对象与保单或者批单实体关联。
投保单、保单、批单、赔案等,这些术语虽然都跟保单有关,但不能将保单这个术语作用在保险全业务领域。因为术语有它的边界,超出了边界理解上就会出现问题。
首先,领域可以拆分为多个子领域。一个领域相当于一个问题域,领域拆分为子域的过程就是大问题拆分为小问题的过程。在这个图里面保险领域被拆分为:投保、支付、保单管理和理赔四个子域。子域还可根据需要进一步拆分为子子域,比如,支付子域可继续拆分为收款和付款子子域。拆到一定程度后,有些子子域的领域边界就可能变成限界上下文的边界了。
子域可能会包含多个限界上下文,如理赔子域就包括报案、查勘和定损等多个限界上下文(限界上下文与理赔的子子域领域边界重合)。也有可能子域本身的边界就是限界上下文边界,如投保子域。每个领域模型都有它对应的限界上下文,团队在限界上下文内用通用语言交流。领域内所有限界上下文的领域模型构成整个领域的领域模型。理论上限界上下文就是微服务的边界。我们将限界上下文内的领域模型映射到微服务,就完成了从问题域到软件的解决方案。可以说,限界上下文是微服务设计和拆分的主要依据。在领域模型中,如果不考虑技术异构、团队沟通等其它外部因素,一个限界上下文理论上就可以设计为一个微服务。
识别限界上下文
明确了系统的问题域和业务期望后,梳理出主要的业务流程,这些业务流程体现了各种参与者在这个过程中通过业务活动共同协作,最终完成具有业务价值的领域功能。业务流程结合了参与角色(Who)、业务活动(What)和业务价值(Why)。在业务流程的基础上,我们就可以抽象出不同的业务场景,这些业务场景又由多个业务活动组成,可以利用领域场景分析方法剖析场景,以帮助我们识别业务活动,例如采用用例对场景进行分析,此时,一个业务活动实则就是一个用例。业务流程是一个由多个用户角色参与的动态过程,而业务场景则是这些用户角色执行业务活动的静态上下文。
接下来,我们利用领域场景分析的用例分析方法剖析这些场景。通过参与者(Actor)来驱动对用例的识别,这些参与者恰好就是参与到场景业务活动的角色。根据用例描述出来的业务活动应该与统一语言一致,最好直接从统一语言中撷取。一旦准确地用统一语言描述出这些业务活动,我们就可以从语义相关性和功能相关性两个方面识别业务边界,进而提炼出初步的限界上下文。
从不同角度看待限界上下文,限界上下文会呈现出对不同对象的控制力。
- 领域逻辑层面:限界上下文确定了领域模型的业务边界,维护了模型的完整性与一致性,从而降低系统的业务复杂度。
- 团队合作层面:限界上下文确定了团队的工作边界,建立了团队之间的合作模式,提升了团队间的协作效率,“康威定律”告诉我们,系统设计(产品结构)等同组织形式,每个设计系统的组织,其产生的设计等同于组织之间的沟通结构,限界上下文指导产生的团队结构的工作模式是最高效的。
- 技术架构层面:限界上下文确定了系统架构的应用边界,保证了系统层和上下文领域层各自的一致性,建立了上下文之间的集成方式。微服务中,限界上下文指导技术人员划分微服务的边界,通常一个限界上下文作为一个在独立进程中运行的微服务。
鲁棒图
鲁棒图是需求设计过程中使用的一种方法(鲁棒性分析),通过鲁棒分析法可以让设计人员更清晰、全面了解需求。它通常使用在需求分析后及需求设计前做软件架构分析之用,它主要注重于功能需求的设计分析工作。需求规格说明书为其输入信息,设计模型为其输出信息。它是从功能需求向设计方案过渡的第一步,重点是识别组成软件系统的高级职责模块、规划模块之间的关系。
鲁棒图包含三种图形:边界、控制、实体,三个图形如下:
- 边界:起与外界交互的作用,它只能与控制对象和执行者有关系
- 控制:对业务控制、流程控制的作用,它能与边界对象和实体对象有关系
- 实体:业务元素的存储对象,与领域模型中的对象有良好的关系。它只能与控制对象有关系
我们在对之前识别出的每个 Use Case,都可以绘制一份鲁棒图。通过绘制鲁棒图,我们可以定义出该场景的涉及到的边界类、控制类以及这些控制类会加工/处理哪些实体对象。我们以邮箱注册账号这个 Case 为例,我们可以简单的绘制出鲁棒图如下:
域划分评估
在领域划分之后往往可以得到一个或多个域划分的方案(或者组合),接下来就是判断哪种划分方案更为合理。主要的判定原则会参照经典的“高内聚、低耦合”的原则,即将绘制每个 Use Case 的时序图,并以域为维度来绘制生命周期线,看域与域之间的调用是否过于频繁?甚至是反复调用不同的域服务?如果存在这种情况,就意味着这两个域之间存在比较严重的耦合。这往往通过直观就能有个大致的判断。
自治单元
DDD 驱动我们把每一个限界上下文设计成一个个“自治”的单元,自治要满足四个特点:
-
最小完备是实现自治的基本条件,指的是自治单元履行的职责是根据业务价值的完整性和最小功能集进行设计的,这让自治单元无需求助其他自治单元获得信息,避免了不必要的依赖关系,同时也避免了不必要和不合适的职责添加到该自治单元上。
-
自我履行意味着由自治单元自身决定要做什么。是否应该履行某职责,由限界上下文拥有的信息来决定。站在自治单元的角度去思考:“如果我拥有了这些信息,我究竟应该履行哪些职责?”这些职责属于当前上下文的活动范围,一旦超出,就该毫不犹豫地将不属于该范围的请求转交给别的上下文。自我履行其实意味着对知识的掌握,为避免风险,你要履行的职责一定是你掌握的知识范畴之内。
-
稳定空间指的是减少外界变化对限界上下文内部的影响。稳定空间符合开放封闭原则(OCP),即对修改是封闭的,对扩展是开放的,该原则其实体现了一个单元的封闭空间与开放空间。封闭空间体现为对细节的封装与隐藏,开放空间体现为对共性特征的抽象与统一,二者共同确保了整个空间的稳定。
-
独立进化指的是减少限界上下文的变化对外界的影响。用限界上下文的上下游关系来阐释,则稳定空间寓意下游限界上下文,无论上游怎么变,我自岿然不动。要做到独立进化,就必须保证对外公开接口的稳定性,因为这些接口被众多消费者依赖和调用,一旦发生变更,就会牵一发而动全身。一个独立进化的限界上下文,需要一个稳定、设计良好的接口设计,并在版本上考虑了兼容与演化。
最小完备是基础,只有赋予了限界上下文足够的信息,才能保证它的自我履行。稳定空间与独立进化则一个对内一个对外,是对变化的有效应对,而它们又是通过最小完备和自我履行来保障限界上下文受到变化的影响最小。
域依赖度
如果要从定量角度来分析,可以参考代码圈复杂度的度量算法,我们也可以设定一个“域依赖度”算法,来衡量域与域之间的依赖度。对于依赖度比较高的几个域,我们可以采用:域合并,域拆分或者提取第三方域做依赖倒置等降低耦合。
通过将所有的 Use Case(至少是最关键的一级 Case)绘制完时序图后,我们基本上就可以得到一个经过平衡考量后合理的域划分。
上下文映射
限界上下文仅是一种对领域问题域的静态划分,还缺少一个重要的关注点,即:限界上下文之间是如何协作的?当我们发现彼此协作存在问题时,说明限界上下文的划分出现了问题,也是识别限界上下文的一种验证方法。Eric Evans 将这种体现限界上下文协作方式的要素称之为“上下文映射(Context Map)”,并给出了 9 种上下文映射关系:
Open Host Service 相当于微服务之间的协作关系;防腐层(Anti-Corruption)是一种高度防御性的策略,结合门面(Facade)模式和适配器(Adapter)设计模式,将模型与其需要集成的其他模型隔离开来,以防止被频繁变更或不稳定的依赖模型污染和腐败。
领域划分案例
接下来,我们通过对所有实体对象进行分类,就可以完成域划分了。
电商系统
比如,主订单、子订单对象可以归类到交易域;买家、卖家对象可以归类到商户域等:
先行分析实体与事件关系,如下:
- 订单:创建、付款、收款、关单、撤销、退款、查询、充转提
- 商户、用户:查询、鉴权、注册
- 产品:签约、制定计收费规划
当然,最终所有的对象是归类到十个域还是二十个域,从理论上看,可以看做一次排列组合过程。只是,我们往往可以根据以往的经验、业务知识,做一个初始的域划分(但不见得是靠谱的)。因此,我们可以认为一个域实际上是一个或多个实体对象的信息集合,并对所管理的实体对象的生命周期进行管理。
- 一个域管理一个或多个实体对象
- 一个实体对象被一个域进行管理
- 如果出现一个实体对象被多个域进行管理,那么相关域的职责实际上就是存在冲突,存在耦合,相互影响。
CRM 系统
比如在 CRM 领域,我们按照下面的战略设计图,我会自然的把 CRM 系统划分成销售服务,组织权限服务,营销服务,售卖服务:
国际报税系统
国际报税系统是为跨国公司的驻外出差雇员(系统中被称之为 Assignee)提供方便一体化的税收信息填报平台。客户是一家会计师事务所,该事务所的专员(Admin)通过该平台可以收集雇员提交的报税信息,然后对这些信息进行税务评审。如果 Admin 评审出信息有问题,则返回给 Assignee 重新修改和填报。一旦信息确认无误,则进行税收分析和计算,并获得最终的税务报告提交给当地政府以及雇员本人。
在早期的架构设计时,架构师并没有对整个系统的问题域进行拆分,而是基于用户角色对系统进行了简单粗暴的划分,分为了两个相对独立的子系统:Frond End 与 Office End,这两个子系统单独部署,分别面向 Assignee 与 Admin。系统之间的集成则通过消息和 Web Service 进行通信。两个子系统的开发分属不同的团队,Frond End 由美国的团队负责开发与维护,而 Office End 则由印度的团队负责。整个架构如下图所示:
采用这种架构面临的问题如下:
- 庞大的代码库:整个 Front End 和 Office End 都没有做物理分解,随着需求的增多,代码库会变得格外庞大。
- 分散的逻辑:系统分解的边界是不合理的,没有按照业务分解,而是按照用户的角色进行分解,因而导致大量相似的逻辑分散在两个不同的子系统中。
- 重复的数据:两个子系统中存在业务重叠,因而也导致了部分数据的重复。
- 复杂的集成:Front End 与 Office End 因为某些相关的业务需要彼此通信,这种集成关系是双向的,且由两个不同的团队开发,导致集成的接口混乱,消息协议多样化。
- 知识未形成共享:两个团队完全独立开发,没有掌握端对端的整体流程,团队之间没有形成知识的共享。
- 无法应对需求变化:新增需求包括对国际旅游、Visa 的支持,现有系统的架构无法很好地支持这些变化。