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


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,我们称之为请求方法,比如这一句,表示,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!

缺点

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,332评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,508评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,812评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,607评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,728评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,919评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,071评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,802评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,256评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,576评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,712评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,389评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,032评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,798评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,026评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,473评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,606评论 2 350

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,637评论 18 139
  • 第一章 Nginx简介 Nginx是什么 没有听过Nginx?那么一定听过它的“同行”Apache吧!Ngi...
    JokerW阅读 32,650评论 24 1,002
  • 1. 网络基础TCP/IP HTTP基于TCP/IP协议族,HTTP属于它内部的一个子集。 把互联网相关联的协议集...
    yozosann阅读 3,440评论 0 20
  • API定义规范 本规范设计基于如下使用场景: 请求频率不是非常高:如果产品的使用周期内请求频率非常高,建议使用双通...
    有涯逐无涯阅读 2,521评论 0 6
  • 上一篇《WEB请求处理一:浏览器请求发起处理》,我们讲述了浏览器端请求发起过程,通过DNS域名解析服务器IP,并建...
    七寸知架构阅读 80,946评论 21 356