weblog项目开发记录--SpringBoot后端工程骨架

知识点查漏补缺

跟着犬小哈做项目实战时发现好多知识点都忘了,还有一些小的知识点可能之前没学过,记录下!顺带整理下开发流程。

完整项目学习见犬小哈实战专栏

SpringBoot后端工程骨架

搭建好的工程骨架中实现了很多基础功能,如日志配置、参数校验、自定义响应、全局异常管理、Knife4j、Jackson序列化配置等。熟悉这些功能组件可以再以后开发新的项目时作为模板显著提升开发效率!

1、多模块项目

1.1 parent标签

xml 复制代码
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.6.3</version>
        <!-- Maven 从仓库中查找父项目-->
        <relativePath/>
    </parent>

parent 标签用于指定当前项目的父项目,这意味着当前项目会继承父项目的一些配置,例如插件版本、依赖版本、构建设置等。

1.2 modules标签

xml 复制代码
    <!--    子模块管理-->
    <modules>
        <!--   入口模块-->
        <module>weblog-web</module>
        <!-- 管理后台 -->
        <module>weblog-module-admin</module>
        <!-- 通用模块 -->
        <module>weblog-module-common</module>
    </modules>

modules标签用来模块管理

1.3 properties标签

xml 复制代码
    <properties>
        <!-- 项目版本号-->
        <revision>0.0.1-SNAPSHOT</revision>
        <java.version>1.8</java.version>
        <guava.version>31.1-jre</guava.version>
        <commons-lang3.version>3.12.0</commons-lang3.version>
        <jackson.verson>2.15.2</jackson.verson>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <!-- Maven 相关 -->
        <maven.compiler.source>${java.version}</maven.compiler.source>
        <maven.compiler.target>${java.version}</maven.compiler.target>
    </properties>

properties标签中可以定义项目中可重用的属性或变量

1.4 dependencyManagement标签

xml 复制代码
<dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>com.bijing</groupId>
                <artifactId>weblog-module-admin</artifactId>
                <version>${revision}</version>
            </dependency>
            ...
            ...
            <dependency>
                <groupId>com.fasterxml.jackson.core</groupId>
                <artifactId>jackson-databind</artifactId>
                <version>${jackson.verson}</version>
            </dependency>
        </dependencies>
    </dependencyManagement>

统一依赖管理,只是声明依赖,并不自动实现引入,只有在子项目中写了该依赖项,并且没有指定具体版本,才会从父项目中继承该项。

1.5 pluginManagement标签

xml 复制代码
<build>
        <!-- 统一插件管理,用于管理 Maven 插件的版本和配置 -->
        <pluginManagement>
            <!-- 插件列表,包含了各个插件的配置 -->
            <plugins>
                <!-- 插件配置 -->
                <plugin>
                    <!-- Spring Boot Maven 插件,用于构建和打包 Spring Boot 项目 -->
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-maven-plugin</artifactId>
                    <!-- 插件的配置信息 -->
                    <configuration>
                        <!-- 配置选项,用于定制插件的行为 -->

                        <!-- 排除特定的依赖,这里是排除 lombok -->
                        <excludes>
                            <exclude>
                                <groupId>org.projectlombok</groupId>
                                <artifactId>lombok</artifactId>
                            </exclude>
                        </excludes>
                    </configuration>
                </plugin>
            </plugins>
        </pluginManagement>
    </build>

在父项目中声明插件的版本,以确保子项目使用相同的插件版本。

2、开发环境和生产环境配置

application.yml:默认的主配置文件,用于存放通用配置信息

yaml 复制代码
# 企业级项目开发中,一般项目默认会激活 dev 环境
spring:
  profiles:
    #默认激活 dev 环境
    active: dev

application-dev.yml:针对开发环境的配置文件

application-prod.yml:针对生产环境的配置文件

yaml 复制代码
# 在生产环境中使用特定的日志配置
logging:
  config: classpath:logback-weblog.xml

3、日志配置

在web模块的 pom.xml 中加入 spring-boot-starter-web 依赖时,它会自动包含 Logback 相关依赖,无需额外添加。日志功能一般放在common模块中,还需要加入下面依赖:

xml 复制代码
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-aop</artifactId>
		</dependency>

		<!-- Jackson工具类 -->
		<dependency>
			<groupId>com.fasterxml.jackson.core</groupId>
			<artifactId>jackson-databind</artifactId>
		</dependency>

3.1 logback日志

logback-weblog.xml配置文件如下(更多见博文):

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<configuration>

    <!-- JMX配置,用于连接和管理JMX工具 -->
    <jmxConfigurator/>

    <!-- 引入Spring Boot默认日志配置 -->
    <include resource="org/springframework/boot/logging/logback/defaults.xml" />

    <!-- 定义应用名称 -->
    <property scope="context" name="appName" value="weblog" />

    <!-- 自定义日志输出路径,以及日志名称前缀 -->
    <property name="LOG_FILE" value="../../logs/${appName}.%d{yyyy-MM-dd}"/>
    <property name="FILE_LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n"/>

    <!-- 按照每天生成日志文件的配置 -->
    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <!-- 日志文件输出的文件名格式 -->
            <FileNamePattern>${LOG_FILE}-%i.log</FileNamePattern>
            <!-- 日志文件保留天数 -->
            <MaxHistory>30</MaxHistory>
            <!-- 日志文件最大的大小,当达到这个大小后,会触发滚动 -->
            <TimeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
                <maxFileSize>10MB</maxFileSize>
            </TimeBasedFileNamingAndTriggeringPolicy>
        </rollingPolicy>
        <!-- 配置日志格式 -->
        <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
            <pattern>${FILE_LOG_PATTERN}</pattern>
        </encoder>
    </appender>

    <!-- dev环境配置,仅输出到控制台 -->
    <springProfile name="dev">
        <include resource="org/springframework/boot/logging/logback/console-appender.xml" />
        <root level="info">
            <appender-ref ref="CONSOLE" />
        </root>
    </springProfile>

    <!-- prod环境配置,仅输出到文件中 -->
    <springProfile name="prod">
        <include resource="org/springframework/boot/logging/logback/console-appender.xml" />
        <root level="INFO">
            <appender-ref ref="FILE" />
        </root>
    </springProfile>

</configuration>

3.2 Spring Boot 自定义注解,实现 API 请求日志切面

3.2.1 自定义注解

一般四个步骤:

  1. 创建自定义注解: 这是定义自己的注解,可以在需要的地方标记,并可能带有一些属性。
java 复制代码
package com.bijing.weblog.common.aspect;

import java.lang.annotation.*;

/**
 * @author 毕晶
 * @date 2024/2/3 20:50
 */

@Retention(RetentionPolicy.RUNTIME)//表示该注解在运行时保留,因此可以通过反射机制在运行时获取注解信息
@Target({ElementType.METHOD})//表示该注解仅能被应用在方法上。
@Documented//表示该注解将包含在 Javadoc 中
public @interface ApiOperationLog {
    /**
     * API功能描述
     * @return
     */
    String description() default "";

}
  1. 创建切面类(Aspect): 这是定义切面逻辑的地方。切面是使用注解的方法执行前后执行的代码块。
java 复制代码
package com.bijing.weblog.common.aspect;

import com.bijing.weblog.common.utils.JsonUtil;
import lombok.extern.slf4j.Slf4j;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;

/**
 * @author 毕晶
 * @date 2024/2/3 21:02
 */
@Aspect
@Component
@Slf4j
public class ApiOperationLogAspect {

    /**
     * 以自定义 @ApiOperationLog 注解为切点,凡是添加 @ApiOperationLog 的方法,都会执行环绕中的代码
     */
    @Pointcut("@annotation(com.bijing.weblog.common.aspect.ApiOperationLog)")
    public void apiOperationLog() {
    }

    /**
     * 环绕
     *
     * @param joinPoint
     * @return
     * @throws Throwable
     */
    @Around("apiOperationLog()")
    public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
        try {
            // 请求开始时间
            long startTime = System.currentTimeMillis();

            // MDC:诊断上下文映射,开发人员可以在 诊断上下文 中放置一些信息,如用户身份信息,关键参数,操作描述,环境信息,异常信息等,
            // 而后通过特定的 logback 组件去获取,或者MDC.get(key)
            MDC.put("traceId", UUID.randomUUID().toString());

            // 获取被请求的类和方法
            // joinPoint 包含了被拦截方法的信息,允许在拦截器中获取和控制被拦截方法的执行
            /*Signature: 通过 getSignature() 方法可以获取连接点的签名,即被拦截方法的方法签名。
            Args: 通过 getArgs() 方法可以获取方法的参数数组。
            Target Object: 通过 getTarget() 方法可以获取目标对象,即被拦截的对象实例。
            This Object: 通过 getThis() 方法可以获取代理对象,即当前执行的代理对象。*/
            String className = joinPoint.getTarget().getClass().getSimpleName();
            String methodName = joinPoint.getSignature().getName();

            // 请求入参
            Object[] args = joinPoint.getArgs();
            // 入参转 JSON 字符串
            // map(toJsonStr()):将流中的每个参数对象转换为相应的 JSON 字符串
            // collect(Collectors.joining(", ")):将流中的元素收集并连接成一个字符串,其中每个元素之间用逗号 , 分隔。
            String argsJsonStr = Arrays.stream(args).map(toJsonStr()).collect(Collectors.joining(", "));

            // 功能描述信息
            String description = getApiOperationLogDescription(joinPoint);

            // 打印请求相关参数
            log.info("====== 请求开始: [{}], 入参: {}, 请求类: {}, 请求方法: {} =================================== ",
                    description, argsJsonStr, className, methodName);

            // 执行切点方法,result放的是被拦截方法的返回值
            Object result = joinPoint.proceed();

            // 执行耗时
            long executionTime = System.currentTimeMillis() - startTime;

            // 打印出参等相关信息
            log.info("====== 请求结束: [{}], 耗时: {}ms, 出参: {} =================================== ",
                    description, executionTime, JsonUtil.toJsonString(result));

            return result;
        } finally {
            MDC.clear();
        }
    }

    /**
     * 获取注解的描述信息
     *
     * @param joinPoint
     * @return
     */
    private String getApiOperationLogDescription(ProceedingJoinPoint joinPoint) {
        // 1. 从 ProceedingJoinPoint 获取 MethodSignature(方法签名信息)
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();

        // 2. 使用 MethodSignature 获取当前被注解的 Method
        Method method = signature.getMethod();

        // 3. 从 Method 中提取 LogExecution 注解
        ApiOperationLog apiOperationLog = method.getAnnotation(ApiOperationLog.class);

        // 4. 从 LogExecution 注解中获取 description 属性
        return apiOperationLog.description();
    }

    /**
     * 转 JSON 字符串
     *
     * @return
     */
    private Function<Object, String> toJsonStr() {
        return JsonUtil::toJsonString;
    }
}
  1. 在启动类 WeblogWebApplication 中,手动添加包扫描 @ComponentScan: 在多模块项目中,Spring Boot 默认的组件扫描可能不会扫描所有模块的包,因此可能需要手动指定要扫描的包。
java 复制代码
package com.bijing.weblog.web;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ComponentScan;

@SpringBootApplication
@ComponentScan({"com.bijing.weblog.*"})// 多模块项目中,必需手动指定扫描 com.bijing.weblog 包下面的所有类
public class WeblogWebApplication {

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


}
  1. 在使用注解的方法上添加注解: 在需要应用切面逻辑的方法上添加自定义注解,这样 AOP 将在这些方法上生效。

补充:SpringBoot的AOP是默认开启的,不需要加注解@EnableAspectJAutoProxy

3.2.2 拾遗

元注解说明:

@Retention(RetentionPolicy.RUNTIME): 这个元注解用于指定注解的保留策略,即注解在何时生效。RetentionPolicy.RUNTIME 表示该注解将在运行时保留,这意味着它可以通过反射在运行时被访问和解析。

@Target({ElementType.METHOD}): 这个元注解用于指定注解的目标元素,即可以在哪些地方使用这个注解。ElementType.METHOD 表示该注解只能用于方法上。这意味着您只能在方法上使用这个特定的注解。

@Documented: 这个元注解用于指定被注解的元素是否会出现在生成的Java文档中。如果一个注解使用了 @Documented,那么在生成文档时,被注解的元素及其注解信息会被包含在文档中。这可以帮助文档生成工具(如 JavaDoc)在生成文档时展示关于注解的信息。

aspectj 注解说明

在配置 AOP 切面之前,我们需要了解下 aspectj 相关注解的作用:

@Aspect:声明该类为一个切面类;

@Pointcut:定义一个切点,后面跟随一个表达式,表达式可以定义为切某个注解,也可以切某个 package 下的方法;

切点定义好后,就是围绕这个切点做文章了:

@Before: 在切点之前,织入相关代码;

@After: 在切点之后,织入相关代码;

@AfterReturning: 在切点返回内容后,织入相关代码,一般用于对返回值做些加工处理的场景;

@AfterThrowing: 用来处理当织入的代码抛出异常后的逻辑处理;

@Around: 环绕,可以在切入点前后织入代码,并且可以自由的控制何时执行切点;

4、参数校验

Spring Boot提供了强大的参数校验功能,它建立在Java Bean Validation规范(JSR 380)之上。

4.1 引入依赖

首先,需要在 weblog-web 模块中的 pom.xml 文件添加参数校验依赖:

xml 复制代码
        <!-- 参数校验依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        

4.2 实体类参数校验

java 复制代码
package com.bijing.weblog.web.model;

import lombok.Data;

import javax.validation.constraints.*;

/**
 * @author 毕晶
 * @date 2024/2/3 23:40
 */
@Data
public class User {
    // 用户名
    @NotBlank(message = "用户名不能为空")
    private String username;

    // 性别
    @NotNull(message = "性别不能为空")
    private Integer sex;

    // 年龄
    @NotNull(message = "年龄不能为空")
    @Min(value = 18, message = "年龄必须大于或等于 18")
    @Max(value = 120, message = "年龄必须小于或等于120")
    private Integer age;

    //邮箱
    @NotBlank(message = "邮箱不能为空")
    @Email(message = "邮箱格式不正确")
    private String email;
}

4.3 Controller 参数校验

每个字段的校验注解添加完成后,还需要在 controller 层进行捕获,并将错误信息返回。

java 复制代码
package com.bijing.weblog.web.controller;

import com.bijing.weblog.common.aspect.ApiOperationLog;
import com.bijing.weblog.web.model.User;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import java.util.stream.Collectors;

/**
 * @author 毕晶
 * @date 2024/2/3 23:40
 */
@RestController
@Slf4j
public class TestController {

    @PostMapping("/test")
    @ApiOperationLog(description = "测试接口")
    public ResponseEntity<String> test(@RequestBody @Validated User user, BindingResult bindingResult) {
        // 是否存在校验错误
        if (bindingResult.hasErrors()) {
            // 获取校验不通过字段的提示信息
            String errorMsg = bindingResult.getFieldErrors()
                    .stream()
                    .map(FieldError::getDefaultMessage)
                    .collect(Collectors.joining(", "));

            return ResponseEntity.badRequest().body(errorMsg);
        }

        // 返参
        return ResponseEntity.ok("参数没有任何问题");
    }

}

ResponseEntity: Spring Framework 提供的一个表示 HTTP 响应的类。它包装了响应的状态码、头部信息和响应体等信息。

@Validated: 告诉 Spring 需要对 User 对象执行校验;

BindingResult : 验证的结果对象,其中包含所有验证错误信息;

4.4 拾遗

1、以下是 JSR 380 中提供的主要验证注解及其描述:

注解 描述
@NotNull 验证对象值不应为 null。
@AssertTrue 验证布尔值是否为 true。
@AssertFalse 验证布尔值是否为 false。
@Min(value) 验证数字是否不小于指定的最小值。
@Max(value) 验证数字是否不大于指定的最大值。
@DecimalMin(value) 验证数字值(可以是浮点数)是否不小于指定的最小值。
@DecimalMax(value) 验证数字值(可以是浮点数)是否不大于指定的最大值。
@Positive 验证数字值是否为正数。
@PositiveOrZero 验证数字值是否为正数或零。
@Negative 验证数字值是否为负数。
@NegativeOrZero 验证数字值是否为负数或零。
@Size(min, max) 验证元素的大小是否在给定的最小值和最大值之间。
@Digits(integer, fraction) 验证数字是否在指定的位数范围内。
@Past 验证日期或时间是否在当前时间之前。
@PastOrPresent 验证日期或时间是否在当前时间或之前。
@Future 验证日期或时间是否在当前时间之后。
@FutureOrPresent 验证日期或时间是否在当前时间或之后。
@Pattern(regexp) 验证字符串是否与给定的正则表达式匹配。
@NotEmpty 验证元素不为 null,并且其大小/长度大于0。
@NotBlank 验证字符串不为 null,且至少包含一个非空白字符。
@Email 验证字符串是否符合有效的电子邮件格式。

除了上述的标准注解,JSR 380 也支持开发者定义和使用自己的自定义验证注解。此外,这个规范还提供了一系列的APIs和工具,用于执行验证和处理验证结果。大部分现代Java框架(如 Spring 和 Jakarta EE)都与 JSR 380 兼容,并支持其验证功能。

2、以下是 ResponseEntity 的一些主要用法:

方法 描述
ok() 创建一个状态码为 200 OK 的 ResponseEntity 对象。
ok(T body) 创建一个状态码为 200 OK 的 ResponseEntity 对象,并设置响应体。
status(HttpStatus status) 创建一个指定状态码的 ResponseEntity 对象。
status(int status) 创建一个指定状态码的 ResponseEntity 对象。
headers(HttpHeaders headers) 设置响应头部信息。
header(String headerName, String... headerValues) 添加指定名称和值的响应头。
body(T body) 设置响应体。
created(URI location) 创建一个状态码为 201 Created 的 ResponseEntity 对象,并设置 Location 头部信息。
noContent() 创建一个状态码为 204 No Content 的 ResponseEntity 对象。

4.5 查漏补缺

以下是 BindingResult 的一些主要用法和特点:

主要用法和特点 描述
获取验证错误信息 通过 bindingResult.getFieldErrors() 方法可以获取所有验证失败的字段信息,每个字段错误包含字段名、错误码和默认错误信息等。
验证错误判断 使用 bindingResult.hasErrors() 方法来判断是否存在验证错误。如果存在验证错误,可以根据实际情况进行相应的处理。
默认错误信息 如果验证失败,BindingResult 会默认将错误信息存储在 FieldError 中,可以通过 getDefaultMessage() 方法获取默认的错误信息。
全局错误 除了字段级别的错误信息,BindingResult 还可以包含全局错误信息。通过 bindingResult.getGlobalErrors() 获取。

5、自定义响应工具类

在开发 RESTful API 时,为了保持响应结构的一致性,公司内部一般都有标准化的响应格式。

5.1 设计响应模型

5.1.1接口执行成功返参格式
java 复制代码
{
	"success": true,
	"data": null
}
5.1.2接口执行异常返参格式
java 复制代码
{
	"success": false,
	"errorCode": "10000"
	"message": "用户名不能为空"
}

5.2创建响应参数工具类

可以在common模块的utils包中定义响应参数工具类:

java 复制代码
package com.bijing.weblog.common.utils;

import lombok.Data;

import java.io.Serializable;

/**
 * @author 毕晶
 * @date 2024/2/5 23:51
 */
@Data
public class Response<T> implements Serializable {
    // 是否成功,默认为 true
    private boolean success = true;
    // 响应消息
    private String message;
    // 异常码
    private String errorCode;
    // 响应数据
    private T data;

    // =================================== 成功响应 ===================================
    public static <T> Response<T> success() {
        Response<T> response = new Response<>();
        return response;
    }

    public static <T> Response<T> success(T data) {
        Response<T> response = new Response<>();
        response.setData(data);
        return response;
    }

    // =================================== 失败响应 ===================================
    public static <T> Response<T> fail() {
        Response<T> response = new Response<>();
        response.setSuccess(false);
        return response;
    }

    public static <T> Response<T> fail(String errorMessage) {
        Response<T> response = new Response<>();
        response.setSuccess(false);
        response.setMessage(errorMessage);
        return response;
    }

    public static <T> Response<T> fail(String errorCode, String errorMessage) {
        Response<T> response = new Response<>();
        response.setSuccess(false);
        response.setErrorCode(errorCode);
        response.setMessage(errorMessage);
        return response;
    }

}

5.3 在控制器中使用

有了 Response 工具类,再配合 Spring Boot 的 @RestController 或者 @ResponseBody 注解, 就可以快速生成 JSON 格式的响应数据了。

java 复制代码
	@PostMapping("/test")
    @ApiOperationLog(description = "测试接口")
    public Response test(@RequestBody @Validated User user, BindingResult bindingResult) {
        // 是否存在校验错误
        if (bindingResult.hasErrors()) {
            // 获取校验不通过字段的提示信息
            String errorMsg = bindingResult.getFieldErrors()
                    .stream()
                    .map(FieldError::getDefaultMessage)
                    .collect(Collectors.joining(", "));

            return Response.fail(errorMsg);
        }

        // 返参
        return Response.success();
    }

补充: 在接口的返参中,有很多 null 值的字段也返回了,咋办?

只需在 applicaiton.yml 文件中对 jackson 添加相关配置即可。

yml 复制代码
  jackson:
    # 设置后台返参,若字段值为 null, 则不返回
    default-property-inclusion: non_null
    # 设置日期字段格式
    date-format: yyyy-MM-dd HH:mm:ss

6、全局异常管理

除了系统异常,很多时候我们还需要处理业务异常,比较推荐的做法是,将自定义业务异常整合到全局异常管理中,使其更加统一且易于维护。

6.1 自定义一个基础异常接口

创建一个 BaseExceptionInterface 基础异常接口,方便后面做拓展。

java 复制代码
package com.bijing.weblog.common.exception;

/**
 * @author 毕晶
 * @date 2024/2/6 15:04
 */
public interface BaseExceptionInterface {

    String getErrorCode();

    String getErrorMessage();
}

6.2 自定义错误码枚举

java 复制代码
package com.bijing.weblog.common.enums;

import com.bijing.weblog.common.exception.BaseExceptionInterface;
import lombok.AllArgsConstructor;
import lombok.Getter;

/**
 * @author 毕晶
 * @date 2024/2/6 15:05
 * @description 自定义错误码枚举
 */
@Getter
@AllArgsConstructor
public enum ResponseCodeEnum implements BaseExceptionInterface {

    // ----------- 通用异常状态码 -----------
    SYSTEM_ERROR("10000", "出错啦,后台小哥正在努力修复中..."),

    // ----------- 业务异常状态码 -----------
    PRODUCT_NOT_FOUND("20000", "该产品不存在(测试使用)"),
    ;

    // 异常码
    private final String errorCode;
    // 错误信息
    private final String errorMessage;

}

6.3 自定义业务异常

java 复制代码
package com.bijing.weblog.common.exception;

import lombok.Getter;
import lombok.Setter;

/**
 * @author 毕晶
 * @date 2024/2/6 15:10
 * @description 自定义业务异常
 */
@Setter
@Getter
public class BizException extends RuntimeException {
    // 异常码
    private String errorCode;
    // 错误信息
    private String errorMessage;

    public BizException(BaseExceptionInterface baseExceptionInterface) {
        this.errorCode = baseExceptionInterface.getErrorCode();
        this.errorMessage = baseExceptionInterface.getErrorMessage();
    }
}

补充:为啥是继承RuntimeException类而不是去实现BaseExceptionInterface接口呢?

从设计的角度去考虑,BizException的本质是个运行时异常,实现BaseExceptionInterface接口是一种功能行为的实现,这一点在继承RuntimeException时可以自定义实现,因此就没有必要去实现BaseExceptionInterface接口了。当然,实现了BaseExceptionInterface接口代码上也没有啥影响。

6.4 参数校验异常

当捕获到MethodArgumentNotValidException异常后,我们可以通过全局异常处理器来捕获该异常,统一返回错误信息。

改造 GlobalExceptionHandler 类,添加 handleMethodArgumentNotValidException() 方法:

java 复制代码
   /**
     * 捕获参数校验异常
     * @return
     */
    @ExceptionHandler({MethodArgumentNotValidException.class})
    @ResponseBody
    public Response<Object> handleMethodArgumentNotValidException(HttpServletRequest request,
                                                                  MethodArgumentNotValidException e) {
        // 参数错误异常码
        String errorCode = ResponseCodeEnum.PARAM_NOT_VALID.getErrorCode();

        // 获取 BindingResult
        BindingResult bindingResult = e.getBindingResult();

        StringBuilder sb = new StringBuilder();

        // 获取校验不通过的字段,并组合错误信息,格式为: email 邮箱格式不正确, 当前值: '123124qq.com';
        Optional.ofNullable(bindingResult.getFieldErrors()).ifPresent(errors -> {
            errors.forEach(error ->
                    sb.append(error.getField())
                            .append(" ")
                            .append(error.getDefaultMessage())
                            .append(", 当前值: '")
                            .append(error.getRejectedValue())
                            .append("'; ")

            );
        });

        // 错误信息
        String errorMessage = sb.toString();

        log.warn("{} request error, errorCode: {}, errorMessage: {}", request.getRequestURI(), errorCode, errorMessage);

        return Response.fail(errorCode, errorMessage);
    }

在参数错误枚举中添加:

java 复制代码
    PARAM_NOT_VALID("10001", "参数错误"),

补充:在Controller中,需要不加 BindingResult 参数才能直接捕获其他异常并返回参数校验的异常信息。

java 复制代码
    @PostMapping("/test")
    @ApiOperationLog(description = "测试接口")
//    如果希望在发生验证错误时返回相应的错误信息,就需要加上 BindingResult 参数。
//    如果不需要处理验证错误,或者希望直接捕获其他异常并返回通用的错误信息,就可以不加 BindingResult 参数。
    public Response test(@RequestBody @Validated User user) {
   			...
  			...
    }

7、整合 Knife4j

Knife4j 是一个为 Java 项目生成和管理 API 文档的工具。

7.1 整合 Knife4j

在父项目 weblog-springboot 中的 pom.xml 文件中,添加 Knife4j 依赖版本号:

xml 复制代码
	<!-- 版本号统一管理 -->
    <properties>

        <!-- 依赖包版本 -->
		省略...        
        <knife4j.version>4.3.0</knife4j.version>
    </properties>
    
    <!-- 统一依赖管理 -->
    <dependencyManagement>
        <dependencies>
            省略...        

            <!-- knife4j(API 文档工具) -->
            <dependency>
                <groupId>com.github.xiaoymin</groupId>
                <artifactId>knife4j-openapi2-spring-boot-starter</artifactId>
                <version>${knife4j.version}</version>
            </dependency>

        </dependencies>
    </dependencyManagement>

因为 admin 后台管理模块和博客前台模块都需要调试接口,所以,我们需要在 weblog-web 和 weblog-module-admin 两个模块中,都需要引入该依赖:

xml 复制代码
		<!-- knife4j -->
		<dependency>
			<groupId>com.github.xiaoymin</groupId>
			<artifactId>knife4j-openapi2-spring-boot-starter</artifactId>
		</dependency>

7.2 添加配置类

新建名为 Knife4jConfig 配置类:

java 复制代码
package com.bijing.weblog.web.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.service.Contact;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2WebMvc;

/**
 * @author 毕晶
 * @date 2024/2/7 16:22
 */
@Configuration
@EnableSwagger2WebMvc
public class Knife4jConfig {

    @Bean("webApi")
    public Docket createApiDoc() {
        Docket docket = new Docket(DocumentationType.SWAGGER_2)
                .apiInfo(buildApiInfo())
                // 分组名称
                .groupName("Web 前台接口")
                .select()
                // 这里指定 Controller 扫描包路径
                .apis(RequestHandlerSelectors.basePackage("com.bijing.weblog.web.controller"))
                .paths(PathSelectors.any())
                .build();
        return docket;
    }

    /**
     * 构建 API 信息
     *
     * @return
     */
    private ApiInfo buildApiInfo() {
        return new ApiInfoBuilder()
                .title("Weblog 博客前台接口文档") // 标题
                .description("Weblog 是一款由 Spring Boot + Vue 3.2 + Vite 4.3 开发的前后端分离博客。") // 描述
                .termsOfServiceUrl("https://www.bilog.com/") // API 服务条款
                .contact(new Contact("bijing", "https://www.blog.com", "1457808125@qq.com")) // 联系人
                .version("1.0") // 版本号
                .build();
    }
}

浏览器访问路径 http://localhost:8080/doc.html , 就可以看到 api 管理界面了

7.3 给 controller 添加 Swagger 相关注解



7.4 生产环境如何屏蔽 Knife4j

7.4.1 Spring Boot Profile 特性

Profile 是 Spring Boot 中的一项特性,允许你在不同环境中使用不同的配置。

@Profile 注解:可以在配置类上添加 @Profile 注解,来控制 Knife4j 是否生效 。只有当指定的 Profile 处于激活状态时,该配置类才会被创建和被使用。

java 复制代码
@Configuration
@EnableSwagger2WebMvc//启用 Swagger2
@Profile("dev")// 只在 dev 环境中开启
public class Knife4jConfig {
...
}
7.4.2 分组功能

weblog 项目接口分为前台和 Admin 后台,所以,除了在 weblog-web 模块中配置 Knife4j 外,还需要在 web-module-admin 也配置一份,并使用 Knife4j 分组功能将各自的接口隔离开来。

添加依赖、配置类和前面类似,注意类名,和 @Bean 的名称不能和 weblog-web 中的一样,否则会冲突。然后,改写分组名称,以及包扫描路径,还有 API 相关信息。

java 复制代码
package com.bijing.weblog.admin.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.service.Contact;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2WebMvc;

/**
 * @author 毕晶
 * @date 2024/2/20 13:42
 */
@Configuration
@EnableSwagger2WebMvc
@Profile("dev")
public class Knife4jAdminConfig {

    @Bean("adminApi")
    public Docket createApiDoc() {
        Docket docket = new Docket(DocumentationType.SWAGGER_2)
                .apiInfo(buildApiInfo())
                // 分组名称
                .groupName("Admin 后台接口")
                .select()
                // 这里指定 Controller 扫描包路径
                .apis(RequestHandlerSelectors.basePackage("com.bijing.weblog.admin.controller"))
                .paths(PathSelectors.any())
                .build();
        return docket;
    }

    /**
     * 构建 API 信息
     *
     * @return
     */
    private ApiInfo buildApiInfo() {
        return new ApiInfoBuilder()
                .title("Weblog 博客前台接口文档") // 标题
                .description("Weblog 是一款由 Spring Boot + Vue 3.2 + Vite 4.3 开发的前后端分离博客。") // 描述
                .termsOfServiceUrl("https://www.bilog.com/") // API 服务条款
                .contact(new Contact("bijing", "https://www.blog.com", "1457808125@qq.com")) // 联系人
                .version("1.0") // 版本号
                .build();
    }
}

7.5 拾遗

常用的Swagger相关注解以及它们的作用:

注解 作用
@Api 描述整个API的信息,包括标题、描述等。
@ApiOperation 描述单个接口的信息,包括接口的标题、描述、请求方法等。
@ApiParam 描述接口的参数信息,包括参数名、描述、是否必需等。
@ApiModel 描述请求或响应的模型信息,包括模型的名称、描述等。
@ApiModelProperty 描述模型的属性信息,包括属性的名称、描述、是否必需等。
@ApiIgnore 用于忽略某个接口或模型,不会被Swagger文档化。
@ApiImplicitParam 描述接口的隐式参数信息,一般用于描述请求头信息。
@ApiImplicitParams 描述接口的多个隐式参数信息。
@ApiResponses 描述接口的响应信息,包括不同响应状态码对应的描述。
@ApiModelProperly 描述模型的属性信息。

8、自定义 Jackson 序列化、反序列化,支持 Java 8 日期新特性

8.1 自定义 Jackson 配置类

由于 Spring Boot 内置使用的就是 Jackson JSON 框架,所以,无需引入新的依赖,仅需添加自定义配置类即可,让其支持新的日期 API。

java 复制代码
package com.bijing.weblog.common.config;

import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.TimeZone;

/**
 * @author 毕晶
 * @date 2024/2/20 13:58
 */
@Configuration
public class JacksonConfig {
    @Bean
    public ObjectMapper objectMapper() {
        // 初始化一个 ObjectMapper 对象,用于自定义 Jackson 的行为
        ObjectMapper objectMapper = new ObjectMapper();

        // 忽略未知字段(前端有传入某个字段,但是后端未定义接受该字段值,则一律忽略掉)
        objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

        // JavaTimeModule 用于指定序列化和反序列化规则
        JavaTimeModule javaTimeModule = new JavaTimeModule();

        // 支持 LocalDateTime、LocalDate、LocalTime的序列化和反序列化
        //定义LocalDateTime的序列化器
        javaTimeModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(
                "yyyy-MM-dd HH:mm:ss")));
        //定义LocalDateTime的反序列化器
        javaTimeModule.addDeserializer(LocalDateTime.class,
                new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
        javaTimeModule.addSerializer(LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd"
        )));
        javaTimeModule.addDeserializer(LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ofPattern("yyyy" +
                "-MM-dd")));
        javaTimeModule.addSerializer(LocalTime.class, new LocalTimeSerializer(DateTimeFormatter.ofPattern("HH:mm:ss")));
        javaTimeModule.addDeserializer(LocalTime.class, new LocalTimeDeserializer(DateTimeFormatter.ofPattern("HH:mm" +
                ":ss")));

        objectMapper.registerModule(javaTimeModule);

        // 设置时区
        objectMapper.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai"));

        // 设置凡是为 null 的字段,返参中均不返回,请根据项目组约定是否开启
        // objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);

        return objectMapper;
    }
}

完成自定义 Jackson 配置类后可以在原先的weblog-web模块下application.yml中删去jackson的一些无用配置。

8.2 拾遗

在Java 8中,LocalDate、LocalTime 和 LocalDateTime 是处理日期和时间的重要类。

  1. LocalDate:
  • LocalDate用于表示日期,不包含时间部分。
  • 它只包含年、月、日三个字段。
  • 例如,它可以用来表示员工的入职日期、某个活动的日期等。
  • LocalDate对象通常用于需要日期信息但不涉及时间的场景。
  1. LocalTime:
  • LocalTime用于表示时间,不包含日期部分。
  • 它只包含时、分、秒三个字段,可以包含纳秒精度。
  • 例如,它可以用来表示公交车的首发时间、会议的开始时间等。
  • LocalTime对象适用于那些只需要时间信息,而日期信息不重要的场景。
  1. LocalDateTime:
  • LocalDateTime是LocalDate和LocalTime的组合,它同时包含日期和时间。
  • 它包含年、月、日、时、分、秒六个字段,也可以包含纳秒精度。
  • LocalDateTime对象适用于需要同时记录日期和时间的场景,如在电商系统中记录交易发生的时间。

常用方法:

方法 描述
LocalDate.now() 获取当前日期
LocalDate.of(int year, int month, int dayOfMonth) 创建特定日期
LocalTime.now() 获取当前时间
LocalTime.of(int hour, int minute)LocalTime.of(int hour, int minute, int second) 创建特定时间
LocalDateTime.now() 获取当前日期时间
LocalDateTime.of(int year, int month, int dayOfMonth, int hour, int minute)LocalDateTime.of(int year, int month, int dayOfMonth, int hour, int minute, int second) 创建特定日期时间
getYear()getMonthValue()getDayOfMonth() 获取年、月、日
getHour()getMinute()getSecond() 获取时、分、秒
plusDays(long daysToAdd)minusDays(long daysToSubtract) 添加/减去指定天数
plusHours(long hoursToAdd)minusHours(long hoursToSubtract) 添加/减去指定小时数
isEqual(LocalDate/LocalTime/LocalDateTime other) 比较日期/时间/日期时间是否相等
isBefore(LocalDate/LocalTime/LocalDateTime other)isAfter(LocalDate/LocalTime/LocalDateTime other) 比较日期/时间/日期时间的大小关系
相关推荐
哎呦没19 分钟前
SpringBoot框架下的资产管理自动化
java·spring boot·后端
2401_8576009522 分钟前
SpringBoot框架的企业资产管理自动化
spring boot·后端·自动化
m0_571957582 小时前
Java | Leetcode Java题解之第543题二叉树的直径
java·leetcode·题解
魔道不误砍柴功4 小时前
Java 中如何巧妙应用 Function 让方法复用性更强
java·开发语言·python
NiNg_1_2344 小时前
SpringBoot整合SpringSecurity实现密码加密解密、登录认证退出功能
java·spring boot·后端
闲晨4 小时前
C++ 继承:代码传承的魔法棒,开启奇幻编程之旅
java·c语言·开发语言·c++·经验分享
种树人202408194 小时前
如何在 Spring Boot 中启用定时任务
spring boot
Chrikk6 小时前
Go-性能调优实战案例
开发语言·后端·golang
幼儿园老大*6 小时前
Go的环境搭建以及GoLand安装教程
开发语言·经验分享·后端·golang·go
canyuemanyue6 小时前
go语言连续监控事件并回调处理
开发语言·后端·golang