AOP 切面:参数绑定、顺序与 AspectJ 编织
"切面写了却没生效""两个切面谁包谁""切面吞了异常事务为什么还提交"是三类不同问题:第一类是增强根本没进链(匹配或入口),第二类是 advisor 排序,第三类是链上异常路由与事务边界错位。本篇固定 Framework v6.1.12,aspectjweaver 取 1.9.22.1(现有正文 Boot v3.3.3 语境的管理版本);@Aspect 声明层、参数绑定、顺序、引介与编织对比的权威讲解在本篇,代理类型选择与 proceed 链语义的主归属仍是AOP 代理与调用入口,事务传播规则归事务正文。注解存在、方法匹配、调用经过代理三者缺一不可,少一个增强就不发生。
@Aspect 是自动代理之上的声明层
@EnableAspectJAutoProxy 注册 AnnotationAwareAspectJAutoProxyCreator:每个 bean 创建后进入 wrapIfNecessary,先查基础设施类与跳过表,再用适用的 advisor 列表决定是否包装。@Aspect bean 不是"扫描到就生效":它的每个通知方法被 ReflectiveAspectJAdvisorFactory 转成 advisor,切面实例本身仍是容器里一个惰性单例——切面里写的实例字段就是所有连接点共享的状态。
原码:AbstractAutoProxyCreator.wrapIfNecessary 与相邻分支,v6.1.12,355–379 行。固定来源。
protected Object wrapIfNecessary(Object bean, String beanName, Object cacheKey) {
if (StringUtils.hasLength(beanName) && this.targetSourcedBeans.contains(beanName)) {
return bean;
}
if (Boolean.FALSE.equals(this.advisedBeans.get(cacheKey))) {
return bean;
}
if (isInfrastructureClass(bean.getClass()) || shouldSkip(bean.getClass(), beanName)) {
this.advisedBeans.put(cacheKey, Boolean.FALSE);
return bean;
}
// Create proxy if we have advice.
Object[] specificInterceptors = getAdvicesAndAdvisorsForBean(bean.getClass(), beanName, null);
if (specificInterceptors != DO_NOT_PROXY) {
this.advisedBeans.put(cacheKey, Boolean.TRUE);
Object proxy = createProxy(
bean.getClass(), beanName, specificInterceptors, new SingletonTargetSource(bean));
this.proxyTypes.put(cacheKey, proxy.getClass());
return proxy;
}
this.advisedBeans.put(cacheKey, Boolean.FALSE);
return bean;
}三个提前返回都是"原样交付":TargetSource 已处理过的、明确不代理的、基础设施类或 shouldSkip 的。适用性检查(getAdvicesAndAdvisorsForBean)对每个候选 advisor 做静态匹配,本页实验证明了一个静默边界:切点变量与通知参数对不上时,advisor 在这一步就失配——若它是该 bean 唯一的增强,bean 保持未代理,refresh 与调用都不报错。
切面到 advisor 的转换在 getAdvisors:先按通知方法逐一生成,再补 @DeclareParents 引介字段。摘录里的注释说明了顺序语义的一个关键变化:5.2.7 起 declarationOrderInAspect 固定为 0,因为 JDK 反射不再保证按声明顺序返回方法——同一切面内通知的顺序由通知类型决定,同类型通知之间顺序未定义。
原码:ReflectiveAspectJAdvisorFactory.getAdvisors,v6.1.12,125–168 行。固定来源。
public List<Advisor> getAdvisors(MetadataAwareAspectInstanceFactory aspectInstanceFactory) {
Class<?> aspectClass = aspectInstanceFactory.getAspectMetadata().getAspectClass();
String aspectName = aspectInstanceFactory.getAspectMetadata().getAspectName();
validate(aspectClass);
// We need to wrap the MetadataAwareAspectInstanceFactory with a decorator
// so that it will only instantiate once.
MetadataAwareAspectInstanceFactory lazySingletonAspectInstanceFactory =
new LazySingletonAspectInstanceFactoryDecorator(aspectInstanceFactory);
List<Advisor> advisors = new ArrayList<>();
for (Method method : getAdvisorMethods(aspectClass)) {
if (method.equals(ClassUtils.getMostSpecificMethod(method, aspectClass))) {
// Prior to Spring Framework 5.2.7, advisors.size() was supplied as the declarationOrderInAspect
// to getAdvisor(...) to represent the "current position" in the declared methods list.
// However, since Java 7 the "current position" is not valid since the JDK no longer
// returns declared methods in the order in which they are declared in the source code.
// Thus, we now hard code the declarationOrderInAspect to 0 for all advice methods
// discovered via reflection in order to support reliable advice ordering across JVM launches.
// Specifically, a value of 0 aligns with the default value used in
// AspectJPrecedenceComparator.getAspectDeclarationOrder(Advisor).
Advisor advisor = getAdvisor(method, lazySingletonAspectInstanceFactory, 0, aspectName);
if (advisor != null) {
advisors.add(advisor);
}
}
}
// If it's a per target aspect, emit the dummy instantiating aspect.
if (!advisors.isEmpty() && lazySingletonAspectInstanceFactory.getAspectMetadata().isLazilyInstantiated()) {
Advisor instantiationAdvisor = new SyntheticInstantiationAdvisor(lazySingletonAspectInstanceFactory);
advisors.add(0, instantiationAdvisor);
}
// Find introduction fields.
for (Field field : aspectClass.getDeclaredFields()) {
Advisor advisor = getDeclareParentsAdvisor(field);
if (advisor != null) {
advisors.add(advisor);
}
}
return advisors;
}依赖边界:spring-aop 对 aspectjweaver 声明为 optional。只要切点表达式是 AspectJ 语法(execution(...)、@annotation(...)),运行期就需要 aspectjweaver——AspectJExpressionPointcut 用它的解析器做匹配。本篇实验 pom 显式声明 org.aspectj:aspectjweaver:1.9.22.1。这是 6.1.12 实现口径,不要把它背成"任何版本都这样"。
切点匹配与参数绑定
绑定规则:通知方法第一个参数可以是 JoinPoint(环绕是 ProceedingJoinPoint);其余参数按名字与切点变量对齐(args(account, cents)、@annotation(marker)、returning/throwing 指定名)。绑定失败有两种结局,方向相反:
原码:AbstractAspectJAdvice.calculateArgumentBindings,v6.1.12,374–393 行。固定来源。
public final void calculateArgumentBindings() {
// The simple case... nothing to bind.
if (this.argumentsIntrospected || this.parameterTypes.length == 0) {
return;
}
int numUnboundArgs = this.parameterTypes.length;
Class<?>[] parameterTypes = this.aspectJAdviceMethod.getParameterTypes();
if (maybeBindJoinPoint(parameterTypes[0]) || maybeBindProceedingJoinPoint(parameterTypes[0]) ||
maybeBindJoinPointStaticPart(parameterTypes[0])) {
numUnboundArgs--;
}
if (numUnboundArgs > 0) {
// need to bind arguments by name as returned from the pointcut match
bindArgumentsByName(numUnboundArgs);
}
this.argumentsIntrospected = true;
}bindArgumentsByName 的名字来自 ParameterNameDiscoverer:字段名兜底始终可用,构造器/方法参数名兜底依赖 -parameters 编译选项(本仓库 lab 已显式开启,见配置与候选解析页的同名边界)。名字发现不了时才抛 IllegalStateException("requires N arguments to be bound by name")——这是响亮失败分支。而"切点变量里没有这个名字"走另一条路:切点参数表按通知签名过滤后匹配失败,静默不生效。实测:通知方法声明了切点变量之外的参数 unknown 后,advisor 在静态适用性检查即失配,目标 bean 干脆不创建代理(beanUnproxied=true),refresh 与调用全程无异常。排查"切面没生效"时要先确认 advisor 是否真的进入了链。
绑定值的求值时点也有边界:绑定在链推进到该通知时按当前参数求值,不是链构建时的快照。实测同一切面里 @Around 用 proceed(args) 把 cents 从 500 改成 1,排在后面的 @Before 的 args 绑定拿到的是 1——因为 args(...) 是动态匹配,ReflectiveMethodInvocation.proceed 在到达动态拦截器时才用本次参数求值(见代理入口页的 proceed 摘录)。@annotation(marker) 绑定的是注解元数据不受改参影响;proceed(args) 决定 target 看到什么;returning 绑定只在正常返回时触发。
通知顺序:切面 @Order 与同切面规则
两层规则要分开:
- 跨切面:advisor 按
Ordered/@Order排序,值小者高优先——进入时先跑,退出时后跑。两个切面都没写@Order时顺序未定义,不能把某次观察固化成规则。同一切面的所有 advisor 共享该切面的 order 值。 - 同切面:按通知类型固定优先级
@Around→@Before→@After→@AfterReturning/@AfterThrowing;@After是 after-finally 语义,正常返回时排在@AfterReturning之后。同类型两个通知之间顺序未定义(上一节摘录的 declarationOrder=0 注释)。
实测(advice-order 模式):正常返回 backbone 为 around-in → before(两条,互相次序不保证)→ target → after-returning → after → around-out;目标抛 IllegalStateException 时为 around-in → before → target → after-throwing → after → around-out。@AfterThrowing 替换 @AfterReturning 出现,@After 两种结局都执行。实验断言只压 backbone 与"两条 @Before 都先于 target",不冒充能断言同类型次序。
跨切面实测(aspect-order 模式):@Order(1) 切面包在 @Order(2) 外层,事件 one-in → two-in → target → two-out → one-out;去掉 @Order 后两次通知仍都执行,实验只记录观察序列并标注 contract=undefined。
有一个固定的特殊advisor:ExposeInvocationInterceptor 总被排在 AspectJ 通知链最前,order 为 HIGHEST_PRECEDENCE + 1——它把当前 MethodInvocation 放进线程局部,是 AopContext.currentProxy() 与动态绑定取上下文的前提。
原码:ExposeInvocationInterceptor.invoke 与 getOrder,v6.1.12,91–107 行。固定来源。
@Override
@Nullable
public Object invoke(MethodInvocation mi) throws Throwable {
MethodInvocation oldInvocation = invocation.get();
invocation.set(mi);
try {
return mi.proceed();
}
finally {
invocation.set(oldInvocation);
}
}
@Override
public int getOrder() {
return PriorityOrdered.HIGHEST_PRECEDENCE + 1;
}两个推论:一是线程局部的读写都在调用线程——跨线程拿不到当前 invocation/proxy;二是比它 order 更高的通知会跑到它前面,破坏依赖该上下文的通知。
异常、返回值与事务叠加的代理层边界
通知不是事件广播:异常和返回值沿链逐层流动。@AfterReturning 只看到正常返回;@AfterThrowing 只看到异常;@After 两者都执行但按官方规则不接收兄弟通知的异常;内层把异常转成别的类型或吞掉,外层看到的就是转换结果(调用控制流的机制解释归代理入口页)。
事务是这条链上的一个 advisor。@EnableTransactionManagement 注册 BeanFactoryTransactionAttributeSourceAdvisor,其 order 来自注解的 order 属性,未指定时是 Ordered.LOWEST_PRECEDENCE:
原码:ProxyTransactionManagementConfiguration.transactionAdvisor,v6.1.12,44–56 行。固定来源。
@Bean(name = TransactionManagementConfigUtils.TRANSACTION_ADVISOR_BEAN_NAME)
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public BeanFactoryTransactionAttributeSourceAdvisor transactionAdvisor(
TransactionAttributeSource transactionAttributeSource, TransactionInterceptor transactionInterceptor) {
BeanFactoryTransactionAttributeSourceAdvisor advisor = new BeanFactoryTransactionAttributeSourceAdvisor();
advisor.setTransactionAttributeSource(transactionAttributeSource);
advisor.setAdvice(transactionInterceptor);
if (this.enableTx != null) {
advisor.setOrder(this.enableTx.<Integer>getNumber("order"));
}
return advisor;
}于是"切面与事务谁包谁"由 order 数字决定,而谁先看到异常决定事务结果。实测(tx-layering 模式,H2 数据行证据,事务 order=1000):
| 布局 | 结果(实测) | 含义 |
|---|---|---|
| 吞异常切面 order=2000(事务内层) | 切面吞掉返回 null,事务拦截器只见正常返回,提交,rows=[1],调用方无感知 | 错位提交:业务失败落库 |
| 吞异常切面 order=500(事务外层) | 异常先过拦截器,回滚 rows=[],但切面吞掉后调用方仍看到成功 | 数据没脏,但调用方与数据状态错位 |
| 内层切面 rethrow(修复) | 拦截器看到异常回滚 rows=[],调用方收到异常 | 唯一让三方一致的布局 |
修复方向只有两个:让切面 rethrow,或显式把它排到事务外层并自行决定补偿。绝不能在事务内层吞异常后假装失败没发生。同类自调用绕过代理导致事务整体失效的实测在事务实验的 self 模式(T14 已跑),本页只维护代理叠加顺序;传播、挂起、保存点归事务正文。
引介与代理 API 的适用面
引介(introduction)给被代理对象"加上"一个接口实现:@DeclareParents(value = "类型模式", defaultImpl = 委托类) 声明在切面的静态字段上,字段类型就是要引入的接口。实测:Echo bean 拿到后 instanceof Audit 成立、调用委托到 DefaultAudit。一个写法陷阱:类型模式对嵌套类用点号源名——study.spring.AopLab.Echo 能匹配,而 execution 签名里可用的 study.spring.AopLab$Echo 在这里失配(实测两写法对照),匹配失败同样静默:advisor 不适用,bean 不代理。引介不改变目标类字节码,接口只存在于代理上;绕过代理的引用拿不到它。
代理 API 的适用面(全部只在"持有代理引用"时成立):
Advised接口默认挂在代理上:运行时addAdvice/removeAdvice、读getAdvisors()、getTargetClass()。实测 add 后下一次调用即生效、remove 后恢复。opaque=true会隐藏该接口,不能强转猜测。AopContext.currentProxy()需要@EnableAspectJAutoProxy(exposeProxy = true)且 invocation 在线程局部里(上一节摘录),未开启实测抛IllegalStateException,消息点名 "Set 'exposeProxy' property on Advised to 'true'"——这是排障的第一入口。跨线程(如@Async上下文,见 Web 与上下文正文)同样拿不到。- 编程式:
ProxyFactory(既有页实验用的入口)与AspectJProxyFactory(用@Aspect类构造代理,不进容器);ProxyFactoryBean是 XML 时代的工厂入口,6.1 仍在,新代码没有理由优先选它。 - 这些 API 修不了自调用与 final 的模型限制,只提供取证与临时重入。
自动代理边界:自调用、final 与代理外引用
自动代理只包装容器 Bean;以下四种调用都不进链:bean 内部 this.method() 自调用(CGLIB 也不改写这个委托事实,见代理入口页)、调用方直接持有 target 引用(循环依赖早期暴露的例外由三级缓存篇维护)、容器外 new 出来的对象、初始化过程中的调用。实测(self-call 模式):proxy.outer() 内部 this.inner() 只有外层 before;exposeProxy=true 后改走 AopContext.currentProxy().inner() 得到 before:outer → outer → before:inner → inner。附带一个容易踩的坑:对 CGLIB 代理对象写实例字段写在代理壳上,target 看不到(CglibAopProxy 的 debug 文档明说代理壳字段未初始化可能 NPE)——实验用静态标志位驱动,就是绕开这个坑。
final 的边界要按代理类型分开说:
- CGLIB 类代理:final 方法切点静态匹配仍然成立,但调用无法被路由到拦截器——
doValidateClass只打 warn/debug,不失败,所以故障是"静默不增强"。实测final-methods模式:normal()有 before,final locked()只有目标事件;代理类名study.spring.AopLab$FinalWork$$SpringCGLIB$$0可直接证明类型。 - final 类:连子类都生成不了,容器在创建期抛
BeanCreationException(根因消息含 "Cannot subclass final class")——这是响亮失败,与 final 方法的静默相反。 - JDK 代理:拦截的是接口调用,不依赖覆盖实现方法,final 实现方法照样增强;但代理不能强转成实现类。
原码:CglibAopProxy.doValidateClass,v6.1.12,280–307 行。固定来源。
private void doValidateClass(Class<?> proxySuperClass, @Nullable ClassLoader proxyClassLoader, Set<Class<?>> ifcs) {
if (proxySuperClass != Object.class) {
Method[] methods = proxySuperClass.getDeclaredMethods();
for (Method method : methods) {
int mod = method.getModifiers();
if (!Modifier.isStatic(mod) && !Modifier.isPrivate(mod)) {
if (Modifier.isFinal(mod)) {
if (logger.isWarnEnabled() && implementsInterface(method, ifcs)) {
logger.warn("Unable to proxy interface-implementing method [" + method + "] because " +
"it is marked as final, consider using interface-based JDK proxies instead.");
}
if (logger.isDebugEnabled()) {
logger.debug("Final method [" + method + "] cannot get proxied via CGLIB: " +
"Calls to this method will NOT be routed to the target instance and " +
"might lead to NPEs against uninitialized fields in the proxy instance.");
}
}
else if (logger.isDebugEnabled() && !Modifier.isPublic(mod) && !Modifier.isProtected(mod) &&
proxyClassLoader != null && proxySuperClass.getClassLoader() != proxyClassLoader) {
logger.debug("Method [" + method + "] is package-visible across different ClassLoaders " +
"and cannot get proxied via CGLIB: Declare this method as public or protected " +
"if you need to support invocations through the proxy.");
}
}
}
doValidateClass(proxySuperClass.getSuperclass(), proxyClassLoader, ifcs);
}
}排障清单(K06,全部可在本页实验复现):一问"代理类型"——打印 proxy.getClass().getName()($$SpringCGLIB$$ 即类代理)或用 AopUtils.isCglibProxy/isJdkDynamicProxy;二问"链里有什么"——((Advised) proxy).getAdvisors();三问"为什么没包上"——查是否基础设施类/唯一 advisor 静态失配(本页静默边界);四问"为什么没拦住"——final/自调用/入口核对,开 CGLIB debug 日志看 doValidateClass 的 warn 行;五问代理类型怎么改——Framework 层 @EnableAspectJAutoProxy(proxyTargetClass=),Boot 下 spring.aop.proxy-target-class(含命令行 -D),其装配语义归 Boot 启动页。异常消息文本与代理类名是 6.1.12 实现观察,不是规范契约。
代理 AOP 与 AspectJ 编织是两个执行模型
官方取舍一句话:只需要增强 Spring bean 的方法执行,用 Spring AOP;要增强非容器对象、或方法执行之外的连接点(字段读写、构造器),才引入 AspectJ 编译器/织入器。两者能力差异直接决定迁移条件:
| 维度 | 代理 AOP(Spring) | AspectJ 编织 |
|---|---|---|
| 前提 | 容器 Bean + 持有代理引用 | 织入器处理字节码,与容器无关 |
| 自调用 | 不重入代理(本页实测) | 调用点被改写,this.inner() 同样增强(LTW 实测 selfCallWoven=true) |
| final | final 方法静默不增强、final 类创建失败 | 不依赖覆盖,final 方法可增强(LTW 实测 finalWoven=true) |
| 连接点 | 方法执行 | 方法执行 + 字段 get/set、构造器、handler 等 |
| 对象来源 | 容器 Bean | 任意对象(含 domain 对象) |
| 织入时机 | 每次调用进代理链 | 编译期(ajc)/编译后(二进制织入)/加载期(LTW:aop.xml + -javaagent),三档出自 AspectJ 官方开发指南 |
| 代价 | 无构建链变更 | 引入 ajc/agent 与 aop.xml 维护,诊断与栈阅读方式改变 |
本页实验用 LTW 给出同一目标的对照:WovenTarget 是普通 new 出来的对象,无容器无代理,编织后 outer() 内部 this.inner() 与 final locked() 都进入通知。配置侧有三个实测约束:切面与通知方法必须 public(非公开通知织入报错、目标调用点拿到半成品直接 NoSuchMethodError);<weaver> 的 include 必须覆盖切面类自身(否则 aspectOf 不生成);嵌套类在 <aspects> 声明用 $ 二进制名、在 @DeclareParents 类型模式用 . 源名。compile-time/post-compile 织入需要 ajc 构建链,本机未搭建,按官方文档推导并如实标注。
实验:同一目标在两种执行模型下的差异
运行 JAVA_HOME=$(/usr/libexec/java_home -v17) bash labs/spring-senior/run.sh aop。八个模式各一个独立 Maven JVM(weaving 为 exec:exec 派生 JVM 加 -javaagent),唯一源码如下。
package study.spring;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.After;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.AfterThrowing;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.DeclareParents;
import org.aspectj.lang.annotation.Pointcut;
import org.h2.jdbcx.JdbcDataSource;
import org.springframework.aop.framework.Advised;
import org.springframework.aop.framework.AopContext;
import org.springframework.aop.support.AopUtils;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
import org.springframework.core.annotation.Order;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.transaction.annotation.Transactional;
/**
* U17 proxy-AOP boundaries: @Aspect declaration layer, advice ordering, argument binding,
* transaction layering through the proxy, self-call/exposeProxy, final limits, introductions,
* and an AspectJ load-time-weaving comparison. One JVM per mode.
*/
public class AopLab {
static final List<String> events = new ArrayList<>();
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD)
@interface Marker { String value(); }
public static class Work {
public String job() { events.add("target"); return "ok"; }
public String failJob() { events.add("target"); throw new IllegalStateException("synthetic failure"); }
}
@Aspect static class OrderAspect {
@Pointcut("execution(* study.spring.AopLab$Work.*(..))") void work() {}
@Around("work()") Object around(ProceedingJoinPoint pjp) throws Throwable {
events.add("around-in");
try { return pjp.proceed(); } finally { events.add("around-out"); }
}
@Before("work()") void before() { events.add("before"); }
// A second @Before of the same type in the same aspect: relative order is undefined by contract.
@Before("work()") void beforeAgain() { events.add("before-again"); }
@After("work()") void after() { events.add("after"); }
@AfterReturning("work()") void afterReturning() { events.add("after-returning"); }
@AfterThrowing("work()") void afterThrowing() { events.add("after-throwing"); }
}
public static class PayService {
public String charge(String account, int cents) {
events.add("charge:" + account + ":" + cents);
return "charged";
}
@Marker(value = "pay-audit")
public String pay() { events.add("pay"); return "paid"; }
}
@Aspect static class BindAspect {
@Pointcut("execution(* study.spring.AopLab$PayService.charge(..)) && args(account, cents)")
void charge(String account, int cents) {}
@Before("charge(account, cents)") void logArgs(JoinPoint jp, String account, int cents) {
events.add("args:" + account + ":" + cents + ":" + jp.getSignature().getName());
}
@Before("@annotation(marker)") void marker(Marker marker) { events.add("marker:" + marker.value()); }
@Around("execution(* study.spring.AopLab$PayService.charge(..))")
Object capCents(ProceedingJoinPoint pjp) throws Throwable {
Object[] args = pjp.getArgs();
args[1] = 1; // proceed with a modified argument
return pjp.proceed(args);
}
@AfterReturning(pointcut = "execution(* study.spring.AopLab$PayService.charge(..))", returning = "result")
void saw(String result) { events.add("returning:" + result); }
}
// Unbindable advice parameter: must fail the first matching invocation, not silently ignore.
@Aspect static class BadBindAspect {
@Before("execution(* study.spring.AopLab$PayService.charge(..))") void bad(String unknown) { events.add("never"); }
}
public static class Echo { public String speak() { events.add("speak"); return "speak"; } }
@Aspect @Order(1) static class OrderedOneAspect {
@Around("execution(* study.spring.AopLab$Echo.speak(..))") Object one(ProceedingJoinPoint pjp) throws Throwable {
events.add("one-in"); try { return pjp.proceed(); } finally { events.add("one-out"); }
}
}
@Aspect @Order(2) static class OrderedTwoAspect {
@Around("execution(* study.spring.AopLab$Echo.speak(..))") Object two(ProceedingJoinPoint pjp) throws Throwable {
events.add("two-in"); try { return pjp.proceed(); } finally { events.add("two-out"); }
}
}
// No @Order: precedence is undefined by contract; this lab only counts invocations.
@Aspect static class UnorderedAspect {
@Around("execution(* study.spring.AopLab$Echo.speak(..))") Object plain(ProceedingJoinPoint pjp) throws Throwable {
events.add("plain"); return pjp.proceed();
}
}
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD)
@interface TxMarked {}
public static class TxService {
final JdbcTemplate jdbc;
TxService(JdbcTemplate jdbc) { this.jdbc = jdbc; }
@TxMarked @Transactional
public void record() {
events.add("record");
jdbc.update("insert into item values(1)");
throw new IllegalStateException("expected business failure");
}
}
@Aspect @Order(2000) static class SwallowInsideAspect {
boolean swallow = true;
@Around("@annotation(study.spring.AopLab$TxMarked)")
Object swallow(ProceedingJoinPoint pjp) throws Throwable {
events.add("swallow-in");
try { return pjp.proceed(); }
catch (IllegalStateException expected) {
if (!"expected business failure".equals(expected.getMessage())) throw expected;
if (swallow) { events.add("swallowed"); return null; }
throw expected;
}
finally { events.add("swallow-out"); }
}
}
// Same advice body, ordered OUTSIDE the transaction advisor (500 < tx order 1000).
@Aspect @Order(500) static class SwallowOutsideAspect {
@Around("@annotation(study.spring.AopLab$TxMarked)")
Object swallow(ProceedingJoinPoint pjp) throws Throwable {
events.add("swallow-in");
try { return pjp.proceed(); }
catch (IllegalStateException expected) {
if (!"expected business failure".equals(expected.getMessage())) throw expected;
events.add("swallowed"); return null;
}
finally { events.add("swallow-out"); }
}
}
@Configuration(proxyBeanMethods = false)
@EnableAspectJAutoProxy(proxyTargetClass = true)
@EnableTransactionManagement(order = 1000, proxyTargetClass = true)
static class TxInsideAspectConfig {
@Bean JdbcDataSource dataSource() {
var ds = new JdbcDataSource(); ds.setURL("jdbc:h2:mem:u17-aop-tx-in;DB_CLOSE_DELAY=-1"); return ds;
}
@Bean JdbcTemplate jdbc(JdbcDataSource ds) { return new JdbcTemplate(ds); }
@Bean DataSourceTransactionManager transactionManager(JdbcDataSource ds) { return new DataSourceTransactionManager(ds); }
@Bean TxService service(JdbcTemplate jdbc) { return new TxService(jdbc); }
@Bean SwallowInsideAspect swallow() { return new SwallowInsideAspect(); }
}
@Configuration(proxyBeanMethods = false)
@EnableAspectJAutoProxy(proxyTargetClass = true)
@EnableTransactionManagement(order = 1000, proxyTargetClass = true)
static class TxOutsideAspectConfig {
@Bean JdbcDataSource dataSource() {
var ds = new JdbcDataSource(); ds.setURL("jdbc:h2:mem:u17-aop-tx-out;DB_CLOSE_DELAY=-1"); return ds;
}
@Bean JdbcTemplate jdbc(JdbcDataSource ds) { return new JdbcTemplate(ds); }
@Bean DataSourceTransactionManager transactionManager(JdbcDataSource ds) { return new DataSourceTransactionManager(ds); }
@Bean TxService service(JdbcTemplate jdbc) { return new TxService(jdbc); }
@Bean SwallowOutsideAspect swallow() { return new SwallowOutsideAspect(); }
}
public static class SelfWork {
// Static on purpose: writing an instance field through a CGLIB proxy writes the
// uninitialized proxy shell, not the target (a CglibAopProxy debug warning boundary).
static boolean viaProxy;
public void outer() { events.add("outer"); if (viaProxy) innerViaProxy(); else inner(); }
public void inner() { events.add("inner"); }
private void innerViaProxy() { ((SelfWork) AopContext.currentProxy()).inner(); }
}
@Aspect static class TraceAspect {
@Before("execution(* study.spring.AopLab$SelfWork.*(..))")
void trace(JoinPoint jp) { events.add("before:" + jp.getSignature().getName()); }
}
@Configuration(proxyBeanMethods = false)
@EnableAspectJAutoProxy // exposeProxy defaults to false
static class SelfPlainConfig {
@Bean SelfWork work() { return new SelfWork(); }
@Bean TraceAspect trace() { return new TraceAspect(); }
}
@Configuration(proxyBeanMethods = false)
@EnableAspectJAutoProxy(exposeProxy = true) // CGLIB: SelfWork exposes no user interface
static class SelfExposedConfig extends SelfPlainConfig {
@Bean SelfWork work() { return new SelfWork(); }
}
public static class FinalWork {
public String normal() { events.add("normal"); return "n"; }
public final String locked() { events.add("locked"); return "l"; }
}
public static final class FinalClass { public String any() { return "x"; } }
@Aspect static class FinalAspect {
@Before("execution(* study.spring.AopLab$FinalWork.*(..))")
void trace(JoinPoint jp) { events.add("before:" + jp.getSignature().getName()); }
}
@Configuration(proxyBeanMethods = false)
@EnableAspectJAutoProxy // FinalWork exposes no interface: CGLIB via hasNoUserSuppliedProxyInterfaces
static class FinalConfig {
@Bean FinalWork work() { return new FinalWork(); }
@Bean FinalAspect aspect() { return new FinalAspect(); }
}
@Aspect static class FinalClassAspect {
@Before("execution(* study.spring.AopLab$FinalClass.*(..))")
void trace(JoinPoint jp) { events.add("before:" + jp.getSignature().getName()); }
}
@Configuration(proxyBeanMethods = false)
@EnableAspectJAutoProxy(proxyTargetClass = true)
static class FinalClassConfig {
@Bean FinalClass work() { return new FinalClass(); }
@Bean FinalClassAspect aspect() { return new FinalClassAspect(); }
}
interface Audit { String audit(); }
static class DefaultAudit implements Audit {
@Override public String audit() { events.add("audit"); return "audit-default"; }
}
@Aspect static class IntroAspect {
// Official annotation-style form: the introduced interface is the field type (static field).
// Type patterns name nested classes with a DOT (source style): "AopLab.Echo" matches,
// while "AopLab$Echo" (accepted by execution signatures) does NOT match here.
@DeclareParents(value = "study.spring.AopLab.Echo", defaultImpl = DefaultAudit.class)
public static Audit audit;
}
@Configuration(proxyBeanMethods = false)
@EnableAspectJAutoProxy(proxyTargetClass = true)
static class IntroConfig {
@Bean Echo echo() { return new Echo(); }
@Bean IntroAspect aspect() { return new IntroAspect(); }
}
// Pure AspectJ load-time weaving: no Spring container, plain objects.
// LTW requires the aspect and its advice methods to be public (weaver error otherwise).
@Aspect public static class WovenAspect {
@Around("execution(* study.spring.AopLab$WovenTarget.*(..))")
public Object trace(ProceedingJoinPoint pjp) throws Throwable {
events.add("weave:" + pjp.getSignature().getName() + "-in");
try { return pjp.proceed(); } finally { events.add("weave:" + pjp.getSignature().getName() + "-out"); }
}
}
public static class WovenTarget {
public String outer() { events.add("outer"); return inner(); }
public String inner() { events.add("inner"); return "inner"; }
public final String locked() { events.add("locked"); return "locked"; }
}
public static void main(String[] args) {
String mode = args.length == 0 ? "advice-order" : args[0];
events.clear();
switch (mode) {
case "advice-order" -> adviceOrder();
case "binding" -> binding();
case "aspect-order" -> aspectOrder();
case "tx-layering" -> txLayering();
case "self-call" -> selfCall();
case "final-methods" -> finalMethods();
case "introduction" -> introduction();
case "weaving" -> weaving();
default -> throw new IllegalArgumentException(mode);
}
}
static void adviceOrder() {
var c = new AnnotationConfigApplicationContext(AopConfig.class);
try {
Work work = c.getBean(Work.class);
if (!AopUtils.isCglibProxy(work)) throw new AssertionError("no-interface target must use CGLIB: " + work.getClass());
work.job();
// AspectJ precedence @Around > @Before > @After > @AfterReturning > @AfterThrowing;
// @After runs with after-finally semantics after @AfterReturning on normal return.
// The two same-type @Before advices have undefined relative order: assert the backbone only.
assertAdviceBackbone(events, "target", "after-returning");
System.out.println("PASS aop advice-order sameTypeBeforeOrder=undefined(" + events.get(1) + "," + events.get(2) + ") normalSequence=backbone");
events.clear();
try {
work.failJob();
throw new AssertionError("expected target failure");
} catch (IllegalStateException expected) {
if (!"synthetic failure".equals(expected.getMessage())) throw expected;
assertAdviceBackbone(events, "target", "after-throwing");
}
System.out.println("PASS aop advice-order failureSequence=after-throwing-before-after");
} finally { c.close(); }
}
static void assertAdviceBackbone(List<String> observed, String targetEvent, String outcomeEvent) {
List<String> backbone = new ArrayList<>(observed);
backbone.removeIf(e -> e.equals("before") || e.equals("before-again"));
if (!backbone.equals(List.of("around-in", targetEvent, outcomeEvent, "after", "around-out")))
throw new AssertionError("advice backbone: " + observed);
if (!(observed.contains("before") && observed.contains("before-again")))
throw new AssertionError("same-type advices must both run: " + observed);
if (observed.indexOf(targetEvent) < observed.indexOf("before") || observed.indexOf(targetEvent) < observed.indexOf("before-again"))
throw new AssertionError("both @Before advices must precede the target: " + observed);
}
static void binding() {
var c = new AnnotationConfigApplicationContext(BindConfig.class);
try {
PayService pay = c.getBean(PayService.class);
if (!AopUtils.isAopProxy(pay)) throw new AssertionError("PayService must be proxied");
String result = pay.charge("acct-7", 500);
if (!result.equals("charged")) throw new AssertionError(result);
// args binding is evaluated when the chain reaches that advice: the @Around already
// replaced cents=1 via proceed(args), so the later @Before binds the MODIFIED value.
if (!events.contains("args:acct-7:1:charge")) throw new AssertionError("args binding must see proceed-modified args: " + events);
if (!events.contains("charge:acct-7:1")) throw new AssertionError("proceed-with-args must reach target: " + events);
if (!events.contains("returning:charged")) throw new AssertionError("returning binding: " + events);
events.clear();
if (!"paid".equals(pay.pay())) throw new AssertionError("pay");
if (!events.equals(List.of("marker:pay-audit", "pay"))) throw new AssertionError("@annotation binding: " + events);
System.out.println("PASS aop binding argsBind=acct-7:1 proceedArgs=1 bindingSeesModifiedArgs=true annotationBind=pay-audit");
} finally { c.close(); }
// Unbindable advice parameter: pointcut variables are filtered to the advice signature,
// so the advisor already fails the static applicability check. As the only advisor it
// leaves the bean completely unproxied — silent, no error at refresh or invocation.
var bad = new AnnotationConfigApplicationContext(BadBindConfig.class);
try {
PayService badPay = bad.getBean(PayService.class);
if (AopUtils.isAopProxy(badPay)) throw new AssertionError("unmatched advisor must leave the bean unproxied: " + badPay.getClass());
events.clear();
badPay.charge("acct-7", 500);
if (events.contains("never")) throw new AssertionError("unmatched advice must not run: " + events);
System.out.println("PASS aop binding badParameterSilentlySkipped=true beanUnproxied=true noFailure=true");
} finally { bad.close(); }
}
static void aspectOrder() {
var c = new AnnotationConfigApplicationContext(OrderedAspectsConfig.class);
try {
Echo echo = c.getBean(Echo.class);
echo.speak();
// Lower @Order value = higher precedence = outermost on the way in, last on the way out.
if (!events.equals(List.of("one-in", "two-in", "speak", "two-out", "one-out")))
throw new AssertionError("@Order nesting: " + events);
System.out.println("PASS aop aspect-order nesting=1-2-2-1");
} finally { c.close(); }
events.clear();
var u = new AnnotationConfigApplicationContext(UnorderedConfig.class);
try {
u.getBean(Echo.class).speak();
if (!events.contains("plain") || !events.contains("speak"))
throw new AssertionError("unordered aspect must still run: " + events);
// No @Order: relative precedence is undefined; this lab records the observed order only.
System.out.println("PASS aop aspect-order unorderedObserved=" + events + " contract=undefined");
} finally { u.close(); }
}
static void txLayering() {
// Case A: swallowing aspect ordered INSIDE the transaction advisor (2000 > 1000):
// the exception never reaches the transaction interceptor, so the transaction commits.
var inside = new AnnotationConfigApplicationContext(TxInsideAspectConfig.class);
try {
var jdbc = inside.getBean(JdbcTemplate.class);
jdbc.execute("drop table if exists item"); jdbc.execute("create table item(id int primary key)");
TxService service = inside.getBean(TxService.class);
if (!AopUtils.isCglibProxy(service)) throw new AssertionError("service must be proxied");
inside.getBean(SwallowInsideAspect.class).swallow = true;
service.record(); // no exception reaches the caller
var rows = jdbc.queryForList("select id from item", Integer.class);
if (!events.contains("swallowed") || !rows.equals(List.of(1)))
throw new AssertionError("swallow inside tx must commit: " + events + " rows=" + rows);
System.out.println("PASS aop tx-layering insideSwallow=committed rows=[1] caller=success");
} finally { inside.close(); }
events.clear();
// Case B: the same swallowing advice ordered OUTSIDE the transaction advisor (500 < 1000):
// the interceptor sees the exception and rolls back; the caller still sees success.
var outside = new AnnotationConfigApplicationContext(TxOutsideAspectConfig.class);
try {
var jdbc = outside.getBean(JdbcTemplate.class);
jdbc.execute("drop table if exists item"); jdbc.execute("create table item(id int primary key)");
TxService service = outside.getBean(TxService.class);
service.record();
var rows = jdbc.queryForList("select id from item", Integer.class);
if (!events.contains("swallowed") || !rows.isEmpty())
throw new AssertionError("swallow outside tx must roll back: " + events + " rows=" + rows);
System.out.println("PASS aop tx-layering outsideSwallow=rolledBack rows=[] caller=success");
} finally { outside.close(); }
events.clear();
// Case C (repair): an inside aspect that rethrows lets the interceptor roll back.
var repaired = new AnnotationConfigApplicationContext(TxInsideAspectConfig.class);
try {
var jdbc = repaired.getBean(JdbcTemplate.class);
jdbc.execute("drop table if exists item"); jdbc.execute("create table item(id int primary key)");
TxService service = repaired.getBean(TxService.class);
repaired.getBean(SwallowInsideAspect.class).swallow = false;
try {
service.record();
throw new AssertionError("expected business failure");
} catch (IllegalStateException expected) {
if (!"expected business failure".equals(expected.getMessage())) throw expected;
}
var rows = jdbc.queryForList("select id from item", Integer.class);
if (!rows.isEmpty()) throw new AssertionError("rethrow inside tx must roll back: " + events + " rows=" + rows);
System.out.println("PASS aop tx-layering rethrowInside=rolledBack rows=[] caller=exception");
} finally { repaired.close(); }
}
static void selfCall() {
SelfWork.viaProxy = false;
// Without exposeProxy: self-calls bypass the proxy; AopContext.currentProxy is unavailable.
var plain = new AnnotationConfigApplicationContext(SelfPlainConfig.class);
try {
SelfWork work = plain.getBean(SelfWork.class);
if (!AopUtils.isCglibProxy(work)) throw new AssertionError("no-interface target must use CGLIB");
work.outer();
if (!events.equals(List.of("before:outer", "outer", "inner")))
throw new AssertionError("self-call must bypass advice: " + events);
System.out.println("PASS aop self-call selfCallBypassesAdvice=true");
} finally { plain.close(); }
events.clear();
var plain2 = new AnnotationConfigApplicationContext(SelfPlainConfig.class);
try {
SelfWork work = plain2.getBean(SelfWork.class);
work.viaProxy = true;
try {
work.outer();
throw new AssertionError("expected currentProxy failure");
} catch (IllegalStateException expected) {
if (!expected.getMessage().startsWith("Cannot find current proxy")) throw expected;
System.out.println("PASS aop self-call currentProxyUnavailable=true messageNamesExposeProxy=true");
}
} finally { plain2.close(); }
events.clear();
// With exposeProxy=true: re-entering through the proxy applies the advice again.
var exposed = new AnnotationConfigApplicationContext(SelfExposedConfig.class);
try {
SelfWork work = exposed.getBean(SelfWork.class);
work.viaProxy = true;
work.outer();
if (!events.equals(List.of("before:outer", "outer", "before:inner", "inner")))
throw new AssertionError("currentProxy call must be advised: " + events);
System.out.println("PASS aop self-call exposeProxyFix=true beforeOuter+beforeInner=true");
} finally { exposed.close(); }
}
static void finalMethods() {
var c = new AnnotationConfigApplicationContext(FinalConfig.class);
try {
FinalWork work = c.getBean(FinalWork.class);
if (!AopUtils.isCglibProxy(work)) throw new AssertionError("expected CGLIB proxy: " + work.getClass().getName());
work.normal();
if (!events.equals(List.of("before:normal", "normal"))) throw new AssertionError("normal: " + events);
events.clear();
work.locked();
if (!events.equals(List.of("locked")))
throw new AssertionError("final method must not be advised via CGLIB: " + events);
System.out.println("PASS aop final-methods proxyClass=" + work.getClass().getName()
+ " normalAdvised=true finalNotAdvised=true");
} finally { c.close(); }
// A final class cannot get a CGLIB proxy at all: creation fails during refresh.
try {
var fc = new AnnotationConfigApplicationContext();
try {
fc.register(FinalClassConfig.class);
fc.refresh();
throw new AssertionError("expected final-class proxy failure");
} finally { fc.close(); }
} catch (Exception expected) {
var message = rootMessage(expected);
if (!message.contains("final")) throw new AssertionError("must name final class cause: " + message);
System.out.println("PASS aop final-methods finalClassFailure=" + expected.getClass().getSimpleName() + " namesFinal=true");
}
}
static void introduction() {
var c = new AnnotationConfigApplicationContext(IntroConfig.class);
try {
Echo echo = c.getBean(Echo.class);
if (!(echo instanceof Audit)) throw new AssertionError("introduction must add Audit: " + echo.getClass());
if (!echo.getClass().getName().contains("SpringCGLIB")) throw new AssertionError(echo.getClass().getName());
if (!"audit-default".equals(((Audit) echo).audit())) throw new AssertionError("mixin delegation");
Advised advised = (Advised) echo;
if (!advised.isProxyTargetClass() || advised.getTargetClass() != Echo.class)
throw new AssertionError("Advised surface");
int before = advised.getAdvisors().length;
org.aopalliance.intercept.MethodInterceptor dynamic = inv -> {
events.add("dyn-in"); return inv.proceed();
};
advised.addAdvice(0, dynamic);
events.clear();
echo.speak();
if (!events.equals(List.of("dyn-in", "speak"))) throw new AssertionError("runtime advisor: " + events);
if (advised.getAdvisors().length != before + 1) throw new AssertionError("advisor count");
advised.removeAdvice(dynamic);
events.clear();
echo.speak();
if (!events.equals(List.of("speak"))) throw new AssertionError("removed advisor: " + events);
System.out.println("PASS aop introduction mixinDelegates=true advisedAddRemove=true targetClass=Echo");
} finally { c.close(); }
}
static void weaving() {
// Pure AspectJ load-time weaving (aop.xml + -javaagent): no Spring proxy, plain object.
WovenTarget target = new WovenTarget();
target.outer();
// Weaving rewrites call sites: this.inner() is intercepted too, unlike proxy AOP.
if (!events.equals(List.of("weave:outer-in", "outer", "weave:inner-in", "inner",
"weave:inner-out", "weave:outer-out"))) throw new AssertionError("woven self-call: " + events);
events.clear();
target.locked();
// Weaving does not rely on overriding: final methods are woven as well.
if (!events.equals(List.of("weave:locked-in", "locked", "weave:locked-out")))
throw new AssertionError("woven final method: " + events);
System.out.println("PASS aop weaving selfCallWoven=true finalWoven=true proxyRequired=false");
}
static String rootMessage(Throwable t) {
Throwable cur = t;
while (cur.getCause() != null && cur.getCause() != cur) cur = cur.getCause();
return String.valueOf(cur.getMessage());
}
@Configuration(proxyBeanMethods = false) @EnableAspectJAutoProxy
static class AopConfig {
@Bean Work work() { return new Work(); }
@Bean OrderAspect aspect() { return new OrderAspect(); }
}
@Configuration(proxyBeanMethods = false) @EnableAspectJAutoProxy(proxyTargetClass = true)
static class BindConfig {
@Bean PayService pay() { return new PayService(); }
@Bean BindAspect aspect() { return new BindAspect(); }
}
@Configuration(proxyBeanMethods = false) @EnableAspectJAutoProxy(proxyTargetClass = true)
static class BadBindConfig {
@Bean PayService pay() { return new PayService(); }
@Bean BadBindAspect aspect() { return new BadBindAspect(); }
}
@Configuration(proxyBeanMethods = false) @EnableAspectJAutoProxy(proxyTargetClass = true)
static class OrderedAspectsConfig {
@Bean Echo echo() { return new Echo(); }
@Bean OrderedOneAspect one() { return new OrderedOneAspect(); }
@Bean OrderedTwoAspect two() { return new OrderedTwoAspect(); }
}
@Configuration(proxyBeanMethods = false) @EnableAspectJAutoProxy(proxyTargetClass = true)
static class UnorderedConfig {
@Bean Echo echo() { return new Echo(); }
@Bean UnorderedAspect plain() { return new UnorderedAspect(); }
}
}- advice-order:五种通知 backbone 与异常分支(同类型次序不断言);binding:args 改参可见性、proceed 改参、returning/@annotation 绑定、坏参数静默失配;aspect-order:@Order 嵌套与 undefined 对照。
- tx-layering:吞异常切面 order 2000/500 与 rethrow 三布局的 H2 数据行证据;self-call:绕过、currentProxy 未开启失败、exposeProxy 修复;final-methods:final 方法静默、final 类创建失败、代理类名。
- introduction:@DeclareParents 点号模式 + Advised 增删;weaving:无容器 LTW 对照(自调用与 final 均被增强)。
本批八模式已实跑通过(PASS 行见实验说明与证据 JSON)。XML <aop:config> 风格、perthis/pertarget 实例化模型、无 -parameters 编译的名字发现失败(按 bindArgumentsByName 摘录的 IllegalStateException 分支推演)、compile-time/编译后织入、原生镜像未实测,相关说明按参考文档/Javadoc/固定源码推导并标注,不冒充日志结果。
架构师推演:增强叠加预算与编织模型选择
假设订单域要叠加幂等、审计、限流与事务,五层增强。方案一全部注解切面靠 order 组链:接入快,但链长随需求增长,"谁看见异常"变成隐式契约,必须配一张 order 表与吞异常审查清单(本页 tx-layering 的三布局就是审查模板);方案二把幂等与审计做成显式编排的领域服务,只留限流和事务在代理层:链短、可读,但接口与代码更多。两个方案共用同一条审查底线:异常要么穿透到事务拦截器,要么吞掉的一方承担补偿责任。
编织模型的选择条件按上一节差异表判:需求只落在"容器 bean 方法执行"内,留在代理 AOP;出现字段级切点、非容器对象(domain 实体、第三方对象)或自调用无法拆分时,才评估引入 AspectJ 编织,并把构建链(agent/ajc/aop.xml)纳入交付物与评审范围。演进触发写成可检查条件:出现第二个需要自调用增强的场景、或审计要求覆盖字段写入,即升级编织;反之不要为了"更彻底的 AOP"预付构建链成本。
成本按结构计而不是拍时延数(本段为推演非实测):每次调用要穿越的通知数 = 链长,链长直接放大"异常路由核对"的评审工作量;编织把检查前移到构建期,运行时少一层反射链,但栈信息变为织入后形态,线上排障要先确认织入方式。恢复责任在两个方案里不同:代理层的补偿靠切面 rethrow 后的调用方协议;显式编排的补偿是代码里的明确分支。验证都必须给出组合矩阵——正常、目标抛错、内层转换、外层吞掉各走一遍数据行证据,才比"调一下 order"可信。可口述主线:"先证 advisor 进链,再证顺序与绑定,最后证异常路由与事务边界。"
闭卷验收
本页 8 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。