前面已经描述过nginx的事件模块了,接下来具体分析nginx如何接收一个HTTP请求,下一部分接着解析nginx解析HTTP请求的流程。

协议状态机编程模式

TCP协议是一种流协议(stream protocol),这意味着数据是以字节流形式给数据接收者的,一次网络接收不一定能接收完毕,需要上面的应用层根据自己协议的情况来解析处理。它的数据没有边界,需要应用层自己根据协议来判断边界的存在。

如果两次请求,分开为几次接收,但是某次接收的数据中,有跨两次请求的数据,这就是所谓的“粘包(sticky-package)”问题。如下图所示:

sticky-package-problem

结合epoll之类的事件派发器来设计一个TCP协议的服务器时,因为并不能确保每一次接收数据,都能完整的接收到协议所需的所有数据。因此一般而言,写一个高性能服务器的协议解析部分,会以状态机的方式来实现,即定义了协议数据的每个部分,如下伪代码所示:

// 定义协议头数据
typedef struct header_t {
  // 协议版本号
  int version;
  // 定义body部分大小
  int size;
} header_t;

// 定义协议数据
typedef struct protocol_t {
  header_t header;
  char body[0];
} protocol;

// 定义接收数据的状态机类型
enum state_t {
  RECV_HEADER,        // 接收包头
  RECV_BODY,          // 接收包体
  PROCESS_PROTOCOL,   // 处理协议
  SEND_RESPONSE       // 发送回复
};

// 处理请求的状态机
void statemachine() {
  switch (state) {
  case RECV_HEADER:
    // 接收协议包头数据
    // 接收完毕之后,切换state到RECV_BODY
  case RECV_BODY:
    // 接收协议包体数据
    // 接收完毕之后,切换state到PROCESS_PROTOCOL
  case PROCESS_PROTOCOL:
    // 处理协议
    // 处理完毕之后,切换state到SEND_RESPONSE
  case SEND_RESPONSE:
    // 发送应答
  }
}

如上面的伪代码所示,接收一个请求之后,会初始化一个变量state用于保存当前协议处理的状态类型,假如第一次接收数据时还不能接收完毕协议的数据,就将接收fd重新放入到事件派发器中,下一次被唤醒之后再根据当前的状态继续接收数据进行处理。

协议的包头部分,一般有两个特点:

  • 包头大小固定,这个原因还是因为不能确定每次都能接收完整的数据,而总是需要一个长度或者确定的结束符(如HTTP协议最后的两个\r\n)来告诉你是否接收完了数据。如果后面还需要对包头数据有扩展,会根据不同的版本进行区别,所以一般而言包头部分还会有个版本号,以便以后包头数据发生了变化,可以根据包头来进行区分。
  • 包头内有字段定义包体部分的大小,这样在状态切换到接收包体时才有依据何时接收完毕了数据。

这是一般的思路,实际中还有一些不一样的地方:

  • 有的服务不是以暴露给使用者事件派发器接口的方式来实现的,内部采用了协程之类的机制,这样应用开发者写起代码了就好像独占了一个“线程”,比如使用golang来写代码,每个连接对应goroutine的情况下,这时候就没有必要连接内部保存一个状态变量了。
  • 上面的协议定义中,用长度来定义每部分的边界:即包头固定长度,包头内部的长度成员来表示包体的大小。有一些协议就不是这么实现协议的,比如HTTP协议采用连续两个\r\n来表示包头部分结束或者包体部分结束(在包体部分长度不确定时)。

有了协议状态机之后,处理前面的粘包问题就很简单了,无非就是当事件被回调的时候进入状态机,看当前在哪个协议状态来进行处理。

Nginx接收HTTP请求流程

以上解释了以状态机来驱动的协议接收流程,Nginx也是类似状态机的机制,只不过内部并没有一个state这样的变量保存当前到哪个状态了,而是切换不同状态对应的handler函数来做解析。

nginx既可以做7层代理,也可以做4层代理,因此在实现的时候,需要考虑兼容不同的应用层协议,具体来说设置了一个监听端口的时候,该端口可能处理的是HTTP请求,也可能是配置在stream块中处理的是4层的TCP请求。

而nginx采用了统一的ngx_connection_t结构体来表示一个tcp连接,这里就要根据不同的协议做区分了,来看看这个结构体中相关的字段:

字段 说明
void *data 连接相关数据
ngx_event_t *read 读事件
ngx_event_t *write 写事件
ngx_recv_pt recv 接收请求的函数指针,每个平台可能有区别
ngx_send_pt send 发送应答的函数指针

即:不同的tcp协议,对应的连接相关的data是不一样的,这样读写事件对应的回调函数也就不一样。

类似的,nginx中ngx_listening_t来表示监听socket,其中的成员handler也是区分了不同的协议有不同的注册回调函数。

如果这个监听端口,处理的是HTTP请求,那么注册进去的回调函数就是ngx_http_init_connection,这样在接收到一个HTTP请求时就会回调该函数进行处理。

完整的流程图如下所示:

nginx-read-http-statemachine

接下来按照上面的流程图具体看接收HTTP请求的流程中对应的几个回调函数。

ngx_http_init_connection

这个函数是接收到HTTP请求之后的第一个回调函数,用于初始化请求连接相关的一些数据,主要做的工作是:

  • 初始化读事件的回调函数为ngx_http_wait_request_handler,而写事件的回调函数是一个什么也不做的空函数ngx_http_empty_handler。这是因为,该接收完连接还没有应答数据,所以即使可写也什么都不做。
  • 将读事件添加到一个定时器中,这样如果一段时间内都接收不完请求则关闭连接,不至于被占用资源。

ngx_http_wait_request_handler

ngx_http_wait_request_handler函数是接收到HTTP请求之后第一次被读事件回调的函数,主要工作:

  • 判断连接是否超时,如果超时则关闭连接。
  • 分配读缓存空间。
  • 调用recv函数指针读客户端请求。此后需要对这个函数调用的结果做判断:
    • NGX_AGAIN:说明还没有读完请求,将会再次将读事件加入定时器,读事件加入到epoll监控事件中,等待下一次被读事件唤醒,或者超时。
    • NGX_ERROR:请求出错了,关闭连接。
    • 没有读到任何数据,说明对端关闭连接。
    • 其他情况说明已经读出一部分数据,此时将该读事件的handler切换为ngx_http_process_request_line,开始接收请求行。

ngx_http_process_request_line

ngx_http_process_request_line负责接收处理请求行,这个函数可能被多次调用,就跟前面分析状态机接收请求的情况一样,只要状态没有变化,下一次被读事件唤醒还是会走到响应的状态处理函数中。

  • 判断连接是否超时,如果超时则关闭连接。
  • 初始化rc为 NGX_AGAIN,接下来进入一个循环处理的流程,分别经历了以下流程:
    • 如果rc == NGX_AGAIN:调用ngx_http_read_request_header函数读请求头,返回NGX_AGAIN或者NGX_ERROR则退出循环返回。
    • 调用ngx_http_parse_request_line函数分析请求行,下面区分该函数的返回值不同情况处理:
    • rc == NGX_OK,说明调用成功:
      • 初始化ngx_http_request_t相关的数据,如request_line、method_name等。
      • 调用ngx_http_process_request_uri处理请求URI。
      • 分析请求HOST。
      • 初始化headers_in链表,准备解析header。
      • 将读事件的hander切换为ngx_http_process_request_headers,准备处理HTTP header。
    • rc != NGX_AGAIN:说明出错了,调用ngx_http_finalize_request终止请求。
    • 其他请求就是rc == NGX_AGAIN的情况了,说明此时还没有接受完毕HTTP请求。如果r->header_in->pos == r->header_in->end,说明接受header的缓冲区已经满了,需要分配一块大内存来存储header。

ngx_http_process_request_headers

ngx_http_parse_request_line分析请求行成功之后,就将读事件的handler切换为ngx_http_process_request_headers,该函数处理请求头:

  • 判断连接是否超时,如果超时则关闭连接。
  • 初始化rc为 NGX_AGAIN,接下来进入一个循环处理的流程,分别经历了以下流程:
    • rc == NGX_AGAIN:
    • 如果r->header_in->pos == r->header_in->end:说明header_in缓冲区已经被用尽,需要分配大空间来接收header。
    • 调用ngx_http_read_request_header函数读请求头,如果返回值为NGX_AGAIN或者NGX_ERROR,则退出循环返回。
    • 调用ngx_http_parse_header_line函数解析header_in缓冲区中的字节流,分析header,根据返回值区分以下情况:
    • rc == NGX_OK:说明成功解析一个HTTP header,向headers_in数组分配一个新的元素用于存储该header。
    • rc == NGX_HTTP_PARSE_HEADER_DONE:说明全部header已经解析完成,将处理HTTP请求的遍历http_state切换为NGX_HTTP_PROCESS_REQUEST_STATE,调用ngx_http_process_request_header处理请求头,如果返回结果不为NGX_OK则退出循环返回,否则进入ngx_http_process_request处理HTTP请求。
    • rc == NGX_AGAIN:说明还有未接收完毕的数据,退出循环等待下一次被读事件唤醒再次读取数据进行处理。
    • 除了上面以外的其他情况,说明出错了,调用ngx_http_finalize_request终止请求。

以上,基本把接收一个HTTP请求中间过程中涉及到的主要函数分析了一遍。可以看到,nginx对接收HTTP请求的处理,也是状态机驱动的,区别于最开始说明的状态机编程模式,nginx没有在每个连接相关的结构体中用一个状态变量来表示当前的状态,而是通过切换读事件的handler来完成状态处理的切换。

以上的流程和涉及的函数,看起来很多,其实就是分了两部分可能会循环被调用的地方:

  • 处理请求URI,对应函数ngx_http_process_request_line。
  • 处理请求header,对应函数ngx_http_process_request_headers。

从这里的分析可以看到,高性能TCP协议服务器的一个重点就是读请求的流程,这部分不能阻塞,要点就是通过状态机驱动的模式来读取协议,nginx这里划分的很细致,不会因为一次网络IO阻塞住这个流程。

下一部分解析nginx处理HTTP请求的流程。