Node.js 核心和 MidwayJS 框架的知识点
好的,我们继续深入 Node.js 核心和 MidwayJS 框架的知识点。
1. Node.js 模块机制
CommonJS vs ES Module (ESM)
这是 JavaScript 两种主流的模块规范,Node.js 现已同时支持两者。
特性 | CommonJS | ES Module (ESM) |
---|---|---|
语法 | require() / module.exports |
import / export |
加载方式 | 动态加载、运行时同步执行 | 静态加载、编译时解析 |
值引用 | 值的拷贝(导出的是值的副本) | 值的引用(导出的是活的只读引用) |
顶层 this |
指向当前模块 | 指向 undefined |
循环依赖 | 支持,但处理逻辑复杂 | 支持,设计上处理得更好 |
适用场景 | Node.js 服务端 | 浏览器和现代 Node.js |
关键区别示例:
// counter.cjs (CommonJS)
let count = 0;
module.exports = { count, increment: () => count++ };
// main.cjs
const { count, increment } = require('./counter.cjs');
console.log(count); // 0
increment();
console.log(count); // 0 (count 是原始值的拷贝,不会变)
// counter.mjs (ESM)
export let count = 0;
export const increment = () => count++;
// main.mjs
import { count, increment } from './counter.mjs';
console.log(count); // 0
increment();
console.log(count); // 1 (count 是对原值的引用)
在 Node.js 中的使用:
- 默认情况下,
.js
和.cjs
文件被解析为 CJS,.mjs
文件被解析为 ESM。 - 在
package.json
中设置"type": "module"
后,.js
文件将被视为 ESM。 - 互操作:在 CJS 中无法使用
require()
加载 ESM 模块(会报错)。在 ESM 中可以使用import
动态导入 CJS 模块(import('./legacy.cjs').then(...)
),但导入的仍然是 CJS 风格的导出。
require
的查找路径
当使用 require('X')
时,Node.js 会按以下顺序查找:
- 核心模块(如
fs
,path
):直接返回。 - 绝对路径/相对路径(如
require('./myModule')
):直接按路径加载。 - 第三方模块(如
require('lodash')
):
a. 在当前目录下的node_modules
文件夹中查找。
b. 如果没找到,向父目录递归查找,直到文件系统的根目录。
c. 在node_modules
中,查找package.json
的main
字段指定的文件。
d. 如果没有package.json
或main
字段,则依次尝试加载index.js
,index.json
,index.node
。 - 目录作为模块(如
require('./some-folder')
):
a. 查找some-folder/package.json
中的main
字段。
b. 如果没有,则尝试加载some-folder/index.js
。
2. 事件驱动、非阻塞I/O模型
这是 Node.js 高并发能力的基石。
-
非阻塞I/O:当 Node.js 执行一个 I/O 操作(如读取文件、网络请求)时,它不会傻等结果。它会立即返回并继续执行后面的 JavaScript 代码。等 I/O 操作完成后,通过回调函数(或 Promise、async/await)来通知并处理结果。
- 优势:单线程可以同时处理大量 I/O 请求,而不是被一个慢速的 I/O 操作阻塞。
-
事件驱动:Node.js 的核心是一个事件循环(Event Loop)。它不断地检查是否有待处理的事件(如完成的I/O操作、定时器到期)。如果有,就取出对应的事件并执行其回调函数。
- libuv:Node.js 使用 C 库 libuv 来抽象和管理这些异步 I/O 操作和事件循环。
高并发原理图解:
想象一个Web服务器处理请求:
总结:Node.js 的单线程指的是执行 JavaScript 代码和事件循环的线程是单一的。但底层的 I/O 操作(文件、网络等)是由 libuv 的线程池(默认4个)或操作系统本身(如网络请求)来并行处理的。这种“一个主线程 + 线程池”的模型,用很少的资源(一个进程)就实现了极高的 I/O 并发能力,非常适合 I/O 密集型的应用(如Web服务器、API网关)。
3. Stream(流)
流是用于处理连续数据的抽象接口,尤其适合处理大文件或持续产生的数据。
四种流类型
- Readable:可读流(数据来源)。例如:
fs.createReadStream
,http request
。 - Writable:可写流(数据目的地)。例如:
fs.createWriteStream
,http response
。 - Duplex:双工流(既可读又可写)。例如:
TCP socket
。 - Transform:转换流(在读写过程中可修改或转换数据)。例如:
zlib.createGzip()
(压缩)。
处理大文件
使用流可以极高地提升内存效率,因为数据不需要全部加载到内存中再处理,而是像流水一样一部分一部分地处理。
错误示例(内存爆炸):
// 如果file非常大(如2GB),内存就爆了
fs.readFile('huge-file.txt', (err, data) => {
// ...处理data
});
正确示例(流式处理):
const readStream = fs.createReadStream('input.txt');
const writeStream = fs.createWriteStream('output.txt');
// 管道:将可读流的数据直接导入可写流
// 数据会以chunk的形式流动,内存中只保留一小部分数据
readStream.pipe(writeStream);
// 也可以在中间加入转换流,例如压缩
// readStream.pipe(zlib.createGzip()).pipe(writeStream);
背压问题 (Back Pressure)
问题:数据源(Readable流)产生数据的速度 > 数据目的地(Writable流)消费数据的速度。这会导致数据在内存中堆积,最终耗尽内存。
解决方案:Node.js 流机制内置了背压控制。
- 当
writeStream.write(chunk)
返回false
时,表示写入队列已满(缓冲区满了),建议暂停读取。 - 可读流接收到这个信号,会暂停(pause)数据流动。
- 当写入队列清空后,Writable 流会发出一个
'drain'
事件。 - 可读流接收到
'drain'
事件,恢复(resume)数据流动。
.pipe()
方法自动帮你处理了所有这些背压逻辑,这是推荐的使用方式。
4. 进程与集群
Node.js 是单进程的,为了充分利用多核 CPU,必须启动多个进程。
Child Process 模块
用于创建和管理子进程。常见方法:
spawn
:最基础的方法,用于启动一个子进程来执行命令。fork
:spawn
的特例,专门用于新建一个 Node.js 子进程。子进程与父进程之间会建立 IPC 通信通道,可以通过send()
和on('message')
交换消息。exec
:用于执行shell命令,并将结果缓冲后在回调中返回。适合输出量小的命令。execFile
:类似于exec
,但直接执行可执行文件,而不用先启动一个 shell。
Cluster 模块
基于 child_process.fork()
和 net
模块,简化了创建共享同一端口的多进程 Web 服务器的过程。
工作原理(主从模式):
- Master 进程:
- 负责启动和管理。
- 监听端口。
fork
出多个 Worker 进程(通常按 CPU 核心数)。- 将接收到的连接请求轮询地分发给各个 Worker(默认策略)。
- Worker 进程:
- 由 Master fork 出来。
- 执行真正的业务逻辑(如你的 HTTP 服务器代码)。
- 共享同一个服务器端口。
const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;
if (cluster.isMaster) {
console.log(`Master ${process.pid} is running`);
// Fork workers
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
} else {
// Workers can share any TCP connection
// In this case it is an HTTP server
http.createServer((req, res) => {
res.writeHead(200);
res.end('hello world\n');
}).listen(8000);
console.log(`Worker ${process.pid} started`);
}
PM2 原理
PM2 是一个强大的生产环境进程管理器,其核心原理就是 Cluster 模块。
- 进程管理:PM2 作为 Master 进程,启动你的应用作为 Worker 进程。
- 零秒重启:重启时,先启动新进程,再优雅关闭旧进程,保证服务不间断。
- 负载均衡:内置了 Cluster 模块的负载均衡功能。
- 监控日志:聚合所有 Worker 进程的日志,并提供丰富的监控功能。
5. MidwayJS 框架
MidwayJS 是一个面向未来的 Node.js 全栈框架,基于 TypeScript 和依赖注入开发。
依赖注入 (IoC) 原理及其优势
- 原理:控制反转 (IoC) 是一种设计思想,将对象的创建和依赖关系的管理权从代码内部反转给一个外部容器(IoC 容器) 来负责。依赖注入 (DI) 是实现 IoC 的主要方式。
- 在 Midway 中的工作流程:
- 你使用
@Provide()
装饰器声明一个类(Service、Controller)可以被容器管理。 - 在需要使用的地方,使用
@Inject()
装饰器声明你需要依赖的实例。 - 框架的 IoC 容器在启动时,会扫描所有文件,自动创建这些类的实例,并自动将依赖注入到需要它的类中。
- 你使用
// service.ts - 声明一个服务
@Provide() // <- 告诉容器“我可以被提供”
export class UserService {
async getUser() {
return { name: 'Midway' };
}
}
// controller.ts - 声明一个控制器并注入服务
@Provide()
@Controller('/user')
export class UserController {
@Inject() // <- 告诉容器“请把UserService的实例注入给我”
userService: UserService;
@Get('/')
async getUser() {
const user = await this.userService.getUser(); // 直接使用,无需自己new
return user;
}
}
- 优势:
- 解耦:代码不关心依赖如何创建,只关心接口,耦合度极低。
- 可测试性:单元测试时可以轻松注入 Mock 对象。
- 可维护性:依赖关系清晰,代码组织优雅。
生命周期、中间件、过滤器机制
-
生命周期:Midway 提供了在应用启动和停止时的钩子。
@Config()
:在配置加载后执行。@Init()
:在依赖注入初始化后执行。@Destroy()
:在应用停止前执行。用于释放资源(如关闭数据库连接)。
-
中间件:与 Koa/Egg 中间件机制一脉相承。是基于 洋葱模型 的 HTTP 请求处理流程。
- 你可以编写中间件来处理日志、鉴权、Body解析等通用逻辑。
- 在
configuration.ts
中全局或局部注册。
-
过滤器:用于统一处理整个请求链路中抛出的异常。
- 你可以定义一个异常过滤器,捕获所有未处理的错误,并统一格式返回给客户端。
- 这是 Midway 对标准 Koa 模型的强大增强,让错误处理变得非常优雅和集中。
连接和操作多种数据库
Midway 通过组件体系来集成各种数据库,遵循依赖注入的原则。
- 安装组件:
npm install @midwayjs/typeorm@3 typeorm mysql2
(以 TypeORM + MySQL 为例) - 配置:在
src/config/config.default.ts
中配置数据库连接信息。 - 定义实体 (Entity):使用 TypeORM 装饰器定义数据模型。
- 在 Service 中注入 Repository:通过
@InjectEntityModel
注入操作数据库的入口。
// configuration.ts - 引入组件
import { Configuration } from '@midwayjs/core';
import * as typeorm from '@midwayjs/typeorm';
@Configuration({
imports: [typeorm], // 导入组件
})
export class MainConfiguration {}
// entity/user.entity.ts - 定义实体
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
}
// service/user.service.ts - 操作数据库
import { Provide } from '@midwayjs/core';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import { User } from '../entity/user.entity';
@Provide()
export class UserService {
@InjectEntityModel(User) // 注入User实体的Repository
userRepo: Repository<User>;
async findUserById(id: number) {
return await this.userRepo.findOne({ where: { id } });
}
}
操作 ClickHouse 等其他数据库流程类似,通常是寻找或开发对应的 Midway 组件(例如 @midwayjs/clickhouse
),然后按照组件的文档进行配置和注入使用。核心思想都是通过 IoC 容器来注入一个配置好的客户端实例。