0x15-套接字编程-HTTP服务器(3)
- 在一切开始之前,我们需要设想一下,为了让自己的HTTP服务器变得更加灵活,我们可以让某些参数不必硬编码进程序中,而是用配置文件的方式读取
一个HTTP服务器的基本配置无非是
- IP地址,端口号, 根目录路径
- 额外增加一个 线程数
- 实际上,
应该不需要我们人为指定,但为了调试方便,所以选择放在配置文件中
接下来我们写一个可以解析配置文件的小模块函数
struct init_config_from_file {
int core_num; /* CPU Core numbers */
#define PORT_SIZE 10
char listen_port[PORT_SIZE]; /* */
#define ADDR_SIZE IPV6_LENGTH_CHAR
char use_addr[ADDR_SIZE]; /* NULL For Auto select(By Operating System) */
#define PATH_LENGTH 256
char root_path[PATH_LENGTH]; /* page root path */
};
typedef struct init_config_from_file wsx_config_t;
这个是配置文件的所有属性,可以将读取的参数,存进这个结构体中,与主线程交互
/*
* Read the config file "wsx.conf" in particular path
* and Put the data to the config object
* @param config is aims to be a parameter set
* @return 0 means Success
* */
int init_config(wsx_config_t * config);
交互的接口,我的配置文件叫做 wsx.conf
对于配置文件存放位置而言,可以灵活一些,例如可以额外添加一个命令行参数,用来指定本次需要使用的配置文件路径:
./httpd -f /path/to/wsx.conf
当然这用在开发版本可以方便调试,实际上的HTTP服务器并不行,参见守护进程的定义最经典的做法还是指定默认路径,将配置文件都存放在某个地方,可以多设定几个,并设定优先级
- 想想,我们需要什么功能,我给自己的配置文件添加了注释功能,以
#
开头的都是注释,这点十分容易做到。 上代码
static const char * config_path_search[] = {CONFIG_FILE_PATH, "./wsx.conf", "/etc/wushxin/wsx.conf", NULL};
int init_config(wsx_config_t * config){
const char ** roll = config_path_search;
FILE * file;
for (int i = 0; roll[i] != NULL; ++i) {
file = fopen(roll[i], "r");
if (file != NULL)
break;
}
if (NULL == file) {
#if defined(WSX_DEBUG)
fprintf(stderr, "Check For the Config file, does it stay its life?\n"
"In Such Path: \n%s\n%s\n%s\n", config_path_search[0], config_path_search[1], config_path_search[2]);
#endif
exit(-1);
}
...未结束
这是很简单的文件操作,包括打开文件,验证是否成功,可以选择将其封装成一个
inline
函数,来模块化这个逻辑。char buf[PATH_LENGTH] = {"\0"};
char * ret;
ret = fgets(buf, PATH_LENGTH, file);
while (ret != NULL) {
char * pos = strchr(buf, ':');
char * check = strchr(buf, '#'); /* Start with # will be ignore */
if (check != NULL)
*check = '\0';
if (pos != NULL) {
*pos++ = '\0';
if (0 == strncasecmp(buf, "thread", 6)) {
sscanf(pos, "%d", &config->core_num);
}
else if (0 == strncasecmp(buf, "root", 4)) {
sscanf(pos, "%s", &config->root_path);
/* End up without "/", Add it */
if ((config->root_path)[strlen(config->root_path)-1] != '/') {
strncat(config->root_path, "/", 1);
}
}
else if (0 == strncasecmp(buf, "port", 4)) {
sscanf(pos, "%s", &config->listen_port);
}
else if (0 == strncasecmp(buf, "addr", 4)) {
sscanf(pos, "%s", &config->use_addr);
}
} /* if pos != NULL */
ret = fgets(buf, PATH_LENGTH, file);
} /* while */
fclose(file);
return 0;
}
真正的核心代码没几行,四个
if
,使用strncasecmp
函数,检测参数。但是并没有 验证参数的正确性。当然你也可以写成
json
的形式,再用第三方库,比如c-json
之类的解析,但 那不是要依赖第三方了吗?所以我的建议还是自己写一个解析的函数。如果没能理解这小段代码,建议翻一下C语言的入门教材,回顾一下语法。
配置文件的样式
# Just Edit this Config file Or
# You can Create a new one and save the Old to
# Back up
# But Remember that , that file can only parse
# the FOUR CONFIGURATION :
# thread root port address
# Watch out the case sensitive !!!
# thread -- For the Worker thread number
# root -- For the WebSite's root path
# port -- Listen Port
# address -- Host's address(Note it If you can)
# Or empty For the auto select by Operating System
thread:8
# Using shell Command (pwd) to show your root Path!
root:/root/ClionProjects/httpd3/
port:9998 # That is a port
address:192.168.141.149
配置文件读取完成了,我们是时候设计一下主函数的流程了,回想一下流程图,下一步就应该创建套接字,绑定,并监听(
listen
)了!(流程图中没有画出listen
,过于冗余,但却必不可少)可以将 创建,绑定合并成一个函数,在成功之后,再执行
listen
。/*
* Open The Listen Socket With the specific host(IP address) and port
* That must be compatible with the IPv6 And IPv4
* host_addr could be NULL
* port MUST NOT BE NULL !!!
* sock_type is the pointer to a memory ,which comes from the Outside(The Caller)
* */
int open_listenfd(const char * restrict host_addr,const char * restrict port, int * restrict sock_type);
可以看出来,需要一个IP, 一个PORT, 第三个参数是套接字类型担不是传入参数,而是传出参数。
int open_listenfd(const char * restrict host_addr, const char * restrict port, int * restrict sock_type){
int listenfd = 0; /* listen the Port, To accept the new Connection */
struct addrinfo info_of_host;
struct addrinfo * result;
struct addrinfo * p;
/* 实际上这一行完全可以在上面使用 初始化来达到目的。
* struct addrinfo info_of_host = {0}; 需要c99
*/
memset(&info_of_host, 0, sizeof(info_of_host));
info_of_host.ai_family = AF_UNSPEC; /* Unknown Socket Type */
info_of_host.ai_flags = AI_PASSIVE; /* Let the Program to help us fill the Message we need */
info_of_host.ai_socktype = SOCK_STREAM; /* TCP */
int error_code;
if(0 != (error_code = getaddrinfo(host_addr, port, &info_of_host, &result))){
fputs(gai_strerror(error_code), stderr);
return ERR_GETADDRINFO; /* -2 */
}
for(p = result; p != NULL; p = p->ai_next) {
listenfd = socket(p->ai_family, p->ai_socktype, p->ai_protocol);
if(-1 == listenfd)
continue; /* Try the Next Possibility */
optimizes(listenfd);
if(-1 == bind(listenfd, p->ai_addr, p->ai_addrlen)){
close(listenfd);
continue; /* Same Reason */
}
break; /* If we get here, it means that we have succeed to do all the Work */
}
freeaddrinfo(result);
if (NULL == p) {
fprintf(stderr, "In %s, Line: %d\nError Occur while Open/ Binding the listen fd\n",__FILE__, __LINE__);
return ERR_BINDIND;
}
fprintf(stderr, "DEBUG MESG: Now We(%d) are in : %s , listen the %s port Success\n", listenfd,
inet_ntoa(((struct sockaddr_in *)p->ai_addr)->sin_addr), port);
*sock_type = p->ai_family;
set_nonblock(listenfd);
return listenfd;
}
其中有一个optimizes,是用来设置一些套接字选项的,现在只需要知道有这些选项就行
套接字选项分别是
TCP_NODELAY
和SO_REUSEADDR
。细看之下,和前面介绍的几个接口几乎是完全一致的用法。但如果认为网络编程就是这样接口调用的话,那就是大错特错。
就这样,如果你的配置文件中,
没什么差错的话,我们就完成了打开服务器套接字的工作,这时候你可以组织并且运行一下前面说的这些代码,看看是否如此。
运行成功与否可以通过你的终端是否显示上述的调试信息看出来:
DEBUG MESG: Now We(x) are in : %s , listen the xx port Success
写到这里,实际上整个主函数的代码已经接近尾声,来看看全部的过程调用
int main(int argc, char * argv[]) {
wsx_config_t config = {0};
init_config(&config)
int sock_type = 0;
int listenfd = open_listenfd(config.use_addr, config.listen_port, &sock_type);
listen(listenfd, SOMAXCONN);
signal(SIGPIPE, SIG_IGN);
handle_loop(listenfd, sock_type, &config);
return 0;
}
这个逻辑已经十分清晰,为了方便我省去了错误检查,在代码中应该自己添加,这里面有两个新事物:
signal()
,handle_loop()
来解释一下
signal(SIGPIPE, SIG_IGN)
是什么以及为什么signal
是信号函数,还记得之前的章节用它来当做函数指针类型的一个练习思考题吗?它的作用就是在本进程/线程接收到该信号(SIGPIPE
)时候,会进行这样的(SIG_IGN
)处理- 当然它有更好更推荐的做法
sigation
,比较复杂但是也比较推荐你用它,这里为了减少概念,就用了最原始的signal
。 SIGPIPE
是一个关于写的错误,触发条件是向一个发送了RST
的对端进行写操作,默认行为就是结束本进程,我们当然不愿意结束了,明明是对方的错,怎么要我们死。最基本的做法就是忽略它SIG_IGN
。- 稍微解释一下
SIGPIPE
,模拟一下情形,这里需要对TCP的工作方式有一定了解,不了解的可以跳过:- TCP是全双工的,意味着可读可写,假设有A,B端,本来工作的好好的,突然B端崩溃退出了,那自然联系A,B端的套接字连接就断了,但是A端并不懂啊,它这时候只知道B端不会再发送消息给自己了(因为接到了B发给自己的FIN,自己回复了ACK,关闭了接收通道),并不懂自己还能不能发消息给B啊(所以A当做自己能发给B端)
- 然而实际上,现在哪里还能发消息给B啊,这就回到了上面,如果向一个发送了
RST
的对端进行写操作的话,就会触发SIGPIPE
,信号这个东西就是全局的,所以如果你想知道哪个线程触发了这个信号,还需要检查写操作是否返回了EPIPE
错误
- 看不懂也无所谓,来日方长,细水长流。这就是这一行代码的意义,就是为了忽略这个信号。
handle_loop
是一个事件循环的入口- 就是所有的事务处理准备都在里面,回想一下流程图,我们接下来该干什么
- 使用
epoll
监听服务器套接字,用来建立新连接 - 分配新连接给子线程,在其中处理各种事件。
- 呐,实际上
handle_loop
就干了两件事- 准备一下服务器资源(包括存储新连接的各种信息)
- 创建子线程用来 监听服务器套接字 或 处理新连接事件
几个全局变量
static int * epfd_group = NULL; /* Workers' epfd set */
static int epfd_group_size = 0; /* Workers' epfd set size */
static int workers = 0; /* Number of Workers */
static int listeners = MAX_LISTEN_EPFD_SIZE; /* Number of Listenner */
static conn_client * clients; /* Client set */
handle_loop()
void handle_loop(int file_dsption, int sock_type, const wsx_config_t * config) {
workers = config->core_num - listeners;
int listen_epfd = epoll_create1(0);
{ /* Register listen fd to the listen_epfd */
struct epoll_event event;
event.data.fd = file_dsption;
event.events = EPOLLET | EPOLLERR | EPOLLIN;
/* 以ET方式监听file_dsption的读事件,错误事件 */
epoll_ctl(listen_epfd, EPOLL_CTL_ADD, file_dsption, &event);
}
/* Prepare Workers Sources */
prepare_workers(config);
pthread_t listener_set[listeners];
pthread_t worker_set[workers];
for (int i = 0; i < listeners; ++i)
pthread_create(&listener_set[i], NULL, listen_thread, (void*)listen_epfd);
for (int j = 0; j < workers; ++j) {
pthread_create(&worker_set[j], NULL, workers_thread, (void*)(epfd_group[j]));
pthread_detach(worker_set[j]);
}
for (int k = 0; k < listeners; ++k)
pthread_join(listener_set[k], NULL);
destroy_resouce();
}
使用了最原始的线性数组来存储所有的连接信息(
conn_client
),这其实弊端很大,比如最明显的数量以及预分配的资源过大。但关键是够简单,且效率最高。整个的原理就是,在接受到新连接以后,按照某种规则分配给第i个子线程,每个子线程中有一个工作
epoll
(epoll_group[i-1]),用来监听新连接的事件,并处理。prepare_workers
就是分配内存空间的相关工作。这段代码,同样省略了错误检查,希望自己添加。{}
里面可以看出来怎么向epoll
实例中注册监听实体,以及监听事件。整段代码的后半部分,是关于线程的启动,操作,销毁。
pthread_detach
意味着放弃线程的资源回收权,用通俗的话来说就是:“撒丫子跑吧,我管不着你了!”。这就是完整的一个主函数逻辑,实际上非常简单,到现在为止也没出现过十分复杂的东西,就像在做繁琐的准备工作一样。
下一节将会详细讲解
- 连接信息都有哪些需要存储的
- 如何处理读事件,字符数据的管理呢?