目录

设计模式-装饰器模式

设计模式–装饰器模式

装饰器模式,最常用的设计模式之一

这是一个新的工程。我们有一个类叫做 HistorySet,它带有一个泛型参数,表示集合中的元素类型。这个集合既像普通的 Set 一样支持增删改查,又能在元素被 remove 掉的时候保留历史记录。

我们先思考如何实现这个功能。既然它是一个 Set,我们可以直接实现 Set<E> 接口,然后实现其中的所有方法。不过我们重点关注的是 remove 方法——如何在删除元素的同时,记录下被删除的元素?

我们可以用一个 List<E> 来保存这些被删除的元素,比如命名为 removedList。当一个元素成功从集合中移除后,我们就把这个元素添加到 removedList 中。


public class HistorySet<E> implements Set<E> {

    List<E> removeList = new ArrayList<>();

    @Override
    public int size() {
        return 0;
    }

    @Override
    public boolean isEmpty() {
        return false;
    }

    @Override
    public boolean contains(Object o) {
        return false;
    }

    @Override
    public Iterator<E> iterator() {
        return null;
    }

    @Override
    public Object[] toArray() {
        return null;
    }
    @Override
    public boolean remove(Object e){
        return false;
    }
    @Override 
    public boolean containsAll(Collecction<?> c){
        return false;
    }
    @Override
    public void clear(){
    }
    @Override
    public String toString(){
        return hashSet.toString() +" remove list: " + removeList;
    }
}

现在的问题是:如何实现 Set 接口的其他方法?如果我们有一个已经实现了 Set 接口的类(比如 HashSet),能不能把所有功能都委托给它来完成,而我们只需要在关键的地方(如 remove)做增强处理?

答案是可以的。我们在 HistorySet 内部持有一个 Set<E> 的实例,比如
private Set hashSet =new HashSet<>();

这样,size() 就直接返回 hashSet.size()isEmpty() 返回 hashSet.isEmpty()add() 直接调用 hashSet.add(),其他方法也都交给这个内部的 HashSet 去完成。

唯独 remove 方法我们需要特殊处理。因为 remove 有返回值:如果返回 true,说明元素确实被移除了;如果返回 false,说明元素原本就不在集合中,无需处理。因此,我们先调用 hashSet.remove(o),判断返回值:


@Override
public boolean remove(Object o) {
    if(hashSet.remove(o)){
        removeList.add((E)o);
        return true;
    }
    return false;
}

注意这里有个泛型问题:remove 参数是 Object 类型,而我们要存入 List<E>,所以需要强制类型转换。在实际使用中应确保类型安全。

接下来我们写一个测试的 main 函数。创建一个 HistorySet<Integer> 实例,可以用 Set<Integer> 多态引用:


public class Main {
    public static void main(String[] args) {
        Set<String> set = new HistorySet<>();
        set.add("1");
        set.add("2");
        set.add("3");
        set.add("4");
        set.remove("4");
        set.remove("4");
        set.remove("5");
        set.remove("1");
        System.out.println(set);
    }
}

然后我们打印这个 set。但我们还没写 toString() 方法,已经补上

 [2,3] remove list : [4,1]

运行后输出显示:当前集合中有 2 和 3,被删除的元素是 4 和 1,而 5 因为从未存在过,所以没有被记录。

我们再回头看这个 HistorySet 类。我们实现了 Set 接口,但我们自己并没有真正实现任何 Set 的逻辑,而是把所有操作都交给了内部的 HashSet。这说明我们其实并不依赖 HashSet,只要是 Set 的实现都可以。

那如果我们希望底层是有序的 Set(比如 TreeSet),怎么办?我们可以让用户在构造时传入一个 Set 实例,而不是固定使用 HashSet。private Set hashSet =new TreeSet<>();

于是我们修改构造函数:

public HistorySet(Set<E> delegate) {  
    this.delegate = delegate;  
}  

这个 delegate 就是被代理的对象。我们在创建 HistorySet 时传入一个 HashSetTreeSet 都可以:


public HistorySet(Set<E> hashSet) {
    this.delegate = hashSet;
}

List<E> removeList = new ArrayList<>();
private final Set<E> delegate;

这样,HistorySet 的职责就非常清晰:它只是一个“包装器”,只增强 remove 功能,其余所有行为都由代理对象完成。

这就是装饰器模式的核心思想:通过包装一个对象,在不改变其原有功能的前提下,增加新的行为或功能。

更进一步,既然 HistorySet 本身也是一个 Set,那我们能不能把它再包装一次?比如:装饰的historyset作为set进一步放到historyset中,记录分别remove的系统;


public class Main {
    public static void main(String[] args) {
        Set<String> historySet = new HistorySet<>(new HashSet<>());
        Set<String> set = new HistorySet<>(historySet);
        set.add("1");
        set.add("2");
        set.add("3");
        set.add("4");
        set.remove("4");
        set.remove("4");
        set.remove("5");
        set.remove("1");
        System.out.println(set);
    }
}

这时,doubleHistorySet 内部嵌套了两个 HistorySet。每次成功删除一个元素,两个包装器都会记录一次。运行测试后可以看到,两个 removedList 都记录了相同的删除操作。

【2,3】remove list :[4,1] remove list :[4,1]

这说明装饰器可以层层叠加,形成“装饰链”。

我们来看一个 JDK 中的经典例子:Collections.synchronizedCollection(Collection<T> c)。它接收一个 Collection,返回一个线程安全的包装版本。

Collection objects =Collections.synchronizedCollection(new ArrayList<>());

点进去看源码,它返回的是 SynchronizedCollection,这个类实现了 Collection,内部持有一个 Collection 对象(称为 c),所有方法都委托给 c 执行,比如:

public int size() {  
    synchronized (mutex) { return c.size(); }  
}  
public boolean isEmpty() {  
    synchronized (mutex) { return c.isEmpty(); }  
}  

唯一的区别是,每个方法都加了 synchronized 锁,锁的是一个互斥对象 mutex(通常是 this)。这样就实现了线程安全。

但要注意:单个方法是线程安全的,但多个方法的组合操作不是原子的。比如:

if (collection.isEmpty()) {  
    collection.add(item); // 这不是原子操作!  
}  

在多线程环境下,这个判断加添加的操作仍然可能出错,需要额外同步。

这是装饰器模式的又一应用:在不修改原始类的情况下,增加同步控制。

再看第三个例子:有一个 20MB 的 PDF 文件,我们需要一个字节一个字节地读取。我们使用 FileInputStream


public class Main {
    public static void main(String[] args) {
        File file = new File("jvm.pdf");
        long l = Instant.now().toEpochMilli();
        try(FileInputStream fileInputStream = new FileInputStream(file)){
            while (true){
                int read = fileInputStream.read();
                if(read == -1){
                    break;
                }
            }
            System.out.println("用时:" + (Instant.now().toEpochMilli() - l) + "毫秒");
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

测试发现耗时约 8 秒。为什么这么慢?因为 read() 每次都是一次 I/O 操作,频繁的小数据读取效率极低。

我们思考:能不能加一个缓冲区?如果能从内存缓冲区读数据,就避免了频繁的 I/O。

于是我们创建一个类:BufferedFileInputStream,它也继承 InputStream,但内部包装了一个 FileInputStream:构造函数传递一下;


public class BufferedFileInputStream extends InputStream {

    private final byte[] buffer = new byte[8192];

    private final FileInputStream fileInputStream;

    public BufferedFileInputStream(FileInputStream fileInputStream){
        this.fileInputStream = fileInputStream;
    }

    @Override
    public int read() throws IOException {
        return 0;
    }

    @Override
    public void close() throws IOException {
        super.close();
    }
}

重写 read() 方法:


@Override
public int read() throws IOException {
    if(buffCanRead()){
        return readFromBuffer();
    }
    refreshBuffer();
    if(!buffCanRead()){
        return -1;
    }

    return readFromBuffer();
}

其中:

  • canReadFromBuffer() 判断是否还能从缓冲区读取:position < capacity
  • refreshBuffer() 调用 fis.read(buffer),将数据读入缓冲区,更新 capacityposition = 0
  • readFromBuffer() 返回 buffer[position++] & 0xFF(转为无符号 int)

public class Main {
    public static void main(String[] args) {
        File file = new File("jvm.pdf");
        long l = Instant.now().toEpochMilli();
        try (InputStream fileInputStream = new BufferedFileInputStream(new FileInputStream(file))) {
            while (true) {
                int read = fileInputStream.read();
                if (read == -1) {
                    break;
                }
            }
            System.out.println("用时:" + (Instant.now().toEpochMilli() - l) + "毫秒");
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

public class BufferedFileInputStream extends InputStream {

    private final byte[] buffer = new byte[8192];

    private int position = -1;

    private int capacity = -1;

    private final FileInputStream fileInputStream;

    public BufferedFileInputStream(FileInputStream fileInputStream) {
        this.fileInputStream = fileInputStream;
    }

    @Override
    public int read() throws IOException {
        if (buffCanRead()) {
            return readFromBuffer();
        }
        refreshBuffer();
        if (!buffCanRead()) {
            return -1;
        }
        return readFromBuffer();
    }

    private int readFromBuffer() {
        return buffer[position++] & 0xFF;
    }

    private void refreshBuffer() throws IOException {
        capacity = this.fileInputStream.read(buffer);
        position = 0;
    }
}

为了实现这几个函数,我们先思考一个问题,Read from buffer,我们从这个buffer中拿到一个字节,那么我们应该拿取哪个位置的字节呢,于是我们可以用一个标记位去标记position,最开始可以是零,标记着我们现在读到了这个buffer的什么位置,那么第二个问题,假如我们现在有一个8192长度的buffer,但是我们的FileInputStream整个字节流都没有8192,那么我们这个buffer是填不满的,所以说我们不能直接把整个buffer一个一个地读取完毕,那么我们应该用一个容量capacity,我们初始化也是零,去标记着我们这个buffer到底有效读取长度是多少,而不能是一次性无脑把8192全都读完,那么我们可以看一下这个buffer的read方法,OK,那么现在我们来思考bufferCanRead,什么时候这个buffer是能读的呢,首先在初始化的时候,这个position和capacity都是零,那肯定是不能读取的,所以当capacity等于零的时候肯定是不能读的,返回false,OK,那如果capacity不是零,假如说现在的capacity是8192,我们的整个Buffer是填满的,那么当position走到了capacity的时候,那说明我们已经读到了最后一个字节,那么我们也不能再读了,所以position等于capacity的时候也是不能读的,也返回false,否则就返回true,返回true的时候说明什么呢,说明position在capacity之前,并且capacity大于position,说明我们的buffer是可以读的,好的,现在我们来填充第二个函数,refreshBuffer,首先我们调用refreshBuffer的时候,bufferCanRead一定是返回false的,所以我们可以直接忽略当前position和capacity的具体值,我们直接用内部的FileInputStream去读取,读取到这个buffer的字节数组中,返回的是一个length,表示这次读取真正读取了多少字节,那么我们可以直接用capacity等于这个数字,如果capacity等于零,那么bufferCanRead就会返回false,表示没有可读数据,如果它真正读取出了长度,那么读取出来是多少,我们的capacity就设为多少,这里有一个IOException,我们直接抛出去,因为我们的read方法也要抛出这个异常,交给上层处理就可以了,当我们修改了capacity的长度之后,我们要把position变成零,因为我们要重置这个position,让它从零开始读取,好的,现在我们来填充最后一个函数,readFromBuffer,这个函数就很简单了,直接return buffer[position],但是position在读取之后要自增一,所以是buffer[position++],OK,这里还有一个细节是buffer是一个字节数组,这个字节默认是有符号的,可能为负数,但是我们的返回值要求是0~255,所以我们要与上0xFF,也就是(byte & 0xFF),去掉符号位的影响,让其转换为无符号整数,这样的话,我们的整个BufferedFileInputStream就写完了,其实整个流程很简单,我们只是包装了一下这个FileInputStream,然后在它读取的时候,我们进行了一些buffer的拦截,如果buffer里面可以读的话,我们就从buffer里面读,如果buffer不可以读的话,我们就调用refreshBuffer去填充buffer,这样的话我们就极大地减少了真正的IO操作次数,那么我们来试一下,我们修改之后的这个BufferedFileInputStream,它到底有多快,我们之前用了八秒来读取整个文件,那么我们把它换成BufferedFileInputStream,然后在里面封装一个FileInputStream,把这个file传进去,我们整个是一个InputStream类,其他函数一行都不用变。

这样,我们用 BufferedFileInputStream 包装 FileInputStream,就能大幅提升性能。再次运行测试,时间从 8 秒降到 46ms,性能提升两个数量级。

我们还可以验证正确性:同时用原始 FileInputStream 和包装后的流读取,对比每次读取的字节是否一致。如果程序正常结束,说明我们的 BufferedFileInputStream 行为正确。

这就是为什么我们说 Java I/O 体系是装饰器模式的经典应用:BufferedInputStreamDataInputStreamObjectInputStream 等都可以层层包装 InputStream,每个装饰器只负责自己的功能(缓冲、数据解析、对象序列化等),而原始流只负责读取字节。

最后留一个思考题:你能否在 InputStream 的基础上再装饰一层,用来记录 read() 方法被调用了多少次?下期我将用装饰器模式实战一个综合案例。

如果你想实现读取行数,我的建议是不要直接在BufferedFileInputStream直接写代码。 而是自己再定义一个CountFileInputStream 然后把BufferedFileInputStream传进来,这样能体现装饰器模式多层装饰的效果

为什么不直接继承: 1. 装饰器的功能可能依赖被装饰对象的特性,比如视频中的例子,需要让set有序,那只能包装TreeSet,HashSet满足不了需求。 2. 如果被装饰的对象是final就没办法继承 3. Java只能单一继承,如果你需要继承其他类那就做不到了。 4. 装饰器可以多次装饰,层级装饰,你可以发挥想象力各种封装组合,但是继承不可以。 5. 组合永远优于继承

实战部分

这是一个简单的 Spring Boot 工程,项目中只有一个依赖:spring-boot-starter-web,版本是 3.4.1。工程里有一个启动类、一个简单的 Controller,Controller 中只有一个 POST 请求接口,接收请求体(request body)的 JSON 数据,并原样返回。

代码非常简单:


/**
 * @author gongxuanzhangmelt@gmail.com
 **/
@RestController
@RequestMapping("/api")
public class MyController {

    @PostMapping
    public Map<String, Object> origin(@RequestBody Map<String, Object> json) {
        return json;
    }
}

@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class);
    }
}

我们启动项目,用工具发送一个请求,传入 {"name": "tom", "age": 12},返回结果也是 {"name": "tom", "age": 12},功能正常。

现在我们有一个新需求:希望在不修改原有逻辑的前提下,自动为所有通过 @RequestBody 接收的 Map 类型数据添加一个时间戳字段,比如返回结果自动变成:

{  
  "name": "tom",  
  "age": 12,  
  "timestamp": 1712345678901  
}  

也就是说,我们想通过一个自定义注解,比如 @TimestampRequestBody,来实现这个功能。但这一次,我们不直接查文档或问 AI,而是通过打断点、读源码、一步步调试的方式来实现。

我们先在 test 方法的参数 data 上打一个断点,看看这个 Map 是什么时候创建的。进入调试后,调用栈显示前面有很多反射相关的调用。我们从最开始的方法看起:

当前方法的参数 data 是一个 LinkedHashMap,内容就是我们传入的 JSON。我们向上查看这个参数值是从哪里来的,发现它来自 getMethodArgumentValues() 方法。

进入该方法,它会根据 Controller 方法的参数个数创建一个 args Object[] 数组,用于存放每个参数的值。我们的方法只有一个参数,所以数组长度为 1。

接下来关键逻辑是:遍历所有 HandlerMethodArgumentResolver(处理器方法参数解析器),判断哪一个支持当前参数,然后调用其 resolveArgument() 方法来解析并返回参数值。https://i-blog.csdnimg.cn/direct/7755fa282acf4c4d8bb8eb233aa0b026.png

我们继续调试,发现最终是某个 HandlerMethodArgumentResolverresolveArgument 方法返回了那个 LinkedHashMap。我们查看这个解析器的类型,发现是 RequestResponseBodyMethodProcessor

我们点进去看这个类,它实现了 HandlerMethodArgumentResolver 接口,该接口有两个方法:

  • supportsParameter(MethodParameter parameter):判断是否支持该参数;
  • resolveArgument(...):真正解析参数并返回值。

我们看 supportsParameter 的实现,只有一行代码:判断参数是否标注了 @RequestBody 注解。所以只要参数加了 @RequestBody,Spring 就会选择这个解析器来处理。

resolveArgument 方法,正是真正将 JSON 反序列化为 Map 的地方。我们不需要关心它内部如何用 Jackson 解析,我们只关心:它返回了我们想要的 Map


现在我们想:能不能“装饰”这个 RequestResponseBodyMethodProcessor?让它先完成原本的解析功能,然后我们再对结果进行增强——比如往 Map 里 put 一个时间戳。

这就是装饰器模式的思想:不改变原有逻辑,只在原有功能基础上增强。

我们新建一个类:TimestampRequestBodyMethodProcessor,也实现 HandlerMethodArgumentResolver 接口。

https://i-blog.csdnimg.cn/direct/e27f955bdd7040de8ddfea0bb66d3093.png

我们再定义一个自定义注解:@TimestampRequestBody,仿照 @RequestBody 的写法:

@Target(ElementType.PARAMETER)  
@Retention(RetentionPolicy.RUNTIME)  
@Documented  
public @interface TimestampRequestBody {}  

然后在 TimestampRequestBodyMethodProcessor 中:

@Override  
public boolean supportsParameter(MethodParameter parameter) {  
    return parameter.hasParameterAnnotation(TimestampRequestBody.class);  
}  

接下来是 resolveArgument 方法的实现。我们想:如果我能拿到那个原始的 RequestResponseBodyMethodProcessor,让它先解析出 Map,然后我再往里面加时间戳,就完成了。

所以我们需要一个成员变量,持有被装饰的 RequestResponseBodyMethodProcessor

private final RequestResponseBodyMethodProcessor delegate;

@Override  
public Object resolveArgument(...) throws Exception {  
    Object result = delegate.resolveArgument(parameter, mavContainer, webRequest, binderFactory);  
      
    if (result instanceof Map) {  
        ((Map<String, Object>) result).put("timestamp", System.currentTimeMillis());  
    }  
      
    return result;  
}  

这样,我们就实现了功能增强,而不需要知道原始解析器内部如何工作。


但问题来了:我们有两个问题要解决:

  1. 如何拿到那个 RequestResponseBodyMethodProcessor
  2. 如何把我们自己的 TimestampRequestBodyMethodProcessor 注册到 Spring 的参数解析器列表中?

先解决第二个问题:注册自定义参数解析器。

我们回到调试,查看 HandlerMethodArgumentResolverComposite(参数解析器组合类)的内部,发现它持有一个 List<HandlerMethodArgumentResolver>,里面共有 27 个解析器。我们找到 RequestResponseBodyMethodProcessor 在第 8 个位置。

这个列表是怎么来的?我们继续向上看,发现是在 RequestMappingHandlerAdapter 中通过 getDefaultArgumentResolvers() 创建的。

我们点进去,发现是一大段“屎山代码”:Spring 硬编码 new 出了所有默认的解析器,并放入列表。

但注意到其中有一段:

addCustomResolvers(customArgumentResolvers);  

这说明 Spring 提供了扩展点:我们可以通过配置,将自定义的解析器添加进去。

我们继续调试,发现 customArgumentResolvers 来自 WebMvcConfigurationSupport 类中的一个方法:addArgumentResolvers()

而这个方法是一个空方法,说明它是留给用户重写的钩子方法

再看 WebMvcConfigurationSupport 的子类,发现只有一个:DelegatingWebMvcConfiguration,它会收集所有实现了 WebMvcConfigurer 接口的 bean,并调用它们的 addArgumentResolvers 方法。

所以,我们只需要:

  1. 创建一个配置类,实现 WebMvcConfigurer
  2. 重写 addArgumentResolvers 方法,把我们的 TimestampRequestBodyMethodProcessor 加进去。

代码如下:

@Configuration  
public class MyWebMvcConfig implements WebMvcConfigurer {

    @Override  
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {  
        resolvers.add(new TimestampRequestBodyMethodProcessor(/* 需要传入 delegate */));  
    }  
}  

这样,我们的解析器就会被加入到 Spring 的解析器列表中,共 28 个。


现在解决第一个问题:如何拿到 RequestResponseBodyMethodProcessor

我们不能用 @Autowired 直接注入,因为它不是 Spring 管理的 Bean——它是直接 new 出来的。

但我们可以通过 RequestMappingHandlerAdapter 拿到它。这个 adapter 是 Spring 管理的 Bean,它内部持有所有参数解析器。

所以我们可以在配置类中注入 ApplicationContext,然后在 addArgumentResolvers 中获取 RequestMappingHandlerAdapter,遍历其 argumentResolvers,找到 RequestResponseBodyMethodProcessor,作为 delegate 传入。

但要注意:此时 RequestMappingHandlerAdapter 可能还未初始化,直接获取会有循环依赖。

解决方案:延迟初始化。我们不在构造函数中赋值,而在 resolveArgument 第一次调用时再去容器中查找并设置 delegate。

我们修改 TimestampRequestBodyMethodProcessor

private RequestResponseBodyMethodProcessor delegate;

public void setDelegate(RequestResponseBodyMethodProcessor delegate) {  
    this.delegate = delegate;  
}

private void initDelegate(ApplicationContext context) {  
    if (delegate != null) return;  
      
    RequestMappingHandlerAdapter adapter = context.getBean(RequestMappingHandlerAdapter.class);  
    for (HandlerMethodArgumentResolver resolver : adapter.getArgumentResolvers()) {  
        if (resolver instanceof RequestResponseBodyMethodProcessor) {  
            this.delegate = (RequestResponseBodyMethodProcessor) resolver;  
            return;  
        }  
    }  
}  

然后在 resolveArgument 开头调用 initDelegate


最后,我们在 Controller 中使用我们的注解:

@PostMapping("/test")  
public Map<String, Object> test(@TimestampRequestBody Map<String, Object> data) {  
    return data;  
}  

重启应用,发送请求,返回结果成功包含 timestamp 字段。


我们回顾整个过程:

  • 我们通过调试,找到了 @RequestBody 的解析器是 RequestResponseBodyMethodProcessor
  • 我们用装饰器模式包装它,增强其功能;
  • 我们通过实现 WebMvcConfigurer,将自定义解析器注册到 Spring 中;
  • 我们通过 ApplicationContext 延迟获取被装饰的对象,避免了依赖问题。

整个过程没有修改任何 Spring 内部代码,完全基于扩展机制实现。


思考题:  
WebMvcConfigurerCompositeHandlerMethodArgumentResolverComposite 都是“组合模式”的体现,它们有什么不同?  
前者是将多个 WebMvcConfigurer 的配置方法合并执行,后者是将多个 ArgumentResolver 组合成一个解析链。它们的职责不同:一个是配置聚合,一个是执行链路。

https://i-blog.csdnimg.cn/direct/7267327b4f12412292a07bdc3d7fd871.png