作者:正龙(沪江Web前端开发工程师)本文为原创文章,转载请注明作者和出处。启动成功。既然Node.js的强项是处理网络请求,那我们就来分析一下Node.js是如何处理一个HTTP请求的,在这个过程中JavaScript引入了多少开销。Node.js采用的网络请求处理模型是IO多路复用。不同于传统的主从多线程并发模型:只使用有限的线程数(1),因此占用系统资源极少;操作系统级的异步IO支持可以减少用户态/内核态的切换,并且其性能更高(因为它直接与网卡驱动交互);JavaScript天生具有保护程序执行现场的能力(关闭),传统模式要么依赖应用程序自身保存现场,要么依赖线程切换时的自动完成。当然,不能说IO多路复用就是最好的并发模型,关键还是要看应用场景。让我们看看“helloworld”Node.jsWeb服务器:require('http').createServer((req,res)=>{res.end('helloworld');}).listen(3333);代码思路分析createServer([requestListener])createServer创建http.Server对象,继承自net.Server。事实上,HTTP协议确实是基于TCP协议实现的。createServer的可选参数requestListener用于监听请求事件;另外,它也监听连接事件,不过回调函数是http.Server自己实现的。然后调用listen让http.Server对象监听3333端口的连接请求,最后创建一个TCP对象,由tcp_wrap.h实现。最后,TCP对象的listen方法将被调用,这将真正开始在指定端口上提供服务。我们来看看涉及到的所有JavaScript对象:涉及到的大部分C++类只是对libuv进行了一层包装并发布到JavaScript,这里就不具体列出了。我们需要提到http-parser,它用于解析http请求/响应消息。非常高效:没有系统调用,没有内存分配操作,纯C实现。连接事件当服务器接受连接请求时,连接事件将被触发。我们可以在这个节点获取socket文件描述符,然后我们就可以对这个文件描述符进行流式读写,也就是所谓的全双工模式。上面提到net.Server的listen方法会创建一个TCP对象,并提供TCP对象的onconnection事件回调方法;这里可以使用字段net.Server.maxConnections来做过载保护,后面会提到。而clientHandle(本次连接的socket文件描述符)会被封装成一个net.Socket对象作为连接事件的参数。我们看一下调用过程:tcp_wrap.ccvoidTCPWrap::Listen(constFunctionCallbackInfo&args){interr=uv_listen(reinterpret_cast(&wrap->handle_),backlog,OnConnection);args.GetReturnValue().Set(err);}OnConnection定义在connection_wrap.cc//...省略不重要的代码uv_stream_t*client_handle=reinterpret_cast(&wrap->handle_);//如果新连接已经关闭,uv_accept可能会失败,在这种情况下//将返回EAGAIN(资源暂时不可用)。如果(uv_accept(句柄,client_handle))返回;//接受成功。在JavaScript领域调用onconnection回调。argv[1]=client_obj;//...省略不重要的代码wrap_data->MakeCallback(env->onconnection_string(),arraysize(argv),argv);上面说的clientHandle其实就是uv_accept的第二个参数,指的是服务当前连接的socket文件描述符。net.Server的_handle字段会在JavaScript端存储这个字段。最后,我们有一个流程图:在request事件连接事件的回调函数connectionListener(lib/_http_server.js)中,首先获取http-parser对象,并设置parser.onIncoming回调(即将用到)。当连接套接字有数据到达时,调用http-parser.execute方法。http-parser在解析过程中会触发如下回调函数:on_message_begin:在开始解析HTTP报文之前,可以设置http-parser的初始状态(注意http-parse可能会被复用,而不是每次都重新创建)on_url:解析请求的url,不作用于响应报文on_status,解析状态码,只作用于http响应报文on_head_field,头域名称on_head_value:头域对应的值on_headers_complete:当全部header解析完成on_body:解析http消息包含payloadon_message_complete:解析工作完成Node.js中的Parser类是http-parser的包装器,它会注册上面所有的回调函数。同时,五个事件暴露给JavaScript:kOnHeaders、kOnHeadersComplete、kOnBody、kOnMessageComplete、kOnExecute。这些事件在lib/_http_common.js中被监听。其中,kOnHeaders会在需要强制将header字段返回给JavaScript时触发;例如header字段数量超过32个,或者解析结束时还有header字段没有返回给JavaScript。调用http_parser_execute后触发kOnExecute。当kOnHeadersComplete事件被触发时,将调用解析器的onIncoming回调函数。只有HTTP头解析完成后,才会触发请求事件。执行过程如下:总结说了这么多,其实还是离不开最基本的socket编程步骤。对于服务器端,它们是:创建、绑定、侦听、接受和关闭。客户端将经历创建、绑定、连接和关闭。想深入了解socket编程的同学可以参考《UNIX网络编程》。HTTP场景分析上面提到的Node.js版的helloworld只涵盖了HTTP处理的最基本情况,但足以说明Node.js处理的非常简洁。现在,我们来分析一些典型的HTTP场景。1.keep-alive对于前端应用来说,瞬间HTTP请求的数量比较多,但是每次请求传输的数据一般都不大;这时,使用同一个TCP连接来处理来自同一个用户的HTTP请求,可以显着提高性能。但保活也不是万能的。如果用户一次只发起一个请求,会延长连接的存活时间,从而浪费服务器资源。对于同一个连接,Node.js会维护一个传入队列和一个传出队列。应用程序可以通过侦听请求事件来访问ServerResponse和IncomingMessage对象。当请求处理完成时(调用response.end()),ServerResponse将响应完成事件。如果是这个连接上的最后一个响应对象,准备关闭连接;否则,继续触发请求事件。默认每次连接的最大超时时间为2分钟,可以通过http.Server.setTimeout进行调整。现在为Node.js版本修改我们的helloworldvardelay=[2000,30,500];vari=0;require('http').createServer((req,res)=>{//为了使request模拟更真实,会调整每个请求的响应时间setTimeout(()=>{res.end('helloworld');},delay[i]);i=(i+1)%(delay.length);}).listen(3333,()=>{//listen的回调函数console.log('listenat3333');});客户端代码如下:varhttp=require('http');//设置HTTPagent启用keep-alive模式//socket将打开1分钟varagent=newhttp.Agent({keepAlive:true,keepAliveMsecs:60000});//每次请求结束后,会重新发起请求//doReq每次调用只会触发2次请求functiondoReq(again,iter){letrequest=http.request({hostname:'192.168.1.10',port:3333,agent:agent},(res)=>{console.log(`${newDate().valueOf()}${iter}${again}标题:${JSON.stringify(res.headers)}`);console.log(request.socket.localPort);//设置解析响应的编码格式res.setEncoding('utf8');//接收响应res.on('data',(chunk)=>{console.log(`${newDate().valueOf()}${iter}${again}主体:${chunk}`);});如果(再次)doReq(false,iter);});//发起请求request.end();}for(leti=0;i<3;i++){doReq(true,i);}socket多路复用的时机如下:2.如果客户端发送POSTExpectheader之前的request,由于传输的数据量比较大,需要向服务器确认请求是否可以处理;在这种情况下,您可以先发送一个包含headerExpect:100-continue的HTTP请求,如果服务器可以处理该请求,它将返回一个响应状态码100(Continue);否则,返回417(ExpectationFailed)。默认情况下,Node.js会自动响应状态码100;同时http.Server会触发事件checkContinue和checkExpectation,方便我们做特殊处理。具体规则是:当服务端收到头域Expect:如果它的值为100-continue,就会触发checkContinue事件,默认行为是返回100;如果值为其他,则会触发checkExpectation事件,默认行为是返回417。比如我们通过curl发送一个HTTP请求:curl-vs--header"Expect:100-continue"http://localhost:3333交互过程如下>GET/HTTP/1.1>Host:localhost:3333>User-Agent:curl/7.49.1>Accept:*/*>Expect:100-continue>>HTTP/1.1100Continue{//请求回调函数console.log(`代理请求:${req.url}`);varurlObj=url.parse(req.url);varoptions={hostname:urlObj.hostname,port:urlObj.port||80,path:urlObj.path,method:req.method,headers:req.headers};//向目标服务器发起请求varproxyRequest=http.request(options,(proxyResponse)=>{//将目标服务器的响应返回给客户端res.writeHead(proxyResponse.statusCode,proxyResponse.headers);proxyResponse.pipe(res);}).on('error',()=>{res.end();});//将客户端请求数据传递给中间请求req.pipe(proxyRequest);}).listen(8089,'0.0.0.0');验证是否真的有效,curl通过代理服务器访问我们的“helloworld”Node.js服务器:curl-xhttp://192.168.132.136:8089http://localhost:3333/Node.js实现时的优化策略HTTP服务器,除了使用高性能的http-parser外,它自己也做了一些性能优化。1.http_parser对象缓存池http-parser对象在处理完一个请求后不会立即释放,而是放入缓存池(/lib/internal/freelist),最多1000个http-parser对象缓存。2.默认的HTTP头总数HTTP协议规范没有限制可以传输的HTTP头总数的上限。为了避免动态分配内存,http-parser默认设置上限为32。其他网络服务器实现也有类似的设置;例如,Apache可以处理的HTTP请求头(LimitRequestFields)的默认限制是100。如果请求消息中的头字段超过32个,Node.js也可以处理。它会通过事件kOnHeaders将解析后的header字段保存到JavaScript中继续解析。如果header字段不超过32个,http-parser会直接处理并触发on_headers_complete一次性传递所有header字段;所以当我们使用Node.js作为web服务器时,我们应该尽量将header字段控制在32以内。3.过载保护理论上,Node.js允许的同时连接数只与文件的上限有关进程可以打开的描述符。但是随着连接数的增加,占用的系统资源也越来越多。很可能连正常的服务都得不到保障,甚至拖累整个系统。这时候我们可以设置http.Server的maxConnections。如果当前并发大于服务器的处理能力,服务器会自动关闭连接。此外,您还可以将套接字超时设置为可接受的最长响应时间。性能测量为了简单分析Node.js引入的开销,现在基于libuv和http_parser编写了一个纯C的HTTP服务器。基本思路是监听默认事件循环队列上的指定TCP端口;如果请求到达端口,任务将被一个接一个地插入到队列中;当这些任务被消费时,connection_cb就会被执行。查看核心代码片段:intmain(){//初始化uv事件循环loop=uv_default_loop();uv_tcp_t服务器;结构sockaddr_in地址;//指定服务器监听地址和端口uv_ip4_addr("192.168.132.136",3333,&addr);//初始化TCP服务器并将其绑定到默认事件循环uv_tcp_init(loop,&server);//绑定服务器端口uv_tcp_bind(&server,(conststructsockaddr*)&addr,0);//指定连接处理回调函数connection_cb//256为TCP等待队列长度intr=uv_listen((uv_stream_t*)&server,256,connection_cb);//在默认时间循环上开始处理消息//如果TCP报告错误,事件循环将自动退出流操作回调函数read_cb:voidconnection_cb(uv_stream_t*server,intstatus){uv_tcp_t*client=(uv_tcp_t*)malloc(sizeof(uv_tcp_t));uv_tcp_init(循环,客户端);//与客户端建立套接字uv_accept(server,(uv_stream_t*)client);uv_read_start((uv_stream_t*)client,alloc_buffer,read_cb);}上面的read_cb用于读取客户端请求数据和发送响应数据:voidread_cb(uv_stream_t*stream,ssize_tnread,constuv_buf_t*buf){if(nread>0){memcpy(reqBuf+bufEnd,buf->base,nread);bufEnd+=nread;免费(buf->基础);//验证TCP请求数据是否为合法的HTTP报文http_parser_execute(parser,&settings,reqBuf,bufEnd);uv_write_t*req=(uv_write_t*)malloc(sizeof(uv_write_t));uv_buf_t*response=malloc(sizeof(uv_buf_t));//响应HTTP消息response->base="HTTP/1.1200OK\r\nConnection:close\r\nContent-Length:11\r\n\r\nhelloworld\r\n\r\n";响应->len=strlen(响应->基础);uv_write(req,stream,response,1,write_cb);}elseif(nread==UV_EOF){uv_close((uv_handle_t*)stream,close_cb);}}完整源码参考simpleHTTPserver我们使用apachebenchmark进行压测:并发数5000,总请求数100000ab-c5000-n100000http://192.168.132.136:3333/测试结果如下:0.8秒(C)vs5秒(Node.js)再看内存使用情况,0.6MB(C)vs51MB(Node.js)虽然Node.js引入了一些overhead,从代码实现的行数来看确实简单很多。iKcamp原创新书《移动Web前端高效开发实战》已在亚马逊、京东、当当网发售。