Java项目——文档搜索引擎

article/2023/6/4 14:38:54

文章目录

  • 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代码


https://www.dgrt.cn/a/392055.html

相关文章

yum仓库及NFS共享

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

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

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

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

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

基于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;客户机上请求服务器上的某个资源&…

ActiveMQ消息队列主从方案

title: ActiveMQ消息队列主从方案 date: 2017-10-24 21:35:17 tags: ActiveMQ消息队列主从 categories:中间件 背景 消息队列是实际项目中经常用到的中间件&#xff0c;目前也有很多开源并广泛应用的消息队列&#xff0c;今天拿ActiveMQ来聊一聊&#xff0c;怎样保证它的高可…

Vue显示图片的几种方式

前言 最近在做自己的项目&#xff0c;有这么一个需求&#xff0c;用户列表需要展示用户的头像&#xff0c;之前一直没有处理&#xff0c;趁着这次机会&#xff0c;正好分享下我的解决过程。 头像这一栏空荡荡的&#xff0c;我这种强迫症患者难受死&#xff01; 首先声明下&am…