一起来写web server 01 -- 单进程版本

csdn的markdown编辑器就是一个傻逼.


web server的原理

如果说,你仅仅是要实现一个简单的网页服务器,ok,这非常简单,用不了多少代码,因为它的原理实在是非常简单.客户端首先通过tcp/ip三次握手连接到服务器,然后向服务器发送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, 我们称之为请求方法,比如这一句,表示,methodGET,请求访问的资源对象(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同时也在追求极致的性能,极致的并发度,响应时间,而这,正是我们这一系类文章想记录的东西.至于网页什么的,那些不是写htmlcss的程序员的事情吗?

好吧,扯了一堆废话,我们继续.当服务器接收到客户端的请求之后,它会解析客户端发送的请求头部,然后按照服务端的请求,发送相应的数据.当然,它也会发送相应的回复,回复大概长这个样子:

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!

缺点

这只是很简单的一个服务器,各种各样的情况都没有考虑,你可以思考一下有那些极端的情况需要我们来考虑,我们将在接下来的一次次迭代中逐步解决这些问题.

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值