文章目录
一、商品业务
1.商品上架(ES+SpuInfo)
POST /product/spuinfo/{spuId}/up
接口功能解析:
1.修改spu状态为已上架
2.保存sku信息到es中,以skuId作为文档ID
sku默认图片
sku标题
sku价格
销量
spu允许被检索的基本规格(尺寸、CPU、分辨率)
1.1.商品json文档格式分析
------格式1:(商品索引保存sku+attr信息)【浪费空间节省时间】
{
skuId:1
spuId:11
skuTitle:华为xx
price:998
attrs:[
{尺寸:5寸},
{CPU:高通945},
{分辨率:全高清}
]
}
优势:方便检索
劣势:造成attrs冗余
假设属性2KB
100万条SKU * 2KB = 1000,000 * 2KB = 2000MB = 2G
------------------------------------------------------
------格式2:(sku与attr分开保存)【节省空间浪费时间】
sku索引 {
skuId:1
spuId:11
skuTitle:华为xx
price:998
}
attr索引 {
spuId:11
attrs:[
{尺寸:5寸},
{CPU:高通945},
{分辨率:全高清}
]
}
优势:数据不冗余
劣势:网络带宽大
例:查询小米,会搜到粮食、手机、电器,假设会查到10000个商品,包含4000个spu,因为要汇总所有商品的属性并列出,所以需要向后台传4000个spuId查询attr索引,spuId是Long类型,假设8byte,8byte*4000=32000byte=32Kb
如果此时1000,000人检索,数据传输达到32GB
------------------------------------------------------
结果:采用格式1
1.2.商品文档格式(nested、doc_values、analyzer)
解析:
"type": "text",// 全文检索字段
"type": "keyword",// 非全文检索字段
"index": false,// 不参与检索
"doc_values": false// 不参与聚合统计(term)
"analyzer": "ik_smart"// 使用ik分词器
文档格式:
PUT product
{
"mappings": {
"properties": {
"skuId": {
"type": "long"
},
"spuId": {
"type": "keyword"
},
"skuTitle": {
"type": "text",
"analyzer": "ik_smart"
},
"skuPrice": {
"type": "keyword"
},
"skuImg": {
"type": "keyword",
"index": false,
"doc_values": false
},
"saleCount": {
"type": "long"
},
"hasStock": {
"type": "boolean"
},
"hotScore": {
"type": "long"
},
"brandId": {
"type": "long"
},
"catalogId": {
"type": "long"
},
"brandName": {
"type": "keyword",
"index": false,
"doc_values": false
},
"brandImg": {
"type": "keyword",
"index": false,
"doc_values": false
},
"catalogName": {
"type": "keyword",
"index": false,
"doc_values": false
},
"attrs": {
"type": "nested",
"properties": {
"attrId": {
"type": "long"
},
"attrName": {
"type": "keyword",
"index": false,
"doc_values": false
},
"attrValue": {
"type": "keyword"
}
}
}
}
}
}
1.3.nested数据类型分析
简介:
未加nested数据类型的数组类型数据保存时会被es扁平化处理,导致查询的时候回出现错误,查看下例未加nested数据类型导致扁平化的案例:
案例:(存储一条id=1的数据)
PUT my_index/_doc/1
{
"group": "fans",
"user": [
{
"first": "John",
"last": "Smith"
},{
"first": "Alice",
"last": "White"
}
]
}
es会处理成:
{
"group": "fans",
"user.first": ["Alice", "John"],
"user.last": ["Smith", "White"]
}
查询my_index索引映射
GET my_index/_mapping
{
"my_index" : {
"mappings" : {
"properties" : {
"group" : {
"type" : "text",
"fields" : {
"keyword" : {
"type" : "keyword",
"ignore_above" : 256
}
}
},
"user" : {
"properties" : {
"first" : {
"type" : "text",
"fields" : {
"keyword" : {
"type" : "keyword",
"ignore_above" : 256
}
}
},
"last" : {
"type" : "text",
"fields" : {
"keyword" : {
"type" : "keyword",
"ignore_above" : 256
}
}
}
}
}
}
}
}
}
检索:(查询Alice+Smith也可以查到,实际应该是没有这条数据的)
GET my_index/_search
{
"query": {
"bool": {
"must": [
{"match": {"user.first": "Alice"}},
{"match": {"user.last": "Smith"}}
]
}
}
}
使用nested类型映射
上面案例的正确映射方式:(避免了es自动对数据扁平化处理)
PUT my_index
{
"mappings": {
"properties": {
"user": {
"type": "nested"
}
}
}
}
然后再存储数据,按照以上查询方式查询不会再查询到值
基本规格案例:
"attrs": {
"type": "nested",
"properties": {
"attrId": {
"type": "long"
},
"attrName": {
"type": "keyword",
"index": false,
"doc_values": false
},
"attrValue": {
"type": "keyword"
}
}
1.4.商品上架接口
POST /product/spuinfo/{spuId}/up
TO商品传输对象
产品模块上架接口要调用es模块,所以在common模块新增TO对象
@Data
public class SkuEsModel {
private Long skuId;
private Long spuId;
private String skuTitle;
private BigDecimal skuPrice;
private String skuImg;
private Long saleCount;
private Boolean hasStock;
private Long hotScore;
private Long brandId;
private Long catalogId;
private String brandName;
private String brandImg;
private String catalogName;
private List<Attrs> attrs;
// 存储为es数据时应该作为nested类型存储
@Data
public static class Attrs {
private Long attrId;
private String attrName;
private String attrValue;
}
}
Service上架代码
/**
* sup商品上架
* 往es上架商品不会重复上架,因为上架时指定了skuId
*/
@Override
public void up(Long spuId) {
// 1.查询spuId对应的所有sku信息
List<SkuInfoEntity> skus = skuInfoService.getSkusBySpuId(spuId);
// 2.封装待上传es数据集合(skuEsModel)
// 查询spu关联的基本属性集合
Map<Long, ProductAttrValueEntity> attrMap = productAttrValueService.baseAttrlistforspu(spuId)
.stream().collect(Collectors.toMap(key -> key.getAttrId(), val -> val));
// 查询允许被检索的基本属性集合ID
List<Long> searAttrIds = attrService.selectSearchAttrIds(new ArrayList<>(attrMap.keySet()));
// 查询允许被检索的基本属性属性集合,并封装成attrEsModels
List<SkuEsModel.Attrs> attrEsModels = searAttrIds.stream().map(attrId -> {
SkuEsModel.Attrs attrModel = new SkuEsModel.Attrs();
ProductAttrValueEntity attrValue = attrMap.get(attrId);
// 封装基本属性
attrModel.setAttrId(attrValue.getAttrId());
attrModel.setAttrName(attrValue.getAttrName());
attrModel.setAttrValue(attrValue.getAttrValue());
return attrModel;
}).collect(Collectors.toList());
Map<Long, Boolean> skuHasStockMap = null;
try {
// 查询库存
List<Long> skuIds = skus.stream().map(SkuInfoEntity::getSkuId).collect(Collectors.toList());
skuHasStockMap = wareAgentService.getSkusHasStock(skuIds).stream()
.collect(Collectors.toMap(SkuHasStockTO::getSkuId, val -> val.getHasStock()));
} catch (Exception e) {
log.error("库存查询异常:原因{}", e);
}
Map<Long, Boolean> finalSkuHasStockMap = skuHasStockMap;
List<SkuEsModel> upProducts = skus.stream().map(sku -> {
// 3.遍历sku封装SkuEsModel
SkuEsModel esModel = new SkuEsModel();
// 封装属性名相同的属性值
BeanUtils.copyProperties(sku, esModel);
// 封装属性名不相同的属性值 skuPrice, skuImg
esModel.setSkuPrice(sku.getPrice());
esModel.setSkuImg(sku.getSkuDefaultImg());
// 封装库存(远程调用失败,默认设置有库存)
esModel.setHasStock(finalSkuHasStockMap == null ? true : finalSkuHasStockMap.get(sku.getSkuId()));
// TODO 热度评分 hotScore(这里直接设置0,待扩展)
esModel.setHotScore(0L);
// 封装品牌和分类的名字
BrandEntity brand = brandService.getById(sku.getBrandId());
esModel.setBrandName(brand.getName());
esModel.setBrandImg(brand.getLogo());
CategoryEntity category = categoryService.getById(sku.getCatalogId());
esModel.setCatalogName(category.getName());
// 封装允许被检索的基本属性值(多条sku冗余)
esModel.setAttrs(attrEsModels);
return esModel;
}).collect(Collectors.toList());
// 4.调用es模块保存数据
boolean result = searchAgentService.productStatusUp(upProducts);
if (result) {
// 上架成功
// 5.修改sku商品状态,上架状态
baseMapper.updateSpuStatus(spuId, SpuConstant.PublishStatusEnum.SPU_UP.getCode());
} else {
// 上架失败
// TODO 7.重复调用,接口幂等性(重试机制)
}
}
2、动静分离(Nginx)
简介:
1.出于教学目的未采用前后端分离,使用视图
2.静态资源放在nginx中(图片、js、css[视图所需的资源])
3.动态资源(需要服务器处理的请求)独立部署、运行、升级
4.nginx接收用户请求转发至网关,网关作统一限流、鉴权,网关再转发给各模块
1.1.整合thymeleaf(模板引擎)
thymeleaf官方文档,查看语法
https://www.thymeleaf.org/documentation.html
可以下载pdf文档:using thymeleaf
1.添加依赖
<!--模板引擎thymeleaf-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
2.关闭缓存【调试时可以实时查看效果】
spring:
thymeleaf:
cache: false
suffix: .html # 默认配置
prefix: /templates/ # 默认配置
3.复制.html到templates文件夹内
复制静态资源到static文件夹内
4.修改controller包名为app,表示rest请求
新建web包,表示请求跳转
5.增加mvc配置类,处理资源无法加载的问题
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
/**
* 添加处理器
* 未加该处理器时使用此链接请求资源localhost:10000/index/js/swiper-3.4.2.jquery.min.js
* 添加处理器后使用此链接请求资源localhost:10000/static/index/js/swiper-3.4.2.jquery.min.js
*/
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/static/**").addResourceLocations("classpath:/static/");
}
}
6.localhost:10000访问首页
springboot作了自动配置,WebMvcAutoConfiguration
7.controller包新增页面跳转controller,增加页面跳转请求
@Controller
public class IndexController {
@GetMapping({"/", "index.html"})
public String indexPage(Model model) {
// 查出所有1级分类
List<CategoryEntity> categoryEntitys = categoryService.getLevel1Categorys();
//
model.addAttribute("categorys", categoryEntitys);
// 解析器自动拼装classpath:/templates/ + index + .html =》 classpath:/templates/index.html
// classpath表示类路径,编译前是resources文件夹,编译后resources文件夹内的文件会统一存放至classes文件夹内
return "index";
}
}
8.粘贴名称空间,才可以使用thymeleaf语法
<html lang="en" xmlns:th="http://www.thymeleaf.org">
10.实现不重启服务实时更新页面
1)引入dev-tools
<!--devtools实现不重启服务实时更新页面-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<optional>true</optional>
</dependency>
2)引入后按 ctrl+shift+F9 【Recompile build project重新编译项目】
注意:需要关掉thymeleaf服务器缓存
1.2.渲染三级分类
1.三级分类返回json格式
{
"一级分类ID": [
{
"catalog1Id": "一级分类ID",
"id": "二级分类ID",
"name": "二级分类名",
"catalog3List":[
{
"catalog2Id": "二级分类ID",
"id": "三级分类ID",
"name": "三级分类名"
}
]
}
]
}
2.返回数据格式List<String, List<Catalog2VO>>
/**
* 2级分类VO
*/
@NoArgsConstructor
@AllArgsConstructor
@Data
public class Catalog2VO {
private String catalog1Id; // 1级父分类ID
private List<Catalog3Vo> catalog3List;// 3级子分类集合
private String id; // 2级分类ID
private String name; // 2级分类name
/**
* 三级分类Vo
*/
@NoArgsConstructor
@AllArgsConstructor
@Data
public static class Catalog3Vo {
private String catalog2Id; // 2级父分类ID
private String id; // 3级分类ID
private String name; // 3级分类name
}
}
3.查询三级分类并封装成Map返回
@Override
public Map<String, List<Catalog2VO>> getCatalogJson() {
// 1.查询1级分类
List<CategoryEntity> level1Categorys = getLevel1Categorys();
// 2.封装数据
Map<String, List<Catalog2VO>> map = level1Categorys.stream().collect(Collectors.toMap(key -> key.getCatId().toString(), l1Category -> {
// 3.查询2级分类,并封装成List<Catalog2VO>
List<Catalog2VO> catalog2VOS = baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("parent_cid", l1Category.getCatId()))
.stream().map(l2Category -> {
// 4.查询3级分类,并封装成List<Catalog3VO>
List<Catalog2VO.Catalog3Vo> catalog3Vos = baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("parent_cid", l2Category.getCatId()))
.stream().map(l3Category -> {
// 封装3级分类VO
Catalog2VO.Catalog3Vo catalog3Vo = new Catalog2VO.Catalog3Vo(l2Category.getCatId().toString(), l3Category.getCatId().toString(), l3Category.getName());
return catalog3Vo;
}).collect(Collectors.toList());
// 封装2级分类VO返回
Catalog2VO catalog2VO = new Catalog2VO(l1Category.getCatId().toString(), catalog3Vos, l2Category.getCatId().toString(), l2Category.getName());
return catalog2VO;
}).collect(Collectors.toList());
return catalog2VOS;
}));
return map;
}
优化版三级分类
/**
* 查询三级分类并封装成Map返回
*/
@Override
public Map<String, List<Catalog2VO>> getCatalogJson() {
// 1.查询所有分类,按照parentCid分组
Map<Long, List<CategoryEntity>> categoryMap = baseMapper.selectList(null).stream()
.collect(Collectors.groupingBy(key -> key.getParentCid()));
// 2.获取1级分类
List<CategoryEntity> level1Categorys = categoryMap.get(0L);
// 3.封装数据
Map<String, List<Catalog2VO>> map = level1Categorys.stream().collect(Collectors.toMap(key -> key.getCatId().toString(), l1Category -> {
// 3.查询2级分类,并封装成List<Catalog2VO>
List<Catalog2VO> catalog2VOS = categoryMap.get(l1Category.getCatId())
.stream().map(l2Category -> {
// 4.查询3级分类,并封装成List<Catalog3VO>
List<Catalog2VO.Catalog3Vo> catalog3Vos = categoryMap.get(l2Category.getCatId())
.stream().map(l3Category -> {
// 封装3级分类VO
Catalog2VO.Catalog3Vo catalog3Vo = new Catalog2VO.Catalog3Vo(l2Category.getCatId().toString(), l3Category.getCatId().toString(), l3Category.getName());
return catalog3Vo;
}).collect(Collectors.toList());
// 封装2级分类VO返回
Catalog2VO catalog2VO = new Catalog2VO(l1Category.getCatId().toString(), catalog3Vos, l2Category.getCatId().toString(), l2Category.getName());
return catalog2VO;
}).collect(Collectors.toList());
return catalog2VOS;
}));
return map;
}
1.3.动静分离
1.未动静分离,指的是静态资源全都存储在Tomcat中,所有静态资源都要从Tomcat获取,
会访问 nginx->gateway->Tomcat获取静态资源,从而导致占用Tomcat很多线程来处理静态资源
2.动静分离,表示将资源与web服务器分离,可存放在nginx中,静态资源直接从nginx中返回
/static/**所有请求都由nginx直接返回
步骤:
1)cd /mydata/nginx/html
mkdir static
2)将product项目内static文件夹下的index文件夹,拖到/mydata/nginx/html/static此目录下
3)修改index.html中静态资源的请求路径
src="index/ =》 src="/static/index/
href=" =》 href="/static/
<script src=" => <script src="/static/
<img src=" => <img src="/static/
url('/ =>
动静分离前下例请求会访问nginx=》gateway=》product=》static...
http://gulimall.com/index/img/img_09.png
分离后使用下例请求直接访问nginx中的静态资源
http://gulimall.com/static/index/img/img_09.png
4)修改gulimall.conf配置(监听gulimall.com/static请求,使用root作为根路径查找静态资源):
location /static/ {
root /usr/share/nginx/html;
}
解析:/static/index/img/img_09.png会找到挂载目录/usr/share/nginx/html
/usr/share/nginx/html/static/index/img/img_09.png
server {
listen 80;
server_name gulimall.com;
location /static/ {
root /usr/share/nginx/html;
}
location / {
proxy_set_header Host $host;
proxy_pass http://gulimall;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
二、nginx搭建域名访问环境
1.正向代理与反向代理
1.正向代理:(重点:需要配置,互联网被请求资源无感知)
例如无法访问google,配置代理服务器,将请求转发给代理服务器,然后代理服务器访问互联网内容并返回给客户端
2.反向代理:(重点:无需配置,客户端无感知)
屏蔽内网服务器信息,负载均衡访问
所有请求访问反向代理服务器
1.1.配置本地dns解析
1.使用SwitchHosts设置本地ip 域名解析
https://sm.myapp.com/original/System/SwitchHosts_v0.2.2.1785.7z
# gulimall
192.168.56.10 gulimall.com
1.2.nginx作为反向代理
1.了解nginx配置文件,看下面的截图
2.一个nginx.conf会包含多个server快(conf.d内*.conf文件)
3.拷贝配置文件
cp default.conf gulimall.conf
注:1)vi时推出编辑模式,双击dd可以删除行
2):set number 显示vi文本的行号
详解server块:
root /usr/share/nginx/html;// 表示去该路径下查询静态资源
server {
listen 80;// 监听80端口
server_name gulimall.com;// 监听的请求host,如果是gulimall.com则接收请求
location / {
proxy_pass http://192.168.56.1:10000;// 将请求转发给商品服务
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
1.3.nginx负载均衡配置
1.查看文档(Using nginx as HTTP load balancer)
https://nginx.org/en/docs/
在http块配置上游服务器,查看上节截图,nginx.conf总共分为多块,http块在nginx.conf文档中
http {
upstream gulimall {// 上游服务器名
server 192.168.56.1:88;// 负载均衡时配置多个网关地址
server 192.168.56.1:89;
}
server {
listen 80;
server_name gulimall.com;
location / {
proxy_pass http://gulimall;// 代理给上游服务器
}
}
}
1.4.配置步骤
1.在http块中配置上游服务器
nginx.conf:
http {
upstream gulimall {
server 192.168.56.1:88;
}
}
2.配置server块,gulimall.conf
server {
listen 80;
server_name gulimall.com;
location / {
proxy_pass http://gulimall;
}
}
3.配置网关断言规则
文档:
https://docs.spring.io/spring-cloud-gateway/docs/2.2.4.RELEASE/reference/html/
=》Route Predicate Factories=》The Host Route Predicate Factory
- id: gulimall_product_route
uri: lb://gulimall-product
predicates:
- Host=gulimall.com,item.gulimall.com
注意:此段配置要放在所有微服务路由后面,因为gulimall.com会拦截访问api请求:
gulimall.com/api/product/attr/list
4.nginx反向代理时会丢失host信息,配置server块,转发时同时转发hosts
server {
listen 80;
server_name gulimall.com;
location / {
proxy_set_header Host $host;
proxy_pass gulimall;
}
}
5.访问以下链接测试是否成功
http://gulimall.com/ :成功访问首页
http://gulimall.com/api/product/attr/list:成功访问api
bug静态资源无法访问
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
/**
* 添加处理器
* 将所有/static访问静态资源的请求映射到/static类路径下
*
* 使用该处理器的场景:
* templates拷贝的是自己之前改动过的gulimall项目,动静分离时静态资源访问路径前都添加了/static/导致了无法在现有项目的
* 类路径下访问到静态资源,有3种解决方案
* 1.创建以下目录结构 resources/static/static/index
* 2.创建以下目录结构 resources/static/index,删除引用静态资源时的/static前缀
* 3.创建以下目录结构 resources/static/index,增加以下处理器重定义加载静态资源的类路径classpath:/static/
*/
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/static/**").addResourceLocations("classpath:/static/");
}
}
2.最终模板(重点)
# nginx.conf
user nginx;
worker_processes 1;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
#tcp_nopush on;
keepalive_timeout 65;
# 上游服务器
upstream gulimall {
server 192.168.56.1:88;
}
#gzip on;
# 引入server块
include /etc/nginx/conf.d/*.conf;
}
# gulimall.conf【server快】
server {
# 监听80端口,监听域名*.gulimall.com、gulimall.com
listen 80;
server_name gulimall.com *.gulimall.com;
# 静态资源请求访问/usr/share/nginx/html
location /static/ {
root /usr/share/nginx/html;
}
# 动态资源请求访问上游服务器
location / {
proxy_set_header Host $host;
proxy_pass http://gulimall;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
三、缓存
1.获取三级分类加入缓存
DB查询+缓存同步方法
/**
* 查询三级分类(从数据源DB查询)
* 加入分布式锁版本代码,double check检查
*/
public Map<String, List<Catalog2VO>> getCatalogJsonFromDB() {
// 1.double check,占锁成功需要再次检查缓存
// 查询非空即返回
String catlogJSON = redisTemplate.opsForValue().get("catlogJSON");
if (!StringUtils.isEmpty(catlogJSON)) {
// 查询成功直接返回不需要查询DB
Map<String, List<Catalog2VO>> result = JSON.parseObject(catlogJSON, new TypeReference<Map<String, List<Catalog2VO>>>() {
});
return result;
}
// 2.查询所有分类,按照parentCid分组
Map<Long, List<CategoryEntity>> categoryMap = baseMapper.selectList(null).stream()
.collect(Collectors.groupingBy(key -> key.getParentCid()));
// 3.获取1级分类
List<CategoryEntity> level1Categorys = categoryMap.get(0L);
// 4.封装数据
Map<String, List<Catalog2VO>> result = level1Categorys.stream().collect(Collectors.toMap(key -> key.getCatId().toString(), l1Category -> {
// 3.查询2级分类,并封装成List<Catalog2VO>
List<Catalog2VO> catalog2VOS = categoryMap.get(l1Category.getCatId())
.stream().map(l2Category -> {
// 4.查询3级分类,并封装成List<Catalog3VO>
List<Catalog2VO.Catalog3Vo> catalog3Vos = categoryMap.get(l2Category.getCatId())
.stream().map(l3Category -> {
// 封装3级分类VO
Catalog2VO.Catalog3Vo catalog3Vo = new Catalog2VO.Catalog3Vo(l2Category.getCatId().toString(), l3Category.getCatId().toString(), l3Category.getName());
return catalog3Vo;
}).collect(Collectors.toList());
// 封装2级分类VO返回
Catalog2VO catalog2VO = new Catalog2VO(l1Category.getCatId().toString(), catalog3Vos, l2Category.getCatId().toString(), l2Category.getName());
return catalog2VO;
}).collect(Collectors.toList());
return catalog2VOS;
}));
// 5.结果集存入redis
// 关注锁时序问题,存入redis代码块必须在同步快内执行
redisTemplate.opsForValue().set(CategoryConstant.CACHE_KEY_CATALOG_JSON,
JSONObject.toJSONString(result), 1, TimeUnit.DAYS);
return result;
}
1.1.无锁版本
/**
* 搭配缓存查询三级分类
*/
@Override
public Map<String, List<Catalog2VO>> getCatalogJson() {
// 查询缓存
String catalogJSON = redisTemplate.opsForValue().get(CategoryConstant.CACHE_KEY_CATALOG_JSON);
if (StringUtils.isEmpty(catalogJSON)) {
// 未命中缓存
// 查询db
Map<String, List<Catalog2VO>> result = getCatalogJsonFromDB();
redisTemplate.opsForValue().set(CategoryConstant.CACHE_KEY_CATALOG_JSON,
JSONObject.toJSONString(result), 1, TimeUnit.DAYS);
return result;
}
// 命中缓存
Map<String, List<Catalog2VO>> result = JSONObject.parseObject(catalogJSON, new TypeReference<Map<String, List<Catalog2VO>>>() {
});
return result;
}
1.2.本地锁版本
/**
* 查询三级分类(本地锁版本)
* 已废弃
*/
@Deprecated
public Map<String, List<Catalog2VO>> getCatalogJsonFromDBWithLocalLock() {
// 本地锁:synchronized,JUC(lock),在分布式0情况下,需要使用分布式锁
synchronized (this) {
// 得到锁以后还要检查一次,double check
return getCatalogJsonFromDB();
}
}
1.3.redisTemplate锁版本
/**
* 查询三级分类(原生版redis分布式锁版本)
*/
public Map<String, List<Catalog2VO>> getCatalogJsonFromDBWithRedisLock() {
// 1.抢占分布式锁,同时设置过期时间
String uuid = UUID.randomUUID().toString();
// 使用setnx占锁(setIfAbsent)
Boolean isLock = redisTemplate.opsForValue().setIfAbsent(CategoryConstant.LOCK_KEY_CATALOG_JSON, uuid, 300, TimeUnit.SECONDS);
if (isLock) {
// 2.抢占成功
Map<String, List<Catalog2VO>> result = null;
try {
// 查询DB
// TODO 业务续期(锁过期)【不应该添加业务续期代码】
return getCatalogJsonFromDB();
} finally {
// 3.查询UUID是否是自己,是自己的lock就删除
// 封装lua脚本(原子操作解锁)
// 查询+删除(当前值与目标值是否相等,相等执行删除,不等返回0)
String luaScript = "if redis.call('get',KEYS[1]) == ARGV[1]\n" +
"then\n" +
" return redis.call('del',KEYS[1])\n" +
"else\n" +
" return 0\n" +
"end";
// 删除锁
redisTemplate.execute(new DefaultRedisScript<Long>(luaScript, Long.class), Arrays.asList(CategoryConstant.LOCK_KEY_CATALOG_JSON), uuid);
}
} else {
// 4.加锁失败,自旋重试
// TODO 不应该使用递归,使用while,且固定次数
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
return getCatalogJsonFromDBWithRedisLock();
}
}
1.4.redisson锁版本(解决击穿+雪崩+穿透null值)
/**
* 查询三级分类(redisson分布式锁版本)
*/
public Map<String, List<Catalog2VO>> getCatalogJsonFromDBWithRedissonLock() {
// 1.抢占分布式锁,同时设置过期时间
RLock lock = redisson.getLock(CategoryConstant.LOCK_KEY_CATALOG_JSON);
lock.lock(30, TimeUnit.SECONDS);
try {
// 2.查询DB
Map<String, List<Catalog2VO>> result = getCatalogJsonFromDB();
return result;
} finally {
// 3.释放锁
lock.unlock();
}
}
2.数据一致性问题
1.修改三级分类 与 查询三级分类存在数据一致性问题(缓存与DB数据一致)
三种方案:
1.仅加过期时间即可(首先考虑业务造成脏数据的概率,例如用户维度数据(订单数据、用户数据)并发几率很小,每过一段时间触发读的主动更新)
2.canal订阅binlog的方式(菜单、商品介绍等基础数据)【完美解决】
3.加读写锁
4.实时性、一致性要求高的数据,应该直接查数据库
最终方案:
1.所有数据加上过期时间
2.读写数据加分布式读写锁(经常写的数据不要放在缓存里)
3.SpringCache
注:
1.springcache使用本地锁【只有@Cacheable可以同步】
2.springcache可以解决击穿、雪崩、穿透【采用了本地锁,不是完全解决击穿,可以自己使用分布式锁(没必要)】
3.springcache未解决数据一致性问题
3.1.失效模式+解决击穿、雪崩、穿透(分布式锁)
/**
* 级联更新所有关联表的冗余数据
* 缓存策略:失效模式,方法执行完删除缓存
*/
@CacheEvict(value = {"category"}, allEntries = true)
@Transactional
@Override
public void updateCascade(CategoryEntity category) {
this.updateById(category);
if (!StringUtils.isEmpty(category.getName())) {
// 更新冗余表
categoryBrandRelationService.updateCategory(category.getCatId(), category.getName());
// TODO 更新其他冗余表
}
}
/**
* 查出所有1级分类
*/
@Cacheable(value = {"category"}, key = "'getLevel1Categorys'")
@Override
public List<CategoryEntity> getLevel1Categorys() {
System.out.println("调用了getLevel1Categorys...");
// 查询父id=0
return baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("parent_cid", 0));
}
/**
* 查询三级分类并封装成Map返回
* 使用SpringCache注解方式简化缓存设置
*/
@Cacheable(value = {"category"}, key = "'getCatalogJson'")
@Override
public Map<String, List<Catalog2VO>> getCatalogJsonWithSpringCache() {
// 未命中缓存
// 1.抢占分布式锁,同时设置过期时间【不使用读写锁,因为就是为了防止缓存击穿】
RLock lock = redisson.getLock(CategoryConstant.LOCK_KEY_CATALOG_JSON);
lock.lock(30, TimeUnit.SECONDS);
try {
// 2.double check,占锁成功需要再次检查缓存
// 查询非空即返回
String catlogJSON = redisTemplate.opsForValue().get("getCatalogJson");
if (!StringUtils.isEmpty(catlogJSON)) {
// 查询成功直接返回不需要查询DB
Map<String, List<Catalog2VO>> result = JSON.parseObject(catlogJSON, new TypeReference<Map<String, List<Catalog2VO>>>() {
});
return result;
}
// 3.查询所有分类,按照parentCid分组
Map<Long, List<CategoryEntity>> categoryMap = baseMapper.selectList(null).stream()
.collect(Collectors.groupingBy(key -> key.getParentCid()));
// 4.获取1级分类
List<CategoryEntity> level1Categorys = categoryMap.get(0L);
// 5.封装数据
Map<String, List<Catalog2VO>> result = level1Categorys.stream().collect(Collectors.toMap(key -> key.getCatId().toString(), l1Category -> {
// 6.查询2级分类,并封装成List<Catalog2VO>
List<Catalog2VO> catalog2VOS = categoryMap.get(l1Category.getCatId())
.stream().map(l2Category -> {
// 7.查询3级分类,并封装成List<Catalog3VO>
List<Catalog2VO.Catalog3Vo> catalog3Vos = categoryMap.get(l2Category.getCatId())
.stream().map(l3Category -> {
// 封装3级分类VO
Catalog2VO.Catalog3Vo catalog3Vo = new Catalog2VO.Catalog3Vo(l2Category.getCatId().toString(), l3Category.getCatId().toString(), l3Category.getName());
return catalog3Vo;
}).collect(Collectors.toList());
// 封装2级分类VO返回
Catalog2VO catalog2VO = new Catalog2VO(l1Category.getCatId().toString(), catalog3Vos, l2Category.getCatId().toString(), l2Category.getName());
return catalog2VO;
}).collect(Collectors.toList());
return catalog2VOS;
}));
return result;
} finally {
// 8.释放锁
lock.unlock();
}
}
3.2.失效模式+解决击穿、雪崩、穿透(本地锁)
/**
* 级联更新所有关联表的冗余数据
* 缓存策略:失效模式,方法执行完删除缓存
*/
@CacheEvict(value = {"category"}, allEntries = true)
@Transactional
@Override
public void updateCascade(CategoryEntity category) {
this.updateById(category);
if (!StringUtils.isEmpty(category.getName())) {
// 更新冗余表
categoryBrandRelationService.updateCategory(category.getCatId(), category.getName());
// TODO 更新其他冗余表
}
}
/**
* 查出所有1级分类
*/
@Cacheable(value = {"category"}, key = "'getLevel1Categorys'", sync = true)
@Override
public List<CategoryEntity> getLevel1Categorys() {
System.out.println("调用了getLevel1Categorys...");
// 查询父id=0
return baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("parent_cid", 0));
}
/**
* 查询三级分类并封装成Map返回
* 使用SpringCache注解方式简化缓存设置
*/
@Cacheable(value = {"category"}, key = "'getCatalogJson'", sync = true)
@Override
public Map<String, List<Catalog2VO>> getCatalogJsonWithSpringCache() {
// 未命中缓存
// 1.double check,占锁成功需要再次检查缓存(springcache使用本地锁)
// 查询非空即返回
String catlogJSON = redisTemplate.opsForValue().get("getCatalogJson");
if (!StringUtils.isEmpty(catlogJSON)) {
// 查询成功直接返回不需要查询DB
Map<String, List<Catalog2VO>> result = JSON.parseObject(catlogJSON, new TypeReference<Map<String, List<Catalog2VO>>>() {
});
return result;
}
// 2.查询所有分类,按照parentCid分组
Map<Long, List<CategoryEntity>> categoryMap = baseMapper.selectList(null).stream()
.collect(Collectors.groupingBy(key -> key.getParentCid()));
// 3.获取1级分类
List<CategoryEntity> level1Categorys = categoryMap.get(0L);
// 4.封装数据
Map<String, List<Catalog2VO>> result = level1Categorys.stream().collect(Collectors.toMap(key -> key.getCatId().toString(), l1Category -> {
// 5.查询2级分类,并封装成List<Catalog2VO>
List<Catalog2VO> catalog2VOS = categoryMap.get(l1Category.getCatId())
.stream().map(l2Category -> {
// 7.查询3级分类,并封装成List<Catalog3VO>
List<Catalog2VO.Catalog3Vo> catalog3Vos = categoryMap.get(l2Category.getCatId())
.stream().map(l3Category -> {
// 封装3级分类VO
Catalog2VO.Catalog3Vo catalog3Vo = new Catalog2VO.Catalog3Vo(l2Category.getCatId().toString(), l3Category.getCatId().toString(), l3Category.getName());
return catalog3Vo;
}).collect(Collectors.toList());
// 封装2级分类VO返回
Catalog2VO catalog2VO = new Catalog2VO(l1Category.getCatId().toString(), catalog3Vos, l2Category.getCatId().toString(), l2Category.getName());
return catalog2VO;
}).collect(Collectors.toList());
return catalog2VOS;
}));
return result;
}
四、检索服务
1.整合静态资源+页面模板
1.检索模块导入依赖
<!--模板引擎thymeleaf-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!--devtools热启动,实现不重启服务实时更新页面-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<optional>true</optional>
</dependency>
2.拷贝list.html到检索模块resources.templates下
拷贝名称空间
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
3.关闭缓存,按ctrl+shift+F9实时更新页面
spring.thymeleaf.cache=false
4.创建以下文件路径
/mydata/nginx/html/static/search
实际映射到nginx容器的/static/search/路径
5.修改list.html静态资源引用路径为:
/static/search/
例:<link rel="stylesheet" href="/static/search/css/index.css">
6.将静态资源保存到/mydata/nginx/html/static/search路径下
7.添加本地域名解析
# gulimall
192.168.56.10 gulimall.com
192.168.56.10 search.gulimall.com
8.配置nginx拦截search.gulimall.com请求转发给88网关
配置gulimall.conf文件,使用*.gulimall.com拦截
server {
listen 80;
server_name gulimall.com *.gulimall.com;
location /static {
root /usr/share/nginx/html; # *.gulimall.com/static 请求nginx下静态资源
}
location / {
proxy_set_header Host $host; # 反向代理封装请求头host
proxy_pass http://gulimall; # 反向代理请求上游服务器(网关)
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
9.网关配置
- id: gulimall_product_route
uri: lb://gulimall-product
predicates:
- Host=gulimall.com,item.gulimall.com
- id: gulimall_search_route
uri: lb://gulimall-search
predicates:
- Host=search.gulimall.com
2.跳转检索页的三种情况
首先编写controller接收请求
@Controller
public class SearchController {
@GetMapping("/list.html")
public String listPage() {
return "list";
}
}
三种情况:
1.点击三级分类跳转检索页list.html
url:search.gulimall.com/list.html?catalog3Id=225
2.在首页输入框点击搜索跳转检索页list.html
前端监听事件onclick=search()
<a href="javascript:search()" ><img src="/static/index/img/img_09.png" onclick="search()" /></a>
3.点击筛选条件搜索
情况1:查询分类
情况2:查询关键字
情况3:筛选条件搜索
3.数据迁移(_reindex)
简介:
老的数据映射,设置了brandName不参与检索、不参与聚合,不符合接下来的业务需求,所以在不删除es数据的情况下需要做数据迁移
1.创建新索引
PUT gulimall_product
{
"mappings": {
"properties": {
"skuId": {
"type": "long"
},
"spuId": {
"type": "keyword"
},
"skuTitle": {
"type": "text",
"analyzer": "ik_smart"
},
"skuPrice": {
"type": "keyword"
},
"skuImg": {
"type": "keyword"
},
"saleCount": {
"type": "long"
},
"hasStock": {
"type": "boolean"
},
"hotScore": {
"type": "long"
},
"brandId": {
"type": "long"
},
"catalogId": {
"type": "long"
},
"brandName": {
"type": "keyword"
},
"brandImg": {
"type": "keyword"
},
"catalogName": {
"type": "keyword"
},
"attrs": {
"type": "nested",
"properties": {
"attrId": {
"type": "long"
},
"attrName": {
"type": "keyword"
},
"attrValue": {
"type": "keyword"
}
}
}
}
}
}
2.迁移数据
POST _reindex
{
"source": {
"index": "product"
},
"dest": {
"index": "gulimall_product"
}
}
4.编写检索接口
1.检索接口
@Autowired
MallSearchService mallSearchService;
@GetMapping("/list.html")
public String listPage(SearchParam param, Model model, HttpServletRequest request) {
param.set_queryString(request.getQueryString());
SearchResult result = mallSearchService.search(param);
model.addAttribute("result", result);
return "list";
}
2.vo参数类(SearchParam)
/**
* 封装页面所有可能传递过来的查询条件
* 三种点击搜索的方式
* 1、点击搜索:keyword 【skuTitle】
* 2、点击分类:传catalog3Id【catalogld】
* 3、选择筛选条件
* 1、全文检索: keyword【skuTitle】
* 2、排序: saleCount【销量】、hotScore【综合排序:热度评分】、skuPrice【价格】
* 3、过滤: hasStock、skuPrice区间、brandld、catalog3ld、attrs
* 4、聚合: attrs
* attrs=2_5寸 传参格式,所以直接for循环split("_")就可以得到attrId与attrValue
* attrs=1_白色:蓝色 然后值split(":")得到各项值attrValue
*/
@Data
public class SearchParam {
private String keyword;// 页面传递过来的全文匹配关键字
private Long catalog3Id;// 三级分类的id
/**
* 排序:sort=saleCount_asc sort=hotScore_asc sort=skuPrice_asc
*/
private String sort;
/**
* 过滤条件:
* hasStock=0/1【有货】
* skuPrice=0_500/500_/_500【价格区间】
* brandld=1
* attrs=1_白色:蓝色&attrs=2_2寸:5寸【属性可多选,值也可多选】
*/
private Integer hasStock;// 是否只显示有货,默认显示所有,null == 1会NullPoint异常 0/1
private String skuPrice;// 是否只显示有货
private List<Long> brandId;// 品牌id,可多选
private List<String> attrs;// 三级分类的id
private Integer pageNum = 1;// 页码
private String _queryString;// 原生的所有查询条件
}
4.1.构建检索DSL语句分析
1.全文检索查询都放在must里
skuTitle
2.不参与评分的放在filter里(比全文检索快)
catalogId、brandId
3.嵌入式字段的 查询、过滤、聚合 都要使用嵌入式的方式
GET gulimall_product/_search
{
"query": {
"bool": {
"must": [
{
"match": {
"skuTitle": "手机"
}
}
],
"filter": [
{
"term": {
"catalogId": "225"
}
},
{
"terms": {
"brandId": [
"3",
"5"
]
}
},
{
"nested": {
"path": "attrs",
"query": {
"bool": {
"must": [
{
"term": {
"attrs.attrId": {
"value": "6"
}
}
},
{
"terms": {
"attrs.attrValue": [
"海思(Hisilicon)",
"以官网信息为准"
]
}
}
]
}
}
}
},
{
"term": {
"hasStock": {
"value": "true"
}
}
},
{
"range": {
"skuPrice": {
"gte": 0,
"lte": 7000
}
}
}
]
}
},
"sort": [
{
"skuPrice": {
"order": "desc"
}
}
],
"from": 0,
"size": 2,
"highlight": {
"fields": {
"skuTitle": {}
},
"pre_tags": "<b style='color:red'>",
"post_tags": "</b>"
},
"aggs": {
"brand_agg": {
"terms": {
"field": "brandId",
"size": 10
},
"aggs": {
"brand_name_agg": {
"terms": {
"field": "brandName",
"size": 10
}
},
"brand_img_agg":{
"terms": {
"field": "brandImg",
"size": 10
}
}
}
},
"catalog_agg": {
"terms": {
"field": "catalogId",
"size": 10
},
"aggs": {
"catalog_name_agg": {
"terms": {
"field": "catalogName",
"size": 10
}
}
}
},
"attr_agg":{
"nested": {
"path": "attrs"
},
"aggs": {
"attr_id_agg": {
"terms": {
"field": "attrs.attrId",
"size": 10
},
"aggs": {
"attr_name_agg": {
"terms": {
"field": "attrs.attrName",
"size": 10
}
},
"attr_value_agg": {
"terms": {
"field": "attrs.attrValue",
"size": 10
}
}
}
}
}
}
}
}
4.2.构建检索对象SearchRequest
/**
* 动态构建检索请求
* 模糊匹配,过滤(按照属性,分类,品牌,价格区间,库存),排序,分页,高亮 ,聚合分析【分析所有可选的规格、分类、品牌】
*/
private SearchRequest buildSearchRequest(SearchParam param) {
// 构建SourceBuilder【通过builder构建DSL语句】
SearchSourceBuilder builder = new SearchSourceBuilder();
// 动态构建查询DSL语句【参照dsl.json分析包装步骤】
// 查询:模糊匹配,过滤(按照属性,分类,品牌,价格区间,库存)
// 1.构建bool
BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
// 1.1.构建must(模糊查询)
if (StringUtils.isNotBlank(param.getKeyword())) {
boolQueryBuilder.must(QueryBuilders.matchQuery("skuTitle", param.getKeyword()));
}
// 1.2.构建filter(过滤)
// 1.2.1.三级分类
if (param.getCatalog3Id() != null) {
boolQueryBuilder.filter(QueryBuilders.termQuery("catalogId", param.getCatalog3Id()));
}
// 1.2.2.品牌id
if (!CollectionUtils.isEmpty(param.getBrandId())) {
boolQueryBuilder.filter(QueryBuilders.termsQuery("brandId", param.getBrandId()));
}
// 1.2.3.属性
if (!CollectionUtils.isEmpty(param.getAttrs())) {
for (String attr : param.getAttrs()) {
// attrs=1_白色:蓝色
String[] attrs = attr.split("_");
String attrId = attrs[0];// 1
String[] attrValues = attrs[1].split(":");// ["白色","蓝色"]
BoolQueryBuilder nestedBoolQueryBuilder = QueryBuilders.boolQuery()
.must(QueryBuilders.termQuery("attrs.attrId", attrId))
.must(QueryBuilders.termsQuery("attrs.attrValue", attrValues));
// 遍历每一个属性生成一个NestedQuery
NestedQueryBuilder nestedQueryBuilder = QueryBuilders.nestedQuery("attrs", nestedBoolQueryBuilder, ScoreMode.None);// ScoreMode.None:不参与评分
boolQueryBuilder.filter(nestedQueryBuilder);
}
}
// 1.2.4.库存
boolQueryBuilder.filter(QueryBuilders.termQuery("hasStock", param.getHasStock() == 1));
// 1.2.5.价格区间【多种区间传参方式:1_500/_500/500_】
if (StringUtils.isNotBlank(param.getSkuPrice())) {
String[] prices = param.getSkuPrice().split("_");
if (prices.length == 2) {
// 1_500
boolQueryBuilder.filter(QueryBuilders.rangeQuery("skuPrice").gte(prices[0]).lte(prices[1]));
} else if (prices.length == 1) {
if (param.getSkuPrice().startsWith("_")) {
// _500
boolQueryBuilder.filter(QueryBuilders.rangeQuery("skuPrice").lte(prices[0]));
} else if (param.getSkuPrice().endsWith("_")) {
// 500_
boolQueryBuilder.filter(QueryBuilders.rangeQuery("skuPrice").gte(prices[0]));
}
}
}
// 1.3.封装bool【bool封装了模糊查询+过滤】
builder.query(boolQueryBuilder);
// 1.4.排序,分页,高亮
// 1.4.1.排序
if (StringUtils.isNotBlank(param.getSort())) {
String[] sorts = param.getSort().split("_");
builder.sort(sorts[0], sorts[1].toLowerCase().equals(SortOrder.ASC.toString()) ? SortOrder.ASC : SortOrder.DESC);
}
// 1.4.2.分页
builder.from(EsConstant.PRODUCT_PAGESIZE * (param.getPageNum() - 1));
builder.size(EsConstant.PRODUCT_PAGESIZE);
// 1.4.3.高亮
if (StringUtils.isNotBlank(param.getKeyword())) {
// 模糊匹配才需要高亮
HighlightBuilder highlightBuilder = new HighlightBuilder();
highlightBuilder.field("skuTitle").preTags("<b style='color:red'>").postTags("</b>");
builder.highlighter(highlightBuilder);
}
// 1.5.聚合分析【分析所有可选的规格、分类、品牌】
// 1.5.1.品牌聚合
TermsAggregationBuilder brandAgg = AggregationBuilders.terms("brand_agg").field("brandId").size(50);
// 品牌聚合子聚合
brandAgg.subAggregation(AggregationBuilders.terms("brand_name_agg").field("brandName").size(1));
brandAgg.subAggregation(AggregationBuilders.terms("brand_img_agg").field("brandImg").size(1));
builder.aggregation(brandAgg);
// 1.5.2.分类聚合
TermsAggregationBuilder catalogAgg = AggregationBuilders.terms("catalog_agg").field("catalogId").size(20);
catalogAgg.subAggregation(AggregationBuilders.terms("catalog_name_agg").field("catalogName").size(1));
builder.aggregation(catalogAgg);
// 1.5.3.属性嵌套聚合
NestedAggregationBuilder attrNestedAgg = AggregationBuilders.nested("attr_agg", "attrs");
// 属性子聚合
TermsAggregationBuilder attrIdAgg = AggregationBuilders.terms("attr_id_agg").field("attrs.attrId").size(50);
attrNestedAgg.subAggregation(attrIdAgg);
attrIdAgg.subAggregation(AggregationBuilders.terms("attr_name_agg").field("attrs.attrName").size(1));
attrIdAgg.subAggregation(AggregationBuilders.terms("attr_value_agg").field("attrs.attrValue").size(50));
builder.aggregation(attrNestedAgg);
System.out.println("构建的DSL语句: " + builder.toString());
// 根据构建了DSL语句的builder创建检索请求对象
SearchRequest searchRequest = new SearchRequest(new String[]{EsConstant.PRODUCT_INDEX}, builder);
return searchRequest;
}
4.3.封装返回结果SearchResponse
/**
* 封装检索结果
* 1、返回所有查询到的商品
* 2、分页信息
* 3、当前所有商品涉及到的所有属性信息
* 4、当前所有商品涉及到的所有品牌信息
* 5、当前所有商品涉及到的所有分类信息
*/
private SearchResult buildSearchResuult(SearchResponse response, SearchParam param) {
SearchResult result = new SearchResult();
// ==========从命中结果获取===========hits
SearchHits hits = response.getHits();// 获取命中结果
// 1.返回所有查询到的商品
List<SkuEsModel> products = new ArrayList<>();
if (!ArrayUtils.isEmpty(hits.getHits())) {
for (SearchHit hit : hits.getHits()) {
String jsonString = hit.getSourceAsString();// 获取jsonString
SkuEsModel esModel = JSONObject.parseObject(jsonString, SkuEsModel.class);
if (StringUtils.isNotBlank(param.getKeyword())) {
// 关键字不为空,返回结果包含高亮信息
// 高亮信息
String skuTitle = hit.getHighlightFields().get("skuTitle").fragments()[0].string();
esModel.setSkuTitle(skuTitle);
}
products.add(esModel);
}
}
result.setProducts(products);
// 2.分页信息
long total = hits.getTotalHits().value;
long totalPages = total % EsConstant.PRODUCT_PAGESIZE == 0 ? total / EsConstant.PRODUCT_PAGESIZE : total / EsConstant.PRODUCT_PAGESIZE + 1;
result.setPageNum(param.getPageNum());// 当前页码
result.setTotal(total);// 总记录数
result.setTotalPages((int) totalPages);// 总页码
// 导航页码
List<Integer> pageNavs = new ArrayList<>();
for (int i = 1; i <= totalPages; i++) {
pageNavs.add(i);
}
result.setPageNavs(pageNavs);
// ==========从聚合结果获取===========aggregations
Aggregations aggregations = response.getAggregations();// 获取聚合结果
// 3.当前所有商品涉及到的所有属性信息
ArrayList<SearchResult.AttrVo> attrs = new ArrayList<>();
ParsedNested attrAgg = aggregations.get("attr_agg");
ParsedLongTerms attrIdAgg = attrAgg.getAggregations().get("attr_id_agg");
for (Terms.Bucket bucket : attrIdAgg.getBuckets()) {
// 封装属性ID
SearchResult.AttrVo attr = new SearchResult.AttrVo();
attr.setAttrId(bucket.getKeyAsNumber().longValue());
// 封装属性名
ParsedStringTerms attrNameAgg = bucket.getAggregations().get("attr_name_agg");
attr.setAttrName(attrNameAgg.getBuckets().get(0).getKeyAsString());
// 封装属性值
ParsedStringTerms attrValueAgg = bucket.getAggregations().get("attr_value_agg");
List<String> attrValue = attrValueAgg.getBuckets().stream()
.map(valueBucket -> valueBucket.getKeyAsString())
.collect(Collectors.toList());
attr.setAttrValue(attrValue);
attrs.add(attr);
}
result.setAttrs(attrs);
// 4.当前所有商品涉及到的所有品牌信息
List<SearchResult.BrandVo> brands = new ArrayList<>();
ParsedLongTerms brandAgg = aggregations.get("brand_agg");
for (Terms.Bucket bucket : brandAgg.getBuckets()) {
// 封装品牌ID
SearchResult.BrandVo brand = new SearchResult.BrandVo();
brand.setBrandId(bucket.getKeyAsNumber().longValue());
// 封装品牌名
ParsedStringTerms brandNameAgg = bucket.getAggregations().get("brand_name_agg");
brand.setBrandName(brandNameAgg.getBuckets().get(0).getKeyAsString());
// 封装品牌图片
ParsedStringTerms brandImgAgg = bucket.getAggregations().get("brand_img_agg");
brand.setBrandImg(brandImgAgg.getBuckets().get(0).getKeyAsString());
brands.add(brand);
}
result.setBrands(brands);
// 5.当前所有商品涉及到的所有分类信息
List<SearchResult.CatalogVo> catalogs = new ArrayList<>();
ParsedLongTerms catalogAgg = aggregations.get("catalog_agg");
for (Terms.Bucket bucket : catalogAgg.getBuckets()) {
// 封装分类ID
SearchResult.CatalogVo catalog = new SearchResult.CatalogVo();
catalog.setCatalogId(bucket.getKeyAsNumber().longValue());
// 封装分类名
ParsedStringTerms catalogNameAgg = bucket.getAggregations().get("catalog_name_agg");// 子聚合
catalog.setCatalogName(catalogNameAgg.getBuckets().get(0).getKeyAsString());
catalogs.add(catalog);
}
result.setCatalogs(catalogs);
return result;
}
4.4.页面查询参数封装
keyword
sort
hasstock
skuPrice=1_
attrs
catalog3Id
brandId
4.5.面包屑导航
描述:
前端后端都可实现,当前采用后端实现
1.面包屑是多个属性值筛选,并且可以去掉筛选回到之前的筛选结果
2.返回对象SearchResult保存了去掉任一筛选属性后的link值(跳转地址)【每一个属性都有自己对应的回退地址】
属性
// 6.构建面包屑导航数据
if (!CollectionUtils.isEmpty(param.getAttrs())) {
// 属性非空才需要面包屑功能
List<SearchResult.NavVo> navs = param.getAttrs().stream().map(attr -> {
// attr:15_海思
SearchResult.NavVo nav = new SearchResult.NavVo();
String[] arr = attr.split("_");
nav.setNavName(attrMap.get(Long.parseLong(arr[0])));
nav.setNavValue(arr[1]);
// 设置跳转地址(将属性条件置空)【当取消面包屑上的条件时,跳转地址】
// 解决编码问题,前端参数使用UTF-8编码了
String encode = null;
encode = UriEncoder.encode(attr);
// try {
// encode = URLEncoder.encode(attr, "UTF-8");// java将空格转义成了+号
// encode = encode.replace("+", "%20");// 浏览器将空格转义成了%20,差异化处理,否则_queryString与encode匹配失败
// } catch (UnsupportedEncodingException e) {
// e.printStackTrace();
// }
// 替换掉当前查询属性,剩下的查询条件即是回退地址
String replace = param.get_queryString().replace("&attrs=" + encode, "");
nav.setLink("http://search.gulimall.com/list.html?" + replace);// 每一个属性都有自己对应的回退地址
return nav;
}).collect(Collectors.toList());
result.setNavs(navs);
}
品牌和分类
将品牌和分类也放进面包屑
// 7.构建面包屑导航数据_品牌
if (!CollectionUtils.isEmpty(param.getBrandId())) {
List<SearchResult.NavVo> navs = result.getNavs();
// 多个品牌ID封装成一级面包屑,所以这里只需要一个NavVo
SearchResult.NavVo nav = new SearchResult.NavVo();
// 面包屑名称直接使用品牌
nav.setNavName("品牌");
StringBuffer buffer = new StringBuffer();
String replace = "";
for (Long brandId : param.getBrandId()) {
// 多个brandId筛选条件汇总为一级面包屑,所以navValue拼接所有品牌名
buffer.append(brandMap.get(brandId)).append(";");
// 因为多个brandId汇总为一级面包屑,所以每一个brandId筛选条件都要删除
replace = replaceQueryString(param, "brandId", brandId.toString());
}
nav.setNavValue(buffer.toString());// 品牌拼接值
nav.setLink("http://search.gulimall.com/list.html?" + replace);// 回退品牌面包屑等于删除所有品牌条件
navs.add(nav);
}
4.6.最终版封装返回结果SearchResponse
/**
* 封装检索结果
* 1、返回所有查询到的商品
* 2、分页信息
* 3、当前所有商品涉及到的所有属性信息
* 4、当前所有商品涉及到的所有品牌信息
* 5、当前所有商品涉及到的所有分类信息
*/
private SearchResult buildSearchResuult(SearchResponse response, SearchParam param) {
SearchResult result = new SearchResult();
// ==========从命中结果获取===========hits
SearchHits hits = response.getHits();// 获取命中结果
// 1.返回所有查询到的商品
List<SkuEsModel> products = new ArrayList<>();
if (!ArrayUtils.isEmpty(hits.getHits())) {
for (SearchHit hit : hits.getHits()) {
String jsonString = hit.getSourceAsString();// 获取jsonString
SkuEsModel esModel = JSONObject.parseObject(jsonString, SkuEsModel.class);
if (StringUtils.isNotBlank(param.getKeyword())) {
// 关键字不为空,返回结果包含高亮信息
// 高亮信息
String skuTitle = hit.getHighlightFields().get("skuTitle").fragments()[0].string();
esModel.setSkuTitle(skuTitle);
}
products.add(esModel);
}
}
result.setProducts(products);
// 2.分页信息
long total = hits.getTotalHits().value;
long totalPages = total % EsConstant.PRODUCT_PAGESIZE == 0 ? total / EsConstant.PRODUCT_PAGESIZE : total / EsConstant.PRODUCT_PAGESIZE + 1;
result.setPageNum(param.getPageNum());// 当前页码
result.setTotal(total);// 总记录数
result.setTotalPages((int) totalPages);// 总页码
// 导航页码
List<Integer> pageNavs = new ArrayList<>();
for (int i = 1; i <= totalPages; i++) {
pageNavs.add(i);
}
result.setPageNavs(pageNavs);
// ==========从聚合结果获取===========aggregations
Aggregations aggregations = response.getAggregations();// 获取聚合结果
// 3.当前所有商品涉及到的所有属性信息
ArrayList<SearchResult.AttrVo> attrs = new ArrayList<>();
ParsedNested attrAgg = aggregations.get("attr_agg");
ParsedLongTerms attrIdAgg = attrAgg.getAggregations().get("attr_id_agg");
Map<Long, String> attrMap = new HashMap<>();// 面包屑map数据源【属性名】
for (Terms.Bucket bucket : attrIdAgg.getBuckets()) {
// 封装属性ID
SearchResult.AttrVo attr = new SearchResult.AttrVo();
attr.setAttrId(bucket.getKeyAsNumber().longValue());
// 封装属性名
ParsedStringTerms attrNameAgg = bucket.getAggregations().get("attr_name_agg");
attr.setAttrName(attrNameAgg.getBuckets().get(0).getKeyAsString());
// 封装属性值
ParsedStringTerms attrValueAgg = bucket.getAggregations().get("attr_value_agg");
List<String> attrValue = attrValueAgg.getBuckets().stream()
.map(valueBucket -> valueBucket.getKeyAsString())
.collect(Collectors.toList());
attr.setAttrValue(attrValue);
attrs.add(attr);
// 构建面包屑数据源
if (!CollectionUtils.isEmpty(param.getAttrs()) && !attrMap.containsKey(attr.getAttrId())) {
attrMap.put(attr.getAttrId(), attr.getAttrName());
}
}
result.setAttrs(attrs);
// 4.当前所有商品涉及到的所有品牌信息
List<SearchResult.BrandVo> brands = new ArrayList<>();
ParsedLongTerms brandAgg = aggregations.get("brand_agg");
Map<Long, String> brandMap = new HashMap<>();// 面包屑map数据源【品牌】
for (Terms.Bucket bucket : brandAgg.getBuckets()) {
// 封装品牌ID
SearchResult.BrandVo brand = new SearchResult.BrandVo();
brand.setBrandId(bucket.getKeyAsNumber().longValue());
// 封装品牌名
ParsedStringTerms brandNameAgg = bucket.getAggregations().get("brand_name_agg");
brand.setBrandName(brandNameAgg.getBuckets().get(0).getKeyAsString());
// 封装品牌图片
ParsedStringTerms brandImgAgg = bucket.getAggregations().get("brand_img_agg");
brand.setBrandImg(brandImgAgg.getBuckets().get(0).getKeyAsString());
brands.add(brand);
// 构建面包屑数据源
if (!CollectionUtils.isEmpty(param.getBrandId()) ) {
brandMap.put(brand.getBrandId(), brand.getBrandName());
}
}
result.setBrands(brands);
// 5.当前所有商品涉及到的所有分类信息
List<SearchResult.CatalogVo> catalogs = new ArrayList<>();
ParsedLongTerms catalogAgg = aggregations.get("catalog_agg");
String catalogName = null;// 面包屑map数据源【分类】
for (Terms.Bucket bucket : catalogAgg.getBuckets()) {
// 封装分类ID
SearchResult.CatalogVo catalog = new SearchResult.CatalogVo();
catalog.setCatalogId(bucket.getKeyAsNumber().longValue());
// 封装分类名
ParsedStringTerms catalogNameAgg = bucket.getAggregations().get("catalog_name_agg");// 子聚合
catalog.setCatalogName(catalogNameAgg.getBuckets().get(0).getKeyAsString());
catalogs.add(catalog);
// 构建面包屑数据源
if (catalog.getCatalogId().equals(param.getCatalog3Id())) {
catalogName = catalog.getCatalogName();
}
}
result.setCatalogs(catalogs);
// 6.构建面包屑导航数据_属性
if (!CollectionUtils.isEmpty(param.getAttrs())) {
// 属性非空才需要面包屑功能
List<SearchResult.NavVo> navs = param.getAttrs().stream().map(attr -> {
// attr:15_海思
SearchResult.NavVo nav = new SearchResult.NavVo();
String[] arr = attr.split("_");
// 封装筛选属性ID集合【给前端判断哪些属性是筛选条件,从而隐藏显示属性栏,显示在面包屑中】
result.getAttrIds().add(Long.parseLong(arr[0]));
// 面包屑名字:属性名
nav.setNavName(attrMap.get(Long.parseLong(arr[0])));
// 面包屑值:属性值
nav.setNavValue(arr[1]);
// 设置跳转地址(将属性条件置空)【当取消面包屑上的条件时,跳转地址】
String replace = replaceQueryString(param, "attrs", attr);
nav.setLink("http://search.gulimall.com/list.html?" + replace);// 每一个属性都有自己对应的回退地址
return nav;
}).collect(Collectors.toList());
result.setNavs(navs);
}
// 7.构建面包屑导航数据_品牌
if (!CollectionUtils.isEmpty(param.getBrandId())) {
List<SearchResult.NavVo> navs = result.getNavs();
// 多个品牌ID封装成一级面包屑,所以这里只需要一个NavVo
SearchResult.NavVo nav = new SearchResult.NavVo();
// 面包屑名称直接使用品牌
nav.setNavName("品牌");
StringBuffer buffer = new StringBuffer();
String replace = "";
for (Long brandId : param.getBrandId()) {
// 多个brandId筛选条件汇总为一级面包屑,所以navValue拼接所有品牌名
buffer.append(brandMap.get(brandId)).append(";");
// 因为多个brandId汇总为一级面包屑,所以每一个brandId筛选条件都要删除
replace = replaceQueryString(param, "brandId", brandId.toString());
}
nav.setNavValue(buffer.toString());// 品牌拼接值
nav.setLink("http://search.gulimall.com/list.html?" + replace);// 回退品牌面包屑等于删除所有品牌条件
navs.add(nav);
}
// 构建面包屑导航数据_分类
if (param.getCatalog3Id() != null) {
List<SearchResult.NavVo> navs = result.getNavs();
SearchResult.NavVo nav = new SearchResult.NavVo();
nav.setNavName("分类");
nav.setNavValue(catalogName);// 分类名
StringBuffer buffer = new StringBuffer();
// String replace = replaceQueryString(param, "catalog3Id", param.getCatalog3Id().toString());
// nav.setLink("http://search.gulimall.com/list.html?" + replace);
navs.add(nav);
}
return result;
}
private String replaceQueryString(SearchParam param, String key, String value) {
// 解决编码问题,前端参数使用UTF-8编码了
String encode = null;
encode = UriEncoder.encode(value);
// try {
// encode = URLEncoder.encode(attr, "UTF-8");// java将空格转义成了+号
// encode = encode.replace("+", "%20");// 浏览器将空格转义成了%20,差异化处理,否则_queryString与encode匹配失败
// } catch (UnsupportedEncodingException e) {
// e.printStackTrace();
// }
// TODO BUG,第一个参数不带&
// 替换掉当前查询条件,剩下的查询条件即是回退地址
String replace = param.get_queryString().replace("&" + key + "=" + encode, "");
return replace;
}
五、商品详情页
1.环境搭建
1.增加DNS解析:
192.168.56.10 item.gulimall.com
目前为止完整DNS模板:
# gulimall
192.168.56.10 gulimall.com
192.168.56.10 search.gulimall.com
192.168.56.10 item.gulimall.com
2.配置nginx域名拦截item.gulimall.com【*.gulimall.com;】
vi ../mydata/nginx/conf/conf.d/gulimall.conf,使得server_name可以匹配item.gulimall.com
server {
listen 80;
server_name gulimall.com *.gulimall.com;
location /static/ {
root /usr/share/nginx/html;
}
location / {
proxy_set_header Host $host;
proxy_pass http://gulimall;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
3.配置网关
- id: gulimall_product_route
uri: lb://gulimall-product
predicates:
- Host=gulimall.com,item.gulimall.com
4.拷贝item.html到product模块
5.拷贝静态资源到nginx以下目录中
/mydata/nginx/html/static/item
6.item.html中静态资源访问路径添加前缀【因为nginx配置location,访问/static的都转到/usr/share/nginx/html路径】
/static/item/
7.新增控制器,渲染商品详情
ItemController
@Controller
public class ItemController {
@Resource
private SkuInfoService skuInfoService;
/**
* 展示当前sku的详情
* @param skuId
* @return
*/
@GetMapping("/{skuId}.html")
public String skuItem(@PathVariable("skuId") Long skuId, Model model) throws ExecutionException, InterruptedException {
System.out.println("准备查询" + skuId + "详情");
/**
* 1、sku基本信息【标题、副标题、价格】pms_sku_info
* 2、sku图片信息【每个sku_id对应了多个图片】pms_sku_images
* 3、spu下所有sku销售属性组合【不只是当前sku_id所指定的商品】
* 4、spu商品介绍【】
* 5、spu规格与包装【参数信息】
*/
SkuItemVo vos = skuInfoService.item(skuId);
model.addAttribute("item",vos);
return "item";
}
}
2.查询Sku商品详情
2.1.商品详情数据结构
最外层
@Data
public class SkuItemVO {
/**
* 1、sku基本信息【标题、副标题、价格】pms_sku_info
* 2、sku图片信息【每个sku_id对应了多个图片】pms_sku_images
* 3、spu下所有sku销售属性组合【不只是当前sku_id所指定的商品】
* 4、spu商品介绍【】
* 5、spu规格与包装【参数信息】
*/
//1、sku基本信息(pms_sku_info)【默认图片、标题、副标题、价格】
private SkuInfoEntity info;
private boolean hasStock = true;// 是否有货
//2、sku图片信息(pms_sku_images)
private List<SkuImagesEntity> images;
//3、当前sku所属spu下的所有销售属性组合(pms_sku_sale_attr_value)
private List<SkuItemSaleAttrVO> saleAttr;
//4、spu商品介绍(pms_spu_info_desc)【描述图片】
private SpuInfoDescEntity desc;
//5、spu规格参数信息(pms_attr)【以组为单位】
private List<SpuItemAttrGroupVO> groupAttrs;
}
销售属性组合
@Data
@ToString
public class SkuItemSaleAttrVO {
/**
* 1.销售属性对应1个attrName
* 2.销售属性对应n个attrValue
* 3.n个sku包含当前销售属性(所以前端根据skuId交集区分销售属性的组合【笛卡尔积】)
*/
private Long attrId;
private String attrName;
private List<AttrValueWithSkuIdVO> attrValues;
}
@Data
public class AttrValueWithSkuIdVO {
private String attrValue;
private String skuIds;
}
规格参数
@Data
@ToString
public class SpuItemAttrGroupVO {
private String groupName;
private List<Attr> attrs;
}
@Data
public class Attr {
private Long attrId;
private String attrName;
private String attrValue;
private Integer searchType;
private Integer valueType;
private String icon;
private String valueSelect;
private Integer attrType;
private Long enable;
private Long catelogId;
private Integer showDesc;
}
2.2.service查询VO返回
/**
* 查询skuId商品信息,封装VO返回
*/
@Override
public SkuItemVO item(Long skuId) {
SkuItemVO result = new SkuItemVO();
// 1.获取sku基本信息(pms_sku_info)【默认图片、标题、副标题、价格】
SkuInfoEntity skuInfo = getById(skuId);
result.setInfo(skuInfo);
Long spuId = skuInfo.getSpuId();// 获取spuId
Long catalogId = skuInfo.getCatalogId();// 获取三级分类Id
// 2.获取sku图片信息(pms_sku_images)
List<SkuImagesEntity> images = skuImagesService.getImagesBySkuId(skuId);
result.setImages(images);
// 3.获取当前sku所属spu下的所有销售属性组合(pms_sku_info、pms_sku_sale_attr_value)
List<SkuItemSaleAttrVO> saleAttr = skuSaleAttrValueService.getSaleAttrBySpuId(spuId);
result.setSaleAttr(saleAttr);
// 4.获取spu商品介绍(pms_spu_info_desc)【描述图片】
SpuInfoDescEntity desc = spuInfoDescService.getById(spuId);
result.setDesc(desc);
// 5.获取spu规格参数信息(pms_product_attr_value、pms_attr_attrgroup_relation、pms_attr_group)
List<SpuItemAttrGroupVO> groupAttrs = attrGroupService.getAttrGroupWithAttrsBySpuId(spuId, catalogId);
result.setGroupAttrs(groupAttrs);
return result;
}
2.3.获取spu销售属性组合(笛卡尔积)
查询所有属性的skuId交集,组成sku笛卡尔积
3.获取当前sku所属spu下的所有销售属性组合(pms_sku_info、pms_sku_sale_attr_value)
// 1.通过spuId查询所有sku(pms_sku_info)
// 2.查询sku涉及到的所有销售属性(pms_sku_sale_attr_value)
/**
* 1.销售属性对应1个attrName
* 2.销售属性对应n个attrValue
* 3.n个sku包含当前销售属性(所以前端根据skuId交集区分销售属性的组合【笛卡尔积】)
* sku_ids用,切割成List<String> skuIds
*/
<resultMap id="skuItemSaleAttrVO" type="com.atguigu.common.vo.product.SkuItemSaleAttrVO">
<result column="attr_id" property="attrId"></result>
<result column="attr_name" property="attrName"></result>
<collection property="attrValues" ofType="com.atguigu.common.vo.product.AttrValueWithSkuIdVO">
<result column="attr_value" property="attrValue"></result>
<result column="sku_ids" property="skuIds"></result>
</collection>
</resultMap>
<select id="getSaleAttrBySpuId" resultMap="skuItemSaleAttrVO">
SELECT ssav.attr_id attr_id,
ssav.attr_name attr_name,
ssav.attr_value,
group_concat(DISTINCT info.sku_id ) sku_ids
FROM pms_sku_info info
LEFT JOIN pms_sku_sale_attr_value ssav ON ssav.sku_id = info.sku_id
WHERE info.spu_id = #{spuId}
GROUP BY ssav.attr_id,
ssav.attr_name,
ssav.attr_value
</select>
选中销售属性,跳转选中商品页
2.4.获取spu规格参数信息
5.获取spu规格参数信息(pms_product_attr_value、pms_attr_attrgroup_relation、pms_attr_group)
// 1.通过spuId查询所有属性值(pms_product_attr_value)
// 2.通过attrId关联所有属性分组(pms_attr_attrgroup_relation)
// 3.通过attrGroupId + catalogId关联属性分组名称(pms_attr_group)
<!--只要有嵌套属性就要封装自定义结果集-->
<resultMap id="spuAttrGroup" type="com.atguigu.common.vo.product.SpuItemAttrGroupVO">
<result property="groupName" column="attr_group_name"/>
<collection property="attrs" ofType="com.atguigu.common.vo.product.Attr">
<result property="attrId" column="attr_id"></result>
<result property="attrName" column="attr_name"></result>
<result property="attrValue" column="attr_value"></result>
</collection>
</resultMap>
<select id="getAttrGroupWithAttrsBySpuId" resultMap="spuAttrGroup">
SELECT pav.spu_id,
ag.attr_group_id,
ag.attr_group_name,
pav.attr_id,
pav.attr_name,
pav.attr_value
FROM pms_product_attr_value pav
LEFT JOIN pms_attr_attrgroup_relation aar ON pav.attr_id = aar.attr_id
LEFT JOIN pms_attr_group ag ON aar.attr_group_id = ag.attr_group_id
WHERE pav.spu_id = #{spuId}
AND ag.catelog_id = #{catalogId}
</select>
2.5.异步编排优化商品详情
/**
* 查询skuId商品信息,封装VO返回
*/
@Override
public SkuItemVO item(Long skuId) throws ExecutionException, InterruptedException {
SkuItemVO result = new SkuItemVO();
CompletableFuture<SkuInfoEntity> skuInfoFuture = CompletableFuture.supplyAsync(() -> {
// 1.获取sku基本信息(pms_sku_info)【默认图片、标题、副标题、价格】
SkuInfoEntity skuInfo = getById(skuId);
result.setInfo(skuInfo);
return skuInfo;
}, executor);
CompletableFuture<Void> imagesFuture = CompletableFuture.runAsync(() -> {
// 2.获取sku图片信息(pms_sku_images)
List<SkuImagesEntity> images = skuImagesService.getImagesBySkuId(skuId);
result.setImages(images);
}, executor);
CompletableFuture<Void> saleAttrFuture = skuInfoFuture.thenAcceptAsync((skuInfo) -> {
// 3.获取当前sku所属spu下的所有销售属性组合(pms_sku_info、pms_sku_sale_attr_value)
List<SkuItemSaleAttrVO> saleAttr = skuSaleAttrValueService.getSaleAttrBySpuId(skuInfo.getSpuId());
result.setSaleAttr(saleAttr);
}, executor);
CompletableFuture<Void> descFuture = skuInfoFuture.thenAcceptAsync((skuInfo) -> {
// 4.获取spu商品介绍(pms_spu_info_desc)【描述图片】
SpuInfoDescEntity desc = spuInfoDescService.getById(skuInfo.getSpuId());
result.setDesc(desc);
}, executor);
CompletableFuture<Void> groupAttrsFuture = skuInfoFuture.thenAcceptAsync((skuInfo) -> {
// 5.获取spu规格参数信息(pms_product_attr_value、pms_attr_attrgroup_relation、pms_attr_group)
List<SpuItemAttrGroupVO> groupAttrs = attrGroupService.getAttrGroupWithAttrsBySpuId(skuInfo.getSpuId(), skuInfo.getCatalogId());
result.setGroupAttrs(groupAttrs);
}, executor);
// 6.等待所有任务都完成
CompletableFuture.allOf(imagesFuture, saleAttrFuture, descFuture, groupAttrsFuture).get();
return result;
}
六、认证服务(认证中心)
1.社交登录 & 单点登录
OAuth: OAuth(开放授权)是一个开放标准,允许用户授权第三方网站访问他们存储在另外的服务提供者上的信息,而不需要将用户名和密码提供给第三方网站或分享他们数据的所有内容。
OAuth2.0:【一种协议标准,现在都使用OAuth2.0】实际上就是服务器开放了一些查询用户信息的OpenAPI(例如获取用户信息,动态同步,照片,日志,分享等),为了保护用户数据的安全和隐私,第三方网站访问用户数据前都需要显式的向用户征求授权,授权成功会拿到token,根据token调用OpenAPI获取用户信息
社交登录:
利用其它网站的OAuth2.0特性实现gulimall社交登录功能
单点登录:
跨域保存登录状态
2.环境搭建
新建模块:
添加依赖:
1.创建认证模块,使用spring initializr
name 谷粒商城-认证中心(社交登录、OAuth2.0、单点登录)
group com.atguigu.gulimall
artifact gulimall-auth-server
packagename com.atguigu.gulimall.auth
2.添加依赖
devtools、lombok、spring web、thymeleaf、openfeign
3.修改springboot版本
修改springcloud版本
引入common模块
主pom引入当前model
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.2.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<properties>
<java.version>1.8</java.version>
<spring-cloud.version>Hoxton.SR6</spring-cloud.version>
</properties>
<!--公共模块-->
<dependency>
<groupId>com.atguigu.gulimall</groupId>
<artifactId>gulimall-common</artifactId>
<version>0.0.1-SNAPSHOT</version>
<exclusions>
<exclusion>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
</exclusion>
</exclusions>
</dependency>
<modules>
<module>gulimall-coupon</module>
<module>gulimall-member</module>
<module>gulimall-order</module>
<module>gulimall-product</module>
<module>gulimall-ware</module>
<module>renren-fast</module>
<module>renren-generator</module>
<module>gulimall-common</module>
<module>gulimall-gateway</module>
<module>gulimall-third-party</module>
<module>gulimall-search</module>
<module>gulimall-auth-server</module>
</modules>
4.将当前模块注册到nacos
配置:
server:
port: 20000
spring:
application:
name: gulimall-auth-server
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
5.启动类注解:
@EnableFeignClients
@EnableDiscoveryClient
@SpringBootApplication
public class GulimallAuthServerApplication {
public static void main(String[] args) {
SpringApplication.run(GulimallAuthServerApplication.class, args);
}
}
6.引入登录页面+注册页面,修改静态资源地址
login.html reg.html
<a href="http://gulimall.com/"><img src="/static/login/JD_img/logo.jpg"/></a>
<script src="/static/reg/js/jQuery/jquery-3.1.1.js"></script>
7.本地添加dns解析
# gulimall
192.168.56.10 gulimall.com
192.168.56.10 search.gulimall.com
192.168.56.10 item.gulimall.com
192.168.56.10 auth.gulimall.com
8.拷贝静态资源到nginx中
/mydata/nginx/html/static,新建文件夹login、reg
9.配置网关转发
- id: gulimall_auth_route
uri: lb://gulimall-auth-server
predicates:
- Host=auth.gulimall.com
3.SpringMVC viecontroller(视图控制器)
功能:
发送一个请求,直接跳转一个页面,例:
/**
* 访问登录页面
*/
@GetMapping(value = "/login.html")
public String loginPage(HttpSession session) {
return "login";
}
但是这样会造成所有请求都需要一个getMapping方法映射页面,springmvc的解决方法:
1.添加配置类继承WebMvcConfigurer【必须使用GET请求访问】
@Configuration
public class GulimallWebConfig implements WebMvcConfigurer {
/**
* 视图映射:拦截请求跳转页面
*/
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/reg.html").setViewName("reg");
}
}
4.发送验证码相关接口
4.1.js代码60秒倒计时
$(function () {
$("#sendCode").click(function () {
//2、倒计时
if ($(this).hasClass("disabled")) {
//正在倒计时中
} else {
//1、给指定手机号发送验证码
$.get("/sms/sendCode?phone=" + $("#phoneNum").val(), function (data) {
if (data.code != 0) {
alert(data.msg);
}
});
timeoutChangeStyle();// 开始倒计时
}
});
});
var num = 60;
function timeoutChangeStyle() {
$("#sendCode").attr("class", "disabled");// 已经点击发送验证码后,当前按钮不可点击
if (num == 0) {
$("#sendCode").text("发送验证码");// 60秒后,文本秀嘎斯为发送验证码
num = 60;
$("#sendCode").attr("class", "");// 60秒后,可以点击
} else {
var str = num + "s 后再次发送";
$("#sendCode").text(str);
setTimeout("timeoutChangeStyle()", 1000);// 一秒后执行timeoutChangeStyle()
num--;
}
}
4.2.第三方服务_发送短信
1.搜索短信接口,购买一个5条的短信
https://market.aliyun.com/products/57126001/cmapi024822.html?spm=5176.2020520132.101.2.70a87218D92LLv#sku=yuncode1882200000
2.购买成功后进入云市场,记录AppKey、AppSecret、AppCode
https://market.console.aliyun.com/?spm=5176.12818093.top-nav.dbutton.5adc16d0RrmD95#/?_k=pdf92o
3.参照案例,请求头使用Authorization作为key + 值使用AppCode的值来验证身份
/**
* 短信配置类
* @Author: wanzenghui
* @Date: 2021/11/27 23:01
*/
@Configuration
public class SmsConfig {
@Bean
@ConfigurationProperties(prefix = "spring.cloud.alicloud.sms")
public SmsServiceImpl smsService() {
return new SmsServiceImpl();
}
}
/**
* 短信服务实现类
* @Author: wanzenghui
* @Date: 2021/11/27 22:58
*/
@Data
public class SmsServiceImpl implements SmsService {
private String host;
private String path;
private String appcode;
private String sign;
private String skin;
@Override
public Boolean sendCode(String phone, String code) {
String urlSend = host + path + "?code=" + code + "&phone=" + phone + "&sign=" + sign + "&skin=" + skin ; // 拼接请求链接
try {
URL url = new URL(urlSend);
HttpURLConnection httpURLCon = (HttpURLConnection) url.openConnection();
httpURLCon.setRequestProperty("Authorization", "APPCODE " + appcode);// 格式Authorization:APPCODE
// (中间是英文空格)
int httpCode = httpURLCon.getResponseCode();
if (httpCode == 200) {
String json = read(httpURLCon.getInputStream());
System.out.println("正常请求计费(其他均不计费)");
System.out.println("获取返回的json:");
System.out.print(json);
} else {
Map<String, List<String>> map = httpURLCon.getHeaderFields();
String error = map.get("X-Ca-Error-Message").get(0);
if (httpCode == 400 && error.equals("Invalid AppCode `not exists`")) {
System.out.println("AppCode错误 ");
} else if (httpCode == 400 && error.equals("Invalid Url")) {
System.out.println("请求的 Method、Path 或者环境错误");
} else if (httpCode == 400 && error.equals("Invalid Param Location")) {
System.out.println("参数错误");
} else if (httpCode == 403 && error.equals("Unauthorized")) {
System.out.println("服务未被授权(或URL和Path不正确)");
} else if (httpCode == 403 && error.equals("Quota Exhausted")) {
System.out.println("套餐包次数用完 ");
} else {
System.out.println("参数名错误 或 其他错误");
System.out.println(error);
}
}
} catch (MalformedURLException e) {
System.out.println("URL格式错误");
} catch (UnknownHostException e) {
System.out.println("URL地址错误");
} catch (Exception e) {
// 打开注释查看详细报错异常信息
// e.printStackTrace();
}
return true;
}
/*
* 读取返回结果
*/
private static String read(InputStream is) throws IOException {
StringBuffer sb = new StringBuffer();
BufferedReader br = new BufferedReader(new InputStreamReader(is));
String line = null;
while ((line = br.readLine()) != null) {
line = new String(line.getBytes(), "utf-8");
sb.append(line);
}
br.close();
return sb.toString();
}
}
/**
* 短信服务
* @Author: wanzenghui
* @Date: 2021/11/27 23:20
*/
@RestController
@RequestMapping("/sms")
public class SmsController {
@Autowired
SmsServiceImpl smsService;
/**
* 发送短信验证码
* 提供其他模块调用
* @param phone 号码
* @param code 验证码
*/
@GetMapping("/test")
public R sendCode(@RequestParam("phone") String phone, @RequestParam("code") String code) {
smsService.sendCode(phone, code);
return R.ok();
}
}
购买:
进入云市场:
4.3.认证模块_远程调用发送短信验证码
@Controller
public class LoginController {
@Autowired
ThirdPartFeignService thirdPartFeignService;
/**
* 发送短信验证码
* @param phone 号码
* @param code 验证码
*/
@ResponseBody
@GetMapping("/sms/sendCode")
public R sendCode(@RequestParam("phone") String phone) {
String code = UUID.randomUUID().toString().substring(0, 5);
thirdPartFeignService.sendCode(phone, code);
return R.ok();
}
}
4.4.认证模块_发送短信接口完善(60秒防刷)
1.引入redis
<!--redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
2.配置
spring:
redis:
host: 192.168.56.10
port: 6379
3.需要解决一些问题:
1)60秒间隔发送
2)接口防刷
/**
* 发送短信验证码
*
* @param phone 号码
* @param code 验证码
*/
@ResponseBody
@GetMapping("/sms/sendCode")
public R sendCode(@RequestParam(name = "phone", required = true) String phone) {
// 1.判断60秒间隔发送,防刷
String _code = redisTemplate.opsForValue().get(AuthConstant.SMS_CODE_CACHE_PREFIX + phone);
if (StringUtils.isNotBlank(_code) && System.currentTimeMillis() - Long.parseLong(_code.split("_")[1]) < 60000) {
// 调用接口小于60秒间隔不允许重新发送新的验证码
return R.error(BizCodeEnume.SMS_CODE_EXCEPTION);
}
// 2.验证码存入缓存
String code = UUID.randomUUID().toString().substring(0, 5);
// 验证码缓存到redis中(并且记录当前时间戳)
redisTemplate.opsForValue().set(AuthConstant.SMS_CODE_CACHE_PREFIX + phone, code + "_" + System.currentTimeMillis(), 10, TimeUnit.MINUTES);
// 3.发送验证码
thirdPartFeignService.sendCode(phone, code);
return R.ok();
}
5.注册接口
5.1.参数校验
/**
* 注册使用的vo,使用JSR303校验
*/
@Data
public class UserRegisterVO {
@NotEmpty(message = "用户名必须提交")
@Length(min = 6, max = 19, 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;
}
5.2.异常结果封装+重定向(防刷+分布式session)
// 1.参数校验
if (result.hasErrors()) {
// 校验出错,返回注册页
Map<String, String> errMap = new HashMap<>();
result.getFieldErrors().forEach(err -> errMap.put(err.getField(), err.getDefaultMessage()));
// 封装异常返回前端显示
attributes.addFlashAttribute("errors", errMap);// flash,session中的数据只使用一次
return "redirect:http://auth.gulimall.com/reg.html";// 采用重定向有一定防刷功能
// 1、return "redirect:http://auth.gulimall.com/reg.html"【采用】 重定向Get请求【配合RedirectAttributes共享数据】
// 2、return "redirect:http:/reg.html" 【采用】 重定向Get请求,省略当前服务url【配合RedirectAttributes共享数据】
// 3、return "redirect:/reg.html" 重定向Get请求,使用视图控制器拦截请求并映射reg视图【配合RedirectAttributes共享数据】【bug:会以ip+port来重定向】
// 4、return "forward:http://auth.gulimall.com/reg.html"; 请求转发与当前请求方式一致(Post请求)【配合Model共享数据】【异常404:当前/reg.html不存在post请求】
// 5、return "forward:http:/reg.html"; 请求转发与当前请求方式一致(Post请求),省略当前服务url 【配合Model共享数据】【异常404:当前/reg.html不存在post请求】
// 6、return "forward:/reg.html"; 请求转发与当前请求方式一致(Post请求),使用视图控制器拦截请求并映射reg视图【配合Model共享数据】【异常405:Request method 'POST' not supported,视图控制器必须使用GET请求访问,而当前请求转发使用post方式,导致异常】
// 7、return "reg"; 视图解析器前后拼串查找资源返回【配合Model共享数据】
}
5.3.验证码校验
// 2.验证码校验
String code = user.getCode();
String redisCode = redisTemplate.opsForValue().get(AuthConstant.SMS_CODE_CACHE_PREFIX + user.getPhone());
if (StringUtils.isBlank(redisCode)) {
// 验证码过期
Map<String, String> errMap = new HashMap<>();
errMap.put("code", "验证码失效");
// 封装异常返回前端显示
attributes.addFlashAttribute("errors", errMap);// flash,session中的数据只使用一次
return "redirect:http://auth.gulimall.com/reg.html";// 采用重定向有一定防刷功能
}
if (!code.equals(redisCode.split("_")[0])) {
// 验证码错误
Map<String, String> errMap = new HashMap<>();
errMap.put("code", "验证码错误");
// 封装异常返回前端显示
attributes.addFlashAttribute("errors", errMap);// flash,session中的数据只使用一次
return "redirect:http://auth.gulimall.com/reg.html";// 采用重定向有一定防刷功能
}
5.4.认证模块_注册接口
/**
* 注册接口
*
* @param vo 接收注册信息
* @param result 接收参数校验结果
* @param attributes 重定向保存数据(原理:使用session,重定向请求后根据cookie拿到session的数据)TODO 分布式session
*/
@PostMapping(value = "/register")
public String register(@Valid UserRegisterVO user, BindingResult result, RedirectAttributes attributes) {
// 1.参数校验
if (result.hasErrors()) {
// 校验出错,返回注册页
Map<String, String> errMap = new HashMap<>();
result.getFieldErrors().forEach(err -> errMap.put(err.getField(), err.getDefaultMessage()));
// 封装异常返回前端显示
attributes.addFlashAttribute("errors", errMap);// flash,session中的数据只使用一次
return "redirect:http://auth.gulimall.com/reg.html";// 采用重定向有一定防刷功能
// 1、return "redirect:http://auth.gulimall.com/reg.html"【采用】 重定向Get请求【配合RedirectAttributes共享数据】
// 2、return "redirect:http:/reg.html" 【采用】 重定向Get请求,省略当前服务url【配合RedirectAttributes共享数据】
// 3、return "redirect:/reg.html" 重定向Get请求,使用视图控制器拦截请求并映射reg视图【配合RedirectAttributes共享数据】【bug:会以ip+port来重定向】
// 4、return "forward:http://auth.gulimall.com/reg.html"; 请求转发与当前请求方式一致(Post请求)【配合Model共享数据】【异常404:当前/reg.html不存在post请求】
// 5、return "forward:http:/reg.html"; 请求转发与当前请求方式一致(Post请求),省略当前服务url 【配合Model共享数据】【异常404:当前/reg.html不存在post请求】
// 6、return "forward:/reg.html"; 请求转发与当前请求方式一致(Post请求),使用视图控制器拦截请求并映射reg视图【配合Model共享数据】【异常405:Request method 'POST' not supported,视图控制器必须使用GET请求访问,而当前请求转发使用post方式,导致异常】
// 7、return "reg"; 视图解析器前后拼串查找资源返回【配合Model共享数据】
}
// 2.验证码校验
String code = user.getCode();
String redisCode = redisTemplate.opsForValue().get(AuthConstant.SMS_CODE_CACHE_PREFIX + user.getPhone());
if (StringUtils.isBlank(redisCode)) {
// 验证码过期
Map<String, String> errMap = new HashMap<>();
errMap.put("code", "验证码失效");
// 封装异常返回前端显示
attributes.addFlashAttribute("errors", errMap);// flash,session中的数据只使用一次
return "redirect:http://auth.gulimall.com/reg.html";// 采用重定向有一定防刷功能
}
if (!code.equals(redisCode.split("_")[0])) {
// 验证码错误
Map<String, String> errMap = new HashMap<>();
errMap.put("code", "验证码错误");
// 封装异常返回前端显示
attributes.addFlashAttribute("errors", errMap);// flash,session中的数据只使用一次
return "redirect:http://auth.gulimall.com/reg.html";// 采用重定向有一定防刷功能
}
// 3.调用login实现注册
redisTemplate.delete(AuthConstant.SMS_CODE_CACHE_PREFIX + user.getPhone());
R r = memberFeignService.regist(user);
if (r.getCode() == 0) {
// 注册成功,重定向到登录页
return "redirect:http://auth.gulimall.com/login.html";// 重定向
} else {
// 注册失败,封装异常
HashMap<String, String> errMap = new HashMap<>();
errMap.put("msg", r.getData("msg", new TypeReference<String>() {
}));
attributes.addFlashAttribute("errors", errMap);// flash,session中的数据只使用一次
return "redirect:http://auth.gulimall.com/reg.html";// 采用重定向有一定防刷功能
}
}
5.5.用户模块_注册接口
1)校验与异常机制
// 校验手机号、用户名是否唯一,不唯一抛出异常,由controller处理
/**
* 注册
*/
@PostMapping("/regist")
public R regist(@RequestBody MemberUserRegisterTO user){
try {
memberService.regist(user);
return R.ok();
} catch (PhoneException ex) {
return R.error(BizCodeEnume.PHONE_EXIST_EXCEPTION);
} catch (UsernameException ex) {
return R.error(BizCodeEnume.USER_EXIST_EXCEPTION);
} catch (Exception ex) {
return R.error(ex.getMessage());
}
}
2)密码加密MD5
1.可逆加密与不可逆加密
可逆加密:通过密文根据算法可以推算出明文
不可逆加密:无法推算出明文
2.彩虹表
暴力破解所有值的MD5值存储到数据库,然后存储一个映射表,该映射表称为彩虹表
3.不可逆加密实现方法:MD5、MD5盐值加密
========================================================================
MD5:信息摘要算法,只要一个字节发生变化,结果值就会变化
- Message Digest algorithm 5,信息摘要算法
·压缩性:任意长度的数据,算出的MD5值长度都是固定的。
·容易计算:从原数据计算出MD5值很容易。
·抗修改性:对原数据进行任何改动,哪怕只修改1个字节,所得到的MD5值都有很大区别。
·强抗碰撞:想找到两个不同的数据,使它们具有相同的MD5值,是非常困难的。
·不可逆
【百度网盘秒传功能:计算文件MD5值,如果在百度的服务器里能找到一个一模一样的,就可以使用这个】
========================================================================
MD5盐值加密:【明文相同,盐值不同密文也不同,增加了彩虹表的难度】
·通过生成随机数与MD5生成字符串进行组合
·数据库同时存储MD5值与salt值。验证正确性时使用salt进行MD5即可
========================================================================
案例:
========================================================================
MD5案例:
String s = DigestUtils.md5Hex("123456");// e10adc3949ba59abbe56e057f20f883e
System.out.println(s);
========================================================================
MD5盐值案例:
System.out.println(Md5Crypt.md5Crypt("123456".getBytes()));// 随机盐值,随机MD5值:【盐值:USI.JoH2】【MD5值:$1$USI.JoH2$6hK88QXt9ijipsa/VcnbR0】
System.out.println(Md5Crypt.md5Crypt("123456".getBytes()));// 随机盐值,随机MD5值:【盐值:tCYQRfTB】【MD5值:$1$tCYQRfTB$thopJ/8DcRSObDwXuKxvn1】
System.out.println(Md5Crypt.md5Crypt("123456".getBytes(), "$1$123"));// 固定盐值,固定MD5值:【盐值:123】【MD5值:$1$123$7mft0jKnzzvAdU4t0unTG1】
System.out.println(Md5Crypt.md5Crypt("123456".getBytes(), "$1$123"));// 固定盐值,固定MD5值:【盐值:123】【MD5值:$1$123$7mft0jKnzzvAdU4t0unTG1】
========================================================================
使用spring的MD5+随机盐方法生成密文:
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
String encodedPassword1 = passwordEncoder.encode("123456");//$2a$10$s0yQ/Tz1aiexGqQGBNgmDuUFpCPjMx8L7TvJ60i9mQSBEmNXbSFEO
String encodedPassword2 = passwordEncoder.encode("123456");//$2a$10$eXhMUTIjoS4cpCB3FRjhlu0QYGwTRgh93CefQSk48hPpvQzzDAvIS
System.out.println(passwordEncoder.matches("123456", encodedPassword1));// 校验结果true
System.out.println(passwordEncoder.matches("123456", encodedPassword2));// 校验结果true
========================================================================
3)service代码
/**
* 注册
*/
@Override
public void regist(MemberUserRegisterTO user) throws InterruptedException {
// 1.加锁
RLock lock = redissonClient.getLock(MemberConstant.LOCK_KEY_REGIST_PRE + user.getPhone());
try {
lock.tryLock(30L, TimeUnit.SECONDS);
// 2.校验
// 校验手机号唯一、用户名唯一
checkPhoneUnique(user.getPhone());
checkUserNameUnique(user.getUserName());
// 3.封装保存
MemberEntity entity = new MemberEntity();
entity.setUsername(user.getUserName());
entity.setMobile(user.getPhone());
// 3.1.设置默认等级信息
MemberLevelEntity level = memberLevelService.getDefaultLevel();
entity.setLevelId(level.getId());
// 3.2.设置密码加密存储
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
String encode = passwordEncoder.encode(user.getPassword());
entity.setPassword(encode);
this.baseMapper.insert(entity);
} finally {
lock.unlock();
}
}
/**
* 校验手机号是否唯一
*/
public void checkPhoneUnique(String phone) throws PhoneException {
Integer count = baseMapper.selectCount(new QueryWrapper<MemberEntity>()
.eq("mobile", phone));
if (count > 0)
throw new PhoneException();
}
/**
* 校验用户名是否唯一
*/
public void checkUserNameUnique(String userName) throws UsernameException {
Integer count = baseMapper.selectCount(new QueryWrapper<MemberEntity>()
.eq("username", userName));
if (count > 0)
throw new UsernameException();
}
6.登录
6.0.session原理+springsession原理
session原理:
1.浏览器存储一个cookie值,jsessionId作为key
2.浏览器请求服务器时会带上这个cookie,服务器根据jsessionId的值找到对应的session对象
3.从而可以拿到session中存储的信息(例如用户信息)
4.session对象存储在内存中
springsession原理:
1.通过CookieSerializer设置cookie相关信息,setCookieName设置cookie的key值(代替jsessionId),并自动生成value值,并且可以setDomainName放大作用域
2.springsession将session对象存储在redis中
6.1.认证模块_登录接口
登录成功设置session
/**
* 登录接口
*/
@PostMapping(value = "/login")
public String login(UserLoginVO user, RedirectAttributes attributes, HttpSession session) {
// 1.远程调用登录
R r = memberFeignService.login(user);
if (r.getCode() == 0) {
// 2.登录成功,设置session值
MemberResponseVO data = r.getData(new TypeReference<MemberResponseVO>() {});
session.setAttribute(AuthConstant.LOGIN_USER, data);
// 3.重定向,视图可以从session中拿到用户信息
return "redirect:http://gulimall.com";
} else {
// 4.登录失败,封装异常信息重定向返回
Map<String,String> errors = new HashMap<>();
errors.put("msg", r.getData("msg",new TypeReference<String>(){}));
attributes.addFlashAttribute("errors",errors);
return "redirect:http://auth.gulimall.com/login.html";
}
}
6.2.用户模块_登录接口
MD5+盐值密码校验
/**
* 登录
*/
@Override
public MemberEntity login(MemberUserLoginTO user) {
String loginacct = user.getLoginacct();
String password = user.getPassword();// 明文
// 1.查询MD5密文
MemberEntity entity = baseMapper.selectOne(new QueryWrapper<MemberEntity>()
.eq("username", loginacct)
.or()
.eq("mobile", loginacct));
if (entity != null) {
// 2.获取password密文进行校验
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
if (passwordEncoder.matches(password, entity.getPassword())) {
// 登录成功
return entity;
}
}
// 3.登录失败
return null;
}
6.3.社交登录OAuth2.0
一、步骤:【例如登录CSDN】
1.用户点击QQ登录
2.引导跳转到QQ授权页
3.用户输入账号密码点击授权
4.授权成功快速注册,然后登录通过CSDN
二、简介:
OAuth: OAuth(开放授权)是一个开放标准,允许用户授权第三方网站访问他们存储在另外的服务提供者上的信息,而不需要将用户名和密码提供给第三方网站或分享他们数据的所有内容。
OAuth2.0:【一种协议标准,现在都使用OAuth2.0】实际上就是服务器开放了一些查询用户信息的OpenAPI(例如获取用户信息,动态同步,照片,日志,分享等),为了保护用户数据的安全和隐私,第三方网站访问用户数据前都需要显式的向用户征求授权,授权成功会拿到token,根据token调用OpenAPI获取用户信息
三、查看下图
1.QQ授权成功,会重定向到CSDN,并返回授权码
2.用户根据授权码,请求获得token令牌
3.带上token令牌访问QQ服务器拿到用户基本信息
6.3.1.微博登录
1.登录微博开放平台
https://open.weibo.com/
2.微连接->网站接入->创建新应用:gulimall_wanwan-
3.设置授权回调页+取消授权回调页
我的应用->应用信息->高级信息
授权回调页:
http://auth.gulimall.com/oauth2.0/weibo/success
取消授权回调页:
http://gulimall.com/fall
4.参照文档开发社交登录功能
https://open.weibo.com/wiki/%E9%A6%96%E9%A1%B5
->OAuth2.0授权认证
https://open.weibo.com/wiki/%E6%8E%88%E6%9D%83%E6%9C%BA%E5%88%B6%E8%AF%B4%E6%98%8E
5.引导用户进入授权页:【添加授权页+授权回调页】
<a href="https://api.weibo.com/oauth2/authorize?client_id=2129105835&response_type=code&redirect_uri=http://auth.gulimall.com/oauth2.0/weibo/success">
<img style="width: 50px;height: 18px;" src="/static/login/JD_img/weibo.png"/>
<!-- <span>weibo</span>-->
</a>
6.上一步用户授权成功后会重定向到授权页,并返回一个code【OTP,使用一次后便失效】
http://auth.gulimall.com/oauth2.0/weibo/success?code=8bbfd9a8bc1379253e5434d28a41df74
7.根据code换取Access Token【查看postman请求截图,Access Token可以使用多次】
指定client_id、client_secret、redirect_uri、code
https://api.weibo.com/oauth2/access_token?client_id=2129105835&client_secret=YOUR_CLIENT_SECRET&grant_type=authorization_code&redirect_uri=http://auth.gulimall.com/oauth2.0/weibo/success&code=8bbfd9a8bc1379253e5434d28a41df74
8.成功获取Access Token后,可以访问的OpenAPI【查看截图】
我的应用->接口管理->已有权限->访问用户信息
9.重点注意:access token和client_secret一定要后端保存,不能泄露
创建新应用:
微博web网站授权流程:
postman获取Access Token截图:
OpenAPI已有权限:
根据access token访问用户信息:
时序图
代码实现步骤
1.设置授权回调页+取消授权回调页
登录微博开放平台【https://open.weibo.com/】->我的应用->应用信息->高级信息
授权回调页:
http://auth.gulimall.com/oauth2.0/weibo/success
取消授权回调页:
http://gulimall.com/fall
2.拷贝gulimall-third-part的HTTPUtils类到common中,并导入相关依赖
3.点击微博登录请求后端地址
<a href="https://api.weibo.com/oauth2/authorize?client_id=2129105835&response_type=code&redirect_uri=http://auth.gulimall.com/oauth2.0/weibo/success">
<img style="width: 50px;height: 18px;" src="/static/login/JD_img/weibo.png"/>
<!-- <span>weibo</span>-->
</a>
4.认证模块_社交登录接口
/**
* 授权回调页
*
* @param code 根据code换取Access Token,且code只能兑换一次Access Token
*/
@GetMapping("/oauth2.0/weibo/success")
public String weibo(@RequestParam("code") String code) throws Exception {
// 1.根据code换取Access Token
Map<String, String> headers = new HashMap<>();
Map<String, String> querys = new HashMap<>();
Map<String, String> map = new HashMap<>();
map.put("client_id", "2129105835");
map.put("client_secret", "201b8aa95794dbb6d52ff914fc8954dc");
map.put("grant_type", "authorization_code");
map.put("redirect_uri", "http://auth.gulimall.com/oauth2.0/weibo/success");
map.put("code", code);
HttpResponse response = HttpUtils.doPost("https://api.weibo.com", "/oauth2/access_token", headers, querys, map);
// 2.处理请求返回
if (response.getStatusLine().getStatusCode() == 200) {
// 换取Access_Token成功
String jsonString = EntityUtils.toString(response.getEntity());
WBSocialUserVO user = JSONObject.parseObject(jsonString, WBSocialUserVO.class);
// 首次登录自动注册(为当前社交登录用户生成一个会员账号信息,以后这个社交账户就会对应指定的会员)
// 非首次登录则直接登录成功
R r = memberAgentService.oauthLogin(user);
if (r.getCode() == 0) {
// 登录成功
MemberResponseVO loginUser = r.getData(new TypeReference<MemberResponseVO>() {
});
log.info("登录成功:用户:{}", loginUser.toString());
// 跳回首页
return "redirect:http://gulimall.com";
} else {
// 登录失败,调回登录页
return "redirect:http://auth.gulimall.com/login.html";
}
} else {
// 换取Access_Token成功
return "redirect:http://auth.gulimall.com/login.html";
}
}
5.会员模块_社交登录接口
/**
* 微博社交登录
*/
@PostMapping("/weibo/oauth2/login")
public R oauthLogin(@RequestBody WBSocialUserTO user) {
try {
MemberEntity entity = memberService.login(user);
return R.ok().setData(entity);
} catch (Exception ex) {
return R.error(ex.getMessage());
}
}
/**
* 微博社交登录(登录和注册功能合并)
*/
@Override
public MemberEntity login(WBSocialUserTO user) throws Exception {
// 1.判断当前用户是否已经在本系统注册
String uid = user.getUid();
MemberEntity _entity = baseMapper.selectOne(new QueryWrapper<MemberEntity>().eq("weibo_uid", user.getUid()));
if (_entity != null) {
// 2.已注册,直接返回
MemberEntity member = new MemberEntity();
member.setId(_entity.getId());
member.setAccessToken(user.getAccessToken());
member.setExpiresIn(user.getExpiresIn());
baseMapper.updateById(member);
// 返回
_entity.setAccessToken(user.getAccessToken());
_entity.setExpiresIn(user.getExpiresIn());
return _entity;
} else {
// 3.未注册
MemberEntity member = new MemberEntity();
try {
// 查询当前社交用户的社交账号信息,封装会员信息(查询结果不影响注册结果,所以使用try/catch)
Map<String, String> queryMap = new HashMap<>();
queryMap.put("access_token", user.getAccessToken());
queryMap.put("uid", user.getUid());
HttpResponse response = HttpUtils.doGet("https://api.weibo.com", "/2/users/show.json", new HashMap<String, String>(), queryMap);
if (response.getStatusLine().getStatusCode() == 200) {
//查询成功
String json = EntityUtils.toString(response.getEntity());
JSONObject jsonObject = JSON.parseObject(json);
String name = jsonObject.getString("name");
String gender = jsonObject.getString("gender");
String profileImageUrl = jsonObject.getString("profile_image_url");
// 封装注册信息
member.setNickname(name);
member.setGender("m".equals(gender) ? 1 : 0);
member.setHeader(profileImageUrl);
member.setCreateTime(new Date());
}
} catch (Exception e) {
}
member.setWeiboUid(user.getUid());
member.setAccessToken(user.getAccessToken());
member.setExpiresIn(user.getExpiresIn());
//把用户信息插入到数据库中
baseMapper.insert(member);
return member;
}
}
6.3.2.微信登录
6.4.cookie和session共享问题
简介:
1.不能跨域名共享cookie
认证模块登录成功后设置cookie【domain=auth.gulimall.com】
然后重定向到首页【domain=gulimall.com】,二者domain不一致,导致cookie不共享
请求gulimall.com时,浏览器默认不会带上cookie
2.不能跨JVM共享session
登录成功后session保存userinfo,但是session存在于auth.gulimall.com的JVM上
与gulimall.com的JVM并不共享,因为session是JVM运行时内存中的数据,JVM之间不共享
3.服务复制多份也不能进行session的共享
6.4.1.问题一:不能跨域名共享cookie
跨域情况下,cookie不共享
方案一:子域session共享,放大作用域
放大cookie的作用域
1.方法1:自己设置domain
// 首次使用session时,spring会自动颁发cookie设置domain,所以这里手动设置cookie很麻烦,采用springsession的方式颁发父级域名的domain权限
// Cookie cookie = new Cookie("JSESSIONID", loginUser.getId().toString());
// cookie.setDomain("gulimall.com");
// servletResponse.addCookie(cookie);
2.使用springsession设置domain放大作用域
6.4.2.问题二:集群下同一个服务不能跨JVM共享session
1.集群环境下,多个会员服务节点不共享JVM,session不共享
2.不同服务之间也不共享JVM,session不共享
方案一:session复制
Tomcat修改配置文件就可以支持。
缺点:
延迟
占用带宽
内存占用【所有Tomcat都需要全量保存数据】
方案二:客户端存储
方案三:hash一致性
推荐使用
6.4.3.问题三:分布式下不同服务共享session
方案一:统一存储==>本系统采用
推荐使用
方案二:token令牌
使用redis共享存储 + springsecurity存token令牌,每个调用接口都带令牌访问
6.5.使用springsession(各模块)
原理:创建了SpringSession过滤器代替了HttpSession的实现
Our Spring Boot Configuration created a Spring bean named springSessionRepositoryFilter that implements Filter. The springSessionRepositoryFilter bean is responsible for replacing the HttpSession with a custom implementation that is backed by Spring Session.
简介:【各模块都需要使用springsession】
1.解决了子域cookie无法共享的问题,放大了cookie的作用域domain
2.解决了跨JVM不能共享session的问题【问题2和问题3本质上是同一个问题,不同JVM不能共享session】,springsession采用redis统一存储的的方式解决了session共享问题
3.查看文档:
https://docs.spring.io/spring-session/docs/2.2.1.RELEASE/reference/
https://spring.io
=》project
=》springsession
=》learn
=》Reference Doc.
=》3. Samples and Guides (Start Here)
=》HttpSession with Redis Guide (Source 源码) + HttpSession with Redis Guide (Guide 引导)
1.在各服务添加springsession依赖(服务自治)【auth、product、search、member、order、】
<!--redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--整合springsession,实现session共享-->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
2.属性配置
server:
servlet:
session:
timeout: 30m
spring:
redis:
host: 192.168.56.10
port: 6379
session:
store-type: redis
3.启动类添加配置
@EnableRedisHttpSession
4.登录接口,把登录信息存入session中
/**
* 授权回调页
*
* @param code 根据code换取Access Token,且code只能兑换一次Access Token
*/
@GetMapping("/oauth2.0/weibo/success")
public String weibo(@RequestParam("code") String code, HttpSession session, HttpServletResponse servletResponse) throws Exception {
// 1.根据code换取Access Token
Map<String, String> headers = new HashMap<>();
Map<String, String> querys = new HashMap<>();
Map<String, String> map = new HashMap<>();
map.put("client_id", "2129105835");
map.put("client_secret", "201b8aa95794dbb6d52ff914fc8954dc");
map.put("grant_type", "authorization_code");
map.put("redirect_uri", "http://auth.gulimall.com/oauth2.0/weibo/success");
map.put("code", code);
HttpResponse response = HttpUtils.doPost("https://api.weibo.com", "/oauth2/access_token", headers, querys, map);
// 2.处理请求返回
if (response.getStatusLine().getStatusCode() == 200) {
// 换取Access_Token成功
String jsonString = EntityUtils.toString(response.getEntity());
WBSocialUserVO user = JSONObject.parseObject(jsonString, WBSocialUserVO.class);
// 首次登录自动注册(为当前社交登录用户生成一个会员账号信息,以后这个社交账户就会对应指定的会员)
// 非首次登录则直接登录成功
R r = memberAgentService.oauthLogin(user);
if (r.getCode() == 0) {
// 登录成功
MemberResponseVO loginUser = r.getData(new TypeReference<MemberResponseVO>() {
});
log.info("登录成功:用户:{}", loginUser.toString());
// 3.信息存储到session中,并且放大作用域(指定domain=父级域名)
session.setAttribute(AuthConstant.LOGIN_USER, loginUser);
// 首次使用session时,spring会自动颁发cookie设置domain,所以这里手动设置cookie很麻烦,采用springsession的方式颁发父级域名的domain权限
// Cookie cookie = new Cookie("JSESSIONID", loginUser.getId().toString());
// cookie.setDomain("gulimall.com");
// servletResponse.addCookie(cookie);
// 跳回首页
return "redirect:http://gulimall.com";
} else {
// 登录失败,调回登录页
return "redirect:http://auth.gulimall.com/login.html";
}
} else {
// 换取Access_Token成功
return "redirect:http://auth.gulimall.com/login.html";
}
}
5.MemberResponseVO实现序列化接口
原理:内存中的对象要序列化成一个二进制流 传输到 redis中存储
public class MemberResponseVO implements Serializable
6.添加以下配置,放大作用域 + 指定redis序列化器【否则使用默认的jdk序列化器】
/**
* springsession配置类
*/
@Configuration
public class GulimallSessionConfig {
@Bean
public CookieSerializer cookieSerializer() {
DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
cookieSerializer.setDomainName("gulimall.com");// 放大作用域
cookieSerializer.setCookieName("GULISESSION");
cookieSerializer.setCookieMaxAge(60 * 60 * 24 * 7);// 指定cookie有效期7天,会话级关闭浏览器后cookie即失效
return cookieSerializer;
}
@Bean
public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
// 指定session序列化到redis的序列化器
return new GenericJackson2JsonRedisSerializer();
}
}
7.修改product模块gulimall首页,去除session中的loginUser
<li>
<a th:if="${session.loginUser != null}">欢迎, [[${session.loginUser.nickname}]]</a>
<a th:if="${session.loginUser == null}" href="http://auth.gulimall.com/login.html">你好,请登录</a>
</li>
8.测试
=》进入auth.gulimall.com并社交登录
=》进入gulimall.com查看cookie作用域是否修改成功
=》查看redis,session是否存储成功
=》查看gulimall首页nickname是否取到值
springsession文档页:
springsession原理
装饰者模式+自动续期
核心原理
1)、@EnablcRedisHttpSession导入RedisHttpSessionConfiguration配置
1、给容器中添加了一个组件
SessionRepository=》》》【RedisOperationsSessionRepository】=-》redis操作session。session的增删改查操作
2、SessionRepositoryFiLter=:》Filter:session '存储过滤器;每个请求过来都必须经过filter
1、创建的时侯,就自动从容器中获取到了sessionRepository;
2、原始的request,response都被包装。SessionRepositoryRequestWrapper, SessionRepositoryResponseWrapper
3、以后获取session。 request.getSession();
4、wrappedRequest.getSession(); 如果session中不存在,就到redis中查找
SessionRepositort中获取到getById(xx)
2)、Spring Session 会给redis中的session数据自动延期
Spring Session核心方法:SessionRepositoryFilter过滤器
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
request.setAttribute(SESSION_REPOSITORY_ATTR, this.sessionRepository);
// 装饰者模式,包装request和response,然后将包装后的request和response对象放行
// 然后request和response被换成了SessionRepositoryRequestWrapper和SessionRepositoryResponseWrapper对象
SessionRepositoryFilter<S>.SessionRepositoryRequestWrapper wrappedRequest = new SessionRepositoryFilter.SessionRepositoryRequestWrapper(request, response);
SessionRepositoryFilter.SessionRepositoryResponseWrapper wrappedResponse = new SessionRepositoryFilter.SessionRepositoryResponseWrapper(wrappedRequest, response);
try {
filterChain.doFilter(wrappedRequest, wrappedResponse);
} finally {
wrappedRequest.commitSession();
}
}
6.6.网站的单点登录
多个不同域名下,springsession无法共享
单点登录特性:非父子域名下共享登录状态
一处退出,处处退出
一处登录,处处登录
原理:
1.客户端访问认证中心并带上回调url,进行登录
2.登录成功认证中心域名下设置cookie,并跳转url?token=xxx,携带token参数
3.客户端根据tokne请求认证中心获取用户信息【微博是用code获取AcsessToken,然后根据AcsessToken获取信息】
4.客户端2再访问认证中心时,会带上浏览器存储的cookie,从而直接登录通过
原理:
实例
1.创建中央认证服务器模块
<groupId>com.atguigu</groupId>
<artifactId>gulimall-test-sso-server</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>gulimall-test-sso-server</name>
<description>单点登录的认证服务器</description>
2.创建两个客户端
<groupId>com.atguigu</groupId>
<artifactId>gulimall-test-sso-client</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>gulimall-test-sso-client</name>
<description>单点登录客户端</description>
<groupId>com.atguigu</groupId>
<artifactId>gulimall-test-sso-client2</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>gulimall-test-sso-client2</name>
<description>单点登录客户端2</description>
3.修改本地host,添加三个域名
127.0.0.1 sso.com
127.0.0.1 client1.com
127.0.0.1 client2.com
4.sso认证接口
@Controller
public class LoginController {
@Autowired
StringRedisTemplate redisTemplate;
@ResponseBody
@GetMapping("/userinfo")
public String userInfo(@RequestParam("token") String token) {
String username = redisTemplate.opsForValue().get(token);
return username;
}
/**
* 访问登录页
* @param url 登录成功回调页
* @param sso_token cookie值
*/
@GetMapping(value = "/login.html")
public String login(@RequestParam("redirect_url") String url, Model model,
@CookieValue(value = "sso_token", required = false) String token) {
// 根据token获取用户信息
if (!StringUtils.isEmpty(token)) {
String username = redisTemplate.opsForValue().get(token);
if (!StringUtils.isEmpty(username)) {
// token正确,已登录状态,跳转回客户端【当前访问客户端共享了其他客户端的登录状态】
return "redirect:" + url + "?token=" + token;
}
}
// 不存在sso_token,未登录返回登录页,并将回调地址链路下传
model.addAttribute("url", url);
return "login";
}
/**
* 登录
* @param url 登录成功回调页
*/
@PostMapping(value = "/doLogin")
public String doLogin(String username, String password, String url,
Model model, HttpServletResponse response) {
if (!StringUtils.isEmpty(username) && !StringUtils.isEmpty(password)) {
// 登录成功,跳转回调页
String token = UUID.randomUUID().toString().replace("-", "");
// token作为key,用户信息作为value存入redis中
redisTemplate.opsForValue().set(token, username);
// 在sso.com域名下设置cookie,使得不同客户端访问单点登录时可以带上cookie值成功登录
Cookie cookie = new Cookie("sso_token", token);
response.addCookie(cookie);
return "redirect:" + url + "?token=" + token;
}
// 登录失败
model.addAttribute("url", url);
return "login";
}
}
登录页面:
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>登录页</title>
</head>
<body>
<form action="/doLogin" method="post">
用户名:<input type="text" name="username"/><br/>
密码:<input type="password" name="password"/><br/>
<input type="hidden" name="url" th:value="${url}">
<input type="submit" value="登录">
</form>
</body>
</html>
5.客户端接口
@Controller
public class HelloController {
/**
* 需要登录状态访问
*/
@GetMapping(value = "/employees")
public String employees(Model model, HttpSession session,
@RequestParam(name = "token", required = false) String token) {
if (!StringUtils.isEmpty(token)) {
// 根据token去sso认证中心获取用户信息
RestTemplate restTemplate = new RestTemplate();
ResponseEntity<String> entity = restTemplate.getForEntity("http://sso.com:8080/userinfo?token=" + token, String.class);
session.setAttribute("loginUser", entity.getBody());
}
Object loginUser = session.getAttribute("loginUser");
if (loginUser == null && token == null) {
// 未登录,跳转认证服务器登录
return "redirect:http://sso.com:8080/login.html?redirect_url=http://client2.com:8082/employees";
} else {
// 登录状态显示
List<String> emps = new ArrayList<>();
emps.add("张三");
emps.add("李四");
model.addAttribute("emps", emps);
return "employees";
}
}
}
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>员工列表</title>
</head>
<body>
<h1>欢迎:[[${session.loginUser}]]</h1>
<ul>
<li th:each="emp:${emps}">姓名:[[${emp}]]</li>
</ul>
</body>
</html>
认证服务器: