写在前面
本文将在第四篇《整合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实现复杂搜索这一功能。本篇笔记源码,可以点击 这里 进行阅读。