配套代码:GitHub 仓库 · 本篇完整源码 · 行为测试。使用 JDK 25 与 Gradle,包名为 com.hanserwei.patterns.proxy

系列导航:Java 25 设计模式学习指南

上一篇:享元 · 下一篇:责任链

文章标题来自一个读取成本较高的数据源。同一页面会重复读取相同文章,如果调用方自己维护缓存,缓存逻辑就会扩散到每个业务入口。

缓存代理实现和数据源相同的接口,调用方仍执行 find(id)。代理决定本次是否需要真正访问数据源,并在成功后保存结果。

从问题提炼设计意图

代理为另一个对象提供代表,通过相同或兼容接口控制对真实对象的访问。本章用缓存代理展示访问控制,属于手写静态代理。

对象职责与协作关系

示例角色 职责
ArticleSource 客户端、代理与真实对象共享的契约
MemoryArticleSource 真实主题替身,记录读取次数
CachingArticleProxy 代理,缓存成功结果并提供失效操作
Demo 通过 ArticleSource 接口访问
classDiagram
    ArticleSource <|.. MemoryArticleSource
    ArticleSource <|.. CachingArticleProxy
    CachingArticleProxy --> ArticleSource : delegates on miss
    Demo ..> ArticleSource : reads

代码思路:变化应该落在哪个对象上

真实数据源保存初始 Map 的不可变快照,并在每次 find 时增加读取次数。这个计数让“代理是否真的省掉访问”成为可测试事实,而不必依赖日志猜测。

CachingArticleProxy 持有 ArticleSource 而非具体 MemoryArticleSource。命中时直接返回缓存,未命中时调用 source.find。computeIfAbsent 的映射函数抛出异常时不会建立这次映射,因此不存在的文章不会被缓存为成功值。

invalidate 显式移除某个条目。调用方若需要管理缓存生命周期,可以持有具体代理或独立管理接口;普通读取业务则只需要 ArticleSource。这两种使用角色应该在装配处区分。

关键实现与独立运行

配套仓库中的包名是 com.hanserwei.patterns.proxy,源码目录为 src/main/java/com/hanserwei/patterns/proxy/。仓库地址统一见系列导航。以下展示关键文件的完整内容;其余角色和测试在同一仓库中,每个顶级类型各占一个文件。

ArticleSource.java

package com.hanserwei.patterns.proxy;

/** 客户端和代理共同依赖的文章读取接口. */
public interface ArticleSource {
  /** 按标识读取标题;不存在时抛出 NoSuchElementException. */
  String find(long id);
}

CachingArticleProxy.java

package com.hanserwei.patterns.proxy;

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

/** 以相同接口控制对真实数据源的访问;仅缓存成功结果. */
public final class CachingArticleProxy implements ArticleSource {
  /** 被代理的文章数据源. */
  private final ArticleSource source;

  /** 当前代理实例的成功读取缓存. */
  private final Map<Long, String> cache = new HashMap<>();

  /** 注入真实数据源. */
  public CachingArticleProxy(ArticleSource source) {
    this.source = Objects.requireNonNull(source, "source");
  }

  /** 命中时跳过真实调用,失败时不写入缓存. */
  @Override
  public String find(long id) {
    return cache.computeIfAbsent(id, source::find);
  }

  /** 使指定文章的缓存失效,下次访问重新加载. */
  public void invalidate(long id) {
    cache.remove(id);
  }
}

MemoryArticleSource.java

package com.hanserwei.patterns.proxy;

import java.util.Map;
import java.util.NoSuchElementException;

/** 带读取计数的真实数据源替身. */
public final class MemoryArticleSource implements ArticleSource {
  /** 不可变文章数据快照. */
  private final Map<Long, String> titles;

  /** 对真实数据源的累计读取次数. */
  private int reads;

  /** 复制初始数据,避免调用者绕过数据源改写内容. */
  public MemoryArticleSource(Map<Long, String> titles) {
    this.titles = Map.copyOf(titles);
  }

  /** 记录访问并读取标题,不存在时明确失败. */
  @Override
  public String find(long id) {
    reads++;
    String title = titles.get(id);
    if (title == null) {
      throw new NoSuchElementException("Article: " + id);
    }
    return title;
  }

  /** 返回真实数据源的访问次数. */
  public int reads() {
    return reads;
  }
}

Demo.java 展示调用方如何装配这些对象:

package com.hanserwei.patterns.proxy;

import java.util.Map;

/** 演示本章对象的装配方式和可观察结果. */
public final class Demo {
  /** 禁止实例化演示入口. */
  private Demo() {}

  /** 运行独立示例;args 为未使用的命令行参数. */
  public static void main(String[] args) {
    MemoryArticleSource source = new MemoryArticleSource(Map.of(1L, "Java 25"));
    ArticleSource proxy = new CachingArticleProxy(source);
    System.out.println(proxy.find(1));
    System.out.println(proxy.find(1) + " reads=" + source.reads());
  }
}

在配套代码仓库根目录运行;Windows 使用 gradlew.bat 替换 ./gradlew

./gradlew runProxy
./gradlew test --tests 'com.hanserwei.patterns.proxy.PatternTest'

示例的业务输出如下,省略 Gradle 自身的任务提示:

Java 25
Java 25 reads=1

用测试确认模式的行为

连续读取同一标识只触发一次真实访问;失效后再访问会重新读取;缺失文章连续读取两次都抛异常且真实读取计数增加两次。

对应测试位于 src/test/java/com/hanserwei/patterns/proxy/PatternTest.java。建议先运行现有测试,再改动一个协作环节,观察哪个断言能够发现问题。

常见用法

  • 远程代理封装跨进程访问,使业务代码使用本地接口。
  • 虚拟代理推迟昂贵对象的加载或创建。
  • 保护代理校验访问条件,缓存代理减少重复读取。

适用边界与容易踩的坑

缓存会改变时间上的可见性。真实数据源发生更新后,代理可能继续返回旧值;本例提供手动失效,但没有 TTL、更新订阅或容量限制。把它用于长期服务前,必须定义一致性与淘汰规则。

失败语义需要保留。若真实读取抛出不存在异常,代理也应遵守接口契约;不能为了“容错”返回空字符串并让调用方把缺失文章当成有效文章。是否做负缓存是另外一种明确的策略。

本例使用 HashMap,面向单线程。并发版本不仅要替换容器,还需考虑同一键同时未命中时的重复加载、加载失败和失效交错。动态代理与字节码代理是实现手段,不是另一个设计意图。

与相近模式比较

装饰器强调由调用方组合新能力;代理强调代表真实对象并控制访问。缓存看起来也是增强,但本例测试的核心是何时绕过或进入真实数据源。

动手练习

给真实数据源增加更新操作,先写一个展示旧缓存值的测试,再选择显式失效或 TTL 策略修复。使用可注入时钟测试 TTL,避免依赖 Thread.sleep 和机器时序。

系列导航:Java 25 设计模式学习指南

上一篇:享元 · 下一篇:责任链