The Hard Parts of Servers & Node.js
在本课程中,你将从两个截然不同的角度深入了解Node.js的底层原理——一是通过直观的基本原理(HTTP、TCP/IP、端口、环回、SSH)理解如何开发服务器,二是理解构成Node的JavaScript特性(事件循环、流、缓冲区、异步性、原型)。将这两种方法结合起来,你将对服务器、Node以及JavaScript本身产生更深入的理解!
0-introduction
- 欢迎来到 "Node, the hard parts" 课程,本课程将探讨服务器以及过去十年中最强大的网络技术:Node.js。
- Node.js 的强大之处:
- 构建高性能服务器:能够构建可同时处理数百万用户的应用程序。
- 大公司都在用:LinkedIn, Uber, Netflix, IBM 等公司都在使用 Node.js。
- 构建桌面应用:通过 Electron 包,可以构建兼容 Windows, Mac, Linux 的桌面应用,例如 Slack, Twitch, VS Code, Atom。
- 硬件和嵌入式系统:可以用于设置硬件和嵌入式系统。
- 对前端开发者的最大优势:
- 能够使用同一种语言——JavaScript,编写从客户端到服务器端的“端到端”应用程序。
1-node-overview
- Web 应用的运行流程:
- 当用户在浏览器(客户端)中打开一个网址(如 twitter.com)时,实际上是在请求代码和数据。
- 客户端需要三种语言:
- HTML:在页面上放置内容(图片、文字)。
- CSS:美化和布局页面元素。
- JavaScript:处理所有逻辑和用户交互。
- 这些代码(HTML, CSS, JS 文件)和数据(推文、图片)都存储在服务器上。
- 服务器 (Server) 与客户端 (Client):
- 服务器:本质上是另一台始终在线、连接到互联网的计算机。它接收来自客户端的请求消息。
- 客户端:用户的设备(电脑、手机等),向服务器发送请求。
- 服务器需要运行代码来解析请求,并决定返回哪些数据和代码。
- 服务器端编程的挑战:
- 我们需要在服务器上编写代码来处理请求。虽然可以用 PHP, Java, Ruby 等语言,但我们最希望能用 JavaScript。
- 问题:浏览器中的 JavaScript 无法直接访问计算机的底层功能,如文件系统(File System)或网络(Networking)。而服务器必须具备这些能力才能读取文件和处理网络请求。
- Node.js 的诞生:
- C++ 语言可以直接与计算机的操作系统和底层功能交互。
- Node.js 是 JavaScript 和 C++ 的结合体。它允许我们用 JavaScript 编写代码,去控制那些用 C++ 构建的、能够操作计算机底层功能的强大特性。
- 我们写的 JavaScript 代码会通过 Node 提供的“标签”(labels),间接调用 C++ 功能,从而控制计算机。
- 核心模型:
我们的 JavaScript 代码 -> Node 的 C++ 功能 -> 计算机底层功能。
- 学习重点:
- 我们不需要学习编写 C++ 代码。
- 但我们必须深入理解这个工作模型:JavaScript 的标签(如
http, fs)是如何触发 C++ 功能来完成任务的。
2-javascript-node-the-computer
- 回顾核心架构:
- 当一个请求(如访问 twitter.com/node)从客户端发出后,它会到达服务器的网络硬件(network card)。
- 这个请求报文不会直接进入我们的 JavaScript 代码。
- 我们的 JavaScript 代码必须通过 Node 的 C++ 功能来间接访问和处理这个网络请求。
- 流程:
客户端请求 -> 计算机底层网络功能 -> Node (C++) -> JavaScript 代码。
- Node 的大部分繁重工作都是在 C++ 层完成的。
- 理解 Node 的前提:理解 JavaScript
- 要想知道如何使用 JavaScript 控制 Node,我们必须先深入理解 JavaScript 自身的执行模型。
- JavaScript 的三件主要工作:
- 保存数据和功能:存储数字、字符串、对象,以及待稍后运行的代码(即函数)。
- 使用数据:通过运行已保存的功能(函数)来处理数据。
- 使用内置标签:触发 Node 中用 C++ 构建的功能,以操作计算机的底层。
3-executing-javascript-code-review
- 回顾 JavaScript 的两个核心任务:
- 保存代码和数据 (Save code and data)
- 运行代码处理数据 (Run code on data)
- 1. 保存代码和数据
- 数据保存在 JavaScript 的内存 (memory) 中。最外层的内存被称为全局内存 (global memory)。
const num = 3;:在全局内存中创建一个标签 num,并存入值 3。
function multiplyByTwo(...) {...}:在全局内存中创建一个标签 multiplyByTwo,并将整个函数(包括参数和函数体代码)作为一个值存起来。此刻函数并未执行。
- 2. 运行代码
- 通过在函数名后加上括号
() 来调用(执行)函数。
- 当一个函数被调用时(例如
multiplyByTwo(num)),会发生以下事情:
- 创建一个全新的执行上下文 (Execution Context)。可以把它想象成一个临时的“迷你应用”。
- 这个上下文拥有自己的局部内存 (local memory),是临时的,函数执行完就会被销毁。
- 参数被传递:
num 的值(3)被传递给参数 inputNumber,所以在局部内存中 inputNumber 的值是 3。
- 执行线程 (Thread of Execution) 开始逐行运行函数内部的代码。
const result = inputNumber * 2;:在局部内存中创建 result 并存入值 6。
return result;:函数返回 result 的值(6),该执行上下文被销毁,局部内存被清空。
- 返回值
6 被赋给全局内存中的 output。
- 函数的重要性
- 函数是将代码打包以便稍后运行的核心机制。
- 在 Node.js 中,很多时候不是我们手动去调用函数。而是 Node 自己在响应某个事件(比如接收到一个网络请求)时,自动替我们调用我们预先定义好的函数。
4-executing-node-code
- 连接 JavaScript 与 Node
- 我们的目标是:编写 JavaScript 代码,来查看收到的网络请求,并根据请求内容返回相应的数据。
- 为此,我们需要一个 JavaScript 中的“标签”,它能够触发 Node 中用于处理网络请求的 C++ 功能。
- HTTP 功能与 Socket
- 这个核心的网络功能标签是
http。它让我们能够处理 HTTP (Hypertext Transfer Protocol) 格式的请求。网络浏览器发送的请求就是这种格式。
- 我们将使用
http 功能来打开一个 socket。
- Socket:一个开放的、双向的互联网数据通道。一旦打开,它就能接收 HTTP 格式的消息。
- Node 的通用模型
- 我们将要看到的这个模型(使用 JavaScript 标签调用底层功能)适用于 Node 中所有的特性。
- 一个具体的命令例子是
http.createServer。这是一个指向 Node C++ 功能的标签,用于创建一个能接收网络请求的服务器。
- 如何使用 Node 的功能
- 与浏览器中的
setTimeout 等全局可用的 API 不同,Node 中的这些功能标签(如 http)不是默认就可用的。
- 我们必须在代码中明确地引入(
require)我们想要使用的功能模块。具体方法将在后面讲解。
5-calling-node-with-javascript
- 实现目标:使用 Node 和 JavaScript 打开一个网络通道 (socket),接收来自客户端的消息。
- 核心命令:
http.createServer()
- 这条 JavaScript 代码并不是直接操作计算机底层,而是向 Node 的内部 C++ 功能发出的命令。
- 作用 1:打开 Socket
- 在 Node 层面,它启动了一个专门处理 HTTP 协议的网络功能。
- 借助于一个名为 libuv 的 C++ 库,这条命令最终会在计算机底层打开一个真正的网络通道 (socket),准备接收 HTTP 消息。
libuv 是一个关键的抽象层,它确保 Node 代码可以在不同的操作系统(Windows, Mac, Linux)上运行。
- 问题:监听哪个端口 (Port)?
- 一台计算机有大约 64,000 个端口(入口点)。
- 浏览器发送 HTTP 请求时,默认会发往服务器的 80 端口。
- 因此,我们需要告诉我们刚创建的 socket,去监听 80 端口。
- 如何配置已创建的 Socket?
- 作用 2:返回一个对象
http.createServer() 执行后,会立即在 JavaScript 中返回一个对象。
- 这个对象被我们存入了
server 变量中。
- 该对象包含了一系列方法(函数),比如
listen,这些方法可以用来编辑和配置我们刚刚在 Node 后台创建的那个网络服务实例。
- 配置端口:
.listen(80)
- 我们调用
server 对象上的 listen 方法,并传入参数 80。
- 这个调用会再次触发 Node 的功能,将我们 socket 的监听端口设置为 80。
- 至此,服务器已准备好接收在 80 端口上的请求。
- 请求到达后怎么办?
- 请求消息到达 Node 后,我们需要运行一段 JavaScript 代码来检查消息内容并决定返回什么。
- 问题:我们不知道请求何时会到达。让 JavaScript 一直空转等待是不现实的。
- 解决方案:
- 谁知道请求何时到达?Node 知道。
- 我们需要把要执行的代码打包成一个函数。
- 然后,把这个函数交给 Node,让 Node 在收到请求时自动为我们运行这段代码。
6-calling-methods-in-node
- 核心模式:让 Node 自动运行我们的代码
- 当一个请求(Request)到达时,我们希望 Node 能自动执行我们预先写好的一段代码。
- 如何实现?
- 打包代码:将要执行的代码包裹在一个函数里(例如,
doOnIncoming)。
- 传递函数:将这个函数作为参数,传递给
http.createServer()。
- 代码执行流程详解
function doOnIncoming...:在 JavaScript 内存中定义并保存 doOnIncoming 函数。此时不执行。
const server = http.createServer(doOnIncoming);
http.createServer(...) 在 Node 后台启动了一个网络服务实例(打开了 socket)。
- 同时,它接收了
doOnIncoming 函数作为参数。Node 会保存这个函数,并将其与“网络请求到达”这个事件关联起来。
- 该方法立即返回一个包含配置方法的对象,并存入
server 变量。
server.listen(80);:配置网络服务监听 80 端口。
- 回调函数 (Callback Function)
doOnIncoming 就是一个典型的回调函数。我们定义它,但不是自己调用它。我们把它传递给另一个功能(Node 的 createServer),由该功能在特定事件发生时(收到请求时)“回调”或执行它。
- 为什么需要这种模式?
- 异步性:我们不知道请求何时到达。所以不能让代码同步等待。
- 非阻塞:JavaScript 是单线程的。所有耗时的操作(如网络请求、文件读写、数据库查询)都应该交给 Node 的后台去处理。JavaScript 主线程可以继续做其他事,而不会被阻塞。当后台任务完成时,Node 会通过执行我们提供的回调函数来通知 JavaScript。
- 这个“设置后台任务 + 附加回调函数”的模式是 Node.js 的核心。
7-calling-a-function-under-the-hood
- 回顾:调用函数的两个要素
- 执行代码:通过在函数名后添加括号
() 来触发。
- 传入数据:在括号内提供参数(arguments)。
- Node 如何自动运行我们的回调函数?
- 我们只是把
doOnIncoming 函数本身传给了 Node,并没有加括号 ()。
- Node 会为我们完成这两件事:
- 自动执行:当网络请求到达时,Node 会在后台为我们的函数
doOnIncoming 加上括号 (),从而执行它。
- 自动传入参数:更强大的是,Node 不仅会执行函数,还会自动创建并传入非常有用的参数。
- Node 自动传入的参数是什么?
- 这些参数就是来自客户端请求的相关数据。
- 这样,在我们的回调函数内部,就能直接访问到请求的详细信息(比如用户请求的是哪个 URL),从而决定应该返回什么内容。
- Node 还会传入什么?
- Node 通常会传入两个主要的参数(以对象形式):
- 第一个对象:包含了所有关于收到的请求 (Incoming Request) 的信息。
- 第二个对象:包含了一系列函数,让我们能够构建和控制将要发回的响应 (Outgoing Response)。
8-creating-a-server-under-the-hood
- 当请求到达时,Node 的完整工作流程:
- 接收消息:客户端的 HTTP 请求消息到达 Node。这个原始消息是一个复杂的文本字符串。
- 创建两个核心对象:Node 为了方便我们处理,不会直接给我们原始的文本消息。相反,它会立即自动创建两个关键的 JavaScript 对象:
- 对象 1 (请求对象,Request):解析收到的 HTTP 消息,并把其中的重要信息(如
url)打包成一个易于访问的 JavaScript 对象。
- 对象 2 (响应对象,Response):创建一个代表即将发回给客户端的响应的对象。这个对象本身不直接包含数据,而是包含了一系列方法(函数),比如
.end()。
- 执行回调函数:Node 调用我们之前传入的
doOnIncoming 函数。
- Node 在
doOnIncoming 后面加上括号 () 来执行它。
- Node 将刚刚创建的请求对象和响应对象作为两个参数,自动传入到
doOnIncoming 函数中。
- 在我们的回调函数内部:
- 获取参数:我们通过在函数定义中设置参数名(如
incomingData 和 functionsToSetOutgoingData,通常简写为 req 和 res)来接收这两个由 Node 传入的对象。这些参数名是我们自己定的,不是关键字。
- 处理请求:我们可以访问
incomingData 对象(即 req)的属性(如 incomingData.url)来了解客户端想要什么。
- 构建响应:我们调用
functionsToSetOutgoingData 对象(即 res)上的方法来构建返回给客户端的响应。
- 响应对象的
.end() 方法:
.end() 是响应对象上的一个重要方法。
- 当我们调用
functionsToSetOutgoingData.end("Welcome") 时:
- 这个调用是一个返回 Node 的信号。
- 它告诉 Node:“我要在响应消息中加入 ‘Welcome’ 这段内容,并且我的响应已经准备好了,可以发送了。”
- Node 随后将这个构建好的 HTTP 响应消息发送回客户端的浏览器。
- 总结:
req 对象(请求对象)是只读的,它包含了客户端发来的所有信息。
res 对象(响应对象)是可写的,它提供了一系列方法,让我们来构建即将发回给客户端的响应。 res.end() 就是其中一个最基本的方法,用于发送响应并结束本次通信。
9-creating-a-server-summary
- Node.js 的本质
- 我们只用了三行 JavaScript 代码,就构建了一个完整的服务器。大部分繁重的工作都是由 Node 的后台功能(紫色部分)完成的。
- 核心模式是统一的,并且会反复出现:
- 使用内置标签:调用一个 Node 的内置标签(如
http.createServer 或处理文件系统的 fs)来启动一个在后台运行的 C++ 功能。这个功能会与计算机的底层特性(如网络、文件系统)交互。
- 提供回调函数:向这个功能传递一个我们自己编写的 JavaScript 函数。
- Node 自动执行:当后台任务有活动(如收到网络请求、文件读取完成)时,Node 会自动执行我们提供的那个回调函数。
- Node 自动传入数据:Node 在执行回调函数时,会自动创建并传入相关的上下文数据作为参数(通常是两个对象:一个包含输入信息,另一个提供输出控制方法)。
- 在回调中处理逻辑:我们在回调函数内部编写逻辑,利用 Node 传入的参数来检查输入信息,并构建响应。
- 这是 Node 的所有:这个“启动后台任务,附加回调函数,在回调中处理自动传入的数据”的模式,构成了 Node.js 的绝对核心。理解了这个模型,就理解了 Node 中几乎所有异步操作的原理。
10-node-under-the-hood-q-a
- Q: 缺少分号 (semicolons)?
- A: 这是讲师的疏忽。在 Node.js 中,推荐总是使用分号,因为 JavaScript 的自动分号插入机制可能导致难以发现的错误。
- Q:
req, res 之外,有时会看到第三个参数 err?
- A: 错误处理是一个重要话题,后面会讲到。Node 的异步操作通常将错误作为回调函数的第一个参数。
- Q: Node 只能访问到
createServer 里传入的那个函数吗?
- A: 基本是的。更精确地说,Node 内部有事件机制。成功的请求会触发一个 "request" 事件,从而调用我们的回调。如果发生错误,可能会触发 "error" 事件,调用另一个处理函数。所以我们可以有更精细的控制。
- Q: 第二个对象(响应对象)的参数名可以随便起吗?
- A: 是的,可以叫任何名字,只要在函数内部使用同样的名字来引用它就行。我们如何知道这个对象上有哪些方法(比如
.end())?需要查阅 Node.js 官方文档。掌握了底层的思维模型后,大部分工作就是去查文档,看某个功能会提供哪些方法和数据。
- Q:
.end() 方法的作用?
- A: 它的主要作用是通知 Node:“响应构建完毕,可以发送了”。虽然可以直接在
.end() 中传入一小段数据作为响应体,但这是一种简化用法。通常我们会用其他方法(如 .write() 设置响应体,.setHeader() 设置头部)来构建复杂的响应,最后调用一个空的 .end() 来发送。
- Q:
http.createServer(...).listen(...) 链式调用可以吗?
- A: 完全可以,而且很常见。
http.createServer() 返回一个对象,所以可以直接在该对象上调用 .listen() 方法。分开写是为了教学目的,让返回对象这一步更清晰。
- Q: 浏览器怎么知道要连接 80 端口?
- A: HTTP 协议的默认端口就是 80。浏览器在发送 HTTP 请求时会自动导向 80 端口,无需手动指定。同理,HTTPS 的默认端口是 443。
- Q: 既然默认是 80,为什么我们还需要设置其他端口?
- A: 在开发环境中,我们通常会使用其他端口(如 3000, 8080)来避免与机器上可能正在运行的其他服务(如一个真正的 web 服务器)冲突,或者因为在非管理员模式下无法使用 1024 以下的端口。
- Q: Node 是如何将回调函数放回 JavaScript 主线程执行的?如果同时有多个回调准备好了怎么办?
- A: 这是个非常深刻的问题。Node 确实需要将回调函数放回到 JavaScript 的单线程中执行。它不能随便插入,必须遵循严格的规则。如果多个回调同时准备好,它们会排队等待执行。这个管理回调执行顺序的机制就是事件循环 (Event Loop),我们将在课程后面深入讲解。
11-request-response-with-node
- 回顾服务器构建流程
- 用
http.createServer 启动一个后台网络服务,得到一个可配置的对象。
- 用
.listen(80) 让服务监听默认的 HTTP 端口。
- 当请求到达时,Node 自动运行我们提供的回调函数,并自动传入两个核心对象:请求对象和响应对象。
- 请求对象包含了从客户端消息中解析出的数据。响应对象提供了一系列方法来构建并发送回复。
- 深入了解 HTTP 消息格式
- 浏览器与服务器之间的通信遵循 HTTP 协议,其消息主要包含三个部分:
- 请求行 (Request Line):包含请求方法(如
GET, POST)和请求的路径(如 /tweets/3)。
- 头部 (Headers):元数据,即关于请求的附加信息。例如浏览器类型、用户登录状态(通过 Cookie)等。
- 主体 (Body):主要用于
POST 或 PUT 请求,携带要发送给服务器的数据,比如新发布的推文内容。对于 GET 请求,主体通常是空的。
- 从通用响应到个性化响应
- 上一个例子中,我们无论收到什么请求,都返回了固定的 "welcome"。
- 接下来,我们将编写代码来检查 (introspect) 请求对象,根据请求的具体内容(如请求的 URL)来决定返回什么数据。
- 例如,如果请求的 URL 是
/tweets/3,我们就应该返回第三条推文的内容。
- 与真实世界的联系
- 这个“解析请求、决定响应”的模式,正是像 Netflix、Hulu 这样的网站工作的基本原理。当用户点击一部电影时,浏览器发送一个特定 URL 的请求;服务器(很可能就是 Node.js)解析这个 URL,找到对应的视频文件,然后把数据发送回来。我们即将学习的,就是这个核心流程的缩影。
12-express-q-a
- Q: 为什么要有 Express.js 这样的框架?Node 本身的功能不够吗?
- A: Node 提供了基础功能,但直接使用它来构建复杂的应用会很繁琐。比如,一个网站有很多页面(首页、关于页、帮助页),如果用原生 Node,我们需要写一长串的
if/else if 语句来判断 URL 并返回相应内容。
- Express 就是一个预先编写好的 JavaScript 代码库(框架),它简化了这些重复性的任务。
- Express 如何简化路由 (Routing)?
- Express 提供了简洁的语法来处理“如果用户访问这个 URL,就执行这段代码”的逻辑,避免了手写大量的
if/else。例如:
app.get("/about", (req, res) => {
res.send("This is the about page");
});
app.get("/help", (req, res) => {
res.send("This is the help page");
});
- 什么是中间件 (Middleware)?
- 核心思想:Node 的主要工作就是分析请求对象 (req),然后据此构建响应对象 (res)。这个分析过程可能包含很多步骤:
- 检查用户是否登录。
- 如果登录了,从数据库获取用户信息。
- 解析请求的 URL。
- ...等等。
- 把所有这些逻辑都写在一个巨大的回调函数里会非常混乱。
- 中间件模式就是将这个处理流程分解成一系列独立的、可串联的函数。
- 每个中间件函数接收请求对象 (
req) 和响应对象 (res),执行一个特定的任务(比如检查登录状态),然后可以选择:
- 结束请求-响应循环(直接发回响应)。
- 将控制权传递给下一个中间件函数。
- Express 的本质就是一个强大的中间件管理器。它允许你将复杂的请求处理逻辑模块化,组织成清晰的、一步一步执行的管道。
13-preparing-for-httprequestobject
- 回顾 Node.js 架构
- JavaScript 代码通过“标签”(labels)触发 Node.js 中的 C++ 功能。
- Node.js 的 C++ 功能进而控制计算机的底层硬件,如网络(Networking)。
- 代码执行步骤详解
const tweets = [...]:在 JavaScript 全局内存中创建一个名为 tweets 的数组,并存入数据(一些字符串和 emoji)。
- 讲师在此部分花时间画了 emoji,强调这是我们的“数据”。
function doOnIncoming(...):在全局内存中定义并保存 doOnIncoming 函数。我们作为开发者永远不会自己调用这个函数。它将被交给 Node,在收到请求时自动运行。
const server = http.createServer(doOnIncoming):这是核心步骤,做了三件事:
- 事情 1 (在底层):通过 Node 的 C++ 功能和
libuv,在计算机底层建立一个网络连接,打开一个 socket,准备接收互联网消息。
- 事情 2 (在 Node 中):将
doOnIncoming 函数的定义存入 Node。Node 会将其与“收到网络消息”事件绑定,准备在事件发生时自动运行 (autorun) 此函数。
- 事情 3 (在 JavaScript 中):
http.createServer 方法立即返回一个对象到 JavaScript 中,并赋值给 server。这个对象包含了一系列“编辑函数”(如 listen),用于后续配置后台的 Node 服务实例。
server.listen(80):调用 server 对象上的 listen 方法。
- 这是一个对 Node 功能的调用。
- 它配置了后台的 socket,使其在 80 端口上监听传入的请求。
- 模式总结
- 这个“设置后台功能 -> 存储待自动运行的函数 -> 返回一个可编辑的对象”的模式,是 Node.js 中所有异步操作的基础,无论是网络还是文件系统。
14-parsing-httprequestobject
- 请求到达
- 客户端(Michael's Mac)访问
twitter.com/tweets/3,发送一个 HTTP 请求。
- HTTP 请求消息包含:
- 请求行:
GET /tweets/3
- 头部 (Headers): 关于客户端的元数据。
- 这个消息通过网络到达服务器,经由
libuv 进入 Node 环境。
- Node 的处理流程
- 消息到达:Node 接收到原始的 HTTP 文本消息。
- 准备响应:同时,Node 准备好一个空的 HTTP 响应消息,等待我们填充内容后发回。
- 创建核心对象 (Arguments):Node 为了方便我们处理,不会直接给我们原始消息,而是自动创建两个 JavaScript 对象:
- 请求对象 (Request Object):解析收到的消息,提取关键信息。
.url: /tweets/3 (从请求行中解析)
.method: 'GET' (从请求行中解析)
- 响应对象 (Response Object):包含一系列函数(如
end, write),用于操作那个准备发回的响应消息。
- 调用回调函数:Node 知道此时应该执行我们之前提供的回调函数
doOnIncoming。
- Node 自动为函数加上括号
()。
- Node 将上面创建的两个对象作为参数 (arguments) 自动传入。
- 关键点
- 我们在代码里看到的一切都运行在服务器上。
- 大量的后台工作(接收消息、解析消息、创建对象)都是 Node 自动完成的,我们的代码只需要关注如何使用这些 Node 准备好的工具。
- 这个模式是固定的:响应后台事件 -> 自动运行回调 -> 自动传入数据。
15-http-response-in-node
- 执行回调函数
- 当请求到达后(可能在服务器启动一天后),Node 自动执行
doOnIncoming 函数。
- Node 的自动执行(用紫色笔表示)意味着它创建了一个新的执行上下文 (Execution Context)。
- 在执行上下文中
- 参数传入:Node 自动创建的两个对象被作为参数传入,并在函数的局部内存中被赋予我们定义的参数名:
incomingData: 接收了包含 { url: '/tweets/3', method: 'GET' } 等信息的请求对象。
functionsToSetOutgoingData: 接收了包含 { end: [Function], write: [Function] } 等方法的响应对象。
- 执行函数体代码:
const tweetNeeded = incomingData.url.slice(8);
incomingData.url 是字符串 '/tweets/3'。
.slice(8) 从第 8 个字符开始截取,得到字符串 '3'。
- 将其转换为数字
3。
- 我们在代码里
tweetNeeded -1 来得到数组索引 2。
- 获取数据:
tweets[2] 从我们之前定义的数组中取出第三条推文,即字符串 'Hello'。
- 发送响应:
functionsToSetOutgoingData.end(tweets[tweetNeeded]);
- 我们调用响应对象上的
.end() 方法,并传入 'Hello'。
- 这个调用是一个返回 Node 的信号,它会:
- 将
'Hello' 作为响应体,添加到后台准备好的 HTTP 响应消息中。
- 告诉 Node:“响应已完成,可以发送了”。
- 返回客户端:Node 通过
libuv 将完整的 HTTP 响应消息发送回客户端的浏览器,浏览器上最终显示 "Hello"。
- 总结:这已经是完整的服务器
- 我们已经实现了一个完整的、能根据请求动态响应的服务器。
- 所有更复杂的应用(如 LinkedIn, Netflix)都建立在这个基础模型之上。它们的不同之处在于:
- 更复杂的请求解析(比如检查更多头部信息)。
- 更复杂的数据获取方式(比如从数据库或文件系统,而不是内存中的一个数组)。
- 更复杂的响应构建方式。
- 但核心的“接收请求 -> 解析请求对象 -> 使用响应对象的方法发送数据”的模式是完全一样的。
16-http-request-response-q-a
- Q: 数据是一次性到达还是分块 (chunks) 到达?
- A: HTTP 协议支持将数据以“块”或“流 (stream)”的形式传输。Node 提供了处理这种分块数据的方式,我们稍后会学到。对于很小的数据,可以看作是一次性到达。
- Q:
doOnIncoming 函数是在客户端浏览器里运行还是在服务器上运行?
- A: 所有这些代码都运行在服务器上(比如 Twitter 的计算机)。客户端浏览器只是发送请求和接收响应。
- Q: 如何处理
GET 以外的请求,如 POST?代码需要重启吗?
- A: 你必须在运行 Node 服务器之前,就写好所有处理不同 HTTP 方法和路径的逻辑。一旦服务器启动,如果你修改了 JavaScript 代码,就必须关闭并重启 Node 服务,以便重新加载和设置所有后台功能。
- Q: 如果 URL 格式错误(如
tweets/A),导致代码出错怎么办?
- A: 如果不处理异常,服务器进程可能会因为未捕获的错误而崩溃,或者浏览器会一直处于等待状态。错误处理 (Error Handling) 在 Node.js 中至关重要,我们后面会专门讨论。
- Q: 如何在响应中显示请求的方法 (method)?
- A: 非常简单。
incomingData.method(或 req.method)就是一个包含了请求方法字符串(如 'GET')的属性。你可以直接 console.log(incomingData.method) 来在服务器控制台打印它,或者 res.end(incomingData.method) 将其作为响应发送回浏览器。
- 引出下一个话题:如何在我们的计算机上运行这一切?
- 我们需要在本地计算机上启动 Node,运行我们的 JavaScript 文件,并设置好所有服务。
- 响应消息也是 HTTP 格式
- 服务器返回给浏览器的消息,其格式与请求消息类似,也包含“头部 (Headers)”和“主体 (Body)”。
- 响应头部的作用
- 响应头部可以包含关于返回数据的元数据。
- 一个非常重要的头部是
Content-Type。
- 它可以告诉浏览器返回的数据是什么类型,例如:
Content-Type: text/html:告诉浏览器这是一段 HTML,请按 HTML 格式解析和渲染。
Content-Type: text/plain:告诉浏览器这是纯文本。
Content-Type: application/json:告诉浏览器这是 JSON 数据。
- 在接下来的练习中,我们会需要设置这个头部,以确保浏览器能正确处理我们发送的数据。
18-intro-to-require-in-node
- 如何访问 Node 的内置功能
- 像
http 这样的 Node 核心模块,并不是像 setTimeout 一样默认就在全局可用的。
- 我们需要通过一个特殊的函数
require() 来明确地告诉 Node 我们想要使用哪个功能。
require('http') 的工作原理
- 当我们写
const http = require('http'); 时:
require() 是 Node 提供的一个函数。
- 它接收一个字符串参数,即我们想要加载的模块名(如
'http')。
- Node 会找到对应的核心模块。
- 然后,
require() 函数会返回一个对象,这个对象上挂载了该模块提供的所有功能。
- 例如,对于
http 模块,返回的对象上就有一个 createServer 方法。
- 为什么要这样做?
- 按需加载:这是设计好的。它避免了一次性把所有 Node 功能都加载到内存中,让我们的应用更轻量。我们只加载我们确实需要的功能。例如,一个邮件服务器可能就不需要
http 模块。
19-javascript-node-development
- 如何在本地运行 Node.js 代码
- 编写代码:在文本编辑器(如 VS Code)中编写我们的 JavaScript 代码。
- 保存文件:将代码保存为一个
.js 文件,例如 server.js。
- 使用终端 (Terminal):Node.js 是一个命令行工具,没有图形界面。我们需要通过终端来启动它。
- 运行命令:在终端中,输入命令
node server.js 并按回车。
node: 这个命令启动 Node.js 应用程序。
server.js: 这是告诉 Node.js 去执行哪个文件里的代码。
- 代码的热重载 (Hot Reloading)
- 当
node server.js 启动后,如果你修改了 server.js 文件的内容,这些改动不会自动生效。
- 你必须手动停止 Node 进程(通常用
Ctrl+C),然后再次运行 node server.js 来加载新的代码。
nodemon:这是一个非常有用的开发工具。它会监视你的文件。当你保存文件时,nodemon 会自动帮你重启 Node 服务器。在开发阶段,我们通常使用 nodemon server.js 来代替 node server.js,以提高效率。nodemon 是 node 的一个包装器。
20-cloud-node-development
- 服务器需要一直在线
- 真正的服务器需要 24/7 不间断运行,并始终连接到互联网。把我们自己的笔记本电脑一直开着显然不现实。
- 云服务 (The Cloud)
- 解决方案是租用别人的电脑,这些电脑由大公司(如 Amazon (AWS), Google (GCP), Microsoft (Azure))维护,确保它们一直在线。
- 这就是所谓的“云”。
- 开发与部署流程
- 本地开发:我们在自己的电脑(Will's Mac)上编写和测试代码。
- 远程控制:我们使用终端通过 SSH (Secure Shell) 等工具,安全地连接并控制我们在云服务商那里租用的远程计算机。
- 远程部署:我们将本地写好的
server.js 文件上传到远程计算机上。
- 远程运行:我们在远程计算机的终端上运行
node server.js,这样我们的服务就在云端一直运行了。
- 域名系统 (DNS)
- 用户如何找到我们在亚马逊上的服务器?通过 DNS (Domain Name Server)。
- DNS 就像一个电话簿,它将人类易于记忆的域名(如
twitter.com)映射到计算机能够理解的、独一无二的 IP 地址(如 32.2.5.7)。
- 当我们将服务部署到 AWS 后,我们需要配置 DNS,将我们的域名指向 AWS 分配给我们的那台服务器的 IP 地址。
- DevOps
- 确保代码能正确部署到云端服务器上,并配置好所有网络、域名和负载均衡等,这个复杂而重要的领域被称为 DevOps (Development & Operations)。
21-local-node-development
- 本地开发的挑战
- 如果每次测试代码都需要部署到云服务器,然后让别人访问来测试,效率会极其低下。
- 本地回环 (Loopback)
- 操作系统为我们提供了一个绝佳的解决方案:本地回环。
- 我们可以在自己的电脑上同时扮演服务器和客户端的角色。
localhost:一个特殊的域名
- 当我们在浏览器中访问
localhost(或其 IP 地址 127.0.0.1)时,发出的请求不会进入互联网。
- 操作系统会截获这个请求,并将其直接“回环”到我们自己的电脑上。
- 本地开发流程
- 启动服务器:在我们的电脑上,用
node server.js 启动我们的 Node 服务器。
- 设置非标准端口:在开发时,我们通常不使用默认的 80 端口,而是使用一些大于 1024 的端口,比如 3000。所以在代码中,我们会写
server.listen(3000)。
- 发送请求:在同一台电脑上,打开浏览器,访问
localhost:3000。
localhost: 指向我们自己的电脑。
:3000: 明确告诉浏览器请求应该发往哪个端口。
- 接收和响应:我们本地运行的 Node 服务器会收到这个来自
localhost:3000 的请求,处理它,然后将响应发回给本地的浏览器。
- 好处
- 这个流程让我们可以在一个完全隔离的环境中,快速地编写、运行和测试我们的服务器代码,而无需依赖互联网或远程服务器。这是所有 Web 开发的基础。
22-node-pair-programming-setup
- 练习准备
- 我们将在本地电脑上进行开发。
- 角色:我们的电脑既是运行 Node 服务器的服务器,也是打开浏览器发送请求的客户端。
- 资源:
- 访问
bit.ly/femnode 下载练习文件。
- 确保已安装 Node.js (
node.org)。
- 解压文件,首先阅读
README 文件并按指示操作。
- 结对编程 (Pair Programming)
- 两名开发者在同一台机器上协作。
- 角色分工:
- 导航员 (Navigator):
- 阅读和理解挑战要求。
- 思考并提出解决问题的宏观策略。
- 将策略分解成一行一行的代码逻辑,用语言清晰地传达给驾驶员。
- 不能碰键盘。
- 驾驶员 (Driver):
- 专注于将导航员的指令翻译成实际的代码。
- 操作键盘和鼠标。
- 结对编程的好处
- 平衡学习方式:避免了“过度研究”或“盲目复制代码”的两个极端,促使开发者在“理解”和“实践”之间找到平衡。
- 提升技术沟通能力:导航员必须能够清晰、准确地口头表达代码逻辑,这是高级开发者的关键技能。
- 高效调试:当出现错误时,导航员因为置身事外,往往能更快发现问题(如一个遗漏的分号)。这提供了一个极好的机会,让双方共同学习如何解读错误信息并解决问题。
- 调试流程
- 当遇到问题时,通过沟通来调试:
- “我们期望发生什么?” (e.g., 返回推文数组)
- “实际发生了什么?” (e.g., 返回 undefined)
- “我们怀疑原因是什么?” (e.g., 可能索引错了)
- “我们准备尝试什么来验证?” (e.g.,
console.log 一个变量)
- 这个沟通过程本身就是一种高效的调试和学习方法。
23-error-handling-in-node
- 服务器开发中的错误处理
- 与其他计算机交互(如接收客户端请求)时,有无数种可能出错的情况。因此,健壮的错误处理至关重要。
- Node 的事件驱动模型
- 之前我们认为,当一个请求到达时,Node 会直接运行我们提供的
doOnIncoming 函数。这是一种简化。
- 真相是:Node 内部有一个事件系统。
- 当一个格式正确的请求到达时,Node 内部会“广播”或“发出 (emit)”一个名为
request 的事件(一个字符串消息)。
- 是这个
request 事件,触发了 doOnIncoming 函数的执行。
- 当我们将函数直接传递给
http.createServer() 时,我们实际上是在隐式地告诉 Node:“当 request 事件发生时,请运行这个函数。”
- 手动处理事件
- 我们可以更精确地手动控制哪个事件触发哪个函数。
server.on('event-name', function-to-run):这是核心方法。
.on() 是 http.createServer 返回的对象上的一个方法,用于监听事件。
request 事件: 代表一个正常的请求。
server.on('request', doOnIncoming);
clientError 事件: 代表一个来自客户端的、有问题的请求(如格式损坏)。
- 当 Node 检测到这种请求时,它会发出
clientError 事件,而不是 request 事件。
server.on('clientError', doOnError);
- 为什么要这样做?
- 这套事件系统让我们能够根据不同的情况(正常请求 vs. 错误请求)来执行不同的处理逻辑,使我们的服务器更加健壮和可控。
- 我们将有两个独立的回调函数:一个处理成功的情况,一个处理失败的情况。
24-event-handling-in-node
- Node 的事件模式
- 当计算机底层发生事情时(如网络请求到达),Node 不会立即运行一个函数,而是先广播一个带有特定名称的事件。
- 我们可以设置监听器,告诉 Node 在某个特定事件发生时,应该自动运行哪个函数。
- 代码执行流程(使用事件监听)
function doOnIncoming..., function doOnError...: 定义并保存两个处理函数。
const server = http.createServer();:
- 不传入任何函数。
- 在 Node/底层: 仍然会打开一个 socket,准备接收请求。
- 在 JavaScript: 立即返回一个包含
listen 和 on 等方法的 server 对象。
server.listen(80);: 使用 listen 方法配置服务器监听 80 端口。
server.on('request', doOnIncoming);:
- 使用
on 方法,这是一个“编辑”Node 后台行为的函数。
- 它告诉 Node 后台的服务实例:“当
request 事件被触发时,请将 doOnIncoming 函数加入待执行队列。”
server.on('clientError', doOnError);:
- 同样,告诉 Node:“当
clientError 事件被触发时,请将 doOnError 函数加入待执行队列。”
server 对象的重要性(再次强调)
http.createServer() 在 JavaScript 中返回的 server 对象是至关重要的。它是我们与后台 Node 服务实例进行持续交互和配置的唯一接口。
- 所有对后台服务的修改(设置端口、添加事件监听器等)都必须通过调用这个对象上的方法来实现。
- Charlie 的总结非常到位:
createServer 在 Node/C++ 中的“输出”是建立了一个后台 socket。
createServer 在 JavaScript 中的“输出”是返回一个包含了一系列可以修改 Node 行为的方法的对象。
25-modifying-the-node-server
- 再次梳理核心流程
- 设置端口:调用
server.listen(80)。这个函数在 JavaScript 中没什么有趣的,它的主要作用是编辑 Node 后台的 HTTP 实例,将其监听端口设置为 80。
- 设置事件监听器:调用
server.on(...)。
- Node 内部已经预设了在特定条件下会触发某些事件(如
request, clientError)。这些是内置的“关键词”。
server.on('request', doOnIncoming):这行代码的作用是在 Node 后台建立一个映射关系。它告诉 Node:“如果 request 事件被触发,就自动运行 doOnIncoming 函数”。
server.on('clientError', doOnError):同理,建立 clientError 事件与 doOnError 函数的映射。
- 与之前方式的对比
- 将回调函数直接传给
http.createServer(doOnIncoming) 是一种隐式的、简化的写法。它在后台做的就是 server.on('request', doOnIncoming) 这件事。
- 手动使用
.on() 方法给了我们更精细的控制,让我们能明确地处理多种不同的事件,比如错误事件。
- 事件系统的本质
- Node 的事件系统为我们的“自动运行函数”模型增加了一层细粒度的控制。
- 我们可以根据具体发生的事件(
request, clientError, 或其他自定义事件)来决定到底应该执行哪一个回调函数。
26-node-event-handling-in-action
- 处理错误请求的流程
- 损坏的请求到达:一天后,一个来自客户端的 HTTP 请求到达服务器。这个请求可能因为各种原因(URL 拼写错误、数据格式损坏等)是“损坏的”。
- Node 检测到错误:Node 在内部检查这个请求,发现它有问题。
- 触发
clientError 事件:因为请求是损坏的,Node 不会触发 request 事件,而是触发 clientError 事件。
- 执行错误处理函数:由于我们之前设置了
server.on('clientError', doOnError),这个事件会触发 doOnError 函数的执行。
- 错误处理函数的执行
- 自动执行:Node 自动调用
doOnError 函数,并创建一个新的执行上下文。
- 自动传入参数(Argument):
- Node 会自动创建一个错误对象 (Error Object)。这是一个特殊的 JavaScript 对象,包含了关于错误的详细信息(如堆栈跟踪)。
- 这个错误对象会作为第一个参数被自动传入
doOnError 函数。
- 在函数内部:
- 我们通过参数
infoOnError 来接收这个错误对象。
console.error(infoOnError):我们使用 console.error(而不是 console.log)来打印这个错误对象,它通常会以更易读的格式显示错误信息。
- 向客户端返回错误状态
- 即使发生了服务器端错误,我们通常也应该向客户端返回一个响应,告知它请求失败了。
- HTTP 状态码:
400 Bad Request 是一个标准的状态码,表示客户端发送了一个服务器无法理解的错误请求。
- 如何发送状态码?
- 在
clientError 事件中,Node 也会传入第二个参数,它提供了对底层 socket 的原始访问能力。
- 我们可以用这个原始访问能力,手动构建一个包含
400 状态码的 HTTP 响应并发回给客户端。这样,用户的浏览器就会显示一个错误,而不是无限期地等待。
27-introducing-the-file-system-api
- Node 的核心功能回顾
- Node 的扩展能力:文件系统
- Node 不仅能处理网络,还能与计算机的文件系统 (File System) 交互。这是它的另一个强大功能。
- 假设我们有大量的推文数据(1.5GB),存储在服务器的硬盘上的一个文件中,而不是在 JavaScript 的内存里。
- 访问文件系统
- 我们需要用 JavaScript 来读取这个文件,比如为了清理其中的不良内容。
fs 模块:Node 提供了 fs (File System) 模块,它是一系列用 C++ 编写的、可以访问计算机文件系统的功能集合。
fs 就是我们用来触发这些底层功能的 JavaScript 标签。
- 处理大文件的挑战
- 读取一个 1.5GB 的大文件需要很长时间(可能需要几秒钟)。
- 将整个文件一次性读入内存再进行处理,可能效率不高。
- 预告:Node 提供了更优化的方式来处理大文件。它可能每读取一小块数据(比如 64KB),就触发一次事件,让我们能以“流”的方式边读边处理。
- 数据大小的概念
- 1 字节 (Byte) = 8 位 (bits),可以表示 256 种组合。
- 1 个英文字符通常是 1 字节。
- 1 个 emoji 通常是 2 字节或更多。
- 1.5 GB 大约是 15 亿个字符,这是一个巨大的数据量。
28-importing-with-fs
- 使用
fs 模块读取文件
fs.readFile() 是 fs 模块中用于异步读取文件的核心函数。
- 第一个参数(路径):
('./tweets.json')
- 这是一个字符串,告诉 Node 要读取哪个文件。
. 代表当前工作目录。这个目录是指你在终端中运行 node 命令时所在的目录,不一定是 .js 文件所在的目录。
/tweets.json 指的是在当前工作目录下寻找名为 tweets.json 的文件。
- JSON (JavaScript Object Notation):
- JavaScript 对象只能存在于 JavaScript 运行时的内存中。
- 如果想把一个对象永久保存到文件或通过网络传输,必须将其转换成一种通用的、基于文本的格式。
- JSON 就是这种格式。它本质上是一个字符串,但遵循着能被解析回 JavaScript 对象的特定语法。
JSON.stringify():将 JavaScript 对象转换为 JSON 字符串。
JSON.parse():将 JSON 字符串解析回 JavaScript 对象。
- 使用新的底层功能
- 我们正在使用计算机的另一个底层功能:文件存储 (File Storage)。
- 我们的
tweets.json 文件就保存在这个文件存储中。
- 我们的 JavaScript 代码无法直接访问它,必须通过 Node 的
fs 模块(C++)作为桥梁。
libuv 在文件系统操作中扮演了重要角色,它会启动专门的线程来处理耗时的文件 I/O 操作。
29-reading-from-the-file-system-with-fs
- 读取文件的完整流程
fs.readFile('./tweets.json', useImportedTweets): 这是核心命令。
- 它向 Node 的
fs 功能发出指令。
- 第一个参数: 文件路径
'./tweets.json'。
- 第二个参数: 回调函数
useImportedTweets。Node 会在文件读取完成后自动运行这个函数。
- Node 的后台工作:
- Node 的
fs 模块接收到指令,通过 libuv 访问计算机的文件系统。
libuv 会启动一个专用的线程 (thread) 来执行这个耗时的文件读取操作。这与网络 I/O 不同,网络 I/O 通常依赖操作系统自身的线程管理。
- 这个线程开始从硬盘中拉取 1.5 GB 的数据。这个过程非常慢,可能需要 15 秒。
- 文件读取完成:
- 15 秒后,文件内容被完全读入 Node。
- Node 准备执行回调函数
useImportedTweets。
- 自动执行回调并传入参数:
- Node 调用
useImportedTweets,并自动传入两个参数:
- 第一个参数(错误优先): 一个错误对象。如果读取成功,这个参数的值是
null。这是 Node 中常见的“错误优先 (error-first)”回调模式。
- 第二个参数(数据): 读取到的文件内容。
- 数据格式 - Buffer:
- 技术上,Node 读取文件后得到的数据不是直接的字符串,而是一种叫做 Buffer 的特殊数据类型。
- Buffer 是 Node 用来表示二进制数据(一串 0 和 1)的方式,非常灵活,可以代表任何类型的数据(文本、图片、视频等)。
- 在我们的回调函数中,需要先将这个 Buffer 转换成我们需要的格式(比如用
toString() 转为字符串,再用 JSON.parse() 转为对象)。
- 回调函数的执行
- 在
useImportedTweets 函数内部,我们接收到 errorData (值为 null) 和 data (一个 Buffer)。
- 我们可以检查
errorData 是否为 null 来判断操作是否成功。
- 然后我们对
data 进行处理(如清理、解析),最终得到可以在 JavaScript 中使用的推文对象。
30-call-stack-introduction
- 调用栈 (Call Stack)
- JavaScript 使用调用栈来追踪当前正在执行的函数。
- 栈顶的函数就是当前正在运行的函数。
- 全局执行上下文: 整个代码文件可以看作一个大的
global() 函数,它最先被压入栈底。
- 当一个函数被调用时,它会被压入 (push) 到调用栈的顶部。
- 当一个函数执行完毕(
return 或执行到末尾),它会从栈顶弹出 (pop),控制权交还给栈中的下一个函数。
fs.readFile 回调的执行流程
- 15 秒后,
useImportedTweets 被 Node 自动调用,它被压入调用栈(在 global 之上)。
- 参数接收:在
useImportedTweets 的局部内存中,errorData 被赋值为 null,data 被赋值为包含 1.5GB 推文数据的 Buffer。
- 调用
cleanTweets(data):
cleanTweets 函数被调用,它被压入调用栈的顶部。
cleanTweets 花了 10 秒钟清理了数据,返回清理后的 JSON 字符串。
cleanTweets 执行完毕,从调用栈弹出。
- 调用
JSON.parse(...):
JSON.parse 是一个内置函数,技术上它也被压入和弹出调用栈。
- 它将清理后的 JSON 字符串转换为一个真正的 JavaScript 对象
tweetsObj。
console.log(tweetsObj.tweet2): 打印出清理和解析后的数据。
useImportedTweets 函数执行完毕,从调用栈弹出。
- 耗时问题
- 整个过程耗时非常长(15s 读取 + 10s 清理 = 25s)。
- 这是一个非常消耗时间的操作。引出一个问题:在等待文件读取和数据清理的时候,我们的 Node 应用能做其他事情吗?或者,我们能一边读取数据一边清理吗?这是对 Node 异步非阻塞能力更深层次的思考。
31-file-system-q-a
- Q: 内置函数(如
JSON.parse)会进入调用栈吗?
- A: 技术上是的。但因为我们无法控制其内部执行,所以在画图理解时,通常只关注我们自己编写和调用的函数,以避免干扰。
- Q:
fs.readFile 返回的数据一定是 JSON 字符串吗?
- A: 不是。它返回的是Buffer,一种原始的二进制数据格式。Buffer 可以代表任何数据。我们需要自己运行函数(如
.toString() 或 JSON.parse)将 Buffer 转换成我们需要的格式。JSON.parse 内部会先将 Buffer 转为字符串再解析。这是一个很重要的技术细节。
- Q: 必须
require('fs') 吗?
- A: 是的,必须。和
http 一样,fs 也是 Node 的核心模块,使用前必须通过 const fs = require('fs'); 来引入。
- Q:
errorData 这个参数名是固定的吗?
- A: 不是固定的。这是一个参数 (parameter),只是一个占位符。你可以叫它任何名字。重要的是顺序。Node 在这个回调中,总是将错误信息作为第一个参数传入。
- 错误优先模式 (Error-First Pattern)
- Q: 如果文件未找到,会发生什么?
- A: 如果文件未找到,
fs.readFile 操作会失败。这时,Node 会调用我们的回调函数,并将一个描述“文件未找到”的错误对象作为第一个参数传入,而第二个数据参数将是 undefined。我们的责任就是在回调函数内部检查并处理这个错误。
32-introducing-node-streams
- 回顾 Node 的事件模式
- Node 内部有事件系统,当后台发生事情时,会广播一个消息(事件),这个事件可以触发一个我们预设的函数自动运行。
- 将事件模式应用于文件读取
- 问题:之前,我们等待整个 1.5GB 的文件被读完(15 秒),然后再花 10 秒清理它,总共 25 秒。
- 优化思路(流):
- Node 每从文件中读取一小批 (batch) 数据(比如 64KB),就广播一个事件。
- 我们可以设置一个函数,让它在每次这个事件发生时自动运行。
- 这个函数接收到这一小批数据,并立即开始清理它。
- 并行处理
- 当我们的 JavaScript 代码正在清理第一批数据时,Node 的后台线程(由 libuv 管理)可以同时去读取第二批数据。
- 当第二批数据准备好时,它会再次触发事件,准备运行清理函数。
- 这样,数据读取和数据处理这两个耗时的操作就可以近乎并行地进行。
- 最终,总耗时将大约等于两者中较长的那一个(在这个例子中是读取文件的 15 秒),而不是两者的总和。
- 新的挑战:函数执行的时机
- 当第一批数据的清理函数还在运行时,第二批数据可能已经准备好,并触发了第二次清理函数的运行请求。
- 问题:JavaScript 是单线程的,不能同时运行两个函数。
- 解决方案:必须有一套严格的规则来管理这些“待运行”的函数。第二个清理函数必须排队 (queue),等待第一个函数执行完毕后才能开始。
- 这引出了队列 (Queues) 的概念,是理解 Node 事件循环的关键部分。
33-node-streams-overview
- 流 (Streams) 不是“流”
- “流”这个词听起来像是连续不断、难以控制的数据洪流。
- 在 Node.js 中,这个概念更容易理解为一批批 (batches) 或 一块块 (chunks) 的数据。
- 想象成是一桶桶的水,而不是一条河。
- 工作机制
- Node 从文件或网络中获取数据,每当装满一个“桶”(默认大小是 64KB,这个值被称为“高水位线 (high watermark)”),它就会:
- 广播一个名为
data 的事件。
- 这个事件会触发我们预先设置好的回调函数。
- 这个回调函数会接收到这一“桶”的数据,并可以立即对其进行处理。
- 为什么这个特性很重要?
- 这是 Node.js 引以为傲的核心特性之一。
- 它提供了极高的效率,尤其是在处理大规模数据(如大文件上传下载、视频流)时。
- 它完美地体现了 Node 的非阻塞 I/O 哲学:在等待 I/O 操作(如读取下一块数据)时,主线程可以继续执行其他计算任务(如处理上一块数据),从而最大化地利用了 CPU 资源。
- 与视频流的关系
- Netflix 使用 Node.js 的原因之一就是其强大的流处理能力。
- 视频传输确实是以块的形式进行的。
- 但在实时视频聊天等场景中,底层协议可能不是 HTTP/TCP,而是更适合实时传输的 UDP。
34-setting-up-the-stream
- 使用流读取文件的步骤
const cleanedTweets = '';: 初始化一个空字符串,用于累积清理后的数据。
function cleanTweets(...), function doOnNewBatch(...): 定义两个辅助函数。doOnNewBatch 将是我们的事件处理回调。
const accessTweetsArchive = fs.createReadStream('./tweets.json');:
- 核心变化:我们不再使用
fs.readFile,而是使用 fs.createReadStream()。
- 在 Node/底层: 这个命令会告诉
libuv 启动一个专门的线程,准备从指定的文件中开始以流的形式拉取数据。
- 在 JavaScript: 这个方法立即返回一个对象,我们称之为“流对象 (stream object)”,并赋值给
accessTweetsArchive。这个对象代表了到 tweets.json 文件的一个打开的连接。
- 流对象的作用:
- 这个返回的
accessTweetsArchive 对象和之前 http.createServer 返回的 server 对象一样,是我们在 JavaScript 中控制这个后台操作的接口。
- 它上面挂载了一系列方法,最重要的是
.on(),用来监听流事件。
accessTweetsArchive.on('data', doOnNewBatch);:
- 我们在流对象上调用
.on() 方法来设置事件监听器。
'data': 这是一个由流自动触发的内置事件。每当 Node 从文件中读取一个数据块(默认 64KB)时,就会触发这个事件。
doOnNewBatch: 这是我们提供的回调函数。每次 'data' 事件触发时,Node 都会自动调用它,并将读取到的那个数据块作为参数传入。
- 关键点
createReadStream 只是建立了连接,真正的文件数据拉取要等到我们设置了 'data' 事件的监听器之后才会开始。
35-processing-data-in-batches
- 流处理的动态过程
- 开始流动: 当我们设置了
accessTweetsArchive.on('data', ...) 后,数据流开始。
- 第一个数据块到达: 大约 1 毫秒后,第一个 64KB 的数据块从文件系统被读入 Node。
- Node 触发
data 事件。
- Node 自动调用
doOnNewBatch 函数,并将这个数据块(一个 Buffer)作为参数传入。
- 处理第一个数据块:
doOnNewBatch 函数被压入调用栈。
- 在函数内部,我们调用
cleanTweets 函数来处理这个数据块。
cleanTweets 函数被压入调用栈。它开始执行清理工作,这需要一些时间(比如 1.5 毫秒)。
- 清理后的数据被添加到全局的
cleanedTweets 字符串中。
- 第二个数据块到达 (并行):
- 在
cleanTweets 还在处理第一个数据块的同时,Node 的后台线程已经成功读取了第二个 64KB 的数据块。
- 在第 2 毫秒时,第二个数据块准备就绪,Node 再次触发
data 事件,并准备再次调用 doOnNewBatch。
- 引入队列 (The Queue)
- 冲突: 此时,调用栈并不为空(
doOnNewBatch 和 cleanTweets 还在里面)。JavaScript 是单线程的,不能中断当前任务去执行新的任务。
- 解决方案: Node 不会立即执行第二次的
doOnNewBatch。相反,它会将这个“待执行的任务”放入一个特殊的等待区域,这个区域被称为 回调队列 (Callback Queue)。
- 这个待执行的
doOnNewBatch 函数会安静地在队列里排队。
- 完成第一个任务并检查队列
- 在第 2.5 毫秒时,
cleanTweets 完成了对第一个数据块的清理,从调用栈中弹出。
- 紧接着,
doOnNewBatch (第一次调用) 也执行完毕,从调用栈中弹出。
- 现在,调用栈为空了。
36-checking-the-callback-queue
- 事件循环 (The Event Loop)
- 作用: Node 内部有一个持续运行的机制,叫做事件循环。它的核心工作之一就是检查调用栈 (Call Stack) 和回调队列 (Callback Queue) 的状态。
- 规则:
- 事件循环会问:“调用栈是空的吗?”
- 如果调用栈是空的(意味着当前没有 JavaScript 同步代码在运行),它就会去检查回调队列。
- 如果回调队列里有等待执行的函数,事件循环就会取出队列中的第一个函数,并将它压入调用栈,然后执行它。
- 处理第二个数据块
- 在 2.5 毫秒时,调用栈变空了。
- 事件循环发现调用栈是空的,于是去检查回调队列。
- 它发现队列里有第二次调用的
doOnNewBatch 正在等待。
- 事件循环将
doOnNewBatch 从队列中取出,放入调用栈,开始执行。
- 这个过程会重复进行:
doOnNewBatch 调用 cleanTweets 来处理第二个数据块,处理完的数据追加到 cleanedTweets 字符串上。
- 流处理的优势
- 这个模型允许我们在后台 I/O (文件读取) 和前台计算 (数据清理) 之间实现高效的并行处理。
- 我们不再需要等待所有数据都加载完毕才开始工作,而是以“流水线”的方式,一块一块地处理数据。
- 这大大缩短了从开始到完成的总时间,是 Node.js 性能优越的关键原因之一。
- 关于队列的进一步说明
- 讲师在这里人为地设计了“清理时间 > 读取时间”的场景,就是为了引出“任务需要在队列中等待”这一核心概念。
- 实际上,Node 的队列系统比这里展示的更复杂,不止一个队列。我们将在最后一部分深入了解这个完整的模型。
37-node-streams-q-a
- Q: 所有事件都共享同一个回调队列吗?
- A: 不是。 这是个非常重要的问题。Node 内部有多个不同的队列,用于处理不同类型的异步任务(如 I/O、定时器等)。我们将在后面的内容中详细探讨。但可以确定的是,所有 I/O 相关的回调(如
fs 和 http)通常会进入同一个队列。
- Q: 文件读完后,会触发什么事件吗?
- A: 是的。当流读取到文件末尾并关闭时,它会触发一个
close 事件(或其他类似的,如 end)。我们可以在这个事件上设置一个回调,例如,用来对所有拼接好的数据进行最后的 JSON.parse。
- Q: 流处理如何处理非字符串数据?
- A: 再次强调,流中的数据块是以 Buffer 的形式存在的。Buffer 是原始的二进制数据。我们可以将 Buffer 转换成字符串 (
.toString()),或者直接操作二进制数据。这使得流可以处理任何类型的文件,如图片、视频等。
- Q: 如果数据块正好在一个 JSON 对象的中间被切断,怎么办?
- A: 这是一个实际的解析挑战。我们不能对不完整的数据块直接运行
JSON.parse。正确的做法是:
- 将每个 Buffer 块转换成字符串。
- 将这些字符串拼接成一个完整的、巨大的字符串。
- 在
close 事件触发后(表示所有数据都已接收并拼接完毕),对这个最终的完整字符串运行一次 JSON.parse。
- Q: 如果读取文件需要 15 秒,浏览器的请求会超时吗?
- A: 这个文件读取操作通常是在服务器启动时进行的预处理,或者是一个与用户请求无关的后台任务。我们不会在响应单个用户请求的生命周期内去同步读取一个 15 秒的文件,那样请求肯定会超时。但如果是向用户发送大文件(如视频),就会用到流,边读边发,以避免超时。
- Q: 有什么能延迟一个任务被加入回调队列吗?
- A: 任务被放入回调队列的时机是在其后台操作完成时(由
libuv 处理)。事件循环只有在调用栈为空时才会去检查队列。所以,即使一个任务早就准备好了,如果主线程一直在忙于执行同步代码,这个任务也只能在队列里等待。执行时机才是关键,而不是入队时机。
- Q: 如何处理单词被数据块切断的情况?
- A: 这是一个常见的流处理问题。一种策略是,在处理一个数据块时,检查最后一个单词是否完整。如果不完整,就将这个不完整的片段缓存起来,然后拼接到下一个数据块的开头,再进行处理。
38-introduction-to-asynchronicity
- 回顾及引子
- 上一节我们看到了一个真实的场景:使用流(streams)分批处理数据,并且当处理速度跟不上数据到达速度时,待处理的函数会进入回调队列 (Callback Queue)。
- 事件循环 (Event Loop) 负责决定何时从队列中取出函数并放到调用栈 (Call Stack) 上执行。
- Node 的异步核心
- Node 的强大之处在于它能在正确的时机自动执行我们提供的 JavaScript 函数,而我们无需等待后台任务完成。
- 但这也意味着我们需要精确地理解 Node 是如何决定什么函数在什么时刻被执行的。
- 实验性代码场景
- 接下来我们将分析一段“不那么真实”的代码。
- 这段代码的目标不是为了完成某个实际任务,而是为了系统性地展示 Node 事件循环和不同队列的工作原理。
- 我们将使用不同的 Node 功能来“延迟”三个函数的执行:
setTimeout(..., 0): 使用定时器延迟 0 毫秒。
fs.readFile(...): 使用文件 I/O,等待文件读取完成。
setImmediate(...): 使用一个特殊的 Node 函数。
- 我们还会有一个长时间运行的同步函数 (
blockFor500ms),它会占用主线程 500 毫秒。
- 揭示 Node 的多队列模型
- 通过这个实验,我们会发现:
- 不同的异步操作,其回调函数会被放入不同的队列中。
- 事件循环在从这些队列中取任务时,有着严格的优先级顺序。
- 只有在所有同步代码都执行完毕后,事件循环才会开始处理这些队列。
39-timer-queue
- 代码准备
- 首先,在全局内存中定义并保存四个函数:
useImportedTweets, immediately, printHello, blockFor500ms。
- 1. 处理
setTimeout
setTimeout(printHello, 0):
setTimeout 不是原生 JavaScript 功能,它是由 Node (C++) 实现的。
- 这个命令在 Node 后台设置了一个定时器,延迟时间为 0 毫秒,并关联了
printHello 函数。
- 时间点 0ms: 定时器立即完成。
- 是否立即执行
printHello? 否。所有由 Node 自动执行的回调函数,都必须等待当前所有的同步代码执行完毕。
- 去向: 完成的定时器回调函数
printHello 被放入了第一个队列——定时器队列 (Timer Queue)。
- 2. 处理
fs.readFile
fs.readFile('./tweets.json', useImportedTweets):
- 这个命令在 Node 后台启动了一个文件读取操作,并关联了
useImportedTweets 回调。
- 时间点 1ms: 文件读取操作开始,但它需要时间才能完成(比如 200ms),所以
useImportedTweets 函数还不会被触发。
- 3. 执行同步代码
blockFor500ms()
- 时间点 2ms:
blockFor500ms 函数被调用。
- 它被压入调用栈 (Call Stack)。
- 这个函数内部是一个高强度的计算任务(如一个巨大的循环),它将阻塞主线程 500 毫秒。它与 Node 的后台定时器无关,是纯粹的 JavaScript 计算。
40-io-queue
- 并行发生的事情
- 在
blockFor500ms 函数占用主线程的 500 毫秒期间,Node 的后台并没有闲着。
- 时间点 200ms:
- 文件读取完成: Node 的后台线程成功读取了
tweets.json 文件的内容。
- 准备回调:
useImportedTweets 函数现在已经准备好被执行。
- 函数去向: 这个准备好的回调函数不会立即执行,因为它必须等待调用栈清空。它被放入了第二个队列——I/O 回调队列 (I/O Callback Queue)。
- 绝大多数(约 95%)与输入/输出相关的 Node 回调(如文件、网络)都会进入这个队列。
- 同步代码执行完毕
- 时间点 502ms:
blockFor500ms 函数执行完毕,从调用栈中弹出。
- 事件循环下一步做什么? 它并不会立即去检查队列,因为它发现还有更多的全局同步代码需要执行。
console.log('me first') 被执行,在控制台打印出 'me first'。
setImmediate 的引入
setImmediate(immediately): 这是下一个要执行的同步代码。
setImmediate 是 Node 提供的一个特殊功能,它的作用是将其回调函数放入第三个优先级的队列。
- 这个功能的设计意图是,确保一个函数在当前事件循环轮次中所有 I/O 回调都执行完毕之后再执行。
41-check-queue
setImmediate 的工作原理
- 当
setImmediate(immediately) 执行时:
- 它告诉 Node 将
immediately 函数放入一个特殊的队列,这个队列被称为 检查队列 (Check Queue)。
- 时间点 503ms:
immediately 函数进入了检查队列。
- 命名是最大的误导:
setImmediate 并不会“立即”执行,反而它的优先级非常靠后。
- 事件循环开始工作
- 时间点 504ms:
- 所有的全局同步代码都已执行完毕。
- 调用栈现在是空的。
- 事件循环正式开始它的“检查队列”周期。
- 事件循环的优先级顺序
- 第一站:定时器队列 (Timer Queue)
- 事件循环首先检查定时器队列。
- 它发现了
printHello 函数。
- 它将
printHello 从队列中取出,压入调用栈并执行。
- 控制台打印出 "hello"。
printHello 执行完毕并出栈。
- 第二站:I/O 回调队列 (I/O Callback Queue)
- 事件循环接着检查 I/O 队列。(下一个视频会讲这里)
- 第三站:检查队列 (Check Queue)
- 在处理完 I/O 队列后,事件循环会检查检查队列。
- 小结
- 我们看到了 Node 有多个队列,并且事件循环按照固定的优先级顺序(Timer -> I/O -> Check -> ...)来处理它们。
- 即便一个任务(如
setTimeout 0ms)很早就准备好了,它也必须在自己的队列里等待,直到轮到它的队列被事件循环处理。
42-event-loop-completion
- 事件循环继续执行
- 时间点 505ms:
- 检查 I/O 队列: 事件循环检查 I/O 回调队列,发现了自 200ms 起就在等待的
useImportedTweets 函数。
- 执行 I/O 回调:
useImportedTweets 被从队列中取出,压入调用栈执行。
- Node 自动传入
errorData (null) 和 data (Buffer) 作为参数。
- 函数内部,数据被解析,然后
console.log 打印出推文内容 "hello"。
useImportedTweets 执行完毕并出栈。
- 时间点 506ms:
- 检查 Check 队列: 在处理完 I/O 队列后,事件循环接着检查 Check 队列。
- 执行 Check 回调:
- 它发现了
immediately 函数。
immediately 被从队列中取出,压入调用栈执行。
console.log 打印出 "run me last"。
immediately 执行完毕并出栈。
- 最终输出顺序
me first (同步代码)
hello (来自 Timer 队列)
hello (来自 I/O 队列,是另一条推文)
run me last (来自 Check 队列)
- 总结
- 我们通过一个精心设计的例子,完整地追踪了不同异步任务的回调函数是如何被放入不同队列,并由事件循环按优先级顺序依次执行的。这揭示了 Node.js 异步执行模型的核心机制。
43-microtask-close-queues
- Node 事件循环的完整队列模型
- 除了我们已经看到的三个队列,还有另外三个,构成了 Node 事件循环的完整六个主要阶段/队列。
- 微任务队列 (Microtask Queue) - 最高优先级
- 这个队列的优先级高于所有其他队列。
- 它本身还分为两个子队列:
- a.
process.nextTick 队列: 通过 process.nextTick(callback) 添加的回调会进入这里。这是最高中的最高优先级。
- b. Promise 队列: 由 Promises 的
.then(), .catch(), .finally() 产生的回调会进入这里。
- 特殊行为: 事件循环在从一个宏任务队列(如 Timer, I/O)切换到下一个之前,会清空整个微任务队列。这意味着微任务有插队执行的能力。
- 4. 关闭句柄队列 (Close Handlers Queue)
- 当一个句柄(如一个 socket 或文件流)被关闭时,会触发
close 事件。
- 与
close 事件相关的回调函数会进入这个队列。
- 它的优先级在 Check 队列之后。
- 完整的事件循环周期 (简要)
- 一个完整的事件循环 tick (轮次) 会按以下顺序检查队列:
- Timers Queue
- (检查微任务)
- I/O Callbacks Queue
- (检查微任务)
- Check Queue (setImmediate)
- (检查微任务)
- Close Handlers Queue
- (检查微任务)
- 这个循环会一直持续,只要 Node 应用中还有未完成的后台任务。
44-priority-of-queue-execution
- 总结 Node 异步执行规则
- 延迟与排队: 所有被我们延迟执行(通过 Node API)的函数(回调)在后台任务完成后,不会立即执行,而是被放入相应的任务队列中排队。
- 调用栈优先: 只有当调用栈完全为空时,事件循环才会开始考虑从队列中取出任务来执行。
- 队列优先级:
- 微任务队列(
process.nextTick 和 Promises)拥有最高优先级。
- 在宏任务队列中,优先级顺序为:
- 定时器队列 (Timer Queue)
- I/O 回调队列 (I/O Queue)
- 检查队列 (Check Queue /
setImmediate)
- 关闭队列 (Close Queue)
- 关键 takeaway
- 这个复杂的模型是 Node.js 高性能、非阻塞特性的基石。虽然在日常开发中不常直接面对所有这些队列,但理解这个模型对于编写健壮的 Node 应用和进行深度调试至关重要,尤其是在面试和解决复杂性能问题时。
45-asynchronicity-in-node-q-a
- Q: 是否所有 Node 功能都需要
require?
- A: 不是。像
setTimeout 和 setImmediate 这样的全局函数不需要 require。但是大多数核心模块,如 fs, http, path 等,都必须通过 require 引入。Node 文档会明确说明哪些需要引入。
- Q: 事件循环本身是什么?可以自己实现吗?
- Q: 队列本身是什么?
- A: 队列是一种数据结构,其特点是“先进先出 (FIFO)”。你可以用数组和一些方法来模拟它。在 JavaScript 中,可以创建一个数组来存放待执行的函数,然后用
.shift() 方法取出第一个函数来执行。
46-wrapping-up
- 课程终极总结
- Node.js 的强大,根植于其核心架构:
- JavaScript 作为接口: 我们使用 JavaScript 编写代码。
- Node C++ 作为桥梁: JS 代码通过“标签”触发 Node 的 C++ 功能。
- 计算机底层作为能力来源: C++ 功能能直接访问计算机的底层硬件和服务,如网络、文件系统等。
- 核心模式:
- 当一个后台事件发生时(如网络请求到达、文件读取完成),Node 不会阻塞,而是:
- 将我们预先提供的回调函数放入相应的队列。
- 在适当的时机(由事件循环决定),自动执行这个函数。
- 自动为函数填充所需的参数 (Arguments),如请求数据、错误信息、响应工具等。
- 展望
- Node.js 的世界还有更多可以探索,例如:
- 模块化: 如何将代码分割到多个文件,并用
require 和 module.exports 组织起来(基于闭包)。
- 集群 (Cluster): 如何利用多核 CPU。
- 新的 API: 如基于 Promise 的
fs 模块 (fs/promises)。
- 最终致谢