在我们的学习过程中,会阅读很多的文档,例如jdk的API文档,但是在这样的大型文档中,如果没有搜索功能,我们是很难找到我们想查阅的内容的,于是我们可以实现一个搜索引擎来帮助我们阅读文档。
1.1 获取文档
第一点,要搜索指定内容,首先要先获取到内容,我们以实现Java API文档搜索引擎来说,我们要先获取到Java的API文档,我们可以在Oracle的官网找到:Overview (Java SE 17 & JDK 17) (oracle.com)
Oracle官网提供了在线和离线两种文档,我们可以下载离线文档,通过离线文档来实现。
离线文档下载地址:Java 开发工具包 17 文档 (oracle.com)
下载好后解压缩,在 jdk-17.0.11_doc-alldocsapi 目录和子目录下的所有html文件就是所有的api文档
1.2 通过关键词查询
获取到了文档,我们还需要能够通过关键词定位到相关的文档,这里需要用到索引
- 正排索引: 给每个文档引入一个文档id,文档id是每个文档的身份标识,不能重复,通过文档id快速获取到对应文档就叫正排索引。
- 倒排索引:通过一个或几个关键词查询到与之有关的所有文档的文档id,这种方式就叫到排索引。
于是要实现关键词查询,我们只需要给下载好的Java API文档实现一个正排索引和倒排索引,通过到排索引查询到相关的文档的id,要查看某个文档时再用查询到的id使用正排索引查询到对应文档。
1.3 如何返回查询到的结果
查询到对应的api文档之后,如何返回给用户,这里我的想法是返回一个在线文档的url,当用户想要查看某个文档时,返回Oracle官方的在线文档对应的页面的url。
那么此种方式就需要我们把在线文档的url和离线文档联系起来:
我们观察某个文档的url和在线文档的本地路径:
在线文档:
离线文档:
我们发现相同api文档的在线版本的url和离线版本路径,它们的后半部分是相同的,所有我们只需要通过一些字符串拼接操作,就可以通过离线文档的文件路径得到在线文档的url。
1.4 模块划分
通过上面的叙述,我们可以对我们的程序进行一个模块划分:
- 索引模块:扫描并解析所有的本地文档并构建出索引;提供一些API实现查正排/到排的功能
- 搜索模块:调用索引模块通过关键词查询到相关文档信息,并处理后返回
- Web模块:实现一个简单的Web程序,能通过网页的形式和用户交互
创建一个Spring项目
2.1 实现Parser类
实现一个Parser类用于扫描并解析本地的离线文档:
实现enumFile方法:
实现parserHTML方法:
要实现parserHTML方法我们要先理清楚,html文件中有什么和我们需要什么:
- 标题:返回查询结果时,可以展示给用户以供选择
- 正文:用于提取关键词构建倒排索引
- url:用户点击时通过url跳转到对应页面
注意:FileReader的read方法是每次从磁盘里读取一个字符到内存中,BuferedReader 内部带有一个缓存区,会一次把多个字符加载到缓存区中,调用read方法时会从缓存区中读取字符,减少直接访问磁盘的次数提高了速度,构造方法中的第二个参数就是设置缓冲区的大小,单位是字节
2.2 实现Index类
实现Index类用于创建索引和通过关键词和索引查询相关文档:
前排索引由文档id和文档组成,要求能够通过文档id快速查询到文档,索引我们可以使用一个List来储存前排索引,即通过数组下标当作文档id,数组的内容即为文档的信息,于是我们创建一个DocInfo类用于存储文档信息:
于是前排索引的形式就是:
后排索引要求由关键词查询到文档id,索引我们可以使用哈希表来关联关键词和文档id:
但是只存储一个文档id无法表示不同文档和某一个关键词的相关程度,于是这里我们可以实现一个Relate类,用于存储一个关键词和一个文档直接的关联程度:
这里的权重我们可以以该关键词在该文档中出现的次数来表示
于是最后的后排索引的形式是:
Index实现:
实现addForward:
实现addInverted:
实现该方法我们需要找出该文档中的所有词,并统计每个词出现的次数,我们可以使用 ansj 库来实现分词操作:
在pom文件中添加对应依赖:
由于制作索引的速度是非常慢的,所有我们可以把制作好的索引存储在磁盘里,使用时再从磁盘加载到内存中,避免每次使用都要制作索引:
在Index类中增加一个存储索引的文件夹路径的常量:SAVE_PATH
实现save 和 load 方法用于保存和加载索引:
由于我们的索引是以对象的形式存在的,所以我们先需要把对象序列化再存入磁盘中,我们可以使用jackson库来完成这个操作
2.3 联系Parser和Index
在上面的代码中,Parser类主要负责解析html文件,Index负责通过解析出的信息来生成索引,所以需要把Parser类解析出来的信息传给Index生成索引,我们可以在parserHTML()方法的最后调用Index类的addDoc()方法,让文件一解析就传给addDoc()开始添加索引,我们给Parser类添加一个Index类的成员变量,通过这个对象来调用addDoc()方法:
parserHTML()方法:
到现在我们的索引模块的功能就已经实现了,调用Parser类的parser方法即可开始解析文件并制作索引。
2.4 速度优化
当我们完成这部分代码,开始制作索引时,发现制作索引的速度是非常慢的,我们添加一些代码统计制作索引的过程消耗的时间:
可以看到,我们制作索引的时间大概消耗了15秒,这只是相对于Java文档来说,要是是更大的文档时间会更长,要想提高速度,我们要先找到代码的那一步影响的速度,显而易见,解析和田间索引消耗的时间最多即parser()方法包含的代码,我们可以考虑优化这部分代码,该部分的代码主要包含三个操作:
- 解析文件
- 生成正排索引
- 生成到排索引
要优化这部分操作的速度我们可以考虑使用多线程,使用多个线程来并发完成这个操作:
实现一个parserByThread()方法使用多线程完成索引制作:
上面代码中我们使用了线程池,用4个线程来完成解析和制作索引的工作,使用CountDownLatch类来保证所有任务执行完再开始执行save方法,CountDownLatch构造方法传入的参数是执行任务的个数,每个任务执行完后需调用countDown方法,执行到await方法时如果调用countDown方法的次数小于实例CountDownLatch时传入的参数就会阻塞等待,直到调用countDown方法次数等于传入参数。
接下来我们还需要考虑线程安全问题,当多个线程操作同一块内存时就会出现线程安全问题 :
在我们的代码中有添加索引时存在这种情况,也就是addForward方法和addInverted方法,我们需要给访问内存的代码加锁来保证线程安全问题:
注意两个方法操作的内存不是同一块,所有可以使用不同的的对象来加锁。
运行代码:
可以看到速度的提升非常明显 ,不过这里我们发现当索引制作完成我们的代码还没有提示运行结束,这是因为,我们通过线程池创建的线程模式是非守护线程,非守护线程会阻止进程的结束,我们可以在任务执行完时调用ExecutorService类的shutdown()方法来销毁线程,从而让进程顺利结束:
搜索模块的功能是调用索引模块的代码,通过用户输入的关键词查询到相关文档信息,处理后返回
查询操作只需要调用索引模块的方法,这里我们重点关注如何处理查询到的信息。
首先我们先思考,需要返回什么信息,首先能想到的有文档标题,和文档描述(这两项需要展示给用户),所以需要在返回结果中包含这两项信息,其次,用户如果想要查看文档的具体信息,那么需要url来跳转到在线文档界面,所以还需要url,最后,如果我们是用户,我们肯定希望能更快的找到想要查询的文档,所以我们还可以对查询结果按和关键词的相关性做一个降序排序。
定义一个Result类用于充当返回结果的类型:
定义DocSearcher类完成搜索模块的主要功能:
这里对用户输入结果处理时还需要考虑一个问题:用户输入的词一定都是关键词吗?显然不是,例如用户输入:What is HashMap? ,显然里面的what 和 is 都不是关键词,并且这样的词在文档里会出现很多,就可能会导致用户想要看到的结果被挤到下方去了,这样的词称为暂停词,我们在对用户输入进行分词时要去掉暂停词,我们可以在网络上下载一个现成的暂停词表,运行程序时把它加载到内存中,用一个Set存储,用于排除分词结果中的暂停词。
这里我放在一个txt文件中。
基本的功能已经实现,现在只需提供一个接口供用户访问即可
现在后端代码已经全部完成,接下来实现一个简单的页面调用后端的接口即可:
启动服务器在前端界面搜索:
可以看到成功的弹出了搜索结果。这里我们还可以做一个优化,当我们使用浏览器搜索某个关键词时,浏览器的搜索结果中会把我们所输入的关键词标红:
我们也可以实现一个同样的功能。
这里我们通过前后端配合的方式实现,在后端生成每个搜索结果的描述的时候,我们给描述中的关键词都加上一个<i>标签,再通过前端设置样式来调整字体颜色:
修改 genDesc方法:
在前端的代码中添加对<i>标签的样式:
重新启动程序:
可以看到关键词成功被标红 , 这里我们还可以在前端代码中添加一个显示搜索结果数量的功能:
到此位置我们 api文件搜索引擎的所有功能都实现了,接下来就可以部署到云服务器上,在此之前我们需要把在本地制作好的索引文件和暂停词文件拷贝到云服务器上:
然后把代码中的路径改为云服务器中对应的路径:
打包程序:
双击package:
把生成的jar包拷贝到云服务器上,输入指令:
nohup java -jar jar包名称.jar &
即部署完毕