用ElasticSearch實現商品搜索
SpringBoot微服務項目筆記-13

搜索頁面

  • 搜尋頁面有自己的子網域,網址是 http://search.mall.com/

  • 我觀察了一下,台灣的商城網站大多是用子目錄,例如:

https://www.momoshop.com.tw/search/

https://www.etmall.com.tw/Search?

https://shopping.friday.tw/ec2/search?
  • 而用子網域的通常是大陸的電商網站,經過查詢兩者其實沒太大差異

  • 通常來說,屬於網站下的附屬小功能,用子目錄;而體量大到可以分割出去才用會用子網域

  • 子網域複雜了一點,反正都學學吧

設定網段

  • 改host模擬DNS

image-20220122225938223

  • nginx
    • 採了坑,改完忘記要重開服務

image-20220122230310851

  • 網關
- id: mall_search_route
  uri: lb://search
  predicates:
    - Host=search.mall.com

靜態資源

  • 這邊一樣用thymeleaf渲染,引包
  • 關閉 spring.thymeleaf.cache=false
  • 調整 list.html,確認一下跟首頁的超連結是否正確

寫Vo

這可就複雜了,需要考慮各種搜尋條件、返回的結果…

  • 搜尋條件 SearchParam.java
@Data
public class SearchParam {

    /**
     * 頁面傳遞過來的全文匹配關鍵字
     */
    private String keyword;

    /**
     * 品牌id,可以多選
     */
    private List<Long> brandId;

    /**
     * 三級分類id
     */
    private Long catalog3Id;

    /**
     * 排序條件:sort=price/salecount/hotscore_desc/asc
     */
    private String sort;

    /**
     * 是否有貨
     */
    private Integer hasStock;

    /**
     * 價格區間查詢
     */
    private String skuPrice;

    /**
     * 按照屬性進行篩選
     */
    private List<String> attrs;

    /**
     * 頁碼
     */
    private Integer pageNum = 1;

    /**
     * 原生的所有查詢條件
     */
    private String _queryString;

}
  • 返回的結果 SearchResult.java
@Data
public class SearchResult {

    /**
     * 查詢到的所有商品信息
     */
    private List<SkuEsModel> product;

    /**
     * 當前頁碼
     */
    private Integer pageNum;

    /**
     * 總記錄數
     */
    private Long total;

    /**
     * 總頁碼
     */
    private Integer totalPages;

    private List<Integer> pageNavs;

    /**
     * 當前查詢到的結果,所有涉及到的品牌
     */
    private List<BrandVo> brands;

    /**
     * 當前查詢到的結果,所有涉及到的所有屬性
     */
    private List<AttrVo> attrs;

    /**
     * 當前查詢到的結果,所有涉及到的所有分類
     */
    private List<CatalogVo> catalogs;

    //===========================以上是返回給頁面的所有信息============================//

    /* 麵包屑導航數據 */
    private List<NavVo> navs;

    @Data
    public static class NavVo {
        private String navName;
        private String navValue;
        private String link;
    }

    @Data
    public static class BrandVo {

        private Long brandId;

        private String brandName;

        private String brandImg;
    }

    @Data
    public static class AttrVo {

        private Long attrId;

        private String attrName;

        private List<String> attrValue;
    }

    @Data
    public static class CatalogVo {

        private Long catalogId;

        private String catalogName;
    }
}
  • 返回的結果中的屬性 AttrResponseVo.java
    • 就是這些啦,根據結果從ES抽出的屬性

image-20220122235040745

@Data
public class AttrResponseVo {

    /**
     * 屬性id
     */
    private Long attrId;
    /**
     * 屬性名
     */
    private String attrName;
    /**
     * 是否需要檢索[0-不需要,1-需要]
     */
    private Integer searchType;
    /**
     * 屬性圖標
     */
    private String icon;
    /**
     * 可選值列表[用逗號分隔]
     */
    private String valueSelect;
    /**
     * 屬性類型[0-銷售屬性,1-基本屬性,2-既是銷售屬性又是基本屬性]
     */
    private Integer attrType;
    /**
     * 啟用狀態[0 - 禁用,1 - 啟用]
     */
    private Long enable;
    /**
     * 所屬分類
     */
    private Long catalogId;
    /**
     * 快速展示【是否展示在介紹上;0-否 1-是】,在sku中仍然可以調整
     */
    private Integer showDesc;

    private Long attrGroupId;

    private String catalogName;

    private String groupName;

    private Long[] catalogPath;

}

查詢

這一節真的太偏了,前後端不分的thymeleaf本身已經很少人用,ES查詢語句的編寫真的枯燥又無聊,暫時先複製貼上跳過

  • 簡單整理一下流程:
    • 造Vo封裝搜索條件與呈現的結果
    • 先在ES根據搜索條件寫出DSL
    • 將DSL編譯成Java語法
    • 處理聚合
    • 將結果封裝,推回給前端
  • SearchController.java
@GetMapping(value = "/list.html")
public String listPage(SearchParam param, Model model, HttpServletRequest request) {
    // Spring自動將頁面提交過來的所有請求參數封裝成我們指定的SearchParam
    param.set_queryString(request.getQueryString());

    // 根據傳遞來的頁面的查詢參數,去es中檢索商品
    SearchResult result = mallSearchService.search(param);
    // 存到model
    model.addAttribute("result", result);
    return "list";
}
  • MallSearchServiceImpl.java
/**
 * 在es檢索
 *
 * @param param 檢索的所有參數
 * @return 返回的結果
 */
public SearchResult search(SearchParam param) {
    SearchResult result = null;
    // 1、動態構建檢索需要的DSL語句

    // 1、準備檢索請求
    SearchRequest searchRequest = buildSearchRequest(param);
    try {
        // 2、執行檢索請求
        SearchResponse searchResponse = client.search(searchRequest, ElasticConfig.COMMON_OPTIONS);

        // 3、分析響應數據,封裝成需要的格式
        result = buildSearchResponse(param, searchResponse);

    } catch (IOException e) {
        e.printStackTrace();
    }
    return result;
}

private SearchRequest buildSearchRequest(SearchParam param) {
    // 構建DSL語句對象
    SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
    /**
     * 模糊匹配,過濾(按照屬性,分類,品牌,價格區間,庫存)
     */
    // 1、構建bool - query
    BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();

    // 1.1、must【得分】
    // 分詞匹配skuTitle
    if (!StringUtils.isEmpty(param.getKeyword())) {
        boolQueryBuilder.must(QueryBuilders.matchQuery("skuTitle", param.getKeyword()));
    }

    // 1.2、filter【無得分】
    // 三級分類
    if (param.getCatalog3Id() != null) {
        boolQueryBuilder.filter(QueryBuilders.termQuery("catalogId", param.getCatalog3Id()));
    }

    // 1.3、品牌
    if (!CollectionUtils.isEmpty(param.getBrandId())) {
        boolQueryBuilder.filter(QueryBuilders.termsQuery("brandId", param.getBrandId()));
    }

    // 1.4、屬性
    if (!CollectionUtils.isEmpty(param.getAttrs())) {
        for (String attr : param.getAttrs()) {
            BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
            String[] s = attr.split("_");
            String attrId = s[0];
            String[] attrValues = s[1].split(":");
            // must中的必須是同時滿足的,如果boolQuery不是內層的
            // 那麼boolQuery會拼接多個must(attrs.attrId),例如id = 6時,同時必須id = 7,沒有此attr
            boolQuery.must(QueryBuilders.termQuery("attrs.attrId", attrId));
            boolQuery.must(QueryBuilders.termsQuery("attrs.attrValue", attrValues));
            // 每一個屬性都要生成一個嵌入式查詢
            // 商品必須包含每一個 傳入的屬性
            NestedQueryBuilder nestedQuery = QueryBuilders.nestedQuery("attrs", boolQuery, ScoreMode.None);
            boolQueryBuilder.filter(nestedQuery);
        }
    }

    // 1.5、庫存
    if (param.getHasStock() != null) {
        boolQueryBuilder.filter(QueryBuilders.termQuery("hasStock", param.getHasStock() == 1));
    }

    // 1.6、價格區間 0_500  _500  500_
    if (!StringUtils.isEmpty(param.getSkuPrice())) {
        RangeQueryBuilder rangeQuery = QueryBuilders.rangeQuery("skuPrice");
        String[] s = param.getSkuPrice().split("_");
        if (s.length == 2) {
            rangeQuery.gte(s[0]).lte(s[1]);
        } else if (s.length == 1) {
            if (param.getSkuPrice().startsWith("_")) {
                rangeQuery.lte(s[0]);
            }
            if (param.getSkuPrice().endsWith("_")) {
                rangeQuery.gte(s[0]);
            }
        }
        boolQueryBuilder.filter(rangeQuery);
    }

    // 1、END 封裝查詢條件
    sourceBuilder.query(boolQueryBuilder);

    /**
     * 排序,分頁,高亮
     */
    // 2.1、排序
    if (!StringUtils.isEmpty(param.getSort())) {
        String[] s = param.getSort().split("_");
        SortOrder order = s[1].equalsIgnoreCase("asc") ? SortOrder.ASC : SortOrder.DESC;
        sourceBuilder.sort(s[0], order);
    }

    // 2.2、分頁
    sourceBuilder.from((param.getPageNum() - 1) * EsConstant.PRODUCT_PAGESIZE);
    sourceBuilder.size(EsConstant.PRODUCT_PAGESIZE);

    // 2.3、高亮
    if (!StringUtils.isEmpty(param.getKeyword())) {
        HighlightBuilder highlightBuilder = new HighlightBuilder();
        highlightBuilder.field("skuTitle");
        highlightBuilder.preTags("<b style='color:red'>");
        highlightBuilder.postTags("</b>");
        sourceBuilder.highlighter(highlightBuilder);
    }

    /**
     * 聚合分析【品牌、分類、分析所有可選的規格】
     */
    // 3.1、品牌聚合
    TermsAggregationBuilder brand_agg = AggregationBuilders.terms("brand_agg").field("brandId").size(10);
    // 子聚合,獲得品牌name和圖片
    brand_agg.subAggregation(AggregationBuilders.terms("brand_name_agg").field("brandName").size(1));
    brand_agg.subAggregation(AggregationBuilders.terms("brand_img_agg").field("brandImg").size(1));
    // 構建DSL
    // TODO 聚合品牌
    sourceBuilder.aggregation(brand_agg);

    // 3.2、分類聚合
    TermsAggregationBuilder catalog_agg = AggregationBuilders.terms("catalog_agg").field("catalogId").size(20);
    // 子聚合,獲得分類名
    catalog_agg.subAggregation(AggregationBuilders.terms("catalog_name_agg").field("catalogName").size(1));
    // 構建DSL
    // TODO 聚合分類
    sourceBuilder.aggregation(catalog_agg);

    // 3.3、規格聚合【嵌入式】
    NestedAggregationBuilder attr_agg = AggregationBuilders.nested("attr_agg", "attrs");
    // 根據id分組
    TermsAggregationBuilder attr_id_agg = AggregationBuilders.terms("attr_id_agg").field("attrs.attrId").size(10);
    // 子聚合=》在id分組內部,繼續按照 attr_name分組
    attr_id_agg.subAggregation(AggregationBuilders.terms("attr_name_agg").field("attrs.attrName").size(1));
    // 子聚合=》在id分組內部,繼續按照 attr_value分組
    attr_id_agg.subAggregation(AggregationBuilders.terms("attr_value_agg").field("attrs.attrValue").size(50));
    // 嵌入式聚合
    attr_agg.subAggregation(attr_id_agg);
    // 構建DSL
    // TODO 聚合屬性規格
    sourceBuilder.aggregation(attr_agg);

    SearchRequest searchRequest = new SearchRequest(new String[]{EsConstant.PRODUCT_INDEX}, sourceBuilder);

    // 打印DSL
    System.out.println(sourceBuilder.toString());
    return searchRequest;
}

/**
 * 封裝檢索結果
 * 1、返回所有查詢到的商品
 * 2、當前所有商品涉及到的所有屬性信息
 * 3、當前所有商品涉及到的所有品牌信息
 * 4、當前所有商品涉及到的所有分類信息
 * 5、分頁信息   pageNum:當前頁碼  total:總記錄數    totalPages: 總頁碼
 */
private SearchResult buildSearchResponse(SearchParam param, SearchResponse searchResponse) {
    SearchResult result = new SearchResult();
    SearchHits hits = searchResponse.getHits();
    // 1、返回所有查詢到的商品
    List<SkuEsModel> products = new ArrayList<>();
    if (!ArrayUtils.isEmpty(hits.getHits())) {
        for (SearchHit hit : hits.getHits()) {
            String jsonStr = hit.getSourceAsString();
            SkuEsModel model = JSON.parseObject(jsonStr, SkuEsModel.class);
            // 設置高亮信息
            if (!StringUtils.isEmpty(param.getKeyword())) {
                HighlightField skuTitle = hit.getHighlightFields().get("skuTitle");
                model.setSkuTitle(skuTitle.getFragments()[0].string());
            }
            products.add(model);
        }
    }
    result.setProducts(products);

    // 2、當前所有商品涉及到的所有屬性信息
    List<SearchResult.AttrVo> attrVos = new ArrayList<>();
    ParsedNested attr_agg = searchResponse.getAggregations().get("attr_agg");
    ParsedLongTerms attr_id_agg = attr_agg.getAggregations().get("attr_id_agg");
    List<? extends Terms.Bucket> attrBuckets = attr_id_agg.getBuckets();
    for (Terms.Bucket bucket : attrBuckets) {
        SearchResult.AttrVo attrVo = new SearchResult.AttrVo();
        // 提取屬性ID
        attrVo.setAttrId(bucket.getKeyAsNumber().longValue());
        // 提取屬性名字
        ParsedStringTerms attr_name_agg = bucket.getAggregations().get("attr_name_agg");
        attrVo.setAttrName(attr_name_agg.getBuckets().get(0).getKeyAsString());
        // 提取品牌圖片
        ParsedStringTerms attr_value_agg = bucket.getAggregations().get("attr_value_agg");
        List<String> attrValues = attr_value_agg.getBuckets().stream().map(item -> {
            return ((Terms.Bucket) item).getKeyAsString();
        }).collect(Collectors.toList());
        attrVo.setAttrValue(attrValues);
        attrVos.add(attrVo);
    }
    result.setAttrs(attrVos);

    // 3、當前所有商品涉及到的所有品牌信息
    List<SearchResult.BrandVo> brandVos = new ArrayList<>();
    ParsedLongTerms brand_agg = searchResponse.getAggregations().get("brand_agg");
    List<? extends Terms.Bucket> brandBuckets = brand_agg.getBuckets();
    for (Terms.Bucket bucket : brandBuckets) {
        SearchResult.BrandVo brandVo = new SearchResult.BrandVo();
        // 提取品牌ID
        brandVo.setBrandId(bucket.getKeyAsNumber().longValue());
        // 提取品牌名字
        ParsedStringTerms brand_name_agg = bucket.getAggregations().get("brand_name_agg");
        brandVo.setBrandName(brand_name_agg.getBuckets().get(0).getKeyAsString());
        // 提取品牌圖片
        ParsedStringTerms brand_img_agg = bucket.getAggregations().get("brand_img_agg");
        brandVo.setBrandImg(brand_img_agg.getBuckets().get(0).getKeyAsString());
        brandVos.add(brandVo);
    }
    result.setBrands(brandVos);

    // 4、當前所有商品涉及到的所有分類信息
    List<SearchResult.CatalogVo> catalogVos = new ArrayList<>();
    ParsedLongTerms catalog_agg = searchResponse.getAggregations().get("catalog_agg");
    List<? extends Terms.Bucket> catalogBuckets = catalog_agg.getBuckets();
    for (Terms.Bucket bucket : catalogBuckets) {
        SearchResult.CatalogVo catalogVo = new SearchResult.CatalogVo();
        // 提取分類Id
        catalogVo.setCatalogId(Long.parseLong(bucket.getKeyAsString()));
        // 提取分類名字
        ParsedStringTerms catalog_name_agg = bucket.getAggregations().get("catalog_name_agg");
        catalogVo.setCatalogName(catalog_name_agg.getBuckets().get(0).getKeyAsString());
        catalogVos.add(catalogVo);
    }
    result.setCatalogs(catalogVos);
    // 5、分頁信息   pageNum:當前頁碼 、total:總記錄數 、totalPages: 總頁碼
    long total = hits.getTotalHits().value;
    int totalPages = (int) total % EsConstant.PRODUCT_PAGESIZE == 0 ? (int) total / EsConstant.PRODUCT_PAGESIZE :
            ((int) total / EsConstant.PRODUCT_PAGESIZE + 1);
    result.setPageNum(param.getPageNum());
    result.setTotal(total);
    result.setTotalPages(totalPages);

    // 6、所有頁碼數
    List<Integer> pageNavs = new ArrayList<>();
    for (int i = 1; i <= totalPages; i++) {
        pageNavs.add(i);
    }
    result.setPageNavs(pageNavs);

    // 7、構建麵包屑導航功能
    if (param.getAttrs() != null && param.getAttrs().size() > 0) {
        List<SearchResult.NavVo> collect = param.getAttrs().stream().map(attr -> {
            //1、分析每一個attrs傳過來的參數值
            SearchResult.NavVo navVo = new SearchResult.NavVo();
            // attrs=2_5寸:6寸
            String[] s = attr.split("_");
            navVo.setNavValue(s[1]);
            R r = productFeignService.attrInfo(Long.parseLong(s[0]));
            // 根據請求構造麵包屑 規格屬性Id集合,這個集合包含的屬性規格不显示【前端會遍歷每個參數显示】
            result.getAttrIds().add(Long.parseLong(s[0]));
            if (r.getCode() == 0) {
                AttrResponseVo data = r.getData("attr", new TypeReference<AttrResponseVo>() {
                });
                // 設置屬性名
                navVo.setNavName(data.getAttrName());
            } else {
                navVo.setNavName(s[0]);
            }

            //2、取消了這個麵包屑以後,我們要跳轉到哪個地方,將請求的地址url裏面的當前置空
            //拿到所有的查詢條件,去掉當前
            String replace = replaceQueryString(param, attr, "attrs");
            navVo.setLink("http://search.mall.com/list.html?" + replace);

            return navVo;
        }).collect(Collectors.toList());

        result.setNavs(collect);
    }

    // 品牌、分類
    if (param.getBrandId() != null && param.getBrandId().size() > 0) {
        List<SearchResult.NavVo> navs = result.getNavs();
        SearchResult.NavVo navVo = new SearchResult.NavVo();
        navVo.setNavName("品牌");
        // TODO 遠程查詢所有品牌
        R r = productFeignService.brandsInfo(param.getBrandId());
        if (r.getCode() == 0) {
            List<BrandVo> brand = r.getData("brand", new TypeReference<List<BrandVo>>() {
            });
            StringBuffer sb = new StringBuffer();
            String replace = "";
            for (BrandVo brandVo : brand) {
                sb.append(brandVo.getName() + ";");
                replace = replaceQueryString(param, brandVo.getBrandId() + "", "brandId");
            }
            navVo.setNavValue(sb.toString());
            navVo.setLink("http://search.mall.com/list.html?" + replace);
        }
        navs.add(navVo);
    }

    // TODO 分類 麵包屑

    return result;
}

private String replaceQueryString(SearchParam param, String value, String key) {
    String encode = null;
    try {
        encode = URLEncoder.encode(value, "UTF-8");
        encode = encode.replace("+", "%20");  //瀏覽器對空格的編碼和Java不一樣,差異化處理
    } catch (UnsupportedEncodingException e) {
        e.printStackTrace();
    }
    // 就是點了X之後,應該跳轉的地址
    // 這裏要判斷一下,attrs是不是第一個參數,因為第一個參數 沒有&符號
    // TODO BUG,第一個參數不帶&
    return param.get_queryString().replace("&" + key + "=" + encode, "");
}

麵包屑導航

  • 學到新名詞 Breadcrumb,就是像這種東西啦

image-20220123211432913

  • 確實就是源自於童話故事糖果屋,主角在前往森林的路上放置麵包屑找到回家的方向
    • 但我記得不是被鳥吃掉了嗎?

聚合問題

  • 報錯 fielddata is unsupported
Can't load fielddata on [XXX] because fielddata is unsupported on fields of type [keyword]. Use doc values instead.
  • 因為當初mapping不想讓這些欄位被查到,做的設定

image-20220123003049183

  • doc_values 預設是true,當手動設為 false 時,無法基於該欄位排序、聚合、在腳本中訪問欄位值,所以報錯
  • 解法: 重構建mapping,可以用POST _reindex複製走舊的資料,重造mapping後再搬回去

上次修改於 2022-01-30