阅读 1396

还没搞懂nodejs的http服务器?看这一篇就够了

http2/https不在本文的讨论范围,本文基于Nodejs v13.1.0

阅读本篇文章之前,请阅读前置文章:

阅读完本篇文章之后,希望你可以掌握以下知识点:

  • 完整的HTTP服务器启动流程
  • HTTP的数据流转

1、前置知识回顾

因为我们知道nodejs启动的服务器依赖于libuv,所以这里我们有必要将libuv如何启动tcp服务器的过程说一下,后面的内容才不会看得糊里糊涂。

这个步骤在nodejs深入学习系列之libuv基础篇(一)2.2.10小节uv_tcp_t有过简单的概括:

1、初始化uv_tcp_t: uv_tcp_init(loop, &tcp_server)
2、绑定地址:uv_tcp_bind
3、监听连接:uv_listen
4、每当有一个连接进来之后,调用uv_listen的回调,回调里要做如下事情:
  4.1、初始化客户端的tcp句柄:uv_tcp_init()
  4.2、接收该客户端的连接:uv_accept()
  4.3、开始读取客户端请求的数据:uv_read_start()
  4.4、读取结束之后做对应操作,如果需要响应客户端数据,调用uv_write,回写数据即可。
复制代码

更多细节参考tcpserver.c

那么这么一个过程,nodejs是如何通过v8和js将整个过程实现出来的呢?这也是我们本文想要阐释的重点。

如果你这个时候问我:明明讲的是HTTP,为啥回顾TCP服务器的启动啊?那么请你先面壁思过三秒钟~

2、从🌰开始

nodejs的魅力在哪里?那就是启动服务器。简简单单几行代码,就可以启动一个HTTP服务器:

const http = require('http')

const server = http.createServer(() => {})

server.listen(3000)
复制代码

但是你知道吗?外表看着简单,实质内心是很复杂的,就好比洋葱,光滑无比的外壳下谁会想到有一圈圈,一圈圈也就算了,还会让人流泪😭

这里的每一行代码背后都有着一套复杂的逻辑,我们将从源码入手,剖析隐藏在后面的原理。

3、http服务器涉及的主要文件

3.1、Js端

  • http.js:http模块的主入口文件

  • _http_common.js:公共模块,比如提供了我们待会会提及到的http解析器

  • _http_incoming.js:实现了IncomingMessage类,继承了Stream

  • _http_outgoing.js:实现了OutgoingMessage类,继承了Strema

  • _http_server.js:http服务器实现主文件

  • net.js:tcp服务器实现主文件

  • internal/net.js:包含一些tcp服务器辅助函数

  • internal/http.js:包含一些http服务器辅助函数

备注:_http_clien.js_http_agent.js是作为HTTP客户端使用的。

3.2、C++端

  • tcp_wrap.cc:实现tcp_wrap这个内建模块,提供诸如openbindlisten这类常用的tcp服务器方法
  • connection_wrap.cc:实现一个模板类,主要根据连接的类型不同,传参不一样,支持TCP和PIPE,当有连接上来的时候会调用模板类的onConnection方法
  • node_http_parser.cc:C++端的HTTP解析器,基于llhttp这个C语言包,可见服务器的HTTP报文解析交给执行效率更高的C++端了
  • stream_wrap.cc:实现stream_wrap这个内建模块,基于libuv的stream模块,继承自StreamBase
  • stream_base.cc:实现StreamBase类,因为该类是stream_wrap的父类,所以其所有的方法都可以通过stream_wrap暴露出去

关于llhttp的介绍,在nodejs是如何和libuv以及v8一起合作的?(文末有彩蛋哦)的第一小节**1、Nodejs依赖些啥?**有讲到过。

3.3、所有文件的关系图

下图是给出上述文件的一个简单的关系图,给大家一个基本印象:

4、http.createServer():服务器的实例化

当执行http.createServer的时候,就是实例化Server,得到的Server实例的原型结构如下图:

Server的原型对象

对应的实例结构可以通过Debug模式看到:

在这个阶段埋下两个重要的事件:requestconnection

到这里实例化完成,是不是很easy?

5、server.listen(3000)

当执行到server.listen(3000)的时候,实际调用的是net.js里面的listen方法。而listen方法最后归一化调用listenInCluster,在这个方法里面,可以解释**为什么集群模式下,所有实例监听相同的端口而不会报端口被占用的错误?**因为,在这个方法中,会去判断当前实例是否是master,如果是的话才会去创建新的socket,否则是worker,则监听master中得到的socket。

listenInCluster中最后调用_listen2,也就是setupListenHandle

Server.prototype._listen2 = setupListenHandle
复制代码

setupListenHandle最终调用:createServerHandle,这个时候C++端才开始参与进来,这个过程的流程如下:

上述流程图,从listen方法开始到结束,展示了如何与V8和libuv的一个完美合作,期间涉及到了三个libuv方法,也就是完成我们在第一小节前置知识提到的前三个步骤:

1、初始化uv_tcp_t: uv_tcp_init(loop, &tcp_server)
2、绑定地址:uv_tcp_bind
3、监听连接:uv_listen
复制代码

同时有一个非常重要的点就是,我们给TCP类的实例方法onconnection赋值了:

this._handle.onconnection = onconnection

对应于C++的代码是(初始化为Null):

t->InstanceTemplate()->Set(env->onconnection_string(), Null(env->isolate()));

env->onconnection_string()的定义在env.h

V(onconnection_string, "onconnection")

也就是大家看到的js端的onconnection方法。给这个方法赋值有啥用呢?

大家再仔细看上述流程图libuv的一部分,最后一个调用的uv_listen传了一个回调!这部分就是我们下一节要讲的内容。

listen完后的server实例有所变化,关注_handle这个变量(也就是红框内):

可以看到_handle此时也就是C++端定义的类TCP,其原型对象是LibuvStreamWrap,关于TCP类的实现可以看tcp_wrap.cc文件的TCPWrap::Initialize,想要看得懂前提是你得先看过这篇文章:如何正确地使用v8嵌入到我们的C++应用中

到这里,服务器算是初始化完毕,接下去的内容更加有意思,请不要走开哦~

6、当有客户端的连接请求进来

在上一小节,我们埋了一个问题:设置了onconnection的js方法,但是没有后续了吗?

当然不是!我们在前置知识中讲到,调用了uv_listen之后,给了一个回调函数,当有连接进来的时候,就会调用回调函数。而V8这里提供的回调就是在上面流程图右下角用红色加粗的函数OnConnection。我们这一小节的内容就从这个函数开始讲起。

如下图展示了当有连接请求的时候,从操作系统底层经过Libuv之后,到js端的一个流程图:

这个过程契合了我们在前置知识中提到的这两个步骤:

4、每当有一个连接进来之后,调用uv_listen的回调,回调里要做如下事情:
  4.1、初始化客户端的tcp句柄:uv_tcp_init()
  4.2、接收该客户端的连接:uv_accept()
复制代码

此时拿到了客户端的tcp句柄client_handle通过回调之前设置的onconnection方法,传值给js端:

wrap_data->MakeCallback(env->onconnection_string(), arraysize(argv), argv);
复制代码

js端将该客户端封装到socket实例后再给_http_server.js,于是到这里主动权又回到了js端。

为了给该socket关联上http解析器,也为了在socket上监听请求的数据,在connectionListenerInternal方法上,做了很多操作,主要有以下几件:

  • 实例化解析器parser
  • 对socket的dataenderrorclosedrainresumepause等事件进行监听,并绑定对应回调
  • parser.onIncoming绑定函数

关于parser的实现也是我们搞懂整个环节的一部分,这一部分我们在下一小节提及。

到这里,整个流程大家是不是有一个比较清晰的认识了?

但是,好像有一点还没有提及到,大家知道是什么吗?想想看,整个环节还缺少了啥?

没错!就是请求的数据是如何从底层传递到我们的应用程序的?这也是我们下一节要讲的内容。

7、当有请求数据到来的时候

咦~从上面一路下来,貌似整个流程已经结束了,至始至终都没有看到任何和请求数据相关的,除了监听data事件,那么data事件是怎么触发的?是否底层调用和我们之前前置知识的最后一个步骤如出一辙呢?带着这么多疑问,我们将视线转到刚才有一个一笔带过的环节:new Socket

7.1、new Socket其实不简单

首先我们需要明确的一点是:

  • Socket实例是一个继承双工流的套接字,因此关于流的一切用法,在Socket上都可以用。

实例化Socket涉及到的一些流程如下所示:

看过这篇文章Nodejs流学习系列之一: Readable Stream的童鞋都知道,Socket实现了可读流的_read方法,也就是上图中用①标注出来的方法,_read是用于从底层读取数据缓存到可读流的缓存中。

上图中C++端的调用关系,请参考文件stream_base.ccstream_base.h,有点C++基础的可以看下源码,里面可以看到那些复杂的C++概念:虚函数、虚类、重载、友类等。我们在这里只提一点:

  • 为什么ReadStartJS调用了ReadStart的时候,走到了LibuvStreamWrap::ReadStart

    因为LibuvStreamWrapStreamBase的派生类,而StreamBase又是StreamResource虚类的派生类,在stream_wrap.h中声明了重载掉纯虚函数(int ReadStart() override):virtual int ReadStart() = 0;,所以你看到的调用关系才是上述流程图所示。

上述流程印证了我们在前置知识中提到的4.3步骤:

4.3、开始读取客户端请求的数据:uv_read_start()

我们给uv_read_start设置的分配缓存回调如同上述流程所示。

注意:上述图的C++空间中有一块颜色特殊的注释,我们给客户端的handle实例添加了一个onread回调,这点待会在下一节会有用到

7.2、当请求数据到来时

终于来到了我们整个环节的最后一部分,兴不兴奋?激不激动?能看到这里的童鞋都很赞👍!

这个环节也是so easy!奉上经典流程图:

看上图知道最后数据到来的时候,最终是会调用到js的终极函数:onStreamRead(stream_base_commons.js),该函数内部还有一些引用C++端的变量,有这么一张对应图:

js端将得到的缓存再通过②箭头所指的FastBuffer实例化一块后才调用①箭头所指的stream.push()方法。

调用这个方法有啥神奇的吗?线索又断了?😯,🙅‍♂️,看过这篇文章Nodejs流学习系列之一: Readable Stream的童鞋都知道,当往可读流中push数据的时候,在flow模式下是会自动触发data事件的,于是....

大结局来了?

还记得我们之前在connectionListenerInternal(_http_server.js)中监听的data事件吗?

socketOnData成了我们最后一个衔接其整个流程的最后一扣,该方法也是借助了我们在之前提到的parser,进行各种操作。

7.3、大结局之HTTP解析器

上面我们一笔带过了parser的分析,这一节终于轮到她粉墨登场了。parser是对C++端内建模块http_parser的实例化体现:

const parser = new HTTPParser()

实例化也就算了,js端还给其绑定了诸多的js回调:

parser.onIncoming = null;
parser[kOnHeaders] = parserOnHeaders;
parser[kOnHeadersComplete] = parserOnHeadersComplete;
parser[kOnBody] = parserOnBody;
parser[kOnMessageComplete] = parserOnMessageComplete;
复制代码

从字面上来看,是让C++端每解析完HTTP一块就需要告知js端一次。

socketOnData调用的是parser.execute(d),我们来看一下完整的解析器流程:

将数据传给C++端,利用llhttp的高效解析,得到的HTTP头部的信息,再回传给js端,之后emit事件给在一开始我们就监听的request事件的回调,从这里开始,你的应用代码才正式被执行,如此一气呵成!

这个时候,需要大家动动脑筋了:

为什么解析数据要搞得这么复杂?不能让C++端接收数据后一并解析完再返回给js端吗?非要将数据给js端、js端再给C++端、解析完又回传给js端,绕来绕去的~

欢迎大家留言讨论~

8、来个大总结吧

一图以蔽之来结束本文:

限于篇幅,无法面面俱到(诸如keepAlive、TCP分片之类的知识),如有想学习更多http服务器的内部实现,欢迎留言~

最后以这篇来结束在耗时两个月,网上最全的原创nodejs深入系列文章(长达十来万字的文章,欢迎收藏)立下的flag。希望整个系列对大家深入掌握nodejs有一定帮助!

感恩~

2019年的目标提前完成咯~

关注下面的标签,发现更多相似文章
评论