Java文档搜索引擎总结

  • 项目介绍
  • 项目使用的技术栈
  • 前端页面展示
  • 后端逻辑部分
  • 索引部分
  • 搜索模块部分
  • Web模块部分


项目介绍

Java文档搜索引擎项目是一个SSM项目,该项目的前端界面部分是由搜索页面和展示页面组成,后端部分索引模块(ScanAnalysis、index)、搜索模块(Searcher)、Web模块(SearcherController)。该项使用ansj第三方分词库进行分词,该项目并没有使用爬虫程序来获取Java文档,而是直接将Java文档下载下来,将Java文档里面的内容进行分词保存到正排索引文件和倒排索引文件中。

项目使用的技术栈

HTML、CSS、JS、Ajax、SpringBoot、SpringMVC

前端页面展示

搜索页面:

Java实现搜索功能的原理 java实现搜索框搜索文本_System


显示页面:

Java实现搜索功能的原理 java实现搜索框搜索文本_开发语言_02

后端逻辑部分

索引部分

索引部分底层实现了两个类:ScanAnalysis类、Index类
***ScanAnalysis类:***用来扫描Java文档中的所有HTML文件,将HTML文件的标题、url路径、正文保存到正排索引文件和倒排索引文件中。
***Index类:***底层实现了正排索引结构和倒排索引结构,Index类是配合ScanAnalysis类一起使用的,Index将HTML文件内容保存到正排索引和倒排索引结构中,最终保存到正排索引文件和倒排索引文件中。

ScanAnalysis类的底层代码:

public class ScanAnalysis {

    //要扫描的根路径
    private static final String PATH_ROOT = "D:\\知识复习思维导图(Java)和Java笔记\\project-warehouse\\jdk-8u351-docs-all\\docs\\api";

    //Java文档的网络地址 不同部分
    private static final String JAVA_PATN = "https://docs.oracle.com/javase/8/docs/api/";

    //索引对象
    private static Index index = new Index();
    /**
     * 启动方法
     * 我们在进行扫描的时候,我们会发现在进行扫描的时候效率是比较低的。
     * 该方法使用的是单线程的方式
     * 我们可以使用多线程的方式来提高效率
     */
    public void run() {
        long ben1 = System.currentTimeMillis();
        //保存每一个文档的路径
        ArrayList<String> arrayList = new ArrayList<>();
        //1.获取每一个文档的路径
        scanPath(PATH_ROOT,arrayList);
        long ben = System.currentTimeMillis();
        //2.对每一个html文件进行解析
        for (String pathChild:arrayList) {
            analysis(pathChild);
        }
        long end = System.currentTimeMillis();
        System.out.println("解析所花费的时间:"+(end - ben)+"ms");
        //3.将索引保存的索引文档中
        index.saveFile();
        long end1 = System.currentTimeMillis();

        System.out.println("整个程序的时间:"+(end1 - ben1) +"ms");
    }

    /**
     * 启动方法2:我们对解析这个步骤使用多线程的方式来提高效率
     *
     */
    public void run2() {
        long ben1 = System.currentTimeMillis();
        //保存每一个文档的路径
        ArrayList<String> arrayList = new ArrayList<>();
        //1.获取每一个文档的路径
        scanPath(PATH_ROOT,arrayList);
        long ben = System.currentTimeMillis();
        //2.对每一个html文件进行解析
        //我们创建一个有时光线程的线程池
        ExecutorService executorService = Executors.newFixedThreadPool(15);
        //这个CountDownLatch对象,是用来表明需要等待多少个任务才结束
        //因为我们要等到解析这个过程完成了在执行下一步
        CountDownLatch countDownLatch = new CountDownLatch(arrayList.size());
        for (String pathChild:arrayList) {
            //将解析的工作提交倒线程池中
            executorService.submit(new Runnable() {
                @Override
                public void run() {
                    analysis(pathChild);
                    //完成一次解析任务就减一
                    countDownLatch.countDown();
                }
            });
        }


        try {
            //等待任务结束,如果没结束,就阻塞等待
            countDownLatch.await();
            //关闭线程池
            executorService.shutdown();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        long end = System.currentTimeMillis();
        System.out.println("解析所花费的时间:"+(end - ben)+"ms");
        //3.将索引保存的索引文档中
        index.saveFile();
        long end1 = System.currentTimeMillis();

        System.out.println("整个程序的时间:"+(end1 - ben1) +"ms");
    }

    /**
     * 对 HTML文件进行解析
     * 获取到题目、正文、url
     * @param pathChild
     */
    private void analysis(String pathChild) {
        File file = new File(pathChild);
        //1.获取标题
        String title = getTitle(file);
//        System.out.println(title);
        //2.获取正文
        String content = getContents(file);
        //3.获取url
        String url = getUrl(file);
        System.out.println(url);
        //4.将标题、正文、url保存到索引中
        index.saveIndex(title,content,url);

    }

    /**
     * 获取url
     * @param file
     * @return
     */
    private String getUrl(File file) {
        StringBuilder stringBuilder = new StringBuilder();
        String str = file.getAbsolutePath().substring(PATH_ROOT.length()+1);
        for (int i = 0; i < str.length(); i++) {
            char ch = str.charAt(i);
            if (ch != '\\') {
                stringBuilder.append(ch);
            } else {
                stringBuilder.append('/');
            }
        }
        return JAVA_PATN+stringBuilder.toString();
    }

    /**
     * 获取正文,这个比较麻烦,我们需要去除标签,和<script></script>里面的内容
     * 这里我们需要使用正则表达式
     * @param file
     * @return
     */
    public String getContents(File file) {
        //获取到HTML里面的内容
        String content = getcontentHtml(file);
        //使用正则表达式,将<script></script>标签和里面的内容都替换掉
        //字符串中的replaceAll方法是支持正则表达式的
        content = content.replaceAll("<script.*?>(.*?)</script>"," ");
        //使用正则表达式,去除其他标签
        content = content.replaceAll("<.*?>"," ");
        //使用正则表达式,去除连续的空格
        content = content.replaceAll("\\s+"," ");
        return content ;
    }

    /**
     * 获取到HTML文件的内容,这人进行文件读取操作,
     * 使用字符流,进行读取
     * @param f
     * @return
     */
    private String getcontentHtml(File f) {

        try(BufferedReader bufferedReader = new BufferedReader(new FileReader(f),1024*1024)) {
            StringBuilder content = new StringBuilder();
            while (true) {
                int ret = bufferedReader.read();
                if (ret == -1) {
                    break;
                }
                char ch = (char) ret;
                //去除换行
                if(ch == '\n' || ch == '\r') {
                    ch = ' ';
                }
                content.append(ch);
            }
            return content.toString();
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }

    /**
     * 获取标题
     * @param file
     * @return
     */
    private String getTitle(File file) {
        return file.getName().replace(".html","");
    }


    /**
     * 扫描根路径,获取该目录下的索引HTML文件的路径
     * 这里要使用的递归 和 文件操作
     * @param pathRoot
     * @param arrayList
     */
    private void scanPath(String pathRoot, ArrayList<String> arrayList) {
        File file = new File(pathRoot);

        //获取到该目录的以及文件对象
        File[] files = file.listFiles();
        //遍历
        for (File file1:files) {
            if (file1.isFile()) {
                //是普通文件
                //我们要的是html文件,所以还要进行处理
                if (file1.getAbsolutePath().endsWith("html")) {
                    arrayList.add(file1.getAbsolutePath());
                    System.out.println(file1.getAbsolutePath());
                }
            } else {
                //是目录,进行递归
                scanPath(file1.getAbsolutePath(),arrayList);
            }
        }
    }

    public static void main(String[] args) {
        ScanAnalysis scanAnalysis = new ScanAnalysis();
        //程序的入口
        scanAnalysis.run2();

    }
}

Index类的底层代码:

public class Index {
    //正排索引的底层,使用顺序表
    public ArrayList<JavaDocModel> arrayList = new ArrayList<>();
    //倒排索引的底层,使用HashMap
    public HashMap<String,ArrayList<Weight>> map = new HashMap<>();

    //创建两个锁
    private Object lock1 = new Object();
    private Object lock2 = new Object();
    //正排索引文件 和倒排索引文件保存的 根目录
    private static final String INDEX_SAVE_PATH =
            "D:\\知识复习思维导图(Java)和Java笔记\\project-warehouse\\jdk-8u351-docs-all\\";
    //线上环境 正排索引文件 和倒排索引文件保存的 根目录
//    private static final String INDEX_SAVE_PATH =
//            "/project/java_doc_searcher_ssm/";
    //进行JSON格式化的 对象
    private ObjectMapper objectMapper = new ObjectMapper();
    /**
     * 1.正排索引:通过文档Id来获取文档对象
     * @param docId
     * @return
     */
    public JavaDocModel getForwardIndex(Integer docId) {
        return arrayList.get(docId);
    }

    /**
     * 2.通过分词来获取相对应的一组文档的id,这里不仅仅获取到了id,还有权重,有利于进行排序
     * @param terim
     * @return
     */
    public ArrayList<Weight> getReverseIndex(String terim) {
        return map.get(terim);
    }

    /**
     * 3.将标题,正文,url
     * 保存到正排索引,和倒排索引中
     */
    public void saveIndex(String title,String content,String url){
        JavaDocModel javaDocModel = new JavaDocModel();
        javaDocModel.setContent(content);
        javaDocModel.setTitle(title);
        javaDocModel.setUrl(url);

        //1.建立正排索引
        buildForwardIndex(javaDocModel);
        //2.建立倒排索引
        buildReverseIndex(javaDocModel);
    }

    /**
     * 建立倒排索引
     * 我们需要对文档的标题,正文 进行分词
     * @param javaDocModel
     */
    private void buildReverseIndex(JavaDocModel javaDocModel) {
        //统计一个分词在标题和内容中出现多少次
        class Count{
            public Integer titleCount;
            public Integer contentCount;
        }
        //1.对文档标题 进行分词
        List<Term> terms = ToAnalysis.parse(javaDocModel.getTitle()).getTerms();
        //用来统计词频
        HashMap<String,Count> hashMap = new HashMap<>();//记录总的分词
        synchronized (lock1) {
            //遍历分词terms
            for (Term term:terms) {
                //获取到分词结果
                String termName = term.getName();

                Count myCount = hashMap.get(termName);
                if (myCount == null) {
                    //没有
                    Count newCount = new Count();
                    newCount.titleCount = 1;
                    newCount.contentCount = 0;
                    hashMap.put(termName,newCount);
                } else {
                    //有,titleCount加一
                    myCount.titleCount += 1;
                }
            }


            //2.对文档对象的正文进行分词
            terms = ToAnalysis.parse(javaDocModel.getContent()).getTerms();
            //遍历分词terms
            for (Term term:terms) {
                //获取到分词结果
                String termName = term.getName();

                Count myCount = hashMap.get(termName);
                if (myCount == null) {
                    //没有
                    Count newCount = new Count();
                    newCount.contentCount = 1;
                    newCount.titleCount = 0;
                    hashMap.put(termName,newCount);
                } else {
                    //有,contentCount加一
                    myCount.contentCount += 1;
                }
            }

            //3.将hashMap 里的数据整合到 map 里面
            //遍历hashMap

            for (Map.Entry<String,Count> entry:hashMap.entrySet()) {
                String key = entry.getKey();
                Count val = entry.getValue();
                //从倒排索引中获取value值
                ArrayList<Weight> weights = map.get(key);

                if (weights == null) {
                    //没有,创建新的
                    ArrayList<Weight> newWeights = new ArrayList<>();
                    Weight weight = new Weight();
                    //设置文档Id
                    weight.setDocId(javaDocModel.getDocId());
                    //设置权重,titleCount*20+contentCount
                    weight.setWeight(val.contentCount + val.titleCount*20);
                    newWeights.add(weight);
                    map.put(key,newWeights);
                } else {
                    //有的话,直接添加
                    Weight weight = new Weight();
                    //设置文档Id
                    weight.setDocId(javaDocModel.getDocId());
                    //设置权重,titleCount*20+contentCount
                    weight.setWeight(val.contentCount + val.titleCount*20);
                    weights.add(weight);
                }
            }
        }

    }

    /**
     * 建立正排索引,以顺序表的下标作为文档ID
     * 直接插入顺序表就行
     * @param javaDocModel
     */
    private void buildForwardIndex(JavaDocModel javaDocModel) {

        synchronized (lock2) {
            //插入docId
            javaDocModel.setDocId(arrayList.size());
            //直接插入顺序表尾部
            arrayList.add(javaDocModel);
        }

    }

    /**
     * 4.将正排索引结构  和 倒排索引结构 保存到 正排索引文件 和倒排索引文件中
     * 序列化的方法:以JSON的格式保存
     */
    public void saveFile() {
        //正排索引 和 倒排索引保存的目录
        File filePath = new File(INDEX_SAVE_PATH);
        if (!filePath.exists()) {
            //创建目录
            filePath.mkdirs();
        }
        //正排索引文件对象
        File fileForwardIndex = new File(INDEX_SAVE_PATH+"forward.txt");
        //倒排索引文件对象
        File fileReverseIndex = new File(INDEX_SAVE_PATH+"reverse.txt");

        if (!fileForwardIndex.exists()) {
            //不存在,创建正排索引文件
            try {
                fileForwardIndex.createNewFile();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

        if (!fileReverseIndex.exists()) {
            //不存在,创建倒排索引文件
            try {
                fileReverseIndex.createNewFile();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

        try {
            //将正排索引结构转成JSON格式,保存到正排索引文件中
            objectMapper.writeValue(fileForwardIndex,arrayList);
            //将倒排索引结构转成JSON格式,保存到倒排索引文件中
            objectMapper.writeValue(fileReverseIndex,map);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    /**
     * 5.加载正排 和 倒排 文件 ,将内容加载倒内存中
     * 反序列
     */
    public void load() {
        long ben = System.currentTimeMillis();
        //正排索引文件对象
        File fileForwardIndex = new File(INDEX_SAVE_PATH+"forward.txt");
        //倒排索引文件对象
        File fileReverseIndex = new File(INDEX_SAVE_PATH+"reverse.txt");

        try {
            //这里的 readValue方法用法要注意
            // 第二个参数是一个匿名内部类,实现了TypeReference,目的就是 我们想要把JSON格式的字符串转成什么类型 告诉了 readValue方法
            //正排
            arrayList = objectMapper.readValue(fileForwardIndex, new TypeReference<ArrayList<JavaDocModel>>() {
            });
            //倒排
            map = objectMapper.readValue(fileReverseIndex, new TypeReference<HashMap<String,ArrayList<Weight>>>() {
            });
        } catch (IOException e) {
            e.printStackTrace();
        }

        long end = System.currentTimeMillis();
        System.out.println("加载文档的时间:"+(end - ben) +"ms");
    }
}

搜索模块部分

搜索模块部分底层实现了Searcher类,提供了searcher方法来搜索相关的文档。
Searcher类的底层代码:

public class Searcher {
    //索引类
    private Index index = new Index();
    //保存停用词表的数据结构
    private Set<String> stopWordsSet = new HashSet<>();
    //停用词表的存放路径
    private static final String STOP_WORDS =
            "D:\\知识复习思维导图(Java)和Java笔记\\project-warehouse\\jdk-8u351-docs-all\\stop_words.txt";
    //线上环境 停用词表的存放路径
//    private static final String STOP_WORDS =
//            "/project/java_doc_searcher_ssm/stop_words.txt";
    public Searcher() {
        //1.创建该类的时候,加载一些索引文档
        index.load();
        //2.创建该类的时候,加载停用词表
        loadStopWords();

    }

    /**
     * 加载停用词表
     */
    private void loadStopWords() {
        long ben = System.currentTimeMillis();
        //进行读操作
        try(BufferedReader bufferedReader = new BufferedReader(new FileReader(STOP_WORDS)) ){

            while (true) {
                String str = bufferedReader.readLine();
                if (str == null) {
                    break;
                }
                stopWordsSet.add(str);
            }

        } catch (IOException e) {
            e.printStackTrace();
        }
        long end = System.currentTimeMillis();
        System.out.println("加载停用词表的时间:"+(end - ben) + "ms");
    }


    public List<ResultReturnModenl> searcher(String word) {
        //将查询词进行分词、
        List<Term> terms = ToAnalysis.parse(word).getTerms();
        //我们通过分词结果可以得出,有些分词是不合理的
        //我们要排除一些不合理的分词结果
        //这里我们使用停用词表进行过滤
        List<Term> newTerms = new ArrayList<>();//保存过滤后的term
        for (Term term:terms) {
            //分词内容
            String wordName = term.getName();
            if (!stopWordsSet.contains(wordName)) {
                //不是停用词
                newTerms.add(term);
            }
        }

        //遍历newTerms,获取要返回的数据
        List<ArrayList<Weight>> listList = new ArrayList<>();
        for (Term term:newTerms) {
            //获取倒分词的内容
            String wordName = term.getName();
            //通过倒排索引,来获取倒相对应的文档对象
            ArrayList<Weight> reverseIndex = index.getReverseIndex(wordName);
            //判断是否拿到
            if (reverseIndex == null) {
                //没有拿到
                continue;
            }
            //将reverseIndex保存到 listList中
            listList.add(reverseIndex);
        }
        //合并listList中的数组,并且进行去重
        //类似于合并多个有序数组,并且最后的结果要有序
        List<Weight> list = sortArray(listList);

        //对list进行排序,按照权重的大小由高到低排序
        Collections.sort(list, new Comparator<Weight>() {
            @Override
            public int compare(Weight o1, Weight o2) {
                //降序
                return o2.getWeight() - o1.getWeight();
            }
        });
        //保存返回的数据
        List<ResultReturnModenl> results = new ArrayList<>();
        //将数据进行封装
        for (Weight weight:list) {
            //通过正排索引找到文档对象
            JavaDocModel forwardIndex = index.getForwardIndex(weight.getDocId());

            ResultReturnModenl resultReturnModenl = new ResultReturnModenl();
            //设置标题
            resultReturnModenl.setTitle(forwardIndex.getTitle());
            //设置url
            resultReturnModenl.setUrl(forwardIndex.getUrl());
            //设置摘要
            resultReturnModenl.setDesc(getDesc(forwardIndex.getContent(),newTerms));

            results.add(resultReturnModenl);
        }
        return results;
    }

    /**
     生成正文摘要
     *    由于docInfo对象里面是正文,所以还要做一些处理
     *    摘要要包含 查询词 或者 查询词的一部分
     *    生成摘要的思路:可以遍历查询词的分词,找到对应位置
     *   就针对这个位置,往前截取60个字符,作为描述的开始,然后从描述开始在截取160个字符
     * @param content
     * @param newTerms
     * @return
     */
    public String getDesc(String content, List<Term> terms) {
        //记录分词出现的位置
        int termIndex = -1;
        for (Term term:terms) {
            //获取到分词内容
            String wordName = term.getName();
            //将正文转成小写 使用toLowerCase()
            //此处需要的是全词匹配,在word前后都加一个空 在进行查找
            //这里的匹配不严谨,更严谨的方法是使用 正则表达式
            //indexOf不支持正则表达式
            //Java提供了 Pattern 和 Matcher 这两个类 来实现正则表达式,自己学习一下
            //Pattern : 描述一个匹配规则
            //Matcher 负责进行具体的匹配工作
            //这里的做法:把不是空格的转成空格
            content = content.toLowerCase().replaceAll("\\b"+wordName+"\\b"," " + wordName + " ");
            termIndex = content.toLowerCase().indexOf(" "+wordName+" ");

            if (termIndex != -1 ) {
                //存在
                break;
            }
        }

        if (termIndex == -1) {
            //所有的分词结果都不存在
            //返回正文的前160个字符
            if (content.length() <=160) {
                return content;
            }
            return content.substring(0,160)+"...";
        }
        //程序如果到这里,说明正文中有分词结果
        //判断是否要往前60个字符
        termIndex = termIndex - 60 >=0?termIndex-60:0;
        String desc = "";//保存正文摘要
        if (termIndex+160 >= content.length()) {
            //从termIndex这个位置截到尾
            desc = content.substring(termIndex);
        } else {
            desc = content.substring(termIndex,160+termIndex)+"...";
        }
        //在此处加上替换操作,把描述中的 和 分词结果相同的部分,
        //加上依次<i>标签,可以使用 replaceAll 的方法来实现
        //者样在前端显示的时候,可以标红
        //遍历分词结果
        for (Term term:terms) {
            //获取到结果
            String word = term.getName();
            //注意此处要进行全字匹配,不区分大小写替换
            desc = desc.replaceAll("(?i) "+word +" ","<i> "+word+" </i>");
        }
        return desc;
    }

    /**
     * 合并listList中的数组,并且进行去重
     * 类似于合并多个有序数组,并且最后的结果要有序
     * @param listList
     * @return
     */
    private List<Weight> sortArray(List<ArrayList<Weight>> listList) {
        class Pos{
            public Integer row = 0;//行
            public Integer col = 0;//列

            public Pos(Integer row, Integer col) {
                this.row = row;
                this.col = col;
            }
        }
        //使用优先级队列,来解决该问题
        //创建优先级队列
        PriorityQueue<Pos> pos = new PriorityQueue<>(new Comparator<Pos>() {
            @Override
            public int compare(Pos o1, Pos o2) {
                //小根堆
                return listList.get(o1.row).get(o1.col).getDocId() - listList.get(o2.row).get(o2.col).getDocId();
            }
        });
        //将每一个数组,按docId的大小,升序排序
        for (ArrayList<Weight> weights:listList) {
            Collections.sort(weights, new Comparator<Weight>() {
                @Override
                public int compare(Weight o1, Weight o2) {
                    return o1.getDocId() - o2.getDocId();
                }
            });
        }
        //将每一个数组的第一个元素的位置放进来
        for (int i = 0; i < listList.size(); i++) {
            pos.offer(new Pos(i,0));
        }
        List<Weight> listResult = new ArrayList<>();//保存最后返回的结果
        while (!pos.isEmpty()) {
            //从优先级队列出来的队首元素
            Pos pos1 = pos.poll();
            if (listResult.size() == 0) {
                //插入第一个元素
                listResult.add(listList.get(pos1.row).get(pos1.col));
            } else {
                //不是第一个,要判断是否于前一个相同,相同权重相加
               if (listResult.get(listResult.size() - 1).getDocId() == listList.get(pos1.row).get(pos1.col).getDocId()) {
                   //文档相同,权重相加
                   listResult.get(listResult.size() - 1).setWeight(listResult.get(listResult.size() - 1).getWeight()+listList.get(pos1.row).get(pos1.col).getWeight());
               } else {
                   //不相同,添加到listResult中
                   listResult.add(listList.get(pos1.row).get(pos1.col));
               }
            }

            if (pos1.col + 1 >= listList.get(pos1.row).size()) {
                //这一行处理完了
                continue;
            }
            pos.offer(new Pos(pos1.row, pos1.col+1));
        }
        return listResult;
    }

    public static void main(String[] args) {
        Searcher searcher = new Searcher();

    }
}

Web模块部分

Web模块部分实现前后端的交互。
Web模块的代码:

@RestController
public class SearcherController {

    @Autowired
    Searcher searcher ;
    @RequestMapping("/searcher")
    public Object searcher(String word) {
        if (word == null || word.trim().equals("")) {
            return -1;
        }
        return searcher.searcher(word);
    }
    @RequestMapping("/getword")
    public String getWord(String word) {
        System.out.println(word);
        return word;
    }
}