前言
从第一篇工厂模式开始,我会持续地更新每一种设计模式的内容,争取用通俗易懂的语言讲解和解释清楚。如果对你学习设计模式有帮助,请不要吝啬手中的赞~ 如果对文章内容有任何疑惑都可以在评论区提出和讨论~
本系列文章中的完整源码已上传 github 仓库,你可以在这里 github.com/FatMii/Desi...获取。
同样的,如果对你有帮助,请给我一个star~谢谢
设计模式合集链接:
Hello~ 大家好,本篇文章我们继续学习设计模式第七篇:代理模式
一、介绍
代理模式是一种结构型设计模式,其核心是为其他对象提供一种代理,以控制对原对象的访问。简单来说,就是在访问目标对象之前,通过一个 "中间代理人" 来处理一些额外逻辑,再决定是否让访问到达目标对象。这种设计就像生活中的房产中介 ------ 我们不会直接和房东谈交易,而是通过中介完成看房、议价、签合同等流程,中介在这个过程中承担了筛选房源、风险把控等额外工作。
代理模式主要包含以下三个关键部分:
- 抽象主题(Subject) :这是一个通用接口,定义了目标对象和代理对象共有的业务方法,保证代理对象和目标对象在使用上具有一致性,客户端无需区分两者。
- 真实主题(Real Subject) :这是被代理的目标对象,实现了抽象主题接口,包含了具体的业务逻辑,是最终需要被访问的对象。
- 代理(Proxy) :这是代理对象,同样实现了抽象主题接口,持有真实主题的引用。它会接收客户端的请求,先执行一些额外操作(如权限校验、缓存处理、日志记录等),再调用真实主题的方法,完成对目标对象的访问控制。
通过这种设计,代理模式能在不修改目标对象代码的前提下,为其增加额外功能,同时隔离了客户端与目标对象的直接交互,提高了系统的灵活性和安全性。
二、前端场景:请求数据缓存
在前端开发中,请求数据缓存是非常实用的优化手段 ------ 比如用户频繁切换页面查看同一列表(如商品列表、新闻列表),如果每次切换都重新调用接口,会浪费网络资源、增加等待时间。此时用代理模式正好合适:将 "发起接口请求" 作为真实主题,代理对象负责缓存已请求的结果,下次请求相同数据时,直接返回缓存内容,无需重复调用接口。
1. 传统方式:重复请求的冗余代码
如果不使用代理模式,每次需要数据时都会直接调用接口,即使数据没变化也会重复请求,代码冗余且性能差:
js
// 传统数据请求:无缓存,重复调用接口
async function fetchProductList(categoryId) {
try {
// 每次调用都会发起新请求
const response = await fetch(`/api/products?category=${categoryId}`);
const data = await response.json();
console.log(`重新请求分类 ${categoryId} 的商品列表`);
return data;
} catch (error) {
console.error("请求商品列表失败:", error);
return [];
}
}
// 场景1:用户第一次查看手机分类商品
fetchProductList(1).then(data => console.log("手机列表:", data));
// 输出:重新请求分类 1 的商品列表 → 手机列表:[数据]
// 场景2:用户切换到电脑分类后,再次切回手机分类
setTimeout(() => {
fetchProductList(1).then(data => console.log("手机列表:", data));
// 输出:重新请求分类 1 的商品列表 → 手机列表:[相同数据](重复请求,浪费资源)
}, 2000);
这种方式的问题很明显:相同参数的请求会重复发起,不仅增加服务器压力,还会让用户等待额外的网络耗时。如果要加缓存,只能在请求函数内部嵌入缓存逻辑,导致业务代码和缓存逻辑耦合。
2. 代理模式:缓存与请求分离的优雅实现
用代理模式改造后,我们将 "发起接口请求" 作为真实主题,"缓存数据" 作为代理逻辑,两者职责分离 ------ 代理先判断缓存中是否有数据,有则直接返回,没有再调用真实接口,后续维护更灵活。
定义抽象主题(Subject)
抽象主题接口定义 "获取数据" 的通用方法,保证代理和真实主题的调用方式一致:
js
// 抽象主题:数据请求接口
class DataFetcher {
async fetch(params) {
throw new Error("数据请求方法未实现!");
}
}
实现真实主题(Real Subject)
真实主题只负责核心的接口请求逻辑,不关心缓存、日志等额外操作:
js
// 真实主题:核心接口请求逻辑
class RealDataFetcher extends DataFetcher {
async fetch(params) {
// params 为请求参数(如分类ID、页码等),这里以商品分类为例
const { categoryId } = params;
if (!categoryId) throw new Error("分类ID不能为空!");
// 发起真实接口请求
const response = await fetch(`/api/products?category=${categoryId}`);
if (!response.ok) throw new Error("接口请求失败");
const data = await response.json();
console.log(`接口请求成功:分类 ${categoryId} 的商品列表`);
return data;
}
}
实现代理(Proxy)
代理对象负责管理缓存,请求数据前先检查缓存:有缓存则直接返回,无缓存则调用真实主题的请求方法,并将结果存入缓存:
js
// 代理:数据缓存代理(处理缓存逻辑,避免重复请求)
class CacheProxy extends DataFetcher {
constructor() {
super();
// 持有真实主题的引用
this.realFetcher = new RealDataFetcher();
// 缓存容器:key 为参数序列化字符串,value 为缓存的 data
this.cache = new Map();
// 可选:设置缓存过期时间(如5分钟,单位ms)
this.cacheExpireTime = 5 * 60 * 1000;
}
// 辅助方法:将参数转为字符串(作为缓存的key)
getCacheKey(params) {
// 序列化参数,确保相同参数生成相同key(如 {categoryId:1} → "categoryId=1")
return Object.entries(params)
.sort(([a], [b]) => a.localeCompare(b)) // 排序参数,避免顺序影响key
.map(([key, value]) => `${key}=${value}`)
.join("&");
}
// 辅助方法:检查缓存是否有效(存在且未过期)
isCacheValid(key) {
if (!this.cache.has(key)) return false;
const { expireTime } = this.cache.get(key);
// 比较当前时间与过期时间
return Date.now() < expireTime;
}
async fetch(params) {
const cacheKey = this.getCacheKey(params);
// 1. 先检查缓存:有效则直接返回缓存数据
if (this.isCacheValid(cacheKey)) {
const { data } = this.cache.get(cacheKey);
console.log(`使用缓存:分类 ${params.categoryId} 的商品列表`);
return data;
}
// 2. 无有效缓存:调用真实主题的请求方法
const data = await this.realFetcher.fetch(params);
// 3. 将请求结果存入缓存(记录过期时间)
this.cache.set(cacheKey, {
data,
expireTime: Date.now() + this.cacheExpireTime // 计算过期时间
});
return data;
}
// 可选:手动清除缓存(如数据更新后需要刷新)
clearCache(params) {
const cacheKey = this.getCacheKey(params);
this.cache.delete(cacheKey);
console.log(`缓存已清除:分类 ${params.categoryId} 的商品列表`);
}
}
使用代理模式
客户端只需调用代理对象的 fetch 方法,无需关心缓存逻辑,使用方式和直接调用真实接口完全一致:
js
// 1. 初始化缓存代理
const dataProxy = new CacheProxy();
// 2. 第一次请求手机分类(无缓存,调用接口)
dataProxy.fetch({ categoryId: 1 }).then(data => {
console.log("页面渲染手机列表:", data);
// 输出:接口请求成功:分类 1 的商品列表 → 页面渲染手机列表:[数据]
});
// 3. 2秒后再次请求手机分类(有缓存,直接返回)
setTimeout(async () => {
const data = await dataProxy.fetch({ categoryId: 1 });
console.log("页面再次渲染手机列表:", data);
// 输出:使用缓存:分类 1 的商品列表 → 页面再次渲染手机列表:[相同数据]
}, 2000);
// 4. 5分钟后缓存过期(模拟),再次请求会重新调用接口
setTimeout(async () => {
// 手动修改缓存过期时间,模拟过期
dataProxy.cache.set(
dataProxy.getCacheKey({ categoryId: 1 }),
{ ...dataProxy.cache.get("categoryId=1"), expireTime: Date.now() - 1000 }
);
const data = await dataProxy.fetch({ categoryId: 1 });
console.log("缓存过期后渲染手机列表:", data);
// 输出:接口请求成功:分类 1 的商品列表 → 缓存过期后渲染手机列表:[数据]
}, 3000);
// 5. 当商品数据更新后,手动清除缓存(如新增商品后)
setTimeout(() => {
dataProxy.clearCache({ categoryId: 1 });
// 输出:缓存已清除:分类 1 的商品列表
}, 4000);
改造后的优势非常明显:如果后续需要修改缓存规则(如延长过期时间、改用 localStorage 持久化缓存),只需修改 CacheProxy;如果需要调整接口请求逻辑(如添加请求头、处理特殊错误),只需修改 RealDataFetcher,两者互不干扰,可维护性大幅提升。
三、后端场景:接口权限控制
在后端开发中,接口权限控制是常见需求 ------ 不同角色的用户(如普通用户、管理员、游客)对接口的访问权限不同,例如 "删除用户" 接口只有管理员能访问,普通用户访问时需拒绝。用代理模式实现时,真实主题负责处理接口的核心业务逻辑,代理对象则负责权限校验,只有通过校验才允许访问真实接口。
1. 传统方式:混乱的权限判断
传统实现中,权限校验逻辑会嵌入到接口业务逻辑中,导致代码混乱,每个接口都要重复写权限判断:
java
// 传统接口权限控制:权限逻辑与业务逻辑耦合
@RestController
@RequestMapping("/api/users")
public class UserController {
// 模拟用户角色判断
private boolean isAdmin(String userId) {
// 实际场景中从数据库或缓存获取用户角色
return "admin123".equals(userId);
}
// 删除用户接口
@DeleteMapping("/{id}")
public String deleteUser(@PathVariable String id, @RequestParam String userId) {
// 权限校验逻辑(嵌入业务代码)
if (!isAdmin(userId)) {
return "权限不足:仅管理员可删除用户";
}
// 核心业务逻辑(删除用户)
System.out.println("删除用户:" + id);
return "用户删除成功";
}
// 编辑用户接口
@PutMapping("/{id}")
public String editUser(@PathVariable String id, @RequestParam String userId) {
// 重复的权限校验逻辑
if (!isAdmin(userId)) {
return "权限不足:仅管理员可编辑用户";
}
// 核心业务逻辑(编辑用户)
System.out.println("编辑用户:" + id);
return "用户编辑成功";
}
}
这种方式的问题很突出:每个需要权限控制的接口都要重复写 isAdmin 判断,代码冗余;如果权限规则变化(如新增 "超级管理员" 角色),需要修改所有接口的校验逻辑,维护成本极高。
2. 代理模式改造
用代理模式改造后,我们将 "权限校验" 作为代理逻辑,"接口业务处理" 作为真实主题,两者职责分离:
定义抽象主题(Subject)
抽象主题接口定义接口的业务方法,这里以用户管理接口为例:
java
// 抽象主题:用户管理接口
public interface UserService {
// 删除用户
String deleteUser(String userId, String operatorId);
// 编辑用户
String editUser(String userId, String operatorId);
}
实现真实主题(Real Subject)
真实主题只负责核心业务逻辑,不包含任何权限判断:
java
// 真实主题:用户管理核心业务逻辑
@Service
public class RealUserService implements UserService {
@Override
public String deleteUser(String userId, String operatorId) {
// 仅处理核心业务:删除用户(无需关心权限)
System.out.println("操作员 " + operatorId + " 删除用户:" + userId);
return "用户删除成功";
}
@Override
public String editUser(String userId, String operatorId) {
// 仅处理核心业务:编辑用户
System.out.println("操作员 " + operatorId + " 编辑用户:" + userId);
return "用户编辑成功";
}
}
实现代理(Proxy)
代理对象负责权限校验,持有真实主题的引用,只有通过校验才调用真实主题的方法:
java
// 代理:权限控制代理
public class AuthProxy implements UserService {
// 持有真实主题的引用(通过构造注入)
private final UserService realUserService;
public AuthProxy(UserService realUserService) {
this.realUserService = realUserService;
}
// 权限校验逻辑:判断操作员是否为管理员
private boolean checkAdminPermission(String operatorId) {
// 实际场景中从数据库/缓存获取角色,这里简化实现
return "admin123".equals(operatorId) || "superAdmin456".equals(operatorId);
}
@Override
public String deleteUser(String userId, String operatorId) {
// 1. 先执行权限校验
if (!checkAdminPermission(operatorId)) {
return "权限不足:仅管理员/超级管理员可删除用户";
}
// 2. 校验通过,调用真实主题的业务方法
return realUserService.deleteUser(userId, operatorId);
}
@Override
public String editUser(String userId, String operatorId) {
// 1. 权限校验
if (!checkAdminPermission(operatorId)) {
return "权限不足:仅管理员/超级管理员可编辑用户";
}
// 2. 调用真实业务逻辑
return realUserService.editUser(userId, operatorId);
}
}
配置与使用代理模式
在 Spring 框架中,通过配置类将代理对象注入容器,客户端(如 Controller)直接使用代理对象,无需感知真实主题:
java
// 配置类:注入代理对象
@Configuration
public class UserConfig {
@Bean
public UserService realUserService() {
return new RealUserService();
}
@Bean
public UserService userServiceProxy() {
// 将真实主题注入代理
return new AuthProxy(realUserService());
}
}
// Controller:使用代理对象
@RestController
@RequestMapping("/api/users")
public class UserController {
// 注入的是代理对象,而非真实主题
private final UserService userService;
// 构造注入代理对象
public UserController(UserService userService) {
this.userService = userService;
}
@DeleteMapping("/{id}")
public String deleteUser(@PathVariable String id, @RequestParam String operatorId) {
// 直接调用代理对象的方法,权限校验由代理自动处理
return userService.deleteUser(id, operatorId);
}
@PutMapping("/{id}")
public String editUser(@PathVariable String id, @RequestParam String operatorId) {
return userService.editUser(id, operatorId);
}
}
实际业务扩展:灵活调整权限规则
如果后续权限规则变化(如新增 "编辑员" 角色可编辑用户),只需修改代理对象的 checkAdminPermission 方法,无需修改真实业务逻辑:
java
// 修改代理的权限校验逻辑
private boolean checkEditPermission(String operatorId) {
// 新增"编辑员"角色权限
List<String> allowedRoles = Arrays.asList("admin123", "superAdmin456", "editor789");
return allowedRoles.contains(operatorId);
}
@Override
public String editUser(String userId, String operatorId) {
// 编辑接口使用新的权限规则
if (!checkEditPermission(operatorId)) {
return "权限不足:仅管理员/超级管理员/编辑员可编辑用户";
}
return realUserService.editUser(userId, operatorId);
}
这种方式实现了权限逻辑与业务逻辑的完全解耦,扩展性极强。
四、Vue 源码中的代理模式应用
Vue 框架中多处使用了代理模式,其中最核心的是 Vue 2 的响应式系统(Object.defineProperty 代理) 和 Vue 3 的响应式系统(Proxy API 代理) ,两者均通过代理模式控制对数据的访问,实现 "数据变化自动更新视图" 的核心功能。
1. Vue 2 中的 Object.defineProperty 代理
Vue 2 为了实现响应式,会通过 Object.defineProperty 对组件的 data 中的每个属性进行 "代理"------ 当访问或修改数据时,代理会触发额外的逻辑(依赖收集、视图更新),而开发者无需感知这个代理过程,直接操作数据即可。
核心原理代码简化版
js
// Vue 2 响应式代理核心逻辑(简化版)
function observe(data) {
if (typeof data !== 'object' || data === null) {
return;
}
// 遍历data的所有属性,为每个属性添加代理
Object.keys(data).forEach(key => {
let value = data[key];
// 递归观察子属性(如对象嵌套)
observe(value);
// 代理:通过Object.defineProperty控制对data属性的访问
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
// 访问属性时触发(getter):收集依赖
get() {
console.log(`收集依赖:访问了data.${key}`);
// 实际Vue中会在这里将当前Watcher添加到依赖列表
Dep.target && dep.addSub(Dep.target);
return value;
},
// 修改属性时触发(setter):触发更新
set(newValue) {
if (newValue === value) return;
console.log(`触发更新:修改了data.${key} = ${newValue}`);
value = newValue;
// 递归观察新值(如新值是对象)
observe(newValue);
// 实际Vue中会在这里通知所有依赖的Watcher更新视图
dep.notify();
}
});
});
}
// 模拟组件data
const data = { name: 'Vue 2', version: '2.6.14' };
// 为data添加代理
observe(data);
// 开发者直接操作data,代理自动处理依赖收集和更新
console.log(data.name); // 触发getter:收集依赖
data.version = '2.7.0'; // 触发setter:触发更新
代理角色分析
- 抽象主题(Subject) :data 对象的属性访问 / 修改行为(隐含接口)。
- 真实主题(Real Subject) :data 中的原始属性(如 name、version)。
- 代理(Proxy) :Object.defineProperty 定义的 getter 和 setter------ 开发者访问 / 修改 data 属性时,实际触发的是代理的 get/set 方法,代理在其中完成依赖收集、视图更新等额外逻辑,再操作原始属性值。
2. Vue 3 中的 Proxy API 代理
Vue 3 改用 ES6 的 Proxy API 实现响应式,相比 Vue 2 的 Object.defineProperty,Proxy 能直接代理整个对象(而非单个属性),支持数组索引修改、新增属性等场景,代理能力更强大。
核心原理代码简化版
js
// Vue 3 响应式代理核心逻辑(简化版)
function reactive(target) {
// 仅代理对象/数组(基本类型直接返回)
if (typeof target !== 'object' || target === null) {
return target;
}
// 创建代理对象:拦截对target的所有操作
const proxy = new Proxy(target, {
// 访问属性时触发(get):收集依赖
get(target, key, receiver) {
console.log(`收集依赖:访问了target.${key}`);
// 实际Vue中会在这里收集依赖(Track)
track(target, key);
// 递归代理子属性(如对象嵌套)
const value = Reflect.get(target, key, receiver);
return typeof value === 'object' ? reactive(value) : value;
},
// 修改/新增属性时触发(set):触发更新
set(target, key, value, receiver) {
console.log(`触发更新:修改了target.${key} = ${value}`);
// 实际Vue中会在这里触发更新(Trigger)
trigger(target, key);
// 用Reflect保证this指向正确
return Reflect.set(target, key, value, receiver);
},
// 删除属性时触发(deleteProperty):触发更新
deleteProperty(target, key) {
console.log(`触发更新:删除了target.${key}`);
trigger(target, key);
return Reflect.deleteProperty(target, key);
}
});
return proxy;
}
// 模拟组件data
const target = { name: 'Vue 3', version: '3.4.0' };
// 创建代理对象
const reactiveData = reactive(target);
// 开发者操作代理对象,代理自动处理依赖和更新
console.log(reactiveData.name); // 触发get:收集依赖
reactiveData.version = '3.4.1'; // 触发set:触发更新
delete reactiveData.version; // 触发deleteProperty:触发更新
代理角色分析
- 抽象主题(Subject) :target 对象的属性访问、修改、删除等行为(隐含接口)。
- 真实主题(Real Subject) :原始的 target 对象。
- 代理(Proxy) :new Proxy(target, handler) 创建的代理对象 reactiveData------ 开发者所有对 reactiveData 的操作(访问、修改、删除属性)都会被代理拦截,代理先执行依赖收集、触发更新等逻辑,再通过 Reflect 操作原始 target 对象,实现对目标对象的访问控制。
总结:代理模式的核心价值
代理模式的核心价值在于 "控制访问 " 和 "职责分离":通过代理对象,我们可以在不修改目标对象的前提下,灵活添加额外逻辑(如权限校验、缓存、日志、性能优化等),同时将这些非核心逻辑与目标对象的业务逻辑解耦,让代码更清晰、更易维护。