5.6.聚合
5.6.1.聚合为桶
桶就是分组,比如这里我们按照品牌brand进行分组:这个聚合查询还是比较复杂的,相对于前面的排序和分页查询,有很多子接。
聚合查询:
@Test
public void testJuhe(){
NativeSearchQueryBuilder queryBuilder=new NativeSearchQueryBuilder();
//不查询结果集合
queryBuilder.withSourceFilter(new FetchSourceFilter(new String[]{""},null));
//设置查询类型为词条查询,查询名称为,brands,查询字段为brand
queryBuilder.addAggregation(AggregationBuilders.terms("brands").field("brand"));
//执行查询,执行查询并转换类型为AggregatedPage
AggregatedPage<Item> page= (AggregatedPage<Item>) this.repository.search(queryBuilder.build());
//获取查询结果中brads的结果集合并转换为字符串的词条
StringTerms brand = (StringTerms) page.getAggregation("brands");
//获取桶,之后遍历得到相应的key和value
List<StringTerms.Bucket> buckets = brand.getBuckets();
for (StringTerms.Bucket bucket : buckets) {
System.out.println(bucket.getKeyAsString());
System.out.println(bucket.getDocCount());
}
}
完成测试:
嵌套子查询加求平均值
@Test
public void avgAndTest(){
NativeSearchQueryBuilder queryBuilder=new NativeSearchQueryBuilder();
queryBuilder.withSourceFilter(new FetchSourceFilter(new String[]{""},null));
queryBuilder.addAggregation(AggregationBuilders.terms("brands").field("brand").subAggregation(AggregationBuilders.avg("price_avg").field("price")));
AggregatedPage<Item> aggregatedPage= (AggregatedPage<Item>) this.repository.search(queryBuilder.build());
StringTerms brands = (StringTerms) aggregatedPage.getAggregation("brands");
List<StringTerms.Bucket> buckets = brands.getBuckets();
for (StringTerms.Bucket bucket : buckets) {
System.out.println(bucket.getKeyAsString());
System.out.println(bucket.getDocCount());
InternalAvg price_avg = (InternalAvg) bucket.getAggregations().asMap().get("price_avg");
System.out.println("平均价格为:"+price_avg.getValue());
}
}
完成测试:
-------------------------------------------------------------------------------------------------------
1.索引库数据导入
昨天我们学习了Elasticsearch的基本应用。今天就学以致用,搭建搜索微服务,实现搜索功能。
1.1.创建搜索服务
创建模块,之后添加依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>leyou</artifactId>
<groupId>com.leyou.parent</groupId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<groupId>com.leyou.search</groupId>
<artifactId>leyou-search</artifactId>
<dependencies>
<!-- web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- elasticsearch -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
<!-- eureka -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<!-- feign -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<dependency>
<groupId>com.leyou.item</groupId>
<artifactId>leyou-item-interface</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.leyou.common</groupId>
<artifactId>leyou-common</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
</dependencies>
</project>
添加对应的配置文件
server:
port: 8083
spring:
application:
name: search-service
data:
elasticsearch:
cluster-name: elasticsearch
cluster-nodes: 192.168.1.99:9300
eureka:
client:
service-url:
defaultZone: http://127.0.0.1:10086/eureka
instance:
lease-renewal-interval-in-seconds: 5 # 每隔5秒发送一次心跳
lease-expiration-duration-in-seconds: 10 # 10秒不发送就过期
添加引导类
@SpringBootApplication
@EnableFeignClients
@EnableDiscoveryClient
public class LeyouSearchAppliction {
public static void main(String[] args) {
SpringApplication.run(LeyouSearchAppliction.class);
}
}
1.2.索引库数据格式分析
接下来,我们需要商品数据导入索引库,便于用户搜索。
那么问题来了,我们有SPU和SKU,到底如何保存到索引库?
1.2.1.以结果为导向
因为要用搜索引擎去搜索
这些过滤条件也都需要存储到索引库中,包括:
商品分类、品牌、可用来搜索的规格参数等
综上所述,我们需要的数据格式有:
spuId、SkuId、商品分类id、品牌id、图片、价格、商品的创建时间、sku信息集、可搜索的规格参数
1.2.3.最终的数据结构
我们创建一个类,封装要保存到索引库的数据,并设置映射属性:
@Document(indexName = "goods", type = "docs", shards = 1, replicas = 0)
public class Goods {
@Id
private Long id; // spuId
@Field(type = FieldType.Text, analyzer = "ik_max_word")
private String all; // 所有需要被搜索的信息,包含标题,分类,甚至品牌
@Field(type = FieldType.Keyword, index = false)
private String subTitle;// 卖点
private Long brandId;// 品牌id
private Long cid1;// 1级分类id
private Long cid2;// 2级分类id
private Long cid3;// 3级分类id
private Date createTime;// 创建时间
private List<Long> price;// 价格
@Field(type = FieldType.Keyword, index = false)
private String skus;// List<sku>信息的json结构
private Map<String, Object> specs;// 可搜索的规格参数,key是参数名,值是参数值
}
一些特殊字段解释:
-
all:用来进行全文检索的字段,里面包含标题、商品分类信息
-
price:价格数组,是所有sku的价格集合。方便根据价格进行筛选过滤
-
skus:用于页面展示的sku信息,不索引,不搜索。包含skuId、image、price、title字段
-
specs:所有规格参数的集合。key是参数名,值是参数值。
例如:我们在specs中存储 内存:4G,6G,颜色为红色,转为json就是:
{ "specs":{ "内存":[4G,6G], "颜色":"红色" } }
当存储到索引库时,elasticsearch会处理为两个字段:
-
specs.内存:[4G,6G]
-
specs.颜色:红色
另外, 对于字符串类型,还会额外存储一个字段,这个字段不会分词,用作聚合。
-
specs.颜色.keyword:红色
-
接下来就是创建微服务接口:创建微服务接口的时候要创建远程的feign客户端进行远程调用,首先创建API接口
BrandApi
@RequestMapping("brand")
public interface BrandApi {
/**
* 根据bid查询品牌名称
*/
@GetMapping("{id}")
public String queryNameById(@PathVariable("id")Long id);
}
CategoryApi
@RequestMapping("category")
public interface CategoryApi {
/**
* 根据ids查询分类名称
*/
@GetMapping
public List<String> queryNameByids(@RequestParam("ids")List<Long> ids);
}
GoodsApi
@RequestMapping("/goods")
public interface GoodsApi {
@RequestMapping("spu/page")
public PageResult<SpuBo> queryGoods(
@RequestParam(value = "key",required = false) String key,
@RequestParam(value = "saleable",required = false) String saleable,
@RequestParam(value = "page",defaultValue = "1") Integer page,
@RequestParam(value = "rows",defaultValue = "5") Integer rows
);
@GetMapping("spu/detail/{spuId}")
public SpuDetail queryBySpuId(@PathVariable("spuId")Long spuId);
@GetMapping("sku/list")
public List<Sku> querBySpu(@RequestParam("id")Long spuId);
}
SpecificationApi
@RequestMapping("spec")
public interface SpecificationApi {
@GetMapping("groups/{cid}")
public List<SpecGroup> querySpecGroyo(@PathVariable("cid") Long cid);
@GetMapping("params")
public List<SpecParam> querySpecParam(@RequestParam(value = "gid",required = false) Long gid,
@RequestParam(value = "cid",required = false)Long cid,
@RequestParam(value = "generic",required = false)Boolean generic,
@RequestParam(value = "searching",required = false)Boolean searching);
}
接下来就是在对应的Search微服务中创建对应的FeignClinet进行远程的调用,直接继承该接口就可以了,
BrandClient
@FeignClient(value = "item-service")
public interface BrandClient extends BrandApi {
}
CategoryClient
@FeignClient(value = "item-service")
public interface CategoryClient extends CategoryApi {
}
SpecificationClient
@FeignClient(value = "item-service")
public interface SpecificationClient extends SpecificationApi {
}
GoodsClient
@FeignClient(value = "item-service")
public interface GoodsClient extends GoodsApi {
}
接下来就是进行远程接口调用的测试,进行一个Test类的编写测试
测试成功:这个测试的时候要把相应的微服务全部启动起来才可以
接下来这个从Spu构建为Goods的模型比较难,业务逻辑比较复杂,但是拆开来看的话还是容易拆的
1,首先要把Spu构建成goods,因为传过来的参数是Spu,固可以得到spu里面的一些属性和字段,这个里面比较难处理的就是Sku和规格参数,其中这个地方处理Sku的地方是很巧妙的,因为一个Spu下面可以有多个sku,而多个Sku又有多个价格,这个就涉及到一个Spu下也存在多个价格的可能,解决的办法:
①针对一个Spu有多个Sku的解决方案,首先定义一个prices的集合,用来存放多个sku的价格,在其次,可以根据spu的Id查询出Sku的集合,将该单个Sku转换为map,统一存放到一个skuListMap中,这样在相应goods的时候,在把这个map的key,value结构在转换成json字符串响应出去,前台就可以解析处理了。
②规格参数的的特殊参数和通用参数,这个处理起来也是比较绕的,因为规格参数在SpuDetail这个 表里,而根据spuid查询出来的规格参数,只有能获取规格参数名字,而不能获取到规格参数的值,这个处理的办法,首先将SpuDetail进行Map反序列化处理,这样就可以得到kv结构,可以根据paramId,拿到对应的规格参数值,在定义一个paramMap,因为规格参数通过SpuId可以查询处理,得到一个集合,在遍历集合,将规格参数名,通过paramId,可以获取到parm的名字,而spuDetail里面的规格参数值,也可以通过paramId获取到,这样就可以用paramID作为中介,将规格参数名,和规格参数值,结合到一起了:
public Goods goodsBuilds(Spu spu) throws IOException {
//创建要返回的Goods对象 第一步必须要创建Goods对象这个后面是为了给Goods对象里面设置属性及其相应的值
Goods goods=new Goods();
//查询品牌 根据Spu的ID去查询出来对应的品牌
Brand brand = this.brandClient.queryNameById(spu.getBrandId());
//查询分类 根据Spu的ID去查询出来对应的分类名称
List<String> categoryList = this.categoryClient.queryNameByids(Arrays.asList(spu.getCid1(), spu.getCid2(), spu.getCid3()));
//查询SPu下的所有 Sku 根据SpuID去查询出来对应的下属的Sku的集合
List<Sku> skus = this.goodsClient.querBySpu(spu.getId());
//因为一个Spu下面可能有多个Sku,所以多个Sku,就可能有多个价格,定义价格price
List<Long> prices=new ArrayList<>();
//定义一个SkuMpa的集合,目的是把sku转成对应的固定的四个属性,之后在同一放到一起
List<Map<String,Object>> skuMapList=new ArrayList<>();
//遍历每个skus
skus.forEach(sku -> {
//把每个sku的价格放进去
prices.add(sku.getPrice());
//开始把每个Sku转换成Map
Map<String,Object> skuMap=new HashMap<>();
skuMap.put("id",sku.getId());
skuMap.put("title",sku.getTitle());
skuMap.put("price",sku.getPrice());
skuMap.put("image", StringUtils.isNotBlank(sku.getImages())?StringUtils.split(sku.getImages(),",")[0]:"");
skuMapList.add(skuMap);
});
//查询出所有的规格参数
List<SpecParam> specParamList = this.specificationClient.querySpecParam(null, spu.getCid3(), null, true);
//查询出所有的SpuDetail,获取规格参数值
SpuDetail spuDetail = this.goodsClient.queryBySpuId(spu.getId());
//获取通用的规格参数反序列化为Map对象 将获取的通用规格参数,反序为Map对象
Map<Long,Object> genericScap=MAPPER.readValue(spuDetail.getGenericSpec(),new TypeReference<Map<Long,Object>>(){});
//获取特殊规格参数反序列化为Map对象
Map<Long,Object> specialSpecMap=MAPPER.readValue(spuDetail.getSpecialSpec(),new TypeReference<Map<Long,List<Object>>>(){});
//定义map接收{规格参数名,规格参数值}
Map<String,Object> paramap=new HashMap<>();
specParamList.forEach(specParam -> {
//检验是否是通用的规格参数
if (specParam.getGeneric()){
String value = genericScap.get(specParam.getId()).toString();
//判断是否是数值类型
if (specParam.getNumeric()){
//如果是数值类型,判断落在那个区间
value=chooseSegment(value,specParam);
}
paramap.put(specParam.getName(),value);
}else{
paramap.put(specParam.getName(),specialSpecMap.get(specParam.getId()));
}
});
//设置参数
goods.setId(spu.getId());
goods.setCid1(spu.getCid1());
goods.setCid2(spu.getCid2());
goods.setCid3(spu.getCid3());
goods.setBrandId(spu.getBrandId());
goods.setCreateTime(spu.getCreateTime());
goods.setSubTitle(spu.getSubTitle());
goods.setAll(spu.getTitle()+brand.getName()+StringUtils.join(categoryList," "));
goods.setPrice(prices);
goods.setSkus(MAPPER.writeValueAsString(skuMapList));
goods.setSpecs(paramap);
return goods;
}
private String chooseSegment(String value, SpecParam p) {
double val = NumberUtils.toDouble(value);
String result = "其它";
// 保存数值段
for (String segment : p.getSegments().split(",")) {
String[] segs = segment.split("-");
// 获取数值范围
double begin = NumberUtils.toDouble(segs[0]);
double end = Double.MAX_VALUE;
if(segs.length == 2){
end = NumberUtils.toDouble(segs[1]);
}
// 判断是否在范围内
if(val >= begin && val < end){
if(segs.length == 1){
result = segs[0] + p.getUnit() + "以上";
}else if(begin == 0){
result = segs[1] + p.getUnit() + "以下";
}else{
result = segment + p.getUnit();
}
break;
}
}
return result;
}
开始构建模型:
@Test
public void TestContext(){
this.elasticsearchTemplate.createIndex(Goods.class);
this.elasticsearchTemplate.putMapping(Goods.class);
Integer page=1;
Integer rows=100;
do {
PageResult<SpuBo> pageResult = this.goodsClient.queryGoods(null, true, page, rows);
List<Goods> goodsList = pageResult.getItems().stream().map(spuBo -> {
try {
return this.searchService.goodsBuilds(spuBo);
} catch (IOException e) {
e.printStackTrace();
}
return null;
}).collect(Collectors.toList());
this.goodsRepository.saveAll(goodsList);
rows=pageResult.getItems().size();
page++;
}while (rows==100);
}
构建模型成功:
2.实现基本搜索
2.1.页面分析
2.1.1.页面跳转
在首页的顶部,有一个输入框:
前台页面的AJAX发生的请求:
var vm = new Vue({
el: "#searchApp",
data: {
search:{
key:"", // 搜索页面的关键字
},
goodList:[]
}, created() {
// 判断是否有请求参数
if (!location.search) {
return;
}
// 将请求参数转为对象
const search = ly.parse(location.search.substring(1));
// 记录在data的search对象中
this.search = search;
// 发起请求,根据条件搜索
this.loadData();
},
methods: {
loadData(){
// ly.http.post("/search/page", ly.stringify(this.search)).then(resp=>{
ly.http.post("/search/page", this.search).then(resp=>{
console.log(resp);
});
}
},
components:{
lyTop: () => import("./js/pages/top.js")
}
});
检查:
开始编写后台接口:
Controller:
@RestController
@RequestMapping
public class SearchController {
@Autowired
private SearchService searchService;
@PostMapping("page")
public ResponseEntity<PageResult<Goods>> queryByPage(@RequestBody SearchRequest searchRequest){
PageResult<Goods> goodsList= this.searchService.queryByPage(searchRequest);
if (goodsList.getItems().size()==0|| CollectionUtils.isEmpty(goodsList.getItems())){
return ResponseEntity.badRequest().build();
}
return ResponseEntity.ok(goodsList);
}
}
Service:
public PageResult<Goods> queryByPage(SearchRequest searchRequest) {
// 判断是否有搜索条件,如果没有,直接返回null。不允许搜索全部商品
String key = searchRequest.getKey();
if (StringUtils.isBlank(key)){
return null;
}
NativeSearchQueryBuilder queryBuilder=new NativeSearchQueryBuilder();
// 1、对key进行全文检索查询
queryBuilder.withQuery(QueryBuilders.matchQuery("all",key).operator(Operator.AND));
// 2、通过sourceFilter设置返回的结果字段,我们只需要id、skus、subTitle
queryBuilder.withSourceFilter(new FetchSourceFilter(new String[]{"id","skus","subTitle"},null));
// 3、分页准备分页参数
Integer page = searchRequest.getPage();
Integer size = searchRequest.getSize();
queryBuilder.withPageable(PageRequest.of(page-1,size));
// 4、查询,获取结果
Page<Goods> goods = this.goodsRepository.search(queryBuilder.build());
// 5.封装结果并返回
return new PageResult<>(goods.getTotalElements(),goods.getTotalPages(),goods.getContent());
}
}
完成测试: