文章目录
- 1. 项目概述
- 2. 准备阶段
- 2.1 项目创建
- 2.2 准备静态页面
- 3. 搜索逻辑
- 4. 分词
- 5. 处理 HTML 文件
- 5.1 枚举文件夹中所有文件
- 5.2 预处理文件
- 5.2.1 获取标题
- 5.2.2 获取 URL
- 5.2.3 获取正文
- 6. 索引
- 6.1 正排索引和倒排索引
- 6.2 往正排索引中添加元素
- 6.3 往倒排索引中添加元素
- 6.3.1 大致思路
- 6.3.2 计算权重(相关性)
- 6.3.3 实现
- 6.4 往索引中添加元素
- 6.5 补充 parseHtml() 方法
- 6.6 获取文档
- 6.7 测试
- 6.8 持久化保存索引结构
- 6.9 将索引结构从文件中加载到内存中
- 7. 多线程优化解析速度
- 7.1 使用线程池完成文件的解析
- 7.2 线程安全问题
- 7.2.1 parseHtml 方法
- 7.2.2 为正排索引加同步代码块
- 7.2.3 为倒排索引加同步代码块
- 7.3 CountDownLatch
- 7.4 测试
- 8. 搜索模块
- 8.1 搜索逻辑
- 8.2 Searcher 类
- 8.2 停用词
- 8.3 加载停用词
- 8.4 Search 方法
- 8.4.1 过滤查询字符串
- 8.4.2 获取文档列表
- 8.4.3 将结果包装成搜索结果
- 8.4.4 生成摘要
- 8.4.5 整合文档列表
- 9. 前端页面
- 9.1 模板
- 9.2 向后端发送 ajax 请求
- 10. Controller 代码
- 11. 统一数据返回
- 12. 前端对接收到的数据进行渲染
- 13. 实现关键字标红
- 14. 完整代码
1. 项目概述
实现一个较为简单的搜索引擎,在拥有较多网页的基础上,在用户输入查询词之后,能够从这些网页中尽可能地匹配出用户想要的网页
当然,不同于百度搜狗这种搜索引擎,它们能够对互联网中大量的网站都进行搜索,我们这里实现的是针对「Java 文档」的搜索引擎,就像下图,能对 Java 帮助文档 的 API 针对关键词进行文档的搜索
2. 准备阶段
2.1 项目创建
了解了项目的大概之后,就可以开始一点一点制作了,首先进行 Spring 项目的创建
至此,项目的创建就完成了,为了简化目录,可以将新创建中的这四个文件进行删除
2.2 准备静态页面
既然要搜索页面,那肯定得先有页面才能搜索,这里建议直接去官网中下载
网址:Java 文档下载
然后点击下载即可
随后,将安装包解压,放到自己指定的目录,这里我就放在项目所在目录(路径上尽量不要有中文)
至此,准备阶段就完成了
3. 搜索逻辑
在真正编写代码之前,先了解一下搜索的逻辑。
首先我们需要预处理所有的静态页面,获取文档标题(这里文档可以理解成一个静态页面),url,正文等信息,然后包装成一个Document对象。并且还需要通过两个索引来组织这些对象——正排索引和倒排索引,同时记录「权重或者说是相关性」,便于将搜索结果进行整合并排序
- 正排索引:根据文档 ID 能够得到相应的 文档,显然这个结构可以让人想起了 哈希表,但是 ArrayList 更适合,下标和元素相对应
- 倒排索引:根据某个词,可以得到相关联的 List<文档ID>
在用户搜索的时候,我们会获取搜索的语句(这里称为 query),然后对 query 进行分词得到分词结果,然后遍历分词结果,得到「相关联的文档」整理后返回给前端展示
如下,和普通的搜索引擎一样,展示的部分主要有标题,url,摘要(其实也就是正文的截取);
并且点击标题能够跳转到相应的页面
4. 分词
接下来就是代码部分:
为了分词,这里可以在仓库—Ansj链接中,点击第一个,选择最高版本的导入 Spring 中即可
然后可以使用ToAnalysis.parse(字符串).getTerms()
获取到根据该字符串分词得到的 List<Term>
对象,而 Term
就是一个分词结果对象,里面有不少属性,其中又可以通过getName()
来获取这个分词的名字,例如
5. 处理 HTML 文件
然后是预处理API中所有的 HTML 文件,然后将这些文件构造成 Document 对象并加入到正排索引和倒排索引中
5.1 枚举文件夹中所有文件
⭐创建一个 Parser
类,负责进行索引的加载,在此之前还要完成 HTML 文件的预处理
🍓并且在 Parser
类中定义一个方法run()
,索引的加载都在这个方法中完成
(1)首先定义一个字符串常量指定目标文件夹的路径(也就是刚刚解压缩后文件夹中的 api 文件路径)
private static final String ROOT_PATH = "D:\\MyJavaCode\\documentSearcher\\jdk-8u361-docs-all\\docs\\api\\";
(2)然后通过递归得到里面的所有 HTML 文件
Ⅰ. 通过 File[] files = 文件对象.listFiles()
可以得到当前文件中所有文件
Ⅱ. 通过 文件对象.isDirectory()
判断当前文件是否是文件夹,如果是,则继续递归
Ⅲ. 通过 文件对象.getName()
可以获得文件名,通过文件名.endsWith(".html")
可以判断这个文件名是否以 ".html"
结尾,如果是,那么就是 HTML 文件了
所以开始编写 Parser 类中的代码,如下
public class Parser {
private static final String ROOT_PATH = "D:\\MyJavaCode\\documentSearcher\\jdk-8u361-docs-all\\docs\\api\\";
public void run() {
// 获取 api 这个文件对象
File root = new File(ROOT_PATH);
// 用来接收 api 文件夹中的所有 HTML 文件, 是一个输入型参数
List<File> allFiles = new ArrayList<>();
enumFile(root, allFiles);
// 未完...
}
/**
* 枚举当前文件中的所有 HTML 文件
* @param allFiles 输入型参数, 记录文件夹中的所有文件
*/
private void enumFile(File file, List<File> allFiles) {
// 列出当前文件的所有文件 / 文件夹
File[] files = file.listFiles();
for (File curFile : files) {
if (curFile.isDirectory()) { // 如果是文件夹
enumFile(curFile, allFiles);
} else if (curFile.getName().endsWith(".html")) { // 是 HTML 文件
allFiles.add(curFile);
}
}
}
}
可以测试一下这个 allFiles
,执行情况如下
enumFile(root, allFiles);
for (File file : allFiles) {
System.out.println(file.getAbsolutePath());
}
System.out.println("总共 " + allFiles.size() + " 个文件");
5.2 预处理文件
获取完所有的 HTML 文件之后,就可以进行对这些文件进行预处理了,这一步目的是:获取 HTML 文档中的标题,url,正文
(1)遍历allFiles
中的文件,定义一个方法 parseHtml()
来“加工”这些文件
然后在parseHtml()
中定义三个方法parseTitle(), parseUrl(), parseContent()
来分别获取标题,url,正文
5.2.1 获取标题
(2)parseTitle()
获取标题,如下,在这些 Java 文档中,我们可以简单地将文件名视为标题,但是还需要特殊处理——将 .html
去掉
private String parseTitle(File file) {
String rm = ".html";
return file.getName().substring(0, file.getName().length() - rm.length());
}
5.2.2 获取 URL
(3)parseUrl()
获取 url,在本地中,我们存储了这些静态页面,故而有它们的位置,但是如果用户点击搜索结果,那么跳转到的是线上的网址,所以我们需要获取这些线上的网址
如下分别是同一个文档,但是分别是线上和本地的路径
- 线上:https://docs.oracle.com/javase/8/docs/api/java/util/Arrays.html
- 本地:D:\MyJavaCode\documentSearcher\jdk-8u361-docs-all\docs\api\java\util\Arrays.html
可以发现,它们的后缀(从 docs 文件夹开始)都是一样的,所以我们可以根据一段固定的前缀 + 本地文档路径的固定后缀来「拼接」得到 url
这制作的是 api 的文档,所以本地的路径可以选用 api\ 之后的路径作为后缀,也就是👇(而白色字体路径就是上文的 ROOT_PATH)
然后再拼接上这段前缀👇
简单来说就是:线上文档的前缀 「拼接」本地文档的后缀,就可以得到文档对应的 url
private String parseUrl(File file) {
// 线上文档的前缀
String prefix = "https://docs.oracle.com/javase/8/docs/api/";
// 本地文档的后缀
String suffix = file.getAbsolutePath().substring(ROOT_PATH.length());
return prefix + suffix;
}
随后生成代码测试一下,传入 Arrays.html 这个文件,如下可以看出,url 顺利生成,并且亲测可以访问
5.2.3 获取正文
(4)parseContent()
获取正文
由于文件都是 HTML 格式的文件,所以自然也就各种各样的标签,比如 html 和 js 中的标签和内容,想要去掉这些,可以使用replaceAll
搭配正则表达式完成这个工作,在 replaceAll
之前,需要先将文章内容转化成字符串
Ⅰ. 由于读取的是 HTML 文件,所以这里需要使用字符流进行读取,可以使用 FileReader
,但是这里可以有更好的选择——BufferedReader
,它相比 FileReader
有一个内置的缓冲区,理论上来说更能够减少 IO 次数,它的使用方法大致和 fileReader
一样,但是创建的时候需要包装一个 fileReader
,也就是:BufferedReader reader = new BufferedReader(new FileReader(文件对象))
;除此之外,它的构造方法还有第二个参数,可以指定缓冲区的大小,这里我设置为 一兆(1024 * 1024)
Ⅱ. 将文件中的所有数据转成字符串,这个过程中顺便将换行替换成空格,因为换行符在这里没什么意义,并且我们不希望在前端显示「摘要」的时候出现换行
/**
* 将文件的全部内容都读取成 字符串
* @return
*/
private String readFile(File file) {
try (BufferedReader bufferedReader = new BufferedReader(new FileReader(file), BUFFERED_CAPACITY)) {
StringBuilder builder = new StringBuilder();
while (true) {
int ret = bufferedReader.read();
if (ret == -1) {
return builder.toString();
}
char ch = (char) ret;
if (ch == '\r' || ch == '\n') { // 如果是换行符则用空格替换
ch = ' ';
}
builder.append(ch);
}
} catch (IOException e) {
e.printStackTrace();
}
return "";
}
Ⅲ. 去除 js 中的内容,以及 HTML 中的标签都替换成空格
使用replaceAll()
和正则表达式进行替换,涉及到正则表达式的使用,这里不多介绍。最后,再将文本中连续的空格都合并成一个空格,就成功完成了文件中正文的提取
public String parseContentByRegex(File file) {
// 使用正则表达式,需要先将文章内容转化成字符串
String content = readFile(file);
// replaceAll 返回的是 一个新的 String 对象
content = content.replaceAll("<script.*?>(.*?)</script>", " "); // 先去除 js 中的文本
content = content.replaceAll("<.*?>", " "); // 去除 标签对
content = content.replaceAll("\\s+", " "); // 合并多余的空格
return content;
}
至此,一个文档的标题,url,正文就能提取出来了,然后再将这些包装成一个「文档对象(Document
)」,再加入到索引中
所以parseHtml
的代码就差一步
private void parseHtml(File file) {
String title = parseTitle(file);
String url = parseUrl(file);
String content = parseContent(file);
// 将这三个变量包装成Document,并添加到索引结构中
// todo: 这个对象添加到索引结构中
}
6. 索引
上面是 Parser 类,负责 HTML 的预处理,然后将它们加入到索引中,完成索引的构建,但是刚刚还差一步:将对象添加到索引中。
而这时候就需要一个类 Index ,来专门维护索引,并提供一些操作索引的 API
6.1 正排索引和倒排索引
以下是我们前面提到的两种索引的概念,但是实际上还要做出一个小修改
- 正排索引:根据文档 ID 能够得到相应的 文档,显然这个结构可以让人想起了 哈希表,但是 ArrayList 更适合,下标和元素相对应
- 倒排索引:根据某个词,可以得到相关联的 List<文档ID>,显然可以使用哈希表
由于是根据搜索的分词结果来筛选文章的,例如前端搜索 Arrays
,那么后端就应该整理出 Arrays 相关的文档列表,但是仅仅如此嘛?当然不是,我们还要进行「相关性排序,降序」,所以在倒排索引中的 value 值,不能只是 List<文档ID>
,List
中的元素除了存储文档 ID ,还需要存储该文档的「权值」。
⭐因此,这里需要是List<Weight>
,而 Weight
其中有两个属性:①文档ID,②该文档的权值
故而就可以确定正排索引和倒排索引的数据结构了
正排索引:ArrayList<Doucment> ,Document 对象包装了文件的 ID,正文,url,标题
倒排索引:HashMap<String, List<Weight>>,String 是分词,Weight 对象包装了 文档ID 和 文档权值
以下分别是 Document 和 Weight 类
@Data // lombok 中提供的注解,提供 toString, get 和 set 等常用方法
public class Document {
private int documentId;
private String title;
private String url;
private String content;
public Document() {};
public Document(String title, String url, String content) {
this.title = title;
this.url = url;
this.content = content;
}
}
@Data
public class Weight {
private int documentId;
private int weight; // 文档权重
public Weight() {}
public Weight(int documentId) {
this.documentId = documentId;
}
// 不过这里还需要一个方法,用来后续计算权重,这里先不做实现
public int calWeight() {
return 0;
}
}
然后是正排索引和倒排索引的创建
// 正排索引:文档 ID -> 文档,文档 ID 就是 ArrayList 中的下标
ArrayList<Document> forwardIndex = new ArrayList<>();
// 倒排索引:词 -> 文档 ID 结合, 考虑到根据词所找到的 文档集合 应该具备优先级,所以此处存储的除了 Id 还 应该有优先值
HashMap<String, ArrayList<Weight>> invertedIndex = new HashMap<>();
6.2 往正排索引中添加元素
这个实现起来比较简单,参数是「待加入」的文档,这里还需要设置这个文档的 ID,而文档 ID 就是加入 forwardIndex
之后的下标,直接上代码
// 构建正排索引
public void buildForward(Document document) {
// 待加入文档的ID
document.setDocumentId(forwardIndex.size());
forwardIndex.add(document);
}
6.3 往倒排索引中添加元素
6.3.1 大致思路
构建倒排索引:这里实现起来会比较复杂
⭐由于倒排索引是:某个关键词 —→ 相关的文档列表。
那么我们在往invertedIndex
添加一个文档的时候,我们需要得到出这篇文档中所有的关键词,然后将这些在倒排索引中获取这些关键词的 List<Weight>
列表,往列表中添加当前文档
如下,在不考虑权重的情况下,假设文档5的分词结果有array
和 pig
,现在要将文档5插入invertedIndex
中,对于 pig
这个词,invertedIndex
中本来就有这个 key 值,所以将这个词关联的List
再加上文档5即可,而对于array
,invertedIndex
中没有这个 key 值,所以就需要新建一个键值对,然后在 array
相关联的 List
列表中加上文档 5
6.3.2 计算权重(相关性)
以上就是添加到倒排索引的简单思路,但是这里还需要考虑到权重的问题,如何去定义「相关性高低」,这里涉及到的学问很多,我们不过多深究,这里简单的认为⭐「某个关键词出现的次数越多,相关性越强,并且 权重 = 关键词在标题中出现的次数 * 系数 + 关键词在正文中出现的次数」
所以我们上文中提到的Weight
类中的 calWeight()
计算权重的方法就可以编写了,具体的系数是多少,看大家心情,这里就设置为 10,👇
/**
* 计算权重,并赋值给 weight 属性
* @param titleCnt 关键词在标题中出现的次数
* @param contentCnt 关键词在正文中出现的次数
*/
public void calWeight(int titleCnt, int contentCnt) {
int w = titleCnt * 10 + contentCnt; // 计算权重
this.setWeight(w); // 赋值
}
6.3.3 实现
整体思路:统计这个文档中所有关键词在标题和在正文中出现的次数,方便统计这个关键词在本文中的权重,然后再将这些关键词整合到倒排索引中
(1)创建一个 Counter 类,负责记录关键词在标题和正文中出现的次数
(2)创建 HashMap<String, Counter>
对象,将关键词和出现次数相对应
(3)开始统计
(4)遍历该文档中的所有关键词,并整合到倒排索引中
代码如下
// 构建倒排索引
public void buildInverted(Document document) {
class Counter {
int titleCnt;
int contentCnt;
public Counter(int titleCnt, int contentCnt) {
this.titleCnt = titleCnt;
this.contentCnt = contentCnt;
}
}
// 1. 记录关键词 出现的 次数
HashMap<String, Counter> counterMap = new HashMap<>();
// 2. 对标题进行分词,统计出现次数
List<Term> terms = ToAnalysis.parse(document.getTitle()).getTerms();
for (Term term : terms) {
// 获取分词字符串
String word = term.getName();
Counter counter = counterMap.get(word);
// 如果为空,说明还没有出现过这个关键词
if (counter == null) {
counter = new Counter(1, 0); // 标题出现次数赋值为 1
counterMap.put(word, counter);
} else { // 出现过这个分词
counter.titleCnt ++;
}
}
// 3. 对正文进行分词,统计出现次数
terms = ToAnalysis.parse(document.getContent()).getTerms();
for (Term term : terms) {
String word = term.getName();
Counter counter = counterMap.get(word);
if (counter == null) {
counter = new Counter(0, 1);
counterMap.put(word, counter);
} else {
counter.contentCnt ++;
}
}
// 4. 将分词结果整合到倒排索引中
// 遍历文档的所有分词结果
for (Map.Entry<String, Counter> entry : counterMap.entrySet()) {
String word = entry.getKey();
Counter counter = entry.getValue();
// 将文档 ID 和 文档权值 包装起来
Weight newWeight = new Weight(document.getDocumentId()); // 存入文档 ID
newWeight.calWeight(counter.titleCnt, counter.contentCnt);
// 取出该关键词相关联的 文档列表
List<Weight> take = invertedIndex.get(word);
// 倒排索引 中没有这个关键词
if (take == null) {
ArrayList<Weight> newList = new ArrayList<>(); // 新建列表
newList.add(newWeight);
invertedIndex.put(word, newList);
} else { // 出现过
take.add(newWeight); // 关联文档数增加
}
}
}
6.4 往索引中添加元素
上述就是正排索引和倒排索引的添加逻辑了,我们可以使用一个方法合并一下:
// 往索引中添加文档
public void add(String title, String url, String content) {
Document document = new Document(title, url, content);
// 自此,Document还没设置ID,ID进入 buildForward() 会设置
buildForward(document);
buildInverted(document);
}
6.5 补充 parseHtml() 方法
现在索引类也完成一半多了,接下来将刚刚在Parser
类中实现到一半的 parseHtml()
方法补充完整
(1)由于这个类要操作索引,所以需要在类中实例化索引这个对象
(2)将 parseHtml()
方法补充完整,如下
private void parseHtml(File file) {
String title = parseTitle(file);
String url = parseUrl(file);
String content = parseContent(file);
// 将这三个变量包装成Document,并添加到索引结构中
index.add(title, url, content);
}
6.6 获取文档
这里就很容易实现了,基于正排索引和倒排索引,可以实现:
Ⅰ. 根据文档 ID 获取文档
// 传入文档 Id,获取文档对象
public Document getDocument(int documentId) {
return forwardIndex.get(documentId);
}
Ⅱ. 传入关键词,获取和这关键词相关的文档列表
// 获取和 关键词 相关的文档
public List<Weight> getRelatedDocument(String word) {
return invertedIndex.get(word);
}
6.7 测试
上面我们就已经实现了:预处理所有 HTML 文档,并将它们整合到索引之中
我们可以测试一下,在 Parser
类中的 run()
方法中,解析完 HTML 文件之后,在倒排索引中获取和"array"
相关的文章,然后看一下有哪些
public void run() {
// 1. 获取 api 这个文件对象
File root = new File(ROOT_PATH);
// 2. 用来接收 api 文件夹中的所有 HTML 文件, 是一个输入型参数
List<File> allFiles = new ArrayList<>();
enumFile(root, allFiles);
System.out.println("总共 " + allFiles.size() + " 个文件");
// 3. 解析这里面的所有文件,然后包装 Document 对象,添加到索引中
for (File file : allFiles) {
System.out.println("开始解析文件:" + file.getAbsolutePath());
parseHtml(file);
}
// 测试代码
List<Weight> tests = index.getRelatedDocument("array");
for (Weight test : tests) {
// 获取文档,然后打印 文档 的 url
System.out.println(index.getDocument(test.getDocumentId()).getUrl());
}
System.out.println("总共有 " + tests.size() + " 个相关文档");
}
打印结果如下,检验了几个 url,没有发现什么问题,后面再进行进一步的检测
6.8 持久化保存索引结构
上面虽然完成了索引的构建,但是只是在内存中,重启就需要重新构建。
在进行索引的构建的时候,特别是在静态页面数量庞大的时候,索引的构造可能会花费相当的时间,所以可以考虑将索引进行持久化保存,这样机器重启的时候即便丢失内存数据,也能够花更少的时间来从文件中读取索引到内存中
(1)首先指定正排索引和倒排索引存放在哪个文件
// 索引存放目录
private static final String INDEX_PATH = "D:\\MyJavaCode\\documentSearcher\\jdk-8u361-docs-all\\";
// 正排索引和倒排索引的文件名,任意一个和 INDEX_PATH 拼接就可以得到完整路径
private static final String FORWARD_PATH = "forward.txt";
private static final String INVERTED_PATH = "inverted.txt";
(2)先检验 上代码中的INDEX_PATH
这个文件夹是否存在,如果不存在,使用mkdirs()
方法创建这个目录
(3)需要实例化一个 JackSon 包中的 ObjectMapper 对象,来进行对象的写入和读取
(4)使用objectMapper.writeValue(File f, Object value)
,能够将指定内容写入到指定文件之中
// 持久化存储索引
public void save() {
System.out.println("开始持久化索引");
long beg = System.currentTimeMillis();
File indexFile = new File(INDEX_PATH);
if (!indexFile.exists()) { // 如果不存在
indexFile.mkdirs();
}
// 打开这两个文件
File forwardFile = new File(INDEX_PATH + FORWARD_PATH);
File invertedFile = new File(INDEX_PATH + INVERTED_PATH);
try {
objectMapper.writeValue(forwardFile, forwardIndex);
objectMapper.writeValue(invertedFile, invertedIndex);
} catch (IOException e) {
throw new RuntimeException(e);
}
long end = System.currentTimeMillis();
System.out.println("索引持久化完成,总共耗时 " + (end - beg) + " ms");
}
写完持久化之后,我们就可以在 Parser
类中的 run()
方法的最后中添加 save()
方法了,添加之后,run()
方法的使命就算是完成了。以下是 run()
方法的完整代码
public void run() {
long beg = System.currentTimeMillis();
// 1. 获取 api 这个文件对象
File root = new File(ROOT_PATH);
// 2. 用来接收 api 文件夹中的所有 HTML 文件, 是一个输入型参数
List<File> allFiles = new ArrayList<>();
enumFile(root, allFiles);
System.out.println("总共 " + allFiles.size() + " 个文件");
// 3. 解析这里面的所有文件,然后包装 Document 对象,添加到索引中
for (File file : allFiles) {
System.out.println("开始解析文件:" + file.getAbsolutePath());
parseHtml(file);
}
index.save();
long end = System.currentTimeMillis();
System.out.println("索引制作完成,总共耗时 " + (end - beg) + " ms");
}
以上👆就是索引的存储位置了,使用 vscode 打开,就是一串很长很长的玩意👇
不过这还没法验证,所以我们再编写 「读取索引到内存中」的代码
6.9 将索引结构从文件中加载到内存中
(1)创建正排索引和倒排索引的文件对象
(2)使用object.readValue(File src, Class<T> valueType)
来将原文件中的数据以 「第二个参数的形式」读取出来,并返回。这里 jackson 是将这个结构的字符串重新转换成一个对象,所以需要指定这个对象的类型,但是Java中,不允许以对象的类型作为参数,因此,JSON还提供了一个工具类TypeReference<>
⭐简而言之,使用的时候,第二个参数传:new TypeReference<参数类型>() {}
即可
所以加载索引到内存中,代码如下:
// 将索引读取到内存中
public void load() {
File forwardFile = new File(INDEX_PATH + FORWARD_PATH);
File invertedFile = new File(INDEX_PATH + INVERTED_PATH);
try {
forwardIndex = objectMapper.readValue(forwardFile, new TypeReference<ArrayList<Document>>() {});
invertedIndex = objectMapper.readValue(invertedFile, new TypeReference<HashMap<String, List<Weight>>>() {});
} catch (IOException e) {
throw new RuntimeException(e);
}
}
自此,我们再测试一下当 load()
方法执行完的时候,根据"array"
来搜索倒排索引,能得到多少个结果
public static void main(String[] args) {
Parser parser = new Parser();
Index index = parser.index;
index.load(); // 加载
List<Weight> tests = index.getRelatedDocument("array");
for (Weight test : tests) {
System.out.println(index.getDocument(test.getDocumentId()).getUrl());
}
System.out.println("总共 " + tests.size() + " 条数据");
}
执行情况如下
7. 多线程优化解析速度
至此Parser
和 Index
类算是基本完成了,但是实际上Parser
类在构建索引的时候是很费时的,在我的电脑上,一整个 run()
方法跑完耗时如下,11443ms,大概 11 秒,如果是电脑长时间没有构建索引(电脑没有缓存的情况下),那么会花更多时间,ps:我的机器在重启后第一次制作索引能花费 30 秒
分析一下 run()
方法中的代码,一共做了三件事:
Ⅰ. 枚举了指定目录下的所有文件 Ⅱ. 对每个 HTML 文件进行解析 Ⅲ. 将索引持久化
而当我们对 第二步 进行计时的时候,可以发现:👇
基本上就是 第二步 耗时最大,其他加起来不如它的一根毛,所以我们可以对此使用多线程进行优化
7.1 使用线程池完成文件的解析
(1)创建线程池ExecutorService threadPool = Executors.newFixedThreadPool(n)
,当然也可以使用其他线程池
(2)将parseHtml
作为任务进行提交
代码如下
// 3. 解析这里面的所有文件,然后包装 Document 对象,添加到索引中
// 创建线程池
ExecutorService threadPool = Executors.newFixedThreadPool(5);
for (File file : allFiles) {
System.out.println("开始解析文件:" + file.getAbsolutePath());
// 提交任务
threadPool.submit(new Runnable() {
@Override
public void run() {
parseHtml(file);
}
});
}
7.2 线程安全问题
涉及到多线程,那自然容易出现线程安全之类的问题,parseHtml()
这个方法是多个线程都要去执行的方法,需要检查一下哪里需要修改,⭐特别是涉及到 同时操作 「共享资源」的代码
7.2.1 parseHtml 方法
这是 parseHtml
中的内部代码,其中parseTitle(), parseUrl(), parseContent()
这三个方法都是「各自的线程操作各自的文件」,不会互相影响
然后再看看 index.add()
方法,创建一个文档对象也是安全的
7.2.2 为正排索引加同步代码块
那就只剩buildForward
和 buildInverted
这两个方法需要考虑了,先看看buildForward
👇
显然这两行代码都在操作 forwardIndex
这个共享资源,所以这里直接加上synchronzied
管理即可,至于锁对象,可以是 this
,但是没必要,等会再谈。
7.2.3 为倒排索引加同步代码块
而buildInverted
这个方法👇,只有图示代码在操作 invertedIndex
这个共享资源
所以这段代码也加上 synchronized
修饰即可,而锁对象可以是 this
,但是没必要。
⭐在这两段代码中,buildForward
和 buildInverted
分别只操作了 forwardIndex
和 invertedIndex
这两个对象,所以可以创建两个对象forwardLock
和 invertedLock
来分别作为这两段同步代码块的锁对象,这相比 this
会更高效,如下
🍓正排索引
🍓倒排索引
7.3 CountDownLatch
修改成多线程之后,如果这时候来测时间是不准的,并且代码还有一点的缺陷。
如下,在 for
循环结束之后,索引就制作完了吗?答案是否定的,循环结束之后,这些任务都提交到了线程池中的阻塞队列中一个个等待线程来执行,而 main 方法还在往下执行。所以想要让所有「任务」都执行完之后再进行其他操作的话,就可以使用 CountDownLatch
,⭐它可以初始化一个值,然后当有一个线程完成一个任务的时候,这个值就自减,当计数器的值变为0时,在 CountDownLatch
上await()
的线程就会被唤醒
// 3. 解析这里面的所有文件,然后包装 Document 对象,添加到索引中
// 创建线程池
ExecutorService threadPool = Executors.newFixedThreadPool(5);
for (File file : allFiles) {
System.out.println("开始解析文件:" + file.getAbsolutePath());
// 提交任务
threadPool.submit(new Runnable() {
@Override
public void run() {
parseHtml(file);
}
});
}
同时,在线程池执行完任务的时候,由于线程池中的线程不是守护线程,这些线程会影响到进程的结束,所以可以使用threadPool.shutdown()
来手动关闭线程池中的线程
这个是最终版本的 run()
方法
// 多线程执行索引的制作
public void runThread() throws InterruptedException {
long beg = System.currentTimeMillis();
// 1. 获取 api 这个文件对象
File root = new File(ROOT_PATH);
// 2. 用来接收 api 文件夹中的所有 HTML 文件, 是一个输入型参数
List<File> allFiles = new ArrayList<>();
enumFile(root, allFiles);
System.out.println("总共 " + allFiles.size() + " 个文件");
long parseBeg = System.currentTimeMillis();
// 3. 解析这里面的所有文件,然后包装 Document 对象,添加到索引中
// 创建线程池
ExecutorService threadPool = Executors.newFixedThreadPool(5);
CountDownLatch latch = new CountDownLatch(allFiles.size()); // 这个值设定为 任务的总量,也就是文件的个数
for (File file : allFiles) {
System.out.println("开始解析文件:" + file.getAbsolutePath());
// 提交任务
threadPool.submit(new Runnable() { // 也可以使用 lambda 表达式
@Override
public void run() {
parseHtml(file);
latch.countDown(); // 任务执行完后,计数器 -1
}
});
}
latch.await(); // 主线程需要等待所有任务完成
threadPool.shutdown(); // 手动关闭线程池中的线程
long parseEnd = System.currentTimeMillis();
System.out.println("解析文件总共耗时" + " " + (parseEnd - parseBeg));
// 4. 进行索引的存储
index.save();
long end = System.currentTimeMillis();
System.out.println("索引制作完成,总共耗时 " + (end - beg) + " ms");
}
7.4 测试
完成上述代码之后,我们再执行一次,可以看出:相比单线程的执行时长,这里优化了大概 40% 多的时间。而我们在线程池中设定的线程数量为 5,但是这可能不是最优解,需要综合测试和其他各项指标来设定线程数量,此处不过多讨论
8. 搜索模块
8.1 搜索逻辑
接着就是搜索模块了,这个是搜索引擎的核心模块,这个模块我们创建一个类Searcher
来进行管理,其搜索逻辑大致是:用户输入查询字符串query
,后端拿到query
之后对它进行分词处理,「针对每个分词结果」,都去倒排索引中取出相关联的文档列表,然后将这些文档列表进行整合并做权值降序处理。最后这个数据还不能直接返回给前端,由于真正展示给用户的是标题,url,和摘要,所以还要在正文中把摘要提取出来,然后使用一个类SearchResult
来包装 标题,url,摘要,并作为返回给前端的对象
8.2 Searcher 类
首先创建 Searcher
类,添加@Service
注解
然后重点来了,由于我们前边Parser
类中的run()
方法已经完成了索引的制作,并且进行了持久化存储,所以我们就需要 Searcher
类在工作的时候,不需要去创建索引,只需要在启动的时候将索引加载到内存中
所以我们就可以通过构造方法注入的方式注入Index
对象,然后顺便在构造方法中完成索引的加载
@Service
public class Searcher {
private Index index ;
@Autowired
public Searcher(Index index) {
this.index = index; // 构造方法注入 Index 对象
index.load(); // 加载索引到内存中
}
}
8.2 停用词
当用户在搜索框中输入query
时,例如输入“a array”的时候,query
的分词结果为a
,(空格),array
,显然,这个a
和空格对于我们的文档搜索引擎来说没什么意义,它无法降低搜索范围,甚至会影响搜索的效率。
因此,我们将类似的词汇称为「停用词」,我们还需要对query
进行一定的过滤,去掉停用词。所以下一步的思路就有了:在 Searcher
启动的时候就将停用词加载到内存中
而停用词可以在网上获取,或者也可以从以下链接中获取这个文件
Gitee 停用词链接 参考博客停用词汇总)
8.3 加载停用词
然后我们将文件放到自己指定的目录,我就放在这里:
然后在 Searcher
类中指定一下路径
private static final String STOP_WORD_PATH = "D:\\MyJavaCode\\documentSearcher\\jdk-8u361-docs-all\\stopWords.txt";
打开这个文件,可以看出这个文件的格式大致如下,就是一行就是一个停用词,所以我们可以通过BufferedReader
和它的readLined()
来获取这些停用词(🍓readLine()
这个方法会读取一行数据,并且读取到的数据不包括换行符)。同时我们使用HashSet
来存储这些停用词
加载停用词代码如下:
private HashSet<String> stopDict = new HashSet<>(); // 停用词词典
// 加载停用词
private void loadStopWords() {
// 创建 BufferedReader 对象, 然后设定缓冲区大小为 1 兆
try (BufferedReader bufferedReader = new BufferedReader(new FileReader(STOP_WORD_PATH), 1024 * 1024)) {
while (true) {
String line = bufferedReader.readLine(); // 读取一行数据
if (line == null) {
return;
}
stopDict.add(line);
}
} catch (IOException e) {
e.printStackTrace();
}
}
然后我们就可以将 Searcher
类的构造方法补充完整了
@Autowired
public Searcher(Index index) {
this.index = index; // 构造方法注入 Index 对象
index.load(); // 加载索引到内存中
loadStopWords(); // 加载停用词
}
8.4 Search 方法
这个方法就是搜索引擎的执行方法了,根据上面所说的执行逻辑,对于每一个搜索结果都是SearchResult
,它有标题,url,摘要属性,如下
@Data
public class SearchResult {
private String title;
private String url;
private String desc; // 摘要
public SearchResult(String title, String url, String desc) {
this.title = title;
this.url = url;
this.desc = desc;
}
}
所以这个方法的返回值应该是List<SearchResult>
,传入的参数是query
,也就是前端传递的查询字符串
public List<SearchResult> search(String query) {
}
8.4.1 过滤查询字符串
public List<SearchResult> search(String query) {
// 得到原本的分词结果
List<Term> terms = ToAnalysis.parse(query).getTerms();
// 临时过滤结果
List<Term> tmpTerms = new ArrayList<>();
for (Term term : terms) { // 开始过滤
String word = term.getName();
if (!stopDict.contains(word)) { // 如果不是是停用词
tmpTerms.add(term);
}
}
terms = tmpTerms; // 过滤完成
}
8.4.2 获取文档列表
过滤之后的所有分词再去倒排索引中取出所有文档列表
terms = tmpTerms; // 过滤完成
List<Weight> relatedDocuments = new ArrayList<>();
for (Term term : terms) {
String word = term.getName();
List<Weight> weights = index.getRelatedDocument(word);
if (weights == null) { // 如果倒排索引中没有这个词
continue;
}
relatedDocuments.addAll(weights);
}
对所有相关的文档直接添加到一个数组(列表)里,但是这样有点小问题,后面再说,先继续往下走
8.4.3 将结果包装成搜索结果
有了这个列表后,就对它进行权重的降序处理:
// 进行权重降序
relatedDocuments.sort((Weight x, Weight y) -> {return y.getWeight() - x.getWeight();});
由于Weight
对象只有两个属性:id 和 weight
,再根据 index.getDocument()
方法获取这个具体的文档,根据正文获取摘要,再使用SearchResult
作为返回值List
中的元素即可,代码如下,其中generateDesc()
生成描述的方法一会实现
// 记录最终的搜索结果
List<SearchResult> results = new ArrayList<>();
for (Weight weight : relatedDocuments) {
// 获取这个文档
Document document = index.getDocument(weight.getDocumentId());
String title = document.getTitle(); // 标题
String url = document.getUrl(); // url
String desc = generateDesc(document.getContent(), terms); // 生成描述
SearchResult result = new SearchResult(title, url, desc);
results.add(result);
}
return results;
8.4.4 生成摘要
根据正文生成摘要有很多方法,这里我们采用「找到第一次出现 分词 的位置」,以该位置前 50 个字符,以该位置后100个字符的区间,认定是摘要
所以generateDesc()
方法代码如下
// 根据正文生成描述
private String generateDesc(String content, List<Term> terms) {
// 记录第一次出现的位置,如果没有出现,默认是 -1
int firstPos = -1;
// 遍历 过滤之后的 分词结果
for (Term term : terms) {
String word = term.getName();
firstPos = content.toLowerCase().indexOf(" " + word + " ");
if (firstPos != -1) { // 如果出现了,则跳出循环
break;
}
}
if (firstPos == -1) { // 如果没有出现, 那就默认正文的前 150 个
return content.substring(0, content.length() <= 150 ? content.length() : 150) + "..."; // 加 "..." 为了美观,表示这是摘要
}
int beg = firstPos - 50 >= 0 ? firstPos - 50 : 0; // 头
int end = beg + 150 <= content.length() ? beg + 150 : content.length(); // 尾
return content.substring(beg, end) + "...";
}
其中,在找分词第一个出现位置的时候firstPos = content.toLowerCase().indexOf(" " + word + " ")
Ⅰ. 由于 Ansj
提供的分词方法,会将传入的参数全部转成小写,再进行分词,所以这里在正文中找分词位置的时候,也需要将正文转成小写进行匹配
Ⅱ. 在匹配分词的时候,通常需要是「全词匹配」,通俗来说就是你搜索「驴」,但是不能给你匹配「驴打滚」,你想要array
,那arraylist
肯定不能是最优结果
8.4.5 整合文档列表
在「8.4.2 获取文档列表」这一步骤中,直接将所有分词相关联的列表整合到一起还是有点不妥,例如,当我搜索array list
的时候,就会根据array
获取一份文档列表,又根据list
获取一份文档列表,然后实际上这两个文档列表可能是有交集的,体现到搜索结果中就是可能出现两篇一样的文档。
因此,我们需要对这 n 个文档列表进行合并,如果 ID 相同,权重就进行简单的合并
这里使用的算法有点类似于力扣上的一个题:合并K个升序链表
算法思路:
Ⅰ. 我们将这 K 个列表中的文档按照 ID 进行升序
Ⅱ. 创建一个小根堆,将每个列表中的第一个元素存放到堆中
Ⅲ. 取出堆顶文档,判断和上一次取出的文档 ID 是否一样,如果一样,那就进行权重合并,如果不一样,就将该文档存放到结果集中
Ⅳ. 将这个堆顶元素在列表中的下一个元素存入堆中
Ⅴ. 重复 Ⅲ, Ⅳ 两个步骤,直到堆中没有元素
我们在进行权重排序之前进行这个优化即可,如下就是优化后的 search()
方法和merge()
方法
public List<SearchResult> search(String query) {
// 得到原本的分词结果
List<Term> terms = ToAnalysis.parse(query).getTerms();
// 临时过滤结果
List<Term> tempTerms = new ArrayList<>();
for (Term term : terms) { // 开始过滤
String word = term.getName();
if (!stopDict.contains(word)) { // 如果不是是停用词
tempTerms.add(term);
}
}
terms = tempTerms; // 过滤完成
List<List<Weight>> waitToMerge = new ArrayList<>();
for (Term term : terms) {
String word = term.getName();
List<Weight> weights = index.getRelatedDocument(word);
if (weights == null) {
continue ;
}
waitToMerge.add(weights);
}
List<Weight> relatedDocuments = merge(waitToMerge);
if (relatedDocuments == null || relatedDocuments.size() == 0) {
return null;
}
// 进行权重降序
relatedDocuments.sort((Weight x, Weight y) -> {return y.getWeight() - x.getWeight();});
// 记录最终的搜索结果
List<SearchResult> results = new ArrayList<>();
for (Weight weight : relatedDocuments) {
// 获取这个文档
Document document = index.getDocument(weight.getDocumentId());
String title = document.getTitle(); // 标题
String url = document.getUrl(); // url
String desc = generateDesc(document.getContent(), terms); // 生成描述
SearchResult result = new SearchResult(title, url, desc);
results.add(result);
}
return results;
}
// 表示二维数组(列表)中的坐标
private static class Pair {
int row;
int col;
public Pair(int x, int y) {
this.row = x;
this.col = y;
}
}
// 对文档列表进行合并
private List<Weight> merge(List<List<Weight>> waitToMerge) {
// 去重, 进行权重的累加
if (waitToMerge == null || waitToMerge.size() == 0) return null;
else if (waitToMerge.size() == 1) return waitToMerge.get(0);
// 1. 对其中所有的列表进行排序, 按照 ID 进行升序
for (List<Weight> cur : waitToMerge) {
cur.sort((Weight x, Weight y) -> {return x.getDocumentId() - y.getDocumentId();});
}
// 2. 构建小根堆
PriorityQueue<Pair> heap = new PriorityQueue<>((Pair x, Pair y) -> {
// 同样是按照 ID 升序
int xid = waitToMerge.get(x.row).get(x.col).getDocumentId();
int yid = waitToMerge.get(y.row).get(y.col).getDocumentId();
return xid - yid;
});
// 3. 所有一维列表中的第一个元素
for (int i = 0; i < waitToMerge.size(); i ++) {
heap.offer(new Pair(i, 0));
}
// 4. 开始进行合并, 合并的结果
List<Weight> result = new ArrayList<>();
while (!heap.isEmpty()) {
Pair pollPair = heap.poll(); // 弹出第一个坐标
Weight pollWeight = waitToMerge.get(pollPair.row).get(pollPair.col);
Weight prevWeight = null; // 上一个入结果集的元素
if (!result.isEmpty()) {
prevWeight = result.get(result.size() - 1);
}
// 如果不存在上一个元素, 或者两者 ID 不同, 就直接入结果集
if (prevWeight == null || prevWeight.getDocumentId() != pollWeight.getDocumentId()) {
result.add(pollWeight);
} else { // 否则, 进行权重的叠加
prevWeight.setWeight(prevWeight.getWeight() + pollWeight.getWeight());
}
// 然后入 pollPair 所在列表的下一个元素, 且合法
if (pollPair.col + 1 < waitToMerge.get(pollPair.row).size()) {
heap.offer(new Pair(pollPair.row, pollPair.col + 1));
}
}
}
9. 前端页面
9.1 模板
事已至此,先搞个前端的搜索页面吧,本人前端只懂皮毛,不是本文重点,以下是前端页面的代码:
这就是搜索页面的一个大致效果,但是可能没那么好看
9.2 向后端发送 ajax 请求
前端的逻辑很简单,就是在搜索框中输入 query
之后,将这个 query
发送个后端,后端处理完将结果发回给前端进行渲染即可。
我们约定后端的路由为/search
,然后为前端的搜索按钮设置一个事件,触发事件后发送 ajax 请求
<script>
let button = document.querySelector("#search-btn");
button.onclick = function() {
let input = document.querySelector(".header input");
let query = input.value;
console.log(query);
// 然后开始向后端发送请求
jQuery.ajax({
type: "GET",
url: "/search",
data: {
"query": query
},
// 接收响应数据,并且进行渲染
success: function(result) {
}
});
}
</script>
发送完query
之后,等待接收后端的数据然就行了
10. Controller 代码
写完前端写后端,这里先写个大致框架:
⭐ SeacherController
首先定义一个 SearcherController
类,并加上 @RestController
注解,其中有一个方法getSearchResult()
,并且为该方法设置一个路由/search
,而这个业务方法只需要去调用Searcher
类中的search
方法就可以了
@RestController
public class SearcherController {
@Autowired
private Searcher searcher;
@RequestMapping("/search")
public List<SearchResult> getSearchResult(String query) {
return searcher.search(query);
}
}
11. 统一数据返回
同时,我们可以为所有返回给前端数据的代码加上一层——统一数据返回处理,通过@ControllerAdvice
注解和实现接口ResponseBodyAdvice
来实现
也可以使用简单一点的,通过一个Result
类,里面提供succeed
和fail
方法,并要求:返回给前端的数据都需要通过这个类中的其中一个方法来返回数据。
并且Result
中返回的数据类型是HashMap
,其中有三个属性:① state
表示业务状态,一般用 200 来表示顺利,② msg
表示其他信息,可以用来传递给前端,③ data
表示传递给前端的数据
Result
类实现如下
public class Result {
// 如果是顺利返回前端数据
public static HashMap<String, Object> succeed(Object body) {
HashMap<String, Object> result = new HashMap<>();
result.put("state", 200); // 状态码 200 表示正常, -1 表示异常
result.put("data", body); // 包装返回值
result.put("msg", "");
return result;
}
public static HashMap<String, Object> succeed(Object body, String msg) {
HashMap<String, Object> result = new HashMap<>();
result.put("state", 200); // 状态码 200 表示正常, -1 表示异常
result.put("data", body); // 包装返回值
result.put("msg", msg);
return result;
}
// 异常返回前端数据
public static HashMap<String, Object> fail(Object body) {
HashMap<String, Object> result = new HashMap<> ();
result.put("state", -1);
result.put("msg", "");
result.put("data", body);
return result;
}
public static HashMap<String, Object> fail(Object body, int state) {
HashMap<String, Object> result = new HashMap<> ();
result.put("state", state);
result.put("msg", "");
result.put("data", body);
return result;
}
}
所以最终在SearcherController
中,可以将代码改成:
@RestController
public class SearcherController {
@Autowired
private Searcher searcher;
@RequestMapping("/search")
public HashMap<String, Object> getSearchResult(String query) {
List<SearchResult> ret = searcher.search(query);
return Result.succeed(ret);
}
}
虽然有 Result
类了,但是我们依然可以再做一层「保护」,当返回给前端的数据没有经过包装的时候,就会强制就行包装:和前边说的一样,通过@ControllerAdvice
注解和实现接口ResponseBodyAdvice
来实现,然后重写 supports
方法 和 beforeBodyWrite
.
由于篇幅有限(总字数近3w了),这里涉及到的学问可以在csdn学习一下,代码如下:
@ControllerAdvice
@ResponseBody
public class ResponseAdvice implements ResponseBodyAdvice {
@Override
public boolean supports(MethodParameter returnType, Class converterType) {
return true; // 表示需要统一返回结果处理
}
@SneakyThrows
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
// body 就是返回给前端的数据
if (body instanceof HashMap) { // 如果返回的数据已经是 HashMap 这个对象了
return body;
}
if (body instanceof String) { // 如果是字符串,就需要做特殊处理
ObjectMapper objectMapper = new ObjectMapper();
return objectMapper.writeValueAsString(Result.succeed(body));
}
return Result.succeed(body);
}
}
12. 前端对接收到的数据进行渲染
首先需要将此处代码进行屏蔽
然后就可以在刚刚编写的Ajax
中的 success
回调函数中的代码了,这里算前端代码,有很多其他写法,笔者前端知识 不够,代码如下
success: function(result) {
jQuery("#searchResults").innerHtml = '';
var finalHtml = "";
var len ;
if (result.data == null || result.data.length == 0) {
len = 0;
} else {
len = result.data.length;
}
finalHtml += '<div class="count">当前总共为您匹配到了 ' + len +' 个结果</div>'
if (len == 0) {
finalHtml += "您访问的资源丢失啦 QAQ";
} else if (result.state == 200 && result.data != null && result.data.length > 0) {
for (var i = 0; i < result.data.length; i ++) {
var term = result.data[i];
finalHtml += '<div class="item">';
finalHtml += '<a href="' + term.url +'" target="_blank">' + term.title + '</a>'; // target=_blank 表示以新标签页的格式打开
finalHtml += '<div class="desc">' + term.desc + '</div>';
finalHtml += '<div class="url">' + term.url + '</div>';
finalHtml += '</div>';
}
}
jQuery("#searchResults").html(finalHtml); // 拼接
}
但这里为止,项目也快制作完了,我们测试一下效果
13. 实现关键字标红
在常见的搜索引擎中,如下图,在摘要中都对关键词进行了标红功能,实现起来并不复杂
思路:我们在生成摘要的时候,根据摘要遍历所有的分词结果,然后将出现的关键词加上<i>标签,然后前端再为这个 i 标签设置样式,设置为红色即可:而 i 标签的添加,可以使用正则表达式
String desc = generateDesc(document.getContent(), terms);
// 这段描述需要对 所有关键词 进行标红,也就是 使用 <i> 标签修饰
// 并且由于 分词 已经转化成了小写,所以这里也对关键词标红的时候,也不需要区分大小写
for (Term term : terms) {
String word = term.getName();
desc = desc.replaceAll("(?i) " + word + " ", "<i> " + word + " </i>");
}
前端代码
.item .desc i {
color: red;
font-style: normal;
}
最终效果如下:
完结撒花