高级篇
商品服务-商品上架
上架的商品才可以在网站展示
上架的商品需要可以检索
1、商品 Mapping
分析:商品上架在 es 中是存 sku 还是 spu ?
- 检索的时候输入名字,是需要按照 sku 的 title 进行全文检索的
- 检索使用商品规格,规格是 spu 的公共属性,每个 spu 是一样的
- 按照分类 id 进去的都是直接列出 spu 的,还可以切换
- 我们如果将 sku 的全量信息保存到 es 中(包括 spu 属性)就太多量字段了
- 我们如果将 spu 以及他包含的 sku 信息保存到 es 中,也可以方便检索。但是 sku 属于 spu 的级联对象,在 es 中需要 nested 模型,这种性能差点
- 但是存储与检索我们必须性能折中
- 如果我们分拆存储,spu 和 attr 一个索引,sku 单独一个索引可能涉及的问题
检索商品的名字,如“手机”,对应的 spu 有很多,我们要分析出这些 spu 的所有关联属性,再做一次查询,就必须将所有 spu_id 都发出去。假设有 1 万个数据,数据传输一次就10000*4=4MB;并发情况下假设 1000 检索请求,那就是 4GB 的数据,,传输阻塞时间会很长,业务更加无法继续。
所以,我们如下设计,这样才是文档区别于关系型数据库的地方,宽表设计,不能去考虑数据库范式
PUT product
{
"mappings":{
"properties":{
"skuId":{
"type":"long"
},
"spuId":{
"type":"keyword"
},
"skuTitle":{
"type":"text",
"analyzer": "ik_smart"
},
"skuPrice":{
"type":"integer"
},
"skuImg":{
"type":"text",
"analyzer": "ik_smart"
},
"saleCount":{
"type":"long"
},
"hasStock":{
"type":"boolean"
},
"hotScore":{
"type":"long"
},
"brandId":{
"type":"long"
},
"catelogId":{
"type":"long"
},
"brandName":{
"type":"keyword"
},
"brandImg":{
"type":"keyword"
},
"catalogName":{
"type":"keyword"
},
"attrs":{
"type":"nested",
"properties": {
"attrId":{
"type":"long"
},
"attrName":{
"type":"keyword"
},
"attrValue": {
"type":"keyword"
}
}
}
}
}
}
注意
skuPrice
的类型是 integer,后面价格区间搜索商品时需要
"index":false
:
默认 true,如果为 false,表示该字段不会被索引,但是检索结果里面有,但字段本身不能当做检索条件。
"doc_values":false
:
默认 true,如果为 false,表示不可以做排序、聚合以及脚本操作,这样更节省磁盘空间。还可以通过设定 doc_values 为 true,index 为 false 来让字段不能被搜索但可以用于排序、聚合以及脚本操作
"type":"nested"
:
表示数组数据是嵌入式的,ES默认数组数据是扁平化处理,不是嵌入式
上架细节
上架是将后台的商品放在 es 中可以提供检索和查询功能
- hasStock:代表是否有库存。默认上架的商品都有库存。如果库存无货的时候才需要更新一下 es
- 库存补上以后,也需要重新更新一下 es
- hotScore 是热度值,我们只模拟使用点击率更新热度。点击率增加到一定程度才更新热度值。
- 下架就是从 es 中移除检索项,以及修改 mysql 状态
商品上架步骤:
- 先在 es 中按照之前的 mapping 信息,建立 product 索引。
- 点击上架,查询出所有 sku 的信息,保存到 es 中
- es 保存成功返回,更新数据库的上架状态信息。
2、上架接口编写
因为数据需要存入 ES,在 common 模块里面新建实体类
SkuEsModel
//上架商品信息
@Data
public class SkuEsModel {
private Long skuId;
private Long spuId;
private String skuTitle;
private BigDecimal skuPrice;
private String skuImg;
private Long saleCount;
private Boolean hasStock;
private Long hotScore;
private Long brandId;
private Long catelogId;
private String brandName;
private String brandImg;
private String catalogName;
private List<Attrs> attrs;
@Data
public static class Attrs{
private Long attrId;
private String attrName;
private String attrValue;
}
}
SpuInfoController
//商品上架
@RequestMapping("/{spuId}/up")
public R up(@PathVariable("spuId") Long spuId){
spuInfoService.up(spuId);
return R.ok();
}
SpuInfoServiceImpl
//商品上架:查出当前 spuid 对应的所有信息封装为 SkuEsModel,发送给 es保存
@Override
public void up(Long spuId) {
//1、查询sku信息(一个spu对应多个sku)
List<SkuInfoEntity> skus = skuInfoService.getSkuBySpuId(spuId);
//2、sku信息封装为 SkuEsModel
//skuPrice skuImg hasStock hotScore brandName brandImg catalogName attrs
//TODO 2.1、发送远程调用,库存系统查询是否有库存(hasStock)
List<Long> skuIdList = skus.stream().map(SkuInfoEntity::getSkuId).collect(Collectors.toList());
//将list转换为map
Map<Long, Boolean> skuHasStock = null;
try {
R r = wareFeignService.getSkuHasStock(skuIdList);
TypeReference<List<SkuHasStockTo>> typeReference = new TypeReference<List<SkuHasStockTo>>() {};
skuHasStock = r.getData(typeReference).stream().collect(Collectors.toMap(SkuHasStockTo::getSkuId, SkuHasStockTo::getHasStock));
} catch (Exception e) {
log.error("库存服务异常:原因:{}",e);
e.printStackTrace();
}
//TODO 2.4、查询当前sku的所有可以用来被检索的基本规格属性(attrs)
//基本属性是跟着spu走,销售属性是跟着sku,所以对于同一个spu下的sku,是同一类基本属性
//属性名、值是在pms_product_attr_value表,是否被检索是在pms_attr表的search_type字段
List<ProductAttrValueEntity> attrValues = productAttrValueService.list(new QueryWrapper<ProductAttrValueEntity>().eq("spu_id", spuId));
//筛选出可检索的属性
List<Long> attrIds = attrValues.stream().map(ProductAttrValueEntity::getAttrId).collect(Collectors.toList());
List<Long> searchAttrIds = attrService.selectSearchAttrs(attrIds);
//再次筛选,过滤掉不包含在 searchAttrIds集合中的元素
HashSet<Long> searchAttrIdsHashSet = new HashSet<>(searchAttrIds);
//封装可被检索的规格属性
List<SkuEsModel.Attrs> attrs = attrValues.stream().filter(item -> {
//过滤掉不包含在 searchAttrIds集合中的元素
return searchAttrIds.contains(item.getAttrId());
}).map(item -> {
SkuEsModel.Attrs attr = new SkuEsModel.Attrs();
BeanUtils.copyProperties(item,attr);
return attr;
}).collect(Collectors.toList());
Map<Long, Boolean> finalSkuHasStock = skuHasStock;
List<SkuEsModel> upProducts = skus.stream().map(sku -> {
SkuEsModel skuEsModel = new SkuEsModel();
BeanUtils.copyProperties(sku,skuEsModel);
skuEsModel.setSkuPrice(sku.getPrice());
skuEsModel.setSkuImg(sku.getSkuDefaultImg());
//2.1 设置库存信息(防止此处多次调用远程服务,所以在循环外部查询)
skuEsModel.setHasStock(finalSkuHasStock != null && finalSkuHasStock.get(sku.getSkuId()));
//TODO 2.2、热度评分默认为 0
skuEsModel.setHotScore(0L);
//TODO 2.3、查询品牌和商品分类的信息
BrandEntity brand = brandService.getById(sku.getBrandId());
skuEsModel.setBrandImg(brand.getName());
skuEsModel.setBrandImg(brand.getLogo());
CategoryEntity category = categoryService.getById(sku.getCatalogId());
skuEsModel.setCatalogName(category.getName());
//2.4 设置属性
skuEsModel.setAttrs(attrs);
return skuEsModel;
}).collect(Collectors.toList());
//TODO 3、将数据发送给 es保存,直接发送给 search服务
R r = searchFeignService.productStatusUp(upProducts);
if (r.getCode() == 0) {
// 远程调用成功
// TODO 3.1、修改当前 spu 的状态
baseMapper.updateSpuStatus(spuId, ProductConstant.StatusEnum.SPU_UP.getCode());
} else {
// 远程调用失败
//TODO 4、重复调用?接口冥等性、重试机制
/**
* feign源码分析:
* 1、构造请求数据,将对象转成json
* RequestTemplate template = buildTemplateFromArgs.create(argv);
* 2、发送请求进行执行(执行成功进行解码)
* executeAndDecode(template);
* 3、执行请求会有重试机制
* while (true) {
* try {
* return executeAndDecode(template);
* } catch (RetryableException e) {
* try {
* retryer.continueOrPropagate(e);
* } catch (RetryableException th) {
* throw cause;
* }
* continute
*/
}
}
1)、远程服务查询有无库存
发送远程调用,库存系统查询是否有库存
common 模块里面新建实体类用于传输数据
SkuHasStockTo
@Data
public class SkuHasStockTo {
private Long skuId;
private Boolean hasStock;
}
gulimall-ware 服务里的接口
WareSkuController
/**
* 查询指定sku是否有库存
*/
@PostMapping("/hasStock")
public R getSkuHasStock(@RequestBody List<Long> skuIds){
List<SkuHasStockTo> tos = wareSkuService.getSkuStock(skuIds);
R r = R.ok().setData(tos);
return r;
}
为了方便传递数据,修改了 R
的代码,添加了泛型方法,存取数据
public class R extends HashMap<String, Object> {
//利用fastjson进行逆转,泛型方法:调用时指定要拿到的数据类型
//注意是 com.alibaba.fastjson.TypeReference
public <T> T getData(TypeReference<T> typeReference){
Object data = get("data");
//此处不能直接将data强转为指定要拿到的数据类型
String s = JSON.toJSONString(data);
T t = JSON.parseObject(s,typeReference);
return t;
}
public R setData(Object object){
put("data",object);
return this;
}
...
}
为什么上面不能直接将data强转为指定要拿到的数据类型?
注意:
因为 map 中的 value 为一个对象,在 springmvc 中取出这个对象时会将这个对象默认转为 map
SpringMVC 对于 Object 转 json 的时候,会变成 key-value 的形式
示例:
当方法返回 HashMap——value为List——List泛型为自定义类对象
以下是正常情况下方法执行结果
以下是远程调用返回结果
会将 map 值value 里面的集合 list 里面的对象转化为 map键值对
示例:如果直接返回 List 集合也会有上面的问题
但是如果直接返回自定义类对象,不会被转化为键值对
gulimall-product 服务里接收数据
//TODO 2.1、发送远程调用,库存系统查询是否有库存(hasStock)
List<Long> skuIdList = skus.stream().map(SkuInfoEntity::getSkuId).collect(Collectors.toList());
//将list转换为map
Map<Long, Boolean> skuHasStock = null;
try {
R r = wareFeignService.getSkuHasStock(skuIdList);
TypeReference<List<SkuHasStockTo>> typeReference = new TypeReference<List<SkuHasStockTo>>() {};
skuHasStock = r.getData(typeReference).stream().collect(Collectors.toMap(SkuHasStockTo::getSkuId, SkuHasStockTo::getHasStock));
} catch (Exception e) {
log.error("库存服务异常:原因:{}",e);
e.printStackTrace();
}
注意:以下写法有误
gulimall-product 服务里的商品上架 SpuInfoServiceImpl
R<List<SkuHasStockTo>> r = wareFeignService.getSkuHasStock(skus.stream().map(SkuInfoEntity::getSkuId).collect(Collectors.toList())); //将list转换为map Map<Long, Boolean> skuHasStock = r.getData().stream().collect(Collectors.toMap(SkuHasStockTo::getSkuId, SkuHasStockTo::getHasStock));
gulimall-ware 服务里的接口 WareSkuController
R<List<SkuHasStockTo>> getSkuHasStock(@RequestBody List<Long> skuIds){ List<SkuHasStockTo> tos = wareSkuService.getSkuStock(skuIds); R r = R.ok(); r.setData(tos); return r; }
此处为了方便传递数据,修改了
R
的代码,添加了泛型属性public class R<T> extends HashMap<String, Object> { private T data; public T getData() {return data;} public void setData(T data) {this.data = data;} ... }
错误原因:
因为Jackson对于HashMap类型会有特殊的处理方式,具体来说就是会对类进行向上转型为Map,导致子类的私有属性消失
就会导致在 gulimall-product 服务里 r.getData() 拿不到属性值数据
所以将 R 修改为泛型类—— pass
WareSkuService
//查询指定skuid列表是否由库存
List<SkuHasStockTo> getSkuStock(List<Long> skuIds);
@Override
public List<SkuHasStockTo> getSkuStock(List<Long> skuIds) {
return skuIds.stream().map(id -> {
SkuHasStockTo to = new SkuHasStockTo();
//SELECT SUM(stock-stock_locked) FROM `wms_ware_sku` where sku_id = ?
//注意这里接收 count 的类型是 Long,因为查询出来的结果可能是null,需要用包装类
Long count = baseMapper.getSkuStockById(id);
to.setSkuId(id);
to.setHasStock(count != null && count > 0);
return to;
}).collect(Collectors.toList());
}
<select id="getSkuStockById" resultType="java.lang.Long">
SELECT SUM(stock-stock_locked) FROM `wms_ware_sku`
where sku_id = #{id}
</select>
gulimall-product 服务里的接口
WareFeignService
@FeignClient("gulimall-ware")
public interface WareFeignService {
//查询指定sku是否有库存
@PostMapping("/ware/waresku/hasStock")
R getSkuHasStock(@RequestBody List<Long> skuIds);
}
AttrService
给定属性id列表,从中筛选出可检索属性列表
//给定属性id列表,从中筛选出可检索属性列表
List<Long> selectSearchAttrs(List<Long> attrIds);
@Override
public List<Long> selectSearchAttrs(List<Long> attrIds) {
return attrDao.selectSearchAttrs(attrIds);
}
<select id="selectSearchAttrs" resultType="java.lang.Long">
select attr_id from pms_attr where attr_id in
<foreach collection="attrIds" item="id" separator="," open="(" close=")">
#{id}
</foreach>
and search_type = 1
</select>
2)、ES保存数据
gulimall-search 模块中编写保存数据接口
ElasticSaveController
@Slf4j
@RequestMapping("search/save")
@RestController
public class ElasticSaveController {
@Autowired
ProductSaveService productSaveService;
@PostMapping("/product")
public R productStatusUp(@RequestBody List<SkuEsModel> list) throws IOException {
boolean b = false;
try {
b = productSaveService.productStatusUp(list);
} catch (IOException e) {
log.error("ElasticSaveController商品上架错误",e);
return R.error(BizCodeEnume.PRODUCT_UP_EXCEPTION.getCode(),BizCodeEnume.PRODUCT_UP_EXCEPTION.getMsg());
}
if(b) return R.ok();
else return R.error(BizCodeEnume.PRODUCT_UP_EXCEPTION.getCode(),BizCodeEnume.PRODUCT_UP_EXCEPTION.getMsg());
}
}
BizCodeEnume
:common 模块里面的异常常量类
public enum BizCodeEnume {
UNKNOW_EXCEPTION(10000,"系统未知异常"),
VAILD_EXCEPTION(10001,"参数格式校验失败"),
PRODUCT_UP_EXCEPTION(11000,"商品上架异常");
private int code;
private String msg;
BizCodeEnume(int code,String msg){
this.code = code;
this.msg = msg;
}
public int getCode() {
return code;
}
public String getMsg() {
return msg;
}
}
ProductSaveService
public interface ProductSaveService{
/**
* @param list
* @return false 批量保存错误;true 批量保存成功
* @throws IOException
*/
Boolean productStatusUp(List<SkuEsModel> list) throws IOException;
}
ProductSaveServiceImpl
@Service("productSaveServiceImpl")
public class ProductSaveServiceImpl implements ProductSaveService {
@Autowired
RestHighLevelClient restHighLevelClient;
@Override
public Boolean productStatusUp(List<SkuEsModel> list) throws IOException {
//先要在es中建立索引,再在es中保存数据;因为此处数据较多,使用批量保存
BulkRequest bulkRequest = new BulkRequest();
for (SkuEsModel skuEsModel : list) {
//指定存储的索引
IndexRequest indexRequest = new IndexRequest(EsConstant.PRODUCT_INDEX);
indexRequest.id(skuEsModel.getSkuId().toString()); //指定唯一id
String s = JSON.toJSONString(skuEsModel);
indexRequest.source(s, XContentType.JSON);
bulkRequest.add(indexRequest);
}
//参数:BulkRequest bulkRequest, RequestOptions options
BulkResponse bulk = restHighLevelClient.bulk(bulkRequest, GulimallElasticsearchConfig.COMMON_OPTIONS);
//TODO 如果批量错误
//false 批量保存错误;true 批量保存成功
boolean b = bulk.hasFailures();
return !b;
}
}
EsConstant
常量类
public class EsConstant {
//sku数据在es中的索引
public static final String PRODUCT_INDEX = "product";
}
gulimall-product 服务里的接口
SearchFeignService
@FeignClient("gulimall-search")
public interface SearchFeignService {
@PostMapping("/search/save/product")
R productStatusUp(List<SkuEsModel> list);
}
商城业务-首页
1、首页整合
1)、SpringBoot 整合 thymeleaf
pom.xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
<!-- 版本后由SpringBoot进行管理-->
</dependency>
配置文件
Spring:
thymeleaf:
cache: false # 开发过程建议关闭缓存
# 视图解析器
# prefix: '/'
# suffix: '.html'
将提供的页面资料放进 resource 目录
默认SpringBoot会直接去找 templates 下的 index.html
2)、渲染分类数据
需求分析:
我们需要在页面的侧边查询出分类的数据,并且选中一级分类数据后显示二级和三级分类数据
先获取一级分类数据
indexController
@Controller
public class indexController {
@Autowired
CategoryService categoryService;
//查询所有一级分类
@GetMapping({"/","/index.html"})
public String indexPage(Model model){
// select * from category where parent_id = 0
//TODO 1、查询所有的一级分类
List<CategoryEntity> categoryEntityList = categoryService.getLevel1Categorys();
model.addAttribute("category",categoryEntityList);
return "index";
}
}
CategoryService
//查询所有的一级分类
@Override
public List<CategoryEntity> getLevel1Categorys() {
// parent_cid为0则是一级目录
return baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("parent_cid",0));
}
修改前端页面
index.html中使用
<!DOCTYPE html>
<!--使用thymeleaf中必须声明加上该行代码-->
<html lang="en" xmlns:th="http://www.thymeleaf.org">
接收数据
<!--和jsp相关表达式有点相似 具体使用过程参考文档-->
<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>
用户选中后在查询二级分类数据
前端页面需要以下格式的 JSON 数据
因为返回的是 json 数据,所以选择后端返回 map 的 key-value 格式
key 就是一级分类的id,value就是二级分类vo的列表
自定义 VO 对象来传输数据
Catalog2Vo
//二级分类vo
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Catalog2Vo {
private String id;
private String catalog1Id;
private String name;
private List<Catalog3Vo> catalog3List;
//三级分类vo
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class Catalog3Vo{
private String id;
private String catalog2Id;
private String name;
}
}
indexController
@ResponseBody
@GetMapping("index/catalog.json")
public Map<String,List<Catalog2Vo>> getCatalogJson(){
return categoryService.getCatalogJson();
}
CategoryService
@Override
public Map<String, List<Catalog2Vo>> getCatalogJson() {
//查询所有的一级分类
List<CategoryEntity> l1List = getLevel1Categorys();
Map<String, List<Catalog2Vo>> map = l1List.stream().collect(Collectors.toMap(k-> k.getCatId().toString(), l1 -> {
//根据一级分类查询二级分类信息
List<CategoryEntity> l2entities = baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("parent_cid", l1.getCatId()));
List<Catalog2Vo> l2List = null;
if (l2entities != null) {
l2List = l2entities.stream().map(l2 -> {
Catalog2Vo catalog2Vo = new Catalog2Vo();
catalog2Vo.setCatalog1Id(l1.getCatId().toString());
catalog2Vo.setId(l2.getCatId().toString());
catalog2Vo.setName(l2.getName());
//根据二级分类查询三级分类信息
List<CategoryEntity> l3entities = baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("parent_cid", l2.getCatId()));
if (l3entities != null) {
List<Catalog2Vo.Catalog3Vo> l3List = l3entities.stream().map(l3 -> {
Catalog2Vo.Catalog3Vo catalog3Vo = new Catalog2Vo.Catalog3Vo(l3.getCatId().toString(), l2.getCatId().toString(), l3.getName());
return catalog3Vo;
}).collect(Collectors.toList());
catalog2Vo.setCatalog3List(l3List);
}
return catalog2Vo;
}).collect(Collectors.toList());
}
return l2List;
}));
return map;
}
测试访问 http://localhost:10000/index/catalog.json
,得到 json 数据与要求一致
2、Nginx 域名访问
域名设置:
在 本机上 hosts 文件配置 域名映射,将云端 Linux 服务器地址 配置到域名 gulimall.com
nginx 是安装在 Linux 服务器上的
1)、Nginx——反向代理配置
什么是 反向代理?
nginx.conf 文件里面包含多个模块内容,大致包括如下
vi nginx.conf 文件后可以发现在底部有该条语句:
表示引入nginx下的 conf.d 下面的conf文件,那么我们开始在该目录下增加关于 谷粒商城的 nginx 配置
拷贝原先默认的 conf
修改 server 模块部分:
1、如果是以下的修改。表示:让nginx帮我们进行反向代理,所有来自原gulimall.com的请求,都转到商品服务
2、但是我们这里需要将 nginx 配置转发到网关服务,由网关转发到具体的服务。
一般网关会有多个,此时就需要实现 nginx 负载均衡
2)、Nginx——负载均衡到网关
server 部分 修改如下:
当访问 gulimall.com:80/ 路径时会负载均衡访问 proxy_pass 指明的地址
http 部分 修改如下:可写入多个网关地址
网关配置了访问某些路径就会跳转到指定服务,但是还需要访问 gulimall.com:80 就会访问到商品服务的首页
因为指定了域名 gulimall.com ,即 host 地址,网关设置路由如下:
注意需要放在最后面
routes:
- id: ware_route
uri: lb://gulimall-ware
# 此处断言路径范围较小,需要写在前面,否则报错404
predicates:
- Path=/api/ware/**
filters:
# 将 /api/** 路由到 /**
- RewritePath=/api/(?<segment>.*),/$\{segment}
- id: third_product_route
uri: lb://gulimall-third-product
predicates:
- Path=/api/thirdparty/**
filters:
# 将 /api/thirdparty/** 路由到 /**
- RewritePath=/api/thirdparty/(?<segment>.*),/$\{segment}
- id: product_route
uri: lb://gulimall-product
predicates:
- Path=/api/product/**
filters:
# 将 /api/** 路由到 /**
- RewritePath=/api/(?<segment>.*),/$\{segment}
- id: member_route
uri: lb://gulimall-member
# 此处断言路径范围较小,需要写在前面,否则报错404
predicates:
- Path=/api/member/**
filters:
# 将 /api/** 路由到 /**
- RewritePath=/api/(?<segment>.*),/$\{segment}
- id: admin_route
# 使用了lb形式,从注册中心负载均衡的获取uri
uri: lb://renren-fast
predicates:
- Path=/api/**
filters:
# 将 /api/** 路由到 /renren-fast/** ,该人人服务设置了context-path: /renren-fast
- RewritePath=/api/(?<segment>.*),/renren-fast/$\{segment}
- id: gulimall_host_route
uri: lb://gulimall-product
predicates:
- Host=**.gulimall.com
此时要注意一个问题:
nginx 代理给网关的时候,会丢失请求的 host 信息,所以需要在 nginx 里面再次配置proxy_set_header Host $host
,如下
3)、流程
配置域名: Linux服务器 —— gulimall.com
1、当直接访问 gulimall.com
、gulimall.com:80
时,就会访问到 Linux 服务器上的 nginx 容器
根据 server 里面配置的路径,负载均衡到网关地址,并携带 host:gulimall.com
根据网关配置的路由,路由到了 商品服务的首页
2、当直接访问 gulimall.com/api/product/attrattrgrouprelation/list
时,会被 nginx 监听到,并负载均衡到网关服务地址,并携带 host:gulimall.com
根据网关配置的路由,路由到了 商品服务
- id: product_route
uri: lb://gulimall-product
predicates:
- Path=/api/product/**
filters:
# 将 /api/** 路由到 /**
- RewritePath=/api/(?<segment>.*),/$\{segment}
商城业务-检索服务
1、nginx 搭建
gulimall-search 服务添加依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
将搜索页资料中的 index.html 放入 templates 目录,其他静态资源放入 nginx 中 static 目录下
更改 index.html 里面引用资源的路径。更改文件名为 list.html
设置本地域名
更改 nginx 的 server 部分 ,注意带星号和不带星号都要指明
更改网关服务的配置文件 的 路由
- id: gulimall_host_route
uri: lb://gulimall-product
predicates:
- Host=gulimall.com
- id: gulimall_search_route
uri: lb://gulimall-search
predicates:
- Host=search.gulimall.com
最后实现转发的效果如下
2、检索 DSL 语句
分析检索条件,设定所有查询参数如下:
/list.html?catalog3Id=225&keyWord=华为&skuPrice=500_&attrs=6_华为:苹果&attrs=1_2017:2018:2019:2020&sort=skuPrice_asc
同时设定查询后需要返回商品信息、品牌、分类、属性
根据查询条件,以及要显示的页面数据,构建 DSL 语句
其中包括几个部分:模糊查询、过滤(按照属性,分类,品牌,价格区间,库存)、排序、分页、高亮、聚合分析要显示的结果
DSL 语句如下:
GET product/_search
{
"query": {
"bool": {
"must": {
"match": {
"skuTitle": "华为"
}
},
"filter": [
{
"term": {
"catelogId": "225"
}
},
{
"terms": {
"brandId": [
"2",
"3",
"4"
]
}
},
{
"nested": {
"path": "attrs",
"query": {
"bool": {
"must": [
{
"term": {
"attrs.attrId": {
"value": "6"
}
}
},
{
"terms": {
"attrs.attrValue": [
"苹果",
"华为"
]
}
}
]
}
}
}
},
{
"term": {
"hasStock": "true"
}
},
{
"range": {
"skuPrice": {
"gte": 0,
"lte": 6000
}
}
}
]
}
},
"sort": {
"skuPrice":"desc"
},
"from": 0,
"size": 5,
"highlight": {
"fields": {"skuTitle": {}},
"pre_tags": "<b style='color:red'>",
"post_tags": "</b>"
},
"aggs": {
"brand_agg": {
"terms": {
"field": "brandId",
"size": 10
},
"aggs": {
"brandName_agg": {
"terms": {
"field": "brandName",
"size": 10
}
},
"brandImg_agg": {
"terms": {
"field": "brandImg",
"size": 10
}
}
}
},
"catelog_agg": {
"terms": {
"field": "catelogId",
"size": 10
},
"aggs": {
"catalogName_agg": {
"terms": {
"field": "catalogName",
"size": 10
}
}
}
},
"attr_aggs": {
"nested": {
"path": "attrs"
},
"aggs": {
"attrId_agg": {
"terms": {
"field": "attrs.attrId",
"size": 10
},
"aggs": {
"attrName_agg": {
"terms": {
"field": "attrs.attrName",
"size": 10
}
},
"attrValue_agg": {
"terms": {
"field": "attrs.attrValue",
"size": 10
}
}
}
}
}
}
}
}
3、检索业务代码
根据查询条件,新建 SearchParamVo
// 封装页面查询所有有可能传递过来的参数
@Data
public class SearchParamVo {
//三级分类id catalog3Id=225
private Long catalog3Id;
//关键字 keyWord=小米
private String keyWord;
//品牌id,可以多选 brandId=1&brandId=2
private List<Long> brandId;
//是否仅看有货 hasStock=0/1
private Integer hasStock;
//价格区间查询 skuPrice=1_500 skuPrice=_500 skuPrice=500_
private String skuPrice;
//相关属性 attrs=6_华为:苹果 & attrs=1_2017:2018:2019:2020
private List<String> attrs;
//页码,默认页码1
private Integer pageNum = 1;
/**
* 根据销量、价格、评分排序
* sort=saleCount_asc/desc
* sort=skuPrice_asc/desc
* sort=hotScore_asc/desc
*/
private String sort;
}
根据要返回的结果,新建 SearchResultVo
//查询结果返回
@Data
public class SearchResultVo {
//查询到的上架商品的信息
private List<SkuEsModel> products;
//分页信息
private Integer pageNum; //当前页码
private Long total; //总记录数
private Integer totalPages; //总页码
//查询到的结果,涉及到的所有品牌
private List<BrandVo> brandVos;
//查询到的结果,涉及到的所有属性
private List<AttrVo> attrVos;
//查询到的结果,涉及到的所有分类
private List<CatelogVo> catelogVos;
@Data
public static class BrandVo{
private Long brandId;
private String brandName;
private String brandImg;
}
@Data
public static class AttrVo{
private Long attrId;
private String attrName;
private List<String> attrValue;
}
@Data
public static class CatelogVo{
private Long catelogId;
private String catalogName;
}
}
接口 SearchController
@Autowired
MallSearchService mallSearchService;
//自动将页面提交过来的所有请求查询参数封装成指定的对象
@GetMapping("/list.html")
public String listPage(SearchParamVo param, Model model){
//1、根据传递来的页面参数,去es中检索商品
SearchResultVo result = mallSearchService.search(param);
model.addAttribute("result",result);
return "list";
}
枚举值 EsConstant
public class EsConstant {
//sku数据在es中的索引
public static final String PRODUCT_INDEX = "product";
//商品检索后的结果每页显示的个数
public static final Integer PRODUCT_PAGESIZE = 3;
}
业务类 MallSearchServiceImpl
package afei.search.service.impl;
import afei.common.to.es.SkuEsModel;
import afei.search.config.GulimallElasticsearchConfig;
import afei.search.constant.EsConstant;
import afei.search.service.MallSearchService;
import afei.search.vo.SearchParamVo;
import afei.search.vo.SearchResultVo;
import com.alibaba.fastjson.JSON;
import org.apache.lucene.search.join.ScoreMode;
import org.elasticsearch.action.search.SearchRequest;
import org.elasticsearch.action.search.SearchResponse;
import org.elasticsearch.client.RestHighLevelClient;
import org.elasticsearch.index.query.BoolQueryBuilder;
import org.elasticsearch.index.query.NestedQueryBuilder;
import org.elasticsearch.index.query.QueryBuilders;
import org.elasticsearch.index.query.RangeQueryBuilder;
import org.elasticsearch.search.SearchHit;
import org.elasticsearch.search.SearchHits;
import org.elasticsearch.search.aggregations.AggregationBuilders;
import org.elasticsearch.search.aggregations.Aggregations;
import org.elasticsearch.search.aggregations.bucket.MultiBucketsAggregation;
import org.elasticsearch.search.aggregations.bucket.nested.NestedAggregationBuilder;
import org.elasticsearch.search.aggregations.bucket.nested.ParsedNested;
import org.elasticsearch.search.aggregations.bucket.terms.ParsedLongTerms;
import org.elasticsearch.search.aggregations.bucket.terms.ParsedStringTerms;
import org.elasticsearch.search.aggregations.bucket.terms.Terms;
import org.elasticsearch.search.aggregations.bucket.terms.TermsAggregationBuilder;
import org.elasticsearch.search.builder.SearchSourceBuilder;
import org.elasticsearch.search.fetch.subphase.highlight.HighlightBuilder;
import org.elasticsearch.search.sort.SortOrder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.thymeleaf.util.StringUtils;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
/**
* @Description:
* @Author:
* @date:2022/10/12 17:15
*/
@Service
public class MallSearchServiceImpl implements MallSearchService {
@Autowired
RestHighLevelClient restHighLevelClient;
@Override
public SearchResultVo search(SearchParamVo param) {
SearchResultVo result = new SearchResultVo();
//1、准备检索请求
SearchRequest searchRequest = buildSearchRequest(param);
try {
//2、执行检索
SearchResponse searchResponse = restHighLevelClient.search(searchRequest, GulimallElasticsearchConfig.COMMON_OPTIONS);
//3、分析响应数据封装成我们需要的格式
result = buildSearchResult(searchResponse, param);
} catch (IOException e) {
e.printStackTrace();
}
return result;
}
此处对照 ES 构建的 DSL 语句编写代码
/**
* 准备检索请求
* 模糊匹配、过滤(按照属性、分类、品牌、价格区间、库存),排序,分页,高亮,聚合分析
*/
private SearchRequest buildSearchRequest(SearchParamVo param) {
//构建DSL查询语句
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
/**
* 模糊匹配、过滤(按照属性、分类、品牌、价格区间、库存),排序,分页,高亮
*/
//1、 query-bool
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
sourceBuilder.query(boolQuery); //将条件进行封装
// 1.1 must-match 根据关键词查询
if (!StringUtils.isEmpty(param.getKeyWord())){
boolQuery.must(QueryBuilders.matchQuery("skuTitle",param.getKeyWord()));
}
// 1.2 filter-term 根据分类id查询
if (null != param.getCatalog3Id()){
boolQuery.filter(QueryBuilders.termQuery("catelogId",param.getCatalog3Id()));
}
// 1.3 filter-terms 根据多个品牌id查询
if (param.getBrandId() != null && param.getBrandId().size()>0){
boolQuery.filter(QueryBuilders.termsQuery("brandId",param.getBrandId()));
}
// 1.4 filter-term 根据是否有货查询,若未传入参数,则不拼装此条件
if (param.getHasStock() != null){
boolQuery.filter(QueryBuilders.termQuery("hasStock",param.getHasStock()==1?true:false));
}
// 1.5 filter-range 根据价格区间查询 skuPrice=1_500 skuPrice=_500 skuPrice=500_
if (!StringUtils.isEmpty(param.getSkuPrice())){
RangeQueryBuilder rangeQuery = QueryBuilders.rangeQuery("skuPrice");
String[] s = param.getSkuPrice().split("_");
if (s.length == 2){
//_6000可能截取成["","6000"]
if (!StringUtils.isEmpty(s[0])){
rangeQuery.gte(s[0]);
}
rangeQuery.lte(s[1]);
}else {
if (param.getSkuPrice().startsWith("_")){
rangeQuery.lte(s[0]);
}
if (param.getSkuPrice().endsWith("_")){
rangeQuery.gte(s[0]);
}
}
boolQuery.filter(rangeQuery);
}
//1.6 filter-nested-must-term 按照属性id 属性value进行查询 attrs=6_华为:苹果 & attrs=1_2020:2021
List<String> attrs = param.getAttrs();
if (attrs != null && attrs.size()>0){
attrs.forEach(attr->{
String[] attrSplit = attr.split("_");
String[] attrValues = attrSplit[1].split(":");
BoolQueryBuilder queryBuilder = QueryBuilders.boolQuery();
queryBuilder.must(QueryBuilders.termQuery("attrs.attrId",attrSplit[0]));
queryBuilder.must(QueryBuilders.termsQuery("attrs.attrValue",attrValues));
//将每个 attrs 都生成一个 nested 查询
NestedQueryBuilder nestedQuery = QueryBuilders.nestedQuery("attrs",queryBuilder, ScoreMode.None);
boolQuery.filter(nestedQuery);
});
}
sourceBuilder.query(boolQuery); //查询条件进行封装
//2、 sort 排序 sort=saleCount_asc/desc
if (!StringUtils.isEmpty(param.getSort())){
String[] s = param.getSort().split("_");
sourceBuilder.sort(s[0],s[1].equalsIgnoreCase("asc")? SortOrder.ASC:SortOrder.DESC);
}
//3、分页
sourceBuilder.from((param.getPageNum() - 1) * EsConstant.PRODUCT_PAGESIZE);
sourceBuilder.size(EsConstant.PRODUCT_PAGESIZE);
//4、高亮
if (!StringUtils.isEmpty(param.getKeyWord())) {
HighlightBuilder highlightBuilder = new HighlightBuilder();
highlightBuilder.field("skuTitle");
highlightBuilder.preTags("<b style='color:red'>");
highlightBuilder.postTags("</b>");
sourceBuilder.highlighter(highlightBuilder);
}
/**
* 聚合分析
*/
//5.1 品牌聚合 brand_agg
TermsAggregationBuilder brand_agg = AggregationBuilders.terms("brand_agg").field("brandId").size(50);
TermsAggregationBuilder brandName_agg = AggregationBuilders.terms("brandName_agg").field("brandName").size(50);
TermsAggregationBuilder brandImg_agg = AggregationBuilders.terms("brandImg_agg").field("brandImg").size(50);
brand_agg.subAggregation(brandName_agg);
brand_agg.subAggregation(brandImg_agg);
sourceBuilder.aggregation(brand_agg);
//5.2 分类聚会 catelog_agg
TermsAggregationBuilder catelog_agg = AggregationBuilders.terms("catelog_agg").field("catelogId").size(50);
TermsAggregationBuilder catalogName_agg = AggregationBuilders.terms("catalogName_agg").field("catalogName").size(50);
catelog_agg.subAggregation(catalogName_agg);
sourceBuilder.aggregation(catelog_agg);
//5.3 嵌入式数据的聚合 attr_aggs
NestedAggregationBuilder nestedAggregationBuilder = new NestedAggregationBuilder("attr_aggs", "attrs");
TermsAggregationBuilder attrId_agg = AggregationBuilders.terms("attrId_agg").field("attrs.attrId").size(50);
TermsAggregationBuilder attrName_agg = AggregationBuilders.terms("attrName_agg").field("attrs.attrName").size(50);
TermsAggregationBuilder attrValue_agg = AggregationBuilders.terms("attrValue_agg").field("attrs.attrValue").size(50);
attrId_agg.subAggregation(attrName_agg);
attrId_agg.subAggregation(attrValue_agg);
nestedAggregationBuilder.subAggregation(attrId_agg);
sourceBuilder.aggregation(nestedAggregationBuilder);
String s = sourceBuilder.toString();
System.out.println("构建的DSL"+s);
SearchRequest searchRequest = new SearchRequest(new String[]{EsConstant.PRODUCT_INDEX}, sourceBuilder);
return searchRequest;
}
对照 ES 查询的 结果参数 SearchResponse 编写代码
//构建结果数据(可对照es查询结果)
private SearchResultVo buildSearchResult(SearchResponse response, SearchParamVo param) {
SearchResultVo resultVo = new SearchResultVo();
SearchHits hits = response.getHits();
//上架的商品
List<SkuEsModel> products = new ArrayList<>();
if (hits.getHits() != null && hits.getHits().length>0){
for (SearchHit hit : hits.getHits()) {
String json = hit.getSourceAsString(); //得到的是json数据
SkuEsModel skuEsModel = JSON.parseObject(json, SkuEsModel.class);
if (!StringUtils.isEmpty(param.getKeyWord())){
//高亮设置
String skuTitle = hit.getHighlightFields().get("skuTitle").fragments()[0].toString();
skuEsModel.setSkuTitle(skuTitle);
}
products.add(skuEsModel);
}
}
resultVo.setProducts(products);
//当前页码
resultVo.setPageNum(param.getPageNum());
//总记录数
long total = hits.getTotalHits().value;
resultVo.setTotal(total);
//总页码
Integer totalPages = (int) (total - 1) / EsConstant.PRODUCT_PAGESIZE + 1;
resultVo.setTotalPages(totalPages);
Aggregations aggregations = response.getAggregations();
//查询结果的品牌信息
ParsedLongTerms brand_agg = aggregations.get("brand_agg");
List<SearchResultVo.BrandVo> brandVos = new ArrayList<>();
for (Terms.Bucket bucket : brand_agg.getBuckets()) {
SearchResultVo.BrandVo vo = new SearchResultVo.BrandVo();
String brandId = bucket.getKeyAsString();
String brandImg = ((ParsedStringTerms)bucket.getAggregations().get("brandImg_agg")).getBuckets().get(0).getKeyAsString();
String brandName = ((ParsedStringTerms)bucket.getAggregations().get("brandName_agg")).getBuckets().get(0).getKeyAsString();
vo.setBrandId(Long.parseLong(brandId));
vo.setBrandName(brandName);
vo.setBrandImg(brandImg);
brandVos.add(vo);
}
resultVo.setBrandVos(brandVos);
//查询结果的分类信息
ParsedLongTerms catelog_agg = aggregations.get("catelog_agg");
List<SearchResultVo.CatelogVo> catelogVos = new ArrayList<>();
for (Terms.Bucket bucket : catelog_agg.getBuckets()) {
SearchResultVo.CatelogVo vo = new SearchResultVo.CatelogVo();
String id = bucket.getKeyAsString();
String catalogName = ((ParsedStringTerms)bucket.getAggregations().get("catalogName_agg")).getBuckets().get(0).getKeyAsString();
vo.setCatelogId(Long.parseLong(id));
vo.setCatalogName(catalogName);
catelogVos.add(vo);
}
resultVo.setCatelogVos(catelogVos);
//查询结果的属性信息
ParsedNested attr_aggs = aggregations.get("attr_aggs");
List<SearchResultVo.AttrVo> attrVos = new ArrayList<>();
ParsedLongTerms attrId_agg = attr_aggs.getAggregations().get("attrId_agg");
for (Terms.Bucket bucket : attrId_agg.getBuckets()) {
SearchResultVo.AttrVo vo = new SearchResultVo.AttrVo();
String id = bucket.getKeyAsString();
String attrName = ((ParsedStringTerms)bucket.getAggregations().get("attrName_agg")).getBuckets().get(0).getKeyAsString();
vo.setAttrId(Long.parseLong(id));
vo.setAttrName(attrName);
ParsedStringTerms attrValue_agg = bucket.getAggregations().get("attrValue_agg");
List<String> attrValue = attrValue_agg.getBuckets().stream().map(MultiBucketsAggregation.Bucket::getKeyAsString).collect(Collectors.toList());
vo.setAttrValue(attrValue);
attrVos.add(vo);
}
resultVo.setAttrVos(attrVos);
return resultVo;
}
4、检索页面渲染
1)、基本数据渲染
显示查询的商品结果信息,并显示聚合的品牌、分类、属性
2)、筛选条件 分页
点击品牌、分类、属性等标签时会进行筛选查询
页面上的函数显示
函数编写
//点击品牌、分类、属性等标签时会进行筛选查询
function searchProducts(name, value) {
var href = location.href + "";
if (href.indexOf("?") != -1){
location.href = location.href +"&"+name+"="+value;
}else {
location.href = location.href +"?"+name+"="+value;
}
}
此处暂存问题:当多次点击 手机 ,查询参数会变成
/list.html?catalog3Id=225&catalog3Id=225&catalog3Id=225
并且筛选查询中多选参数也未解决,例如查询指定多个属性应该时下面这样
&attrs=1_2017:2018:2019:2020
分页渲染,实现效果如下:
分页此处仅实现了如下:
- 是否显示 上一页、下一页按钮
- 点击 页码、上一页、下一页
- 当前页页码按钮样式与其他页码按钮不同
//页码点击跳转,注意此处涉及到第二次点击页码时需要替换请求参数值 &pageNum=1
$(".page_a").click(function () {
var pn = $(this).attr("pn");
var href = location.href;
location.href = replaceAndAddParamVal(href, "pageNum", pn);
return false;
})
//若路径中无此请求参数就添加,有此请求参数就替换参数值
function replaceAndAddParamVal(url, paramName, replaceVal) {
var oldVal = url.toString();
//有此参数就替换参数值
if (oldVal.indexOf(paramName) != -1){
var re = eval('/(' + paramName + '=)([^&]*)/gi');
var newVal = oldVal.replace(re,paramName +'='+ replaceVal);
return newVal;
}else {
//无此参数就添加
var newVal = "";
if (oldVal.indexOf("?") != -1){
newVal = oldVal +"&"+paramName+"="+replaceVal;
}else {
newVal = oldVal +"?"+paramName+"="+replaceVal;
}
return newVal;
}
}
点击搜索框实现关键字搜索
//搜索框搜索关键字
function searchByKeyword() {
location.href = replaceAndAddParamVal(location.href,"keyWord",$("#Keyword_input").val());
}
点击仅显示有货
//仅看有货实现
$("#showHasStock").change(function () {
if ($(this).prop("checked")){
location.href = replaceAndAddParamVal(location.href, "hasStock", 1);
}else {
//若未选,直接移出此请求参数(有货无货都查)
if (location.href.indexOf("?hasStock=1&") != -1){
var re = "hasStock=1&";
}else if((location.href+"").endsWith("?hasStock=1")){
var re = "?hasStock=1";
}else {
var re = eval('/(&hasStock=)([^&]*)/gi');
}
location.href = (location.href+"").replace(re,'');
}
return false;
})
3)、排序渲染
实现综合排序、价格、销量排序
//排序按钮点击样式改变
$(".sort_a").click(function () {
//1、点击改变升降序
$(this).toggleClass("desc"); //toggleClass() 如果存在就删除,没有就添加
//2、绑定指定查询参数 sort=hotScore_asc/desc
var sort = $(this).attr("sort");
sort = $(this).hasClass("desc")?sort+"_desc":sort+"_asc";
location.href = replaceAndAddParamVal(location.href, "sort", sort);
return false; //禁用默认行为
})
实现价格区间查询
//价格区间搜索
$("#skuPriceSearchBtn").click(function () {
var from = $("#skuPriceFrom").val();
var to = $("#skuPriceTo").val();
var query = from +'_'+to;
location.href = replaceAndAddParamVal(location.href, "skuPrice", query);
})
注意,es 中的 mapping 一定是 integer 等相关类型,否则查询的结果不会对
4)、面包屑导航
商品详情
此部分代码均在 gulimall-product
模块
1、nginx 搭建
设置本地域名
查看 nginx 的 server 部分 ,因为之前设置了带星号的,这里不用设置
更改网关服务的配置文件 的 路由
静态资源上传 nginx
更改 item.html 中的 href 、src 路径
修改 gulimall-search 的 list.html
(注意:若使用 nginx ,图中的路径应为域名 )
<p class="da">
<a th:href="|http://item.gulimall.com/${product.skuId}.html|">
<img th:src="${product.skuImg}" class="dim">
</a>
</p>
2、业务代码
1)、详情页面模型抽取
/**
* @Description: 商品详情页面数据模型
* @Author:
* @date:2022/11/15 9:47
*/
@Data
public class SkuItemVo {
private SkuInfoEntity info;
boolean hasStock = true;
private List<SkuImagesEntity> images;
SpuInfoDescEntity desc;
//销售属性组
List<SkuItemSalAttrVo> saleAttr;
//规格参数基本属性组
List<SpuItemAttrGroupVo> groupAttrs;
@Data
public static class SkuItemSalAttrVo{
private Long attrId;
private String attrName;
//因为后面要实现点击销售属性进行 sku 切换
private List<AttrValueWithSkuIdVo> attrValues;
}
@Data
public static class AttrValueWithSkuIdVo{
private String attrValue;
private String skuIds;
}
@Data
public static class SpuItemAttrGroupVo{
private Long attrGroupId;
private String attrGroupName;
private List<Attr> attrs;
}
}
注意,此处的销售属性组设计,为了实现点击销售属性实现 sku 组合切换
sku 组合切换:每一个属性值都绑定对应的 List,几个属性进行组合时(如选中黑色、官方套餐),直接选出这几个属性的 List 的交集,即为要切换的 sku
2)、查询详情代码
控制层 ItemController
@Controller
public class ItemController {
@Autowired
SkuInfoService skuInfoService;
/**
* 跳转至当前商品的详情页
*/
@GetMapping("/{skuId}.html")
public String skuItem(@PathVariable("skuId") Long skuId, Model model){
SkuItemVo skuItemVo = skuInfoService.item(skuId);
model.addAttribute("item", skuItemVo);
return "item";
}
}
SkuInfoServiceImpl
@Autowired
private SkuImagesService skuImagesService;
@Autowired
private SpuInfoDescService spuInfoDescService;
@Autowired
private AttrGroupService attrGroupService;
@Autowired
private SkuSaleAttrValueService skuSaleAttrValueService;
//商品详情页面展示商品信息
@Override
public SkuItemVo item(Long skuId) {
//基本信息
SkuItemVo skuItemVo = new SkuItemVo();
skuItemVo.setInfo(getById(skuId));
Long catalogId = skuItemVo.getInfo().getCatalogId();
Long spuId = skuItemVo.getInfo().getSpuId();
//图片信息
List<SkuImagesEntity> images = skuImagesService.getImagesBySkuId(skuId);
skuItemVo.setImages(images);
//商品介绍
skuItemVo.setDesc(spuInfoDescService.getById(spuId));
//销售属性组
List<SkuItemVo.SkuItemSalAttrVo> saleAttrVos = skuSaleAttrValueService.getSaleAttrsBySpuId(spuId);
skuItemVo.setSaleAttr(saleAttrVos);
//规格参数基本属性组
List<SkuItemVo.SpuItemAttrGroupVo> attrGroupVos = attrGroupService.getAttrGroupWithAttrsBySpuId(spuId,catalogId);
skuItemVo.setGroupAttrs(attrGroupVos);
return skuItemVo;
}
SkuImagesServiceImpl
@Override
public List<SkuImagesEntity> getImagesBySkuId(Long skuId) {
return this.baseMapper.selectList(new QueryWrapper<SkuImagesEntity>().eq("sku_id",skuId));
}
SkuSaleAttrValueServiceImpl
@Override
public List<SkuItemVo.SkuItemSalAttrVo> getSaleAttrsBySpuId(Long spuId) {
SkuSaleAttrValueDao dao = this.baseMapper;
return dao.getSaleAttrsBySpuId(spuId);
}
注意此处因为是内部类,type 属性中需要用$SpuItemAttrGroupVo
代替.SpuItemAttrGroupVo
此处要实现在页面显示切换属性时,快速得到对应skuId的值,比如白色对应的sku_ids为30,29,而8+128GB对应的sku_ids为29,31,27,那么销售属性为白色、8+128GB的商品的skuId则为二者的交集29
效果如下:
<resultMap type="afei.product.vo.SkuItemVo$SkuItemSalAttrVo" id="skuItemSalAttrMap">
<result property="attrId" column="attr_id"/>
<result property="attrName" column="attr_name"/>
<collection property="attrValues" ofType="afei.product.vo.SkuItemVo$AttrValueWithSkuIdVo">
<result property="attrValue" column="attr_value"/>
<result property="skuIds" column="sku_id"/>
</collection>
</resultMap>
<select id="getSaleAttrsBySpuId" resultMap="skuItemSalAttrMap">
SELECT GROUP_CONCAT( i.sku_id) sku_id,GROUP_CONCAT(DISTINCT a.attr_id) attr_id,
GROUP_CONCAT(DISTINCT a.attr_name) attr_name, a.attr_value
from pms_sku_info i
LEFT JOIN pms_sku_sale_attr_value a on a.sku_id = i.sku_id
where i.spu_id = #{spuId}
GROUP BY a.attr_value;
</select>
AttrGroupServiceImpl
@Override
public List<SkuItemVo.SpuItemAttrGroupVo> getAttrGroupWithAttrsBySpuId(Long spuId, Long catalogId) {
AttrGroupDao dao = this.baseMapper;
return dao.getAttrGroupWithAttrsBySpuId(spuId,catalogId);
}
<resultMap type="afei.product.vo.SkuItemVo$SpuItemAttrGroupVo" id="spuItemAttrGroupMap">
<result property="attrGroupId" column="attr_group_id"/>
<result property="attrGroupName" column="attr_group_name"/>
<collection property="attrs" ofType="afei.product.vo.Attr">
<result property="attrId" column="attr_id"/>
<result property="attrName" column="attr_name"/>
<result property="attrValue" column="attr_value"/>
</collection>
</resultMap>
<select id="getAttrGroupWithAttrsBySpuId" resultMap="spuItemAttrGroupMap">
SELECT g.attr_group_name,g.attr_group_id,r.attr_id ,a.attr_name,a.attr_value
from pms_attr_group g
LEFT JOIN pms_attr_attrgroup_relation r on g.attr_group_id=r.attr_group_id
LEFT JOIN pms_product_attr_value a on r.attr_id = a.attr_id
where g.`catelog_id` = #{catalogId} and a.spu_id = #{spuId};
</select>
3、页面渲染 - sku 组合切换
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<div class="boxx">
<!-- 商品大图 -->
<div class="imgbox">
<div class="probox">
<img class="img1" alt="" th:src="${item.info.skuDefaultImg}">
<div class="hoverbox"></div>
</div>
<div class="showbox">
<img class="img1" alt="" th:src="${item.info.skuDefaultImg}">
</div>
</div>
<!-- 商品多张小图 -->
<div class="box-lh">
<div class="box-lh-one">
<ul th:each="img:${item.images}" th:if="${!#strings.isEmpty(img.imgUrl)}">
<li><img th:src="${img.imgUrl}"/></li>
</ul>
</div>
<div class="box-two">
<div class="box-name" th:text="${item.info.skuTitle}">
华为 HUAWEI Mate 10 6GB+128GB 亮黑色 移动联通电信4G手机 双卡双待
</div>
<div class="box-hide" th:text="${item.info.skuSubtitle}">预订用户预计11月30日左右陆续发货!麒麟970芯片!AI智能拍照!
<a href="item/"><u></u></a>
</div>
<div class="box-yuyue">
<div class="yuyue-one">
<img src="item/img/7270ffc3baecdd448958f9f5e69cf60f.png" alt="" /> 预约抢购
</div>
<div class="yuyue-two">
<ul>
<li>
<img src="item/img/f64963b63d6e5849977ddd6afddc1db5.png" />
<span>190103</span> 人预约
</li>
<li>
<img src="item/img/36860afb69afa241beeb33ae86678093.png" /> 预约剩余
<span id="timer">
</span>
</li>
</ul>
</div>
</div>
<div class="box-summary clear">
<ul>
<li>京东价</li>
<li>
<span>¥</span>
<span th:text="${#numbers.formatDecimal(item.info.price,0,2)}">4499.00</span>
</li>
<!--商品介绍-->
<li class="jieshoa actives" id="li1">
<div class="shanpinsssss">
<img class="xiaoguo" th:src="${descp}" th:each="descp:${#strings.listSplit(item.desc.decript,',')}"/>
</div>
</li>
<!--规格与包装-->
<li class="baozhuang actives" id="li2">
<div class="guiGebox">
<div class="guiGe" th:each="group:${item.groupAttrs}">
<h3 th:text="${group.attrGroupName}">主体</h3>
<dl>
<div th:each="attr:${group.attrs}">
<dt th:text="${attr.attrName}">品牌</dt>
<dd th:text="${attr.attrValue}">华为(HUAWEI)</dd>
</div>
</dl>
</div>
销售属性,实现 sku 组合切换
实现在页面显示切换属性时,快速得到对应skuId的值,比如白色对应的sku_ids为30,29,而8+128GB对应的sku_ids为29,31,27,那么销售属性为白色、8+128GB的商品的skuId则为二者的交集29
效果如下:
<div class="box-attr-3">
<div class="box-attr clear" th:each="attr:${item.saleAttr}">
<dl>
<dt>选择[[${attr.attrName}]]</dt>
<dd th:each="vals:${attr.attrValues}">
<a th:attr="skus=${vals.skuIds},
class=${#lists.contains(#strings.listSplit(vals.skuIds,','),item.info.skuId.toString())?
'sku_attr_value checked' : 'sku_attr_value'}">
[[${vals.attrValue}]]
</a>
</dl>
</div>
</div>
通过控制class中是否包换checked属性来控制显示样式,因此要根据skuId判断
<script>
$(".sku_attr_value").click(function(){
//1、点击的元素添加上自定义的属性,为了识别我们是刚才被点击的、
var skus = new Array();
$(this).addClass("clicked");
var curr = $(this).attr("skus").split(",");
//当前被点击的所有sku组合数组放进去
skus.push(curr);
//去掉同一行的所有的checked
$(this).parent().parent().find(".sku_attr_value").removeClass("checked");
$("a[class='sku_attr_value checked']").each(function(){
skus.push($(this).attr("skus").split(","));
});
console.log(skus);
//2、取出他们的交集,得到skuId
// console.log($(skus[0]).filter(skus[1])[0]);
var filterEle = skus[0];
for (var i = 1; i < skus.length; i++){
filterEle = $(filterEle).filter(skus[i])
}
console.log(filterEle[0]);
//3、跳转
location.href = "http://localhost:10000/"+filterEle[0]+".html";
});
$(function(){
$(".sku_attr_value").parent().css({"border":"solid 1px #ccc"});
$("a[class='sku_attr_value checked']").parent().css({"border":"solid 1px red"});
//方法二
// $(".sku_attr_value").parent().css({"border":"solid 1px #ccc"});
// $(".sku_attr_value.checked").parent().css({"border":"solid 1px red"});
})
</script>
4、使用异步编排
使用注解 @ConfigurationProperties 和 @Component 的方式注入配置信息,需要添加依赖
<!--使用 @ConfigurationProperties-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
添加线程池属性配置类,并注入到容器中
@ConfigurationProperties(prefix = "gulimall.thread")
@Component
@Data
public class ThreadPoolProperties {
private Integer corePoolSize;
private Integer maximumPoolSize;
private Long keepAliveTime;
}
配置文件
#线程池属性的配置
gulimall:
thread:
core-pool-size: 20
maximum-pool-size: 200
keep-alive-time: 10
线程池配置,获取线程池的属性值这里直接调用与配置文件相对应的属性配置类
@Configuration
public class MyThreadConfig {
@Bean
public ThreadPoolExecutor threadPoolExecutor(ThreadPoolProperties pool){
return new ThreadPoolExecutor(pool.getCorePoolSize(),
pool.getMaximumPoolSize(),
pool.getKeepAliveTime(),
TimeUnit.SECONDS,
new LinkedBlockingDeque<>(100000),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy());
}
}
为了使任务进行的更快,可以让查询的各个子任务多线程执行,但是由于其中某些任务之间有相互依赖的关系,因此就涉及到了异步编排。
在这次查询中 spu 的销售属性、介绍、规格参数信息都需要 spuId,因此依赖 skuInfo 的获取,使用supplyAsync()
,所以我们要让这些任务在 1 之后运行。因为我们需要1运行的结果,因此调用thenAcceptAsync()
可以接受上一步的结果且没有返回值。
最后,需要调用 get() 方法使得所有线程都已经执行完成之后,才返回结果
SkuInfoServiceImpl
修改业务方法
@Autowired
private ThreadPoolExecutor executor;
//商品详情页面展示商品信息
@Override
public SkuItemVo item(Long skuId) throws ExecutionException, InterruptedException {
SkuItemVo skuItemVo = new SkuItemVo();
//基本信息
CompletableFuture<SkuInfoEntity> infoFuture = CompletableFuture.supplyAsync(() -> {
SkuInfoEntity info = getById(skuId);
skuItemVo.setInfo(info);
return info;
}, executor);
//商品介绍,需要拿到基本信息后执行
CompletableFuture<Void> descFuture = infoFuture.thenAcceptAsync((info) -> {
skuItemVo.setDesc(spuInfoDescService.getById(info.getSpuId()));
}, executor);
//销售属性组,需要拿到基本信息后执行
CompletableFuture<Void> saleAttrFuture = infoFuture.thenAcceptAsync((info) -> {
List<SkuItemVo.SkuItemSalAttrVo> saleAttrVos = skuSaleAttrValueService.getSaleAttrsBySpuId(info.getSpuId());
skuItemVo.setSaleAttr(saleAttrVos);
}, executor);
//规格参数基本属性组,需要拿到基本信息后执行
CompletableFuture<Void> baseAttrFuture = infoFuture.thenAcceptAsync((info) -> {
List<SkuItemVo.SpuItemAttrGroupVo> attrGroupVos = attrGroupService.getAttrGroupWithAttrsBySpuId(info.getSpuId(),info.getCatalogId());
skuItemVo.setGroupAttrs(attrGroupVos);
}, executor);
//图片信息
CompletableFuture<Void> imageFuture = CompletableFuture.runAsync(() -> {
List<SkuImagesEntity> images = skuImagesService.getImagesBySkuId(skuId);
skuItemVo.setImages(images);
}, executor);
//全部执行完成后返回
CompletableFuture.allOf(descFuture,saleAttrFuture,baseAttrFuture,imageFuture).get();
return skuItemVo;
}
认证服务
1、nginx 搭建
设置本地域名
查看 nginx 的 server 部分 ,因为之前设置了带星号的,这里不用设置
更改网关服务的配置文件 的 路由
- id: gulimall_auth_route
uri: lb://gulimall-auth-server
predicates:
- Host=auth.gulimall.com
静态资源上传 nginx ,在虚拟机的/mydata/nginx/html/static/
下创建reg、login,并把静态资源放入其中
更改 item.html 中的 href 、src 路径
创建gulimall-auth-server模块
gulimall-auth-server 导入依赖
此处引入 common 还要排除 mybatis-plus-boot-starter 依赖
或者使用
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
替代也可
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.8.RELEASE</version>
<relativePath/>
</parent>
<groupId>afei.gulimall</groupId>
<artifactId>gulimall-auth-server</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>gulimall-auth-server</name>
<description>认证中心(单点登录、OAuth2.0社交登录)</description>
<properties>
<java.version>1.8</java.version>
<spring-cloud.version>Greenwich.SR3</spring-cloud.version>
</properties>
<dependencies>
<dependency>
<groupId>afei.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-starter-data-redis</artifactId>
</dependency>
<!-- 阿里云升级版 SDK -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>dysmsapi20170525</artifactId>
<version>2.0.22</version>
</dependency>
<!--JSR303校验-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</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>
添加application.properties配置
server.port=20000
spring.cloud.nacos.config.server-addr=127.0.0.1:8848
spring.application.name=gulimall-auth-server
# thymeleaf页面缓存设置(默认为true),开发中方便调试应设置为false,表示关闭模板缓存,上线稳定后应保持默认
spring.thymeleaf.cache=false
主启动类
@EnableFeignClients
@EnableDiscoveryClient
@SpringBootApplication
public class GulimallAuthServerApplication {
public static void main(String[] args) {
SpringApplication.run(GulimallAuthServerApplication.class, args);
}
}
先把login.html名字改为 index.html,接着启动认证中心和网关服务,访问 http://auth.gulimall.com/
成功启动再改回名字 login.html
修改 gulimall-product 的 index.html
<li>
<a href="http://localhost:20000/login.html">你好,请登录</a>
</li>
<li>
<a href="http://localhost:20000/reg.html" class="li_2">免费注册</a>
</li>
修改 gulimall-auth-server 的 login.html
(注意:若使用 nginx ,图中的路径应为域名 )
<!--顶部logo-->
<header>
<a href="http://localhost:10000/"><img src="login/JD_img/logo.jpg" /></a>
<p>欢迎登录</p>
<h5 class="rig">
<img src="login/JD_img/4de5019d2404d347897dee637895d02b_25.png" />
<span><a href="http://localhost:20000/reg.html">立即注册</a></span>
</h5>
修改 gulimall-auth-server 的 list.html
(注意:若使用 nginx ,图中的路径应为域名 )
<div class="dfg">
<span>已有账号?</span>
<a href="http://localhost:20000/login.html">请登录</a>
</div>
创建 LoginController
类
@Controller
public class LoginController {
@GetMapping("/login.html")
public String loginPage(){
return "login";
}
@GetMapping("/reg.html")
public String regPage(){
return "reg";
}
}
以上代码繁冗,可使用 SpringMVC viewController 代替,将请求映射到页面,不需要写空方法
Ctrl + F12 查看类中的所有方法
@Configuration
public class GulimallWebConfig implements WebMvcConfigurer {
/**
* 视图映射
*/
@Override
public void addViewControllers(ViewControllerRegistry registry) {
/**
* 可直接替换下面这种请求映射到页面的空方法
* @GetMapping({"/login.html"})
* public String loginPage(){
* return "login";
* }
*/
registry.addViewController("/login.html").setViewName("login");
registry.addViewController("/reg.html").setViewName("reg");
}
}
2、注册功能
1)、短信验证码
1 在gulimall-third-party中编写发送短信组件
添加依赖
<!-- 阿里云升级版 SDK -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>dysmsapi20170525</artifactId>
<version>2.0.22</version>
</dependency>
<!--使用 @ConfigurationProperties-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
短信签名名称、模板、accessKey 等信息可以在配置文件中使用前缀 spring.cloud.sms 进行配置
spring:
cloud:
sms:
accessKeyId: xxx
accessKeySecret: xxxF
signName: xxx
templateCode: xxx
编写发送验证码业务类 SmsComponent
//可用此代替 @Value("${spring.cloud.sms.signName}")
@ConfigurationProperties("spring.cloud.sms")
@Component
@Data
public class SmsComponent {
private String accessKeyId;
private String accessKeySecret;
//短信签名名称
private String signName;
//短信模板
private String templateCode;
//手机号
private String phoneNumbers;
//验证码
private String templateParam;
public void sendSmsCode(String phone, String param) {
com.aliyun.teaopenapi.models.Config config = new com.aliyun.teaopenapi.models.Config()
.setAccessKeyId(accessKeyId)
.setAccessKeySecret(accessKeySecret);
config.endpoint = "dysmsapi.aliyuncs.com"; //访问的域名
try {
com.aliyun.dysmsapi20170525.Client client = new com.aliyun.dysmsapi20170525.Client(config);
com.aliyun.dysmsapi20170525.models.SendSmsRequest sendSmsRequest = new com.aliyun.dysmsapi20170525.models.SendSmsRequest()
.setSignName(signName)
.setTemplateCode(templateCode)
.setPhoneNumbers(phone)
.setTemplateParam("{\"code\":\"" + param + "\"}");
com.aliyun.teautil.models.RuntimeOptions runtime = new com.aliyun.teautil.models.RuntimeOptions();
client.sendSmsWithOptions(sendSmsRequest, runtime);
} catch (TeaException error) {
com.aliyun.teautil.Common.assertAsString(error.message);
} catch (Exception _error) {
TeaException error = new TeaException(_error.getMessage(), _error);
com.aliyun.teautil.Common.assertAsString(error.message);
}
}
}
编写用于其他服务调用的接口 SmsSendController
@RestController
public class SmsSendController {
@Autowired
SmsComponent smsComponent;
@GetMapping("/sms/sendcode")
public R sendCode(@RequestParam("phone") String phone, @RequestParam("code") String code){
smsComponent.sendSmsCode(phone,code);
return R.ok();
}
}
2 在gulimall-commom常量包下创建验证码的常量类
AuthServerConstant
public class AuthServerConstant {
public static final String SMS_CODE_PREFIX="sms:code:";
}
添加异常常量BizCodeEnume
,60秒内重复手机号获取验证码的错误提示
public enum BizCodeEnume {
UNKNOW_EXCEPTION(10000,"系统未知异常"),
VAILD_EXCEPTION(10001,"参数格式校验失败"),
PRODUCT_UP_EXCEPTION(11000,"商品上架异常"),
SMS_CODE_EXCEPTION(10002,"验证码获取频率太高,请稍后再试");
3 在gulimall-auth-server中进行远程调用短信验证码服务,并且页面渲染
添加 redis 依赖和配置
<!-- feign -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<!-- redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- 校验 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
#配置redis
spring.redis.host=localhost
spring.redis.port=6379
页面 reg.html
<span id="sendCode">发送验证码</span>
$(function () {
/**
* 验证码发送
*/
$("#sendCode").click(function () {
//判断是否有该样式
if ($(this).hasClass("disabled")) {
// 正在倒计时
} else {
// 发送验证码
$.get("/sms/sendCode?phone=" + $("#phoneNum").val(), function (data) {
if (data.code != 0) {
alert(data.msg)
}
})
timeoutChangeStyle();
}
})
})
// 60秒
var num = 60;
function timeoutChangeStyle() {
// 先添加样式,防止重复点击
$("#sendCode").attr("class", "disabled")
// 到达0秒后 重置时间,去除样式
if (num == 0) {
$("#sendCode").text("发送验证码")
num = 60;
// 时间到达后清除样式
$("#sendCode").attr("class", "");
} else {
var str = num + "s 后再次发送"
$("#sendCode").text(str);
setTimeout("timeoutChangeStyle()", 1000);
}
num--;
}
远程调用接口 ThirdPartFeignService
@FeignClient("gulimall-third-product")
public interface ThirdPartFeignService {
@GetMapping("/sms/sendcode")
public R sendCode(@RequestParam("phone") String phone, @RequestParam("code") String code);
}
发送验证码接口 LoginController
数据存在redis:键为 sms:code:12341234123 → 值为 35924_时间戳
防止同一手机号重复获取验证码,设置若 60s 内该号码已经调用过,返回错误信息
60s 以后再次调用,需要删除之前存储的phone-code
code 存在一个过期时间,设置为10min
@Controller
public class LoginController {
@Autowired
ThirdPartFeignService thirdPartFeignService;
@Autowired
StringRedisTemplate redisTemplate;
@ResponseBody
@GetMapping("/sms/sendCode")
public R sendCode(@RequestParam("phone") String phone){
//TODO 1、接口防刷
//2、当点击网页刷新后,60s内不可以重新发送验证码
//防止同一个phone在60秒内再次发送验证码(不能根据redis的过期时间,万一发送失败还要等几分钟不合理)
String prefixPhone = AuthServerConstant.SMS_CODE_PREFIX + phone;
String redisCode = stringRedisTemplate.opsForValue().get(prefixPhone);
if (StringUtils.isNotBlank(redisCode)){
long oldTime = Long.parseLong(redisCode.split("_")[1]);
if (System.currentTimeMillis() - oldTime < 60000){
return R.error(BizCodeEnume.SMS_CODE_EXCEPTION.getCode(),BizCodeEnume.SMS_CODE_EXCEPTION.getMsg());
}
stringRedisTemplate.delete(prefixPhone);
}
//redis缓存验证码 sms:code:12341234123 -> 35924_时间戳 ,过期时间十分钟
String code = UUID.randomUUID().toString().substring(0, 5);
stringRedisTemplate.opsForValue().set(prefixPhone,code + "_" + System.currentTimeMillis(),10, TimeUnit.MINUTES);
//发送验证码
thirdPartFeignService.sendCode(phone,code);
return R.ok();
}
}
查看网页源码会暴露短信发送的请求路径
而且当点击网页刷新后,又可以重新发送验证码
2)、用户注册
1 在gulimall-auth-server服务中编写注册的主体逻辑
添加依赖
<!--JSR303校验-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
实体类接收页面参数 UserRegistVo
@Data
public class UserRegistVo {
@NotEmpty(message = "用户名必须提交")
@Length(min = 6, max = 18, message = "用户名必须是6-18位字符")
private String userName;
@NotEmpty(message = "密码必须填写")
@Length(min = 6, max = 18, message = "密码必须是6-18位字符")
private String password;
@NotEmpty(message = "手机号必须填写")
@Pattern(regexp = "^[1]([3-9])[0-9]{9}$",message = "手机号格式不正确")
private String phone;
@NotEmpty(message = "验证码必须填写")
private String code;
}
修改页面
<form action="/regist" method="post" class="one">
<div class="register-box">
<label class="username_label">用 户 名
<input name="userName" maxlength="20" type="text" placeholder="您的用户名和登录名">
</label>
<div class="tips" style="color: red"
th:text="${errors!=null?(#maps.containsKey(errors,'userName')?errors.userName:''):''}">
</div>
</div>
编写接口 LoginController
主要逻辑:
1 若JSR303校验未通过,则通过BindingResult封装错误信息,并重定向至注册页面
2 若通过JSR303校验,则需要从redis中取值判断验证码是否正确,正确的话通过会员服务注册
3 会员服务调用成功则重定向至登录页,否则封装远程服务返回的错误信息返回至注册页面
校验出错,需要跳转到注册页,如果是 return "forward:/reg.html";
点击注册后会报错如下:
There was an unexpected error (type=Method Not Allowed, status=405).
Request method 'POST' not supported
因为路径映射默认都是 get 访问的,但是此处的接口是 POST 方式,便会报错
改为 return "reg";
虽然也是请求转发,但是因为使用了 viewController ,点击注册不会报错。
但是刷新页面时会重复提交表单,所以不能使用请求转发,要使用请求重定向。但是重定向无法共享数据,需要用到 RedirectAttributes 而不是 Model,底层原理是利用 session 传递数据
/**
* RedirectAttributes 模拟重定向携带数据
* 底层利用session原理,将数据存在session,只要跳转到下一个页面取出数据后,session里的数据就会删掉
* TODO 分布式下的session问题
*/
@PostMapping("/regist")
public String regist(@Valid UserRegistVo vo, BindingResult result, RedirectAttributes redirectAttributes){
//1 校验页面填写是否有误
if (result.hasErrors()){
Map<String, Object> errors = result.getFieldErrors().stream().collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage));
redirectAttributes.addFlashAttribute("errors",errors);
//校验出错,需要跳转到注册页(注意,此处不能转发,转发会导致页面表单重复提交,转发一次就会重新提交一次。如果是重定向无法共享数据)
//如果是重定向无法共享数据,需要用到 RedirectAttributes,底层原理是利用 session传递数据
return "redirect:http://localhost:20000/reg.html";
}
//2 校验验证码是否正确
String redisCode = stringRedisTemplate.opsForValue().get(AuthServerConstant.SMS_CODE_PREFIX + vo.getPhone());
if (StringUtils.isBlank(redisCode)){
Map<String, Object> errors = new HashMap<>();
errors.put("code", "验证码错误");
redirectAttributes.addFlashAttribute("errors",errors);
return "redirect:http://localhost:20000/reg.html";
}
if (!(vo.getCode().equals(redisCode.split("_")[0]))){
Map<String, Object> errors = new HashMap<>();
errors.put("code", "验证码错误");
redirectAttributes.addFlashAttribute("errors",errors);
return "redirect:http://localhost:20000/reg.html";
}
stringRedisTemplate.delete(AuthServerConstant.SMS_CODE_PREFIX + vo.getPhone());
//3 注册逻辑:调用远程服务进行注册
R r = memberFeignService.regist(vo);
if (r.getCode() == 0){
//4 注册成功返回登陆页面
return "redirect:http://localhost:20000/login.html";
}else {
Map<String, String> errors = new HashMap<>();
errors.put("msg", r.getMsg(new TypeReference<String>() {}));
redirectAttributes.addFlashAttribute("errors", errors);
return "redirect:http://localhost:20000/reg.html";
}
}
编写远程调用业务类 MemberFeignService
@FeignClient("gulimall-member")
public interface MemberFeignService {
@PostMapping("/member/member/regist")
R regist(@RequestBody UserRegistVo vo);
}
因为此处调用的远程接口返回错误时,R 中是将错误信息存在 msg 中,所以 R 中新建获取 msg 的方法,如下
在 gulimall-common 中修改 R
添加方法
public <T> T getMsg(TypeReference<T> typeReference){
Object msg = get("msg");
String s = JSON.toJSONString(msg);
T t = JSON.parseObject(s,typeReference);
return t;
}
页面进行错误提示信息反馈
<div class="tips" style="color: red"
th:text="${errors!=null?(#maps.containsKey(errors,'msg')?errors.msg:''):''}">
</div>
2 在gulimall-member中添加自定义异常类
注册逻辑中需要对账号、手机号进行唯一鉴定,在 gulimall-common 添加用户错误信息常量
BizCodeEnume
USER_EXIST_EXCEPTION(15001,"用户已存在"),
PHONE_EXIST_EXCEPTION(15002,"手机号已存在")
在gulimall-member添加自定义异常类
PhoneExistException
public class PhoneExistException extends RuntimeException{
public PhoneExistException(){
super("手机号已存在");
}
}
UserNameExistException
public class UserNameExistException extends RuntimeException{
public UserNameExistException(){
super("用户名已存在");
}
}
3 在gulimall-member中编写注册用户的接口
实体类接收用户注册参数 MemberRegistVo
@Data
public class MemberRegistVo {
private String userName;
private String password;
private String phone;
}
编写注册业务接口 MemberController
1 通过异常机制判断当前注册会员名和电话号码是否已经注册,如果已经注册,则抛出对应的自定义异常,并在返回时封装对应的错误信息
2 如果没有注册,则封装传递过来的会员信息,并设置默认的会员等级、创建时间
@Autowired
private MemberService memberService;
/**
* 用户注册
*/
@PostMapping("/regist")
public R regist(@RequestBody MemberRegistVo vo){
try{
memberService.regist(vo);
//异常机制:通过捕获对应的自定义异常判断出现何种错误并封装错误信息
}catch (PhoneExistException e){
return R.error(BizCodeEnume.PHONE_EXIST_EXCEPTION.getCode(),BizCodeEnume.PHONE_EXIST_EXCEPTION.getMsg());
}catch (UserNameExistException e){
return R.error(BizCodeEnume.USER_EXIST_EXCEPTION.getCode(),BizCodeEnume.USER_EXIST_EXCEPTION.getMsg());
}
return R.ok();
}
编写业务方法逻辑 MemberService
//用户通过用户名、手机号注册
void regist(MemberRegistVo vo);
//检测注册手机号是否唯一
void checkPhoneUnique(String phone) throws PhoneExistException;
//检测注册用户名账号是否唯一
void checkUserNameUnique(String userName) throws UserNameExistException;
实现类 MemberServiceImpl
@Autowired
MemberLevelDao memberLevelDao;
@Override
public void regist(MemberRegistVo vo) {
MemberDao memberDao = this.baseMapper;
MemberEntity memberEntity = new MemberEntity();
//设置会员等级id
MemberLevelEntity entity = memberLevelDao.getDefaultLevel();
memberEntity.setLevelId(entity.getId());
//检查用户名和手机号是否唯一。为了让controller能感知异常,异常机制
checkPhoneUnique(vo.getPhone());
checkUserNameUnique(vo.getUserName());
//设置用户名和手机号
memberEntity.setUsername(vo.getUserName());
memberEntity.setMobile(vo.getPhone());
/**
* 设置密码,将加密后的密文存入数据库
* 加密使用spring提供的BCryptPasswordEncoder
* 内部自动加盐,无需数据库存储盐值,并且同样密码加密后密文结果每次都不同(安全)
*/
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
String encodePassword = encoder.encode(vo.getPassword());
memberEntity.setPassword(encodePassword);
memberEntity.setCreateTime(new Date());
memberDao.insert(memberEntity);
}
@Override
public void checkPhoneUnique(String phone) throws PhoneExistException {
MemberDao memberDao = this.baseMapper;
Integer count = memberDao.selectCount(new QueryWrapper<MemberEntity>().eq("mobile", phone));
if (count > 0){
throw new PhoneExistException();
}
}
@Override
public void checkUserNameUnique(String userName) throws UserNameExistException {
MemberDao memberDao = this.baseMapper;
Integer count = memberDao.selectCount(new QueryWrapper<MemberEntity>().eq("username", userName));
if (count > 0){
throw new UserNameExistException();
}
}
MemberLevelDao
查询 会员等级
<select id="getDefaultLevel" resultType="afei.member.entity.MemberLevelEntity">
select * from `ums_member_level` where `default_status` = 1;
</select>
4 密码加密
如果是MD5加密,对同样的密码加密,不论执行多少次,结果密文都一样
@Test
void contextLoads() {
/**
* MD5加密
* 对同样的密码加密,不论执行多少次,结果密文都一样
* 加密后密文:e10adc3949ba59abbe56e057f20f883e
*/
String s = DigestUtils.md5Hex("123456");
/**
* 盐值加密
* 加盐 $1$+8位字符
* 对同样的密码加同样盐,不论执行多少次,结果密文都一样
* 加密后密文:$1$3333$hDyJ4aO4BeBoDwgnawxCJ1
*/
String s1 = Md5Crypt.md5Crypt("123456".getBytes(), "$1$3333");
System.out.println(s1);
}
此处使用的是spring提供的BCryptPasswordEncoder
内部自动加盐,无需数据库存储盐值,并且同样密码加密后密文结果每次都不同(安全)
验证只需调用其中提供的 matches(明文密码,加密密码)
,返回 boolean
3、用户名密码登录
1)、在gulimall-auth-server服务中编写登录主体逻辑
通过会员服务远程调用登录接口
1、如果调用成功,重定向至首页
2、如果调用失败,则封装错误信息并携带错误信息重定向至登录页
实体类接收页面参数 UserLoginVo
@Data
public class UserLoginVo {
private String loginacct;
private String password;
}
修改页面
<form action="/login" method="post">
<input name="loginacct"
<input name="password"
<li class="bri" style="color: red;font-size: 15px;"
th:text="${errors!=null?(#maps.containsKey(errors,'msg')?errors.msg:''):''}">
</li>
编写接口 LoginController
//因为是表单提交数据,键值对,所以入参不需要 @RequestBody
@PostMapping("/login")
public String login(UserLoginVo vo, RedirectAttributes redirectAttributes){
R r = memberFeignService.login(vo);
if (r.getCode() == 0){
return "redirect:http://localhost:10000/";
}else {
Map<String, String> errors = new HashMap<>();
errors.put("msg", r.getMsg(new TypeReference<String>() {}));
redirectAttributes.addFlashAttribute("errors", errors);
return "redirect:http://localhost:20000/login.html";
}
}
编写远程调用业务类 MemberFeignService
@FeignClient("gulimall-member")
public interface MemberFeignService {
@PostMapping("/member/member/regist")
R regist(@RequestBody UserRegistVo vo);
@PostMapping("/member/member/login")
R login(@RequestBody UserLoginVo vo);
}
2)、在gulimall-member中编写登录的接口
在 gulimall-commom 常量包下添加异常常量
LOGINACCT_PASSWORD_INVAILD_EXCEPTION(15003,"账号或密码错误")
账号密码登录实体类 MemberLoginVo
@Data
public class MemberLoginVo {
private String loginacct;
private String password;
}
编写注册业务接口 MemberController
@Autowired
private MemberService memberService;
/**
* 用户登录
*/
@PostMapping("/login")
public R login(@RequestBody MemberLoginVo vo){
MemberEntity loginEntity = memberService.login(vo);
if (loginEntity != null){
return R.ok();
}else {
return R.error(BizCodeEnume.LOGINACCT_PASSWORD_INVAILD_EXCEPTION.getCode(),BizCodeEnume.LOGINACCT_PASSWORD_INVAILD_EXCEPTION.getMsg());
}
}
编写业务方法逻辑实现类 MemberServiceImpl
@Override
public MemberEntity login(MemberLoginVo vo) {
String loginacct = vo.getLoginacct();
String password = vo.getPassword();
MemberDao memberDao = this.baseMapper;
//查询是否有此用户(注意可以是账号、手机号登录)
MemberEntity entity = memberDao.selectOne(new QueryWrapper<MemberEntity>()
.eq("username", loginacct).or()
.eq("mobile",loginacct));
if (entity == null){
return null;
}
//验证密码
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
boolean b = encoder.matches(password, entity.getPassword());
if (!b){
return null;
}
return entity;
}
4、社交登录
OAuth: OAuth(开放授权)是一个开放标准,允许用户授权第三方网站访问他们存储在另外的服务提供者上的信息,而不需要将用户名和密码提供给第三方网站或分享他们的数据的内容
OAuth2.0: 对于用户相关的 OpenAPI(例如获取用户信息,动态同步,照片,日志,分享等),为了保存用户数据的安全和隐私,第三方网站访问用户数据前都需要显示向用户授权
oauth2.0 流程如下:
以下是具体调用流程:
此处使用的是 gitee 的第三方授权登录,具体可参考 OAuth2说明文档
1)、第三方授权模拟测试
1 创建应用流程
1、在 修改资料 -> 第三方应用,创建要接入码云的应用
2、填写应用相关信息,勾选应用所需要的权限。
其中: 回调地址是用户授权后,码云回调到应用,并且 回传授权码 的地址
3、创建成功后,会生成 Cliend ID 和 Client Secret。他们将会在 OAuth2 认证基本流程用到
2 模拟请求
根据参考文档,浏览器发起 get 请求
https://gitee.com/oauth/authorize?client_id={client_id}&redirect_uri={redirect_uri}&response_type=code
之后会跳转到自定义的回调页面,并 回传授权码
http://localhost:20000/oauth2.0/gitee/success?code=3ad906065cf8bcsj47279bnhs0ba5f5ac7edcb1454cdb849986957d3
使用 postman 向码云认证服务器 发送post请求 传入 用户授权码 以及 回调地址
https://gitee.com/oauth/token?grant_type=authorization_code&code={code}&client_id={client_id}&redirect_uri={redirect_uri}&client_secret={client_secret}
得到返回值
通过 access_token 访问 Open API 使用用户数据
例如 获取授权用户的资料
2)、在 中编写三方登录主体逻辑
参考 OAuth2说明文档
认证接口:
1、通过 RestTemplate 发送请求获取 token,并将 token 等信息交给 member 服务进行社交登录
2、若获取 token 失败或远程调用服务失败,则封装错误信息重新转回登录页
修改页面
<li>
<a href="https://gitee.com/oauth/authorize?client_id=XXXXX&redirect_uri=http://XXXXX/oauth2.0/gitee/success&response_type=code">
<img src="login/JD_img/gitee.png" />
<span>Gitee</span>
</a>
</li>
实体类接收 token 等信息 SocialUser
@Data
public class SocialUser {
//用户授权码,用户授权的唯一票据
private String access_token;
private String token_type;
//access_token的生命周期
private long expires_in;
private String refresh_token;
private String scope;
private String created_at;
//授权用户的id
private String userId;
private String userName;
}
实体类接收社交登录之后返回的用户信息 MemberEntity
为了方便使用,将 gulimall-member 中的 MemberEntity 实体类,放在 gulimall-common 中
/**
* 社交登录
*/
private String socialUid;
private String accessToken;
private long expiresIn;
编写接口 OauthController
@Controller
public class OauthController {
@Autowired
MemberFeignService memberFeignService;
@Value("${spring.cloud.oauth.client_id}")
private String client_id;
@Value("${spring.cloud.oauth.client_secret}")
private String client_secret;
@GetMapping("/oauth2.0/gitee/success")
public String authorize(String code, RedirectAttributes attributes){
// 1、向码云认证服务器发送post请求返回 access_token
//SpringMVC 提供了一个工具类 RestTemplate 用于发起 http 请求,使用此类需要引入 web 依赖
RestTemplate template = new RestTemplate();
String url = "https://gitee.com/oauth/token?grant_type=authorization_code&code={code}&client_id={client_id}&redirect_uri={redirect_uri}&client_secret={client_secret}";
String redirect_uri = "http://localhost:20000/oauth2.0/gitee/success";
ResponseEntity<SocialUser> responseEntity = template.postForEntity(url, null, SocialUser.class, code, client_id, redirect_uri, client_secret);
Map<String, String> errors = new HashMap<>();
//2、成功获取到 access_token,查询用户唯一id、相关信息
if (responseEntity.getStatusCode().is2xxSuccessful()){
SocialUser socialUser = responseEntity.getBody();
url ="https://gitee.com/api/v5/user?access_token={access_token}";
String userInfoJson = template.getForObject(url, String.class,socialUser.getAccess_token());
JSONObject jsonObject = JSON.parseObject(userInfoJson);
//可以通过码云的 Open API 获取用户相关信息
socialUser.setUserId(jsonObject.getString("id"));
socialUser.setUserName(jsonObject.getString("name"));
//3、调用远程服务进行注册、登录
R r = memberFeignService.oauth2Login(socialUser);
if (r.getCode() == 0) {
//登录成功,返回首页并携带用户信息
OauthMemberVo memberVo = r.getData(new TypeReference<OauthMemberVo>() {});
attributes.addFlashAttribute("user", memberVo);
System.out.println(memberVo);
return "redirect:http://localhost:10000/";
}else {
//远程服务失败,返回登录页
errors.put("msg", r.getMsg(new TypeReference<String>() {}));
attributes.addFlashAttribute("errors", errors);
return "redirect:http://localhost:20000/login.html";
}
}else {
errors.put("msg", "获得第三方授权失败,请重试");
attributes.addFlashAttribute("errors", errors);
return "redirect:http://localhost:20000/login.html";
}
}
}
将 client_id、client_secret 等关键信息存放在 nacos 配置中心中
新建 bootstrap.properties
配置 nacos
此处有疑问,此模块原本只有 application.properties 一个文件,以下信息放在 application 中,启动项目会报错 @Value 等无值注入
但是新建 bootstrap.properties 文件并写入信息后,就不会报错了,很奇怪,难道 nacos 配置中心只认 bootstrap 吗???
有可能是因为其他模块用了 bootstrap 文件,虽然此模块只用 application
# 指明nacos配置中心地址、命名空间
spring.cloud.nacos.config.server-addr=127.0.0.1:8848
spring.cloud.nacos.config.namespace=XXXX
# 使用extension-configs[n]来引入多个配置文件
# 注意:若是与服务名(spring.application.name)一致的配置文件,不可使用extension-configs[n]引入,直接配置在spring.cloud.nacos.config.group=DEMO
spring.cloud.nacos.config.extension-configs[0].data-id=oauth.yml
spring.cloud.nacos.config.extension-configs[0].group=DEFAULT_GROUP
spring.cloud.nacos.config.extension-configs[0].refresh=true
编写远程调用业务类 MemberFeignService
@FeignClient("gulimall-member")
public interface MemberFeignService {
@PostMapping("/member/member/regist")
R regist(@RequestBody UserRegistVo vo);
@PostMapping("/member/member/login")
R login(@RequestBody UserLoginVo vo);
@PostMapping("/member/member/oauth2/login")
R oauth2Login(@RequestBody SocialUser vo);
}
3)、在gulimall-member中编写三方登录的接口
登录接口:
1、根据用户唯一 id 来判断是否注册过
2、如果之前未使用该社交账号登录,则使用 token 调用开放 api 获取社交账号相关信息,注册并将结果返回
3、如果之前已经使用该社交账号登录,则更新 token 并将结果返回
MemberSocialUserVo
@Data
public class MemberSocialUserVo {
//用户授权码,用户授权的唯一票据
private String access_token;
private String token_type;
//access_token的生命周期
private long expires_in;
private String refresh_token;
private String scope;
private String created_at;
//授权用户的id
private String userId;
private String userName;
}
修改数据库表 ums_member
,方便存社交登录用户唯一 id 等信息
修改实体类 MemberEntity
,增加对应字段
/**
* 社交登录
*/
private String socialUid;
private String accessToken;
private long expiresIn;
编写接口 MemberController
/**
* 社交登录
*/
@PostMapping("/oauth2/login")
public R oauth2Login(@RequestBody MemberSocialUserVo vo){
MemberEntity loginEntity = memberService.oauth2Login(vo);
if (loginEntity != null){
return R.ok().setData(loginEntity);
}else {
return R.error(BizCodeEnume.OAUTH_EXCEPTION.getCode(),BizCodeEnume.OAUTH_EXCEPTION.getMsg());
}
}
编写业务方法逻辑实现类 MemberServiceImpl
@Override
public MemberEntity oauth2Login(MemberSocialUserVo vo) {
MemberDao memberDao = this.baseMapper;
String userId = vo.getUserId();
MemberEntity one = memberDao.selectOne(new QueryWrapper<MemberEntity>().eq("social_uid", userId));
if (one == null){
//未登录过,进行注册
one = new MemberEntity();
one.setUsername(vo.getUserName());
one.setSocialUid(vo.getUserId());
one.setExpiresIn(vo.getExpires_in());
one.setAccessToken(vo.getAccess_token());
this.save(one);
}else {
//登陆过,直接登录,更新访问令牌等信息
//此处为什么选择新new一个实体,而不是直接修改one
MemberEntity memberEntity = new MemberEntity();
memberEntity.setId(one.getId());
memberEntity.setAccessToken(vo.getAccess_token());
memberEntity.setExpiresIn(vo.getExpires_in());
this.updateById(memberEntity);
one.setExpiresIn(vo.getExpires_in());
one.setAccessToken(vo.getAccess_token());
}
return one;
}
更新用户信息时为什么选择新new一个实体,而不是直接修改查出的实体类?
可点击此处跳转参考文档
在 updateById 方法中所传的实体参数,针对自动填充的字段:
1 如果字段值非空,则按照所传的值更新;
2 如果字段值为空,则按照自动填充的规则更新(如更新时间 @TableField(fill = FieldFill.INSERT_UPDATE) )//自动填充字段不生效自动填充 ENTITY entity= getById(id); entity.setDeleted(true); updateById(entity); //自动填充生效 ENTITY entity= new ENTITY(); entity.setId(id); entity.setDeleted(true); updateById(entity);
所以,所传的实体最好不要select出来,而是新new一个实体,赋值id值和变动值
@Version注解说明:更新时,实体对象的 version 属性必须有值,才会更新对应字段,所以new实体的方法,会使@version失效
4)、 SSO(单点登录)简单实现
简单实现,提供三个服务如下:
ssoserver.com 登陆验证服务器
client1.com 客户端1
client2.com 客户端2
先启动xxl-sso-server 然后启动client1
实现只要 client1 登录成功,client2 就不用进行登录直接登录成功
此处无法使用下一节提供的springsession方法,因为下一节方法使用的前提是 多个 子域 有共同的 父域 .gulimall.com ,而此处没有
客户端中的业务方法
/**
* 需要验证的连接
* @param model
* @param token 只要是ssoserver登陆成功回来就会带上
* @return
*/
@GetMapping("/employees")
public String employees(Model model, HttpSession session,
@RequestParam(value="token",required = false) String token) {
if (!StringUtils.isEmpty(token)) {
// 去ssoserver登录成功调回来就会带上
RestTemplate restTemplate = new RestTemplate();
// 使用restTemplate进行远程请求
ResponseEntity<String> forEntity = restTemplate.getForEntity("http://ssoserver.com:8080/userInfo?token=" + token, String.class);
// 拿到数据
String body = forEntity.getBody();
// 设置到session中
session.setAttribute("loginUser",body);
}
Object loginUser = session.getAttribute("loginUser");
if (loginUser == null ){
// 没有登录重定向到登陆页面,并带上当前地址
return "redirect:" + ssoServerUrl + "?redirect_url=http://client1.com:8081/employees";
} else {
List<String> emps = new ArrayList<>();
emps.add("张三");
emps.add("李四");
model.addAttribute("emps",emps);
return "list";
}
}
登陆验证服务器的业务方法
@GetMapping("login.html")
public String login(@RequestParam("redirect_url") String url, Model model,
@CookieValue(value = "sso_token",required = false)String sso_token) {
if (!StringUtils.isEmpty(sso_token)) {
//说明有人之前登录过,给浏览器留下了痕迹
return "redirect:" + url + "?token=" + sso_token;
}
// 添加url到model地址中,在前端页面进行取出
model.addAttribute("url",url);
return "login";
}
/**
* 登录
* @param url client端带过来的地址
*/
@PostMapping("/doLogin")
public String doLogin(@RequestParam("username") String username,
@RequestParam("password") String password,
@RequestParam("url") String url,
HttpServletResponse response){
// 账号密码不为空
if (!StringUtils.isEmpty(username) && !StringUtils.isEmpty(password)) {
// 把登录成功的用户存起来
String uuid = UUID.randomUUID().toString().replace("-","");
redisTemplate.opsForValue().set(uuid,username);
// 将uuid存入cookie
Cookie token = new Cookie("sso_token",uuid);
response.addCookie(token);
// 保存到cookie
return "redirect:" + url + "?token=" + uuid;
}
// 登录失败,展示登录页
return "login";
}
访问实现流程
5、SpringSession
此小节,是实现在 gulimall-auth-server 服务的登录页面进行登录之后,再访问 gulimall-product 的商品首页、gulimall-search 的搜索界面等页面时实现自动登录,获取登录用户信息,无需再次登录
使用的方法是将用户 token 存放在父域的 session 中【.gulimall.com】,子域可以直接获取【oauth.gulimall.com、search.gulimall.com】
所以此节代码示例前提是配置了各个服务的域名,且 nginx 设置了网关
1)、分布式 Session不共享不同步问题(可跳过)
我们在auth.gulimall.com中保存session,但是网址跳转到 gulimall.com中,取不出auth.gulimall.com中保存的session,这就造成了微服务下的session不同步问题
1 Session同步解决方案-分布式下session共享问题
同一个服务复制多个,但是session还是只能在一个服务上保存,浏览器也是只能读取到一个服务的session
2 Session共享问题解决-session复制
3 Session共享问题解决-客户端存储
4 Session共享问题解决-hash一致性
5 Session共享问题解决-统一存储
2)、SpringSession整合redis
1 导入依赖
由于此处要实现多个服务自动登录,所以多个服务都要导入此依赖,所以可直接在 gulimall-common 导入依赖
即 gulimall-common 中有 session redis 依赖、MemberEntity 实体类
<!--整合SpringSession完成session共享问题-->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
2 修改配置
要使用的服务自己修改配置 gulimall-auth-server、gulimall-product、gulimall-search
spring.session.store-type=redis # Session store type
reids配置
spring.redis.host=localhost # Redis server host.
spring.redis.password= # Login password of the redis server.
spring.redis.port=6379 # Redis server port.
3 主配置类添加注解 @EnableRedisHttpSession
要使用的服务自己修改配置 gulimall-auth-server、gulimall-product、gulimall-search
@EnableRedisHttpSession //整合redis作为session存储
@EnableFeignClients
@EnableDiscoveryClient
@SpringBootApplication
public class GulimallAuthServerApplication {
4 自定义配置完成 Session 子域共享
要使用的服务自己修改配置 gulimall-auth-server、gulimall-product、gulimall-search
若在 gulimall-common 中写配置类,其他服务也使用不到,还是需要各个服务自己写配置类
-
由于默认使用 jdk 进行序列化,存入redis不方便查看数据,可通过导入 RedisSerializer 修改为 json 序列化 ,点击此处查看提供的实例
-
自定义配置完成 Session 子域共享,通过修改 CookieSerializer 扩大 session 的作用域【domain】至 **.gulimall.com,可 点击此处查看参考文档
-
实现 token 存放在父域的 session 中【.gulimall.com】,子域可以直接获取【oauth.gulimall.com、search.gulimall.com】
/**
* SpringSession整合子域
* 以及redis数据存储为json
*/
@Configuration
public class GulimallSessionConfig {
/**
* 设置cookie信息
* @return
*/
@Bean
public CookieSerializer CookieSerializer(){
DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
// 设置一个域名的名字
cookieSerializer.setDomainName("gulimall.com");
// cookie的路径
cookieSerializer.setCookieName("GULIMALLSESSION");
return cookieSerializer;
}
/**
* 设置json转换
* @return
*/
@Bean
public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
// 使用jackson提供的转换器
return new GenericJackson2JsonRedisSerializer();
}
}
3)、修改业务代码
1 用户实体类序列化
因为需要多个服务使用用户实体类,所以建议 MemberEntity
放在 gulimall-common中
因为传递已登录用户实体是将其进行序列化成二进制在进行传递,所以用户实体要进行序列化
public class MemberEntity implements Serializable {
2 返回登入用户信息,每个页面都显示用户名
修改 gulimall-auth-server 的登录逻辑(三方登录以及账号密码登录)
将登陆成功的用户存入 session,方便其他服务的页面获取到登录用户数据
public class AuthServerConstant {
public static final String SMS_CODE_PREFIX="sms:code:";
public static final String LOGIN_USER="loginUser";
}
R r = memberFeignService.oauth2Login(socialUser);
if (r.getCode() == 0) {
//登录成功,返回首页并携带用户信息
OauthMemberVo memberVo = r.getData(new TypeReference<OauthMemberVo>() {});
session.setAttribute(AuthServerConstant.LOGIN_USER,memberVo);
System.out.println(memberVo);
return "redirect:http://localhost:10000/";
}
确保 gulimall-member 远程登录方法会返回用户实体类
MemberEntity loginEntity = memberService.login(vo);
if (loginEntity != null){
return R.ok().setData(loginEntity);
}
修改页面:
以下使用的服务需要
1、导入 redis、session 依赖
2、使用 session 配置
3、使用注解 @EnableRedisHttpSession
4、自定义配置
gulimall-product 的首页、商品详情页
gulimall-search 的搜索页
<li style="border: 0;width: 150px">
<a href="http://localhost:20000/login.html" th:if="${session.loginUser == null}" class="aa">你好,请登录</a>
<a th:if="${session.loginUser != null}" class="aa">你好,[[${session.loginUser.username}]]</a>
</li>
<li th:if="${session.loginUser == null}">
<a href="http://localhost:20000/reg.html" style="color: red;" class="li_2">免费注册</a>
</li>
3 若已登录,再点击登录则跳至首页
只要登录成功,缓存有用户数据,再点击登录链接,直接调转到首页
把 gulimall-auth-server 的 GulimallWebConfig 登录页的映射注释掉
// registry.addViewController("/login.html").setViewName("login");
修改登录接口 LoginController
@GetMapping({"/login.html"})
public String loginPage(HttpSession session){
Object attribute = session.getAttribute(AuthServerConstant.LOGIN_USER);
if (attribute == null) {
//没登录
return "login";
} else{
return "redirect:http://localhost:10000/";
}
}
4)、SpringSession 原理 - 装饰者模式
原生的获取 session 是通过 HttpServletRequest 获取的
这里对 request 进行包装,并且重写了包装 request 的 getSession() 方法
核心代码是 SessionRepositoryFilter 类下面的 doFilterInternal()
方法
@EnableRedisHttpSession
导入 RedisHttpSessionConfiguration 配置
- 给容器中添加了一个组件
sessionRepository = 》》》【RedisOperationsSessionRepository】 redis 操作 session session的增删改查封装类 - SessionRepositoryFilter==>:session存储过滤器,每个请求过来必须经过Filter
1、创建的时候,就自动从容器中获取到了SessionRepostiory
2、原始的request,response都被包装了 SessionRepositoryRequestWrapper、SessionRepositoryResponseWrapper
3、以后获取session.request.getSession()
SessionRepositoryResponseWrapper
4、wrappedRequest.getSession() ==>SessionRepository
装饰者模式
spring-redis的相关功能:
执行session相关操作后,redis里面存储的时间也会刷新
购物车
1、环境搭建
设置本地域名
查看 nginx 的 server 部分 ,因为之前设置了带星号的,这里不用设置
更改网关服务的配置文件 的 路由
在/mydata/nginx/html/static/目录创建cart文件夹,将所有的静态资源全部都传到虚拟机/mydata/nginx/html/static/cart目录下
两个静态页面加入gulimall-cart服务里
更改静态页面中的 href 、src 路径
添加依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.8.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>afei.gulimall</groupId>
<artifactId>gulimall-cart</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>gulimall-cart</name>
<description>购物车</description>
<properties>
<java.version>1.8</java.version>
<spring-cloud.version>Greenwich.SR3</spring-cloud.version>
</properties>
<dependencies>
<dependency>
<groupId>afei.gulimall</groupId>
<artifactId>gulimall-common</artifactId>
<version>0.0.1-SNAPSHOT</version>
</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-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<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>
</project>
添加application.properties配置
server.port=40000
spring.application.name=gulimall-cart
spring.cloud.nacos.discovery.server-addr=127.0.0.1:8848
# thymeleaf页面缓存设置(默认为true),开发中方便调试应设置为false,表示关闭模板缓存,上线稳定后应保持默认
spring.thymeleaf.cache=false
主启动类
@EnableFeignClients
@EnableDiscoveryClient
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
public class GulimallCartApplication
2、页面调整
gulimall-product 中的首页、商品详情页
gulimall-search 中的搜索页
gulimall-cart 中的购物车列表页、添加成功页
检查以上页面,修改以下内容
加入购物车
<div class="box-btns-two">
<a href="http://localhost:40000/addToCart">
加入购物车
</a>
</div>
购物车展示
<span><a href="http://localhost:40000/cart.html">我的购物车</a></span>
<a class="btn-addtocart" href="http://localhost:40000/cart.html" id="GotoShoppingCart"><b></b>去购物车结算</a>
登录部分
<li style="border: 0;width: 150px">
<a href="http://localhost:20000/login.html" th:if="${session.loginUser == null}" class="aa">你好,请登录</a>
<a th:if="${session.loginUser != null}" class="aa">你好,[[${session.loginUser.username}]]</a>
</li>
<li th:if="${session.loginUser == null}">
<a href="http://localhost:20000/reg.html" style="color: red;" class="li_2">免费注册</a>
</li>
首页跳转部分
<a href="http://localhost:10000/">首页</a>
<a href="http://localhost:10000/">谷粒商城首页</a>
<a class="btn-tobback"
th:href="'http://localhost:10000/'+${skuInfo?.id}+'.html'">查看商品详情</a>
3、需求分析 & Vo编写 & ThreadLocal身份验证
1)、需求分析
① 需求分析:
- 实现 离线购物车 ,当未登录状态可以添加商品进购物车,并且关闭浏览器再打开,发现购物车商品仍存在
- 未登录添加一件商品进 离线购物车 ,登录之后这一件商品会自动进入 用户的购物车 合并,并且退出登录(或关闭浏览器)后 离线购物车 清零
② 考虑数据如何存储?
- 用户可以在登录状态下将商品添加到购物车 【用户购物车/在线购物车】
1. 放入数据库
2. mongodb
3. 放入 redis(采用)
登录以后,会将临时购物车的数据全部合并过来,并清空临时购物车
- 用户可以在未登录状态下将商品添加到购物车 【游客购物车/离线购物车/临时购物车】
1. 放入 localstorage(客户端存储,后台不存)
可以考虑存入 localstorage,只要浏览器不卸载,数据都存在,只是不方便大数据分析用户熟悉的商品。放入后端存就方便分析数据
3. cookie
4. WebSQL
5. 放入 redis(采用)
浏览器即使关闭,下次进入,临时购物车数据都在
购物车是一个读写都高并发的操作,考虑将数据存入 redis,但是用户数据是持久化的存在,而 redis 是将数据存入内存,是内存数据库,一旦宕机,数据都没了。
虽然 mysql 可以很好的保存数据,可性能不够,考虑指定 redis 的持久化策略
③ 考虑 redis 中数据存储格式
本节仅实现以下功能
- 给购物车添加商品
- 用户可以查询自己的购物车
- 用户可以在购物车中修改购买商品的数量。
- 用户可以在购物车中删除商品。
- 选中不选中商品
2)、数据结构分析
因此每一个购物项信息,都是一个对象,基本字段包括:
{
skuId: 2131241,
check: true,
title: "Apple iphone.....",
defaultImage: "...",
price: 4999,
count: 1,
totalPrice: 4999,
skuSaleVO: {...}
}
另外,购物车中不止一条数据,因此最终会是对象的数组。即:
[
{...},
{...},
{...}
]
Redis 有 5 种不同数据结构,这里选择哪一种比较合适呢?
- 首先不同用户应该有独立的购物车,因此购物车应该以用户的作为 key 来存储,Value 是用户的所有购物车信息。这样看来基本的
k-v
结构就可以了。 - 但是,我们对购物车中的商品进行增、删、改操作,基本都需要根据商品 id 进行判断,为了方便后期处理,我们的购物车也应该是
k-v
结构,key 是商品 id,value 才是这个商品的购物车信息
一个购物车是由各个购物项组成的,用 List 进行存储并不合适(代码即Map<String, List<String>>
),因为使用 List 查找某个购物项时需要挨个遍历每个购物项,会造成大量时间损耗。为保证查找速度,可以使用 hash 进行存储
综上所述,我们的购物车结构是一个双层 Map:Map<String,Map<String,String>>
- 第一层 Map,Key 是用户 id,Value 是用户的所有购物车信息
- 第二层 Map,Key 是购物车中商品 id,Value 是购物项数据
3)、VO编写
1 购物项数据
CartItem
@Data
public class CartItem {
//商品id
private Long skuId;
//购物车中是否选中
private Boolean check = true;
//商品的标题
private String title;
//商品的图片
private String image;
//商品套餐属性
private List<String> skuAttr;
//商品的价格
private BigDecimal price;
//商品的数量
private Integer count;
//当前购物项总价,使用自定义 get
private BigDecimal totalPrice;
//计算当前购物项总价
public BigDecimal getTotalPrice() {
//new BigDecimal("" + this.count)
return price.multiply(new BigDecimal(count));
}
}
2 购物车信息
Cart
/**
* 整个购物车
* 需要计算的属性,必须重写他的get方法,保证每次获取属性都会进行计算
*/
@Data
public class Cart {
//购物车商品项信息
List<CartItem> items;
//购物车商品总数量
private Integer countNum;
//商品类型数量
private Integer countType;
//商品总价
private BigDecimal totalAmount;
//减免价格
private BigDecimal reduce = new BigDecimal("0.00");
public Integer getCountNum() {
/**
* return items.stream().map(CartItem::getCount).reduce(Integer::sum).orElse(0)
* 若 items 没有初始化,只是空属性,会报空指针异常
* 若已初始化 new ArrayList<>() 为空时会返回 0
* if (items != null && items.size() >0)
* 若 items 没有初始化,只是空属性,不会执行 if 语句
*/
int countNum = 0;
if (items != null && items.size() >0){
countNum = items.stream().map(CartItem::getCount).reduce(Integer::sum).orElse(0);
}
return countNum;
}
public Integer getCountType() {
int countType = 0;
if (items != null && items.size() >0){
countType = items.size();
}
return countType;
}
public BigDecimal getTotalAmount() {
BigDecimal amount = new BigDecimal("0");
//1、计算购物项总价
if (items != null && items.size() >0){
amount = items.stream().map(item->{
if (item.getCheck())
return item.getTotalPrice();
return new BigDecimal(0);
}).reduce(BigDecimal::add).orElse(new BigDecimal(0));
}
//2、减去优惠总价
BigDecimal subtract = amount.subtract(getReduce());
return amount;
}
}
注意,optional 的判空与 if 语句判空,后者更全面些,若属性未初始化,前者会抛空指针异常,后者可排除此情况
4)、ThreadLocal用户身份鉴别
ThreadLocal 同一个线程多个方法共享数据,使用 ThreadLocalMap(Thread, Object)
其原理可参考 史上最全ThreadLocal 详解(一)、史上最全ThreadLocal 详解(二)
Synchronized用于线程间的数据共享,而ThreadLocal则用于线程间的数据隔离
因为static是全局变量,threadlocal 这个对象是每个线程都共享,static修饰的 threadlocal 值确实是一样的,毕竟只会实例化一份副本
ThreadLocalMap 内部的Entry(threadlocal , Object)
虽然不同的线程之间 threadlocal 这个key值是一样,但是不同的线程所拥有的 ThreadLocalMap 是独一无二的
key被回收了,接下来还需要用怎么办?
正确的使用是ThreadLocal变量定义成private static,这样就一直存在ThreadLocal的强引用,也就能保证任何时候都能通过ThreadLocal的弱引用访问到Entry的value值。但是 private 属性其他方法使用不到,所以此处仍是 public
使用场景:
1、每个线程需要有自己单独的实例
2、实例需要在多个方法中共享,但不希望被多线程共享
接口执行:
过滤前 - 拦截前 - action执行 - 拦截后 - 过滤后
(1) 用户身份鉴别方式
参考京东,在点击购物车时,会为临时用户生成一个 name 为 user-key 的 cookie 临时标识,过期时间为一个月,如果手动清除 user-key ,那么临时购物车的购物项也被清除,所以 user-key 是用来标识和存储临时购物车数据的
(2) 使用 ThreadLocal 进行用户身份鉴别信息传递
在调用购物车的接口前,先通过 session 信息判断是否登录,并分别进行用户身份信息的封装,并把 user-key 放在cookie中。这个功能使用拦截器进行完成
1 实体类存放临时用户信息
UserInfoTo
@Data
public class UserInfoTo {
private Long userId;
//浏览器cookie中的 user-key
private String userKey;
//判断cookie中是否有临时用户
private boolean hasTempUser = false;
}
接口方法
/**
* 浏览器有一个cookie;user-key:标识用户身份,一个月后过期
* 对于第一次使用购物车功能,都会给一个临时的用户身份
* 是由浏览器保存,每次访问页面都会带上有这个cookies
*
* 登录的购物车,使用session的 user 作为 key
* 没登录的购物车,按照cookie里面带来的 user-key 作为 key
* 第一次访问页面,如果没有临时用户,就需要创建一个临时用户
*/
@GetMapping("/cart.html")
public String cartList(){
//使用了全局变量,只要是同一个线程内,任何方法都可获取用户
UserInfoTo userInfoTo = (UserInfoTo) CartInterceptor.threadLocal.get();
System.out.println(userInfoTo);
return "cartList";
}
2 拦截器
CartInterceptor
//在执行目标方法之前,判断用户的登录状态。并封装传递给目标请求
public class CartInterceptor implements HandlerInterceptor {
//ThreadLocal同一个线程共享数据
public static final ThreadLocal threadLocal = new ThreadLocal();
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
UserInfoTo userInfo = new UserInfoTo();
HttpSession session = request.getSession();
MemberEntity member = (MemberEntity) session.getAttribute(AuthServerConstant.LOGIN_USER);
if (member != null){
//用户已登录
userInfo.setUserId(member.getId());
}
//cookie 中是否包含 user-key,有就拿
Cookie[] cookies = request.getCookies();
if (cookies != null && cookies.length > 0) {
for (Cookie cookie : cookies) {
if (cookie.getName().equals(CartConstant.TEMP_USER_COOKIE_NAME)){
userInfo.setUserKey(cookie.getValue());
userInfo.setHasTempUser(true);
}
}
}
// 如果没有user-key,就分配一个临时用户
if (StringUtils.isEmpty(userInfo.getUserKey())) {
String uuid = UUID.randomUUID().toString();
userInfo.setUserKey(uuid);
}
//将 user-key 存入全局变量
threadLocal.set(userInfo);
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
UserInfoTo userInfo = (UserInfoTo) threadLocal.get();
//cookie中没有临时用户,给 cookie 保存一个临时用户
if (!userInfo.isHasTempUser()){
Cookie cookie = new Cookie(CartConstant.TEMP_USER_COOKIE_NAME, userInfo.getUserKey());
cookie.setDomain("localhost");
cookie.setMaxAge(CartConstant.TEMP_USER_COOKIE_TIMEOUT);
response.addCookie(cookie);
userInfo.setHasTempUser(true);
}
}
}
配置拦截器 GulimallWebConfig
@Configuration
public class GulimallWebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new CartInterceptor())
.addPathPatterns("/**");
}
}
4、商品添加购物车
1)、修改页面
在页面点击加入购物车后将商品添加进购物车
gulimall-product 的商品详情页 item.html
$("#addToCartA").click(function (){
var num = $("#numInput").val();
var skuId = $(this).attr("skuId");
location.href = "http://localhost:40000/addToCart?skuId="+skuId+"&num="+num;
return false;
})
gulimall-cart 的添加成功回显页 success.html
2)、添加购物车
需求分析:
首先需要在页面拿到要提交的参数:skuid、购买数量,并提交后台完成购物车数据添加
后端如何处理这个数据?
- 通过 skuid 远程查询这个商品的信息
- 远程查询sku组合的信息
- 如果购物车中已经有该商品信息如何进行提交?
- 如果有该商品项,那么只需更改商品数量,根据 skuid 从 reids 中取出数据,转换成对象然后加上对应的数量,再次转换为json存入redis
- 如果没有该商品项,则添加
- 前端页面频繁添加购物车如何解决?
- 如果直接访问 /addToCart 就转发至成功页,不会变更 url,则刷新会频繁添加商品
- 请求发送过来后我们重定向到其他的页面用来显示数据,这时候用户刷新的话也是在其他页面进行刷新
编写接口 CartController
/**
* 添加商品到购物车
* @return 跳转至添加成功页面
*/
@GetMapping("/addToCart")
public String addToCart(@RequestParam("skuId") Long skuId,
@RequestParam("num") Integer num,
Model model) throws ExecutionException, InterruptedException {
CartItem cartItem = cartService.addToCart(skuId,num);
model.addAttribute("item",cartItem);
model.addAttribute("num",num);
return "success";
}
实现类 CartServiceImpl
- 若当前商品已经存在购物车,只需增添数量
- 否则需要查询商品购物项所需信息,并添加新商品至购物车
@Service
public class CartServiceImpl implements CartService {
@Autowired
ProductFeignService productFeignService;
@Autowired
StringRedisTemplate stringRedisTemplate;
@Autowired
ThreadPoolExecutor threadPoolExecutor;
@Override
public CartItem addToCart(Long skuId, Integer num) throws ExecutionException, InterruptedException {
//1.1 获取要操作的 redis 购物车,即直接是 Map<指定用户,Map<String,String>>
BoundHashOperations<String, Object, Object> cartOps = getCartOps();
//1.2 如果购物车已有此商品,则只需要增加数量。若无才增加商品
String string = (String) cartOps.get(skuId.toString());
if (StringUtils.isNotEmpty(string)) { //org.apache.commons.lang3.StringUtils
CartItem redisItem = JSON.parseObject(string, CartItem.class);
redisItem.setCount(redisItem.getCount() + num);
cartOps.put(skuId.toString(),JSON.toJSONString(redisItem));
return redisItem;
}else {
CartItem cartItem = new CartItem();
//2.1 远程服务查询商品信息
CompletableFuture<Void> infoFuture = CompletableFuture.runAsync(() -> {
R infoR = productFeignService.info(skuId);
SkuInfoVo skuInfo = infoR.get("skuInfo", new TypeReference<SkuInfoVo>() {});
cartItem.setSkuId(skuId);
cartItem.setTitle(skuInfo.getSkuTitle());
cartItem.setImage(skuInfo.getSkuDefaultImg());
cartItem.setPrice(skuInfo.getPrice());
cartItem.setCount(num);
}, threadPoolExecutor);
//2.2 远程服务查询商品套餐属性,{机身颜色:黑曜石 ,内存大小:8GB+256GB }
CompletableFuture<Void> attrFuture = CompletableFuture.runAsync(() -> {
R r = productFeignService.getSkuSaleAttrValues(skuId);
List<String> saleAttList = r.get("SaleAttList", new TypeReference<List<String>>() {});
cartItem.setSkuAttr(saleAttList);
}, threadPoolExecutor);
CompletableFuture.allOf(infoFuture,attrFuture).get();
// 存入redis
cartOps.put(skuId.toString(),JSON.toJSONString(cartItem));
return cartItem;
}
}
/**
* 获取我们要操作的 redis 购物车:临时购物车、用户购物车
* @return 若已登录为用户购物车,未登录即为临时购物车
*/
private BoundHashOperations<String, Object, Object> getCartOps() {
UserInfoTo userInfoTo = (UserInfoTo) CartInterceptor.threadLocal.get();
//放入缓存的key
String cartKey = "";
if (userInfoTo.getUserId() != null){
//有UserId表示已登录(无论是否登录都有UserKey)
cartKey=CartConstant.CART_PREFIX + userInfoTo.getUserId();
}else {
cartKey=CartConstant.CART_PREFIX + userInfoTo.getUserKey();
}
//先获取指定用户的redis购物车,即现在 ops 操作的直接是 Map<指定用户,Map<String,String>>
BoundHashOperations<String, Object, Object> cartOps = stringRedisTemplate.boundHashOps(cartKey);
return cartOps;
}
}
此处会调用多个远程服务,耗时长
如果远程查询比较慢,比如方法当中有好几个远程查询,都要好几秒以上,等整个方法返回就需要很久,这块你是怎么处理的?
- 为了提交远程查询的效率,可以使用线程池的方式,异步进行请求
- 要做的操作就是将所有的线程全部放到自己手写的线程池里面
- 每一个服务都需要配置一个自己的线程池
- 完全使用线程池来控制所有的请求
引入线程配置 MyThreadConfig
@Configuration
public class MyThreadConfig {
@Bean
public ThreadPoolExecutor threadPoolExecutor(ThreadPoolProperties pool){
return new ThreadPoolExecutor(pool.getCorePoolSize(),
pool.getMaximumPoolSize(),
pool.getKeepAliveTime(),
TimeUnit.SECONDS,
new LinkedBlockingDeque<>(100000),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy());
}
}
ThreadPoolProperties
@ConfigurationProperties(prefix = "gulimall.thread")
@Component
@Data
public class ThreadPoolProperties {
private Integer corePoolSize;
private Integer maximumPoolSize;
private Long keepAliveTime;
}
application.properties
#线程池属性的配置
gulimall.thread.core-pool-size=20
gulimall.thread.maximum-pool-size=200
gulimall.thread.keep-alive-time=10
实现在 redis 中存储数据 Map<String,Map<String,String>>
远程服务查询商品信息 ProductFeignService
@FeignClient("gulimall-product")
public interface ProductFeignService {
@RequestMapping("/product/skuinfo/info/{skuId}")
R info(@PathVariable("skuId") Long skuId);
@RequestMapping("/product/skusaleattrvalue/stringlist/{skuId}")
R getSkuSaleAttrValues(@PathVariable("skuId") Long skuId);
}
实体类接收商品信息 SkuInfoVo
@Data
public class SkuInfoVo {
private Long skuId;
private Long spuId;
//sku名称
private String skuName;
//sku介绍描述
private String skuDesc;
private Long catalogId;
private Long brandId;
private String skuDefaultImg;
//标题
private String skuTitle;
//副标题
private String skuSubtitle;
//价格
private BigDecimal price;
//销量
private Long saleCount;
}
查询商品套餐属性,在 gulimall-product 服务中补充接口
SkuSaleAttrValueController
@RequestMapping("/stringlist/{skuId}")
public R getSkuSaleAttrValues(@PathVariable("skuId") Long skuId){
List<String> saleAttList = skuSaleAttrValueService.getSkuSaleAttrValues(skuId);
return R.ok().put("SaleAttList", saleAttList);
}
SkuSaleAttrValueServiceImpl
//查询指定商品的销售属性,返回 List<String> {机身颜色:黑曜石 ,内存大小:8GB+256GB }
@Override
public List<String> getSkuSaleAttrValues(Long skuId) {
SkuSaleAttrValueDao dao = this.baseMapper;
return dao.getSkuSaleAttrValues(skuId);
}
SkuSaleAttrValueDao
List<String> getSkuSaleAttrValues(@Param("skuId")Long skuId);
<select id="getSkuSaleAttrValues" resultType="java.lang.String">
SELECT CONCAT(attr_name,":",attr_value," ")
FROM pms_sku_sale_attr_value
where sku_id = #{skuId}
</select>
至此还有一个问题,访问 /addToCart 就转发至成功页,转发不会变更 url,则刷新页面会频繁添加商品
所以我们修改逻辑,参考 jd 页面
添加商品的路径是 /gate.action?pid=126223979&pcount=1&ptype=1
成功后跳转页面的路径是 addToCart.html?rcd=1&pid=126223979&pc=1&ed=1
修改接口 CartController
/**
* 添加商品到购物车
* @return 跳转至添加成功页面
*/
@GetMapping("/addToCart")
public String addToCart(@RequestParam("skuId") Long skuId,
@RequestParam("num") Integer num,
RedirectAttributes attributes) throws ExecutionException, InterruptedException {
cartService.addToCart(skuId,num);
/**
* RedirectAttributes使用:
* 1 addFlashAttribute(); 将数据放在session,可以在重定向的页面取出,但只能取一次
* 2 addAttribute("skuId",skuId); 将数据放在重定向的 url 后面,作为参数
*/
attributes.addAttribute("skuId",skuId);
attributes.addAttribute("num",num);
return "redirect:http://localhost:40000/addToCartSuccess.html";
}
@GetMapping("/addToCartSuccess.html")
public String addToCartSuccessPage(@RequestParam("skuId") Long skuId,@RequestParam("num") Integer num, Model model){
CartItem cartItem = cartService.getCartItem(skuId);
model.addAttribute("item",cartItem);
model.addAttribute("num",num);
return "success";
}
实现类 CartServiceImpl
@Override
public CartItem getCartItem(Long skuId) {
BoundHashOperations<String, Object, Object> cartOps = getCartOps();
String s = (String) cartOps.get(skuId.toString());
return JSON.parseObject(s,CartItem.class);
}
5、获取合并购物车
- 若用户未登录,则直接使用 user-key 获取购物车数据
- 否则使用 userId 获取购物车数据,并将 user-key 对应临时购物车数据与用户购物车数据合并,并删除临时购物车
编写接口 CartController
@GetMapping("/cart.html")
public String cartList(Model model) throws ExecutionException, InterruptedException {
Cart cart = cartService.getCart();
model.addAttribute("cart",cart);
return "cartList";
}
实现类 CartServiceImpl
/**
* 获取当前用户的购物车
*/
@Override
public Cart getCart() throws ExecutionException, InterruptedException {
Cart cart = new Cart();
UserInfoTo userInfoTo = (UserInfoTo) CartInterceptor.threadLocal.get();
//1 无论是否登录都要获取临时购物车
String tempCartKey = CartConstant.CART_PREFIX + userInfoTo.getUserKey();
List<CartItem> tempCartItems = getCartItems(tempCartKey);
//2 若已登录:获取已登录时的用户购物车,合并,删除临时
if (userInfoTo.getUserId() != null){
if (tempCartItems != null && tempCartItems.size()>0){
//2.1 合并:将临时购物车的商品添加进用户购物车
for (CartItem tempItem : tempCartItems) {
addToCart(tempItem.getSkuId(),tempItem.getCount());
}
String cartKey = CartConstant.CART_PREFIX + userInfoTo.getUserId();
List<CartItem> cartItems = getCartItems(cartKey);
//2.2 删除
stringRedisTemplate.delete(tempCartKey);
}
//2.3 获取【包含合并过来的临时购物车的数据,和登录后的购物车数据 】
String cartKey = CartConstant.CART_PREFIX + userInfoTo.getUserId();
List<CartItem> cartItems = getCartItems(cartKey);
//因为其他属性的 get 方法均已重写,只需设置 items
cart.setItems(cartItems);
return cart;
}else {
//2 若未登录:获取临时购物车
cart.setItems(tempCartItems);
return cart;
}
}
//根据指定 redis 中的用户key,返回对应的购物车所有购物项
//只需要拿到购物项,无需skuId
private List<CartItem> getCartItems(String cartKey) {
List<CartItem> cartItems = new ArrayList<>();
BoundHashOperations<String, Object, Object> cartOps = stringRedisTemplate.boundHashOps(cartKey);
List<Object> values = cartOps.values();
if (values != null && values.size()>0){
cartItems = values.stream().map((obj) -> {
String s = (String) obj;
return JSON.parseObject(s, CartItem.class);
}).collect(Collectors.toList());
}
return cartItems;
}
修改页面 cartList.html
<div class="One_ShopCon">
<h1 th:if="${cart.items == null}">
购物车还没有商品,<a href="http://localhost:10000/">去购物</a>
</h1>
<ul th:if="${cart.items != null}">
<li th:each="item:${cart.items}">
<div>
</div>
<div>
<ol>
<li><input type="checkbox" class="check" th:checked="${item.check}"></li>
<li>
<dt><img th:src="${item.image}" alt=""></dt>
<dd style="width: 300px">
<p>
<span th:text="${item.title}">TCL 55A950C 55英寸32核</span>
<br>
<span th:each="attr:${item.skuAttr}" th:text="${attr}">尺码: 55时 超薄曲面 人工智能</span>
</p>
</dd>
</li>
<li>
<p class="dj" th:type="'¥'+${#numbers.formatDecimal(item.price,1,2)}">¥4599.00</p>
</li>
<li>
<p>
<span>-</span>
<span th:text="${item.count}">5</span>
<span>+</span>
</p>
</li>
<li style="font-weight:bold"><p class="zj">¥[[${#numbers.formatDecimal(item.totalPrice,1,2)}]]</p></li>
<li>
<p>删除</p>
</li>
</ol>
</div>
</li>
</ul>
</div>
<div class="One_ShopFootBuy fix1">
<div>
<ul>
<li><input type="checkbox" class="allCheck"><span>全选</span></li>
<li>删除选中的商品</li>
<li>移到我的关注</li>
<li>清除下柜商品</li>
</ul>
</div>
<div>
<font style="color:#e64346;font-weight:bold;" class="sumNum"> </font>
<ul>
<li><img src="/img/buyNumleft.png" alt=""></li>
<li><img src="/img/buyNumright.png" alt=""></li>
</ul>
</div>
<div>
<ol>
<li>总价:<span style="color:#e64346;font-weight:bold;font-size:16px;" class="fnt">¥[[${#numbers.formatDecimal(cart.totalAmount,1,2)}]]</span></li>
<li>优惠:[[${#numbers.formatDecimal(cart.reduce,1,2)}]]</li>
</ol>
</div>
<div><button onclick="toTrade()" type="button">去结算</button></div>
</div>
6、选中购物车项、修改购物项数量、删除购物车项
修改页面
选中
<li><input type="checkbox" th:attr="skuId=${item.skuId}" class="itemCheck" th:checked="${item.check}"></li>
${".itemCheck"}.click(function () {
var skuId = $(this).attr("skuId");
var check = $(this).prop("checked");
location.href = "http://localhost:40000/checkItem?skuId="+skuId+"&checked="+(check?1:0);
})
修改
<p th:attr="skuId=${item.skuId}">
<span class="countOpsBtn">-</span>
<span class="countOpsNum" th:text="${item.count}">5</span>
<span class="countOpsBtn">+</span>
</p>
$('.countOpsBtn').click(function(){
var skuId = $(this).parent().attr("skuId");
var num = $(this).parent().find(".countOpsNum").text();
// alert("商品:" +skuId+"===数量:"+num);
location.href = "http://localhost:40000/changeItemCount?skuId="+skuId+"&num="+num;
})
删除
每行删除键
弹出的删除键
var deleteId = 0;
$(".deleteItemBtn").click(function(){
deleteId = $(this).attr("skuId");
})
function deleteItem(){
location.href = "http://localhost:40000/deleteItem?skuId="+deleteId;
}
编辑接口
/**
* 选中购物车商品项
* @param skuId 被点击商品项
* @param check 1 选中 0 未选中
*/
@GetMapping("/checkItem")
public String checkItem(@RequestParam("skuId") Long skuId,
@RequestParam("checked") Integer checked){
cartService.checkItem(skuId,checked);
return "redirect:http://localhost:40000/cart.html";
}
/**
* 点击修改商品数量【只有点击 +- 才会发起此请求修改redis数量】
* @param skuId 被点击商品项
* @param num 修改后的商品数量
* @return
*/
@GetMapping("/changeItemCount")
public String changeItemCount(@RequestParam("skuId") Long skuId,
@RequestParam("num") Integer num){
cartService.changeItemCount(skuId,num);
return "redirect:http://localhost:40000/cart.html";
}
@GetMapping("/deleteItem")
public String deleteItem(@RequestParam("skuId") Long skuId){
cartService.deleteItem(skuId);
return "redirect:http://localhost:40000/cart.html";
}
实现类
//改变商品项选中状态
void checkItem(Long skuId, Integer check);
//修改商品数量
void changeItemCount(Long skuId, Integer num);
//删除商品项
void deleteItem(Long skuId);
@Override
public void checkItem(Long skuId, Integer check) {
//获取购物车中指定的购物项【已登录为用户购物车,未登录即为临时购物车】
CartItem item = getCartItemBySkuId(skuId);
item.setCheck(check==1);
BoundHashOperations<String, Object, Object> cartOps = getCartOps();
//更新redis中的商品项
cartOps.put(skuId.toString(), JSON.toJSONString(item));
}
@Override
public void changeItemCount(Long skuId, Integer num) {
//获取购物车中指定的购物项【已登录为用户购物车,未登录即为临时购物车】
CartItem item = getCartItemBySkuId(skuId);
item.setCount(num);
BoundHashOperations<String, Object, Object> cartOps = getCartOps();
//更新redis中的商品项
cartOps.put(skuId.toString(), JSON.toJSONString(item));
}
@Override
public void deleteItem(Long skuId) {
BoundHashOperations<String, Object, Object> cartOps = getCartOps();
cartOps.delete(skuId.toString());
}