cms项目细节-aop

共 3,166 字 约 10 分钟

AOP是什么

假设你有很多接口方法,每个方法都要做同样的事:检查权限、打印耗时、开启事务。

没有 AOP 的写法:

Java
UTF-8|23 Lines|
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的思路:把这些和业务无关的的横切逻辑剥离出来,放到一个统一的地方,让业务方法只写业务

Java
UTF-8|37 Lines|

// 日志注解
@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,面向切面编程)就是“把横切关注点从业务代码里抽出来,统一织入到目标方法前后”。

这样的话,我们一行注解即可实现日志记录:

Java
UTF-8|7 Lines|
@Log(description = "method1",methodType = MethodType.INSERT)
public CommonResponse<Object> method1() {
      // 业务逻辑
      xxService.method1();
      // 省略具体的业务处理逻辑
      return CommonResponse.success();
}

写法

引入依赖

xml
UTF-8|4 Lines|
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

springboot自动开启aop,普通spring项目需要写

Java
UTF-8|3 Lines|
@Configuration
@EnableAspectJAutoProxy
public class AopConfig {}

定义methodtype枚举

Java
UTF-8|6 Lines|
public enum MethodType {
    INSERT,   // 新增
    DELETE,   // 删除
    UPDATE,   // 修改
    OTHER     // 其他
}

定义@Log注释

Java
UTF-8|11 Lines|
@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切面

Java
UTF-8|74 Lines|
@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、吞异常)
plaintext
UTF-8|5 Lines|
// 只记录成功响应,用 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 生成一个被代理对象的子类来作为代理,如下图所示:

230ae587a322d6e4d09510161987d346

Spring AOP 不是改写你的源码,而是在运行时生成一个代理对象,让调用先经过代理,再进真实方法:

Text
UTF-8|3 Lines|
调用方
  → 代理对象(执行切面逻辑)
      → 真实对象(执行业务)

两种代理实现:

方式条件原理
JDK 动态代理目标类实现了接口运行时生成一个实现相同接口的代理类
CGLIB目标类没有接口生成目标类的子类,通过继承代理

Spring Boot 默认 spring.aop.proxy-target-class=true,所以即使有接口也优先用 CGLIB。

为什么控制器上的注解能生效:Spring MVC 从容器拿到的 ContentController 其实是代理对象,调用方法时先执行切面。

自调用为什么失效(高频题):

Java
UTF-8|9 Lines|
@Service
public class OrderService {
    public void a() {
        this.b();   // this 是真实对象,不是代理!
    }

    @Transactional
    public void b() { ... }
}

this.b() 没有经过代理对象,所以 b() 上的 @Transactional、@RequireLevel 都不会生效。

解决:

  1. 把方法拆到另一个 bean 注入调用。
  2. 从容器获取代理再调用。
  3. 用 AopContext.currentProxy()。
  4. 本项目做法:注解放在 Controller 入口方法,由外部调用,天然走代理。

动态代理:不手写,程序在运行时自动生成代理类。你只需要告诉 Spring“我要代理谁、做什么”,Spring 自动帮你造出替身:

plaintext
UTF-8|3 Lines|
// 我只需要写这个,代理是 Spring 运行时生成的
@Log(description = "查询订单")
public Order getOrder(Long id) { ... }

所以“动态” = 代理类不是手写的,也不是编译期写死的,而是运行时生成。

Spring 容器拿到的其实是代理对象是什么意思?

plaintext
UTF-8|2 Lines|
@Autowired
private ContentController contentController;

你以为拿到的是 ContentController 真实对象,但 Spring 看到这个类的方法上有 @RequireLevel,就会在启动时创建一个“代理对象”放进容器,注入给你的是代理:

什么是“自调用”,为什么失效?

“自调用”就是类内部,自己调自己的另一个方法:

Java
UTF-8|12 Lines|
@Service
public class OrderService {

    public void a() {
        this.b();   // 这就是自调用
    }

    @Transactional
    public void b() {
        // 要开事务的业务
    }
}

重点理解 this 是什么:

  • 外部调用 orderService.a() 时,orderService 是代理对象,所以能走代理。
  • 但是代理对象内部,真正执行业务的是真实对象。
  • 在真实对象里,this 指向真实对象自己,不是代理对象。

所以 this.b() 是“真实对象直接调真实对象”,根本没经过代理,b() 上的 @Transactional 就不会生效:

plaintext
UTF-8|2 Lines|
外部 → 代理.a() → 真实对象.a()
                      └→ this.b()      ← this 是真实对象,不经过代理,注解失效

对比:如果是另一个类调用 b(),注入的是代理,就会生效:

plaintext
UTF-8|1 Line|
其他类 → 代理.b() → 切面/事务 → 真实对象.b()   ✅

怎么解决

  1. 拆成另一个 bean:a() 调用注入的另一个 Bean.b(),拿到的是代理。
  2. 从容器拿代理:applicationContext.getBean(...)。
  3. AopContext.currentProxy():在当前线程里拿到代理再调。
  4. 项目里的做法:把 @RequireLevel、@Transactional 放在 Controller/Service 的入口方法上,由外部调用,天然走代理,不搞自调用。

自调用 = 在真实对象内部自己调自己,绕过了代理,所以注解失效

本项目:缓存切面(MethodCacheAspect)

命中缓存时不再执行原方法、不再查数据库。

注释定义

Java
UTF-8|6 Lines|
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MethodCache {
    String key();
    long ttlSeconds() default 60;
}

切面实现

Java
UTF-8|40 Lines|
@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

plaintext
UTF-8|1 Line|
String key = buildKey(pjp, methodCache.key());

为什么 key 要带方法名和参数?因为 site("cn") 和 site("us") 是不同数据,不能共用缓存。生成出来类似:

plaintext
UTF-8|3 Lines|
site:site:cn
nav:nav:cn
site:site:us

拆开看 buildKey:

Java
UTF-8|6 Lines|
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 判断

Java
UTF-8|6 Lines|
long now = System.currentTimeMillis();
CacheEntry entry = cache.get(key);

if (entry != null && now - entry.time < methodCache.ttlSeconds() * 1000L) {
    return entry.value;   // 命中
}
  • entry != null:这个 key 缓存过。

  • Java
    UTF-8|1 Line|
    now - entry.time < ttl * 1000

    :还没超过有效期。

    • entry.time 是写入时的时间戳。
    • now - entry.time 是已经缓存了多久。
    • 小于 TTL = 没过期。
  • 两个条件同时成立就直接返回缓存值,不执行真实方法,这就是“第二次请求 0 次 SQL”的原因。

  1. 未命中:执行真实方法 + 回填
Java
UTF-8|5 Lines|
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

Java
UTF-8|1 Line|
private record CacheEntry(long time, Object value) {}
  • time:写入时间戳。
  • value:缓存的数据。
  • record 是 Java 的不可变数据类,自动生成构造器和 getter,简洁。

主动失效

Java
UTF-8|4 Lines|
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 是并发安全的删除操作。
plaintext
UTF-8|4 Lines|
缓存 Map(就在服务器内存里)
  key = "site:site:cn"
  value = Site{title="国科环宇", logo="/static/...", ...}
  附带时间 = 写入时刻

使用方法

Java
UTF-8|5 Lines|
@MethodCache(key = "site", ttlSeconds = 60)
public Site site(String acode) { ... }

@MethodCache(key = "nav", ttlSeconds = 60)
public List<ContentSortTreeVO> nav(String acode) { ... }

写操作主动失效

Java
UTF-8|5 Lines|
// 保存站点后
methodCacheAspect.evictPrefix("site");

// 栏目增删改后
methodCacheAspect.evictPrefix("nav");

不上redis?

本地内存缓存的缺点

plaintext
UTF-8|2 Lines|
服务器 A 缓存了 site
服务器 B 没有这份缓存,自己又查了一次库
  1. 多实例不一致:部署两台服务器,每台各有各的缓存。A 改了数据只清 A 的缓存,B 还能返回旧数据。
  2. 重启就没了:内存缓存存在 JVM 里,服务一重启,缓存全部清空,又得重新查库。
  3. 不共享:每个实例各自为政,缓存命中率低。
  4. 容量受限:只占一个 JVM 的内存。

Redis 解决什么

plaintext
UTF-8|4 Lines|
两台服务器共享同一个 Redis
A 写入缓存 → B 也能读到
A 删除缓存 → B 的下一次请求也查不到
TTL 由 Redis 统一管理,不用自己存时间戳

所以生产多实例场景,缓存要放到所有实例都能访问的公共存储,Redis 是最常见的选择:

  • 自带过期(EXPIRE)
  • 跨实例共享
  • 重启不丢(可持久化)
  • 删除/失效是分布式操作,所有实例立即生效

“现在项目是单机,所以用 ConcurrentHashMap 做本地缓存讲清原理。生产多实例我会用 Redis,TTL 交给 Redis 管理,缓存删除对所有实例生效;如果要更高性能,可以做 Redis + 本地两级缓存。直接用 Spring Cache 的 @Cacheable 配 RedisCacheManager 是更工程化的方案。”

plaintext
UTF-8|4 Lines|
权限:Spring Security + 注解/拦截器 + AOP 切面
缓存:Spring Cache 注解 + Redis,热点叠 Caffeine
日志:AOP 切面记录,异步写库/MQ
事务:@Transactional(Spring AOP)