并发 HTTP 服务器 (4)
0x16-套接字编程-HTTP服务器(4)
新连接
- 一个新晋连接,有哪些信息是值得我们关注的?
- 该如何存储它们?
这里将会叙述的并不会很完整,因为不同目的的网络程序,需要关注的信息也大不相同
特别是这个程序关注的是如何使用C语言编写一个服务器
- 我们最关心的,还是对端通过这个新连接所发来的信息
- 简单来说就是我们
read
到的信息。进行过系统编程的都应该会知道这个函数,与之对应的是write
。与 C标准库 为我们提供的标准格式化输入输出不同的地方在于其操作的对象。read/write
操作的是一个在叫做 文件描述符(file description) 的int
类型的东西,而标准库的函数(printf/scanf
)操作的则是一个FILE*
特殊的结构体指针,这两者之间可以互相转换,通过fdopen(fd-->FILE*)/fileno(FILE*-->fd)
具体相关知识,查阅相关信息,如著名的APUE
。
- 简单来说就是我们
- 其次我们对这个信息做相应处理,中间会有很多状态,也就是常常听到的HTTP状态机
- 实际上也就是几个状态值在转换和过渡,只是名字专业了一些
- 最后我们会生成一个信息,用来回复对端
- 这个也叫做响应报文
*nix
下的文件描述符(file description)在Windows
下近似相当于 文件句柄(file handler),只不过前者是有规律的递增,而后者则不是。
如何存储?
typedef unsigned char boolean; struct connection { int file_dsp; #define CONN_BUF_SIZE 512 int r_buf_offset; int w_buf_offset; string_t r_buf; string_t w_buf; struct { /* Is it Keep-alive in Application Layer */ boolean conn_linger : 1; boolean set_ep_out : 1; boolean is_read_done : 1; /* Read from Peer Done? */ boolean request_http_v : 2; /* HTTP/1.1 1.0 0.9 2.0 */ boolean request_method : 2; /* GET HEAD POST */ int content_type : 4; /* 2 ^ 4 -> 16 Types */ int content_length; /* For POST */ string_t requ_res_path; /* / */ }conn_res; }; typedef struct connection conn_client;
其中有一个陌生的事物,
string_t
,这个是用来进行字符串操作的一个自己写的结构,用于简化操作,可以把它看成一个可以自动增长的字符串类型。再者就是,内嵌结构体中使用到了
位域
这个方式,主要是因为C中没有原生的bool
类型,使用int
来表示又太过奢侈这个位域的写法在某些人看来似乎不太感冒,实际上还有替代的方法可以用,也就是使用掩码的思想,在一个
int
型中的不同位包含不同的信息,实际上和我这个的原理是相同的,只不过我将它拆开了,这样就可以不写各种处理宏/* 另一种写法 */ ... struct { int status_set; int content_length; string_t request_length; }conn_res ... enum { SET_CONN_LINGGER = 1, SET_EPOLLOUT = 1 << 1, ... } /* 几乎对于每一个位置的操作都有三个,设置,复位,检测 */ #define SET_CONN_LINGER(MASK_SET) (MASK_SET &= SET_CONN_LINGER) #define CLR_CONN_LINGER(MASK_SET) (MASK_SET &= (~SET_CONN_LINGER)&0xFFFF) #define IS_CONN_LINGER(MASK_SET) (MASK_SET & SET_CONN_LINGER)
依此类推。
实际上,对于这个
string_t
的设计是一个想当然的失败,当时是想尝试使用面向对象的想法,但是没有考虑到其使用时候的冗余,后面会看到这个小麻烦,但是总体上还是可以得。这次总结出来的就是,在C里面使用面向对象的思维实在有点勉强,具体等后方说到这个
string_t
时会再提到。2016-08-28 将其修改为正常的C风格。
所以实际上来看一看,我存储了哪些状态信息
- 一个新连接的 file description
file_dsp
: 这个肯定是必要的,不然你怎么对这个新连接进行操作。 - 一个读缓冲配着一个读位移(
r_buf
和r_buf_offset
) :- 之所以需要位移,是因为你要牢牢记住,尤其是在网络通信中,总会出现网络不稳的状况,这会导致某时候你的信息不能完全一次新的读取到,也就是需要分次读取,所以你需要知道上次你读到哪里
- 另一个原因是因为,在解析读取的信息的时候,你要时刻知道自己处理到哪里了,是否接收到数据不完整?是否接收的数据有错?等等。
- 一个写缓冲配着一个写位移('w_buf'和
w_buf_offset
)- 写事件要比读事件简单许多。
- 一个包含HTTP状态的属性结构
conn_res
conn_linger
: 是否保持连接(keep-alive)set_ep_out
: 是否设置监听写事件(EPOLLOUT)is_read_done
: 是否已经读取信息完毕request_http_v
: HTTP协议版本request_method
: HTTP请求方法content_type
: 响应报文 中的 属性content_length
: 同上requ_res_path
: 对端想请求的资源
- 一个新连接的 file description
所以这也从另一个方面回答了上面的第二个问题 该如何存储它们?
- 了解过,要存储那些信息,该如何存储这些信息之后,就能继续服务器的编写
事件循环
- 前面我们的进度,已经到了
handle_loop
里面,并且将总体流程已经过了一遍 handle_loop
就是一个事件循环,我们整个程序的编程模型就是一个 事件驱动 的编程体系,什么是事件驱动,可以查阅相关资料,如 UNP 等书籍。在这个事件循环中,我们使用两个事件驱动我们的流程 : 读事件, 写事件- 即,一旦某个连接可读(回忆一下TCP连接可读可写)我就处理读事件,写事件也是如此。
- 在这个循环中,我们启动了两种线程,一种专门用于接受建立新连接,一种专门用来处理新连接的读写事件,分别是
listen_thread
和workers_thread
,常理来说前者一个就够了,后者可以酌情处理。 - 先说说比较简单的
listen_thread
listen_thread
回到
handle_loop
的代码中可以看到有一个独立的代码块{}
,这个代码块的作用就是将我们之前创建的服务器套接字,添加到一个epoll
实例中,准备传给listen_thread
。在该epoll
实例中,我们监听了它的读事件,以及错误事件EPOLLERR
{ /* Register listen fd to the listen_epfd */ struct epoll_event event; event.data.fd = file_dsption; event.events = EPOLLET | EPOLLERR | EPOLLIN; epoll_ctl(listen_epfd, EPOLL_CTL_ADD, file_dsption, &event); }
紧接着,我们需要创建线程,用来完成接受创建新连接, 分配新连接, 处理新连接
先说前两个
listen_thread
/* Listener's Thread * @param arg will be a epoll instance * */ static void * listen_thread(void * arg) { int listen_epfd = (int)arg; struct epoll_event new_client = {0}; /* Adding new Client Sock to the Workers' thread */ int balance_index = 0; while (terminal_server != CLOSE_SERVE) {
这是一个永不停止的循环,除非在外部传入了一个信号
CTRL+C
,其实没什么意义,不过还是写了//这是监听的阻塞地点,在此处会返回有多少个事件发生了,当然这里只有一个 int is_work = epoll_wait(listen_epfd, &new_client, 1, 2000); int sock = 0; // 如果不是因为超时才到了这里 while (is_work > 0) { /* New Connect */ //接受并创建新连接 sock = accept(new_client.data.fd, NULL, NULL); if (sock > 0) { // 如果没有意外的话 set_nonblock(sock); clear_clients(&clients[sock]); clients[sock].file_dsp = sock; // 分配新连接给各个workers_thread add_event(epfd_group[balance_index], sock, EPOLLIN); balance_index = (balance_index+1) % workers; } else /* sock == -1 means nothing to accept */ break; } /* new Connect */ }/* main while */ close(listen_epfd); pthread_exit(0); }
其实在上面的
accept
和set_nonblock
可以用一个系统调用来解决,accept4
,而不需要使用两个不同的系统调用来完成这个功能,具体可以查询文档。
- 可以看出,这个
listen_thread
的职责非常简单,就只是单纯的接受创建新连接,设置一些属性,并且分配给workers_thread
,所以真正复杂的工作还是在后者身上
workers_thread
- 这是整个程序的核心部分,但还是按照庖丁解牛的方法,一步步分解
整个的代码有点冗长,但是逻辑十分清晰,大体可以分成读写两部分
static void * workers_thread(void * arg) { int deal_epfd = (int)arg; struct epoll_event new_apply = {0}; while(terminal_server != CLOSE_SERVE) { int is_apply = epoll_wait(deal_epfd, &new_apply, 1, 2000); if(is_apply > 0) { /* New Apply */ int sock = new_apply.data.fd; conn_client * new_client = &clients[sock];
到此处为止,前面的逻辑和
listen_thread
十分相似,需要额外说的就是epoll_wait
接口中的第二,三个参数 , 代表着有事件改变状态的新连接(new_apply[i]),和有多少个这样的新连接(i)。代码中写的是(,&new_apply,1,)
代表着我每次只想得到一个,说明及替代方案在后面会提到,跳过也无所谓。/* 读事件 */ if (new_apply.events & EPOLLIN) { /* Reading Work */ /* handle_read 是接收并解析HTTP请求报文的地方 */ int err_code = handle_read(new_client); /* 此处省略一个很重要的分片错误处理 */ else if (err_code != HANDLE_READ_SUCCESS) { /* Read Bad Things */ close(sock); continue; } } // Read Event
以上便是简化的读事件的处理,抛开来看,一切的核心就是
handle_read
这个函数,后放会详细讲解。/* 写事件 */ else if (new_apply.events & EPOLLOUT) { /* Writing Work */ int err_code = handle_write(new_client); /* TCP's Write buffer is Busy */ if (HANDLE_WRITE_AGAIN == err_code) mod_event(deal_epfd, sock, EPOLLONESHOT | EPOLLOUT); else if (HANDLE_WRITE_FAILURE == err_code) { /* Peer Close */ close(sock); continue; } /* if Keep-alive */ if(1 == new_client->conn_res.conn_linger) mod_event(deal_epfd, sock, EPOLLIN); else{ close(sock); continue; } } /* EPOLLOUT */
所谓
clear_clients
其实就是清除一些现有状态,不然下次有别的连接占用的时候就会错乱了。else { /* EPOLLRDHUG EPOLLERR EPOLLHUG */ close(sock); } } /* New Apply */ } /* main while */ return (void*)0; }
- 看起来有点长,实际上模块十分清楚。从上往下看,由三个
if - else
分支组成,分别处理 读事件,写事件,错误事件 - 这其中省略了一些十分重要的错误处理,以及某些优化,希望可以自己补全,但这都无所谓,因为已经将这种编程模型全盘托出,接下来就是细节方面的处理了。
handle_read
- 这应该是这个 HTTP服务器 真正的重点所在,用一个词来形容就是 核心技术,当然没那么高端,就是个程序而已。
前面提到一个名词,叫做 HTTP状态机,指的就是状态的转换,在C语言中,可以使用
enum
来实现typedef enum { HANDLE_READ_SUCCESS = -(1 << 1), HANDLE_READ_FAILURE = -(1 << 2), ... }HANDLE_STATUS;
代表了,
handle_read
是成功还是失败,有一个额外的MESSAGE_IMCOMPLETE
状态也输一这个范畴内,但是设计的时候出现了差错,可以选择将其放在里面。MESSAGE_IMCOMPLETE
是为了应对TCP分片问题,所以在显示网络中很常见,但是本地测试的时候可能不容易发现,可以使用工具tc
来模拟弱环境。HANDLE_STATUS handle_read(conn_client * client)
HANDLE_STATUS handle_read(conn_client * client) { int err_code = 0; /* Reading From Socket */ err_code = read_n(client); if (err_code != READ_SUCCESS) { /* If read Fail then End this connect */ return HANDLE_READ_FAILURE; }
到这里为止是读取所有可以读到的数据
/* Parsing the Reading Data */ err_code = parse_reading(client); if (err_code == MESSAGE_INCOMPLETE) return MESSAGE_INCOMPLETE; if (err_code != PARSE_SUCCESS) { /* If Parse Fail then End this connect */ return HANDLE_READ_FAILURE; }
到这里为止是处理所有已经读到的数据
return HANDLE_READ_SUCCESS; }
到了这里,就证明读和处理都已经正确完成了。
巧用
gdb
能让你轻松理解整个状态机的逻辑
- 从函数接口上看,它接受一个
conn_client
类型的指针,回想一下,这就是我们存储每个新连接的各种信息的地方,返回值就是这个动作的状态了。 从功能上看,这个函数主要的工作就是将
handle_read
拆分成两大部分:- 读取数据 (
read_n
)- 首先读取所有能读取的数据(从
socket
中) - 验证数据是否完整
- 对于
GET
而言就是是否读取到了一个空行\r\n
- 对于
POST
来说就是是否一句Content-length
属性的值将 body 读取完整了
- 对于
- 首先读取所有能读取的数据(从
- 处理数据 (
parse_reading
)- 处理HTTP请求报文第一行状态行
- 处理剩余的头属性,如
Connection
- 生成响应报文,你可以考虑将这一步划分出去,因为这一步涉及到了磁盘I/O
- 读取数据 (
先说第一部分,读取数据(
read_n
)static int read_n(conn_client * client)
实现一个
read
函数的加强版__thread char read_buf2[CONN_BUF_SIZE] = {0}; static int read_n(conn_client * client) { int read_offset2 = 0; int fd = client->file_dsp; char * buf = &read_buf2[0]; int buf_index = read_offset2; int read_number = 0; int less_capacity = 0;
从前往后依次是读缓冲区位移, 处理的连接套接字,
buf
纯粹多此一举还可能阻碍编译器优化,但我还是写了,强迫症吧,buf_index
同理,read_number
是本次读的字符个数,less_capacity
是缓冲区的容量余量while (1) { /* 因为是非阻塞,所以要不停地读,直到`read`返回-1,且errno为EAGAIN */ less_capacity = CONN_BUF_SIZE - buf_index; if (less_capacity <= 1) {/* Overflow Protection */ /* 万一这本地的缓冲区容量不够了,就刷新进 conn_client 中 */ buf[buf_index] = '\0'; /* Flush the buf to the r_buf String */ /* 对于 STRING 宏,可以看看我的源码中的 wsx_string.h */ cappend_string(client->r_buf, STRING(buf)); client->r_buf_offset += read_offset2;//- client->read_offset; read_offset2 = 0; buf_index = 0; less_capacity = CONN_BUF_SIZE - buf_index; /* 清空缓冲区成功 */ }
上面的代码中,有一个
APPEND
宏,是用来简化代码的,功能是#define APPEND(str) str,(strlen(str)+1)
read_number = (int)read(fd, buf+buf_index, less_capacity); /* 0代表对端关闭了连接或者说是已经读完了 EOF(对端调用close()/shutdown()) */ if (0 == read_number) { /* We must close connection */ return READ_FAIL; } /* -1 代表现在没东西可以读了 */ else if (-1 == read_number) { /* Nothing to read */ if (EAGAIN == errno || EWOULDBLOCK == errno) { /* 这个时候,我们该做的就是将缓冲区的东西,存储起来 */ buf[buf_index] = '\0'; append_string(client->r_buf, STRING(buf)); client->r_buf_offset += read_offset2;//client->read_offset; return READ_SUCCESS; } return READ_FAIL; } else { /* Continue to Read */ /* 能读取到信息,就继续读 */ buf_index += read_number; read_offset2 = buf_index; } } /* while(1) */ }
__thread
关键字是多线程编程里一个挺有用的一个关键字,具体可以查询资料,简单来说,就是让每个线程拥有一个自己的全局变量。
- 经过
read_n
之后,我们就(可能)获取到了完整的数据了,接下来就是解析它们,引入一个状态 PARSE_STATUS
typedef enum { /* Parse the Reading Success, set the event to Write Event */ PARSE_SUCCESS = 1 << 1, /* Parse the Reading Fail, for the Wrong Syntax */ PARSE_BAD_SYNTAX = 1 << 2, /* Parse the Reading Success, but Not Implement OR No Such Resources*/ PARSE_BAD_REQUT = 1 << 3, }PARSE_STATUS;
解释的很清楚了,不再赘述。
PARSE_STATUS parse_reading(conn_client * client)
PARSE_STATUS parse_reading(conn_client * client) { int err_code = 0; requ_line line_status = {0}; client->r_buf_offset = 0; /* Set the real Storage offset to 0, the end of buf is '\0' */
requ_line
是一个结构体,用来存储状态行所含有的三个信息: 请求方法, 请求资源, HTTP版本号/* Get Request line */ err_code = deal_requ(client, &line_status); /* 回想一下这个状态,TCP分片的情况 */ if (MESSAGE_INCOMPLETE == err_code) /* Incompletely reading */ return MESSAGE_INCOMPLETE; if (DEAL_LINE_REQU_FAIL == err_code) /* Bad Request */ return PARSE_BAD_REQUT;
到这里为止是处理状态行的代码
/* Get Request Head Attribute until /r/n */ err_code = deal_head(client); /* The second line to the Empty line */ if (DEAL_HEAD_FAIL == err_code) return PARSE_BAD_SYNTAX;
到这里为止是处理完了所有的头属性
/* Response Page maker */ err_code = make_response_page(client); if (MAKE_PAGE_FAIL == err_code) return PARSE_BAD_REQUT; return PARSE_SUCCESS; }
对于
deal_requ
,deal_head
来说,只是一个很简单的从大字符串中识别出小字符串,并存储起来的问题,不想过多的叙述。在这个处理过程中,自己实现了一个get_line
按行读取的函数,同样会被后面的deal_head
使用- 这其中有一些问题需要注意一下,那就是你需要考虑TCP分片问题,这是我第三次提到这个东西,也就是用状态机监测好这个问题是否发生,并及时处理。
- 在
deal_head
中,可以按行进行循环读取(get_line
),知道你发现空行,那么你就处理完成了,如果是POST
方法,你还需要继续读取,直到读取完它的body。现在想想,conn_client
这个结构体中的那些属性是干什么的,就是从这里解析出来的。
读取解析完成之后,就能进行响应报文的生成了。在下一节中详述
题外话
- 和上一个部分不同,再上一个部分我尽可能的不落下一丝一毫的细节,将自己如何写程序的想法分享给诸位
- 但这章节,无论怎么看,从思维,从代码都不再像之前那般面面俱到,我认为也没有必要,这一章大家应该就具备了自我独立思考的能力,实际上在给出了结构图之后,后面的章节就不怎么必要了
- 但我想把自己的想法写出来,想想求学的这几年无人引导,苦苦寻找资料的那些日子,我觉得我有必要把自己从网络上得来的知识,再次回馈给网络,这才是生生不息,自我进步的道理。
最后
额外的补充
- 我的博客提到了一些错误处理的详细解释: 一个HTTP服务器的C之路(上)
- 陈硕的书 : 《Linux多线程服务端编程》
这个经验分享系列马上就要到头了,下一步的我也许就该毕业了
- 也许在最后一年,我会用最后的时间完成额外的章节
- 额外的章节有过想法,就是写一个完整可用的数据库系统
- 这个工程量远超前方章节,如果有想法我会及时在本书中更新动态
- 希望大家也能够将自己知道的,学到的知识贡献出来
如果觉得我说的还行,可以给我来一点鼓励呀
下一节
- 讲述如何生成响应报文,以及本章的收尾。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论