文章目录
- 实现的基本功能
- 主要涉及的技术
- 实现的流程
- 项目的测试
实现的基本功能
由于官方自带的javaAPI文档只能通过查字典的类似方式来进行查找自己所需要的类的描述,构造方法,成员方法等等,这对于小白的我来说是学习好java入门的第一步.
然而,这密密麻麻的字母让人查找自己所需要的部分时变得格外"心累"…
常说软件让生活变得更加轻松高效.这个项目就可以基于下载到本地的API文档内容,在前端页面的搜索框内输入需要搜索的 Java API 文档的关键字,对后端发出请求,后端将处理后的结果返回给前端展示,并且按照一定的权重排序展示出来.
效果图:
特点:可通过搜索的方式快速定位到要查找的 API
主要涉及的技术
- 正排索引(从文档指向关键词)
从文档出发,以文档的id为key,表中记录文档相关信息的每个关键字.
正排索引的优点:容易维护;缺点:搜索耗时太长
一般正排索引起到的作用是id到文档相关信息的映射 - 倒排索引(从关键词指向文档)
从关键词出发,以关键词为key,表中可以记录各种文档相关信息.
倒排索引的优点:搜索速度快;缺点:以空间换时间,构建时耗时,维护成本高
本项目中用到的倒排索引是hash索引. - 分词技术
在这个项目中,利用的是一个第三方分词器–Ansj分词器
通过在网上学习其基本用法:
public class TestAnsj {
public static void main(String[] args) {
String str = "小明毕业于清华大学计算机专业," +
"后来去蓝翔技校和新东方深造," +
"擅长使用计算机控制挖掘机炒菜";
List<Term> terms = ToAnalysis.parse(str).getTerms();
for (Term term : terms) {
System.out.print(term.getName() + "/");
}
}
}
效果如下:可以基本把输入的语句进行分解
- Servlet
本次项目中涉及的servlet的知识为:
NO.1 Servlet 生命周期有关的方法一般有以下三个:
(1) init() 方法
init() 方法是在创建 Servlet 对象时被调用,而且只能被调用一次,用于 Servlet 对象在整个生命周期内的唯一一次初始化。只有在 init() 方法调用成功后,Servlet 才会处于服务状态,才能够去处理客户端的请求。
(2) service() 方法
service() 方法是 Servlet 工作的核心方法。当客户端请求访问 Servlet 时,Servlet 容器就会调用 service() 方法去处理来自客户端的请求,并把处理后的响应返回给客户端。
(3) destroy() 方法
destory() 方法是 Servlet 容器回收 Servlet 对象之前调用的,且只会调用一次,而此时的服务器处于停止状态或者访问资源已经被移除
本次项目中只涉及init方法
NO.2 Servlet中重写doGet()方法 - json
json,是一种数据格式(很优雅),在前端与后端的数据交互中有较为广泛的应用.给数据一个统一的格式有利于我们在前后端交互的时候编写和解析数据.
实现的流程
- 预处理下载好的官方javaAPI文档
遍历本地的javaAPI文档中的静态html文件,每一个html需要构建正排索引信息为list其中的 DocInfo(id,title,content,url)
详细处理步骤:
(1)遍历目录,枚举出文档目录下的所有HTML文件,获取文件名,再添加官方api文档根路径形成所需信息的url.
(2)遍历每个html文件内容并解析,只取内容(<标签>内容<标签>)
(3)每行对应一个HTML,每一行中有三列,分别是标题,url,正文
(4)整理成一个行文本文件(raw_data)
Parser类中的主要流程:
/**
* 步骤一:
* 从本地api目录,遍历静态html文件
* 每一个html需要构建正排索引:本地某个文件
* 正文索引信息List<DocInfo>
* DocInfo(id, title, content, url)
*/
public static void main(String[] args) throws IOException {
//找到api本地路径下所有的html文件
List<File> htmls = listHtml(new File(API_PATH));
FileWriter fw = new FileWriter(RAW_DATA);
// BufferedWriter bw = new BufferedWriter(fw);
PrintWriter pw = new PrintWriter(fw, true);
//autoFlush表明:打印输出流,自动刷新缓冲区
for(File html : htmls){
//一个html解析DocInfo有的属性
DocInfo doc = parseHtml(html);
//保存本地正排索引文件:输出流输出到目标文件
//格式:一行为一个doc,title+\3+url+\3+content
String uri = html.getAbsolutePath().substring(API_PATH.length());
System.out.println("Parse: "+uri);
if(doc.getTitle().contains("�")){
System.out.println("title====================="+doc.getTitle());
}
if(doc.getContent().contains("�")){
System.out.println("content====================="+doc.getContent());
}
pw.println(doc.getTitle()+"\3"+doc.getUrl()+"\3"+doc.getContent());
}
}
DocInfo类:
/**
* 每一个本地html文件对应一个文档对象
*/
public class DocInfo {
private Integer id;//类似数据库主键,识别不同的文档
private String title;//标题:html文件名作为标题
private String url;//oracle官网api文档下html的url
private String content;//网页正文:html(<标签>内容</标签>),内容为正文
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
DocInfo docInfo = (DocInfo) o;
return Objects.equals(id, docInfo.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
@Override
public String toString() {
return "DocInfo{" +
"id=" + id +
", title='" + title + '\'' +
", url='" + url + '\'' +
", content='" + content + '\'' +
'}';
}
}
- 建立正排索引和倒排索引
(1)建立正排索引:从本地文件数据中读取到java内存
try {
FileReader fr = new FileReader(Parser.RAW_DATA);
BufferedReader br = new BufferedReader(fr);
int id = 0;//行号设置为docInfo的id
String line;
while((line=br.readLine()) != null){
if(line.trim().equals("")) continue;
//一行对应一个DocInfo对象,类似数据库一行数据对应java对象
DocInfo doc = new DocInfo();
doc.setId(++id);
String[] parts = line.split("\3");//每一行按\3间隔符切分
doc.setTitle(parts[0]);
doc.setUrl(parts[1]);
doc.setContent(parts[2]);
//添加到正排索引
System.out.println(doc);
FORWARD_INDEX.add(doc);
}
} catch (IOException e) {
//不要吃异常,初始化操作有异常让线程不捕获异常,从而结束程序
//记得初始化(启动tomcat),有问题,尽早暴露问题
throw new RuntimeException(e);
}
(2)构建倒排索引:从java内存中获取文档信息来构建倒排索引
标重点:需要在对象中重写hashcode和equals(就像你在茫茫人海中找对象一样,他必须符合这个项目场景)
具体做法:遍历正排索引,分别分割出来标题和正文内容,通过分词的方式将标题和正文的关键词拿出来,放在一个map<关键词,权重>的map里面,每当在标题中遇见关键词其权值+10,在正文中遇到关键词其权值+1,
最终遍历完分词后形成一个倒排索引.
for(DocInfo doc : FORWARD_INDEX){//doc+分词 对应 weight(doc和分词一对多,分词和weight一对一)
//一个doc,分别对标题和正文分词,每一个分词生成一个weight对象,需要计算权重
//第一次出现的分词关键词,要new Weight对象,之后出现相同分词关键词时,
// 要获取之前已经拿到的相同关键词weight对象,再更新权重(把自己的权限加进去)
//实现逻辑:先构造一个HashMap,保存分词(键)和weight对象(value)
Map<String, Weight> cache = new HashMap<>();
List<Term> titleFencis = ToAnalysis.parse(doc.getTitle()).getTerms();
for(Term titleFenci : titleFencis){//标题分词并遍历处理
Weight w = cache.get(titleFenci.getName());//获取标题分词键对应的weight
if(w == null){//如果没有,就创建一个并放到map中
w = new Weight();
w.setDoc(doc);
w.setKeyword(titleFenci.getName());
cache.put(titleFenci.getName(), w);
}
//标题分词,权重+10
w.setWeight(w.getWeight()+10);
}
//正文的分词处理:逻辑和标题分词逻辑一样
List<Term> contentFencis = ToAnalysis.parse(doc.getContent()).getTerms();
for(Term contentFenci : contentFencis){
// if(contentFenci.getName().contains("�")){
// System.out.println("content fenci==========[url: "+doc.getUrl()+"]");
// }
Weight w = cache.get(contentFenci.getName());
if(w == null){
w = new Weight();
w.setDoc(doc);
w.setKeyword(contentFenci.getName());
cache.put(contentFenci.getName(), w);
}
//正文分词,权重+1
w.setWeight(w.getWeight()+1);
}
//把临时保存的map数据(keyword-weight)全部保存到倒排索引
for(Map.Entry<String, Weight> e : cache.entrySet()){
String keyword = e.getKey();
Weight w = e.getValue();
//更新保存到倒排索引Map<String, List<Weight>>-->多个文档,同一个关键词,保存在一个List
//先在倒排索引中,通过keyword获取已有的值
List<Weight> weights = INVERTED_INDEX.get(keyword);
if(weights == null){//如果拿不到,就创建一个,并存放进倒排索引
weights = new ArrayList<>();
INVERTED_INDEX.put(keyword, weights);
}
// System.out.println(keyword+": ("+w.getDoc().getId()+", "+w.getWeight()+")");
weights.add(w);//倒排中,添加当前文档每个分词对应的weight对象
}
- 进行搜索
大致思路:当用户输入查找内容是,由后端处理输入的内容—分词,按照关键词在倒排索引中查找,如果查找到了,就拿到这个权重的list,并且遍历这个list,按照list里面的权重进行降序输出,后端将结果转成一个对象然后在序列化为json字符串,最终返回给前端.
即:(1)搜索内容分词,遍历分词
(2)每个分词倒排去查找
(3)构造返回对象,按权重降序输出
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
req.setCharacterEncoding("UTF-8");
resp.setCharacterEncoding("UTF-8");
resp.setContentType("application/json");//ajax请求,响应json格式
//构造返回给前端的内容:使用对象,之后再序列化为json字符串
Map<String, Object> map = new HashMap<>();
//解析请求数据
String query = req.getParameter("query");//搜索框内容
List<Result> results = new ArrayList<>();
try{
//根据搜索内容处理搜索业务
//校验请求数据:搜索内容
if(query == null || query.trim().length() == 0){
map.put("ok", false);
map.put("msg", "搜索内容为空");
}else{
//1.根据搜索内容,进行分词,遍历每个分词
for(Term t : ToAnalysis.parse(query).getTerms()){
String fenci = t.getName();//搜索的分词
//如果分词是没有意义的分词,就跳过
//TODO 定义一个数组,包含没有意义的关键词 if(isValid(fenci)) continue;
//2.每个分词,在倒排中查找对应的文档(一个分词对应多个文档)
List<Weight> weights = Index.get(fenci);
//3.一个文档转换为一个Result(不同分词可能存在相同文档,需要合并)
for(Weight w : weights){
//转换weight为result
Result r = new Result();
r.setId(w.getDoc().getId());
r.setTitle(w.getDoc().getTitle());
r.setWeight(w.getWeight());
r.setUrl(w.getDoc().getUrl());
//排脑门决定业务:文档内容超过60的部分隐藏为...后面可以更改这个比例
String content = w.getDoc().getContent();
r.setDesc(content.length()<=60?content:content.substring(0,60)+"...");
//TODO 暂时不做合并,合并操作,需要在List<Result>:
// (1)找已有的,判断docId相同,直接在已有的Result权重加上现有的
// (2)不存在,直接放进去
results.add(r);
}
}
//4.合并完成后,对List<Result>排序:权重降序排序
results.sort(new Comparator<Result>() {
@Override
public int compare(Result o1, Result o2) {
// return Integer.compare(o1.getWeight(), o2.getWeight());//权重升序
return Integer.compare(o2.getWeight(), o1.getWeight());//权重降序
}
});
map.put("ok", true);
map.put("data", results);
}
}catch (Exception e){
e.printStackTrace();
map.put("ok", false);
map.put("msg", "未知错误");
}
PrintWriter pw = resp.getWriter();//获取输出流
//设置响应体内容:map对象序列化为json字符串
pw.println(new ObjectMapper().writeValueAsString(map));
}
项目的测试
注:①此项目不支持模糊搜索的功能,所以如果要查找一个 API 必须准确的输入才能被查找到。②我看了一下官方文档,最短的 API 长度为 3 ,最长的 API 这里先不考虑。
一、功能测试
1.等价类和边界值划分:
有效等价类 | 无效等价类 |
三位及三位以上英文字符 | 三位以下英文字符 |
数字字符 | |
中文字符 | |
特殊字符 |
2.设计测试用例:
测试用例 | 期待结果 |
ArrayList | 能被查找到并正确的显示到页面上 |
(不输入任何字符) | 查找不到内容 |
a | 查找不到内容 |
a… | 查找不到内容 |
? | 查找不到内容 |
aaaaaa | 查找不到内容 |
, | 查找不到内容 |
123 | 查找不到内容 |
a123 | 查找不到内容 |
;123 | 查找不到内容 |
;aaa | 查找不到内容 |
li | 查找不到内容 |
链表 | 查找不到内容 |
二、单元测试
单元测试我是在开发阶段在 IDEA 中创建测试类进行测试。
1.测试 Parser 类:
public class ParserTest {
@Test
public void convertLine() throws IOException {
Parser parser = new Parser();
File file = new File("D:\\javaSE8 Doc\\docs\\api\\java\\math\\BigInteger.html");
String res = parser.convertLine(file);
System.out.println(res);
}
@Test
public void convertContent() throws IOException {
File file = new File("D:\\Test.txt");
Parser parser = new Parser();
String res = parser.convertContent(file);
System.out.println(res);
}
@Test
public void convertUrl() {
File file = new File("D:\\javaSE8 Doc\\docs\\api\\EntityResolver.html");
Parser parser = new Parser();
String res = parser.convertUrl(file);
System.out.println(res);
}
@Test
public void convertTitle() {
File file = new File("D:\\javaSE8 Doc\\docs\\api\\EntityResolver.html");
Parser parser = new Parser();
String res = parser.convertTitle(file);
System.out.println(res);
}
}
2.测试 Index 类:
public class IndexTest {
@Test
public void getDocInfo() throws IOException {
Index index = new Index();
index.build("D:\\data.txt");
Index.Weight weight = new Index.Weight();
DocInfo docInfo = index.getDocInfo(weight.docId);
System.out.println(docInfo);
}
@Test
public void getInverted() throws IOException {
Index index = new Index();
index.build("D:\\data.txt");
List<Index.Weight> weights = index.getInverted("arraylist");
for (Index.Weight weight : weights) {
System.out.println(weight.docId);
System.out.println(weight.word);
System.out.println(weight.weight);
System.out.println();
}
}
}
3.测试 Searcher 类:
public class SearcherTest {
@Test
public void search() throws IOException {
Searcher searcher = new Searcher();
List<Result> res = searcher.search("arraylist");
for (Result result : res) {
System.out.println();
System.out.println("=============================================");
System.out.println();
System.out.println(result);
}
}
}
三、自动化测试
自动化测试使用的是 unittest 框架写的脚本,并将运行结果生成 HTML 报告:
from selenium import webdriver
import unittest
import time
class imageTest(unittest.TestCase):#定义的类
def setUp(self):#初始化
self.driver = webdriver.Chrome()
self.driver.get("http://39.97.104.31:8080/docSearcher/")
self.driver.maximize_window()
time.sleep(3)
def test_mytest(self):#测试的方法必须以 'test_' 开头
self.driver.find_element_by_xpath("//*[@id='app']/div[2]/input").send_keys("arraylist")
time.sleep(3)
self.driver.find_element_by_xpath("//*[@id='app']/div[2]/button").click()
time.sleep(3)
self.driver.find_element_by_xpath("//*[@id='app']/div[3]/div[1]/a").click()
time.sleep(3)
def tearDown(self):#清理测试环境
self.driver.quit()#close 也可以,但是 quit 可以清理掉缓存
if __name__ == "__main__":
unittest.main()