搜尋微服務:
- 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:全文檢索技術。
如上所述,Elasticsearch具備以下特點:
- 分布式,無需人工搭建叢集(solr就需要人為配置,使用Zookeeper作為注冊中心)
- Restful風格,一切API都遵循Rest原則,容易上手
- 近實時搜尋,資料更新在Elasticsearch中幾乎是完全同步的。
1.2 kibana
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通過注解來聲明字段的映射屬性,有下面的三個注解:
-
作用在類(Goods),标記實體類為文檔對象,一般有兩個屬性@Document
- indexName:對應索引庫名稱
- type:對應在索引庫中的類型
- shards:分片數量,預設5
- replicas:副本數量,預設1
-
作用在成員變量,标記一個字段作為id主鍵@Id
-
作用在成員變量,标記為文檔的字段,并指定字段映射屬性:@Field
- type:字段類型,是是枚舉:FieldType
- index:是否索引,布爾類型,預設是true
- store:是否存儲,布爾類型,預設是false
- analyzer:分詞器名稱
- 增删改不用
,ElasticsearchTemplate一般會用來做原生的複雜查詢,比如聚合,我們一般的普通增删改查用不到,而spring給我們提供了ElasticsearchTemplate
( Spring Data 的強大之處,就在于你不用寫任何DAO處理,自動根據方法名或類的資訊進行CRUD操作。隻要你定義一個接口,然後繼承Repository提供的一些子接口,就能具備各種基本的CRUD功能。)ElasticsearchRepository
- 是以我們應該寫個
接口繼承ElasticsearchRepository,第一個泛型是實體類,第二個是id類型,接下來就可以直接用了GoodsRepository
-
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 以結果為導向
我們來看下搜尋結果頁:
可以看到,每一個搜尋結果都有至少1個商品,當我們選擇大圖下方的小圖,商品會跟着變化。
是以,搜尋的結果是SPU,即多個SKU的集合。
既然搜尋的結果是SPU,那麼我們索引庫中存儲的應該也是SPU,但是卻需要包含SKU的資訊。
3.2 需要什麼資料
由上圖可以直覺能看到的:圖檔、價格、标題、副标題(屬于SKU資料,用來展示的);暗藏的資料:spu的id,sku的id
另外,頁面還有過濾條件:
這些過濾條件也都需要存儲到索引庫中,包括:商品分類、品牌、可用來搜尋的規格參數等
綜上所述,我們需要的資料格式有:
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接口,并編寫接口聲明:
品牌服務接口:
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;
}
}
因為過濾參數中有一類比較特殊,就是數值區間:
是以我們在存入時要進行處理:
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 頁面分析
- 請求方式: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")
;
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 測試
資料是查到了,但是因為我們隻查詢部分字段,是以結果json 資料中有很多null,解決辦法很簡單,在
application.yml
中添加一行配置,json處理時忽略空值即可。
spring:
jackson:
default-property-inclusion: non_null # 配置json處理時忽略空值
注:所有頁面渲染操作全在前端,這裡就不寫啦
8. 結果過濾
8.1 過濾功能分析
首先看下頁面要實作的效果:
整個過濾部分有3塊:
- 頂部的導航,已經選擇的過濾條件展示:
- 商品分類面包屑,根據使用者選擇的商品分類變化
- 其它已選擇過濾參數
- 過濾條件展示,又包含3部分
- 商品分類展示
- 品牌展示
- 其它規格參數
- 展開或收起的過濾條件的按鈕
頂部導航要展示的内容跟使用者選擇的過濾條件有關。
- 比如使用者選擇了某個商品分類,則面包屑中才會展示具體的分類
- 比如使用者選擇了某個品牌,清單中才會有品牌資訊。
是以,這部分需要依賴第二部分:過濾條件的展示和選擇。是以我們先不着急去做。
展開或收起的按鈕是否顯示,取決于過濾條件現在有多少,如果有很多,那麼就沒必要展示。是以也是跟第二部分的過濾條件有關。
這樣分析來看,我們必須先做第二部分:過濾條件展示。
8.2 生成分類和品牌過濾
先來看分類和品牌。在我們的資料庫中已經有所有的分類和品牌資訊。在這個位置,是不是把所有的分類和品牌資訊都展示出來呢?
顯然不是,使用者搜尋的條件會對商品進行過濾,而在搜尋結果中,不一定包含所有的分類和品牌,直接展示出所有商品分類,讓使用者選擇顯然是不合适的。
無論是分類資訊,還是品牌資訊,都應該從搜尋的結果商品中進行聚合得到。
8.2.1 擴充傳回的結果
原來,我們傳回的結果是PageResult對象,裡面隻有total、totalPage、items3個屬性。但是現在要對商品分類和品牌進行聚合,資料顯然不夠用,我們需要對傳回的結果進行擴充,添加分類和品牌的資料。
那麼問題來了:以什麼格式傳回呢?
看頁面:
分類:頁面顯示了分類名稱,但背後肯定要儲存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,我們才繼續進行規格參數的聚合。
| 如何知道哪些規格需要過濾?
我們不能把資料庫中的所有規格參數都拿來過濾。因為并不是所有的規格參數都可以用來過濾,參數的值是不确定的。
值的慶幸的是,我們在設計規格參數時,已經标記了某些規格可搜尋,某些不可搜尋。
是以,一旦商品分類确定,我們就可以根據商品分類查詢到其對應的規格,進而知道哪些規格要進行搜尋。
| 要過濾的參數,其可選值是如何擷取的?
雖然資料庫中有所有的規格參數,但是不能把一切資料都用來供使用者選擇。
與商品分類和品牌一樣,應該是從使用者搜尋得到的結果中聚合,得到與結果品牌的規格參數可選值。
| 規格過濾的可選值,其資料格式怎樣的?
我們直接看頁面效果:
我們之前存儲時已經将資料分段,恰好符合這裡的需求
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來接收即可。
8.4.2 添加過濾條件
目前,我們的基本查詢是這樣的:
現在,我們要把頁面傳遞的過濾條件也進入進去。
是以不能在使用普通的查詢(搜尋條件與過濾條件不能放在一塊),而是要用到BooleanQuery,基本結構是這樣的:
GET /goods/_search
{
"query":{
"bool":{
"must":{ "match": { "title": "小米手機",operator:"and"}},
"filter":{
"range":{"price":{"gt":2000.00,"lt":3800.00}}
}
}
}
}
是以,我們對原來的基本查詢進行改造:
因為比較複雜,我們将其封裝到一個方法中:
// 建構基本查詢條件
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. 優化
優化搜尋微服務