Spring Boot

简介

Spring Boot让您可以轻松地创建独立的、生产级别的Spring应用程序,并“直接运行”这些应用程序。SpringBoot为大量的第三方库添加了支持,能够做到开箱即用,简化大量繁琐配置,用最少的配置快速构建你想要的项目。

SpringBoot功能有:

  • 能够创建独立的Spring应用程序
  • 内嵌Tomcat、Jetty或Undertow服务器(无需单独部署WAR包,打包成Jar本身就是一个可以运行的应用程序)
  • 提供一站式的“starter”依赖项,以简化Maven配置(需要整合什么框架,直接导对应框架的starter依赖)
  • 尽可能自动配置Spring和第三方库(除非特殊情况,否则几乎不需要进行任何配置)
  • 提供生产环境下相关功能,如指标、运行状况检查和外部化配置
  • 没有任何代码生成,也不需要任何XML配置

在SSM阶段,当我们需要搭建一个基于Spring全家桶的Web应用程序时,不得不做大量的依赖导入和框架整合相关的Bean定义,但是实际上我们发现,整合框架其实基本都是一些固定流程,完全可以将一些重复的配置作为约定,只要框架遵守这个约定,为我们提供默认的配置就好,这样就不用我们再去配置了,约定大于配置

而SpringBoot正是将这些过程大幅度进行了简化,它可以自动进行配置,我们只需要导入对应的启动器(starter)依赖即可


搭建项目

快速创建项目

  1. IDEA中通过Spring Initializr创建Spring Boot项目。
image-20240803224117914

简单选择两个依赖,后续再手动添加对应模块的start依赖即可。

image-20240803224400705

IDEA会自动为我们创建一个用于运行Spring Boot项目的主类,可以一键启动SpringBoot项目。

1
2
3
4
5
6
7
8
9
10
11
12
13
package com.hunter.springboot_demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class SpringbootDemoApplication {

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

}

image-20230711125447493

由于没有添加任何有用的模块,也没有编写什么操作,因此启动之后项目就直接停止了。


常用模块快速整合

SpringBoot的核心思想就是约定大于配置,能在一开始默认的就直接默认,不用我们自己来进行配置,只需要配置某些特殊的部分即可

所有的SpringBoot依赖都是以starter的形式命名的,之后我们需要导入其他模块也是导入spring-boot-starter-xxxx这种名称格式的依赖。

包含内置Tomcat服务器的Web模块

1
2
3
4
5
<!-- 包含tomcat服务器的web模块 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

不需要任何配置,直接就能启动web服务器,并正常访问localhost:8080,虽然是个404页面。

image-20230711133224425

SpringBoot支持自动包扫描,不需要编写任何配置,直接在任意路径(不能跑到主类所在包外面)下创建的组件(如Controller、Service、Component、Configuration等)都可以生效。

包括一个对象也可以直接以JSON形式返回给客户端,无需任何配置,最后浏览器能够直接得到application/json的响应数据。这归功于SpringBoot对应的start自动将处理JSON数据的Converter进行了配置,不需要再单独去配置Converter。SpringBoot官方默认使用的是JacksonGson 的HttpMessageConverter来进行配置

1
2
3
4
5
6
@Data
public class Student {
int sid;
String name;
String sex;
}
1
2
3
4
5
6
7
8
9
@GetMapping("/")
@ResponseBody
public Student index(){
Student student = new Student();
student.setName("小明");
student.setSex("男");
student.setSid(10);
return student;
}

如果需要像之前一样添加WebMvc的配置类,方法是一样的,直接创建即可:

1
2
3
4
5
6
7
8
// 只需要添加Configuration用于注册配置类,不需要其他任何注解,已经自动配置好了
@Configuration
public class WebConfiguration implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
...
}
}

在SSM阶段编写的大量配置,到现在已经彻底不需要了


SpringSecurity框架

1
2
3
4
5
<!-- SpringSecurity框架 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>

对应的Starter帮助我们完成了默认的配置,并且在启动时,就已经帮助我们配置了一个随机密码的用户可以直接登录使用:

image-20230715182323772

密码直接展示在启动日志中,而**默认用户名称为user**,可以直接登录:

image-20230715182448770

同样的,如果要进行额外配置,添加配置类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 只需要Configuration注解即可,不需要其他配置
@Configuration
public class SecurityConfiguration {

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
// 授权Http请求相关配置
.authorizeHttpRequests(auth -> auth
.requestMatchers("/static/**").permitAll() // 静态资源请求全部允许
.anyRequest().authenticated()) // 其余任何请求都需要认证
// 表单登录相关配置
.formLogin(httpSecurityFormLoginConfigurer -> {
httpSecurityFormLoginConfigurer
// .loginPage("/login") // 指定登录页面,若不指定,使用SpringSecurity的默认登录页面
.loginProcessingUrl("/doLogin") // 登录表单提交的地址,由SpringSecurity负责处理
.defaultSuccessUrl("/", true) // 登录成功后跳转的页面,默认将index.html作为首页
.permitAll(); // 将登录相关的地址放行
})
// HttpSecurity实现了SecurityBuilder<DefaultSecurityFilterChain>接口,build返回的是DefaultSecurityFilterChain
.build();
}
}

Thymeleaf模板引擎

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

在默认情况下,resources目录下需要有两个目录:

  • templates - 所有模版文件都存放在这里
  • static - 所有静态资源都存放在这里

如果前端模板合适,只需要放入前端模版,就可以正常使用模版引擎。不需要在Controller中写任何内容,搭配SpringSecurity框架的自定义配置类,它默认将index.html作为首页文件,登录之后就能展示首页。这都是得益于约定大于配置的思想。

1
2
3
4
@Controller
public class TestController {
// 什么都不用写
}
前端资源无法直接正常使用的处理

柏码资源库 - 应用和资源 (itbaima.cn)下载的前端页面和资源并不是无需配置的适用SpringBoot的前端模板

image-20240802145051158

全部文件 > 视频教程 > 项目实战 > 模板.rar

其目录结构如下:
└─static
├─css
├─font
├─image
├─js
└─picture

└─*.html

模板文件和static目录处于同级目录下,各种html文件中引用静态资源的形式,是相对路径的引用直接将前端资源分别放入resources目录下的templatesstatic中,引用路径会有问题。以index.html为例:

1
2
3
<link rel="stylesheet" type="text/css" href="static/css/fonts.css">

<img src="static/picture/product2.png" alt="">

原因分析:

Spring Boot默认的关于静态资源的约定

  • spring.mvc.static-path-pattern 指定匹配静态资源请求的URL模式,默认为/**纯通配符的URL模式,不会对请求路径做额外处理
  • spring.web.resources.static-locations指定静态资源在服务器上的实际位置。默认为{ "classpath:/META-INF/resources/", "classpath:/resources/", "classpath:/static/", "classpath:/public/" }

因此形如"static/css/fonts.css"的静态资源链接路径,Spring Boot会将spring.web.resources.static-locations与请求的路径拼接,进行查找:

  • classpath:/META-INF/resources/static/css/fonts.css
  • classpath:/resources/static/css/fonts.css
  • classpath:/static/static/css/fonts.css
  • classpath:/public/static/css/fonts.css

而项目中关于前端资源,只在resources目录下创建了templatesstatic目录,实际编译完成后的类路径如下,即实际路径为classpath:/static/css/fonts.css因此,无法加载到静态资源故柏码的教程入门:常用框架快速整合中,才让创建static/static的目录,放入静态资源

image-20240806222905678

但是static/static目录实在太别扭了,应该有更合理的解决方式。从处理请求路径和查找静态资源的源码分析:

image-20240807104926952 image-20240807110607474

其实解决方式有3种:

  1. 修改静态资源的存储路径,与html中的请求路径相匹配(创建static/static的目录,放入静态资源)。
  2. 修改html中的请求路径,与静态资源的存储路径相匹配(去除static前缀)。
  3. 配置URL匹配模式,使请求路径去除模式匹配的部分后,与静态资源的存储路径相匹配(配置文件中,将匹配模式修改为/static/**)。

从前后端分离的角度,后端开发者首选第3种解决方式。在后续章节提到的配置文件application.yaml文件中,添加如下内容:

1
2
3
spring:
mvc:
static-path-pattern: /static/** # 匹配静态资源请求的URL模式(若请求路径与该模式相匹配,会去除路径中的非通配符部分)

MyBatis框架

1
2
3
4
5
6
7
8
9
10
11
12
<!-- mybatis-spring-boot-starter版本需要自己指定,因为它没有被父工程默认管理 -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>3.0.3</version>
</dependency>
<!-- 连接mysql的驱动 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>

mybatis-spring-boot-starter版本需要自己指定,因为它没有被父工程默认管理

如果就这样启动,会报错无法启动,这是没有配置数据源导致的。虽然SpringBoot采用约定大于配置的思想,但是数据库信息只有实际的项目搭建者清楚,而且变化多样,无法提前约定。因此,数据库信息还需要在配置文件中编写


自定义运行器

在项目中,可能会需要在项目启动完成之后,紧接着执行一段代码。可以通过编写自定义的ApplicationRunner来解决,它会在项目启动完成后执行

1
2
3
4
5
6
7
8
9
10
package com.hunter.springboot_demo.runner;

@Component
@Slf4j
public class TestRunner implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) throws Exception {
log.info("=========== test: 自定义执行! ===============");
}
}

也可以使用CommandLineRunner,它也支持使用@Order或是实现Ordered接口来支持优先级执行。


配置文件 *.properties*.yaml

SpringBoot可以带来快捷的开发体验,不过有些东西还是需要自己来编写配置,不然SpringBoot项目无法正常启动。可以直接在application.properties中进行配置编写,它是整个SpringBoot的配置文件

application.properties中的配置是各种Starter提供的部分配置在Starter中具有默认值,即使不配置也会使用默认值。除了配置已经存在的选项,也可以添加自定义的配置,以方便程序中使用。例如在application.properties中创建一个测试数据:

1
test.data=100

可以直接在程序中通过@Value访问:

1
2
3
4
5
@Controller
public class TestController {
@Value("${test.data}")
int data; //直接从配置中去取
}

SpringBoot还支持yaml格式的配置文件,它的语法如下:

1
2
3
4
5
6
7
8
一级目录:
二级目录:
三级目录1:
三级目录2:
三级目录List:
- 元素1
- 元素2
- 元素3

我们可以看到,每一级目录都是通过缩进(不能使用Tab,只能使用空格)区分,并且键和值之间需要添加冒号+空格来表示。我们可以将application.properties修改为application.yml或是application.yaml来使用YAML语法编写配置。


MyBatis相关配置

配置数据源,只要能正常连接到数据库,就能正常启动应用。

1
2
3
4
5
6
7
8
9
spring:
application:
name: springboot_demo
datasource:
url: jdbc:mysql://localhost:3306/demo
username: root
password: 123456.root
driver-class-name: com.mysql.cj.jdbc.Driver

之后,就可以正常使用MyBatis。在SpringBoot中使用Mybatis也很简单,不需要进行任何配置,直接编写Mapper即可

  1. 数据库建表。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    CREATE TABLE users
    (
    id INT NOT NULL PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(255) NOT NULL,
    email VARCHAR(255) NOT NULL,
    password VARCHAR(255) NOT NULL
    );

    INSERT INTO users(name, email, password)
    VALUES ('test', 'test@163.com', '123456');
  2. 创建对应的entity类。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    @Data
    public class User {
    private int id;

    private String name;

    private String email;

    private String password;
    }
  3. 在Spring配置类上添加@MapperScan注解,或者直接在Mapper接口上添加@Mapper注解,都可以将接口作为Mapper使用。

    1
    2
    3
    4
    5
    @Configuration
    @MapperScan("com.hunter.springboot_demo.mapper")
    public class WebConfiguration implements WebMvcConfigurer {

    }
    1
    2
    3
    4
    5
    @Mapper
    public interface UserMapper {
    @Select("SELECT * FROM user WHERE id = #{id}")
    User findUserById(int id);
    }

SpringSecurity和SpringMVC配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
spring:  
# Spring Mvc相关配置
mvc:
static-path-pattern: /static/** # 匹配静态资源请求的URL模式(默认是/**)
# Spring Security 相关配置
security:
filter:
order: -100 # Spring Security 过滤器优先级
user:
name: admin # 默认登录用户名
password: 123456 # 默认登录密码
roles: # 默认用户的角色
- admin
- user

打包运行

IDEA创建SpringBoot项目,**pom.xml文件中默认包含了maven插件,能够直接把项目打包成可执行的jar包,然后用java -jar命令直接运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
<plugin>
<!-- 用于打包可执行的 jar 或 war 文件 -->
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
image-20240807120926775

只要能够安装JRE环境,SpringBoot项目就可以通过命令一键运行。


SpringBoot3之后,对GraalVM进行了支持

GraalVM 是一种通用的虚拟机,最初由 Oracle 开发。它支持多种编程语言(例如 Java、JavaScript、Python 等),可以在不同的环境中运行,并提供高性能和低内存消耗。

GraalVM的核心是一个即时编译器,它能够将各种语言的代码直接编译成本地机器码,以获得更高的性能。此外,GraalVM 还提供了一个强大的运行时环境,包括垃圾回收器、即时编译器、线程管理器等,可以提供更好的性能和可扩展性。

GraalVM 的一个重要特性是它的跨语言互操作性。GraalVM 可以使不同语言之间的互操作更加容易。例如,你可以在 Java 代码中直接调用 JavaScript 函数,或者在 JavaScript 代码中直接调用 Java 类。这使得在不同语言之间共享和复用代码变得更加容易。

总的来说,GraalVM 是一个开创性的技术,可以提供出色的性能和灵活性,同时也为多语言开发提供了更好的支持。它是一个非常有潜力的工具,可以用于构建高效的应用程序和解决方案。

image-20230716160131837

简而言之,SpringBoot项目除了打包为传统的Jar包基于JVM运行之外,也可以将其直接编译为操作系统原生的程序来进行使用(这样会大幅提升程序的运行效率,但是由于编译为操作系统原生程序,这将无法支持跨平台)。


日志系统

SpringBoot提供了丰富的日志系统,它几乎是开箱即用的。在之前学习SSM时,如果不配置日志,就会报错,但是到了SpringBoot阶段之后似乎这个问题就不见了,日志打印得也非常统一,这是为什么呢?

SpringBoot为了统一日志框架的使用,做了这些事情:

  • 直接将其他依赖以前的日志框架剔除
  • 导入对应日志框架的Slf4j中间包
  • 导入自己官方指定的日志实现,并作为Slf4j的日志实现层
image-20240807192055568

查看依赖可知,SpringBoot使用logback作为日志框架(实现slf4j日志门面接口)。

包含的jul-to-slf4jlog4j-to-slf4j依赖,能使jul和log4j2也”实现”slf4j日志门面

参考:Java日志


打印日志信息

由于SpringBoot默认包含了logback依赖,打印日志可以像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Controller
public class TestController {
private static final Logger LOGGER = LoggerFactory.getLogger(TestController.class);

@Resource
private UserMapper userMapper;

@RequestMapping("/test")
@ResponseBody
public User test() {
LOGGER.info("用户访问了一次测试数据");
return userMapper.findUserById(1);
}
}

因为使用了Lombok,可以直接使用@Slf4j注解:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Controller
@Slf4j
public class MainController {
@Resource
private UserMapper userMapper;

@RequestMapping("/test")
@ResponseBody
public User test() {
log.info("用户访问了一次测试数据");
return userMapper.findUserById(1);
}
}

日志级别从低到高分为TRACE < DEBUG < INFO < WARN < ERROR < FATAL,SpringBoot默认只会打印INFO以上级别的信息


配置Logback日志

Logging :: Spring Boot

Spring Boot 包含许多对 Logback 的扩展,可以帮助进行高级配置。您可以在 logback-spring.xml 配置文件中使用这些扩展。

logback-spring.xml是SpringBoot下Logback专用的配置,可以使用SpringBoot的高级Profile功能。

SpringBoot在org/springframework/boot/logging/logback/下预设了日志配置:

  • defaults.xml - 提供转换规则、模式属性和常用记录器配置。
  • console-appender.xml - 使用 CONSOLE_LOG_PATTERN添加一个ConsoleAppender
  • file-appender.xml - 使用具有适当设置的FILE_LOG_PATTERNROLLING_FILE_NAME_PATTERN添加 RollingFileAppender

查看console-appender.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?xml version="1.0" encoding="UTF-8"?>

<!--
Console appender logback configuration provided for import, equivalent to the programmatic
initialization performed by Boot
-->

<included>
<!-- 将日志输出到Console的appender -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<!-- 只允许等于或高于指定级别的日志通过 -->
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>${CONSOLE_LOG_THRESHOLD}</level>
</filter>
<!-- 定义日志输出格式和字符集 -->
<encoder>
<pattern>${CONSOLE_LOG_PATTERN}</pattern>
<charset>${CONSOLE_LOG_CHARSET}</charset>
</encoder>
</appender>
</included>

查看file-appender.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<?xml version="1.0" encoding="UTF-8"?>

<!--
File appender logback configuration provided for import, equivalent to the programmatic
initialization performed by Boot
-->

<included>
<!-- 使用RollingFileAppender,日志不仅会写入文件,还会根据一定策略进行滚动 -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<!-- 只允许等于或高于指定级别的日志通过 -->
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>${FILE_LOG_THRESHOLD}</level>
</filter>
<!-- 定义日志输出格式和字符集 -->
<encoder>
<pattern>${FILE_LOG_PATTERN}</pattern>
<charset>${FILE_LOG_CHARSET}</charset>
</encoder>
<!-- 指定日志文件的路径和名称 -->
<file>${LOG_FILE}</file>
<!-- 配置滚动策略 -->
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<!-- 滚动文件命名模式,包括日期和序列号,并支持.gz压缩 -->
<fileNamePattern>${LOGBACK_ROLLINGPOLICY_FILE_NAME_PATTERN:-${LOG_FILE}.%d{yyyy-MM-dd}.%i.gz}</fileNamePattern>
<!-- 是否启动时清理旧的日志文件 -->
<cleanHistoryOnStart>${LOGBACK_ROLLINGPOLICY_CLEAN_HISTORY_ON_START:-false}</cleanHistoryOnStart>
<!-- 单个日志文件的最大大小 -->
<maxFileSize>${LOGBACK_ROLLINGPOLICY_MAX_FILE_SIZE:-10MB}</maxFileSize>
<!-- 所有文件总大小上限,达到上限后,最旧的文件会被删除 -->
<totalSizeCap>${LOGBACK_ROLLINGPOLICY_TOTAL_SIZE_CAP:-0}</totalSizeCap>
<!-- 保存日志文件的最大时间 -->
<maxHistory>${LOGBACK_ROLLINGPOLICY_MAX_HISTORY:-7}</maxHistory>
</rollingPolicy>
</appender>
</included>

按照SpringBoot官网描述,典型的自定义logback配置文件/resource/logback-spring.xml内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- include resource:导入其他配置文件,作为预设 -->
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>

<!-- 配置属性 -->
<property name="LOG_FILE" value="log/springboot_demo.log"/>
<property name="LOGBACK_ROLLINGPOLICY_FILE_NAME_PATTERN" value="${LOG_FILE}.%d{yyyy-MM-dd}.%i.gz"/>
<property name="LOGBACK_ROLLINGPOLICY_CLEAN_HISTORY_ON_START" value="true"/>

<!-- 控制台输出的配置 -->
<include resource="org/springframework/boot/logging/logback/console-appender.xml" />
<!-- 文件打印的配置 -->
<include resource="org/springframework/boot/logging/logback/file-appender.xml" />

<!-- 指定日志输出级别 -->
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/>
</root>
<!-- 指定org.springframework.web包下 日志打印的级别 -->
<logger name="org.springframework.web" level="DEBUG"/>
</configuration>

Logback内置的日志字段比较少,如果我们需要打印有关业务的更多的内容,包括自定义的一些数据,需要借助Logback MDC(Mapped Diagnostic Context,映射诊断上下文)机制,即将一些运行时的上下文数据通过logback打印出来。需要借助**org.sl4j.MDC类**。

比如我们现在需要记录是哪个用户访问我们网站的日志,只要有用户访问我们网站,就在日志中携带该用户的sessionId,而官方提供的字段无法实现此功能,这时就需要使用MDC机制:

1
2
3
4
5
6
@RequestMapping(value = "/test", method = RequestMethod.GET)
@ResponseBody
public User test(HttpSession session) {
MDC.put("requestId", session.getId());
return userMapper.findUserById(1);
}

/resource/logback-spring.xml中添加控制台日志的模式属性,通过占位符%X{key}就能打印出MDC中存放的值。向MDC中添加信息后,只要是当前线程(本质是ThreadLocal实现)下输出的日志,都会自动替换占位符

1
2
3
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>

<property name="CONSOLE_LOG_PATTERN" value="%clr(%d{${LOG_DATEFORMAT_PATTERN:-yyyy-MM-dd'T'HH:mm:ss.SSSXXX}}){faint} %clr(${LOG_LEVEL_PATTERN:-%5p}) %clr([%X{requestId}]){faint} %clr(${PID:- }){magenta} %clr(---){faint} %clr(%applicationName[%15.15t]){faint} %clr(${LOG_CORRELATION_PATTERN:-}){faint}%clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}}"/>

image-20240807220336642


自定义Banner展示

Banner部分和日志部分是独立的,SpringBoot启动后,会先打印Banner部分。可以在配置文件所在目录下创建一个名为banner.txt的文本文档,就能完成banner的替换。

可以使用在线生成网站进行生成自己的个性Banner:https://www.bootschool.net/ascii

还可以在banner.txt中使用颜色代码来为文本切换颜色:

1
${AnsiColor.BRIGHT_GREEN}  //绿色

也可以获取一些常用的变量信息:

1
当前 Spring Boot 版本:${spring-boot.version}

多环境配置

application.propertiesapplication.yml

在日常开发中,我们项目会有多个环境。例如开发环境(develop)、生产环境(production )。不同的环境下,可能我们的配置文件也存在不同,但是我们不可能切换环境的时候又去重新写一次配置文件,所以我们可以将多个环境的配置文件提前写好,进行自由切换

由于SpringBoot只会读取application.properties或是application.yml文件,那么怎么才能实现自由切换呢?可以application.yml中添加如下内容:

1
2
3
spring:
profiles:
active: dev

接着分别创建两个环境的配置文件,application-dev.ymlapplication-prod.yml分别表示开发环境和生产环境的配置文件,比如开发环境我们使用的服务器端口为8080,而生产环境下可能就需要设置为80或是443端口,那么这个时候就需要不同环境下的配置文件进行区分。

1
2
server:
port: 80

这样就可以通过修改application.yml中的spring.profiles.active值,灵活地切换生产环境和开发环境下的配置了。(后续在Maven配置中,还可以与pom.xml文件中的标签进行关联)


logback-spring.xml

SpringBoot自带的Logback日志系统也支持多环境配置,比如想在开发环境下输出日志到控制台,而生产环境下只需要输出到文件即可,这时就需要在logback-spring.xml中进行环境配置:

1
2
3
4
5
6
7
8
9
10
11
12
<springProfile name="dev">
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/>
</root>
</springProfile>

<springProfile name="prod">
<root level="INFO">
<appender-ref ref="FILE"/>
</root>
</springProfile>

Maven配置

如果我们希望生产环境中不要打包开发环境下的配置文件呢?我们目前虽然可以切换开发环境,但是打包的时候依然是所有配置文件全部打包,打包的问题就只能找Maven解决了,Maven也可以设置多环境,在pom.xml中添加:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
<dependencies>
...
</dependencies>

<!--分别设置开发,生产环境-->
<profiles>
<!-- 开发环境 -->
<profile>
<id>dev</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<environment>dev</environment>
</properties>
</profile>
<!-- 生产环境 -->
<profile>
<id>prod</id>
<activation>
<activeByDefault>false</activeByDefault>
</activation>
<properties>
<environment>prod</environment>
</properties>
</profile>
</profiles>

<build>
<plugins>
...
</plugins>
<resources>
<!-- 排除配置文件 -->
<resource>
<directory>src/main/resources</directory>
<!-- 先排除所有的yaml配置文件 -->
<excludes>
<!-- 使用通配符,可以定义多个exclude标签进行排除 -->
<!-- 注意实际配置文件后缀名为yml的情况 -->
<exclude>application*.yaml</exclude>
</excludes>
</resource>

<!-- 根据激活条件引入打包所需的配置和文件 -->
<resource>
<directory>src/main/resources</directory>
<!-- 引入所需环境的配置文件 -->
<filtering>true</filtering>
<includes>
<!-- 注意实际配置文件后缀名为yml的情况 -->
<include>application.yaml</include>
<!-- 根据maven选择环境导入配置文件 -->
<include>application-${environment}.yaml</include>
</includes>
</resource>
</resources>
</build>

在SpringBoot的配置文件中,添加如下内容,从而在构建项目时,替换为对应的值:

1
2
3
spring:
profiles:
active: '@environment@' # 关联到pom.xml中的<environment>标签(YAML配置文件需要加单引号,否则会报错)

最后在IDEA中,使用Maven构建项目时,勾选期望的profile,就能打包指定的配置文件

image-20240807223813245

常用框架

邮件发送框架

在注册很多的网站时,都会遇到邮件或是手机号验证,也就是通过你的邮箱或是手机短信去接受网站发给你的注册验证信息,填写验证码之后,就可以完成注册了,同时,网站也会绑定你的手机号或是邮箱。

SpringBoot提供了封装好的邮件框架:

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>

要在Internet上提供电子邮件功能,必须有专门的电子邮件服务器。例如现在Internet很多提供邮件服务的厂商:新浪、搜狐、163、QQ邮箱等,他们都有自己的邮件服务器。这些服务器类似于现实生活中的邮局,它主要负责接收用户投递过来的邮件,并把邮件投递到邮件接收者的电子邮箱中。

邮件传输有自己的协议,比较常用的有两种:

  1. SMTP协议(主要用于发送邮件 Simple Mail Transfer Protocol)
  2. POP3协议(主要用于接收邮件 Post Office Protocol 3)

整个发送/接收流程大致如下:

img

实际上每个邮箱服务器都有一个smtp发送服务器和pop3接收服务器。比如要从QQ邮箱发送邮件到163邮箱,流程是:

  1. 通过QQ邮箱客户端告知QQ邮箱的smtp服务器我们需要发送邮件,以及邮件的相关信息。
  2. QQ邮箱的smtp服务器就会帮助我们发送到163邮箱的pop3服务器上。
  3. 163邮箱会通过163邮箱客户端告知对应用户收到一封新邮件。

我们如果想要实现给别人发送邮件,那么就需要连接到对应电子邮箱的smtp服务器上,并告知其我们要发送邮件。SpringBoot已经帮助我们将最基本的底层通信全部实现了,只需要关心smtp服务器的地址以及要发送的邮件内容即可。

以163邮箱为例,我们需要在配置文件中告诉SpringBootMail我们的smtp服务器的地址以及邮箱账号和密码,首先我们要去163邮箱的设置中开启smtp/pop3服务才可以,开启后会得到一个随机生成的密钥,这个就是我们的密码。

1
2
3
4
5
spring:
mail:
host: smtp.163.com
username: hspecial@163.com
password: ZVIDFPFUTDHOMVVC # 开启smtp/pop3时自动生成的,记得保存一下,不然就找不到了

配置完成后,可以通过如下方式发送邮件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@SpringBootTest
class SpringbootDemoApplicationTests {
// JavaMailSender是专门用于发送邮件的对象,自动配置类已经提供了Bean
@Resource
JavaMailSender mailSender;

@Test
void contextLoads() throws MessagingException {
// SimpleMailMessage是一个比较简易的邮件封装,支持设置一些比较简单内容
SimpleMailMessage message = new SimpleMailMessage();
message.setSubject("Test");
message.setText("测试内容");
// 设置邮件发送给谁,可以多个
message.setTo("545982472@qq.com");
// 邮件发送者,要与配置文件中的保持一致
message.setFrom("hspecial@163.com");
mailSender.send(message);
}

}

如果要添加附件,可以使用MimeMessageHelper

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Test
void contextLoads() throws MessagingException {
// 可以添加附件的消息
MimeMessage message = mailSender.createMimeMessage();
// 借助 MimeMessageHelper 修改 MimeMessage 中的信息
MimeMessageHelper messageHelper = new MimeMessageHelper(message, true);
messageHelper.setSubject("Test");
messageHelper.setText("测试内容");
// 设置邮件发送给谁,可以多个
messageHelper.setTo("545982472@qq.com");
// 邮件发送者,要与配置文件中的保持一致
messageHelper.setFrom("hspecial@163.com");
mailSender.send(message);
}

通过邮箱获取验证码

添加注册接口与页面
  1. 将前文中下载的前端模板中的register.html放入resources/tmplates下。

  2. 编写Controller类跳转至register.html

    1
    2
    3
    4
    5
    6
    7
    8
    @Controller
    public class PageController {

    @RequestMapping(value = "/")
    public String register() {
    return "register";
    }
    }

发送验证码到指定邮箱
  1. 编写生成验证码的工具类。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    @Slf4j
    public class VerificationCodeGenerateUtil {
    private static Random random = new Random();

    public static final int TEN_THOUSAND = 10_000;

    public static final String CHAR_LOWER = "abcdefghijklmnopqrstuvwxyz";

    public static final String CHAR_UPPER = CHAR_LOWER.toUpperCase();

    public static final String NUMBER = "0123456789";

    // 用于随机生成验证码的字符串
    public static final String DATA_FOR_RANDOM = CHAR_LOWER + CHAR_UPPER + NUMBER;

    /**
    * 生成4位数的纯数字验证码
    *
    * @return 4位数字验证码
    */
    public static String generate4DigitPureDigitalCode() {
    // 确保生成的数在 0 到 9999 之间
    int code = random.nextInt(TEN_THOUSAND);
    // 格式化成4位数的字符串,不足4位在前面补0
    return String.format("%04d", code);
    }

    /**
    * 生成指定长度的混合数字、大小写字母的验证码
    *
    * @param length 验证码长度
    * @return 指定长度的混合验证码
    */
    public static String generate4DigitMixedCode(int length) {
    if (length < 1) {
    log.error("generate code length < 1");
    throw new IllegalArgumentException("length must >= 1");
    }
    StringBuilder stringBuilder = new StringBuilder();
    for (int i = 0; i < length; i++) {
    // 指向随机一个DATA_FOR_RANDOM的下标
    int index = random.nextInt(DATA_FOR_RANDOM.length());
    stringBuilder.append(DATA_FOR_RANDOM.charAt(index));
    }
    return String.valueOf(stringBuilder);
    }
    }
  2. 编写相应的Service接口和ServiceImpl实现类。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    public interface RegisterService {
    /**
    * 发送邮件
    *
    * @param verificationCode 要发送的验证码
    * @param receivingMailAddr 收件的邮箱
    */
    void sendVerificationCodeToMail(String verificationCode, String receivingMailAddr);
    }


    @Service
    @Slf4j
    public class RegisterServiceImpl implements RegisterService {
    // JavaMailSender是专门用于发送邮件的对象,自动配置类已经提供了Bean
    @Resource
    JavaMailSender mailSender;

    // 发件方的邮箱,使用占位符,交给SpringBoot配置文件统一管理
    @Value("${spring.mail.username}")
    private String emailSenderAddr;

    @Override
    public void sendVerificationCodeToMail(String verificationCode, String receivingMailAddr) {
    // 可以添加附件的消息
    SimpleMailMessage mailMessage = new SimpleMailMessage();
    mailMessage.setSubject("请查收验证码");
    mailMessage.setText("验证码:【" + verificationCode + "】,有效时间15分钟,请妥善保存!");
    // 邮件发送者
    mailMessage.setFrom(emailSenderAddr);
    mailMessage.setTo(receivingMailAddr);
    mailSender.send(mailMessage);
    log.info("邮件发送成功");
    }
    }
  3. 编写Controller,生成的验证码放入session,发送给指定邮箱。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    @Controller
    public class RegisterController {
    private static final String VERIFICATION_CODE = "verificationCode";

    private static final String EMAIL = "email";

    @Resource
    RegisterService registerService;

    /**
    * 获取验证码
    *
    * @param session session
    * @param email 接收验证码的邮箱
    * @return 注册成功
    */
    @RequestMapping(value = "/get_verification_code", method = RequestMethod.POST)
    @ResponseBody
    public String getVerificationCode(HttpSession session, @RequestParam String email) {
    String verificationCode = VerificationCodeGenerateUtil.generate4DigitPureDigitalCode();
    // 保证只有收到验证码的邮箱能完成注册
    session.setAttribute(VERIFICATION_CODE, verificationCode);
    session.setAttribute(EMAIL, email);

    registerService.sendVerificationCodeToMail(verificationCode, email);
    return "发送成功";
    }
    }
  4. 如果配置了SpringSecurity,需要让注册页面和发送验证码给指定邮箱的接口免登录验证,并且关闭全部CSRF校验,以顺利访问这些接口。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    @Configuration
    public class SecurityConfiguration {
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
    return httpSecurity
    // 授权Http请求相关配置
    .authorizeHttpRequests(auth -> auth
    .requestMatchers("/static/**",
    "/", "/get_verification_code").permitAll() // 允许请求静态资源 以及 指定的路径
    .anyRequest().authenticated()) // 其余任何请求都需要认证
    ...
    .csrf(AbstractHttpConfigurer::disable) // 关闭全部CSRF校验
    .build();
    }
    }
  5. 前端页面register.html添加axios框架,并做发送验证码的相应改动。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    <html lang="zxx">
    <head>
    ...
    <link rel="stylesheet" type="text/css" href="static/css/auth.css">
    <!-- axios框架 -->
    <script src="https://unpkg.com/axios@1.1.2/dist/axios.min.js"></script>
    </head>

    <body>
    ...
    <div class="ad-auth-form">
    <div class="ad-auth-feilds mb-30">
    <input type="text" placeholder="用户名" class="ad-input">
    ...
    </div>
    <div class="ad-auth-feilds mb-30">
    <input id="email" type="text" placeholder="邮箱" class="ad-input">
    ...
    </div>
    <div class="ad-auth-feilds mb-30" style="display: flex">
    <input type="text" placeholder="验证码" class="ad-input">
    <div class="ad-auth-btn">
    <a href="javascript:getVerificationCode();" class="ad-btn ad-login-member">获取验证码</a>
    </div>
    </div>
    <div class="ad-auth-feilds">
    <input type="password" placeholder="密码" class="ad-input">
    ...
    </div>
    </div>
    ...
    </body>
    </html>

    <script>
    function getVerificationCode() {
    axios.post('/get_verification_code', {
    email: document.getElementById('email').value
    }, {
    headers: {
    'Content-Type': 'application/x-www-form-urlencoded'
    }
    }).then(({data}) => {
    alert(data)
    })
    }
    </script>


使用邮箱及验证码进行用户注册

  1. 数据库对应的用户表。

    1
    2
    3
    4
    5
    6
    7
    CREATE TABLE users
    (
    id INT NOT NULL PRIMARY KEY AUTO_INCREMENT,
    username VARCHAR(255) NOT NULL,
    email VARCHAR(255) NOT NULL,
    password VARCHAR(255) NOT NULL
    );
  2. 编写User类和UserMapper接口。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    @Data
    public class User {
    private int id;

    private String username;

    private String email;

    private String password;
    }
    1
    2
    3
    4
    5
    @Mapper
    public interface UserMapper {
    @Insert("INSERT INTO users(username, email, password) VALUES(#{username}, #{email}, #{password})")
    void createUser(String username, String email, String password);
    }
  3. RegisterService中添加注册用户的方法。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    public interface RegisterService {
    /**
    * 发送邮件
    *
    * @param verificationCode 要发送的验证码
    * @param receivingMailAddr 收件的邮箱
    */
    void sendVerificationCodeToMail(String verificationCode, String receivingMailAddr);

    /**
    * 注册用户
    *
    * @param username 用户名
    * @param email 邮箱
    * @param password 密码
    */
    void createUser(String username, String email, String password);
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    @Service
    @Slf4j
    public class RegisterServiceImpl implements RegisterService {
    ...

    @Resource
    UserMapper userMapper;

    @Override
    public void createUser(String username, String email, String password) {
    userMapper.createUser(username, email, password);
    }

    ...
    }
  4. Controller中添加注册用户接口。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    @Controller
    public class RegisterController {
    private static final String VERIFICATION_CODE = "verificationCode";

    private static final String EMAIL = "email";

    @Resource
    RegisterService registerService;

    @RequestMapping(value = "/register", method = RequestMethod.POST)
    @ResponseBody
    public String register(HttpSession session, @RequestParam String username, @RequestParam String email,
    @RequestParam String verificationCode, @RequestParam String password) {
    String sessionVerificationCode = (String) session.getAttribute(VERIFICATION_CODE);
    String sessionEmail = (String) session.getAttribute(EMAIL);

    if (sessionVerificationCode == null || sessionEmail == null || !sessionEmail.equals(email)) {
    return "请先获取验证码";
    }
    if (!sessionVerificationCode.equals(verificationCode)) {
    return "验证码错误";
    }

    registerService.createUser(username, email, password);
    return "注册成功";
    }
    }
  5. 如果配置了SpringSecurity,需要让注册用户的接口免登录验证,并且关闭全部CSRF校验,以顺利访问这些接口。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    @Configuration
    public class SecurityConfiguration {
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
    return httpSecurity
    // 授权Http请求相关配置
    .authorizeHttpRequests(auth -> auth
    .requestMatchers("/static/**",
    "/", "/get_verification_code", "/register").permitAll() // 允许请求静态资源 以及 指定的路径
    .anyRequest().authenticated()) // 其余任何请求都需要认证
    ...
    .csrf(AbstractHttpConfigurer::disable) // 关闭全部CSRF校验
    .build();
    }
    }
  6. 前端页面register.html中,表单的各个输入框添加id,添加注册接口的调用。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    <body>
    <div class="ad-auth-feilds mb-30">
    <input id="username" type="text" placeholder="用户名" class="ad-input">
    ...
    </div>
    <div class="ad-auth-feilds mb-30" style="display: flex">
    <input id="verificationCode" type="text" placeholder="验证码" class="ad-input">
    <div class="ad-auth-btn">
    <a href="javascript:getVerificationCode();" class="ad-btn ad-login-member">获取验证码</a>
    </div>
    </div>
    ...
    <div class="ad-auth-feilds">
    <input id="password" type="password" placeholder="密码" class="ad-input">
    ...
    </div>
    ...
    <div class="ad-auth-btn">
    <a href="javascript:register();" class="ad-btn ad-login-member">注册</a>
    </div>
    ...
    </body>

    </html>

    <script>
    ...

    function register() {
    axios.post('/register', {
    username: document.getElementById('username').value,
    email: document.getElementById('email').value,
    verificationCode: document.getElementById('verificationCode').value,
    password: document.getElementById('password').value
    }, {
    headers: {
    'Content-Type': 'application/x-www-form-urlencoded'
    }
    }).then(({data}) => {
    alert(data)
    })
    }
    </script>

接口规则校验框架

SpringBoot为我们提供了很方便的接口校验框架

1
2
3
4
5
<!-- 接口校验框架 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

使用注解完成接口校验

验证注解 验证的数据类型 说明
@AssertFalse Boolean,boolean 值必须是false
@AssertTrue Boolean,boolean 值必须是true
@NotNull 任意类型 值不能是null
@Null 任意类型 值必须是null
@Min BigDecimal、BigInteger、byte、short、int、long、double 以及任何Number或CharSequence子类型 大于等于@Min指定的值
@Max 同上 小于等于@Max指定的值
@DecimalMin 同上 大于等于@DecimalMin指定的值(超高精度)
@DecimalMax 同上 小于等于@DecimalMax指定的值(超高精度)
@Digits 同上 限制整数位数和小数位数上限
@Size 字符串、Collection、Map、数组等 长度在指定区间之内,如字符串长度、集合大小等
@Past 如 java.util.Date, java.util.Calendar 等日期类型 值必须比当前时间早
@Future 同上 值必须比当前时间晚
@NotBlank CharSequence及其子类 值不为空,在比较时会去除字符串的首位空格
@Length CharSequence及其子类 字符串长度在指定区间内
@NotEmpty CharSequence及其子类、Collection、Map、数组 值不为null且长度不为空(字符串长度不为0,集合大小不为0)
@Range BigDecimal、BigInteger、CharSequence、byte、short、int、long 以及原子类型和包装类型 值在指定区间内
@Email CharSequence及其子类 值必须是邮件格式
@Pattern CharSequence及其子类 值需要与指定的正则表达式匹配
@Valid 任何非原子类型 用于验证对象属性

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Slf4j
@Validated // 首先在Controller上开启接口校验
@Controller
public class TestController {

...

@PostMapping("/submit")
@ResponseBody
public String submit(@Length(min = 3) String username, // 使用@Length注解
@Length(min = 10) String password){
System.out.println(username.substring(3));
System.out.println(password.substring(2, 10));
return "请求成功!";
}
}

如果接口校验失败,会抛出ConstraintViolationException异常,对用户不太友好,参考SpringMVC 异常处理SpringBoot全局异常统一处理,可以进行全局异常统一处理

1
2
3
4
5
6
7
8
9
@ControllerAdvice // 表明统一处理异常
public class GlobalExceptionHandler {

@ExceptionHandler(ConstraintViolationException.class) // 捕获 违反约束的异常
@ResponseBody
public String error(ValidationException e){ // 可以直接添加形参来获取异常
return e.getMessage(); // 出现异常直接返回消息
}
}

对象类型的校验

比如:

1
2
3
4
5
6
@PostMapping("/submit")
@ResponseBody
public String submit(@Valid User user) { // 直接使用对象接收表单数据,在参数上添加@Valid注解表示需要验证
...
return "请求成功!";
}
1
2
3
4
5
6
7
8
9
10
11
@Data
public class User {
private int id;

@Length(min = 3) // 在对应的字段上添加校验的注解
private String username;

private String email;

private String password;
}

对于实体类接收参数的验证,会抛出MethodArgumentNotValidException异常,也可交由全局异常统一处理器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@ControllerAdvice // 表明统一处理异常
public class GlobalExceptionHandler {

@ExceptionHandler(ConstraintViolationException.class) // 捕获 违反约束的异常
@ResponseBody
public String error(ValidationException e){ // 可以直接添加形参来获取异常
return e.getMessage(); // 出现异常直接返回消息
}

@ExceptionHandler(MethodArgumentNotValidException.class) // 捕获 方法参数无效的异常
@ResponseBody
public String error(MethodArgumentNotValidException e){ // 可以直接添加形参来获取异常
if (exception.getFieldError() == null) {
return "未知错误";
}
return exception.getFieldError().getDefaultMessage();
}
}

接口文档生成框架

在后续学习前后端分离开发中,前端现在由专业的人来做,而我们往往只需要关心后端提供什么接口给前端人员调用,提供一个可以参考的文档是很有必要的

Swagger的主要功能如下:

  • 支持 API 自动生成同步的在线文档:使用 Swagger 后可以直接通过代码生成文档,不再需要自己手动编写接口文档。
  • 提供 Web 页面在线测试 API:参数和格式都定好了,直接在界面上输入参数对应的值即可在线测试接口。

结合Spring框架(Spring-doc,官网:https://springdoc.org),Swagger可以很轻松地**利用注解以及扫描机制,来快速生成在线文档,以实现当我们项目启动之后,前端开发人员就可以打开Swagger提供的前端页面,查看和测试接口**。依赖如下:

1
2
3
4
5
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.6.0</version>
</dependency>

项目启动之后,我们可以直接访问:http://localhost:8080/swagger-ui/index.html,就能看到我们的开发文档。


Spring Boot启动流程

@SpringBootApplication注解

Spring Boot的启动,首先需要一个加了@SpringBootApplication注解的启动类

@SpringBootApplication 注解

这个注解本质上,是由@EnableAutoConfiguration@SpringBootConfiguration@ComponentScan三个注解连起来构成。

  • @EnableAutoConfiguration最为核心

    会导入自动配置AutoConfigurationImportSelector类,这个类会将所有符合条件的@Configuration配置都进行加载。

    @EnableAutoConfiguration 注解
  • @SpringBootConfiguration等同于@Configuration

    将当前类标记为配置类,加载到容器中。

    @SpringBootConfiguration 注解

  • @ComponentScan自动扫描并加载符合条件的Bean


SpringApplication.run方法

image-20231028185647082

在run方法开始执行后,会经历如下4个阶段

  1. 服务构建

    构建SpringApplication本身。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    public SpringApplication(ResourceLoader resourceLoader, Class<?>... primarySources) {
    // 1. 将资源加载器、主方法类加载至内存中
    this.resourceLoader = resourceLoader;
    Assert.notNull(primarySources, "PrimarySources must not be null");
    this.primarySources = new LinkedHashSet<>(Arrays.asList(primarySources));

    // 2. 逐一判断对应的服务类是否存在,来确定Web服务的类型。默认是基于Servlet的Web服务,如Tomcat
    this.webApplicationType = WebApplicationType.deduceFromClasspath();

    // 3. 加载初始化类,读取所有"META-INF/spring.factories"文件中的
    // 注册初始化、上下文初始化、监听器这三类配置
    // Spring Boot和Spring Boot Autoconfigure这两个工程中配置了7个上下文初始化和8个监听器
    this.bootstrapRegistryInitializers = new ArrayList<>(
    getSpringFactoriesInstances(BootstrapRegistryInitializer.class)); // 没有默认的注册初始化配置
    setInitializers((Collection) getSpringFactoriesInstances(ApplicationContextInitializer.class));
    setListeners((Collection) getSpringFactoriesInstances(ApplicationListener.class));

    // 通过StackWalker判断出main方法所在的类(大概率就是启动类本身)
    this.mainApplicationClass = deduceMainApplicationClass();
    }
  2. 环境准备

    SpringApplication.run就是进入环境准备阶段。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    public ConfigurableApplicationContext run(String... args) {
    // ...

    DefaultBootstrapContext bootstrapContext = createBootstrapContext();
    ConfigurableApplicationContext context = null;
    configureHeadlessProperty();
    SpringApplicationRunListeners listeners = getRunListeners(args);
    listeners.starting(bootstrapContext, this.mainApplicationClass);
    try {
    ApplicationArguments applicationArguments = new DefaultApplicationArguments(args);
    ConfigurableEnvironment environment = prepareEnvironment(listeners, bootstrapContext, applicationArguments);
    Banner printedBanner = printBanner(environment);
    context = createApplicationContext();
    context.setApplicationStartup(this.applicationStartup);
    prepareContext(bootstrapContext, context, environment, listeners, applicationArguments, printedBanner);
    refreshContext(context);
    afterRefresh(context, applicationArguments);
    Duration timeTakenToStartup = Duration.ofNanos(System.nanoTime() - startTime);
    if (this.logStartupInfo) {
    new StartupInfoLogger(this.mainApplicationClass).logStarted(getApplicationLog(), timeTakenToStartup);
    }
    listeners.started(context, timeTakenToStartup);
    callRunners(context, applicationArguments);
    }
    catch (Throwable ex) {
    if (ex instanceof AbandonedRunException) {
    throw ex;
    }
    handleRunFailure(context, ex, listeners);
    throw new IllegalStateException(ex);
    }
    try {
    if (context.isRunning()) {
    Duration timeTakenToReady = Duration.ofNanos(System.nanoTime() - startTime);
    listeners.ready(context, timeTakenToReady);
    }
    }
    catch (Throwable ex) {
    if (ex instanceof AbandonedRunException) {
    throw ex;
    }
    handleRunFailure(context, ex, null);
    throw new IllegalStateException(ex);
    }
    return context;
    }
  3. 容器创建

  4. 填充容器

image-20240710223043065

image-20240710223117614


数据交互

JDBC交互框架

除了MyBatis之外,实际上Spring官方也提供了一个非常方便的JDBC交互框架,它同样可以快速进行增删改查。

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>

JDBC模板类

要操作数据库,最简单直接的方法就是使用JdbcTemplate来完成:

1
2
@Resource
JdbcTemplate jdbcTemplate;

JdbcTemplate封装了很多方法,比如需要查询数据库中的一条记录:

id name email password
1 test hspecial@163.com 123456
  • 可以使用queryForMap,快速以Map为结果的形式,查询一行数据:

    1
    2
    Map<String, Object> map = jdbcTemplate.queryForMap("SELECT * FROM user WHERE id =?", 1);
    System.out.println(map);
  • 可以使用queryForObject,编写自定义的Mapper用于直接得到查询结果:

    1
    2
    3
    4
    User user = jdbcTemplate.queryForObject("SELECT * FROM user WHERE id =?",
    // rs 表示 ResultSet(查询结果集),rowNum 表示行号,在此处未使用。
    (rs, rowNum) -> new User(rs.getInt(1), rs.getString(2), rs.getString(3), rs.getString(4)), 1);
    System.out.println(user);
  • 除此之外,还提供了update方法,适用于各种情况的查询、更新、删除操作:

    1
    2
    int update = template.update("insert into user values(2, 'admin', '654321@qq.com', '123456')");
    System.out.println("更新了 "+update+" 行");

这样,如果是那种非常小型的项目,甚至是测试用例的话,都可以快速使用JdbcTemplate快速进行各种操作。


JDBC简单封装

对于一些插入操作,Spring JDBC为我们提供了更方便的SimpleJdbcInsert工具,它可以实现更多高级的插入功能,比如我们的表主键采用的是自增ID,那么它支持插入后返回自动生成的ID

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Resource
DataSource dataSource;

@Test
void testJdbcTemplate() {
SimpleJdbcInsert simpleJdbcInsert = new SimpleJdbcInsert(dataSource)
.withTableName("user")
.usingGeneratedKeyColumns("id"); // 指定在插入操作后,需要返回的生成键列
Map<String, Object> user = new HashMap<>(2); // 指定初始容量,性能可能稍有提升
user.put("username", "hunter");
user.put("password", "123456");
user.put("email", "hspecial@163.com");
Number number = simpleJdbcInsert.executeAndReturnKey(user);
System.out.println(number);
}

JPA框架

实际上大部分的数据库交互操作,到最后都只会做一个事情,那就是把数据库中的数据映射为Java中的对象。在使用MyBatis时,我们只需要编写正确的SQL语句就可以直接将获取的数据映射为对应的Java对象,通过调用Mapper中的方法就能直接获得实体类,这样就方便我们在Java中数据库表中的相关信息了。

这种方式都是通过某种条件去进行查询,而最后的查询结果,都是一个实体类,所以你会发现你写的很多SQL语句都是一个套路select * from xxx where xxx=xxx实际上对于这种简单SQL语句,完全可以弄成一个模版来使用,那么能否有一种框架,帮我们把这些相同的套路给封装起来,直接把这类相似的SQL语句给屏蔽掉,不再由我们编写,而是让框架自己去组合拼接。

JPA(Java Persistence API)和JDBC类似,也是官方定义的一组接口,但是它相比传统的JDBC,它是为了实现ORM(Object Relational Mapping,对象关系映射)而生的,它的作用是在关系型数据库和对象之间形成一个映射,这样,我们在具体的操作数据库的时候,就不需要再去和复杂的SQL语句打交道,只要像平时操作对象一样操作它就可以了。实现JPA规范的框架,一般最常用的就是Hibernate,它是一个重量级框架SpringDataJPA也是采用Hibernate框架作为底层实现,并加以封装。

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

快速上手JPA

以用户类为例子,可以通过注解形式,在属性上添加数据库映射关系,让JPA知道我们的实体类对应的数据库表长什么样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Data;

@Data
@Entity // 表明是实体类
@Table(name = "account") // 对应数据库中的表名
public class Account {
@Id // 主键标识
@Column(name = "id") // 对应数据库表中的列名
@GeneratedValue(strategy = GenerationType.IDENTITY) // 自增长
private int id;

@Column(name = "username")
private String username;

@Column(name = "password")
private String password;
}

application.yml配置文件:

1
2
3
4
5
6
7
spring:
jpa:
# 开启SQL语句执行日志信息
show-sql: true
hibernate:
# 配置为检查数据库表结构,没有时会自动创建
ddl-auto: update

ddl-auto属性用于设置自动表定义,可以实现自动在数据库中为我们创建一个表,表的结构会根据我们定义的实体类决定,它有以下几种:

  • none: 不执行任何操作,数据库表结构需要手动创建。
  • create: 框架在每次运行时都会删除所有表,并重新创建。
  • create-drop: 框架在每次运行时都会删除所有表,然后再创建,但在程序结束时会再次删除所有表。
  • update: 框架会检查数据库表结构,如果与实体类定义不匹配,则会做相应的修改,以保持它们的一致性。
  • validate: 框架会检查数据库表结构与实体类定义是否匹配,如果不匹配,则会抛出异常。

这个配置项的作用是为了避免手动管理数据库表结构,使开发者可以更方便地进行开发和测试,但在生产环境中,更推荐使用数据库迁移工具来管理表结构的变更。

访问对应的表

需要创建一个继承自JpaRepository接口的接口,添加@Repository注解

1
2
3
4
5
package com.hunter.springboot_demo.repo;

@Repository
public interface AccountRepository extends JpaRepository<Account, Integer> { // 2个泛型,前者是具体操作的对象实体,后者是ID的类型
}
  • 可以直接注入该接口,使用save方法进行插入操作:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    @Resource
    AccountRepository repository;

    @Test
    void contextLoads() {
    Account account = new Account();
    account.setUsername("小红");
    account.setPassword("1234567");
    System.out.println(repository.save(account).getId()); //使用save来快速插入数据,并且会返回插入的对象,如果存在自增ID,对象的自增id属性会自动被赋值
    }
  • 查询操作

    1
    2
    3
    4
    5
    @Test
    void contextLoads() {
    // 默认通过ID查找的方法,并且返回的结果是Optional包装的对象,非常人性化
    repository.findById(1).ifPresent(System.out::println);
    }

JPA依靠我们提供的注解信息自动完成了所有信息的映射和关联,相比于MyBatis,它几乎是一个全自动的ORM框架


方法名称拼接自定义SQL

虽然接口预置的方法使用起来非常方便,但如果需要进行条件查询等操作,就需要自定义一些方法来实现。同样,不需要编写SQL语句,通过方法名称的拼接来实现条件判断

属性 拼接方法名称示例 执行的语句
Distinct findDistinctByLastnameAndFirstname select distinct … where x.lastname = ?1 and x.firstname = ?2
And findByLastnameAndFirstname … where x.lastname = ?1 and x.firstname = ?2
Or findByLastnameOrFirstname … where x.lastname = ?1 or x.firstname = ?2
Is,Equals findByFirstname,findByFirstnameIs,findByFirstnameEquals … where x.firstname = ?1
Between findByStartDateBetween … where x.startDate between ?1 and ?2
LessThan findByAgeLessThan … where x.age < ?1
LessThanEqual findByAgeLessThanEqual … where x.age <= ?1
GreaterThan findByAgeGreaterThan … where x.age > ?1
GreaterThanEqual findByAgeGreaterThanEqual … where x.age >= ?1
After findByStartDateAfter … where x.startDate > ?1
Before findByStartDateBefore … where x.startDate < ?1
IsNull,Null findByAge(Is)Null … where x.age is null
IsNotNull,NotNull findByAge(Is)NotNull … where x.age not null
Like findByFirstnameLike … where x.firstname like ?1
NotLike findByFirstnameNotLike … where x.firstname not like ?1
StartingWith findByFirstnameStartingWith … where x.firstname like ?1(参数与附加%绑定)
EndingWith findByFirstnameEndingWith … where x.firstname like ?1(参数与前缀%绑定)
Containing findByFirstnameContaining … where x.firstname like ?1(参数绑定以%包装)
OrderBy findByAgeOrderByLastnameDesc … where x.age = ?1 order by x.lastname desc
Not findByLastnameNot … where x.lastname <> ?1
In findByAgeIn(Collection<Age> ages) … where x.age in ?1
NotIn findByAgeNotIn(Collection<Age> ages) … where x.age not in ?1
True findByActiveTrue … where x.active = true
False findByActiveFalse … where x.active = false
IgnoreCase findByFirstnameIgnoreCase … where UPPER(x.firstname) = UPPER(?1)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Repository
public interface AccountRepository extends JpaRepository<Account, Integer> {
// 按照表中的规则进行名称拼接,不用刻意去记,IDEA会有提示

// 根据用户名模糊匹配查找用户
List<Account> findAllByUsernameLike(String str);

// 根据用户名和id一起查询
Account findByIdAndUsername(int id, String username);
//也可以使用Optional类进行包装,Optional<Account> findByIdAndUsername(int id, String username);

//使用exists判断是否存在
boolean existsAccountById(int id);
}

@Test
void contextLoads() {
repository.findAllByUsernameLike("%明%").forEach(System.out::println);
}

自定义条件操作的方法名称一定要遵循规则,不然会出现异常:

1
Caused by: org.springframework.data.repository.query.QueryCreationException: Could not create query for public abstract  ...

关联查询

在实际开发中,比较常见的场景还有关联查询,也就是我们会在表中添加一个外键字段,而此外键字段又指向了另一个表中的数据,当我们查询数据时,可能会需要将关联数据也一并获取。比如我们想要查询某个用户的详细信息,一般用户简略信息会单独存放一个表,而用户详细信息会单独存放在另一个表中。当然,除了用户详细信息之外,可能在某些电商平台还会有用户的购买记录、用户的购物车,交流社区中的用户帖子、用户评论等,这些都是需要根据用户信息进行关联查询的内容。


一对一关联

在JPA中,每张表实际上就是一个实体类的映射,而表之间的关联关系,也可以看作对象之间的依赖关系,比如用户表中包含了用户详细信息的ID字段作为外键,那么实际上就是用户表实体中包括了用户详细信息实体对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@Data
@Entity
@Table(name = "users_detail")
public class AccountDetail {

@Column(name = "id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Id
int id;

@Column(name = "address")
String address;

@Column(name = "email")
String email;

@Column(name = "phone")
String phone;

@Column(name = "real_name")
String realName;
}


@Data
@Entity
@Table(name = "users")
public class Account {

@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
@Id
int id;

@Column(name = "username")
String username;

@Column(name = "password")
String password;

@JoinColumn(name = "detail_id") // 指定存储外键的字段名称
@OneToOne // 声明为一对一关系
AccountDetail detail;
}

在建立关系之后,查询Account对象时,会自动将关联数据的结果也一并进行查询。要是只想要Account的数据,不想要用户详细信息数据怎么办呢?希望在要用的时候再获取详细信息,这样可以节省一些网络开销,我们可以设置懒加载,这样只有在需要时才会向数据库获取

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Data
@Entity
@Table(name = "users")
public class Account {

...

@JoinColumn(name = "detail_id")
@OneToOne(fetch = FetchType.LAZY) // 将获取类型改为LAZY
AccountDetail detail;
}


@Transactional // 懒加载属性需要在事务环境下获取,因为repository方法调用完后Session会立即关闭
@Test
void pageAccount() {
repository.findById(1).ifPresent(account -> {
System.out.println(account.getUsername()); // 获取用户名
System.out.println(account.getDetail()); // 获取详细信息(懒加载)
});
}

那么是否也可以在添加数据时,利用实体类之间的关联信息,一次性添加两张表的数据呢?可以,但是需要修改一下级联关联操作设定

1
2
3
4
5
6
7
@JoinColumn(name = "detail_id")
@OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL) // 设置关联操作为ALL
// ALL:所有操作都进行关联操作
// PERSIST:插入操作时才进行关联操作
// REMOVE:删除操作时才进行关联操作
// MERGE:修改操作时才进行关联操作
AccountDetail detail;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Test
void addAccount(){
Account account = new Account();
account.setUsername("Nike");
account.setPassword("123456");
AccountDetail detail = new AccountDetail();
detail.setAddress("重庆市渝中区解放碑");
detail.setPhone("1234567890");
detail.setEmail("73281937@qq.com");
detail.setRealName("张三");
account.setDetail(detail);
account = repository.save(account);
System.out.println("插入时,自动生成的主键ID为:"+account.getId()+",外键ID为:"+account.getDetail().getId());
}

一对多关联

在JPA中,一对多关系自然应该由多的一方持有指向单的一方的外键

比如每个用户的成绩信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
@Data
@Entity
@Table(name = "users_score") //成绩表,只存成绩,不存学科信息,学科信息id做外键
public class Score {

@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
@Id
int id;

@OneToOne // 一对一对应到学科上
@JoinColumn(name = "cid")
Subject subject;

@Column(name = "socre")
double score;

@Column(name = "uid")
int uid; // 用户id
}

@Data
@Entity
@Table(name = "subjects") // 学科信息表
public class Subject {

@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "cid")
@Id
int cid;

@Column(name = "name")
String name;

@Column(name = "teacher")
String teacher;

@Column(name = "time")
int time;
}


@Data
@Entity
@Table(name = "users")
public class Account {

...

@OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.REMOVE) // 在移除Account时,一并移除所有的成绩信息,依然使用懒加载
@JoinColumn(name = "uid") // 这里的name指的是Score表中的uid字段,对应的是当前的主键,会将uid外键设置为当前的主键
List<Score> scoreList;
}

再次强调,在 JPA 中,一对多关系自然应该由“多”的一方(Score)持有指向“单”的一方(Account)的外键。而相应的注解@OneToMany@JoinColumn,需要添加到单的一方

在数据库填写相应的数据,就可以查询用户成绩信息了:

1
2
3
4
5
6
7
@Transactional
@Test
void test() {
repository.findById(1).ifPresent(account -> {
account.getScoreList().forEach(System.out::println);
});
}

多对一关联

可以将对应成绩中的教师信息单独分出一张表存储,并建立多对一的关系,因为多门课程可能由同一个老师教授。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Data
@Entity
@Table(name = "teachers")
public class Teacher {

@Column(name = "id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Id
int id;

@Column(name = "name")
String name;

@Column(name = "sex")
String sex;
}

@Data
@Entity
@Table(name = "subjects") // 学科信息表
public class Subject {

...
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "tid") // 存储教师ID的字段,和一对一是一样的,也会在当前表中创建外键
Teacher teacher;
}

测试,能成功得到多对一的教师信息:

1
2
3
4
5
6
7
8
9
10
11
@Transactional
@Test
void test() {
repository.findById(3).ifPresent(account -> {
account.getScoreList().forEach(score -> {
System.out.println("课程名称:"+score.getSubject().getName());
System.out.println("得分:"+score.getScore());
System.out.println("任课教师:"+score.getSubject().getTeacher().getName());
});
});
}

多对多关联

复杂的多对多场景:

  • 一门课可以由多个老师教学
  • 一个老师也可以教授多个课程

可以插入一张中间表,专门存储哪个老师教哪个科目

1
2
3
4
5
6
7
8
9
10
11
12
13
@Data
@Entity
@Table(name = "subjects") // 学科信息表
public class Subject {

...
@ManyToMany(fetch = FetchType.LAZY) // 多对多场景
@JoinTable(name = "teach_relation", // 多对多中间关联表
joinColumns = @JoinColumn(name = "cid"), // 当前实体主键在关联表中的字段名称
inverseJoinColumns = @JoinColumn(name = "tid") // 教师实体主键在关联表中的字段名称
)
List<Teacher> teacher;
}

JPA会自动创建一张中间表,并自动设置外键,就可以将多对多关联信息编写在其中了。


JPQL自定义SQL语句

虽然SpringDataJPA能够简化大部分数据获取场景,但是难免会有一些特殊的场景,需要使用复杂查询才能够去完成。使用JPA,也可以像MyBatis一样,直接编写SQL语句,不过它是JPQL语言,与原生SQL语句很类似,但是它是面向对象的,当然我们也可以编写原生SQL语句。

比如需要更新用户表中指定ID用户的密码:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Repository
public interface AccountRepository extends JpaRepository<Account, Integer> {

@Transactional // DML操作需要事务环境,可以不在这里声明,但是调用时一定要处于事务环境下
@Modifying // 表示这是一个DML操作
@Query("update Account set password = ?2 where id = ?1") // 这里操作的是一个实体类对应的表,参数使用?代表,后面接第n个参数
int updatePasswordById(int id, String newPassword);
}

@Test
void updateAccount(){
repository.updatePasswordById(1, "654321");
}

使用原生SQL来实现根据用户名称修改密码:

1
2
3
4
5
@Transactional
@Modifying
@Query(value = "update users set password = :pwd where username = :name", nativeQuery = true) // 使用原生SQL,和Mybatis一样,这里使用 :名称 表示参数,当然也可以继续用上面那种方式。
int updatePasswordByUsername(@Param("name") String username, // 可以使用@Param指定名称
@Param("pwd") String newPassword);

通过编写原生SQL,在一定程度上弥补了SQL不可控的问题。

虽然JPA能够为我们带来非常便捷的开发体验,但是正是因为太便捷了,保姆级的体验有时也会适得其反,尤其是一些国内用到复杂查询业务的项目,可能开发到后期特别庞大时,就只能从底层SQL语句开始进行优化,而由于JPA尽可能地在屏蔽我们对SQL语句的编写,所以后期优化是个大问题,并且Hibernate相对于Mybatis来说,更加重量级。不过,在微服务的时代,单体项目一般不会太大,JPA的劣势并没有太明显地体现出来。


MyBatis-Plus 框架

面对一些复杂查询时,JPA有点力不从心,而稍微麻烦点的MyBatis却能手动编写SQL,使用起来更灵活。那有没有既能灵活掌控逻辑,又能快速完成开发的持久层框架呢

MyBatis-Plus是一个MyBatis的增强工具,在 MyBatis 的基础上只做增强不做改变,为简化开发、提高效率而生:简介 | MyBatis-Plus

1
2
3
4
5
6
<!-- mybatis plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.9</version>
</dependency>

快速上手

  1. 创建实体类,可以直接映射到数据库中的表:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    @Data
    @TableName("user") // 对应的表名
    public class User {
    @TableId(type = IdType.AUTO) // 对应的主键
    int id;
    @TableField("name") // 对应的字段
    String name;
    @TableField("email")
    String email;
    @TableField("password")
    String password;
    }
  2. 编写Mapper进行操作:

    1
    2
    3
    4
    5
    @Mapper
    public interface UserMapper extends BaseMapper<User> {
    // 使用方式与JPA极其相似,同样是继承一个基础的模版Mapper
    // 这个模版里面提供了预设的大量方法直接使用,跟JPA如出一辙
    }
  3. 测试:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    @SpringBootTest
    class DemoApplicationTests {

    @Resource
    UserMapper mapper;

    @Test
    void contextLoads() {
    System.out.println(mapper.selectById(1)); // 同样可以直接selectById,非常快速方便
    }
    }

条件构造器

查询操作

对于一些复杂查询的情况,MyBatis-Plus支持自己构造QueryMrapper用于复杂条件查询

1
2
3
4
5
6
7
8
9
@Test
void contextLoads() {
QueryWrapper<User> wrapper = new QueryWrapper<>(); // 复杂查询可以使用QueryWrapper来完成
wrapper
.select("id", "name", "email", "password") // 可以自定义选择哪些字段
.ge("id", 2) // 选择判断id大于等于1的所有数据
.orderByDesc("id"); // 根据id字段进行降序排序
System.out.println(mapper.selectList(wrapper)); // Mapper同样支持使用QueryWrapper进行查询
}

如果需要批处理,也可以直接使用批处理操作

1
2
3
4
5
6
@Test
void contextLoads() {
// 支持批处理操作,可以一次性删除多个指定ID的用户
int count = mapper.deleteBatchIds(List.of(1, 3));
System.out.println(count);
}

更新操作

可以使用UpdateWrapper来完成更新操作

1
2
3
4
5
6
7
8
@Test
void contextLoads() {
UpdateWrapper<User> wrapper = new UpdateWrapper<>();
wrapper
.set("name", "lbw")
.eq("id", 1);
System.out.println(mapper.update(null, wrapper));
}

QueryWrapperUpdateWrapper还有专门支持Java 8新增的Lambda表达式的特殊实现,可以直接以函数式的形式进行编写,使用方法是一样的:

1
2
3
4
5
6
7
8
@Test
void contextLoads() {
LambdaQueryWrapper<User> wrapper = Wrappers
.<User>lambdaQuery()
.eq(User::getId, 2) // 比如我们需要选择id为2的用户,前面传入方法引用,后面比的值
.select(User::getName, User::getId); // 比如我们只需要选择name和id,那就传入对应的get方法引用
System.out.println(mapper.selectOne(wrapper));
}

分页查询

1
2
3
4
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId>
</dependency>

可以快速进行分页查询操作。

首先创建配置类:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
public class MyBatisPlusConfig {
/**
* 添加分页插件
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); // 如果配置多个插件, 切记分页最后添加
// 如果有多数据源可以不配具体类型, 否则都建议配上具体的 DbType
return interceptor;
}
}

然后就能使用分页功能:

1
2
3
4
5
6
@Test
void contextLoads() {
// Page.of(1, 2) 创建了一个分页对象,表示当前页为1,每页记录数为2。
Page<User> page = mapper.selectPage(Page.of(1, 2), Wrappers.emptyWrapper());
System.out.println(page.getRecords()); //获取分页之后的数据
}

接口基本操作

虽然使用MyBatis-Plus提供的BaseMapper已经很方便了,但是我们的业务中,实际上很多时候也是一样的工作,都是去简单调用底层的Mapper做一个很简单的事情,那么能不能干脆把Service也给弄个模版?MybatisPlus为我们提供了很方便的CRUD接口,直接实现了各种业务中会用到的增删改查操作,只需要继承即可:

1
2
3
4
5
6
7
8
@Service
public interface UserService extends IService<User> {
// 除了继承模版,也可以把它当成普通Service添加自己需要的方法
}

@Service // 需要继承ServiceImpl才能实现那些默认的CRUD方法
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
}

比如想批量插入一组用户数据到数据库中:

1
2
3
4
5
6
@Test
void contextLoads() {
List<User> users = List.of(new User("xxx"), new User("yyy"));
// 预设方法中已经支持批量保存了,这相比直接用for效率高不少
service.saveBatch(users);
}

还有更加方便快捷的保存或更新操作,当数据不存在时(通过主键ID判断)则插入新数据,否则就更新数据:

1
2
3
4
@Test
void contextLoads() {
service.saveOrUpdate(new User("aaa"));
}

也可以直接使用Service来进行链式查询:

1
2
3
4
5
@Test
void contextLoads() {
User one = service.query().eq("id", 1).one();
System.out.println(one);
}

yaml配置文件中的相关配置

1
2
3
4
5
6
7
8
9
10
mybatis-plus:
configuration:
# 开启SQL日志打印
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
logic-delete-field: delFlag # 逻辑删除的字段名,查询时会自动忽略已被逻辑删除的行
logic-delete-value: 1
logic-not-delete-value: 0
id-type: auto # 主键自增

代码生成器

IDEA直接使用MyBatisX插件,也能自动进行代码生成

代码生成器能够根据数据库做到代码的一键生成。

1
2
3
4
5
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-generator</artifactId>
<version>3.5.9</version>
</dependency>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
public class CodeGenerator {
private static final String URL = "jdbc:mysql://127.0.0.1:3306/seckill" +
"?useUnicode=true&characterEncoding=utf-8&serverTimezone=UTC";
private static final String USERNAME = "root";
private static final String PASSWORD = "123456";
private static final String AUTHOR = "Hunter";

public static void main(String[] args) {
FastAutoGenerator.create(URL, USERNAME, PASSWORD)
// 全局配置
.globalConfig(builder -> {
builder.author(AUTHOR) // 设置作者
.enableSpringdoc() // 开启 Springdoc 模式
.outputDir(System.getProperty("user.dir") + "/src/main/java"); // 指定输出目录,System.getProperty("user.dir")能获取到项目所在的绝对路径
})
// 配置数据源
.dataSourceConfig(builder -> builder.typeConvertHandler((globalConfig, typeRegistry, metaInfo) -> {
int typeCode = metaInfo.getJdbcType().TYPE_CODE;
if (typeCode == Types.SMALLINT) {
// 自定义类型转换
return DbColumnType.INTEGER;
}
return typeRegistry.getColumnType(metaInfo);

}))
// 包配置
.packageConfig(builder -> {
builder.parent("com.hunter") // 设置父包名
.moduleName("seckill") // 设置模块名
.entity("pojo")
.mapper("mapper")
.service("service")
.serviceImpl("service.impl")
.controller("controller")
.pathInfo(Collections.singletonMap(OutputFile.xml,
System.getProperty("user.dir") + "/src/main/resources/mapper")); // 设置mapper.xml生成路径;
})
// 配置模板,可以从 mybatis-plus-generator-3.5.3.2.jar!\templates 下复制,进行自定义
.templateConfig(builder -> {
builder.entity("templates/entity2.java")
.mapper("templates/mapper2.java")
.service("templates/service2.java")
.serviceImpl("templates/serviceImpl2.java")
.controller("templates/controller2.java");
})
// 策略配置
.strategyConfig(builder -> {
builder.addInclude("t_user") // 设置需要生成的表名
.addTablePrefix("t_", "c_") // 设置过滤表前缀
.entityBuilder().enableFileOverride() // 覆盖已生成文件
.mapperBuilder().enableFileOverride()
.serviceBuilder().enableFileOverride()
.controllerBuilder().enableFileOverride();
})
.templateEngine(new FreemarkerTemplateEngine()) // 使用Freemarker引擎模板,默认的是Velocity引擎模板
.execute();
}
}

前后端分离

前后端分离是一种软件架构模式,它将前端和后端的开发职责分开,使得前端和后端可以独立进行开发、测试和部署。

在前后端分离架构中,前端主要负责展示层的开发,包括用户界面的设计、用户交互的实现等。前端使用一些技术栈,如Vue、React等技术来实现用户界面,同时通过Ajax、Axios等技术与后端进行数据的交互,这样前端无论使用什么技术进行开发,都与后端无关,受到的限制会小很多。

后端主要负责业务逻辑的处理和数据的存储,包括用户认证、数据验证、数据处理、数据库访问等,后端只需要返回前端需要的数据即可,我们一般使用JSON格式进行返回。


环境搭建

准备好前文提到的前端模板:全部文件 > 视频教程 > 项目实战 > 模板.rar

由于没有学习过Vue等前端框架,这里依然使用前端模版进行魔改。只不过现在我们的前端页面需要单独进行部署,而不是和后端揉在一起。

这里我们需要先创建一个前端项目demo-frontend,依赖只需勾选SpringWeb即可,主要用作反向代理前端页面。(使用Nginx代理前端项目会更好一些)

创建项目

将所有的前端模版文件全部丢进对应的目录中,创建一个web目录到resource目录下,然后放入前端模版的全部文件:

前端模板存放目录

然后配置一下静态资源代理,现在我们希望的是页面直接被代理,不用我们手动去写Controller来解析视图:

1
2
3
4
spring:
web:
resources:
static-locations: classpath:/web

启动项目,就能通过localhost:8080访问网站。这样前端页面就部署完成了。

接着我们还需要创建一个后端项目,用于去编写我们的后端,选上我们需要的一些依赖:

image-20250109160729324

接着需要修改一下后端服务器的端口,因为现在我们要同时开两个服务器,一个是负责部署前端的,一个是负责部署后端的,这样就是标准的前后端分离了,为了防止端口打架,我们就把端口开放在8081上:

1
2
3
4
5
6
7
8
9
10
11
server:
port: 8081

spring:
application:
name: demo-backend
datasource:
url: jdbc:mysql://localhost:3306/hunter_demo
username: root
password: 123456.root
driver-class-name: com.mysql.cj.jdbc.Driver

启动这两个服务器,学习环境就搭建好了。


基于Session的分离(有状态)

在之前,我们都是使用SpringSecurity提供的默认登录流程完成验证。实际上SpringSecurity在登录之后,会利用Session机制记录用户的登录状态,这就要求我们每次请求的时候都需要携带Cookie才可以,因为Cookie中存储了用于识别的JSESSIONID数据。因此,要实现前后端分离,我们只需要稍微修改一下就可以实现了,这对于小型的单端应用程序非常友好。


实现登录授权和跨域处理

在之前,登录操作以及登录之后的页面跳转都是由SpringSecurity来完成,但是前后端分离之后,整个流程发生了变化,现在前端仅仅是调用登录接口进行一次校验即可,而后端只需要返回本次校验的结果,由前端来判断是否校验成功并跳转页面

登录流程

因此,现在只需要让登录模块响应一个JSON数据告诉前端登录成功与否即可,当然,前端在发起请求的时候依然需要携带Cookie信息,否则后端不认识是谁。

REST响应数据格式

一般使用的格式为JSON,以下是一个标准样例:

1
2
3
4
5
6
7
8
9
{
"code": 200,
"data": {
"id": 1,
"name": "Tom",
"age": 18
},
"message": "查询成功"
}

字段的含义分别为:

  • code:HTTP状态码,表示请求的结果。常见的有200(成功)、400(客户端错误)、500(服务器错误)等。
  • data:响应的真实数据。在上例中,是一个包含用户信息的对象。
  • message:请求响应信息,常用于描述请求处理结果。

上述都是建议的最佳实践,实际应用中可以根据具体的业务需求进行适当的调整

根据Rest API标准来编写JSON格式的数据响应实体类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
package com.hunter.demobackend.domain;

@Data
public class ResponseResult<T> implements Serializable {
private static final long serialVersionUID = 1985501883158534297L;

private Integer code;

private String message;

private T data;

private ResponseResult(int code, String message, T data) {
this.code = code;
this.message = message;
this.data = data;
}

public static <T> ResponseResult<T> success(T data) {
return new ResponseResult<>(HttpCodeEnum.SUCCESS.getCode(), HttpCodeEnum.SUCCESS.getMessage(), data);
}

public static <T> ResponseResult<T> fail(int code, String message) {
return new ResponseResult<>(code, message, null);
}

public String toJson() {
ObjectMapper objectMapper = new ObjectMapper();
try {
return objectMapper.writeValueAsString(this); // 直接将实体类转换为JSON字符串
} catch (JsonProcessingException e) {
throw new RuntimeException("Failed to convert ResponseResult to JSON string.",e);
}
}
}


package com.hunter.demobackend.enums;

public enum HttpCodeEnum {
SUCCESS(200, "查询成功"),
UNAUTHORIZED(401, "未授权"),
FORBIDDEN(403, "禁止访问"),
INTERNAL_SERVER_ERROR(500, "服务器内部错误");

private final Integer code;
private final String message;

HttpCodeEnum(int code, String message) {
this.code = code;
this.message = message;
}

public int getCode() {
return code;
}

public String getMessage() {
return message;
}
}

配置一下SpringSecurity的相关接口,需要让SpringSecurity在登录成功之后返回一个JSON数据给前端而不是默认的重定向,通过手动设置SuccessHandlerFailureHandler来实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
@Configuration
public class SecurityConfiguration {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {

return httpSecurity
// 授权Http请求相关配置
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated()) // 任何请求都需要认证
// 表单登录相关配置
.formLogin(httpSecurityFormLoginConfigurer -> {
httpSecurityFormLoginConfigurer
// 登录表单提交的地址,由SpringSecurity负责处理,一般前后端分离之后,为了统一接口规范,使用 /api/模块/功能 的形式命名接口
.loginProcessingUrl("/api/auth/login")
.failureHandler(this::onAuthenticationFailure) // 自定义登录失败处理器
.successHandler(this::onAuthenticationSuccess) // 自定义登录成功处理器
.permitAll(); // 将登录相关的地址放行
})
.exceptionHandling(exceptionHandlingConfigurer -> {
exceptionHandlingConfigurer
.authenticationEntryPoint(this::onAuthenticationFailure) // 认证失败时的处理逻辑
.accessDeniedHandler(this::onAccessDenied); // 已登录,但没有权限访问时的处理逻辑
})
.csrf(AbstractHttpConfigurer::disable) // 关闭全部CSRF校验
.build();
}

private void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException {
response.setContentType("application/json;charset=UTF-8");
PrintWriter writer = response.getWriter();
writer.write(
ResponseResult.fail(HttpCodeEnum.FAILED.getCode(), exception.getMessage())
.toJson());
}

private void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException {
response.setContentType("application/json;charset=UTF-8");
PrintWriter writer = response.getWriter();
writer.write(
ResponseResult.success(authentication.getName()) // 登录成功返回用户名
.toJson());
}

private void onAccessDenied(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException exception) throws IOException {
response.setContentType("application/json;charset=UTF-8");
PrintWriter writer = response.getWriter();
writer.write(
ResponseResult.fail(HttpCodeEnum.FORBIDDEN.getCode(), exception.getMessage())
.toJson());
}
}

通过API测试工具进行调试,可以看到响应的结果是标准的JSON格式数据:

image-20250110103733503

在前端项目demo-frontendlogin.html文件中,添加前端逻辑,引入Axios框架发起异步请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
...
<html lang="zxx">
<head>
...
<script src="https://unpkg.com/axios@1.1.2/dist/axios.min.js"></script>
</head>

<body>
...
<input id="username" type="text" placeholder="Email Address" class="ad-input">
...
<input id="password" type="password" placeholder="Password" class="ad-input">
...
<a href="javascript:login();" class="ad-btn ad-login-member">Login</a>
...
</body>

</html>

<script>
function login() {
axios.post('http://localhost:8081/api/auth/login', {
username: document.getElementById('username').value,
password: document.getElementById('password').value
}, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
withCredentials: true
}).then(({data}) => {
if(data.code === 200) { // 通过状态码进行判断
window.location.href = '/index.html' // 登录成功进入主页
} else {
alert('登录失败:'+data.message) // 登录失败返回弹窗
}
})
}
</script>

当在前端页面发起请求时,可以看到控制台的报错:

Access to XMLHttpRequest at ‘http://localhost:8081/api/auth/login‘ from origin ‘http://localhost:8080‘ has been blocked by CORS policy: No ‘Access-Control-Allow-Origin’ header is present on the requested resource.

image-20250110134740578

即前端得到了一个跨域请求错误,这是前端和后端的站点不一致导致的。浏览器为了用户安全,防止网页中一些恶意脚本跨站请求数据,会对未经许可的跨域请求发起拦截。跨域问题是后端需要处理的问题,需要在响应的时候,在响应头中添加一些跨域属性,来告诉浏览器从哪个站点发来的请求是安全的

SpringSecurity框架可以直接进行跨域配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
...
.cors(corsConfigurer -> { // 跨域相关配置
CorsConfiguration corsConfig = new CorsConfiguration();
corsConfig.addAllowedOrigin("http://localhost:8080"); // 允许跨域访问的地址
// corsConfig.addAllowedOriginPattern("*"); // 允许所有地址跨域访问
corsConfig.setAllowCredentials(true); // 允许携带cookie
corsConfig.addAllowedHeader("*"); // 允许所有请求头
corsConfig.addAllowedMethod("*"); // 允许所有请求方法
corsConfig.addExposedHeader("*"); // 允许所有响应头
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); // 基于URL的Cors配置源
source.registerCorsConfiguration("/**", corsConfig); // 注册Cors配置,对所有接口都生效
corsConfigurer.configurationSource(source); // 将Cors配置源注入到SpringSecurity
})
...
.build();
}

这样,返回的响应头中都会携带跨域的相关信息,浏览器就不会拦截了,从而实现前后端分离的登录模式。


响应JSON化

完成上述的登录模式之后,看看一般的业务接口实现方式。例如一个获取用户名的接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.User

/**
* @author Hunter
* @since 2025/1/10
*/
@RestController
@RequestMapping("/api/user")
public class UserController {

@RequestMapping(value = "/username", method = RequestMethod.GET)
public ResponseResult<String> getUsername() {
User user = (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
return ResponseResult.success(user.getUsername());
}
}

前端在index.html中的请求设置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
...
<html lang="zxx">
...

<head>
...
<script src="https://unpkg.com/axios@1.1.2/dist/axios.min.js"></script>
</head>
...
<body>
...
<div class="user-info-box">
...
<h4 id="username">John Brown</h4>
...
</div>
...
</body>

</html>

<script>
axios.get('http://localhost:8081/api/user/name', {
withCredentials: true //携带Cookie访问,不然服务器不认识我们
}).then(({data}) => {
document.getElementById('username').innerText = data.data
})
</script>

也可以编写全局异常处理器来统一返回JSON:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.hunter.demobackend.exception;

@RestControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(Exception.class)
public ResponseResult<String> handleException(Exception exception) {
if (exception instanceof NoHandlerFoundException noHandlerFoundException) { // 处理404错误
return ResponseResult.fail(HttpCodeEnum.NOT_FOUND.getCode(), noHandlerFoundException.getMessage());
} else if (exception instanceof ServletException servletException) { // 处理ServletException错误
return ResponseResult.fail(HttpCodeEnum.BAD_REQUEST.getCode(), servletException.getMessage());
} else {
return ResponseResult.fail(HttpCodeEnum.INTERNAL_SERVER_ERROR.getCode(), exception.getMessage());
}
}
}

基于Session的前后端分离实现起来是最简单的,几乎没有多少的学习成本,跟我们之前的使用是一样的,只是现在前端单独编写了而已。


基于Token的分离(无状态)

无状态服务:在处理每个请求时,服务本身不维持任何与请求相关的状态信息。每个请求被视为独立的、自包含的操作,服务只关注处理请求本身,不关心前后的状态变化。用户发起请求时,服务器不会记录其信息,而是通过用户携带的Token来判断是哪个用户

  • 有状态:用户请求接口 -> 从Session中读取用户信息 -> 根据当前的用户来处理业务 -> 返回
  • 无状态:用户携带Token请求接口 -> 从请求中获取用户信息 -> 根据当前的用户来处理业务 -> 返回

无状态服务的优点包括:

  1. 服务端无需存储会话信息:传统的会话管理方式需要服务端存储用户的会话信息,包括用户的身份认证信息和会话状态。所有的认证信息都包含在Token中,使得服务端变得无状态,减轻了服务器的负担,同时也方便了服务的水平扩展
  2. 减少网络延迟:传统的会话管理方式需要在每次请求中都携带会话标识,即使是无状态的RESTful API也需要携带身份认证信息。而使用Token,身份认证信息已经包含在Token中,只需要在请求的Authorization头部携带Token即可,减少了每次请求的数据量,减少了网络延迟
  3. 跨域支持:Token可以在各个不同的域名之间进行传递和使用,因为Token是通过签名来验证和保护数据完整性的,可以防止未经授权的修改

JWT令牌

JWT(JSON Web Token)令牌是一个开放标准,用于在各方之间作为JSON对象安全地传输信息。JWT可以使用密钥(使用HMAC算法)或使用RSA或ECDSA进行公钥/私钥对进行签名

JWT令牌格式

一个JWT令牌由3部分组成:标头(Header)、有效载荷(Payload)和签名(Signature)。在传输的时候,会将JWT的前2部分分别进行Base64编码后用.进行连接形成最终需要传输的字符串。

  • 标头:包含一些元数据信息,比如JWT签名所使用的加密算法,还有类型,这里统一都是JWT。
  • 有效载荷:包括用户名称、令牌发布时间、过期时间、JWT ID等,当然我们也可以自定义添加字段,用户信息一般都在这里存放。
  • 签名:首先需要指定一个密钥,该密钥仅仅保存在服务器中,保证不能让其他用户知道。然后使用Header中指定的加密算法对Header和Payload进行base64加密之后的结果通过密钥计算哈希值,然后就得出一个签名哈希。这个会用于之后验证内容是否被篡改。

Base64是包括小写字母a-z、大写字母A-Z、数字0-9、符号”+”、”/“一共64个字符的字符集(末尾还有1个或多个=用来凑够字节数),任何的符号都可以转换成这个字符集中的字符,这个转换过程就叫做Base64编码,它不是加密算法,只是一种信息的编码方式而已。

JWT令牌实际上是一种经过加密的JSON数据,其中包含了用户名字、用户ID等信息,可以直接解密JWT令牌得到用户的信息

1
2
3
4
5
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>4.4.0</version>
</dependency>

尝试生成一个JWT令牌:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@SpringBootTest
class DemoBackendApplicationTests {

@Test
void contextLoads() {
String jwtKey = "abcdefghijklmn"; // 使用一个JWT秘钥进行加密
Algorithm algorithm = Algorithm.HMAC256(jwtKey); // 创建HMAC256加密算法对象
String jwtToken = JWT.create()
.withClaim("id", 1) // 向令牌中塞入自定义的数据
.withClaim("name", "lbw")
.withClaim("role", "nb")
.withExpiresAt(new Date(2024, Calendar.FEBRUARY, 1)) // JWT令牌的失效时间
.withIssuedAt(new Date()) // JWT令牌的签发时间
.sign(algorithm); // 使用上面的加密算法进行加密,完成签名
System.out.println(jwtToken); // 得到最终的JWT令牌

// 解密JWT令牌
String[] split = jwtToken.split("\\.");
for (int i = 0; i < split.length - 1; i++) {
String s = split[i];
byte[] decode = Base64.getDecoder().decode(s);
System.out.println(new String(decode));
}
}
}

可以直接使用JWT令牌作为权限校验的核心:

image-20250110170844581

用户还是按照正常流程进行登录,在登录成功之后,服务端返回一个JWT令牌用于后续请求使用,由于JWT令牌具有时效性,所以过期之后需要重新登录。后续请求中携带的token可以放在Cookie中,也可以放在请求头中。服务器可以根据Token中的信息判断登录是否过期。

验证流程


SpringSecurity实现JWT校验

这里使用比较常见的请求头携带JWT的方案,客户端发起的请求中会携带这样的特殊请求头:

1
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJzZWxmIiwic3ViIjoidXNlciIsImV4cCI6MTY5MDIxODE2NCwiaWF0IjoxNjkwMTgyMTY0LCJzY29wZSI6ImFwcCJ9.Z5-WMeulZyx60WeNxrQg2z2GiVquEHrsBl9V4dixbRkAD6rFp-6gCrcAXWkebs0i-we4xTQ7TZW0ltuhGYZ1GmEaj4F6BP9VN8fLq2aT7GhCJDgjikaTs-w5BbbOD2PN_vTAK_KeVGvYhWU4_l81cvilJWVXAhzMtwgPsz1Dkd04cWTCpI7ZZi-RQaBGYlullXtUrehYcjprla8N-bSpmeb3CBVM3kpAdehzfRpAGWXotN27PIKyAbtiJ0rqdvRmvlSztNY0_1IoO4TprMTUr-wjilGbJ5QTQaYUKRHcK3OJrProz9m8ztClSq0GRvFIB7HuMlYWNYwf7lkKpGvKDg

这里的Authorization请求头就是携带JWT的专用属性,值的格式为”Bearer Token”,前面的Bearer代表身份验证方式,默认情况下有两种:

  1. Basic:基本的身份验证方式,它将用户名和密码进行base64编码后,放在Authorization请求头中。这种方式不够安全,因为它将密码以明文的形式传输,容易受到中间人攻击。
  2. Bearer:更安全的身份验证方式,它基于令牌Token来验证用户身份。Bearer令牌由身份验证服务器颁发给客户端,客户端会在每个请求中将令牌放在Authorization请求头的Bearer字段中。服务器会验证令牌的有效性和权限,以确定用户身份。Bearer令牌通常使用JWT(JSON Web Token)的形式进行传递和验证

需要自行编写JWT校验拦截器来处理这些信息。


JWT令牌工具类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
package com.hunter.demobackend.utils;

public class JwtUtils {
/**
* JWT密钥
*/
private static final String KEY = "abcdefghijklmn";

/**
* 根据用户信息创建Jwt令牌
*
* @param userDetails 用户信息
* @return Jwt令牌
*/
public static String createJwt(UserDetails userDetails) {
Algorithm algorithm = Algorithm.HMAC256(KEY); // 创建HMAC256算法对象
Calendar calendar = Calendar.getInstance(); // 获取当前时间
Date now = calendar.getTime(); // 获取当前时间
calendar.add(Calendar.SECOND, 3600 * 24 * 7); // 设置过期时间为7天
return JWT.create()
.withClaim("name", userDetails.getUsername()) // 设置用户信息
.withClaim("authorities", userDetails.getAuthorities()
.stream().map(GrantedAuthority::getAuthority).toList()) // 设置用户权限
.withIssuedAt(now) // 设置签发时间
.withExpiresAt(calendar.getTime()) // 设置过期时间
.sign(algorithm); // 最终签名,生成Jwt令牌
}

/**
* 根据Jwt令牌解析用户信息
*
* @param token Jwt令牌
* @return 用户信息
*/
public static UserDetails resolveJwt(String token) {
Algorithm algorithm = Algorithm.HMAC256(KEY); // 创建HMAC256算法对象
JWTVerifier jwtVerifier = JWT.require(algorithm) // 验证签名
.build(); // 创建JWTVerifier对象

DecodedJWT verify = jwtVerifier.verify(token); // 验证令牌,看看是否被修改
Map<String, Claim> claims = verify.getClaims(); // 获取令牌中的信息
if (new Date().after(verify.getExpiresAt())) { // 令牌过期
return null;
} else {
return User.withUsername(claims.get("name").asString())
.password("") // 密码为空
.authorities(claims.get("authorities").asArray(String.class)) // 设置权限
.build();
}
}
}

JWT认证过滤器

需要自行实现一个JwtAuthenticationFilter,加入到SpringSecurity默认提供的过滤器链中,用于处理请求头中携带的JWT令牌,并配置登录状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package com.hunter.demobackend.filter;

/**
* 处理请求头中的JWT,并解析其中的用户信息,设置认证信息到SecurityContextHolder中
*
*/
public class JwtAuthenticationFilter extends OncePerRequestFilter {

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 从Header中取出JWT
String authorization = request.getHeader("Authorization");
if (authorization != null && authorization.startsWith("Bearer ")) {
String token = authorization.substring(7); // 截取JWT
UserDetails userDetails = JwtUtils.resolveJwt(token); // 解析JWT
if (userDetails != null) { // 验证成功
// 创建认证令牌
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); // 设置认证详情
SecurityContextHolder.getContext().setAuthentication(authenticationToken); // 设置认证信息
}
}
filterChain.doFilter(request, response); // 放行
}
}

更新一下SecurityConfiguration配置类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
@Configuration
public class SecurityConfiguration {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {

return httpSecurity
// 授权Http请求相关配置
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated()) // 任何请求都需要认证
.sessionManagement(sessionManagementConfigurer ->
sessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 禁用session,就不会采用Session机制记录用户
.addFilterBefore(new JwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class) // 添加JWT认证过滤器,在表单登录之前
// 表单登录相关配置
.formLogin(httpSecurityFormLoginConfigurer -> {
httpSecurityFormLoginConfigurer
// 登录表单提交的地址,由SpringSecurity负责处理,一般前后端分离之后,为了统一接口规范,使用 /api/模块/功能 的形式命名接口
.loginProcessingUrl("/api/auth/login")
.failureHandler(this::onAuthenticationFailure) // 自定义登录失败处理器
.successHandler(this::onAuthenticationSuccess) // 自定义登录成功处理器
.permitAll(); // 将登录相关的地址放行
})
.exceptionHandling(exceptionHandlingConfigurer -> {
exceptionHandlingConfigurer
.authenticationEntryPoint(this::onAuthenticationFailure) // 认证失败时的处理逻辑
.accessDeniedHandler(this::onAccessDenied); // 已登录,但没有权限访问时的处理逻辑
})
.csrf(AbstractHttpConfigurer::disable) // 关闭全部CSRF校验
.cors(corsConfigurer -> { // 跨域相关配置
CorsConfiguration corsConfig = new CorsConfiguration();
corsConfig.addAllowedOrigin("http://localhost:8080"); // 允许跨域访问的地址
// corsConfig.addAllowedOriginPattern("*"); // 允许所有地址跨域访问
corsConfig.setAllowCredentials(true); // 允许携带cookie
corsConfig.addAllowedHeader("*"); // 允许所有请求头
corsConfig.addAllowedMethod("*"); // 允许所有请求方法
corsConfig.addExposedHeader("*"); // 允许所有响应头
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); // 基于URL的Cors配置源
source.registerCorsConfiguration("/**", corsConfig); // 注册Cors配置,对所有接口都生效
corsConfigurer.configurationSource(source); // 将Cors配置源注入到SpringSecurity
})
.build();
}

private void onAccessDenied(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException exception) throws IOException {
response.setContentType("application/json;charset=UTF-8");
PrintWriter writer = response.getWriter();
writer.write(
ResponseResult.fail(HttpCodeEnum.FORBIDDEN.getCode(), exception.getMessage())
.toJson());
}

private void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException {
response.setContentType("application/json;charset=UTF-8");
PrintWriter writer = response.getWriter();
writer.write(
ResponseResult.fail(HttpCodeEnum.UNAUTHORIZED.getCode(), exception.getMessage())
.toJson());
}

private void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException {
response.setContentType("application/json;charset=UTF-8");
PrintWriter writer = response.getWriter();
writer.write(
ResponseResult.success(
JwtUtils.createJwt((UserDetails) authentication.getPrincipal())) // 登录成功,返回生成的JWT
.toJson());
}
}

这样,登录成功之后,就可以看到返回的JWT令牌:

image-20250113153614025

之后,访问某个接口获取数据,都需要按照Authorization: Bearer xxxxx的格式在请求头携带这个令牌进行访问,如果没有登录或者携带了错误的JWT访问服务器,都会返回401错误

image-20250113153752733
前端同步更新

login.html中,将JWT令牌存入sessionStorage用于本次会话:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<script>
function login() {
axios.post('http://localhost:8081/api/auth/login', {
username: document.getElementById('username').value,
password: document.getElementById('password').value
}, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
withCredentials: true
}).then(({data}) => {
if (data.code === 200) { //通过状态码进行判断
// 将得到的JWT令牌存到sessionStorage用于本次会话
sessionStorage.setItem("access_token", data.data)
window.location.href = '/index.html' // 登录成功进入主页
} else {
alert('登录失败:' + data.message) // 登录失败返回弹窗
}
})
}
</script>

index.html中,获取信息的时候带上JWT,不再需要依赖Cookie:

1
2
3
4
5
6
7
8
9
<script>
axios.get('http://localhost:8081/api/user/name', {
headers: {
'Authorization': 'Bearer ' + sessionStorage.getItem('access_token')
}
}).then(({data}) => {
document.getElementById('username').innerText = data.data
})
</script>

这样就实现了基于SpringSecurity的JWT校验。


退出登录时JWT的处理

虽然JWT已经很方便了,但是由于是无状态,用户来管理Token令牌,服务端只认Token是否合法,要怎么让用户正确退出登录呢?

如果让客户端自行删除,但是用户可以自行保存该Token,后续仍然能正常使用,存在安全问题。目前有两种比较好的方案:

  • 黑名单方案:所有黑名单中的JWT不可使用。
  • 白名单方案:不在白名单中的JWT不可使用。

以黑名单机制为例,让用户退出登录之后,无法再用之前的JWT进行操作。

  1. 首先需要给JWT添加额外用于判断的唯一标识符,例如UUID。
  2. 接着创建存储黑名单的表
  3. SecurityConfiguration中配置退出登录的操作。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
package com.hunter.demobackend.utils;

public class JwtUtils {
private static final String KEY = "abcdefghijklmn";

private static final HashSet<String> BLACK_LIST = new HashSet<>(); // 黑名单

/**
* 将令牌加入黑名单
*
* @param token 令牌
* @return 是否加入成功
*/
public static boolean invalidateToken(String token) {
Algorithm algorithm = Algorithm.HMAC256(KEY); // 创建HMAC256算法对象
JWTVerifier jwtVerifier = JWT.require(algorithm) // 验证签名
.build(); // 创建JWTVerifier对象
DecodedJWT verify = jwtVerifier.verify(token); // 验证令牌,看看是否被修改
return BLACK_LIST.add(verify.getId());
}

/**
* 根据用户信息创建Jwt令牌
*
* @param userDetails 用户信息
* @return Jwt令牌
*/
public static String createJwt(UserDetails userDetails) {
Algorithm algorithm = Algorithm.HMAC256(KEY); // 创建HMAC256算法对象
Calendar calendar = Calendar.getInstance(); // 获取当前时间
Date now = calendar.getTime(); // 获取当前时间
calendar.add(Calendar.SECOND, 3600 * 24 * 7); // 设置过期时间为7天
return JWT.create()
.withJWTId(UUID.randomUUID().toString()) // 添加UUID用于记录黑名单,将其作为JWT的ID属性
.withClaim("name", userDetails.getUsername()) // 设置用户信息
.withClaim("authorities", userDetails.getAuthorities()
.stream().map(GrantedAuthority::getAuthority).toList()) // 设置用户权限
.withIssuedAt(now) // 设置签发时间
.withExpiresAt(calendar.getTime()) // 设置过期时间
.sign(algorithm); // 最终签名,生成Jwt令牌
}

/**
* 根据Jwt令牌解析用户信息
*
* @param token Jwt令牌
* @return 用户信息
*/
public static UserDetails resolveJwt(String token) {
Algorithm algorithm = Algorithm.HMAC256(KEY); // 创建HMAC256算法对象
JWTVerifier jwtVerifier = JWT.require(algorithm) // 验证签名
.build(); // 创建JWTVerifier对象

DecodedJWT verify = jwtVerifier.verify(token); // 验证令牌,看看是否被修改
if (BLACK_LIST.contains(verify.getId())) { // 令牌在黑名单中
return null;
}

Map<String, Claim> claims = verify.getClaims(); // 获取令牌中的信息
if (new Date().after(verify.getExpiresAt())) { // 令牌过期
return null;
} else {
return User.
withUsername(claims.get("name").asString())
.password("") // 密码为空
.authorities(claims.get("authorities").asArray(String.class)) // 设置权限
.build();
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Configuration
public class SecurityConfiguration {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {

return httpSecurity
...
.logout(logoutConfigurer -> {
logoutConfigurer
.logoutUrl("/api/auth/logout")
.logoutSuccessHandler(this::onLogoutSuccess); // 自定义退出登录成功处理器
})
.build();
}

private void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException {
response.setContentType("application/json;charset=UTF-8");
PrintWriter writer = response.getWriter();
String authorization = request.getHeader("Authorization");
if (authorization != null && authorization.startsWith("Bearer ")) {
String token = authorization.substring(7);
// 黑名单处理
if (JwtUtils.invalidateToken(token)) {
writer.write(ResponseResult.success("成功退出登录").toJson());
return;
}
}
writer.write(ResponseResult.fail(HttpCodeEnum.BAD_REQUEST.getCode(), "退出登录失败").toJson());

}
...
}

这样,就成功设置了黑名单机制,即使用户提前保存,这个Token依然是失效的。虽然这种黑名单机制很方便,但是如果到了微服务阶段,可能多个服务器都需要共享这个黑名单,这个时候我们再将黑名单存储在单个应用中就不太行了,后续可以考虑使用Redis服务器来存放黑名单列表,这样就可以实现多个服务器共享,并且根据JWT的过期时间合理设定黑名单中UUID的过期时间,自动清理


自动续签JWT令牌

在有些时候,我们可能希望用户能够一直使用网站,而不是JWT令牌到期之后就需要重新登录,这种情况下前端就可以配置JWT自动续签,在发起请求时如果令牌即将到期,那么就向后端发起续签请求得到一个新的JWT令牌

1
2
3
4
5
6
7
8
9
10
11
@RestController
@RequestMapping("api/auth")
public class AuthorizeController {

@GetMapping("/refresh")
public ResponseResult<String> refreshToken() {
User user = (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); // 获取当前认证用户的主体
String jwt = JwtUtils.createJwt(user);
return ResponseResult.success(jwt);
}
}
  • JWT校验方案适用于无状态、分布式系统,几乎所有常见的前后端分离的架构都可以采用这种方案。

  • 传统Session校验方案适用于需要即时失效、即时撤销和灵活权限管理的场景,适合传统的服务器端渲染应用,以及客户端支持Cookie功能的前后端分离架构

在选择校验方案时,需要根据具体的业务需求和技术场景进行选择