面试题手册

梳理高频技术问题,帮助你按主题复习和查漏补缺。

服务端阅读 06月5日 00:31

TypeORM事务处理:三种API、隔离级别、悲观锁乐观锁和分布式Saga

TypeORM 提供三种写事务的方式:@Transaction 装饰器(0.3.x 已移除)、EntityManager.transaction、和 QueryRunner。选哪种取决于你的场景——简单事务用 EntityManager.transaction,需要精细控制用 QueryRunner。这篇文章把三种方式、隔离级别、锁机制都讲清楚,以及分布式事务的替代方案。三种事务 API方式一:EntityManager.transaction(推荐日常使用)最常用的事务写法——传一个回调函数,回调里所有操作在同一个事务里:await dataSource.transaction(async (manager) => { // 所有操作必须用 manager,不能用全局 repository const user = await manager.findOne(User, { where: { id: 1 } }); user.balance -= 100; await manager.save(user); const target = await manager.findOne(User, { where: { id: 2 } }); target.balance += 100; await manager.save(target);});关键规则:回调里必须用 manager 参数(EntityManager),不能用 dataSource.getRepository()。全局 Repository 不在事务里,它的操作不受事务保护。回调函数正常返回 → 自动 commit。抛异常 → 自动 rollback。方式二:QueryRunner(需要精细控制时)当你需要手动控制 commit/rollback 时机、设置隔离级别、或者同一个 QueryRunner 上执行多个事务时:const queryRunner = dataSource.createQueryRunner();await queryRunner.connect();await queryRunner.startTransaction();try { await queryRunner.manager.save(User, { name: 'Alice', balance: 100 }); await queryRunner.manager.save(User, { name: 'Bob', balance: 200 }); await queryRunner.commitTransaction();} catch (err) { await queryRunner.rollbackTransaction(); throw err;} finally { await queryRunner.release(); // 必须释放,否则连接泄漏}release() 很关键——QueryRunner 持有数据库连接,不释放连接池会耗尽。finally 块里 release 保证即使 commit/rollback 抛异常也能释放。方式三:NestJS 里的 @TransactionEntityManagerNestJS + TypeORM 项目用 @Transaction() 装饰器(TypeORM 0.3.x 已移除,改用 DataSource.transaction):import { Injectable } from '@nestjs/common';import { InjectDataSource } from '@nestjs/typeorm';import { DataSource } from 'typeorm';@Injectable()export class TransferService { constructor( @InjectDataSource() private dataSource: DataSource, ) {} async transfer(fromId: number, toId: number, amount: number) { return this.dataSource.transaction(async (manager) => { const from = await manager.findOne(User, { where: { id: fromId } }); const to = await manager.findOne(User, { where: { id: toId } }); if (from.balance < amount) { throw new Error('余额不足'); } from.balance -= amount; to.balance += amount; await manager.save([from, to]); }); }}隔离级别隔离级别决定事务之间能看到彼此的修改到什么程度。| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 性能 | 适用场景 ||----------|------|-----------|------|------|----------|| READ UNCOMMITTED | ✅ 可能 | ✅ 可能 | ✅ 可能 | 最快 | 几乎不用 || READ COMMITTED | ❌ 不会 | ✅ 可能 | ✅ 可能 | 快 | PostgreSQL 默认 || REPEATABLE READ | ❌ 不会 | ❌ 不会 | ✅ 可能 | 中 | MySQL 默认 || SERIALIZABLE | ❌ 不会 | ❌ 不会 | ❌ 不会 | 最慢 | 金融交易 |设置隔离级别// QueryRunner 方式await queryRunner.startTransaction('SERIALIZABLE');// DataSource.transaction 不支持直接设隔离级别// 需要先用 QueryRunner 设const queryRunner = dataSource.createQueryRunner();await queryRunner.connect();await queryRunner.startTransaction('REPEATABLE READ');实际场景READ COMMITTED:大多数 Web 应用。你的 API 请求处理用这个级别够了——一个请求不会因为另一个请求的未提交数据而出错。REPEATABLE READ:同一个事务里多次读同一行数据,结果必须一致。比如生成报表时,事务期间数据不能变。SERIALIZABLE:转账、库存扣减等绝对不能出错的场景。性能代价大,只在关键业务上用。锁机制悲观锁:数据库层面加锁查询时直接锁行,其他事务不能修改:// 排他锁(FOR UPDATE):其他事务不能读也不能写const user = await manager.findOne(User, { where: { id: 1 }, lock: { mode: 'pessimistic_write' },});// 共享锁(FOR SHARE):其他事务可读不可写const user = await manager.findOne(User, { where: { id: 1 }, lock: { mode: 'pessimistic_read' },});使用场景:扣库存、转账——先锁住行,再修改,防止两个事务同时读到同一个余额然后覆盖。await dataSource.transaction(async (manager) => { // 先锁行 const account = await manager.findOne(Account, { where: { id: 1 }, lock: { mode: 'pessimistic_write' }, }); // 再修改 account.balance -= amount; await manager.save(account);});不加锁的并发问题:事务 A 和事务 B 同时读到余额 1000,各扣 100,都写入 900——应该是 800。加 pessimistic_write 后,事务 B 等 A commit 后才能读,拿到 900 再扣,最终 800。乐观锁:应用层面检查不锁行,更新时检查数据是否被改过。实体加 @Version() 列:@Entity()export class User { @PrimaryGeneratedColumn() id: number; @Column() name: string; @VersionColumn() version: number; // 每次更新自动 +1}const user = await manager.findOne(User, { where: { id: 1 } });// user.version = 1user.name = 'Alice';await manager.save(user);// SQL: UPDATE user SET name = 'Alice', version = 2 WHERE id = 1 AND version = 1// 如果另一个事务已经改了这行(version 变成 2),WHERE 条件不匹配,影响行数为 0// TypeORM 抛出 OptimisticLockVersionMismatchError乐观锁不锁行,不阻塞读,适合读多写少的场景。缺点是冲突时需要重试。乐观锁重试模式async function updateWithRetry(id: number, updateFn: (user: User) => void, retries = 3) { for (let i = 0; i < retries; i++) { const user = await dataSource.getRepository(User).findOne({ where: { id } }); updateFn(user); try { await dataSource.getRepository(User).save(user); return; } catch (err) { if (err.name === 'OptimisticLockVersionMismatchError' && i < retries - 1) { continue; // 重试 } throw err; } }}死锁两个事务互相等对方释放锁,永远卡住。TypeORM 不会自动检测死锁——依赖数据库的死锁检测机制。MySQL 和 PostgreSQL 会自动检测并回滚其中一个事务。避免死锁的原则:事务尽量短——快进快出,不持有锁太久按固定顺序访问资源——总是先锁 A 再锁 B,不要一个先 A 后 B,另一个先 B 后 A不要在事务里做网络请求——网络慢会长时间持有锁分布式事务TypeORM 没有内置分布式事务支持。如果两个操作分别在不同数据库或不同服务上,不能用 TypeORM 的事务保证原子性。替代方案:Saga 模式把分布式事务拆成一系列本地事务,每步成功后触发下一步,失败时执行补偿操作:async function createOrderSaga(orderData: OrderData) { // 步骤1:创建订单(本地事务) const order = await orderService.createOrder(orderData); try { // 步骤2:扣库存(远程调用) await inventoryService.deduct(orderData.items); try { // 步骤3:扣款(远程调用) await paymentService.charge(orderData.amount); } catch (err) { // 步骤3失败:补偿步骤2(还库存) await inventoryService.restore(orderData.items); throw err; } } catch (err) { // 步骤2失败:补偿步骤1(取消订单) await orderService.cancelOrder(order.id); throw err; }}Saga 不保证强一致性——中间状态对外可见(订单创建了但库存还没扣)。但这是分布式系统里唯一的实用方案——两阶段提交(2PC)性能太差,跨服务基本不用。
服务端阅读 06月5日 00:30

TypeORM QueryBuilder高级查询:条件组合、子查询、分页和批量操作

find() 和 findBy() 只能处理简单查询——多表关联、条件组合、子查询、聚合分组,都得用 QueryBuilder。但 QueryBuilder 的 API 设计有时让人困惑:leftJoin 和 innerJoin 有什么区别?where 和 andWhere 能不能混用?子查询怎么写?这篇文章用实际场景把 QueryBuilder 的高级用法讲清楚。QueryBuilder 基础回顾// 简单查询:find() 能搞定的const users = await userRepository.find({ where: { active: true }, order: { createdAt: 'DESC' }, take: 20,});// 复杂查询:必须用 QueryBuilderconst users = await userRepository .createQueryBuilder('user') .where('user.active = :active', { active: true }) .orderBy('user.createdAt', 'DESC') .limit(20) .getMany();'user' 是别名,后续引用这个表都用这个别名。SQL 里变成 SELECT ... FROM user user。条件组合:OR、AND、嵌套OR 条件// 错误:连续写 where,后面的会覆盖前面的.where('user.active = :active', { active: true }).where('user.role = :role', { role: 'admin' })// 生成:WHERE user.role = 'admin'(第一个 where 被覆盖!)// 正确:用 orWhere.where('user.active = :active', { active: true }).orWhere('user.role = :role', { role: 'admin' })// 生成:WHERE user.active = true OR user.role = 'admin'混合 AND + OR// 需求:活跃用户,且(管理员 或 创建于2024年之后).where('user.active = :active', { active: true }).andWhere( new Brackets(qb => { qb.where('user.role = :role', { role: 'admin' }) .orWhere('user.createdAt > :date', { date: '2024-01-01' }); }))// 生成:WHERE user.active = true AND (user.role = 'admin' OR user.createdAt > '2024-01-01')Brackets 是关键——不加的话 OR 会和前面的 AND 混在一起,逻辑不对。需要括号的地方就用 Brackets。动态条件根据用户输入动态拼查询条件:function buildUserQuery(filters: { name?: string; role?: string; minAge?: number; maxAge?: number;}) { const qb = userRepository.createQueryBuilder('user'); if (filters.name) { qb.andWhere('user.name LIKE :name', { name: `%${filters.name}%` }); } if (filters.role) { qb.andWhere('user.role = :role', { role: filters.role }); } if (filters.minAge) { qb.andWhere('user.age >= :minAge', { minAge: filters.minAge }); } if (filters.maxAge) { qb.andWhere('user.age <= :maxAge', { maxAge: filters.maxAge }); } return qb;}注意参数占位符:用 :paramName + 对象传参,不要字符串拼接——防止 SQL 注入:// ❌ SQL 注入风险qb.where(`user.name = '${filters.name}'`);// ✅ 参数化查询qb.where('user.name = :name', { name: filters.name });关联查询:leftJoin vs innerJoinleftJoin:左连接,左表数据全保留const users = await userRepository .createQueryBuilder('user') .leftJoin('user.posts', 'post') .getMany();// 即使没有文章的用户也会返回,post 字段为 []innerJoin:内连接,只返回有关联数据的行const usersWithPosts = await userRepository .createQueryBuilder('user') .innerJoin('user.posts', 'post') .getMany();// 只返回至少有一篇文章的用户关联数据的选择和过滤// 只查用户的文章标题,不加载整个 post 对象const users = await userRepository .createQueryBuilder('user') .leftJoinAndSelect('user.posts', 'post') .where('post.published = :published', { published: true }) .getMany();leftJoinAndSelect 会把关联数据一起查出来(自动 SELECT),leftJoin 只 JOIN 不 SELECT。如果你后面要用 post.title 做过滤但不需要返回 post 数据,用 leftJoin;如果需要返回 post 数据,用 leftJoinAndSelect。子查询WHERE 里的子查询查"有超过 5 篇文章的用户":const users = await userRepository .createQueryBuilder('user') .where((qb) => { const subQuery = qb .subQuery() .select('post.authorId') .from(Post, 'post') .groupBy('post.authorId') .having('COUNT(post.id) > :count') .getQuery(); return `user.id IN ${subQuery}`; }) .setParameter('count', 5) .getMany();SELECT 里的子查询(关联计数)查每个用户的文章数:const users = await userRepository .createQueryBuilder('user') .leftJoin('user.posts', 'post') .groupBy('user.id') .select('user.id', 'id') .addSelect('user.name', 'name') .addSelect('COUNT(post.id)', 'postCount') .getRawMany();// 返回: [{ id: 1, name: 'Alice', postCount: '3' }, ...]注意 getRawMany() 返回原始数据库行,字段名是 select 里指定的别名,类型都是字符串。getMany() 返回实体对象,但聚合查询返回的不是实体,所以必须用 getRawMany()。分页TypeORM 的 skip + take 是最简单的分页:const [users, total] = await userRepository .createQueryBuilder('user') .orderBy('user.createdAt', 'DESC') .skip((page - 1) * pageSize) .take(pageSize) .getManyAndCount();getManyAndCount() 返回 [数据, 总数]——一次查询做 SELECT + COUNT,比分开两次高效。深度分页的坑:skip(100000).take(20) 在 MySQL 上很慢——数据库要扫描前 100020 行再丢弃前 100000 行。深度分页用 keyset pagination:// 不用 offset,用上一页最后一条的 IDconst users = await userRepository .createQueryBuilder('user') .where('user.id > :lastId', { lastId: lastIdOfPrevPage }) .orderBy('user.id', 'ASC') .take(20) .getMany();ID 有索引时,不管翻到第几页都是 O(1)。聚合和分组// 每个分类的文章数const stats = await postRepository .createQueryBuilder('post') .select('post.category', 'category') .addSelect('COUNT(*)', 'count') .addSelect('MAX(post.createdAt)', 'latestPost') .groupBy('post.category') .having('COUNT(*) > :minCount', { minCount: 1 }) .getRawMany();where 过滤行(分组前),having 过滤组(分组后)。窗口函数TypeORM 的 QueryBuilder 不直接支持窗口函数语法。用 .getRawMany() + 原生 SQL:// PostgreSQL:查每个用户的文章数和排名const ranked = await dataSource.createQueryRunner() .query(` SELECT u.id, u.name, COUNT(p.id) AS post_count, RANK() OVER (ORDER BY COUNT(p.id) DESC) AS rank FROM "user" u LEFT JOIN post p ON p."authorId" = u.id GROUP BY u.id ORDER BY rank `);或者用 QueryBuilder 的 select 写原始片段:const result = await userRepository .createQueryBuilder('user') .leftJoin('user.posts', 'post') .groupBy('user.id') .select('user.id', 'id') .addSelect('user.name', 'name') .addSelect('COUNT(post.id)', 'postCount') .addSelect('RANK() OVER (ORDER BY COUNT(post.id) DESC)', 'rank') .getRawMany();窗口函数只有 PostgreSQL 和 SQL Server 完整支持,MySQL 8+ 也支持。SQLite 不支持。批量操作批量插入await userRepository.insert([ { name: 'Alice', email: 'a@test.com' }, { name: 'Bob', email: 'b@test.com' }, { name: 'Charlie', email: 'c@test.com' },]);比循环 save() 快得多——一条 INSERT 语句插入多行。批量更新await userRepository .createQueryBuilder() .update(User) .set({ active: false }) .where('lastLoginAt < :date', { date: sixMonthsAgo }) .execute();Upsert(PostgreSQL / MySQL)// PostgreSQL: ON CONFLICTawait userRepository .createQueryBuilder() .insert() .into(User) .values([{ id: 1, name: 'Alice', email: 'new@test.com' }]) .onConflict('("id") DO UPDATE SET "email" = EXCLUDED."email"') .execute();// MySQL: ON DUPLICATE KEY UPDATEawait userRepository .createQueryBuilder() .insert() .into(User) .values([{ id: 1, name: 'Alice', email: 'new@test.com' }]) .orUpdate(['email'], ['id']) .execute();Upsert 的语法在 PostgreSQL 和 MySQL 上不同——这是 TypeORM "一套代码多数据库" 最大的例外之一。
服务端阅读 06月5日 00:29

TypeORM多数据库支持:配置差异、MongoDB限制和多数据源方案

TypeORM 的卖点之一是"一套代码跑在多种数据库上"。实际体验下来,SQL 数据库之间迁移确实顺畅,但 MongoDB 是另一回事——文档模型和关系模型的 API 差异很大。这篇文章聚焦实际项目中的数据库选择、配置、以及多数据源场景。支持的数据库一览TypeORM 支持的数据库分两类:SQL 数据库(API 统一,切换成本低):MySQL / MariaDBPostgreSQLSQLiteSQL ServerOracleCockroachDBSAP Hana文档数据库(API 有差异):MongoDB关键区别:SQL 数据库共享同一套 QueryBuilder API,切换只改 DataSource 配置。MongoDB 不支持 QueryBuilder 的大部分方法,也不支持事务(TypeORM 层面)、关系懒加载等特性。基础配置MySQLimport { DataSource } from 'typeorm';export const appDataSource = new DataSource({ type: 'mysql', host: process.env.DB_HOST || 'localhost', port: 3306, username: process.env.DB_USER || 'root', password: process.env.DB_PASSWORD, database: process.env.DB_NAME || 'myapp', entities: ['src/entity/*.ts'], synchronize: process.env.NODE_ENV !== 'production', // 生产环境禁止 logging: ['error'], // 只记录错误 SQL});PostgreSQLexport const appDataSource = new DataSource({ type: 'postgres', host: process.env.DB_HOST || 'localhost', port: 5432, username: process.env.DB_USER || 'postgres', password: process.env.DB_PASSWORD, database: process.env.DB_NAME || 'myapp', entities: ['src/entity/*.ts'], synchronize: process.env.NODE_ENV !== 'production', // PostgreSQL 特有配置 ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false, schema: 'public',});MySQL 和 PostgreSQL 的配置几乎一样——改 type 和端口就行。实体代码不需要任何修改。SQLite(本地开发和测试)export const appDataSource = new DataSource({ type: 'sqlite', database: 'data/myapp.db', // 文件数据库 // database: ':memory:', // 内存数据库(测试用) entities: ['src/entity/*.ts'], synchronize: true,});SQLite 的优势:零配置、零依赖、单文件。适合桌面应用(Electron)、CLI 工具、本地开发。缺点:不支持并发写入、不支持 RETURNING、JSON 函数有限。MongoDBexport const appDataSource = new DataSource({ type: 'mongodb', url: process.env.MONGODB_URL || 'mongodb://localhost:27017/myapp', entities: ['src/entity/*.ts'], synchronize: process.env.NODE_ENV !== 'production', // MongoDB 特有配置 authSource: 'admin', replicaSet: 'rs0', // 如果用了副本集});MongoDB 的实体定义和 SQL 不同——没有 @Column,用 @ObjectIdColumn 和 @Field:import { Entity, ObjectIdColumn, ObjectId, Column as Field } from 'typeorm';@Entity()export class User { @ObjectIdColumn() _id: ObjectId; @Field() name: string; @Field() email: string; @Field(type => [String]) // 数组字段 tags: string[];}MongoDB 的限制:不支持 @JoinColumn、@ManyToMany 等 SQL 关系装饰器不支持 QueryBuilder 的 leftJoin、subQueryRepository.find() 的 where 语法不同(用 MongoDB 查询对象)不支持数据库层面的约束(唯一约束、外键)数据库选择指南| 场景 | 推荐 | 原因 ||------|------|------|| Web 后端 API | PostgreSQL | 功能最全(JSON、全文搜索、数组、窗口函数) || 已有 MySQL 基础设施 | MySQL | 不需要额外学习,TypeORM 完全支持 || 桌面应用(Electron) | SQLite | 零依赖,单文件分发 || 测试 | SQLite :memory: | 最快,每个测试隔离 || 文档型数据、灵活 schema | MongoDB | 无需预定义表结构 || 高并发读 + 简单写 | MySQL + 读写分离 | MySQL 主从复制成熟 |PostgreSQL 是新项目的默认推荐——JSON 支持、全文搜索、数组类型、窗口函数,比 MySQL 功能丰富很多,而且 TypeORM 的 PostgreSQL 支持最完善。多数据源配置一个项目需要连接多个数据库的场景:读写分离(主从)、跨库查询、迁移过渡期。注册多个 DataSource// data-sources.tsexport const primaryDataSource = new DataSource({ name: 'primary', // 必须有 name type: 'postgres', host: 'primary-db.example.com', // ... entities: ['src/entity/*.ts'],});export const secondaryDataSource = new DataSource({ name: 'secondary', // 必须有 name type: 'mysql', host: 'legacy-db.example.com', // ... entities: ['src/entity-legacy/*.ts'],});NestJS 里使用多数据源import { TypeOrmModule } from '@nestjs/typeorm';@Module({ imports: [ TypeOrmModule.forRoot({ name: 'primary', type: 'postgres', host: 'primary-db.example.com', entities: [User, Post], }), TypeOrmModule.forRoot({ name: 'secondary', type: 'mysql', host: 'legacy-db.example.com', entities: [LegacyUser], }), // 模块指定使用哪个数据源 TypeOrmModule.forFeature([User, Post], 'primary'), TypeOrmModule.forFeature([LegacyUser], 'secondary'), ],})export class AppModule {}第二个参数 'primary' / 'secondary' 指定该模块用哪个数据源。不同模块可以用不同数据源。跨库查询TypeORM 不支持跨数据源的 JOIN。需要手动查两个库再在代码里合并:async function getUserWithLegacy(userId: number) { const user = await primaryDataSource.getRepository(User) .findOne({ where: { id: userId } }); const legacyUser = await secondaryDataSource.getRepository(LegacyUser) .findOne({ where: { email: user.email } }); return { ...user, legacyData: legacyUser };}数据库切换和迁移从 MySQL 切换到 PostgreSQL修改 DataSource 配置(type: 'mysql' → type: 'postgres')检查实体里的 MySQL 特有类型(如 tinyint 改成 boolean)生成迁移文件:npx typeorm migration:generate -d src/data-source.ts src/migration/InitPg跑迁移:npx typeorm migration:run -d src/data-source.ts检查 QueryBuilder 里有没有 MySQL 专有语法(如 `backtick` 改成 "double quote")大部分情况下只需改配置和迁移,实体代码不用动。synchronize 的正确用法synchronize: true 在开发时方便——改实体自动同步表结构。但生产环境必须关闭,否则:删字段时直接 ALTER TABLE DROP COLUMN,数据丢失重命名字段被当作"删旧列 + 加新列",数据丢失并发启动多个实例可能同时执行 schema 变更生产环境用迁移:typeorm migration:run。
服务端阅读 06月5日 00:27

TypeORM实体继承:单表、类表和具体表继承的选择与实现

TypeORM 支持三种实体继承模式,名字听着抽象,但对应的是数据库设计里真实的问题:不同类型的数据,是放一张表还是分多张表?放一张表会有大量 NULL 列,分多张表 JOIN 查询变慢。这篇文章把三种模式的实现方式、优缺点、以及什么时候选哪种,都讲清楚。三种模式对比| 模式 | 表结构 | 查询性能 | NULL 列 | 适用场景 ||------|--------|----------|---------|----------|| 单表继承(STI) | 一张表,鉴别列区分类型 | 最好(无 JOIN) | 多 | 子类字段差异小 || 类表继承(CTI) | 父类一张 + 每个子类一张 | 中(需 JOIN) | 少 | 子类字段差异大,但有关联查询需求 || 具体表继承(CTI-var) | 每个子类独立一张表 | 差(UNION 查所有类型) | 无 | 子类完全独立,很少跨类型查询 |单表继承(Single Table Inheritance)所有子类存在同一张表里,用鉴别列(discriminator column)标识类型。子类独有的列在不对应的行里为 NULL。实现import { Entity, PrimaryGeneratedColumn, Column, ChildEntity, DiscriminatorValue } from 'typeorm';@Entity()@DiscriminatorColumn({ name: 'type' })export class Content { @PrimaryGeneratedColumn() id: number; @Column() title: string; @Column({ type: 'text' }) body: string;}@ChildEntity()@DiscriminatorValue('article')export class Article extends Content { @Column({ nullable: true }) // 必须设 nullable: true author: string; @Column({ nullable: true }) category: string;}@ChildEntity()@DiscriminatorValue('video')export class Video extends Content { @Column({ nullable: true }) url: string; @Column({ nullable: true }) duration: number;}生成的表结构:content┌────┬───────┬───────────┬────────┬──────┬──────────┬─────┬──────────┐│ id │ type │ title │ body │ url │ duration │ author │ category │├────┼───────┼───────────┼────────┼──────┼──────────┼─────┼──────────┤│ 1 │ article│ ... │ ... │ NULL │ NULL │ Alice│ tech ││ 2 │ video │ ... │ ... │ a.mp4│ 600 │ NULL │ NULL │└────┴───────┴───────────┴────────┴──────┴──────────┴─────┴──────────┘查询方式// 查所有内容(不管类型)const all = await dataSource.getRepository(Content).find();// 只查文章(TypeORM 自动加 WHERE type = 'article')const articles = await dataSource.getRepository(Article).find();// 按 type 过滤const videos = await dataSource.getRepository(Content) .find({ where: { type: 'video' } });NULL 列问题单表继承最大的问题:子类独有字段在不对应的行里全是 NULL。3 个子类各 5 个独有字段,表里就有 15 个可能为 NULL 的列。如果子类差异大,一张表 20+ 列一半是 NULL,可读性和索引效率都差。适合场景:子类字段少、差异小。比如 User 分 Admin/Member,独有字段各 2-3 个。类表继承(Class Table Inheritance)父类一张表,每个子类各一张表,子类表通过外键关联父类表。没有 NULL 列问题,但查询需要 JOIN。实现@Entity()@TableInheritance({ column: { type: 'varchar', name: 'type' } })export class Person { @PrimaryGeneratedColumn() id: number; @Column() name: string; @Column() email: string;}@ChildEntity()export class Employee extends Person { @Column() position: string; @Column({ type: 'decimal', precision: 10, scale: 2 }) salary: number;}@ChildEntity()export class Customer extends Person { @Column() companyName: string; @Column({ default: 0 }) loyaltyPoints: number;}生成的表结构:person employee customer┌────┬───────┬──────┬───────────┐ ┌────┬──────────┬────────┐ ┌────┬─────────────┬───────────────┐│ id │ type │ name │ email │ │ id │ position │ salary │ │ id │ companyName │ loyaltyPoints │├────┼───────┼──────┼───────────┤ ├────┼──────────┼────────┤ ├────┼─────────────┼───────────────┤│ 1 │ employee│ Alice│ a@t.com│ │ 1 │ Engineer │ 80000 │ │ │ │ ││ 2 │ customer│ Bob │ b@t.com│ │ │ │ │ │ 2 │ Acme Inc │ 100 │└────┴───────┴──────┴───────────┘ └────┴──────────┴────────┘ └────┴─────────────┴───────────────┘person.id 和 employee.id/customer.id 是同一个值——子类表的主键同时也是父类表的外键。查询方式// 查所有人(TypeORM 自动 JOIN)const all = await dataSource.getRepository(Person).find();// 只查员工(自动 JOIN person + employee)const employees = await dataSource.getRepository(Employee).find();// 保存const emp = new Employee();emp.name = 'Alice';emp.email = 'alice@test.com';emp.position = 'Engineer';emp.salary = 80000;await dataSource.getRepository(Employee).save(emp);// 自动向 person 和 employee 两张表插入数据JOIN 的性能代价每次查子类都要 JOIN 父类表。数据量大时 JOIN 比单表查询慢。但如果你的查询经常只查父类字段(如按 name 搜索),不需要 JOIN,直接查 person 表即可。适合场景:子类字段差异大,且经常需要跨类型查询公共字段。具体表继承(Concrete Table Inheritance)每个子类一张独立的表,父类不建表。公共字段在每个子类表里重复。TypeORM 用 @Column() 在抽象类里定义,子类继承后各自建表。实现export abstract class Payment { @PrimaryGeneratedColumn() id: number; @Column() amount: number; @Column() currency: string; @Column() createdAt: Date;}@Entity()export class CreditCardPayment extends Payment { @Column() cardNumber: string; @Column() expiryDate: string;}@Entity()export class BankTransferPayment extends Payment { @Column() bankName: string; @Column() accountNumber: string;}生成的表结构:credit_card_payment bank_transfer_payment┌────┬────────┬──────────┬──────┬───────────┬────────────┐ ┌────┬────────┬──────────┬──────┬──────────┬───────────────┐│ id │ amount │ currency │ date │ cardNumber│ expiryDate│ │ id │ amount │ currency │ date │ bankName │ accountNumber │└────┴────────┴──────────┴──────┴───────────┴────────────┘ └────┴────────┴──────────┴──────┴──────────┴───────────────┘公共字段 amount、currency、createdAt 在每张表里都有。没有父类表,没有 JOIN,也没有 NULL 列。查询所有支付类型具体表继承最大的问题:查"所有类型的支付"需要 UNION:// TypeORM 不直接支持跨子类的 UNION 查询// 需要手动查每张表再合并const [creditCards, bankTransfers] = await Promise.all([ dataSource.getRepository(CreditCardPayment).find(), dataSource.getRepository(BankTransferPayment).find(),]);const allPayments = [...creditCards, ...bankTransfers] .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());没有统一的 Repository 可以查所有子类——因为根本没有父类表。适合场景:子类完全独立,几乎不需要跨类型查询。比如支付方式、通知渠道。选择决策子类独有字段多吗?├─ 少(每个子类 2-3 个独有字段)→ 单表继承(STI)└─ 多(每个子类 5+ 个独有字段)→ 经常跨类型查询吗? ├─ 是 → 类表继承(CTI) └─ 否 → 具体表继承迁移时的注意事项单表继承加新子类:加列就行,ALTER TABLE ADD COLUMN,不用建新表类表继承加新子类:新建一张子类表,父类表不变具体表继承加新子类:新建一张独立表单表继承加子类最灵活——只加列,不动已有数据。类表和具体表继承加子类需要新表,但不会影响已有表结构。如果未来子类数量不确定或可能频繁增加,优先选单表继承。
服务端阅读 06月5日 00:26

TypeORM测试:Mock Repository、SQLite内存数据库和NestJS集成

TypeORM 的测试分两层:不依赖数据库的纯逻辑测试(单元测试),和需要真实数据库交互的测试(集成测试)。很多人所有测试都连数据库,跑得又慢又不稳定;也有人不连数据库,Mock 了一大堆,测完发现线上还是出 bug。这篇文章把两种策略的使用场景和实现方式都讲清楚。测试环境:SQLite 内存数据库集成测试需要真实数据库,但不需要用 MySQL/PostgreSQL——SQLite 内存数据库够用,速度快 10 倍以上,每个测试文件启动不到 100ms:import { DataSource } from 'typeorm';import { User } from '../entity/User';import { Post } from '../entity/Post';export const testDataSource = new DataSource({ type: 'sqlite', database: ':memory:', entities: [User, Post], synchronize: true, // 自动建表 logging: false,});为什么用 SQLite 而不是真实数据库:零配置:不需要装数据库、建测试库、管连接字符串隔离性:内存数据库每次测试完自动销毁,测试之间无干扰速度:内存操作,无网络 IO,单测 < 50ms但 SQLite 不支持某些 MySQL/PostgreSQL 特性(如 RETURNING、ON CONFLICT、JSON 函数)。如果你的查询用了这些特性,需要用真实的 PostgreSQL 做集成测试。测试基础设施// test/setup.tsimport { DataSource } from 'typeorm';let dataSource: DataSource;beforeAll(async () => { dataSource = new DataSource({ type: 'sqlite', database: ':memory:', entities: [__dirname + '/../src/entity/*.ts'], synchronize: true, }); await dataSource.initialize();});afterAll(async () => { await dataSource.destroy();});// 每个测试前清空所有表afterEach(async () => { const entities = dataSource.entityMetadatas; for (const entity of entities) { const repository = dataSource.getRepository(entity.name); await repository.clear(); }});单元测试:不连数据库单元测试只测业务逻辑,不测数据库交互。Repository 方法用 Mock 替代。Mock Repositoryimport { UsersService } from './users.service';import { Repository } from 'typeorm';import { User } from './user.entity';describe('UsersService', () => { let service: UsersService; let mockRepository: jest.Mocked<Repository<User>>; beforeEach(() => { // 创建 Mock Repository mockRepository = { find: jest.fn(), findOne: jest.fn(), create: jest.fn(), save: jest.fn(), remove: jest.fn(), count: jest.fn(), } as any; service = new UsersService(mockRepository); }); it('should return all users', async () => { const mockUsers = [ { id: 1, name: 'Alice', email: 'alice@test.com' }, { id: 2, name: 'Bob', email: 'bob@test.com' }, ]; mockRepository.find.mockResolvedValue(mockUsers as User[]); const result = await service.findAll(); expect(result).toEqual(mockUsers); expect(mockRepository.find).toHaveBeenCalled(); }); it('should create a user', async () => { const dto = { name: 'Alice', email: 'alice@test.com' }; const savedUser = { id: 1, ...dto }; mockRepository.create.mockReturnValue(savedUser as User); mockRepository.save.mockResolvedValue(savedUser as User); const result = await service.create(dto); expect(result).toEqual(savedUser); expect(mockRepository.create).toHaveBeenCalledWith(dto); expect(mockRepository.save).toHaveBeenCalled(); }); it('should throw when user not found', async () => { mockRepository.findOne.mockResolvedValue(null); await expect(service.findOne(999)).rejects.toThrow('User not found'); });});Mock 的核心原则:只 Mock 外部依赖(数据库),不 Mock 被测代码本身的逻辑。如果 Service 里有个计算函数,直接测它,不要 Mock。什么时候用 Mock,什么时候不用| 场景 | 策略 | 原因 ||------|------|------|| 纯逻辑函数 | 不 Mock | 没有外部依赖 || Service → Repository | Mock Repository | 隔离数据库,测试快 || 复杂查询逻辑 | 不 Mock,用集成测试 | Mock 查询结果无法验证 SQL 正确性 || 第三方 API | Mock HTTP | 不能调外部服务 |关键判断:你的测试目的是验证"代码逻辑对不对"还是"SQL 查询对不对"?前者用 Mock,后者用集成测试。集成测试:连真实数据库集成测试验证 SQL 是否正确、事务是否生效、约束是否有效——这些 Mock 测不了。事务回滚策略每个测试跑在一个事务里,测完回滚,数据库回到干净状态:describe('User integration tests', () => { let dataSource: DataSource; let userRepository: Repository<User>; beforeAll(async () => { dataSource = new DataSource({ type: 'sqlite', database: ':memory:', entities: [User], synchronize: true, }); await dataSource.initialize(); userRepository = dataSource.getRepository(User); }); afterAll(async () => { await dataSource.destroy(); }); it('should create and find a user', async () => { const user = userRepository.create({ name: 'Alice', email: 'alice@test.com', }); await userRepository.save(user); const found = await userRepository.findOne({ where: { email: 'alice@test.com' }, }); expect(found).toBeDefined(); expect(found!.name).toBe('Alice'); }); it('should enforce unique email constraint', async () => { await userRepository.save({ name: 'Alice', email: 'alice@test.com' }); await expect( userRepository.save({ name: 'Bob', email: 'alice@test.com' }) ).rejects.toThrow(); // SQLite 会抛 UNIQUE constraint 错误 });});测试复杂查询Query Builder 的复杂查询必须用集成测试——Mock 的 find 返回值证明不了 SQL 写对了:it('should find users with post count > 5', async () => { // 准备数据 const user = await userRepository.save({ name: 'Alice', email: 'a@test.com' }); for (let i = 0; i < 6; i++) { await postRepository.save({ title: `Post ${i}`, authorId: user.id }); } // 执行复杂查询 const result = await userRepository .createQueryBuilder('user') .leftJoin('user.posts', 'post') .groupBy('user.id') .having('COUNT(post.id) > 5') .getMany(); expect(result).toHaveLength(1); expect(result[0].name).toBe('Alice');});NestJS + TypeORM 测试NestJS 项目里,TypeORM 通过模块注入,测试需要用 @nestjs/testing:单元测试(Mock DataSource)import { Test } from '@nestjs/testing';import { getRepositoryToken } from '@nestjs/typeorm';import { UsersService } from './users.service';import { User } from './user.entity';describe('UsersService', () => { let service: UsersService; let mockRepo: any; beforeEach(async () => { mockRepo = { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null), save: jest.fn().mockResolvedValue({}), create: jest.fn().mockReturnValue({}), }; const module = await Test.createTestingModule({ providers: [ UsersService, { provide: getRepositoryToken(User), useValue: mockRepo }, ], }).compile(); service = module.get<UsersService>(UsersService); }); it('should be defined', () => { expect(service).toBeDefined(); });});getRepositoryToken(User) 是关键——NestJS 用这个 token 注入 Repository,Mock 时也用同一个 token。E2E 测试(真实数据库)import { Test } from '@nestjs/testing';import { INestApplication } from '@nestjs/common';import { TypeOrmModule } from '@nestjs/typeorm';import * as request from 'supertest';describe('Users API (e2e)', () => { let app: INestApplication; beforeAll(async () => { const moduleFixture = await Test.createTestingModule({ imports: [ TypeOrmModule.forRoot({ type: 'sqlite', database: ':memory:', entities: [User], synchronize: true, }), TypeOrmModule.forFeature([User]), UsersModule, ], }).compile(); app = moduleFixture.createNestApplication(); await app.init(); }); afterAll(async () => { await app.close(); }); it('POST /users', () => { return request(app.getHttpServer()) .post('/users') .send({ name: 'Alice', email: 'alice@test.com' }) .expect(201); }); it('GET /users', () => { return request(app.getHttpServer()) .get('/users') .expect(200); });});E2E 测试用 SQLite 内存数据库,请求走完整的 HTTP → Controller → Service → Repository → 数据库链路,最接近真实场景。测试策略总结| 测试类型 | 用途 | 速度 | 数据库 | 覆盖范围 ||----------|------|------|--------|----------|| 单元测试 | 验证业务逻辑 | < 10ms | 不需要(Mock) | Service 层逻辑 || 集成测试 | 验证 SQL 和约束 | 50-200ms | SQLite 内存 | Repository 查询 || E2E 测试 | 验证完整请求链路 | 100-500ms | SQLite 内存 | HTTP → 数据库 |经验法则:单元测试占 70%,集成测试占 20%,E2E 测试占 10%。核心业务逻辑用单元测试覆盖,复杂查询用集成测试验证,API 入口用少量 E2E 测试保证链路畅通。
服务端阅读 06月4日 23:11

npm audit工作原理:漏洞评估、overrides修复和CI集成

npm audit 一跑一片红,但很多漏洞其实不影响你的项目——间接依赖里的原型链污染,你的代码根本不走那条路径。这篇文章讲清楚 npm audit 的工作原理、怎么判断漏洞是否真的有风险、以及修复和忽略的策略。npm audit 的工作原理npm audit 的工作流程:把你项目的依赖树(包括间接依赖)发送到 npm 的审计服务 https://registry.npmjs.org/-/npm/v1/security/advisories/bulk审计服务把每个包的名称和版本和已知漏洞数据库比对返回匹配到的漏洞列表,按严重级别分类这就是为什么 npm audit 需要网络——它不是本地检查,而是查询 npm 的漏洞数据库。离线环境跑不了 audit。漏洞数据来源npm 的漏洞数据来自社区提交的 Security Advisories。任何人都可以提交漏洞报告,npm 团队审核后入库。这意味着:漏洞可能有延迟——新发现的 CVE 可能几天后才出现在 audit 结果里某些"漏洞"可能是理论性的——在特定条件下才可利用,实际项目根本不触发严重级别是提交者判定的,可能偏严解读 audit 报告npm audit输出示例:# npm audit reportlodash <4.17.21Severity: highPrototype Pollution - https://npmjs.com/advisories/1673fix available via `npm audit fix`node_modules/lodash2 vulnerabilities (1 low, 1 high)关键信息:包名和版本范围:lodash <4.17.21,当前安装的版本在这个范围内严重级别:high漏洞类型:Prototype Pollution(原型链污染)修复方式:npm audit fix 可自动修复依赖路径:哪个顶层依赖引入了这个有漏洞的间接依赖看依赖路径很重要——如果 lodash 是 eslint 的间接依赖,而 eslint 只在开发环境用,生产环境不存在这个风险。修复策略npm audit fix:自动修复# 自动修复兼容范围内的漏洞npm audit fix# 强制修复(可能引入破坏性变更)npm audit fix --forcenpm audit fix 只更新兼容范围内的版本——如果 package.json 里写的是 "lodash": "^4.17.0",audit fix 会更新到 4.17.21。但如果修复需要跨大版本(如 lodash@5),audit fix 不会自动升,需要 --force 或手动处理。--force 有风险:跨大版本升级可能引入不兼容的 API 变更。跑完 --force 后必须跑一遍测试。overrides:强制指定版本(npm 8+)当有漏洞的包是间接依赖时,你无法直接升级它。overrides 可以强制所有层级的依赖使用指定版本:{ "overrides": { "lodash": "^4.17.21", "minimist": "^1.2.6" }}更精确的写法——只覆盖特定间接依赖:{ "overrides": { "eslint": { "lodash": "^4.17.21" } }}这表示:只有 eslint 使用的 lodash 被覆盖为 4.17.21,其他包的 lodash 不受影响。手动升级# 升级到修复漏洞的版本npm install lodash@4.17.21# 升级到最新版本npm install lodash@latest处理无法修复的漏洞不是所有漏洞都能修——有些包的作者已经不维护了,升级会破坏你的项目。这种情况下需要评估风险。评估漏洞是否真的有风险问三个问题:你的代码是否使用了漏洞涉及的 API? 原型链污染只在 _.merge、_.defaultsDeep 等深合并函数上触发,如果你只用 _.get、_.filter,不受影响漏洞包是否在生产环境运行? devDependencies 里的漏洞不影响生产代码,可以忽略攻击者能否控制输入? 如果漏洞涉及的数据只来自你自己的服务器,攻击者无法利用npm audit --production只检查生产依赖,排除 devDependencies:npm audit --production开发工具链(eslint、webpack、jest)的漏洞不需要修——它们不会出现在生产环境。忽略特定漏洞npm 没有官方的 .auditignore 文件。变通方案:方案一:.npmrc 配置审计级别# .npmrcaudit-level=high只报告 high 和 critical,忽略 low 和 moderate。方案二:脚本忽略特定 advisory# 忽略 advisory 1673npm audit --omit=dev 2>&1 | grep -v "1673"方案三:用 npm-audit-resolvernpx resolve-audit交互式选择要忽略或修复的漏洞,忽略记录保存在 .audit-resolve.json 里,团队成员共享。CI 里集成 audit# GitHub Actions- name: Security audit run: npm audit --audit-level=high --production# 只在 critical 漏洞时阻断- name: Block critical run: | audit_output=$(npm audit --json --production) critical=$(echo "$audit_output" | jq '.metadata.vulnerabilities.critical // 0') if [ "$critical" -gt 0 ]; then echo "::error::$critical critical vulnerabilities found" exit 1 fi建议:CI 里只阻断 high 和 critical。low 和 moderate 数量太多,全部阻断会让团队无视 audit 结果。第三方安全工具npm audit 只检查 npm 漏洞数据库。更多维度的安全检查需要第三方工具:Snyknpm install -g snyksnyk authsnyk test # 扫描漏洞snyk monitor # 持续监控snyk wizard # 交互式修复Snyk 的漏洞数据库比 npm 更全面,且支持 Docker 镜像扫描、代码安全扫描。socket.dev检测供应链攻击——恶意包在 install 时执行恶意代码。npm audit 不检测这类攻击,socket.dev 专门做这个。安全最佳实践清单CI 里加 npm audit --production --audit-level=high,只阻断高危生产依赖提交 package-lock.json,保证团队安装相同版本用 npm ci 而非 npm install,CI 环境保证可重现定期 npm outdated,保持依赖不过时overrides 修复间接依赖漏洞,等顶层包更新不如自己覆盖devDependencies 的漏洞可以忽略,不影响生产不要用 --force 绕过 peer 冲突,冲突往往暗示兼容性问题
服务端阅读 06月4日 23:10

npm在CI/CD中的最佳实践:缓存策略、npm ci和安全审计

本地 npm install 跑得好好的,推到 CI 就各种失败——超时、依赖不一致、缓存不命中、构建产物丢失。这篇文章把 npm 在 CI/CD 里的常见坑和最佳实践都过一遍,以 GitHub Actions 为主,其他 CI 工具的思路一样。npm ci vs npm install:CI 里永远用 ci# ❌ 错误:CI 里用 npm installnpm install# ✅ 正确:CI 里用 npm cinpm ci两者区别:| | npm install | npm ci ||---|---|---|| 依赖来源 | 参考 package-lock,但可能更新它 | 严格按 package-lock,不一致则报错 || nodemodules | 增量安装,不清除 | 先删 nodemodules 再装 || 速度 | 较慢(要解析依赖树) | 更快(直接按 lock 文件装) || 确定性 | 不保证(可能偷偷升级) | 保证(锁文件必须和 package.json 一致) |CI 环境的核心要求是可重现——同样的代码两次构建结果必须一样。npm install 可能悄悄修改 lock 文件,npm ci 不允许。前提:npm ci 要求 package-lock.json 必须存在且和 package.json 一致。如果不一致直接报错,不会偷偷修——这正是 CI 需要的行为。缓存策略依赖安装是 CI 里最耗时的步骤之一。缓存 node_modules 或 npm 全局缓存可以节省 80% 以上的安装时间。GitHub Actions- uses: actions/setup-node@v4 with: node-version: 20 cache: npm # 自动缓存 ~/.npm 目录- run: npm ci # 从缓存安装,速度极快cache: npm 是最简单的方案——actions/setup-node 自动根据 package-lock.json 的 hash 生成缓存 key,lock 文件变了缓存自动失效。手动配置缓存(更精细控制):- name: Cache node modules uses: actions/cache@v3 with: path: | ~/.npm node_modules key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-node-- run: npm ci缓存 node_modules 还是 ~/.npm?| 策略 | 优点 | 缺点 ||------|------|------|| 缓存 ~/.npm | 缓存体积小,命中率高 | 还需要跑 npm ci(但只从本地缓存读) || 缓存 node_modules | 跳过安装步骤 | 缓存体积大,不同 job 间可能不兼容 || 都缓存 | 最快 | 缓存体积最大 |推荐:只缓存 ~/.npm。npm ci 配合本地缓存的安装速度已经够快(通常 5-15 秒),而缓存 node_modules 的缓存体积大且跨 job 兼容性差。GitLab CIcache: key: files: - package-lock.json paths: - .npm/install: script: - npm ci --cache .npm --prefer-offline--cache .npm 指定缓存目录,--prefer-offline 优先从缓存读,缓存没有再从网络下载。环境变量CI 环境下几个关键的环境变量:# 跳过 npm fund 和 npm audit 输出(CI 里不需要)export npm_config_fund=falseexport npm_config_audit=false# 不生成 package-lock.json(npm ci 不需要)export npm_config_package_lock=false# 设置日志级别(减少 CI 日志噪音)export npm_config_loglevel=warn在 GitHub Actions 里:env: npm_config_fund: false npm_config_audit: false安全审计集成在 CI 里自动检测安全漏洞:- name: Security audit run: npm audit --audit-level=high continue-on-error: true # 先不阻断,只报告- name: Block on critical run: | critical=$(npm audit --json | jq '.metadata.vulnerabilities.critical // 0') if [ "$critical" -gt 0 ]; then echo "::error::Found $critical critical vulnerabilities" exit 1 fi--audit-level=high 只在发现 high 或 critical 漏洞时返回非零退出码。low 和 moderate 不阻断构建但会输出警告。完整的 GitHub Actions 工作流name: CIon: push: branches: [main] pull_request: branches: [main]jobs: build: runs-on: ubuntu-latest strategy: matrix: node-version: [18, 20] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: npm - run: npm ci - run: npm run lint - run: npm run build - run: npm test - name: Upload coverage if: matrix.node-version == 20 uses: actions/upload-artifact@v4 with: name: coverage path: coverage/常见坑package-lock.json 和 package.json 不一致本地 npm install 后忘了提交 lock 文件,CI 里 npm ci 就会报错。解决:每次 npm install 后检查 lock 文件是否有变化,有就提交。- name: Check lock file run: | npm install --package-lock-only git diff --exit-code package-lock.jsonmonorepo 下缓存 key 不对monorepo 有多个 package-lock.json,缓存 key 只用了根目录的。解决:hashFiles 支持通配符:key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}CI 里的 Node 版本和本地不一致本地用 Node 20,CI 默认跑 Node 18。npm ci 可能在 Node 18 下装的依赖和 Node 20 不兼容。解决:用 matrix.node-version 跑多个版本,或锁定 CI 的 Node 版本。
服务端阅读 06月4日 23:09

npm workspaces monorepo:构建顺序、TypeScript配置和CI实践

npm workspaces 是 npm 7+ 内置的 monorepo 方案——不需要安装额外工具,在 package.json 里声明 workspaces 就能用。但"能用"和"好用"之间有不少坑:workspace 间的依赖引用、构建顺序、TypeScript 配置、CI 下的缓存策略,这些才是实际项目里反复踩的。这篇文章把从搭建到上线的完整流程走一遍。基本配置目录结构my-monorepo/├── package.json # 根配置,声明 workspaces├── package-lock.json # 统一的锁文件├── packages/│ ├── utils/ # 工具库│ │ └── package.json│ ├── core/ # 核心业务│ │ └── package.json│ └── app/ # 应用│ └── package.json└── tsconfig.base.json # 共享的 TypeScript 配置根 package.json{ "name": "my-monorepo", "private": true, "workspaces": ["packages/*"]}private: true 必须设——根目录不是可发布的包,npm publish 时会跳过。子包 package.json// packages/utils/package.json{ "name": "@myorg/utils", "version": "1.0.0", "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { "build": "tsc", "dev": "tsc --watch" }}// packages/app/package.json{ "name": "@myorg/app", "version": "1.0.0", "dependencies": { "@myorg/utils": "workspace:*" // 引用 workspace 内的包 }}workspace:* 是 workspace 协议——npm 会在 node_modules 里创建符号链接指向 packages/utils,而不是从 registry 下载。本地开发改了 utils 的代码,app 里直接生效,不需要 npm link。安装依赖# 在根目录安装所有 workspace 的依赖npm install# 给特定 workspace 加依赖npm install lodash --workspace=@myorg/app# 给根目录加依赖(构建工具等)npm install -D typescript -w .一个 npm install 解决所有 workspace 的依赖,只生成一个 package-lock.json。这比每个子目录单独 install 高效得多。构建顺序monorepo 最大的痛点之一:@myorg/app 依赖 @myorg/utils,utils 没编译,app 就引用不到类型定义。npm workspaces 本身不管理构建顺序。手动按顺序构建// 根 package.json{ "scripts": { "build": "npm run build -w @myorg/utils -w @myorg/core -w @myorg/app" }}-w 按声明顺序执行。缺点:每次加新包要手动改这个列表。用 npm-run-all 并行构建npm install -D npm-run-all{ "scripts": { "build:utils": "npm run build -w @myorg/utils", "build:core": "npm run build -w @myorg/core", "build:app": "npm run build -w @myorg/app", "build": "run-s build:utils build:core build:app" }}run-s 串行执行,run-p 并行执行。没有依赖关系的包可以并行,有依赖的串行。更好的方案:用 turborepo 或 nx大型 monorepo 用 npm workspaces 管构建顺序太痛苦。turborepo 和 nx 可以自动分析依赖图,只构建有变化的包:npm install -D turbo// 根 package.json{ "scripts": { "build": "turbo run build" }}turborepo 自动分析 @myorg/app 依赖 @myorg/utils,先构建 utils 再构建 app,且只构建有改动的包。和 npm workspaces 不冲突——turborepo 只是调度层,底层还是 npm。TypeScript 配置monorepo 里 TypeScript 项目引用(Project References)是关键——让 tsc 知道包之间的依赖关系,支持增量编译。共享基础配置// tsconfig.base.json{ "compilerOptions": { "target": "ES2020", "module": "commonjs", "strict": true, "declaration": true, "declarationMap": true, "sourceMap": true, "outDir": "dist", "rootDir": "src" }}子包配置// packages/utils/tsconfig.json{ "extends": "../../tsconfig.base.json", "compilerOptions": { "composite": true // 必须设,支持 project references }, "include": ["src"]}// packages/app/tsconfig.json{ "extends": "../../tsconfig.base.json", "compilerOptions": { "composite": true }, "references": [ { "path": "../utils" } // 声明对 utils 的引用 ], "include": ["src"]}composite: true + references 让 tsc 先编译依赖包,再编译当前包。增量编译只重新编译有变化的包。发布workspace 协议转真实版本workspace:* 是本地开发用的占位符,发布时 npm 会自动替换成实际版本号:// 开发时"dependencies": { "@myorg/utils": "workspace:*" }// npm publish 时自动替换为"dependencies": { "@myorg/utils": "^1.0.0" }逐个发布# 先构建所有包npm run build# 发布特定包npm publish --workspace=@myorg/utils# 发布所有包(按依赖顺序)npm publish --workspacesCI/CD 配置GitHub Actions 缓存- uses: actions/setup-node@v4 with: node-version: 20 cache: npm # 自动缓存 ~/.npm- run: npm ci # 严格按 lock 文件安装- run: npm run build- run: npm testcache: npm 缓存 npm 的全局缓存目录,npm ci 从缓存安装比从网络快 5-10 倍。只构建变更的包配合 turborepo,CI 里只构建受 PR 影响的包:npx turbo run build --filter=...[HEAD^1]--filter=...[HEAD^1] 表示"从上一个 commit 到现在有变化的包,以及依赖它们的包"。npm workspaces 的局限没有内置构建调度:不分析依赖图,不缓存构建产物没有依赖图可视化:不知道哪个包依赖哪个-w 不够灵活:不能按"依赖了 X 的所有包"过滤,只能指定包名发布流程手动:不会自动按依赖顺序发版、不会自动 bump 版本号如果你遇到这些痛点,说明项目规模已经超出了 npm workspaces 的舒适区——考虑引入 turborepo 做构建调度,或者迁移到 pnpm workspaces(过滤能力更强)。
服务端阅读 06月4日 23:08

npm、pnpm和Bun怎么选?依赖隔离、安装速度和迁移成本对比

选包管理工具不是比谁安装快——安装只在 npm install 那一瞬间,但依赖结构、磁盘占用、monorepo 支持、CI 表现才是日常影响效率的因素。这篇文章不列参数表,而是从实际场景出发:你的项目该用哪个,什么时候该迁移。三者的核心差异| | npm | pnpm | Bun ||---|---|---|---|| 依赖存储 | 每个项目独立安装 | 全局存储 + 硬链接 | 每个项目独立安装 || node_modules 结构 | 扁平化(包可访问未声明的依赖) | 嵌套 + 符号链接(严格隔离) | 扁平化 || monorepo 支持 | npm workspaces(npm 7+) | pnpm workspaces + 过滤器 | Bun workspaces || 锁文件 | package-lock.json | pnpm-lock.yaml | bun.lockb(二进制) || Node.js 依赖 | 自带(npm 就是 Node 的一部分) | 需要 Node.js | 自带运行时(可替代 Node) |pnpm:省磁盘、严格隔离为什么省磁盘npm 和 Yarn 每个项目都把依赖完整安装到 node_modules,10 个项目用同一个版本的 lodash,磁盘上就有 10 份。pnpm 把所有包存到全局存储 ~/.pnpm-store,项目里的 node_modules 通过硬链接指向全局存储——10 个项目只有 1 份 lodash 的实际文件。# 查看全局存储位置pnpm store path# 查看存储占用的磁盘空间pnpm store prune # 清理未被引用的包实际节省:一个 10 个前端项目的机器,npm 可能占 5GB 的 node_modules,pnpm 只要 1-2GB。依赖隔离才是重点npm 的扁平化 node_modules 允许你引用未在 package.json 里声明的依赖——因为 npm 会把所有包提升到顶层。这叫"幻影依赖",代码能跑但不知道为什么能跑。// 你的 package.json 只声明了 express// 但代码里直接用了 express 的依赖 debugconst debug = require('debug'); // npm 下能跑,pnpm 下报错pnpm 的 node_modules 结构是这样的:node_modules/├── .pnpm/│ ├── express@4.18.2/│ │ └── node_modules/│ │ ├── express/ # 硬链接│ │ └── debug/ # 只有 express 能访问│ └── debug@4.3.4/│ └── node_modules/│ └── debug/└── express/ # 符号链接到 .pnpm/expressdebug 在 express 的 node_modules 里,不在项目顶层。你的代码直接 require('debug') 会报 MODULE_NOT_FOUND——必须自己声明依赖。这个"严格模式"是 pnpm 最大的价值:提前发现依赖声明缺失,而不是上线后因为某个间接依赖升级而突然崩溃。pnpm 的 monorepo 过滤器pnpm 的 --filter 是 monorepo 管理最强的功能:# 只构建依赖了 shared 包的包pnpm --filter ...shared build# 只构建 app 包及其所有依赖pnpm --filter app... build# 排除某个包pnpm -r build --filter=!docs... 语法表示"依赖链"——比 npm 的 -w 灵活得多。什么时候用 pnpmmonorepo 项目(过滤器和严格隔离是刚需)磁盘空间有限(CI 服务器、Docker 镜像)想要严格的依赖边界(大型团队、长期维护项目)从 npm 迁移成本:需要加 pnpm-workspace.yaml,.npmrc 里的 shamefully-hoist=true 可以兼容旧代码Bun:最快,但有取舍Bun 不只是包管理器——它是 Node.js 的替代运行时,内置包管理器、测试框架、打包工具。安装速度Bun 的安装速度确实碾压其他工具——用 Zig 写的,多线程解析 package.json,全局缓存 + 硬链接。实际测试(cold install,~500 依赖):npm:~45spnpm:~25sBun:~8sCI 环境下差距缩小(有缓存时 npm 和 pnpm 也不慢),本地开发反复 rm -rf node_modules && install 时差距最明显。运行时差异Bun 的运行时兼容大部分 Node.js API,但不完全兼容:兼容的:fs、path、http、crypto、大部分 npm 包不兼容的:Node.js 的 C++ 原生模块(如 node-gyp 编译的包)需要特殊处理;部分 child_process 行为差异;worker_threads 支持不完整原生支持的:TypeScript 直接运行(不需 ts-node)、JSX、.env 文件、WebSocket什么时候用 Bun新项目,不需要 C++ 原生模块对启动速度和安装速度有极致要求愿意用 Bun 做运行时而不仅仅是包管理器不适合:依赖 node-gyp 编译的包(如 better-sqlite3、canvas)、需要完整 Node.js 兼容性的项目Bun 的锁文件是二进制的bun.lockb 是二进制格式,git diff 看不到变化内容,code review 不友好。这是迁移到 Bun 的常见顾虑。可以用 bun lockfile 导出为可读格式。npm:兼容性最好的默认选择npm 的最大优势:不需要额外安装。Node.js 自带 npm,所有 Node 项目开箱即用。npm 的局限安装速度最慢(单线程解析,无全局缓存复用)扁平化 node_modules 导致幻影依赖monorepo 的 -w 过滤能力比 pnpm --filter 弱很多package-lock.json 合并冲突频发什么时候坚持用 npm小型项目,依赖少于 50 个团队不熟悉 pnpm/Bun,不想引入新工具需要最大兼容性(某些 CI 环境只预装 npm)临时项目、原型验证迁移建议npm → pnpm# 安装 pnpmnpm install -g pnpm# 在项目根目录执行pnpm import # 自动从 package-lock.json 生成 pnpm-lock.yaml# 安装依赖pnpm install# 如果有幻影依赖报错,临时加 shamefully-hoistecho "shamefully-hoist=true" > .npmrcpnpm installshamefully-hoist=true 让 pnpm 的 node_modules 结构和 npm 一样扁平,兼容旧代码。后续逐步补齐缺失的依赖声明后去掉这个配置。npm → Bun# 安装 Buncurl -fsSL https://bun.sh/install | bash# 在项目根目录bun install # 自动从 package-lock.json 生成 bun.lockb注意检查 C++ 原生模块的兼容性。bcrypt、sharp、canvas 等包可能需要额外配置。选择决策树项目需要 C++ 原生模块? ├─ 是 → npm 或 pnpm └─ 否 → 是 monorepo 吗? ├─ 是 → pnpm(过滤器 + 严格隔离) └─ 否 → 追求开发体验和速度吗? ├─ 是 → Bun(安装快、启动快、TS 原生支持) └─ 否 → npm(零配置,开箱即用)
服务端阅读 06月4日 23:07

npm报错怎么排查?ERESOLVE、E404、EACCES等常见错误修复

npm install 报错,跑一次修一次,下次换个项目又遇到——这篇文章按错误类型分类,每种错误给诊断思路和解决方案,不再靠运气修 bug。第一步:看清错误信息npm 的错误信息有时候一大坨,但关键信息只在一两行。先找到 npm ERR! 开头的行,重点关注:code — 错误码,如 ERESOLVE、E404、EACCES、ENOENTpath — 出错的文件或目录路径syscall — 系统调用,如 open、access、mkdir# 加 --verbose 看完整日志npm install --verbose# 或看日志文件cat ~/.npm/_logs/*/debug.logERESOLVE:依赖树冲突npm 7+ 默认严格检查 peerDependencies。最常见也最烦人的错误。npm ERR! ERESOLVE unable to resolve dependency treenpm ERR! Conflicting peer dependency: react@18.2.0原因:你装的包要求 peer 依赖版本和你项目里已有的不一致。比如项目用 React 17,某个包要求 React 18。诊断:# 看完整依赖树npm ls <package># 查看谁依赖了冲突版本npm explain <package>解决方案(按优先级):# 方案一:升级冲突的依赖(推荐)npm install react@18 react-dom@18# 方案二:用 --legacy-peer-deps 跳过 peer 检查(临时方案)npm install --legacy-peer-deps# 方案三:用 overrides 强制指定版本(npm 8+)# package.json:{ "overrides": { "react": "^18.0.0" }}--legacy-peer-deps 是 npm 6 的行为——忽略 peer 冲突直接装。能用但不治本,冲突还在,运行时可能出问题。E404:包找不到npm ERR! 404 Not Found - GET https://registry.npmjs.org/@scope/package排查步骤:包名拼写对不对?npm 包名大小写敏感是私有包吗?需要登录:npm login是 scope 包吗?scope 名对不对?registry 对不对?# 检查当前 registrynpm config get registry# 如果用了镜像,某些私有包在镜像上不存在npm config set registry https://registry.npmjs.org/国内用户常见的坑:用了 npmmirror 镜像,但私有包或刚发布不到 10 分钟的包还没同步。临时切回官方 registry 安装,装完再切回来。EACCES:权限不足npm ERR! Error: EACCES: permission denied, access '/usr/local/lib/node_modules'根本原因:npm 全局安装目录需要 root 权限。错误做法:sudo npm install -g。用 sudo 安装后,某些文件归 root 所有,后续不用 sudo 就装不了,恶性循环。正确做法:修改 npm 全局目录到用户目录:mkdir -p ~/.npm-globalnpm config set prefix ~/.npm-global# 加到 PATHecho 'export PATH=~/.npm-global/bin:$PATH' >> ~/.bashrcsource ~/.bashrc或者用 nvm 管理 Node.js——nvm 安装的 Node 在用户目录下,全局安装不需要 sudo。网络问题:安装超时或卡住# 检查网络ping registry.npmjs.org# 查看代理设置npm config get proxynpm config get https-proxy# 用国内镜像npm config set registry https://registry.npmmirror.com# 超时设置(单位毫秒)npm config set fetch-timeout 60000公司内网常见问题:npm 走代理时,代理可能缓存了旧版本的包。清除代理缓存或加 --no-cache 参数。缓存损坏# 验证缓存完整性npm cache verify# 清除缓存npm cache clean --force# 终极手段:删 node_modules 和 lock 文件重装rm -rf node_modules package-lock.jsonnpm install缓存损坏的症状:npm ERR! ENOENT: no such file 或 npm ERR! EINTEGRITY(校验和不匹配)。npm cache verify 会删除损坏的缓存条目,比 clean --force 温和。锁文件问题package-lock.json 和 node_modules 不一致# 用 npm ci 严格按 lock 文件安装(推荐 CI 环境)npm ci# 重新生成 lock 文件rm package-lock.jsonnpm installnpm ci 和 npm install 的区别:npm ci 删掉 node_modules 后严格按 lock 文件装,不修改 lock 文件。如果 lock 文件和 package.json 不一致直接报错,不会偷偷更新。CI 环境永远用 npm ci。合并冲突git merge 后 lock 文件冲突,不要手动改。重新生成:rm package-lock.jsonnpm installnpm doctor:环境健康检查npm doctor输出示例:Check Value Recommendationnpm 10.2.3 Use npm v10.7.0node 18.17.0 Use node v20.xnpm config ok -global packages ok -cached scripts 57 -registry https://registry.npmjs.org/ ok逐项检查:npm 版本、Node 版本、配置文件、全局包权限、缓存状态、registry 连通性。哪个有问题就修哪个。npm explain 和 npm query查某个包为什么被安装npm explain lodash输出依赖链:lodash@4.17.21 ← eslint@8.50.0 ← 根项目。帮你判断能不能安全移除。查询满足条件的包# 所有开发依赖npm query ":dev"# 所有过期包npm query ":outdated"# 指定包的所有版本npm query "lodash@>4.0.0"npm query 用 CSS 选择器语法过滤依赖树,比 npm ls | grep 精确得多。常见错误速查| 错误码 | 含义 | 快速修复 ||--------|------|----------|| ERESOLVE | peer 依赖冲突 | --legacy-peer-deps 或升级依赖 || E404 | 包找不到 | 检查包名、registry、私有包登录 || EACCES | 权限不足 | 修改全局目录或用 nvm || EINTEGRITY | 校验和不匹配 | npm cache verify || ENOENT | 文件不存在 | 删 node_modules 重装 || ETARGET | 版本不存在 | 检查版本号、用 npm view 确认 || EMFILE | 打开文件过多 | ulimit -n 65536 |
服务端阅读 06月4日 15:48

Python上下文管理器:__exit__异常处理、@contextmanager和ExitStack

with open(...) as f: 这行代码用了十几年,但很多人不知道背后的机制——上下文管理器。更关键的是,自己写一个靠谱的上下文管理器并不简单:__exit__ 里该不该吃掉异常?@contextmanager 的 yield 和 finally 怎么配合?多个资源怎么一起管理?这篇文章把这些问题都讲清楚。上下文管理器解决什么问题资源管理的核心要求:用完必须释放,不管有没有异常。不用上下文管理器就得写 try/finally:# 笨办法f = open('data.txt')try: data = f.read()finally: f.close()# with 语句:等价但简洁with open('data.txt') as f: data = f.read()# f.close() 自动调用,即使 read() 抛异常with 语句保证 __exit__ 一定被调用,省掉 finally 的样板代码。文件、数据库连接、锁、事务,都需要这种保证。enter 和 exit 协议上下文管理器是实现 __enter__ 和 __exit__ 的对象:class Timer: def __enter__(self): import time self.start = time.perf_counter() return self # as 绑定的对象 def __exit__(self, exc_type, exc_val, exc_tb): import time elapsed = time.perf_counter() - self.start print(f"耗时: {elapsed:.4f}s") return False # 不吞异常,继续传播with Timer() as t: time.sleep(1)# 耗时: 1.0012s__enter__ 返回值通过 as 绑定。__exit__ 的三个参数是异常信息(没有异常时都是 None)。返回 True 表示吞掉异常,返回 False(或不返回)让异常继续传播。exit 该不该吞异常大多数场景不应该吞——返回 False,让调用方处理异常。只有极少数场景需要吞:如 suppress(FileNotFoundError) 这种明确要忽略特定异常的。# 危险:吞掉所有异常,问题难以排查def __exit__(self, exc_type, exc_val, exc_tb): return True # ❌ 任何异常都被静默忽略# 正确:只吞特定异常def __exit__(self, exc_type, exc_val, exc_tb): if exc_type is FileNotFoundError: return True # 忽略文件不存在 return False # 其他异常继续传播@contextmanager:更简单的写法手写 __enter__ 和 __exit__ 容易出错——@contextmanager 装饰器把上下文管理器变成生成器函数,逻辑更清晰:from contextlib import contextmanager@contextmanagerdef timer(name="block"): import time start = time.perf_counter() try: yield # yield 处就是 with 块的代码 finally: elapsed = time.perf_counter() - start print(f"{name} 耗时: {elapsed:.4f}s")with timer("数据处理"): process_data()yield 之前是 __enter__,yield 之后是 __exit__。finally 块保证清理逻辑一定执行。yield 可以返回值@contextmanagerdef temp_directory(): import tempfile, shutil dirpath = tempfile.mkdtemp() try: yield dirpath # 返回临时目录路径 finally: shutil.rmtree(dirpath) # 用完删除with temp_directory() as tmpdir: print(f"临时目录: {tmpdir}") # 在 tmpdir 里写文件...# tmpdir 已被删除异常处理@contextmanager 里 yield 抛出的异常会传播到 with 块,但如果在 yield 外面捕获了,就相当于吞掉:@contextmanagerdef safe_operation(): try: yield except ValueError as e: print(f"捕获到 ValueError: {e}") # 不 re-raise,异常被吞掉 finally: print("清理完成")with safe_operation(): raise ValueError("出错了")# 输出:捕获到 ValueError: 出错了# 清理完成# 程序继续执行,不会崩溃如果想在 @contextmanager 里记录但不吞掉异常,用 raise 重新抛出,或者不捕获让 finally 执行后自然传播。contextlib 实用工具suppress:忽略指定异常替代 try/except + pass 的惯用法:from contextlib import suppress# 以前try: os.remove('temp.txt')except FileNotFoundError: pass# 现在with suppress(FileNotFoundError): os.remove('temp.txt')可以忽略多种异常:suppress(FileNotFoundError, PermissionError)。closing:给有 close() 方法的对象加 with 支持from contextlib import closingfrom urllib.request import urlopenwith closing(urlopen('https://example.com')) as response: data = response.read()# response.close() 自动调用redirectstdout/redirectstderr:临时重定向输出from contextlib import redirect_stdoutimport iooutput = io.StringIO()with redirect_stdout(output): print("这行不会显示在终端")captured = output.getvalue() # "这行不会显示在终端"适合测试里捕获 print 输出,或者把进度信息写到日志文件而不是终端。ExitStack:动态管理多个上下文不确定需要打开多少个资源时用 ExitStack:from contextlib import ExitStackfiles = ['a.txt', 'b.txt', 'c.txt']with ExitStack() as stack: handles = [stack.enter_context(open(f)) for f in files] # 三个文件都打开了,任何一个打开失败,之前打开的会自动关闭 for h in handles: process(h)# 三个文件全部自动关闭也可以用 callback 注册清理函数:with ExitStack() as stack: stack.callback(print, "清理完成") do_something()# 无论 do_something 是否抛异常,"清理完成" 都会打印异步上下文管理器Python 3.5+ 支持 async with,对应 __aenter__ 和 __aexit__:class AsyncDBConnection: async def __aenter__(self): self.conn = await create_connection() return self.conn async def __aexit__(self, exc_type, exc_val, exc_tb): await self.conn.close() return Falseasync def main(): async with AsyncDBConnection() as conn: await conn.execute("SELECT 1")@asynccontextmanager 是异步版本:from contextlib import asynccontextmanager@asynccontextmanagerasync def db_transaction(pool): conn = await pool.acquire() try: yield conn finally: await pool.release(conn)async def main(): async with db_transaction(pool) as conn: await conn.execute("INSERT ...")FastAPI 和 SQLAlchemy 的异步数据库会话就是用这个模式。实际应用场景数据库事务@contextmanagerdef transaction(conn): try: yield conn conn.commit() except Exception: conn.rollback() raisewith transaction(conn): conn.execute("INSERT ...") conn.execute("UPDATE ...")# 两条语句要么都成功,要么都回滚临时修改环境变量@contextmanagerdef env_var(key, value): import os old = os.environ.get(key) os.environ[key] = value try: yield finally: if old is None: os.environ.pop(key, None) else: os.environ[key] = oldwith env_var('DATABASE_URL', 'sqlite:///:memory:'): run_tests() # 测试时用内存数据库# DATABASE_URL 恢复原值线程锁threading.Lock 本身就是上下文管理器:import threadinglock = threading.Lock()with lock: # 临界区,自动加锁/释放锁 update_shared_data()比 lock.acquire() ... lock.release() 安全——不会因为异常导致锁不释放。
服务端阅读 06月4日 15:45

NestJS控制器和路由:装饰器、参数获取、响应处理和常见坑

NestJS 的控制器用装饰器声明路由,不用手动写 app.get('/users/:id', ...)——装饰器既是文档又是路由注册。这篇文章把控制器的声明、路由参数获取、响应处理、以及常见的坑都过一遍。基本路由声明@Controller('users') // 路由前缀 /usersexport class UsersController { constructor(private readonly usersService: UsersService) {} @Get() // GET /users findAll() { return this.usersService.findAll(); } @Get(':id') // GET /users/:id findOne(@Param('id') id: string) { return this.usersService.findOne(id); } @Post() // POST /users create(@Body() dto: CreateUserDto) { return this.usersService.create(dto); } @Put(':id') // PUT /users/:id update(@Param('id') id: string, @Body() dto: UpdateUserDto) { return this.usersService.update(id, dto); } @Delete(':id') // DELETE /users/:id remove(@Param('id') id: string) { return this.usersService.remove(id); }}NestJS 自动把返回值序列化为 JSON,状态码默认 200(POST 是 201)。不需要手动 res.json()。路由参数的获取方式| 装饰器 | 来源 | 示例 ||--------|------|------|| @Param('id') | 路径参数 | /users/42 → "42" || @Query('page') | 查询参数 | ?page=2 → "2" || @Body() | 请求体 | {"name": "Tom"} → { name: "Tom" } || @Headers('auth') | 请求头 | Authorization: Bearer ... || @Ip() | 客户端 IP | || @Session() | Express session | |路径参数@Get(':id')findOne(@Param('id') id: string) { // 拿单个参数 return this.usersService.findOne(id);}@Get(':category/:id')findByCategory( @Param('category') category: string, @Param('id') id: string, // 多个路径参数) {}查询参数@Get()findAll( @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, @Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number, @Query('sort') sort?: string,) { return this.usersService.findAll({ page, limit, sort });}多个管道按参数位置从左到右执行:DefaultValuePipe 先设默认值,ParseIntPipe 再转数字。请求体 + DTO@Post()create(@Body() dto: CreateUserDto) { return this.usersService.create(dto);}DTO 配合 ValidationPipe 使用,验证逻辑在 DTO 类上声明,控制器保持干净。响应处理修改状态码@Post()@HttpCode(200) // POST 默认 201,改成 200create(@Body() dto: CreateUserDto) {}@Delete(':id')@HttpCode(204) // 删除成功返回 204 No Contentremove(@Param('id') id: string) {}设置响应头@Get()@Header('Cache-Control', 'max-age=3600')findAll() {}重定向@Get('docs')@Redirect('https://docs.nestjs.com', 302)redirectToDocs() {}动态重定向(返回值覆盖装饰器):@Get('docs')@Redirect('https://docs.nestjs.com')redirectToDocs(@Query('version') version?: string) { if (version === 'v7') { return { url: 'https://v7.docs.nestjs.com' }; }}流式响应大文件下载、SSE 等场景需要流式返回:import { Observable } from 'rxjs';@Get('stream')streamData(): Observable<MessageEvent> { return interval(1000).pipe( map(() => ({ data: `Time: ${new Date().toISOString()}` })), );}返回 Observable 或 Stream 时,NestJS 自动处理背压和清理。手动操作 Response需要完全控制响应时(如设置 cookie、自定义流),注入 @Res():import { Response } from 'express';@Get('download')download(@Res() res: Response) { res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Disposition', 'attachment; filename=report.pdf'); fs.createReadStream('report.pdf').pipe(res);}注意:一旦注入 @Res(),NestJS 不再自动序列化返回值——你必须自己调 res.json() 或 res.send()。如果只想设 cookie 但仍然用自动序列化,用 @Res({ passthrough: true }):@Post('login')login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) { const token = this.authService.login(dto); res.cookie('jwt', token, { httpOnly: true }); // 设 cookie return { message: '登录成功' }; // 返回值照常自动序列化}异步路由NestJS 天然支持 async/await,返回 Promise 就行:@Get()async findAll(): Promise<User[]> { return this.usersService.findAll(); // service 返回 Promise}也可以返回 RxJS Observable:@Get()findAll(): Observable<User[]> { return from(this.usersService.findAll());}路由版本控制API 版本升级时,同一接口需要同时支持 v1 和 v2:// main.ts 启用版本控制app.enableVersioning({ type: VersioningType.URI });@Controller('users')export class UsersController { @Get({ version: '1' }) // GET /v1/users findAllV1() { return this.usersService.findAllV1(); } @Get({ version: '2' }) // GET /v2/users findAllV2() { return this.usersService.findAllV2(); }}也可以用枚举或数组支持多个版本:version: ['1', '2']。请求生命周期一个请求进入控制器前后的完整链路:请求 → 中间件 → 守卫 → 拦截器(before) → 管道 → 控制器方法 → 拦截器(after) → 异常过滤器 → 响应控制器方法抛出的异常会被异常过滤器捕获。如果没有自定义过滤器,NestJS 内置的异常过滤器返回标准 JSON 错误:{ "statusCode": 404, "message": "User not found"}常见坑路由顺序:NestJS 按声明顺序匹配路由。@Get(':id') 在 @Get('profile') 前面的话,/users/profile 会被 :id 匹配,id 值变成 "profile"。把具体路由放在参数路由前面:@Controller('users')export class UsersController { @Get('profile') // ✅ 具体路由在前 getProfile() {} @Get(':id') // 参数路由在后 findOne(@Param('id') id: string) {}}返回 undefined:控制器方法返回 undefined 时,NestJS 返回空响应体和 200 状态码。如果你期望 204,要显式 @HttpCode(204)。@Body() 拿不到数据:需要全局启用 ValidationPipe 或确保 app.useBodyParser() 没被禁用。
服务端阅读 06月4日 15:43

NestJS提供者详解:四种注册方式、循环依赖和作用域选择

NestJS 的提供者(Provider)就是"可以被注入的东西"——@Injectable() 装饰的类,通过依赖注入(DI)容器管理生命周期,在控制器或其他服务里通过构造函数参数自动获得实例。服务是最常见的提供者,但提供者不只有服务:配置对象、数据库连接、工厂函数都可以是提供者。最常用的提供者:服务(Service)服务封装业务逻辑,控制器只负责接收请求和返回响应:// users.service.tsimport { Injectable } from '@nestjs/common';@Injectable()export class UsersService { private users = []; create(name: string, email: string) { const user = { id: Date.now(), name, email }; this.users.push(user); return user; } findAll() { return this.users; }}// users.controller.ts@Controller('users')export class UsersController { constructor(private readonly usersService: UsersService) {} // 自动注入 @Post() create(@Body() dto: CreateUserDto) { return this.usersService.create(dto.name, dto.email); } @Get() findAll() { return this.usersService.findAll(); }}在模块里注册:@Module({ controllers: [UsersController], providers: [UsersService], // 注册服务,DI 容器会自动创建实例})export class UsersModule {}提供者的四种注册方式useClass:默认方式providers: [UsersService]// 等价于:providers: [{ provide: UsersService, useClass: UsersService }]最常用,DI 容器自动 new 一个实例。useValue:提供常量或外部对象providers: [ { provide: 'API_KEY', useValue: process.env.API_KEY, // 直接给一个值 },]注入时用 @Inject() 指定令牌:constructor(@Inject('API_KEY') private apiKey: string) {}适合配置值、环境变量、第三方 SDK 实例等不需要 DI 创建的东西。useFactory:动态创建,可以注入依赖providers: [ { provide: 'DATABASE_CONNECTION', useFactory: (configService: ConfigService) => { return createConnection({ host: configService.get('DB_HOST'), port: configService.get('DB_PORT'), }); }, inject: [ConfigService], // 声明 useFactory 需要哪些依赖 },]useFactory 的参数由 inject 数组提供,DI 容器先解析 inject 里的依赖,再传给工厂函数。适合需要异步初始化、依赖其他服务的场景。useExisting:别名providers: [ UsersService, { provide: 'IUsersService', // 接口令牌 useExisting: UsersService, // 指向已有的提供者 },]接口在 TypeScript 编译后不存在,不能用 provide: IUsersService,用字符串或 Symbol 令牌 + useExisting 是标准做法。依赖注入令牌DI 容器通过令牌(token)匹配依赖。令牌可以是类、字符串或 Symbol:// 类令牌(最常见)constructor(private usersService: UsersService) {}// 字符串令牌constructor(@Inject('API_KEY') private apiKey: string) {}// Symbol 令牌(避免命名冲突)export const DATABASE_CONNECTION = Symbol('DATABASE_CONNECTION');constructor(@Inject(DATABASE_CONNECTION) private db: Connection) {}类令牌最好用——类型安全,不需要 @Inject() 装饰器。字符串和 Symbol 令牌用在没有对应类的场景。循环依赖两个服务互相依赖会报错:Circular dependency detected。// service-a 依赖 service-b,service-b 又依赖 service-a@Injectable()export class ServiceA { constructor(private serviceB: ServiceB) {} // ❌ 循环依赖}解决方案:用 forwardRef 延迟解析:@Injectable()export class ServiceA { constructor( @Inject(forwardRef(() => ServiceB)) private serviceB: ServiceB, ) {}}@Injectable()export class ServiceB { constructor( @Inject(forwardRef(() => ServiceA)) private serviceA: ServiceA, ) {}}模块里也要加 forwardRef:@Module({ imports: [forwardRef(() => ServiceBModule)],})export class ServiceAModule {}但 forwardRef 只是绕过了报错,说明设计有问题——更好的做法是提取公共逻辑到第三个服务,或者通过事件解耦。提供者作用域默认情况下所有提供者都是单例(Singleton)——整个应用共享一个实例。NestJS 支持三种作用域:| 作用域 | 生命周期 | 适用场景 ||--------|----------|----------|| DEFAULT(单例) | 应用启动时创建,共享 | 几乎所有服务 || REQUEST | 每个请求创建一个实例 | 请求上下文数据(如当前用户) || TRANSIENT | 每次注入都创建新实例 | 无状态的临时对象 |@Injectable({ scope: Scope.REQUEST })export class RequestContextService { private userId: string; setUserId(id: string) { this.userId = id; } getUserId() { return this.userId; }}注意:REQUEST 作用域的服务,注入它的控制器也必须是 REQUEST 作用域。而且 REQUEST 作用域会显著影响性能——每个请求都创建新实例,数据库连接等资源不能共享。大多数场景用 DEFAULT + 在请求对象上挂数据就够了。模块间共享提供者默认情况下,模块的提供者对外不可见。要让其他模块用你的服务,必须 export:// users.module.ts@Module({ controllers: [UsersController], providers: [UsersService], exports: [UsersService], // 暴露给其他模块})export class UsersModule {}其他模块 import 后就能注入 UsersService:// posts.module.ts@Module({ imports: [UsersModule], // import 整个模块 providers: [PostsService],})export class PostsModule {}// posts.service.ts@Injectable()export class PostsService { constructor(private usersService: UsersService) {} // 可以用了}关键规则:import 模块,注入 export 的服务。不能直接 import 服务,也不能注入没 export 的服务。可选注入某些依赖不是必须的,找不到时不报错:import { Optional } from '@nestjs/common';@Injectable()export class MyService { constructor(@Optional() private cacheService?: CacheService) {} getData() { return this.cacheService?.get('key') ?? this.fetchFromDB(); }}有 CacheService 就用缓存,没有就直接查数据库。适合功能增强型依赖。
服务端阅读 06月4日 15:42

NestJS中间件和守卫有什么区别?各自适用场景和RBAC实现

NestJS 里中间件和守卫都能拦截请求,很多人搞不清该用哪个。一句话区分:中间件不知道下一站是谁,守卫知道。中间件只能看到原始的 HTTP 请求/响应,守卫能拿到 ExecutionContext,知道当前请求要调用哪个控制器、哪个方法。这个区别决定了各自的职责:中间件做通用预处理(日志、CORS),守卫做权限判断(认证、授权)。核心区别对比| | 中间件(Middleware) | 守卫(Guard) ||---|---|---|| 能看到什么 | req、res、next | ExecutionContext(含控制器、方法元信息) || 能否访问 DI 容器 | 不能(函数式中间件) | 可以(@Injectable()) || 作用范围 | 模块级或全局 | 方法级、控制器级、全局 || 能否用装饰器元数据 | 不能 | 能(Reflector + SetMetadata) || 执行时机 | 最早(路由匹配之前) | 守卫之后,管道之前 || 典型用途 | 日志、CORS、请求转换 | 认证、授权、角色检查 || 返回值 | 无(调 next() 放行) | boolean / Promise<boolean> |中间件:看不到终点站的通用处理中间件直接来自 Express 的概念,签名是 (req, res, next) => void:import { Injectable, NestMiddleware } from '@nestjs/common';import { Request, Response, NextFunction } from 'express';@Injectable()export class LoggerMiddleware implements NestMiddleware { use(req: Request, res: Response, next: NextFunction) { console.log(`${req.method} ${req.url}`); next(); // 放行,必须调,否则请求卡住 }}在模块里注册(中间件不能装饰器注册):@Module({})export class AppModule implements NestModule { configure(consumer: MiddlewareConsumer) { consumer .apply(LoggerMiddleware) .forRoutes('users') // 只对 /users 路由生效 .exclude({ path: 'users', method: RequestMethod.GET }) // 排除 GET }}中间件能做什么日志:记录请求方法、路径、耗时CORS:跨域配置请求转换:解析 body、压缩响应限流:简单的 IP 级别限流中间件不能做什么权限判断:中间件拿不到当前要调用的控制器方法,不知道这个接口需要什么角色读取装饰器元数据:Reflector 在中间件里不可用精细路由控制:只能在模块级别通过路径匹配,不能按方法粒度守卫:知道要去哪,所以能判断能不能去守卫实现 CanActivate 接口,返回 true 放行、false 拒绝(返回 403):import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';@Injectable()export class AuthGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { const request = context.switchToHttp().getRequest(); return !!request.user; // 有 user 就放行 }}使用:@Controller('users')@UseGuards(AuthGuard) // 整个控制器都要认证export class UsersController { @Get() findAll() { /* ... */ } @Post() @UseGuards(AdminGuard) // 这个方法额外要管理员权限 create() { /* ... */ }}基于角色的权限控制(RBAC)守卫真正的威力是配合 SetMetadata + Reflector 实现声明式权限:// 自定义装饰器import { SetMetadata } from '@nestjs/common';export const Roles = (...roles: string[]) => SetMetadata('roles', roles);// 守卫里读取元数据import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';import { Reflector } from '@nestjs/core';@Injectable()export class RolesGuard implements CanActivate { constructor(private reflector: Reflector) {} canActivate(context: ExecutionContext): boolean { // 拿到方法或控制器上 @Roles() 标注的角色 const requiredRoles = this.reflector.getAllAndOverride<string[]>('roles', [ context.getHandler(), // 方法级别的元数据 context.getClass(), // 控制器级别的元数据 ]); if (!requiredRoles) return true; // 没标注角色,放行 const request = context.switchToHttp().getRequest(); return requiredRoles.some(role => request.user?.roles?.includes(role)); }}控制器上使用:@Controller('admin')@UseGuards(AuthGuard, RolesGuard)@Roles('admin') // 整个控制器需要 admin 角色export class AdminController { @Get('dashboard') dashboard() { /* ... */ } @Get('users') @Roles('admin', 'superadmin') // 这个方法需要 admin 或 superadmin listUsers() { /* ... */ }}这是中间件做不到的——中间件拿不到 @Roles('admin') 这个元数据,也不知道当前请求匹配的是哪个方法。守卫里注入服务守卫是 @Injectable() 的,可以注入数据库、缓存等服务:@Injectable()export class AuthGuard implements CanActivate { constructor(private jwtService: JwtService) {} async canActivate(context: ExecutionContext): Promise<boolean> { const request = context.switchToHttp().getRequest(); const token = request.headers.authorization?.replace('Bearer ', ''); if (!token) return false; try { request.user = await this.jwtService.verifyAsync(token); return true; } catch { return false; } }}执行顺序一个请求经过的完整链路:客户端请求 → 中间件 → 守卫 → 拦截器(before) → 管道 → 控制器 → 拦截器(after) → 过滤器中间件最先执行,适合做不依赖业务逻辑的通用处理。守卫在中间件之后,能用中间件预处理的结果(如解析出的 token)做权限判断。权限不通过直接返回 403,不会走到管道和控制器。什么时候用哪个| 场景 | 用什么 | 原因 ||------|--------|------|| 请求日志 | 中间件 | 不需要知道目标方法 || CORS 配置 | 中间件 | 通用 HTTP 头处理 || 请求限流 | 中间件 | 按 IP/路由限流,不涉及业务 || JWT 验证 | 守卫 | 需要注入 JwtService,需要设置 request.user || 角色权限 | 守卫 | 需要读取 @Roles() 元数据 || API Key 验证 | 守卫 | 需要查询数据库验证 key || 请求体转换 | 中间件 | 纯数据处理,不涉及权限 || 多租户隔离 | 守卫 | 需要根据路由决定查询哪个租户的数据 |判断口诀:只看 HTTP 不看业务 → 中间件;要看路由决定权限 → 守卫。
服务端阅读 06月4日 15:41

NestJS管道和验证:class-validator配置、自定义Pipe和常见坑

NestJS 的管道(Pipe)就两件事:转换和验证。转换是把字符串参数变成数字、把日期字符串变成 Date 对象;验证是检查请求体里的字段是否合法,不合法就拒绝请求。听起来简单,但管道是 NestJS 请求生命周期里的关键一环——守卫之后、控制器之前,数据必须过管道这一关。管道的两种用途转换:把输入数据转成目标类型(如 ParseIntPipe 把路由参数 "42" 变成数字 42)验证:检查输入数据是否合法,不合法抛异常(如 class-validator 检查 email 格式)一个管道可以只做转换、只做验证,或者两者都做。NestJS 内置的管道偏向转换,实际项目里的验证管道通常结合 class-validator。内置管道ParseIntPipe:路由参数转数字@Get(':id')findOne(@Param('id', ParseIntPipe) id: number) { // 如果 id 不是数字,自动返回 400 Bad Request return this.usersService.findOne(id);}不加 ParseIntPipe,id 是字符串 "42",你的 service 里拿到的类型和声明不一致。加了之后,不合法的值直接被拦截,控制器方法不会被调用。ParseUUIDPipe@Get(':id')findOne(@Param('id', new ParseUUIDPipe()) id: string) { return this.usersService.findOne(id);}验证 UUID 格式。非法 UUID 返回 400,不需要自己写正则。ParseArrayPipe@Get()findAll(@Query('ids', new ParseArrayPipe({ items: String, separator: ',' })) ids: string[]) { // ?ids=a,b,c → ['a', 'b', 'c'] return this.usersService.findByIds(ids);}DefaultValuePipe@Get()findAll( @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, @Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number,) { return this.usersService.findAll({ page, limit });}DefaultValuePipe 放在 ParseIntPipe 前面——先设默认值,再转数字。管道按参数顺序从左到右执行。用 class-validator 做请求体验证这是实际项目里用得最多的验证方式。安装依赖npm install class-validator class-transformer定义 DTOimport { IsString, IsEmail, IsInt, Min, IsOptional, IsEnum } from 'class-validator';export class CreateUserDto { @IsString() name: string; @IsEmail() email: string; @IsInt() @Min(0) age: number; @IsEnum(['admin', 'user']) @IsOptional() role?: string;}启用全局 ValidationPipe// main.tsimport { ValidationPipe } from '@nestjs/common';async function bootstrap() { const app = await NestFactory.create(AppModule); app.useGlobalPipes( new ValidationPipe({ whitelist: true, // 自动剥离 DTO 里没定义的字段 forbidNonWhitelisted: true, // 有多余字段时返回 400 而不是静默忽略 transform: true, // 自动把普通对象转成 DTO 类实例(class-transformer 生效) transformOptions: { enableImplicitConversion: true, // 自动类型转换(字符串 → 数字等) }, }), ); await app.listen(3000);}三个选项都很重要:whitelist: true — 防止客户端传入多余字段(如 isAdmin: true),只保留 DTO 中定义的属性forbidNonWhitelisted: true — 配合 whitelist,有多余字段直接报错,而不是静默丢弃transform: true — 让 class-transformer 的 @Type()、@Exclude() 等装饰器生效,否则 DTO 上的装饰器不会被执行嵌套对象验证如果 DTO 里有对象类型的属性,必须加 @ValidateNested() 和 @Type():import { ValidateNested } from 'class-validator';import { Type } from 'class-transformer';class AddressDto { @IsString() city: string; @IsString() street: string;}export class CreateUserDto { @IsString() name: string; @ValidateNested() @Type(() => AddressDto) // 必须指定类型,否则 class-transformer 不知道怎么实例化 address: AddressDto;}不加 @Type() 的话,address 仍然是一个普通 JS 对象,@ValidateNested() 无法对其中的属性做验证。自定义错误消息export class CreateUserDto { @IsString({ message: '用户名必须是字符串' }) name: string; @IsEmail({}, { message: '邮箱格式不正确' }) email: string;}或者统一格式:new ValidationPipe({ exceptionFactory: (errors) => { const messages = errors.map(err => ({ field: err.property, constraints: Object.values(err.constraints || {}), })); return new BadRequestException({ statusCode: 400, message: '输入验证失败', errors: messages }); },})自定义管道内置管道不够用时,写自己的:import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';@Injectable()export class ParseDatePipe implements PipeTransform<string, Date> { transform(value: string, metadata: ArgumentMetadata): Date { const date = new Date(value); if (isNaN(date.getTime())) { throw new BadRequestException(`"${value}" 不是有效的日期`); } return date; }}使用:@Get(':date')findByDate(@Param('date', ParseDatePipe) date: Date) { return this.recordsService.findByDate(date);}管道的作用范围管道可以用在四个层级:| 层级 | 写法 | 影响范围 ||------|------|----------|| 参数 | @Param('id', ParseIntPipe) | 只验证这一个参数 || 方法 | @UsePipes(new ValidationPipe()) | 这个路由方法的所有参数 || 控制器 | @Controller() @UsePipes(...) | 这个控制器所有方法 || 全局 | app.useGlobalPipes(...) | 整个应用 |全局管道有两种注册方式:// 方式一:main.ts 里直接用(无法注入依赖)app.useGlobalPipes(new ValidationPipe());// 方式二:模块里用 token 注册(可以注入依赖)import { APP_PIPE } from '@nestjs/core';@Module({ providers: [{ provide: APP_PIPE, useClass: ValidationPipe }],})export class AppModule {}如果你的管道需要注入其他服务(如数据库查询),必须用方式二,方式一拿不到依赖注入容器。请求生命周期中的位置NestJS 处理一个请求的顺序:中间件 → 守卫 → 拦截器(before) → 管道 → 控制器 → 拦截器(after) → 过滤器管道在守卫之后、控制器之前。这意味着守卫可以先判断权限,没权限直接拒绝,不会走到管道的验证逻辑。管道验证失败抛出的异常,会被异常过滤器捕获。
服务端阅读 06月4日 14:01

Electron打包分发:签名、公证、自动更新和体积优化

Electron 应用写完了不算完——打包、签名、分发、自动更新,每一步都有坑。Windows 上没签名的安装包会被 SmartScreen 拦截,macOS 上没公证的应用直接打不开,安装包体积动辄 150MB+ 用户嫌大。这篇文章把打包到分发的完整流程走一遍。打包工具选择| 工具 | 特点 | 适合谁 ||------|------|--------|| electron-builder | 功能最全,签名+更新+多格式一步到位 | 生产环境首选 || electron-forge | 官方推荐,集成开发+打包+发布流程 | 新项目开箱即用 || electron-packager | 只打包不安装包,功能简单 | 只需要可执行文件 |大部分项目选 electron-builder 就对了。electron-builder 配置npm install --save-dev electron-builder在 package.json 里配置:{ "build": { "appId": "com.yourcompany.yourapp", "productName": "YourApp", "directories": { "output": "dist" }, "files": [ "build/**/*", "node_modules/**/*", "package.json" ], "win": { "target": [{ "target": "nsis", "arch": ["x64"] }], "icon": "build/icon.ico" }, "mac": { "target": [{ "target": "dmg", "arch": ["x64", "arm64"] }], "icon": "build/icon.icns", "category": "public.app-category.productivity", "hardenedRuntime": true, "gatekeeperAssess": false, "entitlements": "build/entitlements.mac.plist", "entitlementsInherit": "build/entitlements.mac.plist" }, "linux": { "target": ["AppImage", "deb"], "icon": "build/icon.png", "category": "Utility" } }}打包命令:npx electron-builder --win # Windowsnpx electron-builder --mac # macOSnpx electron-builder --linux # Linuxnpx electron-builder -mwl # 全平台(需要在对应系统上跑)NSIS 安装程序(Windows)NSIS 是 Windows 上最常用的安装包格式:{ "build": { "nsis": { "oneClick": false, "allowToChangeInstallationDirectory": true, "createDesktopShortcut": true, "createStartMenuShortcut": true, "shortcutName": "YourApp", "uninstallDisplayName": "YourApp", "license": "LICENSE.txt" } }}oneClick: false 让用户选择安装目录,而不是一闪而过安装完。createDesktopShortcut 看似方便,但很多用户反感桌面图标——建议设为 alwaysCreate: false 让用户自己勾选。代码签名不签名的应用会被操作系统拦截:Windows 的 SmartScreen 弹蓝框,macOS 的 Gatekeeper 直接说"无法验证开发者"。Windows 签名需要购买代码签名证书(EV 或 Standard)。EV 证书签名后 SmartScreen 立即信任,Standard 证书需要积累信誉。# 环境变量方式(CI/CD 推荐)export CSC_LINK=path/to/certificate.pfxexport CSC_KEY_PASSWORD=your-passwordnpx electron-builder --winelectron-builder 检测到 CSC_LINK 环境变量后自动签名,不用额外配置。macOS 签名和公证macOS 要求应用同时签名和公证(Notarization),否则用户打开时会弹"无法验证开发者"。签名需要 Apple Developer 证书,公证需要 Apple ID:export CSC_LINK=path/to/developer-id.p12export CSC_KEY_PASSWORD=your-passwordexport APPLE_ID=your@email.comexport APPLE_APP_SPECIFIC_PASSWORD=xxxx-xxxx-xxxx-xxxxexport APPLE_TEAM_ID=XXXXXXXXXXnpx electron-builder --macelectron-builder 在 mac.hardenedRuntime: true 的情况下会自动签名并提交公证。公证过程需要 1-5 分钟,期间应用无法分发。entitlements.mac.plist 文件(声明权限):<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict> <key>com.apple.security.cs.allow-jit</key> <true/> <key>com.apple.security.cs.allow-unsigned-executable-memory</key> <true/> <key>com.apple.security.cs.allow-dyld-environment-variables</key> <true/></dict></plist>Electron 需要这三个权限:JIT(V8 引擎)、unsigned memory(渲染进程)、dyld variables(native 模块加载)。不声明的话签名后应用会崩溃。自动更新electron-updater 是 electron-builder 配套的自动更新方案,支持差分更新(只下载变化部分):npm install electron-updaterconst { autoUpdater } = require('electron-updater')const log = require('electron-log')autoUpdater.logger = logautoUpdater.autoDownload = false // 不自动下载,先提示用户app.whenReady().then(() => { autoUpdater.checkForUpdates() autoUpdater.on('update-available', (info) => { // 通知用户有新版本 dialog.showMessageBox({ type: 'info', title: '发现新版本', message: `新版本 ${info.version} 可用,是否现在下载?`, buttons: ['下载', '稍后'] }).then(({ response }) => { if (response === 0) autoUpdater.downloadUpdate() }) }) autoUpdater.on('update-downloaded', () => { dialog.showMessageBox({ type: 'info', title: '更新就绪', message: '新版本已下载,重启应用以完成安装。', buttons: ['立即重启', '稍后'] }).then(({ response }) => { if (response === 0) autoUpdater.quitAndInstall() }) })})更新源配置在 package.json:{ "build": { "publish": { "provider": "github", "owner": "your-username", "repo": "your-repo" } }}支持 GitHub Releases、S3、通用 HTTP 服务器。发布新版本时,electron-builder 自动把安装包上传到 GitHub Releases,autoUpdater 检查 latest.yml 判断是否有更新。体积优化Electron 应用默认 150MB+,因为包含了完整的 Chromium。可以压缩:排除不需要的文件{ "build": { "files": [ "build/**/*", "!build/samples/**/*", "node_modules/**/*", "!node_modules/*/test/**/*", "!node_modules/*/docs/**/*", "!node_modules/*.md" ] }}! 开头表示排除。test、docs、README 等文件打包后不需要。asar 归档asar 把源码打包成只读归档,减少文件数量和体积:{ "build": { "asar": true, "asarUnpack": [ "node_modules/native-module/**/*" // native 模块不能放进 asar ] }}native 模块(better-sqlite3、keytar 等)必须 unpack,因为它们需要加载 .node 动态库,asar 里的文件不能直接 dlopen。双架构 vs 通用二进制macOS 支持 Universal 二进制(同时包含 x64 和 arm64),但体积翻倍。如果不需要 Rosetta 兼容,单独打 arm64 体积小一半:{ "build": { "mac": { "target": [{ "target": "dmg", "arch": ["arm64"] }] } }}M1/M2/M3 用户占 macOS 大多数,只打 arm64 够用。需要兼容 Intel 的场景再打 Universal。CI/CD 自动化用 GitHub Actions 自动打包:name: Buildon: pushjobs: build: strategy: matrix: os: [windows-latest, macos-latest, ubuntu-latest] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: 20 } - run: npm ci - run: npx electron-builder --publish always env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} CSC_LINK: ${{ secrets.CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}关键点:每个平台必须在对应 OS 上打包。Windows 安装包不能在 macOS 上交叉编译(签名工具不兼容)。macOS 公证也必须在 macOS 上跑。
服务端阅读 06月4日 14:00

Electron多窗口管理:IPC通信、MessagePort和窗口状态恢复

Electron 应用超过一个窗口就会遇到两个问题:怎么管(创建、销毁、引用回收)、怎么通(主窗口改了设置,设置窗口怎么知道)。管理不好就内存泄漏,通信不好就数据不一致。这篇文章把多窗口管理和 IPC 通信的常用模式讲清楚。窗口管理创建不同类型的窗口const { BrowserWindow } = require('electron')// 主窗口function createMainWindow() { const win = new BrowserWindow({ width: 1200, height: 800, webPreferences: { nodeIntegration: false, contextIsolation: true, preload: path.join(__dirname, 'preload.js') } }) win.loadFile('index.html') return win}// 设置窗口(模态,附属于主窗口)function createSettingsWindow(parent) { const win = new BrowserWindow({ width: 600, height: 400, parent: parent, modal: true, // 模态:打开时主窗口不可操作 show: false, // 先不显示,等 ready-to-show webPreferences: { nodeIntegration: false, contextIsolation: true, preload: path.join(__dirname, 'preload.js') } }) win.loadFile('settings.html') win.once('ready-to-show', () => win.show()) // 避免白屏闪烁 return win}// 工具窗口(无边框、置顶、透明)function createToolWindow() { const win = new BrowserWindow({ width: 300, height: 200, frame: false, // 无标题栏 alwaysOnTop: true, // 始终置顶 transparent: true, // 透明背景 resizable: false }) win.loadFile('tool.html') return win}窗口引用管理用一个 Map 统一管理所有窗口,防止引用泄漏:const windows = new Map()function createWindow(id, options) { // 如果已存在,聚焦而不是再创建 if (windows.has(id) && !windows.get(id).isDestroyed()) { windows.get(id).focus() return windows.get(id) } const win = new BrowserWindow(options) windows.set(id, win) win.on('closed', () => { windows.delete(id) // 窗口关闭时移除引用,允许 GC }) return win}// 使用createWindow('main', { width: 1200, height: 800 })createWindow('settings', { width: 600, height: 400, parent: windows.get('main'), modal: true })最常见的内存泄漏:窗口关闭了但引用还在——win.on('closed') 里必须把引用清除,否则 BrowserWindow 对象不会被回收。单例窗口某些窗口只能有一个(如设置窗口),重复点击应该聚焦已有窗口而不是新开:let settingsWindow = nullfunction openSettings() { if (settingsWindow && !settingsWindow.isDestroyed()) { settingsWindow.focus() return } settingsWindow = new BrowserWindow({ /* ... */ }) settingsWindow.on('closed', () => { settingsWindow = null })}isDestroyed() 检查很关键——窗口可能已经被 destroy() 了但变量还没清空。IPC 通信Electron 的 IPC 分两种方向:渲染→主(invoke/send)、主→渲染(send/webContents)。现代 IPC 模式(contextIsolation + preload)Electron 12+ 默认开启 contextIsolation,渲染进程不能直接用 require('electron')。正确做法是通过 preload 暴露安全 API:// preload.jsconst { contextBridge, ipcRenderer } = require('electron')contextBridge.exposeInMainWorld('electronAPI', { // invoke: 等待主进程返回结果(Promise) getSettings: () => ipcRenderer.invoke('get-settings'), saveSettings: (settings) => ipcRenderer.invoke('save-settings', settings), // send/on: 单向通知,不等返回 onSettingsChanged: (callback) => { ipcRenderer.on('settings-changed', (event, settings) => callback(settings)) }})// main.jsconst { ipcMain } = require('electron')// invoke 对应 handleipcMain.handle('get-settings', () => { return store.get('settings', { theme: 'light', fontSize: 14 })})ipcMain.handle('save-settings', (event, settings) => { store.set('settings', settings) // 通知所有窗口设置变了 BrowserWindow.getAllWindows().forEach(win => { win.webContents.send('settings-changed', settings) }) return true})invoke/handle 返回 Promise,适合需要返回值的场景。send/on 单向,适合通知类消息。窗口间通信两个渲染窗口之间不能直接 IPC,必须经过主进程中转:渲染进程A → ipcRenderer.invoke() → 主进程 ipcMain.handle() → win.webContents.send() → 渲染进程B主进程充当消息总线:// 主进程:转发消息ipcMain.on('relay-message', (event, targetWindowId, channel, data) => { const targetWin = windows.get(targetWindowId) if (targetWin && !targetWin.isDestroyed()) { targetWin.webContents.send(channel, data) }})MessagePort:双向通信通道Electron 14+ 支持 MessagePort,可以建立渲染进程间的双向通信通道,不需要每次都过主进程:// 主进程:为两个窗口创建通道ipcMain.handle('create-channel', (event, targetId) => { const targetWin = windows.get(targetId) if (!targetWin) return null const { port1, port2 } = new MessageChannelMain() // 给发起方 port1 event.sender.postMessage('channel-created', { port: port1 }, [port1]) // 给目标方 port2 targetWin.webContents.postMessage('channel-created', { port: port2 }, [port2])})渲染进程收到 port 后就可以直接通信:// 渲染进程window.electronAPI.onChannelCreated((port) => { port.onmessage = (event) => { console.log('收到消息:', event.data) } port.postMessage('hello from the other side')})MessagePort 适合实时数据流(如编辑器里主窗口和预览窗口的同步),比每次 invoke 少一次主进程中转。窗口状态持久化记住窗口位置和大小,下次打开时恢复:function createMainWindow() { const bounds = store.get('windowBounds', { width: 1200, height: 800, x: undefined, y: undefined }) const win = new BrowserWindow({ ...bounds, webPreferences: { /* ... */ } }) // 窗口移动或缩放时保存 win.on('resize', () => saveBounds(win)) win.on('move', () => saveBounds(win)) return win}function saveBounds(win) { clearTimeout(saveBounds.timer) saveBounds.timer = setTimeout(() => { store.set('windowBounds', win.getBounds()) }, 500) // 防抖,避免频繁写入}要注意:如果用户外接显示器拔掉了,保存的坐标可能在屏幕外,窗口"消失"了。恢复时检查坐标是否在可见区域内:const { screen } = require('electron')function ensureVisible(win) { const bounds = win.getBounds() const displays = screen.getAllDisplays() const visible = displays.some(d => { const { x, y, width, height } = d.workArea return bounds.x >= x && bounds.x < x + width && bounds.y >= y && bounds.y < y + height }) if (!visible) win.center()}
服务端阅读 06月4日 13:58

Electron菜单和托盘:跨平台差异、右键菜单和托盘图标坑

菜单和托盘是桌面应用的"门面"——用户通过菜单找到功能,通过托盘保持应用在后台运行。Electron 提供了 Menu 和 Tray API,但跨平台差异和坑不少:macOS 的菜单栏和 Windows 完全不同,托盘图标格式要求也不一样。这篇文章把菜单和托盘的常见实现都过一遍。应用菜单创建基础菜单macOS 应用的菜单栏是系统级的,不创建菜单连快捷键都不好使。Windows/Linux 的菜单可以藏在窗口里。const { app, Menu, BrowserWindow } = require('electron')app.whenReady().then(() => { const mainWindow = new BrowserWindow({ /* ... */ }) const template = [ { label: '文件', submenu: [ { label: '新建', accelerator: 'CmdOrCtrl+N', click: () => createNewFile() }, { label: '打开', accelerator: 'CmdOrCtrl+O', click: () => openFile() }, { type: 'separator' }, { label: '保存', accelerator: 'CmdOrCtrl+S', click: () => saveFile() }, { type: 'separator' }, { label: '退出', accelerator: 'CmdOrCtrl+Q', click: () => app.quit() } ] }, { label: '编辑', submenu: [ { role: 'undo', label: '撤销' }, { role: 'redo', label: '重做' }, { type: 'separator' }, { role: 'cut', label: '剪切' }, { role: 'copy', label: '复制' }, { role: 'paste', label: '粘贴' }, { role: 'selectAll', label: '全选' } ] }, { label: '视图', submenu: [ { role: 'reload', label: '刷新' }, { role: 'toggleDevTools', label: '开发者工具' }, { type: 'separator' }, { role: 'resetZoom', label: '重置缩放' }, { role: 'zoomIn', label: '放大' }, { role: 'zoomOut', label: '缩小' }, { type: 'separator' }, { role: 'togglefullscreen', label: '全屏' } ] } ] const menu = Menu.buildFromTemplate(template) Menu.setApplicationMenu(menu)})macOS 的特殊处理macOS 第一项菜单名必须是应用名,系统会自动加"关于""隐藏""退出"等菜单项:const isMac = process.platform === 'darwin'const template = [ ...(isMac ? [{ label: app.name, submenu: [ { role: 'about', label: `关于 ${app.name}` }, { type: 'separator' }, { role: 'services' }, { type: 'separator' }, { role: 'hide', label: '隐藏' }, { role: 'hideOthers' }, { role: 'unhide' }, { type: 'separator' }, { role: 'quit', label: '退出' } ] }] : []), // ... 其他菜单]如果不加这个,macOS 上应用菜单的行为会很奇怪——没有"关于"和"偏好设置"入口,也不支持 Cmd+H 隐藏窗口。动态菜单菜单项可以运行时修改——打勾、禁用、改文字:const menu = Menu.buildFromTemplate([ { label: '视图', submenu: [ { label: '深色模式', type: 'checkbox', checked: store.get('darkMode', false), click: (menuItem) => { store.set('darkMode', menuItem.checked) applyTheme(menuItem.checked) } }, { label: '导出', enabled: false, // 初始禁用,打开文件后启用 id: 'exportMenu' } ] }])// 打开文件后启用导出const exportItem = menu.getMenuItemById('exportMenu')exportItem.enabled = true隐藏默认菜单如果你不需要菜单栏(如工具类应用),可以设为 null:Menu.setApplicationMenu(null)注意:设为 null 后,复制粘贴等默认快捷键也会失效。如果你只是想隐藏菜单栏但保留快捷键,用 autoHideMenuBar: true 创建窗口(Windows/Linux 上按 Alt 显示菜单)。右键菜单(Context Menu)右键菜单是最常用的交互——在列表上右键编辑删除,在输入框里右键复制粘贴:const { Menu, ipcMain } = require('electron')// 渲染进程通过 IPC 请求弹出右键菜单ipcMain.on('show-context-menu', (event, type) => { const template = getContextMenuTemplate(type) const menu = Menu.buildFromTemplate(template) // 在当前窗口弹出 const win = BrowserWindow.fromWebContents(event.sender) menu.popup({ window: win })})function getContextMenuTemplate(type) { if (type === 'file') { return [ { label: '打开', click: () => openFile() }, { label: '重命名', click: () => renameFile() }, { type: 'separator' }, { label: '删除', click: () => deleteFile() } ] } if (type === 'text') { return [ { role: 'copy' }, { role: 'cut' }, { role: 'paste' } ] } return [{ role: 'copy' }, { role: 'paste' }]}渲染进程触发:// preload.jscontextBridge.exposeInMainWorld('electronAPI', { showContextMenu: (type) => ipcRenderer.send('show-context-menu', type)})// renderer.jswindow.addEventListener('contextmenu', (e) => { e.preventDefault() const type = e.target.closest('.file-item') ? 'file' : 'text' window.electronAPI.showContextMenu(type)})也可以用 electron-context-menu 这个库,自动给输入框加复制粘贴菜单、给图片加保存菜单。系统托盘(Tray)托盘让应用最小化到系统托盘区,不占任务栏位置——后台工具、音乐播放器、下载器几乎都要托盘。基础实现const { Tray, Menu, nativeImage } = require('electron')let tray = nullapp.whenReady().then(() => { const iconPath = path.join(__dirname, 'assets', 'tray-icon.png') const icon = nativeImage.createFromPath(iconPath) tray = new Tray(icon.resize({ width: 16, height: 16 })) tray.setToolTip('我的应用') const contextMenu = Menu.buildFromTemplate([ { label: '显示窗口', click: () => mainWindow.show() }, { label: '暂停', type: 'checkbox', checked: false, click: (item) => togglePause(item.checked) }, { type: 'separator' }, { label: '退出', click: () => app.quit() } ]) tray.setContextMenu(contextMenu)})点击托盘图标显示窗口Windows/Linux 上点击托盘图标通常应该显示/聚焦窗口,macOS 上则弹出菜单(系统规范):tray.on('click', () => { if (process.platform === 'darwin') return // macOS 用菜单 if (mainWindow.isVisible()) { mainWindow.hide() } else { mainWindow.show() mainWindow.focus() }})托盘图标格式不同平台对图标的要求不同:| 平台 | 推荐格式 | 尺寸 | 注意 ||------|----------|------|------|| Windows | .ico 或 .png | 16x16 | 支持 ICO 多尺寸 || macOS | .png 或 Template | 16x16 | 深色模式用 Template 图标 || Linux | .png | 16x16 | 部分桌面环境要求 22x22 |macOS 深色模式适配:图标文件名以 Template 结尾(如 tray-iconTemplate.png),系统会自动根据明暗主题反色。使用方式:const iconPath = path.join(__dirname, 'assets', process.platform === 'darwin' ? 'tray-iconTemplate.png' : 'tray-icon.png')最小化到托盘而非关闭点击关闭按钮时隐藏到托盘,而不是退出应用:mainWindow.on('close', (event) => { if (!app.isQuitting) { event.preventDefault() mainWindow.hide() }})app.on('before-quit', () => { app.isQuitting = true})app.isQuitting 是自定义标志——只有通过托盘的"退出"或 Cmd+Q 触发的退出才会真正关闭窗口。直接点关闭按钮只是隐藏。动态托盘图标下载进度、新消息通知等场景需要动态更新托盘图标:// 用 Canvas 生成带数字的图标const { nativeImage } = require('electron')function createBadgeIcon(count) { const { createCanvas } = require('canvas') // 需要 npm install canvas const size = 16 const canvas = createCanvas(size * 2, size * 2) // 2x for retina const ctx = canvas.getContext('2d') ctx.drawImage(baseIcon, 0, 0, size * 2, size * 2) if (count > 0) { ctx.fillStyle = '#FF3B30' ctx.beginPath() ctx.arc(size * 1.5, size * 0.5, 8, 0, Math.PI * 2) ctx.fill() ctx.fillStyle = 'white' ctx.font = 'bold 10px sans-serif' ctx.textAlign = 'center' ctx.fillText(count > 9 ? '9+' : String(count), size * 1.5, size * 0.5 + 4) } return nativeImage.createFromBuffer(canvas.toBuffer())}tray.setImage(createBadgeIcon(unreadCount))macOS 上更简单——系统原生支持 Dock 徽标:app.dock.setBadge(unreadCount > 0 ? String(unreadCount) : '')
服务端阅读 06月4日 13:57

Electron数据持久化:electron-store、IndexedDB和SQLite怎么选?

Electron 应用要存数据,选择比 Web 前端多——除了浏览器自带的 localStorage 和 IndexedDB,还能直接写文件系统、用 SQLite、或者用专门为 Electron 设计的 electron-store。选错了方案,后期迁移成本很高。这篇文章按场景分类,帮你选最合适的存储方案。方案选择速查| 方案 | 数据量 | 查询能力 | 适用场景 ||------|--------|----------|----------|| electron-store | < 1MB | 无(JSON 读写) | 用户设置、应用配置 || localStorage | < 5MB | 无(KV) | 简单状态、主题偏好 || IndexedDB | < 100MB | 索引查询 | 离线数据、缓存 || SQLite | 无上限 | 完整 SQL | 结构化数据、历史记录、搜索 || 文件系统 | 无上限 | 无 | 日志、导出文件、大文件 |electron-store:最简单的配置存储electron-store 是 Electron 生态里用得最多的轻量存储——本质就是把 JSON 文件读写封装了一层,加了 schema 校验、默认值、加密支持。npm install electron-storeconst Store = require('electron-store')const store = new Store({ defaults: { windowBounds: { width: 1200, height: 800 }, theme: 'system', recentFiles: [], }})// 读写store.set('theme', 'dark')store.get('theme') // 'dark'store.get('windowBounds.width') // 1200(支持点号路径)// 删除store.delete('theme')store.has('theme') // false// 监听变化store.onDidChange('theme', (newValue, oldValue) => { console.log(`主题从 ${oldValue} 变为 ${newValue}`)})数据存在 app.getPath('userData')/config.json,Windows 上是 %AppData%/你的应用名/config.json,macOS 上是 ~/Library/Application Support/你的应用名/config.json。适合存:用户偏好、窗口位置、最近打开的文件列表。不适合存:聊天记录、操作日志、任何需要条件查询的数据——JSON 文件每次读写都是全量操作,数据多了就慢。localStorage 和 sessionStorageElectron 的渲染进程里可以用浏览器的 localStorage,但有坑:// 渲染进程localStorage.setItem('key', 'value')localStorage.getItem('key')坑一:容量限制 5-10MB,存不了多少东西。坑二:数据绑定在 origin 上。如果你的应用用了自定义协议(app.setAsDefaultProtocol),localStorage 的 origin 可能变化,之前存的数据就找不到了。坑三:同步 API,数据量大时阻塞渲染线程。坑四:只能在渲染进程用,主进程访问不了。建议:只在渲染进程存一些临时状态(如表单草稿),重要数据不要依赖 localStorage。IndexedDB:浏览器里的结构化存储IndexedDB 是浏览器原生的 NoSQL 数据库,支持索引和事务,容量比 localStorage 大得多。// 打开数据库const request = indexedDB.open('MyAppDB', 1)request.onupgradeneeded = (event) => { const db = event.target.result const store = db.createObjectStore('notes', { keyPath: 'id', autoIncrement: true }) store.createIndex('updatedAt', 'updatedAt', { unique: false })}request.onsuccess = (event) => { const db = event.target.result // 写入 const tx = db.transaction('notes', 'readwrite') tx.objectStore('notes').add({ title: 'Hello', content: 'World', updatedAt: Date.now() }) // 按索引查询 const idxTx = db.transaction('notes', 'readonly') const index = idxTx.objectStore('notes').index('updatedAt') const range = IDBKeyRange.lowerBound(Date.now() - 86400000) // 最近一天 index.openCursor(range).onsuccess = (e) => { const cursor = e.target.result if (cursor) { console.log(cursor.value) cursor.continue() } }}IndexedDB 的 API 是回调式的,非常难用。推荐用 idb 这个库封装成 Promise:npm install idbconst { openDB } = require('idb')const db = await openDB('MyAppDB', 1, { upgrade(db) { db.createObjectStore('notes', { keyPath: 'id', autoIncrement: true }) }})await db.add('notes', { title: 'Hello', content: 'World' })const allNotes = await db.getAll('notes')IndexedDB 适合缓存数据(如离线文章列表),但复杂查询能力有限——没有 JOIN,没有聚合函数。数据量超过几百 MB 性能也会下降。SQLite:需要 SQL 时的选择Electron 主进程是 Node.js,可以直接用 SQLite。这是存储大量结构化数据的最优方案——完整 SQL 支持、事务、索引、百 GB 级数据都没问题。npm install better-sqlite3为什么用 better-sqlite3 而不是 sqlite3?因为 better-sqlite3 是同步 API,不用处理异步回调,性能也更好(C 绑定更高效)。在 Electron 主进程里同步不是问题——主进程本就不应该被数据库操作阻塞。const Database = require('better-sqlite3')const path = require('path')const dbPath = path.join(app.getPath('userData'), 'app.db')const db = new Database(dbPath)// 建表db.exec(` CREATE TABLE IF NOT EXISTS notes ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, content TEXT, updated_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_notes_updated ON notes(updated_at);`)// 插入(prepared statement,防 SQL 注入)const insert = db.prepare('INSERT INTO notes (title, content, updated_at) VALUES (?, ?, ?)')insert.run('My Note', 'Hello world', Date.now())// 批量插入(事务)const batchInsert = db.transaction((items) => { for (const item of items) { insert.run(item.title, item.content, Date.now()) }})batchInsert([ { title: 'Note 1', content: 'A' }, { title: 'Note 2', content: 'B' },])// 查询const notes = db.prepare('SELECT * FROM notes WHERE updated_at > ? ORDER BY updated_at DESC LIMIT 20').all(Date.now() - 86400000)// 搜索const results = db.prepare("SELECT * FROM notes WHERE title LIKE ?").all('%keyword%')better-sqlite3 的 native 模块需要针对 Electron 重新编译:npm install --save-dev electron-rebuildnpx electron-rebuild或者在 package.json 里配置 postinstall:"postinstall": "electron-rebuild"文件系统:大文件和日志直接操作文件适合日志、导出数据、用户文档等场景:const fs = require('fs')const path = require('path')// 日志写入(append)const logPath = path.join(app.getPath('userData'), 'app.log')fs.appendFileSync(logPath, `[${new Date().toISOString()}] Event occurred\n`)// 用户文档目录const docsPath = app.getPath('documents')fs.writeFileSync(path.join(docsPath, 'export.json'), JSON.stringify(data))大量日志建议用 electron-log,它自动处理文件轮转和大小限制。数据迁移策略应用版本升级时,数据结构可能变化。每种方案的迁移方式不同:electron-store:在 defaults 里加新字段,旧数据自动补默认值。删字段要手动处理:const store = new Store({ defaults: { /* 新 schema */ } })// 迁移:删除废弃字段if (store.has('oldKey')) { store.delete('oldKey')}SQLite:用版本号控制迁移:const currentVersion = db.pragma('user_version', { simple: true })if (currentVersion < 1) { db.exec('ALTER TABLE notes ADD COLUMN tags TEXT') db.pragma('user_version = 1')}IndexedDB:onupgradeneeded 在版本号变化时触发,在这里加新 store 和索引。安全注意事项加密敏感数据:electron-store 支持 encryptionKey 选项,密码等敏感数据不要明文存 JSON不要存到代码目录:用 app.getPath('userData') 获取系统标准路径,不要写进 resources/渲染进程不要直接访问文件系统:通过 IPC 让主进程操作,避免开启 nodeIntegration
服务端阅读 06月4日 13:55

Electron调试指南:主进程、渲染进程和生产环境排查

Electron 有两个进程,调试方法完全不同:渲染进程用 Chrome DevTools,和调试网页一样;主进程是 Node.js 环境,需要用 VS Code 或远程调试协议。很多人只会开 DevTools,主进程出了问题只能 console.log 猜——这篇文章把两个进程的调试方法都讲清楚,以及生产环境下的排查手段。渲染进程调试:DevTools渲染进程就是 Chromium 的网页进程,调试体验和 Chrome 一样。自动打开 DevTools开发模式下自动打开,生产模式关闭:mainWindow = new BrowserWindow({ /* ... */ })mainWindow.loadFile('index.html')if (process.env.NODE_ENV === 'development') { mainWindow.webContents.openDevTools()}打包后的应用可以通过快捷键 Ctrl+Shift+I(Windows/Linux)或 Cmd+Option+I(macOS)手动打开。如果你不想让用户打开 DevTools,在创建窗口时设置 devTools: false。自定义快捷键切换const { globalShortcut } = require('electron')app.whenReady().then(() => { globalShortcut.register('CommandOrControl+Shift+D', () => { const win = BrowserWindow.getFocusedWindow() if (!win) return win.webContents.isDevToolsOpened() ? win.webContents.closeDevTools() : win.webContents.openDevTools() })})app.on('will-quit', () => globalShortcut.unregisterAll())安装框架 DevTools 扩展React DevTools 和 Vue DevTools 可以直接集成到 Electron 里:npm install --save-dev electron-devtools-installerconst { default: installExtension, REACT_DEVELOPER_TOOLS, VUEJS_DEVTOOLS } = require('electron-devtools-installer')app.whenReady().then(() => { installExtension(REACT_DEVELOPER_TOOLS) .then(name => console.log(`已安装: ${name}`)) .catch(err => console.error('安装失败:', err))})主进程调试:VS Code主进程跑在 Node.js 里,DevTools 管不到。用 VS Code 断点调试是最方便的方式。launch.json 配置{ "version": "0.2.0", "configurations": [ { "name": "Debug Main Process", "type": "node", "request": "launch", "cwd": "${workspaceFolder}", "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron", "windows": { "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron.cmd" }, "args": ["."], "outputCapture": "std" } ]}F5 启动调试,在 main.js 里打断点,主进程代码可以逐行跟踪。注意:这只能调试主进程,渲染进程的断点要在 DevTools 里设。同时调试两个进程先启动主进程调试,然后在 DevTools 里调试渲染进程。两个调试器互不干扰。但要注意:主进程调试器会拦截 console.log,输出在 VS Code 的 Debug Console 而不是终端。主进程调试:Chrome 远程调试不想用 VS Code 的时候,可以用 Chrome 的 DevTools 连接主进程:app.commandLine.appendSwitch('remote-debugging-port', '9222')启动应用后,在 Chrome 里打开 chrome://inspect,点击 "Configure…" 添加 localhost:9222,就能看到 Electron 主进程的 Node.js 上下文,可以直接断点调试。也可以命令行启动:npx electron --remote-debugging-port=9222 .远程调试的好处是不依赖 VS Code,任何能开 Chrome 的机器都能调试——适合远程排查 CI 环境或同事电脑上的问题。IPC 调试主进程和渲染进程通过 IPC 通信,消息传递出了问题最难排查——两边都能跑,但就是数据传不过去。监听所有 IPC 消息// 主进程:监听所有来自渲染进程的消息ipcMain.on('*', (event, ...args) => { console.log('[IPC Main ← Renderer]', event.channel, args)})// 渲染进程(通过 preload):监听所有来自主进程的消息contextBridge.exposeInMainWorld('electronAPI', { onMainMessage: (callback) => { ipcRenderer.on('*', (event, ...args) => { callback(event.channel, args) }) }})实际上 Electron 的 ipcMain 不支持通配符监听,变通方案是用 webContents.on('ipc-message') 在主进程拦截所有消息:mainWindow.webContents.on('ipc-message', (event, channel, ...args) => { console.log(`[IPC] ${channel}`, args)})这样所有 ipcRenderer.send 的消息都能在主进程看到,不用在每个 handler 里加 log。常见调试场景白屏/加载失败渲染进程白屏,先看 DevTools Console 有没有报错。如果连 DevTools 都打不开,可能是 HTML 文件路径错误或协议问题:// 错误:相对路径在打包后可能找不到文件mainWindow.loadFile('index.html')// 正确:用 file:// 协议的绝对路径mainWindow.loadFile(path.join(__dirname, 'index.html'))内存泄漏Electron 的内存泄漏通常来自两处:渲染进程的 JS 对象没释放(用 DevTools Memory 面板拍快照对比),或者主进程的 BrowserWindow 没 destroy()(检查窗口引用是否置 null)。CPU 占用高用 DevTools Performance 面板录制,看火焰图里的长任务。常见原因:渲染进程里的定时器没清理、大列表没虚拟化、主进程的同步文件操作阻塞了事件循环。生产环境排查生产环境没有 DevTools,需要靠日志:日志收集const { dialog } = require('electron')const fs = require('fs')const log = require('electron-log')log.transports.file.resolvePath = () => path.join(app.getPath('userData'), 'logs/main.log')log.transports.file.maxSize = 5 * 1024 * 1024 // 5MB 轮转// 捕获未处理的异常process.on('uncaughtException', (error) => { log.error('Uncaught Exception:', error) dialog.showErrorBox('意外错误', error.message)})electron-log 同时写文件和控制台,打包后日志在 userData/logs/ 目录下。用户反馈问题时让他们发这个文件。远程错误上报Sentry 提供 Electron SDK,自动捕获主进程和渲染进程的崩溃:const Sentry = require('@sentry/electron')Sentry.init({ dsn: 'your-dsn', attachStacktrace: true,})MiniDump 崩溃也能捕获——这是 DevTools 做不到的。