Java文档搜索引擎总结
- 项目介绍
- 项目使用的技术栈
- 前端页面展示
- 后端逻辑部分
- 索引部分
- 搜索模块部分
- Web模块部分
项目介绍
Java文档搜索引擎项目是一个SSM项目,该项目的前端界面部分是由搜索页面和展示页面组成,后端部分索引模块(ScanAnalysis、index)、搜索模块(Searcher)、Web模块(SearcherController)。该项使用ansj第三方分词库进行分词,该项目并没有使用爬虫程序来获取Java文档,而是直接将Java文档下载下来,将Java文档里面的内容进行分词保存到正排索引文件和倒排索引文件中。
项目使用的技术栈
HTML、CSS、JS、Ajax、SpringBoot、SpringMVC
前端页面展示
搜索页面:
显示页面:
后端逻辑部分
索引部分
索引部分底层实现了两个类:ScanAnalysis类、Index类
***ScanAnalysis类:***用来扫描Java文档中的所有HTML文件,将HTML文件的标题、url路径、正文保存到正排索引文件和倒排索引文件中。
***Index类:***底层实现了正排索引结构和倒排索引结构,Index类是配合ScanAnalysis类一起使用的,Index将HTML文件内容保存到正排索引和倒排索引结构中,最终保存到正排索引文件和倒排索引文件中。
ScanAnalysis类的底层代码:
public class ScanAnalysis {
//要扫描的根路径
private static final String PATH_ROOT = "D:\\知识复习思维导图(Java)和Java笔记\\project-warehouse\\jdk-8u351-docs-all\\docs\\api";
//Java文档的网络地址 不同部分
private static final String JAVA_PATN = "https://docs.oracle.com/javase/8/docs/api/";
//索引对象
private static Index index = new Index();
/**
* 启动方法
* 我们在进行扫描的时候,我们会发现在进行扫描的时候效率是比较低的。
* 该方法使用的是单线程的方式
* 我们可以使用多线程的方式来提高效率
*/
public void run() {
long ben1 = System.currentTimeMillis();
//保存每一个文档的路径
ArrayList<String> arrayList = new ArrayList<>();
//1.获取每一个文档的路径
scanPath(PATH_ROOT,arrayList);
long ben = System.currentTimeMillis();
//2.对每一个html文件进行解析
for (String pathChild:arrayList) {
analysis(pathChild);
}
long end = System.currentTimeMillis();
System.out.println("解析所花费的时间:"+(end - ben)+"ms");
//3.将索引保存的索引文档中
index.saveFile();
long end1 = System.currentTimeMillis();
System.out.println("整个程序的时间:"+(end1 - ben1) +"ms");
}
/**
* 启动方法2:我们对解析这个步骤使用多线程的方式来提高效率
*
*/
public void run2() {
long ben1 = System.currentTimeMillis();
//保存每一个文档的路径
ArrayList<String> arrayList = new ArrayList<>();
//1.获取每一个文档的路径
scanPath(PATH_ROOT,arrayList);
long ben = System.currentTimeMillis();
//2.对每一个html文件进行解析
//我们创建一个有时光线程的线程池
ExecutorService executorService = Executors.newFixedThreadPool(15);
//这个CountDownLatch对象,是用来表明需要等待多少个任务才结束
//因为我们要等到解析这个过程完成了在执行下一步
CountDownLatch countDownLatch = new CountDownLatch(arrayList.size());
for (String pathChild:arrayList) {
//将解析的工作提交倒线程池中
executorService.submit(new Runnable() {
@Override
public void run() {
analysis(pathChild);
//完成一次解析任务就减一
countDownLatch.countDown();
}
});
}
try {
//等待任务结束,如果没结束,就阻塞等待
countDownLatch.await();
//关闭线程池
executorService.shutdown();
} catch (InterruptedException e) {
e.printStackTrace();
}
long end = System.currentTimeMillis();
System.out.println("解析所花费的时间:"+(end - ben)+"ms");
//3.将索引保存的索引文档中
index.saveFile();
long end1 = System.currentTimeMillis();
System.out.println("整个程序的时间:"+(end1 - ben1) +"ms");
}
/**
* 对 HTML文件进行解析
* 获取到题目、正文、url
* @param pathChild
*/
private void analysis(String pathChild) {
File file = new File(pathChild);
//1.获取标题
String title = getTitle(file);
// System.out.println(title);
//2.获取正文
String content = getContents(file);
//3.获取url
String url = getUrl(file);
System.out.println(url);
//4.将标题、正文、url保存到索引中
index.saveIndex(title,content,url);
}
/**
* 获取url
* @param file
* @return
*/
private String getUrl(File file) {
StringBuilder stringBuilder = new StringBuilder();
String str = file.getAbsolutePath().substring(PATH_ROOT.length()+1);
for (int i = 0; i < str.length(); i++) {
char ch = str.charAt(i);
if (ch != '\\') {
stringBuilder.append(ch);
} else {
stringBuilder.append('/');
}
}
return JAVA_PATN+stringBuilder.toString();
}
/**
* 获取正文,这个比较麻烦,我们需要去除标签,和<script></script>里面的内容
* 这里我们需要使用正则表达式
* @param file
* @return
*/
public String getContents(File file) {
//获取到HTML里面的内容
String content = getcontentHtml(file);
//使用正则表达式,将<script></script>标签和里面的内容都替换掉
//字符串中的replaceAll方法是支持正则表达式的
content = content.replaceAll("<script.*?>(.*?)</script>"," ");
//使用正则表达式,去除其他标签
content = content.replaceAll("<.*?>"," ");
//使用正则表达式,去除连续的空格
content = content.replaceAll("\\s+"," ");
return content ;
}
/**
* 获取到HTML文件的内容,这人进行文件读取操作,
* 使用字符流,进行读取
* @param f
* @return
*/
private String getcontentHtml(File f) {
try(BufferedReader bufferedReader = new BufferedReader(new FileReader(f),1024*1024)) {
StringBuilder content = new StringBuilder();
while (true) {
int ret = bufferedReader.read();
if (ret == -1) {
break;
}
char ch = (char) ret;
//去除换行
if(ch == '\n' || ch == '\r') {
ch = ' ';
}
content.append(ch);
}
return content.toString();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
/**
* 获取标题
* @param file
* @return
*/
private String getTitle(File file) {
return file.getName().replace(".html","");
}
/**
* 扫描根路径,获取该目录下的索引HTML文件的路径
* 这里要使用的递归 和 文件操作
* @param pathRoot
* @param arrayList
*/
private void scanPath(String pathRoot, ArrayList<String> arrayList) {
File file = new File(pathRoot);
//获取到该目录的以及文件对象
File[] files = file.listFiles();
//遍历
for (File file1:files) {
if (file1.isFile()) {
//是普通文件
//我们要的是html文件,所以还要进行处理
if (file1.getAbsolutePath().endsWith("html")) {
arrayList.add(file1.getAbsolutePath());
System.out.println(file1.getAbsolutePath());
}
} else {
//是目录,进行递归
scanPath(file1.getAbsolutePath(),arrayList);
}
}
}
public static void main(String[] args) {
ScanAnalysis scanAnalysis = new ScanAnalysis();
//程序的入口
scanAnalysis.run2();
}
}
Index类的底层代码:
public class Index {
//正排索引的底层,使用顺序表
public ArrayList<JavaDocModel> arrayList = new ArrayList<>();
//倒排索引的底层,使用HashMap
public HashMap<String,ArrayList<Weight>> map = new HashMap<>();
//创建两个锁
private Object lock1 = new Object();
private Object lock2 = new Object();
//正排索引文件 和倒排索引文件保存的 根目录
private static final String INDEX_SAVE_PATH =
"D:\\知识复习思维导图(Java)和Java笔记\\project-warehouse\\jdk-8u351-docs-all\\";
//线上环境 正排索引文件 和倒排索引文件保存的 根目录
// private static final String INDEX_SAVE_PATH =
// "/project/java_doc_searcher_ssm/";
//进行JSON格式化的 对象
private ObjectMapper objectMapper = new ObjectMapper();
/**
* 1.正排索引:通过文档Id来获取文档对象
* @param docId
* @return
*/
public JavaDocModel getForwardIndex(Integer docId) {
return arrayList.get(docId);
}
/**
* 2.通过分词来获取相对应的一组文档的id,这里不仅仅获取到了id,还有权重,有利于进行排序
* @param terim
* @return
*/
public ArrayList<Weight> getReverseIndex(String terim) {
return map.get(terim);
}
/**
* 3.将标题,正文,url
* 保存到正排索引,和倒排索引中
*/
public void saveIndex(String title,String content,String url){
JavaDocModel javaDocModel = new JavaDocModel();
javaDocModel.setContent(content);
javaDocModel.setTitle(title);
javaDocModel.setUrl(url);
//1.建立正排索引
buildForwardIndex(javaDocModel);
//2.建立倒排索引
buildReverseIndex(javaDocModel);
}
/**
* 建立倒排索引
* 我们需要对文档的标题,正文 进行分词
* @param javaDocModel
*/
private void buildReverseIndex(JavaDocModel javaDocModel) {
//统计一个分词在标题和内容中出现多少次
class Count{
public Integer titleCount;
public Integer contentCount;
}
//1.对文档标题 进行分词
List<Term> terms = ToAnalysis.parse(javaDocModel.getTitle()).getTerms();
//用来统计词频
HashMap<String,Count> hashMap = new HashMap<>();//记录总的分词
synchronized (lock1) {
//遍历分词terms
for (Term term:terms) {
//获取到分词结果
String termName = term.getName();
Count myCount = hashMap.get(termName);
if (myCount == null) {
//没有
Count newCount = new Count();
newCount.titleCount = 1;
newCount.contentCount = 0;
hashMap.put(termName,newCount);
} else {
//有,titleCount加一
myCount.titleCount += 1;
}
}
//2.对文档对象的正文进行分词
terms = ToAnalysis.parse(javaDocModel.getContent()).getTerms();
//遍历分词terms
for (Term term:terms) {
//获取到分词结果
String termName = term.getName();
Count myCount = hashMap.get(termName);
if (myCount == null) {
//没有
Count newCount = new Count();
newCount.contentCount = 1;
newCount.titleCount = 0;
hashMap.put(termName,newCount);
} else {
//有,contentCount加一
myCount.contentCount += 1;
}
}
//3.将hashMap 里的数据整合到 map 里面
//遍历hashMap
for (Map.Entry<String,Count> entry:hashMap.entrySet()) {
String key = entry.getKey();
Count val = entry.getValue();
//从倒排索引中获取value值
ArrayList<Weight> weights = map.get(key);
if (weights == null) {
//没有,创建新的
ArrayList<Weight> newWeights = new ArrayList<>();
Weight weight = new Weight();
//设置文档Id
weight.setDocId(javaDocModel.getDocId());
//设置权重,titleCount*20+contentCount
weight.setWeight(val.contentCount + val.titleCount*20);
newWeights.add(weight);
map.put(key,newWeights);
} else {
//有的话,直接添加
Weight weight = new Weight();
//设置文档Id
weight.setDocId(javaDocModel.getDocId());
//设置权重,titleCount*20+contentCount
weight.setWeight(val.contentCount + val.titleCount*20);
weights.add(weight);
}
}
}
}
/**
* 建立正排索引,以顺序表的下标作为文档ID
* 直接插入顺序表就行
* @param javaDocModel
*/
private void buildForwardIndex(JavaDocModel javaDocModel) {
synchronized (lock2) {
//插入docId
javaDocModel.setDocId(arrayList.size());
//直接插入顺序表尾部
arrayList.add(javaDocModel);
}
}
/**
* 4.将正排索引结构 和 倒排索引结构 保存到 正排索引文件 和倒排索引文件中
* 序列化的方法:以JSON的格式保存
*/
public void saveFile() {
//正排索引 和 倒排索引保存的目录
File filePath = new File(INDEX_SAVE_PATH);
if (!filePath.exists()) {
//创建目录
filePath.mkdirs();
}
//正排索引文件对象
File fileForwardIndex = new File(INDEX_SAVE_PATH+"forward.txt");
//倒排索引文件对象
File fileReverseIndex = new File(INDEX_SAVE_PATH+"reverse.txt");
if (!fileForwardIndex.exists()) {
//不存在,创建正排索引文件
try {
fileForwardIndex.createNewFile();
} catch (IOException e) {
e.printStackTrace();
}
}
if (!fileReverseIndex.exists()) {
//不存在,创建倒排索引文件
try {
fileReverseIndex.createNewFile();
} catch (IOException e) {
e.printStackTrace();
}
}
try {
//将正排索引结构转成JSON格式,保存到正排索引文件中
objectMapper.writeValue(fileForwardIndex,arrayList);
//将倒排索引结构转成JSON格式,保存到倒排索引文件中
objectMapper.writeValue(fileReverseIndex,map);
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* 5.加载正排 和 倒排 文件 ,将内容加载倒内存中
* 反序列
*/
public void load() {
long ben = System.currentTimeMillis();
//正排索引文件对象
File fileForwardIndex = new File(INDEX_SAVE_PATH+"forward.txt");
//倒排索引文件对象
File fileReverseIndex = new File(INDEX_SAVE_PATH+"reverse.txt");
try {
//这里的 readValue方法用法要注意
// 第二个参数是一个匿名内部类,实现了TypeReference,目的就是 我们想要把JSON格式的字符串转成什么类型 告诉了 readValue方法
//正排
arrayList = objectMapper.readValue(fileForwardIndex, new TypeReference<ArrayList<JavaDocModel>>() {
});
//倒排
map = objectMapper.readValue(fileReverseIndex, new TypeReference<HashMap<String,ArrayList<Weight>>>() {
});
} catch (IOException e) {
e.printStackTrace();
}
long end = System.currentTimeMillis();
System.out.println("加载文档的时间:"+(end - ben) +"ms");
}
}
搜索模块部分
搜索模块部分底层实现了Searcher类,提供了searcher方法来搜索相关的文档。
Searcher类的底层代码:
public class Searcher {
//索引类
private Index index = new Index();
//保存停用词表的数据结构
private Set<String> stopWordsSet = new HashSet<>();
//停用词表的存放路径
private static final String STOP_WORDS =
"D:\\知识复习思维导图(Java)和Java笔记\\project-warehouse\\jdk-8u351-docs-all\\stop_words.txt";
//线上环境 停用词表的存放路径
// private static final String STOP_WORDS =
// "/project/java_doc_searcher_ssm/stop_words.txt";
public Searcher() {
//1.创建该类的时候,加载一些索引文档
index.load();
//2.创建该类的时候,加载停用词表
loadStopWords();
}
/**
* 加载停用词表
*/
private void loadStopWords() {
long ben = System.currentTimeMillis();
//进行读操作
try(BufferedReader bufferedReader = new BufferedReader(new FileReader(STOP_WORDS)) ){
while (true) {
String str = bufferedReader.readLine();
if (str == null) {
break;
}
stopWordsSet.add(str);
}
} catch (IOException e) {
e.printStackTrace();
}
long end = System.currentTimeMillis();
System.out.println("加载停用词表的时间:"+(end - ben) + "ms");
}
public List<ResultReturnModenl> searcher(String word) {
//将查询词进行分词、
List<Term> terms = ToAnalysis.parse(word).getTerms();
//我们通过分词结果可以得出,有些分词是不合理的
//我们要排除一些不合理的分词结果
//这里我们使用停用词表进行过滤
List<Term> newTerms = new ArrayList<>();//保存过滤后的term
for (Term term:terms) {
//分词内容
String wordName = term.getName();
if (!stopWordsSet.contains(wordName)) {
//不是停用词
newTerms.add(term);
}
}
//遍历newTerms,获取要返回的数据
List<ArrayList<Weight>> listList = new ArrayList<>();
for (Term term:newTerms) {
//获取倒分词的内容
String wordName = term.getName();
//通过倒排索引,来获取倒相对应的文档对象
ArrayList<Weight> reverseIndex = index.getReverseIndex(wordName);
//判断是否拿到
if (reverseIndex == null) {
//没有拿到
continue;
}
//将reverseIndex保存到 listList中
listList.add(reverseIndex);
}
//合并listList中的数组,并且进行去重
//类似于合并多个有序数组,并且最后的结果要有序
List<Weight> list = sortArray(listList);
//对list进行排序,按照权重的大小由高到低排序
Collections.sort(list, new Comparator<Weight>() {
@Override
public int compare(Weight o1, Weight o2) {
//降序
return o2.getWeight() - o1.getWeight();
}
});
//保存返回的数据
List<ResultReturnModenl> results = new ArrayList<>();
//将数据进行封装
for (Weight weight:list) {
//通过正排索引找到文档对象
JavaDocModel forwardIndex = index.getForwardIndex(weight.getDocId());
ResultReturnModenl resultReturnModenl = new ResultReturnModenl();
//设置标题
resultReturnModenl.setTitle(forwardIndex.getTitle());
//设置url
resultReturnModenl.setUrl(forwardIndex.getUrl());
//设置摘要
resultReturnModenl.setDesc(getDesc(forwardIndex.getContent(),newTerms));
results.add(resultReturnModenl);
}
return results;
}
/**
生成正文摘要
* 由于docInfo对象里面是正文,所以还要做一些处理
* 摘要要包含 查询词 或者 查询词的一部分
* 生成摘要的思路:可以遍历查询词的分词,找到对应位置
* 就针对这个位置,往前截取60个字符,作为描述的开始,然后从描述开始在截取160个字符
* @param content
* @param newTerms
* @return
*/
public String getDesc(String content, List<Term> terms) {
//记录分词出现的位置
int termIndex = -1;
for (Term term:terms) {
//获取到分词内容
String wordName = term.getName();
//将正文转成小写 使用toLowerCase()
//此处需要的是全词匹配,在word前后都加一个空 在进行查找
//这里的匹配不严谨,更严谨的方法是使用 正则表达式
//indexOf不支持正则表达式
//Java提供了 Pattern 和 Matcher 这两个类 来实现正则表达式,自己学习一下
//Pattern : 描述一个匹配规则
//Matcher 负责进行具体的匹配工作
//这里的做法:把不是空格的转成空格
content = content.toLowerCase().replaceAll("\\b"+wordName+"\\b"," " + wordName + " ");
termIndex = content.toLowerCase().indexOf(" "+wordName+" ");
if (termIndex != -1 ) {
//存在
break;
}
}
if (termIndex == -1) {
//所有的分词结果都不存在
//返回正文的前160个字符
if (content.length() <=160) {
return content;
}
return content.substring(0,160)+"...";
}
//程序如果到这里,说明正文中有分词结果
//判断是否要往前60个字符
termIndex = termIndex - 60 >=0?termIndex-60:0;
String desc = "";//保存正文摘要
if (termIndex+160 >= content.length()) {
//从termIndex这个位置截到尾
desc = content.substring(termIndex);
} else {
desc = content.substring(termIndex,160+termIndex)+"...";
}
//在此处加上替换操作,把描述中的 和 分词结果相同的部分,
//加上依次<i>标签,可以使用 replaceAll 的方法来实现
//者样在前端显示的时候,可以标红
//遍历分词结果
for (Term term:terms) {
//获取到结果
String word = term.getName();
//注意此处要进行全字匹配,不区分大小写替换
desc = desc.replaceAll("(?i) "+word +" ","<i> "+word+" </i>");
}
return desc;
}
/**
* 合并listList中的数组,并且进行去重
* 类似于合并多个有序数组,并且最后的结果要有序
* @param listList
* @return
*/
private List<Weight> sortArray(List<ArrayList<Weight>> listList) {
class Pos{
public Integer row = 0;//行
public Integer col = 0;//列
public Pos(Integer row, Integer col) {
this.row = row;
this.col = col;
}
}
//使用优先级队列,来解决该问题
//创建优先级队列
PriorityQueue<Pos> pos = new PriorityQueue<>(new Comparator<Pos>() {
@Override
public int compare(Pos o1, Pos o2) {
//小根堆
return listList.get(o1.row).get(o1.col).getDocId() - listList.get(o2.row).get(o2.col).getDocId();
}
});
//将每一个数组,按docId的大小,升序排序
for (ArrayList<Weight> weights:listList) {
Collections.sort(weights, new Comparator<Weight>() {
@Override
public int compare(Weight o1, Weight o2) {
return o1.getDocId() - o2.getDocId();
}
});
}
//将每一个数组的第一个元素的位置放进来
for (int i = 0; i < listList.size(); i++) {
pos.offer(new Pos(i,0));
}
List<Weight> listResult = new ArrayList<>();//保存最后返回的结果
while (!pos.isEmpty()) {
//从优先级队列出来的队首元素
Pos pos1 = pos.poll();
if (listResult.size() == 0) {
//插入第一个元素
listResult.add(listList.get(pos1.row).get(pos1.col));
} else {
//不是第一个,要判断是否于前一个相同,相同权重相加
if (listResult.get(listResult.size() - 1).getDocId() == listList.get(pos1.row).get(pos1.col).getDocId()) {
//文档相同,权重相加
listResult.get(listResult.size() - 1).setWeight(listResult.get(listResult.size() - 1).getWeight()+listList.get(pos1.row).get(pos1.col).getWeight());
} else {
//不相同,添加到listResult中
listResult.add(listList.get(pos1.row).get(pos1.col));
}
}
if (pos1.col + 1 >= listList.get(pos1.row).size()) {
//这一行处理完了
continue;
}
pos.offer(new Pos(pos1.row, pos1.col+1));
}
return listResult;
}
public static void main(String[] args) {
Searcher searcher = new Searcher();
}
}
Web模块部分
Web模块部分实现前后端的交互。
Web模块的代码:
@RestController
public class SearcherController {
@Autowired
Searcher searcher ;
@RequestMapping("/searcher")
public Object searcher(String word) {
if (word == null || word.trim().equals("")) {
return -1;
}
return searcher.searcher(word);
}
@RequestMapping("/getword")
public String getWord(String word) {
System.out.println(word);
return word;
}
}