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 会按以下顺序查找:

  1. 核心模块(如 fs, path):直接返回。
  2. 绝对路径/相对路径(如 require('./myModule')):直接按路径加载。
  3. 第三方模块(如 require('lodash')):
    a. 在当前目录下的 node_modules 文件夹中查找。
    b. 如果没找到,向父目录递归查找,直到文件系统的根目录。
    c. 在 node_modules 中,查找 package.jsonmain 字段指定的文件。
    d. 如果没有 package.jsonmain 字段,则依次尝试加载 index.js, index.json, index.node
  4. 目录作为模块(如 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服务器处理请求:

flowchart TD A[客户端请求A到达] --> B[主线程接收请求<br>发起异步文件读取I/O] B -- I/O操作交给libuv线程池处理 --> C[libuv线程池] B -- 主线程不被阻塞 --> D[客户端请求B到达] D --> E[主线程接收请求B<br>发起异步DB查询I/O] E -- I/O操作交给libuv --> C C -- 文件读取完成 --> F[将回调函数放入事件队列] E -- DB查询完成 --> G[将回调函数放入事件队列] subgraph H[Event Loop] direction LR I[检查事件队列] --> J[执行回调函数A] J --> K[执行回调函数B] end F --> H G --> H

总结:Node.js 的单线程指的是执行 JavaScript 代码和事件循环的线程是单一的。但底层的 I/O 操作(文件、网络等)是由 libuv 的线程池(默认4个)或操作系统本身(如网络请求)来并行处理的。这种“一个主线程 + 线程池”的模型,用很少的资源(一个进程)就实现了极高的 I/O 并发能力,非常适合 I/O 密集型的应用(如Web服务器、API网关)。


3. Stream(流)

流是用于处理连续数据的抽象接口,尤其适合处理大文件持续产生的数据

四种流类型

  1. Readable:可读流(数据来源)。例如:fs.createReadStream, http request
  2. Writable:可写流(数据目的地)。例如:fs.createWriteStream, http response
  3. Duplex:双工流(既可读又可写)。例如:TCP socket
  4. 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 流机制内置了背压控制。

  1. writeStream.write(chunk) 返回 false 时,表示写入队列已满(缓冲区满了),建议暂停读取。
  2. 可读流接收到这个信号,会暂停(pause)数据流动。
  3. 当写入队列清空后,Writable 流会发出一个 'drain' 事件。
  4. 可读流接收到 'drain' 事件,恢复(resume)数据流动。

.pipe() 方法自动帮你处理了所有这些背压逻辑,这是推荐的使用方式。


4. 进程与集群

Node.js 是单进程的,为了充分利用多核 CPU,必须启动多个进程。

Child Process 模块

用于创建和管理子进程。常见方法:

  • spawn最基础的方法,用于启动一个子进程来执行命令
  • forkspawn 的特例,专门用于新建一个 Node.js 子进程。子进程与父进程之间会建立 IPC 通信通道,可以通过 send()on('message') 交换消息。
  • exec:用于执行shell命令,并将结果缓冲后在回调中返回。适合输出量小的命令。
  • execFile:类似于 exec,但直接执行可执行文件,而不用先启动一个 shell。

Cluster 模块

基于 child_process.fork()net 模块,简化了创建共享同一端口的多进程 Web 服务器的过程。

工作原理(主从模式)

  1. Master 进程
    • 负责启动和管理。
    • 监听端口。
    • fork 出多个 Worker 进程(通常按 CPU 核心数)。
    • 将接收到的连接请求轮询地分发给各个 Worker(默认策略)。
  2. 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 模块。

  1. 进程管理:PM2 作为 Master 进程,启动你的应用作为 Worker 进程。
  2. 零秒重启:重启时,先启动新进程,再优雅关闭旧进程,保证服务不间断。
  3. 负载均衡:内置了 Cluster 模块的负载均衡功能。
  4. 监控日志:聚合所有 Worker 进程的日志,并提供丰富的监控功能。

5. MidwayJS 框架

MidwayJS 是一个面向未来的 Node.js 全栈框架,基于 TypeScript 和依赖注入开发。

依赖注入 (IoC) 原理及其优势

  • 原理控制反转 (IoC) 是一种设计思想,将对象的创建和依赖关系的管理权从代码内部反转给一个外部容器(IoC 容器) 来负责。依赖注入 (DI) 是实现 IoC 的主要方式。
  • 在 Midway 中的工作流程
    1. 你使用 @Provide() 装饰器声明一个类(Service、Controller)可以被容器管理。
    2. 在需要使用的地方,使用 @Inject() 装饰器声明你需要依赖的实例。
    3. 框架的 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 对象。
    • 可维护性:依赖关系清晰,代码组织优雅。

生命周期、中间件、过滤器机制

  1. 生命周期:Midway 提供了在应用启动和停止时的钩子。

    • @Config():在配置加载后执行。
    • @Init():在依赖注入初始化后执行。
    • @Destroy():在应用停止前执行。用于释放资源(如关闭数据库连接)。
  2. 中间件:与 Koa/Egg 中间件机制一脉相承。是基于 洋葱模型 的 HTTP 请求处理流程。

    • 你可以编写中间件来处理日志、鉴权、Body解析等通用逻辑。
    • configuration.ts 中全局或局部注册。
  3. 过滤器:用于统一处理整个请求链路中抛出的异常

    • 你可以定义一个异常过滤器,捕获所有未处理的错误,并统一格式返回给客户端。
    • 这是 Midway 对标准 Koa 模型的强大增强,让错误处理变得非常优雅和集中。

连接和操作多种数据库

Midway 通过组件体系来集成各种数据库,遵循依赖注入的原则。

  1. 安装组件npm install @midwayjs/typeorm@3 typeorm mysql2 (以 TypeORM + MySQL 为例)
  2. 配置:在 src/config/config.default.ts 中配置数据库连接信息。
  3. 定义实体 (Entity):使用 TypeORM 装饰器定义数据模型。
  4. 在 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 容器注入一个配置好的客户端实例

posted @ 2025-10-10 13:36  阿木隆1237  阅读(11)  评论(0)    收藏  举报