要做到这一点,只需规避Java开发三大怪即可。
Java语言规定了访问修饰符,目的在于隐藏无需公开的细节。其中,字段作为一个对象拥有的数据,往往需要隐藏起来,定义为私有字段乃是标准操作。如果外部调用者需要操作对象的数据,可以通过对外公开的get和set访问器进行读写。——但是,这并不意味着一个私有的字段一定需要对应公开的get和set。
定义Java类时,要从对象拟人化角度思考,结合业务场景,将对象拥有的数据视为一种“隐私”。既然是隐私,自然不能随便暴露。
隐私既是自身数据的保护,又能减少不必要的依赖。当我们在调用一个类的get或set访问器时,先问问自己:操作这些数据的行为究竟该交给调用者,而是交给拥有这些数据的对象?
以如下代码为例:
public class ComponentService {
public boolean publish(ComponentReview review) {
Component component = detailById(review.getId());
component.setStatus(ComStatus.PUBLISH_SUCESS.getCode());
component.setVersion(review.getVersion());
component.setPublishTime(new Date());
// ...
return true;
}
}
就应该问问ComponentService,为何需要调用Component的这些set访问器呢?
如果将数据当做信息,则可推导出信息专家模式:信息的持有者即为操作该信息的专家。简单说来,它就是通常所说面向对象设计的第一原则——数据与行为应该封装在一起。
当一个对象调用另一个对象的get或set访问器时,产生的协作模式是将另一个对象当做数据的提供者。这并非不允许,如果当前业务场景就是要获得数据,这是合理的。
必须明确,良好的协作模式应该形成对象之间的行为协作。如果我们将上述代码这几个set访问器的调用转移到Component,情况就完全不同了:
public class Component {
public void publish(String version) {
setStatus(ComStatus.PUBLISH_SUCESS.getCode());
setVersion(version);
setPublishTime(new Date());
}
}
public class ComponentService {
public boolean publish(ComponentReview review) {
Component component = detailById(review.getId());
component.publish(review.getVersion);
// ...
return true;
}
}
此时的ComponentService与Component之间就属于行为间的协作。
倘若对这一改进不以为然,则可以设想Component的发布逻辑存在多个调用者时,情况会怎么样?
为什么在我们的业务代码中总会出现Martin Fowler所说的“贫血模型”,原因就在于此。这一做法同时也是Martin Fowler在《重构》一书中定义的“特性依恋(feature envy)”坏味道,具体描述为“函数对某个类的兴趣高过对自己所处类的兴趣”。用上述代码阐述,就是ComponentService的publish方法对Component的兴趣更大,它嫉妒Component的特性,故而将其抢了过来,心眼实在太坏!
这一问题同样违背了迪米特法则。该法则要求:对象不要和陌生对象之间进行通信,也就是常说的“不要和陌生人说话”。如果一个对象B对于对象A而言,不符合以下条件:
则将对象B视为A的陌生对象。看一个老生常谈的例子:
public class Cashier {
public void charge(Customer myCustomer, float payment) {
Wallet theWallet = myCustomer.getWallet();
if (theWallet.getTotalMoney() >= payment) {
theWallet.subtractMoney(payment);
} else {
//money not enough
}
}
}
Customer是Cashier方法charge()的参数,所以它们并非陌生对象;但是,Wallet既非Cashier方法的参数,也不是它的属性,更不由它创建,因此,Wallet就是Cashier的陌生对象。
因此,当前的实现违背了迪米特法则的设计,它对Cashier与Customer二者都不讨好:
由于Java社区开始广泛使用lombok框架,使得get和set访问器的滥用变本加厉。许多领域类都被调用者剥削,使得它们只剩下了数据的定义,却失去了对自己隐私的掌控权。责任当然不在于lombok框架的设计者。事实上,lombok已经告诉调用者,@Data注解说明:只有将一个类视为数据类时,才应该如此使用。然则,一个领域类应该作为数据类吗?
静态方法用起来很方便,因为无需实例化即可调用。它的致命缺点是不可扩展,调用者与静态方法之间是紧耦合的。静态方法是代码可测试性的最大障碍,虽然可以使用PowerMock模拟静态方法,但一旦出现这一形式,已经说明代码不具备良好的可测试性。
静态方法是过程式代码的集结地。
为何要使用静态方法?如果一个类的方法都为静态方法,则说明这个类并无状态,它仅仅是诸多行为的一个载体。Martin Fowler将这样的静态方法实现称之为“事务脚本(transacation script)”,以形容它们的实现就像脚本一样,按照规定的过程顺序依次执行。
由于定义静态方法的类自身没有数据,就需要从另外的对象获取数据,就使得事务脚本与贫血模型成为天生一对。
如果程序员建立了贫血模型,则领域行为必然分配给另外一个类,使得贫血对象以数据提供者的身份参与对象之间的协作;调用者需要的数据既然都来自另一个对象,它就没有持有状态的必要,定义为静态方法就成为必然的选择了。
一旦将一个领域行为定义为静态方法,程序员就不去考虑如何封装数据与行为,更不会思考这些行为逻辑应该分配给哪些类。程序员只会思考,要实现这些逻辑需要哪些数据,形成数据驱动的开发模式。
例如作为一家承运商,需要确认一个运输委托。因为与运输相关,就会很自然地定义一个ShipmentServices类,并在其下定义静态方法confirmShipment()。
编写该静态方法时,首先会根据该业务功能梳理执行步骤,如:
一旦梳理好了这些步骤,自然而然就会考虑这些步骤需要哪些数据,这些数据又可以从哪些数据表获得。于是,就会诞生如下所示的代码:
public class ShipmentServices {
public static void confirmShipment(ShipmentRouteSegment routeSegment) {
if (!"承运商编号".equals(routeSegment.getCarrierPartyId()) {
throw new DomainException("Facility Shipment Not Route Segment Carrier", locale);
}
Address origionalAddress = origionalAddressDao.queryBy(routeSegment.getShipmentId());
Address destAddress = destAddressDao.queryBy(routeSegment.getShipmentId());
// 验证起止地址
List<ShipmentPackage> shipmentPackages = shipmentPackage.queryBy(routeSegment.getShipmentId());
if (shipmentPackages == null) {
throw new DomainException("Facility Shipment Package Not Found", locale);
}
if (shipmentPackages.size() != 1) {
throw new DomainException("Facility Shipment Multiple Packages Not Supported", locale));
}
boolean hasBillingWeight = false;
BigDecimal billingWeight = routeSegment.getBillingWeight();
String billingWeightUomId = routeSegment.getBillingWeightUomId();
// 以下略
}
}
这样的代码就是事务脚本的实现方式。
业务功能的各个步骤被映射到代码中,平铺直叙,没有封装,也没有合理的职责分配。ShipmentServices就好似专注于运输的上帝类,它无所不知,成为业务的主控对象。
主控对象是中心,它所操作的对象都是数据的提供者。如果逻辑需要复用,它会毫不吝啬地将这些逻辑封装为另一个静态公开方法,仿佛殷勤的店家,开门迎客,不停地吆喝着:来调用吧,又方便又快捷,尽管调用。
然而,依赖就会由此产生。主控对象就像一个超强的磁力球,凡是经过它的对象,都被它吸住,并由此产生越来越强的磁性,最终形成一个没有空隙的大磁球。
静态方法虽然人人都爱,但它的正确用法只能用于工具类,或者作为静态工厂。除此之外,一定要慎用!
许多人错误地理解了“面向接口编程”,以为定义的每个类必得定义一个对应的接口,方才满足该原则的要求。许多Spring的案例也错误地演示了这一做法,诞生如下图所示的代码结构:
Martin Fowler将这样的接口称之为“header interface”,看如下的代码,是否有一种AccountTransactionServiceImpl类上长了一个AccountTransactionService接口头的荒谬感呢?
public interface AccountTransactionService {
Account creditToAccount(String userNo, BigDecimal amount, String requestNo, String bankTrxNo, String trxType, String remark) throws BizException;
Account debitToAccount(String userNo, BigDecimal amount, String requestNo, String bankTrxNo, String trxType, String remark) throws BizException;
Account freezeAmount(String userNo, BigDecimal freezeAmount) throws BizException;
Account unFreezeAmount(String userNo, BigDecimal amount, String requestNo, String trxType, String remark) throws BizException;
Account unFreezeSettAmount(String userNo, BigDecimal amount) throws BizException;
void settCollectSuccess(String accountNo, String collectDate, int riskDay, BigDecimal totalAmount) throws BizException;
}
public class AccountTransactionServiceImpl implements AccountTransactionService {
public Account creditToAccount(String userNo, BigDecimal amount, String requestNo, String bankTrxNo, String trxType, String remark) throws BizException {}
public Account debitToAccount(String userNo, BigDecimal amount, String requestNo, String bankTrxNo, String trxType, String remark) throws BizException {}
public Account freezeAmount(String userNo, BigDecimal freezeAmount) throws BizException {}
public Account unFreezeAmount(String userNo, BigDecimal amount, String requestNo, String trxType, String remark) throws BizException {}
public Account unFreezeSettAmount(String userNo, BigDecimal amount) throws BizException {}
public void settCollectSuccess(String accountNo, String collectDate, int riskDay, BigDecimal totalAmount) throws BizException {}
}
这样的实现,不知是不是C语言头文件的遗传?
必须明确,面向接口编程原则所谓的“接口”,并非Java的interface类型,而是设计者定义的一种交互标准,以此可形成调用双方都需遵循的契约。实际上,每个类的公开方法定义都可认为是接口。
如果程序员为每个类都定义一个接口,说明他/她并没有真正理解抽象接口的含义。我在之前的文章《面向接口设计与角色接口》中解释过什么是接口:
定义一个Java接口的目的在于应对扩展,如果每个接口只有一个实现类,又何须抽象呢?
试想想一个相对复杂的业务系统,承担业务职责的类恐怕不少于数百个。如果每个类都长一个接口头,类型数量就会翻一倍。这些接口只有一个实现类,抽象的意义何在?除非要使用RPC协议,如Dubbo,需要抽象的接口和实现完全分离;否则,抽象接口的定义就是多余的。
或许有人会说,倘若以后真的出现了扩展,该怎么办?很简单,重构啊!
以上述代码为例,如果交易的credit行为需要支持本行和跨行操作,完全可以在当前类的基础上提取一个新的接口,即运用重构手法Extract Interface:
然后选择“Extract Interface”,挑选需要提取到接口中的方法即可。
如果原本的类名本身就比较抽象,更适合作为接口的名称,可选择“Rename original class and use interface where possible”选项。它会将当前类名当做接口类型的名称,然后要求你输入更为具体的类名。根据“use interface where possible”的语义,IDE会帮助你检测其他用到当前类的地方,将其改为使用抽象的接口类型。
编码实现时,不要做多余的抽象,这符合“简单设计”原则。拜托大家不要再给无需扩展的类装上一个小头,不仅奇怪,而且冗余,除非贵公司按照代码行的多少给你发奖金。