SpringBoot基本知识

SpringBoot基于约定优于配置的思想,让开发人员不必在配置与逻辑业务之间思维切换,全身心投入到代码编写当中,大大提升了开发人员效率。

Spring缺点:配置繁琐(XML配置注解配置麻烦)、依赖繁琐(需要手动引坐标然后管理依赖的版本)

SpringBoot优点:自动配置、起步依赖(简化依赖配置)、辅助功能(自带Tomcat,不需要再手动配置Tomcat,直接启动Main方法就可以)

SpringBoot并不是对Spring功能上的增强,而是提供了一种快速使用Spring的方式。

SpringBoot入门

案例:

假设现在有如下需求:编写一个SpringBoot项目,编写完成以后运行输出Hello。

操作:

创建项目以后,先配置pom文件,把SpringBoot设为项目的父工程

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.demo</groupId>
    <artifactId>SpringBootLearn0706</artifactId>
    <version>1.0-SNAPSHOT</version>

    <!--springboot工程需要继承的父工程    -->
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.4.5</version>
    </parent>

    <dependencies>
        <!-- web开发起步依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
    </dependencies>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

</project>

然后创建对应的文件

hellocontroller类写法如下

package com.demo.hellocontroller;

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class hellocontroller {
    @RequestMapping("/hello")
    public String hello() {
        return "Hello ";
    }
}

最后在写一个引导类(SpringBoot项目的入口,就是一个Main函数)就可以运行这个SpringBoot项目了

package com.demo;

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

/**
 * 引导类。SpringBoot项目的入口
 * */
@SpringBootApplication
public class HelloApplication {
    public static void main(String[] args) {
        SpringApplication.run(HelloApplication.class, args);
    }
}

运行了以后可以查看控制台发现是8080端口,然后我们所写的Controller层映射又是/hello

 所以在浏览器输入localhost:8080/hello就可以查看自己程序的运行结果。

小结:

SpringBoot在创建项目时,使用jar的打包方式;SpringBoot的引导类,是项目入口,运行Main方法就可以启动项目;使用SpringBoot和Spring构建项目,业务代码编写方式完全一样。

在spring-boot-starter-parent中定义了各种技术的版本信息,组合了一套最优搭配的技术版本。在starter中,定义了完成该功能所需要的坐标合集,其中大部分版本信息来源于父工程。父工程继承parent,引入starter后,通过依赖传递,就可以简单方便快捷获得需要的jar包,并且不会存在版本冲突等问题。

SpringBoot配置

配置文件分类

SpringBoot的配置是基于约定的,所以很多配置都有默认值,但是如果想要使用自己的配置替换默认配置的话,就可以使用application.properties或application.yaml进行配置。

properties写法如下

server.port=8080

yaml写法如下(注意yaml中port后面冒号于8080是有一个空格的,若不写空格。将来语法就会出现问题)

server:
  port: 8080

properties的优先级比yaml高。默认的配置文件名称为application,优先级:properties>yml>yaml。

yaml

yaml是以数据为核心的,比传统的xml方式更加简洁。

对比

properties写法

server.port=8080
server.address=127.0.0.1

yaml写法

server:
  port: 8080
  address: 127.0.0.1

基本语法

1.大小写敏感  2.数值前面必须有空格(大于等于1个空格)作为分隔符 3.使用缩进表示层级关系 4.缩进不允许使用Tap键,只允许使用空格(各个系统的Tap对应的空格数可能不同,导致层级混乱) 5.缩进的空格数目不重要,只要相同层级的元素左对齐即可 6.#表示注释

数据格式

对象:键值对的集合

person1:
  name: zhangsan
  age: 18
  
#行内写法
person2: {name: lisi,age: 18}

数组:一组按次序排列的值

address1:
  - beijing
  - shanghai
#行内写法
address2: [beijing,shanghai]

纯量:单个的,不可再分的值(可以理解为常量)

mas1: 'hello \n world' #单引号忽略\n这样的转义字符
msg2: "hello \n world" #双引号识别\n这样的转义字符

参数引用

name: lisi

person:
  name: ${name} #引用上面定义的name值

读取配置文件内容

(1)使用@Value注解

(2)Environment

(3)使用@ConfigurationProperties注解

(1)使用@Value注解

假设有以下yaml配置文件

引用配置文件中的内容。在有少量的内容需要导入时可以选择这个方法

package com.demo.hellocontroller;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class hellocontroller {
    @Value("${name}")
    private String name1;

    @Value("${person.name}")
    private String name2;

    @Value("${address[0]}")
    private String address1;

    @RequestMapping("/hello")
    public String hello() {
        System.out.println(name1);
        System.out.println(name2);
        System.out.println(address1);
        return "Hello ";
    }
}

(2)Environment

需要先导入一个对象Environment,然后再调用这个对象的getProperty方法来导入配置文件中的内容。在有大量的内容需要导入时可以使用这个方法

package com.demo.hellocontroller;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.env.Environment;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;

@RestController
public class hellocontroller {

    @Resource
    private Environment env;

    @RequestMapping("/hello")
    public String hello() {
        System.out.println(env.getProperty("name"));
        System.out.println(env.getProperty("person.name"));
        System.out.println(env.getProperty("address[1]"));
        return "Hello ";
    }
}

(3)使用@ConfigurationProperties注解

在上面的方法中只能单个获取,例如获取单个name或是获取单个age,使用@ConfigurationProperties就可以批量获取然后分别赋值给对应的字段。

假设现在有如下配置文件

现在创建一个名为Person的类,然后字段分别于配置文件相对应即可

package com.demo.hellocontroller;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Component
@ConfigurationProperties(prefix = "person") //这里会寻找配置文件中名为person的对象并自动注入到Person这个类中的字段
public class Person {
    private String name;
    private int age;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    @Override
    public String toString() {
        return "Person{" +
                "name='" + name + '\'' +
                ", age=" + age +
                '}';
    }
}

这样就可以批量的把配置文件中的name还有age赋值给Person这个类中的对应字段,同时数组也是可以赋值的。

profile

应该程序会被安装到不同的环境,比如:开发、测试、生产环境等。其中数据库地址,服务器端口号等配置都不同,如果每次打包,都需要修改配置文件,那么就会非常的麻烦。profile功能就是来进行动态配置切换的。

(1)profile配置方式

1.多profile文件方式

SpringBoot目前最推荐的方式就是使用多个配置文件来对应不同环境的profile,如下所示:

application.yml        // 默认配置
application-dev.yml    // 开发环境
application-prod.yml   // 生产环境
application-test.yml   // 测试环境

然后在yml中指定默认启用哪个profile

spring:
  profiles:
    active: dev

这样SpringBoot就会自动加载application.yml+application-dev.yml的配置组合

2.yml多文档方式

该方式是在一个yml文档中使用---来进行分隔,然后写入多个配置文件,该方式不推荐使用

(2)激活方式

在拿到jar包以后,先点击shift+鼠标右键选择如下选项

可以在控制台输入如下指令来指定运行哪一个配置文件

java -jar myapp.jar --spring.profiles.active=prod

如上所示,在运行了myapp.jar包以后,就会执行application-prod.yml这个配置文件所配置的环境。

将来也是按照如上方式来运行一个spring的jar包

内部配置加载顺序

SpringBoot程序启动时,会从以下位置加载配置文件(优先级从高到底):

1.file:./config/:当前项目下的/config目录下

2.file./:当前项目的根目录

3.classpath:/config/:classpath的/config目录。位置如下所示

4.classpath:/:classpath的根目录。位置如下所示

整合MyBatis框架

实现步骤

1.搭建SpringBoot工程

2.引入MyBatis起步依赖,添加MySQL驱动

3.编写Datasource和MyBatis相关配置

4.定义表和实体类

5.编写DAO和mapper文件/纯注解开发

6.测试

首先创建项目如下所示

然后创建数据库表,数据库表如下所示

CREATE TABLE t_user (
    id INT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(32),
    password VARCHAR(32)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

INSERT INTO t_user (username, password)
VALUES 
('alice', 'password123'),
('bob', 'securepass456');

然后在pojo软件包中创建对应的实体类User

package com.mybatislearn0712.springbootmybatis.POJO;

public class User {
    private int id;
    private String username;
    private String password;

    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    @Override
    public String toString() {
        return "User{" +
                "id=" + id +
                ", username='" + username + '\'' +
                ", password='" + password + '\'' +
                '}';
    }
}

现在使用application.yml来进行配置

spring:
  datasource:
    url: jdbc:mysql:///fjn
    username: root
    password: 123456
    driver-class-name: com.mysql.cj.jdbc.Driver

现在创建User表对应的mapper映射

package com.mybatislearn0712.springbootmybatis.mapper;

import com.mybatislearn0712.springbootmybatis.POJO.User;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;

import java.util.List;

@Mapper
public interface UserMapper {
    
    @Select("select * from t_user")
    public List<User> findAll();
}

然后在test里面进行测试即可

package com.mybatislearn0712.springbootmybatis;

import com.mybatislearn0712.springbootmybatis.POJO.User;
import com.mybatislearn0712.springbootmybatis.mapper.UserMapper;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

import javax.annotation.Resource;
import java.util.List;

@SpringBootTest
class Mybatislearn0712ApplicationTests {

    @Resource
    private UserMapper userMapper;

    @Test
    public void testFindAll() {
        List<User> users = userMapper.findAll();
        System.out.println(users);
    }

}

测试结果如下所示,测试成功

以上为纯注解开发,下面是使用xml配置的开发方式

现在再创建一个新的类UserXMLMapper用来演示xml配置的方式

UserXMLMapper接口如下所示

package com.mybatislearn0712.springbootmybatis.mapper;

import com.mybatislearn0712.springbootmybatis.POJO.User;
import org.apache.ibatis.annotations.Mapper;

import java.util.List;

@Mapper
public interface UserXMLMapper {
    public List<User> findAll();
}

然后在resources目录下新建一个mapper目录专门用来放xml配置文件

配置文件如下

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.mybatislearn0712.springbootmybatis.mapper.UserXMLMapper">
    <select id="findAll" resultType="User">
        select * from t_user
    </select>
</mapper>

xml配置完成以后就需要再application.yml里面进行配置,以下只写新增内容

#mybatis
mybatis:
  mapper-locations: classpath:mapper/*Mapper.xml
  #去 classpath 下找所有以 Mapper.xml 结尾的 XML 映射文件
  #*Mapper.xml:是一个通配符,意思是匹配所有文件名为 xxxMapper.xml 的文件
  #由于mapper的xml配置文件都在resources下,所以要去classpath下面找
  type-aliases-package: com.mybatislearn0712.springbootmybatis.POJO
  #使用了别名则需要指定
  #给 POJO(也就是 Java 的实体类)起了别名,别名就是类名的小写形式。从这个包里找
  #别名默认是类名的首字母小写,也可以自己用注解 @Alias("别名") 来定制。
  #若不想写别名,则就需要这样写resultType="com.mybatislearn0712.springbootmybatis.POJO.User"
  #config-location: #用来指定mybatis核心配置文件

最后在测试类中运行即可

package com.mybatislearn0712.springbootmybatis;

import com.mybatislearn0712.springbootmybatis.POJO.User;
import com.mybatislearn0712.springbootmybatis.mapper.UserMapper;
import com.mybatislearn0712.springbootmybatis.mapper.UserXMLMapper;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.test.context.SpringBootTest;

import javax.annotation.Resource;
import java.util.List;

@SpringBootTest
class Mybatislearn0712ApplicationTests {

    @Resource
    private UserXMLMapper userXMLMapper;

    @Test
    public void testFindAll2() {
        List<User> users = userXMLMapper.findAll();
        System.out.println(users);
    }

}

运行结果如图

XML配置很麻烦,很容易出错,以下是错误整理

1.Mapper 接口方法名与 XML <select> 的 id 不一致 

  • 问题现象:报错 Invalid bound statement (not found)

  • 真实原因:方法名是 findAll(),XML 里却写了 findall(大小写敏感)。

  • 正确做法:保持接口方法名与 <select id="..."> 完全一致,区分大小写

2.Mapper XML 文件位置没被正确加载

  • 问题现象:即使 XML 正确写了,也报“找不到语句”。

  • 真实原因mapper-locations 没写对,或者压根没生效。

3.YML 写了,但properties文件也存在,抢了配置生效权

在自动生成的springboot项目当中,会自动生成一个properties文件,在这个默认的properties文件中会有一些默认的配置干扰自己写的yml文件,因此在实际开发中最好直接删除

 4.XML文件没有正确声明DTD头部

直接照抄即可

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

 5.Mapper XML 放错了路径

  • 问题现象:配置写得对,但 XML 就是加载不到。

  • 正确位置resources/mapper/xxxMapper.xml
    一定要放在 resources 目录下,才会被打包进 classpath!

配置建议 推荐做法
配置风格 全部统一用 .yml
Mapper 路径 classpath:mapper/*Mapper.xmlclasspath*:mapper/**/*.xml
包扫描 @MapperScan("com.xxx.mapper") 统一管理
Mapper 方法名 与 XML 中 <select id="..."> 一致(大小写敏感)
resultType 配置 配好 type-aliases-package,或者用全类名
统一项目结构 XML 放 resources/mapper,接口放 com.xxx.mapper

 SpringBoot高级

SpringBoot原理分析

SpringBoot自动配置

Condition

Condition是Spring4.0增加的条件判断功能,通过这个功能可以实现选择性的创建Bean操作

核心用途:按照条件确定是否注册Bean,比如@ConditionalOnProperty(name = "feature.switch", havingValue = "true")这个注解中,只有在properties配置文件中feature.switch=true这个条件下,这个Bean才会被注册。

Spring Boot 的xxxAutoConfiguration类基本都会搭配@Conditional系列注解使用,这样它能做到“按需注入”,而不是一股脑全注进来浪费资源。

案例:

案例介绍:本案例通过自定义注解 @ConditionalOnMyEnv("prod") 和实现 Condition 接口的判断逻辑,实现了一个基于配置文件中 my.env 值控制 Bean 是否注入的机制。

核心逻辑:启动时,Spring 读取注解参数与配置文件中的环境值进行比较,若匹配则注入对应 Bean,否则跳过注册。

1.定义一个判断条件类,实现 Condition 接口,在这个类中主要写判断的逻辑,返回的值为true则注入,为false则不注入

实现 org.springframework.context.annotation.Condition 接口,重写 boolean matches(ConditionContext, AnnotatedTypeMetadata) 方法

在该方法中:读取注解上的参数值(如 “prod”)、读取配置文件中设置的值(如 my.env=prod)、对比两者是否一致,返回布尔值决定是否注入 Bean

public class MyEnvCondition implements Condition {

    @Override
    public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
        // ① 获取注解参数值(也就是注解写的 "prod")
        Map<String, Object> attributes = metadata.getAnnotationAttributes(ConditionalOnMyEnv.class.getName());
        String expectedEnv = (String) attributes.get("value");

        // ② 获取配置文件中的实际值
        String actualEnv = context.getEnvironment().getProperty("my.env");

        // ③ 比较两个值是否匹配
        return expectedEnv.equalsIgnoreCase(actualEnv);
    }
}

2.定义一个自定义注解,作为条件控制入口

自定义注解如下:

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Conditional(MyCondition.class) // 绑定判断逻辑类
public @interface ConditionalOnMyEnv {
    String value(); // 注解参数
}

3.使用注解

@Configuration
public class MyConfig {

    @Bean
    @ConditionalOnMyEnv("prod")
    public MyService myService() {
        return new MyService();
    }
}

@ConditionOnXxx系列注解

Spring Boot 提供的一组注解,用于根据某种“条件”来决定一个 Bean 或配置类是否注入到 Spring 容器中。它们本质上都是基于 @Conditional 注解实现的,是对它的各种常用场景的封装。可以理解成一套“条件开关插件”,根据类存在、配置值、Bean 状态等判断是否激活配置。

注解 说明 常用场景
@ConditionalOnProperty 根据配置项的值判断 特性开关、灰度功能
@ConditionalOnClass 某个类在 classpath 上时生效 判断依赖是否引入
@ConditionalOnMissingClass 某个类不存在时生效 模块互斥、默认配置
@ConditionalOnBean 某个 Bean 存在时才注入 依赖性注入
@ConditionalOnMissingBean 某个 Bean 不存在时注入 默认 Bean 提供
@ConditionalOnExpression 使用 SpEL 表达式判断 灵活的表达式逻辑
@ConditionalOnResource 某个资源文件存在时生效 判断配置或 XML 是否存在
@ConditionalOnWebApplication 当前是 Web 应用时生效 Web 模块特有配置
@ConditionalOnNotWebApplication 非 Web 环境时生效 非 Web 场景配置
@ConditionalOnJava 判断当前 Java 版本 兼容性判断
@ConditionalOnJndi 某个 JNDI 条目存在时生效 JavaEE 环境支持

 用例:

@ConditionalOnProperty

@ConditionalOnProperty(name = "feature.enabled", havingValue = "true")
//根据配置文件中的 key/value 是否匹配来决定是否注入
//仅当配置中 feature.enabled=true 时,配置生效。

 @ConditionalOnClass/@ConditionalOnMissingClass

@ConditionalOnClass(name = "com.alibaba.fastjson.JSON")
//判断某个类是否存在于 classpath 中
//如果你的项目里引入了某个依赖,自动启用某个功能。

@ConditionalOnBean/@ConditionalOnMissingBean

@ConditionalOnMissingBean(DataSource.class)
public DataSource defaultDataSource() { ... }
//根据是否存在某个 Bean 决定是否注册另一个 Bean
//常用于提供默认实现,如果用户没有自定义 Bean,就注册一个默认的。
@Enable*系列注解

在跨模块导入时有以下几种方法1.ComponentScan通过扫描来导入 2.Import来导入 3.自定义一个Enable开头的注解里面整合Import注解来导入

Enable是一种 功能驱动型注解,用于开启 Spring 中的某项能力或模块。它本质上是 Spring 提供的一种简洁封装 —— 会通过 @Import 把某些关键 Bean、配置类、注册器引入到容器中,从而开启相关的配置或逻辑。 

案例:现在有一个类User(空类),然后创建一个自定义的EnableUser注解,来实现导入bean操作。

my-springboot-project/
├── main-app/             # 主程序模块
│   └── MainApp.java
│   └── application.yml
│   └── @EnableUser
├── user-module/          # 提供 User Bean 的模块
│   └── User.java
│   └── UserConfig.java
├── pom.xml               # 父 pom

1.创建一个空类User并写这个类的配置类UserConfig

package com.example.user;

/**
 * 跨模块要导入的 Bean 类
 */
public class User {
    // 示例空类
}

 User类的配置类UserConfig

package com.example.user;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * 注册 User Bean 的配置类
 */
@Configuration
public class UserConfig {

    @Bean
    public User user() {
        return new User();
    }
}

2.修改主模块的pom.xml 

<!--把User类所在模块整体导入了-->
<dependency>
    <groupId>com.example</groupId>
    <artifactId>user-module</artifactId><!--注意这里表示的是User类所在模块的名字-->
    <version>1.0-SNAPSHOT</version>
</dependency>

必须添加依赖,否则 @Import(UserConfig.class) 会找不到类!

3.主模块加入自定义的注解@EnableUser

package com.example.enable;

import com.example.user.UserConfig;
import org.springframework.context.annotation.Import;

import java.lang.annotation.*;

/**
 * 启用 user-module 模块中的 User Bean
 */
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import(UserConfig.class) // 关键:导入 user-module 的配置类
public @interface EnableUser {
}

4.最后启动项目

package com.example;

import com.example.user.User;
import com.example.enable.EnableUser;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ApplicationContext;

/**
 * 主启动类,演示跨模块导入 Bean
 */
@SpringBootApplication
@EnableUser // 开启 user-module 中的 Bean 注册
public class MainApp {

    public static void main(String[] args) {
        ApplicationContext context = SpringApplication.run(MainApp.class, args);

        User user = context.getBean(User.class);
        System.out.println("User Bean 注册成功:" + user);
    }
}

总结:在 Spring Boot 多模块项目中,我们使用自定义 @EnableXxx 注解 + @Import(配置类.class),将其他模块中的 Bean 注册到主模块的 Spring 容器中,实现跨模块 Bean 注入。跨项目导入基本上也是一样的原理。

@Import注解

Import注解的四种用法1.导入Bean 2.导入配置类 3.导入ImportSelector实现类,一般用于加载配置文件中的类 4.导入ImportBeanDefinitionRegistrar实现类

@EnableAutoConfiguration

Spring实战经验

Lombok自动生成getter/setter等

Lombok 是一个 Java 编译期工具,通过注解在编译时自动生成 getter / setter / 构造方法 / toString / equals / hashCode 等代码

Maven导入方法

<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.30</version>
    <scope>provided</scope>
</dependency>

Lombok最常用的注解就是@Data,等价于一次性加上了@Getter、@Setter、@ToString、@EqualsAndHashCode、@RequiredArgsConstructor(生成一个只包含必要参数(final修饰的字段和@NonNull修饰的字段)的构造方法)

统一响应处理结果的Result类

可以专门建立一个Result类放在pojo软件包里用来统一返回的数据格式

package com.xxx.common.result;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * 通用响应结果封装类
 *
 * @param <T> 响应数据的类型
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result<T> {

    /**
     * 响应状态码
     * <p>
     * 0:表示请求成功<br>
     * 1:表示请求失败
     * </p>
     */
    private Integer code;

    /**
     * 提示信息
     * <p>
     * 通常用于向前端展示成功或失败的原因说明
     * </p>
     */
    private String message;
    /**
     * 响应数据
     * <p>
     * 当请求成功且有数据需要返回时使用;
     * 若不需要返回数据(如新增、删除操作),该字段可以为 null
     * </p>
     */
    private T data;

    /**
     * 请求成功(无返回数据)
     *
     * @return Result<Void>
     */
    public static <T> Result<T> success() {
        return new Result<>(0, "success", null);
    }

    /**
     * 请求成功(有返回数据)
     *
     * @param data 返回的数据
     * @param <T> 数据类型
     * @return Result<T>
     */
    public static <T> Result<T> success(T data) {
        return new Result<>(0, "success", data);
    }

    /**
     * 请求失败
     *
     * @param message 失败提示信息
     * @param <T> 数据类型
     * @return Result<T>
     */
    public static <T> Result<T> failure(String message) {
        return new Result<>(1, message, null);
    }
}

使用例

@GetMapping("/users")
public Result<List<UserDTO>> listUsers() {
    List<UserDTO> users = userService.list();
    return Result.success(users);
}
{
  "code": 0,
  "message": "success",
  "data": [
    { "id": 1, "name": "Tom" },
    { "id": 2, "name": "Jerry" }
  ]
}

补充知识点:泛型类Result<T>

泛型类通过类型参数T,使返回结果在保持结构统一的同时具备类型安全性。泛型类不能直接用基本类型比如Result<int>,Result<Integer>。

泛型需要在定义的时候使用,比如Result<String> r,Result r(不推荐),Result<?> r(不关心里面内容类型)。错误写法Result<> r,正确写法new Result<>;

泛型类上面的泛型值只会影响变化的字段或是其他有泛型的内容固定类型的字段或是其他内容不受影响,该怎么样还是怎么样。

class Wrapper<T> {
    固定字段;
    T 变化字段;
}

密码加密保存数据库

前端输入的密码不可能原模原样保存数据库,所以就要先加密,然后保存数据库。从数据库拿出来也是,要先解密然后输出到前端。以下介绍以AES为例。

首先准备一个固定的密钥,一般放于配置文件中:

private static final String KEY = "1234567890abcdef"; // 16位 = AES-128

加密方法

public static String encrypt(String plainText) throws Exception {
    SecretKeySpec keySpec = new SecretKeySpec(KEY.getBytes(), "AES");
    Cipher cipher = Cipher.getInstance("AES");
    cipher.init(Cipher.ENCRYPT_MODE, keySpec);
    byte[] encrypted = cipher.doFinal(plainText.getBytes());
    return Base64.getEncoder().encodeToString(encrypted);
}

解密方法

public static String decrypt(String cipherText) throws Exception {
    SecretKeySpec keySpec = new SecretKeySpec(KEY.getBytes(), "AES");
    Cipher cipher = Cipher.getInstance("AES");
    cipher.init(Cipher.DECRYPT_MODE, keySpec);
    byte[] decoded = Base64.getDecoder().decode(cipherText);
    byte[] decrypted = cipher.doFinal(decoded);
    return new String(decrypted);
}

使用方法

String phone = "13800138000";

// 加密后存数据库
String encrypted = encrypt(phone);

// 从数据库取出来再解密
String original = decrypt(encrypted);

System.out.println(original); // 13800138000

参数校验

主要使用Spring Validation框架来对前端传回来的参数进行校验

第一种校验方式,controller参数校验:

package com.xxx.controller;

import com.xxx.common.result.Result;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import javax.validation.constraints.Pattern;

/**
 * 示例 Controller
 *
 * 演示:在 Controller 方法参数上直接进行参数校验
 */
@RestController
@Validated // ⭐ 非常关键:开启方法参数校验
public class UserController {

    /**
     * 查询用户(示例接口)
     *
     * @param phone 手机号
     *              要求:必须是 11 位,以 1 开头
     * @return 统一响应结果
     */
    @GetMapping("/user/query")
    public Result<Void> queryUser(
            @RequestParam("phone")
            @Pattern(
                    regexp = "^1\\d{10}$",
                    message = "手机号格式不正确"
            )
            String phone) {

        // ⚠️ 如果 phone 不符合 @Pattern 规则
        // 这个方法根本不会被执行

        return Result.success();
    }
}

@Validated注解非常重要,一定要在Controller上写该注解,确保@Pattern注解有用

第二种校验方式,在DTO类上校验

现在前端传了个数据到后端,我使用一个DTO类来接收,在DTO上来进行校验

首先创建一个DTO类

package com.xxx.dto;

import lombok.Data;

import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;

/**
 * 用户登录请求参数 DTO
 *
 * 用于接收前端传来的 JSON 数据,
 * 并在这里集中定义参数校验规则
 */
@Data
public class LoginRequest {

    /**
     * 用户名
     * 不能为空,且只能包含字母、数字、下划线
     */
    @NotBlank(message = "用户名不能为空")
    @Pattern(
            regexp = "^[a-zA-Z0-9_]{4,16}$",
            message = "用户名格式不正确"
    )
    private String username;

    /**
     * 密码
     * 不能为空
     */
    @NotBlank(message = "密码不能为空")
    private String password;
}

Controller类的参数上使用@Valid进行校验,没有@Valid注解DTO上面的注解完全不生效

package com.xxx.controller;

import com.xxx.common.result.Result;
import com.xxx.dto.LoginRequest;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import javax.validation.Valid;

/**
 * 示例 Controller
 *
 * 演示:使用 DTO + @Valid 进行参数校验
 */
@RestController
public class LoginController {

    /**
     * 用户登录接口
     *
     * @param request 登录请求参数(JSON)
     * @return 统一响应结果
     */
    @PostMapping("/login")
    public Result<Void> login(
            @Valid @RequestBody LoginRequest request) {

        // ⚠️ 如果参数校验失败
        // 这个方法不会被执行

        return Result.success();
    }
}

第三种校验方式,在业务代码中显示调用校验

首先和上面一样在DTO里面先写好校验规则

package com.xxx.dto;

import lombok.Data;

import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;

/**
 * 用户注册请求 DTO
 *
 * 只负责两件事:
 * 1. 接收前端传来的参数
 * 2. 定义参数校验规则
 */
@Data
public class RegisterRequest {

    /**
     * 用户名
     * 不能为空,只能是字母、数字、下划线,长度 4~16
     */
    @NotBlank(message = "用户名不能为空")
    @Pattern(
            regexp = "^[a-zA-Z0-9_]{4,16}$",
            message = "用户名格式不正确"
    )
    private String username;

    /**
     * 密码
     * 不能为空
     */
    @NotBlank(message = "密码不能为空")
    private String password;
}

第二步,在业务代码中让SpringBoot自动注入校验器

package com.xxx.controller;

import com.xxx.common.result.Result;
import com.xxx.dto.RegisterRequest;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import javax.validation.ConstraintViolation;
import javax.validation.Validator;
import java.util.Set;

/**
 * 示例 Controller
 *
 * 演示:如何在方法内部,手动触发参数校验
 */
@RestController
public class UserController {

    /**
     * Validator 是 Spring Boot 自动注入的
     *
     * 你不需要 new,也不应该 new
     */
    private final Validator validator;

    /**
     * 构造器注入 Validator(推荐方式)
     *
     * Spring 启动时会:
     * 1. 创建一个 Validator 的实现(Hibernate Validator)
     * 2. 自动调用这个构造方法
     * 3. 把 Validator 传进来
     */
    public UserController(Validator validator) {
        this.validator = validator;
    }

    /**
     * 用户注册接口
     *
     * 这里不使用 @Valid,而是在方法内部手动校验
     */
    @PostMapping("/register")
    public Result<Void> register(@RequestBody RegisterRequest request) {

        // ==============================
        // ① 手动触发校验
        // ==============================
        Set<ConstraintViolation<RegisterRequest>> violations =
                validator.validate(request);

        // ==============================
        // ② 判断是否有校验错误
        // ==============================
        if (!violations.isEmpty()) {

            Map<String, String> errorMap = new HashMap<>();
            
            // 遍历校验错误并存在Map集合里面
            for (ConstraintViolation<RegisterRequest> violation : violations) {
                String field = violation.getPropertyPath().toString();
                String message = violation.getMessage();
                errorMap.put(field, message);
            }

    
            return Result.failure(errorMap.toString());
        }

        

        // ==============================
        // ③ 校验通过,执行业务逻辑
        // ==============================
        // userService.register(request);

        return Result.success();
    }
}

分组校验

在同一个表里写多个校验的注解,在不同的方法中这个校验的逻辑就会互相干扰,因此需要进行分组校验。

首先,要定义分组的标准,可以使用多个接口来实现定义。只是定义,不需要写更多的方法可以直接在实体类内部定义

package com.xxx.validation;

/**
 * 分组:新增(Create)
 * 用于:新增接口的参数校验
 */
public interface CreateGroup {}

/**
 * 分组:修改(Update)
 * 用于:修改接口的参数校验
 */
public interface UpdateGroup {}

在表中开始使用group参数定义的分组

package com.xxx.dto;

import com.xxx.validation.CreateGroup;
import com.xxx.validation.UpdateGroup;
import lombok.Data;

import javax.validation.constraints.*;

/**
 * 用户参数 DTO(演示分组校验)
 *
 * 场景:
 * - 新增:id 必须为空(由数据库生成)
 * - 修改:id 必须有值(要知道改谁)
 */
@Data
public class UserInfoRequest {

    /**
     * id:
     * - 新增时必须为空(不能传 id)
     * - 修改时必须不为空(必须传 id)
     */
    @Null(message = "新增时id必须为空", groups = CreateGroup.class)
    @NotNull(message = "修改时id不能为空", groups = UpdateGroup.class)
    private Long id;

    /**
     * 用户名:
     * - 新增 / 修改都需要校验,所以两个分组都加上
     */
    @NotBlank(message = "用户名不能为空", groups = {CreateGroup.class, UpdateGroup.class})
    @Size(min = 2, max = 20, message = "用户名长度必须在2~20之间", groups = {CreateGroup.class, UpdateGroup.class})
    private String userName;

    /**
     * 年龄:
     * - 新增时必须传(示例)
     * - 修改时可以不传(示例)
     *
     * 注意:
     * - @Min/@Max 对 null 不生效(null 会跳过)
     * - 所以如果你想“新增必须传”,要配合 @NotNull
     */
    @NotNull(message = "新增时年龄不能为空", groups = CreateGroup.class)
    @Min(value = 1, message = "年龄不能小于1", groups = {CreateGroup.class, UpdateGroup.class})
    @Max(value = 150, message = "年龄不能大于150", groups = {CreateGroup.class, UpdateGroup.class})
    private Integer age;
}

Controller上,使用@Validated的value属性指定使用的分组

package com.xxx.controller;

import com.xxx.dto.UserInfoRequest;
import com.xxx.validation.CreateGroup;
import com.xxx.validation.UpdateGroup;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;
import org.springframework.validation.annotation.Validated;

/**
 * 用户接口(演示分组校验)
 */
@RestController
@RequestMapping("/user")
public class UserController {

    /**
     * 新增接口:使用 CreateGroup 分组校验
     *
     * 规则:
     * - id 必须为空
     * - userName 必须有值且长度 2~20
     * - age 必须有值且范围 1~150
     */
    @PostMapping("/create")
    public String create(@RequestBody @Validated(CreateGroup.class) UserInfoRequest req) {
        return "新增成功";
    }

    /**
     * 修改接口:使用 UpdateGroup 分组校验
     *
     * 规则:
     * - id 必须有值
     * - userName 必须有值且长度 2~20
     * - age 可以不传(但如果传了,仍要满足 1~150)
     */
    @PostMapping("/update")
    public String update(@RequestBody @Validated(UpdateGroup.class) UserInfoRequest req) {
        return "修改成功";
    }
}

默认分组

在分组的定义中,可以让校验规则extends Default,这样就是默认分组。当校验规则没有指定校验分组时,就为默认分组。

public interface CreateGroup extends Default {}

分组也可以继承,A extends B,那么A就拥有B中所有校验项。

public interface B {}
public interface A extends B {}
// A的校验内容等于A+B

全局异常

参数校验以后,会直接抛异常导致500状态码返回到前端,这是不合理的,所以需要定义一个全局异常来统一处理参数校验抛出的异常。

情况一,直接在参数上进行校验抛出的异常的情况

package com.xxx.common.exception;

import com.xxx.common.result.Result;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.validation.FieldError;

import javax.validation.ConstraintViolation;
import javax.validation.ConstraintViolationException;
import java.util.LinkedHashMap;
import java.util.Map;

/**
 * 全局异常处理器(参数校验 - 多字段返回版)
 *
 * 返回结构示例:
 * {
 *   "code": 1,
 *   "message": "参数校验失败",
 *   "data": {
 *     "name": "姓名不合法",
 *     "password": "密码不合法"
 *   }
 * }
 */
@RestControllerAdvice
public class GlobalExceptionHandler {

    /**
     * 处理:DTO 参数校验失败(@Valid @RequestBody)
     *
     * 特点:
     * - 适用于 JSON 请求体
     * - 可一次性拿到多个字段错误
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Result<Map<String, String>> handleMethodArgumentNotValid(MethodArgumentNotValidException ex) {

        // 用 LinkedHashMap 主要是为了“尽量保持”错误顺序更稳定
        Map<String, String> errorMap = new LinkedHashMap<>();

        for (FieldError error : ex.getBindingResult().getFieldErrors()) {
            String field = error.getField();                 // 字段名,如 name
            String message = error.getDefaultMessage();      // 注解 message,如 姓名不合法

            // 如果同一个字段有多个错误,这里保留第一条(也可以改成覆盖最后一条)
            errorMap.putIfAbsent(field, message);
        }

        // message 你也可以固定成“参数校验失败”,更通用
        // 但如果你希望 message 就是“第一条错误信息”,也可以这么做:
        String message = "参数校验失败";

        return new Result<>(1, message, errorMap);
    }

    /**
     * 处理:方法参数校验失败(Controller 参数上直接写 @Pattern/@Min ...)
     *
     * 前提:
     * - Controller 类上通常需要加 @Validated 才会触发这类校验
     */
    @ExceptionHandler(ConstraintViolationException.class)
    public Result<Map<String, String>> handleConstraintViolation(ConstraintViolationException ex) {

        Map<String, String> errorMap = new LinkedHashMap<>();

        for (ConstraintViolation<?> v : ex.getConstraintViolations()) {

            // propertyPath 类似:queryUser.phone 或 createUser.arg0
            String path = v.getPropertyPath().toString();

            // 这里取最后一段作为“参数名/字段名”
            String field = path.contains(".") ? path.substring(path.lastIndexOf('.') + 1) : path;

            String message = v.getMessage();

            errorMap.putIfAbsent(field, message);
        }

        String message = "参数校验失败";

        return new Result<>(1, message, errorMap);
    }
}

情况二,在业务代码里面显示校验,校验以后遍历错误message然后塞进BindingResult然后抛出抛出 BindException

假设有以下DTO类

public class RegisterRequest {

    @NotBlank(message = "姓名不合法")
    private String name;

    @NotBlank(message = "密码不合法")
    private String password;
}

业务代码里面显示调用检验

import org.springframework.validation.BeanPropertyBindingResult;
import org.springframework.validation.BindException;
import org.springframework.validation.ObjectError;

import javax.validation.ConstraintViolation;
import javax.validation.Validator;
import java.util.Set;

@RestController
public class UserController {

    private final Validator validator;

    public UserController(Validator validator) {
        this.validator = validator;
    }

    @PostMapping("/register")
    public Result<Void> register(@RequestBody RegisterRequest request) throws BindException {

        // 1️⃣ 手动校验(一次性校验所有字段)
        Set<ConstraintViolation<RegisterRequest>> violations =
                validator.validate(request);

        // 2️⃣ 如果有校验错误,构造 BindingResult
        if (!violations.isEmpty()) {

            BeanPropertyBindingResult bindingResult =
                    new BeanPropertyBindingResult(request, "registerRequest");

            // 3️⃣ 把所有校验错误塞进 BindingResult
            for (ConstraintViolation<RegisterRequest> v : violations) {
                String field = v.getPropertyPath().toString();
                String message = v.getMessage();

                // 校验错误容器
                // 告诉Spring哪个字段出错了,错误码多少,出的错误message
                bindingResult.rejectValue(field, null, message);
            }

            // 4️⃣ 抛出 BindException(Spring 能识别)
            throw new BindException(bindingResult);
        }

        // 5️⃣ 校验通过,执行业务逻辑
        return Result.success();
    }
}

再加一个全局异常

@ExceptionHandler(BindException.class)
public Result<Map<String, String>> handleBindException(BindException ex) {

    Map<String, String> errorMap = new LinkedHashMap<>();

    ex.getBindingResult().getFieldErrors().forEach(error -> {
        errorMap.putIfAbsent(
                error.getField(),
                error.getDefaultMessage()
        );
    });

    String firstMessage = errorMap.values().stream().findFirst().orElse("参数校验失败");

    return new Result<>(1, firstMessage, errorMap);
}

登录认证(JWT令牌)

JSON Web Token,定义了一种简洁的,自包含的格式,用于通信双方以JSON数据格式安全的传输信息。

JWT要分为三部分

第一部分:Header(头),记录令牌的类型、签名算法等。例如:

{
  "alg": "HS256",
  "typ": "JWT"
}

第二部分:Payload(有效载荷),携带一些自定义信息、默认信息。例如:

{
  "userId": 1,
  "username": "zhangsan",
  "exp": 1710000000
}

第三部分:Signature(签名),防止Token被篡改、确保安全性,将header、payload,并加入指定密钥,通过指定签名算法计算而来。不可能被篡改也不可能人工解码。

SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

以上三部分通过Base64编码以后得到如下完整JWT。每一个部分的内容通过.分隔

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJ1c2VySWQiOjEsInVzZXJuYW1lIjoiemhhbmdzYW4iLCJleHAiOjE3MTAwMDAwMDB9
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

重点Payload 不是加密的,只是 Base64 编码任何人都能解码看到内容

注意Payload不能放密码或其他重要敏感信息

首先导入依赖

<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>4.4.0</version>
</dependency>

生成JWT封装方法

package com.xxx.common.jwt;

import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;

import java.util.Date;

/**
 * JWT 工具类
 *
 * 目前只封装:生成 JWT
 * 使用 HMAC256 对称加密算法
 */
public class JwtUtil {

    /**
     * JWT 签名密钥(非常重要)
     *
     * ⚠️ 实际项目中:
     * - 不要写死在代码里
     * - 一般放在配置文件或环境变量中
     */
    private static final String SECRET = "my_jwt_secret_key";

    /**
     * 生成 JWT
     *
     * @param userId   用户 ID
     * @param username 用户名
     * @return 生成的 JWT 字符串
     */
    public static String generateToken(Long userId, String username) {

        // 1️⃣ 选择签名算法(HMAC256)
        Algorithm algorithm = Algorithm.HMAC256(SECRET);

        // 2️⃣ 设置过期时间(例如:2 小时后过期)
        long expireTime = System.currentTimeMillis() + 2 * 60 * 60 * 1000;
        Date expireDate = new Date(expireTime);

        // 3️⃣ 构建 JWT 并生成
        return JWT.create()
                // ====== Payload(你放的数据)======
                .withClaim("userId", userId)
                .withClaim("username", username)

                // ====== 过期时间(标准字段)======
                .withExpiresAt(expireDate)

                // ====== 使用算法签名 ======
                .sign(algorithm);
    }
}

验证JWT

package com.xxx.common.jwt;

import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.auth0.jwt.exceptions.JWTVerificationException;

import java.util.Date;

/**
 * JWT 工具类
 *
 * 功能:
 * 1. 生成 JWT
 * 2. 验证 JWT
 */
public class JwtUtil {

    /**
     * JWT 签名密钥(必须和生成时一致)
     */
    private static final String SECRET = "my_jwt_secret_key";

    /**
     * JWT 过期时间(2 小时)
     */
    private static final long EXPIRE_TIME = 2 * 60 * 60 * 1000;

    /**
     * 生成 JWT
     *
     * @param userId   用户 ID
     * @param username 用户名
     * @return JWT 字符串
     */
    public static String generateToken(Long userId, String username) {

        Algorithm algorithm = Algorithm.HMAC256(SECRET);

        Date expireDate = new Date(System.currentTimeMillis() + EXPIRE_TIME);

        return JWT.create()
                // ===== Payload =====
                .withClaim("userId", userId)
                .withClaim("username", username)

                // ===== 过期时间 =====
                .withExpiresAt(expireDate)

                // ===== 签名 =====
                .sign(algorithm);
    }

    /**
     * 验证 JWT 是否有效
     *
     * 验证内容包括:
     * 1. Token 是否被篡改(签名校验)
     * 2. Token 是否过期
     * 3. Token 格式是否合法
     *
     * @param token 前端传来的 JWT
     * @return DecodedJWT(校验通过后得到的 JWT 信息)
     * @throws JWTVerificationException 校验失败时抛出异常
     */
    public static DecodedJWT verifyToken(String token) throws JWTVerificationException {

        // 1️⃣ 使用和生成时相同的算法和密钥
        Algorithm algorithm = Algorithm.HMAC256(SECRET);

        // 2️⃣ 构建 JWT 校验器
        JWTVerifier verifier = JWT.require(algorithm)
                // 这里可以加额外校验条件(以后再学)
                .build();

        // 3️⃣ 校验并解析 JWT
        // 如果校验失败(过期、签名错误等),会直接抛异常
        return verifier.verify(token);
    }
}

注意,JWT校验时使用的签名密钥,必须和生成JWT令牌时使用的密钥是配套的

如果JWT令牌解析校验时报错,说明JWT令牌被篡改或是失效了,令牌非法

在以上检验JWT的方法中

检验成功的情况下,返回DecodedJWT对象。

// Header内容
jwt.getAlgorithm();   // HS256
jwt.getType();        // JWT

// Payload内容
Long userId = jwt.getClaim("userId").asLong();
String username = jwt.getClaim("username").asString();
Date expireTime = jwt.getExpiresAt();

令牌过期时,抛出TokenExpiredException错误

com.auth0.jwt.exceptions.TokenExpiredException

令牌非法或篡改时抛出JWTVerificationException错误

com.auth0.jwt.exceptions.JWTVerificationException

try {
    DecodedJWT jwt = JwtUtil.verifyToken(token);
    // 👉 能走到这里,一定是合法 Token
} catch (TokenExpiredException e) {
    // 👉 Token 过期
} catch (JWTVerificationException e) {
    // 👉 Token 非法 / 被篡改
}

JWT生成以后,返回给前端就可以

拦截器

建议专门创建一个软件包interceptors用来放软件包相关类。一般情况下拦截器返回true就是代表放行,返回false代表拦截,不放行。同时如果想拦截器正常使用,还需要在配置类注册一下拦截器,配置类记得要使用@Configuration。注意:登录和注册的网址接口一定要直接放行,否则客户就无法进入到登录和注册的页面了

拦截器代码示例

package com.xxx.interceptors;

import com.auth0.jwt.exceptions.JWTVerificationException;
import com.xxx.common.jwt.JwtUtil;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * JWT 拦截器(教学版 / 简化版)
 *
 * 作用:
 * 1. 拦截请求
 * 2. 从请求头中获取 JWT
 * 3. 验证 JWT 是否有效
 *
 * 注意:
 * - 这里只做最基本的 Token 校验
 * - 校验失败直接拦截请求
 * - 不考虑复杂场景,保证你第一次能跑通
 */
@Component
public class JwtInterceptor implements HandlerInterceptor {

    /**
     * 在 Controller 方法执行之前调用
     *
     * @param request  当前 HTTP 请求
     * @param response 当前 HTTP 响应
     * @param handler  被拦截的 Controller 方法
     * @return true  放行请求
     *         false 拦截请求
     */
    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response,
                             Object handler) throws Exception {

        // 1️⃣ 从请求头中获取 token
        // 约定:前端把 token 放在 Authorization 请求头中
        String token = request.getHeader("Authorization");

        // 2️⃣ 如果请求头中没有 token,说明用户未登录
        if (token == null || token.length() == 0) {
            // 返回 401 状态码,表示未认证
            response.setStatus(401);
            response.getWriter().write("Unauthorized: no token");
            return false;
        }

        // 3️⃣ 校验 token
        try {
            // 如果 token 无效或过期,这行代码会抛异常
            JwtUtil.verifyToken(token);

            // 4️⃣ 校验成功,放行请求
            return true;

        } catch (JWTVerificationException e) {
            // 5️⃣ 校验失败,拦截请求
            response.setStatus(401);
            response.getWriter().write("Unauthorized: invalid token");
            return false;
        }
    }
}

补充:拦截器主要是在Controller之前执行;之所以使用request.getHeader("Authorization")来取token,是因为通常都约定前端在Authorization里面存放token;JwtUtil的verifyToken在校验失败时直接抛异常;在上面的写法中使用了try-catch,在catch里面对校验错误的内容进行了处理,实际过程中可以考虑直接使用全局异常来进行校验失败处理抛出来的异常

接下来在配置类中进行注册

package com.xxx.config;

import com.xxx.interceptors.JwtInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * Web 配置类
 *
 * 作用:
 * 1. 注册拦截器
 * 2. 指定拦截哪些请求,放行哪些请求
 *
 * 这是 Spring MVC 的固定写法
 */
@Configuration
public class WebConfig implements WebMvcConfigurer {

    /**
     * 注入我们自己写的 JWT 拦截器
     *
     * 注意:
     * - JwtInterceptor 上有 @Component
     * - 所以 Spring 能自动创建它
     */
    @Autowired
    private JwtInterceptor jwtInterceptor;

    /**
     * 注册拦截器的方法
     *
     * Spring Boot 启动时会自动调用这个方法
     */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {

        registry.addInterceptor(jwtInterceptor)
                // 1️⃣ 拦截所有请求(/** 表示所有路径)
                .addPathPatterns("/**")

                // 2️⃣ 放行登录接口(否则你连登录都进不去)
                .excludePathPatterns("/login");

        // 如果你还有注册、验证码等接口,后面可以继续加
        // .excludePathPatterns("/register")
        // .excludePathPatterns("/captcha");
    }
}

在放行上,还有以下几种情况

同时放行多个接口,与同时放行某一个模块

// 同时放行多个接口
.excludePathPatterns(
        "/login",
        "/register",
        "/captcha",
        "/sendCode"
);


// 同时放行某一个模块,auth开头的接口直接放行
.excludePathPatterns("/auth/**");

拦截器生命周期函数

1️⃣ preHandle
      ↓
2️⃣ Controller 方法
      ↓
3️⃣ postHandle
      ↓
4️⃣ afterCompletion

Mybatis中下划线命名与驼峰命名自动转换

Java常用驼峰命名,SQL常用下划线命名,如果使用的是MyBatis连接的数据库,可以更改如下配置来达到两种命名的自动转换。

mybatis:
  configuration:
    map-underscore-to-camel-case: true
mybatis.configuration.map-underscore-to-camel-case=true

ThreadLocal优化

在获取用户信息时每次都要获取前端请求头的JWT令牌信息来获取用户名,太麻烦了。现在有如下思路,在请求以后要经过拦截器,拦截器确认令牌正确以后,直接把重要信息比如userID直接保存在ThreadLocal对象里面,然后直接要用的时候获取userID就可以。ThreadLocal是线程安全的一个类,不负责登录、不负责鉴权,只负责“传递上下文”,如果要存大对象、跨线程(异步)使用、长生命周期使用则不建议使用

该ThreadLocal的生命周期为一次请求,也就是说一次请求完成以后就要在对应的Controller里面给销毁了,避免内存泄露。

package com.xxx.common.context;

/**
 * ThreadLocal 工具类(静态工具类版)
 *
 * 设计目的:
 * 1. 统一管理 ThreadLocal 的 set / get / remove 操作
 * 2. 避免在业务代码中直接操作 ThreadLocal
 * 3. 防止每次使用都 new 对象,造成设计混乱
 *
 * 使用说明:
 * - 该类只用于“线程级上下文数据”
 * - 使用完毕后必须调用 remove() 方法清理
 *
 * @param <T> 当前线程中保存的数据类型
 */
public final class ThreadLocalUtil<T> {

    /**
     * 真正存储数据的 ThreadLocal
     *
     * static:
     * - 保证全局只有一份 ThreadLocal 实例
     * - 每个线程依然有自己独立的 value
     */
    private static final ThreadLocal<Object> THREAD_LOCAL = new ThreadLocal<>();

    /**
     * 私有构造方法
     *
     * 防止工具类被实例化
     */
    private ThreadLocalUtil() {
    }

    /**
     * 设置当前线程的值
     *
     * @param value 要存储的值
     */
    public static void set(Object value) {
        THREAD_LOCAL.set(value);
    }

    /**
     * 获取当前线程的值
     *
     * @param <T> 返回值的泛型类型
     * @return 当前线程中存储的值
     */
    @SuppressWarnings("unchecked")
    public static <T> T get() {
        return (T) THREAD_LOCAL.get();
    }

    /**
     * 清除当前线程中的值(非常重要)
     *
     * 说明:
     * - Web 容器使用线程池,线程会被重复复用
     * - 不清除会导致数据错乱甚至内存泄露
     * - 必须在请求结束时调用
     */
    public static void remove() {
        THREAD_LOCAL.remove();
    }
}

销毁时应该在拦截器里面销毁释放,在拦截器重写如下方法进行销毁。

    /**
     * 请求完全结束之后执行(无论成功还是异常)
     *
     * 用于:清理 ThreadLocal,避免内存泄露和用户串号
     */
    @Override
    public void afterCompletion(HttpServletRequest request,
                                HttpServletResponse response,
                                Object handler,
                                Exception ex) {

        // ⚠️ 非常重要:必须清理
        ThreadLocalUtil.remove();
    }

MyBatis单表操作与多表操作开发流程

mybatis单表操作

以User表为例子

单表操作开发流程:

现在有如下数据库表

CREATE TABLE user_info (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_name VARCHAR(50),
    age INT,
    email VARCHAR(100)
);

针对这个表建立POJO实体类

package com.xxx.entity;

import lombok.Data;

/**
 * UserInfo 实体类
 *
 * 对应数据库表:user_info
 * 
 * 说明:
 * - 字段使用驼峰命名
 * - 数据库字段使用下划线命名
 * - 依赖 mybatis 的 mapUnderscoreToCamelCase 自动映射
 */
@Data
public class UserInfo {

    /**
     * 用户主键 ID
     */
    private Long id;

    /**
     * 用户名
     * 对应数据库字段:user_name
     */
    private String userName;

    /**
     * 年龄
     */
    private Integer age;

    /**
     * 邮箱
     */
    private String email;
}

写一个Mapper接口,同时写好SQL代码

package com.xxx.mapper;

import com.xxx.entity.UserInfo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;

/**
 * UserInfo Mapper 接口
 *
 * 负责 UserInfo 表的数据库操作
 */
@Mapper
public interface UserInfoMapper {

    /**
     * 根据 ID 查询用户信息
     *
     * @param id 用户 ID
     * @return UserInfo 实体对象
     */
    @Select("SELECT id, user_name, age, email FROM user_info WHERE id = #{id}")
    UserInfo selectById(Long id);
}

到时候再Service直接调用UserInfoMapper的selectById就可以对ID进行查询了。

多表操作返回非DTO类

首先在mapper层写对应的接口

package com.xxx.mapper;

import org.apache.ibatis.annotations.Mapper;

import java.util.List;
import java.util.Map;

/**
 * 用户-课程 查询 Mapper
 *
 * 说明:
 * - 这里演示多表查询(UserInfo + Course)
 * - 不使用 DTO
 * - 直接把查询结果按“键值对”形式返回(Map)
 *
 * Map 的 key 对应 SQL 中的列别名(AS 后面的名字)
 */
@Mapper
public interface UserCourseMapper {

    /**
     * 查询:某个用户的课程列表(含用户名 + 课程名等字段)
     *
     * @param userId 用户 ID
     * @return List<Map>,每一条 Map 就是一行结果
     */
    List<Map<String, Object>> selectCoursesByUserId(Long userId);
}

多表查询的语句放在XML中

XML文件中,XML放置路径如下

resources/com/xxxxx/mapper/UserCourseMapper.xml

注意,路径必须和java文件里面的路径一模一样,才能映射到。

映射配置文件的名字,必须和接口的名字保持一致。

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<!--
  namespace 必须写 Mapper 接口的全限定名
  否则 MyBatis 找不到对应的方法
-->
<mapper namespace="com.xxx.mapper.UserCourseMapper">

    <!--
      多表查询示例(UserInfo + Course)
      
      假设表结构:
      - user_info: id, user_name
      - course: id, course_name, teacher_name, user_id  (user_id 外键指向 user_info.id)
      
      说明:
      - 这里返回类型是 Map,所以 resultType 写 map
      - SQL 中强烈建议给字段起别名(AS ...)
        这样 Map 的 key 就稳定、清晰,不容易冲突(比如两个表都有 id)
    -->
    <select id="selectCoursesByUserId" resultType="map">
        SELECT
            u.id           AS userId,
            u.user_name    AS userName,
            c.id           AS courseId,
            c.course_name  AS courseName,
            c.teacher_name AS teacherName
        FROM user_info u
        JOIN course c ON c.user_id = u.id
        WHERE u.id = #{userId}
    </select>

</mapper>

service调用示例

package com.xxx.service;

import com.xxx.mapper.UserCourseMapper;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.util.List;
import java.util.Map;

/**
 * 用户-课程 查询 Service
 *
 * 说明:
 * - Service 只接收 Mapper 返回的 Map 结果
 * - 不使用 DTO
 * - 你可以在这里做业务加工(排序、过滤、二次封装等)
 */
@Service
public class UserCourseQueryService {

    @Resource
    private UserCourseMapper userCourseMapper;

    /**
     * 根据用户 ID 查询该用户的课程列表(多表查询)
     *
     * @param userId 用户 ID
     * @return List<Map<String,Object>> 查询结果
     */
    public List<Map<String, Object>> queryCoursesOfUser(Long userId) {
        return userCourseMapper.selectCoursesByUserId(userId);
    }
}

多表操作返回DTO类

mapper层接口要更改

List<Map<String, Object>> selectCoursesByUserId(Long userId);

xml的返回值类型也需要修改

<select id="selectCoursesByUserId" resultType="com.xxx.dto.UserCourseDTO">

总结

1.无论查询的结果有多少个,都要使用List<>集合包起来,例如List<Map<String,String>>。

2.多表查询建议使用AS来给数据起别名避免乱套。使用AS可以让字段自动映射,例如:数据库命名为user_name,java中为userName,那么使用user_name as userName就可以让两个字段互相映射到。

3.接口的namespace要写全名,例如:com.xxx.mapper.UserCourseMapper。同理id也要和mapper接口的方法名一致。

MyBatis动态SQL语法

在XML中,可能在一些地方需要使用where,又不需要使用where,如果不使用动态SQL语句,那么就要针对不同的情况写多个SQL语句,太麻烦,所以有了动态SQL语句。

最基础创建的动态SQL语句如下。在下面的语句中如果有userName,那么则where条件就有where,如果有age条件,那么where条件就有age,来了个都没有则直接没有where条件。

<select id="selectUser" resultType="UserInfo">
    SELECT *
    FROM user_info
    <where>
        <if test="userName != null and userName != ''">
            AND user_name = #{userName}
        </if>
        <if test="age != null">
            AND age = #{age}
        </if>
    </where>
</select>

<if>标签语法

<if test="条件">
    SQL 片段
</if>

如果想多个条件只走一个,用<choose>标签

<where>
    <choose>
        <when test="userName != null">
            user_name = #{userName}
        </when>
        <when test="email != null">
            email = #{email}
        </when>
        <otherwise>
            status = 1
        </otherwise>
    </choose>
</where>

该条件像Java中的if else-if else语句,当userName不为null并且email也不为null时也只执行第一个when,后面的when和otherwise都不会再执行。otherwise就类似else,上面都不满足时就执行otherwise。

<set>标签语法

和<where>几乎一样,只不过是用在update语句

<update id="updateUser">
    UPDATE user_info
    <set>
        <if test="userName != null">
            user_name = #{userName},
        </if>
        <if test="age != null">
            age = #{age},
        </if>
    </set>
    WHERE id = #{id}
</update>

<foreach>标签语法

<where>
    <if test="ids != null and ids.size() > 0">
        id IN
        <foreach collection="ids"
                 item="id"
                 open="("
                 close=")"
                 separator=",">
            #{id}
        </foreach>
    </if>
</where>

分页(PageHalper)

首先要导入PageHalper依赖

<!-- PageHelper 分页插件(Spring Boot Starter) -->
<dependency>
    <groupId>com.github.pagehelper</groupId>
    <artifactId>pagehelper-spring-boot-starter</artifactId>
    <version>2.1.1</version>
</dependency>

properties配置文件修改

pagehelper:
  # 数据库方言:mysql / oracle / postgresql 等
  helper-dialect: mysql
  # 合理化分页:页码<=0 自动查第一页;页码>总页数 自动查最后一页
  reasonable: true
  # 支持通过 Mapper 方法参数传递分页参数(可选)
  support-methods-arguments: true
  # 用于统计总数时的 count 查询(一般默认即可)
  params: count=countSql

mapper接口代码

package com.xxx.mapper;

import com.xxx.entity.UserInfo;
import org.apache.ibatis.annotations.Mapper;

import java.util.List;

/**
 * UserInfo Mapper
 *
 * 说明:
 * - 分页不需要你在方法上写 pageNum/pageSize
 * - 你只要写一个“正常查询”的方法
 * - PageHelper 会在你调用查询前 startPage(...) 后自动生效
 */
@Mapper
public interface UserInfoMapper {

    /**
     * 条件查询用户列表(用于演示分页)
     *
     * @param userName 用户名(可选)
     * @param minAge   最小年龄(可选)
     * @return 用户列表(未分页时返回全量;分页由 PageHelper 控制)
     */
    List<UserInfo> selectByCondition(String userName, Integer minAge);
}

对应的XML语句,因为涉及到动态SQL,所以不建议在注解里面写SQL语句

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.xxx.mapper.UserInfoMapper">

    <!--
      动态条件查询:
      - userName 传了才拼 user_name 条件
      - minAge 传了才拼 age 条件
      - 不传就查全部
    -->
    <select id="selectByCondition" resultType="com.xxx.entity.UserInfo">
        SELECT id, user_name, age, email
        FROM user_info
        <where>
            <if test="userName != null and userName != ''">
                AND user_name LIKE CONCAT('%', #{userName}, '%')
            </if>
            <if test="minAge != null">
                AND age &gt;= #{minAge}
            </if>
        </where>
        ORDER BY id DESC
    </select>

</mapper>

然后在Service层进行分页查询

package com.xxx.service;

import com.github.pagehelper.PageHelper;
import com.github.pagehelper.PageInfo;
import com.xxx.entity.UserInfo;
import com.xxx.mapper.UserInfoMapper;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.util.List;

/**
 * 用户分页查询 Service
 */
@Service
public class UserInfoService {

    @Resource
    private UserInfoMapper userInfoMapper;

    /**
     * 分页查询用户列表(PageHelper 经典用法)
     *
     * @param pageNum  当前页(从 1 开始)
     * @param pageSize 每页条数
     * @param userName 用户名模糊查询(可选)
     * @param minAge   最小年龄(可选)
     * @return PageInfo<UserInfo>:包含 list、total、pages、pageNum、pageSize 等
     */
    public PageInfo<UserInfo> pageQuery(int pageNum, int pageSize, String userName, Integer minAge) {

        // 1️⃣ 开启分页:必须写在 mapper 查询之前!
        PageHelper.startPage(pageNum, pageSize);

        // 2️⃣ 执行“普通查询”(SQL 不用写 limit)
        List<UserInfo> list = userInfoMapper.selectByCondition(userName, minAge);

        // 3️⃣ 用 PageInfo 包装:PageHelper 会把分页信息塞进去
        return new PageInfo<>(list);
    }
}

Controller层参数上可以把分页的参数设为非必须避免一些问题的出现

package com.xxx.controller;

import com.github.pagehelper.PageInfo;
import com.xxx.entity.UserInfo;
import com.xxx.service.UserInfoService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;

/**
 * 用户分页接口
 */
@RestController
public class UserInfoController {

    @Resource
    private UserInfoService userInfoService;

    /**
     * 分页查询接口
     *
     * 示例:
     * GET /user-info/page?pageNum=1&pageSize=5&userName=张&minAge=18
     */
    @GetMapping("/user-info/page")
    public PageInfo<UserInfo> page(
            @RequestParam(defaultValue = "1") int pageNum,
            @RequestParam(defaultValue = "10") int pageSize,
            @RequestParam(required = false) String userName,
            @RequestParam(required = false) Integer minAge
    ) {
        return userInfoService.pageQuery(pageNum, pageSize, userName, minAge);
    }
}

前端拿到PageInfo以后,看到的JSON如下所示

{
  "total": 53,
  "pages": 6,
  "pageNum": 1,
  "pageSize": 10,
  "list": [
    {
      "id": 1,
      "userName": "张三",
      "age": 20,
      "email": "a@test.com"
    },
    {
      "id": 2,
      "userName": "李四",
      "age": 22,
      "email": "b@test.com"
    }
  ],
  "hasNextPage": true,
  "hasPreviousPage": false
}

补充:在执行顺序上,首先执行PageHelper.startPage告诉PageHelper我要分页,分页的数量与每页显示的数量。然后执行mapper查询语句,当执行查询语句的时候PageHelper会自动拦截SQL语句,并给SQL语句加上limit条件来进行分页操作,同时执行Count(*)函数。

List<UserInfo> list真实类型是Page<E> extends ArrayList<E>,这是一个带分页信息的List

提升不建议直接把PageInfo直接暴露给前端,因为PageInfo有太多后端内容,有一定风险。可以像上面的Result类一样,专门设定一个统一的PageResult然后来返回给前端。如下所示

@Data
public class PageResult<T> {

    /** 当前页数据 */
    private List<T> list;

    /** 总记录数 */
    private long total;

    /** 当前页码 */
    private int pageNum;

    /** 每页条数 */
    private int pageSize;

    /** 总页数 */
    private int pages;

    public static <T> PageResult<T> from(PageInfo<T> pageInfo) {
        PageResult<T> result = new PageResult<>();
        result.setList(pageInfo.getList());
        result.setTotal(pageInfo.getTotal());
        result.setPageNum(pageInfo.getPageNum());
        result.setPageSize(pageInfo.getPageSize());
        result.setPages(pageInfo.getPages());
        return result;
    }
}

然后结合前面写的Result类返回给前端

@GetMapping("/user-info/page")
public Result<PageResult<UserInfo>> page(...) {
    PageInfo<UserInfo> pageInfo = userInfoService.pageQuery(...);
    return Result.success(PageResult.from(pageInfo));
}

这样前端就可以看到如下的JSON代码

{
  "code": 0,
  "message": "success",
  "data": {
    "list": [...],
    "total": 53,
    "pageNum": 1,
    "pageSize": 10,
    "pages": 6
  }
}

文件上传并存储到本地

文件上传以后需要使用uuid来给文件重命名并保存到本地,因为直接使用文件原名就会导致在存储过程中重名,所以需要使用uuid来生成名字。

controller代码如下所示

package com.xxx.controller;

import com.xxx.common.result.Result;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.time.LocalDate;
import java.util.UUID;

/**
 * 头像上传 Controller
 *
 * 功能:
 * 1. 接收前端上传的头像文件
 * 2. 使用 UUID 生成唯一文件名
 * 3. 按日期分目录保存文件
 * 4. 返回头像访问 URL
 */
@RestController
@RequestMapping("/api/profile")
public class AvatarController {

    /**
     * 上传头像接口
     *
     * 前端请求:
     * POST /api/profile/avatar
     * Content-Type: multipart/form-data
     * 参数名:file
     */
    @PostMapping("/avatar")
    public Result<String> uploadAvatar(@RequestParam("file") MultipartFile file) throws Exception {

        /* ================= 1️⃣ 基础校验 ================= */

        // 文件是否为空
        if (file == null || file.isEmpty()) {
            return Result.failure("请选择要上传的头像文件");
        }

        // 校验是否为图片类型(简单校验)
        String contentType = file.getContentType();
        if (contentType == null || !contentType.startsWith("image/")) {
            return Result.failure("只能上传图片文件");
        }

        /* ================= 2️⃣ 生成文件名 ================= */

        // 原始文件名(例如 avatar.png)
        String originalFilename = file.getOriginalFilename();

        // 获取文件后缀(png / jpg / jpeg)
        String ext = StringUtils.getFilenameExtension(originalFilename);

        // UUID 生成新文件名(去掉 -,更简洁)
        String uuid = UUID.randomUUID().toString().replace("-", "");

        // 最终文件名:uuid.png
        String newFileName = uuid + (ext != null ? "." + ext : "");

        /* ================= 3️⃣ 按日期生成目录 ================= */

        // 日期目录:2026-01-28
        String dateDir = LocalDate.now().toString();

        // 项目运行目录/upload/avatar/2026-01-28/
        String basePath = System.getProperty("user.dir") + "/upload/avatar/" + dateDir + "/";

        File dir = new File(basePath);
        if (!dir.exists()) {
            dir.mkdirs();
        }

        /* ================= 4️⃣ 保存文件 ================= */

        File targetFile = new File(basePath + newFileName);
        file.transferTo(targetFile);

        /* ================= 5️⃣ 返回访问 URL ================= */

        // 对应你静态资源映射的访问路径
        // /static/avatar/2026-01-28/uuid.png
        String avatarUrl = "/static/avatar/" + dateDir + "/" + newFileName;

        return Result.success(avatarUrl);
    }
}

文件上传到云服务(以阿里云为例)

阿里云OSS-使用步骤

准备工作:1.注册登陆 2.充值 3.开通对象存储服务(OSS) 4.创建bucket 5.获取AccessKey(密钥)

根据阿里官方SDK操作

基础概念:

与本地不同,本地是把文件保存在本地磁盘,然后通过文件路径访问,管理是通过操作系统管理;对象存储(OSS)是通过bucket+objectKey,通过url来访问,管理方是云服务厂商。

名词 含义
Endpoint 服务器地址
Bucket 文件仓库
ObjectKey 文件路径 + 文件名
AccessKey 身份凭证(账号密码)
Object 实际文件

ENDPOINT:是对象存储服务的访问地址,例如https://oss-cn-beijing.aliyuncs.com

BUCKET_NAME:BUCKET是一个存储空间或者说叫容器,所有文件必须放在bucket里面,BUCKET_NAME在整个云厂商内部通常都是唯一的。

Objct(对象)和ObjectKey(对象路径):每上传的一个文件就是一个Object,而一个ObjectKey就是就是Object在Bucket里面的“完整路径+文件名”。

ACCESS_KEY_ID & ACCESS_KEY_SECRET(身份凭证):就是访问对象存储的账号+密码,决不能给前端

整个OSS大致流程如下

前端
  ↓(MultipartFile)
后端
  ↓(拿到文件)
  1. 生成 objectKey(UUID + 日期)
  2. 使用 AccessKey 连接 Endpoint
  3. 把文件放进 Bucket 的 objectKey 路径
  4. 得到一个访问 URL
  5. 返回 URL / 存数据库

最后保存好的url如下所示

https://my-avatar.oss.example.com/avatar/2026-01-29/uuid.png
https://oss.example.com/my-avatar/avatar/2026-01-29/uuid.png

本质url都是Endpoint + Bucket + ObjectKey

以下是代码示例

首先创建配置文件内容,来保存关键信息

aliyun:
  oss:
    # 你看到的 ENDPOINT:对象存储服务入口
    endpoint: https://oss-cn-hangzhou.aliyuncs.com
    # 你看到的 ACCESS_KEY_ID:访问凭证 id(相当于账号)
    access-key-id: testAccessKeyId123
    # 你看到的 ACCESS_KEY_SECRET:访问凭证 secret(相当于密码)
    access-key-secret: testAccessKeySecret456
    # 你看到的 BUCKET_NAME:桶(存储空间)
    bucket-name: my-avatar-bucket
    # 外网访问域名(教学用:一般是 bucket + endpoint 的域名形式,也可能是自定义域名/CDN)
    public-domain: https://my-avatar-bucket.oss-cn-hangzhou.aliyuncs.com

第二部,创建第一个工具类,该工具类的作用是使用UUID生成文件的objectName名,并且以日期的形式指定文件夹的路径比如2026-01-29这样的路径

package com.xxx.oss;

import org.springframework.util.StringUtils;

import java.time.LocalDate;
import java.util.UUID;

/**
 * 生成 OSS 对象名(ObjectName/ObjectKey)的工具类
 *
 * OSS 的 objectName 是“在 bucket 里保存文件的路径”,类似:
 * avatar/2026-01-29/uuid.png
 */
public class OssObjectNameUtil {

    private OssObjectNameUtil() {}

    /**
     * 生成 objectName:prefix/yyyy-MM-dd/uuid.ext
     *
     * @param originalFilename 原始文件名(用于获取后缀)
     * @param prefix           业务目录(例如 avatar)
     */
    public static String build(String originalFilename, String prefix) {

        // 日期目录:2026-01-29
        String dateDir = LocalDate.now().toString();

        // 文件后缀:png/jpg...
        String ext = StringUtils.getFilenameExtension(originalFilename);

        // UUID:防止重名
        String uuid = UUID.randomUUID().toString().replace("-", "");

        // 最终文件名:uuid.png
        String fileName = uuid + (ext != null ? "." + ext : "");

        // objectName:avatar/2026-01-29/uuid.png
        return prefix + "/" + dateDir + "/" + fileName;
    }
}

第二个工具类的作用就是把获取到的文件保存到自己的OSS中,并返回一个url用来访问等操作。该内容和具体平台强相关,所以需要参考官网的SDK代码来写,以下只是提供思路

package com.xxx.oss;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

import java.io.InputStream;

/**
 * 阿里云 OSS 上传工具(教学版:结构完全仿 SDK)
 *
 * 你后期真正接入阿里云 OSS 时:
 * 1. 只要把 pom 引入 aliyun-sdk-oss
 * 2. 取消下面“伪代码注释区”,改用真实 import
 * 3. application.yml 换成真实 endpoint / ak / sk / bucket
 * 就能跑通
 */
@Component
@ConfigurationProperties(prefix = "aliyun.oss")
public class AliyunOssUploadUtil {

    /**
     * ENDPOINT:OSS 服务入口
     * 示例:https://oss-cn-hangzhou.aliyuncs.com
     */
    private String endpoint;

    /**
     * ACCESS_KEY_ID:访问凭证 id
     */
    private String accessKeyId;

    /**
     * ACCESS_KEY_SECRET:访问凭证 secret
     */
    private String accessKeySecret;

    /**
     * BUCKET_NAME:桶名称
     */
    private String bucketName;

    /**
     * 对外访问域名(可能是 OSS 默认域名或自定义域名/CDN 域名)
     * 示例:https://my-bucket.oss-cn-hangzhou.aliyuncs.com
     */
    private String publicDomain;

    // ====== 生成 getter/setter(也可以用 Lombok @Data,这里为了笔记清晰写出来) ======
    public String getEndpoint() { return endpoint; }
    public void setEndpoint(String endpoint) { this.endpoint = endpoint; }
    public String getAccessKeyId() { return accessKeyId; }
    public void setAccessKeyId(String accessKeyId) { this.accessKeyId = accessKeyId; }
    public String getAccessKeySecret() { return accessKeySecret; }
    public void setAccessKeySecret(String accessKeySecret) { this.accessKeySecret = accessKeySecret; }
    public String getBucketName() { return bucketName; }
    public void setBucketName(String bucketName) { this.bucketName = bucketName; }
    public String getPublicDomain() { return publicDomain; }
    public void setPublicDomain(String publicDomain) { this.publicDomain = publicDomain; }

    /**
     * 上传到 OSS,并返回可访问 URL
     *
     * @param objectName  OSS 对象名,例如 avatar/2026-01-29/uuid.png
     * @param inputStream 文件输入流(来自 MultipartFile.getInputStream())
     * @return 文件访问 URL,例如 https://my-bucket.oss-cn-hangzhou.aliyuncs.com/avatar/2026-01-29/uuid.png
     */
    public String upload(String objectName, InputStream inputStream) {

        /*
         * ============================ 真实阿里云 OSS SDK 写法(你以后直接启用) ============================
         *
         * // 1) 创建 OSS 客户端
         * OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
         *
         * try {
         *     // 2) 上传对象到指定 Bucket 的 objectName 路径下
         *     //    putObject(bucketName, objectName, inputStream)
         *     ossClient.putObject(bucketName, objectName, inputStream);
         * } finally {
         *     // 3) 关闭客户端,释放资源
         *     ossClient.shutdown();
         * }
         *
         * ================================================================================================
         */

        // ============================ 教学“仿真”区(现在不接真实云也能跑) ============================
        // 你现在没有真实 OSS 依赖/账号,所以这里不执行真实上传,只把“流程结构”写出来。
        // 你未来接入真实 OSS 时,把上面注释块的代码放开即可。

        // (仿真)假装上传成功
        // ===============================================================================================

        // 4) 拼接访问 URL(常见方式:publicDomain + "/" + objectName)
        // 注意:objectName 已经包含目录,不需要再拼 bucket
        return publicDomain + "/" + objectName;
    }
}

Controller思路示例

package com.xxx.controller;

import com.xxx.common.result.Result;
import com.xxx.oss.AliyunOssUploadUtil;
import com.xxx.oss.OssObjectNameUtil;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

/**
 * 头像上传 Controller(OSS 流程教学版)
 *
 * 核心流程:
 * 1) 接收 MultipartFile
 * 2) 生成 objectName(UUID + 日期目录)
 * 3) 调用 AliyunOssUploadUtil.upload(...) 上传并得到 URL
 * 4) 返回 URL 给前端
 */
@RestController
@RequestMapping("/api/profile")
public class AvatarOssControllerV2 {

    private final AliyunOssUploadUtil aliyunOssUploadUtil;

    public AvatarOssControllerV2(AliyunOssUploadUtil aliyunOssUploadUtil) {
        this.aliyunOssUploadUtil = aliyunOssUploadUtil;
    }

    @PostMapping("/avatar/oss")
    public Result<String> uploadAvatarToOss(@RequestParam("file") MultipartFile file) throws Exception {

        // 1️⃣ 基础校验:文件是否为空
        if (file == null || file.isEmpty()) {
            return Result.failure("请选择要上传的头像文件");
        }

        // 2️⃣ 校验是否为图片(简单判断:contentType 以 image/ 开头)
        String contentType = file.getContentType();
        if (contentType == null || !contentType.startsWith("image/")) {
            return Result.failure("只能上传图片文件");
        }

        // 3️⃣ 生成 objectName(存储到 OSS 的路径)
        // 例如:avatar/2026-01-29/uuid.png
        String objectName = OssObjectNameUtil.build(file.getOriginalFilename(), "avatar");

        // 4️⃣ 上传到 OSS(教学版:内部结构仿阿里云 SDK)
        String url = aliyunOssUploadUtil.upload(objectName, file.getInputStream());

        // 5️⃣ 返回给前端:前端可以直接用 url 做头像展示
        return Result.success(url);
    }
}

注意:正常来说数据库保存的值是这图片的url或是这个图片的objectkey;ENDPOINTACCESS_KEY_IDSECRET_ACCESS_KEYBUCKET_NAME这四个值比较重要,类似数据库的url,username,userpassword,driver那四个值。

导入Redis和基础语法

首先导入reids依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

然后写一下配置文件

spring:
  redis:
    host: localhost
    port: 6379
    database: 0
    password:
    timeout: 5000ms

    lettuce:
      pool:
        max-active: 8      # 最大连接数(并发高就调大)
        max-idle: 8        # 最大空闲连接
        min-idle: 0        # 最小空闲连接
        max-wait: 1000ms   # 连接池耗尽时最大等待时间

基本语法

/**
* StringRedisTemplate:
* - key 和 value 都是 String
* - 底层已经帮你处理好了字符串序列化
*/
@Resource
private StringRedisTemplate stringRedisTemplate;

// 1. 要操作的 key 和 value(都是 String)
String key = "hello";
String value = "world";

// 2. 通过 opsForValue() 获取 ValueOperations
//    ValueOperations 专门用来操作 Redis 的 String 类型
stringRedisTemplate.opsForValue().set(key, value);

// 3. 再从 Redis 中把值取出来
String result = stringRedisTemplate.opsForValue().get(key);

// 4. 打印结果(正常应该输出:world)
System.out.println("从 Redis 中取出的值是:" + result);

如果要设置过期时间使用如下写法

// 设置 key,并指定 10 秒后过期
// 第三个参数为过期时间,第四个参数为过期时间单位
stringRedisTemplate.opsForValue()
            .set(key, value, 10, TimeUnit.SECONDS);

Redis令牌主动失效

Redis使用流程思路:

登录成功以后给浏览器响应令牌的同时,把该令牌存储到Redis中;

在登录拦截器中,需要验证浏览器所携带的拉皮,并同时获取Redis中存储的与之相同的令牌;

用户修改密码成功以后,删除Redis中存储的旧令牌。

代码实现思路:当用户登录成功以后,controller要把JWT存储在redis中,在登录拦截器这一块当获取了用户登录成功的token以后会把token存在redis中,在拦截器里面会检查token是否为null,若为null则抛异常返回401给前端。此外,在修改密码这一块,在用户更新完成密码以后首先删除原token,然后再把新的token给重新存redis里面。

1.首先创建一个service类,专门用来处理token存取redis相关的业务。

核心:存、取、删、过期

package com.example.auth;

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import java.time.Duration;

/**
 * 令牌存取核心:
 * 1) auth:token:{token} -> userId(用于拦截器校验 token 是否有效)
 * 2) auth:user:{userId} -> token(用于主动失效:踢掉旧 token)
 *
 * 说明:
 * - token key 用来验证“token 是否还被服务端承认”
 * - user key 用来找到“这个用户当前的 token”,方便删除旧的
 */
@Service
public class RedisTokenService {

    private static final String TOKEN_KEY_PREFIX = "auth:token:";
    private static final String USER_KEY_PREFIX  = "auth:user:";

    private final StringRedisTemplate redis;

    public RedisTokenService(StringRedisTemplate redis) {
        this.redis = redis;
    }

    /**
     * 登录/刷新时写入 Redis,并确保旧 token 失效(单端登录)
     *
     * @param userId 用户ID
     * @param token  新JWT
     * @param ttl    token 在 Redis 中的有效期(建议与 JWT 的 exp 保持一致)
     */
    public void storeToken(Long userId, String token, Duration ttl) {
        // 1) 如果该用户已有旧 token,先删除旧 token,让旧 token 立即失效
        String oldToken = redis.opsForValue().get(USER_KEY_PREFIX + userId);
        if (oldToken != null && !oldToken.isBlank()) {
            // 删除旧 token 对应的 tokenKey
            redis.delete(TOKEN_KEY_PREFIX + oldToken);
        }

        // 2) 写入 token -> userId,用于拦截器校验
        redis.opsForValue().set(TOKEN_KEY_PREFIX + token, String.valueOf(userId), ttl);

        // 3) 写入 userId -> token,用于后续主动失效(踢下线/改密/重新登录)
        redis.opsForValue().set(USER_KEY_PREFIX + userId, token, ttl);
    }

    /**
     * 校验 token 是否还有效:只要 Redis 里查得到,就认为有效
     * @return userId(查不到返回 null)
     */
    public Long getUserIdByToken(String token) {
        String v = redis.opsForValue().get(TOKEN_KEY_PREFIX + token);
        if (v == null || v.isBlank()) return null;
        return Long.valueOf(v);
    }

    /**
     * 主动失效:删除某个 token(用于退出登录等)
     */
    public void invalidateToken(String token) {
        if (token == null || token.isBlank()) return;
        redis.delete(TOKEN_KEY_PREFIX + token);
        // 注意:这里不删除 auth:user:{userId},因为没有 userId(需要的话走 invalidateUser)
    }

    /**
     * 主动失效:按用户ID踢下线(改密/封号/异地登录)
     */
    public void invalidateUser(Long userId) {
        String token = redis.opsForValue().get(USER_KEY_PREFIX + userId);
        if (token != null && !token.isBlank()) {
            redis.delete(TOKEN_KEY_PREFIX + token); // 删除 tokenKey -> token 立刻失效
        }
        redis.delete(USER_KEY_PREFIX + userId);     // 删除 userKey
    }
}

2. 在JWT的方法内要专门约定好过期时间,比如以2小时为例

3.登录controller,其中省略校验用户密码

package com.example.controller;

import com.example.auth.JwtUtil;
import com.example.auth.RedisTokenService;
import org.springframework.web.bind.annotation.*;

import java.time.Duration;

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    private final RedisTokenService tokenService;

    public AuthController(RedisTokenService tokenService) {
        this.tokenService = tokenService;
    }

    @PostMapping("/login")
    public LoginResp login(@RequestBody LoginReq req) {
        // 1) 校验用户名密码(这里省略,假设通过后拿到 userId)
        Long userId = 1001L; // TODO:替换成真实登录逻辑返回的 userId

        // 2) 生成 JWT
        String token = JwtUtil.generateToken(userId);

        // 3) 存 Redis(并删除旧 token,让旧 token 立即失效)
        // 约定2小时以后token失效
        Duration ttl = Duration.ofHours(2);
        tokenService.storeToken(userId, token, ttl);

        // 4) 返回 token 给前端
        // 这里可以使用专门约定好的返回类
        return new LoginResp(token);
    }

    public record LoginReq(String username, String password) {}
    public record LoginResp(String token) {}
}

4.拦截器:拿token去查询Redis,看这个token是否有效,无效就401

package com.example.interceptor;

import com.example.auth.RedisTokenService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.servlet.HandlerInterceptor;

/**
 * 拦截器逻辑:
 * - 从 Authorization 取 Bearer token
 * - 去 Redis 查 tokenKey 是否存在
 * - 不存在:说明 token 过期/被踢下线/主动失效 => 返回 401
 */
public class LoginInterceptor implements HandlerInterceptor {

    private final RedisTokenService tokenService;

    public LoginInterceptor(RedisTokenService tokenService) {
        this.tokenService = tokenService;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        // 从Header中Authorization参数获取token
        String token = request.getHeader("Authorization");

        // 1) token 为空:直接 401
        if (token == null) {
            throw new UnauthorizedException("Missing token");
        }

        // 2) 查 Redis:如果查不到,说明 token 已失效(过期/被删/被踢)
        Long userId = tokenService.getUserIdByToken(token);
        if (userId == null) {
            throw new UnauthorizedException("Token expired or invalidated");
        }

        // 3) 可选:把 userId 放进 request,后续 controller/service 使用
        request.setAttribute("userId", userId);

        return true;
    }
}

5.拦截器配置类中记得放行登录或改密码等相关页面

6.改密码成功后先删旧token,再存新token(旧token立即失效)

package com.example.controller;

import com.example.auth.JwtUtil;
import com.example.auth.RedisTokenService;
import org.springframework.web.bind.annotation.*;

import jakarta.servlet.http.HttpServletRequest;
import java.time.Duration;

@RestController
@RequestMapping("/api/user")
public class UserController {

    private final RedisTokenService tokenService;

    public UserController(RedisTokenService tokenService) {
        this.tokenService = tokenService;
    }

    @PostMapping("/password")
    public ChangePwdResp changePassword(@RequestBody ChangePwdReq req, HttpServletRequest request) {
        // 1) 从拦截器写入的 request attribute 取 userId
        Long userId = (Long) request.getAttribute("userId");

        // 2) 更新密码(这里省略:校验旧密码、写库)
        // TODO:update password in DB

        // 3) 先让旧 token 失效(踢下线)
        //    这一步会删除 auth:user:{userId} 以及对应的 auth:token:{oldToken}
        tokenService.invalidateUser(userId);

        // 4) 生成新 token,并写入 Redis
        String newToken = JwtUtil.generateToken(userId);
        // 设定token过期时间为2小时
        Duration ttl = Duration.ofHours(2);
        tokenService.storeToken(userId, newToken, ttl);

        // 5) 返回新 token
        return new ChangePwdResp(newToken);
    }

    public record ChangePwdReq(String oldPassword, String newPassword) {}
    public record ChangePwdResp(String token) {}
}

注意

在上面的案例中,设定了两种key值

auth:token:{token} -> userId
auth:user:{userId} -> token
这两个key值互相为反向索引,也就是说拿到了token可以检索到userId,反过来,拿到了userId也可以反过来检索token

若只有auth:token:{token} -> userId,那么在修改密码以后,生成新的token就找不到哪个token是之前用的token,并且也难把token失效,redis也不支持反向查询key值。

所以设定了auth:user:{userId} -> token来进行双向查询

token  -> userId   (校验用)
userId -> token    (失效用)

总结一下流程,登录成功以后分别在两个key中写入。请求过程中拦截器查询token,查的到token正常放行,查不到token就拦截(过期 / 被踢 / 主动失效)。

修改密码时,首先利用userId查询得到oldToken,然后又反过来使用oldToken查询并执行删除操作,并用userId查询把oldToken也删除了。修改密码大致流程示例如下:

String oldToken = redis.get("auth:user:" + userId);
redis.delete("auth:token:" + oldToken);
redis.delete("auth:user:" + userId);

SpringBoot项目部署

首先把项目打包成jar包

然后上传到服务器上,并运行如下代码

java -jar jar包的位置

jar包部署,要求必须有jre环境

属性配置

1.命令行参数的形式

java -jar app.jar --server.port=8081

等价于在yml配置文件中写,如下代码,并且会覆盖yml代码

server:
  port: 8081

2.环境变量的方式

在环境变量参数中增加server.port参数来修改

3.外部配置文件方式

Jar包所在目录下的yml或是properties文件中配置

4.内部配置文件方式

项目中resources目录下的yml或properties文件

命令行参数
  ↑
系统环境变量
  ↑
外部配置文件
  ↑
内部配置文件

多环境开发

实战中有多个场景,开发场景、测试场景、生产场景。每个场景需要用到不同的配置

在资源目录下建议放多个配置文件

application.yml (通用)

application-dev.yml (开发)

application-test.yml (测试)

application-prod.yml (生产)

启动生产环境

java -jar app.jar --spring.profiles.active=dev

启动测试环境

java -jar app.jar --spring.profiles.active=test

启动生产环境

java -jar app.jar --spring.profiles.active=prod

注意:假设执行了java -jar app.jar --spring.profiles.active=dev,那么只会执行application.yml和application-dev.yml,然后另外两个test和prop则直接不会使用。

实战建议以环境变量的方式来使用分组,因为使用命令行可能会泄露一些程序的信息。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐