基于组件设计原则剖析代码结构:循环依赖及其消除方法实例

基于组件设计原则剖析代码结构:循环依赖及其消除方法实例

我们前面介绍 Dubbo 和 Mybatis 的代码结构时主要关注的是稳定抽象和稳定依赖原则,而在组件耦合原则中还包括一条很重要的原则,即无环依赖原则。借助于 JDepend,我们也可以发现结构中存在的循环依赖关系。根据无环依赖原则,系统设计中不应该存在循环依赖。

依赖关系有三种基本的表现形式(见下图),其中类似 Package1 依赖于 Package2 这种直接依赖最容易识别和管理;间接依赖即直接依赖关系的衍生,当 Package1 依赖 Package2,而 Package2 又依赖 Package3 时,Package1 就与 Package3 发生了间接依赖关系;所谓循环依赖就是 Package1 和 Package2 之间相互依赖,循环依赖有时候并不像图中描述的那么容易识别,因为产生循环依赖的多个组件之间可能同时存在各种直接和间接依赖关系。

循环依赖示例

上面的描述比较抽象,我们看一下具体示例代码就比较容易理解循环依赖的产生过程。本文中的代码示例参考了《Java 应用架构设计:模块化模式与 OSGi》一书中的内容,并做了调整和优化。

首先,我们有一个代表用户账户的 Account 类,代码如下:

public class Account {
    private List<Order> orders;

    public BigDecimal getDiscountAmount() {
        //根据账号下的订单数来模拟折扣力度
        if (orders.size() > 5) {
            return new BigDecimal(0.1);
        } else {
            return new BigDecimal(0.03);
        }
    }

    public List<Order> getOrders() {
        return this.orders;
    }

    public void createOrder(BigDecimal chargeAmount) {
        Order order = new Order(this, chargeAmount);
        if (orders == null) {
            orders = new ArrayList<Order>();
        }
        orders.add(order);
    }
}

然后,我们还有一个代表用户下单的订单类,代码如下:

public class Order {

    private BigDecimal chargeAmount;
    private Account account;

    public Order(Account account, BigDecimal chargeAmount) {
            this.account = account;
            this.chargeAmount = chargeAmount;
    }

    public BigDecimal getChargeAmount() {
        return this.chargeAmount;
    }

    public BigDecimal pay() {
            BigDecimal discount = new BigDecimal(1).subtract(this.account.getDiscountAmount());
        BigDecimal paidAmount = this.chargeAmount.multiply(discount);

        //这里省略具体的支付业务代码

        return paidAmount;
    }
}

上述 Account 类和 Order 的代码都非常简单,但实际上已经构成了一种循环依赖,因为 Account 对象可以创建 Order 对象并保持 Order 对象列表,而 Order 对象同样需要使用 Account 对象,并根据 Account 对象中的打折(Discount)信息计算 Order 金额,这样对象 Account 和 Order 之间就存在循环依赖关系。JDepend 的分析结果如下,显然印证了我们的结论。

消除循环依赖的方法

如何消除这种循环依赖?软件行业有一句很经典的话,即当我们碰到问题无从下手时,不妨考虑一下是否可以通过“加一层”的方法进行解决。消除循环依赖的基本思路也是这样,就是通过在两个相互循环依赖的组件之间添加中间层,变循环依赖为间接依赖。有三种策略可以做到这一点,分别是上移、下移和回调。

上移

关系上移意味着把两个相互依赖组件中的交互部分抽象出来形成一个新的组件,而新组件同时包含着原有两个组件的引用,这样就把循环依赖关系剥离出来并上升到一个更高层次的组件中。下图就是使用上移策略对 Account 和 Order 原始关系进行重构的结果,我们引入了一个新的组件 Mediator,并通过其提供的 pay 方法对循环依赖进行了剥离,该方法同时使用 Order 和 Account 作为参数并实现了 Order 中根据 Account 的打折信息进行金额计算的业务逻辑。Mediator 组件消除了 Order 中原有对 Account 的依赖关系并在依赖关系上处于 Account 和 Order 的上层。使用上移之后的包关系调整如下。

相应的 Mediator 类示例代码也比较简单,如下所示。

public class PaymentMediator {
    private Account account;

    public PaymentMediator(Account account) {
        this.account = account;
    }

    public BigDecimal pay(Order order) {
        BigDecimal discount = new BigDecimal(1).subtract(this.account.getDiscountAmount());
        BigDecimal paidAmount = order.getChargeAmount().multiply(discount);

        //这里省略具体的支付业务代码

        return paidAmount;
    }   
}

下移

关系下移策略与上移策略切入点刚好相反。我们同样针对 Account 和 Order 的循环依赖关系进行重构,重构的方法是抽象出一个 Calculator 组件专门包含打折信息的金额计算方法,该 Calculator 由 Account 创建,并注入到 Order 的 pay 方法中去(见下图)。通过这种方式,原有的 Order 对 Account 的依赖关系就转变为 Order 对 Calculator 的依赖关系,而 Account 因为是 Calculator 的创建者同样依赖于 Calculator,这种生成一个位于 Account 和 Order 之下但能同样消除 Order 中原有对 Account 的依赖关系的组件的策略,就称之为下移。

相应的 Calculator 类示例代码也如下所示。

public class DiscountCalculator {
    private Integer orderNums;

    public DiscountCalculator(Integer orderNums) {
        this.orderNums = orderNums;
    }

    public BigDecimal getDiscountAmount() {
        if (orderNums.intValue() > 5) {
            return new BigDecimal(0.1);
        } else {
            return new BigDecimal(0.03);
        }
    }
}

回调

回调(Callback)本质上就是一种双向调用模式,也就是说,被调用方在被调用的同时也会调用对方。在面向对象的语言中,回调通常是通过接口或抽象类的方式来实现。下图就是通过回调机制进行依赖关系重构后的结果。我们抽象出一个 Calculator 接口用于封装金额计算逻辑,该接口与 Order 处于同一层次,而 Account 则实现了该接口,这样 Order 对 Account 的依赖就转变成 Order 对 Calculator 接口的依赖,也就是把对 Account 的直接依赖转变成了间接依赖。通过依赖注入机制,我们可以很容易的实现 Order 和 Account 之间的有效交互。

基于回调的代码结构相对复杂一点,我们首先需要定一个回调接口 DiscountCalculator,代码如下所示。

public interface DiscountCalculator {
        public BigDecimal getDiscountAmount();
}

调整后的 Account 类需要实现该接口,代码如下。

public class Account implements DiscountCalculator {
    private List<Order> orders;

    public BigDecimal getDiscountAmount() {
        if (orders.size() > 5) {
            return new BigDecimal(0.1);
        } else {
            return new BigDecimal(0.03);
        }
    }

    public List<Order> getOrders() {
        return this.orders;
    }

    public void createOrder(BigDecimal chargeAmount) {
        Order order = new Order(this, chargeAmount);
        if (orders == null) {
            orders = new ArrayList<Order>();
        }
        orders.add(order);
    }
}

最后,Order 类中直接注入 DiscountCalculator,当执行支付操作时,则回调 Account 类中的业务逻辑完成计算。

private BigDecimal chargeAmount;
    private DiscountCalculator discounter;

    public Order(DiscountCalculator discounter, BigDecimal chargeAmount) {
            this.discounter = discounter;
            this.chargeAmount = chargeAmount;
    }

    public BigDecimal getChargeAmount() {
        return this.chargeAmount;
    }

    public BigDecimal pay() {
        BigDecimal discount = new BigDecimal(1).subtract(this.discounter.getDiscountAmount());
        BigDecimal paidAmount = this.chargeAmount.multiply(discount);

        //这里省略具体的支付业务代码

        return paidAmount;
    }
}

至此,这个示例就讲完了。该示例非常简单,我们通过肉眼就能发现其中存在的循环依赖关系。但在日常开发过程中,我们的代码可能非常复杂,设计数十个甚至数百个类之间的交互,很难一下子就能发现其中的不合理依赖关系。这时候,我们就可以通过引入 JDepend 等类似的工具进行代码结构分析,并使用本篇中介绍的三种消除循环依赖的技术进行重构。

面试题分析

如何判断代码中是否存在循环依赖?

  • 考点分析

这是一个关于代码设计质量的一个常见问题,因为所有人都认为代码中存在循环依赖是一件不好的事情。笔者就比较喜欢对面试者抛出这个问题来看对方的应对思路。

  • 解题思路

以笔者的经历而言,这个问题回答好的同学不多,一方面主要原因在于大家不了解类似 JDepend 这样的工具,而另一方面,平时针对组件图等类似的设计手段用的也比较少,没有对循环依赖的表现形式有充分的认识。所以从解题思路上,我们可以采用知识点和表现形式并行的方法进展展开。

  • 本文内容与建议回答

判断循环依赖的主要方法还是通过 JDepend 等工具,如果不采用这种自动化工具,我们也可以自己通过绘制组件图的方式进行人工的梳理和判断。

如何消除代码中的循环依赖?

  • 考点分析

这个问题比较常见,也是考查我们理解组件设计原则以及处理复杂代码关系的一个很好的切入点。有些面试官会引申到 Spring 框架中的依赖注入循环依赖的处理机制。

  • 解题思路

上移、下移和回调是消除代码中循环依赖的三种常见方式,需要面试者牢记。同时,针对这种问题,最好有日常开发过程中所经历过的真实案例进行补充,回答起来思路就会很清晰,效果也最好。

  • 本文内容与建议回答

本文中给出的组件图以及代码示例,可以直接作为问题的答案。

日常开发技巧

包括 Dubbo、Mybatis 等优秀开源框架在内的现实中的很多代码实际上都存在循环依赖情况,换句话说,一旦代码结构变得复杂,没有循环依赖的代码实际上是不大可能存在的。因此,关键点在于如何有效识别循环依赖,并在成本可控的范围内对其进行重构。今天我们介绍的内容基本上可以直接应用与日常开发过程中。通过上移、下移和回调这三大重构手段可以满足日常开发过程中大部分消除循环依赖的场景。大家可以先用 JDepend 工具得到量化数据,然后根据需要分别进行重构。

小结与预告

本文讨论了循环依赖以及消除方法。这样,我们花了三篇文章对"基于组件设计原则剖析代码结构"这个主题进行了详细分析。从下一篇开始,我们将通过两篇文章的内容讲解分析代码结构的另一种方法,即"基于架构演进过程剖析代码结构"。

答疑与交流

为了方便与作者交流与学习,GitChat 编辑团队组织了一个专栏读者交流群,添加小助手微信:「Linmicc」,回复关键字「7525」给小助手泰勒获取入群资格。

avatar

上一篇
下一篇