Solr, ElasticSearch等搜索系列教程文档
SpringBoot搜索系列教程
- 1: Solr
- 1.1: 1.环境搭建与简单测试
- 1.2: 2.文档新增与修改使用姿势
- 1.3: 3.Solr文档删除
- 1.4: 4.Solr查询使用姿势小结
- 1.5: 5.Solr身份认证与授权更新异常解决方案
- 2: ElasticSearch
- 2.1: 1.ES基本项目搭建
- 2.2: 2.ES文档基本操作CURD实例演示
- 2.3: 3.ES查询常用实例演示
1 - Solr
SpringBoot整合Solr,支撑搜索业务场景
1.1 - 1.环境搭建与简单测试
搜索可以说是非常常见的场景了,一般选择比较多的有solr和es,底层都是基于Lucene搜索引擎实现。之前简单的使用过solr,一直没有成体系的学习过,正好需要给一个内部项目封装统一的查询组件,借这个机会好好的撸一把solr的知识要点
I. Solr环境搭建
1. docker方式安装solr
使用docker实现solr环境的搭建,快速简洁
docker pull solr
启动solr容器
docker run --name my-solr -d -p 8983:8983 -t solr
浏览器打开: http://localhost:8983/solr/#/
新建core
docker exec -it --user=solr my-solr bin/solr create_core -c yhh
建立成功之后,终端会有相应的提示,然后刷新浏览器,可以看到新的yhh
2. schema
通过docker安装的最新solr版本为8.0,可以直接在http界面通过控制台来创建schema,而不需要像以前那样,进入配置文件进行添加处理;当然也可以通过修改对应的配置
假定我们现在需要在yhh这个core中存文章,结构为
id: string # 默认的全局唯一字段
title: string # 文章标题
content: string # 文章内容
type: int # 文章类型
a. 控制台添加方式
首先进入schema的页面,可以如下操作,也可以点击连接: http://localhost:8983/solr/#/yhh/schema
然后通过点击Add Field按钮添加字段,确认按钮之后完成添加
添加完成之后点击please select...
,弹出下拉框,看到刚才添加的东西
b. 编辑xml文件方式
通过控制台的overiew可以定位到core存储路径,然后我们找到对应的定义文件,添加两个字段
create_at: long # 文章创建时间
publish_at: long # 文章发布时间
修改配置文件
# 首先进入docker内
docker exec -it --user=root my-solr /bin/bash
# 定位配置文件
vim /var/solr/data/yhh/conf/managed-schema
# 新增字段
<field name="create_at" type="pint" uninvertible="true" default="0" indexed="true" stored="true" />
<field name="publish_at" type="pint" uninvertible="true" default="0" indexed="true" stored="true" />
修改完成之后如下图
配置文件修改之后,再去刷控制台,发现并没有显示出来,通过重启solr之后,新的才显示出来
c. 功能测试
schema定义完毕之后,就可以进行简单的测试了,先加几个文档;然后再进行查询
添加文档
直接在控制台进行添加: http://localhost:8983/solr/#/yhh/documents
{
id: 2,
title: "一灰灰",
content: "欢迎来到一灰灰的博客",
type: 1,
create_at: 1557488164,
publish_at: 1557488164
}
文档查询
直接在控制台进行操作:http://localhost:8983/solr/#/yhh/query
然后来个高级一点的查询,我希望查询所有内容包含一灰灰的数据,可以如下查询
II. SpringBoot搭建solr环境
1. 配置
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.0.4.RELEASE</version>
<relativePath/> <!-- lookup parent from update -->
</parent>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<spring-cloud.version>Finchley.RELEASE</spring-cloud.version>
<java.version>1.8</java.version>
</properties>
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</pluginManagement>
</build>
<repositories>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-solr</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependencies>
上面的配置中,需要注意是引入包 spring-boot-starter-data-solr
引入这个包之后,我们就可以愉快的使用SolrTemplate
来完成solr的各种骚操作了
package com.git.hui.boot.solr.config;
import org.apache.solr.client.solrj.SolrClient;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.solr.core.SolrTemplate;
/**
* Created by @author yihui in 19:49 19/5/10.
*/
@Configuration
public class SearchAutoConfig {
@Bean
@ConditionalOnMissingBean(SolrTemplate.class)
public SolrTemplate solrTemplate(SolrClient solrClient) {
return new SolrTemplate(solrClient);
}
}
2. 简单测试
下面搞一个简单的查询,看下能不能获取到solr文档
package com.git.hui.boot.solr.solr;
import lombok.Data;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.solr.core.SolrTemplate;
import org.springframework.stereotype.Component;
/**
* Created by @author yihui in 19:45 19/5/10.
*/
@Component
public class SolrSearchService {
@Autowired
private SolrTemplate solrTemplate;
@Data
public static class DocDO {
private Integer id;
private String title;
private String content;
private Integer type;
private Long create_at;
private Long publish_at;
}
public void query() {
DocDO ans = solrTemplate.getById("yhh", 2, DocDO.class).get();
System.out.println(ans);
}
}
3. 测试
启动下任务开始测试
package com.git.hui.boot.solr;
import com.git.hui.boot.solr.solr.SolrSearchService;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Created by @author yihui in 19:44 19/5/10.
*/
@SpringBootApplication
public class Application {
private SolrSearchService solrSearchService;
public Application(SolrSearchService solrSearchService) {
this.solrSearchService = solrSearchService;
query();
}
private void query() {
this.solrSearchService.query();
}
public static void main(String[] args) {
SpringApplication.run(Application.class);
}
}
执行截图如下
III. 小结
上面介绍了最基础的solr环境搭建,springboot的solr测试环境准备,并实现了一个简单的查询实例,但距离真正上手撸solr还缺不少东西
- solr的基础知识,前面的字段定义是否合法,索引什么的改怎么考虑
- 配置修改,安全保证
- 中文分词如何设置,如何使用在solr中进行使用
- solr的增删改查的基本操作姿势
- solr的全文搜索优势如何体现
- SpringBoot中进行solr操作
- …
0. 项目
1.2 - 2.文档新增与修改使用姿势
大多涉及到数据的处理,无非CURD四种操作,对于搜索SOLR而言,基本操作也可以说就这么几种,在实际应用中,搜索条件的多样性才是重点,我们在进入复杂的搜索之前,先来看一下如何新增和修改文档
I. 环境准备
solr的基础环境需要准备好,如果对这一块有疑问的童鞋,可以参考下上一篇博文: 《190510-SpringBoot高级篇搜索之Solr环境搭建与简单测试》
1. 环境配置
在pom文件中,设置好对应的依赖
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.0.4.RELEASE</version>
<relativePath/> <!-- lookup parent from update -->
</parent>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<spring-cloud.version>Finchley.RELEASE</spring-cloud.version>
<java.version>1.8</java.version>
</properties>
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</pluginManagement>
</build>
<repositories>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-solr</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependencies>
因为我们采用默认的solr访问姿势,所以配置文件中可以不加对应的参数,当然也可以加上
打开 application.yml
配置文件
spring:
data:
solr:
host: http://127.0.0.1:8983/solr
如果我们的solr加上了用户名密码访问条件,参数中并没有地方设置username和password,那应该怎么办?
spring:
data:
solr:
host: http://admin:admin@127.0.0.1:8983/solr
如上写法,将用户名和密码写入http的连接中
2. 自动装配
我们主要使用SolrTemplate来和Solr打交到,因此我们需要先注册这个bean,可以怎么办?
package com.git.hui.boot.solr.config;
import org.apache.solr.client.solrj.SolrClient;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.solr.core.SolrTemplate;
/**
* Created by @author yihui in 19:49 19/5/10.
*/
@Configuration
public class SearchAutoConfig {
@Bean
@ConditionalOnMissingBean(SolrTemplate.class)
public SolrTemplate solrTemplate(SolrClient solrClient) {
return new SolrTemplate(solrClient);
}
}
上面的配置是条件注入,只有当SolrTemplate对应的bean没有被自动加载时,才会加载,为什么要怎么干?
(可以想一想原因…)
II. 使用姿势示例
我们的操作主要依赖的是SolrTemplate,因此有必要在开始之前,看一下它的签名
Spring的源码中,可以发现大多xxxTemplate
都会实现一个xxxOperations
接口,而这个接口就是用来定义CURD的api,比如我们看下 SolrOperations
中与修改相关的api
default UpdateResponse saveBean(String collection, Object obj) {
return saveBean(collection, obj, Duration.ZERO);
}
/**
* Execute add operation against solr, which will do either insert or update with support for commitWithin strategy.
*
* @param collection must not be {@literal null}.
* @param obj must not be {@literal null}.
* @param commitWithin max time within server performs commit.
* @return {@link UpdateResponse} containing update result.
*/
UpdateResponse saveBean(String collection, Object obj, Duration commitWithin);
default UpdateResponse saveBeans(String collection, Collection<?> beans) {
return saveBeans(collection, beans, Duration.ZERO);
}
UpdateResponse saveBeans(String collection, Collection<?> beans, Duration commitWithin);
default UpdateResponse saveDocument(String collection, SolrInputDocument document) {
return saveDocument(collection, document, Duration.ZERO);
}
/**
* Add a solrj input document to solr, which will do either insert or update with support for commitWithin strategy
*
* @param document must not be {@literal null}.
* @param commitWithin must not be {@literal null}.
* @return {@link UpdateResponse} containing update result.
* @since 3.0
*/
UpdateResponse saveDocument(String collection, SolrInputDocument document, Duration commitWithin);
default UpdateResponse saveDocuments(String collection, Collection<SolrInputDocument> documents) {
return saveDocuments(collection, documents, Duration.ZERO);
}
UpdateResponse saveDocuments(String collection, Collection<SolrInputDocument> documents, Duration commitWithin);
上面的api签名中,比较明确的说明了这个 saveXXX
既可以用来新增文档,也可以用来修改文档,主要有提供了两类
- 单个与批量
- saveDocument 与 saveBean
1. 添加文档
从上面的api签名上看,saveDocument
应该是相对原始的操作方式了,因此我们先看下它的使用姿势
a. saveDocument
首先就是创建文档 SolrInputDocument
对象,通过调用addField
来设置成员值
public void testAddByDoc() {
SolrInputDocument document = new SolrInputDocument();
document.addField("id", 3);
document.addField("title", "testAddByDoc!");
document.addField("content", "通过solrTemplate新增文档");
document.addField("type", 2);
document.addField("create_at", System.currentTimeMillis() / 1000);
document.addField("publish_at", System.currentTimeMillis() / 1000);
UpdateResponse response = solrTemplate.saveDocument("yhh", document);
solrTemplate.commit("yhh");
System.out.println("over:" + response);
}
注意:保存文档之后,一定得调用commit提交
b. saveBean
前面需要创建SolrInputDocument
对象,我们更希望的使用case是直接传入一个POJO,然后自动与solr的filed进行关联
因此一种使用方式可以如下
- 定义pojo,成员上通过 @Field 注解来关联solr的field
- pojo对象直接当做参数传入,保存之后,执行 commit 提交
@Data
public static class DocDO {
@Field("id")
private Integer id;
@Field("title")
private String title;
@Field("content")
private String content;
@Field("type")
private Integer type;
@Field("create_at")
private Long createAt;
@Field("publish_at")
private Long publishAt;
}
/**
* 新增
*/
private void testAddByBean() {
DocDO docDO = new DocDO();
docDO.setId(4);
docDO.setTitle("addByBean");
docDO.setContent("新增一个测试文档");
docDO.setType(1);
docDO.setCreateAt(System.currentTimeMillis() / 1000);
docDO.setPublishAt(System.currentTimeMillis() / 1000);
UpdateResponse response = solrTemplate.saveBean("yhh", docDO);
solrTemplate.commit("yhh");
System.out.println(response);
}
c. 批量
批量的方式就比较简单了,传入集合即可
private void testBatchAddByBean() {
DocDO docDO = new DocDO();
docDO.setId(5);
docDO.setTitle("addBatchByBean - 1");
docDO.setContent("新增一个测试文档");
docDO.setType(1);
docDO.setCreateAt(System.currentTimeMillis() / 1000);
docDO.setPublishAt(System.currentTimeMillis() / 1000);
DocDO docDO2 = new DocDO();
docDO2.setId(6);
docDO2.setTitle("addBatchByBean - 2");
docDO2.setContent("新增一个测试文档");
docDO2.setType(1);
docDO2.setCreateAt(System.currentTimeMillis() / 1000);
docDO2.setPublishAt(System.currentTimeMillis() / 1000);
UpdateResponse response = solrTemplate.saveBeans("yhh", Arrays.asList(docDO, docDO2));
solrTemplate.commit("yhh");
System.out.println(response);
}
d. 测试
上面的几个方法,我们执行之后,我们看下是否能查询到新增加的数据
2. 文档修改
在看前面的接口签名时,就知道修改和新增用的是相同的api,所以修改文档和上面的使用实际上也没有什么特别的,下面简单的演示一下
public void testUpdateDoc() {
DocDO docDO = new DocDO();
docDO.setId(5);
docDO.setTitle("修改之后!!!");
docDO.setType(1);
docDO.setCreateAt(System.currentTimeMillis() / 1000);
docDO.setPublishAt(System.currentTimeMillis() / 1000);
UpdateResponse response = solrTemplate.saveBean("yhh", docDO);
solrTemplate.commit("yhh");
System.out.println(response);
}
上面的实例中,修改了id为5的文档标题,并删除了content内容,执行完毕之后,结果如何呢?
- title被替换
- content没有了
到这里就有个疑问了,对于调用而言,怎么保证是修改还是新增呢?
- 这里主要是根据id来判断,这个id类似db中的唯一主键,当我们没有指定id时,会随机生成一个id
- 如果存在相同的id,则修改文档;如果不存在,则新增文档
III. 其他
0. 项目
1.3 - 3.Solr文档删除
之前的搜索教程开了个头就没有继续了,现在重新捡回来,至少也把CURD的基本操作姿势补全了;本篇主要介绍如何删除数据
I. 配置
在介绍demo之前,需要先安装solr环境,搭建SpringBoot项目工程,具体的环境搭建过程不细说,推荐参考文档
在application.yml
配置文件中红,指定solr的域名
spring:
data:
solr:
host: http://127.0.0.1:8983/solr
然后在solr中,写入一些数据,供我们删除使用,可以通过控制台的方式写入,也可以通过190526-SpringBoot高级篇搜索Solr之文档新增与修改使用姿势 这篇文档的case添加
{
"id":"1",
"content_id":1,
"title":"一灰灰blog",
"content":"这是一灰灰blog的内容",
"type":1,
"create_at":1578912072,
"publish_at":1578912072,
"_version_":1655609540674060288},
{
"id":"2",
"content_id":2,
"title":"一灰灰",
"content":"这是一灰灰的内容",
"type":1,
"create_at":1578912072,
"publish_at":1578912072,
"_version_":1655609550229733376},
{
"id":"3",
"content_id":3,
"title":"solrTemplate 修改之后!!!",
"create_at":1578912072,
"publish_at":1578912072,
"type":0,
"_version_":1655609304941592576},
{
"id":"4",
"content_id":4,
"type":1,
"create_at":0,
"publish_at":0,
"_version_":1655609305022332928},
{
"id":"5",
"content_id":5,
"title":"addBatchByBean - 1",
"content":"新增一个测试文档",
"type":1,
"create_at":1578912072,
"publish_at":1578912072,
"_version_":1655609304836734976},
{
"id":"6",
"content_id":6,
"title":"addBatchByBean - 2",
"content":"新增又一个测试文档",
"type":1,
"create_at":1578912072,
"publish_at":1578912072,
"_version_":1655684018701598720
}
II. 删除
我们依然是使用SolrTemplate
来操作solr的正删改查,它整合了solr的各种基本操作
1. 根据主键删除
请注意,这种case是根据主键id进行删除的,支持批量删除,需要solrTemplate.commit("yhh");
这一行来提交修改
private void deleteById() {
solrTemplate.deleteByIds("yhh", Arrays.asList("4"));
solrTemplate.commit("yhh");
}
2. 查询删除
上面根据主键删除适合精准的删除操作,但是适用性有限;下面介绍查询删除的方式,将满足查询条件的数据都删除掉
private void deleteByQuery() {
SolrDataQuery query = new SimpleQuery();
query.addCriteria(Criteria.where("content").startsWith("新增"));
solrTemplate.delete("yhh", query);
solrTemplate.commit("yhh");
}
上面提供了一个简单的查询条件,删除content内容以新增
开头的文档,至于查询语句的使用姿势在下一篇介绍Solr的查询姿势时详细说明
3. 测试
接下来测试一下上面的两种case
首先我们提供一个输出所有文档的方法,用于对比删除前后的数据变化
private void printAll(String tag) {
System.out.println("\n---------> query all " + tag + " start <------------\n");
List<DocDO> list = solrTemplate.query("yhh", new SimpleQuery("*:*").addSort(Sort.by("content_id").ascending()), DocDO.class)
.getContent();
list.forEach(System.out::println);
System.out.println("\n---------> query all " + tag + " over <------------\n");
}
接下来是方法调用
@Autowired
private SolrTemplate solrTemplate;
public void delete() {
printAll("init");
this.deleteById();
this.deleteByQuery();
printAll("afterDelete");
}
输出结果如下,id为4,5,6的都被删除了
---------> query all init start <------------
DocDO(id=1, contentId=1, title=一灰灰blog, content=这是一灰灰blog的内容, type=1, createAt=1578912072, publishAt=1578912072)
DocDO(id=2, contentId=2, title=一灰灰, content=这是一灰灰的内容, type=1, createAt=1578912072, publishAt=1578912072)
DocDO(id=3, contentId=3, title=solrTemplate 修改之后!!!, content=null, type=0, createAt=1578988256, publishAt=1578988256)
DocDO(id=4, contentId=4, title=null, content=null, type=1, createAt=0, publishAt=0)
DocDO(id=5, contentId=5, title=addBatchByBean - 1, content=新增一个测试文档, type=1, createAt=1578988256, publishAt=1578988256)
DocDO(id=6, contentId=6, title=addBatchByBean - 2, content=新增又一个测试文档, type=1, createAt=1578988256, publishAt=1578988256)
---------> query all init over <------------
---------> query all afterDelete start <------------
DocDO(id=1, contentId=1, title=一灰灰blog, content=这是一灰灰blog的内容, type=1, createAt=1578912072, publishAt=1578912072)
DocDO(id=2, contentId=2, title=一灰灰, content=这是一灰灰的内容, type=1, createAt=1578912072, publishAt=1578912072)
DocDO(id=3, contentId=3, title=solrTemplate 修改之后!!!, content=null, type=0, createAt=1578988256, publishAt=1578988256)
---------> query all afterDelete over <------------
II. 其他
0. 系列博文&项目源码
系列博文
项目源码
1.4 - 4.Solr查询使用姿势小结
接下来进入solr CURD的第四篇,查询的使用姿势介绍,本文将主要包括以下知识点
- 基本的查询操作
- fq查询
- fl指定字段查询
- 比较/范围
- 排序
- 分页
- 分组
I. 配置
在介绍demo之前,需要先安装solr环境,搭建SpringBoot项目工程,具体的环境搭建过程不细说,推荐参考文档
在application.yml
配置文件中红,指定solr的域名
spring:
data:
solr:
host: http://127.0.0.1:8983/solr
然后在solr中,写入一些数据,供我们查询使用,可以通过控制台的方式写入,也可以通过190526-SpringBoot高级篇搜索Solr之文档新增与修改使用姿势 这篇文档的case添加
初始化solr文档内容如下
{
"id":"1",
"content_id":1,
"title":"一灰灰blog",
"content":"这是一灰灰blog的内容",
"type":1,
"create_at":1578912072,
"publish_at":1578912072,
"_version_":1655609540674060288},
{
"id":"2",
"content_id":2,
"title":"一灰灰",
"content":"这是一灰灰的内容",
"type":1,
"create_at":1578912072,
"publish_at":1578912072,
"_version_":1655609550229733376},
{
"id":"3",
"content_id":3,
"title":"solrTemplate 修改之后!!!",
"create_at":1578993153,
"publish_at":1578993153,
"type":0,
"_version_":1655694325261008896},
{
"id":"4",
"content_id":4,
"type":1,
"create_at":0,
"publish_at":0,
"_version_":1655694325422489600},
{
"id":"5",
"content_id":5,
"title":"addBatchByBean - 1",
"content":"新增一个测试文档",
"type":1,
"create_at":1578993153,
"publish_at":1578993153,
"_version_":1655694325129936896},
{
"id":"6",
"content_id":6,
"title":"addBatchByBean - 2",
"content":"新增又一个测试文档",
"type":1,
"create_at":1578993153,
"publish_at":1578993153,
"_version_":1655694325136228352
}
II. 查询
solr文档对应的POJO如下,(注意solr中的主键id为string类型,下面定义中用的是Integer,推荐与solr的数据类型保持一致)
@Data
public class DocDO implements Serializable {
private static final long serialVersionUID = 7245059137561820707L;
@Id
@Field("id")
private Integer id;
@Field("content_id")
private Integer contentId;
@Field("title")
private String title;
@Field("content")
private String content;
@Field("type")
private Integer type;
@Field("create_at")
private Long createAt;
@Field("publish_at")
private Long publishAt;
}
1. 主键查询
支持单个查询和批量查询,三个参数,第一个为需要查询的Collection, 第二个为id/id集合,第三个为返回的数据类型
private void queryById() {
DocDO ans = solrTemplate.getById("yhh", 1, DocDO.class).get();
System.out.println("queryById: " + ans);
Collection<DocDO> list = solrTemplate.getByIds("yhh", Arrays.asList(1, 2), DocDO.class);
System.out.println("queryByIds: " + list);
}
输出结果如下
queryById: DocDO(id=1, contentId=1, title=一灰灰blog, content=这是一灰灰blog的内容, type=1, createAt=1578912072, publishAt=1578912072)
queryByIds: [DocDO(id=1, contentId=1, title=一灰灰blog, content=这是一灰灰blog的内容, type=1, createAt=1578912072, publishAt=1578912072), DocDO(id=2, contentId=2, title=一灰灰, content=这是一灰灰的内容, type=1, createAt=1578912072, publishAt=1578912072)]
2. 简单查询
比如最简单的根据某个字段进行查询
Query query = new SimpleQuery("title:一灰灰");
Page<DocDO> ans = solrTemplate.query("yhh", query, DocDO.class);
System.out.println("simpleQuery : " + ans.getContent());
直接在SimpleQuery中指定查询条件,上面的case表示查询title为一灰灰
的文档
输出结果如下:
simpleQuery : [DocDO(id=2, contentId=2, title=一灰灰, content=这是一灰灰的内容, type=1, createAt=1578912072, publishAt=1578912072)]
简单的查询使用上面的姿势ok,当然就是阅读起来不太优雅;推荐另外一种基于Criteria
的查询条件构建方式
- 如果看过之前的mongodb系列教程,可以看到monodb的查询条件也用到了Criteria来拼装,但是请注意这两个并不是一个东西
query = new SimpleQuery();
// 查询内容中包含一灰灰的文档
query.addCriteria(new Criteria("content").contains("一灰灰"));
ans = solrTemplate.query("yhh", query, DocDO.class);
System.out.println("simpleQuery : " + ans.getContent());
输出结果如下
simpleQuery : [DocDO(id=1, contentId=1, title=一灰灰blog, content=这是一灰灰blog的内容, type=1, createAt=1578912072, publishAt=1578912072), DocDO(id=2, contentId=2, title=一灰灰, content=这是一灰灰的内容, type=1, createAt=1578912072, publishAt=1578912072)]
Criteria
可以构建复杂的且阅读友好的查询条件,后面会有具体的演示,这里给出一个多条件查询的case
// 多个查询条件
query = new SimpleQuery();
query.addCriteria(Criteria.where("title").contains("一灰灰").and("content_id").lessThan(2));
ans = solrTemplate.query("yhh", query, DocDO.class);
System.out.println("multiQuery: " + ans.getContent());
输出结果如下,在上面的基础上,捞出了contentId小于2的记录
multiQuery: [DocDO(id=1, contentId=1, title=一灰灰blog, content=这是一灰灰blog的内容, type=1, createAt=1578912072, publishAt=1578912072)]
3. fq查询
fq 主要用来快速过滤,配合query进行操作,主要是借助org.springframework.data.solr.core.query.Query#addFilterQuery
来添加fq条件
// fq查询
query = new SimpleQuery("content: *一灰灰*");
query.addFilterQuery(FilterQuery.filter(Criteria.where("title").contains("blog")));
ans = solrTemplate.query("yhh", query, DocDO.class);
System.out.println("simpleQueryAndFilter: " + ans.getContent());
输出结果如:
simpleQueryAndFilter: [DocDO(id=1, contentId=1, title=一灰灰blog, content=这是一灰灰blog的内容, type=1, createAt=1578912072, publishAt=1578912072)]
4. fl指定查询字段
当我们只关注solr文档中的部分字段时,可以考虑指定fl,只获取所需的字段;通过org.springframework.data.solr.core.query.SimpleQuery#addProjectionOnFields(java.lang.String...)
来指定需要返回的字段名
/**
* 查询指定的字段
*/
private void querySpecialFiled() {
SimpleQuery query = new SimpleQuery();
query.addCriteria(Criteria.where("content_id").lessThanEqual(2));
// fl 查询
query.addProjectionOnFields("id", "title", "content");
List<DocDO> ans = solrTemplate.query("yhh", query, DocDO.class).getContent();
System.out.println("querySpecialField: " + ans);
}
输出结果如下
querySpecialField: [DocDO(id=1, contentId=null, title=一灰灰blog, content=这是一灰灰blog的内容, type=null, createAt=null, publishAt=null), DocDO(id=2, contentId=null, title=一灰灰, content=这是一灰灰的内容, type=null, createAt=null, publishAt=null)]
请注意,我们指定了只需要返回id
, title
, content
,所以返回的DO中其他的成员为null
5. 范围查询
针对数字类型,支持范围查询,比如上面给出Criteria.where("content_id").lessThanEqual(2)
,表示查询content_id
小于2的记录,下面给出一个between的查询
/**
* 范围查询
*/
private void queryRange() {
Query query = new SimpleQuery();
query.addCriteria(Criteria.where("content_id").between(1, 3));
query.addSort(Sort.by("content_id").ascending());
List<DocDO> ans = solrTemplate.query("yhh", query, DocDO.class).getContent();
System.out.println("queryRange: " + ans);
}
输出结果如下,请注意between查询,左右都是闭区间
queryRange: [DocDO(id=1, contentId=1, title=一灰灰blog, content=这是一灰灰blog的内容, type=1, createAt=1578912072, publishAt=1578912072), DocDO(id=2, contentId=2, title=一灰灰, content=这是一灰灰的内容, type=1, createAt=1578912072, publishAt=1578912072), DocDO(id=3, contentId=3, title=solrTemplate 修改之后!!!, content=null, type=0, createAt=1578997659, publishAt=1578997659)]
如果不想要闭区间,可以用between
的重载方法
query = new SimpleQuery();
// 两个false,分表表示不包含下界 上界
query.addCriteria(Criteria.where("content_id").between(1, 3, false, false));
query.addSort(Sort.by("content_id").ascending());
ans = solrTemplate.query("yhh", query, DocDO.class).getContent();
System.out.println("queryRange: " + ans);
输出结果如
queryRange: [DocDO(id=2, contentId=2, title=一灰灰, content=这是一灰灰的内容, type=1, createAt=1578912072, publishAt=1578912072)]
6. 排序
上面的case中,已经用到了排序,主要是Sort
来指定排序字段以及排序的方式;因为id在solr中实际上是字符串格式,所以如果用id进行排序时,实际上是根据字符串的排序规则来的(虽然我们的POJO中id为int类型)
/**
* 查询并排序
*/
private void queryAndSort() {
// 排序
Query query = new SimpleQuery();
query.addCriteria(new Criteria("content").contains("一灰灰"));
// 倒排
query.addSort(Sort.by("content_id").descending());
Page<DocDO> ans = solrTemplate.query("yhh", query, DocDO.class);
System.out.println("queryAndSort: " + ans.getContent());
}
输出结果如下
queryAndSort: [DocDO(id=2, contentId=2, title=一灰灰, content=这是一灰灰的内容, type=1, createAt=1578912072, publishAt=1578912072), DocDO(id=1, contentId=1, title=一灰灰blog, content=这是一灰灰blog的内容, type=1, createAt=1578912072, publishAt=1578912072)]
7. 分页查询
分页查询比较常见,特别是当数据量比较大时,请一定记得,添加分页条件
一个查询case如下,查询所有的数据,并制定了分页条件,查询第二条和第三条数据(计数从0开始)
/**
* 分页
*/
private void queryPageSize() {
Query query = new SimpleQuery("*:*");
query.addSort(Sort.by("content_id").ascending());
// 指定偏移量,从0开始
query.setOffset(2L);
// 查询的size数量
query.setRows(2);
Page<DocDO> ans = solrTemplate.queryForPage("yhh", query, DocDO.class);
// 文档数量
long totalDocNum = ans.getTotalElements();
List<DocDO> docList = ans.getContent();
System.out.println("queryPageSize: totalDocNum=" + totalDocNum + " docList=" + docList);
}
在返回结果中,查了返回查询的文档之外,还会给出满足条件的文档数量,可以通过Page#getTotalElements
获取,
上面case输出结果如下
queryPageSize: totalDocNum=6 docList=[DocDO(id=3, contentId=3, title=solrTemplate 修改之后!!!, content=null, type=0, createAt=1578997946, publishAt=1578997946), DocDO(id=4, contentId=4, title=null, content=null, type=1, createAt=0, publishAt=0)]
8. 分组查询
分组和前面的查询有一点区别,主要在于结果的处理,以及分组参数必须指定分页信息
/**
* 分组查询
*/
private void queryGroup() {
Query query = new SimpleQuery("*:*");
// 请注意,分组查询,必须指定 offset/limit, 否则会抛异常,Pageable must not be null!
GroupOptions groupOptions = new GroupOptions().addGroupByField("type").setOffset(0).setLimit(10);
query.setGroupOptions(groupOptions);
GroupPage<DocDO> ans = solrTemplate.queryForGroupPage("yhh", query, DocDO.class);
GroupResult<DocDO> groupResult = ans.getGroupResult("type");
Page<GroupEntry<DocDO>> entries = groupResult.getGroupEntries();
System.out.println("============ query for group ============ ");
for (GroupEntry<DocDO> sub : entries) {
// type 的具体值
String groupValue = sub.getGroupValue();
Page<DocDO> contentList = sub.getResult();
System.out.println("queryGroup v=" + groupValue + " content=" + contentList.getContent());
}
System.out.println("============ query for group ============ ");
}
上面的case虽然比较简单,但是有几点需要注意, 特别是返回结果的获取,包装层级有点深
- GroupOptions:
- 必须指定offset/limit,当两个条件都没有时会抛异常
- 只指定offset时,limit默认为1
- 只指定limit时,offset默认为0
- 结果处理
GroupPage#getGroupResult(field)
获取分组内容,其中field为指定分组的成员- 遍历
GroupResult#getGroupEntries
,获取每个分组对应的文档列表
输出结果如下
============ query for group ============
queryGroup v=1 content=[DocDO(id=1, contentId=1, title=一灰灰blog, content=这是一灰灰blog的内容, type=1, createAt=1578912072, publishAt=1578912072), DocDO(id=2, contentId=2, title=一灰灰, content=这是一灰灰的内容, type=1, createAt=1578912072, publishAt=1578912072), DocDO(id=5, contentId=5, title=addBatchByBean - 1, content=新增一个测试文档, type=1, createAt=1578997946, publishAt=1578997946), DocDO(id=6, contentId=6, title=addBatchByBean - 2, content=新增又一个测试文档, type=1, createAt=1578997946, publishAt=1578997946), DocDO(id=4, contentId=4, title=null, content=null, type=1, createAt=0, publishAt=0)]
queryGroup v=0 content=[DocDO(id=3, contentId=3, title=solrTemplate 修改之后!!!, content=null, type=0, createAt=1578997946, publishAt=1578997946)]
============ query for group ============
III. 其他
0. 系列博文&工程源码
系列博文
- 200114-SpringBoot系列教程Solr之文档删除
- 190526-SpringBoot高级篇搜索Solr之文档新增与修改使用姿势
- 190510-SpringBoot高级篇搜索之Solr环境搭建与简单测试
工程源码
1.5 - 5.Solr身份认证与授权更新异常解决方案
之前介绍solr的教程中,solr没有开启权限校验,所有的操作都是无需鉴权;当时提到,如果solr开启了权限校验,改一下solr的host,带上用户名/密码即可,然而真实情况却并不太一样,查询ok,涉及到修改的操作,则会抛异常
本文将带你了解一下,这到底是个什么鬼畜现象
I. Solr配置用户登录
1. 安装
之前的solr系列教程中,通过docker安装的solr,下面的步骤也是直接针对docker中的solr进行配置,基本步骤一样
具体可以参考: 【搜索系列】Solr环境搭建与简单测试
不想看的同学,直接用下面的命令即可:
docker pull solr
docker run --name my-solr -d -p 8983:8983 -t solr
2. 配置
下面一步一步教你如何设置用户密码,也可以参考博文: 手把手教你 对 solr8 配置用户登录验证
进入实例,注意使用root
用户,否则某些操作可能没有权限
docker exec -u root -it my-solr /bin/bash
创建鉴权文件
vim server/etc/verify.properties
内容如下,格式为 用户名:密码,权限
, 一行一个账号
root:123,admin
配置鉴权文件
vim server/contexts/solr-jetty-context.xml
添加下面的内容放在Configure
标签内
<Get name="securityHandler">
<Set name="loginService">
<New class="org.eclipse.jetty.security.HashLoginService">
<Set name="name">verify—name</Set>
<Set name="config"><SystemProperty name="jetty.home" default="."/>/etc/verify.properties</Set>
</New>
</Set>
</Get>
修改web.xml
vim server/solr-webapp/webapp/WEB-INF/web.xml
在security-constraint
标签下面,新增
<login-config>
<auth-method>BASIC</auth-method>
<!-- 请注意,这个name 和上面的Set标签中的name保持一致 -->
<realm-name>verify-name</realm-name>
</login-config>
重启solr,配置生效
docker restart my-solr
II. 场景复现
接下来介绍一下我们的环境
- springboot: 2.2.1.RELEASE
- solr: 8.0
1. 项目环境
搭建一个简单的springboot项目,xml依赖如下
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.1.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-solr</artifactId>
</dependency>
<!-- 请注意,在solr开启登录验证时,这个依赖必须有 -->
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
</dependency>
</dependencies>
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</pluginManagement>
</build>
<repositories>
<repository>
<id>spring-snapshots</id>
<name>Spring Snapshots</name>
<url>https://repo.spring.io/libs-snapshot-local</url>
<snapshots>
<enabled>true</enabled>
</snapshots>
</repository>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/libs-milestone-local</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
<repository>
<id>spring-releases</id>
<name>Spring Releases</name>
<url>https://repo.spring.io/libs-release-local</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>
对应的配置文件application.yml
spring:
data:
solr:
# 请注意,用户名密码直接写在了url中
host: http://root:123@127.0.0.1:8983/solr
2. 复现
关于solr的基本操作,如果有疑问的小伙伴可以翻一下我之前的搜索系列博文,满足你的扫盲需求;
核心的solr操作实例如下:
@Data
public class DocDO implements Serializable {
private static final long serialVersionUID = 7245059137561820707L;
@Id
@Field("id")
private Integer id;
@Field("content_id")
private Integer contentId;
@Field("title")
private String title;
@Field("content")
private String content;
@Field("type")
private Integer type;
@Field("create_at")
private Long createAt;
@Field("publish_at")
private Long publishAt;
}
@Component
public class SolrOperater {
@Autowired
private SolrTemplate solrTemplate;
public void operate() {
testAddByDoc();
queryById();
}
public void testAddByDoc() {
SolrInputDocument document = new SolrInputDocument();
document.addField("id", 999999);
document.addField("content_id", 3);
document.addField("title", "testAddByDoc!");
document.addField("content", "新增哒哒哒");
document.addField("type", 2);
document.addField("create_at", System.currentTimeMillis() / 1000);
document.addField("publish_at", System.currentTimeMillis() / 1000);
UpdateResponse response = solrTemplate.saveDocument("yhh", document, Duration.ZERO);
solrTemplate.commit("yhh");
System.out.println("over:" + response);
}
private void queryById() {
DocDO ans = solrTemplate.getById("yhh", 999999, DocDO.class).get();
System.out.println("queryById: " + ans);
}
}
SolrTemplat
定义如下
@Configuration
public class SearchAutoConfig {
@Bean
@ConditionalOnMissingBean(SolrTemplate.class)
public SolrTemplate solrTemplate(SolrClient solrClient) {
return new SolrTemplate(solrClient);
}
}
开始测试
@SpringBootApplication
public class Application {
public Application(SolrOperater solrOperater) {
solrOperater.operate();
}
public static void main(String[] args) {
SpringApplication.run(Application.class);
}
}
请注意,复现上面的场景时,会发现查询没问题,修改则会抛异常
3. 解决方案
a. 降版本
我之前用solr的时候,也是上面的操作方式,然而并没有出现过这种问题,这就有点蛋疼了;
找之前的项目查看版本,发现之前用的solr-solrj
用的是6.6.5
,换个版本试一下(默认的版本是8.2.0
)
<dependency>
<groupId>org.apache.solr</groupId>
<artifactId>solr-solrj</artifactId>
<version>6.6.5</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-solr</artifactId>
<exclusions>
<exclusion>
<groupId>org.apache.solr</groupId>
<artifactId>solr-solrj</artifactId>
</exclusion>
</exclusions>
</dependency>
见证奇迹的时刻到了,执行正常了,虽然saveDocument
方法的调用标红,但是不影响具体的执行哦
b. SystemDefaultHttpClient
通过一顿debug,单步执行,终于找到为啥6.6.5
版本的solr-solrj
可以正常操作,而8.2.0
却不行(如果想知道这一枯燥的过程,请评论告诉我,否则我也不知道啥时候可以看到😂)
关键的问题就是旧版本的用的是SystemDefaultHttpClient
来实现solr的沟通;新版本使用的是InternalHttpClient
那么一个可用的解决方法就是不降版本,改为指定Solr的HttpClient
在配置类中,如下操作:
@Bean
public HttpSolrClient solrClient() {
HttpClient httpClient = new SystemDefaultHttpClient();
return new HttpSolrClient.Builder(url).withHttpClient(httpClient).build();
}
然后测试,也是正常执行,输出结果就不截图了,各位小伙伴可以亲自测试一下
c. HttpClient拦截器
关于下面的这段写法,来自: Preemptive Basic authentication with Apache HttpClient 4
上面的方式虽然可以让我们正确操作solr了,但是SystemDefaultHttpClient
有一个删除注解,也就是说不建议再直接用它了,那就借鉴它的使用方式,来满足我们的需求,所以可以如下操作
@Value("${spring.data.solr.host}")
private String url;
@Data
public static class UrlDo {
private String url;
private String user;
private String pwd;
private String host;
private int port;
public static UrlDo parse(String url) throws MalformedURLException {
// http://root:123@127.0.0.1:8983/solr
URL u = new URL(url);
UrlDo out = new UrlDo();
out.setHost(u.getHost());
out.setPort(u.getPort());
String userInfo = u.getUserInfo();
if (!StringUtils.isEmpty(userInfo)) {
String[] users = org.apache.commons.lang3.StringUtils.split(userInfo, ":");
out.setUser(users[0]);
out.setPwd(users[1]);
}
out.setUrl(url);
return out;
}
}
public class SolrAuthInterceptor implements HttpRequestInterceptor {
@Override
public void process(final HttpRequest request, final HttpContext context) {
AuthState authState = (AuthState) context.getAttribute(HttpClientContext.TARGET_AUTH_STATE);
if (authState.getAuthScheme() == null) {
CredentialsProvider credsProvider =
(CredentialsProvider) context.getAttribute(HttpClientContext.CREDS_PROVIDER);
HttpHost targetHost = (HttpHost) context.getAttribute(HttpCoreContext.HTTP_TARGET_HOST);
AuthScope authScope = new AuthScope(targetHost.getHostName(), targetHost.getPort());
Credentials creds = credsProvider.getCredentials(authScope);
authState.update(new BasicScheme(), creds);
}
}
}
@Bean
public HttpSolrClient solrClient() throws MalformedURLException {
UrlDo urlDo = UrlDo.parse(url);
CredentialsProvider provider = new BasicCredentialsProvider();
provider.setCredentials(new AuthScope(urlDo.getHost(), urlDo.getPort()),
new UsernamePasswordCredentials(urlDo.getUser(), urlDo.getPwd()));
HttpClientBuilder builder = HttpClientBuilder.create();
// 请注意下面这一行,指定拦截器,用于设置认证信息
builder.addInterceptorFirst(new SolrAuthInterceptor());
builder.setDefaultCredentialsProvider(provider);
CloseableHttpClient httpClient = builder.build();
return new HttpSolrClient.Builder(url).withHttpClient(httpClient).build();
}
上面的实现有点长,简单的拆解一下
UrlDo
: 解析solr的url,得到我们需要的host + port + user + password
solrClient
: 在创建SolrClient
bean实例时,指定相应的授权信息SolrAuthInterceptor
: 自定义拦截器,更新authState
信息
d. SolrRequest
上面的三种方式,适用于利用SolrClient
或者SolrTemplate
来操作的solr;当然我可以完全抛弃掉它们,直接使用SolrRequest
来操作,如下
SolrInputDocument document = new SolrInputDocument();
document.addField("id", 999999);
document.addField("content_id", 3);
document.addField("title", "testAddByDoc!");
document.addField("content", "新增哒哒哒");
document.addField("type", 2);
document.addField("create_at", System.currentTimeMillis() / 1000);
document.addField("publish_at", System.currentTimeMillis() / 1000);
UpdateRequest updateRequest = new UpdateRequest();
updateRequest.setBasicAuthCredentials("root", "123");
updateRequest.add(document);
UpdateResponse response = updateRequest.process(solrClient, "yhh");
updateRequest.commit(solrClient, "yhh");
4. 小结
本篇博文主要是针对需要登录验证的solr更新操作异常时,给出了四种解决方案
- 降
solr-solrj
版本到6.6.0
- 指定
SolrClient
的HttpClient
为SystemDefaultHttpClient
- HttpClient拦截器
- SolrRequest指定用户名密码
上面虽然给出了解决方法,但是为啥有这个问题呢?
直接通过curl来测试一下更新solr操作,正常返回,并没有问题,那么这个问题到底啥原因,究竟是谁的锅,请敬请期待后续问题定位盖锅定论
II. 其他
0. 系列博文&工程源码
参考博文
系列博文
- 200115-SpringBoot系列教程Solr之查询使用姿势小结
- 200114-SpringBoot系列教程Solr之文档删除
- 190526-SpringBoot高级篇搜索Solr之文档新增与修改使用姿势
- 190510-SpringBoot高级篇搜索之Solr环境搭建与简单测试
工程源码
2 - ElasticSearch
SpringBoot整合ElasticSearch,支撑搜索全业务场景
2.1 - 1.ES基本项目搭建
之前一直没有写ES相关的博文,现在开始补课,预计5-6篇博文将es的使用姿势展示给各位小伙伴;本文将作为es结合springboot的第一篇博文,基本项目环境搭建
I. 项目搭建
1. 项目依赖
本项目借助SpringBoot 2.2.1.RELEASE
+ maven 3.5.3
+ IDEA
进行开发
开一个web服务用于测试
<dependencies>
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
</dependency>
</dependencies>
2. 配置信息
配置文件application.yml,注意下面的配置信息,下面采用的是由我们自己来解析配置的方式
elasticsearch:
host: localhost
port: 9200
user: elastic
pwd: test123
connTimeout: 3000
socketTimeout: 5000
connectionRequestTimeout: 500
说明
上面配置介绍的是一种偏基础的es文档操作姿势,相比较于封装得更好的spring-boot-starter-data-elasticsearch
,使用更加灵活
II. SpringBoot结合ES使用
1. RestHighLevelClient 初始化
接下来我们基于RestHighLevelClient
来操作es,首先第一步就是需要初始化这实例
@Getter
@Configuration
public class ElasticsearchConfiguration {
@Value("${elasticsearch.host}")
private String host;
@Value("${elasticsearch.port}")
private int port;
@Value("${elasticsearch.connTimeout}")
private int connTimeout;
@Value("${elasticsearch.socketTimeout}")
private int socketTimeout;
@Value("${elasticsearch.connectionRequestTimeout}")
private int connectionRequestTimeout;
@Value("${elasticsearch.user}")
private String user;
@Value("${elasticsearch.pwd}")
private String pwd;
@Bean(destroyMethod = "close", name = "client")
public RestHighLevelClient initRestClient() {
RestClientBuilder builder = RestClient.builder(new HttpHost(host, port))
.setRequestConfigCallback(requestConfigBuilder -> requestConfigBuilder
.setConnectTimeout(connTimeout)
.setSocketTimeout(socketTimeout)
.setConnectionRequestTimeout(connectionRequestTimeout));
return new RestHighLevelClient(builder);
}
}
注意上面的实现,用户名 + 密码并没有使用,当es设置了用户名、密码之后,是通过每次请求时,在请求头基于Basic Auth方式进行身份验证的;后面会介绍到
2. 基本使用
我们在本机搭建了一个es用于模拟测试,在上面的配置完之后,就可以直接与es进行交互了
es安装可以参考:
- 200605-Centos 安装ElasticSearch - 一灰灰Blog
- 210329-Elastic & Kibana安装与基本使用 - 一灰灰Blog docker安装:
docker pull docker.elastic.co/elasticsearch/elasticsearch:xxx
下面是一个简单的使用姿势
@Service
public class EsTest {
@Autowired
private RestHighLevelClient client;
private static String auth;
public EsTest(ElasticsearchConfiguration elasticsearchConfiguration) {
auth = Base64Utils.encodeToString((elasticsearchConfiguration.getUser() + ":" + elasticsearchConfiguration.getPwd()).getBytes());
auth = "Basic " + auth;
}
public void testGet() throws Exception {
// 文档查询
GetRequest getRequest = new GetRequest("first-index", "_doc", "gvarh3gBF9fSFsHNuO49");
RequestOptions.Builder requestOptions = RequestOptions.DEFAULT.toBuilder();
requestOptions.addHeader("Authorization", auth);
GetResponse getResponse = client.get(getRequest, requestOptions.build());
if (getResponse.isExists()) {
String sourceAsString = getResponse.getSourceAsString();
System.out.println(sourceAsString);
} else {
System.out.println("no string!");
}
}
}
注意上面的实现,有下面几个重要知识点
身份验证
采用Basic Auth方式进行身份校验,简单来说就是在请求头中添加一个
key = Authorization
value = "Basic " + base64(user + ":" + pwd)
访问姿势
上面是一个根据id
查询文档的实例,简单可以理解为三步
- 创建:
XxRequest
- 添加请求头:
RequestOptions.Builder.addHeader
- 执行:
client.get(xxRequest, RequestOptions)
III. 不能错过的源码和相关知识点
0. 项目
- 工程:https://github.com/liuyueyi/spring-boot-demo
- 源码:https://github.com/liuyueyi/spring-boot-demo/tree/master/spring-boot/142-search-es
1. 微信公众号: 一灰灰Blog
尽信书则不如,以上内容,纯属一家之言,因个人能力有限,难免有疏漏和错误之处,如发现bug或者有更好的建议,欢迎批评指正,不吝感激
下面一灰灰的个人博客,记录所有学习和工作中的博文,欢迎大家前去逛逛
- 一灰灰Blog个人博客 https://blog.hhui.top
- 一灰灰Blog-Spring专题博客 http://spring.hhui.top
2.2 - 2.ES文档基本操作CURD实例演示
本文将作为es系列第二篇,在前文项目搭建的基础上,先来看一下es的基本操作姿势,如何实现CURD
I. 项目搭建
1. 项目依赖
本项目借助SpringBoot 2.2.1.RELEASE
+ maven 3.5.3
+ IDEA
进行开发
开一个web服务用于测试
<dependencies>
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
</dependency>
</dependencies>
2. 配置信息
配置文件application.yml,注意下面的配置信息,下面采用的是由我们自己来解析配置的方式
elasticsearch:
host: localhost
port: 9200
user: elastic
pwd: test123
connTimeout: 3000
socketTimeout: 5000
connectionRequestTimeout: 500
II. CURD实例
1. 配置
注意,本文介绍的es是添加了权限验证,因此我们在于es进行交互时,需要在请求头中携带验证信息,注意下面的实现姿势
读取配置,初始化RestHighLevelClient,和前文介绍的差不多
@Getter
@Configuration
public class ElasticsearchConfiguration {
@Value("${elasticsearch.host}")
private String host;
@Value("${elasticsearch.port}")
private int port;
@Value("${elasticsearch.connTimeout}")
private int connTimeout;
@Value("${elasticsearch.socketTimeout}")
private int socketTimeout;
@Value("${elasticsearch.connectionRequestTimeout}")
private int connectionRequestTimeout;
@Value("${elasticsearch.user}")
private String user;
@Value("${elasticsearch.pwd}")
private String pwd;
@Bean(destroyMethod = "close", name = "client")
public RestHighLevelClient initRestClient() {
RestClientBuilder builder = RestClient.builder(new HttpHost(host, port))
.setRequestConfigCallback(requestConfigBuilder -> requestConfigBuilder
.setConnectTimeout(connTimeout)
.setSocketTimeout(socketTimeout)
.setConnectionRequestTimeout(connectionRequestTimeout));
return new RestHighLevelClient(builder);
}
@Bean
public RequestOptions requestOptions() {
String auth = "Basic " + Base64Utils.encodeToString((user + ":" + pwd).getBytes());
RequestOptions.Builder build = RequestOptions.DEFAULT.toBuilder();
build.addHeader("Authorization", auth);
return build.build();
}
}
2. 添加数据
@Component
public class BasicCurdDemo {
@Autowired
private RestHighLevelClient client;
@Autowired
private RequestOptions requestOptions;
private String TEST_ID = "11123-33345-66543-55231";
/**
* 新增数据
*/
public void addDoc(String indexName, Object obj, String id) throws IOException {
// 指定索引
IndexRequest request = new IndexRequest(indexName);
request.type("_doc");
// 文档内容,source传参,第一种时按照 fieldName, fieldValue 成对的方式传入;下面是采用json串 + 指定ContentType的方式传入
request.source(JSON.toJSONString(obj), XContentType.JSON);
// 指定特殊的id,不指定时自动生成id
request.id(id);
IndexResponse response = client.index(request, requestOptions);
System.out.println("添加数据返回结果: " + response.toString());
}
}
添加数据,注意是利用 IndexRequest
来构建请求对象,添加文档时有几个注意事项
request.source()
: 具体需要上传的文档,就是通过它挂上去的,我们这里采用的是json方式request.id()
: 如果上传的文档需要指定id,则可以使用它;若未指定,则表明自动生成id
发起请求: client.index()
3. 查询数据
这里先介绍一个基础的根据id进行查询的实例case,更多的查询姿势后面会详细介绍
/**
* 查询结果
*
* @param indexName
* @param id
* @throws Exception
*/
public void get(String indexName, String id) throws IOException {
GetRequest getRequest = new GetRequest(indexName, "_doc", id);
GetResponse response = client.get(getRequest, requestOptions);
System.out.println("查询结果:" + response.toString());
}
3. 增量更新数据
根据主键进行更新文档,如下
/**
* 更新文档,根据id进行更新,增量更新,存在的字段,覆盖;新增的字段,插入;旧字段,保留
*
* @param indexName
* @param docId
* @param obj
* @throws IOException
*/
public void updateDoc(String indexName, String docId, Object obj) throws IOException {
UpdateRequest updateRequest = new UpdateRequest();
updateRequest.index(indexName);
updateRequest.type("_doc");
updateRequest.id(docId);
// 设置数据
updateRequest.doc(JSON.toJSONString(obj), XContentType.JSON);
UpdateResponse response = client.update(updateRequest, requestOptions);
System.out.println("更新数据返回:" + response.toString());
}
注意
- 上面的实现属于增量更新策略
- 即:新传的文档,若key之前已经存在,则覆盖更新;若之前不存在,则插入;之前文档中未被覆盖的数据依然保留
4. 全量更新
另外一个根据条件进行更新的使用case如下
/**
* 条件更新
*
* @param indexName
* @param query
* @param data
* @throws IOException
*/
public void updateByCondition(String indexName, Map<String, String> query, Map<String, Object> data) throws IOException {
UpdateByQueryRequest updateRequest = new UpdateByQueryRequest(indexName);
for (Map.Entry<String, String> entry : query.entrySet()) {
QueryBuilder queryBuilder = new TermQueryBuilder(entry.getKey(), entry.getValue());
updateRequest.setQuery(queryBuilder);
}
// 更新值脚本,精确的更新方式
// ctx._source['xx'].add('新增字段')
// 条件判定 if(ctx._source.addr == 'hubei') { ctx._source.addr = 'wuhan';}
String source = "ctx._source.name='1hui';";
Script script = new Script(source);
updateRequest.setScript(script);
BulkByScrollResponse response = client.updateByQuery(updateRequest, requestOptions);
System.out.println("条件更新返回: " + response.toString());
get(indexName, TEST_ID);
System.out.println("0---------------------0");
// 采用全量覆盖式更新,直接使用data中的数据,覆盖之前的文档内容
source = "ctx._source=params";
script = new Script(ScriptType.INLINE, "painless", source, data);
updateRequest.setScript(script);
response = client.updateByQuery(updateRequest, requestOptions);
System.out.println("条件更新返回: " + response.toString());
get(indexName, TEST_ID);
}
5. 删除数据
直接根据id进行删除
/**
* 根据id进行删除
*
* @param indexName
* @param id
* @throws IOException
*/
public void delete(String indexName, String id) throws IOException {
DeleteRequest deleteRequest = new DeleteRequest(indexName);
deleteRequest.type("_doc");
deleteRequest.id(id);
DeleteResponse response = client.delete(deleteRequest, requestOptions);
System.out.println("删除后返回" + response.toString());
}
6. 条件删除数据
根据条件进行匹配删除
/**
* 条件删除
*
* @param indexName
* @param query
* @throws IOException
*/
public void deleteByQuery(String indexName, Map<String, String> query) throws IOException {
DeleteByQueryRequest request = new DeleteByQueryRequest(indexName);
request.types("_doc");
for (Map.Entry<String, String> entry : query.entrySet()) {
QueryBuilder queryBuilder = new TermQueryBuilder(entry.getKey(), entry.getValue());
request.setQuery(queryBuilder);
}
BulkByScrollResponse response = client.deleteByQuery(request, requestOptions);
System.out.println("条件删除:" + response.toString());
get(indexName, TEST_ID);
}
7. 测试case
写一个测试demo,将上面的case都跑一遍
public void testOperate() throws IOException {
String index = "basic_demo";
Map<String, Object> doc = newMap("name", "一灰灰", "age", 10, "skills", Arrays.asList("java", "python"));
// 新增
addDoc(index, doc, TEST_ID);
// 查询
get(index, TEST_ID);
// 更新
doc.clear();
doc.put("name", "一灰灰blog");
doc.put("addr", "hubei");
updateDoc(index, TEST_ID, doc);
get(index, TEST_ID);
updateByCondition(index, newMap("addr", "hubei"), newMap("name", "yihuihui", "site", "https://hhui.top"));
get(index, TEST_ID);
// 删除文档
delete(index, TEST_ID);
}
public <K, V> Map<K, V> newMap(K k, V v, Object... kv) {
Map<K, V> map = new HashMap<>();
map.put(k, v);
for (int i = 0; i < kv.length; i += 2) {
map.put((K) kv[i], (V) kv[i + 1]);
}
return map;
}
输出如下
# 1. 添加数据
添加数据返回结果: IndexResponse[index=basic_demo,type=_doc,id=11123-33345-66543-55231,version=1,result=created,seqNo=34,primaryTerm=4,shards={"total":2,"successful":1,"failed":0}]
# 2. 查询数据
查询结果:{"_index":"basic_demo","_type":"_doc","_id":"11123-33345-66543-55231","_version":1,"_seq_no":34,"_primary_term":4,"found":true,"_source":{"skills":["java","python"],"name":"一灰灰","age":10}}
# 3. 增量更新
2022-03-28 19:56:08.781 WARN 18332 --- [/O dispatcher 1] org.elasticsearch.client.RestClient : request [POST http://localhost:9200/basic_demo/_doc/11123-33345-66543-55231/_update?timeout=1m] returned 1 warnings: [299 Elasticsearch-7.12.0-78722783c38caa25a70982b5b042074cde5d3b3a "[types removal] Specifying types in document update requests is deprecated, use the endpoint /{index}/_update/{id} instead."]
更新数据返回:UpdateResponse[index=basic_demo,type=_doc,id=11123-33345-66543-55231,version=2,seqNo=35,primaryTerm=4,result=updated,shards=ShardInfo{total=2, successful=1, failures=[]}]
查询结果:{"_index":"basic_demo","_type":"_doc","_id":"11123-33345-66543-55231","_version":2,"_seq_no":35,"_primary_term":4,"found":true,"_source":{"skills":["java","python"],"name":"一灰灰blog","age":10,"addr":"hubei"}}
# 4. 全量条件更新
条件更新返回: BulkByScrollResponse[took=970ms,timed_out=false,sliceId=null,updated=1,created=0,deleted=0,batches=1,versionConflicts=0,noops=0,retries=0,throttledUntil=0s,bulk_failures=[],search_failures=[]]
查询结果:{"_index":"basic_demo","_type":"_doc","_id":"11123-33345-66543-55231","_version":3,"_seq_no":36,"_primary_term":4,"found":true,"_source":{"skills":["java","python"],"name":"1hui","addr":"hubei","age":10}}
III. 不能错过的源码和相关知识点
0. 项目
系列博文
源码
- 工程:https://github.com/liuyueyi/spring-boot-demo
- 源码:https://github.com/liuyueyi/spring-boot-demo/tree/master/spring-boot/142-search-es
1. 微信公众号: 一灰灰Blog
尽信书则不如,以上内容,纯属一家之言,因个人能力有限,难免有疏漏和错误之处,如发现bug或者有更好的建议,欢迎批评指正,不吝感激
下面一灰灰的个人博客,记录所有学习和工作中的博文,欢迎大家前去逛逛
- 一灰灰Blog个人博客 https://blog.hhui.top
- 一灰灰Blog-Spring专题博客 http://spring.hhui.top
2.3 - 3.ES查询常用实例演示
本文将作为es系列第三篇,结合常见的实例,来演示下如何通过RestHighLevelClient
来实现es的各种查询支持
I. 项目搭建
1. 项目依赖
本项目借助SpringBoot 2.2.1.RELEASE
+ maven 3.5.3
+ IDEA
进行开发
开一个web服务用于测试
<dependencies>
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
</dependency>
</dependencies>
2. 配置信息
配置文件application.yml,注意下面的配置信息,下面采用的是由我们自己来解析配置的方式
elasticsearch:
host: localhost
port: 9200
user: elastic
pwd: test123
connTimeout: 3000
socketTimeout: 5000
connectionRequestTimeout: 500
II. 实例演示
0. 准备
在开始之前,先准备插入几条数据,这里会借助上一篇CURD博文中的插入接口
在开始之前就准备两条数据
@Component
public class TermQueryDemo {
private BasicCurdDemo basicCurdDemo;
@Autowired
private RestHighLevelClient client;
@Autowired
private RequestOptions requestOptions;
private String TEST_ID = "11123-33345-66543-55231";
private String TEST_ID_2 = "11123-33345-66543-55232";
private String index = "term-demo";
public TermQueryDemo(BasicCurdDemo basicCurdDemo) throws IOException {
this.basicCurdDemo = basicCurdDemo;
Map<String, Object> doc = newMap("name", "一灰灰", "age", 10, "skills", Arrays.asList("java", "python"), "site", "blog.hhui.top");
basicCurdDemo.addDoc(index, doc, TEST_ID);
doc = newMap("name", "二灰灰", "age", 16, "skills", Arrays.asList("js", "html"));
basicCurdDemo.addDoc(index, doc, TEST_ID_2);
}
@PreDestroy
public void remove() throws IOException {
basicCurdDemo.delete(index, TEST_ID);
basicCurdDemo.delete(index, TEST_ID_2);
}
}
1. 全量查询
即查询所有的文档,如借助kibanan的控制台,发起的请求形如
GET index/_search
{
"query": {
"match_all": {}
}
}
于此对应的java实现如下
/**
* 全量查询
*
* @throws IOException
*/
private void queryAll() throws IOException {
SearchRequest searchRequest = new SearchRequest(index);
searchRequest.types("_doc");
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
// 查询所有的文档
searchSourceBuilder.query(QueryBuilders.matchAllQuery());
searchRequest.source(searchSourceBuilder);
SearchResponse searchResponse = client.search(searchRequest, requestOptions);
System.out.println("mathAll: " + searchResponse.toString());
}
注意上面的实现:
- 初始化
SearchRequest
实例,用于构建请求相关数据 SearchSourceBuilder
来填充查询条件client.search(searchRequest, requestOptions)
执行查询请求,第二个参数为请求参数,这里主要是设置请求时的权限验证信息
通常来说,实际的业务场景中,不太可能出现上面这种没有任何限制的查全量数据,即便真的有查全量数据的case,更常见的是分页查询,如下
private void queryAll() throws IOException {
SearchRequest searchRequest = new SearchRequest(index);
searchRequest.types("_doc");
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
int page = 1;
//每页记录数
int size = 2;
//计算出记录起始下标
int from = (page - 1) * size;
//起始记录下标,从0开始
searchSourceBuilder.from(from);
//每页显示的记录数
searchSourceBuilder.size(size);
// 根据age字段进行倒排
searchSourceBuilder.sort(new FieldSortBuilder("age").order(SortOrder.DESC));
// 查询所有的文档
searchSourceBuilder.query(QueryBuilders.matchAllQuery());
searchRequest.source(searchSourceBuilder);
SearchResponse searchResponse = client.search(searchRequest, requestOptions);
System.out.println("mathAll: " + searchResponse.toString());
}
2. 根据Field值精确查询
即es中常说的term查询,具体实现如下
/**
* term精确查询
*
* @throws IOException
*/
private void term() throws IOException {
SearchRequest searchRequest = new SearchRequest(index);
searchRequest.types("_doc");
// termQuery: 精确查询
// SpanTermQuery: 词距查询
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
searchSourceBuilder.query(QueryBuilders.termQuery("site", "blog.hhui.top"));
searchRequest.source(searchSourceBuilder);
SearchResponse response = client.search(searchRequest, requestOptions);
System.out.println("term: " + response.toString());
}
从上面的实现也可以看出,查询的套路没啥区别,无非就是SearchSourceBuilder
中的参数构造不一样;上面主要通过
QueryBuilders.termQuery("site", "blog.hhui.top")
来构建 term的查询条件,表明查询site=blog.hhui.top
的文档
中文查询不到问题
在我们实际使用过程中,如果value为中文,在查询时,可能会遇到命名有对应的数据,但是就查不到,主要原因就在于分词,如对于中文的查询,可以考虑下面这种方式
private void term2() throws IOException {
SearchRequest searchRequest = new SearchRequest(index);
searchRequest.types("_doc");
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
// 对于中文查询,需要注意分词的场景, 如果直接使用 "name : 一灰灰" 的方式进行查询,则啥也不会返回
// elasticsearch 里默认的IK分词器是会将每一个中文都进行了分词的切割,所以你直接想查一整个词,或者一整句话是无返回结果的。
// 在此种情况下,我们可以通过指定 keyword 的方式来处理, 设置关键词搜索(不进行分词)
searchSourceBuilder.query(QueryBuilders.termQuery("name.keyword", "一灰灰"));
searchRequest.source(searchSourceBuilder);
SearchResponse response = client.search(searchRequest, requestOptions);
System.out.println("term2: " + response.toString());
}
3. Field值in查询
另外一个常见的就是多值查询,也就是我们常说的 field in (val1, val2...)
,这个对应的就是es中的terms
查询
/**
* 相当于in查询
* {"terms": { "name": ["一灰灰", "二灰灰] }}
*
* @throws IOException
*/
private void multTerm() throws IOException {
SearchRequest searchRequest = new SearchRequest(index);
searchRequest.types("_doc");
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
searchSourceBuilder.query(QueryBuilders.termsQuery("name.keyword", "一灰灰", "二灰灰"));
searchRequest.source(searchSourceBuilder);
SearchResponse response = client.search(searchRequest, requestOptions);
System.out.println("term: " + response.toString());
}
4. 范围查询
对于数值类型的Field,同样是支持比较、范围查询的,对应的是es中 range
/**
* 范围查询
* { "range": { "age": { "gt":8, "lt": 12 } }}
*
* @throws IOException
*/
private void range() throws IOException {
SearchRequest searchRequest = new SearchRequest(index);
searchRequest.types("_doc");
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
searchSourceBuilder.query(QueryBuilders.rangeQuery("age").gt(8).lt(12));
searchRequest.source(searchSourceBuilder);
SearchResponse response = client.search(searchRequest, requestOptions);
System.out.println("range: " + response.toString());
}
注意上面的查询有条件
QueryBuilders.rangeQuery("age").gt(8).lt(12)
- 表示查询
age > 8 && age < 12
- gte: 表示 >=
- lte: 表示 <=
5. Field是否存在查询
es不同于mysql的在于它的field可以动态新增,当我们希望查询包含某个字段的文档时,可以考虑 exists
/**
* 根据字段是否存在查询
*
* @throws IOException
*/
private void exists() throws IOException {
SearchRequest searchRequest = new SearchRequest(index);
searchRequest.types("_doc");
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
searchSourceBuilder.query(QueryBuilders.existsQuery("site"));
searchRequest.source(searchSourceBuilder);
SearchResponse response = client.search(searchRequest, requestOptions);
System.out.println("exists: " + response.toString());
}
6. 模糊查询
es作为搜索引擎,更常见的是模糊匹配,比如match查询
/**
* 根据字段匹配查询
*
* @throws IOException
*/
private void match() throws IOException {
SearchRequest searchRequest = new SearchRequest(index);
searchRequest.types("_doc");
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
searchSourceBuilder.query(QueryBuilders.matchQuery("name", "灰"));
searchRequest.source(searchSourceBuilder);
SearchResponse response = client.search(searchRequest, requestOptions);
System.out.println("matchQuery: " + response.toString());
}
多Field中进行查询
/**
* 多字段中查询
*
* @throws IOException
*/
private void multiMatch() throws IOException {
SearchRequest searchRequest = new SearchRequest(index);
searchRequest.types("_doc");
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
searchSourceBuilder.query(QueryBuilders.multiMatchQuery("灰", "name", "site"));
searchRequest.source(searchSourceBuilder);
SearchResponse response = client.search(searchRequest, requestOptions);
System.out.println("multiMatchQuery: " + response.toString());
}
在es的语法支持中,除了match,还有一个wildcard
,可以使用?
来代指单字符,*
来代指0..n字符
/**
* 模糊查询 ? 单字符 * 0..n字符
*
* @throws IOException
*/
private void wild() throws IOException {
SearchRequest searchRequest = new SearchRequest(index);
searchRequest.types("_doc");
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
searchSourceBuilder.query(QueryBuilders.wildcardQuery("site", "*top"));
searchRequest.source(searchSourceBuilder);
SearchResponse response = client.search(searchRequest, requestOptions);
System.out.println("wildcard: " + response.toString());
}
7. 正则匹配
private void regexp() throws IOException {
SearchRequest searchRequest = new SearchRequest(index);
searchRequest.types("_doc");
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
searchSourceBuilder.query(QueryBuilders.regexpQuery("site", ".*hhui.*"));
searchRequest.source(searchSourceBuilder);
SearchResponse response = client.search(searchRequest, requestOptions);
System.out.println("regexpQuery: " + response.toString());
}
8. 前缀查询
private void prefix() throws IOException {
SearchRequest searchRequest = new SearchRequest(index);
searchRequest.types("_doc");
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
searchSourceBuilder.query(QueryBuilders.prefixQuery("site", "blog"));
searchRequest.source(searchSourceBuilder);
SearchResponse response = client.search(searchRequest, requestOptions);
System.out.println("prefixQuery: " + response.toString());
}
9.小结
本文虽然介绍了一些常见的查询case,但注意并不仅仅只有这些,比如
- 查询指定Feild的内容
- 排序
- 分组聚合
- 多查询条件组合:and/or
- 高亮
- …
更多的使用实例,敬请期待…,欢迎感兴趣的小伙伴,点赞收藏评论一波😝
III. 不能错过的源码和相关知识点
0. 项目
系列博文
源码
- 工程:https://github.com/liuyueyi/spring-boot-demo
- 源码:https://github.com/liuyueyi/spring-boot-demo/tree/master/spring-boot/142-search-es
1. 微信公众号: 一灰灰Blog
尽信书则不如,以上内容,纯属一家之言,因个人能力有限,难免有疏漏和错误之处,如发现bug或者有更好的建议,欢迎批评指正,不吝感激
下面一灰灰的个人博客,记录所有学习和工作中的博文,欢迎大家前去逛逛
- 一灰灰Blog个人博客 https://blog.hhui.top
- 一灰灰Blog-Spring专题博客 http://spring.hhui.top