Spring Boot
1. 关于Spring Boot
Spring Boot框架主要解决了创建工程后需要进行繁琐的配置的问题,是一个“开箱即用”的框架,其核心思想是“约定大于配置”。
2. 创建Spring Boot工程
使用IntelliJ IDEA的创建向导中的Spring Initializer
即可创建Spring Boot工程。
在创建时,如果 https://start.spring.io 无响应,可尝试替换为 https://start.springboot.io。
在创建过程中,需要填写并关注的几项有:
- Group Id:组Id,通常是公司的域名倒序排列的结果,例如
cn.tedu
- Artifact Id:坐标Id,应该是此工程的名称,如果名称中有多个单词,应该使用减号分隔,例如
boot-demo
- Java Version:使用到的Java版本,目前推荐选择
8
- Package:项目的根包,默认是由以上填写的
Group Id
和Artifact Id
组成
注意:如果Artifact Id
中使用减号分隔了多个单词,在Package
中默认并没有分开,通常建议手动添加小数点(.
)进行分隔
注意:此处Package
决定了默认的组件扫描,所以,在后续开发代码时,所有的组件类都必须放在此包或其子孙包下,在开发实践中,其实会把所有创建的类、接口都放在此包或其子孙包下,不是组件的类不添加组件即可
注意:当工程已经创建出来后,不要修改包的名称,除非你已经掌握了解决方案!
在添加依赖项时,首先需要注意的就是Spring Boot的版本号,通常非常不建议使用较新的版本号,建议使用的是半年或1年之内的版本即可!本次用的2.5.6如果在创建向导的界面没有需要的版本号,可以随便选一下,当项目创建成功后,打开pom.xml
,修改<parent>
子级的<version>
节点的值即可。
当项目创建成功后,在src/main/java
下默认就存在一个包,是由创建项目时填写的Package
决定的,就是当前项目组件扫描的包,相当于默认就有了@ComponentScan("cn.tedu.boot.demo")
。
项目中默认就存在BootDemoApplication
类,此类的名称是由创建项目时填写的Artifact Id
加上Application
单词组成的,这个类名称是可以改的,这个类中有main()
方法,执行此方法就会启动整个项目,将加载项目中所有依赖所需的环境。
在src/main/resources
下默认存在application.properties
配置文件,它是项目默认会加载的配置文件。另外,Spring Boot的自动配置机制要求此处的许多配置是使用固定的属性名的!
3. 当前案例目标
客户端发出请求,最终增加管理员信息。
4. 开发数据访问层
4.1. 添加Mybatis相关依赖项
在pom.xml
中添加必要的依赖项:
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.2.2</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
4.2. 配置连接数据库的信息
当添加以上依赖项,如果启动项目(执行BootDemoApplication
类中的main()
方法)会报告错误,因为Spring Boot允许自动配置,当添加以上依赖项后,就会自动读取连接数据库的相关信息,并自动配置数据源,甚至Mybatis所需要其它基础配置,而目前并没有配置连接数据库的相关信息,所以出现错误!
则在application.properties
中添加配置:
spring.datasource.url=jdbc:mysql://localhost:3306/mall_ams?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
spring.datasource.username=root
spring.datasource.password=root
完成后,在src/test/java
下找到默认即存在的测试类,在此测试类中尝试获取数据库连接对象:
@SpringBootTest
class BootDemoApplicationTests {
@Autowired
DataSource dataSource;
@Test
void contextLoads() throws Exception {
System.out.println(dataSource.getConnection());
}
}
如果能顺利执行此测试,则表示以上配置是正确的!
4.3. 创建与数据表对应的实体类
为了简化编写POJO类,通常会在项目中添加Lombok
依赖:
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
提示:当使用了Lombok
后,应该在开发工具中安装Lombok插件,否则,在编写代码时,所有相关的Setters & Getters都没有自动提示,也会报告语法错误,但是不影响运行。
在插入数据时,需要使用实体类封装即将插入到表中的多个数据,则在cn.tedu.boot.demo
包下创建entity
子包,并在其下创建Admin
类:
@Data
public class Admin implements Serializable {
private Long id;
private String username;
private String password;
private String nickname;
private String avatar;
private String phone;
private String email;
private String description;
private Integer isEnable;
private String lastLoginIp;
private Integer loginCount;
private LocalDateTime gmtLastLogin;
private LocalDateTime gmtCreate;
private LocalDateTime gmtModified;
}
4.4. 插入管理员数据
要执行的SQL语句大致是:
insert into ams_admin (除了id以外的字段列表……) values (值列表)
则在cn.tedu.boot.demo
包下创建mapper
子包,并在其下创建AdminMapper
接口,在接口中添加抽象方法:
package cn.tedu.boot.demo.mapper;
import cn.tedu.boot.demo.entity.Admin;
import org.springframework.stereotype.Repository;
@Repository
public interface AdminMapper {
int insert(Admin admin);
}
还需要进行配置,使得Mybatis知道这些接口文件在哪里!则在cn.tedu.boot.demo
下创建config
包,并在此包下创建MybatisConfiguration
类,通过@MapperScan
配置接口文件所在的包:
package cn.tedu.boot.demo.config;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan("cn.tedu.boot.demo.mapper")
public class MybatisConfiguration {
}
提示:关于@MapperScan
注解,还可以配置在项目的启动类上(BootDemoApplication
),因为启动类上有@SpringBootApplication
注解,其元注解中有@SpringBootConfiguration
,其元注解中有@Configuration
,所以,启动类本身也是配置类!但是,如果项目中的配置较多,不建议全部写在启动类中,所以,可以分为多个配置类,独立配置。
接下来,在src/main/resources
下创建mapper
文件夹,并从前序项目中复制粘贴得到AdminMapper.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="cn.tedu.boot.demo.mapper.AdminMapper">
<!-- int insert(Admin admin); -->
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
insert into ams_admin (
username, password, nickname, avatar,
phone, email, description, is_enable,
last_login_ip, login_count, gmt_last_login, gmt_create,
gmt_modified
) values (
#{username}, #{password}, #{nickname}, #{avatar},
#{phone}, #{email}, #{description}, #{isEnable},
#{lastLoginIp}, #{loginCount}, #{gmtLastLogin}, #{gmtCreate},
#{gmtModified}
)
</insert>
</mapper>
完成后,还是应该配置这些XML文件的位置,需要在application.properties
中添加配置:
mybatis.mapper-locations=classpath:mapper/*.xml
接下来,应该通过测试检验以上代码是否可以正确运行,为了保证测试时可以正确的断言,应该在src/test
下创建resources
文件夹,并从前序项目中复制脚本文件,至少包含清空并还原数据表、插入测试数据这2个脚本文件。
然后,在src/test/java
下的cn.tedu.boot.demo
包下创建mapper
子包,并在其下创建AdminMapperTests
测试类,在类上添加@SpringBootTest
注解,在类中自动装配AdminMapper
类型的对象,并编写、执行测试:
package cn.tedu.boot.demo.mapper;
import cn.tedu.boot.demo.entity.Admin;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.jdbc.Sql;
@SpringBootTest
public class AdminMapperTests {
@Autowired
AdminMapper mapper;
// 测试插入数据是成功的
@Test
@Sql(scripts = {"classpath:truncate.sql"})
@Sql(scripts = {"classpath:truncate.sql"}, executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
public void testInsertSuccessfully() {
// 准备测试数据
String username = "admin001";
String password = "000000";
Admin admin = new Admin();
admin.setUsername(username);
admin.setPassword(password);
// 断言测试过程中不会抛出异常
Assertions.assertDoesNotThrow(() -> {
// 执行测试
int rows = mapper.insert(admin);
// 断言结果
Assertions.assertEquals(1, rows);
Assertions.assertEquals(1L, admin.getId());
});
}
}
4.5. 根据用户名查询用户数据
基于当前数据表的设计,每个管理员的“用户名”必须是唯一的,在提交增加管理员(或注册)时,必须先检查用户名是否被占用,如果被占用,将不允许增加(或注册)。判断“是否被占用”可以通过“根据用户名查询用户数据”来分析!
此部分练习请自行完成!
5. 业务逻辑层
5.1. 关于业务逻辑层
业务逻辑层是制定数据访问规则的层,此前的数据访问层只有功能,没有规则,例如执行“插入管理员数据”,则对应的方法就一定会执行,并不考虑是否合理,有关“合理”、“是否允许”等这样规则都是通过业务逻辑层来实现的。
业务逻辑层是数据访问层的调用者,通过调用相关的功能来保证规则的合理性、完整性、有效性!例如,在业务逻辑层中,可以先调用“根据用户名查询管理员信息”,再根据调用的返回值来决定是否执行“插入管理员数据”,就可以保证“每个管理员的用户名都是唯一的”这样的规则。
另外,业务逻辑层还需要考虑数据的完整性,因为在执行数据访问时,并不是所有必须的数据都会由客户端提交过来,在这样的过程中,业务逻辑层就需要补全一些数据,例如在“增加管理员”时,“是否启用”可能不会设计为客户端提交的数据,则业务逻辑层就可以补全此属性再调用数据访问进行插入数据操作。
对于一些特殊的数据,可能还需要在业务逻辑层中进行特殊的处理,以保证数据的合理性或有效性,典型的例如各用户的密码,由客户端提交过来的密码通常是明文,在业务逻辑层就应该对密码进行加密处理,并得到密文,然后再向数据库中写入。
在实际编写代码时,业务逻辑层的关键字是Service
,通常业务逻辑层的类或接口名中都有此关键字。
业务逻辑层通常有2个部分,一个是接口,另一个是此接口的实现类。
注意:强烈建议在业务逻辑层先定义接口,再编写实现类!这样做是一种基于接口编程的做法,是提倡的,并且,在后续使用基于Spring JDBC的事务管理中,也要求业务逻辑层必须有接口!
在编写业务逻辑层,所有视为“失败”的情况都应该将异常抛出,而不要处理!
5.2. 自定义异常
为了更好的在业务逻辑层表现“错误”(操作失败,例如增加管理员时,用户名已存在,即视为错误),应该自定义一些异常类型,并在处理业务逻辑的过程中,当出现错误时抛出异常!
则在cn.tedu.boot.demo
下创建ex
子包,并在其下创建ServiceException
异常类,继承自RuntimeException
,并且,至少添加带String
参数的构造方法,便于抛出异常时可以快捷封装错误的描述文本。
package cn.tedu.boot.demo.ex;
public class ServiceException extends RuntimeException {
public ServiceException() {
}
public ServiceException(String message) {
super(message);
}
}
提示:自定义的业务异常应该继承自RuntimeException
,因为当抛出RuntimeException
对象时,不需要在方法的声明上使用throws
声明抛出,并且,此方法的调用者还必须通过try...catch
或throws
解决语法问题,同时,由于业务逻辑层不适合处理异常,应该始终抛出,并且,业务逻辑层的调用者是控制器层,在Spring MVC中有统一处理异常的机制,所以在控制器中也应该是始终抛出即可,那么,对于异常的语法使用是固定的,而使用RuntimeException
就可以避免受到语法的约束!另外,在后续基于Spring JDBC的事务管理中,默认也是根据RuntimeException
进行失败的处理的!
5.3. 业务接口与抽象方法
需要自定义类型将“增加管理员”的各数据封装起来,则在cn.tedu.boot.demo
下创建dto
子包,并在其下创建AdminAddNewDTO
类,并在这个类中声明各属性:
@Data
public class AdminAddNewDTO implements Serializable {
private String username;
private String password;
private String nickname;
}
在cn.tedu.boot.demo
包下创建service
子包,并在其下创建IAdminService
接口,并在接口中声明“增加管理员”的抽象方法:
public interface IAdminService {
void addNew(AdminAddNewDTO adminAddNewDTO);
}
提示:在业务逻辑层的抽象方法中,设计返回值时,仅以操作成功为前提来设计即可,因为所有的失败都会通过抛出异常的方式来表现。
提示:关于抽象方法的参数,如果参数的数量较少,直接声明即可,如果参数数量较多,则应该封装,在封装时,应该注意“将客户端会提交的数据封装在一起,如果某些数据不是客户端提交过来的,则不要封装在一起”。
5.4. 关于SLF4j
SLF4j是一款主流的日志框架,用于在代码中添加一些输出日志的语句,最终这些日志可以输出到控制台,或文件,甚至数据库中。
在SLF4j日志框架中,会将日志的重要程度分为几个级别,常用级别中,从不重要到非常重要,依次是:
- trace:跟踪
- debug:调试
- info:一般信息(默认)
- warn:警告
- error:错误
在使用时,可以控制日志的显示级别,较低级别的将不会被显示,例如:
- 当显示级别为
info
时,只会显示info
、warn
、error
- 当显示级别为
debug
时,只会显示debug
、info
、warn
、error
- 当显示级别为
trace
时,会显示所有级别的日志
在Spring Boot项目中,在spring-boot-starter
中已经集成了日志的依赖项,是可以直接使用的!在application.properties
中添加配置,可以控制日志的显示级别,例如:
logging.level.cn.tedu.boot.demo.service.impl=info
在以上属性名中,配置的包是“根包”,例如配置为cn.tedu
时,其子孙包中都会应用此配置。
当项目中已经添加了Lombok依赖后,可以在需要输出日志的类上添加@Slf4j
注解,然后,在类中就可以使用名为log
的变量来输出日志!
输出日志的示例代码:
log.trace("输出trace级别的日志");
log.debug("输出debug级别的日志");
log.info("输出info级别的日志");
log.warn("输出warn级别的日志");
log.error("输出error级别的日志");
在开发实践中,应该根据要输出的内容的敏感程度、重要性来选择调用某个方法,以输出对应级别的日志,例如涉及关键数据的应该使用trace
或debug
级别,这样的话,当交付项目时,将设置日志显示级别的配置删除,或显式的配置为info
级别,则trace
、debug
级别的日志将不会被输出。
另外,warn
和error
级别的日志不受显示级别的限制。
关于输出日志的方法,都是被重载了多次的!如果输出的内容只是1个字符串,应该使用例如:
public void debug(String msg);
如果这个字符串中需要拼接多个变量的值,则应该使用:
public void debug(String format, Object... arguments);
使用示例如下:
log.debug("已经对密码进行加密处理,原文={},密文={}", rawPassword, encodedPassword);
以上这种做法会缓存、预编译字符串,再将值代入去执行,所以执行效率还远高于System.out.println()
的输出语句!
另外,需要注意的是,SLF4j只是一个日志框架,它提供了使用日志的标准,并没有实现输出日志的具体功能,在现行版本的Spring Boot中,还依赖了SLF4j的具体实现,默认是logback
框架。
注:下面这个红框里的命令可以将日志输出到文件中.
5.5. 业务实现
通常,会在service
包下再创建impl
子包,用于存放业务接口的实现类,并且,实现类的名称通常是“接口名(不包含首字母I
) + Impl
”。
业务实现类应该实现业务接口,并且,还应该添加@Service
注解。
所以,在cn.tedu.boot.demo.service.impl
中创建AdminServiceImpl
类,实现IAdminService
接口,在类上添加@Service
注解,并重写接口中的抽象方法:
package cn.tedu.boot.demo.service.impl;
import cn.tedu.boot.demo.dto.AdminAddNewDTO;
import cn.tedu.boot.demo.service.IAdminService;
import org.springframework.stereotype.Service;
@Service
public class AdminServiceImpl implements IAdminService {
@Override
public void addNew(AdminAddNewDTO adminAddNewDTO) {
}
}
接下来,在编写业务方法(实现接口中的抽象方法)之前,应该整理此业务的编写思路:
package cn.tedu.boot.demo.service.impl;
import cn.tedu.boot.demo.dto.AdminAddNewDTO;
import cn.tedu.boot.demo.entity.Admin;
import cn.tedu.boot.demo.ex.ServiceException;
import cn.tedu.boot.demo.mapper.AdminMapper;
import cn.tedu.boot.demo.service.IAdminService;
import cn.tedu.boot.demo.util.GlobalPasswordEncoder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
@Slf4j
@Service
public class AdminServiceImpl implements IAdminService {
// 自动装配AdminMapper
@Autowired
private AdminMapper adminMapper;
@Override
public void addNew(AdminAddNewDTO adminAddNewDTO) {
// 通过参数adminAddNewDTO中的username,调用AdminMapper的Admin getByUsername(String username)执行查询,并获取查询结果
log.debug("即将增加管理员:{}", adminAddNewDTO);
String username = adminAddNewDTO.getUsername();
Admin queryResult = adminMapper.getByUsername(username);
// 判断查询结果是否【不为null】
if (queryResult != null) {
// 是:表示用户名已经被占用,抛出ServiceException:增加管理员失败,用户名已经被占用
log.warn("增加管理员失败,用户名({})已经被占用!", username);
throw new ServiceException("增加管理员失败,用户名已经被占用!");
}
// 以参数adminAddNewDTO中的password作为明文,执行加密,得到密文密码
String rawPassword = adminAddNewDTO.getPassword();
String encodedPassword = GlobalPasswordEncoder.encode(rawPassword);
log.debug("已经对密码进行加密处理,原文={},密文={}", rawPassword, encodedPassword);
// 创建新的Admin对象
Admin admin = new Admin();
// 为Admin对象的属性赋值:username,nickname来自参数adminAddNewDTO
admin.setUsername(username);
admin.setNickname(adminAddNewDTO.getNickname());
// 为Admin对象的属性赋值:password > 密文密码
admin.setPassword(encodedPassword);
// 为Admin对象的属性赋值:avatar, phone, email, description保持为null
// 为Admin对象的属性赋值:isEnable > 1
admin.setIsEnable(1);
// 为Admin对象的属性赋值:lastLoginIp > null
// 为Admin对象的属性赋值:loginCount > 0
admin.setLoginCount(0);
// 为Admin对象的属性赋值:gmtLastLogin > null
// 为Admin对象的属性赋值:gmtCreate, gmtModified > LocalDateTime.now()
LocalDateTime now = LocalDateTime.now();
admin.setGmtCreate(now);
admin.setGmtModified(now);
// 调用AdminMapper对象的int insert(Admin admin)方法插入管理员数据,并获取返回值
log.debug("即将执行插入管理员数据:{}", admin);
int rows = adminMapper.insert(admin);
// 判断返回值是否不为1
if (rows != 1) {
// 抛出ServiceException:服务器忙,请稍后再次尝试
log.warn("服务器忙,请稍后再次尝试!");
throw new ServiceException("服务器忙,请稍后再次尝试!");
}
}
}
完成后,在src/test/java
下的cn.tedu.boot.demo
下创建service
子包,并在其下创建AdminServiceTests
测试类,编写并执行测试:
package cn.tedu.boot.demo.service;
import cn.tedu.boot.demo.dto.AdminAddNewDTO;
import cn.tedu.boot.demo.ex.ServiceException;
import cn.tedu.boot.demo.service.impl.AdminServiceImpl;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.jdbc.Sql;
@SpringBootTest
public class AdminServiceTests {
@Autowired
IAdminService service;
@Test
@Sql(scripts = {"classpath:truncate.sql"})
@Sql(scripts = {"classpath:truncate.sql"}, executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
public void testAddNewSuccessfully() {
// 测试数据
String username = "admin001";
String password = "123456";
String nickname = "管理员";
AdminAddNewDTO adminAddNewDTO = new AdminAddNewDTO()
.setUsername(username)
.setPassword(password)
.setNickname(nickname);
// 断言不会抛出异常
Assertions.assertDoesNotThrow(() -> {
// 执行测试
service.addNew(adminAddNewDTO);
});
}
@Test
@Sql(scripts = {"classpath:truncate.sql", "classpath:insert_data.sql"})
@Sql(scripts = {"classpath:truncate.sql"}, executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
public void testAddNewFailBecauseUsernameConflict() {
// 测试数据
String username = "admin001";
String password = "123456";
String nickname = "管理员";
AdminAddNewDTO adminAddNewDTO = new AdminAddNewDTO()
.setUsername(username)
.setPassword(password)
.setNickname(nickname);
// 断言不会抛出异常
Assertions.assertThrows(ServiceException.class, () -> {
// 执行测试
service.addNew(adminAddNewDTO);
});
}
}
6. 控制器
6.1. 处理依赖项
当需要开发控制器时,需要在项目中存在spring-boot-starter-web
的依赖项,此依赖项将包含此前学习时涉及的spring-webmvc
、jackson-databind
等依赖项。
在具体操作方面,并不需要追加添加这个依赖项,只需要将spring-boot-starter
改为spring-boot-starter-web
即可,并且,在spring-boot-starter-web
中也包含了spring-boot-starter
,所以,对此项目原本的依赖也不产生影响。
6.2. 简单开发
在cn.tedu.boot.demo
下创建controller
子包,并在其下创建AdminController
类,作为处理“管理员”数据相关请求的控制器类,并在这个类中处理“增加管理员”的请求:
package cn.tedu.boot.demo.controller;
import cn.tedu.boot.demo.dto.AdminAddNewDTO;
import cn.tedu.boot.demo.service.IAdminService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController
@RequestMapping("/admin")
public class AdminController {
@Autowired
private IAdminService adminService;
// http://localhost:8080/admin/add-new?username=admin001&password=1234&nickname=a001
@RequestMapping("/add-new")
public String addNew(AdminAddNewDTO adminAddNewDTO) {
adminService.addNew(adminAddNewDTO);
return "OK";
}
}
因为spring-boot-starter-web
中依赖了Tomcat,相当于每个Spring Boot工程都有一个内置的Tomcat,并且将Context Path配置为空字符串,所以在URL上并不需要添加其它路径,最后,启动项目时,就会自动打包部署此项目到内置的Tomcat上。
所以,执行BootDemoApplication
,打开浏览器,通过 http://localhost:8080/admin/add-new?username=admin001&password=1234&nickname=a001 即可增加管理员。
以上只是简单的实现了数据访问,还需要解决的问题有:
- 响应的结果不是JSON格式
- 没有处理异常
- 需要提供在线API文档
- 没有对参数的基本格式进行检查
6.3. 响应JSON格式的数据
将此前学习Spring MVC时设计的JsonResult
复制到此项目的cn.tedu.boot.demo.web
包中,并且将处理请求的方法的返回值类型改为JsonResult
类型:
// http://localhost:8080/admin/add-new?username=admin001&password=1234&nickname=a001
@RequestMapping("/add-new")
public JsonResult<Void> addNew(AdminAddNewDTO adminAddNewDTO) {
adminService.addNew(adminAddNewDTO);
return JsonResult.ok();
}
完成后,重启项目,通过正确的参数即可成功增加管理员,并且可以看到响应的结果是JSON格式的数据,例如:
{"state":20000,"message":null,"data":null}
以上数据中,message
和data
都没有数据,是多余的!可以在application.properties
中添加配置,以去除JSON数据中为null
的部分:
spring.jackson.default-property-inclusion=non_null
重启服务后,响应的JSON数据中将不再包含为null
的部分!
6.4. 处理异常
目前,在业务逻辑层抛出了2种不同原因导致的异常,异常的类型是完全相同的,会导致处理异常时,无法判断是哪种情况导致的异常,所以,应该先改造异常类,在类中添加State
属性,并要求通过构造方法传入,则每个异常对象中都会包含异常的状态码和错误时的文本描述:
package cn.tedu.boot.demo.ex;
import cn.tedu.boot.demo.web.JsonResult;
public class ServiceException extends RuntimeException {
private JsonResult.State state;
public ServiceException() {
}
public ServiceException(JsonResult.State state, String message) {
super(message);
this.state = state;
}
public JsonResult.State getState() {
return state;
}
}
由于抛出异常时既包含了状态码,又包含了错误的描述文本,在JsonResult
中还可以添加一个更加便捷的静态方法:
public static JsonResult<Void> fail(ServiceException e) {
return fail(e.getState(), e.getMessage());
}
为了保证能够对当前已分析的2种错误进行区分,应该在State
枚举中添加对应的状态码:
public enum State {
OK(20000),
ERR_CONFLICT(40900),
ERR_INTERNAL_ERROR(50000);
Integer value;
State(Integer value) {
this.value = value;
}
public Integer getValue() {
return value;
}
}
经过以上调整,原本的业务逻辑层的实现类将会报告错误,需要在创建并抛出异常时,除了传入错误的描述文本,还需要传入状态码
@Override
public void addNew(AdminAddNewDTO adminAddNewDTO) {
// 忽略此次不需要调整的代码... ...
// 判断查询结果是否【不为null】
if (queryResult != null) {
// 是:表示用户名已经被占用,抛出ServiceException:增加管理员失败,用户名已经被占用
log.warn("增加管理员失败,用户名({})已经被占用!", username);
throw new ServiceException(JsonResult.State.ERR_CONFLICT, "增加管理员失败,用户名已经被占用!");
}
// 忽略此次不需要调整的代码... ...
// 判断返回值是否不为1
if (rows != 1) {
// 抛出ServiceException:服务器忙,请稍后再次尝试
log.warn("服务器忙,请稍后再次尝试!");
throw new ServiceException(JsonResult.State.ERR_INTERNAL_ERROR, "服务器忙,请稍后再次尝试!");
}
}
在cn.tedu.boot.demo.controller
包下创建handler
子包,并在其下创建GlobalExceptionHandler
统一处理异常的类,在类上添加@RestControllerAdvice
注解,并在类中处理异常。
@RestControllerAdvicd
public class GlobalExceptionHandler {
@ExceptionHandler(ServiceException.class)
public JsonResult<Void> handleServiceException(ServiceException e) {
return JsonResult.fail(state, e.getMessage());
}
}
6.5. Knife4j – 在线API文档框架
Knife4j是国人开发一个基于Swagger2的在线API文档的框架,它可以扫描控制器所在的包,并解析每一个控制器及其内部的处理请求的方法,生成在线API文档,为前后端的开发人员的沟通提供便利。
在pom.xml
中添加依赖:
<!-- https://mvnrepository.com/artifact/com.github.xiaoymin/knife4j-spring-boot-starter -->
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-spring-boot-starter</artifactId>
<version>2.0.9</version>
</dependency>
然后,需要在application.properties
中添加配置:
knife4j.enable=true
并且,需要在cn.tedu.boot.demo.config
下创建Knife4jConfiguration
配置类:
/**
* Knife4j(Swagger2)的配置
*/
@Configuration
@EnableSwagger2WebMvc
public class Knife4jConfiguration {
/**
* 【重要】指定Controller包路径
*/
private String basePackage = "cn.tedu.boot.demo.controller";
/**
* 分组名称
*/
private String groupName = "xxx";
/**
* 主机名
*/
private String host = "xxx";
/**
* 标题
*/
private String title = "xxx";
/**
* 简介
*/
private String description = "xxx";
/**
* 服务条款URL
*/
private String termsOfServiceUrl = "http://www.apache.org/licenses/LICENSE-2.0";
/**
* 联系人
*/
private String contactName = "xxx";
/**
* 联系网址
*/
private String contactUrl = "xxx";
/**
* 联系邮箱
*/
private String contactEmail = "xxx";
/**
* 版本号
*/
private String version = "1.0.0";
@Autowired
private OpenApiExtensionResolver openApiExtensionResolver;
@Bean
public Docket docket() {
String groupName = "1.0.0";
Docket docket = new Docket(DocumentationType.SWAGGER_2)
.host(host)
.apiInfo(apiInfo())
.groupName(groupName)
.select()
.apis(RequestHandlerSelectors.basePackage(basePackage))
.paths(PathSelectors.any())
.build()
.extensions(openApiExtensionResolver.buildExtensions(groupName));
return docket;
}
private ApiInfo apiInfo() {
return new ApiInfoBuilder()
.title(title)
.description(description)
.termsOfServiceUrl(termsOfServiceUrl)
.contact(new Contact(contactName, contactUrl, contactEmail))
.version(version)
.build();
}
}
完成后,启动项目,通过 http://localhost:8080/doc.html 即可访问在线API文档。
在开发实践中,每个处理请求的方法应该限定为某1种请求方式,如果允许多种请求方式,则在API文档的菜单中会有多项。
在API文档中,菜单中的各名称默认是根据控制器类名、方法名转换得到的,通常,应该通过配置改为更加易于阅读理解的名称:
@Api
:是添加在控制器类上的注解,通过此注解的tags
属性可以修改原本显示控制器类名称的位置的文本,通常,建议在配置的tags
值上添加序号,例如:"1. 管理员模块"
、"2. 商品模块"
,则框架会根据值进行排序@ApiOperation
:是添加在控制器类中处理请求的方法上的注解,用于配置此方法处理的请求在API文档中显示的文本@ApiOperationSupport
:是添加在控制器类中处理请求的方法上的注解,通过配置其order
属性可以指定各方法在API文档中的显示顺序.建议使用两位数来区分不同类别的顺序.@ApiModelProperty
:是添加在POJO类的属性上的注解,用于对请求参数或响应结果中的某个属性进行说明,主要通过其value
属性配置描述文本,并可通过example
属性配置示例值,还可在响应结果时通过position
属性指定顺序@ApiImplicitParam
:是添加在控制器类中处理请求的方法上的注解,也可以作为@ApiImplicitParams
注解的参数值,主要用于配置非封装的参数,主要配置name
、value
、example
、required
、dataType
属性@ApiImplicitParams
:是添加在控制器类中处理请求的方法上的注解,当方法有多个非封装的参数时,在方法上添加此注解,并在注解内部通过@ApiImplicitParam
数组配置多个参数
提示:以上@ApiImplicitParams
、@ApiImplicitParam
和@ApiModelProperty
可以组合使用。
配置示例–控制器类:
@Api(tags = "1. 管理员模块")
@Slf4j
@RestController
@RequestMapping("/admin")
public class AdminController {
@ApiOperationSupport(order = 10)
@ApiOperation("增加管理员")
@PostMapping("/add-new")
public JsonResult<Void> addNew(AdminAddNewDTO adminAddNewDTO) {
throw new RuntimeException("此功能尚未实现");
}
@ApiOperationSupport(order = 40)
@ApiOperation("根据id查询管理员详情")
@ApiImplicitParam(name = "id", value = "管理员id", example = "1",
required = true, dataType = "long")
@GetMapping("/{id:[0-9]+}")
public JsonResult<Admin> getById(@PathVariable Long id) {
throw new RuntimeException("此功能尚未实现");
}
@ApiOperationSupport(order = 41)
@ApiOperation("根据角色类型查询管理员列表")
@ApiImplicitParams({
@ApiImplicitParam(name = "roleId", value = "角色id", example = "1",
required = true, dataType = "long"),
@ApiImplicitParam(name = "page", value = "页码", example = "1",
dataType = "int")
})
@GetMapping("/list-by-role")
public JsonResult<Admin> listByRole(Long roleId, Integer page) {
throw new RuntimeException("此功能尚未实现");
}
}
配置示例–封装的POJO类:
@Data
@Accessors(chain = true)
public class AdminAddNewDTO implements Serializable {
@ApiModelProperty(value = "用户名", example = "admin001")
private String username;
@ApiModelProperty(value = "密码", example = "123456")
private String password;
@ApiModelProperty(value = "昵称", example = "管理员1号")
private String nickname;
}
6.6. 检查参数的基本格式
使用spring-boot-starter-validation
即可检查请求参数的有效性,需要在pom.xml
中添加此依赖项:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
然后,在封装的POJO类的属性上配置检查相关注解,例如:
@Data
@Accessors(chain = true)
public class AdminAddNewDTO implements Serializable {
@NotNull(message = "增加管理员失败,必须提交用户名!")
private String username;
@NotNull(message = "增加管理员失败,必须提交密码!")
private String password;
@NotNull(message = "增加管理员失败,必须提交昵称!")
private String nickname;
}
然后,在处理请求的方法中,对需要检查的参数添加@Valid
或@Validated
注解,例如:
@PostMapping("/add-new")
public JsonResult<Void> addNew(@Valid AdminAddNewDTO adminAddNewDTO) {
adminService.addNew(adminAddNewDTO);
return JsonResult.ok();
}
至此,Validation框架已经生效,可以对以上请求的参数进行检查!
改端口号:server.port=8080
启动项目后,如果故意没有提交以上必要的参数,则会出现400错误,并且在IntelliJ IDEA控制台可以看到以下信息:
Resolved [org.springframework.validation.BindException: org.springframework.validation.BeanPropertyBindingResult: 3 errors<LF>Field error in object 'adminAddNewDTO' on field 'password': rejected value [null]; codes [NotNull.adminAddNewDTO.password,NotNull.password,NotNull.java.lang.String,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [adminAddNewDTO.password,password]; arguments []; default message [password]]; default message [增加管理员失败,必须提交密码!]<LF>Field error in object 'adminAddNewDTO' on field 'username': rejected value [null]; codes [NotNull.adminAddNewDTO.username,NotNull.username,NotNull.java.lang.String,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [adminAddNewDTO.username,username]; arguments []; default message [username]]; default message [增加管理员失败,必须提交用户名!]<LF>Field error in object 'adminAddNewDTO' on field 'nickname': rejected value [null]; codes [NotNull.adminAddNewDTO.nickname,NotNull.nickname,NotNull.java.lang.String,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [adminAddNewDTO.nickname,nickname]; arguments []; default message [nickname]]; default message [增加管理员失败,必须提交昵称!]]
由于Validation框架在验证不通过时会抛出BindException
,则可以使用Spring MVC统一处理异常的机制进行处理!
首先,在State
枚举中添加新的枚举值:
public enum State {
OK(20000),
ERR_BAD_REQUEST(40000),
ERR_CONFLICT(40900),
ERR_INTERNAL_ERROR(50000);
// 原有其它代码……
}
然后在GlobalExceptionHandler
中添加新的处理异常的方法:
@ExceptionHandler(BindException.class)
public JsonResult<Void> handleBindException(BindException e) {
String message = e.getBindingResult().getFieldError().getDefaultMessage();
return JsonResult.fail(JsonResult.State.ERR_BAD_REQUEST, message);
}
重启项目后,再次故意不提交某些请求参数,将响应类似以下结果:
{
"state": 40000,
"message": "增加管理员失败,必须提交用户名!"
}
提示:当验证请求参数出现多种错误时,以上语句仅会随机的显示其中1个错误的描述文本。
7. 关于Spring Boot的其它
7.1. 关于静态资源
在Spring Boot工程中,在src/main/resources
下的static
文件夹是默认存放静态资源的文件夹,如果创建工程时直接添加了spring-boot-starter-web
,则此文件夹默认已经存在,否则,可能需要自行创建。
静态资源指的是不会发生变化的资源,例如.html
、.css
、.js
、图片文件等。
在Spring Boot工程中,在静态资源文件夹下的资源是可以直接通过GET请求进行访问的,例如在static
下存入一张a.jpg
,则通过 http://localhost:8080/a.jpg 即可访问到此资源。
在静态资源中,index.html
是默认的资源,如果在static
下创建index.html
,将可以直接通过 http://localhost:8080 进行访问,与 http://localhost:8080/index.html 是完全等效的!
另外,可以通过配置指定其它位置作为静态资源文件夹:
spring.web.resources.static-locations=file:f:/static-resource
需要注意,一定通过以上配置指定了静态资源文件夹,原有的static
文件夹就不再是静态资源文件夹了,如果有必要的话,可以同时指定多个位置,例如:
spring.web.resources.static-locations=classpath:/static/, file:f:/static-resource
当指定了多个静态资源文件夹时,请务必保证每个静态资源文件夹下的子级文件夹及名称不会发生冲突!
7.2. 关于application.properties
在Spring Boot工程里,在src/main/resources
下的application.properties
是默认的配置文件,Spring Boot在加载Spring环境时会自动读取此文件。
其实,在项目的开发、测试、生产环节中,所需的配置极有可能是不同的,典型的例如数据库的配置、显示日志的级别等!为了避免频繁修改配置值,Spring框架允许采取Profile配置,即允许创建多个配置文件,并选择性的激活某个配置文件!在Spring Boot中进一步简化了Profile配置的使用,你只需要自定义新的配置文件,文件名为application-???.properties
,在这样的文件中配置不同环境中的属性。
示例:application-dev.properties
# 以下是【开发环境】下的配置信息
spring.datasource.url=jdbc:mysql://localhost:3306/mall_ams
spring.datasource.username=root
spring.datasource.password=root
logging.level.cn.tedu.boot.demo=trace
示例:application-test.properties
# 以下是【测试环境】下的配置信息
spring.datasource.url=jdbc:mysql://192.168.1.199:3306/mall_ams
spring.datasource.username=test-user
spring.datasource.password=test001
logging.level.cn.tedu.boot.demo=debug
示例:application-prod.properties
# 以下是【生产环境】下的配置信息
spring.datasource.url=jdbc:mysql://202.103.0.117:3306/mall_ams
spring.datasource.username=admin
spring.datasource.password=s3cret001
logging.level.cn.tedu.boot.demo=info
另外,原本的application.properties是默认始终加载的,所以,无论什么环境下都需要加载的配置应该配置在这里,例如:
mybatis.mapper-locations=classpath:mapper/*.xml
最后,在application.properties再添加一项配置spring.profiles.active
用于激活某个Profile配置,例如:
# 激活某个Profile配置,此属性的值就是Profile配置文件的文件名中 application- 右侧的值
spring.profiles.active=dev # 测试环境中将此值改为test,生产环境中将此值改为prod
另外,这些配置文件是可以外部化的,Spring Boot的项目最终是可以编译打包成一个独立的jar文件的,并且,在查找配置文件时,会优先从jar文件所在的目录来查找配置文件,所以,只需要将配置文件放在和jar文件同一个文件夹下即可!
7.3. 关于YAML配置文件
YAML是一种编写配置文件的语法,表现为以.yml
作为扩展名的文件,Spring Boot支持使用这种文件进行配置(如果在Spring框架中读取这种文件,需要另外添加依赖项)。
关于YAML配置,其语法特别是”分层级“,例如:
spring:
datasource:
url: jdbc:mysql://localhost:3306/mall_ams
username: root
password: root
以上配置等效于在.properties
里配置为:
spring.datasource.url=jdbc:mysql://localhost:3306/mall_ams
spring.datasource.username=root
spring.datasource.password=root
在YAML的语法中:
- 多层级的配置,相比原
.properties
使用小数点分隔,而YAML使用冒号分隔,如果分隔的右侧不是属性值,而是下一个层级,则冒号右侧回车 - 下一个层级的属性名需要右侧缩进2个空格位置,在使用IntelliJ IDEA时,会自动在编辑
.yml
时将TAB键的效果转换成2个空格 - 如果某个层级的属性名将对应属性值,则在冒号的右侧添加1个空格,然后再填写属性值
通常,在同一个工程中,不应该同时使用.properties
和.yml
,应该只使用其中的1种。
Spring Boot Security
1. 关于Spring Boot Security
Spring Boot Security是在Spring Boot中使用的,基于Spring Security的依赖项,其本质就是Spring Security框架加上了应用于Spring Boot工程的自动配置。
Spring Security是一款主要解决了认证和授权相关处理的安全框架。
认证:验证用户的身份,例如在登录过程中验证用户名与密码是否匹配。
授权:使得用户允许访问服务器端的某些资源,或禁止访问某些资源,例如管理员可以执行一些数据删除操作,而普通用户则不可以删除关键数据。
2. 添加依赖项
提示:关于此框架的案例仍使用boot-demo。
在项目的pom.xml
需要添加的依赖代码为:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
当添加此依赖后,启动项目,在访问所有资源时,都是要求先登录的(未退出之前只需要登录1次)。
默认的用户名是user
,会在启动日志中看到默认的密码,例如:
Using generated security password: 276ff4c1-32da-4e23-adad-c926f33e37c6
当登录成功后,会返回此前尝试访问的页面。
也可以在application.properties
中定义用户名和密码:
spring.security.user.name=admin
spring.security.user.password=1234
当通过以上方式自定义用户名和密码后,启动项目时将不再生成临时密码,原user
用户也不可用。
3. 使用数据库中的用户名和密码
3.1. 关于Bcrypt算法
Spring Security推荐使用Bcrypt算法来实现对密码原文的加密处理,在框架中有BcryptPasswordEncoder
类,此类可以实现加密、判断密码是否匹配等功能。
在密码加密器(PasswordEncoder)中需要关注的方法有:
// 使用原文作为参数,将返回密文
public String encode(CharSequence rawPassword);
// 使用原文作为第1个参数,使用密文作为第2个参数,将返回是否匹配
public boolean matches(CharSequence rawPassword, String encodedPassword)
提示:Bcrypt算法对于同一个原文反复做加密处理时,每次得到的密文都是不同的,是因为在加密处理过程中使用了随机盐,所以可以得到不同的密文,同时,由于盐值被保存在密文中了,所以也是可以正常验证的。
简单的使用示例:
package cn.tedu.boot.demo;
import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
public class BcryptTests {
@Test
public void testEncode() {
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
String rawPassword = "1234";
for (int i = 0; i < 10; i++) {
String encodedPassword = passwordEncoder.encode(rawPassword);
System.out.println("原密码=" + rawPassword + ",密文=" + encodedPassword);
}
}
// 原密码=1234,密文=$2a$10$lQ/BuRDZG6h1GGZLWv/Rx.TjXEJer/668SKVeoFxNs65DMKxeVAmO
// 原密码=1234,密文=$2a$10$jqB0o3.bTzdPLuWVxgfIweZKFXC9qDU15Hzf6vpUSaPifmvdQr4je
// 原密码=1234,密文=$2a$10$NJlBJLl9lp0hAA.UfsvwheborCArK58iJF96te83jbL7J4wq079H6
// 原密码=1234,密文=$2a$10$Jm6fOxg4g4mH.FZ8xXO2OOeTqHAVe6fN/6IATox0NXO1wcW3wq.Q6
// 原密码=1234,密文=$2a$10$uzmvN3IU0yx4cVSBU49jEe2de217l7u2I84NIqiSpMCF2kQefmCZe
// 原密码=1234,密文=$2a$10$EbSsOZUg7F3EMEbgXTHXxuRSbjSxOker.aTjdI1h/zz4SCRNVuDvm
// 原密码=1234,密文=$2a$10$577X/D13svsDuT9cKNSjs.M2JUeqxF7ok9uHqCBYgdqv7pqvlOEM6
// 原密码=1234,密文=$2a$10$WeuikNbKuRCkqr8JrpC75uIa6lYhJ5fuBtWGnGMpJK7MQ8EKLaICa
// 原密码=1234,密文=$2a$10$b25uEZlqDB.koP2iY.RaKueImhdVS9Aeu5fgZmDJglqtpjIXd3UN.
// 原密码=1234,密文=$2a$10$rjTE6LmSC8Z5FBm1TQpmE.OVErbLy1xpkYPlO6pP2T2BoMclr3bTu
@Test
public void testMatch() {
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
String rawPassword = "1234";
String[] encodedPasswords = {
"$2a$10$WeuikNbKuRCkqr8JrpC75uIa6lYhJ5fuBtWGnGMpJK7MQ8EKLaICa",
"$2a$10$Jm6fOxg4g4mH.FZ8xXO2OOeTqHAVe6fN/6IATox0NXO1wcW3wq.Q6",
"$2a$10$EbSsOZUg7F3EMEbgXTHXxuRSbjSxOker.aTjdI1h/zz4SCRNVuDvm"
};
for (int i = 0; i < encodedPasswords.length; i++) {
boolean result = passwordEncoder.matches(rawPassword, encodedPasswords[i]);
System.out.println("原密码=" + rawPassword + ",密文=" + encodedPasswords[i] + ",验证结果=" + result);
}
}
}
为了便于后续的操作都使用了统一的密码处理机制,先在cn.tedu.boot.demo.config
下创建SecurityConfiguration
类,并在此类中使用@Bean
方法返回BcryptPasswordEncoder
对象:
@Configuration
public class SecurityConfiguration {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
接下来,调整GlobalPasswordEncoder
,使得该类是通过自动装配的PasswordEncoder
处理加密的:
@Component
public class GlobalPasswordEncoder {
@Autowired
private PasswordEncoder passwordEncoder;
/**
* 执行加密
*
* @param rawPassword 明文密码(原文)
* @return 密文密码(加密后的结果)
*/
public String encode(String rawPassword) {
return passwordEncoder.encode(rawPassword);
}
}
接下来,应该在AdminServiceImpl
中自动装配以上GlobalPasswordEncoder
的对象,并在原加密的代码处调整为使用装配的对象来调用方法进行加密处理!最后,执行测试,并在数据表中保留至少1个有效管理员信息。
3.2. 实现:根据用户名查询管理员登录时所需的详情
为了保证在处理登录认证时能获取到必要的管理员,且不包含非必要信息,应该先梳理所需要信息,并将它们封装在自定义类,此类将作为查询的返回结果类型!
则在cn.tedu.boot.demo.vo
包中先创建PermissionSimpleVO
类,声明查询时涉及的权限信息中的属性:
@Data
public class PermissionSimpleVO implements Serializable {
private Long id;
private String name;
private String value;
}
然后,再创建AdminLoginVO
类,在类中声明必要属性:
@Data
public class AdminLoginVO implements Serializable {
private Long id;
private String username;
private String password;
private Integer isEnable;
private List<PermissionSimpleVO> permissions;
}
在编写代码之前,仍应分析需要执行的SQL语句:
select id, username, password, is_enable from ams_admin where username=?
然后 ,在AdminMapper
接口中添加抽象方法:
AdminLoginVO getLoginInfoByUsername(String username);
并在AdminMapper.xml
中配置以上抽象方法映射的SQL语句:
<select id="getLoginInfoByUsername" resultMap="LoginResultMap">
select
ams_admin.id,
ams_admin.username,
ams_admin.password,
ams_admin.is_enable,
ams_permission.id AS permission_id,
ams_permission.name AS permission_name,
ams_permission.value AS permission_value
from
ams_admin
left join ams_admin_role
on ams_admin.id=ams_admin_role.admin_id
left join ams_role_permission
on ams_admin_role.role_id=ams_role_permission.role_id
left join ams_permission
on ams_role_permission.permission_id=ams_permission.id
where
username=#{username}
</select>
<resultMap id="LoginResultMap" type="cn.tedu.boot.demo.vo.AdminLoginVO">
<id column="id" property="id" />
<result column="username" property="username" />
<result column="password" property="password" />
<result column="is_enable" property="isEnable" />
<collection property="permissions" ofType="cn.tedu.boot.demo.vo.PermissionSimpleVO">
<id column="permission_id" property="id" />
<result column="permission_name" property="name" />
<result column="permission_value" property="value" />
</collection>
</resultMap>
完成后,在AdminMapperTests
中进行测试:
@Test
// 脚本:清空,插入
// 脚本(测试之后):清空
public void testGetLoginInfoByUsernameSuccessfully() {
// 测试数据
String username = "admin001";
// 执行测试
AdminLoginVO admin = mapper.getLoginInfoByUsername(username);
// 断言查询结果不为null
Assertions.assertNotNull(admin);
}
@Test
// 脚本:清空
public void testGetLoginInfoByUsernameFailBecauseNotExist() {
// 测试数据
String username = "admin001";
// 执行测试
AdminLoginVO admin = mapper.getLoginInfoByUsername(username);
// 断言查询结果为null
Assertions.assertNull(admin);
}
当测试获取数据成功时,应该可以获取以下数据:
AdminLoginVO(
id=1,
username=admin001,
password=123456,
isEnable=0,
permissions=[
PermissionSimpleVO(id=1, name=商品-商品管理-读取, value=/pms/product/read),
PermissionSimpleVO(id=2, name=商品-商品管理-编辑, value=/pms/product/update),
PermissionSimpleVO(id=3, name=商品-商品管理-删除, value=/pms/product/delete),
PermissionSimpleVO(id=4, name=后台管理-管理员-读取, value=/ams/admin/read),
PermissionSimpleVO(id=5, name=后台管理-管理员-编辑, value=/ams/admin/update),
PermissionSimpleVO(id=6, name=后台管理-管理员-删除, value=/ams/admin/delete)
])
管理员 管理员与角色 角色 角色与权限 权限
truncate ams_admin;
truncate ams_role;
truncate ams_permission;
truncate ams_admin_role;
truncate ams_role_permission;
insert into ams_admin (username, password) values ('user001', '1234');
insert into ams_role (name) values ('商品管理员'), ('积分管理员');
insert into ams_admin_role (admin_id, role_id) values (1, 1), (1, 2);
insert into ams_permission (name, value) values ('创建商品', '/product/create'), ('删除商品', '/product/delete'), ('修改库存', '/product/stock/update'), ('查看积分', '/reward-point/show'), ('扣减积分', '/reward-point/reduce'), ('查看订单', '/order/show');
insert into ams_role_permission (role_id, permission_id) values (1, 1), (1, 2), (1, 3), (2, 4), (2, 5), (1, 6), (2, 6);
select
ams_admin.id,
ams_admin.username,
ams_admin.password,
ams_admin.is_enable,
ams_permission.id,
ams_permission.name,
ams_permission.value
from
ams_admin
left join ams_admin_role
on ams_admin.id=ams_admin_role.admin_id
left join ams_role_permission
on ams_admin_role.role_id=ams_role_permission.role_id
left join ams_permission
on ams_role_permission.permission_id=ams_permission.id
where
username='user001';
3.3. 关于UserDetailsService
在Spring Security中,定义了UserDetailsService
接口,此接口中有抽象方法:
UserDetails loadUserByUsername(String username);
Spring Security会在处理登录认证时自动根据尝试登录的用户名调用此接口实现类的此方法,并获得UserDetails
对象,此对象应该包含用户的密码、权限等信息,接下来,Spring Security会自动判断密码正确,如果不正确,将返回错误信息,如果正确,会将此用户信息(包含权限)保存下来(默认保存在Session中)。
则在cn.tedu.boot.demo
下创建security
包,并在其下创建UserDetailsServiceImpl
类,实现UserDetailsService
接口:
package cn.tedu.boot.demo.security;
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private AdminMapper adminMapper;
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
AdminLoginVO admin = adminMapper.getLoginInfoByUsername(s);
if (admin == null) {
throw new BadCredentialsException("登录失败,用户名不存在!!!!!");
}
return User.builder()
.username(admin.getUsername())
.password(admin.getPassword())
.disabled(admin.getIsEnable() == 0)
.accountExpired(false)
.accountLocked(false)
.credentialsExpired(false)
.authorities("权限待定")
.build();
}
}
完成后,Spring Boot Security会自动使用以上类来实验登录过程中的认证!所以,可以直接启动项目进行测试。
注意:在数据表中必须存在有效的管理员信息,且必须保证数据表中的密码是经过Bcrypt算法处理后的密文,且允许登录的账号的is_enable
字段的值必须是1
。
3.4. 关于授权
在处理认证过程中,需要向响应的UserDetails
对象中转入权限数据!则将UserDetailsServiceImpl
中的loadUserByUsername()
方法内部调整为:
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
log.debug("尝试登录,用户名={}", s);
AdminLoginVO admin = adminMapper.getLoginInfoByUsername(s);
log.debug("根据用户名查询的结果为:{}", admin);
if (admin == null) {
log.debug("登录失败,用户名不存在!!!!!");
throw new BadCredentialsException("登录失败,用户名不存在!!!!!");
}
// --------- 以下是处理授权的相关代码 ---------
// 将用户的权限信息转换为Spring Security要求的List<GrantedAuthority>类型的数据
// ----------------------------------------
List<PermissionSimpleVO> permissions = admin.getPermissions();
log.debug("此用户的权限信息为:{}", permissions);
List<GrantedAuthority> authorities = new ArrayList<>();
for (PermissionSimpleVO permission : permissions) {
String permissionValue = permission.getValue();
SimpleGrantedAuthority authority = new SimpleGrantedAuthority(permissionValue);
authorities.add(authority);
}
return User.builder()
.username(admin.getUsername())
.password(admin.getPassword())
.disabled(admin.getIsEnable() == 0)
.accountExpired(false)
.accountLocked(false)
.credentialsExpired(false)
.authorities(authorities) // 将以上得到的用户权限信息封装到UserDetails对象中
.build();
}
关于Spring Security的配置类:
package cn.tedu.boot.demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true) // 添加此注解才可以在控制器方法上配置权限
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// 提示:本方法中的配置可以一次性写完,每写一段都可以调用 and() 方法再拼接下一段配置
// 关闭跨域
http.csrf().disable();
// authorizeRequests():需要对请求进行认证和授权
// antMatchers():匹配某些路径,取值可以是字符串数组表示的URL,每个URL必须使用 / 作为第1个字符
// permitAll():许可所有(不需要认证授权即可访问),此方法之前必须是先匹配了路径的
// anyRequest():任何请求(除开以前已经配置的请求)
// authenticated():已经认证的(要求已经登录)
String[] urls = {
"/favicon.ico",
"/doc.html",
"/**/*.js",
"/**/*.css",
"/swagger-resources",
"/v2/api-docs"
};
http.authorizeRequests()
.antMatchers(urls).permitAll()
.anyRequest().authenticated();
// 当需要登录时,启用表单登录
http.formLogin();
}
}
当需要控制权限时,可以在控制器类中处理请求的方法上添加注解,并配置需要哪个权限值,例如:
@GetMapping("/{id:[0-9]+}")
@PreAuthorize("hasAuthority('/ams/admin/read')") // 要求权限中具有 /ams/admin/read 这一条才允许访问此资源
public JsonResult<Admin> getById(@PathVariable Long id) {
throw new RuntimeException("此功能尚未实现");
}
提示:以上做法可适用于通过Knife4j的调试功能发出的GET请求,如果需要测试POST请求,可能会出现403错误,则应该通过浏览器的调试面板中的“网络”来观察当前的Session ID,例如JSESSIONID=07C874111475B2CD1A41E64EA268059E
,然后,在Knife4j的API文档中,在“文档管理”的“全局参数设置”中添加名为Cookie
的参数,值为例如JSESSIONID=07C874111475B2CD1A41E64EA268059E
,参数类型为header
,则通过Knife4j进行调试时,会自动携带此信息,需要注意的是,如果重新登录、服务器端重启等原因导致Session ID发生变化,在Knife4j调试之前也应该更换Knife4j中配置的Session ID。
提示:为了保证较好的演示效果,请事先准备好数据表中的数据。
当某个已经登录的用户无权限访问资源时,将响应**403(Forbidden)**错误。
第1次响应时
Set-Cookie
JSESSIONID=FDED4F9C1FA8020FA16FF7BF4D07587B
Set-Cookie
JSESSIONID=07C874111475B2CD1A41E64EA268059E
后续请求时
Cookie
JSESSIONID=FDED4F9C1FA8020FA16FF7BF4D07587B
附:关于Session
HTTP协议是一种无状态协议,同一个用户在同一台设备上多次对同一个服务器端进行访问时,默认在服务器端并不保存此用户的相关信息,所以,无论访问多少次,服务器端都无法识别用户的身份!
为了解决此问题,最简单直接的方式是使用Session(会话),当某个客户端第1次向服务器端发送请求后,服务器端会在服务器端的内存中保存此用户的信息,并且此信息会关联到一个唯一的Session ID,当服务器端进行响应时,会将此Session ID响应到客户端,后续,客户端应该在每次请求时都携带此Session ID,则服务器可以根据后续请求头中的Session ID对应到此前保存的数据,从而识别用户的身份!
关于使用Session保存的数据,通常是:
- 用户身份的唯一标识,例如用户的id
- 高频率使用的数据,例如用户的权限
- 可能存在数据不一致的风险
- 不便于使用其它存储技术进行处理的数据
关于Session消失的机制:
- 超时,如果某客户端长时间未向服务器端发起任何请求,则在服务器端上,此客户端对应的Session数据会被清除,常见的设置值是15分钟或30分钟
- 更换客户端(包括更换浏览器、关开浏览器),也会无法访问此前的Session数据,并且,此前的Session数据将会根据超时机制被清理
- 服务器端设备关机或重启
- 服务器端程序调用了清除Session数据的方法,例如调用了
HttpSession
对象的invalidate()
方法
附:关于密码加密
基本原则:存储到数据库中的密码必须加密处理!并且,必须使用不可逆的算法!
所有的哈希算法都是不可逆的,其中,消息摘要算法都属于哈希算法!典型的消息摘要算法包括MD系列的和SHA家族的算法!
由于消息摘要算法的摘要结果长度是固定的,所以,在用于加密时,如果没有做进一步处理,可能会被穷举的方式破解(破解方列举出所有可能的密码与摘要,通过查询的方式,根据密文找出原文)。
为了保障密码安全,基础的加强操作有:
-
尽可能的要求用户使用长度更长的密码,并使用安全强度更高(有更多的字符组合)的密码
-
多重加密
-
加盐
-
盐可以随心所欲去加.
-
使用更加安全的算法(例如从MD5升级为SHA256甚至SHA512)
-
综合以上做法
甚至,在使用盐时,还可以使用随机的盐值,但是,需要注意,如果使用了随机盐值,则这个随机盐值必须被记录下来(可以作为最终密文的一部分,或者在数据表中使用单独的字段存储等),否则后续将无法正确的验证密码!
附:注解(非常重要)
注解 | 所属框架 | 重要程度 | 应用位置 | 说明 |
---|---|---|---|---|
@Component | Spring | ★★★★★ | 组件类 | 添加此注解类将被视为“组件”,当Spring执行组件扫描时,如果发现组件类,就会自动创建类的对象 |
@Controller | Spring | ★★★★★ | 控制器类 | 特定的组件:控制器,是以@Component 作为元注解的,在基于Spring MVC的框架中,控制器类必须添加此注解,或以此为元注解的其它注解 |
@Service | Spring | ★★★★★ | 业务类 | 特定的组件:业务类,是以@Component 作为元注解的 |
@Repository | Spring | ★★★★★ | 数据访问类 | 特定的组件:数据访问类,是以@Component 作为元注解的 |
@ComponentScan | Spring | ★★★★ | 配置类 | 添加此注解后,当此类被加载时,Spring就会执行组件扫描,扫描的是此注解配置的包及其子孙包,包中的类如果添加了组件相关注解,则Spring会自动创建这些添加了组件注解的类的对象,在Spring Boot项目中,组件扫描默认的根包就是创建项目时得到包,此注解还是Spring Boot中@SpringBootApplication 的元注解 |
@Configuration | Spring | ★★★★★ | 配置类 | 添加此注解的类会被视为配置类,在Spring框架中可以使用ApplicationContext直接加载,使类中的配置项生效,或在集成框架中,只要配置类在组件扫描范围内,此类中的配置项即可生效 |
@Autowired | Spring | ★★★★★ | 属性,Setter方法,构造方法 | 当添加在属性上,Spring会自动从容器中找到合适的对象为此属性注入值,当添加在Setter方法上或构造方法上,Spring会自动调用对应的方法 |
@Qualifier | Spring | ★★★★ | 属性,方法参数 | 当使用@Autowired 自动装配时,如果存在多个匹配类型的对象,且根据名称无法装配时,可以使用此注解指定名称 |
@PropertySource | Spring | ★★★★ | 配置类 | 用于指定需要读取的.properties 配置文件,当读取配置文件后,会将数据注入到Spring内置的Environment 对象中 |
@Value | Spring | ★★★★★ | 属性,方法的参数 | 主要用于配置读取Environment 数据的表达式,使得Spring为属性、方法的参数注入值 |
@Bean | Spring | ★★★★★ | 配置类中返回对象的方法 | 使得Spring自动调用此方法,并将方法返回的对象保存在Spring容器中 |
@Scope | Spring | ★★ | 组件类 | 配置此类的对象是否为单例的 |
@Lazy | Spring | ★★ | 组件类 | 当组件类的对象将是单例的,配置它是否为懒加载 |
@PostConstruct | javax | ★★ | 组件类的方法 | 标记此方法是“初始化”的生命周期方法,Spring会在实例化对象后自动调用此方法 |
@PreDestroy | java | ★★ | 组件类的方法 | 标记此方法是“销毁”的生命周期方法,Spring会在销毁对象之前自动调用此方法 |
@Resource | javax | ★★ | 属性 | 用于自动装配,从执行效果上,一定程度可以等效于@Autowired |
@MapperScan | Mybatis | ★★★★★ | 配置类 | 配置Mybatis接口所在的根包,使得Mybatis可以创建这些接口的代理对象 |
@Mapper | Mybatis | ★★★ | 数据访问接口 | 用于指定哪些接口是Mybatis需要创建代理对象的,不与@MapperScan 同时使用 |
@Param | Mybatis | ★★★★★ | 方法参数 | 当Mapper接口中抽象方法的参数超过1个时,应该添加此注解,用于配置参数名称,后续,在SQL中#{} 占位符中的名称就是此注解配置的名称 |
@Insert | Mybatis | ★★ | 抽象方法 | 配置Mapper接口中抽象方法映射的SQL语句 |
@Delete | Mybatis | ★★ | 抽象方法 | 配置Mapper接口中抽象方法映射的SQL语句 |
@Update | Mybatis | ★★ | 抽象方法 | 配置Mapper接口中抽象方法映射的SQL语句 |
@Select | Mybatis | ★★ | 抽象方法 | 配置Mapper接口中抽象方法映射的SQL语句 |
@Test | JUnit | ★★★★★ | 测试方法 | 标识此方法是一个JUnit测试方法 |
@Sql | Spring Test | ★★★★★ | 测试类,测试方法 | 用于配置需要执行的SQL脚本,及执行阶段,当测试类和测试方法上都添加了此注解,则测试方法上的注解会覆盖测试类上的注解 |
@SpringJUnitConfig | Spring Test | ★★★★ | 测试类 | 用于指定Spring的配置类,使得当前测试类在执行测试方法之前会加载Spring环境,则在测试类中可以使用任何Spring的机制,例如自动装配等 |
@ResponseBody | Spring MVC | ★★★ | 控制器类,处理请求的方法 | 当在控制器类上添加此注解,则类中所有处理请求的方法都将响应正文,当在控制器类中处理请求的方法上添加此注解,则此方法将响应正文。此注解是@RestController 、@RestControllerAdvice 的元注解 |
@RestController | Spring MVC | ★★★★★ | 控制器类 | 标记当前类是控制器类,且类中处理请求的方法均响应正文 |
@RequestMapping | Spring MVC | ★★★★★ | 控制器类,处理请求的方法 | 当在控制器类上添加此注解,通常用于配置请求路径的前缀,也可配置一些其它的参数,当在处理请求的方法上添加此注解,通常用于配置请求路径(在类的上配置的路径基础之上),也可以配置其它参数,例如限制请求方式 |
@GetMapping | Spring MVC | ★★★★★ | 处理请求的方法 | 相当于@RequestMapping(method = RequestMethod.GET) |
@PostMapping | Spring MVC | ★★★★★ | 处理请求的方法 | 相当于@RequestMapping(method = RequestMethod.POST) |
@PutMapping | Spring MVC | ★★ | 处理请求的方法 | 相当于@RequestMapping(method = RequestMethod.PUT) |
@DeleteMapping | Spring MVC | ★★ | 处理请求的方法 | 相当于@RequestMapping(method = RequestMethod.DELETE) |
@PathVariable | Spring MVC | ★★★★★ | 处理请求的方法的参数 | 读取在URL中使用{} 格式进行占位的参数值 |
@RequestParam | Spring MVC | ★★★ | 处理请求的方法的参数 | 可以配置此参数的名称、是否必须、默认值 |
@RequestBody | Spring MVC | ★★★★ | 处理请求的方法的参数 | 标记此参数是在请求体中获取的数据,可支持客户端通过JSON格式提交请求参数 |
@ExceptionHandler | Spring MVC | ★★★★★ | 处理异常的方法 | 标记此方法是统一处理异常的方法 |
@ControllerAdvice | Spring MVC | ★★★ | 类 | Spring MVC会在每次处理请求时按需调用此类中的方法,例如,处理请求时抛出异常,则会调用此类中处理异常的方法 |
@RestControllerAdvice | Spring MVC | ★★★★★ | 类 | 标记此类是响应正文的且具有@ControllerAdvice 效果的 |
@EnableWebMvc | Spring MVC | ★★★ | Spring MVC的配置类 | 开启Spring MVC的增强模式,例如响应JSON格式的正文时需要开启,在Spring Boot中默认已开启,无需显式使用此注解 |
@SpringBootConfiguration | Spring Boot | ★★★ | 类 | 标记此类是Spring Boot的配置类 |
@SpringBootApplication | Spring Boot | ★★★★★ | 类 | 标记此类是Spring Boot的应用程序类,也是启动类,也是配置类,每个Spring Boot工程中只能有1个类添加此注解 |
@SpringBootTest | Spring Boot | ★★★★★ | 测试类 | 标记此类是Spring Boot的测试类,会自动加载项目中的环境,例如Spring环境等 |
@Data | Lombok | ★★★★★ | POJO类 | 将在编译期干预,生成Setters & Getters,hashCode() 、equals() 、toString() |
@Accessors | Lombok | ★★★ | POJO类 | 将Setters调整为返回当前对象 |
@Slf4j | Lombok | ★★★★★ | 类 | 将在编译期干预,将声明并实例化一个名为log 的变量,则在此类中可以使用log 变量输出日志 |