Tinyhttpd是一个超轻量级的http服务器,使用C语言开发,代码只有500多行,不用于实际生产, 只是为了学习使用,通过阅读代码可以理解初步web服务器的本质,下面是学习Tinyhttpd的相关资料:
一、HTTP协议
在阅读源码之间, 我们先要初步了解HTTP协议。HTTP协议(HyperText Transfer Protocol,超文本传输协议)是因特网上应用最为广泛的一种网络传输协议,所有的WWW文件都必须遵守这个标准。简单地说HTTP协议就是规定了客户端和服务器的通信格式, 它建立在TCP协议的基础上, 默认使用80端口,也可以改为8080端口或者其他端口,并不涉及数据包的传输, 只规定了通信的规范。
HTTP有如下特点:
- 支持客户/服务器模式
- 客户向服务器请求服务时,只需传送请求方法和路径,请求方法常用的有GET、HEAD、POST
- 允许传输任意类型的数据对象。
- 无连接,无连接的含义是限制每次连接只处理一个请求,服务器处理完客户的请求,并收到客户的应答后,即断开连接。
- 无状态,无状态是指协议对于事务处理没有记忆能力。
HTTP 1.0的协议通信过程如下,当连接建立后,浏览器发送一个请求,服务器回应一个消息,之后,连接就被关闭。当浏览器下次请求的时候,需要重新建立连接。
HTTP 1.1的协议通信过程如下,在HTTP1.1 版本中,给出了持续连接的机制,通过这种连接,浏览器可以在建立一个连接之后,发送请求并得到回应,然后继续发送请求并再次得到回应。这样比较节省时间,因为连接的建立是需要时间的。
根据HTTP标准,HTTP请求可以使用多种请求方法。HTTP1.0定义了三种请求方法;GET,POST和HEAD方法。HTTP1.1新增了五种请求方法:OPTIONS,PUT,DELETE,TRACE和CONNECT方法。
序号 |
方法 | 描述 |
---|---|---|
1 | GET | 请求指定的页面信息,并返回实体主体。 |
2 | HEAD | 类似于get请求,只不过返回的响应中没有具体的内容,用于获取报头。 |
3 | POST | 向制定资源提交数据进行处理请求(例如提交表单或者上传文件)。数据被包含在请求体中。POST请求可能会导致新的资源的建立和/或已有资源的修改。 |
4 | PUT | 从客户端向服务器传送的数据取代制定个的文档内容 |
5 | DELETE | 请求服务器删除指定的页面。 |
6 | CONNECT | HTTP/1.1协议中预留给能够将连接改为管道方式的代理服务器。 |
7 | OPTIONS | 允许客户端查看服务器的性能。 |
8 | TRACE | 回显服务器收到的请求,主要用于测试或诊断。 |
二、SOCKET
1、socket是什么
Socket是应用层与TCP/IP协议族通信的中间软件抽象层,它是一组接口。在设计模式中,Socket其实就是一个门面模式,它把复杂的TCP/IP协议族隐藏在Socket接口后面,对用户来说,一组简单的接口就是全部,让Socket去组织数据,以符合指定的协议。socket位置如图所示:
2、如何使用socket
先从服务器端说起。服务器端先初始化Socket,然后与端口绑定(bind),对端口进行监听(listen),调用accept阻塞,等待客户端连接。在这时如果有个客户端初始化一个Socket,然后连接服务器(connect),如果连接成功,这时客户端与服务器端的连接就建立了。客户端发送数据请求,服务器端接收请求并处理请求,然后把回应数据发送给客户端,客户端读取数据,最后关闭连接,一次交互结束。
3、基本函数
- socket()函数
int socket(int domain, int type, int protocol);
socket对应于普通文件的打开操作。普通文件的打开操作返回一个文件描述字,而socket()用于创建一个socket描述符(socket descriptor),它唯一标识一个socket。这个socket描述字跟文件描述字一样,后续的操作都有用到它,把它作为参数,通过它来进行一些读写操作。函数参数中,domain为协议域,又称为协议族(family),type为指定socket类型,protocol是指定协议
- bind()函数
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
bind()函数把一个地址族中的特定地址赋给socket。例如对应AF_INET、AF_INET6就是把一个ipv4或ipv6地址和端口号组合赋给socket。sockfd为socket描述字,它是通过socket()函数创建了,唯一标识一个socket。addr:一个const struct sockaddr *指针,指向要绑定给sockfd的协议地址。addrlen为对应的是地址的长度。
- listen()、connect()
int listen(int sockfd, int backlog);
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
调用listen()来监听这个socket,如果客户端这时调用connect()发出连接请求,服务器端就会接收到这个请求。listen函数的第一个参数即为要监听的socket描述字,第二个参数为相应socket可以排队的最大连接个数。socket()函数创建的socket默认是一个主动类型的,listen函数将socket变为被动类型的,等待客户的连接请求。connect函数的第一个参数即为客户端的socket描述字,第二参数为服务器的socket地址,第三个参数为socket地址的长度。客户端通过调用connect函数来建立与TCP服务器的连接。
- accept()函数
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
TCP服务器监听到这个请求之后,就会调用accept()函数取接收请求,这样连接就建立好了。之后就可以开始网络I/O操作了,即类同于普通文件的读写I/O操作。accept函数的第一个参数为服务器的socket描述字,第二个参数为指向struct sockaddr *的指针,用于返回客户端的协议地址,第三个参数为协议地址的长度。如果accpet成功,那么其返回值是由内核自动生成的一个全新的描述字,代表与返回客户的TCP连接。
- read()、write()等函数
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);
read函数是负责从fd中读取内容.当读成功时,read返回实际所读的字节数,如果返回的值是0表示已经读到文件的结束了,小于0表示出现了错误。write函数将buf中的nbytes字节内容写入文件描述符fd.成功时返回写的字节数。失败时返回-1,并设置errno变量。在网络程序中,当我们向套接字文件描述符写时有俩种可能。write的返回值大于0,表示写了部分或者是全部的数据。返回的值小于0,此时出现了错误。
- close()函数
int close(int fd);
在服务器与客户端建立连接之后,会进行一些读写操作,完成了读写操作就要关闭相应的socket描述字,好比操作完打开的文件要调用fclose关闭打开的文件。close操作只是使相应socket描述字的引用计数-1,只有当引用计数为0的时候,才会触发TCP客户端向服务器发送终止连接请求。
三、Tinyhttpd源码分析
- 主函数如下:
int main(void)
{
int server_sock = -1;
u_short port = 0;
int client_sock = -1;
struct sockaddr_in client_name;
int client_name_len = sizeof(client_name);
pthread_t newthread;
server_sock = startup(&port);
printf("httpd running on port %d\n", port);
while (1)
{
client_sock = accept(server_sock,
(struct sockaddr *)&client_name,
&client_name_len);
if (client_sock == -1)
error_die("accept");
/* accept_request(client_sock); */
if (pthread_create(&newthread , NULL, accept_request, client_sock) != 0)
perror("pthread_create");
}
close(server_sock);
return(0);
}
- 第一步:初始化httpd服务,包括建立套接字,绑定端口,进行监听。
server_sock = startup(&port);
startup函数如下:
int startup(u_short *port)
{
int httpd = 0;
struct sockaddr_in name;
httpd = socket(PF_INET, SOCK_STREAM, 0);
if (httpd == -1)
error_die("socket");
memset(&name, 0, sizeof(name));
name.sin_family = AF_INET;
name.sin_port = htons(*port);
name.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(httpd, (struct sockaddr *)&name, sizeof(name)) < 0)
error_die("bind");
if (*port == 0) /* if dynamically allocating a port */
{
int namelen = sizeof(name);
if (getsockname(httpd, (struct sockaddr *)&name, &namelen) == -1)
error_die("getsockname");
*port = ntohs(name.sin_port);
}
if (listen(httpd, 5) < 0)
error_die("listen");
return(httpd);
}
首先创建一个socket,协议域为PF_INNET,使用 IPv4 网络协议,类型为SOCK_STREAM,即面向TCP的。然后调用bind函数把一个地址族中的特定地址赋给socket,返回绑定的端口,最后进行监听申请的连接。
- 第二步:TCP服务器监听到请求,调用accept()函数取接收请求,建立连接。
client_sock = accept(server_sock,
(struct sockaddr *)&client_name,
&client_name_len);
- 第三步:创建线程运行accept_request函数
if (pthread_create(&newthread, NULL, (void *)accept_request, (void *)(intptr_t)client_sock) != 0)
perror("pthread_create");
accept_request函数如下:
void accept_request(void *arg)
{
//socket
int client = (intptr_t)arg;
char buf[1024];
int numchars;
char method[255];
char url[255];
char path[512];
size_t i, j;
struct stat st;
int cgi = 0; /* becomes true if server decides this is a CGI
* program */
char *query_string = NULL;
//根据上面的Get请求,可以看到这边就是取第一行
//这边都是在处理第一条http信息
//"GET / HTTP/1.1\n"
numchars = get_line(client, buf, sizeof(buf));
i = 0; j = 0;
//第一行字符串提取Get
while (!ISspace(buf[j]) && (i < sizeof(method) - 1))
{
method[i] = buf[j];
i++; j++;
}
//结束
method[i] = '\0';
//判断是Get还是Post
if (strcasecmp(method, "GET") && strcasecmp(method, "POST"))
{
unimplemented(client);
return;
}
//如果是POST,cgi置为1
if (strcasecmp(method, "POST") == 0)
cgi = 1;
i = 0;
//跳过空格
while (ISspace(buf[j]) && (j < sizeof(buf)))
j++;
//得到 "/" 注意:如果你的http的网址为http://192.168.0.23:47310/index.html
// 那么你得到的第一条http信息为GET /index.html HTTP/1.1,那么
// 解析得到的就是/index.html
while (!ISspace(buf[j]) && (i < sizeof(url) - 1) && (j < sizeof(buf)))
{
url[i] = buf[j];
i++; j++;
}
url[i] = '\0';
//判断Get请求
if (strcasecmp(method, "GET") == 0)
{
query_string = url;
while ((*query_string != '?') && (*query_string != '\0'))
query_string++;
if (*query_string == '?')
{
cgi = 1;
*query_string = '\0';
query_string++;
}
}
//路径
sprintf(path, "htdocs%s", url);
//默认地址,解析到的路径如果为/,则自动加上index.html
if (path[strlen(path) - 1] == '/')
strcat(path, "index.html");
//获得文件信息
if (stat(path, &st) == -1) {
//把所有http信息读出然后丢弃
while ((numchars > 0) && strcmp("\n", buf)) /* read & discard headers */
numchars = get_line(client, buf, sizeof(buf));
//没有找到
not_found(client);
}
else
{
if ((st.st_mode & S_IFMT) == S_IFDIR)
strcat(path, "/index.html");
//如果你的文件默认是有执行权限的,自动解析成cgi程序,如果有执行权限但是不能执行,会接受到报错信号
if ((st.st_mode & S_IXUSR) ||
(st.st_mode & S_IXGRP) ||
(st.st_mode & S_IXOTH) )
cgi = 1;
if (!cgi)
//接读取文件返回给请求的http客户端
serve_file(client, path);
else
//执行cgi文件
execute_cgi(client, path, method, query_string);
}
//执行完毕关闭socket
close(client);
}
首先获取http请求的第一行,取出http请求中method(get或post)和url,对于get方法,如果有携带参数,则query_string指针指向url中后面的get参数。格式化url到path数组,表示浏览器请求的文件路径,在tinyhttpd中服务器文件是在htdocs文件夹下。当url以/结尾,或者url是个目录,则默认在path中加上index.thml,表示访问主页。如果文件路径合法,对于无参数的get请求,直接输出服务器文件到浏览器,即用http格式写到套接字上。其他情况(带参数get,post方法,url为可执行文件),则调用execute_cgi函数执行cgi脚本。最后关闭socket。
cgi脚本执行:
void execute_cgi(int client, const char *path,
const char *method, const char *query_string)
{
//缓冲区
char buf[1024];
//2根管道
int cgi_output[2];
int cgi_input[2];
//进程pid和状态
pid_t pid;
int status;
int i;
char c;
//读取的字符数
int numchars = 1;
//http的content_length
int content_length = -1;
//默认字符
buf[0] = 'A'; buf[1] = '\0';
//忽略大小写比较字符串
if (strcasecmp(method, "GET") == 0)
//读取数据,把整个header都读掉,以为Get写死了直接读取index.html,没有必要分析余下的http信息了
while ((numchars > 0) && strcmp("\n", buf)) /* read & discard headers */
numchars = get_line(client, buf, sizeof(buf));
else /* POST */
{
numchars = get_line(client, buf, sizeof(buf));
while ((numchars > 0) && strcmp("\n", buf))
{
//如果是POST请求,就需要得到Content-Length,Content-Length:这个字符串一共长为15位,所以
//取出头部一句后,将第16位设置结束符,进行比较
//第16位置为结束
buf[15] = '\0';
if (strcasecmp(buf, "Content-Length:") == 0)
//内存从第17位开始就是长度,将17位开始的所有字符串转成整数就是content_length
content_length = atoi(&(buf[16]));
numchars = get_line(client, buf, sizeof(buf));
}
if (content_length == -1) {
bad_request(client);
return;
}
}
sprintf(buf, "HTTP/1.0 200 OK\r\n");
send(client, buf, strlen(buf), 0);
//建立output管道
if (pipe(cgi_output) < 0) {
cannot_execute(client);
return;
}
//建立input管道
if (pipe(cgi_input) < 0) {
cannot_execute(client);
return;
}
// fork后管道都复制了一份,都是一样的
// 子进程关闭2个无用的端口,避免浪费
// ×<------------------------->1 output
// 0<-------------------------->× input
// 父进程关闭2个无用的端口,避免浪费
// 0<-------------------------->× output
// ×<------------------------->1 input
// 此时父子进程已经可以通信
//fork进程,子进程用于执行CGI
//父进程用于收数据以及发送子进程处理的回复数据
if ( (pid = fork()) < 0 ) {
cannot_execute(client);
return;
}
if (pid == 0) /* child: CGI script */
{
char meth_env[255];
char query_env[255];
char length_env[255];
//子进程输出重定向到output管道的1端
dup2(cgi_output[1], 1);
//子进程输入重定向到input管道的0端
dup2(cgi_input[0], 0);
//关闭无用管道口
close(cgi_output[0]);
close(cgi_input[1]);
//CGI环境变量
sprintf(meth_env, "REQUEST_METHOD=%s", method);
putenv(meth_env);
if (strcasecmp(method, "GET") == 0) {
sprintf(query_env, "QUERY_STRING=%s", query_string);
putenv(query_env);
}
else { /* POST */
sprintf(length_env, "CONTENT_LENGTH=%d", content_length);
putenv(length_env);
}
//替换执行path
execl(path, path, NULL);
//int m = execl(path, path, NULL);
//如果path有问题,例如将html网页改成可执行的,但是执行后m为-1
//退出子进程,管道被破坏,但是父进程还在往里面写东西,触发Program received signal SIGPIPE, Broken pipe.
exit(0);
} else { /* parent */
//关闭无用管道口
close(cgi_output[1]);
close(cgi_input[0]);
if (strcasecmp(method, "POST") == 0)
for (i = 0; i < content_length; i++) {
//得到post请求数据,写到input管道中,供子进程使用
recv(client, &c, 1, 0);
write(cgi_input[1], &c, 1);
}
//从output管道读到子进程处理后的信息,然后send出去
while (read(cgi_output[0], &c, 1) > 0)
send(client, &c, 1, 0);
//完成操作后关闭管道
close(cgi_output[0]);
close(cgi_input[1]);
//等待子进程返回
waitpid(pid, &status, 0);
}
}
读取整个http请求并丢弃,如果是post则找出content-length,把http状态码200写到套接字里面。建立两个管道,cgi_input和cgi_output,并fork一个子进程。在子进程中,把stdout重定向到cgi_output的写入端,把stdin重定向到cgi_input的读取端,关闭cgi_input的写入端和cgi_output的读取端,是指request_method的环境变量,get的话设置query_string的环境变量,post的话设置content-length的环境变量,这些环境变量都是为了给cgi脚本调用,接着用execl运行cgi程序。在父进程中,关闭cgi_input的读取端和cgi_output的写入端,如果post的话,把post数据写入到cgo_input,已被重定向到stdin读取cgi_output的管道输出到客户端,等待子进程结束。