返回介绍

并发 HTTP 服务器 (4)

发布于 2024-10-04 12:28:35 字数 43465 浏览 0 评论 0 收藏 0

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. 如何存储?

     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风格。

  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

      { /* 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);
      }
    

其实在上面的 acceptset_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拆分成两大部分:

    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函数的加强版

      __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_requdeal_head来说,只是一个很简单的从大字符串中识别出小字符串,并存储起来的问题,不想过多的叙述。在这个处理过程中,自己实现了一个get_line按行读取的函数,同样会被后面的deal_head使用

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

题外话

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

最后

  • 额外的补充

    • 我的博客提到了一些错误处理的详细解释: 一个HTTP服务器的C之路(上)
    • 陈硕的书 : 《Linux多线程服务端编程》
  • 这个经验分享系列马上就要到头了,下一步的我也许就该毕业了

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

    • 1

      下一节

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

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文