文章目录
- Product:商品上架
- 商城业务:商城首页
- 性能压测(查找优化点)
- 分布式锁与缓存
- 商城业务:检索服务
- 异步编程学习
- 商城业务:商品详情页
- 商城业务:认证服务(重点)
- 商城业务:购物车
Product:商品上架
ES学习
ES的学习链接基础学习&&spring整合
SKU在es中的存储模型
采用空间换时间
PUT product
{
"mappings":{
"properties": {
"skuId":{ "type": "long" },
"spuId":{ "type": "keyword" }, # 不可分词
"skuTitle": {
"type": "text",
"analyzer": "ik_smart" # 中文分词器
},
"skuPrice": { "type": "keyword" }, # 保证精度问题
"skuImg" : { "type": "keyword" }, # 视频中有false
"saleCount":{ "type":"long" },
"hasStock": { "type": "boolean" },
"hotScore": { "type": "long" },
"brandId": { "type": "long" },
"catalogId": { "type": "long" },
"brandName": {"type": "keyword"}, # 视频中有false
"brandImg":{
"type": "keyword",
"index": false, # 不可被检索,不生成index,只用做页面使用
"doc_values": false # 不可被聚合,默认为true
},
"catalogName": {"type": "keyword" }, # 视频里有false
"attrs": {
"type": "nested",
"properties": {
"attrId": {"type": "long" },
"attrName": {
"type": "keyword",
"index": false,
"doc_values": false
},
"attrValue": {"type": "keyword" }
}
}
}
}
}
针对attr字段采用嵌入式的数据的方式:
构造基本数据
注意这个检索的数据是在product和search微服务之间调用的传输数据,所以将javabean封装在common的模块下
@Data
public class SkuEsModel implements Serializable {
private Long skuId;
private Long spuId;
private String skuTitle;
private BigDecimal skuPrice;
private String skuImg;
private Long saleCount;//销售数量
private Boolean hasStock;//库存与否
private Long hotScore;//热度评分
private Long brandId;
private Long catalogId;
private String brandName;
private String brandImg;
private String catalogName;
private List<Attrs> attrs;//嵌入式的属性字段
/**
* 检索属性:使用静态内部类
*/
@Data
public static class Attrs implements Serializable {
private Long attrId;
private String attrName;
private String attrValue;
}
}
业务逻辑代码
仓库是否为空(ware微服务调用)
需要判断仓库是否为空,所以需要product的微服务,远程调用ware的微服务
封装一个记录是否含有库存的sku的vo对象,实际上是一个openfeign的远程传输对象
ES远程上架接口(es微服务调用)
在kibana中创建product的索引(要使用kibana之前先重启服务器,确保负载不会阻塞,再重新开启docker的es和kibana这两个服务,mysql也不要开了)
postman查询成功:
总体的product的商品上架逻辑代码(p134学习debug)
/**
* 不一样的属性:skuPrice、skuImg、hasStock、hotScore、
* brandName、brandImg、catalogName、attrs
* spuId-> 商品和属性关联 productAttrValue s -> attrIds -> attrs ->过滤 -> SkuEsModel.Attrs
* -> skuIds->
*
* @param spuId
*/
@Override // SpuInfoServiceImpl
public void up(Long spuId) {
// 1 组装数据 查出当前spuId对应的所有sku信息
List<SkuInfoEntity> skus = skuInfoService.getSkusBySpuId(spuId);
// 2 封装每个sku的信息
List<Long> skuids = skus.stream().map(sku -> sku.getSkuId()).collect(Collectors.toList());
// 3.查询当前sku所有可以被用来检索的规格属性
List<ProductAttrValueEntity> baseAttrs = attrValueService.baseAttrListForSpu(spuId);
// 得到基本属性(规格属性的)id
List<Long> attrIds = baseAttrs.stream().map(attr -> attr.getAttrId()).collect(Collectors.toList());
// 过滤出可被检索的基本属性id,即search_type = 1 [数据库中目前 4、5、6、11不可检索]
Set<Long> ids = new HashSet<Long>(attrService.selectSearchAttrIds(attrIds));
// 可被检索的属性封装到SkuEsModel.Attrs中(套娃:静态内部类)
List<SkuEsModel.Attrs> attrs = baseAttrs.stream()
.filter(item -> ids.contains(item.getAttrId()))
.map(item -> {
SkuEsModel.Attrs attr = new SkuEsModel.Attrs();
BeanUtils.copyProperties(item, attr);
return attr;
}).collect(Collectors.toList());
// 每件skuId是否有库存(注意:只有有库存的sku才能进行上架)
//以skuid为键,以是否有库存为值(sku_id,isHasStock)
Map<Long, Boolean> stockMap = null;
try {
// 3.1 “远程调用”--Openfeign库存系统 查询该sku是否有库存(调用“ware”的微服务)
R hasStock = wareFeignService.getSkuHasStock(skuids);
// 构造器受保护 所以写成内部类对象
stockMap = hasStock.getData(new TypeReference<List<SkuHasStockVo>>() {})
.stream()
.collect(Collectors.toMap(SkuHasStockVo::getSkuId, item -> item.getHasStock()));//以skuid为键,以是否有库存为值
log.warn("服务调用成功" + hasStock);
} catch (Exception e) {
log.error("库存服务调用失败: 原因{}", e);
}
Map<Long, Boolean> finalStockMap = stockMap;//防止lambda中改变
// 开始封装es,为保存到es数据库中进行的准备的封装工作
List<SkuEsModel> skuEsModels = skus.stream().map(sku -> {
SkuEsModel esModel = new SkuEsModel();
BeanUtils.copyProperties(sku, esModel);
esModel.setSkuPrice(sku.getPrice());
esModel.setSkuImg(sku.getSkuDefaultImg());
// 4 设置库存,只查是否有库存,不查有多少
if (finalStockMap == null) {
esModel.setHasStock(true);
} else {
esModel.setHasStock(finalStockMap.get(sku.getSkuId()));
}
// TODO 1.热度评分 刚上架是0
esModel.setHotScore(0L);
// 设置品牌信息
BrandEntity brandEntity = brandService.getById(esModel.getBrandId());
esModel.setBrandName(brandEntity.getName());
esModel.setBrandImg(brandEntity.getLogo());
// 查询分类信息
CategoryEntity categoryEntity = categoryService.getById(esModel.getCatalogId());
esModel.setCatalogName(categoryEntity.getName());
// 保存商品的属性, 查询当前sku的所有可以被用来检索的规格属性,同一spu都一样,在外面查一遍即可
esModel.setAttrs(attrs);
return esModel;
}).collect(Collectors.toList());
// 5.发给ES进行保存 gulimall-search
R r = searchFeignService.productStatusUp(skuEsModels);
if (r.getCode() == 0) {
// 远程调用成功
baseMapper.updateSpuStatus(spuId, ProductConstant.StatusEnum.SPU_UP.getCode());
} else {
// 远程调用失败 TODO 接口幂等性 重试机制
/**
* Feign 的调用流程 Feign有自动重试机制
* 1. 发送请求执行(成功会返回解码响应的数据)
* 2.执行请求的重试机制,这里是面试的题
*/
}
}
遇到的bug1:@Param的参数类型绑定问题
注意结合类型也需要绑定的操作:虽然是一个变量,实际上代表了一组变量
遇到的bug2:es的index名称要和java程序save的一致
上架的效果
spu商品的状态从新建的状态变为了上架的状态
postman查询es存储的数据:正好命中10条数据:华为sku的6条数据+apple的sku4条数据
商城业务:商城首页
整合thymeleaf渲染首页
利用nginx实现动静分离+Thymeleaf模板引擎
将所有的商城系统业务的页面跳转放在web目录下,将后台管理系统的业务controller改名为app
将商城首页的资源分别放到product的模块的resource下的static和template下面
渲染一级分类数据
渲染二级和三级分类
动态的从数据库获取json数据,注意观察的时候清除浏览器的缓存:
@Override
public Map<String, List<Catelog2Vo>> getCatelogJson() {
List<CategoryEntity> entityList = baseMapper.selectList(null);
// 查询所有一级分类(一级分类的父分类是空的集合)
List<CategoryEntity> level1 = getCategoryEntities(entityList, 0L);
Map<String, List<Catelog2Vo>> parent_cid = level1.stream().collect(Collectors.toMap(k -> k.getCatId().toString(), v -> {
// 拿到每一个一级分类 然后查询他们的二级分类
List<CategoryEntity> entities = getCategoryEntities(entityList, v.getCatId());
List<Catelog2Vo> catelog2Vos = null;
if (entities != null) {
catelog2Vos = entities.stream().map(l2 -> {
Catelog2Vo catelog2Vo = new Catelog2Vo(v.getCatId().toString(),l2.getCatId().toString(),l2.getName(),null);
// 找当前二级分类的三级分类
List<CategoryEntity> level3 = getCategoryEntities(entityList, l2.getCatId());
// 三级分类有数据的情况下
if (level3 != null) {
List<Catalog3Vo> catalog3Vos = level3.stream().map(l3 -> new Catalog3Vo(l2.getCatId().toString(),l3.getCatId().toString(),l3.getName())).collect(Collectors.toList());
catelog2Vo.setCatalog3List(catalog3Vos);
}
return catelog2Vo;
}).collect(Collectors.toList());
}
return catelog2Vos;
}));
return parent_cid;
}
/**
* 第一次查询的所有 CategoryEntity 然后根据 parent_cid去这里找
*/
private List<CategoryEntity> getCategoryEntities(List<CategoryEntity> entityList, Long parent_cid) {
return entityList.stream().filter(item -> item.getParentCid() == parent_cid).collect(Collectors.toList());
}
nginx搭建域名访问环境(nginx位于Gateway之前)–阿里云用不了
nginx代理服务器
电脑的本地的hosts文件(windows–system32–driver–dtc文件下)的解析
映射解析成功
访问域名的时候访问到了服务器的nginx(注意:这里的gulimall.com等于118.31.113.58等于localhost)
复制一份配制为gulimall.conf
windows的主机地址:10.208.4.48等于本电脑的localhost
诡异:能代理安全公网的网站(必须公网ip地址)
问题:我发现我可以代理hao123的官方网址和我csdn的博客主页,但是我就是代理不了我的服务(我不明白了)
诡异:代理linux服务器自己的服务也成功了呀 (没有内网与外网问题–属于同一个局域网内部)
代理服务器上的es的访问首页,也成功了
诡异:我怀疑要给windows主机买一个公网的IP地址(不然无法被阿里云的主机 的nginx进行代理,或者使用内网穿透技术)
不能使用linux自定义的域名(啥玩意儿)
首先代理服务器的域名不能解析成功,可能需要买一个域名(只能暴露出自己的主机地址,这样非常的不安全)
代理不了暴露的windows的主机地址的服务(原因:阿里云与我的windows的服务器不属于同一个局域网,我放到爱丽云服务器上的地址 属于私有IP地址—外网无法直接访问)
可能需要给自己的windows的主机地址买一个域名
性能压测(查找优化点)
Jmeter安装与测试
Jvisualvm使用 (升级版的Jconsole)
安装GC的插件
注意我们需要避免频繁的full GC,即触发老年代回收的GC情况(消耗的时间很长)
中间件对性能的影响
docker stats命令查看容器的cpu使用率
监控gateway(88/)
发现:比较占用cpu
垃圾回收时,eden元区,回收比较耗时,可能提升Eden空间,可以提升吞吐量
监控简单服务
处理简单的服务:吞吐量非常的高,可以达到几万
监控Gateway+普通服务
此时的hello请求需要经过网关这个中间件
发现加了网关的中间件后吞吐量降到了4000多,响应的时间翻倍了
监控首页一级菜单渲染(DB+thymeleaf)
linux服务器上的mysql开始占用cpu
由于需要查询数据库,吞吐量直接100了;响应时间,居然要1000ms了
开启thymeleaf的缓存–》提升渲染速度
提升性能的效果不大
数据库的优化(建索引)
调整sql打印日志的级别,上线后只打印error级别的(开发还是debug模式)
针对一级分类查询,使用parent_cid来判断谁是一级分类,此时为该字段添加普通索引
***动静分离(阿里云搞不了–简单的学习一下)
(nginx在网关之前+nginx直接将静态资源返回(不经过网关)–>减少中间件的经历过程)
在实践中,我们可以通过upstream来反向代理,那么这个时候nginx就变成了一个请求接收服务器,然后加以配置,我们在业务模块需要做的就是通过upstream配合location来扩展业务。
step1:配置nginx.conf的upstream为网关的服务器(由于网关需要代理多个服务器,所以相当于有负载均衡的意思),gmall就是location的conf的名称
==step2: 配置conf.d的server块的location块代理头部是*的服务器的业务(原因这个的服务器业务是由于gateway的配置造成,所有的服务器都需要经过网关),用于拓展业务 ==
实际就是静态资源直接放在nginx里面,编写index页面的时候就是写的nginx的主机服务的地址进行获取静态资源,实际相当于和动态请求进行分离,不在一个微服务的目录下进行访问(原来都是放在微服务的static的目录下面,相当于静态资源与动态请求都只在一个微服务的服务器上,但是微服务的服务器还要经过网关,网关需要经过nginx,多重的中间件。现在静态资源直接在最前面的nginx上,不需要经过微服务的服务器和gateway了,直接渲染的速度变快了)
实际的配置理解
一个server块可以实现多个server_name,相当于指定多个域名
upstream实现负载均衡:如果只有一个网官 的主机,实际可以不用写在upstream,直接写在Proxy——pass中
nginx的配置:
linux主机IP地址与各个域名的映射关系,先配置在windows上,提前告知
我们可以使用"不同的域名host"(代理不同微服务的)来映射"相同的nginx"的主机地址,主要是为了区分网关处的服务(不然从nginx过来的都是相同的ip地址和端口号,无法区分),这样网关处可以根据不同的host转发请求不同的实际的微服务了(我误了!!!)
gateway的配置,将不同的域名(实际是部署nginx的那台linux主机的IP地址)映射到不同的微服务
动态请求的地址,实际相当于原本在nignx的host请求头(本机知道这个映射的关系,陪在host的文件里了),这样先会转发到gateway处,网关进行重新转发到各个微服务的服务器,真实的请求
监控三级分类数据获取的复杂业务 (DB多次)
注意:首页显示的三界分类是使用的ajax异步请求显示,所以首页一级菜单的获取的性能比这个服务好多了
吞吐量只有2了
总结:待优化的点
中间件越多,服务的损耗越多(这个是不可避免的,中间件没办法了)
gateway+简单服务:请求–》网关–》product微服务
全链路:请求–》nginx–》网关–》product微服务
数据库查询的次数越多,时间越长(关闭sql日志的打印+建立索引+业务逻辑的优化+“使用缓存redis”)
模板的渲染速度(开启Thymeleaf的缓存功能)
静态资源的获取速度(nginx的动静分离—实际就是静态请求不用过网关–》直接返回给用户–>减少中间件的经历的步骤,我的阿里云服务器整不了)
分布式锁与缓存
主要是解决数据库的缓存的问题(提升查询的速度),以及缓存的三种异常+缓存与数据库的一致性问题的解决
整合redis测试
@Test
public void testStringRedis() {
ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
//保存
ops.set("hello","world_" + UUID.randomUUID().toString());
//查询
String hello = ops.get("hello");
System.out.println("之前保存的数据:"+hello);
}
改造三级分类业务
redis缓存
发现买的阿里云服务器对带宽限制在了1M,无论怎样性能 都提升不了
/**
* redis无缓存 查询数据库(利用redis缓存进行优化的操作)
*/
private Map<String, List<Catelog2Vo>> getDataFromDB() {
//step1:判断缓存中有没有数据
String catelogJSON = stringRedisTemplate.opsForValue().get("catelogJSON");
if (!StringUtils.isEmpty(catelogJSON)) {
return JSON.parseObject(catelogJSON, new TypeReference<Map<String, List<Catelog2Vo>>>() {
});
}
// step2:如果缓存中没有数据,去数据库mysql中查询
// 优化:将查询变为一次
List<CategoryEntity> entityList = baseMapper.selectList(null);
// 查询所有一级分类
List<CategoryEntity> level1 = getCategoryEntities(entityList, 0L);
Map<String, List<Catelog2Vo>> parent_cid = level1.stream().collect(Collectors.toMap(k -> k.getCatId().toString(), v -> {
// 拿到每一个一级分类 然后查询他们的二级分类
List<CategoryEntity> entities = getCategoryEntities(entityList, v.getCatId());
List<Catelog2Vo> catelog2Vos = null;
if (entities != null) {
catelog2Vos = entities.stream().map(l2 -> {
Catelog2Vo catelog2Vo = new Catelog2Vo(v.getCatId().toString(),l2.getCatId().toString(),l2.getName(),null);
// 找当前二级分类的三级分类
List<CategoryEntity> level3 = getCategoryEntities(entityList, l2.getCatId());
// 三级分类有数据的情况下
if (level3 != null) {
List<Catelog3Vo> catalog3Vos = level3.stream().map(l3 -> new Catelog3Vo(l2.getCatId().toString(),l3.getCatId().toString(),l3.getName())).collect(Collectors.toList());
catelog2Vo.setCatalog3List(catalog3Vos);
}
return catelog2Vo;
}).collect(Collectors.toList());
}
return catelog2Vos;
}));
//step3:将数据库查出的数据存入redis的缓存中
// 优化:查询到数据库就再锁还没结束之前放入缓存
stringRedisTemplate.opsForValue().set("catelogJSON", JSON.toJSONString(parent_cid), 1, TimeUnit.DAYS);
return parent_cid;
}
idea服务的copy操作(有趣)
Redis实现分布式锁(不推荐)
加锁使用redis的原子操作命令set,释放锁使用lua脚本进行执行
/**
* 分布式锁:解决缓存穿透问题
* lua脚本(太强了)
* @return
*/
public Map<String, List<Catelog2Vo>> getCatelogJsonFromDBWithRedisLock() {
// 1.占分布式锁 设置这个锁10秒自动删除 [原子操作]
String uuid = UUID.randomUUID().toString();//确保每个人的锁是自己的锁,设置自己的唯一标识
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", uuid, 30, TimeUnit.SECONDS);//setIfAbsent命令就是setNX命令
if (lock) {
System.out.println("获取锁成功");
// 2.设置过期时间加锁成功 获取数据释放锁 [分布式下必须是Lua脚本删锁,不然会因为业务处理时间、网络延迟等等引起数据还没返回锁过期或者返回的过程中过期 然后把别人的锁删了]
Map<String, List<Catelog2Vo>> data;
try {
data = getDataFromDB();
} finally {//释放锁的过程--确保原子性
String lockValue = stringRedisTemplate.opsForValue().get("lock");//判断是自己的锁才进行删除
// 删除也必须是原子操作 Lua脚本操作 删除成功返回1 否则返回0
/**public <T> T execute(RedisScript<T> script, List<K> keys, Object... args) {
return scriptExecutor.execute(script, keys, args);
} */
String script = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
// 原子删锁
stringRedisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Arrays.asList("lock"), uuid);
}
return data;
} else {
System.out.println("获取锁失败,重试。。。。。");
// 重试加锁
try {
// 登上两百毫秒
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
return getCatelogJsonFromDBWithRedisLock();
}
}
使用jmeter模拟高并发的操作
模拟两台服务器10001和10002,使用两个jemeter进行压测(还是只用原生的redis的依赖)
吞吐量还是不高
发现锁值一直在变化,因为不同的用户线程加锁的序号不同,完成任务后锁就被释放删除了
Redisson实现分布式锁(推荐)
配置工作(bean注入的规范问题–坑)
以后使用redisson作为所有的分布式锁,实现分布式对象等功能
redisson的配置的坑,实际上就是注入的bean对象的方法名必须要是redissonClient,否则就会注入bean失败!!不不不,是我的只能使用3.17.0版本,也不知道是为啥啊???(自动装配还是安装class的名称进行装配的或许!!!!)
@Configuration
public class MyRedissonConfig {
@Value("${ipAddr}")
private String ipAddr;
@Bean(destroyMethod = "shutdown")
RedissonClient redissonClient() throws IOException{
Config config=new Config();
config.useSingleServer().setAddress("redis://" + ipAddr + ":6379");//rediss代表安全模式
return Redisson.create(config);
}
}
测试通过,安心使用
Lock锁(Redisson可重入锁)测试
@ResponseBody
@GetMapping("/hello")
public String hello(){
//获取redissonclient的可重入锁(这个锁就在redis的里面)
RLock lock = redissonClient.getLock("my-lock");
// 阻塞式等待(拿不到锁,就会一直等待,一直阻塞到自己获取锁)
lock.lock();
try {
System.out.println("加锁成功,执行业务");
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println("开始释放锁。。。");
lock.unlock();
}
return "hello";
}
加锁的时候,redis数据库中会存在一把自己命名的锁
注意两台服务器,一台服务器占用锁时,如果宕机,另一个服务器抢锁还是正常的.不会出现不是释放锁的死锁状态(优秀的框架)
即使业务时间很长,也不用担心锁的到期删除,redis会根据业务需求,自动续期锁的过期时间(redisson默认加的锁的过期时间是30秒)
加锁的业务只要运行完成,redis就不会给当前的锁续期,即使不手动释放锁,锁默认在 30秒之后过期
lock锁的看门狗原理–如何解决死锁的问题(源码解读)
注意如果指定了锁的过期时间,最好过期时间大于业务处理的时间
看门狗时间指定
protected void scheduleExpirationRenewal(long threadId) {
ExpirationEntry entry = new ExpirationEntry();
ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);
if (oldEntry != null) {
oldEntry.addThreadId(threadId);
} else {
entry.addThreadId(threadId);
try {
renewExpiration();
} finally {
if (Thread.currentThread().isInterrupted()) {
cancelExpirationRenewal(threadId);
}
}
}
}
具体逻辑代码(简化了redis实现的分布式锁的代码)
利用redisson的同一把锁可重入锁实现
/**
* redisson 微服务集群锁(相当于封装好了原来使用redis实现分布式锁的框架,可靠性更高,封装的功能更好)
* 缓存中的数据如何与数据库保持一致
*/
public Map<String, List<Catelog2Vo>> getCatelogJsonFromDBWithRedissonLock() {
// 这里只要锁的名字一样那锁就是一样的
// 关于锁的粒度 具体缓存的是某个数据 例如: 11-号商品 product-11-lock
RLock lock = redissonClient.getLock("CatelogJson-lock");
lock.lock();
Map<String, List<Catelog2Vo>> data;
try {
data = getDataFromDB();
} finally {
lock.unlock();
}
return data;
}
开启两个jmeter进行压力测试,实现高并发的访问
redisson & canal 解决缓存一致性问题
先写数据库,再写入缓存
先写入数据库,再删除缓存,当从缓存读取发现没有数据时,就会从数据库读取并更新缓存的新值
上面两种方式都存在缺陷,实际我们对业务没有name高的精确性与实时性的要求:
canal伪装成mysql的一个从数据库,从mysql的binlog里读取mysql数据库发生了那些变化,再更新至缓存
我们系统的缓存一致性解决方案
Spring cache(缓存管理器)
同一管理各种的不同的缓存器,如:concurrentHashmap,Redis等等
Springcache的整合与测试
我们使用redis作为缓存,设置缓存类型
@Cacheable注解使用(读模式)
我们只有在第一次缓存中没有数据的时候,才会调用方法,之后不管访问几次首页,都不会调用方法了
自定义缓存配置
同时使用默认配置类的属性,又使用配置文件的配置
@EnableConfigurationProperties(CacheProperties.class)//为了将CacheProperties这个类生效
@EnableCaching//开启缓存
@Configuration
public class MycacheConfig {
/**
*
* 原来:
* @ConfigurationProperties(prefix = "spring.cache")
* public class CacheProperties
*
* 现在要让这个配置文件生效 : @EnableConfigurationProperties(CacheProperties.class),直接使用依赖注入,自动注入CacheProperties这个类
*
*/
@Bean
RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties){
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
// 设置kv的序列化机制(值转换为json格式,key仍然使用redis的序列化机制)
config = config.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()));
config = config.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
CacheProperties.Redis redisproperties = cacheProperties.getRedis();//获取redis的配置类
// 将配置文件中的所有东西进行生效(如果不写,就默认原生的ttl时间,而不是我们的yml中的配置了)
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;
}
}
看:被指定了key的前缀的cache的情况
@CacheEvict注解使用(读写模式)
首先访问主页,触发一次将数据库的数据缓存到redis中
在后台管理系统触发一次商品的更新(调用service的更新业务+加上了@CacheEvict注解),观察redis是否删除了这个缓存
此时原先的缓存已经被删除了,除非重新进行一次首页的访问,就缓存新的值(妙a)
多缓存更新失效机制(更新删除同一分区的所有的缓存,约定:分区名就是缓存的前缀)
同时开启一级菜单和三级商品的json的缓存注解,如果有更新商品时,应该两个缓存都应该改删除
此时,更新的业务的注解应该怎么使用呢??
指定需要失效的缓存的key的具体的位置(成功)
更新数据时,删除指定分区的所有缓存–我们推荐这种方法(只需要制定好相应的业务的设计规则,存储同一类型的数据,就放在同一个value分区的缓存中)
当我们更新数据时,就会删除分区中的所有的缓存,观察发现成功了
SpringCache的原理与不足
商城业务:检索服务
es的学习链接与spring整合链接在此
搭建页面环境
导入相应的静态资源+引入相应的thymeleaf的依赖
调整页面跳转
点击手机,跳转到相应的页面
点击主页的搜索(使用js的函数链接,跳转到相应的函数),跳转到相应的页面
检索查询和返回参数模型分析抽取
由于含有多种查询条件,所以需要封装所有可能的查询属性,可能有很多个限制的查询条件
检索的输入参数:
检索的返回结果:
检索DSL测试
mapping重构+数据迁移
PUT gulimall_product
{
"mappings": {
"properties": {
"skuId":{
"type": "long"
},
"spuId":{
"type": "keyword"
},
"skuTitle":{
"type": "text",
"analyzer": "ik_smart"
},
"skuPrice":{
"type": "keyword"
},
"skuImg":{
"type": "keyword"
},
"saleCount":{
"type": "long"
},
"hasStock":{
"type": "boolean"
},
"hotScore":{
"type": "long"
},
"brandId":{
"type": "long"
},
"catalogId":{
"type": "long"
},
"brandName":{
"type":"keyword"
},
"brandImg":{
"type": "keyword"
},
"catalogName":{
"type": "keyword"
},
"attrs":{
"type": "nested",
"properties": {
"attrId":{
"type":"long"
},
"attrName":{
"type":"keyword"
},
"attrValue":{
"type":"keyword"
}
}
}
}
}
}
新旧索引的数据迁移:注意只是部分字段发生变化
GET gulimall_product/_search
{
## 模糊查询的过滤条件,使用filter和nested(属性值封装--相当于封装为一个独立的个体,防止内部的东西扁平化到所有的查询条件)字段
"query": {
"bool": {
"must": [ {"match": { "skuTitle": "华为" }} ],
"filter": [
{ "term": { "catalogId": "225" } },
{ "terms": {"brandId": [ "5"] } },
{ "term": { "hasStock": "true"} },
{
"range": {
"skuPrice": {
"gte": 1000,
"lte": 7000
}
}
},
## 嵌入式的attr,逆序嵌入式的查询(嵌入式的属性:实际就是内部封装了一个小的整体的对象,防止被扁平化处理)
{
"nested": {
"path": "attrs",
"query": {
"bool": {
"must": [
{
"term": { "attrs.attrId": { "value": "3"} }
}
]
}
}
}
}
]
}
},
## 开始进行聚合查询
"sort": [ {"skuPrice": {"order": "desc" } } ],
"from": 0,
"size": 5,
"highlight": {
"fields": {"skuTitle": {}},
"pre_tags": "<b style='color:red'>",
"post_tags": "</b>"
},
"aggs": {
"brandAgg": {
"terms": {
"field": "brandId",
"size": 10
},
"aggs": {
"brandNameAgg": {
"terms": {
"field": "brandName",
"size": 10
}
},
"brandImgAgg": {
"terms": {
"field": "brandImg",
"size": 10
}
}
}
},
"catalogAgg":{
"terms": {
"field": "catalogId",
"size": 10
},
"aggs": {
"catalogNameAgg": {
"terms": {
"field": "catalogName",
"size": 10
}
}
}
},
## 嵌入式的attr,必须嵌入式的查询
"attrs":{
"nested": {"path": "attrs" },
"aggs": {
"attrIdAgg": {
"terms": {
"field": "attrs.attrId",
"size": 10
},
"aggs": {
"attrNameAgg": {
"terms": {
"field": "attrs.attrName",
"size": 10
}
}
}
}
}
}
}
}
SearchRequset检索请求的构建
将原来准备好的DSL–kibana–json版本的查询聚合语句,转化为RestHighLevelClient执行的java版本,最终获得查询的结果
/**
* 准备检索请求 [构建查询语句---dsl语句]
* 模糊查询(过滤+nested嵌入式查询) +排序+分页+高亮+聚合分析
*/
private SearchRequest buildSearchRequest(SearchParam Param) {
// 帮我们构建DSL语句的源语句,最后放入SearchRequest中
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
// 1. 模糊匹配 过滤(按照属性、分类、品牌、价格区间、库存) 先构建一个布尔Query
// 1.1 must(模糊查询,用户输入的关键字--按照品牌名称进行查询)
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
if(!StringUtils.isEmpty(Param.getKeyword())){
boolQuery.must(QueryBuilders.matchQuery("skuTitle",Param.getKeyword()));
}
// 1.2 bool - filter Catalog3Id
if(Param.getCatalog3Id() != null){
boolQuery.filter(QueryBuilders.termQuery("catalogId", Param.getCatalog3Id()));
}
// 1.2 bool - filter brandId [集合]
if(Param.getBrandId() != null && Param.getBrandId().size() > 0){
boolQuery.filter(QueryBuilders.termsQuery("brandId", Param.getBrandId()));
}
// 1.3 属性查询(nested嵌入式查询:id+value)--为每一个属性都生成一个嵌入式的属性来进行过滤
if(Param.getAttrs() != null && Param.getAttrs().size() > 0){
for (String attrStr : Param.getAttrs()) {
BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
String[] s = attrStr.split("_");
// 检索的id 属性检索用的值
String attrId = s[0];
String[] attrValue = s[1].split(":");
boolQueryBuilder.must(QueryBuilders.termQuery("attrs.attrId", attrId));
boolQueryBuilder.must(QueryBuilders.termsQuery("attrs.attrValue", attrValue));
// 构建一个嵌入式Query 每一个必须都得生成嵌入的 nested 查询
NestedQueryBuilder attrsQuery = QueryBuilders.nestedQuery("attrs", boolQueryBuilder, ScoreMode.None);
boolQuery.filter(attrsQuery);
}
}
// 1.2 bool - filter [库存]:0代表无库存,1代表有库存
if(Param.getHasStock() != null){
boolQuery.filter(QueryBuilders.termQuery("hasStock",Param.getHasStock() == 1));
}
// 1.2 bool - filter [价格区间]:1_500 ,_500(小于500),500_(大于500)---分3种情况进行讨论
if(!StringUtils.isEmpty(Param.getSkuPrice())){
RangeQueryBuilder rangeQuery = QueryBuilders.rangeQuery("skuPrice");
String[] s = Param.getSkuPrice().split("_");
//[价格区间]:1_500 ,_500(小于500),500_(大于500)---分3种情况进行讨论
if(s.length == 2){
// 有2个值 就是区间
rangeQuery.gte(s[0]).lte(s[1]);
}else if(s.length == 1){
// 单值情况
if(Param.getSkuPrice().startsWith("_")){
rangeQuery.lte(s[0]);
}
if(Param.getSkuPrice().endsWith("_")){
rangeQuery.gte(s[0]);
}
}
boolQuery.filter(rangeQuery);
}
// 把以前所有boolquery的条件都拿来进行放入search对象中
sourceBuilder.query(boolQuery);
// 1.排序
if(!StringUtils.isEmpty(Param.getSort())){
String sort = Param.getSort();
// sort=hotScore_asc/desc
String[] s = sort.split("_");
SortOrder order = s[1].equalsIgnoreCase("asc") ? SortOrder.ASC : SortOrder.DESC;
sourceBuilder.sort(s[0], order);
}
// 2.分页 pageSize : 5
sourceBuilder.from((Param.getPageNum()-1) * EsConstant.PRODUCT_PAGESIZE);
sourceBuilder.size(EsConstant.PRODUCT_PAGESIZE);
// 3.高亮(按照哪个属性进行高亮:skuTitle)
if(!StringUtils.isEmpty(Param.getKeyword())){
HighlightBuilder builder = new HighlightBuilder();
builder.field("skuTitle");
builder.preTags("<b style='color:red'>");
builder.postTags("</b>");
sourceBuilder.highlighter(builder);
}
// 聚合分析(聚合分析的各种)
// TODO 1.品牌聚合
TermsAggregationBuilder brand_agg = AggregationBuilders.terms("brand_agg");
brand_agg.field("brandId").size(50);
// 品牌聚合的子聚合
brand_agg.subAggregation(AggregationBuilders.terms("brand_name_agg").field("brandName").size(1));
brand_agg.subAggregation(AggregationBuilders.terms("brand_img_agg").field("brandImg").size(1));
// 将品牌聚合加入 sourceBuilder
sourceBuilder.aggregation(brand_agg);
// TODO 2.分类聚合
TermsAggregationBuilder catalog_agg = AggregationBuilders.terms("catalog_agg").field("catalogId").size(20);
catalog_agg.subAggregation(AggregationBuilders.terms("catalog_name_agg").field("catalogName").size(1));
// 将分类聚合加入 sourceBuilder
sourceBuilder.aggregation(catalog_agg);
// TODO 3.属性聚合 attr_agg 构建嵌入式聚合
NestedAggregationBuilder attr_agg = AggregationBuilders.nested("attr_agg", "attrs");
// 3.1 聚合出当前所有的attrId,每个属性下一个聚合的名称和值
TermsAggregationBuilder attrIdAgg = AggregationBuilders.terms("attr_id_agg").field("attrs.attrId");
// 3.1.1 聚合分析出当前attrId对应的attrName
attrIdAgg.subAggregation(AggregationBuilders.terms("attr_name_agg").field("attrs.attrName").size(1));
// 3.1.2 聚合分析出当前attrId对应的所有可能的属性值attrValue 这里的属性值可能会有很多 所以写50
attrIdAgg.subAggregation(AggregationBuilders.terms("attr_value_agg").field("attrs.attrValue").size(50));
// 3.2 将这个子聚合加入嵌入式聚合
attr_agg.subAggregation(attrIdAgg);
sourceBuilder.aggregation(attr_agg);
//将写好的source的查询源语句,最终传入SearchRequest,并制定好相应的索引
log.info("\n构建语句:->\n" + sourceBuilder.toString());
SearchRequest searchRequest = new SearchRequest(new String[]{EsConstant.PRODUCT_INDEX}, sourceBuilder);
return searchRequest;
}
利用java转化得到的dsl测试,发现可以成功检索(这个是带上华为品牌的检索条件和价格区间检索条件的json查询DSL语句)
{
"from": 0,
"size": 2,
"query": {
"bool": {
"must": [{
"match": {
"skuTitle": {
"query": "华为",
"operator": "OR",
"prefix_length": 0,
"max_expansions": 50,
"fuzzy_transpositions": true,
"lenient": false,
"zero_terms_query": "NONE",
"auto_generate_synonyms_phrase_query": true,
"boost": 1.0
}
}
}],
"filter": [{
"term": {
"catalogId": {
"value": 225,
"boost": 1.0
}
}
}, {
"range": {
"skuPrice": {
"from": "",
"to": "6000",
"include_lower": true,
"include_upper": true,
"boost": 1.0
}
}
}],
"adjust_pure_negative": true,
"boost": 1.0
}
},
"aggregations": {
"brand_agg": {
"terms": {
"field": "brandId",
"size": 50,
"min_doc_count": 1,
"shard_min_doc_count": 0,
"show_term_doc_count_error": false,
"order": [{
"_count": "desc"
}, {
"_key": "asc"
}]
},
"aggregations": {
"brand_name_agg": {
"terms": {
"field": "brandName",
"size": 1,
"min_doc_count": 1,
"shard_min_doc_count": 0,
"show_term_doc_count_error": false,
"order": [{
"_count": "desc"
}, {
"_key": "asc"
}]
}
},
"brand_img_agg": {
"terms": {
"field": "brandImg",
"size": 1,
"min_doc_count": 1,
"shard_min_doc_count": 0,
"show_term_doc_count_error": false,
"order": [{
"_count": "desc"
}, {
"_key": "asc"
}]
}
}
}
},
"catalog_agg": {
"terms": {
"field": "catalogId",
"size": 20,
"min_doc_count": 1,
"shard_min_doc_count": 0,
"show_term_doc_count_error": false,
"order": [{
"_count": "desc"
}, {
"_key": "asc"
}]
},
"aggregations": {
"catalog_name_agg": {
"terms": {
"field": "catalogName",
"size": 1,
"min_doc_count": 1,
"shard_min_doc_count": 0,
"show_term_doc_count_error": false,
"order": [{
"_count": "desc"
}, {
"_key": "asc"
}]
}
}
}
},
"attr_agg": {
"nested": {
"path": "attrs"
},
"aggregations": {
"attr_id_agg": {
"terms": {
"field": "attrs.attrId",
"size": 10,
"min_doc_count": 1,
"shard_min_doc_count": 0,
"show_term_doc_count_error": false,
"order": [{
"_count": "desc"
}, {
"_key": "asc"
}]
},
"aggregations": {
"attr_name_agg": {
"terms": {
"field": "attrs.attrName",
"size": 1,
"min_doc_count": 1,
"shard_min_doc_count": 0,
"show_term_doc_count_error": false,
"order": [{
"_count": "desc"
}, {
"_key": "asc"
}]
}
},
"attr_value_agg": {
"terms": {
"field": "attrs.attrValue",
"size": 50,
"min_doc_count": 1,
"shard_min_doc_count": 0,
"show_term_doc_count_error": false,
"order": [{
"_count": "desc"
}, {
"_key": "asc"
}]
}
}
}
}
}
}
},
"highlight": {
"pre_tags": ["<b style='color:red'>"],
"post_tags": ["</b>"],
"fields": {
"skuTitle": {}
}
}
}
检索后命中3条正确(华为的价格小于6000的sku就是3个)
SearchResult查询结果的封装
对于es检索获得json数据,重新进行封装+聚合函数的分析结果的封装,主要是用来检索页的渲染使用
面包屑的功能(远程调用product属性的查询)—注意:取消面包屑的功能时的字符编码的问题(有点bug)
//6、构建面包屑导航(远程调用procduct的服务进行商品的属性的查询)
if (param.getAttrs() != null && param.getAttrs().size() > 0) {
List<SearchResult.NavVo> collect = param.getAttrs().stream().map(attr -> {
//1、分析每一个attrs传过来的参数值
SearchResult.NavVo navVo = new SearchResult.NavVo();
String[] s = attr.split("_");
navVo.setNavValue(s[1]);
R r = productFeignService.attrInfo(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 encode = null;
try {
encode = URLEncoder.encode(attr,"UTF-8");
encode.replace("+","%20"); //浏览器对空格的编码和Java不一样,差异化处理
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
String replace = param.get_queryString().replace("&attrs=" + attr, "");
navVo.setLink("http://127.0.0.1:12000/list.html?" + replace);
return navVo;
}).collect(Collectors.toList());
result.setNavs(collect);
}
颜色极光蓝不是基本属性,所以无法取消
异步编程学习
初始化线程的三种方法+创建线程池的方法(推荐):
业务待解决的问题:
CompletableFuture异步编排
创建异步对象
计算完成时回调+异常的检查
线程串行化方法
多任务组合
业务使用的方法:
商城业务:商品详情页
环境搭建
导入相应的静态资源,并修改相应的路径
实现点击sku商品的图片和名称跳转到相应的商品的详情页:
sku的规格属性(多表查询)
使用左外连接查询
<resultMap id="spuAttrGroup" type="com.atguigu.gulimall.product.vo.SpuItemAttrGroupVo">
<result property="groupName" column="attr_group_name"/>
<collection property="attrs" ofType="com.atguigu.gulimall.product.vo.Attr">
<result property="attrId" column="attr_id"></result>
<result property="attrName" column="attr_name"></result>
<result property="attrValue" column="attr_value"></result>
</collection>
</resultMap>
<!-- 使用左外连接的查询,实现多表的级联查询,并整合多张表的结果-->
<select id="getAttrGroupWithAttrsBySpuId" resultMap="spuAttrGroup">
SELECT
product.spu_id,
pag.attr_group_id,
pag.attr_group_name,
product.attr_id,
product.attr_name,
product.attr_value
FROM
pms_product_attr_value product
LEFT JOIN pms_attr_attrgroup_relation paar ON product.attr_id = paar.attr_id
LEFT JOIN pms_attr_group pag ON paar.attr_group_id = pag.attr_group_id
WHERE
product.spu_id = #{spuId}
AND pag.catelog_id = #{catalogId}
</select>
spu的销售属性组合
多表的左外连接查询+分组的查询 (注意各种属性组合的笛卡尔积)
<resultMap id="skuItemSaleAttrVo" type="com.atguigu.gulimall.product.vo.SkuItemSaleAttrVo">
<result column="attr_id" property="attrId"></result>
<result column="attr_name" property="attrName"></result>
<collection property="attrValues" ofType="com.atguigu.gulimall.product.vo.AttrValueWithSkuIdVo">
<result column="attr_value" property="attrValue"></result>
<result column="sku_ids" property="skuIds"></result>
</collection>
</resultMap>
<!-- group_concat进行组的连接,DISTINCT实现去重操作 -->
<select id="getSaleAttrBySpuId" resultMap="skuItemSaleAttrVo">
SELECT
ssav.attr_id attr_id,
ssav.attr_name attr_name,
ssav.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>
异步编排优化
自定义线程池
商品详情页的具体逻辑
注意任务的执行顺序,首先获取sku_id才能获取spu的相关的属性的组合
// 商品详情页的查询业务(异步编排)
@Override
public SkuItemVo item(Long skuId) throws ExecutionException, InterruptedException{
SkuItemVo skuItemVo = new SkuItemVo();
CompletableFuture<SkuInfoEntity> infoFuture = CompletableFuture.supplyAsync(() -> {
//1、“sku”基本信息的获取 pms_sku_info(含有返回结果)
SkuInfoEntity info = this.getById(skuId);
skuItemVo.setInfo(info);
return info;
}, executor);
//res就是第一个任务的返回结果(需要从sku对应获取spu的相关的销售属性以及介绍+基本参数)
CompletableFuture<Void> saleAttrFuture = infoFuture.thenAcceptAsync((res) -> {
//3、获取“spu”的销售属性组合(在1之后)
List<SkuItemSaleAttrVo> saleAttrVos = skuSaleAttrValueService.getSaleAttrBySpuId(res.getSpuId());
skuItemVo.setSaleAttr(saleAttrVos);
}, executor);
CompletableFuture<Void> descFuture = infoFuture.thenAcceptAsync((res) -> {
//4、获取“spu”的介绍 pms_spu_info_desc(在1之后)
SpuInfoDescEntity spuInfoDescEntity = spuInfoDescService.getById(res.getSpuId());
skuItemVo.setDesc(spuInfoDescEntity);
}, executor);
CompletableFuture<Void> baseAttrFuture = infoFuture.thenAcceptAsync((res) -> {
//5、获取“spu”的规格参数信息(在1之后)
List<SpuItemAttrGroupVo> attrGroupVos = attrGroupService.getAttrGroupWithAttrsBySpuId(res.getSpuId(), res.getCatalogId());
skuItemVo.setGroupAttrs(attrGroupVos);
}, executor);
//2、“sku”的图片信息 pms_sku_images(直接获取sku图片信息即可)
CompletableFuture<Void> imageFuture = CompletableFuture.runAsync(() -> {
List<SkuImagesEntity> imagesEntities = skuImagesService.getImagesBySkuId(skuId);
skuItemVo.setImages(imagesEntities);
}, executor);
// Long spuId = info.getSpuId();
// Long catalogId = info.getCatalogId();
//等到所有任务都完成(infofuture可以不写的原因是:别的future需要等待它完成之后才能执行)
CompletableFuture.allOf(saleAttrFuture,descFuture,baseAttrFuture,imageFuture).get();
return skuItemVo;
}
商城业务:认证服务(重点)
环境搭建
创建新的模块gulimall-auth-server:社交登录与单点登录
将模板引擎的主页和静态资源加入项目中:(登录页面和注册页面)
确保从上乘的首页能进入相应的注册页面和登录页面:
整合短信验证码的功能
倒计时验证码的功能(前端的js)
体会一下防抖的思想,防止一直点击倒计时,加快倒计时的速度($(“#sendCode”).attr(“class”, “disabled”)防抖)
//发送验证码的单击事件:实现验证码的倒计时功能
$(function () {
$("#sendCode").click(function () {
if ($(this).hasClass("disabled")) {
// 1.进入倒计时效果
} else {
$.get("/sms/snedcode?phone=" + $("#phoneNum").val(), function (data) {
if (data.code != 0) {
layer.msg(data.msg)
}
});
// 2.给指定手机号发送验证码
timeoutChangeStyle()
}
})
})
let num = 60;
//超时改变验证码的样式
function timeoutChangeStyle() {
$("#sendCode").attr("class", "disabled")
if (num == 0) {
num = 60;
$("#sendCode").attr("class", "");
$("#sendCode").text("发送验证码");
} else {
var str = num + "s 后再次发送";
$("#sendCode").text(str);
// 1s后回调
setTimeout("timeoutChangeStyle()", 1000);
}
num--
}
SpringMVC的路径映射功能(get请求)
此时controller中就不用写这个空方法的跳转的业务代码,直接使用路径映射也能够直接跳转页面(妙a)
阿里云的短信接口
设置短信的模板
注意使用提供的模板方法,否则会出现调试失败的情况(查一下模板id的状态还存不存在!!!)
注意“content填写已有的模板方法”(426563就是messageid,用来查询短信的状态)
短信状态查询:
创建自己的短信模板
创建自己的模板,返回的是模板id+查询自己的模板情况:(成功了)
spring整合短信的测试
引入相关的依赖的常量类,学会使用别人的代码
测试发现成功了
@Test
public void sendSms(){
String host = "https://zwp.market.alicloudapi.com";
String path = "/sms/sendv2";
String method = "GET";
String appcode = "27aeabc9d81946c8a65267a395513f7f";
Map<String, String> headers = new HashMap<String, String>();
//最后在header中的格式(中间是英文空格)为Authorization:APPCODE 83359fd73fe94948385f570e3c139105
headers.put("Authorization", "APPCODE " + appcode);
Map<String, String> querys = new HashMap<String, String>();
String code="5678";
querys.put("content", "【谷粒商城】您的验证码是#"+code+"#,请尽快填写。");
querys.put("mobile", "15251858895");
try {
/**
* 重要提示如下:
* HttpUtils请从
* https://github.com/aliyun/api-gateway-demo-sign-java/blob/master/src/main/java/com/aliyun/api/gateway/demo/util/HttpUtils.java
* 下载
*
* 相应的依赖请参照
* https://github.com/aliyun/api-gateway-demo-sign-java/blob/master/pom.xml
*/
HttpResponse response = HttpUtils.doGet(host, path, method, headers, querys);
System.out.println(response.toString());
//获取response的body
//System.out.println(EntityUtils.toString(response.getEntity()));
} catch (Exception e) {
e.printStackTrace();
}
}
编写短信发送的component (third-party的微服务模块)
传入可配置的手机号码和验证码
@ConfigurationProperties(prefix = "spring.cloud.alicloud.sms")
@Data
@Component//引入第三方的组件
public class SmsComponent {
// String host = "https://zwp.market.alicloudapi.com";
// String path = "/sms/sendv2";
// String appcode = "27aeabc9d81946c8a65267a395513f7f";
private String host;
private String path;
private String appcode;
// 放入自己的phone手机号码,放入生成的code验证码
public void sendCode(String phone,String code) {
String method = "GET";
Map<String, String> headers = new HashMap<String, String>();
//最后在header中的格式(中间是英文空格)为Authorization:APPCODE 83359fd73fe94948385f570e3c139105
headers.put("Authorization", "APPCODE " + appcode);
Map<String, String> querys = new HashMap<String, String>();
querys.put("content", "【谷粒商城】您的验证码是#"+code+"#,请尽快填写。");
querys.put("mobile", phone);
try {
HttpResponse response = HttpUtils.doGet(host, path, method, headers, querys);
System.out.println(response.toString());
//获取response的body
//System.out.println(EntityUtils.toString(response.getEntity()));
} catch (Exception e) {
e.printStackTrace();
}
}
}
在配置文件写入相应的配置
测试成功:
@Test
public void testSms(){
//传入相应的手机号码和验证码
smsComponent.sendCode("15251858895","8888");
}
验证码防刷校验
gulimall-auth-server调用gulimall-third-party的发送验证码的业务
// 发送验证码的功能(注意接口防刷的功能)
@ResponseBody
@GetMapping(value = "/sms/sendcode")
public R sendCode(@RequestParam("phone") String phone) {
// TODO 接口防刷(冷却时长递增),redis缓存 sms:code:电话号
String redisCode = stringRedisTemplate.opsForValue().get(AuthServerConstant.SMS_CODE_CACHE_PREFIX + phone);
// 如果不为空,返回错误信息
if(null != redisCode && redisCode.length() > 0){
long CuuTime = Long.parseLong(redisCode.split("_")[1]);
if(System.currentTimeMillis() - CuuTime < 60 * 1000){ // 60s
return R.error(BizCodeEnum.SMS_CODE_EXCEPTION.getCode(), BizCodeEnum.SMS_CODE_EXCEPTION.getMsg());
}
}
// 生成验证码
String code = UUID.randomUUID().toString().substring(0, 6);
String redis_code = code + "_" + System.currentTimeMillis();
// 缓存验证码
stringRedisTemplate.opsForValue().set(AuthServerConstant.SMS_CODE_CACHE_PREFIX + phone, redis_code , 10, TimeUnit.MINUTES);
try {// 调用第三方短信服务
return thirdPartFeignService.sendCode(phone, code);
} catch (Exception e) {
log.warn("远程调用不知名错误 [无需解决]");
}
return R.ok();
}
同时防止60秒内重复发送:
redis中成功的存入数据:
注册页环境
auth-server的注册:验证码的验证+远程调用member微服务的注册功能(多重验证机制:传入方法参数的验证+后端数据库校验重复)
JSR303的检验功能(后端)
异常机制
为了检查用户名和电话号码是否是唯一的,查询数据库是否已经存在数据(自定义运行时异常----存在异常)
@Override
public void register(UserRegisterVo userRegisterVo) throws PhoneExistException, UserNameExistException {
MemberEntity entity = new MemberEntity();
// 设置默认等级
MemberLevelEntity memberLevelEntity = memberLevelDao.getDefaultLevel();
entity.setLevelId(memberLevelEntity.getId());
// 检查手机号 用户名是否唯一 // 不一致则抛出异常(为了让controller感知异常,直接抛出异常,使用异常机制)
checkPhone(userRegisterVo.getPhone());
checkUserName(userRegisterVo.getUserName());
entity.setMobile(userRegisterVo.getPhone());
entity.setUsername(userRegisterVo.getUserName());
// 密码要加密存储(防止非法人员窃取数据库的的铭文密码)--本质:盐值加密
BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
entity.setPassword(bCryptPasswordEncoder.encode(userRegisterVo.getPassword()));
// 其他的默认信息
entity.setCity("湖南 长沙");
entity.setCreateTime(new Date());
entity.setStatus(0);
entity.setNickname(userRegisterVo.getUserName());
entity.setBirth(new Date());
entity.setEmail("xxx@gmail.com");
entity.setGender(1);
entity.setJob("JAVA");
baseMapper.insert(entity);
}
具体的注册逻辑
调用远程的member服务进行调用注册的功能
/**
* TODO 重定向携带数据,利用session原理 将数据放在sessoin中 取一次之后删掉
*
* TODO 1. 分布式下的session问题
* 校验
* RedirectAttributes redirectAttributes : 模拟重定向带上数据
*/
@PostMapping("/register")
public String register(@Valid UserRegisterVo userRegisterVo,
BindingResult result,
RedirectAttributes redirectAttributes){
//JSR303校验+前端的校验功能
if(result.hasErrors()){
// 将错误属性与错误信息一一封装
Map<String, String> errors = result.getFieldErrors().stream().collect(
Collectors.toMap(FieldError::getField, fieldError -> fieldError.getDefaultMessage()));
// addFlashAttribute 这个数据只取一次
redirectAttributes.addFlashAttribute("errors", errors);
return "redirect:http://127.0.0.1:20000/reg.html";
}
// 开始注册 调用远程服务
// 1.校验验证码
String code = userRegisterVo.getCode();
String redis_code = stringRedisTemplate.opsForValue().get(AuthServerConstant.SMS_CODE_CACHE_PREFIX + userRegisterVo.getPhone());
if(!StringUtils.isEmpty(redis_code)){
// 验证码通过
if(code.equals(redis_code.split("_")[0])){
// 删除验证码(令牌机制)
stringRedisTemplate.delete(AuthServerConstant.SMS_CODE_CACHE_PREFIX + userRegisterVo.getPhone());
// 调用远程服务进行注册
R r = memberFeignService.register(userRegisterVo);
if(r.getCode() == 0){
//*** 注册成功,去登录****
return "redirect:http://127.0.0.1:20000/login.html";
}else{
Map<String, String> errors = new HashMap<>();
errors.put("msg",r.getData("msg",new TypeReference<String>(){}));
// 数据只需要取一次
redirectAttributes.addFlashAttribute("errors",errors);
return "redirect:http://127.0.0.1:20000/reg.html";
}
}else{
Map<String, String> errors = new HashMap<>();
errors.put("code", "验证码错误");
// addFlashAttribute 这个数据只取一次
redirectAttributes.addFlashAttribute("errors", errors);
return "redirect:http://127.0.0.1:20000/reg.html";
}
}else{
Map<String, String> errors = new HashMap<>();
errors.put("code", "验证码错误");
// addFlashAttribute 这个数据只取一次
redirectAttributes.addFlashAttribute("errors", errors);
return "redirect:http://127.0.0.1:20000/reg.html";
}
}
注册成功,成功跳转至登录的页面:(数据库新增一条会员的信息)
账号密码登录
成功登录后重定向到首页(远程调用member微服务模块的login业务)
@PostMapping("/login") // auth
public String login(UserLoginVo userLoginVo, // from表单里带过来的
RedirectAttributes redirectAttributes,
HttpSession session){
// 远程登录
R r = memberFeignService.login(userLoginVo);
if(r.getCode() == 0){
return "redirect:http://127.0.0.1:10001";
}else {
HashMap<String, String> error = new HashMap<>();
// 获取错误信息
error.put("msg", r.getData("msg",new TypeReference<String>(){}));
redirectAttributes.addFlashAttribute("errors", error);
return "redirect:http://127.0.0.1:20000/login.html";
}
}
// 登录业务(登录失败返回的就是null,登陆成功就是返回一个非空的实体类)
@Override
public MemberEntity login(MemberLoginVo vo) {
String loginacct = vo.getLoginacct();
BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
// 去数据库查询,用户注册的登录账号的信息
MemberEntity entity = this.baseMapper.selectOne(new QueryWrapper<MemberEntity>().eq("username", loginacct).or().eq("mobile", loginacct));
if(entity == null){
// 登录失败
return null;
}else{
// 前面传一个明文密码 后面传一个编码后的密码
boolean matches = bCryptPasswordEncoder.matches(vo.getPassword(), entity.getPassword());
if (matches){
entity.setPassword(null);
return entity;
}else {
return null;
}
}
}
社交登录–OAuth2.0协议*
微博社交登录测试
App Key:3983482139
App Secret:501d569adbc00db7206359954a880b68
官方的api文档:1-2-3至关重要,获取access_token之后就可以调用许多的api接口
<li>
<a href="https://api.weibo.com/oauth2/authorize?client_id=3983482139&response_type=code&redirect_uri=http://10.203.223.209:20000/oauth2.0/weibo/success">
<img style="width: 50px; height: 18px" src="/login/JD_img/weibo.png" />
</a>
</li>
利用微博账号登录,返回的回调页面地址携带一个code(注意:这个code只能使用一次),用于换取access_token(获得了这个令牌之后就可以调用许多的api接口)
社交登录回调函数
注意使用社交账号,要分为注册过和未注册过(未注册过的后台帮忙注册就行,大前提就是从微博或许相关的用户的信息,想要获取的前提就是用于access_token 令牌)
@Override // 已经用code生成了token(前端传过来的社交用户)
public MemberEntity login(SocialUser socialUser) {
// 微博的uid
String uid = socialUser.getUid();
// 1.判断社交用户登录过系统
MemberDao dao = this.baseMapper;
MemberEntity entity = dao.selectOne(new QueryWrapper<MemberEntity>().eq("social_uid", uid));
MemberEntity memberEntity = new MemberEntity();
if(entity != null){ // 注册过(使用社交账号注册过)
// 说明这个用户注册过, 修改它的资料
// 更新令牌(这三个字段,需要不断更新)
memberEntity.setId(entity.getId());
memberEntity.setAccessToken(socialUser.getAccessToken());
memberEntity.setExpiresIn(socialUser.getExpiresIn());
// 更新
dao.updateById(memberEntity);
entity.setAccessToken(socialUser.getAccessToken());
entity.setExpiresIn(socialUser.getExpiresIn());
entity.setPassword(null);
return entity;
}else{ // 没有注册过(使用社交账号登录,未注册过)
// 2. 没有查到当前社交用户对应的记录 我们就需要注册一个
HashMap<String, String> map = new HashMap<>();
map.put("access_token", socialUser.getAccessToken());
map.put("uid", socialUser.getUid());
try {
// 3. 查询当前社交用户账号信息(昵称、性别、头像等)---进行自动的注册
HttpResponse response = HttpUtils.doGet("https://api.weibo.com", "/2/users/show.json", "get", new HashMap<>(), map);
if(response.getStatusLine().getStatusCode() == 200){
// 查询成功
String json = EntityUtils.toString(response.getEntity());
// 这个JSON对象什么样的数据都可以直接获取
JSONObject jsonObject = JSON.parseObject(json);
memberEntity.setNickname(jsonObject.getString("name"));
memberEntity.setUsername(jsonObject.getString("name"));
memberEntity.setGender("m".equals(jsonObject.getString("gender"))?1:0);
memberEntity.setCity(jsonObject.getString("location"));
memberEntity.setJob("自媒体");
memberEntity.setEmail(jsonObject.getString("email"));
}
} catch (Exception e) {
log.warn("社交登录时远程调用出错 [尝试修复]");
}
memberEntity.setStatus(0);
memberEntity.setCreateTime(new Date());
memberEntity.setBirth(new Date());
memberEntity.setLevelId(1L);
memberEntity.setSocialUid(socialUser.getUid());
memberEntity.setAccessToken(socialUser.getAccessToken());
memberEntity.setExpiresIn(socialUser.getExpiresIn());
// 注册 -- 登录成功
dao.insert(memberEntity);
memberEntity.setPassword(null);
return memberEntity;
}
}
/**
* 登录成功回调页,前提是判断是否需要按照微博登录进行新的注册+成功获取微博登录的令牌即可成功的回调
*/
@GetMapping("/weibo/success") // Oath2Controller
public String weiBo(@RequestParam("code") String code, HttpSession session) throws Exception {
// 根据code换取 Access Token
Map<String,String> map = new HashMap<>();
map.put("client_id", "1361350596");
map.put("client_secret", "e4c4431bc7fab4ab08442229fb7f5e2d");
map.put("grant_type", "authorization_code");
map.put("redirect_uri", "http://10.203.230.49:20000/oauth2.0/weibo/success");
map.put("code", code);
Map<String, String> headers = new HashMap<>();
// 去获取token
HttpResponse response = HttpUtils.doPost("https://api.weibo.com",
"/oauth2/access_token", "post", headers, null, map);
System.out.println(response.getStatusLine().getStatusCode());
if(response.getStatusLine().getStatusCode() == 200){
// 获取响应体: Access Token
String json = EntityUtils.toString(response.getEntity());
SocialUser socialUser = JSON.parseObject(json, SocialUser.class);
// 相当于我们知道了当前是那个用户
// 1.如果用户是第一次进来 自动注册进来(为当前社交用户生成一个会员信息 以后这个账户就会关联这个账号)
R login = memberFeignService.login(socialUser);
if(login.getCode() == 0){
MemberRespVo respVo = login.getData("data" ,new TypeReference<MemberRespVo>() {});
log.info("\n欢迎 [" + respVo.getUsername() + "] 使用社交账号登录");
// 第一次使用session 命令浏览器保存这个用户信息 JESSIONSEID 每次只要访问这个网站就会带上这个cookie
// 在发卡的时候扩大session作用域 (指定域名为父域名)
// TODO 1.默认发的当前域的session (需要解决子域session共享问题)
// TODO 2.使用JSON序列化后保存到redis 自动完成
System.out.println("开始放session");
session.setAttribute(AuthServerConstant.LOGIN_USER, respVo);
// 登录成功 跳回首页
return "redirect:http://127.0.0.1:10001";
}else{
return "redirect:http://127.0.0.1:20000/login.html";
}
}else{
return "redirect:http://127.0.0.1:20000/login.html";
}
}
结果测试(access_token测试阶段一天有效,每天申请一个,哈哈哈哈)
302–302–200就是经过了三次才到达的呀
已经没有测试的机会了,哈哈哈,难受(微博好坑a)
分布式session(共享问题)
HttpSession接口
servlet和Tomcat的解释(原始起源)
servlet接收请求转化为静态资源响应给用户(可交互式的处理客户端发送到服务器的请求,并完成操作响应)!!!!
SpringSession整合
@GetMapping("/weibo/success") // Oath2Controller
public String weiBo(@RequestParam("code") String code, HttpSession session) throws Exception {
// 根据code换取 Access Token
Map<String,String> map = new HashMap<>();
map.put("client_id", "3983482139");
map.put("client_secret", "501d569adbc00db7206359954a880b68");
map.put("grant_type", "authorization_code");
map.put("redirect_uri", "http://10.203.223.209:20000/oauth2.0/weibo/success");
map.put("code", code);
Map<String, String> headers = new HashMap<>();
// 去获取token
HttpResponse response = HttpUtils.doPost("https://api.weibo.com",
"/oauth2/access_token", "post", headers, null, map);
if(response.getStatusLine().getStatusCode() == 200){
// 获取响应体: Access Token
String json = EntityUtils.toString(response.getEntity());
SocialUser socialUser = JSON.parseObject(json, SocialUser.class);
// 相当于我们知道了当前是那个用户
// 1.如果用户是第一次进来 自动注册进来(为当前社交用户生成一个会员信息 以后这个账户就会关联这个账号)
R login = memberFeignService.login(socialUser);
if(login.getCode() == 0){
MemberRespVo respVo = login.getData("data" ,new TypeReference<MemberRespVo>() {});
log.info("\n欢迎 [" + respVo.getUsername() + "] 使用社交账号登录");
// TODO 2.使用JSON序列化后保存到redis 自动完成
session.setAttribute(AuthServerConstant.LOGIN_USER, respVo);
// 登录成功 跳回首页
return "redirect:http://127.0.0.1:10001";
}else{
return "redirect:http://127.0.0.1:20000/login.html";
}
}else{
return "redirect:http://127.0.0.1:20000/login.html";
}
}
设置有session不用提示登陆的关键词与链接
自定义Spring Session解决子域session共享问题 (普通登录)
注意:tomcat创建JSEEIONID时的默认名称且作用域为最小的本域,这里我们自定义tomcat生成给浏览器端的sessionid将其重新命名为GULISESSIONID&&作用域扩大为gulimall.com!!!
只要是127.0.0.1下面的子域都可以共享这个session(在需要这个session获取的微服务,都加上redis和session的依赖,并进行这样的配置文件)
/**
* Description:设置Session作用域、自定义cookie序列化机制
*/
@Configuration
public class AuthSessionConfig {
@Bean
public CookieSerializer cookieSerializer(){
DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
// 明确的指定Cookie的作用域
cookieSerializer.setDomainName("127.0.0.1");
cookieSerializer.setCookieName(AuthServerConstant.SESSION);
return cookieSerializer;
}
/**
* 自定义序列化机制
* 这里方法名必须是:springSessionDefaultRedisSerializer
*/
@Bean
public RedisSerializer<Object> springSessionDefaultRedisSerializer(){
return new GenericJackson2JsonRedisSerializer();
}
}
以下测试均是针对普通登录的情况
微博登录有问题(取不出session,可能是开发阶段的第三方应用使用)
针对使用微博登录:响应显示有session,但是没有存入
数据库里也有!!
这块session取不出,可能是使用的微博测试阶段的应用!!!!
SpringSession的核心原理
相当于对原始的request和response进行包装,实现将包装后的对象应用到了我们的整个执行链
显然进来之前就已经过滤了原生的session为redis的session
利用用户浏览器端的jession-id来从后端服务器查询真正的session----实际是来自redis中
wrappedRequest的原理
它将我们的request进一步包装成 wrappedRequest,然后在包装的request里调用getsession(所以这个包装的request里获取session的方法进行重写了,实际是从redis中获取session----不过需要判断这个session是否真的存在)
@Override
public HttpSessionWrapper getSession(boolean create) {
HttpSessionWrapper currentSession = getCurrentSession();
if (currentSession != null) {
return currentSession;
}
S requestedSession = getRequestedSession();
if (requestedSession != null) {
if (getAttribute(INVALID_SESSION_ID_ATTR) == null) {
requestedSession.setLastAccessedTime(Instant.now());
this.requestedSessionIdValid = true;
currentSession = new HttpSessionWrapper(requestedSession, getServletContext());
currentSession.markNotNew();
setCurrentSession(currentSession);
return currentSession;
}
}
else {
// This is an invalid session id. No need to ask again if
// request.getSession is invoked for the duration of this request
if (SESSION_LOGGER.isDebugEnabled()) {
SESSION_LOGGER.debug(
"No session found by id: Caching result for getSession(false) for this HttpServletRequest.");
}
setAttribute(INVALID_SESSION_ID_ATTR, "true");
}
if (!create) {
return null;
}
if (SESSION_LOGGER.isDebugEnabled()) {
SESSION_LOGGER.debug(
"A new session was created. To help you troubleshoot where the session was created we provided a StackTrace (this is not an error). You can prevent this from appearing by disabling DEBUG logging for "
+ SESSION_LOGGER_NAME,
new RuntimeException("For debugging purposes only (not an error)"));
}
S session = SessionRepositoryFilter.this.sessionRepository.createSession();
session.setLastAccessedTime(Instant.now());
currentSession = new HttpSessionWrapper(session, getServletContext());
setCurrentSession(currentSession);
return currentSession;
}
单点登录问题 (后续学习demo)
(一处登录,处处登录,旗下产品)
商城业务:购物车
环境搭建(maven出故障了)
创建gulimall-cart微服务模块
准备就绪:
数据模型分析
购物车的数据结构应该是一个hash结构
单个购物项:
用户的整个购物车:
ThreadLocal用户身份识别
每次调用购物车的微服务之前,都会使用拦截器来实现用户是否登录过,进行判断是否需要创建临时的用户的session,来让用户进行购物车的添加,以此实现临时用户在浏览器未登录的情况下,也能实现一个月内的购物车的记录
/**
* <p>Title: CartInterceptor</p>
* Description:在执行目标之前 判断用户是否登录(主要是浏览器的session或者cookie信息),并封装(注意:别忘了把拦截器注册到webconfig中)
*/
public class CartInterceptor implements HandlerInterceptor {
// 存放当前线程用户信息(同一个线程共享数据)
public static ThreadLocal<UserInfoTo> threadLocal = new ThreadLocal<>();
/** userInfoTo包含登录用户和临时用户的信息*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 准备好要设置到threadlocal里的user对象
UserInfoTo userInfoTo = new UserInfoTo();
HttpSession session = request.getSession();
// 获取loginUser对应的用户value,没有也不去登录了。登录逻辑放到别的代码里,需要登录时再重定向
MemberRespVo user = (MemberRespVo) session.getAttribute(AuthServerConstant.LOGIN_USER);
// 已登录用户,设置userId
if (user != null){
userInfoTo.setUsername(user.getUsername());
userInfoTo.setUserId(user.getId());//明确下登录用的是UserId,临时用户用的是UserKey
}
// 将cookie中的临时购物车信息设置到threadlocal中 // 不登录也没关系,可以访问临时用户购物车
Cookie[] cookies = request.getCookies();
if( cookies != null && cookies.length > 0){
for (Cookie cookie : cookies) {
String name = cookie.getName();
if(CartConstant.TEMP_USER_COOKIE_NAME.equals(name)){ // 有"user-key";这个cookie
userInfoTo.setUserKey(cookie.getValue());
userInfoTo.setTempUser(true);
}
}
}
// 如果没有临时用户和登录用户,则分配一个临时用户 // 分配的临时用户在postHandle的时候放到cookie里即可
if (StringUtils.isEmpty(userInfoTo.getUserKey()) // 没有临时用户时
&& StringUtils.isEmpty(userInfoTo.getUserId())){ // 有登录用户就不生成临时
String uuid = UUID.randomUUID().toString().replace("-","");
userInfoTo.setUserKey("GULI-" + uuid);//临时用户
}
threadLocal.set(userInfoTo);
return true;
// 还有一个登录后应该删除临时购物车的逻辑没有实现
}
/**
* 执行完毕之后分配临时用户让浏览器保存(在视图渲染之前)
*/
@Override
public void postHandle(HttpServletRequest request,
HttpServletResponse response, Object handler,
ModelAndView modelAndView) throws Exception {
UserInfoTo userInfoTo = threadLocal.get();
// 如果是临时用户,返回临时购物车的cookie,放在浏览器上
if(!userInfoTo.isTempUser()){
Cookie cookie = new Cookie(CartConstant.TEMP_USER_COOKIE_NAME, userInfoTo.getUserKey());
// 设置cookie作用域、过期时间
cookie.setDomain("127.0.0.1");
cookie.setMaxAge(CartConstant.TEMP_USER_COOKIE_TIME_OUT);
response.addCookie(cookie);
}
}
}
添加购物车
主要是要添加的商品的信息以及销售属性的信息,所以需要远程调用product的微服务(由于涉及到了两次调用不同的微服务的业务,所以需要采用异步编排,实现同步),这里我们使用hashmap来封装用户的购物车的信息
//将商品添加到购物车
@Override // CartServiceImpl
public CartItem addToCart(Long skuId, Integer num) throws ExecutionException, InterruptedException {
// 获取当前用户的map
BoundHashOperations<String, Object, Object> cartOps = getCartOps();
// 查看该用户购物车里是否有指定的skuId
String res = (String) cartOps.get(skuId.toString());
// 查看用户购物车里是否已经有了该sku项
if (StringUtils.isEmpty(res)) {
CartItem cartItem = new CartItem();
// 异步编排
CompletableFuture<Void> getSkuInfo = CompletableFuture.runAsync(() -> {
// 1. 远程查询当前要添加的商品的信息
R skuInfo = productFeignService.SkuInfo(skuId);
SkuInfoVo sku = skuInfo.getData("skuInfo", new TypeReference<SkuInfoVo>() {
});
// 2. 填充购物项
cartItem.setCount(num);
cartItem.setCheck(true);
cartItem.setImage(sku.getSkuDefaultImg());
cartItem.setPrice(sku.getPrice());
cartItem.setTitle(sku.getSkuTitle());
cartItem.setSkuId(skuId);
}, executor);
// 3. 远程查询sku销售属性,销售属性是个list
CompletableFuture<Void> getSkuSaleAttrValues = CompletableFuture.runAsync(() -> {
List<String> values = productFeignService.getSkuSaleAttrValues(skuId);
cartItem.setSkuAttr(values);
}, executor);
// 等待执行完成
CompletableFuture.allOf(getSkuInfo, getSkuSaleAttrValues).get();
// sku放到用户购物车redis中
cartOps.put(skuId.toString(), JSON.toJSONString(cartItem));
return cartItem;
} else {//购物车里已经有该sku了,数量+1即可
CartItem cartItem = JSON.parseObject(res, CartItem.class);
// 不太可能并发,无需加锁
cartItem.setCount(cartItem.getCount() + num);
cartOps.put(skuId.toString(), JSON.toJSONString(cartItem));
return cartItem;
}
}
/**
* 用户购物车redis-map,还没合并登录和临时购物车(主要是绑定对应的对象,按照对象进行操作)
* 键是拼接的userid或者临时userkey,对应的hashmap容器的键是skuid,值是具体的skuitem的信息
*/
private BoundHashOperations<String, Object, Object> getCartOps() {
// 1. 这里我们需要知道操作的是离线购物车还是在线购物车
UserInfoTo userInfoTo = CartInterceptor.threadLocal.get();
String cartKey = CART_PREFIX;
// 根据userId区别是登录用户还是临时用户
if (userInfoTo.getUserId() != null) {
log.debug("\n用户 [" + userInfoTo.getUsername() + "] 正在操作购物车");
// 已登录的用户购物车的标识
cartKey += userInfoTo.getUserId();
} else {
log.debug("\n临时用户 [" + userInfoTo.getUserKey() + "] 正在操作购物车");
// 未登录的用户购物车的标识
cartKey += userInfoTo.getUserKey();
}
// 绑定这个 key 以后所有对redis 的操作都是针对这个key
return stringRedisTemplate.boundHashOps(cartKey);
}
防止出现刷新页面一直添加购物车,采取重定向获取最新的购物车的信息
/*** 添加商品到购物车
* RedirectAttributes.addFlashAttribute():将数据放在session中,可以在页面中取出,但是只能取一次
* RedirectAttributes.addAttribute():将数据拼接在url后面,?skuId=xxx和商品的数量
* */
@GetMapping("/addToCart")
public String addToCart(@RequestParam("skuId") Long skuId,
@RequestParam("num") Integer num,
RedirectAttributes redirectAttributes) // 重定向数据, 会自动将数据添加到url后面
throws ExecutionException, InterruptedException {
// 添加数量到用户购物车
cartService.addToCart(skuId, num);
// 返回skuId告诉哪个添加成功了(充定向的时候携带商品的id)
redirectAttributes.addAttribute("skuId", skuId);
// 重定向到成功页面
return "redirect:http://127.0.0.1:40000/addToCartSuccess.html";//充定向页面,而不是新添加数据
}
// 添加sku到购物车响应页面(防止恶意操作,形成购物车的无限添加),这边的item才是真正的传入前端的渲染页面的购物车的具体信息的数据
@GetMapping("/addToCartSuccess.html")
public String addToCartSuccessPage(@RequestParam(value = "skuId",required = false) Object skuId, Model model){
CartItem cartItem = null;
// 然后在查一遍 购物车
if(skuId == null){
model.addAttribute("item", null);
}else{
try {
cartItem = cartService.getCartItem(Long.parseLong((String)skuId));
} catch (NumberFormatException e) {
log.warn("恶意操作! 页面传来skuId格式错误");
}
model.addAttribute("item", cartItem);
}
return "success";
}
获取合并购物车
获取并合并购物车(将原来浏览器上的临时购物车的数据,放入登录的用户的购物车中进行合并),主要是还需要注意清除临时购物车的用户信息,并将这个登录的用户的临时用户身份设置为false
当进入购物车的页面时,会获取所有的商品的信息并进行合并相关的临时用户和已经登录的用户的商品信息,将临时用户的商品信息,加到登录用户的购车中信息中!!!
/**
* 获取并合并购物车(将原来浏览器上的临时购物车的数据,放入登录的用户的购物车中进行合并)
*/
@Override
public Cart getCart() throws ExecutionException, InterruptedException {
//获取当前线程的用户的信息
UserInfoTo userInfoTo = CartInterceptor.threadLocal.get();
Cart cart = new Cart();
// 临时购物车的key // 用户key在哪里设置的以后研究一下
String tempCartKey = CART_PREFIX + userInfoTo.getUserKey();
// 简单处理一下,以后修改
if ("ATGUIGU:cart:".equals(tempCartKey)) tempCartKey += "X";
// 是否登录
if (userInfoTo.getUserId() != null) {
// 已登录 对用户的购物车进行操作
String cartKey = CART_PREFIX + userInfoTo.getUserId();
// 1 如果临时购物车的数据没有进行合并
List<CartItem> tempItem = getCartItems(tempCartKey);
if (tempItem != null) {
// 2 临时购物车有数据 则进行合并
log.info("\n[" + userInfoTo.getUsername() + "] 的购物车已合并");
for (CartItem cartItem : tempItem) {
addToCart(cartItem.getSkuId(), cartItem.getCount());
}
// 3 清空临时购物车,防止重复添加
clearCart(tempCartKey);
// 设置为非临时用户
userInfoTo.setTempUser(false);
}
// 4 获取登录后的购物车数据 [包含合并过来的临时购物车数据]
List<CartItem> cartItems = getCartItems(cartKey);
cart.setItems(cartItems);
} else {
// 没登录 获取临时购物车的所有购物项
cart.setItems(getCartItems(tempCartKey));
}
return cart;
}
选中购物项
点击勾中与不选中,会重定向到购物车的主页
@RequestParam注解警告理解
加上requestParam会自动将请求路径中url中的拼接上skuId和check两个字段赋值给相应的业务方法的参数
get请求的请求参数与路径变量的区别(学到了)