乐优商城来到了搜索功能,这个功能很难。。。。。。。。。。。。其实没什么时间写博客,课程的内容太多了,但是感觉写博客可以让自己感觉到清醒,所以还是得写
我们的工具是kibana。索引库数据统计工具
Kibana是一个基于Node.js的Elasticsearch索引库数据统计工具,可以利用Elasticsearch的聚合功能,生成各种图表,如柱形图,线状图,饼图等。
rest风格的Api
kibana的操作
查询索引库 GET /goods
查看索引库是否存在 HEAD goods
查看映射关系 GET /goods/_mapping
字段的类型
text:可分词,不可参与聚合
keyword:不可分词,数据会作为完整字段进行匹配,可以参与聚合
Numerical:数值类型,分两类
基本数据类型:long、interger、short、byte、double、float、half_float
浮点数的高精度类型:scaled_float
需要指定一个精度因子,比如10或100。elasticsearch会把真实值乘以这个因子后存储,取出时再还原。
Date:日期类型
elasticsearch可以对日期格式化为字符串存储,但是建议我们存储为毫秒值,存储为long,节省空间。
2)index
index影响字段的索引情况。
true:字段会被索引,则可以用来进行搜索。默认值就是true
false:字段不会被索引,不能用来搜索
index的默认值就是true,也就是说你不进行任何配置,所有字段都会被索引。
但是有些字段是我们不希望被索引的,比如商品的图片信息,就需要手动设置index为false。
3)store
是否将数据进行额外存储。
在学习lucene和solr时,我们知道如果一个字段的store设置为false,那么在文档列表中就不会有这个字段的值,用户的搜索结果中不会显示出来。
但是在Elasticsearch中,即便store设置为false,也可以搜索到结果。
原因是Elasticsearch在创建文档索引时,会将文档中的原始数据备份,保存到一个叫做_source
的属性中。而且我们可以通过过滤_source
来选择哪些要显示,哪些不显示。
而如果设置store为true,就会在_source
以外额外存储一份数据,多余,因此一般我们都会将store设置为false,事实上,
接下来是重头戏,索引库的查询1.查询所有
GET /heima/_search{
"query":{
"match_all": {}
}
}
2.or关系的查询
GET /heima/_search
{
"query":{
"match":{
"title":"小米电视"
}
}
}
3.And关系的查询GET /goods/_search
{
"query":{
"match":{
"title":{"query":"小米电视","operator":"and"}
}
}
}
4.最小匹配数查询GET /heima/_search
{
"query":{
"match":{
"title":{
"query":"小米曲面电视",
"minimum_should_match": "75%"
}
}
}
}
5.多字段查询GET /heima/_search
{
"query":{
"multi_match": {
"query": "小米",
"fields": [ "title", "subTitle" ]
}
}
}
词条匹配(term精确值查询)1.单词条
GET /heima/_search{
"query":{
"term":{
"price":2699.00
}
}
}
2.多词条匹配
{
"query":{
"terms":{
"price":[2699.00,2899.00,3899.00]
}
}
}
过滤,得到想要的字段GET /heima/_search
{
"_source": ["title","price"],
"query": {
"term": {
"price": 2699
}
}
}
高级查询1.bool查询
GET /heima/_search{
"query":{
"bool":{
"must": { "match": { "title": "大米" }},
"must_not": { "match": { "title": "电视" }},
"should": { "match": { "title": "手机" }}
}
}
}
2.范围查询GET /heima/_search
{
"query":{
"range": {
"price": {
"gte": 1000.0,
"lt": 2800.00
}
}
}
}
3.模糊查询GET /heima/_search
{
"query": {
"fuzzy": {
"title": "appla"
}
}
}
过滤
查询条件的过滤
{
"query":{
"constant_score": {
"filter": {
"range":{"price":{"gt":2000.00,"lt":3000.00}}
}
}
}
度量,分桶后求价格的平均值get /goods/_search
{
"size":0,
"aggs":{
"category":{
"terms":{
"field":"brandId"
},
"aggs": {
"avg_price": {
"avg": {
"field": "price"
}
}
}
}
}
桶内嵌套桶get /goods/_search
{
"size":0,
"aggs":{
"category":{
"terms":{
"field":"brandId"
},
"aggs": {
"avg_price": {
"avg": {
"field": "price"
}
},
"rand":{
"terms": {
"field": "specs.主屏幕尺寸(英寸)",
"size": 10
}
}
}
}
}
阶梯分桶
GET /goods/_search
{
"size": 0,
"aggs": {
"price": {
"histogram": {
"field": "price",
"interval": 50000,
"min_doc_count": 1
}
}
}
}
基本的语法完成后,我们需要在java中操作了
我们不选择使用 elasticSearch提供的客户端,而使用sping data elasticSearch,因为原生的需要频繁的json操作。
这一块我的博客写在第十二天。
接下来就是搜索的微服务首先是索引类
@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;// sku信息的json结构 private Map<String, Object> specs;// 可搜索的规格参数,key是参数名,值是参数值我们
我们用一个测试类,将数据写入索引库
@Test public void add() { int page = 1; int rows = 100; //数据库中的每次查出的记录数 int size = 0; //目标,填充Goods中的数据 do { //分页查询所有数据 ResponseEntity<PageResult<SpuBo>> pageResultResponseEntity = spuClient.querySpuByPage(null, page, rows, 1); if (pageResultResponseEntity.getBody()==null) { return; } PageResult<SpuBo> pageResult = pageResultResponseEntity.getBody(); List<SpuBo> items = pageResult.getItems(); List<Goods> goodsList = new ArrayList<>(); for (SpuBo spu : items) { //创建一个goods对象 Goods goods = new Goods(); Long id = spu.getId(); Long cid1 = spu.getCid1(); Long cid2 = spu.getCid2(); Long cid3 = spu.getCid3(); ResponseEntity<SpuDetail> spuDetailResponseEntity = goodsClient.querySpuDetailById(id); ResponseEntity<List<Sku>> listResponseEntity = goodsClient.querySkuList(spu.getId()); ResponseEntity<List<String>> categoryNames = categoryClient.queryCategoryNamesBycids(Arrays.asList(cid1, cid2, cid3)); if (!spuDetailResponseEntity.hasBody()||!listResponseEntity.hasBody()||!categoryNames.hasBody()){ break; } //查询spuDetail SpuDetail spuDetail = spuDetailResponseEntity.getBody(); //将可搜索的属性导入 String specifications = spuDetail.getSpecifications(); //将字符串转为对象 List<Map<String, Object>> maps = JsonUtils.nativeRead(specifications, new TypeReference<List<Map<String, Object>>>() { }); //map用来存储可搜索属性 Map<String, Object> specMap = new HashMap<>(); for (Map<String, Object> map : maps) { List<Map<String,Object>> paramsList = (List<Map<String, Object>>) map.get("params"); for (Map<String, Object> paramsMap : paramsList) { Boolean searchable = (Boolean) paramsMap.get("searchable"); if (searchable){ if (paramsMap.get("v")!=null) specMap.put((String) paramsMap.get("k"),paramsMap.get("v")); else if (paramsMap.get("options")!=null) specMap.put((String) paramsMap.get("k"),paramsMap.get("options")); } } } //获取sku的信息 List<Sku> skuList = listResponseEntity.getBody(); //sku的信息是一个json对象,里面有很多对象 List<Map<String,Object>> skuData = new ArrayList<>(); //准备价格的集合,价格不能重复 HashSet<Long> prices = new HashSet<>(); for (Sku sku : skuList) { Map<String, Object> map = new HashMap<>(); map.put("id",sku.getId()); map.put("title",sku.getTitle()); map.put("image", StringUtils.isBlank(sku.getImages())?"":sku.getImages().split(",")[0]); map.put("price",sku.getPrice()); prices.add(sku.getPrice()); skuData.add(map); } //将sku的集合转为json String skuDatas = JsonUtils.serialize(skuData); //查询分类的集合 List<String> categoryNamesBody = categoryNames.getBody(); goods.setSubTitle(spu.getSubTitle()); goods.setSpecs(specMap); goods.setSkus(skuDatas); goods.setPrice(new ArrayList<>(prices)); goods.setAll(spu.getTitle()+StringUtils.join(categoryNamesBody," "));//todo goods.setBrandId(spu.getBrandId()); goods.setCreateTime(spu.getCreateTime()); goods.setId(spu.getId()); goods.setCid1(cid1); goods.setCid2(cid2); goods.setCid3(cid3); goodsList.add(goods); } goodsRepository.saveAll(goodsList); //pageResult的gettotal是每页显示的条数,list。size是个数 size = items.size();//TODO赋值 //本页完成后,查询下一页的数据 page++; } while (size == 100); }
接下来就是搜索的方法,当用户点击搜索的时候,会向后端发送一个请求
我们准备好接收请求的实体类
public class SearchRequest { private String key;// 搜索条件 private Integer page;// 当前页 private String sortBy;//排序条件 private Boolean descending;//升序降序 private Map<String,String> filter;//过滤条件
然后是service层的search方法
public SearchResult<Goods> search(SearchRequest request) { // 创建查询构建器 NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder(); // 1、构建查询条件 // 1.1.对搜索的结果进行过滤 queryBuilder.withSourceFilter(new FetchSourceFilter(new String[]{"id", "subTitle", "skus"}, null)); // 1.2.基本查询 QueryBuilder basicQuery = this.buildBasicQueryWithFilter(request); queryBuilder.withQuery(basicQuery); // 1.3、分页 queryBuilder.withPageable(PageRequest.of(request.getPage() - 1, request.getSize())); // 1.4、聚合 // 对分类聚合 String categoryAggName = "categoryAgg"; String brandAggName = "brandAgg"; queryBuilder.addAggregation(AggregationBuilders.terms(categoryAggName).field("cid3")); queryBuilder.addAggregation(AggregationBuilders.terms(brandAggName).field("brandId")); // 2、查询 AggregatedPage<Goods> aggResult = (AggregatedPage<Goods>) this.goodsRepository.search(queryBuilder.build()); // 3、解析结果: // 3.1、总条数和总页数 long total = aggResult.getTotalElements(); long totalPage = (total + request.getSize() - 1) / request.getSize(); // 3.2、解析商品分类 List<Category> categories = getCategoryAgg(aggResult, categoryAggName); // 3.3、解析品牌 List<Brand> brands = getBrandAgg(aggResult, brandAggName); // 3.4、处理规格参数 List<Map<String, Object>> specs = null; if (categories.size() == 1) { specs = getSpecifications(categories.get(0).getId(), basicQuery); } return new SearchResult<>(total, totalPage, aggResult.getContent(), categories, brands, specs); }
查询分为3个步骤,1.构建查询条件 2.查询 2.解析结果
一.构建查询条件
1.基本条件的构建
// 构建基本查询条件 private QueryBuilder buildBasicQueryWithFilter(SearchRequest request) { BoolQueryBuilder queryBuilder = QueryBuilders.boolQuery(); // 基本查询条件 queryBuilder.must(QueryBuilders.matchQuery("all", request.getKey()).operator(Operator.AND)); // 过滤条件构建器 BoolQueryBuilder filterQueryBuilder = QueryBuilders.boolQuery(); // 整理过滤条件 Map<String, String> filter = request.getFilter(); for (Map.Entry<String, String> entry : filter.entrySet()) { // 判断是否是数值类型 String key = entry.getKey(); String value = entry.getValue(); String regex = "^(\\d+\\.?\\d*)-(\\d+\\.?\\d*)$"; if (value.matches(regex)) { Double[] nums = NumberUtils.searchNumber(value, regex); // 数值类型进行范围查询 filterQueryBuilder.must(QueryBuilders.rangeQuery("specs." + key).gte(nums[0]).lt(nums[1])); } else { if (key != "cid3" && key != "brandId") { key = "specs." + key + ".keyword"; } // 字符串类型,进行term查询 filterQueryBuilder.must(QueryBuilders.termQuery(key, value)); } } // 添加过滤条件 queryBuilder.filter(filterQueryBuilder); return queryBuilder; }
这里将关键词与过滤条件结合在了一起
,然后,我们还需要加一个分页的条件,因为我们的品牌和分类是一定显示的,所以这里还要加上品牌和分类
,所以要对分类进行聚合,然后就可以查询了,查询完成,会得到一个聚合后的结果
三.解析查询结果
对聚合后的结果进行解析解析品牌
private List<Brand> getBrandAgg(AggregatedPage<Goods> aggResult, String brandAggName) { LongTerms terms = (LongTerms) aggResult.getAggregation(brandAggName); List<Long> ids = new ArrayList<>(); for (LongTerms.Bucket bucket : terms.getBuckets()) { ids.add(bucket.getKeyAsNumber().longValue()); } ResponseEntity<List<Brand>> resp = this.brandsClient.queryBrandsByBrandIds(ids); if (resp.hasBody()) { return resp.getBody(); } return null; }
解析分类
private List<Category> getCategoryAgg(AggregatedPage<Goods> aggResult, String categoryAggName) { LongTerms terms = (LongTerms) aggResult.getAggregation(categoryAggName); List<Long> ids = new ArrayList<>(); for (LongTerms.Bucket bucket : terms.getBuckets()) { ids.add(bucket.getKeyAsNumber().longValue()); } // 获取分类名称 ResponseEntity<List<String>> resp = this.categoryClient.queryCategoryNamesBycids(ids); if (!resp.hasBody()) { return null; } List<String> names = resp.getBody(); List<Category> categories = new ArrayList<>(); for (int i = 0; i < ids.size(); i++) { Category c = new Category(); c.setId(ids.get(i)); c.setName(names.get(i)); categories.add(c); } return categories; }
如果分类只有一个的话,我们还需要对其下的过滤条件进行聚合
private List<Map<String, Object>> getSpecifications(Long id, QueryBuilder basicQuery) { // 1、根据分类查询规格 ResponseEntity<String> specResp = this.specificationClient.querySpecificationsBycid(id); if (!specResp.hasBody()) { logger.error("查询规格参数出错,cid={}", id); return null; } String jsonSpec = specResp.getBody(); // 将规格反序列化为集合 List<Map<String, Object>> specs = null; try { specs = JsonUtils.nativeRead(jsonSpec, new TypeReference<List<Map<String, Object>>>() { }); } catch (Exception e) { logger.error("解析规格参数json出错,json={}", jsonSpec, e); return null; } // 2、过滤出可以搜索的哪些规格参数的名称,分成数值类型、字符串类型 // 准备集合,保存字符串规格参数名 Set<String> strSpec = new HashSet<>(); // 准备map,保存数值规格参数名及单位 Map<String, String> numericalUnits = new HashMap<>(); // 解析规格 for (Map<String, Object> spec : specs) { List<Map<String, Object>> params = (List<Map<String, Object>>) spec.get("params"); for (Map<String, Object> param : params) { Boolean searchable = (Boolean) param.get("searchable"); if (searchable) { // 判断是否是数值类型 if (param.containsKey("numerical") && (boolean) param.get("numerical")) { numericalUnits.put(param.get("k").toString(), param.get("unit").toString()); } else { strSpec.add(param.get("k").toString()); } } } } // 3、聚合计算数值类型的interval Map<String, Double> numericalInterval = getNumericalInterval(id, numericalUnits.keySet()); // 4、利用interval聚合计算数值类型的分段 // 5、对字符串类型的参数进行聚合 return this.aggForSpec(strSpec, numericalInterval, numericalUnits, basicQuery); }根据传过来的spu的id查询规格模版,拿到其中的课搜索属性,再对其中的可搜索属性进行判断,判断其是数值类型还是字符串类型,如果是数值类型,我们后续要阶梯分桶,如果是字符串,我们直接关键字分桶,最后,将解析的结果,做处理,处理成前端想要的格式,比如,数值类型的格式是100-200
细节如下
1.首先是查询规格模版的方法
private List<Map<String, Object>> getSpecifications(Long id, QueryBuilder basicQuery) { // 1、根据分类查询规格 ResponseEntity<String> specResp = this.specificationClient.querySpecificationsBycid(id); if (!specResp.hasBody()) { logger.error("查询规格参数出错,cid={}", id); return null; } String jsonSpec = specResp.getBody(); // 将规格反序列化为集合 List<Map<String, Object>> specs = null; try { specs = JsonUtils.nativeRead(jsonSpec, new TypeReference<List<Map<String, Object>>>() { }); } catch (Exception e) { logger.error("解析规格参数json出错,json={}", jsonSpec, e); return null; }
根据数值类型的key,对数值类型的结果做度量
// 聚合得到interval private Map<String, Double> getNumericalInterval(Long cid, Set<String> keySet) { Map<String, Double> numbericalSpecs = new HashMap<>(); // 准备查询条件 NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder(); // 不查询任何数据 queryBuilder.withQuery(QueryBuilders.termQuery("cid3", cid.toString())) .withSourceFilter(new FetchSourceFilter(new String[]{""}, null)) .withPageable(PageRequest.of(0, 1)); // 添加stats类型的聚合 for (String key : keySet) { queryBuilder.addAggregation(AggregationBuilders.stats(key).field("specs." + key)); } Map<String, Aggregation> aggs = this.template.query(queryBuilder.build(), new ResultsExtractor<Map<String, Aggregation>>() { @Override public Map<String, Aggregation> extract(SearchResponse response) { return response.getAggregations().asMap(); } }); for (String key : keySet) { InternalStats stats = (InternalStats) aggs.get(key); double interval = this.getInterval(stats.getMin(), stats.getMax(), stats.getSum()); numbericalSpecs.put(key, interval); } return numbericalSpecs; }所有条件准备好了,最终解析
// 根据规格参数,聚合得出过滤条件 private List<Map<String, Object>> aggForSpec(Set<String> strSpec, Map<String, Double> numericalInterval, Map<String, String> numericalUnits, QueryBuilder query) { List<Map<String, Object>> specs = new ArrayList<>(); // 准备查询条件 NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder(); queryBuilder.withQuery(query); // 聚合数值类型 for (Map.Entry<String, Double> entry : numericalInterval.entrySet()) { queryBuilder.addAggregation( AggregationBuilders.histogram(entry.getKey()) .field("specs." + entry.getKey()) .interval(entry.getValue()) .minDocCount(1) ); } // 聚合字符串 for (String key : strSpec) { queryBuilder.addAggregation( AggregationBuilders.terms(key).field("specs." + key + ".keyword")); } // 解析聚合结果 Map<String, Aggregation> aggs = this.template.query( queryBuilder.build(),SearchResponse::getAggregations).asMap(); // 解析数值类型 for (Map.Entry<String, Double> entry : numericalInterval.entrySet()) { Map<String, Object> spec = new HashMap<>(); String key = entry.getKey(); spec.put("k", key); spec.put("unit", numericalUnits.get(key)); // 获取聚合结果 InternalHistogram histogram = (InternalHistogram) aggs.get(key); spec.put("options", histogram.getBuckets().stream().map(bucket -> { Double begin = (double) bucket.getKey(); Double end = begin + numericalInterval.get(key); // 对begin和end取整 if (NumberUtils.isInt(begin) && NumberUtils.isInt(end)) { // 确实是整数,需要取整 return begin.intValue() + "-" + end.intValue(); } else { // 小数,取2位小数 begin = NumberUtils.scale(begin, 2); end = NumberUtils.scale(end, 2); return begin + "-" + end; } })); specs.add(spec); } // 解析字符串类型 strSpec.forEach(key -> { Map<String, Object> spec = new HashMap<>(); spec.put("k", key); StringTerms terms = (StringTerms) aggs.get(key); spec.put("options", terms.getBuckets().stream().map(bucket -> bucket.getKeyAsString())); specs.add(spec); }); return specs; }