天天看點

樂優商城--服務(五) : 搜尋微服務(LySearchApplication)1. 引言2. 搭建項目3. 索引庫資料格式分析4. 商品微服務提供接口5. 調用接口6. 導入資料7. 實作基本搜尋8. 結果過濾9. 優化

搜尋微服務:

  • 1. 引言
    • 1.1 Elasticsearch
    • 1.2 kibana
    • 1.3 操作索引
    • 1.4 測試
      • 1.4.1 查詢
      • 1.4.2 聚合
  • 2. 搭建項目
    • 2.1 引入依賴
    • 2.2 配置
    • 2.3 啟動類
  • 3. 索引庫資料格式分析
    • 3.1 以結果為導向
    • 3.2 需要什麼資料
    • 3.3 最終的資料結構
  • 4. 商品微服務提供接口
    • 4.1 商品分類名稱查詢
      • 4.1.1 web
      • 4.1.2 service
    • 4.2 商品品牌名稱查詢
      • 4.2.1 web
      • 4.2.2 service
  • 5. 調用接口
  • 6. 導入資料
    • 6.1 建立GoodsRepository
    • 6.2 建立索引
    • 6.3 導入資料
  • 7. 實作基本搜尋
    • 7.1 web
      • 7.1.1 頁面分析
      • 7.1.2 實作業務
    • 7.2 service
    • 7.3 測試
  • 8. 結果過濾
    • 8.1 過濾功能分析
    • 8.2 生成分類和品牌過濾
      • 8.2.1 擴充傳回的結果
      • 8.2.2 聚合商品分類和品牌
    • 8.3 生成規格參數過濾
      • 8.3.1 分析
      • 8.3.2 實作
        • 8.3.2.1 擴充傳回結果
        • 8.3.2.2 完整代碼
    • 8.4 過濾條件的篩選
      • 8.4.1 拓展請求對象
      • 8.4.2 添加過濾條件
  • 9. 優化

1. 引言

1.1 Elasticsearch

Elasticsearch:全文檢索技術。

樂優商城--服務(五) : 搜尋微服務(LySearchApplication)1. 引言2. 搭建項目3. 索引庫資料格式分析4. 商品微服務提供接口5. 調用接口6. 導入資料7. 實作基本搜尋8. 結果過濾9. 優化

如上所述,Elasticsearch具備以下特點:

  • 分布式,無需人工搭建叢集(solr就需要人為配置,使用Zookeeper作為注冊中心)
  • Restful風格,一切API都遵循Rest原則,容易上手
  • 近實時搜尋,資料更新在Elasticsearch中幾乎是完全同步的。

1.2 kibana

樂優商城--服務(五) : 搜尋微服務(LySearchApplication)1. 引言2. 搭建項目3. 索引庫資料格式分析4. 商品微服務提供接口5. 調用接口6. 導入資料7. 實作基本搜尋8. 結果過濾9. 優化

Kibana是一個基于Node.js的Elasticsearch索引庫資料統計工具(發請求),可以利用Elasticsearch的聚合功能,生成各種圖表,如柱形圖,線狀圖,餅圖等。

而且還提供了操作Elasticsearch索引資料的控制台,并且提供了一定的API提示

1.3 操作索引

Elasticsearch也是基于Lucene的全文檢索庫,本質也是存儲資料,很多概念與MySQL類似的。

對比關系:

索引(indices)--------------------------------Databases 資料庫

類型(type)-----------------------------Table 資料表

索引(indices)--------------------------------Databases 資料庫
     類型(type)-----------------------------Table 資料表
     文檔(Document)----------------Row 行
	 字段(Field)-------------------Columns 列 
           

Elasticsearch采用Rest風格API(http請求接口),是以其API就是一次http請求,可以用任何工具發起http請求

索引的請求格式:

  • 請求方式:PUT(建立,修改合二為一)/ GET(檢視)/ DELETE(删除)/ POST(可以向一個已經存在的索引庫中添加資料)
  • 請求路徑:/索引庫名
  • 請求參數:json格式:
{
          "settings": {
              "number_of_shards": 3,
              "number_of_replicas": 2
            }
      }
           
  • settings:索引庫的設定
  • number_of_shards:分片數量
  • number_of_replicas:副本數量

1.4 測試

1.4.1 查詢

@RunWith(SpringRunner.class)
@SpringBootTest
public class GoodsRepositoryTest {
	public void testQuery{
		// 1 建立查詢建構器(spring提供的)
		NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder();
		// 2 結果過濾
		queryBuilder.withSourceFilter(new FetchSourceFilter(new String[]{"id", "subTitle", "skus"}, null));
		// 3 添加查詢條件
		queryBuilder.withQuery(QueryBuilders.matchQuery(name:"title",text:"小米手機"));
		// 4 排序
		queryBuilder.withSort(SortBuilders.fieldSort("price"").order(SortOrder.DESC));
		// 5 分頁
		queryBuilder.withPageable(PageRequest.of(page,size));
		// 6 查詢
		Page<Goods> result = repository.search(queryBuilder.build());
		long total = result.getTotalElements();
		........
	}
}
           

采用類的位元組碼資訊建立索引并映射:

Spring Data通過注解來聲明字段的映射屬性,有下面的三個注解:

  • @Document

    作用在類(Goods),标記實體類為文檔對象,一般有兩個屬性
    • indexName:對應索引庫名稱
    • type:對應在索引庫中的類型
    • shards:分片數量,預設5
    • replicas:副本數量,預設1
  • @Id

    作用在成員變量,标記一個字段作為id主鍵
  • @Field

    作用在成員變量,标記為文檔的字段,并指定字段映射屬性:
    • type:字段類型,是是枚舉:FieldType
    • index:是否索引,布爾類型,預設是true
    • store:是否存儲,布爾類型,預設是false
    • analyzer:分詞器名稱
  • 增删改不用

    ElasticsearchTemplate

    ,ElasticsearchTemplate一般會用來做原生的複雜查詢,比如聚合,我們一般的普通增删改查用不到,而spring給我們提供了

    ElasticsearchRepository

    ( Spring Data 的強大之處,就在于你不用寫任何DAO處理,自動根據方法名或類的資訊進行CRUD操作。隻要你定義一個接口,然後繼承Repository提供的一些子接口,就能具備各種基本的CRUD功能。)
  • 是以我們應該寫個

    GoodsRepository

    接口繼承ElasticsearchRepository,第一個泛型是實體類,第二個是id類型,接下來就可以直接用了
  • Spring Data 的另一個強大功能,是根據方法名稱自動實作功能。

    比如:你的方法名叫做:findByTitle,那麼它就知道你是根據title查詢,然後自動幫你完成,無需寫實作類。當然,方法名稱要符合一定的約定

public interface GoodsRepository extends ElasticsearchRepository<Goods, Long> {
}
           

QueryBuilder(spring提供的)可整合Elasticsearch原生的結果過濾、查詢、排序、分頁等,還有結果過濾,整合完之後利用spring data做一個搜尋,它會幫我們封裝成一個結果

1.4.2 聚合

@RunWith(SpringRunner.class)
@SpringBootTest
public class GoodsRepositoryTest {
	public void testAgg{
		// 1 建立查詢建構器(spring提供的)
		NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder();
		String aggName = "popularBrand";
		// 2 聚合
		queryBuilder.addAggregation(AggregationBuilders.terms(CategoryAggName).field("brand"));
		// 3 查詢并傳回帶聚合結果
	    AggregatedPage<Goods> result = template.queryForPage(queryBuilder.build(), Goods.class);
		// 4 解析聚合
		Aggregations aggs = result.getAggregations();
		// 5 擷取指定名稱的聚合
		StringTerms terms  = aggs.getName(aggName);
		// 6 擷取桶
		List<StringTerms.Bucket> buckets = terms.getBuckets();
		for(StringTerms.Bucket bucket:buckets){
			bucket.getKeyAsString();
			...
		}
		........
	}
}
           

2. 搭建項目

使用者通路我們的首頁,一般都會直接搜尋來尋找自己想要購買的商品。

而商品的數量非常多,而且分類繁雜。如果能正确的顯示出使用者想要的商品,并進行合理的過濾,盡快促成交易,是搜尋系統要研究的核心。

面對這樣複雜的搜尋業務和資料量,一般我們都會使用全文檢索技術: Elasticsearch。

2.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.page.service</groupId>
    <artifactId>ly-search</artifactId>

    <dependencies>
        <!--eureka-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>
        <!--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>
        <!--feign 服務間調用-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-openfeign</artifactId>
        </dependency>
        <!--springboot啟動器的測試功能-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
        </dependency>
        <!--商品實體類的接口-->
        <dependency>
            <groupId>com.leyou.service</groupId>
            <artifactId>ly-item-interface</artifactId>
            <version>1.0.0-SNAPSHOT</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-amqp</artifactId>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>
           

2.2 配置

server:
  port: 8083
spring:
  application:
    name: search-service
  data:
    elasticsearch:
      cluster-name: elasticsearch
      cluster-nodes: 192.168.184.130:9300
  jackson:
    default-property-inclusion: non_null  #排除傳回結構中字段值為null的屬性
  rabbitmq:
    host: 192.168.184.130
    username: leyou
    password: leyou
    virtual-host: /leyou
eureka:
  client:
    service-url:
      defaultZone: http://127.0.0.1:10086/eureka
      registry-fetch-interval-seconds: 10
  instance:
    #lease-renewal-interval-in-seconds: 5 # 每隔5秒發送一次心跳
    #lease-expiration-duration-in-seconds: 10 # 10秒不發送就過期
    prefer-ip-address: true
    ip-address: 127.0.0.1
           

2.3 啟動類

@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
@EnableDiscoveryClient
@EnableFeignClients
public class LySearchApplication {

    public static void main(String[] args) {
        SpringApplication.run(LySearchApplication.class, args);
    }
}
           

3. 索引庫資料格式分析

接下來,我們需要商品資料導入索引庫,便于使用者搜尋。

那麼問題來了,我們有SPU和SKU,到底如何儲存到索引庫?

3.1 以結果為導向

我們來看下搜尋結果頁:

樂優商城--服務(五) : 搜尋微服務(LySearchApplication)1. 引言2. 搭建項目3. 索引庫資料格式分析4. 商品微服務提供接口5. 調用接口6. 導入資料7. 實作基本搜尋8. 結果過濾9. 優化

可以看到,每一個搜尋結果都有至少1個商品,當我們選擇大圖下方的小圖,商品會跟着變化。

是以,搜尋的結果是SPU,即多個SKU的集合。

既然搜尋的結果是SPU,那麼我們索引庫中存儲的應該也是SPU,但是卻需要包含SKU的資訊。

3.2 需要什麼資料

由上圖可以直覺能看到的:圖檔、價格、标題、副标題(屬于SKU資料,用來展示的);暗藏的資料:spu的id,sku的id

另外,頁面還有過濾條件:

樂優商城--服務(五) : 搜尋微服務(LySearchApplication)1. 引言2. 搭建項目3. 索引庫資料格式分析4. 商品微服務提供接口5. 調用接口6. 導入資料7. 實作基本搜尋8. 結果過濾9. 優化

這些過濾條件也都需要存儲到索引庫中,包括:商品分類、品牌、可用來搜尋的規格參數等

綜上所述,我們需要的資料格式有:

spuId、SkuId、商品分類id、品牌id、圖檔、價格、商品的建立時間、sku資訊集、可搜尋的規格參數

3.3 最終的資料結構

我們建立一個類,封裝要儲存到索引庫的資料,并設定映射屬性:

@Data
@Document(indexName = "goods", type = "docs", shards = 1)
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 Set<Long> price;// 價格,對應到elasticsearch/json中是數組,一個spu有多個sku,就有多個價格

    @Field(type = FieldType.keyword, index = false)
    private String skus;// 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:紅色

4. 商品微服務提供接口

索引庫中的資料來自于資料庫,我們不能直接去查詢商品的資料庫,因為真實開發中,每個微服務都是互相獨立的,包括資料庫也是一樣。是以我們隻能調用商品微服務提供的接口服務。

先思考我們需要的資料:

  • SPU資訊
  • SKU資訊
  • SPU的詳情
  • 商品分類名稱(拼接all字段)
  • 規格參數
  • 品牌

再思考我們需要哪些服務:

  • 第一:分批查詢spu的服務,已經寫過。
  • 第二:根據spuId查詢sku的服務,已經寫過
  • 第三:根據spuId查詢SpuDetail的服務,已經寫過
  • 第四:根據商品分類id,查詢商品分類名稱,沒寫過
  • 第五:規格參數,寫過
  • 第六:品牌,沒寫過

是以我們需要額外提供一個查詢商品分類名稱和品牌名稱的接口。

4.1 商品分類名稱查詢

4.1.1 web

@RestController
@RequestMapping("category")
public class CategoryController {
	// 根據商品分類cid清單查詢分類集合
    @GetMapping("list/ids")
    public ResponseEntity<List<Category>> queryCategoryByIds(@RequestParam("ids")List<Long> ids){
        return ResponseEntity.ok(categoryService.queryByIds(ids));
    }
}
           

4.1.2 service

service之前寫過該方法~

@Service
public class CategoryService {
    // 根據商品分類cid清單查詢分類集合
    public List<Category> queryByIds(List<Long> cids){
       List<Category> idList = categoryMapper.selectByIdList(cids);
        if(CollectionUtils.isEmpty(idList)){
            throw new LyException(ExceptionEnum.CATEGORY_NOT_FOUND);
        }
        return idList;
    }
}
           

4.2 商品品牌名稱查詢

4.2.1 web

@RestController
@RequestMapping("brand")
public class BrandController {
    // 根據品牌brandid查詢品牌名稱
    @GetMapping("{id}")
    public ResponseEntity<Brand> queryBrandById(@PathVariable("id")Long id){
        return ResponseEntity.ok(brandService.queryById(id));
    }
} 
           

4.2.2 service

@Service
public class BrandService {
	// 根據品牌brandid查詢品牌名稱
    public Brand queryById(Long id){
        Brand brand = brandMapper.selectByPrimaryKey(id);
        if(brand == null){
            throw new LyException(ExceptionEnum.BRAND_NOT_FOUND);
        }
        return brand;
    }
}
           

5. 調用接口

第一步:服務的提供方在ly-item-interface中提供API接口,并編寫接口聲明:

樂優商城--服務(五) : 搜尋微服務(LySearchApplication)1. 引言2. 搭建項目3. 索引庫資料格式分析4. 商品微服務提供接口5. 調用接口6. 導入資料7. 實作基本搜尋8. 結果過濾9. 優化

品牌服務接口:

public interface BrandApi {
    // 根據品牌id查詢品牌
    @GetMapping("brand/{id}")
    Brand queryBrandById(@PathVariable("id")Long id);

    // 根據bid的集合查詢品牌資訊
    @GetMapping("brand/list")
    List<Brand> queryBrandsByIds(@RequestParam("ids") List<Long> ids);
}
           

商品分類服務接口:

public interface CategoryApi {
	//根據sku的id集合查詢所有的sku
    @GetMapping("category/list/ids")
    List<Category> queryCategoryByIds(@RequestParam("ids") List<Long> ids);
}
           

商品服務接口:

public interface GoodsApi {

    //根據spu的id查詢詳情detail
    @GetMapping("/spu/detail/{id}")
    SpuDetail querySpuDetailById(@PathVariable("id")Long id);

    //根據spu查詢下面所有的sku
    @GetMapping("/sku/list")
    List<Sku> querySkuBySpuId(@RequestParam("id") Long spuId);

    //分頁查詢spu
    @GetMapping("/spu/page")
    PageResult<Spu> querySpuByPage(
            @RequestParam(value = "page",defaultValue = "1")Integer page,
            @RequestParam(value = "rows",defaultValue = "5")Integer rows,
            @RequestParam(value = "saleable",required = false)Boolean saleable,
            @RequestParam(value = "key",required = false)String key
    );

    // 根據spu的id查詢spu
    @GetMapping("spu/{id}")
    Spu querySpuById(@PathVariable("id") Long spuId);

	//根據sku的id集合查詢所有的sku
    @GetMapping("/sku/list/ids")
    List<Sku> querySkuByIds(@RequestParam("ids") List<Long> ids);

    // 減庫存
    @PostMapping("stock/decrease")
    void decreaseStock(@RequestBody List<CartDTO> cartDTOS);
}
           

規格服務接口:

public interface SpecificationApi {

    // 查詢規格參數集合
    @GetMapping("spec/params")
    List<SpecParam> querySpecParams(@RequestParam(value = "gid",required = false)Long gid,
                                    @RequestParam(value = "cid",required = false)Long cid,
                                    @RequestParam(value = "searching",required = false)Boolean searching);

    //根據cid查詢規格組及其規格參數
    @GetMapping("spec/group")
    List<SpecGroup> queryGroupByCid(@RequestParam("cid") Long cid);
}
           

有的方法我們現在還沒有寫或者我們暫時用不到,但以後會用到,是以這裡一并給出。

同時,需要在ly-item-interface中引入一些依賴:

<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-webmvc</artifactId>
</dependency>
<dependency>
    <groupId>com.leyou.common</groupId>
    <artifactId>ly-common</artifactId>
    <version>1.0.0-SNAPSHOT</version>
</dependency>
           

第二步:在調用方ly-search中編寫FeignClient,但不要寫方法聲明了,直接繼承ly-item-interface提供的api接口:

商品的FeignClient:

@FeignClient(value = "item-service")
public interface GoodsClient extends GoodsApi {
}
           

品牌的FeignClient:

@FeignClient("item-service")
public interface BrandClient extends BrandApi{
}
           

商品分類的FeignClient:

@FeignClient("item-service")
public interface BrandClient extends BrandApi{
}
           

商品的FeignClient:

@FeignClient("item-service")
public interface GoodsClient extends GoodsApi{
}
           

規格的FeignClient:

@FeignClient("item-service")
public interface SpecificationClient extends SpecificationApi {
}
           

6. 導入資料

6.1 建立GoodsRepository

public interface GoodsRepository extends ElasticsearchRepository<Goods, Long> {
}
           

6.2 建立索引

我們建立一個測試類,在裡面進行資料的操作:(建立索引隻需一次就好,是以沒有寫到正式代碼裡,而是放在測試類裡運作一下就好)

@RunWith(SpringRunner.class)
@SpringBootTest
public class GoodsRepositoryTest{

    @Autowired
    private GoodsRepository goodsRepository;

    @Autowired
    private ElasticsearchTemplate template;

    @Test
    public void testCreateIndex(){
        // 建立索引庫 會根據Item類的@Document注解資訊來建立
        template.createIndex(Goods.class);
        // 建立映射關系 會根據Item類中的id、Field等字段來自動完成映射
        template.putMapping(Goods.class);
    }
}
           

6.3 導入資料

導入資料其實就是查詢資料庫中的資料,然後把查詢到的資訊封裝成Goods類型的對象放到索引庫裡,是以我們先編寫一個SearchService ,然後在裡面定義一個buildGoods方法, 把Spu封裝為Goods

@Slf4j
@Service
public class SearchService {
    // 把spu封裝為Goods
    public Goods buildGoods(Spu spu){
        // 建構goods對象
        Goods goods = new Goods();

        goods.setBrandId(spu.getBrandId());
        goods.setCid1(spu.getCid1());
        goods.setCid2(spu.getCid2());
        goods.setCid3(spu.getCid3());
        goods.setCreateTime(spu.getCreateTime());
        goods.setSubTitle(spu.getSubTitle());
        goods.setId(spu.getId());

        // all --- 搜尋字段:标題、分類、品牌、規格
        // 标題 spu.getTitle()

        // 查詢分類
        List<String> names = categoryClient.queryCategoryByIds(Arrays.asList(spu.getCid1(), spu.getCid2(), spu.getCid3()))
                .stream()
                .map(Category::getName)
                .collect(Collectors.toList());
        if(CollectionUtils.isEmpty(names)){
            throw new LyException(ExceptionEnum.CATEGORY_NOT_FOUND);
        }
        // 查詢品牌
        Brand brand = brandClient.queryBrandById(spu.getBrandId());
        if(brand == null){
            throw new LyException(ExceptionEnum.BRAND_NOT_FOUND);
        }
        // all
        String all = spu.getTitle() + StringUtils.join(names," ") + brand.getName();


        // sku --- 所有sku的集合的json格式
        List<Sku> skuList = goodsClient.querySkuBySpuId(spu.getId());
        if(CollectionUtils.isEmpty(skuList)){
            throw new LyException(ExceptionEnum.GOODS_SKU_NOT_FOUND);
        }
        // 搜尋字段隻需要部分資料(id,title,price,image) 是以要對sku進行處理
        ArrayList<Map<String,Object>> skus = new ArrayList<>();
        // price
        Set<Long> priceList = new HashSet<>();
        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);

            //處理價格
            priceList.add(sku.getPrice());

        }

        // 查詢規格參數  結果是一個map
        // 規格參數表
        List<SpecParam> params = specificationClient.querySpecParams(null, spu.getCid3(), true);
        if(CollectionUtils.isEmpty(params)){
            throw new LyException(ExceptionEnum.SPEC_PARAM_NOT_FOUND);
        }
        // 規格詳情表
        SpuDetail spuDetail = goodsClient.querySpuDetailById(spu.getId());
        // 擷取通用規格參數
        Map<Long, String> genericSpec = JsonUtils.parseMap(spuDetail.getGenericSpec(), Long.class, String.class);
        //擷取特有規格參數
        Map<Long, List<String>> specialSpec = JsonUtils.nativeRead(
                spuDetail.getSpecialSpec(), new TypeReference<Map<Long, List<String>>>() {});

        //将參數填入map
        Map<String,Object> specs = new HashMap<>();
        for (SpecParam param : params) {
            // 規格名字 key
            String key = param.getName();
            Object value = "";

            //規格參數 value
            if(param.getGeneric()){
                // 通用屬性
                value = genericSpec.get(param.getId());// 通用參數的數值類型有分段的情況存在,要做一個處理,不能按上面那種方法獲得value
                //判斷是否為數值類型 處理成段,覆寫之前的value
                if(param.getNumeric()){
                    value = chooseSegment(value.toString(),param);
                }
            }else {
                // 特殊屬性
                value = specialSpec.get(param.getId());
            }
            value = (value == null ? "其他" : value);

            specs.put(key,value);
        }


        goods.setAll(all); // 搜尋字段,包含标題、分類、品牌、規格
        goods.setSkus(JsonUtils.serialize(skus)); // 所有sku的集合的json格式
        goods.setPrice(priceList); // 所有sku的價格集合
        goods.setSpecs(specs); // 所有可搜尋的規格參數

        return goods;
    }
}
           

因為過濾參數中有一類比較特殊,就是數值區間:

樂優商城--服務(五) : 搜尋微服務(LySearchApplication)1. 引言2. 搭建項目3. 索引庫資料格式分析4. 商品微服務提供接口5. 調用接口6. 導入資料7. 實作基本搜尋8. 結果過濾9. 優化

是以我們在存入時要進行處理:

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;
}
           

然後編寫一個測試類,循環查詢Spu,然後調用SearchService中的方法,把SPU變為Goods,然後寫入索引庫:

@RunWith(SpringRunner.class)
@SpringBootTest
public class GoodsRepositoryTest {
	@Test
	public void loadData(){
	    int page = 1;
	    int rows = 100;
	    int size=0;
	    do {
	        //查詢spu資訊
	        PageResult<Spu> result = goodsClient.querySpuByPage(page, rows, true, null);
	
	        List<Spu> spuList = result.getItems();//目前頁
	        if(CollectionUtils.isEmpty(spuList)){
	            break;
	        }
	        //建構成goods
	        List<Goods> goodsList = spuList.stream().map(searchService::buildGoods).collect(Collectors.toList());
	        //存入索引庫
	        Iterable<Goods> goods = goodsRepository.saveAll(goodsList);
	        //翻頁
	        page++;
	        size=spuList.size();
	    }while(size==100);
	}
}
           

7. 實作基本搜尋

7.1 web

7.1.1 頁面分析

樂優商城--服務(五) : 搜尋微服務(LySearchApplication)1. 引言2. 搭建項目3. 索引庫資料格式分析4. 商品微服務提供接口5. 調用接口6. 導入資料7. 實作基本搜尋8. 結果過濾9. 優化
  • 請求方式:Post
  • 請求路徑:/search/page,不過前面的/search應該是網關的映射路徑,是以真實映射路徑page,代表分頁查詢
  • 請求參數:json格式,目前隻有一個屬性:key,搜尋關鍵字,但是搜尋結果頁一定是帶有分頁查詢的,是以将來肯定會有page屬性,是以我們可以用一個對象來接收請求的json資料:
public class SearchRequest {
    private static final Integer DEFAULT_PAGE = 1;
    private static final Integer DEFAULT_SIZE = 20;
    private String key;//搜尋條件
    private Integer page;//目前頁
    private Integer size=DEFAULT_SIZE;//頁面大小

    public void setSize(Integer size) {
        this.size=DEFAULT_SIZE;
    }

    //排序字段
    private String sortBy;
    //是否降序
    private Boolean descending;

    //過濾字段
    private Map<String, String> filter;

    public String getSortBy() {
        return sortBy;
    }

    public void setSortBy(String sortBy) {
        this.sortBy = sortBy;
    }

    public Boolean getDescending() {
        return descending;
    }

    public void setDescending(Boolean descending) {
        this.descending = descending;
    }

    public String getKey() {
        return key;
    }

    public void setKey(String key) {
        this.key = key;
    }

    public Integer getPage() {
        if (page == null) {//預設為1
            return DEFAULT_PAGE;
        }
        // 擷取頁碼時做一些校驗,不能小于1
        return Math.max(DEFAULT_PAGE, page);
    }

    public void setPage(Integer page) {
        this.page = page;
    }

    public Integer getSize() {
        return size;
    }

    public Map<String, String> getFilter() {
        return filter;
    }

    public void setFilter(Map<String, String> filter) {
        this.filter = filter;
    }
}
           
  • 傳回結果:作為分頁結果,一般都兩個屬性:目前頁資料、總條數資訊,我們可以使用之前定義的PageResult類

注: 由于前台門戶系統采用www.leyou.com進行通路,是以應在GlobalCorsConfig配置

config.addAllowedOrigin("http://www.leyou.com")

;

樂優商城--服務(五) : 搜尋微服務(LySearchApplication)1. 引言2. 搭建項目3. 索引庫資料格式分析4. 商品微服務提供接口5. 調用接口6. 導入資料7. 實作基本搜尋8. 結果過濾9. 優化

7.1.2 實作業務

@RestController
public class SearchController {
    // 搜尋功能
    @PostMapping("page")
    public ResponseEntity<PageResult<Goods>> search(@RequestBody SearchRequest request){
        return ResponseEntity.ok(searchService.search(request));
    }
}
           

7.2 service

@Slf4j
@Service
public class SearchService {
    // 搜尋功能
    public SearchResult search(SearchRequest request) {
        int page = request.getPage() - 1;// page,elasticSearch預設從0開始,要進行減一操作否則一直查詢不到第一頁
        int size = request.getSize();

        // 建立查詢建構器(spring提供的)
        NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder();
        // 0 結果過濾
		queryBuilder.withSourceFilter(new FetchSourceFilter(new String[]{"id", "subTitle", "skus"}, null));
        // 1 分頁
        queryBuilder.withPageable(PageRequest.of(page,size));
        // 2 過濾
        QueryBuilder.withQuery(QueryBuilders.matchQuery(name:"all"),request.getKey());
        // 3 查詢
        Page<Goods> result = repository.search(queryBuilder.build(), Goods.class);

        // 4 解析結果
        long total = result.getTotalElements();
        long totalPage = result.getTotalPages(); //int totalPage = ((int) total + size -1)/size;
		List<Goods> goodsList = result.getContent();
		
        return new SearchResult(total, totalPage, goodsList);
    }
}
           

7.3 測試

樂優商城--服務(五) : 搜尋微服務(LySearchApplication)1. 引言2. 搭建項目3. 索引庫資料格式分析4. 商品微服務提供接口5. 調用接口6. 導入資料7. 實作基本搜尋8. 結果過濾9. 優化

資料是查到了,但是因為我們隻查詢部分字段,是以結果json 資料中有很多null,解決辦法很簡單,在

application.yml

中添加一行配置,json處理時忽略空值即可。

spring:
  jackson:
    default-property-inclusion: non_null # 配置json處理時忽略空值
           

注:所有頁面渲染操作全在前端,這裡就不寫啦

8. 結果過濾

8.1 過濾功能分析

首先看下頁面要實作的效果:

樂優商城--服務(五) : 搜尋微服務(LySearchApplication)1. 引言2. 搭建項目3. 索引庫資料格式分析4. 商品微服務提供接口5. 調用接口6. 導入資料7. 實作基本搜尋8. 結果過濾9. 優化

整個過濾部分有3塊:

  • 頂部的導航,已經選擇的過濾條件展示:
    • 商品分類面包屑,根據使用者選擇的商品分類變化
    • 其它已選擇過濾參數
  • 過濾條件展示,又包含3部分
    • 商品分類展示
    • 品牌展示
    • 其它規格參數
  • 展開或收起的過濾條件的按鈕

頂部導航要展示的内容跟使用者選擇的過濾條件有關。

  • 比如使用者選擇了某個商品分類,則面包屑中才會展示具體的分類
  • 比如使用者選擇了某個品牌,清單中才會有品牌資訊。

是以,這部分需要依賴第二部分:過濾條件的展示和選擇。是以我們先不着急去做。

展開或收起的按鈕是否顯示,取決于過濾條件現在有多少,如果有很多,那麼就沒必要展示。是以也是跟第二部分的過濾條件有關。

這樣分析來看,我們必須先做第二部分:過濾條件展示。

8.2 生成分類和品牌過濾

先來看分類和品牌。在我們的資料庫中已經有所有的分類和品牌資訊。在這個位置,是不是把所有的分類和品牌資訊都展示出來呢?

顯然不是,使用者搜尋的條件會對商品進行過濾,而在搜尋結果中,不一定包含所有的分類和品牌,直接展示出所有商品分類,讓使用者選擇顯然是不合适的。

無論是分類資訊,還是品牌資訊,都應該從搜尋的結果商品中進行聚合得到。

8.2.1 擴充傳回的結果

原來,我們傳回的結果是PageResult對象,裡面隻有total、totalPage、items3個屬性。但是現在要對商品分類和品牌進行聚合,資料顯然不夠用,我們需要對傳回的結果進行擴充,添加分類和品牌的資料。

那麼問題來了:以什麼格式傳回呢?

看頁面:

樂優商城--服務(五) : 搜尋微服務(LySearchApplication)1. 引言2. 搭建項目3. 索引庫資料格式分析4. 商品微服務提供接口5. 調用接口6. 導入資料7. 實作基本搜尋8. 結果過濾9. 優化

分類:頁面顯示了分類名稱,但背後肯定要儲存id資訊。是以至少要有id和name

品牌:頁面展示的有logo,有文字,當然肯定有id,基本上是品牌的完整資料

我們建立一個類,繼承PageResult,然後擴充兩個新的屬性:分類集合和品牌集合:

@Data
public class SearchResult extends PageResult<Goods> {

    private List<Category> categories;// 分類過濾條件
    private List<Brand> brands; // 品牌過濾條件
    private List<Map<String,Object>> specs; // 規格參數過濾條件
    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;
    }
}
           

8.2.2 聚合商品分類和品牌

我們修改搜尋的業務邏輯,對分類和品牌聚合。

因為索引庫中隻有id,是以我們根據id聚合,然後再根據id去查詢完整資料。

是以,商品微服務需要提供一個接口:根據品牌id集合,批量查詢品牌。(之前已提前謝寫過)

// 搜尋功能
    public SearchResult search(SearchRequest request) {

        String key = request.getKey(); // 搜尋條件 eg:手機
        if (StringUtils.isBlank(key)) {
            throw new LyException(ExceptionEnum.SPEC_PARAM_NOT_FOUND);
        }

        int page = request.getPage() - 1;// page,elasticSearch預設從0開始,要進行減一操作否則一直查詢不到第一頁
        int size = request.getSize();

        // 建立查詢建構器(spring提供的)
        NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder();
        // 0 結果過濾
		queryBuilder.withSourceFilter(new FetchSourceFilter(new String[]{"id", "subTitle", "skus"}, null));
        // 1 分頁
        queryBuilder.withPageable(PageRequest.of(page,size));
        // 2 過濾
        QueryBuilder.withQuery(QueryBuilders.matchQuery(name:"all"),request.getKey());
        // 3 聚合
        // 3.1 聚合分類
        String CategoryAggName = "categoryAgg";
        queryBuilder.addAggregation(AggregationBuilders.terms(CategoryAggName).field("cid3"))
        // 3.2 聚合品牌
        String BrandAggName = "brandAgg";
        queryBuilder.addAggregation(AggregationBuilders.terms(BrandAggName).field("brandId"));

        // 4 查詢
        AggregatedPage<Goods> result = template.queryForPage(queryBuilder.build(), Goods.class);

        // 5 解析結果
        // 5.1 解析分頁結果
        long total = result.getTotalElements();
        long totalPage = result.getTotalPages();
        List<Goods> goodsList = result.getContent();

        // 5.2 解析聚合結果
        Aggregations aggs = result.getAggregations();
        // 分類聚合
        List<Category> categories = parseCategoryAgg(aggs.get(CategoryAggName));
        // 品牌集合
        List<Brand> brands = parseBrandAgg(aggs.get(BrandAggName));
        
        return new SearchResult(total, totalPage, goodsList,categories,brands);
    }

    // 解析商品分類聚合結果
    private List<Category> parseCategoryAgg(LongTerms terms) {

        try {
            List<Long> ids = terms.getBuckets().stream()
                    .map(bucket -> bucket.getKeyAsNumber().longValue())
                    .collect(Collectors.toList());
            List<Category> categories = categoryClient.queryCategoryByIds(ids);

            return categories;
        }catch (Exception e){
            return null;
        }
    }

    // 解析品牌聚合結果
    private List<Brand> parseBrandAgg(LongTerms terms) {
        try {
            List<Long> ids = terms.getBuckets().stream()
                    .map(bucket -> bucket.getKeyAsNumber().longValue())
                    .collect(Collectors.toList());

            List<Brand> brands = brandClient.queryBrandsByIds(ids);
            return brands;
        }catch (Exception e){
            return null;
        }
    }
           

8.3 生成規格參數過濾

8.3.1 分析

有四個問題需要先思考清楚:

  • 什麼時候顯示規格參數過濾?
  • 如何知道哪些規格需要過濾?
  • 要過濾的參數,其可選值是如何擷取的?
  • 規格過濾的可選值,其資料格式怎樣的?

| 什麼情況下顯示有關規格參數的過濾?

如果使用者尚未選擇商品分類,或者聚合得到的分類數大于1,那麼就沒必要進行規格參數的聚合。因為不同分類的商品,其規格是不同的。

是以,我們在背景需要對聚合得到的商品分類數量進行判斷,如果等于1,我們才繼續進行規格參數的聚合。

| 如何知道哪些規格需要過濾?

我們不能把資料庫中的所有規格參數都拿來過濾。因為并不是所有的規格參數都可以用來過濾,參數的值是不确定的。

值的慶幸的是,我們在設計規格參數時,已經标記了某些規格可搜尋,某些不可搜尋。

是以,一旦商品分類确定,我們就可以根據商品分類查詢到其對應的規格,進而知道哪些規格要進行搜尋。

| 要過濾的參數,其可選值是如何擷取的?

雖然資料庫中有所有的規格參數,但是不能把一切資料都用來供使用者選擇。

與商品分類和品牌一樣,應該是從使用者搜尋得到的結果中聚合,得到與結果品牌的規格參數可選值。

| 規格過濾的可選值,其資料格式怎樣的?

我們直接看頁面效果:

樂優商城--服務(五) : 搜尋微服務(LySearchApplication)1. 引言2. 搭建項目3. 索引庫資料格式分析4. 商品微服務提供接口5. 調用接口6. 導入資料7. 實作基本搜尋8. 結果過濾9. 優化

我們之前存儲時已經将資料分段,恰好符合這裡的需求

8.3.2 實作

總結一下,應該是以下幾步:

  • 1)使用者搜尋得到商品,并聚合出商品分類
  • 2)判斷分類數量是否等于1,如果是則進行規格參數聚合
  • 3)先根據分類,查找可以用來搜尋的規格
  • 4)對規格參數進行聚合
  • 5)将規格參數聚合結果整理後傳回

8.3.2.1 擴充傳回結果

傳回結果中需要增加新資料,用來儲存規格參數過濾條件。這裡與前面的品牌和分類過濾的json結構類似,是以,在java中我們用List<Map<String,Object>>來表示。

public class SearchResult extends PageResult<Goods>{

    private List<Category> categories;// 分類過濾條件
    private List<Brand> brands; // 品牌過濾條件
    private List<Map<String,String>> specs;
    public SearchResult(Long total, Integer totalPage, List<Goods> items,
                        List<Category> categories, List<Brand> brands,
                        List<Map<String,String>> specs) {
        super(total, totalPage, items);
        this.categories = categories;
        this.brands = brands;
        this.specs = specs;
    }
}
           

8.3.2.2 完整代碼

// 搜尋功能
public SearchResult search(SearchRequest request) {

    String key = request.getKey(); // 搜尋條件 eg:手機
    if (StringUtils.isBlank(key)) {
        throw new LyException(ExceptionEnum.SPEC_PARAM_NOT_FOUND);
    }

    int page = request.getPage() - 1;// page,elasticSearch預設從0開始,要進行減一操作否則一直查詢不到第一頁
    int size = request.getSize();

    // 1 建立查詢建構器(spring提供的)
    NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder();

    // 2 分頁
    queryBuilder.withPageable(PageRequest.of(page,size));

    // 3 過濾
    // 3.1 結果過濾
    queryBuilder.withSourceFilter(new FetchSourceFilter(new String[]{"id", "subTitle", "skus"}, null));

    // 3.2 過濾
    QueryBuilder baseQuery = QueryBuilders.matchQuery(name:"all"),request.getKey();
    queryBuilder.withQuery(baseQuery);

    // 4 聚合
    // 4.1 聚合分類
    String CategoryAggName = "categoryAgg";
    queryBuilder.addAggregation(AggregationBuilders.terms(CategoryAggName).field("cid3"));

    // 4.2 聚合品牌
    String BrandAggName = "brandAgg";
    queryBuilder.addAggregation(AggregationBuilders.terms(BrandAggName).field("brandId"));

    // 5 查詢
    AggregatedPage<Goods> result = template.queryForPage(queryBuilder.build(), Goods.class);

    // 6 解析結果
    // 6.1 解析分頁結果
    long total = result.getTotalElements();
    long totalPage = result.getTotalPages(); //int totalPage = ((int) total + size -1)/size;

    // 6.2 解析聚合結果
    Aggregations aggs = result.getAggregations();
    // 分類聚合
    List<Category> categories = parseCategoryAgg(aggs.get(CategoryAggName));
    // 品牌集合
    List<Brand> brands = parseBrandAgg(aggs.get(BrandAggName));

    // 規格參數的聚合
    List<Map<String, Object>> specs = null;
    // 商品分類存在且值為1,才可以進行規格參數的聚合
    if(categories != null && categories.size() == 1){
        specs = buildSpecificationAgg(categories.get(0).getId(),baseQuery);
    }

    List<Goods> goodsList = result.getContent();
    return new SearchResult(total, totalPage, goodsList,categories,brands,specs);
}

// 解析商品分類聚合結果
private List<Category> parseCategoryAgg(LongTerms terms) {

    try {
        List<Long> ids = terms.getBuckets().stream()
                .map(bucket -> bucket.getKeyAsNumber().longValue())
                .collect(Collectors.toList());
        List<Category> categories = categoryClient.queryCategoryByIds(ids);

        return categories;
    }catch (Exception e){
        return null;
    }
}

// 解析品牌聚合結果
private List<Brand> parseBrandAgg(LongTerms terms) {
    try {
        List<Long> ids = terms.getBuckets().stream()
                .map(bucket -> bucket.getKeyAsNumber().longValue())
                .collect(Collectors.toList());

        List<Brand> brands = brandClient.queryBrandsByIds(ids);
        return brands;
    }catch (Exception e){
        return null;
    }
}

// 聚合規格參數
private List<Map<String,Object>> buildSpecificationAgg(Long cid, QueryBuilder baseQuery) {

    List<Map<String,Object>> specs = new ArrayList<>();

    // 查詢需要聚合的規格參數
    List<SpecParam> params = specificationClient.querySpecParams(null, cid, true);
    // 聚合
    NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder();
    // 1.1 帶上基礎查詢條件
    queryBuilder.withQuery(baseQuery);
    // 1.2 周遊params 聚合名字 字段
    for (SpecParam param : params) {
        String name = param.getName();//規格參數的名字的不會重複 作為聚合的name
        queryBuilder.addAggregation(
                AggregationBuilders.terms(name).field("specs." + name + ".keyword"));
    }

    // 擷取結果
    AggregatedPage<Goods> result = template.queryForPage(queryBuilder.build(), Goods.class);

    // 解析結果
    Aggregations aggs = result.getAggregations();

    // 有幾個param就要做幾個聚合
    for (SpecParam param : params) {
        // 規格參數名稱
        String name = param.getName();
        Terms terms = aggs.get(name);
        // 待選項
        List<Object> options = terms.getBuckets().stream()
                .map(b -> b.getKeyAsString()).collect(Collectors.toList());
        // 準備map
        Map<String, Object> map = new HashMap<>();
        map.put("k",name);
        map.put("options",options);

        specs.add(map);
    }
    return specs;
}
           

8.4 過濾條件的篩選

當我們點選頁面的過濾項,要做哪些事情?

  • 把過濾條件儲存在search對象中(watch監控到search變化後就會發送到背景)
  • 在頁面頂部展示已選擇的過濾項
  • 把商品分類展示到頂部面包屑

8.4.1 拓展請求對象

我們需要在請求類:SearchRequest中添加屬性,接收過濾屬性。過濾屬性都是鍵值對格式,但是key不确定,是以用一個map來接收即可。

樂優商城--服務(五) : 搜尋微服務(LySearchApplication)1. 引言2. 搭建項目3. 索引庫資料格式分析4. 商品微服務提供接口5. 調用接口6. 導入資料7. 實作基本搜尋8. 結果過濾9. 優化

8.4.2 添加過濾條件

目前,我們的基本查詢是這樣的:

樂優商城--服務(五) : 搜尋微服務(LySearchApplication)1. 引言2. 搭建項目3. 索引庫資料格式分析4. 商品微服務提供接口5. 調用接口6. 導入資料7. 實作基本搜尋8. 結果過濾9. 優化

現在,我們要把頁面傳遞的過濾條件也進入進去。

是以不能在使用普通的查詢(搜尋條件與過濾條件不能放在一塊),而是要用到BooleanQuery,基本結構是這樣的:

GET /goods/_search
{
    "query":{
        "bool":{
        	"must":{ "match": { "title": "小米手機",operator:"and"}},
        	"filter":{
                "range":{"price":{"gt":2000.00,"lt":3800.00}}
        	}
        }
    }
}
           

是以,我們對原來的基本查詢進行改造:

樂優商城--服務(五) : 搜尋微服務(LySearchApplication)1. 引言2. 搭建項目3. 索引庫資料格式分析4. 商品微服務提供接口5. 調用接口6. 導入資料7. 實作基本搜尋8. 結果過濾9. 優化

因為比較複雜,我們将其封裝到一個方法中:

// 建構基本查詢條件
private QueryBuilder buildBaseQuery(SearchRequest request) {

    // 建立布爾查詢
    BoolQueryBuilder queryBuilder = QueryBuilders.boolQuery();

    // 查詢條件
    queryBuilder.must(QueryBuilders.matchQuery("all", request.getKey()));
    // 過濾條件 (有n個過濾條件是以要周遊map)
    Map<String, String> map = request.getFilter();
    for (Map.Entry<String, String> entry : map.entrySet()) {
        String key = entry.getKey();
        // 處理key
        if(!"cid3".equals(key) && !"brandId".equals(key)){
            key = "specs." + key + ".keyword";
        }
        String value = entry.getValue();
        queryBuilder.filter(QueryBuilders.termQuery(key,value));
    }

    return queryBuilder;
}
           

其它不變。

9. 優化

優化搜尋微服務