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通过注解来声明字段的映射属性,有下面的三个注解:
@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 以结果为导向
我们来看下搜索结果页:
可以看到,每一个搜索结果都有至少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;
}
其它不变。