C语言实现简单的WebServer服务器
基于TCP的套接字通信
这是一个单线程流程,服务器创建用于监听的套接字,绑定本地的ip和端口,listen函数去监听绑定的端口。
如果有客户端进行连接,服务器端就可以和发起连接的客户端建立连接,连接建立成功会生成一个用于通信的套接字。用于监听的套接字和用于通信的套接字是不一样的。监听的套接字用于建立连接,通信的套接字用于数据交互。用于数据交互的read和write都是阻塞函数,在单线程下面,一个服务器想和多客户端进行通信,肯定是做不到的,因为accept,read,write都是阻塞的。
为了使服务器可以正常的与多个客户端建立连接,并进行数据交互,需要用到多线程,多线程中的主线程负责建立连接(调用accept),子线程负责数据通信。
多线程切换有一定的开销,因此引入非阻塞 I/O。非阻塞 I/O 不会将进程挂起,调用时会立即返回成功或错误,因此可以在一个线程里轮询多个文件描述符是否就绪。但是这种做法缺点是,每次发起系统调用,只能检查一个文件描述符是否就绪。当文件描述符很多时,系统调用的成本很高。
IO多路复用,也就是select,poll,epoll,可以通过一次系统调用,检查多个文件描述符的状态,相比于非阻塞 I/O,在文件描述符较多的场景下,避免了频繁的用户态和内核态的切换,减少了系统调用的开销。在IO多路复用中,阻塞是由内核实现的,自己编写的代码可以少许多不必要的阻塞。
在单线程下,只用IO多路复用,没办法同时处理两件事情,为了提高效率,一般采用多线程+IO多路复用的方法。
单线程服务器流程
自己编写的代码充当服务器,浏览器作为客户端的角色进行固定地址的访问。
1. 在终端输入 启动程序 端口 和 程序主目录。
2. 启动监听套接字initListenFd(unsigned short port):
a) 创建监听fd,采用IPv4,TCP协议
b) 设置端口复用:如果程序服务器是主动断开连接的一方,会有一个2msl的等待时长,为了确认客户端已经收到我断开确认ack
c) 绑定IP和端口
d) 设置监听
e) 返回监听套接字lfd
3. 启动服务器程序epollRun(int lfd):
a) 创建epoll 树的根节点
b) lfd上树:上树用的是epoll_ctl函数
c) while true不停地检测是否有事件到来,根据epoll_wait返回的数组中的fd文件描述符,判断事件是连接请求,还是数据通信请求:
i. 如果是连接请求,调用acceptClient(lfd, epfd),建立新的连接。
ii. 如果是数据通信请求,调用recvHttpRequest(cfd, epfd),以http协议的方式传递消息。
acceptClient(lfd, epfd):
1. 建立连接,调用accept函数。
2. 设置非阻塞模式,非阻塞说的是文件描述符,默认得到的cfd是阻塞的,用fcntl修改文件描述符的属性。
3. cfd添加到epoll中,设置边沿触发方式。
recvHttpRequest(cfd, epfd):
1. 读取客户端发送过来的http请求头:
2. 判断数据是否被接收完毕:
a) if (len == -1 && errno == EAGAIN):证明有数据
解析请求行:parseRequestLine(const char* line, int cfd):
i. sscanf拆分字符串,得到请求方法和请求路径,仅处理get请求:
ii. 对请求路径中的中文进行处理,否则会乱码
iii. 判断文件路径是指向目录,还是文件,或者文件不存在:
1. 若文件路径不存在,发送404.html
2. 若文件路径是目录,则发送html的头部,然后发送格式化的目录列表,也是符合html格式。
3. 若文件路径指向具体文件,则分析文件类型,然后发送具体文件。
由于通信的文件描述符是非阻塞的,用sendfile发送文件的时候要处理返回值,
不断根据偏移量去发送文件,直到文件发送完毕,否则大文件的传输会出现问题。
cfd去读取发送数据内存的时候,是非阻塞的,读数据块速度很快,读到文件末尾偏移量之后,再进行while循环读取的时候
ret返回值为-1,errno == EAGAIN代表没有数据,可以再次进行尝试。
off_t offset = 0;
int size = lseek(fd, 0, SEEK_END);
lseek(fd, 0, SEEK_SET);
while (offset < size) // 如果偏移量小于size,则表示文件没有发送完,继续发送
{
// 通信的文件描述符是非阻塞的
int ret = sendfile(cfd, fd, &offset, size - offset);
printf("ret value: %d\n", ret);
if (ret == -1 && errno == EAGAIN) // EAGAIN的意思是没有数据,可以再次进行尝试
{
printf("没数据...\n");
}
}
close(fd);
b) 否则说明客户端断开了连接,要及时的将cfd下树,并且关闭对应文件描述符
多线程服务流程
单线程的服务器模型中,主程序会不断阻塞的进行连接和数据通信两项工作,两项工作和主线程不独立,当连接请求比较多的时候,效率相对较低。
多线程的处理方法:在建立连接 acceptClient(lfd, epfd) 和 数据通信模块 recvHttpRequest(cfd, epfd)两部分,都开辟新的线程去做,让子线程去处理动作。
注意要在项目的输入,库依赖项中输入pthread,否则linux链接的时候找不到。
main.c代码:
#include <stdio.h>
#include"Server.h"
#include<unistd.h>
#include <stdlib.h>
int main(int argc, char* argv[])
{
if (argc < 3) {
printf("./a.out port path\n");
return -1;
}
unsigned short port = atoi(argv[1]);
// 切换服务器的工作目录
chdir(argv[2]);
// 初始化监听的套接字
int lfd = initListenFd(port);
// 启动服务器程序
epollRun(lfd);
return 0;
}
Server.h代码:
#pragma once
// 初始化监听的文件描述符
int initListenFd(unsigned short port);
// 启动epoll
int epollRun(int lfd);
// 和客户端建立连接
// int acceptClient(int lfd, int epfd);
void* acceptClient(void* arg);
// 接收http请求
// int recvHttpRequest(int cfd, int epfd);
void recvHttpRequest(void* arg);
// 解析请求行
int parseRequestLine(const char* line, int cfd);
// 发送文件
int sendFile(const char* fileName, int cfd);
// 发送响应头(状态行和响应头)
/**
* cfd 通信文件描述符
* status 状态码
* descr 状态描述
* type 描述数据格式
* length 数据库长度 若为-1,则告诉浏览器去计算长度
*/
int sendHeadMsg(int cfd, int status, const char* descr, const char* type, int length);
// 获取文件类型,已经有,不用再写
const char* getFileType(const char* name);
// 发送目录
int sendDir(const char* dirName, int cfd);
// 将数字从十六进制转换成十进制
int hexToDec(char c);
// 解码,解决中文乱码问题,from传入参数,to传出参数
void decodeMsg(char* to, char* from);
Server.c代码:
#include "Server.h"
#include <arpa/inet.h>
#include <sys/epoll.h>
#include <stdio.h>
#include <fcntl.h>
#include <errno.h>
#include <strings.h>
#include <string.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <assert.h>
#include <sys/sendfile.h>
#include <dirent.h>
#include <unistd.h>
#include <stdlib.h>
#include <pthread.h>
#include <ctype.h>
struct FdInfo
{
int fd;
int epfd;
pthread_t tid;
};
int initListenFd(unsigned short port )
{
/**
* 1. 创建监听的fd
* AF_INET:基于IPv4协议
* SOCK_STREAM:采用流式协议,即tcp协议
*/
int lfd = socket(AF_INET, SOCK_STREAM,0);
if (lfd == -1) {
perror("socket");
return -1;
}
// 2. 设置端口复用:如果程序服务器是主动断开连接的一方,会有一个2msl的等待时长,为了确认客户端已经收到我断开确认ack,
// 2msl之后才能释放端口
int opt = 1;
int ret = setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof (opt));
if (ret == -1) {
perror("setsockopt");
return -1;
}
// 3. 绑定
struct sockaddr_in addr;
addr.sin_family = AF_INET; // 地址协议ipv4
addr.sin_port = htons(port); // 指定端口,指定的网络字节序为大端,需要进行转换
addr.sin_addr.s_addr = INADDR_ANY;
ret = bind(lfd, (struct sockaddr*)&addr, sizeof(addr));
if (ret == -1) {
perror("bind");
return -1;
}
// 4. 设置监听
// 128代表在监听过程中一次性能监听多少个连接请求
ret = listen(lfd,128);
if (ret == -1) {
perror("listen");
return -1;
}
// 5. 返回fd
return lfd;
}
int epollRun(int lfd)
{
// 1. 创建epoll 树的根节点
int epfd = epoll_create(1);
if (epfd == -1) {
perror("epoll_create");
return -1;
}
// 2. lfd上树:上树用的是epoll_ctl函数,epoll_ctl函数功能强大,第二个参数是表示当前对epoll树做什么操作
// 上树的时候,第二个参数是add
struct epoll_event ev;
ev.data.fd = lfd;
ev.events = EPOLLIN; //检测读事件
int ret = epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev);
if (ret == -1) {
perror("epoll_ctl");
return -1;
}
// 3. 检测
struct epoll_event evs[1024];
while (1) {
// 若有事件连接,会存到evs中,并返回有多少个值为num。
int size = sizeof(evs) / sizeof(struct epoll_event);
int num = epoll_wait(epfd, evs, size, -1); //最后一个参数为-1,没有连接就一直阻塞
for (int i = 0; i < num; i++) {
struct FdInfo* info = (struct FdInfo*)malloc(sizeof(struct FdInfo));
int fd = evs[i].data.fd;
info->epfd = epfd;
info->fd = fd;
if (fd == lfd) {
// 建立新连接,将新连接添加到epoll树上,添加之后,epoll_wait再检测的节点就变多了。
//acceptClient(lfd, epfd);
pthread_create(&info->tid, NULL, acceptClient, info);
}
else {
// 数据通信,主要接收对端的数据,格式为http协议格式
//recvHttpRequest(fd, epfd);
pthread_create(&info->tid, NULL, recvHttpRequest, info);
}
}
}
return 0;
}
// int acceptClient(int lfd, int epfd);
void* acceptClient(void* arg)
{
struct FdInfo* info = (struct FdInfo*)arg;
/**
* 1. 建立连接,调用accept函数
* accept 三个参数:
* 第一个参数:需要监听的文件描述符
* 第二个参数:传出参数,用来保存客户端的ip和端口信息,这里不需要保存
* 第三个参数:计算第二个参数的大小
*/
int cfd = accept(info->fd, NULL, NULL);
if (cfd == -1) {
perror("accept");
return NULL;
}
// 2. 设置边沿非阻塞模式,非阻塞说的是文件描述符,默认得到的cfd是阻塞的,用fcntl修改文件描述符的属性
int flag = fcntl(cfd, F_GETFL);
flag |= O_NONBLOCK;
fcntl(cfd, F_SETFL, flag);
// 3. cfd添加到epoll中:
struct epoll_event ev;
ev.data.fd = cfd;
ev.events = EPOLLIN | EPOLLET; //检测读事件,EPOLLET设置为边沿触发
int ret = epoll_ctl(info->epfd, EPOLL_CTL_ADD, cfd, &ev);
if (ret == -1) {
perror("epoll_ctl");
return NULL;
}
return NULL;
}
// int recvHttpRequest(int cfd, int epfd);
void recvHttpRequest(void* arg)
{
struct FdInfo* info = (struct FdInfo*)arg;
int len = 0, total = 0;
char tmp[1024] = { 0 };
char buf[4096] = { 0 }; //用来存储客户端发过来的整个数据,不够长也没事,只要读出来请求头其实就可以了。
while ((len = recv(info->fd, tmp ,sizeof tmp, 0)) > 0) {
if (total + len < sizeof buf) {
memcpy(buf+total, tmp, len);
}
total += len;
}
// 判断数据是否被接收完毕
if (len == -1 && errno == EAGAIN) {
//解析请求行,另写一个函数,这里只解析请求头
char* pt = strstr(buf, "\r\n");
int reqLen = pt - buf;
buf[reqLen] = '\0';
parseRequestLine(buf, info->fd);
}
else if (len == 0) {
// 客户端断开了连接
epoll_ctl(info->epfd, EPOLL_CTL_DEL, info->fd, NULL);
close(info->fd);
}
else {
perror("recv");
}
return NULL;
}
int parseRequestLine(const char* line, int cfd)
{
// 解析请求行
// sscanf拆分字符串
char method[12]; // get or post
char path[1024];
sscanf(line, "%[^ ] %[^ ]", method, path);
// 只处理get请求
if (strcasecmp(method, "get") != 0) {
return -1;
}
decodeMsg(path, path); //处理中
// 处理静态资源(目录或文件)
char* file = NULL;
if (strcmp(path, "/") == 0)
{
file = "./";
}
else
{
file = path + 1;
}
// 获取文件属性,判断是目录还是文件
struct stat st;
int ret = stat(file, &st);
if (ret == -1) {
// 文件不存在, 404
sendHeadMsg(cfd, 404, "Not Found", getFileType(".html"), -1);
sendFile("404.html", cfd); //在当前目录下
return 0;
}
else if(S_ISDIR(st.st_mode)) {
// 是目录
sendHeadMsg(cfd, 200, "OK", getFileType(".html"), -1);
sendDir(file, cfd);
}
else {
// 请求的是文件,发送文件
sendHeadMsg(cfd, 200, "OK", getFileType(file), st.st_size);
sendFile(file, cfd);
}
return 0;
}
int sendFile(const char* fileName, int cfd)
{
// 1.打开文件
int fd = open(fileName, O_RDONLY);
assert(fd > 0);
//while (1) {
// char buf[1024];
// int len = read(fd, buf, sizeof buf);
// if (len > 0) {
// send(cfd, buf, len, 0);
// usleep(10); //不要发送太快,给对端一个喘口气的时间
// }
// else if (len == 0) {
// break;
// }
// else {
// prror("read");
// }
//}
off_t offset = 0;
int size = lseek(fd, 0, SEEK_END);
lseek(fd, 0, SEEK_SET);
while (offset < size) // 如果偏移量小于size,则表示文件没有发送完,继续发送
{
// 通信的文件描述符是非阻塞的
int ret = sendfile(cfd, fd, &offset, size - offset);
printf("ret value: %d\n", ret);
if (ret == -1 && errno == EAGAIN) // EAGAIN的意思是没有数据,可以再次进行尝试
{
printf("没数据...\n");
}
}
close(fd);
return 0;
}
int sendHeadMsg(int cfd, int status, const char* descr, const char* type, int length)
{
// 状态行
char buf[4096] = { 0 };
sprintf(buf, "http/1.1 %d %s\r\n", status, descr); //版本 状态码 描述语言
// 响应头
sprintf(buf + strlen(buf), "content-type: %s\r\n", type);
sprintf(buf + strlen(buf), "content-length: %d\r\n\r\n", length);
send(cfd, buf, strlen(buf), 0);
return 0;
}
const char* getFileType(const char* name)
{
// a.jpg a.mp4 a.html
// 自右向左查找‘.’字符, 如不存在返回NULL
const char* dot = strrchr(name, '.');
if (dot == NULL)
return "text/plain; charset=utf-8"; // 纯文本
if (strcmp(dot, ".html") == 0 || strcmp(dot, ".htm") == 0)
return "text/html; charset=utf-8";
if (strcmp(dot, ".jpg") == 0 || strcmp(dot, ".jpeg") == 0)
return "image/jpeg";
if (strcmp(dot, ".gif") == 0)
return "image/gif";
if (strcmp(dot, ".png") == 0)
return "image/png";
if (strcmp(dot, ".css") == 0)
return "text/css";
if (strcmp(dot, ".au") == 0)
return "audio/basic";
if (strcmp(dot, ".wav") == 0)
return "audio/wav";
if (strcmp(dot, ".avi") == 0)
return "video/x-msvideo";
if (strcmp(dot, ".mov") == 0 || strcmp(dot, ".qt") == 0)
return "video/quicktime";
if (strcmp(dot, ".mpeg") == 0 || strcmp(dot, ".mpe") == 0)
return "video/mpeg";
if (strcmp(dot, ".vrml") == 0 || strcmp(dot, ".wrl") == 0)
return "model/vrml";
if (strcmp(dot, ".midi") == 0 || strcmp(dot, ".mid") == 0)
return "audio/midi";
if (strcmp(dot, ".mp3") == 0)
return "audio/mpeg";
if (strcmp(dot, ".ogg") == 0)
return "application/ogg";
if (strcmp(dot, ".pac") == 0)
return "application/x-ns-proxy-autoconfig";
return "text/plain; charset=utf-8";
}
int sendDir(const char* dirName, int cfd)
{
char buf[4096] = { 0 };
sprintf(buf, "<html><head><title>%s</title></head><body><table>", dirName);
struct dirent** namelist;
int num = scandir(dirName, &namelist, NULL, alphasort); //修改vs设置c语言的标准为GUN11
for (int i = 0; i < num; ++i)
{
// 取出文件名 namelist 指向的是一个指针数组 struct dirent* tmp[]
char* name = namelist[i]->d_name;
struct stat st;
char subPath[1024] = { 0 };
sprintf(subPath, "%s/%s", dirName, name); //拼接字符串
stat(subPath, &st);
if (S_ISDIR(st.st_mode))
{
// a标签 <a href="">name</a>
sprintf(buf + strlen(buf),
"<tr><td><a href=\"%s/\">%s</a></td><td>%ld</td></tr>",
name, name, st.st_size);
}
else
{
sprintf(buf + strlen(buf),
"<tr><td><a href=\"%s\">%s</a></td><td>%ld</td></tr>",
name, name, st.st_size);
}
send(cfd, buf, strlen(buf), 0);
memset(buf, 0, sizeof(buf));
free(namelist[i]); //释放内存
}
sprintf(buf, "</table></body></html>");
send(cfd, buf, strlen(buf), 0);
free(namelist);
return 0;
}
// 将数字从十六进制转换成十进制
int hexToDec(char c)
{
if (c >= '0' && c <= '9')
return c - '0';
if (c >= 'a' && c <= 'f')
return c - 'a' + 10;
if (c >= 'A' && c <= 'F')
return c - 'A' + 10;
return 0;
}
void decodeMsg(char* to, char* from)
{
for (; *from != '\0'; ++to, ++from)
{
// isxdigit -> 判断字符是不是16进制格式, 取值在 0-f
if (from[0] == '%' && isxdigit(from[1]) && isxdigit(from[2]))
{
// 将16进制的数 -> 十进制 将这个数值赋值给了字符 int -> char
// B2 == 178
// 将3个字符, 变成了一个字符, 这个字符就是原始数据
*to = hexToDec(from[1]) * 16 + hexToDec(from[2]);
// 跳过 from[1] 和 from[2] 因此在当前循环中已经处理过了
from += 2;
}
else
{
// 字符拷贝, 赋值
*to = *from;
}
}
*to = '\0';
}