这里将会叙述的并不会很完整,因为不同目的的网络程序,需要关注的信息也大不相同
特别是这个程序关注的是如何使用C语言编写一个服务器
read
到的信息。进行过系统编程的都应该会知道这个函数,与之对应的是write
。与 C标准库 为我们提供的标准格式化输入输出不同的地方在于其操作的对象。read/write
操作的是一个在叫做 文件描述符(file description) 的int
类型的东西,而标准库的函数(printf/scanf
)操作的则是一个FILE*
特殊的结构体指针,这两者之间可以互相转换,通过fdopen(fd-->FILE*)/fileno(FILE*-->fd)
具体相关知识,查阅相关信息,如著名的APUE
。
*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_dsp
: 这个肯定是必要的,不然你怎么对这个新连接进行操作。r_buf
和r_buf_offset
) :
w_buf_offset
)
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
: 对端想请求的资源所以这也从另一个方面回答了上面的第二个问题 该如何存储它们?
了解过,要存储那些信息,该如何存储这些信息之后,就能继续服务器的编写
handle_loop
里面,并且将总体流程已经过了一遍handle_loop
就是一个事件循环,我们整个程序的编程模型就是一个 事件驱动 的编程体系,什么是事件驱动,可以查阅相关资料,如 UNP 等书籍。在这个事件循环中,我们使用两个事件驱动我们的流程 : 读事件, 写事件listen_thread
和 workers_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
,所以真正复杂的工作还是在后者身上这是整个程序的核心部分,但还是按照庖丁解牛的方法,一步步分解
整个的代码有点冗长,但是逻辑十分清晰,大体可以分成读写两部分
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
分支组成,分别处理 读事件,写事件,错误事件
这其中省略了一些十分重要的错误处理,以及某些优化,希望可以自己补全,但这都无所谓,因为已经将这种编程模型全盘托出,接下来就是细节方面的处理了。
这应该是这个 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
)
Connection
先说第一部分,读取数据(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
使用
deal_head
中,可以按行进行循环读取(get_line
),知道你发现空行,那么你就处理完成了,如果是POST
方法,你还需要继续读取,直到读取完它的body。现在想想,conn_client
这个结构体中的那些属性是干什么的,就是从这里解析出来的。读取解析完成之后,就能进行响应报文的生成了。在下一节中详述
额外的补充
这个经验分享系列马上就要到头了,下一步的我也许就该毕业了
如果觉得我说的还行,可以给我来一点鼓励呀