设计CGI程序
当浏览器请求的资源是一个可执行文件的时候
此时我们的服务器就会触发CGI模式
现在我们就可以开始编写CGI程序了
根据上面的原理图我们可以知道
- httpsever是一个进程
- CGI程序也是一个进程
- 那么我们应该如何用一个进程去执行另外一个进程呢?
其实早在进程控制章节我们就学过了方法 那就是进程替换
当然我们在讲解程序替换的时候也说过 我们不能使用主进程进行进程替换(使用主进程该进程就变成一次性的了 处理不了下一个任务) 而应该使用子进程
整体的代码如下
int ProcessCgi() { pid_t pid = fork(); if (pid == 0) { // child } else if (pid < 0) { // error LOG(ERROR , "fork error"); return 404; } else { // father waitpid(pid , nullptr , 0); } return OK; }
我们创建子进程的目的当然是为了让他去执行目标程序
那么目标程序是什么呢 它实际上就是浏览器传输给我们的path
进程间通信
建立管道
httpsever需要将数据传输给cgi程序 cgi程序处理完数据之后也需要将数据回传给httpsever
所以说我们这里就要用到进程间通信
因为httpsever进程和我们设计的cgi进程之间本质上是一个父子进程的关系 所以说我们选用进程间通信中的匿名管道
而由于此时我们需要数据进行双向传递 所以说我们可以设计一个双向的管道
为了不混淆这个双向管道的读取 我们约定 所有操作都站在父进程的视角上命名
代码表示如下
int input[2]; int output[2]; if (pipe(input) < 0) { return 404; } if (pipe(output) < 0) { return 404; } pid_t pid = fork(); if (pid == 0) { // child close(input[0]); close(output[1]); } else if (pid < 0) { // error LOG(ERROR , "fork error"); return 404; } else { // father close(input[1]); close(output[0]); waitpid(pid , nullptr , 0); } return OK; }
进程替换
我们选择使用execl函数来进行进程替换
函数原型如下
int execl(const char *path, const char *arg, ...);
我们先看这个函数的名字 相比我们的exec多了一个l
这个l其实就是列表的意思 意味着它的参数要使用列表的形式传入
它的第一个参数是 const char *path 它代表着要执行程序的路径
它的第二个参数是 const char *arg, ... 它代表着可变参数列表 是使用NULL结尾的
例如我们要执行ls程序的话 就可以写出下面的代码
execl("/usr/bin/ls" , "ls" , "-a" , "-i" , NULL);
当然如果我们直接使用路径作为可执行程序也是可以的 比如
execl("/usr/bin/ls" , "/usr/bin/ls" , "-a" , "-i" , NULL);
所以说我们程序替换的代码是
execl(bin.c_str() , bin.c_str() , nullptr);
我们在进行程序替换之后把原先子进程的程序和代码全部替换了
那么替换后的子进程如何得知原先的管道信息呢
代码和数据全部没有了 但是我们要知道的是也仅仅是代码和数据没有了
它并不替换内核进程相关的数据结构 实际上原先子进程的文件描述符表依旧存在
但是数据已经被我们全部删除了 我们要怎么找到呢这两个文件描述符呢?
此时我们可以做出以下的约定
- 让读取管道等价于读取标准输入
- 让写入管道等价于写入标准输出
而由于标准输入和标准输出的文件描述符是固定的
所以说我们直接进行重定向即可
dup2(input[1] , 1); dup2(output[0], 0);
交互数据
在交互数据之前我们首先要知道父进程的数据在哪里
对于GET方法来说父进程的数据一定是在uri当中
对于POST方法来说 父进程的数据一定是在正文当中
POST方法处理
代码表示如下
if (method == "POST") { const char* start = body_text.c_str(); int total = 0; int size = 0; while(1) { size = write(output[1] , start+total , body_text.size()-total); if (size > 0) { total+=size; } else { break; } } }
上面的代码我们做了一个小处理 让write一直写 直到写入成功的数据为0为止 这主要是为了防止数据太多 一次write写不完的情况出现
GET方法处理
首先我们要知道一点 进程替换是不会替换环境变量的 而子进程会继承父进程的环境变量 所以说我们可以直接使用父进程或者没有替换过的子进程的环境变量给替换后的子进程传递数据
代码标识如下
if (method == "GET") { query_string_env = "QUERY_STRING="; query_string_env += query_string; putenv(query_string.c_str()); }
传递传参方法
在子进程接收数据之前我们还要让子进程确认一点
- 浏览器传递参数使用的到底是什么方法
此时我们还是可以通过环境变量让子进程知道是用什么方法传递的参数
method_env = "METHOD="; method_env += method; putenv((char *)method_env.c_str());
CGI程序接收数据
我们首先写出一个CGI程序编译并且将这个程序放到wwwroot目录中
CGI程序代码如下
#include <iostream> #include <cstdlib> using namespace std; int main() { cerr << "Debug Test :" << getenv("METHOD") << endl; return 0; }
这里需要注意的是我们使用的是cerr而不是cout 这是因为我们的cout使用的是标准输出 而标准输出已经被我们重定向了 如果使用cout将不会在显示器上输出任何结果
此时我们发现确实cgi程序确实能够得到浏览器传参的方法
接下来就是根据传参方法的不同使用不同的方式去获得数据了 具体为
- GET方法 使用环境变量获得数据
- POST方法 使用管道获得数据
代码表示如下
if (method == "GET") { query_string = getenv("QUERY_STRING"); cerr << "Debug QUERY_STRING: " << query_string << endl; } else if (method == "POST") { int cl = atoi(getenv("CONTENT_LENGTH")); char c = 0; while(cl) { read(0 , &c , 1); query_string.push_back(c); cl--; } } else { ; }
CGI程序处理数据
我们假设接收的数据是 x=100&y=200
那么首先我们先要得到各个参数的名称和值 很简单的一个字符串分隔即可
void CutString(string& in, const string& sep, string& out1 , string& out2) { auto pos = in.find(sep); if (string::npos == pos) { return; } out1 = in.substr(0 , pos); out2 = in.substr(pos+sep.size()); }
值得注意的是 第二个参数最好加上const修饰 原因有二
- 分隔符一般是不做修改的
- 如果我们不加const修饰 则分隔符必须要用string对象 而不能进行隐式类型转换 比如说填写“=” 这样子就是不可以的
does not name a type 错误
博主在使用auto推导pos类型的时候遇到了这个错误
实际上在这里这个错误的产生的原因是在makefile文件中没有使用c++11来编译该文件的 加上-std=c++11之后错误即可解决
之后我们调用函数处理这批数据即可
string str1; string str2; CutString(query_string , "&" , str1 , str2); string name1; string value1; CutString(str1 , "=" , name1 , value1); string name2; string value2; CutString(str2 , "=" , name2 , value2);
父进程读取处理完的数据
在cgi程序中 我们让程序向管道中写入了自己处理完的数据
到了父进程当中 我们只需要让父进程从管道中读取数据即可 代码如下
char ch = 0; while(read(input[0] , &ch , 1)) { response_body.push_back(ch); }
CGI程序总结
我们的CGI程序总结可以浓缩为下面的一张图
简单介绍下
- 首先浏览器通过GET或者POST方法将想要请求的资源和参数(如果有的话)上传服务器
- 服务器进行判断方法是POST还是GET 是否带参
- 如果不带参则直接构建响应返回
- 如果携带参数则根据方法获得参数
- 父进程创建管道之后使用fork函数创建子进程(cgi程序)
- 之后通过管道传递数据给子进程处理
- 子进程处理完数据之后将处理完的数据传递给父进程
- 之后父进程构建响应将响应传递给浏览器
我们如果将中间的步骤全部省略 就能得到这样一个图
- cgi程序从浏览器获取数据
- cgi程序加工完数据之后再传递给浏览器
这样设计有什么好处呢?
实际上我们使用cgi模式将通信和服务高度解耦了
这使得我们的cgi程序不必关注通信细节 只需要专心设计好服务即可
状态码介绍
HTTP的状态码如下:
编号 | 类别 | 意义 |
1XX | Informational(信息性状态码) | 接收的请求正在处理 |
2XX | Success(成功状态码) | 请求正常处理完毕 |
3XX | Redirection(重定向状态码) | 需要进行附加操作以完成请求 |
4XX | Client Error(客户端错误状态码) | 服务器无法处理请求 |
5XX | Server Error(服务器错误状态码) | 服务器处理请求出错 |
其中我们要记住的有下面这几个
101 信息请求中
101表示客户端发送的请求正在处理中 但是因为网速变快 这种状态已经不怎么常见了
200 OK
这是最常见的一个状态码 也就是我们访问网页成功的时候网页返回的响应行
301 永久重定向
比如果一个老的网站废弃不用了使用一个新的网站 那么此时这个网站就可以使用永久重定向 如果有人还在访问这个网站就会跳转到新网站上
此外如果收藏夹中收藏了老的网站 新的网站会覆盖收藏夹中老的网站
302 307 临时重定向
从名字看就更好理解了 和301永久重定向相比一个是永久的一个是临时的 它并不会覆盖掉收藏夹中的老网站
403 权限不足
这个常见于我们去实习的时候 自己的权限特别低 如果leader丢给你一个文档而你没有观看的权限就会出现这个状态码
404 NOT FOUND
常见于资源消失不见(被删除或过期) 又或者说资源根本不存在
比如说你访问一个网站的时候带上一个不存在的资源路径你就会看到这个状态码
504 Bad Gateway
常见于服务器出现问题 和客户端无关
Redirection(重定向状态码)
除了上面那些要记住的状态码之外 我们还需要更深入的理解重定向状态码
重定向又分为永久重定向和临时重定向 其中301表示永久重定向 302 307表示临时重定向
临时重定向和永久重定向本质是影响客户端的标签 决定客户端是否需要更新目标地址
如果某个网站是永久重定向 那么第一次访问该网站时由浏览器帮你进行重定向 但后续再访问该网站时就不需要浏览器再进行重定向了 此时你访问的直接就是重定向后的网站
而如果某个网站是临时重定向 那么每次访问该网站时如果需要进行重定向 都需要浏览器来帮我们完成重定向跳转到目标网站
本项目中只使用了状态码404来减少工作量
在同学们围绕这个项目做拓展的时候可以参考上面的状态码写出更多 代码大同小异 难度也不大 主要是html页面的编写
处理读取出错
一般来说我们的http项目会出现两种类型的错误
- 逻辑错误 读取完毕 我们要给予对方回应 告诉对方为什么出错
- 读取错误 读取不一定完毕 此时不给对方回应 退出即可
此处我们针对读取错误做出一些处理
首先我们在EndPoint类中设置一个成员变量bool stop
并且在构造函数中将它默认设定为false
之后我们将EndPoint里面的一些读取函数中加上读取错误判定
一旦我们判定此处读取错误则设置stop = true
此处列举几处需要添加读取判断的地方 具体内容可以参考gitee上的源码(在文章的最后)
bool RecvRequestLine() { if(Util::ReadLine(_sock ,http_request._request_line) >0 ) { std::cout << http_request._request_line ; } else { stop = true; } return stop; }
bool RecvRequestHeader() { std::string line; while(true) { line.clear(); if (Util:: ReadLine(_sock , line) <= 0) { stop = true; } if (line == "\n") { break; } line.resize(line.size()-1); // remove \n http_request._request_header.push_back(line); } if (line == "\n") { http_request._blank = line; } return stop; }
如果说stop为true 则我们在最后就不构建和发送数据了 代码表示如下
if (!ep->Stop()) { ep->BuildResponse(); ep->SendResponse(); }
处理写入错误
在进程间通信这一章中我们讲过两个进程通信的四种特殊情况
这其中有一种情况就是 读取端关闭 写入端就会强制关闭
实际上我们在深入理解之后也知道了 这是因为操作系统发送了13号信号的缘故
而在我们这次的项目当中 服务器是要尽量保持24h开机的 不可能因为读取端退出就关闭
所以说我们要忽略13号信号的作用 代码表示如下
signal(SIGPIPE , SIG_IGN);
多线程转化线程池
目前我们所使用的执行任务的方式是多线程 这种方式有以下的缺点
- 每次都是任务来之后再创建线程 浪费时间
- 每次都需要创建和销毁线程 浪费时间
- 如果线程数量并发过多会影响系统性能 从而导致卡顿
而以上的问题我们都可以通过线程池来解决一部分
所以说为了增强代码的健壮性我们将原本的多线程模式转化为线程池模式
设计思路如下
- 在服务器中设计一个任务队列
- 每次浏览器上传任务就上传到服务器的任务队列中
- 在服务器中设计一个线程池
- 线程池中的线程从task_queue中拿任务来执行
有细心的同学可能发现了 这实际上就是一个生产者消费者模型
那么我们首先来设计一个任务类
设计任务类
任务类只需要有两个成员变量
- 套接字
- 处理方法
套接字是为了让线程知道要处理什么
处理方法是为了让线程只要要用什么方法处理
代码表示如下
#pragma once #include <iostream> class Task { private: int sock; CallBack handler; public: Task() {} Task(int _sock) :sock(_sock) {} void ProcessOn() { handler(sock); } ~Task() {} };
设计回调函数
调用函数处理这个工作之前已经有一个Entrance 类做到过了 我们要做的只是给他改个名字
class CallBack { public: CallBack() {} void operator()(int sock) { HandlerRequest(sock); }
之后再给这个类加上一个仿函数方便我们后续调用
设计线程池
线程池的设计思路如下
- 首先要有一个任务队列来存放任务
- 要有一个num来标志任务队列的最大值
- 有个锁和条件变量来保证同步和互斥
代码表示如下
class ThreadPool { private: std::queue<Task> task_queue; int num; bool stop; pthread_mutex_t lock; pthread_cond_t cond;
之后就是一些常见的函数编写
比如说 加锁 解锁 睡眠 唤醒 添加任务 删除任务等等 这些代码在生产者消费者模型那一章节已经写过 这里就不再赘述 需要看全部代码的同学可以在文末连接处查看
补充内容
表单测试
此处会涉及到一些前端知识 有兴趣的同学可以去深入了解下
一般来说我们可以通过表单从前端页面向后端提交数据
表单的格式如下
<form> . form elements . </form>
我们可以写一个表单网页来测试我们的程序
它使用GET方法传参 分别传递两个数据x和y
<!DOCTYPE html> <html> <body> <form action="/testcgi" method="GET"> First name:<br> <input type="text" name="date_x" value="0"> <br> Last name:<br> <input type="text" name="date_y" value="1"> <br><br> <input type = "submit" value="submit"> </form> </body> </html>
我们前面学过了 GET方法是通过url传参的
那么当我们点击submit提交的时候在url中也应该出现参数
我们发现结果确实符合预期
cgi程序支持多种语言
除了c++之外 我们的cgi程序还可以使用其他多种语言编写
比如说c语言 python java php
由于博主目前为止只学过c/c++ 没办法给大家演示 同学们如果有什么有意思的程序也可以在cgi上做一些扩展