前言
一直以來個人博客的搜索功能很蹩腳,只是自己簡單用數據庫的like %keyword%
來實現的,所以導致經常搜不到想要找的內容,而且高亮顯示、摘要截取等也不好實現,所以決定采用Lucene改寫博客的搜索功能。先來看一下最終效果:
本文demo地址:https://github.com/liuxianan/lucene-demo (包括本文需要用到的jar包可以從這里面下載)
效果演示地址:http://blog.liuxianan.com/search?kw=端口 占用
Lucene 介紹
Lucene
是一個用Java開發的開源全文檢索引擎,官網是:http://lucene.apache.org/ ,Lucene
不是一個完整的全文索引應用(與之對應的是solr
),而是是一個用Java寫的全文索引引擎工具包,它可以方便的嵌入到各種應用中實現針對應用的全文索引/檢索功能,更多介紹大家自行搜索。
版本選擇
目前最新版是6.5.1
(截止到2017-05-04),本來想直接用最新版的,但是下載下來之后發現老是提示找不到某些類,可我直接找到對應的jar包下去看卻是有的,不過卻無法用jd-gui
反編譯,提示一個什么錯誤,盲目的我竟然以為是因為版本太新,apache在放出最新jar包時自己沒測試,后來試了幾個老一點的6.x版本發現都是這個錯誤,5.x就不會,好吧,這時才想起來應該是jdk版本不對,Lucene6.x
需要jdk1.8
以上,只能怪我太out了,畢竟確實好久沒怎么寫過Java代碼了。
由於本地、線上都是使用的jdk1.7,不好為了一個Lucene就升級到1.8,所以決定改用5.5.4
版本。
正式開始
下載
從網上下載的包一般比較大,有70多M(官網目前只能下載最新版的,5.x的估計要到其它地方下載),一般人只用下面這幾個就夠了:
也就是這幾個:
其中IKAnalyzer2012_FF.jar
是一個國人寫的中文分詞工具,Lucene自帶的分詞對中文支持不好。注意,這個jar包網上比較亂,隨便從網上下載的話可能不兼容,因為跟具體的Lucene版本有關,初學者建議直接用我demo里面整理好的jar包:https://github.com/liuxianan/lucene-demo/tree/master/WebContent/WEB-INF/lib
建立索引
特別注意,Lucene不同版本的API變化比較大,如果你用的是其它版本,注意代碼可能要變。
其實代碼比較簡單,我們先來一個搜索文件的例子(下面的FileUtil可以自己簡單實現)。
public static final String INDEX_PATH = "E:\\lucene"; // 存放Lucene索引文件的位置
public static final String SCAN_PATH = "E:\\text"; // 需要被掃描的位置,測試的時候記得多在這下面放一些文件
/**
* 創建索引
*/
public void creatIndex()
{
IndexWriter indexWriter = null;
try
{
Directory directory = FSDirectory.open(FileSystems.getDefault().getPath(INDEX_PATH));
//Analyzer analyzer = new StandardAnalyzer();
Analyzer analyzer = new IKAnalyzer(true);
IndexWriterConfig indexWriterConfig = new IndexWriterConfig(analyzer);
indexWriter = new IndexWriter(directory, indexWriterConfig);
indexWriter.deleteAll();// 清除以前的index
// 獲取被掃描目錄下的所有文件,包括子目錄
List<File> files = FileUtil.listAllFiles(SCAN_PATH);
for(int i=0; i<files.size(); i++)
{
Document document = new Document();
File file = files.get(i);
document.add(new Field("content", FileUtil.readFile(file.getAbsolutePath()), TextField.TYPE_STORED));
document.add(new Field("fileName", file.getName(), TextField.TYPE_STORED));
document.add(new Field("filePath", file.getAbsolutePath(), TextField.TYPE_STORED));
document.add(new Field("updateTime", file.lastModified()+"", TextField.TYPE_STORED));
indexWriter.addDocument(document);
}
}
catch (Exception e)
{
e.printStackTrace();
}
finally
{
try
{
if(indexWriter != null) indexWriter.close();
}
catch (Exception e)
{
e.printStackTrace();
}
}
}
執行完之后就在指定目錄新建了索引文件,以后的搜索就靠他們了:
簡單的搜索
代碼比較簡單,具體可以看注釋,這里就不詳述了。
/**
* 搜索
*/
public void search(String keyWord)
{
DirectoryReader directoryReader = null;
try
{
// 1、創建Directory
Directory directory = FSDirectory.open(FileSystems.getDefault().getPath(INDEX_PATH));
// 2、創建IndexReader
directoryReader = DirectoryReader.open(directory);
// 3、根據IndexReader創建IndexSearch
IndexSearcher indexSearcher = new IndexSearcher(directoryReader);
// 4、創建搜索的Query
// Analyzer analyzer = new StandardAnalyzer();
Analyzer analyzer = new IKAnalyzer(true); // 使用IK分詞
// 簡單的查詢,創建Query表示搜索域為content包含keyWord的文檔
//Query query = new QueryParser("content", analyzer).parse(keyWord);
String[] fields = {"fileName", "content"}; // 要搜索的字段,一般搜索時都不會只搜索一個字段
// 字段之間的與或非關系,MUST表示and,MUST_NOT表示not,SHOULD表示or,有幾個fields就必須有幾個clauses
BooleanClause.Occur[] clauses = {BooleanClause.Occur.SHOULD, BooleanClause.Occur.SHOULD};
// MultiFieldQueryParser表示多個域解析, 同時可以解析含空格的字符串,如果我們搜索"上海 中國"
Query multiFieldQuery = MultiFieldQueryParser.parse(keyWord, fields, clauses, analyzer);
// 5、根據searcher搜索並且返回TopDocs
TopDocs topDocs = indexSearcher.search(multiFieldQuery, 100); // 搜索前100條結果
System.out.println("共找到匹配處:" + topDocs.totalHits); // totalHits和scoreDocs.length的區別還沒搞明白
// 6、根據TopDocs獲取ScoreDoc對象
ScoreDoc[] scoreDocs = topDocs.scoreDocs;
System.out.println("共找到匹配文檔數:" + scoreDocs.length);
QueryScorer scorer = new QueryScorer(multiFieldQuery, "content");
// 自定義高亮代碼
SimpleHTMLFormatter htmlFormatter = new SimpleHTMLFormatter("<span style=\"backgroud:red\">", "</span>");
Highlighter highlighter = new Highlighter(htmlFormatter, scorer);
highlighter.setTextFragmenter(new SimpleSpanFragmenter(scorer));
for (ScoreDoc scoreDoc : scoreDocs)
{
// 7、根據searcher和ScoreDoc對象獲取具體的Document對象
Document document = indexSearcher.doc(scoreDoc.doc);
//TokenStream tokenStream = new SimpleAnalyzer().tokenStream("content", new StringReader(content));
//TokenSources.getTokenStream("content", tvFields, content, analyzer, 100);
//TokenStream tokenStream = TokenSources.getAnyTokenStream(indexSearcher.getIndexReader(), scoreDoc.doc, "content", document, analyzer);
//System.out.println(highlighter.getBestFragment(tokenStream, content));
System.out.println("-----------------------------------------");
System.out.println(document.get("fileName") + ":" + document.get("filePath"));
System.out.println(highlighter.getBestFragment(analyzer, "content", document.get("content")));
System.out.println("");
}
}
catch (Exception e)
{
e.printStackTrace();
}
finally
{
try
{
if(directoryReader != null) directoryReader.close();
}
catch (Exception e)
{
e.printStackTrace();
}
}
}
測試:
public static void main(String args[])
{
FileSearchDemo demo = new FileSearchDemo();
demo.creatIndex();
demo.search("讀取 導出");
}
稍微復雜一點的搜索
很多時候搜索時可能需要多個條件配合,就像我們的SQL查詢一樣,不然無法滿足我們的業務。Lucene
可以將多個query
通過BooleanQuery
進行與或非處理得到最終的query
。其實再復雜一點的我也沒試過,下面只是一個簡單的示例:
String[] fields = {"fileName", "content"}; // 要搜索的字段,一般搜索時都不會只搜索一個字段
// 字段之間的與或非關系,MUST表示and,MUST_NOT表示not,SHOULD表示or,有幾個fields就必須有幾個clauses
BooleanClause.Occur[] clauses = {BooleanClause.Occur.SHOULD, BooleanClause.Occur.SHOULD};
// MultiFieldQueryParser表示多個域解析, 同時可以解析含空格的字符串,如果我們搜索"上海 中國"
Query multiFieldQuery = MultiFieldQueryParser.parse(keyWord, fields, clauses, analyzer);
Query termQuery = new TermQuery(new Term("content", keyWord));// 詞語搜索,完全匹配,搜索具體的域
Query wildqQuery = new WildcardQuery(new Term("content", keyWord));// 通配符查詢
Query prefixQuery = new PrefixQuery(new Term("content", keyWord));// 字段前綴搜索
Query fuzzyQuery = new FuzzyQuery(new Term("content", keyWord));// 相似度查詢,模糊查詢比如OpenOffica,OpenOffice
BooleanQuery.Builder queryBuilder = new BooleanQuery.Builder();
queryBuilder.add(multiFieldQuery, BooleanClause.Occur.SHOULD);
queryBuilder.add(termQuery, BooleanClause.Occur.SHOULD);
queryBuilder.add(wildqQuery, BooleanClause.Occur.SHOULD);
queryBuilder.add(prefixQuery, BooleanClause.Occur.SHOULD);
queryBuilder.add(fuzzyQuery, BooleanClause.Occur.SHOULD);
BooleanQuery query = queryBuilder.build(); // 這才是最終的query
TopDocs topDocs = indexSearcher.search(query, 100); // 搜索前100條結果
復雜的搜索還有可能涉及多個索引目錄的搜索,不同結果的權重分配、排序,近義詞搜索,等等,這里就不多說了,本文只是入門而已。
數據庫搜索
其實和文件搜索差不多,只不過建立索引時是從數據庫讀取內容,我也寫了一個簡單的數據庫搜索示例,可以從前面提到的demo找到(https://github.com/liuxianan/lucene-demo/blob/master/src/com/test/DbSearchDemo.java ),這里不細述。
運行效果如下:
共找到匹配處:1
共找到匹配文檔數:1
-----------------------------------------
文章標題:Android原生與JS交互總結
文章地址:http://blog.liuxianan.com/android-native-js-interactive.html
文章內容:
test.testBoolean(false); // 輸出"boolean:null"
可以發現,如果<span style="backgroud:red">Android</span>這邊參數使用了包裝類型會導致參數接收不到,必須使用基本類型,把上面的
基於Lucene實現博客搜索功能
前面都只是例子,下面要試着把它用於正式的項目中。
創建索引的時機
首先寫一個LuceneService
類,這里面只有2個方法,一個是創建索引,一個是搜索,那么在什么時候創建索引呢?
我在SpringMVC的監聽器里面加入了段代碼,在系統啟動時主動創建一次索引,另外每24小時再自動更新一次,防止萬一。為保證實時更新,添加文章、修改文章、刪除文章之后也都立即更新一次索引。
/**
* 更新Lucene索引
* @param event
*/
public void updateLuceneIndex(final ServletContextEvent event)
{
luceneTimer = new Timer("Lucene索引定時構建任務", true);
log.debug("啟動Lucene索引構建定時任務!");
ApplicationContext context = WebApplicationContextUtils.getWebApplicationContext(event.getServletContext());
final LuceneService luceneService = context.getBean(LuceneService.class);
// 系統啟動1分鍾之后主動建立一次Lucene索引
luceneTimer.schedule(new TimerTask()
{
@Override
public void run()
{
luceneService.updateIndex(event.getServletContext());
}
}, 1000 * 60, 1000 * 60 * 60 * 24);
}
必須要開新線程執行
經過測試對於博文內容不是很多的情況下,一般建立索引都在數秒之內,雖然比較快,但還是要避免阻塞主線程,這里我偷懶簡單的用new Thread
來實現:
/**
* 創建索引,發布文章、修改文章、刪除文章之后都應記得更新索引
*/
public void updateIndex(final ServletContext application)
{
new Thread(new Runnable()
{
@Override
public void run()
{
try
{
Thread.sleep(3000); // 由於新增、修改文章之后立即更新索引可能太數據庫還未寫入,所以延遲一段時間執行
}
catch (InterruptedException e)
{
e.printStackTrace();
}
// 創建索引一般需要數秒種,為避免阻塞主線程影響業務,開啟新線程執行
createIndexSingleThread(application);
}
}).start();
}
如何搜索HTML或markdown
由於我的數據庫存放的是markdown,這里着重考慮一下后面這個問題,雖然markdown已經和純文本差不多了,但是在搜索摘要里面顯示一大堆類似# 這是一級標題
這樣的東西也是不爽的,我沒有找到合適的將markdown過濾為純文本的工具類,只能自己簡單寫一個,真的是太簡單,簡單到我的博客里面主要哪種類型的markdown標記,我就過濾什么樣的標記,其它都沒管,這個方法肯定還有很多問題,目前只要能滿足我的需求就足夠了,如果有誰有好的工具歡迎推薦。另外一個就是注意替換HTML的<>
標簽:
/**
* 簡單地過濾markdown標記使之成為純文本,主要用在摘要和搜索的場景
* @param md
* @return
*/
public static String markdownToText(String md)
{
if(StringUtil.isEmpty(md)) return "";
md = md.replaceAll("(^|\n|\r\n)#{1,6} *", "$1"); // 去除 #
md = md.replaceAll("(^|\n|\r\n)\\* *", "$1"); // 去除 *
md = md.replaceAll("(^|\n|\r\n)> *", "$1"); // 去除 > (引用)
md = md.replaceAll("(^|\n|\r\n)```\\w*?(\n|\r\n)([\\s\\S]+?)```", "$2$3"); // 去除代碼塊
md = md.replaceAll("`([^`]+?)`", "$1"); // 去除行內 `code`
md = md.replaceAll("!\\[(.*?)\\]\\(.+?\\)", "$1"); // 去除 img
md = md.replaceAll("\\[(.*?)\\]\\(.+?\\)", "$1"); // 去除 超鏈接
md = md.replaceAll("<", "<");
md = md.replaceAll(">", ">"); // 替換HTML標簽
return md;
}
如果是數據庫存放的是HTML,可以用一些開源庫把它轉換成純文本再建立索引,比如jsoup
。
分頁
官方建議一次性全部查出來,然后再自己分頁,而且如果你要知道總頁數,也只能這么干。雖然還有一個searchAfter
方法,但是對於這里沒啥用。
不同用戶顯示不同內容
比如有一些僅自己可見的文章,我希望當我登錄了時可以被搜索到,沒有登錄時不能搜索,可以這樣實現:
BooleanQuery.Builder queryBuilder = new BooleanQuery.Builder();
queryBuilder.add(multiFieldQuery, BooleanClause.Occur.MUST);
if(user == null)
{
// 未登錄用戶只能查詢公開的文章
Query termQuery = new TermQuery(new Term("permission", "pub")); // term表示准確搜索
queryBuilder.add(termQuery, BooleanClause.Occur.MUST);
}
BooleanQuery query = queryBuilder.build();
效果體驗
可以訪問我的博客 http://blog.liuxianan.com 然后雙擊Ctrl
即可搜索。
結束語
由於時間匆忙,目前草草地實現了搜索功能,后續發現問題再慢慢優化吧,畢竟這不是主業(已轉前端),沒那么多時間搞這東西。
搜索效果文章最前面已經給出了,仿百度做的,哈哈!
本文是面向入門級別的,想深入學習可以參考這位仁兄的系列文章:
http://blog.csdn.net/wuyinggui10000/article/category/3173543