赞
踩
day01
1. 安装Vue CLI
Vue CLI是Vue框架的客户端工具,创建Vue项目、运行Vue项目都需要事先安装此工具。
安装Vue CLI的命令:
npm install -g @vue/cli
以上命令执行完后,只要没有提示错误(Err或Error字样),即可视为成功!
当Vue CLI安装完成后,可以通过以下命令查看版本号并检查是否安装成功:
vue -V
2. 创建项目
在命令提示符窗口中,执行vue create 项目名称的命令,就可以创建项目,创建出来的项目会在命令提示符窗口中提示的位置(即:敲命令时左侧提示的位置)。
例如:创建jsd2206-csmall-web-client-teacher项目:
vue create jsd2206-csmall-web-client-teacher
需要注意:执行以上命令后,会有一点卡顿,此时不要反复按回车,接下来,需要选择创建选项,分别是:
Manually select features
Babel / Vuex / Router
2.x
直接回车
In package.json
直接回车
当创建完成后,可以使用IntelliJ IDEA打开此项目,并且,在IntelliJ IDEA的Terminal(终端)面板中,可以执行启动项目的命令:
npm run serve
3. 关于Vue脚手架项目
Vue脚手架项目是一个单页面的应用,即整个项目中只有1个html页面,它认为这个页面是由若干个视图组合而成的,每个视图都只是该页面中的一个部分,并且,都是可以被替换的!
项目的文件夹结构:
[.idea]:仅当使用IntelliJ IDEA打开此项目后,才会有这个文件夹,是IntelliJ IDEA管理项目时使用的,无需关注此文件
[node_modules]:此项目中使用的各个库的文件,注意:通常,提交到GIT的项目代码中并不包含此文件夹,需要先执行npm install命令,则会自动下载此项目中的各个库的文件,然后才可以运行项目
[public]:此项目的静态资源文件夹,通常用于存放图片、自行编写的js、自行编写的css等文件,此文件夹下的资源的访问路径都是根路径
public/favicon.ico:此项目的图标文件,此文件必须在这个位置,且必须是这个文件名
public/index.html:此项目中唯一的html文件,也是项目打开页面的入口
[src]:源文件的文件夹
[src/assets]:资源文件夹,此处的资源文件应该是不随程序运行而发生变化的
[src/components]:视图组件文件夹,此文件夹下的视图组件通常是被视为封装的视图,且将会被其它视图调用
[src/router]:此项目中配置路径的文件所在的文件夹
src/router/index.js:默认的路由配置文件
[src/stroe]:此项目的配置全局共享变量的文件所在的文件夹
src/store/index.js:默认的配置全局共享变量的文件,此处声明的变量,在任何一个视图组件中均可使用
[views]:一般的视图组件所在的文件夹
src/App.vue:默认绑定到index.html中的<div id="app"></div>的视图组件,可简单理解为页面的入口,此视图组件不需要配置路由,默认就会显示
src/main.js:此项目的主配置文件,通常,在项目中安装了软件之后,都需要在此文件中补充配置
.gitignore:使用GIT时的忽略文件清单,即:用于配置哪些文件不会提交到Git
package.json:项目的配置文件,例如配置了此项目的依赖项等
package-lock.json:锁定的配置文件,不需要,也不建议手动修改此文件中的任何内容
4. 关于视图组件
在Vue脚手架项目中,以.vue为作文件名后缀的,就是视图组件!
在视图组件中,源代码主要有3个部分:
<template>:设计界面的源代码部分,此标签下可以使用HTML或相关技术(例如Element UI)来设计界面
注意:在<template>标签下,只能有1个直接子标签!
<script>:编写JavaScript代码
<style>:编写CSS代码
在设计界面时,可以使用<router-view/>表示此视图组件不确定的内容!例如在App.vue中就使用了这个标签,此标签将显示的内容取决于URL(地址栏中的网址)。
5. 路由
在Vue脚手架项目中,使用“路由”来配置URL与视图组件的对应关系。
通过src/router/index.js可以配置路由。
核心代码是:
import HomeView from '../views/HomeView.vue'
const routes = [
{
path: '/',
name: 'home',
component: HomeView
},
{
path: '/about',
name: 'about',
component: () => import('../views/AboutView.vue')
}
]
以上配置中,path表示路径,name表示名称,可以不必配置,component表示视图组件。
关于component的值,可以使用静态导入的方式来确定,例如HomeView,也可以使用import()函数导入,例如以上关于/about的配置。
通常,在每个项目中,只有1个视图组件是静态导入的。
6. 安装Element UI
在终端中执行以下命令安装Element UI:
npm i element-ui -S
安装完成后,需要在src/main.js中添加配置:
import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
Vue.use(ElementUI);
至此,在项目中的任何一个视图组件中都可以直接使用Element UI,不需要额外的声明或引用!
7. 安装axios
在终端执行安装axios的命令:
npm i axios -S
安装完成后,在main.js中添加配置:
import axios from 'axios';
Vue.prototype.axios = axios;
在Vue CLI项目中,使用axios时,在then()的回调内部,不可以使用匿名函数,必须使用箭头函数,例如:
this.axios.post(url, data).then((response) => {
});
完整代码示例:
this.axios.post(url, this.ruleForm).then((response) => {
// console.log(response);
if (response.data == 1) {
// console.log('登录成功!');
this.$message({
message: '登录成功!',
type: 'success'
});
} else if (response.data == 2) {
// console.log('登录失败,用户名错误!');
this.$message.error('登录失败,用户名错误!');
} else {
// console.log('登录失败,密码错误!');
this.$message.error('登录失败,密码错误!');
}
});
8. 关于跨域问题
默认情况下,不允许向别的服务提交异步请求,例如,在http://localhost:9000服务上,向http://localhost:8080提交异步请求,这是不允许的!
在基于Spring Boot的项目中,要允许跨域访问,可以在启动类上实现WebMvcConfigurer接口,并重写addCorsMappings()方法:
@ServletComponentScan
@SpringBootApplication
public class CoolsharkApplication implements WebMvcConfigurer {
public static void main(String[] args) {
SpringApplication.run(CoolsharkApplication.class, args);
}
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowCredentials(true)
.allowedHeaders("*")
.allowedMethods("*")
.allowedOriginPatterns("*")
.maxAge(3600);
}
}
关于后端服务中处理登录请求的相关代码:
@RequestMapping("/login")
public int login(@RequestBody User user, HttpSession session, HttpServletResponse response){
System.out.println("客户端提交的用户信息:" + user);
User u = mapper.selectByUsername(user.getUsername());
if (u!=null){
if (u.getPassword().equals(user.getPassword())){
if (user.getRem() != null && user.getRem() == true){
Cookie c1 = new Cookie("username",user.getUsername());
Cookie c2 = new Cookie("password",user.getPassword());
response.addCookie(c1);
response.addCookie(c2);
}
//把登录成功的用户对象保存到会话里面
session.setAttribute("user",u);
System.out.println("登录成功");
return 1;
} else {
System.out.println("密码错误");
return 3;
}
} else {
System.out.println("用户名错误");
return 2;
}
}
day02
1. 嵌套路由
当某个显示在<router-view/>位置的视图组件中也设计了<router-view/>,则出现了<router-view/>的嵌套,在配置路由时,需要使用嵌套路由!
在配置router/index.js中的routes数组时,数组元素即是一个个的路由对象,这些路由对象都是应用于App.vue中的<router-view/>的!如果需要某个视图显示在另一个视图的<router-view/>中(例如添加相册的视图组件需要显示到HomeView的<router-view/>中),需在HomeView的路由对象中配置children属性,这个children属性的配置方法与routes完全相同!
#
1. 创建项目
创建项目的参数如下:
![image-20220921141445816](C:\Users\TEDU\Desktop\每日笔记\成恒笔记\images\DAY02\image-20220921141445816.png)
提示:如果创建项目时的URL不可用,可以尝试在 https://start.spring.io 和 https://start.springboot.io 之间切换。
创建过程中,可以不勾选任何依赖项(后续添加的效果也是相同的)。
2. 创建数据库与数据表
创建mall_pms数据库:
create database mall_pms;
在IntelliJ IDEA中,展开右侧的Database面板,选择New > Data Source > MySQL / MariaDB,并在弹出的窗口中配置:
![image-20220921142606614](C:\Users\TEDU\Desktop\每日笔记\成恒笔记\images\DAY02\image-20220921142606614.png)
然后,将mall_pms_jsd2206.sql的代码全部复制到Console中,并全选、执行,即可创建数据表。
3. 调整pom.xml
建议将父级项目(spring-boot-starter-parent)的版本调整为2.5.9或其它2.5.x系列的版本号。
4. 关于数据库编程
Java语言是通过JDBC技术实现数据库编程的,但是,JDBC技术的应用相对比较繁琐,且编码步骤相对固定,所以,通常使用框架技术来实现,这些框架技术大多可以简化JDBC编程,使得实现数据库编程的代码更加简洁。
常见的数据库编程框架有:Mybatis(主流)、Spring Data JPA、Hibernate等。
5. 使用Mybatis框架
使用Mybatis框架之前,需要添加相关依赖项:
<!-- Mybatis整合Spring Boot的依赖项 -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.2.2</version>
</dependency>
<!-- MySQL的依赖项 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
在Spring Boot项目中,当添加了数据库编程的依赖项后,启动项目时,会自动读取连接数据库的配置参数值,如果没有配置,则会启动失败,例如:
***************************
APPLICATION FAILED TO START
***************************
Description:
Failed to configure a DataSource: 'url' attribute is not specified and no embedded datasource could be configured.
Reason: Failed to determine a suitable driver class
在Spring Boot项目中,在src/main/resources下默认已经存在application.properties配置文件,Spring Boot项目在启动时会自动读取此文件中的配置信息,如果配置信息中的属性名是特定的,Spring Boot还会自动应用这些属性值。
则在application.properties中添加配置
# 连接数据库的配置
spring.datasource.url=jdbc:mysql://localhost:3306/mall_pms?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
spring.datasource.username=root
spring.datasource.password=root
找到项目中默认已经创建出来的测试类,在其中添加测试连接数据库的方法,并执行:
package cn.tedu.csmall.product;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import javax.sql.DataSource;
@SpringBootTest
class CsmallProductApplicationTests {
@Test
void contextLoads() {
}
@Autowired
DataSource dataSource;
@Test
void testConnection() throws Throwable {
dataSource.getConnection();
System.out.println("连接数据库成功!");
}
}
6. 关于SQL语句
以pms_album(相册)表为例。
插入数据的SQL语句大致是:
INSERT INTO pms_album (
name, description, sort, gmt_create, gmt_modified
) VALUES (
'华为Mate50的相册', '暂无简介', 200, null, null
);
批量插入数据的SQL语句大致是:
INSERT INTO pms_album
(name, description, sort, gmt_create, gmt_modified)
VALUES
('华为Mate50的相册', '暂无简介', 200, null, null),
('华为Mate40的相册', '暂无简介', 200, null, null),
('华为Mate30的相册', '暂无简介', 200, null, null);
删除数据的SQL语句大致是:
DELETE FROM pms_album WHERE id=1;
批量删除数据的SQL语句大致是:
DELETE FROM pms_album WHERE id=1 OR id=3 OR id=5;
DELETE FROM pms_album WHERE id IN (1,3,5);
更新数据的SQL语句大致是:
UPDATE pms_album SET name='新的名称', description='新的简介' WHERE id=1;
统计查询的SQL语句大致是(例如:查询表中的数据的数量):
SELECT count(*) FROM pms_album;
根据id查询数据详情的SQL语句大致是:
SELECT id, name, description, sort, gmt_craete, gmt_modified
FROM pms_album
WHERE id=1;
查询(所有)数据的列表的SQL语句大致是:
SELECT id, name, description, sort, gmt_craete, gmt_modified
FROM pms_album
ORDER BY id;
day03
1. 关于实体类
实体类是POJO的其中一种。
POJO:Plain Ordinary Java Object,简单的Java对象。
在项目中,如果某个类的作用就是声明若干个属性,并且添加Setter & Getter方法等,并不编写其它的功能性代码,这样的类都称之POJO,用于表示项目中需要处理的数据。
以pms_album为例,这张数据表应该有与之对应的实体类,在数据表中的字段类型与Java中的属性的数据类型的对应关系是:
MySQL中的数据类型 Java中的数据类型
tinyint / smallint / int Integer
bigint Long
char / varchar / text系列 String
datetime LocalDateTime
decimal BigDecimal
关于POJO类,其编写规范是:
所有属性都应该是私有的
所有属性都应该有对应的、规范名称的Setter、Getter方法
必须重写equals()和hashCode(),并保证:
如果两个对象的各属性值完全相同,则equals()对比结果为true,且hashCode()值相同
如果两个对象存在属性值不同的,则equals()对比结果为false,且hashCode()值不同
如果两个对象的hashCode()相同,则equals()对比结果必须为true
如果两个对象的hashCode()不同,则equals()对比结果必须为false
必须实现Serializable接口
建议重写toString()方法,输出各属性的值
在项目中使用Lombok框架,可以实现:添加注解,即可使得Lombok在项目的编译期自动生成一些代码(例如Setter & Getter)。
关于Lombok框架的依赖项:
<!-- Lombok的依赖项,主要用于简化POJO类的编写 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.20</version>
<scope>provided</scope>
</dependency>
在POJO类上添加Lombok框架的@Data注解,可以在编译期生成:
规范的Setter & Getter
规范的hashCode()与equals()
包含各属性与值的toString()
则在项目的根包下创建pojo.entity.Album类为:
package cn.tedu.csmall.product.pojo.entity;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 相册
*
* @author java@tedu.cn
* @version 0.0.1
*/
@Data
public class Album implements Serializable {
/**
* 记录id
*/
private Long id;
/**
* 相册名称
*/
private String name;
/**
* 相册简介
*/
private String description;
/**
* 自定义排序序号
*/
private Integer sort;
/**
* 数据创建时间
*/
private LocalDateTime gmtCreate;
/**
* 数据最后修改时间
*/
private LocalDateTime gmtModified;
}
注意:当使用了Lombok后,由于源代码中并没有Setter & Getter方法,所以,当编写代码时,IntelliJ IDEA不会提示相关方法,并且,即使强行输入调用这些方法的代码,还会报错,但是,并不影响项目的运行!为了解决此问题,强烈推荐安装Lombok插件!
2. 通过Mybatis实现数据库编程
2.1. 关于Mybatis框架
Mybatis是目前主流的解决数据库编程相关问题的框架,主要是简化了数据库编程。
Mybatis框架的基础依赖项的artifactId是:mybatis。
Mybatis框架虽然可以不依赖于Spring等其它框架,但是,直接使用比较麻烦,需要自行编写大量的配置,所以,通常结合Spring一起使用,需要添加的依赖项的artifactId是:mybatis-spring。
在Spring Boot项目中,直接添加mybatis-spring-boot-starter将包含以上依赖项,和其它必要的、常用的依赖项。
Mybatis框架简化数据库编程的表现为:你只需要定义访问数据的抽象方法,并配置此抽象方法映射的SQL语句即可!
2.2. 关于抽象方法
使用Mybatis框架时,访问数据的抽象方法必须定义在接口中!因为Mybatis框架是通过“接口代理”的设计模式,生成了接口的实现对象!
关于Mybatis的抽象方法所在的接口,通常使用Mapper作为名称的最后一个单词!
则可以在项目的根包下创建mapper.AlbumMapper接口,例如:
public interface AlbumMapper {
}
关于抽象方法:
返回值类型:如果要执行的SQL操作是增、删、改类型的,使用int作为返回值类型,表示“受影响的行数”,不建议使用void,如果要执行的SQL操作是查询类型的,只需要保证返回值类型可以封装必要的结果即可
方法名称:自定义的,但推荐遵循规范,不要使用重载
参数列表:取决于需要执行的SQL语句需要哪些参数,在抽象方法中,可以将这些参数一一列举出来,也可以将这些参数封装到自定义类中,使用自定义类作为抽象方法的参数
抛出异常:无
关于抽象方法命名参考(来自《阿里巴巴Java开发手册》):
获取单个对象的方法用 get 做前缀
获取多个对象的方法用 list 做前缀
获取统计值的方法用 count 做前缀
插入的方法用 save / insert 做前缀
删除的方法用 remove / delete 做前缀
修改的方法用 update 做前缀。
例如:插入相册的抽象方法可以设计为:
int insert(Album album);
另外,还需要使得Mybatis框架能明确这个接口是数据访问接口,可以采取的做法有:
【不推荐】在接口上添加@Mapper注解
每个数据访问接口上都需要此注解
【推荐】在配置类上添加@MapperScan注解,并配置数据访问接口所在的包
在根包(含子孙包)下的任何添加了@Configuration注解的类都是配置类
只需要一次配置,各数据访问接口不必添加@Mapper注解
则在根包下创建config.MybatisConfiguration类,配置@MapperScan:
package cn.tedu.csmall.product.config;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
/**
* Mybatis的配置类
*
* @author java@tedu.cn
* @version 0.0.1
*/
@Configuration
@MapperScan("cn.tedu.csmall.product.mapper")
public class MybatisConfiguration {
public MybatisConfiguration() {
System.out.println("创建配置类:MybatisConfiguration");
}
}
2.3. 关于配置SQL语句
在Spring Boot中,整合了Mybatis框架后,可以在数据访问接口的抽象方法上使用@Insert等注解来配置SQL语句,这种做法是不推荐的!
提示:在不是Spring Boot项目中,需要额外的配置,否则,将不识别抽象方法上的@Insert注解。
不推荐使用@Insert等注解配置SQL语句的主要原因有:
长篇的SQL语句不易于阅读
不便于实现特殊的配置
部分配置不易于复用
不便于实现与DBA(Database Administrator)协作
建议使用XML文件来配置SQL语句,这类XML文件需要有固定的、特殊的声明部分,推荐通过复制粘贴得到此文件,或从 http://doc.canglaoshi.org/config/Mapper.xml.zip 下载得到。
在src/main/resources下创建mapper文件夹,并将以上压缩包中的SomeMapper.xml复制到此mapper文件夹中:
![image-20220922114740601](C:\Users\TEDU\Desktop\每日笔记\成恒笔记\images\DAY03\image-20220922114740601.png)
关于XML文件的配置:
根标签必须是<mapper>
在<mapper>标签上必须配置namespace属性,此属性的值是接口的全限定名(包名与类名)
在<mapper>标签内部,使用<insert> / <delete> / <update> / <select>标签来配置增 / 删 / 改 / 查的SQL语句
各配置SQL语句的标签必须配置id属性,取值为对应的抽象方法的名称
各配置SQL语句的标签内部是配置SQL语句的
SQL语句不需要使用分号表示结束
不可以随意添加注释
在配置<select>标签时,必须配置resultType或resultMap这2个属性中的其中1个
例如:配置为:
<?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.csmall.product.mapper.AlbumMapper">
<!-- int insert(Album album); -->
<insert id="insert">
INSERT INTO pms_album (
name, description, sort
) VALUES (
#{name}, #{description}, #{sort}
)
</insert>
</mapper>
另外,还需要在application.properties中配置XML文件所在的位置:
# 配置Mybatis的XML文件的位置
mybatis.mapper-locations=classpath:mapper/*.xml
至此,关于“插入相册数据”的功能已经开发完成!
2.4. 测试
在Spring Boot项目中,当需要编写测试时,可以在src/test/java下的根包下创建测试类,并在类中编写测试方法。
则在测试的根包下创建mapper.AlbumMapperTests测试类:
package cn.tedu.csmall.product.mapper;
import cn.tedu.csmall.product.pojo.entity.Album;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
public class AlbumMapperTests {
@Autowired
AlbumMapper mapper;
@Test
void testInsert() {
Album album = new Album();
album.setName("测试相册001");
album.setDescription("测试简介001");
album.setSort(99); // 注意:取值必须是 [0, 255]
int rows = mapper.insert(album);
System.out.println("插入数据完成,受影响的行数=" + rows);
}
}
如果此前没有正确的配置@MapperScan,在执行测试时,将出现以下错误:
Caused by: org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'cn.tedu.csmall.product.mapper.AlbumMapper' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {@org.springframework.beans.factory.annotation.Autowired(required=true)}
如果出现以下原因的操作错误:
在XML文件中,根标签<mapper>的namespace属性值配置有误
在XML文件中,配置SQL语句的<insert>或类似标签的id属性值配置有误
在application.properties配置文件中,没有正确的配置XML文件的位置
将出现以下错误:
org.apache.ibatis.binding.BindingException: Invalid bound statement (not found): cn.tedu.csmall.product.mapper.AlbumMapper.insert
2.5. 练习:插入属性模板数据
属性模板表:pms_attribute_template
首先,在根包下的pojo.entity包中创建AttributeTemplate实体类:
package cn.tedu.csmall.product.pojo.entity;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 属性模板
*
* @author java@tedu.cn
* @version 0.0.1
*/
@Data
public class AttributeTemplate implements Serializable {
/**
* 记录id
*/
private Long id;
/**
* 属性模板名称
*/
private String name;
/**
* 属性模板名称的拼音
*/
private String pinyin;
/**
* 关键词列表,各关键词使用英文的逗号分隔
*/
private String keywords;
/**
* 自定义排序序号
*/
private Integer sort;
/**
* 数据创建时间
*/
private LocalDateTime gmtCreate;
/**
* 数据最后修改时间
*/
private LocalDateTime gmtModified;
}
然后,在根包下的mapper包中创建AttributeTemplateMapper接口,并在接口中添加抽象方法:
package cn.tedu.csmall.product.mapper;
import cn.tedu.csmall.product.pojo.entity.AttributeTemplate;
import org.springframework.stereotype.Repository;
/**
* 处理属性模板数据的Mapper接口
*
* @author java@tedu.cn
* @version 0.0.1
*/
@Repository
public interface AttributeTemplateMapper {
/**
* 插入属性模板数据
*
* @param attributeTemplate 属性模板数据
* @return 受影响的行数
*/
int insert(AttributeTemplate attributeTemplate);
}
然后,在src/main/resources/mapper通过粘贴得到AttributeTemplateMapper.xml文件,在此文件中配置以上抽象方法映射的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.csmall.product.mapper.AttributeTemplateMapper">
<!-- int insert(AttributeTemplate attributeTemplate); -->
<insert id="insert">
INSERT INTO pms_attribute_template (
name, pinyin, keywords, sort
) VALUES (
#{name}, #{pinyin}, #{keywords}, #{sort}
)
</insert>
</mapper>
最后,在src/test/java的根包下创建mapper.AttributeTemplateMapperTests测试类,编写并执行测试方法:
package cn.tedu.csmall.product.mapper;
import cn.tedu.csmall.product.pojo.entity.Album;
import cn.tedu.csmall.product.pojo.entity.AttributeTemplate;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
public class AttributeTemplateMapperTests {
@Autowired
AttributeTemplateMapper mapper;
@Test
void testInsert() {
AttributeTemplate attributeTemplate = new AttributeTemplate();
attributeTemplate.setName("测试数据002");
attributeTemplate.setPinyin("ceshishuju002");
attributeTemplate.setKeywords("测试关键词列表002");
attributeTemplate.setSort(99); // 注意:取值必须是 [0, 255]
int rows = mapper.insert(attributeTemplate);
System.out.println("插入数据完成,受影响的行数=" + rows);
}
}
2.6. 插入数据时获取自动编号的id值
在XML文件中,在<insert>标签上配置useGeneratedKeys="true"和keyProperty="属性名"这2个属性,就可获取插入的新数据的自动编号的主键值!例如:
<!-- int insert(Album album); -->
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO pms_album (
name, description, sort
) VALUES (
#{name}, #{description}, #{sort}
)
</insert>
当成功插入数据后,Mybatis会自动获取自动编号的主键值,并封装到参数对象album的id属性(由keyProperty指定)中,例如:
@Test
void testInsert() {
Album album = new Album();
album.setName("测试相册005");
album.setDescription("测试简介005");
album.setSort(99); // 注意:取值必须是 [0, 255]
System.out.println("插入数据之前,参数=" + album);
int rows = mapper.insert(album);
System.out.println("插入数据完成,受影响的行数=" + rows);
System.out.println("插入数据之后,参数=" + album);
}
以上的执行结果大概是:
插入数据之前,参数=Album(id=null, name=测试相册005, description=测试简介005, sort=99, gmtCreate=null, gmtModified=null)
插入数据完成,受影响的行数=1
插入数据之后,参数=Album(id=8, name=测试相册005, description=测试简介005, sort=99, gmtCreate=null, gmtModified=null)
2.7. 根据id删除相册数据
需要执行的SQL语句大致是:
DELETE FROM pms_album WHERE id=?
在AlbumMapper接口中添加抽象方法:
/**
* 根据id删除相册数据
* @param id 相册id
* @return 受影响的行数
*/
int deleteById(Long id);
在AlbumMapper.xml中配置以上抽象方法映射的SQL语句:
<!-- int deleteById(Long id); -->
<delete id="deleteById">
DELETE FROM pms_album WHERE id=#{id}
</delete>
完成后,AlbumMapperTests测试类中编写并执行测试方法:
@Test
void testDeleteById() {
Long id = 1L;
int rows = mapper.deleteById(id);
System.out.println("删除数据完成,受影响的行数=" + rows);
}
2.8. 练习:根据id删除属性模板数据
2.9. 统计相册表中数据的数量
需要执行的SQL语句大致是:
SELECT count(*) FROM pms_album
在AlbumMapper接口中添加抽象方法:
int count();
在AlbumMapper.xml中配置以上抽象方法映射的SQL语句:
<select id="count" resultType="int">
SELECT count(*) FROM pms_album
</select>
完成后,AlbumMapperTests测试类中编写并执行测试方法:
@Test
void testCount() {
int count = mapper.count();
System.out.println("统计数据完成,数量=" + count);
}
2.10. 统计属性模板表中数据的数量
作业
补全所有数据表的:
插入数据功能,要求完成测试
注意:pms_spu、pms_sku这2张表的id不是自动编号的,所以,在插入数据时,必须提供id字段的值,并且,在配置XML中的<insert>时,不需要配置useGeneratedKeys和keyProperty属性
根据id删除数据
day04
2. 通过Mybatis实现数据库编程
2.11. 根据id查询相册详情(续)
则在项目的根包下创建pojo.vo.AlbumStandardVO类:
package cn.tedu.csmall.product.pojo.vo;
import lombok.Data;
import java.io.Serializable;
/**
* 相册的标准VO类
*
* @author java@tedu.cn
* @version 0.0.1
*/
@Data
public class AlbumStandardVO implements Serializable {
/**
* 记录id
*/
private Long id;
/**
* 相册名称
*/
private String name;
/**
* 相册简介
*/
private String description;
/**
* 自定义排序序号
*/
private Integer sort;
}
在AlbumMapper接口中添加抽象方法:
/**
* 根据id查询相册标准信息
*
* @param id 相册id
* @return 匹配的相册的标准信息,如果没有匹配的数据,则返回null
*/
AlbumStandardVO getStandardById(Long id);
在AlbumMapper.xml中配置以上抽象方法映射的SQL语句:
<!-- AlbumStandardVO getStandardById(Long id); -->
<select id="getStandardById" resultType="cn.tedu.csmall.product.pojo.vo.AlbumStandardVO">
SELECT id, name, description, sort
FROM pms_album
WHERE id=#{id}
</select>
完成后,AlbumMapperTests测试类中编写并执行测试方法:
@Test
void testGetStandardById() {
Long id = 5L;
Object result = mapper.getStandardById(id);
System.out.println("根据id=" + id + "查询标准信息完成,结果=" + result);
}
2.12. 练习:根据id查询属性模板详情
2.12. 练习:根据id查询品牌详情
提示:品牌表是pms_brand。
2.13. 关于<resultMap>
当Mybatis处理查询的结果集时,会自动将列名(Column)与属性名(Property)相同的数据进行封装,例如,将查询结果集中名为name的数据封装到对象的name属性中,并且,默认情况下,对于列名与属性名不同的数据,不予处理!
提示:查询结果集中的列名(Column)默认是字段名(Field),而字段名是设计数据表时指定的。
在XML文件中,可以配置<resultMap>标签,此标签的作用就是:指导Mybatis将查询结果集中的数据封装到对象中。
建议通过<resultMap>标签配置列名与属性名的对应关系,例如:
<resultMap id="StandardResultMap" type="cn.tedu.csmall.product.pojo.vo.BrandStandardVO">
<result column="product_count" property="productCount"/>
<result column="comment_count" property="commentCount"/>
<result column="positive_comment_count" property="positiveCommentCount"/>
</resultMap>
提示:在单表查询时,列名与属性名本来就相同的部分,可以不必在<resultMap>进行配置。
然后,在<select>标签上,不再配置resultType,而改为配置resultMap,且此属性的值就是<resultMap>标签的id值,例如:
<!-- BrandStandardVO getStandardById(Long id); -->
<select id="getStandardById" resultMap="StandardResultMap">
暂不关心SQL语句部分
</select>
配置的<resultMap>是可以复用的,即:如果另一个<select>查询的结果也使用相同的VO类进行封装,则另一个<select>也配置相同的resultMap即可。
由于<resultMap>对应特定的VO类,而VO类是与字段列表对应的,所以,如果多个<select>复用了同一个<resultMap>,那这些<select>查询的字段列表必然是相同的,则可以通过<sql>和<include>来复用字段列表,例如:
<!-- BrandStandardVO getStandardById(Long id); -->
<select id="getStandardById" resultMap="StandardResultMap">
SELECT
<include refid="StandardQueryFields"/>
FROM
pms_brand
WHERE
id=#{id}
</select>
<sql id="StandardQueryFields">
id, name, pinyin, logo, description,
keywords, sort, sales, product_count, comment_count,
positive_comment_count, enable
</sql>
2.14. 查询相册列表
需要执行的SQL语句大致是:
SELECT id, name, description, sort FROM pms_album ORDER BY id DESC
在许多数据的查询功能中,查询详情(标准信息)和查询列表时,需要查询的字段列表很可能是不同的,所以,应该使用不同的VO类(为了避免后续维护添加字段导致的调整,即使当前查询详情和查询列表的字段完全相同,也应该使用不同的VO类)!
在根包下创建pojo.vo.AlbumListItemVO类:
在AlbumMapper接口中添加抽象方法:
List<AlbumListItemVO> list();
在AlbumMapper.xml中配置SQL语句:
<select id="list" resultMap="ListResultMap">
SELECT <include refid="ListQueryFields"/>
FROM pms_album
ORDER BY id DESC
</select>
<sql id="ListQueryFields">
id, name, description, sort
</sql>
<resultMap id="ListResultMap" type="cn.tedu.csmall.product.pojo.vo.AlbumListItemVO">
</resultMap>
在AlbumMapperTests中编写并执行测试:
@Test
void testList() {
List<?> list = mapper.list();
System.out.println("查询列表完成,列表中的数据的数量=" + list.size());
for (Object item : list) {
System.out.println(item);
}
}
2.14. 练习:查询属性模板列表
2.15. 动态SQL:<foreach>
Mybatis的动态SQL机制表现为:根据参数的不同,生成不同的SQL语句。
例如需要实现:根据若干个id批量删除相册数据。
需要执行的SQL语句大致是:
DELETE FROM pms_album WHERE id=? OR id=? ... OR id=?
或:
DELETE FROM pms_album WHERE id IN (?, ?, ... ?);
首先,在AlbumMapper接口中添加抽象方法,可以是:
int deleteByIds(List<Long> ids);
也可以是:
int deleteByIds(Long[] ids);
还可以是:
int deleteByIds(Long... ids);
提示:可变参数的本质仍是一个数组!
然后,在AlbumMapper.xml中配置SQL语句:
<!-- int deleteByIds(Long[] ids); -->
<delete id="deleteByIds">
DELETE FROM pms_album WHERE id IN (
<foreach collection="array" item="id" separator=",">
#{id}
</foreach>
)
</delete>
或:
<!-- int deleteByIds(Long[] ids); -->
<delete id="deleteByIds">
DELETE FROM pms_album WHERE
<foreach collection="array" item="id" separator=" OR ">
id=#{id}
</foreach>
</delete>
关于<foreach>标签的属性配置:
collection:表示被遍历的参数列表,如果抽象方法的参数只有1个,当参数类型是List集合类型时,当前属性取值为list,当参数类型是数组类型时,当前属性取值为array
item:用于指定遍历到的各元素的变量名,并且,在<foreach>的子级,使用#{}时的名称也是当前属性指定的名字
separator:用于指定遍历过程中各元素的分隔符(或字符串等)
完成后,在AlbumMapperTests中编写并执行测试:
@Test
void testDeleteByIds() {
Long[] ids = {1L, 3L, 5L, 7L, 9L};
int rows = mapper.deleteByIds(ids);
System.out.println("批量删除数据完成,受影响的行数=" + rows);
}
2.16. 练习:批量插入相册数据
需要执行的SQL语句大致是:
INSERT INTO pms_album (name, description, sort) VALUES (?,?,?), (?,?,?), ... (?,?,?);
在AlbumMapper接口中添加抽象方法:
int insertBatch(List<Album> albumList);
在AlbumMapper.xml中配置SQL语句:
<!-- int insertBatch(List<Album> albumList); -->
<insert id="insertBatch" useGeneratedKeys="true" keyProperty="id">
INSERT INTO pms_album (name, description, sort) VALUES
<foreach collection="list" item="album" separator=",">
(#{album.name}, #{album.description}, #{album.sort})
</foreach>
</insert>
在AlbumMapperTests中编写并执行测试:
@Test
void testInsertBatch() {
List<Album> albumList = new ArrayList();
for (int i = 1; i <= 10; i++) {
Album album = new Album();
album.setName("批量插入的测试相册名称" + i);
album.setDescription("批量插入的测试相册简介" + i);
album.setSort(66);
albumList.add(album);
}
int rows = mapper.insertBatch(albumList);
System.out.println("批量插入数据完成,受影响的行数=" + rows);
}
2.17. 练习:批量删除属性模板数据
2.18. 练习:批量插入属性模板数据
2.19. 动态SQL:<if>
假设需要修改相册表中的数据,需要执行的SQL语句大致是:
UPDATE pms_album SET name=?, description=?, sort=? WHERE id=?
如果按照以上SQL来设计抽象方法,则抽象方法大致是:
int updateById(Album album);
并且,配置以上抽象方法映射的SQL语句:
<!-- int updateById(Album album); -->
<update id="updateById">
UPDATE
pms_album
SET
name=#{name},
description=#{description},
sort=#{sort}
WHERE
id=#{id}
</update>
如果采取以上做法,就无法实现“只修改部分字段的值”!例如:只修改sort时,如果在Album对象中只封装了id和sort属性值,则name和description这2个属性的值就是null,在执行SQL语句时,将会把表中原有数据的name和description字段的值更新为null,这是不符合原本的需求的!
期望的执行效果应该是:传入了对应的值,则更新对应字段的值,对于没有传入参数的部分,也不更新表中对应字段的数据!
如果要实现以上效果,则需要使用动态SQL中的<if>,这个标签的作用就是对参数进行判断的!
<!-- int updateById(Album album); -->
<update id="updateById">
UPDATE
pms_album
<set>
<if test="name != null">
name=#{name},
</if>
<if test="description != null">
description=#{description},
</if>
<if test="sort != null">
sort=#{sort},
</if>
</set>
WHERE
id=#{id}
</update>
2.20. 动态SQL:其它
在Mybatis中,<if>标签并没有对应的类似Java中的else标签!如果需要实现类似Java中if ... else ...的效果,可以使用2个条件完全相反的<if>标签,例如:
<if test="name != null">
某代码
</if>
<if test="name == null">
另外一段代码
</if>
以上做法的缺点在于:实际上执行了2次条件的判断,在性能略微有浪费。
或者,使用<choose>系列标签,以实现类似if ... else ...的效果:
<choose>
<when test="判断条件">
满足条件时的SQL片段
</when>
<otherwise>
不满足条件时的SQL片段
</otherwise>
</choose>
3. SLF4j日志
在Spring Boot项目中,spring-boot-starter依赖项中已经包含日志框架!
在Spring Boot项目中,当添加了Lombok依赖项后,可以在任何类上添加@Slf4j注解,则Lombok会在编译期声明一个名为log的日志对象变量,此变量可以调用相关方法来输出日志!
package cn.tedu.csmall.product;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@Slf4j
@SpringBootTest
public class Slf4jTests {
@Test
void testLog() {
log.info("输出了一条日志……");
}
}
SLF4j的日志的可显示级别,根据信息的重要程度,从不重要到严重依次是:
trace
debug
info:一般信息
warn:警告信息
error:错误信息
调用log变量来输出日志时,可以使用以上级别对应的方法,则可以输出对应级别的日志!
在Spring Boot项目中,日志的默认显示级别是info,则默认情况下只会显示info及更加严重的级别的日志!如果需要修改日志的显示级别,需要在application.properties中配置logging.level的属性,例如:
# 日志的显示级别
logging.level.cn.tedu.csmall=info
注意:在配置以上属性时,必须在logging.level右侧加上要配置显示级别的包的名称,此包名就是配置日志显示级别的根包。
作业
补全mall_pms数据库中所有数据表的以下数据访问功能,要求均有对应的测试:
插入数据功能
注意:pms_spu、pms_sku这2张表的id不是自动编号的,所以,在插入数据时,必须提供id字段的值,并且,在配置XML中的<insert>时,不需要配置useGeneratedKeys和keyProperty属性
批量插入数据功能
根据id删除数据
根据若干个id批量删除数据
根据id修改数据
统计表中数据的数量
根据id查询数据详情
查询数据列表
推荐开发优先级:相册(pms_album) > 属性模板(pms_attribute_template) > 品牌(pms_brand) > 属性(pms_attribute) > 类别(pms_category) > 其它
开发进度要求:
9月26日前完成:相册、属性模板、品牌、属性、类别
9月30日前完成:全部
提交截止时间:2022-09-30 23:00
day05
3. SLF4j日志(续)
输出日志的各个方法都被重载了多次,建议使用的方法例如:
void trace(String var1);
void trace(String var1, Object... var2);
提示:以上是trace方法,其它级别的日志也有完全相同参数列表的方法。
以上的第2个方法适用于在输出的日志中添加变量的值,在第1个字符串参数中,各变量均使用{}表示,然后,通过第2个参数依次传入各变量对应的值即可,例如:
int x = 1;
int y = 2;
log.trace("{}+{}={}", x, y, x + y);
使用以上方式输出时,会将字符串部分进行缓存(是一种预编译的做法),在执行时,并不会出现拼接字符串的情况,所以,在执行效率方面,比传统的System.out.println()的要高很多!
4. 关于Profile配置
在配置中,许多配置值会因为当前环境不同,需要配置不同的值,例如,在开发环境中,日志的显示级别可以是trace这种较低级别的,而在测试环境、生产环境(项目部署上线并运行)中可能需要改为其它值,再例如,连接数据库配置的相关参数,在不同环境中,可能使用不同的值……如果在application.properties中反复修改大量配置值,是非常不方便的!
Spring框架支持Profile配置(个性化配置),Spring Boot简化了Profile配置的使用,它支持使用application-xxx.properties作为配置文件的名称,其中,xxx部分是完全自定义的名称,你可以针对不同的环境,编写一组配置文件,这些配置文件中配置了相同的属性,但是值不同,例如:
application-dev.properties(暂定为“开发”环境使用的配置文件)
# 连接数据库的配置
spring.datasource.url=jdbc:mysql://localhost:3306/mall_pms?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
spring.datasource.username=root
spring.datasource.password=root
# 日志的显示级别
logging.level.cn.tedu.csmall=trace
application-test.properties(暂定为“测试”环境使用的配置文件)
# 连接数据库的配置
spring.datasource.url=jdbc:mysql://192.168.1.100:3306/mall_pms?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
spring.datasource.username=test
spring.datasource.password=test123
# 日志的显示级别
logging.level.cn.tedu.csmall=debug
application-prod.properties(暂定为“生产”环境使用的配置文件)
# 连接数据库的配置
spring.datasource.url=jdbc:mysql://192.168.1.255:3306/mall_pms?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
spring.datasource.username=prod
spring.datasource.password=s3ctet@tedu.cn
# 日志的显示级别
logging.level.cn.tedu.csmall=info
以上这些不在application.properties中的配置,默认是不生效的!需要在application.properties中显式的激活后,才会生效!例如:
# 激活Profile配置
spring.profiles.active=prod
小结:
appliation.properties是始终加载的配置
application-xxx.properties是需要通过application.properties中的spring.profiles.active属性来激活的
application-xxx.properties文件名中的xxx是自定义的名称,也是spring.profiles.active属性的值
需要自行评估哪些配置会因为环境不同而配置不同的值
5. 关于YAML配置
YAML是一种使用.yml作为扩展名的配置文件,这类配置文件在Spring框架中默认是不支持的,需要添加额外的依赖项,在Spring Boot项目中,默认已经集成了相关依赖项,所以,在Spring Boot项目中可以直接使用。
在Spring Boot项目中,可以使用.properties配置,也可以使用.yml配置。
相对.properties配置,YAML的配置语法为:
在.properties配置中,属性名使用小数点分隔的,改为使用冒号结束,并从下一行开始,缩进2个空格
属性名与属性值之间使用1个冒号和1个空格进行分隔
多个不同的配置属性中,如果属性名中有相同的部分,可以不必重复配置,只需要将不同的部分缩进在相同位置即可
如果某个属性值是纯数字的,但需要是字符串类型,可以使用一对单引号框住
例如在.properties中配置为:
spring.datasource.username=root
spring.datasource.password=1234
在.yml中则配置为:
spring:
datasource:
username: root
password: '1234'
注意:每换行后,需要缩进2个空格,在IntelliJ IDEA中,编写.yml文件时,IntelliJ IDEA会自动将按下的TAB键的字符替换为2个空格。
提示:如果.yml文件出现乱码(通常是因为复制粘贴文件导致的),则无法解析,项目启动时就会报错,此时,应该保留原代码(将代码复制到别处),删除报错的配置文件,并重新创建新文件,将保留下来的原代码粘贴回新文件即可。
5. 关于业务逻辑
业务逻辑:数据的处理应该有一定的处理流程,并且,在此流程中,可能需要制定某些规则,如果不符合规则,不应该允许执行相关的数据访问!这套流程及相关逻辑则称之为业务逻辑。
例如:当用户尝试注册时,通常要求用户名(或类似的唯一标签,例如手机号码等)需要是“唯一的”,在执行插入数据(将用户信息插入到数据表中)之前,应该先检查用户名是否已经被占用。
业务逻辑层的主要价值是设计业务流程,及业务逻辑,以保证数据的完整性和安全性。
在代码的设计方面,业务逻辑层将使用Service作为名称的关键词,并且,应该先自定义业务逻辑层接口,再自定义类实现此接口,实现类的名称应该在接口名称的基础上添加Impl后缀。例如,处理相册数据的业务逻辑接口的名称应该是IAlbumService或AlbumService,其实现类的名称则是`AlbumServiceImpl。
6. 业务:添加相册
先在项目的根包下创建service.IAlbumService接口:
public interface IAlbumService {
}
然后,在service包下创建impl.AlbumServiceImpl类,实现以上接口,并且在类上添加@Service注解:
@Service
public class AlbumServiceImpl implements IAlbumService {
}
提示:在类上添加了@Autowired注解后,当启动项目或执行任何一个Spring Boot测试时,都会自动创建以上类的对象,并且,可以使用@Autowired实现属性的“自动赋值”。
由于实体类不适合作为业务方法参数,来表示“客户端将提交的数据”,所以,应该使用专门的POJO类型作为业务方法的参数类型!当前项目中使用DTO作为此类POJO的后缀!
则在根包下创建pojo.dto.AlbumAddNewDTO类:
package cn.tedu.csmall.product.pojo.dto;
import lombok.Data;
import java.io.Serializable;
/**
* 添加相册的DTO类
*
* @author java@tedu.cn
* @version 0.0.1
*/
@Data
public class AlbumAddNewDTO implements Serializable {
/**
* 相册名称
*/
private String name;
/**
* 相册简介
*/
private String description;
/**
* 自定义排序序号
*/
private Integer sort;
}
接下来,需要在接口中定义“添加相册”的抽象方法:
/**
* 添加相册
*
* @param albumAddNewDTO 相册数据
*/
void addNew(AlbumAddNewDTO albumAddNewDTO);
提示1:业务方法的参数应该是自定义的POJO类型
提示2:业务方法的名称是自定义的
提示3:业务方法的返回值类型,是仅以“成功”为前提来设计的,不考虑失败的情况,因为失败将通过抛出异常来表现
接下来,在AlbumServiceImpl中重写抽象方法:
@Autowired
AlbumMapper albumMapper;
@Override
public void addNew(AlbumAddNewDTO albumAddNewDTO) {
// 从参数albumAddNewDTO中获取尝试添加的相册名称
// 检查此相册名称是否已经存在:调用Mapper对象的countByName()方法,判断结果是否不为0
// 是:名称已存在,不允许创建,抛出异常
// 创建Album对象
// 调用Album对象的setName()方法来封装数据:来自参数albumAddNewDTO
// 调用Album对象的setDescription()方法来封装数据:来自参数albumAddNewDTO
// 调用Album对象的setSort()方法来封装数据:来自参数albumAddNewDTO
// 调用Mapper对象的insert()方法,插入相册数据
}
所以,在实际以上业务之前,需要先补充“检查相册名称是否已经存在”的查询功能,此查询功能需要执行的SQL语句大致是:
SELECT count(*) FROM pms_album WHERE name=?
则在AlbumMapper接口中添加抽象方法:
/**
* 根据相册名称,统计数据的数量
*
* @param name 相册名称
* @return 此名称的相册数据的数量
*/
int countByName(String name);
并在AlbumMapper.xml中配置以上抽象方法映射的SQL语句:
<!-- int countByName(String name); -->
<select id="countByName" resultType="int">
SELECT count(*) FROM pms_album WHERE name=#{name}
</select>
完成后,还应该在AlbumMapperTests中测试:
@Test
void testCountByName() {
String name = "测试相册001";
int count = mapper.countByName(name);
System.out.println("根据名称【" + name + "】统计数据完成,数量=" + count);
}
当补全Mapper层的查询功能后,再实现Service的方法,即AlbumServiceImpl中的addNew()方法:
package cn.tedu.csmall.product.service.impl;
import cn.tedu.csmall.product.mapper.AlbumMapper;
import cn.tedu.csmall.product.pojo.dto.AlbumAddNewDTO;
import cn.tedu.csmall.product.pojo.entity.Album;
import cn.tedu.csmall.product.service.IAlbumService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
/**
* 处理相册数据的业务实现类
*
* @author java@tedu.cn
* @version 0.0.1
*/
@Service
public class AlbumServiceImpl implements IAlbumService {
@Autowired
AlbumMapper albumMapper;
public AlbumServiceImpl() {
System.out.println("创建业务实现类对象:AlbumServiceImpl");
}
@Override
public void addNew(AlbumAddNewDTO albumAddNewDTO) {
// 从参数albumAddNewDTO中获取尝试添加的相册名称
String name = albumAddNewDTO.getName();
// 检查此相册名称是否已经存在:调用Mapper对象的countByName()方法,判断结果是否不为0
int count = albumMapper.countByName(name);
if (count != 0) {
// 是:名称已存在,不允许创建,抛出异常
throw new RuntimeException();
}
// 创建Album对象
Album album = new Album();
// 将参数DTO中的数据复制到Album对象中
BeanUtils.copyProperties(albumAddNewDTO, album);
// 调用Mapper对象的insert()方法,插入相册数据
albumMapper.insert(album);
}
}
完成后,在src/test/java的根包下创建service.AlbumServiceTests测试类,编写并执行测试:
package cn.tedu.csmall.product.service;
import cn.tedu.csmall.product.pojo.dto.AlbumAddNewDTO;
import cn.tedu.csmall.product.service.impl.AlbumServiceImpl;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
public class AlbumServiceTests {
@Autowired
IAlbumService service;
@Test
void contextLoads() {
System.out.println(service);
}
@Test
void testAddNew() {
AlbumAddNewDTO albumAddNewDTO = new AlbumAddNewDTO();
albumAddNewDTO.setName("测试相册名称001");
albumAddNewDTO.setDescription("测试相册简介001");
albumAddNewDTO.setSort(88);
try {
service.addNew(albumAddNewDTO);
System.out.println("添加相册成功!");
} catch (RuntimeException e) {
System.out.println("添加相册失败,相册名称已经被占用!");
}
}
}
7. 关于控制器
在编写控制器相关代码之前,需要在项目中添加spring-boot-starter-web依赖项。
spring-boot-starter-web是基于(包含)spring-boot-starter的,所以,添加spring-boot-starter-web后,就不必再显式的添加spring-boot-starter了,则可以将原本的spring-boot-starter改成spirng-boot-starter-web即可,例如:
<!-- Spring Boot的Web依赖项,包含基础依赖项 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
spring-boot-starter-web包含了一个内置的Tomcat,当启动项目时,会自动将当前项目编译、打包并部署到此Tomcat上。
在项目中,控制器表现为各个Controller,是由Spring MVC框架实现的相关数据处理功能,以上添加的spring-boot-starter-web包含了Spring MVC框架的依赖项(spring-webmvc)。
控制器的主要作用是接收请求,并响应结果。
8. 控制器:添加相册
在项目的根包下创建controller.AlbumController类,并在类上添加@RestController注解:
@RestController
public class AlbumController {
}
并在控制器类中定义处理请求的方法:
@Autowired
IAlbumService albumService;
// http://localhost:8080/album/add-new?name=TestAlbumName001&description=TestAlbumDescription001&sort=77
@RequestMapping("/album/add-new")
public String addNew(AlbumAddNewDTO albumAddNewDTO) {
try {
albumService.addNew(albumAddNewDTO);
return "添加相册成功!";
} catch (RuntimeException e) {
return "添加相册失败,尝试添加的相册名称已经被占用!";
}
}
完成后,重启项目,在浏览器中可以通过 http://localhost:8080/album/add-new?name=TestAlbumName001&description=TestAlbumDescription001&sort=77 测试访问。
作业:
实现:添加属性模板,业务规则为“属性模板的名称必须唯一”
思考题:
Java语言中异常的继承结构
RuntimeException有什么特殊之处
什么是捕获,什么是抛出,怎么样操作才算是处理异常
day06
9. 关于异常
在Java语言中,异常的继承结构大致是:
Throwable
-- Error
-- -- OutOfMemoryError(OOM)
-- Exception
-- -- IOException
-- -- -- FileNotFoundException
-- -- RuntimeException
-- -- -- NullPointerException(NPE)
-- -- -- IllegalArgumentException
-- -- -- ClassNotFoundException
-- -- -- ClassCastException
-- -- -- ArithmeticException
-- -- -- IndexOutOfBoundsException
-- -- -- -- ArrayIndexOutOfBoundsException
-- -- -- -- StringIndexOutOfBoundsException
如果调用的某个方法抛出了非RuntimeException,则必须在源代码中使用try...catch或throws语法,否则,源代码将报错!而RuntimeException不会受到这类语法的约束!
在项目中,如果需要通过抛出异常来表示某种“错误”,应该使用自定义的异常类型,否则,可能与框架或其它方法抛出的异常相同,在处理时,会模糊不清(不清楚异常到底是显式的抛出的,还是调用其它方法时由那些方法抛出的)!同时,为了避免抛出异常时有非常多复杂的语法约束,通常,自定义的异常都会是RuntimeException的子孙类异常。
另外,抛出异常时,应该对出现异常的原因进行描述,所以,在自定义异常类中,应该添加带String message参数的构造方法,且此构造方法需要调用父类的带String message参数的构造方法。
则在项目的根包下创建ex.ServiceException异常类,继承自RuntimeException,例如:
package cn.tedu.csmall.product.ex;
/**
* 业务异常
*
* @author java@tedu.cn
* @version 0.0.1
*/
public class ServiceException extends RuntimeException {
public ServiceException(String message) {
super(message);
}
}
然后,在Service中,就抛出此类异常,并添加对于错误的描述文本,例如:
if (count != 0) {
// 是:名称已存在,不允许创建,抛出异常
throw new ServiceException("添加相册失败,尝试添加的相册名称已经被占用!");
}
在Controller中,将调用Service中的方法,可以使用try..catch包裹这段代码,对异常进行捕获并处理,例如:
try {
albumService.addNew(albumAddNewDTO);
return "添加相册成功!";
} catch (ServiceException e) {
return e.getMessage();
}
10. Spring MVC框架的统一处理异常机制
由于Service在处理业务,如果视为”失败“,将抛出异常,并且,抛出时还会封装”失败“的描述文本,而Controller每次调用Service的任何方法时,都会使用try..catch进行捕获并处理,并且,处理的代码都是相同的(暂时是return e.getMessage();),这样的做法是非常固定的,导致在Controller中存在大量的try...catch(处理任何请求,调用Service时都是这样的代码)。
Spring MVC提供了统一处理异常的机制,它可以使得Controller不再处理异常,改为抛出异常,而Spring MVC在调用Controller处理请求时,会捕获Controller抛出的异常并尝试处理。
关于处理异常的方法:
访问权限:应该是public
返回值类型:参考处理请求的方法
方法名称:自定义
参数列表:至少需要添加1个异常类型的参数,表示你希望处理的异常,也是Spring MVC框架调用Controller的方法时捕获到的异常
注解:@ExceptionHandler
如果将处理异常的方法定义在某个Controller中,仅作用于当前Controller中所有处理请求的方法,对别的Controller中处理请求的方法是不生效的!Spring MVC建议将处理异常的代码写在专门的类中,并且,在类上添加@RestControllerAdvice注解,当添加此注解后,此类中处理异常的代码将作用于整个项目每次处理请求的过程中
允许存在多个处理异常的方法,只要这些方法处理的异常类型不直接冲突即可
即:不允许2个处理异常的方法都处理同一种异常
即:允许多个处理异常的方法中处理的异常存在继承关系,例如A方法处理NullPointerException,B方法处理RuntimeException
在实际处理时,推荐添加一下对Throwable处理的方法,以避免某些异常没有被处理,导致响应500错误。
关于处理异常的类,暂定为:
package cn.tedu.csmall.product.ex.handler;
import cn.tedu.csmall.product.ex.ServiceException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* 全局异常处理器
*
* @author java@tedu.cn
* @version 0.0.1
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
public GlobalExceptionHandler() {
System.out.println("创建全局异常处理器对象:GlobalExceptionHandler");
}
@ExceptionHandler
public String handleServiceException(ServiceException e) {
log.debug("捕获到ServiceException:{}", e.getMessage());
return e.getMessage();
}
@ExceptionHandler
public String handleThrowable(Throwable e) {
log.debug("捕获到Throwable:{}", e.getMessage());
e.printStackTrace(); // 强烈建议
return "服务器运行过程中出现未知错误,请联系系统管理员!";
}
}
11. 关于控制器类Controller
在Spring MVC框架,使用控制器(Controller)来接收请求、响应结果。
在根包下的任何一个类,添加了@Controller注解,就会被视为控制器类。
在默认情况下,控制器类中处理请求的方法,响应的结果是”视图组件的名称“,即:控制器对请求处理后,将返回视图名称,Spring MVC还会根据视图名称来确定视图组件,并且,由此视图组件来响应!这不是前后端分离的做法!
提示:如果需要了解传统的Spring MVC不使用前后端分离的做法,可以参考扩展视频教程《基于XML配置的Spring MVC框架》。
可以在处理请求的方法上添加@ResponseBody注解,则此方法处理请求后,返回的值就是响应到客户端的数据!这种做法通常称之为”响应正文“。
@ResponseBody注解还可以添加在控制器类上,则此控制器类中所有处理请求的方法都将是”响应正文“的!
另外,还可以使用@RestController取代@Controller和@ResponseBody,关于@RestController的源代码:
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Controller
@ResponseBody
public @interface RestController {
@AliasFor(
annotation = Controller.class
)
String value() default "";
}
可以看到,在@RestController的源代码上,添加了@Controller和@ResponseBody,所以,可以把@RestController称之为”组合注解“,而@Controller和@ResponseBody可以称之为@RestController的”元注解“。
与之类似的,在Spring MVC框架中,添加了@ControllerAdvice注解的类中的特定方法,将可以作用于每次处理请求的过程中,但是,仅仅只使用@ControllerAdvice时,并不是前后端分离的做法,还应该结合@ResponseBody一起使用,或,直接改为使用@RestControllerAdvice,关于@RestControllerAdvice的源代码片段:
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@ControllerAdvice
@ResponseBody
public @interface RestControllerAdvice {
// 暂不关心内部源代码
}
12. 关于控制器类中处理请求的方法
关于处理请求的方法:
访问权限:应该使用public
返回值类型:暂时使用String
方法名称:自定义
参数列表:按需设计,即需要客户端提交哪些请求参数,在此方法的参数列表中就设计哪些参数,如果参数的数量有多个,并且多个参数具有相关性,则可以封装,并使用封装的类型作为方法的参数,另外,可以按需添加Spring容器中的其它相关数据作为参数,例如HttpServletRequest、HttpServletResponse、HttpSession等
异常:如果有,全部抛出
注解:需要通过@RequestMapping系列注解配置请求路径
13. 关于@RequestMapping
在Spring MVC框架中,@RequestMapping的主要作用是:配置请求路径与处理请求的方法的映射关系。
此注解可以添加在控制类上,也可以添加在处理请求的方法上。
通常,会在控制器类和处理请求的方法上都配置此注解,例如:
@RestController
@RequestMapping("/albums")
public class AlbumController {
@RequestMapping("/add-new")
public String addNew(AlbumAddNewDTO albumAddNewDTO) {
// 暂不关心方法内部代码
}
}
以上配置的路径将是:http://主机名:端口号/类上配置路径/方法上配置的路径,即:http://localhost:8080/albums/add-new
并且,在使用`@RequestMapping配置路径时,路径值两端多余的 / 是会被自动处理的,在类上的配置值和方法上的配置值中间的 / 也是自动处理的,例如,以下配置是等效的:
类上的配置值 方法上的配置值
/albums /add-new
/albums add-new
/albums/ /add-new
/albums/ add-new
albums /add-new
albums add-new
albums/ /add-new
albums/ add-new
尽管以上8种组合配置是等效的,但仍推荐使用第1种。
在@RequestMapping注解的源代码中,有:
@AliasFor("path")
String[] value() default {};
以上源代码表示在此注解中存在名为value的属性,并且,此属性的值类型是String[],例如,你可以配置@RequestMapping(value = {"xxx", "zzz"}),此属性的默认值是{}(空数组)。
在所有注解中,value是默认的属性,所以,如果你需要配置的注解参数是value属性,且只配置这1个属性时,并不需要显式的指定属性名!例如:
@RequestMapping(value = {"xxx", "zzz"})
@RequestMapping({"xxx", "zzz"})
以上2种配置方式是完全等效的!
在所有注解中,如果某个属性的值是数组类型的,但是,你只提供1个值(也就是数组中只有1个元素),则这个值并不需要使用大括号框住!例如:
@RequestMapping(value = {"xxx"})
@RequestMapping(value = "xxx")
以上2种配置方式是完全等效的!
在源代码中,关于value属性的声明上还有@AliasFor("path"),它表示”等效于“的意思,也就是说,value属性与另一个名为path的属性是完全等效的!
在@RequestMapping的源代码中,还有:
RequestMethod[] method() default {};
以上属性的作用是配置并限制请求方式,例如,配置为:
@RequestMapping(value = "/add-new", method = RequestMethod.POST)
按照以上配置,以上请求路径只允许使用POST方式提交请求!
强烈建议在正式运行的代码中,明确的配置并限制各请求路径的请求方式!
另外,在Spring MVC框架中,还定义了基于@RequestMapping的相关注解:
@GetMapping
@PostMapping
@PutMapping
@DeleteMapping
@PatchMapping
所以,在开发实践中,通常:在控制器类上使用@RequestMapping配置请求路径的前缀部分,在处理请求的方法上使用@GetMapping、@PostMapping这类限制了请求方式的注解。
14. 根据id删除相册
目前,在Mapper层已经实现了此功能!则只需要开发Service层和Controller层。
在IAlbumService接口中添加抽象方法:
void delete(Long id);
在AlbumServiceImpl类中实现以上方法:
@Override
public void delete(Long id) {
log.debug("开始处理【删除相册】的业务,参数:{}", id);
// 调用Mapper对象的getDetailsById()方法执行查询
AlbumStandardVO queryResult = albumMapper.getStandardById(id);
// 判断查询结果是否为null
if (queryResult == null) {
// 是:无此id对应的数据,抛出异常
String message = "删除相册失败,尝试访问的数据不存在!";
log.warn(message);
throw new ServiceException(message);
}
// 调用Mapper对象的deleteById()方法执行删除
log.debug("即将删除相册数据……");
albumMapper.deleteById(id);
log.debug("删除相册,完成!");
}
在AlbumServiceTests类中编写并执行测试:
@Test
void testDelete() {
Long id = 14L;
try {
service.delete(id);
System.out.println("删除相册成功!");
} catch (ServiceException e) {
System.out.println(e.getMessage());
}
}
在AlbumController中添加处理请求的方法:
// http://localhost:8080/albums/delete?id=1
@RequestMapping("/delete")
public String delete(Long id) {
log.debug("开始处理【删除相册】的请求,参数:{}", id);
albumService.delete(id);
return "OK";
}
作业
请完成以下功能的Service层、Controller层,并且,Service层需要完成测试:
根据id删除属性模板,业务规则:数据必须存在
添加品牌,业务规则:品牌名称必须唯一
根据id删除品牌,业务规则:数据必须存在
添加类别,业务规则:类别名称必须唯一
根据id删除类别,业务规则:数据必须存在
day07
15. 在Spring MVC中接收请求参数
如果客户端提交的请求参数数量较少,且参数之间没有相关性,则可以选择将各请求参数声明为处理请求的方法的参数,并且,参数的类型可以按需设计。
如果客户端提交的请求参数略多(达到2个或以上),且参数之间存在相关性,则应该将这些参数封装到自定义的POJO类型中,并且,使用此POJO类型作为处理请求的方法的参数,同样,POJO类中的属性的类型可以按需设计。
关于请求参数的值:
如果客户端没有提交对应名称的请求参数,则方法的参数值为null
如果客户端提交了对应名称的请求参数,但是没有值,则方法的参数值为空字符串(""),如果方法的参数是需要将字符串转换为别的格式,但无法转换,则参数值为null,例如声明为Long类型时
如果客户端提交对应名称的请求参数,且参数有正确的值,则方法的参数值为就是请求参数值,如果方法的参数是需要将字符串转换为别的格式,但无法转换,则会抛出异常
另外,还推荐将某些具有唯一性的(且不涉及隐私)参数设计到URL中,使得这些参数值是URL的一部分!例如:
https://blog.csdn.net/weixin_407563/article/details/854745877
https://blog.csdn.net/qq_3654243299/article/details/847462823
Spring MVC框架支持在设计URL时,使用{名称}格式的占位符,实际处理请求时,此占位符位置是任意值都可以匹配得到!
例如,将URL设计为:
@RequestMapping("/delete/{id}")
在处理请求的方法的参数列表中,用于接收占位符的参数,需要添加@PathVariable注解,例如:
public String delete(@PathVariable Long id) {
// 暂不关心方法内部的实现
}
如果{}占位符中的名称,与处理请求的方法的参数名称不匹配,则需要在@PathVariable注解上配置占位符中的名称,例如:
@RequestMapping("/delete/{albumId}")
public String delete(@PathVariable("albumId") Long id) {
// 暂不关心方法内部的实现
}
在配置占位符时,可以在占位符名称的右侧,可以添加冒号,再加上正则表达式,对占位符的值的格式进行限制,例如:
@RequestMapping("/delete/{id:[0-9]+}")
如果按照以上配置,仅当占位符位置的值是纯数字才可以匹配到此URL!
并且,多个不冲突有正则表达式的占位符配置的URL是可以共存的!例如:
@RequestMapping("/delete/{id:[a-z]+}")
以上表示的是“占位符的值是纯字母的”,是可以与以上“占位符的值是纯数字的”共存!
另外,某个URL的设计没有使用占位符,与使用了占位符的,是允许共存的!例如:
@RequestMapping("/delete/test")
Spring MVC会优先匹配没有使用占位符的URL,再尝试匹配使用了占位符的URL。
16. 关于RESTful
RESTful也可以简称为REST,是一种设计软件的风格。
RESTful既不是规定,也不是规范。
RESTful风格的典型表现主要有:
处理请求后是响应正文的
将具有唯一性的参数值设计在URL中
根据请求访问数据的方式,使用不用的请求方式
如果尝试添加数据,使用POST请求方式
如果尝试删除数据,使用DELETE请求方式
如果尝试修改数据,使用PUT请求方式
如果尝试查询数据,使用GET请求方式
这种做法在复杂的业务系统中并不适用
在设计RESTful风格的URL时,建议的做法是:
查询某数据的列表:/数据类型的复数,并使用GET请求方式,例如/albums
查询某1个数据(通常根据id):/数据类型的复数/id值,并使用GET请求方式,例如/albums/9527
对某1个数据进行操作(增、删、改):/数据类型的复数/id值/操作,并使用POST请求方式,,例如/albums/9527/delete
17. 关于响应正文的结果
通常,需要使用自定义类,作为处理请求的方法、处理异常的方法的返回值类型。
响应到客户端的数据中,应该包含“业务状态码”,以便于客户端迅速判断操作成功与否,为了规范的管理业务状态码的值,在根包下创建web.ServiceCode枚举类型:
package cn.tedu.csmall.product.web;
/**
* 业务状态码的枚举
*
* @author java@tedu.cn
* @version 0.0.1
*/
public enum ServiceCode {
OK(20000),
ERR_NOT_FOUND(40400),
ERR_CONFLICT(40900);
private Integer value;
ServiceCode(Integer value) {
this.value = value;
}
public Integer getValue() {
return value;
}
}
并且,修改ServiceException异常类的代码,要求此类异常的对象中包含业务状态码,可以通过构造方法进行限制:
package cn.tedu.csmall.product.ex;
import cn.tedu.csmall.product.web.ServiceCode;
/**
* 业务异常
*
* @author java@tedu.cn
* @version 0.0.1
*/
public class ServiceException extends RuntimeException {
private ServiceCode serviceCode;
public ServiceException(ServiceCode serviceCode, String message) {
super(message);
this.serviceCode = serviceCode;
}
public ServiceCode getServiceCode() {
return serviceCode;
}
}
然后,原本业务逻辑层中抛出异常的代码将报错,需要在创建异常对象时传入业务状态码参数,例如:
throw new ServiceException(ServiceCode.ERR_CONFLICT,
"添加相册失败,尝试添加的相册名称已经被占用!");
接下来,还需要使用自定义类型,表示响应到客户端的数据,则在根包下创建web.JsonResult类:
package cn.tedu.csmall.product.web;
import lombok.Data;
import java.io.Serializable;
@Data
public class JsonResult implements Serializable {
/**
* 业务状态码的值
*/
private Integer state;
/**
* 操作失败时的提示文本
*/
private String message;
public static JsonResult ok() {
JsonResult jsonResult = new JsonResult();
jsonResult.state = ServiceCode.OK.getValue();
return jsonResult;
}
public static JsonResult fail(ServiceCode serviceCode, String message) {
JsonResult jsonResult = new JsonResult();
jsonResult.state = serviceCode.getValue();
jsonResult.message = message;
return jsonResult;
}
}
在实际处理请求和异常时,Spring MVC框架会将方法返回的JsonResult对象转换成JSON格式的字符串。
例如:处理请求的代码:
@RequestMapping("/add-new")
public JsonResult addNew(AlbumAddNewDTO albumAddNewDTO) {
log.debug("开始处理【添加相册】的请求,参数:{}", albumAddNewDTO);
albumService.addNew(albumAddNewDTO);
return JsonResult.ok();
}
例如:处理异常的代码:
@ExceptionHandler
public JsonResult handleServiceException(ServiceException e) {
log.debug("捕获到ServiceException:{}", e.getMessage());
return JsonResult.fail(e.getServiceCode(), e.getMessage());
}
通常,不需要在JSON结果中包含为null的属性,所以,可以在application.properties / application.yml中进行配置:
application.properties配置示例
# JSON结果中将不包含为null的属性
spring.jackson.default-property-inclusion=non_nul
application.yml配置示例
# Spring相关配置
spring:
# Jackson框架相关配置
jackson:
# JSON结果中是否包含为null的属性的默认配置
default-property-inclusion: non_null
18. 实现前后端交互
目前,后端(服务器端)项目使用默认端口8080,建议调整,可以通过配置文件中的server.port属性来指定。
例如,在application-dev.yml中添加配置:
# 服务端口
server:
port: 9080
再次启动项目,通过启动时的日志,可以看到后端项目启动在9080端口。
然后,还需要在后端项目中配置允许跨域访问,则需要在实现了WebMvcConfigurer接口的配置类中,通过重写addCorsMappings()方法进行配置!
则在根包下创建config.WebMvcConfiguration类,实现WebMvcConfigurer接口,添加@Configuration注解,并重写方法配置允许跨域访问:
package cn.tedu.csmall.product.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Spring MVC的配置类
*
* @author java@tedu.cn
* @version 0.0.1
*/
@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {
public WebMvcConfiguration() {
System.out.println("创建配置类:WebMvcConfiguration");
}
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("*")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
day08
19. 创建passport项目
创建新的项目,相关参数:
项目仓库:https://gitee.com/qschengheng2022/jsd2206-csmall-passport-teacher.git
项目名称:jsd2206-csmall-passport-teacher
Group:cn.tedu
Artifact:csmall-passport
Package:cn.tedu.csmall.passport
当项目创建成功后,需要先创建本项目所需的数据库mall_ams,然后导入SQL脚本,以创建数据表,及导入测试使用的数据,并在IntelliJ IDEA中配置Database面板。
接下来,调整当前项目的pom.xml文件:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<!-- 模块版本 -->
<modelVersion>4.0.0</modelVersion>
<!-- 父级项目 -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.5.9</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<!-- 当前项目的参数 -->
<groupId>cn.tedu</groupId>
<artifactId>csmall-passport</artifactId>
<version>0.0.1</version>
<!-- 属性配置 -->
<properties>
<java.version>1.8</java.version>
</properties>
<!-- 当前项目使用的依赖项 -->
<!-- scope > test:此依赖项仅用于测试,则此依赖项不会被打包,并且这些依赖项在src/main中不可用 -->
<!-- scope > runtime:此依赖项仅在运行时需要,即编写代码时并不需要此依赖项 -->
<!-- scope > provided:在执行(启动项目,或编译源代码)时,需要运行环境保证此依赖项一定是存在的 -->
<dependencies>
<!-- Spring Boot的Web依赖项,包含基础依赖项 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Mybatis整合Spring Boot的依赖项 -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.2.2</version>
</dependency>
<!-- MySQL的依赖项 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Lombok的依赖项,主要用于简化POJO类的编写 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.20</version>
<scope>provided</scope>
</dependency>
<!-- Spring Boot测试的依赖项 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
当添加以上依赖项后,项目暂时无法正常启动,也无法通过任何测试,必须配置连接数据库的相关参数!
先将application.properties重命名为application.yml,并创建出application-dev.yml,在application-dev.yml中配置:
spring:
datasource:
url: jdbc:mysql://localhost:3306/mall_ams?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Chongqing
username: root
password: root
并且,在application.yml激活application-dev.yml中的配置:
spring:
profiles:
active: dev
完成后,可以执行默认的测试类下的contextLoads()测试方法,应该可以通过测试。然后,在测试类中添加:
@Autowired
DataSource dataSource;
@Test
void testGetConnection() throws Exception {
dataSource.getConnection();
System.out.println("当前配置可以成功的连接到数据库!");
}
20. 添加管理员--Mapper层
使用Mybatis实现数据库编程时,必须要编写的配置:
在配置类上使用@MapperScan配置接口文件的根包
在配置文件中使用mybatis.mapper-locations属性配置XML文件的位置
则先在根包下创建config.MybatisConfiguration类,在类上添加@Configuration注解,并在类上添加@MapperScan("cn.tedu.csmall.passport.mapper"),例如:
package cn.tedu.csmall.passport.config;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan("cn.tedu.csmall.passport.mapper")
public class MybatisConfiguration {
}
在application.yml中添加配置:
mybatis:
mapper-locations: classpath:mapper/*.xml
接下来,在根包下创建pojo.entity.Admin实体类:
package cn.tedu.csmall.passport.pojo.entity;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 管理员的实体类
*
* @author java@tedu.cn
* @version 0.0.1
*/
@Data
public class Admin implements Serializable {
/**
* 数据id
*/
private Long id;
/**
* 用户名
*/
private String username;
/**
* 密码(密文)
*/
private String password;
/**
* 昵称
*/
private String nickname;
/**
* 头像URL
*/
private String avatar;
/**
* 手机号码
*/
private String phone;
/**
* 电子邮箱
*/
private String email;
/**
* 描述
*/
private String description;
/**
* 是否启用,1=启用,0=未启用
*/
private Integer enable;
/**
* 最后登录IP地址(冗余)
*/
private String lastLoginIp;
/**
* 累计登录次数(冗余)
*/
private Integer loginCount;
/**
* 最后登录时间(冗余)
*/
private LocalDateTime gmtLastLogin;
/**
* 数据创建时间
*/
private LocalDateTime gmtCreate;
/**
* 数据最后修改时间
*/
private LocalDateTime gmtModified;
}
在根包下创建mapper.AdminMapper接口,并在接口中添加抽象方法:
/**
* 处理管理员数据的Mapper接口
*
* @author java@tedu.cn
* @version 0.0.1
*/
@Repository
public interface AdminMapper {
/**
* 插入管理员数据
*
* @param admin 管理员数据
* @return 受影响的行数
*/
int insert(Admin admin);
}
在src/main/resources下创建mapper文件夹,并在mapper文件夹下粘贴得到AdminMapper.xml,配置以上抽象方法对应的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.csmall.passport.mapper.AdminMapper">
<!-- int insert(Admin admin); -->
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO ams_admin (
username, password, nickname, avatar, phone,
email, description, enable, last_login_ip, login_count,
gmt_last_login
) VALUES (
#{username}, #{password}, #{nickname}, #{avatar}, #{phone},
#{email}, #{description}, #{enable}, #{lastLoginIp}, #{loginCount},
#{gmtLastLogin}
)
</insert>
</mapper>
完成后,在src/test/java的根包下创建mapper.AdminMapperTests测试类,编写并执行测试:
@Slf4j
@SpringBootTest
public class AdminMapperTests {
@Autowired
AdminMapper mapper;
@Test
void testInsert() {
Admin admin = new Admin();
admin.setUsername("wangkejing");
admin.setPassword("123456");
admin.setPhone("13800138001");
admin.setEmail("wangkejing@baidu.com");
log.debug("插入数据之前,参数:{}", admin);
int rows = mapper.insert(admin);
log.debug("插入数据完成,受影响的行数:{}", rows);
log.debug("插入数据之后,参数:{}", admin);
}
}
在编写Service层之前,可以先分析一下“添加管理员”的业务规则,目前,应该设置的规则有:
用户名不允许重复
手机号码不允许重复
电子邮箱不允许重复
其它规则,暂不考虑
以上“不允许重复”的规则,都可以通过统计查询来实现,例如:
select count(*) from ams_admin where username=?;
select count(*) from ams_admin where phone=?;
select count(*) from ams_admin where email=?;
则在AdminMapper接口中添加:
/**
* 根据用户名统计管理员的数量
*
* @param username 用户名
* @return 匹配用户名的管理员的数据
*/
int countByUsername(String username);
/**
* 根据手机号码统计管理员的数量
*
* @param phone 手机号码
* @return 匹配手机号码的管理员的数据
*/
int countByPhone(String phone);
/**
* 根据电子邮箱统计管理员的数量
*
* @param email 电子邮箱
* @return 匹配电子邮箱的管理员的数据
*/
int countByEmail(String email);
并在AdminMapper.xml中配置以上3个抽象方法对应的3个<select>标签:
<!-- int countByUsername(String username); -->
<select id="countByUsername" resultType="int">
SELECT count(*) FROM ams_admin WHERE username=#{username}
</select>
<!-- int countByPhone(String phone); -->
<select id="countByPhone" resultType="int">
SELECT count(*) FROM ams_admin WHERE phone=#{phone}
</select>
<!-- int countByEmail(String email); -->
<select id="countByEmail" resultType="int">
SELECT count(*) FROM ams_admin WHERE email=#{email}
</select>
最后,在AdminMapperTests中编写并执行测试:
@Test
void testCountByUsername() {
String username = "wangkejing";
int count = mapper.countByUsername(username);
log.debug("根据用户名【{}】统计管理员账号的数量:{}", username, count);
}
@Test
void testCountByPhone() {
String phone = "13800138001";
int count = mapper.countByPhone(phone);
log.debug("根据手机号码【{}】统计管理员账号的数量:{}", phone, count);
}
@Test
void testCountByEmail() {
String email = "wangkejing@baidu.com";
int count = mapper.countByEmail(email);
log.debug("根据电子邮箱【{}】统计管理员账号的数量:{}", email, count);
}
21. 添加管理员--Service层
在根包下创建pojo.dto.AdminAddNewDTO类,封装需要由客户端提交的参数:
package cn.tedu.csmall.passport.pojo.dto;
import lombok.Data;
import java.io.Serializable;
/**
* 添加管理员的DTO类
*
* @author java@tedu.cn
* @version 0.0.1
*/
@Data
public class AdminAddNewDTO implements Serializable {
/**
* 用户名
*/
private String username;
/**
* 密码(原文)
*/
private String password;
/**
* 昵称
*/
private String nickname;
/**
* 头像URL
*/
private String avatar;
/**
* 手机号码
*/
private String phone;
/**
* 电子邮箱
*/
private String email;
/**
* 描述
*/
private String description;
/**
* 是否启用,1=启用,0=未启用
*/
private Integer enable;
}
在根包下创建service.IAdminService接口,并在接口中添加抽象方法:
public interface IAdminService {
void addNew(AdminAddNewDTO adminAddNewDTO);
}
在根包下创建service.impl.AdminServiceImpl类,实现以上接口,并在类上添加@Service注解,在类中自动装配AdminMapper对象:
@Service
public class AdminServiceImpl implements IAdminService {
@Autowired
AdminMapper adminMapper;
}
在实现接口中的方法之前,需要将前序项目(csmall-product)的ServiceCode和ServiceException复制到当前项目相同的位置。
并在以上实现类中实现接口中的方法:
public void addNew(AdminAddNewDTO adminAddNewDTO) {
// 从参数对象中获取username
// 调用adminMapper的countByUsername()方法执行统计查询
// 判断统计结果是否不等于0
// 是:抛出异常
// 从参数对象中获取手机号码
// 调用adminMapper的countByPhone()方法执行统计查询
// 判断统计结果是否不等于0
// 是:抛出异常
// 从参数对象中获取电子邮箱
// 调用adminMapper的countByEmail()方法执行统计查询
// 判断统计结果是否不等于0
// 是:抛出异常
// 创建Admin对象
// 通过BeanUtils.copyProperties()方法将参数对象的各属性值复制到Admin对象中
// 调用adminMapper的insert()方法插入数据
}
实际代码为:
package cn.tedu.csmall.passport.service.impl;
import cn.tedu.csmall.passport.ex.ServiceException;
import cn.tedu.csmall.passport.mapper.AdminMapper;
import cn.tedu.csmall.passport.pojo.dto.AdminAddNewDTO;
import cn.tedu.csmall.passport.pojo.entity.Admin;
import cn.tedu.csmall.passport.service.IAdminService;
import cn.tedu.csmall.passport.web.ServiceCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
/**
* 处理管理员数据的业务实现类
*
* @author java@tedu.cn
* @version 0.0.1
*/
@Slf4j
@Service
public class AdminServiceImpl implements IAdminService {
@Autowired
AdminMapper adminMapper;
public AdminServiceImpl() {
System.out.println("创建业务实现类:AdminServiceImpl");
}
@Override
public void addNew(AdminAddNewDTO adminAddNewDTO) {
log.debug("开始处理【添加管理员】的业务,参数:{}", adminAddNewDTO);
log.debug("即将检查用户名是否被占用……");
{
// 从参数对象中获取username
String username = adminAddNewDTO.getUsername();
// 调用adminMapper的countByUsername()方法执行统计查询
int count = adminMapper.countByUsername(username);
// 判断统计结果是否不等于0
if (count != 0) {
// 是:抛出异常
String message = "添加管理员失败,用户名【" + username + "】已经被占用!";
log.debug(message);
throw new ServiceException(ServiceCode.ERR_CONFLICT, message);
}
}
log.debug("即将检查手机号码是否被占用……");
{
// 从参数对象中获取手机号码
String phone = adminAddNewDTO.getPhone();
// 调用adminMapper的countByPhone()方法执行统计查询
int count = adminMapper.countByPhone(phone);
// 判断统计结果是否不等于0
if (count != 0) {
// 是:抛出异常
String message = "添加管理员失败,手机号码【" + phone + "】已经被占用!";
log.debug(message);
throw new ServiceException(ServiceCode.ERR_CONFLICT, message);
}
}
log.debug("即将检查电子邮箱是否被占用……");
{
// 从参数对象中获取电子邮箱
String email = adminAddNewDTO.getEmail();
// 调用adminMapper的countByEmail()方法执行统计查询
int count = adminMapper.countByEmail(email);
// 判断统计结果是否不等于0
if (count != 0) {
// 是:抛出异常
String message = "添加管理员失败,电子邮箱【" + email + "】已经被占用!";
log.debug(message);
throw new ServiceException(ServiceCode.ERR_CONFLICT, message);
}
}
// 创建Admin对象
Admin admin = new Admin();
// 通过BeanUtils.copyProperties()方法将参数对象的各属性值复制到Admin对象中
BeanUtils.copyProperties(adminAddNewDTO, admin);
// TODO 从Admin对象中取出密码,进行加密处理,并将密文封装回Admin对象中
// 补全Admin对象中的属性值:loginCount >>> 0
admin.setLoginCount(0);
// 调用adminMapper的insert()方法插入数据
log.debug("即将插入管理员数据,参数:{}", admin);
adminMapper.insert(admin);
}
}
完成后,在src/test/java下的根包下创建service.AdminServiceTests测试类,编写并执行测试(在测试方法中记得使用try...catch):
package cn.tedu.csmall.passport.service;
import cn.tedu.csmall.passport.ex.ServiceException;
import cn.tedu.csmall.passport.pojo.dto.AdminAddNewDTO;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@Slf4j
@SpringBootTest
public class AdminServiceTests {
@Autowired
IAdminService service;
@Test
void testAddNew() {
AdminAddNewDTO adminAddNewDTO = new AdminAddNewDTO();
adminAddNewDTO.setUsername("wangkejing3");
adminAddNewDTO.setPassword("123456");
adminAddNewDTO.setPhone("13800138003");
adminAddNewDTO.setEmail("wangkejing3@baidu.com");
try {
service.addNew(adminAddNewDTO);
log.debug("添加管理员成功!");
} catch (ServiceException e) {
log.debug("{}", e.getMessage());
}
}
}
22. 添加管理员--Controller层
由于非常推荐自行指定服务端口,则在application-dev.yml中添加配置:
# 服务端口
server:
port: 9081
将前序项目中的JsonResult类复制到当前项目同样位置。
在根包下创建controller.AdminController类,添加@RestController注解和@RequestMapping("/admins")注解,并在类中添加处理请求的方法:
package cn.tedu.csmall.passport.controller;
import cn.tedu.csmall.passport.pojo.dto.AdminAddNewDTO;
import cn.tedu.csmall.passport.service.IAdminService;
import cn.tedu.csmall.passport.web.JsonResult;
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("/admins")
public class AdminController {
@Autowired
IAdminService adminService;
public AdminController() {
log.info("创建控制器类:AdminController");
}
// http://localhost:9081/admins/add-new?username=aa&phone=bb&email=cc
@RequestMapping("/add-new")
public JsonResult addNew(AdminAddNewDTO adminAddNewDTO) {
log.debug("开始处理【添加管理员】的请求,参数:{}", adminAddNewDTO);
adminService.addNew(adminAddNewDTO);
return JsonResult.ok();
}
}
在application.yml中添加配置,使得为null的属性不会显示在响应的JSON数据中:
spring:
# Jackson框架相关配置
jackson:
# JSON结果中是否包含为null的属性的默认配置
default-property-inclusion: non_null
【处理异常】
完成后,重启项目,可以通过 http://localhost:9081/admins/add-new?username=aa&phone=bb&email=cc 测试访问。
23. 关于Knife4j框架
Knife4j是一款基于Swagger 2的在线API文档框架。
使用Knife4j,需要:
添加Knife4j的依赖
当前建议使用的Knife4j版本,只适用于Spring Boot 2.6以下版本,不含Spring Boot 2.6
在主配置文件(application.yml)中开启Knife4j的增强模式
必须在主配置文件中进行配置,不要配置在个性化配置文件中
添加Knife4j的配置类,进行必要的配置
必须指定控制器的包
关于依赖项的代码:
<!-- Knife4j Spring Boot:在线API -->
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-spring-boot-starter</artifactId>
<version>2.0.9</version>
</dependency>
在application.yml中添加配置:
knife4j:
enable: true
在根包下创建config.Knife4jConfiguration配置类:
package cn.tedu.csmall.passport.config;
import com.github.xiaoymin.knife4j.spring.extension.OpenApiExtensionResolver;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.service.Contact;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2WebMvc;
/**
* Knife4j配置类
*
* @author java@tedu.cn
* @version 0.0.1
*/
@Slf4j
@Configuration
@EnableSwagger2WebMvc
public class Knife4jConfiguration {
/**
* 【重要】指定Controller包路径
*/
private String basePackage = "cn.tedu.csmall.passport.controller";
/**
* 分组名称
*/
private String groupName = "passport";
/**
* 主机名
*/
private String host = "http://java.tedu.cn";
/**
* 标题
*/
private String title = "酷鲨商城在线API文档--管理员管理";
/**
* 简介
*/
private String description = "酷鲨商城在线API文档--管理员管理";
/**
* 服务条款URL
*/
private String termsOfServiceUrl = "http://www.apache.org/licenses/LICENSE-2.0";
/**
* 联系人
*/
private String contactName = "Java教学研发部";
/**
* 联系网址
*/
private String contactUrl = "http://java.tedu.cn";
/**
* 联系邮箱
*/
private String contactEmail = "java@tedu.cn";
/**
* 版本号
*/
private String version = "1.0.0";
@Autowired
private OpenApiExtensionResolver openApiExtensionResolver;
public Knife4jConfiguration() {
log.debug("加载配置类:Knife4jConfiguration");
}
@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:9081/doc.html 即可访问在线API文档!
关于Knife4j的在线API文档,可以通过一系列注解来配置此文件的显示:
@Api:添加在控制器类上,通过此注解的tags属性,可以指定模块名称,并且,在指定名称时,建议在名称前添加数字作为序号,Knife4j会根据这些数字将各模块升序排列,例如:
@Api(tags = "01. 管理员管理模块")
@ApiOpearation:添加在控制器类中处理请求的方法上,通过此注解的value属性,可以指定业务/请求资源的名称,例如:
@ApiOperation("添加管理员")
@ApiOperationSupport:添加在控制器类中处理请求的方法上,通过此注解的order属性(int),可以指定排序序号,Knife4j会根据这些数字将各业务/请求资源升序排列,例如:
@ApiOperationSupport(order = 100)
24. 关于@RequestBody
在Spring MVC框架中,在处理请求的方法的参数前:
当添加了@RequestBody注解,则客户端提交的请求参数必须是对象格式的,例如:
{
"name": "小米11的相册",
"description": "小米11的相册的简介",
"sort": 88
}
如果客户端提交的数据不是对象,而是FormData格式的,在接收到请求时将报错:
org.springframework.web.HttpMediaTypeNotSupportedException: Content type 'application/x-www-form-urlencoded;charset=UTF-8' not supported
当没有添加@RequestBody注解,则客户端提交的请求参数必须是FormData格式的,例如:
name=小米11的相册&description=小米11的相册的简介&sort=88
如果客户端提交的数据不是FormData格式的,而是对象,则无法接收到参数(不会报错,控制器中各参数值为null)
另外,Knife4j框架的调试界面中,如果是对象格式的参数(使用了@RequestBody),将不会显示各请求参数的输入框,而是提供一个JSON字符串供编辑,如果是FormData格式的参数(没有使用@RequestBody),则会显示各请求参数对应的输入框。
通常,更建议使用FormData格式的请求参数!则在控制器处理请求的方法的参数上不需要添加@RequestBody注解!
在Vue脚手架项目中,为了更便捷的使用FormData格式的请求参数,可以在项目中使用qs框架,此框架的工具可以轻松的将JavaScript对象转换成FormData格式!
则在前端的Vue脚手架项目中,先安装qs:
npm i qs -S
然后,在main.js中添加配置:
import qs from 'qs';
Vue.prototype.qs = qs;
最后,在提交请求之前,可以将对象转换成FormData格式,例如:
let formData = this.qs.stringify(this.ruleForm);
console.log('formData:' + formData);
day09
25. 关于Knife4j框架(续)
如果处理请求时,参数是封装的POJO类型,需要对各请求参数进行说明时,应该在此POJO类型的各属性上使用@ApiModelProperty注解进行配置,通过此注解的value属性配置请求参数的名称,通过requeired属性配置是否必须提交此请求参数(并不具备检查功能),例如:
@Data
public class AlbumAddNewDTO implements Serializable {
/**
* 相册名称
*/
@ApiModelProperty(value = "相册名称", example = "小米10的相册", required = true)
private String name;
/**
* 相册简介
*/
@ApiModelProperty(value = "相册简介", example = "小米10的相册的简介", required = true)
private String description;
/**
* 排序序号
*/
@ApiModelProperty(value = "排序序号", example = "98", required = true)
private Integer sort;
}
需要注意,@ApiModelProperty还可以用于配置响应时的各数据!
对于处理请求的方法的参数列表中那些未封装的参数(例如String、Long),需要在处理请求的方法上使用@ApiImplicitParam注解来配置参数的说明,并且,必须配置name属性,此属性的值就是方法的参数名称,使得此注解的配置与参数对应上,然后,再通过value属性对参数进行说明,还要注意,此属性的required属性表示是否必须提交此参数,默认为false,即使是用于配置路径上的占位符参数,一旦使用此注解,required默认也会是false,则需要显式的配置为true,另外,还可以通过dataType配置参数的数据类型,如果未配置此属性,在API文档中默认显示为string,可以按需修改为int、long等。例如:
@ApiImplicitParam(name = "id", value = "相册id", required = true, dataType = "long")
@PostMapping("/{id:[0-9]+}/delete")
public JsonResult delete(@PathVariable Long id) {
// 暂不关心方法内部的代码
}
如果处理请求的方法上有多个未封装的参数,则需要使用多个@ApiImplicitParam注解进行配置,并且,这多个@ApiImplicitParam注解需要作为@ApiImplicitParams注解的参数,例如:
@ApiImplicitParams({
@ApiImplicitParam(xxx),
@ApiImplicitParam(xxx),
@ApiImplicitParam(xxx)
})
26. 关于检查请求参数
在编写服务器端项目时,当接收到请求参数时,必须第一时间对各请求参数的基本格式进行检查!
需要注意:既然客户端(例如网页)已经检查了请求参数,服务器端应该再次检查,因为:
客户端的程序是运行在用户的设备上的,存在程序被篡改的可能性,所以,提交的数据或执行的检查是不可信的
在前后端分离的开发模式下,客户端的种类可能较多,例如网页端、手机端、电视端,可能存在某些客户端没有检查
升级了某些检查规则,但是,用户的设备上,客户端软件没有升级(例如手机APP还是此前的版本)
以上原因都可能导致客户端没有提交必要的数据,或客户端的检查不完全符合服务器端的要求!所以,对于服务器端而言,客户端提交的所有数据都是不可信的!则服务器端需要对请求参数进行检查!
即使服务器端已经对所有请求参数进行了检查,各个客户端仍应该检查请求参数,因为:
能更早的发现明显错误的数据,对应的请求将不会提交到服务器端,能够减轻服务器端的压力
客户端的检查不需要与服务器端交互,当出现错误时,能及时得到反馈,对于用户的体验更好
27. 通过Validation框架检查请求参数的基本格式
27.1. 添加依赖
Spring Validation框架可用于在服务器端检查请求参数的基本格式(例如是否提交了请求参数、字符串的长度是否正确、数字的大小是否在允许的区间等)。
首先,添加依赖项:
<!-- Spring Boot Validation,用于检查请求参数的基本格式 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
27.2. 检查封装在POJO中的请求参数
如果请求参数使用自定义的POJO类型进行封装,当需要检查这些请求参数的基本格式时,需要:
在处理请求的方法的参数列表中,在POJO类型前添加@Validated或@Valid注解,表示需要通过Spring Validation框架对此POJO类型封装的请求参数进行检查
在POJO类型的属性上,使用检查注解来配置检查规则,例如@NotNull注解就表示“不允许为null”,即客户端必须提交此请求参数
所有检查注解都有message属性,配置此属性,可用于向客户端响应相关的错误信息。
由于Spring Validation验证请求参数格式不通过时,会抛出异常,所以,可以在全局异常处理器中对此类异常进行处理!
先在ServiceCode中添加对应的枚举值:
ERR_BAD_REQUEST(40000)
然后,在全局异常处理器中添加对org.springframework.validation.BindException的处理:
@ExceptionHandler
public JsonResult handleBindException(BindException e) {
log.debug("捕获到BindException:{}", e.getMessage());
// 以下2行代码,如果有多种错误时,将随机获取其中1种错误的信息,并响应
// String message = e.getFieldError().getDefaultMessage();
// return JsonResult.fail(ServiceCode.ERR_BAD_REQUEST, message);
// ===============================
// 以下代码,如果有多种错误时,将获取所有错误信息,并响应
StringBuilder stringBuilder = new StringBuilder();
List<FieldError> fieldErrors = e.getFieldErrors();
for (FieldError fieldError : fieldErrors) {
stringBuilder.append(fieldError.getDefaultMessage());
}
return JsonResult.fail(ServiceCode.ERR_BAD_REQUEST, stringBuilder.toString());
}
27.3. 检查时快速失败
可以发现,Spring Validation在检查请求参数格式时,如果检查不通过,会记录下相关的错误,然后,继续进行其它检查,直到所有检查全部完成,才会返回错误信息!
检查全部的错误,相对更加消耗服务器资源,可以通过配置,使得检查出错时直接结束并返回错误!
package cn.tedu.csmall.product.config;
import lombok.extern.slf4j.Slf4j;
import org.hibernate.validator.HibernateValidator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.validation.Validation;
/**
* Spring Validation的配置类
*
* @author java@tedu.cn
* @version 0.0.1
*/
@Slf4j
@Configuration
public class ValidationConfiguration {
public ValidationConfiguration() {
log.debug("创建配置类:ValidationConfiguration");
}
@Bean
public javax.validation.Validator validator() {
return Validation.byProvider(HibernateValidator.class)
.configure() // 开始配置Validator
.failFast(true) // 快速失败,即检查请求参数发现错误时直接视为失败,并不向后继续检查
.buildValidatorFactory()
.getValidator();
}
}
27.4. 常用检查注解
关于检查注解,常用的有:
@NotNull:不允许为null,适用于所有类型的请求参数
@NotEmpty:不允许为空字符串(长度为0的字符串),仅适用于字符串类型的请求参数
此注解不检查是否为null,即请求参数为null将通过检查
此注解可以与@NotNull同时使用
@NotBlank:不允许为空白(形成空白的主要有:空格、TAB制表位、换行等),仅适用于字符串类型的请求参数
此注解不检查是否为null,即请求参数为null将通过检查
此注解可以与@NotNull同时使用
@Pattern:要求被检查的请求参数必须匹配某个正则表达式,通过此注解的regexp属性可以配置正则表达式,仅适用于字符串类型的请求参数
此注解不检查是否为null,即请求参数为null将通过检查
此注解可以与@NotNull同时使用
@Range:要求被检查的数值型请求参数必须在某个数值区间范围内,通过此注解的min属性可以配置最小值,通过此注解的max属性可以配置最大值,仅适用于数值类型的请求参数
此注解不检查是否为null,即请求参数为null将通过检查
此注解可以与@NotNull同时使用
另外,在org.hibernate.validator.constraints和javax.validation.constraints包还有其它检查注解。
27.5. 检查基本值的请求参数
如果请求参数是一些基本值,没有封装(例如String、Integer、Long),则需要将检查注解添加在请求参数上,例如:
@Deprecated
@ApiOperation("删除相册【测试2】")
@ApiOperationSupport(order = 910)
@ApiImplicitParam(name = "id", value = "相册id", paramType = "query")
@PostMapping("/delete/test2")
// ===== 重点关注以下方法参数上的注解 =====
public String deleteTest(@Range(min = 1, message = "测试删除相册失败,id值必须是1或更大的有效整数!") Long id) {
log.debug("【测试】开始处理【删除相册】的请求,这只是一个测试,没有实质功能!");
return "OK";
}
然后,还需要在控制器类上添加@Validated注解,以上方法参数前的检查注解才会生效!如果后续运行时没有通过此检查,Spring Validation框架将抛出ConstraintViolationException类型的异常,例如:
javax.validation.ConstraintViolationException: deleteTest.id: 测试删除相册失败,id值必须是1或更大的有效整数!
则在全局异常处理器中添加处理以上异常的方法:
@ExceptionHandler
public JsonResult handleConstraintViolationException(ConstraintViolationException e) {
log.debug("捕获到ConstraintViolationException:{}", e.getMessage());
StringBuilder stringBuilder = new StringBuilder();
Set<ConstraintViolation<?>> constraintViolations = e.getConstraintViolations();
for (ConstraintViolation<?> constraintViolation : constraintViolations) {
stringBuilder.append(constraintViolation.getMessage());
}
return JsonResult.fail(ServiceCode.ERR_BAD_REQUEST, stringBuilder.toString());
}
28. 显示相册列表--Mapper层
查询相册列表的功能此前已经实现!
29. 显示相册列表--Service层
在IAlbumService接口中添加抽象方法:
/**
* 查询相册列表
*
* @return 相册列表
*/
List<AlbumListItemVO> list();
在AlbumServiceImpl类中实现以上方法:
@Override
public List<AlbumListItemVO> list() {
log.debug("开始处理【查询相册列表】的业务");
return albumMapper.list();
}
在AlbumServiceTests中编写并执行测试:
@Test
void testList() {
List<?> list = service.list();
System.out.println("查询列表完成,列表中的数据的数量=" + list.size());
for (Object item : list) {
System.out.println(item);
}
}
30. 显示相册列表--Controller层
服务器端处理请求后响应的都是JsonResult对象,但是,目前,此类型中并不足以表示“响应到客户端的数据”,需要在类中补充新的属性,用于封装响应到客户端的数据!
则先调整JsonResult类:
package cn.tedu.csmall.product.web;
import cn.tedu.csmall.product.ex.ServiceException;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
@Data
public class JsonResult<T> implements Serializable {
/**
* 业务状态码
*/
@ApiModelProperty("业务状态码")
private Integer state;
/**
* 操作失败时的提示文本
*/
@ApiModelProperty("操作失败时的提示文本")
private String message;
/**
* 操作成功时的响应数据
*/
@ApiModelProperty("操作成功时的响应数据")
private T data;
public static JsonResult<Void> ok() {
// JsonResult jsonResult = new JsonResult();
// jsonResult.state = ServiceCode.OK.getValue();
// jsonResult.message = null;
// jsonResult.data = null;
// return jsonResult;
return ok(null);
}
public static <T> JsonResult<T> ok(T data) {
JsonResult jsonResult = new JsonResult();
jsonResult.state = ServiceCode.OK.getValue();
jsonResult.message = null;
jsonResult.data = data;
return jsonResult;
}
public static JsonResult<Void> fail(ServiceException e) {
// JsonResult jsonResult = new JsonResult();
// jsonResult.state = e.getServiceCode().getValue();
// jsonResult.message = e.getMessage();
// return jsonResult;
return fail(e.getServiceCode(), e.getMessage());
}
public static JsonResult<Void> fail(ServiceCode serviceCode, String message) {
JsonResult jsonResult = new JsonResult();
jsonResult.state = serviceCode.getValue();
jsonResult.message = message;
return jsonResult;
}
}
然后,在AlbumController中添加处理请求的方法:
// http://localhost:9080/albums
@ApiOperation("查询相册列表")
@ApiOperationSupport(order = 420)
@GetMapping("")
public JsonResult<List<AlbumListItemVO>> list() {
log.debug("开始处理【查询相册列表】的请求");
List<AlbumListItemVO> list = albumService.list();
return JsonResult.ok(list);
}
完成后,重启项目,通过在线API文档的调试功能可以测试访问。
作业
完成以下功能,最终通过在线API文档的调试功能可以测试访问即可:
显示品牌列表
显示属性模板列表
显示管理员列表(在passport项目中)
day10
31. 关于Spring框架
31.1. Spring框架的作用
Spring框架主要解决了创建对象、管理对象的相关问题。
创建对象,例如:
User user = new User();
管理对象:Spring会在创建对象之后,完成必要的属性赋值等操作,并且,还会持有所创建的对象的引用,由于持久大量对象的引用,所以,Spring框架也通常被称之为“Spring容器”。
32.2. Spring框架创建对象的做法
Spring框架创建对象有2种做法,第1种是通过配置类中的@Bean方法,第2种是通过组件扫描。
关于@Bean方法:在任何配置类中,自定义返回对象的方法,并在方法上添加@Bean注解,则Spring会自动调用此方法,并且,获取此方法返回的对象,将对象保存在Spring容器中,例如:
@Configuration
public class BeanFactory {
@Bean
public LocalDateTime localDateTime() {
return LocalDateTime.now();
}
// 如果使用这种做法,则AlbumController不必使用组件扫描的做法
@Bean
public AlbumController albumController() {
return new AlbumController();
}
// 如果使用这种做法,则AlbumServiceImpl不必使用组件扫描的做法
@Bean
public AlbumServiceImpl albumServiceImpl() {
return new AlbumServiceImpl();
}
}
关于组件扫描:需要通过@ComponentScan注解来指定扫描的根包,则Spring框架会在此根包下查找组件,并且创建这些组件的对象。
根包:某个包及其子孙包,例如,指定的根包是cn.tedu.csmall.product,则cn.tedu.csmall.product、cn.tedu.csmall.product.controller、cn.tedu.csmall.product.service.impl都属于根包的范围之内!
组件:在Spring框架中,添加了@Component及其衍生注解的,都是组件!常见的组件注解有:
@Component:通用组件注解
@Controller:应该添加在控制器类上
@Service:应该添加在处理业务逻辑的类上
@Repository:应该添加在处理数据访问(直接与数据源交互)的类上
@Configuration:应该添加在配置类上
以上5个组件注解,除了@Configuration以外,另外4个在功能、用法、执行效果方面,在Spring框架的作用范围内是完全相同的,只是语义不同!Spring框架在处理@Configuration注解时,会使用CGLib的代理模式来创建对象,并且,被Spring实际使用的是代理对象。
提示:在Spring Boot项目中,启动类上的注解@SpringBootApplication,此注解的元注解包含@ComponentScan注解,所以,Spring Boot项目启动时就会执行组件扫描,扫描的根包就是启动类所在的包!
在开发实践中,如果需要创建非自定义类(例如Java官方的类,或其它框架中的类)的对象,必须使用@Bean方法,毕竟你不能在别人声明的类上添加组件注解,如果需要创建自定义类的对象,则优先使用组件扫描的做法,因为这种做法更加简单!
32.3. Spring管理的对象的作用域
Spring管理的对象默认是“单例”的,则在整个程序的运行过程中,随时可以获取或访问Spring容器中的“单例”对象!
注意:Spring并没有实际使用单例模式!
单例:单一实例(单一对象),即:在任意时间,某个类的对象最多只有1个!
如果需要Spring管理某个对象采取“非单例”的模式,可以通过@Scope("prototype")注解来实现!
提示:如果是通过@Bean方法创建对象,则@Scope("prototype")注解添加在@Bean方法上,如果是通过组件扫描创建对象,则@Scope("prototype")注解添加在组件类上。
如果没有Spring框架,自己手动实现单例效果,大致需要:
public class Singleton {
private static Singleton instance = new Singleton();
private Singleton() {}
public static Singleton getInstance() {
return instance;
}
}
public class Singleton {
private static final Object lock = new Object();
private static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (lock) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
Spring管理的单例对象,默认情况下预加载的!可以通过@Lazy注解配置为懒加载的!
提示:如果是通过@Bean方法创建对象,则@Lazy注解添加在@Bean方法上,如果是通过组件扫描创建对象,则@Lazy注解添加在组件类上。
32.4. 自动装配机制
Spring的自动装配机制表现为:当Spring管理的类的属性需要被自动赋值,或Spring调用的方法的参数需要值时,Spring会自动从容器中找到合适的值,为属性 / 参数自动赋值!
当类的属性需要值时,可以在属性上添加@Autowired注解。
关于被Spring调用的方法,主要表现为:构造方法、配置类中的@Bean方法等。
关于调用构造方法:
如果类中存在无参数构造方法(无论是否存在其它构造方法),Spring会自动调用无参数构造方法
如果类中仅有1个构造方法,Spring会自动尝试调用,且,如果此构造方法有参数,Spring会自动尝试从容器中查找合适的值用于调用此构造方法
如果类中有多个构造方法,且都是有参数的,Spring不会自动调用任何构造方法,且会报错
如果希望Spring调用特定的构造方法,应该在那一个构造方法上添加@Autowired注解
关于在属性上使用@Autowired时的提示:Field injection is not recommended,其意思是“字段注入是不推荐的”,因为,开发工具认为,你有可能在某些情况下自行创建当前类的对象,例如自行编写代码:AlbumController albumController = new AlbumController();,由于是自行创建的对象,Spring框架在此过程中是不干预的,则类的属性IAlbumService albumService将不会由Spring注入值,如果此时你也没有为这个属性赋值,则这个的属性就是null,如果还执行类中的方法,就可能导致NPE(NullPointerException),这种情况可能发生在单元测试中。开发工具建议使用构造方法注入,即使用带参数的构造方法,且通过构造方法为属性赋值,并且类中只有这1个构造方法,在这种情况下,即使自行创建对象,由于唯一的构造方法是带参数的,所以,创建对象时也会为此参数赋值,不会出现属性没有值的情况,所以,通过构造方法为属性注入值的做法被认为是安全的,是建议使用的做法!但是,在开发实践,通常并不会使用构造方法注入属性的值,因为,属性的增、减都需要调整构造方法,并且,如果类中需要注入值的属性较多,也会导致构造方法的参数较多,不是推荐的!
关于合适的值:Spring框架会查找容器中匹配类型的对象的数量:
0个:无法装配,需要判断@Autowired注解的required属性:
true:在加载Spring时直接报错NoSuchBeanDefinitionException
false:放弃自动装配,且尝试自动装配的属性将是默认值
1个:直接装配,且装配成功
超过1个:尝试按照名称来匹配,如果均不匹配,则在加载Spring时直接报错NoUniqueBeanDefinitionException,按照名称匹配时,要求被装配的变量名与Bean Name保持一致
关于Bean Name:每个Spring Bean都有一个Bean Name,如果是通过@Bean方法创建的对象,则Bean Name就是方法名,或通过@Bean注解参数来指定名称,如果是通过组件扫描的做法来创建的对象,则Bean Name默认是将类名首字母改为小写的名称(例如,类名为AlbumServiceImpl,则Bean Name为albumServiceImpl)(此规则只适用于类名中第1个字母大写、第2个字母小写的情况,如果不符合此情况,则Bean Name就是类名),Bean Name也可以通过@Component等注解的参数进行配置,或者,你还可以在需要装配的属性上使用@Qualifier注解来指定装配哪个Bean Name对应的Spring Bean。
另外,在处理属性的自动装配上,还可以使用@Resource注解取代@Autowired注解,@Resource是先根据名称尝试装配,再根据类型装配的机制!
day11
32. 删除管理员--Mapper层
删除管理员需要执行的SQL大致是:
delete from ams_admin where id=?
在删除之前,还应该检查数据是否存在,可以通过以下SQL查询来实现检查:
select count(*) from ams_admin where id=?
select * from ams_admin where id=?
首先,在pojo.vo包下创建AdminStandardVO类:
package cn.tedu.csmall.passport.pojo.vo;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 管理员的标准VO类
*
* @author java@tedu.cn
* @version 0.0.1
*/
@Data
public class AdminStandardVO implements Serializable {
/**
* 数据id
*/
private Long id;
/**
* 用户名
*/
private String username;
/**
* 昵称
*/
private String nickname;
/**
* 头像URL
*/
private String avatar;
/**
* 手机号码
*/
private String phone;
/**
* 电子邮箱
*/
private String email;
/**
* 描述
*/
private String description;
/**
* 是否启用,1=启用,0=未启用
*/
private Integer enable;
/**
* 最后登录IP地址(冗余)
*/
private String lastLoginIp;
/**
* 累计登录次数(冗余)
*/
private Integer loginCount;
/**
* 最后登录时间(冗余)
*/
private LocalDateTime gmtLastLogin;
}
然后,在AdminMapper.java中添加:
/**
* 根据id删除管理员数据
*
* @param id 管理员id
* @return 受影响的行数
*/
int deleteById(Long id);
/**
* 查询管理员列表
*
* @return 管理员列表
*/
List<AdminListItemVO> list();
在AdminMapper.xml中配置:
<!-- int deleteById(Long id); -->
<delete id="deleteById">
DELETE FROM ams_admin WHERE id=#{id}
</delete>
<!-- AdminStandardVO getStandardById(Long id); -->
<select id="getStandardById" resultMap="StandardResultMap">
SELECT
<include refid="StandardQueryFields" />
FROM
ams_admin
WHERE
id=#{id}
</select>
<!-- List<AdminListItemVO> list(); -->
<select id="list" resultMap="ListResultMap">
SELECT
<include refid="ListQueryFields" />
FROM
ams_admin
ORDER BY
enable DESC, id
</select>
<sql id="StandardQueryFields">
<if test="true">
id, username, nickname, avatar, phone,
email, description, enable, last_login_ip, login_count,
gmt_last_login
</if>
</sql>
<sql id="ListQueryFields">
<if test="true">
id, username, nickname, avatar, phone,
email, description, enable, last_login_ip, login_count,
gmt_last_login
</if>
</sql>
<resultMap id="StandardResultMap" type="cn.tedu.csmall.passport.pojo.vo.AdminStandardVO">
<id column="id" property="id"/>
<result column="username" property="username"/>
<result column="nickname" property="nickname"/>
<result column="avatar" property="avatar"/>
<result column="phone" property="phone"/>
<result column="email" property="email"/>
<result column="description" property="description"/>
<result column="enable" property="enable"/>
<result column="last_login_ip" property="lastLoginIp"/>
<result column="login_count" property="loginCount"/>
<result column="gmt_last_login" property="gmtLastLogin"/>
</resultMap>
完成后,在AdminMapperTests中编写并执行测试:
@Test
void testDeleteById() {
Long id = 11L;
int rows = mapper.deleteById(id);
System.out.println("删除数据完成,受影响的行数=" + rows);
}
@Test
void testGetStandardById() {
Long id = 1L;
Object result = mapper.getStandardById(id);
System.out.println("根据id=" + id + "查询标准信息完成,结果=" + result);
}
33. 删除管理员--Service层
在IAdminService接口中添加抽象方法:
/**
* 删除管理员
*
* @param id 尝试删除的管理员的id
*/
void delete(Long id);
在AdminServiceImpl中实现:
@Override
public void delete(Long id) {
log.debug("开始处理【删除管理员】的业务,参数:{}", id);
// 根据参数id查询管理员数据
AdminStandardVO queryResult = adminMapper.getStandardById(id);
// 判断管理员数据是否不存在
if (queryResult == null) {
String message = "删除管理员失败,尝试访问的数据不存在!";
log.warn(message);
throw new ServiceException(ServiceCode.ERR_NOT_FOUND, message);
}
// 执行删除管理员
adminMapper.deleteById(id);
}
在AdminServiceTests中编写并执行测试:
@Test
void testDelete() {
Long id = 9L;
try {
service.delete(id);
System.out.println("删除成功!");
} catch (ServiceException e) {
System.out.println(e.getMessage());
}
}
34. 删除管理员--Controller层
在AdminController中添加处理请求的方法:
// http://localhost:8080/admins/9527/delete
@ApiOperation("删除管理员")
@ApiOperationSupport(order = 200)
@ApiImplicitParam(name = "id", value = "管理员id", required = true, dataType = "long")
@PostMapping("/{id:[0-9]+}/delete")
public JsonResult<Void> delete(@PathVariable Long id) {
log.debug("开始处理【删除管理员】的请求,参数:{}", id);
adminService.delete(id);
return JsonResult.ok();
}
完成后,重启项目,通过Knife4j在线API文档进行测试访问。
35. 删除管理员--前端
36. 完善添加管理员
添加管理员时,还需要为管理员分配某些(某种)角色,进而使得该管理员具备某些操作权限!
为管理分配角色的本质是向ams_admin_role这张表中插入数据!同时,需要注意,每个管理员账号可能对应多种角色,所以,需要执行的SQL语句大致是:
insert into ams_admin_role (admin_id, role_id) values (?, ?), (?, ?) ... (?, ?);
在pojo.entity包下创建AdminRole实体类:
package cn.tedu.csmall.passport.pojo.entity;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 管理员与角色的关联数据的实体类
*
* @author java@tedu.cn
* @version 0.0.1
*/
@Data
public class AdminRole implements Serializable {
/**
* 数据id
*/
private Long id;
/**
* 管理员id
*/
private Long adminId;
/**
* 角色id
*/
private Long roleId;
/**
* 数据创建时间
*/
private LocalDateTime gmtCreate;
/**
* 数据最后修改时间
*/
private LocalDateTime gmtModified;
}
在mapper包下创建AdminRoleMapper接口,并在接口中添加抽象方法:
package cn.tedu.csmall.passport.mapper;
import cn.tedu.csmall.passport.pojo.entity.AdminRole;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* 处理管理员与角色的关联数据的Mapper接口
*
* @author java@tedu.cn
* @version 0.0.1
*/
@Repository
public interface AdminRoleMapper {
/**
* 批量插入管理员与角色的关联数据
*
* @param adminRoleList 管理员与角色的关联数据的列表
* @return 受影响的行数
*/
int insertBatch(List<AdminRole> adminRoleList);
}
在src/main/resources/mapper文件夹下通过复制粘贴得到AdminRoleMapper.xml文件,在此文件中配置以上抽象方法映射的SQL语句:
<mapper namespace="xx.xx.xx.xx.AdminRoleMapper">
<!-- int insertBatch(List<AdminRole> adminRoleList); -->
<insert id="insertBatch" useGeneratedKeys="true" keyProperty="id">
INSERT INTO ams_admin_role (admin_id, role_id) VALUES
<foreach collection="list" item="adminRole" separator=",">
(#{adminRole.adminId}, #{adminRole.roleId})
</foreach>
</insert>
</mapper>
完成后,在src/test/java下的根包下创建mapper.AdminRoleMapperTests测试类,编写并执行测试:
package cn.tedu.csmall.passport.mapper;
import cn.tedu.csmall.passport.pojo.entity.AdminRole;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@SpringBootTest
public class AdminRoleMapperTests {
@Autowired
AdminRoleMapper mapper;
@Test
void testInsertBatch() {
List<AdminRole> adminRoleList = new ArrayList<>();
for (int i = 1; i <= 3; i++) {
AdminRole adminRole = new AdminRole();
adminRole.setAdminId(100L);
adminRole.setRoleId(i + 0L);
adminRoleList.add(adminRole);
}
int rows = mapper.insertBatch(adminRoleList);
log.debug("批量插入数据完成!受影响的行数={}", rows);
}
}
完成Mapper层的补充后,先在AdminAddNewDTO中添加新的属性,表示“客户端将提交到服务器端的,添加管理员时选择的若干个角色id”:
/**
* 尝试添加的管理员的角色id列表
*/
private Long[] roleIds;
然后,在AdminServiceImpl类中,补充声明:
@Autowired
AdminRoleMapper adminRoleMapper;
并在addNew()方法的最后补充:
// 调用adminRoleMapper的insertBatch()方法插入关联数据
Long[] roleIds = adminAddNewDTO.getRoleIds();
List<AdminRole> adminRoleList = new ArrayList<>();
for (int i = 0; i < roleIds.length; i++) {
AdminRole adminRole = new AdminRole();
adminRole.setAdminId(admin.getId());
adminRole.setRoleId(roleIds[i]);
adminRoleList.add(adminRole);
}
adminRoleMapper.insertBatch(adminRoleList);
完成后,在AdminServiceTests中原有的测试中,在测试数据上添加封装roleIds属性的值,并进行测试:
@Test
void testAddNew() {
AdminAddNewDTO adminAddNewDTO = new AdminAddNewDTO();
adminAddNewDTO.setUsername("wangkejing6");
adminAddNewDTO.setPassword("123456");
adminAddNewDTO.setPhone("13800138006");
adminAddNewDTO.setEmail("wangkejing6@baidu.com");
adminAddNewDTO.setRoleIds(new Long[]{3L, 4L, 5L}); // ===== 新增 =====
try {
service.addNew(adminAddNewDTO);
log.debug("添加管理员成功!");
} catch (ServiceException e) {
log.debug("{}", e.getMessage());
}
}
37. 基于Spring JDBC框架的事务管理
事务:Transaction,是数据库中的一种能够保证多个写操作要么全部成功,要么全部失败的机制!
在基于Spring JDBC的数据库编程中,在业务方法上添加@Transactional注解,即可使得这个业务方法是“事务性”的!
假设,存在某银行转账的操作,转账时需要执行的SQL语句大致是:
UPDATE 存款表 SET 余额=余额-50000 WHERE 账号='国斌老师';
UPDATE 存款表 SET 余额=余额+50000 WHERE 账号='苍松老师';
以上的转账操作就涉及多次数据库的写操作,如果由于某些意外原因(例如停电、服务器死机等),导致第1条SQL语句成功执行,但是第2条SQL语句未能成功执行,就会出现数据不完整的问题!使用事务就可以解决此问题!
关于@Transationcal注解,可以添加在:
业务实现类的方法上
仅作用于当前方法
业务实现类上
将作用于当前类中所有方法
业务接口的抽象方法上
仅作用于当前方法
无论是哪个类重写此方法,都将是事务性的
业务接口上
将作用于当前接口中所有抽象方法
无论是哪个类实现了此接口,重写的所有方法都是将是事务性的
day12
37. 基于Spring JDBC框架的事务管理(续)
在执行数据访问操作时,数据库有一个“自动提交”的机制。
事务的本质是会先将“自动提交”关闭,当业务方法执行结束之后,再一次性“提交”。
在事务中,涉及几个概念:
开启事务:BEGIN
提交事务:COMMIT
回滚事务:ROLLBACK
在基于Spring JDBC的程序设计中,通过@Transactional注解即可使得业务方法是事务性的,其实现过程大致是:
开启事务
try {
执行业务方法
提交事务
} catch (RuntimeException e) {
回滚事务
}
可以看到,Spring JDBC框架在处理事务时,默认将根据RuntimeException进行回滚!
提示:可以配置@Transactional注解的rollbackFor或rollbackForClassName属性来指定回滚的异常类型,即根据其它类型的异常来回滚,例如:
@Transactional(rollbackFor = {IOException.class})
@Transactional(rollbackForClassName = {}"java.io.IOException"})
另外,还可以通过noRollbackFor或noRollbackForClassName属性用于指定不回滚的异常!
建议在业务方法中执行了任何增、删、改操作后,都获取受影响的行数,并判断此值是否符合预期,如果不符合,应该及时抛出RuntimeException或其子孙类异常!
补充:Spring JDBC框架在实现事务管理时,使用到了Spring AOP技术及基于接口的代理模式,由于使用了基于接口的代理模式,所以,如果将@Transactional注解添加在实现类中自定义的方法(不是重写的接口中的抽象方法)上,是错误的做法!
最后,可自行补充相关知识点:事务的ACID特性,事务的传播,事务的隔离。
38. 完善删除管理员
由于添加管理员时,在ams_admin_role表中插入了数据,在删除管理员时,也应该将ams_admin_role表中对应的数据删除!
删除ams_admin_role表中某管理员的数据需要执行的SQL语句大致是:
DELETE FROM ams_admin_role WHERE admin_id=?
则在AdminRoleMapper接口中添加抽象方法:
/**
* 根据管理员id删除管理员与角色的关联数据
*
* @param adminId 管理员id
* @return 受影响的行数
*/
int deleteByAdminId(Long adminId);
并在AdminRoleMapper.xml中配置SQL语句:
<!-- int deleteByAdminId(Long adminId); -->
<delete id="deleteByAdminId">
DELETE FROM ams_admin_role WHERE admin_id=#{adminId}
</delete>
完成后,在AdminRoleMapperTests中编写并执行测试:
@Test
void testDeleteByAdminId() {
Long adminId = 7L;
int rows = mapper.deleteByAdminId(adminId);
log.debug("删除数据完成!受影响的行数={}", rows);
}
当Mapper层的开发完成后,在AdminServiceImpl中的delete()方法最后补充调用以上方法即可:
// 执行删除此管理员与角色的关联数据
rows = adminRoleMapper.deleteByAdminId(id);
if (rows < 1) {
String message = "删除管理员失败,服务器忙,请稍后再次尝试!";
log.warn(message);
throw new ServiceException(ServiceCode.ERR_DELETE, message);
}
完成后,删除管理员的功能将暂时全部完成!
但是,需要注意:不要使用错误的测试数据进行测试!
显示角色列表
在“添加管理员”的界面中,需要将角色列表显示出来,则用户(软件的使用者)可以在界面上选择“添加管理员”时此管理员的角色。
关于“显示角色列表”的Mapper层
需要执行的SQL语句大致是:
SELECT * FROM ams_role ORDER BY sort DESC, id
则需要在pojo.vo包中创建RoleListItemVO类:
package cn.tedu.csmall.passport.pojo.vo;
import lombok.Data;
import java.io.Serializable;
/**
* 角色的列表项的VO类
*
* @author java@tedu.cn
* @version 0.0.1
*/
@Data
public class RoleListItemVO implements Serializable {
private Long id;
private String name;
private String description;
private Integer sort;
}
然后,在mapper包中创建RoleMapper接口,并添加抽象方法:
package cn.tedu.csmall.passport.mapper;
import cn.tedu.csmall.passport.pojo.vo.RoleListItemVO;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* 处理角色数据的Mapper接口
*
* @author java@tedu.cn
* @version 0.0.1
*/
@Repository
public interface RoleMapper {
/**
* 查询角色列表
*
* @return 角色列表
*/
List<RoleListItemVO> list();
}
然后,在src/main/resources/mapper文件夹下,通过复制粘贴得到RoleMapper.xml文件,在此文件中配置以上抽象方法映射的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.csmall.passport.mapper.RoleMapper">
<!-- List<RoleListItemVO> list(); -->
<select id="list" resultMap="ListResultMap">
SELECT
<include refid="ListQueryFields" />
FROM
ams_role
ORDER BY
sort DESC, id
</select>
<sql id="ListQueryFields">
<if test="true">
id, name, description, sort
</if>
</sql>
<resultMap id="ListResultMap" type="cn.tedu.csmall.passport.pojo.vo.RoleListItemVO">
<id column="id" property="id"/>
<result column="name" property="name"/>
<result column="description" property="description"/>
<result column="sort" property="sort"/>
</resultMap>
</mapper>
完成后,在src/test/java下的根包下创建mapper.RoleMapperTests测试类,在此类中编写并执行测试:
package cn.tedu.csmall.passport.mapper;
import cn.tedu.csmall.passport.pojo.entity.Admin;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
@Slf4j
@SpringBootTest
public class RoleMapperTests {
@Autowired
RoleMapper mapper;
@Test
void testList() {
List<?> list = mapper.list();
System.out.println("查询列表完成,列表中的数据的数量=" + list.size());
for (Object item : list) {
System.out.println(item);
}
}
}
关于“显示角色列表”的Service层
在service包下创建IRoleService接口,并在接口中添加抽象方法:
/**
* 处理角色数据的业务接口
*
* @author java@tedu.cn
* @version 0.0.1
*/
@Transactional
public interface IRoleService {
/**
* 查询角色列表
*
* @return 角色列表
*/
List<RoleListItemVO> list();
}
然后,在service.impl包下创建RoleServiceImpl实现类,实现以上接口,并在类上添加@Service注解,在类中声明RoleMapper属性并自动装配:
/**
* 处理角色数据的业务实现类
*
* @author java@tedu.cn
* @version 0.0.1
*/
@Slf4j
@Service
public class RoleServiceImpl implements IRoleService {
@Autowired
RoleMapper roleMapper;
@Override
public List<RoleListItemVO> list() {
log.debug("开始处理【查询角色列表】的业务");
return roleMapper.list();
}
}
完成后,在src/test/java下的根包下创建service.RoleServiceTests测试类,在此类中编写并执行测试:
package cn.tedu.csmall.passport.service;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
@Slf4j
@SpringBootTest
public class RoleServiceTests {
@Autowired
IRoleService service;
@Test
void testList() {
List<?> list = service.list();
System.out.println("查询列表完成,列表中的数据的数量=" + list.size());
for (Object item : list) {
System.out.println(item);
}
}
}
关于“显示角色列表”的Controller层
在controller包下创建RoleController类,在类上添加@RestController和@RequestMapping("/roles")注解,在类中声明并自动装配IRoleService属性,然后,在类中添加处理请求的方法:
package cn.tedu.csmall.passport.controller;
import cn.tedu.csmall.passport.pojo.vo.RoleListItemVO;
import cn.tedu.csmall.passport.service.IRoleService;
import cn.tedu.csmall.passport.web.JsonResult;
import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@Slf4j
@Api(tags = "02. 角色管理模块")
@RestController
@RequestMapping("/roles")
public class RoleController {
@Autowired
IRoleService roleService;
public RoleController() {
log.info("创建控制器类:RoleController");
}
// http://localhost:9081/roles
@ApiOperation("查询角色列表")
@ApiOperationSupport(order = 420)
@GetMapping("")
public JsonResult<List<RoleListItemVO>> list() {
log.debug("开始处理【查询角色列表】的请求");
return JsonResult.ok(roleService.list());
}
}
完成后,重启项目,可以通过在线API文档测试访问。
启动或禁用管理员
管理的启用状态是通过数据表中的enable字段的值来控制的,所以,启用、禁用功能的本质是修改此字段的值!
在开发功能时,应该在Mapper层开发一个能够修改所有字段的值的功能,则此功能可以适用于当前表的任何修改功能!
关于Mapper层
先在AdminMapper接口中添加抽象方法:
int updateById(Admin admin);
然后在AdminMapper.xml中配置SQL:
<!-- int updateById(Admin admin); -->
<update id="updateById">
UPDATE ams_admin
<set>
<if test="username != null">
username=#{username},
</if>
<if test="password != null">
password=#{password},
</if>
...
</set>
WHERE id=#{id}
</update>
完成后,在AdminMapperTests中编写并执行测试:
关于Service层
关于Controller层
day13
40. 启动或禁用管理员(续)
关于Controller层
由于Service设计了2个业务方法,分别用于启用和禁用,在控制器层,应该也设计2个方法,分别用于处理启用管理员的请求和禁用管理员的请求,则客户端在提交请求时,不需要提交enable属性的值!
则在AdminController中添加处理请求的方法:
// http://localhost:9081/admins/9527/enable
@ApiOperation("启用管理员")
@ApiOperationSupport(order = 310)
@ApiImplicitParam(name = "id", value = "管理员id", required = true, dataType = "long")
@PostMapping("/{id:[0-9]+}/enable")
public JsonResult<Void> setEnable(@PathVariable Long id) {
log.debug("开始处理【启用管理员】的请求,参数:{}", id);
adminService.setEnable(id);
return JsonResult.ok();
}
// http://localhost:9081/admins/9527/disable
@ApiOperation("禁用管理员")
@ApiOperationSupport(order = 311)
@ApiImplicitParam(name = "id", value = "管理员id", required = true, dataType = "long")
@PostMapping("/{id:[0-9]+}/disable")
public JsonResult<Void> setDisable(@PathVariable Long id) {
log.debug("开始处理【禁用管理员】的请求,参数:{}", id);
adminService.setDisable(id);
return JsonResult.ok();
}
41. 关于Spring Security框架
Spring Security主要解决了认证与授权的相关问题。
Spring Security的基础依赖项是spring-security-core,在Spring Boot项目中,通常添加spring-boot-starter-security这个依赖项,它包含了spring-security-core,并且,还自动执行了一系列配置!默认的配置效果有:
所有请求都是必须通过认证的
如果未认证,同步请求将自动跳转到 /login,是框架自带的登录页,非跨域的异步请求将响应 403 错误
提供了默认的登录信息,用户名为 user,密码是启动项目是随机生成的,在启动日志中可以看到
当登录成功后,会自动重定向到此前访问的URL
当登录成功后,可以执行所有同步请求,所有异步的POST请求都暂时不可用
可以通过 /logout 退出登录
42. 关于BCrypt算法
当添加了Spring Security相关的依赖项后,此依赖项中将包含BCryptPasswordEncoder工具类,是一个使用BCrypt算法的密码编码器,它实现了PasswordEncoder接口,并重写了接口中的String encode(String rawPassword)方法,用于对密码原文进行编码(加密),及重写了boolean matches(String rawPassword, String encodedPassword)方法,用于验证密码原文与密文是否对应。
BCrypt算法会自动使用随机的盐值进行加密处理,所以,当反复对同一个原文进行加密处理,每次得到的密文都是不同的,但这并不影响验证密码!
BCrypt算法被设计为是一种慢速运算的算法,可以一定程度上避免或缓解密码被暴力破解(使用循环进行穷举的破解)。
43. 关于Spring Security的基本配置
package cn.tedu.csmall.passport.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
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;
@Slf4j
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
// @Bean
public PasswordEncoder passwordEncoder() {
log.debug("创建@Bean方法定义的对象:PasswordEncoder");
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// 【配置白名单】
// 在配置路径时,星号是通配符
// 1个星号只能匹配任何文件夹或文件的名称,但不能跨多个层级
// 例如:/*/test.js,可以匹配到 /a/test.js 和 /b/test.js,但不可以匹配到 /a/b/test.js
// 2个连续的星号可以匹配若干个文件夹的层级
// 例如:/**/test.js,可以匹配 /a/test.js 和 /b/test.js 和 /a/b/test.js
String[] urls = {
"/doc.html",
"/**/*.js",
"/**/*.css",
"/swagger-resources",
"/v2/api-docs"
};
http.csrf().disable(); // 禁用CSRF(防止伪造的跨域攻击)
http.authorizeRequests() // 对请求执行认证与授权
.antMatchers(urls) // 匹配某些请求路径
.permitAll() // (对此前匹配的请求路径)不需要通过认证即允许访问
.anyRequest() // 除以上配置过的请求路径以外的所有请求路径
.authenticated(); // 要求是已经通过认证的
http.formLogin(); // 开启表单验证,即视为未通过认证时,将重定向到登录表单,如果无此配置,则直接响应403
}
}
44. 关于登录的账号
默认情况下,Spring Security使用user作为用户名,使用随机的UUID作为密码来登录!如果需要自行指定登录账号,需要自定义一个组件类,实现UserDetailsService接口,此接口中定义了UserDetails loadUserByUsername(String username),在处理认证时,当用户(使用者)输入了用户名、密码并提交,Spring Security就会自动使用用户在表单中输入的用户名来调用loadUserByUsername()方法,作为开发者,应该重写此方法,并根据用户名来返回匹配的UserDetails对象,此对象中应该包含用户的相关信息,例如密码等,当Spring Security得到调用loadUserByUsername()返回的UserDetails对象后,会自动处理后续的认证过程,例如验证密码是否匹配等。
例如,在根包下创建security.UserDetailsServiceImpl类:
package cn.tedu.csmall.passport.security;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Slf4j
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
log.debug("Spring Security调用了loadUserByUsername()方法,参数:{}", s);
// 暂时使用模拟数据来处理登录认证,假设正确的用户名和密码分别是root和123456
if ("root".equals(s)) {
UserDetails userDetails = User.builder()
.username("root")
.password("123456")
.accountExpired(false)
.accountLocked(false)
.disabled(false)
.authorities("这是一个山寨的权限标识") // 权限,注意,此方法的参数不可以为null,在不处理权限之前,可以写一个随意的字符串值
.build();
log.debug("即将向Spring Security返回UserDetails对象:{}", userDetails);
return userDetails;
}
log.debug("此用户名【{}】不存在,即将向Spring Security返回为null的UserDetails值", s);
return null;
}
}
另外,Spring Security在执行认证时,需要使用到密码编码器(PasswordEncoder),则在SecurityConfiguration配置类中添加:
@Bean
public PasswordEncoder passwordEncoder() {
log.debug("创建@Bean方法定义的对象:PasswordEncoder");
return NoOpPasswordEncoder.getInstance(); // 无操作的密码编码器,即:不会执行加密处理
}
提示:一旦启动项目时,Spring Security从Spring容器中找到了UserDetailsService接口类型的对象,则默认的用户名和随机的密码都不会再使用(启动项目中也不会再看到随机的临时密码)。
day14
44. 关于登录的账号(续)
当Spring容器中存在密码编码器时,在Spring Security处理认证时会自动调用!
本质上,是调用了密码编码器的以下方法:
boolean matches(CharSequence rawPassword, String encodedPassword);
也就是说,Spring Security会使用用户提交的密码作为以上方法的第1个参数,使用UserDetails对象中的密码作为以上方法的第2个参数,然后根据调用以上方法返回的boolean结果来判断此用户是否能通过密码验证!
所以,如果配置了BCryptPasswordEncoder,则返回的UserDetails对象中的密码必须是BCrypt密文,例如:
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
log.debug("Spring Security调用了loadUserByUsername()方法,参数:{}", s);
if ("root".equals(s)) {
UserDetails userDetails = User.builder()
.username("root")
// ====== 重点是以下这一行代码中的密文 ======
.password("$2a$10$nO7GEum8P27F8S0EGEHryel7m89opm/AMdaqMBk.qdsdIpE/SWFwe")
.accountExpired(false)
.accountLocked(false)
.disabled(false)
.authorities("这是一个山寨的权限标识")
.build();
log.debug("即将向Spring Security返回UserDetails对象:{}", userDetails);
return userDetails;
}
log.debug("此用户名【{}】不存在,即将向Spring Security返回为null的UserDetails值", s);
return null;
}
45. 使用数据库中的管理员账号信息来登录
Spring Security在处理认证时,会自动调用用UserDetailsService接口中的UserDetails loadUserByUsername(String username)方法,此方法返回的结果将决定此用户是否能够成功登录,此用户的信息应该来自数据库的管理员表中的数据!
则需要通过数据库查询来实现“根据用户名查询用户登录时所需要的相关信息”!需要执行的SQL语句大致是:
select id, username, password, enable from ams_admin where username=?
要实现以上查询,应该先在pojo.vo包下创建AdminLoginInfoVO类:
package cn.tedu.csmall.passport.pojo.vo;
import lombok.Data;
import java.io.Serializable;
/**
* 管理员的登录VO类
*
* @author java@tedu.cn
* @version 0.0.1
*/
@Data
public class AdminLoginInfoVO implements Serializable {
/**
* 数据id
*/
private Long id;
/**
* 用户名
*/
private String username;
/**
* 密码(密文)
*/
private String password;
/**
* 是否启用,1=启用,0=未启用
*/
private Integer enable;
}
然后,在AdminMapper.java中添加抽象方法:
/**
* 根据用户名查询管理员的登录信息
*
* @param username 用户名
* @return 匹配的管理员详情,如果没有匹配的数据,则返回null
*/
AdminLoginInfoVO getLoginInfoByUsername(String username);
并在AdminMapper.xml中配置以上抽象方法映射的SQL语句:
<!-- AdminLoginInfoVO getLoginInfoByUsername(String username); -->
<select id="getLoginInfoByUsername" resultMap="LoginResultMap">
SELECT
<include refid="LoginQueryFields" />
FROM
ams_admin
WHERE
username=#{username}
</select>
<sql id="LoginQueryFields">
<if test="true">
id, username, password, enable
</if>
</sql>
<resultMap id="LoginResultMap" type="cn.tedu.csmall.passport.pojo.vo.AdminLoginInfoVO">
<id column="id" property="id"/>
<result column="username" property="username"/>
<result column="password" property="password"/>
<result column="enable" property="enable"/>
</resultMap>
最后,在AdminMapperTests中编写并执行测试:
@Test
void testGetLoginInfoByUsername() {
String username = "root";
Object result = mapper.getLoginInfoByUsername(username);
System.out.println("根据username=" + username + "查询登录信息完成,结果=" + result);
}
完成后,调整UserDetailsServiceImpl中的方法实现,根据是否查询到管理员信息来决定是否返回有效的UesrDetails对象,并且,当查询到管理员信息时,将查询到的信息封装到UserDetails对象中并返回:
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
log.debug("Spring Security调用了loadUserByUsername()方法,参数:{}", s);
AdminLoginInfoVO loginInfo = adminMapper.getLoginInfoByUsername(s);
log.debug("从数据库查询与用户名【{}】匹配的管理员信息:{}", s, loginInfo);
if (loginInfo != null) {
UserDetails userDetails = User.builder()
.username(loginInfo.getUsername())
.password(loginInfo.getPassword())
.accountExpired(false)
.accountLocked(false)
.disabled(loginInfo.getEnable() == 0)
.authorities("这是一个山寨的权限标识") // 权限,注意,此方法的参数不可以为null,在不处理权限之前,可以写一个随意的字符串值
.build();
log.debug("即将向Spring Security返回UserDetails对象:{}", userDetails);
return userDetails;
}
log.debug("此用户名【{}】不存在,即将向Spring Security返回为null的UserDetails值", s);
return null;
}
至此,重启项目,通过Spring Security的登录表单,可以使用数据库中正确的管理员信息实现登录。
注意:如果数据库中的某些管理员数据是错误的(例如密码不是BCrypt密文、enable字段为null),则不能使用这些错误的数据尝试登录,应该修复这些错误的数据,或删除这些错误的数据!
46. 前后端分离的登录认证
目前,可以通过Spring Security的登录表单来实现认证,但是,这并不是前后端分离的做法,如果需要实现前后端分离的登录认证,需要:
禁用Spring Security的登录表单
使用控制器接收客户端提交的登录请求
需要将此请求的URL添加到“白名单”
在控制器处理登录请求时,调用Service对象处理登录认证
在Service实现类中处理登录认证
调用AuthenticationManager对象的authenticate()方法,将由Spring完成认证
调用authentication()方法时,需要传入用户名、密码,则Spring Security框架会自动调用UserDetailsService对象的loadUserByUsername()方法,并自动处理后续的认证(判断密码、enable等)
禁用Spring Security的登录表单
在SecurityConfiguration的void configurer(HttpSecurity http)方法中,不再调用http.formLogin()即可。
使用控制器接收客户端提交的登录请求
先在pojo.dto包中创建AdminLoginDTO类:
package cn.tedu.csmall.passport.pojo.dto;
import lombok.Data;
import java.io.Serializable;
/**
* 管理员登录的DTO类
*
* @author java@tedu.cn
* @version 0.0.1
*/
@Data
public class AdminLoginDTO implements Serializable {
/**
* 用户名
*/
private String username;
/**
* 密码(原文)
*/
private String password;
}
在AdminController中添加处理请求的方法:
// http://localhost:9081/admins/login
@PostMapping("/login")
public JsonResult login(AdminLoginDTO adminLoginDTO) {
log.debug("开始处理【管理员登录】的请求,参数:{}", adminLoginDTO);
// TODO 调用Service处理登录
return null;
}
并且,在SecurityConfiguration中,将 /admins/login 添加到“白名单”中。
在控制器处理登录请求时,调用Service对象处理登录认证
在IAdminService中添加处理登录认证的抽象方法:
/**
* 管理员登录
*
* @param adminLoginDTO 封装了管理员的登录信息的对象
*/
void login(AdminLoginDTO adminLoginDTO);
在AdminServiceImpl中实现以上抽象方法:
public void login(AdminLoginDTO adminLoginDTO) {
log.debug("开始处理【管理员登录】的业务,参数:{}", adminLoginDTO);
// TODO 调用AuthenticationManager对象的authenticate()方法处理认证
}
回到AdminController中,可以补充调用Service的代码:
// http://localhost:9081/admins/login
@PostMapping("/login")
public JsonResult login(AdminLoginDTO adminLoginDTO) {
log.debug("开始处理【管理员登录】的请求,参数:{}", adminLoginDTO);
adminService.login(adminLoginDTO);
return JsonResult.ok();
}
在Service实现类中处理登录认证
首先,需要在SecurityConfiguration配置类(继承自WebSecurityConfigurerAdapter)中重写authenticationManager()或authenticationManagerBean()方法,例如:
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
log.debug("创建@Bean方法定义的对象:AuthenticationManager");
return super.authenticationManagerBean();
}
然后,在AdminServiceImpl类中就可以自动装配AuthenticationManager对象了:
@Autowired
AuthenticationManager authenticationManager;
并且,调用此对象的方法执行认证:
@Override
public void login(AdminLoginDTO adminLoginDTO) {
log.debug("开始处理【管理员登录】的业务,参数:{}", adminLoginDTO);
// 调用AuthenticationManager对象的authenticate()方法处理认证
Authentication authentication
= new UsernamePasswordAuthenticationToken(
adminLoginDTO.getUsername(), adminLoginDTO.getPassword());
authenticationManager.authenticate(authentication);
log.debug("执行认证成功");
}
Spring Security在执行认证时,如果不通过(可能是用户名不存在、密码错误、账号已经被禁用等),都会抛出更种异常,如果通过认证,则程序会继续向后执行。
测试访问
以上全部完成后,重启项目,通过在线API文档可以测试访问。
处理异常
在业务状态码的枚举类型中,添加新的枚举值:
public enum ServiceCode {
OK(20000),
ERR_BAD_REQUEST(40000),
ERR_UNAUTHORIZED(40100), // ===== 新增 =====
ERR_UNAUTHORIZED_DISABLED(40101), // ===== 新增 =====
ERR_NOT_FOUND(40400),
ERR_CONFLICT(40900),
// 省略后续代码
}
在全局异常处理器中,补充对相关异常的处理:
@ExceptionHandler({
InternalAuthenticationServiceException.class,
BadCredentialsException.class
})
public JsonResult handleAuthenticationException(AuthenticationException e) {
log.debug("捕获到AuthenticationException");
log.debug("异常类型:{}", e.getClass().getName());
log.debug("异常信息:{}", e.getMessage());
String message = "登录失败,用户名或密码错!";
return JsonResult.fail(ServiceCode.ERR_UNAUTHORIZED, message);
}
@ExceptionHandler
public JsonResult handleDisabledException(DisabledException e) {
log.debug("捕获到DisabledException:{}", e.getMessage());
String message = "登录失败,此管理员账号已经被禁用!";
return JsonResult.fail(ServiceCode.ERR_UNAUTHORIZED_DISABLED, message);
}
注意:尽管此时已经可以通过在线API文档尝试登录,并且可以得到预期的反馈,但是,这并不是真正意义的“登录成功”,因为还没有处理通过认证后保存用户信息(例如将用户信息存储到Session中)!
48. 关于Session
HTTP协议是无状态的协议,即:从协议本身并没有约定需要保存用户状态!表现为:某个客户端访问了服务器之后,后续的每一次访问,服务器都无法识别出这是前序访问的客户端!
在传统的解决方案中,可以从技术层面来解决服务器端识别客户端并保存相关数据的问题,例如使用Session机制。
Session的本质是保存在服务器端的内存中的类似Map结构的数据,每个客户端都有一个属于自己的Key,在服务器端有对应的Value,就是Session。
关于客户端提交请求时的Key:当某客户端第1次向服务器端提交请求时,并没有可用的Key,所以并不携带Key来提交请求,当服务器端发现客户端没有携带Key时,就会响应一个Key到客户端,客户端会将这个Key保存下来,并在后续的每一次请求中自动携带这个Key。并且,服务器端为了保证各个Key不冲突,会使用UUID算法来生成各个Key。由于这些Key是用于访问Session数据的,所以,一般称之为Session ID。
基于Session的特点,在使用时,可能存在一些问题:
不能直接用于集群甚至分布式系统
可以通过共享Session技术来解决
将占用服务器端的内存,则不宜长时间保存
49. 关于Token
Token:票据,令牌
Token机制是目前主流的取代Session用于服务器端识别客户端身份的机制。
Token就类似于现实生活中的“火车票”,当客户端向服务器端提交登录请求时,就类似于“买票”的过程,当登录成功后,服务器端会生成对应的Token并响应到客户端,则客户端就拿到了所需的“火车票”,在后续的访问中,客户端携带“火车票”即可,并且,服务器端有“验票”机制,能够根据客户端携带的“火车票”识别出客户端的身份。
50. 关于JWT
JWT:JSON Web Token,是使用JSON格式来组织多个属性于值,主要用于Web访问的Token。
JWT的本质就是只一个字符串,是通过算法进行编码后得到的结果。
在项目中,如果需要生成、解析JWT,需要添加相关依赖项,能够实现生成、解析JWT的工具包较多,可以自由选择,可参考:https://jwt.io/libraries?language=Java
例如,在pom.xml中添加:
<!-- JJWT(Java JWT) -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
day15
50. 关于JWT(续)
JWT是不安全的,因为在不知道secretKey的情况下,任何JWT都是可以解析出Header、Payload部分的,这2部分的数据并没有做任何加密处理,所以,如果JWT数据被暴露,则任何人都可以从中解析出Header、Payload中的数据!
至于JWT中的secretKey,及生成JWT时使用的算法,是用于对Header、Payload执行签名算法的,JWT中的Signature是用于验证JWT真伪的。
当然,如果你认为有必要的话,可以自行另外使用加密算法,将Payload中应该封装的数据先加密,再用于生成JWT!
另外,如果JWT数据被泄露,他人使用有效的JWT是可以正常使用的!所以,通常,在相对比较封闭的操作系统(例如智能手机的操作系统)中,JWT的有效时间可以设置得很长,但是,不太封闭的操作系统(例如PC端的操作系统)中,JWT的有效时间应该相对较短。
所以,在JWT时,需要注意:
根据你所需的安全性,来设置JWT的有效时间
不要在JWT中存放敏感数据,例如:手机号码、身份证号码、明文密码
如果一定要在JWT中存放敏感数据,应该自行使用加密算法处理过后再用于生成JWT
51. 登录成功时生成并响应JWT
在使用JWT的项目,用户登录就相当于现实生活乘车之前购买火车票的过程,所以,当用户登录成功时,需要生成对应的JWT数据,并响应到客户端。
首先,需要修改IAdminService接口中处理登录的抽象方法的声明,将返回值类型改为String,表示将返回成功登录的JWT数据:
/**
* 管理员登录
*
* @param adminLoginDTO 封装了管理员的登录信息的对象
* @return 成功登录的JWT数据
*/
String login(AdminLoginDTO adminLoginDTO);
然后,在AdminServiceImpl实现类中,也修改重写的方法的声明,并且,在登录成功后,生成、返回JWT数据:
log.debug("准备生成JWT数据");
Map<String, Object> claims = new HashMap<>();
// claims.put("id", null); // 向JWT中封装id
claims.put("username", adminLoginDTO.getUsername()); // 向JWT中封装username
String secretKey = "kns439a}fdLK34jsmfd{MF5-8DJSsLKhJNFDSjn";
Date expirationDate = new Date(System.currentTimeMillis() + 10 * 24 * 60 * 60 * 1000);
String jwt = Jwts.builder()
.setHeaderParam("alg", "HS256")
.setHeaderParam("typ", "JWT")
.setClaims(claims)
.setExpiration(expirationDate)
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
log.debug("返回JWT数据:{}", jwt);
return jwt;
提示:以上代码并不是最终版本。
最后,在AdminController中,还需要响应JWT数据:
// http://localhost:9081/admins/login
@PostMapping("/login")
public JsonResult<String> login(AdminLoginDTO adminLoginDTO) {
log.debug("开始处理【管理员登录】的请求,参数:{}", adminLoginDTO);
String jwt = adminService.login(adminLoginDTO);
return JsonResult.ok(jwt);
}
完成后,重启项目,可以在API文档中测试访问,当登录成功后,响应的结果大致是:
{
"state": 20000,
"data": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2NjY0ODk1NjMsInVzZXJuYW1lIjoic3VwZXJfYWRtaW4ifQ.T5wnIVFk-AhvxPETloDsSgx46vdV45Y3BRk1_0oc3CM"
}
关于处理认证的细节
当调用了AuthenticationManager对象的authenticate()方法,且通过认证后,此方法将返回Authentication接口类型的对象,此对象的具体类型是UsernamePasswordAuthenticationToken,此对象中包含名为Principal(当事人)的属性,值为UserDetailsService对象中loadUserByUsername()返回的对象!
另外,目前在UserDetailsServiceImpl中返回的UserDetails接口类型的对象是User类型的,此类型没有id属性,如果需要向JWT中封装id甚至其它属性,必须自定义类,继承自User或实现UserDetails接口,在自定义类中补充声明所需的属性,并在UserDetailsServiceImpl中返回自定义类的对象,则处理认证通过后,返回的Authentication中的Principal就是自定义类的对象!
在security包中创建AdminDetails类,继承自User对其进行扩展:
@Setter
@Getter
@EqualsAndHashCode
@ToString(callSuper = true)
public class AdminDetails extends User {
private Long id;
public AdminDetails(String username, String password, boolean enabled,
Collection<? extends GrantedAuthority> authorities) {
super(username, password, enabled,
true, true, true,
authorities);
}
}
在UserDetailsServiceImpl中,调整为返回AdminDetails类型的对象:
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
log.debug("Spring Security调用了loadUserByUsername()方法,参数:{}", s);
AdminLoginInfoVO loginInfo = adminMapper.getLoginInfoByUsername(s);
log.debug("从数据库查询与用户名【{}】匹配的管理员信息:{}", s, loginInfo);
if (loginInfo == null) {
log.debug("此用户名【{}】不存在,即将抛出异常");
String message = "登录失败,用户名不存在!";
throw new BadCredentialsException(message);
}
// ===== 以下是调整的内容 =====
List<GrantedAuthority> authorities = new ArrayList<>();
GrantedAuthority authority = new SimpleGrantedAuthority("这是一个山寨的权限标识");
authorities.add(authority);
AdminDetails adminDetails = new AdminDetails(
loginInfo.getUsername(), loginInfo.getPassword(),
loginInfo.getEnable() == 1, authorities);
adminDetails.setId(loginInfo.getId());
log.debug("即将向Spring Security返回UserDetails接口类型的对象:{}", adminDetails);
return adminDetails;
}
经过以上调整,当AuthenticationManager执行authenticate()认证方法后,如果登录成功,返回的Authentication中的Principal就是以上返回的AdminDetails对象,则可以从中获取id、username等数据,用于生成JWT数据,则在AdminServiceImpl中的login()方法中:
@Override
public String login(AdminLoginDTO adminLoginDTO) {
log.debug("开始处理【管理员登录】的业务,参数:{}", adminLoginDTO);
// 调用AuthenticationManager对象的authenticate()方法处理认证
Authentication authentication
= new UsernamePasswordAuthenticationToken(
adminLoginDTO.getUsername(), adminLoginDTO.getPassword());
Authentication authenticateResult
= authenticationManager.authenticate(authentication);
log.debug("执行认证成功,AuthenticationManager返回:{}", authenticateResult);
Object principal = authenticateResult.getPrincipal();
log.debug("认证结果中的Principal数据类型:{}", principal.getClass().getName());
log.debug("认证结果中的Principal数据:{}", principal);
AdminDetails adminDetails = (AdminDetails) principal;
log.debug("准备生成JWT数据");
Map<String, Object> claims = new HashMap<>();
claims.put("id", adminDetails.getId()); // 向JWT中封装id
claims.put("username", adminDetails.getUsername()); // 向JWT中封装username
String secretKey = "kns439a}fdLK34jsmfd{MF5-8DJSsLKhJNFDSjn";
Date expirationDate = new Date(System.currentTimeMillis() + 10 * 24 * 60 * 60 * 1000);
String jwt = Jwts.builder()
.setHeaderParam("alg", "HS256")
.setHeaderParam("typ", "JWT")
.setClaims(claims)
.setExpiration(expirationDate)
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
log.debug("返回JWT数据:{}", jwt);
return jwt;
}
至此,当客户端向服务器端提交登录请求,且登录成功后,将得到服务器端响应的JWT数据,此JWT中包含了id和username。
解析JWT
当客户端已经登录成功并得到JWT,相当于现实生活中某人已经成功购买到了火车票,接下来,此人应该携带火车票去乘车,在程序中,就表现为:客户端应该携带JWT向服务器端提交请求。
关于客户端携带JWT数据,业内惯用的做法是客户端应该将JWT放在请求头(Request Headers)中名为Authorization的属性中。
在服务器端,通常使用过滤器组件来解析JWT数据。
在项目的根包下创建JwtAuthorizationFilter:
package cn.tedu.csmall.passport.filter;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
/**
* JWT认证过滤器
*
* <p>Spring Security框架会自动从SecurityContext读取认证信息,如果存在有效信息,则视为已登录,否则,视为未登录</p>
* <p>当前过滤器应该尝试解析客户端可能携带的JWT,如果解析成功,则创建对应的认证信息,并存储到SecurityContext中</p>
*
* @author java@tedu.cn
* @version 0.0.1
*/
@Slf4j
@Component
public class JwtAuthorizationFilter extends OncePerRequestFilter {
public static final int JWT_MIN_LENGTH = 100;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 尝试获取客户端提交请求时可能携带的JWT
String jwt = request.getHeader("Authorization");
log.debug("接收到JWT数据:{}", jwt);
// 判断是否获取到有效的JWT
if (!StringUtils.hasText(jwt) || jwt.length() < JWT_MIN_LENGTH) {
// 直接放行
log.debug("未获取到有效的JWT数据,将直接放行");
filterChain.doFilter(request, response);
return;
}
// 尝试解析JWT,从中获取用户的相关数据,例如id、username等
log.debug("将尝试解析JWT……");
String secretKey = "kns439a}fdLK34jsmfd{MF5-8DJSsLKhJNFDSjn";
Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody();
Long id = claims.get("id", Long.class);
String username = claims.get("username", String.class);
log.debug("从JWT中解析得到数据:id={}", id);
log.debug("从JWT中解析得到数据:username={}", username);
// 将根据从JWT中解析得到的数据来创建认证信息
List<GrantedAuthority> authorities = new ArrayList<>();
GrantedAuthority authority = new SimpleGrantedAuthority("这是一个山寨的权限标识");
authorities.add(authority);
Authentication authentication = new UsernamePasswordAuthenticationToken(
username, null, authorities);
// 将认证信息存储到SecurityContext中
SecurityContext securityContext = SecurityContextHolder.getContext();
securityContext.setAuthentication(authentication);
// 放行
filterChain.doFilter(request, response);
}
}
完成后,还需要在SecurityConfiguration中自动装配自定义的JWT过滤器:
@Autowired
JwtAuthorizationFilter jwtAuthorizationFilter;
并在configurer()方法中补充:
// 将自定义的JWT过滤器添加在Spring Security框架内置的过滤器之前
http.addFilterBefore(jwtAuthorizationFilter, UsernamePasswordAuthenticationFilter.class);
52. 关于认证信息中的Principal
关于SecurityContext中的认证信息,应该包含当事人(Principal)和权限(Authorities),其中,当事人(Principal)被声明为Object类型的,则可以使用任意数据类型作为当事人!
在使用了Spring Security框架的项目中,当事人的数据是可以被注入到处理请求的方法中的!所以,使用哪种数据作为当事人,主要取决于“你在编写控制器中处理请求的方法时,需要通过哪些数据来区分当前登录的用户”。
通常,使用自定义的数据类型作为当事人,并在此类型中封装关键数据,例如id、username等。
则在security包下创建LoginPrincipal类:
@Data
public class LoginPrincipal implements Serializable {
private Long id;
private String username;
}
在JWT过滤器创建认证信息时,使用以上类型的对象作为认证信息中的当事人:
LoginPrincipal loginPrincipal = new LoginPrincipal(); // 新增
loginPrincipal.setId(id); // 新增
loginPrincipal.setUsername(username); // 新增
// 注意:以下调用构造方法时,第1个参数是以上创建的对象
Authentication authentication = new UsernamePasswordAuthenticationToken(
loginPrincipal, null, authorities);
完成后,在当前项目任何控制器中任何处理请求的方法上,都可以添加@AuthenticationPrincipal LoginPrincipal loginPrincipal参数(与原有的其它参数不区分先后顺序),此参数的值就是以上过滤器中存入到认证信息中的当事人,所以,可以通过这种做法,在处理请求时识别当前登录的用户:
@ApiOperation("删除管理员")
@ApiOperationSupport(order = 200)
@ApiImplicitParam(name = "id", value = "管理员id", required = true, dataType = "long")
@PostMapping("/{id:[0-9]+}/delete")
public JsonResult<Void> delete(@PathVariable Long id,
// ===== 以下是新增的方法参数 =====
@ApiIgnore @AuthenticationPrincipal LoginPrincipal loginPrincipal) {
log.debug("开始处理【删除管理员】的请求,参数:{}", id);
log.debug("当前登录的当事人:{}", loginPrincipal); // 新增,可以控制台观察数据
adminService.delete(id);
return JsonResult.ok();
}
53. 关于CORS与PreFlight
如果客户端向服务器端提交请求,在跨域的前提下,如果提交的请求配置了请求头中的非典型参数,例如配置了Authorization,此请求会被视为“复杂请求”,则会要求执行“预检”(PreFlight),如果预检不通过,则会导致跨域请求错误!
关于预检,浏览器会自动向服务器端提交OPTIONS类型的请求执行预检,为了确保预检通过,不影响处理正常的请求,需要在SecurityConfiguration的configurer()方法中对预检请求放行,可以采取的解决方案有:
http.authorizeRequests()
.antMatchers(urls)
.permitAll()
// 以下2行代码是用于对预检的OPTIONS请求直接放行的
.antMatchers(HttpMethod.OPTIONS, "/**")
.permitAll()
.anyRequest()
.authenticated();
或者,也可以:
http.cors(); // 启用Spring Security框架的处理跨域的过滤器,此过滤器将放行跨域请求,包括预检的OPTIONS请求
则客户端可以携带复杂请求头进行访问:
loadAdminList() {
console.log('loadAdminList ...');
let url = 'http://localhost:9081/admins';
console.log('url = ' + url);
this.axios
.create({
'headers': {
'Authorization': localStorage.getItem('jwt')
}
})
.get(url).then((response) => {
let responseBody = response.data;
console.log(responseBody);
this.tableData = responseBody.data;
});
}
作业
在“类别”表(pms_category)中,存在名为parent_id的字段,表示“父级类别”,
例如存在id=1的类别名为“家电”,则名为“冰箱”的类别的parent_id值就应该是1,
所以,“家电”是一级类别,而“冰箱”是二级类别,另外,
所有一级类别的parent_id值为0,现要求实现:
Mapper层:根据父级类别查询子级类别列表
Service层:同上,无特别的业务规则
Controller层:同上
前端页面:显示所有一级类别的列表,暂不关心子级类别的显示
在前序作业中,已经完成“添加属性”的功能,且已知每个“属性”都归属于某个“属性模板”,现要求实现:
Mapper层:根据“属性模板”查询“属性”列表
Service层:同上,无特别的业务规则
Controller层:同上
前端页面:显示某个“属性模板”中的属性列表,关于“属性模板”的id,可暂时写成一个固定值
此作业请于本周日(10月16日)23:00之前提交到作业系统。
day16
54. 关于SecurityContext中的认证信息
Spring Security框架是根据SecurityContext中是否存在认证信息来判断用户是否已经登录。
关于SecurityContext,是通过ThreadLocal进行处理的,所以,是线程安全的,每个客户端对应的SecurityContext中的信息是互不干扰的。
另外,SecurityContext中的认证信息是通过Session存储的,所以,一旦向SecurityContext中存入了认证信息,在后续一段时间(Session的有效时间)的访问中,即使不携带JWT,也是允许访问的,会被视为“已登录”。如果认为这样的表现是不安全的,可以在JWT过滤器中,在刚刚接收到请求时,就直接清除SecurityContext中的信息(主要是认证信息):
// 清除SecurityContext中原有的数据(认证信息)
SecurityContextHolder.clearContext();
53. 自定义配置
在处理JWT时,无论是生成JWT,还是解析JWT,都需要使用同一个secretKey,则应该将此secretKey定义在某个类中作为静态常量,或定义在配置文件(application.yml或等效的配置文件)中,由于此值是允许被软件的使用者(甲方)自行定义的,所以,更推荐定义在配置文件中。
则在application-dev.yml中添加自定义配置:
# 自定义配置
csmall:
jwt:
secret-key: kns439a}fdLK34jsmfd{MF5-8DJSsLKhJNFDSjn
提示:在配置文件中的自定义属性,应该在属性名称上添加统一的、自定义的前缀,例如以上使用到的csmall,以便于与其它的属性区分开来。
接下来,可以在需要使用以上配置值的类中,通过@Value注解将以上配置值注入到某个全局属性中,例如:
@Value("${csmall.jwt.secret-key}")
String secretKey;
提示:以上使用的@Value注解可以读取当前项目中的全部环境变量,将包括:操作系统的环境变量、JVM的环境变量、各配置文件中的配置。并且,@Value注解可以添加在全局属性上,也可以添加在被Spring自动调用的方法的参数上。
54. 处理解析JWT时的异常
在JWT过滤器中,解析JWT时可能会出现异常,异常的类型主要有:
SignatureException
MalformedJwtException
ExpiredJwtException
由于解析JWT是发生成过滤器中的,而过滤器是整个Java EE体系中最早接收到请求的组件(此时,控制器等其它组件均未开始执行),所以,此时出现的异常不可以使用Spring MVC的全局异常处理器进行处理。
提示:Spring MVC的全局异常处理器在控制器(Controller)抛出异常之后执行。
只能通过最原始的try...catch...语法捕获并处理异常,处理时,需要使用到过滤器方法的第2个参数HttpServletResponse response来向客户端响应错误信息。
为了便于封装错误信息,应该使用JsonResult来封装相关信息,由于需要自行将JsonResult格式的对象转换成JSON格式的数据,所以,需要在pom.xml添加能够实现对象与JSON格式字符串相互转换的依赖,例如可以添加fastjson依赖:
<!-- fastjson:实现对象与JSON的相互转换 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.75</version>
</dependency>
然后,在ServiceCode中添加一些新的业务状态码:
public enum ServiceCode {
// 前序代码
ERR_JWT_SIGNATURE(60000),
ERR_JWT_MALFORMED(60000),
ERR_JWT_EXPIRED(60002),
ERR_UNKNOWN(99999);
// 后续代码
}
再开始处理异常,例如:
// 尝试解析JWT
log.debug("将尝试解析JWT……");
Claims claims = null;
try {
claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody();
} catch (SignatureException e) {
String message = "非法访问!";
JsonResult jsonResult = JsonResult.fail(ServiceCode.ERR_JWT_SIGNATURE, message);
String jsonResultString = JSON.toJSONString(jsonResult);
PrintWriter writer = response.getWriter();
writer.println(jsonResultString);
writer.close();
return;
} catch (MalformedJwtException e) {
String message = "非法访问!";
JsonResult jsonResult = JsonResult.fail(ServiceCode.ERR_JWT_MALFORMED, message);
String jsonResultString = JSON.toJSONString(jsonResult);
PrintWriter writer = response.getWriter();
writer.println(jsonResultString);
writer.close();
return;
} catch (ExpiredJwtException e) {
String message = "登录已过期,请重新登录!";
JsonResult jsonResult = JsonResult.fail(ServiceCode.ERR_JWT_EXPIRED, message);
String jsonResultString = JSON.toJSONString(jsonResult);
PrintWriter writer = response.getWriter();
writer.println(jsonResultString);
writer.close();
return;
} catch (Throwable e) {
e.printStackTrace(); // 重要
String message = "服务器忙,请稍后再次尝试!";
JsonResult jsonResult = JsonResult.fail(ServiceCode.ERR_UNKNOWN, message);
String jsonResultString = JSON.toJSONString(jsonResult);
PrintWriter writer = response.getWriter();
writer.println(jsonResultString);
writer.close();
return;
}
注意:强烈推荐在最后补充处理Throwable异常,以避免某些异常未被考虑到,并且,在处理Throwable时,应该执行e.printStackTrace(),则出现未预测的异常时,可以通过控制台看到相关信息,并在后续补充对这些异常的精准处理!
55. 处理授权
首先,需要调整现有的AdminMapper接口中的AdminLoginInfoVO getLoginInfoByUsername(String username)方法,此方法应该返回参数用户名匹配的管理员信息,信息中应该包含权限!
则需要执行的SQL语句大致是:
SELECT
ams_admin.id,
ams_admin.username,
ams_admin.password,
ams_admin.enable,
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='root';
则需要调整AdminLoginInfoVO类,添加新的属性,用于封装查询到的权限信息:
private List<String> permissions;
然后调整AdminMapper.xml中的相关配置:
<!-- AdminLoginInfoVO getLoginInfoByUsername(String username); -->
<select id="getLoginInfoByUsername" resultMap="LoginResultMap">
SELECT
<include refid="LoginQueryFields"/>
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>
<sql id="LoginQueryFields">
<if test="true">
ams_admin.id,
ams_admin.username,
ams_admin.password,
ams_admin.enable,
ams_permission.value
</if>
</sql>
<!-- 当涉及1个多查询时,需要使用collection标签配置List集合类型的属性 -->
<!-- collection标签的property属性:类中List集合的属性的名称 -->
<!-- collection标签的ofType属性:类中List集合的元素类型的全限定名 -->
<!-- collection标签的子级:需要配置如何创建出一个个元素对象 -->
<!-- constructor标签:将通过构造方法来创建对象 -->
<!-- constructor标签子级的arg标签:配置构造方法的参数 -->
<resultMap id="LoginResultMap" type="cn.tedu.csmall.passport.pojo.vo.AdminLoginInfoVO">
<id column="id" property="id"/>
<result column="username" property="username"/>
<result column="password" property="password"/>
<result column="enable" property="enable"/>
<collection property="permissions" ofType="java.lang.String">
<constructor>
<arg column="value"/>
</constructor>
</collection>
</resultMap>
完成后,可以通过AdminMapperTests中原有的测试方法直接测试,测试结果例如:
根据username=fanchuanqi查询登录信息完成,结果=AdminLoginInfoVO(id=5, username=fanchuanqi, password=$2a$10$N.ZOn9G6/YLFixAOPMg/h.z7pCu6v2XyFDtC4q.jeeGm/TEZyj15C, enable=0, permissions=[/pms/picture/read, /pms/picture/add-new, /pms/picture/delete, /pms/picture/update, /pms/album/read, /pms/album/add-new, /pms/album/delete, /pms/album/update])
接下来,在UserDetailsServiceImpl中,向返回的AdminDetails中封装真实的权限数据:
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
log.debug("Spring Security调用了loadUserByUsername()方法,参数:{}", s);
AdminLoginInfoVO loginInfo = adminMapper.getLoginInfoByUsername(s);
log.debug("从数据库查询与用户名【{}】匹配的管理员信息:{}", s, loginInfo);
if (loginInfo == null) {
log.debug("此用户名【{}】不存在,即将抛出异常");
String message = "登录失败,用户名不存在!";
throw new BadCredentialsException(message);
}
// ===== 以下是此次调整的内容 =====
List<GrantedAuthority> authorities = new ArrayList<>();
for (String permission : loginInfo.getPermissions()) {
GrantedAuthority authority = new SimpleGrantedAuthority(permission);
authorities.add(authority);
}
AdminDetails adminDetails = new AdminDetails(
loginInfo.getUsername(), loginInfo.getPassword(),
loginInfo.getEnable() == 1, authorities);
adminDetails.setId(loginInfo.getId());
}
经过以上调整后,在AdminServiceImpl处理登录的login()方法中,认证返回的结果的当事人(Principal)中就包含管理员的权限信息了!
day17
1. 单点登录(SSO)
SSO:Single Sign On,即:单点登录
单点登录表现为:在集群或分布式系统中,客户端在其中的某1个服务器登录,后续的请求被分配到其它服务器处理时,其它服务器也能识别用户的身份。
单点登录的实现方案有:
共享Session
把所有客户端的Session数据存储到专门的服务器上,其它任何服务器需要识别客户端身份时,都从这个专门的服务器上去查找、读取Session数据
缺点:Session的有效期不宜过长
优点:编码简单,读取Session数据基本上没有额外牺牲性能
Token
当某客户端登录成功,服务器端将响应Token到客户端,在后续的访问中,客户端自行携带Token数据来访问任何服务器,且任何服务器都具备解析此Token的功能,即可识别客户端的身份
JWT(JSON Web Token)也是Token的一种
缺点:编写代码略难,需要频繁解析JWT,需要牺牲一部分性能来进行解析
优点:可以长时间有效
2. 实现SSO
目前,在csmall-passport项目中已经现实了认证与授权,只要客户端能携带有效的JWT,则服务器端可以识别客户端的身份!
在csmall-product项目中,只需要添加Spring Security框架的依赖项,并添加认证相关代码,就可以实现“客户端在csmall-passport登录后,在csmall-product上也可以识别用户的身份”!
需要从csmall-passport中复制到csmall-product中的代码有:
复制相关依赖项
spring-boot-starter-security
jjwt
fastjson
复制application-dev.yml中关于JWT的自定义配置
LoginPrincipal
ServiceCode(更新文件,在passport中添加了一些新的业务状态码,在product中也将需要使用到)
JwtAuthorizationFilter
SecurityConfiguration
删除PasswordEncoder的@Bean方法
删除AuthenticationManager的@Bean方法
删除configurer()方法中“白名单”中的 "/admins/login" 路径
GlobalExceptionHandler(更新文件,处理“无操作权限”相关异常)
在前端项目中,保证除了登录的每个请求都添加了请求头中的JWT即可。
本项目基于Spring Security和JWT实现了SSO(单点登录)。
关于Redis
Redis是一款使用K-V结构的基于内存的NoSQL非关系型数据库。
内存是计算机的硬件系统中,除了CPU/GPU内置的缓存以外,存取效率最高的存储设备。
关于Redis的基本使用
当安装并启动了Redis服务后,在操作系统的终端中,通过redis-cli命令可以登录Redis客户端,当登录成功后,操作提示符将变为 127.0.0.1:6379>。
当已经登录Redis客户端后,可以通过 exit 命令退出,则会回到操作系统的终端。
当已经登录Redis客户端后,可以通过 ping 命令检查Redis服务是否仍处于可用状态。
当已经登录Redis客户端后,可以通过 shutdown 命令停止Redis服务。
在操作系统的终端中,可以通过 redis-server 启动Redis服务。
当已经登录Redis客户端后,可以通过 set key value 命令向Redis中存入数据,例如:
set name liucangsong
当已经登录Redis客户端后,可以通过 get key 命令从Redis中取出曾经存入的数据,例如:
get name
与Java语言中的Map相同,在Redis中的Key也是唯一的,所以,当通过 set key value 存入数据时,如果Key不存在,则是新增数据的操作,如果Key已经存在,则是修改数据的操作。
当已经登录Redis客户端后,可以通过 keys Key的名称或带通配符的模式 命令查询Redis中的相关Key的列表,例如:
keys email
keys *
关于更多Redis的命令操作,可查阅资料,例如:
Redis 常用操作命令,非常详细! - 知乎 (zhihu.com) (https://zhuanlan.zhihu.com/p/47692277)
关于Redis编程
在Spring Boot项目中,要实现Redis编程,应该添加相关的依赖:
<!-- Spring Data Redis,用于实现Redis编程 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
Redis编程主要通过RedisTemplate工具来实现,应该通过配置类的@Bean方法返回此类型的对象,并在需要使用Redis编程时自动装配此对象!
则在根包下创建config.RedisConfiguration配置类:
package cn.tedu.csmall.product.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.RedisSerializer;
import java.io.Serializable;
/**
* Redis的配置类
*
* @author java@tedu.cn
* @version 0.0.1
*/
@Slf4j
@Configuration
public class RedisConfiguration {
public RedisConfiguration() {
log.debug("创建配置类对象:RedisConfiguration");
}
@Bean
public RedisTemplate<String, Serializable> redisTemplate(
RedisConnectionFactory redisConnectionFactory) {
log.debug("创建@Bean方法定义的对象:RedisTemplate");
RedisTemplate<String, Serializable> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.setKeySerializer(RedisSerializer.string());
redisTemplate.setValueSerializer(RedisSerializer.json());
return redisTemplate;
}
}
关于RedisTemplate的基本使用,可以在src/test/java的根包下创建RedisTests测试类,在此类中自动装配RedisTemplate并测试使用:
package cn.tedu.csmall.product;
import cn.tedu.csmall.product.pojo.entity.Brand;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.ListOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
@Slf4j
@SpringBootTest
public class RedisTests {
@Autowired
RedisTemplate<String, Serializable> redisTemplate;
@Test
void setValue() {
String key = "name";
String value = "国斌老师";
ValueOperations<String, Serializable> ops
= redisTemplate.opsForValue(); // 只要是对字符串类型的Value进行操作,必须调用opsForValue()方法得到相应的操作器
ops.set(key, value);
log.debug("已经向Redis中写入Key为【{}】的数据:{}", key, value);
}
@Test
void getValue() {
String key = "name";
ValueOperations<String, Serializable> ops = redisTemplate.opsForValue();
Serializable value = ops.get(key);
log.debug("已经从Redis中取出Key为【{}】的数据:{}", key, value);
}
@Test
void setObjectValue() {
String key = "brand1";
Brand brand = new Brand();
brand.setId(1L);
brand.setName("测试品牌");
ValueOperations<String, Serializable> ops = redisTemplate.opsForValue();
ops.set(key, brand);
log.debug("已经向Redis中写入Key为【{}】的数据:{}", key, brand);
}
@Test
void getObjectValue() {
String key = "brand1";
ValueOperations<String, Serializable> ops = redisTemplate.opsForValue();
Serializable value = ops.get(key);
log.debug("已经从Redis中取出Key为【{}】的数据:{}", key, value);
log.debug("取出的数据的类型是:{}", value.getClass().getName());
}
@Test
void getNull() {
String key = "hahahaha";
ValueOperations<String, Serializable> ops = redisTemplate.opsForValue();
Serializable value = ops.get(key);
log.debug("已经从Redis中取出Key为【{}】的数据:{}", key, value);
}
@Test
void keys() {
String keyPattern = "*";
Set<String> keys = redisTemplate.keys(keyPattern);
log.debug("查询当前Redis中所有的Key,Key的数量:{}", keys.size());
for (String key : keys) {
log.debug("key = {}", key);
}
}
@Test
void delete() {
String key = "name";
Boolean result = redisTemplate.delete(key);
log.debug("删除Key为【{}】的数据,结果:{}", key, result);
}
@Test
void deleteX() {
String keyPattern = "*";
Set<String> keys = redisTemplate.keys(keyPattern);
Long count = redisTemplate.delete(keys);
log.debug("删除多条数据【Keys={}】完成,删除的数据的数量:{}", keys, count);
}
@Test
void setList() {
List<Brand> brands = new ArrayList<>();
for (int i = 1; i <= 8; i++) {
Brand brand = new Brand();
brand.setId(i + 0L);
brand.setName("测试品牌" + i);
brands.add(brand);
}
String key = "brands";
ListOperations<String, Serializable> ops = redisTemplate.opsForList(); // 得到List集合的操作器
for (Brand brand : brands) {
ops.rightPush(key, brand);
}
log.debug("向Redis中写入列表数据完成,Key为【{}】,写入的列表为:{}", key, brands);
}
@Test
void listSize() {
String key = "brands";
ListOperations<String, Serializable> ops = redisTemplate.opsForList();
Long size = ops.size(key);
log.debug("在Redis中Key为【{}】的列表的长度为:{}", key, size);
}
@Test
void listRange() {
String key = "brands";
long start = 0;
long end = -1;
ListOperations<String, Serializable> ops = redisTemplate.opsForList();
List<Serializable> list = ops.range(key, start, end);
log.debug("从Redis中读取Key为【{}】的列表,start={},end={},获取到的列表长度为:{}",
key, start, end, list.size());
for (Serializable item : list) {
log.debug("{}", item);
}
}
}
关于存取字符串类型的数据,直接存、取即可。
关于存取对象型的数据,由于已经将值的序列化器配置为JSON(redisTemplate.setValueSerilizer(RedisSerializer.json())),在处理过程中,框架会自动将对象序列化成JSON字符串、将JSON字符串反序列化成对象,所以,对于Redis而言,操作的仍是字符串,所以,在存取对象型数据时,使用的API与存取字符串完全相同。
关于列表(List)类型的数据,首先,需要理解Redis中列表的数据结构,是一个先进后出、后进先出的栈结构:
![image-20221017174048618](C:\Users\TEDU\Desktop\每日笔记\成恒笔记\images\DAY17\image-20221017174048618.png)
并且,在Redis的List中,允许从左右两端操作列表(请将栈想像为横着的):
![image-20221017174153129](C:\Users\TEDU\Desktop\每日笔记\成恒笔记\images\DAY17\image-20221017174153129.png)
![image-20221017174203181](C:\Users\TEDU\Desktop\每日笔记\成恒笔记\images\DAY17\image-20221017174203181.png)
在读取List中的数据时,相关的API需要指定start和end,也就是读取整个列表中的某个区间的数据,无论是start还是end,都表示需要读取的数据的位置下标,例如传入的是5和10,就表示从下标为5的位置开始读取,直至下标为10的数据。在整个List中,第1个数据的下标为0,并且,Redis中的List元素都有正向的和反向的下标,正向的是从0开始递增的,反向下标是以最后一个元素作为-1,并且向前递减的:
![image-20221017174501682](C:\Users\TEDU\Desktop\每日笔记\成恒笔记\images\DAY17\image-20221017174501682.png)
所以,如果需要读取如以上图例的列表的全部数据,start值可以为0或-8,end值可以为7或-1,通常,读取全部数据推荐使用start为0,且end为-1。
day18
61. 在项目中使用Redis
由于Redis的存取效率非常高,在开发实践中,通常会将一些数据从关系型数据库(例如MySQL)中读取出来,并写入到Redis中,后续,当需要访问相关数据时,将优先从Redis中读取所需的数据,以此,可以提高数据的读取效率,并且,对一定程度的保护关系型数据库。
一旦使用Redis后,相关的数据就会同时存在于关系型数据和Redis中,即同一个数据有2份或更多(如果你使用了更多的Redis服务或其它数据处理技术),则可能出现数据不同步的问题!例如,当修改了或删除了关系型数据库中的数据,那Redis中的数据应该如何处理?同时更新?还是无视数据的变化?如果最终出现了关系型数据库和Redis中的数据不同的问题,则称之为“数据一致性问题”。
关于数据可能存在不一致的问题,首先,你必须知道,并不是所有的数据都必须同步,也就是说,当关系型数据库中的数据变化后,如果Redis中的数据没有同步发生变化,则Redis中的数据可以视为是“不准确的”,这个问题在许多应用场景中是可以接受的!例如热门话题的排行榜,或车票的余票数量、商品的库存余量等。
通常,应该Redis的前提应该是:
高频率访问的数据
例如热门榜单
修改频率非常低的数据
例如电商平台中商品的类别
对数据的“准确性”(一致性)要求不高的
例如商品的库存余量
62. 应用Redis
在项目中应用Redis主要需要实现:
将数据从MySQL中读出
已经由Mapper实现
【XX时】向Redis中写入
当需要读取数据时,将原本的从MySQL中读取数据改为从Redis中读取
推荐创建专门用于读写Redis的组件,则在项目的根包下创建repo.IBrandRedisRepository接口:
public interface IBrandRedisRepository {}
并在项目的根包下创建repo.impl.BrandRedisRepositoryImpl类,实现以上接口,并在类上添加@Repository注解:
@Repository
public class BrandRedisRepositoryImpl implements IBrandRedisRepository {}
然后,在IBrandRedisRepository接口中添加抽象方法:
package cn.tedu.csmall.product.repo;
import cn.tedu.csmall.product.pojo.vo.BrandListItemVO;
import cn.tedu.csmall.product.pojo.vo.BrandStandardVO;
import java.util.List;
/**
* 处理品牌缓存的数据访问接口
*
* @author java@tedu.cn
* @version 0.0.1
*/
public interface IBrandRedisRepository {
/**
* 品牌数据项在Redis中的Key前缀
*/
String BRAND_ITEM_KEY_PREFIX = "brand:item:";
/**
* 品牌列表在Redis中的Key
*/
String BRAND_LIST_KEY = "brand:list";
/**
* 向Redis中写入品牌数据
*
* @param brandStandardVO 品牌数据
*/
void save(BrandStandardVO brandStandardVO);
/**
* 向Redis中写入品牌列表
*
* @param brands 品牌列表
*/
void save(List<BrandListItemVO> brands);
/**
* 从Redis中读取品牌数据
*
* @param id 品牌id
* @return 匹配的品牌数据,如果没有匹配的数据,则返回null
*/
BrandStandardVO get(Long id);
/**
* 从Redis中读取品牌列表
*
* @return 品牌列表
*/
List<BrandListItemVO> list();
/**
* 从Redis中读取品牌列表
*
* @param start 读取数据的起始下标
* @param end 读取数据的截止下标
* @return 品牌列表
*/
List<BrandListItemVO> list(long start, long end);
}
并在BrandRedisRepositoryImpl中实现以上方法:
package cn.tedu.csmall.product.repo.impl;
import cn.tedu.csmall.product.pojo.vo.BrandListItemVO;
import cn.tedu.csmall.product.pojo.vo.BrandStandardVO;
import cn.tedu.csmall.product.repo.IBrandRedisRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.ListOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Repository;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
/**
* 处理品牌缓存的数据访问实现类
*
* @author java@tedu.cn
* @version 0.0.1
*/
@Slf4j
@Repository
public class BrandRedisRepositoryImpl implements IBrandRedisRepository {
@Autowired
RedisTemplate<String, Serializable> redisTemplate;
public BrandRedisRepositoryImpl() {
log.debug("创建处理缓存的数据访问对象:BrandRedisRepositoryImpl");
}
@Override
public void save(BrandStandardVO brandStandardVO) {
log.debug("准备向Redis中写入数据:{}", brandStandardVO);
String key = getItemKey(brandStandardVO.getId());
redisTemplate.opsForValue().set(key, brandStandardVO);
}
@Override
public void save(List<BrandListItemVO> brands) {
String key = getListKey();
ListOperations<String, Serializable> ops = redisTemplate.opsForList();
for (BrandListItemVO brand : brands) {
ops.rightPush(key, brand);
}
}
@Override
public BrandStandardVO get(Long id) {
String key = getItemKey(id);
Serializable serializable = redisTemplate.opsForValue().get(key);
if (serializable != null) {
if (serializable instanceof BrandStandardVO) {
return (BrandStandardVO) serializable;
}
}
return null;
}
@Override
public List<BrandListItemVO> list() {
long start = 0;
long end = -1;
return list(start, end);
}
@Override
public List<BrandListItemVO> list(long start, long end) {
String key = getListKey();
ListOperations<String, Serializable> ops = redisTemplate.opsForList();
List<Serializable> list = ops.range(key, start, end);
List<BrandListItemVO> brands = new ArrayList<>();
for (Serializable item : list) {
brands.add((BrandListItemVO) item);
}
return brands;
}
private String getItemKey(Long id) {
return BRAND_ITEM_KEY_PREFIX + id;
}
private String getListKey() {
return BRAND_LIST_KEY;
}
}
完成后,在src/test/java的根包下创建repo.BrandRedisRepositoryTests测试类,编写并执行测试:
package cn.tedu.csmall.product.repo;
import cn.tedu.csmall.product.pojo.entity.Brand;
import cn.tedu.csmall.product.pojo.vo.BrandListItemVO;
import cn.tedu.csmall.product.pojo.vo.BrandStandardVO;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@SpringBootTest
public class BrandRedisRepositoryTests {
@Autowired
IBrandRedisRepository repository;
@Test
void testSave() {
BrandStandardVO brand = new BrandStandardVO();
brand.setId(1L);
brand.setName("华为");
repository.save(brand);
log.debug("向Redis中写入数据完成!");
}
@Test
void testSaveList() {
List<BrandListItemVO> brands = new ArrayList<>();
for (int i = 1; i <= 8; i++) {
BrandListItemVO brand = new BrandListItemVO();
brand.setId(i + 0L);
brand.setName("测试品牌" + i);
brands.add(brand);
}
repository.save(brands);
log.debug("向Redis中写入列表数据完成!");
}
@Test
void testGet() {
Long id = 10000L;
Object result = repository.get(id);
log.debug("从Redis中读取【id={}】的数据,结果:{}", id, result);
}
@Test
void testList() {
List<?> list = repository.list();
log.debug("从Redis中读取列表,列表中的数据的数量:{}", list.size());
for (Object item : list) {
log.debug("{}", item);
}
}
@Test
void testListRange() {
long start = 2;
long end = 5;
List<?> list = repository.list(start, end);
log.debug("从Redis中读取列表,列表中的数据的数量:{}", list.size());
for (Object item : list) {
log.debug("{}", item);
}
}
}
关于在项目中应用Redis,首先考虑何时将MySQL中的数据读取出来并写入到Redis中!常见的策略有:
直接尝试从Redis中读取数据,如果Redis中无此数据,则从MySQL中读取并写入到Redis
从运行机制上,类似单例模式中的懒汉式
当项目启动时,就直接从MySQL中读取数据并写入到Redis
从运行机制上,类似单例模式中的饿汉式
这种做法通常称之为“缓存预热”
当使用缓存预热的处理机制时,需要使得某段代码是项目启动时就自动执行的,可以自定义组件类实现AppliacationRunner接口,重写其中的run()方法,此方法将在项目启动完成之后自动调用
【技能描述】
【了解/掌握/熟练掌握】开发工具的使用,包括:Eclipse、IntelliJ IDEA、Git、Maven;
【了解/掌握/熟练掌握】Java语法,【理解/深刻理解】面向对象编程思想,【了解/掌握/熟练掌握】Java SE API,包括:String、日期、IO、反射、线程、网络编程、集合、异常等;
【了解/掌握/熟练掌握】HTML、CSS、JavaScript前端技术,并【了解/掌握/熟练掌握】前端相关框架技术及常用工具组件,包括:jQuery、Bootstrap、Vue脚手架、Element UI、axios、qs、富文本编辑器等;
【了解/掌握/熟练掌握】MySQL的应用,【了解/掌握/熟练掌握】DDL、DML的规范使用;
【了解/掌握/熟练掌握】数据库编程技术,包括:JDBC、数据库连接池(commons-dbcp、commons-dbcp2、Hikari、druid),及相关框架技术,例如:Mybatis Plus等;
【了解/掌握/熟练掌握】主流框架技术的规范使用,例如:SSM(Spring,Spring MVC, Mybatis)、Spring Boot、Spring Validation、Spring Security等;
【理解/深刻理解】Java开发规范(参考阿里巴巴的Java开发手册);
【了解/掌握/熟练掌握】基于RESTful的Web应用程序开发;
【了解/掌握/熟练掌握】基于Spring Security与JWT实现单点登录;
day19
计划任务
在Spring Boot项目中,在任何组件类中,自定义方法,并在方法上添加@Scheduled注解,并通过此注解配置计划任务的执行周期或执行时间,则此方法就是一个计划任务方法。
在Spring Boot项目,计划任务默认是禁用的,需要在配置类上添加@EnableScheduling注解以开启项目中的计划任务。
则在根包下创建config.ScheduleConfiguration类:
package cn.tedu.csmall.product.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 计划任务配置类
*
* @author java@tedu.cn
* @version 0.0.1
*/
@Slf4j
@Configuration
@EnableScheduling
public class ScheduleConfiguration {
public ScheduleConfiguration() {
log.debug("创建配置类对象:ScheduleConfiguration");
}
}
另外,在根包下创建schedule.CacheSchedule类,作为处理缓存的计划任务类:
package cn.tedu.csmall.product.schedule;
import cn.tedu.csmall.product.service.IBrandService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* 处理缓存的计划任务类
*
* @author java@tedu.cn
* @version 0.0.1
*/
@Slf4j
@Component
public class CacheSchedule {
@Autowired
IBrandService brandService;
public CacheSchedule() {
log.debug("创建计划任务类对象:CacheSchedule");
}
// 关于@Scheduled注解的属性配置:
// fixedRate:每间隔多少毫秒执行一次
// fixedDelay:上次执行结束后,过多少毫秒执行一次
// cron:使用一个字符串,其中包含6~7个值,每个值之间使用1个空格进行分隔
// >> 在cron的字符串的各值分别表示:秒 分 时 日 月 周(星期) [年]
// >> 例如:cron = "56 34 12 2 1 ? 2035",则表示2035年1月2日12:34:56将执行此计划任务,无论这一天是星期几
// >> 以上各值都可以使用通配符,使用星号(*)则表示任意值,使用问号(?)表示不关心具体值,并且,问号只能用于“周(星期)”和“日”这2个位置
// >> 以上各值,可以使用“x/x”格式的值,例如,分钟对应的值使用“1/5”,则表示当分钟值为1的那一刻开始执行,往后每间隔5分钟执行一次
@Scheduled(fixedRate = 5 * 60 * 1000)
public void rebuildCache() {
log.debug("开始执行处理缓存的计划任务……");
brandService.rebuildCache();
log.debug("处理缓存的计划任务执行完成!");
}
}
以上代码需要在IBrandService中添加“重建缓存”的方法:
/**
* 重建品牌数据缓存
*/
void rebuildCache();
并在BrandServiceImpl中实现:
@Override
public void rebuildCache() {
log.debug("删除Redis中原有的品牌数据");
brandRedisRepository.deleteAll();
log.debug("从MySQL中读取品牌列表");
List<BrandListItemVO> brands = brandMapper.list();
log.debug("将品牌列表写入到Redis");
brandRedisRepository.save(brands);
log.debug("逐一根据id从MySQL中读取品牌详情,并写入到Redis");
for (BrandListItemVO item : brands) {
BrandStandardVO brand = brandMapper.getStandardById(item.getId());
brandRedisRepository.save(brand);
}
}
需要注意,对于周期性的计划任务,首次执行是在项目即将完成启动时,所以,也可以实现类似ApplicationRunner的效果,所以,使用周期性的计划任务也可以实现缓存预热,并且保持周期性的更新缓存!
由于计划任务是在专门的线程中处理的,与普通的处理请求、处理数据的线程是并行的,所以需要关注线程安全问题。
使用Mybatis拦截器处理gmt_create和gmt_modified字段的值
在每张数据表中,都有gmt_create、gmt_modified这2个字段(是在阿里的开发规范上明确要求的),这2个字段的值是有固定规则的,例如gmt_create的值就是INSERT这条数据时的时间,而gmt_modified的值就是每次执行UPDATE时更新的时间,由于这是固定的做法,可以使用Mybatis拦截器进行处理,即每次执行SQL语句之前,先判断是否为INSERT / UPDATE类型的SQL语句,如果是,再判断SQL语句是否处理了相关时间,如果没有,则修改原SQL语句,得到处理了相关时间的新SQL语句,并放行,使之最终执行的是修改后的SQL语句。
关于此拦截器的示例:
package cn.tedu.csmall.product.interceptor.mybatis;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.plugin.*;
import java.lang.reflect.Field;
import java.sql.Connection;
import java.time.LocalDateTime;
import java.util.Properties;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* <p>基于MyBatis的自动更新"最后修改时间"的拦截器</p>
*
* <p>需要SQL语法预编译之前进行拦截,则拦截类型为StatementHandler,拦截方法是prepare</p>
*
* <p>具体的拦截处理由内部的intercept()方法实现</p>
*
* <p>注意:由于仅适用于当前项目,并不具备范用性,所以:</p>
*
* <ul>
* <li>拦截所有的update方法(根据SQL语句以update前缀进行判定),无法不拦截某些update方法</li>
* <li>所有数据表中"最后修改时间"的字段名必须一致,由本拦截器的FIELD_MODIFIED属性进行设置</li>
* </ul>
*
* @author java@tedu.cn
* @version 0.0.1
*/
@Slf4j
@Intercepts({@Signature(
type = StatementHandler.class,
method = "prepare",
args = {Connection.class, Integer.class}
)})
public class InsertUpdateTimeInterceptor implements Interceptor {
/**
* 自动添加的创建时间字段
*/
private static final String FIELD_CREATE = "gmt_create";
/**
* 自动更新时间的字段
*/
private static final String FIELD_MODIFIED = "gmt_modified";
/**
* SQL语句类型:其它(暂无实际用途)
*/
private static final int SQL_TYPE_OTHER = 0;
/**
* SQL语句类型:INSERT
*/
private static final int SQL_TYPE_INSERT = 1;
/**
* SQL语句类型:UPDATE
*/
private static final int SQL_TYPE_UPDATE = 2;
/**
* 查找SQL类型的正则表达式:INSERT
*/
private static final String SQL_TYPE_PATTERN_INSERT = "^insert\\s";
/**
* 查找SQL类型的正则表达式:UPDATE
*/
private static final String SQL_TYPE_PATTERN_UPDATE = "^update\\s";
/**
* 查询SQL语句片段的正则表达式:gmt_modified片段
*/
private static final String SQL_STATEMENT_PATTERN_MODIFIED = ",\\s*" + FIELD_MODIFIED + "\\s*=";
/**
* 查询SQL语句片段的正则表达式:gmt_create片段
*/
private static final String SQL_STATEMENT_PATTERN_CREATE = ",\\s*" + FIELD_CREATE + "\\s*[,)]?";
/**
* 查询SQL语句片段的正则表达式:WHERE子句
*/
private static final String SQL_STATEMENT_PATTERN_WHERE = "\\s+where\\s+";
/**
* 查询SQL语句片段的正则表达式:VALUES子句
*/
private static final String SQL_STATEMENT_PATTERN_VALUES = "\\)\\s*values?\\s*\\(";
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 日志
log.debug("准备拦截SQL语句……");
// 获取boundSql,即:封装了即将执行的SQL语句及相关数据的对象
BoundSql boundSql = getBoundSql(invocation);
// 从boundSql中获取SQL语句
String sql = getSql(boundSql);
// 日志
log.debug("原SQL语句:{}", sql);
// 准备新SQL语句
String newSql = null;
// 判断原SQL类型
switch (getOriginalSqlType(sql)) {
case SQL_TYPE_INSERT:
// 日志
log.debug("原SQL语句是【INSERT】语句,准备补充更新时间……");
// 准备新SQL语句
newSql = appendCreateTimeField(sql, LocalDateTime.now());
break;
case SQL_TYPE_UPDATE:
// 日志
log.debug("原SQL语句是【UPDATE】语句,准备补充更新时间……");
// 准备新SQL语句
newSql = appendModifiedTimeField(sql, LocalDateTime.now());
break;
}
// 应用新SQL
if (newSql != null) {
// 日志
log.debug("新SQL语句:{}", newSql);
reflectAttributeValue(boundSql, "sql", newSql);
}
// 执行调用,即拦截器放行,执行后续部分
return invocation.proceed();
}
public String appendModifiedTimeField(String sqlStatement, LocalDateTime dateTime) {
Pattern gmtPattern = Pattern.compile(SQL_STATEMENT_PATTERN_MODIFIED, Pattern.CASE_INSENSITIVE);
if (gmtPattern.matcher(sqlStatement).find()) {
log.debug("原SQL语句中已经包含gmt_modified,将不补充添加时间字段");
return null;
}
StringBuilder sql = new StringBuilder(sqlStatement);
Pattern whereClausePattern = Pattern.compile(SQL_STATEMENT_PATTERN_WHERE, Pattern.CASE_INSENSITIVE);
Matcher whereClauseMatcher = whereClausePattern.matcher(sql);
// 查找 where 子句的位置
if (whereClauseMatcher.find()) {
int start = whereClauseMatcher.start();
int end = whereClauseMatcher.end();
String clause = whereClauseMatcher.group();
log.debug("在原SQL语句 {} 到 {} 找到 {}", start, end, clause);
String newSetClause = ", " + FIELD_MODIFIED + "='" + dateTime + "'";
sql.insert(start, newSetClause);
log.debug("在原SQL语句 {} 插入 {}", start, newSetClause);
log.debug("生成SQL: {}", sql);
return sql.toString();
}
return null;
}
public String appendCreateTimeField(String sqlStatement, LocalDateTime dateTime) {
// 如果 SQL 中已经包含 gmt_create 就不在添加这两个字段了
Pattern gmtPattern = Pattern.compile(SQL_STATEMENT_PATTERN_CREATE, Pattern.CASE_INSENSITIVE);
if (gmtPattern.matcher(sqlStatement).find()) {
log.debug("已经包含 gmt_create 不再添加 时间字段");
return null;
}
// INSERT into table (xx, xx, xx ) values (?,?,?)
// 查找 ) values ( 的位置
StringBuilder sql = new StringBuilder(sqlStatement);
Pattern valuesClausePattern = Pattern.compile(SQL_STATEMENT_PATTERN_VALUES, Pattern.CASE_INSENSITIVE);
Matcher valuesClauseMatcher = valuesClausePattern.matcher(sql);
// 查找 ") values " 的位置
if (valuesClauseMatcher.find()) {
int start = valuesClauseMatcher.start();
int end = valuesClauseMatcher.end();
String str = valuesClauseMatcher.group();
log.debug("找到value字符串:{} 的位置 {}, {}", str, start, end);
// 插入字段列表
String fieldNames = ", " + FIELD_CREATE + ", " + FIELD_MODIFIED;
sql.insert(start, fieldNames);
log.debug("插入字段列表{}", fieldNames);
// 定义查找参数值位置的 正则表达 “)”
Pattern paramPositionPattern = Pattern.compile("\\)");
Matcher paramPositionMatcher = paramPositionPattern.matcher(sql);
// 从 ) values ( 的后面位置 end 开始查找 结束括号的位置
String param = ", '" + dateTime + "', '" + dateTime + "'";
int position = end + fieldNames.length();
while (paramPositionMatcher.find(position)) {
start = paramPositionMatcher.start();
end = paramPositionMatcher.end();
str = paramPositionMatcher.group();
log.debug("找到参数值插入位置 {}, {}, {}", str, start, end);
sql.insert(start, param);
log.debug("在 {} 插入参数值 {}", start, param);
position = end + param.length();
}
if (position == end) {
log.warn("没有找到插入数据的位置!");
return null;
}
} else {
log.warn("没有找到 ) values (");
return null;
}
log.debug("生成SQL: {}", sql);
return sql.toString();
}
@Override
public Object plugin(Object target) {
// 本方法的代码是相对固定的
if (target instanceof StatementHandler) {
return Plugin.wrap(target, this);
} else {
return target;
}
}
@Override
public void setProperties(Properties properties) {
// 无须执行操作
}
/**
* <p>获取BoundSql对象,此部分代码相对固定</p>
*
* <p>注意:根据拦截类型不同,获取BoundSql的步骤并不相同,此处并未穷举所有方式!</p>
*
* @param invocation 调用对象
* @return 绑定SQL的对象
*/
private BoundSql getBoundSql(Invocation invocation) {
Object invocationTarget = invocation.getTarget();
if (invocationTarget instanceof StatementHandler) {
StatementHandler statementHandler = (StatementHandler) invocationTarget;
return statementHandler.getBoundSql();
} else {
throw new RuntimeException("获取StatementHandler失败!请检查拦截器配置!");
}
}
/**
* 从BoundSql对象中获取SQL语句
*
* @param boundSql BoundSql对象
* @return 将BoundSql对象中封装的SQL语句进行转换小写、去除多余空白后的SQL语句
*/
private String getSql(BoundSql boundSql) {
return boundSql.getSql().toLowerCase().replaceAll("\\s+", " ").trim();
}
/**
* <p>通过反射,设置某个对象的某个属性的值</p>
*
* @param object 需要设置值的对象
* @param attributeName 需要设置值的属性名称
* @param attributeValue 新的值
* @throws NoSuchFieldException 无此字段异常
* @throws IllegalAccessException 非法访问异常
*/
private void reflectAttributeValue(Object object, String attributeName, String attributeValue) throws NoSuchFieldException, IllegalAccessException {
Field field = object.getClass().getDeclaredField(attributeName);
field.setAccessible(true);
field.set(object, attributeValue);
}
/**
* 获取原SQL语句类型
*
* @param sql 原SQL语句
* @return SQL语句类型
*/
private int getOriginalSqlType(String sql) {
Pattern pattern;
pattern = Pattern.compile(SQL_TYPE_PATTERN_INSERT, Pattern.CASE_INSENSITIVE);
if (pattern.matcher(sql).find()) {
return SQL_TYPE_INSERT;
}
pattern = Pattern.compile(SQL_TYPE_PATTERN_UPDATE, Pattern.CASE_INSENSITIVE);
if (pattern.matcher(sql).find()) {
return SQL_TYPE_UPDATE;
}
return SQL_TYPE_OTHER;
}
}
Mybatis拦截器必须注册后才能生效!可以在配置类(或任何组件类)中:
@Autowired
private List<SqlSessionFactory> sqlSessionFactoryList;
@PostConstruct // 使得此方法在调用了构造方法、完成了属性注入之后自动执行
public void addInterceptor() {
InsertUpdateTimeInterceptor interceptor = new InsertUpdateTimeInterceptor();
for (SqlSessionFactory sqlSessionFactory : sqlSessionFactoryList) {
sqlSessionFactory.getConfiguration().addInterceptor(interceptor);
}
}
晚课01
1. 处理密码加密
用户的密码必须被加密后再存储到数据库,否则,就存在用户账号安全问题。
用户使用的原始密码通常称之为“原文”或“明文”,经过算法的运算,得到的结果通常称之为“密文”。
在处理密码加密时,不可以使用任何加密算法,因为所有加密算法都是可以被逆向运算的,也就是说,当密文、算法、加密参数作为已知条件的情况下,是可以根据密文计算得到原文的!
提示:加密算法通常是用于保障数据传输过程的安全的,并不适用于存储下来的数据安全!
对存储的密码进行加密处理,通常使用消息摘要算法!
消息摘要算法的特点:
消息(原文、原始数据)相同,则摘要相同
无论消息多长,每个算法的摘要结果长度固定
消息不同,则摘要极大概率不会相同
注意:消息摘要算法是不可逆向运算的算法!即你永远不可能根据摘要(密文)逆向计算得到消息(原文)!
常见的消息摘要算法有:
SHA系列:SHA-1、SHA-256、SHA-384、SHA-512
MD家族:MD2、MD4、MD5
Spring框架内有DigestUtils的工具类,提供了MD5的API,例如:
package cn.tedu.csmall.product;
import org.junit.jupiter.api.Test;
import org.springframework.util.DigestUtils;
public class MessageDigestTests {
@Test
public void testMd5() {
String rawPassword = "123456";
String encodedPassword = DigestUtils
.md5DigestAsHex(rawPassword.getBytes());
System.out.println("原文:" + rawPassword);
System.out.println("密文:" + encodedPassword);
System.out.println();
rawPassword = "123456";
encodedPassword = DigestUtils
.md5DigestAsHex(rawPassword.getBytes());
System.out.println("原文:" + rawPassword);
System.out.println("密文:" + encodedPassword);
System.out.println();
rawPassword = "1234567890ABCDEFGHIJKLMN";
encodedPassword = DigestUtils
.md5DigestAsHex(rawPassword.getBytes());
System.out.println("原文:" + rawPassword);
System.out.println("密文:" + encodedPassword);
}
}
以上测试的运行结果为:
原文:123456
密文:e10adc3949ba59abbe56e057f20f883e
原文:123456
密文:e10adc3949ba59abbe56e057f20f883e
原文:1234567890ABCDEFGHIJKLMN
密文:41217c45889b5378c3dad3879d7bfac9
提示:在项目中添加commons-codec的依赖项,可以使用更多消息摘要算法的API。
未完待续!
晚课02
处理密码加密(续)
在算法的学术领域中,如果算法的计算结果的长度是固定,会根据结果是由多少位二进制数来组成,来确定是这多少位的算法,以MD5算法为例,其计算结果是由128个二进制数组成的,所以,MD5算法是128位算法,通常,会将二进制结果转换成十六进制来表示,所以,会是32位长度的十六进制数!
常见的消息摘要算法中,MD系列的都是128位算法,SHA-1是160位算法,SHA-256是256位算法,SHA-384是384位算法,SHA-512是512位算法。
理论上来说,如果某个消息摘要算法的结果只是1位(1个二进制数),最多使用2 + 1个不同的原文,必然发生“碰撞”(即完全不同的原文对应相同的摘要),同理,如果算法的结果有2位(2个二进制数组成),最多使用4 + 1个不同的原文必然后发生碰撞,如果算法的结果有3位,最多使用8 + 1个不同的原文必然发生碰撞,而MD5是128位算法,理论上,最多需要使用2的128次方 + 1个不同的原文才能保证必然发生碰撞!
2的128次方的值是:340282366920938463463374607431768211456。
当使用MD5处理密码加密时,理论上,需要尝试340282366920938463463374607431768211456 + 1个不同的原密码,才能试出2个不同的原密码都可以登录同一个账号!由于需要尝试的次数太多,按照目前的计算机的算力,这是不可能实现的!所以,大致可以视为“找不到2个不同的原文对应相同的结果”。
通过,对于使用消息摘要算法处理密码加密的结果,如果需要破解,只能尽可能的穷举原密码(消息/原文)与加密后的密码(摘要/密文)之间的对应关系,当执行“破解”时,从记录下来的结果中进行搜索即可!例如:
原文 密码
0000 4a7d1ed414474e4033ac29ccb8653d9b
0001 25bbdcd06c32d477f7fa1c3e4a91b032
0002 fcd04e26e900e94b9ed6dd604fed2b64
......
9999 fa246d0262c3925617b0c72bb20eeb1d
aaaa 74b87337454200d4d33f80c4663dc5e5
aaab 4c189b020ceb022e0ecc42482802e2b8
......
zzzz 02c425157ecd32f259548b33402ff6d3
00000 dcddb75469b4b4875094e14561e573d8
目前,在网络上也有许多平台提供了这种机制的“破解”!而这些平台收录的原文密文对应关系不可能特别多,假设允许使用在密码中的字符有80种,则8位长度(含以下长度)的密码有约1677万亿种,大多平台不可能收录!
所以,只要原密码足够复杂,此原密码与密文的对应关系大概率是没有被“破解”平台收录的,则不会被破解!
在编程时,为保证密码安全,应该做到:
要求用户使用安全强度更高的原始密码
在处理加密的过程中,使用循环实现多重加密
使用位数更长的算法
加盐
综合以上做法
晚课03
Mybatis中的#{}和${}格式的占位符
在使用Mybatis时,在SQL语句中的参数,可以使用#{}或${}格式的占位符。
当配置的SQL语句如下时:
SELECT
<include refid="StandardQueryFields" />
FROM
ams_admin
WHERE
id=#{id}
以上SQL语句中的参数,无论使用#{}还是${},执行效果完全相同。
当配置的SQL语句如下时:
SELECT
<include refid="LoginQueryFields"/>
FROM
ams_admin
WHERE
username=#{username}
以上SQL语句中的参数,使用#{}格式的占位符时可以正常执行,使用${}格式的占位符将执行出错,错误信息例如:
java.sql.SQLSyntaxErrorException: Unknown column 'fanchuanqi' in 'where clause'
; bad SQL grammar []; nested exception is java.sql.SQLSyntaxErrorException: Unknown column 'fanchuanqi' in 'where clause'
其实,在SQL语句中,除了关键字、数值、特定位置的字符或字符串以外,只要没有使用特殊符号框住,SQL语句中的其它内容都会被视为“字段名”,例如:
SELECT
id, username, password, enable
FROM
`ams_admin`
WHERE
username=fanchuanqi
提示:使用一对单撇符号框住的就是自定义名称
提示:使用一对单引号框住的都是字符串
在使用Mybatis时,如果SQL语句中的参数使用#{}格式的占位符,会进行预编译的处理,如果使用的是${}格式的占位符,则不会预编译。
提示:计算机能够直接识别并执行的只有机器语言,即二进制语言,所有其它编程语言编写的源代码都需要经过编译、解释,转换成机制语言才可以被识别并执行。在执行编译之前,通常都还有词法分析、语义分析等过程。由于语义分析是在编译之前执行的,所以,一旦执行到了编译,则SQL语句的“意思”不会再发生变化!
以以下SQL语句为例:
SELECT
<include refid="LoginQueryFields"/>
FROM
ams_admin
WHERE
username=#{username}
由于使用的是#{}格式的占位符,则#{username}会被识别成参数,经过预编译(先编译,再传值,再执行)处理后,无论在此处传入什么值,都会被认为是参数,语义不会发生变化!
如果使用的是${}格式的占位符,则会先将参数值代入到SQL语句中,然后再执行编译!
所以,使用#{}格式的占位符,不必关心参数值的数据类型问题,并且,没有SQL注入的风险,因为在编译之前就已经确定了此SQL语句的语义,无论传的值是什么样的,语义都不会改变!但是,这种格式的占位符只能表示某个值,不能表示SQL语句中的其它部分!
使用${}格式的占位符,需要关心参数值的数据类型问题,如果参数的值是字符串类型的,必须在值的两侧添加单引号,这种做法存在SQL注入的风险,因为传入的参数值可能会改变语义!需要注意,这种格式的占位符可以表示SQL语句中的任何片段!
另外,对于SQL注入,应该有正确的认识,不需要盲目拒绝!
SELECT * FROM user WHERE username='?' AND password='?'
username: root
password: secret
SELECT * FROM user WHERE username='root' AND password='secret'
username: root
password: a' OR 'a'='a
SELECT * FROM user WHERE username='root' AND password='a' OR 'x'='x' OR 'a'='a'
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。