提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档
文章目录
- 前言
- [1. 实现Index类](#1. 实现Index类)
-
- [1.13 给制作索引模块加锁](#1.13 给制作索引模块加锁)
- [1.14 验证](#1.14 验证)
- [1.15 非守护线程问题](#1.15 非守护线程问题)
- [1.15 首次制作索引比较慢问题](#1.15 首次制作索引比较慢问题)
- [1.16 优化文件读取速度](#1.16 优化文件读取速度)
- [1.17 验证索引加载逻辑](#1.17 验证索引加载逻辑)
- [2. 实现搜索模块](#2. 实现搜索模块)
- [3. 实现DocSearcher类](#3. 实现DocSearcher类)
-
- [3.1 实现search方法](#3.1 实现search方法)
- [3.2 实现生成描述](#3.2 实现生成描述)
- [3.3 验证](#3.3 验证)
- [3.4 去除script标签](#3.4 去除script标签)
- [3.5 合并多个空格为一个空格](#3.5 合并多个空格为一个空格)
- 总结
前言
1. 实现Index类
1.13 给制作索引模块加锁
java
private void parseHTML(File file) {
//1.解析出标题
String title = parseTitle(file);
System.out.println(title);
//2.解析出html对应url
String url = parseUrl(file);
//3.解析出html正文
String content = parseContent(file);
// System.out.println(content);
index.addDoc(title,url,content);
}
像parseTitle,parseUrl,parseContent这种都不涉及线程安全问题,因为不涉及多个线程修改同一个对象的问题
而index.addDoc(title,url,content);这个的话,是多个线程都会操作同一个Index对象,操作同一个正排索引,操作同一个倒排索引

所以就会有线程安全问题了,所以需要加锁来解决
可以直接给addDoc这个方法加锁synchronized
但是给这个方法加锁的话,那么在这个方法上,线程就是串行的了,parseTitle,parseUrl,parseContent这三个方法才是并发的,addDoc是串行的
所以加锁要在细一点,小一些
java
public void addDoc(String title,String url, String content){
//新增文档操作,需要同时给正派索引,和倒排索引添加
DocInfo docInfo = buildForward(title,url,content);
buildInverted(docInfo);
}
java
private DocInfo buildForward(String title, String url, String content) {
DocInfo docInfo = new DocInfo();
docInfo.setTitle(title);
docInfo.setUrl(url);
docInfo.setContent(content);
forwardIndex.add(docInfo);//因为是加在最后的,所以下标就是数组长度,就是docId
docInfo.setDocId(forwardIndex.size()-1);
return docInfo;
}
主要是 forwardIndex.add(docInfo);//因为是加在最后的,所以下标就是数组长度,就是docId
docInfo.setDocId(forwardIndex.size()-1);这两个代码可能会有线程安全,所以给这两行加锁就可以了
因为这两行访问了公共对象forwardIndex正排索引,所以会有线程安全问题
java
private DocInfo buildForward(String title, String url, String content) {
DocInfo docInfo = new DocInfo();
docInfo.setTitle(title);
docInfo.setUrl(url);
docInfo.setContent(content);
synchronized (this){
forwardIndex.add(docInfo);//因为是加在最后的,所以下标就是数组长度,就是docId
docInfo.setDocId(forwardIndex.size()-1);
}
return docInfo;
}
因为indx这个类的实例只有一份,所以可以对这个加锁了
在buildInverted方法中也是一样的
涉及invertedIndex的地方都要加锁
java
for(Map.Entry<String,WordCnt> entry : wordCntHashMap.entrySet()){
synchronized (this){
//先根据词去倒排索引中查
ArrayList<Weight> invertedList = invertedIndex.get(entry.getKey());
if(invertedList == null){
//插入一个新的键值对
invertedList = new ArrayList<>();
Weight weight = new Weight();
weight.setDocId(docInfo.getDocId());
weight.setWeight(entry.getValue().titleCount*10+entry.getValue().contentCount);
invertedList.add(weight);
invertedIndex.put(entry.getKey(),invertedList);
}else{
Weight weight = new Weight();
weight.setDocId(docInfo.getDocId());
weight.setWeight(entry.getValue().titleCount*10+entry.getValue().contentCount);
invertedList.add(weight);
}
}
}
buildInverted方法中只有for循环这里是涉及倒排索引的,所以这里加锁就可以了
但是这里加锁的锁和正派索引的锁是同一个锁this,按理说,这两个可以为不同锁,因为它们不是竞争的同一个资源,它们是操作的不同对象,所以应该是不同的锁才行
意思就是不同线程的正排索引和倒排索引不应该有锁竞争
java
private DocInfo buildForward(String title, String url, String content) {
DocInfo docInfo = new DocInfo();
docInfo.setTitle(title);
docInfo.setUrl(url);
docInfo.setContent(content);
synchronized (forwardIndex){
forwardIndex.add(docInfo);//因为是加在最后的,所以下标就是数组长度,就是docId
docInfo.setDocId(forwardIndex.size()-1);
}
return docInfo;
}
java
for(Map.Entry<String,WordCnt> entry : wordCntHashMap.entrySet()){
synchronized (invertedIndex){
//先根据词去倒排索引中查
ArrayList<Weight> invertedList = invertedIndex.get(entry.getKey());
if(invertedList == null){
//插入一个新的键值对
invertedList = new ArrayList<>();
Weight weight = new Weight();
weight.setDocId(docInfo.getDocId());
weight.setWeight(entry.getValue().titleCount*10+entry.getValue().contentCount);
invertedList.add(weight);
invertedIndex.put(entry.getKey(),invertedList);
}else{
Weight weight = new Weight();
weight.setDocId(docInfo.getDocId());
weight.setWeight(entry.getValue().titleCount*10+entry.getValue().contentCount);
invertedList.add(weight);
}
}
}
}
所以我们把锁改为两个索引,或者直接创建两个新的对象弄为新的锁也是可以的
java
private static final Object lock1 = new Object();
private static final Object lock2 = new Object();
java
synchronized (lock2){
forwardIndex.add(docInfo);//因为是加在最后的,所以下标就是数组长度,就是docId
docInfo.setDocId(forwardIndex.size()-1);
}
另一个方法同理
1.14 验证
java
public static void main(String[] args) throws InterruptedException {
Parser p = new Parser();
p.runByThread();
}

线程池的线程不是设置越多就越好,具体设置为多少---》用实验的方式来· ·
1.15 非守护线程问题

这里进程没有结束
因为守护线程
如果一个线程是守护线程(后台线程)---》线程的运行状态不会影响进程结束
如果是非守护线程---》这个线程运行状态就会影响进程结束
默认创建的都是非守护线程,需要设置setDaemon才能成为守护线程
线程池创建的线程默认都是非守护线程,所以main执行完了,这些线程还在等待新任务到来
要想使线程随着main方法结束而结束,要么变为守护线程,要么干掉线程
java
public void runByThread() throws InterruptedException {
System.out.println("索引制作开始");
long begin = System.currentTimeMillis();
ArrayList<File> fileList = new ArrayList<>();
//1.根据上面指定路径,枚举出所有的文件(html),包括所有的子目录
enumFile(INPUT_PATH,fileList);
//2.循环遍历文件,线程池
CountDownLatch countDownLatch = new CountDownLatch(fileList.size());
ExecutorService executorService = Executors.newFixedThreadPool(4);
for (File file : fileList) {
executorService.submit(new Runnable() {
@Override
public void run() {
System.out.println("解析"+file.getAbsolutePath());
parseHTML(file);//解析html文件
countDownLatch.countDown();//解析完成之后,资源数减一
}
});
}
//3.保存索引,要等线程池执行完成之后才可以,submit只是把任务放入阻塞队列中,执行完毕还要等等
//怎么等待呢,使用CountDownLatch,先指定任务个数,每完成一个任务parseHTML就减一,用await来等待CountDownLatch所有任务数都没有
countDownLatch.await();//会阻塞,直到所有的任务都完成
executorService.shutdown();
index.save();
long end = System.currentTimeMillis();
System.out.println("索引制作完毕:"+(end-begin)+"ms");
}
executorService.shutdown();就是干掉所有线程了

1.15 首次制作索引比较慢问题
我们第一次制作索引的时候很慢,9秒多
后面变快了,变为6秒了
如果重启机器,又变慢了
问题主要是parseContent,这里是读取文件的操作,是一个开销比较大的操作
首次开机-----》读取文件速度很慢
我们要得出一下parseContent这个耗费的时间总长
java
private AtomicLong t1 = new AtomicLong(0);
private AtomicLong t2 = new AtomicLong(0);
这两个都是原子类,都是线程安全的类,可以不用加锁了,基于cas,比较快
java
private void parseHTML(File file) {
//1.解析出标题
String title = parseTitle(file);
System.out.println(title);
//2.解析出html对应url
String url = parseUrl(file);
//3.解析出html正文
long begin = System.nanoTime();//更加细腻度,为纳秒,因为一个文件的parseContent用ms来计算,不准确
//如果是所有文件的parseContent用ms来计算还可以接受
String content = parseContent(file);
long mid = System.nanoTime();
// System.out.println(content);
index.addDoc(title,url,content);
long end = System.nanoTime();
t1.addAndGet(mid-begin);
t2.addAndGet(end-mid);//原子相加,很快
//注意因为单次对文件的操作时间本来就很短了。如果这里还打印的话---》时间就变长了
}
java
System.out.println("t1:"+t1);
System.out.println("t2:"+t2);
在runByThread方法末尾加上这个打印

这是第一次开机之后执行的时间
这里是线程累积时间,所以超过总时间很正常
发现解析正文的时间比addDoc的时间长很多

第二次运行之后发现,t2变化不大,主要是t1变化很大,之间变为7s了
直接从几百秒变为几秒了
为什么呢
parseContent这个操作主要是读取文件----》操作系统会对进程读取的文件进行缓存,首次运行的时候,这些java文档都没有在内存上缓存
所以第一次读取的时候,只能从磁盘上读取
后面再读取的时候,已经在内存中有缓存了,所以直接读缓存了,不读磁盘了
1.16 优化文件读取速度
上面的是操作系统的优化,我们怎么优化呢
fileReader.read()这个方法就是从文件中读取一个字符,每次都是读磁盘一个字符,比较慢,我们可以直接把所有内容从磁盘中先读到内存中,然后在挨个读取
BufferredReader可以搭配fileReader使用
BufferredReader内部就会内置一个缓冲区,就能够自动把fileReader中的一些内容预读到内存中,从而减少我们直接访问磁盘的次数
java
private String parseContent(File file) {
//先按照一个字符一个字符来读取,字符流
//手动设置缓冲区为1M
try(BufferedReader bufferedReader = new BufferedReader(new FileReader(file),1024*1024)) {
//加上一个是否要进行拷贝的开关
boolean isCopy= true;
StringBuilder content = new StringBuilder();
while (true){
int read = bufferedReader.read();//一次读取一个字符,返回值是一个int
if (read == -1){
break;//表示文件读完了
}
char c = (char) read;
if (isCopy){
if(c=='<'){
isCopy=false;
continue;
}
if(c=='\n' || c == '\r'){//\r表示回车符
c=' ';
}
content.append(c);
}else if (c=='>'){
isCopy=true;
}
}
return content.toString();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
java
private static int defaultCharBufferSize = 8192;
BufferredReader提供的缓冲区大小默认是8K

但是html比较大,所以8k太小了
但是我们系统第二次运行本身就有缓存了,所以可能不会提升多少

但是第一次访问的时候还是有效的
1.17 验证索引加载逻辑
就是把文件还原为数据结构了
java
public static void main(String[] args) {
Index index = new Index();
index.load();
System.out.println("索引加载完成");
}


我们调试一下,发现好像没有什么问题

2. 实现搜索模块
索引模块我们就实现完了
搜索模块就是调用索引模块来完成搜索
先对用户输入的查询词进行分词(因为可能是一句话)
然后拿着每个分词去倒排索引中查
针对倒排索引中查询出来的结果,然后按照相关性进行排序,降序
拿着排序后的结果,去查正排,拿到每个文档的详细信息,包装为一定的数据结构后返回
3. 实现DocSearcher类
java
@Data
//表示一个搜索结果
public class Result {
private String title;
private String url;
private String desc;//这是正文的摘要
}
java
public class DocSearcher {
//引入index实例,然后加载好
private Index index = new Index();
public DocSearcher(){
index.load();
}
//完成整个搜索过程
//query就是用户输入查询词
public List<Result> search(String query){
//1.针对query分词
//2.查倒排
//3.排序
//4.包装:查正排
return null;
}
}
3.1 实现search方法
描述是正文中的一段摘要
怎么生成呢
这个描述还要包含查询词
遍历分词结果,看哪个结果在正文中出现
针对这个文档来说,不一定会包含所有的分词结果
就针对这个分词结果去正文中查找。找到对应的位置
就以这个词的位置为中心,往前截取60个字符,作为描述的开始
然后再从描述开始,一股脑截取160个字符,作为整个描述,体现出相关性就可以了,就不深究了

java
//完成整个搜索过程
//query就是用户输入查询词
public List<Result> search(String query){
//1.针对query分词
List<Term> terms = ToAnalysis.parse(query).getTerms();
//2.查倒排
List<Weight> allTermResult = new ArrayList<>();
for (Term term : terms){
String word = term.getName();
List<Weight> invertedList = index.getInverted(word);
//如果分词term是一个生僻的词,那么倒排索引可能查不出来
if (invertedList==null){
//说明这个词不存在
continue;
}
allTermResult.addAll(invertedList);//批量追加一组元素,支追加一个元素用add
}
//3.排序,针对权重来降序
allTermResult.sort(new Comparator<Weight>() {
@Override
public int compare(Weight o1, Weight o2) {
//如果是升序排序,就写o1.getWeight()-o2.getWeight()
return o2.getWeight()-o1.getWeight();//可以先随便写一种,后面测试来修改
}
});
//4.包装:查正排
List<Result> resultList = new ArrayList<>();
for (Weight weight : allTermResult){
DocInfo docInfo = index.getDocInfo(weight.getDocId());
Result result = new Result();
result.setTitle(docInfo.getTitle());
result.setUrl(docInfo.getUrl());
result.setDesc(GenDesc(docInfo.getContent(),terms));
resultList.add(result);
}
return resultList;
}
3.2 实现生成描述
java
private String GenDesc(String content, List<Term> terms) {
//先遍历分词结果
int firstPos = -1;
for (Term term : terms){
//别忘了,分词都是小写的,所以要把正文变为小写toLowerCase再去查询,不然可能会漏
//还有一个问题,正文为ArrayList,分词为List,这样也会查询到---》生成的描述不准确,但是在查倒排的时候,就不会这样。因为ArrayLIst就不会被分成Array和List
//我们搞的是原词匹配,不是近义词匹配。所以正文为ArrayList不应该被查出来,必须查出来整个词都匹配才好
String word = term.getName();
//全字匹配--》加空格就可以了,让word独立成词
//此处不太严谨,严谨的话可以使用正则表达式,因为万一List在开头呢
firstPos = content.toLowerCase().indexOf(" "+word+" ");
if(firstPos>=0){
//找到了位置
break;
}
}
if (firstPos==-1){
//所有分词结果都不在正文中存在----》在标题中,也可以返回正文前160字符
return content.substring(0,160)+".......";
}
//从firstPos往前60开始截取
String desc = "";
int descBeg = firstPos < 60 ? 0 : firstPos-60;
if(descBeg+160>content.length()){
desc = content.substring(descBeg);//从descBeg位置截取到末尾
}else {
desc = content.substring(descBeg,descBeg+160)+"......";
}
return desc;
}
3.3 验证
java
public static void main(String[] args) {
DocSearcher searcher = new DocSearcher();
Scanner scanner = new Scanner(System.in);
while (true){
System.out.print("->");
String query = scanner.next();
List<Result> resultList = searcher.search(query);
for (Result result : resultList){
System.out.println(result);
}
}
}


这个主要是JavaScript中的代码
有些html中有script标签,去除这个以后----》js中的代码也被整理到索引里面去了,这个并不科学
所以js的代码并不需要
3.4 去除script标签
我们这里使用正则表达式,
Java中的String里面的很多方法都是直接支持正则的,比如IndexOf,replace,replaceAll,split
正则里面就是很多符号
java
.表示匹配一个非换行字符
*表示前面的字符可以出现若干次
.*表示匹配非换行字符出现若干次
去除script标签和内容
java
<script.*>(.*)</script>
java
<script.*>
这个表示匹配这个标签,里面还有点和星主要是因为script可能还有属性
括号表示它们是一个整体
去掉普通标签,不去掉内容
java
<.*>
这个既能匹配开始标签,也可以匹配结束标签
java
<script.*?>(.*?)</script>
java
<.*?>
此处问号表示非贪婪匹配---》匹配到符合条件的最短结果
不带问号表示贪婪匹配,就是尽可能长的匹配,尽可能匹配到满足条件的最长字符串





所以最好使用非贪婪匹配,如果是贪婪匹配,可能就把所有都匹配到了,非贪婪的话,就比较细了
java
private String readFile(File file) {
try (BufferedReader bufferedReader = new BufferedReader(new FileReader(file), 1024 * 1024)) {
StringBuilder content = new StringBuilder();
while (true) {
int read = bufferedReader.read();//一次读取一个字符,返回值是一个int
if (read == -1) {
break;//表示文件读完了
}
char c = (char) read;
if (c == '\n' || c == '\r') {//\r表示回车符
c = ' ';
}
content.append(c);
}
return content.toString();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
//这个方法基于正则表达式,实现去标签,去除script
private String parseContentByRegex(File file) {
//1.先把整个文件都读到String中
String content = readFile(file);
//2.替换掉script标签
content = content.replaceAll("<script.*?>(.*?)</script>"," ");//直接把满足"<script.*?>(.*?)</script>"条件的替换为空格
//3.替换掉普通标签
content = content.replaceAll("<.*?>"," ");
return content;
}
这样的话,就没有js的代码了

3.5 合并多个空格为一个空格
java
//这个方法基于正则表达式,实现去标签,去除script
private String parseContentByRegex(File file) {
//1.先把整个文件都读到String中
String content = readFile(file);
//2.替换掉script标签
content = content.replaceAll("<script.*?>(.*?)</script>"," ");//直接把满足"<script.*>(.*?)</script>"条件的替换为空格
//3.替换掉普通标签
content = content.replaceAll("<.*?>"," ");
//多个空格合并为一个空格
content = content.replaceAll("\\s+"," ");
//\s+加号表示至少出现一次>=1次,*的话就是表示出现>=0次,,,,,\s就是空格,\是转义字符,这里就是要使用字符\s才行
return content;
}

然后重新制作索引


这样里面就没有js文件了