✨✨欢迎来到T_X_Parallel的博客!!
🛰️博客主页:T_X_Parallel
🛰️项目代码仓库:站内搜索引擎项目代码仓库
🛰️专栏 : 站内搜索引擎项目
🛰️欢迎关注:👍点赞🙌收藏✍️留言
文章目录
项目环境:vscode、wsl(Ubuntu22.04)、g++/CMake
技术栈:C/C++ C++11、STL、标准库 Boost、Jsoncpp、cppjieba、cpp-httplib、html
1. index索引模块代码增添
一个boost
搜索引擎程序只需要一个index
索引实例即实现类的单例模式,所以我们为了不让这个类被拷贝和赋值,需要先将拷贝构造函数和赋值运算符重载函数改为delete
,同时构造函数和前面这两个函数都设为私有,确保只能通过类内静态成员函数创建实例。
所以在Index
类中增添一个私有Index
指针静态变量instance
,为了外部能够创建和获取实例,建立一个静态成员函数GetInstance()
,在这里面检查instance
是否为空,如果为空那么需要new Index()
来创建一个实例
由于本项目是搜索模块,那么后面使用肯定要使用多线程来处理多条请求,所以这个地方需要加锁(直接使用C++原生提供的锁)来确保
Index
类的单例实例创建是线程安全的,并且需要双重检查锁定模式(可以提高性能并确保线程安全)
- 第一次检查
instance
是否为空,是为了避免不必要的加锁操作,提高性能
- 第一次检查为空,那么直接加锁,确保只用一个线程可以进入接下来的代码
- 第一次检查不为空,直接返回它
- 第二次检查
instance
是否为空,此检查为加锁之后,这里再次检查是因为在第一次检查和加锁之间可能有其他线程创建了instance
- 第二次检查为空,创建新的实例,然后解锁
- 第二次检查不为空,直接解锁
namespace ns_index
{
class Index
{
private:
Index() {}
Index(const Index &) = delete;
Index &operator=(const Index &) = delete;
static Index *instance;
static std::mutex mtx;
public:
static Index *GetInstance()
{
if (instance == nullptr)
{
mtx.lock();
if (instance == nullptr)
{
instance = new Index();
}
mtx.unlock();
}
return instance;
}
};
Index *Index::instance = nullptr;
std::mutex Index::mtx;
}
2. 搜索模块基本结构
该模块主要要实现的功能就是创建Index
对象并建立索引和使用用户输入的关键词去获取相关索引并以特定格式返回。上一篇博客也提到了上一个模块的测试要等实现完这个模块然后一起测试,原因就是,这个模块大部分都是调用索引模块中的函数,所以基本结构不过多阐述,大致结构如下
namespace ns_searcher
{
class Searcher
{
private:
ns_index::Index *index;
public:
Searcher() {} // 构造函数
~Searcher() {}// 析构函数
public:
// 创建Index对象以及建立索引
void InitSearcher(const std::string &file_path)// 数据清洗处理完的数据位置
{}
// 搜索关键字并返回结果
// query:用户搜索的关键词 json_strin:返回给用户浏览器的搜索结果
void Search(const std::string &query, std::string *json_string)
{}
};
}
3. 模块具体实现
获取Index对象以及创建索引功能函数实现
上面的基本结构代码中也展示了,类中添加了一个Index
指针index
,而InitSearcher()
函数中要实现创建Index
对象必然就是调用上面所实现的GetInstance()
函数来获取一个Index
实例,而实现构建索引则是调用Index
类中的BuildIndex()
因为这个boost库的文档比较多,导致构建索引时间需要一点时间所以我们可以在
BuildIndex()
函数中和该函数中添加一些提示信息,以便运行者知道自己此刻的情况在建立索引的时候统计构建索引完成的文档数量,每构建完100(自己定义,想隔多少都行)个打印一次,构建完再打印一次。在
InitSearcher()
函数中也可以添加一些打印信息,比如获取实例成功后和构建索引成功后。
// index.hpp BuildIndex()
// 建立索引
bool BuildIndex(const std::string file_path) // 传parser处理后的文件的地址
{
std::ifstream in(file_path, std::ios::in | std::ios::binary);
if (!in.is_open()) // 检查文件是否打开
{
std::cout << "error! " << file_path << " open fail" << std::endl;
return false;
}
std::string line;
int count = 0;
while (std::getline(in, line))
{
DocInfo *doc = BuildForwardIndex(line);
if (doc == nullptr)
{
std::cout << "Error! Forward build" << line << "fail" << std::endl;
continue;
}
BuildIndexInvertedIndex(*doc);
count = doc->doc_id + 1;
if (count % 100 == 0)
std::cout << "已建立完索引文档数为:" << count << std::endl;
}
std::cout << "已建立完索引文档数为:" << count << std::endl;
return true;
}
// searcher.hpp InitSearcher()
// 创建Index对象以及建立索引
void InitSearcher(const std::string &file_path)
{
// 创建Index对象
index = ns_index::Index::GetInstance();
std::cout << "获取Index单例成功" << std::endl;
// 建立索引
index->BuildIndex(file_path);
std::cout << "构建索引成功" << std::endl;
}
测试后的效果为(为了节省空间和展示美观,改为每个500打印一次):
搜索功能代码实现
该部分主要就分为以下四个步骤
- 将用户输入的关键词进行分词
- 使用分完词的结果获取索引,并汇总结果
- 将汇总好的结果根据权重进行排序
- 将排序后的结果构建成json串(使用jsoncpp库)
第一步将用户输入的关键词query
分词,直接使用前面在util.hpp中实现的CutString()函数进行分词即可,结果存放在vector
数组words
中
// 关键词分词
std::vector<std::string> words;
ns_util::JiebaUtil::CutString(query, &words);
第二步根据words
中的分词结果获得索引并汇总结果,肯定就是遍历一遍words
取出每个关键词,然后通过index->GetInvertedIndex()
函数来获得倒排索引即相关文档ID,因为第三步要根据权重排序,所以在汇总的时候不能只存文档ID,而是将获取到的InvertedElemList
类型(如果不记得该类型是什么,请回顾Boost搜索引擎——02_正排与倒排索引构建-优快云博客)的变量存储起来,这个类型是一个存储InvertedElem结构体的数组,所以我们也可以使用InvertedElemList
类型来汇总结果
提醒:我们在构建倒排索引的时候忽略了字母的大小写,所以在这里我们也同样应该忽略用户输入进来的关键词的大小写
注:这里的汇总存在一定的问题,可能存在用户提供的某个关键词中的多个词对应同一个文档ID,那么在结果汇总的时候会存在两个或多个相同的文档ID,那么导致返回回去的结果也会有重复的文档。这个问题后面会与其他问题解决优化,自己可以想想应该如何实现(提示:哈希表)同样下面的排序也会涉及到优化
// 获得索引并汇总结果
ns_index::InvertedElemList inverted_list_all;
for (std::string word : words)
{
boost::to_lower(word);
ns_index::InvertedElemList *inverted_list = index->GetInvertedIndex(word);
inverted_list_all.insert(inverted_list_all.end(), inverted_list->begin(), inverted_list->end());
}
第三步对汇总的结果根据权重进行排序,直接使用sort()
函数来排序,只需写一个排序函数即可,为了方便,可以使用lambda表达式
// 根据权重排序
sort(inverted_list_all.begin(), inverted_list_all.end(),
[](const ns_index::InvertedElem &e1, const ns_index::InvertedElem &e2){
return e1.weight > e2.weight;
});
第四步将汇总结果构建成json串,可以使用jsoncpp
库来构建,使用这个库可以轻松的对字符串进行序列化和反序列化。
下面先来安装jsoncpp库
- Ubuntu
- 更新包列表:
sudo apt update
- 安装
jsoncpp
库:sudo apt install libjsoncpp-dev
- CentOS
- 更新包列表:
sudo yum update
- 安装EPEL仓库(包含许多额外的软甲包):
sudo yum install epel-release
- 安装
jsoncpp
库:sudo yum install jsoncpp jsoncpp-devel
jsoncpp
库的使用方法:
先创建
JSON
对象:使用Json::Value
类来创建JSON
对象,JSON
对象存储的是一组一组的键值对,也可以存储JSON
对象,因为我们有多个结果,所以可以使用一个个JSON
对象存储每一个结果,再存储到最开始创建的JSON
对象root
然后生成JSON字符串:使用
Json::StyledWriter
或Json::FastWriter
类将Json::Value
对象转换为 JSON 字符串。使用Json::StyledWriter类的效果为具有格式的Json串
使用Json::FastWriter类的效果就是放在一排的Json串(大数据量的网络通信一般使用的是这个类来生成JSON字符串,效率比上面的高)
下面是测试对比效果
#include <iostream> #include <string> #include <jsoncpp/json/json.h> int main() { Json::Value root; Json::Value j1; j1["title"] = "apply"; j1["content"] = "apply phone"; Json::Value j2; j2["title"] = "xiaomi"; j2["title"] = "xiaomisu7"; root.append(j1); root.append(j2); Json::StyledWriter styledwriter; std::string styledjson = styledwriter.write(root); std::cout << "StyledWriter: " << styledjson << std::endl; Json::FastWriter fastwriter; std::string fastjson = fastwriter.write(root); std::cout << "FastWriter: " << fastjson << std::endl; return 0; }
结果图:
注:下面代码中使用的GetSummary()函数能够获取文档内容中摘要,因为在返回到用户浏览器上并展示时,展示的是文档内容的摘要,而不是文档全部内容,所以下一步实现的是从内容中获取摘要功能
// 将结果构建成json串
Json::Value root;
for (const auto &e : inverted_list_all)
{
ns_index::DocInfo *doc = index->GetForwardIndex(e.doc_id);
if (doc == nullptr)
continue;
Json::Value elem;
elem["title"] = doc->title;
// elem["content"] = doc->content;
elem["summary"] = GetSummary(doc->content, e.word);// 获取摘要
elem["url"] = doc->url;
// debug
elem["doc_id"] = doc->doc_id;
elem["weight"] = e.weight;
root.append(elem);
}
// Json::FastWriter writer;
Json::StyledWriter writer;
*json_string = writer.write(root);
获取摘要功能实现
首先要搞清楚摘要需要摘出那些内容,可以借鉴一下百度等搜索引擎的搜索结果来决定
根据上面的搜索结果可以显然看出,摘要一般都会包含用户搜索的关键词,所以我们需要找到文档中第一个关键词的位置,往前和往后一定数量的字符,这就可以作为内容的摘要
第一肯定想到的是使用
string
类中提供的find()
函数,但是在之前的索引建立还有刚才实现的获得索引部分的代码时都是忽略了字母大小写,如果使用的是find()
函数来做的话无法忽略大小写问题,那么在这里可以使用C++标准库中提供的search()
函数来找到第一个关键词所在位置
该函数的参数中的
pred
是可以自己定义匹配函数,和上面一样为了方便,直接使用lambda表达式来代替,在lambda语句中就可以将两个比较值转成小写这里转成小写不能使用boost::to_lower()函数了,这里需要比较,而这个函数为void函数无返回值,所以无法比较。这里可以使用std::tolower()函数,这个函数是有返回值的,可以进行比较
最后返回的是一个迭代器,那么可以使用C++标准库中的
distance()
函数计算这个迭代器的位置pos
找到第一个关键词所在的位置后,假设摘要需要截取从关键词开始往前50个字符,往后100个字符,但是这里需要检查一下,往前是否有50个字符,有的话直接截取,没有这么多字符的话,从字符串的第一个字符开始,往后相同的道理。最后检查一下摘要开始指针是否大于结尾指针即可
// 根据内容获得内容摘要
std::string GetSummary(const std::string &content, const std::string &keyword)
{
const int prev = 50;
const int next = 100;
auto iter = std::search(content.begin(), content.end(),
keyword.begin(), keyword.end(),
[](const int x, const int y){
return (std::tolower(x) == std::tolower(y));
// boost::to_lower()没有返回值无法比较
});
if (iter == content.end())
return "None1";
const int pos = std::distance(content.begin(), iter);
int begin = 0, end = content.size() - 1;
if (pos - prev > begin)
begin = pos - prev;
if (pos + next < end)
end = pos + next;
if (begin > end)
return "None2";
std::string summary = content.substr(begin, end - begin);
summary += "……";
return summary;
}
4. 搜索模块代码
#pragma once
#include "index.hpp"
#include "util.hpp"
#include <jsoncpp/json/json.h>
namespace ns_searcher
{
class Searcher
{
private:
ns_index::Index *index;
public:
Searcher() {}
~Searcher() {}
public:
// 创建Index对象以及建立索引
void InitSearcher(const std::string &file_path)
{
// 创建Index对象
index = ns_index::Index::GetInstance();
std::cout << "获取Index单例成功" << std::endl;
// 建立索引
index->BuildIndex(file_path);
std::cout << "构建索引成功" << std::endl;
}
// 搜索关键字并返回结果
// query:用户搜索的关键词 json_strin:返回给用户浏览器的搜索结果
void Search(const std::string &query, std::string *json_string)
{
// 关键词分词
std::vector<std::string> words;
ns_util::JiebaUtil::CutString(query, &words);
// 获得索引并汇总结果
ns_index::InvertedElemList inverted_list_all;
for (std::string word : words)
{
boost::to_lower(word);
ns_index::InvertedElemList *inverted_list = index->GetInvertedIndex(word);
inverted_list_all.insert(inverted_list_all.end(),
inverted_list->begin(), inverted_list->end());
}
// 根据权重排序
sort(inverted_list_all.begin(), inverted_list_all.end(),
[](const ns_index::InvertedElem &e1, const ns_index::InvertedElem &e2){ return e1.weight > e2.weight;
});
// 将结果构建成json串
Json::Value root;
for (const auto &e : inverted_list_all)
{
ns_index::DocInfo *doc = index->GetForwardIndex(e.doc_id);
if (doc == nullptr)
continue;
Json::Value elem;
elem["title"] = doc->title;
// elem["content"] = doc->content;
elem["summary"] = GetSummary(doc->content, e.word);
elem["url"] = doc->url;
// debug
elem["doc_id"] = doc->doc_id;
elem["weight"] = e.weight;
root.append(elem);
}
// Json::FastWriter writer;
Json::StyledWriter writer;
*json_string = writer.write(root);
}
private:
// 根据内容获得内容摘要
std::string GetSummary(const std::string &content, const std::string &keyword)
{
const int prev = 50;
const int next = 100;
auto iter = std::search(content.begin(), content.end(),
keyword.begin(), keyword.end(),
[](const int x, const int y){
return (std::tolower(x) == std::tolower(y));
// boost::to_lower()没有返回值无法比较
});
if (iter == content.end())
return "None1";
const int pos = std::distance(content.begin(), iter);
int begin = 0, end = content.size() - 1;
if (pos - prev > begin)
begin = pos - prev;
if (pos + next < end)
end = pos + next;
if (begin > end)
return "None2";
std::string summary = content.substr(begin, end - begin);
summary += "……";
return summary;
}
};
}
5. 索引模块修正后的部分代码
#pragma once
#include <iostream>
#include <vector>
#include <string>
#include <unordered_map>
#include <fstream>
#include <mutex>
#include "util.hpp"
namespace ns_index
{
class Index
{
private:
Index() {}
Index(const Index &) = delete;
Index &operator=(const Index &) = delete;
static Index *instance;
static std::mutex mtx;
public:
~Index() {}
public:
static Index *GetInstance()
{
if (instance == nullptr)
{
mtx.lock();
if (instance == nullptr)
{
instance = new Index();
}
mtx.unlock();
}
return instance;
}
// 建立索引
bool BuildIndex(const std::string file_path) // 传parser处理后的文件的地址
{
std::ifstream in(file_path, std::ios::in | std::ios::binary);
if (!in.is_open()) // 检查文件是否打开
{
std::cout << "error! " << file_path << " open fail" << std::endl;
return false;
}
std::string line;
int count = 0;
while (std::getline(in, line))
{
DocInfo *doc = BuildForwardIndex(line);
if (doc == nullptr)
{
std::cout << "Error! Forward build" << line << "fail" << std::endl;
continue;
}
BuildIndexInvertedIndex(*doc);
count = doc->doc_id + 1;
if (count % 500 == 0)
std::cout << "已建立完索引文档数为:" << count << std::endl;
}
std::cout << "已建立完索引文档数为:" << count << std::endl;
return true;
}
};
Index *Index::instance = nullptr;
std::mutex Index::mtx;
}
6. 测试
之前提到过,上一个模块实现完并没有进行测试,原因就是可以等搜索模块实现完再统一测试,毕竟搜索模块基本上都要调用索引模块中的代码
那为什么不等整个项目实现完再测试呢?
因为后面就要加入网络模块,应该先保证除网络模块之外的模块的完善度,提前做测试,以命令行的形式进行测试比加入网络后进行测试方便一点
首先编写一个测试程序,程序测试搜索模块中函数的正常运行即可,因为整个搜索模块调用了所有索引模块的公开函数。
这里进行命令行测试那么就可以使用while
循环加cin
来模拟用户输入关键字的过程,但是这里存在一个问题,如果多个关键词中带空格,那么cin
会依次读取每个关键词,不包括空格,即输入:aaa bbb,会依次读aaa和bbb,读取两次。
则这里可以使用C语言里的fgets()
函数进行读取,该函数可以读取标准输入流中的一行字符串,不会因空格而停止读取
// debug.cc
#include "searcher.hpp"
#include <cstdio>
#include <cstring>
const std::string output_path = "../data/raw_html/raw.txt";
int main()
{
ns_searcher::Searcher *searcher = new ns_searcher::Searcher;
searcher->InitSearcher(output_path);
char buffer[1024];
while (1)
{
std::cout << "Please enter keywords# ";
fgets(buffer, sizeof(buffer) - 1, stdin);
buffer[strlen(buffer) - 1] = 0;
std::string query = buffer;
std::string json_string;
searcher->Search(buffer, &json_string);
std::cout << json_string << std::endl;
}
return 0;
}
然后就是代码编译,下面提供三种编译过程,直接使用命令编译、使用Makefile、使用CMake,三种方法都可以,方便的就是使用CMake
博主在这里补充一下数据清洗模块的测试过程,并简单解释前后boost库的链接问题
可以直接使用命令进行编译,但是parser.cc中使用到了
Boost.Filesystem库
,这个库依赖于Boost.System库,并且它们都需要链接到相应的二进制库。因此,在使用Boost.Filesystem库时,需要在编译时显式地链接这些库g++ -o parser.exe parser.cc -lboost_system -lboost_filesystem -std=c++11
而后面这两个模块只使用了boost.algorithm库,该库时头文件库,所以不需要链接
下面是三种方法,其中makefile和CMake中有parser模块的编译指令,后续会逐一完善
- 使用命令编译,这里使用了jsoncpp外部库,所以编译的时候要链接
g++ debug.cc -o debug.exe -ljsoncpp -std=c++11 # 如果提示警告,可能代码中使用了C++17标准,更改一下标准即可
- 使用makefile,创建一个makefile文件,填入下面的代码,命令行键入make即可编译(makefile具体使用方法请自行询问AI或者查看相关博客,博主还没有出过相关博客,敬请期待)
PARSER=parser DUG=debug cc=g++ .PHONY:all all:$(PARSER) $(DUG) $(PARSER):parser.cc $(cc) -o $@ $^ -lboost_system -lboost_filesystem -std=c++11 $(DUG):debug.cc $(cc) -o $@ $^ -ljsoncpp -std=c++11 .PHONY:clean clean: rm -f $(PARSER) $(DUG)
- 使用CMake工具(自动化创建makefile文件),创建文件
CMakeList.txt
,输入下面的代码,并在工作目录中创建build
文件夹,在build
文件夹中命令行键入cmake ..
就会构建一个makefile文件,然后的操作就和上面makefile一样(CMake工具具体使用方法请自行询问AI或者查看相关博客,博主还没有出过相关博客,敬请期待)cmake_minimum_required(VERSION 3.10) project(Boost_Search_Engine) # # 设置C++标准为C++11 # set(CMAKE_CXX_STANDARD 11) # set(CMAKE_CXX_STANDARD_REQUIRED ON) # set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++11") add_compile_options(-std=c++11 -std=c++17) # # 设置Boost根目录 # set(BOOST_ROOT "/lib64") # 查找Boost库 find_package(Boost REQUIRED COMPONENTS filesystem system) # 查找cppjson库 find_package(jsoncpp REQUIRED) # 添加可执行目标 Parser add_executable(Parser ./code/parser.cc) # 链接Boost库到 Parser target_link_libraries(Parser ${Boost_FILESYSTEM_LIBRARY} ${Boost_SYSTEM_LIBRARY}) # 添加可执行目标 Debug add_executable(Debug ./code/debug.cc) # 链接Boost库和cppjson库到 Debug target_link_libraries(Debug jsoncpp_lib)
最后运行生成的可执行文件即可,博主最后的测试效果如下
下一个模块就是实现网络模块了,为了方便使用第三方库cpp-httplib库实现,期待一下吧
专栏:站内搜索引擎项目
项目代码仓库:站内搜索引擎项目代码仓库(随博客更新)
都看到这里了,留下你们的珍贵的👍点赞+⭐收藏+📋评论吧