<项目> 网页搜索引擎

目录

一、前置工作

1. boost库

2. cppjieba分词

3. 升级gcc/g++

4. 安装cpp-httplib

二、Util.hpp

三、Log.hpp

四、Parser.cc

1. 获取文件路径

2. 分离各html文件为三部分

获取title

获取content

获取url

3. 将结果保存在文档中

五、index.hpp

1. 构建索引

正排索引

倒排索引

六、searcher.hpp

1. 单例模式

2. 搜索功能

七、综合调试

八、http_server.cc

1. cpp-httplib库详细介绍

1. 安装与引入

2. 创建HTTP服务器

3. 设置静态文件目录

4. 定义路由处理函数

5. 获取请求参数

6. 设置响应内容

7. 启动服务器监听

8. 错误处理

总代码

九、Makefile

十、index.html

十一、演示

十二、拓展


 简要概述

  • 技术栈: C/C++ C++11, STL, 准标准库BoostJsoncppcppjiebacpp-httplib 
  • 选学: html5cssjsjQueryAjax
  • 项⽬环境: Centos 7云服务器,vim/gcc(g++)/Makefile , VS2019 or VS Code

一、前置工作

1. boost库

sudo yum install -y boost-devel

2. cppjieba分词

这里我们要去github上下载cppjieba库,但是一般情况下国内访问github的速度很慢,所以我们可以访问github的国内镜像网站 gitcode.net 网站(当然了,能访问github那也行)

git clone https://github.com/yanyiwu/cppjieba.git

我们使用的是include目录下的jieba.hpp头文件

注意:需要将limonp目录添加到include/cppjieba/路径下,这是jieba的一个小坑,因为Jieba.hpp需要包含limonp路径下的Logging.hpp文件。

这里我记着limonp已经被分离到另一个gitee项目理论,还要再git clone一下

最终 

jieba实际上有一些不合适,对于下划线组成的单词,他会进行拆分,还能拆分出来单个的下划线进行搜索,这就比较逆天了,一个文档中有n个下划线,所以后续我们会修补这个小问题。

最后建立软连接,我们把引入的第三方库都放在了 ./thirdpart 目录下 ,路径比较深,所以用软连接在项目中引入路径时比较方便

ln -s ./thirdpart/cppjieba/include/cppjieba/ cppjieba
ln -s thirdpart/cppjieba/dict/ dict

3. jsoncpp 

yum install -y jsoncpp-devel

4. 升级gcc/g++

云服务器上gcc默认版本为4.8.5,这是为了稳定而保留的低版本gcc,如果我们不升级为7以上的版本,那么在使用cpp-httplib时会出现编译错误或运行时报错,因为cpp-httplib内使用了c++17以上语法。

升级方法:使用scl

 scl(Software Collection)是一个用于安装和管理软件集合(Software Collections)的工具。它允许用户在不影响系统默认软件包的情况下安装多个版本的软件包及其依赖项。

SCL 的主要特点:

  • 多版本支持:可以在同一系统上安装和运行多个版本的相同软件包。

  • 隔离环境:通过 scl 安装的软件包不会干扰系统的默认软件包,也不会被系统的包管理器(如 yum 或 dnf)覆盖或更新。

  • 临时启用:可以通过命令行临时启用某个软件集合,而不需要永久修改系统的环境变量。

使用场景:

  • 开发环境:不同项目可能需要不同版本的编译器、解释器或其他工具,scl 可以帮助你轻松切换这些版本。

  • 测试环境:在不改变系统配置的情况下,测试新版本的软件包。

  • 生产环境:确保生产环境中使用的软件版本与开发和测试环境一致。

步骤 

  1. 安装scl
    sudo yum install centos-release-scl scl-utils-build
  2. 安装新版本gcc/g++
    sudo yum install -y devtoolset-7-gcc devtoolset-7-gcc-c++
  3. 启动(命令行启动只能在本回话有效)
    scl enable devtoolset-7 bash
  4. 可选,如果每次登录时都想使用较新的gcc,那么需要在 /.bash_profile中写入,这是因为每次shell启动后都会执行  /.bash_profile 脚本
    scl enable devtoolset-7 bash

5. 安装cpp-httplib

  cpp-httplib: C++ http 网络库 - Gitee.com

git clone https://gitee.com/yuanfeng1897/cpp-httplib.git

建立软连接,方便在代码中引入头文件时减少路径代码

ln -s ./thirdpart/cpp-httplib/ cpp-httplib

 例如,在文件中只需要引入

#include "cpp-httplib/httplib.h"

补充:什么时候用 < >,什么时候用 " " 

#include <jsoncpp/json/json.h>
#include <boost/filesystem.hpp>

#include "cpp-httplib/httplib.h"

很简单,如果我们在操作系统上yum install的那就是<>,因为yum默认将头文件安装在了 /lib/include路径下,将库函数安装在/lib/库名路径下,所以只要在编译时指明各路径,编译器就可以找到

“ ” 是我们 git clone 的,没有安装在OS的默认路径下,所以用“ ”

二、Util.hpp

工具集

  1. 封装ifstream只读文件操作
  2. 封装字符串切分函数boost::split
  3. 封装jieba分词函数,由于分词结果中会含有暂停词(例如,a、the、下划线等无意义又大量存在的词),这会影响搜索结果,所以我们在jieba分词后,需要用hashmap也去掉暂停词。并且需要设计为单例模式,因为该类对象只需要一个即可,初始化一次后,其他进程或线程都可以使用。
#pragma once
#include <iostream>
#include <string>
#include <vector>
#include <mutex>
#include <unordered_set>
#include <fstream>
#include <boost/algorithm/string.hpp>
#include "cppjieba/Jieba.hpp"
#include "Log.hpp"

// 工具集
namespace ns_util
{
    // 功能:逐行读取文件
    class FileUtil
    {
        public:
            static bool ReadFile(const std::string& file_path, std::string* content)
            {
                // 打开文件
                std::ifstream in(file_path, std::ios::in);
                if (!in.is_open())
                {
                    // std::cout << "open file " << file_path << " error" << std::endl;

                    _log(Warning, "open file %s error\n", file_path.c_str());
                    return false;
                }
                // 读取文件
                std::string line;
                // getline返回的是istream&类型,而istream类重载了operator bool(),所以getline的返回值会转为bool类型,进而判断是否读取成功
                while (std::getline(in, line))
                {
                    *content += line;
                }
                // 关闭文件
                in.close();

                return true;
            }
    };

    // 功能:字符串分割
    class StringUtil
    {
        public:
            static void Split(const std::string& line, std::vector<std::string>* out, const std::string& sep)
            {
                // 可以用字符串的find函数进行字符串切分
                // 我们这里直接用现成的 boost::split 进行字符串切分,写项目用现成的就行
                boost::split(*out, line, boost::is_any_of(sep), boost::token_compress_on);
            }
    };
    
    // 功能:jieba分词
    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;

            cppjieba::Jieba jieba;
            std::unordered_set<std::string> stop_words;
            static std::mutex _mutex;

            // 单例模式
            static JiebaUtil* instance;

        private:
            JiebaUtil() : jieba(DICT_PATH, HMM_PATH, USER_DICT_PATH, IDF_PATH, STOP_WORD_PATH)
            {}
            JiebaUtil(const JiebaUtil&) = delete;
            JiebaUtil& operator=(const JiebaUtil&) = delete;
        public:
            // 打开暂停词文件并insert到stops_words
            void InitJiebaUtil()
            {
                std::ifstream in(STOP_WORD_PATH, std::ios::in);
                if (!in.is_open())
                {
                    _log(Fatal, "open file %s error\n", STOP_WORD_PATH);
                    return;
                }
                std::string line;
                while (std::getline(in, line))
                {
                    stop_words.insert(line);
                }
                in.close();
            }

            // 获取单例对象
            static JiebaUtil* GetInstance()
            {
                if (instance == nullptr)
                {
                    _mutex.lock();
                    if (instance == nullptr)
                    {
                        instance = new JiebaUtil();
                        instance->InitJiebaUtil();  // 直接在这里初始化
                    }
                    _mutex.unlock();
                }
                return instance;
            }

            void CutStringHelper(const std::string& src, std::vector<std::string>* out)
            {
                // 分词
                jieba.CutForSearch(src, *out);
                // 遍历删除停用词,注意迭代器失效问题
                for (auto iter = out->begin(); iter != out->end();)
                {
                    if (stop_words.find(*iter) != stop_words.end())
                        iter = out->erase(iter);
                    else
                        ++iter;
                }
            }

            // 为减少其他文件的修改,我们回调新的函数
            static void CutString(const std::string& src, std::vector<std::string>* out)
            {
                ns_util::JiebaUtil::GetInstance()->CutStringHelper(src, out);
            }
    };

    // static成员类外定义
    std::mutex ns_util::JiebaUtil::_mutex;
    JiebaUtil* ns_util::JiebaUtil::instance = nullptr;

    // cppjieba::Jieba JiebaUtil::jieba(DICT_PATH, HMM_PATH, USER_DICT_PATH, IDF_PATH, STOP_WORD_PATH);
}

 jieba分词会出现暂停词,我们需要去除掉。

三、Log.hpp

向项目中引入日志,在各个需要cout、cerr的地方替换为日志。创建log目录,如果日志向文件中输出,则默认生成的文件会放在log目录下。

#pragma once

#include <iostream>
#include <time.h>
#include <stdarg.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>

#define SIZE 1024

//日志等级
#define Info 0
#define Debug 1
#define Warning 2
// #define Error 3
#define Fatal 4

//日志的输出方向
#define Screen 1        //显示器打印
#define Onefile 2       //向单文件打印
#define Classfile 3     //向多文件分类别打印

#define LogFile "log.txt"   //单文件打印时的文件名

class Log
{
public:
    Log() : printMethod(Screen), path("./log/") 
    {}

    //获取打印方式
    void Enable(int method)
    {
        printMethod = method;
    }

    //获取日志等级,并返回相应的string字符串
    std::string levelToString(int level)
    {
        switch(level)
        {
            case Info:
                return "Info";
            case Debug:
                return "Debug";
            case Warning:
                return "Warning";
            // case Error:
            //     return "Error";
            case Fatal:
                return "Fatal";
            default:
                return "None";
        }
    }

    //使用可变参数,并将该函数运算符重载为匿名函数,这就可以在类外直接使用对象(),调用该函数
    void operator()(int level, const char* format, ...)
    {
        //获取实时时间
        time_t t = time(nullptr);
        struct tm* ctime = localtime(&t);

        //向leftbuffer数组中,使用 snprintf 输入:日志等级 + 实时时间
        char leftbuffer[SIZE];
        snprintf(leftbuffer, sizeof(leftbuffer), "[%s][%d-%d-%d %d:%d:%d]", levelToString(level).c_str(),
        ctime->tm_year + 1900, ctime->tm_mon + 1, ctime->tm_mday,
        ctime->tm_hour, ctime->tm_min, ctime->tm_sec);

        //向rightbuffer数组中,使用 vsnprintf 输入可变参数信息
        va_list s;
        va_start(s, format);
        char rightbuffer[SIZE];
        vsnprintf(rightbuffer, sizeof(rightbuffer), format, s);
        va_end(s);

        //最终格式:默认部分(日志等级 + 实时时间) + 自定义部分
        char logtxt[SIZE*2];
        snprintf(logtxt, sizeof(logtxt), "%s %s\n", leftbuffer, rightbuffer);

        //调用函数,将日志分方向输出
        printlog(level, logtxt);
    }

    void printlog(int level, const std::string& logtxt)
    {
        switch (printMethod)
        {
            case Screen:
                std::cout << logtxt /*<< std::endl*/;
                break;
            case Onefile:
                printOneFile(LogFile, logtxt);  //单文件输出时,给出指定的文件名
                break;
            case Classfile:
                printClassFile(level, logtxt);  //因为要分类别向文件输出,所以要传入日志等级
                break;
            default:
                break;
        }
    }

    //日志向单文件输出
    void printOneFile(const std::string& logname, const std::string& logtxt)
    {
        //加上前置的路径,可以将所有日志文件放在我们指定的log目录下
        std::string _logname = path + logname;

        //打开文件,只写、没有就创建、追加写,默认权限0666(umask掩码默认为0002)
        int fd = open(_logname.c_str(), O_WRONLY|O_CREAT|O_APPEND, 0666);
        if (fd < 0)
            return;
        
        //写入日志信息
        write(fd, logtxt.c_str(), logtxt.size());
        close(fd);
    }

    //日志向多文件分类别输出
    void printClassFile(int level, const std::string& logtxt)
    {
        //整理文件名
        std::string filename = LogFile;
        filename += "." + levelToString(level);     //log.txt.Debug、log.txt.Warning

        //复用单文件输出
        printOneFile(filename, logtxt);
    }

    ~Log()
    {}

private:
    int printMethod;    //打印方式:屏幕、单文件、多文件
    std::string path;   //打印文件所在路径
};

Log _log;

四、Parser.cc

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

只需要boost_1_87_0/doc/html⽬录下的html⽂件(在官网下载1.8.7版本的压缩包,放到服务器上解压到./data/input目录下),⽤它来进⾏建⽴索引,但是有一部分遗漏的html文件,可以后续再添加进来(拓展)

思路总览

int main()
{
    std::vector<std::string> files_list;
    // 第一步:将文件路径枚举并保存在files_list中
    if (!EnumFile(input, &files_list))
    {
        // std::cerr << "enum files name error" << std::endl;
        _log(Warning, "enum files name error\n");
        return 1;
    }

    // 第二步:将各个html文件去标签并分割为DocInfo格式,保存在results容器中
    std::vector<DocInfo_t> results;
    if (!ParseHtml(files_list, &results))
    {
        // std::cerr << "parse html error" << std::endl;
        _log(Warning, "parse html error\n");
        return 2;
    }

    // 第三步:将各文档的信息写入到output文件中,各文档间以\3作为分隔符
    if (!SaveHtml(results, output))
    {
        // std::cerr << "save html error" << std::endl;
        _log(Warning, "save html error\n");
        return 3;
    }

    return 0;
}

1. 获取文件路径

  1. 使用boost库的filesystem域内的path类、recursive_directory_iterator类(用来遍历一个路径下的各个目录、文件的封装好的具有递归功能的函数集合)
  2. 因为 ./data/input 路径下的由目录也有文件,也有可能不是html文件,所以需要判断一下,用fs::is_regular_file函数判断是否为目录文件,用iter->path().extension()获取文件后缀是否为 .html
bool EnumFile(const std::string& input, std::vector<std::string>* files_path)
{
    // boost::filesystem命名空间取个简称fs
    namespace fs = boost::filesystem;
    fs::path root_path(input);

    // 输入目录是否存在?
    if (!fs::exists(root_path))
    {
        std::cerr << input << " no exist!" << std::endl;
        _log(Warning, "%s no exist!\n", input.c_str());
        return false;
    }

    // 定义一个end迭代器来控制循环结束,recursive_directory_iterator是递归目录迭代器
    fs::recursive_directory_iterator end;
    for (fs::recursive_directory_iterator iter(root_path); iter != end; ++iter)
    {
        // 是否为普通文件?因为还有目录文件,所以需要判断
        if (!fs::is_regular_file(*iter))
        {
            continue;
        }
        // 文件后缀是否是html? 
        // iter->path:先获取directory_entry 对象,再调用path()获取path对象,再对path对象调用extension()获取文件路径后缀
        if (iter->path().extension() != ".html")
        {
            continue;
        }
        // 执行到此处,该文件就是html文件
        // std::cout << iter->path().string() << std::endl;

        // push到files_path中
        files_path->push_back(iter->path().string());
        
    }
    return true;
}

2. 分离各html文件为三部分

定义DocInfo结构体,包含html文件的title、content、url这三个信息,这是因为我们是模仿例如百度这样的搜索引擎,他的搜索结果的就是这三部分构成的

// 文档去标签后应分割为3个部分:标题、内容、url
typedef struct DocInfo 
{
    std::string title;    // 文档标题
    std::string content;  // 文档内容
    std::string url;      // 该文档的url
}DocInfo_t;

思路总览:

bool ParseHtml(const std::vector<std::string>& files_list, std::vector<DocInfo_t>* results)
{
    for (const std::string& file_path : files_list)
    {    
        // 1. 读取文件内容
        std::string result;
        if (!ns_util::FileUtil::ReadFile(file_path, &result))
            continue;
        // 2. 根据result,获取title
        DocInfo_t doc;
        if (!GetTitle(result, &doc.title))
            continue;
        // 3. 根据result,获取内容
        if (!GetContent(result, &doc.content))
            continue;
        // 4. 根据result,获取url
        if (!GetUrl(file_path, &doc.url))
            continue;

        // move 操作(左值转右值)将临时变量doc移动到results容器中,避免大量拷贝
        results->push_back(std::move(doc)); 

        // ShowDoc(doc);
        // break;
    }
    re
获取title

直接用string容器自带的find函数找<title> 和 </title> 标签之间夹着的内容即可

static bool GetTitle(const std::string& file, std::string* title)
{
    // 获取标题,即<title>...</title>内的内容
    size_t begin = file.find("<title>");
    if (begin == std::string::npos)
        return false;

    size_t end = file.find("</title>");
    if (end == std::string::npos)
        return false;

    // begin += titile的长度
    begin += std::string("<title>").size();
    if (begin < end)
    {
        *title = file.substr(begin, end - begin);   // 添加到title中
    }
    return true;
}
获取content

这里我们枚举两种状态:

  • 标签状态
  • 非标签状态(内容)

用switch case语句,对应两种情况: 

  • 在标签状态时,判断当前遍历到的字符是否为 '>',如果是该字符就表明标签结束了,我们认为下一个字符就是非标签,修改状态
  • 在非标签状态时,判断当前遍历到的字符是否为 '<',如果是该字符就表明内容结束了,我们认为下一个字符就是标签,修改状态。
  • 对于非标签状态,也就是content,我们不保留\n,因为\n我们会在后面作为文档之间的分隔符
static bool GetContent(const std::string& file, std::string* title)
{
    // 获取内容实际上就是去标签,对于标签和内容,这是两种状态,所以枚举两种状态
    enum status
    {
        LABLE,  // 标签状态
        CONTENT // 内容状态
    };
    // 一个html文件初始状态必为LABLE
    enum status s = LABLE;
    // 遍历文件,如果是content状态那么就添加到title中,否则不添加
    for (char c : file)
    {
        switch (s)
        {
            case LABLE:
                if (c == '>') s = CONTENT;  // 遇到>,则回到CONTENT状态
                break;
            case CONTENT:
                if (c == '<') s = LABLE;    // 遇到<,则回到LABLE状态
                else 
                {
                    // 不保留换行符,因为我们要保留换行符作为文档之间的分隔符
                    if (c == '\n') c = ' ';
                    *title += c;
                }
                break;
            default:
                break;
        }
    }
    return true;
}
获取url

我们观察boost官网的url可以发现他们拥有共同的前缀,后面则是各自的html文档名,我们也没有修改html文档名,所以直接拼接共同前缀+html文档名,即可访问对应boost官网的html文档。

因为我们已经在获取文件路径一步获取到了各个文档的路径,即“data/input/XXX.html”,所以substr(input.size(), string::npos)即可截取到html文档的名称。

最后拼接写入到输出型参数url中即可返回true

// 输入输出文件的路径
const std::string input = "data/input";
const std::string output = "data/raw_html/raw.txt";

static bool GetUrl(const std::string& file_path, std::string* url)
{
    std::string url_head = "https://www.boost.org/doc/libs/1_87_0/doc/html";
    std::string url_tail = file_path.substr(input.size());
    *url = url_head + url_tail;
    return true;
}

3. 将结果保存在文档中

将所有html处理后的结构体信息,都构建为一个string,各个string之间按\n作为分隔符,也就是文档的每一行就是一个去标签后的html文档的内容

各文档的title、content、url之间我们用\3作为分隔符,所以将这些信息写入 ./data/raw_html/raw.txt文件时需要按二进制写入,避免分隔符被转为字母

bool SaveHtml(const std::vector<DocInfo_t>& results, const std::string& output)
{
#define SEP "\3"
    // 二进制写入,因为SEP分隔符可能会被转为字母
    std::ofstream out(output, std::ios::out | std::ios::binary);
    for (const DocInfo_t& doc : results)
    {
        // 文档内以SEP作为分隔符,文档间以\n作为分隔符
        // title\3content\3url \n title\3content\3url \n ... 
        std::string line;
        line += doc.title + SEP + doc.content + SEP + doc.url + "\n";   
        out.write(line.c_str(), line.size());
    }
    return true;
}

总代码

#include <iostream>
#include <string>
#include <vector>
#include <boost/filesystem.hpp>
#include "util.hpp"
#include "Log.hpp"

// 输入输出文件的路径
const std::string input = "data/input";
const std::string output = "data/raw_html/raw.txt";

// 文档去标签后应分割为3个部分:标题、内容、url
typedef struct DocInfo 
{
    std::string title;    // 文档标题
    std::string content;  // 文档内容
    std::string url;      // 该文档的url
}DocInfo_t;

// 函数声明
bool EnumFile(const std::string& input, std::vector<std::string>* files_path);
bool ParseHtml(const std::vector<std::string>& files_list, std::vector<DocInfo_t>* results);
bool SaveHtml(const std::vector<DocInfo_t>& results, const std::string& output);

int main()
{
    std::vector<std::string> files_list;
    // 第一步:将文件路径枚举并保存在files_list中
    if (!EnumFile(input, &files_list))
    {
        // std::cerr << "enum files name error" << std::endl;
        _log(Warning, "enum files name error\n");
        return 1;
    }

    // 第二步:将各个html文件去标签并分割为DocInfo格式,保存在results容器中
    std::vector<DocInfo_t> results;
    if (!ParseHtml(files_list, &results))
    {
        // std::cerr << "parse html error" << std::endl;
        _log(Warning, "parse html error\n");
        return 2;
    }

    // 第三步:将各文档的信息写入到output文件中,各文档间以\3作为分隔符
    if (!SaveHtml(results, output))
    {
        // std::cerr << "save html error" << std::endl;
        _log(Warning, "save html error\n");
        return 3;
    }

    return 0;
}


bool EnumFile(const std::string& input, std::vector<std::string>* files_path)
{
    // boost::filesystem命名空间取个简称fs
    namespace fs = boost::filesystem;
    fs::path root_path(input);

    // 输入目录是否存在?
    if (!fs::exists(root_path))
    {
        std::cerr << input << " no exist!" << std::endl;
        _log(Warning, "%s no exist!\n", input.c_str());
        return false;
    }

    // 定义一个end迭代器来控制循环结束,recursive_directory_iterator是递归目录迭代器
    fs::recursive_directory_iterator end;
    for (fs::recursive_directory_iterator iter(root_path); iter != end; ++iter)
    {
        // 是否为普通文件?因为还有目录文件,所以需要判断
        if (!fs::is_regular_file(*iter))
        {
            continue;
        }
        // 文件后缀是否是html? 
        // iter->path:先获取directory_entry 对象,再调用path()获取path对象,再对path对象调用extension()获取文件路径后缀
        if (iter->path().extension() != ".html")
        {
            continue;
        }
        // 执行到此处,该文件就是html文件
        // std::cout << iter->path().string() << std::endl;

        // push到files_path中
        files_path->push_back(iter->path().string());
        
    }
    return true;
}

static bool GetTitle(const std::string& file, std::string* title)
{
    // 获取标题,即<title>...</title>内的内容
    size_t begin = file.find("<title>");
    if (begin == std::string::npos)
        return false;

    size_t end = file.find("</title>");
    if (end == std::string::npos)
        return false;

    // begin += titile的长度
    begin += std::string("<title>").size();
    if (begin < end)
    {
        *title = file.substr(begin, end - begin);   // 添加到title中
    }
    return true;
}

static bool GetContent(const std::string& file, std::string* title)
{
    // 获取内容实际上就是去标签,对于标签和内容,这是两种状态,所以枚举两种状态
    enum status
    {
        LABLE,  // 标签状态
        CONTENT // 内容状态
    };
    // 一个html文件初始状态必为LABLE
    enum status s = LABLE;
    // 遍历文件,如果是content状态那么就添加到title中,否则不添加
    for (char c : file)
    {
        switch (s)
        {
            case LABLE:
                if (c == '>') s = CONTENT;  // 遇到>,则回到CONTENT状态
                break;
            case CONTENT:
                if (c == '<') s = LABLE;    // 遇到<,则回到LABLE状态
                else 
                {
                    // 不保留换行符,因为我们要保留换行符作为文档之间的分隔符
                    if (c == '\n') c = ' ';
                    *title += c;
                }
                break;
            default:
                break;
        }
    }
    return true;
}

static bool GetUrl(const std::string& file_path, std::string* url)
{
    std::string url_head = "https://www.boost.org/doc/libs/1_87_0/doc/html";
    std::string url_tail = file_path.substr(input.size());
    *url = url_head + url_tail;
    return true;
}

// 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;
}
bool ParseHtml(const std::vector<std::string>& files_list, std::vector<DocInfo_t>* results)
{
    for (const std::string& file_path : files_list)
    {    
        // 1. 读取文件内容
        std::string result;
        if (!ns_util::FileUtil::ReadFile(file_path, &result))
            continue;
        // 2. 根据result,获取title
        DocInfo_t doc;
        if (!GetTitle(result, &doc.title))
            continue;
        // 3. 根据result,获取内容
        if (!GetContent(result, &doc.content))
            continue;
        // 4. 根据result,获取url
        if (!GetUrl(file_path, &doc.url))
            continue;

        // move 操作(左值转右值)将临时变量doc移动到results容器中,避免大量拷贝
        results->push_back(std::move(doc)); 

        // ShowDoc(doc);
        // break;
    }
    return true;
}
bool SaveHtml(const std::vector<DocInfo_t>& results, const std::string& output)
{
#define SEP "\3"
    // 二进制写入,因为SEP分隔符可能会被转为字母
    std::ofstream out(output, std::ios::out | std::ios::binary);
    for (const DocInfo_t& doc : results)
    {
        // 文档内以SEP作为分隔符,文档间以\n作为分隔符
        // title\3content\3url \n title\3content\3url \n ... 
        std::string line;
        line += doc.title + SEP + doc.content + SEP + doc.url + "\n";   
        out.write(line.c_str(), line.size());
    }
    return true;
}

五、index.hpp

1. 构建索引

遍历我们处理后保存在的raw.txt的文档,对每一个文档逐一的进行建立正排、倒排索引。

  • 正排索引:就是根据⽂档ID找到⽂档内容(⽂档内的关键字)
  • 倒排索引:根据关键词,找到对应的各个⽂档ID的⽅案

模拟⼀次查找的过程:

⽤⼾输⼊:⼩⽶ -> 倒排索引中查找 -> 提取出⽂档ID(1,2) -> 根据正排索引 -> 找到⽂档的内容 ->

title+conent(desc)+url ⽂档结果进⾏摘要->构建响应结果

思路总览:

  • ifstream读入raw.txt文档,逐行读入到line中,传参到正排索引构建函数中,该函数返回构建后的文档DocInfo,然后倒排索引构建函数根据DocInfo构建倒排索引
  • index类我们应设计为单例模式,因为该类型对象太大了,很占内存,并且构建一次之后,所有进程、线程都可以获取使用,更加方便。单例模式需要私有构造函数、delete拷贝构造、赋值运算符重载、定义static的instance指针、static的mutex互斥锁
    // 需要实现线程安全的单例模式,并完成正排索引、倒排索引的建立
    class index
    {
        private:
            // 用数组建立正排索引,我们可以用下标一一映射文档id
            std::vector<DocInfo> forward_index;
            // 用哈希表实现关键字映射文档id,建立倒排索引
            std::unordered_map<std::string, InverterList> inverted_index;
            static index* instance;     // 单例对象指针,static对象类外定义
            static std::mutex mtx;      // 线程锁,互斥的获取单例对象
        private:
            // 构造函数不能delete,还要new创建对象呢
            index() {}
            index(const index&) = delete;
            index& operator=(const index&) = delete;
        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 input)
            {
                // 读入parse后的文件
                std::ifstream in(input, std::ios::in);
                if (!in.is_open())
                {
                    // std::cerr << input << " open error" << std::endl;
                    _log(Warning, "%s open error\n", input.c_str());
                    return false;
                }

                // 逐行读取
                int cnt = 0;
                std::string line;
                while (getline(in, line))
                {
                    // 建立正排索引,forward_index的下标到DocInfo的映射
                    DocInfo* doc = BuildForwardIndex(line);
                    if (doc == nullptr)
                    {
                        // std::cerr << "one BuildForwardIndex error!" << std::endl;
                        _log(Warning, "one BuildForwardIndex error!\n");
                        return false;
                    }

                    // 建立倒排索引,关键词到倒排拉链InvertedList的映射
                    BuildInvertedIndex(*doc);

                    ++cnt;
                    // if (cnt % 50 == 0)
                        _log(Info, "当前已建立索引文档个数:%d\n", cnt);
                        // std::cout << "当前已建立索引文档个数:" << cnt << std::endl;
                }
                _log(Info, "最终建立索引文档个数:%d\n", cnt);
                // std::cout << "最终建立索引文档个数:" << cnt << std::endl;
                return true;
            }
    }
正排索引

逐行读入 ./data/raw_html/raw.txt 文件,放到line中,分离出三个成员title、content、url构建结构体,放到正排索引vector中,以在vector中的下标作为文档id,进行映射。

// 正排索引实现
DocInfo* BuildForwardIndex(const std::string& line)
{
    // 1. 解析line放到DocInfo中
    std::vector<std::string> results;
    const std::string sep = "\3";
    // 1.1 分割字符串到results中
    ns_util::StringUtil::Split(line, &results, sep);
    // 1.2 判断是否是3个字段,即title、content、url
    if (results.size() != 3)
    {
        return nullptr;
    }

    // 2. 填充到DocInfo中
    DocInfo doc;
    doc.title = results[0];     // 填充title
    doc.content = results[1];   // 填充content
    doc.url = results[2];       // 填充url
    doc.doc_id = forward_index.size();  // 插入前,文档id就是当前forward_index.size()
    
    // 3. 插入到forward_index中,将doc move为右值
    forward_index.push_back(std::move(doc));

    return &forward_index.back();
}

倒排索引
  1. 每一次根据正排索引获取的结构体数据,构建倒排索引。
  2. 将title进行jieba分词,构造词频统计的hashmap(std::unordered_map<std::string, word_cnt> word_map)统计一个文档内所有分词的权重信息,即该分词出现在title、content的情况。
  3. 将所有分词结果转为小写后insert到word_map中,这是为了后期用户输入的关键词可以无视大小写进行查询,例如搜索“HELlo”,可以查询到关键词“hello”、“Hello”等字符串。
  4. 将content进行jieba分词,统计该分词出现在content的情况。
  5. 最后遍历word_map,将first作为倒排索引map的first,将second构造为InverterElem结构体内的weight,构建出一个InverterElem并push_back到倒排索引的second中(是一个vector,即倒排拉链)
// 倒排索引实现
bool BuildInvertedIndex(const DocInfo& doc)
{
    // 形参doc:由正排索引返回的doc,里面有title、content、url、doc_id
    // 1.0 词频结构体,根据一个词在文档中的title、content出现的次数来评价该词的在文档中的权重
    struct word_cnt
    {
        int title_cnt;
        int content_cnt;
        word_cnt(): title_cnt(0), content_cnt(0) 
        {}
    };
    // 2.0 建立词频统计hashmap,统计一个文档内所有分词的权重信息
    std::unordered_map<std::string, word_cnt> word_map;

    // 2.1 对title分词,并统计词频
    // 忽略文档分词后的大小写,即helloWORLD、helloworld都算作同一个词
    std::vector<std::string> title_words;
    ns_util::JiebaUtil::CutString(doc.title, &title_words);

    for (std::string s : title_words)
    {
        boost::to_lower(s);
        word_map[s].title_cnt++;
    }

    // if (doc.doc_id == 1572)
    // {
    //     for (auto& e : title_words)
    //     {
    //         std::cout << "title: " << e << std::endl;
    //     }
    // }

    // 对content分词,并统计词频
    std::vector<std::string> content_words;
    ns_util::JiebaUtil::CutString(doc.content, &content_words);

    for (std::string s : content_words)
    {
        boost::to_lower(s);
        word_map[s].content_cnt++;
    }

    // if (doc.doc_id == 1572)
    // {
    //     for (auto& e : content_words)
    //     {
    //         std::cout << "contnet: " << e << std::endl;
    //     }
    // }

    // 3.0 建立倒排索引,关键词到倒排拉链InvertedList的映射
    for (const auto& word_pair : word_map)
    {
        // 因为我们是按文档逐个进行建立倒排索引的,所以此时分词对应的doc_id都是doc.doc_id
        InverterElem elem;
        elem.doc_id = doc.doc_id;
        elem.word = word_pair.first;
#define TITLE_WEIGHT 10
#define CONTENT_WEIGHT 1
        // 这里的权重,我们就粗略的认为title权重是10,content权重是1
        elem.weight = TITLE_WEIGHT*word_pair.second.title_cnt + CONTENT_WEIGHT*word_pair.second.content_cnt;
        // 由于map、hashmap的[]的特性,key不存在则会自动插入key,此时val为相应的默认值,并返回val的引用,如果key存在则返回对应val的引用
        // 如果此时插入的分词是hello,文档id为1,在2文档中也出现了hello分词,那么在插入倒排索引时,key相同,inverted_index[key]就会对应相同的倒排拉链,即hello关键词对应了文档1、2
        inverted_index[word_pair.first].push_back(std::move(elem));
    }

    return true;
}

push的时候可以使用std::move转为右值(移动构造),更方便拷贝,提高整体效率。

获取正排索引

根据参数传来的doc_id,返回对应文档的信息(title、content、url)

// 正排索引:根据doc_id获取对应的DocInfo
DocInfo* GetForwardIndex(uint64_t doc_id)
{
    if (doc_id >= forward_index.size())
    {
        // std::cerr << "doc_id out range, error!" << std::endl;
        _log(Warning, "doc_id out range, error!\n");
        return nullptr;
    }
    return &forward_index[doc_id];
}

获取倒排索引

根据关键字获取对应的InverterList(对应的文档id的vector容器,倒排拉链)

// 倒排索引:根据关键字获取对应的InverterList(对应的文档id的vector容器)
InverterList* GetInvertedList(const std::string& word)
{
    auto iter = inverted_index.find(word);
    if (iter == inverted_index.end())
    {
        // std::cerr << word << " have not in inverted_index!" << std::endl;
        _log(Warning, "%s have not in inverted_index!\n", word.c_str());
        return nullptr;
    }
    return &inverted_index[word];
}

总代码

#pragma once
#include <iostream>
#include <string>
#include <vector>
#include <unordered_map>
#include <fstream>
#include <mutex>    // 单例模式需要加锁
#include "util.hpp"
#include "Log.hpp"

// 功能:建立索引
namespace ns_index
{
    struct DocInfo
    {
        std::string title;  // 文档的标题
        std::string content;// 文档去标签后的内容
        std::string url;    // 文档的官网url
        uint64_t doc_id;    // 文档id
    };

    struct InverterElem
    {
        uint64_t doc_id;    // 关键字对应的文档id 
        std::string word;   // 关键字
        int weight;         // 文档权重,以确定文档排列顺序
    };
    // 重命名为InverterList
    typedef std::vector<InverterElem> InverterList;

    // 需要实现线程安全的单例模式,并完成正排索引、倒排索引的建立
    class index
    {
        private:
            // 用数组建立正排索引,我们可以用下标一一映射文档id
            std::vector<DocInfo> forward_index;
            // 用哈希表实现关键字映射文档id,建立倒排索引
            std::unordered_map<std::string, InverterList> inverted_index;
            static index* instance;     // 单例对象指针,static对象类外定义
            static std::mutex mtx;      // 线程锁,互斥的获取单例对象
        private:
            // 构造函数不能delete,还要new创建对象呢
            index() {}
            index(const index&) = delete;
            index& operator=(const index&) = delete;
        public:
            ~index()
            {}
        public:
            static index* GetInstance()
            {
                if (instance == nullptr)
                {
                    mtx.lock();
                    if (instance == nullptr)
                    {
                        instance = new index;
                    }
                    mtx.unlock();
                }
                return instance;
            }

            // 正排索引:根据doc_id获取对应的DocInfo
            DocInfo* GetForwardIndex(uint64_t doc_id)
            {
                if (doc_id >= forward_index.size())
                {
                    // std::cerr << "doc_id out range, error!" << std::endl;
                    _log(Warning, "doc_id out range, error!\n");
                    return nullptr;
                }
                return &forward_index[doc_id];
            }
            
            // 倒排索引:根据关键字获取对应的InverterList(对应的文档id的vector容器)
            InverterList* GetInvertedList(const std::string& word)
            {
                auto iter = inverted_index.find(word);
                if (iter == inverted_index.end())
                {
                    // std::cerr << word << " have not in inverted_index!" << std::endl;
                    _log(Warning, "%s have not in inverted_index!\n", word.c_str());
                    return nullptr;
                }
                return &inverted_index[word];
            }

            // 建立索引
            bool BuildIndex(const std::string input)
            {
                // 读入parse后的文件
                std::ifstream in(input, std::ios::in);
                if (!in.is_open())
                {
                    // std::cerr << input << " open error" << std::endl;
                    _log(Warning, "%s open error\n", input.c_str());
                    return false;
                }

                // 逐行读取
                int cnt = 0;
                std::string line;
                while (getline(in, line))
                {
                    // 建立正排索引,forward_index的下标到DocInfo的映射
                    DocInfo* doc = BuildForwardIndex(line);
                    if (doc == nullptr)
                    {
                        // std::cerr << "one BuildForwardIndex error!" << std::endl;
                        _log(Warning, "one BuildForwardIndex error!\n");
                        return false;
                    }

                    // 建立倒排索引,关键词到倒排拉链InvertedList的映射
                    BuildInvertedIndex(*doc);

                    ++cnt;
                    // if (cnt % 50 == 0)
                        _log(Info, "当前已建立索引文档个数:%d\n", cnt);
                        // std::cout << "当前已建立索引文档个数:" << cnt << std::endl;
                }
                _log(Info, "最终建立索引文档个数:%d\n", cnt);
                // std::cout << "最终建立索引文档个数:" << cnt << std::endl;
                return true;
            }
        private:
            // 正排索引实现
            DocInfo* BuildForwardIndex(const std::string& line)
            {
                // 1. 解析line放到DocInfo中
                std::vector<std::string> results;
                const std::string sep = "\3";
                // 1.1 分割字符串到results中
                ns_util::StringUtil::Split(line, &results, sep);
                // 1.2 判断是否是3个字段,即title、content、url
                if (results.size() != 3)
                {
                    return nullptr;
                }

                // 2. 填充到DocInfo中
                DocInfo doc;
                doc.title = results[0];     // 填充title
                doc.content = results[1];   // 填充content
                doc.url = results[2];       // 填充url
                doc.doc_id = forward_index.size();  // 插入前,文档id就是当前forward_index.size()
                
                // 3. 插入到forward_index中,将doc move为右值
                forward_index.push_back(std::move(doc));

                return &forward_index.back();
            }

            // 倒排索引实现
            bool BuildInvertedIndex(const DocInfo& doc)
            {
                // 形参doc:由正排索引返回的doc,里面有title、content、url、doc_id
                // 1.0 词频结构体,根据一个词在文档中的title、content出现的次数来评价该词的在文档中的权重
                struct word_cnt
                {
                    int title_cnt;
                    int content_cnt;
                    word_cnt(): title_cnt(0), content_cnt(0) 
                    {}
                };
                // 2.0 建立词频统计hashmap,统计一个文档内所有分词的权重信息
                std::unordered_map<std::string, word_cnt> word_map;

                // 2.1 对title分词,并统计词频
                // 忽略文档分词后的大小写,即helloWORLD、helloworld都算作同一个词
                std::vector<std::string> title_words;
                ns_util::JiebaUtil::CutString(doc.title, &title_words);

                for (std::string s : title_words)
                {
                    boost::to_lower(s);
                    word_map[s].title_cnt++;
                }

                // if (doc.doc_id == 1572)
                // {
                //     for (auto& e : title_words)
                //     {
                //         std::cout << "title: " << e << std::endl;
                //     }
                // }

                // 对content分词,并统计词频
                std::vector<std::string> content_words;
                ns_util::JiebaUtil::CutString(doc.content, &content_words);

                for (std::string s : content_words)
                {
                    boost::to_lower(s);
                    word_map[s].content_cnt++;
                }

                // if (doc.doc_id == 1572)
                // {
                //     for (auto& e : content_words)
                //     {
                //         std::cout << "contnet: " << e << std::endl;
                //     }
                // }

                // 3.0 建立倒排索引,关键词到倒排拉链InvertedList的映射
                for (const auto& word_pair : word_map)
                {
                    // 因为我们是按文档逐个进行建立倒排索引的,所以此时分词对应的doc_id都是doc.doc_id
                    InverterElem elem;
                    elem.doc_id = doc.doc_id;
                    elem.word = word_pair.first;
#define TITLE_WEIGHT 10
#define CONTENT_WEIGHT 1
                    // 这里的权重,我们就粗略的认为title权重是10,content权重是1
                    elem.weight = TITLE_WEIGHT*word_pair.second.title_cnt + CONTENT_WEIGHT*word_pair.second.content_cnt;
                    // 由于map、hashmap的[]的特性,key不存在则会自动插入key,此时val为相应的默认值,并返回val的引用,如果key存在则返回对应val的引用
                    // 如果此时插入的分词是hello,文档id为1,在2文档中也出现了hello分词,那么在插入倒排索引时,key相同,inverted_index[key]就会对应相同的倒排拉链,即hello关键词对应了文档1、2
                    inverted_index[word_pair.first].push_back(std::move(elem));
                }

                return true;
            }
    };
    // static成员类外定义
    std::mutex index::mtx;
    index* index::instance = nullptr;
}

六、searcher.hpp

1. 单例模式
  1. 设计为懒汉模式,获取index类的单例对象,因为index对象在初始化时很耗费时间,因为要对八千多个html文档进行建立索引操作,所以如果设计为饿汉模式,程序启动会很慢,故设计为懒汉模式,在使用时才构建索引,获取index的单例对象。

  2. InitSearcher函数进行获取单例对象,调用索引构建函数

// 功能:实现搜索
namespace ns_searcher {
    struct InvertedElemPrint {
        uint64_t doc_id;
        int weight;
        std::vector<std::string> words;
    };

    class Searcher {
        private:
            ns_index::index* index; // 提供查找的索引,我们采用懒汉单例模式(这里是懒汉,index类是单例)
        public:
            Searcher() {}
            ~Searcher() {}
        public:
            // 搜索初始化
            void InitSearcher(const std::string& input)
            {
                // 1.0 获取或建立index对象(单例模式)
                index = ns_index::index::GetInstance();
                _log(Info, "获取index单例对象成功...\n");

                // 2.0 根据index对象建立索引,input为html文件去除标签后的文件路径
                index->BuildIndex(input);
                _log(Info, "构建正排和倒排索引成功...\n");
            }
    };

}
2. 搜索功能
  1. 先将参数获取到的搜索关键词进行jieba分词,将每一个分词结果分别进行搜索,也就是用index对象的GetInvertedList,获取倒排拉链,然后根据倒排拉链的各个doc_id调用GetForwardIndex获取文档信息。
  2. 特别注意,在搜索前,需要将分词结果进行小写转化,因为在构建倒排索引时,map的key值都是小写化的,这是为了搜索时忽略大小写。
  3. 将各分词的搜索结果放在同一个vector中。
  4. 还需要注意,搜索关键词可能会jieba分词为多个单词,这多个单词可能对应了相同的文档,例如分词结果hello对应了文档1、3,分词结果world对应了文档2、3,合并结果应为1、2、3,而不是1、2、3、3,一个文档不能在搜索结果中重复出现,所以我们要去重。去重时借助一个hashmap(std::unordered_map<uint64_t, InvertedElemPrint>),相同的doc_id会对应同一个InvertedElemPrint,然后weight相加即可,这样就达到了去重效果,也公平的矫正了该文档的权重。最后遍历这个hashmap,将结果insert到std::vector<InvertedElemPrint> inverter_list_all
        struct InvertedElemPrint {
            uint64_t doc_id;
            int weight;
            std::vector<std::string> words;
        };
  5. 将去重后的vector进行sort排序,使用我们自定义的lambda比较函数,按照文档的weight进行降序排列。
  6. 遍历这个vector,调用GetForwardIndex获取文档信息(title、content的摘要、url),并逐一的建立json,并append到大的json中,最后写入到输出型参数json中。摘要是在content中找到关键词,截取关键词上下20个单词即可,这里要注意:关键词已经小写化了,而content还是原文,如果强行比较可能会找不到,理论上是必能找到的,因为这个关键词就是从content中jieba分词出来的(但是小写化了,为了查找时忽略大小写),所以调用std的search函数,全转为小写再比较。

 实现:

// 搜索功能
void Search(const std::string& query, std::string* json_string)
{
    // 1. 分词:对query进行分词,按照searcher的要求进行分词
    std::vector<std::string> query_words;
    ns_util::JiebaUtil::CutString(query, &query_words);
    
    // // 1.1 去除下划线字符串,例如搜索recursive_directory_iterator,jieba分词会分出_,所以需要去除
    // RemoveUnderline(query_words);

    // 2. 触发:根据分词的各个结果,分别进行查询
    ns_index::InverterList tmp_inverter_list_all;       // 临时记录query所有分词对应的倒排拉链的InverterElem

    // 遍历每一个query的分词
    for (std::string word : query_words)
    {
        // 由于在建立倒排索引时就已经忽略了大小写,所以在搜索时也需要忽略大小写
        boost::to_lower(word);
        // 获取倒排拉链
        ns_index::InverterList* inverter_list = index->GetInvertedList(word);

        if (nullptr == inverter_list)
            continue;
        
        // 插入倒排拉链内的元素
        tmp_inverter_list_all.insert(tmp_inverter_list_all.end(), inverter_list->begin(), inverter_list->end()); 
    }

    // 将重复的元素的weight进行累加并去重,因为不同的分词可能对应相同的文档id,而相同的文档不应该在搜索结果中出现两次
    std::vector<InvertedElemPrint> inverter_list_all;           // 去重后的各个关键词搜索结果的倒排拉链集合
    std::unordered_map<uint64_t, InvertedElemPrint> tokens_map; // 用来去重的map
    for (const auto& elem : tmp_inverter_list_all)
    {
        auto &tmp_elem = tokens_map[elem.doc_id];       // 这里借助map[]的性质来去重,如果没有就插入,有就返回key对应value的引用
        tmp_elem.doc_id = elem.doc_id;                  // 这里是将第一次插入的InverterElemPrint的doc_id赋值
        tmp_elem.weight += elem.weight;                 // 将重复的InverterElemPrint的weight累加
        tmp_elem.words.push_back(elem.word);            // 将重复的InverterElemPrint的word添加到words(vector<string>容器)中
    }

    // 遍历去重后的tokens_map将second(InvertedElemPrint)push_back到inverter_list_all
    for (const auto& elem : tokens_map)
    {
        inverter_list_all.push_back(std::move(elem.second));
    }

    // 对于关键词搜索结果为空时,直接返回不要进行下面的操作
    if (inverter_list_all.empty()) return;
    
    // 3. 合并:对查询结果进行合并,按照相关性进行降序排序(lambda)
    sort(inverter_list_all.begin(), inverter_list_all.end(), [](const InvertedElemPrint& e1, const InvertedElemPrint& e2){
        return e1.weight > e2.weight;
    });

    // 4. 输出:根据排序结果构建json字符串并输出
    Json::Value root;
    for (const auto& elem : inverter_list_all)
    {
        // 根据正排索引获取每个文档的doc信息,并构建一个json对象
        ns_index::DocInfo* doc = index->GetForwardIndex(elem.doc_id);

        Json::Value item;
        item["title"] = doc->title;     
        // 获取文档content的有关搜索关键词的摘要,这里的elem.word就是搜索的关键词,因为在构建倒排索引的时候就在InverterElem中记录下来了
        // 又因为elem是InvertedElemPrint,所以elem.words是一个vector<string>,我们获取摘要时直接使用[0],即第一个关键词即可
        item["desc"] = GetDesc(doc->content, elem.words[0]);    
        item["url"] = doc->url;

        // debug
        // item["weight"] = elem.weight;
        // item["doc_id"] = (int)elem.doc_id;

        // 追加到root的json对象中
        root.append(item);
    }

    // 向json_string中写入json字符串
    // Json::StyledWriter writer;
    Json::FastWriter writer;
    *json_string = writer.write(root);
}

总代码

#pragma once
#include "index.hpp"
#include <algorithm>
#include <unordered_set>
#include <jsoncpp/json/json.h>
#include "Log.hpp"

// 功能:实现搜索
namespace ns_searcher {
    struct InvertedElemPrint {
        uint64_t doc_id;
        int weight;
        std::vector<std::string> words;
    };

    class Searcher {
        private:
            ns_index::index* index; // 提供查找的索引,我们采用懒汉单例模式(这里是懒汉,index类是单例)
        public:
            Searcher() {}
            ~Searcher() {}
        public:
            // 搜索初始化
            void InitSearcher(const std::string& input)
            {
                // 1.0 获取或建立index对象(单例模式)
                index = ns_index::index::GetInstance();
                _log(Info, "获取index单例对象成功...\n");

                // 2.0 根据index对象建立索引,input为html文件去除标签后的文件路径
                index->BuildIndex(input);
                _log(Info, "构建正排和倒排索引成功...\n");
            }

            // void RemoveUnderline(std::vector<std::string>& query_words)
            // {
            //     for (int i = 0; i < query_words.size(); i++)
            //     {
            //         if (query_words[i] == "_")
            //         {
            //             query_words.erase(query_words.begin() + i);
            //         }
            //     }
                
            // }

            // 搜索功能
            void Search(const std::string& query, std::string* json_string)
            {
                // 1. 分词:对query进行分词,按照searcher的要求进行分词
                std::vector<std::string> query_words;
                ns_util::JiebaUtil::CutString(query, &query_words);
                
                // // 1.1 去除下划线字符串,例如搜索recursive_directory_iterator,jieba分词会分出_,所以需要去除
                // RemoveUnderline(query_words);

                // 2. 触发:根据分词的各个结果,分别进行查询
                ns_index::InverterList tmp_inverter_list_all;       // 临时记录query所有分词对应的倒排拉链的InverterElem

                // 遍历每一个query的分词
                for (std::string word : query_words)
                {
                    // 由于在建立倒排索引时就已经忽略了大小写,所以在搜索时也需要忽略大小写
                    boost::to_lower(word);
                    // 获取倒排拉链
                    ns_index::InverterList* inverter_list = index->GetInvertedList(word);

                    if (nullptr == inverter_list)
                        continue;
                    
                    // 插入倒排拉链内的元素
                    tmp_inverter_list_all.insert(tmp_inverter_list_all.end(), inverter_list->begin(), inverter_list->end()); 
                }

                // 将重复的元素的weight进行累加并去重,因为不同的分词可能对应相同的文档id,而相同的文档不应该在搜索结果中出现两次
                std::vector<InvertedElemPrint> inverter_list_all;           // 去重后的各个关键词搜索结果的倒排拉链集合
                std::unordered_map<uint64_t, InvertedElemPrint> tokens_map; // 用来去重的map
                for (const auto& elem : tmp_inverter_list_all)
                {
                    auto &tmp_elem = tokens_map[elem.doc_id];       // 这里借助map[]的性质来去重,如果没有就插入,有就返回key对应value的引用
                    tmp_elem.doc_id = elem.doc_id;                  // 这里是将第一次插入的InverterElemPrint的doc_id赋值
                    tmp_elem.weight += elem.weight;                 // 将重复的InverterElemPrint的weight累加
                    tmp_elem.words.push_back(elem.word);            // 将重复的InverterElemPrint的word添加到words(vector<string>容器)中
                }

                // 遍历去重后的tokens_map将second(InvertedElemPrint)push_back到inverter_list_all
                for (const auto& elem : tokens_map)
                {
                    inverter_list_all.push_back(std::move(elem.second));
                }

                // 对于关键词搜索结果为空时,直接返回不要进行下面的操作
                if (inverter_list_all.empty()) return;
                
                // 3. 合并:对查询结果进行合并,按照相关性进行降序排序(lambda)
                sort(inverter_list_all.begin(), inverter_list_all.end(), [](const InvertedElemPrint& e1, const InvertedElemPrint& e2){
                    return e1.weight > e2.weight;
                });

                // 4. 输出:根据排序结果构建json字符串并输出
                Json::Value root;
                for (const auto& elem : inverter_list_all)
                {
                    // 根据正排索引获取每个文档的doc信息,并构建一个json对象
                    ns_index::DocInfo* doc = index->GetForwardIndex(elem.doc_id);

                    Json::Value item;
                    item["title"] = doc->title;     
                    // 获取文档content的有关搜索关键词的摘要,这里的elem.word就是搜索的关键词,因为在构建倒排索引的时候就在InverterElem中记录下来了
                    // 又因为elem是InvertedElemPrint,所以elem.words是一个vector<string>,我们获取摘要时直接使用[0],即第一个关键词即可
                    item["desc"] = GetDesc(doc->content, elem.words[0]);    
                    item["url"] = doc->url;

                    // debug
                    // item["weight"] = elem.weight;
                    // item["doc_id"] = (int)elem.doc_id;

                    // 追加到root的json对象中
                    root.append(item);
                }

                // 向json_string中写入json字符串
                // Json::StyledWriter writer;
                Json::FastWriter writer;
                *json_string = writer.write(root);
            }

            // 获取文档content有关搜索关键词的摘要,关键词的前20个单词--后10个单词
            std::string GetDesc(const std::string& content, const std::string& word)
            {
                // 大坑:因为word是to_lower的,这是在构建倒排索引时转为小写的,目的是为了让关键词匹配时忽略大小写,也就是说倒排索引inverter_index内键值的key全都是小写
                // 但是我们的content是原装的,没有经过大小写转化,所以例如用“hello”在content内搜索存在的“HELLO”,此时是find失败的!所以我们需要使用忽略大小写的find
            
                // 使用std的search函数进行忽略大小写的查找
                auto iter = std::search(content.begin(), content.end(), word.begin(), word.end(), [](char a, char b){
                    return std::tolower(a) == std::tolower(b);
                });
                if (iter == content.end())
                {
                    _log(Warning, "GetDesc: word not found in content\n");
                    return "None";
                }

                // 计算关键词的下标位置
                int pos = std::distance(content.begin(), iter);

                // 计算关键词前后的单词数
                const int word_count_before = 15;
                const int word_count_after = 10;

                // 找到关键词前10个单词的起始位置
                int start = pos;
                int words_before = 0;
                while (start > 0 && words_before < word_count_before) {
                    if (std::isspace(content[start - 1])) {
                        words_before++;
                    }
                    start--;
                }
                // 跳过前一个空格
                if (start < pos && std::isspace(content[start])) start++;

                // 找到关键词后10个单词的结束位置
                int end = pos + word.size();
                int words_after = 0;
                while (end < content.size() && words_after < word_count_after) {
                    if (std::isspace(content[end])) {
                        words_after++;
                    }
                    end++;
                }
                // 跳过后一个空格
                if (end > pos + word.size() && std::isspace(content[end - 1])) end--;

                // 确保起始和结束位置在content的边界内
                if (start < 0) start = 0;
                if (end >= content.size()) end = content.size() - 1;

                // 在摘要尾部加上"...",表示省略
                std::string desc = content.substr(start, end - start + 1);
                desc += "...";

                return desc;
            }

            // // 获取文档content有关搜索关键词的摘要,前50个字符--后100个字符
            // std::string GetDesc(const std::string& content, const std::string& word)
            // {
            //     // 大坑:因为word是to_lower的,这是在构建倒排索引时转为小写的,目的是为了让关键词匹配时忽略大小写,也就是说倒排索引inverter_index内键值的key全都是小写
            //     // 但是我们的content是原装的,没有经过大小写转化,所以例如用“hello”在content内搜索存在的“HELLO”,此时是find失败的!所以我们需要使用忽略大小写的find
            //     // size_t pos = content.find(word);
            //     // if (pos == std::string::npos) return "None";

            //     // 解决方案:使用std的search函数,这个函数是C++11标准提供的在指定范围内查找子序列的首次出现位置。并可以提供一个自定义回调函数
            //     auto iter = std::search(content.begin(), content.end(), word.begin(), word.end(), [](char a, char b){
            //         return std::tolower(a) == std::tolower(b);
            //     });
            //     if (iter == content.end()) return "==============================================None1==============================================";
            //     int pos = std::distance(content.begin(), iter);

            //     const int prev_step = 50;
            //     const int next_step = 100;

            //     int start = 0;
            //     int end = content.size() - 1;

            //     // 小坑:size_t是无符号类型,如果计算小于0,那么会表现为最大值
            //     // 解决方案:构成正数和正数的比较,等号两边移位。也可以在定义时就定义为int类型
            //     // if (pos - prev_step > start) start = pos - prev_step;
            //     if (pos - prev_step > start) start = pos - prev_step;
            //     if (pos + next_step < end) end = pos + next_step;
            //     if (start >= end) return "==============================================None2==============================================";

            //     return content.substr(start, end - start);
            // }
    };

}

七、综合调试

  1. 简写一个search.cc,进行获取searcher对象并初始化,并获取键盘的搜索关键词输出
  2. 编译并执行,查看是否编译成功,根据编译报错及时的修补bug,并思考g++编译时是否需要添加 -l 指明库文件所在路径(-lboost_system、-lboost_filesystem、-std=c++11)
  3. 添加提示词,以提醒我们进程执行到哪一步
  4. 运行search,输入关键词进行搜索,查看结果是否是按权重排序,是否包含关键词,输出的url是否能进行访问
  5. 再添加json字段,weight、id来查看是否是按照weight来排序的,例如搜索streambuf
  6. 再根据url,在页面内查找我们搜索的关键字,计算weight是否正确

如果在页面内CTRL F查找关键词的数量进行计算后,与weight不相匹配,其实这是jieba分词的问题:

  1. 我们使用jieba分词时,例如splitboostsystem这样一个词,它是一个整体,不会进行分词,但是该词内包含了split关键词,实际上浏览器内置的 CTRL F 页面查找功能是会查询到splitboostsystem这个词的,而我们在构建倒排索引时是根据分词结果作为 key 来存储的,并且我们进行查找时也是根据 map 的 key(例如split)来进行索引查找的,所以weight会匹配不上,这是正常的
  2. 我们在parser模块分离出content时,将titile也包含在内了,所以如果在title内出现的次数会加在content出现次数中,这也是一方面

所以,可以不进行处理该bug,因为对于每一个文档都是这样处理的

在debug时,我们可以先搜索一个关键词,锁定一个搜索结果,根据URL使用页面搜索功能(ctrl + f)搜索关键词出现的次数,再根据doc_id,在构建倒排索引时进行 if 判断该文档 id 是否是该id,进行debug打印,将输出结果输出到一个txt文档内,在进行grep查询关键词在title、content内出现的次数,这样就可以排查出weight计算结果不一致的根本问题所在。

八、http_server.cc

1. cpp-httplib库详细介绍

cpp-httplib 是一个轻量级且易于使用的C++ HTTP库,它允许开发者快速搭建HTTP服务器和客户端,而不需要深入理解底层网络编程的复杂性。以下是 cpp-httplib 的详细功能和使用方法:

1. 安装与引入
  • 安装cpp-httplib 是一个头文件库,只需下载 httplib.h 文件并将其包含在项目中即可。
  • 引入:通过 #include "cpp-httplib/httplib.h" 引入库。
2. 创建HTTP服务器
httplib::Server svr;

创建一个HTTP服务器实例。

3. 设置静态文件目录
svr.set_base_dir(root_path.c_str());

设置静态文件的根目录,这样用户可以直接通过URL访问该目录下的文件。

4. 定义路由处理函数

cpp-httplib 提供了多种HTTP方法的路由定义,包括 GETPOSTPUTDELETE 等。

  • GET请求
svr.Get("/s", [](const httplib::Request& req, httplib::Response& res) { // 处理逻辑 });
  • POST请求
svr.Post("/submit", [](const httplib::Request& req, httplib::Response& res) { // 处理逻辑 });
5. 获取请求参数
  • 查询参数(如 URL 中的 ?key=value):
if (req.has_param("word")) { std::string word = req.get_param_value("word"); }
  • 表单数据(适用于 POST 请求):
auto params = req.params(); for (auto& p : params) { std::cout << p.first << ": " << p.second << "\n"; }

6. 设置响应内容
  • 普通文本
res.set_content("Hello, World!", "text/plain; charset=utf-8");
  • JSON 响应
res.set_content(json_string, "application/json; charset=utf-8");
7. 启动服务器监听
svr.listen("0.0.0.0", 8080);

让服务器开始监听指定的IP地址和端口号,等待客户端连接。

8. 错误处理

cpp-httplib 支持自定义错误处理函数:

svr.set_error_handler([](const httplib::Request &req, httplib::Response &res) { res.status = 404; res.set_content("Not Found", "text/plain"); });
总代码
  1. 搜索初始化
  2. http服务初始化
  3. 设置根目录为 ./wwwroot
  4. 构建 ip:port/s 网页,浏览器网页用Get方法提交参数,服务器搜索关键词参数,构建json串返回浏览器
#include "searcher.hpp"
#include "cpp-httplib/httplib.h"
#include "Log.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);

    // http服务初始化
    httplib::Server svr;
    // 设置静态文件根目录
    svr.set_base_dir(root_path.c_str());

    // 根据浏览器的url,获取搜索关键词,构建http返回内容
    svr.Get("/s", [&search](const httplib::Request &req, httplib::Response &res){ 
        // 判断url是否携带关键词
        if (!req.has_param("word"))
        {
            res.set_content("请输入搜索关键词", "text/plain: charset=utf-8");
            return;
        }

        // 获取url的搜索关键词
        std::string word = req.get_param_value("word");
        _log(Info, "用户搜索了:%s", word.c_str());

        std::string json_string;
        // 进行搜索
        search.Search(word, &json_string);
        
        // 返回json风格的http内容
        if (json_string.size())
            res.set_content(json_string, "application/json; charset=utf-8");
        });

    svr.listen("0.0.0.0", 8080);
    return 0;
}

九、Makefile

相关细节:

  1. cc=g++,这是一种键值关系,$(cc)就是g++
  2. parser可执行文件就是进行去标签的程序,我们的项目第一步就是 ./parser ,将 ./data/input下的各个html文件进行去标签,写入到 ./data/raw_html/raw.txt 文档中。我们使用了boost库里的filesystem,所以要用-l指明库文件路径
  3. debug可执行文件就是我们手写的一个search.cc文件编译来的,是在实现http服务之前进行统一测试用的,用了jsoncpp序列化工具,所以编译时要加上-ljsoncpp选项
  4. http_server可执行文件就是我们最终实现的服务器提供搜索服务的进程,与浏览器的http请求交互,就是前后端交互(前端index.html,后端http_server),httpserver引入了cpp-httplib库,这里面使用了多线程,所以编译时要加上-lpthread选项
cc=g++

.PHONY:all
all:parser debug http_server

parser:parser.cc
	$(cc) -o $@ $^ -lboost_system -lboost_filesystem -std=c++11
debug:search.cc
	$(cc) -o $@ $^ -ljsoncpp -std=c++11
http_server:http_server.cc
	$(cc) -o $@ $^ -ljsoncpp -std=c++11 -lpthread


.PHONY:clean
clean:
	rm -rf parser debug http_server

十、index.html

相关细节: 

  • 网页显示的框架:每一个文档划分为一个框,里面有文档的title、content摘要、url
  • 用jQuery定义search函数
  • 使用Ajax实现前后端交互,先获取用户输入的关键词,使用Ajax用Get方法,并将当前url后追加/s?word=关键词,也就是我们在http_server.cc中设置的/s资源页面,由Get方法在url中获取用户输入的参数,发送http请求,获取服务器构建的关键词搜索结果的json字符串,再将这个json串构造为一个html文件显示在用户浏览器。
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <script src="http://code.jquery.com/jquery-2.1.1.min.js"></script>
    <title>Boost 搜索引擎</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
        html, body {
            height: 100%;
            font-family: Arial, sans-serif;
        }
        /* 初始页面布局:搜索框居中 */
        body.initial-page {
            display: flex;
            justify-content: center;
            align-items: center;
            background-color: #f9f9f9;
        }
        .container {
            width: 800px;
            padding: 20px;
            background-color: #fff;
            border-radius: 10px;
            box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
        }
        /* 搜索结果页面布局:恢复默认 */
        body.results-page .container {
            margin: 20px auto;
        }
        .container .search {
            width: 100%;
            height: 60px;
            display: flex;
            align-items: center;
        }
        .container .search input {
            flex: 1;
            height: 50px;
            padding: 10px;
            border: 1px solid #ccc;
            border-radius: 5px 0 0 5px;
            font-size: 16px;
            color: #333;
        }
        .container .search button {
            width: 150px;
            height: 50px;
            background-color: #4e6ef2;
            color: #fff;
            border: none;
            border-radius: 0 5px 5px 0;
            font-size: 18px;
            cursor: pointer;
            transition: background-color 0.3s ease;
        }
        .container .search button:hover {
            background-color: #3a5bbf;
        }
        .container .result {
            width: 100%;
            margin-top: 20px;
        }
        .container .result .item {
            margin-bottom: 20px;
            padding: 15px;
            background-color: #fff;
            border-radius: 5px;
            box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
            /* 确保内容不会超出容器 */
            overflow: hidden;
        }
        .container .result .item a {
            display: block;
            font-size: 20px;
            color: #4e6ef2;
            text-decoration: none;
            margin-bottom: 10px;
            /* 允许长单词换行 */
            word-break: break-word;
        }
        .container .result .item a:hover {
            text-decoration: underline;
        }
        .container .result .item p {
            font-size: 16px;
            color: #555;
            margin-bottom: 10px;
            /* 允许长单词换行 */
            word-break: break-word;
        }
        .container .result .item i {
            display: block;
            font-style: normal;
            color: #777;
            font-size: 14px;
            /* 确保 URL 换行 */
            word-break: break-all;
        }
        /* 无搜索结果时的提示信息样式 */
        .container .no-results {
            text-align: center;
            font-size: 18px;
            color: #777;
            margin-top: 20px;
        }
    </style>
</head>
<body class="initial-page">
    <div class="container">
        <div class="search">
            <input type="text" id="search-input" value="请输入搜索关键字">
            <button onclick="Search()">搜索一下</button>
        </div>
        <div class="result">
            <!-- 动态生成网页内容 -->

            <!-- <div class="item">
                这是标题
                <p>这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要</p>
                <i>https://search.gitee.com/?skin=rec&type=repository&q=cpp-httplib</i>
            </div>
            <div class="item">
                这是标题
                <p>这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要</p>
                <i>https://search.gitee.com/?skin=rec&type=repository&q=cpp-httplib</i>
            </div> -->
        </div>
    </div>
    <script>
        // 清空搜索框默认提示文本
        document.getElementById("search-input").addEventListener("focus", function() {
            if (this.value === "请输入搜索关键字") {
                this.value = "";
            }
        });

        // 如果用户没有输入内容,离开搜索框时恢复默认提示文本
        document.getElementById("search-input").addEventListener("blur", function() {
            if (this.value === "") {
                this.value = "请输入搜索关键字";
            }
        });

        // 监听回车键按下事件
        document.getElementById("search-input").addEventListener("keydown", function(event) {
            if (event.keyCode === 13) { // 13 是回车键的 keyCode
                Search();
            }
        });

        function Search() {
            let query = $("#search-input").val();
            console.log("query = " + query);

            // 切换到搜索结果页面布局
            document.body.classList.remove("initial-page");
            document.body.classList.add("results-page");

            $.ajax({
                type: "GET",
                url: "/s?word=" + query,
                success: function(data) {
                    console.log(data);
                    BuildHtml(data);
                }
            });
        }

        function BuildHtml(data) {
            let result_lable = $(".container .result");
            result_lable.empty();

            if (data.length === 0) {
                // 如果没有搜索结果,显示提示信息
                let noResults = $("<div>", {
                    class: "no-results",
                    text: "很抱歉,没有找到与您搜索关键词相关的内容。"
                });
                noResults.appendTo(result_lable);
            } else {
                // 如果有搜索结果,正常显示
                for (let elem of data) {
                    let a_lable = $("<a>", {
                        text: elem.title,
                        href: elem.url,
                        target: "_blank"
                    });
                    let p_lable = $("<p>", {
                        text: elem.desc
                    });
                    let i_lable = $("<i>", {
                        text: elem.url
                    });
                    let div_lable = $("<div>", {
                        class: "item"
                    });
                    a_lable.appendTo(div_lable);
                    p_lable.appendTo(div_lable);
                    i_lable.appendTo(div_lable);
                    div_lable.appendTo(result_lable);
                }
            }
        }
    </script>
</body>
</html>

十一、演示

  1. ./parser,将./data/input目录下的html文件进行去标签
  2. ./http_server,启动http服务
  3. 在浏览器的url中输入我们的服务器ip:port(8.141.113.122:8080

因为我们在jieba分词后,用hashmap过滤掉了暂停词,而这个动作非常耗时!因为一个文档如果很大,那么它的分词结果会更加庞大,所需的遍历时间就很长,所以在构建索引时会至少编译6、7分钟。 

访问根目录的index.html页面

 输入搜索关键词,例如filesystem

 搜索ljs

搜索暂停词

 

我们在 data/input中加入了一个自定义的test.html文件,用来测试文档是否去重

在服务器都有回显(是因为我们在http_server.cc中加入了回显日志)

还可以直接输入url: 8.141.113.122:8080/s?word=关键词,这样会直接获取服务器返回d json串,用于debug

    HTML5与CSS3:前端开发解决方案与新增特性详解》一书详细介绍了HTML5中的新结构元素如何用于构建更加清晰和语义化的网页结构。这些新元素的使用,不仅提升了网页的可读性,还优化了内容的组织,这对于搜索引擎优化(SEO)至关重要。 参考资源链接:[HTML5与CSS3:前端开发解决方案与新增特性详解](https://wenku.csdn.net/doc/6mbrdc5jkg?spm=1055.2569.3001.10343) 在实际项目中,我们可以按照以下步骤来利用这些元素: 1. 使用`<section>`来标记文档中的一个独立部分,每个`<section>`通常包含一个标题(`<h1>`-`<h6>`)。这样做可以帮助搜索引擎理解页面的结构和层次。 2. `<article>`元素用于独立的、自包含的内容块,这意味着它可以独立于页面其他内容被阅读。在新闻网站、博客或者论坛中特别有用,每个独立的帖子或评论都可以用`<article>`来标记。 3. `<nav>`元素应该用于主要的导航链接,这样可以突出网站的导航结构,使搜索引擎更容易抓取和理解网站的导航结构。 4. `<aside>`元素用于内容相关的侧边栏,如侧边栏广告、相关文章链接等。它有助于突出侧边栏内容,与主要文章内容区分开来。 例如,下面是一个简单的HTML5页面结构示例: ```html <html> <head> <title>HTML5结构元素示例</title> </head> <body> <header> <h1>网站标题</h1> <nav> <ul> <li><a href= 参考资源链接:[HTML5与CSS3:前端开发解决方案与新增特性详解](https://wenku.csdn.net/doc/6mbrdc5jkg?spm=1055.2569.3001.10343)
    评论
    添加红包

    请填写红包祝福语或标题

    红包个数最小为10个

    红包金额最低5元

    当前余额3.43前往充值 >
    需支付:10.00
    成就一亿技术人!
    领取后你会自动成为博主和红包主的粉丝 规则
    hope_wisdom
    发出的红包
    实付
    使用余额支付
    点击重新获取
    扫码支付
    钱包余额 0

    抵扣说明:

    1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
    2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

    余额充值