一、product-es 准备
ES 在内存中,所以在检索中优于 mysql。ES 也支持集群,数据分片存储。
需求:
- 上架的商品才可以在网站展示。
- 上架的商品需要可以被检索。
1.1 分析 sku 在 es 中如何存储
商品 mapping
分析:商品上架在 es 中是存 sku 还是 spu?
- 1)、检索的时候输入名字,是需要按照 sku 的 title 进行全文检索的
- 2)、检素使用商品规格,规格是 spu 的公共属性,每个 spu 是一样的
- 3)、按照分类 id 进去的都是直接列出 spu 的,还可以切换。
- 4〕、我们如果将 sku 的全量信息保存到 es 中(包括 spu 属性〕就太多字段了
方案 1:
1 2 3 4 5 6 7 8 9 10 11 12
| { skuId:1 spuId:11 skyTitile:华为xx price:999 saleCount:99 attr:[ {尺寸:5}, {CPU:高通945}, {分辨率:全高清} ] 缺点:如果每个sku都存储规格参数(如尺寸),会有冗余存储,因为每个spu对应的sku的规格参数都一样
|
方案 2:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| sku索引 { spuId:1 skuId:11 } attr索引 { skuId:11 attr:[ {尺寸:5}, {CPU:高通945}, {分辨率:全高清} ] } 先找到4000个符合要求的spu,再根据4000个spu查询对应的属性,封装了4000个id,long 8B*4000=32000B=32KB 1K个人检索,就是32MB
结论:如果将规格参数单独建立索引,会出现检索时出现大量数据传输的问题,会引起网络网络
|
因此选用方案 1,以空间换时间
1.2 建立 product 索引
最终选用的数据模型:
- { “type”: “keyword” }, # 保持数据精度问题,可以检索,但不分词
- “analyzer”: “ik_smart” # 中文分词器
- “index”: false, # 不可被检索,不生成 index
- “doc_values”: false # 默认为 true,不可被聚合,es 就不会维护一些聚合的信息
视频原数据:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71
| 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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| PUT product { "mappings":{ "properties": { "skuId":{ "type": "long" }, "spuId":{ "type": "keyword" }, # 不可分词 "skuTitle": { "type": "text", "analyzer": "ik_smart" # 中文分词器 }, "skuPrice": { "type": "keyword" }, # 保证精度问题 "skuImg" : { "type": "keyword" }, # 视频中有false "saleCount":{ "type":"long" }, "hasStock": { "type": "boolean" }, "hotScore": { "type": "long" }, "brandId": { "type": "long" }, "catalogId": { "type": "long" }, "brandName": {"type": "keyword"}, # 视频中有false "brandImg":{ "type": "keyword", "index": false, # 不可被检索,不生成index,只用做页面使用 "doc_values": false # 不可被聚合,默认为true }, "catalogName": {"type": "keyword" }, # 视频里有false "attrs": { "type": "nested", "properties": { "attrId": {"type": "long" }, "attrName": { "type": "keyword", "index": false, "doc_values": false }, "attrValue": {"type": "keyword" } } } } } }
|
如果检索不到商品,自己用 postman 测试一下,可能有的字段需要更改,你也可以把没必要的”keyword”去掉
冗余存储的字段:不用来检索,也不用来分析,节省空间
库存是 bool。
检索品牌 id,但是不检索品牌名字、图片
用 skuTitle 检索
1.3 nested 嵌入式对象
属性是”type”: “nested”,因为是内部的属性进行检索
数组类型的对象会被扁平化处理(对象的每个属性会分别存储到一起)
1 2 3 4 5
| user.name=["aaa","bbb"] user.addr=["ccc","ddd"]
这种存储方式,可能会发生如下错误: 错误检索到{aaa,ddd},这个组合是不存在的
|
数组的扁平化处理会使检索能检索到本身不存在的,为了解决这个问题,就采用了嵌入式属性,数组里是对象时用嵌入式属性(不是对象无需用嵌入式属性)
nested 阅读:https://blog.csdn.net/weixin_40341116/article/details/80778599
使用聚合:https://blog.csdn.net/kabike/article/details/101460578
二、商品上架
按 skuId 上架
POST /product/spuinfo/{spuId}/up
1 2 3 4 5 6
| @GetMapping("/skuId/{id}") public R getSkuInfoBySkuId(@PathVariable("id") Long skuId){
SpuInfoEntity entity = spuInfoService.getSpuInfoBySkuId(skuId); return R.ok().setData(entity); }
|
product 里组装好,search 里上架
2.1 上架实体类
商品上架需要在 es 中保存 spu 信息并更新 spu 的状态信息,由于SpuInfoEntity
与索引的数据模型并不对应,所以我们要建立专门的 vo 进行数据传输
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| @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<Attr> attrs;
@Data public static class Attr{ private Long attrId; private String attrName; private String attrValue; } }
|
2.2 库存量查询
上架要确保还有库存
- 在 ware 微服务里添加”查询 sku 是否有库存”的 controller
1 2 3 4 5 6 7 8 9 10 11
|
@PostMapping("/hasStock") public R getSkuHasStock(@RequestBody List<Long> SkuIds){ List<SkuHasStockVo> vos = wareSkuService.getSkuHasStock(SkuIds); return R.ok().setData(vos); }
|
- 然后用 feign 调用
1 2 3 4 5 6
| @FeignClient("gulimall-ware") public interface WareFeignService {
@PostMapping("/ware/waresku/hasstock") public R getSkuHasStock(@RequestBody List<Long> skuIds); }
|
1 2 3 4 5 6
| @FeignClient("gulimall-search") public interface SearchFeignService {
@PostMapping(value = "/search/save//product") public R productStatusUp(@RequestBody List<SkuEsModel> skuEsModels); }
|
- 设置 R 的时候最后设置成泛型的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| public class R extends HashMap<String, Object> { private static final long serialVersionUID = 1L;
public <T> T getData(TypeReference<T> typeReference){ Object data = get("data"); String jsonString = JSON.toJSONString(data); T t = JSON.parseObject(jsonString,typeReference); return t; }
|
收集成 map 的时候,toMap()
参数为两个方法,如SkyHasStockVo::getSkyId, item->item.getHasStock()
将封装好的 SkuInfoEntity,调用 search 的 feign,保存到 es 中
下面代码为更具 sku 的各种信息保存到 es 中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
|
@PostMapping("/product") public R productStatusUp(@RequestBody List<SkuEsModel> skuEsModels){
boolean status; try { status = productSaveService.productStatusUp(skuEsModels); } catch (IOException e) { log.error("ElasticSaveController商品上架错误: {}", e); return R.error(BizCodeEnum.PRODUCT_UP_EXCEPTION.getCode(), BizCodeEnum.PRODUCT_UP_EXCEPTION.getMsg()); } if(!status){ return R.ok(); } return R.error(BizCodeEnum.PRODUCT_UP_EXCEPTION.getCode(), BizCodeEnum.PRODUCT_UP_EXCEPTION.getMsg()); }
public boolean productStatusUp(List<SkuEsModel> skuEsModels) throws IOException { BulkRequest bulkRequest = new BulkRequest(); for (SkuEsModel esModel : skuEsModels) { IndexRequest indexRequest = new IndexRequest(EsConstant.PRODUCT_INDEX); indexRequest.id(esModel.getSkuId().toString()); String jsonString = JSON.toJSONString(esModel); indexRequest.source(jsonString, XContentType.JSON); bulkRequest.add(indexRequest); } BulkResponse bulk = client.bulk(bulkRequest, GuliESConfig.COMMON_OPTIONS); boolean hasFailures = bulk.hasFailures(); if(hasFailures){ List<String> collect = Arrays.stream(bulk.getItems()).map(item -> item.getId()).collect(Collectors.toList()); log.error("商品上架错误:{}",collect); } return hasFailures; }
|
- 上架失败返回 R.error(错误码,消息)
此时再定义一个错误码枚举。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| public enum BizCodeEnum { UNKNOW_EXCEPTION(10000,"系统未知异常"), VAILD_EXCEPTION(10001,"参数格式校验失败"), PRODUCT_UP_EXCEPTION(11000,"商品上架异常");
private int code; private String msg; BizCodeEnum(int code, String msg){ this.code = code; this.msg = msg; }
public int getCode() { return code; }
public String getMsg() { return msg; } }
|
在接收端获取他返回的状态码
6)上架后再让数据库中变为上架状态
7)mybatis 为了能兼容接收 null 类型,要把 long 改为 Long
debug 时很容易远程调用异常,因为超时了
2.3 根据 spuId 封装上架数据
前面我们写了把 sku 信息放到 es 中,但是这些信息需要我们封装,前端只是传过来了一个 spuId
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61
| public void upSpuForSearch(Long spuId) { List<SkuInfoEntity> skuInfoEntities=skuInfoService.getSkusBySpuId(spuId); List<ProductAttrValueEntity> productAttrValueEntities = productAttrValueService.list(new QueryWrapper<ProductAttrValueEntity>().eq("spu_id", spuId)); List<Long> attrIds = productAttrValueEntities.stream().map(attr -> { return attr.getAttrId(); }).collect(Collectors.toList()); List<Long> searchIds=attrService.selectSearchAttrIds(attrIds); Set<Long> ids = new HashSet<>(searchIds); List<SkuEsModel.Attr> searchAttrs = productAttrValueEntities.stream().filter(entity -> { return ids.contains(entity.getAttrId()); }).map(entity -> { SkuEsModel.Attr attr = new SkuEsModel.Attr(); BeanUtils.copyProperties(entity, attr); return attr; }).collect(Collectors.toList());
Map<Long, Boolean> stockMap = null; try { List<Long> longList = skuInfoEntities.stream().map(SkuInfoEntity::getSkuId).collect(Collectors.toList()); List<SkuHasStockVo> skuHasStocks = wareFeignService.getSkuHasStocks(longList); stockMap = skuHasStocks.stream().collect(Collectors.toMap(SkuHasStockVo::getSkuId, SkuHasStockVo::getHasStock)); }catch (Exception e){ log.error("远程调用库存服务失败,原因{}",e); }
Map<Long, Boolean> finalStockMap = stockMap; List<SkuEsModel> skuEsModels = skuInfoEntities.stream().map(sku -> { SkuEsModel skuEsModel = new SkuEsModel(); BeanUtils.copyProperties(sku, skuEsModel); skuEsModel.setSkuPrice(sku.getPrice()); skuEsModel.setSkuImg(sku.getSkuDefaultImg()); skuEsModel.setHotScore(0L); BrandEntity brandEntity = brandService.getById(sku.getBrandId()); skuEsModel.setBrandName(brandEntity.getName()); skuEsModel.setBrandImg(brandEntity.getLogo()); CategoryEntity categoryEntity = categoryService.getById(sku.getCatalogId()); skuEsModel.setCatalogName(categoryEntity.getName()); skuEsModel.setAttrs(searchAttrs); skuEsModel.setHasStock(finalStockMap==null?false:finalStockMap.get(sku.getSkuId())); return skuEsModel; }).collect(Collectors.toList());
R r = searchFeignService.saveProductAsIndices(skuEsModels); if (r.getCode()==0){ this.baseMapper.upSpuStatus(spuId, ProductConstant.ProductStatusEnum.SPU_UP.getCode()); }else { log.error("商品远程es保存失败"); } }
|
2.4 测试
1 2
| # 使用kibana测试 GET /product/_search
|
三、商城系统首页
3.1 导入依赖
前端使用了 thymeleaf 开发,因此要导入该依赖,并且为了改动页面实时生效导入 devtools
1 2 3 4 5 6 7 8 9
| <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-devtools</artifactId> </dependency>
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency>
|
可以在 product 项目中配置文件中配置
1 2 3
| spring: thymeleaf: cache: false
|
配置基础页面
测试:
3.2 渲染一级分类菜单
由于访问首页时就要加载一级目录,所以我们需要在加载首页时获取该数据
1 2 3 4 5 6 7
| @GetMapping({"/", "index.html"}) public String getIndex(Model model) { List<CategoryEntity> catagories = categoryService.getLevel1Catagories(); model.addAttribute("catagories", catagories); return "index"; }
|
1 2 3 4 5 6 7
| @Override public List<CategoryEntity> getLevel1Catagories() { List<CategoryEntity> parent_cid = this.list(new QueryWrapper<CategoryEntity>().eq("parent_cid", 0)); return parent_cid; }
|
页面遍历菜单数据
1 2 3 4 5 6 7 8 9
| <li th:each="catagory:${catagories}"> <a href="#" class="header_main_left_a" ctg-data="3" th:attr="ctg-data=${catagory.catId}" ><b th:text="${catagory.name}"></b ></a> </li>
|
3.3 渲染三级分类菜单
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| @GetMapping("index/catalog.json") @ResponseBody public Map<String, List<Catalog2Vo>> getCategoryMap() { return categoryService.getCategoryMap(); }
public Map<String, List<Catalog2Vo>> getCategoryMap() { List<CategoryEntity> categoryEntities = this.list(new QueryWrapper<CategoryEntity>().eq("cat_level", 2));
List<Catalog2Vo> catalog2Vos = categoryEntities.stream().map(categoryEntity -> { List<CategoryEntity> level3 = this.list(new QueryWrapper<CategoryEntity>().eq("parent_cid", categoryEntity.getCatId())); List<Catalog2Vo.Catalog3Vo> catalog3Vos = level3.stream().map(cat -> { return new Catalog2Vo.Catalog3Vo(cat.getParentCid().toString(), cat.getCatId().toString(), cat.getName()); }).collect(Collectors.toList()); Catalog2Vo catalog2Vo = new Catalog2Vo(categoryEntity.getParentCid().toString(), categoryEntity.getCatId().toString(), categoryEntity.getName(), catalog3Vos); return catalog2Vo; }).collect(Collectors.toList()); Map<String, List<Catalog2Vo>> catalogMap = new HashMap<>(); for (Catalog2Vo catalog2Vo : catalog2Vos) { List<Catalog2Vo> list = catalogMap.getOrDefault(catalog2Vo.getCatalog1Id(), new LinkedList<>()); list.add(catalog2Vo); catalogMap.put(catalog2Vo.getCatalog1Id(),list); } return catalogMap; }
|
四、搭建域名访问环境
4.1 正向代理与反向代理
nginx 就是通过反向代理实现负载均衡
4.2 Nginx 配置文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83
| user nginx; worker_processes 1;
error_log /var/log/nginx/error.log warn; pid /var/run/nginx.pid;
#event块 events { worker_connections 1024; }
#http块 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;
#gzip on;
upstream gulimall{ server 192.168.43.201:88; }
include /etc/nginx/conf.d/*.conf; ############################################################################ #/etc/nginx/conf.d/default.conf 的server块 server { listen 80; server_name localhost;
#charset koi8-r; #access_log /var/log/nginx/log/host.access.log main;
location / { root /usr/share/nginx/html; index index.html index.htm; }
#error_page 404 /404.html;
# redirect server error pages to the static page /50x.html # error_page 500 502 503 504 /50x.html; location = /50x.html { root /usr/share/nginx/html; }
# proxy the PHP scripts to Apache listening on 127.0.0.1:80 # #location ~ \.php$ { # proxy_pass http://127.0.0.1; #}
# pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000 # #location ~ \.php$ { # root html; # fastcgi_pass 127.0.0.1:9000; # fastcgi_index index.php; # fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name; # include fastcgi_params; #}
# deny access to .htaccess files, if Apache's document root # concurs with nginx's one # #location ~ /\.ht { # deny all; #} }
}
|
4.3 Nginx+Windows 搭建域名访问环境
修改 windows hosts 文件改变本地域名映射,将gulimall.com
映射到虚拟机 ip
进入 conf.d 文件,拷贝一份 default.conf = >gulimall.conf
修改 nginx 的根配置文件nginx.conf
,将upstream
映射到我们的网关服务
1 2 3
| upstream gulimall{ server 192.168.56.1:88; }
|
- 修改 nginx 的 server 块配置文件
gulimall.conf
,将以/
开头的请求转发至我们配好的gulimall
的upstream
,由于 nginx 的转发会丢失host
头,所以我们添加头信息
1 2 3 4
| location / { proxy_pass http://gulimall; proxy_set_header Host $host; }
|
配置网关服务,将域名为**.gulimall.com
转发至商品服务
1 2 3 4
| - id: gulimall_host uri: lb://gulimall-product predicates: - Host=**.gulimall.com
|