分布式高级篇2 —— 商城业务 (1)

一、商品上架

视频来源: 【Java项目《谷粒商城》Java架构师 | 微服务 | 大型电商项目】

完整代码 yangzhaoguang/gilimall: 尚硅谷——谷粒商城项目 (github.com)
对应视频集数:P128~247

1、sku 在 ES 中存储模型分析

再点击上架时,商品信息保存在 ES 中,并且可供商城页面可供检索。

image-20230112170843567

因此我们必须考虑这些商品信息该如何在 ES 中存储?

1)、检索的时候输入名字,是需要按照 sku 的 title 进行全文检索的

2)、检索使用商品规格,规格是 spu 的公共属性,每个 spu 是一样的

3)、按照分类 id 进去的都是直接列出 spu 的,还可以切换。

4)、我们如果将 sku 的全量信息保存到 es 中(包括 spu 属性)就太多量字段了。

5)、我们如果将 spu 以及他包含的 sku 信息保存到 es 中,也可以方便检索。但是 sku 属于 spu 的级联对象,在 es 中需要 nested

6)、但是存储与检索我们必须性能折中。

7)、如果我们分拆存储,spu 和 attr 一个索引,sku 单独一个索引可能涉及的问题。 检索商品的名字,如“手机”,对应的 spu 有很多,我们要分析出这些 spu 的所有关联属性, 再做一次查询,就必须将所有 spu_id 都发出去。假设有 1 万个数据,数据传输一次就 10000*4=4MB;并发情况下假设 1000 检索请求,那就是 4GB 的数据,,传输阻塞时间会很 长,业务更加无法继续。

所以,我们如下设计,这样才是文档区别于关系型数据库的地方,宽表设计,不能去考虑数 据库范式。

{
	"mappings": {
		"properties": {
			"skuId": {
				"type": "long"
			},
			"spuId": {
				"type": "keyword"
			},
			"skuTitle": {
				"type": "text",
				"analyzer": "ik_smart"
			},
			"skuPrice": {
				"type": "keyword"
			},
			"skuImg": {
				"type": "keyword",
				"index": false,
				"doc_values": false
			},
			"saleCount": {
				"type": "long"
			},
			"hasStock": {
				"type": "boolean"
			},
			"hotScore": {
				"type": "long"
			},
			"brandId": {
				"type": "long"
			},
			"catalogId": {
				"type": "long"
			},
			"brandName": {
				"type": "keyword",
				"index": false,
				"doc_values": false
			},
			"brandImg": {
				"type": "keyword",
				"index": false,
				"doc_values": false
			},
			"catalogName": {
				"type": "keyword",
				"index": false,
				"doc_values": false
			},
			"attrs": {
				"type": "nested",
				"properties": {
					"attrId": {
						"type": "long"
					},
					"attrName": {
						"type": "keyword",
						"index": false,
						"doc_values": false
					},
					"attrValue": {
						"type": "keyword"
					}
				}
			}
		}
	}
}

index: 默认 true,如果为 false,表示该字段不会被索引,但是检索结果里面有,但字段本身不能 当做检索条件。

doc_values: 默认 true,设置为 false,表示不可以做排序、聚合以及脚本操作,这样更节省磁盘空间。 还可以通过设定 doc_values 为 true,index 为 false 来让字段不能被搜索但可以用于排序、聚 合以及脚本操作:

2、nested 嵌套类型

嵌套类型是对象数据类型的一种特殊版本,它允许对象数组以可以彼此独立查询的方式进行索引。

内部对象字段数组的工作方式与预期不同。Lucene没有内部对象的概念,因此Elasticsearch将对象层次结构简化为一个简单的字段名和值列表。例如:

PUT my_index/_doc/1
{
  "group" : "fans",
  "user" : [ 
    {
      "first" : "John",
      "last" :  "Smith"
    },
    {
      "first" : "Alice",
      "last" :  "White"
    }
  ]
}

user 保存的是一个数组,数组中有多个对象,但是实际上在将在内部转换为更像这样的文档:

{
  "group" :        "fans",
  "user.first" : [ "alice", "john" ],
  "user.last" :  [ "smith", "white" ]
}

image-20230112174123323

如果需要为对象数组编制索引并保持数组中每个对象的独立性,则应使用嵌套数据类型而不是对象数据类型。

PUT my_index
{
  "mappings": {
    "properties": {
      "user": {
        "type": "nested" 
      }
    }
  }
}

此时再进行检索时:

GET my_index/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "user.first": "Alice"
          }
        },
        {
          "match": {
            "user.last": "Smith"
          }
        }
      ]
    }
  }
}
=========================================== 结果
{
  "took" : 1,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 0,
      "relation" : "eq"
    },
    "max_score" : null,
    "hits" : [ ]
  }
}

3、商品上架业务代码

1、根据 存储模型构建 JavaBean 对象

@Data
public class SkuEsModel {
    // Sku 的基本属性
    private Long skuId;
    private Long spuId;
    private String skuTitle;
    private BigDecimal skuPrice;
    private String skuImg;
    private Long saleCount;
    
    // 是否有库存,需要调用 ware 服务
    private  Boolean hasStock;
    
    private Long hotScore;
    
    // 通过 sku 基本信息中的品牌id查询品牌信息
    private Long brandId;
    private String brandImg;
    private String brandName;
    
    // 通过 sku 基本信息中的分类id查询分类信息
    private Long catalogId;
    private String catalogName;
    
    // sku可检索的属性,
    private List<Attrs> attrs;

    @Data
    public static class Attrs {
        private Long attrId;
        private String attrName;
        private String attrValue;
    }
}

2、访问接口路径

POST
/product/spuinfo/{spuId}/up

思路分析

我们可以得到 spuId:

  • 通过 spuId 可以在 pms_sku_info 表中查询出 sku 的基本信息,并将一部分数据封装在 SkuEsModel 中
  • pms_sku_info 表中保存 brandId,catelogId ,直接在品牌表,分类表查出品牌名、分类名即可
  • 查询出 sku 可检索的属性:
    • 根据 spuId 在 pms_product_attr_value 表中查询spu对应的所有属性 id
    • 根据查出来的属性 id 在 pms_attr 中查询 search_type =1 的属性,并封装在 SkuEsModel 中
  • 查看sku对应的商品是否有库存,远程调用 ware 服务查询 wms_ware_sku
  • 将 SkuEsModel 封装好后,发送给 gulimall-seartch 服务向 ES 发送请求
  • 如果 商品上架成功还需要更新 商品的发布状态 pms_spu_info 表中的 publish_status 字段

(1)构建 sku 基本属性

1、SpuInfoController

    /**
     * 商品上架
     *  /product/spuinfo/{spuId}/up
     */
    @RequestMapping("/{spuId}/up")
    //@RequiresPermissions("com.atguigu.gulimall.product:spuinfo:list")
    public R up(@PathVariable("spuId")Long spuId){
        spuInfoService.up(spuId);
        return R.ok();
    }

2、SpuInfoServiceImpl

    /**
     * 商品上架功能
     *
     *  private BigDecimal skuPrice;
     *  private String skuImg;
     *  private  Boolean hasStock;
     *  private Long hotScore;
     *  private Long brandId;
     *  private Long catalogId;
     *  private String brandName;
     *  private String brandImg;
     *  private String catalogName;
     *  @Data
     *  public static class Attrs {
     *      private Long attrId;
     *      private String attrName;
     *      private String attrValue;
     * */
    @Override
    public void up(Long spuId) {
        // TODO 1、构建SKU基本信息
        List<SkuInfoEntity> skuInfoEntities = skuInfoService.list(new QueryWrapper<SkuInfoEntity>().eq("spu_id", spuId));
       
        List<SkuEsModel> esModelList = skuInfoEntities.stream().map(sku -> {
            SkuEsModel skuEsModel = new SkuEsModel();
            BeanUtils.copyProperties(sku, skuEsModel);
            // 以下俩项属性名不一致,手动设置
            skuEsModel.setSkuPrice(sku.getPrice());
            skuEsModel.setSkuImg(sku.getSkuDefaultImg());
            // TODO 2、查询是否有库存,需要远程调用 private  Boolean hasStock;
            // TODO 3、设置热度评分
            skuEsModel.setHotScore(0L);
            // TODO 4、 查询品牌、以及品牌信息 brandImg、brandName catalogName
            BrandEntity brandEntity = brandService.getById(sku.getBrandId());
            skuEsModel.setBrandName(brandEntity.getName());
            skuEsModel.setBrandImg(brandEntity.getLogo());
            CategoryEntity categoryEntity = categoryService.getById(sku.getCatelogId());
            skuEsModel.setCatalogName(categoryEntity.getName());
            skuEsModel.setCatalogId(sku.getCatelogId());
            // TODO 5、查询 sku 可检索的属性

            return skuEsModel;
        }).collect(Collectors.toList());

            // TODO 6、 向 ES 中发送请求进行保存 gulimall-seartch


    }

(2)构造 sku 检索属性

sku 的检索属性是根据 pms_attr 表中的 search_type 字段决定是否可以检索。

SpuInfoServiceImpl

   @Override
    public void up(Long spuId) {

        // TODO 5、查询 sku 可检索的属性
        List<ProductAttrValueEntity> attrValueEntities = productAttrValueService.listBaseAttrForSpu(spuId);
        // sku对应所有的属性id 集合
        List<Long> attrIds = attrValueEntities.stream().map(ProductAttrValueEntity::getAttrId).collect(Collectors.toList());

        // 可检索的属性
        List<AttrEntity> attrEntities =
                attrService.list(new QueryWrapper<AttrEntity>().in("attr_id", attrIds).eq("search_type", 1));
        // 可检索属性id的集合
        List<Long> searchAttrIds = attrEntities.stream().map(AttrEntity::getAttrId).collect(Collectors.toList());

        /*
        * 先过滤掉不可检索的 属性
        * 在封装数据
        * */
        List<SkuEsModel.Attrs> attrsList = attrValueEntities.stream()
                .filter(item -> searchAttrIds.contains(item.getAttrId()))
                .map(item -> {
                    SkuEsModel.Attrs attrs = new SkuEsModel.Attrs();
                    BeanUtils.copyProperties(item, attrs);
                    return attrs;
                }).collect(Collectors.toList());


        List<SkuInfoEntity> skuInfoEntities = skuInfoService.list(new QueryWrapper<SkuInfoEntity>().eq("spu_id", spuId));
        
        List<SkuEsModel> esModelList = skuInfoEntities.stream().map(sku -> {
            SkuEsModel skuEsModel = new SkuEsModel();
            // 1、构建SKU基本信息
            BeanUtils.copyProperties(sku, skuEsModel);
            skuEsModel.setSkuPrice(sku.getPrice());
            skuEsModel.setSkuImg(sku.getSkuDefaultImg());
            // TODO 2、查询是否有库存,需要远程调用 private  Boolean hasStock;
            // TODO 3、设置热度评分
            skuEsModel.setHotScore(0L);
            // TODO 4、 查询品牌、以及品牌信息 brandImg、brandName catalogName
            BrandEntity brandEntity = brandService.getById(sku.getBrandId());
            skuEsModel.setBrandName(brandEntity.getName());
            skuEsModel.setBrandImg(brandEntity.getLogo());
            CategoryEntity categoryEntity = categoryService.getById(sku.getCatelogId());
            skuEsModel.setCatalogName(categoryEntity.getName());
            skuEsModel.setCatalogId(sku.getCatelogId());
            // 设置可检索的属性
            skuEsModel.setAttrs(attrsList);
            return skuEsModel;
        }).collect(Collectors.toList());

        // TODO: 向 ES 中发送请求进行保存 gulimall-seartch


    }

(3)远程调用查询库存

远程调用 gulimall-ware 查询 sku 对应的商品是否有库存,将 skuId 封装 list 集合,进行统一查询,否则一次一次查询太消耗时间。

我将查询库存的返回值直接封装成了 map 集合,skuId 作为 key,是否有库存作为 value

1、 WareSkuController

    /*
    * 查询sku是否有库存
    * */
    @PostMapping("/hasStock")
    public HashMap<Long, Boolean> hasStock(@RequestBody List<Long> skuIds) {
        HashMap<Long, Boolean> map =  wareSkuService.getSkusHasStock(skuIds);
        return  map;
    }

2、 WareSkuServiceImpl

    /*
    * 查询 sku 是否有库存
    * */
    @Override
    public HashMap<Long, Boolean> getSkusHasStock(List<Long> skuIds) {

        HashMap<Long, Boolean> map = new HashMap<>();
        for (Long skuId : skuIds) {
            // 查询库存: 当前库存 - 锁定库存
            // SELECT SUM(stock - stock_locked) FROM `wms_ware_sku` WHERE sku_id = 1
            Integer count = baseMapper.getSkusHasStock(skuId);
            map.put(skuId,count>0);
        }

        return map;
    }

3、WareSkuDao.xml

库存 = 当前库存 - 锁定库存

    <select id="getSkusHasStock" resultType="java.lang.Integer">
        SELECT SUM(stock - stock_locked) FROM `wms_ware_sku` WHERE sku_id = #{id}
    </select>

4、Product 提供 Feign 接口

@FeignClient("gulimall-ware")
public interface WareFeignService {
    /*
    * 查询 sku 是否有库存
    * */
    @PostMapping("ware/waresku/hasStock")
    public HashMap<Long, Boolean> hasStock(@RequestBody List<Long> skuIds);
}

5、SpuInfoServiceImpl

 @Override
    public void up(Long spuId) {

        // TODO 5、查询 sku 可检索的属性
        List<ProductAttrValueEntity> attrValueEntities = productAttrValueService.listBaseAttrForSpu(spuId);
        // sku对应所有的属性id 集合
        List<Long> attrIds = attrValueEntities.stream().map(ProductAttrValueEntity::getAttrId).collect(Collectors.toList());

        // 可检索的属性
        List<AttrEntity> attrEntities =
                attrService.list(new QueryWrapper<AttrEntity>().in("attr_id", attrIds).eq("search_type", 1));
        // 可检索属性id的集合
        List<Long> searchAttrIds = attrEntities.stream().map(AttrEntity::getAttrId).collect(Collectors.toList());

        /*
        * 先过滤掉不可检索的 属性
        * 在封装数据
        * */
        List<SkuEsModel.Attrs> attrsList = attrValueEntities.stream()
                .filter(item -> searchAttrIds.contains(item.getAttrId()))
                .map(item -> {
                    SkuEsModel.Attrs attrs = new SkuEsModel.Attrs();
                    BeanUtils.copyProperties(item, attrs);
                    return attrs;
                }).collect(Collectors.toList());


        List<SkuInfoEntity> skuInfoEntities = skuInfoService.list(new QueryWrapper<SkuInfoEntity>().eq("spu_id", spuId));
        // TODO 2、查询是否有库存,需要远程调用 private  Boolean hasStock;
        List<Long> skuIds = skuInfoEntities.stream().map(SkuInfoEntity::getSkuId).collect(Collectors.toList());
        // key是skuid,value表示是否有库存

        Map<Long, Boolean> hasStockMap = null;
        try {
            // 远程调用可能会出现异常
            hasStockMap = wareFeignService.hasStock(skuIds);
        } catch (Exception e) {
            e.printStackTrace();
            log.error("远程调用出现异常:{}", e);
        }

        Map<Long, Boolean> finalHasStockMap = hasStockMap;
        List<SkuEsModel> esModelList = skuInfoEntities.stream().map(sku -> {
            SkuEsModel skuEsModel = new SkuEsModel();
            // 1、构建SKU基本信息
            BeanUtils.copyProperties(sku, skuEsModel);
            skuEsModel.setSkuPrice(sku.getPrice());
            skuEsModel.setSkuImg(sku.getSkuDefaultImg());
            // 设置库存
            skuEsModel.setHasStock(finalHasStockMap == null || finalHasStockMap.get(sku.getSkuId()));
            // TODO 3、设置热度评分
            skuEsModel.setHotScore(0L);
            // TODO 4、 查询品牌、以及品牌信息 brandImg、brandName catalogName
            BrandEntity brandEntity = brandService.getById(sku.getBrandId());
            skuEsModel.setBrandName(brandEntity.getName());
            skuEsModel.setBrandImg(brandEntity.getLogo());
            CategoryEntity categoryEntity = categoryService.getById(sku.getCatelogId());
            skuEsModel.setCatalogName(categoryEntity.getName());
            skuEsModel.setCatalogId(sku.getCatelogId());
            // 设置可检索的属性
            skuEsModel.setAttrs(attrsList);
            return skuEsModel;
        }).collect(Collectors.toList());

        // TODO: 向 ES 中发送请求进行保存 gulimall-seartch

    }

(4) 远程调用上架接口

调用 gulimall-seartch 服务,向 ES 发送请求增加索引

1、新增错误异常码

image-20230112225313778

2、创建商品 状态枚举类

    public enum ProductPublishStatusEnum{
        NEW_SPU(0,"新建"),
        UP_SPU(1,"上架"),
        DOWN_SPU(2,"下架");

        private int code ;
        private String msg ;

        ProductPublishStatusEnum(int code, String msg) {
            this.code = code;
            this.msg = msg;
        }

        public int getCode() {
            return code;
        }

        public String getMsg() {
            return msg;
        }
    }

3、ElasticSaveController

@RestController
@RequestMapping("/search/save")
@Slf4j
public class ElasticSaveController {

    @Autowired
    private ProductSaveService productSaveService;

    @PostMapping("/product")
    public R productStatusUp(@RequestBody List<SkuEsModel> skuEsModels) {

        // 返回上架状态
        Boolean b = false;
        try {
            b = productSaveService.productStatusUp(skuEsModels);
        } catch (IOException e) {
            // 上架失败
            e.printStackTrace();
           return R.error(BizCodeEnum.PRODUCT_UP.getCode(), BizCodeEnum.PRODUCT_UP.getMessage());
        }


         return b ? R.error(BizCodeEnum.PRODUCT_UP.getCode(), BizCodeEnum.PRODUCT_UP.getMessage()) : R.ok();
    }
}

4、 ProductSaveServiceImpl

使用 批量请求的API,避免请求次数过多造成时间浪费。

bulk 批量请求,可通过 BulkRequest 中的 add 方法增加 索引请求。

bulk 批量请求,能够返回整体的响应结果

@Service
@Slf4j
public class ProductSaveServiceImpl implements ProductSaveService {

    @Autowired
    private RestHighLevelClient restHighLevelClient;
    /*
    * 商品上架,将商品信息保存到 ES 中
    * */
    @Override
    public Boolean productStatusUp(List<SkuEsModel> skuEsModels) throws IOException {

        // 1、增加索引
        // BulkRequest bulkRequest
        BulkRequest bulkRequest = new BulkRequest(EsConstant.PRODUCT_INDEX);
        for (SkuEsModel skuEsModel : skuEsModels) {
            // 构建批量请求
            IndexRequest indexRequest = new IndexRequest();
            indexRequest.id(skuEsModel.getSkuId().toString());
            indexRequest.source(JSON.toJSONString(skuEsModel), XContentType.JSON);
            bulkRequest.add(indexRequest);
        }
        // 批量请求
        BulkResponse bulk = restHighLevelClient.bulk(bulkRequest, GulimallElasticSearchConfig.COMMON_OPTIONS);
        // 没有错误返回 false,有错误返回 true
        boolean hasFailures = bulk.hasFailures();
        if (hasFailures) {
            BulkItemResponse[] items = bulk.getItems();
            List<Integer> itemIds = Arrays.stream(items).map(BulkItemResponse::getItemId).collect(Collectors.toList());
            log.error("商品上架出现异常: {}", itemIds);
        }

        return hasFailures  ;
    }
}

5、Procut 服务 创建 Feign 接口

@FeignClient("gulimall-seartch")
public interface SearchFeignService {

    /*
    * 向 ES 发送请求,保存商品上架信息
    * */
    @PostMapping("/search/save/product")
    public R productStatusUp(@RequestBody List<SkuEsModel> skuEsModels);
}

6、SpuInfoServiceImpl

 @Override
    public void up(Long spuId) {

        // TODO 5、查询 sku 可检索的属性
        List<ProductAttrValueEntity> attrValueEntities = productAttrValueService.listBaseAttrForSpu(spuId);
        List<Long> attrIds = attrValueEntities.stream().map(ProductAttrValueEntity::getAttrId).collect(Collectors.toList());
        // 可检索的属性
        List<AttrEntity> attrEntities =
                attrService.list(new QueryWrapper<AttrEntity>().in("attr_id", attrIds).eq("search_type", 1));

        // 将属性值、属性名、属性id 封装到 Attrs
        List<SkuEsModel.Attrs> attrsList = attrValueEntities.stream().map(item -> {
            SkuEsModel.Attrs attrs = new SkuEsModel.Attrs();
            BeanUtils.copyProperties(item, attrs);
            return attrs;
        }).collect(Collectors.toList());


        List<SkuInfoEntity> skuInfoEntities = skuInfoService.list(new QueryWrapper<SkuInfoEntity>().eq("spu_id", spuId));
        // TODO 2、查询是否有库存,需要远程调用 private  Boolean hasStock;
        List<Long> skuIds = skuInfoEntities.stream().map(SkuInfoEntity::getSkuId).collect(Collectors.toList());
        // key是skuid,value表示是否有库存

        Map<Long, Boolean> hasStockMap = null;
        try {
            // 远程调用可能会出现异常
            hasStockMap = wareFeignService.hasStock(skuIds);
        } catch (Exception e) {
            e.printStackTrace();
            log.error("远程调用出现异常:{}", e);
        }

        Map<Long, Boolean> finalHasStockMap = hasStockMap;
        List<SkuEsModel> esModelList = skuInfoEntities.stream().map(sku -> {
            SkuEsModel skuEsModel = new SkuEsModel();
            // 1、构建SKU基本信息
            BeanUtils.copyProperties(sku, skuEsModel);
            skuEsModel.setSkuPrice(sku.getPrice());
            skuEsModel.setSkuImg(sku.getSkuDefaultImg());
            // 设置库存
            skuEsModel.setHasStock(finalHasStockMap == null || finalHasStockMap.get(sku.getSkuId()));
            // TODO 3、设置热度评分
            skuEsModel.setHotScore(0L);
            // TODO 4、 查询品牌、以及品牌信息 brandImg、brandName catalogName
            BrandEntity brandEntity = brandService.getById(sku.getBrandId());
            skuEsModel.setBrandName(brandEntity.getName());
            skuEsModel.setBrandImg(brandEntity.getLogo());
            CategoryEntity categoryEntity = categoryService.getById(sku.getCatelogId());
            skuEsModel.setCatalogName(categoryEntity.getName());
            // 设置可检索的属性
            skuEsModel.setAttrs(attrsList);
            return skuEsModel;
        }).collect(Collectors.toList());

        // TODO: 向 ES 中发送请求进行保存 gulimall-seartch
        R r = searchFeignService.productStatusUp(esModelList);
        if (r.getCode() == 0) {
        //     上架成功,还需要修改商品的发布状态
            baseMapper.updateProductPublishStatus(spuId, ProductConstant.ProductPublishStatusEnum.UP_SPU.getCode());
        } else {
            // 上架失败
            //     TODO: 可能出现的问题,重复上架,接口幂等性。。

        }

    }

(5)上架接口测试

1、ES 批量请求中的响应,hasFailures 方法是没有错误返回 false ,有错误才返回 true ,掉坑里了made

image-20230113173017834

2、在判断商品库存时,有可能没有库存返回 null ,因此还要加一个 对 null 的判断

image-20230113173255216

3、Feign 请求过程

1、构造请求数据,将对象转为json;
	RequestTemplate template = buildTemplateFromArgs.create(argv);
2、发送请求进行执行(执行成功会解码响应数据):
	executeAndDecode( template);
3、执行请求会有重试机制
    while(true){
    try {
        executeAndDecode( template) ;
    }catch( ){
            tryiretryer.continueOrPropagate(e);
        }catch(){throw ex;}
            continue;
        }
    }

二、首页

1、整合 Thymeleaf 渲染首页

商城前台页面使用 Nginx 实现动静分离,将页面放到对应的微服务中,使用 Thymeleaf 渲染。

image-20230113223411562

1、引入 thymeleaf 依赖

        <dependency>
            <!--thymeleaf-->
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>

2、关闭 缓存

spring:     
    thymeleaf:
        cache: false

3、将静态资源放在 classpath:/static 目录下,页面放在 templates 目录下

image-20230118155729179

2、整合 dev-tools 渲染一级分类

1、增加开发者工具,更新页面不需要重启服务。只需要按 F9 自动更新

        <!--开发中工具-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <optional>true</optional>
        </dependency>

2、获取一级分类

    @GetMapping({"/","index"})
    public String indexPage(Model model) {
        // 查询所有一级分类
        List<CategoryEntity> categoryEntities = categoryService.getLevelOne();
        // 保存到请求域中
        model.addAttribute("categorys",categoryEntities);
        // 视图解析器会进行解析: classpath:/templates + index + .html
        return "index";
    }

实现类:

    /*
    * 首页显示分类
    * */
    @Override
    public List<CategoryEntity> getLevelOne() {

        return this.list(new QueryWrapper<CategoryEntity>().eq("cat_level", 1));

    }

3、在 html 中增加 thymeleaf 标签库

<html lang="en" xmlns:th="http://www.thymeleaf.org">

4、循环遍历一级分类

th:attr 自定义属性,并指定属性值

		<!-- 一级分类 -->
          <ul>
            <li th:each="category : ${categorys}">
              <a href="#" class="header_main_left_a"  th:attr="ctg-data=${category.catId}"><b th:text="${category.name}"></b></a>
            </li>
          </ul>

3、渲染二级、分级分类

1、在 catalogLoader.js 中 向 该路径:index/json/catalog.json 发送请求。

image-20230118165302452

得到 json 数据的格式:

以 一级分类id作为key

二级分类包含:一级分类ID,二级分类ID,二级分类名字,三级分类集合

三级分类包含:二级分类ID,三级分类ID,三级分类名字

image-20230118165940147

2、按照以上的 JSON 格式,封装一个 Vo 对象

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Catelog2Vo {
    // 一级分类 id
    private String catalog1Id;
    // 三级分类集合
    private List<Catelog3Vo> catalog3List;
    // 二级分类id
    private String id;
    // 二级分类名字
    private String name;

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    static class Catelog3Vo{
        // 二级分类id
        private String catalog2Id;
        // 三级分类 id
        private String id;
        // 三级分类名字
        private String name;
    }
}

3、将三级分类封装一个 Map 集合

查询出所有的一级分类

根据一级分类的 id 找出二级分类

封装二级分类

根据二级分类 id 找出三级分类

封装三级分类

    /*
    * 首页渲染二级、三级分类
    * */
    @GetMapping("index/json/catalog.json")
    @ResponseBody
    public Map<String, List<Catelog2Vo>> getCatelog2Vo() {
        Map<String, List<Catelog2Vo>> map = categoryService.getCatelog2Vo();
        return  map;
    }

实现类:

 /*
    * 首页渲染二级、三级分类
    * */
    @Override
    public Map<String, List<Catelog2Vo>> getCatelog2Vo() {
        // 1、查询出所有的一级分类
        List<CategoryEntity> levelOneList = this.getLevelOne();

        // 将结果封装成一个 map
        Map<String, List<Catelog2Vo>> map = levelOneList.stream().collect(Collectors.toMap(k -> k.getCatId().toString(), v -> {
            // 2、查询出所有的二级分类
            List<CategoryEntity> levelTwoList = this.list(new QueryWrapper<CategoryEntity>().eq("parent_cid", v.getCatId()));

            List<Catelog2Vo> catelog2VoList = null;
            if (levelTwoList != null) {
                // 3、封装二级分类
                catelog2VoList = levelTwoList.stream().map(levelTwo -> {

                    Catelog2Vo catelog2Vo =
                            new Catelog2Vo(levelTwo.getParentCid().toString(), null, levelTwo.getCatId().toString(), levelTwo.getName());
                    // 4、查询出所有的三级分类
                    List<CategoryEntity> levelThreeList = this.list(new QueryWrapper<CategoryEntity>().eq("parent_cid", catelog2Vo.getId()));

                    if (levelThreeList != null) {
                        // 5、封装三级分类
                        List<Catelog2Vo.Catelog3Vo> catalog3List = levelThreeList.stream().map(levelThree -> {
                            Catelog2Vo.Catelog3Vo catelog3Vo =
                                    new Catelog2Vo.Catelog3Vo(levelTwo.getCatId().toString(), levelThree.getCatId().toString(), levelThree.getName());
                            return catelog3Vo;
                        }).collect(Collectors.toList());

                        catelog2Vo.setCatalog3List(catalog3List);
                    }
                    return catelog2Vo;
                }).collect(Collectors.toList());
            }

            return catelog2VoList;
        }));
        return map;
    }

4、Nginx搭建域名访问环境

通过 Nginx 帮助我们进行反向代理,所有来自gulimal.com 的请求,都代理到商品服务中。

image-20230118182505488

正向代理 VS 反向代理

针对我们本机,正向代理是代理我们自己的计算机访问一些网站,反向代理是代理的服务器,避免暴露服务器的内部 IP 以及端口

image-20230118182739831


1、由于我所使用的是本地的虚拟机,是私有ip地址,只能通过修改本机的 hosts 文件,增加 ip 地址与域名的映射。

image-20230118183053697

在我们访问 gulimall.com 域名时,会首先查找本机中的 hosts 文件是否有映射关系,其次是使用 DNS 解析

2、修改 nginx 配置文件,利用域名进行反向代理

image-20230118184216592

此时在我们本机上就可以通过域名访问 网站 首页了,但是这样还是有点问题,我们是通过端口发送请求,但是一个服务有可能不会部署在同一台机器上,端口上也有可能不一样, 因此我们可以使用 Nginx 的负载均衡,将请求转发给网关,有网关进行请求处理…

3、Nginx 配置反向代理

image-20230118185825410

image-20230118185835106

4、配置网关,接受请求

                - id: host_route
                  uri: lb://gulimall-product # 负载均衡
                  predicates:
                      - Host=**.gulimall.com

BUG

配置了Nginx 反向代理 和 网关,但仍然报错:404 找不到页面,这是因为在 Nginx 向网关发送请求时会屏蔽掉一些内容,其中就包括 Host 信息,网关就根据Host进行断言,当然访问不到

配置以下内容,允许Nginx携带host信息:

image-20230118191146384

完整的流程

1、本地访问域名,先去 hosts 文件中查找对应的映射关系,映射到 192.168.56.111 虚拟机中

2、而再虚拟机中我们又配置了 Nginx,通过 反向代理 + 负载均衡,将请求转发给了网关

3、网关接受请求,我们在网关中由配置了路由,会转发给对应的微服务。

Nginx配置文件解释

image-20230118185638992

三、性能压测

1、压力测试

压力测试考察当前软硬件环境下系统所能承受的最大负荷并帮助找出系统瓶颈所在。压测都 是为了系统在线上的处理能力和稳定性维持在一个标准范围内,做到心中有数。

使用压力测试,我们有希望找到很多种用其他测试方法更难发现的错误。有两种错误类型是:

内存泄漏,并发与同步

有效的压力测试系统将应用以下这些关键条件:重复并发量级随机变化

(1)性能指标

  • 响应时间(Response Time: RT)
    • 响应时间指用户从客户端发起一个请求开始,到客户端接收到从服务器端返回的响 应结束,整个过程所耗费的时间。
  • HPS(Hits Per Second)== :每秒点击次数,单位是次/秒。
  • TPS(Transaction per Second):系统每秒处理交易数,单位是笔/秒。
  • QPS(Query per Second):系统每秒处理查询次数,单位是次/秒。
    • 对于互联网业务中,如果某些业务有且仅有一个请求连接,那么 TPS=QPS=HPS,一 般情况下用 TPS 来衡量整个业务流程,用 QPS 来衡量接口查询次数,用 HPS 来表 示对服务器单击请求。
  • 最大响应时间(Max Response Time)
    • 指用户发出请求或者指令到系统做出反应(响应) 的最大时间。
  • 最少响应时间(Mininum ResponseTime)
    • 指用户发出请求或者指令到系统做出反应(响 应)的最少时间。
  • 90%响应时间(90% Response Time)
    • 是指所有用户的响应时间进行排序,第 90%的响应时间。

从外部看,性能测试主要关注如下三个指标

  • 吞吐量:每秒钟系统能够处理的请求数、任务数。
  • 响应时间:服务处理一个请求或一个任务的耗时。
  • 错误率:一批请求中结果出错的请求所占比例。

(2)安装 Jmeter

下载地址:https://jmeter.apache.org/download_jmeter.cgi

解压缩后,双击 bin/jmeter.bat 运行

1、新建线程组

image-20230118224044125

image-20230118224108530

线程组参数详解

  • 线程数:虚拟用户数。一个虚拟用户占用一个进程或线程。设置多少虚拟用户数在这里 也就是设置多少个线程数。
  • Ramp-Up Period(in seconds)准备时长:设置的虚拟用户数需要多长时间全部启动。如果 线程数为 10,准备时长为 2,那么需要 2 秒钟启动 10 个线程,也就是每秒钟启动 5 个 线程。
  • 循环次数:每个线程发送请求的次数。如果线程数为 10,循环次数为 100,那么每个线 程发送 100 次请求。总请求数为 10*100=1000 。如果勾选了“永远”,那么所有线程会 一直发送请求,一到选择停止运行脚本。
  • Delay Thread creation until needed:直到需要时延迟线程的创建。
  • 调度器:设置线程组启动的开始时间和结束时间(配置调度器时,需要勾选循环次数为 永远
  • 持续时间(秒):测试持续时间,会覆盖结束时间
  • 启动延迟(秒):测试延迟启动时间,会覆盖启动时间
  • 启动时间:测试启动时间,启动延迟会覆盖它。当启动时间已过,手动只需测试时当前 时间也会覆盖它。
  • 结束时间:测试结束时间,持续时间会覆盖它。

添加 HTTP 请求

image-20230118224335563

image-20230118224347045

添加监听器

image-20230118224404944

(3)JMeter Address Already in use 错误解决

windows 本身提供的端口访问机制的问题。

Windows 提供给 TCP/IP 链接的端口为 1024-5000,并且要四分钟来循环回收他们。就导致 我们在短时间内跑大量的请求时将端口占满了

image-20230118224651234

解决方法:

  1. win + R,用 regedit 命令打开注册表
  2. 在 HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters 下
    1. .右击 parameters,添加一个新的 DWORD,名字为 MaxUserPort
    2. 然后双击 MaxUserPort,输入数值数据为 65534,基数选择十进制
  3. 新建 TCPTimedWaitDelay:30
  4. 重启电脑才会生效

Windows帮助文档https://support.microsoft.com/zh-cn/help/196271/when-you-try-to-connect-from-tcp-ports-greater-than-5000-you-receive-t

2、性能监控

jvm 介绍以及 调优工具的使用:https://visualvm.github.io/pluginscenters.html

3、中间件对性能的影响

测试Nginx 的情况

image-20230119161813230

Nginx 的吞吐量 700 多

image-20230119161904427

通过 docker stats 命令实时监控,Nginx 的情况,可以看出 Nginx 主要是消耗的是 CPU 资源。

image-20230119161856448

测试网关的情况

image-20230119161223587

吞吐量 四万多

image-20230119161241220

通过 jvisualvm 可以看出 网关和 Nginx 一样,消耗的是 CPU 资源

image-20230119161659295

测试简单服务的情况

image-20230119163542255

不处理任何逻辑,吞吐量是非常高的。

image-20230119163555502

Gateway + 简单服务

image-20230119163837390

image-20230119163827746

Nginx + Gateway + 简单服务

image-20230119164028167

image-20230119164019805

我们可以看出,中间件使用的越多,对吞吐量影响的越大!!

首页渲染

image-20230119164242714

image-20230119164248191

获取三级分类数据

image-20230119164446671

image-20230119164440232

首页全量数据

image-20230119170754187

image-20230119165323215

image-20230119165042239

总结

压测内容线程数吞吐量/s90%响应时间99%响应时间
Nginx50746125278
Gateway504128326
简单服务504914024
Gateway+简单服务5015712512
Nginx + Gateway + 简单服务5083173378
首页渲染5079967130
首页渲染(开启thymeleaf缓存)5086467101
首页渲染(开启缓存+增加索引+关闭日志)5028312037
获取三级分类数据(db)504.91161211736
获取三级分类数据(代码优化)50295188262
获取三级分类数据(代码优化+redis缓存)5011575377
首页全量数据(静态资源)503813691722

从以上的测试我们可以得出结果

  • 中间件使用过多,性能损失越大,大部分时间都在处理网络交互了
  • 在访问首页时,有大量的 db、thymeleaf 操作,影响吞吐量
    • 针对首页的优化:开启 thymeleaf 缓存,优化 db、关闭 mybatis 日志
  • 获取三级分类数据时,主要是我们业务代码中循环查库导致的
    • 解决方法就是 优化代码 + 缓存
  • 首页获取全量数据时,主要是由于请求的静态资源也会由 tomcat 处理,本来吞吐量不多,tomcat 再去处理这些不必要的请求,因此也就慢了很多
    • 针对这种情况,我们可以使用 Nginx 的动静分离功能,将静态资源交给Nginx处理

优化

开启 thymeleaf 缓存
spring:
    thymeleaf:
        cache: true

开启缓存后,对吞吐量确实有一定的提升

image-20230119170846273

数据库增加索引 + 关闭日志

image-20230119171132852

image-20230119171148573

可以发现,吞吐量提升的不是一点半点,提升了将近四倍

image-20230119171409935

Nginx 动静分离

image-20230119181550753

1、将静态资源上传到 Nginx 的 html/static/ 目录下

2、修改页面的路径,都加上 /static/ 前缀

3、修改 Nginx 配置文件,带有 static 的请求都去 html 目录下找

image-20230119173551005

4、重启 Nginx 即可

优化三级分类获取(代码优化)

在获取三级分类时,我们使用嵌套查询数据库,这种方式无疑是很消耗时间,浪费资源。 将嵌套查询改为一次查询

  /*
    * 首页渲染二级、三级分类
    * */
    @Override
    public Map<String, List<Catelog2Vo>> getCatelog2Vo() {

        // 查询出所有的分类
        List<CategoryEntity> allCategorys = this.list();
        // 1、查询出所有的一级分类
        List<CategoryEntity> levelOneList = getEntityList(allCategorys,0L);


        // 将结果封装成一个 map
        Map<String, List<Catelog2Vo>> map = levelOneList.stream().collect(Collectors.toMap(k -> k.getCatId().toString(), v -> {
            // 2、查询出所有的二级分类
            List<CategoryEntity> levelTwoList = getEntityList(allCategorys,v.getCatId());

            List<Catelog2Vo> catelog2VoList = null;
            if (levelTwoList != null) {
                // 3、封装二级分类
                catelog2VoList = levelTwoList.stream().map(levelTwo -> {

                    Catelog2Vo catelog2Vo =
                            new Catelog2Vo(levelTwo.getParentCid().toString(), null, levelTwo.getCatId().toString(), levelTwo.getName());
                    // 4、查询出所有的三级分类
                    List<CategoryEntity> levelThreeList = getEntityList(allCategorys,levelTwo.getCatId());

                    if (levelThreeList != null) {
                        // 5、封装三级分类
                        List<Catelog2Vo.Catelog3Vo> catalog3List = levelThreeList.stream().map(levelThree -> {
                            Catelog2Vo.Catelog3Vo catelog3Vo =
                                    new Catelog2Vo.Catelog3Vo(levelTwo.getCatId().toString(), levelThree.getCatId().toString(), levelThree.getName());
                            return catelog3Vo;
                        }).collect(Collectors.toList());

                        catelog2Vo.setCatalog3List(catalog3List);
                    }
                    return catelog2Vo;
                }).collect(Collectors.toList());
            }

            return catelog2VoList;
        }));
        return map;
    }

    /*
    * 根据父分类id获取指定的分类集合
    * */
    private List<CategoryEntity> getEntityList(List<CategoryEntity> allCategorys,Long parentCid) {
        return allCategorys.stream().filter(item -> item.getParentCid() == parentCid).collect(Collectors.toList());
    }

优化后的吞吐量:

image-20230119180547389

四、缓存

1、缓存的使用

为了系统性能的提升,我们一般都会将部分数据放入缓存中,加速访问。而 db 承担数据落盘工作。

哪些数据适合放入缓存呢?

  • 即时性、数据一致性要求不高的
  • 访问量大且更新频率不高的数据(读多,写少)

举例:电商类应用,商品分类,商品列表等适合缓存并加一个失效时间(根据数据更新频率来定),后台如果发布一个商品,买家需要 5 分钟才能看到新的商品一般还是可以接受的。

image-20230119182424011

我们可以使用 Map 作为一个缓存,map中的数据都是保存在内存中。

    private Map<String, Object> cache = new HashMap<>();


        if (cache.get("key") == null) {
            // 查询数据库 xxxxxxx ,将查询出来的结果放入缓存中
            cache.put("key", "value");
        } else {
            // 缓存中有就取出来
            Object value = cache.get("key");
        }

使用 Map 作为缓存是本地缓存,会出现一些问题:

(1) 在分布式项目中,服务可能会分布在多态服务器上,那么每个服务有一个本地缓存。这样一来,如果某个服务通过负载均衡更新的话,其他服务中缓存的数据做不到及时更新,会出现数据一致性的问题

image-20230119182836993

(2)在分布式系统中,更多的时使用分布式缓存中间件 redis,多个微服务共享同一个缓存

image-20230119183040026

2、整合 Redis

1、增加依赖

        <!--引入 redis-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

2、增加配置

spring:
    redis:
        host: 192.168.56.111
        port: 6379

3、注入 StringRedisTemplate 模板即可

3、三级分类使用 Redis 缓存

    /*
    * 首页渲染二级、三级分类
    * */
    @Override
    public Map<String, List<Catelog2Vo>> getCatelog2Vo() {
        String catalogJSON = stringRedisTemplate.opsForValue().get("catalogJSON");

        if (StringUtils.isEmpty(catalogJSON)) {
            Map<String, List<Catelog2Vo>> catelog2VoByDb = getCatelog2VoByDb();
            // 存入 redis,将对象转换为 JSON
            // 以后存储 redis ,都存储JSON数据,因为JSON是跨平台,跨语言的。
            stringRedisTemplate.opsForValue().set("catalogJSON", JSON.toJSONString(catelog2VoByDb));
             return catelog2VoByDb;
        }
        // 将结果转换为对象
        Map<String, List<Catelog2Vo>> result = JSON.parseObject(catalogJSON, new TypeReference<Map<String, List<Catelog2Vo>>>() {});

        return  result;
    }

    /*
     *
     *  从数据库查询并封装数据
     * */
    public Map<String, List<Catelog2Vo>> getCatelog2VoByDb() {


        // 查询出所有的分类
        List<CategoryEntity> allCategorys = this.list();
        // 1、查询出所有的一级分类
        List<CategoryEntity> levelOneList = getEntityList(allCategorys, 0L);


        // 将结果封装成一个 map
        Map<String, List<Catelog2Vo>> map = levelOneList.stream().collect(Collectors.toMap(k -> k.getCatId().toString(), v -> {
            // 2、查询出所有的二级分类
            List<CategoryEntity> levelTwoList = getEntityList(allCategorys, v.getCatId());

            List<Catelog2Vo> catelog2VoList = null;
            if (levelTwoList != null) {
                // 3、封装二级分类
                catelog2VoList = levelTwoList.stream().map(levelTwo -> {

                    Catelog2Vo catelog2Vo =
                            new Catelog2Vo(levelTwo.getParentCid().toString(), null, levelTwo.getCatId().toString(), levelTwo.getName());
                    // 4、查询出所有的三级分类
                    List<CategoryEntity> levelThreeList = getEntityList(allCategorys, levelTwo.getCatId());

                    if (levelThreeList != null) {
                        // 5、封装三级分类
                        List<Catelog2Vo.Catelog3Vo> catalog3List = levelThreeList.stream().map(levelThree -> {
                            Catelog2Vo.Catelog3Vo catelog3Vo =
                                    new Catelog2Vo.Catelog3Vo(levelTwo.getCatId().toString(), levelThree.getCatId().toString(), levelThree.getName());
                            return catelog3Vo;
                        }).collect(Collectors.toList());

                        catelog2Vo.setCatalog3List(catalog3List);
                    }
                    return catelog2Vo;
                }).collect(Collectors.toList());
            }

            return catelog2VoList;
        }));
        return map;
    }
BUG

当我们使用 Jmeter 压力测试时 会抛出 堆外内存异常

image-20230119192111973

造成的原因

1、自SpringBoot 2.0 之后,SpringBoot 原来使用的 Jedis 替换成了 Lettuce,Lettuce底层采用的是 Netty 框架

2、这是 Lettuce 的 BUG 导致的堆外内存溢出,堆外内存可以通过设置:-Dio.netty.maxDirectMemory 参数 调节堆外内存,如果不设置默认使用 -Xmx , 这种方式治标不治本,只会延缓发生异常的时间。

解决方法

1、使用 Jedis 客户端

2、自己去修改源码

使用 Jedis 客户端

        <!--引入 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>
        <!--使用jedis客户端-->
        <dependency>
            <groupId>redis.clients</groupId>
            <artifactId>jedis</artifactId>
        </dependency>

使用 Jmeter 压力测试

image-20230119193248400

4、缓存穿透

指查询一个一定不存在的数据,由于缓存是不命中,将去查询数据库,但是数据库也无此记录,我们没有将这次查询的null写入缓存,这将导致这个不 存在的数据每次请求都要到存储层去查询,失去了缓存的意义。

在流量大时,可能 DB 就挂掉了,要是有人利用不存在的 key 频繁攻击我们的应用,这就是 漏洞。

解决方法

缓存空结果、并且设置短的过期时间。

5、缓存雪崩

缓存雪崩是指在我们设置缓存时key采用了相同的过期时间, 导致缓存在某一时刻同时失效,请求全部转发到DB,DB瞬时 压力过重 雪崩。

解决:

原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这 样每一个缓存的过期时间的重复率就会降低,就很难引发集体 失效的事件。

6、缓存击穿

与缓存雪崩的区别就是雪崩是多个key同时失效,缓存击穿是某个key失效,并且这个key是热点数据

对于一些设置了过期时间的key,如果这些key可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。

如果这个key在大量请求同时进来前正好失效,那么所有对 这个key的数据查询都落到db,我们称为缓存击穿。

解决:

加锁

大量并发只让一个去查,其他人等待,查到以后释放锁,其他 人获取到锁,先查缓存,就会有数据,不用去db

7、优化 Redis 缓存

基于以上出现的几种情况,在使用 Redis 应:

  • 缓存空结果
  • 设置过期时间
  • 对数据库的操作加锁

设置过期时间

image-20230119222810512

加锁的方式有俩种

  • 本地锁:synchronized、ReentrantLock…
  • 分布式锁

(1)本地锁

本地锁的问题:使用本地锁,只能锁住当前进程的线程,也就是说如果服务部署在多个服务器中,那么每个服务器中都能有一个线程进入同步代码块

image-20230119223015959

演示分布式下本地锁的问题

1、首先对数据库操作增加 synchronized 本地锁

   /*
    * 首页渲染二级、三级分类
    * */
    @Override
    public Map<String, List<Catelog2Vo>> getCatelog2Vo() {
        /*
        * 防止出现缓存击穿、穿透、雪崩“
        * 1、缓存空结果
        * 2、设置过期时间
        * 3、加锁
        * */
        String catalogJSON = stringRedisTemplate.opsForValue().get("catalogJSON");

        if (StringUtils.isEmpty(catalogJSON)) {
            System.out.println("缓存未命中....即将查询数据库....");
            Map<String, List<Catelog2Vo>> catelog2VoByDb = getCatelog2VoByDb();
            // 存入 redis,将对象转换为 JSON,并设置过期时间
            // 以后存储 redis ,都存储JSON数据,因为JSON是跨平台,跨语言的。
            // stringRedisTemplate.opsForValue()
            //         .set("catalogJSON", JSON.toJSONString(catelog2VoByDb),new Random().nextInt(3), TimeUnit.DAYS);
            // return catelog2VoByDb;
        }
        System.out.println("缓存命中....直接返回结果....");
        // 将结果转换为对象
        Map<String, List<Catelog2Vo>> result = JSON.parseObject(catalogJSON, new TypeReference<Map<String, List<Catelog2Vo>>>() {});

        return  result;
    }

    /*
     *
     *  从数据库查询并封装数据
     * */
    public Map<String, List<Catelog2Vo>> getCatelog2VoByDb() {

        synchronized (this) {
            if (!StringUtils.isEmpty(stringRedisTemplate.opsForValue().get("catalogJSON"))){
                String catalogJSON = stringRedisTemplate.opsForValue().get("catalogJSON");
                Map<String, List<Catelog2Vo>> result = JSON.parseObject(catalogJSON, new TypeReference<Map<String, List<Catelog2Vo>>>() {});
                return  result;
            }
            System.out.println(Thread.currentThread().getName() + " 查询数据库...");
            // 查询出所有的分类
            List<CategoryEntity> allCategorys = this.list();
            // 1、查询出所有的一级分类
            List<CategoryEntity> levelOneList = getEntityList(allCategorys, 0L);


            // 将结果封装成一个 map
            Map<String, List<Catelog2Vo>> map = levelOneList.stream().collect(Collectors.toMap(k -> k.getCatId().toString(), v -> {
                // 2、查询出所有的二级分类
                List<CategoryEntity> levelTwoList = getEntityList(allCategorys, v.getCatId());

                List<Catelog2Vo> catelog2VoList = null;
                if (levelTwoList != null) {
                    // 3、封装二级分类
                    catelog2VoList = levelTwoList.stream().map(levelTwo -> {

                        Catelog2Vo catelog2Vo =
                                new Catelog2Vo(levelTwo.getParentCid().toString(), null, levelTwo.getCatId().toString(), levelTwo.getName());
                        // 4、查询出所有的三级分类
                        List<CategoryEntity> levelThreeList = getEntityList(allCategorys, levelTwo.getCatId());

                        if (levelThreeList != null) {
                            // 5、封装三级分类
                            List<Catelog2Vo.Catelog3Vo> catalog3List = levelThreeList.stream().map(levelThree -> {
                                Catelog2Vo.Catelog3Vo catelog3Vo =
                                        new Catelog2Vo.Catelog3Vo(levelTwo.getCatId().toString(), levelThree.getCatId().toString(), levelThree.getName());
                                return catelog3Vo;
                            }).collect(Collectors.toList());

                            catelog2Vo.setCatalog3List(catalog3List);
                        }
                        return catelog2Vo;
                    }).collect(Collectors.toList());
                }

                return catelog2VoList;
            }));
            stringRedisTemplate.opsForValue()
                    .set("catalogJSON", JSON.toJSONString(map),1, TimeUnit.DAYS);
            return map;
        }

    }

2、复制多个实例(或者直接 Ctrl + D)

image-20230119225834761

image-20230119225932994

3、删除redis存入的数据,使用 Jmeter 测试:所有的线程均会通过网关的负载均衡请求product服务

image-20230119231220166

4、最终可以看见,启动了四个product 服务,有四个线程查询数据库。

image-20230119230932169

(2)分布式锁

分布式锁使用原理

分布式锁就是所有的微服务都去同一个地方去抢占锁,比如:mysql、redis ,如果有一个服务占上锁,那么其他服务就等待。

image-20230119231930976

所谓占锁其实就是往 redis 里存入数据,如果有一个服务存进去了就说明占锁成功,并且其他服务就无法在存入数据,在 redis 里有这样一条命令:

SET key value [EX seconds] [PX milliseconds] [NX|XX]

  • NX – 只有键key不存在的时候才会设置key的值
  • XX – 只有键key存在的时候才会设置key的值

我们就可以利用这条命令,实现分布式锁

image-20230119233756115

分布式锁演进——阶段一

基础的上锁释放锁存在的问题:
1、如果占锁成功,查询数据库的过程中,意外宕机、或者出现错误导致程序终止。没有执行 delete 释放锁,那么就会形成 “死锁”

2、因此在上锁时还需要设置锁的过期时间。即使发生意外,锁也会被自动释放

image-20230120001036771

分布式锁演进——阶段二

设置锁的过期时间,有俩种设置方式:

  • 通过 expire 方法设置,这种方法并不推荐,上锁与设置过期时间不是原子操作,中间可能还会出现意外
  • 上锁的同时设置过期时间。推荐这种方式

image-20230120153820439

分布式锁演进——阶段三

在加锁完成后,通过 delete 释放锁,是不靠谱的

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MzMBlbSX-1675935129414)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230120154652706.png)]

对于分布式释放锁,通常由俩种方法:

  • 使用 uuid 或者其他随机token,作为锁。在释放锁时,判断是否释放的是当前锁(这种方式也不推荐,释放锁和判断不是原子操作,容易出现问题)
  • 使用 LUA 脚本释放锁(推荐)

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9qMm7iAp-1675935129415)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230120160253646.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NmSdV3gO-1675935129415)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230120160306850.png)]

使用 Jmeter 进行压力测试:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yGdSajkl-1675935129415)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230120163408536.png)]

五、Redisson

上面通过 redis 设置了分布式锁,但是更多了设置方式是使用 Redisson 分布式锁框架。

根据官方描述,并不是推荐使用 redis 分布式锁,而是使用 RedLock,而 Redisson 就是基于 Redlock 实现的 Java 分布式锁。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XvDLHdoO-1675935129415)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230120164304264.png)]

Redisson 帮助文档:1. 概述 · redisson/redisson Wiki (github.com)

1、整合 Redisson

1、加入依赖

依赖有:Redisson 核心包,starter 场景启动器。初学就使用了 Redisson 手动配置

        <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson</artifactId>
            <version>3.19.0</version>
        </dependency>

2、创建配置文件

@Configuration
public class RedissonConfig {

    /*
    * 创建Redisson对象都通过RedissonClient
    * */
    @Bean(destroyMethod="shutdown")
   public  RedissonClient redisson() throws IOException {
        Config config = new Config();
        // 可使用 rediss 启用 SSH 安全连接
       // useSingleServer 单节点模式
        config.useSingleServer().setAddress("redis://192.168.56.111:6379");
        // 创建对象
        return Redisson.create(config);
    }
}

3、注入 RedissonClient 即可使用

    @Autowired
    private RedissonClient redissonClient;

    @Test
    public void testRedisson() {
        System.out.println(redissonClient);
    }

2、Redisson 的可重入锁

官方描述

基于Redis的Redisson分布式可重入锁RLock Java对象实现了java.util.concurrent.locks.Lock接口。同时还提供了异步(Async)反射式(Reactive)RxJava2标准的接口。

Redisson 可重入锁 RLock 继承了 JUC 中的 Lock 接口,也就说Redisson的RLock 和 本地锁 ReentrantLock 方法都是一样的。

RLock 的简单使用

 @RequestMapping("/hello")
    @ResponseBody
    public String hello() {

        RLock lock = redissonClient.getLock("my-lock");
        // 上锁
        lock.lock();
        try {
            System.out.println(Thread.currentThread().getId() + " 获取锁..");
            // 执行业务
            try {Thread.sleep(30000);} catch (InterruptedException e) {e.printStackTrace();}
        }catch (Exception e) {

        }finally {
            System.out.println(Thread.currentThread().getId() + " 释放锁..");
            // 释放锁
            lock.unlock();
        }
        return "hello";
    }

使用 Redisson 的好处

  • 通过 Redisson 设置的锁,Redisson内部提供了一个监控锁的看门狗,如果执行业务时间过长,锁的有效时间会自动续期。不会出现提前释放锁的情况
  • 锁的默认有效时间为30s,即使服务出现意外宕机,也不会出现死锁,。

对于 lock 方法还可以指定过期时间:

// 加锁以后10秒钟自动解锁
// 无需调用unlock方法手动解锁
lock.lock(10, TimeUnit.SECONDS);

如果自己指定过期时间,是不会进行续期的,时间到了就会自动释放锁。

在源码中也可以看出来,如果指定了过期时间,执行 tryLockInnerAsync 方法,在 tryLockInnerAsync 方法中直接执行 LUA 脚本,并没有续期操作。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-y7Rxmc0D-1675935129415)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230120174515016.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cQPQ2hhU-1675935129416)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230120174529662.png)]


如果我们没有设置过期时间,lock方法会自动将过期时间设置为 -1,

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-k92t8C4Q-1675935129416)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230120174755065.png)]

并且在 tryLockInnerAsync 方法中会将锁的过期时间默认设置为 30s

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-oQCRTmzx-1675935129416)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230120175049471.png)]

通过源码可以看出锁的默认时间就是 30*1000

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-p4ghYRWk-1675935129416)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230120175220090.png)]

设置完锁的默认过期时间,就进行续期

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Do5GGeF6-1675935129416)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230120175429722.png)]

renewExpiration 方法里是一个定时任务,每10s就会进行一次续期。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QeoYqoF7-1675935129417)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230120180156466.png)]

续期的方法其实就是向 Redis 发送一段 命令,重新设置锁的过期时间

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IgAytUcd-1675935129417)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230120180108524.png)]

3、Redisson 的读写锁

Redisson提供了读写锁,和 JUC下的 ReentrantReadWriteLock 一样,共享读,排斥写

  @RequestMapping("/write")
    @ResponseBody
    public String write() {
        RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("readWrite-lock");
        String s = "";
        // 写锁
        readWriteLock.writeLock().lock();
        try {
            s = UUID.randomUUID().toString();
            redisTemplate.opsForValue().set("writeValue",s);
            try {Thread.sleep(30000);} catch (InterruptedException e) {e.printStackTrace();}
        }finally {
            // 释放锁
            readWriteLock.writeLock().unlock();
        }
        return  s;
    }

    @RequestMapping("/read")
    @ResponseBody
    public String read() {
        RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("readWrite-lock");
        String s = "";
        // 读锁
        readWriteLock.readLock().lock();
        try {
            s = redisTemplate.opsForValue().get("writeValue");
        }finally {
            // 释放锁
            readWriteLock.readLock().unlock();
        }
        return  s;
    }

4、Redisson 的信号量

基于Redis的Redisson的分布式信号量(Semaphore)Java对象RSemaphore采用了与java.util.concurrent.Semaphore相似的接口和用法。同时还提供了异步(Async)反射式(Reactive)RxJava2标准的接口。

可以提前在 redis 存储一个信号量,获取信号时,值会-1,释放信号时值会+1,当值为0获取信号时,就会被阻塞。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-C73ZYSn6-1675935129417)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230120195755388.png)]

  /*
    * 三辆车停车
    * */
    @RequestMapping("/park")
    @ResponseBody
    public String park() throws InterruptedException {
        RSemaphore park = redissonClient.getSemaphore("park");
        // 获取信号
        // park.acquire();
        
        // 包括 lock 也会有一个 tryLock 方法,尝试获取锁。
        // 尝试获取信号,成功true,否则 false
        boolean b = park.tryAcquire();
        if (b) {
            // 获取成功
        }else {
            // 获取失败
        }
        return "ok";
    }

    @RequestMapping("/go")
    @ResponseBody
    public String go() throws InterruptedException {
        RSemaphore park = redissonClient.getSemaphore("park");

        // 释放信号
        park.release();

        return "ok";
    }

5、Redisson 的闭锁

基于Redisson的Redisson分布式闭锁(CountDownLatch)Java对象RCountDownLatch采用了与java.util.concurrent.CountDownLatch相似的接口和用法。

    /*
    * 模仿学校锁门,一共有五个班。当五个班人都走了之后,学校才会锁门
    * */
    @RequestMapping("/door")
    @ResponseBody
    public String lockDoor() throws InterruptedException {
        RCountDownLatch park = redissonClient.getCountDownLatch("lcok-door");
        // 总共有5个班
        park.trySetCount(5);
        park.await(); // 等待

        return "锁门了....";
    }


    @RequestMapping("/gogo/{id}")
    @ResponseBody
    public String gogo(@PathVariable Long id) throws InterruptedException {
        RCountDownLatch park = redissonClient.getCountDownLatch("lcok-door");
        park.countDown(); // 计数减一

        return id + " 班走了....";
    }

6、缓存一致性

在使用缓存时,可能会出现数据库更新,而缓存与数据库数据不一致的问题,针对这种情况,一般有俩种方式:

  • 双写模式
  • 失效模式

双写模式

在更新数据库的同时,将缓存中的数据也进行更新。

这种方式也是有一些问题,如图所示:

如果线程 1 更新数据库,在即将更新缓存时,由于网络或者其他原因被阻塞了。

这时 线程2 更新数据库,又更新完缓存,此时线程 1 恢复,那么它更新缓存是更新的旧数据。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vPpDuSKP-1675935129417)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230120204353844.png)]

失效模式

简单来说就是在更新数据库时,将缓存数据清除,这样等下次读的时候就会去读数据库,但是这样也会有一些问题。

如图所示 :

线程 1 更新数据库同时删除缓存,线程2更新数据库,但是此次更新时间较长,中途有线程3读缓存,但是线程3读取的旧数据,有了一些延迟性。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PyyrImA0-1675935129417)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230120210226358.png)]


无论是双写模式还是失效模式,都会导致缓存的不一致问题。即多个实例同时更新会出事。怎么办?

1、如果是用户纬度数据(订单数据、用户数据),这种并发几率非常小,不用考虑这个问题,缓存数据加 上过期时间,每隔一段时间触发读的主动更新即可

2、如果是菜单,商品介绍等基础数据,也可以去使用canal订阅binlog的方式。

3、缓存数据+过期时间也足够解决大部分业务对于缓存的要求。

4、通过加锁保证并发读写,写写的时候按顺序排好队。读读无所谓。所以适合使用读写锁。(业务不关心 脏数据,允许临时脏数据可忽略);

总结

我们能放入缓存的数据本就不应该是实时性、一致性要求超高的。所以缓存数据的时候加上过期时间,保证每天拿到当前最新数据即可。

我们不应该过度设计,增加系统的复杂性

遇到实时性、一致性要求高的数据,就应该查数据库,即使慢点。


在我们系统中对于缓存一致性的解决办法:

  • 设置过期时间,时间到了就重新从数据库中读取数据
  • 加上分布式读写锁

六、SpringCache

帮助文档: Integration (spring.io)

1、介绍

使用 SpringCache 的目的就是简化我们缓存的开发,不需要写大量的缓存代码

Spring 从 3.1 开始定义了 org.springframework.cache.Cacheorg.springframework.cache.CacheManager 接口来统一不同的缓存技术; 并支持使用 JCache(JSR-107)注解简化我们开发;

Cache 与 CacheManager 的关系

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5MdtluUT-1675935129418)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230120212044710.png)]

Spring 中定义了很多个缓存管理器,每一种缓存管理器中都可以存储同种类型的缓存。这就好比一个运动馆,运动馆里有篮球区,羽毛球区,游泳区… 这些区域都属于运动馆,但是每个区域的数据(人)都是不一样的。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-pq2sRYc4-1675935129418)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230120212217030.png)]

2、整合SpringCache

1、引入依赖,同时还要引入使用的缓存依赖,使用redis就引入redis

        <!--引入SpringCache-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-cache</artifactId>
        </dependency>

2、配置缓存类型

# 使用缓存类型
spring.cache.type=redis

3、开启缓存

主启动类上增加 @EnableCaching // 开启缓存

4、简单使用缓存注解

     * @Cacheable 方法返回的结果会存入缓存中:
     *      如果缓存中有则无需调用方法,直接返回缓存中的值
     *      如果缓存中没有将方法返回的结果存到缓存中
     *  可以设置缓存的分区名,建议使用业务名区分。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CLkHK3aS-1675935129418)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230120215303606.png)]

3、缓存注解

常用的缓存注解

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZNSOVOvq-1675935129418)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230121162301913.png)]

注解中的主要属性

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WBptdgmk-1675935129418)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230121163136802.png)]

(1)@Cacheable

使用缓存注解默认生成的:

(1)自动生成的 key

(2)默认的过期时间是 -1

(3)存入的值是经过序列化的

以上这些都是需要我们自定义的。

1、设置指定的 key

key 属性默认使用的是 SpEL 表达式

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NDCEGS07-1675935129419)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230120221051951.png)]

可使用的 SpEL 表达式

NameLocationDescriptionExample
methodNameRoot objectThe name of the method being invoked#root.methodName
methodRoot objectThe method being invoked#root.method.name
targetRoot objectThe target object being invoked#root.target
targetClassRoot objectThe class of the target being invoked#root.targetClass
argsRoot objectThe arguments (as array) used for invoking the target#root.args[0]
cachesRoot objectCollection of caches against which the current method is run#root.caches[0].name
Argument nameEvaluation contextName of any of the method arguments. If the names are not available (perhaps due to having no debug information), the argument names are also available under the #a<#arg> where #arg stands for the argument index (starting from 0).#iban or #a0 (you can also use #p0 or #p<#arg> notation as an alias).
resultEvaluation contextThe result of the method call (the value to be cached). Only available in unless expressions, cache put expressions (to compute the key), or cache evict expressions (when beforeInvocation is false). For supported wrappers (such as Optional), #result refers to the actual object, not the wrapper.

2、设置过期时间

# 过期时间
spring.cache.redis.time-to-live=36000

3、设置存储的数据为JSON格式

需要我们自定义 缓存 配置, 在 RedisCacheConfiguration 中定义了默认的配置,我们只需要往容器中放入我们自己定义的 RedisCacheConfiguration 即可。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xB8ZmjIw-1675935129419)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230120224008389.png)]

创建缓存配置类:

@Configuration
@EnableCaching // 开启缓存
@EnableConfigurationProperties(CacheProperties.class)
public class MyCacheConfig {


    /*
    *
    * 默认的配置
    *  @ConfigurationProperties(prefix = "spring.cache")
    *   public class CacheProperties {}
    * 需要将MyCacheConfig配置类与CacheProperties关联起来
    *   @EnableConfigurationProperties(CacheProperties.class)
    * 带有@Bean注解的方法形参会从容器中获取
    * */
    @Bean
    public RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties) {
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
        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;
    }
}

其他配置:

# 使用缓存类型
spring.cache.type=redis
# 过期时间
spring.cache.redis.time-to-live=36000
# key的前缀,如果没有配置前缀默认使用缓存名(@Cacheable(value)的值),如果关闭前缀,默认使用key的名字
spring.cache.redis.key-prefix=CACHE_
spring.cache.redis.use-key-prefix=false
# 是否缓存控制——解决了缓存穿透的问题
spring.cache.redis.cache-null-values=true

(2)@CacheEvict

删除缓存中的数据,用来实现缓存一致性的失效模式.

在更新分类时,使用该注解清除缓存中的旧数据,等下次重新从数据库中读取。

    /*
     * 更新分类表的同时更新其他关联表
     *  批量删除多个缓存数据有俩种方法:
     * 1、使用  @Caching 注解,可组合多个缓存动作
     * 2、@CacheEvict(value = {"category"},allEntries = true)  allEntries 表示删除category分区下的所有key
     * */
    // @Caching(evict = {
    //         @CacheEvict(value = {"category"},key = "'getLevelOne'"),
    //         @CacheEvict(value = {"category"},key = "'getCatelogJSON'")
    // })
    @CacheEvict(value = {"category"},allEntries = true)
    @Transactional // 事务注解
    @Override
    public void updateCascade(CategoryEntity category) {
        this.updateById(category);
        // 更新 关联表的 分类名
        if (!StringUtils.isEmpty(category.getName())) {
            CategoryBrandRelationEntity categoryBrandRelationEntity = new CategoryBrandRelationEntity();
            categoryBrandRelationEntity.setCatelogName(category.getName());
            categoryBrandRelationService.update(categoryBrandRelationEntity,
                    new QueryWrapper<CategoryBrandRelationEntity>().eq("catelog_id", category.getCatId()));
        }
        // TODO 更新其他表的数据
    }

修改查询二三级分类方法,使用缓存注解代替缓存代码

4、SpringCache 的原理与不足

SpringCache 的不足:

1、读模式

(1)缓存穿透 : 查询与一个 null 数据。解决:spring.cache.redis.cache-null-values=true

(2)缓存击穿 : 大量请求同时查询一个过期的key ,解决: 加锁,而在 SpringCache中可以使用 sync=true 参数 ,设置本地锁

(3)缓存雪崩: 大量 key 同时过期, 解决:加上过期时间。spring.cache.redis.time-to-live=3600000

2、写模式(缓存一致性问题)

(1)加读写锁

(2)引入 cannel

(3)读多写少的场景,直接读取数据库

总结

常规数据: 对于一些读多写少,数据实时性要求不高的数据完全可以用 SpringCache

特殊数据:特殊设计

七、搜索服务

1、搭建页面环境

1、将所有静态资源都放在 Nginx 中的 /html/static/search 目录下

2、将 index 页面放在 search 服务的 templates 下,并修改 index 页面中所有的连接路径都加上 /static/search

3、修改 Nginx 的配置文件,将所有带 gulimall.com 的请求都转发给网关

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OBgbm6td-1675935129419)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230121174747505.png)]

4、修改网关配置

                - id: gulimall_search_route
                  uri: lb://gulimall-seartch # 负载均衡
                  predicates:
                      - Host=search.gulimall.com

5、修改 hosts 文件,增加映射

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OR2NGnmh-1675935129419)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230121180334063.png)]

6、引入thymeleaf依赖

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>

7、引入开发工具

        <!--开发中工具-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <optional>true</optional>
        </dependency>

8、关闭 thymeleaf 缓存

spring:
    thymeleaf:
        cache: false

2、调整页面跳转

1、增加 Controller ,并将搜索页面改成 list.html

@Controller
public class SearchController {

    /*
    * 跳转到搜索界面
    * */
    @GetMapping("/list.html")
    public String toList() {
        return  "list";
    }
}

2、修改 首页 搜索框的链接地址

<a href="javascript:search();" ><img src="/static/index/img/img_09.png" /></a>

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9ontkPk4-1675935129420)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230121184629305.png)]

3、当我们点击分类进入搜索界面时,他的链接是 gmall ,修改成 gulimall

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gB7gnmMt-1675935129420)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230121184801341.png)]

修改 js文件 /mydata/nginx/html/static/index/js/catalogLoader.js

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wGMszkJ6-1675935129420)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230121184848211.png)]

4、修改搜索界面首页链接

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fwn5vq7y-1675935129420)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230121185052982.png)]

3、分析检索条件模型

一共有三种进入检索的方式:

1、选择商品分类入检索

对应的参数:

  • 分类ID

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-P4CJ72S7-1675935129420)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230121223338335.png)]

2、输入关键字进行检索

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UId2ZU9q-1675935129421)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230121223354223.png)]

3、选择筛选条件进行筛选

  • 根据品牌筛选
  • 选择不同的属性筛选【属性值可以选多个】
  • 按照 价格、销量、热度 排序筛选
  • 是否有货进行筛选
  • 价格区间筛选

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zou6c6jK-1675935129421)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230121223405630.png)]

封装成 VO :

Data
public class SearchParamVo {

    // 关键字搜索
    private String keyword;

    // 分类id
    private Long catalog3Id;

    /*
     * 排序条件
     *  saleCount(销量)、hotScore(热度)、skuPrice(价格)
     *   sort=saleCount_asc/desc
     *   sort=hotScore_asc/desc
     *   sort=skuPrice_asc/desc
     * */
    private String sort;

    /*
     * 过滤条件
     * hasStock(是否有库存)、skuPrice(价格区间)、brandId(品牌)、attrs(基本属性)
     *  hasStock=0/1  0表示未勾选[仅显示有货] 1表示勾选了[仅显示有货]
     *  skuPrice=1_200/_200/200_  表示: 1~100 、~200、200~
     *   brandId=1&brandId=2  可能会选中多个品牌
     *   attrs=1_其他  属性也可能勾选多个,1_其他:id为1的属性值为其他
     * */

    public Integer hasStock = 1;
    private String skuPrice;
    private List<Long> brandId;
    private List<String> attrs;
    // 页码数
    private Integer pageNum = 1;

    /*
     * 完整的参数:
     *  keyword=小米&sort=saleCount_desc/asc&hasStock=0/1&skuPrice=400_1900&brandId=1 &catalogId=1&attrs=1_3G:4G:5G&attrs=2_骁龙 845&attrs=4_高清屏
     * */

}

4、分析检索结果模型

检索返回结果应该包含哪些信息:

对于 属性信息 一定是某个商品的一属性。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-acH16xGc-1675935129421)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230123100136989.png)]

封装成 VO:

@Data
public class SearchResultVo {

    // 检索返回的所有商品信息
    private List<SkuEsModel> products;
    // 检索返回的所有品牌信息
    private List<BrandVo> brands;

    // 检索返回的所有分类信息
    private List<CatalogVo> catalogs;
    // 检索返回的所有分类信息
    private List<AttrsVo> attrs;

    // 当前页码
    private Integer pageNum;
    // 总记录数
    private Long total;
    // 总页码
    private Integer totalPages;

    @Data
    public static class BrandVo {
        private Long brandId;
        private String brandImg;
        private String brandName;
    }

    @Data
    public static class CatalogVo {
        private Long catalogId;
        private String catalogName;
    }

    @Data
    public static class AttrsVo {
        private Long attrId;
        private String attrName;
        private List<String> attrValue;
    }
}

5、DSL 语句测试

根据以上封装的条件查询,在 ES 中构建出查询语句:

  • 关键字查询
  • 过滤
    • 根据分类ID过滤
    • 根据品牌ID过滤
      • 品牌ID可能有多个
    • 根据价格区间过滤
    • 根据是否有库存过滤
    • 根据属性过滤
      • 前端发送属性的格式: 属性ID_属性值
  • 分页

根据以上封装的结果模型,再返回结果时我们需要聚合出需要的数据格式:

  • 聚合品牌
  • 聚合分类
  • 聚合属性
{
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "skuTitle": "华为"
          }
        }
      ],
      "filter": [
        {
          "term": {
            "catalogId": "225"
          }
        },
        {
          "terms": {
            "brandId": [
              "5",
              "9"
            ]
          }
        },
        {
          "nested": {
            "path": "attrs",
            "query": {
              "bool": {
                "must": [
                  {
                    "term": {
                      "attrs.attrId": {
                        "value": "2"
                      }
                    }
                  },
                  {
                    "terms": {
                      "attrs.attrValue": [
                        "MGA-AL00"
                      ]
                    }
                  }
                ]
              }
            }
          }
        },
        {
          "term": {
            "hasStock": false
          }
        },
        {
          "range": {
            "skuPrice": {
              "gte": 0,
              "lte": 6000
            }
          }
        }
      ]
    }
  },
  "from": 1,
  "size": 1,
  "highlight": {
    "fields": {
      "skuTitle": {}
    },
    "pre_tags": "<b style='color:red>'",
    "post_tags": "</b>"
  },
  "sort": [
    {
      "skuPrice": {
        "order": "desc"
      }
    }
  ], 
  "aggs": {
    "brand_agg": {
      "terms": {
        "field": "brandId",
        "size": 10
      },
      "aggs": {
        "brand_name_agg": {
          "terms": {
            "field": "brandName",
            "size": 10
          }
        },
        "brand_image_agg": {
          "terms": {
            "field": "brandImg",
            "size": 10
          }
        }
      }
    },
    "catalog_agg": {
      "terms": {
        "field": "catalogId",
        "size": 10
      },
      "aggs": {
        "catalog_name_agg": {
          "terms": {
            "field": "catalogName",
            "size": 10
          }
        }
      }
    },
    "attr_agg" :{
      "nested": {
        "path": "attrs"
      },
      "aggs": {
        "attr_id_agg": {
          "terms": {
            "field": "attrs.attrId",
            "size": 10
          },
          "aggs": {
            "attr_name_agg": {
              "terms": {
                "field": "attrs.attrName",
                "size": 10
              }
            },
            "attr_value_agg":{
              "terms": {
                "field": "attrs.attrValue",
                "size": 10
              }
            }
          }
        }
      }
    }
  }
}

6、构建 SearchReqeust

(1)构建检索条件

 private SearchRequest builderSearchRequest(SearchParamVo searchParam) {
        SearchSourceBuilder builder = new SearchSourceBuilder();

        /**
         * 查询
         */
        // 1、模糊匹配
        BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
        if (!StringUtils.isEmpty(searchParam.getKeyword())) {
            boolQuery.must(QueryBuilders.matchQuery("skuTitle", searchParam.getKeyword()));
        }
        // 2、过滤
        // 2.1 分类
        if (searchParam.getCatalog3Id() != null) {
            boolQuery.filter(QueryBuilders.termQuery("catalogId", searchParam.getCatalog3Id()));
        }
        // 2.2 品牌
        if (searchParam.getBrandId() != null && searchParam.getBrandId().size() > 0) {
            boolQuery.filter(QueryBuilders.termsQuery("brandId", searchParam.getBrandId()));
        }
        // 2.3 属性
        // 属性参数格式: attr=1_value:value,2_value:value
        List<String> attrs = searchParam.getAttrs();
        if (attrs != null && attrs.size() > 0) {
            for (String attr : attrs) {
                // 嵌入式
                BoolQueryBuilder nestedBoolQuery = QueryBuilders.boolQuery();
                String[] s = attr.split("_");
                String attrId = s[0];
                String[] attrsValue = s[1].split(":");
                nestedBoolQuery.must(QueryBuilders.termQuery("attrs.attrId", attrId));
                nestedBoolQuery.must(QueryBuilders.termsQuery("attrs.attrValue", attrsValue));
                // 每一个属性条件都应有一个 filter,不应该将所有的属性条件都放在一个filter里面。
                NestedQueryBuilder nestedQuery = QueryBuilders.nestedQuery("attrs", nestedBoolQuery, ScoreMode.None);
                boolQuery.filter(nestedQuery);
            }
        }
        // 2.4 库存
        if (searchParam.getHasStock() != null) {
            boolQuery.filter(QueryBuilders.termQuery("hasStock", searchParam.getHasStock() != 0));
        }
        // 2.5 价格区间
        /**
         * 价格区间的参数格式: 1_500,_500,500_
         *           "range": {
         *             "skuPrice": {
         *               "gte": "",
         *               "lte": "",
         *             }
         *           }
         */
        String price = searchParam.getSkuPrice();
        if (!StringUtils.isEmpty(price)) {
            // 根据_分割
            String[] s = price.split("_");
            if (s.length == 2) {
                if (price.startsWith("_")) {
                    // _500
                    boolQuery.filter(QueryBuilders.rangeQuery("skuPrice").lte(s[1]));
                } else {
                    // 1_500
                    boolQuery.filter(QueryBuilders.rangeQuery("skuPrice").gte(s[0]).lte(s[1]));
                }
            } else {
                // 500_
                boolQuery.filter(QueryBuilders.rangeQuery("skuPrice").gte(s[0]));
            }
        }
        // 将以上条件封装
        builder.query(boolQuery);


        /**
         * 排序
         *  排序的格式:
         *     saleCount(销量)、hotScore(热度)、skuPrice(价格)
         *     sort=saleCount_asc/desc
         *     sort=hotScore_asc/desc
         *     sort=skuPrice_asc/desc
         * 分页
         *   计算方式:
         *      from: 从第几条数据开始显示
         *      size:每页显示条数
         *      pageNum:当前页码
         *      from = (pageNum -1) * size
         * 高亮
         *  只有进行模糊匹配时,对标题进行高亮
         */
        // 3、排序
        String sort = searchParam.getSort();
        if (!StringUtils.isEmpty(sort)) {
            String[] strings = sort.split("_");
            String filed = strings[0];
            String order = strings[1];
            builder.sort(filed, "asc".equalsIgnoreCase(order) ? SortOrder.ASC : SortOrder.DESC);
        }
        // 4、分页
        builder.from((searchParam.getPageNum() - 1) * EsConstant.PRODUCT_PAGE_SIZE);
        builder.size(EsConstant.PRODUCT_PAGE_SIZE);

        if (!StringUtils.isEmpty(searchParam.getKeyword())) {
            // 5、高亮
            builder.highlighter(new HighlightBuilder().field("skuTitle").preTags("<b style='color:red>'").postTags("</b>"));
        }

        SearchRequest searchRequest = new SearchRequest(new String[]{EsConstant.PRODUCT_INDEX}, builder);
        System.out.println("DSL语句: " + builder.toString());
        return searchRequest;
    }

在对价格区间进行分割时,需要注意:

1_500  	经过分割:长度是2
_500  	经过分割:长度是2
500_	经过分割:长度是1

(2)构建聚合

 private SearchRequest builderSearchRequest(SearchParamVo searchParam) {
        SearchSourceBuilder builder = new SearchSourceBuilder();

        /**
         * 查询
         */
        // 1、模糊匹配
        BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
        if (!StringUtils.isEmpty(searchParam.getKeyword())) {
            boolQuery.must(QueryBuilders.matchQuery("skuTitle", searchParam.getKeyword()));
        }
        // 2、过滤
        // 2.1 分类
        if (searchParam.getCatalog3Id() != null) {
            boolQuery.filter(QueryBuilders.termQuery("catalogId", searchParam.getCatalog3Id()));
        }
        // 2.2 品牌
        if (searchParam.getBrandId() != null && searchParam.getBrandId().size() > 0) {
            boolQuery.filter(QueryBuilders.termsQuery("brandId", searchParam.getBrandId()));
        }
        // 2.3 属性
        // 属性参数格式: attr=1_value:value,2_value:value
        List<String> attrs = searchParam.getAttrs();
        if (attrs != null && attrs.size() > 0) {
            for (String attr : attrs) {
                // 嵌入式
                BoolQueryBuilder nestedBoolQuery = QueryBuilders.boolQuery();
                String[] s = attr.split("_");
                String attrId = s[0];
                String[] attrsValue = s[1].split(":");
                nestedBoolQuery.must(QueryBuilders.termQuery("attrs.attrId", attrId));
                nestedBoolQuery.must(QueryBuilders.termsQuery("attrs.attrValue", attrsValue));
                // 每一个属性条件都应有一个 filter,不应该将所有的属性条件都放在一个filter里面。
                NestedQueryBuilder nestedQuery = QueryBuilders.nestedQuery("attrs", nestedBoolQuery, ScoreMode.None);
                boolQuery.filter(nestedQuery);
            }
        }
        // 2.4 库存
        if (searchParam.getHasStock() != null) {
            boolQuery.filter(QueryBuilders.termQuery("hasStock", searchParam.getHasStock() != 0));
        }
        // 2.5 价格区间
        /**
         * 价格区间的参数格式: 1_500,_500,500_
         *           "range": {
         *             "skuPrice": {
         *               "gte": "",
         *               "lte": "",
         *             }
         *           }
         */
        String price = searchParam.getSkuPrice();
        if (!StringUtils.isEmpty(price)) {
            // 根据_分割
            String[] s = price.split("_");
            if (s.length == 2) {
                if (price.startsWith("_")) {
                    // _500
                    boolQuery.filter(QueryBuilders.rangeQuery("skuPrice").lte(s[1]));
                } else {
                    // 1_500
                    boolQuery.filter(QueryBuilders.rangeQuery("skuPrice").gte(s[0]).lte(s[1]));
                }
            } else {
                // 500_
                boolQuery.filter(QueryBuilders.rangeQuery("skuPrice").gte(s[0]));
            }
        }
        // 将以上条件封装
        builder.query(boolQuery);


        /**
         * 排序
         *  排序的格式:
         *     saleCount(销量)、hotScore(热度)、skuPrice(价格)
         *     sort=saleCount_asc/desc
         *     sort=hotScore_asc/desc
         *     sort=skuPrice_asc/desc
         * 分页
         *   计算方式:
         *      from: 从第几条数据开始显示
         *      size:每页显示条数
         *      pageNum:当前页码
         *      from = (pageNum -1) * size
         * 高亮
         *  只有进行模糊匹配时,对标题进行高亮
         */
        // 3、排序
        String sort = searchParam.getSort();
        if (!StringUtils.isEmpty(sort)) {
            String[] strings = sort.split("_");
            String filed = strings[0];
            String order = strings[1];
            builder.sort(filed, "asc".equalsIgnoreCase(order) ? SortOrder.ASC : SortOrder.DESC);
        }
        // 4、分页
        builder.from((searchParam.getPageNum() - 1) * EsConstant.PRODUCT_PAGE_SIZE);
        builder.size(EsConstant.PRODUCT_PAGE_SIZE);

        if (!StringUtils.isEmpty(searchParam.getKeyword())) {
            // 5、高亮
            builder.highlighter(new HighlightBuilder().field("skuTitle").preTags("<b style='color:red>'").postTags("</b>"));
        }

        /**
         * 聚合分析
         * */

        // 1、品牌聚合
        //subAggregation:子聚合
        TermsAggregationBuilder brandAgg =
                AggregationBuilders.terms("brand_agg").field("brandId").size(50)
                        .subAggregation(AggregationBuilders.terms("brand_name_agg").field("brandName").size(1))
                        .subAggregation( AggregationBuilders.terms("brand_image_agg").field("brandImg").size(1));


        // 2、分类聚合
        TermsAggregationBuilder catalogAgg = AggregationBuilders.terms("catalog_agg").field("catalogId").size(20)
                .subAggregation(AggregationBuilders.terms("catalog_name_agg").field("catalogName").size(1));


        // 3、属性聚合
        NestedAggregationBuilder nestedAgg = AggregationBuilders.nested("attr_agg", "attrs");
        TermsAggregationBuilder attrIdAgg = AggregationBuilders.terms("attr_id_agg").field("attrs.attrId").size(20);
        // attrId 对应的 AttrName 、AttrValue
        attrIdAgg.subAggregation(AggregationBuilders.terms("attr_name_agg").field("attrs.attrName").size(1))
                .subAggregation(AggregationBuilders.terms("attr_value_agg").field("attrs.attrValue").size(10));
        nestedAgg.subAggregation(attrIdAgg);

        builder.aggregation(brandAgg);
        builder.aggregation(catalogAgg);
        builder.aggregation(nestedAgg);

        SearchRequest searchRequest = new SearchRequest(new String[]{EsConstant.PRODUCT_INDEX}, builder);
        System.out.println("DSL语句: " + builder.toString());
        return searchRequest;
    }

7、SearchResponse 分析&封装

根据检索结果模型,我们需要封装 :

  • 商品信息
  • 品牌聚合信息
  • 分类聚合信息
  • 属性聚合信息
    /*
     * 封装返回结果
     * */
    private SearchResultVo builderSearchResult(SearchResponse response, SearchParamVo searchParam) {
        SearchResultVo resultVo = new SearchResultVo();
        // // 1、商品信息
        SearchHit[] hits = response.getHits().getHits();
        if (hits.length > 0 && hits != null) {
            List<SkuEsModel> products = Arrays.stream(hits).map(item -> {
                String product = item.getSourceAsString();
                SkuEsModel skuEsModel = JSON.parseObject(product, SkuEsModel.class);
                if (!StringUtils.isEmpty(searchParam.getKeyword())){
                    // 设置标题高亮
                    String skuTitle = item.getHighlightFields().get("skuTitle").fragments()[0].string();
                    skuEsModel.setSkuTitle(skuTitle);
                }

                return skuEsModel;
            }).collect(Collectors.toList());
            resultVo.setProducts(products);
        }

        // // 2、品牌信息

        // 2.1 获取品牌的聚合信息
        ParsedLongTerms brandAgg = response.getAggregations().get("brand_agg");
        List<SearchResultVo.BrandVo> brandVos = brandAgg.getBuckets().stream().map(bucket -> {
            // 品牌Id
            String brandId =  bucket.getKeyAsString();
            SearchResultVo.BrandVo brandVo = new SearchResultVo.BrandVo();
            // 2.2 获取子聚合品牌图片信息
            String img =
                    ((ParsedStringTerms)bucket.getAggregations().get("brand_image_agg")).getBuckets().get(0).getKeyAsString();

            // 2.3 获取子聚合品牌名信息
            String BrandName =
                    ((ParsedStringTerms)bucket.getAggregations().get("brand_name_agg")).getBuckets().get(0).getKeyAsString();

            brandVo.setBrandId(Long.parseLong(brandId));
            brandVo.setBrandImg(img);
            brandVo.setBrandName(BrandName);
            return brandVo;
        }).collect(Collectors.toList());
        resultVo.setBrands(brandVos);
        // // 3、分类信息
       // 3、1 获取分类聚合信息
       ParsedLongTerms catalogAgg =  response.getAggregations().get("catalog_agg");
        List<SearchResultVo.CatalogVo> catalogVos = catalogAgg.getBuckets().stream().map(bucket -> {
            SearchResultVo.CatalogVo catalogVo = new SearchResultVo.CatalogVo();
            // 分类ID
            String catalogId = bucket.getKeyAsString();
            // 3、2 获取子聚合 分类名信息
            String catalogName =
                    ((ParsedStringTerms)bucket.getAggregations().get("catalog_name_agg")).getBuckets().get(0).getKeyAsString();

            catalogVo.setCatalogId(Long.parseLong(catalogId));
            catalogVo.setCatalogName(catalogName);
            return catalogVo;
        }).collect(Collectors.toList());
        resultVo.setCatalogs(catalogVos);

        // // 4、属性信息
        // 4、1 获取属性聚合信息
        ParsedNested attrAgg = response.getAggregations().get("attr_agg");
        // 4、2 获取子聚合 attrIdAgg
        ParsedLongTerms attrIdAgg = attrAgg.getAggregations().get("attr_id_agg");
        List<SearchResultVo.AttrsVo> attrsVos = attrIdAgg.getBuckets().stream().map(bucket -> {
            SearchResultVo.AttrsVo attrsVo = new SearchResultVo.AttrsVo();
            String attrId = bucket.getKeyAsString();
            // 4、3 获取子聚合属性名信息
            String attrName =
                    ((ParsedStringTerms)bucket.getAggregations().get("attr_name_agg")).getBuckets().get(0).getKeyAsString();
            // 4、3 获取子聚合属性值信息.属性值可能有多个
            List<String> attrValues
                    = ((ParsedStringTerms) bucket.getAggregations().get("attr_value_agg")).getBuckets()
                    .stream().map(MultiBucketsAggregation.Bucket::getKeyAsString).collect(Collectors.toList());


            attrsVo.setAttrId(Long.parseLong(attrId));
            attrsVo.setAttrName(attrName);
            attrsVo.setAttrValue(attrValues);
            return attrsVo;
        }).collect(Collectors.toList());
        resultVo.setAttrs(attrsVos);

        // 5、分页信息:当前页、总记录数、总页码数
        resultVo.setPageNum(searchParam.getPageNum());
        resultVo.setTotal(response.getHits().getTotalHits().value);
        // 页码数 = 总记录数 / 每页记录数 .... 如果有余数 + 1
        Integer pageSize = EsConstant.PRODUCT_PAGE_SIZE;
        Long total = resultVo.getTotal();
        resultVo.setTotalPages((int) (total % pageSize == 0 ? total / pageSize : total / pageSize + 1));


        return resultVo;
    }

到此,检索服务最重要的俩个方法已经完成—发送请求、封装结果。接下来 Controller 调用即可:

    /**
     * @description
     * @date 2023/1/21 23:02
     * @param searchParam 将页面中的搜索条件自动封装在里面
     * @return java.lang.String
     */
    @GetMapping("/list.html")
    public String toList(SearchParamVo searchParam, Model model) {
        SearchResultVo resultVo = mallSearchService.search(searchParam);
        model.addAttribute("result",resultVo);
        return "list";
    }

实现类:

    @Override
    public SearchResultVo search(SearchParamVo searchParam) {
        // 构建查询请求
        SearchRequest searchRequest = builderSearchRequest(searchParam);

        SearchResultVo result = null;
        try {
            // 发送请求
            SearchResponse response = restHighLevelClient.search(searchRequest, GulimallElasticSearchConfig.COMMON_OPTIONS);
            // 封装返回结果
            result = builderSearchResult(response, searchParam);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return result;
    }

8、面包屑导航

  • 选择某个筛选条件,就在导航栏上显示。并且在筛选界面不显示条件
  • 在导航栏上取消某个筛选条件后,重新在筛选界面显示条件,并查询
    • 如果在导航栏上取消某个筛选条件后,其实就是将URL中这个条件对应的参数去掉。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5a0Y5JK1-1675935129421)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230124181551638.png)]

(1)属性面包屑导航栏

设计面包屑导航栏最重要的俩个操作:

  • 如何在导航栏上显示已选择的筛选条件
  • 在导航栏上取消某一个筛选条件,如何拼接参数重新查询

1、在 SearchResultVo 返回结果模型增加俩个属性,navs 封装了显示的筛选条件以及要跳转的链接地址,

attrIds 用于导航栏与筛选条件做联动效果。

    // 面包屑导航数据=======================
    private List<NavVo> navs = new ArrayList<>() ;
    // 筛选了哪些属性,将这些属性的id
    private List<Long> attrIds = new ArrayList<>();

    @Data
    public static class NavVo {
        private  String navName; // 属性名
        private  String navValue; // 属性值
        private  String navLink; // 链接地址

    }

SearchParamVo 增加 _queryString 属性,用于保存地址栏上URL地址中拼接的参数

    // URL 中原生的参数字符串
    private String _queryString;

其中URL参数可以通过 HttpServletRequest 获取

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Be2k5wwA-1675935129422)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230124183317980.png)]

getQueryString 方法返回URL中拼接的参数:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uVGIdnCO-1675935129422)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230124155257478.png)]

2、MallSearchServiceImpl 使用远程调用,根据 前端传过来的 attrId 查询属性信息

/*
         * 属性 面包屑导航
         * */

        // 保存面包屑导航
        List<SearchResultVo.NavVo> navVos = resultVo.getNavs();
        // 当URL中有属性条件时才会封装面包屑
        if (searchParam.getAttrs() != null && searchParam.getAttrs().size() > 0 ) {

            List<String> attrs = searchParam.getAttrs();
            for (String attr : attrs) {
                SearchResultVo.NavVo navVo = new SearchResultVo.NavVo();
                String[] s = attr.split("_");
                navVo.setNavValue(s[1]);
                try {
                    // 远程调用,查询属性信息
                    R r = productFeignService.getAttrById(Long.parseLong(s[0]));
                    // 设置筛选属性的id
                    resultVo.getAttrIds().add(Long.parseLong(s[0]));
                    AttrResponseVo attrResponseVo = r.getData("attr", new TypeReference<AttrResponseVo>() {});
                    navVo.setNavName(attrResponseVo.getAttrName());

                    String link = replaceQueryString(searchParam, attr,"attrs");
                    navVo.setNavLink("http://search.gulimall.com/list.html?" + link);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                navVos.add(navVo);
            }
            resultVo.setNavs(navVos);
        }

3、replaceQueryString 对参数进行转码。

  /*
    * 对指定key进行替换并进行转码
    * */
    @NotNull
    private String replaceQueryString(SearchParamVo searchParam, String attr,String key)  {
        // 替换URL中的属性参数:
        // 原始: http://search.gulimall.com/list.html?catalog3Id=225&attrs=2_MGA-AL00
        // 替换完: http://search.gulimall.com/list.html?catalog3Id=225
        // URL 参数需要进行编码
        String encode = null;
        try {
            encode = URLEncoder.encode(attr, "UTF-8");
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
        }
        encode = attr.replace("+","%20"); // Java中对空格编码会转化成+,而实际上前端的空格表示 %20
        String link = searchParam.get_queryString().replace("&"+key+"=" + encode, "");
        return link;
    }

4、远程调用:增加依赖 + 增加 @EnableFeignClients 注解 + 创建 FeignService 接口

@FeignClient("gulimall-product")
public interface ProductFeignService {

    /*
    * 远程调用:根据属性id查询属性信息
    * */
    @RequestMapping("product/attr/info/{attrId}")
    public R getAttrById(@PathVariable("attrId") Long attrId);
    }

前端代码已省略…资料提供的源码中是写好的…这里就不写了

(2)品牌面包屑导航栏

     /*
        * 品牌面包屑
        * */
        List<SearchResultVo.NavVo> navs = resultVo.getNavs();

        List<Long> brandIds = searchParam.getBrandId();
        if (brandIds != null && brandIds.size() > 0) {
            SearchResultVo.NavVo navVo = new SearchResultVo.NavVo();
            navVo.setNavName("品牌");
            R r = productFeignService.getBrandsByIds(brandIds);
            if (r.getCode() == 0) {
                List<BrandResponseVo> brands = r.getData("brands", new TypeReference<List<BrandResponseVo>>() {});
                // 品牌可能有多个,将品牌名拼接起来
                StringBuffer stringBuffer = new StringBuffer();
                String link = "";
                for (BrandResponseVo brand : brands) {
                   stringBuffer.append(brand.getName() + " ");
                    link = replaceQueryString(searchParam, brand.getBrandId() + "", "brandId");
                }
                navVo.setNavValue(stringBuffer.toString());
                navVo.setNavLink("http://search.gulimall.com/list.html?" + link);
                navs.add(navVo);

                resultVo.setNavs(navs);
            }
        }

远程调用:

@FeignClient("gulimall-product")
public interface ProductFeignService {

    /*
    * 远程调用:根据属性id查询属性信息
    * */
    @RequestMapping("product/attr/info/{attrId}")
    public R getAttrById(@PathVariable("attrId") Long attrId);

    /*
     * 根据 品牌id 集合查询品牌信息
     * */
    @RequestMapping("product/brand/infos")
    // @RequiresPermissions("com.atguigu.gulimall.product:brand:info")
    public R getBrandsByIds(@RequestBody List<Long> brandIds) ;
}

八、异步&线程池

1、线程回顾

(1)创建线程的4种方式

  • 继承 Thread 类
  • 实现 Runnable接口
  • 实现 Callable 接口 + FutureTask
  • 线程池方式

方式 1 和方式 2:主进程无法获取线程的运算结果。不适合当前场景

方式 3:主进程可以获取线程的运算结果,但是不利于控制服务器中的线程资源。可以导致服务器资源耗尽。

方式 4:通过如下两种方式初始化线程池

        // 使用 Executors 调用静态方法,创建不同的线程池
        Executors.newFixedThreadPool(3);
        // 自定义线程池
    new ThreadPoolExecutor(int corePoolSize,
                            int maximumPoolSize,
                            long keepAliveTime,
                            TimeUnit unit,
                            BlockingQueue<Runnable> workQueue,
                            ThreadFactory threadFactory,
                            RejectedExecutionHandler handler)

(2)线程池七大参数

参数含义
corePoolSize核心线程数,创建线程池就会创建指定的核心线程数。核心线程即使空闲也不会被销毁,除非指定了 allowCoreThreadTimeOut 参数
maximumPoolSize最大线程数,线程池允许创建的线程数。
keepAliveTime非核心线程数的活跃时间(maximumPoolSize - corePoolSize)
unit时间单位
BlockingQueue阻塞队列,线程超过核心线程数,会先进入到阻塞队列中等待
ThreadFactory创建线程时使用的工厂
RejectedExecutionHandler拒绝策略,如果线程数超过最大线程数并且阻塞队列已满,超出的线程会按照不同的拒绝策略执行

主要有4种拒绝策略

  1. AbortPolicy:直接丢弃任务,抛出异常,这是默认策略
  2. CallerRunsPolicy:只用调用者所在的线程来处理任务
  3. DiscardOldestPolicy:丢弃等待队列中最旧的任务,并执行当前任务
  4. DiscardPolicy:直接丢弃任务,也不抛出异常

线程池运行流程

  1. 首先线程池会创建 corePoolSize 个核心线程去执行任务
  2. 如果线程数超过了 corePoolSize ,多余的就去阻塞队列中等待
  3. 如果阻塞队列满了,线程池会创建非核心线程执行任务 (maximumPoolSize - corePoolSize),执行完任务,非核心线程会根据指定的 keepAliveTime 销毁。核心线程数不会销毁
  4. 如果阻塞队列满了,并且线程数超过了 maximumPoolSize ,多余的线程会按照指定的 拒绝策略执行

2、CompletableFuture 异步任务

CompletableFuture详细介绍

业务场景:

查询商品详情页的逻辑比较复杂,有些数据还需要远程调用,必然需要花费更多的时间。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lUcbvjmz-1675935129422)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230124213651059.png)]

如果查询商品的详情每个业务按照以上标注的时间完成,那么按照串行的执行方式需要 5.5s 才会查询出来,如果使用异步任务的话,这6个操作同时执行,那么就需要 1.5s。

FutureTask + Callable 也能够实现异步任务,但是 FutureTask 的 get 方法会阻塞线程,这种阻塞的方式是和异步编排的理念是不符合的,并且 如果使用 isDone 方法轮询会消耗CPU性能,因此对于异步任务 CompletableFuture 是 更好的选择

(1)创建 CompletableFuture 的四种方式

推荐使用 CompletableFuture 中的四个静态方法 创建异常任务:

runAsync 无返回值

  • public static CompletableFuture runAsync(Runnable runnable)
  • public static CompletableFuture runAsync(Runnable runnable, Executor executor)

supplyAsync 有返回值

  • public static CompletableFuture supplyAsync(Supplier supplier)
  • public static CompletableFuture supplyAsync(Supplier supplier, Executor executor)
public class CompletableFutureTest {
    // 自定义线程池
    private static ThreadPoolExecutor poolExecutor = new ThreadPoolExecutor(
            3,
            200,
            10,
            TimeUnit.SECONDS,
            new LinkedBlockingDeque<>(),
            Executors.defaultThreadFactory(),
            new ThreadPoolExecutor.AbortPolicy());
    /*
     * 异步编排 CompletableFuture*
     *   使用 CompletableFuture 建议使用自定义的线程池。、
     *   如果不指定自定义的线程池默认使用 ForkJoinPool,这个线程池中的线程跟守护线程是的
     *   如果主线程结束,ForkJoinPool也会随之关闭。因此如果主线程结束太快,异步任务可能会获取不到结果
     * */

    public static void main(String[] args) {

        System.out.println(Thread.currentThread().getName() + " start...");
        // 没有返回值
        /*        CompletableFuture.runAsync(() -> {
            System.out.println(Thread.currentThread().getName() + " start...");
            try {Thread.sleep(200);} catch (InterruptedException e) {e.printStackTrace();}
            System.out.println(Thread.currentThread().getName() +" end...");
        },poolExecutor);*/

        // 有返回值
        CompletableFuture.supplyAsync(() -> {
                    System.out.println(Thread.currentThread().getName() + " start...");
                    try {
                        Thread.sleep(200);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    int i = 10 /0 ;
                    System.out.println(Thread.currentThread().getName() + " end...");
                    return "hello";
                }, poolExecutor)
                .whenComplete((res, e) -> {
                    System.out.println("计算结果:" + res + ", 异常: " + e);
                }).exceptionally(e->{
                    // exceptionally 可以自定义返回异常结果
                    return  "错误原因: " + e.getMessage();
                });
        System.out.println(Thread.currentThread().getName() + " end...");


        // 线程池一定要关闭
        poolExecutor.shutdown();
    }
}

exceptionally : 出现异常执行的步骤

whenComplete: 获取异步任务的计算结果,不会出现阻塞、轮询的问题。

(2)handle

handle 对计算结果进行处理,计算结果存在在依赖关系,使得线程串行化。

与 thenApply 的唯一区别就是 如果出现异常 handle 会继续执行下一步,thenApply 则不会

/**
 *
 * Author: YZG
 * Date: 2022/11/20 17:17
 * Description: 
 */
public class CompletableFutureAPI2Test {
    public static void main(String[] args) throws Exception {

        // handle方法演示
        CompletableFuture.supplyAsync(() -> {
            System.out.println("第一步");
            return 1;
        }).handle((f, e) -> {
            System.out.println("第二步");

            // 出现异常
            // 即使出现异常,也会继续执行第三步
            int i = 10 / 0;

            return f + 2;
        }).handle((f, e) -> {
            System.out.println("第三步");
            return f + 3;
        }).whenComplete((f, e) -> {
            System.out.println("最终的计算结果: " + f);
        }).exceptionally(e -> {
            System.out.println(e.getCause() + "\t" + e.getMessage());
            return null;
        });
    }
}


(3)线程串行化方法

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kv3VDsDV-1675935129422)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230124222537821.png)]

thenApply 方法:当一个线程依赖另一个线程时,获取上一个任务返回的结果,并返回当前任务的返回值。

thenAccept 方法:消费处理结果。接收任务的处理结果,并消费处理,无返回结果。

thenRun 方法:只要上面的任务执行完成,就开始执行 thenRun,只是处理完任务后,执行thenRun 的后续操作

每个方法都带有 Async,可以指定线程池,并且将任务的执行交给线程池!

public class CompletableFutureTest01 {
    /*
     * 异步编排 CompletableFuture*
     *   使用 CompletableFuture 建议使用自定义的线程池。、
     *   如果不指定自定义的线程池默认使用 ForkJoinPool,这个线程池中的线程跟守护线程是的
     *   如果主线程结束,ForkJoinPool也会随之关闭。因此如果主线程结束太快,异步任务可能会获取不到结果
     * */

    private static ThreadPoolExecutor poolExecutor = new ThreadPoolExecutor(
            3,
            200,
            10,
            TimeUnit.SECONDS,
            new LinkedBlockingDeque<>(),
            Executors.defaultThreadFactory(),
            new ThreadPoolExecutor.AbortPolicy());

    // 异步线程串行化
    public static void main(String[] args) throws ExecutionException, InterruptedException {
       CompletableFuture.supplyAsync(() -> {
                    System.out.println("任务一开始...");
                    System.out.println("任务一结束...");
                    return "hello";
                }, poolExecutor)
               .thenApplyAsync(res ->{ // 依赖于上一步的计算结果,并且提供一个新的计算结果
                   System.out.println("任务二开始...");
                   System.out.println("任务二结束...");
                   return res + "world";
               },poolExecutor)
               .thenAccept(res ->{ // 依赖于上一步的计算结果,但是没有返回值

                   System.out.println("任务三开始...");
                   System.out.println("任务三结束..."+ res);
               })
                .thenRun(() ->{ // 不依赖上一步的计算结果,并且没有返回值
                    System.out.println("任务四开始...");
                    System.out.println("任务四结束...");
                })
                .whenCompleteAsync((res, e) -> { // 获取上一步的计算结果 null
                    System.out.println("计算结果:" + res + ", 异常: " + e);
                },poolExecutor);
       
        // String s = future.get();
        System.out.println(Thread.currentThread().getName() + " end... ");
    }
}

(4)俩任务组合都完成

俩个任务都完成,才能执行组合任务

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PWePnVKz-1675935129423)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230124223219943.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SujltJzD-1675935129423)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230124223226598.png)]

thenCombine:组合两个 future,获取两个 future 的返回结果,并返回当前任务的返回值

thenAcceptBoth:组合两个 future,获取两个 future 任务的返回结果,然后处理任务,没有返回值。

runAfterBoth:组合两个 future,不需要获取 future 的结果,只需两个 future 处理完任务后, 处理该任务。

public class CompletableFutureTest02 {
    /*
     * 异步编排 CompletableFuture*
     *   使用 CompletableFuture 建议使用自定义的线程池。、
     *   如果不指定自定义的线程池默认使用 ForkJoinPool,这个线程池中的线程跟守护线程是的
     *   如果主线程结束,ForkJoinPool也会随之关闭。因此如果主线程结束太快,异步任务可能会获取不到结果
     * */

    private static ThreadPoolExecutor poolExecutor = new ThreadPoolExecutor(
            3,
            200,
            10,
            TimeUnit.SECONDS,
            new LinkedBlockingDeque<>(),
            Executors.defaultThreadFactory(),
            new ThreadPoolExecutor.AbortPolicy());

    // 异步线程串行化
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        CompletableFuture<String> future01 = CompletableFuture.supplyAsync(() -> {
            System.out.println("任务一开始...");
            System.out.println("任务一结束...");
            return "hello";
        }, poolExecutor);


        CompletableFuture<String> future02 = CompletableFuture.supplyAsync(() -> {
            System.out.println("任务二开始...");
            System.out.println("任务二结束...");
            return "world";
        }, poolExecutor);

        // 不会获得合并的俩个任务的计算结果,没有返回值
        future01.runAfterBothAsync(future02,()->{
            System.out.println("任务三开始...");
            System.out.println("任务三结束...");
        },poolExecutor);

        // 能获取合并的俩个任务计算结果,没有返回值
        future01.thenAcceptBothAsync(future02,(res1,res2) ->{
            System.out.println("任务四开始...");
            System.out.println("第一个任务的计算结果: " + res1 + " 第二个任务的计算结果: " +res2);
            System.out.println("任务四结束...");
        },poolExecutor);

        // 能获取俩个任务的计算结果,并且有返回值
        CompletableFuture<String> future = future01.thenCombineAsync(future02, (res1, res2) -> {
            System.out.println("任务四开始...");
            System.out.println("第一个任务的计算结果: " + res1 + " 第二个任务的计算结果: " + res2);
            System.out.println("任务四结束...");
            return res1 + res2;
        }, poolExecutor);

        String s = future.get();
        System.out.println(Thread.currentThread().getName() + " end... " + s); // main end... helloworld
    }
}

(5) 俩任务有一个完成

俩个任务有一个完成就能执行任务

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cmSr25NS-1675935129423)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230124225337547.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uJwbireC-1675935129423)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230124225343575.png)]

applyToEither:两个任务有一个执行完成,获取它的返回值,处理任务并有新的返回值。

acceptEither:两个任务有一个执行完成,获取它的返回值,处理任务,没有新的返回值。

runAfterEither:两个任务有一个执行完成,不需要获取 future 的结果,处理任务,也没有返 回值。

(6)多任务组合

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9YSVcHoE-1675935129423)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230124225951936.png)]

allOf:等待所有任务完成

anyOf:只要有一个任务完成

public class CompletableFutureTest03 {
    /*
     * 异步编排 CompletableFuture*
     *   使用 CompletableFuture 建议使用自定义的线程池。、
     *   如果不指定自定义的线程池默认使用 ForkJoinPool,这个线程池中的线程跟守护线程是的
     *   如果主线程结束,ForkJoinPool也会随之关闭。因此如果主线程结束太快,异步任务可能会获取不到结果
     * */

    private static ThreadPoolExecutor poolExecutor = new ThreadPoolExecutor(
            3,
            200,
            10,
            TimeUnit.SECONDS,
            new LinkedBlockingDeque<>(),
            Executors.defaultThreadFactory(),
            new ThreadPoolExecutor.AbortPolicy());

    // 异步线程串行化
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        CompletableFuture<String> future01 = CompletableFuture.supplyAsync(() -> {
            try {Thread.sleep(200);} catch (InterruptedException e) {e.printStackTrace();}
            System.out.println("查询图片信息...");
            return "hello.jpg";
        }, poolExecutor);


        CompletableFuture<String> future02 = CompletableFuture.supplyAsync(() -> {
            System.out.println("查询属性信息...");
            try {Thread.sleep(300);} catch (InterruptedException e) {e.printStackTrace();}
            return "attrs";
        }, poolExecutor);

        CompletableFuture<String> future03 = CompletableFuture.supplyAsync(() -> {
            System.out.println("查询商品信息...");
            try {Thread.sleep(400);} catch (InterruptedException e) {e.printStackTrace();}
            return "attrs";
        }, poolExecutor);

        // 所有任务都完成
        CompletableFuture<Void> allOf = CompletableFuture.allOf(future01, future03, future02);
        // 只要有一个任务完成即可,返回值就是第一个完成任务的返回值
        CompletableFuture<Object> anyOf = CompletableFuture.anyOf(future01, future03, future02);
        allOf.get();
        Object o = anyOf.get();
        System.out.println(Thread.currentThread().getName() + " end... " + o); // 
    }
}

九、商品详情

1、环境搭建

1、hosts 文件增加映射关系

192.168.56.111 		item.gulimall.com

2、x修改网关配置

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-416gEzaB-1675935129424)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230125150326051.png)]

3、将商品详情页的静态资源放到 虚拟机上,将 页面放到 product 下的 templates 模板下,并修改静态资源链接

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-sPZh7Hze-1675935129424)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230125150559000.png)]

4、修改搜索页面的商品跳转连接

                             <a th:href="|http://item.gulimall.com/${product.skuId}.html|" >
                                    <img   class="dim" th:src="${product.skuImg}">
                                </a>
                            </p>

5、创建 Controller 实现跳转

@Controller
public class ItemController {

    @GetMapping("/{skuId}.html")
    public String item(@PathVariable Long skuId) {
        System.out.println("跳转到商品详情页: " + skuId);
        return "item";
    }
}

2、页面模型抽取

1、Sku 的基本信息:对应的表 pms_sku_info

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zmT6eh9o-1675935129424)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230125151956180.png)]

2、Sku 的图片信息:对应的表 pms_sku_images

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rchBZTPl-1675935129425)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230125152101581.png)]

3、Sku 的销售属性组合:比如:

第一组属性: 属性名:颜色 属性值:摩卡金,亮黑色,香槟金,樱粉金

第二组属性: 属性名: 版本 属性值:标准版,套装版

第三组属性: 属性名: 内存 属性值:64G,128G

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MEilnQVH-1675935129425)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230125152142648.png)]

4、商品介绍信息:对应的表 pms_spu_info_desc

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ERFZxs46-1675935129425)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230125152524029.png)]

5、商品的规格参数:一个属性分组下对应多个属性,设计的字段就应该有:分组名,属性名,属性值

分组与属性名、属性值是相关联的。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Brm9iQBg-1675935129425)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230125152637027.png)]

对应的Vo实体类:


/**
 * 商品详情Vo
 * Author: YZG
 * Date: 2023/1/25 15:30
 * Description: 
 */
@Data
public class SkuItemVo {

    // 1、Sku 的基本信息:对应的表 `pms_sku_info`
    private SkuInfoEntity info;
    // 2、Sku 的图片信息:对应的表 `pms_sku_images`
    private SkuImagesEntity images;
    // 3、Sku 的销售属性组合
    private List<SkuItemSaleAttrVo> saleAttrs;
    // 4、商品介绍信息:对应的表`pms_spu_info_desc`
    private SpuInfoDescEntity desc;
    // 5、商品的规格参数:一个属性分组下对应多个属性
    List<SpuItemAttrGroupVo> groupAttrs;

    /**
     * 商品的销售属性信息
     * */
    @Data
    public static class SkuItemSaleAttrVo {
        /**
         * 属性id
         */
        private Long attrId;
        /**
         * 属性名
         */
        private String attrName;
        /**
         * 属性值
         * */
        private String attrValues;
    }

    /**
     * 分组信息
     * */
    @Data
    public static class SpuItemAttrGroupVo {
        private String groupName;
        List<SpuBaseAttr> attrs;
    }

    /**
    * 分组下的基本属性信息
    * */
    @Data
    public static class SpuBaseAttr {
        private String attrName;
        private String attrValue;
    }

}

3、查询商品规格参数

首先分析商品规格参数都需要查询哪些属性:

  • 分组名
    • 属性名
    • 属性值

分组名在 pms_attr_group 表,分组与属性的关系在 pms_attr_attrgroup_relation 表,

属性值与属性名在 pms_product_attr_value 表,需要我们连表查询

1、首先根据 分类id 查询该分类下的所有分组

select * from pms_attr_group where catelog_id=225

2、根据分组id 查询分组下对应的所有属性

select ag.`catelog_id`, ag.`attr_group_name`
from pms_attr_group ag
left join `pms_attr_attrgroup_relation` aar
on aar.`attr_group_id` = ag.`attr_group_id`
where ag.`catelog_id`=225

3、根据 属性id,商品id 查询出所有的属性名、属性值

select ag.`catelog_id`, ag.`attr_group_name`,pav.`attr_name`, pav.`attr_value`,aar.`attr_id`,ag.`attr_group_id`
from pms_attr_group ag
left join `pms_attr_attrgroup_relation` aar
on aar.`attr_group_id` = ag.`attr_group_id`
left join `pms_product_attr_value` pav
on pav.`attr_id` = aar.`attr_id`
where ag.`catelog_id`=225 and pav.`spu_id`=8

4、在 AttrGroupDao.Xml 文件中 声明 SQL

    <!--自定义封装结果集,只要有嵌套属性一定要自定义结果集-->
    <!--
        type 返回集合里面的元素类型
        如果内部类,使用 $ 代替 .
    -->
    <resultMap id="SpuItemAttrGroupVo" type="com.atguigu.gulimall.product.vo.SkuItemVo$SpuItemAttrGroupVo" >
        <result property="groupName" column="attr_group_name" />
        <!--attrs 是一个集合,必须指明-->
        <collection property="attrs" ofType="com.atguigu.gulimall.product.vo.SkuItemVo$SpuBaseAttr">
            <result property="attrName" column="attr_name" />
            <result property="attrValue" column="attr_value" />
        </collection>

    </resultMap>


    <!--resultMap 指定返回类型是自定义的结果集-->
    <select id="getAttrGroupWithAttrsBySpuId" resultMap="SpuItemAttrGroupVo">

        select ag.`catelog_id`, ag.`attr_group_name`,pav.`attr_name`, pav.`attr_value`,aar.`attr_id`,ag.`attr_group_id`
        from pms_attr_group ag
                 left join `pms_attr_attrgroup_relation` aar
                           on aar.`attr_group_id` = ag.`attr_group_id`
                 left join `pms_product_attr_value` pav
                           on pav.`attr_id` = aar.`attr_id`
        where ag.`catelog_id`=#{catelogId} and pav.`spu_id`=#{spuId}

    </select>

5、 方法

    List<SkuItemVo.SpuItemAttrGroupVo> getAttrGroupWithAttrsBySpuId(@Param("spuId") Long spuId, @Param("catelogId") Long catelogId);

4、查询SKU 销售属性

1、根据 spuId pms_sku_info 表中查询 出所有的 sku

select psi.`sku_id`
from `pms_sku_info` psi
where psi.spu_id = 8

2、根据 skuIdpms_sku_sale_attr_value表中查询出所有的销售属性组合

# psi.`sku_id` 使用分组,查询出的字段就必须是分组所包含的,否则就使用GROUP_CONCAT
SELECT sav.`attr_id`,sav.`attr_name`,GROUP_CONCAT(DISTINCT sav.`attr_value`) attrValues
FROM `pms_sku_info` psi
LEFT JOIN `pms_sku_sale_attr_value` sav
ON sav.`sku_id` = psi.`sku_id`
WHERE psi.spu_id = 8
GROUP BY sav.`attr_id`, sav.`attr_name`

3、SkuSaleAttrValueDao.xml

    <select id="getSaleAttrsBySpuID" resultType="com.atguigu.gulimall.product.vo.SkuItemVo$SkuItemSaleAttrVo">
        SELECT sav.`attr_id`,sav.`attr_name`,GROUP_CONCAT(DISTINCT sav.`attr_value`) attrValues
        FROM `pms_sku_info` psi
                 LEFT JOIN `pms_sku_sale_attr_value` sav
                           ON sav.`sku_id` = psi.`sku_id`
        WHERE psi.spu_id = #{spuId}
        GROUP BY sav.`attr_id`, sav.`attr_name`

    </select>

4、方法

List<SkuItemVo.SkuItemSaleAttrVo> getSaleAttrsBySpuID(Long spuId);

5、封装商品详情信息完整代码

Controller

    @GetMapping("/{skuId}.html")
    public String item(@PathVariable Long skuId, Model model) {
        SkuItemVo skuItemVo = skuInfoService.item(skuId);
        model.addAttribute("item",skuItemVo);
        return "item";
    }

Service

  /*
    * 商品详情
    * */
    @Override
    public SkuItemVo item(Long skuId) {

        SkuItemVo skuItemVo = new SkuItemVo();
        // 1、Sku 的基本信息:对应的表 `pms_sku_info`
        SkuInfoEntity skuInfo = getById(skuId);
        skuItemVo.setInfo(skuInfo);
        // 获取SpuId
        Long spuId = skuInfo.getSpuId();
        // 获取catelogId
        Long catelogId = skuInfo.getCatelogId();

        // 2、Sku 的图片信息:对应的表 `pms_sku_images`
        List<SkuImagesEntity> skuImages = skuImagesService.list(new QueryWrapper<SkuImagesEntity>().eq("sku_id", skuId));
        skuItemVo.setImages(skuImages);

        // 3、Sku 的销售属性组合
        List<SkuItemVo.SkuItemSaleAttrVo> saleAttrVos = skuSaleAttrValueService.getSaleAttrsBySpuID(spuId);
        skuItemVo.setSaleAttrs(saleAttrVos);

        // 4、商品介绍信息:对应的表`pms_spu_info_desc`
        SpuInfoDescEntity desc = spuInfoDescService.getById(spuId);
        skuItemVo.setDesc(desc);

        // 5、商品的规格参数:一个属性分组下对应多个属性
        List<SkuItemVo.SpuItemAttrGroupVo> attrGroupVos = attrGroupService.getAttrGroupWithAttrsBySpuId(spuId,catelogId);
        skuItemVo.setGroupAttrs(attrGroupVos);


        return skuItemVo;
    }

前端代码已省略

6、SKU组合切换

点击不同的 sku组合,显示不同的 sku 基本信息

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-L03rhjIo-1675935129426)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230125193235466.png)]

使用 查交集 的方法:

版本 和 颜色 一共有 6中组合关系,可以先各自查出不同属性值对应的 skuId,取出俩个属性值的 skuId 交集。就确定了一个唯一的 sku 信息。

比如:

版本属性值为 8+128 的 skuid 有 13、14、15

颜色属性值为 午夜色的 skuid 有 15、18

那么 8+128G、午夜色 就展示 skuid 为 15 的 商品 sku 信息

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3LgMaiNN-1675935129426)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230125193907408.png)]

1、修改 Vo 模型

属性名 ——》 对应多个属性值

每个属性值 ——》对应 含有该属性值的所有 skuid


    /**
     * 商品的销售属性信息
     * */
    @Data
    public static class SkuItemSaleAttrVo {
        /**
         * 属性id
         */
        private Long attrId;
        /**
         * 属性名
         */
        private String attrName;
        /**
         * 属性值
         * */
        private List<AttrValueWithSkuIdVo> attrValues;
    }

    /**
     * 属性值与 skuid 的对应关系
     * */
    @Data
    public static class AttrValueWithSkuIdVo{

        private String attrValue;
        private String skuIds;
    }

修改SQL 语句,不仅要对 属性值 分组,还要查询出 包含该属性值的所有skuid

# psi.`sku_id` 使用分组,查询出的字段就必须是分组所包含的,否则就使用GROUP_CONCAT
select 
	sav.`attr_id`,
	sav.`attr_name`,
	sav.`attr_value` attrValues,
	GROUP_CONCAT(DISTINCT psi.`sku_id`) skuIds
	
from `pms_sku_info` psi
left join `pms_sku_sale_attr_value` sav
on sav.`sku_id` = psi.`sku_id`
where psi.spu_id = 9
group by sav.`attr_id`, sav.`attr_name`,sav.`attr_value`

查询结果:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UDQcCdNU-1675935129426)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230125183113058.png)]

修改 映射文件:

  <!--自定义结果集-->
    <resultMap id="SkuItemSaleAttrVo" type="com.atguigu.gulimall.product.vo.SkuItemVo$SkuItemSaleAttrVo">
        <result column="attr_id" property="attrId" />
        <result column="attr_name" property="attrName" />
        <collection property="attrValues" ofType="com.atguigu.gulimall.product.vo.SkuItemVo$AttrValueWithSkuIdVo">
            <result column="attr_value" property="attrValue" />
            <result column="sku_ids" property="skuIds" />
        </collection>
    </resultMap>

    <select id="getSaleAttrsBySpuID" resultMap="SkuItemSaleAttrVo">
        select
            sav.`attr_id`,
            sav.`attr_name`,
            sav.`attr_value` ,
            GROUP_CONCAT(DISTINCT sav.`sku_id`) sku_ids

        from `pms_sku_info` psi
                 left join `pms_sku_sale_attr_value` sav
                           on sav.`sku_id` = psi.`sku_id`
        where psi.spu_id = #{spuId}
        group by sav.`attr_id`, sav.`attr_name`,sav.`attr_value`

    </select>

7、异步编排优化

1、自定义线程池,线程池的参数可以通过 配置文件修改

@Configuration
public class MyThreadConfig {

    @Bean
    public ThreadPoolExecutor threadPoolExecutor(ThreadPoolConfigProperties configProperties) {
        return  new ThreadPoolExecutor(
                configProperties.getCorePoolSize(),
                configProperties.getMaximumPoolSize(),
                configProperties.getKeepAliveTime(),
                TimeUnit.SECONDS,
                new LinkedBlockingDeque<>(1000000),
                Executors.defaultThreadFactory(),
                new ThreadPoolExecutor.AbortPolicy());
    }
}

ThreadPoolConfigProperties 关联配置文件

此时就可以通过:gulimall.thread.corePoolSize 设置参数值

@ConfigurationProperties(prefix ="gulimall.thread")
@Component
@Data
public class ThreadPoolConfigProperties {

    private Integer corePoolSize;
    private Integer maximumPoolSize;
    private Long keepAliveTime;

}

2、增加一个配置提示的依赖

        <!--配置提示-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>

线程池配置:(记着重启之后再设置)

# 线程池配置
gulimall.thread.core-pool-size=50
gulimall.thread.keep-alive-time=10
gulimall.thread.maximum-pool-size=200
    @Autowired
   private  ThreadPoolExecutor threadPoolExecutor;

/*
    * 商品详情
    * */
    @Override
    public SkuItemVo item(Long skuId) {

        SkuItemVo skuItemVo = new SkuItemVo();

        CompletableFuture<SkuInfoEntity> infoFuture = CompletableFuture.supplyAsync(() -> {
            // 1、Sku 的基本信息:对应的表 `pms_sku_info`
            SkuInfoEntity skuInfo = getById(skuId);
            skuItemVo.setInfo(skuInfo);
            return skuInfo;
        }, threadPoolExecutor);


        CompletableFuture<Void> imageFuture = CompletableFuture.runAsync(() -> {
            // 2、Sku 的图片信息:对应的表 `pms_sku_images`
            List<SkuImagesEntity> skuImages = skuImagesService.list(new QueryWrapper<SkuImagesEntity>().eq("sku_id", skuId));
            skuItemVo.setImages(skuImages);
        }, threadPoolExecutor);


        CompletableFuture<Void> skuAttrFuture = infoFuture.thenAcceptAsync((res) -> {
            // 3、Sku 的销售属性组合
            List<SkuItemVo.SkuItemSaleAttrVo> saleAttrVos = skuSaleAttrValueService.getSaleAttrsBySpuID(res.getSpuId());
            skuItemVo.setSaleAttrs(saleAttrVos);
        }, threadPoolExecutor);


        CompletableFuture<Void> descFuture = infoFuture.thenAcceptAsync((res) -> {
            // 4、商品介绍信息:对应的表`pms_spu_info_desc`
            SpuInfoDescEntity desc = spuInfoDescService.getById(res.getSpuId());
            skuItemVo.setDesc(desc);
        }, threadPoolExecutor);


        CompletableFuture<Void> baseAttrFuture = infoFuture.thenAcceptAsync((res) -> {
            // 5、商品的规格参数:一个属性分组下对应多个属性
            List<SkuItemVo.SpuItemAttrGroupVo> attrGroupVos = attrGroupService.getAttrGroupWithAttrsBySpuId(res.getSpuId(),res.getCatelogId());
            skuItemVo.setGroupAttrs(attrGroupVos);
        }, threadPoolExecutor);


        // 等待所有异步任务结束
        try {
            CompletableFuture.allOf(baseAttrFuture,descFuture,imageFuture,skuAttrFuture).get();
        } catch (Exception e) {
            e.printStackTrace();
        }

        return skuItemVo;
    }

十、认证服务

1、环境搭建

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VmCzdpoY-1675935129427)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230127082841769.png)]

1、POM 依赖

 <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>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-openfeign</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <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>

2、启动类增加 注解

@EnableDiscoveryClient
@EnableFeignClients

3、配置 NAcos 服务中心,服务名,端口号

spring:
    application:
        name: gulimall-auth-server
    cloud:
        nacos:
            discovery:
                server-addr: localhost:8848
server:
    port: 20000

4、将 登录、注册页面 与 静态资源实现动静分离,并修改静态资源链接

5、网关配置

                - id: gulimall-auth-route
                  uri: lb://gulimall-auth-server # 负载均衡
                  predicates:
                      - Host=auth.gulimall.com

使用 ViewController 代替controller 跳转:

@Configuration
public class MyWebConfig implements WebMvcConfigurer {

    /*
    * 增加视图控制器——视图与请求的映射关系
    * */
    @Override
    public void addViewControllers(ViewControllerRegistry registry) {

        /**
         *     @RequestMapping("login.html")
         *     public String login() {
         *         return  "login";
         *     }
         * */
        registry.addViewController("login.html").setViewName("login");
        /**
         *     @RequestMapping("reg.html")
         *     public String reg() {
         *         return  "reg";
         *     }
         * */
        registry.addViewController("reg.html").setViewName("reg");
    }
}

2、短信验证码

我是用的是 阿里云提供的短信服务,需要申请 签名管理 模板管理 , 具体的申请过程: 开通阿里云短信

如果使用阿里云短信接口的去看视频即可。

API调试控制台: SendSms_短信服务_API调试-阿里云OpenAPI开发者门户 (aliyun.com)

控制台提供了发送短信的 API

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1RFf85yA-1675935129427)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230127103537614.png)]

所有的第三方服务都放在 gulimall-third-party

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-e9TnaMWo-1675935129427)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230127112858333.png)]

1、导入依赖

        <!--阿里云短信服务-->
        <dependency>
            <groupId>com.aliyun</groupId>
            <artifactId>dysmsapi20170525</artifactId>
            <version>2.0.23</version>
        </dependency>

2、创建发送短信组件

@Configuration
@ConfigurationProperties(prefix = "spring.cloud.alicloud.sms")
@Component
@Data
public class SmsSendComponent {


    private String accessKeyId;
    private String accessKeySecret;
    private String signName; // 签名名称
    private String templateCode; // 模板CODE

    /**
     * 使用AK&SK初始化账号Client
     * @return Client
     * @throws Exception
     */
    public  com.aliyun.dysmsapi20170525.Client createClient() throws Exception {
        com.aliyun.teaopenapi.models.Config config = new com.aliyun.teaopenapi.models.Config()
                // 必填,您的 AccessKey ID
                .setAccessKeyId(accessKeyId)
                // 必填,您的 AccessKey Secret
                .setAccessKeySecret(accessKeySecret);
        // 访问的域名
        config.endpoint = "dysmsapi.aliyuncs.com";
        return new com.aliyun.dysmsapi20170525.Client(config);
    }

    public void sendCode(String phone,String code) throws Exception {

        com.aliyun.dysmsapi20170525.Client client = this.createClient();
        com.aliyun.dysmsapi20170525.models.SendSmsRequest sendSmsRequest = new com.aliyun.dysmsapi20170525.models.SendSmsRequest()
                .setPhoneNumbers(phone)
                .setSignName("TaGao")
                .setTemplateCode("SMS_250740234")
                .setTemplateParam("{\"code\":\""+code+"\"}");

        com.aliyun.teautil.models.RuntimeOptions runtime = new com.aliyun.teautil.models.RuntimeOptions();
        try {
            SendSmsResponse response = client.sendSmsWithOptions(sendSmsRequest, runtime);
            // 打印响应信息
            String responJson = JSON.toJSONString(response.getBody());
            System.out.println(responJson);
        } catch (TeaException error) {
            // 如有需要,请打印 error
            System.out.println("错误信息: " + error);
            com.aliyun.teautil.Common.assertAsString(error.message);
        } catch (Exception _error) {
            TeaException error = new TeaException(_error.getMessage(), _error);
            System.out.println("错误信息: " + error);
            // 如有需要,请打印 error
            com.aliyun.teautil.Common.assertAsString(error.message);
        }
    }

}

3、配置文件

spring:
    cloud:
        alicloud:
            sms:
                access-key-id: your access-key
                access-key-secret: your  access-key-secret
                sign-name: TaGao
                template-code: SMS_250740234

4、Controller 层提供远程调用方法

@RestController
@RequestMapping("/sms")
public class SmsController {

    @Autowired
    private SmsSendComponent sendComponent;

    @RequestMapping("/sendCode")
    public R sendCode(@RequestParam("phone") String phone, @RequestParam("code")String code){
        try {
            sendComponent.sendCode(phone,code);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return R.ok();
    }
}

5、在 gulimall-auth-server 模块中远程调用发送短信

controller

    /**
     * 发送验证码
     * */
    @ResponseBody
    @RequestMapping("/sendCode")
    public R sendCode(@RequestParam("phone") String phone) {
        // 随机生产四位数字验证码
        String code = String.valueOf(new Random().nextInt(9000) + 1000);
        System.out.println("验证码: " + code);
        thirdPartyService.sendCode(phone,code);
        return R.ok();
    }

Feign 接口

@FeignClient("gulimall-third-party")
public interface ThirdPartyService {

    /**
     * 发送验证码
     * */
    @RequestMapping("/sms/sendCode")
    public R sendCode(@RequestParam("phone") String phone, @RequestParam("code")String code);
}

3、验证码防刷校验

  • 对 发送验证码 的时间间隔做一个设置,防止有人恶意刷新发送验证码
    • 在将验证码存入redis时,增加时间戳,在每次发送验证码时,判断当前时间与redis中验证码的时间戳的差值是否大于 60s
  • 在进行注册时,需要先判断验证码是否正确,在将用户的个人数据插入到数据库
    • 将 验证码 存入 redis,判断时取出即可
 /**
     * 发送验证码
     * */
    @ResponseBody
    @RequestMapping("/sms/sendCode")
    public R sendCode(@RequestParam("phone") String phone) {
        // TODO: 1、接口防刷
        // 2、防止再次发送验证码
        String redisCode = (String) redisTemplate.opsForValue().get(AuthServerConstant.SMS_CODE_CACHE_PREFIX + phone);
        if (!StringUtils.isEmpty(redisCode)) {
            // 判断存入code的时间与当前时间,如果相差小于60s 就不允许再次发送验证码
            Long l = Long.parseLong(redisCode.split("_")[1]);
            if (System.currentTimeMillis() - l < 60000 ) {
                return R.error(BizCodeEnum.VALID_CODE_EXCEPTION.getCode(), BizCodeEnum.VALID_CODE_EXCEPTION.getMessage());
            }
        }

        // 随机生产四位数字验证码
        String code = String.valueOf(new Random().nextInt(9000) + 1000) ;
        // 放入redis,并加上时间戳,并设置过期时间
        redisTemplate.opsForValue()
                .set(AuthServerConstant.SMS_CODE_CACHE_PREFIX + phone,code + "_" +System.currentTimeMillis(),5, TimeUnit.MINUTES);
        System.out.println("验证码: " + code);
        // 发送验证码
        thirdPartyService.sendCode(phone,code);
        return R.ok();
    }

其实在使用阿里云短信服务时,就已经内置了 短信分钟限流,就算不写以上验证代码,在 60s 重复发送短信也是失败的。这是阿里云做好的。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LT9o3kWF-1675935129428)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230127135056630.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SWel8v7G-1675935129428)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230127225224523.png)]

4、注册功能实现

对前端发送过来的参数封装成Vo,并对【密码、用户名、手机号、验证码】进行格式校验,使用 JSR303 注解进行验证。

@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 = "(13[0-9]|14[01456879]|15[0-35-9]|16[2567]|17[0-8]|18[0-9]|19[0-35-9])\\d{8}",message = "手机号格式不正确")
    private String phone;

    @NotEmpty(message = "验证码不能为空")
    private String code ;
}

(1)LoginController 接受参数

  • 判断参数是否有校验错误
    • 如果有,封装到 map 集合中,保存到 session 域中,方便前端获取
  • 判断前端传过来的验证码是否和redis中的一致
    • 如果不一致,说明验证码错误
    • 如果没获取到redis中的验证码说明验证码过期
  • 验证码没问题,通过远程调用保存用户信息
  /**
     * TODO: 重定向携带数据使用session,但是在分布式系统下还有一些问题....
     * 注册
     * */
    @PostMapping("/register")
    public String regist(@Valid UserRegistVo userRegistVo, BindingResult result, RedirectAttributes attributes) {

        HashMap<String, String> errorMap = new HashMap<>();
        // 1、校验参数格式,并将错误信息封装到map中
        if (result.hasErrors()) {
            for (FieldError fieldError : result.getFieldErrors()) {
                    errorMap.put(fieldError.getField(),fieldError.getDefaultMessage());
            }
            // 将错误信息保存到 session域中
            // addFlashAttribute 只需要取出来一次,刷新页面就没有了
            attributes.addFlashAttribute("errors",errorMap);
            // 重定向到注册页面
            return  "redirect:http://auth.gulimall.com/reg.html";
        }

        // 2、判断验证码是否一致
        String code = redisTemplate.opsForValue().get(AuthServerConstant.SMS_CODE_CACHE_PREFIX + userRegistVo.getPhone());

        
        if (!StringUtils.isEmpty(code)) {
            if (userRegistVo.getCode().equalsIgnoreCase(code.split("_")[0])){
                // 验证码一致
                // 删除redis中的验证码
                redisTemplate.delete(AuthServerConstant.SMS_CODE_CACHE_PREFIX + userRegistVo.getPhone());
                // 远程调用,将信息保存到数据库中
                R r = memberService.regist(userRegistVo);
                if (r.getCode() == 0) {
                    // 调用成功
                    return  "redirect:http://auth.gulimall.com/login.html";
                }else{
                    // 将错误消息放到map集合
                    errorMap.put("msg",(String) r.get("msg"));
                    attributes.addFlashAttribute("errors",errorMap);
                    // 调用失败
                    return  "redirect:http://auth.gulimall.com/reg.html";
                }
            }else{
                errorMap.put("code","验证码错误");
                return "reg";
            }
        }else {
            // 验证码过期
            errorMap.put("code"," 验证码过期");
            attributes.addFlashAttribute("errors",errorMap);
            return  "redirect:http://auth.gulimall.com/reg.html";
        }
    }

(2) MemberFeignService 接口

@FeignClient("gulimall-member")
public interface MemberService {

    @PostMapping("member/member/regist")
    public R regist(@RequestBody UserRegistVo vo);
}

(3)MemberController

  /**
     * 注册
     * */
    @PostMapping("/regist")
    // @RequiresPermissions("member:member:save")
    public R regist(@RequestBody MemberRegistVo vo){
        try {
            memberService.regist(vo);
        // 将异常catch掉
        } catch (UserNameExistException e) {
           return R.error(BizCodeEnum.USERNAME_EXIST_EXCEPTION.getCode(),BizCodeEnum.USERNAME_EXIST_EXCEPTION.getMessage());
        }catch (PhoneExistException e) {
            return R.error(BizCodeEnum.PHONE_EXIST_EXCEPTION.getCode(),BizCodeEnum.PHONE_EXIST_EXCEPTION.getMessage());
        }
        return R.ok();
    }

自定义异常码

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-n6HL3BDk-1675935129428)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230127224923016.png)]

将 UserRegistVo 向 Member 模块复制一份

@Data
public class MemberRegistVo {
    
    private String userName;
    
    private String password;
    
    private String phone;
    
}

(4)MemberServiceImpl

  • 检查用户名、手机号是否重复,如果重复直接抛出异常,终止执行。
  • 对密码进行 MD5+随机盐 加密
 @Override
    public void regist(MemberRegistVo vo) {

        MemberEntity memberEntity = new MemberEntity();
        memberEntity.setCreateTime(new Date());
        // 设置会员默认等级
        MemberLevelEntity defaultLevel = memberLevelService.getOne(new QueryWrapper<MemberLevelEntity>().eq("default_status", 1));
        memberEntity.setLevelId(defaultLevel.getId());

        // 设置用户名和密码之前,判断用户名、手机是否重复
        // 如果重复抛异常执行终止
        checkPhoneUnique(vo.getPhone());
        checkUserNameUnique(vo.getUserName());

        memberEntity.setUsername(vo.getUserName());
        memberEntity.setMobile(vo.getPhone());

        // TODO 设置用户密码,但是不能简单的直接将密码保存到数据库需要进行加密处理
        BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
        // 加密
        String password = bCryptPasswordEncoder.encode(vo.getPassword());
        memberEntity.setPassword(password);

        this.save(memberEntity);
    }

    @Override
    public void checkUserNameUnique(String userName) throws UserNameExistException{
        Long count = baseMapper.selectCount(new QueryWrapper<MemberEntity>().eq("username", userName));
        if (count > 0) {
            // 用户名重复,抛出异常
            throw new UserNameExistException();
        }
    }

    @Override
    public void checkPhoneUnique(String phone) throws PhoneExistException{
        Long count = baseMapper.selectCount(new QueryWrapper<MemberEntity>().eq("mobile", phone));
        if (count > 0) {
            // 手机号重复,抛出异常
            throw new PhoneExistException();
        }
    }

自定义异常:

public class PhoneExistException extends RuntimeException{
    public PhoneExistException() {
        super("手机号重复");
    }
}


public class UserNameExistException extends RuntimeException{
    public UserNameExistException() {
        super("用户名重复");
    }
}

5、MD5 加密算法

MD5

• Message Digest algorithm 5,信息摘要算法

• 压缩性:任意长度的数据,算出的MD5值长度都是固定的。

• 容易计算:从原数据计算出MD5值很容易。

• 抗修改性:对原数据进行任何改动,哪怕只修改1个字节,所得到的MD5值都有很大区别。

• 强抗碰撞:想找到两个不同的数据,使它们具有相同的MD5值,是非常困难的。

• 不可逆

加盐 :

• 通过生成随机数与MD5生成字符串进行组合

• 数据库同时存储MD5值与 salt值。验证正确性时使用salt进行MD5即可

如果简单的使用 MD5 加密算法,是非常危险的,由于MD5的抗修改性,相同内容加密后是一样的,正因为如此网上也有很多对 MD5 暴力破解的。

@Test
public void contextLoads() {
    // MD5 加密
    // 81dc9bdb52d04dc20036dbd8313ed055
    // 81dc9bdb52d04dc20036dbd8313ed055
    String s = DigestUtils.md5Hex("1234");
    System.out.println(s);
}

使用 盐+MD5 进行加密,不同的盐加密后的值也不一样。Spring提供了 BCryptPasswordEncoder 类使用 随机盐 + MD5 进行加密:

        BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
        // 随机盐加密
        String encode = encoder.encode("123456");

由于MD5的不可逆行,无法解密,可以通过对密码加密,判断加密后的值是否相等

boolean matches = encoder.matches("123456", "$2a$10$2ZUEuO15/0tx7tEFSdgdnu/jFhwmWB9FQF.ZRb3Ui9hziTCgTqLgS");

6、登录功能

UserLoginVo

@Data
public class UserLoginVo {

    private String loginacct;
    private String password;
}

LoginController

    /**
     * 登录
     * */
    @PostMapping("/login")
    public String login(UserLoginVo vo,RedirectAttributes attributes) {
        // 远程调用登录
        R login = memberFeignService.login(vo);
        if (login.getCode() == 0) {
            // 登录成功
            return  "redirect:http://gulimall.com";
        }else {
            // 登录失败,保存错误信息
            HashMap<String, String> errorsMap = new HashMap<>();
            errorsMap.put("msg", (String) login.get("msg"));
            attributes.addFlashAttribute("errors",errorsMap);
            return  "redirect:http://auth.gulimall.com/login.html";
        }

    }

MemberFeignService


    /**
     * 登录
     * */
    @PostMapping("member/member/login")
    public R login(@RequestBody UserLoginVo vo);

MemberController

    /**
     * 登录
     * */
    @PostMapping("/login")
    public R login(@RequestBody MemberLoginVo vo) {
        MemberEntity member = memberService.login(vo);

        return member == null
                ? R.error(BizCodeEnum.LOGINACCT_PASSWORD_INVALID_EXCEPTION.getCode(), BizCodeEnum.LOGINACCT_PASSWORD_INVALID_EXCEPTION.getMessage())
                : R.ok();
    }

MemberServiceImpl

@Override
    public MemberEntity login(MemberLoginVo vo) {

        String loginacct = vo.getLoginacct();
        //123456
        String password = vo.getPassword();

        MemberEntity memberEntity =
                this.getOne(new QueryWrapper<MemberEntity>().eq("username", loginacct).or().eq("mobile", loginacct));
        if (memberEntity != null) {
            // 比对密码
            // $2a$10$kNI1IHKgY7cG1KAJqEqaV.pwVSRQ9tHxbTHBlFnbxyIWWXEH24PMO
            String passwordDB = memberEntity.getPassword();
            BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
            boolean matches = bCryptPasswordEncoder.matches(password, passwordDB);

            return matches ? memberEntity : null;
        }
        return null;
    }

7、OAuth2.0

OAuth: OAuth(开放授权)是一个开放标准,允许用户授权第三方网站访问他们存储 在另外的服务提供者上的信息,而不需要将用户名和密码提供给第三方网站或分享他们 数据的所有内容。

OAuth2.0:对于用户相关的 OpenAPI(例如获取用户信息,动态同步,照片,日志,分 享等),为了保护用户数据的安全和隐私,第三方网站访问用户数据前都需要显式的向 用户征求授权。

OAuth2.0 流程

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LEBXWhyh-1675935129429)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230128095216552.png)]

(A)用户打开客户端以后,客户端要求用户给予授权。

(B)用户同意给予客户端授权。

(C)客户端使用上一步获得的授权,向认证服务器申请令牌。

(D)认证服务器对客户端进行认证以后,确认无误,同意发放令牌。

(E)客户端使用令牌,向资源服务器申请获取资源。

(F)资源服务器确认令牌无误,同意向客户端开放资源。

(1)weibo 登录测试

以第三方应用微博为例,点击 微博 登录,跳转到第三方授权页面

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dDH1tPZK-1675935129429)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230128105146659.png)]

授权过后 ,会返回一个认证令牌 token, 通过这个token可以获取个人相关信息。在使用 微博 登录之前,还需要一些前置工作…

微博 OpenAPI 文档:授权机制说明 - 微博API (weibo.com)

1、进入微博开发平台

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-F59s1p2d-1675935129429)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230128110334590.png)]

2、登录微博,选择网站接入

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uSMOSp9c-1675935129430)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230128110351256.png)]

3、创建应用,创建前请确保完成身份认证

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-pEi4MhhY-1675935129430)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230128110403891.png)]

4、修改回调

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rPWDMFTY-1675935129430)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230129142649034.png)]

获取access_token流程

  1. 引导需要授权的用户到如下地址:

URL

https://api.weibo.com/oauth2/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=YOUR_REGISTERED_REDIRECT_URI
  1. 如果用户同意授权,页面跳转至 YOUR_REGISTERED_REDIRECT_URI/?code=CODE

  2. 换取Access Token

URL

https://api.weibo.com/oauth2/access_token?client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET&grant_type=authorization_code&redirect_uri=YOUR_REGISTERED_REDIRECT_URI&code=CODE

其中client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET可以使用basic方式加入header中,返回值

JSON

{
    "access_token": "SlAV32hkKG",
    "remind_in": 3600,
    "expires_in": 3600
}
  1. Oauth2.0;授权通过后,使用 code 换取 access_token,然后去访问任何开放 API

    1)、code 用后即毁

    2)、access_token 在几天内是一样的

    3)、uid 永久固定

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UsfGz7Wy-1675935129431)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230128112049514.png)]

查询用户信息

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cXBc84ed-1675935129431)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230129143632698.png)]

(2)weibo 登录实现

  1. 点击社交登录按钮:跳转到指定的授权页面
  2. 用户在授权页上登录,并进行授权
  3. 授权成功后,会跳转回设置的回调URL并返回一个code

我们需要做的就是处理回调URL请求:

  1. 根据 code 获取 token
  2. 利用这个 token 就可以获取用户公开信息

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LiSLeB9N-1675935129431)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230129103043132.png)]

1、添加HTTPUtils 工具包,用来发送请求

public class HttpUtils {

    /**
     * get
     *
     * @param host
     * @param path
     * @param method
     * @param headers
     * @param querys
     * @return
     * @throws Exception
     */
    public static HttpResponse doGet(String host, String path, String method,
                                     Map<String, String> headers,
                                     Map<String, String> querys)
            throws Exception {
        HttpClient httpClient = wrapClient(host);

        HttpGet request = new HttpGet(buildUrl(host, path, querys));
        for (Map.Entry<String, String> e : headers.entrySet()) {
            request.addHeader(e.getKey(), e.getValue());
        }

        return httpClient.execute(request);
    }

    /**
     * post form
     *
     * @param host
     * @param path
     * @param method
     * @param headers
     * @param querys
     * @param bodys
     * @return
     * @throws Exception
     */
    public static HttpResponse doPost(String host, String path, String method,
                                      Map<String, String> headers,
                                      Map<String, String> querys,
                                      Map<String, String> bodys)
            throws Exception {
        HttpClient httpClient = wrapClient(host);

        HttpPost request = new HttpPost(buildUrl(host, path, querys));
        for (Map.Entry<String, String> e : headers.entrySet()) {
            request.addHeader(e.getKey(), e.getValue());
        }

        if (bodys != null) {
            List<NameValuePair> nameValuePairList = new ArrayList<NameValuePair>();

            for (String key : bodys.keySet()) {
                nameValuePairList.add(new BasicNameValuePair(key, bodys.get(key)));
            }
            UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(nameValuePairList, "utf-8");
            formEntity.setContentType("application/x-www-form-urlencoded; charset=UTF-8");
            request.setEntity(formEntity);
        }

        return httpClient.execute(request);
    }

    /**
     * Post String
     *
     * @param host
     * @param path
     * @param method
     * @param headers
     * @param querys
     * @param body
     * @return
     * @throws Exception
     */
    public static HttpResponse doPost(String host, String path, String method,
                                      Map<String, String> headers,
                                      Map<String, String> querys,
                                      String body)
            throws Exception {
        HttpClient httpClient = wrapClient(host);

        HttpPost request = new HttpPost(buildUrl(host, path, querys));
        for (Map.Entry<String, String> e : headers.entrySet()) {
            request.addHeader(e.getKey(), e.getValue());
        }

        if (StringUtils.isNotBlank(body)) {
            request.setEntity(new StringEntity(body, "utf-8"));
        }

        return httpClient.execute(request);
    }

    /**
     * Post stream
     *
     * @param host
     * @param path
     * @param method
     * @param headers
     * @param querys
     * @param body
     * @return
     * @throws Exception
     */
    public static HttpResponse doPost(String host, String path, String method,
                                      Map<String, String> headers,
                                      Map<String, String> querys,
                                      byte[] body)
            throws Exception {
        HttpClient httpClient = wrapClient(host);

        HttpPost request = new HttpPost(buildUrl(host, path, querys));
        for (Map.Entry<String, String> e : headers.entrySet()) {
            request.addHeader(e.getKey(), e.getValue());
        }

        if (body != null) {
            request.setEntity(new ByteArrayEntity(body));
        }

        return httpClient.execute(request);
    }

    /**
     * Put String
     * @param host
     * @param path
     * @param method
     * @param headers
     * @param querys
     * @param body
     * @return
     * @throws Exception
     */
    public static HttpResponse doPut(String host, String path, String method,
                                     Map<String, String> headers,
                                     Map<String, String> querys,
                                     String body)
            throws Exception {
        HttpClient httpClient = wrapClient(host);

        HttpPut request = new HttpPut(buildUrl(host, path, querys));
        for (Map.Entry<String, String> e : headers.entrySet()) {
            request.addHeader(e.getKey(), e.getValue());
        }

        if (StringUtils.isNotBlank(body)) {
            request.setEntity(new StringEntity(body, "utf-8"));
        }

        return httpClient.execute(request);
    }

    /**
     * Put stream
     * @param host
     * @param path
     * @param method
     * @param headers
     * @param querys
     * @param body
     * @return
     * @throws Exception
     */
    public static HttpResponse doPut(String host, String path, String method,
                                     Map<String, String> headers,
                                     Map<String, String> querys,
                                     byte[] body)
            throws Exception {
        HttpClient httpClient = wrapClient(host);

        HttpPut request = new HttpPut(buildUrl(host, path, querys));
        for (Map.Entry<String, String> e : headers.entrySet()) {
            request.addHeader(e.getKey(), e.getValue());
        }

        if (body != null) {
            request.setEntity(new ByteArrayEntity(body));
        }

        return httpClient.execute(request);
    }

    /**
     * Delete
     *
     * @param host
     * @param path
     * @param method
     * @param headers
     * @param querys
     * @return
     * @throws Exception
     */
    public static HttpResponse doDelete(String host, String path, String method,
                                        Map<String, String> headers,
                                        Map<String, String> querys)
            throws Exception {
        HttpClient httpClient = wrapClient(host);

        HttpDelete request = new HttpDelete(buildUrl(host, path, querys));
        for (Map.Entry<String, String> e : headers.entrySet()) {
            request.addHeader(e.getKey(), e.getValue());
        }

        return httpClient.execute(request);
    }

    private static String buildUrl(String host, String path, Map<String, String> querys) throws UnsupportedEncodingException {
        StringBuilder sbUrl = new StringBuilder();
        sbUrl.append(host);
        if (!StringUtils.isBlank(path)) {
            sbUrl.append(path);
        }
        if (null != querys) {
            StringBuilder sbQuery = new StringBuilder();
            for (Map.Entry<String, String> query : querys.entrySet()) {
                if (0 < sbQuery.length()) {
                    sbQuery.append("&");
                }
                if (StringUtils.isBlank(query.getKey()) && !StringUtils.isBlank(query.getValue())) {
                    sbQuery.append(query.getValue());
                }
                if (!StringUtils.isBlank(query.getKey())) {
                    sbQuery.append(query.getKey());
                    if (!StringUtils.isBlank(query.getValue())) {
                        sbQuery.append("=");
                        sbQuery.append(URLEncoder.encode(query.getValue(), "utf-8"));
                    }
                }
            }
            if (0 < sbQuery.length()) {
                sbUrl.append("?").append(sbQuery);
            }
        }

        return sbUrl.toString();
    }

    private static HttpClient wrapClient(String host) {
        HttpClient httpClient = new DefaultHttpClient();
        if (host.startsWith("https://")) {
            sslClient(httpClient);
        }

        return httpClient;
    }

    private static void sslClient(HttpClient httpClient) {
        try {
            SSLContext ctx = SSLContext.getInstance("TLS");
            X509TrustManager tm = new X509TrustManager() {
                public X509Certificate[] getAcceptedIssuers() {
                    return null;
                }
                public void checkClientTrusted(X509Certificate[] xcs, String str) {

                }
                public void checkServerTrusted(X509Certificate[] xcs, String str) {

                }
            };
            ctx.init(null, new TrustManager[] { tm }, null);
            SSLSocketFactory ssf = new SSLSocketFactory(ctx);
            ssf.setHostnameVerifier(SSLSocketFactory.ALLOW_ALL_HOSTNAME_VERIFIER);
            ClientConnectionManager ccm = httpClient.getConnectionManager();
            SchemeRegistry registry = ccm.getSchemeRegistry();
            registry.register(new Scheme("https", 443, ssf));
        } catch (KeyManagementException ex) {
            throw new RuntimeException(ex);
        } catch (NoSuchAlgorithmException ex) {
            throw new RuntimeException(ex);
        }
    }
}

2、增加依赖

<!--HTTPUtils依赖-->
        <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>

3、获取token,将返回来的信息封装成VO类

@Data
public class SocialUserVo {
    private String access_token;
    private String remind_in;
    private long expires_in;
    private String uid;
    private String isRealName;
}

4、MemberController

    /**
     * 社交登录
     * */
    @PostMapping("/sociallogin")
    public R socialLogin(@RequestBody SocialUserVo vo) {

        MemberEntity member = memberService.socialLogin(vo);

        return R.ok().put("data",member);
    }

5、MemberServiceImpl : 判断用户是否是第一次登录

再次之前,在会员表中、实体类中增加三条字段,用于记录社交登录的信息。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YVrm7LZ7-1675935129432)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230129114032844.png)]

@Override
public MemberEntity socialLogin(SocialUserVo vo) {
    // 查询是否是第一次登录。根据uid查询, uid是永久不变的
    MemberEntity member = this.getOne(new QueryWrapper<MemberEntity>().eq("social_uid", vo.getUid()));
    if (member == null) {
        member = new MemberEntity();
        //  如果是第一次登录,就将用户信息保存到数据库中
        // 查询用户信息
        HashMap<String, String> map = new HashMap<>();
        map.put("access_token",vo.getAccess_token());
        map.put("uid",vo.getUid());
        try {
            // 发送请求
            HttpResponse response = HttpUtils.doGet("https://api.weibo.com", "/2/users/show.json", "get", new HashMap<>(),map);
            // TODO:获取用户信息
            String json = EntityUtils.toString(response.getEntity());
            JSONObject jsonObject = JSON.parseObject(json);
            // 获取昵称
            String name = (String) jsonObject.get("name");
            // 性别
            String gender = (String) jsonObject.get("gender");
            // 省份
            String location = (String) jsonObject.get("location");
            member.setUsername(name);
            member.setCreateTime(new Date());
            member.setGender("f".equalsIgnoreCase(gender) ? 0: 1);
            member.setCity(location);
        } catch (Exception e) {
            e.printStackTrace();
        }
        member.setSocialUid(vo.getUid());
        member.setAccessToken(vo.getAccess_token());
        member.setExpiresIn(vo.getExpires_in());
        // 创建用户
        this.save(member);
        return member;
    }else {
        //  如果不是第一次登录,就更新数据库中的用户信息
        // 主要就是更新访问令牌和失效时间,因为 uid 是永久不变的
        member.setAccessToken(vo.getAccess_token());
        member.setExpiresIn(vo.getExpires_in());
        this.updateById(member);
        return  member;
    }
}

6、MemberFeignService

    /**
     * 社交登录
     * */
    @PostMapping("member/member/sociallogin")
    public R socialLogin(@RequestBody SocialUserVo vo);

7、Auth2Controller

@Controller
public class Auth2Controller {

    @Autowired
    private MemberFeignService memberFeignService;

    /**
     * 处理回调URL
     * */
    @GetMapping("/oauth2.0/weibo/success")
    public String oauthLogin(@RequestParam("code") String code) throws Exception {
        // 封装参数
        HashMap<String, String> map = new HashMap<>();
        map.put("client_id", "1203861779");
        map.put("client_secret", "0a5e60edc13e444fca26a95c3847b7f7");
        map.put("grant_type", "authorization_code");
        map.put("redirect_uri", "http://auth.gulimall.com/oauth2.0/weibo/success");
        map.put("code", code);
        // 发送请求
        HttpResponse response = HttpUtils.doPost("https://api.weibo.com", "/oauth2/access_token", "post", new HashMap<>(), map, new HashMap<>());

        if (response.getStatusLine().getStatusCode() == 200) {
            // 请求成功
            // 获取响应数据 => JSON => 对应的实体类
            String json = EntityUtils.toString(response.getEntity());
            SocialUserVo socialUser = JSON.parseObject(json, SocialUserVo.class);
            // 判断用户是否是第一次登录,如果是第一次登录就注册并返回用户信息,如果不是就修改数据库中的用户信息并返回
            R r = memberFeignService.socialLogin(socialUser);
            if (r.getCode() == 0) {
                // 调用成功
                MemberRespVo memberRespVo = r.getData("data", new TypeReference<MemberRespVo>() {});
                System.out.println("登录成功: " + memberRespVo);
                return "redirect:http://gulimall.com";
            }else {
                return  "redirect:http://auth.gulimall.com/login.html";
            }

        }else{
            // 请求失败
            return  "redirect:http://auth.gulimall.com/login.html";
        }
    }
}

8、分布式session不共享问题

session 服务器用来保存数据的一种手段,session的存储结构就是map,使用 key-value 键值对存储。

每次创建一个 session,都会创建一个 Cookie,key 是 jsessionId,值就是session的 id 值。通过响应会把这个 cookie对象响应给浏览器,浏览器就会创建该 cookie, 并且每次请求都伴随着这个 cookie 去服务器中对应的 session。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RX7VOXwV-1675935129432)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230129160817070.png)]

问题是: 在多个服务器或者多个服务下,session 中的数据并不能共享

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PNekhAoa-1675935129432)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230129161316168.png)]

(1)解决session共享问题

第一种方案:session复制

优点

web-server(Tomcat)原生支持,只需要修改配置 文件

缺点

  • session同步需要数据传输,占用大量网络带宽,降 低了服务器群的业务处理能力
  • 任意一台web-server保存的数据都是所有web- server的session总和,受到内存限制无法水平扩展 更多的web-server
  • 大型分布式集群情况下,由于所有web-server都全 量保存数据,所以此方案不可取。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zzIOOxpX-1675935129433)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230129163125928.png)]

第二种方案:客户端存储

优点

  • 服务器不需存储session,用户保存自己的 session信息到cookie中。节省服务端资源

缺点

  • 都是缺点,这只是一种思路。

具体如下:

  • 每次http请求,携带用户在cookie中的完整信息, 浪费网络带宽
  • session数据放在cookie中,cookie有长度限制 4K,不能保存大量信息
  • session数据放在cookie中,存在泄漏、篡改、 窃取等安全隐患 • 这种方式不会使用。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uXDWyfvH-1675935129433)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230129163340835.png)]

第三种解决方案:hash一致性

简单来说就是通过hash算法,将请求分配在固定服务器中,这样就不会出现分布式session问题

优点:

  • 只需要改nginx配置,不需要修改应用代码
  • 负载均衡,只要hash属性的值分布是均匀的,多台 web-server的负载是均衡的
  • 可以支持web-server水平扩展(session同步法是不行 的,受内存限制)

缺点

  • session还是存在web-server中的,所以web-server重 启可能导致部分session丢失,影响业务,如部分用户 需要重新登录
  • 如果web-server水平扩展,rehash后session重新分布, 也会有一部分用户路由不到正确的session
  • 但是以上缺点问题也不是很大,因为session本来都是有有 效期的。所以这两种反向代理的方式可以使用

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NjGUefi1-1675935129433)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230129163358717.png)]

第四种解决方案:统一存储

优点:

  • 没有安全隐患
  • 可以水平扩展,数据库/缓存水平切分即 可
  • web-server重启或者扩容都不会有 session丢失

不足

  • 增加了一次网络调用,并且需要修改应 用代码;如将所有的getSession方法替 换为从Redis查数据的方式。redis获取数 据比内存慢很多
  • 上面缺点可以用SpringSession完美解决

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-z1iCW6E5-1675935129433)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230129163652486.png)]

本项目使用第四种方式:统一存储,将 session 中的数据存储到 redis 中,将 redis 的 key 存储到 cookie 中,目前唯一的问题就是:存储的cookie默认使用的是当前域名,我们在 auth.gulimall.com 域名下存储用户登录信息,但是在其他域名下想要使用是用不了的,因此需要将 cookie 的域名范围扩大到 父域名: gulimall.com, 这样所有的子域名都能使用—— 这个问题可以使用 SpringSession 完美解决!!!

(2)SpringSession

官方文档:https://docs.spring.io/spring-session/reference/3.0.0/guides/boot-redis.html

1、使用 SpringSession 的服务都引入 依赖

	<dependency>
		<groupId>org.springframework.session</groupId>
		<artifactId>spring-session-data-redis</artifactId>
	</dependency>

2、配置存储类型

spring:
    session:
        store-type: redis

3、增加注解

@EnableRedisHttpSession 

3、将数据保存到 session 中,springSession 会自动存储到 redis 中。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ypdNZcMG-1675935129434)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230129171351568.png)]

4、session 的域名范围问题,默认的域名范围是当前域名,需要扩展到父域名:gulimall.com

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-W2blAl45-1675935129434)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230129171615053.png)]

俩个问题

1、发放的session令牌作用域默认是当前域,其他域无法共享,需要扩展作用域到父域。在官网中也说明了可以自定义 CookieSerializer 设置一些信息

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GGylZBt7-1675935129434)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230129175455951.png)]

2、存入redis 的实体类需要经过序列化,每个实体类都需要 实现 Serializable ,太麻烦

解决

@Configuration
public class GulimallSessionConfig {

    /**
     * session存入redis序列化
     * */
    @Bean
    public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
        return new GenericJackson2JsonRedisSerializer();
    }

    /**
     *设置cookie域名范围
     * */
    @Bean
    public DefaultCookieSerializer webSessionIdResolver() {
        DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
        cookieSerializer.setCookieName("GULISESSION");
        cookieSerializer.setDomainName("gulimall.com");
        return cookieSerializer;
    }
}

(3)页面完善

自定义跳转 login 页面逻辑,不使用 ViewController


    /**
     * 自定义跳转到login页面逻辑
     * */
    @GetMapping("/login.html")
    public String loginPage(HttpSession session) {
        // 判断是否登录
        return session.getAttribute(AuthServerConstant.LOGIN_USER) == null ? "login" : "redirect:http://gulimall.com";
    }

普通登录之后的用户信息也增加到 session 中:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WHR05FzO-1675935129434)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230129205519112.png)]

将 search 服务中 使用 SpringSession,修改登录显示

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uqgr0VyA-1675935129435)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230129205721129.png)]

9、单点登录

上面解决session共享的问题,在多个服务间能够共享session数据,但是在多系统下,还是会有一些问题

比如: 在尚硅谷下有多个系统,对应多个顶级域名,**在多个系统中,用户只需一次登录,各个系统即可感知该用户已经登录。**这时session 共享已经解决不了数据同步问题了,需要使用 单点登录(Single Sign On)

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-z9505XPi-1675935129435)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230129220659443.png)]

案例具体访问流程:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Pu9buLe9-1675935129435)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/demo%E5%8D%95%E7%82%B9%E7%99%BB%E5%BD%95%E6%B5%81%E7%A8%8B%E5%9B%BE.png)]

十一、购物车

1、购物车模型分析

购物车的俩种模式:

  1. 用户在登录的时候将商品增加到购物车【在线购物车/用户购物车
    1. 选择用 redis 存储,可以使用redis的持久化存储将数据持久化到磁盘上
    2. 在用户登录的时候,将临时购物车中的购物项合并到在线购物车中,同时清空临时购物车
  2. 用户在未登录的时候将商品增加到购物车【离线模式/游客购物车/临时购物车
    1. 选择 redis 存储

购物车模型分析

购物车存储是一个个商品,每一个商品都应该是一个对象

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OKP677Em-1675935129435)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230130150427716.png)]

购物车的基本字段:

{
	List<CartItem> items;  // 购物车
	Integer CountNum ; 	// 商品总数量
	Integer CountType ; // 商品类型数量
	BigDecimal totalAmount; // 商品总价
	BigDecimal reduce; // 优惠减免价格
}

CartItem 商品的基本字段:

{
	skuId: 2131241,
	check: true,   // 表示商品是否被勾选上
	title: "Apple iphone.....",
	defaultImage: "...",
	price: 4999,
	count: 1,
	totalPrice: 4999skuSaleVO: {...} // 商品的销售属性集合
}

购物车在redis中的存储模型

购物车包含多个购物项:

[{..},{..},{..}..]

首先想到的是使用 list 类型存储,这种方式有个缺点,在购物车中修改商品时,无法在 list 中精确找到商品,还得一个个遍历,这样无疑浪费时间。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-n75fuIIH-1675935129435)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230130151056192.png)]

因此在 redis 中可以选用 hash 存储,存储结构: Map<String k1,Map<String k2,String value>> , k1 为用户的购物车,k2 为商品的id ,value 为购物车中商品的信息

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qsYGpdZO-1675935129436)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230130151338875.png)]

这样 通过 key 就可以快速找到购物车中的商品。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HZQ6pDQf-1675935129436)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230130151540972.png)]

购物车Vo :

public class Cart {

    private List<CartItem> items;  // 购物车
    private Integer CountNum ; 	// 商品总数量
    private Integer CountType ; // 商品类型数量
    private BigDecimal totalAmount; // 商品总价
    private BigDecimal reduce = new BigDecimal(0); // 优惠减免价格

    public List<CartItem> getItems() {
        return items;
    }

    public void setItems(List<CartItem> items) {
        this.items = items;
    }

    /*
    * 计算商品的总数量
    * */
    public Integer getCountNum() {

        return items != null && items.size() > 0 ? items.stream().map(CartItem::getCount).reduce(Integer::sum).get() : 0;
    }


    /*
    * 计算商品的类型
    * */
    public Integer getCountType() {
        return items != null && items.size() > 0 ? items.size(): 0;
    }


    /*
    * 计算购物车中商品的总价格
    *       每件商品的总和 - 优惠价格
    * */
    public BigDecimal getTotalAmount() {

        return  items != null && items.size() > 0
                ? items.stream().map(CartItem::getTotalPrice).reduce(BigDecimal::add).get().subtract(this.reduce)
                : new BigDecimal(0);
    }

    public BigDecimal getReduce() {
        return reduce;
    }

    public void setReduce(BigDecimal reduce) {
        this.reduce = reduce;
    }
}

商品项Vo

public class CartItem {
    /*
    * 	skuId: 2131241,
    * check: true,   // 表示商品是否被勾选上
    * title: "Apple iphone.....",
    * defaultImage: "...",
    * price: 4999,
    * count: 1,
    * totalPrice: 4999,
    * skuSaleVO: {...} // 商品的销售属性集合
    * */
    private Long  skuId;
    private boolean check = true;
    private String title;
    private String defaultImage;
    private BigDecimal price ;
    private Integer count = 1;
    private BigDecimal totalPrice ;
    private List<String> skuAttr;

    public Long getSkuId() {
        return skuId;
    }

    public void setSkuId(Long skuId) {
        this.skuId = skuId;
    }

    public boolean isCheck() {
        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 getDefaultImage() {
        return defaultImage;
    }

    public void setDefaultImage(String defaultImage) {
        this.defaultImage = defaultImage;
    }

    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;
    }

    /*
    * 计算出商品价格
    * */
    public BigDecimal getTotalPrice() {
        return this.price.multiply(new BigDecimal(this.count));
    }


    public List<String> getSkuAttr() {
        return skuAttr;
    }

    public void setSkuAttr(List<String> skuAttr) {
        this.skuAttr = skuAttr;
    }
}

2、ThreadLocal 用户身份鉴别

购物车有俩种模式:一种是登录模式,一种是离线模式,那么我们首先就需要区分用户到底有没有登录。

在认证服务中,我们已经使用 SpringSession 将用户信息保存到了 session 中,并且设置了它的域名范围,如果登录了,在整个 gulimall.com 下都能获取到用户信息。因此我们只需要判断是否能从session中获取到用户信息,能就是登录,不能就是未登录。

如果是没有登录也能将商品增加到购物车,并且如果下次在打开网页还能将购物车中的商品保存下来。可以参照 JD :

在 京东 中,无论你登没登陆,都会给你一个 user-key 保存到 cookie 中,每次发送请求都会带上这个 user-key

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3VkzlYZL-1675935129436)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230130170302236.png)]

我们可以使用一个拦截器,在访问目标方法之前,都要判断用户的登录状态:

  • 如果登录了,在操作购物车时,我们需要判断用户的登录状态来区分购物车模式。因此需要将用户信息封装好
  • 没有登录:判断cookie中是否有user-key,如果有直接封装 user-key
无论是登录状态,还是临时用户都应该创建出一个 user-key

可以将 封装好的 用户信息保存在 ThreadLocal 中,这样的好处是:ThreadLocal 为每一个线程分配一个独自的变量,并且同一个线程内能共享这个变量。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-D7oIYMuP-1675935129436)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/image-20230130171119353.png)]

提前整合 SpringSession,Redis,已省略,

1、用户登录状态VO

@Data
@ToString
public class UserInfoTo {
    private Long userId; // 登录后的用户id
    private String userKey;
    // 是否设置了 userKey
    private boolean isTempUser = false;
}

2、拦截器

public class CartInterceptor implements HandlerInterceptor {

    public static ThreadLocal<UserInfoTo> threadLocal = new ThreadLocal<>();
    /*
    * 执行目标方法之前执行
    *   判断用户的登录状态
    *       登录:封装登录的用户信息
    *       没有登录:判断cookie中是否有user-key,如果有直接封装 user-key
    *   无论是登录状态,还是临时用户都应该创建出一个 user-key
    * */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 先从 redis 中获取用户信息(整合了SpringSession后,会将session包装,从redis中取数据)
        HttpSession session = request.getSession();
        MemberRespVo memberRespVo = (MemberRespVo) session.getAttribute(AuthServerConstant.LOGIN_USER);
        UserInfoTo userInfoTo = new UserInfoTo();

        if (memberRespVo != null) {
            // 登录状态
            userInfoTo.setUserId(memberRespVo.getId());
        }
            // 没有登录
            Cookie[] cookies = request.getCookies();
            // 判断cookie中是否有user-key
            if (cookies != null && cookies.length > 0) {
                for (Cookie cookie : cookies) {
                    if (CartConstant.TEMP_USER_COOKIE_NAME.equalsIgnoreCase(cookie.getName())) {
                        // 如果有直接封装
                        userInfoTo.setUserKey(cookie.getValue());
                        userInfoTo.setTempUser(true);
                    }
                }
            }
            // 无论是登录状态,还是临时用户都设置一个 userkey
        if (StringUtils.isEmpty(userInfoTo.getUserKey())) {
            String uuid = UUID.randomUUID().toString();
            userInfoTo.setUserKey(uuid);

        }

        // 保存到本地变量池
        threadLocal.set(userInfoTo);
        return  true;
    }

    /*
    * 目标方法执行之后执行
    *   通知浏览器保存带有user-key的cookie
    * */
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        UserInfoTo userInfoTo = threadLocal.get();
        // 通知浏览器保存带有user-key的cookie
        // 只设置一次
        if (!userInfoTo.isTempUser()) {
            Cookie cookie = new Cookie(CartConstant.TEMP_USER_COOKIE_NAME, userInfoTo.getUserKey());
            cookie.setDomain("gulimall.com");
            cookie.setMaxAge(60*60*24*30);
            response.addCookie(cookie);
        }
    }
}

3、注册拦截器

@Configuration
public class MyWebConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
       registry.addInterceptor(new CartInterceptor()).addPathPatterns("/**");
    }
}

3、增加购物车

1、需要判断使用的是用户购物车还是临时购物车

  • 将用户登录状态封装在了 UserInfoTo 里,用户购物车:userId不为空,否则就是临时购物车
  • 用户购物车存入redis的key: gulimall:cart: userId
  • 临时购物车存入redis的key: gulimall:cart: userKey
  • hash的key统一为 skuId

2、知道了使用哪种购物车,判断购物车中是否有新增加的商品

  • 如果有,则直接修改商品数量即可,无需新增
  • 如果没有,新增商品
    • 封装商品项信息【sku基本信息、sku销售属性】

CartController

    /**
     * 增加商品到购物车
     * */
    @GetMapping("/addToCart")
    public String addToCart(
            @RequestParam("skuId") Long skuId,
            @RequestParam("num") Integer num,
            Model model) {

        CartItem cartItem = cartService.addToCart(skuId,num);
        model.addAttribute("cartItem",cartItem);
        return "success";
    }

CartServiceImpl

@Service
public class CartServiceImpl implements CartService {
    // 购物车前缀
    public static final String CART_TYPE_PREFIX = "gulimall:cart:";

    @Autowired
    private StringRedisTemplate redisTemplate;
    @Autowired
    private ProductFeignService productFeignService;
    @Autowired
    private ThreadPoolExecutor executor;

    @Override
    public CartItem addToCart(Long skuId, Integer num) {
        // 1、获取对应的购物车
        BoundHashOperations<String, Object, Object> cartOps = getCart();
        // 判断购物车中是否有新增加的商品
        String cartItemRedis = (String) cartOps.get(skuId.toString());
        if (StringUtils.isEmpty(cartItemRedis)) {
            // 没有此商品
            CartItem cartItem = new CartItem();
            // 2、封装 sku 基本信息
            CompletableFuture<Void> getSkuInfoTask = CompletableFuture.runAsync(() -> {
                R r = productFeignService.getSkuInfo(skuId);
                SkuInfoVo skuInfo = r.getData("skuInfo", new TypeReference<SkuInfoVo>() {
                });
                cartItem.setSkuId(skuInfo.getSkuId());
                cartItem.setCheck(true);
                cartItem.setTitle(skuInfo.getSkuTitle());
                cartItem.setDefaultImage(skuInfo.getSkuDefaultImg());
                cartItem.setPrice(skuInfo.getPrice());
                cartItem.setCount(num);
            }, executor);

            // 3、封装 sku 销售属性
            CompletableFuture<Void> getSkuAttrsListTask = CompletableFuture.runAsync(() -> {
                List<String> skuAttrValueAsStringList = productFeignService.getSkuAttrValueAsStringList(skuId);
                cartItem.setSkuAttr(skuAttrValueAsStringList);
            }, executor);

            try {
                // 等待异步任务完成
                CompletableFuture.allOf(getSkuInfoTask,getSkuAttrsListTask).get();
            } catch (Exception e) {
                e.printStackTrace();
            }

            String cartJson = JSON.toJSONString(cartItem);
            // 将商品信息存入redis
            cartOps.put("" + skuId,cartJson);
            return cartItem;
        }
        else {
            // 有此商品,修改商品数量即可
            CartItem cartItem = JSON.parseObject(cartItemRedis, CartItem.class);
            cartItem.setCount(cartItem.getCount() + num);
            cartOps.put(skuId.toString(),JSON.toJSONString(cartItem));
            return cartItem;
        }
    }

    /*
     * 获取对应的购物车
     * */
    private BoundHashOperations<String, Object, Object> getCart() {
        String cartKey = "";
        // 1、根据用户的登录状态,判断使用哪种购物车
        UserInfoTo userInfoTo = CartInterceptor.threadLocal.get();
        if (userInfoTo.getUserId() != null) {
            // 登录状态、 `gulimall:cart: userId`
            cartKey = CART_TYPE_PREFIX + userInfoTo.getUserId();
        } else {
            // 未登录状态 gulimall:cart: userKey
            cartKey = CART_TYPE_PREFIX + userInfoTo.getUserKey();
        }
        // 绑定一个 hash,使用 boundHashOps 所有的操作都针对此 hash
        BoundHashOperations<String, Object, Object> boundHashOps = redisTemplate.boundHashOps(cartKey);
        return boundHashOps;
    }
}

ProductFeignService

@FeignClient("gulimall-product")
public interface ProductFeignService {
    /*
    * 查询sku基本信息
    * */
    @RequestMapping("product/skuinfo/info/{skuId}")
    public R getSkuInfo(@PathVariable("skuId") Long skuId);

    /*
    * 查询SKU的销售属性名、值。并封装成 List<String>
    * 格式:
    * [
    *   {可选版本:8+128G},
    *   {颜色:冰霜银}
    * ]
    * */
    @GetMapping("product/skusaleattrvalue/getSkuAttrList")
    public List<String> getSkuAttrValueAsStringList(@RequestParam("skuId") Long skuId);
}

SkuSaleAttrValueController

    /*
    * 获取sku的销售属性,并将name、value封装成list集合
    * */
    @GetMapping("/getSkuAttrList")
    public List<String> getSkuAttrValueAsStringList(@RequestParam("skuId") Long skuId){
        List<String> data = skuSaleAttrValueService.getSkuAttrValueAsStringList(skuId);
        return data;
    }

SkuSaleAttrValueDao.xml

    <select id="getSkuAttrValueAsStringList" resultType="java.lang.String">
        SELECT CONCAT(attr_name,":",attr_value)
        FROM `pms_sku_sale_attr_value`
        WHERE sku_id = #{skuId}
    </select>

自定义线程池,省略

4、刷新页面重复增加购物车

由于新增购物车是转发的方式,刷新页面也会造成重复提交请求的bug。

改成重定向的方式,并重新查询购物车中的商品

    /**
     * 增加商品到购物车
     * */
    @GetMapping("/addToCart")
    public String addToCart(
            @RequestParam("skuId") Long skuId,
            @RequestParam("num") Integer num,
            RedirectAttributes attributes) {

        CartItem cartItem = cartService.addToCart(skuId,num);
        attributes.addAttribute("skuId",skuId);
        return "redirect:http://cart.gulimall.com/addToCartSuccess.html";
    }
    /*
    * 重新查询购物车中商品的详情
    *   防止刷新页面重复增加购物车
    * */
    @GetMapping("/addToCartSuccess.html")
    public String addToCartSuccess(@RequestParam("skuId")Long skuId, Model model){
        // 重新查询一遍商品
        CartItem cartItem = cartService.getCartItem(skuId);
        model.addAttribute("cartItem",cartItem);
        return "success";
    }

    @Override
    public CartItem getCartItem(Long skuId) {
        BoundHashOperations<String, Object, Object> cartOps = getCart();
        String cartItem = (String) cartOps.get(skuId.toString());
        return JSON.parseObject(cartItem,CartItem.class);
    }

5、获取&合并购物车

1、判断使用的购物车类型。

  • 如果是用户购物车,还需要查询临时购物车,将俩个购物车合并
  • 如果是临时购物车,直接查询redis封装即可
    /**
     * 获取&合并购物车
     * */
    @GetMapping("/cartList.html")
    public String cartPage(Model model) {

        Cart cart = cartService.getCarts();
        model.addAttribute("cart",cart);
        return "cartList";
    }
  @Override
    public Cart getCarts() {
        UserInfoTo userInfoTo = CartInterceptor.threadLocal.get();
        Cart cart = new Cart();

        if (userInfoTo.getUserId() != null) {
            // 登录:将用户购物车和临时购物车合并
            // 判断是否有临时购物车
            String tempCartKey = CART_TYPE_PREFIX + userInfoTo.getUserKey();
            List<CartItem> tempCart = getCartItems(tempCartKey);
            if (tempCart != null && tempCart.size() > 0) {
                for (CartItem cartItem : tempCart) {
                    // 将临时购物车中的商品合并到用户购物车中
                    addToCart(cartItem.getSkuId(),cartItem.getCount());
                }
            }
            // 合并完成,重新查询用户购物车
            List<CartItem> cartItems = getCartItems(CART_TYPE_PREFIX + userInfoTo.getUserId());
            cart.setItems(cartItems);
            // 清空购物车
            clearCart(tempCartKey);
        }else {
            // 未登录
            // 查询临时购物车
            List<CartItem> cartItems = getCartItems(CART_TYPE_PREFIX + userInfoTo.getUserKey());
            cart.setItems(cartItems);
        }
        return cart;
    }
    /*
    * 根据cartKey获取购物车中的所有商品
    * 用户:gulimall:cart:用户id
    * 临时: gulimall:cart:uuid
    * */
    private List<CartItem> getCartItems(String cartKey ) {

        // 获取购物车
        BoundHashOperations<String, Object, Object> cart = redisTemplate.boundHashOps(cartKey);
        // 获取购物车中所有的商品
        List<Object> cartItems = cart.values();
        if (cartItems != null && cartItems.size() > 0) {
            // 将购物车中的所有商品进行封装
            List<CartItem> items = cartItems.stream().map(item -> {
                String itemJson = (String) item;
                return JSON.parseObject(itemJson, CartItem.class);
            }).collect(Collectors.toList());

            return items;
        }
        return  null ;
    }

6、删除购物车&勾选商品&修改商品数量&删除商品

删除购物车

    /*
    * 清空购物车
    * */
    @Override
    public void clearCart(String cartKey) {
        redisTemplate.delete(cartKey);
    }

勾选商品

    /**
     * 选中商品项
     * */
    @GetMapping("/checkItem")
    public String checkItem(@RequestParam("skuId")Long skuId,@RequestParam("checked")Integer checked){
        cartService.checkItem(skuId,checked);
        return  "redirect:http://cart.gulimall.com/cartList.html";
    }
    @Override
    public void checkItem(Long skuId, Integer checked) {
        BoundHashOperations<String, Object, Object> cart = getCart();
        CartItem cartItem = getCartItem(skuId);
        cartItem.setCheck(checked != 0);
        cart.put(skuId.toString(),JSON.toJSONString(cartItem));
        
    }

修改商品数量

    @Override
    public void countItem(Long skuId, Integer num) {
        CartItem cartItem = getCartItem(skuId);
        cartItem.setCount(num);
        BoundHashOperations<String, Object, Object> cart = getCart();
        cart.put(skuId.toString(), JSON.toJSONString(cartItem));
    }
    /**
     * 改变商品数量
     * */
    @RequestMapping("/countItem")
    public String countItem(@RequestParam("skuId")Long skuId,@RequestParam("num")Integer num){
        cartService.countItem(skuId,num);
        return  "redirect:http://cart.gulimall.com/cartList.html";
    }

删除购物项

    @RequestMapping("/deleteItem")
    public String deleteItem(@RequestParam("skuId")Long skuId) {
        cartService.deleteItem(skuId);
        return  "redirect:http://cart.gulimall.com/cartList.html";
    }
    @Override
    public void deleteItem(Long skuId) {
        BoundHashOperations<String, Object, Object> cart = getCart();
        cart.delete(skuId.toString());
    }

To.getUserId();
} else {
// 未登录状态 gulimall:cart: userKey
cartKey = CART_TYPE_PREFIX + userInfoTo.getUserKey();
}
// 绑定一个 hash,使用 boundHashOps 所有的操作都针对此 hash
BoundHashOperations<String, Object, Object> boundHashOps = redisTemplate.boundHashOps(cartKey);
return boundHashOps;
}
}




`ProductFeignService`

```java
@FeignClient("gulimall-product")
public interface ProductFeignService {
    /*
    * 查询sku基本信息
    * */
    @RequestMapping("product/skuinfo/info/{skuId}")
    public R getSkuInfo(@PathVariable("skuId") Long skuId);

    /*
    * 查询SKU的销售属性名、值。并封装成 List<String>
    * 格式:
    * [
    *   {可选版本:8+128G},
    *   {颜色:冰霜银}
    * ]
    * */
    @GetMapping("product/skusaleattrvalue/getSkuAttrList")
    public List<String> getSkuAttrValueAsStringList(@RequestParam("skuId") Long skuId);
}

SkuSaleAttrValueController

    /*
    * 获取sku的销售属性,并将name、value封装成list集合
    * */
    @GetMapping("/getSkuAttrList")
    public List<String> getSkuAttrValueAsStringList(@RequestParam("skuId") Long skuId){
        List<String> data = skuSaleAttrValueService.getSkuAttrValueAsStringList(skuId);
        return data;
    }

SkuSaleAttrValueDao.xml

    <select id="getSkuAttrValueAsStringList" resultType="java.lang.String">
        SELECT CONCAT(attr_name,":",attr_value)
        FROM `pms_sku_sale_attr_value`
        WHERE sku_id = #{skuId}
    </select>

自定义线程池,省略

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

鲨瓜2号

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值