目录
3.1.2 依次读取文件的title、URL和Content(解析content用到正则表达式)
3.2.5 实现往倒排索引中新增一个文档(Map转Set实现遍历)
3.3.1 引入多线程(线程池ExecutorService+CountDown)
3.3.3 首次制作索引比较慢(AtomicLong、BufferedReader)
4.2.4 针对多个分词结果触发出的相同文档, 进行权重合并
4.2.6 针对排序的结果, 去查正排, 构造出要返回的数据
一、搜索引擎相关概念
1.1 认识搜索引擎
通过观察百度搜索的搜索结果页,我们可以看到,搜索结果包含了若干条结果。在每一个结果中, 包含了标题、描述和URL,有的结果样式会更加复杂一点,包含了时间, 图片等。
1.2 搜索引擎的本质
输入一个查询词, 得到若干个搜索结果. 每个搜索结果包含了标题, 描述和url 。
1.3 搜索的思路
假设我们现在有很多的网页,每个网页称为是一个文档。如何查找出所有与查询词相关的文档。首先,如何定义查询词和文档的相关性?在该项目中,我们简单的认为当网页中包含了查询词,就认为两者具有相关性。本项目分享暴力搜索和倒排索引两个思路。
1.3.1 暴力搜索
每次处理搜索请求的时候,拿着查询词去所有的网站都搜索一遍,观察该网页中是否包含查询词。这个方案的开销非常大,并且随着文档数量的增多,开销会呈线性增长,而搜索引擎对于效率的要求非常高。因此该方案不可行。
1.3.2 倒排索引
这是一种专门针对搜索引擎场景而设计的数据结构。
文档:被检索的html页面(经过预处理)
正排索引:描述一个文档的基本信息, 包括文档标题、正文等信息。
倒排索引:描述该词在哪些文档中出现了,以及出现的次数。
1.4 项目目标
实现一个 Java API 文档的简单的搜索引擎。
在制作索引时,需要先将相关的网页获取到。我们可以使用爬虫来获取这些文档。但是针对Java文档,有更简单的方案,可以直接从官方网站下载文档的压缩包。
因此,我们的项目目标是:在本地基于离线文档制作索引,当用户在搜索页面点击搜索结果时,自动跳转到在线文档的页面。
二、实现思路和前期准备
2.1 项目模块划分
该项目一共划分为三个模块:
索引模块
扫描下载的文档。分析文档的内容,构建正排索引+倒排索引.并且把索引内容保存到文件中.
加载制作好的索引.并提供一些API实现查正排和查倒排这样的功能
搜索模块
加载索引。根据输入的查询词, 基于正排+倒排索引进行检索, 得到检索结果.
输入:用户的查询词
输出:完整的搜索结果(包含了很多条记录,每个记录就有标题,描述,展示URL,并且点击能够跳转)
web模块
需要实现一个简单的web程序,能够通过网页的形式来和用户进行交互,包含了后端和前端。
2.2 分词原理(使用第三方库ansj)
用户在使用搜索引擎时,输入的查询词有可能是一句话。因此,我们需要将完整的句子分成词组。
分词的原理有两种,分别为:
基于词库
尝试把所有的"词"都进行穷举,把这些穷举结果放到词典文件中.然后就可以依次的取句子中的内容.每隔一个字,在词典里查一下; 每隔两个字,查一下。
基于统计
收集到很多很多的"语料库" =>人工标注/直接统计=>也就知道了哪些字在一起的概率比较大。
如果借助代码实现该功能,会比较困难。因此,可以基于第三方库来实现分词。Java中能够实现分词的第三方库很多,我们使用ansj。
三、实现索引模块
3.1 Parser类
Parser类主要是分析文档的内容。在Parser类中,我们首先定义一个静态常量存储Java开发文档的路径。基于该路径创建File对象,罗列File对象包含的所有的.html文件。随后,针对每一个文件进行解析,得到每个文档的URL、title和content。
我们使用run()方法来实现这个过程。
private static final String ROOT_PATH = "E:\\jdk-8u333-docs-all\\docs\\api";
public void run(){
ArrayList<File> fileArrayList = new ArrayList<>();
// 罗列该目录下所有的文件
enumFiles(fileArrayList,ROOT_PATH);
// 针对每个文件进行解析,解析后保存到索引中
for(File file : fileArrayList){
parserFile(file);
}
// 将建立的索引保存到本地磁盘中
index.save();
}
接下来,我们首先学习如何枚举目录下的所有文件。
3.1.1 枚举目录下所有的文件
public void enumFiles(ArrayList<File> fileArrayList, String rootPath) {
File rootFile = new File(rootPath);
if(!rootFile.exists()){
rootFile.mkdirs();
}
File[] fileList = rootFile.listFiles();
for(File file : fileList){
// 如果是目录,就递归
if(file.isDirectory()){
enumFiles(fileArrayList,file.getAbsolutePath());
}else{
if(file.getName().endsWith(".html")){
fileArrayList.add(file);
}
}
}
}
3.1.2 依次读取文件的title、URL和Content(解析content用到正则表达式)
正则表达式:用一些特殊的符号,描述了一些匹配规则。
. :表示匹配一个非换行字符
* :表示前面的字符可以出现若干次
.* :匹配非换行字符出现若干次
? :表示非贪婪匹配,匹配到一个符合条件的最短结果。不带?,表示贪婪匹配,匹配到一个符合条件的最长结果。
\s :表示匹配一个空格
\s+ :匹配一个空格,至少得出现一次
\s* :匹配一个空格,可以一次都不出现。这样写,会导致没有空格也会被匹配出来,不合适
假设content是<div>asd</div><div>qwe</div>
如果使用<.*?>,匹配到的是四个标签。
如果使用<.*>,就把整个content都给匹配到了
private Index index = new Index();
private void parserFile(File file) {
// 解析title
String title = parserTitle(file);
// 解析url
String url = parserUrl(file);
// 解析content
String content = parserContent(file);
// 将解析的内容加到索引中
index.addDoc(title,content,url);
}
private String parserContent(File file){
// 在解析content前,需要将content的从文件中读到一个String里面
String content = readContent(file);
// 由于解析的是个.html文件,content中会包含一些标签,需要将这些标签进行去除
// script标签中有一个与用户动态交互的代码,这些代码与content无关,使用正则表达式将script标签用” “代替
content = content.replaceAll("<script.*?>(.*?)</script>"," ");
// 去除其他标签,这行代码和上一行代码不能交换位置,交换会导致script标签中的内容不能被去除
content = content.replaceAll("<.*?>"," ");
// 去除多余的空格
content = content.replaceAll("\\s+"," ");
return content;
}
private String readContent(File file) {
// String是不可变对象,StringBuilder和StringBuffer是可变对象
StringBuilder content = new StringBuilder();
// 这样写,可以不用手动关闭文件
try(FileReader fileReader = new FileReader(file)){
while (true){
int ch = fileReader.read();
if(ch == -1){
// 读完了
break;
}
char ch1 = (char) ch;
// 将换行和回车用空格代替
if(ch1 == '\n' || ch1 =='\r'){
ch1 = ' ';
}
content.append(ch1);
}
return content.toString();
}catch (IOException e){
e.printStackTrace();
}
return "";
}
private String parserUrl(File file) {
// 分别在本地和网页端打开同一个文件,发现只有前缀不一样,后面都相同,因此,可以将这两个字符串拼接在一起
String path1 = "https://docs.oracle.com/javase/8/docs/api/";
String path2 = file.getAbsolutePath().substring(ROOT_PATH.length());
return path1+path2;
}
private String parserTitle(File file) {
// 解析title时,需要去掉.html的后缀
return file.getName().substring(0,file.getName().length()-".html".length());
}
3.2 Index类
Index类主要实现以下功能:
在正排索引中,根据DocID找到具体的Doc信息。
在倒排索引中,根据查询的词找出与词相关的文章以及在该文章中出现的次数。
将一个文档加到正排索引和倒排索引中。
将索引保存到本地磁盘中
从本地磁盘读取建立好的索引。
3.2.1 分析正排索引和倒排索引使用的数据结构
分析正排索引:由于正排索引需要实现根据docID找到具体的Doc信息,需要新建一个类存储Doc信息,然后,将这个类对象存储到一个ArrayList中,就可以实现根据docID查询doc对象。
新建DocInfo类,生成get和set方法。
public class DocInfo {
private String content;
private String title;
private String url;
private int docID;
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
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 int getDocID() {
return docID;
}
public void setDocID(int docID) {
this.docID = docID;
}
}
使用ArrayList存储DocInfo对象
// 使用数组下标表示 docId
private static ArrayList<DocInfo> forwardIndex = new ArrayList<>();
分析倒排索引:在倒排索引中,我们期望根据词,查询与这个词相关的文档。HashMap可以满足根据key查找value的需求。但是,由于查询词与不同的文章相关度不同,为了体现相关度,新建一个Weight类,保存查询词与文章的相关度。相关度使用weight属性表示。
新建Weight类:
public class Weight {
private int docID;
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;
}
}
使用HashMap存储倒排索引:
// 使用 哈希表 来表示倒排索引,key 就是词, value 表示一组和这个词关联的文章
private static HashMap<String,ArrayList<Weight>> invertIndex = new HashMap<>();
3.2.2 实现正排索引和倒排索引的查询功能
// todo 1. 给定一个 docId, 在正排索引中, 查询文档的详细信息.
public DocInfo selectDocById(int docId){
return forwardIndex.get(docId);
}
// todo 2. 给定一个词, 在倒排索引中, 查哪些文档和这个词关联. 不同文档和词的关联程度不同,因此,不能简单的存储文档ID,应该统计词在文档中出现的个数
public ArrayList<Weight> invertByTerm(String term){
return invertIndex.get(term);
}
3.2.3 实现往索引中新增一个文档
// todo 3. 往索引中新增一个文档.
public void addDoc(String title,String content,String url){
// 往正排索引中添加
DocInfo docInfo = addForwardIndex(title,content,url);
// 往倒排索引中添加
addInvertIndex(docInfo);
}
由于这个功能比较复杂,因此,分开进行介绍。
3.2.4 实现往正排索引中新增一个文档
思路:根据传入的title、content和url创建一个DocInfo对象,将该对象放到正排索引数组的最后,因此正排索引的长度就是该对象的docID。
private DocInfo addForwardIndex(String title, String content, String url) {
DocInfo docInfo = new DocInfo();
docInfo.setContent(content);
docInfo.setUrl(url);
docInfo.setTitle(title);
docInfo.setDocID(forwardIndex.size());
forwardIndex.add(docInfo);
}
3.2.5 实现往倒排索引中新增一个文档(Map转Set实现遍历)
首先:对title进行分词,统计每个词在title中出现的次数;
第二:对content进行分词,统计每个词在content中出现的次数。
第三:统计每个词分别在title和content中出现的次数,然后计算权重。
为了实现这三个目标,建立一个类WordCount存储每个词在title和content中出现的次数,这样,只需要遍历一次,就可以同时统计在title和content中出现的次数。
// 表示这个词出现的次数
static class WordCount{
public int titleCount;
public int contentCount;
}
因为每个词的出现次数都不一样,我们需要记录每个词与其对应出现的次数。采用HashMap存储。
先对title分词,如果这个词在wordCountHashMap中存在,就更新这个词对应的wordCount对象的titleCount属性。如果不存在,就新建一个键值对插进去。统计content的出现结果也是同理。
public void addInvertIndex(DocInfo docInfo) {
String title = docInfo.getTitle();
// 统计词频
HashMap<String,WordCount> wordCountHashMap = new HashMap<>();
// todo 1.针对文档标题进行分词
List<Term> titleTerms = ToAnalysis.parse(title).getTerms();
// 1.1 遍历分词结果, 统计每个词出现的次数.
for(Term term : titleTerms){
String name = term.getName();
WordCount wordCount = wordCountHashMap.get(name);
if(wordCount == null){
// 如果不存在, 就创建一个新的键值对, 插入进去. titleCount 设为 1
WordCount wordCount1 = new WordCount();
wordCount1.titleCount = 1;
wordCount1.contentCount = 0;
wordCountHashMap.put(name,wordCount1);
}else{
// 如果存在, 就找到之前的值, 然后把对应的 titleCount + 1
wordCount.titleCount += 1;
}
}
// todo 2. 针对正文页进行分词.
String content = docInfo.getContent();
List<Term> contentTerms = ToAnalysis.parse(content).getTerms();
// 跟新每个词的出现次数
for(Term term:contentTerms){
String name = term.getName();
WordCount wordCount = wordCountHashMap.get(name);
if(wordCount == null){
WordCount wordCount1 = new WordCount();
wordCount1.titleCount = 0;
wordCount1.contentCount = 1;
wordCountHashMap.put(name,wordCount1);
}else{
wordCount.contentCount += 1;
}
}
我们认为,一个词在标题中出现的次数越能体现该词与文档的关联度。因此,采用标题中出现的次数 * 10 + 正文中出现的次数为权重计算公式。遍历wordCountHashMap,计算每个词与文档的权重,并插入到倒排索引中。
由于Map并没有实现Iterable接口,因此Map不是可迭代的。Set实现了Iterable,因此可以将Map转为Set。Set将一个键值对打包在一起的类成为Entry(入口)。转为Set后,失去了根据key快速查找value的能力,但是可以实现遍历了。
// todo 3 把上面的结果汇总到一个 HashMap 里面.最终文档的权重, 就设定成标题中出现的次数 * 10 + 正文中出现的次数.
// 遍历刚才这个 HashMap, 依次来更新倒排索引中的结构了.
for(Map.Entry<String,WordCount> entry : wordCountHashMap.entrySet()){
// 计算这个词的权重
Weight weight = new Weight();
weight.setDocID(docInfo.getDocID());
weight.setWeight(entry.getValue().titleCount*10+entry.getValue().contentCount);
// 根据这个词,去倒排索引中查,
ArrayList<Weight> invertWeight = invertIndex.get(entry.getKey());
if(invertWeight == null){
// 如果这个词在倒排索引中不存在,就构建一个新的键值对
ArrayList<Weight> weightList = new ArrayList<>();
weightList.add(weight);
invertIndex.put(entry.getKey(),weightList);
}else{
// 查到了就将当前文档的权重加在倒排索引的后面
invertWeight.add(weight);
}
3.2.6 保存到本地(使用ObjectMapper)
目前创建的索引是存储到内存中的。构建索引的过程,是比较耗时的,因此不能在浏览器启动的时候才构建索引,这样会拖慢服务器的启动。我们得解决办法是:将这些耗时的操作,单独去执行。即先制作好索引。然后再让服务器线上直接加载构造好的索引。
如何保存呢?将内存中的索引结构,通过序列化,变成一个字符串,再写入文件中即可。响应,将特定结构的字符串反向解析成一些结构化数据,如类、对象以及基础数据结构,称为反序列化。
序列化和反序列化有很多通用的方法,在本项目中,使用JSON格式来进行序列化或者反序列化。引入Jackson包。
// 本地存储路径
private static final String SAVE_PATH = "E://";
private ObjectMapper objectMapper = new ObjectMapper();
public void save(){
File indexFile = new File(SAVE_PATH);
ArrayList<DocInfo> s1 = forwardIndex;
// 1. 先判定一下索引对应的目录是否存在, 不存在就创建.
if(!indexFile.exists()){
indexFile.mkdirs();
}
try {
objectMapper.writeValue(new File(SAVE_PATH+"forwardIndex.txt"),forwardIndex);
objectMapper.writeValue(new File(SAVE_PATH+"invertIndex.txt"),invertIndex);
} catch (IOException e) {
e.printStackTrace();
}
}
3.2.7 从本地加载索引(TypeReference)
在调用readvalue方法时,需要指定转化的对象类型。Jackson专门提供了一个辅助的工具类,TypeReference<>.创建一个匿名内部类,这个类,实现了TypeReference。同时再创建这个匿名内部类的实例。创建这个实例的主要目的就是将ArrayList<Weight>的类型信息告诉readvalue方法。
public void load(){
try {
forwardIndex = objectMapper.readValue(new File(SAVE_PATH+"forwardIndex.txt"), new TypeReference<ArrayList<DocInfo>>() {});
invertIndex = objectMapper.readValue(new File(SAVE_PATH + "invertIndex.txt"), new TypeReference<HashMap<String, ArrayList<Weight>>>() {});
} catch (IOException e) {
e.printStackTrace();
}
}
3.3 优化思路
先找性能瓶颈,通过不段的测试,找到最耗时间的方法。
3.3.1 引入多线程(线程池ExecutorService+CountDown)
通过统计罗列文件、制作索引的运行时间,发现制作索引的时间很久。进一步观察代码,我们发现对文档的解析可以串行操作,因此,我们可以引入线程池,使用ExecutorService。在确定线程池的线程数目时,通过实验的方式来确定。不是将线程数目设置为CPU核数的XX倍。
由于将任务加入到线程池的速度很快,执行具体的任务稍慢一点。此处的任务是将文档加入到索引中,如果我们不等所有任务结束,直接保存,就会导致保存的索引是个半成品。因此,我们需要知道每个任务都执行完成。如何解决呢?引入CountDownLatch。在初始化时,指定一共有多少个文件,每个文件解析完成之后使用.countDown()方法通知CountDown。调用await()方法实现阻塞等待,作用是直到所有任务都执行结束,阻塞等待才会结束。
如果一个线程是守护线程(后台线程),此时这个线程的运行状态,不会影响到进程结束。如果一个线程不是守护线程,这个线程的运行状态会影响进程结束。通过ExecutorService创建出来的线程,默认都是非后台线程,当main方法执行完了,这些线程仍然还在工作,还在等待新任务的到来。有两种方案,第一种是使用setDaemon方法手动设置,变成守护线程。第二种是调用shutdown(),手动将线程池的所有线程都干掉。此处,我们采用第二种方案。
public void runByThread() throws InterruptedException {
ArrayList<File> fileArrayList = new ArrayList<>();
enumFiles(fileArrayList,ROOT_PATH);
System.out.println(fileArrayList.size());
// 引入countDownLatch,保证所有的文件都加入到索引中再保存索引,在初始化时需要指明有多个选手
CountDownLatch countDownLatch = new CountDownLatch(fileArrayList.size());
// 创建一个线程池
ExecutorService executorService = Executors.newFixedThreadPool(8);
for(File file : fileArrayList){
// 将文件加入到线程池中
executorService.submit(new Runnable() {
@Override
public void run() {
parserFile(file);
countDownLatch.countDown(); // 有一个选手撞线了需要高速countdownlatch
}
});
}
// await 方法会阻塞, 直到所有的选手都调用 countDown 撞线之后, 才能阻塞结束.
countDownLatch.await();
// 手动的把线程池里面的线程都干掉
executorService.shutdown();
// 保存索引
index.save();
}
3.3.2 解决线程安全问题
什么时候会引发线程安全问题呢?当多个线程同时操作同一个对象时,会引发线程安全问题。通过分析代码,在parserFile方法中,最后一步是将解析的内容加到索引中,在往正排索引和倒排索引中加入值时,不同的线程会同时操作正排索引和倒排索引。因此,需要对其加锁。创建两个锁对象,完成加锁。
// 创建锁对象
private Object locker1 = new Object();
private Object locker2 = new Object();
for(Map.Entry<String,WordCount> entry : wordCountHashMap.entrySet()){
synchronized (locker2){
// 计算这个词的权重
Weight weight = new Weight();
weight.setDocID(docInfo.getDocID());
weight.setWeight(entry.getValue().titleCount*10+entry.getValue().contentCount);
// 根据这个词,去倒排索引中查,
ArrayList<Weight> invertWeight = invertIndex.get(entry.getKey());
if(invertWeight == null){
// 如果这个词在倒排索引中不存在,就构建一个新的键值对
ArrayList<Weight> weightList = new ArrayList<>();
weightList.add(weight);
invertIndex.put(entry.getKey(),weightList);
}else{
// 查到了就将当前文档的权重加在倒排索引的后面
invertWeight.add(weight);
}
}
}
private DocInfo addForwardIndex(String title, String content, String url) {
DocInfo docInfo = new DocInfo();
docInfo.setContent(content);
docInfo.setUrl(url);
docInfo.setTitle(title);
// 多个线程同时访问forwardindex对象
synchronized (locker1){
docInfo.setDocID(forwardIndex.size());
forwardIndex.add(docInfo);
}
return docInfo;
}
3.3.3 首次制作索引比较慢(AtomicLong、BufferedReader)
在开机之后,首次制作索引非常慢。但是第二次、第三次制作索引就快了。重启之后,第一次制作又会特别慢。计算机读取文件,是个开销比较大的操作,简单猜测。是否开机之后,首次运行时读取文件的数据特别慢呢?
通过给parserContent和addDoc,都加上时间,来衡量一下这里的时间变化。
定义两个时间,计算读文件和addDoc的执行时间。由于parserContent和addDoc方法是在循环中调用,只计算一次的执行时间很短,就需要计算累计和。由于这块涉及到多线程环境,在进行时间累加时,要注意线程安全问题。使用AtomicLong可以避免线程安全问题。
// 使用AtomicLong可以避免线程安全问题,也可以不必加锁(加锁本身也会有不小的开销)
private AtomicLong t1 = new AtomicLong(0);
private AtomicLong t2 = new AtomicLong(0);
获取读文件和addDoc操作执行的时间差,将其累加到t1和t2中。
long beg = System.nanoTime(); // 纳秒级别。
String content = parserContent(file);
long mid = System.nanoTime();
// 将解析的内容加到索引中
index.addDoc(title,content,url);
long end = System.nanoTime();
// 注意,在此处不能直接打印时间差,因为parserHTML会被循环调用很多次,单词调用的时间很短,因为打印也是一个比较低效的操作,加入频繁的打印操作反而会拖慢这个速度本身
// 次数的原子相加执行时间很短,影响很小
t1.addAndGet(mid-beg);
t2.addAndGet(end-mid);
先重启电脑再运行,通过分析运行时间,我们可以明显的看到解析正文的时间要比addDoc的时间长很多。
接着运行第二次和第三次,我们可以观察到,解析正文的时间变短了。
主要原因是:缓存。
解析正文的核心操作就是读取文件。
首次运行时,当前的文件都没有在内存上缓存,读取时只能直接从硬盘上读取,比较低效。由于操作系统会对经常读取的文件进行缓存。后面再运行的时候,这些文档在操作系统中已经有了一份缓存(内存中),直接读内存的缓存,而不是直接读硬盘,因此速度会快很多。
,每次操作都可能会触发磁盘IO。由于读磁盘是一个比较耗时的操作。我们可以使用BufferedReader标准库中提供的一个FileReader的辅助类。BufferedReader内部内置了缓冲区,可以将FileReader的数据提前放到缓存区中,从而减少了读磁盘的次数。假设现在有100个字节,使用FileReader.read,是每次读一个字节,读100次。使用bufferedReader.read()就可以理解为一次读100个字节,分一次读。bufferedReader.read()可以读取的文件大小是可以自定义的。
修改后的代码如下:
private String readContent(File file) {
// String是不可变对象,StringBuilder和StringBuffer是可变对象
StringBuilder content = new StringBuilder();
try(BufferedReader bufferedReader = new BufferedReader(new FileReader(file),1024*1024)){
while (true){
int ch = bufferedReader.read();
if(ch == -1){
// 读完了
break;
}
char ch1 = (char) ch;
// 将换行和回车用空格代替
if(ch1 == '\n' || ch1 =='\r'){
ch1 = ' ';
}
content.append(ch1);
}
return content.toString();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
return "";
四、实现搜索模块
4.1 DocSearch类的实现思路
在该类中,我们主要实现根据用户输入的查询词或者查询语句,从正排索引和倒排索引中查出与查询词或者查询语句相关的文档的信息。我们的思路是:
第一步:分词,并用停用词过滤分词结果。
由于用户输入的可能是一句话,我们需要先对查询语句进行分词。分词结果里面有些词可能没有具体的含义,如 a、all这种,将这种词成为停用词。我们需要从分词结果中剔除这些词。思路是:使用HashSet存储停用词,依次判断分词结果的词是否在HashSet中存在。
第二步:在初始化DocSearch时,加载制作好的索引和停用词表。
第三步:根据分词结果中的每个词,去倒排索引中查与词相关的文档
第四步:不同的分词可能会查询到相同的文档,需要进行权重合并
第五步:将权重合并后的结果,按照权重进行降序排列
第六步:针对排序结果,去正排索引中查,得到文档的具体信息。根据具体信息构建要返回的数据
4.2 具体步骤
4.2.1 准备工作,加载索引和制作暂停词表
// 停用词文件的路径
private static final String STOP_WORD_PATH = "E:\\stop_word.txt";
// 此处要加上索引对象的实例,同时要完成索引加载的工作.
private static Index index = new Index();
// 使用这个 HashSet 来保存停用词
private static HashSet stopWord = new HashSet();
// 将停用词读到HashSet中
private void loadStopWordHashSet(){
try (BufferedReader bufferedReader = new BufferedReader(new FileReader(STOP_WORD_PATH))){
while (true){
String line = bufferedReader.readLine();
if(line == null){
// 读取文件完毕
break;
}
if(stopWord.contains(line)){
continue;
}
stopWord.add(line);
}
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
// 初始化时,加载制作好的索引,并制作停用词的hashset
public DocSearch(){
index.load();
loadStopWordHashSet();
}
4.2.2 针对查询语句进行分词,并用停用词表进行过滤
public List<Result> search(String query){
// todo 1. [分词] 针对 query 这个查询词进行分词
// 分词结果
List<Term> queryItem = ToAnalysis.parse(query).getTerms();
// 存放使用暂停词表过滤后的分词结果
List<String> newQuery = new ArrayList<>();
// 针对分词结果, 使用暂停词表进行过滤
for(Term term : queryItem){
String word = term.getName();
// 如果该词在暂停词表中存在,就不保存他,进入下一次循环
if(stopWord.contains(word)){
continue;
}
newQuery.add(word);
}
4.2.3 针对分词结果查倒排
调用invertByTerm()方法,输入参数是一个词,输出参数是ArrayList<Weight>。因为分词结果中可能存在多个词,因此会查询到多个ArrayList<Weight>,多个ArrayList<Weight>使用List进行组织。
// 保存倒排索引查询结果
List<ArrayList<Weight>> invertSearchResult = new ArrayList<>();
// todo 2. [触发] 针对分词结果来查倒排
searchInvert(invertSearchResult,newQuery);
private void searchInvert(List<ArrayList<Weight>> invertSearchResult, List<String> newQuery) {
for(String str : newQuery){
ArrayList<Weight> arrayList = index.invertByTerm(str);
if(arrayList == null){
// 说明这个词在所有文档中都不存在.
continue;
}
invertSearchResult.add(arrayList);
}
}
4.2.4 针对多个分词结果触发出的相同文档, 进行权重合并
假设我们输入的查询语句是Spring and Java,分词结果是Spring 和Java,Spring和Java都在文档1中出现,调用invertByTerm()方法,查询Spring在文档1的权重是2,Java在文档1的权重是5.将文档1显示多次不太合理,我们需要得到文档1与整个查询语句的权重关系,查询语句在文档1的权重是7。因此,需要将分词结果触发出的相同文档, 进行权重合并。
// todo 3. [合并] 针对多个分词结果触发出的相同文档, 进行权重合并
List<Weight> result = merge(invertSearchResult);
在进行合并时,是将多个行合并成一行。操作时需要操作二维数组(二维List)的每个元素,我们需要知道行和列,才能确定二维数组中的元素。新建一个Pos类,表示元素的位置:
static class Pos{
public int row;
public int col;
public Pos(int row,int col){
this.row = row;
this.col = col;
}
}
合并思路:
使用 List<Weight> result 表示合并结果。
针对每一行按照id升序排列——》创建一个优先级队列,对这些列进行合并——》将第一列元素放到优先级队列中——》将堆顶元素弹出,判断与result中最后一个元素的id关系,如果相等就合并,不相等就将其加到result中——》当前元素处理完后,移动到该行的下一个元素。如果超过这一行的列数,进入下一次循环。如果没超过,就将该元素加入到优先级队列中
// 2.3 循环的取队首元素(也就是当前这若干行中最小的元素)
while (!priorityQueue.isEmpty()){
Pos minPos = priorityQueue.poll();
Weight curWeight = source.get(minPos.row).get(minPos.col);
if(result.size()>0){
Weight lastWeight = result.get(result.size()-1);
// 2.4 看看这个取到的 Weight 是否和前一个插入到 target 中的结果是相同的 docId
// 如果是, 就合并
if(lastWeight.getDocID() == curWeight.getDocID()){
lastWeight.setWeight(lastWeight.getWeight()+curWeight.getWeight());
}else{
result.add(curWeight);
}
}else{
// 如果 target 当前是空着的, 就直接插入即可
result.add(curWeight);
}
// 2.5 把当前元素处理完了之后, 要把对应这个元素的光标往后移动, 去取这一行的下一个元素
Pos newPos = new Pos(minPos.row, minPos.col+1);
if(newPos.col >= source.get(newPos.row).size()){
// 如果移动光标之后, 超出了这一行的列数, 就说明到达末尾了.
// 到达末尾之后说明这一行就处理完毕了
continue;
}
priorityQueue.offer(newPos);
}
return result;
}
4.2.5 针对合并的结果按照权重降序排序
// todo 4. [排序] 针对触发的结果按照权重降序排序
result.sort(new Comparator<Weight>() {
@Override
public int compare(Weight o1, Weight o2) {
return o2.getWeight()-o1.getWeight();
}
});
4.2.6 针对排序的结果, 去查正排, 构造出要返回的数据
要返回的数据包含title和url,此时不能直接返回content,只需要返回一段包含查询词的摘要。新建一个类Result,存储这些信息。
public class Result {
private String title;
private String url;
private String desc;
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 getDesc() {
return desc;
}
public void setDesc(String desc) {
this.desc = desc;
}
@Override
public String toString() {
return "Result{" +
"title='" + title + '\'' +
", url='" + url + '\'' +
", desc='" + desc + '\'' +
'}';
}
}
List<Result> allResult = new ArrayList<>();
// todo 5. [包装结果] 针对排序的结果, 去查正排, 构造出要返回的数据.
for(Weight weight:result){
// 去正排索引中查
DocInfo docInfo = index.selectDocById(weight.getDocID());
// 构建result对象
Result result1 = new Result();
result1.setUrl(docInfo.getUrl());
result1.setTitle(docInfo.getTitle());
// 描述需要在content的基础上进行修改
result1.setDesc(genDesc(docInfo.getContent(),newQuery));
allResult.add(result1);
}
return allResult;
}
在根据content生成desc时,遍历分词结果,看哪个词在content中出现。找到这个词第一次出现的位置。以第一次出现的位置为基准,向前找60个字符,向后找160个字符,作为desc。
注意事项:
分词表对词转小写了。因此,需要将content转为小写再查询。
为了避免查询List出现ArrayList这样的情况,查询时要进行全字匹配。使用List周围加空格方式去查询,就能避免这种情况。
有一种极端情况,所有的分词结果再正文中都不存在,这种情况直接返回一个空的描述或者正文的前160个字符。
为了能在前端的展示页面中体现出查询词,可以使用正则表达式给查询词加上<i>标签。
private String genDesc(String content, List<String> newQuery) {
// 先遍历分词结果, 看看哪个结果是在 content 中存在.
int firstPos = -1;
for(String str:newQuery){
// 分词库直接针对词进行转小写了.
// 因此 就必须把正文也先转成小写, 然后再查询
// 此处需要的是 "全字匹配", 让 word 能够独立成词, 才要查找出来, 而不是只作为词的一部分.
// 此处的全字匹配的实现并不算特别严谨. 更严谨的做法, 可以使用正则表达式.
content = content.toLowerCase().replaceAll("\\b"+str+"\\b"," "+str+" ");
// 找到第一次出现的位置
firstPos = content.indexOf(" "+str+" ");
if(firstPos > -1){
break;
}
}
// 所有的分词结果都不在正文中存在.因此这是属于比较极端的情况~
// 此时就直接返回一个空的描述了.或者也可以直接取正文的前 160 个字符作为描述.
if(firstPos == -1){
if(content.length() > 160){
return content.substring(0,160)+"...";
}else{
return content;
}
}
String desc = "";
// 从 firstPos 作为基准位置, 往前找 60 个字符, 作为描述的起始位置.
firstPos = firstPos>60 ? firstPos-60:0;
if(firstPos+160>content.length()){
desc = content.substring(firstPos);
}else{
desc = content.substring(firstPos,firstPos+160)+"...";
}
// 在此处加上一个替换操作. 把描述中的和分词结果相同的部分, 给加上一层 <i> 标签. 就可以通过 replace 的方式来实现.
for(String str:newQuery){
desc = desc.replaceAll(" "+str+" ","<i> "+str+" </i>");
}
return desc;
}
五、实现Web模块
import com.fasterxml.jackson.databind.ObjectMapper;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
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 DocSearchServlet extends HttpServlet {
// 此处的 searcher.DocSearcher 对象也应该是全局唯一的. 因此就给一个 static 修饰.
private static DocSearch docSearch = new DocSearch();
private ObjectMapper objectMapper = new ObjectMapper();
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
String query = req.getParameter("query");
System.out.println(query);
if(query == null || query.equals("")){
String msg = "您的参数非法! 没有获取到 query 的值!";
resp.sendError(404, msg);
return;
}
System.out.println("query:"+query);
DocSearch docSearch = new DocSearch();
List<Result> results = docSearch.search(query);
resp.setContentType("application/json;charset=utf-8");
objectMapper.writeValue(resp.getWriter(),results);
}
}
六、实现前端页面设计
使用ajax构造get请求。
<body>
<div class="container">
<div class="header">
<input type="text">
<button id="search-btn">搜索</button>
</div>
<div class="result">
<!-- <div class="item">
<a href="#">标题</a>
<div class="desc">我是摘要</div>
<div class="url">https://waidu.com</div>
</div> -->
</div>
</div>
<script src="js/jquery.js"></script>
<script>
let button = document.querySelector("#search-btn");
button.onclick = function(){
let input = document.querySelector(".header input");
let content = input.value;
console.log(content);
$.ajax({
type: "GET",
url: "searcher?query="+content,
success:function(data,status){
buildData(data);
}
});
}
function buildData(data){
let result = document.querySelector(".result");
// 清空上次的内容
result.innerHTML = '';
let countDiv = document.createElement('div');
countDiv.innerHTML = '当前找到'+data.length +'个结果';
countDiv.className = 'count';
result.appendChild(countDiv);
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>
七、将项目部署在云服务器上
step1:将项目打成war包
step2:在云服务器上,直接使用建立好的索引。在云服务器上,新建一个文件夹,将forwardIndex.txt、invertIndex.txt、stop_word.txt导入。使用pwd查看这三个文件所在的目录,修改Java程序中访问正排索引、倒排索引和停用词的路径。
step3:将war包导入tomcat的webapps目录下。
具体操作见:Lesson14:Linux基础操作和web程序部署_刘减减的博客-CSDN博客的4.4小结。
八、改为SpringBoot项目
step1:创建一个SpringBoot项目,并引入依赖。
ansj的依赖需要手动导入
<!-- https://mvnrepository.com/artifact/org.ansj/ansj_seg -->
<dependency>
<groupId>org.ansj</groupId>
<artifactId>ansj_seg</artifactId>
<version>5.1.6</version>
</dependency>
其他操作可以查看这个博客:
Lesson4:SpringBoot的概念、创建和运行_刘减减的博客-CSDN博客
step2:在demo目录下新建一个包search,将之前写好的代码拷入
step3:将图片、html等资源拷到resource的static目录下
step4:修改代码中索引和停用词的路径
因为文件在本地的路径和在云服务器上的路径是不相同的。我们希望在本地运行的时候,使用本地的路径。在云服务器上运行,使用云服务器的路径。因此,我们要实现方便的路径切换。
目前,使用一个全局变量来表示是否运行在线上环境。
新建一个类Config,
package com.example.demo;
public class Config {
// 如果变量为true,表示是在云服务器上运行,为false表示在本地上运行
public static boolean isOnline = true;
}
修改DocSearch类中停用词的路径:
// 停用词文件的路径
private static String STOP_WORD_PATH = null;
static {
if(Config.isOnline){
STOP_WORD_PATH = "/root/liujiaitem/docsearch/stop_word.txt";
}else{
STOP_WORD_PATH = "E:\\stop_word.txt";
}
}
修改Index类中索引的路径:
// 本地存储路径
private static String SAVE_PATH = null;
static {
if(Config.isOnline){
SAVE_PATH = "/root/liujiaitem/docsearch/";
}else{
SAVE_PATH = "E:\\";
}
}
由于直接访问的是制作好的索引。所以不需要再制作索引,不用修改Parser类中Java开发文档的路径。
step5:创建controller包,在包中新建DocSearchController类,实现前后端的交互
@RestController
public class DocSearchController {
private static DocSearch searcher = new DocSearch();
private ObjectMapper objectMapper = new ObjectMapper();
@RequestMapping(value = "/searcher",produces = "application/json;charset=utf-8")
@ResponseBody
public String search(String query) throws JsonProcessingException {
// 参数是查询词, 返回值是响应内容.
// 参数 query 来自于请求 URL 的 query string 中的 query 这个 key 的值
System.out.println(query);
List<Result> results = searcher.search(query);
return objectMapper.writeValueAsString(results);
}
}
九、将SpringBoot项目配置在云服务器上
step1:检查8080端口是否被占用(通常情况下,一个主机上的一个端口只能被一个进程绑定)
netstat -anp | grep 8080
如果占用,有两种方式:①把占用8080的进程结束掉②给当前程序改一个端口。选择第一种方式
进入tomcat的bin目录,退出tomcat
sh shutdown.sh
step2:将项目打包成jar包。
step3:将jar包导入云服务器
打开一个文件夹。将jar包拖入该目录下。
step4:将前台进程转为后台进程
直接输入一个命令产生的进程,都是前台进程。前台进程会随着终端的关闭被杀死。为了保证退出
后台,还能访问网页。需要将前台进程转为后台进程。
注意:前台进程和Java中的前台线程不一样哦!
转换需要用到nohup。安装教程为:
详解:-bash: nohup:: command not found_刘减减的博客-CSDN博客
安装成功后,在控制台输入
nohup java -jar demo-0.0.1-SNAPSHOT.jar &
step5:验证
输入http://外网ip:8080/search.html
十、项目测试
为了验证搜素引擎的搜索结果是否正确,借助selenium和unittest框架,对其进行测试。一共有两个测试用例:
对暂停词的测试,输入暂停词,输出的搜索结果应该是0;
对存在包含关系的词进行测试,如List和ArrayList,两个的搜索结果应该不同。
在测试的过程中,用到了断言和数据驱动,与此同时,用到了异常捕捉和错误截图,并生成HTML测试报告。
10.1 暂停词自动化测试脚本编写
准备工作:
- 编写getStopWords函数,实现从stopword.txt中读取暂停词,并将词存储在列表中。
- 编写getCount函数,实现从搜索结果中得到结果数量。举个例子:搜索结果是"当前找到1235个结果",返回值是1235.
- 编写saveScreenShot函数,出现异常时就调用该函数进行截图
自动化测试脚本的编写:
- 写测试固件——》写测试用例
测试用例的编写思路:
- 定位到搜索框,输入暂停词
- 定位搜索按钮,点击
- 定位到结果,从结果中读取查询数量
- 使用断言判断是否为0,用try-excep将断言包裹,如果断言不成立,会出现异常,调用截图函数进行截图
- 在测试用例上,加上@data(*getStopWords()),getStopWords()的返回词是一个列表,数据驱动会将列表中的词挨个输入测试用例中进行测试
from selenium import webdriver
import unittest
import time
import os
from selenium.webdriver.common.by import By
from ddt import ddt, unpack, data, file_data
# 从文件中读取暂停词,将结果保存到列表中
def getStopWords():
file = open("stopword.txt")
stopWordList = []
for line in file.readlines():
line = line.strip('\n')
stopWordList.append(line)
return stopWordList
# 从result中分离出数字
def getCount(result):
# result = "当前找到1235个结果"
# result = "1235个结果"
result = result[4:]
# 个结果
str1 = result[len(result) - 3:len(result)]
# 得到中间的数字
result = result[:result.index(str1)]
# 将结果转为int
return int(result)
@ddt
class SearchTest(unittest.TestCase):
# 测试固件
def setUp(self):
print("--------setup---------")
self.driver = webdriver.Chrome()
self.url = "http://121.4.97.30:8080/search.html"
self.driver.maximize_window()
# 脚本运行时,错误的信息将被打印到这个列表中
self.verificationErrors=[]
# 是否继续接受下一个警告
self.accept_next_alert = True
time.sleep(3)
def tearDown(self):
print("--------setup---------")
self.driver.quit()
self.assertEqual([], self.verificationErrors)
# 测试用例
@data(*getStopWords())
def test_stopword(self, value):
driver = self.driver
url = self.url
driver.get(url)
# 定位搜索框,并输入查询词
driver.find_element(By.TAG_NAME, "input").send_keys(value)
# 定位搜索按钮,点击查询
driver.find_element(By.TAG_NAME, "button").click()
driver.implicitly_wait(10)
# 从搜索结果中,定位"当前找到1235个结果"
result = driver.find_element(By.CLASS_NAME, "count").text
# 从result中分离出数字
count = getCount(result)
# print(result)
# print(count)
try:
# 判断数字是否为0
self.assertEqual(count, 0, msg="和预期搜索结果不一致,stopword搜索出来的结果应该为0")
except:
# 不为0,就截图
self.saveScreenShot(driver, "stopword.png") # 出现异常就调用截图函数进行截图
time.sleep(5)
# 截图函数
def saveScreenShot(self, driver, file_name): # 参数:驱动,截图名字
if not os.path.exists("./image"):
os.makedirs("./image")
now = time.strftime("%Y%m%d-%H%M%S", time.localtime(time.time()))
driver.get_screenshot_as_file("./image/"+now+"-"+file_name)
time.sleep(3)
if __name__ == "__main__":
unittest.main()
10.2 存在包含关系的词自动化测试脚本编写
准备工作:
- 编写read_similarWord()函数,实现从similarWord.txt中读取暂停词,并将词存储在列表中。
- 编写getCount函数,实现从搜索结果中得到结果数量。举个例子:搜索结果是"当前找到1235个结果",返回值是1235.
- 编写saveScreenShot函数,出现异常时就调用该函数进行截图
自动化测试脚本的编写:
- 写测试固件——》写测试用例
测试用例的编写思路:
- 定位到搜索框,输入词1
- 定位搜索按钮,点击
- 定位到结果,从结果中读取查询数量1
- 清空搜索框
- 定位到搜索框,输入词2
- 定位搜索按钮,点击
- 定位到结果,从结果中读取查询数量2
- 使用断言判断数量1和数量2是否相等,用try-excep将断言包裹,如果断言不成立,会出现异常,调用截图函数进行截图
- 在测试用例上,加上@data(*read_similarWord())、@unpack,会自动将列表对应到多个参数上
from selenium import webdriver
import unittest
import time
import os
from selenium.webdriver.common.by import By
from ddt import ddt, unpack, data, file_data
def read_similarWord():
file = open("similarWord.txt", "r", encoding="utf8")
li = []
for line in file.readlines():
print(line)
#读取txt文件时存在换行符号\n,此时需要字符串的替换使用方法strip,输出:123456,123455
#读取后的在使其变化为一个列表,使用方法:split,输出为:['123456', '123455']
li.append(line.strip("\n").split(","))
print(li)
file.close()
return li
@ddt
class SearchTest2(unittest.TestCase):
# 测试固件
def setUp(self):
print("--------setup---------")
self.driver = webdriver.Chrome()
self.url = "http://121.4.97.30:8080/search.html"
self.driver.maximize_window()
# 脚本运行时,错误的信息将被打印到这个列表中
self.verificationErrors=[]
# 是否继续接受下一个警告
self.accept_next_alert = True
time.sleep(3)
def tearDown(self):
print("--------setup---------")
self.driver.quit()
self.assertEqual([], self.verificationErrors)
# 测试用例
@data(*read_similarWord())
@unpack
def test_similarword(self, value1,value2):
print(value1)
print(value2)
driver = self.driver
url = self.url
driver.get(url)
driver.find_element(By.TAG_NAME, "input").send_keys(value1)
driver.find_element(By.TAG_NAME, "button").click()
driver.implicitly_wait(10)
# time.sleep(10)
result1 = driver.find_element(By.CLASS_NAME, "count").text
count1 = getCount(result1)
print(result1)
print(count1)
driver.find_element(By.TAG_NAME, "input").clear()
time.sleep(3)
driver.find_element(By.TAG_NAME, "input").send_keys(value2)
driver.find_element(By.TAG_NAME, "button").click()
time.sleep(3)
result2 = driver.find_element(By.CLASS_NAME, "count").text
print(result2)
count2 = getCount(result2)
print(count2)
try:
self.assertNotEqual(count1,count2, msg="这两个关键词是不同的,搜索结果数量应该不相同")
except:
self.saveScreenShot(driver, "similarword.png") # 出现异常就调用截图函数进行截图
time.sleep(5)
# 截图函数
def saveScreenShot(self, driver, file_name): # 参数:驱动,截图名字
if not os.path.exists("./image"):
os.makedirs("./image")
now = time.strftime("%Y%m%d-%H%M%S", time.localtime(time.time()))
driver.get_screenshot_as_file("./image/"+now+"-"+file_name)
time.sleep(3)
if __name__ == "__main__":
unittest.main()
def getCount(result):
# result = "当前找到1235个结果"
# result = "1235个结果"
result = result[4:]
# 个结果
str1 = result[len(result) - 3:len(result)]
# 得到中间的数字
result = result[:result.index(str1)]
# 将结果转为int
return int(result)
10.3 测试套件的编写
import unittest
from search import searchtest
from search import searchtest_similiarword
import HTMLTestRunner
import os
import sys
import time
def creatSuit():
suit = unittest.TestSuite()
# 将SearchTest类下的所有测试用例都加载到suit中
suit.addTest(unittest.makeSuite(searchtest.SearchTest))
suit.addTest(unittest.makeSuite(searchtest_similiarword.SearchTest2))
return suit
if __name__=="__main__":
# 文件夹要创建在哪里
curpath = sys.path[0]
print(sys.path)
print(sys.path[0])
# 1,创建文件夹,创建的这个文件夹干什么
if not os.path.exists(curpath+'/resultreport'):
os.makedirs(curpath+'/resultreport')
# 2,文件夹的命名,不能让名称重复
# 时间 时分秒 ——》名称绝对不会重复
now = time.strftime("%Y-%m-%d-%H %M %S", time.localtime(time.time()))
# 文件名
filename = curpath + '/resultreport/'+ now + 'resultreport.html'
with open(filename, 'wb') as fp:
runner = HTMLTestRunner.HTMLTestRunner(stream=fp, title=u"测试报告",
description=u"用例执行情况", verbosity=2)
suite = creatSuit()
runner.run(suite)
十一、总结
主要介绍了搜索引擎功能的实现。项目主要分为索引模块、搜索模块和Web模块三个部分。在索引模块利用正则表达式、HashMap、ArrayList等数据结构,结合多线程,实现了正排索引和倒排索引的制作,以及两种索引的保存和加载。在搜索模块,对用户输入进行分词,分词后查询倒排索引,对查询结果合并以及构造返回的数据。在Web模块实现了前后端交互。该项目实现了servlet和SpringBoot两个版本,并将项目部署到云服务器上。