当前位置: 首页 > article >正文

Java项目——文档搜索引擎

文章目录

  • 1. 项目概述
  • 2. 准备阶段
    • 2.1 项目创建
    • 2.2 准备静态页面
  • 3. 搜索逻辑
  • 4. 分词
  • 5. 处理 HTML 文件
    • 5.1 枚举文件夹中所有文件
    • 5.2 预处理文件
      • 5.2.1 获取标题
      • 5.2.2 获取 URL
      • 5.2.3 获取正文
  • 6. 索引
    • 6.1 正排索引和倒排索引
    • 6.2 往正排索引中添加元素
    • 6.3 往倒排索引中添加元素
      • 6.3.1 大致思路
      • 6.3.2 计算权重(相关性)
      • 6.3.3 实现
    • 6.4 往索引中添加元素
    • 6.5 补充 parseHtml() 方法
    • 6.6 获取文档
    • 6.7 测试
    • 6.8 持久化保存索引结构
    • 6.9 将索引结构从文件中加载到内存中
  • 7. 多线程优化解析速度
    • 7.1 使用线程池完成文件的解析
    • 7.2 线程安全问题
      • 7.2.1 parseHtml 方法
      • 7.2.2 为正排索引加同步代码块
      • 7.2.3 为倒排索引加同步代码块
    • 7.3 CountDownLatch
    • 7.4 测试
  • 8. 搜索模块
    • 8.1 搜索逻辑
    • 8.2 Searcher 类
    • 8.2 停用词
    • 8.3 加载停用词
    • 8.4 Search 方法
      • 8.4.1 过滤查询字符串
      • 8.4.2 获取文档列表
      • 8.4.3 将结果包装成搜索结果
      • 8.4.4 生成摘要
      • 8.4.5 整合文档列表
  • 9. 前端页面
    • 9.1 模板
    • 9.2 向后端发送 ajax 请求
  • 10. Controller 代码
  • 11. 统一数据返回
  • 12. 前端对接收到的数据进行渲染
  • 13. 实现关键字标红
  • 14. 完整代码

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) {// 待加入文档的IDdocument.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); // 标题出现次数赋值为 1counterMap.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()); // 存入文档 IDnewWeight.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) {// 获取文档,然后打印 文档 的 urlSystem.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() {@Overridepublic 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() {@Overridepublic 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 表达式@Overridepublic 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 ;@Autowiredpublic 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 类的构造方法补充完整了

    @Autowiredpublic 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();     // urlString 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) {// 记录第一次出现的位置,如果没有出现,默认是 -1int 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();     // urlString 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 {@Autowiredprivate 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 {@Autowiredprivate 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 {@Overridepublic boolean supports(MethodParameter returnType, Class converterType) {return true; // 表示需要统一返回结果处理}@SneakyThrows@Overridepublic 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代码

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.dgrt.cn/a/392055.html

如若内容造成侵权/违法违规/事实不符,请联系一条长河网进行投诉反馈,一经查实,立即删除!

相关文章:

Java项目——文档搜索引擎

文章目录1. 项目概述2. 准备阶段2.1 项目创建2.2 准备静态页面3. 搜索逻辑4. 分词5. 处理 HTML 文件5.1 枚举文件夹中所有文件5.2 预处理文件5.2.1 获取标题5.2.2 获取 URL5.2.3 获取正文6. 索引6.1 正排索引和倒排索引6.2 往正排索引中添加元素6.3 往倒排索引中添加元素6.3.1 …...

yum仓库及NFS共享

目录 yum配置文件及命令 yum配置文件 yum命令详解 本地yum仓库搭建 存储和NFS共享 存储类型 NFS简介 NFS特点 实验 yum配置文件及命令 yum配置文件 主配置文件 /etc/yum.conf #主配置文件 仓库设置文件 /etc/yum.repos.d/*.repo #yum仓库文件位置 日志文件 /v…...

【设计模式之美 设计原则与思想:面向对象】06 | 理论三:面向对象相比面向过程有哪些优势?面向过程真的过时了吗?

在上两节课中&#xff0c;我们讲了面向对象这种现在非常流行的编程范式&#xff0c;或者说编程风格。实际上&#xff0c;除了面向对象之外&#xff0c;被大家熟知的编程范式还有另外两种&#xff0c;面向过程编程和函数式编程。面向过程这种编程范式随着面向对象的出现&#xf…...

【源码解析】Ribbon实现负载均衡

背景 在SpringCloud系列中&#xff0c;Eureka实现了服务注册中心&#xff0c;Feign实现了动态代理&#xff0c;Ribbon实现了负载均衡。如果注册中心上某个服务注册了多个实例&#xff0c;ribbon可以通过一定的规则获取特定的实例。 源码解析 RibbonClient使用入口 Synchro…...

配置安全的linux-apache服务器(5)

实验简介 实验所属系列&#xff1a;Linux网络服务配置与安全 实验对象&#xff1a; 本科/专科信息安全专业、网络工程 相关课程及专业&#xff1a;系统安全配置、服务器配置、计算机网络 实验时数&#xff08;学分&#xff09;&#xff1a;2学时 实验类别&#xff1a;实践实验…...

重定向与管道符

一、重定向在知道重定向前&#xff0c;我们首先需要知道三个概念&#xff1a;标准输入、标准输出和标准错误输出。类型设备文件文件描述编号默认设备标准输入/dev/stdin0键盘标准输出/dev/stdout1显示器标准错误输出/dev/stderr2显示器交互式硬件设备标准输入&#xff1a;从该设…...

java基础进阶-day32(集合)

集合HashSet集合HashSet集合概述和特点HashSet集合的基本应用哈希值【理解】哈希表结构【理解】HashSet集合存储学生对象并遍历【应用】Map集合Map集合概述和特点【理解】Map集合的基本功能【应用】Map集合的获取功能【应用】Map集合的遍历&#xff08;方式1&#xff09;【应用…...

基于nodejs+vue+elementui书籍学习平台--vscode

前端&#xff1a;HTML5,CSS3、JavaScript、VUE 前端技术&#xff1a;nodejsvueelementui,视图层其实质就是vue页面&#xff0c;通过编写vue页面从而展示在浏览器中&#xff0c;编写完成的vue页面要能够和控制器类进行交互&#xff0c;从而使得用户在点击网页进行操作时能够正常…...

java多态 ---类的多态

方法的重新与重载是一种形式的多态.然而类的多态才是核心.核心为什么多态可以成立本质上是因为继承关系,让子类可以继承父类,(可以兼容),从而可以直接改变对象运行时的类型,//转化类型是可以的,然后就可以变为运行时类型了.本质上只是多人一个不同类型(父类,或子类)的指向已经n…...

都2023年了,还有必要学习Servlet吗?

文章目录1. 前言2. Servlet 简介3. 快速入门4. 执行流程5. 生命周期6. 方法初识7. 体系结构8. urlPattern 配置9. XML配置10. 总结1. 前言 起初&#xff0c;所有的 Web 网站都是静态的&#xff0c;网页只是作为一个信息的发布平台&#xff0c;客户机上请求服务器上的某个资源&…...

【开发实践】在线考试系统(三) Sortable实现试题的重排序

一、需求分析 开发一个在线考试系统&#xff0c;在教师组卷功能模块中&#xff0c;需要对已经选择的试题进行重排序&#xff0c;获得所需试题顺序的试卷。 如下是试卷编辑页面&#xff0c;需要在将已经选中的试题重排顺序。 二、技术引入 1、sortable.js sortable.js 官方…...

测牛学堂:接口测试中的数据驱动实现以及实现

接口对象封装之数据驱动 在实际接口测试中&#xff0c;测试的参数是通过json文件的方式给到的&#xff0c;所以我们要实现接口测试的数据驱动&#xff0c;也叫参数化。 使用python自带的unittest单元测试框架进行接口参数化测试时&#xff0c;因unittest不具备参数化测试&…...

辽宁申请互联网医院牌照流程

辽宁申请互联网医院牌照流程|沈阳市|大连市|鞍山市|抚顺市|本溪市|丹东市|锦州市|营口市|阜新市|辽阳市|盘锦市|铁岭市|朝阳市|葫芦岛市   很多的人对互联网医院都不是很了解&#xff0c;也不太清楚互联网医院牌照怎么申请&#xff0c;其实牌照申请每个地区都不太一样&#x…...

【SQL开发实战技巧】系列(二十九):数仓报表场景☞简单的树形(分层)查询以及如何确定根节点、分支节点和叶子节点

系列文章目录 【SQL开发实战技巧】系列&#xff08;一&#xff09;:关于SQL不得不说的那些事 【SQL开发实战技巧】系列&#xff08;二&#xff09;&#xff1a;简单单表查询 【SQL开发实战技巧】系列&#xff08;三&#xff09;&#xff1a;SQL排序的那些事 【SQL开发实战技巧…...

《SpringBoot》第02章 自动配置机制(一) 项目启动

前言 关于SpringBoot&#xff0c;最大的特点就是开箱即用&#xff0c;通过自动配置机制&#xff0c;遵守约定大于配置这个准则&#xff0c;那么这个是如何实现的呢&#xff1f; 本章首先会介绍SpringBoot的启动执行 一、启动第一步&#xff1a;初始化 1.本章概述 当启动Sp…...

深入学习JavaScript系列(七)——Promise async/await generator

本篇属于本系列第七篇 第一篇&#xff1a;#深入学习JavaScript系列&#xff08;一&#xff09;—— ES6中的JS执行上下文 第二篇&#xff1a;# 深入学习JavaScript系列&#xff08;二&#xff09;——作用域和作用域链 第三篇&#xff1a;# 深入学习JavaScript系列&#xff…...

JAVA练习94-Excel 表列序号

提示&#xff1a;文章写完后&#xff0c;目录可以自动生成&#xff0c;如何生成可参考右边的帮助文档 目录 前言 一、题目-Excel 表列序号 1.题目描述 2.思路与代码 2.1 思路 2.2 代码 总结 前言 提示&#xff1a;这里可以添加本文要记录的大概内容&#xff1a; 3月30日…...

2023年,PMP难考吗?

PMP考试难不难&#xff0c;还是因人而异的&#xff0c;对小白而言&#xff0c;肯定是难的&#xff0c;对项目管理老人而言&#xff0c;难度肯定是没那么高。 难点主要是非常多而难理解的知识点&#xff0c;以及答题时的知识点提取。经过系统的学习&#xff0c;分解知识点&…...

Vue学习笔记(4.Vue-cli创建vue3项目)

1. 打开VSCode 输入命令&#xff1a;【ctrl ~】打开 终端 2. 创建项目 (1). 输入命令 vue create {项目名} (2). 选择配置 default([Vue 3]) babel, eslint) // vue3默认配置default([Vue 2]) babel, eslint) // vue2默认配置Manually select features //手动配置 这里选手…...

ChatGPT插件刚火了不到一天,就被爆出严重问题了

当地时间&#xff0c;24日晚9点&#xff0c;一位推特上昵称“rez0”的技术骇客&#xff08; 同时也是一位prompt工程师&#xff09;&#xff0c;在研究破解新的ChatGPT API时发现了一个非常有趣的事情&#xff1a;通过从API调用中删除特定参数就能获得80多个秘密插件&#xff0…...