项目地址:https://gitee.com/Vertas/boost-searcher-project
1. 项目背景
- 日常生活中我们使用过很多搜索引擎,比如百度,搜狗,360搜索等。我们今天是要实现一个像百度这样的搜索引擎嘛?那是不可能的,因为像百度这样的搜索引擎搜索的是全网的数据。其数据量之庞大远远超出我们的想象。
- 今天我们要实现的 Boost 搜索引擎是一个栈内搜索引擎。也就是在 Boost 官网https://www.boost.org/ 进行搜索。站内搜索的数据量更加垂直,其实就是数据量更加小!
- 我们为什么要做这个项目的原因还有一个:Boost 官网中并没有栈内搜索的功能。
我们可以在百度中搜索一个关键字看看是什么效果:
我们看到所有的网页都有上图中标注的三个部分:标题,网页内容简介,网页 url。
同理,我们实现的 Boost 搜索引擎搜索关键字时也要展示这三部分信息。
2. 搜索引擎的宏观原理
- 我们需要下载 Boost 库中所有页面的 html 文件,作为后台响应数据。
- 下载完成后,我们需要编写代码,对所有的 html 文件进行去标签,清理数据以及建立索引的工作。
- 我们通过浏览器访问服务器,就是在向服务器发送 Http 请求,通过 Http 请求能够将我们搜索的关键字上传给服务器。
- 服务器就会根据用户搜索的关键字,在提前建立好的索引中查找,将相关的数据返回给用户,用户的浏览器解析之后就能看到搜索的结果啦!
3. 项目的技术栈和环境
- 技术栈: C/C++,C++11, STL, 准标准库Boost,Jsoncpp,cppjieba,cpp-httplib,html5,css,js、jQuery、Ajax。
- 项目环境: Centos 7云服务器,vim/gcc(g++)/Makefile,vscode。
4. 编写数据去标签以及数据清理模块
4.1 下载 Boost 库中所有的 html 页面
-
下载链接:Boost下载
-
使用
rz
命令将下载好的文件上传到 centos 服务器。 -
使用
tar -zxvf
解压下载好的压缩包。 -
我们想要的仅仅是 html 文件,其他的文件我们是不需要的。使用
find
命令来查看下载好的文件到底有多少 html 文件:
可以看到一共是有 23987 个 html 文件哈!
4.2 解析 html 文件
我们来看看 html 文件长什么样子,以及什么是标签:
- 双标签由开始标签和结束标签组成,如图标注的双标签:
<head>
就是开始标签,</head>
就是结束标签。 - 单标签就只有一个标签哈,如上图中的
<meta>
标签。 - 我们要做的工作就是将这些标签全部去掉,只保留网页的内容部分。
显然,在去标签之前肯定要将 html 文件读取到内存,但是我们下载的 Boost 中不只有 html 文件。因此我们还得做个准备工作:将 Boost 中所有的 html 提取出来。想要提取所有的 html 文件,不可避免要遍历整个目录,但是嘞,C++ 标准库做这个工作不方便,因此我们使用 boost 库中的函数来完成!
安装 boost 开发库:
sudo yum install -y boost-devel # devel 就是开发库的意思哈
我们将要使用 Boost 库中 filesystem.hpp
中的相关类来实现过滤 html 文件。
于是我们设计了一个函数 FilterFile
- 参数一:输入型参数,我们要遍历的目录,也就是是下载好的 Boost 库。
- 参数二:输出型参数,保存我们过滤出来的 html 文件。
bool FilterFile(const std::string& src_dir, std::vector<std::string>* file_list)
{namespace fs = boost::filesystem;//根据传入的文件创建一个 path 对象 fs::path root_path(src_dir);//判断当前目录下是否存在 src_dirif(!fs::exists(root_path)){std::cerr << src_dir << "is a " << "Invalid source path." << std::endl;return false;}//创建一个迭代器,用来遍历 src_dir 目录下的所有文件fs::recursive_directory_iterator end;for(fs::recursive_directory_iterator iter(root_path); iter != end; iter++){//判断遍历到的文件是不是普通文件if(!fs::is_regular_file(*iter)){continue;}//判断遍历到的文件的后缀是不是 .htmlif(iter->path().extension() != ".html"){continue;}// for debug 观察是不是将所有的 html 文件提取出来了std::cout << iter->path().string() << std::endl;//将符合要求的文件放到 vector 中file_list->push_back(iter->path().string());}return true;
}
在使用 Boost 库时,编译的时候需要链接你使用到的 Boost 库文件。
g++ -o test debug.cc -std=c++11 -lboost_system -lboost_filesystem
我们通过调用该函数,观察到代码执行效果符合预期:与开头我们使用 find
命令查找的结果一样。
提取标题
通过分析 html 页面,我们不难发现一个 html 页面的标题都是在 <title></title>
这个双标签之间的,并且一个 html 文件中 <title></title>
标签有且只有一个。那么我们就可以将每一个 html 文件读取到内存。通过调用 find
函数找到这两个标签的位置。进而获取到 html 页面的标题。
bool ParseTitle(const std::string& content, std::string* title)
{//查找开始标签的下标size_t start_label = content.find("<title>");if(start_label == std::string::npos){return false;}//查找结束标签的下标size_t end_label = content.find("</title>");if(end_label == std::string::npos){return false;}//截取内容的开始下标size_t begin_pos = start_label + std::string("<title>").size();size_t end_pos = end_label;//开始下标不可能大于结束下标if(begin_pos > end_pos) return false;*title = content.substr(begin_pos, end_pos - begin_pos);return true;
}
- 参数一:输入型参数,一个 html 文件的全部内容。
- 参数二:输出型参数,我们提取到一个 html 文件的标题。
提取内容
除了要提取一个 html 文件的标题,我们还要提取 html 文件的内容。这个内容当然不是 html 文件里面的那些标签,而是指浏览器解析 html 文件之后,网页上能看到的内容。也就是两个标签之间的文字。
想要提取我们想要的内容,需要使用一个简易的状态机。
- 整个 html 文件中的字符可以分为两类:一类是标签,一类是我们想要的内容。我们就可以一个字符一个字符的遍历 html 文件,根据当前的状态来确定当前字符是不是我们需要的。
- 如果遍历到的字符是我们需要的话,将其添加到结果中就行啦!
如果你还是不太明白下面的图片可能会帮到你:
于是我们可以定义一个函数:ParseContent
来获取 html 文件中的内容。
- 参数一:输入型参数,一个 html 文件的全部内容。
- 参数二:输出型参数,我们提取到一个 html 文件的内容。
bool ParseContent(const std::string & file, std::string* content)
{//定义状态机,确定遍历到某个字符时是否是我们需要的字符enum{LABEL,CONTENT} cur_stat;// html 文件一开始一定是标签cur_stat = LABEL;//遍历文件的内容,根据状态来确定是不是我们要的字符for(auto ch : file){switch(cur_stat){case LABEL://状态切换if(ch == '>')cur_stat = CONTENT;break;case CONTENT:// 状态切换if(ch == '<')cur_stat = LABEL;else{// 我们将 html 文件中的 \n 全部置换成为空格,因为我们在将 html 文件// 保存到本地的时候需要让 \n 作为每一个文件的分隔符if(ch == '\n') ch = ' ';content->push_back(ch);}break;default:}}return true;
}
提取 url
用户在搜索某个关键字之后,是能够跳转到 Boost 官网的。因此我们还需要根据过滤出来的 html 页面将对应 html 页面的官网地址提取出来。
对比过滤出来的 html 页面在服务器的位置与官网对应的地址,不难发现:我们只要将服务器本地的 html 页面存放的位置拼接上 Boost 官网前半部分的固定字符串就能正确提取出跳转官网的 url 链接啦!
我们可以定义一个函数:ParseUrl
来实现提取 url
- 参数一:输入型参数,我们过滤出来的 html 文件在服务器的相对路径。
- 参数二:输出型参数,跳转官网的那个 url。
bool ParseUrl(const std::string& src_path, std::string* url)
{// Boost 官网固定前缀std::string url_head = "https://www.boost.org/doc/libs/1_84_0";// boost_1_84_0/doc/html/container/main_features.html// 服务器上的文件截取掉 boost_1_84_0 再拼街上固定前缀即是官网地址std::string url_tail = src_path.substr(src_path.find("/"));*url = url_head + url_tail;return true;
}
4.3 保存 html 文件
当我们将提取标题,提取内容,提取 url 的工作做完了之后,我们就可以将解析出来的数据通过一个结构体封装起来,然后再将结果保存到服务器,方便进行后续建立索引的工作。
为了方便在建立索引的时候读取一个解析之后的 html 文件内容,我们将解析出来的结果统一保存在一个文件中。每一个 html 文件解析出来的结果用换行符进行分割,一个 html 文件中的标题,内容,url 之间使用 \3
进行分割。这里为什么用 \3
呢,是因为在 html 文档中不可能出现 \3
,因此使用 \3
能够正确分割标题,内容,url 这三个部分。当然你用其他不可能在 html 文件中出现的字符也行。
bool SaveHtml(const std::vector<DocInfo_t>& results, const std::string& output_path)
{int cnt = 1;//打开想要保存的文件 不存在就是创建啦std::ofstream out_file(output_path, std::ios::binary | std::ios::out);if(!out_file.is_open()){//文件打开失败结束保存std::cerr << "file " << output_path << "open failed" << std::endl;return false;}// 遍历文件将提取出来的 html 文件保存在服务器for(const auto& result : results){ std::cout << "正在保存第 " << cnt++ << " 个 html 文件" << std::endl; std::string out_string;out_string += result.title;out_string += DATA_BLOCK_SEP; //数据块之间使用 /3 作为分割符,方便构建索引的时候区分out_string += result.content;out_string += DATA_BLOCK_SEP;out_string += result.url;out_string += "\n"; //每一个 html 文件之间使用 \n 作为分割符,方便构建索引的时候读取文件out_file.write(out_string.c_str(), out_string.size());}out_file.close();return true;
}
在解析文件的时候,我们可以顺便将解析的结果打印出来看看:
可以看到,我们解析出来的 content 中已经不含任何的标签啦!
我们可以直接访问解析到的 url:https://www.boost.org/doc/libs/1_84_0/libs/type_traits/doc/html/boost_typetraits/reference/has_trivial_constructor.html
可以看到能够正确跳转官网。
我们查看网页的源代码,可以看到标题也是被正确地提取出来了!
5. 编写建立索引的模块
5.1 获取正排索引
什么是正排索引呢?其实很简单,我们不是提取到了很多很多的 html 文件嘛,正排索引就是给所有文件编一个号,能够根据编号找到对应的文档就行啦!
比如有两个文档:
- 我喜欢中国。
- 中国是我最喜欢的国家。
就可以建立这样的正排索引:
文档编号 | 文档内容 |
---|---|
1 | 我喜欢中国。 |
2 | 中国是我最喜欢的国家。 |
- 我们能根据文档编号 1 找到,“我爱中国。” 的文档内容。
- 我们能根据文档编号 2 找到,“中国是我最喜欢的国家。” 的文档内容。
根据编号找文档内容,我们自然就想到了使用数组来存储所有的正排索引。
于是我们很轻松写出了获取正排的函数:
struct DocInfo
{std::string _title; //文档标题std::string _content; //文档内容std::string _url; //对应官网链接uint32_t _doc_id; //文档的编号
};
std::vector<DocInfo> _forward_index; //正排索引
DocInfo* GetForwardIndex(uint32_t doc_id)
{if(doc_id >= _forward_index.size()) // 文档 id 不能越界{std::cerr << "doc_id out of range" << std::endl;return nullptr;}return &_forward_index[doc_id]; //根据文档 id 返回整个文档
}
5.2 获取倒排索引
那什么又是倒排索引呢?不急哈,我们先来看看我们平时的搜索场景:
可以看到我在百度搜索:“清华大学是中国最好的大学之一”,百度返回的条目中,有 “中国”,“清华大学”,“大学”,“最好的大学”,“清华” 这样的词语匹配成功百度搜索引擎就给我返回了对应网页!
如此可见,我们在搜索引擎进行搜索的时候,会将搜索的字符串进行拆分,得到很多关键字,然后百度服务器根据这些关键字查找服务器上包含这些关键字的文章,最后以一定的顺序返回给用户。
同理我们实现的 Boost 搜索引擎也要做词语拆分的工作!
那么到底什么是倒排索引呢?倒排索引就是根据关键字,找到该关键字对应的文档编号。还是这个例子:
我有两个文档:
- 我喜欢中国。
- 中国是你我都喜欢的国家。
假如我搜索的是:你喜欢中国吗?
- 将这个字符串进行拆分:得到:“喜欢”,“中国”,“你”。
- 于是,就可以建立倒排索引:
关键字 | 文档编号 |
---|---|
喜欢 | 文档 1,文档 2 |
中国 | 文档 1,文档 2 |
你 | 文档 2 |
通过关键字得到了文档编号,即根据倒排索引得到了文档编号。然后再根据正排索引就能获得该文档编号下的所有内容。就能将数据发送给客户端啦!
可以看到 “吗” 这种词并不会参与建立倒排索引,因为像这类语气助词太常见了!这种词我们一般称为暂停词,搜索引擎应该能够去掉这些暂停词,不然会很影响服务器返回用户条目的顺序排列!这类暂停词在英语中就有:“a”,“the”,“an” 等等哈!
通过在百度搜索 “清华大学是中国最好的大学之一” 可以看到 百度服务器返回的条目是按照一个顺序罗列出来的,因此我们还需要确定一个关键字在一个文档中的权重,这样就可以根据用户搜索的关键字,按照权重降序排列返回给客户端啦!
我们要根据关键字也就是 string
找到文档编号等内容,可见比较理想的保存倒排索引的数据结构就是哈希表啦!
于是我们很轻松就写出了获取倒排索引的函数:
struct InvertedElement
{uint32_t _doc_id; //文档编号uint32_t _weight; //关键字对应在该文档中的权重std::string _word; //关键字
};typedef std::vector<InvertedElement> InvertedList; //倒排拉链
std::unordered_map<std::string, InvertedList> _inverted_index; //倒排索引InvertedList* GetInvertedList(const std::string& word)
{auto iter = _inverted_index.find(word); // 根据关键字在倒排索引中查找if(iter == _inverted_index.end()) {std::cerr << word << "have not InvertedList" << std::endl; return nullptr;}return &(iter->second); //找到了就返回倒排拉链
}
显然,一个关键字可能出现在多个文档之中,因此一个 string
对应的应该是一个 vector
我们一般将这个 veector
叫做倒排拉链,是不是非常的形象。
5.3 建立正排索引
我们已经成功将 html 文件解析成功保存到服务器中了,下一步要做的就是将这个文件读取出来,建立正排索引和倒排索引。
解析成功的一个 html 我们在保存的时候是当作一行的!标题,内容,url 之间使用 \3
作为分隔符。因此我们只需要以 \3
作为分隔符将读取到的一行字符串进行切割,建立正排索引之后保存在之前定义好的数据结构中就行啦!
static void Split(const std::string &target, std::vector<std::string> *out, const std::string &sep)
{// 参数一是 vector 哈用来存放切割之后的字符串,参数二就是要切割的字符串,参数三是什么作为分隔符,// 参数四表示多个连续出现的分隔符会进行合并boost::split(*out, target, boost::is_any_of(sep), boost::token_compress_on);
}DocInfo *BuildForwardIndex(const std::string &line)
{DocInfo doc;// 存储分割出来的结果std::vector<std::string> results;// 分隔符const std::string sep = "\3";// 调用分割函数Util::StringUtil::Split(line, &results, sep);// 根据分割结果构建 DocInfo 对象doc._title = results[0];doc._content = results[1];doc._url = results[2];doc._doc_id = _forward_index.size();// 将建立好的正排插入 vector_forward_index.push_back(std::move(doc));// 返回新建立的正排的地址return &(_forward_index.back());
}
我们在分割字符串的时候当然可以使用 find
加 substr
来截取,只不过就是比较麻烦罢了!因此我们选择使用 Boost 库中的 split
函数来处理字符串分割的问题。具体用法在注释中哦!uu 们也可以自行百度!
5.4 建立倒排索引
我们在获取倒排索引的时候讲过,需要将用户搜索的字符串进行词语分割!这个工作看上去很复杂,嗯,没错就是很复杂。因此,我们要使用第三方库啦!
cpp-jieba 项目地址:https://github.com/yanyiwu/cppjieba.git
怎么使用呢?
-
我们使用
ln -s
命令建立两个软连接,指向我们需要的文件。
第一个文件里面有我们要使用的 Jieba.hpp
文件;第二个文件里面则是分词要使用的词库哈!
-
这个第三方库使用之前要将一个文件拷贝到
cppjieba
目录下,你可以先不拷贝,看看报错信息,你应该就知道该怎么解决了,如果你嫌麻烦,直接按照下面的命令拷贝一下就可以使用这个第三方库了!cp -rf deps/limonp include/cppjieba/
在这个项目里面是由 demo
的,你可以直接运行试试:我们要使用的只有一个函数哈:
#include <iostream>
#include <string>
#include <vector>
#include "cppjieba/Jieba.hpp"using namespace std;
const char *const DICT_PATH = "./dict/jieba.dict.utf8";
const char *const HMM_PATH = "./dict/hmm_model.utf8";
const char *const USER_DICT_PATH = "./dict/user.dict.utf8";
const char *const IDF_PATH = "./dict/idf.utf8";
const char *const STOP_WORD_PATH = "./dict/stop_words.utf8";
int main(int argc, char **argv)
{// 初始化一个 jieba 对象,传入的就是我们要使用的哪些词库哈cppjieba::Jieba jieba(DICT_PATH,HMM_PATH,USER_DICT_PATH,IDF_PATH,STOP_WORD_PATH);// 分词的结果将保存在这个 vector 里面vector<string> words;// 这个表示我们要对那个字符串进行分词string s;s = "小明硕士毕业于中国科学院计算所,后在日本京都大学深造";cout << s << endl;cout << "[demo] CutForSearch" << endl;jieba.CutForSearch(s, words);cout << limonp::Join(words.begin(), words.end(), "/") << endl;return EXIT_SUCCESS;
}
我们要使用的就是这个 CutForSearch
函数!
上面就是分词的效果是不是和我们需要的样子差不多啊!
我们现在就来编写分词的模块:
static void CutString(const std::string& src, std::vector<std::string>* out)
{jieba.CutForSearch(src, *out);
}
我们封装一个函数直接调用 CutForSearch
函数就可以啦!
现在我们就来看看如何编写建立倒排索引的函数哈:
- 我们建立了正排索引之后不是得到了一个
DocInfo
嘛?我们将这个DocInfo
传给构建倒排索引的函数,让他根据标题和内容先进性分词。 - 分词完成之后,我们还要统计一个关键字在该文档的权重,怎么计算呢?我们可以自己瞎编一个算法哈!我们就假定,一个关键字如果在标题中出现的话权重加十,如果一个关键字在内容中出现的话权重加一!当然你也可以定义自己的权重的计算方法。
bool BuildInvertedIndex(const DocInfo &doc)
{// 这个用来统计一个词语在标题中出现了几次,在内容中出现了几次struct word_cnt{int _title_cnt;int _content_cnt;word_cnt() : _title_cnt(0), _content_cnt(0){}};// 临时保存一个词语的出现次数,包括在标题中出现的次数和在内容中出现的次数std::unordered_map<std::string, word_cnt> word_map;// 我们先对标题进行分词,然后将该词语在标题中出现的次数加上一std::vector<std::string> title_word;Util::JiebaUtil::CutString(doc._title, &title_word);// 遍历标题分出来的词语,并将 title_cnt 加上一for(auto s : title_word){boost::to_lower(s);word_map[s]._title_cnt++;}// 谈后就是对内容进行分词std::vector<std::string> content_word;Util::JiebaUtil::CutString(doc._content, &content_word);// 同样的道理,对其 content_cnt 加上一for(auto s : content_word){boost::to_lower(s);word_map[s]._content_cnt++;}
#define TITLE_WEIGHT 10
#define CONTENT_WEIGHT 1//现在我们就可以遍历整个 word_map 进行构造 InvertedElement 后插入我们的倒排索引中//定义 word_map 的迭代器,对哈希表进行遍历auto iter = word_map.begin();while(iter != word_map.end()){// 构建结构体,并用已经得到的数据进行初始化InvertedElement ie;// 一个关键词对应的文档 idie._doc_id = doc._doc_id;// 这个关键词是啥ie._word = iter->first;// 这个关键词在该文档中的权重ie._weight = (iter->second)._title_cnt * TITLE_WEIGHT + (iter->second)._content_cnt * CONTENT_WEIGHT;// 将这个结构体插入到一个关键词下的 vector 中,后续需要根据这个哈希表进行倒排索引的查找_inverted_index[iter->first].push_back(std::move(ie));}return true;
}
细节:
- 我们在分词之后,将得到的结果全部转换成了小写,我们想要的结果就是无论用户搜索的是大写的英文单词还是小写的英文单词都是能匹配上的。我们这里就统一转换成小写字符方便处理!
- 我们要理解哈希表以及红黑树里面的
insert
函数的具体实现哈!
5.5 完成索引建立模块
只要把前面的工作做好了,这里只需要简单的调用我们之前写过的函数就可以了!
我们编写这样一个函数:bool BulidIndex(const std::string file)
- 参数一:这个 file 就是我们调用
SaveHtml
函数之后保存到服务器的那个文件。
我们将这个文件一行一行的读取出来,然后分别调用我们之前就写好的 BuildForwardIndex
和 BuildInvertedIndex
函数就行。
// 我们之前不是写了 SaveHtml 这个函数嘛,这里的file 就是保存到服务器的那个文件啦
bool BuildIndex(const std::string& file)
{// 打开SaveHtml 函数保存到服务器的文件std::ifstream in_file(file, std::ios::in | std::ios::binary);if(!in_file.is_open()){std::cerr << "file " << file << " open failed" << std::endl;return false; }//读取到的每一行,也就是解析之后的一个 html 文件 还记得吧: 标题\3内容\3url\nstd::string line;while(getline(in_file, line)){// 建立正排索引DocInfo* doc = BuildForwardIndex(line);if(doc == nullptr){continue;}// 建立倒排索引if(!BuildInvertedIndex(*doc)){continue;}}return true;
}
6. 编写搜索引擎的 searcher 模块
准备工作:
- 我们要将之前写的
Index
模块设计成单例哈!因为在整个项目中只需要一个Index
对象就可以啦! - 设计成单例模式在
searcher
模块中调用Index
模块中的函数十分方便。
设计单例的代码这里就不粘贴出来啦!你可以直接去看项目的源码!我们选用用懒汉的单例模式,并且要加锁哦!
Searcher
模块中我们要根据用户搜索的字符串,返回给客户端相关的条目,因此:
- 用户搜索的字符串也要进行分词的操作。
- 服务端返回客户端的数据格式选用
json
数据格式就行。
好的,现在我们来下载 jsoncpp
吧:
sudo yum install -y jsoncpp-devel # 同样的 -devel 表示的就是开发库的意思
同样地,我们创建一个软连接:
ln -s /usr/include/jsoncpp jsoncpp
想要使用 jsoncpp
我们在编译源文件的时候还要链接这个库哦!
这里可以写一个简单的代码来使用一下 jsoncpp
#include <iostream>
#include <string>
#include <jsoncpp/json/json.h>int main()
{Json::Value root; // 可以往里面插入任何类型的数据Json::Value ele1;ele1["title1"] = "标题1";ele1["content1"] = "内容1";ele1["url1"] = "链接1";root.append(ele1);Json::Value ele2;ele2["title2"] = "标题2";ele2["content2"] = "内容2";ele2["url2"] = "链接2";root.append(ele2);Json::StyledWriter w;std::string s = w.write(root);std::cout << "序列化之后的结果:" <<std::endl;std::cout << s << std::endl;Json::Value ret;Json::Reader r;r.parse(s, ret); //反序列化std::cout << ret[0]["title1"].asString() << std::endl;return 0;
}
可以看到 jsoncpp
的测试程序成功运行啦!
现在我们就来编写 Searcher
模块:
我们可以定义这样一个函数:void Search(const std::string &query, std::string *json_string)
- 参数一:输入型参数,用户在搜索框输入的字符串。
- 参数二:输出型参数,我们根据用户输入的字符串,找到相关的网页,将查找的结果用 json 打包好,通过参数二返回。这个返回的结果就是发送给客户端的数据啦!
- 在这个函数中我们第一步要做的就是对用户搜索的字符串进行分词操作。
- 根据分词的结果查找倒排索引,获取到一个一个的倒排拉链,并且将这些倒排拉链合并到一个
vector
中去。 - 对
vector
中的元素按照降序排序。 - 将查询到的数据打包成
json
数据格式输出。
void Search(const std::string &query, std::string *json_string)
{// 对用户搜索的字符串进行分词操作std::vector<std::string> query_word;Util::JiebaUtil::CutString(query, &query_word);// 一个关键字,对应了李哥倒排拉链,我们需要进行合并操作ns_index::InvertedList inverted_list_all;for (auto s : query_word){// 全部转化成小写,方便进行查找boost::to_lower(s);// 根据倒排索引进行查找ns_index::InvertedList *il = index->GetInvertedList(s);// 合并一条条拉链inverted_list_all.insert(inverted_list_all.end(), (*il).begin(), (*il).end());}// 按照权重进行降序排序std::sort(inverted_list_all.begin(), inverted_list_all.end(), [](const ns_index::InvertedElement &e1, const ns_index::InvertedElement &e2){ return e1._weight > e2._weight; });// 序列化,json 数据格式Json::Value root;for (auto &item : inverted_list_all){ns_index::DocInfo *doc = index->GetForwardIndex(item._doc_id);if (nullptr == doc){continue;}Json::Value elem;elem["title"] = doc->_title;elem["desc"] = GetDesc(doc->_content, item._word); // content是文档的去标签的结果,但是不是我们想要的,我们要的是一部分elem["url"] = doc->_url;root.append(elem);}Json::StyledWriter r;*json_string = r.write(root);}
这里面有一个 GetDesc
函数。一个 html 文件的 content 内容可能会非常非常的长,但是我们不需要这么多。因此只需要有一个简短的描述信息就可以了,这个函数就是根据 html 文件的内容生成一个简单的描述信息。
我们采用的策略是:
- 找到这个关键字第一次出现在 content 中的下标。
- 向前截取 50 个字符,向后截取 100 个字符作为这个 html 文件 content 的描述信息。
因为这个函数比较简单,您可以查看项目的源代码。
书写这个函数时,注意 size_t 类型的易错点就行啦!
7. 编写 http_server 模块
本着有库就不手搓的原则,这个项目中 http_server
模块的编写我们也是用大佬们写好的库哈!如果你想体验手搓的过程,我们会在下一个项目 高并发服务器
中手搓一个!
cpp-httplib
的安装:
git clone https://gitee.com/welldonexing/cpp-httplib.git
这里有一个问题就是使用 httplib
需要较新版本的 gcc
编译器,centos7
默认的 gcc
编译器是 4.8.5
,我们需要升级到 gcc 7
或者更高版本哈!
# 安装 scl
sudo yum install centos-release-scl scl-utils-build
# 安装新版本 gcc
sudo yum install -y devtoolset-7-gcc devtoolset-7-gcc-c++
# 使用 gcc 7
scl enable devtoolset-7 bash
我们在执行使用 gcc 7
的命令后,只在当前会话有效,因此我们需要将这个命令弄到配置文件中:
vim ~/.bash_profile
我们使用 vim 打开家目录下的 .bash_profile
文件,为当前用户配置一下:在这个文件中加上刚才的那个命令就行。当我们登录的时候就会自动执行这条命令啦,保证我们的 gcc
版本一直都是 gcc 7
。
同样我们使用 ln -s
命令建立软连接,就不将整个项目克隆到 Boost 搜索引擎项目中了:
ln -s ~/ThirdPartLibs/cpp-httplib cpp-httplib
#include "cpp-httplib/httplib.h"
#include "searcher.hpp"// 这个是 SaveHtml 保存的文件
const std::string file = "Parse.txt";
// 这个是 web 根目录
const std::string web_root_path = "./wwwroot";int main()
{ns_searcher::Searcher search;// 初始化 Searcher 模块search.InitSearcher(input);httplib::Server svr;svr.set_base_dir(root_path.c_str());svr.Get("/s", [&search](const httplib::Request &req, httplib::Response &rsp){if(!req.has_param("word")){rsp.set_content("必须要有搜索关键字!", "text/plain; charset=utf-8");return;}std::string word = req.get_param_value("word"); //Get 请求中的word参数std::string json_string; //返回可浏览器的 json 数据search.Search(word, &json_string);rsp.set_content(json_string, "application/json");});svr.listen("0.0.0.0", 9999); // 绑定 ip 地址和端口号return 0;
}
那么,现在我们就可以访问我们的服务器看看是什么效果啦:
- 运行
Http_server.cc
编译出来的可执行程序。 - 等待正排索引与倒排索引建立完成。
- 假设我们要搜索关键字:filesystem:
47.180.251.0:9999/s?word=fisystem
可以看到我们的服务器将数据成功返回给了客户端哈!下面我们要做的就是编写前端模块了!如果你会前端可以自己编写,这里的话我就直接将代码贴出来啦!因为个人不怎么会写前端代码!
8. 前端代码的编写
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0"><script src="http://code.jquery.com/jquery-2.1.1.min.js"></script><title>boost 搜索引擎</title><style>/* 去掉网页中的所有的默认内外边距,html的盒子模型 */* {/* 设置外边距 */margin: 0;/* 设置内边距 */padding: 0;}/* 将我们的body内的内容100%和html的呈现吻合 */html,body {height: 100%;}/* 类选择器.container */.container {/* 设置div的宽度 */width: 800px;/* 通过设置外边距达到居中对齐的目的 */margin: 0px auto;/* 设置外边距的上边距,保持元素和网页的上部距离 */margin-top: 15px;}/* 复合选择器,选中container 下的 search */.container .search {/* 宽度与父标签保持一致 */width: 100%;/* 高度设置为52px */height: 52px;}/* 先选中input标签, 直接设置标签的属性,先要选中, input:标签选择器*//* input在进行高度设置的时候,没有考虑边框的问题 */.container .search input {/* 设置left浮动 */float: left;width: 600px;height: 50px;/* 设置边框属性:边框的宽度,样式,颜色 */border: 1px solid black;/* 去掉input输入框的有边框 */border-right: none;/* 设置内边距,默认文字不要和左侧边框紧挨着 */padding-left: 10px;/* 设置input内部的字体的颜色和样式 */color: #CCC;font-size: 14px;}/* 先选中button标签, 直接设置标签的属性,先要选中, button:标签选择器*/.container .search button {/* 设置left浮动 */float: left;width: 150px;height: 52px;/* 设置button的背景颜色,#4e6ef2 */background-color: #4e6ef2;/* 设置button中的字体颜色 */color: #FFF;/* 设置字体的大小 */font-size: 19px;font-family:Georgia, 'Times New Roman', Times, serif;}.container .result {width: 100%;}.container .result .item {margin-top: 15px;}.container .result .item a {/* 设置为块级元素,单独站一行 */display: block;/* a标签的下划线去掉 */text-decoration: none;/* 设置a标签中的文字的字体大小 */font-size: 20px;/* 设置字体的颜色 */color: #4e6ef2;}.container .result .item a:hover {text-decoration: underline;}.container .result .item p {margin-top: 5px;font-size: 16px;font-family:'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif;}.container .result .item i{/* 设置为块级元素,单独站一行 */display: block;/* 取消斜体风格 */font-style: normal;color: green;}</style>
</head>
<body><div class="container"><div class="search"><input type="text" value="请输入搜索关键字"><button onclick="Search()">搜索一下</button></div><div class="result"></div></div><script>function Search(){// 1. 提取数据, $可以理解成就是JQuery的别称let query = $(".container .search input").val();console.log("query = " + query); //console是浏览器的对话框,可以用来进行查看js数据//2. 发起http请求,ajax: 属于一个和后端进行数据交互的函数,JQuery中的$.ajax({type: "GET",url: "/s?word=" + query,success: function(data){console.log(data);BuildHtml(data);}});}function BuildHtml(data){// 获取html中的result标签let result_lable = $(".container .result");// 清空历史搜索结果result_lable.empty();for( let elem of data){// console.log(elem.title);// console.log(elem.url);let a_lable = $("<a>", {text: elem.title,href: elem.url,// 跳转到新的页面target: "_blank"});let p_lable = $("<p>", {text: elem.desc});let i_lable = $("<i>", {text: elem.url});let div_lable = $("<div>", {class: "item"});a_lable.appendTo(div_lable);p_lable.appendTo(div_lable);i_lable.appendTo(div_lable);div_lable.appendTo(result_lable);}}</script>
</body>
</html>
把前端代码粘贴过去之后,我们就能直接用 ip 地址加端口号访问啦。
9. 处理细节问题
9.1 搜索文档重复问题
不止到大家在写代码的时候有没有发现这样一个问题:如果用户搜索的字符串分词过后形成了多个关键字,但是有两个或者以上的关键字在同一个文档中都出现了,用户拿到返回的结果时就会有重复的条目!
我们可以做个实验验证一下:
我们在要过滤的 html 文件中随便加一个 html 文件,添加一串中文:“你是一个好人”。这个随便你怎么添加都行。
然后重新解析 html 文件并启动我们的服务器。
可以看到我们搜索 “你是一个好人” 的时候,服务器给我们相应了四个条目,并且这四个条目是一样的,因为他们都有一个相同的 "id" : 11944
显然,这不是我们期望的结果,我们想要的是服务器返回给我们一个条目就行了,并且权值是 4。我们定义重复文档,让他们的权值相加哈!
这该怎么做呢?
-
之前我们是使用
InvertedElement
的vector
来记录查找到的数据的:struct InvertedElement {uint32_t _doc_id; // 文档编号uint32_t _weight; // 关键字对应在该文档中的权重std::string _word; // 关键字 };
显然我们要进行去重就不能在使用这个
InvertedElement
了,因为多个关键字,可能对应同一个文档嘛,我们要保存的不应该只是一个关键字,而是一个关键字的数组。所以我们重新定义一个结构体:
struct InvertedElementNode {uint32_t _doc_id; // 文档编号uint32_t _weight; // 关键字对应在该文档中的权重std::vector<std::string> _words; // 关键字们InvertedElementNode() : _doc_id(0), _weight(0) {} };
- 接下来我们就要建立一个
doc_id
映射InvertedElementNode
的哈希表,当我们通过分词之后的关键字查找倒排索引得到倒排拉链之后,需要遍历这个倒排拉链,将数据一个一个地插入到unordered_map
中去,注意看代码到底是怎么去重的! - 去重之后的数据都保存在
unordred_map
中哈,我们就需要遍历这个哈希表,将数据插入到我们的vector
中去,等会方便进行按照权值进行降序排序的操作。
下面就是优化之后的代码啦:
bool Search(const std::string &query, std::string *json_string) {// 对用户搜索的字符串进行分词操作std::vector<std::string> query_word;Util::JiebaUtil::CutString(query, &query_word);// 一个关键字,对应了一个倒排拉链,我们需要进行合并操作// ns_index::InvertedList inverted_list_all;// 用户搜索的字符串相关的文档都会保存到这里啦std::vector<InvertedElementNode> inverted_list_all;std::unordered_map<uint32_t, InvertedElementNode> unique_hash;for (auto s : query_word){// 全部转化成小写,方便进行查找boost::to_lower(s);// 根据倒排索引进行查找ns_index::InvertedList *il = index->GetInvertedList(s);// 有可能又得关键词没有倒排拉链if (il == nullptr)continue;// 遍历一个关键对应的倒排拉链for(const auto& ele : (*il)){// 请理解 unordered_map 重载 [] 运算符的底层原理auto& IEN = unique_hash[ele._doc_id];//这里在 [] 插入了一个元素之后就显得有点多余了,但是第一个插入的元素必须这么做,不过代价也不是很大吧IEN._doc_id = ele._doc_id;// 我们定义的规则是进行权值的相加IEN._weight += ele._weight;// 将关键词插入我们维护的 vector 里面IEN._words.push_back(ele._word);}// 合并一条条拉链// inverted_list_all.insert(inverted_list_all.end(), (*il).begin(), (*il).end());}for(const auto& node : unique_hash){// 遍历去重后的数据,也就是哈希表中的数据,将他插入 vector 中方便后续按照权值进行降序排序。inverted_list_all.push_back(std::move(node.second));}if (inverted_list_all.empty())return false;// 按照权重进行降序排序// std::sort(inverted_list_all.begin(), inverted_list_all.end(), [](const ns_index::InvertedElement &e1, const ns_index::InvertedElement &e2)// { return e1._weight > e2._weight; });std::sort(inverted_list_all.begin(), inverted_list_all.end(), [](const InvertedElementNode &e1, const InvertedElementNode &e2){ return e1._weight > e2._weight; });// 序列化,json 数据格式Json::Value root;for (auto &item : inverted_list_all){ns_index::DocInfo *doc = index->GetForwardIndex(item._doc_id);if (nullptr == doc){continue;}Json::Value elem;elem["title"] = doc->_title;// 进行了去重操作之后,获取描述信息的话,我们就用第一个关键字作为锚点就行elem["desc"] = GetDesc(doc->_content, item._words[0]); // content是文档的去标签的结果,但是不是我们想要的,我们要的是一部分 TODOelem["url"] = doc->_url;// for deubg// elem["id"] = (int)item._doc_id;// elem["weight"] = item._weight; // int->stringroot.append(elem);}Json::StyledWriter r;*json_string = r.write(root);return true; }
可以看到,这次我们再来搜索服务器就只给我们返回了一个条目,并且权重是 4 了,这样我们就完成了去重功能的编写啦!
- 接下来我们就要建立一个
9.2 去掉暂停词
暂停词的概念之前提过哈:
暂停词是在自然语言处理中被过滤掉的常见词语,通常是那些对文本含义贡献不大的词,比如“的”、“是”、“在”等。这些词通常在文本处理和分析过程中被忽略,因为它们在大多数情况下不影响文本的含义。
我们处理暂停词的时机就是在进行分词的时候,判断分词结果是否有暂停词就行了,如果有去掉就行。这么来看,我们需要穷举所有的暂停词。我只能说不用,因为 cppjieba
这个库里面就有暂停词这个文件,里面就是一堆的暂停词啦!
- 我们在
JiebaUtil
类中加入去掉暂停词的功能。要求不影响上层调用这个接口,即上层代码不需要更改。 - 首先我们需要读取这个暂停词文件,将所有暂停词加载到内存中。因为我们需要快速查找一个字符串的分词结果中是否含有暂停词,还是得使用
unordered_map
来存储暂停词。 - 我们需要遍历
Jieba
分词的结果,判断这个词语是不是暂停词,如果是的话,就要讲这个词语从分词结果中删除,这里一定要注意vector
迭代器失效的问题! - 最后,我们可以将
JiebaUtil
这个类做成单例。 - 加上去掉暂停词的功能,建立索引的过程会慢的要死,你斟酌斟酌加不加吧!
class JiebaUtil
{
private:JiebaUtil() : jieba(DICT_PATH, HMM_PATH, USER_DICT_PATH, IDF_PATH, STOP_WORD_PATH) {}JiebaUtil(const JiebaUtil&) = delete;JiebaUtil& operator=(const JiebaUtil&) = delete;public:static JiebaUtil* GetInstance(){// 单例之懒汉式if(_instance == nullptr){pthread_mutex_lock(&_mutex);if(_instance == nullptr){_instance = new JiebaUtil;}pthread_mutex_unlock(&_mutex);}return _instance;}void InitJiebaUtil(){// 将保存暂停词的文件读取上来std::ifstream in_file(STOP_WORD_PATH);if(!in_file.is_open()){std::cerr << "file " << STOP_WORD_PATH << " open failed" << std::endl;return;}std::string stop_word;while(getline(in_file, stop_word)){//插入到哈希表中_stop_words.insert({stop_word, true});}}void CutStringHelper(const std::string &src, std::vector<std::string> *out){jieba.CutForSearch(src, *out); // 进行分词for(auto iter = out->begin(); iter != out->end();){if(_stop_words.find(*iter) == _stop_words.end()){// 说明这个次是暂停词iter = out->erase(iter);}else iter++;}}static void CutString(const std::string& src, std::vector<std::string>* out){// jieba.CutForSearch(src, *out);Util::JiebaUtil::GetInstance()->CutStringHelper(src, out);}
private:cppjieba::Jieba jieba; // 分词对象static JiebaUtil* _instance; //单例static pthread_mutex_t _mutex; // 互斥锁std::unordered_map<std::string, bool> _stop_words; // 暂停词保存在哈希表中方便快速查找
};//锁,防止多线程下出现并发访问临界资源的情况,使用PTHREAD_MUTEX_INITIALIZER 就不用 destory了
pthread_mutex_t JiebaUtil::_mutex = PTHREAD_MUTEX_INITIALIZER;
JiebaUtil* JiebaUtil::_instance = nullptr; // 单例的那个 例
最后运行我们的服务程序就行啦:
./Server 2> err.txt # 将标准错误重定向到 err.txt
最后可以加上守护进程,让你的服务一直跑起来!我的服务器比较拉垮,就不让他一直跑起来了!!
10. 总结
- 出现 bug 一定是自己的问题,不是其他什么客观因素导致的。
- 在
DocInfo
这个结构体初始化的时候习惯就这样写了:DocInfo doc = {0}
,导致我找了好久的错。 - 在使用迭代器遍历容器的时候,使用
while
循环,我总是不将迭代器变量加加,不知一回了。下次一定用for
循环,或者直接不用迭代器遍历了!C++11
的范围for
好用。 vector
迭代器失效的问题这次又踩坑了,我想应该没有下次了吧!