前言:
最近毕设开会无意间听到小陈同学使用lucene整一个全文索引,出于好奇了解了一下发现其是结合相关分词器可以对一大段文字建立索引,然后可以实现搜索功能,本来博客一直差着一个搜索博客功能(不想通过数据库模糊查询来做),发现lucene之后感觉打开新世界大门。
简单介绍lucene
Lucene是apache软件基金会4 jakarta项目组的一个子项目,是一个开放源代码的全文检索引擎工具包,但它不是一个完整的全文检索引擎,而是一个全文检索引擎的架构,提供了完整的查询引擎和索引引擎,部分文本分析引擎(英文与德文两种西方语言)。Lucene的目的是为软件开发人员提供一个简单易用的工具包,以方便的在目标系统中实现全文检索的功能,或者是以此为基础建立起完整的全文检索引擎。Lucene是一套用于全文检索和搜寻的开源程式库,由Apache软件基金会支持和提供。Lucene提供了一个简单却强大的应用程式接口,能够做全文索引和搜寻。在Java开发环境里Lucene是一个成熟的免费开源工具。就其本身而言,Lucene是当前以及最近几年最受欢迎的免费Java信息检索程序库。人们经常提到信息检索程序库,虽然与搜索引擎有关,但不应该将信息检索程序库与搜索引擎相混淆。
全文检索的流程分为两大部分:建立索引流程、搜索流程。
索引流程:采集数据—>构建文档对象—>创建索引并存储
搜索流程:创建查询—>执行搜索—>渲染搜索结果。
总流程:
- 获取原始文档
原始文档是指要索引和搜索的内容。本文的搜索内容就是我从数据库中拿出来的博文内容啦 - 创建文档对象(Document)
1.文档中包含多个的域(Field), 域中存储内容。例如我们可以将磁盘上的一个文件当成一个Document,Document包含多个FIeld。Field可以看为一张表中的一个字段,document可以看作一张表
2.每个文档都有一个唯一的编号,就是文档id。 - 分析文档
这部份交给开源的分词器去做咯,本项目使用的是IKAnalyzer分词器 - 创建索引
创建索引的目的是为了搜索,通过只搜索索引从而找到文档。 - 查询索引
从索引库中进行搜索
思路
那么抛开一切,如何实现一个博客搜索功能呢?主要分以下几步
- 创建索引搜索、创建的工具类
- 创建定时任务–每天11点查询数据库并且将博文更新索引
配置maven
由于使用的是IKAnalyzer分词器,其最后的版本更新停留在了好几年前,而lucene则已经更新到了8,IKAnalyzer分词器是依赖于lucene来写的,看源码发现lucene的最新版本内部类的结构已经发生了变化,特别是Analyzer这个抽象类中的一个抽象方法已经变了,而几年前的IKAnalyzer还依赖于Analyzer实现,所以会导致引入会报错,本来想自己解决的=。= 最后绕了好久 只发现了原因没解决,后来看到有大佬解决了,并发布到maven上了 这里就直接引用了,感恩大佬,其他的都是一些lucene的的包,下面贴出来了
<!-- https://mvnrepository.com/artifact/com.jianggujin/IKAnalyzer-lucene -->
<dependency>
<groupId>com.jianggujin</groupId>
<artifactId>IKAnalyzer-lucene</artifactId>
<version>8.0.0</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.lucene/lucene-analyzers-common -->
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-analyzers-common</artifactId>
<version>8.5.0</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.lucene/lucene-queryparser -->
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-queryparser</artifactId>
<version>8.5.0</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.lucene/lucene-core -->
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-core</artifactId>
<version>8.5.0</version>
</dependency>
<!-- https://mvnrepository.com/artifact/commons-io/commons-io -->
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.6</version>
</dependency>
创建索引搜索、创建的工具类
@Component
public class IndexManagerUtil {
private ReadWriteLock lock = new ReentrantReadWriteLock();
private final Lock readLock = lock.readLock();
private final Lock writeLock = lock.writeLock();
/**
* 为指定目录下的文件创建索引,包括其下的所有子孙目录下的文件
*
* @param :创建好的索引保存目录
* @throws IOException
*/
public void indexCreate(List<Article> articles) throws IOException {
// /** 如果传入的路径不是目录或者目录不存在,则放弃*/
// if (!targetFileDir.isDirectory() || !targetFileDir.exists()) {
// return;
// }
/** 创建 Lucene 文档列表,用于保存多个 Docuemnt*/
List<Document> docList = new ArrayList<Document>();
/**Lucene 文档对象(Document),文件系统中的一个文件就是一个 Docuemnt对象
* 一个 Lucene Docuemnt 对象可以存放多个 Field(域)
* Lucene Docuemnt 相当于 Mysql 数据库表的一行记录
* Docuemnt 中 Field 相当于 Mysql 数据库表的字段*/
for (Article article:articles) {
Document luceneDocument = new Document();
/**
* TextField 继承于 org.apache.lucene.document.Field
* TextField(String name, String value, Store store)--文本域
* name:域名,相当于 Mysql 数据库表的字段名
* value:域值,相当于 Mysql 数据库表的字段值
* store:是否存储,yes 表存储,no 为不存储
*
* TextField:表示文本域、默认会分词、会创建索引、第三个参数 Store.YES 表示会存储
* 同理还有 StoredField、StringField、FeatureField、BinaryDocValuesField 等等
* 都来自于超级接口:org.apache.lucene.index.IndexableField
*/
String context=markdownToText(article.getContent());
TextField titleFiled = new TextField("title", article.getTitle(), Field.Store.NO);
TextField decriptionFiled = new TextField("decription", article.getDecription(), Field.Store.NO);
TextField contextFiled = new TextField("context", context, Field.Store.NO);
TextField articleIdFiled = new TextField("articleId", article.getId().toString(), Field.Store.YES);
/**如果是 Srore.NO,则不会存储,就意味着后期获取 fileSize 值的时候,值会为null
* 虽然 Srore.NO 不会存在域的值,但是 TextField本身会分词、会创建索引
* 所以后期仍然可以根据 fileSize 域进行检索:queryParser.parse("fileContext:" + queryWord);
* 只是获取 fileSize 存储的值为 null:document.get("fileSize"));
* 索引是索引,存储的 fileSize 内容是另一回事
* */
// TextField sizeFiled = new TextField("fileSize", fileSize.toString(), Field.Store.YES);
/**将所有的域都存入 Lucene 文档中*/
luceneDocument.add(titleFiled);
luceneDocument.add(contextFiled);
luceneDocument.add(decriptionFiled);
luceneDocument.add(articleIdFiled);
/**将文档存入文档集合中,之后再同统一进行存储*/
docList.add(luceneDocument);
}
/** 创建分词器
* StandardAnalyzer:标准分词器,对英文分词效果很好,对中文是单字分词,即一个汉字作为一个词,所以对中文支持不足
* 市面上有很多好用的中文分词器,如 IKAnalyzer 就是其中一个
*/
Analyzer analyzer = new IKAnalyzer(true);
writeLock.lock();
File indexSaveDir = new File("luceneIndex");
/** 指定之后 创建好的 索引和 Lucene 文档存储的目录
* 如果目录不存在,则会自动创建*/
Path path = Paths.get(indexSaveDir.toURI());
/** FSDirectory:表示文件系统目录,即会存储在计算机本地磁盘,继承于
* org.apache.lucene.store.BaseDirectory
* 同理还有:org.apache.lucene.store.RAMDirectory:存储在内存中
*/
Directory directory = FSDirectory.open(path);
/** 创建 索引写配置对象,传入分词器*/
IndexWriterConfig config = new IndexWriterConfig(analyzer);
// config.setOpenMode(IndexWriterConfig.OpenMode.CREATE_OR_APPEND);
/**创建 索引写对象,用于正式写入索引和文档数据*/
IndexWriter indexWriter = new IndexWriter(directory, config);
indexWriter.deleteAll();
/**将 Lucene 文档加入到 写索引 对象中*/
for (int i = 0; i < docList.size(); i++) {
indexWriter.addDocument(docList.get(i));
/**如果目标文档数量较多,可以分批次刷新一下*/
if ((i + 1) % 50 == 0) {
indexWriter.flush();
}
}
/**最后再 刷新流,然后提交、关闭流*/
indexWriter.flush();
indexWriter.commit();
indexWriter.close();
writeLock.unlock();
}
public List<Integer> indexSearch(String queryWord) throws Exception {
readLock.lock();
File indexDir = new File("luceneIndex");
List<Integer> articleIds=new ArrayList<>();
if (indexDir == null || queryWord == null || "".equals(queryWord)) {
return articleIds;
}
/** 创建分词器
* 1)创建索引 与 查询索引 所用的分词器必须一致
*/
Analyzer analyzer = new IKAnalyzer(true);
// /**创建查询对象(QueryParser):QueryParser(String f, Analyzer a)
// * 第一个参数:默认搜索域,与创建索引时的域名称必须相同
// * 第二个参数:分词器
// * 默认搜索域作用:
// * 如果搜索语法parse(String query)中指定了域名,则从指定域中搜索
// * 如果搜索语法parse(String query)中只指定了查询关键字,则从默认搜索域中进行搜索
*/
// QueryParser queryParser = new QueryParser("content", analyzer);
// Query query = queryParser.parse("title:" + queryWord);
//** parse 表示解析查询语法,查询语法为:"域名:搜索的关键字"
// * parse("fileName:web"):则从fileName域中进行检索 web 字符串
// * 如果为 parse("web"):则从默认搜索域 fileContext 中进行检索
// * 1)查询不区分大小写
// * 2)因为使用的是 StandardAnalyzer(标准分词器),所以对英文效果很好,如果此时检索中文,基本是行不通的
// */
// Query query = queryParser.parse("fileContext:" + queryWord);
// MUST 表示and,MUST_NOT 表示not ,SHOULD表示or
BooleanClause.Occur[] clauses = {BooleanClause.Occur.SHOULD, BooleanClause.Occur.SHOULD, BooleanClause.Occur.SHOULD};
// MultiFieldQueryParser表示多个域解析, 同时可以解析含空格的字符串,如果我们搜索"上海 中国"
String[] fields = {"title", "content", "decription"};
Query multiFieldQuery = MultiFieldQueryParser.parse(queryWord, fields, clauses, analyzer);
/** 与创建 索引 和 Lucene 文档 时一样,指定 索引和文档 的目录
* 即指定查询的索引库
*/
Path path = Paths.get(indexDir.toURI());
Directory dir = FSDirectory.open(path);
/*** 创建 索引库读 对象
* DirectoryReader 继承于org.apache.lucene.index.IndexReader
* */
DirectoryReader directoryReader = DirectoryReader.open(dir);
/** 根据 索引对象创建 索引搜索对象
**/
IndexSearcher indexSearcher = new IndexSearcher(directoryReader);
// 5、根据searcher搜索并且返回TopDocs
TopDocs topdocs = indexSearcher.search(multiFieldQuery, 100); // 搜索前100条结果
System.out.println("查询结果总数:::=====" + topdocs.totalHits);
/**从搜索结果对象中获取结果集
* 如果没有查询到值,则 ScoreDoc[] 数组大小为 0
* */
ScoreDoc[] scoreDocs = topdocs.scoreDocs;
ScoreDoc loopScoreDoc = null;
for (int i = 0; i < scoreDocs.length; i++) {
System.out.println("=======================" + (i + 1) + "=====================================");
loopScoreDoc = scoreDocs[i];
/**获取 文档 id 值
* 这是 Lucene 存储时自动为每个文档分配的值,相当于 Mysql 的主键 id
* */
int docID = loopScoreDoc.doc;
/**通过文档ID从硬盘中读取出对应的文档*/
Document document = directoryReader.document(docID);
//
// /**get方法 获取对应域名的值
// * 如域名 key 值不存在,返回 null*/
// System.out.println("doc id:" + docID);
// System.out.println("fileName:" + document.get("fileName"));
// System.out.println("fileSize:" + document.get("fileSize"));
// /**防止内容太多影响阅读,只取前20个字*/
// System.out.println("fileContext:" + document.get("fileContext").substring(0, 50) + "......");
articleIds.add(Integer.parseInt(document.get("articleId")));
}
readLock.unlock();
return articleIds;
}`
这是lucene的基本使用的代码结构,都有注释不用多说了,主要要注意的地方就是注意了一下并发问题,主要是担心11点的时候定时任务会把索引全部删除,再进行查询数据库再新建,此时如果有人恰好使用会出现查不到东西的问题。
考虑到大多查询多于重写索引,这里使用ReadWriteLock锁进行解决,ReadWriteLock锁读写允许同一时刻被多个读线程访问,而在写线程访问时,所有的读线程和其他的写线程都会被阻塞。刚好符合我们的场景需求
private ReadWriteLock lock = new ReentrantReadWriteLock();
private final Lock readLock = lock.readLock();
private final Lock writeLock = lock.writeLock();
定时任务
定时任务有很多的创建方式,这里采用springboot的创建方式
- 入口程序处加上@EnableScheduling注解
@SpringBootApplication
@EnableScheduling //开启定时任务
public class CommunityApplication {
public static void main(String[] args) {
SpringApplication.run(CommunityApplication.class, args);
}
- 创建定时任务
@Component
public class MultithreadScheduleTask {
@Autowired
IndexManagerUtil indexManagerUtil;
@Autowired
ArticleMapper articleMapper;
private Logger logger = LoggerFactory.getLogger(MultithreadScheduleTask.class);
// cron接受cron表达式,根据cron表达式确定定时规则
@Scheduled(cron="0 0 23 * * ?") //每5秒执行一次
public void Cron() throws IOException {
//更新索引
indexManagerUtil.indexCreate(articleMapper.selectAllArticles());
}
}
- 配置项目启动后先建立索引
主要实现CommandLineRunner接口,然后通过@component注解将其注入进容器中即可在springboot项目启动火执行该代码。
这里额外生成了一个线程来执行。
@Component
public class AfterStartDo implements CommandLineRunner {
@Autowired
IndexManagerUtil indexManagerUtil;
@Autowired
ArticleMapper articleMapper;
@Override
public void run(String... args) throws Exception {
// System.out.println(">>>>>>>>>>>>>>>服务启动执行,操作数据库新建索引等操作<<<<<<<<<<<<<");
//开启新线程来新建索引
new Thread(new Runnable() {
@Override
public void run() {
List<Article> articles;
articles = articleMapper.selectAllArticles();
// 创建索引一般需要数秒种,为避免阻塞主线程影响业务,开启新线程执行
try {
indexManagerUtil.indexCreate(articles);
// System.out.println(">>>>>>>>>>>>>>>操作数据库新建索引完成<<<<<<<<<<<<<");
} catch (IOException e) {
e.printStackTrace();
}
}
}).start();
}
}
至此一个基于lucene的博客搜索功能就实现啦。