并发 HTTP 服务器 (3)
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
意味着放弃线程的资源回收权,用通俗的话来说就是:“撒丫子跑吧,我管不着你了!”。这就是完整的一个主函数逻辑,实际上非常简单,到现在为止也没出现过十分复杂的东西,就像在做繁琐的准备工作一样。
下一节将会详细讲解
- 连接信息都有哪些需要存储的
- 如何处理读事件,字符数据的管理呢?
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论