

文章目录
- 1. 获取数据并清洗
- 下载Boost官方网站中数据
- 获取html文件名
- 清洗文件的内容
- 保存清洗的数据
- 2. 构建索引
- 代码结构
- 构建正排索引(Forward Index)
- 结构与组成
- 使用场景
- 正排索引代码
- 构建倒排索引(Inverted Index)
- 结构与组成
- 特点
- 安装下载cppjieba库
- 倒排索引代码
- 索引模块
- 3. 搜索模块
- 代码结构
- 搜索测试
- 4. 自主HttpServer模块
- TcpServer模块
- HttpProtocol模块
- HttpServer.hpp
- HttpServer.cc
- 5. 运行结果
- 6. 去除重复文档
- 7. 不足与改进点
1. 获取数据并清洗
下载Boost官方网站中数据
Boost库官方网址:https://www.boost.org/
下载相关数据
- 选择
boost_1_87_0t.ar.gz下载
,然后上传到云服务器中,如果出现乱码,可能是上传文件太大可以加上-E
选项:
结果如下:
- 拷贝下载文件中的html文件成为原始数据:
清洗原始数据
- 清洗之前 :
可以看出原始html文件中有很多特定的标签,为了方便我们查找与阅读,我们需要对其进行去标签化;
因为我们期望查找后返回给我们的格式是类似于百度等搜索引擎返回的格式,大致包含以下三个部分:
1. 标题
2. 内容
3. 网址URL
如下图所示:
所以我们可以定义一个结构体存放每个文件去标签化后的内容,然后再使用一个vector
来汇总存放每个文件去标签化后的结构体,方便我们进行管理。
代码结构如下:
#include <boost/filesystem.hpp>
#include <iostream>
#include <string>
#include <vector>
#include <fstream>#include "Util.hpp"
#include "Log.hpp"using namespace LogModule;
const std::string original_file_path = "data/originaldata";
const std::string parse_file_path = "data/parsedata/parse.txt";//清洗文件后的结构体
typedef struct FileParseData{std::string title; //文档标题std::string content; //文档内容std::string url; //文档url
}ParseData;//保存所有html文件名
bool SaveFileName(std::vector<std::string>* filenames, const std::string filepath);//遍历获取保存的html所有文件内容,进行清洗去标签后按照上面ParseData结构体保存
bool ParseFile(const std::vector<std::string>& filenames,std::vector<ParseData>* ParseDatas);//保存清洗后的ParseData到一个文档中
bool SaveParseData(const std::string& parse_file_path, const std::vector<ParseData>* ParseDatas);//清洗标签
int main()
{ENABLE_FILE_LOG_STRATEGY();//1.将文件名字保存在vector中,方便后续遍历打开std::vector<std::string> original_file_names;if(SaveFileName(&original_file_names,original_file_path) == false){LOG(LogLevel::ERROR)<<"SaveAllFileName fail ...";return 1;}LOG(LogLevel::DEBUG)<<"SaveAllFileName success ...";//2. 遍历每个文件,进行清洗标签后分为三个部分:标题、内容、url; 并存储在vector中管理std::vector<ParseData> ParseDatas;if(ParseFile(original_file_names,&ParseDatas) == false){LOG(LogLevel::ERROR)<<"ParseFile fail ...";return 2;}LOG(LogLevel::DEBUG)<<"ParseFile success ...";//3. 保存清洗之后的内容if(SaveParseData(parse_file_path,&ParseDatas) == false){LOG(LogLevel::ERROR)<<"SaveParseData fail ...";return 3;}LOG(LogLevel::DEBUG)<<"SaveParseData success ...";return 0;
}
获取html文件名
获取html文件名,方便后续遍历读取文件内容进行清洗,可以使用C++第三方库Boost来读取目录内容:
//1.将文件名字保存在filenames中,方便后续遍历打开
bool SaveFileName(std::vector<std::string>* filenames, const std::string filepath)
{//使用boost库来打开并读取文件夹namespace fs = boost::filesystem;fs::path root_path(filepath);//判断路径是否存在,不存在,就没有必要再往后走了if(!fs::exists(root_path)){LOG(LogLevel::ERROR)<< filepath << " not exists" ;return false;}//使用迭代器进行遍历文件内容//先定义一个空的迭代器,用来进行判断递归结束fs::recursive_directory_iterator end;for(fs::recursive_directory_iterator iter(root_path); iter != end; iter++){//判断文件是否是普通文件,html都是普通文件 同时 判断文件路径名的后缀是否符合要求if(!fs::is_regular_file(*iter)||iter->path().extension() != ".html"){ continue;}//LOG(LogLevel::DEBUG)<<iter->path().string();//当前的路径一定是一个合法的,以.html结束的普通网页文件//最后,将所有带路径的html保存在filenames中,方便后续进行文本分析filenames->push_back(iter->path().string()); }return true;
}
结果如下:
可以看出成功获取了html文件名
清洗文件的内容
遍历所有html文件,进行清洗标签,每个文件内容清洗之后的内容应该保存在下面的数据结构中:
typedef struct FileParseData{std::string title; //文档标题std::string content; //文档内容std::string url; //文档url
}ParseData;
同时使用
vector
来管理所有文档清洗之后的FileParseData
;
static bool ParseTitle(const std::string& html,std::string* title)
{//要查找<title>std::string html_title = "<title>";auto pos = html.find(html_title);if(pos == std::string::npos){LOG(LogLevel::WARNING)<<"ParseTitle fail ...";return false;}//查找</title>auto endpos = html.find("</title>");if(endpos == std::string::npos){LOG(LogLevel::WARNING)<<"ParseTitle fail ...";return false;}if(pos > endpos){return false;}//截取中间titlepos+=html_title.size();*title = html.substr(pos,endpos-pos);//后一个参数是截取个数nreturn true;
}static bool ParseContent(const std::string& html,std::string* content)
{//使用状态机进行清洗标签char statue = 'l';//表示lable标签for(auto c : html){switch(statue){case 'l'://lable不能读取内容,等到读到标签最后一个字符'>'时才可以读取内容,此时要转换状态为c——contentif(c == '>') statue = 'c';//表示内容break;case 'c':if(c == '<') statue = 'l';//表示标签else {if(c == '\n') c = ' ';//不保留换行符*content+=c;}break;default:break;}}return true;
}static bool ParseUrl(const std::string& htmlpath,std::string* url)
{std::string url_head = "https://www.boost.org/doc/libs/1_87_0/doc/html";std::string url_tail = htmlpath.substr(original_file_path.size());*url = url_head + url_tail;return true;
}//测试
static int count = 0;
void ShowDoc(ParseData doc)
{std::cout<<count<< ":"<<std::endl;std::cout<<"title: "<<doc.title<<std::endl;std::cout<<"content: "<<doc.content<<std::endl;std::cout<<"url: "<<doc.url<<std::endl;std::cout<< "--------------------------------------------"<<std::endl;++count;
}//2. 遍历每个文件,进行清洗标签
bool ParseFile(const std::vector<std::string>& filenames,std::vector<ParseData>* ParseDatas)
{for(auto& file : filenames){//1. 读取文件,Read();std::string html_content;if(!UtilModule::FileUtil::ReadFile(file, &html_content)){continue;}ParseData doc;//2. 解析指定的文件,提取titleif(!ParseTitle(html_content, &doc.title)){continue;}//3. 解析指定的文件,提取content,就是去标签if(!ParseContent(html_content, &doc.content)){continue;}//4. 解析指定的文件路径,构建urlif(!ParseUrl(file, &doc.url)){continue;}//for debug//ShowDoc(doc);//done,一定是完成了解析任务,当前文档的相关结果都保存在了doc里面ParseDatas->push_back(std::move(doc)); //细节,不move会发生拷贝,效率可能会比较低}return true;
}
测试结果如下:
共获取并清洗了8760个html文件的内容
保存清洗的数据
使用’\3’来分开每个文件中的标题、内容和url,文件之间使用’\n’来分隔 ;这样一行就是一个文件的内容
// 3. 保存清洗之后的内容,使用'\3'来分开每个文件中的标题、内容和url,文件之间使用'\n'来分隔
// 这样一行就是一个文件的内容
bool SaveParseData(const std::string& parse_file_path, const std::vector<ParseData>* ParseDatas)
{//按照二进制方式写入文件std::fstream out(parse_file_path,std::ios::out | std::ios::binary);if(!out.is_open()){LOG(LogLevel::ERROR)<<"SaveParseData: "<<parse_file_path <<"open fail ...";return false;}//遍历每个文件读取清洗后内容,并合并到out文件中
#define SEP '\3'for(auto& data : *ParseDatas){std::string parsefile = data.title + SEP + data.content + SEP + data.url + '\n';out.write(parsefile.c_str(),parsefile.size());}//记得关闭文件描述符out.close();return true;
}
结果如下:
可以看到在
const std::string parse_file_path = "data/parsedata/parse.txt";
路径下保存了大量数据。
- 清洗之后:
去掉标签内容以及不必要的换行符
2. 构建索引
代码结构
```cpp
#pragma once#include <iostream>
#include <fstream>
#include <string>
#include <vector>
#include <unordered_map>
#include "Log.hpp"
#include "Util.hpp"namespace IndexModule
{using namespace LogModule;class Index{public:// 构建索引bool BuildIndex(const std::string &parsedata){int count = 0; // 记录构建的索引数LOG(LogLevel::DEBUG)<<"BuildIndex ...";// 1.打开文件//std::fstream in(parsedata, std::ios::out | std::ios::binary);//!!!!不能是out@^@std::fstream in(parsedata, std::ios::in | std::ios::binary);if (!in.is_open()){LOG(LogLevel::ERROR) << "BuildIndex: " << parsedata << " open fail ...";return false;}LOG(LogLevel::DEBUG) << "BuildIndex: " << parsedata << " open success ...";// 2. 读取文件内容// 一行就是一个文件std::string line;while (std::getline(in, line)){//LOG(LogLevel::DEBUG) << "BuildIndex: " << line << " :get a file ...";// 3. 构建正排索引if (BuildForwardIndex(line) == false)continue;// 4. 构建倒排索引BuildInvertedIndex(_forward.back()); // 通过新插入的正排索引元素来构建倒排索引// for debug++count;if (count % 100 == 0)LOG(LogLevel::DEBUG) << "已经构建的索引数:" << count;}// 5. 关闭文件in.close();return true;}private:// 构建正排索引bool BuildForwardIndex(const std::string &file){return true;}// 构建倒排索引// 标题与正文对应单词权重不同,需单独切分——使用jieba库bool BuildInvertedIndex(const ForwardElem &file){return true;}~Index(){}private:std::vector<ForwardElem> _forward;std::unordered_map<std::string, std::vector<InvertedElem>> _inverted;};};
构建正排索引(Forward Index)
正排索引,也称为前向索引或文档索引,是信息检索领域中的一种数据结构。它主要用于存储和组织文档中的内容,以便于快速检索。与倒排索引(Inverted Index)不同,正排索引直接记录了每个文档的内容及其相关信息。
也就是说通过文档id或编号直接找到文档内容以及相关信息
结构与组成
一个典型的正排索引包括以下部分:
- 文档ID:唯一标识每个文档的编号。
- 文档内容:文档的实际内容,可以是文本、图像、视频等多媒体数据。
- 元数据:关于文档的附加信息,如作者、创建时间、修改时间等。
例如,假设我们有一个包含三篇文档的集合:
- 文档1:
"This is the first document."
- 文档2:
"This document is the second document."
- 文档3:
"And this is the third one."
正排索引可能如下所示:
文档ID | 文档内容 |
---|---|
1 | "This is the first document." |
2 | "This document is the second document." |
3 | "And this is the third one." |
使用场景
正排索引通常用于以下几种场景:
- 全文检索系统:在某些情况下,正排索引可以作为辅助结构,帮助快速定位和提取原始文档内容。
- 数据库系统:在关系型数据库中,表的数据行可以视为一种正排索引的形式。
- 文件系统:文件系统的目录结构也是一种形式的正排索引,它记录了文件名及其路径。
对于本次搜索引擎来说正排索引结构应包括:
struct ForwardElem{
std::string title; //⽂档的标题
std::string content; //⽂档对应的去标签之后的内容
std::string url; //官⽹⽂档url
uint64_t doc_id; //⽂档的ID
};
正排索引代码
// 正排索引——根据文档id找到内容ForwardElem *GetForwardIndex(uint64_t id){if (id >= _forward.size()){LOG(LogLevel::ERROR) << "GetForwardIndex:" << id << " 对应的文档未找到....";return nullptr;}return &_forward[id];}// 构建正排索引bool BuildForwardIndex(const std::string &file){if (file.empty()){LOG(LogLevel::DEBUG)<<file <<" is empty";return false;}ForwardElem elem;// 1.将已经清洗过的文件内容按照\3进行分割std::vector<std::string> split_file;UtilModule::StringUtil::Split(&split_file, file, "\3");if (split_file.size() != 3){LOG(LogLevel::WARNING) << "BuildForwardIndex::Split fail ...";return false;}// 2. 填充正排索引字段elem.title = split_file[0];elem.content = split_file[1];elem.url = split_file[2];elem.doc_id = _forward.size();// 3. 将数据保存到正排索引vector结构中_forward.push_back(std::move(elem));return true;}
构建倒排索引(Inverted Index)
结构与组成
倒排索引(Inverted Index) 是信息检索系统中的一种数据结构,用于快速查找包含特定词项的文档。它广泛应用于搜索引擎、数据库和其他需要高效文本检索的应用中。
倒排索引主要由两部分组成:
- 词汇表(Dictionary):存储所有词项及其位置信息。
- 倒排列表(Postings List):每个词项对应的文档ID列表,表示哪些文档包含该词项。
例如,假设有以下三个文档:
- 文档1: “我喜欢编程”
- 文档2: “编程是一种艺术”
- 文档3: “我喜欢学习”
倒排索引可能如下所示:
{"我喜欢": [1, 3],"编程": [1, 2],"是": [2],"一种": [2],"艺术": [2],"学习": [3]
}
特点
- 高效查询:通过倒排索引可以快速定位包含某个词项的所有文档,而不需要逐个扫描文档内容。
- 空间优化:通常会对词项和文档ID进行压缩存储以节省空间。
- 扩展性强:支持多种扩展功能,如词频统计、位置信息等,有助于提高检索精度。
安装下载cppjieba库
构建倒排索引需要将词与文档对应,所以我们需要将文档的内容进行分词,可以借助第三方库jieba库来分词。
git clone https://github.com/yanyiwu/cppjieba.git
为了防止jieba头文件找不到,我们需要建立以下两个连接:
同时将cppjieba中limonp目录拷贝到include/cppjieba/目录下:
cp -rf deps/limonp include/cppjieba/
可以使用下面的代码测试是否成功安装jieba库:
#include "jieba/cppjieba/Jieba.hpp"//这里是你自己建立连接的路径
#include <iostream>
#include <string>
#include <vector>
using namespace std;int main() {cppjieba::Jieba jieba("dict/jieba.dict.utf8","dict/hmm_model.utf8","dict/user.dict.utf8","dict/idf.utf8","dict/stop_words.utf8");std::vector<std::string> words;std::string sentence = "小明硕士毕业于中国科学院计算所,后在日本京都大学深造";// 使用 CutForSearch 方法进行分词jieba.CutForSearch(sentence, words);for (const auto& word : words) {std::cout << word << "/";}std::cout << std::endl;return 0;
}
结果如下:
倒排索引代码
倒排索引通过单词查询相关文档,除了找出相关文档我们还需要将找出的文档按照相关性进行排序,所以倒排索引的结构应该如下面所示:
// 倒排索引元素typedef struct InvertedElem{std::string word; // 倒排索引单词uint64_t doc_id; // 单词对应文档idint weight; // 单词所占权重,用来排序@@} InvertedElem;
将所有与该词相关的文档存在一个vector中,同时使用unordered_map将其与单词之间链接起来,方便我们查询。
// 倒排索引——根据单词找到相关文档std::vector<InvertedElem> *GetInvertedIndex(const std::string& word) {auto pos = _inverted.find(word);if (pos == _inverted.end()){LOG(LogLevel::INFO) << word << " :未找到相关内容...";return nullptr;}return &(pos->second);}// 构建倒排索引// 标题与正文对应单词权重不同,需单独切分——使用jieba库bool BuildInvertedIndex(const ForwardElem &file){typedef struct word_count{int title_count;int content_count;} word_count;// 1. 将file中标题进行切分std::vector<std::string> title_words;UtilModule::JiebaUtil::CutString(&title_words, file.title);// 2.将标题切分的词保存在unordered_map中std::unordered_map<std::string, word_count> words;for (auto &word : title_words){boost::to_lower(word);//需要统一切换成小写,方便查询时大小都有++words[word].title_count;}// 3. 将file中content内容进行切分std::vector<std::string> content_words;UtilModule::JiebaUtil::CutString(&content_words, file.content);// 4. 将content切分的词保存在unordered_map中for (auto &word : content_words){boost::to_lower(word);//需要统一切换成小写,方便查询时大小都有++words[word].content_count;}// 5. 根据自定义相关性来构建倒排元素InvertedElem elem;for (auto &word : words){elem.doc_id = file.doc_id;elem.word = word.first;elem.weight = word.second.title_count * 10 + word.second.content_count; // 相关性 标题 : 正文 = 10 : 1// 6. 将elem插入倒排unordered_map中_inverted[elem.word].push_back(elem);}return true;}
索引模块
每次索引都需要将所有文档进行遍历或分词构建索引,这样效率太低,我们可以引入单例模式即整个程序中只要有一份索引对象即可,如下面代码所示,构建索引类时使用静态指针,如果未定义就创建,定义了就直接使用之前创建的即可,这样可以大大减少空间时间的浪费,提高效率:
#pragma once#include <iostream>
#include <fstream>
#include <string>
#include <vector>
#include <unordered_map>
#include "Log.hpp"
#include "Util.hpp"namespace IndexModule
{using namespace LogModule;// 正排索引元素typedef struct ForwardElem{std::string title; // ⽂档的标题std::string content; // ⽂档对应的去标签之后的内容std::string url; // 官⽹⽂档urluint64_t doc_id; // ⽂档的ID} ForwardElem;// 倒排索引元素typedef struct InvertedElem{std::string word; // 倒排索引单词uint64_t doc_id; // 单词对应文档idint weight; // 单词所占权重,用来排序@@} InvertedElem;class Index{public://单例模式static Index* GetInstance()//static修饰只有一个{if(nullptr == instance){mutex.Lock();if(nullptr == instance)instance = new Index();mutex.Unlock();}return instance;}// 正排索引——根据文档id找到内容ForwardElem *GetForwardIndex(uint64_t id){if (id >= _forward.size()){LOG(LogLevel::ERROR) << "GetForwardIndex:" << id << " 对应的文档未找到....";return nullptr;}return &_forward[id];}// 倒排索引——根据单词找到相关文档std::vector<InvertedElem> *GetInvertedIndex(const std::string& word) {auto pos = _inverted.find(word);if (pos == _inverted.end()){LOG(LogLevel::INFO) << word << " :未找到相关内容...";return nullptr;}return &(pos->second);}// 构建索引bool BuildIndex(const std::string &parsedata){int count = 0; // 记录构建的索引数LOG(LogLevel::DEBUG)<<"BuildIndex ...";// 1.打开文件//std::fstream in(parsedata, std::ios::out | std::ios::binary);//!!!!不能是out@^@std::fstream in(parsedata, std::ios::in | std::ios::binary);if (!in.is_open()){LOG(LogLevel::ERROR) << "BuildIndex: " << parsedata << " open fail ...";return false;}LOG(LogLevel::DEBUG) << "BuildIndex: " << parsedata << " open success ...";// 2. 读取文件内容// 一行就是一个文件std::string line;while (std::getline(in, line)){//LOG(LogLevel::DEBUG) << "BuildIndex: " << line << " :get a file ...";// 3. 构建正排索引if (BuildForwardIndex(line) == false)continue;// 4. 构建倒排索引BuildInvertedIndex(_forward.back()); // 通过新插入的正排索引元素来构建倒排索引// for debug++count;if (count % 100 == 0)LOG(LogLevel::DEBUG) << "已经构建的索引数:" << count;}// 5. 关闭文件in.close();return true;}private:// 构建正排索引bool BuildForwardIndex(const std::string &file){if (file.empty()){LOG(LogLevel::DEBUG)<<file <<" is empty";return false;}ForwardElem elem;// 1.将已经清洗过的文件内容按照\3进行分割std::vector<std::string> split_file;UtilModule::StringUtil::Split(&split_file, file, "\3");if (split_file.size() != 3){LOG(LogLevel::WARNING) << "BuildForwardIndex::Split fail ...";return false;}// 2. 填充正排索引字段elem.title = split_file[0];elem.content = split_file[1];elem.url = split_file[2];elem.doc_id = _forward.size();// 3. 将数据保存到正排索引vector结构中_forward.push_back(std::move(elem));return true;}// 构建倒排索引// 标题与正文对应单词权重不同,需单独切分——使用jieba库bool BuildInvertedIndex(const ForwardElem &file){typedef struct word_count{int title_count;int content_count;} word_count;// 1. 将file中标题进行切分std::vector<std::string> title_words;UtilModule::JiebaUtil::CutString(&title_words, file.title);// 2.将标题切分的词保存在unordered_map中std::unordered_map<std::string, word_count> words;for (auto &word : title_words){boost::to_lower(word);//需要统一切换成小写,方便查询时大小都有++words[word].title_count;}// 3. 将file中content内容进行切分std::vector<std::string> content_words;UtilModule::JiebaUtil::CutString(&content_words, file.content);// 4. 将content切分的词保存在unordered_map中for (auto &word : content_words){boost::to_lower(word);//需要统一切换成小写,方便查询时大小都有++words[word].content_count;}// 5. 根据自定义相关性来构建倒排元素InvertedElem elem;for (auto &word : words){elem.doc_id = file.doc_id;elem.word = word.first;elem.weight = word.second.title_count * 10 + word.second.content_count; // 相关性 标题 : 正文 = 10 : 1// 6. 将elem插入倒排unordered_map中_inverted[elem.word].push_back(elem);}return true;}~Index(){}private:std::vector<ForwardElem> _forward;std::unordered_map<std::string, std::vector<InvertedElem>> _inverted;Index(){}//构建单例模式//静态成员变量,保存唯一的实例static Index* instance;// 互斥锁,确保线程安全static MutexModule::Mutex mutex;Index(const Index &) = delete;Index &operator=(const Index &) = delete;};Index* Index::instance = nullptr;MutexModule::Mutex Index::mutex;//自定义锁类
};
3. 搜索模块
当我们构建好正排和倒排索引后,就可以根据索引类提供的查询接口进行搜索文档:
- 输入搜索词
- 将搜索词进行分词
- 分词后逐一倒排查询到相关文档
- 将相关文档按照相关性进行排序
- 通过正排查询获取文档内容构建json串返回上层
代码结构
#pragma once#include "Index.hpp"
#include <algorithm>
#include <unordered_map>
#include <jsoncpp/json/json.h>namespace SearchModule{class Searcher{public:Searcher(){}~Searcher(){}//初始化,建立索引void InitSearcher(const std::string &input){//1. 获取或者创建Index对象 //2. 根据Index对象建立索引 } //搜索函数//query: 搜索关键字//json_string: 返回给用户浏览器的搜索结果——使用json序列化void Search(const std::string &query, std::string *json_string){//1.将query分词,逐一搜索//2.分好词之后,逐一进行倒排索引——根据单词找文档//3. 将倒排索引元素按照词的weight——相关性进行排序//4. 然后根据倒排元素id通过正排索引找到文档并保存Forward_List中//5. 将查找到的文档合并为json串返回 private:IndexModule::Index *index; //供系统进⾏查找的索引};
}
在seach函数中相关性按照标题 : 正文 = 10 : 1 来分配的,也就是说一个词在标题中出现1次就+10,在正文中出现1次就+1;
同时返回文档的内容不能将所有内容全部返回,应当返回与搜索词相关的一部分,所有这里我们选择正文中第一次出现搜索词前50和后100的位置截取为正文摘要。
代码如下:
#pragma once#include "Index.hpp"
#include <algorithm>
#include <unordered_map>
#include <jsoncpp/json/json.h>namespace SearchModule{class Searcher{public:Searcher(){}~Searcher(){}//初始化,建立索引void InitSearcher(const std::string &input){//1. 获取或者创建Index对象index = IndexModule::Index::GetInstance();LOG(LogLevel::DEBUG)<<"InitSearcher: 获取单例Index对象成功";//2. 根据Index对象建立索引index->BuildIndex(input);LOG(LogLevel::DEBUG)<<"建立正排和倒排索引成功";}//搜索函数//query: 搜索关键字//json_string: 返回给用户浏览器的搜索结果——使用json序列化void Search(const std::string &query, std::string *json_string){//1.将query分词,逐一搜索std::vector<std::string> words;UtilModule::JiebaUtil::CutString(&words,query);//2.分好词之后,逐一进行倒排索引——根据单词找文档std::vector<IndexModule::InvertedElem> Inverted_List;for(auto& word : words){boost::to_lower(word);//记得切换小写std::vector<IndexModule::InvertedElem>* Inverted_Elems = index->GetInvertedIndex(word);if(Inverted_Elems == nullptr)continue;//找到倒排索引元素后存放在Inverted_List中Inverted_List.insert(Inverted_List.end(),Inverted_Elems->begin(),Inverted_Elems->end()); //注意可能有重复文档出现 }//3. 将倒排索引元素按照词的weight——相关性进行排序std::sort(Inverted_List.begin(),Inverted_List.end(),[](IndexModule::InvertedElem& elem1,IndexModule::InvertedElem& elem2){return elem1.weight > elem2.weight;});//4. 然后根据倒排元素id通过正排索引找到文档并保存Forward_List中//5. 将查找到的文档合并为json串返回//std::vector<IndexModule::ForwardElem> Forward_List;Json::Value root;for(auto& inverted_elem : Inverted_List){IndexModule::ForwardElem forward_elem = *index->GetForwardIndex(inverted_elem.doc_id);Json::Value value;value["title"] = forward_elem.title;//value["content"] = forward_elem.content;//应该是摘要value["content"] = GetAbstract(forward_elem.content,inverted_elem.word);value["url"] = forward_elem.url;root.append(value);}Json::StyledWriter writer;//Json::FastWriter writer;*json_string = writer.write(root);//不能分开保存,返回摘要时需要单词来查找// //4. 然后根据倒排元素id通过正排索引找到文档并保存Forward_List中// std::vector<IndexModule::ForwardElem> Forward_List;// for(auto& inverted_elem : Inverted_List)// Forward_List.push_back(*index->GetForwardIndex(inverted_elem.doc_id));// //5. 将查找到的文档合并为json串返回// Json::Value root;// for(auto& forward_elem : Forward_List)// {// Json::Value value;// value["title"] = forward_elem.title;// //value["content"] = forward_elem.content;//应该是摘要// value["content"] = GetAbstract(forward_elem.content,);// value["url"] = forward_elem.url;// root.append(value);// }// Json::FastWriter writer;// *json_string = writer.write(root);}private://在单词首次出现的位置往前50和往后100截取为摘要std::string GetAbstract(const std::string &content,const std::string &word)//word已经是小写{int pos = content.find(word);if(pos == std::string::npos)return "None1";int begin = pos - 50;if(begin < 0)begin = 0;int end = pos + 100;if(end >= content.size())end = content.size()-1;if(begin > end)return "None2";std::string abs = content.substr(begin,end - begin) + " ...";return abs;}private:IndexModule::Index *index; //供系统进⾏查找的索引};
}
搜索测试
#include "Searcher.hpp"
#include <cstdio>
#include <cstring>
#include <iostream>
#include <string>
const std::string file_path = "data/parsedata/parse.txt";
int main()
{SearchModule::Searcher *search = new SearchModule::Searcher();search->InitSearcher(file_path);std::string query;std::string json_string;char buffer[1024];while(true){std::cout << "Please Enter You Search Query# ";fgets(buffer, sizeof(buffer)-1, stdin);buffer[strlen(buffer)-1] = 0;query = buffer;search->Search(query, &json_string);std::cout << json_string << std::endl;}return 0;
}
结果如下:
可以看出构建了8700条索引,与之前清洗的文件个数对应上了;同时我们发现输入搜索词
split
时返回了有关正文、标题以及url的内容但是我们不清楚是否按照相关性进行排序的,所以我们可以再输出一些内容用来测试:
//debugvalue["id"] = (int)inverted_elem.doc_id;value["weight"] = inverted_elem.weight;
结果如下 :
发现确实是按照相关性进行排序的,但是这个weight是否准确呢? 我们可以通过url查询相关文档进行对比
因为我们使用的jieba库分词可能和我们预想的不太一样,所以weight值可能有些偏差,但是影响不大,此外我们在查找正文中词的数量时包括了标题的词,所以有可能weight会比实际的大一点,可以设置weight-1,更准确一点,但我们也不准备输出weight在网页上,所以就不做处理了。
4. 自主HttpServer模块
当搜索服务在本地测试完毕后,就可以将其引入到网络中,实现网络服务,这里可以借助cpp第三方http库,也可以自主实现http服务器进行网络搜索服务。
TcpServer模块
其实Http服务器底层是借助Tcp实现网络传输的,所以我们需要一个TcpServer.hpp; 此外因为套接字不管是udp、tcp都要使用,我们就可以封装一个套接字类,方便我们调用:
#pragma once#include <iostream>
#include <string>
#include <cstdlib>
#include <sys/socket.h>
#include <unistd.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <strings.h>
#include "Log.hpp"
#include "Common.hpp"
#include "InetAddr.hpp"namespace SocketModule
{using namespace LogModule;class Socket;using SockPtr = std::shared_ptr<Socket>;// 基类,规定创建socket的方法// 提供一个/若干个/固定模式的socket的方法// 模版方法模式class Socket{public:virtual ~Socket() = default;virtual void SocketOrDie() = 0;virtual void SetSocketOpt() = 0;virtual bool BindOrDie(int port) = 0;virtual bool ListenOrDie() = 0;virtual SockPtr Accepter(InetAddr *client) = 0;virtual void Close() = 0;virtual int Recv(std::string *out) = 0;virtual int Send(const std::string &in) = 0;virtual int Fd() = 0;// 提供一个创建listensockfd的固定讨论void BuildTcpSocketMethod(int port){SocketOrDie();SetSocketOpt();BindOrDie(port);ListenOrDie();}// 其他方法,需要的时候,在加// #ifdef WIN// // 提供一个创建listensockfd的固定讨论// void BuildTcpSocket()// {// SocketOrDie();// SetSocketOpt();// BindOrDie();// ListenOrDie();// }// // 提供一个创建udpsockfd的固定讨论// void BuildUdpSocket()// {// }// #else // Linux// // 提供一个创建listensockfd的固定讨论// void BuildTcpSocket()// {// SocketOrDie();// SetSocketOpt();// BindOrDie();// ListenOrDie();// }// // 提供一个创建udpsockfd的固定讨论// void BuildUdpSocket()// {// }// #endif};// class UdpSocket : public Socket// {// public:// virtual ~TcpSocket()// {}// virtual int SocketOrDie() = 0;// virtual void SetSocketOpt() = 0;// virtual bool BindOrDie(int listensockfd) = 0;// virtual bool ListenOrDie(int listensockfd) = 0;// virtual int Accepter(int listensockfd) = 0;// virtual void Close(int fd) = 0;// private:// };class TcpSocket : public Socket{public:TcpSocket() : _sockfd(gdefaultsockfd){}TcpSocket(int sockfd) : _sockfd(sockfd){}virtual ~TcpSocket(){}virtual void SocketOrDie() override{_sockfd = ::socket(AF_INET, SOCK_STREAM, 0);if (_sockfd < 0){LOG(LogLevel::ERROR) << "socket error";exit(SOCKET_ERR);}LOG(LogLevel::DEBUG) << "socket create success: " << _sockfd;}virtual void SetSocketOpt() override{// 保证我们的服务器,异常断开之后,可以立即重启,不会有bind问题int opt = 1;int n = ::setsockopt(_sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));(void)n;}virtual bool BindOrDie(int port) override{if (_sockfd == gdefaultsockfd)return false;InetAddr addr(port);int n = ::bind(_sockfd, addr.NetAddr(), addr.NetAddrLen());if (n < 0){LOG(LogLevel::ERROR) << "bind error";exit(SOCKET_ERR);}LOG(LogLevel::DEBUG) << "bind create success: " << _sockfd;return true;}virtual bool ListenOrDie() override{if (_sockfd == gdefaultsockfd)return false;int n = ::listen(_sockfd, gbacklog);if (n < 0){LOG(LogLevel::ERROR) << "listen error";exit(LISTEN_ERR);}LOG(LogLevel::DEBUG) << "listen create success: " << _sockfd;return true;}// 1. 文件描述符 2. client infovirtual SockPtr Accepter(InetAddr *client) override{if (!client)return nullptr;struct sockaddr_in peer;socklen_t len = sizeof(peer);int newsockfd = ::accept(_sockfd, CONV(&peer), &len);if (newsockfd < 0){LOG(LogLevel::WARNING) << "accept error";return nullptr;}client->SetAddr(peer, len);return std::make_shared<TcpSocket>(newsockfd);}virtual void Close() override{if (_sockfd == gdefaultsockfd)return;::close(_sockfd);}virtual int Recv(std::string *out) override{char buffer[1024*8];auto size = ::recv(_sockfd, buffer, sizeof(buffer) - 1, 0);if (size > 0){buffer[size] = 0;*out = buffer;}return size;}virtual int Send(const std::string &in) override{auto size = ::send(_sockfd, in.c_str(), in.size(), 0);return size;}virtual int Fd() override{return _sockfd;}private:int _sockfd;};// for test// int main()// {// Socket *sk = new TcpSocket();// sk->BuildTcpSocket(8080);// }
}
TcpServer.hpp:
#pragma once#include <iostream>
#include <memory>
#include <functional>
#include <sys/wait.h>
#include "Socket.hpp"namespace TcpServerModule
{using namespace SocketModule;using namespace LogModule;using tcphandler_t = std::function<bool(SockPtr, InetAddr)>;// 是它只负责进行IO,流式的IO,不对协议进行任何处理class TcpServer{public:TcpServer(int port): _listensockp(std::make_unique<TcpSocket>()),_running(false),_port(port){}void InitServer(tcphandler_t handler){_handler = handler;_listensockp->BuildTcpSocketMethod(_port);}void Loop(){_running = true;while (_running){// 1. AcceptInetAddr clientaddr;auto sockfd = _listensockp->Accepter(&clientaddr);if (sockfd == nullptr)continue;// 2. IO处理LOG(LogLevel::DEBUG) << "get a new client, info is: " << clientaddr.Addr();// sockfd->Recv();// sockfd->Send();//使用多进程进行通信pid_t id = fork();if (id == 0){_listensockp->Close();if (fork() > 0)exit(0);_handler(sockfd, clientaddr);exit(0);}sockfd->Close();waitpid(id, nullptr, 0);}_running = false;}~TcpServer(){_listensockp->Close();}private:// 一定要有一个Listensockstd::unique_ptr<Socket> _listensockp;bool _running;tcphandler_t _handler;int _port;};
}
HttpProtocol模块
Http服务请求和应答都是有自己的特殊格式的,如下图所示:
所以我们需要定制协议来完成网络通信:
#pragma once#include <iostream>
#include <string>
#include <vector>
#include <sstream>
#include <unordered_map>
#include <fstream>
#include "Common.hpp"
#include "Log.hpp"const std::string Sep = "\r\n";
const std::string LineSep = " ";
const std::string HeaderLineSep = ": ";
const std::string BlankLine = Sep;
const std::string defaulthomepage = "wwwroot";
const std::string http_version = "HTTP/1.0";
const std::string page404 = "wwwroot/404.html";
const std::string firstpage = "index.html";using namespace LogModule;
// B/S
class HttpRequest
{
public:HttpRequest() {}~HttpRequest() {}bool IsHasArgs(){return _isexec;}// GET /favicon.ico HTTP/1.1\r\n// Host: 8.137.19.140:8080// Connection: keep-alive// User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0// Accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8// Referer: http://8.137.19.140:8080/?msg=i_have_sent_a_message_he_he_he_he_he_he_he_he_he_he_he_he_he_he_he_he_he_he_he_he_he_he_he_he_he_he_he_he_he_he_he_he_he_he_he_he_he_he_he_he_he_// Accept-Encoding: gzip, deflate// Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6// dnt: 1// sec-gpc: 1//bool ParseHeaderkv(){std::string key, value;for (auto &header : _req_header){// Connection: keep-aliveif (SplitString(header, HeaderLineSep, &key, &value)){_headerkv.insert(std::make_pair(key, value));}}return true;}bool ParseHeader(std::string &request_str){std::string line;while (true){bool r = ParseOneLine(request_str, &line, Sep);if (r && !line.empty()){_req_header.push_back(line);}else if (r && line.empty()){_blank_line = Sep;break;}else{return false;}}ParseHeaderkv();return true;}void Deserialize(std::string &request_str){if (ParseOneLine(request_str, &_req_line, Sep)){// 提取请求行中的详细字段ParseReqLine(_req_line, LineSep);ParseHeader(request_str);_body = request_str;// 分析请求中,是否含有参数..if (_method == "POST"){_isexec = true; // 参数在正文_args = _body;_path = _uri;LOG(LogLevel::DEBUG) << "POST: _path: " << _path;LOG(LogLevel::DEBUG) << "POST: _args: " << _args;}}}std::string GetContent(const std::string path){std::string content;std::ifstream in(path, std::ios::binary);if (!in.is_open())return std::string();in.seekg(0, in.end);int filesize = in.tellg();in.seekg(0, in.beg);content.resize(filesize);in.read((char *)content.c_str(), filesize);in.close();LOG(LogLevel::DEBUG) << "content length: " << content.size();return content;}void Print(){std::cout << "_method: " << _method << std::endl;std::cout << "_uri: " << _uri << std::endl;std::cout << "_version: " << _version << std::endl;for (auto &kv : _headerkv){std::cout << kv.first << " # " << kv.second << std::endl;}std::cout << "_blank_line: " << _blank_line << std::endl;std::cout << "_body: " << _body << std::endl;}std::string Uri(){return _uri;}void SetUri(const std::string newuri){_uri = newuri;}std::string Path() { return _path; }std::string Args() { return _args; }std::string Suffix()//后缀{// _uri -> wwwroot/index.html wwwroot/image/1.jpg wwwroot/login.htmlauto pos = _uri.rfind(".");if (pos == std::string::npos)return std::string(".html");elsereturn _uri.substr(pos);}private:// GET /favicon.ico HTTP/1.1 -> GET /favicon.ico HTTP/1.1void ParseReqLine(std::string &_req_line, const std::string sep){(void)sep;std::stringstream ss(_req_line);ss >> _method >> _uri >> _version;}private:std::string _req_line;std::vector<std::string> _req_header;std::string _blank_line;std::string _body; // body内部可能会包含参数// 在反序列化的过程中,细化我们解析出来的字段std::string _method;std::string _uri; // 用户想要这个 // /login.html || /login?xxxxxstd::string _path;std::string _args;std::string _version;std::unordered_map<std::string, std::string> _headerkv;bool _isexec = false;
};// 对于http,任何请求,都要有应答
class HttpResponse
{
public:HttpResponse() : _verion(http_version), _blank_line(Sep){}~HttpResponse(){}void Build(HttpRequest &req){std::string uri = defaulthomepage + req.Uri(); // wwwroot/ wwwroot/a/b/if (uri.back() == '/'){uri += firstpage; // wwwroot/index.html// req.SetUri(uri);}LOG(LogLevel::DEBUG) << "------客户端在请求: " << req.Uri();req.Print();LOG(LogLevel::DEBUG) << "----------------------------";_content = req.GetContent(uri);// LOG(LogLevel::DEBUG) << "content length: " << _content.size();if (_content.empty()){// 用户请求的资源并不存在_status_code = 404;// req.SetUri(page404);_content = req.GetContent(page404);}else{_status_code = 200;}_status_desc = Code2Desc(_status_code); // 和状态码是强相关的!if (!_content.empty()){SetHeader("Content-Length", std::to_string(_content.size()));}std::string mime_type = Suffix2Desc(req.Suffix());SetHeader("Content-Type", mime_type);_body = _content;}void SetCode(int code){_status_code = code;_status_desc = Code2Desc(_status_code);}void SetBody(const std::string &body){_body = body;}void SetHeader(const std::string &k, const std::string &v){_header_kv[k] = v;}void Serialize(std::string *resp_str){for (auto &header : _header_kv){_resp_header.push_back(header.first + HeaderLineSep + header.second);}_resp_line = _verion + LineSep + std::to_string(_status_code) + LineSep + _status_desc + Sep;// 序列化*resp_str = _resp_line;for (auto &line : _resp_header){*resp_str += (line + Sep);}*resp_str += _blank_line;*resp_str += _body;}private:std::string Code2Desc(int code){switch (code){case 200:return "OK";case 404:return "Not Found";case 301:return "Moved Permanently";case 302:return "Found";default:return std::string();}}std::string Suffix2Desc(const std::string &suffix){if (suffix == ".html")return "text/html";else if (suffix == ".jpg")return "application/x-jpg";elsereturn "text/html";}private:// 必备的要素std::string _verion;int _status_code;std::string _status_desc;std::string _content;std::unordered_map<std::string, std::string> _header_kv;// 最终要这4部分,构建应答std::string _resp_line;std::vector<std::string> _resp_header;std::string _blank_line;std::string _body;
};
HttpServer.hpp
http服务器除了能返回网页外还应该能够提供搜索、登录等一系列服务;所以我们可以设置回调方法,方便我们注册一些函数,这里只设置搜索服务:
#pragma once
#include <iostream>
#include <string>
#include <functional>
#include <unordered_map>
#include "TcpServer.hpp"
#include "HttpProtocol.hpp"using namespace TcpServerModule;using http_handler_t = std::function<void(HttpRequest &, HttpResponse &)>;class HttpServer
{
public:HttpServer(int port): _tsvr(std::make_unique<TcpServer>(port)){}void Resgiter(std::string funcname, http_handler_t func){_route[funcname] = func;}bool SafeCheck(const std::string &service){auto iter = _route.find(service);return iter != _route.end();}void Start(){_tsvr->InitServer([this](SockPtr sockfd, InetAddr client){ return this->HandlerHttpRequest(sockfd, client); });_tsvr->Loop();}// 就是我们处理http的入口bool HandlerHttpRequest(SockPtr sockfd, InetAddr client){LOG(LogLevel::DEBUG) << "HttpServer: get a new client: " << sockfd->Fd() << " addr info: " << client.Addr();std::string http_request;sockfd->Recv(&http_request); // 在HTTP这里,我们不做报文完整性的处理HttpRequest req;req.Deserialize(http_request);HttpResponse resp;// 请求应该被分成两类: 1. 请求一般的静态资源 2. 提交参数,携带参数,需要我们进行交互设置if (req.IsHasArgs()){std::cout << "-----IsHasArgs()" << std::endl;// 2. 提交参数,携带参数,需要我们进行交互设置std::string service = req.Path();if (SafeCheck(service))_route[req.Path()](req, resp); // /loginelseresp.Build(req); // debug}else{std::cout << "-----Non IsHasArgs()" << std::endl;resp.Build(req);}std::string resp_str;resp.Serialize(&resp_str);sockfd->Send(resp_str);return true;}~HttpServer() {}private:std::unique_ptr<TcpServer> _tsvr;std::unordered_map<std::string, http_handler_t> _route; // 功能路由
};
HttpServer.cc
这个文件就可以帮助我们组合搜索模块与Http服务模块完成网络服务了:
#include "Searcher.hpp"
#include "HttpServer.hpp"
#include "HttpProtocol.hpp"
const std::string input = "data/parsedata/parse.txt";
const std::string root_path = "./wwwroot";#include "HttpServer.hpp"
#include "Session.hpp"using namespace LogModule;
SearchModule::Searcher search;//搜索服务
void Searcher(HttpRequest &req, HttpResponse &resp)
{// req.Path(): /s// 根据req,动态构建username=lisi&password=abcdefgLOG(LogLevel::DEBUG) << "进入搜索模块" << req.Path() << ", " << req.Args();std::string req_args = req.Args();//word=split// 1. 解析参数格式,得到要的参数auto pos = req_args.find('=');if(pos == std::string::npos){resp.SetBody("请输入要查询的关键词...");resp.SetCode(404);resp.SetHeader("Content-Type", "text/plain");return;}std::string keyword = req_args.substr(pos+1);LOG(LogLevel::DEBUG)<<"用户在搜索: "<<keyword;//2. 通过关键词搜索返回给用户std::string json_string;search.Search(keyword, &json_string);resp.SetBody(json_string);resp.SetCode(200);resp.SetHeader("Content-Length", std::to_string(json_string.size()));resp.SetHeader("Content-Type", "application/json");}int main()
{search.InitSearcher(input);auto httpserver = std::make_unique<HttpServer>(8888);// 要让服务器具有登录功能httpserver->Resgiter("/s", Searcher); // restful风格的网络请求接口httpserver->Start();return 0;
}
5. 运行结果
输入关键词split,返回相关文档标题、摘要和url:
6. 去除重复文档
当我们在搜索时,不同的词可能对应一个文档,这样就有多篇相同的文档被索引,是不必要的,所以我们需要在搜索时去重;
可以使用unordered_map
根据文档id
进行去重,但是要注意文档的权值应该累计相加,同时不同的词也要记录下来,所以仅仅是倒排索引元素中包含的文档id、关键词以及权值weight已经不能很好的满足我们了;
我们可以创建一个测试的html文档来验证是否去重:
去重前:
我们需要重新定义一个结构来保证需要完成的功能:
typedef struct InvertedElemR{std::vector<std::string> words; // 倒排索引单词数组uint64_t doc_id; // 单词对应文档idint weight; // 单词所占权重,用来排序@@} InvertedElemR;
代码如下:
//搜索函数//query: 搜索关键字//json_string: 返回给用户浏览器的搜索结果——使用json序列化void Search(const std::string &query, std::string *json_string){//1.将query分词,逐一搜索std::vector<std::string> words;UtilModule::JiebaUtil::CutString(&words,query);//2.分好词之后,逐一进行倒排索引——根据单词找文档std::vector<InvertedElemR> Inverted_List;//用来排序std::unordered_map<uint64_t,InvertedElemR> OnlyInvertedList;//去重for(auto& word : words){boost::to_lower(word);//记得切换小写std::vector<IndexModule::InvertedElem>* Inverted_Elems = index->GetInvertedIndex(word);if( nullptr == Inverted_Elems )continue;//找到倒排索引元素后进行去重for(const auto& elem : *Inverted_Elems){auto& elemr= OnlyInvertedList[elem.doc_id];elemr.doc_id = elem.doc_id;elemr.weight+=elem.weight;//权重累加elemr.words.push_back(elem.word);//同一个文档的单词入vector}//找到倒排索引元素后存放在Inverted_List中//Inverted_List.insert(Inverted_List.end(),Inverted_Elems->begin(),Inverted_Elems->end()); //注意可能有重复文档出现 }for(const auto &item : OnlyInvertedList){Inverted_List.push_back(std::move(item.second));}//3. 将倒排索引元素按照词的weight——相关性进行排序std::sort(Inverted_List.begin(),Inverted_List.end(),[](const InvertedElemR& elem1,const InvertedElemR& elem2){return elem1.weight > elem2.weight;});//4. 然后根据倒排元素id通过正排索引找到文档并保存Forward_List中//5. 将查找到的文档合并为json串返回//std::vector<IndexModule::ForwardElem> Forward_List;Json::Value root;for(auto& inverted_elem : Inverted_List){IndexModule::ForwardElem forward_elem = *index->GetForwardIndex(inverted_elem.doc_id);Json::Value value;value["title"] = forward_elem.title;//value["content"] = forward_elem.content;//应该是摘要value["content"] = GetAbstract(forward_elem.content,inverted_elem.words[0]);//按照第一个单词查找value["url"] = forward_elem.url;// //debug// value["id"] = (int)inverted_elem.second.doc_id;// value["weight"] = inverted_elem.second.weight;root.append(value);}Json::StyledWriter writer;//Json::FastWriter writer;*json_string = writer.write(root);
}
去重后:
7. 不足与改进点
-
暂停词的去除
-
MySQL数据库引入登录
-
建⽴整站搜索
-
设计⼀个在线更新的方案,信号,爬虫,完成整个服务器的设计
-
在我们的搜索引擎中,添加竞价排名
-
热次统计,智能显示搜索关键词(字典树,优先级队列)
易错点:
清洗数据时,结果有很多>,在使用状态机时要注意遇到>时不要获取: