【SpringBoot 基础系列】接口上注解 AOP 拦截不到场景兼容实例演示

image

【SpringBoot 基础系列】接口上注解 AOP 拦截不到场景兼容

在 Java 的开发过程中,面向接口的编程可能是大家的常态,切面也是各位大佬使用 Spring 时,或多或少会使用的一项基本技能;结果这两个碰到一起,有意思的事情就发生了,接口方法上添加注解,面向注解的切面拦截,居然不生效

这就有点奇怪了啊,最开始遇到这个问题时,表示难以相信;事务注解也挺多是写在接口上的,好像也没有遇到这个问题(难道是也不生效,只是自己没有关注到?)

接下来我们好好瞅瞅,这到底是怎么个情况

I. 场景复现

这个场景复现相对而言比较简单了,一个接口,一个实现类;一个注解,一个切面完事

1. 项目环境

采用SpringBoot 2.2.1.RELEASE + IDEA + maven 进行开发

添加 aop 依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

2. 复现 case

声明一个注解

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AnoDot {
}

拦截切面,下面这段代码来自之前分享的博文 【基础系列】AOP 实现一个日志插件(应用篇)

@Aspect
@Component
public class LogAspect {
    private static final String SPLIT_SYMBOL = "|";


    @Pointcut("execution(public * com.git.hui.boot.aop.demo.*.*(..)) || @annotation(AnoDot)")
    public void pointcut() {
    }

    @Around(value = "pointcut()")
    public Object doAround(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
        Object res = null;
        String req = null;
        long start = System.currentTimeMillis();
        try {
            req = buildReqLog(proceedingJoinPoint);
            res = proceedingJoinPoint.proceed();
            return res;
        } catch (Throwable e) {
            res = "Un-Expect-Error";
            throw e;
        } finally {
            long end = System.currentTimeMillis();
            System.out.println(req + "" + JSON.toJSONString(res) + SPLIT_SYMBOL + (end - start));
        }
    }


    private String buildReqLog(ProceedingJoinPoint joinPoint) {
        // 目标对象
        Object target = joinPoint.getTarget();
        // 执行的方法
        Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
        // 请求参数
        Object[] args = joinPoint.getArgs();

        StringBuilder builder = new StringBuilder(target.getClass().getName());
        builder.append(SPLIT_SYMBOL).append(method.getName()).append(SPLIT_SYMBOL);
        for (Object arg : args) {
            builder.append(JSON.toJSONString(arg)).append(",");
        }
        return builder.substring(0, builder.length() - 1) + SPLIT_SYMBOL;
    }
}

然后定义一个接口与实现类,注意下面的两个方法,一个注解在接口上,一个注解在实现类上

public interface BaseApi {
    @AnoDot
    String print(String obj);

    String print2(String obj);
}

@Component
public class BaseApiImpl implements BaseApi {
    @Override
    public String print(String obj) {
        System.out.println("ano in interface:" + obj);
        return "return:" + obj;
    }

    @AnoDot
    @Override
    public String print2(String obj) {
        System.out.println("ano in impl:" + obj);
        return "return:" + obj;
    }
}

测试 case

@SpringBootApplication
public class Application {

    public Application(BaseApi baseApi) {
        System.out.println(baseApi.print("hello world"));
        System.out.println("-----------");
        System.out.println(baseApi.print2("hello world"));
    }

    public static void main(String[] args) {
        SpringApplication.run(Application.class);
    }
}

执行后输出结果如下(有图有真相,别说我骗你 🙃)

image

3. 事务注解测试

上面这个不生效,那我们通常写在接口上的事务注解,会生效么?

添加 mysql 操作的依赖

<dependencies>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-jdbc</artifactId>
    </dependency>
</dependencies>

数据库配置 application.properties

## DataSource
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/story?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=GMT%2b8
spring.datasource.username=root
spring.datasource.password=

接下来就是我们的接口定义与实现

public interface TransApi {
    @Transactional(rollbackFor = Exception.class)
    boolean update(int id);
}

@Service
public class TransApiImpl implements TransApi {
    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Override
    public boolean update(int id) {
        String sql = "replace into money (id, name, money) values (" + id + ", '事务测试', 200)";
        jdbcTemplate.execute(sql);

        Object ans = jdbcTemplate.queryForMap("select * from money where id = 111");
        System.out.println(ans);

        throw new RuntimeException("事务回滚");
    }
}

注意上面的 update 方法,事务注解在接口上,接下来我们需要确认调用之后,是否会回滚

@SpringBootApplication
public class Application {
    public Application(TransApiImpl transApi, JdbcTemplate jdbcTemplate) {
        try {
            transApi.update(111);
        } catch (Exception e) {
            System.out.println(e.getMessage());
        }

        System.out.println(jdbcTemplate.queryForList("select * from money where id=111"));
    }

    public static void main(String[] args) {
        SpringApplication.run(Application.class);
    }
}
image

回滚了,有木有!!!

果然是没有问题的,吓得我一身冷汗,这要是有问题,那就...(不敢想不敢想)

所以问题来了,为啥第一种方式不生效呢???

II. 接口注解切面拦截实现

暂且按下探寻究竟的欲望,先看下如果想让我们可以拦截接口上的注解,可以怎么做呢?

既然拦截不上,多半是因为子类没有继承父类的注解,所以在进行切点匹配时,匹配不到;既然如此,那就让它在匹配时,找下父类看有没有对应的注解

1. 自定义 Pointcut

虽说是自定义,但也没有要求我们直接实现这个接口,我们选择StaticMethodMatcherPointcut来补全逻辑

import org.springframework.core.annotation.AnnotatedElementUtils;

public static class LogPointCut extends StaticMethodMatcherPointcut {

    @SneakyThrows
    @Override
    public boolean matches(Method method, Class<?> aClass) {
        // 直接使用spring工具包,来获取method上的注解(会找父类上的注解)
        return AnnotatedElementUtils.hasAnnotation(method, AnoDot.class);
    }
}

接下来我们采用声明式来实现切面逻辑

2. 自定义 Advice

这个 advice 就是我们需要执行的切面逻辑,和上面的日志输出差不多,区别在于参数不同

自定义 advice 实现自接口MethodInterceptor,顶层接口是Advice

public static class LogAdvice implements MethodInterceptor {
    private static final String SPLIT_SYMBOL = "|";

    @Override
    public Object invoke(MethodInvocation methodInvocation) throws Throwable {
        Object res = null;
        String req = null;
        long start = System.currentTimeMillis();
        try {
            req = buildReqLog(methodInvocation);
            res = methodInvocation.proceed();
            return res;
        } catch (Throwable e) {
            res = "Un-Expect-Error";
            throw e;
        } finally {
            long end = System.currentTimeMillis();
            System.out.println("ExtendLogAspect:" + req + "" + JSON.toJSONString(res) + SPLIT_SYMBOL + (end - start));
        }
    }


    private String buildReqLog(MethodInvocation joinPoint) {
        // 目标对象
        Object target = joinPoint.getThis();
        // 执行的方法
        Method method = joinPoint.getMethod();
        // 请求参数
        Object[] args = joinPoint.getArguments();

        StringBuilder builder = new StringBuilder(target.getClass().getName());
        builder.append(SPLIT_SYMBOL).append(method.getName()).append(SPLIT_SYMBOL);
        for (Object arg : args) {
            builder.append(JSON.toJSONString(arg)).append(",");
        }
        return builder.substring(0, builder.length() - 1) + SPLIT_SYMBOL;
    }
}

3. 自定义 Advisor

将上面自定义的切点 pointcut 与通知 advice 整合,实现我们的切面

public static class LogAdvisor extends AbstractBeanFactoryPointcutAdvisor {
    @Setter
    private Pointcut logPointCut;

    @Override
    public Pointcut getPointcut() {
        return logPointCut;
    }
}

4. 最后注册切面

说是注册,实际上就是声明为 bean,丢到 spring 容器中而已

@Bean
public LogAdvisor init() {
    LogAdvisor logAdvisor = new LogAdvisor();
    // 自定义实现姿势
    logAdvisor.setLogPointCut(new LogPointCut());
    logAdvisor.setAdvice(new LogAdvice());
    return logAdvisor;
}

然后再次执行上面的测试用例,输出如下

image

接口上的注解也被拦截了,但是最后一个耗时的输出,有点夸张了啊,采用上面这种方式,这个耗时有点夸张了啊,生产环境这么一搞,岂不是分分钟卷铺盖的节奏

  • 可以借助 StopWatch 来查看到底是哪里的开销增加了这么多 (关于 StopWatch 的使用,下篇介绍)
  • 单次执行的统计偏差问题,将上面的调用,执行一百遍之后,再看耗时,趋于平衡,如下图
image

5. 小结

到这里,我们实现了接口上注解的拦截,虽说解决了我们的需求,但是疑惑的地方依然没有答案

  • 为啥接口上的注解拦截不到 ?
  • 为啥事务注解,放在接口上可以生效,事务注解的实现机制是怎样的?
  • 自定义的切点,可以配合我们的注解来玩么?
  • 为什么首次执行时,耗时比较多;多次执行之后,则耗时趋于正常?

上面这几个问题,毫无意外,我也没有确切的答案,待我研究一番,后续再来分享

III. 不能错过的源码和相关知识点

0. 项目

AOP 系列博文

1. 一灰灰 Blog

尽信书则不如,以上内容,纯属一家之言,因个人能力有限,难免有疏漏和错误之处,如发现 bug 或者有更好的建议,欢迎批评指正,不吝感激

下面一灰灰的个人博客,记录所有学习和工作中的博文,欢迎大家前去逛逛

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,125评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,293评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,054评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,077评论 1 291
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,096评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,062评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,988评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,817评论 0 273
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,266评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,486评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,646评论 1 347
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,375评论 5 342
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,974评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,621评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,796评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,642评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,538评论 2 352

推荐阅读更多精彩内容