web server的原理
如果说,你仅仅是要实现一个简单的网页服务器,ok,这非常简单,用不了多少代码,因为它的原理实在是非常简单.客户端首先通过<b>tcp/ip</b>三次握手连接到服务器,然后向服务器发送http
请求,这个请求大概长什么样子呢?我这里举一个栗子:
GET /sample.jsp HTTP/1.1\r\n
Accept: image/gif.image/jpeg,*/*\r\n
Accept-Language: zh-cn\r\n
Connection: Keep-Alive\r\n
Host: localhost\r\nUser-Agent: Mozila/4.0(compatible;MSIE5.01;Window NT5.0)\r\n
Accept-Encoding: gzip,deflate\r\n
\r\n
为了方便,我这里直接将换行符打印了出来,你看一下,其实也没有什么难的,不是吗?客户端发送的就是一段文本信息.这里的第一句GET /sample.jsp HTTP/1.1\r\n
,我们称之为请求方法,比如这一句,表示,method
为GET
,请求访问的资源对象(URI
)为/sample.jsp
,请求的方法的版本为http 1.1
.
请求行之后的,就是请求头部了,这里主要记录了客户端一系列的信息,比如说支持的编码(Accept-Encoding
),是否保持连接(Connection
),期望的语言类型(Accept-Language
)......
请求头部以\r\n
结尾,如果你在解析请求(request)的时候没有解析到最后一行的\r\n
,说明这个请求是有问题的,当然,也有可能是对方没有发送完完整的信息.
我们要做的这个web server
非常简单,首先,这个玩意只支持get
方法,其次,只支持静态内容,不支持动态内容,你可能要问了,为毛会这么简陋呢?
好吧,那是因为我们使用的语言是c/cpp
,它们是不带垃圾收集的语言,而web server
最重要的事情就是字符串处理,你用cpp
来处理字符串,是找虐吗?用php
,python
之类的语言它们处理字符串的效率可以完爆cpp
,所以人生苦短,我们不会用一门语言的短处去干一些另一门语言之所长.c/cpp
语言最大的优点是什么,对,是效率,web server
同时也在追求极致的性能,极致的并发度,响应时间,而这,正是我们这一系类文章想记录的东西.至于网页什么的,那些不是写html
和css
的程序员的事情吗?
好吧,扯了一堆废话,我们继续.当服务器接收到客户端的请求之后,它会解析客户端发送的请求头部,然后按照服务端的请求,发送相应的数据.当然,它也会发送相应的回复,回复大概长这个样子:
HTTP/1.1 200 OK\r\n
Last-Modified: Wed, 17 Oct 2007 03:01:41 GMT\r\n
Content-Type: text/html\r\n
Content-Length: 158\r\n
Date: Wed, 17 Oct 2007 03:01:59 GMT\r\n
Server: tiny-server/1.1\r\n
\r\n
这个回复和之前的request
长得差不多,200表示一切都ok,然后后面的一些信息表明了要发送的文件类型是html
,文件大小是158
字节.回复的头部同样要以\r\n
结尾,当然这还没有完,你说了要发送一个158
字节的html
,你要在回复头部的后面紧接着发送这个文件的内容,发送完毕,客户端和服务端的这一次交互才算完成.
然后,如果客户端前面的request
如果提到要keep alive
的话,服务端可不能关闭连接,因为对方还可能继续请求(当然,关闭了影响也不大),否则的话,服务端要主动关闭连接(因为服务端要为非常多的客户提供服务,不能为你一个用户而浪费太多资源).
好了,这其实就是我们的web server
全部的原理啦.原理虽然非常简单,当然,简单的web服务器实现起来也十分简单.如果要实现高并发,编码的难度就陡然增加啦,这也是我为什么迭代了9次才完成一个比较像样的web server
的原因.
包裹函数
UNP
给了我们很好的一个示范,那就是包裹函数,它对原生的API做了简单的包装,可以使得我们的代码变得简洁.举个栗子:
/* $begin forkwrapper */
pid_t Fork(void)
{
pid_t pid;
if ((pid = fork()) < 0)
unix_error("Fork error");
return pid;
}
这是我们的fork
函数的包裹函数Fork
,包裹函数里面主要做了错误检查,如果出错了,立马退出,如果我们不用包裹函数,那么我们的代码写起来可能是这个样子.
int res = fork();
if (res < 0) {
exit(-1);
}
if (res == 0) {
/* 子进程 */
}
else {
/* 父进程 */
}
... /* 接下来做其他的处理 */
如果我们用包裹函数,写起来就简洁多了!
if (0 == Fork()) {
/* 子进程 */
}
else {
/* 父进程 */
}
... /* other code */
你可能会顾虑包裹函数会不会大大降低程序的速度,UNP
里面曾经说过,这些包裹函数对效率的影响是非常小的,相对于网络引起的延迟,你基本上可以忽略不计这些影响.
一些常用的函数
接下来,我会稍微讲解一下在这个server
中我们常用的一些函数.
第一个函数是对open
以及listen
两个函数做的包裹函数open_listenfd
:
/*
* open_listenfd - open and return a listening socket on port
* Returns -1 and sets errno on Unix error.
*/
int open_listenfd(int port)
{
int listenfd, optval = 1;
struct sockaddr_in serveraddr;
/* 构建一个socket描述符 */
if ((listenfd = socket(AF_INET, SOCK_STREAM, 0)) < 0)
return -1;
/* Eliminates "Address already in use" error from bind. */
if (setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR,
(const void *)&optval, sizeof(int)) < 0) /* 设置端口复用 */
return -1;
/* Listenfd will be an endpoint for all requests to port
on any IP address for this host */
bzero((char *)&serveraddr, sizeof(serveraddr));
serveraddr.sin_family = AF_INET;
serveraddr.sin_addr.s_addr = htonl(INADDR_ANY);
serveraddr.sin_port = htons((unsigned short)port);
if (bind(listenfd, (SA *)&serveraddr, sizeof(serveraddr)) < 0) /* 绑定 */
return -1;
/* Make it a listening socket ready to accept connection requests */
if (listen(listenfd, LISTENQ) < 0) /* 监听 */
return -1;
return listenfd; /* 返回监听套接字 */
}
说白了就是要使得写代码变得简洁一点,没有什么别的意味,你如果对这段代码感到很眼熟的话,没错,代码来自csapp
,它的封装已经非常好了,我直接拿过来用了.
逻辑处理代码
这个版本的web server
最重要的一个部分是对request的一个处理,它位于doit
函数之中.doit
函数接收一个已经连接的socket
描述符作为参数,原型如下:
void doit(int fd);
为了讲解方便,我将它的实现一段一段拆分了.
int is_static; /* 请求的是否为静态文件 */
struct stat sbuf; /* 用于获得文件的信息 */
char buf[MAXLINE], method[MAXLINE], uri[MAXLINE], version[MAXLINE];
char filename[MAXLINE], cgiargs[MAXLINE];
rio_t rio;
/* Read request line and headers */
Rio_readinitb(&rio, fd); /* rio首先要进行初始化才行 */
Rio_readlineb(&rio, buf, MAXLINE); /* 读取一行数据 */
上面代码的第9行表示从客户端发送的数据中读取一行数据到buf
中,数据一般类似于下面的形式:
GET / HTTP/1.1\r\n
接下来解析这一行数据:
sscanf(buf, "%s %s %s", method, uri, version);
if (strcasecmp(method, "GET")) {
clienterror(fd, method, "501", "Not Implemented",
"Tiny does not implement this method");
return;
}
read_requesthdrs(&rio);
sscanf
函数将buf
的数据输出到method
, uri
,version
三个数组之中,如果是前面的请求的话,method="GET", uri="uri", version="HTTP/1.1\r\n"
,我们的代码只处理get
方法,如果不是get
方法,就要返回501
错误.
read_requestthdrs
函数具体是处理之后的头部信息,我们的代码里面其实什么也没有干.
is_static = parse_uri(uri, filename, cgiargs);
if (stat(filename, &sbuf) < 0) {
clienterror(fd, filename, "404", "Not found",
"Tiny couldn't find this file"); /* 没有找到文件 */
return;
}
pars_uri
函数用于处理获得的uri
,并从uri
中提取出文件的路径,如果是动态网页的话,还要提取出参数信息.并且返回值代表请求的是否为静态网页.
如果文件不存在,要返回404
错误.
if (is_static) { /* Serve static content */
if (!(S_ISREG(sbuf.st_mode)) || !(S_IRUSR & sbuf.st_mode)) {
clienterror(fd, filename, "403", "Forbidden",
"Tiny couldn't read the file"); /* 权限不够 */
return;
}
serve_static(fd, filename, sbuf.st_size);
}
如果是静态网页的话,我们先要判断权限是否足够.然后继续来处理.
接下来是处理静态网页的serve_static
函数.函数原型如下:
void serve_static(int fd, char *filename, int filesize);
// fd代表和客户端连接的socket描述符
// filename文件所在路径
// filesize文件大小
首先构造回复的头部:
int srcfd;
char *srcp, filetype[MAXLINE], buf[MAXBUF];
/* Send response headers to client */
get_filetype(filename, filetype);
sprintf(buf, "HTTP/1.0 200 OK\r\n");
sprintf(buf, "%sServer: Tiny Web Server\r\n", buf);
sprintf(buf, "%sContent-length: %d\r\n", buf, filesize);
sprintf(buf, "%sContent-type: %s\r\n\r\n", buf, filetype);
Rio_writen(fd, buf, strlen(buf)); /* 发送数据给客户端 */
然后是打开文件,发送文件.
srcfd = Open(filename, O_RDONLY, 0); /* 打开文件 */
srcp = (char *)Mmap(0, filesize, PROT_READ, MAP_PRIVATE, srcfd, 0);
Close(srcfd); /* 关闭文件 */
Rio_writen(fd, srcp, filesize); /* 发送数据 */
Munmap(srcp, filesize); /* 解除映射 */
真的很简单.我们再来看一下处理错误的clienterror
函数吧!
void clienterror(int fd, char *cause, char *errnum,
char *shortmsg, char *longmsg)
{
char buf[MAXLINE], body[MAXBUF];
/* Build the HTTP response body */
sprintf(body, "<html><title>Tiny Error</title>");
sprintf(body, "%s<body bgcolor=""ffffff"">\r\n", body);
sprintf(body, "%s%s: %s\r\n", body, errnum, shortmsg);
sprintf(body, "%s<p>%s: %s\r\n", body, longmsg, cause);
sprintf(body, "%s<hr><em>The Tiny Web server</em>\r\n", body);
/* Print the HTTP response */
sprintf(buf, "HTTP/1.0 %s %s\r\n", errnum, shortmsg);
Rio_writen(fd, buf, strlen(buf));
sprintf(buf, "Content-type: text/html\r\n");
Rio_writen(fd, buf, strlen(buf));
sprintf(buf, "Content-length: %d\r\n\r\n", (int)strlen(body));
Rio_writen(fd, buf, strlen(buf));
Rio_writen(fd, body, strlen(body));
}
其实就是构造头部信息,然后发送给客户端而已,非常简单.
其余的函数我不再一一述说了,你可以去看我的代码.
主函数
这个版本的server
最重要的,我觉得是主函数,我们来看一下主函数是如何实现的吧!
/* 网页的根目录 */
const char * root_dir = "/home/lishuhuakai/WebSiteSrc/html_book_20150808/reference";
/* / 所指代的网页 */
const char * home_page = "index.html";
/*-
* 单进程版本的web server!当没有连接到来的时候,该进程会阻塞在Accept函数上,当然,这里的connfd也是阻塞版本的.
* 也就是说,在对connfd执行write,read等函数的时候也会阻塞.这样一来,这个版本的server效率会非常低下.
*/
int main(int argc, char *argv[])
{
int listenfd = Open_listenfd(8080); /* 8080号端口监听 */
while (true) /* 无限循环 */
{
struct sockaddr_in clientaddr;
socklen_t len = sizeof(clientaddr);
int connfd = Accept(listenfd, (SA*)&clientaddr, &len);
doit(connfd);
Close(connfd);
}
return 0;
}
虽然重要,但是也没有什么好说的,非常简单,就是获得连接,处理连接,然后关闭连接,这样一个无限循环.当然,效率可想而知.为了方便,我将网络的根目录放在了main
函数所在的cpp
中,虽然难看了点,不过这只是第一个版本而已.
这个版本代码大多出自csapp
,并没有多少行,在以后的迭代过程中,我们的代码几乎会发生翻天覆地的变化,尽请期待.
如何运行
我将代码上传到了github
之上,你可以下载下来.地址在这里:https://github.com/lishuhuakai/Spweb
记得将图中标注的那个文件夹放入你的linux
主机下的某个目录,并用root_dir
指向它.
比如说,我将其放入了/home/lishushuakai/
目录下,我的root_dir
就被设置成了"/home/lishuhuakai/WebSiteSrc/html_book_20150808/reference"
.
好吧,现在可以运行代码了,enjoy it!
缺点
这只是很简单的一个服务器,各种各样的情况都没有考虑,你可以思考一下有那些极端的情况需要我们来考虑,我们将在接下来的一次次迭代中逐步解决这些问题.