远离办公室的多人运动,看懂这篇Spring的IoC,AOP让你远离996

Posted 公众号-JavaEdge

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了远离办公室的多人运动,看懂这篇Spring的IoC,AOP让你远离996相关的知识,希望对你有一定的参考价值。

Spring最核心的就是IoC和AOP,它们的初衷都是解耦和扩展。

什么是 IoC?

一种设计思想,将设计好的对象交给Spring容器控制,而不是直接在对象内部控制。

为什么要让容器来管理对象呢?你这程序员咋就知道甩锅呢?

低级的码农,可能只是觉着使用IoC方便、就用来解耦的。这还远不是容器的益处。
以容器为依托来管理所有的框架、业务对象,我们可以做到:

  • 无侵入调整对象的关系
  • 无侵入地随时调整对象的属性
  • 实现对象的替换

这使得框架开发者在后续实现一些扩展就很容易。

什么是AOP?

AOP体现了高内聚、低耦合,在切面集中实现横切关注点(缓存、权限、日志等),然后通过切点配置把代码注入合适位置。

比如,期望找到一个合适地方把奶油注入蛋胚,应该如何指导机器人的程序完成操作呢?

  • 连接点(Join point)
    就是方法执行
  • 切点(Pointcut)
    Spring AOP默认使用AspectJ查询表达式,通过在连接点运行查询表达式来匹配切点
  • 增强(Advice)
    也叫作通知,定义了切入切点后增强的方式,包括前、后、环绕等。Spring AOP中,把增强定义为拦截器
  • 切面(Aspect)
    切面=切点+增强

单例的Bean如何注入Prototype的Bean?

如果要为单例的Bean注入Prototype的Bean,绝不是仅仅修改Scope属性这么简单。由于单例的Bean在容器启动时就会完成一次性初始化。最简单的解决方案是,把Prototype的Bean设置为通过代理注入,也就是设置proxyMode属性为TARGET_CLASS。

比如有如下抽象类,可以认为LearnService是有状态的,如果LearnService是单例的话,那必然会OOM

实际开发时,很多人不假思索把LearnGo和LearnJava类加上了**@Service**,让它们成为了Bean,也没有考虑到父类其实有状态

@Service
@Slf4j
public class LearnJava extends LearnService {

    @Override
    public void learn() {
        super.learn();
        log.info("java");
    }
}

@Service
@Slf4j
public class LearnGo extends LearnService {

    @Override
    public void learn() {
        super.learn();
        log.info("go");
    }
}

因为很多人认为 @Service 意义就在于,能通过**@Autowired**让Spring自动注入对象,就比如可以直接使用注入的List获取到LearnJava和LearnGo,而没想过类的生命周期:

@Autowired
List<LearnService> learnServiceList;

@GetMapping("test")
public void test() {
    log.info("====================");
    learnServiceList.forEach(LearnService::learn);
}

当年开发父类的人将父类设计为有状态的,但并不关心子类是怎么使用父类的;而开发子类的同学,没多想就直接添加 @Service,让类成为Bean,通过 @Autowired注入这个服务。但这样设置后,有状态的父类就可能产生内存泄露或线程安全问题。

那怎么做才能避免问题产生呢?

在为类添加 @Service把类型交由容器管理前,首先考量类是否有状态,然后为Bean设置合适的Scope。比如该案例,就为我们的两个类添加 @Scope即可,设置了PROTOTYPE生命周期,即多例:

但这样还是会内存泄漏,说明修改是无效的。

从日志可以看到,第一次调用和第二次调用的时候,SayBye对象都是4c0bfe9e,SayHello也是一样的问题。从日志第7到10行还可以看到,第二次调用后List的元素个数变为了2,说明父类SayService维护的List在不断增长,不断调用必然出现OOM:

[17:12:45.798] [http-nio-30666-exec-1] [INFO ] [c.j.s.beansingletonandorder.LearnService:26  ] - I'm com.javaedge.springpart1.beansingletonandorder.LearnGo@4a5fe6a2 size:1
[17:12:45.798] [http-nio-30666-exec-1] [INFO ] [c.j.s.beansingletonandorder.LearnGo:20  ] - go
[17:12:45.839] [http-nio-30666-exec-1] [INFO ] [c.j.s.beansingletonandorder.LearnService:26  ] - I'm com.javaedge.springpart1.beansingletonandorder.LearnJava@6cb46b0 size:1
[17:12:45.840] [http-nio-30666-exec-1] [INFO ] [c.j.s.beansingletonandorder.LearnJava:17  ] - java
[17:12:57.380] [http-nio-30666-exec-2] [INFO ] [c.j.s.b.BeanSingletonAndOrderController:25  ] - ====================
[17:12:57.416] [http-nio-30666-exec-2] [INFO ] [c.j.s.beansingletonandorder.LearnService:26  ] - I'm com.javaedge.springpart1.beansingletonandorder.LearnGo@b859c00 size:2
[17:12:57.416] [http-nio-30666-exec-2] [INFO ] [c.j.s.beansingletonandorder.LearnGo:20  ] - go
[17:12:57.452] [http-nio-30666-exec-2] [INFO ] [c.j.s.beansingletonandorder.LearnService:26  ] - I'm com.javaedge.springpart1.beansingletonandorder.LearnJava@5426300 size:2
[17:12:57.452] [http-nio-30666-exec-2] [INFO ] [c.j.s.beansingletonandorder.LearnJava:17  ] - java

所以问题就是单例Bean如何注入Prototype Bean?
Controller标记了 @RestController
@RestController = @Controller + @ResponseBody,又因为 @Controller标记了 @Component元注解,所以 @RestController也是一个Spring Bean。

Bean默认是单例的,所以单例的Controller注入的Service也是一次性创建的,即使Service本身标识了prototype的范围,也不会起作用。

修复方案就是让Service以代理方式注入。这样虽然Controller是单例的,但每次都能从代理获取Service。这样一来,prototype范围的配置才能真正生效:

调试发现,注入的Service都是Spring生成的代理类:

如果不希望走代理,还有一种方案,每次直接从ApplicationContext中获取Bean:

这里Spring注入的LearnService的List,第一个元素是LearnGo,第二个元素是LearnJava。但我们更希望的是先执行Java再执行Go,所以注入一个List Bean时, 还要能控制Bean的顺序。

一般来说,顺序如何都无所谓,但对AOP,顺序可能会引发致命问题。

监控切面因为顺序问题导致Spring事务失效

通过AOP实现一个整合日志记录、异常处理和方法耗时打点为一体的统一切面。但后来发现,使用了AOP切面后,这个应用的声明式事务处理居然都是无效的。

现在分析AOP实现的监控组件和事务失效有什么关系,以及通过AOP实现监控组件是否还有其他坑。

先定义一个自定义注解Metrics,打上该注解的方法可以实现各种监控功能:

然后,实现一个切面完成Metrics注解提供的功能。这个切面可以实现标记了@RestController注解的Web控制器的自动切入,如果还需要对更多Bean进行切入的话,再自行标记@Metrics注解。

测试MetricsAspect的功能。

Service中实现创建用户的时候做了事务处理,当用户名包含test字样时会抛出异常,导致事务回滚。为Service中的createUser添加@Metrics注解。
还可以手动为类或方法添加@Metrics注解,实现Controller之外的其他组件的自动监控。

@Slf4j
@RestController //自动进行监控
@RequestMapping("metricstest")
public class MetricsController {
    @Autowired
    private UserService userService;
    @GetMapping("transaction")
    public int transaction(@RequestParam("name") String name) {
        try {
            userService.createUser(new UserEntity(name));
        } catch (Exception ex) {
            log.error("create user failed because {}", ex.getMessage());
        }
        return userService.getUserCount(name);
    }
}

@Service
@Slf4j
public class UserService {
    @Autowired
    private UserRepository userRepository;
    @Transactional
    @Metrics //启用方法监控
    public void createUser(UserEntity entity) {
        userRepository.save(entity);
        if (entity.getName().contains("test"))
            throw new RuntimeException("invalid username!");
    }

    public int getUserCount(String name) {
        return userRepository.findByName(name).size();
    }
}

@Repository
public interface UserRepository extends JpaRepository<UserEntity, Long> {
    List<UserEntity> findByName(String name);
}

使用用户名“test”测试一下注册功能,自行测试可以观察到日志中打出了整个调用的出入参、方法耗时:

但之后性能分析觉得默认的 @Metrics配置不太好,优化点:

  • Controller的自动打点,不要自动记录入参和出参日志,避免日志量过大
  • Service中的方法,最好可以自动捕获异常

优化调整:

  • MetricsController手动添加 @Metrics,设置logParameters和logReturn为false

  • Service中的createUser方法的@Metrics注解,设置了ignoreException属性为true

可是实际上线发现日志量并没有减少,而且事务回滚还失效了,从输出看到最后查询到了名为test的用户。

执行Service的createUser方法时,Spring 的 TransactionAspectSupport并没有捕获到异常,所以自然无法回滚事务。因为异常被MetricsAspect吞了。

切面本身是一个Bean,Spring对不同切面增强的执行顺序是由Bean优先级决定的,具体规则是:

  • 入操作(Around(连接点执行前)、Before),切面优先级越高,越先执行
    一个切面的入操作执行完,才轮到下一切面,所有切面入操作执行完,才开始执行连接点(方法)
  • 出操作(Around(连接点执行后)、After、AfterReturning、AfterThrowing)
    切面优先级越低,越先执行。一个切面的出操作执行完,才轮到下一切面,直到返回到调用点。
  • 同一切面的Around比After、Before先执行

对于Bean可以通过 @Order 设置优先级:默认情况下Bean的优先级为最低优先级,其值是Integer的最大值。值越大优先级越低。

增强的执行顺序

新建一个TestAspectWithOrder10切面,通过 @Order注解设置优先级为10,在内部定义@Before、@After、@Around三类增强,做简单的日志输出,切点是TestController所有方法;

然后再定义一个类似的TestAspectWithOrder20切面,设置优先级为20:

调用TestController的方法后,通过日志输出可以看到,增强执行顺序符合切面执行顺序的三个规则:

TestAspectWithOrder10 @Around before
TestAspectWithOrder10 @Before
TestAspectWithOrder20 @Around before
TestAspectWithOrder20 @Before
TestAspectWithOrder20 @Around after
TestAspectWithOrder20 @After
TestAspectWithOrder10 @Around after
TestAspectWithOrder10 @After

Spring的事务管理同样基于AOP,默认,优先级最低的,会先执行出操作,但自定义切面MetricsAspect默认情况下也是最低优先级,这时就会产生问题:若出操作先执行捕获了异常,则Spring事务就会因为无法catch异常而无法回滚。

所以要指定MetricsAspect的优先级,可设置为最高优先级,即最先执行入操作最后执行出操作:

切入的连接点是方法,注解定义在类上是无法直接从方法上获取到注解的。所以要改为优先从方法获取,若方法上获取不到再从类获取,若还是获取不到则使用默认注解:

Metrics metrics = signature.getMethod().getAnnotation(Metrics.class);
if (metrics == null) {
    metrics = signature.getMethod().getDeclaringClass().getAnnotation(Metrics.class);
}

修正完后,事务就可以正常回滚了,并且Controller的监控日志也不再出现入参、出参。

监控平台如果想生产可用,需修改:

  • 日志打点,改为对接Metrics监控系统
  • 各种监控开关,从注解属性获取改为通过配置中心实时获取

以上是关于远离办公室的多人运动,看懂这篇Spring的IoC,AOP让你远离996的主要内容,如果未能解决你的问题,请参考以下文章

splay:优雅的区间暴力!

看懂这3点,帮你认识抖音“带货”的本质

看懂这6张图,理解JVM内存布局就没问题了!

入门JavaScript?看懂这篇文章就够了!——Web前端系列学习笔记

入门JavaScript?看懂这篇文章就够了!——Web前端系列学习笔记

看懂这5幅图,研发效能分析和改进就容易了