这是一个基于Web的搜索服务架构
技术栈:C/C++ C++11 STL boost准标准库 JsonCPP cppjieba cpp-httplib
html css js jQuery Ajax
项目环境:Centos7 华为云服务器 gcc/g++/makefile Vscode
const std::string src_path = "data/input/";
const std::string output_file = "data/output/dest.txt";
class DocInfo
{
public:
std::string _title;
std::string _content;
std::string _url;
};
int main()
{
std::vector<std::string> files_list;
// 第一步 把搜索范围src_path内的所有html的路径+文件名放到 files_list中
if (!EnumFileName(src_path, &files_list))
{
lg(_Error,"%s","enum filename err!");
exit(EnumFileNameErr);
}
// 第二步 将files_list中的文件打开,读取并解析为DocInfo后放到 web_documents中
std::vector<DocInfo> html_documents;
if (!ParseHtml(files_list, &html_documents))
{
lg(_Error,"%s","parse html err!");
exit(ParseHtmlErr);
}
// 第三步 将web_documents的信息写入到 output_file文件中, 以\3为每个文档的分隔符
if (!SaveHtml(html_documents, output_file))
{
lg(_Error,"%s","save html err!");
exit(SaveHtmlErr);
}
}
枚举文件:从给定的源路径(src_path
)中枚举所有HTML文件,并将它们的路径和文件名放入files_list
中。
解析HTML:读取files_list
中的每个文件,解析它们为DocInfo
对象(可能包含标题、URL、正文等元素),然后存储到html_documents
向量中。
保存文档:将html_documents
中的文档信息写入到指定的输出文件output_file
中,文档之间用\3
(ASCII码中的End-of-Text字符)分隔。
bool EnumFileName(const std::string &src_path, std::vector<std::string> *files_list)
{
namespace fs = boost::filesystem;
fs::path root_path(src_path);
if (!fs::exists(root_path)) // 判断路径是否存在
{
lg(_Fatal,"%s%s",src_path.c_str()," is not exist");
return false;
}
// 定义一个空迭代器,用来判断递归是否结束
fs::recursive_directory_iterator end;
// 递归式遍历文件
for (fs::recursive_directory_iterator it(src_path); it != end; it++)
{
if (!fs::is_regular(*it))
continue; // 保证是普通文件
if (it->path().extension() != ".html")
continue; // 保证是.html文件
files_list->push_back(it->path().string()); // 插入的都是合法 路径+.html文件名
}
return true;
}
bool ParseHtml(const std::vector<std::string> &files_list, std::vector<DocInfo> *html_documents)
{
for (const std::string &html_file_path : files_list)
{
// 第一步 遍历files_list,根据路径+文件名,读取html文件内容
std::string html_file;
if (!ns_util::FileUtil::ReadFile(html_file_path, &html_file))
{
lg(_Error,"%s","ReadFile err!");
continue;
}
DocInfo doc_info;
// 第二步 解析html文件,提取title
if (!ParseTitle(html_file, &doc_info._title))
{
lg(_Error,"%s%s","ParseTitle err! ",html_file_path.c_str());
continue;
}
// 第三步 解析html文件,提取content(去标签)
if (!ParseContent(html_file, &doc_info._content))
{
lg(_Error,"%s","ParseContent err!");
continue;
}
// 第四步 解析html文件,构建url
if (!ParseUrl(html_file_path, &doc_info._url))
{
lg(_Error,"%s","ParseUrl err!");
continue;
}
// 解析html文件完毕,结果都保存到了doc_info中
// ShowDcoinfo(doc_info);
html_documents->push_back(std::move(doc_info)); // 尾插会拷贝,效率不高,使用move
}
lg(_Info,"%s","ParseHtml success!");
return true;
}
class FileUtil
{
public:
static bool ReadFile(const std::string &file_path, std::string *out)
{
std::ifstream in(file_path, std::ios::in); // 以输入方式打开文件
if (!in.is_open())
{
lg(_Fatal,"%s%s%s","ReadFile:",file_path.c_str()," open err!");
return false;
}
std::string line;
while (std::getline(in, line))
{
*out += line;
}
in.close();
return true;
}
};
static bool ParseTitle(const std::string &html_file, std::string *title)
{
size_t left = html_file.find("<title>");
if (left == std::string::npos)
return false;
size_t right = html_file.find("</title>");
if (right == std::string::npos)
return false;
int begin = left + std::string("<title>").size();
int end = right;
// 截取[begin,end-1]内的子串就是标题内容
if (end-begin<0)
{
lg(_Error,"%s%s%s","ParseTitle:",output_file.c_str(),"has no title");
return false;
}
std::string str = html_file.substr(begin, end - begin);
*title = str;
return true;
}
static bool ParseContent(const std::string &html_file, std::string *content)
{
// 利用简单状态机完成去标签工作
enum Status
{
Lable,
Content
};
Status status = Lable;
for (char ch : html_file)
{
switch (status)
{
case Lable:
if (ch == '>')
status = Content;
break;
case Content:
if (ch == '<')
status = Lable;
else
{
// 不保留html文本中自带的\n,防止后续发生冲突
if (ch == '\n')
ch = ' ';
content->push_back(ch);
}
break;
default:
break;
}
}
return true;
}
static bool ParseUrl(const std::string &html_file_path, std::string *url)
{
std::string url_head = "https://www.boost.org/doc/libs/1_84_0/doc/html";
std::string url_tail = html_file_path.substr(src_path.size());
*url = url_head + "/" + url_tail;
return true;
}
doc_info内部用\3分隔,doc_info之间用\n分隔
//doc_info内部用\3分隔,doc_info之间用\n分隔
bool SaveHtml(const std::vector<DocInfo> &html_documents, const std::string &output_file)
{
const char sep = '\3';
std::ofstream out(output_file, std::ios::out | std::ios::binary|std::ios::trunc);
if (!out.is_open())
{
lg(_Fatal,"%s%s%s","SaveHtml:",output_file.c_str()," open err!");
return false;
}
for(auto &doc_info:html_documents)
{
std::string outstr;
outstr += doc_info._title;
outstr += sep;
outstr += doc_info._content;
outstr += sep;
outstr+= doc_info._url;
outstr+='\n';
out.write(outstr.c_str(),outstr.size());
}
out.close();
lg(_Info,"%s","SaveHtml success!");
return true;
}
class DocInfo // 解析后的html文档的相关信息
{
public:
std::string _title;
std::string _content;
std::string _url;
uint64_t _doc_id;
};
class InvertedElem
{
public:
uint64_t _doc_id;
std::string _word;
int _weight; // 关键词word在该文档内的权重,方便后续查找时按顺序显示
};
私有化构造函数和析构函数:通过将构造函数和析构函数设为私有,禁止了外部通过常规方式创建Index
类的实例。
禁用拷贝构造函数和拷贝赋值操作符:通过将拷贝构造函数和赋值操作符标记为delete
,防止了类的拷贝,确保了单例的唯一性。
静态实例和互斥锁:用静态成员变量instance
来存储这个类的唯一实例,并使用静态互斥锁_mutex
来保证在多线程环境下的线程安全。
GetInstance方法:这是一个静态方法,用于获取Index
类的唯一实例。如果instance
为空,则实例化一个新的Index
对象。这个方法在创建实例之前和之后都有一次判断实例是否为空的逻辑,这是“双重检查锁定”模式,它可以减少每次调用GetInstance
方法时所需的锁定操作,从而提高性能。
正向索引和倒排索引的存储结构:类中定义了两个私有成员变量来存储正向索引_forward_index
和倒排索引_inverted_index
。正向索引是一个vector
,存储文档信息DocInfo
对象,而倒排索引是一个unordered_map
,它映射一个字符串(关键词)到一个InvertedList
(vector<InvertedElem>
)。
构建索引的方法:类提供了两个方法BuildForwardIndex
和BuildInvertedIndex
,分别用于构建正向索引和倒排索引。这两个方法的具体实现在这个代码片段中没有给出。
检索功能的方法:BuildIndex
方法可能用于建立索引,GetForwardIndex
和GetInvertedList
方法分别用于获取正向索引和倒排索引中的数据。
bool BuildIndex(const std::string &input_path) // 构建索引
{
std::fstream in(input_path, std::ios::in | std::ios::binary);
if (!in.is_open())
{
lg(_Fatal,"%s%s%s","BuildIndex fail! ",input_path.c_str()," cannot open");
return false;
}
std::string html_line; // 每个html的的DocInfo以\n间隔
int cnt=1; //debug
while (std::getline(in, html_line))
{
DocInfo *doc_info = BuildForwardIndex(html_line);
if (doc_info == nullptr)
{
lg(_Error,"%s%s%s%s","BuildForwardIndex fail! ","who? ",html_line.c_str()," continue next html");
continue;
}
if (!BuildInvertedIndex(*doc_info))
{
lg(_Error,"%s%s%d","BuildInvertedIndex fail! ","id: ",doc_info->_doc_id);
continue;
}
++cnt;
if(cnt%100 == 0)
std::cout<<"cnt:"<<cnt<<std::endl;
}
lg(_Info,"%s%d","BuildIndex over cnt:",cnt);
in.close();
return true;
}
class StringUtil
{
public:
static void SplitString(const std::string &str, std::vector<std::string> *ret_strs, const std::string &sep)
{
boost::split(*ret_strs, str, boost::is_any_of(sep), boost::token_compress_on);
}
};
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";
class JiebaUtil
{
public:
static void CutString(const std::string &src,std::vector<std::string> *ret)
{
_jieba.CutForSearch(src,*ret);
}
private:
static cppjieba::Jieba _jieba;
};
cppjieba::Jieba JiebaUtil::_jieba(DICT_PATH,
HMM_PATH,
USER_DICT_PATH,
IDF_PATH,
STOP_WORD_PATH);
这段代码展示了两个C++工具类StringUtil
和JiebaUtil
,它们都包含静态方法,用于处理字符串分割和中文分词功能。
StringUtil
类:
SplitString
,它使用Boost库的split
函数来将字符串str
依据分隔符sep
分割,并将结果存储在传入的向量ret_strs
中。boost::token_compress_on
参数指定如果分隔符在字符串中连续出现,那么多个分隔符将被视作一个。JiebaUtil
类:
CutString
,它用于中文的分词。方法接受一个源字符串src
和一个用于存储分词结果的向量ret
。_jieba
,它是cppjieba::Jieba
类的一个实例。cppjieba::Jieba
是一个中文分词库的C++实现。_jieba
成员的静态初始化语法来初始化这个Jieba分词器实例。常量路径定义: 代码中还定义了一些指向分词所需字典文件的路径常量:
DICT_PATH
:指向基础字典文件。HMM_PATH
:指向用于HMM(隐马尔可夫模型)的模型文件。USER_DICT_PATH
:指向用户自定义的词典文件。IDF_PATH
:指向逆文档频率(IDF)字典文件。STOP_WORD_PATH
:指向停用词字典文件。 DocInfo *BuildForwardIndex(const std::string &html_line)
{
// 1~ 切分字符串
std::vector<std::string> ret_strs;
const std::string sep = "\3";
ns_util::StringUtil::SplitString(html_line, &ret_strs, sep);
if (ret_strs.size() < 3)
return nullptr;
// 2~ 填充doc_info
DocInfo doc_info;
doc_info._title = ret_strs[0];
doc_info._content = ret_strs[1];
doc_info._url = ret_strs[2];
doc_info._doc_id = _forward_index.size(); // 插入第一个时id== size ==0
// 3~ 插入到正排索引_forward_index
_forward_index.push_back(std::move(doc_info));
return &_forward_index.back();
}
bool BuildInvertedIndex(const DocInfo &doc_info)
{
struct words_cnt
{
int title_cnt = 0;
int content_title = 0;
};
// 1~ 对doc_info的title和content进行分词
std::unordered_map<std::string, words_cnt> words_frequency;
std::vector<std::string> words_title;//保存title分词后的结果
std::vector<std::string> words_content;//保存content分词后的结果
ns_util::JiebaUtil::CutString(doc_info._title, &words_title);
ns_util::JiebaUtil::CutString(doc_info._content, &words_content);
// 2~ 统计词频填充words_frequency
for (auto &word : words_title)//to_lower转换不能是const修饰
{
boost::to_lower(word); // 需要统一转化成为小写,因为搜索时不区分大小写
//boost::to_lower_copy(word);
words_frequency[word].title_cnt++;
}
for (auto &word : words_content)
{
boost::to_lower(word); // 需要统一转化成为小写,因为搜索时不区分大小写
//boost::to_lower_copy(word);
words_frequency[word].content_title++;
}
// 3~ 自定义权重 title:content = 10:1
static const int title_weight = 10;
static const int content_weight = 1;
// 4~ 对words_frequency内的每个关键词创建InvertedElem并填充
for (const auto &kv : words_frequency)
{
InvertedElem inverted_ele;
inverted_ele._doc_id = doc_info._doc_id;
inverted_ele._word = kv.first;
inverted_ele._weight =
title_weight * kv.second.title_cnt +
content_weight * kv.second.content_title;
// 5~ 将该文档的所有InvertedElem分别插入到倒排索引 _inverted_index中
InvertedList &inverted_list = _inverted_index[kv.first];
inverted_list.push_back(std::move(inverted_ele));
//_inverted_index[kv.first].push_back(std::move(inverted_ele));
}
return true;
}
分词处理: 用户输入的查询字符串 query
通过 ns_util::JiebaUtil::CutString
函数进行分词,分词结果存储在 key_words
向量中。
搜索和去重: 遍历分词后的关键词。对每个关键词,都先将其转换为小写以实现大小写不敏感的搜索,然后获取对应的倒排索引链(InvertedList
)。如果倒排索引链存在,遍历链中的每个元素,并在 tokens_map
中以文档ID为键聚合数据,合并权重和关键词,实现对同一文档的去重。
排序: 将 tokens_map
中聚合的结果转移到一个向量 inverted_ele_all
中,并根据权重对其进行降序排序,这样权重高的(更相关的)文档会排在前面。
构建JSON结果: 遍历排序后的 inverted_ele_all
向量,对于每个元素,使用它的文档ID去查询正向索引获取文档的详细信息,如标题、内容和URL。将这些信息构建成一个JSON对象,并添加到一个 Json::Value
类型的 ret
数组中。函数最后使用 Json::FastWriter
将 ret
转换成JSON格式的字符串并存储在 json_str
指针指向的字符串中。
// query是用户输入的搜索关键字
// json_str是返回给用户浏览器的搜索结果
void Search(const std::string &query, std::string *json_str)
{
// 1~对query进行分词
std::vector<std::string> key_words;
ns_util::JiebaUtil::CutString(query, &key_words);
std::unordered_map<uint64_t,InvertedElemDedup> tokens_map;//去重id后的结果
for (auto &key_word : key_words)
{
// 查询的关键词全部转换为小写,提取出来的信息不区分大小写
boost::to_lower(key_word);
// 2~对分词结果 分别进行搜索
ns_index::Index::InvertedList *inverted_list =
_index->GetInvertedList(key_word);
if (inverted_list == nullptr)
{
continue; // 这个词没能找到 对应的倒排拉链
}
for(auto &elem: *inverted_list)
{
auto& dedup_ele = tokens_map[elem._doc_id];
dedup_ele._doc_id = elem._doc_id;
dedup_ele._weight += elem._weight;
dedup_ele._words.push_back(elem._word);
}
}
// 优化点:对所有的ele合并后指向的doc_id进行去重 这里只关心weight和id
std::vector<InvertedElemDedup> inverted_ele_all;
for(auto &kv:tokens_map)
{
inverted_ele_all.push_back(std::move(kv.second));
}
// 3~对所有的inverted_element按照wegiht排序
sort(inverted_ele_all.begin(), inverted_ele_all.end(),[](InvertedElemDedup& left,InvertedElemDedup& right){
return left._weight > right._weight;
});
// 4~序列化,构建json串返回给用户 -- 使用jsoncpp
Json::Value ret;
int cnt = 0; // debug
for (auto &ele : inverted_ele_all)
{
ns_index::DocInfo *doc_info = _index->GetForwardIndex(ele._doc_id);
if (doc_info == nullptr)
continue;
Json::Value element;
element["title"] = doc_info->_title;
// 搜索时需要摘要,不是所有的content,后面优化
element["desc"] = GetDesc(doc_info->_content, ele._words[0]);
element["url"] = doc_info->_url;
// element["weight"] = ele._weight;
// element["word"] = ele._words[0];
// element["id"] = (int)ele._doc_id; // json自动将int转化为string
ret.append(element);
}
//Json::StyledWriter writer;
Json::FastWriter writer;
*json_str = writer.write(ret);
}
private:
std::string GetDesc(const std::string &html_content, const std::string &word)
{
// 找到word在content中首次出现的位置,向前截取prev_stepbyte,向后截取next_stepbyte
// 向前<prev_step则从content开头开始,向后不足next_step则到content结尾
// 1~ 找到word首次出现的位置
std::cout << word << std::endl; // debug
auto iter = std::search(html_content.begin(), html_content.end(), word.begin(), word.end(),
[](int l, int r)
{
return std::tolower(l) == std::tolower(r);
});
if (iter == html_content.end())
{
lg(_Error,"%s","content里面没找到word");
return "None1";
}
// 找到了
int pos = std::distance(iter, html_content.end());
const int prev_step = 50;
const int next_step = 50;
// 2~ 确定begin和end位置
int begin = pos >= prev_step ? pos - prev_step : 0;
int end = (pos + next_step) < html_content.size() ? pos + next_step : html_content.size();
// 3~ 截取描述子串[begin,end)并返回
if (begin >= end) // end一定大于begin
{
lg(_Error,"%s","begin > end 越界了");
return "None2";
}
std::string desc = html_content.substr(begin, end - begin);
desc += "...";
return desc;
}
};
const std::string input = "data/output/dest.txt";//从input里读取数据构建索引
const std::string root_path = "./wwwroot";
int main()
{
std::unique_ptr<ns_searcher::Searcher> searcher(new ns_searcher::Searcher());
searcher->SearcherInit(input);
httplib::Server svr;
svr.set_base_dir(root_path.c_str()); // 设置根目录
// 重定向到首页
svr.Get("/", [](const httplib::Request &, httplib::Response &rsp)
{ rsp.set_redirect("/home/LZF/boost_searcher_project/wwwroot/index.html"); });
svr.Get("/s",[&searcher](const httplib::Request &req,httplib::Response &rsp)
{
if(!req.has_param("word"))
{
rsp.set_content("无搜索关键字!","test/plain,charset=utf-8");
return;
}
std::string json_str;
std::string query = req.get_param_value("word");
std::cout<<"用户正在搜索: "<<query<<std::endl;
searcher->Search(query,&json_str);
rsp.set_content(json_str,"application/json");
});
svr.listen("0.0.0.0", 8800);
}
初始化: 定义了 input
和 root_path
两个字符串常量,分别表示索引文件的路径和服务器的根目录。
创建搜索对象: 使用 std::unique_ptr
创建了 Searcher
类的一个实例,并通过 SearcherInit
方法初始化,以从指定的 input
文件中构建索引。
创建和配置服务器: 使用 httplib::Server
类创建了一个HTTP服务器实例,设置了服务器的根目录为 root_path
。
首页重定向: 服务器对根路径 /
的GET请求进行处理,通过 set_redirect
方法将请求重定向到指定的HTML页面路径。
搜索请求处理: 对路径 /s
的GET请求进行处理,这是搜索功能的实现部分。服务器检查请求中是否包含名为 word
的参数:
word
参数,则返回错误信息。word
参数的值,打印出查询的内容,并调用 Searcher
实例的 Search
方法来进行搜索。搜索的结果是一个JSON字符串,它会设置为响应体的内容。启动服务器: 使用 svr.listen
方法监听 0.0.0.0
上的 8800
端口,使服务器开始接受连接和处理请求。
httplib::Server
是 httplib
库中用于创建和管理HTTP服务器的类。
<!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(){
let query = $(".container .search input").val();
console.log("query = " + query);
$.get("/s", {word: query}, function(data){
console.log(data);
BuildHtml(data);
});
}
function BuildHtml(data){
let result_lable = $(".container .result");
result_lable.empty();
for( let elem of data){
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>
搜索栏 (div.search
):
Search()
JavaScript函数。搜索结果显示区域 (div.result
):
div
,将来用来动态显示搜索结果。Search() 函数:
jQuery
的 $.get()
函数异步向服务器的 /s
路径发送一个GET请求,并将用户的查询词作为参数传递。BuildHtml()
函数处理数据并构建结果HTML。BuildHtml() 函数:
jQuery
库,以简化DOM操作和Ajax请求。更多【搜索引擎-基于boost准标准库的搜索引擎项目】相关视频教程:www.yxfzedu.com