0%

Java|物资申请系统开发总结

image-20220504104541342

数据库架构

数据库结构图

主要数据表信息

一.物资申请表

image-20220504104541342

  • 用户id : 物资申请条目 = 1 : n
  • 机构id : 物资申请条目 = 1 : n

二.物资申请详情表

image-20220504105313339

  • 物资申请条目id : 物资申请详情条目 = n : n
  • 物资id : 物资申请详情条目 = n : n
  • (物资申请条目id,物资id) : 物资申请详情条目 = 1 : n

三.物资表

image-20220504105953610

四.用户表

image-20220504110242618

  • 用户信息条目 : 权限id = 1 : 1

五.权限表

image-20220504110408506

系统架构

技术架构

一.前端

  • Vue.js:前端逻辑处理数据
  • Bootstrap:使用模板样式
  • Jquery
  • axios
  • Thymeleaf:主要使用其HTML包含技术,整合页面共用部分(Springboot官方推荐的视图)

二.后端

  • SpringBoot 1.5.9 RELEASE
  • Shiro安全框架
  • Maven
  • Hibernate
  • Elasticsearch搜索引擎

三.数据库

  • MySQL数据库
  • Redis

相关依赖准备

pom.xml文件导入相关依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
<dependencies>
<!-- springboot web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!-- springboot tomcat 支持 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
<scope>provided</scope>
</dependency>

<!-- 热部署 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<optional>true</optional>
</dependency>

<!-- jpa:java持久层api,用于操作数据库-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

<!-- redis:基于内存、分布式、可选持久性的键值对(Key-Value)存储数据库,一般说来,会被当作缓存使用。 因为它比数据库(mysql)快 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<!-- springboot test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

<!-- thymeleaf: Thymeleaf 是一种模板语言,可以达到和JSP一样的效果,但是比起JSP 对于前端测试更加友好-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

<!-- elastic search:Elasticsearch是一个基于Lucene库的搜索引擎。它提供了一个分布式、支持多租户的全文搜索引擎 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
<!-- 用了 elasticsearch 就要加这么一个,不然要com.sun.jna.Native 错误 -->

<dependency>
<groupId>com.sun.jna</groupId>
<artifactId>jna</artifactId>
<version>3.0.9</version>
</dependency>

<!-- thymeleaf legacyhtml5 模式支持 -->
<dependency>
<groupId>net.sourceforge.nekohtml</groupId>
<artifactId>nekohtml</artifactId>
<version>1.9.22</version>
</dependency>

<!-- 测试支持 -->
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<scope>test</scope>
</dependency>

<!-- tomcat的支持.-->
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-jasper</artifactId>
<version>8.5.23</version>
</dependency>

<!-- mysql:数据库支持-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.21</version>
</dependency>

<!-- junit:java自动测试工具 -->
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version> 4.12</version>
</dependency>

<!-- commons-lang:提供常用工具包 -->
<dependency>
<groupId>commons-lang</groupId>
<artifactId>commons-lang</artifactId>
<version>2.6</version>
</dependency>

<!-- shiro:Java 当下常见的安全框架,主要用于用户验证和授权操作 -->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.3.2</version>
</dependency>

<!-- hsqldb是一款Java内置的数据库,非常适合在用于快速的测试和演示的Java程序中 -->
<dependency>
<groupId>org.hsqldb</groupId>
<artifactId>hsqldb</artifactId>
</dependency>

<!-- springfox-swagger依赖添加:文档化工具 -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>20.0</version>
</dependency>

<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
<version>2.9.2</version>
</dependency>

<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>swagger-bootstrap-ui</artifactId>
<version>1.9.6</version>
</dependency>

</dependencies>

开发内容

MySQL优化过程

一.T-SQL脚本分表优化

1.相关表的结构

此处展示的表结构为维护前

物资申请表:共4817条数据

image-20220303154338625

物资信息表:

image-20220303154513022

goods_count:当前仓库物品数(物理的)

good_leftCount:当前物品可借数(网络的:存在部分未借出,但已被预订仍在审核中的物品)

2.优化思路:物资申请表分表

从上面的tw_apply表就可以知道:

  • 在用户提出申请后,物资申请信息被后端拼成了一个字符串存储在apply_content(同时利用了前端的数据执行了物品可借数的预扣除,所以这部分没有用到物资申请信息字符串的解析)

  • 通过审核后,物品正式借出,这时候只留有物资申请信息的字符串存储在数据库,所以需要后端对该字符串解析提取出申请物资与其借用数量,再去操作数据库

还好后端大哥没有把物资申请信息的字符串直接发给前端,我真的哭死,设计数据库的那个出来挨打(前端不需要解析,但是要拼接展示字符串)

数据库设计十分不合理,甚至不符合第一范式,浪费数据库大量存储空间不说,而且后端拼接字符串解析字符串这一过程十分耗时且占用内存,而且最新的需求是需要增加一个审核过程申请物资调整功能

所以我将物资申请表进行分表(水平分表),分出物资申请详情表并联系物资信息表,其结构如下

image-20220303164602406

删除掉apply_content字段,节省数据库空间

分表后,通过tw_applydetail表,我们对物资申请信息的所以内容进行操作,省去了物资审核接口对字符串解析的耗时过程并且方便审核过程申请物资调整功能的开发(通过tw_appdetail找到物品信息和物品数量)

3.优化操作:存储过程脚本

存储过程(Stored Procedure)是一种在数据库中存储复杂程序,以便外部程序调用的一种数据库对象

这里值得注意的是在遍历游标的循环中,如果查询不存在或为空会跳出循环

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
CREATE DEFINER=`Autovy`@`localhost` PROCEDURE `demo`()
BEGIN

-- 定义变量
DECLARE i INT DEFAULT 0;
DECLARE s int DEFAULT 0;
DECLARE n TINYTEXT;
DECLARE m INT(11);
# 求分割符号','的位置
DECLARE _index INT;

# 单个物品申请信息
DECLARE str TINYTEXT;

# 单个物品申请信息长度
DECLARE strLength int;

# 物品名称
DECLARE goodName VARCHAR(10);

# 物品数量
DECLARE goodCount int(11);

# 物品id
DECLARE goodId int(11);


-- 定义游标,并将sql结果集赋值给游标
DECLARE apply_id1 CURSOR FOR SELECT id, apply_content FROM tw_apply WHERE apply_content LIKE "本部%";

-- 声明当游标遍历完后将标志变量置成1
DECLARE CONTINUE HANDLER FOR NOT FOUND SET s=1;

-- 打开游标
OPEN apply_id1;

-- 将游标中的值赋值给变量,注意:变量名不要和返回列名同名,变量顺序要和sql结果顺序一致
FETCH apply_id1 into m, n;

-- 当s != 1,一直循环
while s<>1 do


SET _index = LOCATE(';', n);

-- 通过;分割单个物品的申请信息:北院—帐篷物品1个;北院—椅子物品1个;北院—桌子物品1个;
while _index > 0 do

-- 拿到单个物品申请信息:本部——桌子物品1个
SET str = LEFT(n, _index-1);

SET strLength = LENGTH(str) / 5;

-- 拿到物品名称
SET goodName = LEFT(str, strLength);

-- 拿到物品个数(类型转换为整数)
SET goodCount = CAST(LEFT(RIGHT(str, 2), 1) AS signed) ;

SELECT goodName;

-- 按物品名称查到物品id并存储到goodId中
-- 这里如果查询不存在或为空会跳出游标循环,值得注意
SELECT goods_id into goodId FROM tw_goods WHERE goods_show LIKE goodName ORDER BY goods_count DESC LIMIT 1;

-- 插入数据
INSERT tw_applydetail(apply_id, good_id, count) VALUES (m, goodId, goodCount);

-- 移动到下个分界点
SET n = SUBSTR(n FROM _index+1);
SET _index = LOCATE(';', n);

end while;


-- 执行业务逻辑
set i = i+1;


-- 读取下一条数据,读取完成置变量s=1
FETCH apply_id1 into m, n;
end while;


-- 关闭游标
close apply_id1;


END

当时经过一天的对存储过程的学习,我总结出了以下经验:存储过程非常不方便调试,而且报错信息只定位不报错误类型(sql是这样的)。如果能重来,对数据库的批量操作,首选Python或Shell

4.优化结果
  • 截至目前物资申请表已有4817条数据,考虑到后面数据会长期积累,这样的优化是有必要的
  • 去掉后端耗时耗内存的字符串解析工作
  • 节省数据库存储空间,优化前申请表内存占0.79MB,优化后占0.56MB

另外附加一个容量查询小工具,可查询数据库各表容量大小

1
2
3
4
5
6
7
8
9
select
table_schema as '数据库',
table_name as '表名',
table_rows as '记录数',
truncate(data_length/1024/1024, 2) as '数据容量(MB)',
truncate(index_length/1024/1024, 2) as '索引容量(MB)'
from information_schema.tables
where table_schema='bgs'
order by data_length desc, index_length desc;

二.索引优化查询

1.相关表结构

日志记录表:共33687条数据

image-20220303191047146

2.优化思路:添加索引

关于索引的知识点这里不细说,推荐阅读:MySQL 索引详解

由于日志表数据庞大,有3万条数据,为了达到快速通过用户名模糊查找到日志操作内容和操作时间,就需要用到索引,另外在模糊查询中,like语句要使索引生效,like后不能以%开始,也就是说 (like %字段名%) 、(like %字段名)这类语句会使索引失效,而(like 字段名)、(like 字段名%)这类语句索引是可以正常使用

所以我将查询的模糊匹配由“%xxxx%”改为“xxxx%”,只模糊匹配前面部分

3.优化操作

这里直接使用Navicat可视化添加索引,因为后台查询日志是需要用用户名模糊查找到日志操作内容和操作时间,所以需要添加的索引为log_realnam

image-20220303193939350

更改mybatis的sql映射,解决sql注入和索引失效问题

1
SELECT log_realname, log_content, log_time FROM tw_log WHERE log_realname LIKE "%${log_name}%";  

在这种情况下使用#程序会报错,新手程序员就把#号改成了$,这样如果java代码层面没有对用户输入的内容做处理势必会产生SQL注入漏洞。

正确写法:

1
SELECT log_realname, log_content, log_time FROM tw_log WHERE log_realname LIKE concat(‘%’,#{log_name}, ‘%’) 
4.优化结果
  • 添加索引前使用用户名模糊查询日志,耗时大约0.045s,添加索引后耗时大约0.032s,减少了磁盘IO,提高了查询速度
  • 修改mybatis中模糊查询的sql语句,解决索引失效的问题,并解决了模糊查询中拼接字符串的sql注入问题

Elasticsearch搜索

一.ES配置

1.ES可视化

kibana是es的可视化工具,开启后可以通过访问 http://127.0.0.1:5601/ 查看kibana页面

image-20220311102937485

2.配置ES
1
2
# ElasticSearch
spring.data.elasticsearch.cluster-nodes = 127.0.0.1:9300

二.ES开发流程

1.ES注解实体类
1
2
3
// @Document注解Category实体类,一个Category对象即为一个Document(相当于数据库的一行)
// 连接到es的tmall_springboot索引(相当于数据库),produt类(相当于表)上
@Document(indexName = "tmall_springboot",type = "product")
2.esDAO的创建

由于整合了ES的JPA和操作数据库使用的JPA有冲突,所以不能放在同一个包下

1
2
3
4
5
6
7
8
9
10
11
12
package com.how2java.tmall.es;

import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;

import com.how2java.tmall.pojo.Product;
// 用于链接es的DAO
// esDAO和其他DAO不能放在一个包下否则会启动异常
// 主要使用es实现对产品的模糊查询
public interface ProductESDAO extends ElasticsearchRepository<Product,Integer>{

}

3.Application引入ES
1
2
3
4
5
// esJPA引入
@EnableElasticsearchRepositories(basePackages = "com.how2java.tmall.es")

// JPA引入
@EnableJpaRepositories(basePackages = {"com.how2java.tmall.dao", "com.how2java.tmall.pojo"})
4.服务层同步ES

增删改操作

增删改操作的数据需要同步ES和数据库

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 通过ProductDAO对数据库有影响的
// 都要通过productESDAO同步到es
@CacheEvict(allEntries=true)
public void add(Product bean) {
productDAO.save(bean);
productESDAO.save(bean);
}


@CacheEvict(allEntries=true)
public void delete(int id) {
productDAO.delete(id);
productESDAO.delete(id);
}


@CacheEvict(allEntries=true)
public void update(Product bean) {
productDAO.save(bean);
productESDAO.save(bean);
}

ES初始化

ES内数据为空,就将数据库中的数据同步到es

1
2
3
4
5
6
7
8
9
10
11
12
13
// 初始化数据到es
private void initDatabase2ES() {
Pageable pageable = new PageRequest(0, 5);
Page<Product> page =productESDAO.findAll(pageable);
// 查询es中是否有数据
if(page.getContent().isEmpty()) {
// 如果数据为空,将数据从数据库同步到es中
List<Product> products= productDAO.findAll();
for (Product product : products) {
productESDAO.save(product);
}
}
}
5.服务层查询ES
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 通过es进行查询
public List<Product> search(String keyword, int start, int size) {
// 初始化es
initDatabase2ES();

// QueryBuilders提供了大量静态方法,用于生成各种不同类型的查询对象
// 构建查询条件(多条件查询)
FunctionScoreQueryBuilder functionScoreQueryBuilder = QueryBuilders.functionScoreQuery()
// 为提供的字段名和文本创建一个通用查询
.add(QueryBuilders.matchPhraseQuery("name", keyword),
ScoreFunctionBuilders.weightFactorFunction(100))
// 设置权重分为求和模式
.scoreMode("sum")
// 设置权重分最低分
.setMinScore(10);

// 设置分页参数
Sort sort = new Sort(Sort.Direction.DESC,"id");
Pageable pageable = new PageRequest(start, size,sort);

// 添加分页参数和查询条件
SearchQuery searchQuery = new NativeSearchQueryBuilder()
.withPageable(pageable)
.withQuery(functionScoreQueryBuilder).build();

// 执行查询获取结果
Page<Product> page = productESDAO.search(searchQuery);
// 返回结果
return page.getContent();
}

Redis缓存

一.Redis可视化工具

推荐使用RedisClient,数据一般都在db0中

image-20220310201748019

二.Redis配置

1.Redis配置类

该缓存配置类主要是使redis内的key和value转换为可读性的字符串

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Configuration
//Redis 缓存配置类

public class RedisConfig extends CachingConfigurerSupport {
@Bean
public CacheManager cacheManager(RedisTemplate<?,?> redisTemplate) {
RedisSerializer stringSerializer = new StringRedisSerializer();
Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
ObjectMapper om = new ObjectMapper();
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.PUBLIC_ONLY);
om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
jackson2JsonRedisSerializer.setObjectMapper(om);
redisTemplate.setKeySerializer(stringSerializer);
redisTemplate.setHashKeySerializer(stringSerializer);

redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
CacheManager cacheManager = new RedisCacheManager(redisTemplate);
return cacheManager;

}
}

2.Redis配置文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Redis数据库索引(默认为0)
spring.redis.database=0
# Redis服务器地址
spring.redis.host=127.0.0.1
# Redis服务器连接端口
spring.redis.port=6379
# Redis服务器连接密码(默认为空)
spring.redis.password=
# 连接池最大连接数(使用负值表示没有限制)
spring.redis.pool.max-active=10
# 连接池最大阻塞等待时间(使用负值表示没有限制)
spring.redis.pool.max-wait=-1
# 连接池中的最大空闲连接
spring.redis.pool.max-idle=8
# 连接池中的最小空闲连接
spring.redis.pool.min-idle=0
# 连接超时时间(毫秒)
spring.redis.timeout=0

三.缓存启用与检测

1.缓存的启用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 系统启动入口
@SpringBootApplication
// 启动缓存
@EnableCaching
@EnableElasticsearchRepositories(basePackages = "com.how2java.tmall.es")
@EnableJpaRepositories(basePackages = {"com.how2java.tmall.dao", "com.how2java.tmall.pojo"})
public class Application {
static {
// 检测端口上的服务是否启动
PortUtil.checkPort(6379,"Redis 服务端",true);
PortUtil.checkPort(9300,"ElasticSearch 服务端",true);
PortUtil.checkPort(5601,"Kibana 工具", true);
}
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
2.服务开启检测

这里的PortUtil是一个检测端口上服务是否运行的简单工具类,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43

package com.how2java.tmall.util;

import java.io.IOException;
import java.net.ServerSocket;

import javax.swing.JOptionPane;

// 工具类,检查某个端口对应的服务是否启动
// 可以用于检查redis服务和es服务
public class PortUtil {

public static boolean testPort(int port) {
try {
ServerSocket ss = new ServerSocket(port);
ss.close();
return false;
} catch (java.net.BindException e) {
return true;
} catch (IOException e) {
return true;
}
}


public static void checkPort(int port, String server, boolean shutdown) {
if(!testPort(port)) {
if(shutdown) {
String message =String.format("在端口 %d 未检查得到 %s 启动%n",port,server);
JOptionPane.showMessageDialog(null, message);
System.exit(1);
}
else {
String message =String.format("在端口 %d 未检查得到 %s 启动%n,是否继续?",port,server);
if(JOptionPane.OK_OPTION != JOptionPane.showConfirmDialog(null, message))
System.exit(1);


}
}
}

}

四.缓存的使用

缓存的使用一般在服务层使用

1.有序集合管理

通过在服务层中注解@CacheConfig,创建一个有序集合类型的缓存,管理该服务下所有的keys

1
2
3
4
5
6
7
8
// 分类服务层
@Service
// redis缓存一般都在服务层进行操作
// 分类服务下的所有keys都由categories来管理(数据存储与categories是平行关系)
@CacheConfig(cacheNames="categories")
public class CategoryService {
.....
}

image-20220310202613389

2查询插入缓存
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 获得单条分类服务
// 添加一条缓存到redis中,以categories-one- + 参数id为key值
// 存储的主要数据为Category对象
@Cacheable(key="'categories-one-'+ #p0")
public Category get(int id) {
Category c= categoryDAO.findOne(id);
return c;
}

// 列出单页分类表(提供分页组索引)
// 添加一条缓存到redis中,以categories-page- + 参数start + 参数size 为key值
// 存储的主要数据为Page4Navigator<Category>数组
@Cacheable(key="'categories-page-'+#p0+ '-' + #p1")
public Page4Navigator<Category> list(int start, int size, int navigatePages) {
Sort sort = new Sort(Sort.Direction.DESC, "id");
Pageable pageable = new PageRequest(start, size, sort);
Page pageFromJPA =categoryDAO.findAll(pageable);

return new Page4Navigator<>(pageFromJPA,navigatePages);
}

返回的java对象或集合都会变成JSON字符串

image-20220310203123882

image-20220310203207786

3.更新删除缓存

准确来说是插入,删除,更新删除缓存以保持数据一致性

使用@CacheEvict(allEntries=true)删除category~keys的所有keys

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 增加删除更新时
// 增加分类服务
@CacheEvict(allEntries=true)
public void add(Category bean) {
categoryDAO.save(bean);
}

// 删除分类服务
@CacheEvict(allEntries=true)
public void delete(int id) {
categoryDAO.delete(id);
}

// 更新分类服务
@CacheEvict(allEntries=true)
public void update(Category bean) {
categoryDAO.save(bean);
}

Shiro登录验证

由于本项目仅仅有用户一个权限,所以只需要判断用户是否登录,并不需要比较细粒度的权限分配

一.JPARealm验证授权器

Shiro与用户之间的中介,为Shiro提供验证和授权用户的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package com.how2java.tmall.realm;

import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.SimpleAuthenticationInfo;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.util.ByteSource;
import org.springframework.beans.factory.annotation.Autowired;

import com.how2java.tmall.pojo.User;
import com.how2java.tmall.service.UserService;

// 通过JPA进行验证授权
// (相当于一个中介,拿着用户信息去数据库找用户拥有的角色和权限)
// 将Realm提供给Shiro,由其负责调用,不需要直接调用
public class JPARealm extends AuthorizingRealm {

@Autowired
private UserService userService;

// 认证:查询用户身份与密码,解决你是谁的问题
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
// 从token中取出用户名称
String userName = token.getPrincipal().toString();
// 查询用户表得到用户加密密码
User user = userService.getByName(userName);
String passwordInDB = user.getPassword();
// 获得用户表中的盐
String salt = user.getSalt();
// 以用户名,加密密码,盐,真实信息,真正姓名作为认证信息
SimpleAuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(userName, passwordInDB, ByteSource.Util.bytes(salt),
getName());
return authenticationInfo;
}


// 授权:赋予用户权限,解决你能做什么的问题
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
//
SimpleAuthorizationInfo s = new SimpleAuthorizationInfo();
return s;
}


}

二.Shiro配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
package com.how2java.tmall.config;

import com.how2java.tmall.realm.JPARealm;
import org.apache.shiro.authc.credential.HashedCredentialsMatcher;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.spring.LifecycleBeanPostProcessor;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

// Shiro配置文件
@Configuration
public class ShiroConfiguration {
@Bean
public static LifecycleBeanPostProcessor getLifecycleBeanPostProcessor() {
return new LifecycleBeanPostProcessor();
}



// 过滤器,实现对请求的拦截和跳转
@Bean
public ShiroFilterFactoryBean shirFilter(SecurityManager securityManager){
// 创建 ShiroFilterFactoryBean 对象
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
// 设置SecurityManager
shiroFilterFactoryBean.setSecurityManager(securityManager);

/*
这里可以设置URL并为它们配置权限,本项目没有用到
*/

return shiroFilterFactoryBean;
}


// shiro核心组件
@Bean
public SecurityManager securityManager(){
// 创建DefaultWebSecurityManager对象
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
// 设置其使用的Realm
securityManager.setRealm(getJPARealm());
return securityManager;
}

// 加载身份认证与授权模块
@Bean
public JPARealm getJPARealm(){
JPARealm myShiroRealm = new JPARealm();
myShiroRealm.setCredentialsMatcher(hashedCredentialsMatcher());
return myShiroRealm;
}

// 指定使用md5加密算法,并进行两次加密
@Bean
public HashedCredentialsMatcher hashedCredentialsMatcher(){
HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher();
hashedCredentialsMatcher.setHashAlgorithmName("md5");
hashedCredentialsMatcher.setHashIterations(2);
return hashedCredentialsMatcher;
}


/**
* 开启shiro aop注解支持.
* 使用代理方式;所以需要开启代码支持;
* @param securityManager
* @return
*/
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager){
AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
return authorizationAttributeSourceAdvisor;
}
}

三.注册接口

Realm的验证需要对应注册里的加密方法即md5 * 2 + 盐

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 注册接口
@PostMapping("/foreregister")
public Object register(@RequestBody User user) {
String name = user.getName();
String password = user.getPassword();
// 对姓名中的特殊符号进行转义
name = HtmlUtils.htmlEscape(name);
user.setName(name);

// 判断用户名是否存在
boolean exist = userService.isExist(name);

if(exist){
String message ="用户名已经被使用,不能使用";
return Result.fail(message);
}

// 随机生成盐
String salt = new SecureRandomNumberGenerator().nextBytes().toString();
int times = 2;
// 采用md5加密
String algorithmName = "md5";

// md5 + 盐对用户密码进行加密得到加密密码
// times = 2,表明进行两次的md5加密
String encodedPassword = new SimpleHash(algorithmName, password, salt, times).toString();

// 将盐和加密密码存入数据库中
user.setSalt(salt);
user.setPassword(encodedPassword);

userService.add(user);

return Result.success();
}

四.登录接口

配置好Shiro后,登录验证时可以快速使用啦!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 登录接口
@PostMapping("/forelogin")
public Object login(@RequestBody User userParam, HttpSession session) {
String name = userParam.getName();
name = HtmlUtils.htmlEscape(name);

// shiro认证登录(你是谁?)
// subject指的是:"当前正在执行的用户的特定的安全视图"
// 可以把Subject看成是shiro的"User"概念
Subject subject = SecurityUtils.getSubject();
UsernamePasswordToken token = new UsernamePasswordToken(name, userParam.getPassword());
try {
subject.login(token);
User user = userService.getByName(name);
// 将user存储进seesion中,后续可以随时取出用于验证登录
session.setAttribute("user", user);
return Result.success();
} catch (AuthenticationException e) {
String message ="账号密码错误";
return Result.fail(message);
}

}

拦截器

拦截前端某些没有权限的访问,如没有登录权限的用户访问个人信息表,跳转到登录页

一.拦截器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
package com.how2java.tmall.interceptor;

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

import org.apache.commons.lang.StringUtils;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.subject.Subject;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

// 登录拦截器,用于拦截未登录情况下的访问
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o) throws Exception {
HttpSession session = httpServletRequest.getSession();
String contextPath=session.getServletContext().getContextPath();
// 需要验证登录的页面
String[] requireAuthPages = new String[]{
"buy",
"alipay",
"payed",
"cart",
"bought",
"confirmPay",
"orderConfirmed",
"forebuyone",
"forebuy",
"foreaddCart",
"forecart",
"forechangeOrderItem",
"foredeleteOrderItem",
"forecreateOrder",
"forepayed",
"forebought",
"foreconfirmPay",
"foreorderConfirmed",
"foredeleteOrder",
"forereview",
"foredoreview"

};

// 获取uri
String uri = httpServletRequest.getRequestURI();

//移除前缀/tmall_springboot
uri = StringUtils.remove(uri, contextPath+"/");
String page = uri;


// 判断链接名,是否以验证登录数组里的开头
if(begingWith(page, requireAuthPages)){
Subject subject = SecurityUtils.getSubject();
// 如果是则跳转到login页面
if(!subject.isAuthenticated()) {
httpServletResponse.sendRedirect("login");
return false;
}
}
return true;
}

private boolean begingWith(String page, String[] requiredAuthPages) {
boolean result = false;
for (String requiredAuthPage : requiredAuthPages) {
if(StringUtils.startsWith(page, requiredAuthPage)) {
result = true;
break;
}
}
return result;
}

@Override
public void postHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, ModelAndView modelAndView) throws Exception {

}

@Override
public void afterCompletion(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, Exception e) throws Exception {
}
}

通过实现SpringMCV的HandlerInterceptor来实现拦截器,其中包含3个方法:

public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handle)

该方法将在请求处理之前进行调用。SpringMVC中的Interceptor是链式的调用的,在一个应用中或者说是在一个请求中可以同时存在多个Interceptor 。

每个Interceptor的调用会依据它的声明顺序依次执行,而且最先执行的都是Interceptor中的preHandle方法,所以可以在这个方法中进行一些前置初始化操作或者是对当前请求的一个预处理,也可以在这个方法中进行一些判断来决定请求是否要继续进行下去。

该方法的返回值是布尔值Boolean类型的,当它返回为false 时,表示请求结束,后续的Interceptor和Controller都不会再执行;

当返回值为true时就会继续调用下一个Interceptor的preHandle方法,如果已经是最后一个Interceptor的时候就会是调用当前请求的Controller方法

postHandle(HttpServletRequest request, HttpServletResponse response, Object handle, ModelAndView modelAndView)
由preHandle方法的解释我们知道这个方法包括后面要说到的afterCompletion方法都只能是在当前所属的Interceptor的preHandle方法的返回值为true时才能被调用

postHandle方法,顾名思义就是在当前请求进行处理之后,也就是Controller方法调用之后执行,
但是它会在DispatcherServlet进行视图返回渲染之前被调用,所以我们可以在这个方法中对Controller处理之后的ModelAndView对象进行操作。

postHandle方法被调用的方向跟preHandle是相反的,也就是说先声明的Interceptor 的postHandle方法反而会后执行,这和Struts2里面的Interceptor 的执行过程有点类型。Struts2 里面的Interceptor 的执行过程也是链式的,只是在Struts2 里面需要手动调用ActionInvocation 的invoke 方法来触发对下一个Interceptor 或者是Action 的调用,然后每一个Interceptor 中在invoke 方法调用之前的内容都是按照声明顺序执行的,而invoke 方法之后的内容就是反向的

afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handle, Exception ex)
该方法也是需要当前对应的Interceptor 的preHandle 方法的返回值为true 时才会执行。

顾名思义,该方法将在整个请求结束之后,也就是在DispatcherServlet 渲染了对应的视图之后执行。
这个方法的主要作用是用于进行资源清理工作的。

二.拦截器配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 访问拦截器配置
package com.how2java.tmall.config;

import com.how2java.tmall.interceptor.LoginInterceptor;
import com.how2java.tmall.interceptor.OtherInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;

@Configuration
// 拦截器的配置
class WebMvcConfigurer extends WebMvcConfigurerAdapter{
@Bean
public LoginInterceptor getLoginIntercepter() {
return new LoginInterceptor();
}

}

技术亮点

循环依赖解决方案

一.Springboot注解补充

实体类中,@Transient注解的字段,是不与数据库映射的,可以额外添加到接口的字段即该字段不参与自动关联中的sql查询

这些字段可以用来存储:通过查询数据库得到的列表(不用另外建集合对象存储),需要经过计算的数据(也可以放在数据库),数据状态(也可以放在数据库)

订单表@Transient注解字段,在服务层进行赋值操作

1
2
3
4
5
6
7
8
9
10
11
12
// 订单项列表
@Transient
private List<OrderItem> orderItems;
// 订单总金额
@Transient
private float total;
// 订单物品总数量
@Transient
private int totalNumber;
// 订单状态
@Transient
private String statusDesc;

使用

@ManyToOne
@JoinColumn(name=”pid”)

可以标注关系,就可以使用JPA的findBy等方法如:findByProductOrderByIdDesc

1
2
3
4
5
6
7
8
9
// 一个产品有多个属性值
@ManyToOne
@JoinColumn(name="pid")
private Product product;

// 一个属性有多个属性值(属性 + 产品决定一条属性值)
@ManyToOne
@JoinColumn(name="ptid")
private Property property;

二.数据库设计:多对多关系

在实际应用中,多对多关系会分解为两个一对多的关系

属性值由产品和属性共同决定

1
2
3
4
5
6
7
8
9
// 一个产品,有多个属性值(不同属性,同一产品)
@ManyToOne
@JoinColumn(name="pid")
private Product product;

// 一个属性有多个属性值(不同产品,同一属性)
@ManyToOne
@JoinColumn(name="ptid")
private Property property;

订单项由订单,用户,产品共同决定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 一个产品可以有多个订单项(不同用户/不同订单,同一产品)
@ManyToOne
@JoinColumn(name="pid")
private Product product;

// 一个订单可以有多个订单项(不同产品/不同用户,同一订单)
@ManyToOne
@JoinColumn(name="oid")
private Order order;

// 一个用户可以有多个订单项(不同产品/不同订单,同一用户)
@ManyToOne
@JoinColumn(name="uid")
private User user;

在review类中的内对象如:prouct,user由于一对多的关联,在数据库中映射为pid,uid字段)

所以说JPA是一个ORM框架,对象和数据库无缝衔接

三.循环依赖的解决

在SpringBoot + JPA的架构中,容易出现循环依赖问题,一般会出现在一对多的场景下,总结来说是一对多实体中都要引用对方来维持OnetoMany的关系,所以极容易出现循环依赖:(

1.经典场景

订单项中引用订单,以构成多对一关系

可以使用订单项查找其属于的订单

1
2
3
4
// 一个订单可以有多个订单项(不同产品/不同用户,同一订单)
@ManyToOne
@JoinColumn(name="oid")
private Order order;

订单中引用订单项存储在集合中,用来存储从数据库查询来的结构(往往是因为要利用这些字段进行计算)

可以使用订单id查找订单项列表

1
2
3
4
5
6
7
8
9
// 订单项列表
@Transient
private List<OrderItem> orderItems;
// 订单总金额
@Transient
private float total;
// 订单物品总数量
@Transient
private int totalNumber;

这样的结构就是循环依赖,导致数据重复加载,因为orderItems要调用方法填充,所以会为空(一般情况下会栈溢出)最终造成的数据是:Order含有orderItems,orderItems含有Order,Order的orderItem列表为空,所以这里的Order重复了一次

2.方案一:@JsonBackReference注解

JsonBackReference注解用在一(一对多的一)的一方,可以阻止其被序列化,前提是对应的接口不需要调用到它,而只是需要用它来查询

如:一个产品有多张图片,我们不需要在图片列表接口使用到产品信息,而只是需要用产品id查询其图片

产品类

1
2
3
4
5
6
7
@Transient
// 产品首图
private ProductImage firstProductImage;
@Transient
private List<ProductImage> productSingleImages;
@Transient
private List<ProductImage> productDetailImages;

产品图片类

1
2
3
4
@ManyToOne
@JoinColumn(name="pid")
@JsonBackReference
private Product product;

缺点

  • 关系是双向的,使用了JsonBackReference,就无法使用根据图片找到其属于的产品的方法,只能单方向查询即根据产品查找到其图片列表
  • JsonBackReference标记的字段与Redis的整合会有冲突
3.方案二:及时清除法

在服务层定义清除方法,在控制层调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Orderitem中有Order字段,标注多对一关系
// Order中有Orderitem列表,用于存储订单项列表
// Order中有Orderitem列表,而Orderitem中又有Order字段,产生无穷的递归
// 所以这里需要设置Orderitem的Order设为空
public void removeOrderFromOrderItem(List <Order> orders) {
for (Order order : orders) {
removeOrderFromOrderItem(order);
}
}

public void removeOrderFromOrderItem(Order order) {
List<OrderItem> orderItems= order.getOrderItems();
for (OrderItem orderItem : orderItems) {
orderItem.setOrder(null);
}
}
1
2
3
4
// 填充Order的orderItem列表
orderItemService.fill(page.getContent());
// 清除orderItem中的Order字段
orderService.removeOrderFromOrderItem(page.getContent());
4.方案三:延迟加载

关于延迟加载:延迟加载介绍

使用FetchType.LAZY的方法,在不适用关系属性时,就不会自动获取,而一旦触发使用就会自动获取其属性 问题是JacksonHibernateLazyFetch并不默认支持,需要一些额外支持

使用jackson-datatype-hibernate5插件使Jackson支持hibernate的lazyFetch

pom.xml中添加依赖

1
2
3
4
5
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-hibernate5</artifactId>
<version>2.10.1</version>
</dependency>

增加配置类

1
2
3
4
5
6
7
8
9
10
11
@Configuration 
public class HibernateModuleConfig {
@Bean
public MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter() {
MappingJackson2HttpMessageConverter jsonConverter = new MappingJackson2HttpMessageConverter();
ObjectMapper objectMapper = jsonConverter.getObjectMapper();
objectMapper.registerModule(new Hibernate5Module());
return jsonConverter;
}
}

实体上增加主键Id识别信息,防止出现循环引用 所有关系都为Lazy,直观上不会出现循环引用,但是当你通过一对多查询而多对一存在引用时仍会出现循环引用

1
2
3
4
@JsonIdentityInfo(
generator = ObjectIdGenerators.PropertyGenerator.class,
property = "id")
public class CardModifyLog {}
5.其他方案
  • 创建DTO,类似的思路还有创建接口投影或者实体视图,见Spring Data JPA和命名实体图Spring data jpa 投影。 问题在于需要根据情况创建多个视图或者多个投影(DTO),由于各个实体间关系的复杂程度,不建议用此方式
  • 使用@Transient注解使所有的关系不被存储即不与数据库的字段对应,同时存在于实体中,每次使用时,自己手动查询set 也许是一种好办法,但是失去了关系的约束,可能得不偿失

缓存AOP拦截失效问题

一.问题出现原因

Spring只有在代理对象之间进行调用时,可以触发切面逻辑才可以使用事务,在同一个class中,方法B调用方法A,调用的是原对象的方法,而不通过代理对象就无法使用事务,如果方法B有事务只会使用方法B的事务,不会去管方法A的事务所以一个类中方法调用当前类的其他拥有事务的方法时这个被调用方法事务会失效

一个类中方法调用当前类的其他拥有事务的方法时这个被调用方法事务会失效。在默认的代理模式下,只有目标方法由外部调用,才能被 Spring 的事务拦截器拦截

同理使用spring cache模块的@Cacheable等注解 在同一个class中互相调用是无法走缓存的 因为这样无法访问到spring容器中的那个代理对象

因为Springboot的缓存机制是通过切面编程aop来实现,从fill方法中调用listByCategory即内部调用,aop是拦截不到的,自然不会走缓存

二.问题解决方案

可以使用 AspectJ 取代 Spring AOP 代理来解决,也可以使用工具类诱发aop

fill方法调用诱发工具类

1
2
3
4
5
6
7
8
9
10
// 填充分类中的产品集合
public void fill(Category category) {
// 通过SpringContextUtil调用listByCategory上的缓存方法
// 即 @Cacheable(key="'products-cid-'+ #p0.id")
// 这样在方法内部的查询也能够使用缓存
ProductService productService = SpringContextUtil.getBean(ProductService.class);
List<Product> products = productService.listByCategory(category);
productImageService.setFirstProdutImages(products);
category.setProducts(products);
}

SpringContextUtil工具类诱发aop

我们需要在代码中需要动态获取其它bean,我们可以通过实现ApplicationContextAware接口来实现

ApplicationContextAware可以对当前bean传入对应的Spring上下文

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package com.how2java.tmall.util;

import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;

/**
* 获取spring容器,以访问容器中定义的其他bean
*/
public class SpringContextUtil implements ApplicationContextAware {

// Spring应用上下文环境
private static ApplicationContext applicationContext;

/**
* 实现ApplicationContextAware接口的回调方法,设置上下文环境
*/
public void setApplicationContext(ApplicationContext applicationContext)
throws BeansException {
SpringContextUtil.applicationContext = applicationContext;
}

public static ApplicationContext getApplicationContext() {
return applicationContext;
}

/**
* 获取对象 这里重写了bean方法,起主要作用
*
* @param name
* @return Object
* @throws BeansException
*/
public static Object getBean(String beanId) throws BeansException {
return applicationContext.getBean(beanId);
}
}

分页动态数组开发

一.简单分页方法

1.Service层实现简单分页方法

这里使用JPA提供的Pageable类型对列表进行分页

Pageable是从0开始计算页数的,所以这里需要pageNum - 1

1
2
3
4
5
6
public Page<Category> getpage(int pageNum, int pageLimit){

Pageable pageable = new PageRequest(pageNum - 1 , pageLimit);
return categoryDAO.findAll(pageable);

}
2.Controller层调用分页方法

通过@RequestParam设置从前台get方法发来的page和size信息

1
2
3
4
5
6
7
8
@GetMapping("/catepage")
public Page<Category> pageList(@RequestParam(value = "page", defaultValue = "1") int page ,
@RequestParam(value = "size", defaultValue = "5") int size)
throws Exception{

return categoryService.getpage(page, size);

}
3.测试结果

访问请求链接:http://localhost:8080/shopping_system/catepage?page=2&size=5

image-20211215173655433

二.分页动态数组组类

1.分页功能进阶封装

JPA提供的分页类可以返回分割后的列表内容和分类信息如总共数据数(totalElements),总共分割的页面(totalPages)与当前访问的页面(number),但是这些数据不能方便提供一个方便的接口让前端实现部分分页节点展示分页节点遍历

image-20220228201059144

当前是第8页,前面要显示3个,后面要显示3个,总共7条分页点,Pageable默认就不提供了,即Pageable无法实现根据当前选择页调整接口返回的数据,而只能硬性分页

所以我们需要做了一个 PageNavigator, 首先对 Page 类进行了封装,然后在构造方法里提供了一个 navigatePages 参数作为区间分页节点数

在构造方法里,还调用了 calcNavigatepageNums, 就是用来计算这个数值,并返回到一个int 数组变量 navigatepageNums ,方便前端遍历展示,而这个数组的大小为navigatePages

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
public class PageNavigator<T> {

// 引用Page类
Page<T> pageFromJPA;


int totalPages;
int number;
long totalElements;
int size;

// 单页数据数
int numberOfElements;
// 分页数据
List<T> contents;

// 是否为首尾判断
boolean first;
boolean last;

// 是否有数据
boolean isHasContent;
// 是否有前驱
boolean isHasPrevious;
// 是否有后续
boolean isHasNext;

// 规定区间分页节点数
int navigatePages;
// 规定区间分页节点列表
int[] navigatepageNums;

// 无参构造函数
public PageNavigator(){

}

// 构造规定分页区间大小的分页函数
public PageNavigator(Page<T> pageFromJPA, int navigatePages){
// 引用Page里面的成员变量
this.pageFromJPA = pageFromJPA;
this.navigatePages = navigatePages;

totalPages = pageFromJPA.getTotalPages();
number = pageFromJPA.getNumber();
totalElements = pageFromJPA.getTotalElements();
size = pageFromJPA.getNumberOfElements();
contents = pageFromJPA.getContent();
isHasContent = pageFromJPA.hasContent();
first = pageFromJPA.isFirst();
last = pageFromJPA.isLast();
isHasNext = pageFromJPA.hasNext();
isHasPrevious = pageFromJPA.hasPrevious();

}

// 计算出分页节点列表
private void calcNavigatepageNums(){
int[] navigatepageNums;
// 总页数
int totalPages = getTotalPages();
// 当前页
int num = getNumber();

// 总页数小于区间分页节点数
if(totalPages <= navigatePages){
navigatepageNums = new int[totalPages];
for(int i = 0; i < totalPages; i++){
navigatepageNums[i] = i + 1;
}
}

else{
navigatepageNums = new int[ navigatePages];
// 计算区间列表首尾索引
int startNum = num - navigatePages / 2;
int endNum = 0;
if(navigatePages % 2 == 0){
endNum = num + navigatePages / 2 - 1;
}
else{
endNum = num + navigatePages / 2;
}

// 首navigatePages页
if(startNum < 0){
startNum = 1;
for(int i = 0; i < navigatePages; i++){
navigatepageNums[i] = startNum++;
}

}
// 尾navigatePages页
else if(startNum > navigatePages){
endNum = totalPages;
for(int i = navigatePages - 1; i >= 0; i--){
navigatepageNums[i] = endNum--;
}

}

// 中间navigatePages页
else{
for(int i = 0; i < navigatePages; i++){
navigatepageNums[i] = startNum++;
}
}


}

this.navigatepageNums = navigatepageNums;
}

// 成员变量对应的Getter与Setter
public int getTotalPages() {
return totalPages;
}

public void setTotalPages(int totalPages) {
this.totalPages = totalPages;
}

public int getNumber() {
return number;
}

public void setNumber(int number) {
this.number = number;
}

public long getTotalElements() {
return totalElements;
}

public void setTotalElements(long totalElements) {
this.totalElements = totalElements;
}

public int getSize() {
return size;
}

public void setSize(int size) {
this.size = size;
}

public int getNumberOfElements() {
return numberOfElements;
}

public void setNumberOfElements(int numberOfElements) {
this.numberOfElements = numberOfElements;
}

public List<T> getContents() {
return contents;
}

public void setContents(List<T> contents) {
this.contents = contents;
}

public boolean isFirst() {
return first;
}

public void setFirst(boolean first) {
this.first = first;
}

public boolean isLast() {
return last;
}

public void setLast(boolean last) {
this.last = last;
}

public boolean isHasContent() {
return isHasContent;
}

public void setHasContent(boolean hasContent) {
isHasContent = hasContent;
}

public boolean isHasPrevious() {
return isHasPrevious;
}

public void setHasPrevious(boolean hasPrevious) {
isHasPrevious = hasPrevious;
}

public boolean isHasNext() {
return isHasNext;
}

public void setHasNext(boolean hasNext) {
isHasNext = hasNext;
}

public int getNavigatePages() {
return navigatePages;
}

public void setNavigatePages(int navigatePages) {
this.navigatePages = navigatePages;
}

public int[] getNavigatepageNums() {
return navigatepageNums;
}

public void setNavigatepageNums(int[] navigatepageNums) {
this.navigatepageNums = navigatepageNums;
}

}

除了上面的写法外,如果不需要修改方法名,完全可以在继承Page类的基础上进行拓展

2.Service层实现进阶分页方法
1
2
3
4
5
6
7
8
9
public PageNavigator<Category> getpage(int page, int size, int navigatePages){
Sort sort = new Sort(Sort.Direction.DESC, "id");
Pageable pageable = new PageRequest(page, size, sort);
Page pageFrom = categoryDAO.findAll(pageable);

return new PageNavigator<>(pageFrom, navigatePages);


}
3.Controller层调用进阶分页方法
1
2
3
4
5
6
7
8
9
10
11
@GetMapping("/catepage")
public PageNavigator<Category> pageList(@RequestParam(value = "page", defaultValue = "1") int page,
@RequestParam(value = "size", defaultValue = "5") int size)
throws Exception{

// 接口初始页调整为从1开始
page = page < 1 ? 1 : page;
PageNavigator<Category> list = categoryService.getpage(page - 1, size, 5);

return list;
}
4.测试结果

访问地址:http://localhost:8080/tmall_springboot/categories?start=3&size=2

可以看到最终实现了提供一个存储5个页面索引的数组

image-20220228210601487

三.分页方法比较

JPA提供的分页类——Page可以满足各种分页需求,大部分时候用它就足够了,但是Pageable无法实现根据当前选择页调整接口返回的数据,而只能硬性分页即 页数(totalPage) = 数据数(totalElements) / 页大小(size)

表现在前端所有的分页都在一组分页栏中,如果想部分显示分页栏就需要前端去定制分页分组方法

image-20211215173655433

但是如果前端有需求让后端根据当前选择页,以当前页为中点返回n个页面为一组的索引供前端调用

这时候我们就要对Page类进行封装,构造一个分页组类,在构造方法中提供一个navigatePages参数(分页组大小),并提供calNavigateNums方法根据当前页计算出分到同一组的页面索引并存储到数组navigatepageNums中供前端遍历展示

表现在前端可以通过接口获得当前页同一组分页的索引方便遍历

image-20220228210601487

参考资料

Spring Data Elasticsearch基本使用

史上最全面的Elasticsearch使用指南

Spring data jpa中实体关系解决方案

Spring Data JPA 使用详解

Redis实用指南

延迟加载介绍
-lazy-eager-loading)