【实战项目】——Boost搜索引擎(五万字)

提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档

目录

前言

一、项目的相关背景

1.1、什么是Boost库?

1.2、什么是搜索引擎?

1.3、为什么要做Boost库搜索引擎?

二、搜索引擎的宏观原理

三、搜索引擎技术栈和项目环境

[四、正排索引 VS 倒排索引 ------ 搜索引擎的具体原理](#四、正排索引 VS 倒排索引 —— 搜索引擎的具体原理)

[4.1、正派索引(forword index)](#4.1、正派索引(forword index))

[4.2、倒排索引(inverted index)](#4.2、倒排索引(inverted index))

[五、编写数据去除标签和数据清洗模块 Parser(解析器)](#五、编写数据去除标签和数据清洗模块 Parser(解析器))

5.1、数据准备

5.2、编写Parser模块

5.2.1、基本结构设计

5.2.2、实现细节

[六、编写建立索引的模块 Index](#六、编写建立索引的模块 Index)

6.1、节点设计

6.2、基本结构设计

6.2.1、Index类的基本框架

6.2.2、获取正排索引(GetForwardIndex)

6.2.3、获取倒排拉链(GetInvertedList)

6.2.4、构建索引(BuildIndex)

6.2.5、构建正排索引(BuildForwardIndex)

6.2.6、倒排索引的原理介绍 (重点!!)

6.2.7、cppjieba分词工具的安装和使用介绍

6.2.8、引入cppjieba到项目中

6.2.9、构建倒排索引(BuildInvertedIndex)

[七、编写搜索引擎模块 Searcher](#七、编写搜索引擎模块 Searcher)

7.1、基本结构

7.2、初始化服务(InitSearcher)

7.2.1、Index模块的单例设计

7.2.2、编写InitSearcher

7.3、提供服务(Search)

7.3.1、对用户关键字进行分词

[7.3.2、 触发分词,进行索引查找](#7.3.2、 触发分词,进行索引查找)

7.3.3、按文档权重进行降序排序

7.3.4、根据排序结果构建json串

八、编写http_server模块

8.1、引入cpp-httplib到项目中

8.2、cpp-httplib的使用介绍

8.3、正式编写http_server

九、添加日志到项目中

十、编写前端模块

[10.1、了解 vscode](#10.1、了解 vscode)

10.2、了解前端

10.3、HTML网页代码基本实现

十一、结项与项目扩展方向

总结



前言

  • 在我们的生活中有许多的搜索引擎,比如百度,搜狗,360搜索等。像百度这样的搜索引擎我们是实现不了的,因为像百度这样的搜索引擎搜索的是全网的数据。其数据量之庞大远远超出我们的想象。
  • 我们的项目是做一个小范围的搜索引擎,一个用Boost库实现的Boost站内搜索。
  • 站内搜索:搜索的数据更垂直,数据量更小。
  • Boost库的官网是没有站内搜索的,需要我们做一个。

提示:以下是本篇文章正文内容,下面案例可供参考

一、项目的相关背景

1.1、什么是Boost库?

Boost库:是C++的准标准库, 它提供了很多C++没有的功能,可以称之为是C++的后备力量。早期的开发者多为C++标准委员会的成员,一些Boost库也被纳入了C++11中(如:哈希、智能指针);这里大家可以去百度百科上搜索,一看便知。

下面是boost的官网:---------官网链接

1.2、什么是搜索引擎?

对于搜索引擎,相信大家一定不陌生,如:百度、360、搜狗等,都是我们常用的搜索引擎。但是你想自己实现出一个和百度、360、搜狗一模一样哪怕是类似的搜索引擎,是非常非常困难的。我们可以看一下这些搜索引擎在搜索关键字的时候,给我们展示了哪些信息:

我们可以看到,基本上搜索引擎根据我们所给的关键字,搜出来的结果展示都是以,网页标题、网页内容摘要和跳转的网址组成的。但是它可能还有相应的照片、视频、广告,这些我们在设计基于Boost库的搜索引擎项目的时候,不考虑这些,它们属于扩展内容。

1.3、为什么要做Boost库搜索引擎?

我们把boost的官网界面和cplusplus官网界面对比一下,看看有什么区别:

我们可以看到,Boost库是没有站内搜索框的,如果我们可以对boost库做一个站内搜索,向cplusplus一样,搜索一个关键字,就能够跳转到指定的网页,并显示出来。那么这个项目还是具有一定意义的。这也就是项目的背景。

其次,站内搜索的数据更加垂直,数据量其实更小。

二、搜索引擎的宏观原理

刚刚我们介绍完了基于Boost库的搜索引擎的项目背景后,相信大家有了一定的了解,大致上知道了这个项目是什么意思。但是我们还需要了解一下搜索引擎的宏观原理。接下来以下面的图为例,介绍一下其宏观原理。

原理图分析:

我们要实现出boost库的站内搜索引擎,红色虚线框内就是我们要实现的内容,总的分为客户端和服务器,详细分析如下:

  1. 客户端:想要获取到大学生的相关信息(呈现在网页上的样子就是:网页的标题+摘要+网址),首先我们构建的服务器就要有对应的数据存在,这些数据从何而来,我们可以进行全网的一个爬虫,将数据爬到我们的服务器的磁盘上,但是我们这个项目是不涉及任何爬虫程序的,我们可以直接将boost库对应版本的数据直接解压到我们对应文件里。
  2. 现在数据已经被我们放到了磁盘中了,接下来客户端要访问服务器,那么服务器首先要运行起来,服务器一旦运行起来,它首先要做的工作就是对磁盘中的这些数据进行去标签和数据清洗的动作,因为我们从boost库拿的数据其实就是对应文档html网页,但是我们需要的只是每个网页的标题+网页内容摘要+跳转的网址,所以才有了去标签和数据清洗(只拿我们想要的)。这样就可以直接跳过网址跳转到boost库相应文档的位置。
  3. 服务器完成了去标签和数据清洗之后,就需要对这些清洗后的数据建立索引(方便客户端快速查找);
  4. 当服务器所以的工作都完成之后,**客户端就发起http请求,通过GET方法,上传搜索关键,**服务器收到了会进行解析,通过客户端发来的关键字去检索已经构建好的索引,找到了相关的html后,就会将逐个的将每个网页的标题、摘要和网址拼接起来,构建出一个新的网页,响应给客户端;至此,客户就看到了相应的内容,点击网址就可以跳转到boost库相应的文档位置。

三、搜索引擎技术栈和项目环境

技术栈:

  • C/C++ C++11, STL, 准标准库 Boost , Jsoncpp , cppjieba , cpp-httplib

选学:

  • html5 , css , js 、 jQuery 、 Ajax

项目环境:

  • Centos 7 云服务器、vim/gcc(g++)/Makefile、vs2019 or vs code

相关库:

  • cppjieba: 提供分词的相关接口
  • boost: 提供在当前目录下遍历所有子目录文件的迭代器
  • Jsoncpp: 提供可以将格式化的数据和 json 字符串相互转换的接口
  • cpp-httplib: 提供http相关接口

四、正排索引 VS 倒排索引 ------ 搜索引擎的具体原理

  • 文档1:雷军买了四斤小米
  • 文档2:雷军发布了小米手机

4.1、正派索引(forword index)

正排索引:就是从【文档ID】找到【文档内容】(文档内的关键字)

|------|-----------|
| 文档ID | 文档内容 |
| 1 | 雷军买了四斤小米 |
| 2 | 雷军发布了小米手机 |

  • 正排索引:是创建倒排索引的基础,有了正排索引之后,如何构建倒排索引呢? -- 分词
  • 分词的目的:方便建立 倒排索引和查找

我们要对目标文档进行【分词】,以上面的文档1、2为例,我们来进行 分词 演示:

  • 文档1:雷军、买、四斤、小米、四斤小米
  • 文档2:雷军、发布、小米、汽车、小米汽车

我们可以看到,在文档1、2中,其中的 "了" 子被我们省去了,这是因为像:了,呢,吗 ,a,the 等都是属于停止词,一般我们在分词的时候可以不考虑。那么什么是停止词呢?

停止词: 它是搜索引擎分词的一项技术,停止词就是没有意义的词。如:在一篇文章中,你可以发现有很多类似于了,呢,吗 ,a,the等(中文或英文中)都是停止词,因为频繁出现,如果我们在进行分词操作的时候,如果把这些停止词也算上,不仅会建立索引麻烦,而且会增加精确搜索的难度。

4.2、倒排索引(inverted index)

倒排索引:根据文档内容分词,整理不重复的各个关键字,对应联系到文档ID的方案

|------------|-----------------|
| 关键字(具有唯一性) | 文档ID,weight(权重) |
| 雷军 | 文档1 , 文档2 |
| 买 | 文档1 |
| 四斤 | 文档1 |
| 小米 | 文档1, 文档2 |
| 四斤小米 | 文档1 |
| 发布 | 文档2 |
| 汽车 | 文档2 |
| 小米汽车 | 文档2 |

模拟一次查找的过程:

用户输入:小米 ---> 去倒排索引中查找关键字"小米" ---> 提取出文档ID【1,2】---> 去正排索引中,根据文档ID【1,2】找到文档内容 ---> 通过 [ 标题 + 内容摘要 + 网址 ] 的形式,构建响应结果 ---> 返回给用户小米相关的网页信息。

五、编写数据去除标签和数据清洗模块 Parser(解析器)

在编写Parser模块的之前,我们先将数据准备好,去boost官网下载最新版本的库,解压到 Linux下,操作方法如下:

5.1、数据准备

boost官网:https://www.boost.org/

下载好之后,我们先在 linux下,创建一个名为 Boost_Searcher目录,以后将会在这个目录下进行各种代码模块的编写以及存放各种数据,下面是创建过程:

接下来我们将下载好的boost_1_86_0版本的boost库解压到Linux下,使用 rz 命令(用于文件传输),输入rz -E 命令后直接回车,找到 boost,点击打开即可,你也可以直接将压缩包拖拽到命令行中;

效果如下:

此时,我们使用 tar xzf boost_1_85_0.tar.gz 进行解压,解压好后,我们进行查看

可以看到,解压好的boost,里面有这么多文件,但这么多文件并不是我们都需要的,我们需要的就是boost_1_85_0/doc/html目录下的html。为什么呢?结合下面的图:

上面的图就是boost库的操作方法,我们可以看到右下角的这个网页的网址,他们都是在doc/html目录下的文件,都是 .html。我们只要这个就可以了。后期通过地址进行拼接,达到跳转,就能来到这个网页。


我们进入到Linux下的 doc/html 目录,看看里面有哪些东西:

可以看到,里面除了 html为后缀的文件外,还有一些目录,但是我们只需要html文件,所以我们要进行数据清洗。只拿html文件。


我们了解了大概的情况之后,我们来将我们所需要的数据源 拷贝到 data目录下的intput目录下:

5.2、编写Parser模块

5.2.1、基本结构设计

这里我是在vim 下进行代码编写的,也可以选择 vscode,但是需要连接一下云服务器,与 Linux进行同步。

基本框架主要完成的工作如下:

  • data/input/ 所有后缀为 html 的文件筛选出来 ---- 清洗数据
  • 然后对筛选好的html 文件进行解析(去标签),拆分出标题、内容、网址 ---- 去标签
  • 最后将去标签后的所有html文件的标题、内容、网址按照 \3 作为分割符,每个文件再按照 \n 进行区分。写入到 data/raw_html/raw.txt 下

什么是数据清洗呢?

我们进入到Linux下的 doc/html 目录,看看里面有哪些东西:

可以看到,里面除了 html为后缀的文件外,还有一些目录,但是我们只需要html文件,所以我们要进行数据清洗。只拿html文件。
最后,我们在 data 目录下的 raw_html 目录下 创建有一个 raw.txt 文件,用来存储干净的数据文档


什么是去标签呢?

  • 对数据清洗之后,拿到的全都是 html 文件,此时还需要对 html 文件进行去标签处理,我们这里随便看一个html文件:
  • 进入 input 这个目录下,随便打开一个**.html** 的文件

**<> : html的标签,这个标签对我们进行搜索是没有价值的,**需要去掉这些标签,一般标签都是成对出现的!但是也有单独出现的,我们也是不需要的。

我们的目标: 把每个文档都去标签,然后写入到同一个文件中!

采用下面的方案:

  1. 写入文件中,一定要考虑下一次在读取的时候,也要方便操作!
  2. 类似:title\3content\3url \n title\3content\3url \n title\3content\3url \n ...
  3. 方便我们getline(ifsream, line),直接获取文档的全部内容:title\3content\3url

接下来 我们根据上面分析的 基本结构设计 来写出一个parser 模块的框架

  • Boost_Searcher 目录下创建 parser.cpp文件开始编写框架
  • parser.cpp 框架如下:
cpp 复制代码
#include <iostream>
#include <string>
#include <vector>
 
// 首先我们肯定会读取文件,所以先将文件的路径名 罗列出来
// 将 数据源的路径 和 清理后干净文档的路径 定义好
 
const std::string src_path = "data/input";          // 数据源的路径
const std::string output = "data/raw_html/raw.txt"; // 清理后干净文档的路径
 
//DocInfo --- 文件信息结构体
typedef struct DocInfo
{
    std::string title;   //文档的标题
    std::string content; //文档的内容
    std::string url;     //该文档在官网当中的url
}DocInfo_t;
 
// 命名规则
// const & ---> 输入
// * ---> 输出
// & ---> 输入输出
  
//把每个html文件名带路径,保存到files_list中
bool EnumFile(const std::string &src_path, std::vector<std::string> *files_list);
 
//按照files_list读取每个文件的内容,并进行解析
bool ParseHtml(const std::vector<std::string> &files_list, std::vector<DocInfo_t> *results);
 
//把解析完毕的各个文件的内容写入到output
bool SaveHtml(const std::vector<DocInfo_t> &results, const std::string &output);
 

int main()
{
    std::vector<std::string> files_list; // 将所有的 html文件名保存在 files_list 中
 
    // 第一步:递归式的把每个html文件名带路径,
    // 保存到files_list中,方便后期进行一个一个的文件读取
 
    // 从 src_path 这个路径中提取 html文件,将提取出来的文件存放在 string 类型的 files_list 中
    if(!EnumFile(src_path, &files_list)) //EnumFile--枚举文件
    {
        std::cerr << "enum file name error! " << std::endl;
        return 1;
    }
    return 0;
 
    // 第二步:从 files_list 文件中读取每个.html的内容,并进行解析
 
     std::vector<DocInfo_t> results;
     // 从 file_list 中进行解析,将解析出来的内容存放在 DocInfo 类型的 results 中
    if(!ParseHtml(files_list, &results))//ParseHtml--解析html
    {
        std::cerr << "parse html error! " << std::endl;
        return 2;
    }
 
 
    // 第三部:把解析完毕的各个文件的内容写入到output,按照 \3 作为每个文档的分隔符
 
    // 将 results 解析好的内容,直接放入 output 路径下
    if(!SaveHtml(results, output))//SaveHtml--保存html
    {
        std::cerr << "save html error! " << std::endl;
        return 3;
    }
 
    return 0;
}

5.2.2、实现细节

主要实现:枚举文件、解析html文件、保存html文件三个工作。

这三个工作完成是需要我们使用boost库当中的方法的,我们需要安装一下boost的开发库:

命令:apt install -y libboost-all-dev

  • 下图就是我们接下来编写代码需要用到的 boost库 当中的 filesystem方法

枚举文件

cpp 复制代码
//在原有的基础上添加这个头文件
#include <boost/filesystem.hpp>
 
//把每个html文件名带路径,保存到files_list中
bool EnumFile(const std::string &src_path, std::vector<std::string> *files_list)
{
    // 简化作用域的书写
    namespace fs = boost::filesystem;
    fs::path root_path(src_path); // 定义一个path对象,枚举文件就从这个路径下开始
    // 判断路径是否存在
    if(!fs::exists(root_path))
    {
        std::cerr << src_path << " not exists" << std::endl;
        return false;
    }
    // 对文件进行递归遍历
    fs::recursive_directory_iterator end; // 定义了一个空的迭代器,用来进行判断递归结束  -- 相当于 NULL
    for(fs::recursive_directory_iterator iter(root_path); iter != end; iter++)
    {
        // 判断指定路径是不是常规文件,如果指定路径是目录或图片直接跳过
        if(!fs::is_regular_file(*iter))
        {
            continue;
        }
 
        // 如果满足了是普通文件,还需满足是.html结尾的
        // 如果不满足也是需要跳过的
        // ---通过iter这个迭代器(理解为指针)的一个path方法(提取出这个路径)
        // ---然后通过extension()函数获取到路径的后缀
        if(iter->path().extension() != ".html")
        {
            continue;
        }
 
        //std::cout << "debug: " << iter->path().string() << std::endl; // 测试代码
      
        // 走到这里一定是一个合法的路径,以.html结尾的普通网页文件
        files_list->push_back(iter->path().string()); // 将所有带路径的html保存在files_list中,方便后续进行文本分析
    }
    return true;
}
  • 代码编写到这里我们就可以进行测试了,使用上述代码中注释掉的代码进行测试,首先编写Makefile:
cpp 复制代码
cpp=g++
parser:parser.cpp
	$(cpp) -o $@ $^ -lboost_system -lboost_filesystem -std=c++11
.PHONY:clean
clean:
	rm -f parser
  • **注意:**boost库 并不是C++语言的标准库,我们需要指明我们需要链接那个库
  • 链接: -lboost_system -lboost_filesystem

  • 经过EnumFile()函数的筛选后,我们 files_list 中存放的都是 html文件的路径名了,可以进行html 的文件解析了

解析html文件:

  1. 读取刚刚枚举好的文件
  2. 解析html文件中的title
  3. 解析html文件中的content
  4. 解析html文件中的路径,构建url

这里我们将这读取操作写到一个工具类中,包括后续有什么方法也可以写到这个里面,方便调用。创建一个util.hpp

  • **ParseHtml()**解析函数代码的整体框架如下:
  • 函数架构
cpp 复制代码
bool ParseHtml(const std::vector<std::string> &files_list, std::vector<DocInfo_t> *results)
{
    for(const std::string &file : files_list)
    {
        // 1.读取文件,Read()
        std::string result;
        if(!ns_util::FileUtil::ReadFile(file, &result))
        {
            continue;
        }
        // 2.解析指定的文件,提取title
        DocInfo_t doc;
        if(!ParseTitle(result, &doc.title))
        {
            continue;
        }
        // 3.解析指定的文件,提取content
        if(!ParseContent(result, &doc.content))
        {
            continue;
        }
        // 4.解析指定的文件路径,构建url
        if(!ParseUrl(file, &doc.url))
        {
            continue;        
        }
 
        // 到这里,一定是完成了解析任务,当前文档的相关结果都保存在了doc里面
        results->push_back(std::move(doc)); 
        // 本质会发生拷贝,效率肯能会比较低,
        // 这里我们使用move后的左值变成了右值,去调用push_back的右值引用版本
    }
    return true;                                                                                                                                                                                                                                                    
}

  • 读取文件
  • 遍历 files_list 中存储的文件名,从中读取文件内容到 result 中,由函数 ReadFile() 完成该功能。该函数定义于头文件 util.hpp 的类 FileUtil中。
cpp 复制代码
#pragma once
#include <iostream>
#include <string>
#include <fstream>
#include <vector>
 
namespace ns_util
{
    class FileUtil
    {                                                                                                                                                                                                                                                                                                                                                                        
    public:
        //输入文件名,将文件内容读取到out中
        static bool ReadFile(const std::string &file_path, std::string *out)
        {
            // 读取 file_path(一个.html文件) 中的内容  -- 打开文件
            std::ifstream in(file_path, std::ios::in);
            //文件打开失败检查
            if(!in.is_open())
            {
                std::cerr << "open file " << file_path << " error" << std::endl;
                return false;
            }
            //读取文件内容
            std::string line;
            //while(bool),getline的返回值istream会重载操作符bool,读到文件尾eofset被设置并返回false
            //如何理解getline读取到文件结束呢??getline的返回值是一个&,while(bool), 本质是因为重载了强制类型转化
            while(std::getline(in, line)) // 每循环一次,读取的是文件的一行内容
            {
                *out += line;    // 将文件内容保存在 *out 里面
            }
            in.close(); // 关掉文件
            return true;
        }
    };
}

  • 提取title ------ParseTitle()

随意打开一个html文件 ,可以看到我们要提取的title部分 是被title标签包围起来的部分。如下所示:

  • 上图显示标题的时候,是以 <title>标题</title> 构成的,我们只需要find(<title>)就能找到这个标签的左尖括号的位置,然后加上<title>的长度,此时就指向了标题的起始位置,同理,再去找到</title>的左尖括号,最后截取子串;
  • 这里需要依赖函数 ------ bool ParseTitle(const std::string& file,&doc.title), 来帮助完成这一工作,函数就定义在parse.cpp 中。
cpp 复制代码
//解析title
static bool ParseTitle(const std::string& file,std::string* title)
{
    // 查找 <title> 位置
    std::size_t begin = file.find("<title>");
    if(begin == std::string::npos)
    {
        return false;
    }
    // 查找 </title> 位置
    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;
}

  • 提取content,实际上是去除标签 ------ ParseContent()

随意打开一个html文件,即 把所有尖括号及尖括号包含的部分全部去除,只保留尖括号以外有价值的内容

解析内容的时候,我们采用一个简易的状态机来完成,状态机包括两种状态:LABLE(标签)和CONTENT(内容);

**html的代码中标签都是这样的<>;**起始肯定是标签,我们追个字符进行遍历判断,如果遇到">",表明下一个即将是内容了,我们将状态机置为CONTENT,接着将内容保存起来,如果此时遇到了"<",表明到了标签了,我们再将状态机置为LABLE;不断的循环,知道遍历结束;

这里需要依赖函数 ------bool ParseContent(const std::string& file,&doc.content), 来帮助完成这一工作,函数就定义在parse.cpp 中。

cpp 复制代码
//去标签 -- 数据清洗
static bool ParseContent(const std::string& file,std::string* content)
{
     //去标签,基于一个简易的状态机
    enum status // 枚举两种状态
    {                                                                                                                                                                                 
        LABLE,   // 标签
        CONTENT  // 内容
    };
    enum status s = LABLE;  // 刚开始肯定会碰到 "<" 默认状态为 LABLE
    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;
}

  • 解析 html 的 url

boost库 在网页上的 url,和我们 下载的文档的路径 是 有对应关系的

官网URL样例:

我们下载下来的url样例:


而我们项目中的所有数据源都拷贝到了 data/input 目录下,那么在我们项目中寻找该网页文件的路径为:

cpp 复制代码
data/input/accumulators.html

此时,我们想要从我们的项目中得到和官网一样的网址,我们可以这样做:

  1. 1拿官网的部分网址作为头部的 url
  1. 2.将我们项目的路径 data/input 删除后得到 /accumulators.html;
  • url_tail = [data/input(删除)] /accumulators.html -> url_tail = /accumulators.html;
  • url_head + url_tail 得到 官网的 url

url = url_head + url_tail //相当于形成了一个官网链接

  • 这里需要依赖函数 ------ bool ParseUrl(const std::string& file_path,std:string* url) ,来帮助完成这一工作,函数就定义在 parser.cpp 中。
cpp 复制代码
//构建官网url :url_head + url_tail
static bool ParseUrl(const std::string& file_path,std::string* url)
{
    std::string url_head = "https://www.boost.org/doc/libs/1_86_0/doc/html";    
    std::string url_tail = file_path.substr(src_path.size());//将data/input截取掉    
    *url = url_head + url_tail;//拼接
    return true;
}

我们之前已经定义好了两个路径嘛!源数据路径 和 清理后干净文档的路径;url_head这个比较简单,直接复制官网的。url_tail,我们可以将传过来的文件路径使用一个substr把data/input截取掉,保留剩下的,然后和url_head拼接起来。


  • 向源代码中加入了 ShowDoc 测试代码,测试完注释掉即可
cpp 复制代码
// for debug
void ShowDoc(const DocInfo_t& doc)
{
    std::cout<<"title: "<<doc.title<<std::endl;
    std::cout<<"content: "<<doc.content<<std::endl;
    std::cout<<"url: "<<doc.url<<std::endl;
}
 
//按照files_list读取每个文件的内容,并进行解析
bool ParseHtml(const std::vector<std::string> &files_list, std::vector<DocInfo_t> *results)
{
    // 首先在解析文件之前,肯定需要 遍历 读取文件
    for(const std::string &file : files_list)
    {
        // 1.读取文件,Read() --- 将文件的全部内容全部读出,放到 result 中
        std::string result;
        if(!ns_util::FileUtil::ReadFile(file, &result))
        {
            continue;
        }
        // 2.解析指定的文件,提取title
        DocInfo_t doc;
        if(!ParseTitle(result, &doc.title))
        {
            continue;
        }
        // 3.解析指定的文件,提取content
        if(!ParseContent(result, &doc.content))
        {
            continue;
        }
        // 4.解析指定的文件路径,构建url
        if(!ParseUrl(file, &doc.url))
        {
            continue;        
        }
 
        // 到这里,一定是完成了解析任务,当前文档的相关结果都保存在了doc里面
 
        results->push_back(std::move(doc)); 
        // 本质会发生拷贝,效率肯能会比较低,
        // 这里我们使用move后的左值变成了右值,去调用push_back的右值引用版本
 
        // for debug  -- 在测试的时候,将上面的代码改写为 results->push_back(doc);
        ShowDoc(doc);
        break;  // 只截取一个文件打印
    }
        return true;
}
  • 保存html文件

采用下面的方案:

  • 写入文件中,一定要考虑下一次在读取的时候,也要方便操作!
  • 类似:title\3content\3url \n title\3content\3url \n title\3content\3url \n ...
  • 方便我们getline(ifsream, line),直接获取文档的全部内容:title\3content\3url
    保存html文件 ,说明一下,分隔符为什么使用'\3' :

\3在ASSCII码表中是不可以显示的字符,我们将title、content、url用\3进行区分,不会污染我们的文档,当然你也可以使用\4等

cpp 复制代码
bool SaveHtml(const std::vector<DocInfo_t> &results, const std::string &output)
{
    #define SEP '\3'//分割符---区分标题、内容和网址
    
    // 打开文件,在里面进行写入
    // 按照二进制的方式进行写入 -- 你写的是什么文档就保存什么
    // std::ios::out 是一个标志,表示文件流的输出模式
    // 当你创建一个 ofstream 对象时,指定 std::ios::out 表示你要向文件中写入数据
    // ios是C++ //O流的根基,out和binary都是ios里面的内容而不是ostream里面的内容,
    // 所以前面不能用ostream的类域.
    // out和binary是定义在ios类域的命名空间或者类里面的 所以在文档中没有看到
    std::ofstream out(output, std::ios::out | std::ios::binary);
    if(!out.is_open())
    {
        std::cerr << "open " << output << " failed!" << std::endl;
        return false;
    }
 
    // 到这里就可以进行文件内容的写入了
    for(auto &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;
}

接下来我们做一下测试:

我们编译下 parser.cpp,得到./parser可执行文件。如果成功,那么此时 /data/raw_html目录下的 raw.txt 就会填入所有的处理完的 html文档。

  • 用 vim 打开 raw.txt 进行观察,每个html文档占据一行,显然行数与处理之前的html文件数是匹配的。
  • 至此,我们的parser(去标签+数据清)模块就完成了,为了大家能够更好的理解,下面是一张关系图:

六、编写建立索引的模块 Index

首先创建 一个 Index.hpp 文件,用来编写 索引模块
该文件主要负责三件事:

  1. 构建索引
  2. 正排索引
  3. 倒排索引

构建思路框图:

搜索引擎逻辑:
用户输入**【关键字】** 搜索 【倒排索引】 搜出 【倒排拉链】 倒排拉链中包含所有关键字有关的文档ID及其权重 → 【正派索引】 文档ID,得到文档三元素 , 并按照权重排列呈现给用户

6.1、节点设计

在【构建索引】模块时,我们要构建出 正排索引 和 倒排索引,正排索引是构建倒排索引的基础;通过给到的关键字,去倒排索引里查找出文档ID,再根据文档ID,找到对应的文档内容;所以在这个index模块中,就一定要包含两个节点结构,一个是文档信息的节点,一个是倒排对应的节点

cpp 复制代码
namespace ns_index
{
    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;     //关键字(通过关键字可以找到对应的ID)
        int weight;           //权重---根据权重对文档进行排序展示
    };
}

6.2、基本结构设计

6.2.1、Index类的基本框架

我们创建一个 Index类:主要用来构建索引模块,索引模块最大的两个部分当然是构建正排索引和构建倒排索引,其主要接口如下:

cpp 复制代码
#pragma once
#include <iostream>
#include <string>
#include <vector>
#include <unordered_map>
 
namespace ns_index
{
    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;     //关键字(通过关键字可以找到对应的ID)
        int weight;           //权重---根据权重对文档进行排序展示
    };
 
    // 倒排拉链  -- 一个关键字 可能存在于多个文档中,所以一个关键字对应了一组文档
    typedef std::vector<InvertedElem> InvertedList;
 
    class Index
    {
    private:
        // 正排索引的数据结构采用数组,数组下标就是天然的文档ID
        // 每一个数组里面存放一个 文档信息
        std::vector<DocInfo> forward_index; //正排索引
 
        // 一个【关键字】可能出现在 无数个 【文档】中 ,我们需要根据权重判断 文档的重要顺序
        //倒排索引一定是一个关键字和一组(或者一个)InvertedElem对应[关键字和倒排拉链的映射关系]
        std::unordered_map<std::string, InvertedList> inverted_index;
    
    public:
        Index(){} 
        ~Index(){}
 
    public:
 
        //根据doc_id找到正排索引对应doc_id的文档内容
        DocInfo* GetForwardIndex(uint64_t doc_id)
        {
            //...
            return nullptr;
        }
        
        //根据倒排索引的关键字word,获得倒排拉链
        InvertedList* GetInvertedList(const std::string &word)
        {
            //...
            return nullptr;
        }
        
        //根据去标签,格式化后的文档,构建正排和倒排索引                                                                                                              
        //将数据源的路径:data/raw_html/raw.txt传给input即可,这个函数用来构建索引
        bool BuildIndex(const std::string &input)
        {
            return true;
        }
    };
}

6.2.2、获取正排索引(GetForwardIndex)

GetForwardIndex函数:根据【正排索引】的 doc_id 找到文档内容

cpp 复制代码
//根据doc_id找到正排索引对应doc_id的文档内容
DocInfo* GetForwardIndex(uint64_t doc_id)
{
    //如果这个doc_id已经大于正排索引的元素个数,则索引失败
    if(doc_id >= forward_index.size())
    {                                                                                                                                                         
          std::cout << "doc_id out range, error!" << std::endl;
          return nullptr;
    }
    return &forward_index[doc_id];//否则返回相应doc_id的文档内容
}

6.2.3、获取倒排拉链(GetInvertedList)

GetInvertedList函数:根据倒排索引的关键字word,获得倒排拉链

cpp 复制代码
//根据倒排索引的关键字word,获得倒排拉链
InvertedList* GetInvertedList(const std::string &word)
{
    // word关键字不是在 unordered_map 中,直接去里面找对应的倒排拉链即可
    auto iter = inverted_index.find(word);
    if(iter == inverted_index.end()) // 判断是否越界
    {
        std::cerr << " have no InvertedList" << std::endl;
        return nullptr;
    }
    // 返回 unordered_map 中的第二个元素--- 倒排拉链
    return &(iter->second);
}

6.2.4、构建索引(BuildIndex)

构建索引:构建索引的思路和用户使用搜索功能的过程正好相反

BuildIndex函数: 根据 去标签,格式化后的.html 文档,构建 正排 和 倒排索引

在编写这部分代码时,稍微复杂一些,我们要构建索引,那我们应该是先把处理干净的文档读取上来,是按行读取,这样就能读到每个html文档;按行读上来每个html文档后,我们就可以开始构建正排索引和倒排索引,此时就要提供两个函数,分别为BuildForwardIndex(构建正排索引)和 BuildInvertedIndex(构建倒排索引),基本的代码如下:

cpp 复制代码
//根据去标签,格式化后的文档,构建正排和倒排索引
//将数据源的路径:data/raw_html/raw.txt传给input即可,这个函数用来构建索引
bool BuildIndex(const std::string &input)
{
     // 要构建索引,肯定先把我们之前处理好的 raw.txt 打开,按行处理(每一行就是一个.html 文件)
    //在上面SaveHtml函数中,我们是以二进制的方式进行保存的,那么读取的时候也要按照二进制的方式读取,读取失败给出提示
    std::ifstream in(input, std::ios::in | std::ios::binary);
    if(!in.is_open())
    {
        std::cerr << "sory, " << input << " open error" << 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;     
        }
    }
    return true;
}

6.2.5、构建正排索引(BuildForwardIndex)

BuildForwardIndex(构建正排索引):

在编写构建正排索引的代码前,我们要知道,在构建索引的函数中,我们是按行读取了每个html文件的(每个文件都是这种格式:title**\3content\3url...)构建正排索引,就是将DocInfo结构体内的字段进行填充** ,这里我们就需要给一个字符串切分的函数 ,我们写到util.hpp 中,这里我们又要引入一个新的方法------boost库当中的切分字符串函数split;代码如下:

cpp 复制代码
#pragma once
#include <iostream>
#include <string>
#include <fstream>
#include <vector>
#include <boost/algorithm/string.hpp>
 
namespace ns_util
{
    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;
            }
            std::string line;
            while(std::getline(in, line)) //如何理解getline读取到文件结束呢??getline的返回值是一个&,while(bool), 本质是因为重载了强制类型转化
            {
                *out += line;
            }
            in.close();
            return true;
        }
    };
 
    class StringUtil
    {
    public:
        //切分字符串
        static void Splist(const std::string &target, std::vector<std::string> *out, const std::string &sep)
        {
            //boost库中的split函数
            boost::split(*out, target, boost::is_any_of(sep), boost::token_compress_on);
            //第一个参数:表示你要将切分的字符串放到哪里
            //第二个参数:表示你要切分的字符串
            //第三个参数:表示分割符是什么,不管是多个还是一个
            //第四个参数:它是默认可以不传,即切分的时候不压缩,不压缩就是保留空格
            //如:字符串为aaaa\3\3bbbb\3\3cccc\3\3d
                //如果不传第四个参数 结果为aaaa  bbbb  cccc  d
                //如果传第四个参数为boost::token_compress_on 结果为aaaabbbbccccd
                //如果传第四个参数为boost::token_compress_off 结果为aaaa  bbbb  cccc  d
        }
    };
}
  • 构建正排索引的编写:
  1. 构建正排索引 将拿到的一行html文件传输进来,进行解析

  2. 构建的正排索引,就是填充一个 DocInfo这个数据结构 ,然后将 DocInfo 插入 正排索引的 vector中即可

cpp 复制代码
// 构建正排索引 将拿到的一行html文件传输进来,进行解析
// 构建的正排索引,就是填充一个 DocInfo这个数据结构 ,然后将 DocInfo 插入 正排索引的 vector中即可 
DocInfo* BuildForwardIndex(const std::string &line)
{
    // 1. 解析 line ,字符串的切分  分为 DocInfo 中的结构
    // 1. line -> 3 个 string (title , content , url)
    std::vector<std::string> results;
    std::string sep = "\3"; //行内分隔符
    ns_util::StringUtil::Splist(line, &results, sep);//字符串切分                                                                                             
    if(results.size() != 3)                                             
    {                                                                   
        return nullptr;                                                 
    }                                                                   
                                                                                
    // 2.字符串进行填充到DocInfo                                        
    DocInfo doc;                                                        
    doc.title = results[0];                                             
    doc.content = results[1];                                           
    doc.url = results[2];     
    // 第一次的话:正排索引的vector数组中是没有元素的,所以0作为第一次文档的ID                                          
    doc.doc_id = forward_index.size(); //先进行保存id,在插入,对应的id就是当前doc在vector中的下标
                                                                                
    // 3.插入到正排索引的vector                                         
    forward_index.push_back(std::move(doc)); //使用move可以减少拷贝带来的效率降低
    return &forward_index.back();                                       
}

6.2.6、倒排索引的原理介绍 (重点!!)

总的思路:

  • titlecontent 进行分词(使用cppjieba
  • 在分词的时候,必然会有某些词在title 和 content中出现过;我们这里还需要做一个处理,就是对每个词进行词频统计(你可想一下,你在搜索某个关键字的时候,为什么有些文档排在前面,而有些文档排在最后)这主要是词和文档的相关性(我们这里认为关键字出现在标题中的相关性高一些,出现在内容中的低一些,当然,关于相关性其实是比较复杂的,我们这里只考虑这些)
  • 自定义相关性:我们有了词和文档的相关性的认识后,就要来自己设计这个相关性;我们把出现在title中的词,其权重更高,在content中,其权重低一些(如:让出现在title中的词的词频x10,出现在content中的词的词频x1,两者相加的结果称之为该词在整个文档中的权重)根据这个权重,我们就可以对所有文档进行权重排序,进行展示,权重高的排在前面展示,权重低的排在后面展示
    伪代码操作演示:

如下是我们之前的基本结构代码:

cpp 复制代码
//倒排拉链节点
struct InvertedElem{
    uint64_t doc_id;  //文档的ID
    std::string word; //关键词
    int weight;       //权重
};
 
//倒排拉链
typedef std::vector<InvertedElem> InvertedList;
 
//倒排索引一定是一个关键字和一组(个)InvertedElem对应[关键字和倒排拉链的映射关系]
std::unordered_map<std::string, InvertedList> inverted_index;
 
//文档信息节点
struct DocInfo{
    std::string title;   //文档的标题
    std::string content; //文档对应的去标签之后的内容
    std::string url;     //官网文档url
    uint64_t doc_id;     //文档的ID
};

1. 需要对 title && content都要先分词 -- 使用jieba分词

  • title: 吃/葡萄/吃葡萄(title_word)
  • content:吃/葡萄/不吐/葡萄皮(content_word)

2. 词频统计

统计词频,它是包含标题和内容的,我们就需要有一个结构体,来存储每一篇文档中每个词出现在title和content中的次数,伪代码如下:

cpp 复制代码
//词频统计的结点
struct word_cnt
{
    title_cnt;  //词在标题中出现的次数
    content_cnt;//词在内容中出现的次数
}

统计这些次数之后,我们还需要将词频和关键词进行关联,文档中的每个词都要对应一个词频结构体,这样我们通过关键字就能找到其对应的词频结构体,通过这个结构体就能知道该关键字在文档中的title和content中分别出现了多少次,下一步就可以进行权重的计算。这里我们就可以使用数据结构unordered_map来进行存储。伪代码如下:

cpp 复制代码
//关键字和词频结构体的映射
unordered_map<std::string, word_cnt> word_map;
 
//范围for进行遍历,对title中的词进行词频统计
for(auto& word : title_word)
{
    // 一个关键词 对应 标题 中出现的次数
    word_map[word].title_cnt++; //吃(1)/葡萄(1)/吃葡萄(1)
}
//范围for进行遍历,对content中的词进行词频统计
for(auto& word : content_word)
{
    // 一个关键词 对应 内容 中出现的次数
    word_map[word].content_cnt++; //吃(1)/葡萄(1)/不吐(1)/葡萄皮(1)
}

3. 自定义相关性

知道了在文档中,标题 和 内容 每个词出现的次数,接下来就需要我们自己来设计相关性了,伪代码如下:

cpp 复制代码
//遍历刚才那个unordered_map<std::string, word_cnt> word_map;
for(auto& word : word_map)
{
    struct InvertedElem elem;//定义一个倒排拉链节点,然后填写相应的字段
    elem.doc_id = 123;
    elem.word = word.first;  // word.first-> 关键字
    elem.weight = 10*word.second.title_cnt + word.second.content_cnt ;//权重计算
    // 将关键字 对应的 倒排拉链节点 保存到 对应的倒排拉链这个 数组中
    inverted_index[word.first].push_back(elem);//最后保存到倒排索引的数据结构中
}
 
//倒排索引结构如下: 一个关键字 对应的 倒排拉链(一个或一组倒排节点)
//std::unordered_map<std::string, InvertedList> inverted_index;
 
//倒排索引结构体  -- 一个倒排拉链节点
struct InvertedElem
{
    uint64_t doc_id;   // 文档ID
    std::string word; // 文档相关关键字
    int weight;        // 文档权重
};
 
//倒排拉链
typedef std::vector<InvertedElem> InvertedList;

6.2.7、cppjieba分词工具的安装和使用介绍

获取链接: cppjieba 下载链接 里面有详细的教程

我们这里可以使用 git clone,如下:

  • 创建一个test目录,将我们 git clone 直接在 test 目录下进行:
cpp 复制代码
git clone https://github.com/yanyiwu/cppjieba
  • 查看 cppjieba 目录,里面包含如下:
  • 我们待会儿需要用到的分词工具是在 include/cppjieba/jieba.hpp
  • 首先,这是别人的写好的一个开源项目,里面会有这个测试代码,通常是在test目录下:
  • 我们来做个分词演示,先将这个demo.cpp拷贝到我们自己的test目录下:
  • 打开之后,就是一堆错误,主要原因是路径和链接不对:

首先,从上图可以看到头文件的路径就不对,我们先来修改一下头文件的路径,它本身是要使用cppjieba/Jieba.hpp的,我们看一下这个头文件的具体路径:

路径是: cppjieba/include/cppjieba/Jieba.hpp

  • 我们要在 test 目录下执行这个 demo.cpp,就要引入这个头文件,我们不能直接引入,需要使用软连接:
  • 其次我们还要在 test 目录下执行这个 dict(词库)里面的内容,要引入这个头文件,我们不能直接引入,需要使用软连接:
  • 软连接建立好后并修改 demo.hpp 的相应路径,再将该包的头文件包起来,再来查看demo.hpp是否还有错误:
  • 运行结果:

上面的操作做完之后,就可以在我们的项目中引入头文件,来使用cppjieba分词工具啦!!

6.2.8、引入cppjieba到项目中

将软链接建立好之后,我们在util.hpp中编写一个jieba分词的类,主要是为了方便后期其他地方需要使用的时候,可以直接调用。

我们在util.hpp中创建一个 JiebaUtil的分词工具类,首先我们先看一下之前测试过的demo.cpp的代码:

  • 那么接下来就要在我们的项目路径中,加入cppjieba下的Jieba.hpp,操作和上面的类似,这里我就不在操作了。直接看结果:

util.hpp代码如下:

cpp 复制代码
#pragma once
#include <iostream>
#include <string>
#include <fstream>
#include <vector>
#include <boost/algorithm/string.hpp>
#include "cppjieba/Jieba.hpp"  //引入头文件(确保你建立的没有错误才可以使用)
 
namespace ns_util
{
    class FileUtil
    {                                                                                                                                                                                                                                                                                                                                                                        
    public:
        //输入文件名,将文件内容读取到out中
        static bool ReadFile(const std::string &file_path, std::string *out)
        {
            // 读取 file_path(一个.html文件) 中的内容  -- 打开文件
            std::ifstream in(file_path, std::ios::in);
            //文件打开失败检查
            if(!in.is_open())
            {
                std::cerr << "open file " << file_path << " error" << std::endl;
                return false;
            }
            //读取文件内容
            std::string line;
            //while(bool),getline的返回值istream会重载操作符bool,读到文件尾eofset被设置并返回false
            //如何理解getline读取到文件结束呢??getline的返回值是一个&,while(bool), 本质是因为重载了强制类型转化
            while(std::getline(in, line)) // 每循环一次,读取的是文件的一行内容
            {
                *out += line;    // 将文件内容保存在 *out 里面
            }
            in.close(); // 关掉文件
            return true;
        }
    };
 
    class StringUtil
    {
        public:
        //切分字符串
        static void Splist(const std::string &target, std::vector<std::string> *out, const std::string &sep)
        {
            //boost库中的split函数
            boost::split(*out, target, boost::is_any_of(sep), boost::token_compress_on);
            //第一个参数:表示你要将切分的字符串放到哪里
            //第二个参数:表示你要切分的字符串
            //第三个参数:表示分割符是什么,不管是多个还是一个
            //第四个参数:它是默认可以不传,即切分的时候不压缩,不压缩就是保留空格
            //如:字符串为aaaa\3\3bbbb\3\3cccc\3\3d
                //如果不传第四个参数 结果为aaaa  bbbb  cccc  d
                //如果传第四个参数为boost::token_compress_on 结果为aaaabbbbccccd
                //如果传第四个参数为boost::token_compress_off 结果为aaaa  bbbb  cccc  d
        }
    };
 
    //下面这5个是分词时所需要的词库路径
    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    
    {    
    private:    
        static cppjieba::Jieba jieba; //定义静态的成员变量(需要在类外初始化)   
    public:    
        static void CutString(const std::string &src, std::vector<std::string> *out)    
        {   
            //调用CutForSearch函数,第一个参数就是你要对谁进行分词,第二个参数就是分词后的结果存放到哪里
            jieba.CutForSearch(src, *out);    
        }     
    };
 
    //类外初始化,就是将上面的路径传进去,具体和它的构造函数是相关的,具体可以去看一下源代码
    cppjieba::Jieba JiebaUtil::jieba(DICT_PATH, HMM_PATH, USER_DICT_PATH, IDF_PATH, STOP_WORD_PATH);
   
}

6.2.9、构建倒排索引(BuildInvertedIndex)

BuildInvertedIndex(构建倒排索引):

构建倒排索引相对复杂一些,只要将上面倒排索引的原理和伪代码的思路;理解到位后,下面的代码就比较简单了。

cpp 复制代码
//构建倒排索引
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_words;    
    ns_util::JiebaUtil::CutString(doc.title, &title_words);      
    
    //对标题进行词频统计       
    for(auto 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(auto s : content_words)      
    {    
        boost::to_lower(s); // 将我们的分词进行统一转化成为小写的    
        word_map[s].content_cnt++;    
    }    
    
#define X 10    
#define Y 1  
    //最终构建倒排  
    for(auto &word_pair : word_map)    
    {    
        InvertedElem item;    
        item.doc_id = doc.doc_id; //倒排索引的id即文档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;    
} 

七、编写搜索引擎模块 Searcher

7.1、基本结构

我们已经完成了 数据清洗、去标签和索引 相关的工作,接下来就是要编写服务器所提供的服务,我们试想一下,服务器要做哪些工作:首先,我们的数据事先已经经过了数据清洗和去标签的,服务器运行起来之后,应该要先去构建索引,然后根据索引去搜索,所以我们在Searcher模块中实现两个函数,分别为InitSearcher()和Search(),代码如下:

  • 首先创建一个 searcher.hpp 文件,用来编写搜索模块
cpp 复制代码
touch searcher.hpp
cpp 复制代码
#include "index.hpp"

namespace ns_searcher
{
    class Searcher
    {
    private:
        ns_index::Index *index; //供系统进行查找的索引
    public:
        Searcher(){}
        ~Searcher(){}
    public:
        void InitSearcher(const std::string &input)
        {
            //...
            // 获取或者创建index对象(单例)
            // 根据index对象建立索引
        }
        
        //query: 搜索关键字
        //json_string: 返回给用户浏览器的搜索结果
        void Search(const std::string &query, std::string *json_string)
        {
            //...
            //1.[分词]:对搜索关键字query在服务端也要分词,然后查找index
            //2.[触发]:根据分词的各个词进行index查找
            //3.[合并排序]:汇总查找结果,按照相关性(权重weight)降序排序
            //4.[构建]:将排好序的结果,生成json串 ------ jsoncpp
        }
    };
}

7.2、初始化服务(InitSearcher)

服务器 要去 构建索引,本质上就是去构建一个 Index对象,然后调用其内部的方法, 我们知道构建正排索引和倒排索引本质就是将磁盘上的数据加载到内存,其数据量还是比较大的(可能本项目的数据量不是很大)。从这一点可以看出,假设创建了多个Index对象的话,其实是比较占内存的,我们这里就可以将这个Index类设计成为单例模式。

7.2.1、Index模块的单例设计

cpp 复制代码
#pragma once
#include <iostream>
#include <string>
#include <vector>
#include <unordered_map>
#include <fstream>
#include <mutex>
#include "util.hpp"
 
namespace ns_index
{
    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;     //关键字(通过关键字可以找到对应的ID)
        int weight;           //权重---根据权重对文档进行排序展示
    };
 
    // 倒排拉链  -- 一个关键字 可能存在于多个文档中,所以一个关键字对应了一组文档
    typedef std::vector<InvertedElem> InvertedList;
 
    class Index
    {
    private:
        // 正排索引的数据结构采用数组,数组下标就是天然的文档ID
        // 每一个数组里面存放一个 文档信息
        std::vector<DocInfo> forward_index; //正排索引
 
        // 一个【关键字】可能出现在 无数个 【文档】中 ,我们需要根据权重判断 文档的重要顺序
        //倒排索引一定是一个关键字和一组(或者一个)InvertedElem对应[关键字和倒排拉链的映射关系]
        std::unordered_map<std::string, InvertedList> inverted_index;
 
    // 将 Index 转变成单例模式
    private:
        Index(){} //这个一定要有函数体,不能delete
        Index(const Index&) = delete;  // 拷贝构造
        Index& operator = (const Index&) = delete; // 赋值重载
        static Index* instance;
        static std::mutex mtx;//C++互斥锁,防止多线程获取单例存在的线程安全问题
 
    public:
        ~Index(){}
 
    public:
        //获取index单例
        static Index* GetInstance()
        {
            // 这样的【单例】 可能在多线程中产生 线程安全问题,需要进行加锁
            if(nullptr == instance)// 双重判定空指针, 降低锁冲突的概率, 提高性能
            {
                mtx.lock();//加锁
                if(nullptr == instance)
                {
                    instance = new Index();//获取单例
                }
                mtx.unlock();//解锁
            }
            return instance;
        }
 
        //根据doc_id找到正排索引对应doc_id的文档内容
        DocInfo* GetForwardIndex(uint64_t doc_id)
        {
            //如果这个doc_id已经大于正排索引的元素个数,则索引失败
            if(doc_id >= forward_index.size())  // 相当于 越界
            {                                                                                                                                                         
                std::cout << "doc_id out range, error!" << std::endl;
                return nullptr;
            }
            return &forward_index[doc_id];//否则返回相应doc_id的文档内容
        }
        
        //根据倒排索引的关键字word,获得倒排拉链
        InvertedList* GetInvertedList(const std::string &word)
        {
            // word关键字不是在 unordered_map 中,直接去里面找对应的倒排拉链即可
            auto iter = inverted_index.find(word);
            if(iter == inverted_index.end())  // 判断是否越界
            {
                std::cerr << " have no InvertedList" << std::endl;
                return nullptr;
            }
            // 返回 unordered_map 中的第二个元素--- 倒排拉链
            return &(iter->second);
        }
        
        //根据去标签,格式化后的文档,构建正排和倒排索引                                                                                                              
        //将数据源的路径:data/raw_html/raw.txt传给input即可,这个函数用来构建索引
        bool BuildIndex(const std::string &input)
        {
            // 要构建索引,肯定先把我们之前处理好的 raw.txt 打开,按行处理(每一行就是一个.html 文件)
            // 在上面SaveHtml函数中,我们是以二进制的方式进行保存的,那么读取的时候也要按照二进制的方式读取,读取失败给出提示
            std::ifstream in(input, std::ios::in | std::ios::binary);  // 读取input(raw.txt) 
            if(!in.is_open()) 
            {
                std::cerr << "sory, " << input << " open error" << 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;     
                }
            }
            return true;
        }
 
    private:
        // 构建正排索引 将拿到的一行html文件传输进来,进行解析
        // 构建的正排索引,就是填充一个 DocInfo这个数据结构 ,然后将 DocInfo 插入 正排索引的 vector中即可 
        DocInfo* BuildForwardIndex(const std::string& line)
        {
            // 1. 解析 line ,字符串的切分  分为 DocInfo 中的结构
            // 1. line -> 3 个 string (title , content , url)
            std::vector<std::string> results;
            std::string sep = "\3"; //行内分隔符
            ns_util::StringUtil::Splist(line, &results, sep);//字符串切分 
            if(results.size() != 3)                                             
            {                                                                   
                return nullptr;                                                 
            }  
            // 2. 字符串填充到 DocInfo 中
            DocInfo doc;                                                        
            doc.title = results[0];                                             
            doc.content = results[1];                                           
            doc.url = results[2];                                               
            doc.doc_id = forward_index.size(); //先进行保存id,在插入,对应的id就是当前doc在vector中的下标
            // 3. 插入到正排索引的 vector 中
            forward_index.push_back(std::move(doc)); //使用move可以减少拷贝带来的效率降低
 
            return &forward_index.back();                                       
        }
 
        // 构建倒排索引
        bool BuildInvertedIndex(const DocInfo &doc)
        {
            // DocInfo (title , content , url , doc_id)
            // word(关键字) -> 倒排拉链
 
            //词频统计结构体--表示一个节点 
            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(auto 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(auto s : content_words)      
            {    
                boost::to_lower(s); // 将我们的分词进行统一转化成为小写的    
                word_map[s].content_cnt++;     // 一个关键词 对应 内容 中出现的次数
            }
 
            #define X 10    
            #define Y 1 
 
            //最终构建倒排  
            for(auto &word_pair : word_map)    
            {    
                InvertedElem item;    //定义一个倒排拉链节点,然后填写相应的字段
                item.doc_id = doc.doc_id; //倒排索引的id即文档id   
                item.word = word_pair.first;    // 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* Index::instance = nullptr;
    std::mutex Index::mtx;
}

7.2.2、编写InitSearcher

cpp 复制代码
void InitSearcher(const std::string &input)
{
    // 获取或者创建index对象(单例)
    index = ns_index::Index::GetInstance();  
    // 根据index对象建立索引
    index->BuildIndex(input);
}

7.3、提供服务(Search)

对于提供服务,我们需要从四个方面入手,达到服务效果:

  1. 对用户的输入的**【关键字】,** 我们首先要做的就是**【分词】,**只有分成不同的词之后,才能按照不同的词去找文档;
  2. 分词完毕后,我们就要去触发这些分词,本质就是查找建立好的正排索引和倒排索引
  3. 我们的每个文档都是设置了权重字段的,我们就应该在触发分词之后,进行权重的降序排序,达到权重高的文档靠前,权重低的文档靠后;
  4. 根据排序完的结果,构建json串,用于网络传输。因为结构化的数据不便于网络传输,我们就需要使用一个工具(jsoncpp),它是用来将结构化的数据转为字节序(你可以理解为很长的字符串),jsoncpp可以进行序列化(将结构化的数据转换为字节序列,发生到网络)和反序列化(将网络中的字节序列转化为结构化的数据)

jsoncpp使用的效果如下图:

7.3.1、对用户关键字进行分词

为什么我们要对用户输入的关键字进行分词呢?

这也不难理解,虽然我们index模块 中的 正排索引 中已经做了分词操作,这只能说明服务器已经将数据准备好了, 按照不同的词和对应的文档分好类了;但是用户输入的关键字,我们依旧是要做分词操作的。设想一下,**如果没有做分词,直接按照原始的关键字进行查找,给用户反馈的文档一定没有分词来的效果好,甚至有可能匹配不到文档。**影响用户的体验。代码如下:

cpp 复制代码
//query--->搜索关键字    
//json_string--->返回给用户浏览器的搜索结果
void Search(const std::string &query, std::string *json_string)
{
    //1.分词---对query按照Searcher的要求进行分词    
    std::vector<std::string> words; //用一个数组存储分词的结果   
    ns_util::JiebaUtil::CutString(query, &words);//分词操作
}

7.3.2、 触发分词,进行索引查找

分词完成以后,我们就应该按照分好的每个词(关键字)去获取倒排拉链,我们将获取上来的倒排拉链进行保存到vector当中,这也就是我们根据用户关键字所查找的结果,但是我们还需要考虑一个问题,用户输入的关键字进行分词了以后,有没有可能多个关键字对应的是同一个文档,如下图所示:

根据上面的图,我们首先想到的就是去重。其次,每个倒排拉链的结点都包含:doc_id、关键字和权重。既然显示了重复的文档,我们应该是只显示一个,那么这个最终显示的文档其权重就是几个文档之和,关键字就是几个文档的组合,那么我们可以定义一个新的结构体来保存查找后的倒排拉链,代码如下:

cpp 复制代码
//该结构体是用来对重复文档去重的结点结构
struct InvertedElemPrint
{
    uint64_t doc_id;  //文档ID
    int weight;       //重复文档的权重之和
    std::vector<std::string> words;//关键字的集合,我们之前的倒排拉链节点只能保存一个关键字
    InvertedElemPrint():doc_id(0), weight(0){}
};

有了上面的铺垫,我们就可以来编写触发分词的代码了:

cpp 复制代码
//query--->搜索关键字    
//json_string--->返回给用户浏览器的搜索结果
void Search(const std::string &query, std::string *json_string)
{
    //1.分词---对query按照Searcher的要求进行分词    
    std::vector<std::string> words; //用一个数组存储分词的结果   
    ns_util::JiebaUtil::CutString(query, &words);//分词操作
 
    //2.触发---就是根据分词的各个"词",进行index查找,建立index是忽略大小写,所以搜索关键字也需要
    std::vector<InvertedElemPrint> inverted_list_all; //用vector来保存
            
    std::unordered_map<uint64_t, InvertedElemPrint> tokens_map;//用来去重
 
    for(std::string word : words)//遍历分词后的每个词
    {
        boost::to_lower(word);//忽略大小写
        ns_index::InvertedList* inverted_list = index->GetInvertedList(word);//获取倒排拉链
        if(nullptr == inverted_list)
        {
            continue;
        }
        //遍历获取上来的倒排拉链
        for(const auto &elem : *inverted_list)
        {
            auto &item = tokens_map[elem.doc_id];//插入到tokens_map中,key值如果相同,这修改value中的值
            item.doc_id = elem.doc_id;
            item.weight += elem.weight;//如果是重复文档,key不变,value中的权重累加
            item.words.push_back(elem.word);//如果树重复文档,关键字会被放到vector中保存
        }
     }
     //遍历tokens_map,将它存放到新的倒排拉链集合中(这部分数据就不存在重复文档了)
     for(const auto &item : tokens_map)                                                                                                                                        
     {
         inverted_list_all.push_back(std::move(item.second));
     }
}

7.3.3、按文档权重进行降序排序

对于排序,应该不难,我们直接使用C++库当中的sort函数,并搭配lambda表达式使用;当然你也可以自己写一个快排或者归并排序,按权重去排;

cpp 复制代码
//query--->搜索关键字    
//json_string--->返回给用户浏览器的搜索结果
void Search(const std::string &query, std::string *json_string)
{
    //1.分词---对query按照Searcher的要求进行分词    
    std::vector<std::string> words; //用一个数组存储分词的结果   
    ns_util::JiebaUtil::CutString(query, &words);//分词操作
    //2.触发---就是根据分词的各个"词",进行index查找,建立index是忽略大小写,所以搜索关键字也需要
    std::vector<InvertedElemPrint> inverted_list_all; //用vector来保存
            
    std::unordered_map<uint64_t, InvertedElemPrint> tokens_map;//用来去重
 
    for(std::string word : words)//遍历分词后的每个词
    {
        boost::to_lower(word);//忽略大小写
        ns_index::InvertedList* inverted_list = index->GetInvertedList(word);//获取倒排拉链
        if(nullptr == inverted_list)
        {
            continue;
        }
        //遍历获取上来的倒排拉链
        for(const auto &elem : *inverted_list)
        {
            auto &item = tokens_map[elem.doc_id];//插入到tokens_map中,key值如果相同,这修改value中的值
            item.doc_id = elem.doc_id;
            item.weight += elem.weight;//如果是重复文档,key不变,value中的权重累加
            item.words.push_back(elem.word);//如果树重复文档,关键字会被放到vector中保存
        }
    }
     //遍历tokens_map,将它存放到新的倒排拉链集合中(这部分数据就不存在重复文档了)
    for(const auto &item : tokens_map)                                                                                                                                        
    {
        inverted_list_all.push_back(std::move(item.second));
    }
 
    //3. 合并排序---汇总查找结果,按照相关性(weight)降序排序
    std::sort(inverted_list_all.begin(), inverted_list_all.end(),\
         [](const InvertedElemPrint &e1, const InvertedElemPrint &e2)
         {return e1.weight > e2.weight;});
}

7.3.4、根据排序结果构建json串

关于 json 的使用,我们首先需要在 Linux下安装 jsoncppsudo apt-get install -y libjsoncpp-dev****这里我之前下载过了,已经是最新的版本了,你们只需要输入上面的指令,有这样的提示,就表明安装成功了。

如何使用:

cpp 复制代码
touch test.cpp
cpp 复制代码
#include <iostream>
#include <string>
#include <vector>
#include <jsoncpp/json/json.h>

// Value Reader(反序列化) Writer(序列化)
int main()
{
        Json::Value root;
        Json::Value item1;
        item1["key1"]="value1";
        item1["key2"]="value2";

        Json::Value item2;
        item2["key1"]="value3";
        item2["key2"]="value4";

        root.append(item1);
        root.append(item2);

        // 两种序列化的方式
        Json::StyledWriter writer;// 序列化方式1
        //Json::FastWriter Writer;// 序列化方式2
        
        std::string s = writer.write(root);
        std::cout<<s<<std::endl;
        return 0;
}

root对象: 你可以理解为json数组;
item1对象: 就是json中value的对象,他可以保存kv值
item2对象: 就是json中value的对象,他可以保存kv值

将item1和item2 ,append到root中:你可以理解为将root这个大json数组,保存了两个子json
序列化的方式有两种:StyledWriter和FastWriter两者的区别:1. 呈现的格式不一样;2. 在网络传输中FastWriter更快。
序列化方式1: StyledWriter

序列化方式2: FastWriter

  • 有了基本的了解之后,我们开始编写正式的代码:
cpp 复制代码
//query--->搜索关键字    
//json_string--->返回给用户浏览器的搜索结果
void Search(const std::string &query, std::string *json_string)
{
    //1.分词---对query按照Searcher的要求进行分词    
    std::vector<std::string> words; //用一个数组存储分词的结果   
    ns_util::JiebaUtil::CutString(query, &words);//分词操作
    //2.触发---就是根据分词的各个"词",进行index查找,建立index是忽略大小写,所以搜索关键字也需要
    std::vector<InvertedElemPrint> inverted_list_all; //用vector来保存
            
    std::unordered_map<uint64_t, InvertedElemPrint> tokens_map;//用来去重
 
    for(std::string word : words)//遍历分词后的每个词
    {
        boost::to_lower(word);//忽略大小写
        ns_index::InvertedList* inverted_list = index->GetInvertedList(word);//获取倒排拉链
        if(nullptr == inverted_list)
        {
            continue;
        }
        //遍历获取上来的倒排拉链
        for(const auto &elem : *inverted_list)
        {
            auto &item = tokens_map[elem.doc_id];//插入到tokens_map中,key值如果相同,这修改value中的值
            item.doc_id = elem.doc_id;
            item.weight += elem.weight;//如果是重复文档,key不变,value中的权重累加
            item.words.push_back(elem.word);//如果树重复文档,关键字会被放到vector中保存
        }
    }
     //遍历tokens_map,将它存放到新的倒排拉链集合中(这部分数据就不存在重复文档了)
    for(const auto &item : tokens_map)                                                                                                                                        
    {
        inverted_list_all.push_back(std::move(item.second));
    }
 
    //3. 合并排序---汇总查找结果,按照相关性(weight)降序排序
    std::sort(inverted_list_all.begin(), inverted_list_all.end(),\
         [](const InvertedElemPrint &e1, const InvertedElemPrint &e2)
         {return e1.weight > e2.weight;});
 
    //4.构建---根据查找出来的结果,构建json串---jsoncpp    
    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是文档去标签后的结果,但不是我们想要的,我们要的是一部分                                                     
        elem["url"] = doc->url;    
    
        //调式    
        //elem["id"] = (int)item.doc_id;    
        //elem["weight"] = item.weight;    
    
        root.append(elem);    
    }    
    //Json::StyledWriter writer; //方便调试    
    Json::FastWriter writer;//调式没问题后使用这个    
    *json_string = writer.write(root);
}
  • 在上述的代码中,我们构建出来的json串最后是要返回给用户的,对于内容,我们只需要一部分,而不是全部,所以我们还要实现一个 GetDesc 的函数:

获取内容摘要接口注意点:

(1)我们的建立的索引Index,其中的倒排表实际上是统一按照小写词段进行查找的,也就是说,我们倒排索引表的左侧是小写的词。

我们搜索到的倒排拉链里面的文档ID信息里面存储的也是小写化的搜索关键词word。

(2)而索引Index.其中的正排表中,文档id对应的文档内容,doc_info里面的_content内容,却是不区分大小写的。

所以我们不能直接用小写化的关键词word在content中find搜索,大小写不匹配,这是可能找不到包含该关键词的语句的。

我们应该在content中寻找word时,在检查对比时,统一小写化对比。

这里我们使用C++ <algorithm>中的search接口解决。

cpp 复制代码
std::string GetDesc(const std::string &html_content, const std::string &word)
{
    //找到word(关键字)在html_content中首次出现的位置
    //然后往前找50个字节(如果往前不足50字节,就从begin开始)
    //往后找100个字节(如果往后不足100字节,就找到end即可)
    //截取出这部分内容
            
    const int prev_step = 50;
    const int next_step = 100;
    //1.找到首次出现
    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 "None1";
    }
    int pos = std::distance(html_content.begin(), iter);
            
    //2.获取start和end位置
    int start = 0;
    int end = html_content.size() - 1;
    //如果之前有50个字符,就更新开始位置
    if(pos > start + prev_step) start = pos - prev_step;
    if(pos < end - next_step) end = pos + next_step;
 
    //3.截取子串,然后返回
    if(start >= end) return "None2";
    std::string desc = html_content.substr(start,end - start);
    desc += "...";
    return desc;
}
  • 最后,我们来测试一下效果,编写debug.cpp,这个文件和我们项目文件关联性不大,主要是用来调式(需要将上文代码中备注调式的代码放开):
cpp 复制代码
#include "searcher.hpp"    
#include <cstdio>    
#include <iostream>    
#include <string>    
    
const std::string input = "data/raw_html/raw.txt";    
    
int main()    
{    
    ns_searcher::Searcher *search = new ns_searcher::Searcher();    
    search->InitSearcher(input);  //初始化search,创建单例,并构建索引  
    
    std::string query; //自定义一个搜索关键字   
    std::string json_string; //用json串返回给我们   
    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);  //执行服务,对关键字分词->查找索引->按权重排序->构建json串->保存到json_string->返回给我们                                                                                                                                      
        std::cout << json_string << std::endl;//输出打印    
    }    
    return 0;    
}
  • 对应的Makefile:
cpp 复制代码
PARSER=parser
DUG=debug
cpp=g++

.PHONY:all
all:$(PARSER) $(DUG)

$(PARSER):parser.cpp
        $(cpp) -o $@ $^ -lboost_system -lboost_filesystem -std=c++11
$(DUG):dubug.cpp
        $(cpp) -o $@ $^ -std=c++11 -ljsoncpp

.PHONY:clean
clean:
        rm -f $(PARSER) $(DUG)
  • 运行结果如下:
  • 我们输入搜索关键字:split

八、编写http_server模块

8.1、引入cpp-httplib到项目中

  • 我们将cpp-httplib放到项目中的test目录下,并解压好;
  • 建立软连接到我们的项目路径下:
  • 注意:要使用 cpp-httplib ,我们的 gcc 的版本必须时7 以上哦

至此,我们就可以在我们的项目中使用了。

8.2、cpp-httplib的使用介绍

  • 创建一个http_server.cpp的文件,编写测试代码:
cpp 复制代码
#include "cpp-httplib/httplib.h"    
      
int main()    
{    
    //创建一个Server对象,本质就是搭建服务端
    httplib::Server svr; 
 
    // 这里注册用于处理 get 请求的函数,当收到对应的get请求时(请求hi时),程序会执行对应的函数(也就是lambda表达式)
    svr.Get("/hi", [](const httplib::Request& req, httplib::Response& rsp){ 
            //设置 get "hi" 请求返回的内容   
            rsp.set_content("hello world!", "text/plain; charset=utf-8");                                                                                                            
          });    
    // 绑定端口(8081),启动监听(0.0.0.0表示监听任意端口)
    svr.listen("0.0.0.0", 8081);  
  
    return 0;    
}
  • 对应的Makefile:
cpp 复制代码
PARSER=parser
DUG=debug
HTTP_SERVER=http_server
cpp=g++
 
.PHONY:all
all:$(PARSER) $(DUG) $(HTTP_SERVER)
 
$(PARSER):parser.cpp
	$(cpp) -o $@ $^ -lboost_system -lboost_filesystem -std=c++11
$(DUG):debug.cpp
	$(cpp) -o $@ $^ -std=c++11 -ljsoncpp
$(HTTP_SERVER):http_server.cpp
	$(cpp) -o $@ $^ -std=c++11 -ljsoncpp -lpthread
.PHONY:clean
clean:
	rm -f $(DUG) $(PARSER) $(HTTP_SERVER)
  • 我们直接编译运行 http_server
  • 打开浏览器,访问我们这个端口(如服务器IP:8080/hi),结果如下:
  • 但是当我们访问服务器IP:8080时,却找不到对应的网页,
  • 像我们访问百度时,www.baidu.com,百度会给一个首页,所有在我们的项目目录下呢,也需要一个首页。 (在项目路径下创建一个wwwroot目录,目录中包含一个index.html文件)
cpp 复制代码
wwwroot目录下的index.html
cpp 复制代码
<!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">
    <title>boost搜索引擎</title>
</head>
<body>
    <h1>欢迎来到我的世界</h1>
</body>
</html>
  • 编写我们的首页,并修改我们的 http_server.cpp:
cpp 复制代码
#include "cpp-httplib/httplib.h"
 
const std::string root_path = "./wwwroot";
 
int main()    
{    
    //创建一个Server对象,本质就是搭建服务端
    httplib::Server svr; 
 
    //访问首页
    svr.set_base_dir(root_path.c_str());
 
    // 这里注册用于处理 get 请求的函数,当收到对应的get请求时(请求hi时),程序会执行对应的函数(也就是lambda表达式)
    svr.Get("/hi", [](const httplib::Request& req, httplib::Response& rsp){ 
            //设置 get "hi" 请求返回的内容   
            rsp.set_content("hello world!", "text/plain; charset=utf-8");                                                                                                            
          });    
    // 绑定端口(8080),启动监听(0.0.0.0表示监听任意端口)
    svr.listen("0.0.0.0", 8080);  
  
    return 0;    
}
  • 再次通过浏览器进行访问:

8.3、正式编写http_server

cpp 复制代码
#include "cpp-httplib/httplib.h"    
#include "searcher.hpp"    
    
const std::string input = "data/raw_html/raw.txt";    
const std::string root_path = "./wwwroot";    
    
int main()    
{    
    ns_searcher::Searcher search;    
    search.InitSearcher(input); 
   
    //创建一个Server对象,本质就是搭建服务端
    httplib::Server svr;   
 
    //访问首页
    svr.set_base_dir(root_path.c_str()); 
  
     // 这里注册用于处理 get 请求的函数,当收到对应的get请求时(请求s时),程序会执行对应的函数(也就是lambda表达式)
    svr.Get("/s", [&search](const httplib::Request &req, httplib::Response &rsp){
            //has_param:这个函数用来检测用户的请求中是否有搜索关键字,参数中的word就是给用户关键字取的名字(类似word=split)    
            if(!req.has_param("word")){    
                rsp.set_content("必须要有搜索关键字!", "text/plain; charset=utf-8");    
                return;    
            }    
 
            //获取用户输入的关键字
            std::string word = req.get_param_value("word");    
            std::cout << "用户在搜索:" << word << std::endl;    
            
            //根据关键字,构建json串
            std::string json_string;    
            search.Search(word, &json_string);
 
            //设置 get "s" 请求返回的内容,返回的是根据关键字,构建json串内容
            rsp.set_content(json_string, "application/json");       
            });    
 
    std::cout << "服务器启动成功......" << std::endl; 
 
    // 绑定端口(8080),启动监听(0.0.0.0表示监听任意端口)
    svr.listen("0.0.0.0", 8080);                                                                                                                                                      
    return 0;    
}
  • 此时我们编译运行我们的代码,先执行parser进行数据清洗,然后执行http_server,搭建服务,创建单例,构建索引,发生请求(根据用户输入的关键字,进行查找索引,构建json串),最后响应给用户
  • 此时服务器启动成功,索引也建立完毕
  • 此时,我们在浏览器进行访问(服务器IP:8080/s)
  • 此时,我们在浏览器进行访问(服务器IP:8080/s?word=split)

最终,在浏览器上就显示出来了,到这里我们的后端内容大致上算是完成了,最后添加一个日志就可以了。

九、添加日志到项目中

我们创建一个log.hpp的头文件,需要添加日志的地方:index模块,searcher模块、http_server模块。代码如下:

cpp 复制代码
#pragma once     
#include <iostream>    
#include <string>    
#include <ctime>    
    
#define NORMAL 1   //正常的                                                                                                                                                                     
#define WARNING 2  //错误的     
#define DEBUG 3    //bug    
#define FATAL 4    //致命的   
    
#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;    
} 
/*
简单说明:   
    我们用宏来实现日志功能,其中LEVEL表明的是等级(有四种),
    这里的#LEVEL的作用是:把一个宏参数变成对应的字符串(直接替换)
C语言中的预定义符号:
    __FILE__:进行编译的源文件
    __LINE__:文件的当前行号
补充几个:
    __DATE__:文件被编译的日期
    __TIME__:文件被编译的时间
    __FUNCTION__:进行编译的函数
*/
  • 假设在如下示例代码:
cpp 复制代码
int main() {
    LOG(NORMAL, "This is a normal log message");
    LOG(WARNING, "This is a warning log message");
    LOG(DEBUG, "This is a debug log message");
    LOG(FATAL, "This is a fatal log message");
    return 0;
}
  • 编译并运行这段代码后,输出会类似于:
cpp 复制代码
[NORMAL][1691585012][This is a normal log message][main.cpp : 2]
[WARNING][1691585012][This is a warning log message][main.cpp : 3]
[DEBUG][1691585012][This is a debug log message][main.cpp : 4]
[FATAL][1691585012][This is a fatal log message][main.cpp : 5]
  • 所以我们可以将日志添加到有输出入口的地方,方便监视我们的代码那里出现了问题。

日志系统的作用

  1. 调试和错误追踪:记录程序执行过程中的各种状态和错误信息,方便定位和修复问题。
  2. 运行监控:监控程序的运行状态,了解程序的执行流程和重要事件。
  3. 审计和分析:分析日志记录,了解用户行为和系统性能,进行数据挖掘和改进

十、编写前端模块

10.1、了解 vscode

我们使用Vscode连接云服务器进行前端代码的编写 , 下面我们安装Vscode并进行连接。

  • 下载链接:

官网:https://code.visualstudio.com/

  • 安装插件

Chinese (Simplified) (简体中文) Language Pack for Visual Studio Code

Open in Browser

  • 远程链接Linux

安装插件: Remote SSH

示例:

【1】安装好Remote - SSH之后 ,按F1打开输入对话框。

【2】输入remote-ssh

【3】ssh root@121.36.106.202

10.2、了解前端

  1. !tab

  2. 由标签构成:单标签,双标签

了解前端三大件:html , css , javascript(js)

html:是网页的骨骼 --- 负责网页结构

css: 网页的皮肉 --- 负责网页的美观

js:网页的灵魂 --- 负责动态效果,以及前后端交互

前端学习网站教程:http://www.w3school.com.cn

10.3、HTML网页代码基本实现

进入wwwroot/index.html进行代码书写

下面我们先介绍一下快捷键:

  • !+ Tab :会自动设计出html网页代码基本结构
  • h1 + Tab :会自动设计出h1标签
cpp 复制代码
<!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="https://code.jquery.com/jquery-3.6.0.min.js"></script>
    <title>Boost 库搜索引擎</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
 
        html,
        body {
            height: 100%;
            background: url('https://images.unsplash.com/photo-1517430816045-df4b7de6d0e6') no-repeat center center fixed;
            background-size: cover;
            font-family: Arial, sans-serif;
        }
 
        .container {
            width: 90%;
            max-width: 800px;
            margin: 50px auto;
            padding: 20px;
            background-color: rgba(255, 255, 255, 0.9);
            box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
            border-radius: 8px;
        }
 
        h1 {
            margin-bottom: 20px;
            font-size: 36px;
            color: #4e6ef2;
            text-align: center;
        }
 
        .search {
            display: flex;
            justify-content: center;
            position: relative;
        }
 
        .search input {
            flex: 1;
            height: 50px;
            border: 2px solid #ccc;
            padding-left: 10px;
            font-size: 17px;
            border-radius: 25px 0 0 25px;
            transition: border-color 0.3s;
        }
 
        .search input:focus {
            border-color: #4e6ef2;
            outline: none;
        }
 
        .search button {
            width: 160px;
            height: 50px;
            background-color: #4e6ef2;
            color: #fff;
            font-size: 19px;
            cursor: pointer;
            transition: background-color 0.3s;
            border: none;
            border-radius: 0 25px 25px 0;
        }
 
        .search button:hover {
            background-color: #3b5ec2;
        }
 
        .clear-btn {
            position: absolute;
            right: 170px;
            top: 50%;
            transform: translateY(-50%);
            cursor: pointer;
            font-size: 18px;
            display: none;
            color: #ccc;
        }
 
        .result {
            width: 100%;
            margin-top: 20px;
        }
 
        .result .item {
            margin-top: 15px;
            padding: 15px;
            background-color: #fff;
            border: 1px solid #ddd;
            border-radius: 5px;
            transition: box-shadow 0.3s;
            text-align: left;
        }
 
        .result .item:hover {
            box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
        }
 
        .result .item a {
            display: block;
            text-decoration: none;
            font-size: 22px;
            color: #4e6ef2;
            margin-bottom: 5px;
        }
 
        .result .item a:hover {
            text-decoration: underline;
        }
 
        .result .item p {
            font-size: 16px;
            color: #333;
            margin-bottom: 5px;
        }
 
        .result .item i {
            display: block;
            font-style: normal;
            color: green;
        }
 
        .loader {
            border: 4px solid #f3f3f3;
            border-top: 4px solid #4e6ef2;
            border-radius: 50%;
            width: 40px;
            height: 40px;
            animation: spin 2s linear infinite;
            display: none;
            margin: 20px auto;
        }
 
        @keyframes spin {
            0% {
                transform: rotate(0deg);
            }
 
            100% {
                transform: rotate(360deg);
            }
        }
 
        .error-message {
            color: red;
            text-align: center;
            margin-top: 20px;
        }
 
        .pagination {
            margin-top: 20px;
            display: flex;
            justify-content: center;
        }
 
        .pagination button {
            background-color: #4e6ef2;
            color: #fff;
            border: none;
            border-radius: 5px;
            padding: 10px 15px;
            margin: 0 5px;
            cursor: pointer;
            transition: background-color 0.3s;
        }
 
        .pagination button:hover {
            background-color: #3b5ec2;
        }
 
        .pagination button:disabled {
            background-color: #ccc;
            cursor: not-allowed;
        }
 
        .previous-searches {
            margin-top: 20px;
        }
 
        .previous-searches h2 {
            font-size: 20px;
            color: #4e6ef2;
            text-align: center;
            margin-bottom: 10px;
        }
 
        .previous-searches ul {
            list-style-type: none;
            text-align: center;
        }
 
        .previous-searches ul li {
            display: inline-block;
            margin: 5px 10px;
            padding: 5px 10px;
            background-color: #4e6ef2;
            color: #fff;
            border-radius: 5px;
            cursor: pointer;
            transition: background-color 0.3s;
        }
 
        .previous-searches ul li:hover {
            background-color: #3b5ec2;
        }
    </style>
</head>
 
<body>
    <div class="container">
        <h1>Boost库搜索引擎</h1>
        <div class="search">
            <input type="text" placeholder="输入搜索关键字..." id="searchInput">
            <span class="clear-btn" id="clearBtn">&times;</span>
            <button onclick="Search()">搜索一下</button>
        </div>
        <div class="loader" id="loader"></div>
        <div class="result" id="resultContainer"></div>
        <div class="pagination" id="paginationContainer"></div>
        <div class="error-message" id="errorMessage"></div>
        <div class="previous-searches" id="previousSearches">
            <h2>之前的搜索</h2>
            <ul id="previousSearchList"></ul>
        </div>
    </div>
    <script>
        let currentPage = 1;
        const resultsPerPage = 8;
        let allResults = [];
        let previousSearches = JSON.parse(localStorage.getItem('previousSearches')) || [];
 
        $(document).ready(function () {
            $("#searchInput").on("input", function () {
                if ($(this).val()) {
                    $("#clearBtn").show();
                } else {
                    $("#clearBtn").hide();
                }
            });
 
            $("#clearBtn").on("click", function () {
                $("#searchInput").val('');
                $(this).hide();
            });
 
            displayPreviousSearches();
        });
 
        function Search() {
            const query = $("#searchInput").val().trim();
            if (!query) {
                alert("请输入搜索关键字!");
                return;
            }
 
            if (!previousSearches.includes(query)) {
                if (previousSearches.length >= 5) {
                    previousSearches.shift();
                }
                previousSearches.push(query);
                localStorage.setItem('previousSearches', JSON.stringify(previousSearches));
            }
 
            $("#loader").show();
            $("#errorMessage").text('');
            $.ajax({
                type: "GET",
                url: "/s?word=" + query,
                success: function (data) {
                    $("#loader").hide();
                    allResults = data;
                    currentPage = 1;
                    displayResults();
                },
                error: function () {
                    $("#loader").hide();
                    $("#errorMessage").text('搜索失败,请稍后重试。');
                }
            });
        }
 
        function displayResults() {
            const resultContainer = $("#resultContainer");
            const paginationContainer = $("#paginationContainer");
            resultContainer.empty();
            paginationContainer.empty();
 
            const totalResults = allResults.length;
            const totalPages = Math.ceil(totalResults / resultsPerPage);
 
            if (totalResults === 0) {
                $("#errorMessage").text('没有搜索到相关的内容。');
                return;
            }
 
            const start = (currentPage - 1) * resultsPerPage;
            const end = Math.min(start + resultsPerPage, totalResults);
            const currentResults = allResults.slice(start, end);
 
            currentResults.forEach(elem => {
                const item = $(`
                    <div class="item">
                        <a href="${elem.url}" target="_blank">${elem.title}</a>
                        <p>${elem.desc}</p>
                        <i>${elem.url}</i>
                    </div>
                `);
                resultContainer.append(item);
            });
 
            displayPagination(totalPages);
            displayPreviousSearches();
        }
 
        function displayPagination(totalPages) {
            const paginationContainer = $("#paginationContainer");
 
            if (currentPage > 1) {
                const prevButton = $('<button>上一页</button>');
                prevButton.on('click', function () {
                    currentPage--;
                    displayResults();
                });
                paginationContainer.append(prevButton);
            }
 
            let startPage, endPage;
            if (totalPages <= 5) {
                startPage = 1;
                endPage = totalPages;
            } else {
                if (currentPage <= 3) {
                    startPage = 1;
                    endPage = 5;
                } else if (currentPage + 2 >= totalPages) {
                    startPage = totalPages - 4;
                    endPage = totalPages;
                } else {
                    startPage = currentPage - 2;
                    endPage = currentPage + 2;
                }
            }
 
            for (let i = startPage; i <= endPage; i++) {
                const button = $(`<button>${i}</button>`);
                if (i === currentPage) {
                    button.prop('disabled', true);
                }
                button.on('click', function () {
                    currentPage = i;
                    displayResults();
                });
                paginationContainer.append(button);
            }
 
            if (currentPage < totalPages) {
                const nextButton = $('<button>下一页</button>');
                nextButton.on('click', function () {
                    currentPage++;
                    displayResults();
                });
                paginationContainer.append(nextButton);
            }
        }
 
        function displayPreviousSearches() {
            const previousSearchList = $("#previousSearchList");
            previousSearchList.empty();
 
            previousSearches.forEach(search => {
                const item = $(`<li>${search}</li>`);
                item.on('click', function () {
                    $("#searchInput").val(search);
                    Search();
                });
                previousSearchList.append(item);
            });
        }
    </script>
</body>
 
</html>

十一、结项与项目扩展方向

关于项目总结,主要是针对项目的扩展

  1. 建立整站搜索
  2. 我们搜索的内容是在boost库下的doc目录下的html文档,你可以将这个库建立搜索,也可以将所有的版本,但是成本是很高的,对单个版本的整站搜索还是可以完成的,取决于你服务器的配置。
  3. 设计一个在线更新的方案,信号,爬虫,完成整个服务器的设计
  4. 我们在获取数据源的时候,是我们手动下载的,你可以学习一下爬虫,写个简单的爬虫程序。采用信号的方式去定期的爬取。
  5. 不使用组件,而是自己设计一下对应的各种方案
  6. 我们在编写http_server的时候,是使用的组件,你可以自己设计一个简单的;
  7. 在我们的搜索引擎中,添加竞价排名
  8. 我们在给用户反馈是,提供的是json串,显示到网页上,有title、content和url;就可以在构建json串时,你加上你的博客链接(将博客权重变高了,就能够显示在第一个)
  9. 热次统计,智能显示搜索关键词(字典树,优先级队列)
  10. 设置登陆注册,引入对mysql的使用

总结

好了,本篇博客到这里就结束了,如果有更好的观点,请及时留言,我会认真观看并学习。

不积硅步,无以至千里;不积小流,无以成江海。

相关推荐
soulteary几秒前
突破内存限制:Mac Mini M2 服务器化实践指南
运维·服务器·redis·macos·arm·pika
我们的五年8 分钟前
【Linux课程学习】:进程程序替换,execl,execv,execlp,execvp,execve,execle,execvpe函数
linux·c++·学习
爱吃青椒不爱吃西红柿‍️28 分钟前
华为ASP与CSP是什么?
服务器·前端·数据库
做人不要太理性35 分钟前
【C++】深入哈希表核心:从改造到封装,解锁 unordered_set 与 unordered_map 的终极奥义!
c++·哈希算法·散列表·unordered_map·unordered_set
程序员-King.44 分钟前
2、桥接模式
c++·桥接模式
chnming19871 小时前
STL关联式容器之map
开发语言·c++
程序伍六七1 小时前
day16
开发语言·c++
小陈phd1 小时前
Vscode LinuxC++环境配置
linux·c++·vscode
火山口车神丶2 小时前
某车企ASW面试笔试题
c++·matlab
hzyyyyyyyu2 小时前
内网安全隧道搭建-ngrok-frp-nps-sapp
服务器·网络·安全