項目詳情: https://github.com/BlackerGod/Java_search_api
成品展示: 點擊查看
(ps:受限於服務器帶寬和處理器,會導致這個有點慢。。。)
一、項目需求
當我們遇到Java內一些不熟悉的函數或者寶,我們都需要去查詢官方文檔,那麼問題就來了,我們可以看到的是官方文檔並沒有提供一個查詢接口,我們每次使用時還要知道在哪個包下,然後再去一個一個查找,費時費力。那麼我們今天就做一個項目,來實現對Java官方文檔的查詢功能;
二、項目分析
當我們需要查找的時候,必須要已經獲取到了整個頁面的資源,我們要知道每個資源都在哪裏,這塊我們可以用爬蟲實現(比較麻煩),我們先下載好整個文檔的資源,然後對這個資源做一些處理,然後從文件中查找,最終返回他的url、描述、題目等信息。
三、項目設計
1.預處理模塊:把下載好的html文檔進行一次初步的處理(簡單分析結構並且幹掉其中的html標籤)
把api目錄中的所有html進行處理 =》 得到一個單個文件。使用行文本的方式進行組織(爲了製作索引方便)
第一列:文檔標題
第二列:文檔url
第三列:文檔正文(去掉HTML標籤)
2.索引模塊:預處理得到的結果,構建正排+倒排索引
3.搜索模塊:完成一次搜索過程基本流程(從用戶輸入查詢詞,得到最終的搜索結果)
4.前端模塊:有一個頁面,展示結果並且讓用戶輸入數據
四、編碼
1.預處理模塊
我們先下載好文檔:
我們只錄入api,可以看到都是一些html頁面,這其實是對應着官方的頁面的
那麼我們先找完整個文件夾,並且把每個html的title、url、content(除了標籤的文段)全部保存下來。
package parser;
import java.io.*;
import java.util.ArrayList;
/**
* 遍歷文檔目錄,讀取所有的html文檔內容,把結果解析成行文本文件
* 每一行對應一個文檔,每一行都包含文檔信息
* Parser是一個單獨可以執行的類(含main)
*/
public class Parser {
private static final String INPUT_PATH = null; //下載的api路徑
private static final String OUTPUT_PATH = null; //輸出處理文件的路徑
/**
* //完成預處理
* 1.枚舉INPUT_PATH下所有html文件(遞歸)
* 2.對html文件路徑進行遍歷,一次打開每個文件,並讀取內容
* 3.把內容轉換成需要結構化的數據(DocInfo對象),然後寫出文件
* @param args
*/
public static void main(String[] args) throws IOException {
FileWriter fileWriter = new FileWriter(new File(OUTPUT_PATH));
ArrayList<File> fileList = new ArrayList<>();
enumFile(INPUT_PATH,fileList);
for (File f : fileList){
//System.out.println("converting" + f.getAbsolutePath() + "...");
String line = convertLine(f);
//System.out.println(line);
fileWriter.write(line);
}
fileWriter.close();
}
/**
*
* @param f 文件
* @return 根據文件來獲取標題.url.content;
*/
private static String convertLine(File f) throws IOException {
String title = convertTitle(f);
String url = convertUrl(f);
String content = convertContent(f);
// \3起到分隔三個部分的效果. \3爲ascii碼爲3的字符
return title + "\3" + url + "\3" + content + "\n";
}
private static String convertContent(File f) throws IOException {
//把標籤和\n去掉
FileReader fileReader = new FileReader(f);
boolean isContent = true;
StringBuilder output = new StringBuilder();
while (true){
int ret = fileReader.read();
if(ret == -1){
break;
}
char c = (char)ret;
if(isContent){//是正文
if(c == '<'){
isContent = false;
continue;
}
if(c == '\n' || c == '\r'){ //\n換行,\r表示回車
c = ' ';
}
output.append(c);
} else { // 是標籤
if(c == '>'){
isContent = true;
}
}
}
fileReader.close();
return output.toString();
}
private static String convertUrl(File f) {
//線上文檔對應的Url
String prev = "https://docs.oracle.com/javase/8/docs/api";
String text = f.getAbsolutePath().substring(INPUT_PATH.length());
text = text.replaceAll("\\\\","/");//轉不轉換都可以的
return prev + text;
}
private static String convertTitle(File f) {
//把文件名當做標題就可以了(去掉.html)
String name = f.getName();
return name.substring(0,name.length() - ".html".length());
}
/**
*
* @param inputPath 當前目錄
* @param fileList 已經保存的文件列表
*/
private static void enumFile(String inputPath, ArrayList<File> fileList) {
//遞歸把inputPath對應的全部目錄和文件都遍歷一遍
File root = new File(inputPath);
File[] files = root.listFiles(); //查看當前路徑下的所有文件(包括文件夾)
for (File f : files){
if(f.isDirectory()){
enumFile(f.getAbsolutePath(),fileList);
//遞歸向下
} else if(f.getAbsolutePath().endsWith(".html")){
//是否是.html,是的話就添加
fileList.add(f);
}
}
}
}
當處理完成之後,我們就得到一個文件:
我們獲取到了,名字,url,正文。這個過程只生成一次就行,以後只用tmp.txt了。
2.索引模塊
先清楚兩個概念:
【正排索引】:根據文章的ID去找搜索詞是否存在
【倒排索引】:根據文章中出現了搜索詞找到文章ID
然後我們又遇到一個問題,就是這個關鍵詞可能在很多文章都出現了,那我們如何得知它就是我們最需要的呢?
那我們就需要去計算一個【權重】,根據權重去排序,這裏我就簡單的寫了一下計算方法
【權重】=關鍵詞在題目中出現的次數 x 10 + 關鍵詞在正文中出現的次數x1;
package common;
public class DocInfo {
private int docId; //文章ID不能重複
private String title;//文檔標題,用文件名命名
private String url;//線上URL,根據本地構造
private String content;//html輸出標籤的內容
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;
}
@Override
public String toString() {
return "DocInfo{" +
"docId=" + docId +
", title='" + title + '\'' +
", url='" + url + '\'' +
", content='" + content + '\'' +
'}';
}
}
然後開始把文件加到到內存中;
package index;
import common.DocInfo;
import org.ansj.domain.Term;
import org.ansj.splitWord.analysis.ToAnalysis;
import java.io.*;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 構建索引,正排索引(ID =》 文檔),倒排索引(文檔 =》 ID)
*/
public class Index {
/**
* 這個靜態類是爲了計算權重。
*/
static public class Weight{
public String word;
public int docId;
public int weight;
//weight = titleCount*10 + textCount;
}
// 正排索引
private ArrayList<DocInfo> forwardIndex = new ArrayList<>();
// 倒排索引,不僅知道在那個docId下,而且要顯示其權重
// 權重:該詞和文檔的關聯程度
private HashMap<String,ArrayList<Weight>> invertedIndex = new HashMap<>();
/**
* 查詢正排
* @param docId 文章ID
* @return 文章信息
*/
public DocInfo getDocInfo(int docId){
return forwardIndex.get(docId);
}
/**
* 查詢倒排
* @param term 目標詞
* @return 文章列表
*/
public ArrayList<Weight> getInverted(String term){
return invertedIndex.get(term);
}
/**
* 把txt文件內容讀取出來,加載到內存上面的數據結構
* \3分隔
*/
public void build(String path) throws IOException {
long startTime = System.currentTimeMillis();
System.out.println("build start");
// 1.打開文件,按行讀取
BufferedReader bw = new BufferedReader(new FileReader(new File(path)));
// 2.接收每一行
String line = "";
while((line = bw.readLine()) != null){
// 3.構造正排的過程:按照 \3來切分,切分結果構造成DocInfo對象,加入數據結構
DocInfo docInfo = buildForward(line);
// 4.構造倒排的過程
buidInverted(docInfo);
System.out.println("Build" + docInfo.getTitle() + "Finished");
}
bw.close();
long finishTime = System.currentTimeMillis();
System.out.println("build finished Time" + (finishTime - startTime)+"ms");
}
/**
*
* @param line 正排就是字符串切分
* @return 返回docInfo
*/
private DocInfo buildForward(String line) {
// 把一行按照\3切分
// 分出來的三個部分就是一個文檔的 標題 url 正文;
String[] tokens = line.split("\3");
if(tokens.length != 3){
// 文件格式有問題
System.out.println("文件格式存在問題:" + line);
return null;
}
DocInfo docInfo = new DocInfo();
// id 就是正排索引下標
docInfo.setDocId(forwardIndex.size());
docInfo.setTitle(tokens[0]);
docInfo.setUrl(tokens[1]);
docInfo.setContent(tokens[2]);
forwardIndex.add(docInfo);
return docInfo;
}
private void buidInverted(DocInfo docInfo) {
/**
*計算權重的類
*/
class WordCnt{
public int titleCount;
public int contengtCount;
public WordCnt(int titleCount, int contengtCount) {
this.titleCount = titleCount;
this.contengtCount = contengtCount;
}
}
HashMap<String,WordCnt> wordCntHashMap = new HashMap<>();
// 1.對標題分詞(分詞是靠依賴實現的)
List<Term> titleTerms = ToAnalysis.parse(docInfo.getTitle()).getTerms();
// 2.遍歷分詞結果,統計標題中的每個詞出現頻率
for (Term term : titleTerms){
// 此處word已經轉成小寫了
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.contengtCount++;
}
}
// 5.遍歷HashMap,一次構建weight對象並更新倒排索引的映射關係
for (Map.Entry<String,WordCnt> entry : wordCntHashMap.entrySet()){
Weight weight = new Weight();
weight.word = entry.getKey();
weight.docId = docInfo.getDocId();
weight.weight = entry.getValue().titleCount * 10 + entry.getValue().contengtCount;
//weight加入到倒排索引中
ArrayList<Weight> invertedList = invertedIndex.get(entry.getKey());
if(invertedList == null){
// 不存在
invertedList = new ArrayList<>();
invertedIndex.put(entry.getKey(),invertedList);
}
invertedList.add(weight);
}
}
}
構造倒排索引這塊有點不好理解,我還是畫個圖吧
這是兩篇文章,正排索引就是根據docID去返回整篇文章內容,而倒排索引就是根據裏面的內容來進行返回文章docID
構建倒排索引的過程:
【首先】針對一個docInfo,我們創建一個來記錄一篇文章中,詞分別出現的次數,當結束之後,就是像我們下圖這樣了
構造結束後,我們查詢總的結構
就是看看,這個詞是否已經存在,如果存在就把它的docID和weigh信息放在順序表中
最終我們再根據weight中的weight來排序即可
3.搜索模塊
搜索模塊就很簡單了,我們是web應用,寫一個Serverlet調用響應的查詢方法就行
我是以json格式傳輸,使用了Gson。
package api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import searcher.Result;
import searcher.Searcher;
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.List;
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){
resp.setStatus(404);
resp.getWriter().write("query參數非法");
return;
}
List<Result> results = searcher.search(query);
String respString = gson.toJson(results);
resp.getWriter().write(respString);
}
}
還有就是web.xml的配置,我們可以看到的是,由於構建比較慢,所以第一個提交請求的用戶會等很久,之後已經都加載到內存裏了,就比較快了。爲了避免這個情況,我們就在啓動時先構建一次可以避免
<web-app>
<display-name>Archetype Created Web Application</display-name>
<servlet>
<servlet-name>DocSearcherServlet</servlet-name>
<servlet-class>api.DocSearcherServlet</servlet-class>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>DocSearcherServlet</servlet-name>
<url-pattern>/search</url-pattern>
</servlet-mapping>
</web-app>
4.前端模塊
這塊我不是很擅長,查了一些東西最終才搞定
<html>
<head>
<!-- Bootstrap 文檔: https://v3.bootcss.com/css/ -->
<!-- Vue 文檔: https://cn.vuejs.org/v2/guide/ -->
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<!-- Bootstrap CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
<title>Java API 搜索</title>
<style>
#app {
margin-left:50px;
margin-right:50px;
}
div button {
width:100%;
}
.row {
padding-top: 10px;
}
.col-md-5,.col-md-1 {
padding-left:2;
padding-right:2;
}
.title {
font-size: 22px;
}
.desc {
font-size: 18px;
}
.url {
font-size: 18px;
color: green;
}
</style>
</head>
<body>
<div id="app">
<div class="row">
<img src="image/1.jpg" width="55px" height="60px" />
</div>
<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>
<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>
</div>
</body>
<script src="https://apps.bdimg.com/libs/jquery/2.1.4/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.min.js" integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/vue"></script>
<script>
var vm = new Vue({
el: "#app",
data: {
query: "",
results: [ ]
},
methods: {
search() {
$.ajax({
url:"/JavaAPI/search?query=" + this.query,
type: "get",
context: this,
success: function(respData, status) {
this.results = respData;
}
})
},
}
})
</script>
</html>
前端頁面也沒有啥,主要是寫css,Javascript的話,主要是把query提交給後端處理,然後接收過來的數據,分別處理。