前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Java开发三大怪

Java开发三大怪

作者头像
张逸
发布2023-03-23 18:14:46
4310
发布2023-03-23 18:14:46
举报
文章被收录于专栏:斑斓斑斓
看了许多企业级Java项目的源代码,发现许多Java程序员都在用Java这门面向对象语言行过程式开发之事,且对此乐此不疲,毫不自觉。本文并非比较过程式设计与面向对象设计之优劣,而是反对挂羊头卖狗肉,希望将Java开发拉回到面向对象的轨道上。

要做到这一点,只需规避Java开发三大怪即可。

第一怪

隐私暴露,嫉妒他人心眼坏

Java语言规定了访问修饰符,目的在于隐藏无需公开的细节。其中,字段作为一个对象拥有的数据,往往需要隐藏起来,定义为私有字段乃是标准操作。如果外部调用者需要操作对象的数据,可以通过对外公开的get和set访问器进行读写。——但是,这并不意味着一个私有的字段一定需要对应公开的get和set。

定义Java类时,要从对象拟人化角度思考,结合业务场景,将对象拥有的数据视为一种“隐私”。既然是隐私,自然不能随便暴露。

隐私既是自身数据的保护,又能减少不必要的依赖。当我们在调用一个类的get或set访问器时,先问问自己:操作这些数据的行为究竟该交给调用者,而是交给拥有这些数据的对象?

以如下代码为例:

代码语言:javascript
复制
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,情况就完全不同了:

代码语言:javascript
复制
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定义方法的参数
  • B是A的属性
  • B的示例由A创建

则将对象B视为A的陌生对象。看一个老生常谈的例子:

代码语言:javascript
复制
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二者都不讨好:

  • 对于Customer:Cashier要操作顾客的钱包,侵犯了顾客的隐私,违背了隐私法则
  • 对于Cashier:Cashier要操作顾客的钱包,增加了Cashier的负担,违背了最小知识法则

由于Java社区开始广泛使用lombok框架,使得get和set访问器的滥用变本加厉。许多领域类都被调用者剥削,使得它们只剩下了数据的定义,却失去了对自己隐私的掌控权。责任当然不在于lombok框架的设计者。事实上,lombok已经告诉调用者,@Data注解说明:只有将一个类视为数据类时,才应该如此使用。然则,一个领域类应该作为数据类吗?

第二怪

懒用实例,静态方法人人爱

静态方法用起来很方便,因为无需实例化即可调用。它的致命缺点是不可扩展,调用者与静态方法之间是紧耦合的。静态方法是代码可测试性的最大障碍,虽然可以使用PowerMock模拟静态方法,但一旦出现这一形式,已经说明代码不具备良好的可测试性。

静态方法是过程式代码的集结地。

为何要使用静态方法?如果一个类的方法都为静态方法,则说明这个类并无状态,它仅仅是诸多行为的一个载体。Martin Fowler将这样的静态方法实现称之为“事务脚本(transacation script)”,以形容它们的实现就像脚本一样,按照规定的过程顺序依次执行。

由于定义静态方法的类自身没有数据,就需要从另外的对象获取数据,就使得事务脚本与贫血模型成为天生一对。

如果程序员建立了贫血模型,则领域行为必然分配给另外一个类,使得贫血对象以数据提供者的身份参与对象之间的协作;调用者需要的数据既然都来自另一个对象,它就没有持有状态的必要,定义为静态方法就成为必然的选择了。

一旦将一个领域行为定义为静态方法,程序员就不去考虑如何封装数据与行为,更不会思考这些行为逻辑应该分配给哪些类。程序员只会思考,要实现这些逻辑需要哪些数据,形成数据驱动的开发模式。

例如作为一家承运商,需要确认一个运输委托。因为与运输相关,就会很自然地定义一个ShipmentServices类,并在其下定义静态方法confirmShipment()。

编写该静态方法时,首先会根据该业务功能梳理执行步骤,如:

  • 确定承运商是否为当前承运商
  • 获得运输路线的起始地址
  • 获得承运货物清单
  • 计算重量
  • 获得运输方式
  • 获取与当前承运商有关的运输凭证
  • 完成确认

一旦梳理好了这些步骤,自然而然就会考虑这些步骤需要哪些数据,这些数据又可以从哪些数据表获得。于是,就会诞生如下所示的代码:

代码语言:javascript
复制
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接口头的荒谬感呢?

代码语言:javascript
复制
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 JDK中定义了很多这种接口,如Runnable, Cloneable, Seriazable。
  • 接口代表业务场景中与其他类型协作的角色,从语法特性看,就是对履行职责的角色的抽象。

定义一个Java接口的目的在于应对扩展,如果每个接口只有一个实现类,又何须抽象呢?

试想想一个相对复杂的业务系统,承担业务职责的类恐怕不少于数百个。如果每个类都长一个接口头,类型数量就会翻一倍。这些接口只有一个实现类,抽象的意义何在?除非要使用RPC协议,如Dubbo,需要抽象的接口和实现完全分离;否则,抽象接口的定义就是多余的。

或许有人会说,倘若以后真的出现了扩展,该怎么办?很简单,重构啊!

以上述代码为例,如果交易的credit行为需要支持本行和跨行操作,完全可以在当前类的基础上提取一个新的接口,即运用重构手法Extract Interface:

然后选择“Extract Interface”,挑选需要提取到接口中的方法即可。

如果原本的类名本身就比较抽象,更适合作为接口的名称,可选择“Rename original class and use interface where possible”选项。它会将当前类名当做接口类型的名称,然后要求你输入更为具体的类名。根据“use interface where possible”的语义,IDE会帮助你检测其他用到当前类的地方,将其改为使用抽象的接口类型。

编码实现时,不要做多余的抽象,这符合“简单设计”原则。拜托大家不要再给无需扩展的类装上一个小头,不仅奇怪,而且冗余,除非贵公司按照代码行的多少给你发奖金。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2022-11-22,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 逸言 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 第一怪
  • 隐私暴露,嫉妒他人心眼坏
  • 第二怪
  • 懒用实例,静态方法人人爱
  • 第三怪
  • 接口泛滥,类上长头小妖怪
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档