SpringBoot学习记录,一个小项目实战

1、什么是Spring boot

Spring Boot 是一个基于 Spring 的快速开发框架,它通过"自动配置"和"约定优于配置"的思想,把原本复杂的 Spring 项目简化成开箱即用的形式(比如内置服务器、自动依赖管理),让你几乎不用写大量 XML 或繁琐配置,就能快速搭建并运行一个后端应用(如 Web 服务或接口)。

"起步依赖(Starter )"就是:把某一类常用功能需要的所有依赖提前帮你打包配置好,你只需要引入一个依赖即可。

xml 复制代码
<!--        这个是spring web的起步依赖,版本由parent父工程或者dependencymanager决定-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

自动配置:遵循约定大约配置的原则,在boot程序启动后,一些bean对象会自动注入到ioc容器,不需要手动声明,简化开发

什么是微服务

微服务(Microservices)是一种软件架构风格,它把一个大型应用拆分成多个小而独立的服务,每个服务只负责单一业务功能(比如用户、订单、支付),并通过接口(通常是 HTTP API)相互通信;这样做的好处是各个服务可以独立开发、部署和扩展,提高系统的灵活性和可维护性,但也会带来服务间通信、部署和运维复杂度增加的问题。

2、第一个SpringBoot项目

1、快速上手

你可以使用下面这个网站快速构建一个springboot项目

start.spring.io/

idea也内置了这个功能,你可以把服务改为 start.aliyun.com ,这是阿里针对国内的优化版

直接启动启动类就运行起来了,如果失败考虑你有没有添加web-starter依赖,不要指定version,让spring自己选择,否则可能出各类奇怪问题

建一个控制类,控制类等的包必须和启动类同级别,否则会无法注册,因为springboot会默认扫描其启动类所在的包及其子包

使用下面的操作可以通过jar包运行程序

2、配置

1、properties和pom.xml

properties 复制代码
# 端口
server.port=8081

# 项目名
spring.application.name=demo

# 数据库
spring.datasource.url=jdbc:mysql://localhost:3306/test
spring.datasource.username=root
spring.datasource.password=123456

# 日志
logging.level.root=INFO
logging.level.com.zhang=DEBUG

很多操作不可计数,记住最常用的就行

  • 在资源目录下新建一个banner.txt,可以为控制台修改启动画

你可以在这个网站找到很多ascii码艺术画为编码添加情趣 www.bootschool.net/ascii

markdown 复制代码
 ████        █████        ████████     █████ █████  
░░███      ███░░░███     ███░░░░███   ░░███ ░░███   
 ░███     ███   ░░███   ░░░    ░███    ░███  ░███ █ 
 ░███    ░███    ░███      ███████     ░███████████ 
 ░███    ░███    ░███     ███░░░░      ░░░░░░░███░█ 
 ░███    ░░███   ███     ███      █          ░███░  
 █████    ░░░█████░     ░██████████          █████  
░░░░░       ░░░░░░      ░░░░░░░░░░          ░░░░░   
                                                    
                         大吉大利            永无BUG  

2、原理:Spring Boot 的自动配置就像"点外卖套餐"

你只需要在项目xml里引入一个 starter(比如 web),就相当于点了一份"套餐"。

Spring Boot 在启动时,会去一个"菜单清单"(以前是 spring.factories,现在是自动配置类列表,点import文件)里找:这个套餐都需要哪些东西(比如 Tomcat、Spring MVC、JSON 处理等)。

然后它会判断当前环境是否满足条件(比如有没有相关依赖、有没有你自己定义的 Bean),如果满足,就自动帮你把这些组件(Bean)全部创建好并放进容器里。

最终效果就是:

你不用自己写一堆配置,Spring Boot 已经根据你选的"套餐"帮你全配好了
引入 starter → 启动扫描 → 条件判断 → 自动装配 → 可直接使用

  • Spring Boot 自动配置基于 @EnableAutoConfiguration 实现。

  • 启动时会通过 AutoConfigurationImportSelector

  • 读取 META-INF 下的自动配置类,

  • 然后根据 @Conditional 系列条件注解,

  • 按需将满足条件的 Bean 注册到 IOC 容器中。

  • 这样开发者只需要引入 starter,

  • 很多配置就能自动完成。

3、如何自定义starter?

在实际开发中,经常会定义一些公共组件,提供给各个项目团队使用。而在SpringBoot的项目中,一般会将这些公共组件封装为SpringBoot的starter。

我们尝试实现自己的mybatis起步依赖的功能,也就需要上面的两个模块

1、自动配置依赖

这个是MyBatis 的自动配置模块。应当自动创建 SqlSessionFactory这样的Bean,自动帮你配置 MyBatis

  • autoconfigure 模块需要"使用那些技术的类",所以必须导入对应依赖。
xml 复制代码
<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter</artifactId>
   <version>4.0.6</version>
</dependency>
<!-- Source: https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-jdbc -->
<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-jdbc</artifactId>
   <version>4.0.6</version>
   <scope>compile</scope>
</dependency>
<dependency>
   <groupId>org.mybatis</groupId>
   <artifactId>mybatis</artifactId>
   <version>3.5.19</version>
</dependency>
<dependency>
   <groupId>org.mybatis</groupId>
   <artifactId>mybatis-spring</artifactId>
   <version>4.0.0</version>
</dependency>
<dependency>
   <groupId>junit</groupId>
   <artifactId>junit</artifactId>
   <version>3.8.1</version>
   <scope>test</scope>
</dependency>
2、编写自动配置
java 复制代码
package com.zhang.config;

import org.apache.ibatis.annotations.Mapper;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.mapper.MapperScannerConfigurer;
import org.springframework.beans.factory.BeanFactory;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.AutoConfigurationPackages;
import org.springframework.context.annotation.Bean;

import javax.sql.DataSource;
import java.util.List;

@AutoConfiguration//告诉 Spring Boot:"这个类可以参与自动配置"
public class MyBatisAutoConfig {
    @Bean //"把这个方法返回的对象交给 Spring 管理"。
    public SqlSessionFactoryBean sqlSessionFactoryBean(DataSource dataSource) {
//        datasource是spring-boot-starter-jdbc依赖自动注入的
        SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
        sqlSessionFactoryBean.setDataSource(dataSource);
        return sqlSessionFactoryBean;
    }
    @Bean
    public MapperScannerConfigurer mapperScannerConfigurer(BeanFactory beanFactory) {
//        Spring 容器自己创建 BeanFactory而来
        MapperScannerConfigurer mapperScannerConfigurer = new MapperScannerConfigurer();
//        获取 Spring Boot "自动配置基础包"。也就是:@SpringBootApplication 所在包 以及它的子包。
        List<String> packages = AutoConfigurationPackages.get(beanFactory);
//        get(0)作用package com.zhang得到com.zhang
        String p = packages.get(0);
//        "去扫描 com.zhang 包下的 Mapper 接口"
        mapperScannerConfigurer.setBasePackage(p);
//          解决的是:"扫到什么算 Mapper?"(筛选条件)
        mapperScannerConfigurer.setAnnotationClass(Mapper.class);
        return mapperScannerConfigurer;

    }
}

告诉 Spring Boot:

"有哪些自动配置类需要加载",所以要编写一个AutoConfiguration.imports 文件

3、编写starter模块
  • 在xml中添加自动配置的依赖

starter 中引入 autoconfigure,

是因为 starter 本身只负责依赖聚合,

真正的自动配置逻辑写在 autoconfigure 模块中。 用户只需要引 starter,

就会间接引入 autoconfigure,

Spring Boot 才能自动加载配置类,实现自动装配。

两个模块,自动配置只需要配置和imports,starter只需要xml

4、测试

上述步骤就完成了starter编写,我们代替官方的mybatisstarter依赖试试

4、yml配置

SpringBoot使用一个全局的配置文件,配置文件名称是固定的

  • application.properties

    • 语法结构:key=value
  • application.yml

    • 语法结构:key:空格 value

配置文件的作用:修改SpringBoot自动配置的默认值,因为SpringBoot在底层都给我们自动配置好了;

YAML是 "YAML Ain't a Markup Language"(YAML不是一种置标语言)的递归缩写。

在开发的这种语言时,YAML 的意思其实是:"Yet Another Markup Language"(仍是一种置标语言)

1、标记语言

以前的配置文件,大多数都是使用xml来配置;比如一个简单的端口配置,我们来对比下yaml和xml

yaml配置: ​

yaml 复制代码
server:
  prot: 8080

xml配置: ​

xml 复制代码
<server>
  <port>8081<port>
</server>

在springboot中更推荐使用yaml配置,因为它对比properties和xml更加简洁清爽,更适合表达"有层级的配置",写得更少、更直观,所以在 Spring Boot 里更推荐,两个配置可以同时生效

yml 复制代码
# k=v

# 普通的key-value
name: qinjiang

# 对象
student:
  name: qinjiang
  age: 3

#行内写法
student: {name: qinjiang,age: 3}

# 数组
pets:
  - cat
  - dog
  - pig

pets: [cat,dog,pig]
2、用yaml注入实体类

一般java中我们可以new给对象赋值,spring中我们知道可以通过- @value("${?}") - 和自动注入给字段和对象赋值,在springboot中又多了可以用yaml配置文件注入对象,如下图

  • 配置yml和配置properties都可以获取到值 , 强烈推荐 yml

  • 如果我们在某个业务中,只需要获取配置文件中的某个值,可以使用一下 @value("${?.?}")

  • 如果说,我们专门编写了一个JavaBean来和配置文件进行映射,就直接使用

    @configurationProperties,不要犹豫!

3、jsr303校验

在下面这个图里使用了@Validated 数据校验,可以看到你要是在yaml配置或者其他的什么,为这个字段添加了不为邮箱格式的数据,就会爆红并返回message

分类 注解 示例 作用
非空 @NotNull @NotNull 不能为 null
非空字符串 @NotBlank @NotBlank 不能为 null 且去空格后不为空
非空集合 @NotEmpty @NotEmpty 不能为 null 且长度 > 0
数值范围 @Min @Min(18) 最小值
数值范围 @Max @Max(60) 最大值
精确范围 @DecimalMin @DecimalMin("0.1") 小数最小值
精确范围 @DecimalMax @DecimalMax("100.0") 小数最大值
正数/负数 @Positive @Positive 必须 > 0
非负数 @PositiveOrZero @PositiveOrZero ≥ 0
长度 @Size @Size(min=3,max=10) 字符串/集合长度
格式 @Email @Email 邮箱格式
正则 @Pattern @Pattern(regexp="^1[3-9]\d{9}$") 自定义规则(手机号等)
时间 @Past @Past 必须是过去时间
时间 @Future @Future 必须是未来时间
4、多环境配置
  • 现代springboot开发时,如果有多个不一样的配置文件,怎么方便的切换环境?
yml 复制代码
application.yml            ← 默认配置
application-dev.yml        ← 开发环境
application-test.yml       ← 测试环境
application-prod.yml       ← 生产环境
  • 在默认的配置中指定我们要使用哪个环境
yml 复制代码
spring:
  profiles:
    active: dev
  • 指定之后就只有它生效
  • 还有把多个环境写到一个yml文件的办法
yml 复制代码
spring:
  profiles:
    active: dev

---
spring:
  config:
    activate:
      on-profile: dev
server:
  port: 8081

---
spring:
  config:
    activate:
      on-profile: prod
server:
  port: 8080

可以用"一个 yml"练习 , 但最好尽早习惯多文件结构

3、手动创建SpringBoot应用

如果在没有各种快速构建工具,手动应该怎么做呢?

1、新建一个maven项目

使用quickstart骨架就行

2、引入相关的依赖

springboot父依赖

xml 复制代码
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>4.0.6</version>
</parent>

或者用管理依赖

xml 复制代码
<dependencyManagement>
    <dependencies>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-dependencies</artifactId>
            <version>3.4.6</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>

    </dependencies>
</dependencyManagement>

最后导入你想要的依赖,比如web开发

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

3、创建启动类与配置文件

启动类名字一般是项目名加Application

java 复制代码
@SpringBootApplication
public class SpringCreateManualApplication
{
    public static void main( String[] args )
    {

        SpringApplication.run(SpringCreateManualApplication.class, args);
    }
}

4、整合MyBatis

1、导入相关依赖与配置

xml 复制代码
<!--        mybatis起步依赖-->
        <!-- Source: https://mvnrepository.com/artifact/org.mybatis.spring.boot/mybatis-spring-boot-starter -->
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>4.0.1</version>
        </dependency>
<!--        mysql驱动依赖-->
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <version>9.6.0</version>
        </dependency>
yml 复制代码
spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/mybatis
    username: root
    password: 123456

2、编写相关业务逻辑

java 复制代码
@Mapper
public interface UserMapper {
    @Select("select * from mybatis.user where id = #{id}")
    User getUserById(int id);
}
java 复制代码
public interface UserService {
    User getUserById(int id);
}
java 复制代码
@Service
public class UserServiceImpl implements UserService {
    @Autowired
    private UserMapper userMapper;
    @Override
    public User getUserById(int id) {
        return userMapper.getUserById(id);
    }
}
java 复制代码
@RestController
public class UserController {
    @Autowired
    private UserServiceImpl userService;
    @GetMapping("/findById")
    public User findById(Integer id) {
        return userService.getUserById(id);
    }
}

3、springboot wed开发

1、静态资源

在springboot项目结构中,js,html等静态资源有三个可供你选择的位置,

还有一个供给依赖包classpath:/META-INF/resources/

js 复制代码
src/main/resources/
├── static/          ✅ 最常用
├── public/          ✅ 也可以用
├── resources/       ✅ 很少用(但存在)

90% 项目:只用 static/ 放一切前端资源;public/ 放极少数站点根文件;resources/ 基本不用

在以上三个目录任选建一个index.html就是默认页,最推荐做法只用:static/index.html

2、模板引擎Thymeleaf

1、Thymeleaf是什么

  • 模板引擎和前后端分离是两种不同的页面生成方式:
  • 模板引擎属于"后端渲染",由 Spring Boot 等后端把数据填进 HTML 模板(如 Thymeleaf)直接生成完整页面返回;
  • 而前后端分离是"前端渲染",后端只提供 JSON 数据接口,前端用 Vue.js 或 React 在浏览器中把数据渲染成页面。两者本质区别在于"页面由谁生成"(后端 vs 前端),实际项目通常二选一为主,但也可以在不同模块中混合使用。
  • Thymeleaf 和 JSP 做的是同一类事,但 Thymeleaf 是"现代版、更规范、更好维护"的替代方案
xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

使用模板引擎时在资源文件夹新建templates目录,给模板引擎放动态页面

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<!--src/main/resources/templates/test.html-->
<p th:text="${msg}">test</p>

<!--不转译text,相当于传过来标签会被变html的标签-->
<p th:utext="${msg}">test2</p>

<!--接受集合,循环数据给array,再text转译这个array,你也可以像jsp,vue那样取数据-->
<li th:each="array:${arrays}" th:text="${array}">array</li>
<!--<li th:each="array:${arrays}" >[[${array}]]</li>-->

</body>
</html>
java 复制代码
@GetMapping("/hello")
public String hello(Model model) {
    model.addAttribute("msg", "<h1>Hello World</h1>");

    model.addAttribute("arrays", Arrays.asList("a", "b", "c"));
    return "test";
}
表达式 作用
${} 访问普通变量
*{} 访问表单对象属性
@{} 生成 URL 路径
#{} 读取国际化(配置文件)
~{} 模板片段引用(布局/复用)

2、Thymeleaf标签如何用

Thymeleaf 不是"新标签",而是给 HTML 标签加 th:* 属性

  • 文本
bash 复制代码
<p th:text="${msg}"></p>

给 <p> 标签加属性


  • 链接
css 复制代码
<a th:href="@{/login}">登录</a>

  • 循环
bash 复制代码
<li th:each="user : ${users}"></li>

  • 条件
bash 复制代码
<div th:if="${isLogin}"></div>

4、实战

1、环境搭建

yml 复制代码
spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/big_event?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false
    username: root
    password: 123456

2、注册接口

1、定义相关的实体类和对应的结果类

这个 Result<T> 本质上是:

一个"统一返回结果类"

也就是后端接口不管成功还是失败,都返回统一格式给前端。

java 复制代码
public class Result<T> {
    private Integer code;//业务状态码 0-成功 1-失败
    private String message;//提示信息
    private T data;//响应数据

    public Result() {
    }

    public Result(Integer code, String message, T data) {
        this.code = code;
        this.message = message;
        this.data = data;
    }

    //快速返回操作成功响应结果(带响应数据)
    //public static <泛型声明> 返回值类型 方法名(参数)
    public static <E> Result<E> success(E data) {
        return new Result<>(0, "操作成功", data);
        }

    //快速返回操作成功响应结果
    public static Result success() { return new Result(0, "操作成功", null); }

    public static Result error(String message) { return new Result(1, message, null); }

2、定义注册业务相关逻辑接口

java 复制代码
@RestController
@RequestMapping("/user")
public class UserController {
    @Autowired
    private UserServiceImpl userService;

    @PostMapping("/register")
    public Result register(String username, String password) {
        User u = userService.findByUSerName(username);
        if (u != null) {//用户名存在
            return Result.error("用户名已存在");
        }
        else {
            String md= MD5Util.md5(password);//先加密,把加密数据存入数据库
            userService.register(username, md);
            return Result.success();

        }

    }
}
java 复制代码
@Service
public class UserServiceImpl implements UserService {
    @Autowired
    private UserMapper userMapper;

    @Override
    public User findByUSerName(String userName) {
        return userMapper.findUserByName(userName);
    }

    @Override
    public Boolean register(String username, String password) {
        return userMapper.register(username, password);
    }
}
java 复制代码
@Mapper
public interface UserMapper {
    @Select("select * from big_event.user where username=#{username}")
    User findUserByName(String userName) ;
    @Insert("insert into big_event.user(username,password,create_time,update_time) " +
            "values (#{username},#{password},now(),now())")
    Boolean register(String username, String password);
}

其中的MD5加密工具类如下,不过由于md5如今没有加盐,已经不再安全,实际密码存储不建议用

java 复制代码
package com.zhang.bigevent.utils;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;

public class MD5Util {

    /**
     * 32位 MD5 加密(小写)
     * 16位和32位 MD5,本质上其实是同一个东西。仅仅在展示上有一点区别
     * md5在现在的密码存储已经不再安全,简单的密码撞库就可以破解,BCrypt等是更好的方法
     */
    public static String md5(String text) {
        try {
            MessageDigest md = MessageDigest.getInstance("MD5");

            byte[] bytes = md.digest(text.getBytes(StandardCharsets.UTF_8));

            StringBuilder sb = new StringBuilder();

            for (byte b : bytes) {
                String hex = Integer.toHexString(b & 0xff);

                if (hex.length() == 1) {
                    sb.append("0");
                }

                sb.append(hex);
            }

            return sb.toString();

        } catch (Exception e) {
            throw new RuntimeException("MD5 加密失败", e);
        }
    }

    /**
     * 16位 MD5
     */
    public static String md5_16(String text) {
        return md5(text).substring(8, 24);
    }


}

3、对数据进行校验--Spring Validation的使用

  • 在上面的案例里,我们没有对用户名密码进行任何长度或者字符上的约束,这显然不符合实际业务,于是我们需要对数据进行校验

Spring Validation 是Spring 提供的一个参数校验框架,使用预定义的注解完成参数校验

  1. 引入Spring Validation 起步依赖

  2. 在参数前面添加@Pattern注解

    使用正则表达式限制参数格式,例如限制用户名必须是 5~16 位字母数字。

java 复制代码
@Pattern(regexp = "^[a-zA-Z0-9_]{5,16}$")
String username
  1. 在Controller类上添加@Validated注解

    开启 Spring 的参数校验功能,让 @Pattern、@NotNull 等校验注解真正生效。

java 复制代码
@RestController
@RequestMapping("/user")
@Validated
public class UserController {
    @Autowired
    private UserServiceImpl userService;

    @PostMapping("/register")
    public Result register(@Pattern(regexp = "^\S{5,16}$") String username,@Pattern(regexp = "^\S{5,16}$") String password) {
        User u = userService.findByUSerName(username);
        if (u != null) {//用户名存在
            return Result.error("用户名已存在");
        }
        else {
            String md= MD5Util.md5(password);//先加密,把加密数据存入数据库
            userService.register(username, md);
            return Result.success();

        }

    }
}

这样我们的用户名与密码就被约束了,但是出现不符合要求的数据时会抛出一个500错误,这个500放回到前端不符合我们统一格式的要求,因此需要定义一个全局异常处理器

全局异常处理器

全局异常处理器(Global Exception Handler):

本质上是:

统一处理项目中出现的异常,SpringBoot 用来"统一接管项目报错"的机制

避免:

  • 到处写 try-catch
  • 报错信息混乱
  • 直接返回 500 白页
  • 前后端返回格式不统一
java 复制代码
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
//声明这是一个全局异常处理类,监听整个 SpringBoot 项目中的异常,返回 JSON 数据,不返回页面
@RestControllerAdvice
public class GlobalExceptionHandler {
//    指定当前方法处理哪种异常,Exception.class表示所有异常
    @ExceptionHandler(Exception.class)
    public Result handleException(Exception e) {
//        打印异常详细信息,方便我们调试
        e.printStackTrace();
//        如果异常有信息,返回异常信息;否则,返回"操作失败"
        return Result.error(StringUtils.hasLength(e.getMessage()) ? e.getMessage() : "操作失败");
    }

}

3、登录接口

1、Controller业务

java 复制代码
@PostMapping("/login")
public Result login(
        @Pattern(regexp = "^\S{5,16}$") String username,
        @Pattern(regexp = "^\S{5,16}$") String password) {
    User u = userService.findByUSerName(username);
    if (u == null) {
        return Result.error("该用户不存在");
    }
    //由于数据库存储的是加密后的密码,所以我们要把输入的密码加密再与数据库比对
    if (MD5Util.md5(password).equals(u.getPassword())) {
        return Result.success("jwt 令牌");//后面再说这是什么
    }
    return Result.error("密码错误");
}

2、登录认证---令牌

在只存在上面的业务逻辑时,一个没有登陆的人也可以访问一些内部接口,因此需要认证

拦截器是一个办法,现在这里介绍另一种办法---令牌

  • Token 就是:服务器发给客户端的"登录凭证"以后客户端拿着它:证明"我已经登录过了"
  • JWT,全称:JSON Web Token (jwt.io/),定义了一种简洁的、自包含的格式,用于通信双方以json数据格式安全的传输信息。
xml 复制代码
<!-- Source: https://mvnrepository.com/artifact/com.auth0/java-jwt -->
<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>4.5.0</version>
    <scope>compile</scope>
</dependency>
  1. 组成

    • Header(头), 记录令牌类型和签名算法等
    • PayLoad(荷载),携带自定义的信息
    • Signature(签名),对头部和荷载进行加密计算得来
  2. 使用

    • 引入java-jwt坐标
    • 调用API生成和校验令牌
    • 解析令牌抛出异常,就证明令牌被篡改或者过期了
java 复制代码
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;

import java.util.Date;
import java.util.Map;

public class JwtUtil {
//定义 JWT 签名密钥。作用:生成 token 时加密,验证 token 时解密,必须一致。否则:token 无法验证
    private static final String KEY = "zhang";
    //接收业务数据,生成token并返回,claims就是你要放进 token 的用户数据。
    public static String genToken(Map<String, Object> claims) {
        return JWT.create()//创建 token
                .withClaim("claims", claims)//向 token 中放数据。
//                设置 token 过期时间。整体意思:当前时间 + 12小时,token 十二小时后失效。
                .withExpiresAt(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 12))
//                使用:HMAC256算法。用:KEY = "zhang"进行签名。
                .sign(Algorithm.HMAC256(KEY));
    }

    //接收token,验证token,并返回业务数据
    public static Map<String, Object> parseToken(String token) {
        return JWT.require(Algorithm.HMAC256(KEY))//要求 必须使用同一个 KEY 验证签名。
                .build()//创建 verifier(验证器)。
                .verify(token)//真正开始验证。
                .getClaim("claims")//获取 claims
                .asMap();//转为 Map
    }
}
1、在登陆后生成token
java 复制代码
@PostMapping("/login")
public Result login(
        @Pattern(regexp = "^\S{5,16}$") String username,
        @Pattern(regexp = "^\S{5,16}$") String password) {
    User u = userService.findByUSerName(username);
    if (u == null) {
        return Result.error("该用户不存在");
    }
    //由于数据库存储的是加密后的密码,所以我们要把输入的密码加密再与数据库比对
    if (MD5Util.md5(password).equals(u.getPassword())) {
        HashMap<String, Object> claims = new HashMap<>();//map来存储数据
        claims.put("id", u.getId());
        claims.put("username", username);
        String token = JwtUtil.genToken(claims);//工具类得到token
        return Result.success(token);//封装到result返回前端
    }
    return Result.error("密码错误");
}
2、在需要验证的功能添加验证

由于还没写文章的功能,下面的是模拟token验证的情况

java 复制代码
@RestController
@RequestMapping("/article")
public class ArticleController {
    @GetMapping("/list")
//    因为登录后的用户会把token一直存在请求头中,因此我们要从请求头中去取
    public Result getArticles(@RequestHeader(name = "Authorization") String token
    , HttpServletResponse resp) {
        try {
            Map<String, Object> claims = JwtUtil.parseToken(token);
            return Result.success("所有的文章数据");//如果上面验证没没抛出错误,说明已经登录,执行业务
        } catch (Exception e) {
           resp.setStatus(401);//执行到这里说明未登录,故而设定错误状态码,返回错误信息
            return Result.error("未登录");
        }

    }
}
3、使用拦截器简化我们的代码

假使我们的程序有大量的controller,一个个解析token太麻烦。于是拦截器可以实现简化

java 复制代码
@Component
public class LoginInterceptors implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String token = request.getHeader("Authorization");//得到令牌
        try {
            JwtUtil.parseToken(token);//验证令牌
            return true;//放行
        } catch (Exception e) {
            response.setStatus(401);
            return false;//阻止
        }
    }
}

在高并发场景下不建议用catch,因为异常的开销比if判断大,所以用if判断token是不是null更节省性能,这是GPT老师说的,供参考

java 复制代码
// 1. 获取请求头中的 token  
String token = request.getHeader("token");  
  
// 2. 判断 token 是否存在  
if(token == null || token.isEmpty()){  
response.setStatus(401);  
return false;  
}  
  
// 3. 验证 token  
try{  
JwtUtil.parseToken(token);  
}catch (Exception e){  
response.setStatus(401);  
return false;  
}  
  
// 4. 放行  
return true;

注册拦截器

java 复制代码
package com.zhang.bigevent.config;

import com.zhang.bigevent.interceptors.LoginInterceptors;
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;
@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Autowired
    private LoginInterceptors loginInterceptors;
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
//        除了登录注册,其他的都验证token
        registry.addInterceptor(loginInterceptors)
                .excludePathPatterns("/user/login","/user/register");
    }
}

现在我们的controller代码可以专注它自己的事情了

java 复制代码
    @GetMapping("/list")
//    因为登录后的用户会把token一直存在请求头中,因此我们要从请求头中去取
    public Result getArticles( ) {
//        try {
//            Map<String, Object> claims = JwtUtil.parseToken(token);
//            return Result.success("所有的文章数据");//如果上面验证没没抛出错误,说明已经登录,执行业务
//        } catch (Exception e) {
//           resp.setStatus(401);//执行到这里说明未登录,故而设定错误状态码,返回错误信息
//            return Result.error("未登录");
//        }
        return Result.success("所有的文章数据");
    }

4、获取用户详细信息

因为token存了用户名,所以可以通过用户名得到user

java 复制代码
@GetMapping("/userinfo")
public Result<User> UserInfo(@RequestHeader(name = "Authorization") String token) {
    Map<String, Object> claims = JwtUtil.parseToken(token);
    String username = (String) claims.get("username");
    User user = userService.findByUSerName(username);
    return Result.success(user);
}

在测试时需要在postman的headers添加Authorization。为了方便你可以在接口统一配置,这样所有方法都能得到token

然而你这么做返回的user数据会把密码这种数据也一起返回。虽然是加密后的,但也是不安全,故而做出以下改进

java 复制代码
public class User {

    private Integer id;
    private String username;
    @JsonIgnore//这样实体类作为json数据返回前端时,会忽略password
    private String password;
    private String nickname;
    private String email;
驼峰下划线问题

我们可以看到createTime这种字段不会为空,为什么显示为null呢? 因为数据库命名是下划线命名,而实体类是驼峰,这时我们要在配置文件添加camel

yml 复制代码
mybatis:
  configuration:
    map-underscore-to-camel-case: true

ThreadLocal优化数据存取

ThreadLocal 可以理解成"线程自己的变量盒子"。

每个线程都能往里面存数据,但只能拿到自己存的那份,别的线程看不到,因此不会互相干扰。它常用于保存当前登录用户、数据库连接等"当前线程专属的数据"。 提供线程局部变量

  • 用来存取数据: set()/get()
  • 使用ThreadLocal存储的数据, 线程安全

下面这个new线程的代码可以这么理解:"创建一个叫线程1的新员工,然后 start() 让他开始干活,干的内容就是 {} 里的代码。"

上图看出它确实实现了线程隔离的效果

  • 很多业务都需要获取当前登录用户的信息。为了避免在 Controller、Service、DAO 之间层层传递用户 id,
  • 可以在拦截器中解析 token,将用户信息存入 ThreadLocal,后续业务代码直接通过 get() 获取当前线程中的用户数据,
  • 从而简化参数传递。请求结束后再调用 remove() 清理数据,防止线程复用导致数据污染。

为此新建一个工具类

java 复制代码
package com.zhang.bigevent.utils;

public class ThreadLocalUtil {
    //提供全局唯一ThreadLocal对象,
    private static final ThreadLocal THREAD_LOCAL = new ThreadLocal();

    //根据键获取值
    public static <T> T get(){
        return (T) THREAD_LOCAL.get();
    }

    //存储键值对
    public static void set(Object value) { THREAD_LOCAL.set(value); }

    //清除ThreadLocal 防止内存泄漏
    public static void remove() { THREAD_LOCAL.remove(); }
}

在拦截器里配置Threadlocal

java 复制代码
@Component
public class LoginInterceptors implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String token = request.getHeader("Authorization");//得到令牌
        try {
            Map<String, Object> claims = JwtUtil.parseToken(token);//验证令牌
            ThreadLocalUtil.set(claims);
            return true;//放行
        } catch (Exception e) {
            System.out.println("拦截了");
            response.setStatus(401);
            return false;//阻止
        }
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception {
        ThreadLocalUtil.remove();
    }
}

为什么清除threadlocal的数据要在拦截器的after方法里?

是因为每次拦截器都会在开始方法里put数据,故而每个请求都会把这个数据又装一遍,所以在after里清除不会影响下一次的使用

java 复制代码
@GetMapping("/userinfo")
public Result<User> UserInfo() {
    Map<String,Object> map = ThreadLocalUtil.get();//得到线程存的map数据
    String username = (String) map.get("username");//得到username
    User user = userService.findByUSerName(username);
    return Result.success(user);
}

5、更新用户基本信息

1、基本信息修改

java 复制代码
@PutMapping("/update")//@PutMapping 可以理解成:专门对应 HTTP PUT 请求的映射注解,也就是更新
public Result update(@RequestBody User user){//RequestBody可以要求前端把零散的字段封装成对象
    userService.update(user);
    return Result.success();
}
java 复制代码
@Override
public Integer update(User user) {
    user.setUpdateTime(LocalDateTime.now());//设定修改时间,每次操作必须改的
    return userMapper.update(user);
}
java 复制代码
@Update("update big_event.user set nickname=#{username},email=#{email}," +
        "update_time=#{updateTime} where id=#{id}")
Integer update(User user);
对实体类字段进行校验

在上面的代码里没有对nickname,email的格式进行约束,现在要解决这个问题,

相对比,Spring Framework 里的 Spring Validation(参见4.2.3) 本质上就是:

利用 @NotNull、@Size、@Email 这类校验注解,自动帮你做参数校验。

java 复制代码
public class User {
    @NotNull
    private Integer id;
    private String username;
    @JsonIgnore//这样实体类作为json数据返回前端时,会忽略password
    private String password;
    @NotEmpty
    @Pattern(regexp = "^\S{1,10}$")
    private String nickname;
    @NotEmpty
    @Email
    private String email;
    private String userPic;

还需要在controller添加 @Validated注解,这样实体类里的约束注解才能生效

java 复制代码
@PutMapping("/update")//@PutMapping 可以理解成:专门对应 HTTP PUT 请求的映射注解,也就是更新
public Result update(@RequestBody @Validated User user){//RequestBody可以要求前端把零散的字段封装成对象
    userService.update(user);
    return Result.success();
}

2、更新用户头像

java 复制代码
//    @PatchMapping 用来处理:HTTP PATCH 请求。它的语义通常是:"部分修改资源"。Put是完整修改,patch:补丁
    @PatchMapping("/updateAvatar")
    public Result updateAvatar(@RequestParam String avatarUrl) {//@RequestParam不指定参数名的情况下,它是会根据后面的形参名来在请求里找
        userService.updateAvatar(avatarUrl);
        return Result.success();
    }
java 复制代码
  @Override
    public void updateAvatar(String avatarUrl) {
        Map<String,Object> claims = ThreadLocalUtil.get();
//        获取当前的登录用户的id,好在mapper层的语句中执行where子句
        Integer id = (Integer) claims.get("id");
        userMapper.updateAvatar(avatarUrl,id);
    }
java 复制代码
@Update("update big_event.user set user_pic=#{avatarUrl},update_time=now() where id=#{id}")
void updateAvatar(String avatarUrl,Integer id);

这里有和上面业务一样的问题,没有验证头像url是不是链接,所以需要改进

java 复制代码
@PatchMapping("/updateAvatar")
public Result updateAvatar(@RequestParam @URL String avatarUrl) {//@RequestParam不指定参数名的情况下,它是会根据后面的形参名来在请求里找
    userService.updateAvatar(avatarUrl);
    return Result.success();
}

同样你也可以在实体类字段上用@URL,然后在controller参数前加@Validated

3、更新用户密码

java 复制代码
    @PatchMapping("/updatePwd")
//    把前端传来的 JSON 请求体 自动转换成一个 Map<String,String>
    public Result updatePWD(@RequestBody Map<String,String> params) {
        String oldPwd = params.get("old_pwd");
        String newPwd = params.get("new_pwd");
        String rePwd = params.get("re_pwd");
        if (StringUtils.isEmpty(oldPwd) || StringUtils.isEmpty(newPwd) || StringUtils.isEmpty(rePwd)) {
            return Result.error("缺少必要参数");
        }
        Map<String, Object> claims = ThreadLocalUtil.get();
        String username = (String) claims.get("username");
//        由于数据库里是加密的,所以要和加密的比
        if ( !userService.findByUSerName(username).getPassword().equals(MD5Util.md5(oldPwd))){
            return Result.error("原密码错误");
        }
        if (!newPwd.equals(rePwd)) {
            return Result.error("两次密码填写内容不一致");
        }
        userService.updatePwd(newPwd);
        return Result.success();

    }
java 复制代码
    @Override
    public void updatePwd(String newPwd) {
        Map<String,Object> claims = ThreadLocalUtil.get();
//        获取当前的登录用户的id,好在mapper层的语句中执行where子句
        Integer id = (Integer) claims.get("id");
//        因为密码不能改成明文的,不能在数据库存明文
        userMapper.updatePwd(MD5Util.md5(newPwd),id);
    }
java 复制代码
@Update("update big_event.user set big_event.user.password=#{newPwd},big_event.user.update_time=now() where big_event.user.id=#{id}")
void updatePwd(String newPwd, Integer id);

6、文章模块

1、新增文章分类

java 复制代码
@RestController
@RequestMapping("/category")
public class CategoryController {
    @Autowired
    private CategoryServiceImpl categoryService;
    @PostMapping
    public Result add(@RequestBody @Validated Category category) {
        categoryService.add(category);
        return Result.success();
    }
}
java 复制代码
@NotEmpty
private String categoryName;
@NotEmpty
private String categoryAlias;
java 复制代码
@Service
public class CategoryServiceImpl implements CategoryService {
    @Autowired
    private CategoryMapper categoryMapper;
    @Override
    public void add(Category category) {
        category.setUpdateTime(LocalDateTime.now());
        category.setCreateTime(LocalDateTime.now());
        Map<String,Object> claims = ThreadLocalUtil.get();
        Integer id = (Integer) claims.get("id");
        category.setCreateUser(id);
        categoryMapper.add(category);
    }
}
java 复制代码
@Mapper
public interface CategoryMapper {
    @Insert("insert into big_event.category (big_event.category.id, big_event.category.category_name, big_event.category.category_alias, big_event.category.create_user, big_event.category.create_time, big_event.category.update_time)" +
            "values (#{id},#{categoryName},#{categoryAlias},#{createUser},#{createTime},#{updateTime})")
    void add(Category category);
}

2、查询当前用户的分类

你可以看到我这两个接口都没指定不一样的url,但是只要发送类型不同,一个post,一个get就可以访问不同的

java 复制代码
@GetMapping
public Result<List<Category>> getAll() {
    List<Category> list = categoryService.list();
    return Result.success(list);
}
java 复制代码
@Override
public List<Category> list() {
    Map<String,Object> claims = ThreadLocalUtil.get();
    Integer id = (Integer) claims.get("id");
    return categoryMapper.list(id);
}
java 复制代码
@Select("select * from category where id=#{id}")
List<Category> list(Integer id);

实体类里指定时间类型

java 复制代码
@JsonFormat(pattern = "yyyy-MM-dd HH-mm-ss")
  private LocalDateTime createTime;
  @JsonFormat(pattern = "yyyy-MM-dd HH-mm-ss")
  private LocalDateTime updateTime;

3、获取文章分类详情

java 复制代码
@GetMapping("/detail")
public Result<Category> detail(@RequestParam int id) {
    Category category=categoryService.detail(id);
    return Result.success(category);
}
java 复制代码
@Override
public Category detail(int id) {
    return categoryMapper.detail(id);
}
java 复制代码
@Select("select * from category where id=#{id}")
Category detail(int id);

4、修改分类

java 复制代码
@PutMapping
public Result update(@RequestBody @Validated Category category) {
    categoryService.update(category);
    return Result.success();
}
java 复制代码
@Override
public void update(Category category) {
    category.setUpdateTime(LocalDateTime.now());
    categoryMapper.update(category);
}
java 复制代码
@Update("update category set category_name=#{categoryName}" +
        ",category_alias=#{categoryAlias} where id=#{id}")
void update(Category category);

由于我们依据id来修改,所以id不能为空

java 复制代码
public class Category {
    @NotNull
    private Integer id;
    @NotEmpty
    private String categoryName;
    @NotEmpty
    private String categoryAlias;
注解 null "" " "
@NotNull ❌ ✅ ✅
@NotEmpty ❌ ❌ ✅
@NotBlank ❌ ❌ ❌

但是这个添加了id不能为null,这和新增分类冲突了,要解决这个问题

5、分组校验

把校验项进行归类分组,在完成不同的功能的时候,校验指定组中的校验项

  1. 定义分组
  2. 定义校验项时指定归属的分组
  3. 校验时指定要校验的分组

解决上一个小节的问题

java 复制代码
public class Category {
    @NotNull(groups = {Update.class})
    private Integer id;
    @NotEmpty(groups = {Update.class, Add.class})
    private String categoryName;
    @NotEmpty(groups = {Add.class, Update.class})
    private String categoryAlias;
  private Integer createUser;
  @JsonFormat(pattern = "yyyy-MM-dd HH-mm-ss")
    private LocalDateTime createTime;
    @JsonFormat(pattern = "yyyy-MM-dd HH-mm-ss")
    private LocalDateTime updateTime;

    public interface Add{

    }
    public interface Update{}
java 复制代码
@PutMapping
public Result update(@RequestBody @Validated(Category.Update.class) Category category) {
    categoryService.update(category);
    return Result.success();
}
java 复制代码
@PostMapping
public Result add(@RequestBody @Validated(Category.Add.class) Category category) {
    categoryService.add(category);
    return Result.success();
}

上面是比较麻烦的写法实际上:

  • 只要定义了一个未指定接口类约束,它就属于默认分组,你自定义的分组继承了默认分组就得到它的约束
  • 下面这个例子实现了和上面实体类代码一样的效果,id的约束指定了故而不在default,其他的都在
java 复制代码
public class Category {
    @NotNull(groups = {Update.class})
    private Integer id;
    @NotEmpty
    private String categoryName;
    @NotEmpty
    private String categoryAlias;
  private Integer createUser;
  @JsonFormat(pattern = "yyyy-MM-dd HH-mm-ss")
    private LocalDateTime createTime;
    @JsonFormat(pattern = "yyyy-MM-dd HH-mm-ss")
    private LocalDateTime updateTime;
    public interface Add extends Default {}
    public interface Update extends Default {}

这个模块还有删除分类的方法省略了

java 复制代码
@Delete("delete from category where id=#{id}")
void delete(Integer id);

6、新增文章

java 复制代码
@RestController
@RequestMapping("/article")
public class ArticleController {
   @Autowired
   private ArticleService articleService;

   @PostMapping
   public Result add(@RequestBody @Validated Article article) {
      articleService.add(article);
      return Result.success();
   }
java 复制代码
@Autowired
private ArticleMapper articleMapper;
@Override
public void add(Article article) {
    article.setCreateTime(LocalDateTime.now());
    article.setUpdateTime(LocalDateTime.now());
    Map<String,Object> claims=ThreadLocalUtil.get();
    Integer id = (Integer) claims.get("id");
    article.setCreateUser(id);
    articleMapper.add(article);

}
java 复制代码
@Insert("insert into article (title, content, cover_img, state, category_id,create_user,create_time,update_time)" +
        "values (#{title},#{content},#{coverImg},#{state},#{categoryId},#{createUser},#{createTime},#{updateTime})")
void add(Article article);
java 复制代码
public class Article {

    private Integer id;
    @NotEmpty
    @Pattern(regexp = "^\S{1,10}$")
    private String title;
    @NotEmpty
    private String content;
    @NotEmpty
    @URL
    private String coverImg;
    private String state;
    @NotNull
    private Integer categoryId;

如果validate提供的校验不满足要求怎么办

自定义校验

7、分页查询文章

这个类用来放返回结果,一个字段是返回数据总数,一个是list集合

java 复制代码
public class PageBean <T>{
   private Long total;
   private List<T> items;


    public PageBean() {
    }

    public PageBean(Long total, List<T> items) {
        this.total = total;
        this.items = items;
    }
xml 复制代码
<!-- Source: https://mvnrepository.com/artifact/com.github.pagehelper/pagehelper-spring-boot-starter -->
<dependency>
    <groupId>com.github.pagehelper</groupId>
    <artifactId>pagehelper-spring-boot-starter</artifactId>
    <version>2.1.0</version>
    <scope>compile</scope>
</dependency>
java 复制代码
@GetMapping
public Result<PageBean<Article>> list(
        Integer pageNum,
        Integer pageSize,
        // required = false表示这个参数可以不传
        @RequestParam(required = false) Integer categoryId,
        @RequestParam(required = false) String state
){
   PageBean<Article> pb=articleService.list(pageNum,pageSize,categoryId,state);
   return Result.success(pb);
}
java 复制代码
   @Override
    public PageBean<Article> list(Integer pageNum, Integer pageSize, Integer categoryId, String state) {
        PageBean<Article> pb = new PageBean<>();
//"告诉 PageHelper:
//接下来马上要执行的第一个 select 查询,
//请自动帮我加上分页 limit。" PageHelper本质是一个拦截器,你后面的mapper执行select * from article
//        它会给他变成select * from article limit pageNum,pageSize
//        pageNum = 当前页
//      pageSize = 每页条数
//limit 第一个参数 = 需要跳过多少条数据(offset),也就是从那个参数开始
        PageHelper.startPage(pageNum,pageSize);
        Map<String,Object> o = ThreadLocalUtil.get();
        Integer  userId = (Integer) o.get("id");
        List<Article> as=articleMapper.list(userId,categoryId,state);
// 虽然变量类型写的是 List,
//但 PageHelper 实际返回的是 Page 对象,这个对象是PAgeHelper带来的
//所以可以强转后获取分页信息。
        Page<Article> p= (Page<Article>) as;
        pb.setTotal(p.getTotal());
        pb.setItems(p.getResult());
//不用 Page 直接返回,
//是因为 Page 是分页插件内部对象;为了解耦不要直接返回框架内部对象
//PageBean 才是项目自己的统一分页响应格式。
        return pb;
    }

下面这个是mapper

java 复制代码
List<Article> list(Integer userId, Integer categoryId, String state);

下面这个是xml配置文件,实现动态查询

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实际相当于JDBC中的UserDaoImpl-->
<!--namespace=绑定一个对应的dao/mapper接口-->
<mapper namespace="com.zhang.bigevent.mapper.ArticleMapper">
    <select id="list" resultType="com.zhang.bigevent.pojo.Article">
        select * from article
        <where>
            <if test="categoryId!=null">
                and  category_id=#{categoryId}
            </if>
            <if test="categoryId!=null">
                and state=#{state}
            </if>
            and create_user=#{userId}
        </where>
    </select>

</mapper>

7、文件上传

java 复制代码
@RestController
public class FileUploadController {

    @PostMapping("/upload")
    public Result<String> upload( MultipartFile file) throws IOException {
//        得到文件的名字
        String originalFilename = file.getOriginalFilename();
//        通过原名字和UUID组合,确保每一个文件名都不一样,也就不会两个操作一个覆盖另一个
        String filename= UUID.randomUUID().toString()+originalFilename.substring(originalFilename.lastIndexOf("."));
//        把上传的文件数据放在一个新地方。\这个分隔符可以用File.separator 代替只要在父目录和filename代替一个分隔符就行
//        更推荐这种写法 ,new File(父目录, 子文件名),Java 会自动帮你处理路径分隔符。
        file.transferTo(new File("D:\code\project\bigevent\src\main\resources\static\upload",filename));
//  file.transferTo(new File("D:\code\project\bigevent\src\main\resources\static\upload"+File.separator+filename));
//    file.transferTo(new File("D:\code\project\bigevent\src\main\resources\static\upload\"+filename));    
        return Result.success(filename);
    }
}

MultipartFile 是 Spring 框架中用于"接收上传文件"的接口,位于:

它主要用于处理:

  • 图片上传
  • 文件上传
  • 表单中的 file 类型数据

例如前端:

html 复制代码
<input type="file" name="file">

提交后,后端就可以用:

java 复制代码
MultipartFile file

接收这个文件。

场景 常用方法
获取文件名 getOriginalFilename()
判断是否上传 isEmpty()
获取文件类型 getContentType()
保存文件 transferTo()
获取文件内容 getBytes()
大文件处理 getInputStream()

1、阿里云OSS

"云"本质上就是:

别人的服务器 + 网络服务

也就是:

  • 不是存你自己电脑
  • 而是存到互联网的数据中心服务器上

例如:

  • Alibaba Cloud
  • Tencent Cloud
  • Amazon Web Services
  • Google Cloud

这些公司都有大量机房。你存在本地的上传数据,开发可以,但不能被其他用户自由简便的访问,使用专门的云服务提供商可以解决这些问题

阿里云对象存储OSS(Object Storage Service),是一款海量、安全、低成本、高可靠的云存储服务。使用OSS,您可以通过网络随时存储和调用包括文本、图片、音频和视频等在内的各种文件。

cn.aliyun.com/ 配置

help.aliyun.com/zh/oss/user...

xml 复制代码
<!-- Source: https://mvnrepository.com/artifact/com.aliyun/alibabacloud-oss-v2 -->
<dependency>
    <groupId>com.aliyun</groupId>
    <artifactId>alibabacloud-oss-v2</artifactId>
    <version>0.3.0</version>
    <scope>compile</scope>
</dependency>

下面是一个基于官网简单上传写的,实际开发推荐key写到环境变量

java 复制代码
public class Demo {

    public static void main(String[] args) throws Exception {
        // Endpoint以华东1(杭州)为例,其它Region请按实际情况填写。
        String endpoint = "https://oss-cn-beijing.aliyuncs.com";
        // 从环境变量中获取访问凭证。运行本代码示例之前,请确保已设置环境变量OSS_ACCESS_KEY_ID和OSS_ACCESS_KEY_SECRET。
        String accessKeyId = "LTAI51t227oPKMrgcsQF3ccGMs4";
        String accessKeySecret = "yKU03uRCbB6yNdgyDsv1Byrt2ZphzbPh";

        OSS ossClient = new OSSClientBuilder().build(
                endpoint,
                accessKeyId,
                accessKeySecret
        );
        // 填写Bucket名称,例如examplebucket。
        String bucketName = "big-event-zhanglei";
        // 填写Object完整路径,完整路径中不能包含Bucket名称,例如exampledir/exampleobject.txt。
        String objectName = "001.png";
        // 填写本地文件的完整路径,例如D:\localpath\examplefile.txt。
        // 如果未指定本地路径,则默认从示例程序所属项目对应本地路径中上传文件。
        String filePath= "D:\图片\OIP.jpg";
        // 填写Bucket所在地域。以华东1(杭州)为例,Region填写为cn-hangzhou。
        String region = "cn-beijing";



        try {
            // 创建PutObjectRequest对象。
            PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, objectName, new File(filePath));
            // 如果需要上传时设置存储类型和访问权限,请参考以下示例代码。
            // ObjectMetadata metadata = new ObjectMetadata();
            // metadata.setHeader(OSSHeaders.OSS_STORAGE_CLASS, StorageClass.Standard.toString());
            // metadata.setObjectAcl(CannedAccessControlList.Private);
            // putObjectRequest.setMetadata(metadata);

            // 上传文件。
            PutObjectResult result = ossClient.putObject(putObjectRequest);
        } catch (OSSException oe) {
            System.out.println("Caught an OSSException, which means your request made it to OSS, "
                    + "but was rejected with an error response for some reason.");
            System.out.println("Error Message:" + oe.getErrorMessage());
            System.out.println("Error Code:" + oe.getErrorCode());
            System.out.println("Request ID:" + oe.getRequestId());
            System.out.println("Host ID:" + oe.getHostId());
        } catch (ClientException ce) {
            System.out.println("Caught an ClientException, which means the client encountered "
                    + "a serious internal problem while trying to communicate with OSS, "
                    + "such as not being able to access the network.");
            System.out.println("Error Message:" + ce.getMessage());
        } finally {
            if (ossClient != null) {
                ossClient.shutdown();
            }
        }
    }
}

把这个示例改成工具类

java 复制代码
package com.zhang.bigevent.utils;

import com.aliyun.oss.ClientException;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.OSSException;
import com.aliyun.oss.model.PutObjectRequest;
import com.aliyun.oss.model.PutObjectResult;

import java.io.File;
import java.io.InputStream;

public class AliOssUtil {
    private static String endpoint = "https://oss-cn-beijing.aliyuncs.com";
    // 从环境变量中获取访问凭证。运行本代码示例之前,请确保已设置环境变量OSS_ACCESS_KEY_ID和OSS_ACCESS_KEY_SECRET。
    private static String accessKeyId = "LTAI5t7oPKMrgcs332QF3ccGMs4";
    private static String accessKeySecret = "yKU03uRCbB6yNdgyDs12vByrtZphzbPh";
    // 填写Bucket名称,例如examplebucket。
    private static String bucketName = "big-event-zhanglei";
    private static  String region = "cn-beijing";
    public static String uploadFile(String objectName, InputStream filePath) throws Exception {
        // Endpoint以华东1(杭州)为例,其它Region请按实际情况填写。
        String url = "";

        OSS ossClient = new OSSClientBuilder().build(
                endpoint,
                accessKeyId,
                accessKeySecret
        );

        try {
//                                              InputStream 可以代替 new File(...) 作为"文件数据来源"。
            PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, objectName, filePath);

            PutObjectResult result = ossClient.putObject(putObjectRequest);
//https://big-event-zhanglei.oss-cn-beijing.aliyuncs.com/001.png
            url="https://"+bucketName+"."+endpoint.substring(endpoint.lastIndexOf("/")+1)+
            "/"+objectName;

        } catch (OSSException oe) {
            System.out.println("Caught an OSSException, which means your request made it to OSS, "
                    + "but was rejected with an error response for some reason.");
            System.out.println("Error Message:" + oe.getErrorMessage());
            System.out.println("Error Code:" + oe.getErrorCode());
            System.out.println("Request ID:" + oe.getRequestId());
            System.out.println("Host ID:" + oe.getHostId());
        } catch (ClientException ce) {
            System.out.println("Caught an ClientException, which means the client encountered "
                    + "a serious internal problem while trying to communicate with OSS, "
                    + "such as not being able to access the network.");
            System.out.println("Error Message:" + ce.getMessage());
        } finally {
            if (ossClient != null) {
                ossClient.shutdown();
            }
        }
        return url;
    }
}

new File和inputstream都可以作为文件数据的来源,这个工具类之所以有流作为参数,是因为控制类的参数MultipartFile有getInputstream的方法

java 复制代码
import java.util.UUID;

@RestController
public class FileUploadController {

    @PostMapping("/upload")
    public Result<String> upload( MultipartFile file) throws Exception {
//        得到文件的名字
        String originalFilename = file.getOriginalFilename();
//        通过原名字和UUID组合,确保每一个文件名都不一样,也就不会两个操作一个覆盖另一个
        String filename= UUID.randomUUID().toString()+originalFilename.substring(originalFilename.lastIndexOf("."));

        String s = AliOssUtil.uploadFile(filename, file.getInputStream());
        System.out.println(s);
        return Result.success(s);
    }
}

8、登录优化

在登录后,如果你修改了密码,那么原密码的令牌就应该失效了,然而令牌在内存里生成时是 Map,但一旦发出去就变成了不可变的加密字符串,我们无法直接操作它,故而我们这样实现这个改进

Redis

Redis 是数据库,但和我们熟悉的 MySQL 完全不同 ,Redis 是一个开源的内存数据结构存储系统,用作数据库、缓存和消息中间件。

  • Redis 是一种基于内存的 NoSQL 键值数据库,它的核心特点是读写速度极快(微秒级),支持字符串、哈希、列表、集合等多种数据结构。
  • 它最常用于缓存热点数据 (如用户信息、页面内容)以减轻数据库压力,同时也被广泛用作分布式会话存储 (如存登录 Token,实现主动失效)、计数器 (如点赞数、限流)和分布式锁。
  • 与传统关系型数据库(如 MySQL)不同,Redis 主要将数据存储在内存中,因此能提供极高的并发读写能力,但存储容量受内存限制,通常作为数据库的"加速层"配合使用。

使用前你需要在官网下载,并点击redis-sever启动服务 github.com/microsoftar...

xml 复制代码
<!-- Source: https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-data-redis -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    <version>4.0.6</version>
    <scope>compile</scope>
</dependency>
yml 复制代码
spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/big_event?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false
    username: root
    password: 123456
  data:
    redis:
      host: localhost
      port: 6379
      

执行一个测试

java 复制代码
@SpringBootTest//这个注解的作用就是test前注册spring容器
public class RedisTest {
    @Autowired
//StringRedisTemplate 是 Spring Data Redis 提供的一个专门用于处理字符串数据的工具类
    private StringRedisTemplate stringRedisTemplate;
    @Test
    public void test() {
        ValueOperations<String, String> operations = stringRedisTemplate.opsForValue();
        operations.set("username", "zhangsan");

    }
}

1、基于redis的登录优化

java 复制代码
@Autowired
private StringRedisTemplate stringRedisTemplate;

@PostMapping("/login")
public Result login(
        @Pattern(regexp = "^\S{5,16}$") String username,
        @Pattern(regexp = "^\S{5,16}$") String password) {
    User u = userService.findByUSerName(username);
    if (u == null) {
        return Result.error("该用户不存在");
    }
    //由于数据库存储的是加密后的密码,所以我们要把输入的密码加密再与数据库比对
    if (MD5Util.md5(password).equals(u.getPassword())) {
        HashMap<String, Object> claims = new HashMap<>();//map来存储数据
        claims.put("id", u.getId());
        claims.put("username", username);
        String token = JwtUtil.genToken(claims);//工具类得到token

        ValueOperations<String, String> operations = stringRedisTemplate.opsForValue();
        //直接把键和值设置为token,避免了重复,也方便比对,过期时间与token一致
        operations.set(token, token,12, TimeUnit.HOURS);
        return Result.success(token);//封装到result返回前端
    }
    return Result.error("密码错误");
}
java 复制代码
@Component
public class LoginInterceptors implements HandlerInterceptor {
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String token = request.getHeader("Authorization");//得到令牌
        try {
        //token名直接为键名优点就在于,本地token直接取
        //,通过能不能取出就知道有没有失效,不用取出再比对
            String redisToken = stringRedisTemplate.opsForValue().get(token);
            if (redisToken == null) {//为空说明已经失效,直接抛出错误
                throw new RuntimeException();
            }
            Map<String, Object> claims = JwtUtil.parseToken(token);//验证令牌
            ThreadLocalUtil.set(claims);
            return true;//放行
        } catch (Exception e) {
            System.out.println("拦截了");
            response.setStatus(401);
            return false;//阻止
        }
    }
相关推荐
xcl092524 分钟前
北京24小时自助健身房系统软件开发实战:从架构到部署全流程指南
java·spring boot·架构
一 乐2 小时前
动漫书销售商城|基于springboot + vue动漫书销售商城(源码+数据库+文档)
java·数据库·vue.js·spring boot·毕业设计
卓怡学长2 小时前
w214基于jsp知道特产网
java·intellij-idea
步行cgn2 小时前
Spring 注解使用详解
java·spring
一条小小yu3 小时前
Spring IoC的理解
java·后端·spring
落魄实习生3 小时前
Agent Scope Java 2.x 系列【7】工具使用
java·开发语言·ai
旺仔学长 哈哈3 小时前
springboot钓鱼爱好者交流平台APP设计与实现
java·spring boot·mysql·充电桩管理系统
自强的小白5 小时前
核心功能(Service接口)
java·mybatis
乌暮6 小时前
深入理解 Java 泛型:把「万能盒子」用对、用稳
java·开发语言·后端·学习