前言
Tinyhttp是一个用C语言编写的超轻量级服务器,对于有C语言基础的初学者来说,是一个非常好的练手项目,仔细读完代码对LINUX网络编程也有了初步的认识。由于本人之前对于网络编程没有了解,所以看源码时,都是边查阅相关的概念边学习。本文前半部分总结了我学习过程中所有的知识点,有了这些基本感念后再去读源码是相当有帮助的。
HTTP
这是一个重点,只有了解了要解析的数据格式后才能理解代码中的逻辑。由于这部分内容太多,所以我们重点来看下面的两幅图,其他的详细内容可查看参考链接, 参考链接:www.cnblogs.com/linliquan/p…
请求报文格式
- 在本文中只能识别两种请求方法
GET
和POST
。 GET
:当客户端要从服务器中读取某个资源时,使用GET
方法。GET
方法要求服务器将URL
定位的资源放在响应报文的部分,回送给客户端,即向服务器请求某个资源。使用GET
方法时,请求参数和对应的值附加在URL
后面,利用一个问号(“?”)代表URL 的结尾与请求参数的开始,传递参数长度受限制。例如, GET / HTTP/1.1POST
:当客户端给服务器提供信息较多时可以使用POST
方法,GET
一般用于获取/查询资源信息,POST
会附带用户数据,一般用于更新资源信息。例:POST /color.cgi HTTP/1.1
响应报文格式
"HTTP/1.0 200 OK\r\n"
代表着成功接收。
socket编程
这是linux网络编程的基础,下面是使用TCP传输的流程图。
socket
函数建立一个scoket
通信,即向系统注册,通知系统建立一个通信端口。bind
函数用来给scoket
端口设置一个名称。listen
函数用来等待scoket
连线,并可以指定同时处理的最大连接要求。accept
函数用于接受远程计算机的连接请求,建立与客户机之间的通信连接。当函数接受一个请求,会产生一个新的套接字,数据传输都用这新的套接字。
fork函数
fork 创建一个子进程,代码和父进程完全一样,执行fork之后的语句,fork调用一次,返回两次。
- 在父进程中,fork返回新创建子进程的ID
- 在子进程中,fork返回0
- 如果出现错误,fork返回一个负值。
管道
pipe 管道是一种最基本的IPC机制,作用于有血缘关系的进程之间,完成数据传递。
- 其本质是一个伪文件(实为内核缓冲区)
- 由两个文件描述符引用,一个表示读端,一个表示写端。
- 规定数据从写端流入,从读端流出
原理:实为内核使用环形队列机制,借助内核缓冲区实现
局限性:
- 数据自己读不能自己写
- 只能读一次,读后不存在
- 采用半双工通信方式,只能在一个方向上流动
- 只能在有公共祖先的进程间使用管道
工作流程
在这部分将会介绍客户端或服务器执行相关操作后,程序中函数的调用过程,每个函数的具体实现将在下一个部分介绍。
源码分析
为了不显得代码冗长,只说明和展示重要代码。
main()
函数
int main(void)
{
...
u_short port = 4000; //#1
server_sock = startup(&port); //#2
while (1)
{
client_sock = accept(server_sock, //#3
(struct sockaddr *)&client_name,
&client_name_len);
if (pthread_create(&newthread , NULL, (void //#4
*)accept_request, (void *)(intptr_t)client_sock) != 0)
perror("pthread_create");
}
close(server_sock); //#5
return(0);
}
- 这部分完全按照《socket编程》部分讲解的TCP流程编写。
#1~#2
:startup
函数完成socket
、bind
、listen
。并指定端口号为4000。#3~#4
:accpet
操作,当有客户端发送一个请求时,就会生成一个新socket
端口和线程,对于该请求的响应都通过该端口和线程处理,然后继续监听,直到有下一个请求。- 这就是服务器工作的主要过程。
startup()
函数
startup
函数完成socket
、bind
、listen
。
int startup(u_short *port)
{
...
httpd = socket(PF_INET, SOCK_STREAM, 0); //#1
name.sin_family = AF_INET; //#2
name.sin_port = htons(*port); //#3
name.sin_addr.s_addr = htonl(INADDR_ANY); //#4
if ((setsockopt(httpd, SOL_SOCKET, SO_REUSEADDR, //#5
&on, sizeof(on))) < 0)
{
error_die("setsockopt failed");
}
if (bind(httpd, (struct sockaddr *)&name, //#6
sizeof(name)) < 0)
error_die("bind");
if (listen(httpd, 5) < 0) //#7
error_die("listen");
return(httpd); //#8
}
#1
:socket
函数建立一个scoket
通信,PF_INET
表示IPv4通信,SOCK_STREAM
表示TCP协议。#2~#6
:设置端口的信息,及端口号,ip地址等。#7
:listen
函数用来等待scoket
连线,并可以指定同时处理的最大连接要求。
accept_request()
函数
accept_request()
函数是线程处理函数。主要功能就是解析请求命令,然后执行相应操作,http请求格式在前文中已经说明。
void accept_request(void *arg)
{
int client = (intptr_t)arg;
char buf[1024];
size_t 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;
/*读取一行*/
numchars = get_line(client, buf, sizeof(buf));
i = 0; j = 0;
/*提取方法名直到检测到空格*/
while (!ISspace(buf[i]) && (i < sizeof(method) - 1))
{
method[i] = buf[i];
i++;
}
j=i;
method[i] = '\0';
/*当既不是GET请求也不是POST请求时,调用unimplemented()函数*/
if (strcasecmp(method, "GET") && strcasecmp(method, "POST"))
{
unimplemented(client);
return;
}
if (strcasecmp(method, "POST") == 0)
cgi = 1;
i = 0;
/*跳过空格*/
while (ISspace(buf[j]) && (j < numchars))
j++;
/*提取域名,即服务器地址*/
while (!ISspace(buf[j]) && (i < sizeof(url) - 1) && (j < numchars))
{
url[i] = buf[j];
i++; j++;
}
url[i] = '\0';
/*区分GET方法和POST方法,因为两种方法格式不同,所以解析的代码也不同*/
if (strcasecmp(method, "GET") == 0)
{
query_string = url;
/*找到第一个?,判断后面带不带参数,若带参数,则需要调用cgi程序/
while ((*query_string != '?') && (*query_string != '\0'))
query_string++;
if (*query_string == '?')
{
cgi = 1;
*query_string = '\0';
query_string++;
}
}
/*获取服务器端htdocs/index.html文件路径*/
sprintf(path, "htdocs%s", url);
if (path[strlen(path) - 1] == '/')
strcat(path, "index.html");
printf("%s\n", path);
/*取得指定文件的文件属性,若找不到,就通过not_found()函数发送错误*/
if (stat(path, &st) == -1) {
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) /*S_IFMT = 掩码, S_IFDIR : D
irectory表示路径是个文件夹*/
strcat(path, "/index.html");
if ((st.st_mode & S_IXUSR) || /*X表示文件所有者拥有执行权限*/
(st.st_mode & S_IXGRP) ||
(st.st_mode & S_IXOTH) )
cgi = 1;
/*通过cgi标志位,判断是否需要执行可执行文件,若不需要执行cgi程序,则直接通过serve_file发送文件,若需要则通过execute_cgi执行cgi程序*/
if (!cgi)
serve_file(client, path);
else
execute_cgi(client, path, method, query_string);
}
/*http是无连接的,所以执行完一次请求后,关闭端口*/
close(client);
}
execute_cgi()
函数
执行相应的cgi函数
void execute_cgi(int client, const char *path,
const char *method, const char *query_string)
{
char buf[1024];
int cgi_output[2];
int cgi_input[2];
pid_t pid;
int status;
int i;
char c;
int numchars = 1;
int content_length = -1;
buf[0] = 'A'; buf[1] = '\0';
if (strcasecmp(method, "GET") == 0)
while ((numchars > 0) && strcmp("\n", buf)) /* read & discard headers */
numchars = get_line(client, buf, sizeof(buf));
else if (strcasecmp(method, "POST") == 0) /*POST*/
{
numchars = get_line(client, buf, sizeof(buf));
/*根据请求报文格式获取所有请求头部,直到遇到“\n”*/
while ((numchars > 0) && strcmp("\n", buf))
{
buf[15] = '\0';
if (strcasecmp(buf, "Content-Length:") == 0)
content_length = atoi(&(buf[16]));
numchars = get_line(client, buf, sizeof(buf));
}
if (content_length == -1) {
bad_request(client);
return;
}
}
else/*HEAD or other*/
{
}
/*创建两个管道供父进程和子进程传输数据*/
if (pipe(cgi_output) < 0) {
cannot_execute(client);
return;
}
if (pipe(cgi_input) < 0) {
cannot_execute(client);
return;
}
/*fork一个子进程*/
if ( (pid = fork()) < 0 ) {
cannot_execute(client);
return;
}
/*发送响应报文,根据响应报文格式*/
sprintf(buf, "HTTP/1.0 200 OK\r\n");
send(client, buf, strlen(buf), 0);
if (pid == 0) /* child: CGI script */
{
char meth_env[255];
char query_env[255];
char length_env[255];
/*子进程修改管道为标准输入输出,标准输入输出与cgi程序挂钩*/
dup2(cgi_output[1], STDOUT);
dup2(cgi_input[0], STDIN);
close(cgi_output[0]);
close(cgi_input[1]);
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);
}
execl(path, NULL); /*执行文件函数*/
exit(0);
}
else { /* parent */
close(cgi_output[1]);
close(cgi_input[0]);
if (strcasecmp(method, "POST") == 0){
/*父进程接收scoket端口的数据将其通过管道传给cgi程序*/
for (i = 0; i < content_length; i++) {
recv(client, &c, 1, 0);
write(cgi_input[1], &c, 1);
}
}
/*读不到数据会阻塞*/
while (read(cgi_output[0], &c, 1) > 0){
send(client, &c, 1, 0);
printf("%s", &c);
}
close(cgi_output[0]);
close(cgi_input[1]);
/*等待子进程结束,然后才能结束父进程*/
waitpid(pid, &status, 0);
}
}
get_line()
函数
根据从端口中读取数据,直到遇到'\r\n'、'\n'和'\r'就返回。
int get_line(int sock, char *buf, int size)
{
int i = 0;
char c = '\0';
int n;
while ((i < size - 1) && (c != '\n'))
{
n = recv(sock, &c, 1, 0);
if (n > 0) /*若读到数据,判断是否是'\r\n'、'\n'和'\r'*/
{
if (c == '\r')
{
/*预览下一个字符,但不读出,判断是否是'\n',若是就读出,若不是直接返回,不读出字符*/
n = recv(sock, &c, 1, MSG_PEEK);
if ((n > 0) && (c == '\n'))
recv(sock, &c, 1, 0);
else
c = '\n';
}
buf[i] = c;
i++;
}
else /*若没有读到数据,直接返回*/
c = '\n';
}
buf[i] = '\0';
return(i);
}
测试结果
为了方便测试,我在程序中数据传输部分加上printf
打印出来。
启动服务器
- 可看到端口号为我们指定的4000。
客户端连接
- 可看到客户端向服务器传来的第一个请求内容为
GET / HTTP/1.1
。 - 按照程序解析这条指令将会执行
serve_file()
函数,将htdocs/index.html
文件发送给客户端,该文件内容就是第一张图显示的内容。
在网页上提交输入一个颜色并提交
- 当客户端提交一个请求后,服务器收到了
POST /color.cgi HTTP/1.1
请求指令。 - 然后根据请求格式一直读取全部的请求头部。
- 读完头部,然后在读取数据,由上图可知数据为
color = red
,将他传给cgi程序,当执行完cgi程序后,从管道中read就可以读到要发给客户端的内容,即一个新页面的html格式的内容,这与页面的显示也一致。