第一版 只支持 GET 请求的 WebServer
1.1 总览
1.2 客户访问一个文件的全部流程
- 客户可以访问服务器的静态资源,如图片文件、文本文件、html 文件等。具体操作是服务端启动后,用户在浏览器输入服务器的 ip 以及端口号和要访问的资源,如 127.0.0.1:8089/pic.png。
- 服务端主线程监听到有请求到来,把该请求放到请求队列中,线程池中的线程竞争该请求,通过互斥锁实现多线程间的互斥。
- 竞争到请求的某个工作线程对该 http 报文进行解析:
-
以 1 中的请求为例,用 chrome 进行访问,服务器接收的 http 报文如下所示: -
以上各字段的分类在《图解 http》中有描述: -
由于上面的报文是客户端发往服务器的,所以是请求报文,比对后,第一行是请求行,其中方法是 GET,请求的资源是/pic.png,使用的协议是 HTTP/1.1。剩下的各行说明属于其他首部字段,不过暂时用不上 -
解析 http 报文做的就是,通过字符串匹配一一提取出各首部字段 - 工作线程通过对 http 报文进行解析,知道了这是一个 GET 请求,请求的是服务器根目录下的 pic.png 文件,于是通过使用 sys/stat.h 头文件下 stat()判断该文件是否是目录、是否存在等状态,如果真实存在于服务器上,就使用 mmap()将其映射到内存中
- 工作线程通过向全局 epoll 内核事件表注册写就绪事件,通知主线程它已经准备好向客户端回复了
- 主线程安排该工作线程回复客户端
- 工作线程使用 writev()向客户端写入,wirtev()实现了多个不同内存区域集中写入,在这个例子中,第一个内存块是响应报文的头部,第二个内存块就是之前 mmap()映射的图片资源。
- 客户端接收到响应报文,chrome 浏览器将其渲染成页面,用户就看到了请求的图片,如下所示(由于本机的图片资源集中存放到 resource 文件夹下,所以 url 是图示这样,但原理是一样的):
1.3 事件处理模式
- 结构
??基本结构是《Linux 高性能服务器编程》最后给出的例子。采用 C/S 结构,即客户机/服务器结构,客户机访问服务器,服务器对外提供服务。 - 事件处理模式
??在主线程中采用 I/O 复用中的 epoll 对事件源进行监听,并使用 Reactor 事件处理模式,即主线程监听到 epoll 事件表中有读写就绪事件后,自己本身不进行处理,而是安排工作线程去处理,主线程只负责监听。
1.4 缺点
- GET 请求只能访问服务器上的静态资源,如果要实现一个 OJ 网站,比如查看题目列表,容易想象到,不存在这样的一个静态页面,必须是要先访问数据库获取题单数据,而后通过循环实现页面的动态生成。这就不是这一版 WebServer 能够胜任的了。
第二版 支持 GET 和 POST 请求的 WebServer
2.1 总览
2.2 POST 表单提交生成一个动态页面的过程
2.2.1 背景:
??一般网站都会有登录的功能,用户输入账号密码通过 form 表单提交,向服务器发送一个 POST 方法的 http 报文。服务器要去访问数据库查看账号密码是否正确,以此决定向用户展示一个什么样的页面,而这就是一个动态页面,如下所示:
-
登录页 -
登录失败 -
登录成功 -
通过以上三张图片的比较,有几点特别的需要关注:
-
页面跳转不是通过我们在地址栏输入 url 而实现,而是通过点击“确定”按钮,提交了登录表单,表单跳转到了指定的处理页面(cgi/cgiMysql),实际代码如下(忽略格式): <form action="cgi/cgiMysql.cgi" method="post">
<input
type="text"
name="username"
placeholder="用户名"
required="required"
/>
<input
type="password"
name="password"
placeholder="登录密码"
required="required"
/>
<button type="submit">确定</button>
</form>
-
登录失败和登录成功的 url 地址是一样的,但展示的页面却不一样,实际上服务器上并不存在两个实际的静态页面来分别展示登录成功和失败。而是通过一定手段实现了动态页面的生成,这个手段就是 CGI(Common Gateway Interface)。关于 CGI,简单用下面一个图来描述:
2.2.2 CGI 实现动态页面生成
- 先来看代码,看看登录验证的 cgi/cgiMysql 是什么(省略部分暂时无关内容,说的就是数据库查询)
// ./cgi/cgiMysql.cgi
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <mysql/mysql.h>
int main(int argc, char *argv[])
{
char username[20];
char password[20];
int statusNum;
char statusStr[200];
if (sscanf(argv[0], "username=%[^&]&password=%s", username, password) == 2)
{//(1)
if (testSearch(username, password))
statusNum = 1;
else
statusNum = 2;
}
//响应头
printf("HTTP/1.1 200 OK\r\nContent-type:hello\r\n\r\n");
//页面主体
printf("<!DOCTYPE html>");
printf("<html lang=\"en\">\n");
printf("<head>\n");
printf("<title>CGI Check Program</title>\n");
//printf("<meta http-equiv=\"refresh\" content=\"3;url=/index.html\">");//变成html有用,cgi渲染的没用
if (statusNum == 1)
printf("<script>window.setTimeout(\"location.href = '../index.html'\", 3000);</script>\n"); //可行
else if (statusNum == 2)
printf("<script>window.setTimeout(\"location.href = '../log.html'\", 3000);</script>\n");
printf("<meta charset=\"UTF-8\" />");
printf("</head>\n");
printf("<body>\n");
if (statusNum == 1)
{
printf("<h2>hello %s</h2>", username);
printf("<h3>登录成功,3秒后自动跳转到主页,或点击下方链接跳转</h3>");
}
else if (statusNum == 2)
printf("<h3>登录失败,3秒后返回登录页</h3>");
printf("<a href=\"/index.html\">主页</a>");
printf("</body>\n");
printf("</html>\n");
return 0;
}
-
从上往下看,首先看(1)部分,使用 sscanf()解析字符串 argv[0],从该字符串中解析出用户名和密码。为什么,先从一个简单的例子入手: char str[100];
strcpy(str,"username=admin&password=123456");
char username[20];
char password[20];
sscanf(str, "username=%[^&]&password=%s", username, password);
上面这几行代码,可以实现对字符串的提取,原字符串为"username=admin&password=123456",sscanf 之后,username=“admin”,password=“123456”,关于 sscanf()还有其他的正则匹配,可以自行查找用法 -
接下里一个问题,为什么解析 argv[0]就能得到用户名和密码?为什么 argv[0]的值就是"username=admin&password=123456"?要回答这个问题,要看下之前登录时,点击“确定”按钮后发生了什么: -
按照之前对 GET 请求报文的分析,来看这个 POST 请求报文,首先是第一行请求行,方法是 POST,登录之后请求转发到 cgi/cgiMysql.cgi,协议使用 HTTP/1.1。以下其他的首部字段大多数不用看,只看Content-Length这个字段(post 请求携带的数据长度=30,这是 debug 时忘记删了,不是请求报文的组成部分),首先,之前的 GET 请求报文没有这个字段,其次,其值是30(字节);直接看该报文的最后一行,发现是"username=admin&password=123456",这正是刚刚我输入的账号和密码,而且也正好是30字节长度。对该请求进行字符串解析,容易提取出该部分内容,所以这是对 cgiMysql 代码中 argv[0]值为什么是"username=admin&password=123456"的解释 -
那么为什么又是通过 argv[]这种命令行参数的方式进行参数传递的呢?这其实和 cgi 程序是怎么执行的有关:cgi 本质上也是一个 C/Cpp 程序,只不过后缀是.cgi,编译时也是通过 g++编译的;现在想一个场景,在一个 C 程序里面,怎么在该程序运行时,调用另一个程序?一个答案是使用 exec 系列函数,一个简单例子如下: #include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <wait.h>
int main()
{
pid_t childpid;
if ((childpid = fork()) == 0)
{ //子进程
execl("/bin/ls", "ls", NULL);
}
wait(NULL);
printf("hello\n");
}
这个例子中,fork()一个子进程,让子进程使用 execl()替换为另一个可执行程序,本例中是将子进程替换为 ls 进程,执行的是"ls"命令,即列出当前文件下所有文件,在 wait(NULL)即父进程回收了子进程之后,父进程又执行了一个输出语句。所有最终程序的运行结果是,先列出当前目录下的所有文件,随后输出 hello。这就达到了一个程序运行另一个程序的目的。execl()可以传递参数(以命令行参数形式),如下: execl("/bin/ls","ls","-l",NULL);
所以为了在正在运行解析 http 报文的进程中,转头去执行 cgi 程序,就要 fork()一个子进程,然后使用 exec 进行进程替换,替换为 cgi 程序,并传入参数(post 报文携带的数据)。所以就有了在 cgiMysql 直接解析 argv[0]参数的一幕,这在项目中实际的代码贴出来如下: pid_t childpid;
int piperet = socketpair(PF_UNIX, SOCK_STREAM, 0, cgiPipe); //创建双向管道,0端父进程用,1端子进程用
assert(piperet != -1);
if ((childpid = fork()) == 0)
{
int ret = dup2(cgiPipe[1], STDOUT_FILENO); //此后进入到cgi处理程序中,向标准输出输出就是向双向管道的1端写入数据,父进程读取0端获取
execl(m_real_file, m_post_data, NULL);
}
创建管道和 dup2()暂且不看,只看 execl(),其中 m_real_file 是 cgi 程序的地址,m_post_data 为传入的 post 参数。 -
重新回到 cgiMysql 程序中, 当获取到 username 和 password 之后,调用 testSearch(username, password)查询数据库,如验证通过 statusNum 为 1 否则为 2,testSearch()不是库函数,而是自己实现的函数,之前没贴出来,下面展示 cgiMysql 的完整代码:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <mysql/mysql.h>
bool testSearch(char *_username, char *_password)
{
bool flag;
//可能会发生sql注入攻击
char SelectAllExec[200] = "select * from user where username=";
strcat(SelectAllExec, "\'");
strcat(SelectAllExec, _username);
strcat(SelectAllExec, "\' and passwd=");
strcat(SelectAllExec, "\'");
strcat(SelectAllExec, _password);
strcat(SelectAllExec, "\';");
int ResultNum = 0;
MYSQL_RES *Result;
MYSQL_ROW Row;
MYSQL ConnectPointer;
mysql_init(&ConnectPointer);
mysql_real_connect(&ConnectPointer, "127.0.0.1", "root", "muou123", "webServer", 0, NULL, 0);
if (&ConnectPointer)
{
ResultNum = mysql_query(&ConnectPointer, SelectAllExec); //查询成功返回0
if (ResultNum != 0)
flag = false;
Result = mysql_store_result(&ConnectPointer); //获取结果集
if (Result == NULL)
flag = false;
if ((Row = mysql_fetch_row(Result)) == NULL) //遍历结果集,但因为这是查询语句,所以只看有无一行数据
flag = false;
else
flag = true;
}
else
flag = false;
mysql_close(&ConnectPointer); //关闭数据库连接
return flag;
}
int main(int argc, char *argv[])
{
char username[20];
char password[20];
int statusNum;
char statusStr[200];
if (sscanf(argv[0], "username=%[^&]&password=%s", username, password) != 1)
{
if (testSearch(username, password))
statusNum = 1;
else
statusNum = 2;
}
//响应头
//printf("HTTP/1.1 200 OK\r\nContent-type:hello\r\n\r\n");
//页面主体
printf("<!DOCTYPE html>");
printf("<html lang=\"en\">\n");
printf("<head>\n");
printf("<title>CGI Check Program</title>\n");
//printf("<meta http-equiv=\"refresh\" content=\"3;url=/index.html\">");//变成html有用,cgi渲染的没用
if (statusNum == 1)
printf("<script>window.setTimeout(\"location.href = '../index.html'\", 3000);</script>\n"); //可行
else if (statusNum == 2)
printf("<script>window.setTimeout(\"location.href = '../log.html'\", 3000);</script>\n");
printf("<meta charset=\"UTF-8\" />");
printf("</head>\n");
printf("<body>\n");
if (statusNum == 1)
{
printf("<h2>hello %s</h2>", username);
printf("<h3>登录成功,3秒后自动跳转到主页,或点击下方链接跳转</h3>");
}
else if (statusNum == 2)
printf("<h3>登录失败,3秒后返回登录页</h3>");
printf("<a href=\"/index.html\">主页</a>");
printf("</body>\n");
printf("</html>\n");
return 0;
}
- 之后就是根据 statusNum 的值,来动态生成要展示的页面。不过另外一个问题是,为什么使用 printf 可以展示页面到客户端呢?这就是 dup2()函数的作用,一个小例子如下:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <wait.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
pid_t childpid;
if ((childpid = fork()) == 0)
{
int filefd = open("result.txt", O_CREAT | O_RDWR, 0664); //打开一个输出文件描述符,文件不存在则创建
dup2(filefd, STDOUT_FILENO);
execl("/bin/ls", "ls", NULL);
}
wait(NULL);
printf("hello\n");
}
这个例子经过上面的 ls 改编,编译运行后,本应该输出在终端上的目录下所有文件名没有出现,而是出现在 result.txt 文件中,但是 hello 正常出现在终端上,因为 dup2 重定向只在子进程中有效,printf(“hello”)是在父进程中,父进程的标准输出没有被重定向。 这个小例子是将一个文件重定向到标准输出上,而实际项目中,是将双向管道的一端重定向到标准输出上,这样在 cgiMysql 中,直接向标准输出输出动态页面,实际是直接输出到了双向管道中,在父进程中读取该双向管道另一端的数据就是读取了 cgi 生成的动态页面。
2.3 生成动态页面更优雅的方法
recv(cgiPipe[0], jsonDataBuf, sizeof(jsonDataBuf), 0);
//使用json库解析
json parseRes = json::parse(jsonDataBuf);
//http模板
google::TemplateDictionary dict("example");
dict.SetValue("problemId", parseRes["problemId"].get<std::string>());
dict.SetValue("description", parseRes["description"].get<std::string>());
dict.SetValue("caseIn", parseRes["caseIn"].get<std::string>());
dict.SetValue("caseOut", parseRes["caseOut"].get<std::string>());
std::string output;
google::Template *tpl;
tpl = google::Template::GetTemplate("./root/OJPage/OJProblem.html", google::DO_NOT_STRIP);
tpl->Expand(&output, &dict);
- 注意到以上还用到了 json 库,这个的 github 地址为:https://github.com/nlohmann/json ,不需要进行配置,只要包含一个头文件即可用。使用 json 库可以更加优雅的进行参数解析。不过只适用于比较小的数据,如果数据比较大的话,使用 json 库会降低代码运行的速度,对于一个高性能服务器来说还是比较重要的。
第三版 OJ 测评服务器
3.1 总览
3.2 后台测评用户代码功能实现
- 其实第二版的 WebServer 已经几乎用到了所有需要的知识点了,OJServer 只是在第二版的基础增加了一个代码评测的功能。而其实现,也在上面都提到了,无非是 fork and exec、使用 dup2 进行重定向等,更多的是进行文件操作,比如遍历服务器上某道题的样例输入文件,作为参数输入给用户代码,用户代码输出的数据重定向到文件中,然后对比用户输出的代码和标准答案是否相同,有多少个样例输入就进行循环。
- 代码中关键地方都有注释,执行代码评测的 cgi 地址为./cgi/cgiCodeJudge.cgi
项目部署
- 因为该项目本身就是一个服务器,所以也不是很需要租一个云服务器,我个人采用的是内网穿透的方式,因为对于内网穿透也基本是小白,就不做描述了。
- 项目在线访问地址:http://42dd949812.qicp.vip:10958
注:暂时只有“替换空格”这道题设置了两个用例,其他的题目暂时都还没设置用例
- 项目 github 地址: https://github.com/sleep-jyx/OJServer
|