Java项目——文档搜索引擎

1. 项目概述

实现一个较为简单的搜索引擎,在拥有较多网页的基础上,在用户输入查询词之后,能够从这些网页中尽可能地匹配出用户想要的网页

当然,不同于百度搜狗这种搜索引擎,它们能够对互联网中大量的网站都进行搜索,我们这里实现的是针对「Java 文档」的搜索引擎,就像下图,能对 Java 帮助文档 的 API 针对关键词进行文档的搜索
在这里插入图片描述

2. 准备阶段

2.1 项目创建

了解了项目的大概之后,就可以开始一点一点制作了,首先进行 Spring 项目的创建

在这里插入图片描述在这里插入图片描述至此,项目的创建就完成了,为了简化目录,可以将新创建中的这四个文件进行删除
在这里插入图片描述

2.2 准备静态页面

既然要搜索页面,那肯定得先有页面才能搜索,这里建议直接去官网中下载
网址:Java 文档下载

然后点击下载即可
在这里插入图片描述随后,将安装包解压,放到自己指定的目录,这里我就放在项目所在目录(路径上尽量不要有中文)

在这里插入图片描述
至此,准备阶段就完成了

3. 搜索逻辑

在真正编写代码之前,先了解一下搜索的逻辑。

首先我们需要预处理所有的静态页面,获取文档标题(这里文档可以理解成一个静态页面),url,正文等信息,然后包装成一个Document对象。并且还需要通过两个索引来组织这些对象——正排索引和倒排索引,同时记录「权重或者说是相关性」,便于将搜索结果进行整合并排序

  • 正排索引:根据文档 ID 能够得到相应的 文档,显然这个结构可以让人想起了 哈希表,但是 ArrayList 更适合,下标和元素相对应
  • 倒排索引:根据某个词,可以得到相关联的 List<文档ID>

在用户搜索的时候,我们会获取搜索的语句(这里称为 query),然后对 query 进行分词得到分词结果,然后遍历分词结果,得到「相关联的文档」整理后返回给前端展示

如下,和普通的搜索引擎一样,展示的部分主要有标题,url,摘要(其实也就是正文的截取);
并且点击标题能够跳转到相应的页面
在这里插入图片描述

4. 分词

接下来就是代码部分:
为了分词,这里可以在仓库—Ansj链接中,点击第一个,选择最高版本的导入 Spring 中即可

然后可以使用ToAnalysis.parse(字符串).getTerms() 获取到根据该字符串分词得到的 List<Term> 对象,而 Term 就是一个分词结果对象,里面有不少属性,其中又可以通过getName() 来获取这个分词的名字,例如
在这里插入图片描述

5. 处理 HTML 文件

然后是预处理API中所有的 HTML 文件,然后将这些文件构造成 Document 对象并加入到正排索引和倒排索引中

5.1 枚举文件夹中所有文件

⭐创建一个 Parser 类,负责进行索引的加载,在此之前还要完成 HTML 文件的预处理
🍓并且在 Parser 类中定义一个方法run(),索引的加载都在这个方法中完成

(1)首先定义一个字符串常量指定目标文件夹的路径(也就是刚刚解压缩后文件夹中的 api 文件路径)
在这里插入图片描述

private static final String ROOT_PATH = "D:\\MyJavaCode\\documentSearcher\\jdk-8u361-docs-all\\docs\\api\\";

(2)然后通过递归得到里面的所有 HTML 文件
Ⅰ. 通过 File[] files = 文件对象.listFiles() 可以得到当前文件中所有文件
Ⅱ. 通过 文件对象.isDirectory() 判断当前文件是否是文件夹,如果是,则继续递归
Ⅲ. 通过 文件对象.getName() 可以获得文件名,通过文件名.endsWith(".html") 可以判断这个文件名是否以 ".html"结尾,如果是,那么就是 HTML 文件了

所以开始编写 Parser 类中的代码,如下

public class Parser {
    private static final String ROOT_PATH = "D:\\MyJavaCode\\documentSearcher\\jdk-8u361-docs-all\\docs\\api\\";

    public void run() {
        // 获取 api 这个文件对象
        File root = new File(ROOT_PATH);
        // 用来接收 api 文件夹中的所有 HTML 文件, 是一个输入型参数
        List<File> allFiles = new ArrayList<>();
        enumFile(root, allFiles);
        // 未完...
    }

    /**
     * 枚举当前文件中的所有 HTML 文件
     * @param allFiles 输入型参数, 记录文件夹中的所有文件
     */
    private void enumFile(File file, List<File> allFiles) {
        // 列出当前文件的所有文件 / 文件夹
        File[] files = file.listFiles();
        for (File curFile : files) {
            if (curFile.isDirectory()) {  // 如果是文件夹
                enumFile(curFile, allFiles);
            } else if (curFile.getName().endsWith(".html")) { // 是 HTML 文件
                allFiles.add(curFile);
            }
        }
    }
}

可以测试一下这个 allFiles,执行情况如下

	enumFile(root, allFiles);
    for (File file : allFiles) {
        System.out.println(file.getAbsolutePath());
    }
    System.out.println("总共 " + allFiles.size() + " 个文件");

在这里插入图片描述

5.2 预处理文件

获取完所有的 HTML 文件之后,就可以进行对这些文件进行预处理了,这一步目的是:获取 HTML 文档中的标题,url,正文
(1)遍历allFiles中的文件,定义一个方法 parseHtml()来“加工”这些文件
然后在parseHtml()中定义三个方法parseTitle(), parseUrl(), parseContent()来分别获取标题,url,正文

5.2.1 获取标题

(2)parseTitle() 获取标题,如下,在这些 Java 文档中,我们可以简单地将文件名视为标题,但是还需要特殊处理——将 .html 去掉

在这里插入图片描述

	private String parseTitle(File file) {
        String rm = ".html";
        return file.getName().substring(0, file.getName().length() - rm.length());
    }

5.2.2 获取 URL

(3)parseUrl() 获取 url,在本地中,我们存储了这些静态页面,故而有它们的位置,但是如果用户点击搜索结果,那么跳转到的是线上的网址,所以我们需要获取这些线上的网址

如下分别是同一个文档,但是分别是线上和本地的路径

  • 线上:https://docs.oracle.com/javase/8/docs/api/java/util/Arrays.html
  • 本地:D:\MyJavaCode\documentSearcher\jdk-8u361-docs-all\docs\api\java\util\Arrays.html

可以发现,它们的后缀(从 docs 文件夹开始)都是一样的,所以我们可以根据一段固定的前缀 + 本地文档路径的固定后缀来「拼接」得到 url

这制作的是 api 的文档,所以本地的路径可以选用 api\ 之后的路径作为后缀,也就是👇(而白色字体路径就是上文的 ROOT_PATH)
在这里插入图片描述然后再拼接上这段前缀👇
在这里插入图片描述简单来说就是:线上文档的前缀 「拼接」本地文档的后缀,就可以得到文档对应的 url

    private String parseUrl(File file) {
        // 线上文档的前缀
        String prefix = "https://docs.oracle.com/javase/8/docs/api/";
        // 本地文档的后缀
        String suffix = file.getAbsolutePath().substring(ROOT_PATH.length());
        return prefix + suffix;
    }

随后生成代码测试一下,传入 Arrays.html 这个文件,如下可以看出,url 顺利生成,并且亲测可以访问

在这里插入图片描述

5.2.3 获取正文

(4)parseContent() 获取正文
由于文件都是 HTML 格式的文件,所以自然也就各种各样的标签,比如 html 和 js 中的标签和内容,想要去掉这些,可以使用replaceAll搭配正则表达式完成这个工作,在 replaceAll 之前,需要先将文章内容转化成字符串

Ⅰ. 由于读取的是 HTML 文件,所以这里需要使用字符流进行读取,可以使用 FileReader ,但是这里可以有更好的选择——BufferedReader,它相比 FileReader 有一个内置的缓冲区,理论上来说更能够减少 IO 次数,它的使用方法大致和 fileReader一样,但是创建的时候需要包装一个 fileReader,也就是:BufferedReader reader = new BufferedReader(new FileReader(文件对象));除此之外,它的构造方法还有第二个参数,可以指定缓冲区的大小,这里我设置为 一兆(1024 * 1024)

Ⅱ. 将文件中的所有数据转成字符串,这个过程中顺便将换行替换成空格,因为换行符在这里没什么意义,并且我们不希望在前端显示「摘要」的时候出现换行

    /**
     * 将文件的全部内容都读取成 字符串
     * @return
     */
    private String readFile(File file) {
        try (BufferedReader bufferedReader = new BufferedReader(new FileReader(file), BUFFERED_CAPACITY)) {
            StringBuilder builder = new StringBuilder();
            while (true) {
                int ret = bufferedReader.read();
                if (ret == -1) {
                    return builder.toString();
                }
                char ch = (char) ret;
                if (ch == '\r' || ch == '\n') { // 如果是换行符则用空格替换
                    ch = ' ';
                }
                builder.append(ch);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        return "";
    }

Ⅲ. 去除 js 中的内容,以及 HTML 中的标签都替换成空格
使用replaceAll() 和正则表达式进行替换,涉及到正则表达式的使用,这里不多介绍。最后,再将文本中连续的空格都合并成一个空格,就成功完成了文件中正文的提取

    public String parseContentByRegex(File file) {
        // 使用正则表达式,需要先将文章内容转化成字符串
        String content = readFile(file);
        // replaceAll 返回的是 一个新的 String 对象
        content = content.replaceAll("<script.*?>(.*?)</script>", " "); // 先去除 js 中的文本
        content = content.replaceAll("<.*?>", " "); // 去除 标签对
        content = content.replaceAll("\\s+", " ");  // 合并多余的空格
        return content;
    }

至此,一个文档的标题,url,正文就能提取出来了,然后再将这些包装成一个「文档对象(Document)」,再加入到索引中
所以parseHtml的代码就差一步

	private void parseHtml(File file) {
        String title = parseTitle(file);
        String url = parseUrl(file);
        String content = parseContent(file);
        // 将这三个变量包装成Document,并添加到索引结构中
        // todo: 这个对象添加到索引结构中
    }

6. 索引

上面是 Parser 类,负责 HTML 的预处理,然后将它们加入到索引中,完成索引的构建,但是刚刚还差一步:将对象添加到索引中。
而这时候就需要一个类 Index ,来专门维护索引,并提供一些操作索引的 API

6.1 正排索引和倒排索引

以下是我们前面提到的两种索引的概念,但是实际上还要做出一个小修改

  • 正排索引:根据文档 ID 能够得到相应的 文档,显然这个结构可以让人想起了 哈希表,但是 ArrayList 更适合,下标和元素相对应
  • 倒排索引:根据某个词,可以得到相关联的 List<文档ID>,显然可以使用哈希表

由于是根据搜索的分词结果来筛选文章的,例如前端搜索 Arrays,那么后端就应该整理出 Arrays 相关的文档列表,但是仅仅如此嘛?当然不是,我们还要进行「相关性排序,降序」,所以在倒排索引中的 value 值,不能只是 List<文档ID>List中的元素除了存储文档 ID ,还需要存储该文档的「权值」。
⭐因此,这里需要是List<Weight>,而 Weight其中有两个属性:①文档ID,②该文档的权值

故而就可以确定正排索引和倒排索引的数据结构了

正排索引:ArrayList<Doucment> ,Document 对象包装了文件的 ID,正文,url,标题
倒排索引:HashMap<String, List<Weight>>,String 是分词,Weight 对象包装了 文档ID 和 文档权值

以下分别是 Document 和 Weight 类

@Data  // lombok 中提供的注解,提供 toString, get 和 set 等常用方法
public class Document {
    private int documentId;
    private String title;
    private String url;
    private String content;

    public Document() {};

    public Document(String title, String url, String content) {
        this.title = title;
        this.url = url;
        this.content = content;
    }
}

@Data
public class Weight {
    private int documentId;
    private int weight;     // 文档权重

    public Weight() {}

    public Weight(int documentId) {
        this.documentId = documentId;
    }

    // 不过这里还需要一个方法,用来后续计算权重,这里先不做实现
    public int calWeight() {
        return 0;
    }
}

然后是正排索引和倒排索引的创建

    // 正排索引:文档 ID -> 文档,文档 ID 就是 ArrayList 中的下标
    ArrayList<Document> forwardIndex = new ArrayList<>(); 

    // 倒排索引:词 -> 文档 ID 结合, 考虑到根据词所找到的 文档集合 应该具备优先级,所以此处存储的除了 Id 还 应该有优先值
    HashMap<String, ArrayList<Weight>> invertedIndex = new HashMap<>();

6.2 往正排索引中添加元素

这个实现起来比较简单,参数是「待加入」的文档,这里还需要设置这个文档的 ID,而文档 ID 就是加入 forwardIndex 之后的下标,直接上代码

    // 构建正排索引
    public void buildForward(Document document) {
    	// 待加入文档的ID
        document.setDocumentId(forwardIndex.size());
        forwardIndex.add(document);
    }

6.3 往倒排索引中添加元素

6.3.1 大致思路

构建倒排索引:这里实现起来会比较复杂
⭐由于倒排索引是:某个关键词 —→ 相关的文档列表。
那么我们在往invertedIndex添加一个文档的时候,我们需要得到出这篇文档中所有的关键词,然后将这些在倒排索引中获取这些关键词的 List<Weight> 列表,往列表中添加当前文档

如下,在不考虑权重的情况下,假设文档5的分词结果有arraypig,现在要将文档5插入invertedIndex中,对于 pig 这个词,invertedIndex中本来就有这个 key 值,所以将这个词关联的List再加上文档5即可,而对于arrayinvertedIndex中没有这个 key 值,所以就需要新建一个键值对,然后在 array 相关联的 List列表中加上文档 5
在这里插入图片描述

6.3.2 计算权重(相关性)

以上就是添加到倒排索引的简单思路,但是这里还需要考虑到权重的问题,如何去定义「相关性高低」,这里涉及到的学问很多,我们不过多深究,这里简单的认为⭐「某个关键词出现的次数越多,相关性越强,并且 权重 = 关键词在标题中出现的次数 * 系数 + 关键词在正文中出现的次数」

所以我们上文中提到的Weight类中的 calWeight() 计算权重的方法就可以编写了,具体的系数是多少,看大家心情,这里就设置为 10,👇

	/**
     * 计算权重,并赋值给 weight 属性
     * @param titleCnt   关键词在标题中出现的次数
     * @param contentCnt    关键词在正文中出现的次数
     */
    public void calWeight(int titleCnt, int contentCnt) {
        int w =  titleCnt * 10 + contentCnt; // 计算权重
        this.setWeight(w); // 赋值
    }

6.3.3 实现

整体思路:统计这个文档中所有关键词在标题和在正文中出现的次数,方便统计这个关键词在本文中的权重,然后再将这些关键词整合到倒排索引中
(1)创建一个 Counter 类,负责记录关键词在标题和正文中出现的次数
(2)创建 HashMap<String, Counter> 对象,将关键词和出现次数相对应
(3)开始统计
(4)遍历该文档中的所有关键词,并整合到倒排索引中

代码如下

    // 构建倒排索引
    public void buildInverted(Document document) {
        class Counter {
            int titleCnt;
            int contentCnt;
            public Counter(int titleCnt, int contentCnt) {
                this.titleCnt = titleCnt;
                this.contentCnt = contentCnt;
            }
        }

        // 1. 记录关键词 出现的 次数
        HashMap<String, Counter> counterMap = new HashMap<>();

        // 2. 对标题进行分词,统计出现次数
        List<Term> terms = ToAnalysis.parse(document.getTitle()).getTerms();
        for (Term term : terms) {
            // 获取分词字符串
            String word = term.getName();
            Counter counter = counterMap.get(word);
            // 如果为空,说明还没有出现过这个关键词
            if (counter == null) {
                counter = new Counter(1, 0); // 标题出现次数赋值为 1
                counterMap.put(word, counter);
            } else { // 出现过这个分词
                counter.titleCnt ++;
            }
        }

        // 3. 对正文进行分词,统计出现次数
        terms = ToAnalysis.parse(document.getContent()).getTerms();
        for (Term term : terms) {
            String word = term.getName();
            Counter counter = counterMap.get(word);
            if (counter == null) {
                counter = new Counter(0, 1);
                counterMap.put(word, counter);
            } else {
                counter.contentCnt ++;
            }
        }

        // 4. 将分词结果整合到倒排索引中
        //    遍历文档的所有分词结果
        for (Map.Entry<String, Counter> entry : counterMap.entrySet()) {
            String word = entry.getKey();
            Counter counter = entry.getValue();

            // 将文档 ID 和 文档权值 包装起来
            Weight newWeight = new Weight(document.getDocumentId()); // 存入文档 ID
            newWeight.calWeight(counter.titleCnt, counter.contentCnt);

            // 取出该关键词相关联的 文档列表
            List<Weight> take = invertedIndex.get(word);
            // 倒排索引 中没有这个关键词
            if (take == null) {
                ArrayList<Weight> newList = new ArrayList<>();  // 新建列表
                newList.add(newWeight);
                invertedIndex.put(word, newList);
            } else { // 出现过
                take.add(newWeight); // 关联文档数增加
            }
        }
    }

6.4 往索引中添加元素

上述就是正排索引和倒排索引的添加逻辑了,我们可以使用一个方法合并一下:

    // 往索引中添加文档
    public void add(String title, String url, String content) {
        Document document = new Document(title, url, content);
        // 自此,Document还没设置ID,ID进入 buildForward() 会设置
        buildForward(document);
        buildInverted(document);
    }

6.5 补充 parseHtml() 方法

现在索引类也完成一半多了,接下来将刚刚在Parser类中实现到一半的 parseHtml() 方法补充完整
(1)由于这个类要操作索引,所以需要在类中实例化索引这个对象
(2)将 parseHtml() 方法补充完整,如下

    private void parseHtml(File file) {
        String title = parseTitle(file);
        String url = parseUrl(file);
        String content = parseContent(file);
        // 将这三个变量包装成Document,并添加到索引结构中
        index.add(title, url, content);
    }

6.6 获取文档

这里就很容易实现了,基于正排索引和倒排索引,可以实现:
Ⅰ. 根据文档 ID 获取文档

	// 传入文档 Id,获取文档对象
    public Document getDocument(int documentId) {
        return forwardIndex.get(documentId);
    }

Ⅱ. 传入关键词,获取和这关键词相关的文档列表

    // 获取和 关键词 相关的文档
    public List<Weight> getRelatedDocument(String word) {
        return invertedIndex.get(word);
    }

6.7 测试

上面我们就已经实现了:预处理所有 HTML 文档,并将它们整合到索引之中
我们可以测试一下,在 Parser 类中的 run() 方法中,解析完 HTML 文件之后,在倒排索引中获取和"array" 相关的文章,然后看一下有哪些

    public void run() {
        // 1. 获取 api 这个文件对象
        File root = new File(ROOT_PATH);
        // 2. 用来接收 api 文件夹中的所有 HTML 文件, 是一个输入型参数
        List<File> allFiles = new ArrayList<>();
        enumFile(root, allFiles);
        System.out.println("总共 " + allFiles.size() + " 个文件");

        // 3. 解析这里面的所有文件,然后包装 Document 对象,添加到索引中
        for (File file : allFiles) {
            System.out.println("开始解析文件:" + file.getAbsolutePath());
            parseHtml(file);
        }

        // 测试代码
        List<Weight> tests = index.getRelatedDocument("array");
        for (Weight test : tests) {
            // 获取文档,然后打印 文档 的 url
            System.out.println(index.getDocument(test.getDocumentId()).getUrl());
        }
        System.out.println("总共有 " + tests.size() + " 个相关文档");
    }

打印结果如下,检验了几个 url,没有发现什么问题,后面再进行进一步的检测

在这里插入图片描述

6.8 持久化保存索引结构

上面虽然完成了索引的构建,但是只是在内存中,重启就需要重新构建。
在进行索引的构建的时候,特别是在静态页面数量庞大的时候,索引的构造可能会花费相当的时间,所以可以考虑将索引进行持久化保存,这样机器重启的时候即便丢失内存数据,也能够花更少的时间来从文件中读取索引到内存中

(1)首先指定正排索引和倒排索引存放在哪个文件

    // 索引存放目录
    private static final String INDEX_PATH = "D:\\MyJavaCode\\documentSearcher\\jdk-8u361-docs-all\\";

    // 正排索引和倒排索引的文件名,任意一个和 INDEX_PATH 拼接就可以得到完整路径
    private static final String FORWARD_PATH = "forward.txt";
    private static final String INVERTED_PATH = "inverted.txt";

(2)先检验 上代码中的INDEX_PATH这个文件夹是否存在,如果不存在,使用mkdirs() 方法创建这个目录
(3)需要实例化一个 JackSon 包中的 ObjectMapper 对象,来进行对象的写入和读取

在这里插入图片描述
(4)使用objectMapper.writeValue(File f, Object value),能够将指定内容写入到指定文件之中

    // 持久化存储索引
    public void save() {
        System.out.println("开始持久化索引");
        long beg = System.currentTimeMillis();
        File indexFile = new File(INDEX_PATH);
        if (!indexFile.exists()) { // 如果不存在
            indexFile.mkdirs();
        }
        // 打开这两个文件
        File forwardFile = new File(INDEX_PATH + FORWARD_PATH);
        File invertedFile = new File(INDEX_PATH + INVERTED_PATH);

        try {
            objectMapper.writeValue(forwardFile, forwardIndex);
            objectMapper.writeValue(invertedFile, invertedIndex);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }

        long end = System.currentTimeMillis();
        System.out.println("索引持久化完成,总共耗时 " + (end - beg) + " ms");
    }

写完持久化之后,我们就可以在 Parser 类中的 run() 方法的最后中添加 save() 方法了,添加之后,run() 方法的使命就算是完成了。以下是 run() 方法的完整代码

    public void run() {
        long beg = System.currentTimeMillis();
        // 1. 获取 api 这个文件对象
        File root = new File(ROOT_PATH);
        // 2. 用来接收 api 文件夹中的所有 HTML 文件, 是一个输入型参数
        List<File> allFiles = new ArrayList<>();
        enumFile(root, allFiles);
        System.out.println("总共 " + allFiles.size() + " 个文件");

        // 3. 解析这里面的所有文件,然后包装 Document 对象,添加到索引中
        for (File file : allFiles) {
            System.out.println("开始解析文件:" + file.getAbsolutePath());
            parseHtml(file);
        }

        index.save();
        long end = System.currentTimeMillis();
        System.out.println("索引制作完成,总共耗时 " + (end - beg) + " ms");
    }

在这里插入图片描述
以上👆就是索引的存储位置了,使用 vscode 打开,就是一串很长很长的玩意👇
在这里插入图片描述不过这还没法验证,所以我们再编写 「读取索引到内存中」的代码

6.9 将索引结构从文件中加载到内存中

(1)创建正排索引和倒排索引的文件对象
(2)使用object.readValue(File src, Class<T> valueType)来将原文件中的数据以 「第二个参数的形式」读取出来,并返回。这里 jackson 是将这个结构的字符串重新转换成一个对象,所以需要指定这个对象的类型,但是Java中,不允许以对象的类型作为参数,因此,JSON还提供了一个工具类TypeReference<>
⭐简而言之,使用的时候,第二个参数传:new TypeReference<参数类型>() {}即可

所以加载索引到内存中,代码如下:

    // 将索引读取到内存中
    public void load() {
        File forwardFile = new File(INDEX_PATH + FORWARD_PATH);
        File invertedFile = new File(INDEX_PATH + INVERTED_PATH);

        try {
            forwardIndex = objectMapper.readValue(forwardFile, new TypeReference<ArrayList<Document>>() {});
            invertedIndex = objectMapper.readValue(invertedFile, new TypeReference<HashMap<String, List<Weight>>>() {});
        } catch (IOException e) {
            throw new RuntimeException(e);
        }

    }

自此,我们再测试一下当 load() 方法执行完的时候,根据"array"来搜索倒排索引,能得到多少个结果

    public static void main(String[] args) {
        Parser parser = new Parser();
        Index index = parser.index;
        index.load(); // 加载
        List<Weight> tests = index.getRelatedDocument("array");
        for (Weight test : tests) {
            System.out.println(index.getDocument(test.getDocumentId()).getUrl());
        }
        System.out.println("总共 " + tests.size() + " 条数据");
    }

执行情况如下
在这里插入图片描述

7. 多线程优化解析速度

至此Parser Index 类算是基本完成了,但是实际上Parser类在构建索引的时候是很费时的,在我的电脑上,一整个 run() 方法跑完耗时如下,11443ms,大概 11 秒,如果是电脑长时间没有构建索引(电脑没有缓存的情况下),那么会花更多时间,ps:我的机器在重启后第一次制作索引能花费 30 秒
在这里插入图片描述
分析一下 run() 方法中的代码,一共做了三件事:
Ⅰ. 枚举了指定目录下的所有文件 Ⅱ. 对每个 HTML 文件进行解析 Ⅲ. 将索引持久化

而当我们对 第二步 进行计时的时候,可以发现:👇

在这里插入图片描述

基本上就是 第二步 耗时最大,其他加起来不如它的一根毛,所以我们可以对此使用多线程进行优化

7.1 使用线程池完成文件的解析

(1)创建线程池ExecutorService threadPool = Executors.newFixedThreadPool(n),当然也可以使用其他线程池
(2)将parseHtml作为任务进行提交

代码如下

        // 3. 解析这里面的所有文件,然后包装 Document 对象,添加到索引中
        //    创建线程池
        ExecutorService threadPool = Executors.newFixedThreadPool(5);

        for (File file : allFiles) {
            System.out.println("开始解析文件:" + file.getAbsolutePath());
            // 提交任务
            threadPool.submit(new Runnable() {
                @Override
                public void run() {
                    parseHtml(file);
                }
            });
        }

7.2 线程安全问题

涉及到多线程,那自然容易出现线程安全之类的问题,parseHtml()这个方法是多个线程都要去执行的方法,需要检查一下哪里需要修改,⭐特别是涉及到 同时操作 「共享资源」的代码

7.2.1 parseHtml 方法

这是 parseHtml 中的内部代码,其中parseTitle(), parseUrl(), parseContent()这三个方法都是「各自的线程操作各自的文件」,不会互相影响
在这里插入图片描述
然后再看看 index.add() 方法,创建一个文档对象也是安全的

在这里插入图片描述

7.2.2 为正排索引加同步代码块

那就只剩buildForwardbuildInverted 这两个方法需要考虑了,先看看buildForward 👇

在这里插入图片描述
显然这两行代码都在操作 forwardIndex 这个共享资源,所以这里直接加上synchronzied管理即可,至于锁对象,可以是 this,但是没必要,等会再谈。

7.2.3 为倒排索引加同步代码块

buildInverted这个方法👇,只有图示代码在操作 invertedIndex 这个共享资源

在这里插入图片描述所以这段代码也加上 synchronized 修饰即可,而锁对象可以是 this,但是没必要。
⭐在这两段代码中,buildForwardbuildInverted 分别只操作了 forwardIndexinvertedIndex这两个对象,所以可以创建两个对象forwardLockinvertedLock来分别作为这两段同步代码块的锁对象,这相比 this 会更高效,如下

🍓正排索引

在这里插入图片描述
🍓倒排索引

在这里插入图片描述

7.3 CountDownLatch

修改成多线程之后,如果这时候来测时间是不准的,并且代码还有一点的缺陷。

如下,在 for 循环结束之后,索引就制作完了吗?答案是否定的,循环结束之后,这些任务都提交到了线程池中的阻塞队列中一个个等待线程来执行,而 main 方法还在往下执行。所以想要让所有「任务」都执行完之后再进行其他操作的话,就可以使用 CountDownLatch,⭐它可以初始化一个值,然后当有一个线程完成一个任务的时候,这个值就自减,当计数器的值变为0时,在 CountDownLatchawait()的线程就会被唤醒

        // 3. 解析这里面的所有文件,然后包装 Document 对象,添加到索引中
        //    创建线程池
        ExecutorService threadPool = Executors.newFixedThreadPool(5);

        for (File file : allFiles) {
            System.out.println("开始解析文件:" + file.getAbsolutePath());
            // 提交任务
            threadPool.submit(new Runnable() {
                @Override
                public void run() {
                    parseHtml(file);
                }
            });
        }

同时,在线程池执行完任务的时候,由于线程池中的线程不是守护线程,这些线程会影响到进程的结束,所以可以使用threadPool.shutdown()来手动关闭线程池中的线程

这个是最终版本的 run() 方法

    // 多线程执行索引的制作
    public void runThread() throws InterruptedException {
        long beg = System.currentTimeMillis();
        // 1. 获取 api 这个文件对象
        File root = new File(ROOT_PATH);
        // 2. 用来接收 api 文件夹中的所有 HTML 文件, 是一个输入型参数
        List<File> allFiles = new ArrayList<>();
        enumFile(root, allFiles);
        System.out.println("总共 " + allFiles.size() + " 个文件");

        long parseBeg = System.currentTimeMillis();

        // 3. 解析这里面的所有文件,然后包装 Document 对象,添加到索引中
        //    创建线程池
        ExecutorService threadPool = Executors.newFixedThreadPool(5);
        CountDownLatch latch = new CountDownLatch(allFiles.size()); // 这个值设定为 任务的总量,也就是文件的个数

        for (File file : allFiles) {
            System.out.println("开始解析文件:" + file.getAbsolutePath());
            // 提交任务
            threadPool.submit(new Runnable() { // 也可以使用 lambda 表达式
                @Override
                public void run() {
                    parseHtml(file);
                    latch.countDown(); // 任务执行完后,计数器 -1
                }
            });
        }

        latch.await(); // 主线程需要等待所有任务完成
        threadPool.shutdown(); // 手动关闭线程池中的线程

        long parseEnd = System.currentTimeMillis();
        System.out.println("解析文件总共耗时" + "  " + (parseEnd - parseBeg));

        // 4. 进行索引的存储
        index.save();
        long end = System.currentTimeMillis();
        System.out.println("索引制作完成,总共耗时 " + (end - beg) + " ms");
    }

7.4 测试

完成上述代码之后,我们再执行一次,可以看出:相比单线程的执行时长,这里优化了大概 40% 多的时间。而我们在线程池中设定的线程数量为 5,但是这可能不是最优解,需要综合测试和其他各项指标来设定线程数量,此处不过多讨论

在这里插入图片描述

8. 搜索模块

8.1 搜索逻辑

接着就是搜索模块了,这个是搜索引擎的核心模块,这个模块我们创建一个类Searcher来进行管理,其搜索逻辑大致是:用户输入查询字符串query,后端拿到query之后对它进行分词处理,「针对每个分词结果」,都去倒排索引中取出相关联的文档列表,然后将这些文档列表进行整合并做权值降序处理。最后这个数据还不能直接返回给前端,由于真正展示给用户的是标题,url,和摘要,所以还要在正文中把摘要提取出来,然后使用一个类SearchResult来包装 标题,url,摘要,并作为返回给前端的对象

8.2 Searcher 类

首先创建 Searcher 类,添加@Service注解

然后重点来了,由于我们前边Parser类中的run() 方法已经完成了索引的制作,并且进行了持久化存储,所以我们就需要 Searcher 类在工作的时候,不需要去创建索引,只需要在启动的时候将索引加载到内存中

所以我们就可以通过构造方法注入的方式注入Index对象,然后顺便在构造方法中完成索引的加载

@Service
public class Searcher {
    private Index index ;

    @Autowired
    public Searcher(Index index) {
        this.index = index; // 构造方法注入 Index 对象
        index.load();       // 加载索引到内存中
    }
}

8.2 停用词

当用户在搜索框中输入query时,例如输入“a array”的时候,query的分词结果为a (空格),array,显然,这个a和空格对于我们的文档搜索引擎来说没什么意义,它无法降低搜索范围,甚至会影响搜索的效率。

因此,我们将类似的词汇称为「停用词」,我们还需要对query进行一定的过滤,去掉停用词。所以下一步的思路就有了:在 Searcher 启动的时候就将停用词加载到内存中

而停用词可以在网上获取,或者也可以从以下链接中获取这个文件

Gitee 停用词链接
参考博客停用词汇总

8.3 加载停用词

然后我们将文件放到自己指定的目录,我就放在这里:

在这里插入图片描述
然后在 Searcher 类中指定一下路径

    private static final String STOP_WORD_PATH = "D:\\MyJavaCode\\documentSearcher\\jdk-8u361-docs-all\\stopWords.txt";

打开这个文件,可以看出这个文件的格式大致如下,就是一行就是一个停用词,所以我们可以通过BufferedReader 和它的readLined() 来获取这些停用词(🍓readLine()这个方法会读取一行数据,并且读取到的数据不包括换行符)。同时我们使用HashSet来存储这些停用词

在这里插入图片描述
加载停用词代码如下:

    private HashSet<String> stopDict = new HashSet<>(); // 停用词词典

    // 加载停用词
    private void loadStopWords() {
        // 创建 BufferedReader 对象, 然后设定缓冲区大小为 1 兆
        try (BufferedReader bufferedReader = new BufferedReader(new FileReader(STOP_WORD_PATH), 1024 * 1024)) {
            while (true) {
                String line = bufferedReader.readLine(); // 读取一行数据
                if (line == null) {
                    return;
                }
                stopDict.add(line);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

然后我们就可以将 Searcher 类的构造方法补充完整了

    @Autowired
    public Searcher(Index index) {
        this.index = index; // 构造方法注入 Index 对象
        index.load();       // 加载索引到内存中
        loadStopWords();	// 加载停用词
    }

8.4 Search 方法

这个方法就是搜索引擎的执行方法了,根据上面所说的执行逻辑,对于每一个搜索结果都是SearchResult,它有标题,url,摘要属性,如下

@Data
public class SearchResult {
    private String title;
    private String url;
    private String desc; // 摘要

    public SearchResult(String title, String url, String desc) {
        this.title = title;
        this.url = url;
        this.desc = desc;
    }
}

所以这个方法的返回值应该是List<SearchResult>,传入的参数是query,也就是前端传递的查询字符串

    public List<SearchResult> search(String query) {
    }

8.4.1 过滤查询字符串

    public List<SearchResult> search(String query) {
        // 得到原本的分词结果
        List<Term> terms = ToAnalysis.parse(query).getTerms();
        // 临时过滤结果
        List<Term> tmpTerms = new ArrayList<>();

        for (Term term : terms) { // 开始过滤
            String word = term.getName();
            if (!stopDict.contains(word)) { // 如果不是是停用词
                tmpTerms.add(term);
            }
        }

        terms = tmpTerms; // 过滤完成
    }

8.4.2 获取文档列表

过滤之后的所有分词再去倒排索引中取出所有文档列表

        terms = tmpTerms; // 过滤完成

        List<Weight> relatedDocuments = new ArrayList<>();
        for (Term term : terms) {
            String word = term.getName();
            List<Weight> weights = index.getRelatedDocument(word);
            if (weights == null) { // 如果倒排索引中没有这个词
                continue;
            }
            relatedDocuments.addAll(weights);
        }

对所有相关的文档直接添加到一个数组(列表)里,但是这样有点小问题,后面再说,先继续往下走

8.4.3 将结果包装成搜索结果

有了这个列表后,就对它进行权重的降序处理:

// 进行权重降序
relatedDocuments.sort((Weight x, Weight y) -> {return y.getWeight() - x.getWeight();});

由于Weight对象只有两个属性:id 和 weight,再根据 index.getDocument()方法获取这个具体的文档,根据正文获取摘要,再使用SearchResult作为返回值List中的元素即可,代码如下,其中generateDesc()生成描述的方法一会实现

        // 记录最终的搜索结果
        List<SearchResult> results = new ArrayList<>();
        for (Weight weight : relatedDocuments) {
            // 获取这个文档
            Document document = index.getDocument(weight.getDocumentId());
            String title = document.getTitle(); // 标题
            String url = document.getUrl();     // url
            String desc = generateDesc(document.getContent(), terms); // 生成描述

            SearchResult result = new SearchResult(title, url, desc);
            results.add(result);
        }
        return results;

8.4.4 生成摘要

根据正文生成摘要有很多方法,这里我们采用「找到第一次出现 分词 的位置」,以该位置前 50 个字符,以该位置后100个字符的区间,认定是摘要

所以generateDesc()方法代码如下

    // 根据正文生成描述
    private String generateDesc(String content, List<Term> terms) {
        // 记录第一次出现的位置,如果没有出现,默认是 -1
        int firstPos = -1;

        // 遍历 过滤之后的 分词结果
        for (Term term : terms) {
            String word = term.getName();
            firstPos = content.toLowerCase().indexOf(" " + word + " ");
            if (firstPos != -1) { // 如果出现了,则跳出循环
                break;
            }
        }

        if (firstPos == -1) { // 如果没有出现, 那就默认正文的前 150 个
            return content.substring(0, content.length() <= 150 ? content.length() : 150) + "..."; // 加 "..." 为了美观,表示这是摘要
        }

        int beg = firstPos - 50 >= 0 ? firstPos - 50 : 0; // 头
        int end = beg + 150 <= content.length() ? beg + 150 : content.length(); // 尾
        return content.substring(beg, end) + "...";
    }

其中,在找分词第一个出现位置的时候firstPos = content.toLowerCase().indexOf(" " + word + " ")
Ⅰ. 由于 Ansj 提供的分词方法,会将传入的参数全部转成小写,再进行分词,所以这里在正文中找分词位置的时候,也需要将正文转成小写进行匹配
Ⅱ. 在匹配分词的时候,通常需要是「全词匹配」,通俗来说就是你搜索「驴」,但是不能给你匹配「驴打滚」,你想要array,那arraylist肯定不能是最优结果

8.4.5 整合文档列表

在「8.4.2 获取文档列表」这一步骤中,直接将所有分词相关联的列表整合到一起还是有点不妥,例如,当我搜索array list 的时候,就会根据array获取一份文档列表,又根据list获取一份文档列表,然后实际上这两个文档列表可能是有交集的,体现到搜索结果中就是可能出现两篇一样的文档。

因此,我们需要对这 n 个文档列表进行合并,如果 ID 相同,权重就进行简单的合并

这里使用的算法有点类似于力扣上的一个题:合并K个升序链表

算法思路:
Ⅰ. 我们将这 K 个列表中的文档按照 ID 进行升序
Ⅱ. 创建一个小根堆,将每个列表中的第一个元素存放到堆中
Ⅲ. 取出堆顶文档,判断和上一次取出的文档 ID 是否一样,如果一样,那就进行权重合并,如果不一样,就将该文档存放到结果集中
Ⅳ. 将这个堆顶元素在列表中的下一个元素存入堆中
Ⅴ. 重复 Ⅲ, Ⅳ 两个步骤,直到堆中没有元素

我们在进行权重排序之前进行这个优化即可,如下就是优化后的 search()方法和merge()方法

    public List<SearchResult> search(String query) {
        // 得到原本的分词结果
        List<Term> terms = ToAnalysis.parse(query).getTerms();
        // 临时过滤结果
        List<Term> tempTerms = new ArrayList<>();

        for (Term term : terms) { // 开始过滤
            String word = term.getName();
            if (!stopDict.contains(word)) { // 如果不是是停用词
                tempTerms.add(term);
            }
        }

        terms = tempTerms; // 过滤完成

        List<List<Weight>> waitToMerge = new ArrayList<>();
        for (Term term : terms) {
            String word = term.getName();
            List<Weight> weights = index.getRelatedDocument(word);
            if (weights == null) {
                continue ;
            }
            waitToMerge.add(weights);
        }

        List<Weight> relatedDocuments = merge(waitToMerge);
        if (relatedDocuments == null || relatedDocuments.size() == 0) {
            return null;
        }

        // 进行权重降序
        relatedDocuments.sort((Weight x, Weight y) -> {return y.getWeight() - x.getWeight();});

        // 记录最终的搜索结果
        List<SearchResult> results = new ArrayList<>();
        for (Weight weight : relatedDocuments) {
            // 获取这个文档
            Document document = index.getDocument(weight.getDocumentId());
            String title = document.getTitle(); // 标题
            String url = document.getUrl();     // url
            String desc = generateDesc(document.getContent(), terms); // 生成描述

            SearchResult result = new SearchResult(title, url, desc);
            results.add(result);
        }
        return results;
    }

    // 表示二维数组(列表)中的坐标
    private static class Pair {
        int row;
        int col;
        public Pair(int x, int y) {
            this.row = x;
            this.col = y;
        }
    }

    // 对文档列表进行合并
    private List<Weight> merge(List<List<Weight>> waitToMerge) {
        // 去重, 进行权重的累加
        if (waitToMerge == null || waitToMerge.size() == 0)  return null;
        else if (waitToMerge.size() == 1)               return waitToMerge.get(0);

        // 1. 对其中所有的列表进行排序, 按照 ID 进行升序
        for (List<Weight> cur : waitToMerge) {
            cur.sort((Weight x, Weight y) -> {return x.getDocumentId() - y.getDocumentId();});
        }

        // 2. 构建小根堆
        PriorityQueue<Pair> heap = new PriorityQueue<>((Pair x, Pair y) -> {
           // 同样是按照 ID 升序
           int xid = waitToMerge.get(x.row).get(x.col).getDocumentId();
           int yid = waitToMerge.get(y.row).get(y.col).getDocumentId();
           return xid - yid;
        });

        // 3. 所有一维列表中的第一个元素
        for (int i = 0; i < waitToMerge.size(); i ++) {
            heap.offer(new Pair(i, 0));
        }

        // 4. 开始进行合并, 合并的结果
        List<Weight> result = new ArrayList<>();
        while (!heap.isEmpty()) {
            Pair pollPair = heap.poll(); // 弹出第一个坐标
            Weight pollWeight = waitToMerge.get(pollPair.row).get(pollPair.col);

            Weight prevWeight = null; // 上一个入结果集的元素
            if (!result.isEmpty()) {
                prevWeight = result.get(result.size() - 1);
            }

            // 如果不存在上一个元素, 或者两者 ID 不同, 就直接入结果集
            if (prevWeight == null || prevWeight.getDocumentId() != pollWeight.getDocumentId()) {
                result.add(pollWeight);
            } else { // 否则, 进行权重的叠加
                prevWeight.setWeight(prevWeight.getWeight() + pollWeight.getWeight());
            }

            // 然后入 pollPair 所在列表的下一个元素, 且合法
            if (pollPair.col + 1 < waitToMerge.get(pollPair.row).size()) {
                heap.offer(new Pair(pollPair.row, pollPair.col + 1));
            }
        }
    }

9. 前端页面

9.1 模板

事已至此,先搞个前端的搜索页面吧,本人前端只懂皮毛,不是本文重点,以下是前端页面的代码:

Gitee 前端代码链接

这就是搜索页面的一个大致效果,但是可能没那么好看

在这里插入图片描述

9.2 向后端发送 ajax 请求

前端的逻辑很简单,就是在搜索框中输入 query 之后,将这个 query 发送个后端,后端处理完将结果发回给前端进行渲染即可。

我们约定后端的路由为/search,然后为前端的搜索按钮设置一个事件,触发事件后发送 ajax 请求

    <script>
        let button = document.querySelector("#search-btn");
        button.onclick = function() {
            let input = document.querySelector(".header input");
            let query = input.value;
            console.log(query);
            // 然后开始向后端发送请求
            jQuery.ajax({
                type: "GET",
                url: "/search",
                data: {
                    "query": query
                },
                // 接收响应数据,并且进行渲染
                success: function(result) {
                    
                }
            });
        }
    </script>

发送完query之后,等待接收后端的数据然就行了

10. Controller 代码

写完前端写后端,这里先写个大致框架:

SeacherController
首先定义一个 SearcherController 类,并加上 @RestController 注解,其中有一个方法getSearchResult(),并且为该方法设置一个路由/search,而这个业务方法只需要去调用Searcher类中的search方法就可以了

@RestController
public class SearcherController {

    @Autowired
    private Searcher searcher;

    @RequestMapping("/search")
    public List<SearchResult> getSearchResult(String query) {
        return searcher.search(query);
    }
}

11. 统一数据返回

同时,我们可以为所有返回给前端数据的代码加上一层——统一数据返回处理,通过@ControllerAdvice注解和实现接口ResponseBodyAdvice来实现

也可以使用简单一点的,通过一个Result类,里面提供succeedfail方法,并要求:返回给前端的数据都需要通过这个类中的其中一个方法来返回数据。

并且Result中返回的数据类型是HashMap,其中有三个属性:① state表示业务状态,一般用 200 来表示顺利,② msg表示其他信息,可以用来传递给前端,③ data表示传递给前端的数据

Result 类实现如下

public class Result {
    // 如果是顺利返回前端数据
    public static HashMap<String, Object> succeed(Object body) {
        HashMap<String, Object> result = new HashMap<>();
        result.put("state", 200); // 状态码 200 表示正常, -1 表示异常
        result.put("data", body); // 包装返回值
        result.put("msg", "");
        return result;
    }

    public static HashMap<String, Object> succeed(Object body, String msg) {
        HashMap<String, Object> result = new HashMap<>();
        result.put("state", 200); // 状态码 200 表示正常, -1 表示异常
        result.put("data", body); // 包装返回值
        result.put("msg", msg);
        return result;
    }

    // 异常返回前端数据
    public static HashMap<String, Object> fail(Object body) {
        HashMap<String, Object> result = new HashMap<> ();
        result.put("state", -1);
        result.put("msg", "");
        result.put("data", body);
        return result;
    }

    public static HashMap<String, Object> fail(Object body, int state) {
        HashMap<String, Object> result = new HashMap<> ();
        result.put("state", state);
        result.put("msg", "");
        result.put("data", body);
        return result;
    }
}

所以最终在SearcherController中,可以将代码改成:

@RestController
public class SearcherController {

    @Autowired
    private Searcher searcher;

    @RequestMapping("/search")
    public HashMap<String, Object> getSearchResult(String query) {
        List<SearchResult> ret = searcher.search(query);
        return Result.succeed(ret);
    }
}

虽然有 Result 类了,但是我们依然可以再做一层「保护」,当返回给前端的数据没有经过包装的时候,就会强制就行包装:和前边说的一样,通过@ControllerAdvice注解和实现接口ResponseBodyAdvice来实现,然后重写 supports 方法 和 beforeBodyWrite.

由于篇幅有限(总字数近3w了),这里涉及到的学问可以在csdn学习一下,代码如下:

@ControllerAdvice
@ResponseBody
public class ResponseAdvice implements ResponseBodyAdvice {

    @Override
    public boolean supports(MethodParameter returnType, Class converterType) {
        return true; // 表示需要统一返回结果处理
    }

    @SneakyThrows
    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
        // body 就是返回给前端的数据
        if (body instanceof HashMap) { // 如果返回的数据已经是 HashMap 这个对象了
            return body;
        }

        if (body instanceof String) {   // 如果是字符串,就需要做特殊处理
            ObjectMapper objectMapper = new ObjectMapper();
            return objectMapper.writeValueAsString(Result.succeed(body));
        }

        return Result.succeed(body);
    }
}

12. 前端对接收到的数据进行渲染

首先需要将此处代码进行屏蔽

在这里插入图片描述
然后就可以在刚刚编写的Ajax中的 success回调函数中的代码了,这里算前端代码,有很多其他写法,笔者前端知识 不够,代码如下

    success: function(result) {
        jQuery("#searchResults").innerHtml = '';
        var finalHtml = "";
        var len ;
        if (result.data == null || result.data.length == 0) {
            len = 0;
        } else {
            len = result.data.length;
        }

        finalHtml += '<div class="count">当前总共为您匹配到了 ' + len +' 个结果</div>'
        if (len == 0) {
            finalHtml += "您访问的资源丢失啦 QAQ";
        } else if (result.state == 200 && result.data != null && result.data.length > 0) {
            for (var i = 0; i < result.data.length; i ++) {
                var term = result.data[i];
                finalHtml += '<div class="item">';
                finalHtml += '<a href="' + term.url +'" target="_blank">' + term.title + '</a>';  //  target=_blank 表示以新标签页的格式打开
                finalHtml += '<div class="desc">' + term.desc + '</div>';
                finalHtml += '<div class="url">' + term.url + '</div>';
                finalHtml += '</div>';
            }
        }
        jQuery("#searchResults").html(finalHtml); // 拼接
    }

但这里为止,项目也快制作完了,我们测试一下效果

在这里插入图片描述
在这里插入图片描述

13. 实现关键字标红

在常见的搜索引擎中,如下图,在摘要中都对关键词进行了标红功能,实现起来并不复杂
在这里插入图片描述思路:我们在生成摘要的时候,根据摘要遍历所有的分词结果,然后将出现的关键词加上<i>标签,然后前端再为这个 i 标签设置样式,设置为红色即可:而 i 标签的添加,可以使用正则表达式

        String desc = generateDesc(document.getContent(), terms);
        // 这段描述需要对 所有关键词 进行标红,也就是 使用 <i> 标签修饰
        // 并且由于 分词 已经转化成了小写,所以这里也对关键词标红的时候,也不需要区分大小写
        for (Term term : terms) {
            String word = term.getName();
            desc = desc.replaceAll("(?i) " + word + " ", "<i> " + word + " </i>");
        }

前端代码

        .item .desc i {
            color: red;
            font-style: normal;
        }

最终效果如下:
在这里插入图片描述完结撒花

14. 完整代码

Gitee代码

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

答辣喇叭

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值