SpringCloud使用ElasticSearch
搜索微服務模塊結構
配置文件
-
pom.xml----->elasticsearch依賴
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-elasticsearch</artifactId> </dependency>
-
pom.xml----->該搜索微服務全部依賴
<?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>ly-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 因爲每一個微服務都是獨立的, 所以我們這裏要用feign來遠程調用商品微服務 --> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-openfeign</artifactId> </dependency> <!-- 測試 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> </dependency> <!-- 需要用到商品實體類, 所以這裏需要引用 --> <dependency> <groupId>com.leyou.upload.service</groupId> <artifactId>ly-item-interface</artifactId> <version>1.0.0-SNAPSHOT</version> </dependency> </dependencies> </project>
-
application.yml----->配置elasticsearch
data: elasticsearch: cluster-name: elasticsearch # 集羣名稱 cluster-nodes: 192.168.79.128:9300 # 集羣地址
-
application.yml----->該搜索微服務全部配置
server: port: 8084 spring: application: name: search-service data: elasticsearch: cluster-name: elasticsearch # 集羣名稱 cluster-nodes: 192.168.79.128:9300 # 集羣地址 Jackson: default-property-inclusion: non_null # 返回的結果是null的就排除 eureka: client: service-url: defaultZone: http://127.0.0.1:10086/eureka registry-fetch-interval-seconds: 5 # 每5秒拉一次註冊信息 instance: prefer-ip-address: true ip-address: 127.0.0.1
啓動類
-
LySearchApplication
package com.leyou.search; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cloud.client.discovery.EnableDiscoveryClient; import org.springframework.cloud.openfeign.EnableFeignClients; /** * @program: leyou * @description: * @author: Mr.Xiao * @create: 2020-06-11 14:39 **/ @SpringBootApplication @EnableDiscoveryClient // eureka @EnableFeignClients // feign public class LySearchApplication { public static void main(String[] args) { SpringApplication.run(LySearchApplication.class); } }
實體類
-
Goods
package com.leyou.search.pojo; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import org.springframework.data.annotation.Id; import org.springframework.data.elasticsearch.annotations.Document; import org.springframework.data.elasticsearch.annotations.Field; import org.springframework.data.elasticsearch.annotations.FieldType; import java.util.Date; import java.util.List; import java.util.Map; import java.util.Set; @NoArgsConstructor @AllArgsConstructor @Data @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;// 賣點 // 過濾字段 不加註解, spring會自動映射進去 private Long brandId;// 品牌id private Long cid1;// 1級分類id private Long cid2;// 2級分類id private Long cid3;// 3級分類id private Date createTime;// 創建時間 private Set<Long> price;// 價格 @Field(type = FieldType.Keyword, index = false) private String skus;// sku信息的json結構 private Map<String, Object> specs;// 可搜索的規格參數,key是參數名,值是參數值 }
-
SearchRequest
package com.leyou.search.pojo; import lombok.Data; import org.bouncycastle.jcajce.provider.symmetric.IDEA; import java.util.Map; /** * @program: leyou * @description: * @author: Mr.Xiao * @create: 2020-06-12 17:49 **/ public class SearchRequest { // 當前頁碼 private Integer page; // 搜索字段 private String key; // 因爲是給用戶看的頁面, 所以每頁大小必須定死, 不可修改, 要是給用戶輸入的話如果是1千萬那不搜索服務器直接掛了 private static final int DEFAULT_PAGE = 1; private static final int DEFAULT_SIZE = 20; // 過濾字段 private Map<String, String> filter; public Integer getPage() { if (page == null) { return DEFAULT_PAGE; } // 比較兩個數的大小, page 大於 DEFAULT_PAGE用page, 小於DEFAULT_PAGE用DEFAULT_PAGE return Math.max(DEFAULT_PAGE, page); } public void setPage(Integer page) { this.page = page; } public String getKey() { return key; } public void setKey(String key) { this.key = key; } public int getSize() { return DEFAULT_SIZE; } public Map<String, String> getFilter() { return filter; } public void setFilter(Map<String, String> filter) { this.filter = filter; } }
-
SearchResult
package com.leyou.search.pojo; import com.leyou.common.vo.PageResult; import com.leyou.item.pojo.Brand; import com.leyou.item.pojo.Category; import lombok.Data; import java.util.List; import java.util.Map; @Data public class SearchResult extends PageResult<Goods> { private List<Category> categories; // 分類待選項 private List<Brand> brands; // 品牌待選項 private List<Map<String, Object>> specs; // 規格參數 key及待選項 public SearchResult() { } public SearchResult(Long total, Long totalPage, List<Goods> items, List<Category> categories, List<Brand> brands, List<Map<String, Object>> specs) { super(total, totalPage, items); this.categories = categories; this.brands = brands; this.specs = specs; } }
client---->繼承的是別的微服務下的接口, 接口中提供了對應的可以遠程調用的方法
-
BrandClient
package com.leyou.search.client; import com.leyou.item.api.BrandApi; import org.springframework.cloud.openfeign.FeignClient; /** * @program: leyou * @description: 告訴feign 請求服務 請求方式 請求路徑 請求參數 返回結果 * @author: Mr.Xiao * @create: 2020-06-11 17:00 **/ @FeignClient("item-service") // 參數是服務名稱 public interface BrandClient extends BrandApi { }
-
CategoryClient
package com.leyou.search.client; import com.leyou.item.api.CategoryApi; import org.springframework.cloud.openfeign.FeignClient; /** * @program: leyou * @description: * @author: Mr.Xiao * @create: 2020-06-11 17:00 **/ @FeignClient("item-service") public interface CategoryClient extends CategoryApi { }
-
GoodsClient
package com.leyou.search.client; import com.leyou.item.api.GoodsApi; import org.springframework.cloud.openfeign.FeignClient; /** * @program: leyou * @description: * @author: Mr.Xiao * @create: 2020-06-11 16:59 **/ @FeignClient("item-service") public interface GoodsClient extends GoodsApi { }
-
SpecificationClient
package com.leyou.search.client; import com.leyou.item.api.SpecificationApi; import org.springframework.cloud.openfeign.FeignClient; /** * @program: leyou * @description: * @author: Mr.Xiao * @create: 2020-06-11 17:01 **/ @FeignClient("item-service") public interface SpecificationClient extends SpecificationApi { }
repository---->(操作ElasticSearch接口, 提供了各種方法)
-
GoodsRepository
package com.leyou.search.repository; import com.leyou.search.pojo.Goods; import org.springframework.data.elasticsearch.repository.ElasticsearchRepository; /** * @program: leyou * @description: ElasticsearchRepository 第一個參數實體類類型, 第二個參數id類型 * @author: Mr.Xiao * @create: 2020-06-12 12:13 **/ // ElasticsearchRepository 跟 通用mapper一樣, 裏面包含了各種增刪改查 public interface GoodsRepository extends ElasticsearchRepository<Goods, Long> { }
Controller
-
SearchController
package com.leyou.search.web; import com.leyou.common.vo.PageResult; import com.leyou.search.pojo.Goods; import com.leyou.search.pojo.SearchRequest; import com.leyou.search.pojo.SearchResult; import com.leyou.search.service.SearchService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; /** * @program: leyou * @description: 搜索服務 表現層 * @author: Mr.Xiao * @create: 2020-06-12 17:57 **/ @RestController public class SearchController { @Autowired private SearchService searchService; @PostMapping("/page") public ResponseEntity<SearchResult> search(@RequestBody SearchRequest request) { return ResponseEntity.ok(searchService.search(request)); } }
Service
-
SearchService接口
package com.leyou.search.service; import com.leyou.common.vo.PageResult; import com.leyou.item.pojo.Spu; import com.leyou.search.pojo.Goods; import com.leyou.search.pojo.SearchRequest; import com.leyou.search.pojo.SearchResult; /** * @program: leyou * @description: * @author: Mr.Xiao * @create: 2020-06-12 09:32 **/ public interface SearchService { /** * 封裝goods對象 * @param spu * @return */ Goods buildGoods(Spu spu); /** * 獲取搜索結果 * @param request * @return */ SearchResult search(SearchRequest request); }
-
SearchServiceImpl實體類
package com.leyou.search.service.impl; import com.fasterxml.jackson.core.type.TypeReference; import com.leyou.common.enums.ExceptionEnum; import com.leyou.common.exception.LyException; import com.leyou.common.utils.JsonUtils; import com.leyou.common.utils.NumberUtils; import com.leyou.common.vo.PageResult; import com.leyou.item.pojo.*; import com.leyou.search.client.BrandClient; import com.leyou.search.client.CategoryClient; import com.leyou.search.client.GoodsClient; import com.leyou.search.client.SpecificationClient; import com.leyou.search.pojo.Goods; import com.leyou.search.pojo.SearchRequest; import com.leyou.search.pojo.SearchResult; import com.leyou.search.repository.GoodsRepository; import com.leyou.search.service.SearchService; import javafx.beans.binding.ObjectExpression; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.bouncycastle.asn1.esf.SPUserNotice; import org.elasticsearch.index.query.BoolQueryBuilder; import org.elasticsearch.index.query.MatchQueryBuilder; import org.elasticsearch.index.query.QueryBuilder; import org.elasticsearch.index.query.QueryBuilders; import org.elasticsearch.search.aggregations.Aggregation; import org.elasticsearch.search.aggregations.AggregationBuilders; import org.elasticsearch.search.aggregations.bucket.terms.LongTerms; import org.elasticsearch.search.aggregations.bucket.terms.StringTerms; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.elasticsearch.core.ElasticsearchTemplate; import org.springframework.data.elasticsearch.core.aggregation.AggregatedPage; import org.springframework.data.elasticsearch.core.query.FetchSourceFilter; import org.springframework.data.elasticsearch.core.query.NativeSearchQueryBuilder; import org.springframework.stereotype.Service; import org.springframework.util.CollectionUtils; import javax.swing.plaf.ScrollPaneUI; import java.util.*; import java.util.stream.Collectors; /** * @program: leyou * @description: * @author: Mr.Xiao * @create: 2020-06-12 09:32 **/ @Slf4j @Service("searchService") public class SearchServiceImpl implements SearchService { @Autowired private BrandClient brandClient; @Autowired private CategoryClient categoryClient; @Autowired private GoodsClient goodsClient; @Autowired private SpecificationClient specificationClient; @Autowired private GoodsRepository goodsRepository; @Autowired private ElasticsearchTemplate template; /** * 封裝goods對象 * @param spu * @return */ @Override public Goods buildGoods(Spu spu) { // 獲取spu id Long spuId = spu.getId(); // 獲取all 所有需要被搜索的信息,包含標題,分類,甚至品牌 String all = ""; // 獲取分類信息 List<Category> categoryList = categoryClient.queryCategoryListByIds(Arrays.asList(spu.getCid1(), spu.getCid2(), spu.getCid3())); if (CollectionUtils.isEmpty(categoryList)) { throw new LyException(ExceptionEnum.CATEGORY_NOT_FOUND); } // 獲取分類名稱 以 空格 隔開 List<String> cnames = categoryList.stream().map(Category::getName).collect(Collectors.toList()); // 獲取品牌 Brand brand = brandClient.queryBrandById(spu.getBrandId()); if (brand == null) { throw new LyException(ExceptionEnum.BRAND_NOT_FOUND); } all = spu.getTitle() + StringUtils.join(cnames, " ") + brand.getName(); // 獲取sku商品信息 List<Sku> skuList = goodsClient.querySkuListBySpuId(spuId); if (CollectionUtils.isEmpty(skuList)) { throw new LyException(ExceptionEnum.GOODS_NOT_FOUND); } // sku 商品集合 ArrayList<Map<String, Object>> skus = new ArrayList<>(); // sku 價格集合 HashSet<Long> prices = new HashSet<>(); // 優於展示字段只是sku中的幾個字段, 所以我們這裏不需要全部字段, 需要做抽離 for (Sku sku : skuList) { HashMap<String, Object> map = new HashMap<>(); map.put("id", sku.getId()); map.put("title", sku.getTitle()); map.put("price", sku.getPrice()); // 獲取第一張圖片信息 map.put("image", StringUtils.substringBefore(sku.getImages(), ",")); // 添加sku商品 skus.add(map); // 添加sku價格 prices.add(sku.getPrice()); } // 規格參數 Map<String, Object> specs = new HashMap<>(); // 獲取存儲規格參數鍵對象 // 可搜索字段, 分類id是3級分類 List<SpecParam> specParams = specificationClient.queryParamByList(null, spu.getCid3(), true); if (CollectionUtils.isEmpty(specParams)) { throw new LyException(ExceptionEnum.SPEC_PARAM_NOT_FOUND); } // 獲取spu詳細信息 SpuDetail spuDetail = goodsClient.querySpuDetailBySpuId(spuId); if (spuDetail == null) { throw new LyException(ExceptionEnum.SPU_NOT_FOUND); } // 獲取通用規格參數 Map<Long, Object> genericSpec = JsonUtils.parseMap(spuDetail.getGenericSpec(), Long.class, Object.class); // 獲取特有規格參數 Map<Long, List<Object>> specialSpec = JsonUtils.nativeRead(spuDetail.getSpecialSpec(), new TypeReference<Map<Long, List<Object>>>() {}); // 遍歷處理specs for (SpecParam specParam : specParams) { String key = specParam.getName(); Object value = ""; // 是通用規格參數 if (specParam.getGeneric()) { value = genericSpec.get(specParam.getId()); if (specParam.getNumeric()) { // 是數值類型 if (StringUtils.isNotBlank(specParam.getSegments())) { // 處理成段 value = chooseSegment(value.toString(), specParam); } } } else { value = specialSpec.get(specParam.getId()); } specs.put(key, value); } // 封裝對象 Goods goods = new Goods(); goods.setCid1(spu.getCid1()); goods.setCid2(spu.getCid2()); goods.setCid3(spu.getCid3()); goods.setBrandId(spu.getBrandId()); goods.setCreateTime(spu.getCreateTime()); goods.setId(spuId); goods.setSubTitle(spu.getSubTitle()); goods.setAll(all); // 所有需要被搜索的信息,包含標題,分類,甚至品牌 goods.setSkus(JsonUtils.serialize(skus)); // sku商品 goods.setPrice(prices); // sku 價格集合 goods.setSpecs(specs); // 可搜索的規格參數,key是參數名,值是參數值 return goods; } /** * 獲取搜索結果 * @param request * @return */ @Override public SearchResult search(SearchRequest request) { // 獲取參數 int page = request.getPage() - 1; // 注意: elasticsearch分頁是以0開始的 int size = request.getSize(); // 1. 創建查詢構建器 NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder(); // 2. 結果過濾 queryBuilder.withSourceFilter(new FetchSourceFilter(new String[]{"id", "subTitle", "skus"}, null)); // 3. 分頁 queryBuilder.withPageable(PageRequest.of(page, size)); // 4. 創建查詢條件 QueryBuilder basicQuery = buildConditions(request); queryBuilder.withQuery(basicQuery); // 5. 聚合 // 5.1品牌聚合 String brandAggName = "brand_agg"; queryBuilder.addAggregation(AggregationBuilders.terms(brandAggName).field("brandId")); // 5.2分類集合 String categoryAggName = "category_agg"; queryBuilder.addAggregation(AggregationBuilders.terms(categoryAggName).field("cid3")); // 6. 執行這裏用了聚合, 所以只能用template AggregatedPage<Goods> goodsList = template.queryForPage(queryBuilder.build(), Goods.class); // 7. 結果解析 long total = goodsList.getTotalElements(); int totalPages = goodsList.getTotalPages(); List<Goods> content = goodsList.getContent(); // 7. 獲取品牌List List<Brand> brandList = parseBrand((LongTerms) goodsList.getAggregation(brandAggName)); // 8. 獲取分類List List<Category> categoryList = parseCategory((LongTerms) goodsList.getAggregation(categoryAggName)); // 9. 判斷分類不問空, 並且爲1才聚合規格參數 List<Map<String, Object>> specs = null; if (categoryList != null && categoryList.size() == 1) { // 構建規格參數 specs = buildSpecificationAgg(categoryList.get(0).getId(), basicQuery); } return new SearchResult(total, Long.valueOf(totalPages), content, categoryList, brandList, specs); } /** * 構建查詢過濾條件 * @param request * @return */ private QueryBuilder buildConditions(SearchRequest request) { // 1. 構建布爾條件 BoolQueryBuilder queryBuilder = QueryBuilders.boolQuery(); // 2. 構建查詢條件 queryBuilder.must(QueryBuilders.matchQuery("all", request.getKey())); // 3. 構建過濾條件 Map<String, String> filter = request.getFilter(); if (filter != null) { for (Map.Entry<String, String> map : filter.entrySet()) { // 獲取, 過濾條件的鍵 String key = map.getKey(); // 如果不是分類或者品牌id if (!"cid3".equals(key) && !"brandId".equals(key)) { key = "specs." + key + ".keyword"; } queryBuilder.filter(QueryBuilders.termQuery(key, map.getValue())); } } return queryBuilder; } /** * 構建規格參數集合 * @param cid * @param basicQuery * @return */ private List<Map<String, Object>> buildSpecificationAgg(Long cid, QueryBuilder basicQuery) { // 1. 創建查詢構建器 NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder(); // 2. 把搜索條件添加進去 queryBuilder.withQuery(basicQuery); // 3. 獲取規格參數 List<SpecParam> specParams = specificationClient.queryParamByList(null, cid, true); // 4. 判斷是否爲空 if (CollectionUtils.isEmpty(specParams)) { throw new LyException(ExceptionEnum.SPEC_PARAM_NOT_FOUND); } // 5. 創建對象 List<Map<String, Object>> specs = new ArrayList<>(); // 6. 遍歷規格參數, 聚合每一個規格參數 for (SpecParam specParam : specParams) { String name = specParam.getName(); queryBuilder.addAggregation(AggregationBuilders.terms(name).field("specs." + name + ".keyword")); } // 在原有的搜索條件上加上現在聚合的規格參數條件進行查詢 AggregatedPage<Goods> goods = template.queryForPage(queryBuilder.build(), Goods.class); // 7. 解析結果集 // 7.1 遍歷規格參數構造, 規格參數待選項 for (SpecParam specParam : specParams) { String name = specParam.getName(); StringTerms terms = (StringTerms) goods.getAggregation(name); List<StringTerms.Bucket> buckets = terms.getBuckets(); // 構建map對象 Map<String, Object> map = new HashMap<>(); map.put("k", name); map.put("options", buckets.stream().map(b -> b.getKeyAsString()).collect(Collectors.toList())); // 添加進規格參數集合中 specs.add(map); } return specs; } /** * 獲取聚合後所有的 品牌 * @param terms * @return */ private List<Brand> parseBrand(LongTerms terms) { try{ List<Long> ids = terms.getBuckets().stream().map(b -> b.getKeyAsNumber().longValue()).collect(Collectors.toList()); List<Brand> brands = brandClient.queryBrandByIds(ids); return brands; } catch (Exception e) { log.error("[搜索服務]查詢品牌異常: ", e); return null; } } /** * 獲取聚合後所有的分類 * @param terms * @return */ private List<Category> parseCategory(LongTerms terms) { try{ List<Long> ids = terms.getBuckets().stream().map(b -> b.getKeyAsNumber().longValue()).collect(Collectors.toList()); List<Category> categories = categoryClient.queryCategoryListByIds(ids); return categories; } catch (Exception e) { log.error("[搜索服務]查詢品牌分類異常: ", e); return null; } } /** * 拼接規格參數 * @param value * @param p * @return */ 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; } }