这里写自定义目录标题
0、ElasticSearch
1、Nginx配置域名问题
01、Nginx(反向代理) 配置
> 正向代理和反向代理
配置反向代理
我们打算访问 wulawula.com 网址转到我们本地的localhost:10000
1、使用 SwitchHosts 工具 配置域名 使用管理员方式打开
2、使用 自定义的域名访问 默认访问的是Nginx自定义的html页面
3、打开 nginx.conf 路径:/mydata/nginx/conf/nginx.conf
nginx.conf的介绍图:
发现把server块放到了 conf.d文件夹下
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
#tcp_nopush on;
keepalive_timeout 65;
#gzip on;
include /etc/nginx/conf.d/*.conf;
}
4、打开 default.conf 路径:/mydata/nginx/conf/conf.d/default.conf
修改以下箭头部分
server {
listen 80;
server_name wulawula.com; <------
#charset koi8-r;
#access_log /var/log/nginx/log/host.access.log main;
location / {
proxy_pass http://192.168.56.1:10000; <------ 注意分号结尾
}
#error_page 404 /404.html;
# redirect server error pages to the static page /50x.html
#
重启nginx docker restart nginx
5、访问 wulawula.com 发现访问的是我们原来的localhost:10000的页面
02、Nginx(负载均衡)+ 网关 配置
1、打开 nginx.conf 路径:/mydata/nginx/conf/nginx.conf
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
#tcp_nopush on;
keepalive_timeout 65;
#gzip on;
upstream gulimall{ <------ 上游服务器组 名称为 gulimall
server 192.168.56.1:88; <------ 配置服务器 (此处配置网关)
} <------
include /etc/nginx/conf.d/*.conf;
}
2、打开 default.conf 路径:/mydata/nginx/conf/conf.d/default.conf
server {
listen 80;
server_name wulawula.com; <------
#charset koi8-r;
#access_log /var/log/nginx/log/host.access.log main;
location / {
proxy_pass http://gulimall; <------找到上游服务器组,此处上有服务器组的名称为 gulimall
}
#error_page 404 /404.html;
# redirect server error pages to the static page /50x.html
3、增加gateway配置文件的网关规则
注意:此规则一定要放到其他所有规则的最后面
# nginx + gateway 配置域名访问 wulawula.com
- id: gulimall_host_route
# lb 代表的就是负载均衡
uri: lb://gulimall-product
predicates:
- Host=**.wulawula.com
4、然后发现报错404
原因:nginx代理给网关的时候,会丢失请求的host信息
需要添加 proxy_set_header Host $host;
server {
listen 80;
server_name wulawula.com;
#charset koi8-r;
#access_log /var/log/nginx/log/host.access.log main;
location / {
proxy_set_header Host $host; <------
proxy_pass http://gulimall;
}
重启nginx docker restart nginx
5、访问 wulawula.com 发现访问的是我们原来的localhost:10000的页面
原理图
03、Nginx动静分离
1、在html文件夹下 新建static文件夹 路径: /mydata/nginx/html/static
2、把静态资源放到了 static下 【static例包含index index下包含js、css、img】 把项目resources/static的静态资源删除
3、修改 default.conf 路径: /mydata/nginx/conf/conf.d/default.conf
4、重启 nginx docker restart nginx
5、访问 wulawula.com 静态资源正常显示
2、JMeter 压力测试
影响性能考虑点包括:
- 数据库、应用程序、中间件( tomact、Nginx)、网络和操作系统等方面首先考虑自己的应用属于CPu密集型还是I0密集型
01、基本测试
1、创建一个 **线程组**
2、设置线程组参数 所谓线程数就是并发用户数
3、在线程组下创建 HTTP请求
4、设置 HTTP参数(此处以百度为例)
5、在线程组下创建 监听器 此处以:查看结果树、汇总报告、聚合报告、汇总图 为例
6、点击执行 执行完后 自动停止
点击执行完后可以看到数据
- 查看结果树
- 可以发现每次 HTTP请求都完成
- 汇总报告 单位毫秒
- 平均1.241s 最小0.015s 最大5.265s 异常0%
- 聚合报告 单位毫秒
- 90%的在 1.465s完成 95%的在1.649s完成 99%的在2.514s完成
- 汇总图 单位毫秒
- 可以看到图表信息
注意
:自己写的项目。压测数据的时候 测试的数据和此处的内存大小也有关系
02、尝试用大于5000的TCP端口连接时发生错误
JMeter Address Already in use错误解决 `地址被占用`
原因
windows本身提供的端口访问机制的问题。
Windows提供给TCP/IP链接的端口为1024-5000,并且要四分钟来循环回收他们。就导致我们在短时间内跑大量的请求时将端口占满了。
解决
1.cmd
中,用regedit
命令打开注册表
2.在 HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters
下,
1.右击parameters
,添加一个新的 DWORD
,名字为 MaxUserPort
2 .然后双击MaxUserPort
,输入数值数据为65534
,基数选择十进制
(如果是分布式运行的话,控制机器和负载机器都需要这样操作哦)
也可以设置这个:
- 右击
parameters
,添加一个新的DWORD
,名字为TCPTimedWaitDelay
- 双击
TCPTimedWaitDelay
输入数值数据为30
(原来的回收时间是4分钟,此处的含义为回收时间改为30秒
)
3.修改配置完毕之后记得重启机器才会生效
3、性能监控
01、jconsole
1、cmd打开命令窗口 输入 jconsole 回车
2、选择我们想要查看的 微服务 ,此处以红色箭头处为例
02、jvisualvm
1、cmd打开命令窗口 输入 jvisualvm回车
2、选择我们想要查看的 微服务 ,此处以红色箭头处为例
- 运行:正在运行的
- 休眠:调用sleep方法的
- 等待:调用wait方法的
- 驻留:线程池里面的空闲线程
- 监视:阻塞的线程,正在等待锁的
扩展插件
1、点击 工具 -> 插件 -> 可用插件 -> 检查最新插件
2、安装完成后 退出 重新打开 jvisualvm
扩展插件
报错
如果点击 检查最新版本 时有以下错误
:
解决:
- 1、cmd查看自己的 jdk 的版本
java -version
举例:此次电脑上的jdk版本为 java version “1.8.0_231” - 2、打开
https://visualvm.github.io/pluginscenters.html
找到自己的 jdk对应的版本 点击链接进去
- 3、复制 此处URL
- 4、复制到此处 注意URL的后缀一定是
updates.xml.gz
03、jvisualvm + JMeter 结合测试
测试截图数据略…
因为我们的这个测试项目的构架是这样的:
请求 -> Nginx -> GateWay -> 服务集群的商品服务 --- 服务处理完成 -> GateWay -> Nginx -> 请求发送者
所以我们需要先测试中间件 Nginx GateWay
测试Nginx
Nginx的端口为80 本地Linux地址为 192.168.56.10 压力测试为1秒200个线程循环次数为无限
测试GateWay
Nginx的端口为88 地址为 localhost 压力测试为1秒200个线程循环次数为无限
开始压力测试 发现GateWay的CPU占用率高
测试简单服务
//测试简单服务 用于调优
@ResponseBody
@GetMapping("/hello")
public String hello(){
return "hello";
}
简单服务的端口为10000 地址为 localhost http://localhost:10000/hello
压力测试为1秒200个线程循环次数为无限
测试GateWay+简单服务
- 加入网关配置yml
- id: product_route
uri: lb://gulimall-product
predicates:
- Path=/api/product/**,/hello
filters:
- RewritePath=/api/(?<segment>.*),/$\{segment} #路径重写
GateWay+简单服务的端口为88 地址为localhost http://localhost:88/hello
压力测试为1秒200个线程循环次数为无限
全链路
GateWay + Nginx + 简单服务
全链路的端口为80 地址为wulawula.com http://wulawula.com/hello
压力测试为1秒200个线程循环次数为无限
页面一级菜单渲染
页面一级菜单渲染的端口为10000 地址为localhost 压力测试为1秒200个线程循环次数为无限
需要渲染前台。慢
三级分类数据获取
三级分类数据获取的端口为10000 http://localhost:10000/index/catalog.json
地址为localhost 压力测试为1秒200个线程循环次数为无限
需要反复查数据库。慢
首页全量数据获取
首页全量数据获取的端口为10000 http://localhost:10000
地址为localhost 压力测试为1秒200个线程循环次数为无限
高级 如下设置: 并行下载可以限制一下 默认为6
首页全量数据获取 (开缓存、优化数据库、关日志) 优化
- 开启缓存 调整日志级别
#开启缓存
spring
thymeleaf:
cache: true
#调整日志级别
logging:
level:
com.wulawula.gulimall: error
- 添加索引
4、优化
Nginx动静分离
- 1.03、有详细
首页全量数据获取 (开缓存、优化数据库、关日志) 优化
- 开启缓存 调整日志级别
#开启缓存
spring
thymeleaf:
cache: true
#调整日志级别
logging:
level:
com.wulawula.gulimall: error
- 添加索引
设置JVM
-Xmx1024m -Xms1024m -Xmn512m
- -Xms:
JVM 初始分配的内存
-> 数值大点,程序会启动的快一点,但是也可能会导致机器暂时间变慢。 - -Xmx:
JVM最大可用内存
-> 如果程序运行需要占用更多的内存,超出了这个设置值,会抛出OutOfMemory异常 - -Xss:
每个线程的堆栈大小
-> 根据应用的线程所需内存大小进行调整。 - -Xmn:
年轻代大小
-> 整个JVM内存大小=年轻代大小 + 年老代大小 + 持久代大小
3级分类优化业务
- 优化: 抽取方法 —> 将数据库的多次查询变为1次
//处理2级和3级
@Override
public Map<String,List<Catelog2Vo>> getCatalogJson() {
/*
* 【优化】
* 1、将数据库的多次查询变为1次
*/
List<CategoryEntity> selectList = baseMapper.selectList(null);
//1、查出所有一级分类
List<CategoryEntity> level1Categorys = getParent_cid(selectList,0L);
//2、封装1级的数据 // k:一级分类的id
Map<String, List<Catelog2Vo>> parent_cid = level1Categorys.stream().collect(Collectors.toMap(k -> k.getCatId().toString(), v -> {
//-------------------------------二级---------------------------------------
//1、通过每一个的一级分类,查到这个一级分类的二级分类 //v:当前遍历的一级分类
List<CategoryEntity> level2Catelog = getParent_cid(selectList,v.getCatId()); //二级分类的parent_cid 【eq】 一级分类的catid
//2、封装2级的数据
List<Catelog2Vo> catelog2Vos = null;
/*2级分类的集合*/
if (level2Catelog != null) {
catelog2Vos = level2Catelog.stream().map(l2 -> {
/***
* v.getCatId() 一级分类的id
* catalo3List在下面
* l2.getCatId() 二级分类的id
* l2.getName() 二级分类的名字
*/
Catelog2Vo catelog2Vo = new Catelog2Vo(v.getCatId().toString(), null, l2.getCatId().toString(), l2.getName());//封装
//-------------------------------三级---------------------------------------
//1、找当前二级分类的三级分类封装成VO
List<CategoryEntity> level3Catelog = getParent_cid(selectList,l2.getCatId()); //三级分类的parent_cid 【eq】 二级分类的catid
if (level3Catelog != null){
/*3及分类的集合*/
List<Catelog2Vo.Catelog3Vo> collect = level3Catelog.stream().map(l3 -> {
//2、封装成指定格式
/***
* item.getCatId() 2级节点的id
* l3.getCatId() 当前3级节点的id
* l3.getName() 当前3级节点的名字
*/
Catelog2Vo.Catelog3Vo catelog3Vo = new Catelog2Vo.Catelog3Vo(l2.getCatId().toString(),l3.getCatId().toString(),l3.getName()); //封装
return catelog3Vo;
}).collect(Collectors.toList());
catelog2Vo.setCatalog3List(collect);
}
return catelog2Vo;
}).collect(Collectors.toList());
}
return catelog2Vos;
}));
return parent_cid;
}
//抽取方法
/*
* List<CategoryEntity> selectList 从这个里面查询
* Long parent_cid 条件
*/
private List<CategoryEntity> getParent_cid(List<CategoryEntity> selectList,Long parent_cid) {
List<CategoryEntity> collect = selectList.stream().filter(item -> item.getParentCid().equals(parent_cid)).collect(Collectors.toList());
return collect;
// return baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("parent_cid", v.getCatId()));
}
缓存Redis
压测内容 | 压测线程数 | 吞吐量/s | 90%响应时间 | 99%响应时间 |
---|---|---|---|---|
Nginx | 200 | 1363.7 | 6 | 1949 |
GateWay | 200 | 5070.1 | 25 | 1340 |
简单服务 | 200 | 11596.6 | 37 | 70 |
页面一级菜单渲染 | 200 | 290.7 | 804 | 1443 |
三级分类数据获取 | 200 | 10.7(db) | 18574 | 18610 |
三级分类数据获取(优化业务) | 200 | 131.6 | 2220 | 3781 |
三级分类(Redis) | 200 | 408.5 | 634 | 1374 |
首页全量数据获取 | 200 | 11.6(静态) | 23477 | 24138 |
首页全量(开缓存、优化数据库、关日志) | 200 | 58.8 | 15433 | 14579 |
Nginx+GateWay | 200 | |||
GateWay+简单服务 | 200 | 1780.2 | 140 | 1975 |
全链路 | 200 | 567.1 | 1586 | 4956 |
中间件越多,性能损失越大,大多都损失在网络交互了
业务:DB(MySql优化)、模板的渲染速度(上线需要打开缓存)、静态资源 影响
5、缓存
为了系统性能的提升,我们一般都会将部分数据放入缓存中,加速访间。而db承担数据落盘工作。
哪些数据适合放入缓存:
- 即时性、数据一致性要求不高的
- 访问量大且更新频字不高的数据(读多,写少)
举例:电商类应用,商品分类,商品列表等适合缓存并加一个失效时间(根据数据更新频率来定),后台如果发布一个商品,买家需要5分钟才能看到新的商品一般还是可以接受的。
01、本地缓存
不推荐
/* 【优化2 将数据库的多次查询变为1次 且加入本地缓存 】 --------------------------------------------------------------------------------------------*/
//处理2级和3级
@Override
public Map<String,List<Catelog2Vo>> getCatalogJson() {
/* 自定义缓存 本地缓存 */
//1、如果缓存中有 就用缓存的
Map<String, List<Catelog2Vo>> catalogJson = (Map<String, List<Catelog2Vo>>) cache.get("catalogJson");
//2、如果没有就查数据库 且最后给缓存放一份
if (catalogJson == null) {
/*
* 【优化】
* 1、将数据库的多次查询变为1次
*/
List<CategoryEntity> selectList = baseMapper.selectList(null);
//1、查出所有一级分类
List<CategoryEntity> level1Categorys = getParent_cid(selectList, 0L);
//2、封装1级的数据 // k:一级分类的id
Map<String, List<Catelog2Vo>> parent_cid = level1Categorys.stream().collect(Collectors.toMap(k -> k.getCatId().toString(), v -> {
//-------------------------------二级---------------------------------------
//1、通过每一个的一级分类,查到这个一级分类的二级分类 //v:当前遍历的一级分类
List<CategoryEntity> level2Catelog = getParent_cid(selectList, v.getCatId()); //二级分类的parent_cid 【eq】 一级分类的catid
//2、封装2级的数据
List<Catelog2Vo> catelog2Vos = null;
/*2级分类的集合*/
if (level2Catelog != null) {
catelog2Vos = level2Catelog.stream().map(l2 -> {
/***
* v.getCatId() 一级分类的id
* catalo3List在下面
* l2.getCatId() 二级分类的id
* l2.getName() 二级分类的名字
*/
Catelog2Vo catelog2Vo = new Catelog2Vo(v.getCatId().toString(), null, l2.getCatId().toString(), l2.getName());//封装
//-------------------------------三级---------------------------------------
//1、找当前二级分类的三级分类封装成VO
List<CategoryEntity> level3Catelog = getParent_cid(selectList, l2.getCatId()); //三级分类的parent_cid 【eq】 二级分类的catid
if (level3Catelog != null) {
/*3及分类的集合*/
List<Catelog2Vo.Catelog3Vo> collect = level3Catelog.stream().map(l3 -> {
//2、封装成指定格式
/***
* item.getCatId() 2级节点的id
* l3.getCatId() 当前3级节点的id
* l3.getName() 当前3级节点的名字
*/
Catelog2Vo.Catelog3Vo catelog3Vo = new Catelog2Vo.Catelog3Vo(l2.getCatId().toString(), l3.getCatId().toString(), l3.getName()); //封装
return catelog3Vo;
}).collect(Collectors.toList());
catelog2Vo.setCatalog3List(collect);
}
return catelog2Vo;
}).collect(Collectors.toList());
}
return catelog2Vos;
}));
//3、给缓存放一份
cache.put("catalogJson",parent_cid);
return parent_cid;
}
}
//抽取方法
/*
* List<CategoryEntity> selectList 从这个里面查询
* Long parent_cid 条件
*/
private List<CategoryEntity> getParent_cid(List<CategoryEntity> selectList,Long parent_cid) {
List<CategoryEntity> collect = selectList.stream().filter(item -> item.getParentCid().equals(parent_cid)).collect(Collectors.toList());
return collect;
// return baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("parent_cid", v.getCatId()));
}
02、分布式缓存 Redis
推荐
- pom
<!--引入Redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
优化3:Redis 【注意:value转为了JSON字符串存取】
【注意:因为给Redis存入的是JSON字符串,取出JSON字符串的时候需要逆转为能用的对象类型 --> 序列化与反序列化】
Alibaba.
fastJson
:
- 对象 -> JSON:
String str = JSON.toJSONString(infoDo);
- JSON -> 对象:
InfoDo infoDo = JSON.parseObject(strInfoDo, InfoDo.class);
- 对象集合 -> JSON:
String users = JSON.toJSONString(users);
- JSON -> 对象集合:
List<User> userList = JSON.parseArray(userStr, User.class);
- 这样写会出现问题 注意看 18、03、
堆外内存溢出问题
/*
* 优化3:Redis 【注意:value转为了JSON字符串存取】
* 【注意:因为给Redis存入的是JSON字符串,取出JSON字符串的时候需要逆转为能用的对象类型 --> 序列化与反序列化】
*/
@Override
public Map<String,List<Catelog2Vo>> getCatalogJson() {
//1、加入缓存逻辑
//从Redis中获取缓存
String catalogJSON = redisTemplate.opsForValue().get("catalogJSON");
//判断是否为空
if (StringUtils.isEmpty(catalogJSON)){
//2、如果缓存中没有数据
//查询数据库
Map<String, List<Catelog2Vo>> catalogJsonDb = getCatalogJsonDB();
//3、查到的数据再放入缓存,
//因为用的是StringRedisTemplate 接受的value值是一个String 所以 catalogJsonDb 不能直接放入,需要转换一下
//将查到的对象转为 JSON 放入缓存中
/* 缓存中存的是 JSON 字符串,优点:JSON 有跨语言跨平台的特点 */
String s = JSON.toJSONString(catalogJsonDb);
redisTemplate.opsForValue().set("catalogJSON",s);
}
//4、转为我们指定的对象(查完数据库 或者 Redis有缓存)
// 要转换的JSON 要转换的类型 把自己想要的类型给 TypeReference 这个方法是受保护的,我们需要自己写一个静态内部类来处理
Map<String, List<Catelog2Vo>> result = JSON.parseObject(catalogJSON,new TypeReference<Map<String, List<Catelog2Vo>>>(){});
//5、返回转换后的结果
return result;
}
//处理2级和3级
public Map<String,List<Catelog2Vo>> getCatalogJsonDB() {
/*
* 【优化】
* 1、将数据库的多次查询变为1次
*/
List<CategoryEntity> selectList = baseMapper.selectList(null);
//1、查出所有一级分类
List<CategoryEntity> level1Categorys = getParent_cid(selectList, 0L);
//2、封装1级的数据 // k:一级分类的id
Map<String, List<Catelog2Vo>> parent_cid = level1Categorys.stream().collect(Collectors.toMap(k -> k.getCatId().toString(), v -> {
//-------------------------------二级---------------------------------------
//1、通过每一个的一级分类,查到这个一级分类的二级分类 //v:当前遍历的一级分类
List<CategoryEntity> level2Catelog = getParent_cid(selectList, v.getCatId()); //二级分类的parent_cid 【eq】 一级分类的catid
//2、封装2级的数据
List<Catelog2Vo> catelog2Vos = null;
/*2级分类的集合*/
if (level2Catelog != null) {
catelog2Vos = level2Catelog.stream().map(l2 -> {
/***
* v.getCatId() 一级分类的id
* catalo3List在下面
* l2.getCatId() 二级分类的id
* l2.getName() 二级分类的名字
*/
Catelog2Vo catelog2Vo = new Catelog2Vo(v.getCatId().toString(), null, l2.getCatId().toString(), l2.getName());//封装
//-------------------------------三级---------------------------------------
//1、找当前二级分类的三级分类封装成VO
List<CategoryEntity> level3Catelog = getParent_cid(selectList, l2.getCatId()); //三级分类的parent_cid 【eq】 二级分类的catid
if (level3Catelog != null) {
/*3及分类的集合*/
List<Catelog2Vo.Catelog3Vo> collect = level3Catelog.stream().map(l3 -> {
//2、封装成指定格式
/***
* item.getCatId() 2级节点的id
* l3.getCatId() 当前3级节点的id
* l3.getName() 当前3级节点的名字
*/
Catelog2Vo.Catelog3Vo catelog3Vo = new Catelog2Vo.Catelog3Vo(l2.getCatId().toString(), l3.getCatId().toString(), l3.getName()); //封装
return catelog3Vo;
}).collect(Collectors.toList());
catelog2Vo.setCatalog3List(collect);
}
return catelog2Vo;
}).collect(Collectors.toList());
}
return catelog2Vos;
}));
return parent_cid;
}
//抽取方法
/*
* List<CategoryEntity> selectList 从这个里面查询
* Long parent_cid 条件
*/
private List<CategoryEntity> getParent_cid(List<CategoryEntity> selectList,Long parent_cid) {
List<CategoryEntity> collect = selectList.stream().filter(item -> item.getParentCid().equals(parent_cid)).collect(Collectors.toList());
return collect;
// return baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("parent_cid", v.getCatId()));
}
03、堆外内存溢出问题 OutOfDirectMemoryError
原因:
- springboot 2.0 以后默认使用
lettuce
作为操作 redis 的客户端,它使用netty
进行通信 - lettuce 的 bug 导致 netty 堆外内存溢出
-Xmx300m
:如果没有指定堆外内存,netty 默认使用堆内存(Xmx)
作为 堆外内存
解决方案:
注意:不能使用 -Dio.netty.maxDirectMemory
只调大堆外内存
- 升级
lettuce
客户端 - 切换使用
jedis
[推荐]
- pom
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- 移除默认的 lettuce-core 核心,加入 jedis 核心 -->
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
- lettuce、jedis 都是操作redis的底层客户端 Spring 将其两个进行了再封装 --> RedisTemplate
04、缓存穿透、雪崩、击穿
- 缓存穿透 --> 空结果缓存
- 缓存雪崩 --> 设置过期时间(加随机值)
- 缓存击穿 --> 加锁
缓存穿透
指高并发情况下一直查询一个不存在的数据
。查询到null,但是并没有将null放入缓存,就进行多次查数据库,导致数据库瞬时压力增大,最终导致崩溃
缓存雪崩
指缓存设置的过期时间一致,在某一时刻缓存大面积失效
,高并发情况下的查询,进而转到了数据库进行查询,导致数据库瞬时压力过大,最终导致崩溃
缓存击穿
指失效的缓存为查询频率很高的某一个单点key --> 热点数据
,在某一时刻失效,高并发情况下的查询,进而转到了数据库进行查询,导致数据库瞬时压力过大,最终导致崩溃
6、本地锁和分布式锁
解决击穿
01、本地锁 synchronized 不推荐
使用this的方式
只要是同一把锁,就能锁住需要这个锁的所有线程
-
1、代码块 使用 synchronized
synchronized(this)
:SpringBoot所有的组件在容器中都是单例的 this就是当前实例对象 -
2、方法 使用 synchronized
直接给方法加synchronized也是可以的
public synchronized Map<String,List<Catelog2Vo>> getCatalogJsonDB() { }
@Override
public Map<String,List<Catelog2Vo>> getCatalogJson() {
//1、加入缓存逻辑
//从Redis中获取缓存
String catalogJSON = redisTemplate.opsForValue().get("catalogJSON");
//判断是否为空
if (StringUtils.isEmpty(catalogJSON)){
//2、如果缓存中没有数据
System.out.println("缓存不命中...查询数据库...");
//查询数据库
Map<String, List<Catelog2Vo>> catalogJsonDb = getCatalogJsonDB();
//3、查到的数据再放入缓存,
//因为用的是StringRedisTemplate 接受的value值是一个String 所以 catalogJsonDb 不能直接放入,需要转换一下
//将查到的对象转为 JSON 放入缓存中
/* 缓存中存的是 JSON 字符串,优点:JSON 有跨语言跨平台的特点 */
String s = JSON.toJSONString(catalogJsonDb);
redisTemplate.opsForValue().set("catalogJSON",s);
}
//4、转为我们指定的对象(查完数据库 或者 Redis有缓存)
System.out.println("缓存命中...");
// 要转换的JSON 要转换的类型 把自己想要的类型给 TypeReference 这个方法是受保护的,我们需要自己写一个静态内部类来处理
Map<String, List<Catelog2Vo>> result = JSON.parseObject(catalogJSON,new TypeReference<Map<String, List<Catelog2Vo>>>(){});
//5、返回转换后的结果
return result;
}
/* 【优化2 将数据库的多次查询变为1次 且加入本地缓存 】 --------------------------------------------------------------------------------------------*/
//处理2级和3级
public Map<String,List<Catelog2Vo>> getCatalogJsonDB() {
/***
* 缓存击穿 解决一:synchronized
* 只要是同一把锁,就能锁住需要这个锁的所有线程
*
* 1、场景一:使用this的方式
* 代码块 使用 synchronized
* synchronized(this) :SpringBoot所有的组件在容器中都是单例的 this就是当前实例对象
* 方法 使用 synchronized
* public synchronized Map<String,List<Catelog2Vo>> getCatalogJsonDB() { 直接给方法加synchronized也是可以的
*/
//TODO 本地锁:synchronized、JUC的Lock锁,在分布式的情况下,想要锁住所有,必须使用 “分布式锁”
synchronized (this){
/***
* 得到锁之后,我们应该再去缓存中确定一次,如果没有才需要继续查询
*/
String catalogJSON = redisTemplate.opsForValue().get("catalogJSON");
//如果缓存不为空 直接 转换对象return就可以了
if( ! StringUtils.isEmpty(catalogJSON)){
//JSON --> 对象
Map<String, List<Catelog2Vo>> result = JSON.parseObject(catalogJSON,new TypeReference<Map<String, List<Catelog2Vo>>>(){});
//返回转换后的结果
return result;
}
System.out.println("查询了数据库 进入锁------");
/*
* 【优化】
* 1、将数据库的多次查询变为1次
*/
List<CategoryEntity> selectList = baseMapper.selectList(null);
//1、查出所有一级分类
List<CategoryEntity> level1Categorys = getParent_cid(selectList, 0L);
//2、封装1级的数据 // k:一级分类的id
Map<String, List<Catelog2Vo>> parent_cid = level1Categorys.stream().collect(Collectors.toMap(k -> k.getCatId().toString(), v -> {
//-------------------------------二级---------------------------------------
//1、通过每一个的一级分类,查到这个一级分类的二级分类 //v:当前遍历的一级分类
List<CategoryEntity> level2Catelog = getParent_cid(selectList, v.getCatId()); //二级分类的parent_cid 【eq】 一级分类的catid
//2、封装2级的数据
List<Catelog2Vo> catelog2Vos = null;
/*2级分类的集合*/
if (level2Catelog != null) {
catelog2Vos = level2Catelog.stream().map(l2 -> {
/***
* v.getCatId() 一级分类的id
* catalo3List在下面
* l2.getCatId() 二级分类的id
* l2.getName() 二级分类的名字
*/
Catelog2Vo catelog2Vo = new Catelog2Vo(v.getCatId().toString(), null, l2.getCatId().toString(), l2.getName());//封装
//-------------------------------三级---------------------------------------
//1、找当前二级分类的三级分类封装成VO
List<CategoryEntity> level3Catelog = getParent_cid(selectList, l2.getCatId()); //三级分类的parent_cid 【eq】 二级分类的catid
if (level3Catelog != null) {
/*3及分类的集合*/
List<Catelog2Vo.Catelog3Vo> collect = level3Catelog.stream().map(l3 -> {
//2、封装成指定格式
/***
* item.getCatId() 2级节点的id
* l3.getCatId() 当前3级节点的id
* l3.getName() 当前3级节点的名字
*/
Catelog2Vo.Catelog3Vo catelog3Vo = new Catelog2Vo.Catelog3Vo(l2.getCatId().toString(), l3.getCatId().toString(), l3.getName()); //封装
return catelog3Vo;
}).collect(Collectors.toList());
catelog2Vo.setCatalog3List(collect);
}
return catelog2Vo;
}).collect(Collectors.toList());
}
return catelog2Vos;
}));
return parent_cid;
}
}
//抽取方法
/*
* List<CategoryEntity> selectList 从这个里面查询
* Long parent_cid 条件
*/
private List<CategoryEntity> getParent_cid(List<CategoryEntity> selectList,Long parent_cid) {
List<CategoryEntity> collect = selectList.stream().filter(item -> item.getParentCid().equals(parent_cid)).collect(Collectors.toList());
return collect;
}
注意:
本地锁:synchronized、JUC的Lock锁
synchronized(this) 为本地锁,只能锁住当前进程,在分布式的情况下,想要锁住所有,必须使用 分布式锁
this锁的是当前实例对象,加入集群环境下,需要每一台机器都去加synchronized(this),每一个this都是不同的锁,进而情况就是,1号机器有很多请求但是只放进了1个,2号机器也只放进了1个,导致有几台机器就放进多少个线程进来,去数据库查询相同的数据
- 且存在
锁的时序问题
- 查询了数据库 进入锁------ 控制台输出了两次以上 表示不止一个线程去查了数据库
02、本地锁 的时序问题
会造成高并发情况下,至少两个线程以上进入 synchronized (this) 来查询数据库,
原因:假如1号线程查询完数据库,释放锁,然后1号线程往Redis中放数据,这是一次网络交互,是有时间的,在这很短的时间内,紧接着2号线程进来了,发现缓存中没有,继续查询数据库,然后继续这种循环,可能在这一段时间内,还会有更多的线程进来,所以我们需要把查询到的数据放入缓存中的这一部分放在锁里面
@Override
public Map<String,List<Catelog2Vo>> getCatalogJson() {
//1、加入缓存逻辑
//从Redis中获取缓存
String catalogJSON = redisTemplate.opsForValue().get("catalogJSON");
//判断是否为空
if (StringUtils.isEmpty(catalogJSON)){
//2、如果缓存中没有数据
System.out.println("缓存不命中...查询数据库...");
//查询数据库
Map<String, List<Catelog2Vo>> catalogJsonDb = getCatalogJsonDB();
//===============================锁的时序问题=====================
/***
* 【注意】
*
* 注释这样写会造成高并发情况下,至少两个线程以上进入 synchronized (this) 来查询数据库,
* 原因:假如1号线程查询完数据库,释放锁,然后1号线程往Redis中放数据,这是一次网络交互,是有时间的,在这很短的时间内,紧接着2号线程进来了,发现缓存中没有,继续查询数据库,然后继续这种循环,可能在这一段时间内,还会有更多的线程进来,所以我们需要把查询到的数据放入缓存中的这一部分放在锁里面
*/
// //3、查到的数据再放入缓存,
// //因为用的是StringRedisTemplate 接受的value值是一个String 所以 catalogJsonDb 不能直接放入,需要转换一下
// //将查到的对象转为 JSON 放入缓存中
// /* 缓存中存的是 JSON 字符串,优点:JSON 有跨语言跨平台的特点 */
// String s = JSON.toJSONString(catalogJsonDb);
// /***
// * 设置过期时间 解决缓存雪崩
// */
// redisTemplate.opsForValue().set("catalogJSON",s,1, TimeUnit.DAYS);
return catalogJsonDb;
}
//4、转为我们指定的对象(查完数据库 或者 Redis有缓存)
System.out.println("缓存命中...");
Map<String, List<Catelog2Vo>> result = JSON.parseObject(catalogJSON,new TypeReference<Map<String, List<Catelog2Vo>>>(){});
//5、返回转换后的结果
return result;
}
/* 【优化2 将数据库的多次查询变为1次 且加入本地缓存 】 --------------------------------------------------------------------------------------------*/
//处理2级和3级
public Map<String,List<Catelog2Vo>> getCatalogJsonDB() {
==
/***
* 缓存击穿 解决一:synchronized
* 只要是同一把锁,就能锁住需要这个锁的所有线程
*
* 1、场景一:使用this的方式
* 代码块 使用 synchronized
* synchronized(this) :SpringBoot所有的组件在容器中都是单例的 this就是当前实例对象
* 方法 使用 synchronized
* public synchronized Map<String,List<Catelog2Vo>> getCatalogJsonDB() { 直接给方法加synchronized也是可以的
*/
//TODO 本地锁:synchronized、JUC的Lock锁,在分布式的情况下,想要锁住所有,必须使用 “分布式锁”
synchronized (this){
/***
* 得到锁之后,我们应该再去缓存中确定一次,如果没有才需要继续查询
*/
String catalogJSON = redisTemplate.opsForValue().get("catalogJSON");
//如果缓存不为空 直接 转换对象return就可以了
if( ! StringUtils.isEmpty(catalogJSON)){
//JSON --> 对象
Map<String, List<Catelog2Vo>> result = JSON.parseObject(catalogJSON,new TypeReference<Map<String, List<Catelog2Vo>>>(){});
//返回转换后的结果
return result;
}
System.out.println("查询了数据库 进入锁------");
/*
* 【优化】
* 1、将数据库的多次查询变为1次
*/
List<CategoryEntity> selectList = baseMapper.selectList(null);
//1、查出所有一级分类
List<CategoryEntity> level1Categorys = getParent_cid(selectList, 0L);
//2、封装1级的数据 // k:一级分类的id
Map<String, List<Catelog2Vo>> parent_cid = level1Categorys.stream().collect(Collectors.toMap(k -> k.getCatId().toString(), v -> {
//-------------------------------二级---------------------------------------
//1、通过每一个的一级分类,查到这个一级分类的二级分类 //v:当前遍历的一级分类
List<CategoryEntity> level2Catelog = getParent_cid(selectList, v.getCatId()); //二级分类的parent_cid 【eq】 一级分类的catid
//2、封装2级的数据
List<Catelog2Vo> catelog2Vos = null;
/*2级分类的集合*/
if (level2Catelog != null) {
catelog2Vos = level2Catelog.stream().map(l2 -> {
/***
* v.getCatId() 一级分类的id
* catalo3List在下面
* l2.getCatId() 二级分类的id
* l2.getName() 二级分类的名字
*/
Catelog2Vo catelog2Vo = new Catelog2Vo(v.getCatId().toString(), null, l2.getCatId().toString(), l2.getName());//封装
//-------------------------------三级---------------------------------------
//1、找当前二级分类的三级分类封装成VO
List<CategoryEntity> level3Catelog = getParent_cid(selectList, l2.getCatId()); //三级分类的parent_cid 【eq】 二级分类的catid
if (level3Catelog != null) {
/*3及分类的集合*/
List<Catelog2Vo.Catelog3Vo> collect = level3Catelog.stream().map(l3 -> {
//2、封装成指定格式
/***
* item.getCatId() 2级节点的id
* l3.getCatId() 当前3级节点的id
* l3.getName() 当前3级节点的名字
*/
Catelog2Vo.Catelog3Vo catelog3Vo = new Catelog2Vo.Catelog3Vo(l2.getCatId().toString(), l3.getCatId().toString(), l3.getName()); //封装
return catelog3Vo;
}).collect(Collectors.toList());
catelog2Vo.setCatalog3List(collect);
}
return catelog2Vo;
}).collect(Collectors.toList());
}
return catelog2Vos;
}));
//===============================锁的时序问题=====================
//【注意这一段是从上面优化到下面的】 //在此处将数据库查询到的数据放入缓存,解决锁的时序问题
//3、查到的数据再放入缓存,
//因为用的是StringRedisTemplate 接受的value值是一个String 所以 catalogJsonDb 不能直接放入,需要转换一下
//将查到的对象转为 JSON 放入缓存中
/* 缓存中存的是 JSON 字符串,优点:JSON 有跨语言跨平台的特点 */
String s = JSON.toJSONString(parent_cid);
/***
* 设置过期时间 解决缓存雪崩
*/
redisTemplate.opsForValue().set("catalogJSON",s,1, TimeUnit.DAYS);
return parent_cid;
}
}
//抽取方法
/*
* List<CategoryEntity> selectList 从这个里面查询
* Long parent_cid 条件
*/
private List<CategoryEntity> getParent_cid(List<CategoryEntity> selectList,Long parent_cid) {
List<CategoryEntity> collect = selectList.stream().filter(item -> item.getParentCid().equals(parent_cid)).collect(Collectors.toList());
return collect;
}
- 只输出了一次:查询了数据库 进入锁------ 表示一直是只有一个线程进入了锁,锁的时序问题得以解决
03、本地锁 压力测试
1、启动 4个微服务
2、设置参数 清空Redis的数据
3、测试 每个微服务只打印了一次 查询了数据库 进入锁------ 一个微服务一把锁
04、分布式锁
分布式锁演化1
docker exec -it redis redis-cli //连接客户端
docker lock wula NX //使用NX参数 占位
分布式锁演化2
EX:设置过期时间 NX:占位 设置过期时间和加锁必须是同步的、原子的
127.0.0.1:6379> set lock wula EX 300 //NX 设置过期时间为300s,且用NX占位
OK
127.0.0.1:6379> ttl lock //查询过期时间
(integer) 295
127.0.0.1:6379> ttl lock
(integer) 294
127.0.0.1:6379> ttl lock
(integer) 291
127.0.0.1:6379> ttl lock
(integer) 288
127.0.0.1:6379> ttl lock
(integer) 281
分布式锁演化3
分布式锁演化4
分布式锁演化5
使用Lua
脚本
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; //Lua脚本
@Override
public Map<String, List<Catelog2Vo>> getCatalogJson() {
//1、加入缓存逻辑
//从Redis中获取缓存
String catalogJSON = redisTemplate.opsForValue().get("catalogJSON");
//判断是否为空
if (StringUtils.isEmpty(catalogJSON)) {
//2、如果缓存中没有数据
System.out.println("缓存不命中...查询数据库...");
//查询数据库
Map<String, List<Catelog2Vo>> catalogJsonDb = getCatalogJsonDBWithRedisLock();
return catalogJsonDb;
}
//4、转为我们指定的对象(查完数据库 或者 Redis有缓存)
System.out.println("缓存命中...");
// 要转换的JSON 要转换的类型 把自己想要的类型给 TypeReference 这个方法是受保护的,我们需要自己写一个静态内部类来处理
Map<String, List<Catelog2Vo>> result = JSON.parseObject(catalogJSON, new TypeReference<Map<String, List<Catelog2Vo>>>() {
});
//5、返回转换后的结果
return result;
}
/***
* 分布式锁
*/
public Map<String, List<Catelog2Vo>> getCatalogJsonDBWithRedisLock() {
//4、设置value为随机值 根据value来删锁 每个线程都有自己唯一的uuid
String uuid = UUID.randomUUID().toString();
// //1、占分布式锁。去Redis占坑
// Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", String.valueOf(wula)); //setIfAbsent 相当于 NX参数
// //3、设置过期时间和加锁必须是同步的、原子的
// Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "wula", 300, TimeUnit.SECONDS); //setIfAbsent 相当于 EX参数 和 NX参数
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 300, TimeUnit.SECONDS); //setIfAbsent 相当于 EX参数 和 NX参数
if (lock){
System.out.println("获取分布式锁成功");
//加锁成功...执行业务
2、设置过期时间 --> 防止死锁(不是原子的)
// redisTemplate.expire("lock",30,TimeUnit.SECONDS);
Map<String, List<Catelog2Vo>> dataFromDb;
try{
dataFromDb = getDataFromDb();
}finally {
//获取值对比,对比成功才删除 --> 原子操作 Lua脚本
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; //Lua脚本
//删除锁 --> 原子删锁
Long lua = redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class), Arrays.asList("lock"), uuid);
}
// redisTemplate.delete("lock"); // 业务成功之后,删除锁
// //获取值对比,对比成功才删除 这两步也应该是一个原子操作
// String lockValue = redisTemplate.opsForValue().get("lock");
// if (uuid.equals(lockValue)){
// //如果 Redis例存的值和设置的值一样,才进行删除
// redisTemplate.delete("lock"); // 业务成功之后,删除锁
// }
return dataFromDb;
}else {
//加锁失败...重试
//休眠100ms重试
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("获取分布式锁失败............");
return getCatalogJsonDBWithRedisLock(); /* 自旋 */
}
}
//抽取方法二
private Map<String, List<Catelog2Vo>> getDataFromDb() {
//再次判断缓存是否有数据
String catalogJSON = redisTemplate.opsForValue().get("catalogJSON");
//如果缓存不为空 直接 转换对象return就可以了
if (!StringUtils.isEmpty(catalogJSON)) {
//JSON --> 对象
Map<String, List<Catelog2Vo>> result = JSON.parseObject(catalogJSON, new TypeReference<Map<String, List<Catelog2Vo>>>() {
});
//返回转换后的结果
return result;
}
System.out.println("查询了数据库 进入锁------");
/*
* 【优化】
* 1、将数据库的多次查询变为1次
*/
List<CategoryEntity> selectList = baseMapper.selectList(null);
//1、查出所有一级分类
List<CategoryEntity> level1Categorys = getParent_cid(selectList, 0L);
//2、封装1级的数据 // k:一级分类的id
Map<String, List<Catelog2Vo>> parent_cid = level1Categorys.stream().collect(Collectors.toMap(k -> k.getCatId().toString(), v -> {
//-------------------------------二级---------------------------------------
//1、通过每一个的一级分类,查到这个一级分类的二级分类 //v:当前遍历的一级分类
List<CategoryEntity> level2Catelog = getParent_cid(selectList, v.getCatId()); //二级分类的parent_cid 【eq】 一级分类的catid
//2、封装2级的数据
List<Catelog2Vo> catelog2Vos = null;
/*2级分类的集合*/
if (level2Catelog != null) {
catelog2Vos = level2Catelog.stream().map(l2 -> {
/***
* v.getCatId() 一级分类的id
* catalo3List在下面
* l2.getCatId() 二级分类的id
* l2.getName() 二级分类的名字
*/
Catelog2Vo catelog2Vo = new Catelog2Vo(v.getCatId().toString(), null, l2.getCatId().toString(), l2.getName());//封装
//-------------------------------三级---------------------------------------
//1、找当前二级分类的三级分类封装成VO
List<CategoryEntity> level3Catelog = getParent_cid(selectList, l2.getCatId()); //三级分类的parent_cid 【eq】 二级分类的catid
if (level3Catelog != null) {
/*3及分类的集合*/
List<Catelog2Vo.Catelog3Vo> collect = level3Catelog.stream().map(l3 -> {
//2、封装成指定格式
/***
* item.getCatId() 2级节点的id
* l3.getCatId() 当前3级节点的id
* l3.getName() 当前3级节点的名字
*/
Catelog2Vo.Catelog3Vo catelog3Vo = new Catelog2Vo.Catelog3Vo(l2.getCatId().toString(), l3.getCatId().toString(), l3.getName()); //封装
return catelog3Vo;
}).collect(Collectors.toList());
catelog2Vo.setCatalog3List(collect);
}
return catelog2Vo;
}).collect(Collectors.toList());
}
return catelog2Vos;
}));
//3、查到的数据再放入缓存,
//因为用的是StringRedisTemplate 接受的value值是一个String 所以 catalogJsonDb 不能直接放入,需要转换一下
//将查到的对象转为 JSON 放入缓存中
/* 缓存中存的是 JSON 字符串,优点:JSON 有跨语言跨平台的特点 */
String s = JSON.toJSONString(parent_cid);
/***
* 设置过期时间 解决缓存雪崩
*/
redisTemplate.opsForValue().set("catalogJSON", s, 1, TimeUnit.DAYS);
return parent_cid;
}
//抽取方法
/*
* List<CategoryEntity> selectList 从这个里面查询
* Long parent_cid 条件
*/
private List<CategoryEntity> getParent_cid(List<CategoryEntity> selectList, Long parent_cid) {
List<CategoryEntity> collect = selectList.stream().filter(item -> item.getParentCid().equals(parent_cid)).collect(Collectors.toList());
return collect;
}
public Map<String, List<Catelog2Vo>> getCatalogJsonDBWithRedisLock() {
//设置value为随机值 根据value来删锁 每个线程都有自己唯一的uuid
String uuid = UUID.randomUUID().toString();
//占分布式锁。去Redis占坑 设置过期时间和加锁必须是同步的、原子的
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 300, TimeUnit.SECONDS); //setIfAbsent 相当于 EX参数 和 NX参数
if (lock){
System.out.println("获取分布式锁成功");
//加锁成功...执行业务
Map<String, List<Catelog2Vo>> dataFromDb;
try{
dataFromDb = getDataFromDb();
}finally {
//获取值对比,对比成功才删除 --> 原子操作 Lua脚本
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; //Lua脚本
//删除锁 --> 原子删锁
Long lua = redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class), Arrays.asList("lock"), uuid);
}
return dataFromDb;
}else {
//加锁失败...重试
//休眠100ms重试
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("获取分布式锁失败............");
return getCatalogJsonDBWithRedisLock(); /* 重试:自旋 */
}
}
7、Redisson 分布式锁
8、SpringCache
简介
01、整合SpringCache
- pom
<!-- 整合SpringCache -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
- 配置redis使用
<!--引入Redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- 移除默认的 lettuce-core 核心,加入 jedis 核心 -->
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
- 配置
自动配置了哪些
CacheAutoConfiguration,会导入RedisCacheConfiguration 自动配置了缓存管理器RedisCacheManager
配置使用redis作为缓存 (在Redis配置好的前提下)
spring.cache.type=redis
常用注解:
-
`@Cacheable` :触发将数据保存到缓存的操作
-
`@CacheEvict` :触发将数据从缓存中删除的操作 --> 失效模式
-
`@CachePut` :在不影响方法执行的情况下更新缓存 --> 双写模式
-
`@Caching` :组合以上多个操作
-
`@CacheConfig` :在类级别共享缓存的相同设置。
测试使用缓存:
- 开启缓存功能,在
主启动类
上,标注@EnableCaching
- 只需要使用注解,就可以完成缓存操作
- 在
业务方法
的头部标上@Cacheable
,表示当前方法的结果需要缓存,如果缓存中有,该方法不会调用。如果缓存中没有,就会调用该方法,最终将方法的结果放入缓存 指定缓存分区
。每一次需要缓存的数据,我们都需要指定要放到哪个名字的缓存【缓存的分区】通常按照业务类型进行划
- 示例:将前台:查询一级分类 放入缓存中 分区为 category
@Cacheable({"category"}) //表示当前缓存最终放到 category 分区里了
@Override
public List<CategoryEntity> getLevel1Categorys() {
System.out.println("getLevel1Categorys......");
long l = System.currentTimeMillis();
List<CategoryEntity> categoryEntities = baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("parent_cid", 0));
return categoryEntities;
}
细节
上面我们将一级分类数据的信息缓存到Redis中了,缓存到Redis中数据具有如下的特点:
-
如果缓存中有,方法不会被调用;
-
key默认自动生成;形式为
缓存的名字::SimpleKey [](自动生成的key值)
; -
缓存的value值,默认使用jdk序列化机制,将序列化后的数据缓存到redis;
-
默认
TTL
时间为-1,表示永不过期
然而这些并不能够满足我们的需要,我们希望:
- 能够指定生成缓存所使用的key;
- 指定缓存的数据的存活时间;
- 将缓存的数据保存为
json
形式;
改进
针对第一点:可以使用@Cacheable
注解的时候,设置key
属性,接收一个SpEL
注意:加上单引号,让其成为字符串
@Cacheable(value = {"category"},key = "'level1Categorys'")
针对第二点:在配置文件中指定TTL
#设置缓存(TTL)存活时间 单位为毫秒
spring.cache.redis.time-to-live= 3600000
清空Redis,进行测试: http://localhost:10000
SpEL
的详细语法,在文档中给予了详细的说明:https://docs.spring.io/spring-framework/docs/5.3.0-SNAPSHOT/spring-framework-reference/integration.html#cache-spel-context
示例: 将方法的名字设置为key值
@Cacheable(value = {"category"},key = "#root.method.name")
02、整合–自定义缓存配置
针对第三点:将缓存的数据保存为json
形式
这涉及到修改缓存管理器的设置,CacheAutoConfiguration
导入了RedisCacheConfiguration
,而RedisCacheConfiguration
中自动配置了缓存管理器RedisCacheManager
,而RedisCacheManager
要初始化所有的缓存,每个缓存决定使用什么样的配置,如果RedisCacheConfiguration
有,就用已有的,没有就用默认配置。
想要修改缓存的配置,只需要给容器中放一个redisCacheConfiguration
即可,这样就会应用到当前RedisCacheManager
管理的所有缓存分区中。
- Config
/***
* Cache 自定义缓存配置
*/
@EnableConfigurationProperties(CacheProperties.class) //开启配置文件的绑定功能
@Configuration
@EnableCaching
public class MyCacheConfig {
// 注入 CacheProperties 方式1
// @Autowired
// CacheProperties cacheProperties;
/***
* 原来配置文件重的东西都没用上:
* 1、原来配置文件中的绑定的配置类是这样子的:
* @ConfigurationProperties(prefix="spring.cache")
* public class CacheProperties
* 2、让他生效
* @EnableConfigurationProperties(CacheProperties.class)
* @return
*/
@Bean
RedisCacheConfiguration rcc(CacheProperties cacheProperties){ //注入 CacheProperties 方式2
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
// config = config.entryTtl();
//key的序列化用什么
config = config.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()));
//value的序列化用什么
config = config.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
//将配置文件中的所有配置都生效
CacheProperties.Redis redisProperties = cacheProperties.getRedis();
if (redisProperties.getTimeToLive() != null) {
config = config.entryTtl(redisProperties.getTimeToLive());
}
if (redisProperties.getKeyPrefix() != null) {
config = config.prefixKeysWith(redisProperties.getKeyPrefix());
}
if (!redisProperties.isCacheNullValues()) {
config = config.disableCachingNullValues();
}
if (!redisProperties.isUseKeyPrefix()) {
config = config.disableKeyPrefix();
}
return config;
}
}
- 其他properties配置
#配置使用Redis作为缓存
spring.cache.type=redis
#设置缓存(TTL)存活时间 单位为毫秒
spring.cache.redis.time-to-live= 3600000
#设置Key的前缀,如果指定了前缀,则使用我们定义的前缀,否则使用缓存的名字作为前缀 --> 不建议使用
spring.cache.redis.key-prefix=CACHE_
#是否使用前缀功能 --> 不建议使用
spring.cache.redis.use-key-prefix=false
#是否缓存空值 --> 防止缓存穿透问题
spring.cache.redis.cache-null-values=true
@CacheEvict
:将数据从缓存中删除 --> 用于失效模式
修改菜单之后,删除缓存中的仅有一条
原有缓存缓存,重新查询才会重新生成最新的数据
@CacheEvict(value = "category",key = "'getLevel1Categorys'") //value:指定删除的片区 key:指定删除的缓存名
@Transactional //事务
@Override
public void updateCascade(CategoryEntity category) {
this.updateById(category); //更新自己
//更新关联表 //三级分类的id 更新的名字
categoryBrandRelationService.updateCategory(category.getCatId(), category.getName());
}
1级2级3级分类数据 联合添加到缓存
另外在修改了一级缓存时,对应的二级缓存也需要更新,需要修改原来二级分类的执行逻辑。
将getCatelogJson
恢复成为原来的逻辑,但是设置@Cacheable,非侵入的方式将查询结果缓存到redis中:
- 修改1级分类
@Override
//使用SpEL来指定value,value为该方法的名字
@Cacheable(value = {"category"},key = "#root.method.name")
public List<CategoryEntity> getLevel1Categorys() {
System.out.println("getLevel1Categorys......");
long l = System.currentTimeMillis();
List<CategoryEntity> categoryEntities = baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("parent_cid", 0));
return categoryEntities;
}
- 根据1级分类修改对应的2级、3级分类
@Cacheable(value = "category",key = "#root.methodName")
@Override
public Map<String, List<Catelog2Vo>> getCatalogJson() {
/*
* 【优化】
* 1、将数据库的多次查询变为1次
*/
List<CategoryEntity> selectList = baseMapper.selectList(null);
//1、查出所有一级分类
List<CategoryEntity> level1Categorys = getParent_cid(selectList, 0L);
//2、封装1级的数据 // k:一级分类的id
Map<String, List<Catelog2Vo>> parent_cid = level1Categorys.stream().collect(Collectors.toMap(k -> k.getCatId().toString(), v -> {
//-------------------------------二级---------------------------------------
//1、通过每一个的一级分类,查到这个一级分类的二级分类 //v:当前遍历的一级分类
List<CategoryEntity> level2Catelog = getParent_cid(selectList, v.getCatId()); //二级分类的parent_cid 【eq】 一级分类的catid
//2、封装2级的数据
List<Catelog2Vo> catelog2Vos = null;
/*2级分类的集合*/
if (level2Catelog != null) {
catelog2Vos = level2Catelog.stream().map(l2 -> {
/***
* v.getCatId() 一级分类的id
* catalo3List在下面
* l2.getCatId() 二级分类的id
* l2.getName() 二级分类的名字
*/
Catelog2Vo catelog2Vo = new Catelog2Vo(v.getCatId().toString(), null, l2.getCatId().toString(), l2.getName());//封装
//-------------------------------三级---------------------------------------
//1、找当前二级分类的三级分类封装成VO
List<CategoryEntity> level3Catelog = getParent_cid(selectList, l2.getCatId()); //三级分类的parent_cid 【eq】 二级分类的catid
if (level3Catelog != null) {
/*3及分类的集合*/
List<Catelog2Vo.Catelog3Vo> collect = level3Catelog.stream().map(l3 -> {
//2、封装成指定格式
/***
* item.getCatId() 2级节点的id
* l3.getCatId() 当前3级节点的id
* l3.getName() 当前3级节点的名字
*/
Catelog2Vo.Catelog3Vo catelog3Vo = new Catelog2Vo.Catelog3Vo(l2.getCatId().toString(), l3.getCatId().toString(), l3.getName()); //封装
return catelog3Vo;
}).collect(Collectors.toList());
catelog2Vo.setCatalog3List(collect);
}
return catelog2Vo;
}).collect(Collectors.toList());
}
return catelog2Vos;
}));
return parent_cid;
}
访问:wulawula.com 且重复访问时,没有重新查数据库 发现Redis里有两个缓存
@Caching
:组合多个Cache操作
在 1级2级3级分类数据 联合添加到缓存
的基础上操作
修改菜单之后,删除缓存中的两条以上
原有缓存数据,重新查询才会重新生成最新的数据
@Caching(evict = { //组合多个cache操作
@CacheEvict(value = "category",key = "'getLevel1Categorys'"),
@CacheEvict(value = "category",key = "'getCatalogJson'")
})
@Transactional //事务
@Override
public void updateCascade(CategoryEntity category) {
this.updateById(category); //更新自己
//更新关联表 //三级分类的id 更新的名字
categoryBrandRelationService.updateCategory(category.getCatId(), category.getName());
}
只使用
@CacheEvict
也可以删除多个缓存数据
@CacheEvict(value = "category",allEntries = true)
它表示要删除category
分区下的所有数据。
注意:
可以看到存储同一类型的数据,都可以指定未同一个分区,可以批量删除这个分区下的数据。以后不建议使用分区前缀
,而是使用默认的分区前缀
。
#设置Key的前缀,如果指定了前缀,则使用我们定义的前缀,否则使用缓存的名字作为前缀 --> 不建议使用
#spring.cache.redis.key-prefix=CACHE_
#是否使用前缀功能 --> 不建议使用
#spring.cache.redis.use-key-prefix=false
03、 总结
1)读模式
-
缓存穿透:查询一个null值。解决,缓存空数据;
cache-null-value=true
; -
缓存击穿:大量并发进来同时查询一个正好过期的数据。解决方法,是进行加锁,默认是没有加锁的,查询时设置
Cacheable的sync=true
即可解决缓存击穿。 -
缓存雪崩:大量的key同时过期。解决方法:加上随机时间;加上过期时间。
spring.cache.redis.time-to-live=3600000
2)写模式(为了保证 --> 缓存与数据一致)
- 读写加锁;
- 引入canal,感知到mysql的更新去更新数据库;
- 读多写少,直接去数据库查询就行;
总结:
-
常规数据(读多写少,即时性,一致性要求不高的数据):完全可以使用spring-cache;写模式,只要缓存的数据有过期时间就足够了;
-
特殊数据:特殊设计;
9、前台检索
检索条件分析
- 全文检索:skuTitle->keyword
- 排序:saleCount(销量)、hotScore(热度分)、skuPrice(价格)
- 过滤:hasStock(是否有货 0/1)、skuPrice(价格区间)、brandId、catalog3Id、attrs
- 聚合:attrs
完整查询参数 keyword=小米&sort=saleCount_desc/asc&hasStock=0/1&skuPrice=400_1900&brandId=1&catalog3Id=1&at trs=1_3G:4G:5G&attrs=2_骁龙845&attrs=4_高清屏
PUT product
{
"mappings": {
"properties": {
"attrs": {
"type": "nested",
"properties": {
"attrId": {
"type": "long"
},
"attrName": {
"type": "keyword"
},
"attrValue": {
"type": "keyword"
}
}
},
"autoGeneratedTimestamp": {
"type": "long"
},
"brandId": {
"type": "long"
},
"brandImg": {
"type": "keyword"
},
"brandName": {
"type": "keyword"
},
"catalogId": {
"type": "long"
},
"catalogName": {
"type": "keyword"
},
"description": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
},
"hasStock": {
"type": "boolean"
},
"hotScore": {
"type": "long"
},
"parentTask": {
"properties": {
"id": {
"type": "long"
},
"nodeId": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
},
"set": {
"type": "boolean"
}
}
},
"refreshPolicy": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
},
"retry": {
"type": "boolean"
},
"saleCount": {
"type": "long"
},
"shouldStoreResult": {
"type": "boolean"
},
"skuId": {
"type": "long"
},
"skuImg": {
"type": "keyword"
},
"skuPrice": {
"type": "keyword"
},
"skuTitle": {
"type": "text",
"analyzer": "ik_smart"
},
"spuId": {
"type": "keyword"
}
}
}
}
完整查询参数 es.wulawula.com/list.html?catalog3Id=225&keyword=华为&brandId=10&brand=20&attrs=12_海思(HS)&attrs=12_骁龙&sort=saleCount_desc/asc&hasStock=0/1&skuPrice=400_1900&catalog3Id=1&at trs=1_3G:4G:5G
GET product/_search
{
"query": { 《=== 检索 ===
"bool": {
"must": [ <-- 必须
{
"match": { <--全文匹配
"skuTitle": "华为" //商品标题
}
}
],
"filter": [ <--过滤写在filter
{
"term": { <-- 精确查询
"catalogId": "225" //根据3级分类Id
}
},
{
"terms": { <-- 多个值匹配
"brandId": [ //根据品牌Id
"10",
"20"
]
}
},
{
"nested": { < ** 嵌入式查询
"path": "attrs",
"query": {
"bool": {
"must": [
{
"term": {<-- 精确查询
"attrs.attrId": { //根据属性Id
"value": "12"
}
}
},
{
"terms": { <-- 多个精确查询
"attrs.attrValue": [ //根据属性的值
"海思(HS)",
"骁龙"
]
}
}
]
}
}
}
},
{
"term": {
"hasStock": { //根据是否有库存
"value": true
}
}
},
{
"range": { <--区间检索
"skuPrice": { //根据价格区间
"gte": 0, // >=0
"lte": 6000 // <=6000
}
}
}
]
}
},
"sort": [ 《=== 排序 ===
{
"skuPrice": { //根据价格降序
"order": "desc"
}
}
],
"from": 0, <--分页 从几开始
"size": 4, <--分页 查询几个
"highlight": { 《=== 高亮 ===
"fields": {"skuTitle": {}}, //指定哪个属性高亮
"pre_tags": "<b style='color:red'>", //前置标签
"post_tags": "</b>" //后置标签
},
"aggs": { 《=== 聚合 ===
"brand_agg": { <---聚合1
"terms": {
"field": "brandId", //根据品牌Id聚合
"size": 50
},
"aggs": { <-- 子聚合
"brand_name_agg": {
"terms": {
"field": "brandName", //根据品牌Id得到 品牌名称聚合
"size": 1
}
},
"brand_img_agg": {
"terms": {
"field": "brandImg", //根据品牌Id得到 品牌图片聚合
"size": 1
}
}
}
},
"catalog_agg": { <---聚合2
"terms": {
"field": "catalogId", //根据分类Id聚合
"size": 20
},
"aggs": { <-- 子聚合
"catalog_name_agg": {
"terms": {
"field": "catalogName", //根据分类Id得到 分类名称聚合
"size": 1
}
}
}
},
"attr_agg": { <---聚合3
"nested": { <** 嵌入式聚合
"path": "attrs" < ** 需要先声明是嵌入式聚合
},
"aggs": { <-- 子聚合
"attr_id_agg": {
"terms": {
"field": "attrs.attrId", //根据属性得到 属性Id聚合
"size": 10
},
"aggs": { <-- 子聚合
"attr_name_agg": {
"terms": {
"field": "attrs.attrName", //根据属性Id得到 属性名称聚合
"size": 1
}
},
"attr_value_agg": {
"terms": {
"field": "attrs.attrValue", //根据属性Id得到 属性值聚合
"size": 50
}
}
}
}
}
}
}
}
10、页面渲染
01、基本效果渲染
<!--排序内容 商品每四个是一组-->
<div class="rig_tab">
<!-- 遍历每个商品-->
<div th:each="product:${result.getProducts()}">
<div class="ico">
<i class="iconfont icon-weiguanzhu"></i>
<a href="/static/es/#">关注</a>
</div>
<p class="da">
<!--图片 -->
<a href="/static/es/#">
<img th:src="${product.skuImg}" class="dim">
</a>
</p>
<ul class="tab_im">
<li><a href="/static/es/#" title="黑色">
<img th:src="${product.skuImg}"></a>
</li>
</ul>
<p class="tab_R">
<!-- 价格 -->
<span th:text="'¥'+${product.skuPrice}">¥5199.00</span>
</p>
<p class="tab_JE">
<!-- 标题 -->
<!-- 使用utext标签,使检索时高亮不会被转义-->
<a href="/static/es/#" th:utext="${product.skuTitle}">
Apple iPhone 7 Plus (A1661) 32G 黑色 移动联通电信4G手机
</a>
</p>
<p class="tab_PI">已有<span>11万+</span>热门评价
<a href="/static/es/#">二手有售</a>
</p>
<p class="tab_CP"><a href="/static/es/#" title="谷粒商城Apple产品专营店">谷粒商城Apple产品...</a>
<a href='#' title="联系供应商进行咨询">
<img src="/static/es/img/xcxc.png">
</a>
</p>
<div class="tab_FO">
<div class="FO_one">
<p>自营
<span>谷粒商城自营,品质保证</span>
</p>
<p>满赠
<span>该商品参加满赠活动</span>
</p>
</div>
</div>
</div>
</div>
02、筛选条件渲染
将结果的品牌、分类、商品属性进行遍历显示,并且点击某个属性值时可以通过拼接url
进行跳转
- html
<div class="JD_selector">
<!--手机商品筛选-->
<div class="title">
<h3><b>手机</b><em>商品筛选</em></h3>
<div class="st-ext">共 <span>10135</span>个商品</div>
</div>
<div class="JD_nav_logo">
<!--品牌-->
<div class="JD_nav_wrap">
<div class="sl_key">
<span><b>品牌:</b></span>
</div>
<div class="sl_value">
<div class="sl_value_logo">
<ul>
<li th:each="brand:${result.brands}">
<!--拼接URL-->
<a href="/static/es/#" th:href="${'javascript:searchProducts("brandId",'+brand.brandId+')'}">
<img th:src="${brand.brandImg}" alt="">
<div th:text="${brand.brandName}">
华为(HUAWEI)
</div>
</a>
</li>
</ul>
</div>
</div>
<div class="sl_ext">
<a href="/static/es/#">
更多
<i style='background: url("image/search.ele.png")no-repeat 3px 7px'></i>
<b style='background: url("image/search.ele.png")no-repeat 3px -44px'></b>
</a>
<a href="/static/es/#">
多选
<i>+</i>
<span>+</span>
</a>
</div>
</div>
<!--分类-->
<div class="JD_pre">
<div class="sl_key">
<span><b>分类:</b></span>
</div>
<div class="sl_value">
<ul>
<li th:each="catalog:${result.catalogs}">
<!--拼接URL-->
<a href="/static/es/#"
th:href="${'javascript:searchProducts("catalog3Id",'+catalog.catalogId+')'}"
th:text="${catalog.catalogName}">分类名称</a>
</li>
</ul>
</div>
<div class="sl_ext">
<a href="/static/es/#">
更多
<i style='background: url("image/search.ele.png")no-repeat 3px 7px'></i>
<b style='background: url("image/search.ele.png")no-repeat 3px -44px'></b>
</a>
<a href="/static/es/#">
多选
<i>+</i>
<span>+</span>
</a>
</div>
</div>
<!--其他的所有需要展示的属性-->
<div class="JD_pre" th:each="attr:${result.attrs}">
<div class="sl_key">
<span th:text="${attr.attrName}">其他属性:</span>
</div>
<div class="sl_value">
<ul>
<!--此处的value也是一个list,也需要遍历-->
<li th:each="val:${attr.attrValue}">
<!--拼接URL-->
<a href="/static/es/#"
th:href="${'javascript:searchProducts("attrs","'+attr.attrId+'_'+val+'")'}"
th:text="${val}">其他属性</a>
</li>
</ul>
</div>
</div>
</div>
<div class="JD_show">
<a href="/static/es/#">
<span>
更多选项( CPU核数、网络、机身颜色 等)
</span>
</a>
</div>
</div>
- js
//按照属性筛选
function searchProducts(name, value) {
//原来的页面
location.href = replaceParamVal(location.href,name,value,true);
}
03、搜索栏
- html
<!--搜索导航-->
<div class="header_sous">
<div class="logo">
<a href="http://wulawula.com"><img src="/static/es/./image/logo1.jpg" alt=""></a>
</div>
<div class="header_form">
<input id="keyword_input" type="text" placeholder="手机"/>
<a href="javascript:searchByKeyword();">搜索</a>
</div>
</div>
- js
//搜索栏
function searchByKeyword() {
searchProducts("keyword",$("#keyword_input").val())
}
04、分页
- html
<!--分页-->
<div class="filter_page">
<div class="page_wrap">
<span class="page_span1">
<!--上一页-->
<!--th:attr="pn=${result.pageNum - 1}" 自定义属性:当前页+1 -->
<a class="page_a"
href="#"
th:attr="pn=${result.pageNum - 1}"
th:if="${result.pageNum >1}"><!-- 当前页>1 -->
《 上一页
</a>
<!--当前页-->
<a class="page_a"
href="#"
th:attr="pn=${nav},style=${nav == result.pageNum?'border: 0;color:#ee2222;background: #fff':''}"
th:each="nav:${result.pageNavs}">[[${nav}]]</a>
<!--下一页-->
<a class="page_a"
href="#"
th:attr="pn=${result.pageNum + 1}"
th:if="${result.pageNum < result.totalPages}"><!-- 当前页>总页码 -->
下一页 》
</a>
</span>
<span class="page_span2">
<em>共<b>[[${result.totalPages}]]</b>页 到第</em>
<input type="number" value="1">
<em>页</em>
<a class="page_submit">确定</a>
</span>
</div>
</div>
- js
//分页 1
$(".page_a").click(function () {
var pn=$(this).attr("pn");
location.href=replaceParamVal(location.href,"pageNum",pn,false);
console.log(replaceParamVal(location.href,"pageNum",pn,false))
})
//分页2
/**
* @param url 目前的url
* @param paramName 需要替换的参数属性名
* @param replaceVal 需要替换的参数的新属性值
* @param forceAdd 该参数是否可以重复查询(attrs=1_3G:4G:5G&attrs=2_骁龙845&attrs=4_高清屏)
* @returns {string} 替换或添加后的url
*/
function replaceParamVal(url, paramName, replaceVal, forceAdd) {
var oUrl = url.toString();
var nUrl;
if (oUrl.indexOf(paramName) != -1) {
if (forceAdd && oUrl.indexOf(paramName + "=" + replaceVal) == -1) {
if (oUrl.indexOf("?") != -1) {
nUrl = oUrl + "&" + paramName + "=" + replaceVal;
} else {
nUrl = oUrl + "?" + paramName + "=" + replaceVal;
}
} else {
var re = eval('/(' + paramName + '=)([^&]*)/gi');
nUrl = oUrl.replace(re, paramName + '=' + replaceVal);
}
} else {
if (oUrl.indexOf("?") != -1) {
nUrl = oUrl + "&" + paramName + "=" + replaceVal;
} else {
nUrl = oUrl + "?" + paramName + "=" + replaceVal;
}
}
return nUrl;
};
05、排序
- html
<!--综合排序-->
<div class="filter_top">
<div class="filter_top_left" th:with="p = ${param.sort}"> <!--不能直接用 param.sort去判断,所以用p来做中间替换,将param.sort 赋值给p,p当作text类型来用-->
<!--判断当param.sort不为空,并且是以自己的param.sort开始的,并且是以desc结尾的 然后3元运算 更改值-->
<!--判断param.sort是否为空,或者是否是以自己的param.sort开始的 然后3元运算拼接高亮样式和默认样式-->
<a th:class="${(! #strings.isEmpty(p) && #strings.startsWith(p,'hotScore') && #strings.endsWith(p,'desc')) ? 'sort_a desc':'sort_a'}"
th:attr="style = ${#strings.isEmpty(p) || #strings.startsWith(p,'hotScore')} ? 'color: #FFF;border-color:#e4393c;background: #e4393c' : 'color: #333;border-color:#ccc;background: #FFF'"
sort="hotScore" href="/static/es/#">综合排序 [[${(! #strings.isEmpty(p) && #strings.startsWith(p,'hotScore') && #strings.endsWith(p,'desc'))?'↓':'↑'}]]</a><!--判断3元运算,是否需要加上↓ 和 ↑-->
<a th:class="${(! #strings.isEmpty(p) && #strings.startsWith(p,'saleCount') && #strings.endsWith(p,'desc')) ? 'sort_a desc':'sort_a'}"
th:attr="style = ${#strings.isEmpty(p) || #strings.startsWith(p,'saleCount')} ? 'color: #FFF;border-color:#e4393c;background: #e4393c' : 'color: #333;border-color:#ccc;background: #FFF'"
sort="saleCount" href="/static/es/#">销量 [[${(! #strings.isEmpty(p) && #strings.startsWith(p,'saleCount') && #strings.endsWith(p,'desc'))?'↓':'↑'}]]</a>
<a th:class="${(! #strings.isEmpty(p) && #strings.startsWith(p,'skuPrice') && #strings.endsWith(p,'desc')) ? 'sort_a desc':'sort_a'}"
th:attr="style = ${#strings.isEmpty(p) || #strings.startsWith(p,'skuPrice')} ? 'color: #FFF;border-color:#e4393c;background: #e4393c' : 'color: #333;border-color:#ccc;background: #FFF'"
sort="skuPrice" href="/static/es/#">价格 [[${(! #strings.isEmpty(p) && #strings.startsWith(p,'skuPrice') && #strings.endsWith(p,'desc'))?'↓':'↑'}]]</a>
<a href="/static/es/#">评论分</a>
<a href="/static/es/#">上架时间</a>
</div>
<div class="filter_top_right">
<span class="fp-text">
<b>1</b><em>/</em><i>169</i>
</span>
<a href="/static/es/#" class="prev">《 </a>
<a href="/static/es/#" class="next"> 》 </a>
</div>
</div>
- js
$(".sort_a").click(function () {
//调用设置好的样式
// changStyle(this); //传入 this:当前元素 方法用ele接收了这个this
//跳转到指定位置
$(this).toggleClass("desc"); //被点击自动加上 desc的样式 再次点击就会取消
var sort = $(this).attr("sort"); //得到当前点击元素的sort里的值
console.log("sort = "+sort)
sort = $(this).hasClass("desc") ? sort+"_desc":sort+"_asc"; //判断是否包含desc 是否进行拼串
location.href = replaceParamVal(location.href,"sort",sort)
//禁用默认行为,禁止绑定标签的href跳转等
return false;
});
- 样式参考
function changStyle(ele) {
/***
* 1、改变当前元素和兄弟元素的样式(当前被点击的元素变为选中状态)
*/
//1.清空之前元素的样式
//默认样式 color: #333;border-color:#ccc;background: #FFF
$(".sort_a").css({"color":"#333","border-color":"#ccc","background":"#FFF"})
//2.改变当前被点击的元素变成被选中状态
//高亮样式 color: #FFF;border-color:#e4393c;background: #e4393c
$(ele).css({"color":"#FFF","border-color":"#e4393c","background":"#e4393c"}) //( ${this}就是当前元素 )
//3.去掉兄弟元素的 ↑ ↓ 符号
$(".sort_a").each(function () { //使用each遍历兄弟元素
var text = $(this).text().replace("↓","").replace("↑",""); //去掉全部的 ↑ ↓
$(ele).text(text); //设置进原始文本内容(不带拼接的)
});
/***
* 2、改变升序降序
*/
//被点击自动加上 desc的样式 再次点击就会取消
$(ele).toggleClass("desc"); //加上就是降序,不加就是升序
if ($(ele).hasClass("desc")){ //检查被选元素是否包含指定的class内容
//包含 -> 降序
var text = $(ele).text().replace("↓","").replace("↑",""); //得到当前的文本值 例如:综合排序。然后将 ↓ ↑ 两个符号的文本清空
console.log("text1" + text)
text = text+"↓"; //在文本值的基础上拼接 ↓
console.log("text2" + text)
$(ele).text(text) //将拼接好的文本值 设置到当前元素的文本上用于显示
}else {
//不包含 -> 升序
var text = $(ele).text().replace("↓","").replace("↑","");
text = text+"↑";
$(ele).text(text)
}
}
06、价格区间
- html
<!--不能直接用 param.sort去判断,所以用p来做中间替换,将param.sort 赋值给p,p当作text类型来用。priceRange也是替换param.skuPrics的值-->
<div class="filter_top_left" th:with="p = ${param.sort},priceRange=${param.skuPrice}">
<!--上架时间-->
<a href="/static/es/#">上架时间</a>
<!--价格区间-->
<!-- th:value="${#strings.isEmpty(priceRange)}" 用于input框的回显。且把param.skuPrics的值赋值给pricsRange 并且判断是否为空 -->
<!-- #strings.substringBefore(priceRange,'_') 截取出指定字符串的 _ 前面的内容 substringAfter为截取指定字符串的 _ 后面的内容-->
<input id="skuPriceFrom" type="number" style="width: 100px; margin-left: 20px;"
th:value="${#strings.isEmpty(priceRange) ? '' : #strings.substringBefore(priceRange,'_')}"> -
<input id="skuPriceTo" type="number" style="width: 100px;"
th:value="${#strings.isEmpty(priceRange) ? '' : #strings.substringAfter(priceRange,'_')}">
<button id="skuPriceSearchBth">确定</button>
</div>
- js
$("#skuPriceSearchBth").click(function () {
//1、拼接上价格区间的查询条件
var from = $("#skuPriceFrom").val(); //价格开始
var to = $("#skuPriceTo").val(); //价格结束
var query = from + "_" + to; //拼接字符串
location.href = replaceParamVal(location.href,"skuPrice",query); //拼接字符串后调用方法替换url
});
07、仅显示有货
- html
<li>
<a href="#" th:with="check = ${param.hasStock}">
<input id="showHasStock" type="checkbox"
th:checked="${#strings.equals(param.hasStock,'1')}"><!--使用equals()判断param.hasStock里是否是1-->
仅显示有货
</a>
</li>
- js
//仅显示有货 -> 选择框
$("#showHasStock").change(function () {
//prop可以获取调用此方法的checked类型的值为true或false(checked返回true或false)
if ($(this).prop('checked')) {
//true -> 有库存
location.href = replaceParamVal(location.href, "hasStock", 1);
} else {
//false -> 没选中 (有库存+无库存)
var re = eval('/(hasStock=)([^&]*)/gi'); //正则表达式匹配hasStock的值
location.href = (location.href + "").replace(re, ''); //将hasStock替换成空串
}
return false;
});
08、面包屑导航 + 条件筛选联动
- EsResult
/***
* 面包屑导航
*/
private List<NavVo> navs = new ArrayList<>();
@Data
public static class NavVo{
private String navName; //导航的名字
private String navValue; //导航的值
private String link; //取消一个导航后要跳转的位置
}
/***
* 判断哪些属性被筛选了。页面就不需要展示了,点击x才显示
*/
private List<Long> attrIds = new ArrayList<>();
- Impl
/*** 8、构建面包屑导航功能 */
//属性
if (param.getAttrs() != null && param.getAttrs().size()>0){
List<EsResult.NavVo> collect = param.getAttrs().stream().map(attr -> {
//1、分析出每个attr传过来的查询参数值
EsResult.NavVo navVo = new EsResult.NavVo();
//示例:attrs=2_5寸:6寸
String[] s = attr.split("_"); //分割
navVo.setNavValue(s[1]); //值
// id需要查询出对应的名字
//远程调用
R r = productFeignService.attrInfo(Long.parseLong(s[0]));
result.getAttrIds().add(Long.parseLong(s[0]));
if (r.getCode() == 0){ //正常返回
AttrResponseVo data = r.getData("attr", new TypeReference<AttrResponseVo>() {
});
navVo.setNavName(data.getAttrName());
}else {
navVo.setNavName(s[0]);
}
//2、取消了面包屑以后,跳转到哪个地方。将请求地址的URL里面的当前条件置空
//拿到所有的查询条件,去掉当前
String replace = replaceQueryString(param, attr,"attrs");
navVo.setLink("http://es.wulawula.com/list.html?" + replace);
return navVo;
}).collect(Collectors.toList());
result.setNavs(collect);
}
//品牌、分类
if (param.getBrandId() != null && param.getBrandId().size()>0){
List<EsResult.NavVo> navs = result.getNavs();
EsResult.NavVo navVo = new EsResult.NavVo();
navVo.setNavName("品牌");
//远程调用
R r = productFeignService.brandsInfo(param.getBrandId());
if (r.getCode() == 0) {
List<BrandVo> brand = r.getData("brand", new TypeReference<List<BrandVo>>() {
});
StringBuffer buffer = new StringBuffer();
String replace = "";
for (BrandVo brandVo:brand){
buffer.append(brandVo.getName());
replace = replaceQueryString(param, brandVo.getBrandId()+"","brandId");
}
navVo.setNavValue(buffer.toString());
navVo.setLink("http://es.wulawula.com/list.html?" + replace);
}
navs.add(navVo);
}
- R代码
public <T> T getData(String key,TypeReference<T> typeReference){
Object data = get(key);
String s = JSON.toJSONString(data); //对象 --> JSON字符串
//转换对象,可以将字符串类型的对象转换成我们指定类型的对象
T t = JSON.parseObject(s, typeReference); //JSON字符串 --> 相应的对象
return t;
}
@Data
public class BrandVo {
private Long brandId; //品牌id
private String name; //品牌名字
}
- feign
@FeignClient("gulimall-product")
public interface ProductFeignService {
//查询属性的信息
@GetMapping("/product/attr/info/{attrId}")
public R attrInfo(@PathVariable("attrId") Long attrId);
//返回所有的品牌数据
@GetMapping("/product/brand/infos")
public R brandsInfo(@RequestParam("brandIds") List<Long> brandIds);
}
- html
<!--遍历面包屑功能-->
<div class="JD_ipone_one c">
<!--获取点击x后跳转的地址-->
<a th:href="${nav.link}"
th:each="nav:${result.navs}">
<!--获取跳转的属性名和值-->
<span th:text="${nav.navName}"></span>
<span th:text="${nav.navValue}"></span> x </a>
</div>
- html
- 使用
#lists.contains()
方法获取list中的返回的值
<!--其他的所有需要展示的属性-->
<div class="JD_pre" th:each="attr:${result.attrs}" th:if="${!#lists.contains(result.attrIds,attr.attrId)}">
<div class="sl_key">
<span th:text="${attr.attrName}">属性名字</span>
</div>
<div class="sl_value">
<ul>
<!--此处的value也是一个list,也需要遍历-->
<li th:each="val:${attr.attrValue}"><a
th:href="${'javascript:searchProducts("attrs","'+attr.attrId+'_'+val+'")'}"
th:text="${val}">显示所有属性值</a>
</li>
</ul>
</div>
</div>
11、异步
12、CompletableFuture异步编排
13、商品详情
01、初步
1、修改hosts的http规则
# gulimall
192.168.56.10 wulawula.com
192.168.56.10 es.wulawula.com
192.168.56.10 item.wulawula.com
2、修改 Nginx 配置文件
/mydata/nginx/conf/conf.d/mydata/nginx/conf/conf.d
3、配置 GateWay
### 搜索页面
- id: gulimall_es_route
# lb 代表的就是负载均衡
uri: lb://gulimall-es
predicates:
- Host=es.wulawula.com
4、修改商品列表的跳转路径,点击商品图片即可跳转到商品详情页
<!--图片 -->
<a th:href="|http://item.wulawula.com/${product.skuId}.html|">
<img th:src="${product.skuImg}" class="dim">
</a>
使用 |${}|
的方式动态拼接url路径:|
http://item.wulawula.com/${
product.skuId}
.html|
5、封装数据Vo
- 总封装vo
/***
* 封装前台商品详情页信息
*/
@Data
public class SkuItemVo {
//1、sku基本信息获取 pms_sku_info
SkuInfoEntity info;
//2、sku的图片信息 pms_sku_images
List<SkuImagesEntity> images;
//3、获取spu的销售属性组合 -> 组合有多种 -> 将单个的销售属性组合起来
List<SkuItemSaleAttrVo> saleAttr;
//4、获取spu的介绍 pms_spu_info_desc
SpuInfoDescEntity desc;
//5、获取spu的规格参数信息
SpuItemAttrGroup groupAttrs;
}
- 下属vo 2.1
//2.1、获取spu的销售属性(单个)
@Data
public class SkuItemSaleAttrVo{
private Long skuId; //id
private String attrName; //名称
private List<String> attrValues; //值
}
- 下属vo 5.1
//5.1、获取spu的基本属性分组(分组下有多个属性)
@ToString
@Data
public class SpuItemAttrGroupVo{
private String groupName; //分组的名字
private List<SpuBaseAttrVo> attrs; //分组下对应的属性
}
- 下属vo 5.1.1
//5.1.1、spu的基本属性(单个)
@ToString
@Data
public class SpuBaseAttrVo{
private String attrName; //属性的名字
private String attrValue; //属性的值
}
- controller
@Controller
public class ItemController {
@Autowired
SkuInfoService skuInfoService;
/***
* 展示当前sku的详情
*/
@GetMapping("/{skuId}.html")
public String skuItem(@PathVariable Long skuId, Model model){
System.out.println("准备查询" + skuId + "详情");
//前台:查询出sku的详情内容,用于展示
SkuItemVo vo = skuInfoService.item(skuId);
model.addAttribute("item",vo);
return "item";
}
}
- SkuInfoServiceImpl
//前台:查询出sku的详情内容,用于展示
@Override
public SkuItemVo item(Long skuId) {
SkuItemVo skuItemVo = new SkuItemVo();
//1、sku基本信息获取 pms_sku_info
SkuInfoEntity info = getById(skuId);
skuItemVo.setInfo(info);
Long catalogId = info.getCatalogId();
Long spuId = info.getSpuId();
//2、sku的图片信息 pms_sku_images
//前台:通过skuid查询sku的图片信息
List<SkuImagesEntity> images = imagesService.getImagesBySkuId(skuId);
skuItemVo.setImages(images);
//3、获取spu的销售属性组合 -> 组合有多种
List<SkuItemSaleAttrVo> saleAttrVos = skuSaleAttrValueService.getSaleAttrsBySpuId(spuId);
skuItemVo.setSaleAttr(saleAttrVos);
//4、获取spu的介绍 pms_spu_info_desc
SpuInfoDescEntity desc = spuInfoDescService.getById(spuId);
skuItemVo.setDesc(desc);
//5、获取spu的规格参数信息 -> 组合
//获取spu的销售属性(单个)
//通过spuid查询出属性分组
List<SpuItemAttrGroupVo>attrGroupVos = attrGroupService.getAttrGroupWithAttrsBySpuId(spuId,catalogId);
skuItemVo.setGroupAttrs(attrGroupVos);
return skuItemVo;
}
针对 3、获取spu的销售属性组合 -> 组合有多种 写sql
- SkuSaleAttrValueServiceImpl
@Override
public List<SkuItemSaleAttrVo> getSaleAttrsBySpuId(Long spuId) {
SkuSaleAttrValueDao dao = this.baseMapper;
//sql
List<SkuItemSaleAttrVo> saleAttrVos = dao.getSaleAttrsBySpuId(spuId);
return saleAttrVos;
}
- SkuSaleAttrValueDao
//3、获取spu的销售属性组合 -> 组合有多种
List<SkuItemSaleAttrVo> getSaleAttrsBySpuId(@Param("spuId") Long spuId);
- SkuSaleAttrValueDao.xml
<select id="getSaleAttrsBySpuId" resultType="com.wulawula.gulimall.product.vo.web.SkuItemSaleAttrVo">
SELECT
ssav.attr_id attr_id,
ssav.attr_name attr_name,
GROUP_CONCAT(DISTINCT ssav.attr_value) attrValues
FROM
pms_sku_info info
LEFT JOIN
pms_sku_sale_attr_value ssav ON ssav.sku_id = info.sku_id
WHERE
info.spu_id=#{spuId}
GROUP BY
ssav.attr_id,ssav.attr_name
</select>
针对 5、获取spu的规格参数信息 -> 组合 写sql
@Override
public List<SpuItemAttrGroupVo> getAttrGroupWithAttrsBySpuId(Long spuId, Long catalogId) {
//1、查出当前spu对应的所有属性的分组信息以及当前分组下的所有属性对应的值
AttrGroupDao baseMapper = this.getBaseMapper();
//sql
List<SpuItemAttrGroupVo> vos = baseMapper.getAttrGroupWithAttrsBySpuId(spuId,catalogId);
return vos;
}
- AttrGroupDao
List<SpuItemAttrGroupVo> getAttrGroupWithAttrsBySpuId(@Param("spuId") Long spuId, @Param("catalogId") Long catalogId);
- AttrGroupDao.xml
<!-- resultType:返回集合里面元素的类型,只要有嵌套属性就要封装自定义结果集 -->
<resultMap id="spuItemAttrGroupVo" type="com.wulawula.gulimall.product.vo.web.SpuItemAttrGroupVo">
<result property="groupName" column="attr_group_name"/>
<collection property="attrs" ofType="com.wulawula.gulimall.product.vo.web.SpuBaseAttrVo">
<result property="attrName" column="attr_name"/>
<result property="attrValue" column="attr_value"/>
</collection>
</resultMap>
<select id="getAttrGroupWithAttrsBySpuId"resultMap="spuItemAttrGroupVo">
SELECT pav.spu_id, ag.attr_group_name, ag.attr_group_id, aar.attr_id, attr.attr_name, pav.attr_value
FROM pms_attr_group ag
LEFT JOIN pms_attr_attrgroup_relation aar ON aar.attr_group_id = ag.attr_group_id
LEFT JOIN pms_attr attr ON attr.attr_id = aar.attr_id
LEFT JOIN pms_product_attr_value pav ON pav.attr_id = attr.attr_id
WHERE ag.catelog_id = #{catalogId} AND pav.spu_id = #{spuId}
</select>
02、sku组合切换
- 修改 SkuItemSaleAttrVo
//2.1、获取spu的销售属性(单个)
@ToString
@Data
public class SkuItemSaleAttrVo{
private Long attrId; //id
private String attrName; //名称
private List<AttrValueWithSkuIdVo> attrValues; //值(修改为有多种)
}
- 新增 AttrValueWithSkuIdVo
//2.1.1、组合多种值
@Data
public class AttrValueWithSkuIdVo {
private String attrValue;
private String skuIds;
}
- 修改 SkuSaleAttrValueDao.xml
<resultMap id="skuItemSaleAttrVo" type="com.wulawula.gulimall.product.vo.web.SkuItemSaleAttrVo">
<result property="attrId" column="attr_id"/>
<result property="attrName" column="attr_name"/>
<collection property="attrValues" ofType="com.wulawula.gulimall.product.vo.web.AttrValueWithSkuIdVo">
<result property="attrValue" column="attr_value"/>
<result property="skuIds" column="sku_ids"/>
</collection>
</resultMap>
<select id="getSaleAttrsBySpuId" resultMap="skuItemSaleAttrVo">
SELECT
ssav.attr_id attr_id,
ssav.attr_name attr_name,
ssav.attr_value attr_value,
GROUP_CONCAT(DISTINCT info.sku_id) sku_ids
FROM
pms_sku_info info
LEFT JOIN
pms_sku_sale_attr_value ssav ON ssav.sku_id = info.sku_id
WHERE
info.spu_id = #{spuId}
GROUP BY
ssav.attr_id,ssav.attr_name, ssav.attr_value
</select>
- html
<div class="box-attr-3">
<div class="box-attr clear" th:each="attr:${item.saleAttr}">
<dl>
<!--遍历属性-->
<dt>选择[[${attr.attrName}]]</dt>
<!--遍历下面的数组用逗号分隔开 val就是每一个值-->
<dd th:each="vals:${attr.attrValues}">
<!--用逗号把vals.skuIds分隔开。再判断这俩门面是否包含item.info.skuId-->
<!--然后自定义方法判断是否包含?包含的话加上checked 示例:商品为:黑色8GB+128GB的手机 查看它的属性。只有黑色和8GB+128GB两个属性会有checked-->
<a
th:attr="skus=${vals.skuIds} , class=${#strings.contains(#strings.listSplit(vals.skuIds,','),item.info.skuId.toString()) ? 'sku_attr_value checked' : 'sku_attr_value'}" >
[[${vals.attrValue}]]
<!-- <img src="/static/item/img/59ddfcb1Nc3edb8f1.jpg" /> -->
</a>
</dd>
</dl>
</div>
</div>
- js1
$(".sku_attr_value").click(function () {
var skus = new Array();
//1、点击的元素加上clicked自定义属性值,代表这个是我们刚刚点击的
$(this).addClass("clicked");
//2、获得当前元素的sku集合 字符串 并用逗号分隔开转为数组
var curr = $(this).attr("skus").split(",");
skus.push(curr); //当前被点击的所有sku组合的数组放到集合里面
//3、去掉原来的checked属性,在当前标签用parent()往上找两级父标签,再用find()去找后代标签,用removeClass()移除指定的属性
$(this).parent().parent().find(".sku_attr_value").removeClass("checked")
//其他属性也遍历放进集合里面
$("a[class='sku_attr_value checked']").each(function () {
skus.push($(this).attr("skus").split(","));
});
console.log(skus);
//2、取出它们的交集,得到skuId
// console.log($(skus[0]).filter(skus[1])[0]);
var filterEle = skus[0];
for (var i=1;i<skus.length;i++){
filterEle = $(filterEle).filter(skus[1])
}
//跳转到指定的颜色界面
location.href = "http://item.wulawula.com/"+ filterEle[0] +".html";
console.log(filterEle[0]);
});
- js2
//判断是否选中标签,去改变它的css样式
$(function () {
//因为方法加再了dd标签 用parent()方法去找它的父标签
$("a[class='sku_attr_value']").parent().css({"border":"solid 1px #CCC"}) //先清除
$("a[class='sku_attr_value checked']").parent().css({"border":"solid 1px red"}) //再加色
// $(".sku_attr_value.checked").parent().css({"border":"solid 1px red"})
// $(".sku_attr_value").parent().css({"border":"solid 1px #CCC"})
})
03、异步编排优化
1、自定义线程池
- config
//如果指定的 ThreadPoolConfigProperties类 没有用Component加到容器中,那么我们要在需要此配置的类里开启属性配置,
//来使用此class的配置内容。示例:都不加的话,此类的方法上不能传入 ThreadPoolConfigProperties类
//@EnableConfigurationProperties(ThreadPoolConfigProperties.class) //开启属性配置
@Configuration
public class MyThreadConfig {
@Bean
public ThreadPoolExecutor executor(ThreadPoolConfigProperties pool) {
return new ThreadPoolExecutor(pool.getCoreSize(), /*核心线程大小*/
pool.getMaxSize(), /*最大线程大小*/
pool.getKeepAliveTime(), /*空闲线程多久关闭*/
TimeUnit.SECONDS, /*时间单位 -> 秒*/
new LinkedBlockingQueue<>(100000), /*阻塞队列长度*/
Executors.defaultThreadFactory(), /*线程工厂 -> 此处使用默认的*/
new ThreadPoolExecutor.AbortPolicy() /*拒绝策略 -> 抛弃*/
);
}
}
2、设置 线程池属性配置类 让其可以在yml里作为可配置的
- ConfigProperties
@ConfigurationProperties(prefix = "wulawula.thread" ) //生成配置元数据,可在yml里面配置
@Component
@Data
public class ThreadPoolConfigProperties {
private Integer coreSize;
private Integer maxSize;
private Integer keepAliveTime;
}
3、在yml里设置 配置类 的参数
- yml
wulawula:
thread:
core-size: 20
max-size: 200
keep-alive-time: 10
4、修改程序 -> 异步编排
在这次查询中任务2、3、4、5、6都需要spuId
,因此需要等待任务1执行完毕,得到任务1的执行结果。因此任务1采用supplyAsync
,需要其有返回值。任务2、3、4、5、6调用thenAcceptAsync()
可以接受上一步的结果且没有返回值。任务1和任务7谁也不依赖谁,平级的。都需要传入一个skuId
,因此可以创建两个异步对象。 记得注入线程池
最后,调用 allOf().get()
方法使得所有方法都已经执行完成且是按照顺序执行
//注入线程池
@Autowired
ThreadPoolExecutor executor;
//前台:查询出sku的详情内容,用于展示
@Override
public SkuItemVo item(Long skuId) throws ExecutionException, InterruptedException {
SkuItemVo skuItemVo = new SkuItemVo();
/***
* 异步编排
*/
/* 1.创建异步任务 -> 有返回值 */
CompletableFuture<SkuInfoEntity> infoFuture = CompletableFuture.supplyAsync(() -> {
//1、sku基本信息获取 pms_sku_info
SkuInfoEntity info = getById(skuId);
skuItemVo.setInfo(info);
return info;
}, executor);
/* 2.接收上一步结果,并消费处理该结果,无返回值 */
CompletableFuture<Void> saleAttrFuture = infoFuture.thenAcceptAsync(result -> {
//3、获取spu的销售属性组合 -> 组合有多种
List<SkuItemSaleAttrVo> saleAttrVos = skuSaleAttrValueService.getSaleAttrsBySpuId(result.getSpuId());
skuItemVo.setSaleAttr(saleAttrVos);
}, executor);
/* 3.接收上一步结果,并消费处理该结果,无返回值 */
CompletableFuture<Void> descFuture = infoFuture.thenAcceptAsync(result -> {
//4、获取spu的介绍 pms_spu_info_desc
SpuInfoDescEntity desc = spuInfoDescService.getById(result.getSpuId());
skuItemVo.setDesc(desc);
}, executor);
/* 4.接收上一步结果,并消费处理该结果,无返回值 */
CompletableFuture<Void> baseAttrFuture = infoFuture.thenAcceptAsync(result -> {
//5、获取spu的规格参数信息 -> 组合
//获取spu的销售属性(单个)
//通过spuid查询出属性分组
List<SpuItemAttrGroupVo> attrGroupVos = attrGroupService.getAttrGroupWithAttrsBySpuId(result.getSpuId(), result.getCatalogId());
skuItemVo.setGroupAttrs(attrGroupVos);
}, executor);
/* 5.接收上一步结果,并消费处理该结果,无返回值 */
CompletableFuture<Void> spuInfoFuture = infoFuture.thenAcceptAsync(result -> {
//商品描述
SpuInfoEntity spuInfo = spuInfoService.getById(result.getSpuId());
skuItemVo.setSpuInfo(spuInfo);
}, executor);
/* 6.接收上一步结果,并消费处理该结果,无返回值 */
CompletableFuture<Void> brandFuture = infoFuture.thenAcceptAsync(result -> {
//品牌名
Long brandId = result.getBrandId();
BrandEntity byId = brandService.getById(brandId);
skuItemVo.setBrand(byId);
}, executor);
/* 7.新开一个异步任务,无返回值 */
CompletableFuture<Void> imagesFuture = CompletableFuture.runAsync(() -> {
//2、sku的图片信息 pms_sku_images
//前台:通过skuid查询sku的图片信息
List<SkuImagesEntity> images = imagesService.getImagesBySkuId(skuId);
skuItemVo.setImages(images);
}, executor);
/* 8.等待所有vo都完成 */ CompletableFuture.allOf(saleAttrFuture,descFuture,baseAttrFuture,spuInfoFuture,brandFuture,imagesFuture).get();
return skuItemVo;
}
14、登录 注册
1、修改hosts的http规则
# gulimall
192.168.56.10 wulawula.com
192.168.56.10 es.wulawula.com
192.168.56.10 item.wulawula.com
192.168.56.10 auth.wulawula.com
2、修改 Nginx 配置文件
/mydata/nginx/conf/conf.d/mydata/nginx/conf/conf.d
3、配置 GateWay
### 登陆页面注册页面
- id: gulimall_auth_route
# lb 代表的就是负载均衡
uri: lb://gulimall-auth-server
predicates:
- Host=auth.wulawula.com
4、
01、倒计时效果
1、给a标签绑定一个sendCode
<a id="sendCode">发送验证码</a>
2、自定义的倒计时方法
//发送验证码。倒计时
$(function f() {
$("#sendCode").click(function () {
//1、倒计时效果
//判断class里是否有disavled。如果有的话代表正在倒计时。不能重复点击自定义的倒计时方法
if ($(this).hasClass("disavled")){
//正在倒计时
}else {
//2、给指定手机号发送验证码
timeoutChangeStyle();
}
})
})
//自定义的倒计时方法
var num = 60; //设置初始时间
function timeoutChangeStyle() {
$("#sendCode").attr("class","disavled"); //给a标签的class设置一个class值,防止点击完后还可以重复点击
if (num == 0){
//发送验证码倒计时完成,重新发送
$("#sendCode").text("发送验证码"); //重新修改文本内容
num = 60;
$("#sendCode").attr("class",""); //此时移除class的值,让其可以再次点击
}else {
//验证码倒计时
var str = num+"s 后再次发送";
$("#sendCode").text(str); //文本内容
setTimeout("timeoutChangeStyle()",1000); //计时器方法。不断的调用此大方法,时间间隔为1s
num --;
}
}
02、视图映射跳转
- controller (以前的方法。直接在controller里跳转页面)
@Controller
public class LoginController {
//跳转登录页
@GetMapping("/login.html")
public String loginPage(){
return "login";
}
//跳转注册页
@GetMapping("/reg.html")
public String regPage(){
return "reg";
}
}
发送一个请求直接跳转到一个页面,此请求不传递数据。只是单纯的跳转方法就可以使用视图映射
SpringMVC viewcontroller:将请求和页面映射过来
- config (视图映射 实现WebMvcConfigurer接口 参数可以参考之前的controller方法)
@Configuration
public class GulimallConfig implements WebMvcConfigurer {
/***
* 视图映射
*/
@Override
public void addViewControllers(ViewControllerRegistry registry) {
/* addViewController:添加视图配置器 参数1:url路径 参数2:视图名 */
registry.addViewController("/login.html").setViewName("login");
registry.addViewController("/reg.html").setViewName("reg");
}
}
03、整合阿里短信服务
第三方调用模块
- 定义方法类 短信服务的具体方法
@ConfigurationProperties(prefix = "spring.cloud.alicloud.sms")
@Data //为这些方法生成getter,setter
@Component
public class SmsComponent {
//aliyuncs的参数
private String accessKeyID;
private String accessKeySecret;
private String signName;//您的申请签名
private String templateCode; //你的模板
//param里面包含验证码,phone里面是手机号
public void sendSmsCode(String phone, String param) {
//判断手机号是否为空
if (StringUtils.isEmpty(phone)) {
System.out.println("手机号为空");
}else {
DefaultProfile profile = DefaultProfile.getProfile("default", accessKeyID, accessKeySecret);
IAcsClient client = new DefaultAcsClient(profile);
//设置相关固定的参数
CommonRequest request = new CommonRequest();
request.setMethod(MethodType.POST); //提交方式
request.setDomain("dysmsapi.aliyuncs.com");
request.setVersion("2017-05-25");
request.setAction("SendSms");
//设置发送相关的参数
request.putQueryParameter("PhoneNumbers", phone); //手机号
request.putQueryParameter("SignName", signName); //申请的阿里云的 签名名称
request.putQueryParameter("TemplateCode", templateCode); //申请的阿里云的 模板code
HashMap<String, Object> params = new HashMap<>();
params.put("code",param);
request.putQueryParameter("TemplateParam", JSONObject.toJSONString(params)); //验证码数据,转换json数据传递
try {
//最终发送
CommonResponse response = client.getCommonResponse(request);
System.out.println("发送成功");
//判断成功还是失败
}catch (ServerException e) {
e.printStackTrace();
System.out.println("发送失败1");
} catch (ClientException e) {
e.printStackTrace();
System.out.println("发送失败2");
}
}
}
}
- application.yml 配置自定义的配置文件参数
spring:
cloud:
alicloud:
### 自定义短信配置文件
sms:
access-key-i-d: 自己的AccessKeyId
access-key-secret: 自己的AccessKeySecret
sign-name: 申请的阿里云的 签名名称
template-code: 申请的阿里云的 模板code
主程序模块
- 错误状态码枚举类
public enum BizCodeEnume {
UNKNOWN_EXCEPTION(10000,"系统未知异常"),
VAILD_EXCEPTION(10001,"参数格式校验失败"),
PRODUCT_UP_EXCEPTION(11000,"商品上架异常"),
SMS_CODE_EXCEPTION(10002,"验证码获取频率太高,稍后再试");
private int code;
private String msg;
BizCodeEnume(int code,String msg){
this.code = code;
this.msg = msg;
}
public int getCode(){
return code;
}
public String getMsg(){
return msg;
}
}
- openfeign 远程调用第三方模块发送验证码
@FeignClient("gulimall-third-party")
public interface ThirdPartFeignService {
//调用第三方模块发送验证码
@GetMapping("/sms/sendcode")
public R sendCode(@RequestParam("phone") String phone, @RequestParam("code") String code);
}
- controller
- 验证码的再次校验:存进redis
- 防止同一个手机号在60s内再次发送验证码:
存的时候加上当前的时间戳
。并在每一次调用发送验证码的远程接口时先取出来redis里存的验证码。并以 _ 切割字符串。判断redis里存取的时间和当前时间的时间差是否在60000毫秒以内,在的话抛出枚举类里定义的异常
@Controller
public class LoginController {
@Autowired
ThirdPartFeignService thirdPartFeignService; //注入远程调用
@Autowired
StringRedisTemplate stringRedisTemplate; //注入redis
//调用短信发送功能
@ResponseBody
@GetMapping("/sms/sendcode")
public R sendCode(@RequestParam("phone") String phone){
//每次执行先读取是否由此验证码
String redisCode = stringRedisTemplate.opsForValue().get(AuthServerConstant.SMS_CODE_CACHE + phone);
//TODO 1、接口防刷
//第一次发送验证码肯定是空的。所以此处不为空时,才判断是否在60s之内
if (!StringUtils.isEmpty(redisCode)){
long s = Long.parseLong(redisCode.split("_")[1]); //截取存进redis的时间
if (System.currentTimeMillis() - s < 60000){
//防止同一个手机号在60s内再次发送验证码
System.out.println("60s内不能再次发送");
return R.error(BizCodeEnume.SMS_CODE_EXCEPTION.getCode(),BizCodeEnume.SMS_CODE_EXCEPTION.getMsg());
}
}
String code = UUID.randomUUID().toString().substring(0, 5) + "_" + System.currentTimeMillis(); //给redis寸数据加时间戳。
//2、验证码的再次校验 -> redis key对应phone value对应code sms:code:18346779985 -> 123456
stringRedisTemplate.opsForValue().set(AuthServerConstant.SMS_CODE_CACHE+phone,code,10, TimeUnit.MINUTES); //指定过期时间为10分钟
thirdPartFeignService.sendCode(phone,code);
return R.ok();
}
}
- js 给指定手机号发送完验证码后,调用回调方法。判断 主程序的code的值
//发送验证码。倒计时
$(function f() {
$("#sendCode").click(function () {
//1、倒计时效果
//判断class里是否有disavled。如果有的话代表正在倒计时。不能重复点击自定义的倒计时方法
if ($(this).hasClass("disavled")){
//正在倒计时
}else {
//2、给指定手机号发送验证码
phoneNumber = $("#phoneNum").val(),//得到手机号
$.get("/sms/sendcode?phone="+ phoneNumber,function (data) {
if (data.code != 0){
alert(data.msg);
}
}); //给LoginController发送请求。拼接phone参数
timeoutChangeStyle();
}
})
})
04、注册【异常机制、加密】
被调用服务 -> 会员服务
- 错误状态码枚举类 定义枚举 用于判断手机号或者验证码是否重复
// 错误状态码枚举类
public enum BizCodeEnume {
UNKNOWN_EXCEPTION(10000,"系统未知异常"),
VAILD_EXCEPTION(10001,"参数格式校验失败"),
PRODUCT_UP_EXCEPTION(11000,"商品上架异常"),
SMS_CODE_EXCEPTION(10002,"验证码获取频率太高,稍后再试"),
USER_EXIST_EXCEPTION(15001,"用户名已存在"),
PHONE_EXIST_EXCEPTION(15002,"手机号已存在");
private int code;
private String msg;
BizCodeEnume(int code,String msg){
this.code = code;
this.msg = msg;
}
public int getCode(){
return code;
}
public String getMsg(){
return msg;
}
}
- vo 【接收注册页面传过来的注册信息的vo】
//接收注册页面传过来的注册信息的vo
@Data
public class MemberRegistVo {
private String username;
private String password;
private String phone;
}
- controller 需要捕获异常 -> 判断手机号已存在 用户名已存在。然后返回给调用服务
- 通过异常机制判断当前注册会员名和电话号码是否已经注册,如果已经注册,则抛出对应的自定义异常,并在返回时封装对应的错误信息
- 如果没有注册,则封装传递过来的会员信息,并设置默认的会员等级、创建时间
/***
* 会员的注册功能 -> 远程调用
*/
@PostMapping("/regist")
public R regist(@RequestBody MemberRegistVo vo){
try{
memberService.regist(vo);
//捕获异常 -> 手机号已存在 用户名已存在
}catch (PhoneExistException e){
R.error(BizCodeEnume.PHONE_EXIST_EXCEPTION.getCode(),BizCodeEnume.PHONE_EXIST_EXCEPTION.getMsg());
}catch (UserNameExistException e){
R.error(BizCodeEnume.USER_EXIST_EXCEPTION.getCode(),BizCodeEnume.USER_EXIST_EXCEPTION.getMsg());
}
return R.ok();
}
- server 接口
public interface MemberService extends IService<MemberEntity> {
//会员的注册功能
void regist(MemberRegistVo vo);
//检查用户名和手机号是否唯一
void checkPhoneUnique(String phone) throws PhoneExistException;
void checkUseNameUnique(String username) throws UserNameExistException;
}
- serviceImpl 异常机制
@Service("memberService")
public class MemberServiceImpl extends ServiceImpl<MemberDao, MemberEntity> implements MemberService {
@Autowired
MemberLevelDao memberLevelDao;
//会员的注册功能
@Override
public void regist(MemberRegistVo vo) {
MemberDao memberDao = this.baseMapper;
MemberEntity entity = new MemberEntity();
//1、注册时设置默认等级:普通会员。
MemberLevelEntity levelEntity = memberLevelDao.getDefaultLevel();
entity.setLevelId(levelEntity.getId());
//2、检查用户名和手机号是否唯一,为了让controller感知到异常,需要使用异常机制
checkPhoneUnique(vo.getPhone());
checkUseNameUnique(vo.getUsername());
entity.setMobile(vo.getPhone());
entity.setUsername(vo.getUsername());
//密码要进行加密存储 -> 不可逆 -> MD5 -> MD5盐值 -> BCryptPasswordEncoder
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
String encode = passwordEncoder.encode(vo.getPassword());
entity.setPassword(encode);
//其他的默认信息
//保存
memberDao.insert(entity);
}
//-------------------------------------异常机制----------------------------------------
//检查用户名和手机号是否唯一
@Override
public void checkPhoneUnique(String phone) throws PhoneExistException{
MemberDao memberDao = this.baseMapper;
Integer count1 = memberDao.selectCount(new QueryWrapper<MemberEntity>().eq("mobile", phone));
if (count1 > 0){
throw new PhoneExistException();
}
}
@Override
public void checkUseNameUnique(String username) throws UserNameExistException{
MemberDao memberDao = this.baseMapper;
Integer count2 = memberDao.selectCount(new QueryWrapper<MemberEntity>().eq("username", username));
if (count2 > 0){
throw new UserNameExistException();
}
}
}
- 设置用户的默认等级
- MemberLevelDao
//1、注册时设置默认等级:普通会员 -> default_status = 1
MemberLevelEntity getDefaultLevel();
- MemberLevelDao.xml
<select id="getDefaultLevel" resultType="com.wulawula.gulimall.member.entity.MemberLevelEntity">
SELECT * FROM ums_member_level WHERE default_status = 1
</select>
加密
1、 MD5加密
- MD5不可直接进行加密存储:
- 抗修改性 -> MD5暴力破解网站每天大量搜索MD5的值。 -> 制作成
彩虹表
每个数段的MD5值都是一样的,就会存储起来,等待查询
@Test
void contextLoads() {
//e10adc3949ba59abbe56e057f20f883e
String s = DigestUtils.md5Hex("123456");
//7e8feb2276322ecddd4423b649dfd4d9
String s = DigestUtils.md5Hex("123456 ");
System.out.println(s)
}
2、 MD5盐值加密
- 盐值加密 加盐(
随机盐
):$1$ + 8位字符
- 且两次的运行结果是一样的
- 验证密码进行登陆:再次将用户输入的 123456 进行盐值加密 【盐值要去数据库查,数据库需要保存盐值字段】
@Test
void contextLoads() {
//$1$YdkJTmB1$jsWeFyCOFNJ1jXRp3rHJe1
String s = Md5Crypt.md5Crypt("123456".getBytes());
//第一次:$1$88888888$.04CpISZfzlbsDnC6Fjr11
//第二次:$1$88888888$.04CpISZfzlbsDnC6Fjr11
String s = Md5Crypt.md5Crypt("123456".getBytes(),"$1$88888888");
System.out.println(s)
}
3、 BCryptPasswordEncoder 【推荐
】
-
Spring的密码加密器(基于盐值加密)
-
也是拼接了随机字符串
即使要加密的数据一样,得到的加密字符串却不一样
-
加密使用
encode
解密使用matches
-
一个字符串加密两次得到两个不同的结果,将两个结果解密,发现两个都是123456的加密字符,返回true
@Test
void contextLoads() {
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
//第一次:$2a$10$6VBbkOIjUzoYsA/Wo/glsui6uZ1kJ8lMaygh6YjdhY2tMC3c4kPf6
//第二次:$2a$10$ezArPw7xXBsZ.DMFLFEh1u4VnOccNSDCwHnnih3mGVRc5ewzpVV9q
String s = passwordEncoder.encode("123456");
//true
boolean matches = passwordEncoder.matches("123456", "$2a$10$6VBbkOIjUzoYsA/Wo/glsui6uZ1kJ8lMaygh6YjdhY2tMC3c4kPf6");
//true
boolean matches = passwordEncoder.matches("123456", "$2a$10$ezArPw7xXBsZ.DMFLFEh1u4VnOccNSDCwHnnih3mGVRc5ewzpVV9q");
System.out.println(s + " -> " + matches);
}
调用服务 -> 登录注册服务
- feign 【会员的注册功能 -> 远程调用】
@FeignClient("gulimall-member")
public interface MemberFeignService {
//会员的注册功能 -> 远程调用
@PostMapping("/member/member/regist")
R regist(@RequestBody UserRegistVo vo);
}
- VO 【此处使用JSR303校验vo字段】
@Data
public class UserRegistVo {
@NotEmpty(message = "用户名不能为空") //不能为空
@Length(min = 6,max = 18,message = "用户名必须是6-18位字符") //用户名长度限制
private String username;
@NotEmpty(message = "密码不能为空") //不能为空
@Length(min = 6,max = 18,message = "用户名必须是6-18位字符") //密码长度限制
private String password;
@NotEmpty(message = "手机号不能为空") //不能为空
@Pattern(regexp = "^[1]([3-9])[0-9]{9}$",message = "手机号格式不正确") //正则表达式 第一个数字是1,第2个数字是3-9内的,后面的9个数字是0-9之内的
private String phone;
@NotEmpty(message = "验证码不能为空") //不能为空
private String code;
}
- controller 【此处的注册功能是根据是根据另一个服务调用的】
- 若JSR303校验未通过,则通过
BindingResult
封装错误信息,并重定向至注册页面 - 若通过JSR303校验,则需要从
redis
中取值判断验证码是否正确,正确的话通过会员服务注册 - 会员服务调用成功则重定向至登录页,否则封装远程服务返回的错误信息返回至注册页面
RedirectAttributes可以通过session保存信息并在重定向的时候携带过去
@Autowired
StringRedisTemplate stringRedisTemplate; //Redis
@Autowired
MemberFeignService memberFeignService; //远程调用
//注册
@PostMapping("/regist")
//@Valid开启校验功能。错误信息都会在BindingResult中
//RedirectAttributes 重定向携带数据,不要用Model了
// TODO 重定向携带数据,利用session原理,将数据放在session中。只要跳转到下一个页面且去除这个数据以后,session里面的数据就会被删除
// TODO 分布式下session会出现问题
public String regist(@Valid UserRegistVo vo, BindingResult result, /*Model model*/ RedirectAttributes redirectAttributes){
/***
* 1、使用JSR303校验填写的内容是否出错。出错则重定向到注册页面。且将错误信息返回给前台。
*/
//1、如果BindingResult里有校验错误
if (result.hasErrors()){
//将收集的错误信息手机成一个map集合。传给前台页面。用于展示
//方法 1
// HashMap<String, String> errors = new HashMap<>();
// result.getFieldErrors().stream().map(fieldError -> {
// String field = fieldError.getField(); //哪个字段出现了错误
// String defaultMessage = fieldError.getDefaultMessage(); //错误的消息
// errors.put(field,defaultMessage);
// return errors;
// });
//方法 2
Map<String, String> errors = result.getFieldErrors().stream().collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage));
// model.addAttribute("errors",errors);
redirectAttributes.addFlashAttribute("errors",errors); //重定向的写法
//2、校验出错。重定向到注册页
/**
* 使用 return "forward:/reg.html"; 会出现
* 问题:Request method 'POST' not supported的问题
* 原因:用户注册-> /regist[post] ------>转发/reg.html (路径映射默认都是get方式访问的)
*/
// return "foeward:/reg.html"; //转发
// return "reg"; //转发会出现重复提交的问题,不要以转发的方式
//使用重定向 解决重复提交的问题。但面临着数据不能携带的问题,就用RedirectAttributes
return "redirect:http://auth.wulawula.com/reg.html";
}
/***
* 2、校验验证码。调用远程服务,真正的进行注册
*/
//1、校验验证码
String code = vo.getCode();
String rediscode = stringRedisTemplate.opsForValue().get(AuthServerConstant.SMS_CODE_CACHE + vo.getPhone());
//判断redis里是否有验证码
if (! StringUtils.isEmpty(rediscode)){
String s = rediscode.split("_")[0];
//判断验证码是否正确
if (code.equals(s)){
//校验成功。删除验证码(下次再用旧的验证码就不能通过了) --> 令牌机制
stringRedisTemplate.delete(AuthServerConstant.SMS_CODE_CACHE + vo.getPhone());
//2、验证码通过 真正的注册,调用远程读取进行注册
R r = memberFeignService.regist(vo);
//判断状态码
if (r.getCode() == 0){
//成功
return "redirect:http:/login.html";
}else {
//失败 异常
HashMap<String, String> errors = new HashMap<>();
errors.put("msg",r.getData(new TypeReference<String>(){}));
redirectAttributes.addFlashAttribute("errors",errors);
return "redirect:http://auth.wulawula.com/reg.html";
}
}else {
//验证码出错
Map<String, String> errors = new HashMap<>();
errors.put("code","验证码错误");
redirectAttributes.addFlashAttribute("errors",errors); //重定向的写法
return "redirect:http://auth.wulawula.com/reg.html";
}
}else {
//redis里没有验证码
Map<String, String> errors = new HashMap<>();
errors.put("code","验证码错误");
redirectAttributes.addFlashAttribute("errors",errors); //重定向的写法
return "redirect:http://auth.wulawula.com/reg.html";
}
//3、注册成功,回到登录页
// return "redirect:http://auth.wulawula.com/login.html";
// return "redirect:http:/login.html"; //重定向
}
转发和重定向的区别
forward 【转发】 | redirect 【重定向】 | |
---|---|---|
地址栏 | 服务器的直接跳转,客户端浏览器并不知道,地址栏内容不变(服务器内部的动作) | 为客户端浏览器根据url地址重新向服务器请求,地址栏变(有可能是请求的URI地址发生变化) |
数据共享 | 共享浏览器传来的request | 全新的request |
运用的地方 | 用户登录后根据角色跳转页面 | 在用户注销后跳转主页或其他页面 |
效率 | 较高(比重定向少了一次服务器请求) | 较低 |
05、简单登录
被调用的服务 -> 会员服务
- 错误状态码枚举类
public enum BizCodeEnume {
UNKNOWN_EXCEPTION(10000,"系统未知异常"),
VAILD_EXCEPTION(10001,"参数格式校验失败"),
PRODUCT_UP_EXCEPTION(11000,"商品上架异常"),
SMS_CODE_EXCEPTION(10002,"验证码获取频率太高,稍后再试"),
USER_EXIST_EXCEPTION(15001,"用户名已存在"),
PHONE_EXIST_EXCEPTION(15002,"手机号已存在"),
LOGINACCT_PASSWORD_INVAILD_EXCEPTION(15003,"账号或密码错误");
private int code;
private String msg;
BizCodeEnume(int code,String msg){
this.code = code;
this.msg = msg;
}
public int getCode(){
return code;
}
public String getMsg(){
return msg;
}
}
- vo 【封装登陆传过来的数据】
@Data
public class MemberLoginVo {
private String loginacct;
private String password;
}
- controller 【会员的登录功能 -> 远程调用】
@PostMapping("/login")
public R login(@RequestBody MemberLoginVo vo){
MemberEntity entity = memberService.login(vo);
if (entity != null){
return R.ok();
}else {
return R.error(BizCodeEnume.LOGINACCT_PASSWORD_INVAILD_EXCEPTION.getCode(),BizCodeEnume.LOGINACCT_PASSWORD_INVAILD_EXCEPTION.getMsg());
}
}
- MemberServiceImpl 【会员的登录功能】
因为是使用的 BCryptPasswordEncoder 加密。必须去数据库查询,然后解密比对
@Service("memberService")
public class MemberServiceImpl extends ServiceImpl<MemberDao, MemberEntity> implements MemberService {
@Override
public MemberEntity login(MemberLoginVo vo) {
String loginacct = vo.getLoginacct();
String password = vo.getPassword();
//1、去数据库查询 SELECT * FROM ums_member WHERE username = ? OR mobile = ?
MemberDao memberDao = this.baseMapper;
//用户名和手机号都可以当作登录名使用
MemberEntity entity = memberDao.selectOne(new QueryWrapper<MemberEntity>().eq("username", loginacct).or().eq("mobile",loginacct));
if (entity == null){
//登陆失败 手机号或用户名匹配不上
return null;
}else {
//判断密码是否匹配 需要解密
String passwordDb = entity.getPassword();
//解密
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
//2、进行密码匹配 参数1:明文密码 参数2:数据库查到的加密密码
boolean matches = passwordEncoder.matches(password, passwordDb);
if (matches){
return entity;
}else {
return null;
}
}
}
}
调用的服务 -> 登录注册服务
通过会员服务远程调用登录接口
- 如果调用成功,重定向至首页
- 如果调用失败,则封装错误信息并携带错误信息重定向至登录页
- vo 【封装前台传来的数据】
//登录数据封装
@Data
public class UserLoginVo {
private String loginacct;
private String password;
}
- feign
@FeignClient("gulimall-member")
public interface MemberFeignService {
//会员的登录功能 -> 远程调用
@PostMapping("/member/member/login")
R login(@RequestBody UserLoginVo vo);
}
- controller 【登录】
@Controller
public class LoginController {
@Autowired
MemberFeignService memberFeignService; //调用远程服务
@PostMapping("/login")
public String login(UserLoginVo vo,RedirectAttributes result){
//远程登陆
R r = memberFeignService.login(vo);
if (r.getCode() == 0){
//成功 -> 首页
return "redirect:http://wulawula.com"; //重定向
}else {
//失败 -> 登录页
HashMap<String, String> errors = new HashMap<>();
errors.put("msg",r.getData("msg",new TypeReference<String>(){}));
result.addFlashAttribute("errors",errors);
System.out.println(errors);
return "redirect:http://auth.wulawula.com/login.html"; //重定向
}
}
}
- html
<div class="si_bom1 tab" style="display: none;">
<div class="error">
<div></div>
请输入账户名和密码
</div>
<form action="/login" method="post">
<!-- <div style="color: red" th:text="${errors != null ? (#maps.containsKey(errors,'msg')?errors.msg:''):''}"></div>-->
<ul>
<li class="top_1">
<img src="/static/login/JD_img/user_03.png" class="err_img1"/>
<input type="text" name="loginacct" placeholder=" 邮箱/用户名/已验证手机" class="user"/>
</li>
<li>
<img src="/static/login/JD_img/user_06.png" class="err_img2"/>
<input type="password" name="password" placeholder=" 密码" class="password"/>
</li>
<li class="bri">
<a href="">忘记密码</a>
<div style="color: red" th:text="${errors != null ? (#maps.containsKey(errors,'msg')?errors.msg:''):''}"></div>
</li>
<li class="ent">
<button class="btn2" type="submit"><a>登 录</a></button>
</li>
</ul>
</form>
</div>
06、社交登录 OAuth2.0
以
微博作为社交账号
进行社交登录为例
1、进入微博开放平台且登陆自己的微博账号
https://open.weibo.com/
2、点击 微连接 -> 网站接入 -> 立即接入
3、设置回调页面路径
4、点击 文档 -> OAuth2.0授权认证 【里面有需要的资源】
5、引导需要授权的用户到如下地址:URL GET请求
https://api.weibo.com/oauth2/authorize?client_id=
YOUR_CLIENT_ID&response_type=code&redirect_uri=
YOUR_REGISTERED_REDIRECT_URI
- YOUR_CLIENT_ID:你申请的应用的AppKey
- YOUR_REGISTERED_REDIRECT_URI:登陆之后重定向的跳转URI(授权回调页)
HTML
<li>
<a href="https://api.weibo.com/oauth2/authorize?client_id=2416521972&response_type=code&redirect_uri=http://wulawula.com/success">
<img style="width: 50px;height: 18px" src="/static/login/JD_img/weibo.png" />
</a>
</li>
点击登陆之后。跳转成功
6、如果用户同意授权,页面跳转至 授权回调页/?code=CODE
code是我们用来换取令牌的参数
http://wulawula.com/success?code=f53d29f843b681a28d1fe6321f00476e
7、 换取Access Token(访问令牌)URL Code只能换取一次Access Token
https://api.weibo.com/oauth2/access_token?client_id=
YOUR_CLIENT_ID&client_secret=
YOUR_CLIENT_SECRET&grant_type=authorization_code&redirect_uri=
YOUR_REGISTERED_REDIRECT_URI&code
=CODE
- YOUR_CLIENT_ID: 你申请的应用的
AppKey
- YOUR_CLIENT_SECRET: 创建网站应用时的
App Secret
- YOUR_REGISTERED_REDIRECT_URI: 登陆之后重定向的跳转URI(授权回调页)
- CODE:换取令牌的认证码
https://api.weibo.com/oauth2/access_token?client_id=2416521972&client_secret=bb9dd96f8c51c01799ab5915672b6cdb&grant_type=authorization_code&redirect_uri=http://wulawula.com/success&code=f53d29f843b681a28d1fe6321f00476e
使用PostMan测试得到以下JSON POST请求
可以发现得到了 Access Token
{
"access_token": "2.00iOICcGqkTXdC0ca7b7d7e5moPOSC",
"remind_in": "157679999",
"expires_in": 157679999,
"uid": "6058806080",
"isRealName": "true"
}
8、使用获得的Access Token
调用API
点击接口管理 -> 根据用户ID获取用户信息 -> 测试接口示例:
https://api.weibo.com/2/users/show.json?access_token=2.00iOICcGqkTXdC0ca7b7d7e5moPOSC&uid=6058806080
使用PostMan测试得到以下JSON POST请求
{
"id": 6058806080,
"idstr": "个人信息......",
"class": 个人信息......,
"screen_name": "个人信息......",
"name": "个人信息......",
"province": "个人信息......",
"city": "个人信息......",
"location": "个人信息......",
"description": "个人信息......",
......
}
1、如果用户同意授权,页面跳转至 授权回调页/?code=CODE code是我们用来换取令牌的参数
http://wulawula.com/success?code=f53d29f843b681a28d1fe6321f00476e
此处也应该屏蔽掉
2、应用的 client_id
和 client_secret
应该都是保密的,不应该由页面处理,应该由服务器后台处理
修改授权回调页 http://auth.wulawula.com/oauth2.0/weibo/success
登陆之后重定向的跳转URI
流程图
被调用的服务
- SocialUser 【社交登录封装远程调用的数据。注册进会员表】
@Data
public class SocialUser {
private String access_token;
private String remind_in;
private long expires_in;
private String uid; //社交id
private String isRealName;
}
- MemberOAuth2Entity 【封装数据的实体类】
@Data
@TableName("ums_member_oauth2")
public class MemberOAuth2Entity implements Serializable {
private static final long serialVersionUID = 1L;
// id
private Long id;
// 会员等级id
private Long levelId;
// 用户名
private String username;
// 密码
private String password;
// 昵称
private String nickname;
// 手机号码
private String mobile;
// 邮箱
private String email;
// 头像
private String header;
// 性别
private Integer gender;
// 生日
private Date birth;
// 所在城市
private String city;
// 职业
private String job;
// 个性签名
private String sign;
// 用户来源
private Integer sourceType;
// 积分
private Integer integration;
// 成长值
private Integer growth;
// 启用状态
private Integer status;
// 注册时间
private Date createTime;
// 社交帐号id
private String socialUid;
// 社交帐号访问令牌
private String accessToken;
// 社交帐号访问令牌的过期时间
private Long expiresIn;
}
- MemberOAuth2Controller
- 登录包含两种流程,实际上包括了注册和登录
- 如果之前未使用该社交账号登录,则使用
token
调用开放api获取社交账号相关信息,注册并将结果返回 - 如果之前已经使用该社交账号登录,则更新
token
并将结果返回
@RestController
@RequestMapping("/member/oauth")
public class MemberOAuth2Controller {
@Autowired
MemberOAuth2Service memberOAuth2Service;
/***
* 社交用户(登录+注册 合并) -> 远程调用
*/
@PostMapping("/oauth2/login")
public R oauthlogin(@RequestBody SocialUser socialUser) throws Exception {
MemberOAuth2Entity entity = memberOAuth2Service.oauthlogin(socialUser);
if (entity != null){
//TODO 1、登陆成功处理
return R.ok().setData(entity);
}else {
return R.error(BizCodeEnume.LOGINACCT_PASSWORD_INVAILD_EXCEPTION.getCode(),BizCodeEnume.LOGINACCT_PASSWORD_INVAILD_EXCEPTION.getMsg());
}
}
}
- MemberOAuth2ServiceImpl
@Service("memberOAuth2Service")
public class MemberOAuth2ServiceImpl extends ServiceImpl<MemberOAuth2Dao, MemberOAuth2Entity> implements MemberOAuth2Service {
@Autowired
MemberOAuth2Dao memberOAuth2Dao;
//社交用户(登录+注册 合并) -> 远程调用
@Override
public MemberOAuth2Entity oauthlogin(SocialUser socialUser) throws Exception {
String uid = socialUser.getUid();
//1、判断当前社交用户是否已经登陆过系统
MemberOAuth2Dao memberDao = this.baseMapper;
MemberOAuth2Entity memberOAuth2Entity = memberDao.selectOne(new QueryWrapper<MemberOAuth2Entity>().eq("social_uid", uid));
if (memberOAuth2Entity != null){
//------------------登录------------------
//2、这个用户已经注册过了
MemberOAuth2Entity update = new MemberOAuth2Entity();
update.setId(memberOAuth2Entity.getId());
update.setAccessToken(socialUser.getAccess_token());
update.setExpiresIn(socialUser.getExpires_in());
memberDao.updateById(update);
memberOAuth2Entity.setAccessToken(socialUser.getAccess_token());
memberOAuth2Entity.setExpiresIn(socialUser.getExpires_in());
return memberOAuth2Entity;
}else {
//------------------注册------------------
//3、没有查到当前社交用户对应的记录,注册
MemberOAuth2Entity regist = new MemberOAuth2Entity();
//4、查询当前社交用户的社交登录账号(昵称、性别等)
try {
HashMap<String, String> query = new HashMap<>();
query.put("access_token",socialUser.getAccess_token());
query.put("uid",socialUser.getUid());
HttpResponse response = HttpUtils.doGet("https://api.weibo.com", "/2/users/show.json", "get", new HashMap<String,String>(), query);
if (response.getStatusLine().getStatusCode() == 200){ //返回状态码 200
//查询成功
String json = EntityUtils.toString(response.getEntity()); //得到请求体对象转为JSON
JSONObject jsonObject = JSON.parseObject(json); //直接从json里取值
String name = jsonObject.getString("name"); //昵称
String gender = jsonObject.getString("gender"); //性别
//---将查询到的信息写入实体类---
regist.setNickname(name);
regist.setGender("m".equals(gender)? 1 : 0);
}
}catch (Exception e){
e.printStackTrace();
}
regist.setSocialUid(socialUser.getUid());
regist.setAccessToken(socialUser.getAccess_token());
regist.setExpiresIn(socialUser.getExpires_in());
System.out.println("regist =" + regist);
memberDao.insert(regist);
return regist;
}
}
}
调用的服务
SocialUser 和 MemberOAuth2ResponseVo 分别对应被调用程序的 SocialUser 和 **MemberOAuth2Entity **
- feign
@FeignClient("gulimall-member")
public interface MemberFeignService {
//社交用户(登录+注册 合并) -> 远程调用
@PostMapping("/member/oauth/oauth2/login")
R oauthlogin(@RequestBody SocialUser socialUser);
}
- OAuth2Controller 【处理社交登录请求】
- 通过
HttpUtils
发送请求获取token
,并将token
等信息交给member
服务进行社交登录 - 若获取
token
失败或远程调用服务失败,则封装错误信息重新转回登录页
@Slf4j
@Controller
public class OAuth2Controller {
@Autowired
MemberFeignService memberFeignService;
/***
* 根据用户登录得到的code换取Access Token
*/
@RequestMapping("/oauth2.0/weibo/success")
public String weibo(@RequestParam("code") String code) throws Exception {
//1、根据code换取Access Token -> 说明登陆成功
HashMap<String, String> map = new HashMap<>();
map.put("client_id","24******72");
map.put("client_secret","bb9dd96f8c************15672b6cdb");
map.put("grant_type","authorization_code");
map.put("redirect_uri","http://auth.wulawula.com/oauth2.0/weibo/success");
map.put("code",code);
/***
* 参数1:换取Access Token的主机地址
* 参数2:path给哪里发请求
* 参数3:请求方式
* 参数4:请求头
* 参数5:查询参数
* 参数6:请求体
*/
Map<String, String> headers = new HashMap<>();
HttpResponse response = HttpUtils.doPost("https://api.weibo.com", "/oauth2/access_token", "post", headers, null, map);
//2、处理
//获取响应状态码
if (response.getStatusLine().getStatusCode() == 200){
//成功 -> 获取到了 Access Token
// response.getEntity() 获取到响应体内容
// EntityUtils.toString() 可以将响应体内容转换为JSON
String json = EntityUtils.toString(response.getEntity());
// JSON -> 实体类对象
SocialUser socialUser = JSON.parseObject(json, SocialUser.class);
//知道是哪个社交用户了
// 3.如果用户是第一次进来 自动注册进来(为当前社交用户生成一个会员信息 以后这个账户就会关联这个账号)
//登陆或者注册这个社交用户
R login = memberFeignService.oauthlogin(socialUser);
if (login.getCode() == 0){
MemberOAuth2ResponseVo data = login.getData("data", new TypeReference<MemberOAuth2ResponseVo>() {
});
System.out.println("登陆成功...... 用户信息: " + data);
log.info("登陆成功,用户信息:{}");
//登陆成功,跳回首页
return "redirect:http://wulawula.com";
}else {
//失败 -> 重定向到登陆页
return "redirect:http://auth.wulawula.com/login.html";
}
}else {
//失败 -> 重定向到登陆页
return "redirect:http://auth.wulawula.com/login.html";
}
}
}
07、Session
jsessionid
相当于银行卡卡号,存在服务器的session
相当于存储的现金,每次都能通过jsessionid
取出现金
分布式下session的共享问题
- 问题1、同一服务的集群,session不同步问题
如果第一次访问1号服务器,且保存了cookie在1号服务器的内存空间中,因为是分布式集群环境,第2次可能访问2号服务器,虽然请求也携带了cookie,但是之前cookie存的是在1号服务器的内存中,2号服务器并没有,因此也会出现问题
- 问题2、:不同服务、不同域名的session跨域问题
正常情况下session
不可跨域,它有自己的作用范围,session只能在当前域名(Domain
)下生效,域名一换,session就找不到了
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jbZNR4Ji-1608222854287)(D:\Java项目\00_A_Java笔记\小技术点笔记配套图片\注册登录12.png)]
问题1解决方式:同一服务的集群,session不同步问题
1、session复制 不推荐
2、客户端存储 不推荐
3、hash一致性
4、统一存储 整合 Redis + SpringSession 推荐
问题2解决方式:不同服务、不同域名的session跨域问题
示例:父域名:wulawula.com 子域名:auth.wulawula.com order.wulawula.com
示例:第一次浏览器在会员服务里登陆成功,会员服务会将数据存到session,且session不在自己服务的内存里存储,让其在redis里存储,然后给浏览器返回cookie,此处让这个cookie的JSESSIONID对应的的作用域不能只是自己的服务,此处应该放大作用域,由子域名放大到父域名,使得以后的访问都由父域名进行访问,且数据都统一存储在redis中,就算要使用JSESSIONID取出对应的数据,都要去Redis里去查询
08、整合SpringSession
因为是分布式环境。登录模块为 gulimall-auth-server。 首页模块为 gulimall-product。公共模块为 gulimall-common
gulimall-common
- MemberOAuth2ResponseVo 【序列化】
@ToString
@Data
//由于SpringSession默认使用jdk进行序列化,通过导入RedisSerializer修改为json序列化
public class MemberOAuth2ResponseVo implements Serializable {
// id
private Long id;
// 会员等级id
private Long levelId;
// 用户名
private String username;
// 密码
private String password;
// 昵称
private String nickname;
// 手机号码
private String mobile;
// 邮箱
private String email;
// 头像
private String header;
// 性别
private Integer gender;
// 生日
private Date birth;
// 所在城市
private String city;
// 职业
private String job;
// 个性签名
private String sign;
// 用户来源
private Integer sourceType;
// 积分
private Integer integration;
// 成长值
private Integer growth;
// 启用状态
private Integer status;
// 注册时间
private Date createTime;
// 社交帐号id
private String socialUid;
// 社交帐号访问令牌
private String accessToken;
// 社交帐号访问令牌的过期时间
private Long expiresIn;
}
gulimall-auth-server
- pom
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
- properties
###配置SpringSession
#SpringSession的保存类型
spring.session.store-type=redis
#session的过期时间 (默认30分钟)
server.servlet.session.timeout=30m
启动类添加注解 整合Redis作为session的存储 @EnableRedisHttpSession
- 自定义配置 config 【两个模块都需要加】
/***
* 需要解决子域共享问题
*/
@Configuration
public class GulimallSessionConfig {
//需要解决子域共享问题 -> 默认是子域,需要改为父域
@Bean
public CookieSerializer cookieSerializer(){
DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
cookieSerializer.setDomainName("wulawula.com"); //指定作用域为父域 而不是子域
cookieSerializer.setCookieName("WULA_SESSION"); //修改名字
return cookieSerializer;
}
//使用JSON的序列化方式来序列化对象数据到redis中 -> redis中看到的就不是二进制字符了
@Bean
public RedisSerializer<Object> springSessionDefaultRedisSerializer(){
return new GenericJackson2JsonRedisSerializer();
}
}
- controller
@Slf4j
@Controller
public class OAuth2Controller {
@Autowired
MemberFeignService memberFeignService;
/***
* 根据用户登录得到的code换取Access Token
* @param code
* @return
* @throws Exception
*/
@RequestMapping("/oauth2.0/weibo/success")
public String weibo(@RequestParam("code") String code, HttpSession session) throws Exception {
//1、根据code换取Access Token -> 说明登陆成功
HashMap<String, String> map = new HashMap<>();
map.put("client_id","2416521972");
map.put("client_secret","bb9dd96f8c51c01799ab5915672b6cdb");
map.put("grant_type","authorization_code");
map.put("redirect_uri","http://auth.wulawula.com/oauth2.0/weibo/success");
map.put("code",code);
/***
* 参数1:换取Access Token的主机地址
* 参数2:path给哪里发请求
* 参数3:请求方式
* 参数4:请求头
* 参数5:查询参数
* 参数6:请求体
*/
Map<String, String> headers = new HashMap<>();
HttpResponse response = HttpUtils.doPost("https://api.weibo.com", "/oauth2/access_token", "post", headers, null, map);
//2、处理
//获取响应状态码
if (response.getStatusLine().getStatusCode() == 200){
System.out.println("获取token成功");
//成功 -> 获取到了 Access Token
// response.getEntity() 获取到响应体内容
// EntityUtils.toString() 可以将响应体内容转换为JSON
String json = EntityUtils.toString(response.getEntity());
// JSON -> 实体类对象
SocialUser socialUser = JSON.parseObject(json, SocialUser.class);
//知道是哪个社交用户了
// 3.如果用户是第一次进来 自动注册进来(为当前社交用户生成一个会员信息 以后这个账户就会关联这个账号)
//登陆或者注册这个社交用户
R login = memberFeignService.oauthlogin(socialUser);
if (login.getCode() == 0){
System.out.println("登陆成功");
MemberOAuth2ResponseVo data = login.getData("data", new TypeReference<MemberOAuth2ResponseVo>() {
});
System.out.println("登陆成功...... 用户信息: " + data);
log.info("登陆成功,用户信息:{}");
/*session---------------------------------------------*/
//TODO 1、默认发的令牌.session=唯一的字符串。作用域为当前域 (需要解决子域共享问题)
//TODO 2、使用JSON的序列化方式来序列化对象数据到redis中
session.setAttribute("loginUser",data); //直接加入
/*--------------------------------------------------*/
//登陆成功,跳回首页
return "redirect:http://wulawula.com";
}else {
System.out.println("登陆失败");
//失败 -> 重定向到登陆页
return "redirect:http://auth.wulawula.com/login.html";
}
}else {
//失败 -> 重定向到登陆页
System.out.println("获取token失败");
return "redirect:http://auth.wulawula.com/login.html";
}
}
}
gulimall-product
- pom
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
- properties
###配置SpringSession
#SpringSession的保存类型
spring.session.store-type=redis
启动类添加注解 整合Redis作为session的存储 @EnableRedisHttpSession
- 自定义配置 config 【两个模块都需要加】
/***
* 需要解决子域共享问题
*/
@Configuration
public class GulimallSessionConfig {
//需要解决子域共享问题 -> 默认是子域,需要改为父域
@Bean
public CookieSerializer cookieSerializer(){
DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
cookieSerializer.setDomainName("wulawula.com"); //指定作用域为父域 而不是子域
cookieSerializer.setCookieName("WULA_SESSION"); //修改名字
return cookieSerializer;
}
//使用JSON的序列化方式来序列化对象数据到redis中 -> redis中看到的就不是二进制字符了
@Bean
public RedisSerializer<Object> springSessionDefaultRedisSerializer(){
return new GenericJackson2JsonRedisSerializer();
}
}
- js 此处使用三元判断判断显示昵称问题
<li>
<a href="http://auth.wulawula.com/login.html">你好,请登录 [[${session.loginUser==null ? '' : session.loginUser.nickname}]]</a>
</li>
<li>
<a href="http://auth.wulawula.com/reg.html" class="li_2">免费注册</a>
</li>
自动延期:redis中的数据也是有过期时间的
SpringSession核心原理 - 装饰者模式
- 原生的获取
session
时是通过HttpServletRequest
获取的 SpringSession是包装过的
@EnableRedisHttpSession导入RedisHttpSessionConfiguration.class配置
- 1、给容器中添加了一个组件
SessionRepository=》》》 【RedisIndexedSessionRepository】=>redis操作session.session的增删改查的封装类
- 2、SessionRepositoryFilter=》Filter: session存储过滤器,每个请求过来都必须经过filter
- 1、创建的时候,就自动从容器中获取到了SessionRepository:
- 2、原生的request,response都
被包装
。SessionRepositoryRequestWrapper,SessionRepositoryResponseWrapper - 3、以前获取session。 request.getSession()
- 4、以后获取session。wrapperedRequest.getSession();===>SressionRepository中获取到
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
request.setAttribute(SESSION_REPOSITORY_ATTR, this.sessionRepository);
//包装原始的请求对象
SessionRepositoryRequestWrapper wrappedRequest = new SessionRepositoryRequestWrapper(
request, response, this.servletContext);
//包装原始的响应对象
SessionRepositoryResponseWrapper wrappedResponse = new SessionRepositoryResponseWrapper(
wrappedRequest, response);
try {
//此执行链使用的是包装后的对象
filterChain.doFilter(wrappedRequest, wrappedResponse); //使得我们获取的session都是包装过的
}
finally {
wrappedRequest.commitSession();
}
}
09、细节修改
- gulimall-common 的 AuthServerConstant
- 存redis时定义的key
public class AuthServerConstant {
public static final String SMS_CODE_CACHE= "sms:code:"; //redis存取验证码的前缀
public static final String LOGIN_USER= "loginUser"; //redis存取登陆的key的前缀
}
- gulimall-auth-server 的 LoginController
- 尝试获取session。有session就是登陆了 -> 首页 。 没有session就是没登陆 -> 登录页
@GetMapping("/login.html")
public String loginPage(HttpSession session){
Object attribute = session.getAttribute(AuthServerConstant.LOGIN_USER);
if (attribute == null){
//没登陆
return "login";
}else {
//登陆了
return "redirect:http://wulawula.com"; //重定向
}
}
- GulimallConfig 【视图映射】
需要判断是否有session。视图映射不能直接跳转。
@Configuration
public class GulimallConfig implements WebMvcConfigurer {
@Override
public void addViewControllers(ViewControllerRegistry registry) {
/* 参数1:url路径 参数2:视图名 */
// registry.addViewController("/login.html").setViewName("login");
registry.addViewController("/reg.html").setViewName("reg");
}
}
es服务也需要加入 08整合SpringSession中的 pom、properties、自定义配置 config、以及redis的pom
只要session没过期,让其在首页、搜索页、商品页都显示登陆的会员的昵称
- 示例 html
<li>
<a href="http://auth.wulawula.com/login.html" class="li_2" th:if="${session.loginUser == null}">你好,请登录</a>
<a class="li_2" th:else style="width: 100px">欢迎:[[${session.loginUser.nickname}]]</a>
</li>
<li>
<a href="http://auth.wulawula.com/reg.html" th:if="${session.loginUser == null}">免费注册</a>
</li>
010、单点登录
希望一个账号登陆旗下多个不同应用。一处登录处处登录。一处退出处处退出
1、配置XXL-SSO
XXL-SSO 是三个分布式单点登录框架。只需要登录一次就可以访问所有相互信任的应用系统。
拥有"轻量级、分布式、跨域、Cookie+Token均支持、Web+APP均支持""等特性。现已开放源代码,开箱即用。
编排:
- ssoserver.com 登录认证服务器
- client1.com 客户端1
- client2.com 客户端2
1、修改SwitchHosts
# XLL-OOS
127.0.0.1 ssoserver.com
127.0.0.1 client1.com
127.0.0.1 client2.com
修改 xxl-sso配置文件 application.properties 【且知道不同的端口号以及访问路径】
8080/xxl-sso-server
8081/xxl-sso-web-sample-springboot
2、打包gitee项目
删除 .git文件 --> 在xxl-sso页 cmd --> mvn clean package -Dmaven.skip.test=true
D:\Java项目\单点登录demo\xxl-sso>mvn clean package -Dmaven.skip.test=true
3、启动 在target页 cmd --> java -jar xxl-sso-server-1.1.1-SNAPSHOT.jar
D:\Java项目\单点登录demo\xxl-sso\xxl-sso-server\target>java -jar xxl-sso-server-1.1.1-SNAPSHOT.jar
访问 http://ssoserver.com:8080/xxl-sso-server/login
4、启动客户端 方法同3 【一个客户端启动两次,修改端口号即可】
第一个加上server.port=8081
D:\Java项目\单点登录demo\xxl-sso\xxl-sso-samples\xxl-sso-web-sample-springboot\target>java -jar xxl-sso-web-sample-springboot-1.1.1-SNAPSHOT.jar --server.port=8081
访问 http://client1.com:8081/xxl-sso-web-sample-springboot/
第二个加上server.port=8082
D:\Java项目\单点登录demo\xxl-sso\xxl-sso-samples\xxl-sso-web-sample-springboot\target>java -jar xxl-sso-web-sample-springboot-1.1.1-SNAPSHOT.jar --server.port=8082
访问 http://client1.com:8082/xxl-sso-web-sample-springboot/
2、测试
略…
未完 接下一章.