幾百行代碼完成百度搜索引擎,真的可以嗎?
本文轉(zhuǎn)載自微信公眾號「Java極客技術(shù)」,作者鴨血粉絲 。轉(zhuǎn)載本文請聯(lián)系Java極客技術(shù)公眾號。
Hello 大家好,我是鴨血粉絲,大家都叫我阿粉,搜索引擎想必大家一定不會默認(rèn),我們項(xiàng)目中經(jīng)常使用的 ElasticSearch 就是一種搜索引擎,在我們的日志系統(tǒng)中必不可少,ELK 作為一個(gè)整體,基本上是運(yùn)維標(biāo)配了,另外目前的搜索引擎底層都是基于 Lucene 來實(shí)現(xiàn)的。
阿粉最近遇到一個(gè)需求,因?yàn)閿?shù)據(jù)量沒有達(dá)到需要使用 ElasticSearch 的級別,也不想單獨(dú)部署一套集群,所以準(zhǔn)備自己基于 Lucene 實(shí)現(xiàn)一個(gè)簡易的搜索服務(wù)。下面我們一起來看一下吧。
背景
**Lucene **是一套用于全文檢索和搜索的開放源碼程序庫,由 Apache 軟件基金會支持和提供。Lucene 提供了一個(gè)簡單卻強(qiáng)大的應(yīng)用程序接口,能夠做全文索引和搜索。Lucene 是現(xiàn)在最受歡迎的免費(fèi) Java 信息檢索程序庫。
上面的解釋是來自維基百科,我們只需要知道 Lucene 可以進(jìn)行全文索引和搜索就行了,這里的索引是動詞,意思是我們可以將文檔或者文章或者文件等數(shù)據(jù)進(jìn)行索引記錄下來,索引過后,我們查詢起來就會很快。
索引這個(gè)詞有的時(shí)候是動詞,表示我們要索引數(shù)據(jù),有的時(shí)候是名詞,我們需要根據(jù)上下文場景來判斷。新華字典前面的字母表或者書籍前面的目錄本質(zhì)上都是索引。
接入
引入依賴
首先我們創(chuàng)建一個(gè) SpringBoot 項(xiàng)目,然后在 pom 文件中加入如下內(nèi)容,我這里使用的 lucene 版本是 7.2.1,
- <properties>
- <lucene.version>7.2.1</lucene.version>
- </properties>
- <!-- Lucene核心庫 -->
- <dependency>
- <groupId>org.apache.lucene</groupId>
- <artifactId>lucene-core</artifactId>
- <version>${lucene.version}</version>
- </dependency>
- <!-- Lucene解析庫 -->
- <dependency>
- <groupId>org.apache.lucene</groupId>
- <artifactId>lucene-queryparser</artifactId>
- <version>${lucene.version}</version>
- </dependency>
- <!-- Lucene附加的分析庫 -->
- <dependency>
- <groupId>org.apache.lucene</groupId>
- <artifactId>lucene-analyzers-common</artifactId>
- <version>${lucene.version}</version>
- </dependency>
索引數(shù)據(jù)
在使用 Lucene 之前我們需要先索引一些文件,然后再通過關(guān)鍵詞查詢出來,下面我們來模擬整個(gè)過程。為了方便我們這里模擬一些數(shù)據(jù),正常的數(shù)據(jù)應(yīng)該是從數(shù)據(jù)庫或者文件中加載的,我們的思路是這樣的:
- 生成多條實(shí)體數(shù)據(jù);
- 將實(shí)體數(shù)據(jù)映射成 Lucene 的文檔形式;
- 索引文檔;
- 根據(jù)關(guān)鍵詞查詢文檔;
第一步我們先創(chuàng)建一個(gè)實(shí)體如下:
- import lombok.Data;
- @Data
- public class ArticleModel {
- private String title;
- private String author;
- private String content;
- }
我們再寫一個(gè)工具類,用來索引數(shù)據(jù),代碼如下:
- import org.apache.commons.collections.CollectionUtils;
- import org.apache.commons.lang.StringUtils;
- import org.apache.lucene.analysis.Analyzer;
- import org.apache.lucene.analysis.standard.StandardAnalyzer;
- import org.apache.lucene.document.*;
- import org.apache.lucene.index.IndexWriter;
- import org.apache.lucene.index.IndexWriterConfig;
- import org.apache.lucene.store.Directory;
- import org.apache.lucene.store.FSDirectory;
- import org.springframework.beans.factory.annotation.Value;
- import org.springframework.stereotype.Component;
- import java.io.IOException;
- import java.nio.file.Paths;
- import java.util.ArrayList;
- import java.util.List;
- import java.util.Map;
- public class LuceneIndexUtil {
- private static String INDEX_PATH = "/opt/lucene/demo";
- private static IndexWriter writer;
- public static LuceneIndexUtil getInstance() {
- return SingletonHolder.luceneUtil;
- }
- private static class SingletonHolder {
- public final static LuceneIndexUtil luceneUtil = new LuceneIndexUtil();
- }
- private LuceneIndexUtil() {
- this.initLuceneUtil();
- }
- private void initLuceneUtil() {
- try {
- Directory dir = FSDirectory.open(Paths.get(INDEX_PATH));
- Analyzer analyzer = new StandardAnalyzer();
- IndexWriterConfig iwc = new IndexWriterConfig(analyzer);
- writer = new IndexWriter(dir, iwc);
- } catch (IOException e) {
- log.error("create luceneUtil error");
- if (null != writer) {
- try {
- writer.close();
- } catch (IOException ioException) {
- ioException.printStackTrace();
- } finally {
- writer = null;
- }
- }
- }
- }
- /**
- * 索引單個(gè)文檔
- *
- * @param doc 文檔信息
- * @throws IOException IO 異常
- */
- public void addDoc(Document doc) throws IOException {
- if (null != doc) {
- writer.addDocument(doc);
- writer.commit();
- writer.close();
- }
- }
- /**
- * 索引單個(gè)實(shí)體
- *
- * @param model 單個(gè)實(shí)體
- * @throws IOException IO 異常
- */
- public void addModelDoc(Object model) throws IOException {
- Document document = new Document();
- List<Field> fields = luceneField(model.getClass());
- fields.forEach(document::add);
- writer.addDocument(document);
- writer.commit();
- writer.close();
- }
- /**
- * 索引實(shí)體列表
- *
- * @param objects 實(shí)例列表
- * @throws IOException IO 異常
- */
- public void addModelDocs(List<?> objects) throws IOException {
- if (CollectionUtils.isNotEmpty(objects)) {
- List<Document> docs = new ArrayList<>();
- objects.forEach(o -> {
- Document document = new Document();
- List<Field> fields = luceneField(o);
- fields.forEach(document::add);
- docs.add(document);
- });
- writer.addDocuments(docs);
- }
- }
- /**
- * 清除所有文檔
- *
- * @throws IOException IO 異常
- */
- public void delAllDocs() throws IOException {
- writer.deleteAll();
- }
- /**
- * 索引文檔列表
- *
- * @param docs 文檔列表
- * @throws IOException IO 異常
- */
- public void addDocs(List<Document> docs) throws IOException {
- if (CollectionUtils.isNotEmpty(docs)) {
- long startTime = System.currentTimeMillis();
- writer.addDocuments(docs);
- writer.commit();
- log.info("共索引{}個(gè) Document,共耗時(shí){} 毫秒", docs.size(), (System.currentTimeMillis() - startTime));
- } else {
- log.warn("索引列表為空");
- }
- }
- /**
- * 根據(jù)實(shí)體 class 對象獲取字段類型,進(jìn)行 lucene Field 字段映射
- *
- * @param modelObj 實(shí)體 modelObj 對象
- * @return 字段映射列表
- */
- public List<Field> luceneField(Object modelObj) {
- Map<String, Object> classFields = ReflectionUtils.getClassFields(modelObj.getClass());
- Map<String, Object> classFieldsValues = ReflectionUtils.getClassFieldsValues(modelObj);
- List<Field> fields = new ArrayList<>();
- for (String key : classFields.keySet()) {
- Field field;
- String dataType = StringUtils.substringAfterLast(classFields.get(key).toString(), ".");
- switch (dataType) {
- case "Integer":
- field = new IntPoint(key, (Integer) classFieldsValues.get(key));
- break;
- case "Long":
- field = new LongPoint(key, (Long) classFieldsValues.get(key));
- break;
- case "Float":
- field = new FloatPoint(key, (Float) classFieldsValues.get(key));
- break;
- case "Double":
- field = new DoublePoint(key, (Double) classFieldsValues.get(key));
- break;
- case "String":
- String string = (String) classFieldsValues.get(key);
- if (StringUtils.isNotBlank(string)) {
- if (string.length() <= 1024) {
- field = new StringField(key, (String) classFieldsValues.get(key), Field.Store.YES);
- } else {
- field = new TextField(key, (String) classFieldsValues.get(key), Field.Store.NO);
- }
- } else {
- field = new StringField(key, StringUtils.EMPTY, Field.Store.NO);
- }
- break;
- default:
- field = new TextField(key, JsonUtils.obj2Json(classFieldsValues.get(key)), Field.Store.YES);
- break;
- }
- fields.add(field);
- }
- return fields;
- }
- public void close() {
- if (null != writer) {
- try {
- writer.close();
- } catch (IOException e) {
- log.error("close writer error");
- }
- writer = null;
- }
- }
- public void commit() throws IOException {
- if (null != writer) {
- writer.commit();
- writer.close();
- }
- }
- }
有了工具類,我們再寫一個(gè) demo 來進(jìn)行數(shù)據(jù)的索引
- import java.util.ArrayList;
- import java.util.List;
- /**
- * <br>
- * <b>Function:</b><br>
- * <b>Author:</b>@author Silence<br>
- * <b>Date:</b>2020-10-17 21:08<br>
- * <b>Desc:</b>無<br>
- */
- public class Demo {
- public static void main(String[] args) {
- LuceneIndexUtil luceneUtil = LuceneIndexUtil.getInstance();
- List<ArticleModel> articles = new ArrayList<>();
- try {
- //索引數(shù)據(jù)
- ArticleModel article1 = new ArticleModel();
- article1.setTitle("Java 極客技術(shù)");
- article1.setAuthor("鴨血粉絲");
- article1.setContent("這是一篇給大家介紹 Lucene 的技術(shù)文章,必定點(diǎn)贊評論轉(zhuǎn)發(fā)?。?!");
- ArticleModel article2 = new ArticleModel();
- article2.setTitle("極客技術(shù)");
- article2.setAuthor("鴨血粉絲");
- article2.setContent("此處省略兩千字...");
- ArticleModel article3 = new ArticleModel();
- article3.setTitle("Java 極客技術(shù)");
- article3.setAuthor("鴨血粉絲");
- article3.setContent("最后邀請你加入我們的知識星球,Today is big day!");
- articles.add(article1);
- articles.add(article2);
- articles.add(article3);
- luceneUtil.addModelDocs(articles);
- luceneUtil.commit();
- } catch (Exception e) {
- e.printStackTrace();
- }
- }
- }
上面的 content 內(nèi)容可以自行進(jìn)行替換,阿粉這邊避免湊字?jǐn)?shù)的嫌疑就不貼了。
展示
運(yùn)行結(jié)束過后,我們用過 Lucene 的可視化工具 luke 來查看下索引的數(shù)據(jù)內(nèi)容,下載過后解壓我們可以看到有.bat 和 .sh 兩個(gè)腳本,根據(jù)自己的系統(tǒng)進(jìn)行運(yùn)行就好了。阿粉這邊是 mac 用的是 sh 腳本運(yùn)行,運(yùn)行后打開設(shè)置的索引目錄即可。
進(jìn)入過后,我們可以看到下圖顯示的內(nèi)容,選擇 content 點(diǎn)擊 show top items 可以看到右側(cè)的索引數(shù)據(jù),這里根據(jù)分詞器的不同,索引的結(jié)果是不一樣的,阿粉這里采用的分詞器就是標(biāo)準(zhǔn)的分詞器,小伙伴們可以根據(jù)自己的要求選擇適合自己的分詞器即可。
搜索數(shù)據(jù)
數(shù)據(jù)已經(jīng)索引成功了,接下來我們就需要根據(jù)條件進(jìn)行數(shù)據(jù)的搜索了,我們創(chuàng)建一個(gè) LuceneSearchUtil.java 來操作數(shù)據(jù)。
- import org.apache.commons.collections.MapUtils;
- import org.apache.lucene.analysis.Analyzer;
- import org.apache.lucene.analysis.standard.StandardAnalyzer;
- import org.apache.lucene.index.DirectoryReader;
- import org.apache.lucene.queryparser.classic.QueryParser;
- import org.apache.lucene.search.*;
- import org.apache.lucene.store.Directory;
- import org.apache.lucene.store.FSDirectory;
- import org.springframework.beans.factory.annotation.Value;
- import java.io.IOException;
- import java.nio.file.Paths;
- import java.util.Map;
- public class LuceneSearchUtil {
- private static String INDEX_PATH = "/opt/lucene/demo";
- private static IndexSearcher searcher;
- public static LuceneSearchUtil getInstance() {
- return LuceneSearchUtil.SingletonHolder.searchUtil;
- }
- private static class SingletonHolder {
- public final static LuceneSearchUtil searchUtil = new LuceneSearchUtil();
- }
- private LuceneSearchUtil() {
- this.initSearcher();
- }
- private void initSearcher() {
- Directory directory;
- try {
- directory = FSDirectory.open(Paths.get(INDEX_PATH));
- DirectoryReader reader = DirectoryReader.open(directory);
- searcher = new IndexSearcher(reader);
- } catch (IOException e) {
- e.printStackTrace();
- }
- }
- public TopDocs searchByMap(Map<String, Object> queryMap) throws Exception {
- if (null == searcher) {
- this.initSearcher();
- }
- if (MapUtils.isNotEmpty(queryMap)) {
- BooleanQuery.Builder builder = new BooleanQuery.Builder();
- queryMap.forEach((key, value) -> {
- if (value instanceof String) {
- Query queryString = new PhraseQuery(key, (String) value);
- // Query queryString = new TermQuery(new Term(key, (String) value));
- builder.add(queryString, BooleanClause.Occur.MUST);
- }
- });
- return searcher.search(builder.build(), 10);
- }
- return null;
- }
- }
在 demo.java 中增加搜索代碼如下:
- //查詢數(shù)據(jù)
- Map<String, Object> map = new HashMap<>();
- map.put("title", "Java 極客技術(shù)");
- // map.put("title", "極客技術(shù)");
- // map.put("content", "最");
- LuceneSearchUtil searchUtil = LuceneSearchUtil.getInstance();
- TopDocs topDocs = searchUtil.searchByMap(map);
- System.out.println(topDocs.totalHits);
運(yùn)行結(jié)果如下,表示搜索到了兩條。
通過可視化工具我們可以看到 title 為"Java 極客技術(shù)"確實(shí)是有兩條記錄,而且我們也確認(rèn)只插入了兩條數(shù)據(jù)。注意這里如果根據(jù)其他字符去查詢可能查詢不出來,因?yàn)榘⒎圻@里的分詞器采用的是默認(rèn)的分詞器,小伙伴可以根據(jù)自身的情況采用相應(yīng)的分詞器。
至此我們可以索引和搜索數(shù)據(jù)了,不過這還是簡單的入門操作,對于不同類型的字段,我們需要使用不同的查詢方式,而且根據(jù)系統(tǒng)的特性我們需要使用特定的分詞器,默認(rèn)的標(biāo)準(zhǔn)分詞器不一定符合我們的使用場景。而且我們索引數(shù)據(jù)的時(shí)候也需要根據(jù)字段類型進(jìn)行不同 Field 的設(shè)定。上面的案例只是 demo 并不能在生產(chǎn)上使用,搜索引擎在互聯(lián)網(wǎng)行業(yè)是領(lǐng)頭羊,很多先進(jìn)的互聯(lián)網(wǎng)技術(shù)都是從搜索引擎開始發(fā)展的。