12 代码实现(下):怎样更加“面向对象”?
你好,我是钟敬。今天咱们继续研究编码。
上节课我们学习了 领域服务 和 工厂 两个模式,分别用于实现领域逻辑以及创建领域对象。今天我们考虑再增加一些面向对象的元素。
面向对象的三个特征是封装、继承和多态。其中多态我们暂时还不涉及。而 上节课 我们完成的 添加组织 功能,在封装和继承方面还不到位。
今天我们会继续开发 修改组织 功能,结合这个功能的开发,看看怎样运用封装和继承。
为“修改组织”功能开卡
为了完成 修改组织 的功能,我们再一次把产品经理老王请过来进行“开卡”,看需求的细节是否理解到位。
首先要确认的一个问题是,到底要修改组织的哪些信息。目前的Org对象一共有11个属性,我们列成一个表,和老王逐一确认。
这个表的含义是,在完成某个功能时,客户端要提供哪些数据。其中“创建”一栏是创建组织时需要哪些参数。“修改”一栏中的问号,是我们觉得在修改时需要客户提供,但需要与业务确认的。
老王轻轻一笑,眼神中的意思是“你们这些搞IT的还是不懂业务呀”,然后给出了后面四条意见。
1.如果修改“上级组织”的话,实际上是在调整组织结构。一般不作为普通的修改功能,建议增加专门的“调整组织结构”功能。
2.“组织类别”不能修改,也就是说,一个开发组不能直接变成开发中心。如果真有这种需求,需要另外创建新的开发中心,把人迁移过去,然后撤销当前的开发组。
3.“状态”不能直接改,应该通过“撤销组织”功能间接修改。
4.最终只有“负责人”和“名称”可以直接修改,建议这个功能叫做“修改组织基本信息”。
根据老王的建议,我们把表格改成了下面的样子。由于调整组织结构功能比较复杂,所以以后再考虑,这里只增加了“撤销组织”功能。
接着,老王又为撤销组织增加了2个业务规则。
初步完成程序功能
采用前两节课的方法,我们初步完成了“修改组织基本信息”和“撤销组织”功能。
修改后的OrgServcie是这样的。
package chapter12.unjuanable.application.orgmng;
// imports ...
@Service
public class OrgService {
private final OrgBuilderFactory orgBuilderFactory;
private final OrgRepository orgRepository;
private final OrgHandler orgHandler; //新增了一个领域服务依赖
@Autowired
public OrgService(OrgBuilderFactory orgBuilderFactory
, OrgHandler orgHandler
, OrgRepository orgRepository) {
// 为依赖注入赋值 ...
}
@Transactional
public OrgDto addOrg(OrgDto request,Long userId) {
// 添加组织的功能已完成,这里省略 ...
}
//修改组织基本信息
@Transactional
public OrgDto updateOrgBasic(Long id, OrgDto request, Long userId) {
Org org = orgRepository.findById(request.getTenant(), id)
.orElseThrow(() -> {
throw new BusinessException("要修改的组织(id ="
+ id + " )不存在!");
});
orgHandler.updateBasic(org, request.getName()
, request.getLeader(), userId);
orgRepository.update(org);
return buildOrgDto(org);
}
//取消组织
@Transactional
public Long cancelOrg(Long id, Long tenant, Long userId) {
Org org = orgRepository.findById(tenant, id)
.orElseThrow(() -> {
throw new BusinessException("要取消的组织(id ="
+ id + " )不存在!");
});
orgHandler.cancel(org, userId);
orgRepository.update(org);
return org.getId();
}
private static OrgDto buildOrgDto(Org org) {
// 将领域对象转换成DTO
}
}
我们写了一个OrgHandler来协助完成功能。和Validator一样,这也是一个 领域服务, 但命名为 OrgDomainService显得有点繁琐,我们这里按XxxHandler命名,也就是Xxx处理器。
OrgHandler的代码是这样的。
package chapter12.unjuanable.domain.orgmng.org;
// imports...
@Component
public class OrgHandler {
private final CommonValidator commonValidator;
private final OrgNameValidator nameValidator;
private final OrgLeaderValidator leaderValidator;
private CancelOrgValidator cancelValidator;
public OrgHandler(CommonValidator commonValidator
, OrgNameValidator nameValidator
, OrgLeaderValidator leaderValidator
, CancelOrgValidator cancelValidator) {
// 为依赖注入赋值...
}
public void updateBasic(Org org, String newName
, Long newLeader, Long userId) {
updateName(org, newName);
updateLeader(org, newLeader);
updateAuditInfo(org, userId);
}
public void cancel(Org org, Long userId) {
cancelValidator.cancelledOrgShouldNotHasEmp(org.getTenant()
, org.getId());
cancelValidator.OnlyEffectiveOrgCanBeCancelled(org);
org.setStatus(OrgStatus.CANCELLED);
updateAuditInfo(org, userId);
}
private void updateLeader(Org org, Long newLeader) {
if (newLeader != null && !newLeader.equals(org.getLeader())) {
leaderValidator.leaderShouldBeEffective(org.getTenant()
, newLeader);
org.setLeader(newLeader);
}
}
private void updateName(Org org, String newName) {
if (newName != null && !newName.equals(org.getName())) {
nameValidator.orgNameShouldNotEmpty(newName);
nameValidator.nameShouldNotDuplicatedInSameSuperior(
org.getTenant(), org.getSuperior(), newName);
org.setName(newName);
}
}
private void updateAuditInfo(Org org, Long userId) {
// 设置最后修改人和时间
}
}
与OrgBuilder类似,这个类中的方法基本上也是先校验,再赋值,只不过这里是更改而不是新建。同时OrgHandler没有可变属性,因此可以直接注入到应用服务。
提高应用API的封装性
下面我们看看怎么提高程序的封装性。
所谓“封装”,指的是将一个模块的实现细节尽量隐藏在内部,只向外界暴露最小的可访问接口,也叫信息隐藏原则,或最小接口原则,目的是减小模块间的耦合,提高程序的可维护性。这里说的模块是广义的,一个函数、一个类、一个包乃至整个应用系统,都可以看作模块,而我们之前领域建模中说的 模块 模式是狭义的,专门指领域模型里的领域对象所组成的包。
先看一下系统对外提供的API。我们的系统采用RestfulAPI,以JSON作为参数格式,JSON的结构则是与OrgDto一致的。目前的DTO中有领域对象中所有11个属性,因此,不论增加还是修改,入口参数的JSON格式都是下面这样。
{
"createdAt": "2022-10-05T06:49:39.659Z",
"createdBy": 0,
"id": 0,
"lastUpdatedAt": "2022-10-05T06:49:39.659Z",
"lastUpdatedBy": 0,
"leader": 0,
"name": "string",
"orgType": "string",
"status": "string",
"superior": 0,
"tenant": 0
}
我们已经知道,添加和修改操作,实际上都只是用了这些属性的一个子集。现在这样简单粗暴地把所有属性都暴露给客户端,不仅违反了最小接口原则,也容易在理解上发生混淆。
为了解决这个问题,我们要为每个功能编写单独的参数DTO,只包含必要的参数。DTO本身的修改比较简单,你可以自己试一下。这里我们重点来看一下修改后应用层的包结构。
CreateOrgRequest和UpdateOrgBasicRequest 分别是添加和修改组织的参数DTO。原来的OrgDto改名为OrgResponse,作为两个功能共同的返回参数类型。另外,这里又出现了一个包结构打横分还是打竖分的问题。
一些伙伴更喜欢打横分,也就是创建一个dto包,把EmpService和OrgService的DTO都放进去。而我们这里采用的是 打竖分,也就是把service和这个service用到的DTO放在同一个包内,提高了模块的内聚性。
修改之后,添加组织的JSON参数格式成了下面这样。
这就简洁多了,关键是缩小了API,系统的封装性提高了。修改组织功能的JSON的优化也是类似的。
提高领域对象的封装性
改进了系统API的封装之后,我们再来考虑领域对象层面。
现在的Org对象只是纯粹的DTO,所有属性都通过getter和setter暴露在外,没有任何封装性可言。要提高封装性,可以从两个角度考虑。第一是限制getter和setter的数量;第二是用表示业务含义的接口代替简单的setter和getter。
由于getter只是用来查询,不会破坏数据,而不恰当的setter则可能破坏数据,导致程序出错,所以相对而言,限制setter比限制getter更重要一些。为了限制setter,我们再列一个表,研究一下在创建了组织以后,哪些属性是可以修改的。
我们可以只为那些可以修改的属性保留setter,其他的只有getter,成为只读属性。再为Org类增加一个包含只读属性的构造器,以便创建对象。
修改后的Org类是这样的。
public class Org {
private Long id;
private Long tenantId;
private Long superiorId;
private String orgTypeCoDe;
private Long leaderId;
private String name;
private OrgStatus status;
private LocalDateTime createdAt;
private Long createdBy;
private LocalDateTime lastUpdatedAt;
private Long lastUpdatedBy;
public Org(Long tenantId, String orgTypeCoDe
, LocalDateTime createdAt, Long createdBy) {
// 为属性赋值 ...
}
// 所有属性的 getter ...
// 保留了 superiorId, leaderId, name,
// lastUpdateAt, lastUPdateBy 和 status 的 setter ...
}
其中OrgId比较特殊,大部分情况下是只读的,但当Repository将新建的Org保存到数据库时,由于id是数据库自动生成的,需要回填id值。这可以通过反射等技巧来绕过去。事实上,很多数据库访问框架就是利用反射,直接为私有属性赋值,以便在不破坏封装的前提下,从数据库中取出对象。
通过减少领域对象的Setter,我们进一步提高了程序的封装性。
通过“表意接口”提高封装性
做了这些改进以后,我们再看看在OrgHandler中完成“撤销组织”功能的代码。
package chapter12.unjuanable.domain.orgmng.org;
// imports ...
@Component
public class OrgHandler {
//...
public void cancel(Org org, Long userId) {
cancelValidator.OrgToBeCancelledShouldNotHasEmp(
org.getTenantId(), org.getId());
cancelValidator.OrgToBeCancelledShouldBeEffective(org);
org.setStatus(OrgStatus.CANCELLED); // 直接为 Status 赋值
updateAuditInfo(org, userId);
}
// ...
}
其中,org.setStatus(OrgStatus.CANCELLED) 直接将组织的状态设置成了“已撤销”。从面向对象的角度来看,更好的做法是Org类提供一个cancel() 方法,像下面这样:
package chapter12.unjuanable.domain.orgmng.org;
// imports ...
public class Org {
//...
//Org 自己管理自己的状态
public void cancel() {
this.status = OrgStatus.CANCELLED;
}
//...
}
这样,Org类就可以自己管理自己的状态,OrgHandler就不必了解Org内部状态的转换细节,只需告诉Org需要撤销就可以了,像下面这样。
package chapter12.unjuanable.domain.orgmng.org;
// imports ...
@Component
public class OrgHandler {
//...
public void cancel(Org org, Long userId) {
cancelValidator.OrgToBeCancelledShouldNotHasEmp(
org.getTenant(), org.getId());
cancelValidator.OrgToBeCancelledShouldBeEffective(org);
org.cancel(); // 只需告诉 Org 要进行撤销,但不必了解 Org 内部细节
updateAuditInfo(org, userId);
}
// ...
}
类似的,我们看一下“只有有效的组织才能被撤销”这条规则的实现代码。
package chapter12.unjuanable.domain.orgmng.org.validator;
// imports...
@Component
public class CancelOrgValidator {
//...
// 只有有效的组织才能被撤销
public void OnlyEffectiveOrgCanBeCancelled(Org org) {
//直接访问了状态属性
if (!org.getStatus().equals(OrgStatus.EFFECTIVE)) {
throw new BusinessException("该组织不是有效状态,不能撤销!");
}
}
//...
}
我们看到CancelOrgValidator直接访问了Org的状态,判断是否为有效组织。这实际上又是重构中的一种坏味道,叫做 特性依恋(Feature Envy)。Status这个特性是属于Org类的,而 CancelOrgValidator要通过访问这个特性来实现自己的逻辑,所以 CancelOrgValidator “依恋”了Org的特性。这是对象封装性被破坏的征兆。
解决方法是将这段判断逻辑移动到Org类内部,重构后的Org类是这样的。
package chapter12.unjuanable.domain.orgmng.org;
// imports ...
public class Org {
//...
public boolean isEffective() {
return status.equals(OrgStatus.EFFECTIVE);
}
//...
}
这样,CancelOrgValidator就可以不依赖Org的内部状态了。
package chapter12.unjuanable.domain.orgmng.org.validator;
// imports...
@Component
public class CancelOrgValidator {
//...
// 只有有效的组织才能被撤销
public void OnlyEffectiveOrgCanBeCancelled(Org org) {
//不再依赖 Org 的内部状态
if (!org.isEffective()) {
throw new BusinessException("该组织不是有效状态,不能撤销!");
}
}
//...
}
Org.cancel() 和 Org.isEffective() 既提高了对象的封装性,也表达了这个功能的业务含义,因此也是 表意接口 模式的一种应用。
上一节的表意接口体现在“领域服务”,这一节体现在“领域对象”。对“表意接口”的运用,往往可以避免“特性依恋”的坏味道,反之,发现和消除“特性依恋”,也会重构出“表意接口”。
用继承消除重复
下面再来看看Org类还有什么可以优化的地方。
我们发现,其实每个实体都包含4个审计字段createdAt、createdBy、lastUpdatedAt和 lastUpdatedBy。那么我们可以考虑抽出一个包含这四个属性的父类,减少编写各个类的重复工作。抽出的父类是下面的样子。
package chapter12.unjuanable.common.framework.domain;
import java.time.LocalDateTime;
public abstract class AuditableEntity {
protected LocalDateTime createdAt;
protected Long createdBy;
protected LocalDateTime lastUpdatedAt;
protected Long lastUpdatedBy;
public AuditableEntity(LocalDateTime createdAt, Long createdBy) {
this.createdAt = createdAt;
this.createdBy = createdBy;
}
// lastUPdatedAt 和 lastUpdatedBy 的 setter 以及所有属性的 getter ...
}
这个类放在common.framework包里面,因为这种基类属于框架层面。
在面向对象设计中一个常见的陷阱就是滥用继承。要防止这一倾向,要记住一个原则, 不要仅仅为了复用而使用继承。你还要问自己一个问题,父类和子类的关系,在语义上, 是否有分类关系,或者概念的普遍和特殊关系。只有符合这种关系的,才能采用继承,否则应该用“组合”来实现复用。
在我们的例子中,我们发现审计字段可以复用,只是可能可以采用继承的一个征兆。然后我们再从语义上进行分析。可以说,每个实体都是可审计的,或者说,每类实体都是一类特殊的可审计实体。 我们发现了这种普遍和特殊的分类关系,所以在这里运用继承是合理的。
编程风格回顾
在 第10节课 中我们说过,具体的编程风格是多种多样的,每种都各有利弊。因此,不可能也没有必要强求一致。关键是理解背后的原理,作出取舍,然后在自己的开发团队中形成比较统一的风格。
下面总结一下我们这几节课采用的风格的要点,说明背后的原因,以及其他可选的方式。
第一,领域对象不访问数据库。
我们在领域对象中既不会显式,也不会隐式访问数据库,目的是使领域对象和数据库解耦,便于维护和测试。
如果使用JPA并结合延迟加载,领域对象就会隐式地访问数据库;如果在领域对象的方法中写SQL,则会显式地访问数据库。领域对象访问数据库,才能实现更加纯粹的面向对象风格,代价是一般需要对ORM框架有深入的理解,或者增加了领域对象和数据库访问机制的耦合,还可能无意间导致性能等问题。你可以根据这两者的利弊进行取舍。
第二,领域服务只能读数据库。
在实现一些业务规则时,需要访问数据库中的数据,因此领域服务需要读数据库。而写库的功能通常可以由应用服务来做,从而减轻领域层的负担。有些伙伴喜欢把写功能也放在领域服务,从而使应用服务非常薄,这样也可以。
第三,应用服务可以读写数据库。
纯粹的(通过调用仓库)读写数据库并不包含领域知识,因此放在应用服务似乎更合适一些。比如更新一个对象前,要把这个对象先从数据库中读出来;创建或修改对象后,要存回数据库,这些本身都没有领域逻辑。
第四,用 ID 表示对象之间的关联。
如果按偏面向对象的风格,对象之间的关联应该用对象导航的方式来实现。比如说,Org对象的leader属性的类型,应该是Emp(员工),这样就可以在内存中直接从组织对象导航到充当负责人的员工对象了。不过我们的程序中,leader属性仅保存了员工的ID,而不是员工对象。这是考虑到在企业应用中节省带宽和内存。关于对象导航风格,我们会在下个迭代中继续探讨。
第五,领域对象有自己的领域服务。
如果是偏面向对象风格,很多领域逻辑可以在领域对象内部完成,因此不一定需要领域服务。但对于偏过程式的风格,由于领域对象不能访问数据库,很多领域逻辑就要外化到领域服务中,因此多数领域对象都会有相应的领域服务。
第六,在以上前提下利用封装和继承。
即使是我们这种偏过程式的风格,如果善于利用封装、合理利用继承,也能有效提高程序质量。一个技巧是识别 特性依恋 的坏味道,并重构到 表意接口。
最后要说的是,我们这个例子其实比较简单,但体现出的原理和复杂功能是一样的,咱们要以小见大,举一反三。
总结
好,今天的内容先讲到这里,现在来总结一下。
这节课我们主要是利用封装和继承,进一步提高了代码质量。我们可以通过两个层面来提高封装性。
第一个层面 是API的封装。添加和修改组织两种API的参数是不同的,我们只对外暴露出每个API 必须的参数,从而缩小了接口,提高了封装性。
第二个层面 是领域对象的封装。我们分析了哪些属性是不需要修改的,把这些属性变成只读的,从而缩小了对象的接口。另一方面,我们用 表意接口 封装了状态属性,使外界不需要直接关心状态转换的细节,同时消除了 特性依恋 的坏味道。
另外,我们还可以利用继承减少代码的重复。在我们的例子里,是通过抽象出AuditableEntity 作为所有实体的父类来实现的。
最后,我们对这三节课所使用的编程风格进行了回顾,说明了这么做的理由以及一些其他做法。这里没有唯一正确的方式,重要的是根据实际情况进行权衡。
讲完今天的课,第一个迭代就结束了。通过实现 组织 的 创建 和 修改 功能,我已经把这个迭代需要掌握的知识点都介绍清楚了(为了让你关注重点,我没有把所有的代码实现都写进课程里)。你可以尝试运用这些知识,自己实现其他的功能。
学完这个迭代,应该可以利用DDD开发一些不太复杂的需求了,你不妨在自己的项目中试一试。
另外,为了帮助你检验迭代一的学习效果,我策划了加餐1的内容(包括十道选择题和一道建模题目),你可以通过 这个链接 去练习。建模题目的解析和参考答案,等你自己亲手练习以后,可以对照 加餐4 的分析点评加深理解。
思考题
1.对于领域对象,可否利用Java的包级私有权限,进一步增强封装?
2.在现在的Org类里,只对setter进行了限制,而所有属性都有getter,能否举例说明什么样的属性不应该有getter呢?
好,今天的课程结束了,有什么问题欢迎在评论区留言。从下节课开始,我们将进入第二个迭代,学习DDD中几个有些难度的内容,包括 聚合 和 值对象 模式以及 泛化 等技能。相信在咱们的共同努力下,你一定能顺利掌握。