Java—AOP解析

介绍

Aspect Oriented Programming,即面向切面编程
OOP是面向对象编程,封装,多态,继承
AOP是一种新的编程模式,把系统看做多个对象的交互。AOP把系统分解为不同的关注点,称之为切面

实例

一个业务组件BookService,有创建,更新,删除几个操作,还需要安全检查,日志记录和事物处理

public class BookService {
    public void createBook(Book book) {
        securityCheck();
        Transaction tx = startTransaction();
        try {
            // 核心业务逻辑
            tx.commit();
        } catch (RuntimeException e) {
            tx.rollback();
            throw e;
        }
        log("created book: " + book);
    }
}

对于安全检查,日志,事务代码等会重复出目前每个业务方法中,OOP很难将这些四处分散的代码模块化
可以使用代理模式,将某个功能例如权限检查放入proxy中

public class SecurityCheckBookService implements BookService {
    private final BookService target;

    public SecurityCheckBookService(BookService target) {
        this.target = target;
    }

    public void createBook(Book book) {
        securityCheck();
        target.createBook(book);
    }

    public void updateBook(Book book) {
        securityCheck();
        target.updateBook(book);
    }

    public void deleteBook(Book book) {
        securityCheck();
        target.deleteBook(book);
    }

    private void securityCheck() {
        ...
    }
}

这种方式比较麻烦,要先抽取接口,针对每个方法实现proxy
将权限检查视作一种切面。日志,事物也视为切面,再以某种自动化的形式,将切面植入核心逻辑,实现proxy

  1. 核心逻辑,BookService
  2. 切面逻辑
  3. 权限检查Aspect
  4. 日志Aspect
  5. 事物Aspect

AOP原理

如何把切面植入到核心逻辑中,如果客户端获得了BookService引用,当调用bookService.createBook时,如何对方法进行拦截,并对拦截后进行安全检查,日志,事务,相当于完成所有业务功能
三种方式:

  1. 编译期:编译期间吧切面调用编译进入字节码,这种方式需要定义新的关键字并扩展编译器,AspectJ就扩展了Java编译器
  2. 类加载器:在目标类被装载到JVM时,需要一个特殊的类装载器,对目标字节码进行增强
  3. 运行期:目标对象和切面都是普通的java类,通过JVM动态代理或第三方库实现运行期动态植入
    Sprin的aop就是基于JVM的动态代理,由于JVM的动态代理必须实现接口,如果一个普通得类没有业务接口,就需要通过GCLIB或JAVAASSIST第三方库实现
    AOP本质上就是一个动态代理,把一些常用的功能从每个业务方法中抽离出来
    AOP对于解决特定问题,例如事务管理很管用,由于分散到各处的事务代码几乎是完全一样的,并且他们需要的参数(JDBC的Connenction)也是固定的,另一些特定问题,例如日志打印,常常需要捕获局部变量,但如果使用AOP实现日志,只能输出特定格式的日志,AOP必须适合特定的场景

概念

  • Aspect:切面 一个横跨多个核心逻辑功能,系统关注点
  • Joinpoint:连接点 定义在应用程序流程在何处的切面执行
  • Pointcut:切入点 一组连接的集合点
  • Advice:增强 特定连接点上执行的动作
  • Introduction:引介 一个已有的java对象动态的增加新的接口
  • weaving: 织如 将切面整合到程序的执行流程中
  • Interceptor:拦截器 实现增强方式
  • TargetObject:目标对象 真正执行业务的核心逻辑对象
  • AOP Proxy:AOP代理 客户端持有的增强后的对象引用

装配

准备给UserService的每个业务执行前添加日志,MailService每个业务执行前后添加日志,spring中,Maven引入

<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-aspects</artifactId>
    <version>${spring.version}</version>
</dependency>
定义日志切面
@Aspect
@Component
public class LoggingAspect {
    // 在执行UserService的每个方法前执行:
    @Before("execution(public * com.itranswarp.learnjava.service.UserService.*(..))")
    public void doAccessCheck() {
        System.err.println("[Before] do access check...");
    }

    // 在执行MailService的每个方法前后执行:
    @Around("execution(public * com.itranswarp.learnjava.service.MailService.*(..))")
    public Object doLogging(ProceedingJoinPoint pjp) throws Throwable {
        System.err.println("[Around] start " + pjp.getSignature());
        Object retVal = pjp.proceed();
        System.err.println("[Around] done " + pjp.getSignature());
        return retVal;
    }
}
doAccessCheck方法,定义了一个before朱姐,后面的字符串告知Aspectj在何处执行该方法,执行UserService每个public方法前执行doAccessCheck代码
doLogging定义了around注解,决定是否执行目标方法,因此在内部先打印日志,再调用函数,最后打印日之后返回结果
LoggingAspect类的声明处,除了用component表明本身是一个beab以外,再加上aspect注解,表明他的before标注的函数需要注入到UserService每个public方法执行前,around表明需要注入到mailservice每个public执行前后
再给配置类加上EnableAspectJAutoProxy
@Configuration
@ComponentScan
@EnableAspectJAutoProxy
public class AppConfig {
    ...
}
spring的IOC容器看到该注解,会自动查找带有aspect的bean,根据每个函数的before,around注解把aop注入到特定的bean中
[Before] do access check...
[Around] start void com.itranswarp.learnjava.service.MailService.sendRegistrationMail(User)
Welcome, test!
[Around] done void com.itranswarp.learnjava.service.MailService.sendRegistrationMail(User)
[Before] do access check...
[Around] start void com.itranswarp.learnjava.service.MailService.sendLoginMail(User)
Hi, Bob! You are logged in at 2020-02-14T23:13:52.167996+08:00[Asia/Shanghai]
[Around] done void com.itranswarp.learnjava.service.MailService.sendLoginMail(User)

AOP的原理就是编写一个子类,并持有原始实例的引用

public UserServiceAopProxy extends UserService {
    private UserService target;
    private LoggingAspect aspect;

    public UserServiceAopProxy(UserService target, LoggingAspect aspect) {
        this.target = target;
        this.aspect = aspect;
    }

    public User login(String email, String password) {
        // 先执行Aspect的代码:
        aspect.doAccessCheck();
        // 再执行UserService的逻辑:
        return target.login(email, password);
    }

    public User register(String email, String password, String name) {
        aspect.doAccessCheck();
        return target.register(email, password, name);
    }
    ...
}

spring容器启动时为我们家自动创建aspect的子类,取代了原始的userservice,打印从spring容器获取的userservice类型,类似UserServiceJava---AOP解析1f44e01c,是spring使用GCLib动态创建的子类,对调用方来说没有区别

使用步骤

虽然spring容器实现sop较为复杂,我们使用aop却超级简单,分为三步

  1. 定义执行方法,在方法上通过AspectJ注解告知spring应该在何处调用该方法
  2. 标记@Component和@Aspect
  3. 在@Configuration类上标注@EnableAspectAutoProxy

拦截器类型

  • @Before:拦截器先执行拦截代码,再执行目标代码,拦截器异常,目标代码不运行
  • @After:拦截器先执行目标代码,再执行拦截器代码,无论目标代码是否异常,都将执行拦截器代码
  • @AfterReturning: 和@After不同的是,只有当目标代码返回正常时,才执行拦截器代码
  • @AfterThrowning: 和@After不同的是,只有当目标代码抛出了异常时,才执行拦截器代码
  • @Around:能完全控制目标代码是否执行

注解装配

一个SecurityAspect

@Aspect
@Component
public class SecurityAspect {
    @Before("execution(public * com.itranswarp.learnjava.service.*.*(..))")
    public void check() {
        if (SecurityContext.getCurrentUser() == null) {
            throw new RuntimeException("check failed");
        }
    }
}
能实现无差别覆盖,某个包下面的所有bean的所有方法都会被这个check函数拦截
@Around("execution(public * update*(..))")
public Object doLogging(ProceedingJoinPoint pjp) throws Throwable {
    // 对update开头的方法切换数据源:
    String old = setCurrentDataSource("master");
    Object retVal = pjp.proceed();
    restoreCurrentDataSource(old);
    return retVal;
}
误伤范围更大,由于从方法前缀区分是否是数据库操作是超级不可取的
在使用AOP时,虽然spring容器可以把指定方法通过AOP规则装配到指定bean的指定函数前后,但是自动装配由于不恰当的范围容易导致以外的结果,许多不需要的AOP代理也被自动代理了,后续新增的bean由于不清楚规则,容易被自动装配
最好被装配的bean能够知道自己被安排了,spring提供的Transactional就是一个超级好的例子,如果我们希望在一个数据库事务中被调用,就标注Transactional
@Component
public class UserService {
    // 有事务:
    @Transactional
    public User createUser(String name) {
        ...
    }

    // 无事务:
    public boolean isValidName(String name) {
        ...
    }

    // 有事务:
    @Transactional
    public void updateUser(User user) {
        ...
    }
}
或者直接在类上注解,表明所有public都被安排了
@Component
@Transactional
public class UserService {
    ...
}
定义一个性能监控的注解
@Target(METHOD)
@Retention(RUNTIME)
public @interface MetricTime {
    String value();
}
在需要被监控的地方标识该注解
@Component
public class UserService {
    // 监控register()方法性能:
    @MetricTime("register")
    public User register(String email, String password, String name) {
        ...
    }
    ...
}
定义MetricAspect
@Aspect
@Component
public class MetricAspect {
    @Around("@annotation(metricTime)")
    public Object metric(ProceedingJoinPoint joinPoint, MetricTime metricTime) throws Throwable {
        String name = metricTime.value();
        long start = System.currentTimeMillis();
        try {
            return joinPoint.proceed();
        } finally {
            long t = System.currentTimeMillis() - start;
            // 写入日志或发送至JMX:
            System.err.println("[Metrics] " + name + ": " + t + "ms");
        }
    }
}
metric函数标注了Around表明符合目标方法是带有MetricTime注解的函数,由于metric()方法参数类型是MetricTime(注意参数名是metricTime不是MetricTime),我们通过它获取性能监控的名称。
有了@MetricTime注解,再配合MetricAspect,任何bean只要函数标注了MetricTime注解,就可以实现自动监控
Welcome, Bob!
[Metrics] register: 16ms

避坑

无论是使用Aspect语法,还是Annotation,使用AOP,实际上就是Spring自动为我们创建一个Proxy,使得调用方无感知的调用指定函数,但运行期间动态织入其他逻辑,本质上是一个代理模式
使用GClib来实现运行期间动态创建proxy

定义一个UserService的Bean
@Component
public class UserService {
    // 成员变量:
    public final ZoneId zoneId = ZoneId.systemDefault();

    // 构造方法:
    public UserService() {
        System.out.println("UserService(): init...");
        System.out.println("UserService(): zoneId = " + this.zoneId);
    }

    // public方法:
    public ZoneId getZoneId() {
        return zoneId;
    }

    // public final方法:
    public final ZoneId getFinalZoneId() {
        return zoneId;
    }
}
MailService,注入Userservice
@Component
public class MailService {
    @Autowired
    UserService userService;

    public String sendMail() {
        ZoneId zoneId = userService.zoneId;
        String dt = ZonedDateTime.now(zoneId).toString();
        return "Hello, it is " + dt;
    }
}
@Configuration
@ComponentScan
public class AppConfig {
    public static void main(String[] args) {
        ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
        MailService mailService = context.getBean(MailService.class);
        System.out.println(mailService.sendMail());
    }
}
UserService(): init...
UserService(): zoneId = Asia/Shanghai
Hello, it is 2020-04-12T10:23:22.917721+08:00[Asia/Shanghai]
输出正常
给UserService加上AOP支持,添加一个简单的LoggingAspect
@Aspect
@Component
public class LoggingAspect {
    @Before("execution(public * com..*.UserService.*(..))")
    public void doAccessCheck() {
        System.err.println("[Before] do access check...");
    }
}
并在AppConfig加上EnableAspectJAutoProxy,会得到空指针异常
Exception in thread "main" java.lang.NullPointerException: zone
    at java.base/java.util.Objects.requireNonNull(Objects.java:246)
    at java.base/java.time.Clock.system(Clock.java:203)
    at java.base/java.time.ZonedDateTime.now(ZonedDateTime.java:216)
    at com.itranswarp.learnjava.service.MailService.sendMail(MailService.java:19)
    at com.itranswarp.learnjava.AppConfig.main(AppConfig.java:21)

跟踪到是在 ZoneId zoneId = userService.zoneId;为null,但其是由final定义

缘由定位

  1. 正常创建一个UserService原始实例,通过反射调用构造函数实现,行为和预期一致
  2. 通过GCLIB创建UserService子类,引用原始的LoggingAspect

public UserService$$EnhancerBySpringCGLIB extends UserService {
    UserService target;
    LoggingAspect aspect;

    public UserService$$EnhancerBySpringCGLIB() {
    }

    public ZoneId getZoneId() {
        aspect.doAccessCheck();
        return target.getZoneId();
    }
}

spring创建的AOP代理,类名总是类似UserServiceJava---AOP解析1c76af9d,为了让调用方获得UserService引用,必须继承自UserService,代理类会覆盖所有public和protect函数,并在内部将调用委托给原始的UserService实例
这时出现了两个UserService实例,一个是代码中定义的原始实例
UserService original = new UserService();
一个是UserService$$EnhancerBySpringCGLIB

UserService$$EnhancerBySpringCGLIB proxy = new UserService$$EnhancerBySpringCGLIB();
proxy.target = original;
proxy.aspect = ...

当启用了AOP,从ApplicationContext获取到的UserService实例是proxy,注入到mailservice的userservice也是代理,最终proxy实例的成员变量,也就是继承userservice的zoneid是null
其中,

public class UserService {
    public final ZoneId zoneId = ZoneId.systemDefault();
    ...
}
在UserService$$EnhancerBySpringCGLIB中,并未执行,由于没必要初始化代理的成员变量,由于proxy的目的是代理方法,编译器实际的代码
public class UserService {
    public final ZoneId zoneId;
    public UserService() {
        super(); // 构造方法的第一行代码总是调用super()
        zoneId = ZoneId.systemDefault(); // 继续初始化成员变量
    }
}
不过,spring通过GCLib创建的UserService$$EnhancerBySpringCGLIB代理类的构造函数中,并未调用super,因此从父类继承的成员变量,包括final成员变量,都没有初始化
疑问:java规定任何类的构造函数,第一行必须调用super,没有编译器会自动加上,但是GCLib构造的proxy类,是直接生成字节码,并没有源码到编译这一步骤,直接构造字节码,在一个类的构造函数中就不必定非要调用super

Spring通过GCLib创建的代理类,不会初始化代理类自身继承的任何成员变量,包括final类型的成员变量

修复

只需要把直接访问的字段,改为通过函数访问

@Component
public class MailService {
    @Autowired
    UserService userService;

    public String sendMail() {
        // 不要直接访问UserService的字段:
        ZoneId zoneId = userService.getZoneId();
        ...
    }
}
无论注入的UserService是原始实例还是代理实例,getZoneId()都能正常工作,由于代理类会覆写getZoneId()方法,并将其委托给原始实例:
public UserService$$EnhancerBySpringCGLIB extends UserService {
    UserService target = ...
    ...

    public ZoneId getZoneId() {
        return target.getZoneId();
    }
}
注意到我们还给UserService添加了一个public+final的方法:
@Component
public class UserService {
    ...
    public final ZoneId getFinalZoneId() {
        return zoneId;
    }
}
如果在MailService中,调用的不是getZoneId(),而是getFinalZoneId(),又会出现NullPointerException,这是由于,代理类无法覆写final方法(这一点绕不过JVM的ClassLoader检查),该方法返回的是代理类的zoneId字段,即null。
实际上,如果我们加上日志,Spring在启动时会打印一个警告:

10:43:09.929 [main] DEBUG org.springframework.aop.framework.CglibAopProxy - Final method [public final java.time.ZoneId xxx.UserService.getFinalZoneId()] 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.
上面的日志大意就是,由于被代理的UserService有一个final方法getFinalZoneId(),这会导致其他Bean如果调用此方法,无法将其代理到真正的原始实例,从而可能发生NPE异常。

因此,正确使用AOP

  1. 访问被注入的bean时,总是调用方法而非直接访问字段
  2. 编写bean时,如果可能会被代理,就不要编写public final函数
© 版权声明
THE END
如果内容对您有所帮助,就支持一下吧!
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容