项目简介
该项目主要实现了在前端页面的搜索框内输入需要搜索的 Java API 文档的关键字,对后端发出请求,后端将处理后的结果返回给前端展示,并且按照一定的权重排序展示出来。
- 开发环境:IDEA、Tomcat 9、Maven、JDK1.8
- 相关技术:正排索引、倒排索引、分词技术、过滤器、Servlet、Json、Ajax
- 本地资源:Java API 文档(点击这里获取)
项目详解
我主要从一下几个方面进行详解:
- 几个用于保存数据的实体类
- 通过本地 API 构建正排索引
- 构建倒排索引,并创建实体类
- 后端处理逻辑
- 测试
实体类
每一个 api 文档的 html 文件都对应一个该类,在该类中主要有四个属性字段,分别是:
id
:类似于数据库的主键可以对应单独一个文档
title
:文档的文件名
content
:文档的正文部分
url
:Oracle 官网上的 api 文档下 html 的 url 地址
/**
* 每一个html文件对应一个文档对象
*/
public class DocInfo {
private Integer id;
private String title;
private String content;
private String url;
}
该类表示的是某个关键词在某个文档中的权值,配合 Map<String, List<Weight>>
使用,表示某个关键词对应的所有文档及其对应的权值。在该类中主要有三个属性字段,分别是:
keyword
:关键词
docInfo
:该关键词对应的文档类
weight
:该关键词在该文档中的权值
public class Weight {
private DocInfo docInfo;
private String keyword;
private int weight;
}
该类表示的是将搜索内容进行分词后,会得到多个关键词,每个关键词会对应多个文档,而其中不乏出现重复的文档,这时就需要对重复文档进行合并,用文档 ID 作为唯一标识,将 ID 相同的文档的权值根据关键字先后顺序不同进行加权操作,最终所有会匹配到的文档都是唯一的,根据权值对其进行排序后返回前端展示。在该类中主要有五个属性字段,分别是:
id
:文档的唯一标识
weight
:合并后该文档的加权权值
title
:该文档的标题
url
:该文档的 url
decs
:该文档的描述
public class Result {
private Integer id;
private int weight;
private String title;
private String url;
private String desc;
}
构建正排索引
遍历 api 文档存储的目录,对每个 html 文件进行读取解析,并且将需要的信息提取出来并且封装到实体类 DocInfo
中,然后将所有提取到的信息持久化到本地的 raw_data
文件中。
public class Parser {
// api目录
public static final String API_PATH = "E:\\IDEA2020\\searchEngine\\jdk-8u261-docs-all\\docs\\api";
// 构建的本地文件的正拍索引
public static final String RAW_DATA = "E:\\IDEA2020\\searchEngine\\jdk-8u261-docs-all\\docs\\raw_data.txt";
// 官方api文档的根路径
public static final String API_BASE_PATH = "https://docs.oracle.com/javase/8/docs/api";
public static void main(String[] args) throws IOException {
// api本地路径下所有的html文件找到
List<File> htmls = listHtml(new File(API_PATH));
List<DocInfo> list = new ArrayList<>();
// 输出流
FileWriter fw = new FileWriter(RAW_DATA);
BufferedWriter bw = new BufferedWriter(fw);
for (File html : htmls) {
DocInfo doc = parseHtml(html);
// 输出格式为:title + '\3' + url + '\3' + content
bw.append(doc.getTitle()).append(String.valueOf('\3'));
bw.append(doc.getUrl()).append(String.valueOf('\3'));
bw.append(doc.getContent()).append("\n");
bw.flush();
}
bw.close();
fw.close();
}
// 将html文件转化为DocInfo对象
private static DocInfo parseHtml(File html) {
DocInfo docInfo = new DocInfo();
docInfo.setTitle(html.getName().substring(0, html.getName().length() - 5));
docInfo.setUrl(API_BASE_PATH + html.getAbsolutePath().substring(API_PATH.length()));
docInfo.setContent(parseContent(html));
return docInfo;
}
// 将html文件的内容部分提取
public static String parseContent(File html) {
StringBuilder sb = new StringBuilder();
try {
FileReader fr = new FileReader(html);
boolean isContext = false;
int i;
while ((i = fr.read()) != -1) {
char c = (char) i;
if (isContext) {
if (c == '<') {
isContext = false;
} else if (c == '\n' || c == '\r') {
sb.append(" ");
} else {
sb.append(c);
}
} else if (c == '>'){
isContext = true;
}
}
} catch (IOException e) {
throw new RuntimeException(e);
}
return sb.toString().trim();
}
// 递归遍历子文件
private static List<File> listHtml(File dir) {
File[] children = dir.listFiles();
List<File> list = new ArrayList<>();
for (File child : children) {
if (child.isDirectory()) { // 子文件夹:递归调用文件夹内的html文件
list.addAll(listHtml(child));
} else if (child.getName().endsWith(".html")){ // html文件
list.add(child);
}
}
return list;
}
}
构建倒排索引
- 首先加载本地文件内容,加载到正排索引的集合中
- 根据正排索引构建倒排索引(标题权重10,内容权重1),具体实现如下:
首先有一个 Map<String, List<Weight>>
集合表示一个关键词对应多个 api 文档,然后遍历存储所有 DocInfo
类的 List<DocInfo>
,对于每一个 DocInfo
都分为对标题和内容进行分词,分词技术使用的是一个开源的分词工具 Ansj,可以很高效的将句子进行分词处理。我们将分词之后的关键词加入 Map 集合,关键词作为键,Weight
类的集合作为值,用来保存每个关键词在对应的每个 api 文档中的权值。对于权值的计算,我们自定义的认为如果出现在标题中那么权值 +10,如果出现在文章中,那么权值 +1,从而构建好倒排索引。
public class Index {
// 正排索引
public static final List<DocInfo> FORWARD_INDEX = new ArrayList<>();
// 倒排索引
public static final Map<String, List<Weight>> INVERTED_INDEX = new HashMap<>();
// 构建正排索引
public static void buildForwardIndex(){
try {
FileReader fr = new FileReader(Parser.RAW_DATA);
BufferedReader br = new BufferedReader(fr);
String line = null;
int id = 0;
while ((line = br.readLine()) != null) {
if (line.trim().equals("")) continue;
String[] parts = line.split("\3");
DocInfo docInfo = new DocInfo();
docInfo.setId(++id);
docInfo.setTitle(parts[0]);
docInfo.setUrl(parts[1]);
docInfo.setContent(parts[2]);
FORWARD_INDEX.add(docInfo);
// if (id == 5) break;
}
} catch (IOException e) {
throw new RuntimeException(e); // 直接抛出异常,再构建阶段就发现异常
}
}
// 构建倒排索引: 从 java内从中的正排索引获取文档信息来构造
public static void buildInvertedIndex(){
Map<String, Weight> temp = new HashMap<>();
for (DocInfo docInfo : FORWARD_INDEX) {
// 一个doc,分别对标题和正文分词,每一个分词生成一个weigh对象,需要计算权重
// 计算title权重
String title = docInfo.getTitle();
List<Term> terms = ToAnalysis.parse(title).getTerms(); // 获取title分词结果
for (Term term : terms) {
if (temp.containsKey(term.getName())) {
temp.get(term.getName()).setWeight(temp.get(term.getName()).getWeight() + 10);
} else {
Weight we = new Weight();
we.setWeight(10);
we.setDocInfo(docInfo);
we.setKeyword(term.getName());
temp.put(term.getName(), we);
}
}
// 计算context权重
String content = docInfo.getContent();
terms = ToAnalysis.parse(content).getTerms(); // 获取title分词结果
for (Term term : terms){
if (temp.containsKey(term.getName())) {
temp.get(term.getName()).setWeight(temp.get(term.getName()).getWeight() + 1);
} else {
Weight we = new Weight();
we.setWeight(1);
we.setDocInfo(docInfo);
we.setKeyword(term.getName());
temp.put(term.getName(), we);
}
}
// 将该docInfo的关键词注入到总的关键词map
for (String key : temp.keySet()) {
if (INVERTED_INDEX.containsKey(key)) {
INVERTED_INDEX.get(key).add(temp.get(key));
} else {
List<Weight> list = new ArrayList<>();
list.add(temp.get(key));
INVERTED_INDEX.put(key, list);
}
}
// 注入完成后,释放temp内容
temp.clear();
}
}
}
后端逻辑(Servlet)
首先接收前端发来的请求信息(搜索内容),使用分词技术对搜索内容进行分词操作,会得到多个关键词,其根据先后排序有不同的权重,从第一个关键词开始查找,可以得到单个分词的 Weight
集合,里面包括包含该分词的所有文档及其权值。因为对于多个分词而言,可能出现一个文档包含多个分词的情况,所以这样就会出现文档重复的情况,故需要对搜索到的文档进行合并操作,具体操作由 Result
类以及 Map<Integer, Result>
配合完成,根据每个文档的唯一主键判断是否为同一文档,如果是同一个文档就对其值部分的 weight 属性字段进行权值相加操作,这样最后会得到不重复的结果集,最后对其进行按照权值降序排序,以 Json
格式返回给前端即可。(权值设定:设定初始权值为 100 ,对于多个关键字,按排序依次权值减半)
代码如下:
@WebServlet("/search")
public class SearchServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
System.out.println("进入SearchServlet - doGet");
// 构造返回给前端的内容,使用对象,在使用jackson序列化为json字符串
Map<String, Object> map = new HashMap<>();
// 解析请求的数据
String query = req.getParameter("query");
// 每个文档转换为一个Result(可能出现多个分词对应一个文档,需要进行文档合并)
Map<Integer, Result> resultMap = new HashMap<>();
// 初始权重为100,随着排序依次减半
int w = 100;
try {
// 根据搜索内容处理搜索业务
// 1. 根据搜索内容,进行分词,遍历每个分词
List<Term> terms = ToAnalysis.parse(query).getTerms();
for (Term term : terms) {
String word = term.getName(); // 单个分词
List<Weight> weightList = Index.INVERTED_INDEX.get(word); // 获取到单个分词的 Weight 集合
// 2. 每个分词,在倒牌中查找对应的文档(一个分词对一个多个文档)
for (Weight weight : weightList) {
final Integer id = weight.getDocInfo().getId();
// 文档合并
if (resultMap.containsKey(id)) {
// 存在id
resultMap.get(id).setWeight(resultMap.get(id).getWeight() + w);
} else {
// 不存在id
final Result temp = new Result(id, weight.getWeight(),
weight.getDocInfo().getTitle(),
weight.getDocInfo().getUrl(),
weight.getDocInfo().getContent().substring(0, 100) + "......");
resultMap.put(id, temp);
}
}
// 每过一个关键字,w减半
w >>= 1;
}
// 4. 文档合并后,根据权重对List<Result>进行排序
List<Result> resultList = new ArrayList<>(resultMap.values());
resultList.sort((o1, o2) -> o2.getWeight() - o1.getWeight());
map.put("data", resultList);
// 如果成功
map.put("ok", true);
} catch (Exception e) {
e.printStackTrace();
// 失败
map.put("ok", false);
}
// 获取输出流
PrintWriter pw = resp.getWriter();
// 将map序列化为json对象,然后通过ajax传递给前端
pw.print(new ObjectMapper().writeValueAsString(map));
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
doGet(req, resp);
}
}
还有一点就是使用过滤器在初始化的时候完成正排索引和倒排索引的构建,同时设置字符集编码为“UTF-8”
,响应格式为 Json
格式。
测试
-
启动Tomcat,我们可以看到以下展示的是搜索页面
-
输入关键词
Map
,点击Search
进行搜索,下面展示的搜索内容
-
随便点击一个链接,比如
TreeMap
,我们可以看到会跳转至 Oracle 官网的 API 文档链接
结果表明搜索功能已经基本实现,后续还可以对其进行改进,比如缓存操作可以加快搜索速度,还有前端的美化,以及目前 api 文档的解析手法比较粗糙,可能会出现将代码解析至内容中的情况,所以后续还可以对解析手法进行进一步优化。代码已经上传至 GitHub,点此获取…