springboot集成ES实现磁盘文件全文检索的示例代码
最近有个朋友咨询如何实现对海量磁盘资料进行目录、文件名及文件正文进行搜索,要求实现简单高效、维护方便、成本低廉。我想了想利用ES来实现文档的索引及搜索是适当的选择,于是就着手写了一些代码来实现,下面就将设计思路及实现方法作以介绍。
整体架构
考虑到磁盘文件分布到不同的设备上,所以采用磁盘扫瞄代理的模式构建系统,即把扫描服务以代理的方式部署到目标磁盘所在的服务器上,作为定时任务执行,索引统一建立到ES中,当然ES采用分布式高可用部署方法,搜索服务和扫描代理部署到一起来简化架构并实现分布式能力。
部署ES
ES(elasticsearch)是本项目唯一依赖的第三方软件,ES支持docker方式部署,以下是部署过程
dockerpulldocker.elastic.co/elasticsearch/elasticsearch:6.3.2 dockerrun-eES_JAVA_OPTS="-Xms256m-Xmx256m"-d-p9200:9200-p9300:9300--namees01docker.elastic.co/elasticsearch/elasticsearch:6.3.2
部署完成后,通过浏览器打开http://localhost:9200,如果正常打开,出现如下界面,则说明ES部署成功。
工程结构
依赖包
本项目除了引入springboot的基础starter外,还需要引入ES相关包
org.springframework.boot spring-boot-starter-data-elasticsearch io.searchbox jest 5.3.3 net.sf.jmimemagic jmimemagic 0.1.4
配置文件
需要将ES的访问地址配置到application.yml里边,同时为了简化程序,需要将待扫描磁盘的根目录(index-root)配置进去,后面的扫描任务就会递归遍历该目录下的全部可索引文件。
server: port:@elasticsearch.port@ spring: application: name:@project.artifactId@ profiles: active:dev elasticsearch: jest: uris:http://127.0.0.1:9200 index-root:/Users/crazyicelee/mywokerspace
索引结构数据定义
因为要求文件所在目录、文件名、文件正文都有能够检索,所以要将这些内容都作为索引字段定义,而且添加ESclient要求的JestId来注解id。
packagecom.crazyice.lee.accumulation.search.data;
importio.searchbox.annotations.JestId;
importlombok.Data;
@Data
publicclassArticle{
@JestId
privateIntegerid;
privateStringauthor;
privateStringtitle;
privateStringpath;
privateStringcontent;
privateStringfileFingerprint;
}
扫描磁盘并创建索引
因为要扫描指定目录下的全部文件,所以采用递归的方法遍历该目录,并标识已经处理的文件以提升效率,在文件类型识别方面采用两种方式可供选择,一个是文件内容更为精准判断(Magic),一种是以文件扩展名粗略判断。这部分是整个系统的核心组件。
这里有个小技巧
对目标文件内容计算MD5值并作为文件指纹存储到ES的索引字段里边,每次在重建索引的时候判断该MD5是否存在,如果存在就不用重复建立索引了,可以避免文件索引重复,也能避免系统重启后重复遍历文件。
packagecom.crazyice.lee.accumulation.search.service;
importcom.alibaba.fastjson.JSONObject;
importcom.crazyice.lee.accumulation.search.data.Article;
importcom.crazyice.lee.accumulation.search.utils.Md5CaculateUtil;
importio.searchbox.client.JestClient;
importio.searchbox.core.Index;
importio.searchbox.core.Search;
importio.searchbox.core.SearchResult;
importlombok.extern.slf4j.Slf4j;
importnet.sf.jmimemagic.*;
importorg.apache.poi.hwpf.extractor.WordExtractor;
importorg.apache.poi.xwpf.extractor.XWPFWordExtractor;
importorg.apache.poi.xwpf.usermodel.XWPFDocument;
importorg.elasticsearch.index.query.QueryBuilders;
importorg.elasticsearch.search.builder.SearchSourceBuilder;
importorg.springframework.beans.factory.annotation.Autowired;
importorg.springframework.stereotype.Component;
importjava.io.File;
importjava.io.FileInputStream;
importjava.io.FileNotFoundException;
importjava.io.IOException;
@Component
@Slf4j
publicclassDirectoryRecurse{
@Autowired
privateJestClientjestClient;
//读取文件内容转换为字符串
privateStringreadToString(Filefile,StringfileType){
StringBufferresult=newStringBuffer();
switch(fileType){
case"text/plain":
case"java":
case"c":
case"cpp":
case"txt":
try(FileInputStreamin=newFileInputStream(file)){
Longfilelength=file.length();
byte[]filecontent=newbyte[filelength.intValue()];
in.read(filecontent);
result.append(newString(filecontent,"utf8"));
}catch(FileNotFoundExceptione){
log.error("{}",e.getLocalizedMessage());
}catch(IOExceptione){
log.error("{}",e.getLocalizedMessage());
}
break;
case"doc":
//使用HWPF组件中WordExtractor类从Word文档中提取文本或段落
try(FileInputStreamin=newFileInputStream(file)){
WordExtractorextractor=newWordExtractor(in);
result.append(extractor.getText());
}catch(Exceptione){
log.error("{}",e.getLocalizedMessage());
}
break;
case"docx":
try(FileInputStreamin=newFileInputStream(file);XWPFDocumentdoc=newXWPFDocument(in)){
XWPFWordExtractorextractor=newXWPFWordExtractor(doc);
result.append(extractor.getText());
}catch(Exceptione){
log.error("{}",e.getLocalizedMessage());
}
break;
}
returnresult.toString();
}
//判断是否已经索引
privateJSONObjectisIndex(Filefile){
JSONObjectresult=newJSONObject();
//用MD5生成文件指纹,搜索该指纹是否已经索引
StringfileFingerprint=Md5CaculateUtil.getMD5(file);
result.put("fileFingerprint",fileFingerprint);
SearchSourceBuildersearchSourceBuilder=newSearchSourceBuilder();
searchSourceBuilder.query(QueryBuilders.termQuery("fileFingerprint",fileFingerprint));
Searchsearch=newSearch.Builder(searchSourceBuilder.toString()).addIndex("diskfile").addType("files").build();
try{
//执行
SearchResultsearchResult=jestClient.execute(search);
if(searchResult.getTotal()>0){
result.put("isIndex",true);
}else{
result.put("isIndex",false);
}
}catch(IOExceptione){
log.error("{}",e.getLocalizedMessage());
}
returnresult;
}
//对文件目录及内容创建索引
privatevoidcreateIndex(Filefile,Stringmethod){
//忽略掉临时文件,以~$起始的文件名
if(file.getName().startsWith("~$"))return;
StringfileType=null;
switch(method){
case"magic":
Magicparser=newMagic();
try{
MagicMatchmatch=parser.getMagicMatch(file,false);
fileType=match.getMimeType();
}catch(MagicParseExceptione){
//log.error("{}",e.getLocalizedMessage());
}catch(MagicMatchNotFoundExceptione){
//log.error("{}",e.getLocalizedMessage());
}catch(MagicExceptione){
//log.error("{}",e.getLocalizedMessage());
}
break;
case"ext":
Stringfilename=file.getName();
String[]strArray=filename.split("\\.");
intsuffixIndex=strArray.length-1;
fileType=strArray[suffixIndex];
}
switch(fileType){
case"text/plain":
case"java":
case"c":
case"cpp":
case"txt":
case"doc":
case"docx":
JSONObjectisIndexResult=isIndex(file);
log.info("文件名:{},文件类型:{},MD5:{},建立索引:{}",file.getPath(),fileType,isIndexResult.getString("fileFingerprint"),isIndexResult.getBoolean("isIndex"));
if(isIndexResult.getBoolean("isIndex"))break;
//1.给ES中索引(保存)一个文档
Articlearticle=newArticle();
article.setTitle(file.getName());
article.setAuthor(file.getParent());
article.setPath(file.getPath());
article.setContent(readToString(file,fileType));
article.setFileFingerprint(isIndexResult.getString("fileFingerprint"));
//2.构建一个索引
Indexindex=newIndex.Builder(article).index("diskfile").type("files").build();
try{
//3.执行
if(!jestClient.execute(index).getId().isEmpty()){
log.info("构建索引成功!");
}
}catch(IOExceptione){
log.error("{}",e.getLocalizedMessage());
}
break;
}
}
publicvoidfind(StringpathName)throwsIOException{
//获取pathName的File对象
FiledirFile=newFile(pathName);
//判断该文件或目录是否存在,不存在时在控制台输出提醒
if(!dirFile.exists()){
log.info("donotexit");
return;
}
//判断如果不是一个目录,就判断是不是一个文件,时文件则输出文件路径
if(!dirFile.isDirectory()){
if(dirFile.isFile()){
createIndex(dirFile,"ext");
}
return;
}
//获取此目录下的所有文件名与目录名
String[]fileList=dirFile.list();
for(inti=0;i
扫描任务
这里采用定时任务的方式来扫描指定目录以实现动态增量创建索引。
packagecom.crazyice.lee.accumulation.search.service;
importlombok.extern.slf4j.Slf4j;
importorg.springframework.beans.factory.annotation.Autowired;
importorg.springframework.beans.factory.annotation.Value;
importorg.springframework.context.annotation.Configuration;
importorg.springframework.scheduling.annotation.Scheduled;
importorg.springframework.stereotype.Component;
importjava.io.IOException;
@Configuration
@Component
@Slf4j
publicclassCreateIndexTask{
@Autowired
privateDirectoryRecursedirectoryRecurse;
@Value("${index-root}")
privateStringindexRoot;
@Scheduled(cron="*0/5***?")
privatevoidaddIndex(){
try{
directoryRecurse.find(indexRoot);
directoryRecurse.writeIndexStatus();
}catch(IOExceptione){
log.error("{}",e.getLocalizedMessage());
}
}
}
搜索服务
这里以restFul的方式提供搜索服务,将关键字以高亮度模式提供给前端UI,浏览器端可以根据返回的JSON进行展示。
packagecom.crazyice.lee.accumulation.search.web;
importcom.alibaba.fastjson.JSONObject;
importcom.crazyice.lee.accumulation.search.data.Article;
importio.searchbox.client.JestClient;
importio.searchbox.core.Search;
importio.searchbox.core.SearchResult;
importio.swagger.annotations.ApiImplicitParam;
importio.swagger.annotations.ApiImplicitParams;
importio.swagger.annotations.ApiOperation;
importlombok.extern.slf4j.Slf4j;
importorg.elasticsearch.index.query.BoolQueryBuilder;
importorg.elasticsearch.index.query.QueryBuilders;
importorg.elasticsearch.search.builder.SearchSourceBuilder;
importorg.elasticsearch.search.fetch.subphase.highlight.HighlightBuilder;
importorg.springframework.beans.factory.annotation.Autowired;
importorg.springframework.lang.NonNull;
importorg.springframework.web.bind.annotation.PathVariable;
importorg.springframework.web.bind.annotation.RequestMapping;
importorg.springframework.web.bind.annotation.RequestMethod;
importorg.springframework.web.bind.annotation.RestController;
importjava.io.IOException;
importjava.util.HashMap;
importjava.util.List;
importjava.util.Map;
@RestController
@Slf4j
publicclassController{
@Autowired
privateJestClientjestClient;
@RequestMapping(value="/search/{keyword}",method=RequestMethod.GET)
@ApiOperation(value="全部字段搜索关键字",notes="es验证")
@ApiImplicitParams(
@ApiImplicitParam(name="keyword",value="全文检索关键字",required=true,paramType="path",dataType="String")
)
publicListsearch(@PathVariableStringkeyword){
SearchSourceBuildersearchSourceBuilder=newSearchSourceBuilder();
searchSourceBuilder.query(QueryBuilders.queryStringQuery(keyword));
HighlightBuilderhighlightBuilder=newHighlightBuilder();
//path属性高亮度
HighlightBuilder.FieldhighlightPath=newHighlightBuilder.Field("path");
highlightPath.highlighterType("unified");
highlightBuilder.field(highlightPath);
//title字段高亮度
HighlightBuilder.FieldhighlightTitle=newHighlightBuilder.Field("title");
highlightTitle.highlighterType("unified");
highlightBuilder.field(highlightTitle);
//content字段高亮度
HighlightBuilder.FieldhighlightContent=newHighlightBuilder.Field("content");
highlightContent.highlighterType("unified");
highlightBuilder.field(highlightContent);
//高亮度配置生效
searchSourceBuilder.highlighter(highlightBuilder);
log.info("搜索条件{}",searchSourceBuilder.toString());
//构建搜索功能
Searchsearch=newSearch.Builder(searchSourceBuilder.toString()).addIndex("gf").addType("news").build();
try{
//执行
SearchResultresult=jestClient.execute(search);
returnresult.getHits(Article.class);
}catch(IOExceptione){
log.error("{}",e.getLocalizedMessage());
}
returnnull;
}
}
搜索restFul结果测试
这里以swagger的方式进行API测试。其中keyword是全文检索中要搜索的关键字。
搜索结果
使用thymeleaf生成UI
集成thymeleaf的模板引擎直接将搜索结果以web方式呈现。模板包括主搜索页和搜索结果页,通过@Controller注解及Model对象实现。