Node.js是如何构建的

242 阅读1小时+

这里说的构建也就是英文中的“Building”,构建往往除了包括项目本身的编译、打包等自动化操作外,还和持续集成紧密相连。这一篇主要讲编译、打包等自动化操作。持续集成部分将单独进行讲解。

“Node.js”的“Github”仓库中有一个项目github.com/nodejs/buil… ,这个项目并不是这次我们要讲的内容,这个项目是“CI”项目,也就是持续集成相关的东西,是用来驱动和管理“Nodejs.js”构建的,并不是构建本身。

常用构建工具

为什么要有构建环节呢?一个大型程序,所谓大型程序,我们暂且可以理解为是“程序源文件多到我们一下子看不过来的项目,而且需要支持多个系统环境,还有若干的依赖”,这样的程序我们就可以认为是大型程序了。要把这样的程序从源代码变成最终可交付的应用程序,会有成规模的构建相关的业务逻辑。因此构建也就成了一个重要的模块。 我们可以大致把构建环节分类成这几部分功能:

  • 编译管理
  • 系统环境适配
  • 依赖管理
  • 自动化测试
  • 安装
  • 流程自动化

可以看到除了软件主业务逻辑源代码之外,构建也做了大量的事情,要做这么多事情,如果都是由每个程序员自己去做就会有很多重复的工作,因此各种构建工具也就应运而生了。 下面我们先来看一下目前常用的构建工具:

常用构建工具

可以看到这其实是一张流程图,每一个流程都有对应的工具 整个流程是从左到右,大体上分四步。我们倒过来从最后一步往前看。

最后一步是安装,所谓安装就是将二进制程序安装到操作系统约定的位置。这一步不是必须的,因为可执行文件需要放到什么位置可能需要根据具体情况而定,也有可能会用来发布,而不是装到当前的编译机器上。

下面我们来看第三步,第三步是编译。不同的操作系统可能有不同的编译工具,图里面列出了几个主流的工具。“GCC”主要给“GNU/Linux”使用,“Clang LLVM”主要是“BSD”系列系统使用,包括“macOS”,“VS套件”是微软的一套编译工具。 这里需要说明的是,类“Unix”系统上的“GCC”在大型项目编译时使用起来还是挺不方便的,而“make”工具把“GCC”的调用集成到其内部了,类“Unix”系统上的编译这一步并不需要我们手动执行,而是在我们执行“make”时,就已经执行了编译。这里把它分化出来只是为了便于大家理解,因为它是最重要的一步,另外“Windows”系统上“VS套件”已经比较好用了,并不需要在上层再封装一个工具,因此在“Windows”系统上并没有第一步和第二步的工具。因此前面两个步骤主要是针对类“Unix”系统的。

第二步是“make”,为什么需要“make”这一步呢?一方面,由于大型程序的源代码之间会有很多依赖关系,有很多自动化脚本需要按顺序执行,因此编译时有些需要先做,有些需要后做,这时候使用“make”工具的“Makefile”文件可以很方便的约定这种依赖关系。另一方面,使用“make”还可以避免重复地编译,大大提高开发效率。 总之,有了“make”之后,我们的编译工作变得轻松了很多。“make”这个词本身也很形象:“制作”嘛,将源程序制作成应用程序。 “make”早在1976年就已经诞生了,不断的迭代演进,在这过程中也出现了各种替代产品,例如:“SCons”和“Chorium/Ninja”。

这还不够吗?对,还不够,我们的程序大部分时候是希望能够在不同的平台上运行的,例如:不同的处理器架构、不同的操作系统、不同的编译器。这就需要我们有不同的“make”,不同的“Makefile”,甚至不同版本的“make”程序。 因此第一步出现了,第一步早期时就是一个名叫"configure"的 shell 脚本,用来根据当前环境和传入参数来配置“Makefile”。但是后来发现“configure”脚本越来越复杂,“Makefile”也越来越复杂。已经有点不太适合人类编辑阅读了,于是又产生了协助生成“configure”脚本和“Makefile”的工具“Autoconf”和“Automake”,这两个工具其实依然很复杂,需要执行一系列步骤,后来又有了“CMake”,只需要编辑一个“CMakeLists.txt”文件,就可以自动生成“Makefile”。后来谷歌在Chromium项目中先后创造了GYP和GN,GYP被GN淘汰,而且GN彻底弃用了“make”。

这里要提一下的是另一个构建工具“SCons”,发音是“S” “Cons”,他彻底抛弃了“make”系列,主要通过“SConstruct”和“SConscript”这样一个“Python”脚本来配置和构建,它可以自动分析依赖,全面拥抱“Python”。

虽然“SCons”很特别,但它今天不是我们的主角。今天的主角是“Chromium/GYP”,对,被谷歌淘汰的“GYP”,它是我们“Node.js”的主要构建工具。“Node.js”的创始人曾经说过关于创建“Node.js”最后悔的10件事中,其中一个就是用了“GYP”,Node.js成为了“GYP”的唯一用户,连谷歌自己都不用了。

上面的图中蓝色框就是我们“Node.js”使用的构建方式。我们看第三步中的“VS套件”,在Windows系统上有自己的一套集成化的构建工具,并不太需要前面的“配置”、“Make”步骤,因此“Node.js”在“Windows”系统中构建时,是单独写了一个“vcbuild.bat”的“Windows”批处理文件。

获取Node.js源码

有了上面的概念之后,咱们现在来看一下“Node.js”具体的构建源码,我们这次主要讲类“Unix”系统的构建,也就是像“GNU/Linux”和“BSD”系列的构建(苹果的“Mac”也属于“BSD”的一员)。国内的大部分服务器都是类“Unix”操作系统。

我们从“Github”上克隆“Node.js”的仓库:github.com/nodejs/node ,由于很多分支都在不停的维护,时常有新的代码提交,为了和大家机器上的代码保持一致,我们选择一个“tag”来讲解代码,“tag”就像一个快照,可以作为一个发布点,“Node.js”每发布一个版本都会在仓库上打一个“tag”,我们选择“tag”: v18.17.1,使用“Git”命令:“git checkout v18.17.1”来切换到该“tag”的代码。

构建的入口文件

我们用编辑器打开代码项目,我这里用的“VSCode”,看一下项目的根目录。我们先看看文件夹,好像没有构建相关的文件夹,再往下看看文件。我们看到了“Makefile”,“node.gyp”,“node.gypi”,“configure”,“configure.py”,“common.gypi” "BSDmakfile"。这些就是“GYP”和“Make”相关的文件了。

看源代码我们得先找到程序的入口,从入口开始研究,我们看一下“Node”构建的文档:github.com/nodejs/node… ,找到“Building Node.js on supported platforms”这一节,可以看到他分了三部分,第一部分是“Unix和macOS”,第二部分是“Windows”,第三部分是“Android”,可以看到内容最详细的那部分是“Unix和macOS”。我们也主要看“Unix和macOS”部分。可以看到构建时,第一步是运行“./configure”,这就是构建的入口,我们就先从这个文件开始看起。

我们在源代码中找到“./configure”,打开。这里我们先说一下为什么非要有一个“./”的前缀才能执行“configure”,因为“shell”程序会判断当前执行的命令是否是一个内置命令或者一个绝对路径,如果都不是的话,它就会从当前用户的环境变量“PATH”指定的路径列表中去寻找具有当前命令名字的文件,如果找到了就会去执行该文件,关键是当前目录默认不会在“PATH”中,因此执行命令时是不会执行到当前目录下的这个文件的,如果在“PATH”指定的所有目录中都没有找到指定的文件,系统则会提示你“找不到该命令”。那怎么解决这个问题呢?每次都写绝对路径会很繁琐,幸运的是类“Unix”系统中有一个快捷字符就是“.”,它代表当前目录,因此“./configure”就代表当前目录下的“configure”,相当于一个绝对路径,这样“shell”就可以找到“configure”,并顺利执行了。

下面我们开始看“configure”的源代码,从第一行的“shebang”可以看到(“shebang”可以指定用什么程序来执行下方的脚本),这应该是一段“shell”脚本,可是继续往下看就越来越不像了。“import sys”很像是“Python”的语句,再仔细看整个文件,如果用Python来执行该文件,语法上没有任何问题。真正像是shell脚本的只有第6行到第15行。看一下这段脚本的内容,中括号中包含了一段”exec“命令,“shell”语言中的中括代表要启动“test”命令,而我们可以看到中括号结束后就不再是“shell”脚本了,而是“python”脚本,所以“test”命令后的内容必须要终止“shell”的执行,否则“shell”就报错了。再往后看,“test”中执行的“exec”命令,它的作用是执行一段“shell”脚本,用这段脚本替换当前的“shell”中的脚本,这样的话当前“shell”后面的脚本就不会再执行了,这正好符合我们预期。


现在我们看一下“exec”后面的“shell”,“-c”代表要把后面的字符串当作命令来执行。“command -v ...”用于查看当前后面所带命令在系统中的位置,“command -v python3.10”用于查看python3.10的位置,如果没有的话,该命令执行失败,会返回1,如果执行成功会返回0,如果返回0,则可以继续执行 “&&” 后面的内容,如果返回1则不执行,继续执行下一行。 很显然,这段“shell”在检测系统中是否有指定版本的“Python”。如果有的话,则执行"exec pythonx.x xxx",我电脑里的“Python”是“python3”,因此,第12行后面的“exec”会被执行,“$0“代表当前”configure”文件名,“$@”则代表传入的参数。 “exec”使用单引号包裹可以让“Python”认为这是一个字符串,而且“Python”支持用空格连接字符串,因此6到15行的语法对于“Python”来说只是创建了一个具有一个字符串成员的list,并没有太多实际意义,不会报错。但对于“shell”来说,这段则是在执行“exec”命令。 因此实际上“configure”这个文件是先被当作“shell”脚本执行,“shell”脚本找到指定的“Python”版本后,再换成用“Python”执行该“configure”文件。 其实完全可以单独创建一个“shell”脚本文件,找到合适的“Python”版本后,再执行“Python”文件。我追溯了当时的Node.js的“pull request”,得知其实这段代码的作者初衷是希望这段代码作为通用解决方案放到所有的“Python”脚本的开头,这样每次就可以自动寻找“Python”,而且工作量上没有变化,都是执行“./xxx”。

弄清楚这些之后,咱们就可以继续往下看了,下面很显然是“Python”代码。 25行,提示用户找到了某版本的“Python” 26行,定义了一个可以接受的“Python”版本的列表 27行 28行,判断如果当前的“Python”版本在可接受的版本之列时,则“import configure”。这个“configure”应该才是真正主要执行“Python”业务逻辑的地方。 30行以后,如果当前“Python”版本不在可接受列表之内,则要给出错误提示,提示用户只能使用这些版本的“Python”。它还利用“which” 方法去寻找当前电脑上所有可用的“Python”,然后把调用方式一一列出来。

configure.py文件分析

可以看到真正的主逻辑就在28行的“import configure”里。“import”会在当前目录寻找“configure”模块,“Python”规定以“.py”结尾的“Python”代码文件就是一个模块。在“configure”文件的同级目录下,我们找到了“configure.py”。打开看一下,代码量还是比较大的,一共两千多行。 对于这种代码量很大的文件,我们最好不要从第一行开始一行一行往下看,因为这会让你一上来就陷入到细节里,就像一下子掉入迷宫里一样。我们最好是先从外部来观察这个迷宫,先了解它的大体结构,然后以某个切入点为主线索,顺藤摸瓜。 所以总结一下,我们从两方面着手来研究这段脚本: 1.观察代码的全局 2.找一个切入点 好,想观察全局代码我们可以利用快捷键将所有代码折叠,我用的是“mac VSCode”,“command + k + 0” 可以折叠所有代码。

我们来看一下代码的大体结构。 先是导入模块,然后是变量初始化,然后开始配置程序的输入参数,“argparse”是“Python”自带的处理命令行输入参数的工具,可以看到这个自动化脚本有非常多的参数设置,我们先不看细节,继续往下看。

后面是一系列的函数定义。再后面从2049行开始是主逻辑开始的地方直到最后2185行。

现在全局代码的结构我们已经有大概了解了。而且主逻辑的代码并不多,我们就以主逻辑为切入点。

可以看到2049行定义了一个“output”字典,里面只有键,值都是空的。然后后面以“output”为参数调用了一系列“configure_”开头的函数,这些函数都没有返回值,基本可以肯定是这些函数将数据写进了“output”。

在“configure_”系列函数之前还有一个函数调用“check_compiler”,从名字我们也可以看出它是在检查编译器是否符合要求。我们进去看一下,可以看到它主要是调用了“try_check_compiler”函数和“get_llvm_version”函数,该函数执行编译器的命令,然后从返回信息中解析出编译器的版本信息。如果版本过老的话,会给出警告信息,最终某些编译器的版本信息被存入到入参中。

现在我们在返回到2058行。调用一系列“configure_”系列函数后,又针对“output”进行个别键的处理后,最终调用“write”函数,将“output”键值对的内容写进了一个叫“config.gypi”的文件。 其实这就是“configure.py”程序的主要作用,根据系统环境、命令行参数生成配置文件。

2122行,创建了“config.status”文件。往文件里写入了一段shell脚本:

#!/bin/sh
set -x
exec ./configure xxxxxx

可以看到这段脚本又在执行“configure”,参数来自于“original_argv”,“original_argv”在22行被赋值,代表当前所使用的所有参数的“list”。 这段脚本的意思是按照最近一次执行“configure”的参数再执行一遍。也就是说“config.status”记录了最近一次是如何执行“configure”的,当我们要重复构建时,不用再输入之前的参数,直接运行“config.status”即可。这是一个比较实用的功能。 2124行给“config.status”加执行权限。 2127行,定义了“config”变量。 然后将一些信息放入了“config”变量,然后用“=”将键值对一一对应,然后用换行符分隔,变成这样的形式:

[xxx=yyy, aaa=bbb]

2152行做了一些特殊的事情,在非“win32”环境下,执行“make_bin_override”函数,因为“Python”有不同的版本,例如“Python 3”,那调用命令可能就是“python3”,而之前的2.7版本,可能直接就是“python”,由于我们现在可能执行的命令是“python3.xx”这样的形式,而GYP内部是“python”这样的形式,为了统一使用指定的版本,“make_bin_override”函数中如果发现当前执行的“Python”和系统默认的“Python”不是同一个版本时,会创建一个符号链接指向当前所用的“Python”,然后2154行将该符号链接的路径加入环境变了“PATH”的最前面。这样后续再执行“python”命令时就会去执行我们现在使用的“Python”。这样也就解决了“GYP”中调用“python”的问题。

2156行,“config”变量的数据被记录进了“config.mk”,“.mk”后缀代表“Makefile”文件,“config.mk”文件会被另外一个“Makefile”文件包含,这个我们后面会讲。那“config”具体都是什么内容呢?“BUILDTYPE”指定要构建“Release”版还是“Debug”版,“NODE_TARGET_TYPE”全局搜索一下,可以看到是用来指定是要构建可执行文件还是静态库还是共享库,“PYTHON”指定当前“Python”的版本,“prefix”可以看85行,是指定安装的路径,默认是“/usr/local”,“BUILD_WITH”可以看731行,是指定是否用“ninja”进行构建。这些都是从命令行参数获取的。这里通过源码可以看到,如果我们想用“ninja”进行构建,需要这样“./configure --ninja”。

再往下看2160行,可以看到从这行开始,就已经在准备调用“run_gyp”函数。"run_gyp"是什么呢?我们看看他是怎么导入的,搜索“run_gyp”,找到第43行。可以看到”run_gyp“来自于“gyp_node包“,“gyp_node”看名字不像是“Python”内置的包,但是我们看同目录下也没有这个包。这时我们就要看看是不是某些目录被加进了“Python”的“sys.path”里。往上翻看,确实看到了“sys.path.insert”,39行,“tools”目录被加进了搜索目录,我们打开看一下,里面有“gyp_node.py”文件,该文件定义了“run_gyp”方法,该方法最终调用“gyp.main”。 打开“gyp_node.py”文件,从第9行和第10行可以看出,“gyp”包就在“tools/gyp/pylib”中,而“tools/gyp”就是“Chromium”项目的GYP工具,这里我们只是使用“GYP”,不准备去研究“GYP”的源代码,只需要知道大体怎么用就可以了。建议读者也不要去研究它,因为它已经是一个被抛弃的项目了。 “run_gyp”的代码并不多,从16行到47行,可以看到除了传入了几个普通参数外,我们可以关注一下21到23行,指定了三个GYP文件:“node.gyp” “common.gypi” "config.gypi"。这个应该就是GYP的配置文件。 总的来说,“gyp_node.py”对“GYP”的调用进行了封装,“configure.py”直接调用“gyp_node.py”。 现在我们再回到“configure.py”,看一下2163行,可以看到从2163行到2171行都在设置“-f”参数,“-f”参数用于指定“GYP”生成哪种构建工具。“GYP”是一个元构建工具,也就是说它是用来生成构建工具配置文件的工具,“-f”就是用来告诉“GYP”,需要生成哪种构建工具配置文件。 从代码中可以看出,可以生成“ninja”、“msvs”、“make”配置文件,如果没有指定用“ninja”,且又不是“Windows”平台,那就会生成“Make”配置文件“Makefile”,所以所有的类“Unix”系统包括“macOS”,最终都会使用“Make”或者“ninja”作为构建工具。

“gyp_node.py”中的“run_gyp”需要“gyp_args”参数,“gyp_args”又主要依靠“args”,我们搜索“args”,看到第870行,“args”来源于当前“Python”脚本的命令行输入参数。可以看到,运行“GYP”所需的参数可以通过命令行输入。

至此“configure.py”的主干逻辑就结束了。 我们再回过头来从全盘看一下“configure.py”,它其实主要做了这几件事: 1.接受命令行参数 2.配置各个库相关的配置,并最终写入“config.gypi”文件,“gypi”是GYP的文件格式,供“GYP”的“include”使用。 3.生成“config.gypi” “config.status” “config.mk”等文件 4.调用“GYP”

那我们现在还需要再研究“configure.py”的其它代码吗?

我认为目前是不太需要了,如果需要了解具体的配置,就打开具体的函数看就可以了,就像打开手册一样。

##了解GYP 好,现在我们知道“configure.py”最终调用了“GYP”的API,看来我们有必要了解一下“GYP”了,前面已经大概讲了常见的构建工具,这里我们再细说一下。 “GYP”,用其官方的说法,它是一个用来生成其它构建系统的构建系统,也叫做元构建系统。 这样说有点太抽象了。我们还是具体点,从头开始讲起。 大型项目有大量的文件、依赖还有配置在编译时需要管理,在以命令行为主进行交互的操作系统上(类Unix)或者我们想走自动化持续集成的流程时,如果都通过GCC之类的编译器在命令行来搞定这些就有些太麻烦了。 因此“make”诞生,“make”程序通过一个叫“Makefile”的文本文件来配置管理依赖,进行各种自动化操作,大大方便了构建工作。“make”在1976年就已经在贝尔实验室诞生了,应用及其广泛,几乎是Unix和类Unix系统的标配。 “make”应用虽然非常广泛,但是“make”的配置文件“Makefile”依然比较繁琐,功能划分不清晰,不够聚焦,跨平台也比较麻烦,后来就有人想到在它的基础上再封装一层,让配置文件变得更简洁清晰一些。最先出现的就是“configure”脚本,“configure”脚本对当前环境进行判断,然后生成适合当前环境的“Makefile”,“configure”和“Makefile”也几乎成了GNU/Linux的标配。

再后来有了“Autoconf/Automake”,再后面有了“CMake”,这些都让构建的配置工作变得越来越轻松。“Chromium”项目的“GYP”就是和“CMake”是同类别的产品,它通过可读性更强的配置来生成“Makefile”,功能划分清晰,以最终目标的不同来进行模块化。 另外“GYP”不但可以生成“Makefile”文件,还可以生成“Xcode”的项目文件、“Windows”的“Visual Studio”的解决方案文件和不同目标的项目文件。

还有一个“Build”工具则彻底抛弃了“make”,那就是“SCons”,用户主要是编写“SConstruct”文件。 我们今天的主角是“GYP”,现在大家应该对“GYP”是个啥有个大概的了解了吧?它确实就像它官方文档上所说,它是一个用来生成其它构建系统的构建系统。 “GYP”最为关键的就是“GYP”的配置文件,调用“GYP”的API时“GYP”会去按照指定的配置文件做事情,配置文件是一个“Python”字典格式的文件,很类似于“JSON”格式,只不过它可以有“#”开头的注释,结尾也可以加逗号。

下面我们来看一下“Node.js”项目中“node.gyp”文件。

##node.gyp文件分析 还是按照我们之前的方式,现将代码折叠起来,看一下文件的大体结构。 有四个键,“variable” “target_defaults” “targets” “conditions”。 “variable”顾名思义:变量。这里定义了要在“GYP”文件其他地方用到的各种变量。 “target_defaults”,“GYP”用“target”来表示我们最终要完成的目标,“targets”中每一个“target”都是我们要完成的一个目标,而“target_defaults”代表着每个“target”的公共部分,被每个第一层级的“target”公用,所谓第一层级“target”就是作为第一层“targets”的直接子“target”。 “conditions”代表某些特定条件下要做的事情。 我们展开“conditions”可以看到每个“condition”都用[]括起来。第一个元素是判断条件,第二个元素是配置内容,如果判断为真,则配置内容被采用。 可以看到第一个判断是说,当操作系统是”aix”且node_shared=="true"时,会有一个“node_aix_shared”的“target”,它的目标是生成共享库。

下面我们主要来看一下“targets”,这里是“GYP”文件的主逻辑。 展开“targets”,每一个大括号代表一个“target”。我们把每个“target”都打开看一下,可以看到大概有这么几个高频使用的配置: “target_name“ “type” “conditions” “defines” “includes” “include_dirs” “sources” “dependencies” “actions” 咱们来分别讲解一下。 首先是“target_name”,顾名思义,就是这个“target”的名字。在整个“GYP”文件中,它必须是唯一的。如果是“Windows”平台,它还会作为生成的项目文件的名字。如果是“Mac”平台,它会作为“XCode”配置文件中“target”的名字。 其次是“type”,它是指希望构建成什么类型的文件,是静态库还是动态库,还是可执行文件,还是什么类型都不是,静态库就是“static_library” 动态库也叫共享库就是“shared_library”,可执行文件就是“executable”,什么类型都不是就是“none”,“none”代表产出的文件不会被链接,例如一些文档文件。 “conditions”:顾名思义条件判断,刚才已经讲过了。 “defines”:定义C/C++中的宏 “includes”:“.gypi”文件列表,“.gypi”文件的格式和“GYP”文件一样,只不过通过“includes”,可以把它合并进来。 "include_dirs":相当于C/C++编译器在命令行传入“-I”,代表头文件的位置。 “sources”:这个应该是最终的一个配置。指定源代码文件。 “dependencies”:其它的“target”依赖,这里的“target”会先于当前“target”执行。 “actions”:顾名思义,动作,对指定文件的自定义处理动作

通过这几个配置,我们可以看出,要想知道某个“target”是干什么的,只需要看“target_name”和“type”这两个配置,其它配置都是为了达成目标而需要做的细节。 我们先依次看一下每个“target”的“target_name”和“type”配置。 第一个:

“target_name”: "node_text_start"type”: “none”

这个第一眼不太能看出来干什么的,我们先留着一会儿再说。 第二个: “<(node_core_target_name)”代表着一个变量,我们往上搜索,找到了它的值”node“,这里需要注意,这个变量名最后有一个“%”后缀, 官方文档中说,这个后缀表示,当“GYP”运行时,如果这个变量的值未定义,则此处设定的值被使用。也就是说,这个值其实是个默认值,它可以被改变。 因此:

“target_name“: 默认值是“node”
"type":“executable”

这个可能就是用来创建“node”可执行文件的 第三个: "<(node_lib_target_name)"也是一个变量,我们往上搜索,找到它的默认值是“libnode”,之所以说是默认值,是因为它也有“%”后缀, 再看“<(node_intermediate_lib_type)”,默认值是“static_library”。 因此:

“target_name”:默认值是“libnode”
“type”:默认值是“static_library”

这个用来创建“node”静态库,用于把“node”作为“library”使用的项目。 第四个:

“target_name”:“node_etw”
“type”:“none”

“ETW”是“Windows”下的一种追踪和事件记录的一种监控方式,“Windows”环境下“Node.js”很有可能使用它来进行运行情况或者性能的监控。

第五个:

“target_name”:“node_dtrace_header”,
“type”: “none
```”
“Dtrace”也是用于追踪程序运行的。
第六个:
```python
“target_name”:“node_dtrace_provider”,
“type”:“none”

也是“Dtrace”相关的。 第七个: 还是“Dtrace”相关的。 第八个:

“target_name”:“specialize_node_d”,
“type”:“none”

在往下看一下,看到了“conditions”,“node_use_dtrace”==“true”的判断,还是和Dtrace相关的。 第九个:

“target_name“:”fuzz_url“,
“type”:“executable”

“fuzz”是模糊测试,这个“target”和模糊测试相关。 第十个:

“target_name“:”fuzz_env“

还是和模糊测试相关。 第十一个:

“target_name”:“cctest”,
“type”:“executable”

有一个测试框架叫做“cctest”,这个“target”肯定是和“test”相关的。 第十二个:

“target_name”:“embedtest”

还是和测试相关的。 第十三个:

“target_name”:“overlapped_checker”
"type": "executable"

“overlapped”是“Windows”中异步IO的意思,因此这个“target”和异步IO有关 第十四个:

“target_name”:“node_mksnapshot”,
“type”:“executable”

“snapshot”是快照的意思,“V8”有一个快照功能,可以提高程序的启动速度,这个“target”是和这个功能相关的

一共十四个“target”,大家可以看到从第四个以后基本都是和测试监控相关的任务。 现在只有第一个任务一眼无法看出来是做什么的。第四个“target”之后的“target”基本都是和测试、监控相关。咱们先展开看一下第一个“target”。可以看到所有的内容都在“conditions”内部。当“OS”是“linux”、“freebsd”、或者“solaris”且处理器架构是“x64”时,会生成一个静态库文件,源码是一个“.S”后缀的文件。 “.S”后缀是汇编文件。为什么需要这个汇编文件呢?我们还是打开该文件看一下。只有8行代码。 前三行,判断如果是“ELF”文件,则关闭栈的可执行功能,如果没有这句话,“GCC”编译器会给出警告。 第四行“.text”,用于表明后续的指令需要放到代码区。这个咱们讲业务源码时详细讲。 第五行“.align”,“.align”用于内存对齐, 第六行“.global”将"__node_text_start"这个“label”定为可全局访问。 第七行“.hidden”,告诉编译器,当前文件作为动态链接库时“__node_text_start”这个“label”不可被外界所见。 第八行,定义了“__node_text_start”这个”label”。

“label”本质上就是一个内存地址。到此结束。那这段程序想要做什么呢? 它最重要的动作就是:

  1. 以静态的方式向外界暴露了“__node_text_start”这个“label”。
  2. 对该label进行了内存对齐。

所以单看这8行代码,并没有做实质性的事情,我们待后续的业务代码解析时再结合其它部分来研究这部分代码。

现在咱们接着往下看“GYP”中的“target”。 第二个“target”,“type”是“executable”,说明是可执行文件,不出所料的话,这个就是生成“Node”可执行程序的“target”。展开该“target”,看到“sources”:“src/node_main.cc”。“node_main.cc”是“Node.js”程序的入口文件。“sources”中只有一个“node_main.cc”文件。其它文件都放到哪里去了呢?看来我们需要仔细看一下其他的配置项。"defines",定义了几个C++中的宏。第一个宏“NODE_ARCH”,看样子是CPU架构。“target_arch”是一个“GYP”变量,我们搜索整个“GYP”文件,并没有找到他的定义。如果它是指“CPU”架构的话,很有可能是从“configure”中获取的,因为“configure”的一个重要职能就是根据当前系统环境生成对应的配置。咱们打开“configure.py”,搜索“target_arch”,可以看到它在“configure_node“中被赋值。1270行,它的值有可能是来自“options.dest_cpu”或者“host_arch”。看一下"options.dest_cpu",我们继续搜索“options”,看看它是来自哪里,看到870行,“options”来自于“parser.parse_known_args”,而“parser”,可以看到45行,它是“Python”用来设置命令行参数的对象。因此“options”在来自于命令行,我们再搜索"dest_cpu",看到109行,确认了其来自于命令行参数。现在可以理解1270行了,优先从命令行获取“target_arch”,如果没有则使用“host_arch”。 "host_arch"来自于1269行,"os.name"是“nt”时,说明是“Windows”,调用“host_arch_win()”函数来获取“host_arch”。“host_arch_win()”可以看到通过环境变量来获取“CPU”架构。这里需要主要注意的是“AMD64”,当你的电脑是“Intel”的64位“CPU”时,“%PROCESSOR_ARCHITECTURE%”依然是“AMD64”,并不是操作系统错了,而是基于X86架构基础上的64位扩展是“AMD”先搞出来的,“Intel”也采用了这个技术,所以“Intel”把“X86_64”叫做“AMD64”。因此“AMD64”只是处理器架构的名字,并不代表处理器的品牌。 另外还可以看到“x86”被改名成“ia32”,因为基于“X86”的32位架构也是“X86”架构的延伸,“X86”一开始是16位的。 我们继续回到1269行,如果当前不是“Windows”,则调用“host_arch_cc()”。"host_arch_cc"函数中调用了"cc_macros",“cc_macros”在子进程中执行了“shell”:“cc or CC”,这个是指编译器的命令,那我们可以用哪个编译器呢? 看一下“cc”,“cc”是函数参数,“cc_macros”的参数是“os.environ.get('CC_host')”,“CC_host”如果没有设置的话,“cc”就不存在,回去读取大写的“CC”。 26行定义了“CC”,优先获取“CC”环境变量,如果没有,则判断如果是苹果的“darwin”,就是字符串“cc”,如果是其它系统,就是“gcc”。 不管是“cc”还是“gcc”,他们的命令行所需参数都极其类似。 现在我们再回到“cc_macros”。我先假设 “cc or CC”就是“gcc”,这个和你本地环境有关。这里执行的shell命令是这个:“gcc -dM -E -”,如果我们要直接在命令行执行,需要这样输入:“gcc -dM -E - < /dev/null”,最后的横杠代表需要从标准输入中获取用于保存返回数据的文件名,而我们只是为了获取预定义宏,所以并需要有一个实际的文件。所以我们用/dev/null代替。“Python”函数中并不需要这个“/dev/null”是因为“Python”的“communicate”函数自动终止了标准输入,所以也就不需要这个“/dev/null”了 这个命令用于获取gcc预定义的宏。最终“cc_macros”把这些宏的名称和值以键值对的形式返回了。

大家如果尝试把这个命令在命令行运行,会发现预定宏大部分都是前面两个下划线开头,后面两个下划线结尾。 我们再回到“host_arch_cc”,可以看到定义了一个字典映射,里面全部是“CPU”架构的名称,将有下划线的变成没有下划线的名称。“gcc”的预定义宏中可以获取到当前电脑“CPU”架构。因此这个“host_arch_cc”函数做了两件事,第一是获取当前电脑的“CPU”架构,第二是将“CPU”架构名称的下划线去掉,变成常用的架构名,然后返回。现在我们再返回到"configure_node"。可以看到"target_arch"就是这么获取到的,并且最终传给了“o“的”variable“属性,”o“最终传给了“GYP”,因此这个”target_arch“就是“GYP”的变量“target_arch”。 现在我们再回到“node.gyp”。 145行,“NODE_ARCH”这个宏定义的源头我们已经找到了。 下面看一下“NODE_PLATFORM”,它的值是变量“OS”,“OS”是“GYP”的预定义变量,可能的值有“linux mac win”等等,根据你当前操作系统而定。 再看“NODE_WANT_INTERNALS”,被设置成1,这个宏定义主要用于“Node.js”的“C++”模块,具体内容我们在讲“Node.js”主业务逻辑源码时再详细讲。 “defines”到这里就结束了。 下面是“includes”,“node.gypi”可以被理解为模块化的“GYP”配置文件,一个“GYP”文件的片段。后缀上的“i”字母可以理解为“include”的意思。 我们打开“node.gypi”看一下,先将代码折叠,可以看到它仍然是个“JSON”结构的“Python”字典。第一层就两个配置项,“variables”、“conditions”。我们先看一下“variables”。可以看到这个结构还是有些奇怪的,有三层的“variables”嵌套。我们再仔细看,可以看到,其实它只做了一件事,就是设置“force_load”。用逻辑判断的方式去设置“force_load”。可以看到第一层是“variables”和“force_load”,“force_load”的值就是“force_load”变量,而variables中有“variables”、“conditions”和“force_load”,“conditions”中也是在给“force_load”赋值,“variables”中给“force_load”设置了初始值。为什么要搞这种嵌套的“variables”呢,如果不嵌套,像这样子去写不可以吗?

{
    'variables': {
        'current_type%': 'executable',
        'xxx': '<(current_type)'    
    }
}

答案是:“不可以”。

“GYP”中如果要获取默认值,只能从同级别的“variables”中定义的默认值获取,同级别的变量默认值无法获取。“GYP”的官方文档并没有相关说明,所以这个无法确认是设计如此还是“GYP”的bug。 好在“GYP”是开源的,我们通过分析“GYP”的源代码,分析并验证出以下规则:

  1. 首先只能访问本层“variables”配置中直接子的变量,当前层级是无法访问同层级“variables”的子“variables”中的变量的,举例:
{
    'variables': {
        'variables': {
            'variables': {
                'current_type': 'executable',
            }        
        },
        'xxx': '<(current_type)'
    }
}

这段代码将会报:"current_type"是“undefined”。因为“current_type”不是本层“variables”配置项的变量,而是里面嵌套的“variables”的变量。

{
    'variables': {
        'variables': {
            'current_type': 'executable',
        }
        'xxx': '<(current_type)'
    }
}

以上代码正确,“xxx”可以成功获取到“executable”。

  1. 默认值变量(带%后缀的变量名的变量)不能被同层访问,举例:
{
    'variables': {
        'current_type%': 'executable',
        'xxx': '<(current_type)'    
    }
}

以上代码中,'xxx'的值'<(current_type)'是undefined。该“current_type“只能被“variables”同层级访问,例如:

{
    'variables': {
        'current_type%': 'executable',
    },
    'targets': [
        {
            'target_name': 'experiment',
            'type': 'executable',
            'defines': [
                'type="<(current_type)"'            
            ],
            'sources':[
                'src/main.cc'            
            ]
        }    
    ]
}

如果“current_type“没有另外设置,第10行将会获取到默认值"executable"。

如果我们希望"xxx"也能获取到“current_type”的默认值,我们只能这么写:

{
    'variables': {
        'variables': {
            'current_type%': 'executable',
        },
        'xxx': '<(current_type)'
    }
}

就是在“xxx”的同层级再写一个“variables”。

这里还有一个问题,举例如下:

{
    'variables': {
        'variables': {
            'xxx%': 'executable',
        },
        'xxx': '<(xxx)'
    }
}

这个配置看似没问题,我们期望“xxx%”默认值能够被变量“<(xxx)”所用,但其实“xxx”无法获取到值。因为GYP在处理默认值时,会判断对应的非默认值变量是否已经存在,如果已经存在,则不会将默认值赋值给非默认值变量。在这个例子中,非默认值就是“xxx”,按照GYP的代码逻辑,此时已经有“xxx”存在了,因此“xxx%”默认值便不会被复制给“xxx”,因此“<(xxx)”不存在,“xxx”也无法获取到期望的值。 那对于这种名字相同的情况怎么处理呢?示例代码如下:

{
    'variables': {
        'variables': {
            'xxx%': 'executable',
        },
        'xxx%': '<(xxx)'
    }
}

对,只需将“xxx”变成“xxx%”,这样“GYP”在处理内层“variables”中的“xxx%”时,会认为外层并没有“xxx”变量存在,有的是“xxx%”,由于GYP是先处理最里层的”variables“,因此这是还没有正式的”xxx“变量存在,这时,”xxx%“便会赋值给外层的”xxx“,这样就有了变量”xxx“,这样外层的”xxx%“也就有值了,而根据GYP的规则,该默认值可以被其外层使用,因此”target“就可以使用”xxx“了。

不过仅仅限于默认值变量会有这些限制规则,普通变量则没有这个问题: { 'variables': { 'current_type': 'executable', 'xxx': '<(current_tpe)'
} } 这种情况是可以正常访问的。 这些很难说是“GYP”的bug,还是说“GYP”就是这么设计的。总之使用起来还是挺容易让人迷惑的。

现在我们再回到“node.gypi”。 可以看到“variables”中定义了“force_load%”,由于“force_load%”需要经过条件判断,因此我们就不奇怪会有若干的嵌套“variables”了。如果把这些嵌套去掉,就会碰到我们上面说的问题。可以看到这段“variables”实际上是做了一个逻辑判断,如果“current_type”是“static_library”或者“current_type”是“executable”且“node_target_type”是“shared_library”时,则“force_load”为“false”。其它情况“force_load”为“true”。 我们分别来解释一下这几个变量的意思。 “current_type”是“_type”变量,根据“GYP”的规则,下划线开头的变量是“GYP”的自动变量,“GYP”配置文件字典中的任何“key” “value”键值对都会有一个对应的下划线开头的自动变量,下划线后面是键名。因此“_type”实际上就是“type”。另外我们看到“node.gypi”是被包含在“node.gyp”的很多“target”中的,每个“target”都会有一个“type”,因此“_type”实际上就是“target”的“type”。 因此“current_type”是“static_library”时,意思就是在构建静态库,“current_type”是“executable”时,意思就是构建可执行文件。 “node_target_type”是“share_library”时,代表什么意思呢?我们进入“node.gyp”,搜索到“node_target_type”,可以看到“node_target_type”是“shared_library”时,只要不是“AIX”系统,“node_intermediate_lib_type”就是“shared_library”,而“node_intermediate_lib_type”是什么呢?从字面意思大家应该也能猜出,它代表“Node.js”的库文件形式,就是当你想把“Node.js”构建成库文件以便供“Electron”这类的产品使用时,“node_intermediate_lib_type”可以指定库的类型。 ”node_target_type“是“shared_library”时,“node_intermediate_lib_type”也会是“shared_library”,即共享库。

“force_load”我们搜索一下整个文件,可以看到,它代表着“xcode”里的"force_load",linux里的“--whole-archive”,“Windows”里的“WHOLEARCHIVE”参数,而这些参数的意思是,当我们构建可执行文件或者共享库时,如果使用到了某些静态库,那就需要全部加载这些静态库,如果没有这些参数,链接器会去除那些从没有被引用的object files,以达到减少目标文件体积的目的。 我们看到代码中,将“Libuv” “Zlib”这些库都全部加载进来,主要是为了给用户插件使用的,有些函数“Node.js”本身没有使用,但是用户插件可能会使用,所以最好还是把这几个必要的库进行全加载。

现在我们再回过头来看“variables”中的这段内容的真正含义,有两种情况“force_load“不会开启:

  1. 当要把Node.js构建成静态库时
  2. 当用Node.js共享库构建Node.js构建成可执行文件时 为什么是这两种情况呢? 第一种情况,构建静态库是不需要“force_load”的,因为静态库本来就是全部打包的,静态库就是一堆“object files”的集合。 第二种情况,由于“Node.js”的共享库构建时会使用这几个参数将几个静态库都包含到共享库中,因此构建可执行文件时就不再需要了。

根据这些我们可以反过来推一下,什么时候会开启”force_load“呢? 就是当构建“Node.js”共享库时和利用“Node.js”静态库构建Node.js可执行文件时。

这就是整个“variables”的全部意义。

整个“node.gypi”就两部分,一个是“variables”,另一个是“conditions” “conditions”部分我们不准备细讲,因为里面基本上是针对各个操作系统和编译工具的细节配置,并没有核心逻辑。

我们可以注意到“node.gypi”有“conditions”,“node.gyp”中也有很多“conditions”。那为什么要单独分出一个“node.gypi”呢? 我们在“node.gyp”中搜索“node.gypi”,可以看到有8个“node.gypi”,都在各个“target”中。说明“node.gypi”是一个公用的gyp文件,多个“target”都会使用,如果有”target“公用的配置就应该放到”node.gypi“中。

现在我们再回到“node.gyp”文件的第二个“target”的“include_dirs”配置,这里指定了头文件的位置,它等同与编译器的“-I”或者“/I”选项。 继续往下看“sources”配置,这里应该放这次构建所需的所有文件,包括源码文件和“readme”文件。 “dependencies”配置,一个所依赖“target”的列表。 “msvs_settings”下面是Windows下的链接器设置。GYP中平台相关的配置名都会有一个平台相关的前缀。 “msvs_disabled_warnings“,”msvs“开头的,很明显是Windows的配置,该配置用于关闭警告,这里的警告是C4244。 “conditions”中有一大堆零零散散的配置,咱们看一下核心的配置。 咱们看197行,“node_intermediate_lib_type”,这个变量从名字上也能理解,它指定了“Node.js”库的类型,是静态库还是共享库。当前“target”其实就是构建“Node.js”可执行文件的“target”,但是可以看到“sources”只指定了一个文件,很显然这不是“Node.js”的全部,因为“Node.js”的其它文件都是在另一个“target”中构建的,会打包成静态文件或者动态文件,这个“target”就是下一个“target”,它的“type”就是“node_intermediate_lib_type”变量。 往上搜索“node_intermediate_lib_type”,可以看到它只有两个可能的值“static_library”和“shared_library”。默认值是“static_library”,“node_shared”是“true”时,会被设置成“shared_library”,这里有个特例就是“AIX”,“AIX”需要先生成静态文件,然后和“exp”文件一起才能生成“shared_library”。 “node_shared”可以通过"configure.py"文件进行设置,打开“configure.py”可以看到“node_shared“来在于”options.shared“,而“options.shared”来自于命令行参数,因此,如果想生成Node.js共享库,那就是这样“./configure --shared”。 我们再回到“node.gyp”的197行,可以看到,当是“AIX”系统且要使用Node.js共享库时,会依赖“node_aix_shared”这个“target”。这个“target”从名字也可以看出来,它是用来生成“AIX”上的共享库的。如果不是AIX系统,则会依赖“<(node_lib_target_name)”,这个我们搜一下可以看到就是那个生成“Node.js”库文件的“target”。在非“AIX”系统上,就是要依赖这个“target”生成库文件。 下面的内容则是根据“Windows”和“Mac”系统分别进行了配置,用于将生成的库文件链接进来,最终生成当前“target”的任务:可执行的“Node.js”文件。

这些就是“<(node_core_target_name)”这个“target”的核心内容。“<(node_core_target_name)”这个变量的默认值是“node”,我们生成的“Node.js”可执行文件的文件名也就是“node”。

下面我们再看一下另一个主要的“target”,就是刚才说的生成“Node.js”库文件的那个“target”,大部分情况下主要靠这两个“target”来构建“Node.js”。 现在我们可以看到,“Node.js”可执行文件的构建总是会分两步构建,先构建“Node.js”的库文件,然后才是基于库文件构建可执行文件。库文件可以供其他软件内嵌使用,例如:“Electron”。该“target”中也是有不少零碎的设置,光这些设置本身逻辑并不复杂,由于我们这里主要讲构建核心逻辑,因此我们准备跳过这些零碎设置,后续讲到具体功能时,可能就会提到这些设置。

##将Javascript模块代码转成C++ 这里我们主要说一下“node_js2c”这个“action”。 相信我们大部分人都或多或少了解过,“Node.js”中的内置的“Javascript”模块会以“C++”的形式被编译进Node.js可执行文件中。 那“Javascript”是怎么转成“C++”的呢?其秘诀就在这个“node_js2c”的“action”。“action”的作用是执行自定义的构建动作,通过对输入进行处理,最终产生输出,其实整个构建操作也就是在做这个事儿。“action”的配置也是一个字典,关键的几个配置字段有“action_name” "inputs" "outputs" "action"。 “action_name”很好理解,就是个名字,要保持唯一性。 “action”是命令行调用的命令。 “inputs” “outputs”用来指定输入和输出的文件路径。 看似有些没必要,因为“action”可以执行任何指令,输入和输出从“action”里指定就可以了。 但是指定“inputs” “outputs”可以避免重复的执行“action”,当“outputs”没有变化时,构建系统可以不用重复地去执行“action”中的操作。 “action”的操作会在编译之前执行。这里需要注意的是,“action”操作其实不是“GYP”执行的,“GYP”只是将其放到真正的构建系统里合适的位置。

现在我们看一下当前这个“action”的具体内容。可以看到,主要就是用“Python”执行“tools/js2c.py”。咱们进这个“Python”文件看一下。可以看到执行入口是“main”函数。“main”函数先是获取命令行的参数。“target”是输出文件的路径,我们回到“node.gyp”,看970行,可以看到“target”是“<@(_outputs)”,带“@”前缀的变量在“GYP”中的意思是后面的“_output”必须是一个“list”,且会将此“list”展开,“GYP”中带下划线前缀的是自动变量,他代表上面962行的“outputs”,“outpus”确实是一个“list”,它指定了一个“node_javascript.cc”的文件,路径是“<(SHARED_INTERMEDIATE_DIR)”变量,这个变量是“GYP”内置的,代表着“GYP”的各个“target”间可以共享的一个用于放置中间文件的目录, 所谓中间文件,就是指在构建过程中产生的临时文件,“GYP”文档中并没有指定“<(SHARED_INTERMEDIATE_DIR)”的具体位置,我们通过翻看它的源代码会发现不同的平台会有不同的位置,我们拿Linux平台举例,它的位置是:“out/(BUILDTYPE)/obj/gen”,“(BUILDTYPE)/obj/gen”,“(BUILDTYPE)”是指“Release”还是“Debug”,假设是“Release”,那最终“node_javascript.cc”文件的路径就是:“./out/Release/obj/gen/node_javascript.cc”。

我们再继续往下看其余参数,“config.gypi”和“<@(deps_files)”,“deps_files”是一个“list”变量,咱们搜索看一下它的定义。它的定义在顶层的“variables”中,可以看到它指定了一些“Javascript”依赖。“mjs”后缀在Node.js中指的是ES6模块,也就是说这些文件里是用“import” “export”导入导出的。我们再回到“js2c.py”这个“action”。

“<@(deps_files)”这个list会被展开,和“config.gypi”一起作为位置参数传给“Python”,位置参数是“Python”命令行传参的一个概念,命令行中没有“key”,只有“value”。

下一步咱们进入“js2c.py”文件,看看它具体都做了什么。同样咱们还是先将代码折叠起来,然后找到程序的入口。该文件的入口在最后一行,“main”函数的调用。我们进入“main”函数,可以看到首先在获取命令行参数,其它参数的获取都比较简单,就是直接获取命令行参数,然后存入变量,“verbose”还被存入了全局变量,这个相信大家也能猜到了,如果你想看运行的详细日志,就将该参数设为true。“source”这个参数有些特别,进行了一些处理后才最终传给了“JS2C”函数。咱们看一下它的具体逻辑。 首先“sources”参数是位置参数,位置参数的值是个“list”,那它的成员就是“action”中的“config.gypi”和“<@(deps_files)”展开之后的成员。 即

[
    'config.gypi', 
    'deps/v8/tools/splaytree.mjs',
    'deps/v8/tools/codemap.mjs',
    'deps/v8/tools/consarray.mjs',
    'deps/v8/tools/csvparser.mjs',
    'deps/v8/tools/profile.mjs',
    'deps/v8/tools/profile_view.mjs',
    'deps/v8/tools/logreader.mjs',
    'deps/v8/tools/arguments.mjs',
    'deps/v8/tools/tickprocessor.mjs',
    'deps/v8/tools/sourcemap.mjs',
    'deps/v8/tools/tickprocessor-driver.mjs',
    'deps/acorn/acorn/dist/acorn.js',
    'deps/acorn/acorn-walk/dist/walk.js',
    'deps/cjs-module-lexer/lexer.js',
    'deps/cjs-module-lexer/dist/lexer.js',
    'deps/undici/undici.js',
]

其次看到“directory”作为文件夹路径传入了“SearchFiles”,分别寻找了里面的“js”文件和“mjs”文件,“mjs”在Node.js中是指“ES6”模块文件。然后这些文件和“options.sources”一起放到了“sources”变量中。我们看一下“directory”到底是啥。“directory”在命令行中是“lib”,也就是说“lib”目录下的“Javascript”会被找出来。我们打开“lib”目录,可以看到这个“lib”目录就是“Node.js”中内置的“Javascript”模块。因此“js2c.py”中的“sources”变量中存储了我们所有需要放到“C++”文件中的“Javascript”文件路径,以“list”形式存储。下面我们看231行,对“sources”变量进行了处理。这个“reduce”和Javascript中的“reduce”功能类似,它们都是函数式编程中“reduce”的概念。通过“SourceFileByExt“这个函数对“sources”中的成员数据进行串联式的处理,最终得到一个归纳数据。咱们看一下“SourceFileByExt”函数。逻辑非常简单,用文件的扩展名作为键名,用对应的文件名作为值,由于会有多个相同后缀的文件,因此文件名是一个list,形成一个“dictionary”返回。 因此“source_files”是这样一个结构:

{
    '.js':['lib/_http_agent.js', 'lib/_http_client.js', 'lib/_http_common.js'],
    '.mjs': ['deps/v8/tools/splaytree.mjs', 'deps/v8/tools/codemap.mjs'],
    '.gypi': ['config.gypi']
}

这里并没有把所有文件列出,主要是为了说明这个数据结构。

目前根据“sources”的内容看,一共只会有三种文件类型“.js” “.mjs” ”.gypi“。238行,又对“source_files”新加了一个键值对:{'config.gypi': 'config.gypi'}。 下面我们进入“JS2C”函数,核心逻辑从这个函数开始了。 首先分别遍历“.js”和“.mjs”,调用“AddModule”函数。"AddModule"首先调用“ReadFile”读入“Javascript”文件内容。然后调用“NormalizeFileName”将文件名进行了统一处理,"deps"下的文件,统一在“deps”前加“internal”。“lib”下的文件统一把“lib”去掉。最后再把后缀去掉。然后将“.” “-” “/”替换成“_”。最后又在名字最后加上了“_raw”,最终赋值给了变量“var”。 我们举一个例子,例如“Javascript”文件“lib/internal/webstreams/util.js”,经过“AddModule”处理后,变为“internal_webstreams_utils_raw”。最终“var”变量和“Javascript”文件内容一起赋值给了“GetDefinition”。我们进入“GetDefinition”。可以看到“GetDefinition”先将“Javascript”代码的所有字符全部转成了“Unicode”码,“Unicode”码有一个特点就是“Unicode”码包括了“ASCII”码, 带着这个知识我们继续往下看,下一步寻找出了所有码值大于127的字符,127是“ASCII”码值的上限,也就是说会对非ASCII码进行处理。继续往下看,“bytearray”这个方法用于获取“source”这个字符串的“utf-16le”的编码。他是想要做什么呢?我想我们还是先说一下这段代码的目的,然后再来理解这段代码可能会更清晰一些。“js2c”其实在把Javascript代码字符转化成“uint16_t”或者“uint8_t”类型的整形数据,16代表16比特位,8代表8比特位。 为什么要转成这种形式的数据呢?都是因为“V8”的API的要求,“V8”有两个特殊的构建“Javascript”中“String”类型数据的API:“ExternalOneByteStringResource” “ExternalStringResource”,通过这两个API构建的“Javascript”字符串可以不用拷贝到“V8”的“heap”(堆内存),其他的“V8” API构建的“Javascript”字符串会拷贝到“V8”的“heap”,这个是需要一定时间的。因此“Node.js”中使用这两个API来构建“Javascript”字符串,而它的入参则分别要求是“Latin1”编码的“char*”和“uint16_t *”。 “Latin1”是兼容“ASCII”码的单字节编码。 所以如果我们将“Javascript”代码字符以上面说的格式传给这两个API,由于我们内置的“Javascript”模块代码量也不小,用这两个API可以省去往“V8”虚拟机的“heap”堆内存中拷贝的过程,是可以提高效率的。 因此“JS2C”可以把“Javascript”代码字符要么都转成“ASCII”码,要么都转成“uint16_t”,这两种情况都可以满足“V8”那两个API的要求。 “JS2C”也确实是这么做的。在96行,判断每个字符的“Unicode”码是否有大于127的,如果有说明当前“Javascript”文件代码字符中有非“ASCII”字符,这时需要将所有字符转成“uint16_t”。97行将“template”设置成了“TWO_BYTE_STRING”,“TWO_BYTE_STRING”从定义看,是一个模版字符,其实际内容是“C++”代码,可以看到它定义了一个“uint16_t”的数组,数组变量名和内容需要实际填入。我们继续往下看,调用“bytearray”函数获取“source”中字符的“utf-16le”编码,“utf-16”的大部分字符都是两个字节,“le”代表“little endian”小端字节序,所谓小端大端,我们举一个例子,例如对于一个具有两个字节的数据:“0XABCD”,这是一个十六进制的数字,我们按照字节长度来分的话,它可以分成两个字节,分别是“AB”和“CD”,从数字的角度看,“AB”属于高位,“CD”属于低位,由于高位数字的变动会导致整个数字更大的变动,因此最高位在英文中也叫“MSB”(most significant bit),最低位在英文中也叫“LSB”(least significant bit)。所谓小端字节序,就是当存储数据时,最低位的字节会放到内存的起始位置,次低位字节会放到内存次第二起始位置,以此类推,而大端字节序正好相反,最高位的字节会放到内存的起始位置。大家可以这样记忆,所谓小端字节序,就是先从低位字节开始,所谓大端字节序,就是指先从高位字节开始。关于字节序咱们就讲这么多。如果大家听的迷糊的话,咱们可以另开一个专题专门讲,因为毕竟当前主题是“Node.js”。 "utf-16le"是小端字节序,因此低位字节在前面,咱们所说的前也可以理解为左面、数组的更小索引。假设现在我们现在有一个字符“中”,其“utf-16”的编码是:“0x4e2d”。当我们要往内存中存储字符“中”时,就涉及到大小端的问题, 如果我们按照小端序存储,那它在内存中就是这样的顺序:“0x2d 0x4e”, 假设我们还有一个字符“国”,其“utf-16”的编码是:“0x56fd”,但是我们如果按照小端序存储,那它的顺序就应该是:“0xfd 0x56”,因此“中国”这两个字符,其真实的编码应该是:“0x4e2d 0x56fd”,但是小端序是这样的:“0x2d 0x4e 0xfd 0x56” 现在我们再看看代码中的99行,“bytearray”方法拿到了“Javascript”代码字符串的“utf-16”的小端字节序编码,构造了“bytearray”对象赋值给了“encoded_source”。“bytearray”对象按照“Python”文档的说法,它其实是个整型类型变量的序列,和“list”类似,也可以通过数组索引的形式进行访问,其中的值就是整型数据。这个整型数据就是“utf-16”编码的字节数据。由于“utf-16”编码是两个字节长度,因此“encoded_source”序列每两个连着的元素代表一个字符, 例如:encoded_source[0] encoded_source[1],代表一个字符,encoded_source[2] encoded_source[3]代表一个字符,只是我们获取的是“utf-16le”,小端字节序,低位字节在前面,因此字符的真正编码应该是:encoded_source[1] encoded_source[0],下一个字符是encoded_source[3] encoded_source[2]。

我们现在回到代码,看102行,用range方法按照2的步长,获取到这样的序列:0 2 4 6 8等,上限是“encoded_source”的长度。第101行,将带+1索引的序列乘以256与不加1索引的序列相加。也就是说,encoded_source[1]乘以256 再加上 encoded_source[0],encoded_source[3]乘以256 再加上encoded_source[2],以此类推。也就是说,每个字符的高位字节乘以256再加上低位字节。我们从二进制字节的角度来考虑问题,乘以256就等于乘以2的8次方,在2进制中,乘以2的八次方,就等于在现有二进制数字后加8个0。 例如,有一个字符的UTF-16的二进制编码是:“10011101 10110011”,中间放一个空格将两个字节隔开是为了方便大家看。另外为了方便大家分辨,我们将其转成十六进制,转16进制可以通过Javascript转一下:

var highNum = 0b10011101
console.log(highNum.toString(16))
// 9d
var lowNum = 0b10110011
console.log(lowNum.toString(16))
// b3

转换后的值是“9d b3”,这样就更好对比了。这个编码的小端字节序是这样的“0xb3 0x9d”,倒过来,低位字节放到前面。而“encoded_source”中存储的就是类似这样的倒过来的小端字节序 如果我们要把“encoded_source”中的小端字节序变成正常的编码数字,那就需要做下面的操作 将“0x9d”这个高位字节乘以256,然后再加上低位的“0xb3”,最终就可以获取到一个完整整数表示的“utf-16”编码。 为什么是这样呢,我们用二进制的形式来看一下, 二进制字节:0b10011101,乘以2的8次方后,就会变为:1001110100000000。 这个时候再加上低位的字节0b10110011,正好填补在新加的0的位置,变成0b1001110110110011。 2的8次方用10进制表示就是256,因此就有了101行的做法。

因此当Javascript源代码中有非“ASCII”字符时,“code_points”这个数组拿到的是“Javascript”源代码字符的“utf-16”编码。当“Javascript”源代码全部是“ASCII”码时,“code_points”数组存储的是代码字符的“ASCII”码。 到这里基本上最核心复杂的逻辑就结束了。下面就是一马平川了。

第106行,将code_points的每个成员格式化成3个字符,产生新的数组“elements_s”,原本少于三个字符的用空格在前面补齐。 108行,将“elements_s”切割成每个30个元素形成一个子数组,并赋值给“slices”,类似于这样的形式:slices = [[' 47', ' 47', ' 32', ' 67'...], ['111', '112', '121',...]]。 109行将“slices”中的子数组处理成用逗号连接的字符串,重新形成新的数组“lines”。 类似于这样的形式:lines = [' 47, 47, 32, 67 ...', '111,112,121,114...']。 第110行将lines的各个元素以逗号和换行符链接,变成这样的形式,赋值给“array_content”。 第111行,将“var”和“array_content”赋值给“template.format”。 “var”就是咱们之前讲的“Javascript”代码文件名经过处理后的字符串,类似于这样,例如有Javascript文件“_http_agent.js”,则“var”就是“_http_agent_raw”。“template”是一个字符串模版,当Javascript代码全部是"ASCII"码时,“template”是“ONE_BYTE_STRING”变量, 当"Javascript"代码中含有ASCII码之外的字符时,“template”是”TWO_BYTE_STRING“。咱们分别看73行和79行,对“ONE_BYTE_STRING”和“TWO_BYTE_STRING”进行了定义。 可以看到里面实际上是“C++”的代码,需要被替换的地方用“{0}” "{1}"代替,0 1 代表format参数的顺序。 它们分别定义了"uint8_t"类型和“uint16_t”类型的数组,一个用来存储“ASCII”码,一个用来存储“UTF-16”编码。 我们再回到第111行,最终形成新的字符串赋值给“definition”。 最后再将“definition”和所有代码字符的个数一起返回。

现在回到"AddModule",第121行,获取到了“GetDefinition“的返回值。 再看后面的两行,主要做了两件事儿,往”initializers“数组和”definitions“数组追加值。“definitions”数组还是比较好理解的,就是将各个“Javascript”文件中的代码以文件名作为“C++”数组名,代码内容作为“C++”数组的内容,这些“C++”代码都以“Python”字符串的形式放入“definition”,然后每个“Javascript”文件对应一个Python字符串,最后都被压入“definitions”数组。 “initializers”也是一个数组,每个成员都是一个“initializer”,咱们看一下“INITIALIZER”模版的内容,依然是“C++”代码,“source_”是在“src”下的“node_native_module.h”中定义的, 是一个std::map。调用“source_.emplace”往“source_”加入key value。根据该模版,“initializer”变量获取到的值类似于这样:

source_.emplace("crypto", UnionBytes{crypto_raw, 8879});

“crypto”是“Javascript”文件名,"crypto_raw"是“Javascript”代码字符串编码的数组,8879是数组的长度。 可以看到被“emplace”的“value”并不是我们的数组,而是“UnionBytes”对象,“UnionBytes”封装了将我们这里的数组转化成V8可以识别的 v8::String的功能。 “AddModule”返回到JS2C。 第143行和第145行分别将所有的“.js”文件“.mjs”文件都进行了上面所说的处理。 第147行很显然是在处理“config.gypi”。咱们进入“handle_config_gypi“看一下。 第一步读取”gypi“文件。 第二步,”jsonify“将文件内容“JSON”化。 其实“gypi”是“Python”的字典,本身就是一个“JSON”格式。 “jsonify”做了这几件事,

  1. 去除了注释
  2. 将多行字符改成单行
  3. 将字符串周围的单引号改成双引号
  4. 将“true” “false”周围的引号去掉,变成真正的布尔值 然后把“config.gypi”的内容交给“GetDefinition”, 然后返回。最终“config.gypi”处理后的文件名和“JSON”字符串都压进了“definitions”。“definitions”数组和“initializers”数组都连成了字符串,“definitions”中每个一项的最后都是换行符,所以可以直接串连成字符串。 “initializers”数组中每一项末尾没有换行符,因此在连接时需要加换行符。 153行,串连后的“definitions”和“initializers”传给了“TEMPLATE.format”。 咱们看一下“TEMPLATE”,可以看到这个才是要生成的“C++”文件的骨架。 可以看到,其实“definitions”中的内容被放在了外层,即代码数组的定义被放到了外层。而“emplace”操作被放在了“LoadJavascriptSource”函数内。另外还定义了“GetConfig”函数,返回了“config.gypi”的代码内容经过“UnionBytes”包装的对象。 最终生成的C++文件类似于这样:node_javascript.cc,实际产生的这个文件会非常大,大概有十几兆,这里我们去除了不重要的内容,这样大家可以清晰的看到整个文件的结构。

至此所有的内容相关的逻辑结束了。

下一步就开始写文件了。 文件的位置就是“node.gyp”的“node_js2c”中定义的“outputs”,咱们之前已经分析过了。 整个“JS2C”的逻辑就结束了。

至此整个“node.gpy”的重点的几个“action”咱们都已经研究完了。

剩下的几个“aciton”主要是用于调试、测试,不算是Node.js的核心逻辑。咱们这里就不做讲解了。

##为什么Makefile和GYP混用 “GYP”是一个元构建系统,它并不会去构建,只是生成构建的配置文件。当我们运行完“./configure”后,会生成一个“out”目录,“GYP”生成的构建配置文件都在其中。

根据Node.js的构建文档,运行完“./configure”后,是运行"make -j4"。这里就有个问题了,这个“make”命令并没有进入“out”目录再运行“make”,因此该“make”肯定并不是直接运行“GYP”生成的“./out/Makefile”。

事实上项目根目录下确实有一个“Makefile”文件,因此这个“make”实际上是在运行这个“Makefile”。 下面我们就来看一下这个“Makefile”。 “Makefile”洋洋洒洒一千多行,我们这里就有一个疑问,既然已经用了“GYP”,为什么还在大量使用“Makefile”呢? 我咨询了“Node.js”项目相关的贡献者,并且追踪了“Node.js”的核心技术委员会(TSC)的会议记录,其实有这样几个原因:

  1. “GYP”只承担了真正的构建工作,真正的构建工作主要是指编译(当然了“GYP”只是元构建工具,真正做事的是它的下一级构建系统),其余工作还是由项目根目录的这个“Makefile”去做的。
  2. 为什么不把所有的自动化工作交由“GYP”去做呢?因为“GYP”已经是被谷歌废弃的项目了,文档不全,很多“Node.js”贡献者也并不了解“GYP”,而“Makefile”可以较好地在所有类“Unix”系统上运行,大家也都比较熟悉,因此自然而然的就使用“Makefile”了。 另外“GYP”实际上是一个“Python”项目,要想运行“GYP”,开发者的机器上必须装有“Python”,这也曾经让很多贡献者不喜欢“GYP”,甚至有人创建了一个叫“GYP.js”的项目,用“Javascript”去代替“Python”,还得到了不少开发者的响应,但是后来就不了了之了。要替换“GYP”其实不太容易,不光是用于构建“Node.js”的“GYP”需要替换,构建插件的“GYP”也需要替换,但是插件已经有很多了,他们都在使用“node-gyp”进行构建,如果把它替换掉,那很多的插件就要进行修改,这很有可能导致大量的插件来不及修改从而导致插件在新版“Node.js”无法使用,因此“GYP”一直作为一个搁置的问题,留到了今天。所以“Node.js”的作者曾经说:当初用“GYP”来构建“Node.js”是他的几大错误之一。

因此最终的结果就是,大量的自动化工作还是通过“Makefile”完成的,只有编译“Node.js”等少数工作通过“GYP”完成。

##Node.js的Makefile文件 下面咱们看一下这个“Makefile”。 “Makefile“的核心配置其实就是一个个的“target”,官方把它叫做“规则”,大家可以把它理解为目标“target”。 形式如下:

目标: 前提条件(依赖)
    动作

动作前面默认必须是“tab”字符。 目标可以是一个文件名,也可以是一个纯粹的名字,如果不想“make”认为这是文件名,则需要使用“.PHONY”来指定其为一个指令目标,例如:

.PHONY: clean
clean: 
    rm *.o temp

这个“clean”的目标不需要前提条件,直接执行删除操作。 如果要想执行某个目标,例如“clean”,那就在命令行进入到“Makefile”所在的目录,然后执行“make clean”。 如果不指定目标,那默认是执行“Makefile”中的第一个目标。 目标后面是指定执行这个目标所需要的前提条件,也可以叫做依赖,它也是一个目标或者文件名。如果要执行某个目标,就需要先执行其前提条件。 动作其实就是“shell”脚本。所谓执行某个目标,也就是指执行动作中的“shell”脚本。

有了这些知识,咱们就可以来看一下“Makefile”了。 大部分的目标都不是文件名,而是一个纯粹的指令,因此咱们直接搜一下“.PHONY”指令,可以看到一共有120个指令,如果大家挨个儿看一下名字,会看到其实大部分的命令行操作都在“Makefile”文件中。

这些操作大概分这几类:

  • 编译“Node.js”
  • 测试
  • 生成文档
  • 打包、安装与上传
  • 代码“lint”与代码格式化

前端的同学看到这个是不是很熟悉呢?前端项目的“package.json”中的“scripts”里往往就是这些操作。 咱们在这里并不打算把所有的指令都讲一遍,咱们还是看最核心的编译部分,剩下的其实都是比较琐碎的小命令,遇到专门的问题再去看一眼就好了。 下面咱们就来看一下编译的部分。 可以看到,其实一直到103行,都是变量的定义。咱们直接从105行开始往下看。 105行定义了一个指令“all”,再往下是一个条件判断,分别根据不同的情况定义了目标“all”,这是第一个目标定义,根据Make的规则,如果命令行调用时没有指定要运行的目标,那默认会运行第一个目标,在这里就是“all”。 下面咱们看一下这个具体的条件判断。 108行,如果“BUILDTYPE”是“Release”,那“all”的依赖就是“(NODEEXE)”,如果不是“Release”,那“all”的依赖就是“(NODE_EXE)”,如果不是“Release”,那“all”的依赖就是“(NODE_EXE)” 和“(NODE_G_EXE)”。我们看一下“BUILDTYPE“是怎么获取的。往上搜索”BUILDTYPE“,可以看到第3行,对“BUILDTYPE”进行了默认值设置,“?=”代表默认值设置。“BUILDTYPE”的默认值是“Release”,也就是说如果我们不从命令行中设置“BUILDTYPE”的话,那他的值就是“Rlease”。通过字面意思咱们可以看出,如果“make”命令不传入参数直接运行,那构建的类型就是“Release”,即生产环境的Build。现在我们再回到109行,“all”的前提条件是“(NODE_EXE)”,我们搜一下“(NODEEXE)”的定义,第79行,“(NODE_EXE)”的定义,第79行,“(NODE_EXE)”由“node(EXEEXT)”组成,“(EXEEXT)”组成,“(EXEEXT)“是什么呢? 第76行,可以看到它执行了一段Python脚本,如果系统是“win32”,则“(EXEEXT)“就是”.exe“,如果不是则为空。因此“(EXEEXT)“就是”.exe“,如果不是则为空。因此“(NODE_EXE)”这个变量,在Windows环境,就是“node.exe”,其它环境就是“node”。这个很好理解,Windows的可执行文件都是以“.exe”结尾的。可以看出,这个目标是要生成node的可执行文件。咱们看一下“(NODEEXE)”这个目标的定义。往下搜索找到第130行,对“(NODE_EXE)”这个目标的定义。 往下搜索找到第130行,对“(BUILD_WITH)”进行了判断,看130行和138行,可以看到“(BUILDWITH)”可以是两种值“make”或者是“ninja”。“ninja”咱们之前讲过是在整个构建流程中,和“make”相同级别的工具,用于替代“make”。那现在就有一个问题,我们现在已经在用“make”了,为什么这里又要判断是用“make”还是“ninja”?这里咱们上面提到的,真正的“Node.js”编译并不是在这个“Makefile”中做的,而是在“GYP”中做的,“GYP”在“out”目录下生成了另外一个“Makefile”,或者生成了“ninja”。因此这里实际上是判断编译Node.js是用“make”还是“ninja”。如果用“ninja”的话,那在命令行运行“make”命令时,需要加上”BUILDWITH“参数:makeBUILDWITH=ninja如果不加该参数,则默认是用“make”。不管使用“make”还是“ninja”,它的上游都是“GYP”。我们先看看使用“make”的情况。131行,可以看到在前提条件部分是一个变量赋值。这种变量叫做目标特定变量,该目标下的动作可以使用到该值。可以看到当运行“(BUILD_WITH)”可以是两种值“make”或者是“ninja”。“ninja”咱们之前讲过是在整个构建流程中,和“make”相同级别的工具,用于替代“make”。 那现在就有一个问题,我们现在已经在用“make”了,为什么这里又要判断是用“make”还是“ninja”?这里咱们上面提到的,真正的“Node.js”编译并不是在这个“Makefile”中做的,而是在“GYP”中做的,“GYP”在“out”目录下生成了另外一个“Makefile”,或者生成了“ninja”。因此这里实际上是判断编译Node.js是用“make”还是“ninja”。如果用“ninja”的话,那在命令行运行“make”命令时,需要加上”BUILD_WITH“参数: `make BUILD_WITH=ninja` 如果不加该参数,则默认是用“make”。不管使用“make”还是“ninja”,它的上游都是“GYP”。我们先看看使用“make”的情况。131行,可以看到在前提条件部分是一个变量赋值。这种变量叫做目标特定变量,该目标下的动作可以使用到该值。可以看到当运行“(NODE_EXE)”时,“build_type”是"Release",当运行“$(NODE_G_EXE)”时,“build_type”是“Debug”。这两个目标都依赖“config.gypi”和“out/Makefile”。“config.gypi“和“out/Makefile”均是由“configure.py”生成,“out/Makefile”由“configure.py”调用GYP的API生成。

我们再继续看一下“config.gypi”是否有对应的目标,搜索“config.gypi”,确实它对应的目标定义。我们注意到“config.gypi”并不是一个由“.PHONY”指定的指令,因此“make”在执行时,会判断这个文件是否存在,如果已经存在那就不用执行它所对应的目标了。而“config.gypi”是由“./configure”生成的,正式构建时肯定会执行“./configure”,只有在我们修改代码重复构建时,我们运行“make clean”之后,“config.gypi”会被删除,这时我们想继续重用上次从命令行传入“./configure”的配置时,我们可以直接调用“make”。 这时“config.gypi”不存在,它所对应的目标就会开始执行了。 下面我们看一下这个目标的具体内容。180行,它依赖“configure“ ”configure.py“ “src/node_version.h”这几个文件,这几个文件是Node.js项目源代码的一部分,所以肯定是会有的。下面看一下动作部分,181行,如果“config.status”存在且可执行,则将环境变量中的“PATH”设置成“(NOBINOVERRIDEPATH)”。“(NO_BIN_OVERRIDE_PATH)”。“(NO_BIN_OVERRIDE_PATH)”是什么呢,我们往上搜到他的定义。67行,这里用到几个“make”工具的函数,它的意思实际上就是将环境变量“PATH”中关于“out/tools/bin”这段路径剔除, 为什么要这么做呢?主要是“V8”的构建和测试工具当时还在直接用“python”命令,而不是“python3”这样的指定版本的命令,当直接调用“python”命令很有可能无法调用成功,因此“configure.py”中就把当前使用的“Python”做了一个链接放到了“out/tools/bin”目录下,并且将它放到了环境变量“PATH”里,这样调用“python”时,实际上就会调用到“python3”这些具体版本的“Python”。这些我们在说“configure.py”的代码时也讲过了。现在我们要重新构建了,那我们需要将快捷方式的这个路径从“PATH”中剔除,恢复原状,因此这里获取到的一个新的“PATH”:“$(NO_BIN_OVERRIDE_PATH)”,这个“PATH”会在多个地方被用到。回到182行,最终拿到了一个新的“PATH”,然后开始执行“config.status”,上面咱们已经讲过了,“config.status”会按照上一次执行“./configure”时的执行命令行参数重新执行“./configure”。这样“config.gypi”就生成了。 下面我们再回到133行,我们在看一下“out/Makefile”,它也是由“./configure”生成的,“configure”调用“configure.py”,然后“configure.py”调用“GYP”的API,生成“out/Makefile”。所以正常情况下不会没有“out/Makefile”。 但是我们也可以看到,确实也有“out/Makefile”的目标定义,171行,直接调用“gyp_node.py”,生成“Makefile”。 我们再回到133行,依赖都有了,动作部分做了什么呢? 第134行,调用“make”,使用“out”下的“Makefile”,指定了“Release”或者“Debug”,“V”代表verbose,表示是否要详细输出日志。 134行就是在做真正的构建工作,所以说,咱们现在看的这个“Makefile”并不是真正用来构建的“Makefile”,真正用来的构建的“Makefile”在“out”目录下,由“GYP”生成。 构建结束后,用当前目标的名字在项目根目录创建了一个软链接,软链接可以理解为“Windows”中的快捷方式。目标名字如果是“Release”版本,Windowns和非Windows下分别是“node.exe”和“node”。 目标名字如果是“Debug”版本,“Windowns”和非“Windows”下分别是“node_g.exe”和“node_g”。由于“BUILDTYPE”默认是“Release”,所以默认是生成“node.exe”或者“node”的软链接。

到此使用“make”方式构建的部分就到此结束了。

下面咱们看一下使用“ninja”构建的部分,这部分就比较简单了。 要想使用“ninja”进行构建,那在运行“make”时,需要传入参数“make BUILD_WITH=ninja”。 这时我们看“Makefile”的149行和153行,调用Ninja的命令进行了构建。

“Makefile”中最核心的部分到这里也就结束了。

到这里基本上Node.js构建的核心源码就基本上讲完了。

##全局流程总结 细节部分完事儿了,我们再回到全局,我们通过一张图来看一下全局的构建流程。

Node.js的核心构建流程

图中蓝色的框代表构建文件,箭头代表动作,整个过程很像一个汽车工厂,将大铁块给工厂,然后工厂经过一系列工序流程,最后成品汽车出场。 我们这里的大铁块就是“node.gyp”加上命令行参数和系统环境。 第一道工序,执行“./configure”,生成了“./config.mk”和“./out/Makefile”文件或者“./out/*.ninja”文件。 第二道工序,执行“./Makefile”文件中的“make”或者“ninja”动作,生成“Node.js”的可执行文件“node”或者“node.exe”。 当你需要重复构建时,可以回到开头,直接运行“config.status”,这样可以不用重复的输入命令行参数。 整个Node.js的核心构建逻辑就是这些了。