美文网首页Java
Lucene还可以这样玩?SpringBoot集成Lucene实

Lucene还可以这样玩?SpringBoot集成Lucene实

作者: 小螺旋丸 | 来源:发表于2021-04-10 21:15 被阅读0次

    前言

    哈喽,大家好,我是丸子。

    搜索引擎想必大家都并不陌生,比如百度,谷歌都是常见的搜索引擎。

    在我们实际的项目开发中,也经常遇到类似的业务需求,比如公司要开发一个知识库项目,知识库里有上百万条文章,要求我们能够输入关键字,查询出包含有关键字的文章内容,并且对关键字进行高亮处理,显示查询后的最佳摘要,这个时候传统的数据库LIKE查询虽然能勉强满足业务需求,但是查询速度令人无法忍受,这个时候就需要借助搜索引擎来进行处理。

    在Java开发领域,Lucene可以算是开山鼻祖,现在常用的SolrElasticSearch底层都是基于Lucene,很多开发人员并没有系统的学习过Lucene,都是直接上手SolrElasticSearch进行开发,但实际上掌握Lucene的常用api,理解其底层原理还是比较重要的,这有利于我们对全文检索领域有更加深入的理解,同时我们也可以根据自己的业务需求定制个性化的搜索引擎,我所在的公司使用的就是基于Lucene自研的搜索引擎服务,针对公司独特的业务场景,使用起来特别方便。

    本篇文章将详细讲解如何使用SpringBoot集成Lucene实现自己的轻量级搜索引擎,相关源码资料可以查看文末获取!

    Lucene为什么查的快

    Lucene之所以查的快,原因在于它内部使用了倒排索引算法,在这里简单的介绍一下原理:
    普通查询是根据文章找关键字,而倒排索引是根据关键字找文章!

    比如“我今天很开心,因为马上就要下班了”这句话,从中搜索“开心”,普通查询要遍历整句话,直到找到“开心”二字为止,效率低下。倒排索引则是对整句话使用分词器进行分词处理,从而“开心”二字可以直接指向这句话,搜索的时候直接就可以根据“开心”搜到所属的内容,达到快速响应的效果。

    springBoot集成Lucene

    下面我会以Demo的形式详细讲解springBoot如何集成Lucene实现增删查改,以及显示高亮和最佳摘要(demo全部资料和源码在文末获取)。

    一.建表

    以Mysql为例,创建数据库lucene_demo,建表article,作为数据源,之后对表内容进行增删查改的时候同步到Lucene索引数据,建表语句如下:

    CREATE TABLE `article` (
      `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键id',
      `title` varchar(200) DEFAULT NULL COMMENT '标题',
      `content` longtext COMMENT '内容',
      PRIMARY KEY (`id`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
    

    二.创建SpringBoot项目

    在这里我直接拿自己的代码生成器生成,配置好基础内容点击生成,即可生成一个完整的前后台项目框架,省去了搭建项目的繁琐步骤,这样我们可以在生成的代码基础上进行开发:

    代码生成器
    生成的项目结构和代码如下: 生成的项目
    三.引入Lucene相关依赖

    pom.xml引入Lucene相关依赖:

    <dependency>
                <groupId>org.apache.lucene</groupId>
                <artifactId>lucene-core</artifactId>
                <version>8.1.0</version>
            </dependency>
            <dependency>
                <groupId>org.apache.lucene</groupId>
                <artifactId>lucene-analyzers-common</artifactId>
                <version>8.1.0</version>
            </dependency>
            <dependency>
                <groupId>org.apache.lucene</groupId>
                <artifactId>lucene-queryparser</artifactId>
                <version>8.1.0</version>
            </dependency>
            <dependency>
                <groupId>org.apache.lucene</groupId>
                <artifactId>lucene-highlighter</artifactId>
                <version>8.1.0</version>
            </dependency>
    

    四.引入IK分词器依赖

    目前市面上有不少中文分词器,但最受欢迎的还是IK分词器,Lucene自带的分词器对中文只能单字拆分,显然不符合我们的需求,但IK分词器解决了这个问题,他可以把一段话分成多组不同的中文单词,帮助建立搜索索引。

    公共maven仓库中没有IK分词器的依赖,需要我们install一下,文末资料中有IK分词器的源码,可以导入idea直接install到自己的maven仓库,然后引入依赖到项目即可。


    install

    pom.xml引入Ik分词器相关依赖(因为之前已经引入了Lucene相关依赖,所以引入Ik的时候去除一下,防止依赖冲突):

     <dependency>
                <groupId>org.wltea.ik-analyzer</groupId>
                <artifactId>ik-analyzer</artifactId>
                <version>8.1.0</version>
                <exclusions>
                    <exclusion>
                        <groupId>org.apache.lucene</groupId>
                        <artifactId>lucene-analyzers-common</artifactId>
                    </exclusion>
                    <exclusion>
                        <groupId>org.apache.lucene</groupId>
                        <artifactId>lucene-queryparser</artifactId>
                    </exclusion>
                    <exclusion>
                        <groupId>org.apache.lucene</groupId>
                        <artifactId>lucene-core</artifactId>
                    </exclusion>
                    <exclusion>
                        <groupId>org.slf4j</groupId>
                        <artifactId>slf4j-api</artifactId>
                    </exclusion>
                </exclusions>
            </dependency>
    

    五.项目启动时加载IK分词器

    最好在我们启动项目的时候就把IK分词器加载进内存当中,这样第一次查询就不必再进行加载,避免第一次查询因为加载分词器造成卡顿,创建init包,建立BusinessInitializer类,如下:

    初始化加载IK分词器
    代码如下:
    package lucenedemo.init;
    
    import org.springframework.boot.ApplicationArguments;
    import org.springframework.boot.ApplicationRunner;
    import org.springframework.stereotype.Component;
    import org.wltea.analyzer.cfg.DefaultConfig;
    import org.wltea.analyzer.dic.Dictionary;
    
    /**
     * 业务初始化器
     *
     * @author zrx
     */
    @Component
    public class BusinessInitializer implements ApplicationRunner {
    
        @Override
        public void run(ApplicationArguments args) {
            //加载ik分词器配置 防止第一次查询慢
            Dictionary.initial(DefaultConfig.getInstance());
        }
    }
    

    引入IK的配置文件IKAnalyzer.cfg.xml以及扩展字典ext.dic和停止词字典stopword.dic,可以添加和屏蔽某些词语,把配置文件放入resources下:

    Ik配置文件
    在这里我们添加两个扩展词小螺旋丸小千鸟,查询的时候可以用来做测试,如果测试的时候可以被完整标记高亮,说明词语被成功识别,因为IK自带的字典里,没有这两个单词,IK自带的字典位于IK源码的resources包下,感兴趣的朋友可以通过源码自行查看:
    添加扩展词
    添加完毕,我们启动项目,发现词典被成功加载,如下:
    词典被加载
    接下来我们进行增删查改的开发。

    六.增删查改业务开发:

    1、配置索引库存放位置

    首先我们需要配置索引的存放位置,可以把它理解为一个数据库,只不过这个数据库存放的是一些索引文件,我们在yml中指定位置,创建Config配置类,用@value注解获取它的值,方便随时在代码中获取,如下:

    yml和Config
    2、增删查改的时候同步索引

    数据库的增删查改方法代码生成器已经帮助我们生成完毕,只需要在原来的功能基础上添加对于索引库相关的代码逻辑即可!

    首先是添加和更新操作,添加更新放在一起,根据主键id判断,如果索引中存在此id,则更新,否则添加,在service实现类中添加addOrUpIndex方法,同时每次添加和更新的时候都要调一下此方法,同步索引,代码基本每一行都有完整注释,如下:

    /**
         * mapper文件里增加 useGeneratedKeys="true" keyProperty="id" keyColumn="id"属性,否则自增主键映射不上
         *
         * @param entity
         */
        @Override
        public void add(ArticleEntity entity) {
            dao.add(entity);
            addOrUpIndex(entity);
        }
    
        @Override
        public void update(ArticleEntity entity) {
            dao.update(entity);
            addOrUpIndex(entity);
        }
    
        /**
         * 添加或更新索引
         * @param entity
         */
        private void addOrUpIndex(ArticleEntity entity) {
            IndexWriter indexWriter = null;
            IndexReader indexReader = null;
            Directory directory = null;
            Analyzer analyzer = null;
            try {
                //创建索引目录文件
                File indexFile = new File(config.getIndexLibrary());
                File[] files = indexFile.listFiles();
                // 1. 创建分词器,分析文档,对文档进行分词
                analyzer = new IKAnalyzer();
                // 2. 创建Directory对象,声明索引库的位置
                directory = FSDirectory.open(Paths.get(config.getIndexLibrary()));
                // 3. 创建IndexWriteConfig对象,写入索引需要的配置
                IndexWriterConfig writerConfig = new IndexWriterConfig(analyzer);
                // 4.创建IndexWriter写入对象
                indexWriter = new IndexWriter(directory, writerConfig);
                // 5.写入到索引库,通过IndexWriter添加文档对象document
                Document doc = new Document();
                //查询是否有该索引,没有添加,有则更新
                TopDocs topDocs = null;
                //判断索引目录文件是否存在文件,如果没有文件,则为首次添加,有文件,则查询id是否已经存在
                if (files != null && files.length != 0) {
                    //创建查询对象
                    QueryParser queryParser = new QueryParser("id", analyzer);
                    Query query = queryParser.parse(String.valueOf(entity.getId()));
                    indexReader = DirectoryReader.open(directory);
                    IndexSearcher indexSearcher = new IndexSearcher(indexReader);
                    //查询获取命中条目
                    topDocs = indexSearcher.search(query, 1);
                }
                //StringField 不分词 直接建索引 存储
                doc.add(new StringField("id", String.valueOf(entity.getId()), Field.Store.YES));
                //TextField 分词 建索引 存储
                doc.add(new TextField("title", entity.getTitle(), Field.Store.YES));
                //TextField 分词 建索引 存储
                doc.add(new TextField("content", entity.getContent(), Field.Store.YES));
                //如果没有查询结果,添加
                if (topDocs != null && topDocs.totalHits.value == 0) {
                    indexWriter.addDocument(doc);
                    //否则,更新
                } else {
                    indexWriter.updateDocument(new Term("id", String.valueOf(entity.getId())), doc);
                }
            } catch (Exception e) {
                e.printStackTrace();
                throw new RuntimeException("添加索引库出错:" + e.getMessage());
            } finally {
                if (indexWriter != null) {
                    try {
                        indexWriter.close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
                if (indexReader != null) {
                    try {
                        indexReader.close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
                if (directory != null) {
                    try {
                        directory.close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
                if (analyzer != null) {
                    analyzer.close();
                }
            }
        }
    

    代码应该很容易就可以看明白,这里我们把实体的titlecontent进行分词,并存储为索引文件,所以接下来查询的时候也要根据这两个字段来进行查询,查询的时候我们要对查询结果进行分页,Lucene的分页方式比较特别,他没有类似数据库那种提供开始和结束下标定位元素的方法,而是只能指定查询的总条目数,然后把所有的命中结果查询出来,比如一共有100条数据,查询第一页返回10条,查询第十页则会返回100条,需要我们在逻辑上对查询结果进行分页,取我们想要的数据,也可以利用Luncene提供的SearchAfter方法进行查询,它可以根据指定的最后一个元素查询接下来指定数目的元素,但这需要我们查询出前n个元素然后取最后一个元素传给SearchAfter方法,两种方法效率上并没有太大区别,毕竟Lucene本身就很快。但这也涉及到一个问题,如果查询的数据量过多,比如上千万条可能会导致内存溢出,这就需要我们根据业务做一个取舍,用户在查询的时候通常只会看前几页的数据,所以我们可以指定一下最大的查询数量,比如10000条,无论实际符合条件的结果有多少,我们最多只查询前10000条,这样问题便得到解决,其实很多搜索引擎也是这样做的!

    如果你说我就要看全部的数据,那就涉及到了数据的分布式存储,在分页的时候就需要每台服务器进行查询然后汇总查询结果,这里的问题就比较复杂了,在此处不做深究,以后可以专门聊一聊,其实业界已经有了几种比较成熟的解决方案,可以较好的解决分布式存储的分页问题。

    这里代码中并没有指定查询的最大数量,毕竟是个demo,没必要弄的这么复杂,代码如下:

        @Override
        public PageData<ArticleEntity> fullTextSearch(String keyWord, Integer page, Integer limit) {
            List<ArticleEntity> searchList = new ArrayList<>(10);
            PageData<ArticleEntity> pageData = new PageData<>();
            File indexFile = new File(config.getIndexLibrary());
            File[] files = indexFile.listFiles();
            //沒有索引文件,不然沒有查詢結果
            if (files == null || files.length == 0) {
                pageData.setCount(0);
                pageData.setTotalPage(0);
                pageData.setCurrentPage(page);
                pageData.setResult(new ArrayList<>());
                return pageData;
            }
            IndexReader indexReader = null;
            Directory directory = null;
            try (Analyzer analyzer = new IKAnalyzer()) {
                directory = FSDirectory.open(Paths.get(config.getIndexLibrary()));
                //多项查询条件
                QueryParser queryParser = new MultiFieldQueryParser(new String[]{"title", "content"}, analyzer);
                //单项
                //QueryParser queryParser = new QueryParser("title", analyzer);
                Query query = queryParser.parse(!StringUtils.isEmpty(keyWord) ? keyWord : "*:*");
                indexReader = DirectoryReader.open(directory);
                //索引查询对象
                IndexSearcher indexSearcher = new IndexSearcher(indexReader);
                TopDocs topDocs = indexSearcher.search(query, 1);
                //获取条数
                int total = (int) topDocs.totalHits.value;
                pageData.setCount(total);
                int realPage = total % limit == 0 ? total / limit : total / limit + 1;
                pageData.setTotalPage(realPage);
                //获取结果集
                ScoreDoc lastSd = null;
                if (page > 1) {
                    int num = limit * (page - 1);
                    TopDocs tds = indexSearcher.search(query, num);
                    lastSd = tds.scoreDocs[num - 1];
                }
                //通过最后一个元素去搜索下一页的元素 如果lastSd为null,查询第一页
                TopDocs tds = indexSearcher.searchAfter(lastSd, query, limit);
                QueryScorer queryScorer = new QueryScorer(query);
                //最佳摘要
                SimpleSpanFragmenter fragmenter = new SimpleSpanFragmenter(queryScorer, 200);
                //高亮前后标签
                SimpleHTMLFormatter formatter = new SimpleHTMLFormatter("<b><font color='red'>", "</font></b>");
                //高亮对象
                Highlighter highlighter = new Highlighter(formatter, queryScorer);
                //设置高亮最佳摘要
                highlighter.setTextFragmenter(fragmenter);
                //遍历查询结果 把标题和内容替换为带高亮的最佳摘要
                for (ScoreDoc sd : tds.scoreDocs) {
                    Document doc = indexSearcher.doc(sd.doc);
                    ArticleEntity articleEntity = new ArticleEntity();
                    Integer id = Integer.parseInt(doc.get("id"));
                    //获取标题的最佳摘要
                    String titleBestFragment = highlighter.getBestFragment(analyzer, "title", doc.get("title"));
                    //获取文章内容的最佳摘要
                    String contentBestFragment = highlighter.getBestFragment(analyzer, "content", doc.get("content"));
                    articleEntity.setId(id);
                    articleEntity.setTitle(titleBestFragment);
                    articleEntity.setContent(contentBestFragment);
                    searchList.add(articleEntity);
                }
                pageData.setCurrentPage(page);
                pageData.setResult(searchList);
                return pageData;
            } catch (Exception e) {
                e.printStackTrace();
                throw new RuntimeException("全文檢索出错:" + e.getMessage());
            } finally {
                if (indexReader != null) {
                    try {
                        indexReader.close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
                if (directory != null) {
                    try {
                        directory.close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
    

    最后是删除索引,根据唯一标识id删除即可,代码如下:

        @Override
        public void delete(ArticleEntity entity) {
            dao.delete(entity);
            //同步删除索引
            deleteIndex(entity);
        }
    
        private void deleteIndex(ArticleEntity entity) {
            //删除全文检索
            IndexWriter indexWriter = null;
            Directory directory = null;
            try (Analyzer analyzer = new IKAnalyzer()) {
                directory = FSDirectory.open(Paths.get(config.getIndexLibrary()));
                IndexWriterConfig writerConfig = new IndexWriterConfig(analyzer);
                indexWriter = new IndexWriter(directory, writerConfig);
                //根据id字段进行删除
                indexWriter.deleteDocuments(new Term("id", String.valueOf(entity.getId())));
            } catch (Exception e) {
                e.printStackTrace();
                throw new RuntimeException("删除索引库出错:" + e.getMessage());
            } finally {
                if (indexWriter != null) {
                    try {
                        indexWriter.close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
                if (directory != null) {
                    try {
                        directory.close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
    

    至此,Lucene的后台增删查改功能开发完毕!

    3、利用swagger测试

    接下来我们利用swagger对功能进行测试,测试之前我们把controller层增删查改方法的 @LoginRequired 注解去掉(@LoginRequired是代码生成器最新版添加的注解,可以控制方法必须登录才可以调用),这样可以不必登录,打开swagger,添加一条数据,如下:


    添加数据

    如上,数据添加成功,数据库数据添加成功,Lucene索引文件夹也生成了相关索引文件,如下:


    数据库 Lucene索引文件
    接下里我们测一下全文检索功能,如下:
    查询结果

    删除功能也可正常使用并同步删除索引,此处就不截图了。这样一来,后台api测试完毕,符合预期效果,接下来进入前台实现阶段。

    4、前台实现

    前台实现没有什么好说的,就是跟后端对接口进行交互,前端真是我的硬伤,我根据代码生成器生成的列表页做了调整,最终实现效果如下:


    前端效果

    前台代码就不贴了,没有太大意义,毕竟有了后台的数据返回,前台有n多种展示方式,大家根据自己的习惯去对接口就好了,完整的前后台代码以及sql文件等可于文末获取。

    结语

    本篇文章我们利用Lucene自己实现了一个非常轻量的搜索引擎,其实我们可以利用反射把它做成一个通用的查询框架,这样无论实体的属性名称怎么变,都可以灵活应对。

    全文检索在Java开发领域是一个重要的知识点,需要我们深入理解和掌握,希望通过本篇文章可以让你对Lucene有一个更加全面的认识,代码生成器不出意外本月会更新一版,我们下次更新,再见啦!

    附:关注公众号 螺旋编程极客 获取更多精彩内容,我们一起进步,一起成长,回复 1024 可获取本篇文章的项目源码等资料,期待您的关注!

    相关文章

      网友评论

        本文标题:Lucene还可以这样玩?SpringBoot集成Lucene实

        本文链接:https://www.haomeiwen.com/subject/xrtokltx.html