Chap10 领域模型 - 领域知识
领域模型 - 业务需求 - 概念、规则、概念之间的关系 - 统一语言可视化 - 封装,隐藏细节;抽象,提取共同特征。
了解整个系统的目标和范围,对系统的领域需求达成共识。
共识
沟通与协作 - 无法“发现”需求,而是要和客户一起“培育”需求,并在这个培育过程中逐渐成熟。
信息不同,背景不同 —— 原始的需求是“哈姆雷特”。
合同方式约定需求知识不可靠,易发生超出文档边界的变更 —— 理解偏差的存在:
- 从客户那里了解到的需求,并非最终的需求。
- 沟通有效性低,理解偏差。
- 理解到的需求并没有揭示完整的领域知识。
通过可视化的交流形式逐渐在多个角色之间达成共识:
用可视化的方式表现出来,例如,绘图、便签、编写用户故事或测试用例等都是重要的辅助手段。
看到需求之间的差异,互补不足去冗余,最终得到大家都一致认可的需求,形成统一的认知模型。
团队协作
项目之初,尤其是有客户参与的先启阶段,是最好的理解领域知识的方法。
建立初步的统一语言,在识别出主要的史诗级故事与主要用户故事之后,进而识别出限界上下文,并建立系统的逻辑架构与物理架构。
- 在期望与愿景的核心目标指导下,团队与客户才可能就问题域达成共同理解。
- 我们需要确定项目的当前状态与未来状态,从而确定项目的业务范围。
- 之后,就可以对需求进行分解了。
- 在先启阶段,对需求的分析不宜过细,因此需求分解可以从史诗级(Epic)到主故事级(Master)进行逐层划分。
- 并最终在业务范围内确定迭代开发需要的主故事列表。
迭代开发沟通关键点:
每一个功能的实现、每一行代码的编写都是围绕着用户故事开展的,它是构成领域知识的最基本单元。
发开人员
当用户故事从需求分析人员传递给开发人员时,充分理解用户故事描述的需求后,将需求分析人员与测试人员叫过来,大家一起做一个极短时间的沟通与确认。我们称这一活动为“Kick Off” —— 应对“盲人摸象”。
开发人员多问需求分析人员“为什么”,探索用户故事带来的价值,理解业务逻辑与业务规则。
开发人员要与测试人员再三确认验收标准,以形成一种事实上的需求规约。
运用领域场景提炼领域知识
业务场景分析 6W 要素:Who、What、Why、Where、When、HoW。
识别用户角色,分析用户特征和属性,辨别其在场景中参与的活动 —— 明确业务功能(What),该功能给用户带来的业务价值(Why)。
领域功能三层次(职责层次,Responsebilaty):
- 业务价值(Why):业务价值体现职责的目的,提供职责,“用户履行了职责”,场景对用户才有价值。
- 业务功能(What):支撑功能,实现业务价值。
-
下订单
下订单这一职责具有业务价值,职责分层结构:
下订单
- 验证订单有效
- 合法用户
- 信息完整
- 商品库存
- 验证业务规则计算订单总价、优惠与配送费
- 促销规则
- 订单总价
- 订单优惠
- 配送费
- 提交订单
- 插入订单、订单项
- 订单状态
- 更新购物车
- 删除商品
- 发送通知
- 邮件通知付款
- 验证订单有效
利用场景建模,考虑场景边界。校验库存量的业务实现需要库存接口,该功能属于下订单场景的边界之外(引入限界上下文)。
提炼的领域知识必须具备 6W 要素:
- 校验领域逻辑,缺乏部分要素 => 忽略了重要领域概念。
按场景 6W 模型分析领域逻辑、提炼领域知识 => 保证领域模型完整性。
领域场景分析方法
团队和领域专家的对话常态化 —— 团队形成一种相对固定的场景分析模式 => 对话更高效。
场景分析模式:用例(Use Case)
- 用户故事(User Story)
- 测试驱动开发(Test-Driven Development)
-
用例
Ivar Jacobson —— 用例 —— 通过某部分功能使用系统的一种具体方法(相关事务的一个具体序列,对话形式) —— 一个用例对应一个完整序列的事件。
用例规格说明:
用例名称:买家下订单
用例目的:本用例为买家提供了购买心仪商品的功能。
参与者:买家
前置条件:买家已经登录并将自己心仪的商品添加到了购物车。
基础流程:
1. 买家打开购物车
2. 买家提交订单
3. 验证订单是否有效
4. 计算订单总价
5. 计算订单优惠
6. 计算配送费
7. 系统提交订单
8. 删除购物车中对应的商品
9. 系统通过电子邮件将订单信息发送给买家
替代流程:系统验证订单无效
在第3步,系统确认订单无效,提示验证失败原因
替代流程:提交订单失败
在第7步,系统提交订单失败,提示订单失败原因
用例图可视化,要素: 参与者(Actor,Who)
- 用例(Use Case,What)
- 用例关系:使用(use,Why)、包含、扩展、繁华、特化等。
- 边界(Boundray,Where)
仅 place order 用例和 buyer 参与者存在使用(use)关系 —— 仅“下订单”对“买家”有业务价值 —— 领域场景主用例,其他用例为协作关系的子用例。
用例协作关系:
- 包含(include):主用例中不可缺少的一个执行步骤,缺少 => 不完整(must have)。
- 扩展(extend):对主用例的补充或强化,对主用例不产生直接影响,缺少 => 保持完整(nice to have)。
包含、扩展 —— 子用例都是服务于主用例 => 体现用例规格描述的流程(When and hoW)。
边界划分 —— 用例的职责相关性 —— 责任内聚(亲密程度) —— orders context、notification context、… => 用例边界(Where)。
总结:
开发团队和领域专家可视化沟通的一种方式。
用例的关注点是领域,避免陷入技术细节。
用例表达的领域概念必须精准,采用统一语言中的概念为每个用例命名(引入“局外人”对用例提问)。
然后用言简意赅的动宾短语描述用例,并提供英语表达(防止破坏统一语言)。
用户故事
软件系统最重要的核心 —— 用户。
用户故事 —— 用户角度 —— 身临其境,“讲故事” —— 领域场景描述 —— 6W 模型。
模板:
As a(作为)<角色>
I would like(我希望)<活动>
so that(以便于)<业务价值>
例:
作为一名用户,
我希望可以提供查询功能,
以便于了解分配给我的任务情况。
业务目标不清晰,改进:
作为一名项目成员,
我希望获取分配给自己的未完成任务,
以便于跟踪自己的工作进度。
发起者:”项目成员”,因:“跟踪工作进度(价值)”,果:“获取分配给自己的未完成任务”。
敏捷实践 —— 需求分析人员与测试人员结对编写用户故事 —— 可测试(Testable)—— 验收标准(Acceptance Criteria),是对业务活动的细节描述,并非测试用例。
阐述验收标准的方式:Given-When-Then模式,实例化需求。
例:
作为一名销售经理
我希望为订单设置合适的配送免费的总额阈值
以便于促进平均订单总额的提高
验收标准:
订单总额的货币单位应以当前国家的货币为准
订单总额阈值必须大于0
采用 Given-When-Then 模式(hoW),并通过实例化需求的方式编写用户故事:
作为一名销售经理
我希望为订单设置合适的配送免费的总额阈值
以便于促进平均订单总额的提高
场景1:订单满足配送免费的总额阈值
Given:配送免费的总额阈值设置为95元人民币
And:我目前的购物车总计90元人民币
When:我将一个价格为5元人民币的商品添加到购物车
Then:我将获得配送免费的优惠
场景2:订单不满足配送免费的总额阈值
Given:配送免费的总额阈值设置为95元人民币
And:我目前的购物车总计85元人民币
When:我将一个价格为9元人民币的商品添加到购物篮
Then:我应该被告知如果我多消费1元人民币,就能享受配送免费的优惠
行为驱动开发(Behavior-Driven Development)—— 使用领域特定语言(Domain Specific Language,DSL)描述用户行为,编写用户故事。
核心在于“行为” —— 做了什么 —— 不是怎么做 —— 业务建模阶段,业务才是中心(勿舍本逐末)。
用户故事只受业务规则与业务流程变化的影响 —— 不受影响于 UI 改动和技术方案改动。
测试驱动开发
一种开发实践:测试优先 <= 需求分析优先 <= 任务分解优先 <= 识别职责(可验证)。
任务分解后,不应针对被测方法编写单元测试,根据领域场景编写 —— 驱动我们去寻找领域概念。
遵循 Given-When-Then 模式 —— 测试的准备、期待的行为、验收条件 —— 体现 DDD 对设计的驱动力:
- Given 驱动我们思考被测对象创建,以及对象与其它对象的协作。
- When 驱动我们思考被测接口的方法命名,传参,行为方式(命令式或查询式)。
- Then 启动我们思考被测接口返回值。
一定要从业务而非实现的角度去思考接口(表达领域知识):
- 实现角度的设计:check()
- 业务角度的设计:guess()
Then 验证:
@Test
public void should_return_0A0B_when_no_number_is_correct() {
//given
Answer actualAnswer = Answer.createAnswer(“1 2 3 4”);
Game game = new Game(actualAnswer);
Answer inputAnswer = Answer.createAnswer(“5 6 7 8”);
//when
String result = game.guess(inputAnswer);
//then
assertThat(result , is(“0A0B”));
}
测试方法名可以足够长,清晰表达业务,用 Ruby 风格命名,以 should 开头。
提炼领域知识
贯穿 DDD 全过程,重视领域知识,维护统一语言。
双向过程:已提炼的领域知识指导识别用例,编写用户故事和测试用例 <=> 领域场景分析帮助确认领域知识,将达成共识的统一语言更新到领域知识。
建立统一语言
获取统一语言,是需求分析的过程,是团队就系统目标、范围、具体功能达成一致的过程。
体现在两方面:
- 统一的领域术语
-
统一的领域术语
维护领域术语表,给出对应的英语术语,防止影响代码实现。不保证纯正,但保证一致性。
建模之初就明确母语和英语的表达,杜绝命名不一致。领域行为描述
描述要求:
从领域而非实现的角度描述领域行为;
- 遵循术语表规范
- 动词精确性,符合业务动作在该领域的合理性
- 要突出与领域行为有关的领域概念
例:
作为一名Scrum Master,
我希望将Sprint Backlog分配给团队成员,
以便于明确Backlog的负责人并跟踪进度。
验收标准:
被分配的Sprint Backlog没有被关闭
分配成功后,系统会发送邮件给指定的团队成员
一个Sprint Backlog只能分配给一个团队成员
若已有负责人与新的负责人为同一个人,则取消本次分配
每次对Sprint Backlog的分配都需要保存以便于查询
明确领域行为的前置条件、执行的主语和宾语、*执行结果。
分配 Sprint Backlog 领域行为的代码实现:
package practiceddd.projectmanager.scrumcontext.domain;
public class SprintBacklog extends Entity
private String title;
private String description;
private BacklogStatus backlogStatus;
private MemberId ownerId;
public SprintBacklog(BacklogId backlogId, String title, String description) {<br /> if (title == null) {<br /> throw new InvalidBacklogException("the title of backlog can't be null");<br /> }
this.id = backlogId;<br /> this.title = title;<br /> this.description = description;<br /> this.backlogStatus = new NewBacklogStatus();<br /> }
public SprintBacklogAssignment assignTo(TeamMember assignee) {<br /> if (this.backlogStatus.isClosed()) {<br /> throw new InvalidAssignmentException(<br /> String.format("The closed sprint backlog %s can not be assigned to %s.", <br /> this.title, assignee.getName()));<br /> }<br /> if (assignee.isSame(this.ownerId)) {<br /> throw new InvalidAssignmentException(<br /> String.format("The sprint backlog %s not allow to assign to same team member %s.", <br /> this.title, assignee.getName()));<br /> }<br /> return new SprintBacklogAssignment(this.id, assignee.id());<br /> }<br />}<br />基于“**信息专家模式**”,assignTo() 仅完成部分领域行为(只承担能够履行的职责)。AssignSprintBacklogService 来完成整个用户故事描述的业务场景:<br />package practiceddd.projectmanager.scrumcontext.domain;
@Service
public class AssignSprintBacklogService {
@Autowired
private SprintBacklogRepository backlogRepository;
@Autowired
private SprintBacklogAssignmentRepository assignmentRepository;
@Autowired
private NotificationService notificationService;
public void assign(SprintBacklog backlog, TeamMember assignee) {<br /> SprintBacklogAssignment assignment = backlog.assignTo(assignee);<br /> backlogRepository.update(backlog);<br /> assignmentRepository.add(assignment);
AssignmentNotification notification = new AssignmentNotification(assignment);<br /> notificationService.send(notification.address(), notification.content());<br /> } <br />}