四月初就开始着手写Web服务器,因为一些事耽搁了一个月,最近又在提交代码了,文章列表:
这不是一个写Web服务器的教程,只是做的过程的记录,因为是先写代码后写文章的,有些过程我直接凭记忆在这里写下来,可能会有疏漏。
建立 TCP 连接
这一部分参考 Liso Project 的 start_code、深入理解计算机系统 第二版 10-12 章、UNIX网络编程 卷1 第三版 1-4 章。
首先作为一个 Web服务器,要能够监听端口、等待TCP连接、建立TCP连接,这是基本要求。因为使用C语言开发,所以要用 UNIX 的套接字API,TCP连接建立与释放的流程如下:
其中:
- socket() 用于创建一个套接字结构体,这里需要使用的协议族、协议类型等。这一步一般不会出问题。
- bind() 用于绑定到一个本地端口。出错的原因一般有端口已经被占用、非 Root 用户绑定知名端口。
- listen() 用于说明这个套接字用于被动接收连接请求。
- accept() 会导致程序陷入睡眠,直到系统中断提醒程序有新连接建立。
- read() 也是一个 I/O操作,会导致程序陷入睡眠,这个时候内核开始从网卡的Buffer里面拷贝数据到内存里,一旦拷贝完了,系统中断让进程切回来继续执行。
这部分对应的代码:
#include <stdlib.h>
#include <stdio.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <netinet/in.h>
#include <netinet/ip.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <string.h>
#define BUFFER_SIZE 4096
#define FORK_CHILD_PID 0
typedef struct http_mod {
int sockfd;
struct sockaddr_in addr;
} http_mod;
http_mod* http_init(uint16_t port) {
http_mod* m = (http_mod*) malloc(sizeof(http_mod*));
m->sockfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (m->sockfd == -1) {
fprintf(stderr, "Create socket failed.");
free(m);
return NULL;
}
m->addr.sin_family = AF_INET;
m->addr.sin_port = htons(port);
m->addr.sin_addr.s_addr = htonl(INADDR_ANY);
int bind_ret = bind(m->sockfd, (struct sockaddr*) &(m->addr), sizeof(m->addr));
if (bind_ret != 0) {
fprintf(stderr, "bind to %d failed!", port);
if(close(m->sockfd)) {
fprintf(stderr, "Close socked failed!\n");
}
free(m);
return NULL;
}
int listen_ret = listen(m->sockfd, 5);
if (listen_ret != 0) {
fprintf(stderr, "Failed to listen!\n");
if(close(m->sockfd)) {
fprintf(stderr, "Close socked failed!\n");
}
free(m);
return NULL;
}
fprintf(stdout, "start http mod successfully! \n");
return m;
}
//TODO: replace fork with IO multiplex
int start_receive_conn(http_mod *http) {
struct sockaddr_in client_sock_addr;
socklen_t cs_size = sizeof(client_sock_addr);
fprintf(stdout, "Waiting for a connection, localport: %d\n", ntohs(http->addr.sin_port));
while (1) {
int new_sock = accept(http->sockfd, (struct sockaddr*)(&client_sock_addr), &cs_size);
if (new_sock == -1) {
fprintf(stderr, "Accept connections failed!\n");
if (close(http->sockfd)) {
fprintf(stderr, "Close socket failed!\n");
}
return -1;
} else {
fprintf(stdout, "Receive connection from %s\n", inet_ntoa(client_sock_addr.sin_addr));
}
if (fork() == FORK_CHILD_PID) {
handle_request_loop(new_sock);
if (close(new_sock)) {
fprintf(stderr, "Close real sock failed! \n'");
}
fprintf(stdout, "Close connection from %s", inet_ntoa(client_sock_addr.sin_addr));
exit(0);
}
if (close(new_sock)) {
fprintf(stderr, "Close real sock failed! \n'");
return -1;
}
}
if (close(http->sockfd)) {
fprintf(stderr, "Close sock failed! \n'");
}
fprintf(stdout, "Liso has stopped.\n");
return 0;
}
void liso_init(struct arguments* arg) {
http_mod* hm = http_init(arg->port);
if (hm == NULL) {
fprintf(stderr, "Initialize http module failed!\n");
return;
}
start_receive_conn(hm);
}
因为程序在调用 accept() 建立了新连接后就不处于监听状态了,此时别的客户端是无法和服务端建立连接的。所以必须要处理并发问题,一个最简单的处理办法是用 fork() 的方式支持并发:
每次建立了一个TCP连接后,就调用fork() 派生出一个子进程,此时子进程和父进程所有的内存数据、文件打开列表都是一样的,这时可以让子进程继续处理数据,而父进程把刚刚建立的连接关闭掉,继续调用 accept() 等待客户端连接就可以支持并发了。
但是这样弊端也很大,那就是支持并发所需要的开销太大了。更好的方法是用线程和IO多路复用,但是出于快速写一个架子的考虑,我先让这个“服务器”可以踉踉跄跄地跑起来再说,多路复用先加入到 TODO List 里面去。
解析 HTTP 请求
这一部分参考 编译原理(龙书) 3到4 章、Flex&Bison 开发文档、RFC 2616(HTTP/1.1 标准)、RFC 2396。
解决一个计算机问题,建立一个稳定可复现、可调试的一个观察点还是很必要的,尤其我在TCP协议之上做数据传输,如果无法观察到实际传输的数据是很恼火的,所以我要抓包看数据。一开始我想用 WireShark,后来发现那玩意从源码编译起来有点麻烦,直接用 apt 安装了一个 tcpdump。
RFC文档看的是 2616,这是 CS-15-441/641 Project1 的项目要求给的参考文档,似乎这个文档已经被新的标准所替代,但是绝大部分内容还是可以参考的。
HTTP请求的格式如下:
仔细研究了一下格式,发现其实用C语言代码解析一下就很方便了,完全没有必要用Flex和Bison,但是既然文档那么要求,就试一下这两个工具。
Flex 是用于词法分析的工具,它把一个输入的字符串分割成一个个词法单元送给语法分析工具 Bison。Flex 和 Bison 在 Ubuntu 中都可以通过 apt 安装。在 Flex 中,我需要定义一些正则表达式来匹配词法单元,Flex 文件格式如下:
%{
定义词法单元,可以用 #define 表示
%}
声明部分
%%
转换规则
%%
辅助函数
Flex 和 Bison 结合起来使用的时候, %{ %} 中的词法单元可以不定义,放到 Bison 文件中去定义。转换规则是重点,每行规则以一个正则表达式开始,后面再跟一个代码块。代码块里可以执行一些逻辑,比如调用辅助函数,返回词法单元给 Bison。
转换规则这里有几个坑点:
- 我一开始每行加了\t来indent一下,这里不能加,不要从行首开始写正则表达式
- Flex支持的正则表达式和我平时用的正则表达式不太一样,有些符号如 \w 是不支持的
- 正斜杠(forward slash)符号 / 会被转义,要想不转义前面加上 \ 是没有用的,得用双引号括起来:"/"
这里放一部分规则:
辅助函数那里,如果不使用 Bison 的话,需要定义一个 yywrap() 函数:
int yywrap() {
return 1;
}
使用 flex xxx.l 命令就可以把 xxx.l 文件编译为 c 文件,然后调用 yylex() 函数就会开始解析输入的数据。 默认是从 stdin 解析的。
Bison文件的格式和 Flex 类似。需要定义产生式。Bison 会根据产生式自动生成语法解析器。
网友评论