一.项目目标

实现一个Java API文档的站内搜索引擎。

用户点击搜索框输入查询词之后点击搜索,将会在服务器中检索出所有与查询词相关的文档,并且将这些文档返回到页面上,用户点击搜索结果,就会跳转到文档的详细页面。

1.为什么要搜索Java API文档?

    1)官方文档上没有一个好用的搜索框。

    2)Java API文档数量较少,当前有限的硬件资源足以处理几万个页面。

   3)文档内容不需要使用爬虫来获取,可以直接在官网上下载。

二.项目模块

1.预处理模块

下载好的API文档的html进行初步处理,把若干个html文件处理成一个行文本格式的文件raw_data.txt。

每一行对应一个文档,有三列,这三列使用\3分割,包含文档的标题,这个文档的url(线上版本的url),正文(去掉html标签)。

正文去掉html标签,是为了让搜索结果集中到文档的正文上。

1)单线程枚举出INPUT_PATH下所有的html文件 ;(经典面试题)

public static void enumFile(String inputPath,ArrayList<File> fileList){
        //递归的把input路径下单所有的目录和文件都遍历一遍
        File root = new File(inputPath);
        //listFiles相当于Linux中ls命令 可以将当前目录下所有的文件都列举出来
        File[] files = root.listFiles();
        //遍历当前目录和文件 分别处理
        for(File file : files){
            if(file.isDirectory()){//是目录
                enumFile(file.getAbsolutePath(),fileList);
            }else if(file.getAbsolutePath().endsWith(".html")){//是文件
                //看文件后缀是不是.html 如果是就将整个文件加入到fileList的List中
                fileList.add(file);
            }
        }
    }

单线程会导致读取过慢,可以使用多线程来优化,简化执行时间;

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

        // 1. 枚举出所有的文件
        ArrayList<File> files = new ArrayList<>();
        enumFile(INPUT_PATH, files);
        // 2. 循环遍历文件. 此处为了能够通过多线程制作索引, 就直接引入线程池.
        CountDownLatch latch = new CountDownLatch(files.size());
        ExecutorService executorService = Executors.newFixedThreadPool(8);
        for (File f : files) {
            executorService.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println("解析 " + f.getAbsolutePath());
                    parseHTML(f);
                    latch.countDown();
                }
            });
        }
        // await 方法会阻塞, 直到所有的选手都调用 countDown 撞线之后, 才能阻塞结束.
        latch.await();
        // 手动的把线程池里面的线程都干掉!
        executorService.shutdown();
        // 3. 保存索引
        index.save();

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

2)针对枚举出的html文件路径进行遍历,依次打开每个文件并读取内容,把读取得到的内容 转化成docInfo对象;

public static String convertTitle(File file){
        String name = file.getName();
        return name.substring(0,name.length() - ".html".length());
    }
public static String convertUrl(File file){
        //线上文档路径由两部分组成
        //1.固定的 https://docs.oracle.com/javase/8/docs/api
        //2./java/util/*.html 这一部分与本地路径相关
        String str1 = "https://docs.oracle.com/javase/8/docs/api";
        String str2 = file.getAbsolutePath().substring(INPUT_PATH.length());
        return str1 + str2;
    }

转换正文时,需要去掉html标签和换行符。

采用遍历的方式,一个一个的读取字符,设置一个Boolean对象isContent来标记当前内容是不是正文。

当当前字符为<时,把isContent置为false,读到的字符自动忽略;

当当前字符为>时,把isContent置为true,并将之后所读到的字符放入StringBuffer中。

public static String convertContent(File file) throws IOException {
        //1.把html中的标签去掉
        //2.把\n去掉
        //一个一个的读取字符并判定
        FileReader fileReader = new FileReader(file);
        Boolean isContent = true;
        StringBuilder output = new StringBuilder();
        while(true){
            int ret = fileReader.read();
            if(ret == -1){
                //说明已将该文件读取完毕
                break;
            }
            char ch = (char)ret;
            if(isContent) {
                //是正文
                if (ch == '<') {
                    isContent = false;
                    continue;
                }
                if(ch == '\n' || ch == '\r'){
                    //\n表示换行 \r表示回车
                    ch = ' ';
                }
                output.append(ch);
            }else{
                if(ch == '>'){
                    isContent = true;
                }
            }
        }
        fileReader.close();
        return output.toString();
    }

2.索引模块

根据预处理模块输出文件raw_data.txt,制作正排和倒排索引。

正排:根据文档id找到对应的文档信息。

           docId => 文档内容

private static DocInfo buildForword(String line){
        //line有三列使用\3分割,包含文档的标题 文档的url 正文
        String[] tokens = line.split("\3");
        if(tokens.length != 3){
            System.out.println("文件格式存在问题" + line);
            return null;
        }
        DocInfo docInfo = new DocInfo();
        docInfo.setDocId(forwordIndex.size());
        docInfo.setTitle(tokens[0]);
        docInfo.setUrl(tokens[1]);
        docInfo.setContent(tokens[2]);
        //建立正排索引
        forwordIndex.add(docInfo);
        return docInfo;
    }

倒排:根据搜索词找到这个词在哪些文档id中存在。

          文档内容分词 => docId

         (分词使用第三方库.ansj)

倒排索引可以通过遍历正排索引的方式来构建。

     权重:反映该词与文档的相关程度。相关程度越高,权重越大。

     实际的搜索引擎根据搜索词与文档之间的相关性进行降序排列。

     把相关程度越高的文档排的越前,相关程度越低的文档排的越后。

     这里相关性的衡量方式采用该词在文档中的出现次数;但是如果该词在标题出现比在正文中出现的相关性更强。

     因此定义 权重:标题中出现次数*10 + 正文中出现次数

private void buildInverted(DocInfo docInfo){
        class WordCnt{
            public int titleCount;
            public int contentCount;
            public WordCnt(int titleCount,int contentCount){
                this.titleCount = titleCount;
                this.contentCount = contentCount;
            }
        }
        HashMap<String,WordCnt> wordCntHashMap = new HashMap<>();
        //针对DocInfo中的title和content进行分词
        //在根据分词结果构建出Weight对象 更新倒排索引
        //1.先针对标题进行分词
        List<Term> titleTerms = ToAnalysis.parse(docInfo.getTitle()).getTerms();
;        //2.遍历分词结果 统计标题中的每个词出现的次数
        for(Term term : titleTerms){
            String word = term.getName();
            WordCnt wordCnt = wordCntHashMap.get(word);
            if(wordCnt == null){
                wordCntHashMap.put(word,new WordCnt(1,0));
            }else{
                wordCnt.titleCount++;
            }
        }
        //3.再针对正文进行分词
        List<Term> contentTerms = ToAnalysis.parse(docInfo.getContent()).getTerms();
        //4.遍历分词·结果 统计正文中的每个词出现的次数
        for(Term term : contentTerms){
            String word = term.getName();
            WordCnt wordCnt = wordCntHashMap.get(word);
            if(wordCnt == null){
                wordCntHashMap.put(word,new WordCnt(0,1));
            }else{
                wordCnt.contentCount++;
            }
        }
        //5.遍历HasMap 依次构建Weight对象并更新倒排索引的映射关系
        for(Map.Entry<String,WordCnt> entry : wordCntHashMap.entrySet()){
            Weight weight = new Weight();
            weight.word = entry.getKey();
            weight.docId = weight.docId;
            weight.weight = entry.getValue().titleCount * 10 + entry.getValue().contentCount;
            //把weight对象加入到倒排索引中
            //倒排索引是一个HasMap value就是Weight构成的一个ArrayList
            //先根据这个词找到hasMap中对应的value
            ArrayList<Weight> invertedList = invertedIndex.get(entry.getKey());
            if(invertedList == null){
                //当前键值对不存在 新加一个键值对即可
                invertedList = new ArrayList<>();
                invertedIndex.put(entry.getKey(),invertedList);
            }
            //此时invertedIndex已经是一个合法的ArrayList 直接加入Weight
            invertedList.add(weight);
        }
    }

【关于索引模块的简单测试】

通过查看weight对象的打印,检验索引模块正排索引和倒排索引的实现是否正确。

public class TestIndex {
    public static void main(String[] args) throws IOException {
        Index index = new Index();
        index.build("D:\\raw_data.txt");
        List<Index.Weight> invertedList = index.getInverted("arraylist");
        for(Index.Weight weight : invertedList){
            System.out.println(weight.docId);
            System.out.println(weight.word);
            System.out.println(weight.weight);
            DocInfo docInfo = index.getDocInfo(weight.docId);
            System.out.println(docInfo.getTitle());
            System.out.println("===================");
        }
    }
}

3.搜索模块

完成一次搜索过程基本流程。从用户输入查询词,到得到最终结果的过程。

1)针对输入的查询词进行分词;

List<Term> terms = ToAnalysis.parse(query).getTerms();

2)遍历分词结果,去索引中找到所有和分词结果相关的记录(一系列docId);

ArrayList<Index.Weight> allTokenResult = new ArrayList<>();
        for(Term term : terms){
            String word = term.getName();
            List<Index.Weight> invertedList = index.getInverted(word);
            if(invertedList == null){
                //查询词在每个文档中都不存在
                continue;
            }
            allTokenResult.addAll(invertedList);
        }

3)针对相关性的高低,进行降序排序;

   这里针对一个集合类进行排序时,尤其时集合类内部包含引用对象时,需要指定比较规则。

   Comparable:实现被比较的类实现这个接口,重写compare To方法;

   Comparator:创建一个比较器实现这个接口,重写compare方法。

allTokenResult.sort(new Comparator<Index.Weight>() {
            @Override
            public int compare(Index.Weight o1, Index.Weight o2) {
                return o2.weight - o1.weight;
            }
        });

4)包装结果:把这些docId所对应的docInfo信息查找到,组装成一个响应数据。

List<Result> results = new ArrayList<>();
        for(Index.Weight weight : allTokenResult){
            //根据weight中包含的docId找到docInfo
            DocInfo docInfo = index.getDocInfo(weight.docId);
            Result result = new Result();
            result.setTitle(docInfo.getTitle());
            result.setShowUrl(docInfo.getUrl());
            result.setClickUrl(docInfo.getUrl());
            //GenDesc表示根据当前词找到这个词在正文中的位置 再把这个位置周围的文本获取到 得到一个描述
            result.setDesc(GenDesc(docInfo.getContent(),weight.word));
        }
        return results;
private String GenDesc(String content,String word) {
        //1.查找word在content中的位置
        int firstPos = content.toLowerCase().indexOf(word);
        if(firstPos == -1) {
            //某个词在标题中出现 在正文中没出现
            return "";
        }
        //定义描述为在firstPos前面60个字符 不足60个则从开头开始
        //后面160个字符 不足160则到结尾即可
        int descBegin = firstPos < 60 ?  0 : firstPos - 60;
        int descEnd = content.length() - firstPos < 160 ? content.length() : firstPos + 160;
        return content.substring(descBegin,descEnd) + "...";
    }

【关于搜索模块的简单测试】

检验通过输入关键词是否能查询出一系列包含关键词的文档,且输出形式是否符合所设定的要求。

public class TestSearcher {
    public static void main(String[] args) throws IOException {
        Searcher searcher = new Searcher();
        List<Result> results = searcher.search("arraylist");
        for(Result result : results){
            System.out.println(result);
            System.out.println("=====================");
        }
    }
}

4.前端模块

显示最终结果,和用户进行交互。

public class DocSearcherServlet extends HttpServlet {
    private Searcher searcher = new Searcher();
    private Gson gson = new GsonBuilder().create();

    public DocSearcherServlet() throws IOException {
    }

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        resp.setContentType("application/json;charset=utf-8");
        String query = req.getParameter("query");
        if(query == null || query.equals("")){
            resp.setStatus(404);
            resp.getWriter().write("query参数非法");
            return;
        }
        List<Result> results = searcher.search(query);
        //将响应转化为json格式
        String respString = gson.toJson(results);
        resp.getWriter().write(respString);
    }
}

 把上述搜索类放到一个服务器中,通过服务器完成搜索过程。这里使用HTTP servlet.

1)约定前后端交互接口

请求:

GET /search?query=ArrayList

响应(使用Json来组织):

[

  {

    title:标题,

    showUrl:展示URL,

    clickUrl:点击URL,

    desc:描述

  },

  {

  }

]

2)构造请求并获取结果

当输入框中输入内容,就会自动被放在query这个变量;点击搜索就会自动调用search()函数,使用ajax向服务器发送Json格式的请求。

<div class="row">
        <div class="col-md-5">
            <input type="text" class="form-control" placeholder="请输入关键字" v- 
                model="query">
        </div>
        <div class="col-md-1">
            <button class="btn btn-success" v-on:click="search()">搜索</button>
        </div>
</div>
var vm = new Vue({
        el: "#app",
        data: {
            query: "",
            results: [ ]
        },
        methods: {
            search() {
                $.ajax({
                    url:"/java_doc_searcher/search?query=" + this.query,
                    type: "get",
                    context: this,
                    success: function(respData, status) {
                        this.results = respData;
                    }
                })
            },
        }
    })

3)获取到结果后显示到页面上

v-for是Vue提供的对象会循环访问result对象,将result中的Json数据返回。取到数据之后显示到页面上。

{{result.title}}是Vue中的语法,表示把result数据的标题放到页面上。

<div class="row" v-for="result in results">
        <!--用来存放结果-->
        <div class="title"><a v-bind:href="result.clickUrl">{{result.title}}</a></div>
        <div class="desc">{{result.desc}}</div>
        <div class="url">{{result.showUrl}}</div>
</div>

三.项目亮点:

(1)多线程读取文档

java8中共有10460个字符,使用单线程制作索引,制作索引共需要时间15054ms,严重拖慢执行时间;改用多线程读取文档;

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

        // 1. 枚举出所有的文件
        ArrayList<File> files = new ArrayList<>();
        enumFile(INPUT_PATH, files);
        // 2. 循环遍历文件. 此处为了能够通过多线程制作索引, 就直接引入线程池.
        CountDownLatch latch = new CountDownLatch(files.size());
        ExecutorService executorService = Executors.newFixedThreadPool(8);
        for (File f : files) {
            executorService.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println("解析 " + f.getAbsolutePath());
                    parseHTML(f);
                    latch.countDown();
                }
            });
        }
        // await 方法会阻塞, 直到所有的选手都调用 countDown 撞线之后, 才能阻塞结束.
        latch.await();
        // 手动的把线程池里面的线程都干掉!
        executorService.shutdown();
        // 3. 保存索引
        index.save();

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

单线程:

java写磁力搜索引擎 java开发搜索引擎_搜索

 多线程:

java写磁力搜索引擎 java开发搜索引擎_java_02

(2)加锁保证线程安全

通过多线程来实现时,由于这8个线程都需要调用addDoc()方法,addDoc()又需要调用buildForward()和buildInverted(),buildForward()需要修改forwardIndex,buildInverted()需要修改invertedIndex,那么就会引起线程安全问题。如果直接给addDoc()方法加入synchronized关键字,那么锁粒度太高了,就会导致效率并没有多大的提高,就可以在必要的地方加锁;

即在正排索引对forwardIndex操作和倒排索引对invertedIndex操作时需要加锁,又因为这两个对象对forwardIndex操作和invertedIndex操作互不影响,可以创建两个锁对象,分别对两个操作进行加锁,避免一起加锁带来的资源浪费。

// 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);
                }
            }
private DocInfo buildForward(String title, String url, String content) {
        DocInfo docInfo = new DocInfo();
        docInfo.setTitle(title);
        docInfo.setUrl(url);
        docInfo.setContent(content);
        synchronized (locker1) {
            docInfo.setDocId(forwardIndex.size());
            forwardIndex.add(docInfo);
        }
        return docInfo;
    }

(3)多路计算文档权重

搜索"array list",观察搜索结果,不难发现,当遍历分词"array"时,生成一组docId,此时的docId是根据array计算权重;遍历分词"list"时,生成一组docId,此时docId说根据list计算权重,此时就有一个Collections.html文档,既包含array,又包含list分词,那么在统计docId时,Collections.html文档就被展示了两次,搜索结果中包含两个Collections.html文档,这显然时不合理的;

以上这种情况,意味着这个文档对搜索词而言,相关性更高,此时就应该提高这个文档的权重。此时采用将这两个权重相加的方式;

要想实现以上效果,就需要把多个分词结果触发出的文档进行去重,同时进行权重的合并;

两路数组归并:对比指向两个数组元素的值的大小关系,找到小的,插入到结果中;

N路数组归并:对比指向多个数组元素的值的大小关系,找到小的,插入到结果中;

在实际开发中,我采用先使用二维数组统计搜索结果,再使用mergeResult方法,对二维数组中内容进行合并。

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

        public Pos(int row, int col) {
            this.row = row;
            this.col = col;
        }
    }
private List<Weight> mergeResult(List<List<Weight>> source) {
        // 在进行合并的时候, 是把多个行合并成一行了.
        // 合并过程中势必是需要操作这个二维数组(二维List) 里面的每个元素的....
        // 操作元素就涉及到 "行" "列" 这样的概念~~ 要想确定二维数组中的一个元素, 就需要明确知道 行 和 列

        // 1. 先针对每一行进行排序(按照 id 进行升序排序)
        for (List<Weight> curRow : source) {
            curRow.sort(new Comparator<Weight>() {
                @Override
                public int compare(Weight o1, Weight o2) {
                    return o1.getDocId() - o2.getDocId();
                }
            });
        }
        // 2. 借助一个优先队列, 针对这些行进行合并
        //    target 表示合并的结果
        List<Weight> target = new ArrayList<>();
        //  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;
    }

(4)标红显示搜索结果关键字

后端:在result中获取描述时,遍历描述结果,找到对应查询词,给该查询词加入<i>word</i>标签;前端:给i标签加入标红样式。

// 在此处加上一个替换操作. 把描述中的和分词结果相同的部分, 给加上一层 <i> 标签. 就可以通过 replace 的方式来实现.
        for (Term term : terms) {
            String word = term.getName();
            // 注意, 此处加空格表示进行全字匹配. 也就是当查询词为 List 的时候 不能把 ArrayList 中的 List 给单独标红
            //(?i)表示不区分大小写进行替换
            desc = desc.replaceAll("(?i) " + word + " ", "<i> " + word + " </i>");
        }
.item .desc i {
            color: red;
            /* 去掉斜体 */
            font-style: normal;
        }

四.测试用例

java写磁力搜索引擎 java开发搜索引擎_java写磁力搜索引擎_03