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-jsonWeb 起步依赖会自动引入(jackson)
spring-boot-starter-tomcatWeb 起步依赖的默认嵌入式容器
spring-webWeb 基础模块
spring-webmvcMVC 框架

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.jar

war(复杂,不推荐)

先排除掉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.ymlapplication-prod.yml分别表示开发环境和生产环境的配置文件

server:
  port: 8080
server:
  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>

比较常用的协议有两种:

  1. SMTP协议(主要用于发送邮件 Simple Mail Transfer Protocol)
  2. 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();   //出现异常直接返回消息
    }
}
验证注解验证的数据类型说明
@AssertFalseBoolean,boolean值必须是false
@AssertTrueBoolean,boolean值必须是true
@NotNull任意类型值不能是null
@Null任意类型值必须是null
@MinBigDecimal、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同上值必须比当前时间晚
@NotBlankCharSequence及其子类值不为空,在比较时会去除字符串的首位空格
@LengthCharSequence及其子类字符串长度在指定区间内
@NotEmptyCharSequence及其子类、Collection、Map、数组值不为null且长度不为空(字符串长度不为0,集合大小不为0)
@RangeBigDecimal、BigInteger、CharSequence、byte、short、int、long 以及原子类型和包装类型值在指定区间内
@EmailCharSequence及其子类值必须是邮件格式
@PatternCharSequence及其子类值需要与指定的正则表达式匹配
@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请求接口 -> 从请求中获取用户信息 -> 根据当前的用户来处理业务 -> 返回

无状态服务的优点包括:

  1. 服务端无需存储会话信息:传统的会话管理方式需要服务端存储用户的会话信息,包括用户的身份认证信息和会话状态。而使用Token,服务端无需存储任何会话信息,所有的认证信息都包含在Token中,使得服务端变得无状态,减轻了服务器的负担,同时也方便了服务的水平扩展。
  2. 减少网络延迟:传统的会话管理方式需要在每次请求中都携带会话标识,即使是无状态的RESTful API也需要携带身份认证信息。而使用Token,身份认证信息已经包含在Token中,只需要在请求的Authorization头部携带Token即可,减少了每次请求的数据量,减少了网络延迟。
  3. 客户端无需存储会话信息:传统的会话管理方式中,客户端需要存储会话标识,以便在每次请求中携带。而使用Token,客户端只需要保存Token即可,方便了客户端的存储和管理。
  4. 跨域支持: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进行限流等操作,防止频繁请求。

分类: Java-Backend 标签: JavaSpring

评论

暂无评论数据

暂无评论数据

目录