1)使用Spring Boot的DevTools实现热部署
在项目创建之初我们就已经在pom文件中引入了spring-boot-devtools
这个Spring Boot提供的热部署插件,但是可能会遇到热部署不生效的问题,如果你使用的是Intellij Idea开发工具,参见《使用Spring Boot Devtools实现热部署》。
2)创建数据表的实体类
虽说我们要做的是对于博客的全文检索,但是在用户没有给定搜索条件之前,我们还是应该使用Mysql通过主键索引的方式查询出博客数据(当然,简单起见这里没有实现分页查询功能),Mysql的主键查询是很快的,无需使用ES;当用户输入查询条件时,表示需要进行全文检索时我们才使用ES。
Mysql自不用说,ES也是需要实体类来承载数据的,他们的字段结构都是相同的,不同的只是标注在字段上的注解,下面来快速创建他们的实体类:
2.1)创建博客文章Mysql实体类
@Data
@Table(name = "t_article") // 指定实体类对应的数据表
@Entity
public class MysqlArticle {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // 使用数据表定义的增长策略(自增)
private Integer id; // 文章ID
private String author; // 文章作者
private String title; // 文章标题
// String类型默认映射的是varchar类型,content字段使用到了mediumtext,需要显式指定一下
@Column(name = "content", columnDefinition = "mediumtext")
private String content; // 文章正文内容
private Date createTime; // 文章创建时间
private Date updateTime; // 文章更新时间
}
2.2)创建博客文章ElasticSearch实体类
@Data
// 指定实体类对应ES的索引名称为blog,类型type是文档类型,使用服务器远程配置
// 为避免每次重启项目都将ES中的数据删除后再同步,createIndex指定为false
@Document(indexName = "blog", type = "_doc",
useServerConfiguration = true, createIndex = false)
public class ElasticArticle {
@Id // org.springframework.data.annotation.Id
private Integer id; // 文章ID
// 指定字段对应的ES类型是Text,analyzer指定分词器为ik_max_word
@Field(type = FieldType.Text, analyzer = "ik_max_word")
private String author; // 文章作者
@Field(type = FieldType.Text, analyzer = "ik_max_word")
private String title; // 文章标题
@Field(type = FieldType.Text, analyzer = "ik_max_word")
private String content; // 文章正文内容
// 指定字段对应ES中的类型是Date,使用自定义的日期格式化,pattern指定格式化
// 规则是“日期时间”或“日期”或“时间毫秒”
@Field(type = FieldType.Date, format = DateFormat.custom,
pattern = "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_millis")
private Date createTime; // 文章创建时间
@Field(type = FieldType.Date, format = DateFormat.custom,
pattern = "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_millis")
private Date updateTime; // 文章更新时间
}
3)编写Mysql和ES的数据操作接口
由于使用了Spring-Data-JPA
,这项工作将十分简单,只需要自建接口继承自对应的JpaRepository
和ElasticsearchRepository
接口即可。
创建Mysql数据访问接口
public interface MysqlArticleRepository
extends JpaRepository<MysqlArticle, Integer> {
}
创建ES数据访问接口
public interface ElasticArticleRepository
extends ElasticsearchRepository<ElasticArticle, Integer> {
}
4)测试从ES中检索数据
为了保证ES已成功集成到项目中,我们需要测试一下能否从ES中获取到数据。在工程的测试目录下编写一个ES测试类获取并遍历ES中所有文章的标题:
@SpringBootTest
public class ElasticTest {
@Autowired
private ElasticArticleRepository elasticArticleRepository;
@Test
void testElasticSearch() {
elasticArticleRepository.findAll().iterator()
.forEachRemaining(elasticArticle -> System.out.println(elasticArticle.getTitle()));
}
}
此时直接启动测试会报错NoNodeAvailableException[None of the configured nodes are available
,我们之前启动ES只需要一条简单的启动命令,就能使用HTTP的形式在浏览器中成功访问到ES。但是在项目中用Java代码的形式使用的是TCP的形式,我们需要对ES的配置文件做一些简单的修改:进入到ElasticSearch的目录下的config,找到名为elasticsearch.yml
的配置文件,将其中cluster.name
和network.host
的注释取消(删除"#"),再将network.host
一项的值改为127.0.0.1
。将ES进程停止后再次启动ES,然后到logstash的bin目录下执行命令./logstash -f ../config/mysql.conf
启动logstash将数据同步到ES中,最后执行测试用例:
5)编写后端控制器
在确保能够正常连接ES后,我们直接编写最主要的后端控制器SearchController
(因为业务简单我们省略Service层的编写):
@RestController
@RequestMapping("/article")
public class SearchController {
private final MysqlArticleRepository mysqlRepository;
private final ElasticArticleRepository elasticRepository;
public SearchController(MysqlArticleRepository mysqlRepository,
ElasticArticleRepository elasticRepository) {
this.mysqlRepository = mysqlRepository;
this.elasticRepository = elasticRepository;
}
@GetMapping
public List<MysqlArticle> queryAllArticles() {
return mysqlRepository.findAll();
}
@PostMapping
// @IgnoreAdvice
public List<ElasticArticle> handleSearchRequest(
@RequestBody RequestParam requestParam) {
BoolQueryBuilder queryBuilder = QueryBuilders.boolQuery();
// 构造ES查询条件
String keyword = requestParam.getKeyword();
queryBuilder.should(QueryBuilders.matchPhraseQuery("title", keyword))
.should(QueryBuilders.matchPhraseQuery("content", keyword));
StopWatch watch = new StopWatch();
watch.start(); // 开始计时
Page<ElasticArticle> articles = (Page<ElasticArticle>)
elasticRepository.search(queryBuilder); // 检索数据
watch.stop(); // 结束计时
System.out.println(String.format("数据检索耗时:%s ms",
watch.getTotalTimeMillis()));
return articles.getContent();
}
@GetMapping("{id:d+}")
public Object getArticleDetails(@PathVariable("id") Integer id) {
return elasticRepository.findById(id).orElseGet(ElasticArticle::new);
}
}
同时为了让项目启动时会访问首页index.html
,它是我们的主要页面,负责展示文章数据和提供文章检索的入口。我们需要一个控制器IndexController
:
@Controller
public class IndexController {
private final MysqlArticleRepository mysqlRepository;
public IndexController(MysqlArticleRepository mysqlRepository) {
this.mysqlRepository = mysqlRepository;
}
@GetMapping("/")
public String toIndexPage() {
mysqlRepository.findAll();
return "index.html";
}
}
6)统一响应的处理
一般我们的后端响应都应该有一个统一的格式,所以这里我再做一下统一响应的处理。考虑到可能不是所有的后端返回都需要统一格式,我们提供一个注解@IgnoreAdvice
来决定是否需要返回统一格式,它可以标注在类和方法上:
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface IgnoreAdvice {
}
既然需要统一响应,我们就要有对应格式的Java类来包装返回结果:
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ResponseData<T> implements Serializable {
private Integer code; // 请求响应码
private String message; // 请求响应消息
private T result; // 响应数据
public ResponseData(T result) {
this.code = 0;
this.message = "请求处理成功";
this.result = result;
}
public ResponseData(Integer code, String message) {
this.code = code;
this.message = message;
}
}
在Spring MVC框架中提供了对返回结果进行(增强)处理的支持,我们只需要实现ResponseBodyAdvice
接口即可对其他@RestController
控制器方法的返回结果进行拦截修改:
@RestControllerAdvice
public class ResponseAdvice implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(
MethodParameter methodParameter,
Class<? extends HttpMessageConverter<?>> aClass) {
// 方法类上存在IgnoreAdvice注解,不需要增强处理
if (methodParameter.getDeclaringClass().isAnnotationPresent(
IgnoreAdvice.class)) {
return false;
}
// 如果方法上存在IgnoreAdvice注解,则不需要增强处理
return !Objects.requireNonNull(methodParameter.getMethod())
.isAnnotationPresent(IgnoreAdvice.class
);
}
@Override
public Object beforeBodyWrite(
Object o, MethodParameter methodParameter,
MediaType mediaType,
Class<? extends HttpMessageConverter<?>> aClass,
ServerHttpRequest serverHttpRequest,
ServerHttpResponse serverHttpResponse) {
if (o == null) {
return new ResponseData<>(0, "请求结果为空");
} else if (o instanceof ResponseData) {
return o;
} else { return new ResponseData<>(o); } // 包装结果
}
}
7)编写前端页面
在编写页面之前我们先通过浏览器看看后端响应是否跟我们想的一样。启动项目访问id为1的文章(注意这里其实是从ElasticSearch中读取数据的),可以看到数据正常返回,通用响应也工作了:
上面我们并没有验证ElasticSearch的搜索接口的功能,因为这需要使用到post请求,我们这里也不再使用Postman验证了,而是编写一个前端页面来测试;在编写之前需要介绍一下几个必要的类库和Bootstrap前端框架:
以上的资源都可以百度“静态资源库”查找并引用,如果想要下载到本地,可以打开对应链接将内容复制到新建文件中;下面先编写一个名为index.html的页面展示文章列表和提供查询入口:
<!DOCTYPE html>
<html lang="en" xmlns:v-on="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>Spring Boot + ElasticSearch博客检索系统</title>
<link rel="stylesheet" href="styles/bootstrap.min.css">
<link rel="stylesheet" href="styles/bootstrap-grid.min.css">
</head>
<body>
<div class="container">
<div class="row" style="margin-top: 20px;">
<div class="col-12"><h2>Spring Boot博客检索系统</h2></div>
</div>
<div class="row" style="margin-top: 20px;" id="article-view">
<div class="col-12">
<form class="form-inline">
<div class="form-group mb-2">
<label for="search"></label>
<input type="text" class="form-control" id="search" placeholder="请输入要检索的关键字" v-model="keyword">
</div>
<button type="button" class="btn btn-primary mb-2" style="" v-on:click="elasticSearch">开始检索</button>
</form>
<div class="row">
<div class="col-sm-6" v-for="(article, index) in articles" style="margin-top: 20px;">
<div class="card">
<div class="card-body">
<h5 class="card-title">{{article.title}}</h5>
<p>{{article.author}}发布于{{article.createTime}}</p>
<a :href="'details.html?id=' + article.id">文章详情</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="scripts/axios.min.js"></script>
<script src="scripts/vue.min.js"></script>
<script type="text/javascript">
const app = new Vue({
el: '#article-view',
data: {
articles: [],
keyword: ''
},
methods: {
getArticles: function () {
let vue = this;
axios.get("http://localhost:8080/article").then(function (response) {
vue.articles = response.data.result;
})
},
elasticSearch: function () {
let vue = this;
let param = {"keyword": vue.keyword};
axios.post("http://localhost:8080/article", param).then(function (response) {
vue.articles = response.data.result;
})
}
},
created: function () { this.getArticles(); }
});
</script>
</body>
</html>
启动项目访问localhost:8080
,自动跳转到首页:
接下来在搜索框中输入“Kibana”然后点击搜索:
可以看到,搜索到的文章标题中并不包含关键字“kibana”,因为我们设置的检索条件是检索文章标题和内容,只要标题或内容包含了对应的关键字就表示匹配到了。要看我们的文章内容究竟有没有“kibana”关键字,我们需要再编写一个文章详情页details.html,同样使用vue.js完成数据的获取和页面渲染:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>文章详情</title>
<link rel="stylesheet" href="styles/bootstrap.min.css">
<link rel="stylesheet" href="styles/bootstrap-grid.min.css">
</head>
<body>
<div class="container">
<div class="row">
<div class="row" id="article-view">
<div class="col-12">
<h1 id="title">{{title}}</h1>
<span>作者:{{author}} | 发布时间:{{createTime}}</span>
<div class="col-9" v-html="content"></div>
</div>
</div>
</div>
</div>
<script src="scripts/axios.min.js"></script>
<script src="scripts/marked.min.js"></script>
<script src="scripts/vue.min.js"></script>
<script type="text/javascript">
function getArticleId(id) {
let query = window.location.search.substring(1);
let vars = query.split("&");
for (let i = 0; i < vars.length; i++) {
let pair = vars[i].split("=");
if (pair[0] === id) return pair[1];
}
return false;
}
const app = new Vue({
el: '#article-view',
data: {
title: '',
author: '',
createTime: '',
content: ''
},
methods: {
getArticle: function () {
let vue = this;
let id = getArticleId("id");
axios.get('http://localhost:8080/article/' + id).then(function (response) {
let article = response.data.result;
vue.title = article.title;
vue.author = article.author;
vue.createTime = article.createTime;
vue.content = marked(article.content);
})
}
},
created: function () { this.getArticle(); }
})
</script>
</body>
</html>
点击“查看文章详情”进入详情页,可以发现文章内容确实存在检索的关键字:
至此一个简单的博客文章检索系统就完成啦~~~