搜索微服务搭建
索引库数据导入
创建搜索服务
Pom文件:
<?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.taotao.parent</groupId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<groupId>com.taotao.service</groupId>
<artifactId>ly-search</artifactId>
<version>1.0.0-SNAPSHOT</version>
<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>
</dependencies>
</project>
application.yml:
server:
port: 8083
spring:
application:
name: search-service
data:
elasticsearch:
cluster-name: elasticsearch
cluster-nodes: 192.168.56.101:9300
eureka:
client:
service-url:
defaultZone: http://127.0.0.1:10086/eureka
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
instance-id: ${spring.application.name}:${server.port}
启动类:
@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients
public class TtSearchService {
public static void main(String[] args) {
SpringApplication.run(LySearchService.class, args);
}
}
索引库数据格式分析
需要商品数据导入索引库,便于用户搜索。SPU和SKU,到底如何保存到索引库?
以结果为导向
每一个搜索结果都有至少1个商品,当我们选择大图下方的小图,商品会跟着变化。
因此,搜索的结果是SPU,即多个SKU的集合。
既然搜索的结果是SPU,那么我们索引库中存储的应该也是SPU,但是却需要包含SKU的信息。
需要什么数据
直观能看到的:图片、价格、标题、副标题
暗藏的数据:spu的id,sku的id
另外,页面还有过滤条件
这些过滤条件也都需要存储到索引库中,包括:
商品分类、品牌、可用来搜索的规格参数等
综上所述,我们需要的数据格式有:
spuId、SkuId、商品分类id、品牌id、图片、价格、商品的创建时间、sku信息集、可搜索的规格参数
最终的数据结构
我们创建一个类,封装要保存到索引库的数据,并设置映射属性:
@Document(indexName = "goods", type = "docs", shards = 1, replicas = 0)
public class Goods {
@Id
private Long id; // spuId
@Field(type = FieldType.text, analyzer = "ik_max_word")
private String all; // 所有需要被搜索的信息,包含标题,分类,甚至品牌
@Field(type = FieldType.keyword, index = false)
private String subTitle;// 卖点
private Long brandId;// 品牌id
private Long cid1;// 1级分类id
private Long cid2;// 2级分类id
private Long cid3;// 3级分类id
private Date createTime;// 创建时间
private List<Long> price;// 价格
@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:红色
商品微服务提供接口
索引库中的数据来自于数据库,不能直接去查询商品的数据库,因为真实开发中,每个微服务都是相互独立的,包括数据库也是一样。所以只能调用商品微服务提供的接口服务。
需要的数据:
-
SPU信息 *
-
SKU信息 *
-
SPU的详情 *
-
商品分类名称(拼接all字段)
-
品牌名称 *
-
规格参数
需要哪些服务:
- 第一:分批查询spu的服务
- 第二:根据spuId查询sku的服务
- 第三:根据spuId查询SpuDetail的服务
- 第四:根据商品分类id,查询商品分类名称
需要额外提供一个查询商品分类名称的接口。
商品分类名称查询
controller:
/**
* 根据商品分类id查询名称
* @param ids 要查询的分类id集合
* @return 多个名称的集合
*/
@GetMapping("names")
public ResponseEntity<List<String>> queryNameByIds(@RequestParam("ids") List<Long> ids){
List<String > list = this.categoryService.queryNameByIds(ids);
if (list == null || list.size() < 1) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
return ResponseEntity.ok(list);
}
service:
public List<String> queryNameByIds(List<Long> ids) {
return this.categoryMapper.selectByIdList(ids).stream()
.map(Category::getName).collect(Collectors.toList());
}
编写FeignClient
问题展现:
现在,要在搜索微服务调用商品微服务的接口。
第一步要引入商品微服务依赖:Tt-item-interface
。
<!--商品微服务-->
<dependency>
<groupId>com.taotao.service</groupId>
<artifactId>Tt-item-interface</artifactId>
<version>${taotao.latest.version}</version>
</dependency>
第二步,编写FeignClient
@FeignClient(value = "item-service")
@RequestMapping("/goods")
public interface GoodsClient {
/**
* 分页查询商品
* @param page
* @param rows
* @param saleable
* @param key
* @return
*/
@GetMapping("/spu/page")
ResponseEntity<PageResult<SpuBo>> querySpuByPage(
@RequestParam(value = "page", defaultValue = "1") Integer page,
@RequestParam(value = "rows", defaultValue = "5") Integer rows,
@RequestParam(value = "saleable", defaultValue = "true") Boolean saleable,
@RequestParam(value = "key", required = false) String key);
/**
* 根据spu商品id查询详情
* @param id
* @return
*/
@GetMapping("/spu/detail/{id}")
ResponseEntity<SpuDetail> querySpuDetailById(@PathVariable("id") Long id);
/**
* 根据spu的id查询sku
* @param id
* @return
*/
@GetMapping("sku/list")
ResponseEntity<List<Sku>> querySkuBySpuId(@RequestParam("id") Long id);
}
以上的这些代码直接从商品微服务中拷贝而来,完全一致。差别就是没有方法的具体实现
FeignClient代码遵循SpringMVC的风格,因此与商品微服务的Controller完全一致。这样就存在一定的问题:
- 代码冗余。尽管不用写实现,只是写接口,但服务调用方要写与服务controller一致的代码,有几个消费者就要写几次。
- 增加开发成本。调用方还得清楚知道接口的路径,才能编写正确的FeignClient。
解决方案:
因此,一种比较友好的实践是这样的:
- 我们的服务提供方不仅提供实体类,还要提供api接口声明
- 调用方不用字节编写接口方法声明,直接继承提供方给的Api接口即可,
第一步:服务的提供方在tt-item-interface
中提供API接口,并编写接口声明:
商品分类服务接口:
@RequestMapping("category")
public interface CategoryApi {
@GetMapping("names")
ResponseEntity<List<String>> queryNameByIds(@RequestParam("ids") List<Long> ids);
}
商品服务接口:
@RequestMapping("/goods")
public interface GoodsApi {
/**
* 分页查询商品
* @param page
* @param rows
* @param saleable
* @param key
* @return
*/
@GetMapping("/spu/page")
PageResult<SpuBo> querySpuByPage(
@RequestParam(value = "page", defaultValue = "1") Integer page,
@RequestParam(value = "rows", defaultValue = "5") Integer rows,
@RequestParam(value = "saleable", defaultValue = "true") Boolean saleable,
@RequestParam(value = "key", required = false) String key);
/**
* 根据spu商品id查询详情
* @param id
* @return
*/
@GetMapping("/spu/detail/{id}")
SpuDetail querySpuDetailById(@PathVariable("id") Long id);
/**
* 根据spu的id查询sku
* @param id
* @return
*/
@GetMapping("sku/list")
List<Sku> querySkuBySpuId(@RequestParam("id") Long id);
}
需要在ly-item-interface中引入一些依赖:
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webmvc</artifactId>
</dependency>
<dependency>
<groupId>com.taotao.common</groupId>
<artifactId>tt-common</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
第二步:在调用方Tt-search
中编写FeignClient,但不要写方法声明了,直接继承Tt-item-interface
提供的api接口:
商品的FeignClient:
@FeignClient(value = "item-service")
public interface GoodsClient extends GoodsApi {
}
商品分类的FeignClient:
@FeignClient(value = "item-service")
public interface CategoryClient extends CategoryApi {
}
测试
引入springtest依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
创建测试类:
在接口上按快捷键:Ctrl + Shift + T
@RunWith(SpringRunner.class)
@SpringBootTest(classes = LySearchService.class)
public class CategoryClientTest {
@Autowired
private CategoryClient categoryClient;
@Test
public void testQueryCategories() {
List<String> names = this.categoryClient.queryNameByIds(Arrays.asList(1L, 2L, 3L));
names.forEach(System.out::println);
}
}
导入数据
创建GoodsRepository
java代码:
public interface GoodsRepository extends ElasticsearchRepository<Goods, Long> {
}
创建索引
我们新建一个测试类,在里面进行数据的操作:
@RunWith(SpringRunner.class)
@SpringBootTest(classes = LySearchService.class)
public class ElasticsearchTest {
@Autowired
private GoodsRepository goodsRepository;
@Autowired
private ElasticsearchTemplate elasticsearchTemplate;
@Test
public void createIndex(){
// 创建索引
this.elasticsearchTemplate.createIndex(Goods.class);
// 配置映射
this.elasticsearchTemplate.putMapping(Goods.class);
}
}
导入数据
导入数据其实就是查询数据,然后把查询到的Spu转变为Goods来保存,因此我们先编写一个IndexService,然后在里面定义一个方法, 把Spu转为Goods
@Service
public class IndexService {
@Autowired
private GoodsClient goodsClient;
@Autowired
private CategoryClient categoryClient;
@Autowired
private SpecificationClient specificationClient;
public Goods buildGoods(Spu spu) {
Long id = spu.getId();
// 1、准备数据
// sku集合
List<Sku> skus = this.goodsClient.querySkuList(id);
// spuDetail
SpuDetail detail = this.goodsClient.querySpuDetailBySpuId(id);
// 商品分类
List<String> names = this.categoryClient.queryNamesByIds(
Arrays.asList(spu.getCid1(), spu.getCid2(), spu.getCid3()));
// 查询规格参数
List<SpecParam> params = this.specificationClient.querySpecParams(
null, spu.getCid3(), null, true);
// TODO 查询品牌名称
// 处理sku
List<Long> prices = new ArrayList<>();
List<Map<String,Object>> skuList = new ArrayList<>();
for (Sku sku : skus) {
prices.add(sku.getPrice());
Map<String,Object> map = new HashMap<>();
map.put("id", sku.getId());
map.put("title", sku.getTitle());
map.put("image", StringUtils.isBlank(sku.getImages()) ? "" : sku.getImages().split(",")[0]);
map.put("price", sku.getPrice());
skuList.add(map);
}
// 处理规格参数
Map<Long, String> genericMap = JsonUtils.parseMap(detail.getGenericSpec(), Long.class, String.class);
Map<Long, List<String>> specialMap = JsonUtils.nativeRead(
detail.getSpecialSpec(), new TypeReference<Map<Long, List<String>>>() {
});
Map<String, Object> specs = new HashMap<>();
for (SpecParam param : params) {
if(param.getGeneric()){
// 通用参数
String value = genericMap.get(param.getId());
if(param.getNumeric()){
// 数值类型,需要存储一个分段
value = this.chooseSegment(value, param);
}
specs.put(param.getName(), value);
}else{
// 特有参数
specs.put(param.getName(), specialMap.get(param.getId()));
}
}
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.setId(id);
goods.setSubTitle(spu.getSubTitle());
// 搜索条件 拼接:标题、分类、品牌
goods.setAll(spu.getTitle() + " " + StringUtils.join(names, " "));
goods.setPrice(prices);
goods.setSkus(JsonUtils.serialize(skuList));
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,然后调用IndexService中的方法,把SPU变为Goods,然后写入索引库:
@Test
public void loadData(){
int page = 1;
int rows = 100;
int size = 0;
do {
// 查询spu
PageResult<SpuVO> result = this.goodsClient.querySpuByPage(page, rows, true, null);
List<SpuVO> spus = result.getItems();
// spu转为goods
List<Goods> goods = spus.stream().map(spu -> this.indexService.buildGoods(spu))
.collect(Collectors.toList());
// 把goods放入索引库
this.goodsRepository.saveAll(goods);
size = spus.size();
page++;
}while (size == 100);
}
通过kibana查询, 可以看到数据成功导入
实现基本搜索
后台提供搜索接口
controller
首先分析几个问题:
-
请求方式:Post
-
请求路径:/search/page,不过前面的/search应该是网关的映射路径,因此真实映射路径page,代表分页查询
-
请求参数:json格式,目前只有一个属性:key,搜索关键字,但是搜索结果页一定是带有分页查询的,所以将来肯定会有page属性,因此我们可以用一个对象来接收请求的json数据:
public class SearchRequest { private String key;// 搜索条件 private Integer page;// 当前页 private static final Integer DEFAULT_SIZE = 20;// 每页大小,不从页面接收,而是固定大小 private static final Integer DEFAULT_PAGE = 1;// 默认页 public String getKey() { return key; } public void setKey(String key) { this.key = key; } public Integer getPage() { if(page == null){ return DEFAULT_PAGE; } // 获取页码时做一些校验,不能小于1 return Math.max(DEFAULT_PAGE, page); } public void setPage(Integer page) { this.page = page; } public Integer getSize() { return DEFAULT_SIZE; } }
-
返回结果:作为分页结果,一般都两个属性:当前页数据、总条数信息,我们可以使用之前定义的PageResult类
代码:
@RestController
@RequestMapping
public class SearchController {
@Autowired
private IndexService indexService;
/**
* 搜索商品
*
* @param request
* @return
*/
@PostMapping("page")
public ResponseEntity<PageResult<Goods>> search(@RequestBody SearchRequest request) {
PageResult<Goods> result = this.searchService.search(request);
if (result == null) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
return ResponseEntity.ok(result);
}
}
service
public PageResult<Goods> search(SearchRequest request) {
String key = request.getKey();
if(StringUtils.isBlank(key)){
// 如果用户没搜索条件,我们可以给默认的,或者返回null
return null;
}
Integer page = request.getPage() - 1;// page 从0开始
Integer size = request.getSize();
// 1、创建查询构建器
NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder();
// 2、查询
// 2.1、对结果进行筛选
queryBuilder.withSourceFilter(new FetchSourceFilter(new String[]{"id","skus","subTitle"}, null));
// 2.2、基本查询
queryBuilder.withQuery(QueryBuilders.matchQuery("all", key));
// 2.3、分页
queryBuilder.withPageable(PageRequest.of(page, size));
// 3、返回结果
Page<Goods> result = this.repository.search(queryBuilder.build());
// 4、解析结果
long total = result.getTotalElements();
long totalPage = (total + size - 1) / size;
return new PageResult<>(total, totalPage, result.getContent());
}
注意点:我们要设置SourceFilter,来选择要返回的结果,否则返回一堆没用的数据,影响查询效率。
解决办法很简单,在application.yml中添加一行配置,json处理时忽略空值:
spring:
jackson:
default-property-inclusion: non_null # 配置json处理时忽略空值
页面分页效果
刚才的查询中,我们默认了查询的页码和每页大小,因此所有的分页功能都无法使用,接下来我们一起看看分页功能条该如何制作。
这里要分两步,
- 第一步:如何生成分页条
- 第二步:点击分页按钮,我们做什么
需要的数据
分页数据应该是根据总页数、当前页、总条数等信息来计算得出。
- 当前页:肯定是由页面来决定的,点击按钮会生成不同的页
- 总页数:需要后台传递给我们
- 总条数:需要后台传递给我们
后台提供数据
后台返回的结果中,要包含total和totalPage,我们改造下刚才的接口:
在我们返回的PageResult对象中,其实是有totalPage字段的:
//分页
queryBuilder.withPageable(PageRequest.of(page, size));
//查询,获取结果
Page<Goods> result = this.goodsRepository.search(queryBuilder.build());
// 封装结果并返回
//总条数
long total = result.getTotalElements();
//总页数
long totalPage = (total + size - 1) / size;
return new PageResult<>(total, totalPage, result.getContent());
排序
String sortBy = request.getSortBy();
Boolean descending = request.getDescending();
if(StringUtils.isNotBlank(sortBy)){
queryBuilder.withSort(SortBuilders.fieldSort(sortBy).order(descending? SortOrder.DESC:SortOrder.ASC));
}