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这个结构体中的那些属性是干什么的,就是从这里解析出来的。
  • 读取解析完成之后,就能进行响应报文的生成了。在下一节中详述

题外话

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

最后

  • 额外的补充

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

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

    • 1

下一节

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

书籍推荐