DocSearcher:文档搜索引擎

项目描述

我们要实现什么样的搜索引擎呢?
该项目主要针对Java API 文档设计出一款文档搜索引擎,当用户在页面上输入查询词后,能够快速的匹配出相关API在线文档,补充了Java在线文档中没有搜索的功能。 每个搜索结果中包含了标题, 描述, 展示 url 和点击 url等信息,便于用户浏览。
Java API 文档线上版本参见: https://docs.oracle.com/javase/8/docs/api/index.html

项目流程

1.对离线版本的HTML文档进行解析,将解析的结果整理为一个行文本文件;
2.读取处理好的行文本文件进行Ansj分词,权重计算等操作,在内存中构造正排索引和倒排索引;
3.根据输入查询词进行分词、触发依据相似性分析以及倒排索引结构对结果进行检索排序,并以Json格式进行包装后序列化为字符串返回;
4.编写简单页面,通过HTTP服务器搭载搜索页面,点击搜索结果可跳转至对应API在线文档。
开发环境:IDEA、Tomcat、Maven、JDK1.8
相关技术栈:Ansj分词、倒排索引、过滤器、HTML、Servlet、Json、Ajax
项目代码https://github.com/xiaoting-hub/Projects-DocSearcher

基础知识

什么是倒排索引?

文档(DOC):经过预处理,用户输入关键字要被检索的页面
正排索引:“一个文档包含了哪些词”。描述一个文档的基本信息, 包括文档标题、 正文,以及文档标题和正文的分词/断句结果。
倒排索引:“一个词被哪些文档引用了”。 描述一个词的基本信息, 包括这个词都被哪些文档引用, 这个词在该文档中的重要程度以及这个词的出现位置等信息。

问题:为什么要用倒排索引?暴力搜索行不行啊?
每次处理搜索请求的时候, 拿着查询词去所有的网页中搜索一遍,检查每个网页是否包含查询词字符串。显然,暴力搜索这种方式随着文档数量的增加开销会线性增加,一般我们对搜索引擎的效率还是比较看重的。所以尽可能的高效才是重点(^ . ^)。

为什么要进行分词?分词的原理,在该项目中分词如何来实现?

用户输入的关键字有时候是多个词/一句话,要搜索准确就必须进行分词,分词原理有两个方面:一种是基于词库,尝试把这些词进行穷举,放到字典文件中,我们可以依次取句子中的内容,每隔一个词进行查找。第二种是基于统计,会有很多官方的语料库进行人工标注/统计,分词技术在NLP中也比较常见。我们在该项目中使用Maven中的第三方库Ansj分词技术。
附带依赖链接:https://mvnrepository.com/artifact/org.ansj/ansj_seg/5.1.6

基本实现

模块划分

项目总共划分为三个模块:
索引模块:扫描下载到的文档,分析数据内容构建正排+倒排索引,并保存到文件中。
搜索模块:加载索引。根据输入的查询词, 基于正排+倒排索引进行检索,得到检索结果。
Web模块:编写一个简单的页面,展示搜索结果。点击其中的搜索结果能跳转到对应的 Java API 文档页面。

创建项目

使用IDEA创建一个SpringBoot项目,具体细节不在详细赘述。项目的目录结结构大致是这样:
在这里插入图片描述

引入分词依赖

使用Ansj分词第三方库,可以看一些简单的示例:java分词-ansj的初次使用
在pom.xml中注入依赖:

<dependency>
    <groupId>org.ansj</groupId>
   	<artifactId>ansj_seg</artifactId>
    <version>5.1.6</version>
</dependency>

注意:当 ansj 对英文分词时,会自动把单词大写转为小写。

实现索引模块

我们要实现的是在本地基于离线文档制作索引,实现检索,当用户在搜素结果页点击具体的搜索结果时,就自动跳转到在线文档的页面。跳转过去的目标页面也称为落地页面。
在这里插入图片描述

1. 实现Parse类

Parse类构建一个可执行程序。
① 根据指定路径,枚举出该路径中的所有文件(html),这个过程中需要把所有子目录的文件获取到;
② 根据文件罗列出的文件路径,打开文件,读取文件内容,解析并构建索引;
a) 标题:直接使用解析操作
b) URL:基于文件路径进行了简单拼接(离线文档和线上文档路径的关系)
c) 正文:核心操作,去标签~简单粗暴的方式实现的。使用<>作为“是否考虑要拷贝数据”的开关
③ 把在文件中构建好的索引数据结构,保存在指定的文件,使用Index类中的addDoc()方法。
Parse类最主要的事情是辅助Index类完成索引的制作过程。详细代码见上述github链接。

public class Parser {
    private static final String INPUT_PATH = "E:/IdeaProjects/doc_searcher_index/jdk-8u231-docs-all/docs/api";
    //TODO补充上索引实例
    public static void main(String[] args) throws InterruptedException {
        Parser parser = new Parser();
        parser.run();
    }
    
    public void run() {
        System.out.println("开始解析!");
        long beg = System.currentTimeMillis();
        // 1. 枚举出这个目录下的所有文件
        ArrayList<File> fileList = new ArrayList<>();
        enumFile(INPUT_PATH, fileList);
        for (File f : fileList) {
            System.out.println("解析 " + f.getAbsolutePath());
            // 2. 针对每个文件, 打开, 并读取内容, 进行转换
            parseHTML(f);
        }
        System.out.println("解析完成! 开始保存索引!");
        long end = System.currentTimeMillis();
        System.out.println("保存索引完成! 时间: " + (end - beg));
    }

    // 递归完成目录枚举过程
    private void enumFile(String rootPath, ArrayList<File> fileList) {
        File rootFile = new File(rootPath);
        File[] files = rootFile.listFiles();
        for (File f : files) {
            if (f.isDirectory()) {
                enumFile(f.getAbsolutePath(), fileList);
            } else if (f.getAbsolutePath().endsWith(".html")) {
                fileList.add(f);
            }
        }
    }
    
    private void parseHTML(File f) {
        // 1. 转换出标题
        String title = parseTitle(f);  //得到的文件名字-“.html”
        // 2. 转换出 url
        String url = parseUrl(f);  //网络URL和本地URL进行拼接
        // 3. 转换出正文(正文需要去除 html 标签)
        String content = parseContent(f);  // 先按照一个字符一个字符的方式来读取,以 < 和 > 来控制拷贝数据的开关
        // 4. TODO 添加到索引中
        
    }

    private String parseTitle(File f) {
        // 直接使用文件名作为标题
        String name = f.getName();
        return name.substring(0, name.length() - ".html".length());
    }

    private String parseUrl(File f) {
        // 这个 url 是指在线文档对应的链接.
        // url 由两个部分构成.
        // 第一部分是 https://docs.oracle.com/javase/8/docs/api
        // 第二部分是 文件路径中 api 之后的部分.
        String part1 = "https://docs.oracle.com/javase/8/docs/api";
        String part2 = f.getAbsolutePath().substring(INPUT_PATH.length());
        return part1 + part2;
    }

    private String parseContent(File f) {
        // 读取文件内容, 并去除其中的 html 标签和换行
        try {
            FileReader fileReader = new FileReader(f);
            // 是否当前读的字符是正文
            boolean isContent = true;
            StringBuilder output = new StringBuilder();
            while (true) {
                int ret = fileReader.read();
                if (ret == -1) {
                    break;
                }
                char c = (char)ret;
                if (isContent) {
                    if (c == '<') {
                        isContent = false;
                        continue;
                    }
                    if (c == '\n' || c == '\r') {
                        c = ' ';
                    }
                    output.append(c);
                } else {
                    if (c == '>') {
                        isContent = true;
                    }
                }
            }
            fileReader.close();
            return output.toString();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return "";
    }
}

在这里插入图片描述

2. 实现Index

Index 负责构建索引数据结构。主要提供以下方法:

  • getDocInfo():根据 docId 查正排,返回类型是DocInfo类,包含文档中的详细信息(docId, title, url, content),直接按照下标来取元素
  • getInverted():根据关键词查倒排,返回值类型为List列表;Weight包含了docId和weight权重;按照key取HashMap<String, ArrayList>的value即可
  • addDoc(): 往索引中新增一个文档,包括构建正排索引和构建倒排索引。①构建正排,构造DocInfo对象,添加到正排索引末尾。②构建倒排,先进行标题和正文分词,统计词频。遍历分词结果,去更新倒排索引中对应的倒排拉链即可,同时注意线程安全问题。
  • save():往磁盘中写索引数据,使用ObjectMapper类保存成Json格式;ObjectMapper类是Jackson库的主要类。它能够提供writeValue()和readValue()方法将Java对象和JSON结构相互转换(序列化和反序列化),基于JSON格式把索引数据保存到指定文件中
  • load():从磁盘加载索引数据,基于JSON格式对数据进行解析,将硬盘中的文件读出来,解析到内存中
创建Index类

首先,我们要了解Parse类和Index类的关系,Parse类相当于制作索引的入口,Index类相当于实现了索引的数据结构,可以提供一些API。
所用到的类结构:
在这里插入图片描述

public class Index {
    public static final String INDEX_PATH = "E:/IdeaProjects/doc_searcher_index/";
    // 正排索引, 下标对应 docId
    private ArrayList<DocInfo> forwardIndex = new ArrayList<>();
    // 倒排索引, key 是分词结果, value 是这个分词 term 对应的倒排拉链(包含一堆 docid)
    private HashMap<String, ArrayList<Weight>> invertedIndex = new HashMap<>();

    // 根据 docId 查正排
    public DocInfo getDocInfo(int docId) {}
    // 根据 分词结果 查倒排
    public ArrayList<Weight> getInverted(String term) {}
    // 向索引中新增一条文档
    public void addDoc(String title, String url, String content) {}
    // 加载索引文件
    public void load() {}
    // 保存索引文件
    public void save() {}
}
创建Weight类

Weight类表示一个文档的权重信息,其中 weight 的值通过词出现的频率来构造。
在代码中使用的是:weight = 10×标题中出现的次数 + 1×正文中出现的次数。

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;}
}
实现 getDocInfo 和 getInverted
// 根据 docId 查正排
public DocInfo getDocInfo(int docId) {
    return forwardIndex.get(docId);
}

// 根据 分词结果 查倒排
public ArrayList<Weight> getInverted(String term) {
    return invertedIndex.get(term);
}
实现addDoc
DocInfo docInfo = buildForward(title, url, content);
buildInverted(docInfo);
构建正排索引 buildForward
private synchronized 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;
}
构建倒排索引 buildInverted
private void buildInverted(DocInfo docInfo) {
    // 构造 Weight 对象, 并更新倒排索引。此处 "权重" 简单粗暴的认为词出现的次数.
    // weight = 10 * 这个词标题中出现的次数 + 1 * 这个词正文中出现的次数
    // 核心流程:
    // 1. 对标题进行分词
    // 2. 遍历分词结果, 统计标题中每个词出现的次数
    // 3. 对正文进行分词
    // 4. 遍历分词结果, 统计正文中每个词出现的次数
    // 5. 把以上内容都整理到一个 HashMap 中
    // 6. 遍历 HashMap, 可以得到 词 -> 权重 这样的映射关系, 更新到倒排索引中
    // 这个类用于辅助统计词出现的次数
    class WordCnt {
        public int titleCount;
        public int contentCount;

        public WordCnt(int titleCount, int contentCount) {
            this.titleCount = titleCount;
            this.contentCount = contentCount;
        }
    }
    HashMap<String, WordCnt> wordCntMap = new HashMap<>();

    // 1. 对标题进行分词
    List<Term> terms = ToAnalysis.parse(docInfo.getTitle()).getTerms();
    // 2. 遍历分词结果, 统计标题中每个词出现的次数
    for (Term term : terms) {
        // 注意, 此时的 word 已经全转成小写了.
        String word = term.getName();
        WordCnt wordCnt = wordCntMap.get(word);
        if (wordCnt == null) {
            wordCntMap.put(word, new WordCnt(1, 0));
        } else {
            wordCnt.titleCount++;
        }
    }
    // 3. 对正文进行分词
    terms = ToAnalysis.parse(docInfo.getContent()).getTerms();
    // 4. 统计正文中出现的词的个数
    for (Term term : terms) {
        // 注意, 此时的 word 已经全转成小写了.
        String word = term.getName();
        WordCnt wordCnt = wordCntMap.get(word);
        if (wordCnt == null) {
            wordCntMap.put(word, new WordCnt(0, 1));
        } else {
            wordCnt.contentCount++;
        }
    }
    // 5. 把以上内容都整理到一个 HashMap 中
    for (HashMap.Entry<String, WordCnt> entry : wordCntMap.entrySet()) {
        // 6. 遍历 HashMap, 可以得到 词 -> 权重 这样的映射关系, 更新到倒排索引中
        Weight weight = new Weight();
        weight.setDocId(docInfo.getDocId());
        weight.setWeight(entry.getValue().titleCount * 10 + entry.getValue().contentCount);
        // weight.setWord(entry.getKey());
        // 这个逻辑也可以使用 Map.putIfAbsent. 此处为了直观, 还是直接用 get 吧
        synchronized (this) {
            ArrayList<Weight> invertedList = invertedIndex.get(entry.getKey());
            if (invertedList == null) {
                invertedList = new ArrayList<>();
                invertedIndex.put(entry.getKey(), invertedList);
            }
            invertedList.add(weight);
        }
    }
}
实现save

save()方法保存索引到文件中,生成两个文件:forward.dat 和 inverted.dat, 通过 json 格式来表示。

// 保存索引文件
public void save() {
    long beg = System.currentTimeMillis();
    System.out.println("保存索引开始!");
    File indexPathFile = new File(INDEX_PATH);
    if (!indexPathFile.exists()) {
        indexPathFile.mkdirs();
    }
    File forwardIndexFile = new File(INDEX_PATH + "forward.dat");
    File invertedIndexFile = new File(INDEX_PATH + "inverted.dat");
    try {
        objectMapper.writeValue(forwardIndexFile, forwardIndex);
        objectMapper.writeValue(invertedIndexFile, invertedIndex);
    } catch (IOException e) {
        e.printStackTrace();
    }
    long end = System.currentTimeMillis();
    System.out.println("保存索引结束! 消耗时间: " + (end - beg));
}
实现load

把索引文件的内容加载到内存中。

// 加载索引文件
public void load() {
    long beg = System.currentTimeMillis();
    System.out.println("加载索引开始!");
    File forwardIndexFile = new File(INDEX_PATH + "forward.dat");
    File invertedIndexFile = new File(INDEX_PATH + "inverted.dat");
    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));
}

3. 优化速度

测试发现,当前加载制作索引的过程是非常耗时的,主要的性能瓶颈出在循环遍历文件上。每次循环都要针对一个文件进行解析,读文件+分词+解析内容;因此,我们可以通过多线程的方式提高制作速度。
在Parse类中创建新方法 runByThreadPool

  • 通过线程池 ExecutorService 类的方式,用多个线程来解决并发解析html文件的问题;run创建一个任务,通过submit往线程池中提交任务(只是将Runnable对象给放到阻塞队列中)
  • 通过调用 CountDownLatch 倒计时锁存器的await()方法来表示所有的任务都执行完后,再将索引保存至内存
public void runByThreadPool() throws InterruptedException {
    System.out.println("开始解析!");
    long beg = System.currentTimeMillis();
    // 1. 枚举出这个目录下的所有文件
    ArrayList<File> fileList = new ArrayList<>();
    enumFile(INPUT_PATH, fileList);
    System.out.println("共需要处理 " + fileList.size() + " 个文档!");
    CountDownLatch latch = new CountDownLatch(fileList.size());
    ExecutorService executorService = Executors.newFixedThreadPool(8); //这样创建出来的不是守护线程
    for (File f : fileList) {
        executorService.submit(new Runnable() { 
            @Override
            public void run() {
                System.out.println("解析 " + f.getAbsolutePath());
                parseHTML(f);
                latch.countDown();  //告知countDownLatch当前任务处理完成
            }
        });
    }
    //await 方法会阻塞, 直到所有的选手都调用 countDown() 撞线之后, 才能阻塞结束
    latch.await();
    // 所有任务都结束之后, 就可以销毁线程池了. 否则进程无法正常退出!
    executorService.shutdown();
    System.out.println("解析完成! 开始保存索引!");
    //等待所有线程把文档任务都处理完,才能进行save保存索引操作
    index.save();
    long end = System.currentTimeMillis();
    System.out.println("保存索引完成! 时间: " + (end - beg));
}

为什么开机之后首次创建索引非常慢,之后就快了呢?
首先,经过时间性能测试,我们发现解析正文的时间要比 addDoc 制作索引的时间长很多~首次运行时,是直接从磁盘上读取本地文件的,频繁的从磁盘IO读取数据是非常耗时的,操作系统之后就会对这些文件进行缓存,后面在运行时,直接从内存取数据,就会快很多。
同时,在扫描文件内容时,我们使用BufferReader类代替FileReader类,BufferReader内部内置了缓冲区,可以将FileReader中的数据提前放到缓冲区中,从而减少读取磁盘的次数。

如何解决线程结束,而进程不结束的问题?
我们在代码中使用 ExecutorService 类创建线程池,并设置相应的最大线程数;而这种方式创建出来的线程不是守护线程,线程的运行状态会影响进程结束。可以通过setDaemon方法手动设置,才能成为守护线程。
如果是守护线程的话,线程运行状态不会影响进程状态。
这里我们采用简单粗暴的方式直接手动的把线程池里面的线程都干掉:executorService.shutdown()

如何保证线程安全?
在使用多线程的时候,要将parseHTML()添加到任务中,而这会出现多个线程同时调用addDoc(),引发线程不安全。因此,我们要对addDoc中的buildForward和buildInverted分别加锁;
这里注意的是,直接把Synchronized加在parseHTML 或者 addDoc方法上是不科学的,因为锁的粒度太粗,并发程度不高。
buildForward和buildInverted是在操作不同的对象。

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

    // 构建倒排索引 (查询词-->文档id之前的关系): 分词
    private void buildInverted(DocInfo docInfo) {
        class WordCnt {
            // 表示这个词在标题中出现的次数
            public int titleCount;
            // 表示这个词在正文中出现的次数
            public int contentCount;
        }
        // 这个数据结构用来统计词频
        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) {
                // 如果不存在, 就创建一个新的键值对, 插入进去. 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 (locker2) {
                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());
                    // 权重计算公式: 标题中出现的次数 * 10 + 正文中出现的次数
                    weight.setWeight(entry.getValue().titleCount * 10 + entry.getValue().contentCount);
                    newInvertedList.add(weight);
                    invertedIndex.put(entry.getKey(), newInvertedList);
                } else {
                    // 如果非空, 就把当前这个文档, 构造出一个 searcher.Weight 对象, 插入到倒排拉链的后面
                    Weight weight = new Weight();
                    weight.setDocId(docInfo.getDocId());
                    // 权重计算公式: 标题中出现的次数 * 10 + 正文中出现的次数
                    weight.setWeight(entry.getValue().titleCount * 10 + entry.getValue().contentCount);
                    invertedList.add(weight);
                }
            }
        }
    }

    // 构建正排索引 (文档id-->文档信息之间的关系)
    private DocInfo buildForward(String title, String url, String content) {
        DocInfo docInfo = new DocInfo();
        docInfo.setTitle(title);
        docInfo.setUrl(url);
        docInfo.setContent(content);
        synchronized (locker1) {
            //新增一份文档的docId正好是当前索引的长度值所在的位置
            docInfo.setDocId(forwardIndex.size());
            forwardIndex.add(docInfo);
        }
        return docInfo;
    }

实现搜索模块

在搜索模块部分,我们要使用的是调用索引模块,来完成搜索的核心过程~
简化流程主要分为以下几个部分:
1.分词。针对用户输入的查询词进行分词(用户输入的查询词可能不是一个词,有可能是一句话)
2.触发。拿着每个分词的结果,去倒排索引中查找具有相关性的文档(调用index类里面的查倒排的方法)
3.排序。针对上面触发出来的结果,进行排序(按照相关性,降序排序)
4.包装结果。根据排序后的结果,依次查正排列表,获取每个文档的详细信息,包装成一定的数据结构返回

负责实现搜索功能,主要方法有:
在这里插入图片描述

创建DocSearcher类
public class DocSearcher {

    private Index index = new Index();

    // 根据查询词, 进行搜索, 得到搜索结果集合.
    // 结果集合中包含若干条记录, 每个记录中包含搜索结果的标题, 描述, url
    // 1. [分词] 对查询词进行分词
    // 2. [触发] 对每个分词结果查找倒排索引, 得到一个倒排拉链
    // 3. [排序] 针对结果集合进行排序, 按权重降序排序即可
    // 4. [返回] 构造返回结果
    public List<Result> search(String query) {}
}
创建Result类

表示搜索结果

public class Result {
    private String title;
    private String url;
    // desc 是正文的一段摘要
    private String desc;
 	// 省略 getter, setter 方法
}
实现search方法
// 根据查询词, 进行搜索, 得到搜索结果集合.
// 结果集合中包含若干条记录, 每个记录中包含搜索结果的标题, 描述, url
// 1. [分词] 对查询词进行分词
// 2. [触发] 对每个分词结果查找倒排索引, 得到一个倒排拉链
// 3. [排序] 针对结果集合进行排序, 按权重降序排序即可
// 4. [返回] 构造返回结果
public List<Result> search(String query) {
    // 1. [分词] 对查询词进行分词
    List<Term> terms = ToAnalysis.parse(query).getTerms();
    // 2. [触发] 对每个分词结果查找倒排索引, 得到一个倒排拉链
    System.out.println("分词结果为: " + terms);
    ArrayList<Weight> allTokenResult = new ArrayList<>();
    for (Term term : terms) {
        String word = term.getName();
        ArrayList<Weight> invertedList = index.getInverted(word);
        // 倒排拉链可能找不到, 比如这个词根本就在索引中不存在.
        if (invertedList == null) {
            continue;
        }
        allTokenResult.addAll(invertedList);
    }
    // 3. [排序] 针对结果集合进行排序, 按权重降序排序即可
    allTokenResult.sort(new Comparator<Weight>() {
        @Override
        public int compare(Weight o1, Weight o2) {
            // 如果升序就是 o1 - o2, 降序是 o2 - o1
            return o2.getWeight() - o1.getWeight();
        }
    });
    // 4. [返回] 构造返回结果
    ArrayList<Result> results = new ArrayList<>();
    for (Weight weight : allTokenResult) {
        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;
}
实现GenDesc方法

生成描述信息Desc,我们采用的方法是获取到所有的查询词分析结果,遍历分析结果,看哪个结果在正文中出现,找对应的位置,以这个词为中心,往前截取60字符作为描述的开始,然后在从描述开始,截取160个字符,作为整个描述~

private String GenDesc(String content, List<Term> terms) {
    // 先找一下 word 在 content 中第一次出现的位置
    // 由于此时的 word 已经是全小写了, 需要把原来正文也转成小写
    // 进行全字匹配. 要求待查找结果必须是个独立的单词.
    // 用分词结果中的第一个在描述能找到的词, 作为位置的中心
    int firstPos = -1;
    for (Term term : terms) {
        String firstWord = term.getName();
        firstPos = content.toLowerCase().indexOf(" " + firstWord + " ");
        if (firstPos > 0) {
            break;
        }
    }
    // 如果所有的分词结果在正文中都不存在, 则直接返回空的描述
    if (firstPos == -1) {
        return "";
    }
    // 直接截取 firstPos 周围的文本
    // 从 firstPos 往前找 60 个字符作为描述开始,
    // 然后从描述开始位置往后找 160 个字符作为整个描述
    // 注意: 此处的 60, 160 都是拍脑门出来的
    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;
}

在上面测试的情况下,我们运行程序测试查询词ArrayList,观察结果,发现有一些Script标签,因此我们要去除Script标签里的内容。
采用基于表达式去除script标签内容
修改Parse类

// NOTE 新增代码
private String readFile(File f) {
    StringBuilder content = new StringBuilder();
    try (BufferedReader bufferedReader = new BufferedReader(new FileReader(f))) {
        while (true) {
            String line = bufferedReader.readLine();
            if (line == null) {
                break;
            }
            content.append(line);
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
    return content.toString();
}

// NOTE 新增代码
public String parseContentByRe(File f) {
    // 1. 先把整个文件内容都读取出来
    String content = readFile(f);
    // 2. 使用正则替换掉 <script> 标签
    content = content.replaceAll("<script.*?>(.*?)</script>", " ");
    // 3. 使用正则替换掉其他标签
    content = content.replaceAll("<.*?>", " ");
    // 4. 多个空格合并成一个
    content = content.replaceAll("\\s+", " ");
    return content;
}

此处使用的正则表达式,.*? 表示匹配任意非 \n 的字符,允许出现任意次。并且是一个非贪婪匹配。
使用贪婪匹配 <.*> , 会把整个 content 都匹配到。 最后的替换结果就全都替换啦。
正则表达式学习链接:https://www.runoob.com/regexp/regexp-tutorial.html

实现Web模块

这个模块主要是给用户提供一个简易的搜索界面,同时提供一个web接口,以网页访问的形式,把程序呈现给用户。
在进行Web模块编写之前,我们首先要约定交互的请求和响应格式:
请求:GET /searcher?query=[查询词] HTTP/1.1
响应:HTTP/1.1 200 ok
[
{
title:“这是标题”,
url:“这是url”,
desc:“这是描述”
}
]
以上我们采用Ajax前后端交互的方式触发请求和响应。当用户点击搜索按钮时,浏览器就会获取到搜索框内容,基于Ajax构造HTTP请求,然后发送给搜索服务器;浏览器获取到搜索结果之后,再根据结构的Json数据显示界面。
我们采用jQuery(JS的第三方库)构造Ajax请求:https://jquery.com/download/

导入servlet包

Maven中央仓库导包

<!-- 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.java文件:添加@WebServlet(“/searcher”)注解

@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.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);
    }
}
编写index.html代码

将 index.html, 背景图片, jquery.min.js 都放到 resources/public 目录中。编写简易的查询界面:
在这里插入图片描述

// 实现页面结构
<div class="container">
    <div class="header">
        <input type="text">
        <button onclick="search()">搜索</button>
    </div>
    <div class="result">
        <!-- <div class="item">
            <a href="#">我是标题</a>
            <div class="desc">我是描述</div>
            <div class="url">我是 url</div>
        </div> -->
    </div>
</div>

实现样式:

* {
    margin: 0;
    padding: 0;
}

/* 要把 html, body 都设置成 100% 高度, 才能和浏览器窗口一样高 */
html,
body {
    height: 100%;
}

body {
    background-image: url("background.jpg");
    background-repeat: no-repeat;
    background-position: center center;
    background-size: cover;
}

.container {
    width: 800px;
    /* 让高度和页面一样高 */
    height: 100%;
    margin: 0 auto;
    padding: 10px;
    background-color: rgba(255, 255, 255, 0.7);
    overflow: auto;
}

.header {
    width: 100%;
    height: 50px;
}

.header input {
    /* input 总宽度为 700px, 掉 10px padding, 4px 左右边框, 还剩 686px */
    float: left;
    width: 600px;
    height: 46px;
    font-size: 22px;
    padding-left: 10px;
}

.header button {
    float: left;
    width: 100px;
    height: 50px;
    color: #fff;
    background-color: blue;
    font-size: 22px;
}

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

.item a {
    display: block;
    font-size: 22px;
    height: 40px;
    line-height: 40px;
    font-weight: 700;
}

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

.item .url {
    font-size: 16px;
    color: rgb(0, 128, 0);
    font-style: normal;
}

实现查询:

function search() {
    // 1. 获取到搜索框中的查询词
    let query = $(".header input").val();
    console.log("query: " + query);
    // 2. 构造 http 请求发送给服务器
    $.ajax({
        type: "get",
        url: "searcher?query=" + query,
        success: function (data, status) {
            // 3. 根据返回数据, 创建 html 标签.
            buildResult(data);
        }
    });
}

function buildResult(data) {
    // data 默认是字符串类型, 需要先转成对象. 
    data = JSON.parse(data);    
    let result = document.querySelector('.result');
    // 要清空原来的子元素
    result.innerHTML = '';
    let total = document.createElement('div');
    total.innerHTML = "共找到结果: " + data.total + " 个!";
    result.appendChild(total);
    for (let item of data.results) {
        let itemHtml = document.createElement('div');
        itemHtml.className = 'item';

        // 取到数组中的每个 json 对象
        let title = document.createElement('a');
        title.innerHTML = item.title;
        title.href = item.url;
        title.target = '_blank';
        itemHtml.appendChild(title);

        let desc = document.createElement('div');
        desc.innerHTML = item.desc;
        desc.className = 'desc';
        itemHtml.appendChild(desc);

        let showUrl = document.createElement('div');
        showUrl.innerHTML = item.url;
        showUrl.className = 'url';
        itemHtml.appendChild(showUrl);

        result.appendChild(itemHtml);
    }
}
遇到的问题

测试页面代码发现,查询出来的信息太多,超出一个屏幕的限制,超出背景范围
可以通过CSS属性,让当前的内容都仅仅局限在.container这个div内部滚动,而不是整个页面滚动,我们可以加上overflow: auto属性

点击搜索之后,搜索结果页被目标页替换
给a标签加上target = "_blank"属性,点击搜索时,会跳转到新页面打开。

输入含有两个分词的句子,两次搜索结果混合在一起
每次点击按钮,都是把搜索结果往.result里面进行追加,没有清理过内容,所以在每次搜索之前清空显示内容,添加result.innerHTML = ‘’"。

实现标红逻辑(搜索词在显示结果中标记为红色)
这个需要前后端的配合,修改后端代码,生成搜索结果的时候,将包含查询词的部分加上一个标记标签;然后在前端css设置中将的样式代码设置为红色。

在搜素结果部分(文本框下面)添加搜索个数说明
有两种方法:①直接在服务器这边计算好,返回给浏览器显示(前后端同时修改)②在浏览器这边根据查询到的结果自动计算(只需修改前端),我们当然选择后者。

	let countDiv = document.createElement('div');
	countDiv.innerHTML = '当前找到 ' + data.length + '个结果!';
	countDiv.className = 'count';
	result.appendChild(countDiv);
处理暂停词

当我们查询array list时发现,一些没有被匹配的文档也显示了出来。这是为什么呢?原来是将搜索句分为array、list和空格了,所以我们要将包括空格在内的多个没有意义的暂停词去除掉。

//依次遍历文件中的每一行
 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();
     }
 }
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);
}
多路归并去重

在处理暂停词的同时,还发现array list查询是将array查询以及list查询的结果相加得到的,有的文档甚至出现了两次。这样显然是不科学的。像Collection这样的文档,同时包含多个分词结果,其实是意味着这个文档的想相关性更高!因此,应该查询排列更靠前;为了实现这样的效果,我们进行多路归并,将相同文件的权重合并。
在这里插入图片描述

//核心代码
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<>();
    //  2.1 创建优先级队列, 并指定比较规则(按照 Weight 的 docId, 取小的更优先)
    PriorityQueue<Pos> queue = new PriorityQueue<>(new Comparator<Pos>() {
        @Override
        public int compare(Pos o1, Pos o2) {
            // 先根据 pos 值找到对应的 Weight 对象, 再根据 Weight 的 docId 来排序
            Weight w1 = source.get(o1.row).get(o1.col);
            Weight w2 = source.get(o2.row).get(o2.col);
            return w1.getDocId() - w2.getDocId();   //升序排序
        }
    });
    //  2.2 初始化优先级队列, 把每一行的第一个元素放到队列中.
    for (int row = 0; row < source.size(); row++) {
        // 初始插入的元素的 col 就是 0
        queue.offer(new Pos(row, 0));
    }
    //  2.3 循环的取队首元素(也就是当前这若干行中最小的元素)
    while (!queue.isEmpty()) {
        Pos minPos = queue.poll();
        Weight curWeight = source.get(minPos.row).get(minPos.col);
        //  2.4 看看这个取到的 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 {
                // 如果文档 id 不相同, 就直接把 curWeight 给插入到 target 的末尾
                target.add(curWeight);
            }
        } else {
            // 如果 target 当前是空着的, 就直接插入即可
            target.add(curWeight);
        }
        //  2.5 把当前元素处理完了之后, 要把对应这个元素的光标往后移动, 去取这一行的下一个元素
        Pos newPos = new Pos(minPos.row, minPos.col + 1);
        if (newPos.col >= source.get(newPos.row).size()) {
            // 如果移动光标之后, 超出了这一行的列数, 就说明到达末尾了.
            // 到达末尾之后说明这一行就处理完毕了~~
            continue;
        }
        queue.offer(newPos);
    }
    return target;
}

部署在云服务器上

1. 准备环境:
在云服务器上安装Centos7系统,安装一些依赖程序jdk,tomcat
将war包放在服务器的安装目录webapp下
查看8080端口是否被占用:netstat -anp | grep 8080
2. 将程序依赖的数据给拷贝到云服务器上
注意更改数据的目录文件路径: java静态文档、正排/倒排索引文件、暂停词文件、image文件…
3. 打war包,指定war包名称

    <packaging>war</packaging>
    <build>
        <finalName>java_doc_searcher</finalName>
    </build>

操作步骤: Maven–Lifecycle–package–run
4.部署云服务器上,进行网页访问
在这里插入图片描述

测试用例设计

注意:此项目暂不支持模糊查询的功能,所以如果要查找一个API就必须准确的输入才能被查找到。暂且观察API文档的长度为3,最长暂不考虑。

功能测试

Parse 类
需求:遍历本地 Java API 文档,将所有 html 文档的内容解析成文本文件;文本文件的每一行对应一个 html 文档,每一行的内容包括 html 文档的标题、URL和正文 。
功能点
① 通过遍历枚举E:\IdeaProjects\doc_searcher_index\jdk-8u231-docs-all\docs\api目录下的所有html文件;
②解析的内容放在行文本文件中;
③文本文件的一行对应一个html文件内容;
④每一行的内容是否有三部分,即html的标题、url和正文;
⑤解析后正文内容没有html标签、script标签和\n标签。
测试用例设计
①枚举遍历的目录是否正确,文件夹下的 html 文档是否都遍历到;
②解析的内容是否都放在行文本文件中,即 data.txt 中;
③解析出的每一行是否都是一个 html 文档的内容;
④每一行的内容是否都有三部分,即 html 的标题、url 和正文,每部分并用空格隔开;
⑤解析到的正文内容是否还包含 html 标签、script标签和 \n 标签;
⑥解析到的标题是否和 html 文件的文件名是一致的。

Index类
需求:对解析文件中的内容构建正排索引和倒排索引。
功能点
①创建一个字符串数组,将文本文件中内容按空格分割放入数组,即数组的长度大小应为 3 。数组的下标作为正排索引的 docId ;将标题放入数组的 0 号下标位置,将 url 放入数组的 1 号下标位置,将正文放入数组的 2 号下标位置;
②对标题和正文内容借用分词工具实现分词的功能;
③计算每一个 API 在所在文档的权重;
④将标题和正文分词后的结果用 hashmap 存储,key 为分词后的词,value 为一个链表,链表大小为 2 ,分别存放的是关键词在标题总共出现的次数和在正文中总共出现的次数。
测试用例设计
①存放分割内容的数组长度大小是否为 3 ;
②数组存放内容的顺序是否是按标题、url、正文的顺序来存放的;
③构建正排索引的时候是否把每一行都加入进去了;
④分词器是否能正确的分词;
⑤计算权重的时候是否计算的是对应关键词的权重;
⑥计算权重时是否标题和正文中都计算到了。

Searcher类
需求:根据用户输入的内容进行全文搜索,并返回搜索的结果。
功能点
①将用户输入的内容进行分词;
②根据分词结果在 data.txt 文件中查找哪些文档中有关键词;
③将出现关键词的文档按权重从高到低排序;
④根据查找到的文档得到标题并作为要在页面上显示的标题;
⑤根据查找到的文档得到 url 并作为页面上显示的 url 和需要点击的 url;
⑥根据查找到的文档得到正文,取正文中出现关键词的地方的前 60 个字符和后 160 个字符作为页面上显示的描述部分。
测试用例设计
①是否把用户输入的内容进行分词了;
②根据关键词查找内容的时候是否是对data.txt 文件进行查找的;
③出现关键词的文档是否是按权重降序排列的;
④得到的标题是否与用户输入的关键词相似度最高;
⑤每一组点击的 url 和展示的 url 是否是一样的;
⑥描述的内容中是否出现了用户输入的关键词;
⑦显示的标题、url 和描述内容是否都是相对应的;
⑧显示的第一部分内容是否与输入的内容相关性最高。

HTML界面测试
功能:用户在输入框输入查询的内容,点击搜索按钮进行搜索,界面下方显示搜索到的结果。
测试用例设计
①界面显示是否美观;
②输入框能否输入内容;
③正确的输入一个关键词,点击搜索按钮是否能实现搜索功能;
④正确的输入一个关键词,界面上能否显示出与之相关的标题、描述、显示的 url ;
⑤搜索结果的显示是否美观,是否存在重叠部分;
⑥点击标题看是否能跳转到对应的线上文档;
⑦对输入框输入除英文字符外的其他内容能否进行查找,例如输入中文字符、特殊字符、数字字符、不输入任何东西等;
⑧任意输入英文字符能否进行查找;
⑨输入部分正确的关键词能否查找到内容;
⑩对输入的内容改变大小写能否查找到。

单元测试

单元测试是在开发阶段在 IDEA 中创建测试类进行测试,这一部分与功能测试写的测试用例相结合实施测试。
测试 Parser 类:Junit测试

public class ParserTest {
    @Test
    public void convertLine() throws IOException {
        Parser parser = new Parser();
        File file = new File("E:\IdeaProjects\doc_searcher_index\jdk-8u231-docs-all\docs\api\\java\\math\\BigInteger.html");
        String res = parser.convertLine(file);
        System.out.println(res);
    }

    @Test
    public void convertContent() throws IOException {
        File file = new File("E:\IdeaProjects\doc_searcher_index\jdk-8u231-docs-all\docs\api\\java\\index.html");
        Parser parser = new Parser();
        String res = parser.convertContent(file);
        System.out.println(res);
    }

    @Test
    public void convertUrl() {
        File file = new File("E:\IdeaProjects\doc_searcher_index\jdk-8u231-docs-all\docs\api\\java\\Array.html");
        Parser parser = new Parser();
        String res = parser.convertUrl(file);
        System.out.println(res);
    }

    @Test
    public void convertTitle() {
        File file = new File("E:\IdeaProjects\doc_searcher_index\jdk-8u231-docs-all\docs\api\\java\\Array.html");
        Parser parser = new Parser();
        String res = parser.convertTitle(file);
        System.out.println(res);
    }
}

自动化测试

//自动化测试脚本编写
from selenium import webdriver
import unittest
import time

class imageTest(unittest.TestCase):
    def setUp(self):#初始化
         self.driver = webdriver.Chrome()
        self.driver.get("http://localhost:8080/java_doc_searcher/)
        self.driver.maximize_window()
        time.sleep(3)

    def test_mytest(self):#测试的方法必须以 'test_' 开头
    	//输入框输入 ArrayList 进行查询
        self.driver.find_element_by_xpath("//*[@id='app']/div[2]/input").send_keys("arraylist")
        time.sleep(3)
        #点击搜索按钮
        self.driver.find_element_by_xpath("//*[@id='app']/div[2]/button").click()
        time.sleep(3)
        #点击跳转到线上文档的 url,即显示的标题
        self.driver.find_element_by_xpath("//*[@id='app']/div[3]/div[1]/a").click()
        time.sleep(3)

    def tearDown(self):#清理测试环境
        self.driver.quit()#close 也可以,但是 quit 可以清理掉缓存
        
if __name__ == "__main__":
    unittest.main()

注:相关的兼容性测试、性能测试方法暂不涉及,后续考虑。


版权声明:本文为fxt579810原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。