前言:
最近畢設開會無意間聽到小陳同學使用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的博客搜索功能就實現啦。