一.项目目标
实现一个Java API文档的站内搜索引擎。
用户点击搜索框输入查询词之后点击搜索,将会在服务器中检索出所有与查询词相关的文档,并且将这些文档返回到页面上,用户点击搜索结果,就会跳转到文档的详细页面。
1.为什么要搜索Java API文档?
1)官方文档上没有一个好用的搜索框。
2)Java API文档数量较少,当前有限的硬件资源足以处理几万个页面。
3)文档内容不需要使用爬虫来获取,可以直接在官网上下载。
二.项目模块
1.预处理模块
下载好的API文档的html进行初步处理,把若干个html文件处理成一个行文本格式的文件raw_data.txt。
每一行对应一个文档,有三列,这三列使用\3分割,包含文档的标题,这个文档的url(线上版本的url),正文(去掉html标签)。
正文去掉html标签,是为了让搜索结果集中到文档的正文上。
1)单线程枚举出INPUT_PATH下所有的html文件 ;(经典面试题)
public static void enumFile(String inputPath,ArrayList<File> fileList){
//递归的把input路径下单所有的目录和文件都遍历一遍
File root = new File(inputPath);
//listFiles相当于Linux中ls命令 可以将当前目录下所有的文件都列举出来
File[] files = root.listFiles();
//遍历当前目录和文件 分别处理
for(File file : files){
if(file.isDirectory()){//是目录
enumFile(file.getAbsolutePath(),fileList);
}else if(file.getAbsolutePath().endsWith(".html")){//是文件
//看文件后缀是不是.html 如果是就将整个文件加入到fileList的List中
fileList.add(file);
}
}
}
单线程会导致读取过慢,可以使用多线程来优化,简化执行时间;
public void runByThread() throws InterruptedException {
long beg = System.currentTimeMillis();
System.out.println("索引制作开始!");
// 1. 枚举出所有的文件
ArrayList<File> files = new ArrayList<>();
enumFile(INPUT_PATH, files);
// 2. 循环遍历文件. 此处为了能够通过多线程制作索引, 就直接引入线程池.
CountDownLatch latch = new CountDownLatch(files.size());
ExecutorService executorService = Executors.newFixedThreadPool(8);
for (File f : files) {
executorService.submit(new Runnable() {
@Override
public void run() {
System.out.println("解析 " + f.getAbsolutePath());
parseHTML(f);
latch.countDown();
}
});
}
// await 方法会阻塞, 直到所有的选手都调用 countDown 撞线之后, 才能阻塞结束.
latch.await();
// 手动的把线程池里面的线程都干掉!
executorService.shutdown();
// 3. 保存索引
index.save();
long end = System.currentTimeMillis();
System.out.println("索引制作完毕! 消耗时间: " + (end - beg) + "ms");
System.out.println("t1: " + t1 + ", t2: " + t2);
}
2)针对枚举出的html文件路径进行遍历,依次打开每个文件并读取内容,把读取得到的内容 转化成docInfo对象;
public static String convertTitle(File file){
String name = file.getName();
return name.substring(0,name.length() - ".html".length());
}
public static String convertUrl(File file){
//线上文档路径由两部分组成
//1.固定的 https://docs.oracle.com/javase/8/docs/api
//2./java/util/*.html 这一部分与本地路径相关
String str1 = "https://docs.oracle.com/javase/8/docs/api";
String str2 = file.getAbsolutePath().substring(INPUT_PATH.length());
return str1 + str2;
}
转换正文时,需要去掉html标签和换行符。
采用遍历的方式,一个一个的读取字符,设置一个Boolean对象isContent来标记当前内容是不是正文。
当当前字符为<时,把isContent置为false,读到的字符自动忽略;
当当前字符为>时,把isContent置为true,并将之后所读到的字符放入StringBuffer中。
public static String convertContent(File file) throws IOException {
//1.把html中的标签去掉
//2.把\n去掉
//一个一个的读取字符并判定
FileReader fileReader = new FileReader(file);
Boolean isContent = true;
StringBuilder output = new StringBuilder();
while(true){
int ret = fileReader.read();
if(ret == -1){
//说明已将该文件读取完毕
break;
}
char ch = (char)ret;
if(isContent) {
//是正文
if (ch == '<') {
isContent = false;
continue;
}
if(ch == '\n' || ch == '\r'){
//\n表示换行 \r表示回车
ch = ' ';
}
output.append(ch);
}else{
if(ch == '>'){
isContent = true;
}
}
}
fileReader.close();
return output.toString();
}
2.索引模块
根据预处理模块输出文件raw_data.txt,制作正排和倒排索引。
正排:根据文档id找到对应的文档信息。
docId => 文档内容
private static DocInfo buildForword(String line){
//line有三列使用\3分割,包含文档的标题 文档的url 正文
String[] tokens = line.split("\3");
if(tokens.length != 3){
System.out.println("文件格式存在问题" + line);
return null;
}
DocInfo docInfo = new DocInfo();
docInfo.setDocId(forwordIndex.size());
docInfo.setTitle(tokens[0]);
docInfo.setUrl(tokens[1]);
docInfo.setContent(tokens[2]);
//建立正排索引
forwordIndex.add(docInfo);
return docInfo;
}
倒排:根据搜索词找到这个词在哪些文档id中存在。
文档内容分词 => docId
(分词使用第三方库.ansj)
倒排索引可以通过遍历正排索引的方式来构建。
权重:反映该词与文档的相关程度。相关程度越高,权重越大。
实际的搜索引擎根据搜索词与文档之间的相关性进行降序排列。
把相关程度越高的文档排的越前,相关程度越低的文档排的越后。
这里相关性的衡量方式采用该词在文档中的出现次数;但是如果该词在标题出现比在正文中出现的相关性更强。
因此定义 权重:标题中出现次数*10 + 正文中出现次数
private void buildInverted(DocInfo docInfo){
class WordCnt{
public int titleCount;
public int contentCount;
public WordCnt(int titleCount,int contentCount){
this.titleCount = titleCount;
this.contentCount = contentCount;
}
}
HashMap<String,WordCnt> wordCntHashMap = new HashMap<>();
//针对DocInfo中的title和content进行分词
//在根据分词结果构建出Weight对象 更新倒排索引
//1.先针对标题进行分词
List<Term> titleTerms = ToAnalysis.parse(docInfo.getTitle()).getTerms();
; //2.遍历分词结果 统计标题中的每个词出现的次数
for(Term term : titleTerms){
String word = term.getName();
WordCnt wordCnt = wordCntHashMap.get(word);
if(wordCnt == null){
wordCntHashMap.put(word,new WordCnt(1,0));
}else{
wordCnt.titleCount++;
}
}
//3.再针对正文进行分词
List<Term> contentTerms = ToAnalysis.parse(docInfo.getContent()).getTerms();
//4.遍历分词·结果 统计正文中的每个词出现的次数
for(Term term : contentTerms){
String word = term.getName();
WordCnt wordCnt = wordCntHashMap.get(word);
if(wordCnt == null){
wordCntHashMap.put(word,new WordCnt(0,1));
}else{
wordCnt.contentCount++;
}
}
//5.遍历HasMap 依次构建Weight对象并更新倒排索引的映射关系
for(Map.Entry<String,WordCnt> entry : wordCntHashMap.entrySet()){
Weight weight = new Weight();
weight.word = entry.getKey();
weight.docId = weight.docId;
weight.weight = entry.getValue().titleCount * 10 + entry.getValue().contentCount;
//把weight对象加入到倒排索引中
//倒排索引是一个HasMap value就是Weight构成的一个ArrayList
//先根据这个词找到hasMap中对应的value
ArrayList<Weight> invertedList = invertedIndex.get(entry.getKey());
if(invertedList == null){
//当前键值对不存在 新加一个键值对即可
invertedList = new ArrayList<>();
invertedIndex.put(entry.getKey(),invertedList);
}
//此时invertedIndex已经是一个合法的ArrayList 直接加入Weight
invertedList.add(weight);
}
}
【关于索引模块的简单测试】
通过查看weight对象的打印,检验索引模块正排索引和倒排索引的实现是否正确。
public class TestIndex {
public static void main(String[] args) throws IOException {
Index index = new Index();
index.build("D:\\raw_data.txt");
List<Index.Weight> invertedList = index.getInverted("arraylist");
for(Index.Weight weight : invertedList){
System.out.println(weight.docId);
System.out.println(weight.word);
System.out.println(weight.weight);
DocInfo docInfo = index.getDocInfo(weight.docId);
System.out.println(docInfo.getTitle());
System.out.println("===================");
}
}
}
3.搜索模块
完成一次搜索过程基本流程。从用户输入查询词,到得到最终结果的过程。
1)针对输入的查询词进行分词;
List<Term> terms = ToAnalysis.parse(query).getTerms();
2)遍历分词结果,去索引中找到所有和分词结果相关的记录(一系列docId);
ArrayList<Index.Weight> allTokenResult = new ArrayList<>();
for(Term term : terms){
String word = term.getName();
List<Index.Weight> invertedList = index.getInverted(word);
if(invertedList == null){
//查询词在每个文档中都不存在
continue;
}
allTokenResult.addAll(invertedList);
}
3)针对相关性的高低,进行降序排序;
这里针对一个集合类进行排序时,尤其时集合类内部包含引用对象时,需要指定比较规则。
Comparable:实现被比较的类实现这个接口,重写compare To方法;
Comparator:创建一个比较器实现这个接口,重写compare方法。
allTokenResult.sort(new Comparator<Index.Weight>() {
@Override
public int compare(Index.Weight o1, Index.Weight o2) {
return o2.weight - o1.weight;
}
});
4)包装结果:把这些docId所对应的docInfo信息查找到,组装成一个响应数据。
List<Result> results = new ArrayList<>();
for(Index.Weight weight : allTokenResult){
//根据weight中包含的docId找到docInfo
DocInfo docInfo = index.getDocInfo(weight.docId);
Result result = new Result();
result.setTitle(docInfo.getTitle());
result.setShowUrl(docInfo.getUrl());
result.setClickUrl(docInfo.getUrl());
//GenDesc表示根据当前词找到这个词在正文中的位置 再把这个位置周围的文本获取到 得到一个描述
result.setDesc(GenDesc(docInfo.getContent(),weight.word));
}
return results;
private String GenDesc(String content,String word) {
//1.查找word在content中的位置
int firstPos = content.toLowerCase().indexOf(word);
if(firstPos == -1) {
//某个词在标题中出现 在正文中没出现
return "";
}
//定义描述为在firstPos前面60个字符 不足60个则从开头开始
//后面160个字符 不足160则到结尾即可
int descBegin = firstPos < 60 ? 0 : firstPos - 60;
int descEnd = content.length() - firstPos < 160 ? content.length() : firstPos + 160;
return content.substring(descBegin,descEnd) + "...";
}
【关于搜索模块的简单测试】
检验通过输入关键词是否能查询出一系列包含关键词的文档,且输出形式是否符合所设定的要求。
public class TestSearcher {
public static void main(String[] args) throws IOException {
Searcher searcher = new Searcher();
List<Result> results = searcher.search("arraylist");
for(Result result : results){
System.out.println(result);
System.out.println("=====================");
}
}
}
4.前端模块
显示最终结果,和用户进行交互。
public class DocSearcherServlet extends HttpServlet {
private Searcher searcher = new Searcher();
private Gson gson = new GsonBuilder().create();
public DocSearcherServlet() throws IOException {
}
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
resp.setContentType("application/json;charset=utf-8");
String query = req.getParameter("query");
if(query == null || query.equals("")){
resp.setStatus(404);
resp.getWriter().write("query参数非法");
return;
}
List<Result> results = searcher.search(query);
//将响应转化为json格式
String respString = gson.toJson(results);
resp.getWriter().write(respString);
}
}
把上述搜索类放到一个服务器中,通过服务器完成搜索过程。这里使用HTTP servlet.
1)约定前后端交互接口
请求:
GET /search?query=ArrayList
响应(使用Json来组织):
[
{
title:标题,
showUrl:展示URL,
clickUrl:点击URL,
desc:描述
},
{
}
]
2)构造请求并获取结果
当输入框中输入内容,就会自动被放在query这个变量;点击搜索就会自动调用search()函数,使用ajax向服务器发送Json格式的请求。
<div class="row">
<div class="col-md-5">
<input type="text" class="form-control" placeholder="请输入关键字" v-
model="query">
</div>
<div class="col-md-1">
<button class="btn btn-success" v-on:click="search()">搜索</button>
</div>
</div>
var vm = new Vue({
el: "#app",
data: {
query: "",
results: [ ]
},
methods: {
search() {
$.ajax({
url:"/java_doc_searcher/search?query=" + this.query,
type: "get",
context: this,
success: function(respData, status) {
this.results = respData;
}
})
},
}
})
3)获取到结果后显示到页面上
v-for是Vue提供的对象会循环访问result对象,将result中的Json数据返回。取到数据之后显示到页面上。
{{result.title}}是Vue中的语法,表示把result数据的标题放到页面上。
<div class="row" v-for="result in results">
<!--用来存放结果-->
<div class="title"><a v-bind:href="result.clickUrl">{{result.title}}</a></div>
<div class="desc">{{result.desc}}</div>
<div class="url">{{result.showUrl}}</div>
</div>
三.项目亮点:
(1)多线程读取文档
java8中共有10460个字符,使用单线程制作索引,制作索引共需要时间15054ms,严重拖慢执行时间;改用多线程读取文档;
public void runByThread() throws InterruptedException {
long beg = System.currentTimeMillis();
System.out.println("索引制作开始!");
// 1. 枚举出所有的文件
ArrayList<File> files = new ArrayList<>();
enumFile(INPUT_PATH, files);
// 2. 循环遍历文件. 此处为了能够通过多线程制作索引, 就直接引入线程池.
CountDownLatch latch = new CountDownLatch(files.size());
ExecutorService executorService = Executors.newFixedThreadPool(8);
for (File f : files) {
executorService.submit(new Runnable() {
@Override
public void run() {
System.out.println("解析 " + f.getAbsolutePath());
parseHTML(f);
latch.countDown();
}
});
}
// await 方法会阻塞, 直到所有的选手都调用 countDown 撞线之后, 才能阻塞结束.
latch.await();
// 手动的把线程池里面的线程都干掉!
executorService.shutdown();
// 3. 保存索引
index.save();
long end = System.currentTimeMillis();
System.out.println("索引制作完毕! 消耗时间: " + (end - beg) + "ms");
System.out.println("t1: " + t1 + ", t2: " + t2);
}
单线程:
多线程:
(2)加锁保证线程安全
通过多线程来实现时,由于这8个线程都需要调用addDoc()方法,addDoc()又需要调用buildForward()和buildInverted(),buildForward()需要修改forwardIndex,buildInverted()需要修改invertedIndex,那么就会引起线程安全问题。如果直接给addDoc()方法加入synchronized关键字,那么锁粒度太高了,就会导致效率并没有多大的提高,就可以在必要的地方加锁;
即在正排索引对forwardIndex操作和倒排索引对invertedIndex操作时需要加锁,又因为这两个对象对forwardIndex操作和invertedIndex操作互不影响,可以创建两个锁对象,分别对两个操作进行加锁,避免一起加锁带来的资源浪费。
// 6. 遍历刚才这个 HashMap, 依次来更新倒排索引中的结构了.
for (Map.Entry<String, WordCnt> entry : wordCntHashMap.entrySet()) {
// 先根据这里的词, 去倒排索引中查一查~
// 倒排拉链
synchronized (locker2) {
List<Weight> invertedList = invertedIndex.get(entry.getKey());
if (invertedList == null) {
// 如果为空, 就插入一个新的键值对
ArrayList<Weight> newInvertedList = new ArrayList<>();
// 把新的文档(当前 searcher.DocInfo), 构造成 searcher.Weight 对象, 插入进来.
Weight weight = new Weight();
weight.setDocId(docInfo.getDocId());
// 权重计算公式: 标题中出现的次数 * 10 + 正文中出现的次数
weight.setWeight(entry.getValue().titleCount * 10 + entry.getValue().contentCount);
newInvertedList.add(weight);
invertedIndex.put(entry.getKey(), newInvertedList);
} else {
// 如果非空, 就把当前这个文档, 构造出一个 searcher.Weight 对象, 插入到倒排拉链的后面
Weight weight = new Weight();
weight.setDocId(docInfo.getDocId());
// 权重计算公式: 标题中出现的次数 * 10 + 正文中出现的次数
weight.setWeight(entry.getValue().titleCount * 10 + entry.getValue().contentCount);
invertedList.add(weight);
}
}
private DocInfo buildForward(String title, String url, String content) {
DocInfo docInfo = new DocInfo();
docInfo.setTitle(title);
docInfo.setUrl(url);
docInfo.setContent(content);
synchronized (locker1) {
docInfo.setDocId(forwardIndex.size());
forwardIndex.add(docInfo);
}
return docInfo;
}
(3)多路计算文档权重
搜索"array list",观察搜索结果,不难发现,当遍历分词"array"时,生成一组docId,此时的docId是根据array计算权重;遍历分词"list"时,生成一组docId,此时docId说根据list计算权重,此时就有一个Collections.html文档,既包含array,又包含list分词,那么在统计docId时,Collections.html文档就被展示了两次,搜索结果中包含两个Collections.html文档,这显然时不合理的;
以上这种情况,意味着这个文档对搜索词而言,相关性更高,此时就应该提高这个文档的权重。此时采用将这两个权重相加的方式;
要想实现以上效果,就需要把多个分词结果触发出的文档进行去重,同时进行权重的合并;
两路数组归并:对比指向两个数组元素的值的大小关系,找到小的,插入到结果中;
N路数组归并:对比指向多个数组元素的值的大小关系,找到小的,插入到结果中;
在实际开发中,我采用先使用二维数组统计搜索结果,再使用mergeResult方法,对二维数组中内容进行合并。
// 通过这个内部类, 来描述一个元素在二维数组中的位置
static class Pos {
public int row;
public int col;
public Pos(int row, int col) {
this.row = row;
this.col = col;
}
}
private List<Weight> mergeResult(List<List<Weight>> source) {
// 在进行合并的时候, 是把多个行合并成一行了.
// 合并过程中势必是需要操作这个二维数组(二维List) 里面的每个元素的....
// 操作元素就涉及到 "行" "列" 这样的概念~~ 要想确定二维数组中的一个元素, 就需要明确知道 行 和 列
// 1. 先针对每一行进行排序(按照 id 进行升序排序)
for (List<Weight> curRow : source) {
curRow.sort(new Comparator<Weight>() {
@Override
public int compare(Weight o1, Weight o2) {
return o1.getDocId() - o2.getDocId();
}
});
}
// 2. 借助一个优先队列, 针对这些行进行合并
// target 表示合并的结果
List<Weight> target = new ArrayList<>();
// 2.1 创建优先级队列, 并指定比较规则(按照 Weight 的 docId, 取小的更优先)
PriorityQueue<Pos> queue = new PriorityQueue<>(new Comparator<Pos>() {
@Override
public 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();
}
});
// 2.2 初始化队列, 把每一行的第一个元素放到队列中.
for (int row = 0; row < source.size(); row++) {
// 初始插入的元素的 col 就是 0
queue.offer(new Pos(row, 0));
}
// 2.3 循环的取队首元素(也就是当前这若干行中最小的元素)
while (!queue.isEmpty()) {
Pos minPos = queue.poll();
Weight curWeight = source.get(minPos.row).get(minPos.col);
// 2.4 看看这个取到的 Weight 是否和前一个插入到 target 中的结果是相同的 docId
// 如果是, 就合并
if (target.size() > 0) {
// 取出了上次插入的元素
Weight lastWeight = target.get(target.size() - 1);
if (lastWeight.getDocId() == curWeight.getDocId()) {
// 说明遇到了相同的文档.
// 合并权重
lastWeight.setWeight(lastWeight.getWeight() + curWeight.getWeight());
} else {
// 如果文档 id 不相同, 就直接把 curWeight 给插入到 target 的末尾
target.add(curWeight);
}
} else {
// 如果 target 当前是空着的, 就直接插入即可
target.add(curWeight);
}
// 2.5 把当前元素处理完了之后, 要把对应这个元素的光标往后移动, 去取这一行的下一个元素
Pos newPos = new Pos(minPos.row, minPos.col + 1);
if (newPos.col >= source.get(newPos.row).size()) {
// 如果移动光标之后, 超出了这一行的列数, 就说明到达末尾了.
// 到达末尾之后说明这一行就处理完毕了~~
continue;
}
queue.offer(newPos);
}
return target;
}
(4)标红显示搜索结果关键字
后端:在result中获取描述时,遍历描述结果,找到对应查询词,给该查询词加入<i>word</i>标签;前端:给i标签加入标红样式。
// 在此处加上一个替换操作. 把描述中的和分词结果相同的部分, 给加上一层 <i> 标签. 就可以通过 replace 的方式来实现.
for (Term term : terms) {
String word = term.getName();
// 注意, 此处加空格表示进行全字匹配. 也就是当查询词为 List 的时候 不能把 ArrayList 中的 List 给单独标红
//(?i)表示不区分大小写进行替换
desc = desc.replaceAll("(?i) " + word + " ", "<i> " + word + " </i>");
}
.item .desc i {
color: red;
/* 去掉斜体 */
font-style: normal;
}
四.测试用例