R02-后端项目基础设置


1. 介绍

这一节我们来封装一些通用对象,比如统一的返回对象,统一自定义异常处理。这两个东西并不属于业务需求,但这些文件如果随意放置也不方便管理,我们平常都是用文件夹来管理文件,这里亦如此,只不过在这里叫做 “包(Package)”。所以我就在项目中新建一个 common 包,在里面放置一些公共的东西,在这下面新建一个 api 包,比如我们的统一返回对象类就可以放在这里,然后再新建一个 exception 包,用来统一管理异常类。

在这里插入图片描述


2. 封装统一返回对象

为什么需要统一返回对象呢?想想我们之前开发了一个接口,前端调用就返回了 “test success!!” 的字符串。我们应该想到,在实际开发中后端处理和返回的可不仅仅只是一个字符串,而是一堆复杂的数据,各种各样的数据应该要进行统一管理,这样更方便前端进行数据处理。

你想想前端收到的是一个冷冰冰的数据,但如果后端出现了问题前端如何能做出判断呢,所以我们可以在返回数据的时候加上一些描述,来记录数据的状态,常见的就是三要素(状态码、提示信息和数据),不同的状态码提示的信息不同,我们也能更快的定位到问题所在。

404 (Not Found:服务器无法根据客户端的请求找到网页资源)、500 (Internal Server Error:服务器内部错误,无法完成请求)。

当然除了这些外我们还可以自定义一些状态码来体现具体的模块,系统模块出现的异常可以以 10 开头,比如 1000、1001等;商品模块出现的异常可以以 11 开头,比如 1100、1101 等;这样根据状态码就能更好定位问题了。既然要给每一个接口的返回都加上一个状态码等信息,那么索性咱封装一个统一的类,把这些基本信息都定义好,直接把数据丢进去就可以了。

在这里插入图片描述

common/api 包下新建统一返回对象类 R.java

序列化:实现序列化接口 Serializable 可以将对象转换为字节流,这样它就可以在网络上进行传输或保存到文件中。与之对应的反序列化就是将字节流还原为对象的过程。如果这段数据是需要发送给前端,需要经过网络传输,在这种情况下,需要确保其能够序列化。

R.java 代码内容如下:

package org.example.myspringboot.common.api;

import java.io.Serial;
import java.io.Serializable;

/**
 * 统一响应对象
 */
public class R<T> implements Serializable {

    @Serial
    private static final long serialVersionUID = -6280365250111439360L;
    
    /**
     * 状态码
     */
    private long code;

    /**
     * 提示消息
     */
    private String msg;

    /**
     * 数据封装
     */
    private T data;

    /**
     * 空构造器
     */
    public R() {}

    /**
     * 全参构造器
     *
     * @param code 状态码
     * @param msg 提示消息
     * @param data 数据
     */
    public R(long code, String msg, T data) {
        this.code = code;
        this.msg = msg;
        this.data = data;
    }

    /**
     * 成功返回结果
     *
     * @param data 数据
     */
    public static <T> R<T> success(T data) {
        return new R<T>(200, null, data);
    }

    /**
     * 成功返回结果
     *
     * @param data 数据
     * @param msg 提示消息
     */
    public static <T> R<T> success(T data, String msg) {
        return new R<T>(200, msg, data);
    }

    /**
     * 失败返回结果
     */
    public static <T> R<T> fail() {
        return new R<T>(500, null, null);
    }

    /**
     * 失败返回结果
     * 
     * @param code 状态码
     */
    public static <T> R<T> fail(long code) {
        return new R<T>(code, null, null);
    }

    /**
     * 失败返回结果
     * 
     * @param msg 提示消息
     */
    public static <T> R<T> fail(String msg) {
        return new R<T>(500, msg, null);
    }

    /**
     * 失败返回结果
     * 
     * @param code 状态码
     * @param msg 提示消息
     */
    public static <T> R<T> fail(long code, String msg) {
        return new R<T>(code, msg, null);
    }

    /* 以下是 getter / setter 方法 */
    public long getCode() {
        return code;
    }

    public void setCode(long code) {
        this.code = code;
    }

    public String getMsg() {
        return msg;
    }

    public void setMsg(String msg) {
        this.msg = msg;
    }

    public T getData() {
        return data;
    }

    public void setData(T data) {
        this.data = data;
    }
}

如何生成以上代码中的 serialVersionUID 请参考:

我们可以发现以上代码中返回的状态码是写死的,如 200、500,这样的数字遍布在项目中就很不方便管理,所以我们应该把这些状态码提出来放到一个枚举类中统一管理,后续如果需要再加其他状态码只需要在这个枚举类中添加即可。然后记得将上面代码中的 200 的地方替换为从枚举类中获取的 ApiResultCode.SUCCESS.getCode(),500 的地方替换为 ApiResultCode.FAILED.getCode()

package org.example.myspringboot.common.api;

/**
 * 返回结果状态码枚举
 */
public enum ApiResultCode {
    
    SUCCESS(200, "操作成功"),
    FAILED(500, "操作失败");
    
    /**
     * 状态码
     */
    private final long code;

    /**
     * 提示消息
     */
    private final String message;
    
    private ApiResultCode(long code, String message) {
        this.code = code;
        this.message = message;
    }
    
    public long getCode() {
        return code;
    }
    
    public String getMessage() {
        return message;
    }
}

测试结果如下:

在这里插入图片描述


3. 整合 Lombok

Lombok 是一个 Java 类库,可以以简单的注解的形式来简化代码。你可以看到前面的统一返回类(R.java)中,每个字段都对应了两个基本的方法 (getter, setter),一个实体类就包含有很多字段,每一个字段都去加两个方法就很麻烦,虽然 IDEA 也可以自动生成,但是这样的类还是感觉有点冗杂了。而 Lombok 就可以很优雅的解决这个问题,只需要在实体类上加上一个 @Data 注解,那么这个类便有 getter、setter、equal、hashcode 和 toString 等方法。

maven 中央仓库中搜索 Lombok,然后选择一个使用人数比较多的版本。当然有的依赖是需要结合自己的 Spring Boot 版本,太高或太低都是不行的哦。将 maven 依赖配置复制到项目的 pom.xml 文件的 <dependencies></dependencies> 标签内;最后记得刷新依赖。

maven 仓库地址: maven 中央仓库

引入依赖:

<!-- https://mvnrepository.com/artifact/org.projectlombok/lombok -->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.24</version>
</dependency>

在这里插入图片描述

安装 Lombok 插件:打开 File -> Setting -> Plugins 搜索 lombok 插件并安装,有的 IDEA 版本可能需要重启。

在这里插入图片描述


4. 整合 Hutool 工具类库

引入依赖:

<!-- https://mvnrepository.com/artifact/cn.hutool/hutool-all -->
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.8.25</version>
</dependency>

Hutool 是一个小而全的 Java 工具类库,通过静态方法封装,提供了很多方便且实用的方法,提高工作效率。有时间和日期、IO 流相关的和其他工具类。

这里说一下我之前在项目中踩过的坑:调用一个对象的方法前,要判断这个对象是否有可能为 null,调用一个为 null 的对象的方法是会报错的。

比如平时判断字符串 str 是否为空有如下写法。

if (ex.getModule() != null && !ex.getModule().isBlank()) {
    String message = String.format("%s : %s", ex.getModule(), ex.getMessage());
}

如果直接使用 str.isBlank() ,那么当 strnull 时就会报错,而使用 Hutool 工具类无需再考虑 str 是否为 null 的问题,因为其内部做了判断。 StrUtil.isBlank(str) 或者 StrUtil.isNotBlank(str)

if (StrUtil.isNotBlank(ex.getModule())) {
	String message = String.format("%s : %s", ex.getModule(), ex.getMessage());
}

5. 自定义异常类

我们在 common/exception 包下新建一个 ApiException.java 类,可以根据自己项目的业务模块来抛出自定义异常。自定义异常类可分为编译期异常(自定义异常类并继承 Exception 类)和运行期异常(自定义异常类并继承 RuntimeException 类)。

编译时期异常就是在编译阶段就需要进行处理的异常,否则编译不通过(如:日期解析异常),也就是如果方法内部出现了编译期异常,那么就必须要对这个异常进行处理,要么 throws 向上抛出,要么try...catch 捕获并处理掉。

运行时异常不要求必须处理该异常,编译阶段不报错,是在程序运行时出现的异常。我们定义的异常类为运行时期异常,即继承 RuntimeException 类。

代码中的 args 字段用于传入一些自定义参数,比如 “长度必须在 min 到 max 之间”、“密码输出错误 count 次”,这里的 min、max、count 就可以通过参数传递。使得异常信息更加灵活。

package org.example.myspringboot.common.exception;

import lombok.Getter;
import org.example.myspringboot.utils.SpringUtils;
import org.example.myspringboot.common.api.ApiResultCode;
import org.springframework.context.MessageSource;
import org.springframework.context.i18n.LocaleContextHolder;
import java.io.Serial;

/**
 * 自定义统一异常类
 */
@Getter
public class ApiException extends RuntimeException {

    @Serial
    private static final long serialVersionUID = 1L;

    /**
     * 模块
     */
    private final String module;

    /**
     * 错误码
     */
    private final Long errorCode;

    /**
     * 错误消息提示
     */
    private final String errorMsg;

    /**
     * 参数
     */
    private final Object[] args;

    /**
     * 构造器
     *
     * @param module 模块名
     * @param errorCode 错误码
     */
    public ApiException(String module, Long errorCode, String errorMsg, Object[] args) {
        this.module = module;
        this.errorCode = errorCode;
        this.errorMsg = errorMsg;
        this.args = args;
    }

    public ApiException(String module, long errorCode, Object[] args) {
        this(module, errorCode, null, args);
    }

    public ApiException(String module, String errorMsg) {
        this(module, ApiResultCode.FAILED.getCode(), errorMsg, null);
    }

    public ApiException(String module, Long errorCode) {
        this(module, errorCode, null, null);
    }

    public ApiException(Long errorCode, Object[] args) {
        this(null, errorCode, null, args);
    }

    public ApiException(String errorMsg) {
        this(null, ApiResultCode.FAILED.getCode(), errorMsg, null);
    }
}

现在就可以通过 throw new ApiException([参数]) 来抛出自定义异常。但是异常抛出之后呢,又如何将这些信息返回给前端?所以我们需要一个全局的异常处理类,针对不同的异常捕获到并做不同的处理。

我们需要再新建一个全局异常处理类 GlobalException.java,在这里捕获各种异常再进行一些对应的处理,通过统一返回类(R.java)把一些必要的错误信息进行包装然后返回给前端。

全局异常处理类由 @ControllerAdvice 注解修饰,里面的方法可以通过 @ExceptionHandler 注解指定捕获特定的异常进行处理。一般来说,先捕获需要特殊处理的一些小异常,最后由 Excpetion 异常兜底,就是说如果那些特殊类型的异常都没有捕获到,就走最后一条逻辑。

package org.example.myspringboot.common.exception;

import cn.hutool.core.util.StrUtil;
import org.example.myspringboot.common.api.R;
import org.example.myspringboot.common.api.ApiResultCode;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;

@ControllerAdvice
public class GlobalException {
    @ExceptionHandler(ApiException.class) // 捕获 ApiException 类型的异常
    @ResponseBody
    @ResponseStatus() // 默认状态 500
    public R<Void> handleApiException(ApiException ex) {
        String message = ex.getErrorMsg();
        if (StrUtil.isNotBlank(ex.getModule())) {
            message = String.format("%s : %s", ex.getModule(), message);
        }
        return R.fail(ex.getErrorCode(), message);
    }
    
    @ExceptionHandler // 捕获未被其他异常捕获的异常
    @ResponseBody
    @ResponseStatus()
    public R<Void> handleException(Exception e) {
        return R.fail(ApiResultCode.FAILED.getCode(), e.getMessage());
    }
}

这时我们就可以在 TestController.java 中测试一下了。

@RestController
@RequestMapping("/api/test")
public class TestController {
    @GetMapping()
    public R<String> test() {
        throw new ApiException(throw new ApiException("系统模块", "姓名不能为空");
    }
}

测试结果如下:

在这里插入图片描述

如果我们抛出其他类型的异常,则会被另一个异常处理器捕获。

@RestController
@RequestMapping("/api/test")
public class TestController {
    @GetMapping()
    public void test() {
        int result = 1 / 0;
    }
}

测试结果如下:

在这里插入图片描述

再比如抛出一个运行时异常:

@RestController
@RequestMapping("/api/test")
public class TestController {
    @GetMapping()
    public void test() {
        throw new RuntimeException("这是一个运行时异常");
    }
}

测试结果如下:

在这里插入图片描述

好了,现在需求已经基本实现了。但是我们又发现,每抛出一次异常就得写一个错误码和错误信息,这样如果有大量的错误码和其对应的错误信息就会显得特别乱,所以我们希望在一个地方统一进行管理。

我们可以用一个枚举类来管理模块,而错误码和错误信息的话我这里想用国际化资源 message 进行管理,因为我之前公司就是用这个实现的,且若依框架也是使用 message 来管理错误码定义的,所以我也想尝试一下,就简单来实现一下。(所谓国际化,就是我定义两个分别为中文和英文版本的错误信息,根据请求头信息判断,如果为 zh-CN 则返回中文版本的,为 en-US 则返回英文版本的)

Spring Boot 默认就支持国际化,先演示一下哈,我在 i18n 目录下新建了三个文件,内容分别如下:

在这里插入图片描述

现在我在 TestController.java 中抛出异常,错误码为 11001

@RestController
@RequestMapping("/api/test")
public class TestController {
    @GetMapping()
    public void test() {
        throw new ApiException(ExceptionModule.SYSTEM_ERROR.getModule(), 11001L);
    }
}

当请求头中的 Accept-Language 的值为 zh-CH,则返回结果如下:

在这里插入图片描述

当我把请求头的信息 Accept-Language 的值设为 en-US。此处只需要修改 axios.js 中请求头配置:

import axios from 'axios';
// 创建一个 Axios 实例,可以给所有的请求都加上一个 /api 前缀
const http = axios.create({
    baseURL: 'http://localhost:8989/api/',
    headers: {
        'Accept-Language': 'en-US'
    }
});
export default http;

则返回结果又如下:

在这里插入图片描述

好了,演示结束,下面进入正题。

5.1. 模块枚举类

对于模块的枚举类,我们在 exception 包下新建一个 ExceptionModule.java 的枚举类来管理项目的各个模块。

package org.example.myspringboot.common.exception;

import lombok.AllArgsConstructor;
import lombok.Getter;

/**
 * 模块枚举
 */
@AllArgsConstructor
@Getter
public enum ExceptionModule {

    SYSTEM_ERROR("SYSTEM", "系统模块"),
    OTHER_ERROR("OTHER", "其他模块");

    private final String module;
    private final String message;
}

5.2. 国际化资源(错误码定义)

我们可以在项目的 resources/static 目录下新建一个 i18n 目录,然后在其中新建一个 messages.properties 文件,文件内容如下:(当然你可以再新建其他语言版本的,只要保证错误码一致就行)

# 11000 - 11100 为系统模块异常错误码
11001=姓名不能为空
11002=长度为 {min} 到 {max} 之间
11003=密码输错 {count} 次,系统锁定 5 分钟
# 11100 - 11200 为其他模块异常错误码
11200=其他模块异常信息

然后在配置文件 application.yml 中指定国际化资源文件位置。

spring:
  messages:
    basename: static/i18n/messages # 指定其名字即可,不需要后缀名
5.2.1. 思路

我们将错误码和错误信息放入 messages.properties 文件中统一管理,并在配置文件 application.yml 中配置了其存放路径。那么现在我们的目标就变为了如何去读取这个文件,并且让我们传递的参数去替换掉文件中的占位符 (如 {count})。

Spring Boot 已经对 i18n 国际化做了自动配置,可以使用 ResourceBundleMessageSource 管理国际化资源文件,而该类对 MessageSource 接口做了实现。也就是如果有了 ResourceBundleMessageSource 我们就可以操作那些资源文件了。那么现在的目标又变为了如何得到 ResourceBundleMessageSource 对象,也就是如何从 Spring 容器中去拿这个对象,当然你可以直接使用反射,在 Spring 框架中,我们通常使用 ApplicationContext 接口提供的方法来获取 bean 对象,这种方式更加方便和安全。大致的逻辑就是这样,咱们再反过来总结一下:

  1. 首先通过 ApplicationContext 接口在应用上下文中查找 MessageSource 的 bean 实例;
  2. 通过 MessageSource 可以管理 message 国际化资源文件;

针对一:新建一个 SpringUtils 工具类,专门用来从容器中获取 bean 实例;

针对二:新建一个 MessageUtils 工具类,专门用来获取 i18n 资源信息;

5.2.2. 实现

我们可以新建一个 utils 工具包,用来存放项目中可以随时使用的工具类。(工具类中的代码应该是通用和独立的,不与特定的业务上下文相关联,有需要的地方可以直接使用。比如日期转换工具:就应该只处理日期相关的,不牵涉其他和业务相关的东西,在系统模块中可以使用,在商品和支付模块亦可使用)

在这里插入图片描述

其中 SpringUtils.java 主要用于获取 bean 实例对象,这里我们已明确可以通过 MessageSource 类获取其实例,所以在该工具类中就只添加一个根据类来获取实例的方法,有需要其他方式的后续可以在该类中添加。

package org.example.myspringboot.utils;

import org.springframework.beans.BeansException;
import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;

@Component
public class SpringUtils implements ApplicationContextAware {

    private static ApplicationContext applicationContext;

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        SpringUtils.applicationContext = applicationContext;
    }

    public static <T> T getBean(Class<T> clz) {
        return applicationContext.getBean(clz);
    }
}

MessageUtils.java 中,可以通过 MessageSource 提供的 getMessage() 方法来获取资源文件,如果传入的 args 参数不为 null,则会自动进行替换。

package org.example.myspringboot.utils;

import org.springframework.context.MessageSource;
import org.springframework.context.i18n.LocaleContextHolder;

/**
 * 获取 i18n 国际化资源文件
 */
public class MessageUtils {

    /**
     * 根据编码和参数获取消息
     *
     * @param code 编码(键)
     * @param args 参数
     */
    public static String getMessage(String code, Object... args) {
        MessageSource messageSource = SpringUtils.getBean(MessageSource.class);
        return messageSource.getMessage(code, args, LocaleContextHolder.getLocale());
    }
}

工具类制作完成,该在哪里使用呢?想一想,异常抛出后我们在全局异常类 GlobalException.java 中捕获,并将其封装了以下返回给前端,所以我们可以在这里动手脚,错误编码是不需要变的,重点就是其中的 message 的获取方式,之前是通过 ex.getErrorMsg()ApiException 实例对象的属性中获取,现在应该改为从资源文件获取了是吧,如果资源文件没有则再从属性中取。

所以我们将下图中这里的 ex.getErrorMsg() 改为 ex.getMessage() ,然后在 getMessage() 方法中编写从资源文件中获取错误信息的逻辑。

在这里插入图片描述

打开 ApiException.java 并在其中新建一个 getMessage() 方法:首先判断 errorCode ,如果有值,则根据 errorCode 从资源文件中查找对应的错误提示消息;如果没有值则返回该类实例对象的属性值。

@Override
public String getMessage() {
    String message = null;
    if (errorCode != null) {
        message = MessageUtils.getMessage(errorCode.toString(), args);
    }
    if (message == null) {
        message = errorMsg;
    }
    return message;
}

TestController.java 编写接口进行测试:

@RestController
@RequestMapping("/api/test")
public class TestController {
    @GetMapping()
    public void test() {
        throw new ApiException(ExceptionModule.SYSTEM_ERROR.getModule(), 11001L);
    }
}

测试结果如下:

在这里插入图片描述


6. AOP 切面

通俗一点来讲,注重一个 “切” 字。拿经典的日志举例,如果希望统计一个方法执行的时间,那么我就需要在方法执行前获取一个开始时间,执行后获取一个结束时间,然后两个时间相减就得到想要的结果,没什么难度呀。如果项目每一个方法都如此呢,一个项目那么多方法,每一个加的还都是一样的逻辑,这不仅不优雅,而且很繁琐,还需要去动别人写好的代码。

此时 AOP 就可以发挥它的特长了,计算方法执行时间就是一个公共的逻辑(因为所有方法都会用到),而项目程序又是一个完整的个体,如何将其融合在一起呢?所以我们要想办法在这个完整的程序中找到需要添加日志的目标方法,给它前后这么一 “切”,再把我们的逻辑给它塞进去再连接起来不就大功告成啦。这样就不仅能实现需求,而且还动不到别人写的代码。

大致的逻辑就是这样的,然后这里会涉及一些专有名词:切面、通知、目标、代理、连接点、切点等,我就不做详细解释,因为我也不是特别清楚这些概念,可以自行百度或询问 ChatGPT 进行了解。

6.1. 思路

切面类。获取要切入的点和编写要实现的逻辑代码。要声明它是一个切面类并且交由容器进行管理,则需要在类上加上 @Component 注解和 @Aspect 两个注解,@Aspect 就表示这是一个切面类,不然谁知道你是干嘛的呢。

切点。就是定位到我们要 “切” 的哪些方法,有时候并不是所有的方法都需要,而是某些包下面的某些方法。所以需要使用 @Pointcut 注解来指定切点位置。

通知。要切入的方法找到了,现在有个问题,我们是打算只从方法前面切呢,还是从方法后面切呢,还是直接前后都给来一刀。这里就有几个注解来解决这个问题了,这个地方涉及的专有名词叫 通知。我们把要切入的目标方法暂且叫业务模块代码。

  • @Before:业务模块代码执行之前执行。因为该部分逻辑执行在前,如果这里抛出了异常,就无法正常执行业务模块代码了。
  • @AfterReturning:业务模块代码执行之后就执行这里的代码了。
  • @AfterThrowing:业务模块代码抛出异常了就会执行这里的代码。
  • @After:以上所有的 Advice 执行完成后执行,也就是不管是正常执行完方法,还是抛出了异常,最后都会执行这里的操作,类似于 finally 的作用。
  • @Around:功能就有点强大了,名称为 “环绕通知”。它的第一个参数必须是 ProceedingJoinPoint 类型,在通知的方法体类,可以通过 ProceedingJoinPoint 对象的 proceed() 方法动态执行业务模块代码。也就是在这个方法中,调用 proceed() 前相当于 @Before 注解,调用 proceed() 后就相当于 @After 注解。

6.2. 实现

新建一个 aop 包用来管理所有的切面类,在 aop 包下新建 AspectTest.java 切面类。

package org.example.myspringboot.aop;

import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class AspectTest {

    /**
     * controller 下面所有的类的所有方法
     */
    @Pointcut("execution(* org.example.myspringboot.controller.*.*(..))")
    public void pointcutAll() {}

    /**
     * pointcutAll() 指定的所有切点之前执行下面的代码逻辑
     */
    @Before("pointcutAll()")
    public void before() {
        System.out.println("AOP 切面驾到,统统闪开!");
    }
}

解析:execution(* org.example.myspringboot.controller.*.*(..))

  • execution:此关键字表示匹配方法执行的连接点。
  • 第一个 * 号:表示方法返回值类型。如果写 Long,则表示匹配返回值类型为 Long 的方法。
  • org.example.myspringboot.controller.*.*(..):定位切入的方法位置。
  • 第二个 * 号:表示匹配的类名。此处表示 controller 包下面的所有类。如果写 Test,则表示特指 controller 包下的 Test 类。
  • 第三个 * 号:表示匹配的方法。此处表示所有的方法。如果写 test,则表示特指该路径下的 test() 方法。
  • (..):匹配方法参数。此处表示任意参数。如果写 Long,则表示参数为 Long 类型的那个方法。

以下运行结果是通过 @Around 环绕通知获取目标类、切点方法名以及参数列表,然后调用 proceed() 执行切点方法,并获取到目标方法的返回值。

在这里插入图片描述

该切面类的完整代码如下:

package org.example.myspringboot.aop;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.example.myspringboot.common.api.R;
import org.springframework.stereotype.Component;

import java.util.Arrays;

@Aspect
@Component
public class AspectTest {

    /**
     * controller 下面所有的类的所有方法
     */
    @Pointcut("execution(* org.example.myspringboot.controller.*.*(..))")
    public void pointcutAll() {}

    /**
     * controller 下面所有的类的所有方法,但返回值必须是 Long 类型
     */
    @Pointcut("execution(org.example.myspringboot.common.api.R<Long> org.example.myspringboot.controller.*.*(..))")
    public void pointcutLong() {}

    /**
     * 精准定位 test() 方法
     */
    @Pointcut("execution(* org.example.myspringboot.controller.TestController.test())")
    public void pointcutTest() {}

    /**
     * TestController 中所有方法,但其输入参数必须是两个 Integer 类型的
     */
    @Pointcut("execution(* org.example.myspringboot.controller.TestController.*(Integer, Integer))")
    public void pointcutInteger() {}

    /**
     * 方法执行前执行
     * 切点为 pointcutAll(),即 controller 包下所有类中任意入参类型、任意返回类型的任意方法
     */
    @Before("pointcutAll()")
    public void before() {
        System.out.println("@Before -> AOP 切面驾到,统统闪开!");
    }

    /**
     * 方法执行完成后执行
     * 切点为 pointcutLong(),即返回值为 R<Long> 类型的方法 test3()
     */
    @AfterReturning("pointcutLong()")
    public void afterReturning() {
        System.out.println("@AfterReturning -> 方法执行后执行:没猜错应该是返回值为 R<Long> 类的的 test3() 方法");
    }

    /**
     * 方法抛异常后执行
     * 切点为 pointcutTest(),即精准定位到 test() 方法
     */
    @AfterThrowing("pointcutTest()")
    public void afterThrowing() {
        System.out.println("@AfterThrowing -> 方法抛异常后执行:没猜错应该是 test() 方法吧!");
    }

    /**
     * 方法执行之后执行。不管是正常结束还是有异常抛出都会执行这里
     * 切点为 pointcutInteger(),即入参为两个 Integer 类型的方法,就是 test2()
     */
    @After("pointcutInteger()")
    public void after() {
        System.out.println("@After -> 方法执行之后执行: 没猜错是两个 Integer 类型入参的 test2() 方法");
    }

    /**
     * 环绕通知:方法执行前后执行
     * 切入点 pointcutAll(),即所有方法都会执行下列通知
     */
    @Around("pointcutAll()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        /* 可以通过 joinPoint 获取 target 目标类、参数列表、方法名等信息 */
        Object[] args = joinPoint.getArgs();
        System.out.println("args: " + Arrays.toString(args));
        String methodName = joinPoint.getSignature().getName();
        System.out.println("methodName = " + methodName);
        System.out.println("@Around -> 此处是所有方法执行之前我要执行的逻辑");
        /* 这里调用 proceed() 可执行切点方法,可以得到切点方法的返回值,进行类型转换可以得到返回参数 */
        Object proceed = joinPoint.proceed();
        R r = (R) proceed;
        System.out.println("code: " + r.getCode() + ", msg: " + r.getMsg() + ", data: " + r.getData());
        System.out.println("@Around -> 方法执行之后我要执行的逻辑");
        return proceed;
    }
}

7. 自定义注解

注解可以提高代码的可读性、灵活性和可维护性,还可以简化代码,只需要加上一个注解就可以实现想要的功能。我们可以结合 AOP 切面编程来实现一个打印日志的自定义注解,也就是在方法上加上这个注解,就可以在该方法执行前后打印我们想要的日志。

7.1. 思路

我们想要实现的效果是在某个方法或类上加一个注解,就能执行想要的逻辑。首先就得新建一个类,不然哪会凭空产生注解,并定义该类为一个注解类 。注解有了,我们想要实现的逻辑写在哪里呢?所以就再新建一个类用来处理业务需求,也就是自定义注解的处理类。

如何将两者关联起来,就是我们在处理类中如何获取加了该注解的方法并且还能控制这个方法的执行,然后在方法执行前后自定义逻辑实现。怎么实现呢?当然就是使用 AOP 切面了,获取加该注解的切点方法,然后使用前置通知 @Before 、后置通知 @After 或环绕通知 @Around 来添加自定义逻辑。

7.2. 实现

新建一个 annotation 包,用来管理各种注解类,在包下新建一个 LogAnnotation.java 注解类。类名 LogAnnotation 前面写 class 就是一个普通类,写 enum 就是枚举类,写 interface 就是接口类,写 @interface 就是一个注解类。

介绍四种元注解,就是来修饰注解的注解,我们定义一个注解,你得说明这个注解需要用到什么地方吧(是用在类上、方法上还是字段上),以及什么时候使用这个注解(这个注解在编译期使用还是在运行期使用)等。

@Documented:表示注解是否包含在 JavaDoc 中

@Retention:指定被它注解的注解的生命周期。参数是 RetentionPolicy 类型:

  • SOURCE:只在源代码中可见
  • CLASS:编译时保留
  • RUNTIME:运行时保留

@Target:指定被注解的注解可以应用在哪些地方。参数是 ElementType 类型的列表,可以指定多个:

  • TYPE:表示注解可以加在类上面
  • METHOD:表示注解可以加在方法上面
  • FIELD:表示注解可以加在字段上面

@Inherited:表示是否允许子类继承该注解

然后就是注解的属性参数了,使用注解时可以传入指定的属性值,与平时的类属性写法有区别,属性名后需要加括号(但并不表示它是个方法),默认值需要使用 default 关键字指定。

package org.example.myspringboot.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * 日志注解类
 */
@Retention(value = RetentionPolicy.RUNTIME)
@Target(value = {ElementType.METHOD})
public @interface LogAnnotation {
    /* 注解的属性字段,有默认值则通过 default 关键字指定 */
    String value() default "";
    
    String arg();
}

自定义注解其实就已经完成了,现在要做的就是再开发一个注解处理器来实现具体的需求。

我们可以在 annotation 包下新建一个 handler 包用来管理所有的注解处理器,在 handler 包下新建一个 LogAnnotationHanlder.java 切面类,需要使用 @Aspect@Component 注解修饰该类。

然后使用 @Piontcut 指定切点,其中 @annotation(logAnnotation) 是一个切点表达式,用来匹配带有特定注解的连接点,这里的 logAnnotation 是切点表达式的参数,表示在匹配的连接点中会传入一个 LogAnnotation 类型的参数。

最后在 @Around 环绕通知中指定 value 参数为切点,argNames 参数为通知方法体需要传入的参数,然后就是在通知方法体中写我们需要实现的逻辑。

package org.example.myspringboot.annotation.handler;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.example.myspringboot.annotation.LogAnnotation;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class LogAnnotationHandler {

    @Pointcut("@annotation(logAnnotation))")
    public void pointCut(LogAnnotation logAnnotation) {}

    @Around(value = "pointCut(logAnnotation)", argNames = "joinPoint,logAnnotation")
    public Object around(ProceedingJoinPoint joinPoint, LogAnnotation logAnnotation) throws Throwable {
        System.out.println("======== 自定义注解开始 =======");
        String value = logAnnotation.value();
        String arg = logAnnotation.arg();
        System.out.println("注解的传入的参数 value: " + value + " arg: " + arg);
        long startTime = System.currentTimeMillis();
        Object result = joinPoint.proceed();
        long endTime = System.currentTimeMillis();
        System.out.println("方法 (" + joinPoint.getSignature().getName() + ") 执行时间为: " + (endTime - startTime) + "ms");
        System.out.println("======= 自定义注解结束 =======");
        return result;
    }
}

最后就是使用注解了,在 TestController.java 中的 test2() 方法上使用该注解,并传入所需的两个参数。

@GetMapping("/test2")
@LogAnnotation(value = "abc", arg = "AAA")
public R<Integer> test2(@RequestParam Integer a, @RequestParam Integer b) {
    return R.success(a * b);
}

运行结果如下:

在这里插入图片描述

不是仅有 @Around 可以实现哦,其他几个通知注解也可以的,只是需要传入注解类型的参数而已,如下所示:

@Before(value = "pointCut(arg)", argNames = "joinPoint, arg")
public void before(JoinPoint joinPoint, LogAnnotation arg) {

}

ok !!!

  • 7
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值