谷粒商城项目笔记之高级篇
谷粒商城项目笔记之高级篇笔记
- 谷粒商城项目笔记之高级篇笔记
- 1 商城业务
- 1.1 商品上架
- 1.2 商城系统首页
- 1.3 nginx 搭建域名访问环境
- 1.4 缓存
- 1.5 商品检索
- 1.6 商品详情
- 1.7 认证服务
- 1.8 购物车服务
- cartList.html
- 1.9 订单服务
- 1.9.1 环境搭建
- 1.9.2 整合SpringSession
- 1.9.3 订单基本概念
- 1.9.4 订单登录拦截
- 1.9.5 订单确认页模型抽取编写
- 1.9.6 订单确认页数据获取
- 1.9.7 订单确认页请求完成
- 1.9.8 Feign远程调用丢失请求头问题
- 1.9.9 Feign异步调用丢失上下文的问题
- 1.9.10 订单确认页渲染
- 1.9.11 订单确认页库存查询
- 1.9.12 订单确认页模拟运费效果
- 1.9.13 订单确认页细节显示
- 1.9.14 接口幂等性
- 1.9.15 订单确认页完成
- 1.9.16 原子验证令牌
- 1.9.17 构造订单数据
- 1.9.18 构造订单项数据
- 1.9.19 订单验价
- 1.9.20 保存订单数据
- 1.9.21 锁定库存
- 1.9.22 提交订单的问题
- 1.9.23 创建库存上锁、解锁 业务交换机&队列
- 1.9.24 监听库存解锁
- 1.9.25 库存解锁逻辑&库存自动解锁完成&测试库存自动解锁
- 1.9.26 定时关单完成
- 1.9.27 消息丢失、积压、重复等解决方案
- 1.9.28 支付服务
- 1.10 秒杀服务
- 2 高级篇总结
1 商城业务
1.1 商品上架
需求:
- 上架的商品才可以在网站展示。
- 上架的商品需要可以被检索。
1.1.1 商品Mapping
商品mapping
分析:商品上架在es中是存sku还是spu?
1)、检索的时候输入名字,是需要按照sku的title进行全文检索的
2)、检索使用商品规格,规格是spu的公共属性,每个spu是一样的
3)、按照分类id进去的都是直接列出spu的,还可以切换。
4〕、我们如果将sku的全量信息保存到es中(包括spu属性〕就太多字段了
选取如下方案:
方案1:方便检索
{
skuId:1
spuId:11
skyTitile:华为xx
price:999
saleCount:99
attr:[
{尺寸:5},
{CPU:高通945},
{分辨率:全高清}
]
}
缺点:如果每个sku都存储规格参数(如尺寸),会有冗余存储,因为每个spu对应的sku的规格参数都一样
冗余:
举例:100万*20=2000MB=2G
方案2:分布式
sku索引
{
spuId:1
skuId:11
xxx
}
attr索引
{
skuId:11
attr:[
{尺寸:5},
{CPU:高通945},
{分辨率:全高清}
]
}
举例:
先找到4000个符合要求的spu,再根据4000个spu查询对应的属性,封装了4000个id,long 8B*4000=32000B=32KB
1K个人检索,就是32MB
结论:如果将规格参数单独建立索引,会出现检索时出现大量数据传输的问题,会引起网络拥堵
因此选用方案1,以空间换时间
1.1.2 建立product索引
{ “type”: “keyword” }
, # 保持数据精度问题,可以检索,但不分词“analyzer”: “ik_smart”
# 中文分词器“index”: false
, # 不可被检索,不生成index“doc_values”: false
# 默认为true,设置为false,表示不可以做排序、聚合以及脚本操作,这样更节省磁盘空间。还可以通过设定doc_values为true,index为false来让字段不能被搜索但可以用于排序、聚合以及脚本操作
PUT 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",
"index": false, # 不可被检索,不生成index
"doc_values": false # 不可被聚合
},
"catalogName": {"type": "keyword" },
"attrs": { # attrs:当前sku的属性规格
"type": "nested",
"properties": {
"attrId": {"type": "long" },
"attrName": {
"type": "keyword",
"index": false,
"doc_values": false
},
"attrValue": {"type": "keyword" }
}
}
}
}
}
nested嵌入式对象
属性是"type": “nested”,因为是内部的属性进行检索
数组类型的对象会被扁平化处理(对象的每个属性会分别存储到一起)
user.name=["aaa","bbb"]
user.addr=["ccc","ddd"]
这种存储方式,可能会发生如下错误:
错误检索到{aaa,ddd},这个组合是不存在的
数组的扁平化处理会使检索能检索到本身不存在的,为了解决这个问题,就采用了嵌入式属性,数组里是对象时用嵌入式属性(不是对象无需用嵌入式属性)
nested阅读:https://blog.csdn.net/weixin_40341116/article/details/80778599
使用聚合:https://blog.csdn.net/kabike/article/details/101460578
1.1.3 上架细节
上架是将后台的商品放在es 中可以提供检索和查询功能。
1)、hasStock:代表是否有库存。默认上架的商品都有库存。如果库存无货的时候才需要
更新一下es
2)、库存补上以后,也需要重新更新一下es
3)、hotScore 是热度值,我们只模拟使用点击率更新热度。点击率增加到一定程度才更新
热度值。
4)、下架就是从es 中移除检索项,以及修改mysql 状态
商品上架步骤:
1)、先在es 中按照之前的mapping 信息,建立product 索引。
2)、点击上架,查询出所有sku 的信息,保存到es 中
3)、es 保存成功返回,更新数据库的上架状态信息。
1.1.4 数据一致性
1)、商品无库存的时候需要更新es 的库存信息
2)、商品有库存也要更新es 的信息
1.1.5 代码实现
- 接口文档
- SpuInfoController:
/**
* /product/spuinfo/{spuId}/up
* 商品上架功能
*/
@PostMapping("/{spuId}/up")
public R spuUp(@PathVariable("spuId") Long spuId){
spuInfoService.up(spuId);
return R.ok();
}
product微服务里组装好,search微服务里保存到es中,进行商品上架
- 商品上架entity SkuEsMode
商品上架需要在es中保存spu信息并更新spu的状态信息,由于SpuInfoEntity与索引的数据模型并不对应,所以我们要建立专门的vo进行数据传输。
package com.atguigu.common.to.es;
//商品在 es中保存的数据模型
@Data
public class SkuEsModel {
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 {
private Long attrId;
private String attrName;
private String attrValue;
}
}
- 商品上架service
- spu下的sku的规格参数相同,因此我们要将查询规格参数提前,只查询一次
- spu和sku:spu是款,sku是件。spu > sku.
1)先查库存(商品微服务远程调用库存微服务)
- 在ware库存微服务里添加"查询sku是否有库存"的controller
- WareSkuController
package com.atguigu.gulimall.ware.controller;
/**
* 查询sku是否有库存
*/
@PostMapping("/hasstock")
public R getSkuHasStock(@RequestBody List<Long> skuIds){
//sku_id,stock --- 引出创建SkuHasStockVo
List<SkuHasStockVo> vos = wareSkuService.getSkuHasStock(skuIds);
return R.ok().setData(vos);
}
- 在ware微服务中的vo包下面新建 SkuHasStockVo (方便查询库存) (后面product微服务也会用到,所以在后面会将其复制到common微服务中)
package com.atguigu.gulimall.ware.vo;
@Data
public class SkuHasStockVo {
private Long skuId;
private Boolean hasStock;
}
- WareSkuServiceImpl (查出当前商城下面的所有以sku_id和库存量为集合的数据)
package com.atguigu.gulimall.ware.service.impl;
@Override
public List<SkuHasStockVo> getSkuHasStock(List<Long> skuIds) {
List<SkuHasStockVo> collect = skuIds.stream().map(skuId -> {
SkuHasStockVo vo = new SkuHasStockVo();
//查询当前 sku的总库存量
//SELECT SUM(stock-stock_locked) FROM `wms_ware_sku` WHERE sku_id = 1
Long count = baseMapper.getSkuStock(skuId);
vo.setSkuId(skuId);
vo.setHasStock(count==null?false:count>0);
return vo;
}).collect(Collectors.toList());
return collect;
}
SELECT SUM(stock-stock_locked) FROM `wms_ware_sku` WHERE sku_id = 1
查询华为这个sku下的总库存量
- WareSkuDao (查库存)
package com.atguigu.gulimall.ware.dao;
@Mapper
public interface WareSkuDao extends BaseMapper<WareSkuEntity> {
Long getSkuStock(Long skuId);//一个参数的话,可以不用写@Param,多个参数一定要写,方便区分
}
- WareSkuDao.xml
<select id="getSkuStock" resultType="java.lang.Long">
SELECT SUM(stock-stock_locked) FROM `wms_ware_sku` WHERE sku_id=#{skuId} #动态获取
</select>
- 接着我们在商品微服务调用库存微服务的查询库存总量的方法来查询商品的库存总量
在 package com.atguigu.gulimall.product.feign下:
package com.atguigu.gulimall.product.feign;
@FeignClient("gulimall-ware") //调用库存微服务
public interface WareFeignService {
/**
* 1、R设计的时候可以加上泛型
* 2、直接返回我们想要的结果
* 3、自己封装解析结果
* @param skuIds
* @return
*/
@PostMapping("/ware/waresku/hasstock")//注意路径复制完全
R getSkuHasStock(@RequestBody List<Long> skuIds);
}
- 将 R 工具类进行改装,查完库存后直接返回封装好的数据(debug检查错误后最终选择自己封装解析结果) --- 解决
return R.ok().setData(vos)
package com.atguigu.common.utils;
public class R extends HashMap<String, Object> {
private static final long serialVersionUID = 1L;
/**
加入以下代码
*/
//利用 阿里巴巴提供的fastjson 进行逆转
public <T> T getData(TypeReference<T> typeReference){
/**
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
//默认是HashMap.Node<k,v> e
*/
Object data = get("data");
String s = JSON.toJSONString(data);
/**
String jsonStr = "[{\"id\":1001,\"name\":\"Jobs\"}]";
List<Model> models = JSON.parseObject(jsonStr, new TypeReference<List<Model>>() {});
Params:
text – json string
type – type refernce
features –
Returns:
*/
T t = JSON.parseObject(s, typeReference);
return t;
}
public R setData(Object data){
//将数据data和data进行映射
put("data",data);
return this;
}
...
2)商品上架,保存到es中(商品微服务远程调用搜索微服务)
- 搜索微服务下创建controller.ElasticSaveController
package com.atguigu.gulimall.search.controller;
@Slf4j
@RequestMapping("/search/save")
@RestController
public class ElasticSaveController {
@Autowired
ProductSaveService productSaveService;
//上架商品
// 添加@RequestBody 将 请求体中的 List<SkuEsModel> 集合转换为json数据,因此请求方式必须为 @PostMapping
// GET方式无请求体,所以使用@RequestBody接收数据时,前端不能使用GET方式提交数据,而是用POST方式进行提交。
@PostMapping("/product")
public R productStatusUp(@RequestBody List<SkuEsModel> skuEsModels){
// 如果返回的是 boolean 类型的false,说明我们的 sku数据有问题
//如果返回的是 catch里面的内容,可能是 es 客户端连接不上问题
boolean b = false;
try {
b = productSaveService.productStatusUp(skuEsModels);
}catch (Exception e){
log.error("ElasticSaveController商品上架错误: {}",e);
return R.error(BizCodeEnume.PRODUCT_UP_EXCEPTION.getCode(), BizCodeEnume.PRODUCT_UP_EXCEPTION.getMsg());
}
if (!b){
return R.ok();
}else {
return R.error(BizCodeEnume.PRODUCT_UP_EXCEPTION.getCode(), BizCodeEnume.PRODUCT_UP_EXCEPTION.getMsg());
}
}
}
- 搜索微服务下创建service和impl
- ProductSaveService
package com.atguigu.gulimall.search.service;
public interface ProductSaveService {
boolean productStatusUp(List<SkuEsModel> skuEsModels) throws IOException;
}
- ProductSaveServiceImpl
package com.atguigu.gulimall.search.service.impl;
@Slf4j
@Service("productSaveService")
public class ProductSaveServiceImpl implements ProductSaveService {
@Autowired
RestHighLevelClient restHighLevelClient; //eslaticsearch和springboot整合中我们就使用的这个
@Override
public boolean productStatusUp(List<SkuEsModel> skuEsModels) throws IOException {
//保存到es
//1.给 es 中建立索引。product,建立好映射关系。 (提前使用kibana为商品建立索引及映射)
//2.给 es 中保存这些数据
//bulkRequest 用来批量处理请求
BulkRequest bulkRequest = new BulkRequest();
for (SkuEsModel model : skuEsModels) {
//1.构造保存请求 index在eslaticsearch中是保存操作
/**
public IndexRequest(String index) {
super(NO_SHARD_ID);
this.index = index;
}
*/
IndexRequest indexRequest = new IndexRequest(EsConstant.PRODUCT_INDEX);
//设置索引文档的id
indexRequest.id(model.getSkuId().toString());
String s = JSON.toJSONString(model);
/**
public IndexRequest source(String source, XContentType xContentType) {
return source(new BytesArray(source), xContentType);
}
*/
indexRequest.source(s, XContentType.JSON);
//批量操作中添加index请求
bulkRequest.add(indexRequest);
}
//BulkRequest bulkRequest, RequestOptions options
//批量执行的响应
BulkResponse bulk = restHighLevelClient.bulk(bulkRequest, GulimallElasticSearchConfig.COMMON_OPTIONS);
//TODO 1、如果批量错误
boolean b = bulk.hasFailures();
List<String> collect = Arrays.stream(bulk.getItems()).map(item -> {
return item.getId();
}).collect(Collectors.toList());
log.info("商品上架完成:{},返回数据:{}",collect,bulk.toString());
return b;
}
}
-
**RestHighLevelClient ** eslaticsearch和springboot整合中我们就使用的这个
- 搜索微服务下创建constant.EsConstant
package com.atguigu.gulimall.search.constant;
public class EsConstant {
public static final String PRODUCT_INDEX = "product"; //sku数据在 es中的索引
}
- fenign 调用: gulimall-product 调用 gulimall-search
- 商品微服务下SearchFeignService
package com.atguigu.gulimall.product.feign;
@FeignClient("gulimall-search")
public interface SearchFeignService {
@PostMapping("/search/save/product")
R productStatusUp(@RequestBody List<SkuEsModel> skuEsModels);
}
- 上架失败返回R.error(错误码,消息)
此时再定义一个错误码枚举。在接收端获取他返回的状态码
- BizCodeEnume(common微服务下的exception:专门存放设置错误码)
- 注意枚举类如果新增一个,必须要将
;
改为,
package com.atguigu.common.exception;
PRODUCT_UP_EXCEPTION(11000,"商品上架异常");
- 点击上架后再让数据库中状态变为上架状态
- 这里在 gulimall-common包下的constant.ProductConstant 类中创建一个新的枚举类(复制里面有的类,稍作修改即可)
package com.atguigu.common.constant;
public class ProductConstant {
...
public enum StatusEnum {
NEW_SPU(0,"新建"), SPU_UP(1,"商品上架"),SPU_DOWN(2,"商品下架");
private int code;
private String msg;
StatusEnum(int code, String msg) {
this.code = code;
this.msg = msg;
}
public int getCode() {
return code;
}
public String getMsg() {
return msg;
}
}
}
3) 商品微服务中商品上架总代码
- SpuInfoController
package com.atguigu.gulimall.product.controller;
/**
* /product/spuinfo/{spuId}/up
* 商品上架功能
*/
@PostMapping("/{spuId}/up")
public R spuUp(@PathVariable("spuId") Long spuId){
spuInfoService.up(spuId);
return R.ok();
}
- SpuInfoServiceImpl
package com.atguigu.gulimall.product.service.impl;
@Override
public void up(Long spuId) {
//1.查出当前 spuid 对应的所有 sku信息、品牌的名字
List<SkuInfoEntity> skus = skuInfoService.getSkusBySpuId(spuId);
List<Long> skuIdList = skus.stream().map(SkuInfoEntity::getSkuId).collect(Collectors.toList());
//TODO 4、查询当前sku的所有可以用来被检索的规格属性,
//SkuInfoEntity --- pms_product_attr_value spu属性值表
List<ProductAttrValueEntity> baseAttrs = attrValueService.baseAttrlistforspu(spuId);
List<Long> attrIds = baseAttrs.stream().map(attr -> { //返回所有属性的id
return attr.getAttrId();
}).collect(Collectors.toList());
List<Long> searchAttrIds = attrService.selectSearchAttrIds(attrIds);
Set<Long> idSet = new HashSet<>(searchAttrIds);//因为是kv 键值对,转换成 set 集合比较方便
// 从 baseAttrs 集合中 过滤 出 attrValueEntities 集合
List<SkuEsModel.Attrs> attrsList = baseAttrs.stream().filter(item -> {
return idSet.contains(item.getAttrId());
}).map(item -> {
//将 set集合 映射 成 map集合
SkuEsModel.Attrs attrs = new SkuEsModel.Attrs();
BeanUtils.copyProperties(item, attrs);//属性对拷:item 是数据库中查出来的数据
return attrs;
}).collect(Collectors.toList());
//TODO 1、发送远程调用,库存系统查询是否有库存
//由于远程调用可能出现网络问题,所以需要进行try - catch处理一下
Map<Long, Boolean> stockMap = null;
try {
R r = wareFeignService.getSkuHasStock(skuIdList);
TypeReference<List<SkuHasStockVo>> typeReference = new TypeReference<List<SkuHasStockVo>>(){
};
stockMap = r.getData(typeReference).stream().collect(Collectors.toMap(SkuHasStockVo::getSkuId, item -> item.getHasStock()));
}catch (Exception e){
log.error("库存服务查询异常:原因{}",e);
}
//2.封装每个sku的信息
Map<Long, Boolean> finalStockMap = stockMap;
List<SkuEsModel> upProducts = skus.stream().map(sku -> { //通过 stream API 将 skus中的 数据遍历
//组装我们需要的数据
SkuEsModel esModel = new SkuEsModel();
BeanUtils.copyProperties(sku, esModel);//属性对拷,将 sku中的属性 拷贝到 esmodel中
//需要单独处理的数据 ,SkuInfoEntity SkuEsModel中相比SkuInfoEntity少的数据。
//skuPrice,skuImg
esModel.setSkuPrice(sku.getPrice());
esModel.setSkuImg(sku.getSkuDefaultImg());
//hotScore(热度评分) hasStock(库存)
//设置库存信息
//如果远程调用出现问题,默认给 true值;如果没有问题,那就赋真正的值 为了不影响正常调用
if (finalStockMap == null){
esModel.setHasStock(true);
}else {
esModel.setHasStock(finalStockMap.get(sku.getSkuId()));
}
//TODO 2、热度评分。0
esModel.setHotScore(0L);//这里的热度评分应该是一个比较复杂的操作,这里简单处理一下
//TODO 3、查询品牌和分类的名字信息
//品牌
BrandEntity brand = brandService.getById(esModel.getBrandId());
esModel.setBrandName(brand.getName());
esModel.setBrandImg(brand.getLogo());
//分类
CategoryEntity category = categoryService.getById(esModel.getCatalogId());
esModel.setCatalogName(category.getName());
//设置检索属性
esModel.setAttrs(attrsList);
return esModel;
}).collect(Collectors.toList());
//TODO 5、将数据发送给 es 进行保存,gulimall-search
R r = searchFeignService.productStatusUp(upProducts);
if (r.getCode() == 0){
//远程调用成功
//TODO 6、修改当前spu的状态
baseMapper.updataSpuStatus(spuId, ProductConstant.StatusEnum.SPU_UP.getCode());
}else {
//远程调用失败
//TODO 7、重复调用?接口幂等性;重试机制? xxx
//Feign 调用流程原理
/**
* 1.构造请求数据,将对象转为json;
* RequestTemplate template = buildTemplateFromArgs.create(argv);
* 2.发送请求进行执行(执行成功会解码响应数据);
* executeAndDecode(template)'
* 3.执行请求会有重试机制
* while(true){
* try{
* executeAndDecode(template);
* }catch(){
* try{ retryer.continueOrPropagate(e);}catch(){throw ex;
* continue;
* }
* }
*
*/
}
}
- AttrService
package com.atguigu.gulimall.product.service;
List<Long> selectSearchAttrIds(@Param("attrIds") List<Long> attrIds); //加这个@Param,否则会出现
- AttrServiceImpl
package com.atguigu.gulimall.product.service;
@Override
public List<Long> selectSearchAttrIds(List<Long> attrIds) {
/**
* SELECT attr_id FROM `pms_attr` WHERE attr_id IN(?) AND search_type = 1
*/
return baseMapper.selectSearchAttrIds(attrIds);
}
- AttrDao(查询出哪些规格属性可以被检索)
package com.atguigu.gulimall.product.dao;
List<Long> selectSearchAttrIds(@Param("attrIds") List<Long> attrIds);
- AttrDao.xml
<select id="selectSearchAttrIds" resultType="java.lang.Long">
SELECT attr_id FROM `pms_attr` WHERE attr_id IN
<foreach collection="attrIds" item="id" separator="," open="(" close=")">
#{id}
</foreach>
AND search_type = 1
</select>
- SpuInfoDao (更新库存状态)
package com.atguigu.gulimall.product.dao;
@Mapper
public interface SpuInfoDao extends BaseMapper<SpuInfoEntity> {
void updataSpuStatus(@Param("spuId") Long spuId, @Param("code") int code);
}
- SpuInfoDao.xml
<update id="updataSpuStatus">
UPDATE `pms_spu_info` SET publish_status =#{code},update_time=NOW() WHERE id =#{spuId}
</update>
4)上架中调用的两个远程微服务
gulimall-product 调用 gulimall-search 将 商品上架内容保存在 ElasticSearch中,方便全文检索:
SearchFeignService
@FeignClient("gulimall-search")
public interface SearchFeignService {
@PostMapping("/search/save/product")
R productStatusUp(@RequestBody List<SkuEsModel> skuEsModels);
}
gulimall-product 调用 gulimall-ware 将 查询 商品库存:
WareFeignService
@FeignClient("gulimall-ware") //说明调用哪一个 远程服务
public interface WareFeignService {
/**
* 1、R设计的时候可以加上泛型
* 2、直接返回我们想要的结果
* 3、自己封装解析结果
* @param skuIds
* @return
*/
@PostMapping("/ware/waresku/hasstock")//注意路径复制完全
R getSkuHasStock(@RequestBody List<Long> skuIds);
}
5)踩坑
报错:
nested exception is org.apache.ibatis.binding.BindingException: Parameter 'attrIds' not found. Avail .....
- 解决办法:(来自网络)
我的错误就是没有在dao或者接口中加入@Param这个注解。找不到对应的属性名。 加入注解即可。
6)效果展示
商品成功上架,显示状态 为 已上架
1.2 商城系统首页
前面的分布式基础我们使用的是前后端分离,即前端使用vue进行开发,后端就只做后端代码。但是从分布式高级开始,我们开始使用动静分离的方式。
- 对于以前的代码,我们可以将分布式基础中关于rest风格的,对接app操作的,前后端分离的包从controller改为app.将对接页面的controller,我们放到web中
1.2.1 整合thymeleaf渲染首页
1)、导入依赖
<!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-thymeleaf -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
将thymeleaf模板引擎导入商品微服务中
2)、资源存放
将老师给的课件中有关首页的资源放到gulimall-product包下的resources下。index文件夹放到static静态资源文件夹下,index.html放到templates文件夹下。
3)、关闭thymeleaf缓存
我们在application.yml中关闭thymeleaf缓存,方便我们在更新网页资源的时候可以实时更新。
4)、访问首页
localhost:11000
5)、踩坑
这里记录一个坑:
按照老师的设置之后启动访问首页,一直显示访问不了。也不是什么端口问题,后面发现是缓存的问题。
解决办法:删除 target目录,重启商品微服务。
1.2.2 整合dev-tools渲染一级分类
1)、编写IndexController
-
我们现在访问的首页的数据都是写死的,我们对于一级分类数据需要从数据库中查出。
在web包下编写一个IndexController类,实现我们访问localhost:11000/ 和localhost:11000/index.html都可以跳转到首页。
package com.atguigu.gulimall.product.web;
/**
* @author hxld
* @create 2022-11-26 20:55
*/
@Controller
public class IndexController {
@Autowired
CategoryService categoryService;
@GetMapping({"/","/index.html"})
public String indexPage(Model model){
//TODO 1.查出所有的1级分类
List<CategoryEntity> categoryEntities = categoryService.getLevel1Categorys();
model.addAttribute("categorys",categoryEntities);
return "index";
}
}
2)、service和serviceImpl
-
CategoryService
package com.atguigu.gulimall.product.service; List<CategoryEntity> getLevel1Categorys();
-
CategoryServiceImpl
package com.atguigu.gulimall.product.service.impl;
@Override
public List<CategoryEntity> getLevel1Categorys() {
//parent_cid=0 或者 cat_level=1 都表示是一级分类。这里我们选用parent_cid=0
List<CategoryEntity> entities = baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("parent_cid", 0));
return entities;
}
3) 、引入dev-tools
对于商城首页的编写,我们每次修改之后都希望不重启微服务就可以实时查看是否修改成功。所以我们可以引入热部署工具。
当然对于简单的页面修改我们可以直接ctrl+shift+f9。但是对于一些类或者方法的修改我们还是建议重启微服务,因为可以避免一些不必要的错误。
<!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-devtools -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<optional>true</optional> <!--这个才相当于将工具真正的导入进来了-->
</dependency>
模板引擎 1)、thymeleaf-stater:关闭缓存 2)、静态资源都放在static文件夹下就可以按照路径直接访问 3)、页面放在templates下,直接访问 4)、页面修改不重启服务器实时更新 1)、引入dev-tools 2)、修改网页面 ctrl+shift+f9重新自动编译下页面,代码配置,推荐重启微服务
4) 、修改index.html
<!--轮播主体内容-->
<div class="header_main">
<div class="header_banner">
<div class="header_main_left">
<ul>
<li th:each="category : ${categorys}">
<!--th:attr="ctg-data=${category.catId}" 自定义属性写法-->
<a href="#" class="header_main_left_a" th:attr="ctg-data=${category.catId}"><b th:text="${category.name}">家用电器 </b></a>
</li>
</ul>
将写死的内容给删除掉,使用thymeleaf语法进行编写,动态获取数据。
1.2.3 渲染二、三级分类数据
1)、删除静态资源json数据
前面的二、三级分类的数据都是写在index.json文件夹下的catalog.json中,是写死的。我们将这些json数据进行格式化,如下图。
- 删除json,我们自己编写
2)、代码实现
- 修改js文件夹下的catalogLoader.js,改成如下图所示,到时候我们向
index/catalog.json
发送请求查数据
- 根据上面的json解析的数据新建Catelog2Vo
package com.atguigu.gulimall.product.vo;
/**
* @author hxld
* @create 2022-11-26 21:36
*/
//2级分类vo
@NoArgsConstructor
@AllArgsConstructor
@Data
public class Catelog2Vo {
private String catalog1Id;//1级分类id
private List<Catelog3Vo> catalog3List; //三级子分类
private String id;
private String name;
/**
* 三级分类 vo
* "catalog2Id":"61",
* "id":"610",
* "name":"商务休闲鞋"
*/
@NoArgsConstructor
@AllArgsConstructor
@Data
public static class Catelog3Vo {
private String catalog2Id; //父分类,2级分类 id
private String id;
private String name;
}
}
- indexcontroller中编写
//index/catalog.json
@ResponseBody
@GetMapping("/index/catalog.json")
public Map<String, List<Catelog2Vo>> getCatalogJson() {
Map<String, List<Catelog2Vo>> catalogJson = categoryService.getCatalogJson();
return catalogJson;
}
- CategoryService
Map<String, List<Catelog2Vo>> getCatalogJson();
- CategoryServiceImpl
@Override
public Map<String, List<Catelog2Vo>> getCatalogJson() {
//1.查出所有1级分类
List<CategoryEntity> level1Categorys = getLevel1Categorys();
//2.封装数据
Map<String, List<Catelog2Vo>> parent_cid = level1Categorys.stream().collect(Collectors.toMap(k -> k.getCatId().toString(), v -> {
//1.每一个的一级分类,查到这个一级分类的二级分类
List<CategoryEntity> categoryEntities = baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("parent_cid", v.getParentCid()));
//2.封装上面的结果
List<Catelog2Vo> catelog2Vos = null;
if (categoryEntities != null) {
catelog2Vos = categoryEntities.stream().map(l2 -> {
Catelog2Vo catelog2Vo = new Catelog2Vo(v.getCatId().toString(), null, l2.getCatId().toString(), l2.getName());
//1.找到当前二级分类的三级分类,封装成 vo
List<CategoryEntity> level3Catelog = baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("parent_cid", l2.getCatId()));
// 三级分类有数据的情况下
if (level3Catelog != null){
List<Catelog2Vo.Catelog3Vo> collect = level3Catelog.stream().map(l3 -> {
//2.封装成指定格式
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;
}
- 访问首页
3)、问题
我发现我的在数据库中修改了三级分类的名字,但是首页不实时更新,正常来说数据都是从mysql数据库中取出的,应该会更新的,但是我的不知道为什么不更新。
1.3 nginx 搭建域名访问环境
1.3.1 反向代理配置
1)、编写本机hosts
我们使用SwitchHosts文件进行更方便的hosts文件的编写。
192.168.56.10 gulimall.com
- 当我们在浏览器输入gulimall.com.因为本机hosts中指定了映射,访问gulimall.com.会访问192.168.56.10 虚拟机
2) 、nginx中配置反向代理
- Nginx的配置文件详解:
我们查看到配置不仅仅在/nginx.conf,这个是总配置。为了不让单个配置文件过大,nginx还会在/etc/nginx/conf.d/*.conf存放配置文件,然后总配置文件在进行包含。
- 我们修改默认配置文件之前先进行备份。
- 修改server_name和 配置一个代理
nginx中我们设置了监听80端口,当端口(默认)是80.访问是gulimall.com的时候,会路由访问http://192.168.56.1:11000商品微服务端口,即又跳回到本机11000端口。
- 重启nginx
- 访问测试
当我们在浏览器输入gulimall.com.因为本机hosts中指定了映射,访问gulimall.com.会访问192.168.56.10 虚拟机。然后我们在虚拟机中的nginx中设置了映射。nginx中我们设置了监听80端口,当端口是80.访问是gulimall.com的时候,会代理访问http://192.168.56.1:11000商品微服务端口,即又跳回到本机11000端口。
1.3.2 负载均衡到网关
我们想一个问题,以后是集群的时候,难道我们没增加一台机器,就要重新去修改nginx吗?
所以我们解决办法是直接让nginx代理到网关,由网关来处理,加上nacos,就能动态上下线。
1)、nginx.conf中配置上游服务器
在http{}块内:
2)、gulimall.conf中配置代理
如下图:
3)、网关微服务中配置路由规则
注意这个配置 一定要放在 最后:因为如果放在前面 ,它会禁用下面其他的网关配置:
比如,http://gulimall.com//product/attrattrgrouprelation/list
这个api 接口访问,它会首先到 gulimall.com,然后因为没有进行 截串 设置(截取 /api前缀),出现 404 访问不到。
4)、测试及解决办法
我们在浏览器中访问gulimall.com的时候,报404错。
原因:
Nginx 转发给网关的时候,会丢失很多请求头信息,这里就缺失了 host ,这里我们暂时只配置 上 host 地址,以后缺啥补啥。
- 来到gulimall.conf中修改,添加丢失的host 使用$host动态获取。
nginx -- 网关 转给商品服务
-
测试
访问成功。
-
总结
-
-
首先浏览器访问 gulimall.com
因为我们在Windows配置了host映射:gulimall.com 映射IP 192.168.56.10(虚拟机Ip)
所以会直接来到虚拟机 -
又因为 浏览器访问 默认不带端口,那就是访问80端口,所以会来到 Nginx,我们又配置 了 80端口监听 gulimall.com 这个域名;此外由于 location/下的配置:代理转发:
Nginx 又代理给网关,这里注意一个细节:由于Nginx 转发会丢失 一些请求头信息,所以我们要加上请求头的配置,这里暂时只配置 host地址,之后的其他请求头配置我们用到的时候在进行添加;
-
网关发现 域名 是gulimall.com,进而就会找到 对应的配置:路由到商品服务,进而就转给了商品服务,这处网关配置一定要放在最后面,避免放在前面禁用后面的其他截串配置。
-
gateway 是前端工程 到 后台服务器之间的一个 对内网关,nginx是用户到 前端工程 的网关,对外网关.
1.4 缓存
1.4.1 缓存
1) 、缓存使用
2)、本地缓存
3)、整合redis 作为缓存
- 引入redis-starter(前提是docker中要安装redis)
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
- 配置redis
spring:
redis:
host: 192.168.56.10
port: 6379 #不写默认也是6379
- 使用RedisTemplate 操作redis
@Autowired
StringRedisTemplate stringRedisTemplate;
@Test
public void test(){
//hello world
ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
//保存
ops.set("hello","world"+ UUID.randomUUID().toString());
//查询
String hello = ops.get("hello");
System.out.println("之前保存的数据:"+hello);
}
- 优化菜单获取业务getCatalogJson
package com.atguigu.gulimall.product.service.impl;
@Override
public Map<String, List<Catelog2Vo>> getCatalogJson() {
//给缓存中放json字符串,拿出的json字符串,还要逆转为能用的对象类型(序列化与反序列化)
//1.加入缓存逻辑,注意缓存中存放的数据是json字符串
//JSON跨语言,跨平台兼容
String catalogJSON = redisTemplate.opsForValue().get("catalogJSON");
if(StringUtils.isEmpty(catalogJSON)){
//2.如果缓存中没有,就查询数据库
System.out.println("缓存不命中....查询数据库....");
Map<String, List<Catelog2Vo>> catalogJsonFromDb = getCatalogJsonFromDbWithRedisLock();
return catalogJsonFromDb;
}
System.out.println("缓存命中....");
// 缓存中有的话就直接逆转,转 为我们指定的对象
Map<String, List<Catelog2Vo>> result = JSON.parseObject(catalogJSON, new TypeReference<Map<String, List<Catelog2Vo>>>(){});
return result;
}
- lettuce堆外内存溢出bug
-
产生堆外内存溢出:OutOfDirectMemoryError
1.springboot2.0以后默认使用lettuce作为操作redis的客户端,它使用netty进行网络通信
2.lettuce的bug导致netty堆外内存溢出 -Xmx300m;netty如果没有指定堆外内存,默认使用-Xmx300m
可以通过-Dio.netty.maxDirectMemory进行设置 -
解决方案:不能使用-Dio.netty.maxDirectMemory只去调大堆外内存
1.升级lettuce客户端
2.切换使用jedis (开发中先选择这个,上线之后在使用lettcue,通过日志来判断问题在哪) -
redisTemplate:
lettuce、jedis操作redis的底层客户端。spring再次封装redisTemplate;
<!--引入redis-->
<!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-data-redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<!--排除使用lettuce ,改为使用jedis-->
<exclusions>
<exclusion>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
1.4.2 缓存失效问题
先来解决大并发读情况下的缓存失效问题;
1)、缓存穿透
-
缓存穿透是指查询一个一定不存在的数据,由于缓存是不命中,将去查询数据库,但是数据库也无此记录,我们没有将这次查询的null 写入缓存,这将导致这个不存在的数据每次
请求都要到存储层去查询,失去了缓存的意义。 -
在流量大时,可能DB 就挂掉了,要是有人利用不存在的key 频繁攻击我们的应用,这就是漏洞。
-
解决:
- 缓存空结果、并且设置短的过期时间。
- 布隆过滤器、mvc拦截器
2)、缓存雪崩
- 缓存雪崩是指在我们设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到DB,DB 瞬时压力过重雪崩。
- 解决:
- 原有的失效时间基础上增加一个随机值,比如1-5 分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。
3)、缓存击穿
-
对于一些设置了过期时间的key,如果这些key 可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。
-
这个时候,需要考虑一个问题:如果这个key 在大量请求同时进来前正好失效,那么所有对这个key 的数据查询都落到db,我们称为缓存击穿。
-
解决:
- 加锁
- 加互斥锁:业界比较常用的做法,是使用mutex。简单地来说,就是在缓存失效的时候(判断拿出来的值为空),不是立即去load db去数据库加载,而是先使用缓存工具的某些带成功操作返回值的操作(比如Redis的
SETNX
或者Memcache的ADD
)去set一个mutex key,当操作返回成功时,再进行load db的操作并回设缓存;否则,就重试整个get缓存的方法。
4)、本地锁
对于以上的问题,我们的解决办法:
1.空结果缓存:解决缓存穿透
2.设置过期时间(加随机值):解决缓存雪崩
3.加锁:解决缓存击穿
对于单体应用,使用本地锁的方式来进行解决缓存失效问题。
//从数据库查询并封装数据
// @Override //本地锁
public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithLocalLock() {
/**
* 只要是同一把锁,就能锁住需要这个锁的所有线程
*
* 1.synchronized (this) :springboot所有的组件在容器中都是单例的
* 2. public synchronized Map<String, List<Catelog2Vo>> getCatalogJsonFromDb() {}加到方法上
*
* TODO 本地锁:synchronized,JUC(Lock),在分布式情况下,想要锁住所有,必须使用分布式锁。本地锁只能锁住当前服务进程(单个服务器)
* 想要在分布式情况下,锁住所有,必须使用分布式锁。
*/
synchronized (this){
//得到锁以后,我们应该再去缓存中确定一次,如果没有才需要继续查询
return getDataFromDb();
}
}
- 我们将原来的查询二、三级分类的方法可以使用idea中的快捷方式抽取为一个方法。
抽取代码为方法:
修改抽取的方法的名字:
测试本地锁在分布式情况下会遇到的问题
- 快速复制微服务,模拟多个微服务
右键点击服务,copy configuration
在
program arguments: --server.port=10003
1.4.3 分布式锁
1)、分布式锁与本地锁
2)、分布式锁实现
redis中设置占坑和设置过期时间的操作:
详细可查看redis文档:https://redis.io/commands/set/。
3)、分布式锁演进
- 阶段一
- 阶段二
- 阶段三
- 阶段四
- 阶段五
- 我们在最后解决方案中使用的是redis+lua脚本进行完成,保证删除锁式原子性的。
- lua脚本
if redis.call("get",KEYS[1]) == ARGV[1]
then
return redis.call("del",KEYS[1])
else
return 0
end
- 最终代码
//分布式锁
public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithRedisLock() {
//1.占分布式锁。去redis占坑
//Redis Documentation: SETNX (redis中加锁操作) --- > setIfAbsent
String uuid = UUID.randomUUID().toString(); //4
// Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock","1111",300,TimeUnit.SECONDS); //阶段3
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock",uuid,300,TimeUnit.SECONDS); //阶段4
// Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "111");
if(lock){
// System.out.println("获取分布式锁成功。。。。。");
//加锁成功...执行业务
//2.先要设置过期时间,注意过期时间和加锁操作必须是同步的,即原子操作。 //
// redisTemplate.expire("lock",30,TimeUnit.SECONDS);//阶段2
Map<String, List<Catelog2Vo>> dataFromDb = null;
try{
dataFromDb = getDataFromDb();
}finally {
//lua脚本 阶段5 lock = KEYS[1] uuid = ARGV[1] 删除成功返回1,删除失败返回0
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
//删除锁
Long lock1 = redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class), Arrays.asList("lock"), uuid);
}
// redisTemplate.delete("lock"); //删除锁 //阶段3
//获取值对比+对比成功删除 也需要是原子操作 使用lua脚本解锁
/**
String lockValue = redisTemplate.opsForValue().get("lock");
if(uuid.equals(lockValue)){
//只有最开始设置锁的uuid和现在获取到的锁,即要删除的锁,即自己的锁一样了,才删除
redisTemplate.delete("lock"); //删除锁
}
*/
return dataFromDb;
}else {
//加锁失败...
//休眠100ms重试
// System.out.println("获取分布式锁失败。。。。。等待重试");
try{
Thread.sleep(200);
}catch (Exception e){
}
return getCatalogJsonFromDbWithLocalLock();//自旋的方式
}
}
上面的lua脚本写法每次用分布式锁时比较麻烦,我们可以采用redisson现有框架。
4)、Redisson 完成分布式锁
1. 简介
官方文档:https://github.com/redisson/redisson/wiki/目录
2.环境搭建
导入依赖
- 先用redisson用作练习,上手,了解。后面可以使用redisson-spring-boot-starter
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.4</version>
</dependency>
- 创建配置类MyRedissonConfig 文档:https://github.com/redisson/redisson/wiki/2.-配置方法
package com.atguigu.gulimall.product.config;
/**
* @author hxld
* @create 2022-11-28 20:43
*/
@Configuration
public class MyRedissonConfig {
/**
* 所有对Redisson的使用都是通过RedissonClient对象
* @return
* @throws IOException
*/
@Bean(destroyMethod = "shutdown")
public RedissonClient redisson() throws IOException{
//1.创建配置
Config config = new Config();
//Redis url should start with redis:// or rediss:// (for SSL connection)
// 创建单例模式的配置
config.useSingleServer().setAddress("redis://192.168.56.10:6379");
//2.根据config创建出redissonclient示例
RedissonClient redissonClient = Redisson.create(config);
return redissonClient;
}
}
3. 可重入锁(Reentrant Lock)
A调用B。AB都需要同一把锁,此时可重入锁就可以重入,A就可以调用B。不可重入锁时,A调用B将死锁
// 参数为锁名字
RLock lock = redissonClient.getLock("CatalogJson-Lock");//该锁实现了JUC.locks.lock接口
lock.lock();//阻塞等待
// 解锁放到finally // 如果这里宕机:有看门狗,不用担心
lock.unlock();
基于Redis的Redisson分布式可重入锁RLock
Java对象实现了java.util.concurrent.locks.Lock
接口。同时还提供了异步(Async)、反射式(Reactive)和RxJava2标准的接口。
-
锁的续期:大家都知道,如果负责储存这个分布式锁的Redisson节点宕机以后,而且这个锁正好处于锁住的状态时,这个锁会出现锁死的状态。为了避免这种情况的发生,Redisson内部提供了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前,不断的延长锁的有效期。默认情况下,看门狗的检查锁的超时时间是30秒钟(每到20s就会自动续借成30s,是1/3的关系),也可以通过修改Config.lockWatchdogTimeout来另行指定。
//1.获取一把锁,只要锁的名字一样,就是同一把锁 RLock lock = redissonClient.getLock("my-lock"); //2.加锁 lock.lock(); //阻塞式等待 // lock.lock(10, TimeUnit.SECONDS); //10s自动解锁,自动解锁时间一定要大于业务的执行事件 //问题:lock.lock(10,TimeUnit.SECONDS);在锁时间到了以后,不会自动续期。 //1.如果我们传递了锁的超时时间,就发送给redis执行脚本,进行占锁,默认超时就是我们指定的时间 //2.如果我们未指定锁的超时时间,就使用30*1000【LockWatchdogTimeout看门狗的默认时间】; //只要占锁成功,就会启动一个定时任务【重新给锁设置过期时间,新的过期时间就是看门狗的默认时间】,每隔10s都会自动续期,续为30s //internalLockLeaseTime【看门狗时间】 /3 ,10s /** * 默认加的锁都是30s * 1.锁的自动续期,如果业务运行时间超长,redisson运行期间会自动给快要失效的锁续上新的30s,不用担心业务时间长,锁自动过期被删掉 * 2.加锁的业务只要运行完成,就不会给当前锁续期,即使不手动解锁,锁默认在30s以后自动删除。 * */ //最佳实战: //1)、lock.lock(30,TimeUnit.SECONDS);省掉了整个续期操作,手动解锁 try{ System.out.println("加锁成功,执行业务..."+Thread.currentThread().getId()); Thread.sleep(30000); } catch (Exception e) { } finally { //3.解锁 System.out.println("释放锁..."+Thread.currentThread().getId()); lock.unlock(); } return "hello"; }
-
Redisson同时还为分布式锁提供了异步执行的相关方法:
RLock lock = redisson.getLock("anyLock"); lock.lockAsync(); lock.lockAsync(10, TimeUnit.SECONDS); Future<Boolean> res = lock.tryLockAsync(100, 10, TimeUnit.SECONDS);
-
-
RLock
对象完全符合Java的Lock规范。也就是说只有拥有锁的进程才能解锁,其他进程解锁则会抛出IllegalMonitorStateException
错误。但是如果遇到需要其他进程也能解锁的情况,请使用分布式信号量Semaphore
对象.
public Map<String, List<Catalog2Vo>> getCatalogJsonDbWithRedisson() {
Map<String, List<Catalog2Vo>> categoryMap=null;
RLock lock = redissonClient.getLock("CatalogJson-Lock");
lock.lock();
try {
Thread.sleep(30000);
categoryMap = getCategoryMap();
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
lock.unlock();
return categoryMap;
}
}
最佳实战:自己指定锁时间,时间长点即可
lock.lock(30,TimeUnit.SECONDS);省掉了整个续期操作,手动解锁
4. 读写锁(ReadWriteLock)
基于Redis的Redisson分布式可重入读写锁RReadWriteLock Java对象实现了java.util.concurrent.locks.ReadWriteLock接口。其中读锁和写锁都继承了RLock接口。
分布式可重入读写锁允许同时有多个读锁和一个写锁处于加锁状态。
RReadWriteLock rwlock = redisson.getReadWriteLock("anyRWLock");
// 最常见的使用方法
rwlock.readLock().lock();
// 或
rwlock.writeLock().lock();
// 10秒钟以后自动解锁
// 无需调用unlock方法手动解锁
rwlock.readLock().lock(10, TimeUnit.SECONDS);
// 或
rwlock.writeLock().lock(10, TimeUnit.SECONDS);
// 尝试加锁,最多等待100秒,上锁以后10秒自动解锁
boolean res = rwlock.readLock().tryLock(100, 10, TimeUnit.SECONDS);
// 或
boolean res = rwlock.writeLock().tryLock(100, 10, TimeUnit.SECONDS);
...
lock.unlock();
//保证一定能读到最新数据,修改期间,写锁是一个排他锁(互斥锁)。读锁是一个共享锁
//写锁没释放就必须等待
//读+读:相当于无锁,并发读,只会在redis中记录好,所有当前的读锁。他们都会同时加锁成功
//写+读:等待写锁释放
//写+写:阻塞方式
//读+写:有读锁,写也需要等待
//只要有写的存在,都必须等待。
@GetMapping("/write")
@ResponseBody
public String writeValue(){
RReadWriteLock lock = redissonClient.getReadWriteLock("rw-lock");
String s = "";
RLock rLock = lock.writeLock();
try {
//1.改数据加写锁,读数据加读锁
rLock.lock();
System.out.println("写锁加锁成功:..."+Thread.currentThread().getId());
s = UUID.randomUUID().toString();
Thread.sleep(30000);
redisTemplate.opsForValue().set("writeValue",s);
} catch (Exception e) {
e.printStackTrace();
}finally {
rLock.unlock();
}
return s;
}
@GetMapping("/read")
@ResponseBody
public String readValue(){
RReadWriteLock lock = redissonClient.getReadWriteLock("rw-lock");
String s = "";
//加读锁
RLock rLock = lock.readLock();
rLock.lock();
System.out.println("读锁加锁成功:..."+Thread.currentThread().getId());
try {
s = redisTemplate.opsForValue().get("writeValue");
} catch (Exception e) {
e.printStackTrace();
}finally {
rLock.unlock();
}
return s;
}
上锁时在redis的状态:
HashWrite-Lock
key:mode value:read
key:sasdsdffsdfsdf... value:1
5. 信号量(Semaphore)
信号量为存储在redis中的一个数字,调用acquire()方法获取资源时应该是让数字-1吧,同样release()方法释放资源会让数字+1。
基于Redis的Redisson的分布式信号量(Semaphore)Java对象RSemaphore采用了与java.util.concurrent.Semaphore相似的接口和用法。同时还提供了异步(Async)、反射式(Reactive)和RxJava2标准的接口。
RSemaphore semaphore = redisson.getSemaphore("semaphore");
semaphore.acquire();
//或
semaphore.acquireAsync();
semaphore.acquire(23);
semaphore.tryAcquire();
//或
semaphore.tryAcquireAsync();
semaphore.tryAcquire(23, TimeUnit.SECONDS);
//或
semaphore.tryAcquireAsync(23, TimeUnit.SECONDS);
semaphore.release(10);
semaphore.release();
//或
semaphore.releaseAsync();
/**
* 车库停车 3车位
* 信号量也可以用作分布式限流
*/
@GetMapping("/park")
@ResponseBody
public String park() throws InterruptedException {
RSemaphore park = redissonClient.getSemaphore("park");
// park.acquire(); //获取一个信号,获取一个值,占一个车位
boolean b = park.tryAcquire();
if(b){
//执行业务
}else {
return "error";
}
return "ok=>" +b;
}
@GetMapping("/go")
@ResponseBody
public String go() throws InterruptedException {
RSemaphore park = redissonClient.getSemaphore("park");
park.release(); //释放一个车位
return "ok" ;
}
6. 闭锁(CountDownLatch)
基于Redisson的Redisson分布式闭锁(CountDownLatch)Java对象RCountDownLatch
采用了与java.util.concurrent.CountDownLatch
相似的接口和用法。
以下代码只有offLatch()
被调用5次后 setLatch()
才能继续执行
RCountDownLatch latch = redisson.getCountDownLatch("anyCountDownLatch");
latch.trySetCount(1);
latch.await();
// 在其他线程或其他JVM里
RCountDownLatch latch = redisson.getCountDownLatch("anyCountDownLatch");
latch.countDown();
/**
*放假,锁门
* 1班没人了,2
* 5个班全部走完,我们才可以锁大门
*/
@GetMapping("/lockDoor")
@ResponseBody
public String lockDoor() throws InterruptedException{
RCountDownLatch door = redissonClient.getCountDownLatch("door");
door.trySetCount(5);
door.await(); //等待闭锁都完成
return "放假了。。。。。";
}
@GetMapping("/gogogo/{id}")
public String gogogo(@PathVariable("id") Long id){
RCountDownLatch door = redissonClient.getCountDownLatch("door");
door.countDown(); //计数-1
return id +"班的人都走完了";
}
1.4.4 缓存数据一致性
1)、保证一致性模式
1.双写模式
2.失效模式
3. 改进方法1-分布式读写锁
分布式读写锁。读数据等待写数据整个操作完成。
4.改进方法2-使用cananl
5. 汇总
1.4.5 Spring Cache
1)、简介
2)、基础概念
3)、注解
4)、代码实现
1.配置
- 依赖
<dependency>
<groupId>org.springframework.b oot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
- 指定缓存类型并在主配置类上加上注解
@EnableCaching
application.properties
spring.cache.type=redis
#毫秒为单位
spring.cache.redis.time-to-live=3600000
#如果指定了前缀就用我们指定的前缀,如果没有就默认使用缓存的名字作为前缀
#spring.cache.redis.key-prefix=CACHE_
spring.cache.redis.use-key-prefix=true
#是否缓存空值,防止缓存穿透
spring.cache.redis.cache-null-values=true
- 默认使用jdk进行序列化(可读性差),默认ttl为-1永不过期,自定义序列化方式需要编写配置类
- 创建config.MyCacheConfig
package com.atguigu.gulimall.product.config;
/**
* @author hxld
* @create 2022-11-29 21:59
*/
@EnableConfigurationProperties(CacheProperties.class)
@EnableCaching
@Configuration
public class MyCacheConfig {
// @Autowired
// public CacheProperties cacheProperties;
/**
* 配置文件的配置没有用上
*
* 1. 原来和配置文件绑定的配置类是这样的:
* @ConfigurationProperties(prefix = "spring.cache")
* public class CacheProperties
*
* 2. 要让他生效
* @EnableConfigurationProperties(CacheProperties.class)
*
*/
@Bean
public RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
// config = config.entryTtl();
config = config.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()));
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;
}
}
2. 缓存自动配置
// 缓存自动配置源码
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(CacheManager.class)
@ConditionalOnBean(CacheAspectSupport.class)
@ConditionalOnMissingBean(value = CacheManager.class, name = "cacheResolver")
@EnableConfigurationProperties(CacheProperties.class)
@AutoConfigureAfter({ CouchbaseAutoConfiguration.class, HazelcastAutoConfiguration.class,
HibernateJpaAutoConfiguration.class, RedisAutoConfiguration.class })
@Import({ CacheConfigurationImportSelector.class, // 看导入什么CacheConfiguration
CacheManagerEntityManagerFactoryDependsOnPostProcessor.class })
public class CacheAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public CacheManagerCustomizers cacheManagerCustomizers(ObjectProvider<CacheManagerCustomizer<?>> customizers) {
return new CacheManagerCustomizers(customizers.orderedStream().collect(Collectors.toList()));
}
@Bean
public CacheManagerValidator cacheAutoConfigurationValidator(CacheProperties cacheProperties,
ObjectProvider<CacheManager> cacheManager) {
return new CacheManagerValidator(cacheProperties, cacheManager);
}
@ConditionalOnClass(LocalContainerEntityManagerFactoryBean.class)
@ConditionalOnBean(AbstractEntityManagerFactoryBean.class)
static class CacheManagerEntityManagerFactoryDependsOnPostProcessor
extends EntityManagerFactoryDependsOnPostProcessor {
CacheManagerEntityManagerFactoryDependsOnPostProcessor() {
super("cacheManager");
}
}
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(RedisConnectionFactory.class)
@AutoConfigureAfter(RedisAutoConfiguration.class)
@ConditionalOnBean(RedisConnectionFactory.class)
@ConditionalOnMissingBean(CacheManager.class)
@Conditional(CacheCondition.class)
class RedisCacheConfiguration {
@Bean // 放入缓存管理器
RedisCacheManager cacheManager(CacheProperties cacheProperties,
CacheManagerCustomizers cacheManagerCustomizers,
ObjectProvider<org.springframework.data.redis.cache.RedisCacheConfiguration> redisCacheConfiguration,
ObjectProvider<RedisCacheManagerBuilderCustomizer> redisCacheManagerBuilderCustomizers,
RedisConnectionFactory redisConnectionFactory, ResourceLoader resourceLoader) {
RedisCacheManagerBuilder builder = RedisCacheManager.builder(redisConnectionFactory).cacheDefaults(
determineConfiguration(cacheProperties, redisCacheConfiguration, resourceLoader.getClassLoader()));
List<String> cacheNames = cacheProperties.getCacheNames();
if (!cacheNames.isEmpty()) {
builder.initialCacheNames(new LinkedHashSet<>(cacheNames));
}
redisCacheManagerBuilderCustomizers.orderedStream().forEach((customizer) -> customizer.customize(builder));
return cacheManagerCustomizers.customize(builder.build());
}
3. @Cacheable
/**
* //1. 每一个需要缓存的数据我们都来指定要放到哪个名字的缓存【缓存的分区(建议按照业务类型分)】 逻辑分区,我们自己设置的分区
* 2. @Cacheable({"category"})
* 代表当前方法的结果需要缓存,如果缓存中有,方法不用调用。
* 如果缓存中没有,会调用方法,最后将方法的结果放入缓存
* 3. 默认行为
* 1)、如果缓存中有,方法不用调用
* 2)、key默认自动生成,缓存的名字::SimpleKey{}(自主生成的key值)
* 3)、缓存的value的值,默认使用Jdk序列化机制,将序列化后的数据存到redis
* 4)、默认ttl时间 -1
*
* 自定义
* 1)、指定生成的缓存使用的key key属性指定,接受一个spel(动态取值)
* 2)、指定缓存的数据的存活时间 配置文件中设置ttl
* 3)、将数据保存为json格式
*
*/
// @Cacheable(value={"category"},key="'Level1Categorys'") //代表当前方法的结果需要缓存,如果缓存中有,方法不调用。如果缓存中没有,会调用方法,最后将方法的结果放入缓存
@Cacheable(value={"category"},key="#root.method.name",sync = true) //代表当前方法的结果需要缓存,如果缓存中有,方法不调用。如果缓存中没有,会调用方法,最后将方法的结果放入缓存
@Override
public List<CategoryEntity> getLevel1Categorys() {
System.out.println("getLevel1Categorys");
//parent_cid=0 或者 cat_level=1 都表示是一级分类。这里我们选用parent_cid=0
List<CategoryEntity> entities = baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("parent_cid", 0));
return entities;
}
- redis中生成的缓存的名称就是我们自己设置的方法名或其他。
4. @CacheEvict
/**
* 级联更新所有关联的数据
* 1. @CacheEvict:缓存失效模式
* 2. @Caching:同时进行多种缓存操作。比如一次性删除两个缓存
* 3. 指定删除某个分区下的所有数据 @CacheEvict(value="category",allEntries = true)
* 4.存储同一类型的数据,都可以指定成同一个分区。好处是如果存在多个缓存,修改了数据之后,同一个分区下的所有缓存都可以被清空
* 分区名默认就是缓存的前缀
* category:key (redis中存储的样子)
* @CachePut:双写模式
* @param category
*/
// @CacheEvict(value="category",key="'getLevel1Categorys'")
// @Caching(evict = {
// @CacheEvict(value="category",key="'getLevel1Categorys'"),
// @CacheEvict(value="category",key="'getCatalogJson'")
// })
@CacheEvict(value="category",allEntries = true) //默认是false,删除分区里面的所有数据 失效模式
// @CachePut:双写模式
@Transactional
@Override
public void updateCascade(CategoryEntity category) {
this.updateById(category);
categoryBrandRelationService.updateCategory(category.getCatId(),category.getName());
}
5)、SpringCache原理与不足
1.5 商品检索
1.5.1 搭建页面环境
- 我们仍然使用的是动静分离的方法,我们将搜索页面的前端代码放到nginx中,我们在nginx中和商品上架一样进行处理。
- 将这个文件夹下面的除index.html中的文件夹都放到nginx中(创建search文件夹),index.html这个页面放到gulimall_search中的resourches下的templates下。
-
将index.html中关于href=“ 使用ctrl+r查找替换为href="/static/search,将src="查找替换为src=‘’/static/search。这样搜索页面的图片什么的就能展示出来了。
-
浏览器中输入
search.guimall.com
,可以访问到搜索页面,我们需要去修改hosts文件。如下图。
- 去到mydata/nginx/conf/conf.d这个文件夹下面修改gulimall.conf 。因为我们以前在商品上架的时候已经设置好了上游服务器,所以我们这个地方只用加入
*.gulimall.com
(这个地方不要只修改为*.gulimall.com,还要将gulimall.com这个加上,因为后面测试过程中发现在搜索页跳转不了首页)。
- 修改网关路由中的路径。
6.将搜索微服务下的index.html页面修改为list.html。因为我们搜索的时候跳转的地址是http://search.gulimall.com/list.html?catalogId=225
踩坑
我们在首页点击手机分类-手机,跳转到搜索页面。这个时候出现问题,就是点击list,请求的url不是http://search.gulimall.com/list.html?catalogId=225 ,而是http://search.gmall.com/list.html?catalogId=225
- 解决方法:
去nginx中的html/static/static/index/js文件夹中,将catalogLoader.js中的第22行的gmall修改为gulimall.然后去浏览器中将缓存清除,然后再重新访问。
- 我们在首页搜索框点击搜索按钮的时候,比如说搜索手机,会跳转到搜索页面,展示搜索手机的信息。
- 点击审查元素---搜索按钮找到是search(),然后list.html页面进行查找,即search()方法,将原来的代码修改为以下代码:
-
遇到的问题:最开始老师课件中只做了如上的修改。但是我们点击的时候跳转页面显示的url如下图:
- 问题解决:找到search()这个方法,将search这个方法的href修改为正确的url即可。
1.5.2 检索业务分析
商品检索三个入口:
1)、选择分类进入商品检索
2)、输入检索关键字展示检索页
3)、选择筛选条件进入
4)、检索参数及检索返回结果抽取
gulimall-search包下新建vo封装
- SearchParam(检索参数)
package com.atguigu.gulimall.search.vo;
import lombok.Data;
import java.util.List;
/**
* 封装页面可能检索的条件
* catalog3Id=225&keyword=小米&sort=saleCount_asc&Stock=0/1&brandId=1&brandId=2
* @author hxld
* @create 2022-11-30 20:27
*/
@Data
public class SearchParam {
/**
* 页面传递过来的全文匹配关键字
*/
private String keyword;
/**
* 品牌id,可以多选
*/
private List<Long> brandId;
/**
* 三级分类id
*/
private Long catalog3Id;
/**
* 排序条件
* sort=saleCount_asc/desc 倒序
* sort=skuPrice_asc/desc 根据价格
* sort=hotScore_asc/desc
*/
private String sort;
/**
* 很多过滤条件
* hasStock(是否有货) skuPrice价格区间 brandId catalog3Id attrs
* hasStock 0/1
* skuPrice=1_500 500_ _500
* brandId = 1 品牌id
* attrs1_5寸_6寸 手机大小
* // 0 无库存 1有库存
*/
private Integer hasStock;
/**
* 价格区间查询
*/
private String skuPrice;
/**
* 按照属性进行筛选
*/
private List<String> attrs;
/**
* 页码
*/
private Integer pageNum = 1;
/**
* 原生的所有查询条件
*/
private String _queryString;
}
- SearchResult(返回结果)
查询得到商品、总记录数、总页码
品牌list用于在品牌栏显示,分类list用于在分类栏显示其他栏每栏用AttrVo表示
- 不仅要根据关键字从es中检索到商品
- 还要通过聚合生成品牌等信息,方便分类栏显示
package com.atguigu.gulimall.search.vo;
/**
* <p>Title: SearchResponse</p>
* Description:包含页面需要的所有信息
*/
@Data
public class SearchResult {
/** * 查询到的所有商品信息*/
private List<SkuEsModel> products;
/*** 当前页码*/
private Integer pageNum;
/** 总记录数*/
private Long total;
/** * 总页码*/
private Integer totalPages;
/** 当前查询到的结果, 所有涉及到的品牌*/
private List<BrandVo> brands;
/*** 当前查询到的结果, 所有涉及到的分类*/
private List<CatalogVo> catalogs;
/** * 当前查询的结果 所有涉及到所有属性*/
private List<AttrVo> attrs;
/** 导航页 页码遍历结果集(分页) */
private List<Integer> pageNavs;
// ================以上是返回给页面的所有信息================
/** 导航数据*/
private List<NavVo> navs = new ArrayList<>();
/** 便于判断当前id是否被使用*/
private List<Long> attrIds = new ArrayList<>();
@Data
public static class NavVo {
private String name;
private String navValue;
private String link;
}
@Data
public static class BrandVo {
private Long brandId;
private String brandName;
private String brandImg;
}
@Data
public static class CatalogVo {
private Long catalogId;
private String catalogName;
}
@Data
public static class AttrVo {
private Long attrId;
private String attrName;
private List<String> attrValue;
}
}
1.5.3 ES语句DSL
此处先写出如何检索指定的商品,如检索"华为"关键字
- 嵌入式的属性
- highlight:设置该值后,返回的时候就包装过了
- 查出结果后,附属栏也要对应变化
- 嵌入式的聚合时候也要注意
1)、dsl
GET gulimall_product/_search
{
"query": {
"bool": {
"must": [ {"match": { "skuTitle": "华为" }} ], # 检索出华为
"filter": [ # 过滤
{ "term": { "catalogId": "225" } },
{ "terms": {"brandId": [ "2"] } },
{ "term": { "hasStock": "false"} },
{
"range": {
"skuPrice": { # 价格1K~7K
"gte": 1000,
"lte": 7000
}
}
},
{
"nested": {
"path": "attrs", # 聚合名字
"query": {
"bool": {
"must": [
{
"term": { "attrs.attrId": { "value": "6"} }
}
]
}
}
}
}
]
}
},
"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": { # 每个商品id的品牌
"terms": {
"field": "brandName",
"size": 10
}
},
"brandImgAgg": {
"terms": {
"field": "brandImg",
"size": 10
}
}
}
},
"catalogAgg":{
"terms": {
"field": "catalogId",
"size": 10
},
"aggs": {
"catalogNameAgg": {
"terms": {
"field": "catalogName",
"size": 10
}
}
}
},
"attrs":{
"nested": {"path": "attrs" },
"aggs": {
"attrIdAgg": {
"terms": {
"field": "attrs.attrId",
"size": 10
},
"aggs": {
"attrNameAgg": {
"terms": {
"field": "attrs.attrName",
"size": 10
}
}
}
}
}
}
}
}
2)、数据转移---修改映射
- 修改以前不能聚合的属性,比如说商品brand
//PUT gulimall_product
{
"mappings": {
"properties": {
"attrs": {
"type": "nested",
"properties": {
"attrId": {
"type": "long"
},
"attrName": {
"type": "keyword"
},
"attrValue": {
"type": "keyword"
}
}
},
"brandId": {
"type": "long"
},
"brandImg": {
"type": "keyword"
},
"brandName": {
"type": "keyword"
},
"catalogId": {
"type": "long"
},
"catalogName": {
"type": "keyword"
},
"hasStock": {
"type": "boolean"
},
"hotScore": {
"type": "long"
},
"saleCount": {
"type": "long"
},
"skuId": {
"type": "long"
},
"skuImg": {
"type": "keyword"
},
"skuPrice": {
"type": "keyword"
},
"skuTitle": {
"type": "text",
"analyzer": "ik_smart"
},
"spuId": {
"type": "keyword"
}
}
}
}
//数据迁移
//POST _reindex
{
"source": {
"index": "product"
},
"dest": {
"index": "gulimall_product"
}
}
//##数据迁移
//POST _reindex
{
"source": {
"index": "bank",
"type": "account"
},
"dest": {
"index": "newba
- 修改sku数据在es中的索引,由
product
中的gulimall_product
.
1.5.4 检索服务构建(响应和结果提取封装)
- 新建SearchController
package com.atguigu.gulimall.search.controller;
@Controller
public class SearchController {
@Autowired
MallSearchService mallSearchService;
/**
* 自动将页面提交过来的所有请求查询参数封装成指定的对象
* @param param
* @return
*/
@GetMapping("/list.html")
public String listPage(SearchParam param, Model model){
//1.根据传递过来的页面的查询参数,去es中检索商品
SearchResult result = mallSearchService.search(param);
model.addAttribute("result",result);
return "list";
}
}
- 新建MallSearchService
package com.atguigu.gulimall.search.service;
public interface MallSearchService {
/**
*
* @param param
* @return
*/
SearchResult search(SearchParam param);
}
我们要将上面的dsl语句进行java代码的构建。
- 新建MallSearchServiceImpl
package com.atguigu.gulimall.search.service.impl;
/**
* @author hxld
* @create 2022-11-30 20:30
*/
@Service
public class MallSearchServiceImpl implements MallSearchService {
@Autowired
private RestHighLevelClient client;
//去es进行检索
@Override
public SearchResult search(SearchParam param) {
//1.动态构建出查询需要的DSL语句
SearchResult result = null;
//1.准备检索请求
SearchRequest searchRequest = buildSearchRequest(param);
try {
//2.执行检索请求
SearchResponse response = client.search(searchRequest, GulimallElasticSearchConfig.COMMON_OPTIONS);
//3.分析响应数据封装成我们需要的格式
result = buildSearchResult(response,param);
} catch (IOException e) {
e.printStackTrace();
}
return result;
}
/**
* 准备检索请求 查询!
* 模糊匹配 过滤 按照属性 分类 品牌 价格区间 库存 排序 分页 高亮 聚合分析
*/
private SearchRequest buildSearchRequest(SearchParam param) {
//构建DSL语句
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
/**
* 模糊匹配 过滤 按照属性 分类 品牌 价格区间 库存
*/
//1.构建boolQuery bool:聚合查询 termsQuery方法参数可以传一个或多个或数组 termQuery(域字段名,参数) 方法参数只能传入一个
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
//1.1 must 模糊匹配 must : {skuTitle:华为}
if(!StringUtils.isEmpty(param.getKeyword())){
boolQuery.must(QueryBuilders.matchQuery("skuTitle",param.getKeyword()));
}
//1.2 bool = filter 按照三级分类id查询 term:{catalogId:225}
if(param.getCatalog3Id() != null){
boolQuery.filter(QueryBuilders.termQuery("catalogId",param.getCatalog3Id()));
}
//1.2 bool = filter 按照品牌id查询 是list
if(param.getBrandId() != null && param.getBrandId().size() > 0 ){
boolQuery.filter(QueryBuilders.termsQuery("brandId",param.getBrandId()));
}
//1.2 bool = filter 按照所属属性进行查询
if(param.getAttrs() != null && param.getAttrs().size()>0){
//attrs=1_5寸:6寸&attrs=2_16G:8G
for (String attrStr : param.getAttrs()) {
BoolQueryBuilder nestBoolQuery = QueryBuilders.boolQuery();
//attrs=1_5寸:6寸
String[] s = attrStr.split("_");
//检索的属性id 1
String attrId = s[0];
//检索的属性值 5寸:6寸
String[] attrValues = s[1].split(":");
nestBoolQuery.must(QueryBuilders.termQuery("attrs.attrId",attrId));
nestBoolQuery.must(QueryBuilders.termsQuery("attrs.attrValue",attrValues));
//每一个都必须生成一个nested查询 属性有很多
NestedQueryBuilder nestedQuery = QueryBuilders.nestedQuery("attrs", nestBoolQuery, ScoreMode.None);
boolQuery.filter(nestedQuery);
}
}
//1.2 bool = filter 按照是否有库存进行查询 "hasStock": "false" // 0 无库存 1有库存
if(param.getHasStock() != null){
boolQuery.filter(QueryBuilders.termsQuery("hasStock",param.getHasStock() == 1));
}
//1.2 bool = filter 按照价格区间进行查询
//1_500 _500 500_
if(!StringUtils.isEmpty(param.getSkuPrice())){
RangeQueryBuilder rangeQuery = QueryBuilders.rangeQuery("skuPrice");
String[] s = param.getSkuPrice().split("_");
//1_500
if(s.length == 2){
//区间
rangeQuery.gte(s[0]).lte(s[1]);
}else if(s.length == 1){
if(param.getSkuPrice().startsWith("_")){
// _500
rangeQuery.lte(s[0]);
}
if(param.getSkuPrice().endsWith("_")){
//500_
rangeQuery.gte(s[0]);
}
}
boolQuery.filter(rangeQuery);
}
//把以前的所有条件都拿来进行封装 "query": {"bool":......}
sourceBuilder.query(boolQuery);
/**
* 排序 分页 高亮
*/
//2.1 排序
if(!StringUtils.isEmpty(param.getSort())){
//sort=saleCount_asc/desc 倒序
String[] s = param.getSort().split("_");
SortOrder order = s[1].equalsIgnoreCase("asc") ? SortOrder.ASC : SortOrder.DESC;
sourceBuilder.sort(s[0],order);
}
//2.2 分页 每页5个
//pageNum:1 from:0 size:5 [0,1,2,3,4]
// pageNum:2 from:5 size:5
// from = (pageNum - 1)*size
sourceBuilder.from((param.getPageNum() -1) * EsContant.PRODUCT_PAGESIZE);
sourceBuilder.size(EsContant.PRODUCT_PAGESIZE);
//2.3 高亮
if(!StringUtils.isEmpty(param.getKeyword())){
HighlightBuilder builder = new HighlightBuilder();
builder.field("skuTitle");
builder.preTags("<b style='color:red'>");
builder.postTags("</b>");
sourceBuilder.highlighter(builder);
}
/**
* 聚合分析
*/
//3.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.aggregation(brand_agg);
//3.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.aggregation(catalog_agg);
//3.3 属性聚合
NestedAggregationBuilder attr_agg = AggregationBuilders.nested("attr_agg", "attrs");
//子聚合
TermsAggregationBuilder attr_id_agg = AggregationBuilders.terms("attr_id_agg").field("attrs.attrId");
//子子聚合 2 个
//聚合分析出当前所有attrId对应的名字
attr_id_agg.subAggregation(AggregationBuilders.terms("attr_name_agg").field("attrs.attrName")).size(1);
//聚合分析出当前attrid对应的所有可能的属性值 attrValue
attr_id_agg.subAggregation(AggregationBuilders.terms("attr_value_agg").field("attrs.attrValue")).size(50);
attr_agg.subAggregation(attr_id_agg);
sourceBuilder.aggregation(attr_agg);
String s = sourceBuilder.toString();
System.out.println("构建的DSL...." + s);
SearchRequest searchRequest = new SearchRequest(new String[]{EsContant.PRODUCT_INDEX},sourceBuilder);
return searchRequest;
}
/**
* 构建结果数据
* 根据es查询到的结果,分析得到页面真正得到的数据模型
* @param response
* @param param
* @return
*/
private SearchResult buildSearchResult(SearchResponse response, SearchParam param) {
//要封装的大对象
SearchResult result = new SearchResult();
//1 封装返回的所有查询到的商品
ArrayList<SkuEsModel> esModels = new ArrayList<>();
SearchHits hits = response.getHits();
if(hits.getHits() != null && hits.getHits().length > 0){
for (SearchHit hit : hits.getHits()) {
String sourceAsString = hit.getSourceAsString();
SkuEsModel esModel = JSON.parseObject(sourceAsString, SkuEsModel.class);
//高亮
if(!StringUtils.isEmpty(param.getKeyword())){
HighlightField skuTitle = hit.getHighlightFields().get("skuTitle");
String string = skuTitle.getFragments()[0].string();
esModel.setSkuTitle(string);
}
esModels.add(esModel);
}
}
result.setProducts(esModels);
//2 当前所有商品涉及到的所有属性信息 Aggregation -> ParsedNested
ArrayList<SearchResult.AttrVo> attrVos = new ArrayList<>();
ParsedNested attr_agg = response.getAggregations().get("attr_agg");
//nested的第一层 聚合 Aggregation -> ParsedLongTerms
ParsedLongTerms attr_id_agg = attr_agg.getAggregations().get("attr_id_agg");
for(Terms.Bucket bucket: attr_id_agg.getBuckets()){
//要封装的小对象
SearchResult.AttrVo attrVo = new SearchResult.AttrVo();
//得到属性id
long attrId = bucket.getKeyAsNumber().longValue();
attrVo.setAttrId(attrId);
//子聚合 得到属性名 Aggregation -> ParsedStringTerms
ParsedStringTerms attr_name_agg = bucket.getAggregations().get("attr_name_agg");
String attrName = attr_name_agg.getBuckets().get(0).getKeyAsString();//因为这个属性不是list
attrVo.setAttrName(attrName);
//子聚合 复杂 得到属性值 Aggregation -> ParsedStringTerms
ParsedStringTerms attr_value_agg = bucket.getAggregations().get("attr_value_agg");
List<String> attrValues = attr_value_agg.getBuckets().stream().map((item) -> { //因为这个属性是list
return item.getKeyAsString();
}).collect(Collectors.toList());
attrVo.setAttrValue(attrValues);
attrVos.add(attrVo);
}
result.setAttrs(attrVos);
//3 当前所有商品所涉及的品牌信息 Aggregation ->ParsedLongTerms
ArrayList<SearchResult.BrandVo> brandVos = new ArrayList<>();
ParsedLongTerms brand_agg = response.getAggregations().get("brand_agg");
for(Terms.Bucket bucket:brand_agg.getBuckets()){
//要封装的小对象
SearchResult.BrandVo brandVo = new SearchResult.BrandVo();
//得到品牌id
long brandId = bucket.getKeyAsNumber().longValue();
brandVo.setBrandId(brandId);
//子聚合 得到品牌名 Aggregation -> ParsedStringTerms
ParsedStringTerms brand_name_agg = bucket.getAggregations().get("brand_name_agg");
String brandName = brand_name_agg.getBuckets().get(0).getKeyAsString();//因为这个属性不是List
brandVo.setBrandName(brandName);
//子聚合 得到品牌图片
ParsedStringTerms brand_img_agg = bucket.getAggregations().get("brand_img_agg");
String brandImg = brand_img_agg.getBuckets().get(0).getKeyAsString();//因为这个属性不是list
brandVo.setBrandImg(brandImg);
brandVos.add(brandVo);
}
result.setBrands(brandVos);
//4 当前所有商品所涉及到的所有分类信息 Aggregation -> ParsedLongTerms
ArrayList<SearchResult.CatalogVo> catalogVos = new ArrayList<>();
ParsedLongTerms catalog_agg = response.getAggregations().get("catalog_agg");
for (Terms.Bucket bucket : catalog_agg.getBuckets()) {
//要封装的小对象
SearchResult.CatalogVo catalogVo = new SearchResult.CatalogVo();
//得到分类id
String keyAsString = bucket.getKeyAsString();
catalogVo.setCatalogId(Long.parseLong(keyAsString));
//子聚合 得到分类名 Aggregation -> ParsedStringTerms
ParsedStringTerms catalog_name_agg = bucket.getAggregations().get("catalog_name_agg");
String catalog_name = catalog_name_agg.getBuckets().get(0).getKeyAsString();//因为这个不是List
catalogVo.setCatalogName(catalog_name);
catalogVos.add(catalogVo);
}
result.setCatalogs(catalogVos);
// ===============以上从聚合信息中获取==================
//5 分页信息 - 页码
result.setPageNum(param.getPageNum());
//5分页信息 -总记录数
long total = hits.getTotalHits().value;
result.setTotal(total);
//5 分页信息 - 总页码 计算得到 11 /2 = 5 ...1
int totalPages = (int) (total % EsContant.PRODUCT_PAGESIZE == 0 ? (int)total/EsContant.PRODUCT_PAGESIZE:(int)(total/EsContant.PRODUCT_PAGESIZE+1));
result.setTotalPages(totalPages);
return result;
}
}
1.5.5 页面基本数据渲染
修改 list.html页面。
1)、页面商品展示
th:utext:不转义 ,此处使用是:可以让我们搜索的关键字高亮显示。
2)、品牌、分类等显示
效果:
效果:
显示全部:
修改代码:SearchParam
private Integer hasStock;//是否只显示有货
MallSearchServiceImpl -> buildSearchRequest
//1.2、bool - filter - 按照库存是否有进行查询
if (param.getHasStock() != null){
boolQuery.filter(QueryBuilders.termQuery("hasStock", param.getHasStock() == 1));
}
效果:
加粗:
- 分类栏显示
<!--分类-->
<div class="JD_pre">
<div class="sl_key">
<span><b>分类:</b></span>
</div>
<div class="sl_value">
<ul>
<li th:each="catalog:${result.catalogs}">
<a href="/static/search/#" th:text="${catalog.catalogName}">5.56英寸及以上</a>
</li>
</ul>
</div>
<div class="sl_ext">
<a href="/static/search/#">
更多
<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/search/#">
多选
<i>+</i>
<span>+</span>
</a>
</div>
</div>
效果:
1.5.6 页面筛选条件渲染
当我们选择比如品牌,分类,型号等自动拼接上参数。
函数:
function searchProducts(name,value){
//原来的页面
var href = location.href + "";
if(href.indexOf("?")!=-1){
location.href = location.href + "&"+name+"="+value;
}else{
location.href = location.href + "?"+name+"="+value;
}
}
品牌:
使用函数:th:href="${'javascript:searchProducts("brandId",'+brand.brandId+')'}"
测试:
分类:
th:href="${'javascript:searchProducts("catalog3Id",'+catalog.catalogId+')'}"
其他属性:
th:href="${'javascript:searchProducts("attrs","'+attr.attrId+'_'+val+'")'}"
1.5.7 页面分页数据渲染
1)、修改搜索导航
- 实现搜索的时候地址栏加上关键字
搜索关键字:华为,地址栏加上华为。
回显搜索过的关键字:
<div class="header_form">
<input id="keyword_input" type="text" placeholder="手机" th:value="${param.keyword}"/>
<a href="javascript:searchByKeyword();">搜索</a>
</div>
function searchByKeyword() {
searchProducts("keyword", $("#keyword_input").val());
}
2)、 分页调整
从最开始的测试2改为16。
页面修改:
<div class="filter_page">
<div class="page_wrap">
<span class="page_span1">
<a class="page_a" th:attr="pn=${result.pageNum - 1}" href="/static/search/#"
th:if="${result.pageNum>1}">
< 上一页
</a>
<a class="page_a"
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" 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>确定</a>
</span>
</div>
$(".page_a").click(function () {
var pn = $(this).attr("pn");
var href = location.href;
if (href.indexOf("pageNum") != -1) {
//替换pageNum的值
location.href = replaceParamVal(href, "pageNum", pn);
} else {
location.href = location.href + "&pageNum=" + pn;
}
return false;
});
function replaceParamVal(url, paramName, replaceVal) {
var oUrl = url.toString();
var re = eval('/(' + paramName + '=)([^&]*)/gi');
var nUrl = oUrl.replace(re, paramName + '=' + replaceVal);
return nUrl;
};
代码修改:
/**
* 构建结果数据
*
* @return
*/
private SearchResult buildSearchResult(SearchResponse response, SearchParam param) {
...
//导航页
List<Integer> pageNavs = new ArrayList<>();
for (int i = 1; i <= totalPages; i++) {
pageNavs.add(i);//可遍历的页码
}
result.setPageNavs(pageNavs);
效果展示:
![1669790957966](谷粒商城项目笔记之高级篇/3019773-20221201095736162-135900037.png)
thymeleaf中有关indexOf()方法,可以借助这个方法来判断是否包含某个字符串。
1.5.8 页面排序功能
function replaceAndAddParamVal(url, paramName, replaceVal) {
var oUrl = url.toString();
//1.如果没有就添加,有就替换;
if (oUrl.indexOf(paramName) != -1) {
var re = eval('/(' + paramName + '=)([^&]*)/gi');
var nUrl = oUrl.replace(re, paramName + '=' + replaceVal);
return nUrl;
} else {
var nUrl = "";
if (oUrl.indexOf("?") != -1) {
nUrl = oUrl + "&" + paramName + '=' + replaceVal;
} else {
nUrl = oUrl + "?" + paramName + '=' + replaceVal;
}
return nUrl;
}
};
$(".sort_a").click(function () {
//1.当前被点击的元素变为选中状态
// color: #FFF;border-color: #e4393c;background: #e4393c;
//改变当前元素以及兄弟元素的样式
changeStyle(this);
//2.跳转到指定位置 sort=skuPrice_asc/desc
var sort = $(this).attr("sort");
sort = $(this).hasClass("desc") ? sort + "_desc" : sort + "_asc";
location.href = replaceAndAddParamVal(location.href, "sort", sort);
//禁用默认行为
return false;
});
function changeStyle(ele) {
$(".sort_a").css({"color": "#333", "border-colo": "#CCC", "background": "#FFF"});
$(".sort_a").each(function () {
var text = $(this).text().replace("↓", "").replace("↑", "");
$(this).text(text);
});
$(ele).css({"color": "#FFF", "border-colo": "#e4393c", "background": "#e4393c"});
//改变升降序
$(ele).toggleClass("desc");//加上就是降序,不加就是升序
if ($(ele).hasClass("desc")) {
//降序
var text = $(ele).text().replace("↓", "").replace("↑", "");
text = text + "↓";
$(ele).text(text);
} else {
var text = $(ele).text().replace("↓", "").replace("↑", "");
text = text + "↑";
$(ele).text(text);
}
}
- 效果:有上下箭头(升降序)
- 按价格排序
1.5.9 页面排序字段回显
<div class="filter_top">
<div class="filter_top_left" th:with="p = ${param.sort}">
<a sort="hotScore"
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;' }">
综合排序[[${(!#strings.isEmpty(p) && #strings.startsWith(p,'hotScore') &&
#strings.endsWith(p,'desc')) ?'↑':'↓' }]]</a>
<a sort="saleCount"
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;' }">
销量[[${(!#strings.isEmpty(p) && #strings.startsWith(p,'saleCount') &&
#strings.endsWith(p,'desc'))?'↑':'↓' }]]</a>
<a sort="skuPrice"
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;' }">
价格[[${(!#strings.isEmpty(p) && #strings.startsWith(p,'skuPrice') &&
#strings.endsWith(p,'desc'))?'↑':'↓' }]]</a>
<a href="/static/search/#">评论分</a>
<a href="/static/search/#">上架时间</a>
</div>
效果:
1.5.10 页面价格区间搜索&&仅显示有货
- 页面价格区间搜索
<div class="filter_top">
<div class="filter_top_left" th:with="p = ${param.sort},priceRange = ${param.skuPrice}">
<a sort="hotScore"
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;' }">
综合排序[[${(!#strings.isEmpty(p) && #strings.startsWith(p,'hotScore') &&
#strings.endsWith(p,'desc')) ?'↑':'↓' }]]</a>
<a sort="saleCount"
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;' }">
销量[[${(!#strings.isEmpty(p) && #strings.startsWith(p,'saleCount') &&
#strings.endsWith(p,'desc'))?'↑':'↓' }]]</a>
<a sort="skuPrice"
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;' }">
价格[[${(!#strings.isEmpty(p) && #strings.startsWith(p,'skuPrice') &&
#strings.endsWith(p,'desc'))?'↑':'↓' }]]</a>
<a href="/static/search/#">评论分</a>
<a href="/static/search/#">上架时间</a>
<input id="skuPriceFrom" type="number"
th:value="${#strings.isEmpty(priceRange)?'':#strings.substringBefore(priceRange,'_')}"
style="width: 100px; margin-left: 30px">
-
<input id="skuPriceTo" type="number"
th:value="${#strings.isEmpty(priceRange)?'':#strings.substringAfter(priceRange,'_')}"
style="width: 100px">
<button id="skuPriceSearchBtn">确定</button>
</div>
$("#skuPriceSearchBtn").click(function () {
//1、拼上价格区间的查询条件
var from = $("#skuPriceFrom").val();
var to = $("#skuPriceTo").val();
var query = from + "_" + to;
location.href = replaceAndAddParamVal(location.href, "skuPrice", query);
});
效果:
- 仅显示有货
<li>
<a href="#" th:with="check = ${param.hasStock}">
<input id="showHasStock" type="checkbox" th:checked="${#strings.equals(check,'1')}">
仅显示有货
</a>
</li>
$("#showHasStock").change(function (){
if ($(this).prop('checked')){
location.href = replaceAndAddParamVal(location.href,"hasStock",1);
}else {
//没选中
var re = eval('/(hasStock=)([^&]*)/gi');
location.href = (location.href+"").replace(re,'');
}
});
效果展示:
bug解决:之前搜索过的关键词在URL地址栏不会被替换,而是一直叠加。
function searchProducts(name, value) {
//原来的页面
location.href = replaceAndAddParamVal(location.href,name,value);
}
1.5.11 面包屑导航
1)、准备及条件删除与URL编码问题
这里我们要使用远程调用查询属性。所以我们可以先引入feign这些。
-
search微服务引入依赖:
<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-openfeign</artifactId> </dependency>
-
让spring-cloud版本一致:
<properties>
<java.version>1.8</java.version>
<elasticsearch.version>7.4.2</elasticsearch.version>
<spring-cloud.version>Greenwich.SR3</spring-cloud.version>
</properties>
- 引入依赖管理(管理版本):
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
我们需要调用商品的远程服务,获取属性。
gulimall-search下新建feign包:
- ProductFeignService
package com.atguigu.gulimall.search.feign;
/**
* @author hxld
* @create 2022-12-02 23:20
*/
@FeignClient("gulimall-product")
public interface ProductFeignService {
@GetMapping("/product/attr/info/{attrId}")
public R attrInfo(@PathVariable("attrId") Long attrId);
}
主启动类添加调用远程服务注解
@EnableFeignClients //开启远程调用
- 新建 AttrResponseVo封装结果:这里我们暂时不用 gulimall-product下的 AttrRespVo了,可以将其放到公共服务中去。但是如果我们每个人只能修改自己负责的微服务,我们就新建然后进行封装就行。(最主要是因为我们使用远程调用查询商品信息,这个会给我们返回结果,我们可以使用将返回结果的类型放到公共服务中之后再来去调用,但是我们也可以在自己的微服务下进行封装,如果我们分工各做各的微服务)
package com.atguigu.gulimall.search.vo;
@Data
public class AttrResponseVo {
/**
* 属性id
*/
private Long attrId;
/**
* 属性名
*/
private String attrName;
/**
* 是否需要检索[0-不需要,1-需要]
*/
private Integer searchType;
/**
* 值类型[0-为单个值,1-可以选择多个值]
*/
private Integer valueType;
/**
* 属性图标
*/
private String icon;
/**
* 可选值列表[用逗号分隔]
*/
private String valueSelect;
/**
* 属性类型[0-销售属性,1-基本属性,2-既是销售属性又是基本属性]
*/
private Integer attrType;
/**
* 启用状态[0 - 禁用,1 - 启用]
*/
private Long enable;
/**
* 所属分类
*/
private Long catelogId;
/**
* 快速展示【是否展示在介绍上;0-否 1-是】,在sku中仍然可以调整
*/
private Integer showDesc;
private Long attrGroupId;
private String catelogName;
private String groupName;
private Long[] catelogPath;
}
SearchResult
package com.atguigu.gulimall.search.vo;
//面包屑导航数据
private List<NavVo> navs;
@Data
public static class NavVo{
private String navName;
private String navValue;
private String link;
}
SearchParam
package com.atguigu.gulimall.search.vo;
private String _queryString;//原生的所有查询条件
SearchController
package com.atguigu.gulimall.search.controller;
@GetMapping("/list.html")
public String listPage(SearchParam param, Model model, HttpServletRequest request) {
param.set_queryString(request.getQueryString());
//1、根据传递过来的页面的查询参数,去es中检索商品
SearchResult result = mallSearchService.search(param);
//放到 model 中,方便页面取值
model.addAttribute("result",result);
return "list";
}
MallSearchServiceImpl
@Autowired
ProductFeignService productFeignService;
/**
* 构建结果数据
*
* @return
*/
private SearchResult buildSearchResult(SearchResponse response, SearchParam param) {
.....
//6、构建面包屑导航功能
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();
// attrs=2_5存:6寸
String[] s = attr.split("_");
navVo.setNavValue(s[1]); //
R r = productFeignService.attrInfo(Long.parseLong(s[0])); //2
if (r.getCode() == 0) {
AttrResponseVo data = r.getData("attr", new TypeReference<AttrResponseVo>() {
});
navVo.setNavName(data.getAttrName());
} else {
navVo.setNavName(s[0]);
}
//2、取消了这个面包屑之后,我们要跳转到那个地方,将请求地址的url里面的当前置空
//拿到所有的查询条件,去掉当前。
//attrs = 15_海思(Hisilicon)
String encode = null;
try {
encode = URLEncoder.encode(attr, "UTF-8");
encode = encode.replace("+", "%20");//浏览器对空格编码和java不一样
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
String replace = param.get_queryString().replace("&attrs=" + encode, "");
navVo.setLink("http://search.gulimall.com/list.html?" + replace);
return navVo;
}).collect(Collectors.toList());
result.setNavs(collect);
}
}
list.html页面修改
<div class="JD_ipone_one c">
<!-- 遍历面包屑功能 -->
<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>
下面两个JS有修改:
function searchByKeyword() {
searchProducts("keyword", $("#keyword_input").val());
}
function replaceAndAddParamVal(url, paramName, replaceVal, forceAdd) {
var oUrl = url.toString();
//1.如果没有就添加,有就替换;
if (oUrl.indexOf(paramName) != -1) {
if (forceAdd) {
var nUrl = "";
if (oUrl.indexOf("?") != -1) {
nUrl = oUrl + "&" + paramName + '=' + replaceVal;
} else {
nUrl = oUrl + "?" + paramName + '=' + replaceVal;
}
return nUrl;
} else {
var re = eval('/(' + paramName + '=)([^&]*)/gi');
var nUrl = oUrl.replace(re, paramName + '=' + replaceVal);
return nUrl;
}
} else {
var nUrl = "";
if (oUrl.indexOf("?") != -1) {
nUrl = oUrl + "&" + paramName + '=' + replaceVal;
} else {
nUrl = oUrl + "?" + paramName + '=' + replaceVal;
}
return nUrl;
}
};
测试:
地址加上了属性。
点 “ x” 地址栏消失属性。
2)、条件筛选联动
商品服务的 BrandController中添加获取品牌id集合的方法:
package com.atguigu.gulimall.product.app;
@GetMapping("/infos")
public R info(@RequestParam("brandIds") List<Long> brandIds) {
List<BrandEntity> brand = brandService.getBrandsByIds(brandIds);
return R.ok().put("brand", brand);
}
BrandServiceImpl
package com.atguigu.gulimall.product.service.impl;
@Override
public List<BrandEntity> getBrandsByIds(List<Long> brandIds) {
return baseMapper.selectList(new QueryWrapper<BrandEntity>().in("brand_id",brandIds));
}
因为 这些查询都比较耗费时间:远程调用,所以可以加上缓存。
AttrServiceImpl
@Cacheable(value = "attr",key = "'attrinfo:'+#root.args[0]")
@Override
public AttrRespVo getAttrInfo(Long attrId) {
}
查询服务的ProductSaveService
@GetMapping("/product/brand/infos")
public R brandsInfo(@RequestParam("brandIds") List<Long> brandIds);
SearchParam
@Data
public class SearchParam {
...
private String _queryString;//原生的所有查询条件
}
SearchResult
@Data
public class SearchResult {
//面包屑导航数据
private List<NavVo> navs = new ArrayList<>();
private List<Long> attrIds = new ArrayList<>();
}
MallSearchServiceImpl
因为我们经常使用编码的方法,所以提取成一个公共方法。
略做修改
private String replaceQueryString(SearchParam param, String value, String key) {
String encode = null;
try {
encode = URLEncoder.encode(value, "UTF-8");
encode = encode.replace("+", "%20");//浏览器对空格编码和java不一样
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
String replace = param.get_queryString().replace("&" + key + "=" + encode, "");
return replace;
}
上一节的属性面包屑导航增加和修改一些代码:
//6、构建面包屑导航功能
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();
// attrs=2_5存:6寸
String[] s = attr.split("_");
navVo.setNavValue(s[1]);
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里面的当前置空
//拿到所有的查询条件,去掉当前。
//attrs = 15_海思(Hisilicon)
String replace = replaceQueryString(param, attr, "attrs");
navVo.setLink("http://search.gulimall.com/list.html?" + replace);
return navVo;
}).collect(Collectors.toList());
result.setNavs(collect);
}
对于品牌,分类的面包屑导航,这里暂时只做 品牌的。
- 将product中vo包中的BrandVo复制一份到search中的vo包中
//品牌,分类
if (param.getBrandId() != null && param.getBrandId().size() > 0) {
List<SearchResult.NavVo> navs = result.getNavs();
SearchResult.NavVo navVo = new SearchResult.NavVo();
navVo.setNavName("品牌");
//TODO 远程查询所有品牌
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.getBrandName() + ";");
replace = replaceQueryString(param, brandVo.getBrandId() + "", "brandId");
}
navVo.setNavValue(buffer.toString());
navVo.setLink("http://search.gulimall.com/list.html?" + replace);
}
navs.add(navVo);
}
//TODO 分类:不需要导航取消
list.html
<div class="JD_nav_logo" th:with="brandid= ${param.brandId}">
<!--品牌-->
<div th:if="${#strings.isEmpty(brandid)}" class="JD_nav_wrap">
<!--其他所有需要展示的属性-->
<div class="JD_pre" th:each="attr:${result.attrs}" th:if="${!#lists.contains(result.attrIds,attr.attrId)}">
测试
"
这个表示是字符串.
1.6 商品详情
详情数据:
1.6.1 环境搭建
1)、修改Hosts
加入 item.gulimall.com
2)、NGINX配置
我们可以看到,在以前我们已经配置过*.gulimamll.com这个域名配置了,所以这次可以不用在进行配置了。
3)、网关配置
我们在网关中配置item.gulimall.com。
4)、动静资源设置
将动态资源shangpinxiangqing.html这个页面改为item.html,然后将其复制到商品微服务中。
- 静态资源我们照样放在虚拟机中
我们新建item这个文件夹,然后将上面的资源放到文件夹中。
-
将静态资源的访问路径修改为正确的nginx下的路径。
href=" --- > href="/static/item/ || src=" --- > src="/static/item/
-
实现点击商品图片跳转到商品详情页
- 最开始点击的时候跳转显示404页面,这个是因为跳转的逻辑没有写对
- 我们右键图片,审查元素,修改搜索搜索微服务下面的list.html页面。 找到审查元素中的
- 将连接改为跳转到如下页面。
1.6.2 模型抽取
商品服务下新建 ,这里我们只做一些基本的,其他的比如说多少人预约,预约剩余这些我们暂时不做。
- SkuItemVo
package com.atguigu.gulimall.product.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的介绍
SpuInfoDescEntity desp;
//5、获取spu的规格参数信息。
List<SpuItemAttrGroupVo> groupAttrs;
}
- SkuItemSaleAttrVo
package com.atguigu.gulimall.product.vo;
@Data
@ToString
public class SkuItemSaleAttrVo {
private Long attrId;
private String attrName;
private String attrValues;
}
- SpuItemAttrGroupVo
package com.atguigu.gulimall.product.vo;
@Data
@ToString
public class SpuItemAttrGroupVo {
private String groupName;
private List<Attr> attrs;
}
1.6.3 规格参数
- ItemController
@Controller
public class ItemController {
@Autowired
SkuInfoService skuInfoService;
/**
* 展示当前sku的详情
*
* @param skuId
* @return
*/
@GetMapping("/{skuId}.html")
public String skuItem(@PathVariable("skuId") Long skuId, Model model) {
System.out.println("准备查询" + skuId + "详情");
SkuItemVo vo = skuInfoService.item(skuId);
model.addAttribute("item",vo);
return "item";
}
}
- SkuInfoServiceImpl
@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
List<SkuImagesEntity> images = imagesService.getImagesBySkuId(skuId);
skuItemVo.setImages(images);
//3、获取的spu的销售属性组合。
//4、获取spu的介绍 pms_spu_info_desc
SpuInfoDescEntity spuInfoDescEntity = spuInfoDescService.getById(spuId);
skuItemVo.setDesp(spuInfoDescEntity);
//5、获取spu的规格参数信息
List<SpuItemAttrGroupVo> attrGroupVos = attrGroupService.getAttrGroupWithAttrsBySpuId(spuId,catalogId);
skuItemVo.setGroupAttrs(attrGroupVos);
return null;
}
- SkuImagesServiceImpl
@Override
public List<SkuImagesEntity> getImagesBySkuId(Long skuId) {
SkuImagesDao imagesDao = this.baseMapper;
List<SkuImagesEntity> imagesEntities = imagesDao.selectList(new QueryWrapper<SkuImagesEntity>().eq("sku_id", skuId));
return imagesEntities;
}
-
AttrGroupServiceImpl
@Override public List<SpuItemAttrGroupVo> getAttrGroupWithAttrsBySpuId(Long spuId, Long catalogId) { //1、查出当前spu对应的所有属性的分组信息以及当前分组下的所有属性对应的值 AttrGroupDao baseMapper = this.baseMapper; 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.atguigu.gulimall.product.vo.SpuItemAttrGroupVo">
<result property="groupName" column="attr_group_name"></result>
<collection property="attrs" ofType="com.atguigu.gulimall.product.vo.Attr">
<result column="attr_name" property="attrName"></result>
<result column="attr_value" property="attrValue"></result>
</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>
- sql语句
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` = 225 AND pav.`spu_id` = 6
- GulimallProductApplicationTests 测试
@Autowired
AttrGroupDao attrGroupDao;
@Test
public void test() {
List<SpuItemAttrGroupVo> attrGroupWithAttrsBySpuId = attrGroupDao.getAttrGroupWithAttrsBySpuId(6L, 225L);
System.out.println(attrGroupWithAttrsBySpuId);
}
1.6.4 销售属性组合
- SkuInfoServiceImpl(最终代码)
@Autowired
SkuImagesService imagesService;
@Autowired
SpuInfoDescService spuInfoDescService;
@Autowired
AttrGroupService attrGroupService;
@Autowired
SkuSaleAttrValueService skuSaleAttrValueService;
@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
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 spuInfoDescEntity = spuInfoDescService.getById(spuId);
skuItemVo.setDesp(spuInfoDescEntity);
//5、获取spu的规格参数信息。
List<SpuItemAttrGroupVo> attrGroupVos = attrGroupService.getAttrGroupWithAttrsBySpuId(spuId,catalogId);
skuItemVo.setGroupAttrs(attrGroupVos);
return skuItemVo;
}
- SkuSaleAttrValueServiceImpl
@Override
public List<SkuItemSaleAttrVo> getSaleAttrsBySpuId(Long spuId) {
SkuSaleAttrValueDao dao = this.baseMapper;
List<SkuItemSaleAttrVo> saleAttrVos = dao.getSaleAttrsBySpuId(spuId);
return saleAttrVos;
}
- SkuSaleAttrValueDao
List<SkuItemSaleAttrVo> getSaleAttrsBySpuId(@Param("spuId") Long spuId);
- SkuSaleAttrValueDao.xml
<select id="getSaleAttrsBySpuId" resultType="com.atguigu.gulimall.product.vo.SkuItemSaleAttrVo">
SELECT
ssav.`attr_id` attr_id,
ssav.`attr_name` attr_name,
GROUP_CONCAT(DISTINCT ssav.`attr_value`) attr_values
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>
sql语句
SELECT
ssav.`attr_id` attr_id,
ssav.`attr_name` attr_name,
GROUP_CONCAT(DISTINCT ssav.`attr_value`) attr_values
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`
GulimallProductApplicationTests 测试:
@Autowired
SkuSaleAttrValueDao skuSaleAttrValueDao;
@Test
public void test() {
List<SkuItemSaleAttrVo> saleAttrVos = skuSaleAttrValueDao.getSaleAttrsBySpuId(7L);
System.out.println(saleAttrVos);
}
1.6.5 详情页渲染
- 对item.html这个页面文件进行修改。
<div class="box-name" th:text="${item.info.skuTitle}">
华为 HUAWEI Mate 10 6GB+128GB 亮黑色 移动联通电信4G手机 双卡双待
</div>
<div class="box-hide" th:text="${item.info.skuSubtitle}">预订用户预计11月30日左右陆续发货!麒麟970芯片!AI智能拍照!
<a href="/static/item/"><u></u></a>
</div>
<div class="probox">
<img class="img1" alt="" th:src="${item.info.skuDefaultImg}">
<div class="hoverbox"></div>
</div>
<div class="showbox">
<img class="img1" alt="" th:src="${item.info.skuDefaultImg}">
</div>
<li>
<span th:text="${item.hasStock?'有货':'无货'}">无货</span>, 此商品暂时售完
</li>
<span th:text="${#numbers.formatDecimal(item.info.price,3,2)}">4499.00</span>
<li th:each="img : ${item.images}" th:if="${!#strings.isEmpty(img.imgUrl)}"><img th:src="${img.imgUrl}" /></li>
<div class="box-attr clear" th:each="attr:${item.saleAttr}">
<dl>
<dt>选择[[${attr.attrName}]]</dt>
<dd th:each="val:${#strings.listSplit(attr.attrValues,',')}">
<a href="/static/item/#">
[[${val}]]
<!--<img src="/static/item/img/59ddfcb1Nc3edb8f1.jpg" /> 摩卡金-->
</a>
</dd>
</dl>
</div>
<img class="xiaoguo" th:src="${descp}" th:each="descp:${#strings.listSplit(item.desp.decript,',')}"/>
<div class="guiGe" th:each="group:${item.groupAttrs}">
<h3 th:text="${group.groupName}">主体</h3>
<dl>
<div th:each="attr:${group.attrs}">
<dt th:text="${attr.attrName}">品牌</dt>
<dd th:text="${attr.attrValue}">华为(HUAWEI)</dd>
</div>
</div>
效果:
1.6.6 销售属性渲染
修改后台代码
- SkuItemSaleAttrVo
@Data
@ToString
public class SkuItemSaleAttrVo {
private Long attrId;
private String attrName;
private List<AttrValueWithSkuIdVo> attrValues;
}
- AttrValueWithSkuIdVo
@Data
public class AttrValueWithSkuIdVo {
private String attrValue;
private String skuIds;
}
- SkuSaleAttrValueDao.xml
<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>
<select id="getSaleAttrsBySpuId" 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>
- sql代码:
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`=7
GROUP BY ssav.`attr_id`,ssav.`attr_name`,ssav.`attr_value`
- item.html
<div class="box-attr clear" th:each="attr:${item.saleAttr}">
<dl>
<!--strings.listSplit 切分
#list.contains(A,B) 判断A数组中间 是否包含B
skuId 同时设置一个class 在列表中包含的设置为选中 否则不选中
-->
<dt>选择[[${attr.attrName}]]</dt>
<dd th:each="vals:${attr.attrValues}">
<a class="sku_attr_value" th:attr="skus=${vals.skuIds},class=${#lists.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>
$(function () {
//页面初始化 给父类id设置样式
$(".sku_attr_value").parent().css({"border": "solid 1px #CCC"});
//class里面有对应样式的父类设置样式 checked 表示选中
$("a[class = 'sku_attr_value checked']").parent().css({"border": "1px solid red"});
})
效果:
1)、点击sku能够动态切换
实现点击 sku 能够动态切换。
$(".sku_attr_value").click(function () {
//1、点击的元素先添加上自定义的属性。为了识别我们是刚才被点击的。
var skus = new Array();
$(this).addClass("checked");
//属性skus以逗号拆分
var curr = $(this).attr("skus").split(",");
//当前被点击的所有sku组合数组放进去
skus.push(curr);
//去掉同一行的所有checked
/**
* parent 父类 中查询 拥有class的 然后删除调 checked
*/
$(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
var filterEle = skus[0];
for (var i = 1; i < skus.length; i++) {
filterEle = $(filterEle).filter(skus[i]);
}
console.log(filterEle[0]);
//3、跳转
location.href = "http://item.gulimall.com/" + filterEle[0] + ".html";
})
效果:点击颜色和版本都可以自动切换。
1.6.7 异步编排优化代码
①引入依赖:配置类可以有提示,这个可以配也可以不配。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
② 商品服务下新建 ThreadPoolConfigProperties (我们设置自定义一些线程池属性)
@ConfigurationProperties(prefix = "gulimall.thread")
@Component
@Data
public class ThreadPoolConfigProperties {
private Integer coreSize;
private Integer maxSize;
private Integer keepAliveTime;
}
③ application.properties中添加线程池的相应配置
gulimall.thread.core-size=20
gulimall.thread.max-size=200
gulimall.thread.keep-alive-time=10
④商品服务下新建 MyThreadConfig
//@EnableConfigurationProperties(ThreadPoolConfigProperties.class) //因为 ThreadPoolConfigProperties添加了注解@Component,可以不用写这个配置了,直接从容器中拿
//上面这个要注释掉,否则运行报错,报只需要一个,但是提供两个的错误
@Configuration
public class MyThreadConfig {
@Bean
public ThreadPoolExecutor threadPoolExecutor(ThreadPoolConfigProperties pool){
return new ThreadPoolExecutor(pool.getCoreSize(),
pool.getMaxSize(),pool.getKeepAliveTime(),
TimeUnit.SECONDS,new LinkedBlockingDeque<>(100000),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy());
}
}
⑤ SkuInfoServiceImpl
package com.atguigu.gulimall.product.service.impl;
@Autowired
Executor executor;
@Override
public SkuItemVo item(Long skuId) throws ExecutionException, InterruptedException {
SkuItemVo skuItemVo = new SkuItemVo();
// 第一步获得的数据,第3步、4步、5步也要使用
CompletableFuture<SkuInfoEntity> infoFuture = CompletableFuture.supplyAsync(() -> {
//1、sku基本信息获取 pms_sku_info
SkuInfoEntity info = getById(skuId);
skuItemVo.setInfo(info);
return info;
}, executor);
CompletableFuture<Void> saleAttrFuture = infoFuture.thenAcceptAsync((res) -> {
//3、获取的spu的销售属性组合。
List<SkuItemSaleAttrVo> saleAttrVos = skuSaleAttrValueService.getSaleAttrsBySpuId(res.getSpuId());
skuItemVo.setSaleAttr(saleAttrVos);
}, executor);
CompletableFuture<Void> descFuture = infoFuture.thenAcceptAsync((res) -> {
//4、获取spu的介绍 pms_spu_info_desc
SpuInfoDescEntity spuInfoDescEntity = spuInfoDescService.getById(res.getSpuId());
skuItemVo.setDesp(spuInfoDescEntity);
}, executor);
CompletableFuture<Void> baseAttrFuture = infoFuture.thenAcceptAsync((res) -> {
//5、获取spu的规格参数信息。
List<SpuItemAttrGroupVo> attrGroupVos = attrGroupService.getAttrGroupWithAttrsBySpuId(res.getSpuId(), res.getCatalogId());
skuItemVo.setGroupAttrs(attrGroupVos);
}, executor);
//不需要返回,直接用异步run就行
CompletableFuture<Void> imageFuture = CompletableFuture.runAsync(() -> {
//2、sku的图片信息 pms_sku_images
List<SkuImagesEntity> images = imagesService.getImagesBySkuId(skuId);
skuItemVo.setImages(images);
}, executor);
//allOf()方法:返回一个新的 CompletableFuture,当所有给定的 CompletableFutures 完成时,该 CompletableFuture 就完成了。
//等待所有任务都完成
CompletableFuture.allOf(saleAttrFuture,descFuture,baseAttrFuture,imageFuture).get();
return skuItemVo;
}
⑥ItemController
/**
* 展示当前sku的详情
*
* @param skuId
* @return
*/
@GetMapping("/{skuId}.html")
public String skuItem(@PathVariable("skuId") Long skuId, Model model) throws ExecutionException, InterruptedException {
System.out.println("准备查询" + skuId + "详情");
SkuItemVo vo = skuInfoService.item(skuId);
model.addAttribute("item",vo);
return "item";
}
⑦测试:一切正常。
1.7 认证服务
1.7.1 环境搭建
1)、创建认证服务微服务
我们添加的依赖如上图所示。主要是springbootDevTools + Lombok + spring Web + Thymeleaf + OpenFeign(远程调用情况)
2)、引入依赖
<dependencies>
<dependency>
<groupId>com.atguigu.gulimall</groupId>
<artifactId>gulimall-common</artifactId>
<version>0.0.1-SNAPSHOT</version>
<exclusions>
<exclusion>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
</exclusion>
</exclusions>
</dependency>
3)、添加相应的域名
4)、动静分离
- 我们需要将登陆页面(login.html)和认证页面(reg.html)都放到新创建的认证服务下的templates下。
- 同理在nginx中的静态文件夹下的html中创建两个login文件夹和reg的文件夹。
- 修改login.html和reg.html页面中的静态资源路径。将href=" 改为href="/static/login/ 和 src=" 改为src="/static/login/ 。同理reg.html页面中我们也要进行相同的修改。
5)、nacos中注册
- application.properties
spring.application.name=gulimall-auth-server
spring.cloud.nacos.discovery.server-addr=127.0.0.1:8848
server.port=50000
- GulimallAuthServerApplication
@EnableFeignClients //加入远程调用
@EnableDiscoveryClient //加入服务注册发现功能
@SpringBootApplication
public class GulimallAuthServerApplication {
http://localhost:8848/nacos/#/login
输入这个网址之后,本机才能打开naocs注册中心地址。否则不行。
6)、配置网关
- id: gulimall_auth_route
uri: lb://gulimall-auth-server
predicates:
- Host=auth.gulimall.com
7)、测试访问登录页面
-
我们暂时将login.html改为index.html页面,这样因为是默认的会被模板引擎解析。但是这个是暂时的。
8)、实现各个页面之间跳转
1、实现登录页面点击”谷粒商城“图标能跳转到首页:
login.html
2、实现首页点击登录和注册能跳转到登录和注册页面:
修改商品服务下的首页index.html
认证服务编写 controller 实现跳转
@Controller
public class LoginController {
@GetMapping("/login.html")
public String loginPage(){
return "login";
}
@GetMapping("/reg.html")
public String regPage(){
return "reg";
}
}
登录页面点击“立即注册”能够跳转到注册页面。
注册页面点击“请登录”能够跳转到登录页面。
ps:这里可以稍微修改一下 登录页面的宽度,让页面更好看一点。
1.7.2 验证码功能
1)、验证码功能
①把 reg.html页面中这一处修改为 “发送验证码”
发送验证码,有60秒倒计时:
$(function (){
$("#sendCode").click(function () {
//2、倒计时
if ($(this).hasClass("disabled")){
//正在倒计时。
}else{
//1、给指定手机号码发送验证码
timeoutChangeStyle();
}
});
})
var num = 60;
function timeoutChangeStyle(){
$("#sendCode").attr("class","disabled");
if (num == 0){
$("#sendCode").text("发送验证码");
num = 60;
$("#sendCode").attr("class","");
}else{
var str = num +"s 后再次发送";
$("#sendCode").text(str);
//每隔1s调用timeoutChangeStyle()
setTimeout("timeoutChangeStyle()",1000);
}
num --;
}
效果:
②修改后台代码
如果编写一个接口仅仅是为了跳转页面,没有数据的处理,如果这样的跳转接口多了则可以使用SpringMVC的view Controller(视图控制器)将请求与页面进行绑定.
新建 GulimallWebConfig
@Configuration
public class GulimallWebConfig implements WebMvcConfigurer {
/**
* 视图映射
* @param registry
*/
@Override
public void addViewControllers(ViewControllerRegistry registry) {
/**
* * @GetMapping("/login.html")
* * public String loginPage(){
* *
* * return "login";
* * }
* * @param registry
*/
registry.addViewController("/login.html").setViewName("login");
registry.addViewController("/reg.html").setViewName("reg");
}
}
ps:idea快捷键:实现接口方法
alt + shift + p
以前的 LoginController 里面的 方法就可以注释掉了。(这个地方编写的代码仅仅是帮助我们实现跳转页面的)
@Controller
public class LoginController {
/**
* 发送一个请求直接跳转到一个页面。
* springMVC viewcontroller:将请求和页面映射过来。
*/
// @GetMapping("/login.html")
// public String loginPage(){
//
// return "login";
// }
//
// @GetMapping("/reg.html")
// public String regPage(){
//
// return "reg";
// }
}
- 为了我们内存问题,我们可以将认证微服务的内存修改为只占用100m
2)、整合验证码
- 我们可以去阿里云云市场中购买三网短信接口,使用这个来进行完成我们的短信验证码功能。
- 试试调试功能
- 请求示例
public static void main(String[] args) {
String host = "https://dfsns.market.alicloudapi.com";
String path = "/data/send_sms";
String method = "POST";
String appcode = "你自己的AppCode";
Map<String, String> headers = new HashMap<String, String>();
//最后在header中的格式(中间是英文空格)为Authorization:APPCODE 83359fd73fe94948385f570e3c139105
headers.put("Authorization", "APPCODE " + appcode);
//根据API的要求,定义相对应的Content-Type
headers.put("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8");
Map<String, String> querys = new HashMap<String, String>();
Map<String, String> bodys = new HashMap<String, String>();
bodys.put("content", "code:1234");
bodys.put("phone_number", "156*****140");
bodys.put("template_id", "TPL_0000");
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.doPost(host, path, method, headers, querys, bodys);
System.out.println(response.toString());
//获取response的body
//System.out.println(EntityUtils.toString(response.getEntity()));
} catch (Exception e) {
e.printStackTrace();
}
}
ps:当我们在页面上点击“发送验证码”,我们不能通过js代码带上我们的APPCODE ,这样就直接将APPCODE 暴露给别人了,然后别人使用它发送大量短信(让短信服务崩溃),这样就有危机了。我们通过后台来发送验证码,这样比较保险。
- 在第三方微服务下进行简单的测试
- 直接将上面的请求示例的代码放到test中,手机号写自己的,测试看看。
- 我们发现需要引入依赖,所以我们
package com.atguigu.gulimall.thirdparty.utils;
这个包下面将请求示例中给我们提供的地址中的HttpUtils这个java代码复制到utils包中。 - 测试,发现发送成功
①短信远程服务准备
- 我们可以进行自定义,不要短信验证码写死。将请求实例中的代码抽取为一个组件 --- SmsComponent
package com.atguigu.gulimall.thirdparty.component;
@ConfigurationProperties(prefix = "spring.cloud.alicloud.sms")
@Data
@Component
public class SmsComponent {
private String host;
private String path;
private String appcode;
public void sendSmsCode(String phone, String content) {
String method = "POST";
Map<String, String> headers = new HashMap<String, String>();
headers.put("Authorization", "APPCODE " + appcode);
headers.put("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8");
Map<String, String> querys = new HashMap<String, String>();
Map<String, String> bodys = new HashMap<String, String>();
bodys.put("content", "code: " +content);
bodys.put("phone_number", phone);
bodys.put("template_id", "TPL_0000");
try {
HttpResponse response = HttpUtils.doPost(host, path, method, headers, querys, bodys);
System.out.println(response.toString());
//获取response的body
//System.out.println(EntityUtils.toString(response.getEntity()));
} catch (Exception e) {
e.printStackTrace();
}
}
}
- 在pom.xml文件中将下面的这个依赖引入,这样我们在application.yml文件中编写的时候就有提示了
- application.yml文件中编写我们自定义的。
- 在test中进行测试
@Test
public void testSms(){
smsComponent.sendSmsCode("15642848274","8639");
}
随便写一个,发现测试通过。
- 编写controller,提供给别的服务进行调用。
@RestController
@RequestMapping("/sms")
public class SmsSendController {
@Autowired
SmsComponent smsComponent;
/**
* 提供给别的服务进行调用
*
* @return
*/
@GetMapping("/sendcode")
public R sendCode(@RequestParam("phone") String phone,@RequestParam("content") String content){
smsComponent.sendSmsCode(phone,content);
return R.ok();
}
}
②认证服务远程调用短信
- 在认证微服务中编写一个feign接口(相应的主启动类上要加入
@EnableFeignClients
这个注解)
package com.atguigu.gulimall.auth.feign;
@FeignClient("gulimall-third-party")
public interface ThirdPartFeignService {
@GetMapping("/sms/sendcode")
public R sendCode(@RequestParam("phone") String phone, @RequestParam("content") String content);
}
③ 验证码防刷
-
我们将发送的验证码要按照一定的规则存储到redis中,到时候认证的时候需要根据用户输入的和实际存储的验证码进行对比。而且还要防止其他人利用网页刷新,将原来60s重新设置60s,导致短信服务崩溃。
-
redis保存验证码
注意引入:redis依赖
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>
- 设置redis中保存的前缀
package com.atguigu.common.constant; public class AuthServerConstant { public static final String SMS_CODE_CACHE_PREFIX = "sms:code:"; }
- 如果没有超过60s,设置错误代码
-
修改LoginController
-
@Controller public class LoginController { @Autowired ThirdPartFeignService thirdPartFeignService; @Autowired StringRedisTemplate redisTemplate; /** * 发送一个请求直接跳转到一个页面 * SpringMVC viewcontroller;将请求和页面映射过来 * @return */ @ResponseBody //返回json数据 @GetMapping("/sms/sendcode") public R sendCode(@RequestParam("phone")String phone){ //TODO //1.接口防刷 //相同手机号,即使再次刷新页面,但是手机号是相同的,你再次发送也不会生效 String redisContent = redisTemplate.opsForValue().get(AuthServerConstant.SMS_CODE_CACHE_PREFIX + phone); if(!StringUtils.isEmpty(redisContent)){ long l = Long.parseLong(redisContent.split("_")[1]); if(System.currentTimeMillis() - l < 60000){ //系统当前时间减去保存redis的时间间隔小于60s return R.error(BizCodeEnume.SMS_CODE_EXCEPTION.getCode(), BizCodeEnume.SMS_CODE_EXCEPTION.getMsg()); } } //2.验证码的再次校验。redis.存key-phone,value-code sms:code:17512080612 -> 45678 String content = UUID.randomUUID().toString().substring(0, 5); String substring = content + "_"+System.currentTimeMillis(); //redis缓存验证码,防止同一个phone在60秒内再次发送验证码 redisTemplate.opsForValue().set(AuthServerConstant.SMS_CODE_CACHE_PREFIX+phone,substring,10, TimeUnit.MILLISECONDS); thirdPartFeignService.sendCode(phone,content); return R.ok(); } @PostMapping("/regist") public String regist(@Valid UserRegistVo vo, BindingResult result, Model model){ if(result.hasErrors()){ Map<String, String> errors = result.getFieldErrors().stream().collect((Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage))); model.addAttribute("errors",errors); //校验出错,转发到注册页 return "forward:/reg.html"; } //真正注册,调用远程服务进行注册 //注册成功回到首页,回到登录页 return "redirect:/login.html"; } }
-
为页面设置的输入验证码功能
-
- 回调函数
1.7.3 一步一坑的注册页环境
1)、编写 vo封装注册页内容
package com.atguigu.gulimall.auth.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 = "手机号格式不正确")
private String phone;
@NotEmpty(message = "验证码必须填写")
private String content;
}
后端使用jsr303校验。
JSR303校验的结果,被封装到 BindingResult ,再结合 BindingResult.getFieldErrors() 方法获取错误信息, 有错误就重定向至注册页面。
2)、编写 controller接口
使用@Valid注解开启数据校验功能,将校验后的结果封装到BindingResult中。 LoginController
package com.atguigu.gulimall.auth.controller;
@PostMapping("/regist")
public String regist(@Valid UserRegistVo vo, BindingResult result) {
if (result.hasErrors()) {
//校验出错,转发到注册页
return "redirect:http://auth.gulimall.com/reg.html";
}
//注册成功回到首页,回到登录页
return "redirect:/login.html";
}
3)、编写注册页面
为每个input框设置name属性,值需要与Vo的属性名一一对应
点击注册按钮没有发送请求,说明:为注册按钮绑定了单击事件,禁止了默认行为。将绑定的单击事件注释掉
4)、为Model绑定校验错误信息
使用方法引用的方式。
5)、编写前端页面获取错误信息
<form action="/regist" method="post" class="one">0
<div class="register-box">
<label class="username_label">用 户 名
<input name="userName" maxlength="20" type="text" placeholder="您的用户名和登录名">
</label>
<div class="tips" style="color:red" th:text="${errors!=null?(#maps.containsKey(errors, 'userName')?errors.userName:''):''}">
</div>
</div>
<div class="register-box">
<label class="other_label">设 置 密 码
<input name="password" maxlength="20" type="password" placeholder="建议至少使用两种字符组合">
</label>
<div class="tips" style="color:red" th:text="${errors!=null?(#maps.containsKey(errors, 'password')?errors.password:''):''}">
</div>
</div>
<div class="register-box">
<label class="other_label">确 认 密 码
<input maxlength="20" type="password" placeholder="请再次输入密码">
</label>
<div class="tips">
</div>
</div>
<div class="register-box">
<label class="other_label">
<span>中国 0086∨</span>
<input name="phone" class="phone" id="phoneNum" maxlength="20" type="text" placeholder="建议使用常用手机">
</label>
<div class="tips" style="color:red" th:text="${errors!=null?(#maps.containsKey(errors, 'phone')?errors.phone:''):''}">
</div>
</div>
<div class="register-box">
<label class="other_label">验 证 码
<input name="code" maxlength="20" type="text" placeholder="请输入验证码" class="caa">
</label>
<a id="sendCode">发送验证码</a>
<div class="tips" style="color:red" th:text="${errors!=null?(#maps.containsKey(errors, 'code')?errors.code:''):''}">
</div>
6)、测试--踩坑
- 第一个踩坑
- 我的错误却是
Request method 'GET' not supported
Resolved [org.springframework.web.HttpRequestMethodNotSupportedException: Request method 'GET' not supported]
这个是我百度找到的结果,使用@RequestMapping
- 第二个踩坑
- 刷新页面,会重复提交表单
-
出现问题:分布式下重定向使用session存储数据会出现一些问题(这个后续来解决)
-
完整代码
/**
* //TODO 重定向携带数据,利用session原理。将数据放在session中,只要跳到下一个页面取出这个数据以后,session里面的数据就会删掉
*
*
*
* // TODO 1、分布式下的session问题。
* RedirectAttributes redirectAttributes : 模拟重定向携带数据
* @param vo
* @param result
* @param redirectAttributes
* @return
*/
@PostMapping("/regist")
public String regist(@Valid UserRegistVo vo, BindingResult result, RedirectAttributes redirectAttributes, HttpSession session) {
if (result.hasErrors()) {
/**
* .map(fieldError ->{
* String field = fieldError.getField();
* String defaultMessage = fieldError.getDefaultMessage();
* errors.put(field,defaultMessage);
* return
* })
*
*
*/
Map<String, String> errors = result.getFieldErrors().stream().collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage));
// model.addAttribute("errors", errors);
redirectAttributes.addFlashAttribute("errors",errors);
// Request method 'POST' not supported
//用户注册 -》/regist[post] ---->转发/reg.html(路径映射默认都是get方法访问的。)
//真正注册。调用远程服务进行注册
//校验出错,转发到注册页
return "redirect:http://auth.gulimall.com/reg.html";
}
//注册成功回到首页,回到登录页
return "redirect:/login.html";
}
ps: 以上内容是注册用户
在 gulimall-auth-server服务中编写注册的主体逻辑
- 从redis中确认手机验证码是否正确,一致则删除验证码,(令牌机制)
- 会员服务调用成功后,重定向至登录页(防止表单重复提交),否则封装远程服务返回的错误信息返回至注册页面
- 重定向的请求数据,可以利用RedirectAttributes参数转发
- 但是他是利用的session原理,所以后期我们需要解决分布式的session问题
- 重定向取一次后,session数据就消失了,因为使用的是.addFlashAttribute(
- 重定向时,如果不指定host,就直接显示了注册服务的ip,所以我们重定义写http://…
注: RedirectAttributes可以通过session保存信息并在重定向的时候携带过去
1.7.4 异常机制
1)、校验验证码
@PostMapping("/regist")
public String regist(@Valid UserRegistVo vo, BindingResult result, RedirectAttributes redirectAttributes, HttpSession session) {
if (result.hasErrors()) {
/**
* .map(fieldError ->{
* String field = fieldError.getField();
* String defaultMessage = fieldError.getDefaultMessage();
* errors.put(field,defaultMessage);
* return
* })
*/
Map<String, String> errors = result.getFieldErrors().stream().collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage));
// model.addAttribute("errors", errors);
redirectAttributes.addFlashAttribute("errors",errors);
// Request method 'POST' not supported
//用户注册 -》/regist[post] ---->转发/reg.html(路径映射默认都是get方法访问的。)
//校验出错,重定向到注册页
return "redirect:http://auth.gulimall.com/reg.html";
}
//1、校验验证码
String code = vo.getCode();
String s = redisTemplate.opsForValue().get(AuthServerConstant.SMS_CODE_CACHE_PREFIX + vo.getPhone());
if (!StringUtils.isEmpty(s)){
if (code.equals(s.split("_")[0])){
//删除验证码;令牌机制
redisTemplate.delete(AuthServerConstant.SMS_CODE_CACHE_PREFIX + vo.getPhone());
//验证通过。//真正注册。调用远程服务进行注册。
}else{
Map<String, String> errors = new HashMap<>();
errors.put("code","验证码错误");
redirectAttributes.addFlashAttribute("errors",errors);
return "redirect:http://auth.gulimall.com/reg.html";
}
}else{
Map<String, String> errors = new HashMap<>();
errors.put("code","验证码错误");
redirectAttributes.addFlashAttribute("errors",errors);
return "redirect:http://auth.gulimall.com/reg.html";
}
//注册成功回到首页,回到登录页
return "redirect:/login.html";
}
验证短信验证码通过,下面开始去数据库保存。
-
member远程服务 ------- 通过
gulimall-member
会员服务注册逻辑-
通过异常机制判断当前注册会员名和电话号码是否已经注册,如果已经注册,则抛出对应的自定义异常,并在返回时封装对应的错误信息
-
如果没有注册,则封装传递过来的会员信息,并设置默认的会员等级、创建时间
-
2)、会员服务中编写Vo接受数据
package com.atguigu.gulimall.member.vo;
@Data
public class MemberRegistVo {
/**
* 这个地方就不需要校验了,因为只有正确了,才会进行保存
*/
private String userName;
private String password;
private String phone;
}
3)、编写会员服务的用户注册接口
- MemberController
//因为我们注册会提交很多的东西,所以是 post方式提交
@RequestMapping("/regist")
public R regist(@RequestBody MemberRegistVo vo){
memberService.regist(vo);
return R.ok();
}
- MemberServiceImpl
@Override
public void regist(MemberRegistVo vo) {
MemberDao memberDao = this.baseMapper;
MemberEntity entity = new MemberEntity();
//设置默认等级
MemberLevelEntity levelEntity = memberLevelDao.getDefaultLevel();
entity.setLevelId(levelEntity.getId());
//检查用户名和手机号是否唯一。为了让controller能够感知异常,使用异常机制:一直往上抛
checkPhoneUnique(vo.getPhone());
checkUsernameUnique(vo.getUserName());
entity.setMobile(vo.getPhone());
entity.setUsername(vo.getUserName());
//密码要进行加密存储。
memberDao.insert(entity);
}
-
MemberLevelDao.xml -> :查询会员的默认等级
<select id="getDefaultLevel" resultType="com.atguigu.gulimall.member.entity.MemberLevelEntity"> SELECT * FROM `ums_member_level` WHERE default_status = 1 </select>
4)、异常类的编写
- PhoneExistException
package com.atguigu.gulimall.member.exception;
public class PhoneExistException extends RuntimeException{
public PhoneExistException(){
super("手机号存在");
}
}
- UsernameExistException
public class UsernameExistException extends RuntimeException {
public UsernameExistException(){
super("用户名存在");
}
}
- 检查方法编写->MemberServiceI
void checkPhoneUnique(String phone) throws PhoneExistException;
void checkUsernameUnique(String username) throws UsernameExistException;
- MemberServiceImpl
@Override
public void checkPhoneUnique(String phone) throws PhoneExistException{
MemberDao memberDao = this.baseMapper;
Integer mobile = memberDao.selectCount(new QueryWrapper<MemberEntity>().eq("mobile", phone));
if (mobile > 0){
throw new PhoneExistException();
}
}
@Override
public void checkUsernameUnique(String username) throws UsernameExistException {
MemberDao memberDao = this.baseMapper;
Integer count = memberDao.selectCount(new QueryWrapper<MemberEntity>().eq("username", username));
if (count > 0){
throw new UsernameExistException();
}
}
如果抛出异常,则进行捕获
@Test
public void contextLoads() {
//e10adc3949ba59abbe56e057f20f883e
//抗修改性:彩虹表。 123456 -> xxxx
String s = DigestUtils.md5Hex("123456");
//MD5不能直接进行密码的加密存储:可以被直接暴力破解
// System.out.println(s);
}
1.7.5 MD5&盐值&BCrypt
- 密码的设置,前端传来的密码是明文,存储到数据库中需要进行加密。
@Test
public void contextLoads() {
//e10adc3949ba59abbe56e057f20f883e
//抗修改性:彩虹表。 123456 -> xxxx
String s = DigestUtils.md5Hex("123456");
//MD5不能直接进行密码的加密存储:可以被直接暴力破解
// System.out.println(s);
}
Apache.common下DigestUtils工具类的md5Hex()方法,将MD5加密后的数据转化为16进制
MD5并安全,很多在线网站都可以破解MD5,通过使用彩虹表,暴力破解。
因此,可以通过使用MD5+盐值进行加密
盐值:随机生成的数
方法1是加默认盐值:$1$xxxxxxxx
方法2是加自定义盐值
//盐值加密:随机值 加盐:$1$+8位字符
//$1$qqqqqqqq$AZofg3QwurbxV3KEOzwuI1
//验证: 123456进行盐值(去数据库查)加密
// String s1 = Md5Crypt.md5Crypt("123456".getBytes(), "$1$qqqqqqqq");
// System.out.println(s1);
这种方法需要在数据库添加一个专门来记录注册时系统时间的字段,此外还需额外在数据库中存储盐值。
可以使用Spring家的BCryptPasswordEncoder,它的encode()方法使用的就是MD5+盐值进行加密,盐值是随机产生的,通过matches()方法进行密码是否一致。
//使用 spring家的
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
//$2a$10$R/VBymW1UA.VzeBedBcspe.iypJIyQiWkka/Ds5SDG7h6r0wQsF6G
String encode = passwordEncoder.encode("123456");
boolean matches = passwordEncoder.matches("123456", "$2a$10$R/VBymW1UA.VzeBedBcspe.iypJIyQiWkka/Ds5SDG7h6r0wQsF6G");
// $2a$10$jLJp4edbLb9pnCg9quGk0u2uvsm4E/6TD5zi1wqHY4jz/f1ydS.LS=>true
System.out.println(encode+"=>"+matches);
用户注册业务中的密码加密
//密码要进行加密存储。
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
String encode = passwordEncoder.encode(vo.getPassword());
entity.setPassword(encode);
1.7.6 注册完成
1)、在common的exception包下,编写异常枚举
USER_EXIST_EXCEPTION(15001,"用户存在"),
PHONE_EXIST_EXCEPTION(15002,"手机号存在"),
2)、进行异常的捕获
- MemberController
package com.atguigu.gulimall.member.controller;
// @PostMapping("/regist")
@RequestMapping("/regist")
public R regist(@RequestBody MemberRegistVo vo ){
try {
memberService.regist(vo);
} catch (PhoneExistException e) {
return 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();
}
3)、远程服务接口编写
- 在 auth 服务下新建 MemberFeignService
@FeignClient("gulimall-member")
public interface MemberFeignService {
@PostMapping("/member/member/regist")
public R regist(@RequestBody UserRegistVo vo);
}
4)、 远程服务调用
package com.atguigu.gulimall.auth.controller;
/**
* //todo 重定向携带数据,利用的是session原理,将数据放在session中,只要跳到下一个页面,取出这个数据以后,session里面的数据就会删掉
* RedirectAttributes redirectAttributes :模拟重定向携带数据
*
* @param vo
* @param result
* @param redirectAttributes
* @return
*/
// @PostMapping("/regist")
@RequestMapping("/regist") //这个地方和老师不一样,写成postmapping的话,
// 会报 Request method 'GET' not supported,百度下改为requestmapping才行
public String regist(@Valid UserRegistVo vo, BindingResult result, RedirectAttributes redirectAttributes,
HttpSession session) {
if (result.hasErrors()) {
Map<String, String> errors = result.getFieldErrors().stream().collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage));
// model.addAttribute("errors",errors);
redirectAttributes.addFlashAttribute("errors", errors);
// Request method 'GET' not supported
// 用户注册 -》 /regist[约定是post表单提交] - 》转发/reg.html(路径映射默认都是get方式访问的)
//校验出错,转发到注册页
return "redirect:http://auth.gulimall.com/reg.html";
// return "forward:/reg.html";
}
//1.校验验证码
String content = vo.getContent();
String s = redisTemplate.opsForValue().get(AuthServerConstant.SMS_CODE_CACHE_PREFIX + vo.getPhone());
if (!StringUtils.isEmpty(s)) {
if (content.equals(s.split("_")[0])) {
//删除验证码 ;令牌机制
redisTemplate.delete(AuthServerConstant.SMS_CODE_CACHE_PREFIX + vo.getPhone());
//验证通过 //真正注册,调用远程服务进行注册
R r = memberFeignService.regist(vo);
if(r.getCode() == 0){
//成功
return "redirect:http://auth.gulimall.com/login.html";
}else{
HashMap<String,String> errors = new HashMap<>();
errors.put("msg",r.getData("msg",new TypeReference<String>(){
}));
redirectAttributes.addFlashAttribute("errors",errors);
return "redirect:http://auth.gulimall.com/reg.html";
}
} else {
Map<String, String> errors = new HashMap<>();
errors.put("content", "验证码错误");
redirectAttributes.addFlashAttribute("errors", errors);
return "redirect:http://auth.gulimall.com/reg.html";
}
} else {
Map<String, String> errors = new HashMap<>();
errors.put("content", "验证码错误");
redirectAttributes.addFlashAttribute("errors", errors);
//校验出错,转发到注册页
return "redirect:http://auth.gulimall.com/reg.html";
}
//真正注册,调用远程服务进行注册
//注册成功回到首页,回到登录页
}
}
5)、注册页错误消息提示
6)、测试
- 注册页面输入之后,跳转到登录页面
- 数据库中也有数据
- 报错:
status 400 reading MemberFeignService#regist(UserRegistVo)
这个地方最开始写成@RequestParam了。
1.7.7 账户密码登录完成
1)、编写vo
package com.atguigu.gulimall.auth.vo;
@Data
public class UserLoginVo {
private String loginacct;
private String password;
}
2)、数据绑定
我们进行输入账号密码进行提交,我们必须要采用表单的方式。
3)、编写登录接口
package com.atguigu.gulimall.auth.controller;
@PostMapping("/login")
public String login(UserLoginVo vo,RedirectAttributes redirectAttributes){
//因为请求第一次过来传过来的是kv键值对(form表单),不是Json,所以不加@requestBody,调用远程服务时将其又转换为了json
//远程登录
R login = memberFeignService.login(vo);
if(login.getCode() == 0){
//成功
return "redirect:http://gulimall.com";
// return "redirect:http//gulimall.com";
}else {
//失败 ,回到登录页
Map<String, String> errors = new HashMap<>();
errors.put("msg",login.getData("msg",new TypeReference<String>(){}));
redirectAttributes.addFlashAttribute("errors",errors);
return "redirect:http://auth.gulimall.com/login.html";
}
}
4)、MemberLoginVo
package com.atguigu.gulimall.member.vo;
@Data
public class MemberLoginVo {
private String loginacct;
private String password;
}
5)、登录校验功能
- MemberController
//login
@PostMapping("/login")
// @RequestMapping("/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_INVALID_EXCEPTION.getCode(), BizCodeEnume.LOGINACCT_PASSWORD_INVALID_EXCEPTION.getMsg());
}
}
- MemberServiceImpl
package com.atguigu.gulimall.member.service.impl;
@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{
//1.获取到数据库的password
String passwordDb = entity.getPassword();
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
//2.密码匹配
boolean matches = passwordEncoder.matches(password, passwordDb);
if(matches){
return entity;
}else {
return null;
}
}
}
6)、编写异常枚举
7)、远程服务接口编写
认证服务调用会员服务中的登录校验功能进行登录验证
- MemberFeignService
package com.atguigu.gulimall.auth.feign;
@PostMapping("/member/member/login")
R login(@RequestBody UserLoginVo vo);
-
LoginController
@PostMapping("/login") public String login(UserLoginVo vo,RedirectAttributes redirectAttributes){ //因为请求第一次过来传过来的是kv键值对(form表单),不是Json,所以不加@requestBody,调用远程服务时将其又转换为了json //远程登录 R login = memberFeignService.login(vo); if(login.getCode() == 0){ //成功 return "redirect:http://gulimall.com"; // return "redirect:http//gulimall.com"; }else { //失败 ,回到登录页 Map<String, String> errors = new HashMap<>(); errors.put("msg",login.getData("msg",new TypeReference<String>(){})); redirectAttributes.addFlashAttribute("errors",errors); return "redirect:http://auth.gulimall.com/login.html"; } }
8)、页面错误消息提示
- 在 form 表单下面新增一个 div存放错误消息提示
9)、测试
- 专门输入错误的账号密码看看到时会不会有错误消息提示
- 踩坑报错:
报这个错误
404 找不到资源,其实我们来看登录成功之后跳转的页面可看到重定向的网址写错了
1.7.8 社交登录
1)、OAuth 2.0
2)、微博登陆准备工作
3)、微博登陆测试(课件)
4)、实际操作(gitee+weibo)
- oauth2.0的原理
① Gitee测试
注册地址:https://gitee.com/oauth/applications/
对应文档:Gitee OAuth 文档:https://gitee.com/api/v5/oauth_doc#/
api 文档:https://gitee.com/api/v5/swagger#/getV5User
- 创建一个第三方应用
- oauth2认证基本流程
- 修改认证服务下的login.html页面
https://gitee.com/oauth/authorize?client_id={client_id}&redirect_uri={redirect_uri}&response_type=code
-
这个地方就是填自己的client_id和redirect_uri
-
图片随便找一个,将下方<src="/static/login/JD_img/gitee.png">即可。(相应的图标这些需要传到nginx中,根据图片路径来即可)。
- 测试获取code码
-
页面点击gitee图片登录
-
引导到指定页面登录
- 利用apifox获取access_token
-
https://gitee.com/oauth/token?grant_type=authorization_code&code={code}&client_id={client_id}&redirect_uri={redirect_uri}&client_secret={client_secret}
- 将上面获得的code码和uri和client_secret和client_id这些都填进去
- 得到access_token
{
"access_token": "289e10c1952bd8a7613d400b87b7b53d",
"token_type":"bearer",
"expires_in": 86400,
"refresh_token": "427b4f1c7bfc5948a07d62879782476bb23bccdc342f3174c8aeb17be83528a5",
"scope": "user_info",
"created_at": 1670673346
}
获取到这个令牌之后,我们就可以获取到公开的所有api。
- 比如说获取授权用户的所有资料
可以看到我们需要发送一个GET请求:https://gitee.com/api/v5/user 带上access_token即可。
② 微博测试
- 创建一个网页应用
2.授权机制说明:https://open.weibo.com/wiki/授权机制说明
高级信息:https://open.weibo.com/apps/2475976673/info/advanced
用户普通读取信息:https://open.weibo.com/wiki/2/users/show
- 其他测试我们在老师的课件中可以明确的看到。这里不再过多赘述。
③操作
我们发现获取的code等这些敏感数据都直接被显示到浏览器上,所以我们需要使用后台代码将这些隐藏在后台代码中。
- 首先设置回调地址
回调地址是用户授权后,码云回调到应用,并且回传授权码的地址
比如说qq:用户点击QQ登录跳转到QQ登录页面,登录成功后,应该跳转回网站。回调地址即在这里用来指定跳转回网站的URL。回调地址注册的目的是为了保障第三方APPID帐户的安全,以免被其他恶意网站盗用。
个人理解:我们在csdn中进行社交登录,选择qq,qq登录成功之后,会跳回csdn,微博也一样,登录成功之后,要跳回哪个页面,这个页面就是指的回调地址。
- 这就是我们设置的回调地址,即用户登录成功之后。
- gitee
- 微博
-
相应的我们在认证服务下的login.html的页面中设置的redirect_uri也要改回我们设置的这个回调地址
- 将第三方服务中短信验证码中使用到的HTTPutils这个工具类复制到common服务中,并按照要求引入依赖。----主要是为了后面发送请求方便,而且放在common中也可以让其他微服务能调用
-
依赖地址: https://github.com/aliyun/api-gateway-demo-sign-java/blob/master/pom.xml
将里面的依赖导入到公共服务中
<dependencies>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.15</version>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.2.1</version>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpcore</artifactId>
<version>4.2.1</version>
</dependency>
<dependency>
<groupId>commons-lang</groupId>
<artifactId>commons-lang</artifactId>
<version>2.6</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-util</artifactId>
<version>9.3.7.v20160115</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.5</version>
<scope>test</scope>
</dependency>
</dependencies>
test依赖暂时不需要。
- 我们需要注意的是gitee和微博登录之后返回的数据是不一样的,具体返回的结果是有一些差异的。
- 认证服务包下新建SocialUser
- 我们使用这个用户普通读取接口来进行获取用户信息
- 这个是返回的数据,我们可以使用Json工具将其转换为java实体类
package com.atguigu.gulimall.auth.vo;
@Data
public class SocialUser {
//gitee
/**
private String access_token;
private Long expires_in;
private String uid;
*/
//微博
private String access_token;
private String remind_in;
private Long expires_in;
private String uid;
private String isRealName;
}
- 修改数据库中
ums_member
表,增加三个属性
- 认证服务下编写OAuth2Controller
package com.atguigu.gulimall.auth.controller;
@Controller
@Slf4j
public class OAuth2Controller {
@Autowired
MemberFeignService memberFeignService;
// @GetMapping("/oauth2.0/gitee/success") ----- gitee
//登录成功之后处理回调
@GetMapping ("/oauth2.0/weibo/success") // ----- 微博
public String gitee(@RequestParam("code")String code) throws Exception {
Map<String, String> map = new HashMap<>();
/** ----- gitee
map.put("grant_type","authorization_code");
map.put("code",code);
map.put("client_id","882f054cba88fb88b67f402b6e458dd7d0938ce2483ebeaea220ddc54b8eebb5");
map.put("client_secret","1f87cb951c4faa8350673b6d7f0a56b6c6a8f442d19de0f2031756737359abe3");
*/
//微博
map.put("client_id", "2475976673");
map.put("client_secret", "a9298e9d9cad1d01d45574ba8a56d6d0");
map.put("grant_type", "authorization_code");
map.put("redirect_uri", "http://auth.gulimall.com/oauth2.0/weibo/success");
map.put("code", code);
Map<String, String> headers = new HashMap<>();
Map<String, String> querys = new HashMap<>();
//1.根据code换取access_token ----- gitee
// HttpResponse response = HttpUtils.doPost("http://gitee.com", "/oauth/token", "post", headers, querys, map);
//获取返回结果
HttpResponse response = HttpUtils.doPost("https://api.weibo.com", "/oauth2/access_token", "post", headers, querys, map);
//2.处理 getStatusLine() :获取响应状态行,响应状态行中有响应状态码
if(response.getStatusLine().getStatusCode() == 200){
//获取到了accessToken
//response.getEntity():获取响应实体,EntityUtils.toString:将httpentityzhaun'h
String json = EntityUtils.toString(response.getEntity());
SocialUser socialUser = JSON.parseObject(json, SocialUser.class);
/** ----- gitee
JSONObject jsonObject = JSON.parseObject(json);
String accessTtoken = jsonObject.getString("access_token");
Long expiresIn = Long.valueOf(jsonObject.getString("expires_in"));
//通过access_token获取用户id
Map<String, String> map1 = new HashMap<>();
map1.put("access_token",accessTtoken);
HttpResponse response1 = HttpUtils.doGet("http://gitee.com", "/api/v5/user", "get", new HashMap<>(), map1);
String json1 = EntityUtils.toString(response1.getEntity());
JSONObject jsonObject1 = JSON.parseObject(json1);
String id = jsonObject1.getString("id");
//将access_token expires_id uid封装到SocialUser
SocialUser socialUser = new SocialUser();
socialUser.setUid(id);
socialUser.setAccess_token(accessTtoken);
socialUser.setExpires_in(expiresIn);
*/
//知道当前是哪个社交用户
//1.当前用户如果是第一次进入网站,自动注册(为当前社交用户生成一个会员信息账号,以后这个社交账号就对应指定的会员账户)
//登录或注册这个社交用户
R oauthLogin = memberFeignService.oauthLogin(socialUser);
if(oauthLogin.getCode() == 0){
MemberRespVo data = oauthLogin.getData("data",new TypeReference<MemberRespVo>(){});
log.info("登录成功:用户信息:{}",data.toString());
//2.登录成功就跳回首页
return "redirect:http://gulimall.com";
}else {
return "redirect:http://auth.gulimall.com/login.html";
}
}else {
return "redirect:http://auth.gulimall.com/login.html";
}
}
}
- 会员服务下编写 处理社交登录的类(认证服务会远程调用会员服务中的社交登录)
- MemberEntity实体类下新增3个字段(因为如果是以前没有登录过得,我们要为其快速创建一个用户)
private String socialUid;
private String accessToken;
private Long expiresIn;
- MemberController
//社交登录
@PostMapping("/oauth2/login")
public R oauthLogin(@RequestBody SocialUser socialUser) throws Exception{
MemberEntity entity = memberService.login(socialUser);
if(entity != null){
//TODO1.登录成功处理
return R.ok().setData(entity);
}else {
return R.error(BizCodeEnume.LOGINACCT_PASSWORD_INVALID_EXCEPTION.getCode(),BizCodeEnume.LOGINACCT_PASSWORD_INVALID_EXCEPTION.getMsg());
}
}
- MemberServiceImpl
@Override
public MemberEntity login(SocialUser socialUser) throws Exception{
//登录和注册合并逻辑
String uid = socialUser.getUid();
//1.判断当前社交用户是否已经登录过系统
MemberDao memberDao = this.baseMapper;
MemberEntity memberEntity = memberDao.selectOne(new QueryWrapper<MemberEntity>().eq("social_uid", uid));
if(memberEntity != null){
//这个用户已经注册
MemberEntity update = new MemberEntity();
update.setId(memberEntity.getId());
update.setAccessToken(socialUser.getAccess_token());
update.setExpiresIn(socialUser.getExpires_in());
memberDao.updateById(update);
memberEntity.setAccessToken(socialUser.getAccess_token());
memberEntity.setExpiresIn(socialUser.getExpires_in());
return memberEntity;
}else{
//2.没有查询到当前社交用户对应的记录我们就需要注册一个
MemberEntity regiset = new MemberEntity();
try {
Map<String, String> query = new HashMap<>();
/**
query.put("access_token", socialUser.getAccess_token());
*/
query.put("access_token", socialUser.getAccess_token());
query.put("uid",socialUser.getUid());
// HttpResponse response = HttpUtils.doGet("http://gitee.com", "/api/v5/user", "get", new HashMap<>(), query); ----- gitee
HttpResponse response = HttpUtils.doGet("https://api.weibo.com", "/2/users/show.json", "get", new HashMap<>(), query);
if(response.getStatusLine().getStatusCode() == 200){
//查询成功
String json = EntityUtils.toString(response.getEntity());
JSONObject jsonObject = JSON.parseObject(json);
/**
//昵称 ----- gitee
String name = jsonObject.getString("name");
regiset.setNickname(name);
*/
String name = jsonObject.getString("name");
String gender = jsonObject.getString("gender");
regiset.setGender("m".equals(gender)?1:0);
regiset.setNickname(name);
}
} catch (Exception e) {
}
regiset.setSocialUid(socialUser.getUid());
regiset.setAccessToken(socialUser.getAccess_token());
regiset.setExpiresIn(socialUser.getExpires_in());
memberDao.insert(regiset) ;
return regiset;
}
}
- SocialUser类复制到member服务的vo包下,因为需要这个类型的数据,我们直接复制即可。
- 远程调用:认证服务调用会员服务
- 认证服务
package com.atguigu.gulimall.auth.feign;
@PostMapping("/member/member/oauth2/login")
public R oauthLogin(@RequestBody SocialUser socialUser) throws Exception;
- 为数据封装方便,将会员服务中的 MemberEntity 复制到认证服务下,重命名为 MemberRespVo
package com.atguigu.gulimall.auth.vo;
import lombok.Data;
import lombok.ToString;
import java.util.Date;
@ToString
@Data
public class MemberRespVo {
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;
private String socialUid;
private String accessToken;
private Long expiresIn;
}
- 测试
- 登录成功-授权--返回首页(数据库中有记录)
1.7.9 springsession
1)、session共享问题
2)、SpringSession核心原理
-
@EnableRedisHttpSession注解导入了RedisHttpSessionConfiguration.class这个配置类
- 在 RedisHttpSessionConfiguration.class这个配置类,为容器中注入了一个组件 sessionRepository -> sessionRedisOperations : redis操作session,实现session的增删改查
- 调用SpringHttpSessionConfiguration中的springSessionRepositoryFilter()方法,获取一个SessionRepositoryFilter对象,调用doFilterInternal()对原生的request和response对象进行封装即装饰者模式, request对象调用getSession()方法就会调用wrapperRequest对象的getSession()方法。
1.7.10 页面效果完善
1)、完善社交登录的页面效果
product包下面:index.html
进行简单的判断,如果登录了,就显示昵称;如果没有登录,就显示免费注册。
2)完善账号密码登录
页面登录除了进行扫码登录之外,还有一种方法是通过账号密码登录,这种登录方式的用户我们也需要进行保存到session中。
- 公共服务包
- 会员服务包 --- MemberController
以前账号密码登录成功之后,直接是return R.ok.没有做任何操作,现在我们加入一段代码,使之保存到session中。
- 认证服务包 ---- LoginController
3)、设置默认的昵称
- 会员服务包下 ------ MemberServiceImpl
4)、完善登录逻辑
我们如果已经登录了,那么我们在浏览器中重新访问登录页面的话就直接跳转到首页,而不是再次跳转到登录页面了。所以这一块我们需要自定义。
- 认证服务 ------ GulimallWebConfig
这个是以前我们对于只有登录跳转页面这个controller的抽取。使用springmvc抽取的,现在我们需要自定义。
-
自己编写接口实现逻辑
-
认证服务下 LoginController
-
//如果登录成功了之后,访问auth.gulimall.login.html这个页面会自动跳转到首页 @GetMapping("/login.html") public String loginPage(HttpSession session){ Object attribute = session.getAttribute(AuthServerConstant.LOGIN_USER); if(attribute == null){ //没登录 return "login"; }else{ return "redirect:http://gulimall.com"; } }
-
5)、商品详情页面登录完善
- 详情页显示昵称
- 点击详情页面左上角京东图标返回谷粒商城首页
6)、搜索页面用户昵称显示
注意这个是在检索服务包下。
- 导入依赖
因为我们都是将session这些存在redis中,都是对redis的操作。所以需要引入redis相关依赖。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--spring整合session-->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
- 配置
- 检索服务包下的application.properties中配置redis的相关配置
- 开启session共享
- 因为都是使用同一个session。所以我们可以将认证服务中的GulimallSessionConfig复制到检索服务中。
@Configuration
public class GulimallSessionConfig {
//子域共享问题解决
@Bean
public CookieSerializer cookieSerializer(){
DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
//将session作用域设置为不仅仅只限于子域名,而是整个父域名。这样所有子域名都可以获得一样的cookie
cookieSerializer.setDomainName("gulimall.com");
cookieSerializer.setCookieName("GULISESSION");
return cookieSerializer;
}
// 默认使用jdk进行序列化机制,这里我们使用json序列化方式来序列化对象数据到redis中
@Bean
public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
return new GenericJackson2JsonRedisSerializer();
}
}
- item.html页面完善
总结
1.7.11 单点登录
1)、介绍
2)、前置概念
3)、xxl单点登录demo
这里以码云上的[许雪里](https://gitee.com/xuxueli0323) / [xxl-sso](https://gitee.com/xuxueli0323/xxl-sso)单点登录为演示。
- 修改配置
- 配置本机域名
- 修改服务器redis地址
- 修改客户端配置
- xxl-sso-master\xxl-sso-master\xxl-sso-samples\xxl-sso-web-sample-springboot\src\main\resources
- 打包
mvn clean package -Dmaven.skip.test=true
- 启动服务器端和客户端
同样启动一个8081。
- 当访问客户端1,会自动重定向到服务器认证中心,认证中心登录后,客户端2刷新之后就登录上了。
认证中心访问路径:http://ssoserver.com:8080/xxl-sso-server
客户端1访问路径:http://client1.com:8081/xxl-sso-web-sample-springboot
客户端2访问路径:http://client2.com:8082/xxl-sso-web-sample-springboot
实现一处登录,处处登录。一处退出,处处退出。
4)、单点登录代码实现
① 总流程梳理
下图是一个总的流程。单点登录就是单独起一个认证服务,其他服务登录先请求认证服务,认证服务判断自己域名下是否有cookie保存登录信息,如果有直接返回,如果没有就登录并保存cookie重定向到申请地址。
② 环境准备
- 本机配置域名
根据此我们要在idea中创建相应的模块,服务端和客户端。
- 创建模块
- 使用Spring Initializr创建 gulimall-test-sso-server (服务器端)和 gulimall-test-sso-client(客户端)
- 引入的依赖
- spring web
- thymeleaf
- lombok
接下来我们将单点登录流程的全部分为三个部分进行理解和编排。
③单点登录流程1:
客户端:
- 创建一个HelloController
package com.atguigu.gulimall.ssoclient.controller;
@Controller
public class HelloController {
@Value("${sso.server.url}") #取出配置文件中的值
String ssoServerUrl;
/**
* 无需登录即可访问
* @return
*/
@ResponseBody
@GetMapping("/hello")
public String hello(){
return "hello";
}
/**
需要登录才可以访问的
*/
@GetMapping("/employees")
public String employees(Model model, HttpSession session){
//先从session中获取,看是否有登录用户
Object loginUser = session.getAttribute("loginUser");
//进行判断 没有登录
if (loginUser == null){
//如果没登录,让其跳转重定向到登录服务器进行登录,并且在跳转时带上前一个页面,即我们在sso登录之后需要跳回的页面。
//redirect:http://client1.com:8081/employees
return "redirect:"+ssoServerUrl+"?
redirect_url=http://client1.com:8081/employees";
}else{
//登录了,保存。这个地方我们都是写死了,两个用户的。开发中不可能是这样
List<String> emps = new ArrayList<>();
emps.add("张三");
emps.add("李四");
// model 就是个 key-value 集合。Model 对象负责在控制器和展现数据的视图之间传递数据。
model.addAttribute("emps",emps);
return "list";
}
}
}
- 创建一个list.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>员工列表</title>
</head>
<body>
<h1>欢迎:[]</h1>
<ul>
<li th:each="emp:${emps}">姓名:[[${emp}]]</li>
</ul>
</body>
</html>
- application.properties
server.port=8081
sso.server.url=http://sso.com:8080/login.html
服务器端:
- LoginController
@Controller
public class LoginController {
@GetMapping("/login.html")
public String loginPage(@RequestParam("redirect_url")String url){
return "login";
}
@PostMapping("/doLogin")
public String doLogin(){
//登录成功跳转,跳回到之前的页面
return "";
}
}
- login.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>登录页</title>
</head>
<body>
<!--表单提交-->
<form action="/doLogin" method="post">
用户名:<input name="username"/><br/>
密码:<input name="password" type="password"/><br/>
<input type="submit" value="登录"/>
</form>
</body>
</html>
- application.properties
server.port=8080
- 演示:访问客户端直接重定向到 服务端。
④单点登录流程2:
服务器端:
- 加入依赖:(将cookie这些存入到redis中)
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
- 配置:
spring.redis.host=192.168.56.10
- login页面带一个隐藏输入框:用于存储调回的url
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>登录页</title>
</head>
<body>
<form action="/doLogin" method="post">
用户名:<input name="username"/><br/>
密码:<input name="password" type="password"/><br/>
<input type="hidden" name="url" th:value="${url}"/>
<input type="submit" value="登录"/>
</form>
</body>
</html>
- LoginController
- 登录成功保存用户信息并传递token
@Controller
public class LoginController {
@Autowired
private StringRedisTemplate redisTemplate;
@GetMapping("/login.html")
public String loginPage(@RequestParam("redirect_url")String url, Model model){
model.addAttribute("url",url);
return "login";
}
@PostMapping("/doLogin")
public String doLogin(@RequestParam("username") String username,
@RequestParam("password") String password,
@RequestParam("url") String url){
if (!StringUtils.isEmpty(username) && !StringUtils.isEmpty(password)){
//登录成功,跳回之前页面
String uuid = UUID.randomUUID().toString().replace("-","");
//把登录成功的用户保存起来
//登录成功保存用户信息并传递token
redisTemplate.opsForValue().set(uuid,username);
return "redirect:"+url+"?token="+uuid;
}
//登录失败,展示登录页
return "login";
}
客户端:
- 拿到令牌需要去认证中心查询用户的信息,这里只是简单保存了以下并没有模拟
/**
* 能够感知这次是在 ssoserver登录成功跳回来的。
* @param model
* @param session
* @param token 只要去ssoserver登录成功跳回来就会带上
* @return
*/
@GetMapping("/employees")
public String employees(Model model, HttpSession session,
@RequestParam(value = "token",required = false) String token){
if (!StringUtils.isEmpty(token)){
//去ssoserver登录成功跳回来就会带上
//TODO 1、去ssoserver获取当前token真正对应的用户信息
session.setAttribute("loginUser","zhangsan");
}
//先从session中获取,看是否有登录用户
Object loginUser = session.getAttribute("loginUser");
//进行判断 没有登录
if (loginUser == null){
//如果没登录,让其跳转重定向到登录服务器进行登录,并且在跳转时带上前一个页面,即我们在sso登录之后需要跳回的页面。
//redirect:http://client1.com:8081/employees
return "redirect:"+ssoServerUrl+"?
redirect_url=http://client1.com:8081/employees";
}
else{
List<String> emps = new ArrayList<>();
emps.add("张三");
emps.add("李四");
model.addAttribute("emps",emps);
return "list";
}
}
- 测试:客户端1加上了token
⑤单点登录流程3:
- 将客户端复制一份,改为客户端2,修改一些配置
- 添加进项目
- 服务器端:
@Controller
public class LoginController {
@Autowired
private StringRedisTemplate redisTemplate;
//登录之后保存用户token
@ResponseBody
@GetMapping("/userInfo")
public String userInfo(@RequestParam("token") String token){
String s = redisTemplate.opsForValue().get(token);
return s;
}
@GetMapping("/login.html")
public String loginPage(@RequestParam("redirect_url")String url, Model model,
@CookieValue(value = "sso_token",required = false) String sso_token){
if (!StringUtils.isEmpty(sso_token)){
//说明之前有人登录过,浏览器留下了痕迹
return "redirect:"+url+"?token="+sso_token;
}
model.addAttribute("url",url);
return "login";
}
@PostMapping("/doLogin")
public String doLogin(@RequestParam("username") String username,
@RequestParam("password") String password,
@RequestParam("url") String url,
HttpServletResponse response){
if (!StringUtils.isEmpty(username) && !StringUtils.isEmpty(password)){
//登录成功,跳回之前页面
//把登录成功的用户保存起来
// 登录成功保存用户信息并传递token
//UUID 01E4DC67-6F61-4272-07E1-28417A4A4707
String uuid = UUID.randomUUID().toString().replace("-","");
redisTemplate.opsForValue().set(uuid,username);
Cookie sso_token = new Cookie("sso_token", uuid);
response.addCookie(sso_token);
return "redirect:"+url+"?token="+uuid;
}
//登录失败,展示登录页
return "login";
}
}
- 客户端:
@Controller
public class HelloController {
@Value("${sso.server.url}")
String ssoServerUrl;
/**
* 无需登录即可访问
* @return
*/
@ResponseBody
@GetMapping("/hello")
public String hello(){
return "hello";
} /**
* 能够感知这次是在 ssoserver登录成功跳回来的。
* @param model
* @param session
* @param token 只要去ssoserver登录成功跳回来就会带上
* @return
*/
@GetMapping("/employees")
public String employees(Model model, HttpSession session,
@RequestParam(value = "token",required = false) String token){
if (!StringUtils.isEmpty(token)){
//去ssoserver登录成功跳回来就会带上
//TODO 1、去ssoserver获取当前token真正对应的用户信息
RestTemplate restTemplate = new RestTemplate();
ResponseEntity<String> forEntity =
restTemplate.getForEntity("http://sso.com:8080/userInfo?token=" + token, String.class);
String body = forEntity.getBody();
session.setAttribute("loginUser",body);
}
Object loginUser = session.getAttribute("loginUser");
if (loginUser == null){
//没登录,跳转到登录服务器进行登录
//跳转过去以后,使用url上的查询参数标识我们自己是那个页面
//redirect:http://client1.com:8081/employees
return "redirect:"+ssoServerUrl+"?
redirect_url=http://client1.com:8081/employees";
}else{
List<String> emps = new ArrayList<>();
emps.add("张三");
emps.add("李四");
model.addAttribute("emps",emps);
return "list";
}
}
}
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>员工列表</title>
</head>
<body>
<h1>欢迎:[[${session.loginUser}]]</h1>
<ul>
<li th:each="emp:${emps}">姓名:[[${emp}]]</li>
</ul>
</body>
</html>
- 测试:
1.8 购物车服务
1.8.1 环境搭建
1)、配置域名:cart.gulimall.com
2)、创建微服务
-
gulimall-cart
-
加入的依赖:lombok springweb springbootdevTools thymeleaf openfeign
-
导入公共服务的依赖
<dependency>
<groupId>com.atguigu.gulimall</groupId>
<artifactId>gulimall-common</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
- application.properties配置
server.port= 15000
spring.application.name=gulimall-cart
spring.cloud.nacos.discovery.server-addr=127.0.0.1:8848
- 主启动类上加入nacos服务注册发现功能,而且由于后面还需要远程调用,所以我们还需要加上相应注解(特别提醒:我们导入了common包,但是没有用到数据库服务,所以我们还需要暂时排除数据库自动配置,否则启动会报错)
@EnableFeignClients
@EnableDiscoveryClient
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
3)、动静分离
-
将课件中的静态资源放到nginx中
- 在nginx中,/mydata/nginx/html/static下创建一个cart文件夹。将静态资源放入起重
-
将动态页面放入项目中
-
由于更换了路径,所以一些文件路径和图片路径会发生变化,所以我们需要修改。
4)、网关配置
- id: gulimall_cart_route
uri: lb://gulimall-cart
predicates:
- Host=cart.gulimall.com
5)、前端页面调试
在不编写任何代码的情况下,我们进行测试访问。为了方便,将success.html页面改名为Index.html
-
测试中发现的问题
- 页面中存在th:这个thymeleaf预先写好的代码,导致找不到,进而报错。我们可以先将其替换为空 。
- 页面中存在th:这个thymeleaf预先写好的代码,导致找不到,进而报错。我们可以先将其替换为空 。
-
前端页面点击图标可以返回首页
1.8.2 数据模型分析
1)、购物车需求
2)、数据结构
因此每一个购物项信息,都是一个对象,基本字段包括:
1.8.3 vo编写
1)、购物项的Vo编写:
package com.atguigu.gulimall.cart.vo;
public class CartItem {
private Long skuId;
private Boolean check = true;//是否被选中
private String title;//标题
private String image;//图片
private List<String> skuAttr;//销售属性组合描述
private BigDecimal price;//商品单价
private Integer count;//商品数量
private BigDecimal totalPrice;//总价,总价需要计算
public Long getSkuId() {
return skuId;
}
public void setSkuId(Long skuId) {
this.skuId = skuId;
}
public Boolean getCheck() {
return check;
}
public void setCheck(Boolean check) {
this.check = check;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getImage() {
return image;
}
public void setImage(String image) {
this.image = image;
}
public List<String> getSkuAttr() {
return skuAttr;
}
public void setSkuAttr(List<String> skuAttr) {
this.skuAttr = skuAttr;
}
public BigDecimal getPrice() {
return price;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
public Integer getCount() {
return count;
}
public void setCount(Integer count) {
this.count = count;
}
/**
* 计算当前项的总价
*
* @return
*/
public BigDecimal getTotalPrice() {
return this.price.multiply(new BigDecimal("" + this.count));
}
public void setTotalPrice(BigDecimal totalPrice) {
this.totalPrice = totalPrice;
}
}
2)、购车Vo
/**
* 整个购物车
* 需要计算的属性,必须重写它的get方法,保证每次获取属性都会进行计算,所以不用使用@Data方法
*/
public class Cart {
//一个购物车有很多个购物项
List<CartItem> items;
private Integer countNum;//商品数量
private Integer countType;//商品类型数量
private BigDecimal totalAmount;//商品总价
private BigDecimal reduce = new BigDecimal("0.00");//减免价格
public List<CartItem> getItems() {
return items;
}
public void setItems(List<CartItem> items) {
this.items = items;
}
public Integer getCountNum() {
int count = 0;
if (items != null && items.size() > 0) {
for (CartItem item : items) {
count += item.getCount();
}
}
return count;
}
public Integer getCountType() {
int count = 0;
if (items != null && items.size() > 0) {
for (CartItem item : items) {
count += 1;
}
}
return count;
}
public BigDecimal getTotalAmount() {
BigDecimal amount = new BigDecimal("0");
//1、计算购物项总价
if (items != null && items.size() > 0) {
for (CartItem item : items) {
BigDecimal totalPrice = item.getTotalPrice();
amount = amount.add(totalPrice);
}
}
//2、减去优惠总价
BigDecimal subtract = amount.subtract(getReduce());
return subtract;
}
public void setTotalAmount(BigDecimal totalAmount) {
this.totalAmount = totalAmount;
}
public BigDecimal getReduce() {
return reduce;
}
public void setReduce(BigDecimal reduce) {
this.reduce = reduce;
}
}
1.8.4 ThreadLocal用户身份鉴别
1)、数据存储-redis
将购物车数据存储至Redis中,因此,需要导入Spring整合Redis的依赖以及Redis的配置。项目上线之后,应该有 一个专门的Redis负责存储购物车的数据不应该使用缓存的Redis
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
- 配置redis地址
spring.redis.host=192.168.56.10
2)、准备cartService和其实现类
这个接口是以后我们编写购物车具体服务的
- 创建CartService这个类
- CartServiceImpl
package com.atguigu.gulimall.cart.service.impl;
@Slf4j
@Service
public class CartServiceImpl implements CartService {
@Autowired
private StringRedisTemplate stringRedisTemplate;
}
3)、引入springsession判断用户登录与否
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
我们会将登录的用户保存到session中,然后通过判断session中是否有用户的数据来判断登录与否。
-
配置session
-
直接将我们认证服务中的编写好的sessionconfig拿过来
-
GulimallSessionConfig
-
package com.atguigu.gulimall.cart.config; @Configuration public class GulimallSessionConfig { //子域共享问题解决 @Bean public CookieSerializer cookieSerializer(){ DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer(); //将session作用域设置为不仅仅只限于子域名,而是整个父域名。这样所有子域名都可以获得一样的cookie cookieSerializer.setDomainName("gulimall.com"); cookieSerializer.setCookieName("GULISESSION"); return cookieSerializer; } // 默认使用jdk进行序列化机制,这里我们使用json序列化方式来序列化对象数据到redis中 @Bean public RedisSerializer<Object> springSessionDefaultRedisSerializer() { return new GenericJackson2JsonRedisSerializer(); } }
-
4)、cookie中的user-key说明
第一次访问京东,会给你的cookie中设置user-key标识你的身份,有效期为一个月,浏览器会保存你的user-key, 以后访问都会带上
*浏览器有一个cookie;user-key;标识用户身份,一个月后个过期;
* 如果第一次使用jd的购物车功能,都会给一个临时的用户身份;
* 浏览器以后保存,每次访问都会带上这个cookie;
* 登录:session有
* 没登录:按照cookie里面带来user-key来做。
* 第一次:如果没有临时用户,帮忙创建一个临时用户。
*
5)、编写To与常量
- UserInfoTo
package com.atguigu.gulimall.cart.vo;
@ToString
@Data
public class UserInfoTo {
private Long userId;
private String userKey;
private boolean tempUser = false; //标识临时用户
}
-
公共服务下增加一个有关购物车的常量(设置临时用户的过期时间)
-
package com.atguigu.common.constant; public class CartConstant { public static final String TEMP_USER_COOKIE_NAME = "user-key"; //过期时间为1个月 ,默认单位是秒 临时用户的过期时间(参考京东) public static final int TEMP_USER_COOKIE_TIMEOUT= 60*60*24*30; }
6)、编写拦截器(登录前检查)
拦截器逻辑:业务执行之前,判断是否登录,若登录则封装用户信息,将标识位设置为true,postHandler就不再
设置作用域和有效时间,否则为其创建一个user-key
注意细节:整合SpringSession之后,Session获取数据都是从Redis中获取的
使用ThreadLocal,解决线程共享数据问题,方便同一线程共享UserInfoTo
-
编写拦截器 ---- CartInterceptor
-
package com.atguigu.gulimall.cart.interceptor; /** 在执行目标方法之前,判断用户的登录状态,并封装传递给controller目标请求 * @author hxld * @create 2022-12-15 21:36 */ public class CartInterceptor implements HandlerInterceptor { public static ThreadLocal<UserInfoTo> threadLocal = new ThreadLocal<>(); /** * 目标方法执行之前 * @param request * @param response * @param handler * @return * @throws Exception */ @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { UserInfoTo userInfoTo = new UserInfoTo(); HttpSession session = request.getSession(); MemberRespVo member = (MemberRespVo) session.getAttribute(AuthServerConstant.LOGIN_USER); if(member != null){ //用户登录 userInfoTo.setUserId(member.getId()); } Cookie[] cookies = request.getCookies(); if(cookies!=null && cookies.length>0){ for (Cookie cookie : cookies) { //user-key String name = cookie.getName(); if(name.equals(CartConstant.TEMP_USER_COOKIE_NAME)){ userInfoTo.setUserKey(cookie.getValue()); } } } //如果没有临时用户一定要分配一个临时用户 if(StringUtils.isEmpty(userInfoTo.getUserKey())){ String uuid = UUID.randomUUID().toString(); userInfoTo.setUserKey(uuid); } threadLocal.set(userInfoTo); return true; } /** * 业务执行之后:分配临时用户,让浏览器保存 * @param request * @param response * @param handler * @param modelAndView * @throws Exception */ @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { UserInfoTo userInfoTo = threadLocal.get(); //如果没有临时用户一定要保存一个临时用户 if(!userInfoTo.isTempUser()){ //持续的延长临时用户的过期时间 Cookie cookie = new Cookie(CartConstant.TEMP_USER_COOKIE_NAME, userInfoTo.getUserKey()); cookie.setDomain("gulimall.com"); cookie.setMaxAge(CartConstant.TEMP_USER_COOKIE_TIMEOUT); response.addCookie(cookie); } } }
-
-
配置拦截器拦截所有请求
-
package com.atguigu.gulimall.cart.config; @Configuration public class GulimallWebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { //配置catrInterceptor拦截器拦截所有请求 registry.addInterceptor(new CartInterceptor()).addPathPatterns("/**"); } }
-
7)、CartController
@Controller
public class CartController {
/**
*浏览器有一个cookie;user-key;标识用户身份,一个月后个过期;
* 如果第一次使用jd的购物车功能,都会给一个临时的用户身份;
* 浏览器以后保存,每次访问都会带上这个cookie;
*
*
* 登录:session有
* 没登录:按照cookie里面带来user-key来做。
* 第一次:如果没有临时用户,帮忙创建一个临时用户。
*
* @return
*/
@GetMapping("/cart.html")
public String cartListPage(){
//1.快速得到用户信息,id,user-key
UserInfoTo userInfoTo = CartInterceptor.threadLocal.get();
System.out.println(userInfoTo);
return "cartList";
}
}
8)、测试
我们不登录,检查看看session是否有user-key
测试通过。
1.8.5 页面环境搭建
-
商品详情页
-
CartController
-
/** * 添加商品到购物车 */ @GetMapping("/addToCart") public String addToCart(){ return "success"; }
-
-
加入商品成功后,跳转到购物车列表页面
- success.html
- 这里的查看详情,我们先写成查看跳转到1号商品
-
购物车详情页面
-
cartList.html
1.8.6 添加购物车
1)、加入购物车
-
首先我们需要知道的是添加商品到购物车,必须要知道两个属性,商品的id和数量。
- 在CartController中编写一个addToCart方法,这个方法专门来处理该请求。
-
商品详情页面中加入购物车这个也需要绑定一个单击事件
-
为该属性加入单击事件,将这个href的跳转地址改为#(href="#"是一种临时链接的写法,这样写就是说这个链接目前不可用,点击了也不会有作用),并且我们还要通过自定义属性方式获取商品的SKUID,我们在编写的单击事件逻辑中编辑完整功能。
-
-
<div class="box-btns-two"> <a href="#" id="addToCartA" th:attr="skuId=${item.info.skuId}"> 加入购物车 </a> </div>
-
为文本框设置一个id,以供加入购物车单击事件获取商品数量(js中使用id属性为特定元素执行某些任务)
-
单击事件
-
$("#addToCartA").click(function () { var num = $("#numInput").val(); //获取数量 var skuId = $(this).attr("skuId"); //获取商品的skuId location.href = "http://cart.gulimall.com/addToCart?skuId=" + skuId + "&num=" + num; //拼接路径,加入购物车之后要跳转的的页面 return false; //取消默认行为 })
-
-
-
-
2)、修改加入购物车后的成功页面展示
① 简单修改页面
- 加入购物车的商品,我们需要在页面进行展示,将其相关信息等展示出来。
- 默认图片的显示、商品详情页跳转以及标题显示、商品数量显示(注意这里的item是当跳转成功页面之后使用
model.addAttribute("item",item);
放到请求域中的)
- 默认图片的显示、商品详情页跳转以及标题显示、商品数量显示(注意这里的item是当跳转成功页面之后使用
②抽取获取购物车(临时用户/登录用户的)方法
-
依照前面在CatrServiceImpl类中放入StringRedisTemplate的做法,我们为临时用户和登录用户设置一个购物车前缀。(登录用户会显示具体的,临时用户则是uuid)
-
-
private final String CART_PREFIX="gulimall:cart:";//购物车前缀
-
-
-
/** 获取用户购物车数据 * 获取到我们要操作的购物车,购物车存储在redis中,是双层map MAP(key,map<k,v>) * redisTemplate.opsForValue() redisTemplate.opsForHash():redis原生操作 * BoundHashOperations:经过一层包装的 类似于redisTemplate.opsForHash() 操作hash BoundHashOperations在操作单个key中的内容细节方面比原生操作更简单,比如一个键key对应Map类型的数据,这种使用BoundHashOperations来做就很方便了, */ private BoundHashOperations<String,Object,Object> getCartOps(){ UserInfoTo userInfoTo = CartInterceptor.threadLocal.get(); String cartKey = ""; if(userInfoTo.getUserId() != null){ //gulimall:cart:1 ----判断是登录用户 cartKey = CART_PREFIX + userInfoTo.getUserId(); }else { //gulimall:cart:20150b0a-678c-4f2b-1252-d7b148ca3fbc --- 临时用户 cartKey = CART_PREFIX + userInfoTo.getUserKey(); } //redisTemplate.boundHashOps(cartKey); 使用redis来操作cartkey BoundHashOperations<String, Object, Object> operations = redisTemplate.boundHashOps(cartKey); return operations; }
③远程调用商品服务中查询商品销售属性的方法
- 新建一个ProductFeignService(info/{skuId}直接使用以前的即可)
@FeignClient("gulimall-product")
public interface ProductFeignService {
@GetMapping("/product/skusaleattrvalue/stringlist/{skuId}") //获取销售属性 需要我们编写
List<String> getSkuSaleAttrValues(@PathVariable("skuId") Long skuId);
@RequestMapping("/product/skuinfo/info/{skuId}") //直接调用SkuInfoController中的 @RequestMapping("/info/{skuId}") 现成方法
R getSkuInfo(@PathVariable("skuId") Long skuId);
}
记得在主启动类上加入开启远程调用的注解@EnableFeignClients
④去到商品服务中编写要远程调用的方法
-
SkuSaleAttrValueController
-
package com.atguigu.gulimall.product.app; public class SkuSaleAttrValueController { @Autowired private SkuSaleAttrValueService skuSaleAttrValueService; @GetMapping("/stringlist/{skuId}") public List<String> getSkuSaleAttrValues(@PathVariable("skuId") Long skuId){ return skuSaleAttrValueService.getSkuSaleAttrValueAsStringList(skuId); } }
-
-
SkuSaleAttrValueService
-
package com.atguigu.gulimall.product.service; List<String> getSkuSaleAttrValueAsStringList(Long skuId);
-
-
SkuSaleAttrValueServiceImpl
-
package com.atguigu.gulimall.product.service.impl; @Override public List<String> getSkuSaleAttrValueAsStringList(Long skuId) { SkuSaleAttrValueDao dao = this.baseMapper; return dao.getSkuSaleAttrValuesAsStringList(skuId); }
-
-
SkuSaleAttrValueDao
-
List<String> getSkuSaleAttrValuesAsStringList(@Param("skuId")Long skuId);
-
-
SkuSaleAttrValueDao.xml
-
<select id="getSkuSaleAttrValuesAsStringList" resultType="java.lang.String"> select concat(attr_name,":",attr_value) from `pms_sku_sale_attr_value` where sku_id = #{skuId} </select>
-
⑤配置线程池 提高远程服务查询效率
这个我们可以直接复制商品服务写好的即可。
-
MyThreadConfig
-
@Configuration public class MyThreadConfig { @Bean public ThreadPoolExecutor threadPoolExecutor(ThreadPoolConfigProperties pool){ return new ThreadPoolExecutor(pool.getCoreSize(), pool.getMaxSize(),pool.getKeepAliveTime(), TimeUnit.SECONDS,new LinkedBlockingDeque<>(100000), Executors.defaultThreadFactory(), new ThreadPoolExecutor.AbortPolicy()); } }
-
-
ThreadPoolConfigProperties
-
@ConfigurationProperties(prefix = "gulimall.thread") @Component @Data public class ThreadPoolConfigProperties { private Integer coreSize; private Integer maxSize; private Integer keepAliveTime; }
-
-
application.properties
-
gulimall.thread.core-size=20 gulimall.thread.max-size=100 gulimall.thread.keep-alive-time=10
-
⑥异步编排
-
SkuInfoVo(将SkuInfoEntity这个复制过来就行)---因为我们远程调用返回的类型需要这个,所以直接放在购物车微服务中
-
@Data public class SkuInfoVo { /** * skuId */ private Long skuId; /** * spuId */ private Long spuId; /** * sku名称 */ private String skuName; /** * sku介绍描述 */ private String skuDesc; /** * 所属分类id */ private Long catalogId; /** * 品牌id */ private Long brandId; /** * 默认图片 */ private String skuDefaultImg; /** * 标题 */ private String skuTitle; /** * 副标题 */ private String skuSubtitle; /** * 价格 */ private BigDecimal price; /** * 销量 */ private Long saleCount; }
-
-
异步编排
-
CartService
-
//将商品添加到购物车 CartItem addToCart(Long skuId,Integer num) throws ExecutionException,InterruptedException;
-
-
CatrServiceImpl
-
public class CatrServiceImpl implements CartService { @Autowired ThreadPoolExecutor executor; @Override public CartItem addToCart(Long skuId, Integer num) throws ExecutionException, InterruptedException { //获取用户购物车数据 BoundHashOperations<String, Object, Object> cartOps = getCartOps(); // 判断商品是否存在 String res = (String) cartOps.get(skuId.toString()); if(StringUtils.isEmpty(res)){ //购物车中没有此商品 CartItem cartItem = new CartItem(); //异步编排 CompletableFuture<Void> getSkuInfoTask = CompletableFuture.runAsync(() -> { //商品不存在,则添加商品信息 //1.远程调用商品服务查询商品详情 R skuInfo = productFeignService.getSkuInfo(skuId); SkuInfoVo data = skuInfo.getData("skuInfo", new TypeReference<SkuInfoVo>() { }); //2.添加商品到购物车 cartItem.setCheck(true); cartItem.setCount(num); cartItem.setImage(data.getSkuDefaultImg()); cartItem.setTitle(data.getSkuTitle()); cartItem.setSkuId(skuId); cartItem.setPrice(data.getPrice()); }, executor); //3.远程查询sku的组合信息 //同时调用多个远程服务,为了不影响最终的查询速度,我们使用多线程的方式,用我们自定义的线程池提高效率 CompletableFuture<Void> getSkuSaleAttrValues = CompletableFuture.runAsync(() -> { List<String> values = productFeignService.getSkuSaleAttrValues(skuId); cartItem.setSkuAttr(values); }, executor); //等待getSkuSaleAttrValues 和 getSkuInfoTask 都完成,否则会报空指针异常 CompletableFuture.allOf(getSkuInfoTask,getSkuSaleAttrValues).get(); String s = JSON.toJSONString(cartItem); //加入购物车 cartOps.put(key,value); cartOps.put(skuId.toString(),s); return cartItem; } } }
-
⑦启动测试
- 以前编写的逻辑代码出现语法用法问题
⑧完善购物车细节
- 上面的代码中,添加购物车我们默认是购物车中没有此商品,但是如果购物车中有此商品呢?没有是新建,有的话就应该是修改数量了。这个细节我们需要进行修改。
-
我们目前的商城页面还存在一个问题。某个商品下加入购物车这个如果我们使用新标签页打开链接,重复刷新我们会发现,购物车该商品的数量会一直变化。这就导致了一个bug,如果有人拿到这个链接,或者用户自己操作这个链接,本来只想要1个的,操作之后变为2个,用户体验等大大下降。所以我们为了解决这个问题可以参考京东的做法,就是我们通过重定向携带数据方式。
当controller层需要重定向到指定页面时,如何携带数据?
-
传统使用session
-
使用RedirectAttributes. (利用session原理)【提供了addFlashAttribute 等方法.确保数据只能被使用一次后删除】
- 使用:
- 直接在Controller的参数中添加RedirectAttributes.
- addFlashAttribute会在重定向到下一个页面取出这个数据以后,将session里面的数据删除
- addFlashAttribute 方法会将数据存储在session中,访问一次后失效
- addAttribute 方法会将数据拼接在url后(get的形式)
- 使用:
-
RedirectAttributes的addFlashAttribut()方法:将对象存储在Session中且只能使用一次,再次刷新就没有了
RedirectAttributes的addAttribut()方法:将对象拼接在url中。
-
CartController
-
/** * 添加商品到购物车 * 防止用户恶意刷新,可以使用重定向的办法,本方法不跳转页面,只是执行完业务代码后,跳转到别的方法,让那个方法跳转页面 * redirectAttributes.addFlashAttribute() 将数据保存在session里面可以在页面取出,但是只能取一次 * redirectAttributes.addAttribute("skuId", skuId); 将数据放在URL后面 */ @GetMapping("/addToCart") public String addToCart(@RequestParam("skuId") Long skuId,@RequestParam("num") Integer num ,RedirectAttributes ra) throws ExecutionException, InterruptedException { cartService.addToCart(skuId,num); //将数据拼接在url后 ra.addAttribute("skuId",skuId); //重定向到某个页面,由这个页面跳转到成功页面 return "redirect:http://cart.gulimall.com/addToCartSuccess.html"; } /** * 跳转到成功页面 * 获取购物车中某个购物项 */ @GetMapping("/addToCartSuccess.html") public String addToCartSuccessPage(@RequestParam("skuId")Long skuId, Model model){ //重定向到成功页面,再次从Redis查询购物车数据即可 CartItem item = cartService.getCartItem(skuId); //放到请求域中 model.addAttribute("item",item); //跳转到成功页面 return "success"; }
-
-
对于上面的操作,我们让加入购物车之后不是跳转,而变为重定向,通过重定向进行页面再次跳转
-
CartService
-
//获取购物车中某个购物项 CartItem getCartItem(Long skuId);
-
-
CatrServiceImpl
-
/** * 获取购物车中的商品 * @param skuId * @return */ @Override public CartItem getCartItem(Long skuId) { BoundHashOperations<String, Object, Object> cartOps = getCartOps(); String item = (String) cartOps.get(skuId.toString()); CartItem cartItem = JSON.parseObject(item, CartItem.class); return cartItem; }
-
⑨前端页面完善
1.8.7 获取&合并购物车
- 购物车列表展示逻辑:首先判断是否登录,没有登录则展示临时购物车,若登录则展示合并后的购物车,将临时购物 车合并后并清空
1)、编写获取购物车的方法
-
CatrServiceImpl
-
/** * 购物车项展示 */ private List<CartItem> getCartItems(String cartKey){ BoundHashOperations<String, Object, Object> hashOps = redisTemplate.boundHashOps(cartKey); //获取所有商品数据 List<Object> values = hashOps.values(); if(values!=null && values.size() > 0){ List<CartItem> collect = values.stream().map((obj) -> { String str = (String) obj; CartItem cartItem = JSON.parseObject(str, CartItem.class); return cartItem; }).collect(Collectors.toList()); return collect; } return null; }
-
2)、编写删除临时用户购物车的方法
-
CartService
-
//清空购物车数据 void clearCart(String cartKey);
-
-
CatrServiceImpl(直接使用redis的原生delete操作即可)
-
@Override public void clearCart(String cartKey) { redisTemplate.delete(cartKey); }
-
3)、合并购物车,合并完之后要删除临时购物车
-
CartService
-
//获取整个购物车 Cart getCart() throws ExecutionException,InterruptedException;
-
-
CatrServiceImpl
-
/** * 获取整个购物车 * @return * @throws ExecutionException * @throws InterruptedException */ @Override public Cart getCart() throws ExecutionException, InterruptedException { Cart cart = new Cart(); UserInfoTo userInfoTo = CartInterceptor.threadLocal.get(); if(userInfoTo.getUserKey() != null){ //1.登录 String cartKey = CART_PREFIX + userInfoTo.getUserId(); //2.如果临时购物车的数据还没有,进行合并 合并购物车 String temCartKey = CART_PREFIX + userInfoTo.getUserKey(); List<CartItem> temCartItems = getCartItems(temCartKey); if(temCartItems != null){ //临时购物车有数据,需要进行合并 for (CartItem item : temCartItems) { addToCart(item.getSkuId(), item.getCount()); } //清楚临时购物车的数据 clearCart(temCartKey); } //3.获取登录后的购物车的数据(包含合并过来的临时购物车的数据和登录后的购物车的数据) List<CartItem> cartItems = getCartItems(cartKey); cart.setItems(cartItems); }else { //4.没登录 String cartKey = CART_PREFIX + userInfoTo.getUserKey(); //获取临时购物车的所有购物项 List<CartItem> cartItems = getCartItems(cartKey); cart.setItems(cartItems); } return cart; }
-
4)、完善前端页面
① 登录回显
<ul class="header-right">
<li>
<a th:if="${session.loginUser == null }" href="http://auth.gulimall.com/login.html"
class="li_2">你好,请登录</a>
<a th:if="${session.loginUser != null }" style="width: 100px">[[${session.loginUser.nickname}]]</a>
</li>
<li>
<a th:if="${session.loginUser == null }" href="http://auth.gulimall.com/reg.html">免费注册</a>
</li>
<li class="spacer"></li>
<li><a href="">我的订单</a></li>
<li class="spacer"></li>
</ul>
② 购物车是否有数据的逻辑判断
③ 购物车中商品遍历
④ 是否被选中、图片展示、标题展示、销售属性展示、格式化商品单价展示、商品数量展示、商品总价显示
<div class="One_ShopCon">
<h1 th:if="${cart.items == null}">
购物车还没有商品,<a href="http://gulimall.com">去购物</a>
</h1>
<ul th:if="${cart.items != null}">
<li th:each="item : ${cart.items}">
<div>
</div>
<div>
<ol>
<li><input type="checkbox" th:attr="skuId=${item.skuId}" class="itemCheck" th:checked="${item.check}"></li>
<li>
<dt><img th:src="${item.image}" alt=""></dt>
<dd style="width: 300px">
<p>
<span th:text="${item.title}">TCL 55A950C 55英寸32核</span>
<br/>
<span th:each="attr:${item.skuAttr}" th:text="${attr}">尺码: 55时 超薄曲面 人工智能</span>
</p>
</dd>
</li>
<li>
<p class="dj" th:text="'¥'+${#numbers.formatDecimal(item.price,3,2)}">¥4599.00</p>
</li>
<li>
<p th:attr="skuId=${item.skuId}">
<span class="countOpsBtn">-</span>
<span class="countOpsNum" th:text="${item.count}">5</span>
<span class="countOpsBtn">+</span>
</p>
</li>
<li style="font-weight:bold"><p class="zj">¥[[${#numbers.formatDecimal(item.totalPrice,3,2)}]]</p></li>
<li>
<p class="deleteItemBtn" th:attr="skuId=${item.skuId}">删除</p>
</li>
</ol>
</div>
</li>
</ul>
</div>
⑤购物车总价显示、优惠价格显示
<div>
<ol>
<li>总价:<span style="color:#e64346;font-weight:bold;font-size:16px;" class="fnt">¥[[${#numbers.formatDecimal(cart.totalAmount,3,2)}]]</span>
</li>
<li>优惠:[[${#numbers.formatDecimal(cart.reduce,1,2)}]]</li>
</ol>
</div>
⑥ 登录与未登录页面展示效果不同
登录之后不显示“你还没有登录。。。。。” ,没有登录则显示,并且可以跳转去登录页面。
⑦效果展示
1.8.8 选中购物项(单选框)
1)、 页面修改
-
首先为input框设置class方便后续绑定单击事件修改选中状态,自定义属性保存skuId
-
-
单击事件编写 ,选中单选框,类型设置为true,否则为fasle. prop会返回true或false
-
$(".itemCheck").click(function (){ var skuId = $(this).attr("skuId"); var check = $(this).prop("checked");//使用prop获取到的是 true、false;使用 attr获取到的就是checked:这里需要使用prop location.href="http://cart.gulimall.com/checkItem?skuId="+skuId+"&check="+(check?1:0);// check = 1:true;check = 0:false });
-
-
-
2)、编写请求
-
CartController
-
//勾选购物项 @GetMapping("/checkItem") public String checkItem(@RequestParam("skuId")Long skuId, @RequestParam("check")Integer check){ cartService.checkItem(skuId,check); return "redirect:http://cart.gulimall.com/cart.html"; }
-
-
CartService
-
void checkItem(Long skuId, Integer check);
-
-
CatrServiceImpl
-
@Override public void checkItem(Long skuId, Integer check) { BoundHashOperations<String, Object, Object> cartOps = getCartOps(); //获取单个商品 CartItem cartItem = getCartItem(skuId); cartItem.setCheck((check==1?true:false)); String s = JSON.toJSONString(cartItem); //序列化进Redis中 cartOps.put(skuId.toString(),s); }
-
1.8.9 改变购物项数量
1)、页面修改
- 为父标签自定义属性存储skuId,为加减操作设置相同的class,为数量设置class
-
<li> <p th:attr="skuId=${item.skuId}"> <span class="countOpsBtn">-</span> <span class="countOpsNum" th:text="${item.count}">5</span> <span class="countOpsBtn">+</span> </p> </li>
-
编写加减的单击事件
-
$(".countOpsBtn").click(function () { //1、skuId var skuId = $(this).parent().attr("skuId"); var num = $(this).parent().find(".countOpsNum").text(); location.href="http://cart.gulimall.com/countItem?skuId="+skuId+"&num="+num; });
-
2)、编写请求
-
Controller
-
//修改购物车数量 @GetMapping("/countItem") public String countItem(@RequestParam("skuId")Long skuId,@RequestParam("num") Integer num){ cartService.changeItemCount(skuId,num); return "redirect:http://cart.gulimall.com/cart.html"; }
-
-
CartService
-
void changeItemCount(Long skuId, Integer num);
-
-
CatrServiceImpl
-
@Override public void changeItemCount(Long skuId, Integer num) { CartItem cartItem = getCartItem(skuId); cartItem.setCount(num); //根据当前登录状态获取购物车 BoundHashOperations<String, Object, Object> cartOps = getCartOps(); //保存金redis中(需要序列化保存)skuId转为string作为key,商品序列化后的文本作为值 cartOps.put(skuId.toString(),JSON.toJSONString(cartItem)); }
-
1.8.10 删除购物项
1)、页面修改
为图中的删除按钮设置class,绑定单击事件临时保存skuId
本来应该是有一个x号退出删除操作的,这个我们先不做。
-
//全局变量 var deleteId = 0; //删除购物项 function deleteItem(){ location.href = "http://cart.gulimall.com/deleteItem?skuId="+deleteId; }
2)、编写请求
-
CartController
-
//删除购物项 @GetMapping("/deleteItem") public String deleteItem(@RequestParam("skuId") Long skuId){ cartService.deleteItem(skuId); //删除完了之后重新跳转到此页面,相当于刷新获取最新的内容 return "redirect:http://cart.gulimall.com/cart.html"; }
-
-
CartService
-
void deleteItem(Long skuId);
-
-
CatrServiceImpl
-
@Override public void deleteItem(Long skuId) { //根据当前状态获取购物车 BoundHashOperations<String, Object, Object> cartOps = getCartOps(); //利用skuid进行删除 cartOps.delete(skuId.toString()); }
-
1.9 订单服务
1.9.1 环境搭建
1)、动静分离
2)、修改hosts
3)、html页面完善
src="" href=""
这些都加入/static/order/
等各自对应的路径- 有些错误自己进行修改即可
4)、配置网关
- id: gulimall_order_route
uri: lb://gulimall-order
predicates:
- Host=order.gulimall.com
5)、注册到nacos
spring.cloud.nacos.discovery.server-addr=127.0.0.1:8848
spring.application.name=gulimall-order
主启动类上加入@EnableDiscoveryClient
服务注册发现功能注解
6)、导入thymeleaf并禁用缓存
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
spring.thymeleaf.cache=false
7)、编写Controller访问订单页面
@Controller
public class HelloController {
@GetMapping("/{page}.html")
public String listPage(@PathVariable("page") String page){
return page;
}
}
这个主要是为了测试查看我们的页面是否可以正常访问。
- 测试访问各个页面:
- 出现错误:confirm.html 报 Unfinished block structure
- 解决方案: 将/*删除即可
- 最后能够成功访问各个页面。
1.9.2 整合SpringSession
1)、redis相关配置
- Redis默认使用lettuce作为客户端可能导致内存泄漏,因此需要排除lettuce依赖,使用jedis作为客户端或者使用 高版本的Redis依赖即可解决内存泄漏问题。
<!--引入redis-->
<!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-data-redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<!--排除使用lettuce ,改为使用jedis-->
<exclusions>
<exclusion>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</exclusion>
</exclusions>
</dependency>
-
spring.redis.host=192.168.56.10 spring.redis.port=6379
2) 、配置session
-
导入session依赖
-
<!--spring整合session--> <dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session-data-redis</artifactId> </dependency>
-
-
配置session的存储类型
-
spring.session.store-type=redis
-
-
编写session的配置类(我们直接导入以前写过的就行)
GulimallSessionConfig
@Configuration
public class GulimallSessionConfig {
//子域共享问题解决
@Bean
public CookieSerializer cookieSerializer(){
DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
//将session作用域设置为不仅仅只限于子域名,而是整个父域名。这样所有子域名都可以获得一样的cookie
cookieSerializer.setDomainName("gulimall.com");
cookieSerializer.setCookieName("GULISESSION");
return cookieSerializer;
}
//使用json序列化方式来序列化对象数据到redis中
@Bean
public RedisSerializer<Object> springSessionDefaultRedisSerializer(){
return new GenericJackson2JsonRedisSerializer();
}
}
- 主启动类上加入@EnableRedisHttpSession
3)、线程池配置
MyThreadConfig
@Configuration
public class MyThreadConfig {
@Bean
public ThreadPoolExecutor threadPoolExecutor(ThreadPoolConfigProperties pool){
return new ThreadPoolExecutor(pool.getCoreSize(),
pool.getMaxSize(),pool.getKeepAliveTime(),
TimeUnit.SECONDS,new LinkedBlockingDeque<>(100000),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy());
}
}
-
线程池配置
-
gulimall.thread.core-size=20 gulimall.thread.max-size=100 gulimall.thread.keep-alive-time=10
-
4)、登录回显
我们在订单等这些新引入的页面中需要将我们的登录状态进行回显,所以我们接下来进行编写。
1.9.3 订单基本概念
1)、订单中心
2)、订单状态
3)、订单流程
4)、幂等性处理
参照幂等性文档。
1.9.4 订单登录拦截
1)、处理去结算请求
- 点击去结算 跳转到订单详情页面进行结算。
- 购物车页面(cartList.html)
修改连接。当我们点击去跳转的时候实际上发送的请求是http://order.gulimall.com/toTrade
所以我们接下来就要编写代码处理这个请求。
- 新建一个OrderWebController,专门来处理订单微服务中的各种请求处理
@Controller
public class OrderWebController {
@Autowired
OrderService orderService;
@GetMapping("/toTrade")
public String toTrade(){
return "confirm";
}
}
2)、登录检查
-
只有登录状态下的用户才可以去结算,因为结算页面涉及到地址等信息,所以我们必须要是登录状态下才行。未登录的用户我们需要编写一个拦截器来拦截,让其登录后来进行结算。
-
LoginUserInterceptor
@Component
public class LoginUserInterceptor implements HandlerInterceptor {
public static ThreadLocal<MemberRespVo> loginUser = new ThreadLocal<>();
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//获取登录用户
MemberRespVo attribute = (MemberRespVo) request.getSession().getAttribute(AuthServerConstant.LOGIN_USER);
if (attribute != null) {
//登录成功后,将用户信息存储至ThreadLocal中方便其他服务获取用户信息;
//加上ThreadLocal共享数据,是为了登录后把用户放到本地内存,而不是每次都去远程session里面查
loginUser.set(attribute);
return true;
} else {
//没登录就去登录
request.getSession().setAttribute("msg", "请先进行登录");
response.sendRedirect("http://auth.gulimall.com/login.html");
return false;
}
}
}
- 拦截器还需要进行一些配置才可以生效
@Configuration
public class OrderWebConfiguration implements WebMvcConfigurer {
@Autowired
LoginUserInterceptor interceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
//配置LoginUserInterceptor拦截器拦截所有请求
registry.addInterceptor(interceptor).addPathPatterns("/**");
}
}
- 未登录消息提醒 (回显)
1.9.5 订单确认页模型抽取编写
1)购物车价格小bug修改
- 购物车计算价格存在小bug,未选中的商品不应该加入总价的计算
- 修改 购物车服务vo 包下的 Cart
加入判断如果被勾选了,才去计算总价格。
2)、订单确认页的数据编写
-
用户地址信息来源于
ums_member_receive_address
这张表 -
订单确认页面商品的价格,我们要获取最新的价格,而不应该是加入购物车时的价格
- 优惠券信息 ,使用京豆的形式增加用户的积分
- 订单总额和应付总额信息
3)、编写vo
- MemberAddressVo
package com.atguigu.gulimall.order.vo;
/**
* @author hxld
* @create 2022-12-22 15:45
*/
@Data
public class MemberAddressVo {
/**
* id
*/
private Long id;
/**
* member_id
*/
private Long memberId;
/**
* 收货人姓名
*/
private String name;
/**
* 电话
*/
private String phone;
/**
* 邮政编码
*/
private String postCode;
/**
* 省份/直辖市
*/
private String province;
/**
* 城市
*/
private String city;
/**
* 区
*/
private String region;
/**
* 详细地址(街道)
*/
private String detailAddress;
/**
* 省市区代码
*/
private String areacode;
/**
* 是否默认
*/
private Integer defaultStatus;
}
- OrderItemVo
package com.atguigu.gulimall.order.vo;
/**
* @author hxld
* @create 2022-12-22 15:48
*/
@Data
public class OrderItemVo {
private Long skuId;
private Boolean check; //是否被选中
private String title; //标题
private String image; //图片
private List<String> skuAttr; //销售属性组合描述
private BigDecimal price; //商品单价
private Integer count; //商品数量
private BigDecimal totalPrice; //总价,总价需要计算
private BigDecimal weight; //重量
}
- OrderConfirmVo
//订单确认页需要用的数据
@Data
public class OrderConfirmVo {
// 收货地址,ums_member_receive_address 表
List<MemberAddressVo> address;
//所有选中的购物项
List<OrderItemVo> items;
//发票记录...
//优惠券信息...
Integer integration;
BigDecimal total;
//订单总额
BigDecimal payPrice;
//应付价格
}
4)、编写业务代码
- 完善OrderWebController
@Controller
public class OrderWebController {
@Autowired
OrderService orderService;
@GetMapping("/toTrade")
public String toTrade(Model model, HttpServletRequest request) throws ExecutionException, InterruptedException {
OrderConfirmVo confirmVo = orderService.confirmOrder();
model.addAttribute("orderConfirmData",confirmVo);
//展示订单确认的数据
return "confirm";
}
}
- OrderService
/**
* 订单确认页返回需要用的数据
* @return
*/
OrderConfirmVo confirmOrder();
service实现类的代码我们在后面会详细进行编写,因为涉及到多个远程调用。
1.9.6 订单确认页数据获取
1)、远程查询会员地址准备
会员服务
- MemberReceiveAddressController
/**
* 根据用户id查询用户地址信息
* @param memberId
* @return
*/
@GetMapping("/{memberId}/addresses")
public List<MemberReceiveAddressEntity> getAddress(@PathVariable("memberId") Long memberId){
return memberReceiveAddressService.getAddress(memberId);
}
- MemberReceiveAddressServiceImpl
/**
* 查出对应会员id的地址
* @param memberId
* @return
*/
@Override
public List<MemberReceiveAddressEntity> getAddress(Long memberId) {
return this.list(new QueryWrapper<MemberReceiveAddressEntity>().eq("member_id",memberId));
}
2)、远程调用查询会员地址
订单服务
- MemberFeignService
@FeignClient("gulimall-member")
public interface MemberFeignService {
@GetMapping("/member/memberreceiveaddress/{memberId}/addresses")
List<MemberAddressVo> getAddress(@PathVariable("memberId") Long memberId);
}
- 开启远程调用功能
主启动类上加入注解:@EnableFeignClients
3)、远程查询商品的最新价格准备
商品服务
- SkuInfoController
/**
* 获取商品的最新价格
* @param skuId
* @return
*/
@GetMapping("/{skuId}/price")
public R getPrice(@PathVariable("skuId")Long skuId){
SkuInfoEntity byId = skuInfoService.getById(skuId);
return R.ok().setData(byId.getPrice().toString());
}
4)、远程查询商品的最新价格
购物车服务
- ProductFeignService
@GetMapping("/product/skuinfo/{skuId}/price")
R getPrice(@PathVariable("skuId")Long skuId);
5)、远程查询实时购物车准备
结算页面要获取当前购物车。
购物车服务
- CartController
@GetMapping("/currentUserCartItems")
public List<CartItem> getCurrentUserCartItems(){
return cartService.getCurrentUserCartItems();
}
- CartServiceImpl
@Override
public List<CartItem> getCurrentUserCartItems() {
UserInfoTo userInfoTo = CartInterceptor.threadLocal.get();
if(userInfoTo.getUserId() == null){
return null;
}else {
String cartKey = CART_PREFIX + userInfoTo.getUserId();
List<CartItem> cartItems = getCartItems(cartKey);
//获取所有被选中的购物项
List<CartItem> collect = cartItems.stream().filter(item -> item.getCheck()).map(item -> {
R price = productFeignService.getPrice(item.getSkuId());
//todo 1 更新为最新价格
String data = (String) price.get("data");
item.setPrice(new BigDecimal(data));
return item;
}).collect(Collectors.toList());
return collect;
}
}
6)、远程查询实时购物车
订单服务
- CartFeignService
@FeignClient("gulimall-cart")
public interface CartFeignService {
@GetMapping("/currentUserCartItems")
List<OrderItemVo> getCurrentUserCartItems();
}
7)、动态计算价格
为了防止用户重复提交订单,我们还需要编写一个令牌。
- OrderConfirmVo
//订单确认页需要用的数据
// @Data
public class OrderConfirmVo {
//收货地址
@Setter @Getter
List<MemberAddressVo> address;
//所有选中的购物项
@Setter @Getter
List<OrderItemVo> items;
//发票记录。。。。
//优惠券信息....
@Setter @Getter
Integer integration;
//防止重复令牌(防止因为网络原因,用户多次点击提交订单,造成多次提交)
@Getter @Setter
String orderToken;
// BigDecimal total; //订单总额
public BigDecimal getTotal(){
BigDecimal sum = new BigDecimal("0");
if(items != null){
for (OrderItemVo item : items) {
BigDecimal multiply = item.getPrice().multiply(new BigDecimal(item.getCount().toString()));
sum = sum.add(multiply);
}
}
return sum;
}
// BigDecimal payPrice; //应付价格
public BigDecimal getPayPrice(){
return getTotal();
}
}
1.9.7 订单确认页请求完成
- OrderWebController
@GetMapping("/toTrade")
public String toTrade(Model model, HttpServletRequest request) throws ExecutionException, InterruptedException {
OrderConfirmVo confirmVo = orderService.confirmOrder();
model.addAttribute("orderConfirmData",confirmVo);
//展示订单确认的数据
return "confirm";
}
- OrderServiceImpl
@Override
public OrderConfirmVo confirmOrder() {
OrderConfirmVo confirmVo = new OrderConfirmVo();
MemberRespVo memberRespVo = LoginUserInterceptor.loginUser.get();
//1、远程查询所有的收货地址列表
List<MemberAddressVo> address = memberFeignService.getAddress(memberRespVo.getId());
confirmVo.setAddress(address);
//2、远程查询购物车所有选中的购物项
List<OrderItemVo> items = cartFeignService.getCurrentUserCartItems();
confirmVo.setItems(items);
//3、查询用户积分
Integer integration = memberRespVo.getIntegration();
confirmVo.setIntegration(integration);
//4、其他数据自动计算
//TODO 5、防重令牌(幂等性章节)
return confirmVo;
}
1.9.8 Feign远程调用丢失请求头问题
feign远程调用丢失请求头
- feign源码分析
- targetRequest() 构造出了一个request对象,而最终的response就是这个request请求的执行结果。
- GuliFeignConfig
@Configuration
public class GuliFeignConfig {
@Bean("requestInterceptor")
public RequestInterceptor requestInterceptor(){
return new RequestInterceptor() {
@Override
public void apply(RequestTemplate template) {
// 拦截器和toTrade接口同为一个线程,可以使用threadlocal将请求头信息进行线程共享。
// spring给我们提供了RequestContextHolder请求上下文保持器,底层也是对threadlocal进行封装
//1.RequestContextHolder拿到刚进来的这个请求
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest(); //老请求 远程调用前的准备工作
// 同步请求数据,新请求远程调用建立的request请求,将老请求中的请求头信息同步到新请求
String cookie = request.getHeader("Cookie");
//给新请求同步了老请求的cookie
template.header("Cookie",cookie);
}
};
}
}
1.9.9 Feign异步调用丢失上下文的问题
1)、注入线程池
2)、使用异步编排
- 各个任务彼此之间互不相关,但是需要等待各个任务处理完成
@Override
public OrderConfirmVo confirmOrder() {
OrderConfirmVo confirmVo = new OrderConfirmVo();
MemberRespVo memberRespVo = LoginUserInterceptor.loginUser.get();
CompletableFuture<Void> getAddressFuture = CompletableFuture.runAsync(() -> {
//1、远程查询所有的收货地址列表
List<MemberAddressVo> address =
memberFeignService.getAddress(memberRespVo.getId());
confirmVo.setAddress(address);
}, executor);
CompletableFuture<Void> cartFuture = CompletableFuture.runAsync(() -> {
//2、远程查询购物车所有选中的购物项
List<OrderItemVo> items = cartFeignService.getCurrentUserCartItems();
confirmVo.setItems(items);
//feign在远程调用之前要构造请求,调用很多的拦截器
//RequestInterceptor interceptor :requestInterceptors
}, executor);
//3、查询用户积分
Integer integration = memberRespVo.getIntegration();
confirmVo.setIntegration(integration);
//4、其他数据自动计算
//TODO 5、防重令牌(幂等性章节)
CompletableFuture.allOf(getAddressFuture,cartFuture).get(); //等待所有结果完成
return confirmVo;
}
3)、问题及解决方案
- 出现问题: 异步任务执行远程调用时会丢失请求上下文,oldRequest会为null
此外:远程获取价格的时候应该用R。
- 购物车服务
CartServiceImpl
- 商品服务
SkuInfoController
1.9.10 订单确认页渲染
1)、收货人信息回显
2)、商品项信息回显
- 添加两项新属性
3)、商品总件数、总金额、应付金额回显
-
OrderConfirmVo :总件数计算
//总件数 public Integer getCount(){ Integer i = 0; if(items != null){ for (OrderItemVo item : items) { i += item.getCount(); } } return i; }
1.9.11 订单确认页库存查询
1)、查询库存方法
-
库存服务中查询库存的方法我们在以前就已经编写好了
-
WareSkuController
/** * 查询sku是否有库存 */ @PostMapping("/hasstock") public R getSkuHasStock(@RequestBody List<Long> skuIds){ //sku_id,stock List<SkuHasStockVo> vos = wareSkuService.getSkuHasStock(skuIds); return R.ok().setData(vos); }
2)、远程查询商品的库存
-
订单服务下
-
创建一个WmsFeignService,专门用于远程调用库存微服务的接口
@FeignClient("gulimall-ware")
public interface WmsFeignService {
//查询sku 是否有库存
@PostMapping("/ware/waresku/hasstock")
R getSkuHasStock(@RequestBody List<Long> skuIds);
3)、编写vo
- SkuStockVo
package com.atguigu.gulimall.order.vo;
@Data
public class SkuStockVo {
private Long skuId;
private Boolean hasStock;
}
4)、编写异步任务查询库存信息
- 在OrderConfirmVo中新增一个map属性用于封装库存信息
public class OrderConfirmVo {
//库存
@Getter @Setter
Map<Long,Boolean> stocks;
}
- 完善 OrderServiceImpl
@Override
public OrderConfirmVo confirmOrder() throws ExecutionException, InterruptedException {
// 封装订单
OrderConfirmVo confirmVo = new OrderConfirmVo();
// 获取用户,用用户信息获取购物车
MemberRespVo memberRespVo = LoginUserInterceptor.loginUser.get();
// System.out.println("主线程..."+Thread.currentThread().getId());
/*** 我们要从request里获取用户数据,但是其他线程是没有这个信息的,
*所以可以手动设置新线程里也能共享当前的request数据
*/
//获取之前的请求
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
CompletableFuture<Void> getAddressFuture = CompletableFuture.runAsync(() -> {
//1、远程查询所有的收货地址列表
// System.out.println("member线程..."+Thread.currentThread().getId());
//每一个线程都来共享之前的请求数据
//因为异步线程需要新的线程,而新的线程里没有request数据,所以我们自己设置进去
RequestContextHolder.setRequestAttributes(requestAttributes);
List<MemberAddressVo> address =
memberFeignService.getAddress(memberRespVo.getId());
confirmVo.setAddress(address);
}
, executor);
CompletableFuture<Void> cartFuture = CompletableFuture.runAsync(() -> {
//2、远程查询购物车所有选中的购物项
System.out.println("cart线程..."+Thread.currentThread().getId());
//每一个线程都来共享之前的请求数据
RequestContextHolder.setRequestAttributes(requestAttributes);
List<OrderItemVo> items = cartFeignService.getCurrentUserCartItems();
confirmVo.setItems(items);
//feign在远程调用之前要构造请求,调用很多的拦截器
//RequestInterceptor interceptor :requestInterceptors
}
, executor).thenRunAsync(()->{
List<OrderItemVo> items = confirmVo.getItems();
List<long> collect = items.stream().map(item ->
item.getSkuId()).collect(Collectors.toList());
// TODO 一定要启动库存服务,否则库存查不出
R hasStock = wmsFeignService.getSkuHasStock(collect);
List<SkuStockVo> data = hasStock.getData(new TypeReference<List<SkuStockVo>>() {
}
);
if (data != null){
Map<long, Boolean> map =
data.stream().collect(Collectors.toMap(SkuStockVo::getSkuId, SkuStockVo::getHasStock));
confirmVo.setStocks(map);
}
}
,executor);
//3、查询用户积分
Integer integration = memberRespVo.getIntegration();
confirmVo.setIntegration(integration);
//4、其他数据自动计算
//TODO 5、防重令牌(幂等性章节)
CompletableFuture.allOf(getAddressFuture,cartFuture).get();
//等待所有结果完成
return confirmVo;
}
5)、库存信息回显
<span>[[${orderConfirmData.stocks[item.skuId]?"有货":"无货"}]]</span></p>
- 出现空指针异常:无需共享数据就不用做以下操作了
- 测试:
- 注意,想要显示有货,数据库 wms_ware_sku表中这两个字段必须有值才行。
1.9.12 订单确认页模拟运费效果
1)、远程查询收货地址
- 库存微服务下
- 新建一个MemberFeignService接口专门用做查询用户的收货地址信息
package com.atguigu.gulimall.ware.feign;
@FeignClient("gulimall-member")
public interface MemberFeignService {
@RequestMapping("/member/memberreceiveaddress/info/{id}")
R addrInfo(@PathVariable("id") Long id);
}
这个远程获取地址的方法在 会员服务 MemberReceiveAddressController下已经写过了,所以可以直接使用。
2)、获取运费
-
WareInfoController
package com.atguigu.gulimall.ware.controller; /** * 根据用户的收货地址计算运费 * @param addrId * @return */ @GetMapping("/fare") public R getFare(@RequestParam("addrId") Long addrId){ FareVo fare = wareInfoService.getFare(addrId); //这里的代码是下面经过改良之后的,因为目前为止farevo还没有被创建 return R.ok().setData(fare); }
-
WareInfoServiceImpl
@Override public BigDecimal getFare(long addrId) { R r = memberFeignService.addrInfo(addrId); MemberAddressVo data = r.getData("memberReceiveAddress",new TypeReference<MemberAddressVo>() { } ); if (data != null){ String phone = data.getPhone(); // 12345678918 8:截取手机号的最后一位当做运费,实际上应该是接入第三方物流作为接口。这里只是 String substring = phone.substring(phone.length() - 1, phone.length()); return new BigDecimal(substring); 简单模拟一下。 } } return null;
3)、页面优化
地址高亮显示
- 为div绑定class方便被函数方法调用,自定义def属性存储默认地址值,默认地址为1,否则为0;自定义属性存储地址Id
- 函数调用
运费回显
应付总额回显
为p标签绑定单击事件
默认收货地址运费查询回显
效果展示
1.9.13 订单确认页细节显示
- 查询运费时连同地址信息一起返回,也就是选中地址的地址信息回显
1)、编写FareVo
package com.atguigu.gulimall.order.vo;
@Data
public class FareVo {
private MemberAddressVo address;
private BigDecimal fare;
}
2)、改良实现代码
- WareInfoController
/**
* 根据用户的收货地址计算运费
* @param addrId
* @return
*/
@GetMapping("/fare")
public R getFare(@RequestParam("addrId") long addrId){
FareVo fare = wareInfoService.getFare(addrId);
return R.ok().setData(fare);
}
- WareInfoService
/*
* 根据用户的收货地址计算运费
* @param addrId
* @return
*/
FareVo getFare(Long addrId);
- WareInfoServiceImpl
@Override
public FareVo getFare(long addrId) {
FareVo fareVo = new FareVo();
R r = memberFeignService.addrInfo(addrId);
MemberAddressVo data = r.getData("memberReceiveAddress",new
TypeReference<MemberAddressVo>() {
}
);
if (data != null){
String phone = data.getPhone();
// 12345678918 8:截取手机号的最后一位当做运费,实际上应该是接入第三方物流作为接口。这里只是
简单模拟一下。
String substring = phone.substring(phone.length() - 1, phone.length());
BigDecimal bigDecimal = new BigDecimal(substring);
fareVo.setAddress(data);
fareVo.setFare(bigDecimal);
return fareVo;
}
return null;
}
3)、信息回显
效果展示
1.9.14 接口幂等性
- 幂等解决方案(本次商城服务采用以下两种方式)
- Token机制
- 对订单号设置唯一约束
1.9.15 订单确认页完成
- 使用TOKEN机制处理幂等性
1)、订单服务执行流程
2)、防重令牌的编写
① 注入StringRedisTemplate
- OrderServiceImpl
② 创建订单服务常量即防重令牌前缀,格式:order:token:userId
package com.atguigu.gulimall.order.constant;
public class OrderConstant {
//订单放入redis中的防重令牌
public static final String USER_ORDER_TOKEN_PREFIX = "order:token:";
}
③ 防重令牌存到redis中
④ 提交页面数据Vo的编写
-
仿照京东:京东的结算页中的商品信息是实时获取的,结算的时候会去购物车中再去获取一遍,因此,提交页 面的数据Vo没必要提交商品信息
-
OrderSubmitVo
package com.atguigu.gulimall.order.vo;
//封装订单提交的数据
@Data
public class OrderSubmitVo {
private Long addrId; //收货地址id
private Integer payType; //支付方式
//无需提交需要购买的商品,去购物车再获取一遍
//优惠 ,发票
private String orderToken; //防重令牌
private BigDecimal payPrice; //应付价格,验价
private String note; //订单备注
//用户相关信息,直接去session中取出登录的用户即可
}
⑤ 前端页面提交表单编写
- 我们使用隐藏的输入框将我们需要使用的数据带上
⑥ 为input框绑定数据
⑦ 编写提交订单数据接口
- OrderWebController
/**
* 下单功能
* @param vo
* @return
*/
@PostMapping("/submitOrder")
public String submitOrder(OrderSubmitVo vo){
//下单:去创建订单,验令牌,验价格,锁库存...
//下单成功来到支付选择页
//下单失败回到订单确认页重新确认订单信息
return null;
}
1.9.16 原子验证令牌
1)、提交订单返回结果Vo编写
- SubmitOrderResponsevo
package com.atguigu.gulimall.order.vo;
@Data
public class SubmitOrderResponsevo {
private OrderEntity order; //当前订单内容
private Integer code; // 0成功 错误状态码
}
2)、下单功能接口完善
- OrderWebController
/**
* 下单功能
* @param vo
* @return
*/
@PostMapping("/submitOrder")
public String submitOrder(OrderSubmitVo vo){
SubmitOrderResponseVo responseVo = orderService.submitOrder(vo);
if (responseVo.getCode() == 0){
//下单成功来到支付选择页
return "pay";
} else{
//下单失败回到订单确认页重新确认订单信息
return "redirect:http://order.gulimall.com/toTrade";
}
}
-
验证令牌的核心:保证令牌的比较和删除的原子性
-
解决方案:使用脚本
-
if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end
-
脚本执行的返回结果:
-
0:代表令牌校验失败
1:代表令牌成功删除即成功
-
-
-
OrderServiceImpl
@Override
public SubmitOrderResponseVo submitOrder(OrderSubmitVo vo) {
SubmitOrderResponseVo response = new SubmitOrderResponseVo();
MemberRespVo memberRespVo = LoginUserInterceptor.loginUser.get();
//得到当前用户
//1、验证令牌【令牌的对比和删除必须保证原子性】
//0 令牌失败 - 1删除成功
// lua脚本实现原子性
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return
redis.call('del', KEYS[1]) else return 0 end";
String orderToken = vo.getOrderToken();
//原子验证令牌和删除令牌
long result = redisTemplate.execute(new DefaultRedisScript<long>(script,long.class),Arrays.asList(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberRespVo.getId()), orderToken);
if (result == 0L){
//令牌验证失败
return response;
} else {
//令牌验证成功//下单:去创建订单,验令牌,验价格,锁库存...
}
return response;
}
-
execute(arg1,arg2,arg3)参数解释:
arg1:用DefaultRedisScript的构造器封装脚本和返回值类型
arg2:数组,用于存放Redis中token的key
arg3:用于比较的token即浏览器存储的token
1.9.17 构造订单数据
1) 、订单创建To的编写
- OrderCreateTo
package com.atguigu.gulimall.order.to;
@Data
public class OrderCreateTo {
private OrderEntity order; //当前订单内容
private List<OrderItemEntity> orderItems; //订单包含的所有订单项
private BigDecimal payPrice; //订单计算的应付价格
private BigDecimal fare; //运费
}
2) 、创建订单方法编写
① 订单状态枚举类的编写
- OrderStatusEnum
package com.atguigu.gulimall.order.enume;
public enum OrderStatusEnum {
CREATE_NEW(0,"待付款"),
PAYED(1,"已付款"),
SENDED(2,"已发货"),
RECIEVED(3,"已完成"),
CANCLED(4,"已取消"),
SERVICING(5,"售后中"),
SERVICED(6,"售后完成");
private Integer code;
private String msg;
OrderStatusEnum(Integer code, String msg) {
this.code = code;
this.msg = msg;
}
public Integer getCode() {
return code;
}
public String getMsg() {
return msg;
}
}
② 远程服务调用获取地址和运费信息
- WmsFeignService
//获取运费
@GetMapping("/ware/wareinfo/fare")
R getFare(@RequestParam("addrId")Long addrId);
③ 使用ThreadLocal,实现同一线程共享数据
④ 实现
- OrderServiceImpl(4个方法)
public OrderCreateTo createOrderTo(){
OrderCreateTo createTo = new OrderCreateTo();
//1、生成订单号
String orderSn = IdWorker.getTimeId();
//创建订单号
OrderEntity orderEntity = buildOrder(orderSn);
//2、获取到所有的订单项
List<OrderItemEntity> itemEntities = bulidOrderItems();
//3、验价
return createTo;
}
/**
* 构建订单
* @param orderSn
* @return
*/
private OrderEntity buildOrder(String orderSn) {
OrderEntity entity = new OrderEntity();
entity.setOrderSn(orderSn);
OrderSubmitVo submitVo = submitVoThreadLocal.get();
//获取收货地址信息
R fare = wmsFeignService.getFare(submitVo.getAddrId());
FareVo fareResp = fare.getData(new TypeReference<FareVo>() {});
//设置运费信息
entity.setFreightAmount(fareResp.getFare());
//设置收货人信息
entity.setReceiverCity(fareResp.getAddress().getCity());
entity.setReceiverDetailAddress(fareResp.getAddress().getDetailAddress());
entity.setReceiverName(fareResp.getAddress().getName());
entity.setReceiverPhone(fareResp.getAddress().getPhone());
entity.setReceiverPostCode(fareResp.getAddress().getPostCode());
entity.setReceiverProvince(fareResp.getAddress().getProvince());
entity.setReceiverRegion(fareResp.getAddress().getRegion());
return entity;
}
/**
* 构建所有订单项数据
* @return
*/
private List<OrderItemEntity> bulidOrderItems() {
List<OrderItemVo> currentUserCartItems = cartFeignService.getCurrentUserCartItems();
if (currentUserCartItems != null && currentUserCartItems.size() > 0){
currentUserCartItems.stream().map(cartItem ->{
OrderItemEntity itemEntity = bulidOrderItem(cartItem);
return itemEntity;
}
).collect(Collectors.toList());
}
return null;
}
/**
* 构建某一个订单项
* @param cartItem
* @return
*/
private OrderItemEntity bulidOrderItem(OrderItemVo cartItem) {
return null;
}
IDWorker中的getTimeId()生成时间id,不重复,用于充当订单号
1.9.18 构造订单项数据
1)、远程服务通过skuId获取spu信息准备
- 商品服务下 SpuInfoController
/**
* 通过skuId获取Spu信息
* @param skuId
* @return
*/
@GetMapping("/skuId/{id}")
public R getSpuInfoBySkuId(@PathVariable("id") long skuId) {
SpuInfoEntity entity = spuInfoService.getSpuInfoBySkuId(skuId);
return R.ok().setData(entity);
}
-
实现 :SpuInfoServiceImpl
@Override public SpuInfoEntity getSpuInfoBySkuId(long skuId) { SkuInfoEntity byId = skuInfoService.getById(skuId); long spuId = byId.getSpuId(); SpuInfoEntity spuInfoEntity = getById(spuId); return spuInfoEntity; }
2)、远程服务通过skuId获取spu信息
- 订单服务下ProductFeignService
@FeignClient("gulimall-product")
public interface ProductFeignService {
@GetMapping("/product/spuinfo/skuId/{id}")
R getSpuInfoBySkuId(@PathVariable("id") Long skuId);
}
3)、设置订单购物项数据
- SpuInfoVo
@Data
public class SpuInfoVo {
private long id;
/**
* 商品名称
*/
private String spuName;
/**
* 商品描述
*/
private String spuDescription;
/**
* 所属分类id
*/
private long catalogId;
/**
* 品牌id
*/
private long brandId;
private BigDecimal weight;
/**
* 上架状态[0 - 下架,1 - 上架]
*/
private Integer publishStatus;
private Date createTime;
private Date updateTime;
}
4)、实现方法完善
- OrderServiceImpl
@Autowired
ProductFeignService productFeignService;
public OrderCreateTo createOrderTo(){
OrderCreateTo createTo = new OrderCreateTo();
//1、生成订单号
String orderSn = IdWorker.getTimeId();
//创建订单号
OrderEntity orderEntity = buildOrder(orderSn);
//2、获取到所有的订单项
List<OrderItemEntity> itemEntities = bulidOrderItems(orderSn);
//3、验价
return createTo;
}
/**
* 构建订单
* @param orderSn
* @return
*/
private OrderEntity buildOrder(String orderSn) {
OrderEntity entity = new OrderEntity();
entity.setOrderSn(orderSn);
OrderSubmitVo submitVo = submitVoThreadLocal.get();
//获取收货地址信息
R fare = wmsFeignService.getFare(submitVo.getAddrId());
FareVo fareResp = fare.getData(new TypeReference<FareVo>() {
}
);
//设置运费信息
entity.setFreightAmount(fareResp.getFare());
//设置收货人信息
entity.setReceiverCity(fareResp.getAddress().getCity());
entity.setReceiverDetailAddress(fareResp.getAddress().getDetailAddress());
entity.setReceiverName(fareResp.getAddress().getName());
entity.setReceiverPhone(fareResp.getAddress().getPhone());
entity.setReceiverPostCode(fareResp.getAddress().getPostCode());
entity.setReceiverProvince(fareResp.getAddress().getProvince());
entity.setReceiverRegion(fareResp.getAddress().getRegion());
return entity;
}
/**
* 构建所有订单项数据
* @return
*/
private List<OrderItemEntity> bulidOrderItems(String orderSn) {
//最后确定每个购物项的价格
List<OrderItemVo> currentUserCartItems = cartFeignService.getCurrentUserCartItems();
if (currentUserCartItems != null && currentUserCartItems.size() > 0){
List<OrderItemEntity> itemEntities = currentUserCartItems.stream().map(cartItem -> {
OrderItemEntity itemEntity = bulidOrderItem(cartItem);
itemEntity.setOrderSn(orderSn);
return itemEntity;
}
).collect(Collectors.toList());
return itemEntities;
}
return null;
}
/**
* 构建某一个订单项
* @param cartItem
* @return
*/
private OrderItemEntity bulidOrderItem(OrderItemVo cartItem) {
OrderItemEntity itemEntity = new OrderItemEntity();
//1、订单信息:订单号
//2、商品的spu信息
long skuId = cartItem.getSkuId();
R r = productFeignService.getSpuInfoBySkuId(skuId);
SpuInfoVo data = r.getData(new TypeReference<SpuInfoVo>() {});
itemEntity.setSpuId(data.getId());
itemEntity.setSpuBrand(data.getBrandId().toString());
itemEntity.setSpuName(data.getSpuName());
itemEntity.setCategoryId(data.getCatalogId());
//3、商品的sku信息
itemEntity.setSkuId(cartItem.getSkuId());
itemEntity.setSkuName(cartItem.getTitle());
itemEntity.setSkuPic(cartItem.getImage());
itemEntity.setSkuPrice(cartItem.getPrice());
String skuAttr = StringUtils.collectionToDelimitedString(cartItem.getSkuAttr(), ":");
itemEntity.setSkuAttrsVals(skuAttr);
itemEntity.setSkuQuantity(cartItem.getCount());
//4、优惠信息(不做)
//5、积分信息
itemEntity.setGiftGrowth(cartItem.getPrice().intValue());
itemEntity.setGiftIntegration(cartItem.getPrice().intValue());
return itemEntity;
}
1.9.19 订单验价
1)、 计算单个购物项的真实价格
2)、设置订单的价格
- computePrice
private void computePrice(OrderEntity orderEntity, List<OrderItemEntity> itemEntities) {
BigDecimal total = new BigDecimal("0.0");
BigDecimal coupon = new BigDecimal("0.0");
BigDecimal integration = new BigDecimal("0.0");
BigDecimal promotion = new BigDecimal("0.0");
BigDecimal gift = new BigDecimal("0.0");
BigDecimal growth = new BigDecimal("0.0");
//订单的总额,叠加每一个订单项的总额信息
for (OrderItemEntity entity : itemEntities) {
coupon = coupon.add(entity.getCouponAmount());
integration = integration.add(entity.getIntegrationAmount());
promotion = promotion.add(entity.getPromotionAmount());
total = total.add(entity.getRealAmount());
gift = gift.add(new BigDecimal(entity.getGiftIntegration().toString()));
growth = growth.add(new BigDecimal(entity.getGiftGrowth().toString()));
}
//1、订单价格相关
orderEntity.setTotalAmount(total);
//应付总额
orderEntity.setPayAmount(total.add(orderEntity.getFreightAmount()));
orderEntity.setPromotionAmount(promotion);
orderEntity.setIntegrationAmount(integration);
orderEntity.setCouponAmount(coupon);
//设置积分等信息
orderEntity.setIntegration(gift.intValue());
orderEntity.setGrowth(growth.intValue());
orderEntity.setDeleteStatus(0);
//未删除
}
3)、订单其它信息设置
4)、验价
1.9.20 保存订单数据
1)、保存订单和订单项数据
- 保存订单和订单项以及锁库存操作处于事务当中,出现异常需要回滚
-
注入orderItemService
-
保存
- OrderServiceImpl
/**
* 保存订单数据
* @param order
*/
private void saveOrder(OrderCreateTo order){
OrderEntity orderEntity = order.getOrder();
orderEntity.setModifyTime(new Date());
this.save(orderEntity);
//保存订单项
}
1.9.21 锁定库存
1)、锁库存逻辑
2)、远程服务调用锁定库存
-
- 锁库存Vo编写
订单服务下 WareSkuLockVo :
@Data
public class WareSkuLockVo {
//根据订单号判断是否存库存成功
private String orderSn;
//需要锁住的所有库存信息:skuId skuName num
private List<OrderItemVo> locks;
}
-
将订单服务的WareSkuLockVo和OrderItemVo复制到库存服务中
-
- 锁库存响应Vo编写
- 库存服务下
/**商品的库存锁定状态 */ @Data public class LockStockResult { //那个商品 private long skuId; //锁了几件 private Integer num; //锁住了没有,状态 private Boolean locked; }
-
- 锁库存异常类的编写
public class NoStockException extends RuntimeException{ private long skuId; public NoStockException(long skuId){ super("商品id:" + skuId + ";没有足够的库存了"); } public long getSkuId() { return skuId; } public void setSkuId(long skuId) { this.skuId = skuId; } }
-
- 库存不足异常状态码编写
-
- 为库存表的锁库存字段设置默认值:0
-
- 查询库存接口编写
-
WareSkuController
-
/** * 锁定库存 * @param vo * @return */ @PostMapping("/lock/order") public R orderLockStock(@RequestBody WareSkuLockVo vo){ try { Boolean stock = wareSkuService.orderLockStock(vo); return R.ok(); } catch (NoStockException e){ return R.error(BizCodeEnume.NO_STOCK_EXCEPTION.getCode(),BizCodeEnume.NO_STOCK_EXCEPTION.getMsg()); } }
-
实现:
-
指定抛出此异常时一定要回滚,不指定也会回滚默认运行时异常都会回滚
- WareSkuServiceImpl
-
内部类保存商品在哪些仓库有库存以及锁库存数量
@Data
class SkuWareHasStock{
private long skuId;
private Integer num;
private List<long> wareId;
}
3)、锁库存实现
- OrderServiceImpl
/**
* 为某个订单锁定库存
*
*
* (rollbackFor = NoStockException.class)
* 默认只要是运行时异常就会回滚
* @param vo
* @return
*/
@Transactional
@Override
public Boolean orderLockStock(WareSkuLockVo vo) {
//1、按照下单的收货地址,找到一个就近仓库,锁定库存。//暂时不这样做
//1、找到每个商品在哪个仓库都有库存
List<OrderItemVo> locks = vo.getLocks();
List<SkuWareHasStock> collect = locks.stream().map(item -> {
SkuWareHasStock stock = new SkuWareHasStock();
long skuId = item.getSkuId();
stock.setSkuId(skuId);
stock.setNum(item.getCount());
//查询这个商品在哪里有库存
List<long> wareIds = wareSkuDao.listWareIdHasSkuStock(skuId);
stock.setWareId(wareIds);
return stock;
}
).collect(Collectors.toList());
//2、锁定库存
for (SkuWareHasStock hasStock : collect) {
Boolean skuStocked = false;
//标识位
long skuId = hasStock.getSkuId();
List<long> wareIds = hasStock.getWareId();
if (wareIds == null || wareIds.size() == 0){
//没有任何仓库有这个商品的库存
throw new NoStockException(skuId);
}
for (long wareId : wareIds) {
//成功就返回的是1行受影响,否则就是0行受影响
long count = wareSkuDao.lockSkuStock(skuId,wareId,hasStock.getNum());
if (count == 1){
skuStocked = true;
break;
} else {
//当前仓库锁定失败,重试下一个仓库
}
}
if (skuStocked == false){
//当前商品所有仓库都没有锁住
throw new NoStockException(skuId);
}
}
return true;
}
- WareSkuDao
@Mapper
public interface WareSkuDao extends BaseMapper<WareSkuEntity> {
void addStock(@Param("skuId") long skuId, @Param("wareId") long wareId, @Param("skuNum") Integer skuNum);
Long getSkuStock(long skuId);//一个参数的话,可以不用写@Param,多个参数一定要写,方便区分
List<long> listWareIdHasSkuStock(@Param("skuId") long skuId);
long lockSkuStock(@Param("skuId") long skuId, @Param("wareId") long wareId, @Param("num") Integer num);
}
4)、 远程服务调用
- 订单服务下
5)、接口完善
- OrderWebController
@PostMapping("/submitOrder")
public String submitOrder(OrderSubmitVo vo,Model model){
SubmitOrderResponseVo responseVo = orderService.submitOrder(vo);
if (responseVo.getCode() == 0){
//下单成功来到支付选择页
model.addAttribute("submitOrderResp",responseVo);
return "pay";
} else{
//下单失败回到订单确认页重新确认订单信息
return "redirect:http://order.gulimall.com/toTrade";
}
}
- OrderServiceImpl
@Transactional
@Override
public SubmitOrderResponseVo submitOrder(OrderSubmitVo vo) {
submitVoThreadLocal.set(vo);
//放到线程中共享数据
SubmitOrderResponseVo response = new SubmitOrderResponseVo();
MemberRespVo memberRespVo = LoginUserInterceptor.loginUser.get();
//得到当前用户
response.setCode(0);
//1、验证令牌【令牌的对比和删除必须保证原子性】
//0 令牌失败 - 1删除成功
// lua脚本实现原子性
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return
redis.call('del', KEYS[1]) else return 0 end";
String orderToken = vo.getOrderToken();
//原子验证令牌和删除令牌
long result = redisTemplate.execute(new DefaultRedisScript<long>(script,
long.class),
Arrays.asList(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberRespVo.getId()),
orderToken);
if (result == 0L) {
//令牌验证失败
response.setCode(1);
//设置为失败状态
return response;
} else {
//令牌验证成功
//下单:去创建订单,验令牌,验价格,锁库存...
//1、创建订单、订单项等信息
OrderCreateTo order = createOrderTo();
//2、验价
BigDecimal payAmount = order.getOrder().getPayAmount();
BigDecimal payPrice = vo.getPayPrice();
//页面提交价格与计算的价格相差小于0.01则验价成功
if (Math.abs(payAmount.subtract(payPrice).doubleValue()) < 0.01) {
//金额对比
//.....
//3、保存订单
saveOrder(order);
//4、库存锁定。只要有异常回滚订单数据。
WareSkuLockVo lockVo = new WareSkuLockVo();
lockVo.setOrderSn(order.getOrder().getOrderSn());
List<OrderItemVo> locks = order.getOrderItems().stream().map(item -> {
OrderItemVo itemVo = new OrderItemVo();
itemVo.setSkuId(item.getSkuId());
itemVo.setCount(item.getSkuQuantity());
itemVo.setTitle(item.getSkuName());
return itemVo;
}
).collect(Collectors.toList());
lockVo.setLocks(locks);
// TODO 远程锁库存
R r = wmsFeignService.orderLockStock(lockVo);
if (r.getCode() == 0){
//锁成功了
response.setOrder(order.getOrder());
return response;
} else{
//锁定失败
response.setCode(3);
return response;
}
} else {
response.setCode(2);
return response;
}
}
}
1.9.22 提交订单的问题
1)、订单号显示、应付金额回显
ps:之前代码忘记创建订单之后相应数据,这个方法应该补充以下两项。
- 出现问题:orderSn长度过长
- 解决方案:数据库的表中的对应字段长度增大
2)、提交订单消息回显
- confirm.html
3)、为了确保锁库存失败后,订单和订单项也能回滚,需要抛出异常
- 修改 NoStockException,将库存下的这个异常类直接放到公共服务下。并添加一个构造器。
- orderServiceImpl
1.9.23 创建库存上锁、解锁 业务交换机&队列
1)、流程梳理
2)、解锁库存实现
①库存服务导入RabbitMQ的依赖
<!-- RabbitMQ的依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependen
②库存服务 RabbitMQ的配置
spring.rabbitmq.host=192.168.56.10
spring.rabbitmq.virtual-host=/
③ 库存服务 配置RabbitMQ的序列化机制
@Configuration
public class MyRabbitConfig {
/**
* 使用JSON序列化机制,进行消息转换
* @return
*/
@Bean
public MessageConverter messageConverter(){
return new Jackson2JsonMessageConverter();
}
}
④ 库存服务 开启RabbitMQ
⑤ 按照下图创建交换机、队列、绑定关系
统一使用 topic交换机:因为交换机需要绑定多个队列,不同的路由键,且具有模糊匹配功能。
库存服务
package com.atguigu.gulimall.ware.config;
@Configuration
public class MyRabbitConfig {
/**
* 使用JSON序列化机制,进行消息转换
* @return
*/
@Bean
public MessageConverter messageConverter(){
return new Jackson2JsonMessageConverter();
}
/**
出现问题: 并没创建交换机、队列、绑定关系
出现问题的原因:只有当第一次连接上RabbitMQ时,发现没有这些东西才会创建
解决方案:监听队列
* @param message
*/
@RabbitListener(queues = "stock.release.stock.queue")
public void handle(Message message){
}
@Bean
public Exchange stockEventExchange(){
//String name, boolean durable, boolean autoDelete, Map<String, Object> arguments
return new TopicExchange("stock-event-exchange",true,false);
}
@Bean
public Queue stockReleaseStockQueue(){
//String name, boolean durable, boolean exclusive, boolean autoDelete, Map<String, Object> arguments
return new Queue("stock.release.stock.queue",true,false,false,null);
}
/**
* 延时队列
* @return
*/
@Bean
public Queue stockDelayQueue() {
Map<String, Object> arguments = new HashMap<>();
arguments.put("x-dead-letter-exchange","stock-event-exchange");
arguments.put("x-dead-letter-routing-key","stock.release");
arguments.put("x-message-ttl",120000);
return new Queue("stock.delay.queue",true,false,false,arguments);
}
@Bean
public Binding stockReleaseBinding(){
//String destination, DestinationType destinationType, String exchange, String routingKey,
// Map<String, Object> arguments
return new Binding("stock.release.stock.queue",
Binding.DestinationType.QUEUE,
"stock-event-exchange",
"stock.release.#",
null);
}
@Bean
public Binding stockLockedBinding(){
//String destination, DestinationType destinationType, String exchange, String routingKey,
// Map<String, Object> arguments
return new Binding("stock.delay.queue",
Binding.DestinationType.QUEUE,
"stock-event-exchange",
"stock.locked",
null);
}
}
3)、测试及问题解决
进行启动测试-------出现问题: 并没创建交换机、队列、绑定关系
出现问题的原因:只有当第一次连接上RabbitMQ时,发现没有这些东西才会创建
解决方案:监听队列
注意:交换机、队列、绑定关系创建成功后,将上述代码注释
1.9.24 监听库存解锁
库存解锁的两种场景:
①下单成功,订单过期没有支付被系统自动取消、被用户手动取消。都要解锁
②下单成功,库存锁定成功,接下来的业务调用失败,导致订单回滚。之前锁定的库存就要自动解锁
1)、数据库新增字段
①更改数据库表 ---- 库存服务下的wms_ware_order_task_detail
- 添加两个字段(仓库id和商品锁定状态),方便库存回滚
- 实体类中需要修改的:
WareOrderTaskDetailEntity
加上 全参和无参构造器方便消息传播该实体类数据。
WareOrderTaskDetailDao.xml
中结果集映射修改
② 保存工作单详情方便回溯
- WareSkuServiceImpl中的
orderLockStock
方法中增加下面的代码
③ Common服务中创建To,方便MQ发送消息
package com.atguigu.common.to.mq;
@Data
public class StockLockedTo {
private Long id;//库存工作单的id
private Long detailId;//库存工作单详情id //接下来需要修改,这里只是暂时的
}
如果To仅仅保存这个两个数据的话,会存在一些问题,
当1号订单在1号仓库扣减1件商品成功,2号订单在2号仓库扣减2件商品成功,3号订单在3号仓库扣减3件商品失败时,库存工作单的数据将会回滚,
此时,数据库中将查不到1号和2号订单的库存工作单的数据,但是库存扣减是成功的,导致无法解锁库存
解决方案: 保存库存工作详情To
package com.atguigu.common.to.mq;
@Data
public class StockDetailTo {
private Long id;
/**
* sku_id
*/
private Long skuId;
/**
* sku_name
*/
private String skuName;
/**
* 购买个数
*/
private Integer skuNum;
/**
* 工作单id
*/
private Long taskId;
/**
* 仓库id
*/
private Long wareId;
/**
* 库存锁定状态
*/
private Integer lockStatus;
}
- 进行修改
④ WareSkuServiceImpl 中的orderLockStock ----- 向MQ发送库存锁定成功的消息
库存回滚解锁
1)库存锁定
在库存锁定时添加以下逻辑
- 由于可能订单回滚的情况,所以为了能够得到库存锁定的信息,在锁定时需要记录库存工作单,其中包括订单信息和锁定库存时的信息(仓库id,商品id,锁了几件…)
- 在锁定成功后,向延迟队列发消息,带上库存锁定的相关信息
逻辑:
遍历订单项,遍历每个订单项的每个库存,直到锁到库存
发消息后库存回滚也没关系,用id是查不到数据库的
锁库存的sql
这里编写了发送消息队列的逻辑,下面写接收消息队列后还原库存的逻辑。
1.9.25 库存解锁逻辑&库存自动解锁完成&测试库存自动解锁
- 解锁场景:
1.下单成功,库存锁定成功,接下来的业务如果调用失败导致订单回滚。之前锁定的库存就要自动解锁。
2.锁库存失败无需解锁
解决方案:通过查询订单的锁库存信息,如果有则仅仅说明库存锁定成功,还需判断是否有订单信息,如果有订单信息则判断订单状态,若订单状态已取消则解锁库存,反之:不能解锁库存,如果没有订单信息则需要解锁库存,如果没有锁库存信息则无需任何操作。
1.编写Vo,通过拷贝订单实体----> OrderEntity,用于接收订单信息
- 库存服务中
package com.atguigu.gulimall.ware.vo;
2. 远程服务编写,获取订单状态功能
订单服务下编写:OrderController
@GetMapping("/status/{orderSn}")
public R getOrderStatus(@PathVariable("orderSn") String orderSn){
OrderEntity orderEntity = orderService.getOrderByOrderSn(orderSn);
return R.ok().setData(orderEntity);
}
实现:
- OrderServiceImpl
@Override
public OrderEntity getOrderByOrderSn(String orderSn) {
OrderEntity order_sn = this.getOne(new QueryWrapper<OrderEntity>().eq("order_sn", orderSn));
return order_sn;
}
库存服务下编写:调用远程服务 OrderFeignService
@FeignClient("gulimall-order")
public interface OrderFeignService {
@GetMapping("/order/order/status/{orderSn}")
R getOrderStatus(@PathVariable("orderSn") String orderSn);
}
3.监听事件
接收消息
- 延迟队列会将过期的消息路由至
"stock.release.stock.queue"
,通过监听该队列实现库存的解锁 - 为保证消息的可靠到达,我们使用手动确认消息的模式,在解锁成功后确认消息,若出现异常则重新归队
库存服务 编写 StockReleaseListener
当@RabbitListener标注在类上,需要搭配@RabbitHandler一起使用
@Service
@RabbitListener(queues = "stock.release.stock.queue")
public class StockReleaseListener {
@Autowired
WareSkuService wareSkuService;
@RabbitHandler
public void handleStockLockedRelease(StockLockedTo to, Message message, Channel channel) throws IOException {
System.out.println("收到解锁库存的消息...");
try {
wareSkuService.unlockStock(to);
channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
}catch (Exception e){
channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
}
}
}
WareSkuServiceImpl
库存解锁
-
如果工作单详情不为空,说明该库存锁定成功
- 查询最新的订单状态,
- 如果订单不存在,说明订单提交出现异常回滚,
- 如果订单存在(但订单处于已取消的状态),我们要对已锁定的库存进行解锁
-
如果工作单详情为空,说明库存未锁定,自然无需解锁
-
为保证幂等性,我们分别对订单的状态和工作单的状态都进行了判断,只有当订单过期且工作单显示当前库存处于锁定的状态时,才进行库存的解锁
@Autowired
WareSkuDao wareSkuDao;
@Autowired
WareOrderTaskService orderTaskService;
@Autowired
WareOrderTaskDetailService orderTaskDetailService;
@Autowired
RabbitTemplate rabbitTemplate;
@Autowired
OrderFeignService orderFeignService;
/**
* 1、库存自动解锁。
* 下订单成功,库存锁定成功,接下来的业务如果调用失败,导致订单回滚。之前锁定的库存就要自动解锁。
* 2、订单失败。
* 锁库存失败。
* <p>
* 只要解锁库存的消息失败。一定要告诉服务器解锁失败。
*
* @param to
*/
@Override
public void unlockStock(StockLockedTo to) {
StockDetailTo detail = to.getDetail();
Long detailId = detail.getId();
//解锁
//1、查询数据库关于这个订单的锁定库存信息。
//有:证明库存锁定成功了。
// 解锁:查询订单情况。
// 1、没有这个订单。必须解锁
// 2、有这个订单。不是解锁库存。
// 订单状态:已取消:解锁库存
// 没取消:不能解锁
//没有:库存锁定失败了,库存回滚了。这种情况无需解锁
WareOrderTaskDetailEntity byId = orderTaskDetailService.getById(detailId);
if (byId != null) {
//解锁
Long id = to.getId();
WareOrderTaskEntity taskEntity = orderTaskService.getById(id);
String orderSn = taskEntity.getOrderSn();//根据订单号查询订单的状态
R r = orderFeignService.getOrderStatus(orderSn);
if (r.getCode() == 0) {
//订单数据返回成功
OrderVo data = r.getData(new TypeReference<OrderVo>() {
});
if (data == null || data.getStatus() == 4) {
//订单不存在
//订单已经被取消了。才能解锁库存
// detailId
if (byId.getLockStatus() == 1){
//当前库存工作单详情,状态 是1 :已锁定但是未解锁才可以解锁
unLockStock(detail.getSkuId(), detail.getWareId(), detail.getSkuNum(), detailId);
}
}
} else {
//消息拒绝以后重新放到队里里面,让别人继续消费解锁。
throw new RuntimeException("远程服务失败");
}
} else {
//无需解锁
}
}
/**
* 解锁方法
*/
private void unLockStock(Long skuId, Long wareId, Integer num, Long taskDetailId) {
//库存解锁
wareSkuDao.unlockStock(skuId, wareId, num);
//更新库存工作单的状态
WareOrderTaskDetailEntity entity = new WareOrderTaskDetailEntity();
entity.setId(taskDetailId);
entity.setLockStatus(2);//变为已解锁
orderTaskDetailService.updateById(entity);
}
WareSkuDao.xml
SQL编写:
UPDATE `wms_ware_sku` SET stock_locked = stock_locked -1
WHERE sku_id = 1 AND ware_id = 2
<update id="unlockStock">
UPDATE `wms_ware_sku` SET stock_locked = stock_locked -#{num}
WHERE sku_id = #{skuId} AND ware_id = #{wareId}
</update>
4. 远程服务调用可能会出现失败,需要设置手动ACK,确保其它服务能消费此消息
库存服务下
#手动ACK设置
spring.rabbitmq.listener.simple.acknowledge-mode=manual
5.出现问题: 远程调用订单服务时被拦截器拦截
解决方案:请求路径适配放行
订单服务下的 拦截器。
1.9.26 定时关单完成
1.定时关单代码编写
①订单创建成功,给MQ发送关单消息
订单服务下的 OrderServiceImpl 的 submitOrder提交订单方法
② 监听事件,进行关单
订单服务下
package com.atguigu.gulimall.order.listener;
@Service
@RabbitListener(queues = "order.release.order.queue")
public class OrderCloseListener {
@Autowired
OrderService orderService;
/**
* 定时关单
* @param entity
* @param channel
* @param message
* @throws IOException
*/
@RabbitHandler
public void listener(OrderEntity entity, Channel channel, Message message) throws IOException {
System.out.println("收到过期的订单消息:准备关闭订单" + entity.getOrderSn());
try {
orderService.closeOrder(entity);
channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
} catch (Exception e) {
//失败了重新放到队列中,让其他消费者能够消费
channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
}
}
}
OrderServiceImpl
/**
* 关单操作
* @param entity
*/
@Override
public void closeOrder(OrderEntity entity) {
//查询当前这个订单的最新状态
OrderEntity orderEntity = this.getById(entity.getId());
if (orderEntity.getStatus() == OrderStatusEnum.CREATE_NEW.getCode()){
//关单
OrderEntity update = new OrderEntity();
update.setId(entity.getId());
update.setStatus(OrderStatusEnum.CANCLED.getCode());
this.updateById(update);
}
}
订单释放和库存解锁逻辑: 当订单创建成功,1分钟之后,向MQ发送关单消息,2分钟后,向MQ发送解锁库存消息,关单操作完成之后,过了1分钟解锁库存操作。
存在问题:由于机器卡顿、消息延迟等导致关单消息延迟发送,解锁库存消息正常发送和监听,导致解锁库存消息被消费,当执行完关单操作后便无法再执行解锁库存操作,导致卡顿的订单永远无法解锁库存。
解决方案:采取主动补偿的策略。当关单操作正常完成之后,主动去发送解锁库存消息给MQ,监听解锁库存消息进行解锁。
③ 按上图创建绑定关系
订单服务MyMQConfig
④ common服务中,创建OrderTo(拷贝order实体)
⑤ 向MQ发送解锁库存消息
- OrderServiceImpl
package com.atguigu.gulimall.order.service.impl;
/**
* 关单操作
* @param entity
*/
@Override
public void closeOrder(OrderEntity entity) {
//查询当前这个订单的最新状态
OrderEntity orderEntity = this.getById(entity.getId());
if (orderEntity.getStatus() == OrderStatusEnum.CREATE_NEW.getCode()){
//未付款状态,进行关单
OrderEntity update = new OrderEntity();
update.setId(entity.getId());
update.setStatus(OrderStatusEnum.CANCLED.getCode());
this.updateById(update);
OrderTo orderTo = new OrderTo();
BeanUtils.copyProperties(orderEntity,orderTo);
//发给MQ一个消息:解锁库存
rabbitTemplate.convertAndSend("order-event-exchange","order.release.other",orderTo);
}
}
⑥ 解锁库存操作
库存服务下
@RabbitHandler
public void handleOrderCloseRelease(OrderTo orderTo, Message message, Channel channel) throws IOException {
System.out.println("订单关闭准备解锁库存...");
try {
wareSkuService.unlockStock(orderTo);
channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
}catch (Exception e){
channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
}
}
//防止订单服务卡顿,导致订单状态消息一直改不了,库存消息优先到期。查订单状态新建状态,什么都不做就走了。
//导致卡顿的订单,永远不能解锁库存。
@Transactional
@Override
public void unlockStock(OrderTo orderTo) {
String orderSn = orderTo.getOrderSn();
//查一下最新库存的状态,防止重复解锁库存
WareOrderTaskEntity task = orderTaskService.getOrderTaskByOrderSn(orderSn);
Long id = task.getOrderId();
//按照工作单找到所有 没有解锁的库存,进行解锁
List<WareOrderTaskDetailEntity> entities = orderTaskDetailService.list(
new QueryWrapper<WareOrderTaskDetailEntity>()
.eq("task_id", id)
.eq("lock_status", 1));
// Long skuId, Long wareId, Integer num, Long taskDetailId
for (WareOrderTaskDetailEntity entity : entities) {
unLockStock(entity.getSkuId(),entity.getWareId(),entity.getSkuNum(),entity.getId());
}
}
WareOrderTaskServiceImpl
@Override
public WareOrderTaskEntity getOrderTaskByOrderSn(String orderSn) {
WareOrderTaskEntity one = this.getOne(new QueryWrapper<WareOrderTaskEntity>().eq("order_sn", orderSn));
return one;
}
1.9.27 消息丢失、积压、重复等解决方案
- 如何保证消息可靠性-消息丢失
1、消息丢失
消息发送出去,由于网络问题没有抵达服务器
做好容错方法(try-catch),发送消息可能会网络失败,失败后要有重试机制,可记录到数据库,采用定期扫描重发的方式
做好日志记录,每个消息状态是否都被服务器收到都应该记录
做好定期重发,如果消息没有发送成功,定期去数据库扫描未成功的消息进行重发
消息抵达Broker,Broker要将消息写入磁盘(持久化)才算成功。此时Broker尚未持久化完成,宕机。
publisher也必须加入确认回调机制,确认成功的消息,修改数据库消息状态。
自动ACK的状态下。消费者收到消息,但没来得及消息然后宕机
一定开启手动ACK,消费成功才移除,失败或者没来得及处理就noAck并重新入队
情况一: 消息发送出去但是由于网络原因未到达服务器,解决方案:采用try-catch将发送失败的消息持久化到数据库中,采用定期扫描重发的方式。
drop table if exists mq_message;
CREATE TABLE `mq_message` (
`message_id` CHAR(32) NOT NULL,
`content` TEXT,#json
`to_exchange` VARCHAR(255) DEFAULT NULL,
`routing_key` VARCHAR(255) DEFAULT NULL,
`class_type` VARCHAR(255) DEFAULT NULL,
`message_status` INT(1) DEFAULT '0' COMMENT '0-新建 1-已发送 2-错误抵达 3-已抵达',
`create_time` DATETIME DEFAULT NULL,
`update_time` DATETIME DEFAULT NULL,
PRIMARY KEY (`message_id`)
) ENGINE=INNODB DEFAULT CHARSET=utf8mb4
情况二:消息抵达服务器的队列中才算完成消息的持久化,解决方案----->publish + consumer的两端的ack机制
情况三: 防止自动ack带来的缺陷,采用手动ack,解决方案上面都有这里不再细说
- 如何保证消息可靠性-消息重复
2、消息重复
- 消息消费成功,事务已经提交,ack时,机器宕机。导致没有ack成功,Broker的消息重新由unack变为ready,并发送给其他消费者
- 消息消费失败,由于重试机制,自动又将消息发送出去
- 成功消费,ack时宕机,消息由unack变为ready,Broker又重新发送
- 消费者的业务消费接口应该设计为幂等性的。比如扣库存有工作单的状态标志
- 使用防重表(redis/mysql),发送消息每一个都有业务的唯一标识,处理过就不用处理
- rabbitMQ的每一个消息都有redelivered字段,可以获取是否是被重新投递过来的,而不是第一次投递过来的
消息被成功消费,ack时宕机,消息由unack变成ready,Broker又重新发送。解决方案:将消费者的业务消费接口应该设计为幂等性的,比如扣库存有工作单的状态标志。
- 如何保证消息可靠性-消息积压
3、消息积压
- 消费者宕机积压
- 消费者消费能力不足积压
- 发送者发送流量太大
- 上线更多的消费者,进行正常消费
- 上线专门的队列消费服务,将消息先批量取出来,记录数据库,离线慢慢处理
消息积压即消费者的消费能力不够, 上线更多的消费者进行正常的消费。
1.9.28 支付服务
1)、支付宝沙箱代码
① 文档和资料
支付宝开放平台传送门:支付宝开放平台
网站支付DEMO传送门:手机网站支付 DEMO | 网页&移动应用
沙箱应用传送门:沙箱应用
密钥工具下载传送门:密钥工具
沙箱环境文档:沙箱环境文档
- 下载手机网站支付DEMO,使用eclipse导入
2)、RSA、加密加签、密钥等相关概念
对称加密:发送方和接收方用的是同一把密钥,存在问题:当某一方将密钥泄漏之后,发送的消息可以被截取获悉并且随意进行通信。
非对称加密:发送方和接收方使用的不是同一把密钥,发送方使用密钥A对明文进行加密,接收方使用密钥B对密文进行解密,然后接收方将回复的明文用密钥C进行加密,发送方使用密钥D进行解密。采用非对称加密的好处是:即使有密钥被泄漏也不能自由的通信。
密钥的公私性是相对于生成者而言的。发送方通过密钥A对明文进行加密,密钥A是只有发送方自己知道的,接收方想要解密密文,就需要拿到发送方公布出来的密钥B。
公钥:生成者发布的密钥可供大家使用的
私钥:生成者自己持有的密钥
签名:为了防止中途传输的数据被篡改和使用的方便,发送方采用私钥生成明文对应的签名,此过程被成为加签。接收方使用公钥去核验明文和签名是否对应,此过程被成为验签。
配置支付宝的沙箱环境:
沙箱环境配置查看传送门:登录 - 支付宝
接口加签方式共有两种:
①采用系统默认生成的支付宝的公钥、应用的私钥和公钥:
② 采用自定义密钥
传送门:密钥工具下载
将支付宝密钥工具生成的应用公钥复制进加签内容配置中,会自动生成支付宝的公钥
- 沙箱账号:用于测试环境中的商品支付
- 使用eclipse测试:
注意如果项目报红:因为老师给的 沙箱测试Demo 默认使用的 是 tomact7.0 ,所以这里需要将 tomact7.0移除掉,使用我们自己本机安装的 tomcat。
选择 tomcat 7.0移除 ,然后导入自己的 就行。这里我已经移除好了。
- 老师课件:
一、支付宝支付
1、进入“蚂蚁金服开放平台”
https://open.alipay.com/platform/home.htm
2、下载支付宝官方demo,进行配置和测试
文档地址
https://open.alipay.com/platform/home.htm 支付宝&蚂蚁金服开发者平台
https://docs.open.alipay.com/catalog 开发者文档
https://docs.open.alipay.com/270/106291/ 全部文档=>电脑网站支付文档;下载demo
3、配置使用沙箱进行测试
1、使用RSA 工具生成签名
2、下载沙箱版钱包
3、运行官方demo 进行测试
4、什么是公钥、私钥、加密、签名和验签?
1、公钥私钥
公钥和私钥是一个相对概念
它们的公私性是相对于生成者来说的。
一对密钥生成后,保存在生成者手里的就是私钥,
生成者发布出去大家用的就是公钥
2、加密和数字签名
-
加密是指:
- 我们使用一对公私钥中的一个密钥来对数据进行加密,而使用另一个密钥来进行解密的技术。
- 公钥和私钥都可以用来加密,也都可以用来解密。
- 但这个加解密必须是一对密钥之间的互相加解密,否则不能成功。
- 加密的目的是:
- 为了确保数据传输过程中的不可读性,就是不想让别人看到。
-
签名:
- 给我们将要发送的数据,做上一个唯一签名(类似于指纹)
- 用来互相验证接收方和发送方的身份;
- 在验证身份的基础上再验证一下传递的数据是否被篡改过。因此使用数字签名可以
用来达到数据的明文传输。
-
验签
- 支付宝为了验证请求的数据是否商户本人发的,
- 商户为了验证响应的数据是否支付宝发的
3)、 内网穿透
如果别人直接访问我们自己电脑的本地项目,是不能访问的。
内网穿透的原理: 内网穿透服务商是正常外网可以访问的ip地址,我们的电脑通过下载服务商软件客户端并与服务器建立起长连接,然后服务商就会给我们的电脑分配一个域名,别人的电脑访问hello.hello.com会先找到hello.com即一级域名,然后由服务商将请求转发给我们电脑的二级域名;从而实现了别人可以通过IP地址访问我们本地的项目。
下面是老师课件:
二、内网穿透
1、简介
内网穿透功能可以允许我们使用外网的网址来访问主机;
正常的外网需要访问我们项目的流程是:
- 1、买服务器并且有公网固定IP
- 2、买域名映射到服务器的IP
- 3、域名需要进行备案和审核
2、使用场景
- 1、开发测试(微信、支付宝)
- 2、智慧互联
- 3、远程控制
- 4、私有云
3、内网穿透的几个常用软件
1、natapp:https://natapp.cn/ 优惠码:022B93FD(9 折)[仅限第一次使用]
2、续断:www.zhexi.tech 优惠码:SBQMEA(95 折)[仅限第一次使用]
3、花生壳:https://www.oray.com/
老师课件中使用的 是 续断进行测试的,这里我们使用免费的内网穿透工具进行测试。
内网穿免费工具下载地址:cpolar - 安全的内网穿透工具
使用教程: Win系统如何下载安装使用cpolar内网穿透工具?_Cpolar Lisa的博客-CSDN博客
上面两个 教程都可以参考,个人推荐 第二个教程。
已经成功建立连接。
【需要注意的是,对于免费版本的cpolar随机URL地址是会在24小时之后变化的,如果需要进一步使用,可以将站点配置成二级子域名,或自定义域名(使用自己的域名)长期使用。】
- 配置修改:
修改url前缀:
- 测试:访问成功。
4)、 整合支付
注意,需要保证所有项目的编码格式都是 utf-8
1.导入支付宝支付SDK的依赖
订单服务
<!-- https://mvnrepository.com/artifact/com.alipay.sdk/alipay-sdk-java -->
<!--导入支付宝的SDK-->
<dependency>
<groupId>com.alipay.sdk</groupId>
<artifactId>alipay-sdk-java</artifactId>
<version>4.35.7.ALL</version>
</dependency>
2. 编写AlipayTemplate工具类和PayVo
直接复制老师给的课件。
更改一些,这里我使用的是 绑定配置文件的方式来声明一些变量的:因为老师使用了一个@ConfigurationProperties(prefix = "alipay")。
- AlipayTemplate
@ConfigurationProperties(prefix = "alipay")
@Component
@Data
public class AlipayTemplate {
//在支付宝创建的应用的id
@Value("${alipay.app_id}")//这里使用的是绑定配置文件的方式
private String app_id;
// 商户私钥,您的PKCS8格式RSA2私钥
@Value("${alipay.merchant_private_key}")
private String merchant_private_key;
// 支付宝公钥,查看地址:https://openhome.alipay.com/platform/keyManage.htm 对应APPID下的支付宝公钥。
@Value("${alipay.alipay_public_key}")
private String alipay_public_key;
// 服务器[异步通知]页面路径 需http://格式的完整路径,不能加?id=123这类自定义参数,必须外网可以正常访问
// 支付宝会悄悄的给我们发送一个请求,告诉我们支付成功的信息
@Value("${alipay.notify_url}")
private String notify_url;
// 页面跳转同步通知页面路径 需http://格式的完整路径,不能加?id=123这类自定义参数,必须外网可以正常访问
//同步通知,支付成功,一般跳转到成功页
@Value("${alipay.return_url}")
private String return_url;
// 签名方式
private String sign_type = "RSA2";
// 字符编码格式
private String charset = "utf-8";
// 支付宝网关; https://openapi.alipaydev.com/gateway.do
@Value("${alipay.gatewayUrl}")
private String gatewayUrl;
public String pay(PayVo vo) throws AlipayApiException {
//AlipayClient alipayClient = new DefaultAlipayClient(AlipayTemplate.gatewayUrl, AlipayTemplate.app_id, AlipayTemplate.merchant_private_key, "json", AlipayTemplate.charset, AlipayTemplate.alipay_public_key, AlipayTemplate.sign_type);
//1、根据支付宝的配置生成一个支付客户端
AlipayClient alipayClient = new DefaultAlipayClient(gatewayUrl,
app_id, merchant_private_key, "json",
charset, alipay_public_key, sign_type);
//2、创建一个支付请求 //设置请求参数
AlipayTradePagePayRequest alipayRequest = new AlipayTradePagePayRequest();
alipayRequest.setReturnUrl(return_url);
alipayRequest.setNotifyUrl(notify_url);
//商户订单号,商户网站订单系统中唯一订单号,必填
String out_trade_no = vo.getOut_trade_no();
//付款金额,必填
String total_amount = vo.getTotal_amount();
//订单名称,必填
String subject = vo.getSubject();
//商品描述,可空
String body = vo.getBody();
alipayRequest.setBizContent("{\"out_trade_no\":\""+ out_trade_no +"\","
+ "\"total_amount\":\""+ total_amount +"\","
+ "\"subject\":\""+ subject +"\","
+ "\"body\":\""+ body +"\","
+ "\"product_code\":\"FAST_INSTANT_TRADE_PAY\"}");
String result = alipayClient.pageExecute(alipayRequest).getBody();
//会收到支付宝的响应,响应的是一个页面,只要浏览器显示这个页面,就会自动来到支付宝的收银台页面
System.out.println("支付宝的响应:"+result);
return result;
}
}
application.properties
PayVo
//封装数据
@Data
public class PayVo {
private String out_trade_no; // 商户订单号 必填
private String subject; // 订单名称 必填
private String total_amount; // 付款金额 必填
private String body; // 商品描述 可空
}
3.访问支付接口
4. 编写支付接口
produces属性:用于设置返回的数据类型
AlipayTemplate的pay()方法返回的就是一个用于浏览器响应的付款页面
- 订单服务下PayWebController
package com.atguigu.gulimall.order.web;
@Controller
public class PayWebController {
@Autowired
AlipayTemplate alipayTemplate;
@Autowired
OrderService orderService;
/**
* 1、将支付页让浏览器展示。
* 2、支付成功以后,我们要跳到用户的订单列表页。
* @param orderSn
* @return
* @throws AlipayApiException
*/
@ResponseBody
@GetMapping(value = "/payOrder",produces = "text/html") //处理点击支付宝跳转
public String payOrder(@RequestParam("orderSn") String orderSn) throws AlipayApiException {
// PayVo payVo = new PayVo();
// payVo.setBody();//订单的备注
// payVo.setOut_trade_no();//订单号
// payVo.setSubject();//订单的主题
// payVo.setTotal_amount();//订单的金额
//直接封装一个返回的数据
PayVo payVo = orderService.getOrderPay(orderSn);
//返回的是一个页面。将此页面直接交给浏览器就行
String pay = alipayTemplate.pay(payVo);
System.out.println(pay);
return "hello";
}
}
实现:
- OrderService
/**
* 获取当前订单的支付信息
* @param orderSn
* @return
*/
PayVo getOrderPay(String orderSn);
- OrderServiceImpl
应付金额需要处理,支付宝只能支付保留两位小数的金额,采用ROUND_UP的进位模式
@Override
public PayVo getOrderPay(String orderSn) {
PayVo payVo = new PayVo();
OrderEntity order = this.getOrderByOrderSn(orderSn);
//数据库 应付金额显示的4位小数:1215.0000
//支付宝要求是两位小数,此外我们设置有余数就进位:譬如:12.0001 变为 12.01
BigDecimal bigDecimal = order.getPayAmount().setScale(2, BigDecimal.ROUND_UP);
//设置金额
payVo.setTotal_amount(bigDecimal.toString());
//设置订单号
payVo.setOut_trade_no(order.getOrderSn());
List<OrderItemEntity> order_sn = orderItemService.list(new QueryWrapper<OrderItemEntity>().eq("order_sn", orderSn));
OrderItemEntity entity = order_sn.get(0);//得到订单中的第一个商品
payVo.setSubject(entity.getSkuName());//这里我们将订单的第一个商品的名字设置为提示
payVo.setBody(entity.getSkuAttrsVals());//销售属性设为 订单的备注
return payVo;
}
5)、 支付成功同步回调
会员服务下
1.会员服务导入thymeleaf的依赖并配置
<!-- Thymeleaf的依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
开发环境下,关闭thymeleaf的缓存
spring.thymeleaf.cache=false
2.将订单页文件夹中的index.html复制到会员服务的templates下并更名为orderList.html,将静态资源复制到Nginx中并替换访问路径【动静分离】
Nginx 中 配置静态资源位置。
修改 orderList.html中的静态资源前缀:
3. 配置网关及域名映射
配置 域名映射:192.168.56.10 member.gulimall.com
配置网关:
5. 引入Spring-Session
①导入依赖
<!--引入 redis-->
<!--导入Spring Session with 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>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
<!--导入SpringBoot整合Redis的依赖-->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
② 配置
spring.session.store-type=redis
spring.redis.host=192.168.56.10
复制订单服务中有关session的配置
/**一个新系统引入需要以下配置
* 1、spring-session依赖等导入
* 2、spring-session配置
* 3、引入 LoginUserInterceptor WebMvcConfig等
*/
package com.atguigu.gulimall.member.config;
@Configuration
public class GulimallSessionConfig {
@Bean
public CookieSerializer cookieSerializer() {
DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
cookieSerializer.setDomainName("gulimall.com");
cookieSerializer.setCookieName("GULISESSION");
return cookieSerializer;
}
@Bean
public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
return new GenericJackson2JsonRedisSerializer();
}
}
③ 启用Spring-Session
主启动类 加入 @EnableRedisHttpSession注解
④ 配置拦截器
远程服务调用获取运费信息,都给放过
package com.atguigu.gulimall.member.interceptor;
@Component
public class LoginUserInterceptor implements HandlerInterceptor {
public static ThreadLocal<MemberRespVo> loginUser = new ThreadLocal<>();
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 库存解锁需要远程调用---->需要登录:所以设置拦截器不拦截 order/order/status/{orderSn}。
// 对于查询订单等请求直接放行。
// /order/order/status/45648913346789494
//远程服务调用获取运费信息,都给放过
///member/memberreceiveaddress/info/{id}
String uri = request.getRequestURI();
boolean match = new AntPathMatcher().match("/member/**", uri);
if (match){
return true;
}
//获取登录用户
MemberRespVo attribute = (MemberRespVo) request.getSession().getAttribute(AuthServerConstant.LOGIN_USER);
if (attribute != null) {
//登录成功后,将用户信息存储至ThreadLocal中方便其他服务获取用户信息:
//加上ThreadLocal共享数据,是为了登录后把用户放到本地内存,而不是每次都去远程session里查
loginUser.set(attribute);
return true;
} else {
//没登录就去登录
request.getSession().setAttribute("msg", "请先进行登录");
response.sendRedirect("http://auth.gulimall.com/login.html");
return false;
}
}
}
注册拦截器
package com.atguigu.gulimall.member.config;
@Configuration
public class MemberMvcConfigurer implements WebMvcConfigurer {
@Autowired
LoginUserInterceptor loginUserInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginUserInterceptor).addPathPatterns("/**");
}
}
6. 前端页面跳转修改
首页将我的订单处修改
7. controller编写
package com.atguigu.gulimall.member.web;
@Controller
public class MemberWebController {
@GetMapping("/memberOrder.html")
public String memberOrderPage(){
//查出当前登录的用户的所有订单列表数据
return "orderList";
}
}
8.配置支付成功后的跳转页面
订单服务的 application.properties中修改 成功回调的地址。
6)、订单列表页渲染完成
1.远程服务调用获取订单项详情
①订单服务中编写获取订单项详情接口
OrderController
package com.atguigu.gulimall.order.controller;
/**
* 分页查询当前登录用户的所有订单
*/
@PostMapping("/listWithItem")
//@RequiresPermissions("order:order:list")
public R listWithItem(@RequestBody Map<String, Object> params){
PageUtils page = orderService.queryPageWithItem(params);
return R.ok().put("page", page);
}
② 订单实体中编写订单项属性
OrderEntity
@TableField(exist = false) //表明不是数据库中的字段
private List<OrderItemEntity> itemEntities;
③ 分页查询订单项详情接口实现
OrderServiceImpl
package com.atguigu.gulimall.order.service.impl;
@Override
public PageUtils queryPageWithItem(Map<String, Object> params) {
//获取用户信息
MemberRespVo memberRespVo = LoginUserInterceptor.loginUser.get();
//根据用户id获取最新的订单
IPage<OrderEntity> page = this.page(
new Query<OrderEntity>().getPage(params),
new QueryWrapper<OrderEntity>().eq("member_id",memberRespVo.getId()).orderByDesc("id"));
List<OrderEntity> order_sn = page.getRecords().stream().map(order -> {
//根据订单号获取订单项数据
List<OrderItemEntity> itemEntities = orderItemService.list(new QueryWrapper<OrderItemEntity>()
.eq("order_sn", order.getOrderSn()));
order.setItemEntities(itemEntities);
return order;
}).collect(Collectors.toList());
page.setRecords(order_sn);
return new PageUtils(page);
}
④ 会员服务远程调用订单服务查询订单项详情接口编写
package com.atguigu.gulimall.member.feign;
@FeignClient("gulimall-order")
public interface OrderFeignService {
@PostMapping("/order/order/listWithItem")
//@RequiresPermissions("order:order:list")
R listWithItem(@RequestBody Map<String, Object> params);
}
MemberWebController
package com.atguigu.gulimall.member.web;
@Controller
public class MemberWebController {
@Autowired
OrderFeignService orderFeignService;
@GetMapping("/memberOrder.html")
public String memberOrderPage(@RequestParam(value = "pageNum",defaultValue = "1") Integer pageNum,
Model model){
//查出当前登录的用户的所有订单列表数据
HashMap<String, Object> page = new HashMap<>();
page.put("page",pageNum.toString());
R r = orderFeignService.listWithItem(page);
System.out.println(JSON.toJSONString(r));
model.addAttribute("orders",r);
return "orderList";
}
}
会出现两个问题:
①远程服务调用未携带cookie信息被拦截器拦截需要登录
解决方案:远程调用时拦截器将老请求的请求头信息再次封装(导入GuliFeignConfig)
package com.atguigu.gulimall.member.config;
@Configuration
public class GuliFeignConfig {
@Bean("requestInterceptor")
public RequestInterceptor requestInterceptor() {
return new RequestInterceptor() {
@Override
public void apply(RequestTemplate template) {
//1、RequestContextHolder拿到刚进来的这个请求
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
// System.out.println("RequestInterceptor线程..." + Thread.currentThread().getId());
if (attributes != null) {
HttpServletRequest request = attributes.getRequest();//老请求
if (request != null) {
//同步请求头数据,Cookie
String cookie = request.getHeader("Cookie");
//给新请求同步了老请求的cookie
template.header("Cookie", cookie);
}
}
}
};
}
}
②getPage()将String类型的page又强转为String
com.atguigu.common.utils.Query
解决方案:
2.前端页面展示
只保留一个table用于遍历
遍历订单
获取订单号
遍历订单项
固定照片大小,取出图片
获取商品描述
获取订单项数量、收货人姓名、应付总额
获取订单状态
改进: 这些信息只出现一次,所占行数依据订单项数而定
打印结果如下:
只遍历一次,有几个商品占几行
缺失
<table class="table" th:each="order : ${orders.page.list}">
<tr>
<td colspan="7" style="background:#F7F7F7" >
<span style="color:#AAAAAA">2017-12-09 20:50:10</span>
<span><ruby style="color:#AAAAAA">订单号:</ruby> [[${order.orderSn}]]</span>
<span>谷粒商城<i class="table_i"></i></span>
<i class="table_i5 isShow"></i>
</td>
</tr>
<tr class="tr" th:each=" item,itemStat : ${order.itemEntities}">
<td colspan="3" style="border-right: 1px solid #ccc;">
<img style="height: 60px;width: 60px" th:src="${item.skuPic}" alt="" class="img">
<div>
<p style="width: 242px;height: auto;overflow: auto">
[[${item.skuName}]]
</p>
<div><i class="table_i4"></i>找搭配</div>
</div>
<div style="margin-left:15px;">x[[${item.skuQuantity}]]</div>
<div style="clear:both"></div>
</td>
<td th:if="${itemStat.index==0}" th:rowspan="${itemStat.size}">[[${order.receiverName}]]<i><i class="table_i1"></i></i></td>
<td th:if="${itemStat.index==0}" th:rowspan="${itemStat.size}" style="padding-left:10px;color:#AAAAB1;">
<p style="margin-bottom:5px;">总额 ¥[[${order.payAmount}]]</p>
<hr style="width:90%;">
<p>在线支付</p>
</td>
<td th:if="${itemStat.index==0}" th:rowspan="${itemStat.size}">
<ul>
<li style="color:#71B247;" th:if="${order.status==0}">待付款</li>
<li style="color:#71B247;" th:if="${order.status==1}">已付款</li>
<li style="color:#71B247;" th:if="${order.status==2}">已发货</li>
<li style="color:#71B247;" th:if="${order.status==3}">已完成</li>
<li style="color:#71B247;" th:if="${order.status==4}">已取消</li>
<li style="color:#71B247;" th:if="${order.status==5}">售后中</li>
<li style="color:#71B247;" th:if="${order.status==6}">售后完成</li>
<li style="margin:4px 0;" class="hide"><i class="table_i2"></i>跟踪<i class="table_i3"></i>
<div class="hi">
<div class="p-tit">
普通快递 运单号:390085324974
</div>
<div class="hideList">
<ul>
<li>
[北京市] 在北京昌平区南口公司进行签收扫描,快件已被拍照(您
的快件已签收,感谢您使用韵达快递)签收
</li>
<li>
[北京市] 在北京昌平区南口公司进行签收扫描,快件已被拍照(您
的快件已签收,感谢您使用韵达快递)签收
</li>
<li>
[北京昌平区南口公司] 在北京昌平区南口公司进行派件扫描
</li>
<li>
[北京市] 在北京昌平区南口公司进行派件扫描;派送业务员:业务员;联系电话:17319268636
</li>
</ul>
</div>
</div>
</li>
<li class="tdLi">订单详情</li>
</ul>
</td>
<td th:if="${itemStat.index==0}" th:rowspan="${itemStat.size}">
<button>确认收货</button>
<p style="margin:4px 0; ">取消订单</p>
<p>催单</p>
</td>
</tr>
</table>
效果展示:
ps:注意:如果使用的 cpolar进行内网穿透测试,每隔24小时需要更换一下 url地址。
获取隧道地址:http://127.0.0.1:9200/#/status/online
更换这个 地址:
服务器[异步通知]页面路径
7)、异步通知内网穿透环境搭建
支付回调异步通知:异步通知参数说明 | 网页&移动应用
支付宝采用的是最终一致性中的最大努力通知策略
①搭建隧道
图形管理界面方式:
或者使用命令行方式:
cpolar http 192.168.56.10:80
测试:需要成功访问到Nginx
配置支付成功后的回调请求路径
alipay.notify_url=http://81953a3.vip.cpolar.cn/payed/notify
订单服务下回调接口编写,成功响应后必须返回给支付宝success
package com.atguigu.gulimall.order.listener;
@RestController
public class OrderPayedListener {
@PostMapping("/payed/notify")
public String handleAlipayed(HttpServletRequest request){
//只要我们收到了支付宝给我们的异步通知,告诉我们订单支付成功。
//我们返回 success,支付宝就再也不会通知。
Map<String, String[]> map = request.getParameterMap();
System.out.println("支付宝通知到位了...数据:"+ map);
return "success";
}
③ 配置拦截器放过
支付宝异步通知不需要进行登录。
④ 配置Nginx
注意细节:
1.配置域名,否则将会路由给静态页面
2.精确匹配要在模糊匹配的上面
在 gulimall.conf中进行配置:
重启Nginx
docker restart nginx
postman 客户端中测试:
8)、支付完成
1.将支付宝支付成功后的异步通知信息抽取成vo
复制老师课件。
@ToString
@Data
public class PayAsyncVo {
private String gmt_create;
private String charset;
private String gmt_payment;
private Date notify_time;
private String subject;
private String sign;
private String buyer_id;//支付者的id
private String body;//订单的信息
private String invoice_amount;//支付金额
private String version;
private String notify_id;//通知id
private String fund_bill_list;
private String notify_type;//通知类型; trade_status_sync
private String out_trade_no;//订单号
private String total_amount;//支付的总额
private String trade_status;//交易状态 TRADE_SUCCESS
private String trade_no;//流水号
private String auth_app_id;//
private String receipt_amount;//商家收到的款
private String point_amount;//
private String app_id;//应用id
private String buyer_pay_amount;//最终支付的金额
private String sign_type;//签名类型
private String seller_id;//商家的id
}
2. 配置SpringMVC日期转化格式
spring.mvc.date-format=yyyy-MM-dd HH:mm:ss
3. 验签,确保是支付宝返回的信息
验签核心代码
package com.atguigu.gulimall.order.listener;
//获取支付宝POST过来反馈信息
Map<String,String> params = new HashMap<String,String>();
Map<String,String[]> requestParams = request.getParameterMap();
for (Iterator<String> iter = requestParams.keySet().iterator(); iter.hasNext();) {
String name = (String) iter.next();
String[] values = (String[]) requestParams.get(name);
String valueStr = "";
for (int i = 0; i < values.length; i++) {
valueStr = (i == values.length - 1) ? valueStr + values[i]
: valueStr + values[i] + ",";
}
//乱码解决,这段代码在出现乱码时使用
valueStr = new String(valueStr.getBytes("ISO-8859-1"), "utf-8");
params.put(name, valueStr);
}
boolean signVerified = AlipaySignature.rsaCheckV1(params, AlipayConfig.alipay_public_key, AlipayConfig.charset, AlipayConfig.sign_type); //调用SDK验证签名
//——请在这里编写您的程序(以下代码仅作参考)——
/* 实际验证过程建议商户务必添加以下校验:
1、需要验证该通知数据中的out_trade_no是否为商户系统中创建的订单号,
2、判断total_amount是否确实为该订单的实际金额(即商户订单创建时的金额),
3、校验通知中的seller_id(或者seller_email) 是否为out_trade_no这笔单据的对应的操作方(有的时候,一个商户可能有多个seller_id/seller_email)
4、验证app_id是否为该商户本身。
*/
if(signVerified) {//验证成功
//商户订单号
String out_trade_no = new String(request.getParameter("out_trade_no").getBytes("ISO-8859-1"),"UTF-8");
//支付宝交易号
String trade_no = new String(request.getParameter("trade_no").getBytes("ISO-8859-1"),"UTF-8");
//交易状态
String trade_status = new String(request.getParameter("trade_status").getBytes("ISO-8859-1"),"UTF-8");
if(trade_status.equals("TRADE_FINISHED")){
//判断该笔订单是否在商户网站中已经做过处理
//如果没有做过处理,根据订单号(out_trade_no)在商户网站的订单系统中查到该笔订单的详细,并执行商户的业务程序
//如果有做过处理,不执行商户的业务程序
//注意:
//退款日期超过可退款期限后(如三个月可退款),支付宝系统发送该交易状态通知
}else if (trade_status.equals("TRADE_SUCCESS")){
//判断该笔订单是否在商户网站中已经做过处理
//如果没有做过处理,根据订单号(out_trade_no)在商户网站的订单系统中查到该笔订单的详细,并执行商户的业务程序
//如果有做过处理,不执行商户的业务程序
//注意:
//付款完成后,支付宝系统发送该交易状态通知
}
out.println("success");
}else {//验证失败
out.println("fail");
//调试用,写文本函数记录程序运行情况是否正常
//String sWord = AlipaySignature.getSignCheckContentV1(params);
//AlipayConfig.logResult(sWord);
}
4. 业务处理(①保存交易流水号②修改订单状态)
确保流水号的唯一性,添加索引
将订单号设置的长度变长一点,防止订单号设置错误。
以下两种状态都是支付成功状态
代码实现:
OrderPayedListener
package com.atguigu.gulimall.order.listener;
@RestController
public class OrderPayedListener {
@Autowired
OrderService orderService;
@Autowired
AlipayTemplate alipayTemplate;
@PostMapping("/payed/notify")
public String handleAlipayed(PayAsyncVo vo,HttpServletRequest request) throws UnsupportedEncodingException, AlipayApiException {
//只要我们收到了支付宝给我们的异步通知,告诉我们订单支付成功。
//我们返回 success,支付宝就再也不会通知。
// Map<String, String[]> map = request.getParameterMap();
// for (String key : map.keySet()) {
// String value = request.getParameter(key);
// System.out.println("参数名:"+ key +"==>参数值:"+ value);
// }
// System.out.println("支付宝通知到位了...数据:"+ map);
//验签
//获取支付宝POST过来反馈信息
Map<String,String> params = new HashMap<String,String>();
Map<String,String[]> requestParams = request.getParameterMap();
for (Iterator<String> iter = requestParams.keySet().iterator(); iter.hasNext();) {
String name = (String) iter.next();
String[] values = (String[]) requestParams.get(name);
String valueStr = "";
for (int i = 0; i < values.length; i++) {
valueStr = (i == values.length - 1) ? valueStr + values[i]
: valueStr + values[i] + ",";
}
//乱码解决,这段代码在出现乱码时使用
// valueStr = new String(valueStr.getBytes("ISO-8859-1"), "utf-8");
params.put(name, valueStr);
}
boolean signVerified = AlipaySignature.rsaCheckV1(params, alipayTemplate.getAlipay_public_key(),alipayTemplate.getCharset(), alipayTemplate.getSign_type()); //调用SDK验证签名
if (signVerified){
System.out.println("签名验证成功...");
String result = orderService.handlePayResult(vo);
return result;
}else {
System.out.println("签名验证失败...");
return "error";
}
}
}
实现:
OrderServiceImpl
package com.atguigu.gulimall.order.service.impl;
@Autowired
PaymentInfoService paymentInfoService;
/**
* 处理支付宝支付成功修改订单状态
*
* @param vo
* @return
*/
@Override
public String handlePayResult(PayAsyncVo vo) {
//1、保存交易流水
PaymentInfoEntity infoEntity = new PaymentInfoEntity();
infoEntity.setAlipayTradeNo(vo.getTrade_no());
infoEntity.setOrderSn(vo.getOut_trade_no());
infoEntity.setPaymentStatus(vo.getTrade_status());
infoEntity.setCallbackTime(vo.getNotify_time());
paymentInfoService.save(infoEntity);
//修改订单的状态信息
if (vo.getTrade_status().equals("TRADE_SUCCESS") || vo.getTrade_status().equals("TRADE_FINISHED")) {
//支付成功状态
String outTradeNo = vo.getOut_trade_no();
this.baseMapper.updataOrderStatus(outTradeNo, OrderStatusEnum.PAYED.getCode());
}
return "success";
}
OrderDao
void updataOrderStatus(@Param("outTradeNo") String outTradeNo, @Param("code") Integer code);
OrderDao.xml
<update id="updataOrderStatus">
UPDATE `oms_order` SET `status` = #{code} WHERE order_sn = #{outTradeNo}
</update>
sql代码:
UPDATE `oms_order` SET `status` = ? WHERE order_sn = ?
测试:
控制台成功打印:
页面显示:已付款
9)、收单
1、订单在支付页,不支付,一直刷新,订单过期了才支付,订单状态改为已支付了,但是库 存解锁了。
- 使用支付宝自动收单功能解决。只要一段时间不支付,就不能支付了。
2、由于时延等问题。订单解锁完成,正在解锁库存的时候,异步通知才到
- 订单解锁,手动调用收单
3、网络阻塞问题,订单支付成功的异步通知一直不到达
- 查询订单列表时,ajax获取当前未支付的订单状态,查询订单状态时,再获取一下支付宝 此订单的状态
4、其他各种问题
- 每天晚上闲时下载支付宝对账单,一一进行对账
情况一:订单在支付页,不支付,一直刷新,订单过期了才支付,订单状态已经改为已付款但是库存解锁了
解决方案:自动关单
AlipayTemplate
情况二: 由于网络延时原因,订单解锁完成,正要解锁库存时,异步通知才到
解决方案:订单解锁,手动关单
- 测试中如果报这个错误:
Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request p
1.10 秒杀服务
1.10.1 后台管理系统完善
-
启动人人开源后台和前端,点击 优惠营销-每日秒杀。
-
完善这个每日秒杀业务,当点击每日秒杀,发送的请求:
-
- 来到网关进行配置
-
- 访问成功后,尝试添加两个秒杀场次
-
-
当点击 每个场次 的关联商品
-
-
修改 后台代码:
首先,这个请求是在 优惠券服务的 SeckillSkuRelationController 的 list方法下,所以修改查询时的参 数。- SeckillSkuRelationServiceImpl
@Service("seckillSkuRelationService") public class SeckillSkuRelationServiceImpl extends ServiceImpl<SeckillSkuRelationDao, SeckillSkuRelationEntity> implements SeckillSkuRelationService { @Override public PageUtils queryPage(Map<String, Object> params) { QueryWrapper<SeckillSkuRelationEntity> queryWrapper = new QueryWrapper<SeckillSkuRelationEntity>(); //场次id不是Null String promotionSessionId = (String) params.get("promotionSessionId"); if(!StringUtils.isEmpty(promotionSessionId)){ queryWrapper.eq("promotion_session_id",promotionSessionId); } IPage<SeckillSkuRelationEntity> page = this.page( new Query<SeckillSkuRelationEntity>().getPage(params), queryWrapper ); return new PageUtils(page); } }
- 数据库中按照场次 id 添加一些测试数据。
- sms_seckill_session表
- sms_seckill_sku_relation表
- sms_seckill_sku_relation表
- 后台管理系统查看效果:
1.10.2 秒杀微服务搭建
因为 秒杀具有瞬间高并发的特点,针对这一特点,必须要做限流+ 异步+ 缓存(页面静态化) + 独立部署;
所以我们需要新建一个微服务来编写 秒杀业务,如果放到其他业务下,譬如放到 商品系统下,可能会因为秒杀业务带来的高并发将数据库或者商品系统压垮。
①新建 gulimall-seckill秒杀服务
暂时配置的依赖:
② 导入公共服务依赖
<dependency>
<groupId>com.atguigu.gulimall</groupId>
<artifactId>gulimall-common</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
③application.properties配置
spring.application.name=gulimall-seckill
server.port=25000
spring.cloud.nacos.discovery.server-addr=127.0.0.1:8848
spring.redis.host=192.168.56.10
④主启动类加上 @EnableDiscoveryClient 服务发现注解,因为 common服务引入了 mybatis 的数据库设置,这里排除数据源设置。
1.10.3 SpringBoot整合定时任务&异步任务
1、cron 表达式
语法:秒分时日月周年(Spring 不支持)
官网文档
中文文档:
介绍
cron是一个已经存在很长时间的 UNIX 工具,因此它的调度功能非常强大且经过验证。CronTrigger类基于 cron 的调度功能。
CronTrigger
使用“cron 表达式”,它能够创建触发时间表,例如:“每周一至周五上午 8:00”或“每月最后一个周五凌晨 1:30”。
Cron 表达式很强大,但也很容易混淆。本教程旨在揭开创建 cron 表达式的神秘面纱,为用户提供一个资源,他们可以在论坛或邮件列表中提问之前访问该资源。
格式
cron 表达式是由 6 或 7 个字段组成的字符串,由空格分隔。字段可以包含任何允许的值,以及该字段允许的特殊字符的各种组合。字段如下:
字段名称 | 强制的 | 允许值 | 允许的特殊字符 |
---|---|---|---|
秒 | 是的 | 0-59 | , - * / |
分钟 | 是的 | 0-59 | , - * / |
小时 | 是的 | 0-23 | , - * / |
一个月中的第几天 | 是的 | 1-31 | , - * ?/ 长宽 |
月 | 是的 | 1-12 或 1 月至 12 月 | , - * / |
星期几 | 是的 | 1-7 或 SUN-SAT | , - * ?/大号# |
年 | 不 | 空,1970-2099 | , - * / |
*
( “所有值”)- 用于选择字段中的所有值。例如,分钟字段中的“ * ”表示“每分钟”。?
( “无特定值”)- 当您需要在允许字符的两个字段之一中指定某些内容而不是另一个时很有用。例如,如果我希望我的触发器在一个月中的特定一天(比如 10 号)触发,但不关心碰巧是星期几,我会在日期中输入“10” -月字段,和“?” 在星期字段中。请参阅下面的示例以进行说明。-
- 用于指定范围。例如,小时字段中的“10-12”表示“第 10、11 和 12 小时”。,
- 用于指定附加值。例如,星期几字段中的“MON,WED,FRI”表示“星期一、星期三和星期五”。/
- 用于指定增量。例如,秒字段中的“0/15”表示“第 0、15、30 和 45 秒”。秒字段中的“5/15”表示“第 5、20、35 和 50 秒”。您还可以在“ ”字符之后指定“/”——在这种情况下,“ ”相当于在“/”之前添加“0”。day-of-month 字段中的 '1/3' 表示“从该月的第一天开始每 3 天触发一次”。L
( “last”)——在允许使用的两个字段中各有不同的含义。例如,day-of-month 字段中的值“L”表示“该月的最后一天” - 1 月的第 31 天,非闰年的 2 月的第 28 天。如果单独用于星期几字段,它仅表示“7”或“SAT”。但如果在星期几字段中用在另一个值之后,则表示“该月的最后一个 xxx 日” ——例如“6L”表示“该月的最后一个星期五”。您还可以指定从该月最后一天开始的偏移量,例如“L-3”,这表示该日历月的倒数第三天。 使用“L”选项时,重要的是不要指定列表或值范围,因为您会得到令人困惑/意外的结果。W
( “工作日”)- 用于指定离给定日期最近的工作日(周一至周五)。例如,如果您指定“15W”作为日期字段的值,则含义是: “离该月 15 日最近的工作日”。因此,如果 15 日是星期六,触发器将在 14 日星期五触发。如果 15 号是星期天,触发器将在 16 号星期一触发。如果 15 号是星期二,那么它将在 15 号星期二触发。但是,如果您指定“1W”作为日期的值,并且 1 号是星期六,触发器将在 3 号星期一触发,因为它不会“跳过”一个月的日期边界。“W”字符只能在日期是一天而不是日期范围或列表时指定。
'L' 和 'W' 字符也可以在日期字段中组合以产生 'LW',转换为 “该月的最后一个工作日”。
#
- 用于指定该月的“第 n 个”XXX 日。例如,day-of-week 字段中的值“6#3”表示“该月的第三个星期五”(第 6 天 = 星期五,“#3”= 该月的第 3 个星期五)。其他示例:“2#1”= 每月的第一个星期一,“4#5”= 每月的第五个星期三。请注意,如果您指定“#5”并且该月中给定的星期几不是第 5 天,那么该月将不会触发。
合法字符以及月份和星期几的名称不区分大小写。
MON与``mon
相同。
2、cron 示例
表达 | 意义 |
---|---|
0 0 12 * * ? |
每天中午 12 点(中午)触发 |
0 15 10 ?* * |
每天上午 10:15 触发 |
0 15 10 * * ? |
每天上午 10:15 触发 |
0 15 10 * * ?* |
每天上午 10:15 触发 |
0 15 10 * * ?2005年 |
2005 年每天上午 10:15 触发 |
0 * 14 * * ? |
每天从下午 2 点开始到下午 2:59 结束,每分钟触发一次 |
0 0/5 14 * * ? |
每天从下午 2 点开始到下午 2:55 结束,每 5 分钟触发一次 |
0 0/5 14,18 * * ? |
从下午 2 点开始到下午 2:55 结束,每 5 分钟触发一次,从下午 6 点开始到下午 6:55 结束,每 5 分钟触发一次,每天 |
0 0-5 14 * * ? |
每天从下午 2 点开始到下午 2:05 结束,每分钟触发一次 |
0 10,44 14 ?3 星期三 |
在 3 月份的每个星期三的下午 2:10 和下午 2:44 触发。 |
0 15 10 ?* 周一至周五 |
每周一、周二、周三、周四和周五上午 10:15 触发 |
0 15 10 15 * ? |
每月第 15 天上午 10:15 触发 |
0 15 10 升 * ? |
每月最后一天上午 10:15 触发 |
0 15 10 L-2 * ? |
在每个月的倒数第二天上午 10:15 触发 |
0 15 10 ?* 6L |
在每个月的最后一个星期五上午 10:15 触发 |
0 15 10 ?* 6L |
在每个月的最后一个星期五上午 10:15 触发 |
0 15 10 ?* 6L 2002-2005 |
在 2002、2003、2004 和 2005 年的每个月的最后一个星期五上午 10:15 触发 |
0 15 10 ?* 6#3 |
在每个月的第三个星期五上午 10:15 触发 |
0 0 12 1/5 * ? |
从每月的第一天开始,每月每 5 天在中午 12 点(中午)触发。 |
0 11 11 11 11 ? |
每年 11 月 11 日上午 11:11 触发。 |
注意'?'的影响 在星期几和星期几字段中使用“*”!
可以使用在线工具进行表达式的快速编写:https://cron.qqe2.com/
3、SpringBoot 整合
秒杀 服务下编写 HelloSchedule
/**
* 定时任务
* 1、@EnableScheduling //开启定时任务
* 2、@Scheduled 开启一个定时任务
* 3、自动配置类 TaskSchedulingAutoConfiguration 属性绑定在 TaskSchedulingProperties
*
* 异步任务
* 1、@EnableAsync 开启异步任务功能
* 2、@Async 给希望异步执行的方法上标注
* 3、自动配置类 TaskExecutionAutoConfiguration 属性绑定在 TaskExecutionProperties
*/
@EnableAsync //开启异步任务
@EnableScheduling //开启定时任务
@Slf4j
@Component
public class HelloSchedule {
/**
* 1、Spring中6位组成,不允许第7位的年
* 2、在周几的位置,1-7代表周一到周日;也可以使用 MON-SUN
* 3、定时任务不应该阻塞。默认是阻塞的。
* 1)、可以让业务运行以异步的方式,自己提交到线程池
* CompletableFuture.runAsync(()->{
* xxxxService.hello();
* },executor);
* 2)、支持定时任务线程池:设置 TaskSchedulingProperties;
* spring.task.scheduling.pool.size=5
*
* 3)、让定时任务异步执行
* 异步任务;
*
* 解决:使用异步 + 定时任务来完成定时任务不阻塞的功能。
*
*/
@Async
@Scheduled(cron = "* * * ? * 2") //每秒都执行,只要月份是2月份
public void hello() throws InterruptedException {
log.info("hello...");
Thread.sleep(3000);
}
}
application.properties
#定时任务线程池设置
#不同版本下,该配置有时候生效,有时候不生效
#spring.task.scheduling.pool.size=5
#异步任务线程池设置
spring.task.execution.pool.core-size=5
spring.task.execution.pool.max-size=50
最终方案:使用异步 + 定时任务来完成定时任务不阻塞的功能。
因为 @Scheduled默认是一个单线程,如果开启多个任务时,任务的执行时机会受上一个任务执行时间的影响。
1.10.4 时间日期处理
1、创建 SeckillSkuScheduled
package com.atguigu.gulimall.seckill.config;
@EnableAsync // 3.开启异步任务:防止定时任务之间相互阻塞
@EnableScheduling // 2.开启定时任务
@Configuration //1.主要用于标记配置类,兼备Component的效果。
public class ScheduledConfig {
}
2、编写 接口
SeckillSkuScheduled
package com.atguigu.gulimall.seckill.scheduled;
/**
* 秒杀商品 的定时上架:
* 每天3点:上架最近3天需要秒杀的商品
* 当天 00:00:00 - 23:59:59
* 明天 00:00:00 - 23:59:59
* 后天 00:00:00 - 23:59:59
*/
@Slf4j
@Service
public class SeckillSkuScheduled {
@Autowired
SeckillService seckillServicel;
@Scheduled(cron = "0 0 3 * * ?")
public void uploadSeckillSkuLatest3Days(){
//1、重复上架无需处理
seckillServicel.uploadSeckillSkuLatest3Days();
}
}
实现:
SeckillService
package com.atguigu.gulimall.seckill.service;
public interface SeckillService {
void uploadSeckillSkuLatest3Days();
}
SeckillServiceImpl
package com.atguigu.gulimall.seckill.service.impl;
@Service
public class SeckillServiceImpl implements SeckillService {
@Override
public void uploadSeckillSkuLatest3Days() {
//1、扫描最近3天需要参与秒杀的活动
}
}
- 秒杀服务中远程查询优惠服务中最近3天需要参与秒杀的活动商品
package com.atguigu.gulimall.seckill.feign;
@FeignClient("gulimall-coupon")
public interface CouponFeignService {
}
3、优惠服务中准备远程服务接口的编写
- SeckillSessionController(秒杀活动场次相关)
package com.atguigu.gulimall.coupon.controller;
@GetMapping("/lates3DaySession")
public R getLates3DaySession() {
List<SeckillSessionEntity> sessions = seckillSessionService.getLates3DaysSession();
return R.ok().setData(sessions);
}
- 时间日期处理:
在秒杀服务的单元测试中编写获取最近 3天的时间范围测试:
package com.atguigu.gulimall.seckill;
@Test
public void contextLoads() {
LocalDate now = LocalDate.now();
LocalDate plus = now.plusDays(1);
LocalDate plus2 = now.plusDays(2);
/**
* 2022-12-20
* 2022-12-21
* 2022-12-22
*/
System.out.println(now);
System.out.println(plus);
System.out.println(plus2);
LocalTime min = LocalTime.MIN;
LocalTime max = LocalTime.MAX;
/**
* 00:00
* 23:59:59.999999999
*/
System.out.println(min);
System.out.println(max);
/**
* 2022-12-20T00:00
* 2022-12-22T23:59:59.999999999
*/
LocalDateTime start = LocalDateTime.of(now, min);
LocalDateTime end = LocalDateTime.of(plus2, max);
System.out.println(start);
System.out.println(end);
}
SeckillSessionServiceImpl
package com.atguigu.gulimall.coupon.service.impl;
@Override
public List<SeckillSessionEntity> getLates3DaysSession() {
//计算最近3天
List<SeckillSessionEntity> list = this.list(new QueryWrapper<SeckillSessionEntity>().between("start_time", startTime(), endTime()));
return list;
}
//当前天数的 00:00:00
private String startTime(){
LocalDate now = LocalDate.now();
LocalTime min = LocalTime.MIN;
LocalDateTime start = LocalDateTime.of(now, min);
String format = start.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
return format;
}
//当前天数+2 23:59:59
private String endTime(){
LocalDate now = LocalDate.now();
//加 2天
LocalDate localDate = now.plusDays(2);
LocalTime max = LocalTime.MAX;
LocalDateTime end = LocalDateTime.of(localDate, max);
String format = end.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
return format;
}
1.10.5 秒杀商品上架
1、秒杀系统关注的问题
-
1、服务单一职责+独立部署
- 秒杀服务即使自己扛不住压力,挂掉。不要影响别人
-
2、秒杀链接加密
- 防止恶意攻击,模拟秒杀请求,1000次/s攻击
- 防止链接暴露,自己工作人员,提前秒杀商品。
-
3、库存预热+快速扣减
- 秒杀读多写少。无需每次实时校验库存。我们库存预热,放到redis中。信号量控制进来秒杀的请求
-
4、动静分离
- nginx做好动静分离。保证秒杀和商品详情页的动态请求才打到后端的服务集群。
使用cDN网络,分担本集群压力
- nginx做好动静分离。保证秒杀和商品详情页的动态请求才打到后端的服务集群。
-
5、恶意请求拦截
- 识别非法攻击请求并进行拦截,网关层
-
6、流量错峰
- 使用各种手段,将流量分担到更大宽度的时间点。比如验证码,加入购物车
-
7、限流&熔断&降级
- 前端限流+后端限流
- 限制次数,限制总量,快速失败降级运行,熔断隔离防止雪崩
-
8、队列削峰
- 1万个商品,每个1000件秒杀。双11所有秒杀成功的请求,进入队列,慢慢创建订单,扣减库存即可。
人人开源后台vue的后台管理系统里上架秒杀,打开F12看url然后去编写逻辑
- 秒杀名称
- 开始时间
- 结束时间
- 启用状态
2、秒杀架构设计
(1) 秒杀架构
nginx–>gateway–>redis分布式信号量–> 秒杀服务
- 项目独立部署,独立秒杀模块gulimall-seckill
- 使用定时任务每天三点上架最新秒杀商品,削减高峰期压力
- 秒杀链接加密,为秒杀商品添加唯一商品随机码,在开始秒杀时才暴露接口
- 库存预热,先从数据库中扣除一部分库存以redisson 信号量的形式存储在redis中
- 队列削峰,秒杀成功后立即返回,然后以发送消息的形式创建订单
秒杀活动:存在在scekill:sesssions这个redis-key里,。value为 skyIds[]
秒杀活动里具体商品项:是一个map,redis-key是seckill:skus,map-key是skuId+商品随机码
(2) redis存储模型设计
-
秒杀场次存储的List可以当做hash key在SECKILL_CHARE_PREFIX中获得对应的商品数据
-
随机码防止人在秒杀一开放就秒杀完,必须携带上随机码才能秒杀
-
结束时间
-
设置分布式信号量,这样就不会每个请求都访问数据库。seckill:stock:#(商品随机码)
-
session里存了session-sku[]的列表,而seckill:skus的key也是session-sku,不要只存储sku,不能区分场次
//存储的秒杀场次对应数据 //K: SESSION_CACHE_PREFIX + startTime + "_" + endTime //V: sessionId+"-"+skuId的List private final String SESSION_CACHE_PREFIX = "seckill:sessions:"; //存储的秒杀商品数据 //K: 固定值SECKILL_CHARE_PREFIX //V: hash,k为sessionId+"-"+skuId,v为对应的商品信息SeckillSkuRedisTo private final String SECKILL_CHARE_PREFIX = "seckill:skus"; //K: SKU_STOCK_SEMAPHORE+商品随机码 //V: 秒杀的库存件数 private final String SKU_STOCK_SEMAPHORE = "seckill:stock:"; //+商品随机码
接下来完善 秒杀商品上架业务。
获取最近三天的秒杀信息
- 获取最近三天的秒杀场次信息,再通过秒杀场次id查询对应的商品信息
- 防止集群多次上架
SeckillSessionServiceImpl
@Override
public List<SeckillSessionEntity> getLates3DaysSession() {
//计算最近3天
// Date date = new Date();// 2022-12-20 13:59:16
//获取最近3天的秒杀活动
List<SeckillSessionEntity> list = this.list(new QueryWrapper<SeckillSessionEntity>().between("start_time", startTime(), endTime()));
//设置秒杀活动里面的秒杀商品
if (list != null && list.size()>0){
List<SeckillSessionEntity> collect = list.stream().map(session -> {
//给每一个活动写入他们的秒杀项
Long id = session.getId();
//根据活动场次 id 获取每个 sku项
List<SeckillSkuRelationEntity> relationEntities = seckillSkuRelationService.
list(new QueryWrapper<SeckillSkuRelationEntity>().eq("promotion_session_id", id));
session.setRelationSkus(relationEntities);
return session;
}).collect(Collectors.toList());
return collect;
}
return null;
}
- SeckillSessionEntity中添加字段
@TableField(exist = false)//表示不是数据库中存在的字段
private List<SeckillSkuRelationEntity> relationSkus;
- 秒杀服务编写调用库存远程服务
CouponFeignService
@FeignClient("gulimall-coupon")
public interface CouponFeignService {
@GetMapping("/coupon/seckillsession/lates3DaySession")
R getLates3DaySession();
}
完善 SeckillSkuScheduled 的 uploadSeckillSkuLatest3Days方法
SeckillServiceImpl
秒杀商品上架
@Autowired
CouponFeignService couponFeignService;
/**
* 秒杀商品上架
*/
@Override
public void uploadSeckillSkuLatest3Days() {
//1、扫描最近3天需要参与秒杀的活动
R session = couponFeignService.getLates3DaySession();
if (session.getCode() == 0){
//上架商品
List<SeckillSessionsWithSkus> sessionData = session.getData(new TypeReference<List<SeckillSessionsWithSkus>>() {
});
//缓存到redis
//1、缓存活动信息
saveSessionInfo(sessionData);
//2、缓存活动的关联商品信息
saveSessionSkuInfos(sessionData);
}
}
SeckillSessionsWithSkus:复制SeckillSessionEntity
@Data
public class SeckillSessionsWithSkus {
/**
* id
*/
private Long id;
/**
* 场次名称
*/
private String name;
/**
* 每日开始时间
*/
private Date startTime;
/**
* 每日结束时间
*/
private Date endTime;
/**
* 启用状态
*/
private Integer status;
/**
* 创建时间
*/
private Date createTime;
private List<SeckillSkuVo> relationSkus;
}
SeckillSkuVo:复制 SeckillSkuRelationEntity
@Data
public class SeckillSkuVo {
/**
* id
*/
private Long id;
/**
* 活动id
*/
private Long promotionId;
/**
* 活动场次id
*/
private Long promotionSessionId;
/**
* 商品id
*/
private Long skuId;
/**
* 秒杀价格
*/
private BigDecimal seckillPrice;
/**
* 秒杀总量
*/
private BigDecimal seckillCount;
/**
* 每人限购数量
*/
private BigDecimal seckillLimit;
/**
* 排序
*/
private Integer seckillSort;
}
Redis保存秒杀场次信息
@Autowired
StringRedisTemplate redisTemplate;
private final String SESSIONS_CACHE_PREFIX = "seckill:sessions:";
/**
* 1 缓存活动信息
* @param sessions
*/
private void saveSessionInfo(List<SeckillSessionsWithSkus> sessions){
sessions.stream().forEach(session ->{
Long startTime = session.getStartTime().getTime();
Long endTime = session.getEndTime().getTime();
String key = SESSIONS_CACHE_PREFIX + startTime + "_" + endTime;
List<String> collect = session.getRelationSkus().stream().map(item -> item.getSkuId().toString()).collect(Collectors.toList());
//缓存活动信息
redisTemplate.opsForList().leftPushAll(key,collect);
});
}
redis保存秒杀商品信息
前面已经缓存了sku项的活动信息,但是只有活动id和skuID,接下来我们要保存完整的sku信息到redis中
@Autowired
ProductFeignService productFeignService;
@Autowired
RedissonClient redissonClient;
private final String SKUKILL_CACHE_PREFIX = "seckill:skus";
private final String SKU_STOCK_SEMAPHORE = "seckill:stock:";// + 商品随机码
/**
* 2 缓存商品信息
* @param sessions
*/
private void saveSessionSkuInfos(List<SeckillSessionsWithSkus> sessions){
sessions.stream().forEach(session ->{
//准备hash操作
BoundHashOperations<String, Object, Object> ops = redisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);
session.getRelationSkus().stream().forEach(seckillSkuVo -> {
//缓存商品
SeckillSkuRedisTo redisTo = new SeckillSkuRedisTo();
//1、sku的基本数据
R skuInfo = productFeignService.getSkuInfo(seckillSkuVo.getSkuId());
if (skuInfo.getCode() == 0 ){
SkuInfoVo info = skuInfo.getData("skuInfo", new TypeReference<SkuInfoVo>() {
});
redisTo.setSkuInfo(info);
}
//2、sku的秒杀信息
BeanUtils.copyProperties(seckillSkuVo,redisTo);
//3、设置上当前商品的秒杀时间信息
redisTo.setStartTime(session.getStartTime().getTime());
redisTo.setEndTime(session.getEndTime().getTime());
//4、随机码 seckill?skuId=1&key=jaojgoajgoa;
String token = UUID.randomUUID().toString().replace("-", "");
redisTo.setRandomCode(token);
//5、使用库存作为分布式的信号量 :限流
RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMAPHORE + token);
//商品可以秒杀的数量作为信号量
semaphore.trySetPermits(seckillSkuVo.getSeckillCount().intValue());
String jsonString = JSON.toJSONString(redisTo);
ops.put(seckillSkuVo.getSkuId().toString(),jsonString);
});
});
}
这里需要远程调用 商品服务 下 SkuInfoController的 info()方法查询sku信息。
编写 ProductFeignService
@FeignClient("gulimall-product")
public interface ProductFeignService {
@RequestMapping("/product/skuinfo/info/{skuId}")
//@RequiresPermissions("product:skuinfo:info")
R getSkuInfo(@PathVariable("skuId") Long skuId);
}
编写 to 封装数据
@Data
public class SeckillSkuRedisTo {
/**
* 活动id
*/
private Long promotionId;
/**
* 活动场次id
*/
private Long promotionSessionId;
/**
* 商品id
*/
private Long skuId;
/**
* 秒杀价格
*/
private BigDecimal seckillPrice;
/**
* 秒杀总量
*/
private BigDecimal seckillCount;
/**
* 每人限购数量
*/
private BigDecimal seckillLimit;
/**
* 排序
*/
private Integer seckillSort;
//sku详细信息
private SkuInfoVo skuInfo;
//当前sku的秒杀开始时间
private Long startTime;
//当前sku的秒杀结束时间
private Long endTime;
//商品秒杀随机码
private String randomCode;
}
SkuInfoVo:复制SkuInfoEntity
@Data
public class SkuInfoVo {
/**
* skuId
*/
private Long skuId;
/**
* spuId
*/
private Long spuId;
/**
* sku名称
*/
private String skuName;
/**
* sku介绍描述
*/
private String skuDesc;
/**
* 所属分类id
*/
private Long catalogId;
/**
* 品牌id
*/
private Long brandId;
/**
* 默认图片
*/
private String skuDefaultImg;
/**
* 标题
*/
private String skuTitle;
/**
* 副标题
*/
private String skuSubtitle;
/**
* 价格
*/
private BigDecimal price;
/**
* 销量
*/
private Long saleCount;
}
需要引入 redisson依赖
<!-- 以后使用Redisson作为所有分布式锁,分布式对象等功能框架 -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.12.0</version>
</dependency>
复制MyRedissonConfig
@Configuration
public class MyRedissonConfig {
/**
* 所有对 Redisson的使用都是通过RedissonClient对象
* @return
* @throws IOException
*/
@Bean(destroyMethod = "shutdown")
public RedissonClient redisson() throws IOException {
//1、创建配置
Config config = new Config();
// Redis url should start with redis:// or rediss:// (for SSL connection)
config.useSingleServer().setAddress("redis://192.168.56.10:6379");
//2、根据Config 创建出 RedissonClient实例
RedissonClient redissonClient = Redisson.create(config);
return redissonClient;
}
}
1.10.6 幂等性处理
避免高并发下多机器同时上架情况。
修改代码:
SeckillSkuScheduled
/**
* 定时任务
* 每天三点上架最近三天的秒杀商品
*/
//TODO 幂等性处理
@Scheduled(cron = "*/3 * * * * ?")
public void uploadSeckillSkuLatest3Days(){
//1、重复上架无需处理
log.info("上架秒杀的商品信息...");
//分布式锁:拿到锁的机器才执行:锁的业务执行完成,状态已经更新完成,释放锁以后,其他人获取到就会拿到最新的状态。
//为避免分布式情况下多服务同时上架的情况,使用分布式锁
RLock lock = redissonClient.getLock(upload_lock);
lock.lock(10, TimeUnit.SECONDS);//锁住
try{
seckillServicel.uploadSeckillSkuLatest3Days();
}finally {
lock.unlock();//解锁
}
}
ps:这里为了开发测试效果,改为了每 3秒 做一次上架商品的定时任务。
SeckillServiceImpl
/**
* 1 缓存活动信息
* @param sessions
*/
private void saveSessionInfo(List<SeckillSessionsWithSkus> sessions){
sessions.stream().forEach(session ->{
Long startTime = session.getStartTime().getTime();
Long endTime = session.getEndTime().getTime();
String key = SESSIONS_CACHE_PREFIX + startTime + "_" + endTime;
Boolean hasKey = redisTemplate.hasKey(key);
//幂等性处理
// 防止重复添加活动到redis中
if (!hasKey){
// 获取所有商品id // 格式:活动id-skuId
List<String> collect = session.getRelationSkus().stream().map(item ->
item.getPromotionSessionId().toString() +"_"+item.getSkuId().toString())
.collect(Collectors.toList());
//缓存活动信息
redisTemplate.opsForList().leftPushAll(key,collect);
}
});
}
/**
* 2 缓存商品信息
* @param sessions
*/
private void saveSessionSkuInfos(List<SeckillSessionsWithSkus> sessions){
// 遍历session
sessions.stream().forEach(session ->{
//准备hash操作
BoundHashOperations<String, Object, Object> ops = redisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);
// 遍历sku
session.getRelationSkus().stream().forEach(seckillSkuVo -> {
//4、随机码 seckill?skuId=1&key=jaojgoajgoa;
String token = UUID.randomUUID().toString().replace("-", "");
//幂等性处理
//只需要上架一次,如果已经上架,就不需要再上架
// 缓存中没有再添加
if(!ops.hasKey(seckillSkuVo.getPromotionSessionId().toString()+"_"+ seckillSkuVo.getSkuId().toString())){
//缓存商品
SeckillSkuRedisTo redisTo = new SeckillSkuRedisTo();
//1、sku的基本数据 sku的秒杀信息
R skuInfo = productFeignService.getSkuInfo(seckillSkuVo.getSkuId());
if (skuInfo.getCode() == 0 ){
SkuInfoVo info = skuInfo.getData("skuInfo", new TypeReference<SkuInfoVo>() {
});
redisTo.setSkuInfo(info);
}
//2、sku的秒杀信息
BeanUtils.copyProperties(seckillSkuVo,redisTo);
//3、设置上当前商品的秒杀时间信息
redisTo.setStartTime(session.getStartTime().getTime());
redisTo.setEndTime(session.getEndTime().getTime());
// 设置随机码
redisTo.setRandomCode(token);
String jsonString = JSON.toJSONString(redisTo);
// 活动id-skuID 秒杀sku信息
ops.put(seckillSkuVo.getPromotionSessionId().toString()+"_"+seckillSkuVo.getSkuId().toString(),jsonString);
//5、使用库存作为分布式的信号量 :限流
RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMAPHORE + token);
//商品可以秒杀的数量作为信号量
semaphore.trySetPermits(seckillSkuVo.getSeckillCount().intValue());
}
});
});
}
redis中效果图:
1.10.7 查询秒杀商品
前面已经在redis中缓存了秒杀活动的各种信息,现在写获取缓存中当前时间段在秒杀的sku,用户点击页面后发送请求。
- 接口方法编写
SeckillController
@RestController
public class SeckillController {
@Autowired
SeckillService seckillService;
/**
* 返回当前时间可以参与的秒杀商品信息
* @return
*/
@GetMapping("/currentSeckillSkus")
public R getCurrentSeckillSkus(){
List<SeckillSkuRedisTo> vos = seckillService.getCurrentSeckillSkus();
return R.ok().setData(vos);
}
}
SeckillServiceImpl
//返回当前时间可以参与的秒杀商品信息
@Override
public List<SeckillSkuRedisTo> getCurrentSeckillSkus() {
//1、确定当前时间属于哪个秒杀场次
long time = new Date().getTime();
Set<String> keys = redisTemplate.keys(SESSIONS_CACHE_PREFIX + "*");
for (String key : keys) {
//seckill:sessions:1671674400000_1671678000000
String replace = key.replace(SESSIONS_CACHE_PREFIX, "");//截串
String[] s = replace.split("_");//分割
Long start = Long.parseLong(s[0]);
Long end = Long.parseLong(s[1]);
if (time >= start && time <= end){
//2、获取这个秒杀场次需要的所有商品信息
List<String> range = redisTemplate.opsForList().range(key, -100, 100);
//获取到hash key----seckill:skus
BoundHashOperations<String, String, String> hashOps = redisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);
List<String> list = hashOps.multiGet(range);
if (list != null){
List<SeckillSkuRedisTo> collect = list.stream().map(item -> {
SeckillSkuRedisTo redis = JSON.parseObject((String) item, SeckillSkuRedisTo.class);
// redis.setRandomCode(null);//当前秒杀开始就需要随机码
return redis;
}).collect(Collectors.toList());
return collect;
}
break;
}
}
return null;
}
- 网关配置
域名映射
网关服务
- id: gulimall_seckill_route
uri: lb://gulimall-seckill
predicates:
- Host=seckill.gulimall.com
首页获取并渲染
ul中的各个 li标签全部删除掉,使用 Ajax 局部获取刷新。
ajax请求:
$.get("http://seckill.gulimall.com/currentSeckillSkus",function (resp) {
if (resp.data.length > 0){
resp.data.forEach(function (item) {
$("<li></li>")
.append($("<img style='width: 130px;height: 130px' src='"+item.skuInfo.skuDefaultImg+"'/>"))
.append($("<p>"+ item.skuInfo.skuTitle+"</p>"))
.append($("<span>"+ item.seckillPrice+"</span>"))
.append($("<s>"+ item.skuInfo.price+"</s>"))
.appendTo("#seckillSkuContent");
})
}
//append 添加元素
//appendTo 添加到哪个位置去
// <li>
// <img src="/static/index/img/section_second_list_img1.jpg" alt="">
// <p>花王 (Merries) 妙而舒 纸尿裤 大号 L54片 尿不湿(9-14千克) (日本官方直采) 花王 (Merries) 妙而舒 纸尿裤 大号 L54片 尿不湿(9-14千</p>
// <span>¥83.9</span><s>¥99.9</s>
// </li>
});
效果展示:
1.10.8 秒杀页面渲染
- 秒杀服务下的SeckillController
/**
* 获取当前商品的秒杀信息
* @param skuId
* @return
*/
@GetMapping("/sku/seckill/{skuId}")
public R getSkuSeckillInfo(@PathVariable("skuId") Long skuId){
SeckillSkuRedisTo to = seckillService.getSkuSeckillInfo(skuId);
return R.ok().setData(to);
}
SeckillServiceImpl
// 获取当前商品的秒杀信息
@Override
public SeckillSkuRedisTo getSkuSeckillInfo(Long skuId) {
//1、找到所有需要参与秒杀的商品的key
BoundHashOperations<String, String, String> hashOps = redisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);
Set<String> keys = hashOps.keys();
if (keys != null && keys.size() > 0) {
String regx = "\\d_" + skuId; //6_4 正则匹配
for (String key : keys) {
if (Pattern.matches(regx, key)) {
String json = hashOps.get(key);
SeckillSkuRedisTo skuRedisTo = JSON.parseObject(json, SeckillSkuRedisTo.class);
//随机码
long current = new Date().getTime();
if (current >= skuRedisTo.getStartTime() && current <= skuRedisTo.getEndTime()) {
} else {
skuRedisTo.setRandomCode(null);
}
return skuRedisTo;
};
}
}
return null;
}
商品服务
编写 SeckillFeignService远程调用
@FeignClient("gulimall-seckill")
public interface SeckillFeignService {
@GetMapping("/sku/seckill/{skuId}")
R getSkuSeckillInfo(@PathVariable("skuId") Long skuId);
}
在查询商品详情页的接口中查询秒杀对应信息:
SkuInfoServiceImpl 下的 item方法完善
@Autowired
SeckillFeignService seckillFeignService;
@Override
public SkuItemVo item(Long skuId) throws ExecutionException, InterruptedException {
SkuItemVo skuItemVo = new SkuItemVo();
// 第一步获得的数据,第3步、4步、5步也要使用
CompletableFuture<SkuInfoEntity> infoFuture = CompletableFuture.supplyAsync(() -> {
//1、sku基本信息获取 pms_sku_info
SkuInfoEntity info = getById(skuId);
skuItemVo.setInfo(info);
return info;
}, executor);
CompletableFuture<Void> saleAttrFuture = infoFuture.thenAcceptAsync((res) -> {
//3、获取的spu的销售属性组合。
List<SkuItemSaleAttrVo> saleAttrVos = skuSaleAttrValueService.getSaleAttrsBySpuId(res.getSpuId());
skuItemVo.setSaleAttr(saleAttrVos);
}, executor);
CompletableFuture<Void> descFuture = infoFuture.thenAcceptAsync((res) -> {
//4、获取spu的介绍 pms_spu_info_desc
SpuInfoDescEntity spuInfoDescEntity = spuInfoDescService.getById(res.getSpuId());
skuItemVo.setDesp(spuInfoDescEntity);
}, executor);
CompletableFuture<Void> baseAttrFuture = infoFuture.thenAcceptAsync((res) -> {
//5、获取spu的规格参数信息。
List<SpuItemAttrGroupVo> attrGroupVos = attrGroupService.getAttrGroupWithAttrsBySpuId(res.getSpuId(), res.getCatalogId());
skuItemVo.setGroupAttrs(attrGroupVos);
}, executor);
CompletableFuture<Void> imageFuture = CompletableFuture.runAsync(() -> {
//2、sku的图片信息 pms_sku_images
List<SkuImagesEntity> images = imagesService.getImagesBySkuId(skuId);
skuItemVo.setImages(images);
}, executor);
CompletableFuture<Void> secKillFuture = CompletableFuture.runAsync(() -> {
//3、查询当前sku是否参与秒杀优惠
R seckillInfo = seckillFeignService.getSkuSeckillInfo(skuId);
if (seckillInfo.getCode() == 0) {
SeckillInfoVo seckillInfoVo = seckillInfo.getData(new TypeReference<SeckillInfoVo>() {
});
skuItemVo.setSeckillInfoVo(seckillInfoVo);
}
}, executor);
//等待所有任务都完成
CompletableFuture.allOf(saleAttrFuture,descFuture,baseAttrFuture,imageFuture,secKillFuture).get();
return skuItemVo;
}
注意所有的时间都是距离1970的差值
- 更改商品详情页的显示效果:显示秒杀预告、秒杀价等
<li style="color: red;" th:if="${item.seckillInfoVo != null}">
<span th:if="${#dates.createNow().getTime() < item.seckillInfoVo.startTime}">
商品将会在[[${#dates.format(new java.util.Date(item.seckillInfoVo.startTime),"yyyy-MM-dd HH:mm:ss")}]]进行抢购
</span>
<span th:if="${#dates.createNow().getTime() >= item.seckillInfoVo.startTime && #dates.createNow().getTime() <= item.seckillInfoVo.endTime}">
秒杀价:[[${#numbers.formatDecimal(item.seckillInfoVo.seckillPrice,1,2)}]]
</span>
</li>
- 首页点击秒杀商品,跳转到商品详情页
function to_href(skuId){
location.href = "http://item.gulimall.com/" + skuId + ".html";
}
$.get("http://seckill.gulimall.com/currentSeckillSkus",function (resp) {
if (resp.data.length > 0){
resp.data.forEach(function (item) {
$("<li onclick='to_href("+item.skuId+")'></li>")
.append($("<img style='width: 130px;height: 130px' src='"+item.skuInfo.skuDefaultImg+"'/>"))
.append($("<p>"+ item.skuInfo.skuTitle+"</p>"))
.append($("<span>"+ item.seckillPrice+"</span>"))
.append($("<s>"+ item.skuInfo.price+"</s>"))
.appendTo("#seckillSkuContent");
})
}
效果展示:
该商品在秒杀时间中,显示秒杀价格
如果该商品不在秒杀时间内,显示 秒杀预告。
1.10.9 秒杀系统设计
02:使用随机码
03:本次秒杀使用 redis预热库存(信号量)
05:本次秒杀使用登录拦截器
08:给MQ发送消息
之前我们已经做了 前面 4步,接下来我们要完善后面4步。
1.10.10 登录检查
秒杀的最终的处理
- 秒杀商品定时上架
- 秒杀业务开始
①晚上 详情页的页面效果:如果不在秒杀时间范围内,显示 “加入购物车”;如果在秒杀时间范围内,显示“立即抢购”,并进行跳转,跳转的路径带上 商品 id 和 场次 id 和 随机码、以及数量。
item.html
<div class="box-btns-two" th:if="${#dates.createNow().getTime() >= item.seckillInfoVo.startTime && #dates.createNow().getTime() <= item.seckillInfoVo.endTime}">
<a href="#" id="secKillA" th:attr="skuId=${item.info.skuId},sessionId=${item.seckillInfoVo.promotionSessionId},code=${item.seckillInfoVo.randomCode}">
立即抢购
</a>
</div>
<div class="box-btns-two" th:if="${#dates.createNow().getTime() < item.seckillInfoVo.startTime || #dates.createNow().getTime() > item.seckillInfoVo.endTime}">
<a href="#" id="addToCartA" th:attr="skuId=${item.info.skuId}">
加入购物车
</a>
</div>
跳转函数。
$("#secKillA").click(function () {
var isLogin = [[${session.loginUser != null}]];//true
if (isLogin){
var killId = $(this).attr("sessionId") + "_" + $(this).attr("skuId");
var key = $(this).attr("code");
var num = $("#numInput").val();
location.href = "http://seckill.gulimall.com/kill?killId="+killId+"&key="+key+"&num="+num;
}else {
alert("秒杀请先登录");
}
return false;
})
②编写去秒杀时的登录检查
- 加入session有关等依赖:
spring-boot-starter-data-redis
排除lettuce-core
,使用jedis
<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>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
- application.properties配置
spring.session.store-type=redis
- 主启动类加上 开启 @EnableRedisHttpSession 注解
复制有关session配置和拦截器配置:
GulimallSessionConfig:全局 session配置
@Configuration
public class GulimallSessionConfig {
@Bean
public CookieSerializer cookieSerializer() {
DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
cookieSerializer.setDomainName("gulimall.com");
cookieSerializer.setCookieName("GULISESSION");
return cookieSerializer;
}
@Bean
public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
return new GenericJackson2JsonRedisSerializer();
}
}
LoginUserInterceptor:拦截器
@Component
public class LoginUserInterceptor implements HandlerInterceptor {
public static ThreadLocal<MemberRespVo> loginUser = new ThreadLocal<>();
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 库存解锁需要远程调用---->需要登录:所以设置拦截器不拦截 order/order/status/{orderSn}。
// 对于查询订单等请求直接放行。
// /order/order/status/45648913346789494
String uri = request.getRequestURI();
AntPathMatcher antPathMatcher = new AntPathMatcher();
//不是真正点击去秒杀【立即抢购】的都放行
boolean match = antPathMatcher.match("/kill", uri);
if (match){
//获取登录用户
MemberRespVo attribute = (MemberRespVo) request.getSession().getAttribute(AuthServerConstant.LOGIN_USER);
if (attribute != null) {
//登录成功后,将用户信息存储至ThreadLocal中方便其他服务获取用户信息:
//加上ThreadLocal共享数据,是为了登录后把用户放到本地内存,而不是每次都去远程session里查
loginUser.set(attribute);
return true;
} else {
//没登录就去登录
request.getSession().setAttribute("msg", "请先进行登录");
response.sendRedirect("http://auth.gulimall.com/login.html");
return false;
}
}
return true;
}
}
SecKillWebController:注册拦截器
@Configuration
public class SecKillWebController implements WebMvcConfigurer {
@Autowired
LoginUserInterceptor loginUserInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginUserInterceptor).addPathPatterns("/**");
}
}
- 接口方法编写
SeckillController
/**
* 秒杀商品
* @param killId
* @param key
* @param code
* @return
*/
@GetMapping("/kill")
public R secKill(@RequestParam("killId") String killId,
@RequestParam("key") String key,
@RequestParam("code") String code){
//1、判断是否登录
return null;
}
1.10.11 秒杀流程
- 秒杀方案:
第一种:
优点:分散流量;缺点:流量会级联映射到其他系统里面,极限情况下,造成各个系统崩溃
第二种
优点:在秒杀开始到快速创建订单过程中,没有进行一次数据库操作或者远程调用,只需要校验数据的合法性,因为所有数据都在缓存中放着。
缺点:如果订单服务已经崩溃了,那秒杀服务发出的消息一直不能消费,订单一直支付不成功。
这里我们采用 第二种方案。
消息队列:
秒杀controller:
/**
* 秒杀商品
* @param killId
* @param key
* @param code
* @return
*/
@GetMapping("/kill")
public R secKill(@RequestParam("killId") String killId,
@RequestParam("key") String key,
@RequestParam("num") Integer num){
//秒杀成功就创建一个订单号
String orderSn = seckillService.kill(killId,key,num);
//1、判断是否登录
return R.ok().setData(orderSn);
}
秒杀service:创建订单、发消息
@Override
public String kill(String killId, String key, Integer num) {
//从拦截器中获取当前用户信息
MemberRespVo respVo = LoginUserInterceptor.loginUser.get();
//1、获取当前秒杀商品的详细信息
BoundHashOperations<String, String, String> hashOps = redisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);
String json = hashOps.get(killId);
if (StringUtils.isEmpty(json)){
return null;
}else{
SeckillSkuRedisTo redis = JSON.parseObject(json, SeckillSkuRedisTo.class);
//校验合法性
Long startTime = redis.getStartTime();
Long endTime = redis.getEndTime();
long time = new Date().getTime();
//1、校验时间的合法性
if (time >= startTime && time <= endTime){
//2、校验随机码和商品id
String randomCode = redis.getRandomCode();
String skuId = redis.getPromotionSessionId() + "_" + redis.getSkuId();
if (randomCode.equals(key) && killId.equals(skuId)){
//3、验证购物数量是否合理
if (num <= redis.getSeckillLimit().intValue()){
//4、验证这个人是否已经购买过。幂等性:只要秒杀成功,就去占位。 userId_SessionId_skuId
//SETNX
String redisKey = respVo.getId() + "_" + skuId;
// 让数据自动过期
long ttl = redis.getEndTime() - redis.getStartTime();
//自动过期
Boolean aBoolean = redisTemplate.opsForValue().setIfAbsent(redisKey, num.toString(), ttl, TimeUnit.MILLISECONDS);
if (aBoolean){
//占位成功说明从来没有买过
RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMAPHORE + randomCode);
try {
// 120 ms
boolean b = semaphore.tryAcquire(num, 100, TimeUnit.MILLISECONDS);
//秒杀成功;
// 快速下单。发送MQ消息 10ms
String timeId = IdWorker.getTimeId();
return timeId;
} catch (InterruptedException e) {
return null;
}
}else{
//说明已经买过了
return null;
}
}
}else{
return null;
}
}else{
return null;
}
}
return null;
}
- 给 MQ发消息:流程如下:
以上秒杀流程最大的特点就是:流量削峰,不是每个请求过来都要去调用订单服务。
①引入 rabbitmq依赖
<!--引入 操作Rabbitmq依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
② application.properties配置
spring.rabbitmq.virtual-host=/
spring.rabbitmq.host=192.168.56.10
③ 关于 rabbitmq 的配置类
@Configuration
public class MyRabbitConfig {
/**
* 使用JSON序列化机制,进行消息转换
* @return
*/
@Bean
public MessageConverter messageConverter(){
return new Jackson2JsonMessageConverter();
}
}
④秒杀服务给 MQ发消息
秒杀的to数据准备(直接放在公共服务中)
SeckillOrderTo
@Data
public class SeckillOrderTo {
private String orderSn;//订单号
/**
* 活动场次id
*/
private Long promotionSessionId;
/**
* 商品id
*/
private Long skuId;
/**
* 秒杀价格
*/
private BigDecimal seckillPrice;
/**
* 秒杀总量
*/
private Integer num;
//会员id
private Long memberId;
}
发送消息
@Override
public String kill(String killId, String key, Integer num) {
long s1 = System.currentTimeMillis();
//从拦截器中获取当前用户信息
MemberRespVo respVo = LoginUserInterceptor.loginUser.get();
//1、获取当前秒杀商品的详细信息
BoundHashOperations<String, String, String> hashOps = redisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);
String json = hashOps.get(killId);
if (StringUtils.isEmpty(json)) {
return null;
} else {
SeckillSkuRedisTo redis = JSON.parseObject(json, SeckillSkuRedisTo.class);
//校验合法性
Long startTime = redis.getStartTime();
Long endTime = redis.getEndTime();
long time = new Date().getTime();
//过期时间
long ttl = endTime - startTime;
//1、校验时间的合法性
if (time >= startTime && time <= endTime) {
//2、校验随机码和商品id
String randomCode = redis.getRandomCode();
String skuId = redis.getPromotionSessionId() + "_" + redis.getSkuId();
if (randomCode.equals(key) && killId.equals(skuId)) {
//3、验证购物数量是否合理
if (num <= redis.getSeckillLimit().intValue()) {
//4、验证这个人是否已经购买过。幂等性:只要秒杀成功,就去占位。 userId_SessionId_skuId
//SETNX
String redisKey = respVo.getId() + "_" + skuId;
//自动过期
Boolean aBoolean = redisTemplate.opsForValue().setIfAbsent(redisKey, num.toString(), ttl, TimeUnit.MILLISECONDS);
if (aBoolean) {
//占位成功说明从来没有买过
RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMAPHORE + randomCode);
// 120 ms
boolean b = semaphore.tryAcquire(num);
if (b) {
//秒杀成功;
// 快速下单。发送MQ消息 10ms
String timeId = IdWorker.getTimeId();
SeckillOrderTo orderTo = new SeckillOrderTo();
orderTo.setOrderSn(timeId);
orderTo.setMemberId(respVo.getId());
orderTo.setNum(num);
orderTo.setPromotionSessionId(redis.getPromotionSessionId());
orderTo.setSkuId(redis.getSkuId());
orderTo.setSeckillPrice(redis.getSeckillPrice());
rabbitTemplate.convertAndSend("order-event-exchange", "order.seckill.order", orderTo);
long s2 = System.currentTimeMillis();
log.info("耗时...{},(s2-s1)");
return timeId;
}
return null;
} else {
//说明已经买过了
return null;
}
}
} else {
return null;
}
} else {
return null;
}
}
return null;
}
在订单服务设置一个队列和一个绑定关系:创建秒杀所需队列
订单服务中的 MyMQConfig
@Bean
public Queue orderSeckillOrderQueue(){
//String name, boolean durable, boolean exclusive, boolean autoDelete, Map<String, Object> arguments
Queue queue = new Queue("order.seckill.order.queue", true, false, false);
return queue;
}
@Bean
public Binding orderSeckillOrderBinding(){
//String destination, DestinationType destinationType, String exchange, String routingKey,
// Map<String, Object> arguments
return new Binding("order.seckill.order.queue",
Binding.DestinationType.QUEUE,
"order-event-exchange",
"order.seckill.order",
null);
}
监听队列:接收消息
SeckilOrderListener
package com.atguigu.gulimall.order.listener;
@Slf4j
@RabbitListener(queues = "order.seckill.order.queue")
@Component
public class SeckilOrderListener {
@Autowired
OrderService orderService;
/**
* 创建秒杀单
* @param seckillOrder
* @param channel
* @param message
* @throws IOException
*/
@RabbitHandler
public void listener(SeckillOrderTo seckillOrder, Channel channel, Message message) throws IOException {
try {
log.info("准备创建秒杀单的详细信息." +
"" +
"..");
orderService.createSeckillOrder(seckillOrder);
channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
} catch (Exception e) {
//失败了重新放到队列中,让其他消费者能够消费
channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
}
}
}
创建订单: createSeckillOrder
@Override
public void createSeckillOrder(SeckillOrderTo seckillOrder) {
//TODO 保存订单信息
OrderEntity orderEntity = new OrderEntity();
orderEntity.setOrderSn(seckillOrder.getOrderSn());
orderEntity.setMemberId(seckillOrder.getMemberId());
orderEntity.setStatus(OrderStatusEnum.CREATE_NEW.getCode());
BigDecimal multiply = seckillOrder.getSeckillPrice().multiply(new BigDecimal("" + seckillOrder.getNum()));
orderEntity.setPayAmount(multiply);
this.save(orderEntity);
//TODO 保存订单项信息
OrderItemEntity orderItemEntity = new OrderItemEntity();
orderItemEntity.setOrderSn(seckillOrder.getOrderSn());
orderItemEntity.setRealAmount(multiply);
//TODO 获取当前sku的详细信息进行设置 productFeignService.getSpuInfoBySkuId()
orderItemEntity.setSkuQuantity(seckillOrder.getNum());
orderItemService.save(orderItemEntity);
}
测试:点击 “立即抢购”:第一次成功
第二次直接返回null。
1.10.12 秒杀页面完成
这里先修改一下 商品详情页的显示 “立即抢购” 和 “加入购物车”的逻辑。
如果不是秒杀商品也要显示
加入购物车
。
①复制 购物车页面 的 success.html 页面
②引入 thymeleaf依赖
<!--模板引擎:thymeleaf-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
application.properties配置
spring.thymeleaf.cache=false
③修改 业务逻辑
SeckillController
除了 /kill
这个请求外,其他都是返回 JSON数据,而 /kill
跳转到 指定页面。
④编写秒杀成功页面
修改前缀:静态资源改为从购物车服务直接获取。
编写秒杀成功显示:
秒杀成功,显示订单号,以及跳转的支付页面;
秒杀失败,温馨提示。
<div class="main">
<div class="success-wrap">
<div class="w" id="result">
<div class="m succeed-box">
<div th:if="${orderSn != null}" class="mc success-cont">
<h1>恭喜,秒杀成功,订单号[[${orderSn}]]</h1>
<h2>正在准备订单数据,10s以后自动跳转支付
<a style="color: red" th:href="${'http://order.gulimall.com/payOrder?orderSn=' + orderSn}">去支付</a>
</h2>
</div>
<div th:if="${orderSn == null}" class="mc success-cont">
<h2>手气不好,秒杀失败,下次再来</h2>
</div>
</div>
</div>
</div>
</div>
成功:
失败:
上面我们已经完成了秒杀( 高并发) 系统关注的问题:
-
01 服务单一职责+独立部署
-
02 秒杀链接加密:使用随机码
-
03 库存预热+快速扣减:使用redis缓存,预热库存(信号量)
-
04 动静分离
-
05 恶意请求拦截:登录拦截器
-
08 队列削峰:秒杀成功给MQ发送消息(给订单服务发送消息,让其慢慢消费)
06 流量削峰 和 07 限流&熔断&降级没有完成。
以上可以保证高并发系统下 能够快速处理,但是不能保证稳定。
2 高级篇总结
基础篇注重的是 基本的增删改查的能力;搭建的是一个前后分离的环境来做整个后台管理系统的增删改查,其中穿插了一些技术:数据校验、对象存储、一些VO数据的封装等等...
高级篇:实现的是整个商城功能,而不是后台管理系统;其中业务有购物车、结账、详情、检索;将所有的功能都抽取成了一个微服务:也就是说在整个商城系统中,将每个业务拆分成了微服务,由许多微服务共同组合成商城;其中用到的技术和难点:分布式开发期间:核心掌握 SpringCloud组件:包括SpringCloud Alibaba、SpringCloud。
在Springcloud组件中使用的最为频繁的就是 feign远程调用:开启接口,声明feign客户端&&复制目标方法及其签名。
-
课件详解
01 响应式编程
只在最后网关SentinelGatewayConfig类中有使用:网关的sentinel的容错回调中使用了一下;
02 接口幂等性
分布式开发中最需要关注的就是接口幂等性问题:无论服务怎么调用,都希望接口是幂等的;这样我们无论调用多少次,结果都是一样的;这个是我们分布式系统里面业务功能接口的保证;可以通过加锁、数据库的乐观锁、悲观锁字段、加事务等都可以实现接口幂等性;我们在系统中使用最多的就是令牌机制【类似于验证码】:我给你发一个令牌,你下一次给我带上,用过一次就销毁。
03 事务
在分布式系统中,除了每个系统要解决自己的事务问题之外,分布式系统中调用还要有分布式事务的存在;在这个过程中,还顺带讲解了 springcloud alibaba 中的 seata
组件:它可以解决分布式事务,但是它最好用的地方在于 后台管理系统中使用:比如我们在后台管理系统中添加 一个商品,保存的时候需要将 它的优惠信息、库存等在系统中保存,要成功都成功,要失败都失败,【对于并发性能要求不高的,例如后台管理系统,我们可以使用seata 来做分布式事务】
;
但是对于高并发系统,我们使用的是最终一致性,所以分布式事务的最终解决方案就是 RabbitMQ:首先发送一个消息:【可靠消息的保证:发送端和消费端的两端确认机制】,将消息发送出去后,最终的系统只需要监听这些消息,完了之后根据消息改变我们系统的数据;【不能达到强一致,达到的是柔性事务的最终一致:最终看到的数据是我们想要的就行。】
04 性能与压力测试
对于高并发系统,使用Jmeter进行压力测试;在压测期间,还可以监控JVM的性能,【使用jvisualvm控制台,或者Jconsole
】。
05 缓存和分布式锁
特别在分布式系统中,我们要保证接口的吞吐量,性能的提升【缓存是必须的】,所以在高并发系统中,缓存是一个很重要的提升性能的手段,但是使用缓存期间,也有一些问题:缓存的击穿【缓存的单点问题】,缓存雪崩【缓存的大面积问题】,缓存穿透【一直从数据库查询null值】:解决方案:使用分布式
。
当大并发量全部要来查询数据库,大家先来查询缓存,使用分布式锁来锁住,只有一个请求进来,查询不到缓存再来查询数据库,最终的效果就是只会给数据库放一条查询,而其他人得到锁之后,下次只会来查询缓存,也就不用再去查询数据库了,【分布式锁除了缓存问题使用外,接口幂等性的保证也可以使用】
比如现在有 几个并发的请求,都来调用了一个接口,这个接口只能调用一次,这一次数据是怎么样,那就是怎么样:我们释放锁之后,如果再来调用,就判断一下数据的状态,如果已经被处理了,那就不再处理。
定时任务中也有使用:比如定时启动了3台机器,这3台机器定时同时启动了一个任务,比如都来上架我们的秒杀商品,只要我们使用了分布式锁,我们上架过了,就不需要再次 上架;分布式锁可以来锁住我们所有的机器,让
一个机器来执行这个事情即可。
06 ElasticSearch
我们商品的检索肯定不能放在mysql中进行,通过写一些SQL执行,这样性能会大幅度下降;我们的检索功能:无论是分类检索,还是按照名字检索;所有的检索数据都保存在ElasticSearch中【一个非常专业的检索引擎】,注意它的安装使用都需要非常清楚。
07 异步和线程池
在高并发系统中,异步是非常必须的:我们复习了以前那种简单的 new Thread start
这种简单的异步,如果在高并发系统中,我们每一个请求进来都new Thread start
,这样资源将很快耗尽;所以为了控制住系统的资源,我们使用线程池:以后所有的异步任务都要提交给线程池;这样的话,线程池中就有一个最大量,比如核心是20个线程,最大是200个,排队可以排500个、800个、乃至更多,我们通过线程池控制住资源,峰值也就是200个正在运行的,以及800个正在排队的,再也不可能占用更多的资源。这样有了资源的统一管控后,我们就不用害怕因为系统的某些资源耗尽导致系统崩溃。
我们使用异步,将任务提交给线程池,但是可能异步任务中有顺序,我们可以使用异步编排CompetableFuture。
08 单点登录和社交登录
我们着重演示了使用微博进行社交登录,此外还演示了单点登录,比如我们在不同域名下如何登录。但是我们后来是相同域名的,所以我们暂时可以不用使用单点登录。
但是在相同域名下,登录一处,在处处都可以实现单点登录效果,这时我们就整合了 spring session:一处登录,处处可用。【将所有微服务的session进行了同步,只要登录成功之后,去任何微服务,都可以获取到session】---> 使用spring session 解决了分布式系统session不一致问题。
09 商城业务
特别是购物车、订单、秒杀等,所有的业务都做了实现,但是一些细节需要我们自己填充。核心业务:商品上架【后台管理系统】、商品检索、商品详情、购物车、订单、秒杀。其中在商品详情里面,使用最多的是缓存技术:除了整合redis,还整合了springCache
。
整合springCache来方便的使用缓存,以后我们的全系统都应该使用这种方式来进行缓存。
缓存出现的不一致问题该如何解决
:缓存的清空以及缓存的更新,使用spring cache 都可以很方便的解决这些问题。
10 RabbitMQ
做分布式事务,来实现最终一致性的时候,rabbitmq是一个非常必要的工具。
我们只需要给A服务给B服务发送一个消息,A服务不用关心怎么做。【在订单系统中,我们使用rabbitmq来完成分布式事务】;【在秒杀系统中,加入rabbitmq队列来进行削峰处理:将所有的流量排队放到队列中,由后台慢慢的进行消费】。此外,通过rabbitmq,A服务和B服务不用关心接口调用了,A都不需要调用B,相当于我们将应用之间进行了解耦。
在订单的关单中关于rabbitmq的使用:我们通过死信队列,保证关单的数据都能被关掉。
11 支付
我们整合了支付宝的沙箱来进行支付。
12 定时任务与分布式调度
我们秒杀系统的所有上架,都需要定时任务来做。
13 ShardingSphere
对于 13 分库分表 ShardingSphere在高可用做mysql集群时在使用。
14 SpringCloud组件
SpringCloud中的组件都需要进行掌握:除了nacos作为注册中心我们在一直使用外,配置中心只进行了演示;以后我们需要将所有微服务的配置都放给配置中心,【通过nacos的控制台也可以修改配置,修改完了之后微服务可以在不下线的情况下,应用到最新的配置】;
最后,我们为了保护整个系统,引入了 sentinel
【作为一个流量哨兵】:从流量入口开始,保护我们任何想要保护的资源;当然,我们最好结合 Sleuth + Zipkin进行服务链路追踪
,这样可以看到我们整个系统的运行状况,链路追踪。可以找出一些异常问题,通过sentinel流量哨兵进行服务的降级,保护整个系统。
技术中最重要的是使用缓存来加快速度。异步也是来加快速度和控制资源。此外通过消息队列,无论是流量削峰,还是分布式做最终一致也好,我们都可以通过消息队列来减轻某一个服务的压力【所有消息存入队列中,闲的时候再来慢慢消费】。
在高级篇构建一个高并发系统,除了引入springcloud组件或者是SpringCloud Alibaba来作为周边设施
外,高并发有三宝:
缓存、异步、队排好。
缓存就是使用redis作为缓存,保证它的缓存一致性,保证缓存的击穿、穿透等这些问题不会出现;
异步结合线程池,不能只是new Tread start;结合线程池以及异步编排来作为整个异步功能。
队排好:使用rabbitmq将所有的高峰流量,或者要做分布式事务的这些消息,全都放进rabbitmq消息队列中,让他们排好队,后台服务一个个处理。
加上这3个手段,构建高并发系统并不难。