Boost库大家一定耳熟能详,它是一个功能强大、构造精巧、跨平台、开源并且完全免费的 C++ 程序库。但是这个库却没有内置的搜索功能,所以本项目是针对Boost库的一个搜索引擎,可以根据用户搜索的关键字给出Boost库内相关的网页内容。
项目源码:https://gitee.com/hehuoren8685/boost_searcher
项目所用技术及环境
技术: C/C++ C++11, STL, 准标准库Boost,Jsoncpp,cppjieba,cpp-httplib
项目环境: Centos 7云服务器,vim , VScode
总体流程
1.去标签及数据清洗模块Parser
我使用的boost库版本为boost_1_80_0。从官网下载压缩包并拖拽至自己的云服务器内,对其进行解包。
这便是下载并解包后的所有文件,存放于data/input目录下。
这是具体的一个html文件内的内容。
parser内完成的具体步骤:
①先将每个html文件的路径+文件名保存到vector内,方便下一步处理;
②读取每个文件内的内容并将其解析(去标签,将其标题、正文、URL保存在自定义的文件信息结构体内,该结构体信息如下);
typedef struct DocInfo
{
std::string title;//每个文档的标题
std::string content;//每个文档的内容
std::string url;//每个文档的url
}DocInfo_t;
③将解析后的每个文件写入data/raw_html/raw.txt内。
首先定义input和output的文件路径。
//src_path存放的是一个目录,其内包含原始的html网页
const std::string src_path = "data/input";
//output内存放的是将src_path内的html字符串去标签后的字符串,其内每两个html文件以\n分割,每个html文件的标题、去标签后的内容和url以\3分割
const std::string output = "data/raw_html/raw.txt";
Enunfile模块来完成①的功能,其中使用了boost库的功能,需要引入头文件<boost/filesystem.hpp>。
bool EnumFile(const std::string& src_path,std::vector<std::string>* files_list)
{
//用path对象定义一个root_path,遍历时从src_path开始
namespace fs = boost::filesystem;
fs::path root_path(src_path);
//exists函数:判断一个路径是否存在,不存在则没有走的必要了
if(!fs::exists(root_path))
{
std::cerr << src_path << "not exist" << std::endl;
return false;
}
//定义一个空迭代器,当遍历结束时方便判断结束
fs::recursive_directory_iterator end;
for(fs::recursive_directory_iterator iter(root_path);iter != end;iter++)
{
//iter指向的文件必须是符合规范的(不能是图片)才能计入,html文件都是普通文件
if(!fs::is_regular_file(*iter))
{
continue;
}
//文件的后缀必须为html,才能计入
//path方法:用于提取当前指向的路径字符串;extension:提取后缀
if(iter->path().extension() != ".html")
{
continue;
}
//走到此处,当前的路径一定是合法的且以.html结束的普通网页文件
//加入到files_list时应该是一个string对象,将所有带路径的html保存到files_list中,方便后续进行文本分析
files_list->push_back(iter->path().string());
}
return true;
}
ParseHtml模块来完成②的内容,其中包括了解析标签、正文、URL的子模块。这些子模块只想在本文件内有效,因此增添static选项。
解析标签时,以文件内< title > 和< /title >作为识别标志;解析正文时,要去掉每一个标签及其内的内容,因此可以用一个枚举来区分是正文还是标签;解析URL时,则可以直接用一个固定的网页前缀+具体的文件名构建。
另外,有一些需要被其他大模块多次复用的函数,放在了util工具头文件内,如ReadFile函数,下面先给出这个函数的定义。
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())
{
std::cerr << "open file" << file_path << "error!" << std::endl;
return false;
}
//用getline方法按行读取文件内容
//问:如何理解getline返回值istream& 作为判断条件呢?
//while(bool),本质是因为返回的流类型重载了强制类型转化,当读取到文件结尾时返回false
std::string line;
while(std::getline(in,line))
*out += line;
in.close();
return true;
}
};
下面是ParseHtml的代码
bool ParseHtml(const std::vector<std::string>& files_list,std::vector<DocInfo_t>* results)
{
for(const std::string& file : files_list)
{
//1.读取文件进result
std::string result;
if(!ns_util::FileUtil::ReadFile(file,&result))
{
//读取失败一个文件,这个先不管,继续往下读
continue;
}
DocInfo_t doc;
//2.解析指定的文件,提取title
if(!ParseTitle(result,&doc.title))
{
continue;
}
//3.解析指定的文件,提取content
if(!ParseContent(result,&doc.content))
{
continue;
}
//4.解析指定的文件路径,构建url
if(!ParseUrl(file,&doc.url))
{
continue;
}
//运行到此处已经完成解析任务,可以保存了
//results -> push_back(doc); --> 若如此做,每次push_back时都要拷贝这个临时对象doc,对于内容繁多的html文件来说,可能会浪费很多时间,故采用move函数。
//move函数作用:把要拷贝的对象在地址空间层面上与容器中的成员相关联起来,不会发生太多拷贝!
results->push_back(std::move(doc));
//for debug:
//showdoc(doc);
//break;
}
return true;
}
static bool ParseTitle(const std::string& file,std::string* title)
{
std::size_t begin = file.find("<title>");
if(begin == std::string::npos)
return false;
std::size_t end = file.find("</title>");
if(end == std::string::npos)
return false;
begin += std::string("<title>").size();
if(begin > end)
return false;
*title = file.substr(begin,end-begin);
return true;
}
static bool ParseContent(const std::string& file,std::string* content)
{
//去标签的动作,基于一个简易的状态机
enum status
{
LABLE, //尖括号内的内容
CONTENT //正常的文本内容
};
//任何网页最开始一定是左尖括号,初始化时先设置为LABLE
enum status s = LABLE;
//遍历file,只要遇到了 '>',就意味着当前标签被处理完毕!
//只要遇到了 '<',就意味着新的标签开始了!!
for(char c : file)
{
switch(s)
{
case LABLE:
if(c == '>')
s = CONTENT;
break;
case CONTENT:
if(c == '<') //防止两个标签连着的情况
s = LABLE;
else
{
//注:不保留原始文件中的\n,因为要用\n作为后面html解析之后的文本分隔符!
if(c == '\n')
c = ' ';
content -> push_back(c);
}
break;
default:
break;
}
}
return true;
}
static bool ParseUrl(const std::string& file_path,std::string* url)
{
//boost库的官方文档和我们下载下来的文档是有路径的对应关系的!
//url_head = https://www.boost.org/doc/libs/1_80_0/doc/html (官网url)
//url_tail = (data/input)(删除) /accumulators.html(具体的文件名) = /accumulators.html
//url = url_head + url_tail
std::string url_head = "https://www.boost.org/doc/libs/1_80_0/doc/html";
std::string url_tail = file_path.substr(src_path.size());
*url = url_head + url_tail;
return true;
}
SaveHtml函数用来实现③。我们的写入自定义规则是:将所有文件写入data/raw.html/raw.txt内,其内每两个html文件之间用/n来分割;而每个html的标题、正文及URL用 ‘\3’ 来区分。同时使用二进制来写入,防止 ‘\3’ 被特殊解析。
bool SaveHtml(const std::vector<DocInfo_t>& results,const std::string& output)
{
#define SEP '\3'
//写入文件中时一定要考虑到在往外读取时,也要方便操作!!
//这里采用的策略是:两个html文件以\n隔开,而每个html文件内的标题、去标签后的内容和url以\3分割。这样方便以后用getline(ifsream,line)一次直接获取一个文档内的全部内容。类似于:title\3content\3url\ntitle\3content\3url\n...
//以二进制写入,以便\3写入时就是\3,不会被特殊解析
std::ofstream out(output,std::ios::out|std::ios::binary);
if(!out.is_open())
{
std::cerr << "open" << output << "failed!" << std::endl;
return false;
}
//此时可以进行文件写入
for(const DocInfo_t& item : results)
{
std::string out_string;
out_string = item.title;
out_string += SEP;
out_string += item.content;
out_string += SEP;
out_string += item.url;
out_string += '\n';
//接下来将字符串内容写入文件
out.write(out_string.c_str(),out_string.size());
}
out.close();
return true;
}
2.索引建立头文件index.hpp
这里新添加了日志头文件log.hpp,内容如下:
#pragma once
#include <iostream>
#include <string>
#include <ctime>
//此数1234仅仅是方便我们区分,无实际意义!
#define NORMAL 1
#define WARNING 2
#define DEBUG 3
#define FATAL 4
//日志头文件
//在宏参中带#LEVEL可以将宏名称转为字符串(将宏定义中的传入参数名转换成用一对双引号括起来参数名字符串),__FILE__和__LINE__用于获取调用文件的文件名和行号
#define LOG(LEVEL,MESSAGE) log(#LEVEL,MESSAGE,__FILE__,__LINE__)
void log(std::string level,std::string message,std::string file,int line)
{
std::cout << "[" << level << "]" << "[" << time(nullptr) << "]" << "[" << message << "]" << "[" << file << "]" << "[" << line << "]" << std::endl;
}
这里先介绍两个概念:正排索引和倒排索引
正排索引是根据文档id直接找到文档内容。
文档ID | 文档内容 |
---|---|
1 | 合伙人8685今天吃什么 |
2 | 合伙人8685大魔王 |
而倒排索引是根据输入的内容进行分词,对应到不同的文档ID
输入:合伙人8685今天吃魔王
分词为:合伙人8685/今天/吃/魔王
分词后的关键字 | 对应文档ID |
---|---|
合伙人8685 | 1、2 |
今天 | 1 |
吃 | 1 |
魔王 | 2 |
这里在显示时,还要以权重来对搜索结果进行排序。
index.hpp内完成的任务是:对parser内处理完成的html网页文件,建立正排和倒排索引,加载到内存中。这里先介绍下自定义的两个结构体以及倒排拉链:
struct DocInfo
{
std::string title; //文档的标题
std::string content;//文档去标签后的内容
std::string url; //文档的url
uint64_t doc_id; //将来文档的ID
};
struct InvertedElem
{
uint64_t doc_id; //文档的ID
std::string word; //关键字
int weight; //权重
};
//倒排拉链:一个vector内存放着与一个关键字对应的文档
typedef std::vector<InvertedElem> InvertedList;
DocInfo是正排索引的结构体,InvertedElem是倒排索引所需要的结构体。因为日后一个单词可能对应很多很多的文件,所以倒排拉链是一个vector,内含多个倒排索引结构体。
建立一个Index类,其对象可以完成正排倒排索引的建立。正排倒排索引都需要相对应的数据结构,这两个数据结构存储于Index类内。正排索引可以用一个vector存储上面的DocInfo,因为正排索引是根据文档ID找到文档,而数组下标正好可以充当文档ID;而倒排索引是根据单词对应许多InvertedElem,也就是上面的倒排拉链,所以可以用一个unordered_map来确定这个关系。同时因为搜索引擎的索引应该只有一份,所以设置为单例模式,从而可以节约时间空间。在初始化时采用双检查加锁模式,保证线程安全的同时也可以保证速率,同时还要将类的构造函数私有化、拷贝构造和赋值重载delete。
//正排索引的数据结构用数组,因为数组的下标天然是文档的ID!
std::vector<DocInfo> forward_index;
//倒排索引一定是一个关键字和一组InvertedElem对应,故我们用unordered_map(关键字和倒排拉链的映射关系)
std::unordered_map<std::string,InvertedList> inverted_index;
建立索引前,先把每个html文件独立的从文件内读出来。因为之前我们制定的规则,所以直接用一次getline就可以读出一个文件,并可以用 '\3’来区分每个文件独立的标题、正文及URL,然后针对这单个文件建立正排倒排索引。
注:这里需要传入的参数是去标签后文档的路径,即data/raw_html/raw.txt
bool BuildIndex(const std::string& input)
{
std::ifstream in(input,std::ios::in | std::ios::binary);
if(!in.is_open())
{
std::cerr << "sorry, " << input << " open failed" << std::endl;
return false;
}
std::string line;
int count = 0;
while(std::getline(in,line))
{
//建立正排索引
DocInfo* doc = BuildForwardIndex(line);
if(nullptr == doc)
{
std::cerr << "build " << line << " error!" << std::endl;
continue;
}
//建立倒排索引
BuildInvertedIndex(*doc);
count++;
//if(count % 50 == 0)
//std::cout << "已建立的索引数: " << count << std::endl;
LOG(NORMAL,"已建立的索引文档数:"+std::to_string(count));
}
/*for(auto e : inverted_index[" "])
std::cout << e.doc_id << std::endl;*/
//后来实践证明:空格会被jieba分词保留,那么就会导致倒排拉链中空格对应所有文件(每个文件必有空格)。如果在搜索时含有空格,这个搜索的空格也会被保留,从而结果出现所有文件!!因此,倒排拉链中的空格要手动删除。
//inverted_index.erase(inverted_index.find(std::string(" ")));
return true;
}
建立正排索引时比较简单,只需要将当前文件按 ‘\3’ 分割出标题、正文和URL,便可以直接填充进DocInfo结构体内,然后存入forward_index内,将其下标充当文档ID即可,最后返回填充的结构体指针。这里用了boost库内的split字符分割函数,也一并给出定义。
class StringUtil
{
public:
static void Split(const std::string& target,std::vector<std::string>* out,const std::string& sep)
{
//boost split函数介绍
//第一个参数:切分后的字符串存放地址
//第二个参数:数据源
//第三个参数:分隔符列表,其中的每个字符都会作为切分的分隔符
//第四个参数:可选为boost::token_compress_on 或 boost::token_compress_off,默认为后者,当为off时,会保留两个连续分隔符中间的空白,意味着每两个连续的分隔符就会往结果中存一个空白字符串;而为on时,这个空白不会被保留,两个连续的分隔符不会为结果留下任何内容。可以理解为:有多个连续的相邻分隔符时,压缩为一个分隔符。
boost::split(*out,target,boost::is_any_of(sep),boost::token_compress_on);
}
};
DocInfo* BuildForwardIndex(const std::string& line)
{
//解析line,进行字符串切分
std::vector<std::string> results;
const std::string sep = "\3";
ns_util::StringUtil::Split(line,&results,sep);
if(results.size() != 3)
return nullptr;
//字符串填充进DocInfo
DocInfo doc;
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();
}
建立倒排索引相对比较复杂。我们在这里认为:出现在标题内的关键字权重比出现在正文内的关键字权重大,所以我们还需对文件标题和正文分别进行词频统计。我们定义一个局部结构体,其内包括了一个html文件内一个单词的出现次数。但这个单词并不存在该结构体内,而是额外定义一个unordered_map,其内含单个单词与该单词对应的词频结构体。
另外,在建立倒排索引统计关键词时,应该对关键词进行小写处理,因为实际的搜索引擎是不区分大小写的。
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;//暂存词频的映射表
在统计关键词时,首先要对标题或正文内容进行分词,我们这里利用Jieba库来进行分词。在分词后,我们要写入到进单个InvertedElem内,而在此之后,还要将其插入Index类内对应的inverted_index内。
这里要注意:在实际的搜索中,用户可能输入以空格作为分隔的多个关键字,或者有如is等根本不重要但在每个文件中都广泛出现的,而在此时切分后,这种暂停词可能也作为关键字保留,从而搜索结果就包括了绝大多数甚至全部的文件。所以在搜索之前,先将暂停词文件加入数据结构stop_words中,然后在实行切分字符串时,对每个分出来的关键字进行检索,看它是否在数据结构stop_word内,如果在,就将其去除。但因为在进行倒排索引的建立时,需要对每个文件的正文及标题进行字符串切分,那也就意味着要对每一个文件的切分结果都要进行遍历式检测,那么这个过程会进一步的加长索引建立的时间。
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
{
//在实际的实践中,发现CutForSearch会将诸如空格、is这种在所有文档内大量存在的暂停词保留。当用户输入这些暂停词时,得到的结果就可能是所有文件。因此,要改动这部分代码,手动将暂停词去除。但是这个过程会非常漫长!
public:
void InitJiebaUtil()
{
std::ifstream in(STOP_WORD_PATH);
if(!in.is_open())
{
LOG(FATAL,"load stop words file error!");
return;
}
std::string line;
while(std::getline(in,line))
{
stop_words.insert({line,true});
}
in.close();
}
void CutStringHelper(const std::string& src, std::vector<std::string>* out)
{
jieba.CutForSearch(src,*out);
//此处out内还含有暂停词。为了更好的搜索体验,应该把暂停词去除!
for(auto iter = out->begin();iter != out->end();)//这里不用iter++,因为会涉及迭代器失效问题,迭代器迭代会在循环逻辑里处理
{
auto it = stop_words.find(*iter);
if(it != stop_words.end())
{
//走到这里说明当前的*it是暂停词,需要去掉
iter = out->erase(iter);//erase自动指向下一个元素
}
else
iter++;
}
}
static void CutString(const std::string& src,std::vector<std::string>* out)
{
//jieba.CutForSearch(src,*out);
//加载单例、初始化(仅第一次)、获得CutStringHelper、调用jieba分词去掉暂停词
ns_util::JiebaUtil::get_instance()->CutStringHelper(src,out);
}
static JiebaUtil* get_instance()
{
static std::mutex mtx;
if(nullptr == instance)
{
mtx.lock();
if(nullptr == instance)
{
instance = new JiebaUtil();
instance->InitJiebaUtil();
}
mtx.unlock();
}
return instance;
}
private:
//定义为static防止每次想分词都要建立JiebaUtil对象麻烦
//static cppjieba::Jieba jieba;
cppjieba::Jieba jieba;
std::unordered_map<std::string,bool> stop_words;
//构造私有、拷贝赋值delete保证单例,因为涉及文件操作,只需要加载一次
JiebaUtil()
:jieba(DICT_PATH,HMM_PATH,USER_DICT_PATH,IDF_PATH,STOP_WORD_PATH)
{}
JiebaUtil(const JiebaUtil&) = delete;
JiebaUtil& operator=(const JiebaUtil&) = delete;
static JiebaUtil* instance;
};
JiebaUtil* JiebaUtil::instance = nullptr;
bool BuildInvertedIndex(const DocInfo& doc)
{
//根据文档内容(title,content,url),形成一个或多个InvertedElem(倒排拉链)
//因为当前是一个一个文档进行处理的,一个文档会包含多个“词”,都应当对应到当前的doc_id,所以要先对title和content进行分词!
//利用jieba进行分词,然后进行词频统计
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_words;
ns_util::JiebaUtil::CutString(doc.title,&title_words);
for(std::string s:title_words)
{
//注意:在实际的搜索中,用户的大小写数据都是一视同仁的,所以这里应该对用户输入的数据做全部小写的处理,这也是为什么循环不用引用的原因,因为不想改变原数组内容。
boost::to_lower(s);
word_map[s].title_cnt++;
}
//统计正文词频
std::vector<std::string> content_words;
ns_util::JiebaUtil::CutString(doc.content,&content_words);
for(std::string s:content_words)
{
//这里同上
boost::to_lower(s);
word_map[s].content_cnt++;
}
//同时也要注意词和文档的相关性。这里简单采用词频。具体逻辑:在标题中出现的词,可以认为相关性更高一些,而在内容中出现,相关性要低一些!
//遍历word_map构建倒排索引
#define X 10
#define Y 1
for(auto& word_pair : word_map)
{
InvertedElem item;
item.doc_id = doc.doc_id;
item.word = word_pair.first;
//权重,标题出现权重更大
item.weight = X*word_pair.second.title_cnt + Y*word_pair.second.content_cnt;
InvertedList& inverted_list = inverted_index[word_pair.first];
inverted_list.push_back(std::move(item));
}
return true;
}
这便是index.hpp内的主体内容。其内还有获取单例指针、单例的初始化、获取正排索引或倒排索引的成员函数。具体可以到源码内查看。
3.搜索头文件searcher.hpp
一次完整的搜索过程为:用户提交关键字->分词后倒排索引搜索文档->找出文档ID->正排索引找到文档内容->构建文档摘要(因为搜索页无法对全部文档进行展示)->返回给用户
我们在给用户返回时,要返回的是一个html文件的内容。我们在这里再次新建一个文件结构体,定义如下:
struct InvertedElemPrint
{
//文档去重结构体
uint16_t doc_id;
int weight;
std::vector<std::string> words;
InvertedElemPrint()
:doc_id(0)
,weight(0)
{}
};
这里不同于之前的word是单个string,而是一个vector,因为这个结构体代表的是一个文件,用户输入的字符串经过切分后可能有多个关键字对应这个文件,所以用一个vector来存储。
定义一个Searcher类,其内包含一个Index类的指针,方便对索引建立搜索。
定义一个成员函数InitSearcher,用于获取Index的单例指针并对其进行初始化。
class Searcher
{
public:
Searcher() {}
~Searcher() {}
void InitSearcher(const std::string& input)
{
//1.获取index单例
index = ns_index::Index::GetInstance();
//std::cout << "获取单例成功..." << std::endl;
LOG(NORMAL,"获取index单例成功...");
//2.根据index对象建立索引
index->BuildIndex(input);
//std::cout<< "建立正排倒排索引成功" << std::endl;
LOG(NORMAL,"建立正排倒排索引成功...");
}
private:
ns_index::Index *index; //供系统进行查找的索引
};
在搜索的过程中,我们有以下的步骤:
①分词:对query按照searcher的要求进行分词
②触发:根据分词后的各个词,进行index查找 (注意:因为在建立索引存储关键词时用的是小写,所以这里也要对用户输入做小写处理)
③合并排序:汇总查找结果,按照相关性(weight)降序排序
④构建:根据查找出的结果构建json串(这里用了第三方json库jsoncpp,通过jsoncpp完成序列化与反序列化)
①的过程很简单,我们只需要对之前的代码进行复用,就可以完成很好的切片。
std::vector<std::string> words;
ns_util::JiebaUtil::CutString(query,&words);
//query是用户通过参数传入的字符串
②因为我们要把搜索结果保存在InvertedElemPrint结构体内,所以要建立一个vector来保存文件Print结构体。又因为多个关键字可能对应同一个文件,所以还要对这个vector进行去重,所以再用一个unordered_map来存储文档ID和Print结构体之间的关系。
//2.触发:根据分词后的各个词,进行index查找 注:索引忽略大小写,搜索时关键字也不应区分大小写!
//ns_index::InvertedList inverted_list_all; 内部元素为ns_index::InvertedElem,在这里表示所有InvertedElem的集合
std::vector<InvertedElemPrint> inverted_list_all;//用于保存不重复倒排拉链节点的vector
std::unordered_map<uint64_t,InvertedElemPrint> tokens_map;
for(std::string word : words)
{
//用户输入的信息也要全小写处理,因为在之前建立倒排索引时,InvertedElem内的word就是小写的,方便对应
boost::to_lower(word);
//根据关键词分词内容依次获取其对应的倒排索引
ns_index::InvertedList* inverted_list = index -> GetInvertedList(word);
if(nullptr == inverted_list)
{
//若该关键字没有倒排索引,继续向下寻找下一个单词
continue;
}
//inverted_list_all.insert(inverted_list_all.end(),inverted_list->begin(),inverted_list->end());这里要注意一个问题:当用户输入的搜索内容被分为多个单词,而这多个单词指向同一个文档时,搜索结果就会出现重复,即多个搜索结果对应同一个文本。为了避免这种情况,不能简单的将所有内容添加到inverted_list_all中。
for(const auto& elem: *inverted_list)
{
auto& item = tokens_map[elem.doc_id];//存在则获取,不存在则创建
//此时item一定是doc_id相同的print节点!!
item.doc_id = elem.doc_id;//考虑新建的情况
item.weight += elem.weight;
item.words.push_back(elem.word);
}
}
//最后把map内的去重print结构体加入vector中
for(const auto& item: tokens_map)
{
inverted_list_all.push_back(std::move(item.second));
}
③直接复用官方的sort排序即可,在第三个参数填入一个lambda函数,以权值来决定两个Print结构体的先后顺序。
//3.合并排序:汇总查找结果,按照相关性(weight)降序排序
/*std::sort(inverted_list_all.begin(),inverted_list_all.end(),
[](const ns_index::InvertedElemPrint e1,const ns_index::InvertedElem e2){
return e1.weight > e2.weight;
});*/
std::sort(inverted_list_all.begin(),inverted_list_all.end(),[](const InvertedElemPrint& e1,const InvertedElemPrint& e2){
return e1.weight > e2.weight;
});
④我们利用第三方jsoncpp来完成对用户的响应。用已获取的文档ID来进行正排索引,找到DocInfo结构体,用其内的标题、正文及URL来构建返回json串。
注意:因为我们给用户返回的是一条文件,同时搜索页面内可能会有很多条搜索结果,所以返回时不能把所有正文全部显示,只能显示一个文件的概括。我们这里再定义一个GetDesc函数,来将关键字附近的局部正文给用户展示出来。如果有多个关键字与该文件对应,我们只找顺位第一个关键字周围的正文展示出来。
Json::Value root;//Json序列化的中间变量,搜索结果的总集合
for(auto& item : inverted_list_all)
{
//根据倒排索引得到的文档id寻找正排索引,找到其文档内容
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]);//这里显示的是正文的一部分(摘要),这个摘要最好要凸显关键字。以单词数组中的0号元素构建索引。
elem["url"] = doc->url;
root.append(elem);//将一个搜索结果加入到所有搜索结果的集合中
}
//这里用writer写入搜索结果
Json::FastWriter writer;
*json_string = writer.write(root);
以下是GetDesc的代码
std::string GetDesc(const std::string& html_content,const std::string& word)
{
//逻辑:找到word在html_content中首次出现的位置,然后向前寻找50个字节、向后寻找100字节,并将这部分内容截取返回。若向前不足,就从begin开始;若向后不足,就直到end。
const std::size_t prev_step = 50;
const std::size_t next_step = 100;
//1.找到首次出现的位置
//std::size_t pos = html_content.find(word); 这里要注意一个坑:html_content是从正排索引获得的html文档正文,是源文档未加任何改动的原本内容;而word是从倒排索引InvertedElem中获取的word,但是!!这个word为了匹配用户不区分大小写的输入,已经做了全小写处理!!而string的find函数并不自动屏蔽大小写,因此可能出现源文档中出现Split、倒排索引中有split,但是find函数并未找到任何内容的情况!!所以这里采用std::search接口。
auto iter = std::search(html_content.begin(),html_content.end(),word.begin(),word.end(),
[](int x,int y){ return (std::tolower(x) == std::tolower(y));});
//在前两个迭代器区间寻找后两个迭代器区间的元素的第一个出现位置,然后自己定义一个忽略大小的比较函数
if(iter == html_content.end())
{
//这种情况是不可能发生的,不过仍然处理一下
return "None";
}
std::size_t pos = std::distance(html_content.begin(),iter);
//2.获取start、end
//默认begin和end为正文起始和结尾
std::size_t start = 0;
std::size_t end = html_content.size() - 1;
//如果之前有50+字符,就更新起始位置;如果之后有100+字符,就更新结束位置
//if(pos - prev_step > start) 注意:这里是个坑,因为size_t是无符号整形,如果如此书写,即使前面的字符不足(pos-prev< 0),负数也是远大于0的。因此要用下面这种方式。
if(pos > start + prev_step)
start = pos - prev_step;
if(pos + next_step < end)//这里也要注意上一个if的问题
end = pos + next_step;
//3.截取子串返回
if(start >= end)
{
//也几乎是不可能的,做一下处理
return "None";
}
std::string desc = html_content.substr(start,end-start);
desc += "...";
return desc;
}
4.网络连接模块http_server及前端代码的编写
我们在网络连接部分使用了第三方httplib库。这里需要注意,httplib库需要高版本的gcc,所以先要升级gcc。
const std::string root_path = "./wwwroot";
const std::string input = "data/raw_html/raw.txt";
int main()
{
//使用cpp-httplib库
/*注意:cpp-httplib要使用较新版本的gcc,而centos7的gcc默认为4.8.5,这个编译器要么运行报错,要么编译不通过。
所以在使用前,应该先升级gcc。此外,我们也不使用cpp-httplib的最新版本,而是使用其0.7.15版本。*/
ns_searcher::Searcher search;
search.InitSearcher(input);//创建索引
httplib::Server svr;//用httplib先创建一个Server对象
svr.set_base_dir(root_path.c_str()); //设置首页路径
//当获取到"/s"(搜索)命令时,需要返回数据。
svr.Get("/s",[&search](const httplib::Request &req,httplib::Response &rsp)
{
//req为申请串,rsp为应答串
if(!req.has_param("word"))//检测是否有参数,参数名为word
{
rsp.set_content("必须要有搜索内容!","text/plain;charset=utf-8");//构建应答正文,以及这个应答以什么形式返回(想让用户浏览器将返回内容当做什么解释)。这里返回的是一个正常的文本文件。
return;
}
//走到此处说明有搜索内容,此时应该搜索
std::string word = req.get_param_value("word");//获取用户提上来的参数word的内容
//std::cout << "用户在搜索:" << word << std::endl;
LOG(NORMAL,"用户在搜索:"+ word);
std::string json_string;
search.Search(word,&json_string);
rsp.set_content(json_string,"application/json");//返回一个.json文件
});
LOG(NORMAL,"服务器启动成功!");
svr.listen("0.0.0.0",8081);//绑定任意ip的8081端口号,开始listen
return 0;
}
编写前端代码时要注意与http_server的配合,如后端代码中/s代表搜索,而后面word代表用户搜索的关键字,所以在前端代码搜索时要给请求带上 “/s?word=…” 的请求串。
前端代码可以在源码内查看,这里不做展示。
演示
在项目目录下make clean,再make一下,生成parser、http_server两个文件。
执行./http_server
此时用浏览器搜索http://101.43.189.110:8081/(云服务器地址)
若什么也不搜索,显示提示信息
我们这里假设搜索单词number。输入后搜索结果显示如下
而此时我们的服务器也提示了我们相关信息
我们点进第一条搜索结果,发现成功跳转到网页,项目测试成功。
后记
在编写该项目时,发生过很多预期之外的事情,如在引用第三方jieba库时,需要自己cp一个库,当我在cp后发现还是编译报错。。结果查看该库内时发现库内一个头文件都没有,是一个空库,于是自己又上网找到源库单独拷贝了一份;前端代码的编写更是折磨,要照葫芦画瓢写很多代码,最后实现整体的逻辑。size_t的问题也是找了很久。另外一次偶然心血来潮搜索几个连续空格发现把所有文件都找出来了,当时人都傻了,设置了很多调试代码才发现原来是切分了空格作为关键字,后来才对暂停词进行了处理。总之这个项目让我发现了很多我的不足,在日后也要不断完善自己。