1.背景介绍


对于一个网站来说,搜索引擎需要提前预备好很多很多的静态资源。当用户输入查询的关键词的时候根据这些关键词来模糊查询匹配对应的资源,然后将这些资源展示给用户即可。

1.1 搜索核心思路

主要是依赖于互联网上的爬虫程序,它们可以极大效率的利用互联网获取到海量信息资源。我们这里没有用到爬虫,而是根据倒排索引来实现关键词快速查询指定文档id。

1.2 倒排索引

我们都会暴力搜索,时间复杂度很高,面对海量资源的时候查询效率非常低下。因此会用到数据结构中的搜索——倒排索引
了解倒排索引之后,我们就把对应的数据结构建立起来。

  1. 文档:就是项目中预备的静态资源
  2. 正排索引:根据文档id查询文档内容
  3. 倒排索引:根据关键词查询文档id列表

1.3 项目目标

区分全站搜索和站内搜索

全站搜索:Google,Bing应,火狐,百度,QQ浏览器,360浏览器。。。等等出名的浏览器在使用过程中会发现可以搜索到任意用户任意输入的关键词
站内搜索:比如在CSDN博客站内搜索某个内容,就会只显示站内的静态资源,站外的资源由于没有存储,所以无法搜索到。也一定程度上实现资源的筛选,得到搜索更准确的目的。

1.4 获取java文档

把相关的网页文档获取到,这样才能制作正派索引和倒排索引

官网JDK8在线文档查看步骤








至此就已经一步步进入到官网的文档了

下载离线JDK8文档步骤


往下拉,就会有Java8文档

至此就可以下载离线文档

这样做的目的就是为了备好静态资源。
为什么不用爬虫来准备好这些静态资源呢?
如果用爬虫的话相当于直接拿到了静态资源而不需要我们去实现中间这个过程,这并不是项目的核心技术。也不需要为了爬虫而去学习Python这门语言。只要编程语言能够访问网络,那么就可以实现爬虫。
针对JDK8文档来说,我们选择更简单的方案:直接从官网下载文档的压缩包放在我们的静态资源中。不必通过爬虫来实现了。

我们对比一下熟悉的ArrayList文档
在线版本

按住ctrl新打开标签页就会出现如下显示

离线版本

URL
在线 https://docs.oracle.com/javase/8/docs/api/java/util/ArrayList.html
离线线 file:///D:/Programme/Java/Spring/jdk-8u333-docs-all/docs/api/java/util/ArrayList.html

在本地基于离线文档来制作索引,根据关键词实现搜索;当用户在搜索结果页点击具体的搜索内容的时候就自动跳转到在线文档的页面

1.5 模块划分

  1. 索引模块

    1. 扫描下载到的文档,分析文档内容,构建出正排索引+倒排索引。并把索引内容保存到文件中
    2. 加载制作好的索引文件,提供一些 API 实现查正排和查倒排这样的功能
  2. 搜索模块
    1. 调用搜索模块实现一个完整的搜索过程
      输入:关键词
      输出:完整的搜索结果【标题,URL并且点击能够跳转,内容】
  3. web模块
    1. 需要实现一个简单的 web 模块,能够通过网页的形式来和用户进行交互。包含前后端。

1.6 创建项目

1.7 分词

分词是为了后续构造倒排索引,根据关键词查文档id用的。英文分词很简单,但是中文分词就容易造成误解

  1. 每天膳食,无鸡鸭亦可,无鱼肉亦可,青菜一碟足矣 --> 每天膳食,无鸡,鸭亦可;无鱼,肉亦可;青菜一碟,足矣
  2. 下雨天留客天留我不留 --> 下雨天,留客天,留我不?留!
  3. 寄钱三百吊买柴烧孩子小心带和尚田租等我回去收 --> 寄钱三百吊买柴烧孩子小心带和尚田租等我回去收/寄钱三百吊买柴烧孩子小心带和尚田租等我回去收

1.8 分词原理

  1. 基于词库

    1. 尝试把所有的“的”都进行穷举~把这些穷举结果放到词典中。然后就可以一次的取句子中的内容,每隔一个字,再次电力查一下;每隔两个词,查一下。
      但是由于互联网带来的一些新词或者说是年度最热的词句是一只变动的,那么就无法正确的分词
  2. 基于统计
    收集到很多很多的“语料库”–>人工标注/直接统计,也就知道了哪些字在一起的概率比较大
    分词的实现,就是属于“人工智能”典型的应用场景

  3. 基于第三方库
    这里使用的 ansj

    选择最新的5.1.6版本即可,复制到项目中 pom.xml,并添加对应版本的 servlet,mysql,jackson依赖

servlet要和使用的jdk版本对应,mysql要和对应的数据库对应

Jackson

分词代码示例

import org.ansj.domain.Term;
import org.ansj.splitWord.analysis.ToAnalysis;import java.util.List;public class TestAnsj {public static void main(String[] args) {// 测试分词String str="小明毕业于清华大学,后来去蓝翔技校和新东方深造,擅长使用计算机控制挖掘机炒菜";List<Term> terms = ToAnalysis.parse(str).getTerms();// terms 就是分词的结果,getTerms返回一个List那样的集合for (Term t : terms) {System.out.println(t);}}
}小明/nz
毕业/v
于/p
清华大学/nt
,/w
后来/t
去/v
蓝/a
翔/nr
技校/n
和/c
新/a
东方/s
深造/v
,/w
擅长/v
使用/v
计算机/n
控制/v
挖掘机/n
炒菜/v

2.实现索引模块

2.1 Parser类的整体流程

  1. 根据指定的路径,加载本地的静态资源【html结尾的文件】
  2. 根据加载的文件列表,读取文件内容解析后构建正排索引和倒排索引
  3. 把内存中加载好的索引保存到文件中

2.2 递归枚举文件

通过ArrayList装填File类,来存储当前路径下的全部文件,递归处理目录

import java.io.File;
import java.util.ArrayList;public class Parse {// 先指定一个加载文档的路径private static final String INPUT_PATH = "D:/Downloads/jdk-8u333-docs-all/docs/api";private void run() {// 整个 Parse 类的入口// 1.根据上面指定的路径,枚举出整个路径中所有的文件(html),这个过程需要把所有子目录中的文件都获取到ArrayList<File> fileList = new ArrayList<>();enumFile(INPUT_PATH, fileList);System.out.println(fileList);System.out.println(fileList.size());// 2.针对上面罗列出的文件路径,打开文件,读取文件内容,并进行解析,并构建索引// 3.把内存中构造好的索引数据结构保存到指定的文件中}/*** @param inputPath 从那个目录开始递归遍历* @param fileList  递归得到的结果*/private void enumFile(String inputPath, ArrayList<File> fileList) {File rootPath = new File(inputPath);// listFiles 能获取到 rootPath 目录下的全部文件/目录,要想看到目录中的内如还需要进行递归File[] files = rootPath.listFiles();for (File f : files) {// 根据 f 类型来决定是否递归:f是一个普通文件,就把f加入到 fileList 中;f是一个目录,就继续调用 enumFile 方法,来进一步获取目录中的文件if (f.isDirectory()) {// getAbsolutePath:获取绝对路径,getPath获取相对路径,getCanonicalPath获取修饰之后的路径enumFile(f.getAbsolutePath(), fileList);} else {fileList.add(f);}}}public static void main(String[] args) {// 通过 main 方法实现整个制作索引的过程Parse parse = new Parse();parse.run();}
}

有一个问题是这里包含了一些css,font,script文件,这些并不是我们所需要的应该排除掉

2.3 排除非html文件

private void enumFile(String inputPath, ArrayList<File> fileList) {File rootPath = new File(inputPath);// listFiles 能获取到 rootPath 目录下的全部文件/目录,要想看到目录中的内如还需要进行递归File[] files = rootPath.listFiles();for (File f : files) {// 根据 f 类型来决定是否递归:f是一个普通文件,就把f加入到 fileList 中;f是一个目录,就继续调用 enumFile 方法,来进一步获取目录中的文件if (f.isDirectory()) {// getAbsolutePath:获取绝对路径,getPath获取相对路径,getCanonicalPath获取修饰之后的路径enumFile(f.getAbsolutePath(), fileList);} else {// 排除非html文件if (f.getAbsolutePath().endsWith(".html")){fileList.add(f);}}}}

打印后发现获取到的都是html结尾的文件了

2.4 解析html

解析html就是把html文件的标题,URL,描述内容给展现在网页上

描述来源于正文,所以首先需要解析html文件的内容正文
整个 HTML 的解析通过 parseHTML 函数解决
parseHTML 再由 parseTitle,parseUrl,parseConten 四部分构成

private void parseHTML(File f) {// 1.解析出 HTML 的标题String title = parseTitle(f);// 2.解析出 HTML 的URLString url = parseUrl(f);// 3.解析出 HTML 的描述String content = parseContent(f);// 4.把解析出来的这些信息加入到索引当中
}

2.5 解析标题

发现html文件名可以作为我们的搜索结果的标题

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

2.6 解析URL

还记得前文中现实的线上和线下的文档路径吗?

URL
在线 https://docs.oracle.com/javase/8/docs/api/java/util/ArrayList.html
离线线 file://D:/Programme/Java/Spring/jdk-8u333-docs-all/docs/api/java/util/ArrayList.html
做到的效果就是用户点击搜索结果,就能够跳转到对应的线上文档的页面
根据线下文档的后半部分URL和线上文档的前半部分URL进行拼接即可
import java.io.File;public class TestGetUrl {private static final String INPUT_PATH = "D:/Downloads/jdk-8u333-docs-all/docs/api/";public static void main(String[] args) {File file = new File("D:/Downloads/jdk-8u333-docs-all/docs/api/java/util/ArrayList.html");// 先获得一个固定的前缀String part1 = "https://docs.oracle.com/javase/8/docs/api/";String part2=file.getAbsolutePath().substring(INPUT_PATH.length());String result = part1+part2;System.out.println(result);}
}https://docs.oracle.com/javase/8/docs/api/java\util\ArrayList.html

会发现这里的正斜杠和反斜杠都存在,会不会影响浏览器的正常访问呢?

把地址复制到浏览器中发现,被正常解析了且成功访问。
主流的浏览器都有这样的纠错能力,也就是所谓 “鲁棒性【robot】”

也可以利用 replaceAll 函数进行替换掉也可以【注意使用正则的时候java语言中需要4个 \\\\ 才能代表 1个 \

import java.io.File;public class TestGetUrl {private static final String INPUT_PATH = "D:/Downloads/jdk-8u333-docs-all/docs/api/";public static void main(String[] args) {File file = new File("D:/Downloads/jdk-8u333-docs-all/docs/api/java/util/ArrayList.html");// 先获得一个固定的前缀String part1 = "https://docs.oracle.com/javase/8/docs/api/";String part2 = file.getAbsolutePath().substring(INPUT_PATH.length());String result = part1 + part2;result = result.replaceAll("\\\\", "/");System.out.println(result);}
}

2.7 解析正文

一个完整的 HTML文件 由 HTML标签 + 内容(Java文档),接下来,进行的解析正文的核心操作就是去掉 HTML 标签获取到里边的内容【相当于 innerText】
利用正则会太麻烦,因此我们就利用标签的 左右尖括号< > 来判断是否为内容
举例

文档索引项目

都遇到一个字符 '<' 这个位置开始不进行数据拷贝,接下来读到 d,i,v也都不拷贝。读到 '>' 也不拷贝,但是不拷贝状态结束,后续读到 “这是一段内容” 就能进行拷贝。读到 '<' 就有结束拷贝。

思考:万一html中有 < 该怎么办

其实这个不用担心,因为 html 中的 ‘<’ 等这些特殊符号是由 & l t 来构成的

private String parseContent(File f) {// 还得准备一个保存结果的 StringBuilderStringBuilder content = new StringBuilder();// 这里需要按照字符来读try(FileReader fileReader = new FileReader(f)) {// 加上一个是否拷贝的开关boolean isCopy = true;while (true) {int read = fileReader.read();if (read == -1) {break;}char c = (char) read;if (isCopy) {// 开关打开状态,遇到普通字符就考呗到StringBuilder中if (c == '<') {// 遇到 '<' 就关闭拷贝isCopy = false;continue;}if (c == '\n' || c == '\r'){c = ' ';}// 其他字符,直接进行拷贝content.append(c);} else {// 开关关闭状态,暂不拷贝,直到遇到 '>' 打开if (c == '>') {isCopy = true;}}}} catch (IOException e) {throw new RuntimeException(e);}return content.toString();}

2.8 Parser类小结

Parser 类通过 run 方法启动。

  1. 先扫描指定路径的文件,放入到一个 ArrayList 文件列表中【√】
  2. 再完善添加文件过程:排除非html文件【√】
  3. 再通过一个 parseHTML 解析全部的 html文件【√】
  4. 根据解析结果构造索引【×需要通过index类完成】

2.9 创建index类

index类 主要用来在内存中构造索引结构。有4部分组成。

  1. 通过 docId 查 文档信息【这是正排索引做的事情】
  2. 给定一个 关键词 查 docId【这是倒排索引做的事情】
  3. 往索引中增加一个文档【要及时更新正排索引和倒排索引】
  4. 把内存中的索引结构保存在文件中
  5. 加载文件中的索引结构到内存中

先创建一个文档对象 DocInfo

public class DocInfo {private int docId;private String title;private String url;private String content;public int getDocId() {return docId;}public void setDocId(int docId) {this.docId = docId;}public String getTitle() {return title;}public void setTitle(String title) {this.title = title;}public String getUrl() {return url;}public void setUrl(String url) {this.url = url;}public String getContent() {return content;}public void setContent(String content) {this.content = content;}
}

在创建 index 类

import java.util.ArrayList;// 通过这个类在内存中构造出索引结构
public class index {//这个类提供的方法://1.给定一个 docId,在正排索引中查询文档的详细信息public DocInfo getDocInfo(int docId) {return null;}//2.给定一个 关键词,在倒排索引中查询文档idpublic ArrayList<Weight> getInverted(String term) {return null;}//3.往索引中新增一个文档public void addDoc(String title, String url, String content) {}//4.把内存中的索引结构保存到磁盘中public void save() {}//5.把磁盘中的索引数据加载到内存中public void load() {}
}

2.10 实现索引结构

使用 ArrarList 来创建正派索引;HashMap<String, ArrayList> 来创建倒排索引

注意点

  1. 正排索引就利用 ArrayList 取下标的方式获取文档,时间复杂度为 O(1)
  2. 倒排索引可以使用 HashMap<String, ArrayList> 来实现 O(1) 复杂度的获取相关文档 ID

倒排索引为何这样构建

如果使用 HashMap<String, Integer> 是可以实现每个文档的 关键词 与 文档ID 的匹配联系,但是我们需要一个 权重Weight 。类似于搜索引擎中的搜索结果的排名,我们还需要文档和关键词匹配的相关性进行计算权重,因此使用一个数组来装填全部的与此关键词匹配的文档ID

Weight权重类

具体的权重计算先把大致框架搭建好之后再详细设计算法【这里先埋个坑】

// 根据 docId 和 文档与词的相关性 权重 进行一个包裹
public class Weight {private int docId;// 通过这个 weight 就表示 文档 和 词 之间的 “相关性”:值越大,就认为相关性越强private int weight;public int getDocId() {return docId;}public void setDocId(int docId) {this.docId = docId;}public int getWeight() {return weight;}public void setWeight(int weight) {this.weight = weight;}
}

Index索引类大致框架

import java.util.ArrayList;
import java.util.HashMap;// 通过这个类在内存中构造出索引结构
public class index {// 使用数组下标表示 docIdprivate ArrayList<DocInfo> forwardIndex = new ArrayList<>();// 使用 哈希表 来表示倒排索引 关键词:一组和这个词关联的文章private HashMap<String, ArrayList<Weight>> invertedIndex = new HashMap<>();//这个类提供的方法://1.给定一个 docId,在正排索引中查询文档的详细信息public DocInfo getDocInfo(int docId) {return forwardIndex.get(docId);}//2.给定一个 关键词,在倒排索引中查询文档id【为了使得存储的是相关性,所以使用了 Weight 取代 docId】public ArrayList<Weight> getInverted(String term) {return invertedIndex.get(term);}//3.往索引中新增一个文档public void addDoc(String title, String url, String content) {// 新增文档需要同时新增正排索引和倒排索引// 新增正排索引DocInfo docInfo=buildForward(title, url, content);// 构建倒排索引buildInverted(docInfo);}private DocInfo buildForward(String title, String url, String content) {}//4.把内存中的索引结构保存到磁盘中public void save() {}//5.把磁盘中的索引数据加载到内存中public void load() {}
}

2.11 实现新增文档

这里就i是简单的通过参数生成指定文档之后加入正排索引

private DocInfo buildForward(String title, String url, String content) {DocInfo docInfo = new DocInfo();docInfo.setDocId(forwardIndex.size());docInfo.setUrl(url);docInfo.setTitle(title);docInfo.setContent(content);forwardIndex.add(docInfo);return docInfo;
}

2.12 实现构建倒排

倒排索引:关键词->文档id 之间的映射关系。正常搜索的时候我们会发现结果相关性高的排名会靠前。因此 HashMap 的 value 需要进行一定的排序,根据 key 关键词 来匹配 文档分词,如果匹配,就把 DocId 加入到 vlue 中即可。
权重如何设计?

此处我们就通过简单的次数统计来计算。
相关性往往是由部门的算法团队来训练模型的,根据文档中提取的特征,训练模型最终借助机器学习的方式来衡量

private void buildInverted(DocInfo docInfo) {class WordCnt {// 这个 关键词 标题出现次数public int titleCount;// 这个 关键词 正文出现次数public int contentCount;}// 用这个数据结构进行词频统计HashMap<String, WordCnt> wordCntHashMap = new HashMap<>();// 1.统计标题出现次数List<Term> terms = ToAnalysis.parse(docInfo.getTitle()).getTerms();// 2.遍历分词结果,统计每个分词出现次数【标题的出现次数意义大于正文】for (Term t : terms) {String word = t.getName();WordCnt wordCnt = wordCntHashMap.get(word);if (wordCnt == null) {wordCnt = new WordCnt();wordCnt.titleCount = 1;wordCnt.contentCount = 0;wordCntHashMap.put(word, wordCnt);} else {wordCnt.titleCount += 1;}}// 3.统计正文出现次数terms = ToAnalysis.parse(docInfo.getContent()).getTerms();// 4.遍历分词结果,统计每个分词出现次数for (Term t : terms) {String word = t.getName();WordCnt wordCnt = wordCntHashMap.get(word);if (wordCnt == null) {wordCnt = new WordCnt();wordCnt.titleCount = 0;wordCnt.contentCount = 1;wordCntHashMap.put(word, wordCnt);} else {wordCnt.contentCount += 1;}}// 5.把上面的结果汇总到 HashMap 中//      最终文档的权重就是 标题出现的次数*10 + 正文出现的次数【实际应用的算法会很复杂】// 6.遍历刚才的 HashMap 依次来更新倒排索引for (Map.Entry<String, WordCnt> entry : wordCntHashMap.entrySet()) {ArrayList<Weight> invertedList = invertedIndex.get(entry.getKey());if (invertedList == null) {ArrayList<Weight> tmp=new ArrayList<>();Weight weight = new Weight();weight.setDocId(docInfo.getDocId());weight.setWeight(entry.getValue().titleCount * 10 + entry.getValue().contentCount);tmp.add(weight);invertedIndex.put(entry.getKey(), tmp);} else {Weight weight = new Weight();weight.setDocId(docInfo.getDocId());weight.setWeight(entry.getValue().titleCount * 10 + entry.getValue().contentCount);invertedList.add(weight);}}
}

这个倒排索引代码稍长。我们一步一步分析。
我们需要统计标题个数和正文个数,所以创建了一个内部类 WordCnt ,先对标题进行分词并统计,统计结果放在 wordCntHashMap 中。再对正文进行分词统计,统计结果也放在 wordCntHashMap 中。最后遍历整个 wordCntHashMap 将结果保存在类变量 invertedIndex 中。

2.13 为何保存加载索引

当前这些索引是保存在内存中的,构建索引过程又是很耗时。因此我们不应该在服务器启动的时候就够建索引(服务器的启动速度会被拖慢很多的)
通常的做法就是这些好事的操作单独执行,然后让线上的服务器直接加载这个构造好的索引。因此就需要吧内存中的 索引这样的数据结构变成字符串,然后进行写文件【序列化操作;对应的反序列化就是把字符串反向解析成一些结构化数据(类/对象/基础数据结构)】
对于序列化操作,jdk 自带了就有 Serializable。我们这里使用第三方库 Jackson

给 index类 添加文件的保存路径变量和Jackson对象

private static String INDEX_PATH = "D:/Programme/Java/5.Spring/DocSearch/src/main/resources";
private ObjectMapper mapper = new ObjectMapper();

2.14 实现保存索引文件

public void save() {System.out.println("保存索引开始");// 使用两个文件,分别保存正排索引和倒排索引// 1.先判定索引的目录是否存在,不存在就创建File indexPathFile = new File(INDEX_PATH);if (!indexPathFile.exists()){indexPathFile.mkdirs();}File forwardIndexFile=new File(INDEX_PATH + "forward.txt");File invertedIndexFile=new File(INDEX_PATH + "inverted.txt");try {mapper.writeValue(forwardIndexFile, forwardIndex);mapper.writeValue(invertedIndexFile, invertedIndex);} catch (StreamWriteException e) {throw new RuntimeException(e);} catch (DatabindException e) {throw new RuntimeException(e);} catch (IOException e) {throw new RuntimeException(e);}System.out.println("保存索引结束");
}

2.15 实现加载索引

public void load() {long start = System.currentTimeMillis();System.out.println("加载索引开始");// 1.加载索引的文件路径File forwardIndexFile = new File(INDEX_PATH + "forward.txt");File invertedIndexFile = new File(INDEX_PATH + "inverted.txt");try {forwardIndex = mapper.readValue(forwardIndexFile, new TypeReference<ArrayList<DocInfo>>() {});invertedIndex = mapper.readValue(invertedIndexFile, new TypeReference<HashMap<String, ArrayList<Weight>>>() {});} catch (IOException e) {throw new RuntimeException(e);}System.out.println("加载索引结束、耗时:" + (System.currentTimeMillis() - start));
}

2.16 在Parser中调用index

在 Parser类 中创建 index 实例,并在 parserHTML 函数中 调用 index.addDoc(title, url, content) 方法进行保存操作,run函数 中调用 index.save() 方法

2.17 验证索引制作

2.18 关于索引制作速度

在程序的方法入口 run 中加上一个前后的时间戳。一步一步查看程序的性能瓶颈



我们发现方法中给还有一个递归枚举文件的时间消耗,因此也需要计算一次。



发现解析文件和索引制作是很耗时的。这些程序都是单线程执行的,且都是 CPU密集型,解析文件用的是分词库,每个html文件for循环一次就会进行分词解析,这个操作是在cpu上进行的;索引制作整个过程表面上耗时这么多,其实是这个2s包含了:枚举0.1s,解析1.9s,保存0.8s。虽然计算不完整,都是大致结果所以整个过程和磁盘无关而是在CPU的性能上出现了瓶颈。因此我们可以采用多线程的方式进行提高速度。

2.19 实现多线程制作索引

需要注意的是这里的多线程执行问题:可能会在 for 循环执行完毕了但是线程中的 parseHTML 函数没有执行完毕就会执行到后续的 index.save() 就会有错误的
因此需要等待所有线程执行完毕才执行后续代码

// 多线程制作
private void runByThread() {long beg = System.currentTimeMillis();//1.枚举出所有文件ArrayList<File> files = new ArrayList<>();enumFile(INPUT_PATH, files);// 2.循环遍历文件:线程池ExecutorService executorService = Executors.newFixedThreadPool(4);for (File f : files) {executorService.submit(new Runnable() {@Overridepublic void run() {System.out.println("开始解析:" + f.getAbsolutePath());parseHTML(f);}});}// 3.保存索引index.save();long end = System.currentTimeMillis();System.out.println("索引制作完毕耗时:" + (end - beg) + "ms");
}

到此为止,多线程的框架已经出来。我们主要看 parseHTML 是否会导致线程不安全问题即可

主要观察是否存在多个线程修改同一个对象即可

2.20 给制作索引代码加锁

这俩函数的执行只是简单的读取操作,并没有进行一些修改操作。因此再往下看 parseContent

这里涉及到文件的读取和拷贝,因为没有任何的修改操作,所以也不用担心多个线程修改同一个变量的问题。

看最后的 addDoc

这里我们发现会出现多个线程同时修改同一个变量的问题,当有很多个线程的时候,对于全局变量的正排索引和倒排索引都会有影响,因此需要加锁。

锁的粒度越小,并发程度越高。

构建正排索引加锁

构建倒排索引加锁

因为需要对圈红的代码进行加锁,所以为了简化,直接对整个 for循环代码块 进行加锁

这样加锁对吗?

我们发现加的都是同样的锁 this ,而构建正排不会影响构建倒排,所以如果是相同的锁,就会出现锁竞争的情况发生,所以我们需要不同的锁。

2.21 解决线程不退出的隐患和保证全部线程任务执行完毕

为什么呢?
这有需要理解两个概念:守护线程和非守护线程

  1. 守护线程【后台线程】:这个线程的运行状态不会影响进程结束。
  2. 非守护线程:这个线程的运行状态会影响进程结束

我们创建的默认都是 非守护线程 所以我们需要设置成守护线程,进而不影响进程的结束。
如何设置?

使用线程池的 shutdown 方法直接毁掉线程或者使用线程本身的 setDemeon(true) 设置为守护线程

// 这里使用线程池的 shutdown 方法直接毁掉线程
executorService.shutdown();
// 为了保证每一个线程执行完毕【相当于运动员到达终点后就撞线一次,裁判要等所有远动员撞线之后才能公布比赛结束】
latch.countDown();


多线程效果

单线程效果

2.22 首次制作索引比较慢的问题

优化了索引制作速度,但还有一个速度是磁盘文件的生成也很慢。我们计算一下文件生成的时间【主要是 parseHTML 函数的执行】
由于是多线程环境下,所以需要使用原子类来计算。
准备两个类成员

private AtomicLong t1 = new AtomicLong(0);
private AtomicLong t2 = new AtomicLong(0);


我们发现对于标题和URL的速度很快因此可以忽略不计,重点放在了html文件内容的读取分词上。
代码准备好之后,IDEA重启,运行run方法来模拟服务器的重启效果。
运行效果

会发现明显的第一次加载的时候速度会慢的不是一点半点儿。

名称 简写 转换
s 1s
毫秒 ms【millisecond】 1s=103ms
微秒 µs【microsecond】 1ms=103µs
纳秒 ns【nanosecond】 1µs=103ns
皮秒 ps【picosecond】 1ns=103ps

解析:13s、保存:1.3s、制作:14s、解析内容:48s、新增文档:62s
为什么会出现482s?
482s是10个线程累加的时间,62是新增一个文档需要的时间
我们在继续运行一次查看效果

解析:9s、保存:0.7s、制作:10s、解析内容:33s、新增文档:58s
这是因为 缓存:操作系统会对经常用的文件进行缓存在内存中方便后续操作的读取 。 IDEA重启后,操作系统里对之前的所有操作的缓存都会清空;而当 IDEA 第一次运行的时候会重新读磁盘、各种文件读取一个一个处理,并缓存一些数据,当第二次继续编译运行的时候就会将内存中的缓存拿出来使用而不是从磁盘中拿出来使用,从而提高IO效率。

2.23 优化文件读取速度

从上边的优化和测试后得知:速度的瓶颈在于文件处理的操作上
之前我们是一个一个字符的读取,我们可以利用 BufferedReader 设置一个缓存区来加快读取操作。

public String parseContent(File f) {// 还得准备一个保存结果的 StringBuilderStringBuilder content = new StringBuilder();// 这里需要按照字符来读try (BufferedReader reader = new BufferedReader(new FileReader(f))) {// 加上一个是否拷贝的开关boolean isCopy = true;while (true) {int read = reader.read();if (read == -1) {break;}char c = (char) read;if (isCopy) {// 开关打开状态,遇到普通字符就考呗到StringBuilder中if (c == '<') {// 遇到 '<' 就关闭拷贝isCopy = false;continue;}if (c == '\n' || c == '\r') {c = ' ';}// 其他字符,直接进行拷贝content.append(c);} else {// 开关关闭状态,暂不拷贝,直到遇到 '>' 打开if (c == '>') {isCopy = true;}}}} catch (IOException e) {throw new RuntimeException(e);}return content.toString();
}

只更改了 FileReader

2.24 验证索引加载逻辑

在 Index 类中进行打断点测试

2.25 索引模块小结

  1. 实现一个 Parser 类

    1. 通过递归枚举出全部的 html 文件
    2. 针对每个 html 文件进行解析
      1. 解析 Title
      2. 解析 URL
      3. 解析 Content:用一个开关控制字符是否拷贝
    3. 解析后添加正排索引和倒排索引
      1. 正排索引:使用 ArrayList<DocInfo> 数组长度作为新增 DocInfo 的下标,搜索时间复杂度为 O(1)
      2. 倒排索引:使用 HashMap<String, ArrayList<Weight>> 作为访问的数据结构后,时间复杂度也为 O(1)。这里先把要解析的 DocInfo 装填入 HashMap<String, WordCnt>中,然后遍历取得的 key 在 倒排中获取,因为如果没有就设为1,如果有就+1。所以把标题*10 + 内容 的出现次数相加作为最后的 权重
    4. 保存一个解析后的新增文档到文件中
    5. 在启动的时候加载本地文件到内存中
  2. 实现了一个 Index 类

    1. 查正排:直接取下标 ArrayList<DocInfo> 即可
    2. 查倒排:按照 key 获取 HashMap<String, ArrayList<Weight>> 中 value 即可
    3. 添加文档,Parser 类 构建索引的时候调用该方法
      1. 构建正排:构造 DocInfo 对象,依据 ArrayList<DocInfo> 长度作为 DocInfoID 进行添加
      2. 构建倒排:先进行标题,内容的分词。利用一个内部类 WordCnt 统计一个 DocInfo 出现的标题和内容的词频,去更新倒排索引
      3. 整个构建过程中涉及到的 多个线程修改同一个变量是应该注意线程安全问题
  3. 优化过程

    1. 单线程 vs 多线程

      1. 线程的安全性需要保证
      2. 线程资源的正常关闭
    2. 文件读取优化
      1. 增加一个文件缓存区
  4. 保存索引:把数据转换为 JSON 格式存储在文件中

  5. 加载索引:把 JSON 格式的文件读取出来,加载在内存中

3.实现搜索模块

3.1 搜索模块思路梳理

调用搜索模块,来完成搜索的核心过程

  1. 分词:针对用户输入的 查询词 进行分词【用输入的可能是一个句子,可能是一个字也可能是一个词】
  2. 触发:拿着每个分词结果去倒排索引中查询
  3. 排序:针对上面出发的结果,进行排序【按照相关性,降序排序】
  4. 包装结果:根据排序后的结果一次去查正排,获取每个文档的详细信息,包装成一定数据结构的返回给页面

3.2 创建DocSearcher类

import java.util.ArrayList;// 通过这个类,来完成整个搜索过程
public class DocSearch {// 加上对象索引的实例private Index index = new Index();// 在运行的时候就开始加载索引到内存中public DocSearch() {index.load();}// 完成整个搜索过程// 参数(输入部分)就是用户给出的查询词// 返回值(输出部分)就是搜索结果的集合public ArrayList<Result> search(String query) {// 1.【分词】根据查询词进行分词// 2.【触发】针对分词结果来查倒排// 3.【降序】针对触发的结果按照权重降序排序// 4.【包装结果】针对降序的结果返回return null;}
}

创建一个搜索结果 Result类,用来包装搜索后的返回结果

// 这个表示搜索结果
public class Result {private String title;private String url;// 描述是正文的一段摘要private String desc;public String getTitle() {return title;}public void setTitle(String title) {this.title = title;}public String getUrl() {return url;}public void setUrl(String url) {this.url = url;}public String getDesc() {return desc;}public void setDesc(String desc) {this.desc = desc;}
}

3.3 实现search方法

注意分词结果全是小写,因此需要现在把 DocInfo 的 Content 全转换为 小写

// 通过这个类,来完成整个搜索过程
public class DocSearch {// 加上对象索引的实例private Index index = new Index();// 在运行的时候就开始加载索引到内存中public DocSearch() {index.load();}// 完成整个搜索过程// 参数(输入部分)就是用户给出的查询词// 返回值(输出部分)就是搜索结果的集合public ArrayList<Result> search(String query) {// 1.【分词】根据查询词进行分词List<Term> terms = ToAnalysis.parse(query).getTerms();// 2.【触发】针对分词结果来查倒排ArrayList<Weight> allTermResult = new ArrayList<>();for (Term t : terms) {String word = t.getName();ArrayList<Weight> invertedList = index.getInverted(word);if (invertedList == null) {// 说明这个词在所有文档中不存在continue;}allTermResult.addAll(invertedList);}// 3.【降序】针对触发的结果按照权重降序排序allTermResult.sort(new Comparator<Weight>() {@Overridepublic int compare(Weight o1, Weight o2) {// 大堆升序:o1-o2;小堆降序:o2-o1return o2.getWeight() - o1.getWeight();}});// 4.【包装结果】针对降序的结果返回ArrayList<Result> results = new ArrayList<>();for (Weight w : allTermResult) {DocInfo docInfo = index.getDocInfo(w.getDocId());Result result = new Result();result.setTitle(docInfo.getTitle());result.setUrl(docInfo.getUrl());// 描述:正文的一段摘要。关键词往前截取 60,往后截取 160 个字符作为整个描述result.setDesc(GenDesc(docInfo.getContent().toLowerCase(), terms));results.add(result);}return results;}private String GenDesc(String content, List<Term> terms) {// TODO 先遍历分词结果,看看哪个结果是在 content 中存在}
}

3.4 实现生成描述

思考一个问题:List关键词能不能只查 List 而排除掉 ArrayList?

这就会导致搜索结果的不准确,类似的情况在查倒排的时候是否会存在呢?倒排索引中的 key 都是分词的结果,我们应该让 List 仅查询出 List,视 ArrayList 为一个单词即可

public class DocSearch {// 加上对象索引的实例private Index index = new Index();// 在运行的时候就开始加载索引到内存中public DocSearch() {index.load();}// 完成整个搜索过程// 参数(输入部分)就是用户给出的查询词// 返回值(输出部分)就是搜索结果的集合public ArrayList<Result> search(String query) {// 1.【分词】根据查询词进行分词List<Term> terms = ToAnalysis.parse(query).getTerms();// 2.【触发】针对分词结果来查倒排ArrayList<Weight> allTermResult = new ArrayList<>();for (Term t : terms) {String word = t.getName();ArrayList<Weight> invertedList = index.getInverted(word);if (invertedList == null) {// 说明这个词在所有文档中不存在continue;}allTermResult.addAll(invertedList);}// 3.【降序】针对触发的结果按照权重降序排序allTermResult.sort(new Comparator<Weight>() {@Overridepublic int compare(Weight o1, Weight o2) {// 大堆升序:o1-o2;小堆降序:o2-o1return o2.getWeight() - o1.getWeight();}});// 4.【包装结果】针对降序的结果返回ArrayList<Result> results = new ArrayList<>();for (Weight w : allTermResult) {DocInfo docInfo = index.getDocInfo(w.getDocId());Result result = new Result();result.setTitle(docInfo.getTitle());result.setUrl(docInfo.getUrl());// 描述:正文的一段摘要。关键词往前截取 60,往后截取 160 个字符作为整个描述result.setDesc(GenDesc(docInfo.getContent().toLowerCase(), terms));results.add(result);}return results;}private String GenDesc(String content, List<Term> terms) {// 先遍历分词结果,看看哪个结果是在 content 中存在int firstPos = -1;for (Term t : terms) {String word = t.getName();// 此处需要的是 “全字匹配” 让 word 独立成词,查找出来而不是只作为词的一部分firstPos = content.indexOf(" " + word + " ");if (firstPos >= 0) {break;}}// 所有的分词结果都不在正文中存在,因此这是属于比较模糊的情况,应该会返回一个 空描述 或者 直接截取正文的 前 160 个字符if (firstPos == -1) {return content.substring(0, 160) + "...";}// 从 firstPost 作为基准位置,前找 60,后找 160String desc = "";int descBeg = firstPos < 60 ? 0 : firstPos - 60;if (descBeg + 160 > content.length()) {desc = content.substring(descBeg) + "...";} else {desc = content.substring(descBeg, descBeg + 160) + "...";}return desc;}
}

3.5 简单验证

public static void main(String[] args) {DocSearch docSearch = new DocSearch();Scanner scanner = new Scanner(System.in);while (true) {System.out.print("-->");String query = scanner.nextLine();ArrayList<Result> results=docSearch.search(query);for (Result r:results ) {System.out.println(r);}}
}



原因

因为 parseContent 仅仅是通过 ‘<>’ 来标签的,遇到 js 之后仅仅是把 <script><\script> 给去掉了,而 <script>xxx<script> 内容 xxx 则没被去掉

因此现在问题就是如何去掉 xxx 呢?
就是使用正则表达式来实现效果。

3.6 使用正则表达式

Java 中的 String 方法很多都是支持正则的【indexOf,replaceAll,replace,split…】

元字符 作用 举例
. 匹配非换行【非 \r、\n】
* 前面的字符可以出现 >=0 次【.*:表示匹配非换行字符若干次】
+ 前面的字符可以出现 >=1 次
? 前面的字符可以出现 0 次 或 1 次
() 匹配一个集合 (z|f)ood 匹配 zood 或者 food
{n,m} 前面的字符可以出现次数大于等于n,小于等于m o{1,3} 匹配fooooood 前三个o,数字之间只能逗号不能空格
[abc] 匹配任意含有 a,b,c 的字符
[^abc] 匹配非a,非 b,非c
.*? 非贪婪匹配:匹配到符合条件的最短结果 <div>aaa</div> <div>bbb</div>:只匹配到4个标签,替换也只是替换标签不替换内容
.* 贪婪匹配,匹配到符合条件最长的结果 <div>aaa</div> <div>bbb</div>:匹配整个正文,替换也就把正文替换掉了
\s 匹配任意空白字符包含 \r \n \t \v \f
\S 匹配非空字符
\b 匹配一个单词边界,也就是指单词和空格间的位置 ‘er\b’ 可以匹配"never" 中的 ‘er’,但不能匹配 “verb” 中的 ‘er’
修饰符 作用
i ignore,表示忽略大小写的匹配

3.7 替换script标签及其内容

知道了正则表达式后。
去掉 script 标签和内容:<script.*?>(.*?)
去掉普通标签(不去掉内容)<.*?>既能匹配到开始标签又能匹配到结束标签
可以提前用正则在线测试工具检测自己的正则语句

非贪婪匹配结果:只替换了标签,正文内容被保留下来

贪婪匹配结果:整个正文内容全被替换

知道了正则该如何写之后就可以对 parseContent 进行替换
这里需要注意的是:一定要先替换 script 标签,再替换普通的 html 标签。如果顺序反了之后会导致先去掉 html 标签,script 标签的东西还存在并且后续的 script ,结局就是改了和没改一样。

    // 这个解析是基于正则表达式,实现去标签和去scriptpublic String parseContentByRegex(File f) {// 1.先把整个文件读取到 String 里面String content = readFile(f);// 2.换掉 script 标签content = content.replaceAll("<script.*?>(.*?)</script>", " ");// 3.换掉普通的 html 标签content = content.replaceAll("<.*?>", " ");return content;}private String readFile(File f) {StringBuilder stringBuilder = new StringBuilder();try (BufferedReader reader = new BufferedReader(new FileReader(f))) {while (true) {int ret = reader.read();if (ret == -1) {break;}char c = (char) ret;if (c == '\n' || c == '\r') {c = ' ';}stringBuilder.append(c);}return stringBuilder.toString();} catch (IOException e) {throw new RuntimeException(e);}}

协议俄国测试函数,检测一下 ArrayList 的去 script 标签效果

ok,对比之前的函数解析正文内容说明已经成功达到目标效果

3.8 合并多个空格

问题又来了,我们发现有太多的空格,我们不需要这么多空格,因此需要合并多个空格
还是继续使用正则

\s*:即使没有匹配到空格,我也替换空格,这无中生有不科学
\s+:匹配到了至少1个空格,就开始替换,更合理一些
\s?:也会出现和*一样的bug,如果出现0次也会被替换就会出现bug,如果出现1次就被替换这就是目的结果,因此这样的逻辑也不完善

注意 java 正则规则需要转义 \ ,因此多加一个 \

public String parseContentByRegex(File f) {// 1.先把整个文件读取到 String 里面String content = readFile(f);// 2.换掉 script 标签content = content.replaceAll("<script.*?>(.*?)</script>", " ");// 3.换掉普通的 html 标签content = content.replaceAll("<.*?>", " ");content = content.replaceAll("\\s+", " ");return content;
}


效果已经完善。仔细观察后边的的,会发现还有 &nbsp; 这样的 html 中的空白占位符,我们也需要替换掉。因此在合并多个空格之前去掉 &nbsp;

代码完善之后就开始最后的替换掉之前使用的 parseContent

在验证一下整个 run 方法能否执行顺利

使用正则之后的优化速度还快了几秒钟的时间【解析正文快了2秒,新增文档快了7秒】
在对 Index 类打断点验证一下是否解析正确

3.9 搜索模块小结

到这里,我们已经实现好了搜索模块的需求。这里做一个总结。

  1. 创建一个 DocSearch

    1. 程序入口是在 search(String query) ,根据参数分词后的结果在倒排索引中查询指定文档信息。这个参数就是倒排索引中数据结构中的 key,然后把查询得到全部信息装填在一个新的 ArrayList<Weight> 并降序,最后封装返回结果 ArrayList<Result> 集合。
    2. 生成描述 GenDesc
      1. 根据分词后的关键词在内容中的位置进行往前60到往后160之间做一个截取操作
  2. 优化 Parser类parseContent 方法。
    1. 先取消掉 js 代码,再取消带 &nbsp;,最后合并多个空格

在以后的实际开发中,技术都是为了业务服务的,更重要的是也要学习产品的业务

4.实现web模块

4.1 约定前后端交互接口

现在后台的逻辑,数据都有了。现在需要最终以网页的形式把我们的程序呈现给用户
前端(HTML+CSS+JS)+后端(Java,Servlet/Spring)
现在我们需要名的描述出,服务器接受什么样的请求都能返回什么样的响应。此处,我们需要是一个接口,搜索接口即可

请求:GET /search?query=查询词 HTTP/1.1
响应:
HTTP/1.1 200 OK
{{title: "标题,url: "链接",desc: "描述"},{title: "标题,url: "链接",desc: "描述"},{title: "标题,url: "链接",desc: "描述"}
}

Java代码

package api;import com.fasterxml.jackson.databind.ObjectMapper;
import search.DocSearch;
import search.Result;import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.ArrayList;@WebServlet("/search")
public class DocSearchServlet extends HttpServlet {// 此处的 search.DocSearch 对应也应该是全局唯一的,此处就给一个 static 修饰private static DocSearch docSearch=new DocSearch();private ObjectMapper mapper=new ObjectMapper();@Overrideprotected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {// 1.先解析请求,拿到用户的查询词req.setCharacterEncoding("UTF-8");resp.setContentType("application/json; charset=UTF-8");String query = req.getParameter("query");System.out.println(query);if (query == null || query.isEmpty()) {String msg = "您的参数非法,没有获取到 query 值";resp.sendError(404, msg);return;}// 2.打印记录一下 query 值System.out.println("query:" + query);// 3.调用搜索模块进行搜索ArrayList<Result> results=docSearch.search(query);// 4.把当前的搜索结果进行包装响应给前端mapper.writeValue(resp.getWriter(), results);}
}

验证一下,我们需要的数据都在,现在需要把它放在 html 页面上

4.2 实现页面样式(1)

这是大致的 HTML 代码

<!DOCTYPE html>
<html lang="zh"><head><meta charset="UTF-8"><title>Java 文档搜索</title>
</head><body><!-- 通过 .container 表示整个页面元素的容器 --><div class="container"><!-- 1.搜索框 + 搜索按钮 --><div class="header"><input type="text"><button id="search-btn">搜索</button></div><!-- 2.显示搜搜结果 --><div class="result"><div class="item"><a href="#">title</a><div class="url">https://www.baidu.com</div><div class="desc">desc</div></div></div></div>
</body>
</html>

4.3 实现页面样式(2)

实现简陋的CSS样式

<!DOCTYPE html>
<html lang="zh"><head><meta charset="UTF-8"><title>Java 文档搜索</title>
</head><body><!-- 通过 .container 表示整个页面元素的容器 --><div class="container"><!-- 1.搜索框 + 搜索按钮 --><div class="header"><input type="text"><button id="search-btn">搜索</button></div><!-- 2.显示搜搜结果 --><div class="result"><div class="item"><a href="#">title</a><div class="url">https://www.baidu.com</div><div class="desc">desc</div></div></div></div><style>/* 先去掉浏览器的默认样式*/* {margin: 0px;padding: 0px;box-sizing: border-box;}/* 给整体页面指定一个高度(和浏览器窗口一样高) */html,body {height: 100%;/* 设置背景图:不需要字符串 */background-image: url(./img/bg.png);/* 不平铺 */background-repeat: no-repeat;/* 设置背景图位置 */background-position: center center;/* 设置背景图大小 */background-size: cover;}.container {/* 此处的宽度也可以设置成百分数 */width: 1200px;;height: 100%;/* 设置水平居中 */margin: 0 auto;/* 设置背景色,让版心和背景图能够区分开*/background-color: rgba(255, 255, 255, 0.8);/* 设置圆角矩形 */border-radius: 10px;/* 设置内边距:避免文字紧贴 */padding: 10px;}.header {width: 100%;height: 50px;display: flex;justify-content: space-around;align-items: center;}.header>input {width: 1050px;height: 50px;font-size: 22px;line-height: 50px;padding-left: 10px;border-radius: 10px;}.header>button {width: 100px;height: 50px;background-color: rgb(42, 107, 205);font-size: 22px;line-height: 50px;border-radius: 10px;border: none;}.header>button:active {background-color: gray;}.item {width: 100%;margin-top: 20px;}.item a {display: block;height: 40px;font-size: 22px;line-height: 40px;font-weight: 700;color: rgb(42, 107, 205);}.item .desc {font-size: 18px;}.item .url {font-size: 18px;color: rgb(0, 128, 0);}</style>
</body></html>

4.4 通过ajax获取搜索结果

到此为止,页面的大概布局已经完成,现在需要获取后端数据来填充网页了。
ajax 前后端交互的常用手段,当用户点击搜索按钮的时候,浏览器就会获取到搜获框内容,基于 ajax 构造 HTTP 请求并发送给服务器,浏览器获取到服务器响应结果后再根据结果的 json 数据 把页面给生成出来
JS 是原生的 ajax,是 XMLHttpRequest,就可以采取其它方式来使用 ajax(借助第三方库),JQuery(js的第三方库,这个库里功能很多,单单是使用 ajax 即可)

如何使用JQuery?

搜索 JQuery,找到 JQuery 的官方然后下载即可

这里选用的压缩版本

4.5 根据相应数据构造页面内容

先验证代码是否获取到搜索框内容
如果不验证,后续代码如果拿不到搜索框的内容将会出现获取不到值也就无法搜索

<script>let button=document.querySelector("#search-btn");button.onclick = function(){let input=document.querySelector(".header input");// js 获取元素的值是 value,不加括号【JQuery 需要加上括号】let query=input.value;console.log(query);}
</script>


验证成功后再构造一个 ajax 请求发给服务器

$:是一个变量名,这个是 JQuery 这个库提供的一个内置的对象的变量名,使用的 JQuery 中的函数/方法,其实就是这个 $ 对象提供的
有的语言允许使用 $ 作为变量名(Java/JS),有的不允许(C/C++)

先测试一下后端服务器是否正常相应前端 ajax 发送的 HTTP 请求
如果不验证,不知道这个 ajax 请求是否正常发送

<script src="./js/jquery-3.6.0.min.js"></script>
<script>let button=document.querySelector("#search-btn");button.onclick = function(){let input=document.querySelector(".header input");// js 获取元素的值是 value,不加括号【JQuery 需要加上括号】let query=input.value;console.log(query);// 然后构造一个 ajax 请求发给服务器jQuery.ajax({type: "GET",url: "search?query=" + query,// success:这个函数会在请求成功后调用,data 参数就表示拿到的服务器响应的数据;status 参数表示 HTTP 状态码success: function(data, status){console.log(data);// 利用 DOM API 把数据填充到 html 中// buildResult(data);}});}
</script>


这一步代码没有问题,前后端的数据交互已经初步完成
细心的朋友会看到 url 中出现的两个 \\

其实这个只是浏览器显示的问题,当初我发现的时候以为前边的 parseUrl 写错了。检查半天也没觉得不妥的地方,后来往下继续完成的时候发现仅仅是浏览器转义字符的问题,数据方面是完全正确的

下一步就是我们利用 DOM API 把数据填充到 html 中

4.6 验证页面效果

完善 buildResult(data) 函数

// 通过这个函数构造响应数据或页面内容
function buildResult(data) {let result = document.querySelector(".result");// 要做的工作就是便利 data 中的每个数据元素,针对每个元素创建一个 div.item 然后把 title,url,content 都构造成 html 元素,然后再把这个 div.item 给加入到 div.result 中for (let item of data) {// 构造 div.itemlet itemDiv = document.createElement("div");itemDiv.className = "item";// 构造 titlelet title = document.createElement("a");title.innerHTML = item.title;title.href = item.url;itemDiv.appendChild(title);// 构造 urllet url = document.createElement("div");url.className = "url";url.innerHTML = item.url;itemDiv.appendChild(url);// 构造 desclet desc = document.createElement("div");desc.className = "desc";desc.innerHTML = item.desc;itemDiv.appendChild(desc);// 添加 div.item 到 div.resultresult.appendChild(itemDiv);}
}


针对内容太多,超出屏幕问题
可以设置 CSS 的 overflow:aauto 属性让超过的部分隐藏


针对点击之后搜索结果停留在当前页的修改
利用 DOM API 设置 title的 taret="_blank"即可

针对多次搜搜结果重叠在一起
每次点击按钮,都是宝所结果往 .result 中进行累加,没有清理过,更合理的做法应该是在搜索前把之前的的搜搜结果清空掉

4.7 实现标红逻辑

想把用户的关键词在搜索的页面中全部标红显示。需要前后端配合

  1. 修改后端代码,生成搜索结果的时候(GenDesc描述)就需要把其中包含关键词的部分你给加上一个 <i></i> 标签
  2. 前端这里针对 <i></i> 标签设置样式进行标红

Java 代码

private String GenDesc(String content, List<Term> terms) {// 先遍历分词结果,看看哪个结果是在 content 中存在int firstPos = -1;for (Term t : terms) {String word = t.getName();// 此处需要的是 “全字匹配” 让 word 独立成词,菜肴查找出来而不是只作为词的一部分firstPos = content.indexOf(" " + word + " ");if (firstPos >= 0) {break;}}// 所有的分词结果都不在正文中存在,因此这是属于比较模糊的情况,应该会返回一个 空描述 或者 直接截取正文的 前 160 个字符if (firstPos == -1) {return content.substring(0, 160) + "...";}// 从 firstPost 作为基准位置,前找 60,后找 160String desc = "";int descBeg = firstPos < 60 ? 0 : firstPos - 60;if (descBeg + 160 > content.length()) {desc = content.substring(descBeg) + "...";} else {desc = content.substring(descBeg, descBeg + 160) + "...";}// 在此处加上一个替换操作,把描述中的和分词结果相同的成分加上 <i>标签 可以通过 replace 的方式来实现for (Term t : terms) {String word = t.getName();// 此处应该继续进行全词匹配,由于 word 已经变为小写,这里应该不区分大小写替换所以需要 (?i)desc = desc.replaceAll("(?i) " + word + " ", "<i> " + word + " </i>");}return desc;
}


CSS 代码

.item .desc i{color: red;/* 去掉斜体 */font-style: normal;
}

4.8 测试更复杂的查询词

测试一些稀奇古怪的查询词


发现报了 500 错误。查看源头是越界异常的问题。
原来的代码

因此忘记了越界的情况,修改如下

修改效果图,已经完善了。

![在这里插入图片描述](https://img-blog.csdnimg.cn/7ae680be308c47538587508aa5ad3439.png

我们发现搜索 Array List 为何也会出现 一个标红的也没有 呢?

其实这里的原因是因为 Array List 中间的空格导致的,如果以 空格 来查询的话,那查询的数据会很多了。

因为空格就相当于汉语中的 我,的,是,用,好,有… 等等这些常用的词,英语中对应的是 is,a,yes,yeah,ok… 这些常用词。因此就需要使用一个叫做 “暂停词”的词表

4.9 处理停用词

这里去搜索关键词 暂停词 就会出现很多。下载保存即可。然后再使用 HashSet 把这些词存储起来,再针对分词结果在停用词表中进行筛选。如果某个词在词表中存在就直接干掉。



完整 Search 类代码

package search;import org.ansj.domain.Term;
import org.ansj.splitWord.analysis.ToAnalysis;import java.io.BufferedReader;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.IOException;
import java.util.*;// 通过这个类,来完成整个搜索过程
public class DocSearch {// 停用词路径private static final String STOP_WORDS_PATH = "D:/Programme/Java/5.Spring/DocSearch/src/main/webapp/index/" + "stop_words.txt";// 使用 HashSet<String> 保存停用词private static HashSet<String> stopWords = new HashSet<>();// 加上对象索引的实例private Index index = new Index();// 在运行的时候就开始加载索引到内存中public DocSearch() {index.load();loadStopWords();}// 完成整个搜索过程// 参数(输入部分)就是用户给出的查询词// 返回值(输出部分)就是搜索结果的集合public ArrayList<Result> search(String query) {// 1.【分词】根据查询词进行分词List<Term> oldTerms = ToAnalysis.parse(query).getTerms();List<Term> terms = new ArrayList<>();// 针对暂停词进行过滤for (Term t : oldTerms) {if (stopWords.contains(t.getName())) {continue;}terms.add(t);}// 2.【触发】针对分词结果来查倒排ArrayList<Weight> allTermResult = new ArrayList<>();for (Term t : terms) {String word = t.getName();ArrayList<Weight> invertedList = index.getInverted(word);if (invertedList == null) {// 说明这个词在所有文档中不存在continue;}allTermResult.addAll(invertedList);}// 3.【降序】针对触发的结果按照权重降序排序allTermResult.sort(new Comparator<Weight>() {@Overridepublic int compare(Weight o1, Weight o2) {// 大堆升序:o1-o2;小堆降序:o2-o1return o2.getWeight() - o1.getWeight();}});// 4.【包装结果】针对降序的结果返回ArrayList<Result> results = new ArrayList<>();for (Weight w : allTermResult) {DocInfo docInfo = index.getDocInfo(w.getDocId());Result result = new Result();result.setTitle(docInfo.getTitle());result.setUrl(docInfo.getUrl());// 描述:正文的一段摘要。关键词往前截取 60,往后截取 160 个字符作为整个描述result.setDesc(GenDesc(docInfo.getContent().toLowerCase(), terms));results.add(result);}return results;}private String GenDesc(String content, List<Term> terms) {// 先遍历分词结果,看看哪个结果是在 content 中存在int firstPos = -1;for (Term t : terms) {String word = t.getName();// 此处需要的是 “全字匹配” 让 word 独立成词,菜肴查找出来而不是只作为词的一部分firstPos = content.indexOf(" " + word + " ");if (firstPos >= 0) {break;}}// 所有的分词结果都不在正文中存在,因此这是属于比较模糊的情况,应该会返回一个 空描述 或者 直接截取正文的 前 160 个字符if (firstPos == -1) {if (content.length() > 160) {return content.substring(0, 160) + "...";}}// 从 firstPost 作为基准位置,前找 60,后找 160String desc = "";int descBeg = firstPos < 60 ? 0 : firstPos - 60;if (descBeg + 160 > content.length()) {desc = content.substring(descBeg) + "...";} else {desc = content.substring(descBeg, descBeg + 160) + "...";}// 在此处加上一个替换操作,把描述中的和分词结果相同的成分加上 <i>标签 可以通过 replace 的方式来实现for (Term t : terms) {String word = t.getName();// 此处应该继续进行全词匹配,由于 word 已经变为小写,这里应该不区分大小写替换所以需要 (?i)desc = desc.replaceAll("(?i) " + word + " ", "<i> " + word + " </i>");}return desc;}public void loadStopWords() {try (BufferedReader reader = new BufferedReader(new FileReader(STOP_WORDS_PATH))) {while (true) {String line = reader.readLine();if (line == null) {break;}stopWords.add(line);}} catch (IOException e) {throw new RuntimeException(e);}}public static void main(String[] args) {DocSearch docSearch = new DocSearch();Scanner scanner = new Scanner(System.in);while (true) {System.out.print("-->");String query = scanner.nextLine();ArrayList<Result> results = docSearch.search(query);for (Result r : results) {System.out.println(r);}}}
}

再搜索 Array List 则不会出现那样的情况

4.10处理生成描述的bug

我们在搜索 ArrayList 会发现还是有:既没有 Array 也没有 List 的情况发生,那么这可能是我们的后端代码的BUG了
我们点标题链接进去查看一下是什么原因

查看网页源代码

发现关键词 Array 是包含在 div 之中的,但是却没有出现在描述中

再看描述的开头

再看线上文档的开头

现在应该知道问题在哪儿了

因为找到了 array 关键字。而在原文档中是 array)

而代码中为了达到全词匹配的效果采用的是 firstPos = content.indexOf(" " + word + " "); 所以就找不到,触发下面的代码
return content.substring(0, 160) + "...";


这也就是我们看的一个标红的也没有的原因,但是也查到了 Array 关键词
因此还是需要使用 正则表达式 来去除
正则在线测试工具
使用 \b 来代替空格实现全词匹配,实际效果更好

但是不能全部使用 \b 来代替

因为 indexOf 不支持 正则表达式


解决方案:位置问题转为已知问题

提前先把 关键词周围的标点,符号全部转为空格,在进行之前的全词查找即可【经过转化之后就可以使用了】

查看效果,已经纠正之前的bug了,出现的结果一定会被标红,标红的结果一定会出现。

4.11 加上搜索结果的个数

正常搜索引擎都会有一个搜索结果的统计。这里我们也加上一个。

有两个方案

  1. 直接在服务器这边算好了个数,返回给浏览器【及需要修改前端有需要修改后端】
  2. 在浏览器这边根据收到的结果的数组的的长度自动地展示出个数【只需要修改前端】

因此我们选择方案2简单


效果如图所示

4.12 关于重复文档的问题

我们看一下查询个数




是否可能存在某个文档同时包含 array 和 list 呢?我们就用集合类的接口 Collections 来举例


前面计算权重的时候,都是对 query 进行了分词。举例:
query=array list 则会被分为 array空格list 三部分。经过暂停词的过滤之后只有 arraylist 。由于 arraylist 都实现了 Collections 接口,因此会出现两次相同的结果。
正常来说,对于同一个结果不应该出现两次分重复的搜索结果,像 Collections 这样意的文档味着权重更高,我们提高权重之后相关性也就会更高。

一个简单的办法就是把权重进行相加

要实现这样的效果就需要把触发结果进行合并,把多个分词结果触发的文档按照 dociId 进行去重,同时进行权重的合并。
数据结构中有一个经典的题目就是 合并两个有序链表 此处我们就可以模仿类似的思路进行合并两个相同数组:先把统计结果按照 docId 升序排序,再合并的时候相同 docId 的就可以进行权重相加。此时我们可能还需要和合并 N个 数组。

利用优先级队列,建立大根堆,那么就会存储的是最小的堆顶元素,进行 N 个合并了。

需要改动一下的代码

package search;import org.ansj.domain.Term;
import org.ansj.splitWord.analysis.ToAnalysis;import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.util.*;// 通过这个类,来完成整个搜索过程
public class DocSearch {// 停用词路径private static final String STOP_WORDS_PATH = "D:/Programme/Java/5.Spring/DocSearch/src/main/webapp/index/stop_words.txt";// 使用 HashSet<String> 保存停用词private static HashSet<String> stopWords = new HashSet<>();// 加上对象索引的实例private Index index = new Index();// 在运行的时候就开始加载索引到内存中public DocSearch() {index.load();loadStopWords();}// 完成整个搜索过程// 参数(输入部分)就是用户给出的查询词// 返回值(输出部分)就是搜索结果的集合public ArrayList<Result> search(String query) {// 1.【分词】根据查询词进行分词List<Term> oldTerms = ToAnalysis.parse(query).getTerms();List<Term> terms = new ArrayList<>();// 针对暂停词进行过滤for (Term t : oldTerms) {if (!stopWords.contains(t.getName())) {terms.add(t);}}// 2.【触发】针对分词结果来查倒排ArrayList<ArrayList<Weight>> termResult = new ArrayList<>();for (Term t : terms) {String word = t.getName();ArrayList<Weight> invertedList = index.getInverted(word);if (invertedList != null) {// 说明这个词在所有文档中存在termResult.add(invertedList);}}// 2.1【合并】针对多个分词结果触发出的相同文档进行权重合并ArrayList<Weight> allTermResult = mergeResult(termResult);// 3.【降序】针对触发的结果按照权重降序:小堆,后-前allTermResult.sort(new Comparator<Weight>() {@Overridepublic int compare(Weight o1, Weight o2) {// 大堆升序:o1-o2;小堆降序:o2-o1return o2.getWeight() - o1.getWeight();}});// 4.【包装结果】针对降序的结果返回ArrayList<Result> results = new ArrayList<>();for (Weight w : allTermResult) {DocInfo docInfo = index.getDocInfo(w.getDocId());Result result = new Result();result.setTitle(docInfo.getTitle());result.setUrl(docInfo.getUrl());// 描述:正文的一段摘要。关键词往前截取 60,往后截取 160 个字符作为整个描述result.setDesc(GenDesc(docInfo.getContent().toLowerCase(), terms));results.add(result);}return results;}/*** 在进行合并的时候,是把多个行合并成一行* 合并过程中势必使需要操作这个二维数组里面的每个元素* 操作就涉及到 行,列 这样的概念,就需要明确的知道 行 和 列*/static class Pos {public int row;public int col;public Pos(int row, int col) {this.row = row;this.col = col;}}private ArrayList<Weight> mergeResult(ArrayList<ArrayList<Weight>> source) {// 1.针对每一行进行排序(按照 docId 进行升序)for (ArrayList<Weight> curRow : source) {curRow.sort(new Comparator<Weight>() {@Overridepublic int compare(Weight o1, Weight o2) {return o1.getDocId() - o2.getDocId();}});}// 2.用优先队列,针对这些进行合并【升序:大堆;降序:小堆】PriorityQueue<Pos> minHeap = new PriorityQueue<>(new Comparator<Pos>() {@Overridepublic int compare(Pos o1, Pos o2) {// 先根据 Pos 获取对应的 Weight 对象,再根据 Weight 的 docId 来升序Weight w1 = source.get(o1.row).get(o1.col);Weight w2 = source.get(o2.row).get(o2.col);return w1.getDocId() - w2.getDocId();}});// 初始化优先队列,把每一行的第一个元素放到队列中ArrayList<Weight> target = new ArrayList<>();for (int row = 0; row < source.size(); row++) {minHeap.offer(new Pos(row, 0));}// 循环的取队首元素(也就是当前这若干行中最小的元素)while (!minHeap.isEmpty()) {Pos minPos = minHeap.poll();Weight curWeight = source.get(minPos.row).get(minPos.col);if (target.size() > 0) {// 去除上次插入的元素Weight lastWeight = target.get(target.size() - 1);if (lastWeight.getDocId() == curWeight.getDocId()) {// 遇到相同的就进行权重合并lastWeight.setWeight(lastWeight.getWeight() + curWeight.getWeight());} else {// 不相同,就把 curWeight 插入到 target 末尾target.add(curWeight);}} else {// 如果 target 当前是空的,就直接插入即可target.add(curWeight);}// 当前元素处理完之后要处理这一行的下一个元素Pos newPos = new Pos(minPos.row, minPos.col + 1);if (newPos.col < source.get(newPos.row).size()){// 如果移动光标之后,没超出了这一行的列数,继续入队列minHeap.offer(newPos);}}return target;}private String GenDesc(String content, List<Term> terms) {// 先遍历分词结果,看看哪个结果是在 content 中存在int firstPos = -1;for (Term t : terms) {String word = t.getName();// 此处需要的是 “全字匹配” 让 word 独立成词,菜肴查找出来而不是只作为词的一部分content = content.toLowerCase().replaceAll("\\b" + word + "\\b", " " + word + " ");firstPos = content.indexOf(" " + word + " ");
//            firstPos = content.indexOf("\\b" + word + "\\b");if (firstPos >= 0) {break;}}// 所有的分词结果都不在正文中存在,因此这是属于比较模糊的情况,应该会返回一个 空描述 或者 直接截取正文的 前 160 个字符if (firstPos == -1) {if (content.length() > 160) {return content.substring(0, 160) + "...";}}// 从 firstPost 作为基准位置,前找 60,后找 160String desc = "";int descBeg = firstPos < 60 ? 0 : firstPos - 60;if (descBeg + 160 > content.length()) {desc = content.substring(descBeg) + "...";} else {desc = content.substring(descBeg, descBeg + 160) + "...";}// 在此处加上一个替换操作,把描述中的和分词结果相同的成分加上 <i>标签 可以通过 replace 的方式来实现for (Term t : terms) {String word = t.getName();// 此处应该继续进行全词匹配,由于 word 已经变为小写,这里应该不区分大小写替换所以需要 (?i)desc = desc.replaceAll("(?i) " + word + " ", "<i> " + word + " </i>");}return desc;}public void loadStopWords() {try (BufferedReader reader = new BufferedReader(new FileReader(STOP_WORDS_PATH))) {while (true) {String line = reader.readLine();if (line == null) {break;}stopWords.add(line);}} catch (IOException e) {throw new RuntimeException(e);}}public static void main(String[] args) {DocSearch docSearch = new DocSearch();}
}

记得一定要验证权重合并:在此搜索 array list 然后浏览器查询 Collections 关键字就只会有一个标题出现了

5.部署的准备工作

如果服务器还未购买的可以先看我的博客,介绍了 阿里云服务器的购买及搭建一个博客园的流程


有了 JDKTomcat 环境之后就把生成的 war 包放在服务器上即可自动解压
利用的 xshell 可以直接把 war 包拖入 tomcat 的 webapps 目录即可

验证一下成功没有

然后我们再放 正倒排索引和暂停词 的文件【注意源代码中修改路径】

更改 Index 代码路径

更改 DocSearch 代码路径

云服务器测试通过,至此为止已经完成项目了。

6.改成SpringBoot

把当前 Servlet 版本的程序改进成 Spring 版本。
现下载创建 Spring 项目的插件 Spring Initializr adn Assistant





然后更新一下 Maven

6.1代码拷贝到新项目

  • 拷贝 pom.xml 中的依赖
  • 拷贝 Java 代码
  • 拷贝 webapp 目录下的资源


拷贝整个 Search包 和 Spring 的启动类 DocSearchApplication 同级目录

再拷贝 webapp 目录下的资源

6.2实现Controller

package App.search.controller;import App.search.DocSearch;
import App.search.Result;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;import java.util.ArrayList;@RestController
public class DocSearchController {private static DocSearch search=new DocSearch();private static ObjectMapper mapper=new ObjectMapper();@RequestMapping("/search")public String search(@RequestParam("query") String query) throws JsonProcessingException {// 参数是查询词,返回值是响应内容ArrayList<Result> results=search.search(query);return mapper.writeValueAsString(results);}
}

6.3线上线下路径切换

当启动 Spring 的时候却发现找不到路径

因此我们设置一个配置文件,在本地运行的时候就设置为本地路径;在服务器上运行的时候就设置为线上路径


先测试本地

发现了一丝不妙的情况


抓包之后发现是数据的格式对不上,如果是 text/plain 则前端会被认为是一个 字符串 ,因此我们需要在后端的数据返回的时候设置为 application/json 格式或者前端代码把 data 转为 json格式【这里采用后端修改】

前端修改应该修改 data【可查看JSON.Stringfy()】
修改后段的结果,程序正常显示

6.4部署到云服务器

修改配置文件,资源加载路径进行切换

在继续利用 Maven 打包却发现没有通过测试,因为路径不存在

添加 pom.xml 代码如下就可以在测试出错的情况下也完成编译【忽略Test单元测试】

<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-surefire-plugin</artifactId><configuration><testFailureIgnore>true</testFailureIgnore></configuration>
</plugin>


上传至服务区

再启动之前一定要记得,先确定对应的端口号(默认是8080是否已经被占用),一个主机上的一个端口通常情况下只能被一个进程来绑定。
解决方案

  1. 关闭现有的8080端口
  2. 修改本程序的启动端口

这里我们选择关闭之前的 8080 端口

// 查看当前服务器哪些进程占用8080端口
netstat -anp | grep 8080
// 关闭 8080 端口的进程
kill -9 1838

kill命令,netstat命令

启动程序

java -jar jar包名称


验证效果

部署也已经完成了
但是也有瑕疵,还没完成。
当我们把 xShell 关闭之后就会发现数据无响应


这里涉及到一个概念:前台进程 vs 后台进程

这里和 Java 中的 前台线程 和 后台线程【isDaemon】没有关系

直接输入一个命令来产生的进程都是 前台进程,前台进程会随着终端的关闭而随之被杀死

ps命令

查看后发现 java 产生的进程运行时间为 0
因此需要把这个前台进程转换为后台进程
nohup命令

在这里,我们会多出一个日志文件 nohup.out

终端不显示,而是把内容输出到文件中。我们再关闭 xShell 看看能否成功

已经成功,如果发现上述操作失败的了。可以重新把前台进程转换为后台进程就可以了。可能是服务器卡的原因。

7. 项目总结

项目链接

  1. 索引模块
    Parser 类完成制作索引的流程;Index类实现索引的数据结构
  2. 搜索模块
    Search 类来完成搜索的整个过程,调用了 Index 类来查正排查倒排。同时也实现了生成描述,关键词标红,重复文档合并等功能

核心内容:通过一些数据结构来完成了一个搜索引擎最小功能的集合

  1. Web 模块
    通过 Servlet/Spring Boot 实现了两个版本的服务器程序;通过 HTML/CSS/JS 做了一个搜索页面

已经设置免费下载,至此完整代码如下 下载

Servlet的修改项

其中 Parser类 并不是一定要设置,因为只是作为一个索引制作的类,制作成功之后的项目启动并不会依赖它
Spring版本的修改

源码下载

Java制作JDK8文档搜索引擎项目并部署到阿里云服务器相关推荐

  1. 如何将nodejs项目程序部署到阿里云服务器上

    将nodejs项目程序部署到阿里云服务器上 一.概述 二.具体步骤 1.拥有自己的服务器 2.下载Xshell 3. oneinstack配置web环境 4. XShell连接远程主机 5.更新系统软 ...

  2. django项目如何部署到阿里云服务器

    django项目如何部署到阿里云服务器 阿里云服务器购买 在阿里云上搭建项目及安装数据库 使用Git bash将本地项目文件推送到github远程仓库 将github仓库导入阿里云 安装依赖 安装数据 ...

  3. 【自用】VUE项目 宝塔部署 上线阿里云服务器CentOS7.6

    一.给VUE项目打包 1.开始打包 运行命令: npm run build 2.找到打包好的 dist 文件夹 要记住这个dist文件放在了哪儿,记住哦! 二.服务器端安装宝塔面板 1.进入root用 ...

  4. 【项目总结】基于SpringBoot+Ansj分词+正倒排索引的Java文档搜索引擎项目总结

    文章目录 项目介绍(开发背景) 主要用到的技术点 前端 后端 Ansj分词 实现索引模块 实现Parser类 实现Index类 完善Parser类 优化制作索引速度 实现搜索模块 实现DocSearc ...

  5. SpringBoot 部署: 项目打包 手动部署到阿里云服务器上

    SpringBoot 部署: 项目打包 & 手动部署到阿里云服务器上 文章目录 SpringBoot 部署: 项目打包 & 手动部署到阿里云服务器上 前言 正文 1. 环境准备 &am ...

  6. 保姆级教程——将springboot项目部署到阿里云服务器(小白包会)

    保姆级教程--将springboot项目部署到阿里云服务器(小白包会) 前言: 昨天本想着看论文,结果发现找的论文和课题不一致.那干点什么好呢?那就把我的毕业设计(一个springboot项目)部署到 ...

  7. Django项目部署到阿里云服务器及后台常驻

    本文将记述如何简单的将自己的Django项目部署到阿里云服务器上. 准备条件: 阿里云服务器:centos 7.5 Nginx(反向代理) Python 3.7.3 Django 2.2+注意:cen ...

  8. 王者荣耀全栈项目部署到阿里云服务器笔记

    王者荣耀全栈项目部署到阿里云服务器笔记 原创Charles_GX 最后发布于2020-03-27 01:27:00 阅读数 177  收藏 展开 王者荣耀全栈项目部署到阿里云服务器笔记 学习的项目来自 ...

  9. vue/react/web前端项目部署到阿里云服务器_nginx_pm2流程及部署前的准备

    前端开发完成的项目,不管是使用vue.react.或者是别的web项目,最终都是要部署到外网上,让用户可以通过域名来访问.这篇文章以一个 react 移动端的项目为例,讲怎样将自己本地的项目部署到阿里 ...

  10. 解决springboot项目部署到阿里云服务器邮箱无法发送邮件

    解决springboot项目部署到阿里云服务器邮箱无法发送邮件 前言:今天部署了一下springboot项目,使用的是docker部署的,如果有兴趣可以看我这篇文章: docker上部署前后端分离的s ...

最新文章

  1. python使用生成器生成浮点数列表、使用生成器生成(正)负的浮点数列表
  2. springBoot+springSecurity 数据库动态管理用户、角色、权限(二)
  3. [云炬创业学笔记]第二章决定成为创业者测试1
  4. web系统 手机app 能访问吗?_苹果手机能下载什么好用的桌面便签?有什么好的便签app推荐吗...
  5. 如何在dw上编写php_用dw制作php网站视频教程
  6. 强化学习之基础入门_强化学习基础
  7. java中setDocument_Java ActionItem.setDocumentId方法代码示例
  8. java http请求原理_浅谈Spring Cloud zuul http请求转发原理
  9. stl中map函数_map :: max_size()函数,以及C ++ STL中的Example
  10. C++——C++11中的defalut和delete关键字
  11. 字符级Seq2Seq-英语粤语翻译的简单实现
  12. kafka in action
  13. 并发控制技术手段之多版本(三)
  14. qt 创建第二个ui_Qt自定义提示信息弹窗
  15. 三国战纪2 ,西游2的FBA 移植攻略!
  16. 【运筹学】线性规划 单纯形法原理 ( 构造初始可行基 | 基变换 | 最优性检验 | 解的判别 | 检验数 | ( 唯一 / 无穷多 ) 最优解判别定理 | 无界解判别定理 )
  17. 【学习强化学习】三、Q learning和Sarsa算法
  18. 如何才能制定好测试策略_全(转载)
  19. github上Android常用第三方库
  20. 模拟电子_安规X电容和Y电容的区别与作用

热门文章

  1. windows上传ipa文件到苹果开发者中心的教程
  2. 2015年仿团800首页视频教程
  3. 电脑怎么设置计算机系统,电脑定时开关机如何设置?
  4. web开发中添加分享按钮
  5. python 集合字典_frozenset defaultdict MD5在线加密解密工具
  6. 2015 android 5.0 手机排行榜,2015上半年顶级新款Android手机汇总
  7. DragonFly BSD 4.2发布
  8. 《学习如何学习》week3 3.1 Interview with Nelson Dellis
  9. 网页动画--鲜花爱心表白动画
  10. Android完美解决监听home键