cms项目细节-aop
AOP是什么
假设你有很多接口方法,每个方法都要做同样的事:检查权限、打印耗时、开启事务。
没有 AOP 的写法:
public CommonResponse<Object> method1() {
// 业务逻辑
xxService.method1();
// 省略具体的业务处理逻辑
// 日志记录
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
// 省略记录日志的具体逻辑 如:获取各种信息,写入数据库等操作...
return CommonResponse.success();
}
public CommonResponse<Object> method2() {
// 业务逻辑
xxService.method2();
// 省略具体的业务处理逻辑
// 日志记录
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
// 省略记录日志的具体逻辑 如:获取各种信息,写入数据库等操作...
return CommonResponse.success();
}
// ...方法在方法中被多次调用,改一次权限规则需要改几十个方法,少改一个就会出现漏洞。
AOP的思路:把这些和业务无关的的横切逻辑剥离出来,放到一个统一的地方,让业务方法只写业务
// 日志注解
@Target({ElementType.PARAMETER,ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Log {
/**
* 描述
*/
String description() default "";
/**
* 方法类型 INSERT DELETE UPDATE OTHER
*/
MethodType methodType() default MethodType.OTHER;
}
// 日志切面
@Component
@Aspect
public class LogAspect {
// 切入点,所有被 Log 注解标注的方法
@Pointcut("@annotation(cn.javaguide.annotation.Log)")
public void webLog() {
}
/**
* 环绕通知
*/
@Around("webLog()")
public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
// 省略具体的处理逻辑
}
// 省略其他代码
}AOP(Aspect Oriented Programming,面向切面编程)就是“把横切关注点从业务代码里抽出来,统一织入到目标方法前后”。
这样的话,我们一行注解即可实现日志记录:
@Log(description = "method1",methodType = MethodType.INSERT)
public CommonResponse<Object> method1() {
// 业务逻辑
xxService.method1();
// 省略具体的业务处理逻辑
return CommonResponse.success();
}写法
引入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>springboot自动开启aop,普通spring项目需要写
@Configuration
@EnableAspectJAutoProxy
public class AopConfig {}定义methodtype枚举
public enum MethodType {
INSERT, // 新增
DELETE, // 删除
UPDATE, // 修改
OTHER // 其他
}定义@Log注释
@Target({ElementType.PARAMETER, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Log {
/** 描述:这个操作是干什么的 */
String description() default "";
/** 方法类型:INSERT / DELETE / UPDATE / OTHER */
MethodType methodType() default MethodType.OTHER;
}三个元注解的作用:
| 注解 | 作用 |
|---|---|
@Target({PARAMETER, METHOD}) | 允许标在参数和方法上 |
@Retention(RUNTIME) | 运行时还能反射读到,AOP 靠它拿注解值 |
@Documented | 让注解出现在 Javadoc 里 |
写LogAspect切面
@Component
@Aspect
public class LogAspect {
private static final Logger log = LoggerFactory.getLogger(LogAspect.class);
/**
* 切点:所有标了 @Log 的方法
* 方法体可以为空,它只是一个"命名切点",方便多个通知复用
*/
@Pointcut("@annotation(cn.javaguide.annotation.Log)")
public void webLog() {
}
/**
* 环绕通知
*/
@Around("webLog()")
public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
String methodName = joinPoint.getSignature().getName();
String description = "";
MethodType methodType = MethodType.OTHER;
// 从方法签名上拿到 @Log 注解,读取描述和类型
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Log logAnnotation = signature.getMethod().getAnnotation(Log.class);
if (logAnnotation != null) {
description = logAnnotation.description();
methodType = logAnnotation.methodType();
}
try {
Object result = joinPoint.proceed(); // 执行真正的业务方法
saveLog(methodName, description, methodType,
System.currentTimeMillis() - start, null);
return result;
} catch (Throwable e) {
saveLog(methodName, description, methodType,
System.currentTimeMillis() - start, e);
throw e; // 一定要继续抛,不能让切面吞掉业务异常
}
}
private void saveLog(String methodName, String description,
MethodType methodType, long cost, Throwable error) {
String user = "anonymous";
try {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getPrincipal() instanceof UserDetails u) {
user = u.getUsername();
}
} catch (Exception ignored) {
}
String ip = "";
try {
ServletRequestAttributes attrs =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs != null) {
ip = attrs.getRequest().getRemoteAddr();
}
} catch (Exception ignored) {
}
String status = error == null ? "SUCCESS" : "FAIL: " + error.getMessage();
String args = Arrays.toString(joinPointArgs(methodName)); // 参数摘要
log.info("[AOP LOG] user={}, ip={}, method={}, desc={}, type={}, cost={}ms, args={}, status={}",
user, ip, methodName, description, methodType, cost, args, status);
// 生产环境这里通常是:写入数据库/发 MQ/调审计系统
}
}几个关键点:
joinPoint.proceed()才是执行原方法;不调用它,业务代码不会执行。- 必须用
catch + throw e,否则异常被吞掉,事务和上层异常处理都会失效。 MethodSignature.getMethod().getAnnotation(Log.class)用于读取方法上的注解值。- 用户从
SecurityContextHolder取,IP 从RequestContextHolder取。 - 日志写入要保证“切面本身出错不能影响业务”,所以用 try/catch 包住取用户和 IP 的部分
调用方法时,先走切面,切面里执行 pjp.proceed() 时才会进入真实方法体
五种通知类型
| 通知 | 适合的语义 | 对比 Around |
|---|---|---|
@Before | 只要前置检查/打点 | 不用写 proceed,忘写也不会让业务消失 |
@AfterReturning | 只关心成功结果 | 不会误吞异常 |
@AfterThrowing | 只关心异常 | 不用自己 catch + throw |
@After | 无论成败都要收尾 | 不用 try/finally |
@Around | 完全控制 | 最灵活,但最容易写错(忘 proceed、吞异常) |
// 只记录成功响应,用 AfterReturning 最直观
@AfterReturning(pointcut = "webLog()", returning = "result")
public void logSuccess(JoinPoint joinPoint, Object result) {
log.info("{} 成功, 返回 {}", joinPoint.getSignature().getName(), result);
}@AfterReturning 的语义:目标方法正常返回(没抛异常)之后,自动执行这个方法。
webLog():切点,决定拦哪些方法。
returning = "result":告诉 Spring “把目标方法的返回值,绑定到下面这个叫 result 的参数上”。
参数 Object result:就是真实方法 return 出来的值。
joinPoint.getSignature().getName():拿到被调方法名。
“Around 是功能最全的通知类型,权限、缓存、事务这类需要控制原方法是否执行的场景只能用 Around。但 Before/AfterReturning/AfterThrowing 更贴合单一语义,比如只记录成功、只记录异常,代码更短也更容易保证不误吞异常。我的习惯是:需要完全控制用 Around,轻量的横切关注点用专用通知。”
Spring AOP 的核心原理:动态代理
Spring AOP 就是基于动态代理的,如果要代理的对象,实现了某个接口,那么 Spring AOP 会使用 JDK Proxy,去创建代理对象,而对于没有实现接口的对象,就无法使用 JDK Proxy 去进行代理了,这时候 Spring AOP 会使用 CGLIB 生成一个被代理对象的子类来作为代理,如下图所示:

Spring AOP 不是改写你的源码,而是在运行时生成一个代理对象,让调用先经过代理,再进真实方法:
调用方
→ 代理对象(执行切面逻辑)
→ 真实对象(执行业务)两种代理实现:
| 方式 | 条件 | 原理 |
|---|---|---|
| JDK 动态代理 | 目标类实现了接口 | 运行时生成一个实现相同接口的代理类 |
| CGLIB | 目标类没有接口 | 生成目标类的子类,通过继承代理 |
Spring Boot 默认 spring.aop.proxy-target-class=true,所以即使有接口也优先用 CGLIB。
为什么控制器上的注解能生效:Spring MVC 从容器拿到的 ContentController 其实是代理对象,调用方法时先执行切面。
自调用为什么失效(高频题):
@Service
public class OrderService {
public void a() {
this.b(); // this 是真实对象,不是代理!
}
@Transactional
public void b() { ... }
}this.b() 没有经过代理对象,所以 b() 上的 @Transactional、@RequireLevel 都不会生效。
解决:
- 把方法拆到另一个 bean 注入调用。
- 从容器获取代理再调用。
- 用
AopContext.currentProxy()。 - 本项目做法:注解放在 Controller 入口方法,由外部调用,天然走代理。
动态代理:不手写,程序在运行时自动生成代理类。你只需要告诉 Spring“我要代理谁、做什么”,Spring 自动帮你造出替身:
// 我只需要写这个,代理是 Spring 运行时生成的
@Log(description = "查询订单")
public Order getOrder(Long id) { ... }所以“动态” = 代理类不是手写的,也不是编译期写死的,而是运行时生成。
Spring 容器拿到的其实是代理对象是什么意思?
@Autowired
private ContentController contentController;你以为拿到的是 ContentController 真实对象,但 Spring 看到这个类的方法上有 @RequireLevel,就会在启动时创建一个“代理对象”放进容器,注入给你的是代理:
什么是“自调用”,为什么失效?
“自调用”就是类内部,自己调自己的另一个方法:
@Service
public class OrderService {
public void a() {
this.b(); // 这就是自调用
}
@Transactional
public void b() {
// 要开事务的业务
}
}重点理解 this 是什么:
- 外部调用
orderService.a()时,orderService是代理对象,所以能走代理。 - 但是代理对象内部,真正执行业务的是真实对象。
- 在真实对象里,
this指向真实对象自己,不是代理对象。
所以 this.b() 是“真实对象直接调真实对象”,根本没经过代理,b() 上的 @Transactional 就不会生效:
外部 → 代理.a() → 真实对象.a()
└→ this.b() ← this 是真实对象,不经过代理,注解失效对比:如果是另一个类调用 b(),注入的是代理,就会生效:
其他类 → 代理.b() → 切面/事务 → 真实对象.b() ✅怎么解决
- 拆成另一个 bean:
a()调用注入的另一个Bean.b(),拿到的是代理。 - 从容器拿代理:
applicationContext.getBean(...)。 AopContext.currentProxy():在当前线程里拿到代理再调。- 项目里的做法:把
@RequireLevel、@Transactional放在 Controller/Service 的入口方法上,由外部调用,天然走代理,不搞自调用。
自调用 = 在真实对象内部自己调自己,绕过了代理,所以注解失效
本项目:缓存切面(MethodCacheAspect)
命中缓存时不再执行原方法、不再查数据库。
注释定义
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MethodCache {
String key();
long ttlSeconds() default 60;
}切面实现
@Aspect
@Component
public class MethodCacheAspect {
private final Map<String, CacheEntry> cache = new ConcurrentHashMap<>();
@Around("@annotation(methodCache)")
public Object around(ProceedingJoinPoint pjp, MethodCache methodCache) throws Throwable {
String key = buildKey(pjp, methodCache.key());
long now = System.currentTimeMillis();
CacheEntry entry = cache.get(key);
if (entry != null && now - entry.time < methodCache.ttlSeconds() * 1000L) {
return entry.value; // 命中缓存,不执行原方法
}
Object value = pjp.proceed(); // 未命中,执行原方法
if (value != null) {
cache.put(key, new CacheEntry(now, value));
}
return value;
}
public void evictPrefix(String prefix) {
String start = prefix + ":";
cache.keySet().removeIf(key -> key.startsWith(start));
}
private String buildKey(ProceedingJoinPoint pjp, String prefix) {
StringBuilder key = new StringBuilder(prefix).append(':');
MethodSignature signature = (MethodSignature) pjp.getSignature();
key.append(signature.getMethod().getName());
for (Object arg : pjp.getArgs()) {
key.append(':').append(arg == null ? "null" : arg);
}
return key.toString();
}
private record CacheEntry(long time, Object value) {}
}@Around:包住目标方法执行,能决定“执不执行原方法”。
@annotation(methodCache):切点 = 所有标了 @MethodCache 的方法,并把注解对象绑定到参数 methodCache。
ProceedingJoinPoint pjp:代表“这次被拦截的方法调用”,调用 pjp.proceed() 就等于执行真实方法。
拼缓存 key
String key = buildKey(pjp, methodCache.key());为什么 key 要带方法名和参数?因为 site("cn") 和 site("us") 是不同数据,不能共用缓存。生成出来类似:
site:site:cn
nav:nav:cn
site:site:us拆开看 buildKey:
StringBuilder key = new StringBuilder(prefix).append(':'); // site:
MethodSignature signature = (MethodSignature) pjp.getSignature(); // 方法信息
key.append(signature.getMethod().getName()); // site
for (Object arg : pjp.getArgs()) { // 遍历所有参数
key.append(':').append(arg == null ? "null" : arg); // :cn
}pjp.getArgs() 返回 Object 数组,就是调用时的参数列表。注意这里不能直接 arg.toString(),所以空参数写成 "null"。
查缓存 + TTL 判断
long now = System.currentTimeMillis();
CacheEntry entry = cache.get(key);
if (entry != null && now - entry.time < methodCache.ttlSeconds() * 1000L) {
return entry.value; // 命中
}-
entry != null:这个 key 缓存过。 -
now - entry.time < ttl * 1000:还没超过有效期。
entry.time是写入时的时间戳。now - entry.time是已经缓存了多久。- 小于 TTL = 没过期。
-
两个条件同时成立就直接返回缓存值,不执行真实方法,这就是“第二次请求 0 次 SQL”的原因。
- 未命中:执行真实方法 + 回填
Object value = pjp.proceed(); // 真正进入 site() 方法体,查库
if (value != null) {
cache.put(key, new CacheEntry(now, value)); // 把结果存进缓存
}
return value;pjp.proceed()才会执行真实方法。- 为什么要判空?不缓存 null。否则“查询失败”也会被当成正常结果缓存,之后 60 秒内一直返回空。
new CacheEntry(now, value):记录“现在这个时刻 + 查询结果”,下次靠entry.time判断过期。
CacheEntry
private record CacheEntry(long time, Object value) {}time:写入时间戳。value:缓存的数据。- record 是 Java 的不可变数据类,自动生成构造器和 getter,简洁。
主动失效
public void evictPrefix(String prefix) {
String start = prefix + ":";
cache.keySet().removeIf(key -> key.startsWith(start));
}- 管理员保存站点后调用
evictPrefix("site"),把site:site:cn这种 key 全删掉。 - 为什么不直接
cache.clear()?因为会误删 nav 的缓存,removeIf按前缀精准删。 ConcurrentHashMap.keySet().removeIf是并发安全的删除操作。
缓存 Map(就在服务器内存里)
key = "site:site:cn"
value = Site{title="国科环宇", logo="/static/...", ...}
附带时间 = 写入时刻使用方法
@MethodCache(key = "site", ttlSeconds = 60)
public Site site(String acode) { ... }
@MethodCache(key = "nav", ttlSeconds = 60)
public List<ContentSortTreeVO> nav(String acode) { ... }写操作主动失效
// 保存站点后
methodCacheAspect.evictPrefix("site");
// 栏目增删改后
methodCacheAspect.evictPrefix("nav");不上redis?
本地内存缓存的缺点
服务器 A 缓存了 site
服务器 B 没有这份缓存,自己又查了一次库- 多实例不一致:部署两台服务器,每台各有各的缓存。A 改了数据只清 A 的缓存,B 还能返回旧数据。
- 重启就没了:内存缓存存在 JVM 里,服务一重启,缓存全部清空,又得重新查库。
- 不共享:每个实例各自为政,缓存命中率低。
- 容量受限:只占一个 JVM 的内存。
Redis 解决什么
两台服务器共享同一个 Redis
A 写入缓存 → B 也能读到
A 删除缓存 → B 的下一次请求也查不到
TTL 由 Redis 统一管理,不用自己存时间戳所以生产多实例场景,缓存要放到所有实例都能访问的公共存储,Redis 是最常见的选择:
- 自带过期(
EXPIRE) - 跨实例共享
- 重启不丢(可持久化)
- 删除/失效是分布式操作,所有实例立即生效
“现在项目是单机,所以用 ConcurrentHashMap 做本地缓存讲清原理。生产多实例我会用 Redis,TTL 交给 Redis 管理,缓存删除对所有实例生效;如果要更高性能,可以做 Redis + 本地两级缓存。直接用 Spring Cache 的 @Cacheable 配 RedisCacheManager 是更工程化的方案。”
权限:Spring Security + 注解/拦截器 + AOP 切面
缓存:Spring Cache 注解 + Redis,热点叠 Caffeine
日志:AOP 切面记录,异步写库/MQ
事务:@Transactional(Spring AOP)