14|代理:在同一接口前控制真实访问
配套代码: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 设计模式学习指南