C++实战:搜索引擎项目(二)
接上期 C++实战:搜索引擎项目(一)
接下来还有三个模块,搜索引擎模块,http_server模块,前端模块
搜索引擎模块 Searcher
思路
- 初始化(InitSearcher)
获取索引模块的单例对象(Index::GetInstance()),保证全局唯一索引。
调用index->BuildIndex(input),基于输入文件(如raw.txt)构建正排和倒排索引(索引构建逻辑在index.hpp中实现)。 - 搜索处理(Search)
接收用户查询词(query),输出 JSON 格式结果(json_string),分四步完成:
(1)分词处理
调用工具类JiebaUtil::CutString对查询词进行分词,结果存入words。
(2)索引触发
遍历分词结果,将每个词转为小写(统一大小写,与索引构建时的处理一致)。
调用index->GetInvertedList(word)查询倒排索引,获取包含该词的所有文档信息(InvertedElem,含文档 ID 和权重)。
将所有分词对应的倒排结果汇总到inverted_list_all(缺陷:可能存在重复文档,需后续去重优化)。
(3)结果排序
按文档权重(weight)对汇总结果降序排序(std::sort),权重越高的文档相关性越强,排在前面。
(4)构建 JSON 结果
遍历排序后的结果,通过文档 ID 从正排索引(index->GetForwardIndex)获取文档详情(标题、正文、URL)。
调用GetDesc生成文档摘要(截取包含关键词的上下文片段)。
使用Jsoncpp将结果封装为 JSON 格式(包含标题、摘要、URL、权重),写入json_string返回。 - 摘要生成(GetDesc)
在文档正文中查找关键词(忽略大小写),定位其位置。
以关键词位置为中心,向前截取 50 个字符、向后截取 100 个字符作为摘要(若边界不足则取到开头 / 结尾)。
若未找到关键词,返回正文前 160 个字符作为默认摘要。
代码基本结构
#include"index.hpp"namespace ns_searcher
{class Searcher{private:ns_index::Index* index;//索引对象的指针public:Searcher(){}~Searcher(){}public:void InitSearcher(const std::string &inp){//1.获取或者创建index对象//2.根据index对象建立索引}//query:搜索关键词//json_string:返回给用户的搜索结果void Search(const std::string &query, std::string *json_string){//1.[分词]:对query进行分词//2.[触发]:根据query的各个分词,进行index查找//3.[排序]:汇总查找结果,按照相关性(weight)进行排序//4.[构建]:根据结果,构建json串}}
}
void InitSearcher(const std::string &input)
{//1.获取或者创建index对象index = ns_index::Index::GetInstance();//2.根据index对象建立索引index->BuildIndex(input);
}
总代码
#include"index.hpp"
#include"util.hpp"
#include<algorithm>
#include<jsoncpp/json/json.h>
namespace ns_searcher
{class Searcher{private:ns_index::Index* index;//索引对象的指针public:Searcher(){}~Searcher(){}public:void InitSearcher(const std::string &input){//1.获取或者创建index对象index = ns_index::Index::GetInstance();std::cout<<"获取index对象成功"<<std::endl;//2.根据index对象建立索引index->BuildIndex(input);std::cout<<"建立索引成功"<<std::endl;}//query:搜索关键词//json_string:返回给用户的搜索结果void Search(const std::string &query, std::string *json_string){//1.[分词]:对query进行分词std::vector<std::string> words;ns_util::JiebaUtil::CutString(query, &words);//words存储query分词结果//2.[触发]:根据query的各个分词,进行index查找,建立index是忽略大小写的 ns_index::InvertedList_t inverted_list_all;//存储所有的倒排链表的元素for(std::string word : words){boost::to_lower(word);//统一转小写//先进行倒排查找ns_index::InvertedList_t* inverted_list = index->GetInvertedList(word);//根据关键字找到倒排链表if(nullptr == inverted_list)//当前词没有倒排链表,并且也无法进行正排查找{std::cerr<<word<<" have no inverted_list"<<std::endl;continue;}//缺陷:关键词对应有相同的倒排链表元素,会导致inverted_list_all中有重复的元素 TODOinverted_list_all.insert(inverted_list_all.end(), inverted_list->begin(), inverted_list->end());}//3.[排序]:汇总查找结果,按照相关性(weight)进行排序std::sort(inverted_list_all.begin(), inverted_list_all.end(), [](const ns_index::InvertedElem &e1, const ns_index::InvertedElem &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.word);//这里不能全部放入,只要一部分作为摘要elem["url"] = doc->url;//for debugelem["weight"] = item.weight;root.append(elem);}Json::StyledWriter writer;*json_string = writer.write(root);}std::string GetDesc(const std::string &html_content, const std::string &word)//截取摘要{const int prev_step = 50;const int next_step = 100;//1.先查找word在content中的位置//std::size_t pos = html_content.find(word);//原始文档大小写都有,而关键字都被转成小写,find无特定模式忽略大小写查找auto iter = std::search(html_content.begin(), html_content.end(),word.begin(), word.end(),[](char ch1, char ch2){return std::toupper(ch1) == std::toupper(ch2);});if(iter == html_content.end()){return "正文无关键词" + html_content.substr(0,160);//直接返回前160个字符}int pos = iter - html_content.begin();//2.从pos位置向前取50个字符(如果没有,从begin开始),向后取100个字符(如果没有,到end结束)int begin = (pos < prev_step) ? 0 : (pos - prev_step);int end = ((html_content.size() - pos) < next_step) ? html_content.size() : (pos + next_step);return html_content.substr(begin, end - begin);}};
}
http_server模块
相关库下载
cpp-httplib 是一个第三方轻量级 HTTP 服务器库,用于快速搭建 HTTP 服务(处理请求、响应、路由等)。
安装 cpp-httplib库:https://gitee.com/welldonexing/cpp-httplib
注意:用老的编译器,可能会编译不通过,或者直接运行出错
搜索:scl gcc devsettool 升级gcc
思路
核心过程
主函数是程序入口,负责初始化搜索引擎和启动 HTTP 服务,具体分为 3 个阶段:
阶段 | 核心操作 | 目的 |
---|---|---|
1. 搜索引擎初始化 | 创建Searcher对象,调用InitSearcher(input) | 加载raw.txt中的原始数据,构建索引(正排与倒排索引) |
2. HTTP 服务配置 | 创建httplib::Server对象,设置静态资源目录 | 让客户端能访问wwwroot下的前端页面(搜索框页面) |
3. 路由与请求处理 | 注册/s路径的GET请求回调函数,启动服务监听0.0.0.0:8081 | 接收客户端的搜索请求,处理并返回结果;监听所有网卡的 8081 端口,支持外部访问 |
代码
#include "cpp-httplib/httplib.h" // 引入cpp-httplib库,用于搭建HTTP服务器
#include "searcher.hpp" const std::string root_path = "./wwwroot";
const std::string input = "data/raw_html/raw.txt";int main()
{// -------------------------- 步骤1:初始化搜索模块 --------------------------ns_searcher::Searcher search;search.InitSearcher(input);// -------------------------- 步骤2:创建并配置HTTP服务器 --------------------------// 创建cpp-httplib的Server对象,用于监听HTTP请求、处理路由httplib::Server svr;// 配置1:设置静态资源目录// 作用:当浏览器请求静态资源(如http://ip:port/index.html)时,服务器会从root_path目录下查找对应文件svr.set_base_dir(root_path.c_str());// 配置2:注册GET请求路由(搜索接口)// 路由路径:/s(约定为搜索接口,前端通过该路径提交搜索请求)// 回调函数:接收请求(req)、处理并生成响应(rsp),捕获外部的search对象供内部使用svr.Get("/s", [&search](const httplib::Request& req, httplib::Response& rsp) {// -------------------------- 子步骤1:校验请求参数 --------------------------// 检查请求中是否包含"word"参数(前端提交的搜索关键词,如http://ip:port/s?word=xxx)if(!req.has_param("word")){// 若缺少参数,返回错误提示:设置响应内容类型为纯文本,编码为UTF-8(避免中文乱码)rsp.set_content("必须要有搜索关键字!","text/plain; charset=utf-8");return; // 终止回调,避免后续无效处理}// -------------------------- 子步骤2:提取搜索关键词 --------------------------// 从请求参数中获取"word"的值(即用户输入的搜索关键词)std::string word = req.get_param_value("word");// 日志打印:记录用户的搜索行为(便于调试和统计)std::cout<<"用户在搜索:"<<word<<std::endl;// -------------------------- 子步骤3:调用搜索模块获取结果 --------------------------// 定义字符串存储JSON格式的搜索结果(后续返回给前端)std::string json_string;// 调用搜索器的Search方法:传入关键词,结果写入json_string(内部包含分词、索引检索、排序、JSON构建)search.Search(word, &json_string);// -------------------------- 子步骤4:构建并返回HTTP响应 --------------------------// 设置响应内容为JSON字符串,指定内容类型为application/jsonrsp.set_content(json_string, "application/json");});// -------------------------- 步骤3:启动服务器并监听 --------------------------// 监听地址:0.0.0.0(允许所有网络设备访问,而非仅本地)// 监听端口:8081(自定义端口)// 注意:服务器启动后会阻塞在此行,持续等待并处理HTTP请求svr.listen("0.0.0.0", 8081);return 0;
}
前端模块
双标签与单标签
了解html,css,js
html:网页的骨骼 – 负责网页的结构
css:网页的皮肉 – 负责网页的美观
js(javascript):网页的灵魂 – 网页的动态效果,和前后端交互
教程:https://www.w3school.com.cn/
HTML、CSS、JS 分别承担 “结构”“样式”“交互” 的核心角色,三者的代码编写需遵循 “先搭骨架(HTML)→ 再穿衣服(CSS)→ 最后加动作(JS)” 的逻辑顺序。
顺序上:先 HTML(结构)→ 再 CSS(样式)→ 最后 JS(交互);
协作上:HTML 提供 DOM 节点,CSS 通过选择器修饰节点,JS 通过 DOM API 操作节点(内容 / 样式 / 事件)。
编写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"><title>boost 搜索引擎</title>
</head>
<body><div class="container"><div class="autor"><p>作者:syb</p></div><div class="title"><h1>boost 搜索引擎</h1></div><div class="search"><input type="text" value="请输入搜索关键字"><button>搜索一下</button></div><div class="result"><div class="item"><a href="#">这是标题</a><p>这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要</p><i>url</i></div><div class="item"><a href="#">这是标题</a><p>这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要</p><i>url</i></div><div class="item"><a href="#">这是标题</a><p>这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要</p><i>url</i></div><div class="item"><a href="#">这是标题</a><p>这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要</p><i>url</i></div><div class="item"><a href="#">这是标题</a><p>这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要</p><i>url</i></div> </div></div>
</body>
</html>
编写css
设置样式的本质:找到要设置的标签,设置它的属性。
- 选择特定的标签:类选择器,标签选择器,复合选择器
- 设置指定标签的属性:见代码
编写到<style>…</style>中
<style>/*去掉内外边距,html的盒子模型*/*{margin: 0; /* 外边距 */padding: 0;/* 内边距 */box-sizing: border-box; /* 盒子模型 */}/*将我们的body内容100%与html的呈现吻合*/html,body{height: 100%;}/*类选择器*/.container{width: 800px;/* 设置div宽度为800px */margin: 0px auto; /* 上下0px,左右自动 *//*设置外边距的上边距,保持元素和网页的上部距离*/margin-top: 15px;}/*复合选择器,选中container类下的author类*/.container .autor{text-align: left;/* 2. 消除容器左侧内边距(关键:避免文字与容器左侧有间隙) */padding-left: 0;/* 3. 取消段落首行缩进(针对中文段落默认缩进的情况) */text-indent: 0;}/*复合选择器,选中container类下的title类*/.container .title{text-align: center; /* 文字居中 */margin-top: 20px; /* 外边距上部20px */margin-bottom: 40px; /* 外边距下部40px */}/*后代选择器,选中title类下的h1标签*/.container .title h1{font-size: 80px; /* 字体大小80px */font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; /* 字体 */color: black; /* 字体颜色black */height: 100px; /* 高度100px */}/*复合选择器,选中container类下的search类*/.container .search{/*宽度与父标签保持一致*/width: 100%;height: 52px; /* 高度52px */}/*后代选择器,选中search类下的input标签*/.container .search input{float: left; /* 左浮动 */width: 600px; /* 宽度600px */height: 52px; /* 高度52px */padding-left: 10px; /* 内边距左侧10px */padding-right: 10px; /* 内边距右侧10px */border: 1px solid black; /* 边框1px,实线,颜色black */border-radius: 5px; /* 边框圆角5px */font-size: 17px; /* 字体大小17px */font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; /* 字体 */color: gray; /* 字体颜色gray */}/*后代选择器,选中search类下的button标签*/.container .search button{float: right; /* 右浮动 */width: 150px; /* 宽度150px */height: 52px; /* 高度52px */border: 1px solid black; /* 边框1px,实线,颜色black */border-radius: 5px; /* 边框圆角5px */background-color: #3c2feb; /* 背景颜色#32afe4 */color:blanchedalmond; /* 字体颜色blanchedalmond */font-size: 17px; /* 字体大小17px */font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; /* 字体 */cursor: pointer; /* 鼠标样式为手指 */}.container .result .item{width: 100%; /* 宽度100%继承父标签 */margin-top: 20px; /* 外边距上部20px */}.container .result .item a{display: block; /* 块级元素 */font-size: 22px; /* 字体大小22px */font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; /* 字体 */color: blue; /* 字体颜色blue */text-decoration: none; /* 去掉下划线 */}.container .result .item a:hover{text-decoration: underline; /* 鼠标悬停时显示下划线 */}.container .result .item p{margin-top: 5px; /* 外边距上部5px */font-size: 15px; /* 字体大小15px */font-family:'Franklin Gothic Medium', 'Arial Narrow', Arial, sans-serif; /* 字体 */color: black; /* 字体颜色black */}.container .result .item i{display: block; /* 块级元素 */margin-top: 5px; /* 外边距上部5px */font-size: 12px; /* 字体大小12px */font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; /* 字体 */color: green; /* 字体颜色green */}
</style>
编写js
如果直接使用原生的js成本会比较高,推荐使用JQuery
写在<script>…</script>中
<script>function Search(){//1.提取数据,$可以理解成JQuery的别称let query = $('.container .search input').val();console.log("query = " + query);//console是浏览器的控制台(F12打开),可以用来进行查看js的数据//2.发起https请求,ajax:属于一个和后端进行数据交互的函数,JQuery中自带的$.ajax({type: "GET", //请求类型url:"/s?word=" + query, //请求的url地址success: function(data){console.log(data);BuildHtml(data);}})}function BuildHtml(data){//获取html中的result标签let result_label = $('.container .result');//清空历史标签中的内容result_label.empty();for(let elem of data){let a_label = $("<a>",{text: elem.title,href: elem.url,//跳转到新的页面target: "_blank"}); //创建a标签let p_label = $("<p>",{text: elem.desc}); //创建p标签let i_label = $("<i>",{text: elem.url}); //创建i标签let div_label = $("<div>",{class: "item"}); //创建div标签div_label.append(a_label);div_label.append(p_label);div_label.append(i_label);result_label.append(div_label);}}
</script>
其他问题
搜索出相同结果
这里随便建立一个.html文件放在parser要搜索的路径下
这里是拿其他文件改的
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html><head><!-- Copyright (C) 2002 Douglas Gregor <doug.gregor -at- gmail.com>Distributed under the Boost Software License, Version 1.0.(See accompanying file LICENSE_1_0.txt or copy athttp://www.boost.org/LICENSE_1_0.txt) --><title>用来测试</title><meta http-equiv="refresh" content="0; URL=../../libs/core/doc/html/core/ref.html"></head><body>你是一个好人</body>
</html>
如果用户输入关键词为“你是一个好人”,通过jieba分词会分成,“你/是/一个/好人”,一共4个词,然而只有一个测试文档中的内容有“你是一个好人”同样也被jieba分词成4个词,这样关键词“你”“是”“一个”“好人”,每个关键词搜索结果都是这一个文档,且权重weight分别是1,出现如下图情况。
这4个文档实际上是一样的,我们不希望都显示出同一个文档,我们希望将这几个搜索结果合并一下,成为一个weight为4的测试文档。
对比InvertedElem结构体,建立一个InvertedElemDedup结构体,目的是在一次搜索中,多个关键词对应同一个文档时,将它们(关键词)进行合并。
searcher.hpp
namespace ns_searcher
{struct InvertedElemDedup{uint64_t doc_id;//一次搜索中,多个关键词对应同一个文档int weight;//总权重(多个关键词的权重之和)std::vector<std::string> words;//多个关键词放在一起InvertedElemDedup():doc_id(0), weight(0){}};
...
建立新的去重的倒排拉链,及map映射关系( doc_id 与 InvertedElemDedup的映射),然后在原来代码最后本来只是简单地将InvertedElem元素插入,现在改为
- 对这些InvertedElem元素进行去重,去重后的元素(某些doc_id相同的进行合并)为InvertedElemDedup
- 将这些去重的元素插入到倒排链表,最终组成一次搜索的倒排链表
//2.[触发]:根据query的各个分词,进行index查找,建立index是忽略大小写的
//ns_index::InvertedList_t inverted_list_all;//存储所有的倒排链表的元素std::vector<InvertedElemDedup> inverted_list_all;//去重的倒排拉链
std::unordered_map<uint64_t, InvertedElemDedup> tokens_map;//存储中间结果,key是doc_id,value是InvertedElemDedupfor(std::string word : words)
{boost::to_lower(word);//统一转小写//先进行倒排查找ns_index::InvertedList_t* inverted_list = index->GetInvertedList(word);//根据关键字找到倒排链表if(nullptr == inverted_list)//当前词没有倒排链表,并且也无法进行正排查找{std::cerr<<word<<" have no inverted_list"<<std::endl;continue;}//缺陷:关键词对应有相同的倒排链表元素,会导致inverted_list_all中有重复的元素 TODO//inverted_list_all.insert(inverted_list_all.end(), inverted_list->begin(), inverted_list->end());for(const auto &elem : *inverted_list)//遍历倒排链表,去重,根据doc_id建立映射,其余:关键词进数组,权重累加{InvertedElemDedup &item = tokens_map[elem.doc_id];//如果没有这个doc_id,就会创建一个InvertedElemDedup对象item.doc_id = elem.doc_id;item.weight += elem.weight;//累加权重item.words.push_back(elem.word);}
}
for(const auto &item : tokens_map)
{inverted_list_all.push_back(std::move(item.second));//去重后的元素组成倒排拉链
}
...
elem["title"] = doc->title;
elem["desc"] = GetDesc(doc->content, item.words[0]);//<----word改为words[0]取第一个关键字,查找摘要即可
elem["url"] = doc->url;
...
再次测试一下,只出现了一个结果。
weight改为了4,是累加的结果。
添加日志
#pragma once
#include<iostream>
#include<string>
#include<ctime>#define NORMAL 1
#define WARNING 2
#define DEBUG 3
#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;}
部署服务到linux上
nohup ./http_server > log/log.txt 2>&1 &
nohup
是 Linux 系统中一个常用的命令,用于在后台运行进程,并且即使终端关闭(退出登录),进程也能继续执行。它的名字来源于 “no hang up”(不挂断)的缩写。
基本语法
nohup 命令 [参数] &
主要作用
- 忽略 SIGHUP 信号(当终端关闭时系统会发送此信号终止进程)
- 将命令的输出重定向到 nohup.out 文件(默认)
- 允许进程在后台持续运行
例子
1.基本用法(后台运行脚本)
nohup ./可执行程序 &
脚本输出会被保存到当前目录的 nohup.out 文件中(默认重定向到 $HOME/nohup.out)
注:如果不使用 &,命令会在前台运行,此时可以按 Ctrl+Z 暂停,再用 bg 命令放到后台
2.自定义输出文件
nohup ./可执行程序 > output.log 2>&1 &
output.log:将标准输出重定向到 output.log
2>&1:将错误输出也重定向到标准输出(即同样写入 output.log)
3.查看后台运行的 nohup 进程:
ps -ef | grep 命令名
4.终止后台运行的nohup进程:
kill -9 进程ID
(进程 ID 可通过 ps 命令查看)
结项总结—展望
其他方向的拓展
- 建立整站搜索
- 设计一个在线更新的方案,信号,爬虫,完成整个服务器的设计
- 不使用组件,而是自己设计一下对应的方案
- 在搜索引擎中,添加竞价排名
- 热词统计,智能显示搜索关键词(数据结构:字典树,优先级队列)
- 设置登录注册,引入对mysql的使用