目录
boost库 搜索引擎
项目背景
引入
表现形式
boost库介绍
项目环境
数据源
下载文档
页面目录
查看html文件的数量
技术栈
原理
过程
正排/倒排索引
正排索引
分词
暂停/停止词
倒排索引
模拟查找过程
parser模块
读取文件
标签
如何存放
代码编写思路
遍历目录
读取文件
格式化
标题
正文
代码
注意
构建url
借助move
添加分隔符
建立索引模块
正排索引
倒排索引
查找索引
建立索引
读取文档
建立正排索引
切分字符串 -- split
插入元素
代码
建立倒排索引
思路
分词 -- cppjieba
确定权重
忽略大小写
插入元素
代码
搜索引擎模块
索引对象单例化
搜索
流程
忽略大小写
查询倒排索引
得到集合
去重
排序
查询正排索引
引入jsoncpp
截取文档摘要
代码
服务器模块
cpp-httplib
devtoolset
创建对象
设置根目录
处理请求
获取请求
获取搜索内容
调用搜索模块
代码
运行结果
前端模块
代码
运行结果
代码结构
boost库 搜索引擎
项目背景
引入
市面上已经有很多公司设计出很多的搜索引擎
- 百度,搜狗,goole,360搜索等等
- 这种大型的项目我们一个人是没办法完成的,因为它是全网搜索,涉及到的内容过于庞大
但我们可以实现站内搜索
- 比如cplusplus.com
与全网搜索的不同点在于,站内搜索得到的结果更垂直,数据量更小
- 垂直 -- 数据之间具有很强的相关性
表现形式
搜索引擎以什么方式显示给用户?
- 搜索框+搜索按钮
搜索关键字后,每个搜索结果基本由三大部分组成:
- 并且,提供跳转网页的功能
- 其他的搜索引擎也基本是这些内容,只是细节有所不同
boost库介绍
Boost库是一个广泛使用的C++库集合,提供了许多功能强大且高效的工具和组件,可以帮助开发者提高代码的质量和效率
- Boost库包含超过160个独立的库,这些库各自提供特定的功能,包括数据结构、算法、并发编程、网络编程等
但是boost库的官网并没有提供搜索功能,但网站提供的资源量非常大
- 所以需要我们自己做一个boost库的搜索引擎
- 注意,搜索的是boost官网上罗列的手册(接口什么的介绍),而不是boost库的源码
项目环境
centos7云服务器,vim,gcc/g++,makefile,vscode
数据源
下载文档
在boost官网下可以下载 -- Boost C++ Libraries
- 点击下图的download
-
然后将下载的文件上传到云服务器下
- 如果在上传过程中出现乱码(文件如果很大,就可能会出现乱码)
- 可以在使用rz命令时添加E选项
上传成功后,进行解压缩
页面目录
我们在boost官网下的文档进行查询时,点击其中某个标签页,会发现得到的新页面大多数是放在/doc/html目录下的
-
- 所以我们在后续使用该网页上的文档作为搜索引擎数据源时,就只需要使用/doc/html目录下的文件即可
这些文件就是boost各个组件对应的手册内容,以网页形式存在
- 我们后面就用它们来建立索引
- 所以我们直接将这个目录拷贝到我们项目文件中
不过也有不在doc下的网页文件,但我们不考虑这些
-
查看html文件的数量
我们可以查看一下我们的目标目录中一共有多少html文件:
ls -Rl | grep -E '*.html' | wc-l
-
技术栈
- c/c++,c++11,stl,boost库
- jsoncpp -- cs两端进行数据交换的格式
- cppjieba -- 分词工具(对文档和用户输入内容进行分词)
- cpp-httplib -- 构建http服务器
- html5,css,JavaScript,jQuery,ajax -- 前端内容
原理
过程
客户端(pc或手机)
- 上面会运行着很多软件(浏览器)
服务器(比如公司服务主机)
- 上面也会服务运行软件(searcher)
在提供服务前,需要在各个网站中抓取网页,放入主机磁盘中
- 并且对抓取到的数据进行处理,并建立索引
- 完成之后,就可以基于搜索引擎进行搜索了
用户通过浏览器
- 向服务器发送http请求,其中携带有搜索关键字
服务器接收后
- 使用关键字在索引中进行检索,得到相关网页
- 最后将多个网页信息经过包装(title+desc+url),形成一个新的网页后,返回给客户端
我们这里要完成的内容,就是红框内的功能
- 至于爬虫,我们可以通过正规渠道将目标网站的内容下载下来,也是一样的(也就是前面说的数据源)
正排/倒排索引
假设有两个文档
正排索引
从文档id 找到 文档内容
建立正排索引:
-
分词
将一段文本切分成更小的单元(称为“词”或“标记”)
我们建立搜索引擎,需要对目标文档分词
-
- 目的 -- 方便建立倒排索引和查找
可以分出来的不止这些词语
- 因为有些词语是可以连起来的
- 比如四斤小米,或是直接是整句话,总之会有很多种组合方式
暂停/停止词
对文本的意义或分析贡献较小的常见词汇
- 通常用于构建句子的结构,帮助表达语法关系,但对于主题识别和信息检索的贡献有限
- 所以,区分唯一性的价值不大,还会增加搜索成本,一般会在分词的时候忽略掉
- 比如 : 了,的,在,是,吗,a,the
倒排索引
根据关键字(也就是我们对文档进行分词后得到的结果),找到文档id
- 关键字具有唯一性,如果关键字在多个文档中出现,只保留一份
建立倒排索引 -- 我们将文档内容分词后,整理出不重复的关键字,与文档id联系起来
-
模拟查找过程
接下来,基于这两个索引,我们来模拟一次查找的过程:
假设用户输入"小米"
- 那第一步一定是使用"小米"在倒排索引中进行查找,提取出对应的文档id(1,2)
- 再拿着文档id在正排索引中查找,找到两个文档对应的内容,然后分别对文档结果进行格式化(提取出title+desc+url)
- 最后将结果以网页的形式构建出响应,返回给用户
因为可能有多个文档与关键字匹配
- 所以还需要给每个文档赋权值,谁的权重更高,就把哪个文档显示在前面
- 就像是在各种搜索引擎上搜索出来的内容,一定会经过排序的,而顺序一定是由权重决定的
parser模块
整体思路 -- 对下载下来的文档信息(网页)进行去标签和数据清洗
读取文件
首先肯定是要先拿到文件,再对文件进行处理
- 也就是需要先找到所有文件名,以vector的形式组织起来
- 然后挨个进行读取
因为c++和stl对文件系统的支持不是特别好,所以我们需要使用boost库中的filesystem
- boost开发库的安装 -- sudo yum install boost-devel
- centos7下默认下载的是1.53版本的boost开发库,所以我们去官网上查看接口使用方法时,也应该查看1.53版本的
- 注意:开发库 和 官网上下载到的文档(手册网页信息) ,不是一个东西
查看手册中filesystem的教程,并随便点击一个函数,进入filesystem库的接口说明:
-
因为boost库并不是c++的标准库,所以我们需要在编译语句中指明我们要使用boost库
- 否则会链接错误 -- -lboost_system 和 -lboost_filesystem
标签
这里截取一部分html目录下某文件的内容:
-
html的标签都被<>包裹起来
- 对搜索没有意义,只是用来组成网页格式,并且这些标签在每个html文件中都会存在
- 我们需要的是去掉这些标签后的部分,这才是对于搜索引擎来说的有效数据
- 所以需要去标签
标签有成对出现的,也有单独出现的
- eg:<head> 和 </head>,<link...>
如何存放
将文件数据做完去标签化后,该放在哪里呢?
- 新建一个文件夹,并只创建一个文件,再将每个文档去标签的内容都写入到该文件中
- 每个文档之间用特殊符号隔开,相当于每个文档的内容只占"一行"
因为文档中的字符基本都是打印字符,是可显的
- 而控制字符是不可显的,就不会污染我们形成的新的文档
- 所以我们可以选择使用控制字符作为分隔符
于是我们就有了html文档的原始数据和有效内容
代码编写思路
遍历目录
将html文档所在目录定义成一个path对象
- 并在核心逻辑开始前,判断该路径是否存在
我们这里使用boost库提供的迭代器(注意,一定要使用递归迭代器recursive_directory_iterator,但在前期测试的时候可以使用普通迭代器directory_iterator,不然每次运行都可慢)
- 迭代遍历目录下的子文件,直到为空
- 迭代方式就和迭代查询一样,会将目录下所有文件都查出来,包括子目录下的文件
但遍历时,可能遍历到的文件不是html文件,而是其他文件
- 所以,需要判断
- 是否是常规文件 -- is_regular_file()
- 后缀是否是.html -- extension()
path()
- 提取迭代器指向的路径对象
string()
- 获取路径对应的字符串形式(可以用来debug,以及传参)
读取文件
我们可以借助c++的文件流对象
- 打开文件可以使用直接构建文件流对象的形式,然后结合getline读取
如何理解getline读取到文件结束?
- getline的返回值是一个引用,但while判断的是bool类型
- 判断过程的本质是因为重载了强制类型转化
我们可以将像这样的辅助函数全部写在一个文件中,并且限制下作用域
格式化
根据每个文档的内容都形成三个部分,方便我们进行后续操作
接下来是提取三大部分
标题
每个文档的标题都在<titile>...</title>内
-
- 所以,提取的重点就是识别出这一对标签
然后根据迭代器位置,进行截取
- 注意,在截取之前,我们还需要考虑一种情况,虽然这种情况的概率很小:begin在end之后
- 这样的话就会导致截取到的字符串的字符个数是负数,不符合常理
正文
其实就是去掉标签后的内容,需要我们基于一个简单的状态机来实现
- 因为文档中的内容,要么是标签,要么是有效内容
- 所以我们就定义两种状态
如何判断状态切换的时机呢?
- 遇到'<'代表进入标签
- 遇到'>'代表出标签
注意,出标签后不代表就进入了有效内容部分,也可能接在后面的是一个新标签
- 所以,处于content状态时,还需要进行判断当前字符是否是'<'
- 如果是,则说明又是个标签
- 如果不是,则确实是有效内容
- 无论是单标签还是双标签,都适用这套规则
小细节:
这里我们不能直接将有效内容直接插入
- 要去掉\n
- 因为我们后续还要添加一个分隔符\n,为了更好地读取
- 所以我们需要将文档内容中原有的\n去掉,改成' '
代码
enum state{LABLE,CONTENT};enum state s = LABLE;for (char c : data){switch (s){case LABLE:if (c == '>')s = CONTENT;break;case CONTENT:if (c == '<')s = LABLE;else{// 我们不想保留原始文件中的\n,因为我们想用\n作为html解析之后文本的分隔符if (c == '\n')c = ' ';result->content_ += c;}break;default:break;}}
注意
我们是将整个html页面上的内容进行去标签化的
- 所以标题也在正文里头
- 那么,当我们在后面进行词频统计时,如果一个词在标题中出现了,那么它也会在正文中再出现一次
- 相当于正文中多统计了一次
构建url
官网中url中的路径,是和我们下载下来的文档中的路径有对应关系的
-
- 我们下载下来的文档: boost_1_86_9/doc/html/*
- 所以,/doc/libs/1_86_0/doc/html = boost_1_86_0/doc/html
而我们将boost库的doc/html/* 拷贝到我们项目中的指定目录下
-
- 所以,boost_1_86_0/doc/html = 项目所在路径/input
所以/doc/libs/1_86_0/doc/html = 项目所在路径/input
因为我们要做的是搜索引擎,要将本地文档映射到官网网页上
- 所以,如果我们要构建出 一个url,首先官网ip地址和使用协议不能少 --https://www.boost.org
- 接着就是资源所属路径,本来文档在我们linux本地文件目录下 -- 项目所在路径/input/*,但因为实际应该使用网页中的路径,所以是/doc/libs/1_86_0/doc/html/*
- 也就是说,https://www.boost.org/doc/libs/1_86_0/doc/html/是不变的,作为固定前缀
- 所以只需要拼上当前文档在input中的相对路径,作为变化后缀
- 就完成了url的构建
借助move
格式化完成之后,需要将doc_info插入到容器中
- 如果直接将结构体插入vector中,是直接拷贝进去,效率较低
- 所以,我们可以引入移动语义,使用move函数来避免拷贝
- 后面的一些拷贝也都可以这样做
添加分隔符
最后,将解析完成的各个文件内容,全部写入到指定文件中
- 并以'\3'作为分隔符
但是,每个文档都被分成三部分,如果直接放进去,该如何区分这三部分呢?
- 所以,我们需要再添加一个分隔符
- 并且,考虑到后续对该文件的读取,如果我们能让每次读取操作都精准读完一个文档,可以使之后的处理更加方便
- 那么,让一个文档的内容变成一行就好了,这样使用getline的话,就可以刚好每次读完一个文档,然后在文档内进行分割
所以,最终文档内的存放形式如下:
建立索引模块
我们两个索引都要建立好,这样才能完成一个完整的查找流程
正排索引
因为映射关系是id->内容,而id就是数字
- 所以数组是最方便的,天然就有数字作为标识符
- 而vector就是更高级的数组,可以自动管理内存,所以采用vector建立正排索引
倒排索引
一个关键字可以对应多个文档id,并且要存放每个关键字对应的权重
- 所以,先定义一个结构体,表示关键字信息
因为要建立关键字->文档id(可能有多个)的映射关系
- 所以,采用unordered_map最为合适,第二个元素用vector将多个文档信息组织起来
我们也可以将[用vector将多个关键字信息组织起来]的结构,重命名成一个新类型
- 称为倒排拉链
- 所以,倒排索引的结构就变成了关键字和倒排拉链之间的映射关系
查找索引
在索引结构中查找前,需要先确定使用的标识是否存在
比如,在正排索引中,判断拿到的文档id是否合法
- 因为我们是直接用数组下标表示文档id的,并且一定是顺序排放的(索引中的某项一旦建立好,一般就不会再删除了)
- 所以直接判读下标是否合法即可
而在倒排索引中,则是要判断关键字是否存在
建立索引
读取文档
除了要提供查找正排/倒排索引的接口,还要先建立好索引才行
- 根据去标签化后的正文内容,建立两种索引结构
而文本内容已经被我们存入了文件中,所以我们只需要为该接口提供路径就可以了
- 打开文件时,因为我们是以二进制方式写入的,所以也要以二进制方式读出
- 因为我们之前存入文件时,已经用\n将每个文档内容区分开了,所以我们直接按行读取,读出来的就是一个完整的文档
- 读出来一个文档,就进行索引的建立
建立正排索引
切分字符串 -- split
因为我们是以这种方式插入的,所以读出来也是按格式读
切分字符串我们可以用现成的接口,而不需要手动调用find去切
- boost库中的algorithm库中的split函数
如果有多个分隔符连在一起
- 默认下(token_compress_off)是保留的,也就是会切分出多个空串
如果设置成token_compress_on,会将多个分隔符压缩成一个
插入元素
提取出来后,构建文档信息对象
- 注意,这个对象还要再增加一个字段 -- doc_id
- 方便我们后续通过该结构获取对应的文档id
而插入到vector中,数组下标会自动增加,这个下标可以和元素个数对应
- 元素下标=元素个数-1
- 所以我们可以进行事务id的分配时,直接使用插入新元素前的元素个数即可
然后插入到代表正排索引的数组中
- 注意这里和前面是一样的,可以结合move进行移动拷贝,提高效率
代码
void create_positive_index(const std::string &path) // 以文档为单位{std::ifstream in(path, std::ios_base::in | std::ios_base::binary);if (!in.is_open()){std::cerr << "file: " << path.c_str() << " open failed" << std::endl;return;}std::string doc;while (std::getline(in, doc)){// 拿到一个文档,进行解析docInfo_index di;if (!analysis(doc, delimiter, &di)){std::cerr << "analysis faild" << std::endl;continue;}di.doc_id_ = pos_index_.size();// 解析完成后,插入到索引中pos_index_.push_back(std::move(di));}}
建立倒排索引
思路
我们可以在正排索引的基础上,拿到对应的文档信息对象
- 其中包含四部分,title+content+url+doc_id
- 而最重要的就是将content进行分词
因为我们已经建立了'倒排拉链'这一自定义结构
- 所以,我们最终目的就是要根据所有文档的content,提取出关键字,以此形成一个/多个倒排拉链
- 每个倒排拉链都对应一个词的信息
而我们的处理方式是读取一个文档,处理一个文档
- 处理当前文档时,是提取出多个关键字->当前文档id
- 处理很多个文档后,就会形成关键字和文档id的多对多关系
分词 -- cppjieba
那么,究竟如何分词呢?
- --cppjieba工具
下载:
git clone --recurse-submodules git@github.com:yanyiwu/cppjieba.git
- 词库: dict目录
-
- 作为分词的基准,判断哪些词可以分开/合并
为了让程序可以找到我们下载的文件,可以建立两个软链接
- 一个对应头文件,一个对应词库文件
-
当我们试着将demo代码编译时,却显示没有limonp目录下的一个文件
- 需要我们手动将limonp目录拷贝进cppjieba/include/cppjieba中
我们需要使用的函数是CutForSearch
- 我们直接仿照demo代码中的格式,传入需要进行分词的内容,存放分词后结果的vector容器即可
- 我们可以将jieba变量设置成类内静态变量,因为每个文档进行分词时都要使用,而且用户输入的内容也要进行分词
注意,这里分词的时候,不会将英文单词分成两块
- 比如typedef中,不会将它拆成typedef和type
- 所以,如果我们搜type时,并不会和typedef匹配上
但在网页中使用ctrl f进行查询时是可以查到的
- 问题不在于我们代码有问题,只是分词规则不同
确定权重
分词完成后,我们需要统计词和文档的相关性
- 以此来确定某词在文档内的权重
但是,实际上相关性是可以由多种维度衡量的,很难去设计
- 这里我们就使用简单的方法 -- 词频
- 一个关键字在某文档出现的频率越高,如果用户搜索该词,就应该把这个文档放在更前面
- 如果是在标题中出现的,相关性就更高一些;内容中出现的,相关性就低一些
我们可以定义一个结构体,来表示两个级别
-
然后分别对标题和内容进行统计,统计出每个词出现的频率
- 因为某些词可能在标题和内容中都会出现,所以我们可以让词作为标识,也就是用unordered_map将不同的[词和对应词频]组织起来
-
- 可以称它为词频映射表
- 于是,我们就能得到在文档的标题和内容中[每个词出现的次数],从而得到文档与词的相关性
如何制定计算权重的规则呢?
- 因为设定上,标题中出现关键字比内容中出现的相关性更高,所以在计算权重时,可以是:
- 某词的权重=在标题中出现的次数*10+在内容中出现的次数
最后,我们可以遍历cnt_map
- 取出一个元素,就拿到了词+在标题/内容中的出现次数
- 就可以构建一个倒排拉链中的元素,然后插入到关键字对应的倒排拉链中
忽略大小写
在构建倒排索引时,有一个非常重要的且容易被忽视的点
搜索不同大小写下的英文的搜索结果是否应该一致呢?
- 应该,匹配的时候应该忽略大小写
如何实现?
- 我们可以把所有英文单词全部转换为小写
- 内部存放的都是小写单词,用户输入后也都转换成小写
- 这样不管搜的是啥,原文内容是啥,都可以匹配上,只要是同一个字母
- 所以,在插入到倒排索引(也就是词频映射表)前,我们要对分出来的词进行大小写转换
可以使用c++的接口,或是boost库提供的
-
插入元素
这里在构建对象时,需要当前文档id
- 因为我们是以文档为单位处理的,所以提取出的关键字映射的都是当前文档
- 而我们事先已经将文档id添加进docInfo_index中了,所以直接使用即可
在向vector插入时,可以继续使用move
代码
void create_inverted_index(const docInfo_index &doc) // 以文档为单位{struct word_cnt{int title_cnt_;int content_cnt_;word_cnt() : title_cnt_(0), content_cnt_(0) {}~word_cnt() {}};std::unordered_map<std::string, word_cnt> cnt_map;// 统计每个词在所属文档中的相关性std::vector<std::string> content_words;ns_helper::JiebaUtil::CutString(doc.content_, &content_words);for (auto it : content_words){// 为了实现匹配时忽略大小写,将所有单词转换为小写boost::to_lower(it);++cnt_map[it].content_cnt_;}std::vector<std::string> title_words;ns_helper::JiebaUtil::CutString(doc.title_, &title_words);for (auto it : title_words){boost::to_lower(it);++cnt_map[it].title_cnt_;}// 计算权值 #define title_count 10 #define content_count 1for (const auto &it : cnt_map){word_info t;t.doc_id_ = doc.doc_id_;t.word_ = it.first;t.weight_ = (it.second).title_cnt_ * title_count + (it.second).content_cnt_ * content_count;inv_index_[t.word_].push_back(t); // 插入的是小写单词}}
搜索引擎模块
索引对象单例化
搜索模块中一定要包含前面的索引结构
- 所以我们必须要构建索引对象
并且,索引结构在我们项目运行时,只存在一份就够了
- 所以我们要将索引设置为单例模式
设置单例模式时
- 需要将构造函数私有化
- 禁掉拷贝构造和赋值重载
- 在创建对象时,一定要加锁,否则可能会有并发问题导致创建出多个对象
为了解决效率问题(每个要获取对象的线程,都要经历加锁解锁)
- 在外层再包一层判断,指针为空再加锁
搜索
流程
在搜索逻辑中,需要传入用户输入的内容,以及一个可以接收搜索结果的变量
- 这个字符串肯定是要分词的,于是我们得到词的集合
搜索时需要将所有词进行搜索,先捋顺一下这里的逻辑:
- 根据词,在倒排索引中找到对应的倒排拉链,然后就能拿到包含该词的文档id
- 根据文档id,在正排索引中就能找到对应的文档内容
然后将结果合并起来返回给浏览器(合并起来后,需要对这些文档进行相关性排序,按照降序的方式,将高相关性显示在前面)
- 其他搜索引擎也是这样做的
有了返回内容,还需要根据这些内容构建json串 -- jsoncpp
忽略大小写
在对分词结果进行遍历时,因为需要实现忽略大小写
- 所以我们前面提到,我们计划是让存入内存的都是小写字符,在搜索时也将用户输入转换为小写,这样就可以实现大小写忽略
- 所以这里要对分出来的词进行大小写转换
那这样的话,这里查询的都是小写字符
- 确实可以成功匹配到文档(因为我们建立倒排索引时,就都是小写)
但我们后面在构建结果字符串时
- 插入到文档对象中的content,依然是原单词
- 到时候我们如果想显示文档内容的话,是得显示关键字周围的文字,如果我们依然选择这样的方式,到时候在content中查找关键字时就匹配不上了
- 所以,我们可以借助stl库中的search接口或是boost库中的接口 -- 在content中查找摘要时,将原文中的字符转成小写后,再和我们的关键字进行匹配
查询倒排索引
得到集合
完成上述过程后,就该找到词对应的倒排拉链
- 当然,这个拉链也可能不存在,也就是没有包含该词的文档
- 也很正常,因为我们做的只是一个站内搜索
因为我们这里有多个词
- 每个词都可能对应多个倒排拉链中的元素(也就是对应多个文档)
- 同时,也可能存在多个词对应同一个文档,那岂不是就得拿到很多份同样的文档?
- 或者没有这么极端,总之搜索结果是可能会有重复的
- 所以,我们还需要在这个基础上进行改进
去重
如果搜到重复内容,说明输入内容有多处和该文档匹配,也就是多个分词对应一个文档id
- 说明相关性更高,那权重就应该更高
- 所以,应该将重复结果合并起来,并将权值相加
我们可以定义一个和关键字信息类似的新结构,里面存放的是一组关键字
- 这样就可以表示一个文档id->多个分词的映射关系
我们在遍历[通过关键字找到的倒排拉链]的同时,查询在新结构中是否含有该文档的映射关系(用倒排拉链元素中的文档id)
- 如果没有,就新建,调用我们定义的构造,将权值初始化为0,并在后续流程中,将当前文档的id和权值赋值给新创建的对象
- 如果有,权值相加,并加入关键字
- 这块的代码是可以重复利用的,所以不需要加判断
于是,就可以定义一个新结构来保存得到的不重复的倒排拉链结点
- 插入到新结构,依然可以使用move来提高效率
- 注意,遍历时不能将元素定义成const,这样和我们使用移动语义相违背
在后续进行构建json串时,因为可能会有多个关键字
- 所以可以选取第一个关键字周围的文字作为摘要就行
排序
因为unordered_map中的元素没法进行排序
- 所以我们将这个结构转换为vector后再进行
然后,我们需要将这些不重复的结点,根据相关性进行排序,这样我们在后续可以直接构建json串,而不用考虑权重
- 可以直接使用sort,然后我们自定义让它根据对象中的weight字段进行降序排序
查询正排索引
引入jsoncpp
接下来就可以根据倒排索引查询出来的不重复集合,进行正排索引的查询
- 很简单,调用我们定义好的接口即可
- 查到文档内容后,通过jsoncpp完成序列化/反序列化
因为我们最终是要通过http协议,将查到的结果以网页的形式发送给浏览器,让浏览器来解析
- 这就涉及到cv端通信时的粘包问题
- 为了解决粘包问题,我们才引入了自定义协议,而序列化/反序列化就是自定义协议的一种方式
- 其中,json库是现成的序列化工具,我们也曾经也使用过,并且手动序列化过 --网络计算器代码编写+注意点(序列化,反序列化,报头封装和解包,服务端和客户端,计算),客户端和服务端数据传递流程图,守护进程化+日志重定向到文件_计算器封装-CSDN博客网络计算器(使用json序列化/反序列化,条件编译,注意点),json介绍+语法介绍_json序列化和反序列化工具-CSDN博客
安装jsoncpp -- sudo yum install jsoncpp jsoncpp-devel
- 使用时带上头文件 -- #include <jsoncpp/json/json.h>
- 编译时需要带上-ljsoncpp
将jsoncpp引入到我们的项目后,将需要在网页上显示的部分,插入到value对象中
截取文档摘要
这里有个注意点:
- 我们一般搜出来的结果网页中,文章内容都只是一小部分,所以还需要特殊处理下
显示给用户的内容,基本都是包含关键字的那部分
- 所以我们可以截取[关键字首次出现时周围的文字]
假设截取关键字之前的50字节(如果不足,就从开头截取),之后到100字节(不足就到结尾结束)
- 注意:如果我们使用无符号整数作为截取子串的头尾指针,那么就要注意不要做减法运算,因为负数+无符号->很大的整数,可能会导致结果出错(比如,left>right)
因为我们是按顺序遍历容器中的元素的
- 这些元素已经经过了排序,json串显示的时候也是按插入顺序显示的
- 所以,插入到value对象后,就已经是将相关性强的结果放在了前面,完成了我们想要实现的目标
代码
void search(const std::string &data, std::string *json){// 进行分词std::vector<std::string> words;ns_helper::JiebaUtil::CutString(data, &words);// 得到不重复的文档集合struct words_info{std::vector<std::string> words_; // 多个分词可以在某文档中查找到doc_id_t doc_id_ = 0;int weight_ = 0; // 这个词在文档中的权重};std::unordered_map<doc_id_t, words_info> Non_duplicate_map;for (auto word : words){boost::to_lower(word);inverted_zipper zipper;index_->search_inverted_index(word, zipper);for (auto &it : zipper){Non_duplicate_map[it.doc_id_].doc_id_ = it.doc_id_;Non_duplicate_map[it.doc_id_].weight_ += it.weight_;Non_duplicate_map[it.doc_id_].words_.push_back(std::move(it.word_));}}// 将文档集合转换类型(转成vector方便排序)std::vector<words_info> doc_map;for (auto &it : Non_duplicate_map){doc_map.push_back(std::move(it.second));}// 按相关性排序std::sort(doc_map.begin(), doc_map.end(),[](const words_info &x, const words_info &y){ return x.weight_ > y.weight_; });// 查询正排索引,并构建json串Json::Value root;for (const auto &it : doc_map){docInfo_index doc;if (!index_->search_positive_index(it.doc_id_, doc)){continue;}Json::Value item;item["title"] = doc.title_;item["desc"] = get_desc(doc.content_, it.words_[0]);item["url"] = doc.url_;root.append(item);}// Json::FastWriter writer;Json::StyledWriter writer;*json = writer.write(root);}std::string get_desc(const std::string &content, const std::string &word){ #define left_count 50 #define right_count 80// ssize_t pos = content.find(word);// 要实现大小写匹配,查找时要将原文也转换为小写auto iter = std::search(content.begin(), content.end(), word.begin(), word.end(), [](int x, int y){ return (std::tolower(x) == std::tolower(y)); });if (iter == content.end()){return "None1";}int pos = std::distance(content.begin(), iter);// 找范围ssize_t left = 0, right = content.size() - 1;if (pos > left_count + left){left = pos - left_count;}if (pos + word.size() + right_count < right){right = pos + word.size() + right_count;}if (left >= right){return "None2";}return content.substr(left, right - left);}
服务器模块
cpp-httplib
git clone https://github.com/yhirose/cpp-httplib.git
注意事项
- 需要使用较新版本的gcc 7以上
- 可以使用devsettool升级gcc
如果gcc不是特别新的话,不能使用最新的cpp-httplib,推荐使用0.7.15
- 在标签中可以选择下载版本
然后在项目目录下创建指向该第三方库的软链接
- 使用时,只需要包含头文件和系统pthread库的头文件即可,不需要在编译语句中指明使用了cpp-httplib
devtoolset
devtoolset是 Red Hat Developer Toolset(RHDTS)的一个组件,用于提供更新的 GNU 开发工具链,包括 GCC、GDB 等
- software collections(简称scl) 是 CentOS 和 Red Hat 系统中的一个功能,用于在系统中同时安装和使用多个版本的软件包,而不会影响系统默认版本的运行环境
- devtoolset是其中的一个集合,专注于提供更新的开发工具集
- 常用于老旧的 CentOS 或 RHEL 系统上,这些系统自带的软件版本可能较老,无法满足现代开发需求
- 查看工具集 -- ls/opt/rh
- 启动centos中scl仓库 -- sudo yum install centos-release-scl
- 安装devsettool -- sudo yum install devtoolset-7
- 启动工具集 -- scl enable devtoolset-9 bash
- 这种方式让gcc变成新版本,只在本会话中有效
如果我们想每次登录时自动启动工具集,可以设置进配置文件中
- 最好不要直接写进系统范围的配置文件(/etc/bash)中,这是全局添加,会让所有用户都会执行它
- 添加进当前用户就可以,登录shell时,~/bash_profile会被自动加载
创建对象
httplib::Server是cpp-httplib中的核心类,用于创建和管理http服务器
设置根目录
作为web服务器,必须要有一个web目录,里面存放所有的网页资源
- 所以,需要设置根目录 set_base_dir()
- 虽然设置了,但因为里面并没有网页,所以依然无法显示
- 我们一般将默认网页(首页)的名字设置为index.html
处理请求
获取请求
我们需要使用Get()来指定在客户端发送 HTTP GET 请求时,服务器应该执行的操作
- 第一个参数用于指定请求路径
- 第二个参数是用于获取请求和响应对象,供我们使用 -- 一般会传入一个lambda 表达式作为处理程序
因为我们这里是一个搜索引擎,可以设置让用户在/s下请求时,进行搜索操作
获取搜索内容
搜索需要获得用户输入的内容
- 而这个内容一般是被http协议的get方法获取,放在url中
并且,用户输入的关键字一般放在wd中,也就是word字段中
- 所以提取word参数中填充的值,就可以获取到搜索内容
req.has_param() 可以检查请求中是否包含某个查询参数
- 所以,一旦检查到有,就可以调用get_param_value()获取
调用搜索模块
一旦可以获取用户输入内容,就可以调用我们的search接口
- 传入用户要搜索的内容,进行搜索
- 并以输出型参数的形式返回搜索结果
然后使用set_content()
- 以application/json的格式输出构建响应,服务器会将这个响应自动发送给对应的客户端
代码
#include "searcher.hpp" #include "/home/mufeng/cpp-httplib/httplib.h" #include <pthread.h>#define root_path "../wwwroot"int main() {Searcher s;httplib::Server svr;svr.set_base_dir(root_path);svr.Get("/s", [&s](const httplib::Request &req, httplib::Response &rsp){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;std::string json_string;s.search(word,&json_string);rsp.set_content(json_string, "application/json"); });svr.listen("0.0.0.0", 8080);return 0; }
运行结果
前端模块
这里就不详细讲了,我也不太懂,等我再研究研究
- 总之,当我们在网页中加入搜索功能后,我们的搜索引擎就有两个途径实现搜索功能了
代码
<!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>/* 去掉网页中的所有的默认内外边距,html的盒子模型 */* {margin: 0;padding: 0;box-sizing: border-box;}/* 将我们的body内的内容100%和html的呈现吻合 */html, body {height: 100%;font-family: 'Arial', sans-serif;background-color: #f4f4f4;}/* 类选择器.container */.container {width: 800px;margin: 15px auto;background-color: #fff;border-radius: 8px;box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);padding: 20px;}/* 复合选择器,选中container 下的 search */.container .search {display: flex;align-items: center;margin-bottom: 20px;}/* input样式 */.container .search input {flex: 1;height: 50px;border: 1px solid #ddd;border-radius: 4px 0 0 4px;padding-left: 10px;font-size: 16px;transition: border-color 0.3s;}.container .search input:focus {border-color: #4e6ef2;outline: none;}/* button样式 */.container .search button {width: 150px;height: 50px;background-color: #4e6ef2;border: none;color: #fff;font-size: 16px;border-radius: 0 4px 4px 0;cursor: pointer;transition: background-color 0.3s;}.container .search button:hover {background-color: #3d5bbf;}/* result样式 */.container .result {margin-top: 20px;}.container .result .item {padding: 15px;margin-top: 10px;border: 1px solid #ddd;border-radius: 5px;background-color: #f9f9f9;transition: transform 0.2s;}.container .result .item:hover {transform: scale(1.02);}.container .result .item a {display: block;font-size: 18px;color: #4e6ef2;text-decoration: none;margin-bottom: 5px;}.container .result .item a:hover {text-decoration: underline;}.container .result .item p {font-size: 14px;color: #666;margin: 5px 0;}.container .result .item i {display: block;font-size: 12px;color: green;}.no-results {font-size: 16px;color: #ff0000;margin-top: 20px;}</style> </head> <body><div class="container"><div class="search"><input type="text" placeholder="请输入搜索关键字"><button onclick="Search()">搜索一下</button></div><div class="result"><!-- 动态生成网页内容 --></div></div><script>function Search() {let query = $(".container .search input").val();console.log("query = " + query);$.ajax({type: "GET",url: "/s?word=" + encodeURIComponent(query),success: function(data) {console.log(data);BuildHtml(data);},error: function() {$(".container .result").empty().append('<div class="no-results">请求失败,请重试</div>');}});}function BuildHtml(data) {let resultLabel = $(".container .result");resultLabel.empty();// 确保 data 是数组并检查长度if (!Array.isArray(data) || data.length === 0) {resultLabel.append('<div class="no-results">没有找到结果</div>');return;}for (let elem of data) {let divLabel = $("<div>", { class: "item" });let aLabel, pLabel, iLabel;// 检查是否有标题if (elem.title) {aLabel = $("<a>", {text: elem.title,href: elem.url,target: "_blank"});divLabel.append(aLabel);}// 显示描述pLabel = $("<p>", {text: elem.desc || "无描述"});divLabel.append(pLabel);// 显示链接iLabel = $("<i>", {text: elem.url});divLabel.append(iLabel);resultLabel.append(divLabel);}}</script> </body> </html>
运行结果