UCAS-Computer Network-Lab-2: Sockets and HTTP Servers

恭喜计网Lab2\text{Lab}-2成为我的博客里第一篇本校实验

提醒:本博客含有AI\text{AI}辅助创作内容

0. 关于本系列\text{0. 关于本系列}

上一次更新计算机网络相关的东西还是在换博客之前做了点儿CS144\text{CS144},但是只做到TCP\text{TCP}发送方就咕咕没做了。这学期选了计算机网络实验课,感觉还是非常有意思,内容相当丰富,工程量也比CS144\text{CS144}大了很多,主推一个量大管饱。

这次实验的内容是写简单的HTTP\text{HTTP}HTTPS\text{HTTPS}服务器,一天半的时间里写了一个小玩具,还是挺综合的,故在此记录。

这次实验要求倒也不高,对于HTTP\text{HTTP}服务器的请求无脑全部响应301\text{301},对于HTTPS\text{HTTPS}服务器也只需要处理少数几种简单情况,只能说玩具服务器跟真实的服务器还是有巨大的差别,要实现完整的互联网协议都不容易。

1. Parser\text{1. Parser}

对于本实验而言,请求报文也不长,暴力字符串查找也是可行的,但是这样并不具有拓展性,而且反而可能会把代码写得非常屎山。这个时候我突然想起来自己用过一套非常轻量级而且优雅的xml\text{xml}解析器pugixml\text{pugixml},将xml\text{xml}文件解析后得到文本树,从而可以在此基础上轻松分析轻松个锤子,纯纯苦力活重复劳动

其实说到底,请求报文也就是一种形式化的语言,既然如此就可以老方法来一套简单的词法分析语法分析(相比于任何一门常见的编程语言,报文的语法都算是极其简单的了)然后把解析后的AST\text{AST}存在一个数据结构里面开开心心地拿去查询字段。为此我们首先定义HTTP request\text{HTTP request}解析后的数据结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct http_request_line {
enum http_method method;
char* url;
int version_major;
int version_minor;
};
struct http_request_header {
char* field;
char* value;
struct http_request_header* nxt;
};
struct http_request {
struct http_request_line* request_line;
struct http_request_header* header_front;
};

本次实验也就用到这些字段就够了。

这里的麻烦之处在于,我们是开了两个线程处理两个服务器,两个服务器都需要进行解析的服务,这意味着语法分析必须是线程安全的,不仅如此,内存的申请也需要是线程安全的,malloc\text{malloc}free\text{free}自然可行,但是考虑到服务器可能会频繁地分配和释放资源,我最终还是选择自己模拟一个非常非常简单的内存池,也就是小数组+自旋锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// now this is only a toy server so static small arrays + lock can serve as a toy mem pool
// TODO: in the future I may need to upgrade it
#define MEM_POOL_SIZE 32
static pthread_spinlock_t req_head_lock;
static struct http_request_header mem_pool[MEM_POOL_SIZE];
static bool mem_pool_used[MEM_POOL_SIZE];
static pthread_spinlock_t req_line_lock;
static struct http_request_line req_line_pool[MEM_POOL_SIZE];
static bool req_line_used[MEM_POOL_SIZE];

void init_mem_pool() {
pthread_spin_init(&req_head_lock, PTHREAD_PROCESS_PRIVATE);
pthread_spin_init(&req_line_lock, PTHREAD_PROCESS_PRIVATE);
for (int i = 0; i < MEM_POOL_SIZE; i++) {
mem_pool_used[i] = false;
req_line_used[i] = false;
}
}

static struct http_request_header* alloc_request_header() {
pthread_spin_lock(&req_head_lock);
for (int i = 0; i < MEM_POOL_SIZE; i++) {
if (!mem_pool_used[i]) {
mem_pool_used[i] = true;
pthread_spin_unlock(&req_head_lock);
return &mem_pvoid http_server_init(void (*callback)()) {
server_socket = socket(AF_INET, SOCK_STREAM, 0);
if (server_socket == -1) {
perror("Socket creation failed");
exit(1);
}
http_log_msg("Socket created\n");
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(HTTP_PORT);
if (bind(server_socket, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1)
http_error("Binding failed");
http_log_msg("Socket bound at port %d\n", ntohs(server_addr.sin_port));
if (listen(server_socket, 5) == -1)
http_error("Listening failed");
http_log_msg("Listening on port %d.\n", ntohs(server_addr.sin_port));
http_handler = callback;
}
}

static struct http_request_line* new_request_line() {
pthread_spin_lock(&req_line_lock);
for (int i = 0; i < MEM_POOL_SIZE; i++) {
if (!req_line_used[i]) {
req_line_used[i] = true;
pthread_spin_unlock(&req_line_lock);
return &req_line_pool[i];
}
}
pthread_spin_unlock(&req_line_lock);
return NULL;
}


当然这个所谓的“内存池”是极其低效的,但是无所谓,只是意思意思,等我会写更好的内存池了,只需要把这一套换掉即可。

另外一方面,解析器可能会保存状态变量,对此我们的解决方法是要么上锁要么每个线程各自用一套(其实也就相当于有一个解析器类的不同实例)。一开始我被copilot\text{copilot}忽悠着用strtoktokenize\text{tokenize},结果自然是数据竞争——这个标准库函数需要保存静态中间变量,因此不是线程安全的。

我的解决方案就是把这个函数做成tokenizer\text{tokenizer}对象,然后在进行解析的时候,每个线程传入自己的tokenizer\text{tokenizer},这样就互不干扰了。

一个例子:解析HTTP\text{HTTP}头部

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// headers = (header"\r\n")+
static struct http_request_header* parse_http_request_headers(char* request_headers, struct tokenizer* tk) {
if (request_headers == NULL) return NULL;
struct http_request_header* header_list_tail = parse_http_request_header(request_headers, tk);
if (header_list_tail == NULL) return NULL;
struct http_request_header* header_list_head = header_list_tail;
char* request_header = tk->str;
while (request_header != NULL) {
header_list_tail->nxt = parse_http_request_header(request_header, tk);
header_list_tail = header_list_tail->nxt;
request_header = tk->nxt;
}
return header_list_head;
}

最后我们就可以为解析得到的数据结构定义一些简单的操作方法,比如最基本的操作查找头部字段。

1
2
3
4
5
6
7
8
9
struct http_request_header* get_header(struct http_request* http_request, char* field) {
struct http_request_header* header = http_request->header_front;
while (header != NULL) {
if (strcmp(header->field, field) == 0)
return header;
header = header->nxt;
}
return NULL;
}

我这里为了简单使用了链表进行查找,实际上我想使用哈希表或者树状结构效率更高,不过这个东西也就是换个存储数据结构的事情。

2. Server Frameworks\text{2. Server Frameworks}

手上有了解析器以后就可以开始写服务器处理请求了,对于HTTP\text{HTTP}服务器而言,处理请求的主循环大致就是这样的。

1
2
3
4
5
6
7
8
9
10
11
12
13
void http_server_run() {
while (http_server_should_run()) {
client_socket = accept(server_socket, (struct sockaddr *)&client_addr, &client_len);
if (client_socket == -1) {
perror("Accepting connection failed");
continue;
}
http_log_msg("Connection from client socket %d accepted\n", client_socket);
http_handler();
close(client_socket);
http_log_msg("Connection closed\n");
}
}

http_handler是一个回调函数,我在项目里设计成一个函数指针,这样就可以将HTTP\text{HTTP}处理连接的逻辑和处理请求的逻辑分开了,而且也能支持更灵活的自定义处理函数。http_log_msg则是自己写的一些用来记录服务器运行日志的简单函数。

原理非常简单,初始化服务器后服务器会监听在某个指定端口上并进入阻塞,客户端请求被accept捕捉到,然后继续进行处理。

初始化服务器的代码挺死板的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void http_server_init(void (*callback)()) {
server_socket = socket(AF_INET, SOCK_STREAM, 0);
if (server_socket == -1) {
perror("Socket creation failed");
exit(1);
}
http_log_msg("Socket created\n");
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(HTTP_PORT);
if (bind(server_socket, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1)
http_error("Binding failed");
http_log_msg("Socket bound at port %d\n", ntohs(server_addr.sin_port));
if (listen(server_socket, 5) == -1)
http_error("Listening failed");
http_log_msg("Listening on port %d.\n", ntohs(server_addr.sin_port));
http_handler = callback;
}

传入自定义的回调函数指针,这将用来设置服务器的http_handler,然后就是一些固定的流程:创建套接字,绑定端口,监听端口。

对于HTTPS\text{HTTPS}服务器,流程也差不多,无非就是初始化的时候要把加密算法库和加密算法上下文也初始化一下。

3. Handlers\text{3. Handlers}

还是非常容易理解的,就是把字节流读入缓冲区中,然后解析请求,然后根据请求生成响应。

这里我做了一点儿简化处理,因为在这个实验里服务器收到的请求报文一般都挺短小的,所以一个缓冲区就能装下了。对于HTTP\text{HTTP}服务器而言,我们只需要发一个响应头部,所以一个缓冲区也能够把发送内容全部装下了。

但是对于HTTPS\text{HTTPS}服务器,我们还可能需要发送主体部分,这通常是非常大的,所以需要分块传输,不断将新的字节块写入输出缓冲区然后发送,直到全部传完。

由于我们会在响应头部直接指定Content-Length字段,所以在发送字节块的时候不需要按照chunked的格式发送,也就是说不需要在每个字节块前面加上字节块长度。

HTTPS\text{HTTPS}服务器的handler\text{handler}大致如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
void my_https_handler() {
static char recv_buf[BUFFER_SIZE];
static char send_buf[BUFFER_SIZE];
static struct http_response response;
static struct http_request request;
https_log_msg("Start handling request\n");
int n = https_read(recv_buf, sizeof(recv_buf));
if (n == -1) {
perror("Reading from socket failed");
return;
}
https_log_msg("Request of length %d received. Start parsing request\n", n);
https_log_msg("Request:\n %s\n", recv_buf);
parse_http_request(recv_buf, &request, &https_tokenizer);
recv_buf[n] = '\0';
int start = -1, end = -2;
prepare_https_response(&request, &response, &start, &end);
if (response.status_code != NOT_FOUND && start < 0) {
https_log_msg("Invalid range\n");
return;
}
int header_len = response_to_header(&response, send_buf);
int length = 0;
if (header_len >= BUFFER_SIZE) {
https_log_msg("Response header too long\n");
return;
}
https_log_msg("Sending response header of length %d:\n %s\n", header_len, send_buf);
https_send(send_buf, header_len);
if (response.status_code == NOT_FOUND)
return;
FILE* fp = fopen(response.url, "r");
if (fp == NULL) {
https_log_msg("Opening file %s failed\n", response.url);
perror("Opening file failed");
return;
}
if (response.status_code == PARTIAL_CONTENT)
fseek(fp, start, SEEK_SET);
while (length < response.content_length) {
int block_len = BUFFER_SIZE;
if (length + block_len > response.content_length)
block_len = response.content_length - length;
int n = fread(send_buf, 1, block_len, fp);
assert(n == block_len);
https_send(send_buf, block_len);
length += block_len;
https_log_msg("Sending block of length %d\n", block_len);
}
free_http_request(&request);
}

我想这个代码还是非常直观的,我简单封装了SSL\text{SSL}加密读写的逻辑,这样就能隐藏细节,便于我们专心处理请求。

剩下的就是prepare_https_response了,这里有个小坑点,就是request\text{request}里面的url\text{url}字段是以/开头的,需要手动加一个.,从当前目录开始查找文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
static char path[SMALL_BUFFER_SIZE];
sprintf(path, ".%s", request->request_line->url);
FILE* fp = fopen(path, "r");
response->url = path;
response->version_major = HTTP_VERSION_MAJOR;
response->version_minor = HTTP_VERSION_MINOR;
if (fp == NULL) {
response->status_code = NOT_FOUND;
return;
}
struct http_request_header* range = get_header(request, "Range");
if (range != NULL) {
response->status_code = PARTIAL_CONTENT;
parse_range_value(range->value, start, end);
if (*end == -1) {
// get file size
fseek(fp, 0, SEEK_END);
*end = ftell(fp) - 1;
}
response->content_length = (*end) - (*start) + 1;
} else {
response->status_code = OK;
// get file size
fseek(fp, 0, SEEK_END);
response->content_length = ftell(fp);
*start = 0;
*end = response->content_length - 1;
}
response->location = NULL;
fclose(fp);

但是我发现我每次这样处理需要开关文件两次,应该还是有优化的空间。

4. Other things\text{4. Other things}

最后是一些杂项细节,比如说加密怎么加的,日志怎么打的。

除了固定的创建套接字等流程以外,HTTPS\text{HTTPS}服务器的初始化还要加上这样一段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
SSL_library_init();
OpenSSL_add_all_algorithms();
SSL_load_error_strings();
ctx = SSL_CTX_new(TLS_server_method());
// load certificate and private key
if (SSL_CTX_use_certificate_file(ctx, "./keys/cnlab.cert", SSL_FILETYPE_PEM) <= 0) {
perror("load cert failed");
exit(1);
}
https_log_msg("Certificate loaded\n");
if (SSL_CTX_use_PrivateKey_file(ctx, "./keys/cnlab.prikey", SSL_FILETYPE_PEM) <= 0) {
perror("load prikey failed");
exit(1);
}
https_log_msg("Private key loaded\n");

然后处理请求的时候要多加一个

1
2
3
4
5
6
7
8
9

ssl = SSL_new(ctx);
SSL_set_fd(ssl, client_socket);
if (SSL_accept(ssl) == -1) {
perror("SSL_accept failed");
continue;
}
https_log_msg("SSL connection established\n");

你肯定还会好奇前面的https_readhttps_send是怎么实现的,其实也很简单,就是对SSL_readSSL_write的封装。

这样哪怕我们换了加密算法库,也不需要动handler本身的逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
int https_read(char* buf, int len) {
int bytes = SSL_read(ssl, buf, len);
if (bytes < 0) {
perror("SSL_read failed");
exit(1);
}
return bytes;
}

void https_send(const char* buf, int len) {
SSL_write(ssl, buf, len);
}

至于日志的实现,倒是和计算机网络本身关系不大,我就不展开了。

让室友用浏览器连了一下服务器,结果文件是能传了,但是传过去的中文显示出来全是乱码(我猜是编码问题?),然后回头一看服务器崩了。我猜测是请求报文太长把缓冲区给爆了。

想想也是,我在写服务器的时候还真的没考虑浏览器的请求报文长啥样,显然是比学校测试用的那些例子要长多了,内存池我也开得很小,资源规模一上去自然就寄了。

5. Summary\text{5. Summary}

下面有请copilot\text{copilot}发表一下感言。

这次实验的内容还是非常有趣的,虽然只是一个玩具服务器,但是也能够让我对于网络协议有更深的认识,也算是对于CS144\text{CS144}的一个补充吧。

但这并不是CS144\text{CS144}的补充。有趣归有趣,我已经不想再手动写任何形式的parser\text{parser}点名某个m姓渲染器场景文件