《Effective Java》第五条的核心思想是:类不应自行创建其依赖的资源(如数据库连接、网络服务、配置信息等),而应通过外部传入(即 “依赖注入”)的方式获取,以提高灵活性、可测试性和复用性
 

一、核心问题:硬编码依赖的弊端

很多类在实现时会 “硬编码” 依赖资源,例如在类内部直接创建数据库连接、实例化特定服务等。这种做法存在三个严重问题:

1. 紧耦合:类与特定资源实现绑定,若需要切换资源(如从 MySQL 换成 PostgreSQL),必须修改类的源码。

2. 测试困难:无法用 “测试替身”(如 Mock 对象)替代真实资源,导致单元测试必须依赖真实环境(如真实数据库),测试效率低且不稳定。

硬编码的本质是 **“类自己创建依赖”,而测试需要的是“替换依赖为测试替身(如 Mock 对象)”**,这两者是矛盾的。

具体场景:

假设我们有一个发送短信的服务类,硬编码了具体的短信网关实现:

public class SmsService {
    // 硬编码:自己创建依赖(具体的短信网关)
    private SmsGateway gateway = new AliyunSmsGateway("apiKey", "secret");
    
    public boolean send(String phone, String content) {
        return gateway.send(phone, content); // 使用自己创建的网关
    }
}

如果我们要测试send方法的逻辑(比如参数为空时是否返回false),理论上不需要真实调用阿里云网关(可能收费、有网络依赖、不稳定)。但因为gateway是在SmsService内部new出来的,测试代码无法替换它——SmsServiceAliyunSmsGateway死死绑定在一起。

此时你要么:

  • 被迫用真实网关测试(成本高、不稳定);
  • 想办法通过反射修改gateway字段(破坏封装,且复杂)。

3. 复用性差:类只能使用硬编码的资源,无法适应不同场景(如开发环境、生产环境的不同配置)。

二、解决方案:依赖注入(Dependency Injection)

依赖注入的本质是:让类依赖于抽象资源(如接口),并通过外部(而非类内部)提供具体实现。实现方式通常有三种:

  • 构造器注入(最推荐,强制依赖不可变)
  • Setter 方法注入(适合可选依赖)
  • 工厂方法注入

三、代码示例对比

1. 错误做法(硬编码依赖)

// 硬编码依赖:类内部创建数据库连接,无法替换
public class UserDao {
    // 硬编码MySQL连接,无法切换到其他数据库
    private Connection conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/db");
    
    public User getUser(Long id) {
        // 使用conn查询数据库...
        return null;
    }
}

问题:若需要测试getUser方法,必须启动真实 MySQL 数据库;若要切换到 Oracle,必须修改conn的创建逻辑。

2. 正确做法(依赖注入)

import java.sql.Connection;

// 依赖抽象:通过构造器注入Connection,不关心具体数据库
public class UserDao {
    private final Connection conn; // 不可变,线程安全
    
    // 构造器注入:外部传入Connection,类只负责使用
    public UserDao(Connection conn) {
        // 校验依赖非空,避免空指针
        this.conn = Objects.requireNonNull(conn, "Connection must not be null");
    }
    
    public User getUser(Long id) {
        // 使用注入的conn查询,不关心它是MySQL还是Oracle
        return null;
    }
}

优势

  • 灵活性:外部可传入 MySQL、Oracle 或测试用的内存数据库连接,无需修改UserDao
  • 可测试性:单元测试时可注入 Mock 连接(如Mockito.mock(Connection.class)),无需真实数据库。
  • 清晰性:通过构造器参数明确声明依赖,读者一眼可知UserDao需要什么资源。

四、关键原则

1. 依赖抽象而非具体:注入的资源应是接口(如Connection)而非具体实现(如MySQLConnection),进一步降低耦合。

2. 强制依赖用构造器注入:必须的资源通过构造器传入,并标记为final,保证不可变和线程安全。

3. 可选依赖用 Setter 注入:非必须的资源(如缓存服务)可通过 Setter 方法注入,允许动态修改。

4. 避免单例 / 静态工具类持有资源:单例和静态工具类往往硬编码资源,难以注入和测试,应优先使用实例类。

单例和静态工具类的设计特性,天然导致它们难以接受外部传入的依赖,最终必然走向硬编码资源。

(1)单例模式的问题:

单例的核心是 “全局唯一实例”,通常通过私有构造器 + 静态方法获取实例(如getInstance())。这种设计封闭了实例的创建过程,外部无法通过构造器注入依赖。

(2)静态工具类的问题:

静态工具类的方法是静态的,依赖的资源通常也是静态的(在类加载时初始化),同样没有外部注入的机会。

单例和静态工具类的测试最佳实践:

  1. 优先避免:新代码中应减少单例和静态工具类的使用,改用 “实例类 + 依赖注入”,从根本上解决测试问题;
  2. 不得不使用时
    • 单例:预留依赖注入点(如 setter 方法),避免 final 依赖;
    • 静态工具类:抽象为接口,让业务代码依赖接口而非静态方法;
  3. 测试工具选择:非必要不使用 PowerMock(可能引入隐藏问题),优先通过重构实现可测试性。
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐