商城搜索功能
平时购物时习惯都是直接搜索要买的商品,可以发现商品很快的就整齐的列到面前。
但是只靠从数据库里查的话不仅数据库的压力大,有人买要减库存,商家上架商品要存商品,用户搜索商品也要搜索很多数据,只靠数据库不崩溃也会很慢。
为了解决这个问题,我们可以使用全文检索技术:Elasticsearch。
Elasticsearch简介
Elasticsearch甚至可以当做数据库使用,不过这里我们把商品数据存进去,实现快速搜索,快速找到商品。
Elasticsearch具备以下特点:
- 分布式,无需人工搭建集群(solr就需要人为配置,使用Zookeeper作为注册中心)
- Restful风格,一切API都遵循Rest原则,容易上手
- 近实时搜索,数据更新在Elasticsearch中几乎是完全同步的。
Elasticsearch安装到linux我发现就有很多bug,使用的时候有时候还会报错,这是前期解决完bug后有一次又出现了的一个错误,不过好在网上基本都可以找到解决办法。
在linux安装以后,我们可以在win平台的本机下安装Kibana。
Kibana是一个基于Node.js的Elasticsearch索引库数据统计工具,可以利用Elasticsearch的聚合功能,生成各种图表,如柱形图,线状图,饼图等。
而且还提供了操作Elasticsearch索引数据的控制台,并且提供了一定的API提示,非常有利于我们学习Elasticsearch的语法。
安装以后启动就可以正常使用了。
Elasticsearch的字段
Elasticsearch基于Lucene的全文检索库,本质也是存储数据,很多概念与MySQL类似。
1,type
Elasticsearch中支持的数据类型有很多,举例一些常用的:
- String类型,又分两种:
- text:可分词,不可参与聚合
- keyword:不可分词,数据会作为完整字段进行匹配,可以参与聚合
- Numerical:数值类型,分两类
- 基本数据类型:long、interger、short、byte、double、float、half_float
- 浮点数的高精度类型:scaled_float
- 需要指定一个精度因子,比如10或100。elasticsearch会把真实值乘以这个因子后存储,取出时再还原。
- Date:日期类型
elasticsearch可以对日期格式化为字符串存储,但是建议存储为毫秒值,存储为long,节省空间。
2,index
index影响字段的索引情况。
- true:字段会被索引,则可以用来进行搜索。默认值就是true
- false:字段不会被索引,不能用来搜索
index的默认值就是true,也就是说你不进行任何配置,所有字段都会被索引。
但是有些字段是我们不希望被索引的,比如商品的图片信息,就需要手动设置index为false。
3,store
是否将数据进行额外存储。
在学习lucene和solr时,我们知道如果一个字段的store设置为false,那么在文档列表中就不会有这个字段的值,用户的搜索结果中不会显示出来。
但是在Elasticsearch中,即便store设置为false,也可以搜索到结果。
原因是Elasticsearch在创建文档索引时,会将文档中的原始数据备份,保存到一个叫做_source的属性中。而且我们可以通过过滤_source来选择哪些要显示,哪些不显示。
而如果设置store为true,就会在_source以外额外存储一份数据,多余,因此一般我们都会将store设置为false,事实上,store的默认值就是false。
也就是说,对于额外存储我们啥都不用做也行。
Elasticsearch的使用
首先注意Elasticsearch本身就是分布式的,因此即便你只有一个节点,Elasticsearch默认也会对你的数据进行分片和副本操作,当你向集群添加新数据时,数据也会在新加入的节点中进行平衡。
因为Elasticsearch本身也有自己的语法,不过这里我直接用Spring Data Elasticsearch,这个是Spring提供的套件,引入坐标,在yml中配置就好。
pom
<!--elasticsearch-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
yml
我这里填写的虚拟机里的Elasticsearch地址
spring:
application:
name: search-service
data:
elasticsearch:
cluster-name: elasticsearch
cluster-nodes: 192.168.209.128:9300
下面开始使用:
第一步
要使用Elasticsearch做搜索,首先要创建索引,就跟创建一个数据库一样。
创建数据库要准备一个商品(goods)的实体类
@Data
@Document(indexName = "goods", type = "docs", shards = 1, replicas = 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;
private Long cid1;
private Long cid2;
private Long cid3;
private Date createTime;
private Set<Long> price; //是所有sku的价格集合。方便根据价格进行筛选过滤
@Field(type = FieldType.Keyword, index = false)
private String skus; //sku信息的json结构数据
private Map<String, Object> specs; //可搜索的规格参数,key是参数名,值是参数值
}
其中的注解解释:
Spring Data通过注解来声明字段的映射属性,有下面的三个注解:
- @Document 作用在类,标记实体类为文档对象,一般有两个属性
- indexName:对应索引库名称
- type:对应在索引库中的类型
- shards:分片数量,默认5
- replicas:副本数量,默认1
- @Id 作用在成员变量,标记一个字段作为id主键
- @Field 作用在成员变量,标记为文档的字段,并指定字段映射属性:
- type:字段类型,是是枚举:FieldType
- index:是否索引,布尔类型,默认是true
- store:是否存储,布尔类型,默认是false
- analyzer:分词器名称
第二步
创建索引库:
创建一个测试类比较好,因为就用一次。一会往索引库存数据也在这里。
@Autowired
private ElasticsearchTemplate template;
/**
* 映射配置(mappings)
*
* 字段的数据类型、属性、是否索引、是否存储等特性
*/
@Test
public void test() {
template.createIndex(Goods.class);
template.putMapping(Goods.class);
}
第三步
现在索引库也弄好了,就需要向里面塞数据了,数据需要从数据库中查,这一步比较麻烦。
首先创建跟通用mapper差不多的接口,也需要填上实体类,和主键类型。
public interface GoodsRepository extends ElasticsearchRepository<Goods, Long> {
}
service:
代码比较多,解释一下:
- 像BrandClient这种的是调用商品微服务处理,远程调用需要用到fegin,简单的做法就是item微服务那里事先写好api接口,上面写好功能,像下面这样。然后本地的微服务继承这个借口,注解开启fegin,填写item的eureka服务名称(@FeignClient(“item-service”))
@GetMapping("brand/{id}")
Brand queryById(@PathVariable("id") Long id);
- 要存到索引库的东西有很多,要花点功夫查询的东西一共分为4个步骤,注释有S1,S2,S3,S4实现的步骤。
- JsonUtils是一个自己写的工具类。
@Service
public class SearchService {
/**
* 要把查询的结果封装成goods对象,因为搜索所需要的就是spu
*
* @param spu
* @return
*/
@Autowired
private CategoryClient categoryClient;
@Autowired
private BrandClient brandClient;
@Autowired
private GoodsClient goodsClient;
@Autowired
private SpecClient specClient;
@Autowired
private GoodsRepository goodsRepository;
@Autowired
private ElasticsearchTemplate template;
public Goods buildGoods(Spu spu) {
/**
* S1,下面到拼接搜索字段的注释那里全是为了拼接搜索字段
*/
// 查询分类
Long spuId = spu.getId();
List<Category> categoryList = categoryClient.queryCategoryByIds(Arrays.asList(spu.getCid1(), spu.getCid2(), spu.getCid3()));
if (CollectionUtils.isEmpty(categoryList)) {
throw new LyException(ExceptionEnum.CATEGORY_NOT_FOUND);
}
// 获取category的名字
List<String> names = categoryList.stream().map(Category::getName).collect(Collectors.toList());
// 查询品牌
Brand brand = brandClient.queryById(spu.getBrandId());
if (brand == null) {
throw new LyException(ExceptionEnum.BRAND_NOT_FOUND);
}
// 拼接搜索字段
String all = spu.getTitle() + StringUtils.join(names, " ") + brand.getName();
/**
* S2,查询价格
*/
List<Sku> skus = goodsClient.querySkuByIds(spuId);
if (CollectionUtils.isEmpty(skus)) {
throw new LyException(ExceptionEnum.GOODS_NOT_FOUND);
}
// Set<Long> price = skus.stream().map(Sku::getPrice).collect(Collectors.toSet());
//存储price的集合,这样比上面那个更省性能。
TreeSet<Long> priceSet = new TreeSet<>();
/**
* S3,对skus进行处理
*/
//设置存储skuf的json结构的集合,用map结果转化sku对象,转化为json之后与对象结构相似(或者重新定义一个对象,存储前台要展示的数据,并把sku对象转化成自己定义的对象)
List<Map<String, Object>> skuf = new ArrayList<>();
//从sku中取出要进行展示的字段,并将sku转换成json格式
for (Sku sku : skus) {
HashMap<String, Object> map = new HashMap<>();
map.put("id", sku.getId());
map.put("title", sku.getTitle());
//sku中有多个图片,只展示第一张
map.put("image", StringUtils.substringBefore(sku.getImages(), ","));
map.put("price", sku.getPrice());
skuf.add(map);
priceSet.add(sku.getPrice());
}
/**
*S4
*/
// 查询规格参数
List<SpecParam> params = specClient.queryParamByList(null, spu.getCid3(), true, null);
if (CollectionUtils.isEmpty(params)) {
throw new LyException(ExceptionEnum.SPEC_PARAM_NOT_FOUND);
}
// 查询商品详情
SpuDetail spuDetail = goodsClient.queryDetailById(spuId);
//获取通用规格参数
Map<Long, String> genericSpec = JsonUtils.toMap(spuDetail.getGenericSpec(), Long.class, String.class);
//获取特有规格参数
Map<Long, List<String>> specialSpec = JsonUtils.nativeRead(spuDetail.getSpecialSpec(), new TypeReference<Map<Long, List<String>>>() {
});
// 规格参数,key是规格参数的名字,value是规格参数的值
HashMap<String, Object> map = new HashMap<>();
//对规格进行遍历,并封装spec,其中spec的key是规格参数的名称,值是商品详情中的值
for (SpecParam param : params) {
//key是规格参数的名称
String key = param.getName();
Object value = "";
if (param.getGeneric()) {
//参数是通用属性,通过规格参数的ID从商品详情存储的规格参数中查出值
value = genericSpec.get(param.getId());
// 判断是否是数值类型
if (param.getNumeric()) {
//参数是数值类型,处理成段,方便后期对数值类型进行范围过滤
value = chooseSegment(value.toString(), param);
}
} else {
//参数不是通用类型
value = specialSpec.get(param.getId());
}
value = (value == null ? "其他" : value);
//存入map
map.put(key, value);
}
// 构建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.setId(spu.getId());
goods.setAll(all); // S1,搜索字段,包含标题,分类,品牌,规格等
goods.setPrice(priceSet); //S2,所有sku的价格集合
goods.setSkus(JsonUtils.toString(skuf)); //S3,所有sku集合json格式
goods.setSpecs(null); //S4,所有可搜索的规格参数,这里填map,我忘了,存索引库也忘了
goods.setSubtitle(spu.getSubTitle());
return goods;
}
/**
* 将规格参数为数值型的参数划分为段
*
* @param value
* @param p
* @return
*/
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;
}
}
第四步
在刚才的测试类中存入
@Autowired
private GoodsRepository goodsRepository;
@Autowired
private GoodsClient goodsClient;
@Autowired
private SearchService searchService;
@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());
// 存入索引库
goodsRepository.saveAll(goodsList);
// 翻页
page++;
size = spuList.size();
} while (size == 100);
}
第四步
最后一步,实现搜索功能的代码,也是一顿操作猛如虎
我的搜索过滤功能没有实现,报的错也搜不到解决办法,现在好像知道了,上面goods.setSpecs(null); 里面应该是map,结果没有传进去。忙了好久没解决就放弃了,现在总结的时候才发现,希望自己以后写代码认真点。。。
/**
* 下面开始是搜索的业务功能
*
* @param request
* @return
*/
public PageResult<Goods> search(SearchRequest request) {
// 因为es的page从0开始,永远有一页显示不出来,所以-1
// page=第几页 size=每页显示多少条数据(已经在类中固定,不能改了)
int page = request.getPage() - 1;
int size = request.getSize();
// 创建查询构造器,可以分页和过滤
NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder();
// 结果过滤,里面第一个参数是包含什么,这里用数组,第二个参数是不包含什么,这里是mull
queryBuilder.withSourceFilter(new FetchSourceFilter(new String[]{"id", "subtitle", "skus"}, null));
// 分页
queryBuilder.withPageable(PageRequest.of(page, size));
// 过滤
queryBuilder.withQuery(QueryBuilders.matchQuery("all", request.getKey()));
// 聚合分类和品牌(二次功能实现添加)
String categoryAggName = "category_agg";
queryBuilder.addAggregation(AggregationBuilders.terms(categoryAggName).field("cid3")); //根据cid进行聚合
// 聚合品牌
String brandAggName = "brand_agg";
queryBuilder.addAggregation(AggregationBuilders.terms(brandAggName).field("brandId"));
// 查询(二次查询的结果不是分页结果,而是聚合结果,不能再用普通查询,需要template查询)
// Page<Goods> search = goodsRepository.search(queryBuilder.build());
AggregatedPage<Goods> search = template.queryForPage(queryBuilder.build(), Goods.class);
// 解析结果
// 解析分页结果
long totalElements = search.getTotalElements();//总条数
int totalPages = search.getTotalPages();//总页数
List<Goods> goodsList = search.getContent(); //当前页面数据
// 解析聚合结果
Aggregations aggs = search.getAggregations();
List<Category> categories = parseCategoryAgg(aggs.get(categoryAggName));
List<Brand> brands = parsebrandAgg(aggs.get(brandAggName));
return new SearchResult<>(totalElements, totalPages, goodsList, categories, brands);
}
private List<Brand> parsebrandAgg(LongTerms terms) {
try {
List<Long> ids = terms.getBuckets().stream().map(b -> b.getKeyAsNumber().longValue()).collect(Collectors.toList());
List<Brand> brands = brandClient.queryBrandByIds(ids);
return brands;
} catch (Exception e) {
return null;
}
}
private List<Category> parseCategoryAgg(LongTerms terms) {
try {
List<Long> ids = terms.getBuckets().stream().map(b -> b.getKeyAsNumber().longValue()).collect(Collectors.toList());
List<Category> categories = categoryClient.queryCategoryByIds(ids);
return categories;
} catch (Exception e) {
return null;
}
}
总结
到这里搜索功能结束了,有很多知识需要巩固,想着存个数据而已,其实要从很多表里面查,存数据然后再取出数据(查询的时候),都有要注意的点,代码的一个小失误没注意导致错误也找不到,这一点应该注意。