写在前面

本文将在第四篇《整合SpringSecurity和JWT实现认证与授权》的基础上整合ElasticSearch,实现商品搜索这一功能。

ElasticSearch简介

ElasticSearch是一个分布式、可扩展、实时的搜索与数据分析引擎,它能从项目一开始就赋予你的数据以搜索、分析和探索的能力,在日常工作和学习中扮演着非常重要的角色。关于ElasticSearch的学习,可以参考笔者的其他文章。注意本篇使用的ElasticSearch版本为6.8.6。

Kibana作为访问ElasticSearch的客户端,可以很方便的提供开发者可视化方式操作ES。

在整合前,请确保ElasticSearch和Kibana都已经正确安装并启动,且ElasticSearch的分词器也已经安装。

Spring Data Elasticsearch

Spring Data Elasticsearch是Spring提供的一种以Spring Data风格来操作数据存储的方式,可以避免开发者编写大量的样板代码,提升代码质量。

Spring Data Elasticsearch常用注解

@Document

@Document注解添加到需要映射到ElasticSearch文档上的领域对象上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Persistent
@Inherited
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE})
public @interface Document {
//索引名称,类似于数据库中的数据库
String indexName();
//类型,类似于数据库中的数据表
String type() default "";

boolean useServerConfiguration() default false;
//分片数,默认为5
short shards() default 5;
//副本数,默认为1
short replicas() default 1;
//每次刷新间隔,默认1秒
String refreshInterval() default "1s";

String indexStoreType() default "fs";

boolean createIndex() default true;
}

@Id

@Id注解添加到映射到ElasticSearch文档上的领域对象的ID字段上,即文档的id,类似于数据库中的行:

1
2
3
4
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.METHOD, ElementType.ANNOTATION_TYPE})
public @interface Id {
}

@Field

@Field注解添加到映射到ElasticSearch文档上的领域对象字段上,注意它用于为文档自动指定元数据类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD})
@Documented
@Inherited
public @interface Field {
//文档中字段的类型
FieldType type() default FieldType.Auto;
//是否建立倒排索引,默认是
boolean index() default true;

DateFormat format() default DateFormat.none;

String pattern() default "";
//是否进行存储
boolean store() default false;

boolean fielddata() default false;

String searchAnalyzer() default "";
//分词器名称
String analyzer() default "";

String normalizer() default "";

String[] ignoreFields() default {};

boolean includeInParent() default false;

String[] copyTo() default {};
}

实际上这个FieldType是一个枚举类,用于为文档自动指定元数据类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public enum FieldType {
Text, //进行分词并建立索引的字符类型
Integer,
Long,
Date,
Float,
Double,
Boolean,
Object,
Auto, //自动判断字段类型
Nested, //嵌套对象类型
Ip,
Attachment,
Keyword; //不进行分词并建立索引的类型

private FieldType() {
}
}

Spring Data操作数据的方式

如果你之前使用过JPA,你会发现Spring Data操作数据的方式都是类似的,即继承XXXRepository接口,然后就可以获得一些操作数据的常用方法。此处是继承ElasticsearchRepository接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@NoRepositoryBean
public interface ElasticsearchRepository<T, ID extends Serializable> extends ElasticsearchCrudRepository<T, ID> {
<S extends T> S index(S var1);

Iterable<T> search(QueryBuilder var1);

Page<T> search(QueryBuilder var1, Pageable var2);

Page<T> search(SearchQuery var1);

Page<T> searchSimilar(T var1, String[] var2, Pageable var3);

void refresh();

Class<T> getEntityClass();
}

实际上开发者也可以使用衍生查询,即在接口中直接指定查询方法的名称就可以实现查询,无需提供具体的实现:

就像后面会使用到的,商品表中有商品名称、商品副标题和关键字,直接在接口中定义如下方法,即可对这三个字段进行全文搜索:

1
2
3
4
5
6
7
8
9
/**
* 搜索查询
* @param name 商品名称
* @param subTitle 商品副标题
* @param keywords 商品关键字
* @param page 分页信息
* @return 搜索结果
*/
Page<EsProduct> findByNameOrSubTitleOrKeywords(String name, String subTitle, String keywords, Pageable page);

如果开发者使用的IDE是IDEA,那么它会在开发者编写方法的时候直接提示对应字段信息:

当然了,开发者也可以使用@Query注解,直接使用ElasticSearch的DSL语句来进行查询:

1
2
@Query("{"bool" : {"must" : {"field" : {"name" : "?0"}}}}")
Page<EsProduct> findByName(String name,Pageable pageable);

项目使用到的表说明

(1)pms_product:商品信息表;
(2)pms_product_attribute:商品属性参数表;
(3)pms_product_attribute_value:存储产品参数值的表,即商品id与商品属性id之间的对应关系表。

整合ElasticSearch实现商品搜索

第一步,复制一份shop-springsecurity-jwt源码,将其名字修改为shop-elasticsearch,然后对应包和文件中的信息也记得修改,本篇后续所有操作均在shop-elasticsearch这一Module中进行。注意复制之后需要重新执行一下Generator类,以覆盖之前项目的自动生成文件。

第二步,在shop-elasticsearch的POM文件中新增如下依赖:

1
2
3
4
5
<!--Elasticsearch相关依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>

第三步,往application.yml配置文件中在spring节点下添加ElasticSearch相关配置信息:

1
2
3
4
5
6
7
# ElasticSearch相关
data:
elasticsearch:
repositories:
enabled: true
cluster-nodes: localhost:9300 # es的连接地址及端口号
cluster-name: elasticsearch # es集群的名称

第四步,修改generatorConfig.xml配置文件中的数据表信息,以生成前面所述三张表的对应信息。

第五步,在com.kenbings.shop.shopelasticsearch包内新建一个名为nosql的包,并在nosql包内定义一个名为elasticsearch的包,接着在elasticsearch包内定义一个名为document的包。然后在document包内定义一个名为EsProduct的类,注意这是商品文档对象。

一般来说,不需要分词的字段可以设置类型为Keyword,也就是使用@Field(type = FieldType.Keyword)注解,如这里的货号、品牌名称和商品分类名称。而需要进行分词的字段可以设置为Text类型,即使用@Field(analyzer = "ik_max_word",type = FieldType.Text)注解,如这里的商品名称、副标题和关键字,同时需要指定分词器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
/**
* 搜索中的商品信息
*/
@Data
@Document(indexName = "pms", type = "product",shards = 1,replicas = 0)
public class EsProduct implements Serializable {
private static final long serialVersionUID = -1L;

@Id
private Long id;
private Long brandId;
private Long productCategoryId;

/**
* 货号
*/
@Field(type = FieldType.Keyword)
private String productSn;

/**
* 品牌名称
*/
@Field(type = FieldType.Keyword)
private String brandName;

/**
* 商品分类名称
*/
@Field(type = FieldType.Keyword)
private String productCategoryName;

/**
* 商品名称
*/
@Field(analyzer = "ik_max_word",type = FieldType.Text)
private String name;

/**
* 副标题
*/
@Field(analyzer = "ik_max_word",type = FieldType.Text)
private String subTitle;

/**
* 关键字
*/
@Field(analyzer = "ik_max_word",type = FieldType.Text)
private String keywords;

private String pic;
private BigDecimal price;
private Integer sale;
private Integer newStatus;
private Integer recommendStatus;
private Integer stock;
private Integer promotionType;
private Integer sort;

/**
* 嵌套类型,商品属性列表
*/
@Field(type =FieldType.Nested)
private List<EsProductAttributeValue> attrValueList;
}

第六步,在document包内定义一个名为EsProductAttributeValue的类,因为商品文档对象EsProduct中有一个名为attrValueList的属性,它是一个List类型,里面都是EsProductAttributeValue对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
* 搜索中的商品属性信息
*/
@Data
public class EsProductAttributeValue implements Serializable {
private static final long serialVersionUID = 1L;

private Long id;

/**
* 商品属性值id
*/
private Long productAttributeId;

/**
* 属性值
*/
@Field(type = FieldType.Keyword)
private String value;

/**
* 属性参数:0->规格;1->参数
*/
private Integer type;

/**
* 属性名称
*/
@Field(type=FieldType.Keyword)
private String name;
}

第七步,在com.kenbings.shop.shopelasticsearch.nosql.elasticsearch包内新建一个名为repository的包,并在该包内定义一个名为EsProductRepository的接口,注意这个接口需要继承ElasticsearchRepository接口,这样就拥有了一些基本的操作ElasticSearch数据的方法,同时我们在里面定义了一个衍生的根据条件搜索查询商品的方法:

1
2
3
4
5
6
7
8
9
10
11
public interface EsProductRepository extends ElasticsearchRepository<EsProduct,Long> {
/**
* 搜索查询
* @param name 商品名称
* @param subTitle 商品副标题
* @param keywords 商品关键字
* @param page 分页信息
* @return 搜索结果
*/
Page<EsProduct> findByNameOrSubTitleOrKeywords(String name, String subTitle, String keywords, Pageable page);
}

第八步,在dao包内定义一个名为EsProductDao的接口,这是搜索系统中商品管理自定义的Dao。我们在里面添加一个从数据库中查询所有商品文档对象EsProduct的方法,当然了这个方法也可以传入id变成查询指定id的商品文档对象。简单来说就是查询数据库的多张表,将商品信息组装为EsProduct对象:

1
2
3
4
5
6
/**
* 搜索系统中商品管理自定义的Dao
*/
public interface EsProductDao {
List<EsProduct> getAllEsProductList(@Param("id") Long id);
}

第九步,在resources/mapper包内定义一个名为EsProductDao的XML文件,这是第八步中对应的XML文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.kenbings.shop.shopelasticsearch.dao.EsProductDao">
<resultMap id="esProductListMap" type="com.kenbings.shop.shopelasticsearch.nosql.elasticsearch.document.EsProduct"
autoMapping="true"
>
<id column="id" jdbcType="BIGINT" property="id" />
<collection property="attrValueList" columnPrefix="attr_" ofType="com.kenbings.shop.shopelasticsearch.nosql.elasticsearch.document.EsProductAttributeValue">
<id column="id" property="id" jdbcType="BIGINT"/>
<result column="product_attribute_id" property="productAttributeId" jdbcType="BIGINT"/>
<result column="value" property="value" jdbcType="VARCHAR"/>
<result column="name" property="name"/>
<result column="type" property="type"/>
</collection>
</resultMap>
<select id="getAllEsProductList" resultMap="esProductListMap">
select
p.id id,
p.product_sn productSn,
p.brand_id brandId,
p.brand_name brandName,
p.product_category_id productCategoryId,
p.product_category_name productCategoryName,
p.pic pic,
p.name name,
p.sub_title subTitle,
p.price price,
p.sale sale,
p.new_status newStatus,
p.recommend_status recommendStatus,
p.stock stock,
p.promotion_type promotionType,
p.keywords keywords,
p.sort sort,
pav.id attr_id,
pav.value attr_value,
pav.product_attribute_id attr_product_attribute_id,
pa.type attr_type,
pa.name attr_name
from pms_product p
left join pms_product_attribute_value pav on p.id = pav.product_id
left join pms_product_attribute pa on pav.product_attribute_id= pa.id
where delete_status = 0 and publish_status = 1
<if test="id!=null">
and p.id=#{id}
</if>
</select>
</mapper>

简单解释一下上述配置信息的含义:
(1)mapper标签中的namespace属性用于指定此Mapper文件所对应的接口文件是第八步中定义的接口文件,即该文件的项目路径;
(2)由于getAllEsProductList(@Param("id") Long id)方法返回的是一个List<EsProduct>对象,因此该select标签返回的是一个resultMap对象,而这个对象返回的应该是List<EsProduct>中的EsProduct对象;
(3)在该mapper文件中定义一个id为esProductListMap的resultMap,然后指定type属性为com.kenbings.shop.shopelasticsearch.nosql.elasticsearch.document.EsProduct,也就是ES中存的对象类型。同时这里需要设置autoMapping属性的值为true,表示自动根据sql语句查询的字段名称与EsProduct类中设置的属性名称进行映射绑定;
(4)在resultMap标签内使用一个collection标签,指定property属性为attrValueList,这个值必须与开发者在EsProduct中指定的属性名称完全一致。接着设置columnPrefix,表示我们从数据库使用sql语句查询得到的字段名称都是attr_开头的,ofType指定类型为com.kenbings.shop.shopelasticsearch.nosql.elasticsearch.document.EsProductAttributeValue。接着我们就使用result标签来手动将其进行绑定;
(5)通过上面的分析,我们知道在select标签中,pms_product表里查询得到的字段必须指定别名为与EsProduct属性相同的名称,而pms_product_attribute_value表中查询得到的字段别名必须以attr_开头,且符合上述自定义的映射关系。

第十步,在service包内定义一个名为EsProductService的接口,用于定义EsProduct搜索相关的接口方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/**
* EsProduct搜索的Service
*/
public interface EsProductService {
/**
* 从数据中导入所有商品到ES
* @return 导入的文档数
*/
int importAll();

/**
* 根据id删除ES中的商品
* @param id 商品id
*/
void delete(Long id);

/**
* 在ES中创建指定id的商品文档信息
* @param id 商品id
*/
EsProduct create(Long id);

/**
* 根据id批量删除ES中的商品
* @param ids 商品id
*/
void delete(List<Long> ids);

/**
* 根据关键字搜索商品信息
* @param keyword 关键字
* @param pageNum 页码
* @param pageSize 每页数量
* @return
*/
Page<EsProduct> search(String keyword,Integer pageNum, Integer pageSize);
}

第十一步,在impl包内定义一个名为EsProductServiceImpl的类,这个类需要实现EsProductService接口,并重写其中的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
/**
* EsProduct搜索Service的实现类
*/
@Service
public class EsProductServiceImpl implements EsProductService {
private static final Logger LOGGER = LoggerFactory.getLogger(EsProductServiceImpl.class);

@Autowired
private EsProductDao esProductDao;
@Autowired
private EsProductRepository esProductRepository;

@Override
public int importAll() {
List<EsProduct> allEsProductList = esProductDao.getAllEsProductList(null);
Iterable<EsProduct> esProductIterable = esProductRepository.saveAll(allEsProductList);
Iterator<EsProduct> esProductIterator = esProductIterable.iterator();
int result = 0;
while (esProductIterator.hasNext()){
result++;
esProductIterator.next();
}
return result;
}

@Override
public void delete(Long id) {
esProductRepository.deleteById(id);
}

@Override
public EsProduct create(Long id) {
EsProduct result = null;
List<EsProduct> esProductLists = esProductDao.getAllEsProductList(id);
if(esProductLists.size()>0){
EsProduct esProduct = esProductLists.get(0);
result = esProductRepository.save(esProduct);
}
return result;
}

@Override
public void delete(List<Long> ids) {
if(!CollectionUtils.isEmpty(ids)){
List<EsProduct> esProductList = new ArrayList<>();
for (Long id:ids) {
EsProduct esProduct = new EsProduct();
esProduct.setId(id);
esProductList.add(esProduct);
}
esProductRepository.deleteAll(esProductList);
}
}

@Override
public Page<EsProduct> search(String keyword, Integer pageNum, Integer pageSize) {
Pageable pageable = PageRequest.of(pageNum,pageSize);
return esProductRepository.findByNameOrSubTitleOrKeywords(keyword,keyword,keyword,pageable);
}
}

第十二步,在controller包内定义一个名为EsProductController的类,这是商品搜索功能的Controller:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
/**
* 商品搜索管理Controller
*/
@Api(tags = "EsProductController",description = "商品搜索管理")
@RestController
@RequestMapping("/esProduct")
public class EsProductController {
@Autowired
private EsProductService esProductService;

@ApiOperation(value = "导入所有数据库中商品到ES")
@PostMapping("/importAll")
public CommonResult<Integer> importAllList() {
int count = esProductService.importAll();
return CommonResult.success(count);
}

@ApiOperation(value = "根据id删除Es中指定的商品信息")
@GetMapping("/delete/{id}")
public CommonResult<Object> delete(@PathVariable Long id) {
esProductService.delete(id);
return CommonResult.success(null);
}

@ApiOperation(value = "根据id批量删除Es中的商品信息")
@PostMapping( "/delete/batch")
public CommonResult<Object> delete(@RequestParam("ids") List<Long> ids) {
esProductService.delete(ids);
return CommonResult.success(null);
}

@ApiOperation(value = "根据id在ES中创建商品信息")
@PostMapping( "/create/{id}")
public CommonResult<EsProduct> create(@PathVariable Long id) {
EsProduct esProduct = esProductService.create(id);
if (esProduct != null) {
return CommonResult.success(esProduct);
} else {
return CommonResult.failed();
}
}

@ApiOperation(value = "简单搜索")
@GetMapping("/search/simple")
public CommonResult<CommonPage<EsProduct>> search(@RequestParam(required = false) @ApiParam("关键字") String keyword,
@RequestParam(required = false, defaultValue = "0")@ApiParam("页码") Integer pageNum,
@RequestParam(required = false, defaultValue = "5")@ApiParam("每页数量") Integer pageSize) {
Page<EsProduct> esProductPage = esProductService.search(keyword, pageNum, pageSize);
return CommonResult.success(CommonPage.restPage(esProductPage));
}
}

第十三步,修改com.kenbings.shop.shopelasticsearch.common.api.CommonPage类,在里面新增加一个将SpringData 分页后的list转为分页信息的restPage方法:

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 将 SpringData 分页后的list转为分页信息
*/
public static <T> CommonPage<T> restPage(Page<T> pageInfo){
CommonPage<T> commonPage = new CommonPage<>();
commonPage.setPageNum(pageInfo.getNumber());
commonPage.setPageSize(pageInfo.getSize());
commonPage.setTotalPage(pageInfo.getTotalPages());
commonPage.setTotal(pageInfo.getTotalElements());
commonPage.setList(pageInfo.getContent());
return commonPage;
}

第十四步,启动项目,访问Swagger-UI接口文档地址,即浏览器访问http://localhost:8080/swagger-ui.html链接,可以看到新的接口已经出现了:

第十五步,进行接口测试。首先后台用户进行登录,接着测试“导入所有数据库中商品到ES”:

接着我们测试一下“简单搜索”这一接口,可以看到测试成功:

这样本篇关于整合ElasticSearch实现商品搜索的学习就完成了,后续介绍如何使用ElasticSearch实现复杂搜索这一功能。本篇笔记源码,可以点击 这里 进行阅读。