Web Server 和 HTTP协议(转)
转自:http://www.kuqin.com/shuoit/20150809/347488.html
一直在找实习,有点什么东西直接就在evernote里面记了,也没时间来更新到这里。找实习真是个蛋疼的事,一直找的是困难模式的C++的后台开发这种职位,主要是因为其他的更不会了。虽然找的是C++的职位,但是我的简历有俩项目都是php的,因为老赵的项目就是用php做网站。最近越来越感觉这样的简历不靠谱,想换个C++的和网络有关的多线程的项目吧。所以最近准备点几个网络和多线程的技能点。于是我看了tinyhttpd、LightCgiServer和吴导的husky。基本上对着吴导的husky抄了个paekdusan,但是也不能纯粹的抄一遍啊,所以还是改了一些小东西,大的框架没变。主要的改变包括以下几方面:
- 在线程池部分中,使用C++11的thread替代了pthread,从而实现跨平台的目标
- 在支持并发的队列中,使用C++11的mutex和lock替代了pthread的mutex和lock,从而实现跨平台的目标
- 在socket部分,使用了预编译宏的方式,从而实现跨平台的目标
- 接收数据部分更健壮,以面对不能一次性读完一个HTTP头部的情况;发送也一样
- 实现了一个具有简易的KeepAlive策略的HTTP服务器
- 实现了一个静态文件的HTTP服务器
tinyhttpd和LightCgiServer
首先,还是先介绍一下tinyhttpd吧。网上的评价还是很高的,能让人仅从500-600行的代码中了解HTTP Server的本质。 贴一张tinyhttpd的流程图吧:
关于tinyhttpd更详细的信息,大家还是直接去看代码吧,因为真的很易读、易懂。tinyhttpd的代码给人的感觉就是,怎么易读、易懂怎么来,例如服务器回复一个501 Method Not Implemented
的response是这么写的,看到我就惊呆了,只能怪我以前看过的代码太少,我第一反应就是先sprintf到一个长的buff里面,然后一起send,但是它这样的写法确实更加易懂、易读。
void unimplemented(int client) { char buf[1024]; sprintf(buf, "HTTP/1.0 501 Method Not Implementedrn"); send(client, buf, strlen(buf), 0); sprintf(buf, SERVER_STRING); send(client, buf, strlen(buf), 0); sprintf(buf, "Content-Type: text/htmlrn"); send(client, buf, strlen(buf), 0); sprintf(buf, "rn"); send(client, buf, strlen(buf), 0); sprintf(buf, "<HTML><HEAD><TITLE>Method Not Implementedrn"); send(client, buf, strlen(buf), 0); sprintf(buf, "</TITLE></HEAD>rn"); send(client, buf, strlen(buf), 0); sprintf(buf, "<BODY><P>HTTP request method not supported.rn"); send(client, buf, strlen(buf), 0); sprintf(buf, "</BODY></HTML>rn"); send(client, buf, strlen(buf), 0); }
除此之外,值得一提的是tinyhttpd实现的是一个CGI Server的功能,但是在CGI的功能上实现得比较简陋,LightCgiServer实现得更完整一些,关于CGI Server更详细的情况请看CGI Server
husky和paekdusan
正如本文开头所说,大的程序结构上,paekdusan基本是对着husky抄的,只是做了一些小的改变。程序在大的结构上,可以看作是一个生产者消费者模型
。
先看一个不伦不类的流程图:
从上图可以看出,主线程是生产者
,线程池中的线程们是消费者
,它们之间通过task队列来通信。主线程作为生产者
,accept成功返回之后,将处理该client的task添加到task队列中,然后继续accept等待client的到来;线程池的线程们作为消费者
,不断的从task队列中取出task,调用task的run接口。
值得注意的是,task队列是一个BoundedBlockingQueue
,也就是说,task队列是一个有容量限制,并且阻塞的队列。当消费者试图从task队列中取task时,如果task队列是空的,则消费者会被阻塞,直到生产者往task队列中放入task,将消费者唤醒。同样的,当生产者试图向task队列中放入task时,如果task队列是满的,则生产者会被阻塞,直到消费者从task队列中取出task,将生产者唤醒。
再看一个不伦不类的时序图:
这里要求task实现了run接口,task队列的设计可以认为是command模式
的实践。看上去有很多类,但是其实是因为每个类的功能比较单一,程序只是把一些功能单一的类组合在一起了而已,其实类之间的耦合性比较低。
具体实现的代码见这里paekdusan
问题记录
-
HTTP协议的基本格式
HTTP的request的第一部分是request line,以空格分割得到的三部分依次是method,URI和version
HTTP的request的第二部分是header,header以rn结尾,header中的每一行也以rn结尾,也就是说,当header是空时,以一个rn结尾;当header不空时,一定是以两个连续的rn结尾的。heder中的每一行格式是 key : value,其中value可以是空,所以简单的说,header是一个map,键和值之间用:分隔,键值对之间用rn分隔,在map的最后还有一个rn。值得注意的是,cookie是在header里面的。
HTTP的request的第三部分是body,协议规定body后面不能再有其他字符,所以body不能靠去找rn来结束,要靠header里面的content-length来指明,content-length就是body的字节数。
HTTP的response的第一部分是response line,以空格分割得到的三部分依次是version,status code和Reason Phrase
HTTP的response的第二部分是header,格式和request类似
HTTP的response的第三部分是body,格式和request类似
另外,还有一点,我不知道request里面有没有可能出现,反正在response里面是会出现的,那就是如果header中指明了transfer-coding是chunked,那么body将会是一串chunked的块。在HTTP协议的rfc2616中是这么定义chunk-body的格式的:
Chunked-Body = *chunk last-chunk trailer CRLF chunk = chunk-size [ chunk-extension ] CRLF chunk-data CRLF chunk-size = 1*HEX last-chunk = 1*("0") [ chunk-extension ] CRLF chunk-extension= *( ";" chunk-ext-name [ "=" chunk-ext-val ] ) chunk-ext-name = token chunk-ext-val = token | quoted-string chunk-data = chunk-size(OCTET) trailer = *(entity-header CRLF)
也就是说chunk由四部分组成,首先是若干个chunk块(每个chunk块由chunk-size,可选的chunk-extension,rn, chunk-data 和 rn组成),接着是last-chunk块(chunk-size是0,没有chunk-data的特殊chunk块),然后是trailer(若干和header一样格式的数据组成),最后是一个rn。其实不看中间“可选的chunk-extension”还是比较简单的。
-
KeepAlive的实现
在之前husky的代码中,server端发送了response之后,就close socket了,即关闭该socket,如果client需要再次发送http request需要再次建立一个新的tcp连接。而打开一个常见的网页,通常有很多http request从client发送到server,那么就需要很多次tcp的建立和断开,比较低效。之所以用KeepAlive就是为了避免多次请求需要重复的建立TCP连接,也就是说server端发送完response之后,不关闭连接,而是在该连接上继续等待数据。KeepAlive在HTTP1.1是默认开启的,如果要关闭,需要在header中声明connection: close。
但是朴素的KeepAlive会引起一些问题,例如client一直不断开连接,那么和client的连接一直保持,client多了的时候,新来的client无法获得server的资源,所以需要一些其他的折衷。例如如果接下来的5s内都没有收到数据则断开连接,或者是接下来的5s内服务器接收了100个客户端请求就断开连接。
由于“5s内服务器接收了100个客户端请求就断开连接”这需要在根据一个线程外部的信息控制线程的运行,使得线程运行过程中对于外部的以来过多,故而paekdusan没有这么实现,而是在同一个连接上接收了50个http request之后断开连接。另外paekdusan还实现了“一个连接的持续时间超过5s就断开连接”,具体来说是这样的,recv超时时间1s,每次recv完数据之后判断距离第一次recv的时间是否超过5s,超过则断开连接。详见如下代码:
//简单起见 删除了一些处理不完整http请求的代码,并且简化了now 和 startTime的设置 //详见https://github.com/aholic/paekdusan/blob/master/KeepAliveWorker.hpp while ((now - startTime <= 5) && requestCount > 0) { recvLen = recv(sockfd, recvBuff, RECV_BUFFER_SIZE, 0); if (recvLen <= 0 && getLastErrorNo() == ERROR_TIMEOUT) { LogInfo("recv returns %d", recvLen); continue; } if (recvLen <= 0) { LogError("recv failed: %d", getLastErrorNo()); break; } //do with recvBuff, get a http request requestCount--; }
但是由于使用的是阻塞的recv,所以实现的不是非常合理,存在一些问题。 例如此时恰好距离第一次recv只有4.99秒,所以(now - startTime <= 5)满足,继续进入while循环,然后阻塞在recv上,recv设置的超时是1秒,那么其实最后跳出while循环的时间距离第一次recv已经过去了5.99秒。暂时没想到什么好办法,因为把recv设置成理解返回的话,while循环的次数太多,效率也不高。所以最好是要有一种通知的机制。
-
CGI Server
CGI Server一般是要fork一个进程来执行http request的URI中指定的CGI Script的,并且通过环境变量,向CGI Script传递本次请求的信息,具体怎么做可以看看这篇文章。但是注意到paekdusan是一个多线程的服务器,所以这里涉及到多线程和多进程,这是个很蛋疼的情况。多线程和多进程的混合会有很多问题,这篇文章有详细的介绍,好吧,你可能会发现它被墙了,那我还是简单的介绍一下会有什么问题吧。
首先需要说明的是,在一个子线程中调用fork会发生什么:产生的子进程中只会有一个线程,也就是调用fork的这个线程。
那么问题来了,假如父进程中的其他线程获取了一个锁,正在改线程间共享的数据,这时共享数据处于半修改状态。但是在子进程中,其他线程都消失了,那这些共享数据的修改怎么办?并且,锁的状态也得未定义了。另外,即使你的代码是线程安全的,你也不能保证你用的Lib的实现是线程安全的。
所以唯一合理的在多线程的环境下使用多进程的情况,只有fork之后立即exec,也就是马上讲子进程替换成一个新的程序,这样的话,子进程中所有的数据都变得不重要,都抛弃了。所以,其实多线程的CGI Server也算是合理,但是需要注意安全性问题。因为打开的子进程默认是继承了父进程的文件描述符的,也就是说,子进程可以有父进程对文件的读写权限。
我大概知道的就这么多,更详细的还是FQ去读原文吧。
-
C++11的thread
C++11的thread用起来感觉比以前的linux上的pthread或者是windows上的beginthread都好用太多来,来一段简短的代码展示一下基本用法吧。
void sayWord(const string& word) { for (int i = 0; i < 1000; i++) { cout << word << endl; } } void saySentence(const string& sentence) { for (int i = 0; i < 1000; i++) { cout << sentence << endl; } } int main() { string word = "hello"; string sentence = "this is an example from cstdlib.com"; thread t1(std::bind(sayWord, ref(word))); thread t2(saySentence, ref(sentence)); t1.join(); t2.join(); return 0; }
运行以上代码,会发现交替输出“hello”和“this is an example from cstdlib.com”。注意到t1和t2的构造参数看起来怪怪的,“std::bind(sayWord, ref(word))”和“saySentence, ref(sentence)”,主要线程函数的参数是引用,有个模版里面的bind在这,我也很难解释清楚,感觉模版叼叼的。另外,以非静态类成员函数创建线程时,需要在参数中带上this或者是ref一个对象的实例,不然无法调用。
-
C++11的mutex和lock
注意到上面thread的代码其实是有问题的,两个线程交替输出的东西可能会混合,所以要加锁。C++11的mutex和lock也很好用。
代码里面用的是lock_guard,lock_guard就是在构造函数里面调用mutex的lock方法,析构函数里面调用mutex的unlock方法,用起来比较方便。unique_lock和lock_guard类似,但是多了一些其他的成员函数来配合其他的mutex类。关于mutex和lock的用法,[这篇博客](http://www.cnblogs.com/haippy/p/3237213.html)说的比较详细。主要和pthread里面的lock的区别就是,在pthread中,重复获取一个已经获得的lock不会报错,而在C++11中会报错。
-
C++11的condition_variable
在paekdusan的task队列是
BoundedBlockingQueue
,也就是说有阻塞和唤醒的操作。所以涉及到condition_variable。condition_variable主要用两个函数:wait(unique_lock<mutex>& lck)
和notify_one()
。线程被wait阻塞时,会先调用lck.unlock()函数释放锁;线程被notify_one唤醒时,会调用lck.lock()获取锁,以回复当时wait前的样子。关于condition_variable的用法,这篇博客说的比较详细 -
windows和linux上socket的API的不同点
- windows上需包含winsock.h;linux上需包含cerrno,sys/socket.h,sys/types.h,arpa/inet.h和unistd.h
- windows上调用socket之前要调用WSAStartup,并且用#pragma comment(lib,”Ws2_32”)链接Ws2_32.lib
- windows上关闭socket的函数叫做closesocket;linux上叫做close
- windows上获取错误码用GetLastError();linux上查看全局变量errno,错误码的意义也不一样
- windows上设置SO_RCVTIMEO和SO_SNDTIMEO选项时,单位是毫秒;linux是秒
- windows上accept的原型是accept(SOCKET, struct sockaddr, int);linux上accept的原型是accept(int, struct sockaddr, socklen_t)
我遇到的就这些,估计还有很多,只是我还没遇到。