抽象工厂模式(Abstract Factory),是23种设计模式之一。DP中是这么定义抽象工厂模式的:

抽象工厂模式(Abstract Factory),提供一个创建一系列相关或互相依赖对象的接口,而无需指定它们具体的类。

在学习抽象工厂模式之前,最好熟悉简单工厂模式以及工厂方法模式,这样对理解抽象工厂模式会有一定帮助,而且抽象工厂也是基于工厂方法模式的。

至于工厂是用来干什么的,我这里也不过多介绍了,因为之前在工厂方法模式里已经说过了,如果对工厂的概念不是太清楚的话,可以参考我之前的文章:

https://blog.51cto.com/zero01/2067822

在这里我们暂时先不谈论抽象工厂是什么,因为如果直接上来就去描述、解释什么是抽象工厂,以及如何使用抽象工厂模式来设计代码,这样是无法很好的明白抽象工厂模式的概念以及它所带来的好处或坏处的,只会让人下意识的只去记住实现代码,而不是设计模式的思想。讲解其他模式也是一样,如果一上来就是代码+理论一顿灌,只会让人看得亿脸懵逼或似懂非懂。这就好比给你一块披萨告诉你很好吃,以及这块披萨上用了哪些好食材,你只管吃就可以了,那么如果你没有吃过难吃的披萨,可能就会以为披萨就应该是这个味道的。

所以我们先从有些糟糕的代码入手,并且分析这些代码哪些地方有问题,然后再演进成使用设计模式去重构代码,这样就能有一个明显的对比,毕竟有对比才有伤害嘛2333。

下面我们来写一些简单的代码,这些代码用于对MySQL数据库的表格数据进行访问:

1.User类,封装User表的数据,假设只有uid和uname两个字段:

package org.zero01.test;

public class User {

    public int getUid() {
        return uid;
    }

    public void setUid(int uid) {
        this.uid = uid;
    }

    public String getUname() {
        return uname;
    }

    public void setUname(String uname) {
        this.uname = uname;
    }

    private int uid;
    private String uname;

}

2.MysqlUser类,用于对mysql数据库进行访问,这里只是简单的进行模拟,并没有实际的访问数据库的代码:

package org.zero01.test;

public class MysqlUser {

    public void insert(User user){
        System.out.println("对 MySQL 里的 User 表插入了一条数据");
    }

    public User getUser(int uid){
        System.out.println("通过 uid 在 MySQL 里的 User 表得到了一条数据");
        return null;
    }
}

3.客户端代码如下:

package org.zero01.test;

public class Client {

    public static void main(String[] args){

        User user=new User();

        MysqlUser mysqlUser=new MysqlUser();

        mysqlUser.insert(user);
        mysqlUser.getUser(1);
    }
}

从以上的客户端代码可以很明显到看到一个问题,就是MysqlUser mysqlUser=new MysqlUser();这一句代码使得mysqlUser 这个对象被写死在了MysqlUser 上。如果需求变更,数据库方面不用MySQL而改用Oracle了呢,那么与之有关联的代码都得需要进行更改。


使用工厂方法模式进行重构

这是因为代码上依赖了具体的实现类,导致与 MysqlUser 耦合,如果熟悉多态或工厂模式的话,可能就已经想到可以用工厂模式来改造它了,通过工厂方法模式可以封装 new MysqlUser(); 所造成的变化,因为工厂方法模式可以定义一个用于创建对象的接口,让子类决定实例化哪一个类。

使用工厂方法重构以上的代码,代码结构图如下:
设计模式之抽象工厂模式_第1张图片

IUser接口,用于客户端访问,解除与具体数据库访问的耦合:

package org.zero01.product;

import org.zero01.test.User;

public interface IUser {

    public void insert(User user);
    public IUser getUser(int uid);

}

MysqlUser类,用于访问MySQL数据库的User表:

package org.zero01.product;

import org.zero01.test.User;

public class MysqlUser implements IUser{

    public void insert(User user) {
        System.out.println("对 MySQL 里的 User 表插入了一条数据");
    }

    public IUser getUser(int uid) {
        System.out.println("通过 uid 在 MySQL 里的 User 表得到了一条数据");
        return null;
    }
}

OracleUser类,用于访问Oracle数据库的User表:

package org.zero01.product;

import org.zero01.test.User;

public class OracleUser implements IUser{

    public void insert(User user) {
        System.out.println("对 Oracle 里的 User 表插入了一条数据");
    }

    public IUser getUser(int uid) {
        System.out.println("通过 uid 在 Oracle 里的 User 表得到了一条数据");
        return null;
    }
}

IFactory接口,定义一个抽象的工厂接口,该工厂用于生产访问User表的对象:

package org.zero01.factory;

import org.zero01.product.IUser;

public interface IFactory {

    public IUser createUser();

}

MysqlFactory类,实现IFactory接口,用于生产 MysqlUser 的实例对象:

package org.zero01.factory;

import org.zero01.product.IUser;
import org.zero01.product.MysqlUser;

public class MysqlFactory implements IFactory{

    public IUser createUser() {
        return new MysqlUser();
    }
}

OracleFactory 类,实现IFactory接口,用于生产 OracleUser 的实例对象:

package org.zero01.factory;

import org.zero01.product.IUser;
import org.zero01.product.OracleUser;

public class OracleFactory implements IFactory{

    public IUser createUser() {
        return new OracleUser();
    }
}

客户端代码如下:

package org.zero01.client;

import org.zero01.factory.IFactory;
import org.zero01.factory.MysqlFactory;
import org.zero01.product.IUser;
import org.zero01.test.User;

public class Client {

    public static void main(String[] args){

        User user=new User();

        IFactory factory=new MysqlFactory();
        IUser userOperation=factory.createUser();

        userOperation.getUser(1);
        userOperation.insert(user);

    }
}

以上我们使用工厂方法模式重构的之前的代码,现在如果需求改变,要更换数据库,只需要把 MysqlFactory(); 改为 OracleFactory(); 就可以了,此时由于多态的特性,使得 IUser 接口的对象 userOperation 根本不知道是在访问哪个数据库,却可以在运行时很好的完成工作,这就是所谓的业务逻辑与数据访问的解耦。


使用抽象工厂模式重构

但是,问题还没有解决完,因为数据库里不可能只有一个表吧,很有可能会有其他表,比如与用户表相关的登录记录表(Login表),此时该如何解决?

Login 类,封装 Login 表的数据,假设只有 id 和 date 两个字段:

package org.zero01.obj;

import java.util.Date;

public class Login {

    private int id;
    private Date date;

    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public Date getDate() {
        return date;
    }

    public void setDate(Date date) {
        this.date = date;
    }
}

其实即便是数据库中会有多个表,那也是属于数据访问这一类的,属于这一系列的,所以我们只需要增加一些相关的类即可。

代码结构图如下:
设计模式之抽象工厂模式_第2张图片

ILogin接口,用于客户端访问,解除与具体数据库访问的耦合:

package org.zero01.product;

import org.zero01.obj.Login;

public interface ILogin {

    public void insert(Login login);
    public Login getLogin(int id);

}

MysqlLogin类,用于访问MySQL数据库的Login表:

package org.zero01.product;

import org.zero01.obj.Login;

public class MysqlLogin implements ILogin{

    public void insert(Login login) {
        System.out.println("对 MySQL 里的 Login 表插入了一条数据");
    }

    public Login getLogin(int id) {
        System.out.println("通过 uid 在 MySQL 里的 Login 表得到了一条数据");
        return null;
    }
}

OracleLogin 类,用于访问MySQL数据库的Login表:

package org.zero01.product;

import org.zero01.obj.Login;

public class OracleLogin implements ILogin{

    public void insert(Login login) {
        System.out.println("对 Oracle 里的 Login 表插入了一条数据");
    }

    public Login getLogin(int id) {
        System.out.println("通过 uid 在 Oracle 里的 Login 表得到了一条数据");
        return null;
    }
}

IFactory,定义一个抽象的工厂接口,该工厂用于生产访问User表以及Login表的对象:

package org.zero01.factory;

import org.zero01.product.ILogin;
import org.zero01.product.IUser;

public interface IFactory {

    public IUser createUser();
    public ILogin createLogin();
}

MysqlFactory类,实现IFactory接口,用于生产 MysqlUser 以及 MysqlLogin 的实例对象:

package org.zero01.factory;

import org.zero01.product.ILogin;
import org.zero01.product.IUser;
import org.zero01.product.MysqlLogin;
import org.zero01.product.MysqlUser;

public class MysqlFactory implements IFactory{

    public IUser createUser() {
        return new MysqlUser();
    }

    public ILogin createLogin() {
        return new MysqlLogin();
    }
}

OracleFactory 类,实现IFactory接口,用于生产 OracleUser 以及 OracleLogin 的实例对象:

package org.zero01.factory;

import org.zero01.product.ILogin;
import org.zero01.product.IUser;
import org.zero01.product.OracleLogin;
import org.zero01.product.OracleUser;

public class OracleFactory implements IFactory{

    public IUser createUser() {
        return new OracleUser();
    }

    public ILogin createLogin() {
        return new OracleLogin();
    }
}

客户端代码如下:

package org.zero01.client;

import org.zero01.factory.IFactory;
import org.zero01.factory.MysqlFactory;
import org.zero01.factory.OracleFactory;
import org.zero01.obj.Login;
import org.zero01.product.ILogin;
import org.zero01.product.IUser;
import org.zero01.obj.User;

public class Client {

    public static void main(String[] args){

        User user=new User();
        Login login = new Login();

        // 只需要确定实例化哪一个数据库访问对象给factory
        // IFactory factory=new MysqlFactory();
        IFactory factory=new OracleFactory();

        // 已与具体的数据库访问解除了耦合
        IUser userOperation=factory.createUser();

        userOperation.getUser(1);
        userOperation.insert(user);

        // 已与具体的数据库访问解除了耦合
        ILogin loginOperation=factory.createLogin();

        loginOperation.insert(login);
        loginOperation.getLogin(1);

    }
}

运行结果:

通过 uid 在 Oracle 里的 User 表得到了一条数据
对 Oracle 里的 User 表插入了一条数据
对 Oracle 里的 Login 表插入了一条数据
通过 uid 在 Oracle 里的 Login 表得到了一条数据

从客户端的代码中,我们只需要更改 IFactory factory=new MysqlFactory();IFactory factory=new OracleFactory();,就实现了数据库访问的切换。而且实际上我们这次代码的重构已经使用到了抽象工厂模式,抽象工厂可能表面上看起来貌似与工厂方法模式没什么区别,其实不然,所以我之前才说抽象工厂模式是基于工厂方法模式的。

只有一个User表的封装类和User表的操作类时,我们只用到了工厂方法模式,而且也只需要使用到工厂方法模式。但是显然现在我们的数据库已经不止一个User表了,而 MySQL 和 Oracle 又是两大不同的分类,所以解决这种涉及到多个产品系列的问题,就需要使用到专门解决这种问题的模式:抽象工厂模式。这时候再回过头去看DP对抽象工厂模式的定义就不难理解了。

所以抽象工厂与工厂方法模式的区别在于:抽象工厂是可以生产多个产品的,例如 MysqlFactory 里可以生产 MysqlUser 以及 MysqlLogin 两个产品,而这两个产品又是属于一个系列的,因为它们都是属于MySQL数据库的表。而工厂方法模式则只能生产一个产品,例如之前的 MysqlFactory 里就只可以生产一个 MysqlUser 产品。

示意图:
设计模式之抽象工厂模式_第3张图片

抽象工厂模式(Abstract Factory)结构图:
设计模式之抽象工厂模式_第4张图片

AbstractProductA 和 AbstractProductB是两个抽象的产品,之所以为抽象,是因为他们都有可能有两种或多种不同的实现,就刚才的例子来说就是 User 和 Login 表的不同数据库的访问对象,而ProductA1、ProductA2和ProductB1、ProductB2 就是对两个抽象产品的具体分类的实现,例如 ProductA1可以对比为 MysqlUser ,而 ProductB1 则可以对比为 MysqlLogin。

IFactory 则是一个抽象的工厂接口,它里面应该包含所有的产品创建的抽象方法。而ConcreteFactory 1 和 ConcreteFactory 2 就是具体的工厂了。就像MysqlFactory和OracleFactory一样。

我们通常是在运行时再创建一个 ConcreteFactory 类的实例对象,这个具体的工厂再创建具有特定实现的产品对象,也就是说,为创建不同的产品对象,客户端应该使用不同的具体工厂。


抽象工厂模式的优缺点

优点:

抽象工厂模式最大的好处是易于交换产品系列,由于具体工厂类,例如 IFactory factory=new OracleFactory(); 在一个应用中只需要在初始化的时候出现一次,这就使得改变一个应用的具体工厂变得非常容易,它只需要改变具体工厂即可使用不同的产品配置。不管是任何人的设计都无法去完全防止需求的更改,或者项目的维护,那么我们的理想便是让改动变得最小、最容易,例如我现在要更改以上代码的数据库访问时,只需要更改具体的工厂即可。

抽象工厂模式的另一个好处就是它让具体的创建实例过程与客户端分离,客户端是通过它们的抽象接口操作实例,产品实现类的具体类名也被具体的工厂实现类分离,不会出现在客户端代码中。就像我们上面的例子,客户端只认识IUser和ILogin,至于它是MySQl里的表还是Oracle里的表就不知道了。

缺点:

但是任何的设计模式都有自身的缺陷都不是完美的,都有不适用的时候,例如抽象工厂模式虽然可以很方便的帮我们切换两个不同的数据库访问的代码。但是如果我们的需求来自于增加功能,例如我们还需要加多一个会员数据表 MemberData,那么我们就得先在以上代码的基础上,增加三个类:IMemberData,MysqlMemberData,OracleMemberData,还需要修改IFactory、MysqlFactory以及OracleFactory才可以完全实现。增加类还好说,毕竟我们是对扩展开放的,但是却要修改三个类,就有点糟糕了。

而且还有一个问题就是客户端程序类在实际的开发中,肯定不止一个,很多地方都会需要使用 IUser 或 ILogin ,而这样的设计,其实在每一个类的开始都需要写上 IFactory factory=new OracleFactory(); 这样的代码,如果我有一百个访问 User 或 Login 表的类,那不就得改一百个类?很多人都喜欢说编程是门艺术,但也的确如此,对于艺术我们应该去追求美感,所以这样大批量的代码更改,显然是非常丑陋的做法。


用简单工厂来改进抽象工厂

我们要有不向丑陋代码低头的精神,所以我们再来改进一下这些代码。实际上,在这种情况下与其用那么多的工厂类,不如直接用一个简单工厂来实现,我们将IFactory、MySQLFactory以及OracleFactory三个工厂类都抛弃掉,取而代之的是一个简单工厂类EasyFactory,代码结构图如下:
设计模式之抽象工厂模式_第5张图片

EasyFactory类,简单工厂:

package org.zero01.easyfactory;

import org.zero01.product.*;

public class EasyFactory {

    // 数据库名称
    private static String db="MySQL";
    // private static String db="Oracle";

    public static IUser createUser(){

        IUser user=null;
        switch (db){
            case "MySQL":
                user=new MysqlUser();
                break;

            case "Oracle":
                user=new OracleUser();
                break;
        }
        return user;
    }

    public static ILogin createLogin(){

        ILogin login=null;
        switch (db){
            case "MySQL":
                login=new MysqlLogin();
                break;

            case "Oracle":
                login=new OracleLogin();
                break;
        }
        return login;
    }
}

客户端代码如下:

package org.zero01.client;

import org.zero01.easyfactory.EasyFactory;
import org.zero01.obj.Login;
import org.zero01.product.ILogin;
import org.zero01.product.IUser;
import org.zero01.obj.User;

public class Client {

    public static void main(String[] args){

        User user=new User();
        Login login = new Login();

        // 直接得到实际的数据库访问实例,而不存在任何依赖
        IUser userOperation= EasyFactory.createUser();

        userOperation.getUser(1);
        userOperation.insert(user);

        // 直接得到实际的数据库访问实例,而不存在任何依赖
        ILogin loginOperation=EasyFactory.createLogin();

        loginOperation.insert(login);
        loginOperation.getLogin(1);

    }
}

由于事先在简单工厂类里设置好了db的值,所以简单工厂的方法都不需要由客户端来输入参数,这样在客户端就只需要使用 EasyFactory.createUser();EasyFactory.createLogin(); 方法来获得具体的数据库访问类的实例,客户端代码上没有出现任何一个 MySQL 或 Oracle 的字样,达到了解耦的目的,客户端已经不再受改动数据库访问的影响了。


用反射机制+简单工厂模式继续改进代码

但是我们都知道简单工厂也存在一个缺陷,例如我要增加一个 SQL Server 数据库的访问类,那么本来抽象工厂模式只需要增加一个 SQLServerFactory 工厂类就可以了,而简单工厂则需要在每个方法的switch中增加case条件了。

所以我们要考虑的是可以不可以不在代码里写明条件分支语句,而是根据字符串db的值来去某个地方找需要实例化的那个类,这样的话,我们就可以和switch语句say goodbye了。

而在Java中有一种技术可以做到这一点,那就是反射机制,有了反射机制我们只需要使用字符串就可以获取某个类的实例,例如:

// 字符串里的该类的全名
IUser result = (IUser) Class.forName("org.zero01.product.MysqlUser").newInstance();

这种反射的写法和 IUser result = new MysqlUser(); 一样可以拿到 MysqlUser 类的实例。而它们的区别在于,反射可以通过字符串来获取 MysqlUser 类的实例,使用new关键字则不行,编译后就无法改变了。我们都知道字符串是可以存储在变量中的,可以通过变量来处理字符串,也就是说可以根据需求来进行动态更换。

以上我们使用简单工厂模式设计的代码中,是用一个字符串类型的db变量来存储数据库名称的,所以变量的值到底是 MySQL 还是 Oracle ,完全可以由事先设置的那个db变量来决定,而我们又可以通过反射来去获取实例,这样就可以去除switch语句了。

下面我们就来使用反射机制改造一下之前的简单工厂类:

package org.zero01.easyfactory;

import org.zero01.product.*;

public class EasyFactory {

    private static String packName = "org.zero01.product";

    // 数据库名称,可替换成Oracle
    private static String db = "Mysql";
    // private static String db="Oracle";

    public static IUser createUser() throws Exception {

        String className = packName + "." + db + "User";

        return (IUser)Class.forName(className).newInstance();
    }

    public static ILogin createLogin() throws Exception {

        String className = packName + "." + db + "Login";

        return (ILogin)Class.forName(className).newInstance();
    }
}

客户端代码如下,除了抛多一个异常,其他代码都不需要改动:

package org.zero01.client;

import org.zero01.easyfactory.EasyFactory;
import org.zero01.obj.Login;
import org.zero01.product.ILogin;
import org.zero01.product.IUser;
import org.zero01.obj.User;

public class Client {

    public static void main(String[] args) throws Exception {

        User user=new User();
        Login login = new Login();

        IUser userOperation= EasyFactory.createUser();

        userOperation.getUser(1);
        userOperation.insert(user);

        ILogin loginOperation=EasyFactory.createLogin();

        loginOperation.insert(login);
        loginOperation.getLogin(1);

    }
}

运行结果:

通过 uid 在 MySQL 里的 User 表得到了一条数据
对 MySQL 里的 User 表插入了一条数据
对 MySQL 里的 Login 表插入了一条数据
通过 uid 在 MySQL 里的 Login 表得到了一条数据

现在如果需要增加 SQL Server数据库的访问功能,那么增加相关的类是不可避免的,这点无论如何都无法解决,但是这叫扩展,开-闭原则告诉我们对于扩展要开放,但对于修改就要尽量关闭。就目前而言,如果要切换数据库需要更改db变量的值即可,也就是说只需要改动一下代码的注释就可以了:

// private static String db = "Mysql";
private static String db="Oracle";

那么如果我还需要增加会员数据表 MemberData 的话,只需要增加三个与 MemberData 相关的类,再修改一下 EasyFactory 类,在里面增加一个创建实例的方法即可。

如果项目比较大的话,就可以直接使用工厂方法模式了,那样只需要增加新的类即可,不需要对原有的代码进行改动,灵活性比简单工厂更强。所以在实际的项目中,我们应该根据情况来选择使用哪种设计模式,不然使用哪种模式也好,都有可能会导致设计过度或不足。


用反射机制+配置文件+简单工厂模式继续改进代码

虽然我们已经使用了反射机制改进了代码,但是总感觉还是有点缺憾,因为在更换数据库访问时,我们还是需要去打开代码更改db变量的值,然后再重新进行编译。所以如果能够不打开代码修改程序,就能达到更改变量的效果,那才是完全符合开-闭原则。

这也不是没办法解决的,例如典型的配置文件就可以解决这种问题,我们可以在外部文件写好这些信息,让程序去读文件中配置的信息来给变量赋值就可以了,以后修改也只需要修改配置文件,而不需要去打开代码来修改,修改之后还得重新编译那么麻烦了。

在工程的根目录下创建一个.json的配置文件,内容如下:

{
  "packName": "org.zero01.product",
  "DB": "Mysql"
}

由于用的是json来作为配置文件的格式,所以我这里使用了解析json的包:
设计模式之抽象工厂模式

EasyFactory 类代码如下:

package org.zero01.easyfactory;

import org.json.JSONObject;
import org.zero01.product.ILogin;
import org.zero01.product.IUser;

import java.io.*;

public class EasyFactory {

    private static String packName;
    private static String db;

    // 读取配置文件内容,初始化变量值
    static {

        try {

            FileReader fileReader = new FileReader("app.json");
            BufferedReader bufferedReader = new BufferedReader(fileReader);

            StringBuffer config = new StringBuffer();
            String s = null;

            while ((s = bufferedReader.readLine()) != null) {
                config.append(s);
            }

            bufferedReader.close();

            JSONObject jsonObject = new JSONObject(config.toString());

            packName = jsonObject.getString("packName");
            db = jsonObject.getString("DB");

        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public static IUser createUser() throws Exception {

        String className = packName + "." + db + "User";

        return (IUser) Class.forName(className).newInstance();
    }

    public static ILogin createLogin() throws Exception {

        String className = packName + "." + db + "Login";

        return (ILogin) Class.forName(className).newInstance();
    }
}

客户端代码无需改动,运行结果如下:

通过 uid 在 Oracle 里的 User 表得到了一条数据
对 Oracle 里的 User 表插入了一条数据
对 Oracle 里的 Login 表插入了一条数据
通过 uid 在 Oracle 里的 Login 表得到了一条数据

小结:经过了一系列的改进代码,这下对于目前的需求来说基本算得上是满分了,我们最后应用了反射机制+配置文件+简单工厂模式解决了数据库访问时的可维护、可扩展的问题。而且从这个角度上讲,几乎所有在用简单工厂的地方,都可以考虑利用反射机制来去除 switch case 或 if else 等条件分支语句,进一步解除分支判断带来的耦合,所以在后面我才没有用工厂方法模式而是用简单工厂方法来去改进之前的抽象工厂。