关于单例模式读配置文件的一些讨论

2021-06-11

< view all posts

读取配置文件是单例的一个典型应用,因为配置文件可能在程序的许多地方使用,但各处读取的都是相同的配置,我们只希望它被实例化一次。不过单例模式也有一些固有的缺点,这篇笔记记录一些对单例模式读配置文件的思考。

单例模式有很多实现方式,这里以比较简单也线程安全的饿汉式为例,读取配置文件的典型写法是:

public class AppConfig{
    private static AppConfig instance = new AppConfig("config_filename");
    private AppConfig(String filename){
        // 具体读取文件的逻辑
    }
    public static AppConfig getInstance(){
        return instance;
    }
}

这种写法最明显的一个缺点就是测试不友好。例如在测试阶段,我们希望用一些测试专用的配置去替代掉原本的配置文件,这是就会遇上麻烦:首先,配置文件的名称是写在单例这个类内部的,意味着如果我们使用另一个配置文件进行测试,就必须修改代码的内容,侵入性很强。

如果继承这个单例的类再用测试配置去重写它呢,也不可行,因为代码中已经定义了使用 AppConfig.getInstance() 的方式获取单例,重写的用于测试的子类并不能传递进去。

那么读取配置文件到底要不要用单例模式,有没有兼具单例模式的优点又对测试友好的写法?这个问题在网上也是众说纷纭。比如StackOverflow的这个问题下面就有一些讨论。

其中一个方法是,可以保留单例模式,但是配置文件的名字(或路径)不写死在单例的类里,而是以 System.getProperty() 的方式传递进去。这样就可以通过 java -D 参数来指定使用不同的配置文件。这个方法确实解决了问题,也没有改变单例模式,但是在之后运行和测试的时候都需要带上参数。

另一种能够保留单例的写法是,使用能够mock单例的测试库去进行测试。例如这篇文章介绍的内容。这样的好处是完全不需要改动原来的代码,但是在测试的时候需要引入额外的库,并进行mock操作。

还有一个能保留单例模式的折衷方法:将单例用get方法包装一层,测试时重写外层的类。举个例子:

public class ConfigWrapper {
    public String getName(){
        return AppConfig.getInstance().getStringProperty("name");
    }
}

这样在测试的时候去重写ConfigWrapper类里面的get方法就能够载入测试数据。这个方法在需要二次开发旧项目的时候比较好用:对功能的实现可以沿用旧代码中已经写好的单例,只对新增的部分再包装一层。缺点就是对每个属性都需要包装和重写对应的方法,增加了代码量。

也有一些建议是不要使用单例模式。那么如果不用单例模式,有什么好的写法呢?

一种比较好的思路是在应用的入口处(如初始化时)将配置文件读取好,作为一个对象传递到后续需要调用它的类。这样配置文件的路径就不必写死在代码中。举个例子:

public class App{
    private static AppConfig config;
    // 假设这个方法是应用的入口
    public static void start(String configFilename){
        // 在入口就将配置文件加载完成
        config = new AppConfig(configFilename);
        // 例如Use这个类需要用到Config,就将对象传递给它
        Use u = new Use(config);
    }
}

class Use{
    private final AppConfig config;
    // 在构造函数中保存对config对象的引用
    public Use(AppConfig config){
        this.config = config;
    }
}

class AppConfig{
    AppConfig(String configFilename){
        // ...读取配置文件的逻辑
    }
}

这样写能够解决问题,不过也可能变得有些繁琐:如果有多个配置文件,那么在入口处就需要挨个读取它们,而每个需要使用它们的类都要在构造函数中保存它们,如果这些类中又涉及到其它的类,还得继续一层层向下传递……

为了简化这个过程,一些库提供了依赖注入的功能,以及相配套的mock注入的方法,例如Spring。其实我们上面所说的“入口”,某种程度上扮演了类似于IoC容器的角色(但是不完全)。关于依赖注入是另一个值得深入的话题,会在之后的笔记中讨论。

总结下来,如果是使用了Spring之类的框架,或者是较大型的项目,利用框架提供的依赖注入功能是很好的。如果不使用依赖注入,在配置文件不多的情况下,通过统一的对象传递依赖,我认为也是比较好的方法。虽然使用单例模式去读配置文件也能通过设置java -D系统属性,或者mock单例之类的方式进行测试,但这种方法更像是一种work around。总的来说,从测试友好的角度,似乎还是避免单例模式较好。