1.应用层
疑问引入:在tcp传输中,write是把数据写给了网络吗?
不是,write是文件接口,他只能将内容写入到文件当中
其实在内核中有一段由sockfd对应结构体管理的TCP输入缓冲区和输出缓冲区(一个sockfd对应一组),调用write的时候其实将数据传输给了输出缓冲区,然后输出缓冲区发送到网络的时机由TCP自主决定,用户无法操作
结论:
1.TCP网络发送数据,本质是将数据从本端的发送数据缓冲区通过网络拷贝到对端的接受数据缓冲区
2.TCP支持全双工,本质是TCP拥有一对发送接受缓冲区,所以可以在发送数据的同时接收数据
3.每一个发送单元都是一个生产者消费者模型,发送缓冲区满了就write不了,接收缓冲区空了就read不了,所以才要让write和read都是阻塞等待
4.数据报粘报问题:数据可能会不完整的被用户读取
发送缓冲区只关心最终是否将所有写入数据发送完成,并不关心数据是否是完整发送的,接收缓冲区就需要对接收的数据进行判断,如果他认为接收的数据就是完整的,那么就可以给用户去读取
1.1网络服务端计算器
方案1:传递结构体变量来传输数据
由于我们实现的是远端计算器,所以我们直接传递包含需要进行运算的数据值,以及运算符号的结构体变量,然后服务器端再进行解析就可以去进行运算了
注意:
1.该方案可行,但是只能在同一主机的os内核之间进行,在不同主机间网络传输中是不行的
原因如下:
1.字节序问题(内存对齐规则不同)
2.跨语言问题
3.编译器不同,规则不同
方案2:序列化与反序列化
如果直接传递结构体数据,那么网络传输每次需要考虑的东西都是不同的,比如这次是三个字符串,下次是十个字符串,处理方法从空格隔开变为""隔开.......
为了让网络传输底层更加简洁,我们配套使用自定义协议下的序列化与反序列化方案
**序列化:**将网络传输数据由多变一,作为一个完整字节流进行网络传输
**反序列化:**将网络发送过来的数据由一变多,重新解析成结构体数据
序列化的优势:
1.方便网络传输,因为底层只关心字符串的内容和大小
2.具有高扩展性和可维护性,上层结构体的修改不影响网络传输
与osi七层模型的上三层对应关系:
1.TCPserver:负责通信,对应会话层
2.Protocal:负责解析,对应表示层
3.Calculater:负责具体计算业务,属于应用层
1.2守护进程
1.2.1前后台进程
前台进程会影响linux服务器的运行,在进程运行完成之前,无法继续使用linux
使用方式:
命令语句 + 回车
后台进程则不会影响linux服务器的正常使用
使用方式:
命令语句 + & + 回车
1.2.2进程组
这里我们让三个sleep进程同时作为后台进程执行同一个任务,他们的组号就是1
PGID就是他们的进程组id,为60331,这是因为进程组的id一般就是组长进程的pid
进程组组长可以创建进程组或者创建该组中的进程,生命周期为该进程组创建开始到最后一个进程组内进程退出为止,组长进程退出不影响进程组id
引入:会话(包含一个或多个进程组)
举例:当我们用户在登录云服务器的时候,就是启动了一个会话,客户端的shell进程就启动了,然后由会话内的进程组负责完成我们需要完成的任务,但是这种进程会受到用户登录和退出的影响
如果需要让任务不受用户登录和退出的影响,我们可以将他设置为独立会话(也叫守护进程)
注意:一个会话中只有一个前台进程,但是可以有多个后台进程
只有前台进程可以从终端获取数据,后台进程无法获取
1.2.3接口
(1)创建独立会话:setsid
**创建要求:**申请创建一个独立会话的进程必须不是进程组组长
为了达到创建要求,我们会使用fork创建子进程,然后让当前进程退出,将代码任务交给子进程执行,此时虽然组长进程退出,但是进程组仍然存在,非组长进程允许创建独立会话
**返回值:**新的会话id/进程的新pid(两者相等)
守护进程的本质是一个孤儿进程
1.3实现网络服务端计算器
实现功能:
客户端输入需要计算的两个数以及运算符,服务器端返回计算结果
文件结构:
文件功能: 绿色为当前项目实现文件,其他的文件都是直接调用
1.Calculator:负责具体计算业务,属于OSI七层模型的应用层2.Client:客户端处理
3.Daemon:构建守护进程,独立于当前会话的新会话,作为后台进程持续运行
4.Inetaddr:负责网络地址与主机地址的转化
5.logger:日志打印
6.main:服务器端处理
7.mutex:锁功能
8.Praser:报文解析具体实现
9.Protocal:协议规定
10.Socket:套接字封装
11.TcpServer:服务器端业务逻辑实现
讲解顺序:10->11->9->8->1->3->2->6
1.Socket.hpp
cpp#pragma once #include <iostream> #include <string> #include <unistd.h> #include <cstdlib> #include <sys/types.h> #include <sys/socket.h> #include <arpa/inet.h> #include <netinet/in.h> #include "logger.hpp" #include "Inetaddr.hpp" static int gbacklog = 16; static int gsockfd = -1; enum { SUCCESS, CREATE_ERR, BIND_ERR, LISTEN_ERR, }; class Socket { public: virtual ~Socket() {}; virtual void CreateSocketOrDie() = 0; virtual void BuildSocketOrDie(int port) = 0; virtual void ListenSocketOrDie(int backlog) = 0; virtual std::shared_ptr<Socket> Accept(Inetaddr *clientaddr) = 0; virtual int SockFd() = 0; virtual void Close() = 0; virtual ssize_t Recv(std::string *out) = 0; virtual ssize_t Send(std::string *in) = 0; virtual bool Connect(Inetaddr &peer) = 0; public: void BulidListenSocketMethod(int port, int backlog = gbacklog) { CreateSocketOrDie(); BuildSocketOrDie(port); ListenSocketOrDie(backlog); } void BuildClientSocketMethod() { CreateSocketOrDie(); } }; /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// class TcpSocket : public Socket { public: TcpSocket(int sockfd = gsockfd) : _sockfd(sockfd) {}; void CreateSocketOrDie() override { _sockfd = socket(AF_INET, SOCK_STREAM, 0); if (_sockfd < 0) { LOG(loglevel::FATAL) << "create socket error"; exit(CREATE_ERR); } } void BuildSocketOrDie(int port) override { Inetaddr local(port); if (bind(_sockfd, local.getstruct(), local.structlen()) != 0) { LOG(loglevel::FATAL) << "bind socket err"; exit(BIND_ERR); } } void ListenSocketOrDie(int backlog) override { if (listen(_sockfd, backlog) != 0) { LOG(loglevel::FATAL) << "listen socket err"; exit(LISTEN_ERR); } } std::shared_ptr<Socket> Accept(Inetaddr *clientaddr) override // 底层是socket,但是返回的是结构体 { struct sockaddr_in peer; socklen_t len = sizeof(peer); int fd = accept(_sockfd, (struct sockaddr *)&peer, &len); if (fd < 0) { LOG(loglevel::WARNING) << "accept socket err"; return nullptr; } clientaddr->Init(peer); return std::make_shared<TcpSocket>(fd); } int SockFd() override { return _sockfd; } void Close() override { if (_sockfd >= 0) { close(_sockfd); } } ssize_t Recv(std::string *out) override { char buff[1024]; ssize_t n = recv(_sockfd, buff, sizeof(buff) - 1, 0); if (n > 0) { buff[n] = 0; *out += buff; // 不断累加 } return n; } ssize_t Send(std::string *in) override { return send(_sockfd, in->c_str(), in->size(),0); } bool Connect(Inetaddr &peer) override { int n = connect(_sockfd,peer.getstruct(),peer.structlen()); if(n >= 0) { return true; } return false; } private: int _sockfd; };在封装套接字的时候使用的是模板类设计模式,即先创建一个基类(在基类中规定好需要实现的方法)
注意:
1.基类一般使用纯虚函数来进行方法规定,子类需要override覆写
2.Tcp是面向字节流的,所以这里recv的数据接收是累加的
落实到Socket的封装上:
基类socket:
1.规定出套接字创建,绑定,监听,接受新连接,接受发送数据等方法
2.按照实际构建需求对细分方法进行组合,构建出外部可以直接使用的接口
eg:利用套接字创建,绑定,监听组合封装一个监听套接字生成方法
子类Tcpsocket:
对基类的函数进行具体的实现,然后外部使用的时候就可以直接使用组合后接口
2.TcpServer.hpp
cpp#pragma once #include <memory> #include <unistd.h> #include <signal.h> #include <functional> #include "Socket.hpp" using callback_t = std::function<std::string(std::string &)>; class TcpServer { public: TcpServer(int port, callback_t cb) : _port(port), _listensocket(std::make_unique<TcpSocket>()), _cb(cb) { _listensocket->BulidListenSocketMethod(_port); } void HandlerRequest(std::shared_ptr<Socket> sockfd, Inetaddr addr) { std::string inbuff; while (true) { ssize_t n = sockfd->Recv(&inbuff); if (n > 0) { LOG(loglevel::DEBUG) << addr.Tostring() << "# " << inbuff; // 进行数据处理 std::string send_str = _cb(inbuff); if (send_str.empty()) continue; sockfd->Send(&send_str); } else if (n == 0) { LOG(loglevel::INFO) << "quit"; break; } else { LOG(loglevel::FATAL) << "read err"; break; } } } void Run() { signal(SIGCHLD, SIG_IGN); while (true) { Inetaddr addr; auto sockfd = _listensocket->Accept(&addr); if (sockfd == nullptr) continue; LOG(loglevel::DEBUG) << "获取一个新链接: " << addr.Tostring() << ",sockfd: " << sockfd->SockFd(); pid_t id = fork(); if (id > 0) { sockfd->Close(); } else if(id == 0) { _listensocket->Close(); HandlerRequest(sockfd,addr); exit(0); } } } private: int _port; std::unique_ptr<Socket> _listensocket; callback_t _cb; };由于Tcpserver只负责进行数据io通信,所以我们的数据处理直接交给外部传递的回调函数来完成
**HandlerRequest接口:**属于内部调用接口,专门负责接收并处理数据
**Run接口:**负责完整通信,包括接受新连接,以及接受链接之后调用HandlerRequest接口进行数据处理与返回
注意:
为了防止链接阻塞,所以我们创建了子进程来实际执行处理任务,而父进程执行流就继续进行下一次接受新连接的循环
3.Praser.hpp
cpp#pragma once #include "Protocal.hpp" #include <iostream> #include <string> #include <functional> using Handler_t = std::function<Response(Request &rq)>; class Praser { public: Praser(Handler_t handler) :_handler(handler) {} std::string prase(std::string &inbuff) { std::string sendstr; while (true) { std::string json; int n = Protocal::UpPack(inbuff, &json); if (n < 0) { exit(0); } else if (n == 0) { break; // 解析完成 } else // 解包成功->反序列化 { Request req; if (!req.DeSerializa(json)) // 反序列化失败 { return std::string(); } // 计算处理 Response resp = _handler(req); // 序列化返回 std::string resp_json; if (!resp.Serializa(&resp_json)) // 结果序列化失败 { return std::string(); } sendstr += Protocal::Package(resp_json); return sendstr; } } } ~Praser() {} private: Handler_t _handler; };这个解析类专门负责除了具体业务以外的所有数据处理任务,包括数据解包->数据反序列化提取->调用业务处理接口处理具体业务->序列化数据->打包数据
4.Protocal.hpp
cpp#pragma once #include <iostream> #include <string> #include <jsoncpp/json/json.h> // 解决报文接受完整性问题 // 添加有效载荷 class Request { public: Request() {} // 序列化 bool Serializa(std::string *out) { Json::Value root; root["x"] = _x; root["y"] = _y; root["oper"] = _oper; Json::StyledWriter writer; *out = writer.write(root); return true; } // 反序列化 bool DeSerializa(std::string &in) { Json::Reader reader; Json::Value root; bool ret = reader.parse(in, root); if (!ret) return false; _x = root["x"].asInt(); _y = root["y"].asInt(); _oper = root["oper"].asInt(); return true; } int X() { return _x; } int Y() { return _y; } char Oper() { return _oper; } ~Request() {} // 约定1:x oper y public: int _x; int _y; char _oper; }; class Response { public: Response() {} // 序列化 bool Serializa(std::string *out) { Json::Value root; root["result"] = _result; root["code"] = _code; Json::StyledWriter writer; *out = writer.write(root); return true; } // 反序列化 bool DeSerializa(std::string &in) { Json::Reader reader; Json::Value root; bool ret = reader.parse(in, root); if (!ret) return false; _result = root["reslut"].asInt(); _code = root["code"].asInt(); return true; } void SetResult(int res) { _result = res; } void SetCode(int c) { _code = c; } void Print() { std::cout << "result" << _result; } ~Response() {} private: int _result; int _code; // 可信度,0(可信)/其他数表示错误码(不可信) }; static const std::string sep = "\r\n"; class Protocal { public: static std::string Package(std::string &jsonstr) // 格式化字符串 { if (jsonstr.empty()) return std::string(); std::string jsonlen = std::to_string(jsonstr.size()); return jsonlen + sep + jsonstr + sep; } static int UpPack(std::string &str, std::string *package) { if (package == nullptr) { return 0; } auto pos = str.find(sep); if (pos == std::string::npos) // 一定不是完整报文 { return -1; } // 至少有一个完整报文数据(比如len\r\n) std::string lenstr = str.substr(0, pos); int digit_len = std::stoi(lenstr); // 根据协议推测完整报文长度 int target_len = lenstr.size() + digit_len + 2 * sep.size(); if (target_len > str.size()) // 没有整个报文信息数据 { return -1; } // 拥有一整个完整报文信息 *package = str.substr(pos + sep.size(), digit_len); str.erase(0, target_len); return package->size(); } private: };协议需要规定请求方与应答方的接口
对于双方:
规定好序列化和反序列化接口
对于应答方或请求方的其他接口,则根据具体需求来添加
对于协议本身:需要将数据真正转化为可以网络传输的数据
打包接口:由于接收方识别完整字节流数据需要知道有效正文到底有多少字节,所以我们会将正文字节数添加到字节流中,格式为:len + sep + jsonstr + sep
解包接口:首先得至少识别到len,所以直接find(sep),知道len之后,根据打包格式推断出完整报文所占字节数,然后判断是否有完整报文,如果确实有一条完整报文,那么就对他进行剪切,提取到package中
注意:
1.这里我们的序列化和反序列化没有直接手写,而是使用了成熟的解决方案json
5.Calculator.hpp
cpp#pragma once #include <string> #include <iostream> #include "Protocal.hpp" class Calculator { public: Calculator() {} Response Exec(Request &rq) { Response resp; switch (rq.Oper()) { case '+': resp.SetResult(rq.X() + rq.Y()); break; case '-': resp.SetResult(rq.X() - rq.Y()); break; case '*': resp.SetResult(rq.X() * rq.Y()); break; case '/': { if (rq.Y() == 0) { resp.SetCode(1); } else { resp.SetResult(rq.X() / rq.Y()); } } break; case '%': { if (rq.Y() == 0) { resp.SetCode(2); } else { resp.SetResult(rq.X() % rq.Y()); } } break; } } ~Calculator() {} private: };主要逻辑:
通过switch语句控制实现不同的运算符计算,然后利用从request中提取的数据进行对应运算符计算,计算结果放入response中
6.Daemon.hpp
cpp#pragma once #include <iostream> #include <unistd.h> #include <signal.h> #include <fcntl.h> #include <sys/types.h> void Daemon() { // 忽略可能导致进程退出的信号 signal(SIGCHLD, SIG_IGN); signal(SIGPIPE, SIG_IGN); // 让单执行流不再为组长进程 if (fork() > 0) { exit(0); } // 构建新会话 setsid(); // 看待标准输入,输出,错误 int fd = open("/dev/null", O_RDWR); if(fd >= 0) { dup2(fd,0); dup2(fd,1); dup2(fd,2); } }这是用于构建守护进程的,因为守护进程是独立会话,不会受到当前会话窗口影响,即使当前会话退出了,守护进程也会继续运行,从而达到永不终断的目的
注意:
守护进程不能是进程组的组长进程,所以我们需要fork一个进程然后setsid构建新会话,最后将守护进程的标准输入输出以及错误流输出重定向到/dev/null文件中
cpp#pragma once #include<iostream> #include<string> #include<cstdint> #include<memory> #include"Socket.hpp" #include"Protocal.hpp" //./client serverip serverport int main(int argc, char* argv[]) { if(argc != 3) { exit(0); } std::string serverip = argv[1]; uint16_t serverport = std::stoi(argv[2]); std::unique_ptr<Socket> sockptr = std::make_unique<TcpSocket>(); sockptr->BuildClientSocketMethod(); Inetaddr server(serverport,serverip); if(sockptr->Connect(server)) { std::string inbuff; while(true) { //获取信息 Request req; std::cout << "please enter x: "; std::cin >> req._x; std::cout << "please enter y: "; std::cin >> req._y; std::cout << "please enter oper: "; std::cin >> req._oper; //序列化 std::string jsonstr; req.Serializa(&jsonstr); //打包(方便网络发送) std::string sendstr = Protocal::Package(jsonstr); //发送 sockptr->Send(&sendstr); //接受 sockptr->Recv(&inbuff); //报文解析 std::string package; int n = Protocal::UpPack(inbuff,&package); if(n > 0) { Response re; bool r = re.DeSerializa(package); if(r) { re.Print(); } } } } return 0; }这里直接调用之前封装好的接口,完成套接字创建,建立连接,数据序列化,打包,发送,接受计算结果数据,反序列化,解包,打印
cpp#include "TcpServer.hpp"//io通信 #include"Praser.hpp"//报文解析 #include"Protocal.hpp"//协议规定 #include"Calculator.hpp"//业务处理 #include"Daemon.hpp"//构建守护进程 #include <memory> int main() { Daemon(); EnableFileLogStrategy(); //业务模块 std::unique_ptr<Calculator> cal = std::make_unique<Calculator>(); //协议解析模块 std::unique_ptr<Praser> praser = std::make_unique<Praser>([&cal](Request &rq)->Response{ return cal->Exec(rq); }); //网络通信模块 std::unique_ptr<TcpServer> tsock = std::make_unique<TcpServer>( 8080,[&praser](std::string &inbuff)->std::string{ return praser->prase(inbuff); }); tsock->Run(); return 0; }这里我们采用的是osi七层模型的最高三层编写方法,将会话层,表示层,应用层分开编写,确保低耦合
软件交付流程:发送方
1.将文件编译好放置在指定文件夹中
eg:bin/Server conf/Server.conf
2.将文件压缩
接收方
1.将文件解压缩
2.将文件拷贝到系统指定目录下(安装)
3.使用
守护进程的工作路径建议设置为根目录
原因1:避免文件被挂起无法卸载
原因2:防止因守护进程运行而无法删除用户目录
原因3:防止因目录被删除导致进程出错
1.4HTTP协议
HTTP协议是一种超文本传输协议用于在浏览器和网页之间进行交互
他的请求和应答本质都是一个大字符串,然后通过字符串的切割来完成序列化和反序列化
代理服务器:
该服务器的代码中不直接硬编码完成服务端功能,而是利用回调函数执行服务端功能,所以功能就解耦了,我们既可以让他执行客户端函数,也可以让他执行服务器端函数,从而让该服务器既能当客户端,又能当服务器端,这种服务器叫做代理服务器
代理服务器的原理就是改变浏览器实际使用的库
将它使用的库从标准系统库替换为io接口添加了信息读取回调函数的系统库,从而每次运行的时候都能截获浏览器请求数据,然后再交给客户端
补充:
(1)HTTP常见Header
1.Content-Type:传输的数据的类型格式
2.Content-Length:正文字节数
3.Host:请求的服务器的ip地址和端口号
4.User-Agent:用户操作系统和浏览器版本信息
5.referer:表名是从哪个页面跳转过来的
6.Location告诉客户端接下来去哪访问
7.Cookie:在客户端存储少量信息,用于会话
8.Connection:连接状态,分为长连接和短连接,1.1版本支持长连接,即一次连接进行多个请求,短连接是一个链接一个请求
(2)HTTP状态码
1xx:信息响应中
2xx:成功响应
3xx:重定向响应
4xx:客户端错误
5xx:服务器错误
(3)临时重定向VS永久重定向
临时重定向不会影响客户端对服务器端地址的认知,所以客户端都会先访问旧地址,然后获取新的临时地址信息**(比如:用户登录成功跳转到网址首页)**
永久重定向则会改变客户端对服务器端地址的认知,客户端后续访问都是直接访问重定向后的地址**(比如:网站域名更换后,搜索引擎永久更换访问地址)**
访问流程:
客户端用a域名访问网站,而收到的应答是301/302状态码,且附上了b域名,则说明地址被重定向了,如果为临时重定向,客户端会继续访问b域名,如果为永久重定向,客户端会先将自己的访问地址标签改为b域名,然后再访问b域名的网站
(4)HTTP常用方法
1.GET方法:检索与获取资源
GET方法也可以传参,通过将参数追加到url上发送即可(该方法的输入数据会在网页上面回显)
2.POST方法:创建新资源或提交数据
POST方法通过正文来传参(该方法私密性更好,适合登录注册)
注意:这两种方法都是不安全的,因为他们都是明文传输的
(5)HTTP本身是无状态的,意思是不会对用户的行为做任何记录
eg:用户登录某个网站,跳转该网站其他页面之后又要重新登录
但是这种特征不符合我们的使用习惯,所以HTTP后面需要增加Cookie和Session来支持用户信息留存
Cookie技术:分为内存级和文件级保存
version-1:
客户端第一次请求的时候将账号密码传递给服务器,服务器的Cookie属性会记录下来,然后服务器应答客户端的时候会将Cookie属性一并发送过去
客户端第二次请求的时候,由于Cookie属性已经填写过了,所以就不用再填写了,直接可以进行账密认证
version-2:
版本一种的cookie由于是用户保管,所以很容易被黑客获取,所以版本2的cookie技术主要是引进了服务器的保管机制(但是如果session_id仍然被获取,盗号仍然发生)
服务器会给每一个用户创建一个session文件,用来保存客户cookie信息,并且会用session_id来作为cookie的内容进行发送和接收,如果客户端需要获取session的内容,在服务器端认证了session_id后,调用session文件内容并返回受保护信息
疑问:既然仍然可以被盗号,那么这个方案有什么补救措施?
他可以对登录地进行ip地址检测,如果发现ip地址进行跨省,跨境登录,则很可能是盗号者进行的登录请求,此时服务器可以直接释放掉用户session文件,从而组织盗号者进行登录,对于用户来说只需要重新登录一次即可
1.4.1http的请求与应答格式
**请求格式包含:**请求行,请求报头,空行,请求正文(有效载荷)
疑问1:什么是协议?为什么要有空行?
协议就是一种规定,在代码上体现为包含了相关字段,序列化与反序列化方法的类/结构体
空行是用来区分报头和有效载荷的
疑问2:正文部分一定要有吗?
正文是根据用户的目的来决定是否需要的,如果用户要上传信息则一定要有
**应答格式包括:**状态行,应答报头,空行,正文数据
分析网址属性
前面的http表示协议,chat.deepseek.com则是域名,域名会自动进行DNS操作解析为公网ip,而这个公网ip就是对应的全网唯一的主机ip地址,后面跟着的数据就是主机上对应文件资源所处的路径
URL与URI的区别:
URL是统一资源定位符,他既可以标识唯一文件资源,也可以找到唯一文件资源,它属于特殊的URI
URI是统一资源标识符,他包含了URL
注意:
1.协议版本(version)具有控制使用功能范围的效果
协议是具有版本的,这是因为协议所规范的功能可能会不断进行更新,需要不断更改协议内容
而企业的不同版本上线是有一个灰度上线法(逐渐增加覆盖功能的用户群体),这样子可以避免因为存在bug导致的部分群体无法正常使用微信
2.user_agent:用于标识客户端平台
3.如何识别完整报头?如何识别正文部分是否完全读取下来?
我们利用空行来识别是否读取到完整报头,用content_length字段识别是否正文部分被完全读取下来
1.4.2网址格式含义
C++中std::string::npos详解 - DeepSeek
https://chat.deepseek.com/a/chat/s/c9c1bdfb-8c3e-4710-9cba-f1ab770e57b在chat.deepseek.com后面接着的" / ":web根目录,是开发者指定的服务器上任意一个目录,用户可以通过网页访问该目录下的文件资源
" / "后面跟着的就是服务器上对应的文件路径
1.4.3编写HTTP协议服务器代码
文件结构:
分为前端文件和后端文件,其中wwwroot所包含的属于前端文件,主要包含各种网页的前端代码
剩下的属于后端文件,我们主要讲解后端编写思路与方法
我们先看main.cc文件来看整体运作方式:
cpp#include "TcpServer.hpp" #include "logger.hpp" #include "Http.hpp" #include <memory> HttpResponse Login(HttpRequest &req) { HttpResponse resp; resp.SetCode(200); resp.SetCookie("Set-Cookie",req.Body()); LOG(loglevel::DEBUG) << req.Body(); return resp; } HttpResponse Register(HttpRequest &req) { LOG(loglevel::DEBUG) << req.Body(); } HttpResponse Search(HttpRequest &req) { LOG(loglevel::DEBUG) << req.Body(); } int main(int argc, char *argv[]) { if (argc != 2) { exit(0); } EnableConsoleLogStrategy(); std::unique_ptr<Http> http = std::make_unique<Http>(); http->Register("/login", Login); http->Register("/register", Register); http->Register("/search", Search); uint16_t serverport = std::stoi(argv[1]); std::unique_ptr<TcpServer> tsock = std::make_unique<TcpServer>(serverport, [&http](std::string &reqstr) -> std::string { return http->HandlerRequest(reqstr); }); tsock->Run(); return 0; }Http.hpp文件中的Http类可以帮助服务器解决HTTP类型的请求
TcpServer.hpp文件中的TcpServer类则是解决数据网络传输问题(自己完成accept,recv,send的任务,然后利用回调函数调用Http中的实际处理数据方法获取需要send的数据)
注意:login,register等任务是HTTP中可以增加的功能函数,后续讲解
接下来讲解Http.hpp
文件顶部定义与头文件包含
cpp#pragma once #include <iostream> #include <sstream> #include <fstream> #include <vector> #include <cstring> #include <functional> #include <cerrno> #include <unordered_map> #include "logger.hpp" static const std::string linesep = "\r\n"; static const std::string innersep1 = " "; static const std::string innersep2 = ": "; static const std::string webroot = "./wwwroot"; static const std::string defaulthome = "index.html"; static const std::string html_404 = "404.html"; static const std::string suffixsep = "."; static const std::string argssep = "?";这里的定义基本都是分隔符,用来对http协议的各部分进行提取时需要使用到
总架构:HttpRequest,HttpResponse,Http
由于我们是只编写服务器端代码,所以这里HttpRequest只用写反序列化接口,HttpResponse只用写序列化接口
(1)HttpRequest
cppclass HttpRequest { private: std::string ReadOneLine(std::string &reqstr, bool *status) { auto pos = reqstr.find(linesep); if (pos == std::string::npos) { *status = false; return std::string(); } *status = true; // 用于区分line读取失败还是读到正文前的空行 auto line = reqstr.substr(0, pos); reqstr.erase(0, pos + linesep.size()); return line; } void ParseReqLine(std::string &reqline) { std::stringstream ss(reqline); ss >> _method >> _uri >> _httpversion; } void BuildKV(std::string &line, std::string *k, std::string *v) { auto pos = line.find(innersep2); if (pos == std::string::npos) { *k = *v = std::string(); return; } *k = line.substr(0, pos); *v = line.substr(pos + innersep2.size()); return; } public: HttpRequest() {} void Serialize() { } bool DeSerialize(std::string &reqstr) { bool status = true; std::string reqline = ReadOneLine(reqstr, &status); if (!status) { return false; } ParseReqLine(reqline); while (true) { status = true; reqline = ReadOneLine(reqstr, &status); if (!reqline.empty() && status) { std::string k, v; BuildKV(reqline, &k, &v); if (k.empty() || v.empty()) continue; _req_headers.insert(std::make_pair(k, v)); } else if (status) // 为空且status为true { _blank_line = linesep; break; } else // 即使没找到/r/n也继续进行后续操作 { break; } } _req_body = reqstr; _path = webroot; _path += _uri; if (_uri == "/") { _path += defaulthome; } LOG(loglevel::DEBUG) << "_path" << _path; if (_method == "GET") { auto pos = _path.find(argssep); if (pos != std::string::npos) { _req_body = _path.substr(pos + argssep.size()); // 访问的参数 _path = _path.substr(0, pos); // 要请求的资源 } LOG(loglevel::DEBUG) << "_args" << _req_body; LOG(loglevel::DEBUG) << "_path" << _path; } else if (_method == "POST") { _req_body = reqstr; } return true; } std::string Path() { return _path; } std::string Body() { return _req_body; } void SetPath(std::string &path) { _path = path; } std::string Suffix() // 获取资源类型 { if (_path.empty()) { return std::string(); } else { auto pos = _path.rfind(suffixsep); if (pos == std::string::npos) { return std::string(); } else { return _path.substr(pos); } } } ~HttpRequest() {} private: std::string _method; std::string _uri; std::string _httpversion; std::unordered_map<std::string, std::string> _req_headers; std::string _blank_line; std::string _req_body; std::string _path; // 访问的文件资源的路径 std::vector<std::string> _cookie; // std::string _args; };类功能:解析远端传送的HTTP报文信息
属性讲解:
1._method:可供选择的额外功能
2._uri:统一文件标识符
3._httpversion:协议版本
4._req_headers:请求报头的k-v对
5._blank_line:空行
6._req_body:正文信息
7._cookie:用户登录等信息
细分功能接口:
1.逐行提取:
cppstd::string ReadOneLine(std::string &reqstr, bool *status)由于HTTP的请求格式中,每一行都有/r/n作为结尾符,所以我们直接find出分隔符的位置,然后利用substr+erase进行剪切并返回提取结果
注意:status的作用是标志当前是读取失败还是读到请求格式中的空行,从而区分后续执行逻辑
2.提取请求行:
cppvoid ParseReqLine(std::string &reqline)在提取了请求行之后,将他放入stringstream类型的ss变量中,然后让ss将请求行的数据依次输入给_method,_uri,_httpversion(因为请求行和>>的分割符都是空格,所以可行)
3.提取请求报头:
cppvoid BuildKV(std::string &line, std::string *k, std::string *v)key和value之间的分割符是": ",所以分隔符之前的都是key,之后的都是value
4.设置文件路径:
cppvoid SetPath(std::string &path)直接将传递的路径赋值给_path即可
5.获取资源类型
cppstd::string Suffix()我们的文件资源路径中,结尾处就是文件资源类型,和前面的文件名是使用"."作为分隔符隔开的,所以我们直接rfind找.即可
主要功能接口:反序列化
cppbool DeSerialize(std::string &reqstr)第一步:提取请求行
调用一次提取一行的接口,然后用解析第一行的接口提取信息,并将信息放置在类内成员属性中
第二步:提取请求头的键值对
循环逐行提取键值对,如果遇到status为true但是提取为空,说明键值对全部提取完成,正在提取隔开正文的空行,将_blank_line填写为空,然后break
如果status为false说明提取失败,直接break
第三步:给剩下的成员属性赋值
第四步:提取GET和POST方法的参数
GET的参数是直接跟在文件访问路径后面的,使用?隔开,所以我们直接对_path进行find即可,然后将发送的参数给到_req_body,剩下的文件路径给到_path(因为官方规定有GET请求的时候不能有其他有效载荷,只能有参数)
POST的参数直接就是在正文中存储的,所以直接将剩下的reqstr给到_req_body即可
(2)HttpResponse
cppclass HttpResponse { private: std::string Code2Desc(int code) { switch (code) { case 200: return "OK"; case 400: return "Bad Request"; case 404: return "Not Found"; default: return ""; } } public: HttpResponse() : _code(200), _desc("OK"), _httpversion("HTTP/1.1"), _blank_line(linesep) { } std::string Serialize() { std::string respstr = _httpversion + innersep1 + std::to_string(_code) + innersep1 + _desc + linesep; if (!_resp_body.empty()) { std::string len = std::to_string(_resp_body.size()); SetHeader("Content-Length", len); } for (auto &elem : _resp_headers) { std::string line = elem.first + innersep2 + elem.second + linesep; respstr += line; } for (auto &cookie : _cookie) { respstr += cookie; respstr += linesep; } respstr += _blank_line; respstr += _resp_body; return respstr; } void DeSerialize() { } bool ReadContent(const std::string path) // 二进制方式读取,因为数据是可能有多重格式的 { // 1. 检查路径是否为空 if (path.empty()) { LOG(loglevel::WARNING) << "文件路径为空"; return false; } // 2. 打开文件(二进制模式 + 在末尾定位) std::ifstream file(path, std::ios::binary | std::ios::ate); // 3. 检查文件是否成功打开 if (!file.is_open()) { LOG(loglevel::WARNING) << "错误:无法打开文件 '" << path << "'" << "错误码: " << strerror(errno); return false; } // 4. 获取文件大小 std::streamsize fileSize = file.tellg(); // 5. 检查文件大小是否有效 if (fileSize < 0) { LOG(loglevel::WARNING) << "错误:无法获取文件大小"; file.close(); return false; } if (fileSize == 0) { LOG(loglevel::WARNING) << "提示:文件 '" << path << "' 为空"; file.close(); return false; } // 6. 回到文件开头 file.seekg(0, std::ios::beg); // 7. 创建字符串并调整大小 try { _resp_body.resize(static_cast<size_t>(fileSize)); } catch (const std::bad_alloc &e) { LOG(loglevel::WARNING) << "错误:内存分配失败 - " << e.what() << "文件大小: " << fileSize << " 字节"; file.close(); return false; } // 8. 读取文件内容 if (!file.read(_resp_body.data(), fileSize)) { LOG(loglevel::WARNING) << "错误:读取文件失败" << "已读取: " << file.gcount() << "/" << fileSize << " 字节"; file.close(); return false; } // 10. 关闭文件 file.close(); return true; } void SetCode(int code) { if (code >= 100 && code < 600) { _code = code; _desc = Code2Desc(_code); } else { LOG(loglevel::DEBUG) << "非法状态码" << code; } } void SetHeader(const std::string &key, const std::string &value) { _resp_headers[key] = value; } void SetCookie(const std::string &key, const std::string &value) { std::string cookie = key; cookie += ": "; cookie += value; _cookie.push_back(cookie); } ~HttpResponse() {} private: std::string _httpversion; int _code; std::string _desc; std::unordered_map<std::string, std::string> _resp_headers; std::string _blank_line; std::string _resp_body; std::vector<std::string> _cookie; };特殊成员变量:
1._code:应答状态码
2._desc:状态码描述
细分接口:
1.设置状态码与状态描述
cppvoid SetCode(int code) std::string Code2Desc(int code)先判断状态码是否合法,然后再将状态码和状态码对应的描述赋值
2.设置报头键值对
cppvoid SetHeader(const std::string &key, const std::string &value)直接将key和value给到unordered_map即可
3.设置cookie信息
cppvoid SetCookie(const std::string &key, const std::string &value)将k-v按照"key: value"的格式组织为一个字符串,然后将字符串插入_cookie字符串数组即可
4.以二进制格式读取指定文件内容
cppbool ReadContent(const std::string path) // 二进制方式读取,因为数据是可能有多重格式的主要接口:
cppstd::string Serialize()第一步:将状态行编写好给到respstr
第二步:循环执行,将报头都添加到respstr
注意:还需要添加一个键值对:"Content-Length: len",因为对端需要知道正文的大小
第三步:循环执行设置cookie,将所有cookie都添加好
第四步:添加剩下的请求信息,比如空行,正文等
(3)Http
cppclass HttpResponse { private: std::string Code2Desc(int code) { switch (code) { case 200: return "OK"; case 400: return "Bad Request"; case 404: return "Not Found"; default: return ""; } } public: HttpResponse() : _code(200), _desc("OK"), _httpversion("HTTP/1.1"), _blank_line(linesep) { } std::string Serialize() { std::string respstr = _httpversion + innersep1 + std::to_string(_code) + innersep1 + _desc + linesep; if (!_resp_body.empty()) { std::string len = std::to_string(_resp_body.size()); SetHeader("Content-Length", len); } for (auto &elem : _resp_headers) { std::string line = elem.first + innersep2 + elem.second + linesep; respstr += line; } for (auto &cookie : _cookie) { respstr += cookie; respstr += linesep; } respstr += _blank_line; respstr += _resp_body; return respstr; } void DeSerialize() { } bool ReadContent(const std::string path) // 二进制方式读取,因为数据是可能有多重格式的 { // 1. 检查路径是否为空 if (path.empty()) { LOG(loglevel::WARNING) << "文件路径为空"; return false; } // 2. 打开文件(二进制模式 + 在末尾定位) std::ifstream file(path, std::ios::binary | std::ios::ate); // 3. 检查文件是否成功打开 if (!file.is_open()) { LOG(loglevel::WARNING) << "错误:无法打开文件 '" << path << "'" << "错误码: " << strerror(errno); return false; } // 4. 获取文件大小 std::streamsize fileSize = file.tellg(); // 5. 检查文件大小是否有效 if (fileSize < 0) { LOG(loglevel::WARNING) << "错误:无法获取文件大小"; file.close(); return false; } if (fileSize == 0) { LOG(loglevel::WARNING) << "提示:文件 '" << path << "' 为空"; file.close(); return false; } // 6. 回到文件开头 file.seekg(0, std::ios::beg); // 7. 创建字符串并调整大小 try { _resp_body.resize(static_cast<size_t>(fileSize)); } catch (const std::bad_alloc &e) { LOG(loglevel::WARNING) << "错误:内存分配失败 - " << e.what() << "文件大小: " << fileSize << " 字节"; file.close(); return false; } // 8. 读取文件内容 if (!file.read(_resp_body.data(), fileSize)) { LOG(loglevel::WARNING) << "错误:读取文件失败" << "已读取: " << file.gcount() << "/" << fileSize << " 字节"; file.close(); return false; } // 10. 关闭文件 file.close(); return true; } void SetCode(int code) { if (code >= 100 && code < 600) { _code = code; _desc = Code2Desc(_code); } else { LOG(loglevel::DEBUG) << "非法状态码" << code; } } void SetHeader(const std::string &key, const std::string &value) { _resp_headers[key] = value; } void SetCookie(const std::string &key, const std::string &value) { std::string cookie = key; cookie += ": "; cookie += value; _cookie.push_back(cookie); } ~HttpResponse() {} private: std::string _httpversion; int _code; std::string _desc; std::unordered_map<std::string, std::string> _resp_headers; std::string _blank_line; std::string _resp_body; std::vector<std::string> _cookie; }; using func_t = std::function<HttpResponse(HttpRequest &)>; class Http { private: std::unordered_map<std::string, func_t> _handlers; public: std::string Suffix2Desc(std::string &suffix) { if (suffix == ".html") return "text/html"; else if (suffix == ".css") return "text/css"; else if (suffix == ".js") return "application/x-javascript"; else if (suffix == ".png") return "image/png"; else if (suffix == ".jpg") return "image/jpeg"; else if (suffix == ".txt") return "text/plain"; else return "text/html"; } public: Http() {} void Register(const std::string &action, func_t handler) { std::string key = webroot; key += action; _handlers[key] = handler; } std::string HandlerRequest(std::string &reqstr) { std::string respstr; HttpRequest req; HttpResponse resp; if (req.DeSerialize(reqstr)) { std::string target = req.Path(); // 交互式网页处理 if (_handlers.find(target) != _handlers.end()) { resp = _handlers[target](req); } else { // 静态网页处理 if (resp.ReadContent(req.Path())) { std::string suffix = req.Suffix(); std::string mime_type_value = Suffix2Desc(suffix); // 资源后缀转换成Content-Type resp.SetHeader("Content-Type", mime_type_value); resp.SetCode(200); } else { // std::string err404 = webroot + "/" + html_404; // // 资源不存在 // req.SetPath(err404); // resp.ReadContent(req.Path()); // resp.SetCode(404); resp.SetCode(302); resp.SetHeader("Location", "/404.html"); } } respstr = resp.Serialize(); } return respstr; } ~Http() {} };细分接口:
1.文件后缀转路径
cppstd::string Suffix2Desc(std::string &suffix)这个接口可以对不同的文件后缀进行识别,并将对应识别成功的文件后缀完整路径返回
2.添加追加功能接口
cppvoid Register(const std::string &action, func_t handler)建立对应功能的文件路径和功能接口的k-v关系
主要接口:
1.处理请求:
cppstd::string HandlerRequest(std::string &reqstr)反序列化后,查看当前请求是否已经注册过,如果注册过就直接执行注册功能接口
如果没有就执行静态网页代码
读取后都要序列化然后返回序列化后的结果字符串
1.5HTTPS
他是建立与HTTP协议层之下,TCP/UDP协议层之上的,具有加密解密功能的协议
对数据加密的原因:
1.防止出现"运营商劫持"事件,对客户端的请求的有效保护
2.防止出现连接公共网络导致的信息泄露事件
加密方法:
1.对称加密:加密和解密使用的秘钥是相同的(运算速度快)
2.非对称加密:需要用到两个秘钥进行加密和解密,一个是公钥,一个是私钥(运算很慢)
加密和解密必须使用不同秘钥(eg:加密公钥,解密私钥)
补充:
1.数据摘要:通过hash散列算法对数据进行运算,生成一段固定长度的数字摘要,他虽然不可以进行逆推,但是他却也并不属于加密算法,他是用来判断数据是否经过篡改的,只要数据出现了哪怕一点篡改,最终得出的散列值都是不一样的
1.5.1加密方案选择
1.只使用对称加密:
不可行,因为还要保证秘钥的传输是安全的,需要给秘钥再加密,从而进入死循环2.只使用非对称加密:
只能解决单方通信 ,但是通信速度慢不能达到双方通信的目的,因为私钥不能传播,只能存在于某一方手中,另一方只能发送秘文,不能解密秘文
3.双方各自持有一套非对称加密并使用:
解决了双端通信问题 ,没解决通信速度问题
此时两套非对称加密的方法是公开的,而套一的私钥在客户端手上,套二在服务器端手上,所以当客户端根据套二加密方式加密,服务器端就可以解析,而服务器端根据套一进行加密,客户端就能解析
4.非对称加密+对称加密:
该方案似乎可以解决通信速度和双端通信问题
利用非对称加密的公钥将客户端形成的对称加密秘钥以秘文方式发送给可以解密的服务器端,从而服务器和客户端都拥有了安全的对称秘钥,将最终通信方式变更为可行的对称加密
1.5.2中间人攻击
中间人是通信双端的信息传输枢纽,所有双端信息都可以被中间人截获,他可以让非对称加密+对称加密的方案失效
做法:
截取拥有一套非对称加密秘钥的A端的公钥之后,不发送给B端,而是将中间人的一套非对称加密公钥发送给B端,从而B端发送过来的包含B端秘钥的秘文就只能被中间人解密
此时中间人就获取了双端通信的对称加密秘钥
为了让双端正常通信,中间人还会将正确的B端对称秘钥利用A端公钥进行加密并发送给A端,从而A端也获取了正确的B端秘钥,可以正常进行秘文通信(但是会被中间人截获并解密)
疑问:该攻击的破解核心是什么?
只要接受公钥的一端可以分辨出当前传输过来的公钥是否真的是对端传递的公钥即可破解
1.5.3数据签名与CA证书
签名过程是对数据摘要进行对应公钥的加密,然后将数据和摘要加密后的签名直接追加在一起,从而形成签名数据
验证则是对数据摘要和利用私钥解密后的签名值进行对比,如果相同说明数据没有被篡改
而拥有私钥的签名者就是CA机构
证书:证书是同时包含签名和明文的数据,其中明文不仅包含公钥,还有其他的信息
在第五步验证证书的步骤中,即可验证发送的公钥的真伪,从而解决中间人攻击的问题













