SpringBoot 笔记(一)(3.6w字讲解)
SpringBoot 笔记(一)
1、创建项目
2、日志系统
3、多环境配置
4、常用框架(邮件、接口校验、文档生成、运行监控)
5、数据交互(JDBC、JPA、MyBatisPlus)
6、前后端分离
*7、实现原理
一、创建项目
常用模块快速整合
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>所有的SpringBoot依赖都是以starter的形式命名的,需要导入其他模块也是导入spring-boot-starter-xxxx这种名称格式的依赖。
我们只需导入spring-boot-starter-web即可开箱即用,不再需要单独导入spring-boot-starter:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>该模块包含以下部分:
| 依赖 | 属于 spring-boot-starter-web 的哪部分 |
|---|---|
spring-boot-starter | 基础核心(自动配置、日志等) |
spring-boot-starter-json | Web 起步依赖会自动引入(jackson) |
spring-boot-starter-tomcat | Web 起步依赖的默认嵌入式容器 |
spring-web | Web 基础模块 |
spring-webmvc | MVC 框架 |
1. spring-boot-starter
作用:Spring Boot 的核心启动器
功能:
├── 自动配置核心(spring-boot-autoconfigure)
├── Spring 框架基础(spring-core, spring-context)
├── 日志支持(Logback, SLF4J)
└── YAML 配置文件解析本质:所有 Spring Boot Starter 的公共基础
2. spring-web
作用:Spring Web 基础模块
功能:
├── HTTP 抽象(HttpRequest, HttpResponse)
├── 客户端(RestTemplate, WebClient)
├── 文件上传/下载支持
└── Web 工具类本质:不依赖 Servlet 容器,纯 Web 基础功能
3. spring-webmvc
作用:Spring MVC 框架
功能:
├── DispatcherServlet(前端控制器)
├── 注解驱动(@Controller, @RequestMapping)
├── 视图解析器(ViewResolver)
├── 消息转换器(HttpMessageConverter)
├── 拦截器(Interceptor)
└── 数据绑定/验证本质:基于 Servlet API 的完整 MVC 框架
4. spring-boot-starter-json
作用:Spring Boot JSON 自动配置
功能:
├── 自动引入 Jackson 相关库
│ ├── jackson-databind(核心)
│ ├── jackson-datatype-jsr310(Java 8 时间)
│ └── jackson-module-parameter-names(参数名)
├── 配置默认的 ObjectMapper
└── 提供 JSON 消息转换器本质:让 Spring MVC 能自动 对象 ↔ JSON
5. spring-boot-starter-tomcat
作用:Spring Boot 嵌入式 Tomcat 容器
功能:
├── 内嵌 Tomcat 服务器
├── 无需外部部署,直接运行 main 方法
├── 默认端口 8080
└── 支持 JSP(需额外配置)本质:让 Spring Boot 应用自带 Web 服务器
整合MyBatis
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>3.0.2</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>spring:
datasource:
url: jdbc:mysql://localhost:3306/test
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver使用:在配置类上添加@MapperScan注解
@Configuration
@MapperScan("com.example.mapper")
public class WebConfiguration implements WebMvcConfigurer {
...或者直接为需要注册为Mapper的接口添加@Mapper注解:
@Mapper
public interface UserMapper {
@Select("select * from user where id = #{id}")
User findUserById(int id);
}自定义运行器
如果我们需要在项目启动完成之后,紧接着执行一段代码,我们可以编写自定义的ApplicationRunner来解决,它会在项目启动完成后执行:
@Component
public class TestRunner implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) throws Exception {
System.out.println("我是自定义执行!");
}
}打包运行
jar
maven package
java -jar demo-0.0.1-SNAPSHOT.jarwar(复杂,不推荐)
先排除掉spring-boot-starter-web中自带的Tomcat服务器依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</exclusion>
</exclusions>
</dependency>添加Servlet依赖:
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<scope>provided</scope>
</dependency>将打包方式修改为war包:
<packaging>war</packaging>修改主类,将其继承SpringBoot需要的Initializer:
@SpringBootApplication
public class DemoApplication extends SpringBootServletInitializer { //继承专用的初始化器
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
//重写configure方法,完成启动类配置
@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) {
return builder.sources(DemoApplication.class);
}
}二、日志系统
JUL
在Java生态中,JUL 是指 Java Util Logging,即 JDK 自带的日志框架。
import java.util.logging.Logger;
import java.util.logging.Level;
public class JulDemo {
// 通常按类名获取 Logger
private static final Logger logger = Logger.getLogger(JulDemo.class.getName());
public static void main(String[] args) {
// 默认级别是 INFO,只会输出 INFO 及以上
logger.info("这是一条 INFO 日志");
logger.warning("这是一条 WARNING 日志");
logger.severe("这是一条 SEVERE 日志");
// FINE 是 DEBUG 级别的日志,默认不输出
logger.fine("这是一条 FINE 日志");
// 可以使用 Level 控制级别
logger.log(Level.CONFIG, "配置信息");
}
}生产环境几乎不会直接写 JUL,而是通过 SLF4J 统一门面。
日志门面与日志实现
日志门面,如Slf4j,是把不同的日志系统的实现进行了具体的抽象化,只提供了统一的日志使用接口
日志实现,具体的日志系统有log4j、logback、java.util.logging等
SpringBoot使用的是Slf4j作为日志门面,Logback作为日志实现:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</dependency>使用示例:
@ResponseBody
@GetMapping("/test")
public User test(){
Logger logger = LoggerFactory.getLogger(TestController.class);
logger.info("用户访问了一次测试数据");
return mapper.findUserById(1);
}如果使用Lombok,可以直接用一个注解:
@Slf4j
@Controller
public class MainController {
@ResponseBody
@GetMapping("/test")
public User test() {
log.info("用户访问了一次测试数据");
return mapper.findUserById(1);
}
...6个日志级别从低到高分为TRACE < DEBUG < INFO < WARN < ERROR < FATAL,SpringBoot默认只会打印INFO以上级别的信息。
配置Logback
| 组件 | 作用 | 类比 |
|---|---|---|
| Logger | 日志记录器 | 谁在说话 |
| Appender | 日志输出目的地 | 说给谁听(控制台/文件/数据库) |
| Encoder | 日志格式编码 | 用什么语言说 |
| Layout | 日志格式化(旧版) | 已废弃,推荐 Encoder |
| Filter | 日志过滤器 | 什么能说,什么不能说 |
logback-spring.xml(一旦编写,就会替换默认的配置):
<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="60 seconds">
<!-- 属性定义:可被 ${name} 引用 -->
<property name="LOG_PATH" value="./logs" />
<property name="LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n" />
<property name="MDC_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} [traceId=%X{traceId}] - %msg%n" />
<!-- 1. 控制台输出 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${MDC_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- 2. 文件输出:按大小滚动 -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/app.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/app.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>100MB</maxFileSize>
<maxHistory>30</maxHistory>
<totalSizeCap>10GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>${LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- 3. 错误日志单独输出 -->
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/error.log</file>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>ERROR</level>
</filter>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/error.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>100MB</maxFileSize>
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder>
<pattern>${LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- 4. 异步输出(包装同步 Appender) -->
<appender name="ASYNC_FILE" class="ch.qos.logback.classic.AsyncAppender">
<discardingThreshold>0</discardingThreshold>
<queueSize>512</queueSize>
<neverBlock>true</neverBlock>
<appender-ref ref="FILE" />
</appender>
<!-- 包级别日志控制 -->
<logger name="org.springframework" level="WARN" />
<logger name="com.example.myapp.service" level="DEBUG" additivity="false">
<appender-ref ref="FILE" />
</logger>
<!-- 根日志记录器 -->
<root level="INFO">
<appender-ref ref="CONSOLE" />
<appender-ref ref="ASYNC_FILE" />
<appender-ref ref="ERROR_FILE" />
</root>
</configuration>Spring Boot 提供了 logback-spring.xml 的扩展支持,可以根据环境加载不同配置:
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- 引入 Spring Boot 默认配置 -->
<include resource="org/springframework/boot/logging/logback/defaults.xml" />
<!-- 控制台输出:彩色日志 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${CONSOLE_LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- 文件输出:每日滚动 -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<encoder>
<pattern>${FILE_LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH:-./logs}/app.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>
</appender>
<!-- 开发环境:控制台输出 DEBUG 级别 -->
<springProfile name="dev">
<root level="DEBUG">
<appender-ref ref="CONSOLE" />
<appender-ref ref="FILE" />
</root>
</springProfile>
<!-- 生产环境:控制台关闭,文件只输出 INFO 以上 -->
<springProfile name="prod">
<root level="INFO">
<appender-ref ref="FILE" />
</root>
</springProfile>
</configuration>MDC
MDC(Mapped Diagnostic Context,映射诊断上下文) 是 SLF4J 和 Logback/Log4j 等日志框架提供的一项线程上下文的日志增强功能。它的核心作用是在一条日志中自动附带某些上下文信息,比如:用户ID、订单号、请求IP、TraceId,而无需在每次打印日志时手动拼接这些参数。
自定义Banner展示
直接在配置文件所在目录下创建一个名为banner.txt的文本文档。
三、多环境配置
分别创建两个环境的配置文件,application-dev.yml和application-prod.yml分别表示开发环境和生产环境的配置文件
server:
port: 8080server:
port: 80通过application.yml配置文件指定:
spring:
profiles:
active: dev这样我们就可以灵活切换生产环境和开发环境下的配置文件了。
Logback日志多环境配置
比如想在开发环境下输出日志到控制台,而生产环境下只需要输出到文件:
<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设置多环境:
<!--分别设置开发,生产环境-->
<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>根据环境的不同,排除其他环境的配置文件:
<resources>
<!--排除配置文件-->
<resource>
<directory>src/main/resources</directory>
<!--先排除所有的配置文件-->
<excludes>
<!--使用通配符,当然可以定义多个exclude标签进行排除-->
<exclude>application*.yml</exclude>
</excludes>
</resource>
<!--根据激活条件引入打包所需的配置和文件-->
<resource>
<directory>src/main/resources</directory>
<!--引入所需环境的配置文件-->
<filtering>true</filtering>
<includes>
<include>application.yml</include>
<!--根据maven选择环境导入配置文件-->
<include>application-${environment}.yml</include>
</includes>
</resource>
</resources>我们可以直接将Maven中的environment属性,传递给SpringBoot的配置文件,在构建时替换为对应的值:
spring:
profiles:
active: '@environment@' #注意YAML配置文件需要加单引号,否则会报错注意切换环境之后要重新加载一下Maven项目,不然不会生效。
四、常用框架
邮件发送模块
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>比较常用的协议有两种:
- SMTP协议(主要用于发送邮件 Simple Mail Transfer Protocol)
- POP3协议(主要用于接收邮件 Post Office Protocol 3)
spring:
mail:
# 163邮箱的地址为smtp.163.com,直接填写即可
host: smtp.163.com
# 申请的163邮箱
username: [email protected]
# 密码是在开启smtp/pop3时自动生成的
password: AZJTOAWZESLMHTNI@Service
public class MailService {
@Autowired
private JavaMailSender mailSender;
@Value("${spring.mail.username}")
private String from;
// 发送简单文本邮件
public void sendSimpleMail(String to, String subject, String content) {
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(from);
message.setTo(to);
message.setSubject(subject);
message.setText(content);
mailSender.send(message);
}
// 发送HTML邮件
public void sendHtmlMail(String to, String subject, String htmlContent) throws MessagingException {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setFrom(from);
helper.setTo(to);
helper.setSubject(subject);
helper.setText(htmlContent, true); // true 表示 HTML
mailSender.send(message);
}
// 发送带附件的邮件
public void sendAttachmentMail(String to, String subject, String content, String filePath) throws MessagingException {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setFrom(from);
helper.setTo(to);
helper.setSubject(subject);
helper.setText(content, true);
// 添加附件
FileSystemResource file = new FileSystemResource(new File(filePath));
helper.addAttachment(file.getFilename(), file);
mailSender.send(message);
}
}接口规则校验
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>现在,我们可以直接使用注解完成全部接口的校验:
@Slf4j
@Validated //首先在Controller上开启接口校验
@Controller
public class TestController {
...
@ResponseBody
@PostMapping("/submit")
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 "请求成功!";
}
}不过这样依然会抛出一个异常,对用户不太友好,我们可以使用异常处理Controller来自行处理这类异常:
@ControllerAdvice
public class ValidationController {
@ResponseBody
@ExceptionHandler(ConstraintViolationException.class)
public String error(ValidationException e){
return e.getMessage(); //出现异常直接返回消息
}
}| 验证注解 | 验证的数据类型 | 说明 |
|---|---|---|
| @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 以及原子类型和包装类型 | 值在指定区间内 |
| CharSequence及其子类 | 值必须是邮件格式 | |
| @Pattern | CharSequence及其子类 | 值需要与指定的正则表达式匹配 |
| @Valid | 任何非原子类型 | 用于验证对象属性 |
对象类型参数的规则校验
在参数上添加@Valid注解,然后在对应POJO的字段上添加校验的注解:
@ResponseBody
@PostMapping("/submit") //在参数上添加@Valid注解表示需要验证
public String submit(@Valid Account account){
System.out.println(account.getUsername().substring(3));
System.out.println(account.getPassword().substring(2, 10));
return "请求成功!";
}@Data
public class Account {
@Length(min = 3) //只需要在对应的字段上添加校验的注解即可
String username;
@Length(min = 10)
String password;
}对于实体类接收参数的验证,会抛出MethodArgumentNotValidException异常,这里也进行一下处理:
@ResponseBody
@ExceptionHandler({ConstraintViolationException.class, MethodArgumentNotValidException.class})
public String error(Exception e){
if(e instanceof ConstraintViolationException exception) {
return exception.getMessage();
} else if(e instanceof MethodArgumentNotValidException exception){
if (exception.getFieldError() == null) return "未知错误";
return exception.getFieldError().getDefaultMessage();
}
return "未知错误";
}接口文档生成(Swagger)
Swagger的主要功能如下:
- 支持 API 自动生成同步的在线文档:使用 Swagger 后可以直接通过代码生成文档,不再需要自己手动编写接口文档了,对程序员来说非常方便,可以节约写文档的时间去学习新技术。
- 提供 Web 页面在线测试 API:光有文档还不够,Swagger 生成的文档还支持在线测试。参数和格式都定好了,直接在界面上输入参数对应的值即可在线测试接口。
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.1.0</version>
</dependency>配置接口的一些描述信息:
@Bean
public OpenAPI springDocOpenAPI() {
return new OpenAPI().info(new Info()
.title("图书管理系统 - 在线API接口文档") //设置API文档网站标题
.description("这是一个图书管理系统的后端API文档,欢迎前端人员查阅!") //网站介绍
.version("2.0") //当前API版本
.license(new License().name("我的B站个人主页") //遵循的协议,这里拿来写其他的也行
.url("https://space.bilibili.com/37737161")));
}//使用@Tag注解来添加Controller描述信息
@Tag(name = "账户验证相关", description = "包括用户登录、注册、验证码请求等操作。")
public class TestController {
...
}@ApiResponses({
@ApiResponse(responseCode = "200", description = "测试成功"),
@ApiResponse(responseCode = "500", description = "测试失败") //不同返回状态码描述
})
@Operation(summary = "请求用户数据测试接口") //接口功能描述
@ResponseBody
@GetMapping("/hello")
//请求参数描述和样例
public String hello(@Parameter(description = "测试文本数据", example = "KFCvivo50") @RequestParam String text) {
return "Hello World";
}@Hidden //不需要展示在文档中的接口,我们也可以将其忽略掉
@ResponseBody
@GetMapping("/hello")
public String hello() {
return "Hello World";
}@Data
@Schema(description = "用户信息实体类")
public class User {
@Schema(description = "用户编号")
int id;
@Schema(description = "用户名称")
String name;
@Schema(description = "用户邮箱")
String email;
@Schema(description = "用户密码")
String password;
}生产环境,我们需要关闭文档:
springdoc:
api-docs:
enabled: false项目运行监控(Actuator)
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>访问http://localhost:8080/actuator,可以查看当前开启的接口,默认只开启以下:
{
"_links": {
"self": {"href":"http://localhost:8080/actuator","templated":false}, //actuator自己的信息
"health-path":{"href":"http://localhost:8080/actuator/health/{*path}","templated":true},
"health":{"href":"http://localhost:8080/actuator/health","templated":false} //应用程序健康情况监控
}
}修改一下配置文件,让其暴露全部接口,并开启对应模块:
management:
endpoints:
web:
exposure:
include: '*' #使用*表示暴露全部接口
#开启某些默认为false的信息
info:
env:
enabled: true
os:
enabled: true
java:
enabled: true除此以外,还有线程转储和堆内存转储文件直接生成。
如访问http://localhost:8080/actuator/heapdump获取堆内存转储文件,用IDEA可以直接打开。
线程转储信息,也可以通过http://localhost:8080/actuator/threaddump直接获取。
五、前后端分离
基于Session的分离(有状态)
@Configuration
public class SecurityConfiguration {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(conf -> {
conf.anyRequest().authenticated();
})
.formLogin(conf -> {
//一般分离之后,为了统一规范接口,使用 /api/模块/功能 的形式命名接口
conf.loginProcessingUrl("/api/auth/login");
conf.permitAll();
})
.csrf(AbstractHttpConfigurer::disable)
.build();
}
}让SpringSecurity在登录成功之后返回一个JSON数据给前端,而不是默认的重定向,我们可以手动设置SuccessHandler和FailureHandler来实现:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
...
.formLogin(conf -> {
conf.loginProcessingUrl("/api/auth/login");
//使用自定义的成功失败处理器
conf.failureHandler(this::onAuthenticationFailure);
conf.successHandler(this::onAuthenticationSuccess);
conf.permitAll();
})
...
}
//自定义成功失败处理器
void onAuthenticationFailure(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException exception) {
}
void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) {
}建一个实体类来装载响应数据,可以使用记录类型:
public record RestBean<T> (int code, T data, String message) {
//写几个工具方法,用于快速创建RestBean对象
public static <T> RestBean<T> success(T data){
return new RestBean<>(200, data, "请求成功");
}
public static <T> RestBean<T> failure(int code, String message){
return new RestBean<>(code, null, message);
}
public static <T> RestBean<T> failure(int code){
return failure(code, "请求失败");
}
//将当前对象转换为JSON格式的字符串用于返回
public String asJsonString() {
return JSONObject.toJSONString(this, JSONWriter.Feature.WriteNulls);
}
}设置一下对应的Handler:
void onAuthenticationFailure(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException exception) throws IOException {
response.setContentType("application/json;charset=utf-8");
PrintWriter writer = response.getWriter();
writer.write(RestBean.failure(401, exception.getMessage()).asJsonString());
}
void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException {
response.setContentType("application/json;charset=utf-8");
PrintWriter writer = response.getWriter();
writer.write(RestBean.success(authentication.getName()).asJsonString());
}跨域配置:
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
...
.cors(conf -> {
CorsConfiguration cors = new CorsConfiguration();
//添加前端站点地址,这样就可以告诉浏览器信任了
cors.addAllowedOrigin("http://localhost:8080");
//虽然也可以像这样允许所有 cors.addAllowedOriginPattern("*");
//但是这样并不安全,我们应该只许可给我们信任的站点
cors.setAllowCredentials(true); //允许跨域请求中携带Cookie
cors.addAllowedHeader("*"); //其他的也可以配置,为了方便这里就 * 了
cors.addAllowedMethod("*");
cors.addExposedHeader("*");
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", cors); //直接针对于所有地址生效
conf.configurationSource(source);
})
...
.build();
}基于Token的分离(无状态)
- 有状态:用户请求接口 -> 从Session中读取用户信息 -> 根据当前的用户来处理业务 -> 返回
- 无状态:用户携带Token请求接口 -> 从请求中获取用户信息 -> 根据当前的用户来处理业务 -> 返回
无状态服务的优点包括:
- 服务端无需存储会话信息:传统的会话管理方式需要服务端存储用户的会话信息,包括用户的身份认证信息和会话状态。而使用Token,服务端无需存储任何会话信息,所有的认证信息都包含在Token中,使得服务端变得无状态,减轻了服务器的负担,同时也方便了服务的水平扩展。
- 减少网络延迟:传统的会话管理方式需要在每次请求中都携带会话标识,即使是无状态的RESTful API也需要携带身份认证信息。而使用Token,身份认证信息已经包含在Token中,只需要在请求的Authorization头部携带Token即可,减少了每次请求的数据量,减少了网络延迟。
- 客户端无需存储会话信息:传统的会话管理方式中,客户端需要存储会话标识,以便在每次请求中携带。而使用Token,客户端只需要保存Token即可,方便了客户端的存储和管理。
- 跨域支持:Token可以在各个不同的域名之间进行传递和使用,因为Token是通过签名来验证和保护数据完整性的,可以防止未经授权的修改。
一个JWT令牌由3部分组成:标头(Header)、有效载荷(Payload)和签名(Signature)。在传输的时候,会将JWT的前2部分分别进行Base64编码后用.进行连接形成最终需要传输的字符串。
- 标头:包含一些元数据信息,比如JWT签名所使用的加密算法,还有类型,这里统一都是JWT。
- 有效载荷:包括用户名称、令牌发布时间、过期时间、JWT ID等,当然我们也可以自定义添加字段,我们的用户信息一般都在这里存放。
- 签名:首先需要指定一个密钥,该密钥仅仅保存在服务器中,保证不能让其他用户知道。然后使用Header中指定的算法对Header和Payload进行base64加密之后的结果通过密钥计算哈希值,然后就得出一个签名哈希。这个会用于之后验证内容是否被篡改。
JWT令牌实际上是一种经过加密的JSON数据,其中包含了用户名字、用户ID等信息,我们可以直接解密JWT令牌得到用户的信息。
JWT支持库依赖:
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>4.3.0</version>
</dependency>生成JWT令牌:
public class Main {
public static void main(String[] args) {
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实际上最后会有一个加密的签名,这个是根据秘钥+JWT本体内容计算得到的,用户在没有持有秘钥的情况下,是不可能计算得到正确的签名的。
客户端发起的请求中会携带这样的的特殊请求头:
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJzZWxmIiwic3ViIjoidXNlciIsImV4cCI6MTY5MDIxODE2NCwiaWF0IjoxNjkwMTgyMTY0LCJzY29wZSI6ImFwcCJ9.Z5-WMeulZyx60WeNxrQg2z2GiVquEHrsBl9V4dixbRkAD6rFp-6gCrcAXWkebs0i-we4xTQ7TZW0ltuhGYZ1GmEaj4F6BP9VN8fLq2aT7GhCJDgjikaTs-w5BbbOD2PN_vTAK_KeVGvYhWU4_l81cvilJWVXAhzMtwgPsz1Dkd04cWTCpI7ZZi-RQaBGYlullXtUrehYcjprla8N-bSpmeb3CBVM3kpAdehzfRpAGWXotN27PIKyAbtiJ0rqdvRmvlSztNY0_1IoO4TprMTUr-wjilGbJ5QTQaYUKRHcK3OJrProz9m8ztClSq0GRvFIB7HuMlYWNYwf7lkKpGvKDg这里的Authorization请求头就是携带JWT的专用属性,值的格式为"Bearer Token"。
处理JWT令牌的工具类:
public class JwtUtils {
//Jwt秘钥
private static final String key = "abcdefghijklmn";
//根据用户信息创建Jwt令牌
public static String createJwt(UserDetails user) {
Algorithm algorithm = Algorithm.HMAC256(key);
Calendar calendar = Calendar.getInstance();
Date now = calendar.getTime();
calendar.add(Calendar.SECOND, 3600 * 24 * 7);
return JWT.create()
.withClaim("name", user.getUsername()) //配置JWT自定义信息
.withClaim("authorities", user.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList())
.withExpiresAt(calendar.getTime()) //设置过期时间
.withIssuedAt(now) //设置创建创建时间
.sign(algorithm); //最终签名
}
//根据Jwt验证并解析用户信息
public static UserDetails resolveJwt(String token) {
Algorithm algorithm = Algorithm.HMAC256(key);
JWTVerifier jwtVerifier = JWT.require(algorithm).build();
try {
DecodedJWT verify = jwtVerifier.verify(token); //对JWT令牌进行验证,看看是否被修改
Map<String, Claim> claims = verify.getClaims(); //获取令牌中内容
if(new Date().after(claims.get("exp").asDate())) //如果是过期令牌则返回null
return null;
else
//重新组装为UserDetails对象,包括用户名、授权信息等
return User
.withUsername(claims.get("name").asString())
.password("")
.authorities(claims.get("authorities").asArray(String.class))
.build();
} catch (JWTVerificationException e) {
return null;
}
}
}实现一个JwtAuthenticationFilter加入到SpringSecurity默认提供的过滤器链中,用于处理请求头中携带的JWT令牌,并配置登录状态:
public class JwtAuthenticationFilter extends OncePerRequestFilter {
//继承OncePerRequestFilter表示每次请求过滤一次,用于快速编写JWT校验规则
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//首先从Header中取出JWT
String authorization = request.getHeader("Authorization");
//判断是否包含JWT且格式正确
if (authorization != null && authorization.startsWith("Bearer ")) {
String token = authorization.substring(7);
//开始解析成UserDetails对象,如果得到的是null说明解析失败,JWT有问题
UserDetails user = JwtUtils.resolveJwt(token);
if(user != null) {
//验证没有问题,那么就可以开始创建Authentication了,这里我们跟默认情况保持一致
//使用UsernamePasswordAuthenticationToken作为实体,填写相关用户信息进去
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
//然后直接把配置好的Authentication塞给SecurityContext表示已经完成验证
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
//最后放行,继续下一个过滤器
//可能各位小伙伴会好奇,要是没验证成功不是应该拦截吗?这个其实没有关系的
//因为如果没有验证失败上面是不会给SecurityContext设置Authentication的,后面直接就被拦截掉了
//而且有可能用户发起的是用户名密码登录请求,这种情况也要放行的,不然怎么登录,所以说直接放行就好
filterChain.doFilter(request, response);
}
}配置一下SecurityConfiguration配置类:
@Configuration
public class SecurityConfiguration {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
//其他跟之前一样,就省略掉了
...
//将Session管理创建策略改成无状态,这样SpringSecurity就不会创建会话了,也不会采用之前那套机制记录用户,因为现在我们可以直接从JWT中获取信息
.sessionManagement(conf -> {
conf.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
})
//添加我们用于处理JWT的过滤器到Security过滤器链中,注意要放在UsernamePasswordAuthenticationFilter之前
.addFilterBefore(new JwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
.build();
}
//这个跟之前一样的写法,整合到一起处理,统一返回JSON格式
private void handleProcess(HttpServletRequest request,
HttpServletResponse response,
Object exceptionOrAuthentication) throws IOException {
response.setContentType("application/json;charset=utf-8");
PrintWriter writer = response.getWriter();
if(exceptionOrAuthentication instanceof AccessDeniedException exception) {
writer.write(RestBean.failure(403, exception.getMessage()).asJsonString());
} else if(exceptionOrAuthentication instanceof AuthenticationException exception) {
writer.write(RestBean.failure(401, exception.getMessage()).asJsonString());
} else if(exceptionOrAuthentication instanceof Authentication authentication){
//不过这里需要注意,在登录成功的时候需要返回我们生成的JWT令牌,这样客户端下次访问就可以携带这个令牌了,令牌过期之后就需要重新登录才可以
writer.write(RestBean.success(JwtUtils.createJwt((User) authentication.getPrincipal())).asJsonString());
}
}
}前端获取信息的时候携带上JWT即可,不需要依赖Cookie了:
<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>退出登录JWT处理
现在是无状态的,用户来管理Token令牌,服务端只认Token是否合法,没办法像Session那样去踢用户下线。
最简单的方案,让客户端删除自己的JWT令牌,但是存在一个问题,用户可以自行保存这个Token拿来使用。虽然客户端已经删除掉了,但是这个令牌仍然是可用的,如果用户私自保存过,那么依然可以正常使用这个令牌,这显然是有问题的。
目前有两种比较好的方案:
- 黑名单方案:所有黑名单中的JWT将不可使用。
- 白名单方案:不在白名单中的JWT将不可使用。
这里我们以黑名单机制为例,让用户退出登录之后,无法再次使用之前的JWT进行操作,首先我们需要给JWT额外添加一个用于判断的唯一标识符,如UUID,这样我们发出去的所有令牌都会携带一个UUID作为唯一凭据,接着我们可以创建一个专属的表用于存储黑名单,来SecurityConfiguration中配置一下退出登录操作,只有成功加入黑名单才会退出成功,这样,我们就成功安排上了黑名单机制,即使用户提前保存,这个Token依然是失效的。
虽然这种黑名单机制很方便,但是如果到了后面的微服务阶段,可能多个服务器都需要共享这个黑名单,这个时候我们再将黑名单存储在单个应用中就不太行了,后续我们可以考虑使用Redis服务器来存放黑名单列表,这样就可以实现多个服务器共享,并且根据JWT的过期时间合理设定黑名单中UUID的过期时间,自动清理。
自动续签JWT令牌
我们写一个接口专门用于令牌刷新,前端在发现令牌可用时间不足时,就会先发起一个请求自动完成续期,得到一个新的Token。
@RestController
@RequestMapping("/api/auth")
public class AuthorizeController {
@GetMapping("/refresh")
public RestBean<String> refreshToken(){
User user = (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
String jwt = JwtUtils.createJwt(user);
return RestBean.success(jwt);
}
}可能还需要配置一下这种方案的请求频率,不然用户疯狂请求刷新Token就不太好了,我们同样可以借助Redis进行限流等操作,防止频繁请求。
本文系作者 @xiin 原创发布在To Future$站点。未经许可,禁止转载。
暂无评论数据