结构型-适配器(Adapter)

3/26/2022 设计模式

摘要

JDK:1.8.0_202

# 一:场景问题

生活中,如果在国内使用一些外国电子产品,充电时,经常需要使用转接头才能使用原装赠送的充电器。现在部分手机的耳机孔直接 type-c,如果想要兼容之前圆孔的耳机,往往也需要在 type-c和圆孔耳机之间,加个转换器。这些转换器就是本章主角——适配器

下面使用一个日志记录例子,来讲述适配器模式

# 1.1 日志管理V1.0

在 V1.0 的时候,上级要求日志以文件的形式记录。开发人员遵照用户的要求,对日志文件的存取实现如下。

先简单定义日志对象,也就是描述日志的对象模型,由于这个对象需要被写入文件中,因此这个对象需要序列化,示例代码如下:

/**
 * 日志数据对象
 */
public class LogModel implements Serializable {

    /**
     * 日志编号
     */
    private String logId;

    /**
     * 操作人员
     */
    private String operateUser;

    /**
     * 操作时间,以yyyy-MM-dd HH:mm:ss的格式记录
     */
    private String operateTime;

    /**
     * 日志内容
     */
    private String logContent;

    public String getLogId() {
        return logId;
    }

    public void setLogId(String logId) {
        this.logId = logId;
    }

    public String getOperateUser() {
        return operateUser;
    }

    public void setOperateUser(String operateUser) {
        this.operateUser = operateUser;
    }

    public String getOperateTime() {
        return operateTime;
    }

    public void setOperateTime(String operateTime) {
        this.operateTime = operateTime;
    }

    public String getLogContent() {
        return logContent;
    }

    public void setLogContent(String logContent) {
        this.logContent = logContent;
    }

    public String toString() {
        return "logId=" + logId + ",operateUser=" + operateUser + ",operateTime=" + operateTime + ",logContent=" + logContent;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60

操作日志文件的接口,示例代码如下:

/**
 * 日志文件操作接口
 */
public interface LogFileOperateApi {

    /**
     * 读取日志文件,从文件里面获取存储的日志列表对象
     *
     * @return 存储的日志列表对象
     */
    List<LogModel> readLogFile();

    /**
     * 写日志文件,把日志列表写出到日志文件中去
     *
     * @param list 要写到日志文件的日志列表
     */
    void writeLogFile(List<LogModel> list);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

实现日志文件读写,示例代码如下:

/**
 * 实现对日志文件的操作
 */
public class LogFileOperate implements LogFileOperateApi {

    private final String logFilePathName;

    public LogFileOperate(String logFilePathName) {
        // 此处默认文件已经存在
        this.logFilePathName = logFilePathName != null && logFilePathName.trim().length() > 0 ? logFilePathName : "D:\\AdapterLog.log";
    }

    @Override
    public List<LogModel> readLogFile() {
        File file = new File(logFilePathName);
        try (ObjectInputStream in = new ObjectInputStream(new BufferedInputStream(new FileInputStream(file)))) {
            return (List<LogModel>) in.readObject();
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }

    @Override
    public void writeLogFile(List<LogModel> list) {
        File file = new File(logFilePathName);
        try (ObjectOutputStream out = new ObjectOutputStream(new BufferedOutputStream(new FileOutputStream(file)))) {
            out.writeObject(list);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

客户端,示例代码如下:

public class Client1 {
    public static void main(String[] args) {
        // 准备日志内容,测试数据
        LogModel lm1 = new LogModel();
        lm1.setLogId("001");
        lm1.setOperateUser("admin");
        lm1.setOperateTime("2022-03-06 15:09:10");
        lm1.setLogContent("这是一个测试");

        List<LogModel> list = new ArrayList<>();
        list.add(lm1);
        // 创建操作日志文件的对象
        LogFileOperateApi api = new LogFileOperate(null);
        // 保存日志文件
        api.writeLogFile(list);

        // 读取日志文件的内容
        List<LogModel> readLog = api.readLogFile();
        System.out.println("readLog=" + readLog);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

运行结果

运行结果

# 1.2 日志管理V2.0

上级使用日志管理的第一版一段时间过后,开始考虑升级系统,决定要采用数据库来管理日志,很快,按照数据库的日志管理也实现出来了,并定义了日志管理的操作接口,主要是针对日志的增删改查方法,接口的示例代码如下:

/**
 * 定义操作日志的应用接口,为了示例的简单,只是简单的定义了增删改查的方法
 */
public interface LogDbOperateApi {
    /**
     * 新增日志
     *
     * @param lm 需要新增的日志对象
     */
    void createLog(LogModel lm);

    /**
     * 修改日志
     *
     * @param lm 需要修改的日志对象
     */
    void updateLog(LogModel lm);

    /**
     * 删除日志
     *
     * @param id 需要删除的日志的id
     */
    void removeLog(String id);

    /**
     * 获取所有的日志
     *
     * @return 所有的日志对象
     */
    List<LogModel> getAllLog();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

实现数据库日志读写,示例代码如下:

/**
 * 模拟对数据库的实现
 */
public class LogDbOperate implements LogDbOperateApi {

    /**
     * 这里使用一个List来模拟表中记录的所有记录
     */
    private static List<LogModel> DB_LOG = new ArrayList<>();

    @Override
    public void createLog(LogModel lm) {
        DB_LOG.add(lm);
    }

    @Override
    public void updateLog(LogModel lm) {
        removeLog(lm.getLogId());
        DB_LOG.add(lm);
    }

    @Override
    public void removeLog(String id) {
        DB_LOG.removeIf(logModel -> logModel.getLogId().contains(id));
    }

    @Override
    public List<LogModel> getAllLog() {
        return DB_LOG;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

客户端,示例代码如下:

public class Client2 {

    public static void main(String[] args) {
        LogDbOperateApi db = new LogDbOperate();
        // 准备日志内容,测试数据
        for (int i = 1; i < 4; i++) {
            LogModel lm = new LogModel();
            lm.setLogId("00" + i);
            lm.setOperateUser("admin" + i);
            lm.setOperateTime("2022-03-06 15:09:10");
            lm.setLogContent("这是一个测试" + i);
            db.createLog(lm);
        }

        db.getAllLog().forEach(System.out::println);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

运行结果

运行结果

上级提出了新的要求,能不能让日志管理的第二版,实现同时支持数据库存储和文件存储两种方式?

# 1.3 有何问题

有朋友可能会想,这有什么困难的呢,两种实现方式不是都已经实现了的吗,合并起来不就可以了?

问题就在于,现在的业务是使用的第二版的接口,直接使用第二版新加入的实现是没有问题的,第二版新加入了保存日志到数据库中;但是对于已有的实现方式,也就是在第一版中采用的文件存储的方式,它的操作接口和第二版不一样,这就导致现在的客户端,无法以同样的方式来直接使用第一版的实现

这就意味着,要想同时支持文件和数据库存储两种方式,需要再额外的做一些工作,才可以让第一版的实现适应新的业务的需要。

可能有朋友会想,干脆按照第二版的接口要求重新实现一个文件操作的对象不就可以了,这样确实可以,但是何必要重新做已经完成的功能呢?应该要想办法复用,而不是重新实现

一种很容易想到的方式是直接修改已有的第一版的代码。这种方式是不太好的,如果直接修改了第一版的代码,那么可能会导致其它依赖于这些实现的应用不能正常运行,再说,有可能第一版和第二版的开发公司是不一样的,在第二版实现的时候,根本拿不到第一版的源代码。

那么该如何来实现呢?

# 二:解决方案

用来解决上述问题的一个合理的解决方案就是适配器模式。

适配器模式:将一个类的接口转换成客户希望的另外一个接口。适配器模式使得原本由于接口不兼容而不能一起工作的那些类可以一起工作。

# 2.1 解决思路

仔细分析上面的问题,问题的根源在于接口的不兼容,功能是基本实现了的,也就是说,只要想办法让两边的接口匹配起来,就可以复用第一版的功能了。

按照适配器模式的实现方式,可以定义一个类来实现第二版的接口,然后在内部实现的时候,转调第一版已经实现了的功能,这样就可以通过对象组合的方式,既复用了第一版已有的功能,同时又在接口上满足了第二版调用的要求。完成上述工作的这个类就是适配器。

# 2.2 模式结构和说明

适配器模式的结构如图所示:

类图

  • Client:客户端,调用自己需要的领域接口Target。
  • Target:定义客户端需要的跟特定领域相关的接口。
  • Adaptee:已经存在的接口,通常能满足客户端的功能要求,但是接口与客户端要求的特定领域接口不一致,需要被适配。
  • Adapter:适配器,把Adaptee适配成为Client需要的Target。

# 2.3 示例代码

接口:(Target.java)

/**
 * 定义客户端使用的接口,与特定领域相关
 */
public interface Target {
    /**
     * 示意方法,客户端请求处理的方法
     */
    public void request();
}
1
2
3
4
5
6
7
8
9

需要被适配的对象定义:(Adaptee.java)

/**
 * 已经存在的接口,这个接口需要被适配
 */
public class Adaptee {
    /**
     * 示意方法,原本已经存在,已经实现的方法
     */
    public void specificRequest() {
        //具体的功能处理
    }
}
1
2
3
4
5
6
7
8
9
10
11

适配器的基本实现:(Adapter.java)

/**
 * 适配器
 */
public class Adapter implements Target {

    /**
     * 持有需要被适配的接口对象
     */
    private Adaptee adaptee;

    /**
     * 构造方法,传入需要被适配的对象
     *
     * @param adaptee 需要被适配的对象
     */
    public Adapter(Adaptee adaptee) {
        this.adaptee = adaptee;
    }

    @Override
    public void request() {
        //可能转调已经实现了的方法,进行适配
        adaptee.specificRequest();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

客户端:(Client.java)

/**
 * 使用适配器的客户端
 */
public class Client {
    public static void main(String[] args) {
        // 创建需被适配的对象
        Adaptee adaptee = new Adaptee();
        // 创建客户端需要调用的接口对象
        Target target = new Adapter(adaptee);
        // 请求处理
        target.request();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13

# 2.4 重写案例

结构如图所示:

类图

要使用适配器模式来实现示例,关键就是要实现这个适配器对象,它需要实现第二版的接口,但是在内部实现的时候,需要调用第一版已经实现的功能。也就是说,第二版的接口就相当于适配器模式中的Target接口,而第一版已有的实现就相当于适配器模式中的Adaptee对象

适配器实现:(Adapter.java)

/**
 * 适配器对象,把记录日志到文件的功能适配成第二版需要的增删改查的功能
 */
public class Adapter implements LogDbOperateApi {

    /**
     * 持有需要被适配的接口对象
     */
    private LogFileOperateApi adaptee;

    public Adapter(LogFileOperateApi adaptee) {
        this.adaptee = adaptee;
    }

    @Override
    public void createLog(LogModel lm) {
        // 1. 先读取文件的内容
        List<LogModel> list = adaptee.readLogFile();
        // 2. 加入新的日志对象
        list.add(lm);
        // 3. 重新写入文件
        adaptee.writeLogFile(list);
    }

    @Override
    public void updateLog(LogModel lm) {
        // 1. 先读取文件的内容
        List<LogModel> list = adaptee.readLogFile();
        // 2. 删除相应的日志对象
        list.removeIf(logModel -> logModel.getLogId().contains(lm.getLogId()));
        // 3. 覆盖
        list.add(lm);
        // 4. 重新写入
        adaptee.writeLogFile(list);
    }

    @Override
    public void removeLog(String id) {
        // 1. 先读取文件的内容
        List<LogModel> list = adaptee.readLogFile();
        // 2. 删除相应的日志对象
        list.removeIf(logModel -> logModel.getLogId().contains(id));
        // 3. 重新写入文件
        adaptee.writeLogFile(list);
    }

    @Override
    public List<LogModel> getAllLog() {
        return adaptee.readLogFile();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51

客户端:(Client3.java)

public class Client3 {
    public static void main(String[] args) {
        // 准备日志内容,测试数据
        LogModel lm = new LogModel();
        lm.setLogId("007");
        lm.setOperateUser("admin7");
        lm.setOperateTime("2022-03-06 16:15:17");
        lm.setLogContent("这是一个test 7");
        List<LogModel> list = new ArrayList<>();
        list.add(lm);
        // 创建操作日志文件的对象
        LogFileOperateApi logFileApi = new LogFileOperate(null);

        // 创建新版的操作日志的接口对象
        LogDbOperateApi api = new Adapter(logFileApi);

        // 保存日志文件
        api.createLog(lm);
        // 读取日志文件的内容
        List<LogModel> allLog = api.getAllLog();
        System.out.println("allLog=" + allLog);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

运行结果

运行结果

# 三:模式讲解

# 3.1 认识适配器模式

1. 模式的功能

适配器模式的主要功能是进行转换匹配,目的是复用已有的功能,而不是来实现新的接口。也就是说,客户端需要的功能应该是已经实现好了的,不需要适配器模式来实现,适配器模式主要负责把不兼容的接口转换成客户端期望的样子就好了

但这并不是说,在适配器里面就不能实现功能,适配器里面可以实现功能,称这种适配器为智能适配器。再说了,在接口匹配和转换的过程中,也是有可能需要额外实现一定的功能,才能够转换过来的,比如需要调整参数以进行匹配等。

2. Adaptee和Target的关系

适配器模式中被适配的接口Adaptee和适配成为的接口Target是没有关联的,也就是说,Adaptee和Target中的方法既可以相同,也可以不同,极端情况下两个接口里面的方法可能是完全不同的,当然极端情况下也可以完全相同。

这里所说的相同和不同,是指的方法定义的名称、参数列表、返回值、包括方法本身的功能都可以相同和不同。

3. 对象组合

根据前面的实现,你会发现,适配器的实现方式其实是依靠对象组合的方式。通过给适配器对象组合被适配的对象,然后当客户端调用Target的时候,适配器会把相应的功能,委托给被适配的对象去完成。

# 3.2 优缺点

1. 更好的复用性

如果功能是已经有了的,只是接口不兼容,那么通过适配器模式就可以让这些功能得到更好的复用。

2. 更好的可扩展性

在实现适配器功能的时候,可以调用自己开发的功能,从而自然的扩展系统的功能。

3. 过多的使用适配器,会让系统非常零乱,不容易整体进行把握

比如:明明看到调用的是A接口,其实内部被适配成了B接口来实现,一个系统如果太多这种情况,无异于一场灾难。因此如果不是很有必要,可以不使用适配器,而是直接对系统进行重构。

# 3.3 思考适配器模式

1. 适配器模式的本质

适配器模式的本质:转换匹配,复用功能。

适配器通过转换调用已有的实现,从而能把已有的实现匹配成需要的接口,使之能满足客户端的需要。也就是说转换匹配是手段,而复用已有的功能才是目的。

在进行转换匹配的过程中,适配器还可以在转换调用的前后实现一些功能处理,也就是实现智能的适配。

2. 何时选用适配器模式

建议在如下情况中,选用适配器模式:

如果你想要使用一个已经存在的类,但是它的接口不符合你的需求,这种情况可以使用适配器模式,来把已有的实现转换成你需要的接口

如果你想创建一个可以复用的类,这个类可能和一些不兼容的类一起工作,这种情况可以使用适配器模式,到时候需要什么就适配什么

如果你想使用一些已经存在的子类,但是不可能对每一个子类都进行适配,这种情况可以选用对象适配器,直接适配这些子类的父类就可以了。

# 3.4 相关模式

1. 适配器模式与桥接模式

其实这两个模式除了结构略为相似外,功能上完全不同。

适配器模式是把两个或者多个接口的功能进行转换匹配;而桥接模式是让接口和实现部分相分离,以便它们可以相对独立的变化。

2. 适配器模式与装饰模式

从某种意义上讲,适配器模式能模拟实现简单的装饰模式的功能,也就是为已有功能增添功能。比如我们在适配器里面这么写:

public void adapterMethod(){
      System.out.println("在调用Adaptee的方法之前完成一定的工作");
      //调用Adaptee的相关方法
      adaptee.method();
      System.out.println("在调用Adaptee的方法之后完成一定的工作");
}
1
2
3
4
5
6

如上的写法,就相当于在调用Adaptee的被适配方法前后添加了新的功能,这样适配过后,客户端得到的功能就不单纯是Adaptee的被适配方法的功能了。看看是不是类似装饰模式的功能呢?

注意,仅仅是类似,造成这种类似的原因:两种设计模式在实现上都是使用的对象组合,都可以在转调组合对象的功能前后进行一些附加的处理,因此有这么一个相似性。它们的目的和本质都是不一样的

两个模式有一个很大的不同:一般适配器适配过后是需要改变接口的,如果不改接口就没有必要适配了;而装饰模式是不改接口的,无论多少层装饰都是一个接口。因此装饰模式可以很容易的支持递归组合,而适配器就做不到了,每次的接口不同,没法递归。

3. 适配器模式和代理模式

适配器模式可以跟代理模式组合使用,在实现适配器的时候,可以通过代理来调用Adaptee,这可以获得更大的灵活性。

4. 适配器模式和抽象工厂模式

在适配器实现的时候,通常需要得到被适配的对象,如果被适配的是一个接口,那么就可以结合一些可以创造对象实例的设计模式,来得到被适配的对象示例。比如:抽象工厂模式、单例模式、工厂方法模式等等。

# 四:JDK

# 五:参考文献

最后更新: 3/27/2022, 6:04:09 PM