0x16-套接字编程-HTTP服务器(4)

新连接

  1. 一个新晋连接,有哪些信息是值得我们关注的?
  2. 该如何存储它们?

这里将会叙述的并不会很完整,因为不同目的的网络程序,需要关注的信息也大不相同

特别是这个程序关注的是如何使用C语言编写一个服务器

  1. 我们最关心的,还是对端通过这个新连接所发来的信息
    • 简单来说就是我们read到的信息。进行过系统编程的都应该会知道这个函数,与之对应的是write。与 C标准库 为我们提供的标准格式化输入输出不同的地方在于其操作的对象read/write操作的是一个在叫做 文件描述符(file description)int类型的东西,而标准库的函数(printf/scanf)操作的则是一个FILE*特殊的结构体指针,这两者之间可以互相转换,通过fdopen(fd-->FILE*)/fileno(FILE*-->fd)具体相关知识,查阅相关信息,如著名的APUE
  2. 其次我们对这个信息做相应处理,中间会有很多状态,也就是常常听到的HTTP状态机
    • 实际上也就是几个状态值在转换和过渡,只是名字专业了一些
  3. 最后我们会生成一个信息,用来回复对端
    1. 这个也叫做响应报文

*nix下的文件描述符(file description)Windows下近似相当于 文件句柄(file handler),只不过前者是有规律的递增,而后者则不是。

  1. 如何存储?

    1. typedef unsigned char boolean;
    2. struct connection {
    3. int file_dsp;
    4. #define CONN_BUF_SIZE 512
    5. int r_buf_offset;
    6. int w_buf_offset;
    7. string_t r_buf;
    8. string_t w_buf;
    9. struct {
    10. /* Is it Keep-alive in Application Layer */
    11. boolean conn_linger : 1;
    12. boolean set_ep_out : 1;
    13. boolean is_read_done : 1; /* Read from Peer Done? */
    14. boolean request_http_v : 2; /* HTTP/1.1 1.0 0.9 2.0 */
    15. boolean request_method : 2; /* GET HEAD POST */
    16. int content_type : 4; /* 2 ^ 4 -> 16 Types */
    17. int content_length; /* For POST */
    18. string_t requ_res_path; /* / */
    19. }conn_res;
    20. };
    21. typedef struct connection conn_client;

    其中有一个陌生的事物,string_t,这个是用来进行字符串操作的一个自己写的结构,用于简化操作,可以把它看成一个可以自动增长的字符串类型。

    再者就是,内嵌结构体中使用到了 位域 这个方式,主要是因为C中没有原生的bool类型,使用int来表示又太过奢侈

    这个位域的写法在某些人看来似乎不太感冒,实际上还有替代的方法可以用,也就是使用掩码的思想,在一个int型中的不同位包含不同的信息,实际上和我这个的原理是相同的,只不过我将它拆开了,这样就可以不写各种处理宏

    1. /* 另一种写法 */
    2. ...
    3. struct {
    4. int status_set;
    5. int content_length;
    6. string_t request_length;
    7. }conn_res
    8. ...
    9. enum {
    10. SET_CONN_LINGGER = 1,
    11. SET_EPOLLOUT = 1 << 1,
    12. ...
    13. }
    14. /* 几乎对于每一个位置的操作都有三个,设置,复位,检测 */
    15. #define SET_CONN_LINGER(MASK_SET) (MASK_SET &= SET_CONN_LINGER)
    16. #define CLR_CONN_LINGER(MASK_SET) (MASK_SET &= (~SET_CONN_LINGER)&0xFFFF)
    17. #define IS_CONN_LINGER(MASK_SET) (MASK_SET & SET_CONN_LINGER)

    依此类推。

实际上,对于这个string_t 的设计是一个想当然的失败,当时是想尝试使用面向对象的想法,但是没有考虑到其使用时候的冗余,后面会看到这个小麻烦,但是总体上还是可以得。

这次总结出来的就是,在C里面使用面向对象的思维实在有点勉强,具体等后方说到这个string_t时会再提到。

2016-08-28 将其修改为正常的C风格。

  1. 所以实际上来看一看,我存储了哪些状态信息

    • 一个新连接的 file description file_dsp: 这个肯定是必要的,不然你怎么对这个新连接进行操作。
    • 一个读缓冲配着一个读位移(r_bufr_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 : 对端想请求的资源
  2. 所以这也从另一个方面回答了上面的第二个问题 该如何存储它们?

  3. 了解过,要存储那些信息,该如何存储这些信息之后,就能继续服务器的编写

事件循环

  • 前面我们的进度,已经到了handle_loop里面,并且将总体流程已经过了一遍
  • handle_loop 就是一个事件循环,我们整个程序的编程模型就是一个 事件驱动 的编程体系,什么是事件驱动,可以查阅相关资料,如 UNP 等书籍。在这个事件循环中,我们使用两个事件驱动我们的流程 : 读事件写事件
  • 即,一旦某个连接可读(回忆一下TCP连接可读可写)我就处理读事件,写事件也是如此。
  • 在这个循环中,我们启动了两种线程,一种专门用于接受建立新连接,一种专门用来处理新连接的读写事件,分别是listen_threadworkers_thread,常理来说前者一个就够了,后者可以酌情处理。
  • 先说说比较简单的listen_thread

listen_thread

  • 回到handle_loop的代码中可以看到有一个独立的代码块{},这个代码块的作用就是将我们之前创建的服务器套接字,添加到一个epoll实例中,准备传给listen_thread。在该epoll实例中,我们监听了它的读事件,以及错误事件 EPOLLERR

    1. { /* Register listen fd to the listen_epfd */
    2. struct epoll_event event;
    3. event.data.fd = file_dsption;
    4. event.events = EPOLLET | EPOLLERR | EPOLLIN;
    5. epoll_ctl(listen_epfd, EPOLL_CTL_ADD, file_dsption, &event);
    6. }
  • 紧接着,我们需要创建线程,用来完成接受创建新连接, 分配新连接, 处理新连接

  • 先说前两个

  • listen_thread

    1. /* Listener's Thread
    2. * @param arg will be a epoll instance
    3. * */
    4. static void * listen_thread(void * arg) {
    5. int listen_epfd = (int)arg;
    6. struct epoll_event new_client = {0};
    7. /* Adding new Client Sock to the Workers' thread */
    8. int balance_index = 0;
    9. while (terminal_server != CLOSE_SERVE) {

    这是一个永不停止的循环,除非在外部传入了一个信号CTRL+C,其实没什么意义,不过还是写了

    1. //这是监听的阻塞地点,在此处会返回有多少个事件发生了,当然这里只有一个
    2. int is_work = epoll_wait(listen_epfd, &new_client, 1, 2000);
    3. int sock = 0;
    4. // 如果不是因为超时才到了这里
    5. while (is_work > 0) { /* New Connect */
    6. //接受并创建新连接
    7. sock = accept(new_client.data.fd, NULL, NULL);
    8. if (sock > 0) {
    9. // 如果没有意外的话
    10. set_nonblock(sock);
    11. clear_clients(&clients[sock]);
    12. clients[sock].file_dsp = sock;
    13. // 分配新连接给各个workers_thread
    14. add_event(epfd_group[balance_index], sock, EPOLLIN);
    15. balance_index = (balance_index+1) % workers;
    16. } else /* sock == -1 means nothing to accept */
    17. break;
    18. } /* new Connect */
    19. }/* main while */
    20. close(listen_epfd);
    21. pthread_exit(0);
    22. }

其实在上面的 acceptset_nonblock 可以用一个系统调用来解决,accept4,而不需要使用两个不同的系统调用来完成这个功能,具体可以查询文档。

  • 可以看出,这个listen_thread 的职责非常简单,就只是单纯的接受创建新连接,设置一些属性,并且分配给workers_thread,所以真正复杂的工作还是在后者身上

workers_thread

  • 这是整个程序的核心部分,但还是按照庖丁解牛的方法,一步步分解
  • 整个的代码有点冗长,但是逻辑十分清晰,大体可以分成读写两部分

    1. static void * workers_thread(void * arg) {
    2. int deal_epfd = (int)arg;
    3. struct epoll_event new_apply = {0};
    4. while(terminal_server != CLOSE_SERVE) {
    5. int is_apply = epoll_wait(deal_epfd, &new_apply, 1, 2000);
    6. if(is_apply > 0) { /* New Apply */
    7. int sock = new_apply.data.fd;
    8. conn_client * new_client = &clients[sock];

    到此处为止,前面的逻辑和listen_thread 十分相似,需要额外说的就是 epoll_wait 接口中的第二,三个参数 , 代表着有事件改变状态的新连接(new_apply[i]),和有多少个这样的新连接(i)。代码中写的是(,&new_apply,1,)代表着我每次只想得到一个,说明及替代方案在后面会提到,跳过也无所谓。

    1. /* 读事件 */
    2. if (new_apply.events & EPOLLIN) { /* Reading Work */
    3. /* handle_read 是接收并解析HTTP请求报文的地方 */
    4. int err_code = handle_read(new_client);
    5. /* 此处省略一个很重要的分片错误处理 */
    6. else if (err_code != HANDLE_READ_SUCCESS) {
    7. /* Read Bad Things */
    8. close(sock);
    9. continue;
    10. }
    11. } // Read Event

    以上便是简化的读事件的处理,抛开来看,一切的核心就是handle_read这个函数,后放会详细讲解。

    1. /* 写事件 */
    2. else if (new_apply.events & EPOLLOUT) { /* Writing Work */
    3. int err_code = handle_write(new_client);
    4. /* TCP's Write buffer is Busy */
    5. if (HANDLE_WRITE_AGAIN == err_code)
    6. mod_event(deal_epfd, sock, EPOLLONESHOT | EPOLLOUT);
    7. else if (HANDLE_WRITE_FAILURE == err_code) { /* Peer Close */
    8. close(sock);
    9. continue;
    10. }
    11. /* if Keep-alive */
    12. if(1 == new_client->conn_res.conn_linger)
    13. mod_event(deal_epfd, sock, EPOLLIN);
    14. else{
    15. close(sock);
    16. continue;
    17. }
    18. } /* EPOLLOUT */

    所谓clear_clients其实就是清除一些现有状态,不然下次有别的连接占用的时候就会错乱了。

    1. else { /* EPOLLRDHUG EPOLLERR EPOLLHUG */
    2. close(sock);
    3. }
    4. } /* New Apply */
    5. } /* main while */
    6. return (void*)0;
    7. }
  • 看起来有点长,实际上模块十分清楚。从上往下看,由三个if - else 分支组成,分别处理 读事件,写事件,错误事件
  • 这其中省略了一些十分重要的错误处理,以及某些优化,希望可以自己补全,但这都无所谓,因为已经将这种编程模型全盘托出,接下来就是细节方面的处理了。

handle_read

  • 这应该是这个 HTTP服务器 真正的重点所在,用一个词来形容就是 核心技术,当然没那么高端,就是个程序而已。
  • 前面提到一个名词,叫做 HTTP状态机,指的就是状态的转换,在C语言中,可以使用enum来实现

    1. typedef enum {
    2. HANDLE_READ_SUCCESS = -(1 << 1),
    3. HANDLE_READ_FAILURE = -(1 << 2),
    4. ...
    5. }HANDLE_STATUS;

    代表了,handle_read 是成功还是失败,有一个额外的 MESSAGE_IMCOMPLETE 状态也输一这个范畴内,但是设计的时候出现了差错,可以选择将其放在里面。

    MESSAGE_IMCOMPLETE 是为了应对TCP分片问题,所以在显示网络中很常见,但是本地测试的时候可能不容易发现,可以使用工具 tc 来模拟弱环境。

  • HANDLE_STATUS handle_read(conn_client * client)

    1. HANDLE_STATUS handle_read(conn_client * client) {
    2. int err_code = 0;
    3. /* Reading From Socket */
    4. err_code = read_n(client);
    5. if (err_code != READ_SUCCESS) { /* If read Fail then End this connect */
    6. return HANDLE_READ_FAILURE;
    7. }

    到这里为止是读取所有可以读到的数据

    1. /* Parsing the Reading Data */
    2. err_code = parse_reading(client);
    3. if (err_code == MESSAGE_INCOMPLETE)
    4. return MESSAGE_INCOMPLETE;
    5. if (err_code != PARSE_SUCCESS) { /* If Parse Fail then End this connect */
    6. return HANDLE_READ_FAILURE;
    7. }

    到这里为止是处理所有已经读到的数据

    1. return HANDLE_READ_SUCCESS;
    2. }

    到了这里,就证明读和处理都已经正确完成了。

巧用gdb能让你轻松理解整个状态机的逻辑

  • 从函数接口上看,它接受一个conn_client类型的指针,回想一下,这就是我们存储每个新连接的各种信息的地方,返回值就是这个动作的状态了。
  • 从功能上看,这个函数主要的工作就是将handle_read拆分成两大部分:

    1. 读取数据 (read_n)
      1. 首先读取所有能读取的数据(从socket中)
      2. 验证数据是否完整
        1. 对于GET 而言就是是否读取到了一个空行\r\n
        2. 对于POST 来说就是是否一句Content-length属性的值将 body 读取完整了
    2. 处理数据 (parse_reading)
      1. 处理HTTP请求报文第一行状态行
      2. 处理剩余的头属性,如Connection
      3. 生成响应报文,你可以考虑将这一步划分出去,因为这一步涉及到了磁盘I/O
  • 先说第一部分,读取数据(read_n)

  • static int read_n(conn_client * client)

  • 实现一个read函数的加强版

    1. __thread char read_buf2[CONN_BUF_SIZE] = {0};
    2. static int read_n(conn_client * client) {
    3. int read_offset2 = 0;
    4. int fd = client->file_dsp;
    5. char * buf = &read_buf2[0];
    6. int buf_index = read_offset2;
    7. int read_number = 0;
    8. int less_capacity = 0;

    从前往后依次是读缓冲区位移处理的连接套接字, buf纯粹多此一举还可能阻碍编译器优化,但我还是写了,强迫症吧, buf_index同理,read_number是本次读的字符个数,less_capacity是缓冲区的容量余量

    1. while (1) {
    2. /* 因为是非阻塞,所以要不停地读,直到`read`返回-1,且errno为EAGAIN */
    3. less_capacity = CONN_BUF_SIZE - buf_index;
    4. if (less_capacity <= 1) {/* Overflow Protection */
    5. /* 万一这本地的缓冲区容量不够了,就刷新进 conn_client 中 */
    6. buf[buf_index] = '\0'; /* Flush the buf to the r_buf String */
    7. /* 对于 STRING 宏,可以看看我的源码中的 wsx_string.h */
    8. cappend_string(client->r_buf, STRING(buf));
    9. client->r_buf_offset += read_offset2;//- client->read_offset;
    10. read_offset2 = 0;
    11. buf_index = 0;
    12. less_capacity = CONN_BUF_SIZE - buf_index;
    13. /* 清空缓冲区成功 */
    14. }

    上面的代码中,有一个APPEND宏,是用来简化代码的,功能是
    #define APPEND(str) str,(strlen(str)+1)

    1. read_number = (int)read(fd, buf+buf_index, less_capacity);
    2. /* 0代表对端关闭了连接或者说是已经读完了 EOF(对端调用close()/shutdown()) */
    3. if (0 == read_number) { /* We must close connection */
    4. return READ_FAIL;
    5. }
    6. /* -1 代表现在没东西可以读了 */
    7. else if (-1 == read_number) { /* Nothing to read */
    8. if (EAGAIN == errno || EWOULDBLOCK == errno) {
    9. /* 这个时候,我们该做的就是将缓冲区的东西,存储起来 */
    10. buf[buf_index] = '\0';
    11. append_string(client->r_buf, STRING(buf));
    12. client->r_buf_offset += read_offset2;//client->read_offset;
    13. return READ_SUCCESS;
    14. }
    15. return READ_FAIL;
    16. }
    17. else { /* Continue to Read */
    18. /* 能读取到信息,就继续读 */
    19. buf_index += read_number;
    20. read_offset2 = buf_index;
    21. }
    22. } /* while(1) */
    23. }

__thread 关键字是多线程编程里一个挺有用的一个关键字,具体可以查询资料,简单来说,就是让每个线程拥有一个自己的全局变量。

  • 经过read_n之后,我们就(可能)获取到了完整的数据了,接下来就是解析它们,引入一个状态
  • PARSE_STATUS

    1. typedef enum {
    2. /* Parse the Reading Success, set the event to Write Event */
    3. PARSE_SUCCESS = 1 << 1,
    4. /* Parse the Reading Fail, for the Wrong Syntax */
    5. PARSE_BAD_SYNTAX = 1 << 2,
    6. /* Parse the Reading Success, but Not Implement OR No Such Resources*/
    7. PARSE_BAD_REQUT = 1 << 3,
    8. }PARSE_STATUS;

    解释的很清楚了,不再赘述。

  • PARSE_STATUS parse_reading(conn_client * client)

    1. PARSE_STATUS parse_reading(conn_client * client) {
    2. int err_code = 0;
    3. requ_line line_status = {0};
    4. client->r_buf_offset = 0; /* Set the real Storage offset to 0, the end of buf is '\0' */

    requ_line是一个结构体,用来存储状态行所含有的三个信息: 请求方法, 请求资源, HTTP版本号

    1. /* Get Request line */
    2. err_code = deal_requ(client, &line_status);
    3. /* 回想一下这个状态,TCP分片的情况 */
    4. if (MESSAGE_INCOMPLETE == err_code) /* Incompletely reading */
    5. return MESSAGE_INCOMPLETE;
    6. if (DEAL_LINE_REQU_FAIL == err_code) /* Bad Request */
    7. return PARSE_BAD_REQUT;

    到这里为止是处理状态行的代码

    1. /* Get Request Head Attribute until /r/n */
    2. err_code = deal_head(client); /* The second line to the Empty line */
    3. if (DEAL_HEAD_FAIL == err_code)
    4. return PARSE_BAD_SYNTAX;

    到这里为止是处理完了所有的头属性

    1. /* Response Page maker */
    2. err_code = make_response_page(client);
    3. if (MAKE_PAGE_FAIL == err_code)
    4. return PARSE_BAD_REQUT;
    5. return PARSE_SUCCESS;
    6. }
  • 对于deal_requdeal_head来说,只是一个很简单的从大字符串中识别出小字符串,并存储起来的问题,不想过多的叙述。在这个处理过程中,自己实现了一个get_line按行读取的函数,同样会被后面的deal_head使用

    • 这其中有一些问题需要注意一下,那就是你需要考虑TCP分片问题,这是我第三次提到这个东西,也就是用状态机监测好这个问题是否发生,并及时处理。
    • deal_head中,可以按行进行循环读取(get_line),知道你发现空行,那么你就处理完成了,如果是POST方法,你还需要继续读取,直到读取完它的body。现在想想,conn_client这个结构体中的那些属性是干什么的,就是从这里解析出来的。
  • 读取解析完成之后,就能进行响应报文的生成了。在下一节中详述

题外话

  • 和上一个部分不同,再上一个部分我尽可能的不落下一丝一毫的细节,将自己如何写程序的想法分享给诸位
  • 但这章节,无论怎么看,从思维,从代码都不再像之前那般面面俱到,我认为也没有必要,这一章大家应该就具备了自我独立思考的能力,实际上在给出了结构图之后,后面的章节就不怎么必要了
  • 但我想把自己的想法写出来,想想求学的这几年无人引导,苦苦寻找资料的那些日子,我觉得我有必要把自己从网络上得来的知识,再次回馈给网络,这才是生生不息,自我进步的道理。

最后

  • 额外的补充

  • 这个经验分享系列马上就要到头了,下一步的我也许就该毕业了

    • 也许在最后一年,我会用最后的时间完成额外的章节
    • 额外的章节有过想法,就是写一个完整可用的数据库系统
    • 这个工程量远超前方章节,如果有想法我会及时在本书中更新动态
    • 希望大家也能够将自己知道的,学到的知识贡献出来
  • 如果觉得我说的还行,可以给我来一点鼓励呀

    • 1

      下一节

  • 讲述如何生成响应报文,以及本章的收尾。