公司的电商app需要做搜索功能,分析整理后列出如下需求点:
- 商品搜索(标题、描述全文检索)
- 搜索词汉字拼音自适应
- 搜索词自动补全
- 搜索词无匹配结果时给出联想关键词
- 历史搜索记录
- 热门搜索展示
- 搜索发现(搜索推荐)
接下来就是愉悦(tongku)的设计和coding了
涉及技术框架: OpenJDK
11
+ elasticsearch7.6.2
+ Redis + MySQL + Sping-Boot2.3.2
(包含es版本7.6.2
)
商品搜索(标题、描述全文检索)
商品表已经在业务库中存在直接用,只需要设计出es索引就可以。es安装后顺带装上jk、pinyin插件,因为后续要用到拼音功能,不过在本阶段先不管拼音的事儿。
spring-data-elasticsearch
提供了Java Domain到Es indices mapping的自动创建功能,只需配置继承了ElasticsearchRepository
的Repository类,并在项目类配置@EnableElasticsearchRepositories
即可,我们使用此种方式配置
产品表定义:
此处只展示关键字段,其他字段可根据业务需求进行适当冗余,如果能直接从es取回所有需展示字段,就不用再去mysql中查询了,能大大提高效率,但是es索引文件的占用空间也会增加,需要权衡取舍。
另外关于analyzer的选择,此处不展开讲,如有需要可另外开贴讨论
@Document(indexName = "demo-product", refreshInterval = "5s")
@Getter
@Setter
@NoArgsConstructor
@ToString
public class Product {
@Id
private Long id;
//商品名
@MultiField(mainField = @Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart"), otherFields = @InnerField(type = FieldType.Keyword, suffix = "raw", ignoreAbove = 256))
private String productName;
//商品描述
@Field(type = FieldType.Text, analyzer = "ik_smart")
private String description;
//数据更新时间
@Field(type = FieldType.Date_Nanos, index = false)
private Long utime;
//品牌名称
@Field(type = FieldType.Text, analyzer = "ik_smart")
private String brandName;
//分类名称
@Field(type = FieldType.Text, analyzer = "ik_smart")
private String categoryName;
//预配置索引的_class字段
@Field(name = "_class", type = FieldType.Keyword, index = false)
@JsonIgnore
@Getter(value = AccessLevel.NONE)
@Setter(value = AccessLevel.NONE)
private String clazz;
}
产品类对应Repository定义:
//此处只需要声明父类和试题类型class、id类型,即可使用esRepository提供的便利CRUD方法
public interface ProductRepository extends ElasticsearchRepository {
}
maven-pom文件增加引用
org.springframework.boot
spring-boot-starter-data-elasticsearch
application.yml文件添加es相关配置
spring:
elasticsearch:
rest:
uris:
//多个节点继续添加即可
- localhost:9200
以上定义完成后,启动项目,成功后可以看到如下提示(debug级别下):
2021-03-01 15:03:38.480 DEBUG 9664 --- [ main] org.elasticsearch.client.RestClient : request [HEAD http://localhost:9200/demo-product?ignore_throttled=false&ignore_unavailable=false&expand_wildcards=open%2Cclosed&allow_no_indices=false] returned [HTTP/1.1 404 Not Found]
2021-03-01 15:03:39.491 DEBUG 9664 --- [ main] org.elasticsearch.client.RestClient : request [PUT http://localhost:9200/demo-product?master_timeout=30s&timeout=30s] returned [HTTP/1.1 200 OK]
2021-03-01 15:03:39.768 DEBUG 9664 --- [ main] org.elasticsearch.client.RestClient : request [PUT http://localhost:9200/demo-product/_mapping?master_timeout=30s&timeout=30s] returned [HTTP/1.1 200 OK]
如果没有伴随出现报错信息(es索引配置有误,不能创建索引时会给出相应提示),此时es中的product
索引应该已经成功创建了。
使用esGET /demo-product/_mapping
命令可查看索引结构已经完整创建:
{
"demo-product" : {
"mappings" : {
"properties" : {
"_class" : {
"type" : "keyword",
"index" : false
},
"brandName" : {
"type" : "text",
"analyzer" : "ik_smart"
},
"categoryName" : {
"type" : "text",
"analyzer" : "ik_smart"
},
"description" : {
"type" : "text",
"analyzer" : "ik_smart"
},
"id" : {
"type" : "long"
},
"productName" : {
"type" : "text",
"fields" : {
"raw" : {
"type" : "keyword",
"ignore_above" : 256
}
},
"analyzer" : "ik_max_word",
"search_analyzer" : "ik_smart"
},
"utime" : {
"type" : "date_nanos",
"index" : false
}
}
}
}
}
索引表的插入,可使用spring-boot的@Schedule定时器,以指定间隔扫描产品表的新增数据,以es对应product表的字段封装后,插入es索引即可
//部分代码
//valid状态判断可自行去除
List products = isNull(latestUtime) ? productMapper.getAllValidProducts() : productMapper.getModifiedProducts(latestUtime);
if (isNotEmpty(products)) {
Set validProducts = products.parallelStream().filter(p -> p.getStatus() == STATUS_VALID)
.collect(Collectors.toSet());
if (isNotEmpty(validProducts)) productRepository.saveAll(validProducts);
Set invalidProducts = products.parallelStream()
.filter(p -> p.getStatus() == STATUS_INVALID || p.getStatus() == STATUS_DELETED)
.collect(Collectors.toSet());
if (isNotEmpty(invalidProducts)) productRepository.deleteAll(invalidProducts);
}
此时索引表结构和数据已经准备就绪,可以开始写查询方法了!
我们先准备一些测试商品数据:
var product1 = new Product();
product1.setProductName("2020新款中年妈妈秋装风衣");
product1.setBrandName("胖织缘福");
product1.setCategoryName("女式风衣");
product1.setId(1l);
product1.setDescription("胖织缘福是知名的时尚中老年女装品牌,注重个性文化又不过分张扬,推崇时尚简约、独特个性设计风格,通过胖织缘福把自信、魅力潇洒淋漓尽致的展现出来。");
product1.setUtime(Instant.now().getEpochSecond());
var product2 = new Product();
product2.setProductName("戈美其2020春季新款漆皮水钻单鞋女低跟复古舒适休闲英伦风小皮鞋");
product2.setBrandName("戈美其");
product2.setCategoryName("女式单鞋");
product2.setId(2l);
product2.setDescription(
"“戈美其”女鞋成立于1990年,以“追求卓越品质、缔造名优品牌”为宗旨理念。款式时尚,脚感舒适,品质优良\n" + "发货地:浙江温州 发货物流:截单后24小时内顺丰快递发货");
product2.setUtime(Instant.now().getEpochSecond());
var product3 = new Product();
product3.setProductName("【疫情地区不发货】Conniekids【疯狂秒杀】【随机4条装】女童纯棉螺纹吊带");
product3.setBrandName("Conniekids");
product3.setCategoryName("女童背心");
product3.setId(3l);
product3.setDescription(
"CONNIEKIDS品牌总部设在英国伦敦,专注研发设计适合各国成长期儿童的贴身内衣内裤家居服等。品牌创始人热爱环球旅行,关注儿童健康成长,成立慈善基金。品牌每卖出一件衣服就会捐出0.01英镑。");
product3.setUtime(Instant.now().getEpochSecond());
var product4 = new Product();
product4.setProductName("隽达卷边V领针织打底毛衣【疫情地区不发货】");
product4.setBrandName("隽达");
product4.setCategoryName("女式毛衣");
product4.setId(4l);
product4.setDescription("本次活动以春装为主,产品以时尚化、舒适化、精致化的设计理念。\n" + "隽达专为25-45岁成熟、高贵、讲究品位的都市女性设计。\n" +
"秉承“都市轻熟女之家”的品牌核心。产品选用中高端面料,手感舒适,质地柔软。");
product4.setUtime(Instant.now().getEpochSecond());
var product5 = new Product();
product5.setProductName("吉俪 加厚保暖牛奶绒魔法绒毛毯珊瑚绒毯子空调午睡毯");
product5.setBrandName("吉俪");
product5.setCategoryName("毛毯");
product5.setId(5l);
product5.setDescription(
"吉俪家纺对商品的挑选,检验标准均为最高标准,能保证产品质量,服务和信誉;在产品品类的开发上,加强新技术的应用;产品品质上关注每一个细节,做到精益求精; 致力于为您创造高品质、健康舒适的家居生活和睡眠环境。");
product5.setUtime(Instant.now().getEpochSecond());
productRepository.saveAll(Arrays.asList(product1, product2, product3, product4, product5));
此处有一点需要注意,由于商品品牌名称大多为互相无关联意义的字组成,如果使用默认分词,会被分的很散,不符合要求,所以需要将品牌名中文部分列出来,加入到ik分词插件的扩展词典中去,此处不展开。
分析一下搜索需求,当用户传入搜索关键词后,需要在商品名称、描述、类目名称和品牌名称四个字段中做match操作,暂定互相之间的权重比为0.5:0.1:0.2:1,可使用NativeSearchQuery创建查询方法:
/**
* 核心查询方法
* @param queryStr
* @param page 从1开始
* @param size 最大100,最小1
* @return
*/
public Object search(String queryStr, int page, int size) {
var query = new NativeSearchQuery(
new MultiMatchQueryBuilder(queryStr).field("productName", 0.5f).field("description", 0.1f)
.field("brandName", 1).field("categoryName", 0.2f))
.setPageable(PageRequest.of(page < 0 ? 0 : page - 1, max(1, min(100, size))));
return restTemplate.search(query, Product.class);
}
//接口定义方法,@RequestMapping("product")
@GetMapping("{q}/{page}/{size}")
public Object search(@PathVariable("q") String queryStr, @PathVariable("page") int page,
@PathVariable("size") int size) {
return productService.search(queryStr, page, size);
}
打开浏览器,输入/product/风衣%20毛衣/1/10
,可以得到如下结果:
{
"totalHits": 2,
"totalHitsRelation": "EQUAL_TO",
"maxScore": 0.85801333,
"scrollId": null,
"searchHits": [
{
"id": "1",
"score": 0.85801333,
"sortValues": [],
"content": {
"id": 1,
"productName": "2020新款中年妈妈秋装风衣",
"description": "胖织缘福是知名的时尚中老年女装品牌,注重个性文化又不过分张扬,推崇时尚简约、独特个性设计风格,通过胖织缘福把自信、魅力潇洒淋漓尽致的展现出来。",
"utime": 1614666414,
"brandName": "胖织缘福",
"categoryName": "女式风衣"
},
"highlightFields": {}
},
{
"id": "4",
"score": 0.7199211,
"sortValues": [],
"content": {
"id": 4,
"productName": "隽达卷边V领针织打底毛衣【疫情地区不发货】",
"description": "本次活动以春装为主,产品以时尚化、舒适化、精致化的设计理念。\n隽达专为25-45岁成熟、高贵、讲究品位的都市女性设计。\n秉承“都市轻熟女之家”的品牌核心。产品选用中高端面料,手感舒适,质地柔软。",
"utime": 1614666414,
"brandName": "隽达",
"categoryName": "女式毛衣"
},
"highlightFields": {}
}
],
"aggregations": null,
"empty": false
}
至此,第一步工作算是暂时完成。