使用 java -version
命令可以查看当前环境是否安装 JDK。
maven 是一个项目构建和管理的工具,使用 mvn -version
命令可以检查当前环境是否安装,因为 maven 工具在编译的过程中是需要用到 JDK 的,所以也给出了 Java 版本相关信息。
使用 sudo service mysql start
命令启动 MySQL,启动成功之后可以使用 mysql -u root
命令进行连接,连接成功即mysql没问题。
<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/maven-v4_0_0.xsd">
<modelVersion>4.0.0modelVersion>
<parent>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-parentartifactId>
<version>2.4.1version>
parent>
<groupId>com.shiyanlou.filegroupId>
<artifactId>qiwen-fileartifactId>
<version>1.0-SNAPSHOTversion>
<packaging>jarpackaging>
<dependencies>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-testartifactId>
<scope>testscope>
dependency>
dependencies>
project>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-jdbcartifactId>
dependency>
<dependency>
<groupId>com.mysqlgroupId>
<artifactId>mysql-connector-jartifactId>
<scope>runtimescope>
dependency>
#mysql数据库
spring:
datasource:
url: jdbc:mysql://192.168.142.77:3306/file
username: root
password: 1234
使用 sudo mysql -u root
连接数据库环境,会进入到数据库命令行模式,执行创建数据库脚本,命令如下:
create database file default charset utf8 collate utf8_general_ci;
因为 Spring Boot 内部已经集成了 Logback 日志模块,所以,我们只在 application.properties
配置文件中添加 log 日志的相关信息即可。
为了便于查看日志,我们将日志路径设置到环境工程路径下:/home/project
,在实际开发过程中,可以根据需要调整日志级别。
配置之后重新启动项目mvn spring-boot:run,就可以看到日志文件了。
# 配置日志级别和存储位置
logging:
file:
name: /home/jl/log/web.log
level:
root: info # 指定根目录下都是info的日志
注:项目没有上线,日志打印在控制台好一点,比较好调试,开发时可以不配置
网盘系统主要是对文件进行管理,这里列出几个比较重要的点:
在计算机内部,由于文件都是以二进制的形式进行存储的,因此一个文件实际上就是一个二进制文件,占用一定的磁盘空间,这就是文件的物理存储。而作为一个网盘项目,我们在界面上展示的文件信息实际上只是在数据库存储的数据信息,包括文件路径,文件大小,文件名等,但是它会通过一个 url 字段指向服务器的一个具体文件,这就是逻辑存储。
**对现实世界进行分析、抽象、并从中找出内在联系,进而确定数据库的结构,这一过程就称为数据库建模。**它主要包括两部分内容:
实现一个网盘项目,然后实现用户登录,登录用户可以对文件进行管理,其中包含以下功能:
从上面需求描述,我们需要从中提取出实体和属性,如下表:
实体 | 属性 |
---|---|
文件 | 文件名、扩展名、大小、路径、… |
用户 | 用户名、手机号、密码、年龄、… |
当实体和属性提取出来之后,就可以对实体和属性,实体和实体之间的关系进行分析,这个分析过程需要用 E-R 图。
E-R 图也称为实体-联系图(Entity Relationship Diagram),它提供了表示实体类型、属性和联系的方法,用来描述现实世界的概念模型。
在 E-R 图中,分别用矩形、菱形、椭圆形来表示不同的含义,如下表:
形状 | 含义 |
---|---|
矩形 | 实体 |
菱形 | 实体之间的联系 |
椭圆形 | 实体或联系的属性 |
由于文件是需要用户去进行管理的,因此这里要清楚文件和用户之间的关系,是一对一、一对多、还是多对多,然后在 ER 图中将他们关联起来。
要搞清楚他们之间的关系,首先需要明确下面两个问题:
作为一个网盘系统,一个用户肯定是能够拥有多个文件,主要关键在于一个文件是否可以被多个用户所拥有,由于后面我们要实现极速秒传的功能,那么这里就会涉及到,一个文件被多个用户所拥有。
从以上两个问题的分析结果,可以得出用户和文件之间是多对多的关系,因此在 ER 图中,我们可以将文件和用户关联起来,如下图:
从上图可以看出,两个实体之间进行关联需要用到菱形,在菱形的两边用 M 进行标识,表示两个实体类之间是多对多的关系。
将多对多联系转为一对多联系模型
**在数据库设计中,如果两个实体之间是多对多的关系,那么就需要一张中间表进行关联,从而将多对多联系转为一对多联系模型。**这个操作是关键点,也是难点,因为之前的两个实体都是直观的,现在就需要抽象出来一个新的实体。
我们将这种中间表起名为用户文件表,它存在的意义就是将文件表和用户表关联起来,如下图:
到此为止,整个数据库底层的关系模型就已经出来了,在此之前,我们所说的文件还是一个模糊的概念,而到了这一步,整个关系模型跟之前讲物理存储和逻辑存储的图正好能够对应,其中文件就是物理存储,它跟磁盘存储的文件是一一对应的,用户文件属于逻辑存储,用户在前台对文件进行移动复制等操作,其实只是做一些数据库的操作,但是指向文件的 url 没有变动,这就恰恰反向论证了整个设计思路是没有问题的。
根据上图,这里还需要做进一步的解释,我们发现,在整个 E-R 图的演进过程中,本来属于文件的属性,我却把它放到了用户文件这一层,比如文件名,扩展名,是否是目录,其原因是修改文件名和扩展名,是不会影响文件本身的二进制内容,你可以不妨一试,因此我将它放到逻辑存储的用户文件属性中了。另外我们在文件磁盘存储的角度是不存在目录这个概念的,它只是我们在管理层面抽象出来的,因此它也需要提升到用户文件这个实体类中。
导入依赖
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-jpaartifactId>
dependency>
<dependency>
<groupId>org.projectlombokgroupId>
<artifactId>lombokartifactId>
<optional>trueoptional>
dependency>
编写实体类
注解名称 | 说明 |
---|---|
@Entity | 表明该类是一个实体类,添加了该注解后,才能被 jpa 扫描到 |
@Table | 可以自定义表名 |
@Id | 用来声明主键 |
@GeneratedValue | 设置主键生成方式,主要有四种类型,这里我们将 strategy 属性设置为 GenerationType.IDENTITY,表明主键由数据库生成,为自动增长型 |
@Column | 可以自定义列名或者定义其他的数据类型 |
User.java
package pers.jl.model;
import lombok.Data;
import javax.persistence.*;
@Data
@Table(name = "user")
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(columnDefinition = "bigint(20) comment '用户id'")
private Long userId;
@Column(columnDefinition = "varchar(30) comment '用户名'")
private String username;
@Column(columnDefinition = "varchar(35) comment '密码'")
private String password;
@Column(columnDefinition = "varchar(15) comment '手机号码'")
private String telephone;
@Column(columnDefinition = "varchar(20) comment '盐值'")
private String salt;
@Column(columnDefinition = "varchar(30) comment '注册时间'")
private String registerTime;
}
File.java
package pers.jl.model;
import lombok.Data;
import javax.persistence.*;
@Data
@Table(name = "file") // 自定义表名
@Entity// 表明这是一个实体类,可以被jpa扫描到
public class File {
@Id// 声明主键
@GeneratedValue(strategy = GenerationType.IDENTITY)// 主键根据数据库自增
@Column(columnDefinition="bigint(20) comment '文件id'")// 自定义列名和和数据类型
private Long fileId;
@Column(columnDefinition="varchar(500) comment '时间戳名称'")
private String timeStampName;
@Column(columnDefinition="varchar(500) comment '文件url'")
private String fileUrl;
@Column(columnDefinition="bigint(10) comment '文件大小'")
private Long fileSize;
}
UserFile.java
package pers.jl.model;
import lombok.Data;
import javax.persistence.*;
@Data
@Table(name = "userfile", uniqueConstraints = {
@UniqueConstraint(name = "fileindex", columnNames = {"fileName", "filePath", "extendName"})})
@Entity
public class UserFile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(columnDefinition = "bigint(20) comment '用户文件id'")
private Long userFileId;
@Column(columnDefinition = "bigint(20) comment '用户id'")
private Long userId;
@Column(columnDefinition="bigint(20) comment '文件id'")
private Long fileId;
@Column(columnDefinition="varchar(100) comment '文件名'")
private String fileName;
@Column(columnDefinition="varchar(500) comment '文件路径'")
private String filePath;
@Column(columnDefinition="varchar(100) comment '扩展名'")
private String extendName;
@Column(columnDefinition="int(1) comment '是否是目录 0-否, 1-是'")
private Integer isDir;
@Column(columnDefinition="varchar(25) comment '上传时间'")
private String uploadTime;
}
编写配置文件
jpa: # JPA配置
show-sql: true # 打印原生SQL查询语句以方便调试和分析性能。
properties:
hibernate:
hbm2ddl:
auto: update # 设置Hibernate在启动时根据实体类自动生成 DDL 脚本并更新数据库表结构。
dialect: org.hibernate.dialect.MySQL5InnoDBDialect # 指定 Hibernate 使用的数据库方言。这里使用MySQL5的 InnoDB 方言。
hibernate:
naming:
# 自定义命名策略,不做任何修改,即使用实体类的类名作为表名
physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
配置详解:
hbm2ddl.auto 这个属性(可以替换为ddl-auto)是创建表的属性,有4个
- create: 每次启动将之前的表和数据都删除,然后重新根据实体建立表。
- create-drop: 比上面多了一个功能,就是在应用关闭的时候,会把表删除。
- update: 最常用的,第一次启动根据实体建立表结构,之后重启会根据实体的改变更新表结构,不删除表和数据。
- validate: 验证创建数据库表结构,只会和数据库中的表进行比较,不会创建新表,但是会插入新值,运行程序会校验实体字段与数据库已有的表的字段类型是否相同,不同会报错
项目启动之后就会通过 JPA 生成三张表
导入依赖
org.mybatis.spring.boot
mybatis-spring-boot-starter
2.3.1
添加配置
# mybatis配置
mybatis:
config-location: classpath:mybatis/mybatis-config.xml
mapper-locations: classpath:mybatis/mapper/*.xml
启动类扫描mapper包
@MapperScan("pers.jl.mapper")
添加mapper接口
package pers.jl.mapper;
import org.springframework.stereotype.Repository;
@Repository
public interface FileMapper {
}
package pers.jl.mapper;
import org.springframework.stereotype.Repository;
@Repository
public interface UserfileMapper {
}
package pers.jl.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.springframework.stereotype.Repository;
import pers.jl.model.User;
import java.util.List;
@Repository
public interface UserMapper{
// 新增用户
void insertUser(User user);
// 查询用户
List<User> selectUser();
}
添加mybatis配置文件和映射文件
mybatis-config.xml(该配置文件主要用来指定 MyBatis 基础配置文件和实体类映射文件的地址)
UserMapper.xml
namespace
指定了它的 Mapper 接口,因此它的内容就是 Mapper 接口的包名;insert
标签和 select
标签分别对应 Mapper 接口中的插入和查询操作,因此这里的标签 id 与 Mapper 接口的方法名是一致的。
insert into user (username, password, telephone)
value (#{username}, #{password}, #{telephone})
测试
// mybatis测试
@Test
void test1() {
User user = new User();
user.setUsername("用户名1");
user.setPassword("密码1");
user.setTelephone("手机号1");
usermapper.insertUser(user);
System.out.println("数据库字段查询结果显示");
List list = usermapper.selectUser();
list.forEach(System.out::println);
}
导入依赖
com.baomidou
mybatis-plus-boot-starter
3.4.1
添加配置
# mybatis-plus配置
mybatis-plus:
mapper-locations: classpath:mybatis/mapper/*.xml
configuration:
map-underscore-to-camel-case: false # 关闭驼峰命名规则映射
添加注解
在之前已经创建的三个实体类中,添加 MyBatis-Plus 相关注解,这里需要用到的注解有两个,分别是 @TableName
和 @TableId
。
注解 | 描述 |
---|---|
@TableName(“表名”) | 实体类添加,如果不添加,会按照默认规则进行表明的映射,比如 UserTable->user_table |
@TableId(type = IdType.AUTO) | 用来标注实体类主键 |
mapper接口继承BaseMapper
package pers.jl.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.springframework.stereotype.Repository;
import pers.jl.model.User;
import java.util.List;
@Repository
public interface UserMapper extends BaseMapper {
// 新增用户
void insertUser(User user);
// 查询用户
List selectUser();
}
测试
// mybatis-plus测试
@Test
public void test2() {
User user = new User();
user.setUsername("用户名2");
user.setPassword("密码2");
user.setTelephone("手机号2");
usermapper.insert(user);
List list = usermapper.selectList(null);
System.out.println("数据库字段查询结果显示");
list.forEach(System.out::println);
}
HTTP 请求有 5 种请求方法,对应 CRUD 操作,通常我们使用 GET 来做查询,POST 做提交。
既然上面 HTTP 请求方法已经为我们指定了请求的动作,那么请求的 url 路径只需要指定好需要请求的资源就可以了,它是名词,而非动词,或者动宾,比如 /articles
就是正确的,而下面的 URL 不是名词,所以是错误的:
/getArticles
/createNewCar
/deleteAllRedCars
URL 是不区分单数和复数的,通常的做法是当读取一个集合,比如 GET /articles
,则表示读取所有文章,GET /articles/3
则表示读取某一篇文章。
常见的情况是,资源需要多级分类,因此很容易写出多级的 URL,比如获取某个作者的某一类文章。
GET /authors/12/categories/2
这种 URL 不利于扩展,语义也不明确,往往要想一会,才能明白含义。更好的做法是,除了第一级,其他级别都用查询字符串表达。
GET /authors/12?categories=2
下面是另一个例子,查询已发布的文章。你可能会设计成下面的 URL。
GET /articles/published
查询字符串的写法明显更好。
GET /articles?published=true
**接口的 API 返回的数据格式,不应该是纯文本,而应该是一个 JSON 对象,因为这样才能返回标准的结构化数据。**所以,服务器回应的 HTTP 头的 Content-Type 属性要设为 application/json
。 目前的前后端开发大部分数据的传输格式都是 JSON,因此定义一个统一规范的数据格式有利于前后端的交互与 UI 的展示。
我们需要统一返回格式,这里我们首先使用枚举类来定义各种返回状态。
一般后台返回给前台的状态可以大致分为两类:成功和失败,但是失败的情况却有很多种,为了能够让前台调用者更加清楚的知道后台报了什么错,这里我们可以尽可能的将错误细化,比如下面枚举类中参数错误,空指针异常等。
package pers.jl.common;
import lombok.Getter;
/**
* 结果枚举类
*/
@Getter
public enum ResultCodeEnum {
/**
* 1.定义不同的枚举值,对应返回给页面的不同状态
*/
SUCCESS(true,20000,"成功"),
UNKNOW_ERROR(false,20001,"未知错误"),
PARAM_ERROR(false,20002,"参数错误"),
NULL_POINT(false,20003,"空指针异常"),
INDEX_OUT_OF_bOUNDS(false,20004,"下标越界异常");
/**
* 2.定义枚举属性
*/
// 相应是否成功
private Boolean success;
// 响应码
private Integer code;
// 相应信息
private String message;
/**
* 3.全参构造,方便构造枚举类型
* @param success
* @param code
* @param message
*/
ResultCodeEnum(Boolean success, Integer code, String message) {
this.success = success;
this.code = code;
this.message = message;
}
}
package pers.jl.common;
import lombok.Data;
/**
* 统一结果返回
* @param
*/
@Data// 在类上添加之后自动生成getters、setters、equals、hashCode、toString等一些Java Bean必须的方法
public class RestResult<T> {
// 返回结果属性
private Boolean success = true;
private Integer code;
private String message;
private T data;
// 通用返回成功
public static RestResult success(){
// 返回结果设置成功枚举值的属性
RestResult r = new RestResult();
r.setSuccess(ResultCodeEnum.SUCCESS.getSuccess());
r.setCode(ResultCodeEnum.SUCCESS.getCode());
r.setMessage(ResultCodeEnum.SUCCESS.getMessage());
return r;
}
// 通用返回失败,未知错误
public static RestResult fail(){
// 返回结果设置未知错误枚举值的属性
RestResult r = new RestResult();
r.setSuccess(ResultCodeEnum.UNKNOW_ERROR.getSuccess());
r.setCode(ResultCodeEnum.UNKNOW_ERROR.getCode());
r.setMessage(ResultCodeEnum.UNKNOW_ERROR.getMessage());
return r;
}
}
有些情况,当系统返回失败的时候我们需要自定义返回码或者返回信息,因此需要在 RestResult 结果类里面添加方法,来满足各种错误场景。
// 自定义返回数据
public RestResult data(T param){
this.setData(param);
return this;
}
// 自定义状态信息
public RestResult message(String message){
this.setMessage(message);
return this;
}
// 自定义状态码
public RestResult code(Integer code){
this.setCode(code);
return this;
}
// 设置结果,形参为结果枚举
public static RestResult setResult(ResultCodeEnum result){
RestResult r = new RestResult();
r.setSuccess(result.getSuccess());
r.setCode(result.getCode());
r.setMessage(result.getMessage());
return r;
}
这些方法的返回结果都是RestResult,所以它们可链式调用,下面举例
//查询文件列表,成功时返回列表数据
public RestResult list() {
List filelist = new ArrayList();
//获取文件列表(略)
...
return RestResult.success().data(filelist);
}
//模拟用户登录失败响应场景
public RestResult loginFailResult() {
return RestResult.fail().message("手机号不存在!");
}
@RestController
@RequestMapping("/user")
public class UserController {
/**
* 成功响应测试
* @return
*/
@GetMapping("/test1")
public RestResult test1(){
return RestResult.success();
}
/**
* 失败响应测试
* @return
*/
@GetMapping("/test2")
public RestResult test2(){
return RestResult.fail();
}
}
NullPointerException
异常,且有同时存在处理Exception
和NullPointerException
的异常处理方法,那么会优先匹配处理NullPointerException
的方法,如果已经匹配到了NullPointerException
的异常处理方法并执行了该方法,那么就不会再去匹配处理Exception
的方法了。异常处理类
@Slf4j
@ControllerAdvice
@ResponseBody
public class GlobalExceptionHandlerAdvice {
/**
* 通用异常处理方法
*/
@ExceptionHandler(Exception.class)
public RestResult error(Exception e){
e.printStackTrace();
log.error("全局异常捕获:"+e);
return RestResult.fail();
}
/**
* 空指针处理方法
*/
@ExceptionHandler(NullPointerException.class)
public RestResult error(NullPointerException e){
e.printStackTrace();
log.error("全局异常捕获:"+e);
return RestResult.setResult(ResultCodeEnum.NULL_POINT);
}
/**
* 下标越界处理方法
*/
@ExceptionHandler(IndexOutOfBoundsException.class)
public RestResult error(IndexOutOfBoundsException e){
e.printStackTrace();
log.error("全局异常捕获:"+e);
return RestResult.setResult(ResultCodeEnum.INDEX_OUT_OF_bOUNDS);
}
}
测试
/**
* 空指针异常响应测试
*/
@GetMapping("/test3")
public RestResult test3() {
// 构造了一个空指针异常
String s = null;
int i = s.length();
return RestResult.success();
}
我们都知道,一个普通 Web 应用前后台进行交互使用的是 HTTP 协议,由于 HTTP 协议是无状态的,因此每个请求对于后台服务端来说,都是全新的,但是随着互联网的 Web 应用兴起,像很多购物网站,需要登录的网站就面临一个问题,那就是会话管理,后台需要记住哪些人登录系统,哪些人进行了购物操作,也就是说必须把每个人区分开,但是 HTTP 请求时无状态的,所以就想到一个办法,给每个请求者发一个会话标识,也就是 session,说白了就是一个随机的字符串,保证每个人不重复,这样当大家再次向服务端发送请求的时候,就能区分开来谁是谁了。
随着用户数量的增加,服务器要保存所有用户的 session,这对服务器来说是一个巨大的开销,严重的限制了服务器的扩展能力,比如负载均衡+服务器集群部署方式,往往用户在 A 服务器登录了系统,session 会保存在机器 A 上,但是如果下一次请求被转发到了机器 B 怎么办?通常有两种做法,一种是通过 Sticky 技术将相同用户请求分发到同一个机器上,但是这样就违背了做负载均衡的初衷了,而且万一这个机器挂了,那么还是会请求到另外的机器上。另一种做法就 session 的复制了,将 session 在多个应用之间进行复制,如下图:
但是集群的数量如果过大,将 session 拷来拷去,却是一件很麻烦的事情,因此,有人支招,将 session 集中存储到一个地方,所有机器都来访问这个地方的数据,这样一来,就不用复制了,如下:
但是如果这个 session 存储的机器挂了,所有人都得受到影响,因此也尝试将这个机器也搞一个集群,增加可靠性,但是不管如何,这个小小的 session 对后台来说都是一个沉重的负担。
于是又有人思考,为什么后台要保存这些 session 呢?如果让每个客户端自己去保存该有多好,出于这个起点,人们开始不断的尝试。关键点在于如果服务端不保存 session,如何知道 session 是我生成的。关键点就在于验证,比如客户端 M 登录了系统,我给他发了一个令牌(token),里面包含了该用户的 user id,用来标识客户端身份,下次客户端 M 再次访问我的时候,再把这个 token 通过 Http header 带过来就行了,不过这个本质和 session 没区别,任何人都可以伪造,所以得想点办法,让别人无法伪造。
那就对数据做个签名,将数据和密钥一起作为 token,由于密钥别人不知道,就无法伪造 token 了。当客户端再次进行请求,服务端只需要用密钥再次签名进行验证,如果签名一致,则为有效 token,这时就可以直接取到 user id,如果签名不一致,则说明数据被篡改了,则告诉请求者,认证失败。
传统的 token 认证有一个缺陷,就是用户的关键信息,比如 id 等信息是通过明文来进行传输的,因此基于安全考虑,Java Web Token 应运而生了。
JSON Web Token (JWT) 是一个开放标准(RFC 7519),它定义了一种紧凑且自包含的方式,用于将信息作为 JSON 对象安全地在各方之间传输信息。此信息可以验证和信任,因为它是数字签名。JWT 可以使用密钥(使用 HMAC 算法)或使用 RSA 或 ECDSA 进行公钥/私钥对进行签名。
JSON Web 令牌以紧凑的形式由三个部分组成,由点(.)分隔,它们包括:
将上面三部分用(.)拼接起来就形成了 JWT,因此,JWT 的格式通常如下所示。
xxxxx.yyyyy.zzzzz
标头通常由两部分组成:token 的类型(typ)和正在使用的签名算法(alg),如 HMAC SHA256 或 RSA。
例如:
{
"alg": "HS256",
"typ": "JWT"
}
然后,此 JSON 编码为 Base64Url,以形成 JWT 的第一部分。
token 的第二部分是有效负载,其中包含 claims。claims 是关于实体(通常为用户)和其他数据的语句。
示例有效负载可能是:
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}
然后对有效负载进行 Base64Url 编码,以形成 JSON Web 令牌的第二部分。
请注意,对于已签名的 token,此信息虽然可防止篡改,但任何人都可以阅读。除非对 JWT 进行加密,否则不要将机密信息放在 JWT 的有效负载或标头元素中。
要创建签名部分,您必须使用编码标头、编码有效负载、机密、标头中指定的算法,并签名。
例如,如果要使用 HMAC SHA256 算法,将采用以下方式创建签名:
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
签名用于验证信息在传输过程中不被篡改,对于使用私钥签名的 token,它还可以验证 JWT 的发件人是否为它所说的发件人。
输出是三个 Base64-URL 字符串,由点分隔,这些点可以在 HTML 和 HTTP 环境中轻松传递,但与基于 XML 的标准(如 SAML)相比,更紧凑。
<dependency>
<groupId>io.jsonwebtokengroupId>
<artifactId>jjwtartifactId>
<version>0.9.1version>
dependency>
上面已经介绍了 JWT 的基本概念了,它由三部分构成,分别是 header, payload 和 signature, 其中前两部分中的参数设计到的一些参数需要用户自定义,因此我们将这些参数放到配置文件中,方便后续修改。
# JWT配置(自定义参数)
jwt:
secret: 6L6T5LqG5L2g77yM6LWi5LqG5LiW55WM5Y+I6IO95aaC5L2V44CC # 密钥
header:
alg: HS256 # 签名算法:HS256,HS384,HS512,RS256,RS384,RS512,ES256,ES384,ES512,PS256,PS384,PS512
payload:
registerd-claims:
iss: qiwen-cms # jwt签发者
exp: 60 * 60 * 1000 * 24 * 7 # jwt过期时间(单位:毫秒)
aud: qiwenshare # jwt接收者
/**
* jwt第一部分的header
*/
@Data
public class JwtHeader {
private String alg;
private String typ;
}
/**
* jwt第二部分的payload,由一个claims构成。
*/
@Data
public class JwtPayload {
private RegisterdClaims registerdClaims;
}
/**
* jwt第二部分payload的claims。
*/
@Data
public class RegisterdClaims {
private String iss;
private String exp;
private String sub;
private String aud;
}
application.yml
配置文件中以 jwt 开头的配置。/**
* 完整的JWT配置类,通过注解@ConfigurationProperties读取开头为jwt的配置
*/
@Data
@Component
@ConfigurationProperties(prefix = "jwt")
public class JwtProperties {
private String secret;
private JwtHeader header;
private JwtPayload payload;
}
/**
* JWT工具类
*/
@Component
public class JWTUtil {
@Resource
JwtProperties jwtProperties;// 读取配置
/**
* 由字符串生成加密key
* @return
*/
private SecretKey generalKey() {
// 本地的密码解码
byte[] encodedKey = Base64.decodeBase64(jwtProperties.getSecret());
// 根据给定的字节数组使用AES加密算法构造一个密钥
SecretKey key = new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES");
return key;
}
/**
* 创建jwt
* @param subject 参数subject需要一个json格式数据,它其实就是payload部分,如果你想传给jwt的数据,可以是用户id、姓名等。
* @return
* @throws Exception
*/
public String createJWT(String subject){
// 生成JWT的时间
long nowTime = System.currentTimeMillis();
Date nowDate = new Date(nowTime);
// 生成签名的时候使用的秘钥secret,切记这个秘钥不能外露,是你服务端的私钥,在任何场景都不应该流露出去,一旦客户端得知这个secret,那就意味着客户端是可以自我签发jwt的
SecretKey key = generalKey();
ScriptEngineManager manager = new ScriptEngineManager();
ScriptEngine se = manager.getEngineByName("js");
int expireTime = 0;
try {
expireTime =(int) se.eval(jwtProperties.getPayload().getRegisterdClaims().getExp());
} catch (ScriptException e) {
e.printStackTrace();
}
// 为payload添加各种标准声明和私有声明
DefaultClaims defaultClaims = new DefaultClaims();
defaultClaims.setIssuer(jwtProperties.getPayload().getRegisterdClaims().getIss());// 设置jwt签发者
defaultClaims.setExpiration(new Date(System.currentTimeMillis() + expireTime));// 设置过期时间
defaultClaims.setSubject(subject);// 设置数据
defaultClaims.setAudience(jwtProperties.getPayload().getRegisterdClaims().getAud());// 设置jwt接收者
JwtBuilder builder = Jwts.builder() // 表示new一个JwtBuilder,设置jwt的body
.setClaims(defaultClaims)
.setIssuedAt(nowDate) // iat(issuedAt):jwt的签发时间
.signWith(SignatureAlgorithm.forName(jwtProperties.getHeader().getAlg()), key); // 设置签名,使用的是签名算法和签名使用的秘钥
return builder.compact();
}
/**
* 解密jwt
* @param jwt
* @return
* @throws Exception
*/
public Claims parseJWT(String jwt){
SecretKey key = generalKey(); // 签名秘钥,和生成的签名的秘钥一模一样
Claims claims = Jwts.parser() // 得到DefaultJwtParser
.setSigningKey(key) // 设置签名的秘钥
.parseClaimsJws(jwt).getBody(); // 设置需要解析的jwt
return claims;
}
}
添加日期工具类
/**
* 日期工具类
*/
public class DateUtil {
/**
* 获取系统当前时间
* @return 系统当前时间
*/
public static String getCurrentTime() {
Date date = new Date();
String stringDate = String.format("%tF %, date);
return stringDate;
}
}
注册逻辑(UserServiceImpl.java中)
// 用户注册
@Override
public RestResult<String> registerUser(User user) {
//判断验证码
String telephone = user.getTelephone();
String password = user.getPassword();
if (!StringUtils.hasLength(telephone) || !StringUtils.hasLength(password)){
return RestResult.fail().message("手机号或密码不能为空!");
}
if (isTelePhoneExit(telephone)){
return RestResult.fail().message("手机号已存在!");
}
String salt = UUID.randomUUID().toString().replace("-", "").substring(15);
String passwordAndSalt = password + salt;
String newPassword = DigestUtils.md5DigestAsHex(passwordAndSalt.getBytes());
user.setSalt(salt);
user.setPassword(newPassword);
user.setRegisterTime(DateUtil.getCurrentTime());
int result = userMapper.insert(user);
if (result == 1) {
return RestResult.success();
} else {
return RestResult.fail().message("注册用户失败,请检查输入信息!");
}
}
// 判断手机号是否存在
private boolean isTelePhoneExit(String telePhone) {
LambdaQueryWrapper<User> lambdaQueryWrapper = new LambdaQueryWrapper<>();
lambdaQueryWrapper.eq(User::getTelephone, telePhone);
List<User> list = userMapper.selectList(lambdaQueryWrapper);
if (list != null && !list.isEmpty()) {
return true;
} else {
return false;
}
}
// 用户登录
@Override
public RestResult<User> login(User user) {
String telephone = user.getTelephone();
String password = user.getPassword();
if (!StringUtils.hasLength(telephone) || !StringUtils.hasLength(password)){
return RestResult.fail().message("手机号或密码不能为空!");
}
LambdaQueryWrapper<User> lambdaQueryWrapper = new LambdaQueryWrapper<>();
lambdaQueryWrapper.eq(User::getTelephone, telephone);
User saveUser = userMapper.selectOne(lambdaQueryWrapper);
if(saveUser == null){
return RestResult.fail().message("该手机号用户不存在,请先注册!");
}
String salt = saveUser.getSalt();
String passwordAndSalt = password + salt;
String newPassword = DigestUtils.md5DigestAsHex(passwordAndSalt.getBytes());
if (newPassword.equals(saveUser.getPassword())) {
// 手机号和密码都正确,安全考虑,将盐和密码设置为空,返回用户信息
saveUser.setPassword("");
saveUser.setSalt("");
return RestResult.success().data(saveUser);
} else {
return RestResult.fail().message("密码错误!");
}
}
/**
* dto对象用来存放接口请求参数
*/
@Data
public class RegisterDTO {
private String username;
private String telephone;
private String password;
}
/**
* 用户注册接口
* @param registerDTO
* @return
*/
@PostMapping(value = "/register")
public RestResult<String> register(@RequestBody RegisterDTO registerDTO) {
RestResult<String> restResult = null;
User user = new User();
user.setUsername(registerDTO.getUsername());
user.setTelephone(registerDTO.getTelephone());
user.setPassword(registerDTO.getPassword());
restResult = userService.registerUser(user);
return restResult;
}
vo对象用来存放接口响应参数
/**
* vo对象用来存放接口响应参数
*/
@Data
public class LoginVO {
private String username;
private String token;
}
登录接口
/**
* 用户登录接口
* @param telephone
* @param password
* @return
*/
@Operation(summary = "用户登录",description = "用户登陆认证后才能进入系统",tags={"user"})
@GetMapping(value = "/login")
public RestResult<LoginVO> userLogin(String telephone, String password) {
// 封装参数,进行登录
User user = new User();
user.setTelephone(telephone);
user.setPassword(password);
RestResult<User> loginResult = userService.login(user);
if (!loginResult.getSuccess()) {
return RestResult.fail().message("登录失败!");
}
// 待返回vo对象
LoginVO loginVO = new LoginVO();
// 登录成功,设置响应vo对象的值,用户名和token
loginVO.setUsername(loginResult.getData().getUsername());
String jwt = "";
try {
/**
* ObjectMapper 是 Jackson 库的一部分,用于序列化和反序列化 Java 对象与 JSON 数据格式之间的相互转换。
* 在本段代码中,通过使用 ObjectMapper 的 writeValueAsString() 方法,
* 将 loginResult.getData() 对象转换成了 JSON 格式的字符串。
* 这个字符串可以用于生成 JWT token 的有效负载。
*/
ObjectMapper objectMapper = new ObjectMapper();
jwt = jwtUtil.createJWT(objectMapper.writeValueAsString(loginResult.getData()));
} catch (Exception e) {
e.printStackTrace();
return RestResult.fail().message("token签发失败!");
}
loginVO.setToken(jwt);
return RestResult.success().data(loginVO);
}
用户登录成功后,可以调用该接口来获取登录状态,判断 token 是否失效,保证前后台登录状态一致。
如果 token 不正确,就会导致解码失败,返回认证失败,如果能够正确解析,那么就会返回解析出来的对象数据,或者 token 过期,会返回用户未登录,需要重新登录。
/**
* token校验接口
* @RequestHeader注解 可以获取 HTTP 请求的请求头 Header 中的 token 属性的值,并将其赋值给方法参数 token。
* @param token
* @return
*/
@Operation(summary = "检查用户登录信息",description = "验证token的有效性",tags={"user"})
@GetMapping("/checkuserlogininfo")
public RestResult<User> checkToken(@RequestHeader("token") String token) {
User tokenUserInfo = null;
if(token == null){
return RestResult.fail().message("用户暂未登录!");
}
try {
// 固定流程操作
Claims c = jwtUtil.parseJWT(token);
String subject = c.getSubject();
ObjectMapper objectMapper = new ObjectMapper();
// 将字符串subject转换成User对象
tokenUserInfo = objectMapper.readValue(subject, User.class);
} catch (Exception e) {
log.error("解码异常,认证失败!");
return RestResult.fail().message("解码异常,认证失败!");
}
if (tokenUserInfo != null) {
return RestResult.success().data(tokenUserInfo);
} else {
return RestResult.fail().message("解码异常,认证失败!");
}
}
与手动编写接口文档不同,swagger 是一个自动生成接口文档的工具,在需求不断变更的环境下,手动编写文档的效率实在太低。与 swagger2 相比新版的 swagger3 配置更少,使用更加方便,本实验通过介绍 swagger 3,可以很方便的生成 api 文档。
Swagger 是一个 API 文档维护组织,后来成为了 Open API 标准的主要定义者,现在最新的版本为 17 年发布的 Swagger3。
Swagger 是一个规范和完整的框架,用于生成可视化 RESTful 风格的 Web 服务。
如果想要将 Swagger 文档集成到 Spring 中,目前有两个开源项目可供开发者选择,一个是 SpringFox(就是@Api那套),另一个是 SpringDoc(@Tag那套),这两个项目都是由 Spring 社区来维护的,在 Swagger 2.0 时代,SpringFox 是主流,但是随着 Swagger 3.0 版本发布之后,SpringDoc 对最新版本的兼容性更好,而且 SpringDoc 支持 Swagger 页面 Oauth2 登录,因此使用 SpringDoc 是更好的选择,下面我将主要介绍 SpringDoc 集成。
SpringDoc-OpenAPI和Knife4j都是用于生成Swagger/OpenAPI接口文档的工具包。
<dependency>
<groupId>org.springdocgroupId>
<artifactId>springdoc-openapi-uiartifactId>
<version>1.6.9version>
dependency>
/**
* OpenApi配置类
* 使用springdoc-openapi-ui
* 注意导包是io.swagger.v3.oas.models下的
*/
@Configuration
public class OpenApiConfig {
//OpenAPI bean 是在 springfox-openapi 模块中新增的,
// 用于生成 OpenAPI 3.0 规范的 API 文档。这个 bean 实际是一个包含 API 文档信息的对象。
@Bean
public OpenAPI qiwenFileOpenAPI() {
return new OpenAPI()
.info(new Info().title("网盘项目 API")
.description("基于springboot + vue 框架开发的Web文件系统,旨在为用户提供一个简单、方便的文件存储方案,能够以完善的目录结构体系,对文件进行管理 。")
.version("v1.0.0")
.license(new License().name("MIT").url("http://springdoc.org")))
.externalDocs(new ExternalDocumentation()
.description("网盘gitee地址")
.url("https://www.gitee.com/qiwen-cloud/qiwen-file"));
}
}
该注解可以用在类或方法上,当作用在方法是用来定义单个操作,当作用在类上代表所有操作。
属性 | 描述 |
---|---|
name | 标签名 |
description | 这里可以做一个简短的描述 |
externalDocs | 添加一个扩展文档 |
extensions | 可选的扩展列表 |
该注解可用于将资源方法定义为 OpenAPI 操作,在该注解中也可以定义该操作的其他属性。
属性 | 描述 |
---|---|
method | HTTP 请求方法 |
tags | 按照资源对操作进行逻辑分组 |
summary | 提供此操作的简要说明。 |
description | 对操作的详细描述 |
requestBody | 与操作关联的请求报文 |
parameters | 一个可选的参数数组 |
responses | 执行此操作返回的可能响应的列表 |
deprecated | 允许将操作标记为已弃用 |
security | 可用于此操作的安全机制的声明。 |
该注解用来定义模型,主要用来定义模型类及模型的属性,请求和响应的内容、报文头等。
属性 | 描述 |
---|---|
not | 提供用于禁止匹配属性的 java 类。 |
name | 用于描述模型类或属性的名称 |
title | 用于描述模型类的标题 |
maximum | 设置属性的最大数值。 |
minimum | 设置属性的最小数值。 |
maxLength | 设置字符串值的最大长度。 |
minLength | 设置字符串值的最大小度。 |
pattern | 值必须满足的模式。 |
required | 是否必输 |
description | 描述 |
nullable | 如果为 true 则可能为 null。 |
example | 使用示例 |
@Tag(name = "user",description = "该接口为用户接口,主要做用户登录,注册和校验token")
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {
@Resource
private UserService userService;
/**
* 用户注册接口
* @param registerDTO
* @return
*/
@Operation(summary = "用户注册",description = "注册账号",tags={"user"})
@PostMapping(value = "/register")
public RestResult<String> register(@RequestBody RegisterDTO registerDTO) {
···
}
/**
* 用户登录接口
* @param telephone
* @param password
* @return
*/
@Operation(summary = "用户登录",description = "用户登陆认证后才能进入系统",tags={"user"})
@GetMapping(value = "/login")
public RestResult<LoginVO> userLogin(String telephone, String password) {
···
}
/**
* token校验接口
* @RequestHeader注解 可以获取 HTTP 请求的请求头 Header 中的 token 属性的值,并将其赋值给方法参数 token。
* @param token
* @return
*/
@Operation(summary = "检查用户登录信息",description = "验证token的有效性",tags={"user"})
@GetMapping("/checkuserlogininfo")
public RestResult<User> checkToken(@RequestHeader("token") String token) {
···
}
}
/**
* dto对象用来存放接口请求参数
*/
@Data
@Schema(description = "注册DTO")
public class RegisterDTO {
@Schema(description = "用户名")
private String username;
@Schema(description = "手机号")
private String telephone;
@Schema(description = "密码")
private String password;
}
--------------------------------------------
/**
* vo对象用来存放接口响应参数
*/
@Data
@Schema(description = "登录VO")
public class LoginVO {
@Schema(description = "用户名")
private String username;
@Schema(description = "token")
private String token;
}
http://localhost:8080/swagger-ui.html
Knife4j
是基于 Swagger2
的增强型解决方案,旨在提供更好的 UI
和 UX
,以及更丰富的功能扩展,让 Swagger
更易于使用和管理。Knife4j
提供了一些 Swagger
原生 UI
没有的功能,例如页面主题定制、接口增删改查、RapMock 模拟测试等。同时,Knife4j
也保留了 Swagger2
的所有功能,可以完全兼容 Swagger2
规范。替换依赖
<!--使用knife4j(Swagger2的增强版)替代springdoc-openapi-->
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-spring-boot-starter</artifactId>
<version>3.0.2</version>
</dependency>
<!--配置springboot注解器-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
解决knife4j报错问题
spring:
mvc: # 解决集成knife4j报错问题
pathmatch:
#Springfox使用的路径匹配是基于AntPathMatcher的,而Spring Boot使用的是PathPatternMatcher 解决knife4j匹配boot,结果boot版本过高的问题
matching-strategy: ant_path_matcher
新的文档访问地址
http://localhost:8080/doc.html
修改配置文件
/**
* OpenApi配置类
* 使用knife4j替代springdoc-openapi-ui
*/
@Configuration
public class OpenApiConfig {
//Docket bean:定义一个 Docket 类型的 bean 对象, 用于配置 Swagger 的行为,来配置 Swagger 文档的构建和开启 Swagger 支持。
@Bean(value = "indexApi")
public Docket indexApi() {
return new Docket(DocumentationType.OAS_30)
.groupName("网站前端接口分组").apiInfo(apiInfo())
.select()
.apis(RequestHandlerSelectors.basePackage("pers.jl.controller"))// 指定controller包
.paths(PathSelectors.any())
.build();
}
//ApiInfo bean 是 springfox-swagger 模块中的一个类,用于设置 Swagger 的 API 文档信息,例如 API 名称、版本、描述。
private ApiInfo apiInfo() {
return new ApiInfoBuilder()
.title("网盘项目 API")
.description("基于springboot + vue 框架开发的Web文件系统,旨在为用户提供一个简单、方便的文件存储方案,能够以完善的目录结构体系,对文件进行管理 。")
.version("1.0")
.build();
}
}
导入依赖
<dependency>
<groupId>commons-iogroupId>
<artifactId>commons-ioartifactId>
<version>2.8.0version>
dependency>
<dependency>
<groupId>net.coobirdgroupId>
<artifactId>thumbnailatorartifactId>
<version>0.4.13version>
dependency>
<dependency>
<groupId>com.alibabagroupId>
<artifactId>fastjsonartifactId>
<version>1.2.75version>
dependency>
Spring Boot 默认会限制文件上传文件大小 2M,超过该大小的文件都会上传失败,因此需要在配置文件中修改该限制。
#修改上传下载文件大小
servlet:
multipart:
max-file-size: 100MB
max-request-size: 100MB
enabled: true
#文件存储类型
file:
storage-type: 0
下图为删除文件流程图,整个删除的过程其实只是做标记,并非真正的删除数据和删除磁盘文件,这样做的目的是为了后续回收站功能扩展。
在移动文件之前,需要对当前文件系统的目录结构进行显示,因此首先要做的就是展示当前目录树
移动文件接口的本质其实就是将保存到数据库中的虚拟路径做一个修改即可,真实的保存在磁盘上的文件是不需要做任何变动的
到此为止整个文件管理的最基本的功能就已经开发完成了,但是这仅仅只是基础,后面可以根据自己的需要,自行设计及开发。需要注意的是,目前的删除文件只是实现了文件的逻辑删除,并没有真正的从服务器删除掉,真正删除文件的操作可以在回收站中去删除,关于回收站的功能大家可以自行进行完善。