Featured image of post 使用依赖注入,「消灭」单例模式

使用依赖注入,「消灭」单例模式

前言

单例模式,作为 GoF 开篇提出的第一个设计模式,因其简单的需求,简单的实现,基本上每个学习过 OOP 的同学都会随手默写一个出来。更不用说如今绝大多数 IDE 都自带的生成 Singleton 类的模板了。

不过,简单往往意味着粗糙与简陋。根据我个人至今的项目经验(其实也没多少,但足以发现其问题),单例模式保证全局唯一实例的特性确实有着一定的用武之地,但是,初学者们却往往不是以其全局唯一实例的用途为出发点去使用这个模式的,而是将其全局可访问的特性发扬光大了起来。

这便是单例模式最大的弱点。全局唯一实例总是有着自身的状态(否则,单纯的静态方法便能满足全局可访问性),但是这种状态又因其全局可访问性导致了全局可修改性。而众所周知,全局可修改性意味着接踵而至的依赖关系。任何一个类的任何一个实例都可以反手就是一个XXXClass.getInstance()与某个单例类建立依赖。随着与单例类建立依赖的类越来越多,整个项目的架构变得混乱不堪,高度耦合。

在这种错误的应用方式下,单例模式逐渐向着那个恶魔靠近。没错,充其量,不过是比全局变量好了一点点而已

解决方案

可能有人要问了,既然你把单例模式说得那么坏,那么如果我确实需要全局唯一性怎么办呢?

这个问题比较好回答。其实单例模式也不是不可以用,只是不能滥用,不能我随便在任何一个地方想调用它就调用它。

  1. 完全不符合面向接口编程的思想。因为单例是不能被接口化的(这里不严谨,实际上单例可以让 getInstance 方法返回接口,只是这样没什么卯月,因为调用方的代码已经写死了没法替换),因此只能显式地与某个特定类产生依赖。未来任何对这个类的替换或者扩展都变成了不可能的任务。
  2. 增加了单元测试的编写难度。因为单例在整个测试过程中也是全局唯一的,使得需要在测试开始前模拟出某种状态的过程,同时需要在测试完成后手动销毁这种状态以备下一个测试,造成潜在的逻辑异常。

那么我们该如何正确地使用单例模式呢?

答案就是使用依赖注入(Dependency Injection),不是由调用方主动发起指向单例类的连接,而是从一开始就假设调用方已经拥有了这个单例类。其实不论是使用构造函数传入,还是使用普通方法绑定,核心思想都只有一个:

只要我想要某个依赖,我就一定会拥有。至于这个依赖究竟来自哪里,是谁给我的,我不关心。

举个栗子

就拿手头一个正在推进的项目举例吧。

class TaskManager implements ITaskManager {
    private Iterable<Rule> rules;
    private Timer timer = new Timer();
    private ConcurrentHashMap<Rule, TimerTask> timerTasks = new ConcurrentHashMap<>();

    public TaskManager(Iterable<Rule> rules) {
        this.rules = rules;
        filterActiveRules();
        createTimers();
    }

    private void filterActiveRules() {
        ArrayList<Rule> activeRules = new ArrayList<>();
        for (Rule rule : this.rules) {
            if (rule.isActive()) {
                activeRules.add(rule);
            }
        }
        this.rules = activeRules;
    }

    private void createTimers() {
        for (Rule rule : this.rules) {
            timerTasks.put(rule, new TimerTask() {
                @Override
                public void run() {
                    // emmm
                    BackgroundWorker.getInstance().newTask(rule);
                }
            });
        }
    }

    public void start() {
        if (this.timer == null) throw new IllegalStateException();

        // emmm
        BackgroundWorker.getInstance().start();
        for (Rule rule : this.timerTasks.keySet()) {
            this.timer.scheduleAtFixedRate(
                    this.timerTasks.get(rule),
                    0,
                    (long) rule.getDuration() * 60 * 1000);
        }
    }

    public void destroy() {
        for (Rule rule : this.timerTasks.keySet()) {
            this.timerTasks.get(rule).cancel();
        }
        this.timer.cancel();
        this.timer.purge();
        this.timer = null;
        // emmm
        BackgroundWorker.getInstance().stop();
    }
}

TaskManager 类是一个负责管理消息推送任务的类,每隔特定的时间向 BackgroundWorker 这个单例类推送某个特定的任务让其执行。

看起来似乎没什么问题,代码也很简单。但是在我开始编写单元测试的时候,问题出现了:

因为 Mockito 不支持 mock 静态方法,因此我没有办法隔离 TaskManager 对 BackgroundWorker 的调用,而 BackgroundWorker 本身又是依赖 Android 设备的。

当然,我可以选择使用功能更强大,支持 mock 静态方法的 Powermock,但似乎本能的,我对这种 hack 的解决方式打心眼里不甚喜欢,且不说对静态函数的修改会导致测试代码变得丑陋。

怎么办呢?单元测试总是要写的。

那么,答案就只有一……

咳咳,刚才我什么都没说。那么,是时候祭出我们的依赖注入了!

使用依赖注入的改正

直接贴代码,解释见注释。

class TaskManager implements ITaskManager {
    private Iterable<Rule> rules;
    private Timer timer = new Timer();
    private ConcurrentHashMap<Rule, TimerTask> timerTasks = new ConcurrentHashMap<>();
    /* 留有一份IBackgroundWorker的实例 */
    private IBackgroundWorker backgroundWorker;

    /* 为了简化调用逻辑,使用构造函数注入,并更换为接口 */
    public TaskManager(Iterable<Rule> rules, IBackgroundWorker backgroundWorker) {
        this.rules = rules;
        /*
          拿到IBackgroundWorker实例,可以调用啦
          至于它是哪里来的,不管,反正只要能用就行
         */
        this.backgroundWorker = backgroundWorker;
        filterActiveRules();
        createTimers();
    }

    private void filterActiveRules() {
        ArrayList<Rule> activeRules = new ArrayList<>();
        for (Rule rule : this.rules) {
            if (rule.isActive()) {
                activeRules.add(rule);
            }
        }
        this.rules = activeRules;
    }

    private void createTimers() {
        for (Rule rule : this.rules) {
            timerTasks.put(rule, new TimerTask() {
                @Override
                public void run() {
                    /*
                      这里,直接使用我们得到的IBackgroundWorker实例
                      免去直接对单例类的依赖
                    */
                    TaskManager.this.backgroundWorker.newTask(rule);
                }
            });
        }
    }

    public void start() {
        if (this.timer == null) throw new IllegalStateException();

        /* 同理 */
        this.backgroundWorker.start();
        for (Rule rule : this.timerTasks.keySet()) {
            this.timer.scheduleAtFixedRate(
                    this.timerTasks.get(rule),
                    0,
                    (long) rule.getDuration() * 60 * 1000);
        }
    }

    public void destroy() {
        for (Rule rule : this.timerTasks.keySet()) {
            this.timerTasks.get(rule).cancel();
        }
        this.timer.cancel();
        this.timer.purge();
        this.timer = null;
        /* 同理 */
        this.backgroundWorker.stop();
    }
}

这样,当我在编写单元测试的时候,就可以直接 mock 一个 BackgroundWorker 实例,作为参数传入 TaskManager,通过调用计数的方式检查其是否成功推送了任务。

大概就长这样:

BackgroundWorker cttBackgroundWorker = mock(BackgroundWorker.class);
doNothing().when(cttBackgroundWorker).newTask(any());
doNothing().when(cttBackgroundWorker).start();
doNothing().when(cttBackgroundWorker).stop();
TaskManager taskManager = new TaskManager(rules, cttBackgroundWorker);
taskManager.start();
...
verify(cttBackgroundWorker, times(3)).newTask(any());

当然,我 TaskManager 里用实例是用得爽了,但是实例要从哪来呢?

其实简单思考一下,答案马上就出来了:

TaskManager taskManager = new TaskManager(rules, BackgroundWorker.getInstance());

完事。

总结

其实这篇文章的诞生完全是一个顺理成章的过程,以上的种种经过也是我在开发项目过程中真实产生的问题。而想到这个解决方案也是完全凭借我自己的思考。之前虽然有看过诸如控制反转,依赖注入之类的文章,但当时就是云里雾里,完全不知道他们在说什么,如今编写了真实的代码,遇到了真实的问题,才对这一切有了真实的感受。

软件工程本就是一个经验科学。任何理论最终还是需要用实践去体会。我不知道这是否意味着只要是一个实战经验丰富的软件工程师,哪怕仅凭无意识,也能熟练运用类似的构建方式,毕竟这会使我怀疑起软件工程这门学科本身的存在意义。

但是,我确定知道的一点是,亲手让一个系统中的各个模块变得简单,专一,易于测试,这个过程,是十分令人愉快的。

Unless otherwise specified, all materials and content available on this website are licensed under CC BY-NC 4.0.
Built with Hugo
Theme Stack designed by Jimmy