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对象实现。