项目实战 —— HTTP服务器设计与实现

目录

一,项目介绍

二,背景知识补充

2.1 http特点

2.2 URI,URL,URN

2.3 http请求方法

三,前置功能实现

3.1 日志编写

3.2 封装相关套接字

3.3 http请求结构设计

3.4 http响应结构设计

3.5 http服务器主体逻辑

四,读取和解析请求

4.1 EndPoing类介绍

4.2 读取http请求

4.3 解析http请求

五,构建并发回应答

5.1 CGI机制介绍

5.2 处理HTTP请求

5.3 CGI处理

5.4 非CGI处理

六,构建并发回响应

6.1 构建状态行

6.2 构建响应报头

6.3 发送响应

七,差错处理

7.1 逻辑错误

7.2 读取资源文件错误

 7.3 发送出错

八,接入线程池

8.1 为什么需要线程池

8.2 任务设计 

 8.3 线程池设计

九,测试

9.1 返回网页

9.2 测试CGI

9.3 实现成守护进程

十,源码


一,项目介绍

该项目是实现一个HTTP服务器,该服务器能通过基本的网络套接字读取客户端发送来的HTTP请求报文并进行解析,最终构建合适的HTTP响应报文并返回给客户端

  •  项目会抽取HTTP自定义协议的核心模块,采用浏览器与服务器形式的CS模型实现一个小的HTTP通信渠道,目的是深入学习HTTP协议的处理与响应过程
  • 该项目涉及技术:C/C++,网络套接字编程,单例模式,线程池,CGI等技术

二,背景知识补充

HTTP协议的内容我们在往期文章有过了解:计算机网络(六) —— http协议详解-CSDN博客

关于其它的比如套接字编程可以参考更多往期文章哦

2.1 http特点

主要有五个:

  • 服务器客户端模式:一条通信线路上必定有一端是服务端,另一端是客户端,请求从客户端发出,服务器收到请求构建响应发回,使信息的传递具有针对性
  • 简单快速:客户端只需将请求方法和请求资源路径传给服务器即可,这使得客户端不需要发送很多信息给服务器,并且http协议结构简单, 也使得http服务器通信规模较小,简单快速
  • 灵活:http协议允许传输任意类型的数据对象,也就是可以传图片,音频,视频等非文本资源,通过报头的Content-Type属性进行标记
  • 无连接:每次连接只对一个请求进行处理,发回响应报文给客户端并收到客户端的应答之后,直接断开连接,这样能大大节省传输时间,提高传输效率,比如Tcp就需要花费资源来维护连接才能进行传送
  • 无状态:http协议不对请求和响应之间的通信状态进行保存,每个请求独立,可以让http更快速处理事务,确保协议的可伸缩性;但是随着http的普及,图片视频等非文本资源量大大增加,再继续执行每次请求都断开连接的方案,明显增加了通信的代价,因此 HTTP 1.1 支持了长连接Keey-Alive,就是任意一端只要没有明确提出断开连接,则保持连接状态

2.2 URI,URL,URN

三个东东的定义如下:

  • URI(Uniform Resource Indentifier)统一资源标识符:用来唯一标识资源
  • URL(Uniform Resource Locator)统一资源定位符:用来定位唯一的资源
  • URN(Uniform Resource Name)统一资源名称:是通过名字来直接标识资源,访问直接下载

三者的关系如下:

2.3 http请求方法

方法

解释支持的HTTP版本
GET获取资源1.0,1.1
POST

传输实体

1.0,1.1
PUT传输文件1.0,1.1
HEAD获得报文首部1.0,1.1
DELETE删除文件1.0,1.1
OPTIONS询问支持的方法1.1
TRACE追踪路径1.1
CONNECT使用隧道协议连接代理1.1
LINK建立和资源之间的联系1.1
UNLINK断开联系1.1

目前市面上用到的95%以上的方法就是GET方法和POST方法,两种方法都可以带参,GET通过URL传参,POST通过请求报文的正文部分传参,所以GET传参一般用来上传简短的数据比如百度上传关键字时就是用的GET方法,POST方法一般用来传文件,因为URL一般不建议太长,而请求正文一般没有长度限制

三,前置功能实现

该项目要用到的文件如下:

后面的代码开头都会标记上该代码所在的文件名

3.1 日志编写

项目中的日志格式如下:

其中日志级别:

  • INFO:表示一切正常运行
  • WARNING:表示警告,意思是存在风险,但是不影响整体运行
  • ERROR:发生错误,但不致命,风险大于警告
  • FATAL:发生了致命的错误,直接导致整体运行失败

下面是日志头文件的编写:

//Log.hpp
#pragma once#include <iostream>
#include <string>
#include <ctime>#define INFO    1
#define WARNING 2
#define ERROR   3
#define FATAL   4#define LOG(level, message) Log(#level, message, __FILE__, __LINE__)void Log(std::string level, std::string message, std::string file_name, int line)
{std::cout << "[" << level << "]" << "[" << time(nullptr) << "]" << "[" << message << "]" << "[" << file_name << "]" << "[" << line << "]" << std::endl;
}
  •  time函数作用是获取时间戳,所以调用Log函数时不必带时间
  • __FILE____LINE__是C语言中的预定义符号,作用是获取当前文件名称和当前所在行,但由于我们的Log函数是定义在Log.hpp里的,那么每次调用时这两个参数都只会显示在Log.hpp里的位置
  • 所以我们可以使用宏定义,因为宏在预处理阶段会将代码替换到目标地点,这样就可以和time函数一样,自动获取所在文件名和所在行了

3.2 封装相关套接字

套接字的介绍和使用往期文章已经详细介绍过了,这里不再赘述:

计算机网络(二) —— 网络编程套接字_计算机网络中套接字-CSDN博客

计算机网络(三) —— 简单Udp网络程序_udp初始化-CSDN博客

下面是Socket.hpp的封装套接字的代码:

//Socket.hpp
#pragma once#include <iostream>
#include <cstdlib>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <pthread.h>
#include<sys/types.h>
#include "Log.hpp"class Socket
{
private: //设置成单例模式Socket(int port):_port(port),_listen_sock(-1){}Socket(const Socket &s) = delete;Socket* operator=(const Socket&) = delete;
public:static Socket *getinstance(int port) //获取单例模式{static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;if(nullptr == svr){pthread_mutex_lock(&lock);if(nullptr == svr){svr = new Socket(port);svr->InitServer();}pthread_mutex_unlock(&lock);}return svr;}void InitServer(){GetSocket();Bind();Listen();LOG(INFO, "tcp_server init ... success");}void GetSocket(){_listen_sock = socket(AF_INET, SOCK_STREAM, 0);if(_listen_sock < 0){LOG(FATAL, "socket error!");exit(1);}int opt = 1;setsockopt(_listen_sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); //使支持地址复用LOG(INFO, "create socket ... success");}void Bind(){struct sockaddr_in local;memset(&local, 0, sizeof(local));local.sin_family = AF_INET;local.sin_port = htons(_port);local.sin_addr.s_addr = INADDR_ANY;if(bind(_listen_sock, (struct sockaddr*)&local, sizeof(local)) < 0){LOG(FATAL, "bind error!");exit(2);}LOG(INFO, "bind socket ... success");}void Listen(){if(listen(_listen_sock, 10) < 0){LOG(FATAL, "listen socket error!");exit(3);}LOG(INFO, "listen socket ... success");}int Sock() { return _listen_sock; }~Socket(){if(_listen_sock >= 0) close(_listen_sock);}
private:int _port;int _listen_sock;static Socket* svr;
};Socket* Socket::svr = nullptr; //创建唯一对象,单例模式
  • 我们仍然把Socket搞成单例模式,然后提供一个全局访问点来访问,这次我们搞成饿汉模式,一开始就把对象创建好
  • 由于我们是云服务器,所以在填充sockaddr结构体的IP地址时,我们设置成INADDR_ANY即可,表示可以从本地任何一张网卡读取数据,并且由于INADDR_ANY本质就是0,所以也不需要inet_addr函数进行网络字节序转换
  • 由于我们后面会搞成多线程,所以要用到锁,锁我们用PTHREAD_MUTEX_INITIALIZER来定义,这样就不需要手动释放了,同时为了保证后续获取单例对象时避免频繁加锁解锁,我们以双检查的方式进行加锁

3.3 http请求结构设计

//Protocol.hpp
class Request
{
public:std::string request_line; //请求行std::vector<std::string> request_header; //包括请求报头各字段std::string blank; //表示空行std::string request_body; //请求正文//下面是保存解析完毕之后的结果,包括:http方法,URI,http版本等std::string method; //请求方法std::string uri; //URL也分成两部分,左边是URL,右边可能是带的参数std::string version; //http版本std::unordered_map<std::string, std::string> header_kv; //将报头中的字段以KV形式解析出来int content_length; //报头字段中表示正文的长度,单位字节std::string path; //表示URL的路径std::string query_string; //表示URL右边带的参数std::string suffix; //表示文件后缀,方便后面填写响应报文的Content-Tyoebool cgi; //表示是否要使用CGI模式int size;
public:Request(): content_length(0) //请求长度初始化为0, cgi(false) //默认不使用CGI{}~Request(){}
};

3.4 http响应结构设计

//Protocol.hpp
class Response
{
public:std::string status_line; //状态行std::vector<std::string> response_header; //响应报头的各个属性std::string blank; //空行std::string response_body; //响应正文int status_code; //表示响应报文中第一行请求行的状态码int fd; //表示用open打开的文件的文件描述符int size; //表示响应文件的大小std::string suffix; //表示响应文件的后缀
public:Response(): status_code(OK) //状态码默认200, fd(-1), blank(LINE_END) //设置空行, size(0){}~Response(){}
};

3.5 http服务器主体逻辑

HttpServer.hpp主题逻辑:

服务器主体逻辑和我们之前实现的那个很相似:计算机网络(六) —— http协议详解-CSDN博客

  • 将http服务器也搞成一个类,构造时传入端口,然后调用Loop就可以让服务器跑起来了:
  • 运行起来后首先就是获取Socket单例对象然后获取监听套接字,然后不断从中获取新连接‘
  • 当获取一个新连接后就创建一个线程来处理(前面我们先用线程,后期再搞成线程池)
//HttpServer.hpp
#include <iostream>
#include <pthread.h>
#include <signal.h>
#include "Log.hpp"
#include "Socket.hpp"
#include "Task.hpp"
#include "ThreadPool.hpp"
#include "Protocol.hpp"static const int defaultport = 8081; //默认端口,可更改class HttpServer
{
public:HttpServer(int _port = defaultport): port(_port),stop(false){}void InitServer(){//信号SIGPIPE需要进行忽略,如果不忽略,在写入时候,可能直接崩溃serversignal(SIGPIPE, SIG_IGN); }void Loop(){Socket *tsvr = Socket::getinstance(port); //获取单例对象LOG(INFO, "Loop begin");while(!stop){struct sockaddr_in peer;socklen_t len = sizeof(peer);int sock = accept(tsvr->Sock(), (struct sockaddr*)&peer, &len); //获取连接if(sock < 0) continue;int* _sock = new int(sock);pthread_t tid;pthread_create(&tid, nullptr, CallBack::HandlerRequest, (void*)_sock);pthread_detach(tid);//Task task(sock);//ThreadPool::GetInstance()->PushTask(task);}}private:int port;bool stop;
};

main.cc主函数逻辑:

  •  我们可以在命令行指定我们的端口号,然后用这个端口号创建一个HttpServer对象,然后调用Loop函数运行服务器,之后服务器就会不断获取新连接并创建线程来处理主业务逻辑
//main.cc
#include "HttpServer.hpp"
#include <iostream>
#include <string>
#include <memory>
#include "Daemon.hpp"static void Usage(std::string proc)
{std::cout << "Usage:\n\t" << proc << " port" << std::endl;;
}int main(int argc, char *argv[])
{//Daemon(); //守护进程if( argc != 2 ){Usage(argv[0]);exit(4);}int port = atoi(argv[1]);std::shared_ptr<HttpServer> http_server(new HttpServer(port)); //使用智能指针自动初始化和释放http_server->InitServer();http_server->Loop();return 0;
}

四,读取和解析请求

4.1 EndPoing类介绍

EndPoing这个单词的中文翻译是“终点,端点”,所以我们可以将其比作“终端”,经常用来描述进程间通信的双方,比如客户端和服务器通信时,客户端是一个EndPoint,服务器是另一个EndPoint,所以我们用这个单词来当我们服务器主逻辑的类的类名

下面是EndPoint类的总览

下面是EndPoint类的主结构:

//Protocol.hpp
class EndPoint
{
public:void RecvRequest(); //读取并解析请求void BuildReponse(); //构建http响应void SendResponse(); //响应构建好,然后就是发送响应了bool IsStop() { return stop; }EndPoint(int sock): _sock(sock), stop(false) {}~EndPoint() { close(_sock); }
private:int total;int _sock; //通信套接字Request http_request; //http请求Response http_response; //http响应bool stop; //表示读取是否出错
};

 设计线程回调

前面说了我们的HttpServer.hpp里的服务器主逻辑是创建线程然后让线程处理,所以我们需要给线程传一个回调函数,也就是前面的CallBack

服务器每获取到一个新连接就会创建一个新线程来进行处理,而这个线程的任务就是先搞出一个EndPoint对象,然后依次进行“读取请求-->解析请求-->构建响应-->发回响应”四个步骤,处理完成后通过析构函数关闭套接字即可,下面是CallBack类的代码:

//Protocol.hpp
class CallBack
{
public:CallBack(){}~CallBack(){}void operator()(int sock){HandlerRequest((void*)sock); //仿函数,重载(),调用HandlerRequest}static void* HandlerRequest(void* arg){LOG(INFO, "Hander Request Begin");
#ifdef DEBUG //测试打印http请求报头char buffer [4096];recv(sock, buffer, sizeof(buffer), 0);std::cout << "-------------begin----------------" << std::endl;std::cout << buffer << std::endl;std::cout << "-------------end----------------" << std::endl;
#elseint sock = *(int*)arg;EndPoint* ep = new EndPoint(sock);ep->RecvRequest();//读取并解析请求if(!ep->IsStop()) //当读取解析请求都成功时,才开始构建和发回相应{LOG(INFO, "Recv No Error, Begin Build And Send Reponse");ep->BuildReponse(); //构建响应ep->SendResponse(); //发回响应}else{LOG(WARNING, "Recv Error, Stop Build And Send Reponse");}delete ep;
#endifLOG(INFO, "Hander Request End");}
};

Protocol.hpp需要包含的头文件和需要定义的内容: 

//Protocol.hpp
#pragma once
#include<iostream>
#include<sstream>
#include<unistd.h>
#include<string>
#include<vector>
#include <algorithm>
#include <unordered_map>#include <sys/types.h>
#include <sys/stat.h>
#include<fcntl.h>
#include<sys/socket.h>
#include<sys/sendfile.h>
#include<sys/wait.h>
#include "Log.hpp"
#include "Util.hpp"#define SEP ": "
#define WEB_ROOT "wwwroot"
#define HOME_PAGE "index.html"
#define HTTP_VERSION "HTTP/1.0"
#define LINE_END "\r\n"
#define PAGE_400 "400.html"
#define PAGE_404 "404.html"
#define PAGE_500 "500.html"//状态码
#define OK 200
#define BAD_REQUEST 400
#define NOT_FOUND 404
#define SERVER_ERROR 500

4.2 读取http请求

我们测试打印的http请求如下:

读取和请求我们可以放到一个函数RecvRequest函数里,如下:

void RecvRequest() //读取并解析请求
{//只有当读取请求行和读取请求报头都成功的时候,才执行后续操作if((!RecvRequestLine()) && (!RecvRequestHeader())) {ParseRequestLine(); //解析请求行ParseRequestHeader(); //解析请求报头各字段RecvRequestBoby(); //如果是POST请求方法就去读取正文,如果不是POST就什么也不做}
}

①读取请求行

注意,我们这个只是把第一行的内容提取出来,提取出来后http版本等内容还是黏在一起的,这个步骤交给解析的时候处理,报头字段也是同理

下面是RecvRequestLine读取请求行函数代码:

//class EndPoint
bool RecvRequestLine() //读取请求报头的第一行请求行
{std::string& line = http_request.request_line;if(Util::ReadLine(_sock, line) > 0){line.resize(line.size() - 1);LOG(INFO, http_request.request_line);}else{stop = true; //如果读取出错,直接不玩了}return stop;
}

 我们熟知的换行符是“\n”,但是不同平台下的行分隔符可能不一样的,可能是“ \r ”,“ \n ”或者“ \r\n ”,所以不能直接用C/C++的gets或者getline等函数进行读取,我们需要手动实现一个ReadLine函数,使这个函数能兼容这三种分隔符,定义在一个工具类Unit里,代码如下:

//Unit.hpp
class Util
{
public://分隔符有很多,'\r\n'  '\n'  '\r',我们统一按照'\n'的方式将各字段读取static int ReadLine(int sock, std::string &out) //读取报头信息,能够处理各种行分隔符{char ch = 'a'; //初始化,可以随便设置,只要不是\n就行,目的是为了进入循环while(ch != '\n') //如果行分隔符是'\n',则自动退出循环,返回请求行长度{ssize_t s = recv(sock, &ch, 1, 0); //从sock里面读,读到ch里面,每次循环读1个字符if(s > 0) //读取成功{if(ch == '\r') //如果这个if条件成立,那么ch读取到的换行符有两种情况'\r\n' 和 '\r'{//查看一下'\r'后面的内容,不取走recv(sock, &ch, 1, MSG_PEEK); //MSG_PEEK这个选项,会直接返回接收队列的头部,但不取走,这叫做“数据窥探”if(ch == '\n') recv(sock, &ch, 1, 0); //如果是'\r\n',把'\r\n'转化成'\n'else ch = '\n'; //如果就是一个'\r'则直接转化为'\n'}//走到这里后,只能有两种字符:普通字符和 '\n'out.push_back(ch);}else if(s == 0) return 0;//表面对方已经关闭连接,所有读到0else return -1;}return out.size();}
}

解释下功能:

  •  一开始进入循环,先雷打不动读取一个字符,然后对这个字符做判断
  • 根据我们的循环条件,如果读取到普通字符,则再次循环读取,如果读取到' \n ',则自动退出循环
  • 如果读取到\r,那么有‘  \r\n ’ 和 ‘  \r ’,两种情况,那么我们使用“数据窥探”先查看一下下一个字符,如果窥探成功说明行分隔符是' \r\n ',失败则是' \r '
  • 然后不论是\r\n还是\r,都转化成\n,然后添加到最终提取结果中,这样就能兼容三种换行符了

②读取报头各字段 

http的请求报头都是按行排列的,所以可以循环调用ReadLine进行读取,并存储读取结果到http请求类的request_header字段中方便后续操作

//class EndPoint 
bool RecvRequestHeader() //读取报头各字段{std::string line;while(true) //当请求报文中出现单个\n时,就说明请求报头读取完毕{line.clear(); //每次读取前刷新一下//http请求报头的各字段都是以\n结尾的,所以可以用作分割符,将各字段分离出来if(Util::ReadLine(_sock, line) <= 0) {stop = true; //如果读取报头出错,我也直接不玩了break;}if(line == "\n") //这个就表示读取到的这一行只有一个单独的换行符,就表示读取到空行了{http_request.blank = line; //空行后面的就是请求正文break;}line.resize(line.size() - 1); //ReadLine读取时是会把\n一起搞上来的,但是我们不需要\n,所以要去掉http_request.request_header.push_back(line); //然后将各字段信息插入到请求类中,方便后续处理LOG(INFO, line);}   return stop;}

4.3 解析http请求

解析步骤主要涉及三个函数:

①解析请求行

  • ParseRequestLine函数作用就是将请求行中的http版本号,请求方法和URI都分开来,然后依次存储到请求类的对应字段里
  • 我们这里用stringstream以流的形式进行拆分
  • 并且由于平台不同,请求方法可能也会不同,比如有get,Get还有GET,所以我们存储请求方法时同一全部搞成大写再存储
//class EndPoint
void ParseRequestLine() //把请求行变成三个字符串后存起来
{auto &line = http_request.request_line; //拿到请求行std::stringstream ss(line); //stringstring可以将目标字符传以流的形式格式化输入到目标字符串里ss >> http_request.method >> http_request.uri >> http_request.version;//因为可能不是所有的协议都严格按照标准的,所以方法可能是 "Get",或者"get",就不是全是大写,所以我们需要做下处理auto &method = http_request.method;std::transform(method.begin(), method.end(), method.begin(), ::toupper); //toupper就是全转成大写,类似仿函数
}

②解析请求报头

  •  我们前面将读取到的一行行的请求报头都存储在数组里,所以我们直接遍历这个数组,以: 为分隔符拆分成一个一个键值对,然后存储到请求类的对应字段里,方便后续操作,如下代码:
//class EndPoint
void ParseRequestHeader() //把请求报头各字段变成一个一个的键值对然后存起来
{std::string key;std::string value;for (auto &iter : http_request.request_header){// 以SEP为分隔符,把字符从iter迭代器拿出来,通过这个函数做分割放到key和value里面if (Util::CutString(iter, key, value, SEP)){http_request.header_kv.insert(make_pair(key, value)); // 然后再以KV形式将key和value保存到map里// std::cout << "debug: " << key << std::endl;// std::cout << "debug: " << value << std::endl;}}
}

由于涉及到比较复杂的字符串切割工作,所以我们需要自己搞一个切割函数CutString,也定义在Unit工具类中:

//Unit.hpp
class Util
{
public:static bool CutString(const std::string &target, std::string &sub1_out, std::string &sub2_out, std::string sep){size_t pos = target.find(sep); //找到冒号的位置if(pos != std::string::npos){sub1_out = target.substr(0, pos); //截取到冒号位置sub2_out = target.substr(pos+sep.size()); //分割符是"\n ",是一个\n加一个空格,所以截取后面位置要从pos+分隔符大小位置处开始return true;}return false;}
};
  •  先通过find找到指定分隔符的位置,然后用substr进行截取

③读取请求正文(如果是POST方法)

  •  当请求方法是GET时,就是单纯的获取资源,我们只需要返回指定资源即可
  • 但是当请求方法是POST时,可能会有请求正文,我们就需要通过请求报头的Content-Length属性得知正文长度,然后进行读取并存储到请求类的对应字段

首先是判断是不是POST方法:

//Protocol.hpp
bool IsNeedRecvHttpRequestBody() // 判断是不是POST方法
{auto &method = http_request.method;if (method == "POST"){auto &header_kv = http_request.header_kv;// header_kv["Content-Length"] //并不是所有的协议都按照规定的,所以不建议这样写auto iter = header_kv.find("Content-Length"); // 找该属性if (iter != header_kv.end())                  // 找到了该属性{LOG(INFO, "Post Method, Content-Length: " + iter->second);http_request.content_length = atoi(iter->second.c_str());return true;}}return false;
}

然后就是读取正文函数:

//Protocol.hpp
bool RecvRequestBoby() // 读取并保存请求正文
{if (IsNeedRecvHttpRequestBody()) // 如果method是"POST",就要读取正文内容{int content_length = http_request.content_length; // 正文长度auto &body = http_request.request_body;           // 要存储正文的地方char ch = 0;while (content_length){ssize_t s = recv(_sock, &ch, 1, 0);if (s > 0){body.push_back(ch);content_length--;}else{stop = true; // 正文读取出错,我直接不玩了break;}}LOG(INFO, body);}return stop;
}

五,构建并发回应答

5.1 CGI机制介绍

①关于CGI机制

CGI(Common Gateway Interface,通用网关接口)是一种非常重要的互联网技术,可以让客户端从网页浏览器向一个执行在服务器上的进程请求数据

我们在使用网络时,无非就两种目的:

  • 获取资源:客户端向服务器申请某种资源,比如打开网页,下载资源等
  • 上传资源:客户端要将自己的资源上传给服务器,比如上传个人资料,发送聊天消息,登录注册等
  • 一般从服务器上获取资源的请求方法是GET方法,将数据上传至服务器的请求方法是POST方法,通过请求正文上传,GET方法也可以用URL上传数据,

  • 而用户将自己的数据上传至服务器并不仅仅是为了上传,用户上传数据的目的是为了让http或相关程序对该数据进行处理;比如用户提交搜索关键字,服务器就要在后端进行搜索,然后将搜索结果返回给浏览器,再由浏览器对HTML文件进行解析并构建页面最终展示给用户。

  • 但实际对数据的处理我们并不交给HTTP来做,所以HTTP提供了CGI机制,上层可以在服务器中部署若干个CGI可执行程序,当HTTP获取到数据后会将其提交给对应CGI程序进行处理,然后再用CGI程序的处理结果构建HTTP响应返回给浏览器

  • 当HTTP获取到数据后,如何调用目标CGI程序、如何传递数据给CGI程序、如何得到CGI程序的处理结果,就都属于CGI机制的通信细节,而我们这个项目就是要实现一个HTTP服务器,因此CGI的所有交互细节都需要由我们来完成

 ②CGI的工作步骤

一,创建子进程后进行程序替换:

  • 服务器获取到连接后是创建一个线程来处理的,执行CGI程序需要调用exec系列函数进行进程程序替换
  • 但是不能直接调用,因为服务器创建的线程和服务器进程是共用一个进程地址空间的,如果直接调用exec,那么我们整个服务器的代码和数据就直接被替换掉了,简单点说就是http服务器执行一次CGI程序后直接退出了
  • 所以我们用fork搞个子进程,然后让exec替换子进程即可

二,建立管道通信信道:

  • 我们把数据通过exec交给CGI程序后,还需要获得CGI处理数据后的结果,所以我们需要使用管道,并且服务器进程和CGI进程是父子关系,所以最好使用匿名管道
  • 但是匿名管道是半双工通信,是单向的,所以我们需要搞两个管道,在创建子进程之前建立好,并且父子进程要分别关闭两个管道的读写端

三,完成重定向相关的设置

  • 创建匿名管道时,父子进程都是用两个变量来记录管道的读写端文件描述符的,但是当子进程执行exec后,子进程的代码和数据就被替换成了CGI程序的代码和数据了,也就意味着被替换后的CGI程序丢失了管道的读写端了
  • 但是进程程序替换只替换对应进程的代码和数据,而对于进程的PCB,页表等内核数据没有改变,所以匿名管道依然存在,只是CGI程序无法获取管道的读写端文件描述符了
  • 所以我们在子进程被替换之前,将0号文件描述符也就是标准输入重定向到对应管道的读端,将1号文件描述符重定向到对应管道的写端
  • 这样一来,CGI程序直接从标准输入里读数据,处理好的结果直接通过标准输出返回给父进程即可

四,父子进程交付结果

在需要启动CGI机制的情况下: 

  • 如果请求方法是GET方法,那么是通过URL传数据的,我们先通过putenv函数将参数导入环境变量,因为环境变量不受进程程序替换的影响,所以CGI可以通过访问环境变量得到参数(这样做是因为URL传递的参数较短,再搞管道的话会降低效率)
  • 如果请求方法是POST方法,此时父进程直接将请求正文数据写入管道即可,同时也要通过环境变量告诉CGI写入管道的数据大小,这样CGI才能读取到正确数量的数据
  • 但是CGI并不知道本次的请求方法是GET还是POST,所以也需要通过环境变量将本次的请求方法也传给CGI

③CGI机制的处理流程

 如下图:

  • 先判断请求方法,如果是POST或者带参GET方法就直接启动CGI,如果是不带参GET就以非CGI进行处理
  • 非CGI处理就是直接根据用户请求的资源构建HTTO响应返回给用户,没有创建子进程程序替换等步骤
  • CGI处理步骤就是我们上面说的,最终也就是把CGI程序的处理结果也放进应答里然后返回给浏览器

④CGI机制的意义

  • 最大的意义就是实现了服务器逻辑和业务逻辑的功能解耦,明确分工,可以显著提高效率
  • CGI机制让用户提交的数据最终交给了CGI,CGI的结果最终交给了用户,忽略了中间服务器的处理逻辑,能够减少用户和业务的沟通成本 

5.2 处理HTTP请求

状态码我们一开始就定义好了,因为在处理请求的过程中可能因为很多原因导致处理失败,比如请求方法不合法,请求资源不存在等等,状态码就是负责标识处理情况,让后续构建http应答时返回对应的错误状态码对应页面

下面是处理请求的代码,由于处理请求可以和构建应答放到一起,所以直接使用BuildReponse作为函数名: 

//class EndPoint
void BuildReponse() // 构建http响应
{std::string Path;auto &code = http_response.status_code; // 状态码struct stat st;                         // 用户获取资源文件的属性信息int size = 0;std::size_t found = 0; // 表示文件后缀的点的位置if (http_request.method != "GET" && http_request.method != "POST"){// 走到这里说明这是个非法请求(因为我们目前的服务器只支持GET和POST方法)std::cout << "method: " << http_request.method << std::endl;LOG(WARNING, "method is not right");code = BAD_REQUEST;return;}if (http_request.method == "GET"){// 如果是GET方法,需要知道http请求行的URL中是否携带了参数ssize_t pos = http_request.uri.find('?'); // 一般以问号作为分隔符if (pos != std::string::npos)             // 有问号,说明带参,要启动CGI{Util::CutString(http_request.uri, http_request.path, http_request.query_string, "?");http_request.cgi = true;}elsehttp_request.path = http_request.uri; // 没有问号,不启动CGI}else if (http_request.method == "POST"){http_request.cgi = true; // 如果是POST方法,就要启动CGI机制http_request.path = http_request.uri;}else{} // 如果要想支持其它的请求方法都可以在这里往后面扩展// 测试//  std::cout << "debug - uri: " << http_request.uri << std::endl;//  std::cout << "debug - path: " << http_request.path << std::endl;//  std::cout << "debug - quary_string: " << http_request.query_string << std::endl;// 然后就是需要把客户端传过来的目录,变成我们的web根目录Path = http_request.path;http_request.path = WEB_ROOT;http_request.path += Path;// std::cout << "debug - path: " << http_request.path << std::endl;// 如果请求路径以 / 结尾,说明请求的是一个目录if (http_request.path[http_request.path.size() - 1] == '/'){http_request.path += HOME_PAGE;}// 当把web目录处理好后,接下来就是要确认要申请的资源是否存在了:// 1,如果存在,就返回    2,如果不存在,返回首页if (stat(http_request.path.c_str(), &st) == 0) // stat函数用于获取某个文件的属性{// 走到这里说明要获取的资源是存在的// 问题:存在就是可以访问读取的呢?不一定,因为有可能要访问的资源是一个目录if (S_ISDIR(st.st_mode)) // 这是个宏,man手册说是判断st里mode是否为目录{// 走到这里说明申请的资源是一个目录,需要做特殊处理http_request.path += "/"; // 细节:虽然是一个目录,但是不会以"/"结尾,因为上面已经做了对"/"的处理http_request.path += HOME_PAGE;stat(http_request.path.c_str(), &st); // 细节:由于路径发生更改,所以再重新获取一下属性}// 请求的资源也有可能是一个可执行程序,需要特殊处理// 当文件的拥有者,所属组,other有任何一个有可执行权限,那么这个文件就是可执行权限if ((st.st_mode & S_IXUSR) || (st.st_mode & S_IXGRP) || (st.st_mode & S_IXOTH)){http_request.cgi = true;}http_request.size = st.st_size; // 获取目标文件大小,方便后面sendfile发送}else{// 走到这里说明获取的资源不存在LOG(WARNING, http_request.path + " Not Found");code = NOT_FOUND;return;}// 走到了这里,说明一切正常// 获得资源后缀found = http_request.path.rfind(".");if (found == std::string::npos) // 没找到后缀,添加默认后缀{http_request.suffix = ".html";}else // 成功找到后缀{http_request.suffix = http_request.path.substr(found); // 截取点后面的字符}if (http_request.cgi == true){code = ProcessCgi(); // 以CGI方式处理,结果已经存储到:http_response.response_body里面了}else{code = ProcessNonCgi(); // 以非CGI方式处理:简单的返回静态网页// 1,目标网页一定是存在的// 2,返回的不只是网页,还要构建Http响应,将网页以响应正文形式返回}BuildHttpResponseHelper(); //开始构建http响应报文
}
  • GET方法通过URL的方式带参,一般以?作为URL和参数的分隔符,左边是资源路径,右边是参数,所以当有问号时,要启动CGI机制
  • 如果URL以 / 结尾,那么表示要请求的资源是一个目录,但是我们不可能真的把目录返回过去,所以我们默认将该目录下的index.html返回给用户,所以需要在实际的请求资源路径后面加上字符串“index.html”
  • 关于啥是web根目录我们之前的http简单服务器已经介绍过了,只需要知道服务器对外提供的资源都会放在web根目录下,然后所有的子目录都会有一个index.html首页文件  

5.3 CGI处理

CGI主要是这个函数:

在创建子进程之前要创建两个匿名管道,我们以父进程为主,input管道用于读取厨具,output用于写入数据:

  • 对于父进程来说:input用于读数据,output用于写数据,所以需要关闭 input[1] 和 output[0],保留 input[0] 和 output[1]
  • 对于子进程来说:input用于写数据,output用于读数据,所以需要关闭 input[0] 和 output[1],保留 input[1] 和 output[0]

 具体步骤就不详细展开讲了,上面已经介绍过,先直接上代码再解释:

//class EndPoint
int ProcessCgi()
{// 问题// 1,父进程拿到的数据在哪里?body(POST), query_string(GET)// 2,父进程如何把参数交给子进程?管道,环境变量(具有全局属性,可以被子进程继承下去,并不受exec的影响)int code = OK;                      // 默认状态码是200auto &method = http_request.method; // 请求方法// 获取要传递的参数auto &query_string = http_request.query_string;    // GETauto &body_text = http_request.request_body;       // POSTauto &bin = http_request.path;                     // 要让子进程执行的目标程序,走到了这里说明一定存在int content_length = http_request.content_length;  // 请求正文的长度auto &response_body = http_response.response_body; // 负责保存CGI程序的处理结果// 用于导入环境变量std::string query_string_env;std::string method_env;std::string content_length_env;// 对于程序替换:// 1,可以创建子进程,让子进程去执行exec进程替换,那么要替换的目标程序在哪里呢?是通过URL告诉我们的// 2,子进程处理完通信要把数据发给父进程,所以要用到进程间通信,也就是管道;同时父子进程要相互通信,那就要两个管道// 约定:管道的读写,完全站在父进程角度int input[2];int output[2];if (pipe(input) < 0) // pipi创建管道,可能会失败{LOG(ERROR, "pipe input error");code = SERVER_ERROR;return code;}if (pipe(output) < 0){LOG(ERROR, "pipe output error");code = SERVER_ERROR;return code;}pid_t pid = fork();if (pid == 0) // 子进程,需要关闭两个管道的读写端{close(input[0]);  // 一般0为读,1为写,所以子进程通过input从父进程读数据close(output[1]); // 通过output向父进程写数据method_env = "METHOD=";method_env += method;putenv((char *)method_env.c_str()); // putenv导入环境变量,导入请求方法// 根据不同的方法导入环境变量if (method == "GET"){query_string_env = "QUERY_STRING=";query_string_env += query_string;putenv((char *)query_string_env.c_str());LOG(INFO, "Get Method, Add Query_String Env");}else if (method == "POST"){// 通过环境变量告诉cgi程序要从管道读取多少正文数据// 由于正文可能很长,所以正文内容通过管道传递content_length_env = "CONTENT_LENGTH=";content_length_env += std::to_string(content_length);putenv((char *)content_length_env.c_str());LOG(INFO, "Post Method, Add Content_Length Env");}else{} // 要想支持其它方法直接在这里扩展即可// 替换成功之后,目标子进程如何得知对应的读写文件描述符是多少呢?// 程序替换,只替换代码和数据,不会对内核进程的数据结构做改变,所以替换后,位于用户层的文件描述符没有了,但是曾经打开的文件的数据结构还在// 约定:让目标进程被替换之后,读取管道等价于读取标准输入;写入管道等价与写到标准输出dup2(output[0], 0); // 让本来从0标准输入读的,现在直接从管道里面读dup2(input[1], 1);  // 让本来往1标准输出写的,我现在直接让它写到管道里execl(bin.c_str(), bin.c_str(), nullptr); // 子进程进行程序替换exit(1);}else if (pid < 0) // 创建子进程失败{LOG(ERROR, "fork error! ");return 404;}else // 父进程{close(input[1]);      // 一般0为读,1为写,所以父进程通过output向子进程写数据close(output[0]);     // 通过input读数据if (method == "POST") // 如果请求方法是POST,就要把正文部分通过管道传递给CGI程序{const char *start = body_text.c_str();int total = 0; // 表示已经写了多少int size = 0;  // 表示这一次要写多少while (total < content_length && (size = write(output[1], start + total, body_text.size() - total)) > 0){total += size;}}// 子进程处理完数据后通过管道写回来,我们也通过管道读取到char ch = 0;while (read(input[0], &ch, 1) > 0) // 读取结果{// CGI执行完之后,把结果放到响应的正文里,不能直接send发回,因为这部分内容只是响应的正文,不是响应全部response_body.push_back(ch);}int status = 0; // 子进程退出码pid_t ret = waitpid(pid, &status, 0);if (ret == pid){if (WIFEXITED(status)) // 这个宏用来检测进程退出码是否正常{if (WEXITSTATUS(status) == 0)code = OK; // 检测进程退出码是否为0,如果是0说明一切正常elsecode = BAD_REQUEST;}else{code = SERVER_ERROR;}}// 关闭不必要的文件描述符,最大程度节省资源close(input[0]);close(output[1]);}return code;
}
  • 环境变量也是key,value的键值对形式,所以我们要先把我们要传递的参数也先搞成键值对的形式再传递,这样方便CGI程序获取
  • 父进程是循环调用read函数从管道中读取CGI的处理结果的,当CGI程序执行结束时,也就是CGI这个进程没了,所以与其相关的包括写端在内的文件描述符也就没了,此时read循环就会结束继续执行后续代码,而不会阻塞

5.4 非CGI处理

非CGI处理比CGI处理简单很多,只需要根据URL中的路径找到对应的资源,放进应答即可:

直接上代码:

int ProcessNonCgi(){http_response.fd = open(http_request.path.c_str(), O_RDONLY); // 以只读打开指定路径的文件if (http_response.fd >= 0)                                    // 下面的添加状态行的操作都建立在文件被成功打开的情况下{// 这里只要打开成功就可以了,返回静态网页直接交给错误码处理即可LOG(INFO, http_request.path + " open success!");return OK;}return NOT_FOUND;}

我们一般不推荐直接将文件的内容拷贝到http响应类的response_body中然后发送回去,因为我们的http响应类存在于用户缓冲区,而目标文件是存储在磁盘上的,如果是直接这样搞的话我们需要先将文件搞到内核层缓冲区,然后再拷贝到用户层缓冲区,然后发送时需要再次拷贝到内核缓冲区,然后再拷贝到网卡上,如下图:

这样来回拷贝的代价是非常高的,所以我们其实可以直接将磁盘中的目标文件内容搞到内核层缓冲区,然后直接在内核层缓冲区直接发给网卡,省去用户层的拷贝,如下图:

我们需要使用sendfile函数来达到上述效果,该函数的主要作用就是将一个文件描述符拷贝到另一个文件描述符,在内核层完成,所以效率会比用户层的文件IO更高

但是需要注意,我们的非CGI处理逻辑还不能直接调用sendfile函数,因为sendfile是即时拷贝,也就是一调用就会发送,我们需要先构建http响应后再发送,所以我们这里的工作仅仅是打开这个文件,保存文件描述符到http响应类的对应字段即可

六,构建并发回响应

6.1 构建状态行

http响应首先是状态行,由状态码,状态码描述,http版本构成,以空格作为分隔符,我们先将状态行拼接好后保存在http响应类的status_line里即可,而响应报头需要根据请求是否正常处理完毕按情况构建,代码如下:(HandlerError是差错处理要执行的逻辑,这里先摆出来)

void BuildHttpResponseHelper()
{auto &code = http_response.status_code; // 获取前面执行完的状态码,有正确也有错误// 构建状态行auto &status_line = http_response.status_line; // 状态行的状态码,不一定被填充status_line += HTTP_VERSION;                   // 添加http版本status_line += " ";status_line += std::to_string(code); // 添加状态码status_line += " ";status_line += Code2Desc(code); // 添加状态码描述,比如Not Foundstatus_line += LINE_END;// 构建响应正文,可能包括响应报头std::string path = WEB_ROOT;path += "/";switch (code){case OK:BuildOkResponse();break;case NOT_FOUND:path += PAGE_404;HandlerError(path);break;case BAD_REQUEST:path += PAGE_400;HandlerError(path);break;case SERVER_ERROR:path += PAGE_500;HandlerError(path);break;default:break;}
}

 对于状态码描述,我们可以单独搞一个函数CodeToDesc,该函数作用是能根据状态码返回对应的状态码描述,代码如下:

static std::string Code2Desc(int code)
{std::string desc;switch(code){case 200:desc = "OK";break;case 404:desc = "Not Found";break;case 500:desc = "Internal Server Error";break;default:break;}return desc;
}

6.2 构建响应报头

构建响应报头也有两种情况,一种是请求正常处理完成,一种是请求处理出错,前者我们返回正确的内容,后面我们发送的则是错误的内容

①构建正确的响应报头 

  • 对于响应报头,我们最少要包含两个对象:Content-Type和Content-Length这两个内容,用于告诉对方响应资源的类型和大小
  • 对于正常处理完毕的http请求,我们需要根据用户请求资源的后缀来填充Content-Type以告诉用户我们给你的资源是什么类型,这样用户才能根据类型来对资源进行各种操作
  • 而资源的大小Content-Length需要根据处理的方式来获取,如果没有启动CGI机制,那么返回资源的大小是保存在http响应类的size中,如果启动了CGI机制,返回资源的大小对应着http响应类中的response_body的大小

 下面是构建响应报头的BuildOkResponse函数代码:

void BuildOkResponse()
{std::string line = "Content-Length: ";if (http_request.cgi) // POST方法和带参GET方法{line += std::to_string(http_response.response_body.size());}else // 无参GET方法{line += std::to_string(http_request.size);}line += LINE_END;http_response.response_header.push_back(line);line = "Content-Type: ";line += SuffixToDesc(http_request.suffix); // 添加资源文件类型line += LINE_END;http_response.response_header.push_back(line);
}

对于Content-Type,我们可以编写一个函数SuffixToDesc,用户根据文件后缀返回对应的文件类型,原因我们以前也讲过:计算机网络(六) —— http协议详解-CSDN博客

//Protocol.hpp
static std::string SuffixToDesc(const std::string &suffix)
{static std::unordered_map<std::string, std::string> suffix2desc = {{ ".html", "text/html" },{ ".css", "text/css" },{ ".js", "application/javascript" },{ ".jpg", "application/x-jpg" },{ ".xml", "application/xml" },{ ".png", "image/png" },};//https://www.runoob.com/http/http-content-type.htmlauto iter = suffix2desc.find(suffix);if(iter != suffix2desc.end()) return iter->second;else return "text/html";
}

②构建错误的响应报头 

  •  对于请求处理过程中出现错误的http请求,服务器将会为返回对应的错误页面,因此返回的资源类型就是text/html,而返回资源的大小可以通过获取错误页面对应的文件属性信息来得知
  • 此外,为了后续发送响应时可以直接调用sendfile进行发送,这里需要将错误页面对应的文件打开,并将对应的文件描述符保存在HTTP响应类的fd当中
  • 如果是处理CGI时出错,需要将其HTTP请求类中的cgi重新设置为false,好让后续发送的错误页面也是以非CGI方式发送的
void HandlerError(std::string page)
{http_request.cgi = false;http_response.fd = open(page.c_str(), O_RDONLY);if (http_response.fd > 0){struct stat st;stat(page.c_str(), &st); // 获取错误页面的属性std::string line = "Content-Type : text/html";line += LINE_END;http_response.response_header.push_back(line);line = "Content-Length: ";line += std::to_string(st.st_size);line += LINE_END;http_response.response_header.push_back(line);http_request.size = st.st_size;}
}

6.3 发送响应

  • 我们要发三个东西,一个是响应状态行,响应报头和响应正文,响应报头和响应正文通过空行分开
  •  如果是启动了CGI机制,直接用send函数把http响应类的response_body发过去即可
  • 如果是非CGI机制,直接用sendfile把对应的资源文件或者错误文件的文件描述符搞过去即可
void SendResponse() // 响应构建好,然后就是发送响应了
{// 1,先把状态行发过去send(_sock, http_response.status_line.c_str(), http_response.status_line.size(), 0);// 2,再把响应报头发过去for (auto iter : http_response.response_header) // 是vector,有很多{send(_sock, iter.c_str(), iter.size(), 0);}send(_sock, http_response.blank.c_str(), http_response.blank.size(), 0); // 状态行和报头发出去后再发一个空行,表示接下来发的就是响应正文了// send发送不是真的发,而是拷贝到Tcp的发送缓冲区// 3,最后发送正文if (http_request.cgi == true) // CGI和非CGI要分开发{// 如果是cgi模式,那么我们的正文是在http_response的body中的auto &response_body = http_response.response_body;size_t size = 0;size_t total = 0;const char *start = response_body.c_str();while (total < response_body.size() && (size = send(_sock, start + total, response_body.size() - total, 0)) > 0){total += size;}// 这个步骤和我们上面把cgi程序执行完之后把数据写回管道那里的步骤是一样的,只是write变成了send}else{// 正常页面和错误页面通过同一种方法返回sendfile(_sock, http_response.fd, nullptr, http_request.size); // 发送效率比write和send高一些close(http_response.fd);}
}

七,差错处理

到这里服务器的逻辑差不多已经完善了,但我们的服务器在处理请求过程中还是有很大缺陷,这是因为当前服务器的错误处理还没有完全处理完毕:

7.1 逻辑错误

  • 这个我们已经解决了,主要是服务器在处理请求的过程中出现的一些错误,比如请求方法不正确、请求资源不存在或服务器处理请求时出错等等。当出现这类错误时服务器会将对应的错误页面返回给客户端
  • 在BuildResponse也就是根据请求填充响应类各字段这个函数里,我们如果遇到了逻辑错误,是直接标记响应类的status_code状态码然后就直接return返回了,但是这里我们可以再优化下
  • 当有任何一个步骤出错了,我们标记完状态码后,可以直接跳转到开始构建http响应报文那里,所以我们可以使用goto语句来完成跳转工作
void BuildReponse() // 构建http响应
{std::string Path;auto &code = http_response.status_code; // 状态码struct stat st;                         // 用户获取资源文件的属性信息int size = 0;std::size_t found = 0; // 表示文件后缀的点的位置if (http_request.method != "GET" && http_request.method != "POST"){// 走到这里说明这是个非法请求(因为我们目前的服务器只支持GET和POST方法)std::cout << "method: " << http_request.method << std::endl;LOG(WARNING, "method is not right");code = BAD_REQUEST;goto END;}if (http_request.method == "GET"){// 如果是GET方法,需要知道http请求行的URL中是否携带了参数ssize_t pos = http_request.uri.find('?'); // 一般以问号作为分隔符if (pos != std::string::npos)             // 有问号,说明带参,要启动CGI{Util::CutString(http_request.uri, http_request.path, http_request.query_string, "?");http_request.cgi = true;}elsehttp_request.path = http_request.uri; // 没有问号,不启动CGI}else if (http_request.method == "POST"){http_request.cgi = true; // 如果是POST方法,就要启动CGI机制http_request.path = http_request.uri;}else{} // 如果要想支持其它的请求方法都可以在这里往后面扩展// 测试//  std::cout << "debug - uri: " << http_request.uri << std::endl;//  std::cout << "debug - path: " << http_request.path << std::endl;//  std::cout << "debug - quary_string: " << http_request.query_string << std::endl;// 然后就是需要把客户端传过来的目录,变成我们的web根目录Path = http_request.path;http_request.path = WEB_ROOT;http_request.path += Path;// std::cout << "debug - path: " << http_request.path << std::endl;// 如果请求路径以 / 结尾,说明请求的是一个目录if (http_request.path[http_request.path.size() - 1] == '/'){http_request.path += HOME_PAGE;}// 当把web目录处理好后,接下来就是要确认要申请的资源是否存在了:// 1,如果存在,就返回    2,如果不存在,返回首页if (stat(http_request.path.c_str(), &st) == 0) // stat函数用于获取某个文件的属性{// 走到这里说明要获取的资源是存在的// 问题:存在就是可以访问读取的呢?不一定,因为有可能要访问的资源是一个目录if (S_ISDIR(st.st_mode)) // 这是个宏,man手册说是判断st里mode是否为目录{// 走到这里说明申请的资源是一个目录,需要做特殊处理http_request.path += "/"; // 细节:虽然是一个目录,但是不会以"/"结尾,因为上面已经做了对"/"的处理http_request.path += HOME_PAGE;stat(http_request.path.c_str(), &st); // 细节:由于路径发生更改,所以再重新获取一下属性}// 请求的资源也有可能是一个可执行程序,需要特殊处理// 当文件的拥有者,所属组,other有任何一个有可执行权限,那么这个文件就是可执行权限if ((st.st_mode & S_IXUSR) || (st.st_mode & S_IXGRP) || (st.st_mode & S_IXOTH)){http_request.cgi = true;}http_request.size = st.st_size; // 获取目标文件大小,方便后面sendfile发送}else{// 走到这里说明获取的资源不存在LOG(WARNING, http_request.path + " Not Found");code = NOT_FOUND;goto END;}// 走到了这里,说明没有goto跳转到END,一切正常// 获得资源后缀found = http_request.path.rfind(".");if (found == std::string::npos) // 没找到后缀,添加默认后缀{http_request.suffix = ".html";}else // 成功找到后缀{http_request.suffix = http_request.path.substr(found); // 截取点后面的字符}if (http_request.cgi == true) // 启动CGI机制{code = ProcessCgi(); // CGI处理完后的结果已经存储到:http_response.response_body里面了}else{code = ProcessNonCgi(); // 以非CGI方式处理:简单的返回静态网页// 1,目标网页一定是存在的// 2,返回的不只是网页,还要构建Http响应,将网页以响应正文形式返回}
END:BuildHttpResponseHelper(); // 开始构建http响应报文
}

7.2 读取资源文件错误

逻辑错误是服务器在处理请求时可能出现的错误,而在处理请求之前就是需要先读取请求,在这个过程中出现的错误就是读取错误,比如recv等读取函数读取出错

这时候我们EndPoint类中的stop成员就派上用场了,表示是否停止本次处理

读取请求行出错时:

 读取报头出错时:

读取请求正文出错时:

最后我们的线程回调函数通过IsStop函数得知读取是否出错,如果出错,后续处理请求构建响应等步骤统统不再执行,直接打印错误日志:

 7.3 发送出错

我们发送时是把响应状态行,响应报头和响应正文分三次发的,所以也可以针对这里做一些判断处理

bool SendResponse() // 响应构建好,然后就是发送响应了
{// 1,先把状态行发过去if (send(_sock, http_response.status_line.c_str(), http_response.status_line.size(), 0) <= 0)stop = true; // 如果发送出错,也用stop标记一下// 2,再把响应报头发过去if (!stop){for (auto &iter : http_response.response_header) // 是vector,有很多{if (!stop && send(_sock, iter.c_str(), iter.size(), 0) <= 0)stop = true;}if (!stop && send(_sock, http_response.blank.c_str(), http_response.blank.size(), 0) <= 0) // 状态行和报头发出去后再发一个空行,表示接下来发的就是响应正文了stop = true;// send发送不是真的发,而是拷贝到Tcp的发送缓冲区}// 3,最后发送正文if (!stop && http_request.cgi == true) // CGI和非CGI要分开发{// 如果是cgi模式,那么我们的正文是在http_response的body中的auto &response_body = http_response.response_body;size_t size = 0;size_t total = 0;const char *start = response_body.c_str();while (total < response_body.size() && (size = send(_sock, start + total, response_body.size() - total, 0)) > 0){total += size;}// 这个步骤和我们上面把cgi程序执行完之后把数据写回管道那里的步骤是一样的,只是write变成了send}else{// 正常页面和错误页面通过同一种方法返回if (!stop){if (sendfile(_sock, http_response.fd, nullptr, http_request.size) <= 0) // 发送效率比write和send高一些stop = true;}close(http_response.fd);}return stop;
}

八,接入线程池

8.1 为什么需要线程池

我们目前的服务器对于效率上的问题:

  • 每次获取新线程后,主线程都会创建新线程进行处理,处理完毕后又销毁线程,这样也就导致了效率低下,使得很多资源无法重复利用
  • 而且如果同时有大量客户端连接进来,那么线程数也就越多,CPU压力也就越大,这样一个线程得处理时间就变长了,严重影响效率,严重些可能导致崩溃

所以我们可以接入线程池:

  • 在服务器启动时创建一批线程,用一个任务队列组织起来,每当获取到一个新连接时,就封装成一个任务交给任务队列,然后任务队列自动分配任务到下面得线程里去

8.2 任务设计 

  • 任务类首先得有一个套接字,也就是与用户进行通信得套接字
  • 然后还需要一个回调函数,给线程用的 
//Task.hpp
#pragma once #include <iostream>
#include "Protocol.hpp"class Task
{    
public:Task(){}Task(int _sock):sock(_sock){}//处理任务void ProcessOn(){handler(sock);}~Task(){}
private:int sock;CallBack handler; //设置回调
};

 关于回调类CallBack我们前面也实现过了:

  • 把CallBack类的 () 运算符重载为调用HandlerRequest函数时,CallBack对象就变成了一个仿函数对象,可以让这个类像函数一样直接被调用
  • 需要改一下HandlerRequest的参数,因为我们是通过任务类调用的这个方法,不是线程直接调用了
//#define DEBUG 1
class CallBack
{
public:CallBack(){}~CallBack(){}void operator()(int sock){HandlerRequest(sock); //仿函数,重载(),调用HandlerRequest}static void* HandlerRequest(int sock){LOG(INFO, "Hander Request Begin");
#ifdef DEBUG //测试打印http请求报头char buffer [4096];recv(sock, buffer, sizeof(buffer), 0);std::cout << "-------------begin----------------" << std::endl;std::cout << buffer << std::endl;std::cout << "-------------end----------------" << std::endl;
#elseEndPoint* ep = new EndPoint(sock);ep->RecvRequest();//读取并解析请求if(!ep->IsStop()) //当读取解析请求都成功时,才开始构建和发回相应{LOG(INFO, "Recv No Error, Begin Build And Send Reponse");ep->BuildReponse(); //构建响应if(ep->SendResponse()) //发回响应{LOG(WARNING, "Send Error");}}else{LOG(WARNING, "Recv Error, Stop Build And Send Reponse");}delete ep;
#endifLOG(INFO, "Hander Request End");}
};

 8.3 线程池设计

关于线程池,我们以前也做过介绍:Linux系统编程——线程池_linux内核线程池-CSDN博客

所以,现在实现一个线程池也不是很困难,只要把代码搬过来做下修改即可:

  • 将ThreadPool类的构造函数设置为私有,并将拷贝构造和拷贝赋值函数设置为私有或删除,防止外部创建或拷贝对象
  • 提供一个指向单例对象的static指针,并在类外将其初始化为nullptr
  • 提供一个全局访问点获取单例对象,并且在单例对象第一次被获取时就创建这个单例对象并进行初始化
//ThreadPool.hpp
#pragma once#include <iostream>
#include <queue>
#include <pthread.h>
#include "Log.hpp"
#include "Task.hpp"#define NUM 6class ThreadPool
{
public:static ThreadPool* GetInstance() //获取对象,第一次获取对象时,初始化线程池,往后再次获取时不再初始化{static pthread_mutex_t _mutex = PTHREAD_MUTEX_INITIALIZER;if (single_instance == nullptr){pthread_mutex_lock(&_mutex);if (single_instance == nullptr){single_instance = new ThreadPool();single_instance->InitThreadPool();}pthread_mutex_unlock(&_mutex);}return single_instance; // 返回静态的对象指针}bool InitThreadPool() //初始化线程池{for (int i = 0; i < num; i++){pthread_t tid;if (pthread_create(&tid, nullptr, ThreadRoutine, this) != 0){LOG(FATAL, "create thread pool error!");return false;}}LOG(INFO, "create thread pool success!");return true;}void PushTask(const Task &task) //放任务进来,让队列里的线程自己自动执行{Lock();task_queue.push(task); //将任务放入任务队列Unlock();ThreadWakeup(); //唤醒在条件变量下等待的一个线程处理任务}void PopTask(Task &task) //任务执行完毕,干掉任务{task = task_queue.front();task_queue.pop();}static void *ThreadRoutine(void *args) //线程池中每个线程的执行函数{//线程函数的参数只能有一个void*,当传的是this指针,就能通过this指针访问任务队列ThreadPool *tp = (ThreadPool *)args;while (true){Task t;tp->Lock();while (tp->TaskQueueIsEmpty()) //这里用while判断任务队列是否为空,防止线程被伪唤醒{tp->ThreadWait(); // 当我醒来的时候,一定是占有互斥锁的!}tp->PopTask(t);tp->Unlock();t.ProcessOn();}}
public:bool IsStop(){return stop;}bool TaskQueueIsEmpty(){return task_queue.size() == 0 ? true : false;}void Lock(){pthread_mutex_lock(&lock);}void Unlock(){pthread_mutex_unlock(&lock);}void ThreadWait(){pthread_cond_wait(&cond, &lock);}void ThreadWakeup(){pthread_cond_signal(&cond);}~ThreadPool(){pthread_mutex_destroy(&lock);pthread_cond_destroy(&cond);}private: //搞成单例模式ThreadPool(int _num = NUM):num(_num),stop(false){pthread_mutex_init(&lock, nullptr);pthread_cond_init(&cond, nullptr);}ThreadPool(const ThreadPool &){}static ThreadPool *single_instance; //指向单例对象的指针private:int num; //表示线程池中线程的个数bool stop;std::queue<Task> task_queue; //用于暂时存储未被处理的任务对象pthread_mutex_t lock; //互斥锁pthread_cond_t cond; //条件变量,当任务队列没有任务时,让线程进行等待,当任务队列中有任务时,通过该条件变量唤醒线程
};ThreadPool* ThreadPool::single_instance = nullptr;

 最后更改下服务器主逻辑即可:

九,测试

makfile文件内容如下:

bin=httpserver
cgi=test_cgi
cc=g++
LD_FLAGS=-std=c++11 -lpthread
curr=$(shell pwd)
src=main.ccALL:$(bin) $(cgi)
.PHONY:ALL$(bin):$(src)$(cc) -o $@ $^ $(LD_FLAGS)$(cgi):cgi/test_cgi.cc$(cc) -o $@ $^.PHONY:clean
clean:rm -rf $(bin) $(cgi)rm -rf output.PHONY:output #发布
output:mkdir -p outputcp $(bin) outputcp -rf wwwroot outputcp $(cgi) output/wwwroot

9.1 返回网页

我们这个服务器使用的wwwroot还是我们之前用的那个:计算机网络(六) —— http协议详解-CSDN博客

 首先是启动服务器:

然后打开浏览器输入IP加端口即可获取html网页信息

获取主页:

<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Document</title><style>a {color: blue;text-decoration: none;}a:hover {text-decoration: underline;}table {width: 536px}.title .col-1 {font-size: 20px;font-weight: bolder;}.col-1 {width: 80%;text-align: left;/*居左*/}.col-2 {width: 20%;text-align: center;}.icon {background-image: url(./male.png);width: 24px;height: 24px;background-size: 100% 100%;display: inline-block;/*加上后图片才能显示出来*/vertical-align: bottom;/*使垂直对齐*/}.content {font-size: 18px;line-height: 30px;}.content .col-1,.content .col-2 {border-bottom: 2px solid #f3f3f3;}.num {font-size: 20px;color: #fffff3;}.first {background-color: #f54545;padding-right: 8px;}.second {background-color: #ff8547;padding-right: 8px;}.third {background-color: #ffac38;padding-right: 8px;}.other {background-color: #81b9f5;padding-right: 8px;}</style>
</head><body><table cellspacint="0px"><th class="title col-1">热搜</th><<th class="title col-2"><a href="./a/b/hello.html">登录<span class="icon"></span></a></th><tr class="content"><td class="col-1"><span class="num first">1</span><ahref="https://github.com/"target="blank">GitHub</a></td><td class="col-2">666万</td></tr><tr class="content"><td class="col-1"><span class="num second">2</span><a href="https://www.csdn.net/"target="blank">CSDN</a></td><td class="col-2">666万</td></tr><tr class="content"><td class="col-1"><span class="num third">3</span><a href="https://gitee.com/"target="blank">Gitee</a></td><td class="col-2">666万</td></tr><tr class="content"><td class="col-1"><span class="num other">4</span><a href="https://leetcode.cn/"target="blank">LeetCode</a></td><td class="col-2">666万</td></tr><tr><td><a href="./image.html" target="blank">你好</a></td></tr></table>
</body></html>

获取不存在的资源时返回404页面:

<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Document</title>
</head><body><h1>404 Not Found</h1><h21>您好,您访问的页面不存在</h21>
</body></html>

获取图片等超文本数据:

<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Document</title>
</head><body><img src="/image/1.jpg" alt="你好" weigh="800px" width="800px"><img src="/image/2.jpg" alt="你好" weigh="800px" width="800px"><img src="/image/3.jpg" alt="你好" weigh="800px" width="800px"></body></html>

图片文件可以自己上传:

9.2 测试CGI

①编写CGI程序

在测试CGI程序之前,我们要先编写一个简单的CGI程序

首先,CGI程序启动后需要先获取父进程传过来的数据:

  • 先通过getenv获取环境变量中的请求方法
  • 如果请求方法伪GET方法,就再通过getenv函数获取父进程传递过来的数据
  • ru张请求方法是POST方法,则先通过getenv函数获取父进程传递过来的数据的长度,然后再从0号文件描述符中读取指定长度的数据即可,如下代码:

#include<iostream>
#include<cstdlib>
#include <unistd.h>
#include <string>bool GetQueryString(std::string &query_string) //获取需要的参数
{bool result = false;std::string method = getenv("METHOD");if(method == "GET"){//通过环境变量拿到GET方法后,照样通过环境变量拿到参数query_string = getenv("QUERY_STRING");result = true;}else if(method == "POST"){int content_length = atoi(getenv("CONTENT_LENGTH"));char c = 0;while(content_length){read(0, &c, 1);query_string.push_back(c);content_length--;}result = true;}else //如果要支持其它方法就可以在这里继续拓展{result = false;}return result;
}

CGI获取父进程传递过来的数据后,就是进行数据处理了:

  • 我们这里假设用户上传的是形如a=100&b=200这样的字符串,需要CGI程序进行加减乘除运算并把运算结果返回过去
  • 我们的CGI要先以&为分隔符分开两个操作数,再以=为分隔符分别获取两个具体的数字,最后进行运算,把结果通过标准输出输出到管道里即可,如下代码:
void CutString(std::string &in, const std::string &sep, std::string &out1, std::string &out2)
{auto pos = in.find(sep);if(std::string::npos != pos){out1 = in.substr(0, pos);out2 = in.substr(pos+sep.size());}
}int main()
{std::string query_string;GetQueryString(query_string); //a=100$b=200std::string str1;std::string str2;CutString(query_string, "&", str1, str2); //"a=100", "b=200"std::string name1;std::string value1;CutString(str1, "=", name1, value1); //"a", "100"std::string name2;std::string value2;CutString(str2, "=", name2, value2); //"b", "200"std::cerr << name1 << " : " << value1 << std::endl; //方便测试std::cerr << name2 << " : " << value2 << std::endl;int x = atoi(value1.c_str());int y = atoi(value2.c_str());std::cout<<"<html>";std::cout<<"<head><meta charset=\"UTF-8\"></head>";std::cout<<"<body>";std::cout<<"<h3>"<<x<<" + "<<y<<" = "<<x+y<<"</h3>";std::cout<<"<h3>"<<x<<" - "<<y<<" = "<<x-y<<"</h3>";std::cout<<"<h3>"<<x<<" * "<<y<<" = "<<x*y<<"</h3>";std::cout<<"<h3>"<<x<<" / "<<y<<" = "<<x/y<<"</h3>"; //除0后cgi程序崩溃,属于异常退出std::cout<<"</body>";std::cout<<"</html>";return 0;
}
  • CGI程序输出的结果最终会交给浏览器,因此CGI程序输出的最好是一个HTML文件,这样浏览器收到后就可以其渲染到页面上,看起来更美观
  • 但是使用C/C++以HTML的格式进行输出是很费劲的,因此这部分操作一般是由Python等语言来完成的,而在此之前对数据进行业务处理的动作一般才用C/C++等语言来完成

我们先需要把test_cgi.cc编译成可执行文件,然后再把可执行文件放到wwwroot下再进行访问:

②URL上传数据测试

如果第二个操作数是0,那么CGI在进行除法运算时会除0错误就会崩溃:

 ③表单上传数据测试

  • 服务器一般会让用户通过表单来上传参数,HTML中的表单用于搜集用户的输入,我们可以通过设置表单的method属性来指定表单提交的方法,通过设置表单的action属性来指定表单需要提交给服务器上的哪一个CGI程序。
  • 比如我们将/a/b/hello.html的内容改成以下HTML代码,指定将表单中的数据以GET方法提交给web根目录下的test_cgi程序

<!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>在线计算器</title>
</head>
<body><form action="/test_cgi" method="get">操作数1:<br><input type="text" name="x"><br>操作数2:<br><input type="text" name="y"><br><br><input type="submit" value="计算"></form>
</body>
</html>

下面是测试结果:

9.3 实现成守护进程

守护进程的作用和实现这里不再赘述,之前也讲过:计算机网络(四) —— 简单Tcp网络程序-CSDN博客

下面是Daemon.hpp的代码:

#pragma once#include <iostream>
#include <unistd.h>
#include <cstdlib>
#include <signal.h>
#include <string>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>const std::string nullfile = "/dev/null";void Daemon(const std::string &cwd = "") // 不传参数的话就是默认把守护进程工作目录放到根目录去
{// 1,守护进程需要忽略其它信信号signal(SIGCLD, SIG_IGN);  // 直接忽略17号信号,为了防止万一出现一些读端关掉了,写端还在写的情况,守护进程signal(SIGPIPE, SIG_IGN); // 直接忽略13号信号signal(SIGSTOP, SIG_IGN); // 忽略19号暂停信号// 2,将自己变成独立的会话if (fork() > 0)exit(0); // 直接干掉父进程setsid();    // 子进程自成会话// 3,更改当前调用进程的工作目录if (!cwd.empty())chdir(cwd.c_str());// 4,不能直接关闭三个输入流,打印时会出错,Linux中有一个/dev/null 字符文件,类似垃圾桶,所有往这个文件写的数据会被直接丢弃,读也读不到// 所以可以把标准输入输出错误全部重定向到这个文件中// 如果需要就往文件里写,反正不能再打印到屏幕上了int fd = open(nullfile.c_str(), O_RDWR); // 以读写方式打开if (fd > 0)                              // 打开成功{// 把三个默认流全部重定向到垃圾桶的null的套接字里去dup2(fd, 0);dup2(fd, 1);dup2(fd, 2);close(fd);}
}

然后在main.cc里添加守护进程函数即可:

十,源码

 Http服务器 · 小堃学编程/项目集合 - 码云 - 开源中国

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/diannao/69364.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

GitHub Copilot:智能助手觉醒

GitHub Copilot: The agent awakens - The GitHub Blog github copilot 官方文档刚刚宣布支持 agent 模式&#xff01; 这一模式和之前的 chat 方式不同&#xff0c;类似于 cursor 可以根据需求直接运行、调试和修改代码 这一模式在 preview 版本可以使用&#xff0c;并且需…

网络安全威胁框架与入侵分析模型概述

引言 “网络安全攻防的本质是人与人之间的对抗&#xff0c;每一次入侵背后都有一个实体&#xff08;个人或组织&#xff09;”。这一经典观点概括了网络攻防的深层本质。无论是APT&#xff08;高级持续性威胁&#xff09;攻击、零日漏洞利用&#xff0c;还是简单的钓鱼攻击&am…

深入浅出谈VR(虚拟现实、VR镜头)

1、VR是什么鬼&#xff1f; 近两年VR这次词火遍网上网下&#xff0c;到底什么是VR&#xff1f;VR是“Virtual Reality”&#xff0c;中文名字是虚拟现实&#xff0c;是指采用计算机技术为核心的现代高科技手段生成一种虚拟环境&#xff0c;用户借助特殊的输入/输出设备&#x…

postman免登录版本,实测可用(解决一直卡在登录界面无法进入的问题)

一、背景 2025今年开工后&#xff0c;打开postman&#xff0c;一直提示需要登录&#xff0c;但是一直卡在登录界面&#xff0c;好几个人的postman都是这样的情况&#xff0c;不知道是什么原因。 折腾几小时无果&#xff0c;网上下载了各种版本都试了&#xff0c;最新的版本也…

Unity中Spine骨骼动画完全指南:从API详解到避坑实战

Unity中Spine骨骼动画完全指南&#xff1a;从API详解到避坑实战 一、为什么要选择Spine&#xff1f; Spine作为专业的2D骨骼动画工具&#xff0c;相比传统帧动画可节省90%资源量。在Unity中的典型应用场景包括&#xff1a; 角色换装系统&#xff08;通过插槽替换部件&#xf…

HTTP异步Client源码解析

我们知道Netty作为高性能通信框架&#xff0c;优点在于内部封装了管道的连接通信等操作&#xff0c;用户只需要调用封装好的接口&#xff0c;便可以很便捷的进行高并发通信。类似&#xff0c;在Http请求时&#xff0c;我们通过调用HttpClient&#xff0c;内部使用java NIO技术&…

Golang:Go 1.23 版本新特性介绍

流行的编程语言Go已经发布了1.23版本&#xff0c;带来了许多改进、优化和新特性。在Go 1.22发布六个月后&#xff0c;这次更新增强了工具链、运行时和库&#xff0c;同时保持了向后兼容性。 Go 1.23 的新增特性主要包括语言特性、工具链改进、标准库更新等方面&#xff0c;以下…

无界构建微前端?NO!NO!NO!多系统融合思路!

文章目录 微前端理解1、微前端概念2、微前端特性3、微前端方案a、iframeb、qiankun --> 使用比较复杂 --> 自己写对vite的插件c、micro-app --> 京东开发 --> 对vite支持更拉跨d、EMP 方案--> 必须使用 webpack5 --> 很多人感觉不是微前端 --> 去中心化方…

SQL Server 数据库迁移到 MySQL 的完整指南

文章目录 引言一、迁移前的准备工作1.1 确定迁移范围1.2 评估兼容性1.3 备份数据 二、迁移工具的选择2.1 使用 MySQL Workbench2.2 使用第三方工具2.3 手动迁移 三、迁移步骤3.1 导出 SQL Server 数据库结构3.2 转换数据类型和语法3.3 导入 MySQL 数据库3.4 迁移数据3.5 迁移存…

RabbitMQ深度探索:死信队列

死信队列产生背景&#xff1a; RabbitMQ 死信队列俗称 备胎队列&#xff1a;消息中间件因为某种原因拒收该消息后&#xff0c;可以转移到私信队列中存放&#xff0c;死信队列也可以有交换机和路由 key 等 生产死信队列的原因&#xff1a; 消息投递到 MQ 存放&#xff0c;消息已…

蓝桥算法基础2

位运算 按位与&#xff0c;x&1x%2.因为1不论和几位二进制与&#xff0c;都只有最后一位为1&#xff0c;前面都是0&#xff0c;那么&前面也都为0&#xff0c;只有最后一位&#xff0c;若为1那么2的0次方为1&#xff0c;该数一定为奇数&#xff0c;与取余结果同&#xff…

B站自研的第二代视频连麦系统(上)

导读 本系列文章将从客户端、服务器以及音视频编码优化三个层面&#xff0c;介绍如何基于WebRTC构建视频连麦系统。希望通过这一系列的讲解&#xff0c;帮助开发者更全面地了解 WebRTC 的核心技术与实践应用。 背景 在文章《B站在实时音视频技术领域的探索与实践》中&#xff…

redis之AOF持久化过程

流程图 在redis.conf文件中配置appendonly为yes则开启aof持久化机制 #开启aof持久化&#xff0c;默认关闭为no appendonly no也可以在命令行开启 aof刷盘策略 #每个写操作都会同步刷盘。 appendfsync always #执行命令后先放入aof缓冲区&#xff0c;每秒钟将缓冲区数据刷盘…

力扣.623. 在二叉树中增加一行(链式结构的插入操作)

Problem: 623. 在二叉树中增加一行 文章目录 题目描述思路复杂度Code 题目描述 思路 1.首先要说明&#xff0c;对于数据结构无非两大类结构&#xff1a;顺序结构、链式结构&#xff0c;而二叉树实质上就可以等效看作为一个二叉链表&#xff0c;而对于链表插入一个节点的操作是应…

【GoLang】切片的面试知识点

nil切片 和 空切片 nil切片是只声明但未初始化&#xff0c;没有分配底层数组的内存空间&#xff0c; 空切片是初始化了的&#xff0c;有分配数组内存&#xff0c;只是数组内没有元素。 二者都可以正常扩容、遍历。不会报错。 append 如何添加切片 append 可以增加切片&…

利用 IMU 估计人体关节轴向和位置 —— 论文推导

Title: 利用 IMU 估计人体关节轴向和位置 —— “Joint axis and position estimation from inertial measurement data by exploiting kinematic constraints” —— 论文推导 文章目录 I. 论文回顾II. 铰接关节的约束1. 铰接关节约束的原理2. 铰接关节约束的梯度3. 铰接关节约…

JVM图文入门

往期推荐 【已解决】redisCache注解失效&#xff0c;没写cacheConfig_com.howbuy.cachemanagement.client.redisclient#incr-CSDN博客 【已解决】OSS配置问题_keyuewenhua.oss-cn-beijing.aliyuncs-CSDN博客 【排坑】云服务器docker部署前后端分离项目域名解析OSS-CSDN博客 微服…

利用ETL工具进行数据挖掘

ETL的基本概念 数据抽取&#xff08;Extraction&#xff09;&#xff1a;从不同源头系统中获取所需数据的步骤。比如从mysql中拿取数据就是一种简单的抽取动作&#xff0c;从API接口拿取数据也是。 数据转换&#xff08;Transformation&#xff09;&#xff1a;清洗、整合和转…

MySQL数据库(五)索引

一 索引概述 1 介绍&#xff1a;MySQL索引是一种有序数据结构&#xff0c;它能够高效帮助数据库系统快速定位到表中的特定记录&#xff0c;从而显著提高查询效率。索引可以被看作是书的目录&#xff0c;通过它可以迅速找到所需的信息而不需要逐页翻阅整本书。 2 优缺点 二 索…

让文物“活”起来,以3D数字化技术传承文物历史文化!

文物&#xff0c;作为不可再生的宝贵资源&#xff0c;其任何毁损都是无法逆转的损失。然而&#xff0c;当前文物保护与修复领域仍大量依赖传统技术&#xff0c;同时&#xff0c;文物管理机构和专业团队的力量相对薄弱&#xff0c;亟需引入数字化管理手段以应对挑战。 积木易搭…