「Java 项目详解」API 文档搜索引擎(万字长文)

目录

运行效果

一、项目介绍

一)需求介绍

二)功能介绍

三)实现思路

四)项目目标

二、前期准备

一)了解正排索引

二)了解倒排索引

三)获取 Java API 开发文档

四)了解分词

五)功能划分

1、索引模块

2、搜索模块

3、Web 模块

三、项目实操

一)实现分词功能

1、创建项目

2、导入分词依赖

3、测试分词效果

二)实现解析模块

1、定义变量

2、实现递归枚举所有文件

4、实现标题解析

5、解析 URL 思路

6、实现 URL 解析

7、解析描述思路

8、实现描述解析

9、Parser 类小结

三)实现 Index 类

1、定义变量

2、声明索引结构

3、实现正排索引

4、实现倒排索引

5、保存索引

6、调用索引

7、优化索引制作

8、Index 类小结

四)实现 DocSearcher 类

1、定义变量

2、实现 search 方法

3、实现文档描述部分

4、使用正则表达式替换内容

5、DocSearch 类小结

五)实现前端模块

1、准备工作

2、验证后端接口

3、实现前端页面

4、引入停用词

5、处理重复结果

6、修改为 Spring Boot 项目

7、添加图标

8、部署到云服务器

四、项目总结

一)可以优化的地方

二)项目卡点

三)我得到了什么

1、对困难祛魅

2、多向人请教


大家好呀,一个多月没更新博客了,最近在忙着制作自己的第一个项目,这期间也遇到了挺多卡点的。

第一次做项目,前前后后做了一个月,以及花了三天写完这篇博客,回过头来,竟有点恍惚,才发现自己已经走了这么远。

但好在最终结果是好的,项目也成功做出来了,于是这篇博客,就来详细讲讲这个项目。

对了,完整代码我都传到码云上了,链接在文末。

下面我们正式开始!

运行效果

一、项目介绍

一)需求介绍

作为一名 Java 程序员,在开发过程中查看官方的 API 文档,几乎是不可避免,同时频率也不算低的一件事。

但官方的 API 手册,查阅起来并不是特别方便,只能通过文件名来查找,而无法根据文件内容进行查找。

这时候,就需要一个 API 搜索引擎,来方便我们查询。

二)功能介绍

搜索引擎,顾名思义,它的功能,就是 "搜索” 。

查找用户输入的查询词,在哪些网页中出现过,或者出现过一部分,把结果展示到网页上,点击结果就能跳转到该页面。

三)实现思路

其实呢,我们搜索引擎的每一个搜索结果,都是一个 HTML 界面,如下图。


如果每一次搜索,我们都是暴力遍历所有 HTML,然后从中查询是否包含查询词,效率就会非常低了,同时响应时间也会比较长,不利于用户使用。

这时候,我们就要用到一种名为 “倒排索引” 的数据结构了,通过它来把「查询词」和「文档 i d 列表」给连接起来。

关于倒排索引,以及后面出现的正排索引,都会在下文详细解释,这里大家主要先了解实现思路即可。


四)项目目标

实现一个基于 Java API 文档的搜索引擎。

官方发布的这一份 API 手册,其实有两个版本,一个需线上使用,一个是离线版本。

而如果选择线上版本,优势就是节省了三百多 MB 的本地空间,却牺牲了运行效率,每次程序运行都要通过「爬虫」获取文档。

当然还有一个原因就是,博主本人的技术有限,暂时还不能很好地掌握爬虫这门技术~

于是乎,我们的项目就选择基于离线版本的 API 开发手册,来进行搭建。


二、前期准备

一)了解正排索引

‌正排索引是一种存储文档中特定字段对应值的索引,它从 文档 指向 关键词,记录了每个关键词在文档中出现的次数和位置信息,方便我们通过文档 ID 快速查到对应的内容。‌

举个简单的例子

文档 ID文档内容
1马斯克创立了 SpaceX
2马斯克创立了特斯拉

根据文档 ID 1,我们能很快地查询结果 “马斯克创立了 SpaceX” 这部分内容;

而根据 文档 ID 2,我们能查询到的是 “马斯克创立了特斯拉”。

综上,这样通过 ID 查找到 内容 的结构,就被我们叫做 “正排索引”。


二)了解倒排索引

‌倒排索引的逻辑,则和正排索引不太一样,但在我看来,这两者并不是「相反」的关系,而是并列。

倒排索引,指的是从 词 到 文档 ID 列表 的映射关系。‌

老样子,上例子

出现过该词的文档 ID
马斯克1、 2
创立1、 2
1、 2
SpaceX1
特斯拉2

根据 查询词,我们就能查询到那些文档出现过这个词,并拿到他们的文档 ID,这就是倒排索引。


三)获取 Java API 开发文档

前文说过,我们用的是把开发文档下载到本地,这种方式省去了爬虫,不过我们需要去 Java 官网下载文档。

官网下载链接:Java Development Kit 8 Documentation (oracle.com)

按照我图中箭头的指示,点击下载

然后我们把下载后的文件解压,放到一个纯英文路径,方便后续调用。

找到 Arrays.html,和官方在线文档对比一下,页面都是一样的。

唯一不同就是路径了,所以实现思路来了:

通过本地文档来制作正排索引和倒排索引,然后利用字符串拼接,让程序能自动跳转到在线文档路径。


四)了解分词

我们知道,用户输入的,可能不单单是一个词,也可能是一个短语,可能是一段描述性语句。那这个时候就需要程序能进行分词了。

所谓分词,就是把一个句子分成多个词,方便去我们的索引里查找这些词,看有哪些文档里有这个词,然后加入到结果列表。

举个例子:

分词前:马斯克创立 SpaceX

分词后:马斯克 / 创立 / SpaceX

至于短语,本身就含有空格,不需要我们在进行分词了,可以直接查找。

关于分词功能的实现,我们可以通过 pom.xml 文件导入分词库依赖的方式来实现。


五)功能划分

1、索引模块

这部分的作用是扫描本地文档,分析其 文档 ID、标题、内容,进而构造出正排、倒排索引,并把索引内容保存下来,免去每次启动都要加载的麻烦。

2、搜索模块

通过用户输入的查询词,返回标题、简要描述、展示 URL,以及能够点击跳转。

3、Web 模块

实现前端页面,以及前后端交互。


三、项目实操

一)实现分词功能

1、创建项目

这里我们先使用 Maven 创建项目,后面会再修改为 Spring Boot 项目;


2、导入分词依赖

第三方分词库很多,我这里选择的是下面这个库,把代码复制到 pom.xml 中的 <dependencies> 标签中就行了,复制完记得刷新一下 maven 哦。

        <!-- https://mvnrepository.com/artifact/org.ansj/ansj_seg -->
        <dependency>
            <groupId>org.ansj</groupId>
            <artifactId>ansj_seg</artifactId>
            <version>5.1.6</version>
        </dependency>

3、测试分词效果

这里我在 test 包下,创建了一个测试分词效果的方法 TestAnsj:

具体代码如下:

import org.ansj.domain.Term;
import org.ansj.splitWord.analysis.ToAnalysis;

import java.util.List;

public class TestAnsj {
    public static void main(String[] args) {
        String str = "我在广州读书";
        List<Term> terms = ToAnalysis.parse(str).getTerms();
        for (Term term : terms) {
            System.out.println(term.getName());
        }
    }
}

而由于我们用到了 Ansj 依赖中的 parse 方法,所以我们得看看这个方法的功能以及返回值。

通过按住 ctrl 键,鼠标点击 parseStr 方法,就能进入到该方法的源码上。

同样按住 ctrl 点击 parseStr:

重复几次上述操作,我们就来到了 Result 方法,也就是我们的返回值类型了,可以看到这里用的是 terms 类型。

而这个类型是一个类似 List 的集合,所以我们需要用 getTerms() 这个方法来获取,返回类型为 List。

在控制台我们可以看到,分词结果是没错的。

不过一些比较偏僻的词,那我们用的第三方库,也有可能因为没有收录,而出现分词不精确的情况,但大部分情况是够用的。

同时,大小写问题,在这里也不会存在,Ansj 会自动帮我们转成小写,这个搜索一下就可以看到啦~


二)实现解析模块

1、定义变量

我们需要通过这个类,实现正排索引和倒排索引的制作。

实现思路就是:

1️⃣根据从官网下载的开发手册所保存的路径,枚举出所有的 HTML 文件,这需要把路径中所有子目录中的文件全部获取到;

2️⃣针对上面罗列出的路径,打开文件,读取文件内容,并进行解析,进而构建出索引;

3️⃣把内存中构造好的索引数据结构,保存到指定的文件中,比如一个 txt 文件中。


第一点中,我们需要提供给程序的,也就是下面这一个路径:

第二点,我们需要把这个路径告知给程序,也就是定义一个 String:

第三点,我们暂时先不实现,故当前代码块如下:

public class Parser {
    private static final String INPUT_PATH = "D:/CODE/doc_searcher_index/jdk-8u411-docs-all/api";

    public  void run(){
        // 1. 根据 INPUT_PATH 路径,枚举出该路径中所有的 html 文件,而这一步的实现需要把所有字目录中的文件获取到
        // 2. 针对上面罗列出的文件的路径,依次打开文件并读取内容,然后进行解析和构建索引
        // 3.把在内存中构造好的索引数据结构,保存到指定的文件中



    }

    public static void main(String[] args) {
        //通过main方法来实现整个制作索引的过程
        Parser parser = new Parser();
        parser.run();
    }

}


2、实现递归枚举所有文件

package com.project.java_doc_searcher.searcher;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.IOException;
import java.util.ArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicLong;

public class Parser {

    private static final String INPUT_PATH = "D:/CODE/doc_searcher_index/jdk-8u411-docs-all/api";


    public void run(){
        // 整个 searcher.Parser 类的入口
        // 1. 根据 INPUT_PATH 路径,枚举出该路径中所有的 html 文件,而这一步的实现需要把所有字目录中的文件获取到
        ArrayList<File> fileList = new ArrayList<>();
        enumFile(INPUT_PATH, fileList);

//        System.out.println(fileList);
//        System.out.println("总文件数: " + fileList.size());
        // 2. 针对上面罗列出的文件的路径,依次打开文件并读取内容,然后进行解析和构建索引
        // 3.把在内存中构造好的索引数据结构,保存到指定的文件中

    }



    // 参数: 递归遍历起始目录、 递归得到的结果
    private void enumFile(String inputPath, ArrayList<File> fileList){
        File rootPath = new File(inputPath);

        // listFiles 能够获取到 rootPath 当前目录下所包含的文件/目录
        // 使用 listFiles 只能看到一级目录,而看不了子目录里的内容
        // 所以还需要借助递归,以实现查看子目录内容这个功能
        File[] files = rootPath.listFiles();
        for (File f: files) {
            // 根据 f 的类型,来判断是否要加入递归
            // f 为普通文件: 直接加入 fileList 结果中;
            // f 为目录: 递归调用 enumFile 这个方法,以进一步获取子目录内容
            if(f.isDirectory()){
                enumFile(f.getAbsolutePath(), fileList);
            }else{
                if(f.getAbsolutePath().endsWith(".html")){
                    fileList.add(f);
                }
            }
        }
    }

    //通过main方法来实现整个制作索引的过程    
    public static void main(String[] args) {
        Parser parser = new Parser();
        parser.run();
    }
}

这一步着重注意一下 enumFile 方法,使用了 listFiles 方法,用于获取目标路径下的当前目录的所有文件和子目录。

在遍历所有文件的过程中,判断其是目录还是文件,如果是目录,则继续递归,直到当前元素是文件为止;如果是文件,则加入到 fileList 这个列表中。

如果当前元素是文件的话,其实还不够,因为我们要的是 HTML 文件,所以这里还要加上 endsWith(".html") 这串代码来限定。


3、解析 HTML 

上一步我们已经成功拿到了开发手册中所有的 HTML 文件了,接下来我们就需要去解析这些文件,为的是拿到其中的标题、描述以及 URL 链接。

就像这样

有了目标后,实现思路也变得清晰起来了,这里我们要通过三个方法来分别拿到标题、描述以及 URL:

package com.project.java_doc_searcher.searcher;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.IOException;
import java.util.ArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicLong;

public class Parser {

    private static final String INPUT_PATH = "D:/CODE/doc_searcher_index/jdk-8u411-docs-all/api";


    public void run(){
        // 整个 searcher.Parser 类的入口
        // 1. 根据 INPUT_PATH 路径,枚举出该路径中所有的 html 文件,而这一步的实现需要把所有字目录中的文件获取到
        ArrayList<File> fileList = new ArrayList<>();
        enumFile(INPUT_PATH, fileList);

//        System.out.println(fileList);
//        System.out.println("总文件数: " + fileList.size());
        // 2. 针对上面罗列出的文件的路径,依次打开文件并读取内容,然后进行解析和构建索引
        // 3.把在内存中构造好的索引数据结构,保存到指定的文件中

    }

    //通过这个方法解析单个HTML文件
    private void parseHTML(File f) {
//        1. 解析出HTML标题
        String title  = parseTitle(f);
//        2. 解析出HTML对应的文章
        String url = parseUrl(f);
//        3. 解析出HTML对应的正文(有正文才有后续的描述)
        String content = parseContent(f);
    }

    private String parseContent(File f) {
    }

    private String parseUrl(File f) {
    }

    private String parseTitle(File f) {
    }


    // 参数: 递归遍历起始目录、 递归得到的结果
    private void enumFile(String inputPath, ArrayList<File> fileList){
        File rootPath = new File(inputPath);

        // listFiles 能够获取到 rootPath 当前目录下所包含的文件/目录
        // 使用 listFiles 只能看到一级目录,而看不了子目录里的内容
        // 所以还需要借助递归,以实现查看子目录内容这个功能
        File[] files = rootPath.listFiles();
        for (File f: files) {
            // 根据 f 的类型,来判断是否要加入递归
            // f 为普通文件: 直接加入 fileList 结果中;
            // f 为目录: 递归调用 enumFile 这个方法,以进一步获取子目录内容
            if(f.isDirectory()){
                enumFile(f.getAbsolutePath(), fileList);
            }else{
                if(f.getAbsolutePath().endsWith(".html")){
                    fileList.add(f);
                }
            }
        }
    }

    //通过main方法来实现整个制作索引的过程    
    public static void main(String[] args) {
        Parser parser = new Parser();
        parser.run();
    }
}

4、实现标题解析

想实现这一部分功能,我们得先了解下标题到底包含在 HTML 文件中的那一部分。这里我拿 Arrays.html 来举例,可以看到他的标题就在我箭头所标注的地方:

而这里的名字,其实和它的文件名是一样的:

那我们就直接获取文件名就好了,省去了写代码去文件里面拿名字的麻烦。

Ok,思路搞定,接下来就到了具体的代码实现,老样子,我还是先通过一个测试方法,使用了 getName 方法:

来看看控制台输出:

    private String parseTitle(File f) {
        String name = f.getName();
//      return name.substring(0, name.lastIndexOf("."));
        return name.substring(0, name.length() - ".html".length());
    }

,而不是单纯的文件名!所以还差一步,就是把文件后缀名 .html 给去掉。

这一步我提供两种类似的代码方案,不过本质都是借助 substring 方法来截取:

再看输出:

嗯,这个测试方法已经没有问题了,接下来我们就把代码搬到 Parser 类中


5、解析 URL 思路

据我所知,真正的搜索引擎所展示的 URL 和跳转的 URL 是不一样的,大概率需要先经过搜索引擎的服务器,然后再跳到目标页面的。

关于这一点,有相关技术大牛说,是搜索引擎需要收集用户的点击信息,了解用户的喜好,然后通过他们后台的用户画像,就可以给用户更加精准的推送了。同样道理,这就像赛马机制,可以减少一些低质量链接的展现量,提升用户的使用体验。

但在这里,我们的实现逻辑就不弄那么复杂了,做成直接跳转就行了。

好,那么我们还要思考一个问题:是要跳转到本地 HTML 页面,还是官网的 URL 链接呢?

先说结果,肯定是跳转到官网的链接,毕竟如果第一种方式的话,跳转链接是以我电脑为基础的,而不是在用户电脑。

不过,这种方式也不是完全没有意义,先来看看两种不同方式对应的 URL 吧:

D:\CODE\doc_searcher_index\jdk-8u411-docs-all\api\java\util\Arrays.html

https://docs.oracle.com/javase/8/docs/api//java/util/Arrays.html

我们可以观察到,后半部分的路径其实是一样的,于是,我们就可以通过字符串拼接,来获取到官网的跳转链接。


6、实现 URL 解析

怎么拼接呢?首先我们需要「官网 URL」的前半部分: "https://docs.oracle.com/javase/8/docs/api//java/util/",再拼接上文件名,注意这次要带后缀了。
具体代码实现,还是现实通过一个 test 方法来看看:

import java.io.File;

public class TestURL {
    private static final String INPUT_PATH = "D:/CODE/doc_searcher_index/jdk-8u411-docs-all/api";

    public static void main(String[] args) {
        File file = new File("D:\\CODE\\doc_searcher_index\\jdk-8u411-docs-all\\api\\java\\util\\ArrayList.html");
        //获取固定前缀:
        String part1 = "https://docs.oracle.com/javase/8/docs/api/";
        String part2 = file.getAbsolutePath().substring(INPUT_PATH.length());
        String result = part1 + part2;
        System.out.println(result);

    }
}

返回结果:

诶,这结果里的斜杠,一正一反看着有点不舒服呢。

但点是能点进的,并不影响跳转,因为浏览器会自动帮我们优化,把链接解析成正确链接。如果觉得不美观也可以调用 replace 方法来替代。

嗯,测试方法没问题,我们就把代码加进 Parser 类中

    private String parseUrl(File f) {
        String part1 = "https://docs.oracle.com/javase/8/docs/api/";
        String part2 = f.getAbsolutePath().substring(INPUT_PATH.length());
        return part1 + part2;
    }

7、解析描述思路

描述部分比较复杂的一点,就是去掉 HTML 文件的的各种标签,保留标签内的内容。

想要去除标签,我们需要依次遍历 HTML 中的字符,如果当前元素是 < ,说明遇到标签头了,不予拷贝;遇到 > 则在它的后一个元素开始拷贝。

这里又有一个问题,要是标签里头的内容带有 <、>该怎么办呢,这可不能不拷贝呀。

诶,不影响,HTML 约定了,内容中的 <> 分别使用 &lt; 或者 &gt; 来代替,就和转移字符一样。


8、实现描述解析

具体实现思路在上面已经讲过了,这里直接上代码:

    public String parseContent(File f) {

        // 先一个字符一个字符读取,以 < 和 > 来判断是否开始拷贝数据
        // 手动把缓冲区设置成 1M 大小
        try(BufferedReader bufferedReader = new BufferedReader(new FileReader(f), 1024 * 1024)){
            // 判断是否开始拷贝
            boolean isCopy = true;
            StringBuilder content = new StringBuilder();
            while (true) {
                // ret 返回值为 int, 而不是 char,
                // 主要是为了表示一些非法情况,
                // 比如读到了文件末尾,再继续读,就返回 -1
                int ret = bufferedReader.read();
                if (ret == -1){
                    // 表示文件读完了
                    break;
                }
                // 若结果不为 -1, 代表是一个合法字符
                char c = (char)ret;
                if(isCopy){
                    // 拷贝条件合法的情况下,遇到普通自负就拷贝到 StringBuilder 中
                    if(c == '<'){
                        isCopy = false;
                        continue;
                    }
                    if (c == '\n' || c == '\r'){
                        // 把换行替换成空格
                        c = ' ';
                    }
                    content.append(c);
                }else {
                    if (c == '>'){
                        isCopy = true;
                    }
                }
            }
            return content.toString();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return "";
    }

9、Parser 类小结

先来梳理一下前面实现的三个方法,分别对应哪部分:

当前部分的代码:

package com.project.java_doc_searcher.searcher;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.IOException;
import java.util.ArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicLong;

public class Parser {

    private static final String INPUT_PATH = "D:/CODE/doc_searcher_index/jdk-8u411-docs-all/api";


    private AtomicLong t1 = new AtomicLong(0);
    private AtomicLong t2 = new AtomicLong(0);

    // 单线程制作索引
    public void run(){
        // 整个 searcher.Parser 类的入口
        // 1. 根据 INPUT_PATH 路径,枚举出该路径中所有的 html 文件,而这一步的实现需要把所有字目录中的文件获取到
        ArrayList<File> fileList = new ArrayList<>();
        enumFile(INPUT_PATH, fileList);

        // 2. 针对上面罗列出的文件的路径,依次打开文件并读取内容,然后进行解析和构建索引
        for (File f : fileList){
            // 解析文件内容,得到文件标题、文件 URL 、文件正文
            parseHTML(f);
        }

        // 3.把在内存中构造好的索引数据结构,保存到指定的文件中

    }


    private void parseHTML(File f) {
        // 1.解析出 HTML 的文件标题
        String title = parseTitle(f);
        // 2.解析出 HTML 对应的 URL
        String url = parseUrl(f);
        // 3.解析出 HTML 的文件正文
        String content = parseContent(f);
        // 4、 把解析出来的信息加入到索引 index 中

    }

    private String parseTitle(File f) {
        String name = f.getName();
//      return name.substring(0, name.lastIndexOf("."));
        return name.substring(0, name.length() - ".html".length());
    }

    private String parseUrl(File f) {
        String part1 = "https://docs.oracle.com/javase/8/docs/api/";
        String part2 = f.getAbsolutePath().substring(INPUT_PATH.length());
        return part1 + part2;
    }

    public String parseContent(File f) {
        // 先一个字符一个字符读取,以 < 和 > 来判断是否开始拷贝数据
        // 手动把缓冲区设置成 1M 大小
        try(FileReader fileReader = new FileReader(f)){
            // 判断是否开始拷贝
            boolean isCopy = true;
            StringBuilder content = new StringBuilder();
            while (true) {
                // ret 返回值为 int, 而不是 char,
                // 主要是为了表示一些非法情况,
                // 比如读到了文件末尾,再继续读,就返回 -1
                int ret = fileReader.read();
                if (ret == -1){
                    // 表示文件读完了
                    break;
                }
                // 若结果不为 -1, 代表是一个合法字符
                char c = (char)ret;
                if(isCopy){
                    // 拷贝条件合法的情况下,遇到普通自负就拷贝到 StringBuilder 中
                    if(c == '<'){
                        isCopy = false;
                        continue;
                    }
                    if (c == '\n' || c == '\r'){
                        // 把换行替换成空格
                        c = ' ';
                    }
                    content.append(c);
                }else {
                    if (c == '>'){
                        isCopy = true;
                    }
                }
            }
            return content.toString();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return "";
    }



    // 参数: 递归遍历起始目录、 递归得到的结果
    private void enumFile(String inputPath, ArrayList<File> fileList){
        File rootPath = new File(inputPath);

        // listFiles 能够获取到 rootPath 当前目录下所包含的文件/目录
        // 使用 listFiles 只能看到一级目录,而看不了子目录里的内容
        // 所以还需要借助递归,以实现查看子目录内容这个功能
        File[] files = rootPath.listFiles();
        for (File f: files) {
            // 根据 f 的类型,来判断是否要加入递归
            // f 为普通文件: 直接加入 fileList 结果中;
            // f 为目录: 递归调用 enumFile 这个方法,以进一步获取子目录内容
            if(f.isDirectory()){
                enumFile(f.getAbsolutePath(), fileList);
            }else{
                if(f.getAbsolutePath().endsWith(".html")){
                    fileList.add(f);
                }
            }
        }
    }

    public static void main(String[] args) {
        Parser parser = new Parser();
        parser.run();
    }
}

三)实现 Index 类

1、定义变量

接下来我们就需要实现 Index 类了,这个类的功能是在内存中构造出索引结构,然后再把结果保存到一个 txt 文件中,方便每次查询。

//通过这个类在内存中来构造出索引结构
public class Index {

    //这个类需要提供的方法
    //1.给定一个docId ,在正排索引中,查询文档的详细信息
    public DocInfo getDocInfo(int docId){
        
        return null;
    }

    
    //2.给定一词,在倒排索引中,查哪些文档和这个文档词关联
    public List<Weight> getInverted(String term){
       
        return null;
    }

    //3.往索引中新增一个文档
    public void addDoc(String title,String url,String content){
        
    }

    //4.把内存中的索引结构保存到磁盘中
    public void save(){
        
    }

    //5.把磁盘中的索引数据加载到内存中
    public void load(){
        

    }
}

先来了解看看上面各项分别代表什么意思吧。


    //这个类需要提供的方法
    //1.给定一个docId ,在正排索引中,查询文档的详细信息
    public DocInfo getDocInfo(int docId){
        
        return null;
    }

getDocInfo 方法主要是通过文档 ID ,拿到文档的各项信息。

那么,去哪里拿呢?

这时候,我们就需要创建一个 DocId 类,用来定义和声明文档的各项信息,如 URL 、标题、描述等等,以便于便于 DocInfo 方法的获取。

package com.project.java_doc_searcher.searcher;

public class DocInfo {
    private int docId;
    private String title;
    private String url;
    private String content;

    public int getDocId() {
        return docId;
    }

    public void setDocId(int docId) {
        this.docId = docId;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public String getUrl() {
        return url;
    }

    public void setUrl(String url) {
        this.url = url;
    }

    public String getContent() {
        return content;
    }

    public void setContent(String content) {
        this.content = content;
    }
}

第二个方法 getInverted,为的是获取分词结果,然后拿去查询。

    //2.给定一词,在倒排索引中,查哪些文档和这个文档词关联
    public List<Weight> getInverted(String term){
       
        return null;
    }

而由于获取到的结果不止一个,是一个 List 列表,所以我们返回值要设置成 List<Weight>,这里的 Weight 则是每一个分词的权重、相关性,因为肯定是权重越高的结果,要放在更前面,让用户一下子就能搜到他大概率想要的结果,提升使用体验。

下面我们也需要创建一个 Weight 类,用于属性的声明。

// 这个类的作用是把 文档id 和 文档与词的相关性 权重 进行封装
public class Weight {
    private int docId;

    // weigh 表示 文档 和 词 之间的 “相关性”,也就是 “权重”
    // 值越大,则相关性越强
    private int weight;

    public int getDocId() {
        return docId;
    }

    public void setDocId(int docId) {
        this.docId = docId;
    }

    public int getWeight() {
        return weight;
    }

    public void setWeight(int weight) {
        this.weight = weight;
    }
}

Weight 值越大,代表这个搜索结果和用户查询词之间的相关性越大,我们就需要把这个链接放在更前的位置。


第三个方法,作用是获取一个新的文档,传值信息有他的标题、URL 和描述。我们从本地拿到文件后,就需要用到这个方法,把文档信息加入到索引中。

    //3.往索引中新增一个文档
    public void addDoc(String title,String url,String content){
        
    }

第四和第五个方法,其作用较为简单,已经在上面代码中写出,这里不做赘述。


2、声明索引结构

还记得前面我们提到的正排索引和倒排索引吗,接下来我们就要声明和实现这两个结构了。

首先来看正排索引,他的定义关系是从 文档 指向 关键词

因此,我们通过数组下标表示文档 ID,后面通过 forwardIndex.get(docId) 就可以通过 DocId 拿到对应的文档,而文档里面就有它的关键词了。

    // 正排索引:使用数组下标表示 docId
    private ArrayList<DocInfo> forwardIndex = new ArrayList<>();

而倒排索引表示的是 分词结果 到 文档 ID 列表 的映射关系。

所以,我们需要用一个 HashMap 来保存查询词,以及这个词的权重,这里可以结合我下面的注释体会一下构造思路。

    // 倒排索引:
    // 使用 HashMap 存储, 
    // key 表示分词结果, 
    // value 表示一个 List, 存储所有包含这个词的文档
    // 以及词与文档的相关性 Weight,方便后续排序
    private HashMap<String, ArrayList<Weight>> invertedIndex = new HashMap<>();

当前部分代码如下:

package com.project.java_doc_searcher.searcher;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

// 这个类的作用是把 文档id 和 文档与词的相关性 权重 进行封装
public class Weight {

    // 正排索引:使用数组下标表示 docId
    private ArrayList<DocInfo> forwardIndex = new ArrayList<>();

    // 倒排索引:
    // 使用 HashMap 存储, 
    // key 表示分词结果, 
    // value 表示一个 List, 存储所有包含这个词的文档
    private HashMap<String, ArrayList<Weight>> invertedIndex = new HashMap<>();

    private int docId;

    // weigh 表示 文档 和 词 之间的 “相关性”,也就是 “权重”
    // 值越大,则相关性越强
    private int weight;

    public int getDocId() {
        return docId;
    }

    public void setDocId(int docId) {
        this.docId = docId;
    }

    public int getWeight() {
        return weight;
    }

    public void setWeight(int weight) {
        this.weight = weight;
    }
}

3、实现正排索引

在前面我们已经实现了下面这部分代码,代表了我们所需要的文档信息。

    private void parseHTML(File f) {

        // 1.解析出 HTML 的文件标题
        String title = parseTitle(f);

        // 2.解析出 HTML 对应的 URL
        String url = parseUrl(f);

        // 3.解析出 HTML 的文件正文
        String content = parseContent(f);

        // 4、 把解析出来的信息加入到索引 index 中

    }

那么,我们现在就开始往索引里添加文档了,正排倒排都要。

    // 往索引中新增一个文档
    public void addDoc(String title, String url, String content) {

        // 新增文档操作,需要同时给正排索引和倒排索引新增
        // 构建正排索引
        DocInfo docInfo = buildForward(title, url, content);

        // 构建倒排索引
        buildInverted(docInfo);
    }

    // 构造正排索引
    private DocInfo buildForward(String title, String url, String content) {
        DocInfo docInfo =new DocInfo();

        docInfo.setDocId(forwardIndex.size());
        docInfo.setTitle(title);
        docInfo.setUrl(url);
        docInfo.setContent(content);

        forwardIndex.add(docInfo);
        return docInfo;

        }

这里其实就是把标题、URL、文档描述这三个参数传给 docInfo 这个变量里面,然后再把 docInfo传到正排索引 forwardIndex 里面,俗称把参数信息传给索引。


4、实现倒排索引

倒排索引,是 查询词 文档 ID 之间的映射,那我们可以通过一个 HashMap 来保存这样的信息,Key 就是查询词,使用 String 类型;Value 就是包含了 Key 的文档 ID,但由于不止一个,所以我们使用一个 List 来保存,同时,附带上和 Key 的相关性 Weight,方便我们后续的排序。

创建倒排索引之前,我们也已经创建好 Weight 类,用于声明和权重相关的各种变量。

而真实的开发中,这里的相关性一般都是由一个算法团队做的,条件所限,这里我选择的方式是一种逻辑较为简单的算法,即:文档的权重 = 查询词作为标题的出现次数 * 10 + 查询词在正文中出现的次数

所以,总结一下需要做的几步:

  1. 针对文档标题进行分词
  2. 遍历分词结果,统计每个词出现的次数
  3. 针对正文进行分词
  4. 遍历分词结果,统计每个词出现的次数
  5. 把上面的结果汇总到 HasMap 里面

由此可以得出,我们需要实现一个功能,用于统计词频。

    private void buildInverted(DocInfo docInfo) {
        class WordCnt {
            // 这个词在标题出现的次数:
            public int titleCount;

            // 这个词在正文出现的次数:
            public int contentCount;
        }

        // 通过一个 HashMap 来统计词频
        HashMap<String, WordCnt> wordCntHashMap = new HashMap<>();

        // 1、 针对文档标题进行分词
        List<Term> terms = ToAnalysis.parse(docInfo.getTitle()).getTerms();

        // 2、 遍历分词结果,统计每个词出现的次数
        for (Term term : terms) {
            // 先判定 term 是否存在
            String word = term.getName();
            WordCnt wordCnt = wordCntHashMap.get(word);
            if (wordCnt == null) {
                // 如果不存在,就创建一个新的键值对
                // 并插入进哈希表 wordCntHashMap ,titleCount 设为 1

                WordCnt newWordCnt = new WordCnt();
                newWordCnt.titleCount = 1;
                newWordCnt.contentCount = 0;
                wordCntHashMap.put(word, newWordCnt);
            } else {
                // 如果存在, 就找到之前的值, 然后把对应的 titleCount + 1
                wordCnt.titleCount += 1;
            }
        }

        // 3、 针对正文页进行分词
        terms = ToAnalysis.parse(docInfo.getContent()).getTerms();
        // 4、 遍历分词结果,统计每个词出现的次数
        for (Term term : terms) {
            String word = term.getName();
            WordCnt wordCnt = wordCntHashMap.get(word);
            if (wordCnt == null) {
                WordCnt newWordCnt = new WordCnt();
                newWordCnt.titleCount = 0;
                newWordCnt.contentCount = 1;
                wordCntHashMap.put(word, newWordCnt);
            } else {
                wordCnt.contentCount += 1;
            }
        }
        // 5、 把上面结果汇总到一个 HashMap 里面
        // 最终文档的权重,设定为 标题出现次数 * 10 + 正文出现次数
        // 6、 遍历这个 HashMap, 依次更新倒排索引中的结构

    }

这里我花了一张关于倒排索引实现逻辑的图,建议结合上面代码一起理解:

综上,目前部分倒排索引代码如下,不过依旧有很多地方可以继续优化的。

    private void buildInverted(DocInfo docInfo) {
        class WordCnt {
            // 这个词在标题出现的次数:
            public int titleCount;

            // 这个词在正文出现的次数:
            public int contentCount;
        }

        // 通过一个 HashMap 来统计词频
        HashMap<String, WordCnt> wordCntHashMap = new HashMap<>();

        // 1、 针对文档标题进行分词
        List<Term> terms = ToAnalysis.parse(docInfo.getTitle()).getTerms();

        // 2、 遍历分词结果,统计每个词出现的次数
        for (Term term : terms) {
            // 先判定 term 是否存在
            String word = term.getName();
            WordCnt wordCnt = wordCntHashMap.get(word);
            if (wordCnt == null) {
                // 如果不存在,就创建一个新的键值对
                // 并插入进哈希表 wordCntHashMap ,titleCount 设为 1

                WordCnt newWordCnt = new WordCnt();
                newWordCnt.titleCount = 1;
                newWordCnt.contentCount = 0;
                wordCntHashMap.put(word, newWordCnt);
            } else {
                // 如果存在, 就找到之前的值, 然后把对应的 titleCount + 1
                wordCnt.titleCount += 1;
            }
        }

        // 3、 针对正文页进行分词
        terms = ToAnalysis.parse(docInfo.getContent()).getTerms();
        // 4、 遍历分词结果,统计每个词出现的次数
        for (Term term : terms) {
            String word = term.getName();
            WordCnt wordCnt = wordCntHashMap.get(word);
            if (wordCnt == null) {
                WordCnt newWordCnt = new WordCnt();
                newWordCnt.titleCount = 0;
                newWordCnt.contentCount = 1;
                wordCntHashMap.put(word, newWordCnt);
            } else {
                wordCnt.contentCount += 1;
            }
        }

        // 5、 把上面结果汇总到一个 HashMap 里面
        // 最终文档的权重,设定为 标题出现次数 * 10 + 正文出现次数
        // 6、 遍历这个 HashMap, 依次更新倒排索引中的结构
        for (Map.Entry<String, WordCnt> entry : wordCntHashMap.entrySet()) {
            // 先根据当前遍历到的词,去倒排索引中查一查,看是否已经存在
            // 倒排拉链
            List<Weight> invertedList = invertedIndex.get(entry.getKey());
            if (invertedList == null) {
                // 如果为空,就插入一个新的键值对
                ArrayList<Weight> newInvertedList = new ArrayList<>();
                // 把新的文档,也就是当前的 searcher.DocInfo,构造成 searcher.Weight 对象,插入进来
                Weight weight = new Weight();
                weight.setDocId(docInfo.getDocId());
                weight.setWeight(entry.getValue().titleCount * 10 + entry.getValue().contentCount);
                newInvertedList.add(weight);
                invertedIndex.put(entry.getKey(), newInvertedList);
            } else {
                // 如果非空,就把当前这个文档,构造出一个 weight 对象,插入到刚刚倒排拉链的后面
                Weight weight = new Weight();
                weight.setDocId(docInfo.getDocId());
                weight.setWeight(entry.getValue().titleCount * 10 + entry.getValue().contentCount);
                invertedList.add(weight);
            }
        }
    }

在上述代码注释中的第 6 点,有一个 Map.Entry 值得理解一下。

不是所有结构都能通过 for 循环遍历的,只有这个对象是”可迭代的“,实现Iterable 接口才可以。比如,Map 就不支持遍历,因为他是一个键值对,你让它遍历 Key 还是 Value 嘛!而我们的需求是两个都要一起遍历,那咋办呢?

这时候就要使用 Map.Entry 了,把 Map 转换成 Set 以进行遍历。Set 就可以被遍历,因为它实现了 Iterable 接口,并且还把 Key 和 Value 都存下来了。

OK,回到代码,如果我们遍历到一个还没创建索引的新词,也就是 invertedList 为空的时候,那么就创建一个新的 ArrayList 容器,存放它的文档 ID 和权重大小。

if (invertedList == null) {
    // 如果为空,就插入一个新的键值对
    ArrayList<Weight> newInvertedList = new ArrayList<>();
    // 把新的文档,也就是当前的 searcher.DocInfo,构造成 searcher.Weight 对象,插入进来
    Weight weight = new Weight();
    weight.setDocId(docInfo.getDocId());
    weight.setWeight(entry.getValue().titleCount * 10 + entry.getValue().contentCount);
    newInvertedList.add(weight);
    invertedIndex.put(entry.getKey(), newInvertedList);
}

如果 invertedList 不为空,那把 DocId 和权重设置好即可

else {
    // 如果非空,就把当前这个文档,构造出一个 weight 对象,插入到刚刚倒排拉链的后面
    Weight weight = new Weight();
    weight.setDocId(docInfo.getDocId());
    weight.setWeight(entry.getValue().titleCount * 10 + entry.getValue().contentCount);
    invertedList.add(weight);
}

5、保存索引

索引制作完毕后,我们是不是可以把结果保存下来呢?毕竟文档 ID 、内容、URL 和分词结果都是固定不变的,如果每次都要重新制作索引,那也太浪费时间和系统资源了吧!

诶~解决方法前文我也不小心透露了出来,就是把索引记录在一个 txt 文件中,要用的时候去这个 txt 文件中读取就好了。

通常的做法是,把这些相对耗时的操作,拎出来单独执行,执行完了之后,让服务器去加载这个构建好的索引。

那么如何保存呢?

现在我们在内存中的索引结构,想要保存下来,就需要把他转化成字符串,然后通过文件 IO 写入 txt 即可。

此处有一个小知识点:把内存中的索引结构,变成一个字符串,这个过程叫做序列化;而对应的把特定的结果的字符串,反向解析成一些结构化数据(类、对象,基础数据结构)可叫做反序列化。

这里我们就使用 Json 格式来进行序列化操作,通过导入 jackson 这个库来实现序列化和反序列化即可。

大家可以直接复制我这段代码,粘贴到自己的 pom.xml 文件中。

        <!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-databind -->
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
            <version>2.17.2</version>
        </dependency>

同时还要在 Index 类中创建一个 ObjectMapper 类型的实例,这个类型是专门用于处理 Java 对象与 JSON 之间的转换的,序列化和反序列化都要用到它。

    private ObjectMapper objectMapper = new ObjectMapper();

同时再提供一个路径,是索引的本地存放链接。

            INDEX_PATH = "D:\\CODE\\doc_searcher_index\\";

下面则是保存方法的代码,可以记录下开始时间和结束时间,看看制作索引花费了多少时间:

    // 4、把内存中的索引结构保存到磁盘中
    public void save() {
        // 使用两个文件,分别保存正派索引和倒排索引
        long beg = System.currentTimeMillis();
        System.out.println("保存索引开始!");
        // 1、先判断索引对应的目录是否存在,不存在则创建
        File indexPathFile = new File(INDEX_PATH);
        if (!indexPathFile.exists()) {
            indexPathFile.mkdirs();
        }
        File forwardIndexFile = new File(INDEX_PATH + "forward.txt");
        File invertedIndexFile = new File(INDEX_PATH + "inverted.txt");
        try {
            objectMapper.writeValue(forwardIndexFile, forwardIndex);
            objectMapper.writeValue(invertedIndexFile, invertedIndex);
        } catch (IOException e) {
            e.printStackTrace();
        }
        long end = System.currentTimeMillis();
        System.out.println("保存索引完成! 消耗时间:" + (end - beg) + "ms");
    }

6、调用索引

前面制作的 Parser 类,其主要作用是解析、遍历文件等,那么我们就需要让他和索引制作类 Index 交互起来,以实现索引的制作。

直接在 Parser 中新建一个 Index 实例:

    // 创建一个 Index 实例
    private  Index index = new Index();

然后再调用 index.save() ,就能把构造好的索引,保存到指定路径中。

接下来,就可以运行 Parser 的 main 方法了,让我们一起看看索引的制作吧!

控制台运行结果如下:

打开我们设置的保存路径,可以发现 inverted.txt,就是我们保存的索引文本。

打开来后是这样的,分词和文档 ID、权重 Weight 都是保存下来了的:

但因为没加入换行符,都挤在一行,影响我们阅读了,但是主要目的是让程序读取的,所以这一点其实无所谓。

7、优化索引制作

可以看到,索引制作其实还是挺耗费时间的,287584 毫秒,快五分钟了都。

而我们可以发现,时间大都花在了枚举文件上,索引制作其实只花了 0.7 秒,占小头部分而已。

针对这个问题,思路很简单,就是通过 多线程,以尽可能地避免任务的串行执行

所以,也就有了以下的多线程代码:

    // 多线程制作索引
    public void runByThread() throws InterruptedException {
        long beg = System.currentTimeMillis();
        System.out.println("索引制作开始!");

        // 1、枚举出所有文件
        ArrayList<File> files = new ArrayList<>();
        enumFile(INPUT_PATH, files);

        // 2、循环遍历文件,同时为了能够通过多线程制作索引,这里直接引入线程池
        CountDownLatch latch = new CountDownLatch(files.size());
        ExecutorService executorService = Executors.newFixedThreadPool(6);
        for (File f : files){
            executorService.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println("解析" + f.getAbsolutePath());
                    parseHTML(f);
                    latch.countDown();
                }
            });
        }

        // await 方法会阻塞,直到所有线程都执行完毕,才阻塞结束
        latch.await();
        // 手动把线程池里的线程都关掉
        executorService.shutdown();
        // 3、保存索引
        index.save();

        long end = System.currentTimeMillis();
        System.out.println("索引文件制作完毕!消耗时间:" + (end - beg) + "ms");
        System.out.println("t1:" + t1 + "t2:" + t2);
    }

我这里引入了线程池,大小为 6 个线程。

同时,有个小重点需要注意:由于是多线程,旧线程在还没执行完任务的时候,新线程就开始了,导致旧线程的进度没有更新就被新线程拿去用了,得到的将会是一个不完整的索引文件,俗称有线程安全问题的存在。

因此,我们需要利用 CountDownLatch 这个同步辅助类,通过它的 countDown() 方法记录多少任务尚未完成,等所有进程都完成之后再 lathc.await() 方法来结束阻塞。


当然了,涉及多线程的问题时,免不了考虑加锁问题,我们一步步来观察代码,首先从 run 方法看起,他调用了 parseHTML 方法。

再按住 ctrl 键点击 parseHTML 点进去这个方法看。

线程安全问题,一般都涉及操作公共对象,而这个方法里面,只有 addDoc 这个方法有所涉及到。

可以看到,正排索引这两行代码,都设计了操作公共对象:

于是我们直接加锁:

同样的,倒排索引中也有同样的操作:

老样子,直接上锁:

当然,我上面代码中,synchronized 里面的参数,是 locker1 和 locker2,这里的设计有点小细节。

给 synchronized 传参 this 其实是比较常见的,但在这里,如果加上 this,意味着正排索引和倒排索引的对象都不能同时进行,这很明显不是我们想。

我们真正想要的是正排索引和倒排索引可以同时进行,但正排索引和正排索引之间,则需要等上一个进程结束后,再开始下一个;倒排索引和倒排索引之间也是同理。

于是,我便在 Index 类中新建了两个锁对象,通过调用两个不同的锁对象,实现正排索引和正排索引之间、倒排索引和倒排索引之间的阻塞,然后把 locker1 和 locker2 分别传给正排索引和倒排索引:

 另外,我们在前面构建了 6 个线程池,到程序结束后,是要把这些线程关掉的。养成好习惯,避免在实际开发中造成资源浪费。

关闭方法是在 Parser 类中的 await 方法后面,加上下面代码:

OK,那我们现在就来运行一下程序,看看多线程能提升多少效率:

嗯,对比起前面确实有提升,但也不是说一下子就节省了很大。

然后,有意思的来了,我们看看第二次运行时,变化有多大!


为什么会出现这样的情况呢?

这其实涉及到的是 缓存 这一概念,第一次读取文件时,系统是从硬盘一个个文件读取到的。第二次则是去内存中的缓存读取的。而在操作系统的专业课中,我们可以知道,内存读取速度,是比硬盘要快的,所以造成了这样的结果~

诶,既然如此,那我们怎么不直接通过程序,把文件都写到缓存里,然后让程序去读缓存,而不是读硬盘中的文件呢?

说干就干,我们在 Parser 这个解析类中,找到了用于读取文件的 fileReader ,把他修改成 bufferedReader ,同时抛出异常的地方也得进行修改,定义出一个 1024 * 1024 的缓冲区,也就是 1KB 大。

嗯,总归是越优化,速度越快了:

忽然觉得编程真的很神奇昂,有很多种方法都能达到目的,这种向上探索的过程真的有意思。

8、Index 类小结

OK,直到现在,Parser 类已经能初步完成我们解析文件的功能了,而 Index 类也能实现我们制作索引的功能。

  • 构造正排索引,先构造 docInfo 对象,然后直接传参过去即可,标题、URL、描述一并传参
  • 构造倒排索引,先分好词,然后遍历分词结果,更新倒排拉链,看是否存在,新增或修改词频
  • 查正排索引,直接用 get 方法拿到 ArrayList 中的元素即可
  • 查倒排索引,通过 Key 拿到查询词后,去查询 HashMap 中的 Value 即可
  • 用多线程提升效率时,也要注意线程安全问题
  • 通过缓存提升程序读取速度
  • 最后把索引保存到一个 txt 文件中

OK,到目前为止,Index 类代码如下:

package com.project.java_doc_searcher.searcher;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.ansj.domain.Term;
import org.ansj.splitWord.analysis.ToAnalysis;

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

// 通过这个类在内存中构造出索引结构
public class Index {

    private ObjectMapper objectMapper = new ObjectMapper();

    private static String INDEX_PATH = null;

    static {
        if (Config.isOnline) {
            INDEX_PATH = "/root/install/doc_searcher_index/";
        } else {
            INDEX_PATH = "D:\\CODE\\doc_searcher_index\\";
        }
    }

    // 正排索引:使用数组下标表示 docId
    private ArrayList<DocInfo> forwardIndex = new ArrayList<>();

    // 倒排索引:使用 HashMap 存储, key 表示词, value 表示一个 List, 存储所有包含这个词的文档
    private HashMap<String, ArrayList<Weight>> invertedIndex = new HashMap<>();

    // 新建两个锁对象
    private Object locker1 = new Object();
    private Object locker2 = new Object();

    // 这个类所有提供的方法:
    // 1、给定一个 docId, 在正排索引中,查询文档的详细信息
    public DocInfo getDocInfo(int docId) {
        return forwardIndex.get(docId);
    }

    // 2、给定一个词,在倒排索引中,查哪些文档和这个词关联
    // 返回值设计:应该是 List<>, 而不是单纯的 List
    // 因为词和文档之间是存在一定的 “相关性” 的
    public List<Weight> getInverted(String term) {
        return invertedIndex.get(term);
    }

    // 3、往索引中新增一个文档
    public void addDoc(String title, String url, String content) {
        // 新增文档操作,需要同时给正排索引和倒排索引新增

        // 构建正排索引
        DocInfo docInfo = buildForward(title, url, content);

        // 构建倒排索引
        buildInverted(docInfo);
    }

    private void buildInverted(DocInfo docInfo) {
        class WordCnt {
            // 这个词在标题出现的次数:
            public int titleCount;

            // 这个词在正文出现的次数:
            public int contentCount;
        }

        // 通过一个 HashMap 来统计词频
        HashMap<String, WordCnt> wordCntHashMap = new HashMap<>();

        // 1、 针对文档标题进行分词
        List<Term> terms = ToAnalysis.parse(docInfo.getTitle()).getTerms();

        // 2、 遍历分词结果,统计每个词出现的次数
        for (Term term : terms) {
            // 先判定 term 是否存在
            String word = term.getName();
            WordCnt wordCnt = wordCntHashMap.get(word);
            if (wordCnt == null) {
                // 如果不存在,就创建一个新的键值对
                // 并插入进哈希表 wordCntHashMap ,titleCount 设为 1

                WordCnt newWordCnt = new WordCnt();
                newWordCnt.titleCount = 1;
                newWordCnt.contentCount = 0;
                wordCntHashMap.put(word, newWordCnt);
            } else {
                // 如果存在, 就找到之前的值, 然后把对应的 titleCount + 1
                wordCnt.titleCount += 1;
            }
        }

        // 3、 针对正文页进行分词
        terms = ToAnalysis.parse(docInfo.getContent()).getTerms();
        // 4、 遍历分词结果,统计每个词出现的次数
        for (Term term : terms) {
            String word = term.getName();
            WordCnt wordCnt = wordCntHashMap.get(word);
            if (wordCnt == null) {
                WordCnt newWordCnt = new WordCnt();
                newWordCnt.titleCount = 0;
                newWordCnt.contentCount = 1;
                wordCntHashMap.put(word, newWordCnt);
            } else {
                wordCnt.contentCount += 1;
            }
        }
        // 5、 把上面结果汇总到一个 HashMap 里面
        // 最终文档的权重,设定为 标题出现次数 * 10 + 正文出现次数
        // 6、 遍历这个 HashMap, 依次更新倒排索引中的结构
        for (Map.Entry<String, WordCnt> entry : wordCntHashMap.entrySet()) {
            // 先根据当前遍历到的词,去倒排索引中查一查,看是否已经存在
            // 倒排拉链
            synchronized (locker1){
                List<Weight> invertedList = invertedIndex.get(entry.getKey());
                if (invertedList == null) {
                    // 如果为空,就插入一个新的键值对
                    ArrayList<Weight> newInvertedList = new ArrayList<>();
                    // 把新的文档,也就是当前的 searcher.DocInfo,构造成 searcher.Weight 对象,插入进来
                    Weight weight = new Weight();
                    weight.setDocId(docInfo.getDocId());
                    weight.setWeight(entry.getValue().titleCount * 10 + entry.getValue().contentCount);
                    newInvertedList.add(weight);
                    invertedIndex.put(entry.getKey(), newInvertedList);
                } else {
                    // 如果非空,就把当前这个文档,构造出一个 weight 对象,插入到刚刚倒排拉链的后面
                    Weight weight = new Weight();
                    weight.setDocId(docInfo.getDocId());
                    weight.setWeight(entry.getValue().titleCount * 10 + entry.getValue().contentCount);
                    invertedList.add(weight);
                }
            }
        }
    }



    private DocInfo buildForward(String title, String url, String content) {
        DocInfo docInfo = new DocInfo();
        docInfo.setTitle(title);
        docInfo.setUrl(url);
        docInfo.setContent(content);
        synchronized (locker2){
            docInfo.setDocId(forwardIndex.size());
            forwardIndex.add(docInfo);
        }
        return docInfo;
    }

    // 4、把内存中的索引结构保存到磁盘中
    public void save() {
        // 使用两个文件,分别保存正派索引和倒排索引
        long beg = System.currentTimeMillis();
        System.out.println("保存索引开始!");
        // 1、先判断索引对应的目录是否存在,不存在则创建
        File indexPathFile = new File(INDEX_PATH);
        if (!indexPathFile.exists()) {
            indexPathFile.mkdirs();
        }
        File forwardIndexFile = new File(INDEX_PATH + "forward.txt");
        File invertedIndexFile = new File(INDEX_PATH + "inverted.txt");
        try {
            objectMapper.writeValue(forwardIndexFile, forwardIndex);
            objectMapper.writeValue(invertedIndexFile, invertedIndex);
        } catch (IOException e) {
            e.printStackTrace();
        }
        long end = System.currentTimeMillis();
        System.out.println("保存索引完成! 消耗时间:" + (end - beg) + "ms");
    }

    // 5、把磁盘中的索引结构加载到内存中
    public void load(){
        long beg = System.currentTimeMillis();
        System.out.println("加载索引开始!");
        // 1、先设置加载索引的路径
        File forwardIndexFile = new File(INDEX_PATH + "forward.txt");
        File invertedIndexFile = new File(INDEX_PATH + "inverted.txt");
        try {
            forwardIndex = objectMapper.readValue(forwardIndexFile, new TypeReference<ArrayList<DocInfo>>() {});
            invertedIndex = objectMapper.readValue(invertedIndexFile, new TypeReference<HashMap<String, ArrayList<Weight>>>() {});
        } catch (IOException e) {
            e.printStackTrace();
        }
        long end = System.currentTimeMillis();
        System.out.println("加载索引结束!消耗时间:" + (end - beg) + "ms");
    }

    public static void main(String[] args) {
        Index index = new Index();
        index.load();
        System.out.println("索引加载完成!");
    }
}

四)实现 DocSearcher 类

DocSearch 属于搜索模块的实现类了,下面我们先来梳理一下搜索的实现流程:

1️⃣给用户输入的查询词进行分词;

2️⃣根据分词结果,去倒排索引中查找,拿到与之相关的文档所对应的文档 ID;

3️⃣根据结果的相关性,进行排序;

4️⃣封装结果,每个结果包含正文、跳转 URL、描述。

1、定义变量

下面,我们先创建好 DocSearcher 类,定义一些基本的变量:

import java.util.List;

public class DocSearcher {

    private Index index = new Index();

    public DocSearcher(){
        index.load();
    }

    // 完成整个搜索过程的方法
    // 参数(输入部分)就是用户给出的查询词
    // 返回值(输出部分)就是搜索结果的集合
    public List<Result>  search(String query){

        // 1.[分词]针对query这个查询词进行分词
        // 2.[触发]针对分词结果来查倒排
        // 3.[排序]针对触发的结果按照权重降序排序
        // 4.[包装结果]针对排序的结果,去查正排,构造出要返回的数据

        return null;
    }
}

而上边 search 方法的返回值大家也注意到了吧,是一个 List<Result> 类型,参数 Result 代表的就是我上面实现流程里第 4️⃣ 点所说的,通过一个 Result 类,对结果的正文、标题、URL 进行封装。而查询词基本都会有多个结果,所以还需要在外面套一层 List,返回结果列表。

下面,我们也需要创建这个 Result 类:

package com.project.java_doc_searcher.searcher;

// 这个类表示搜索结果
public class Result {
    private String title;
    private String url;

    // 描述正文的摘要
    private String desc;

    public String getTitle() {
        return title;
    }

    @Override
    public String toString() {
        return "searcher.Result{" +
                "title='" + title + '\'' +
                ", url='" + url + '\'' +
                ", desc='" + desc + '\'' +
                '}';
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public String getUrl() {
        return url;
    }

    public void setUrl(String url) {
        this.url = url;
    }

    public String getDesc() {
        return desc;
    }

    public void setDesc(String desc) {
        this.desc = desc;
    }
}

2、实现 search 方法

上面准备工作做完之后,我们就要来实现 search 方法了,它的四个功能在前文已有提及,这里就直接上代码了:

    // 完成整个搜索过程的方法
    // 参数(输入部分)就是用户给出的查询词
    // 返回值(输出部分)就是搜索结果的集合
    public List<Result> search(String query){
        // 1、 [分词] 针对 query 这个查询词进行分词
        List<Term> terms= ToAnalysis.parse(query).getTerms();



        // 2、 [触发] 针对分词结果来查倒排
        List<List<Weight>> termResult = new ArrayList<>();
        for (Term term : terms) {
            String word = term.getName();
            // 倒排索引中的词,都是之前文档中存在的
            List<Weight> invertedList = index.getInverted(word);
            if(invertedList == null){
                // 说明这个词在所有文档中都不存在
                continue;
            }
            termResult.add(invertedList);
        }

        // 3、[排序] 针对出发的结果按照权重降序进行排序
        allTermResult.sort(new Comparator<Weight>() {
            @Override
            public int compare(Weight o1, Weight o2) {
                // 升序:return o1.getWeight() - o2.getWeight();
                // 降序:return o2.getWeight() - o1.getWeight();
                return o2.getWeight() - o1.getWeight();
            }
        });

        // 4、[包装结果] 针对排序的结果,去查正排,构造出要返回的数据
        List<Result> results = new ArrayList<>();
        for (Weight weight : allTermResult) {
            DocInfo docInfo = index.getDocInfo(weight.getDocId());
            Result result = new Result();
            result.setTitle(docInfo.getTitle());
            result.setUrl(docInfo.getUrl());
            result.setDesc(GenDesc(docInfo.getContent(), terms));
            results.add(result);
        }
        return results;
    }

在第 4 步中,我们通过 getContent 方法拿到的是返回文档的简介描述,但目前还没有实现文档的描述部分,所以下一部分我们就来实现这个功能~


3、实现文档描述部分

我们先观察一下这张图中的 “描述” 部分,寻找一下制作思路:

可以总结出两点:

1️⃣描述部分如果包含查询词,在前端页面显示时需标红;

2️⃣末尾部分为 … ;

第一点等到后面前端部分咱们再来介绍,这里主要是第二点,引出了我们的设计思路:

使用 全字匹配 进行模糊匹配,看文档中哪部分内容包含了查询词,我们就以此为中心,分情况进行截取,作为描述内容。

    private String GenDesc(String content, List<Term> terms) {
        // 先遍历分词结果,看看哪个结果是在 content 中存在
        int firstPos = -1;
        for (Term term : terms) {
            // 由于分词库会直接把词进行转小写
            // 所以我们也必须把正文转成小写,然后再查询
            String word = term.getName();
            // 这里需要 “全字匹配”,让 word 能够独立成词,才能查找出来,而不是只作为词的一部分
            // 此处全字符匹配的实现并不是十分严谨,优化逻辑是使用正则表达式
            firstPos = content.toLowerCase().indexOf(" " + word + " ");
            if (firstPos >= 0) {
                // 找到位置了
                break;
            }
        }

        if (firstPos == -1) {
            // 所有的分词结果都不在正文中存在,这属于比较极端的情况了
            // 此时直接返回空串,或者取正文前 160 个字符也行
            if (content.length() < 160) {
                return content.substring(0, 160) + "...";
            }
            return content;
        }

        // 从 firstPos 作为基准位置,往前找 60 个字符,作为描述的起始位置
        String desc = "";
        int descBeg = firstPos < 60 ? 0 : firstPos - 60;
        if (descBeg + 160 > content.length()) {
            desc = content.substring(descBeg);
        } else {
            desc = content.substring(descBeg, descBeg + 160) + "...";
        }

        return desc;
    }

Ok,当前 DocSearch 类的所有代码如下:

package com.project.java_doc_searcher.searcher;

import org.ansj.domain.Term;
import org.ansj.splitWord.analysis.ToAnalysis;

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.util.*;

// 通过这个类,来完成整个搜索过程
public class DocSearcher {
    // 此处要加上索引对象的实例
    // 同时也要完成索引加载的工作
    private Index index = new Index();
    public DocSearcher(){
        index.load();
    }

    // 完成整个搜索过程的方法
    // 参数(输入部分)就是用户给出的查询词
    // 返回值(输出部分)就是搜索结果的集合
    public List<Result> search(String query){
        // 1、 [分词] 针对 query 这个查询词进行分词
       List<Term> terms = ToAnalysis.parse(query).getTerms();

        // 2、 [触发] 针对分词结果来查倒排
        List<List<Weight>> termResult = new ArrayList<>();
        for (Term term : terms) {
            String word = term.getName();
            // 倒排索引中的词,都是之前文档中存在的
            List<Weight> invertedList = index.getInverted(word);
            if(invertedList == null){
                // 说明这个词在所有文档中都不存在
                continue;
            }
            termResult.add(invertedList);
        }

        // 3、[排序] 针对出发的结果按照权重降序进行排序
        allTermResult.sort(new Comparator<Weight>() {
            @Override
            public int compare(Weight o1, Weight o2) {
                // 升序:return o1.getWeight() - o2.getWeight();
                // 降序:return o2.getWeight() - o1.getWeight();
                return o2.getWeight() - o1.getWeight();
            }
        });
        // 4、[包装结果] 针对排序的结果,去查正排,构造出要返回的数据
        List<Result> results = new ArrayList<>();
        for (Weight weight : allTermResult) {
            DocInfo docInfo = index.getDocInfo(weight.getDocId());
            Result result = new Result();
            result.setTitle(docInfo.getTitle());
            result.setUrl(docInfo.getUrl());
            result.setDesc(GenDesc(docInfo.getContent(), terms));
            results.add(result);
        }
        return results;
    }
}



    private String GenDesc(String content, List<Term> terms) {
        // 先遍历分词结果,看看哪个结果是在 content 中存在
        int firstPos = -1;
        for (Term term : terms) {
            // 由于分词库会直接把词进行转小写
            // 所以我们也必须把正文转成小写,然后再查询
            String word = term.getName();
            // 这里需要 “全字匹配”,让 word 能够独立成词,才能查找出来,而不是只作为词的一部分
            // 此处全字符匹配的实现并不是十分严谨,优化逻辑是使用正则表达式
            firstPos = content.toLowerCase().indexOf(" " + word + " ");
            if (firstPos >= 0) {
                // 找到位置了
                break;
            }
        }

        if (firstPos == -1) {
            // 所有的分词结果都不在正文中存在,这属于比较极端的情况了
            // 此时直接返回空串,或者取正文前 160 个字符也行
            if (content.length() < 160) {
                return content.substring(0, 160) + "...";
            }
            return content;
        }

        // 从 firstPos 作为基准位置,往前找 60 个字符,作为描述的起始位置
        String desc = "";
        int descBeg = firstPos < 60 ? 0 : firstPos - 60;
        if (descBeg + 160 > content.length()) {
            desc = content.substring(descBeg);
        } else {
            desc = content.substring(descBeg, descBeg + 160) + "...";
        }

        return desc;
    }



    public static void main(String[] args) {
        DocSearcher docSearcher = new DocSearcher();
        Scanner scanner = new Scanner(System.in);
        while (true) {
            System.out.print("-> ");
            String query = scanner.next();
            List<Result> results = docSearcher.search(query);
            for (Result result : results) {
                System.out.println("==============================");
                System.out.println(result);
            }
        }
    }
}

运行一下 main 方法,然后输入 ArrayList 搜索看看结果如何:

返回如下:

在我标出红框的地方,明显不是描述部分内容,而是 js 代码,这说明我们没有处理好前端标签的去除工作。

还记得在前面 HTML 文档的去标签工作中,我们只是单纯地去掉了标签的 < 和 > ,这就导致 <script> 标签中的内容,也被我们整理到了倒排索引中,而它所在的位置又刚好被我们的描述部分所截取到,所以才会呈现出这样的效果。

因此,此处我们就可以利用到正则表达式的特殊匹配机制,来实现替换功能。

4、使用正则表达式替换内容

所谓正则表达式,其实就是一种强大的文本处理工具,它能帮助用户匹配、查找、替换或提取字符串中的特定模式。

这里举几个较常用到的特殊符号:

利用上面规则,然后再结合下面 贪婪匹配 非贪婪匹配 的定义,我们就可以推导出去掉 <script> 标签的步骤:

不过上面规则都是概念性的东西,这里我们来点小实操,找个在线的正则表达式生成器,带大家举几个例子看看。

在第一行,我们输入 .* 表示要匹配的字符可以无限次出现,对应到 <script> 标签上就是去掉所有的标签 <script> ,而不是只去掉一个。

这里,我们的正则表达式选择 .*? ,代表 "非贪婪匹配”,即匹配到一个符合条件的最短结果。

然后在下面的替换文本中,我们输入一个空格,代表的就是把 <div> 标签中的文本,都替换成一个空格。

嗯,正则表达式就是这样用,下面我们落实到代码上,来实现去掉 <script> 标签的功能

    private String readFile(File f) {
        try (BufferedReader bufferedReader = new BufferedReader(new FileReader(f))) {
            StringBuilder content = new StringBuilder();
            while (true) {
                int ret = bufferedReader.read();
                if (ret == -1) {
                    break;
                }
                char c = (char) ret;
                if(c == '\n' || c == '\r') {
                    c = ' ';
                }
                content.append(c);
             }
            return content.toString();
        } catch (IOException e){
            e.printStackTrace();
        }
        return "";
    }

    // 这个方法内部基于正则表达式,实现去标签,以及去除 script 标签
    public String parseContentByRegex(File f) {
        // 1、 先把整个文件都读到 String 中
        String content = readFile(f);
        // 2、 替换掉 script 标签
        content = content.replaceAll("<script.*>(.*?)</script>", " ");
        // 3、 替换掉普通的 html 标签
        content = content.replaceAll("<.*?>", " ");
        // 4、 适用正则表达式,把多个空格,合并成一个空格
        content = content.replaceAll("\\s+", " ");
        return content;
    }

同时,这里还有一个细节需要注意,就是使用正则表达式的时候,要先替换掉 <script> 标签,然后再替换 <html> 标签,不然就跟老样子一样,会把 <script> 标签的内容都打印出来了。

下面我们再来测试一下代码:

可以看到,这次的描述部分就很完美了。

目前的 Parser 类全部代码:

package com.project.java_doc_searcher.searcher;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.IOException;
import java.util.ArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicLong;

public class Parser {

    private static final String INPUT_PATH = "D:/CODE/doc_searcher_index/jdk-8u411-docs-all/api";

    // 创建一个 index 实例
    private  Index index = new Index();

    private AtomicLong t1 = new AtomicLong(0);
    private AtomicLong t2 = new AtomicLong(0);

    // 单线程制作索引
    public void run(){
        long beg = System.currentTimeMillis();
        System.out.println("索引制作开始!");
        // 整个 searcher.Parser 类的入口
        // 1. 根据 INPUT_PATH 路径,枚举出该路径中所有的 html 文件,而这一步的实现需要把所有字目录中的文件获取到
        ArrayList<File> fileList = new ArrayList<>();
        enumFile(INPUT_PATH, fileList);

        long endEnumFile = System.currentTimeMillis();
        System.out.println("枚举文件完毕!消耗时间:" + (endEnumFile - beg));
//        System.out.println(fileList);
//        System.out.println("总文件数: " + fileList.size());
        // 2. 针对上面罗列出的文件的路径,依次打开文件并读取内容,然后进行解析和构建索引
        for (File f : fileList){
            // 解析文件内容,得到文件标题、文件 URL 、文件正文
            System.out.println("开始解析" + f.getAbsolutePath());
            parseHTML(f);
        }

        long endFor = System.currentTimeMillis();
            System.out.println("遍历文件完毕!消耗时间:" + (endFor - endEnumFile) + "ms");

        // 3.把在内存中构造好的索引数据结构,保存到指定的文件中
        index.save();
        long end = System.currentTimeMillis();
        System.out.println("索引制作结束!消耗时间:" + (end - beg) + "ms");
    }

    // 多线程制作索引
    public void runByThread() throws InterruptedException {
        long beg = System.currentTimeMillis();
        System.out.println("索引制作开始!");

        // 1、枚举出所有文件
        ArrayList<File> files = new ArrayList<>();
        enumFile(INPUT_PATH, files);

        // 2、循环遍历文件,同时为了能够通过多线程制作索引,这里直接引入线程池
        CountDownLatch latch = new CountDownLatch(files.size());
        ExecutorService executorService = Executors.newFixedThreadPool(6);
        for (File f : files){
            executorService.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println("解析" + f.getAbsolutePath());
                    parseHTML(f);
                    latch.countDown();
                }
            });
        }

        // await 方法会阻塞,直到所有线程都执行完毕,才阻塞结束
        latch.await();
        // 手动把线程池里的线程都关掉
        executorService.shutdown();
        // 3、保存索引
        index.save();

        long end = System.currentTimeMillis();
        System.out.println("索引文件制作完毕!消耗时间:" + (end - beg) + "ms");
        System.out.println("t1:" + t1 + "t2:" + t2);
    }

    private void parseHTML(File f) {
        // 1.解析出 HTML 的文件标题
        String title = parseTitle(f);
        // 2.解析出 HTML 对应的 URL
        String url = parseUrl(f);
        // 3.解析出 HTML 的文件正文
        long beg = System.nanoTime();
        String content = parseContent(f);
        long mid = System.nanoTime();
        // 4、 把解析出来的信息加入到索引 index 中
        index.addDoc(title, url, content);
        long end = System.nanoTime();

        // 这里还不能用 print, 因为频繁打印反而有可能拖慢速度
        t1.addAndGet(mid - beg);
        t2.addAndGet(end - mid);
    }

    private String parseTitle(File f) {
        String name = f.getName();
//      return name.substring(0, name.lastIndexOf("."));
        return name.substring(0, name.length() - ".html".length());
    }

    private String parseUrl(File f) {
        String part1 = "https://docs.oracle.com/javase/8/docs/api/";
        String part2 = f.getAbsolutePath().substring(INPUT_PATH.length());
        return part1 + part2;
    }

    public String parseContent(File f) {
        // 先一个字符一个字符读取,以 < 和 > 来判断是否开始拷贝数据
        // 手动把缓冲区设置成 1M 大小
        try(BufferedReader bufferedReader = new BufferedReader(new FileReader(f), 1024 * 1024)){
            // 判断是否开始拷贝
            boolean isCopy = true;
            StringBuilder content = new StringBuilder();
            while (true) {
                // ret 返回值为 int, 而不是 char,
                // 主要是为了表示一些非法情况,
                // 比如读到了文件末尾,再继续读,就返回 -1
                int ret = bufferedReader.read();
                if (ret == -1){
                    // 表示文件读完了
                    break;
                }
                // 若结果不为 -1, 代表是一个合法字符
                char c = (char)ret;
                if(isCopy){
                    // 拷贝条件合法的情况下,遇到普通自负就拷贝到 StringBuilder 中
                    if(c == '<'){
                        isCopy = false;
                        continue;
                    }
                    if (c == '\n' || c == '\r'){
                        // 把换行替换成空格
                        c = ' ';
                    }
                    content.append(c);
                }else {
                    if (c == '>'){
                        isCopy = true;
                    }
                }
            }
            return content.toString();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return "";
    }

    private String readFile(File f) {
        try (BufferedReader bufferedReader = new BufferedReader(new FileReader(f))) {
            StringBuilder content = new StringBuilder();
            while (true) {
                int ret = bufferedReader.read();
                if (ret == -1) {
                    break;
                }
                char c = (char) ret;
                if(c == '\n' || c == '\r') {
                    c = ' ';
                }
                content.append(c);
             }
            return content.toString();
        } catch (IOException e){
            e.printStackTrace();
        }
        return "";
    }

    // 这个方法内部基于正则表达式,实现去标签,以及去除 script 标签
    public String parseContentByRegex(File f) {
        // 1、 先把整个文件都读到 String 中
        String content = readFile(f);
        // 2、 替换掉 script 标签
        content = content.replaceAll("<script.*>(.*?)</script>", " ");
        // 3、 替换掉普通的 html 标签
        content = content.replaceAll("<.*?>", " ");
        // 4、 适用正则表达式,把多个空格,合并成一个空格
        content = content.replaceAll("\\s+", " ");
        return content;
    }

    // 参数: 递归遍历起始目录、 递归得到的结果
    private void enumFile(String inputPath, ArrayList<File> fileList){
        File rootPath = new File(inputPath);

        // listFiles 能够获取到 rootPath 当前目录下所包含的文件/目录
        // 使用 listFiles 只能看到一级目录,而看不了子目录里的内容
        // 所以还需要借助递归,以实现查看子目录内容这个功能
        File[] files = rootPath.listFiles();
        for (File f: files) {
            // 根据 f 的类型,来判断是否要加入递归
            // f 为普通文件: 直接加入 fileList 结果中;
            // f 为目录: 递归调用 enumFile 这个方法,以进一步获取子目录内容
            if(f.isDirectory()){
                enumFile(f.getAbsolutePath(), fileList);
            }else{
                if(f.getAbsolutePath().endsWith(".html")){
                    fileList.add(f);
                }
            }
        }
    }

    public static void main(String[] args) {
        Parser parser = new Parser();
        parser.run();
    }
}

5、DocSearch 类小结

DocSearch 主要实现的是搜索模块的功能,具体就是以下三点:

1️⃣分词;

2️⃣根据分词结果查找索引;

3️⃣拿到结果后排序;

4️⃣把结果进行封装后返回。

做完了以上工作,后端的工作也基本告一段落了,接下来我们就要实现前端板块了。

五)实现前端模块

1、准备工作

前端这一块,我们先通过使用 Servlet 实现,后续再修改为 Spring Boot 版本,因为现在大多数公司还是用 Spring Boot 做项目多一点,但我个人认为 Servlet 也有用起来很高效的地方,所以两样我们都来做一做吧。

首先,我们要创建一个类,用于前后端的交互。

使用 Servlet 前先导下包:

这里我选择的是 3.1.0 的版本,虽然它不是最新的,但使用人数最多,咱就用它啦~

<!-- https://mvnrepository.com/artifact/javax.servlet/javax.servlet-api -->
<dependency>
    <groupId>javax.servlet</groupId>
    <artifactId>javax.servlet-api</artifactId>
    <version>3.1.0</version>
    <scope>provided</scope>
</dependency>

 创建好目录:

DocSearcherServlet 的完整代码:

package
 api;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.project.java_doc_searcher.searcher.DocSearcher;
import com.project.java_doc_searcher.searcher.Result;


import javax.servlet.annotation.WebServlet;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;

@WebServlet("/searcher")
public class DocSearcherServlet extends HttpServlet {

    //此处的 searcher.DocSearcher 对象也应该是全局唯一的,因此就给一个 static 修饰
    private static DocSearcher docSearcher = new DocSearcher();
    private ObjectMapper objectMapper = new ObjectMapper();

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        // 1、 先解析请求,拿到用户提交的查询词
        String query = req.getParameter("query");
        if (query == null || query.trim().equals("")) {
            String msg = "您的参数非法!没有获取到 query 的值!";
            System.out.println(msg);
            resp.sendError(404, msg);
            return;
        }
        // 2、 打印记录一下 query 的值
        System.out.println("query=" + query);
        // 3、 调用搜索模块,来进行搜索
        List<Result> results = docSearcher.search(query);
        // 4、 把当前的搜索结果进行打包
        resp.setContentType("application/json;charset=utf-8");
        objectMapper.writeValue(resp.getWriter(), results);

    }
}

2、验证后端接口

社区版 Idea 是没有集成 Tomcat 的,所以我们想要验证后端接口,还需要自己去插件市场里安装 Smart Tomcat,然后填上下面红框中的信息。

然后我们就可以通过这串 URL 链接,从浏览器查看信息了:

3、实现前端页面

关于前端页面的代码实现,我个人觉得不想后端那样考验编程思维,更多的是多去试,然后慢慢调整。

奈何篇幅所限,再加上我本人对于前端的研究也确实不深,没办法全部讲一遍,所以这里直接放出前端页面 index.html 的代码:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Java API 文档搜索</title>
</head>
<body>
    
    <!-- 通过 .container 来表示整个页面元素的容器-->
    <div class = "container">
        <!-- 1、 搜索框 + 搜索按钮 -->
        <div class = "header">
            <input type = "text">
            <button id = "search-btn">搜索</button>
        </div>

        <!-- 2、 显示搜索结果-->
        <div class = "result">
            <!-- 包含了很多条记录 -->
            <!-- 每个 .item 就表示一条记录-->
<!--            <div class = "item">-->
<!--                <a href = "#">我是标题</a>-->
<!--                <div class = "desc">我是一段描述 Lorem ipsum dolor sit, amet consectetur adipisicing elit. Deserunt quam possimus, expedita quos delectus ea ipsam corporis. Porro nesciunt mollitia amet necessitatibus officiis magnam ab deserunt cumque ad? Consectetur, ut.</div>-->
<!--                <div class = "url">https://www.baidu.com</div>-->
<!--            </div>-->
        </div>
    </div>

    <style>
        /* 这部分代码来写样式 */
        /* 先去掉浏览器的默认样式 */
        /* 用 " * " 来选中所有元素 */
        *{
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }

        /* 给整体的页面制定一个高度(和浏览器窗口一样高) */
        html, body {
            height: 100%;

            /* 设置背景图 */
            background-image: url(image/background.jpg);

            /* 设置背景图不平铺 */
            background-repeat: no-repeat;

            /* 设置背景图的位置 */
            background-position: center center;

            /* 设置背景图大小 */
            background-size: cover;
        }

        /* 针对 .container 也设置样式,实现版心效果*/
        .container {
            /* 宽度也可以设置成百分数的形式 */
            width: 1200px;
            height: 100%;

            /* 设置水平居中 */
            margin: 0 auto;

            /* 设置背景色,让版心和背景图能够区分开 */
            background-color: rgba(255, 255, 255, 0.8);

            /* 设置圆角矩形 */
            border-radius: 10px;

            /* 设置内边距,避免文字内容紧贴着边界*/
            padding: 20px;

            /* 加上这个属性,使得超出元素部分会自动生成一个滚动条 */
            overflow: auto;
        }

        .header {
            width: 100%;
            height: 50px;
            display: flex;
            justify-content: space-between;
            align-items: center;
        }

        .header>input {
            width: 1050px;
            height: 50px;
            font-size: 22px;
            line-height: 50px;
            border-radius: 10px;
            padding-left: 10px;
        }

        .header>button {
            width: 100px;
            height: 50px;
            background-color: rgb(42, 107, 205);
            color: #fff;
            font-size: 22px;
            line-height: 50px;
            border-radius: 10px;
            border: none;
        }

        .header>button:active {
            background-color: gray;
        }

        .result .count{
            color: gray;
            margin-top: 10px;
        }

        .item {
            width: 100%;
            margin-top: 20px;
        }

        .item a {
            display: block;
            height: 40px;

            font-size: 22px;
            line-height: 40px;
            font-weight: 700;

            color: rgb(42, 107, 205);
        }

        .item .desc {
            font-size: 18px;
        }

        .item .url {
            font-size: 18px;
            color: rgba(0, 128, 0);
        }

        .item .desc i {
            color: red;
            /* 去掉斜体 */
            font-style: normal;
        }
    </style>

    <!-- 引入 jquery -->
    <script src="http://libs.baidu.com/jquery/2.0.0/jquery.min.js"></script>

    <script>
        // 放置自己的 js 代码
        let button = document.querySelector("#search-btn");
        button.onclick = function() {
            // 先获取输入框内容
            let input = document.querySelector(".header input");
            let query = input.value;
            console.log("query: " + query);

            // 然后构造一个 ajax 请求发送给服务器
            $.ajax({
                type: "GET",
                url: "searcher?query=" + query,
                success: function(data, status) {
                    // success 这个函数会在请求成功后调用
                    // data 参数表示拿到的结果数据
                    // status 参数表示 HTTP 状态码
                    // 根据收到的数据结果,构造出页面内容
                    // console.log(data);
                    buildResult(data);
                }
            })
        }

        function buildResult(data) {
            // 通过这个函数,来把相应数据给构造成页面内容
            // 所以要做的工作就是,遍历 data 的每一个元素
            // 针对每个元素都创建一个 div.item,然后把标题,url,描述都构造好
            // 再把这个 div.item 加入到 div.result 中
            // 这些操作都是基于 DOM API 来展开的

            // 获取到 .result 这个标签
            let result = document.querySelector('.result');
            // 清空上次的结果
            result.innerHTML = '';

            // 先构造一个 div 用于显示结果个数
            let countDiv = document.createElement('div');
            countDiv.innerHTML = '当前找到 ' + data.length + ' 个结果';
            countDiv.className = 'count';
            result.appendChild(countDiv);

            // 此处的 for 循环就相当于 Java 中的 for each 循环
            // 此处得到的 item 就会分别代表 data 中的每个元素
            for (let item of data){
                let itemDiv = document.createElement('div');
                itemDiv.className = 'item';

                // 构造一个标题
                let title = document.createElement('a');
                title.href = item.url;
                title.innerHTML = item.title;
                title.target = '_blank';
                itemDiv.appendChild(title);

                // 构造一个描述
                let desc = document.createElement('div');
                desc.className = 'desc';
                desc.innerHTML = item.desc;
                itemDiv.appendChild(desc);

                // 构造一个 url
                let url = document.createElement('div');
                url.className = 'url';
                url.innerHTML = item.url;
                itemDiv.appendChild(url);
            
                // 把 itemDiv 加到 .result 中
                result.appendChild(itemDiv);
            
            }
        }

    </script>

</body>
</html>

还有文件存放路径:

4、引入停用词

当我们输入多个单词查询时,会发现返回结果中会有一些结果,和我们的查询词没有一点关系。

这其实是因为我们没有引入停用词,导致输入的空格也被算作查询词了

所谓停用词,意思就是一些高频但没必要查询的词,比如 的、是、对 等等,这类词需要屏蔽掉,不让他们影响查询结果。

为此,我们还需要去网上下载一份停用词文档,然后放到索引文件夹就好。

我们点开看看,可以发现都是一些常用的词:

但要注意,网上有些版本需要自己手动把空格加上,比如我下载的时候就遇到了,所以第一行的空格也是我自己加上去的。

那停用词准备好了,接下来我们就要用它了,代码这块也需要重新修改:

package com.project.java_doc_searcher.searcher;

import org.ansj.domain.Term;
import org.ansj.splitWord.analysis.ToAnalysis;

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.util.*;

// 通过这个类,来完成整个搜索过程
public class DocSearcher {
    // 停用词文件路径
    private static String STOP_WORD_PATH = null;

    static {
        if (Config.isOnline) {
            STOP_WORD_PATH = "/root/install/doc_searcher_index/stop_word.txt";
        } else {
            STOP_WORD_PATH = "D:\\CODE\\doc_searcher_index\\stop_word.txt";
        }
    }

    // 使用 HashSet 来保存停用词
    private HashSet<String> stopWords = new HashSet<>();

    // 此处要加上索引对象的实例
    // 同时也要完成索引加载的工作
    private Index index = new Index();
    public DocSearcher(){
        index.load();
        loadStopWords();
    }

    // 完成整个搜索过程的方法
    // 参数(输入部分)就是用户给出的查询词
    // 返回值(输出部分)就是搜索结果的集合
    public List<Result> search(String query){
        // 1、 [分词] 针对 query 这个查询词进行分词
        List<Term> oldTerms = ToAnalysis.parse(query).getTerms();
        List<Term> terms = new ArrayList<>();
        // 针对分词结果,使用暂停此表进行过滤
        for (Term term : oldTerms) {
            if (stopWords.contains(term.getName())) {
                continue;
            }
            terms.add(term);
        }
        // 2、 [触发] 针对分词结果来查倒排
        List<List<Weight>> termResult = new ArrayList<>();
        for (Term term : terms) {
            String word = term.getName();
            // 倒排索引中的词,都是之前文档中存在的
            List<Weight> invertedList = index.getInverted(word);
            if(invertedList == null){
                // 说明这个词在所有文档中都不存在
                continue;
            }
            termResult.add(invertedList);
        }

        // 3、[排序] 针对出发的结果按照权重降序进行排序
        allTermResult.sort(new Comparator<Weight>() {
            @Override
            public int compare(Weight o1, Weight o2) {
                // 升序:return o1.getWeight() - o2.getWeight();
                // 降序:return o2.getWeight() - o1.getWeight();
                return o2.getWeight() - o1.getWeight();
            }
        });
        // 4、[包装结果] 针对排序的结果,去查正排,构造出要返回的数据
        List<Result> results = new ArrayList<>();
        for (Weight weight : allTermResult) {
            DocInfo docInfo = index.getDocInfo(weight.getDocId());
            Result result = new Result();
            result.setTitle(docInfo.getTitle());
            result.setUrl(docInfo.getUrl());
            result.setDesc(GenDesc(docInfo.getContent(), terms));
            results.add(result);
        }
        return results;
    }

    private String GenDesc(String content, List<Term> terms) {
        // 先遍历分词结果,看看哪个结果是在 content 中存在
        int firstPos = -1;
        for (Term term : terms) {
            // 由于分词库会直接把词进行转小写
            // 所以我们也必须把正文转成小写,然后再查询
            String word = term.getName();
            // 这里需要 “全字匹配”,让 word 能够独立成词,才能查找出来,而不是只作为词的一部分
            // 此处全字符匹配的实现并不是十分严谨,优化逻辑是使用正则表达式
            content = content.toLowerCase().replaceAll("\\b" + word + "\\b", " " + word + " ");
            firstPos = content.indexOf(" " + word + " ");
            if (firstPos >= 0) {
                // 找到位置了
                break;
            }
        }

        if (firstPos == -1) {
            // 所有的分词结果都不在正文中存在,这属于比较极端的情况了
            // 此时直接返回空串,或者取正文前 160 个字符也行
            if (content.length() < 160) {
                return content.substring(0, 160) + "...";
            }
            return content;
        }

        // 从 firstPos 作为基准位置,往前找 60 个字符,作为描述的起始位置
        String desc = "";
        int descBeg = firstPos < 60 ? 0 : firstPos - 60;
        if (descBeg + 160 > content.length()) {
            desc = content.substring(descBeg);
        } else {
            desc = content.substring(descBeg, descBeg + 160) + "...";
        }

        // 此处加上一个替换操作,把描述中和分词结果相同的部分,加上一层 <i> 标签,就可以通过 replace 的方式来实现
        for (Term term : terms) {
            String word = term.getName();
            //  此处需要 “全字匹配”,让 word 能够独立成词,才能查找出来,而不是只作为词的一部分
            // 比如当查询词为 List 时,不能把 ArrayList 中的 List 给单独标红
            desc = desc.replaceAll("(?i)" + word + " ", "<i> " + word + " <i>");
        }


        return desc;
    }

    public void loadStopWords() {
        try (BufferedReader bufferedReader = new BufferedReader(new FileReader(STOP_WORD_PATH))) {
            while (true) {
                String line = bufferedReader.readLine();
                if (line == null) {
                    // 读取文件完毕
                    break;
                }
                stopWords.add(line);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) {
        DocSearcher docSearcher = new DocSearcher();
        Scanner scanner = new Scanner(System.in);
        while (true) {
            System.out.print("-> ");
            String query = scanner.next();
            List<Result> results = docSearcher.search(query);
            for (Result result : results) {
                System.out.println("==============================");
                System.out.println(result);
            }
        }
    }
}

具体的逻辑就是,把暂停词放到一个 HashSet 中,每个查询词都去这个 HashSet 中看看有没有同样的词,有的话就不查询他。

为什么使用 HashSet 呢?

主要是考虑到我们的需要是 判断停用词表中是否包含查询词 ,用 Set 的 contains 方法就够了,不需要用到 Map 。

5、处理重复结果

查找一下以下三个词,仔细观察有什么特殊之处

可以看出,前两个词的结果总数相加,正好等于第三个词的结果数。

并且,还有一个更严重的问题:出现重复结果。

来,我们搜索一下 Collections 这个类,发现出现了不止一次:

针对这个问题我们首先要先去重,然后因为他出现了两次,所以这个结果在权重上也会更高,我们应该把他排得更加靠前。

首先来分析下为什么会重复出现?

查找 array 时,触发了 DocId 一次,Collections 被当成结果第一次;查找 list 时,则为第二次。

那么,这里我们就要把两次结果合并,也可以认为是去重。

于是解决思路就是,通过一个优先级队列,并指定其比较规则,按照 Weight 的 DocId,取小的更优先。

而用户输入的不一定就是两个词,还有可能是多个词,所以我们这里定义一个二维数组,通过它来遍历所有结果。

每一个查询词都有一个结果列表,所以二维数组的行就代表每一个查询词的结果合集,而列则代表结果合集中的元素。

    // 通过这个内部类,来描述一个元素在二维数组中的位置
    static class Pos{
        public int row;
        public int col;

        public Pos(int row, int col) {
            this.row = row;
            this.col = col;
        }
    }

    private List<Weight> mergeResult(List<List<Weight>> source) {
        // 在进行合并的时候,是把多行合并成一行了
        // 合并过程中,是鄙视需要操作这个二维 List 中的每一个元素的
        // 操作元素涉及了 “行” “列” 概念,所以需要创建一个新的类,用于表示 “行” 和 “列”

        // 1、 先针对每一行进行排序(按照 id 进行升序排序)
        for (List<Weight> curRow : source) {
            curRow.sort(new Comparator<Weight>(){
                @Override
                public int compare(Weight o1, Weight o2){
                    return o1.getDocId() - o2.getDocId();
                }
            });
        }

        // 2、 借助一个优先队列,针对每一行进行合并
        // target 表示合并的结果
        List<Weight> target = new ArrayList<>();
        // 创建优先级队列,并指定比较规则,按照 Weight 的 DocId,取小的更优先
        PriorityQueue<Pos> queue = new PriorityQueue<>(new Comparator<Pos>() {
            @Override
            public int compare(Pos o1, Pos o2) {
                Weight w1 = source.get(o1.row).get(o1.col);
                Weight w2 = source.get(o2.row).get(o2.col);
                return w1.getDocId() - w2.getDocId();
            }
        });

        // 初始化队列,把每一行的第一个元素放到队列中
        for (int row = 0; row < source.size(); row++) {
            // 初始插入的元素的列数 col 就是 0
            queue.offer(new Pos(row, 0));
        }

        // 循环取出队首元素(每一行的最小元素)
        while (!queue.isEmpty()) {
            Pos minPos = queue.poll();
            Weight curWeight = source.get(minPos.row).get(minPos.col);
            // 判断取到的 Weight 和前一个插入到 target 中的结果是否是相同的 DocId
            // 如果是就得合并
            if (target.size() > 0) {
                Weight lastWeight = target.get(target.size() - 1);
                if (lastWeight.getDocId() == curWeight.getDocId()) {
                    // 说明遇到了相同的文档,就合并权重
                    lastWeight.setWeight(lastWeight.getWeight() + curWeight.getWeight());
                } else {
                    target.add(curWeight);
                }
            } else {
              // target 没有元素,直接插入
              target.add(curWeight);
            }
            // 把当前元素处理完了之后,要把对应的这个元素的光标往后移动,去取这一行的下一个元素
            Pos newPos = new Pos(minPos.row, minPos.col + 1);
            if (newPos.col >= source.get(newPos.row).size()) {
                // 说明这一行的所有元素都处理完了,就跳过这一行
                continue;
            } else {
                // 说明这一行的元素还没有处理完,继续处理
                queue.offer(newPos);
            }
        }
        return target;
    }

target 是一个新的结果合集,前面我们每次取出来的都是最小值,而最先插入到 target 的也是这个最小值,所以他在 target 中的位置也会更靠后,借此实现了 “权重越高越靠前” 的逻辑。

以下是 DocSearcher 类的完整代码:

package com.project.java_doc_searcher.searcher;

import org.ansj.domain.Term;
import org.ansj.splitWord.analysis.ToAnalysis;

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.util.*;

// 通过这个类,来完成整个搜索过程
public class DocSearcher {
    // 停用词文件路径
    private static String STOP_WORD_PATH = null;

    static {
        if (Config.isOnline) {
            STOP_WORD_PATH = "/root/install/doc_searcher_index/stop_word.txt";
        } else {
            STOP_WORD_PATH = "D:\\CODE\\doc_searcher_index\\stop_word.txt";
        }
    }

    // 使用 HashSet 来保存停用词z
    private HashSet<String> stopWords = new HashSet<>();

    // 此处要加上索引对象的实例
    // 同时也要完成索引加载的工作
    private Index index = new Index();
    public DocSearcher(){
        index.load();
        loadStopWords();
    }

    // 完成整个搜索过程的方法
    // 参数(输入部分)就是用户给出的查询词
    // 返回值(输出部分)就是搜索结果的集合
    public List<Result> search(String query){
        // 1、 [分词] 针对 query 这个查询词进行分词
        List<Term> oldTerms = ToAnalysis.parse(query).getTerms();
        List<Term> terms = new ArrayList<>();
        // 针对分词结果,使用暂停此表进行过滤
        for (Term term : oldTerms) {
            if (stopWords.contains(term.getName())) {
                continue;
            }
            terms.add(term);
        }
        // 2、 [触发] 针对分词结果来查倒排
        List<List<Weight>> termResult = new ArrayList<>();
        for (Term term : terms) {
            String word = term.getName();
            // 倒排索引中的词,都是之前文档中存在的
            List<Weight> invertedList = index.getInverted(word);
            if(invertedList == null){
                // 说明这个词在所有文档中都不存在
                continue;
            }
            termResult.add(invertedList);
        }
        // 3、[合并] 针对多个粉刺结果触发的相同文档,进行权重合并
        List<Weight> allTermResult = mergeResult(termResult);

        // 4、[排序] 针对出发的结果按照权重降序进行排序
        allTermResult.sort(new Comparator<Weight>() {
            @Override
            public int compare(Weight o1, Weight o2) {
                // 升序:return o1.getWeight() - o2.getWeight();
                // 降序:return o2.getWeight() - o1.getWeight();
                return o2.getWeight() - o1.getWeight();
            }
        });
        // 5、[包装结果] 针对排序的结果,去查正排,构造出要返回的数据
        List<Result> results = new ArrayList<>();
        for (Weight weight : allTermResult) {
            DocInfo docInfo = index.getDocInfo(weight.getDocId());
            Result result = new Result();
            result.setTitle(docInfo.getTitle());
            result.setUrl(docInfo.getUrl());
            result.setDesc(GenDesc(docInfo.getContent(), terms));
            results.add(result);
        }
        return results;
    }

    // 通过这个内部类,来描述一个元素在二维数组中的位置
    static class Pos{
        public int row;
        public int col;

        public Pos(int row, int col) {
            this.row = row;
            this.col = col;
        }
    }

    private List<Weight> mergeResult(List<List<Weight>> source) {
        // 在进行合并的时候,是把多行合并成一行了
        // 合并过程中,是鄙视需要操作这个二维 List 中的每一个元素的
        // 操作元素涉及了 “行” “列” 概念,所以需要创建一个新的类,用于表示 “行” 和 “列”

        // 1、 先针对每一行进行排序(按照 id 进行升序排序)
        for (List<Weight> curRow : source) {
            curRow.sort(new Comparator<Weight>(){
                @Override
                public int compare(Weight o1, Weight o2){
                    return o1.getDocId() - o2.getDocId();
                }
            });
        }

        // 2、 借助一个优先队列,针对每一行进行合并
        // target 表示合并的结果
        List<Weight> target = new ArrayList<>();
        // 创建优先级队列,并指定比较规则,按照 Weight 的 DocId,取小的更优先
        PriorityQueue<Pos> queue = new PriorityQueue<>(new Comparator<Pos>() {
            @Override
            public int compare(Pos o1, Pos o2) {
                Weight w1 = source.get(o1.row).get(o1.col);
                Weight w2 = source.get(o2.row).get(o2.col);
                return w1.getDocId() - w2.getDocId();
            }
        });

        // 初始化队列,把每一行的第一个元素放到队列中
        for (int row = 0; row < source.size(); row++) {
            // 初始插入的元素的列数 col 就是 0
            queue.offer(new Pos(row, 0));
        }

        // 循环取出队首元素(每一行的最小元素)
        while (!queue.isEmpty()) {
            Pos minPos = queue.poll();
            Weight curWeight = source.get(minPos.row).get(minPos.col);
            // 判断取到的 Weight 和前一个插入到 target 中的结果是否是相同的 DocId
            // 如果是就得合并
            if (target.size() > 0) {
                Weight lastWeight = target.get(target.size() - 1);
                if (lastWeight.getDocId() == curWeight.getDocId()) {
                    // 说明遇到了相同的文档,就合并权重
                    lastWeight.setWeight(lastWeight.getWeight() + curWeight.getWeight());
                } else {
                    target.add(curWeight);
                }
            } else {
              // target 没有元素,直接插入
              target.add(curWeight);
            }
            // 把当前元素处理完了之后,要把对应的这个元素的光标往后移动,去取这一行的下一个元素
            Pos newPos = new Pos(minPos.row, minPos.col + 1);
            if (newPos.col >= source.get(newPos.row).size()) {
                // 说明这一行的所有元素都处理完了,就跳过这一行
                continue;
            } else {
                // 说明这一行的元素还没有处理完,继续处理
                queue.offer(newPos);
            }
        }
        return target;
    }

    private String GenDesc(String content, List<Term> terms) {
        // 先遍历分词结果,看看哪个结果是在 content 中存在
        int firstPos = -1;
        for (Term term : terms) {
            // 由于分词库会直接把词进行转小写
            // 所以我们也必须把正文转成小写,然后再查询
            String word = term.getName();
            // 这里需要 “全字匹配”,让 word 能够独立成词,才能查找出来,而不是只作为词的一部分
            // 此处全字符匹配的实现并不是十分严谨,优化逻辑是使用正则表达式
            content = content.toLowerCase().replaceAll("\\b" + word + "\\b", " " + word + " ");
            firstPos = content.indexOf(" " + word + " ");
            if (firstPos >= 0) {
                // 找到位置了
                break;
            }
        }

        if (firstPos == -1) {
            // 所有的分词结果都不在正文中存在,这属于比较极端的情况了
            // 此时直接返回空串,或者取正文前 160 个字符也行
            if (content.length() < 160) {
                return content.substring(0, 160) + "...";
            }
            return content;
        }

        // 从 firstPos 作为基准位置,往前找 60 个字符,作为描述的起始位置
        String desc = "";
        int descBeg = firstPos < 60 ? 0 : firstPos - 60;
        if (descBeg + 160 > content.length()) {
            desc = content.substring(descBeg);
        } else {
            desc = content.substring(descBeg, descBeg + 160) + "...";
        }

        // 此处加上一个替换操作,把描述中和分词结果相同的部分,加上一层 <i> 标签,就可以通过 replace 的方式来实现
        for (Term term : terms) {
            String word = term.getName();
            //  此处需要 “全字匹配”,让 word 能够独立成词,才能查找出来,而不是只作为词的一部分
            // 比如当查询词为 List 时,不能把 ArrayList 中的 List 给单独标红
            desc = desc.replaceAll("(?i)" + word + " ", "<i> " + word + " <i>");
        }


        return desc;
    }

    public void loadStopWords() {
        try (BufferedReader bufferedReader = new BufferedReader(new FileReader(STOP_WORD_PATH))) {
            while (true) {
                String line = bufferedReader.readLine();
                if (line == null) {
                    // 读取文件完毕
                    break;
                }
                stopWords.add(line);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) {
        DocSearcher docSearcher = new DocSearcher();
        Scanner scanner = new Scanner(System.in);
        while (true) {
            System.out.print("-> ");
            String query = scanner.next();
            List<Result> results = docSearcher.search(query);
            for (Result result : results) {
                System.out.println("==============================");
                System.out.println(result);
            }
        }
    }
}

6、修改为 Spring Boot 项目

这里需要重新创建一个项目,然后

我使用的 Spring Boot 是 3.X 的版本,而这个版本和 JDK 8 会冲突,所以我的 JDK 用的是17

然后下一个页面,注意在这里和我一样即可

后面的依赖导入,我们可以先空着,后面通过 pom.xml 文件导入即可。

然后把原有项目的文件都拷贝下来

同时需要新建一个控制类。

package com.project.java_doc_searcher.controller;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.project.java_doc_searcher.searcher.DocSearcher;
import com.project.java_doc_searcher.searcher.Result;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;


import java.util.List;

@RestController
public class DocSearcherController {

    private static DocSearcher searcher = new DocSearcher();
    private ObjectMapper objectMapper = new ObjectMapper();

    @RequestMapping(value = "/searcher", produces = "application/json;charset=utf-8")
    @ResponseBody
    public String search(@RequestParam("query") String query) throws JsonProcessingException {
        // 参数是查询词,返回值是相应内容
        // 参数 query 来自请求 URL 的 query string 中的 query 这个 key 的值
        List<Result> results = searcher.search(query);
        return objectMapper.writeValueAsString(results);
    }
}

然后,由于我们还要把程序部署到云服务器上,以实现 24 小时运行,但本地有时候也要用来调试,所以我们新建一个 Config 类,在里面设置一个变量,用于判断环境是本地还是云端,以方便查找索引和停用词。

package com.project.java_doc_searcher.searcher;

public class Config {

    // 这个值为 false, 表示是本地运行
    // 为 true,表示为服务器上运行
    public static boolean isOnline = false;
}

7、添加图标

这一步是我自己的拓展,有一天心血来潮,想美化一下自己网页上的图标,于是开始查找教程,最终也是实现了,请看成果图。

实现起来也非常简单,我们去网上找到自己喜欢的 ico 图标,下载完放到这个文件夹即可。

8、部署到云服务器

最后一步就是去打包完部署到云服务器上了,记得把索引文件和停用词一块拷贝到云端上哦。

大功告成!

四、项目总结

这个 Java API 搜索引擎,是我自己做的第一个项目,也是第一次自己从零到一,跟着网上教程,一步步搭建出来一个项目。

虽然在我眼里,她还有很多可以优化的地方,但写到这里时,我心中又感受到了当我做完那一刻的兴奋与成就!

一)可以优化的地方

下面简单讲几点尚能优化的地方,待日后有时间再好好优化:

1️⃣权重计算公式;这个前文已经说过了,搜索引擎真正的权重计算公式是较为复杂的,一般需要算法团队来完成。

2️⃣前端页面自适应;我发现自己的网页在手机上某些浏览器打开,会出现缩放不正常问题,但考虑到 Java API 搜索引擎的使用场景一般是电脑端,边编程边搜索较多,所以这个功能我也暂时还没实现。

二)项目卡点

第一次做项目,遇到困难还是很多的,比如社区版 Idea 创建项目的时候没有 webapp 这个文件夹,里面的一些配置文件自然也没有了,查资料查了一下午。后面还是新建了一个项目,然后才有了这些个文件,然后复制过去,再去 Smart Tomcat 里面配好路径。

再比如,前端中导入 ajax 时,用的是百度的镜像链接才成功,用官方链接导不进去,而且关键是报错信息太少了,光是排查就花了不少时间,后来也是请教了前辈才做出来的。

还有我印象中比较深刻的,就是这篇文章中最开始的 gif 大家都有看到吧,对,那张短短几秒的图,我录了一个多小时。

哈哈哈,第一次碰到这种场景,确实花了不少时间精力来研究。

三)我得到了什么

1、对困难祛魅

碰到以前从未遇到的问题,这种时刻在做项目的时候也是经常出现,但是慢慢地,我对于自己未曾碰到的问题也开始祛魅了,因为在这个过程中,我独立解决问题的能力得到了很好地锻炼。

在面对困难时我想的不是如何放弃、如何走捷径,而是有哪些方法能够解决这个问题,以及该如何实现,这是我觉得对我个人来说有很大意义的一点。

2、多向人请教

很多问题第一次遇见的时候,都是不知道咋解决的,查资料也是查了好半天,最后还是问了一位大厂前辈,才得以解决。

不过,我这属于有点贵人贱用了,现在我知道了,正确做法是先去网上查一下报错信息,了解它的意思,然后去对应地方排错。

而不是像上面那样,自己想不明白就直接扔给前辈,在他们看来,这其实是很低级的问题。

但是这也侧面反映了,有问题要多提问,有时候前辈的一句话就能点醒我们,同时还要学会提问。

第一次写这么长的技术文章,不足之处还请各位多多指教。

算上代码已经快 6 万字了,又是自己的一大突破呀!

附上项目的完整代码链接:https://gitee.com/coder_cai/project/tree/master/java_doc_searcher

最后,感谢大家看到这里!

  • 8
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

爱敲代码的罗根

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值