服务端面试题手册

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

服务端阅读 06月19日 15:57

TypeORM 支持哪些数据库?如何选择和配置?

TypeORM 能连的数据库不少,但“支持”不等于“随便换个 type 就能无痛迁移”。MySQL、PostgreSQL、SQLite 这类关系型数据库是它最常见的使用场景;MongoDB 虽然也在支持列表里,但功能边界和 SQL 数据库明显不同。选型时要同时看驱动成熟度、团队经验、迁移能力、事务需求和生产环境的运维成本。TypeORM 官方支持的数据库有哪些常见项目里会遇到的数据库大致可以分成三类。| 数据库 | TypeORM type 值 | 常用驱动 | 适合场景 ||---|---|---|---|| MySQL | mysql | mysql2 | Web 应用、传统业务系统、读写规模中等的服务 || MariaDB | mariadb | mysql2 | MySQL 兼容生态、开源替代 MySQL 的场景 || PostgreSQL | postgres | pg | 复杂查询、JSONB、地理信息、事务要求高的系统 || SQLite | sqlite | sqlite3 | 本地开发、桌面应用、小型工具、测试环境 || better-sqlite3 | better-sqlite3 | better-sqlite3 | 需要同步 API、性能更好的本地 SQLite 场景 || sql.js | sqljs | sql.js | 浏览器、纯 JS 运行环境、无需本地数据库进程的场景 || Microsoft SQL Server | mssql | mssql | Windows / .NET 技术栈、企业内部系统 || Oracle | oracle | oracledb | 大型企业、金融、电信等已有 Oracle 基础设施的系统 || CockroachDB | cockroachdb | pg | 分布式 SQL、高可用、多地域部署 || SAP HANA | sap | @sap/hana-client 或 hdb-pool | SAP 生态、企业数据平台 || Google Spanner | spanner | @google-cloud/spanner | Google Cloud 上的全球分布式关系型数据库 || MongoDB | mongodb | mongodb | 文档模型、非关系型数据,但 TypeORM 支持有限 |原文里提到的 MySQL / MariaDB、PostgreSQL、SQLite、Microsoft SQL Server、Oracle、MongoDB、CockroachDB 都是 TypeORM 里比较重要的数据库类型。补充的 better-sqlite3、sqljs、sap、spanner 也经常出现在官方支持列表或实际项目配置里。使用前先安装对应数据库驱动TypeORM 本身不内置所有数据库驱动。你配置了 type: 'postgres',还需要安装 pg;配置了 type: 'mysql',通常还要安装 mysql2。这一步漏掉时,项目启动会直接报找不到依赖。常见安装命令如下:# MySQL / MariaDBnpm install typeorm mysql2# PostgreSQL / CockroachDBnpm install typeorm pg# SQLitenpm install typeorm sqlite3# better-sqlite3npm install typeorm better-sqlite3# sql.jsnpm install typeorm sql.js# Microsoft SQL Servernpm install typeorm mssql# Oraclenpm install typeorm oracledb# SAP HANAnpm install typeorm @sap/hana-client# Google Spannernpm install typeorm @google-cloud/spanner# MongoDBnpm install typeorm mongodb如果项目使用 pnpm 或 yarn,把 npm install 换成对应命令即可。生产环境还要注意驱动的原生依赖,例如 Oracle 的 oracledb 往往需要额外的客户端库,部署容器时不能只复制 Node.js 代码。常见数据库配置示例MySQL / MariaDBMySQL 和 MariaDB 的配置很接近,差别主要在 type。如果用 MariaDB,建议明确写成 mariadb,不要因为兼容 MySQL 协议就偷懒写 mysql。import { DataSource } from 'typeorm';export const AppDataSource = new DataSource({ type: 'mysql', host: 'localhost', port: 3306, username: 'root', password: 'password', database: 'test', entities: ['src/entities/**/*.ts'], migrations: ['src/migrations/**/*.ts'], synchronize: false,});MariaDB 示例:new DataSource({ type: 'mariadb', host: 'localhost', port: 3306, username: 'root', password: 'password', database: 'test', synchronize: false,});PostgreSQLPostgreSQL 是 TypeORM 项目里很常见的选择,尤其适合需要 JSONB、复杂索引、事务和更强 SQL 能力的后端服务。new DataSource({ type: 'postgres', host: 'localhost', port: 5432, username: 'postgres', password: 'password', database: 'test', schema: 'public', entities: ['src/entities/**/*.ts'], migrations: ['src/migrations/**/*.ts'], synchronize: false,});PostgreSQL 的一个优势是类型能力更强,比如 jsonb、数组、枚举、部分索引等。但这些能力也会带来迁移成本:如果以后切到 MySQL,并不是改个连接配置就结束。SQLite / better-sqlite3 / sql.jsSQLite 适合本地、小工具、桌面端或测试环境。它不需要单独启动数据库服务,配置也最简单。new DataSource({ type: 'sqlite', database: './database.sqlite', entities: ['src/entities/**/*.ts'], synchronize: false,});如果希望使用 better-sqlite3:new DataSource({ type: 'better-sqlite3', database: './database.sqlite', entities: ['src/entities/**/*.ts'], synchronize: false,});浏览器或纯 JS 环境可以考虑 sqljs:new DataSource({ type: 'sqljs', entities: ['src/entities/**/*.ts'], synchronize: false,});SQLite 系列不要被“轻量”两个字误导。它很适合嵌入式和本地单机,但并不适合高并发写入的服务端核心库。多人同时写、长事务、复杂权限隔离这些场景,还是优先考虑 PostgreSQL 或 MySQL。Microsoft SQL ServerSQL Server 常出现在 Windows Server、Azure、企业内部系统里。new DataSource({ type: 'mssql', host: 'localhost', port: 1433, username: 'sa', password: 'yourStrong(!)Password', database: 'test', options: { encrypt: true, trustServerCertificate: true, }, entities: ['src/entities/**/*.ts'], synchronize: false,});这里的 options 经常影响连接结果。开发环境可能需要 trustServerCertificate: true,生产环境则要认真配置证书,不建议长期用“信任所有证书”的方式糊过去。OracleOracle 在大型企业里很常见,但 Node.js 部署比 MySQL、PostgreSQL 麻烦一些,尤其是客户端依赖和连接池配置。new DataSource({ type: 'oracle', host: 'localhost', port: 1521, username: 'system', password: 'password', sid: 'xe', entities: ['src/entities/**/*.ts'], synchronize: false,});Oracle 字段类型、序列、大小写标识符、分页 SQL 和日期处理都和 MySQL / PostgreSQL 有差异。已有 Oracle 基础设施时可以用;新项目如果没有强约束,不建议为了“企业级”三个字盲选。CockroachDBCockroachDB 走 PostgreSQL 协议,TypeORM 配置上和 PostgreSQL 很像,但 type 要写成 cockroachdb。new DataSource({ type: 'cockroachdb', host: 'localhost', port: 26257, username: 'root', password: '', database: 'defaultdb', ssl: false, entities: ['src/entities/**/*.ts'], synchronize: false,});它适合分布式 SQL 和高可用需求,但要接受一个现实:分布式事务、唯一索引、时间戳、重试策略都会比单机 PostgreSQL 更复杂。业务代码里要考虑事务重试,不能只按传统单库思路写。SAP HANASAP HANA 通常不是普通互联网项目的首选,但在 SAP 生态和企业数据平台里会遇到。new DataSource({ type: 'sap', host: 'localhost', port: 30015, username: 'SYSTEM', password: 'password', database: 'test', entities: ['src/entities/**/*.ts'], synchronize: false,});这类数据库的关键不在 TypeORM 配置,而在企业环境里的账号权限、网络策略、驱动安装和 DBA 协作。上线前最好让运维或 DBA 一起确认连接池、超时、字符集和权限边界。Google SpannerSpanner 面向 Google Cloud 的全球分布式数据库场景,和传统单机关系型数据库的使用习惯差异比较大。new DataSource({ type: 'spanner', projectId: 'my-gcp-project', instanceId: 'my-instance', databaseId: 'my-database', entities: ['src/entities/**/*.ts'], synchronize: false,});如果项目没有明确的全球分布式、一致性和云厂商绑定需求,Spanner 通常不是默认选择。它的价值在架构层面,不是 ORM 层面。MongoDBTypeORM 也支持 MongoDB,但要单独看待。MongoDB 不是 SQL 数据库,很多 TypeORM 在关系型数据库里的能力不能直接套用。new DataSource({ type: 'mongodb', host: 'localhost', port: 27017, database: 'test', entities: ['src/entities/**/*.ts'], synchronize: false,});MongoDB 实体通常使用 ObjectIdColumn:import { Entity, ObjectIdColumn, ObjectId, Column } from 'typeorm';@Entity()export class User { @ObjectIdColumn() id: ObjectId; @Column() name: string;}需要特别注意:TypeORM 对 MongoDB 的支持并不等同于 Mongoose 那种围绕文档数据库设计的完整体验。关系映射、迁移、QueryBuilder、事务习惯、复杂聚合这些地方都会受限。如果业务核心就是 MongoDB 文档模型,很多团队会直接选择 MongoDB 原生驱动或 Mongoose,而不是用 TypeORM 硬包一层。SQL 数据库和 MongoDB 的差异别忽略TypeORM 的主场仍然是关系型数据库。SQL 数据库里,Entity 通常对应表,Column 对应列,Relation 对应外键或中间表;迁移系统也主要围绕表结构变更工作。MongoDB 的模型完全不同。它存的是文档,天然支持嵌套对象,很多时候不需要外键和 Join。你当然可以在 TypeORM 里写 MongoDB Entity,但不要期待 @ManyToOne、leftJoinAndSelect、SQL 风格迁移这些能力都能照常使用。简单判断:需要事务、关系、报表查询、强一致结构:优先 SQL 数据库。数据结构经常变化、文档嵌套明显、关系较弱:可以考虑 MongoDB。已经决定用 MongoDB,并且会大量用聚合管道和文档特性:优先评估原生驱动或 Mongoose。选择数据库时看这几个问题团队最熟哪个数据库ORM 能降低一部分使用门槛,但不能替代数据库经验。慢查询、锁等待、索引失效、连接池打满、迁移失败,这些问题最后还是要靠数据库知识解决。团队熟 PostgreSQL,就不要为了“切换简单”硬上 Oracle;团队 MySQL 经验深,也不必因为 PostgreSQL 功能强就马上换。业务关系是否复杂电商订单、权限系统、财务流水、内容平台这类关系清晰的业务,MySQL / PostgreSQL 更稳。日志、配置快照、用户画像、表单结果这类结构变化频繁的数据,MongoDB 或 JSONB 可能更舒服。是否需要数据库特有能力PostgreSQL 的 JSONB、数组、GIN 索引很好用;MySQL 的生态和运维资料很丰富;SQLite 部署简单;CockroachDB 和 Spanner 解决的是分布式一致性问题。用了这些能力,就要接受迁移时不再“数据库无关”。部署环境是否支持有些驱动在本地跑得很顺,进容器或 CI 就开始报错。Oracle、SAP HANA、SQLite 原生模块、better-sqlite3 都可能遇到构建环境问题。生产部署前要确认镜像、系统依赖、CPU 架构和连接配置。迁移和 schema 的常见坑TypeORM 给人一种“同一套 Entity 可以跑所有数据库”的感觉,但实际项目里,schema 差异经常藏在细节里。字段类型不完全通用varchar、text、timestamp、json、uuid、enum 在不同数据库中的实现不完全一样。PostgreSQL 有 jsonb,MySQL 有自己的 JSON 类型,SQLite 对类型更宽松,Oracle 的日期和字符类型又是另一套习惯。尽量不要在通用代码里随便写数据库特有类型。如果明确只支持 PostgreSQL,那用 jsonb 没问题;如果未来要兼容 MySQL,就要提前设计抽象方式。自动同步不适合生产synchronize: true 在开发初期很方便,但生产环境必须关掉。它会根据 Entity 自动改表,遇到字段删除、类型调整、索引变化时风险很高。生产环境应该使用 migration,并在测试环境先跑一遍。new DataSource({ type: 'postgres', host: 'localhost', port: 5432, username: 'postgres', password: 'password', database: 'test', entities: ['src/entities/**/*.ts'], migrations: ['src/migrations/**/*.ts'], synchronize: false,});迁移生成结果要人工检查migration:generate 能减少手写 SQL,但不要把它当成绝对正确。字段重命名可能被识别成“删除旧列 + 新增新列”,这会导致数据丢失。索引、默认值、枚举、外键变化也要仔细看生成文件。一个比较稳的习惯是:npm run typeorm migration:generate -- src/migrations/UpdateUserTablenpm run typeorm migration:runnpm run typeorm migration:show生成后先读 migration 文件,再决定是否提交。对大表做字段修改时,最好拆成多次迁移:先加新列,再回填数据,再切读写,最后删旧列。命名和大小写会影响跨库PostgreSQL 对未加引号的标识符会转小写,MySQL 在不同系统上表名大小写行为可能不同,Oracle 对大小写和保留字也更敏感。Entity、表名、列名建议统一使用小写加下划线,少用数据库关键字。@Entity({ name: 'user_account' })export class UserAccount { @Column({ name: 'created_at' }) createdAt: Date;}这类看起来啰嗦的命名,能省掉不少跨环境问题。生产环境配置要注意什么连接池别用默认值糊弄数据库连接不是越多越好。Node.js 服务副本一多,每个进程都开连接池,很容易把数据库打满。MySQL、PostgreSQL、SQL Server 等驱动的连接池配置方式略有不同,上线前要按实例数和数据库上限计算。日志要区分开发和生产开发环境可以打开 SQL 日志,排查很方便;生产环境全量打印 SQL 可能泄露敏感参数,也会制造大量日志。建议生产只打开错误日志或慢查询监控。new DataSource({ type: 'mysql', host: 'localhost', port: 3306, username: 'root', password: 'password', database: 'test', logging: ['error'], synchronize: false,});事务和锁要按数据库特性处理同样一段事务代码,在 MySQL、PostgreSQL、CockroachDB 上的表现可能不同。隔离级别、死锁重试、行锁语法、DDL 是否能在事务里执行,都需要看数据库文档。特别是 CockroachDB 这类分布式 SQL,事务重试不是异常情况,而是应用必须处理的正常流程。不要轻易承诺“随时切库”TypeORM 的统一 API 确实能让简单 CRUD 更像一套代码,但真实系统里总会用到数据库特有能力:JSONB、全文索引、枚举、分页语法、锁、地理类型、数组、触发器、存储过程。用了以后,切库成本就会明显上升。比较靠谱的说法是:TypeORM 能降低多数据库适配成本,但不能消除数据库差异。怎么选最稳如果是普通 Node.js 后端,默认可以从 MySQL / MariaDB 或 PostgreSQL 里选。团队重视生态、运维经验和兼容性,MySQL / MariaDB 很稳;需要复杂查询、JSONB、强事务和扩展能力,PostgreSQL 更合适。本地工具、桌面应用、测试环境选 SQLite、better-sqlite3 或 sql.js。企业已有 SQL Server、Oracle、SAP HANA,就按现有基础设施接入。需要分布式 SQL,再评估 CockroachDB 或 Spanner。MongoDB 则要单独判断:如果只是想用 TypeORM 顺手连一下可以,但如果业务大量依赖文档数据库特性,最好认真比较 TypeORM MongoDB 支持和 MongoDB 原生生态的差别。TypeORM 支持的数据库很多,真正影响项目质量的不是列表有多长,而是你有没有选一个团队能维护、迁移能控制、生产问题能排查的数据库。
服务端阅读 06月19日 15:56

TypeORM 核心概念是什么?Entity 和 Repository 怎么用?

TypeORM 的核心概念可以用一句话理解:用 TypeScript 类描述数据库结构,用 Repository、EntityManager 或 QueryBuilder 操作数据,再由 DataSource 统一管理连接、实体、迁移和事务。如果只会 save() 和 find(),确实也能写业务;但一到关联查询、事务、迁移、多数据库配置,很多问题就会暴露出来。下面按实际项目里最常接触的顺序,把 TypeORM 的主要组件讲清楚。Entity:数据库表在代码里的样子Entity 是 TypeORM 的基础。一个 Entity 类通常对应数据库里的一张表,类的属性对应表字段。import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn,} from 'typeorm';@Entity('users')export class User { @PrimaryGeneratedColumn() id: number; @Column({ length: 50 }) name: string; @Column({ unique: true }) email: string; @CreateDateColumn() createdAt: Date; @UpdateDateColumn() updatedAt: Date;}这里的 User 不是普通 DTO,而是 TypeORM 能识别的数据库模型。@Entity('users') 表示它映射到 users 表;如果不传表名,TypeORM 会按类名推导。Column 装饰器:字段如何落到数据库里Column 装饰器决定类属性和数据库列之间如何映射。常见装饰器有这些:@Column():普通字段,可配置类型、长度、默认值、是否可空等。@PrimaryColumn():手动指定主键。@PrimaryGeneratedColumn():自增主键或 UUID 主键。@CreateDateColumn():插入时自动写入创建时间。@UpdateDateColumn():更新时自动刷新修改时间。@DeleteDateColumn():软删除时间字段。@Generated():生成额外值,比如 UUID。例如:@Column({ type: 'varchar', length: 120, nullable: false })title: string;@Column({ type: 'int', default: 0 })viewCount: number;这些配置最好和真实数据库约束保持一致。不要只在业务代码里判断“必填”,数据库层也要有对应约束,否则线上数据很容易脏。DataSource:TypeORM 0.3 之后的连接入口从 TypeORM 0.3 开始,官方用 DataSource 取代了旧版的 Connection。也就是说,以前很多文章里的 createConnection()、getConnection() 已经不是新项目的推荐写法。import { DataSource } from 'typeorm';import { User } from './entity/User';export const AppDataSource = new DataSource({ type: 'mysql', host: 'localhost', port: 3306, username: 'root', password: 'password', database: 'app', entities: [User], migrations: ['dist/migrations/*.js'], synchronize: false, logging: true,});DataSource 负责管理数据库连接、实体注册、迁移、订阅器、缓存等配置。应用启动时通常先执行:await AppDataSource.initialize();需要特别注意 synchronize。开发环境里它可以帮你根据 Entity 自动同步表结构,但生产环境必须设为 false。生产库结构变更应该走 migrations,否则一次字段删除或类型变化就可能造成数据损坏。Repository:最常用的数据访问入口Repository 是操作某个 Entity 的仓储对象,适合大多数 CRUD 场景。const userRepository = AppDataSource.getRepository(User);const user = userRepository.create({ name: 'Alice', email: 'alice@example.com',});await userRepository.save(user);const users = await userRepository.find();const oneUser = await userRepository.findOne({ where: { id: 1 },});await userRepository.update(1, { name: 'Alice Updated' });await userRepository.delete(1);Repository 的优点是直观、类型友好,适合写简单查询和常规业务逻辑。项目里如果每张表都有独立的服务层,Repository 通常就是服务层访问数据库的第一选择。EntityManager:跨多个实体时更方便Repository 更像“只管一张表”,EntityManager 则可以统一操作多个实体。const manager = AppDataSource.manager;const user = await manager.findOne(User, { where: { id: 1 },});const post = manager.create(Post, { title: 'Hello TypeORM', author: user,});await manager.save(post);EntityManager 在事务里尤其常见。事务回调里拿到的 transactionalEntityManager,必须用它来执行本次事务内的所有数据库操作,不要混用外面的 Repository,否则可能出现事务不生效的问题。await AppDataSource.transaction(async manager => { const user = await manager.save(User, { name: 'Bob', email: 'bob@example.com', }); await manager.save(Profile, { bio: 'TypeORM user', user, });});Relation:实体之间的关系怎么表达TypeORM 支持常见的关系映射:@OneToOne():一对一,比如用户和用户资料。@OneToMany():一对多,比如一个用户有多篇文章。@ManyToOne():多对一,比如多篇文章属于一个作者。@ManyToMany():多对多,比如文章和标签。@Entity()export class Post { @PrimaryGeneratedColumn() id: number; @Column() title: string; @ManyToOne(() => User, user => user.posts) author: User;}@Entity()export class User { @PrimaryGeneratedColumn() id: number; @Column() name: string; @OneToMany(() => Post, post => post.author) posts: Post[];}关系映射看起来简单,真正要小心的是加载方式。简单场景可以用 relations:const users = await userRepository.find({ relations: { posts: true, },});复杂查询更建议用 QueryBuilder,把 join 条件、筛选、排序写清楚,避免无意中加载过多数据。QueryBuilder:复杂 SQL 的可控写法当查询条件变多,或者需要关联查询、分页、聚合时,Repository 的 find 语法会显得吃力。这时可以用 QueryBuilder。const users = await AppDataSource .getRepository(User) .createQueryBuilder('user') .leftJoinAndSelect('user.posts', 'post') .where('user.email LIKE :keyword', { keyword: '%@example.com' }) .andWhere('post.published = :published', { published: true }) .orderBy('user.createdAt', 'DESC') .skip(0) .take(20) .getMany();QueryBuilder 的优势是接近 SQL,同时保留参数绑定,能减少 SQL 注入风险。调试时还可以用 getSql() 或 getQuery() 看最终生成的 SQL。Active Record 和 Data Mapper 有什么区别TypeORM 同时支持 Active Record 和 Data Mapper 两种模式。Active Record 是把数据访问方法放到实体类上。实体需要继承 BaseEntity:@Entity()export class User extends BaseEntity { @PrimaryGeneratedColumn() id: number; @Column() name: string; static findByName(name: string) { return this.find({ where: { name } }); }}const users = await User.findByName('Alice');Data Mapper 则把实体和数据库操作分开,通过 Repository 或 EntityManager 访问数据:const userRepository = AppDataSource.getRepository(User);const users = await userRepository.find({ where: { name: 'Alice' },});小项目用 Active Record 会比较顺手;中大型项目更常用 Data Mapper,因为实体更干净,测试和分层也更容易。尤其是业务逻辑复杂时,把数据库访问放在 service 或 repository 层通常更稳。Migrations:生产环境管理表结构的方式迁移系统用于记录数据库结构变化,比如创建表、增加字段、添加索引、修改外键等。它的价值不是“自动建表”,而是让数据库结构变更可追踪、可回滚、可在不同环境重复执行。常见命令类似:typeorm migration:generate src/migrations/AddUserTable -d src/data-source.tstypeorm migration:run -d src/data-source.tstypeorm migration:revert -d src/data-source.ts实际命令会受项目构建方式影响,比如 ts-node、NestJS、ESM/CJS 配置不同,写法也会不同。关键是:生产环境不要依赖 synchronize: true 改表,应该生成 migration,审核 SQL,再执行。Transactions:多步写入必须一起成功涉及扣库存、创建订单、写流水这类操作时,事务是必需的。TypeORM 可以直接用 DataSource.transaction():await AppDataSource.transaction(async manager => { await manager.update(User, { id: 1 }, { name: 'New Name' }); await manager.save(AuditLog, { action: 'update_user', targetId: 1, });});事务里最容易犯的错,是一部分操作用了 manager,另一部分又用了外部的 AppDataSource.getRepository()。这样代码看着在事务里,实际可能不在同一个事务上下文里。Cache:查询缓存适合读多写少的数据TypeORM 支持查询缓存,可以给不频繁变化的数据减少数据库压力。const users = await userRepository.find({ cache: 60000,});QueryBuilder 也可以启用缓存:const users = await userRepository .createQueryBuilder('user') .where('user.active = :active', { active: true }) .cache(60000) .getMany();缓存不是越多越好。用户权限、库存、余额这类强一致数据不适合随便缓存;配置项、分类、字典表这类读多写少的数据更合适。多数据库支持:统一 API,不等于没有差异TypeORM 支持 MySQL、MariaDB、PostgreSQL、SQLite、SQL Server、Oracle、CockroachDB、MongoDB 等多种数据库。它提供了统一的 Entity、Repository、QueryBuilder API,能降低切换数据库时的学习成本。但不要误以为所有数据库能力都完全一样。比如 JSON 字段、全文索引、分页语法、锁、事务隔离级别,不同数据库仍然有差异。写通用业务可以依赖 TypeORM 抽象;写到数据库特性时,最好明确当前数据库类型,并在测试环境验证生成的 SQL。怎么把这些概念串起来一个典型 TypeORM 项目大概是这样运行的:用 Entity 和 Column 装饰器描述表结构。用 Relation 描述表之间的关系。用 DataSource 初始化数据库连接和配置。简单 CRUD 用 Repository。跨实体操作或事务用 EntityManager。复杂查询用 QueryBuilder。生产环境结构变更用 migrations。读多写少的数据按需开启 cache。TypeORM 的核心不难,难的是边界:什么时候用 Repository,什么时候换 QueryBuilder;什么时候能用同步表结构,什么时候必须用迁移;什么时候关系可以自动加载,什么时候应该手写 join。把这些边界弄清楚,TypeORM 才不会从“省代码的工具”变成“线上排查 SQL 的麻烦”。
服务端阅读 06月19日 15:56

TypeORM 关系映射如何配置一对一、一对多和多对多?

TypeORM 的关系映射,说白了就是把数据库里的外键、中间表和对象属性对应起来。真正容易出错的地方不在装饰器名字,而在谁拥有外键、什么时候自动加载、级联会不会误删数据。下面按常用关系类型讲清楚:OneToOne、ManyToOne / OneToMany、ManyToMany,再补上查询、级联、删除策略和性能上的坑。一对一关系:JoinColumn 放在拥有外键的一侧OneToOne 适合两个实体一一对应的场景,比如一个用户只有一份个人资料。关键点是:只有拥有外键的一侧需要写 @JoinColumn()。@Entity()export class Profile { @PrimaryGeneratedColumn() id: number; @Column() gender: string; @Column() bio: string; @OneToOne(() => User, user => user.profile) @JoinColumn({ name: 'user_id' }) user: User;}@Entity()export class User { @PrimaryGeneratedColumn() id: number; @Column() name: string; @OneToOne(() => Profile, profile => profile.user, { cascade: ['insert', 'update'] }) profile: Profile;}这里 Profile 表里会有 user_id 外键,因为 @JoinColumn() 写在 Profile.user 上。另一侧的 User.profile 只是反向引用,不会再生成一根外键。cascade 不建议一上来就写 true。如果只是希望保存用户时顺便保存资料,写 cascade: ['insert', 'update'] 更安全,避免后面误触发删除类操作。多对一和一对多:外键永远在 ManyToOne 这一侧ManyToOne / OneToMany 是项目里最常见的关系,比如一个用户有多篇文章,一篇文章只属于一个作者。@Entity()export class Post { @PrimaryGeneratedColumn() id: number; @Column() title: string; @Column() content: string; @ManyToOne(() => User, user => user.posts, { onDelete: 'RESTRICT' }) @JoinColumn({ name: 'author_id' }) author: User;}@Entity()export class User { @PrimaryGeneratedColumn() id: number; @Column() name: string; @OneToMany(() => Post, post => post.author) posts: Post[];}这里要记住两句话:@ManyToOne() 放在“多”的一侧,也就是 Post.author,外键列会出现在 post 表里。@OneToMany() 只是反向集合,它必须依赖另一侧的 @ManyToOne(),自己不能单独存在。反过来说,ManyToOne 可以不写对应的 OneToMany。如果业务里只需要从文章查作者,不需要从用户查文章,可以只保留 Post.author:@Entity()export class Post { @ManyToOne(() => User) @JoinColumn({ name: 'author_id' }) author: User;}这在大表里反而更清爽,少暴露一个容易被误加载的集合属性。多对多关系:JoinTable 只写在拥有中间表的一侧ManyToMany 需要中间表,比如一篇文章有多个标签,一个标签也能关联多篇文章。@Entity()export class Tag { @PrimaryGeneratedColumn() id: number; @Column() name: string; @ManyToMany(() => Post, post => post.tags) posts: Post[];}@Entity()export class Post { @PrimaryGeneratedColumn() id: number; @Column() title: string; @ManyToMany(() => Tag, tag => tag.posts, { cascade: ['insert'] }) @JoinTable({ name: 'post_tags', joinColumn: { name: 'post_id', referencedColumnName: 'id' }, inverseJoinColumn: { name: 'tag_id', referencedColumnName: 'id' } }) tags: Tag[];}@JoinTable() 只能放一侧,放它的一侧就是关系拥有方。TypeORM 会根据它创建中间表,默认包含两边的外键。多对多看起来方便,但如果中间表有额外字段,比如 createdAt、sort、role,就不要继续硬用 ManyToMany。这时更适合把中间表建成独立实体,例如 PostTag,再用两个 ManyToOne 表达关系。JoinColumn 和 JoinTable 分别解决什么问题@JoinColumn() 用在有外键列的一侧,常见于 OneToOne 拥有方和 ManyToOne 一侧。它可以自定义外键列名和引用列:@ManyToOne(() => User, user => user.posts)@JoinColumn({ name: 'author_id', referencedColumnName: 'id'})author: User;@JoinTable() 用在 ManyToMany 的拥有方,用来配置中间表:@ManyToMany(() => Tag, tag => tag.posts)@JoinTable({ name: 'post_tags', joinColumn: { name: 'post_id', referencedColumnName: 'id' }, inverseJoinColumn: { name: 'tag_id', referencedColumnName: 'id' }})tags: Tag[];如果你发现数据库里生成了不符合预期的列名,第一时间就该检查这两个装饰器是不是放错了位置。cascade 要少开,尤其别随手开删除cascade 控制的是 ORM 层面的级联保存、更新、删除,不等同于数据库外键的 ON DELETE CASCADE。@OneToMany(() => Post, post => post.author, { cascade: ['insert', 'update']})posts: Post[];可选值包括:insert:保存父实体时自动插入新关联实体update:保存父实体时自动更新关联实体remove:删除父实体时自动删除关联实体soft-remove:软删除关联实体recover:恢复软删除实体项目里最危险的是 cascade: true 和 cascade: ['remove']。比如删除一个用户时,如果关系配置不清楚,可能把文章、评论甚至更多关联数据一起删掉。除非你非常确认生命周期绑定关系,否则级联删除最好交给明确的业务代码处理。onDelete 和 onUpdate 是数据库外键行为onDelete、onUpdate 会影响数据库外键约束,通常写在拥有外键的一侧,也就是 ManyToOne 或带 JoinColumn 的一侧。@ManyToOne(() => User, user => user.posts, { onDelete: 'CASCADE', onUpdate: 'CASCADE'})@JoinColumn({ name: 'author_id' })author: User;常见选择有:CASCADE:父记录删除或更新时,子记录跟着处理SET NULL:父记录删除后,子表外键置空,外键列必须允许 nullableRESTRICT / NO ACTION:阻止删除仍被引用的父记录业务数据通常不建议默认 CASCADE。例如用户删除后文章是否也删除,这是产品规则,不只是数据库规则。很多系统会选择 SET NULL 或软删除,避免误删历史内容。Eager 和 Lazy 加载不要混着滥用关系加载有三种常见方式:显式 relations、QueryBuilder join、以及关系配置里的 eager / lazy。eager: true 会在使用 Repository 的 find* 方法时自动加载关系:@OneToMany(() => Post, post => post.author, { eager: true})posts: Post[];它省事,但容易让一个简单查询变成大查询。列表页、后台筛选页、导出任务里尤其要谨慎。Lazy loading 在 TypeORM 里通常通过 Promise 类型表达:@Entity()export class User { @OneToMany(() => Post, post => post.author) posts: Promise<Post[]>;}const user = await userRepository.findOneBy({ id: 1 });const posts = await user.posts;它的坑也不少:属性类型必须是 Promise<T>,访问属性时会触发额外查询;在序列化、事务边界、测试 mock、部分运行环境配置下都可能带来意外行为。新项目如果没有明确理由,更推荐显式使用 relations 或 QueryBuilder,把要查什么写清楚。使用 relations 查询关联数据简单场景可以用 find 的 relations 选项:const users = await userRepository.find({ relations: { profile: true, posts: true }});也可以对关联字段加条件:const users = await userRepository.find({ relations: { posts: true }, where: { posts: { title: Like('%TypeORM%') } }});这种写法适合中小规模查询。关系层级一深,或者需要选择部分字段、排序、分页、聚合,就应该换 QueryBuilder。复杂关联查询用 QueryBuilder 更稳QueryBuilder 能明确控制 join、条件、字段和排序:const users = await dataSource .getRepository(User) .createQueryBuilder('user') .leftJoinAndSelect('user.profile', 'profile') .leftJoinAndSelect('user.posts', 'post') .where('post.title LIKE :keyword', { keyword: '%TypeORM%' }) .orderBy('post.createdAt', 'DESC') .getMany();如果只需要关联表的部分字段,不要直接 leftJoinAndSelect 全量加载,可以用 leftJoin 配合 addSelect:const users = await dataSource .getRepository(User) .createQueryBuilder('user') .leftJoin('user.posts', 'post', 'post.status = :status', { status: 'published' }) .addSelect(['post.id', 'post.title']) .getMany();这类写法对性能更友好,也方便查看最终 SQL。const qb = userRepository .createQueryBuilder('user') .leftJoin('user.posts', 'post');console.log(qb.getSql());外键和中间表要补索引TypeORM 会帮你表达关系,但不代表所有性能问题都自动解决。高频查询的外键列、中间表两侧外键,通常都应该有索引。@Entity()export class Post { @Index() @ManyToOne(() => User, user => user.posts) @JoinColumn({ name: 'author_id' }) author: User;}多对多中间表也要关注索引。如果经常按 post_id 查标签、按 tag_id 查文章,两边都要能走索引。否则数据量一上来,关联查询会很快变慢。实际配置时怎么选如果只是“多条记录属于同一个父记录”,优先用 ManyToOne,需要反向集合时再补 OneToMany。如果是一对一扩展信息,把 JoinColumn 放在真正保存外键的一侧。多对多只有在中间表没有额外业务字段时才适合直接用 ManyToMany。关系配置里最该克制的是两个东西:过度 eager 和过度 cascade。前者容易把查询拖慢,后者容易把数据删错。把拥有方、外键位置和加载方式想清楚,TypeORM 的关系映射才会稳定好维护。
服务端阅读 06月19日 15:47

TypeORM QueryBuilder 如何写复杂查询?

QueryBuilder 适合处理 Repository 的 find 选项不太好表达的查询,比如多表关联、复杂条件组合、聚合统计、子查询、批量更新删除。它的核心价值不是“写法更高级”,而是让你在 TypeScript 里拼出可控的 SQL,同时继续使用参数绑定、实体映射和事务能力。如果只是按主键查一条数据,用 repository.findOne() 就够了;如果查询里开始出现 JOIN、GROUP BY、HAVING、EXISTS 或动态条件,QueryBuilder 会更清晰。如何创建 QueryBuilder最常用的写法是从 Repository 创建,这样 TypeORM 已经知道主表实体,只需要给它一个别名。const userRepository = dataSource.getRepository(User);const qb = userRepository.createQueryBuilder('user');const users = await qb.getMany();也可以从 DataSource 创建,但要把实体和别名都传进去:const users = await dataSource .createQueryBuilder(User, 'user') .where('user.isActive = :isActive', { isActive: true }) .getMany();还有一种更接近 SQL 的写法,适合原始表或临时构造查询:const users = await dataSource .createQueryBuilder() .select('user') .from(User, 'user') .getMany();不要写成 dataSource.createQueryBuilder('user') 后直接查实体。单独传字符串别名并不能告诉 TypeORM 主表是谁,实际项目里容易生成错误 SQL 或拿不到实体映射。条件查询怎么写where 会设置第一段条件,andWhere 和 orWhere 继续追加。参数用 :name 占位,再通过对象传值。const users = await dataSource .getRepository(User) .createQueryBuilder('user') .where('user.age >= :minAge', { minAge: 18 }) .andWhere('user.isActive = :isActive', { isActive: true }) .getMany();如果条件里有 OR,建议用 Brackets 明确括号范围。否则 SQL 的 AND / OR 优先级可能和你想的不一样。import { Brackets } from 'typeorm';const users = await dataSource .getRepository(User) .createQueryBuilder('user') .where('user.isActive = :isActive', { isActive: true }) .andWhere( new Brackets(qb => { qb.where('user.role = :adminRole', { adminRole: 'admin' }) .orWhere('user.score >= :minScore', { minScore: 90 }); }) ) .getMany();上面生成的逻辑接近:user.isActive = true AND (user.role = 'admin' OR user.score >= 90)参数绑定和 SQL 注入安全QueryBuilder 可以写 SQL 片段,但不要把用户输入直接拼进字符串。错误写法:.where(`user.name = '${keyword}'`)正确写法:.where('user.name = :name', { name: keyword })模糊查询也一样,通配符放在参数值里:const users = await dataSource .getRepository(User) .createQueryBuilder('user') .where('user.name LIKE :keyword', { keyword: `%${keyword}%` }) .getMany();IN 查询要用 TypeORM 的数组展开语法 :...ids:const users = await dataSource .getRepository(User) .createQueryBuilder('user') .where('user.id IN (:...ids)', { ids: [1, 2, 3] }) .getMany();不要写成 user.id IN :ids,多数数据库不会把它解析成合法的 IN (...)。也不要把 Like()、Between()、In()、MoreThan() 这些 FindOptions 操作符直接塞进字符串条件里;在 QueryBuilder 的字符串条件中,应当使用 SQL 操作符:const users = await dataSource .getRepository(User) .createQueryBuilder('user') .where('user.age BETWEEN :start AND :end', { start: 18, end: 30 }) .andWhere('user.score > :score', { score: 60 }) .getMany();关联查询:leftJoin、innerJoin 和 addSelectleftJoinAndSelect 会做左连接,并把关联实体一起映射回来。即使用户没有文章,用户也会出现在结果中。const users = await dataSource .getRepository(User) .createQueryBuilder('user') .leftJoinAndSelect('user.posts', 'post') .where('user.id = :id', { id: 1 }) .getMany();innerJoinAndSelect 只返回有关联数据的记录。比如只想查“至少发过文章的用户”,用 inner join 更准确。const users = await dataSource .getRepository(User) .createQueryBuilder('user') .innerJoinAndSelect('user.posts', 'post') .getMany();如果你只需要关联表的少数字段,可以用 leftJoin 加 addSelect,避免把整张关联表都查出来。const users = await dataSource .getRepository(User) .createQueryBuilder('user') .leftJoin('user.posts', 'post', 'post.status = :status', { status: 'published' }) .addSelect(['post.id', 'post.title', 'post.createdAt']) .getMany();多层关联也可以继续往下 join:const users = await dataSource .getRepository(User) .createQueryBuilder('user') .leftJoinAndSelect('user.posts', 'post') .leftJoinAndSelect('post.comments', 'comment') .leftJoinAndSelect('comment.author', 'commentAuthor') .getMany();多层 join 很方便,但也容易把结果集放大。列表页通常不建议一次性把评论、评论作者、点赞等全部查出来,先把主列表查准,再按业务补必要数据,往往更稳。分页、排序和 getManyAndCount分页一般配合稳定排序使用。只写 skip / take 不写 orderBy,翻页时可能出现重复或漏数据。const page = 1;const pageSize = 20;const [users, total] = await dataSource .getRepository(User) .createQueryBuilder('user') .where('user.isActive = :isActive', { isActive: true }) .orderBy('user.createdAt', 'DESC') .addOrderBy('user.id', 'DESC') .skip((page - 1) * pageSize) .take(pageSize) .getManyAndCount();getManyAndCount() 会返回 [列表, 总数],适合后台管理页或普通列表页。数据量很大时,深分页的 OFFSET 会越来越慢,这时可以考虑基于游标的分页,比如用 createdAt + id 作为翻页条件。排序可以用单字段,也可以追加多个字段:const users = await dataSource .getRepository(User) .createQueryBuilder('user') .orderBy('user.createdAt', 'DESC') .addOrderBy('user.name', 'ASC') .getMany();随机排序要谨慎。MySQL 的 RAND()、PostgreSQL 的 RANDOM() 在大表上通常很慢,不适合高频接口。聚合、分组和 Having统计类查询通常返回原始结果,用 getRawMany() 或 getRawOne() 更合适。const rows = await dataSource .getRepository(User) .createQueryBuilder('user') .select('user.role', 'role') .addSelect('COUNT(user.id)', 'count') .groupBy('user.role') .getRawMany();如果要过滤分组结果,用 having,不是 where:const rows = await dataSource .getRepository(User) .createQueryBuilder('user') .select('user.role', 'role') .addSelect('COUNT(user.id)', 'count') .groupBy('user.role') .having('COUNT(user.id) >= :minCount', { minCount: 5 }) .getRawMany();常见聚合函数也可以直接写:const stats = await dataSource .getRepository(User) .createQueryBuilder('user') .select('COUNT(user.id)', 'count') .addSelect('AVG(user.age)', 'avgAge') .addSelect('MAX(user.score)', 'maxScore') .getRawOne();注意,聚合结果不是实体字段,返回值一般是字符串或数据库驱动决定的类型。金额、计数这类字段最好在业务层做一次显式转换。子查询和 EXISTS子查询适合表达“满足另一张表里的某个条件”。例如查找发过 TypeORM 相关文章的用户:const users = await dataSource .getRepository(User) .createQueryBuilder('user') .where(qb => { const subQuery = qb .subQuery() .select('post.authorId') .from(Post, 'post') .where('post.title LIKE :title') .getQuery(); return `user.id IN ${subQuery}`; }) .setParameter('title', '%TypeORM%') .getMany();EXISTS 更适合只关心“是否存在”的场景:const users = await dataSource .getRepository(User) .createQueryBuilder('user') .where(qb => { const subQuery = qb .subQuery() .select('1') .from(Post, 'post') .where('post.authorId = user.id') .andWhere('post.status = :status') .getQuery(); return `EXISTS ${subQuery}`; }) .setParameter('status', 'published') .getMany();子查询里也要继续使用参数绑定,不要为了拼 SQL 省掉这一步。更新和删除QueryBuilder 不只能查,也能做批量更新和删除。更新时常见写法如下:await dataSource .createQueryBuilder() .update(User) .set({ isActive: false }) .where('lastLoginAt < :date', { date: new Date('2024-01-01') }) .execute();如果要基于原字段计算新值,可以用函数形式:await dataSource .createQueryBuilder() .update(User) .set({ score: () => 'score + 10' }) .where('id IN (:...ids)', { ids: [1, 2, 3] }) .execute();删除也类似:await dataSource .createQueryBuilder() .delete() .from(User) .where('createdAt < :date', { date: new Date('2023-01-01') }) .andWhere('isActive = :isActive', { isActive: false }) .execute();批量更新和删除不会像 save() 那样逐个加载实体,也不适合依赖实体生命周期钩子的逻辑。涉及重要数据时,先用同样的条件跑一遍 select 确认范围,再执行写操作。原生 SQL 和数据库函数QueryBuilder 可以混合数据库函数,例如 MySQL 的 JSON 查询:const users = await dataSource .getRepository(User) .createQueryBuilder('user') .where('JSON_CONTAINS(user.preferences, :preferences)', { preferences: JSON.stringify({ theme: 'dark' }), }) .getMany();如果整段 SQL 都不适合用 QueryBuilder 表达,也可以使用 query 执行原生 SQL:const rows = await dataSource.query( 'SELECT id, name FROM user WHERE createdAt >= ? LIMIT ?', [new Date('2024-01-01'), 20]);原生 SQL 依然要传参数数组。直接拼用户输入,风险和手写 SQL 完全一样。缓存怎么用TypeORM 支持查询缓存,适合不频繁变化、允许短时间延迟的数据,比如字典表、热门配置、低频统计。const users = await dataSource .getRepository(User) .createQueryBuilder('user') .where('user.isActive = :isActive', { isActive: true }) .cache(60_000) .getMany();也可以给缓存指定 id,方便后续清理:const users = await dataSource .getRepository(User) .createQueryBuilder('user') .where('user.role = :role', { role: 'admin' }) .cache('admin_users', 60_000) .getMany();缓存不是性能问题的万能药。条件不稳定、权限敏感、更新频繁的数据,不适合随手加缓存。事务里使用 QueryBuilder在事务中,要使用事务回调传入的 transactionalEntityManager,不要在中途又切回全局 dataSource。await dataSource.transaction(async manager => { await manager .createQueryBuilder() .insert() .into(User) .values({ name: 'John', email: 'john@example.com' }) .execute(); await manager .createQueryBuilder() .insert() .into(Post) .values({ title: 'New Post', authorId: 1 }) .execute();});如果需要更细粒度控制,也可以用 QueryRunner 手动开启事务,但要记得释放连接:const queryRunner = dataSource.createQueryRunner();await queryRunner.connect();await queryRunner.startTransaction();try { await queryRunner.manager .createQueryBuilder() .update(User) .set({ isActive: true }) .where('id = :id', { id: 1 }) .execute(); await queryRunner.commitTransaction();} catch (error) { await queryRunner.rollbackTransaction(); throw error;} finally { await queryRunner.release();}性能优化要看生成的 SQLQueryBuilder 写起来像链式 API,最终执行的还是 SQL。排查性能问题时,先看它生成了什么。const qb = dataSource .getRepository(User) .createQueryBuilder('user') .leftJoin('user.posts', 'post') .where('user.isActive = :isActive', { isActive: true });console.log(qb.getSql());console.log(qb.getParameters());常见优化点有这些:避免 N+1 查询:列表中需要关联数据时,用 leftJoinAndSelect 或分批查询,不要在循环里一条条查。只查必要字段:列表页不要默认取大文本、JSON、头像原图这类字段。给过滤和排序字段建索引:where、join、orderBy 里的高频字段尤其要关注。控制 join 数量:多层关联会放大结果集,必要时拆成两次查询。分页要稳定排序:createdAt 后面追加 id,可以减少同一时间数据导致的翻页抖动。用 getRawMany 处理统计结果:聚合统计没必要强行映射成实体。一个实用判断是:当 QueryBuilder 链条长到你自己都看不清时,不一定要继续硬拼。可以把动态条件封装成函数,把统计查询拆出去,或者直接写一段参数化原生 SQL。ORM 是帮你少写重复代码的,不是让 SQL 从项目里消失的。
服务端阅读 06月19日 15:47

TypeORM migrations 如何创建、运行和回滚?

如果数据库结构只靠 synchronize: true 自动同步,开发环境看起来很省事,到了生产环境就容易变成“谁也说不清表结构为什么变了”。TypeORM migrations 解决的就是这个问题:把每一次表结构或数据修正写成可追踪、可回滚的脚本,让团队里的每台机器、测试库和生产库按同一套顺序变更。简单说,migration 是数据库变更的版本记录。up 负责执行本次变更,down 负责撤回本次变更。TypeORM 会把已经执行过的迁移记录在数据库的 migrations 表里,之后只运行还没执行过的文件。migrations 主要解决什么问题TypeORM migrations 常用来处理这些变更:创建、删除表添加、删除、修改字段创建、删除索引添加、删除外键约束执行少量数据修正或数据搬迁用原生 SQL 处理 ORM API 覆盖不到的数据库能力它和 synchronize 最大的区别是:migration 是显式、可审查、可纳入代码评审的;synchronize 是根据 Entity 自动改库,方便但不适合生产。生产环境的 DataSource 里应该明确设置 synchronize: false。一个迁移文件长什么样迁移类需要实现 MigrationInterface,核心就是 up 和 down 两个方法。QueryRunner 是执行数据库操作的入口,Table、TableColumn、TableIndex、TableForeignKey 则用来描述表、字段、索引和外键。import { MigrationInterface, QueryRunner, Table,} from 'typeorm';export class CreateUserTable1710000000000 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise<void> { await queryRunner.createTable( new Table({ name: 'user', columns: [ { name: 'id', type: 'int', isPrimary: true, isGenerated: true, generationStrategy: 'increment', }, { name: 'name', type: 'varchar', length: '100', }, { name: 'email', type: 'varchar', length: '255', isUnique: true, }, { name: 'createdAt', type: 'timestamp', default: 'CURRENT_TIMESTAMP', }, ], }), true, ); } public async down(queryRunner: QueryRunner): Promise<void> { await queryRunner.dropTable('user'); }}up 里写“上线时要做什么”,down 里写“需要回滚时怎么撤销”。哪怕一开始觉得不会回滚,也要认真写 down,因为出问题时它就是最后一道保险。DataSource 应该怎么配置TypeORM 0.3 以后以 DataSource 为中心,CLI 也需要通过 -d 指向这个配置文件。import { DataSource } from 'typeorm';import { User } from './entity/User';export const AppDataSource = new DataSource({ type: 'mysql', host: process.env.DB_HOST, port: 3306, username: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, entities: [User], migrations: ['src/migration/**/*.ts'], subscribers: ['src/subscriber/**/*.ts'], synchronize: false, logging: ['error', 'warn', 'migration'],});本地开发如果短期使用 synchronize,也应该只放在本地配置里,别让它跟生产配置共用同一个开关。迁移文件才应该是生产数据库结构变更的来源。TypeORM 0.3+ 如何创建和运行迁移很多旧文章还在写 migration:generate -n CreateUserTable,这是 TypeORM 0.2 时代常见的写法。0.3+ 更推荐传入迁移文件路径,并用 -d 指定 DataSource。如果项目使用 TypeScript,可以先在 package.json 里配置脚本:{ "scripts": { "typeorm": "typeorm-ts-node-commonjs" }}ESM 项目通常改用 typeorm-ts-node-esm。之后常用命令如下:npm run typeorm -- migration:create src/migration/CreateUserTablenpm run typeorm -- migration:generate src/migration/AddUserEmailIndex -d src/data-source.tsnpm run typeorm -- migration:run -d src/data-source.tsnpm run typeorm -- migration:revert -d src/data-source.tsnpm run typeorm -- migration:show -d src/data-source.tsmigration:generate 依赖当前数据库状态,所以生成前要确认连的是正确环境。生产库上一般只运行已经评审过的迁移,不在生产环境临时生成迁移。QueryRunner 常见操作怎么写添加和修改字段import { TableColumn } from 'typeorm';public async up(queryRunner: QueryRunner): Promise<void> { await queryRunner.addColumn( 'user', new TableColumn({ name: 'avatar', type: 'varchar', length: '255', isNullable: true, }), ); await queryRunner.changeColumn( 'user', 'name', new TableColumn({ name: 'name', type: 'varchar', length: '200', }), );}public async down(queryRunner: QueryRunner): Promise<void> { await queryRunner.changeColumn( 'user', 'name', new TableColumn({ name: 'name', type: 'varchar', length: '100', }), ); await queryRunner.dropColumn('user', 'avatar');}字段改动最容易踩坑。缩短长度、修改类型、删除字段都可能造成数据丢失,提交前要先确认线上数据是否真的满足条件。创建索引import { TableIndex } from 'typeorm';public async up(queryRunner: QueryRunner): Promise<void> { await queryRunner.createIndex( 'user', new TableIndex({ name: 'IDX_USER_EMAIL', columnNames: ['email'], isUnique: true, }), );}public async down(queryRunner: QueryRunner): Promise<void> { await queryRunner.dropIndex('user', 'IDX_USER_EMAIL');}索引名最好显式指定。否则不同数据库、不同生成策略下名字可能不一致,回滚和排查都会更麻烦。添加外键import { TableForeignKey } from 'typeorm';public async up(queryRunner: QueryRunner): Promise<void> { await queryRunner.createForeignKey( 'post', new TableForeignKey({ columnNames: ['authorId'], referencedTableName: 'user', referencedColumnNames: ['id'], onDelete: 'CASCADE', }), );}public async down(queryRunner: QueryRunner): Promise<void> { const table = await queryRunner.getTable('post'); const foreignKey = table?.foreignKeys.find((fk) => fk.columnNames.includes('authorId'), ); if (foreignKey) { await queryRunner.dropForeignKey('post', foreignKey); }}外键的 onDelete: 'CASCADE' 不要随手加。它很方便,也可能在删除一条主记录时带走一大片业务数据。执行原生 SQLpublic async up(queryRunner: QueryRunner): Promise<void> { await queryRunner.query(` CREATE TRIGGER update_user_timestamp BEFORE UPDATE ON user FOR EACH ROW SET NEW.updatedAt = CURRENT_TIMESTAMP `);}public async down(queryRunner: QueryRunner): Promise<void> { await queryRunner.query('DROP TRIGGER update_user_timestamp');}原生 SQL 适合处理触发器、复杂索引、数据库特定语法。缺点是可移植性差,团队里如果同时支持 MySQL 和 PostgreSQL,就要特别标注这段迁移只适用于哪个数据库。如何在代码中执行迁移有些项目不会直接在命令行里跑迁移,而是在部署脚本或启动流程里调用 runMigrations。写法大致如下:import { AppDataSource } from './data-source';async function runPendingMigrations() { await AppDataSource.initialize(); try { await AppDataSource.runMigrations({ transaction: 'each' }); } finally { await AppDataSource.destroy(); }}runPendingMigrations().catch((error) => { console.error(error); process.exit(1);});如果需要回滚最后一次迁移,可以调用 AppDataSource.undoLastMigration({ transaction: 'each' })。是否把迁移放进应用启动流程,要看团队部署方式。小项目可以接受;大项目更常见的做法是把迁移作为独立发布步骤,执行成功后再启动新版本应用,避免多个实例同时抢着改库。数据迁移应该怎么处理结构迁移和数据迁移经常绑在一起,比如把 firstName、lastName 合并成 fullName。安全的顺序通常是:先加新字段,再回填数据,确认代码已切到新字段后,最后再删旧字段。public async up(queryRunner: QueryRunner): Promise<void> { await queryRunner.addColumn( 'user', new TableColumn({ name: 'fullName', type: 'varchar', length: '200', isNullable: true, }), ); await queryRunner.query(` UPDATE user SET fullName = CONCAT(firstName, ' ', lastName) WHERE fullName IS NULL `);}public async down(queryRunner: QueryRunner): Promise<void> { await queryRunner.dropColumn('user', 'fullName');}如果表很大,不要一条 UPDATE 扫完整张表。可以按主键分批处理,比 LIMIT/OFFSET 更稳,因为 OFFSET 在大表上会越来越慢,也容易受并发写入影响。public async up(queryRunner: QueryRunner): Promise<void> { const batchSize = 1000; let lastId = 0; while (true) { const rows: Array<{ id: number }> = await queryRunner.query( `SELECT id FROM user WHERE id > ? ORDER BY id ASC LIMIT ?`, [lastId, batchSize], ); if (rows.length === 0) break; const ids = rows.map((row) => row.id); const placeholders = ids.map(() => '?').join(','); await queryRunner.query( `UPDATE user SET migrated = 1 WHERE id IN (${placeholders})`, ids, ); lastId = rows[rows.length - 1].id; }}批量迁移还要考虑锁表时间、主从延迟、事务日志膨胀。越接近核心业务表,越应该拆成小步发布,而不是把所有动作塞进一个巨大迁移。事务、冲突和生产安全要注意什么TypeORM 可以用事务包裹迁移,CLI 里可以通过 --transaction all|each|none 控制,代码里也可以在 runMigrations 传入 transaction。但数据库支持程度不一样:PostgreSQL 对很多 DDL 的事务支持较好,MySQL 的不少 DDL 会隐式提交。不要以为写在事务里就一定能完整回滚,关键迁移仍然要有备份和回滚方案。多人协作时,迁移冲突通常出现在两个人同时改了同一张表。处理方式不是随便改已经执行过的迁移文件,而是先合并代码,再基于最新数据库状态重新生成或手写一个新的迁移。已经跑到共享环境或生产环境的迁移,原则上只追加新迁移修正,不直接改历史文件。生产环境建议记住这几条:synchronize 必须是 false迁移前备份数据库,并确认备份可恢复先在测试环境或预发环境跑一遍同样的迁移大表字段变更优先使用“先新增、再回填、后切换、最后清理”的节奏创建索引、修改字段类型前评估锁表时间删除字段和删除表尽量延后一个版本发布日志里记录迁移开始、结束、耗时和失败原因回滚不只看 down 能不能执行,还要看业务数据是否允许回到旧结构TypeORM migrations 本身不复杂,真正难的是把数据库变更当成发布的一部分来管理。MigrationInterface、QueryRunner 和现代 CLI 命令只是工具;能不能安全上线,取决于迁移是否可审查、可回滚、可分批,并且没有把生产数据库交给自动同步去碰运气。
服务端阅读 06月19日 12:17

npm 工具有哪些?如何按项目场景选择?

npm 生态里的工具很多,但项目里真正值得长期保留的,通常只解决几类问题:依赖更新、依赖清理、安全扫描、包体积控制、脚本编排、文档测试、构建开发、发布和 Git 工作流。选工具时不要先问“有哪些”,而要先问“现在项目最卡在哪里”。依赖老旧就看 npm-check-updates;怀疑装了没用的包就跑 depcheck;发布前担心漏文件就检查 packlist;团队提交质量不稳定,再加 husky 和 lint-staged。依赖更新:先看影响范围,再决定升不升npm-check-updates:批量检查 package.json 版本npm-check-updates 常用来检查 package.json 里的依赖是否有新版本。它不会直接安装依赖,通常先改版本声明,再由 npm、pnpm 或 yarn 重新安装。不一定要全局安装,临时使用可以这样跑:npx npm-check-updatesnpx npm-check-updates -unpm install常见用法:# 只看生产依赖npx npm-check-updates --dep prod# 只更新 patch,适合保守升级npx npm-check-updates -u --target patch# 只更新 minornpx npm-check-updates -u --target minor# 使用指定 registrynpx npm-check-updates --registry https://registry.npmmirror.com它适合做“依赖升级前的体检”。如果项目稳定性要求高,不建议直接 ncu -u 后一把安装,最好先升级 patch/minor,再单独评估 major 版本。npm-check:交互式查看依赖状态npm-check 更适合人工巡检。它会把依赖更新、未使用依赖、缺失依赖等信息放在交互界面里,适合本地排查,不太适合放进 CI 阻塞流程。npx npm-checknpx npm-check -unpx npm-check --ignore-unused如果团队已经有 Renovate、Dependabot 之类的自动升级工具,npm-check 的价值会下降,但它仍然适合在大版本升级前快速扫一遍项目状态。depcheck:找出未使用和缺失依赖depcheck 用来检查两类问题:package.json 里写了,但代码里可能没用到的包;代码里引用了,但 package.json 里可能没声明的包。npx depchecknpx depcheck ./srcnpx depcheck --ignore-patterns=dist,coverage它的结果需要人工判断。比如 Babel、ESLint、Vite、Webpack 插件可能只出现在配置文件里,动态 import、约定式插件也可能被误判。比较稳妥的做法是:先用它生成候选清单,再结合构建、测试和运行结果删除依赖。安全审计:npm audit 负责底线,Snyk 补充风险视角npm audit:内置漏洞扫描npm audit 是 npm 自带的安全检查,适合每个项目默认开启。npm auditnpm audit --audit-level=highnpm audit fix --dry-runnpm audit fixnpm audit fix --force 要谨慎,它可能引入破坏性升级。生产项目更推荐先看 --dry-run 结果,再决定是否手动升级关键包。如果只关注生产依赖,新版 npm 更推荐使用:npm audit --omit=devnpm audit fix --omit=devSnyk:适合需要持续监控的项目Snyk 的优势是漏洞库、修复建议和项目监控更完整,适合团队项目、开源库和对安全要求较高的业务。npx snyk authnpx snyk testnpx snyk test --severity-threshold=highnpx snyk monitorGitHub Actions 里可以只阻塞高危漏洞:- name: Snyk security check uses: snyk/actions/node@master env: SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} with: args: --severity-threshold=highretire.js:更适合检查旧前端资源retire 主要用于识别 JavaScript 库的已知漏洞,尤其是老项目里直接放在 public、vendor、static 目录下的前端库。npx retire --path ./publicnpx retire --path ./dist --outputformat json如果项目完全通过 npm 管理依赖,优先级通常是 npm audit 和 Snyk;如果项目里还有手工复制的 jQuery、Bootstrap、旧插件文件,retire 就很有用。包体积和发布内容:别等用户下载完才发现问题npm-packlist 与 npm pack:确认发布包会包含什么发布 npm 包前,最怕两件事:源码漏了,或者测试文件、截图、临时文件被打进去了。可以先用 npm 自带命令检查:npm pack --dry-runnpm pack --dry-run --json如果需要和 npm 实际打包规则保持一致,可以使用 npm-packlist 做更细的检查。它会参考 files、.npmignore、.gitignore 等规则,判断哪些文件会进入最终包。发布库时建议重点看:dist 或构建产物是否存在;类型声明文件是否被包含;README、LICENSE 是否存在;测试数据、私有配置、临时日志是否误入包内。bundlephobia:评估依赖对前端包体积的影响Bundlephobia 适合在新增依赖前看一眼包大小,尤其是浏览器端项目。可以直接访问 Bundlephobia 网站搜索包名,也可以把它当成评审习惯:新增一个工具库前,先看它的 minified、gzipped 体积,以及是否会带来一串间接依赖。典型场景:为一个小函数引入大型工具库;日期处理库替换;图表、富文本、拖拽库选型;SDK 是否应该按需加载。cost-of-modules:看依赖安装成本cost-of-modules 会统计依赖安装后的体积和成本,适合用来发现“一个小功能带来一大包依赖”的情况。npx cost-of-modulesnpx cost-of-modules --json它不必每次 CI 都跑,更适合在项目依赖明显膨胀、安装变慢、容器镜像变大时做分析。脚本编排:npm-run-all 和 concurrently 分工不同npm-run-all:适合组合 npm scriptsnpm-run-all 解决的是“多个脚本按顺序或并行执行”的问题。npm install -D npm-run-all{ "scripts": { "clean": "rimraf dist", "lint": "eslint src", "test": "vitest run", "build": "webpack --mode production", "check": "run-p lint test", "release:build": "run-s clean check build" }}常用命令:run-s clean build testrun-p lint testrun-s clean "run-p lint test" buildrun-s 表示顺序执行,run-p 表示并行执行。它更适合一次性任务,比如构建、测试、发布前检查。concurrently:适合同时启动长期进程concurrently 更适合开发环境,比如同时启动前端、后端和测试监听。npm install -D concurrently{ "scripts": { "dev:api": "nodemon server.js", "dev:web": "vite", "dev": "concurrently --names API,WEB --prefix-colors blue,green \"npm run dev:api\" \"npm run dev:web\"" }}常用参数:concurrently "npm run dev" "npm run test:watch"concurrently --kill-others "npm run api" "npm run web"concurrently --names "API,WEB" "npm run api" "npm run web"简单判断:短任务编排用 npm-run-all,长期进程并跑用 concurrently。文档和测试:先覆盖公共 API,再追求漂亮页面jsdoc:适合从注释生成 API 文档jsdoc 适合传统 JavaScript 项目,也适合给公共函数、类、模块生成 API 文档。npm install -D jsdocnpx jsdoc src -d docsnpx jsdoc -c jsdoc.conf.json如果项目已经大量使用 TypeScript,JSDoc 的重点可以放在“说明行为和边界”,不要把类型信息重复写一遍。documentation:适合生成 Markdown 或 HTML 文档documentation 更适合把 API 文档输出成 Markdown、HTML 或 JSON,方便放进站点或 README。npm install -D documentationnpx documentation build src -f html -o docsnpx documentation build src -f md -o API.mdnpx documentation build src -f json -o api.json对开源库来说,文档工具的价值不只是生成页面,而是逼你确认公共 API 是否稳定、参数是否清楚、异常行为是否写明。nyc:统计测试覆盖率nyc 是 Istanbul 的命令行工具,适合 Mocha、AVA 等测试框架,也能配合很多 Node.js 测试命令使用。npm install -D nycnpx nyc npm testnpx nyc report --reporter=htmlnpx nyc --check-coverage --lines 80 npm test覆盖率阈值不要一开始就设得很高。老项目可以先记录基线,再逐步提高;新项目可以对核心目录设置更严格的要求。testdouble:让单元测试少依赖外部环境testdouble 用来创建测试替身,适合隔离网络请求、文件系统、数据库访问等外部依赖。npm install -D testdoubleconst td = require('testdouble')const send = td.function('send')send('hello')td.verify(send('hello'))如果项目已经使用 Jest 或 Vitest 自带 mock,未必需要再引入 testdouble;但在偏函数式、模块边界清晰的 Node.js 项目里,它的表达会比较直接。构建和开发:webpack、rollup、nodemon、live-server 各有位置webpack:适合应用型项目Webpack 的优势是生态成熟,处理复杂前端应用很稳,尤其是多类型资源、代码分割、Loader/Plugin 定制较多的项目。npm install -D webpack webpack-clinpx webpack --config webpack.config.jsnpx webpack --mode productionnpx webpack --watch如果项目是大型 Web 应用,且已有复杂配置,继续使用 webpack 很正常;如果是新项目,也可以结合 Vite 等工具评估启动速度和配置成本。rollup:适合库和组件包Rollup 更适合打包库,尤其是希望输出 ESM、CJS、UMD 多种格式,并保持较好 tree-shaking 的项目。npm install -D rollupnpx rollup -cnpx rollup -c -w常见选择是:应用用 webpack/Vite,库用 rollup/tsup。工具不是越统一越好,关键是产物形态和维护成本合适。nodemon:Node 服务开发时自动重启nodemon 会监听文件变化并重启 Node.js 进程,适合 API 服务、本地脚本、CLI 开发。npm install -D nodemon{ "scripts": { "dev": "nodemon app.js", "dev:debug": "nodemon --inspect app.js", "dev:watch": "nodemon --watch src app.js" }}live-server:简单静态页面预览live-server 适合预览静态 HTML、构建后的 demo 或文档站点,不适合复杂前端应用的正式开发服务器。npx live-servernpx live-server --port=8080npx live-server --root=distnpx live-server --ignore=node_modules如果只是临时看一个 dist 目录,它很方便;如果项目已经用 Vite、Next.js、Nuxt 或自带 dev server,就没必要再加一层。发布自动化:np 适合人工确认,semantic-release 适合机器发布np:让手动发布更稳np 会帮你做发布前检查,比如工作区是否干净、测试是否通过、版本号和 tag 是否正确。它适合仍然希望人工确认版本和发布动作的 npm 包。npm install -D npnpx npnpx np 1.2.3npx np --tag betanp --yolo 会跳过不少检查,除非是临时包或内部包,否则不建议作为常规流程。semantic-release:根据提交记录自动发版semantic-release 适合成熟团队和持续发布场景。它通常依赖 Conventional Commits,根据 commit 类型自动判断版本、生成 release notes,并发布到 npm 或 GitHub。npm install -D semantic-release @semantic-release/commit-analyzer @semantic-release/release-notes-generator @semantic-release/npm @semantic-release/github.releaserc.json 示例:{ "branches": ["main"], "plugins": [ "@semantic-release/commit-analyzer", "@semantic-release/release-notes-generator", "@semantic-release/npm", "@semantic-release/github" ]}如果团队提交信息不规范,先上 semantic-release 反而会痛苦。可以先用 commitlint 和 husky 把提交格式稳定下来。Git 工作流:husky 和 lint-staged 管住提交入口husky:管理 Git hooksHusky 用来把检查命令挂到 Git hooks 上,例如提交前跑 lint、提交信息校验等。现代版本通常这样初始化:npm install -D huskynpx husky init然后编辑 .husky/pre-commit:npm test或者只跑更快的检查,避免每次提交都等很久。lint-staged:只检查暂存文件lint-staged 的价值是“只处理这次提交涉及的文件”,比全量 lint 更快。npm install -D lint-staged{ "lint-staged": { "*.{js,jsx,ts,tsx}": ["eslint --fix", "prettier --write"], "*.{css,scss,md,json}": ["prettier --write"] }}新版 lint-staged 会处理修改后的文件状态,通常不需要再手写 git add。如果团队成员经常因为 hook 太慢而绕过检查,说明 hook 里放的任务太重了,应该把耗时任务挪到 CI。一个比较实用的 package.json 组合下面这个组合适合中小型 Node.js 或前端项目,不追求工具最多,只覆盖日常维护的关键点:{ "scripts": { "dev": "concurrently --names API,WEB \"npm run dev:api\" \"npm run dev:web\"", "dev:api": "nodemon server.js", "dev:web": "vite", "lint": "eslint src", "test": "vitest run", "test:coverage": "nyc npm test", "check": "run-p lint test", "deps:check": "npm-check", "deps:update": "ncu -u", "deps:unused": "depcheck", "security": "npm audit --audit-level=high", "pack:check": "npm pack --dry-run", "build": "webpack --mode production", "release": "np" }, "lint-staged": { "*.{js,jsx,ts,tsx}": ["eslint --fix", "prettier --write"] }}这类脚本的好处是团队不用记住每个工具的参数,只要记住几个入口:npm run check:提交或合并前的基础检查;npm run security:安全审计;npm run deps:unused:依赖清理;npm run pack:check:发布前确认文件;npm run release:发布流程。怎么决定工具要不要进项目可以用几个很朴素的标准判断:| 场景 | 推荐工具 | 是否建议进 devDependencies ||---|---|---|| 经常检查依赖更新 | npm-check-updates、npm-check | 可以 || 偶尔清理依赖 | depcheck | 可以,也可 npx 临时跑 || 每次 CI 做安全检查 | npm audit、Snyk | npm audit 内置,Snyk 看团队需求 || 发布 npm 包 | npm pack、npm-packlist、np | 建议 || 控制前端体积 | bundlephobia、cost-of-modules | 多数情况下临时使用即可 || 多脚本编排 | npm-run-all、concurrently | 建议 || 生成 API 文档 | jsdoc、documentation | 看项目是否对外提供 API || 覆盖率统计 | nyc | 建议用于库或核心服务 || 自动重启服务 | nodemon | 建议用于 Node 服务 || 提交前检查 | husky、lint-staged | 团队项目建议 |最后要注意一点:工具本身也会增加维护成本。能用 npm 内置能力解决的,就别急着加依赖;需要团队统一入口的,再把它写进 scripts。一个项目里真正有价值的 npm 工具,不是清单最长的那组,而是大家每天都愿意运行、出了问题也知道该怎么修的那组。
服务端阅读 06月19日 12:15

Python 函数式编程怎么用才适合实际项目?

Python 函数式编程先解决什么问题在 Python 里谈函数式编程,不是要把所有代码都写成一串看不懂的 lambda。它更适合处理这类问题:一批数据进来,经过过滤、转换、排序、聚合,最后得到一个新结果。比如清洗接口返回的订单、统计日志里的错误类型、把配置项按规则合并。只要中间步骤能拆成几个独立的小函数,函数式写法就能让数据流向更清楚,也更容易测试。Python 不是纯函数式语言,所以不用排斥循环、类和可变对象。更实际的做法是:在关键的数据处理逻辑里多用纯函数、少改共享状态,必要时用 map、filter、reduce、生成器和装饰器把重复逻辑收起来。两个基础习惯:纯函数和不可变数据纯函数有两个特点:同样的输入总是得到同样的输出;函数内部不修改外部状态。这个习惯看起来朴素,但在排查线上问题时很省心,因为你不用猜某个全局变量是不是被别处改过。# 纯函数:只依赖参数,不改外部状态def add_tax(price, rate): return round(price * (1 + rate), 2)print(add_tax(100, 0.06)) # 106.0print(add_tax(100, 0.06)) # 106.0# 非纯函数:依赖并修改外部状态total = 0def add_to_total(amount): global total total += amount return total不可变数据的意思不是所有地方都必须用元组,而是尽量别在函数里偷偷改传进来的对象。尤其是列表、字典这种可变对象,修改前最好明确创建新对象。# 更稳妥:返回新列表def append_item(items, item): return items + [item]original = [1, 2, 3]new_items = append_item(original, 4)print(original) # [1, 2, 3]print(new_items) # [1, 2, 3, 4]写业务代码时,这个区别很常见。比如函数接收一份配置,如果直接往里面塞默认值,调用方后面可能拿到一份已经被改过的配置;返回新配置就清楚得多。map、filter、reduce、sorted 分别适合什么场景这几个函数是 Python 函数式编程里最常见的工具。别把它们当成必须使用的写法,先看语义是否合适。| 工具 | 适合场景 | 常见替代 ||---|---|---|| map | 每个元素都做同一种转换 | 列表推导式 || filter | 按条件保留一部分元素 | 带 if 的列表推导式 || reduce | 把多个值折叠成一个值 | sum、max、普通循环 || sorted | 排序并返回新列表 | list.sort() 会原地修改 |map 适合表达“逐个转换”:numbers = [1, 2, 3, 4, 5]squared = list(map(lambda x: x ** 2, numbers))print(squared) # [1, 4, 9, 16, 25]left = [1, 2, 3]right = [4, 5, 6]print(list(map(lambda x, y: x + y, left, right))) # [5, 7, 9]filter 适合表达“筛掉不需要的”:numbers = range(1, 11)even_numbers = list(filter(lambda x: x % 2 == 0, numbers))print(even_numbers) # [2, 4, 6, 8, 10]words = ["apple", "banana", "cherry", "date"]long_words = list(filter(lambda word: len(word) > 5, words))print(long_words) # ['banana', 'cherry']reduce 适合做累积,不过要谨慎。能用 sum()、max()、min() 表达清楚时,不必强行用 reduce。from functools import reducenumbers = [1, 2, 3, 4, 5]print(reduce(lambda x, y: x + y, numbers)) # 15print(reduce(lambda x, y: x * y, numbers)) # 120print(reduce(lambda x, y: x + y, numbers, 10)) # 25,初始值为 10sorted 的好处是不会改原列表,配合 key 很适合处理对象或字典列表。students = [ {"name": "Alice", "age": 25}, {"name": "Bob", "age": 20}, {"name": "Charlie", "age": 30},]by_age = sorted(students, key=lambda item: item["age"])print(by_age[0]) # {'name': 'Bob', 'age': 20}lambda 要短,复杂逻辑交给命名函数lambda 是匿名函数,适合一眼能看懂的小逻辑,比如取字段、简单计算、简单条件。它只能写表达式,不能写多行语句。students = [("Alice", 25), ("Bob", 20), ("Charlie", 30)]sorted_students = sorted(students, key=lambda item: item[1])print(sorted_students)如果逻辑开始变长,就别硬塞进 lambda。命名函数不仅更好读,也方便单独测试。# 不推荐:条件嵌套太多score_to_level = lambda score: "A" if score >= 90 else "B" if score >= 80 else "C"# 更清楚:写成普通函数def score_to_level(score): if score >= 90: return "A" if score >= 80: return "B" return "C"一个简单判断:如果你需要回头数括号,或者要解释这个 lambda 到底在做什么,那它就该变成普通函数了。列表推导式和生成器表达式更有 Python 味很多时候,列表推导式比 map 和 filter 更直观。尤其是转换和过滤同时出现时,它读起来更接近自然语言。numbers = [1, 2, 3, 4, 5]even_squared = [x ** 2 for x in numbers if x % 2 == 0]print(even_squared) # [4, 16]matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]flattened = [item for row in matrix for item in row]print(flattened) # [1, 2, 3, 4, 5, 6, 7, 8, 9]数据量大时,生成器表达式更合适。它不会一次性把所有结果塞进内存,而是用到一个算一个。large_squares = (x ** 2 for x in range(1_000_000))for value in large_squares: if value > 100: print(value) break处理日志、文件行、分页接口时,生成器很实用。它让“数据可能很多”这件事不必一开始就变成内存压力。装饰器:把横切逻辑从业务函数里拿走装饰器本质上也是高阶函数:接收一个函数,返回一个新函数。它适合处理日志、鉴权、重试、计时、缓存这类“很多函数都需要,但又不属于核心业务”的逻辑。from functools import wrapsimport timedef timer(func): @wraps(func) def wrapper(*args, **kwargs): start = time.perf_counter() result = func(*args, **kwargs) cost = time.perf_counter() - start print(f"{func.__name__} took {cost:.4f}s") return result return wrapper@timerdef load_items(): return [x for x in range(10000)]这里的 @wraps 很重要,它会保留原函数的 __name__、文档字符串等元数据。没有它,调试、日志和一些框架反射逻辑可能会看到一堆 wrapper。带参数的装饰器也很常见,比如做简单重试:from functools import wrapsdef retry(times): def decorator(func): @wraps(func) def wrapper(*args, **kwargs): last_error = None for _ in range(times): try: return func(*args, **kwargs) except Exception as error: last_error = error raise last_error return wrapper return decoratorpartial:提前固定一部分参数functools.partial 可以把一个函数的部分参数先固定住,得到一个更具体的新函数。它比自己写一堆薄封装函数更省事。from functools import partialdef power(base, exponent): return base ** exponentsquare = partial(power, exponent=2)cube = partial(power, exponent=3)print(square(5)) # 25print(cube(5)) # 125在项目里,它常用于给通用函数预置上下文。from functools import partialdef format_message(level, service, message): return f"[{level}] {service}: {message}"api_error = partial(format_message, "ERROR", "payment-api")print(api_error("timeout")) # [ERROR] payment-api: timeout如果参数越来越多,partial 也可能让人迷糊。这个时候改成一个带清晰名字的函数,反而更直接。用小函数拼出数据处理管道函数式写法最舒服的场景,是把数据处理拆成几个小步骤。每个函数只做一件事,输入输出都清楚,组合起来就是一条管道。from functools import reduceorders = [ {"id": 1, "status": "paid", "amount": 120, "country": "CN"}, {"id": 2, "status": "cancelled", "amount": 80, "country": "US"}, {"id": 3, "status": "paid", "amount": 260, "country": "CN"},]def is_paid(order): return order["status"] == "paid"def to_amount(order): return order["amount"]def add(left, right): return left + rightpaid_orders = filter(is_paid, orders)paid_amounts = map(to_amount, paid_orders)total_amount = reduce(add, paid_amounts, 0)print(total_amount) # 380上面这段可以继续写得更 Pythonic:total_amount = sum( order["amount"] for order in orders if order["status"] == "paid")这不是说 map/filter/reduce 不好,而是 Python 里有多种表达方式。短逻辑用推导式,复用逻辑拆成命名函数,复杂聚合用普通循环,都很正常。函数组合和柯里化适合少量使用函数组合就是把多个函数接起来,前一个函数的输出作为后一个函数的输入。它能让处理流程更集中。def compose(*functions): def inner(value): result = value for func in reversed(functions): result = func(result) return result return innerdef add_one(x): return x + 1def multiply_two(x): return x * 2def square(x): return x ** 2pipeline = compose(square, multiply_two, add_one)print(pipeline(3)) # 64柯里化是把多参数函数变成一连串单参数函数。它在一些函数式语言里很常见,在 Python 里偶尔有用,但不要为了形式感到处写。def curry(func): def curried(*args): if len(args) >= func.__code__.co_argcount: return func(*args) return lambda *more: curried(*(args + more)) return curried@currydef add(a, b, c): return a + b + cprint(add(1)(2)(3)) # 6实际项目里,partial 往往比通用柯里化更容易被团队接受。组合和柯里化适合用在边界清晰的数据转换上,不适合把普通业务流程绕成谜题。记忆化:让重复计算少做几次如果一个函数是纯函数,而且同样的参数会反复出现,就可以考虑记忆化。Python 标准库里的 lru_cache 已经够用。from functools import lru_cache@lru_cache(maxsize=128)def fibonacci(n): if n < 2: return n return fibonacci(n - 1) + fibonacci(n - 2)print(fibonacci(100))print(fibonacci.cache_info())手写一个简化版也不难:def memoize(func): cache = {} def wrapper(*args): if args not in cache: cache[args] = func(*args) return cache[args] return wrapper不过缓存不是免费午餐。参数必须可哈希,缓存会占内存,数据有时效性时还要考虑失效策略。像“按用户权限查询结果”这类函数,缓存前要先确认参数是否完整表达了影响结果的所有条件。函数式写法带来的几个实际好处第一是可测试。纯函数不依赖数据库、全局变量、当前时间,单测只需要准备输入和断言输出。def calculate_discount(price, discount_rate): return price * (1 - discount_rate)assert calculate_discount(100, 0.2) == 80assert calculate_discount(50, 0) == 50第二是可预测。函数不偷偷修改外部对象,调用前后状态更容易判断。多人协作时,这比少写两行代码更重要。第三是更容易并行。没有共享状态的函数,放进线程池或进程池时少很多锁和竞态问题。from concurrent.futures import ThreadPoolExecutordef process_item(item): return item ** 2items = list(range(1000))with ThreadPoolExecutor(max_workers=4) as executor: results = list(executor.map(process_item, items))第四是复用。小函数边界清楚,就能在不同管道里重新组合。今天用于订单统计,明天用于报表导出,不需要复制一份差不多的循环。什么时候不要过度使用函数式写法函数式编程很好用,但过度使用会把 Python 写得不像 Python。下面几种情况,普通写法通常更合适。lambda 里出现多层条件判断,改成命名函数。reduce 的累积逻辑需要读半天,改成 for 循环。为了组合而组合,导致调试时看不到中间变量。明明需要维护一组有生命周期的状态,却强行拆成很多无状态函数。团队成员普遍不熟悉柯里化,却把核心业务写成连续调用。一个实用原则:如果函数式写法让数据流更清楚,就用;如果它只是让代码显得“高级”,就停一下。落到 Python 项目里怎么用在 Python 项目里,函数式编程更像一套写代码的习惯,而不是一套必须遵守的规矩。纯函数让逻辑更稳定,不可变思路减少意外修改;map、filter、reduce、sorted 适合表达数据转换;列表推导式和生成器让代码更贴近 Python;装饰器、partial、组合和记忆化则适合处理复用和性能问题。保留循环,保留类,也保留清晰的中间变量。真正有价值的函数式写法,是让下一位读代码的人更快明白:数据从哪里来,经过了什么规则,最后变成了什么。
服务端阅读 06月19日 12:15

Python 元编程怎么用于框架开发和数据校验?

先弄清楚:元编程到底在改什么写 Python 框架时,经常会遇到一种需求:用户只写几行类定义,框架却能自动生成字段、校验规则、查询语句或接口对象。Django ORM、Pydantic、SQLAlchemy、表单库和很多 API SDK 都有这种味道。它们背后常用的就是元编程。元编程的重点,是把“对象怎么创建、类怎么创建、属性怎么访问”这些过程开放出来。普通业务代码通常操作对象;元编程会再往前一步,操作类、方法、属性协议,甚至在运行时生成类。常见工具包括:元类、动态属性、动态方法、描述符、property、type() 动态建类和类装饰器。它们适合做框架层能力,不太适合塞进每个业务函数里。元类:控制类创建过程在 Python 里,类本身也是对象。普通对象由类创建,类由元类创建。默认情况下,大部分类的元类都是 type。class User: passu = User()print(type(u)) # <class '__main__.User'>print(type(User)) # <class 'type'>如果要在“类被创建时”统一检查或改造类,就可以写自定义元类。比如下面这个元类要求类必须声明 required_attr,并给类补上一个标记属性:class ValidateMeta(type): def __new__(mcls, name, bases, namespace): if name != 'Base' and 'required_attr' not in namespace: raise TypeError(f'{name} must define required_attr') namespace['created_by_meta'] = True return super().__new__(mcls, name, bases, namespace)class Base(metaclass=ValidateMeta): passclass Service(Base): required_attr = 'ok'print(Service.created_by_meta) # True元类常见用途有三类:收集声明式字段:ORM、表单、序列化框架会扫描类属性,把字段整理到 _fields 里。约束类定义:要求子类必须声明某些属性或方法,或者必须带类型标注。改变实例化行为:例如注册表、插件系统、单例缓存等。单例也能用元类实现,但要谨慎。下面的写法会让同一个类只创建一个实例:class SingletonMeta(type): _instances = {} def __call__(cls, *args, **kwargs): if cls not in cls._instances: cls._instances[cls] = super().__call__(*args, **kwargs) return cls._instances[cls]class Config(metaclass=SingletonMeta): def __init__(self, value): self.value = valuec1 = Config('first')c2 = Config('second')print(c1 is c2) # Trueprint(c1.value) # second注意这里第二次初始化仍然会执行 __init__,所以 value 被改成了 second。这类细节很容易被忽略,也是元类代码需要写清楚文档的原因。动态属性和动态方法:让对象按规则响应__getattr__ 会在正常属性查找失败后触发,适合做“按命名规则生成属性”的能力。__setattr__ 会拦截赋值,适合把外部赋值统一存进内部字典。class DynamicRecord: def __init__(self): super().__setattr__('_data', {}) def __setattr__(self, name, value): if name.startswith('_'): super().__setattr__(name, value) else: self._data[name] = value def __getattr__(self, name): if name.startswith('get_'): key = name[4:] return self._data.get(key) raise AttributeError(name)record = DynamicRecord()record.name = 'Alice'record.age = 25print(record.get_name) # Aliceprint(record.get_age) # 25动态方法可以直接挂到类上,也可以用 types.MethodType 绑定到某个实例上。区别是:挂到类上会成为所有实例的方法,绑定到实例上只影响当前对象。import typesclass Greeter: passdef hello(self, name): return f'Hello, {name}'Greeter.hello = helloprint(Greeter().hello('Alice')) # Hello, Alicedef only_this(self): return 'only this instance'g = Greeter()g.only_this = types.MethodType(only_this, g)print(g.only_this())这里最好少碰 __getattribute__,因为它会拦截所有属性访问,包括内部属性。除非非常确定查找顺序和递归边界,否则调试成本会很高。描述符:把属性访问做成可复用规则只要一个对象实现了 __get__、__set__ 或 __delete__,它就可以作为描述符使用。描述符最适合做字段校验、懒加载、缓存属性、权限控制这类“每个字段都要同一套规则”的事情。class ValidatedField: def __init__(self, expected_type, required=False, default=None): self.expected_type = expected_type self.required = required self.default = default self.name = None def __set_name__(self, owner, name): self.name = '_' + name def __get__(self, instance, owner): if instance is None: return self return getattr(instance, self.name, self.default) def __set__(self, instance, value): if self.required and value is None: raise ValueError(f'{self.name} is required') if value is not None and not isinstance(value, self.expected_type): raise TypeError(f'{self.name} expects {self.expected_type.__name__}') setattr(instance, self.name, value)class User: name = ValidatedField(str, required=True) age = ValidatedField(int, default=18)u = User()u.name = 'Alice'u.age = 25print(u.name, u.age)property 其实也是描述符的一种常用封装。它更轻,适合单个类里的计算属性或受控赋值;如果同一套字段规则要在很多类里复用,描述符更合适。class Circle: def __init__(self, radius): self.radius = radius @property def radius(self): return self._radius @radius.setter def radius(self, value): if value <= 0: raise ValueError('radius must be positive') self._radius = value @property def area(self): return 3.14159 * self._radius ** 2circle = Circle(5)print(circle.area)用 type 动态创建类type(name, bases, namespace) 可以在运行时创建类。它适合处理“类结构来自配置、数据库、接口 schema”的场景,比如根据 API schema 生成响应对象。def __init__(self, name): self.name = namedef greet(self): return f'Hello, {self.name}'DynamicUser = type( 'DynamicUser', (object,), { '__init__': __init__, 'greet': greet, 'source': 'runtime' })user = DynamicUser('Alice')print(user.greet())print(user.source)动态类也能创建子类:class BasePlugin: def run(self): return 'base'Plugin = type('Plugin', (BasePlugin,), { 'name': 'csv_importer', 'run': lambda self: 'import csv'})print(Plugin().run()) # import csv这类代码要注意可读性。动态生成的类最好保留清晰的类名、模块名和文档字符串,否则日志、报错和调试器里只会出现一堆看不懂的运行时对象。类装饰器:很多时候比元类更顺手类装饰器接收一个类,修改后再返回。它不改变类创建机制,理解成本比元类低。只是给类补方法、补属性或做注册时,优先考虑类装饰器。registry = {}def register(name): def decorator(cls): registry[name] = cls cls.plugin_name = name return cls return decorator@register('email')class EmailPlugin: passprint(registry['email'] is EmailPlugin)print(EmailPlugin.plugin_name)类装饰器也能做轻量单例:def singleton(cls): instance = None def wrapper(*args, **kwargs): nonlocal instance if instance is None: instance = cls(*args, **kwargs) return instance return wrapper@singletonclass Database: pass不过这种写法会把类替换成函数,类型检查、继承和调试信息可能受影响。生产代码里如果要保留完整类语义,通常会选择更明确的工厂函数或容器管理。ORM、表单和 API 校验里的典型写法ORM 是元编程最常见的应用之一。用户声明字段,元类收集字段,描述符控制赋值,模型基类负责初始化。这就是很多框架“少写配置”的来源。class Field: def __init__(self, field_type, primary_key=False): self.field_type = field_type self.primary_key = primary_key self.name = None def __set_name__(self, owner, name): self.name = name def __get__(self, instance, owner): if instance is None: return self return instance.__dict__.get(self.name) def __set__(self, instance, value): if not isinstance(value, self.field_type): raise TypeError(f'{self.name} expects {self.field_type.__name__}') instance.__dict__[self.name] = valueclass ModelMeta(type): def __new__(mcls, name, bases, namespace): fields = { key: value for key, value in namespace.items() if isinstance(value, Field) } namespace['_fields'] = fields return super().__new__(mcls, name, bases, namespace)class Model(metaclass=ModelMeta): def __init__(self, **kwargs): for name in self._fields: if name in kwargs: setattr(self, name, kwargs[name]) def to_dict(self): return {name: getattr(self, name) for name in self._fields}class User(Model): id = Field(int, primary_key=True) name = Field(str) age = Field(int)user = User(id=1, name='Alice', age=25)print(user.to_dict())表单校验也很像 ORM,只是字段目标从“数据库列”变成了“用户输入”。字段对象负责校验,表单基类负责按字段列表处理输入。class FormField: def __init__(self, field_type, required=False, default=None): self.field_type = field_type self.required = required self.default = default self.name = None def __set_name__(self, owner, name): self.name = name def validate(self, value): if self.required and value is None: raise ValueError(f'{self.name} is required') if value is not None and not isinstance(value, self.field_type): raise TypeError(f'{self.name} has invalid type')class FormMeta(type): def __new__(mcls, name, bases, namespace): namespace['_fields'] = { key: value for key, value in namespace.items() if isinstance(value, FormField) } return super().__new__(mcls, name, bases, namespace)class Form(metaclass=FormMeta): def __init__(self, **data): for name, field in self._fields.items(): value = data.get(name, field.default) field.validate(value) setattr(self, name, value)class UserForm(Form): name = FormField(str, required=True) age = FormField(int, default=18)form = UserForm(name='Alice')print(form.name, form.age)API 响应校验可以用类装饰器来做。接口 schema 变化时,装饰器负责注入 __init__ 和 validate,调用方仍然使用普通类。def validated_response(schema): def decorator(cls): def validate(self, data): for field, field_type in schema.items(): if field not in data: raise ValueError(f'missing field: {field}') if not isinstance(data[field], field_type): raise TypeError(f'{field} has invalid type') def __init__(self, data): self.validate(data) for key, value in data.items(): setattr(self, key, value) cls.validate = validate cls.__init__ = __init__ return cls return decorator@validated_response({'name': str, 'age': int, 'email': str})class UserResponse: passresp = UserResponse({'name': 'Alice', 'age': 25, 'email': 'a@example.com'})print(resp.email)选择哪种元编程工具可以用一个简单判断来选:只想控制单个属性的读取或赋值,用 property。同一套属性规则要复用到多个类,用描述符。想根据字段声明收集类信息,用元类。只想给类补方法、补属性或做注册,用类装饰器。类结构来自运行时配置,再考虑 type() 动态建类。只是在对象上临时挂一个方法,用 types.MethodType 或直接给类赋函数。元类能力最强,但通常不是第一选择。很多需求用描述符或类装饰器已经足够,而且更容易被同事读懂。注意事项:性能、调试和文档元编程会把一部分逻辑藏到类创建、属性访问或装饰阶段。代码能少写,但排查问题时要多看一层。性能上,描述符、property、__getattr__ 都会参与属性访问路径。单次开销通常不大,但在高频循环、序列化大量对象、ORM 批量构造模型时可能放大。昂贵计算可以用缓存描述符:class cached_property: def __init__(self, func): self.func = func self.name = func.__name__ def __get__(self, instance, owner): if instance is None: return self if self.name not in instance.__dict__: instance.__dict__[self.name] = self.func(instance) return instance.__dict__[self.name]调试上,动态生成的方法和类要保留可读名称。必要时设置 __name__、__qualname__、__module__ 和 __doc__,不要让错误栈只剩 wrapper 或 DynamicClass。文档上,需要把隐式规则写明白:哪些属性会自动生成,什么时候触发校验,元类会给类添加哪些字段,装饰器会不会替换原类。元编程本身没错,麻烦通常来自规则藏得太深。最后怎么取舍Python 元编程适合放在框架边界:ORM 字段声明、表单校验、API 响应模型、插件注册、缓存属性和运行时类生成。它能把重复样板代码收起来,让使用者写更少的声明。业务代码里则要克制。能用普通函数解决的,不必上元类;能用类装饰器解决的,不必改类创建流程;能用 property 写清楚的,不必做复杂描述符。好的元编程代码应该让调用方更简单,而不是让维护者猜半天。
服务端阅读 06月19日 10:46

Python 性能优化应该从哪里下手才有效?

先确认:慢在哪里,别先改代码Python 性能优化最容易走偏的地方,是一上来就把 for 循环改成列表推导式,或者把所有地方都加缓存。真正有效的顺序应该反过来:先测量,再定位瓶颈,最后只改最值得改的那一小段。一个实用判断是:如果你说不清某段代码慢了多少、占总耗时多少、内存峰值在哪里,那现在还不是优化的时候。先把基准数据拿到手。用 timeit 做小片段基准测试timeit 适合比较一小段代码的耗时,比如列表查找和集合查找、字符串拼接和 join。它会重复执行代码,减少单次测量的抖动。import timeitsetup = "data = list(range(10000)); target = 9999"list_time = timeit.timeit("target in data", setup=setup, number=10000)setup = "data = set(range(10000)); target = 9999"set_time = timeit.timeit("target in data", setup=setup, number=10000)print(list_time, set_time)注意不要把 @timeit.timeit 当装饰器用,timeit.timeit() 返回的是耗时数字,不是函数包装器。函数级别的简单计时可以自己写装饰器,或者用 time.perf_counter():import timefrom functools import wrapsdef timed(func): @wraps(func) def wrapper(*args, **kwargs): start = time.perf_counter() try: return func(*args, **kwargs) finally: cost = time.perf_counter() - start print(f"{func.__name__}: {cost:.4f}s") return wrapper用 cProfile 看整段程序的热点cProfile 适合回答“到底哪个函数占了最多时间”。它给出的 cumtime 很有用,表示函数及其子调用的累计耗时。import cProfileimport pstatsdef main(): data = range(1_000_000) sum(x * x for x in data)cProfile.run("main()", "profile.stats")stats = pstats.Stats("profile.stats")stats.strip_dirs().sort_stats("cumtime").print_stats(20)如果是线上服务,别只在本地跑一个玩具输入。可以在压测环境里收集真实请求路径,必要时用 py-spy 这类采样工具,避免为了分析性能反而把服务拖慢。内存和逐行分析也要看有些慢不是 CPU 慢,而是内存涨得太快、频繁触发 GC、或者一次性加载了太多数据。可以用 memory_profiler 看函数执行期间的内存变化:# pip install memory-profilerfrom memory_profiler import profile@profiledef load_data(): data = [i for i in range(1_000_000)] return sum(data)load_data()line_profiler 更适合分析“这个函数里到底哪一行慢”:# pip install line_profilerfrom line_profiler import LineProfilerdef build_result(): result = [] for i in range(10000): result.append(i * 2) return sum(result)lp = LineProfiler()lp_wrapper = lp(build_result)lp_wrapper()lp.print_stats()先用 cProfile 找函数,再用 line_profiler 看函数内部,通常比一开始就逐行分析更省时间。算法比语法糖更值钱Python 单行写法再漂亮,也救不了错误的复杂度。一个 O(n²) 的逻辑,在数据量小的时候看不出来,数据一大就会突然变成事故现场。用 set 或 dict 降低查找成本找重复元素时,双重循环是典型的慢写法:# O(n²)def find_duplicates_slow(items): duplicates = [] for i in range(len(items)): for j in range(i + 1, len(items)): if items[i] == items[j] and items[i] not in duplicates: duplicates.append(items[i]) return duplicates用集合记录状态,复杂度可以降到 O(n):# O(n)def find_duplicates_fast(items): seen = set() duplicates = set() for item in items: if item in seen: duplicates.add(item) else: seen.add(item) return list(duplicates)这种优化比把变量名缩短、把循环换成推导式有效得多。减少重复计算两两计算距离时,如果 (i, j) 和 (j, i) 都算一遍,工作量直接翻倍:def calculate_distances(points): distances = [] for i in range(len(points)): for j in range(i + 1, len(points)): dx = points[j][0] - points[i][0] dy = points[j][1] - points[i][1] distances.append((dx * dx + dy * dy) ** 0.5) return distances还能继续优化吗?要看业务是否真的需要保存所有距离。如果只是找最小值,就不必把中间结果全部放进列表。数据结构会直接影响速度和内存Python 的列表、集合、字典、元组都很好用,但适用场景不一样。性能问题经常不是“Python 不够快”,而是数据结构选错了。列表适合顺序遍历,集合适合成员判断users = list(range(100000))user_set = set(users)99999 in users # 需要顺序扫描99999 in user_set # 哈希查找,平均 O(1)如果一段代码每天要做几百万次成员判断,把列表换成集合通常很划算。但集合会占用更多内存,也不保留重复元素,不能只看速度。生成器适合流式处理一次性构造大列表,会把所有结果放进内存。只需要逐个消费时,用生成器更稳。def squares(n): for i in range(n): yield i * itotal = sum(squares(1_000_000))列表推导式不是坏东西。需要多次遍历、随机访问、切片时,列表更方便。生成器的优势在于“边算边用”,不是所有场景都要换成生成器。slots 能省内存,但会牺牲灵活性当你需要创建大量结构相同的小对象时,__slots__ 可以减少每个对象的 __dict__ 开销。class Point: __slots__ = ("x", "y") def __init__(self, x, y): self.x = x self.y = y它的代价是不能随意添加新属性,和某些依赖 __dict__ 的库也可能不兼容。几十个对象没必要上 __slots__,几十万、几百万个对象才值得认真比较。多用内置函数,但别迷信“一行代码”sum()、max()、min()、sorted()、any()、all() 这类内置函数通常跑得更快,因为核心循环在 C 层实现。def manual_sum(items): total = 0 for item in items: total += item return totalbuiltin_sum = sum不过可读性也要算账。为了把三段清晰的业务逻辑压成一个嵌套表达式,最后没人敢改,这不是优化,是埋雷。I/O 优化先看批量、缓冲和异步文件、网络、数据库这些 I/O 场景,瓶颈通常不在 Python 运算本身,而在等待外部系统响应。优化方向也不一样。文件读写尽量批量处理逐行写入不是不能用,但如果数据已经在内存里,批量写入会少很多系统调用。def write_lines_fast(filename, lines): with open(filename, "w", encoding="utf-8") as f: f.write("\n".join(lines))读取大文件时,不要为了“省事”直接 read() 全部加载:def process_large_file(filename): with open(filename, "r", encoding="utf-8") as f: for line in f: yield line.strip()缓冲大小可以调,但别在文本模式下写 buffering=0,无缓冲只允许二进制模式。大多数情况下,默认缓冲已经够用。网络 I/O 适合异步或线程池如果任务主要在等网络返回,异步 I/O 或线程池都能提高吞吐。下面是 aiohttp 的常见写法:import asyncioimport aiohttpasync def fetch(session, url): async with session.get(url, timeout=10) as response: response.raise_for_status() return await response.text()async def fetch_all(urls): async with aiohttp.ClientSession() as session: tasks = [fetch(session, url) for url in urls] return await asyncio.gather(*tasks)真实项目里还要限制并发数,不要同时打出几千个请求把对方服务或自己机器打满。并发要分清 CPU 密集还是 I/O 密集Python 有 GIL,线程不适合加速纯 CPU 计算,但很适合等待网络、磁盘、数据库这类 I/O。CPU 密集任务通常考虑多进程、C 扩展、NumPy、Cython,或者把计算交给更合适的服务。I/O 密集用 ThreadPoolExecutorfrom concurrent.futures import ThreadPoolExecutorimport requestsdef download(url): response = requests.get(url, timeout=10) response.raise_for_status() return len(response.content)with ThreadPoolExecutor(max_workers=8) as executor: sizes = list(executor.map(download, urls))CPU 密集用 ProcessPoolExecutorfrom concurrent.futures import ProcessPoolExecutordef compute(chunk): return sum(x * x for x in chunk)with ProcessPoolExecutor(max_workers=4) as executor: result = sum(executor.map(compute, chunks))多进程有序列化和进程通信成本。数据块太小、任务太轻时,开进程可能比单进程更慢。缓存能救热点,也能制造脏数据缓存适合重复计算、重复查询、重复读取的场景。它不适合掩盖慢 SQL,也不适合缓存所有东西。lru_cache 适合纯函数from functools import lru_cache@lru_cache(maxsize=1024)def get_user_permissions(user_id): return load_permissions_from_db(user_id)lru_cache 的参数必须可哈希;结果最好由参数唯一决定。如果函数依赖当前时间、登录态、外部可变配置,就要谨慎。Redis 适合跨进程缓存import jsonimport redisr = redis.Redis(host="localhost", port=6379, db=0)def set_cache(key, value, ttl=300): r.setex(key, ttl, json.dumps(value, ensure_ascii=False))def get_cache(key): raw = r.get(key) return json.loads(raw) if raw else None示例里用 JSON 是为了避免随手 pickle.loads() 带来的安全风险。只有在数据来源完全可信时,才考虑 pickle。缓存一定要设计失效策略。TTL 太长会读到旧数据,太短又没有命中率;热点 key 还要考虑击穿、雪崩和并发回源。字符串和对象创建也有细节大量字符串拼接时,join() 通常比循环里的 + 更合适:def build_text(parts): return "".join(parts)格式化字符串优先用 f-string,清晰也快:message = f"Name: {name}, Age: {age}"处理大小写、替换、分割时,先看字符串内置方法。手写循环不仅慢,也更容易漏掉边界情况。数据库优化别只盯 Python 代码很多接口慢,Python 只背了一半锅,另一半在数据库。连接池减少连接开销from sqlalchemy import create_engineengine = create_engine( "postgresql://user:password@localhost/dbname", pool_size=10, max_overflow=5, pool_pre_ping=True,)连接池不是越大越好。池子太大会把数据库连接打满,反而拖垮整体服务。批量写入比逐条写入稳定def insert_fast(cursor, items): cursor.executemany( "INSERT INTO events(name, value) VALUES (%s, %s)", [(item.name, item.value) for item in items], )ORM 也通常提供 bulk insert,但要确认它是否跳过了模型钩子、默认值、校验逻辑。索引要服务查询条件CREATE INDEX idx_users_name ON users(name);SELECT * FROM users WHERE name = 'Alice';如果查询写成 WHERE LOWER(name) = 'alice',普通 name 索引可能用不上。要么调整查询方式,要么建立对应的函数索引。遇到慢查询,先看 EXPLAIN,不要凭感觉加索引。监控决定优化有没有持续价值本地优化通过之后,还要看线上是否真的变快。至少记录这些指标:接口耗时、P95/P99 延迟、错误率、内存峰值、队列长度、缓存命中率、数据库慢查询。一个简单的耗时日志装饰器可以这样写:import loggingimport timefrom functools import wrapslogger = logging.getLogger(__name__)def log_cost(func): @wraps(func) def wrapper(*args, **kwargs): start = time.perf_counter() try: return func(*args, **kwargs) finally: cost = time.perf_counter() - start logger.info("%s cost %.4fs", func.__name__, cost) return wrapper更完整的项目会把指标打到 Prometheus、StatsD、OpenTelemetry 或 APM 系统里。单次平均值不够,P95/P99 更能反映用户真实体感。优化时必须保留的取舍性能优化不是越快越好,至少要同时看四件事:可读性:快 3%,但代码复杂一倍,通常不值得。正确性:缓存、并发、批量写入都会引入一致性和边界问题。内存:用空间换时间很常见,但容器内存有限。可观测性:没有监控,优化效果很快会被后续改动吃掉。如果只能记住一个顺序,那就是:先用 timeit、cProfile、内存分析和线上指标确认瓶颈;再从算法、数据结构、I/O、并发、缓存、数据库这些高收益位置下手;最后用同样的指标复测。Python 性能优化最怕“感觉上更快”,真正可靠的是前后两组数据。
服务端阅读 06月19日 10:46

Python 深拷贝和浅拷贝有什么区别,什么时候该用?

先把“赋值”和“拷贝”分开很多 Python 拷贝问题,最容易错在第一步:把赋值当成了复制。赋值只是多了一个名字,两个变量仍然指向同一个对象。拷贝才会创建一个新对象。至于是只复制外层,还是连里面的对象一起复制,这就是浅拷贝和深拷贝的区别。original = [1, 2, 3]assigned = originalassigned[0] = 99print(original) # [99, 2, 3]上面没有发生拷贝,assigned 和 original 指向同一个列表。如果使用 copy.copy(),外层列表会变成两个对象:import copyoriginal = [1, 2, 3]copied = copy.copy(original)copied[0] = 99print(original) # [1, 2, 3]这段代码看起来已经解决问题了,但只适用于没有嵌套可变对象的情况。真正让人踩坑的,通常是列表里还有列表、字典里还有列表、对象属性里还有可变对象。浅拷贝复制外层,里面的对象仍然共享浅拷贝会创建一个新的“外壳”。外层容器是新的,但容器里的元素还是原来的引用。import copyoriginal = [1, 2, [3, 4]]shallow = copy.copy(original)print(original is shallow) # False,外层列表不同print(original[2] is shallow[2]) # True,嵌套列表仍然是同一个所以修改顶层元素没问题:shallow[0] = 99print(original) # [1, 2, [3, 4]]但修改嵌套对象,就会影响原数据:shallow[2][0] = 99print(original) # [1, 2, [99, 4]]这也是很多“明明复制了一份,原数据还是被改了”的来源。常见浅拷贝写法有哪些不同容器有自己的浅拷贝写法。它们写法不同,本质接近:只复制外层容器。import copyitems = [1, 2, [3, 4]]copy1 = copy.copy(items)copy2 = items.copy()copy3 = items[:]copy4 = list(items)字典也类似:original = {"debug": False, "endpoints": ["api1", "api2"]}cloned = original.copy()cloned["debug"] = Truecloned["endpoints"].append("api3")print(original["debug"]) # Falseprint(original["endpoints"]) # ['api1', 'api2', 'api3']debug 是顶层值,改副本不会影响原字典;endpoints 是嵌套列表,浅拷贝后仍然共享。深拷贝会递归复制嵌套对象深拷贝使用 copy.deepcopy()。它会尽量把对象内部引用到的对象也复制一份。import copyoriginal = [1, 2, [3, 4]]deep = copy.deepcopy(original)deep[2][0] = 99print(original) # [1, 2, [3, 4]]再看一个更接近业务数据的例子:import copydata = { "users": [ {"name": "Alice", "scores": [85, 90, 78]}, {"name": "Bob", "scores": [92, 88, 95]}, ]}processed = copy.deepcopy(data)for user in processed["users"]: user["average"] = sum(user["scores"]) / len(user["scores"])print("average" in data["users"][0]) # False如果这里只做浅拷贝,users 列表和里面的用户字典仍然可能被共享,后续处理就容易污染原始数据。可变对象和不可变对象对拷贝结果影响很大讨论深浅拷贝时,先看对象能不能被原地修改。不可变对象:int、float、str、tuple、frozenset 等。可变对象:list、dict、set、大多数自定义对象等。不可变对象通常没必要复制。对整数、字符串这类对象,copy.copy() 和 copy.deepcopy() 往往会返回原对象。import copya = 42b = copy.copy(a)c = copy.deepcopy(a)print(a is b) # Trueprint(a is c) # True但元组要稍微小心。元组本身不可变,不代表它里面的元素都不可变。import copyt = (1, 2, [3, 4])shallow = copy.copy(t)deep = copy.deepcopy(t)print(t is shallow) # True,浅拷贝直接复用元组print(t[2] is deep[2]) # False,深拷贝复制了里面的列表如果一个不可变容器里藏着可变对象,深拷贝仍然有意义。什么时候用浅拷贝,什么时候用深拷贝可以按“后面会不会改嵌套对象”来判断。| 场景 | 更合适的方式 | 原因 || --- | --- | --- || 只改列表或字典的顶层元素 | 浅拷贝 | 外层独立就够了,成本低 || 要改嵌套列表、嵌套字典 | 深拷贝 | 避免共享内部对象 || 数据完全只读 | 不拷贝 | 拷贝只会增加开销 || 配置模板生成多份独立配置 | 深拷贝 | 每份配置可能修改嵌套项 || 缓存对外返回数据 | 通常深拷贝 | 防止调用方改坏缓存 || 大型数据结构且性能敏感 | 谨慎深拷贝 | 可能复制大量对象 |浅拷贝不是“低级版深拷贝”。它只是适合不同场景。比如复制一个简单列表后只追加顶层元素,用浅拷贝就很好:items = [1, 2, 3]new_items = items.copy()new_items.append(4)print(items) # [1, 2, 3]配置、数据处理、缓存、撤销功能里的用法配置模板配置对象经常有嵌套字段,比如 endpoints、headers、feature flags。用默认配置派生新配置时,深拷贝更稳妥。import copydefault_config = { "debug": False, "max_retries": 3, "timeout": 30, "endpoints": ["api1.example.com", "api2.example.com"],}config = copy.deepcopy(default_config)config["debug"] = Trueconfig["endpoints"].append("api3.example.com")print(default_config["debug"]) # Falseprint(default_config["endpoints"]) # ['api1.example.com', 'api2.example.com']数据处理做清洗、补字段、格式转换时,是否拷贝取决于函数约定。如果函数承诺“不修改输入”,要么在内部复制,要么返回新数据结构。import copydef add_average(data): result = copy.deepcopy(data) for user in result["users"]: user["average"] = sum(user["scores"]) / len(user["scores"]) return result更推荐把这个约定写清楚:函数会不会修改参数,比它内部用了什么拷贝方式更重要。缓存数据缓存最怕外部代码拿到引用后直接修改。import copyclass DataCache: def __init__(self): self._cache = {} def set(self, key, value): self._cache[key] = copy.deepcopy(value) def get(self, key): if key not in self._cache: return None return copy.deepcopy(self._cache[key])cache = DataCache()cache.set("data", {"items": [1, 2, 3]})cached = cache.get("data")cached["items"].append(4)print(cache.get("data")) # {'items': [1, 2, 3]}这里 set 和 get 都做深拷贝,是为了让缓存内部状态和调用方彻底隔开。代价是性能会下降,数据很大时要评估是否值得。撤销和重做撤销功能保存的是“当时的状态”。如果状态里有嵌套对象,通常要保存深拷贝快照。import copyclass EditorState: def __init__(self): self.document = {"blocks": []} self.history = [] def save(self): self.history.append(copy.deepcopy(self.document)) def add_block(self, text): self.save() self.document["blocks"].append({"text": text}) def undo(self): if self.history: self.document = self.history.pop()如果只保存浅拷贝,历史记录里的嵌套 block 可能继续被当前文档共享,撤销出来的状态就不可靠。自定义对象如何控制拷贝行为对自定义类,Python 会尝试按对象属性进行拷贝。但有些对象需要自己定义规则:比如连接池、文件句柄、缓存、父子节点引用等。可以实现 __copy__ 和 __deepcopy__。import copyclass Settings: def __init__(self, name, options): self.name = name self.options = options def __copy__(self): cls = type(self) new_obj = cls(self.name, self.options) return new_obj def __deepcopy__(self, memo): cls = type(self) new_obj = cls( copy.deepcopy(self.name, memo), copy.deepcopy(self.options, memo), ) memo[id(self)] = new_obj return new_objoriginal = Settings("prod", {"endpoints": ["api1"]})shallow = copy.copy(original)deep = copy.deepcopy(original)shallow.options["endpoints"].append("api2")print(original.options) # {'endpoints': ['api1', 'api2']}deep.options["endpoints"].append("api3")print(original.options) # {'endpoints': ['api1', 'api2']}__deepcopy__(self, memo) 里的 memo 很关键。它用来记录“某个对象已经复制过了”,既能保持共享关系,也能处理循环引用。循环引用为什么不会轻易拖垮 deepcopy循环引用就是对象之间互相指向。比如链表节点指回前一个节点,树节点存 parent,图结构里节点互相关联。import copya = [1, 2]b = [3, 4]a.append(b)b.append(a)cloned = copy.deepcopy(a)print(cloned[2][2] is cloned) # Truedeepcopy 能处理这个例子,是因为它内部使用了 memo 字典。复制对象前先查 memo,如果这个对象已经复制过,就直接复用那份副本,而不是无限递归下去。自定义 __deepcopy__ 时也要配合 memo:import copyclass Node: def __init__(self, value): self.value = value self.next = None def __deepcopy__(self, memo): if id(self) in memo: return memo[id(self)] new_node = type(self)(self.value) memo[id(self)] = new_node new_node.next = copy.deepcopy(self.next, memo) return new_nodenode1 = Node(1)node2 = Node(2)node1.next = node2node2.next = node1copied = copy.deepcopy(node1)print(copied.next.next is copied) # True如果忘了写入 memo,遇到循环结构就可能递归到报错。性能上别把 deepcopy 当默认选项深拷贝要遍历对象图,数据越大、嵌套越深、对象越复杂,成本越高。它不只是“慢一点”,有时会复制出大量本来不需要复制的对象。可以用一个简单脚本感受差异:import copyimport timelarge_data = { "items": [{"id": i, "tags": ["python", "copy"]} for i in range(10000)]}start = time.perf_counter()shallow = copy.copy(large_data)print(f"shallow: {time.perf_counter() - start:.6f}s")start = time.perf_counter()deep = copy.deepcopy(large_data)print(f"deep: {time.perf_counter() - start:.6f}s")实际项目里,更常见的优化不是“让 deepcopy 更快”,而是减少不必要的复制:只读数据直接传引用。只改顶层结构时用浅拷贝。只需要修改某个分支时,手动复制那条路径。大对象考虑不可变数据结构,或者明确函数的修改边界。缓存场景权衡安全和性能,不要对超大结果无脑深拷贝。例如只改一个用户的分数,不一定要复制整份数据:data = { "users": { "alice": {"scores": [85, 90]}, "bob": {"scores": [92, 88]}, }}new_data = data.copy()new_data["users"] = data["users"].copy()new_data["users"]["alice"] = data["users"]["alice"].copy()new_data["users"]["alice"]["scores"] = data["users"]["alice"]["scores"].copy()new_data["users"]["alice"]["scores"].append(100)print(data["users"]["alice"]["scores"]) # [85, 90]这段写法比 deepcopy(data) 啰嗦,但在大数据结构里可能更可控。容易忽略的边界有些对象不适合拷贝模块、函数、文件对象、网络连接、数据库连接这类对象,通常不应该被深拷贝。对它们来说,“复制一份”本身就不一定有合理语义。import copyf = open("example.txt", "w")try: copy.deepcopy(f)except TypeError as e: print(type(e).__name__)finally: f.close()如果类里持有这类资源,最好在 __copy__ 或 __deepcopy__ 里明确处理:共享、重新创建,或者直接禁止复制。深拷贝会保持共享关系如果原对象里两个字段指向同一个列表,deepcopy 后它们仍然会指向同一个“副本列表”,而不是变成两个独立列表。import copyshared = [1, 2]original = {"a": shared, "b": shared}cloned = copy.deepcopy(original)print(cloned["a"] is cloned["b"]) # Trueprint(cloned["a"] is shared) # False这也是 memo 的作用之一:不是机械地“见一次复制一次”,而是在副本里保留原来的引用关系。一套实用判断方式写代码时可以先问三个问题:后续会不会修改这个对象?不会,就别拷贝。只改外层,还是会改里面的列表、字典、对象属性?只改外层,用浅拷贝;会改里面,用深拷贝或手动复制相关分支。数据大不大、对象里有没有资源句柄或循环引用?如果有,别直接把 deepcopy 当万能按钮。一句话概括:赋值不会复制对象;浅拷贝只换外壳;深拷贝会递归复制内部对象,但要付出性能和语义成本。配置模板、数据清洗、缓存隔离、撤销快照这些场景,深拷贝很有用;简单列表、只读数据和性能敏感路径,浅拷贝甚至不拷贝往往更合适。
服务端阅读 06月18日 23:40

LSP 是什么?VS Code 为什么靠它实现代码智能提示?

很多人第一次听到 LSP,会以为它是 VS Code 的某个插件接口。其实它更像一份编辑器和语言工具之间的“通信约定”:编辑器负责打开文件、显示提示、接收用户操作;语言服务器负责理解代码、分析类型、找定义、给诊断结果。这件事的价值在于,语言能力不用再为每个编辑器重复写一遍。一个 Rust 的语言服务器可以服务 VS Code,也可以服务 Neovim、Emacs、Zed 等编辑器。VS Code 推动并普及了 Language Server Protocol,但 LSP 本身已经是一个跨编辑器的通用协议。LSP 解决了什么问题没有 LSP 之前,编辑器想支持一门语言,通常要在编辑器内部实现一套语言分析能力。代码补全、跳转定义、查找引用、错误检查、重命名,每个功能都要和具体语言绑定。问题很快就来了:TypeScript、Python、Go、Rust 等语言的语法和类型系统差异很大;同一门语言如果要支持多个编辑器,维护成本会成倍增加;编辑器团队不一定最懂语言本身,语言团队也不一定想研究每个编辑器的插件机制。LSP 的做法很直接:把编辑器通用能力和语言专属能力拆开。编辑器只要会发标准请求,语言服务器只要按标准响应,双方就能配合工作。LSP 的基本架构:客户端和语言服务器LSP 采用客户端-服务器架构。客户端:通常是 VS Code 扩展或其他编辑器插件,负责 UI、文件事件、用户交互,以及把请求转成 LSP 消息。语言服务器:独立进程,负责语法分析、类型推断、符号索引、诊断、重构等语言相关逻辑。通信协议:双方通过 JSON-RPC 2.0 交换消息,常见传输方式包括 stdio、socket、named pipe。在 VS Code 里,用户按下补全快捷键时,真正发生的大致是:VS Code 客户端把当前文件 URI 和光标位置发给语言服务器;服务器分析上下文后返回候选项;VS Code 再把这些候选项渲染成补全列表。一次典型的 LSP 会话会发生什么LSP 不是只在用户触发功能时才工作。一个项目打开后,客户端和语言服务器会先建立能力协商,然后不断同步文档状态。常见流程如下:客户端发送 initialize,告诉服务器自己支持哪些能力,比如补全、诊断、语义高亮。服务器返回能力声明,比如是否支持跳转定义、重命名、增量同步。用户打开文件时,客户端发送 textDocument/didOpen。文件内容变化时,客户端发送 textDocument/didChange,可以是全文同步,也可以是增量同步。用户触发补全、悬停、跳转定义时,客户端发送对应请求。服务器可以主动推送诊断信息,例如 textDocument/publishDiagnostics。项目关闭或扩展停用时,客户端发送 shutdown 和 exit。这里有个容易忽略的点:LSP 里的 line 和 character 通常从 0 开始,位置计算还涉及 UTF-16 code unit。处理中文、emoji 或复合字符时,如果服务器用字节偏移直接换算,很容易出现跳转位置偏一格的问题。核心功能是怎么通过协议表达的LSP 的接口名称通常很直白,看到方法名基本就能猜到用途。代码补全:textDocument/completion客户端把当前文档和光标位置发给服务器,服务器返回补全项。{ "jsonrpc": "2.0", "id": 1, "method": "textDocument/completion", "params": { "textDocument": { "uri": "file:///path/to/file.ts" }, "position": { "line": 5, "character": 10 } }}服务器响应时会返回 CompletionItem 列表。每一项可以包含 label、kind、detail、documentation,也可以带上插入文本、排序规则和额外编辑。{ "jsonrpc": "2.0", "id": 1, "result": { "isIncomplete": false, "items": [ { "label": "console", "kind": 3, "detail": "Console", "documentation": "Provides access to the debugging console." } ] }}isIncomplete 也很实用。比如补全项依赖用户继续输入,服务器可以先返回一部分候选,并提示客户端后续继续请求。跳转定义:textDocument/definition跳转定义请求同样依赖文档 URI 和光标位置。{ "method": "textDocument/definition", "params": { "textDocument": { "uri": "file:///path/to/file.ts" }, "position": { "line": 0, "character": 12 } }}服务器返回的位置可能是一个 Location,也可能是多个位置。比如接口有多个实现、符号来自声明文件和源码文件时,就不一定只有一个答案。悬停提示:textDocument/hover悬停提示通常用来展示类型、文档注释、函数签名等信息。{ "method": "textDocument/hover", "params": { "textDocument": { "uri": "file:///path/to/file.ts" }, "position": { "line": 2, "character": 8 } }}好的 Hover 不是把所有文档都塞进去,而是优先给当前位置最有用的信息。过长的悬停内容会遮住代码,体验反而差。诊断信息:textDocument/publishDiagnostics诊断和补全、跳转不太一样。它通常是服务器主动推送给客户端,用来显示错误、警告和提示。{ "method": "textDocument/publishDiagnostics", "params": { "uri": "file:///path/to/file.ts", "diagnostics": [ { "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 5 } }, "severity": 1, "message": "Cannot find name 'foo'." } ] }}诊断要注意时机。保存时才检查,反馈会慢;每次输入都全量检查,大项目会卡。成熟的语言服务器通常会做增量分析、缓存索引和请求取消。在 VS Code 扩展里接入 LSPVS Code 扩展通常不直接手写 JSON-RPC,而是使用 vscode-languageclient 封装客户端逻辑。服务器端可以用 Node.js、Go、Rust、Java 等语言实现,只要遵守 LSP 即可。package.json 注册语言先告诉 VS Code 哪些文件属于你的语言,以及什么时候激活扩展。{ "contributes": { "languages": [ { "id": "mylang", "aliases": ["My Language", "mylang"], "extensions": [".mylang"] } ], "grammars": [ { "language": "mylang", "scopeName": "source.mylang", "path": "./syntaxes/mylang.tmLanguage.json" } ] }, "activationEvents": ["onLanguage:mylang"]}语法高亮和 LSP 是两件事。TextMate grammar 负责把代码染色,LSP 负责理解代码语义。很多新扩展看起来“有高亮”,但没有补全和跳转,通常就是只做了 grammar,没有接语言服务器。客户端启动语言服务器一个简化版 VS Code 客户端大概长这样:import * as vscode from 'vscode';import { LanguageClient, LanguageClientOptions, ServerOptions } from 'vscode-languageclient/node';let client: LanguageClient;export function activate(context: vscode.ExtensionContext) { const serverModule = context.asAbsolutePath('server/out/server.js'); const serverOptions: ServerOptions = { run: { command: 'node', args: [serverModule] }, debug: { command: 'node', args: ['--inspect=6009', serverModule] } }; const clientOptions: LanguageClientOptions = { documentSelector: [{ scheme: 'file', language: 'mylang' }], synchronize: { configurationSection: 'mylang' } }; client = new LanguageClient('mylang', 'My Language Server', serverOptions, clientOptions); context.subscriptions.push(client.start());}export function deactivate() { return client?.stop();}这里最关键的是 documentSelector。如果 language id、文件扩展名或 scheme 对不上,服务器可能已经启动了,但请求根本不会发过去。常见语言服务器有哪些| 语言 | 常见语言服务器 | 说明 ||---|---|---|| TypeScript / JavaScript | tsserver | VS Code 内置 TypeScript 支持主要依赖它,严格说它不是标准 LSP 服务器,但生态里常被一起讨论 || Python | Pylance / Pyright | Pylance 基于 Pyright,替代了较早的 python-language-server 方案 || Go | gopls | Go 官方维护的语言服务器 || Rust | rust-analyzer | Rust 生态主流语言服务器 || Java | Eclipse JDT Language Server(jdt.ls) | VS Code Java 扩展常用 || C / C++ | clangd | 基于 Clang,支持补全、诊断、跳转等能力 |如果你只是使用 VS Code,通常不需要手动理解这些服务器。但当补全很慢、跳转失败、诊断不更新时,知道背后是哪一个语言服务器在工作,排查会快很多。LSP 的优势和边界LSP 最大的优势是复用。一套语言分析能力可以被多个编辑器使用,语言团队可以把精力放在语言本身,编辑器团队也不用为每门语言重复造轮子。它还有几个实际好处:解耦清晰:编辑器管展示和交互,语言服务器管分析和语义。跨编辑器:同一语言服务器可以接入不同编辑器。独立优化:语言服务器可以单独做索引、缓存、多进程和性能分析。扩展能力强:补全、诊断、Code Action、重命名、格式化、语义高亮都能按协议扩展。但 LSP 不是万能的。它适合表达编辑器里的语言能力,不负责构建系统、运行时调试、包管理的全部细节。很多复杂能力仍然需要语言服务器读取项目配置,比如 tsconfig.json、go.mod、Cargo.toml 或 Maven/Gradle 配置。配置读错了,协议再标准,结果也会错。性能优化时最该关注什么语言服务器性能问题通常不是某一个请求慢,而是项目变大后,索引、诊断和文件监听一起把资源吃满。实用的优化方向有几个:增量同步:优先处理变化片段,而不是每次重新分析整个文件。缓存符号索引:项目级符号、依赖包信息、类型结果都应尽量复用。支持取消请求:用户连续输入时,旧的补全或诊断请求可能已经没有意义,应及时取消。分清前台和后台任务:补全、悬停要快;全项目索引可以放后台慢慢做。控制诊断频率:输入中频繁全量诊断会拖慢编辑器,适当 debounce 很重要。尊重工作区配置:不同项目可能有不同编译参数、依赖路径和语言版本。有些扩展会尝试“延迟初始化”,但 LSP 标准流程里更常见的做法是通过能力协商、懒加载索引和后台任务减少启动压力,而不是让服务器完全不初始化。调试 LSP 问题的几个切入点遇到 VS Code 里补全不出来、跳转失败,可以按这个顺序看:确认文件语言模式是否正确:右下角 language id 是否是扩展注册的语言。检查语言服务器是否启动:看 VS Code Output 面板里对应扩展的日志。确认 documentSelector 是否匹配:scheme 是 file、untitled 还是远程工作区,都会影响匹配。检查项目配置:例如 TypeScript 看 tsconfig.json,Go 看 go.mod,Rust 看工作区和 crate 配置。查看请求和响应日志:很多 language client 支持 trace,可以看到 JSON-RPC 消息。注意路径和 URI:Windows 路径、符号链接、大小写差异,都可能让服务器找不到文件。LSP 的核心并不复杂:编辑器问问题,语言服务器回答问题;编辑器同步文件状态,语言服务器返回语义结果。真正决定体验的,是语言服务器对项目的理解是否准确、响应是否足够快、错误信息是否能帮开发者定位问题。
服务端阅读 06月18日 23:40

VS Code snippets 代码片段如何创建并高效使用?

为什么要用 VS Code snippets如果你经常重复写 console.log、React 组件、文件头注释、Python 类或者 HTML 模板,VS Code snippets 就很值得配置。它的作用很直接:输入一个短前缀,按下 Tab 或 Enter,就把一段常用代码插入到当前位置,并且让光标按顺序跳到需要修改的地方。它适合解决两类问题:一类是减少重复输入,另一类是统一团队写法。比如每个人创建 React 组件时都按同一套 props、导出方式和文件头格式来写,后续维护会少很多无意义差异。VS Code 代码片段存放在哪里VS Code 的代码片段主要有三种范围,选择哪一种取决于你希望它给谁用、在哪些文件里生效。| 类型 | 存放位置 | 适合场景 ||---|---|---|| 用户代码片段 | VS Code 用户配置目录 | 只给自己用,跨项目生效 || 语言代码片段 | 针对 JavaScript、Python、HTML 等语言配置 | 只在某一种语言文件里提示 || 项目代码片段 | 项目下的 .vscode/*.code-snippets | 团队共享,随仓库提交 |个人习惯类片段放用户配置里就够了;团队约定类片段更建议放在项目的 .vscode 目录中,这样新人拉下仓库后也能直接使用。如何创建 VS Code snippets最常用的创建方式是通过 VS Code 菜单进入:打开命令面板或菜单:File > Preferences > Configure User Snippets选择一种语言,例如 javascript.json、typescriptreact.json、python.json如果要创建全局片段,选择 New Global Snippets file...如果要给当前项目共享,创建 .vscode/xxx.code-snippets按 JSON 格式写入片段并保存一个最小可用的代码片段长这样:{ "Print Console Log": { "prefix": "log", "body": ["console.log($1);"], "description": "Insert console.log" }}保存后,在支持的文件里输入 log,选择提示项,按 Tab 或 Enter 即可展开。代码片段的基本结构一个 snippet 通常包含三个字段:{ "Snippet Name": { "prefix": "trigger", "body": [ "code line 1", "code line 2" ], "description": "Snippet description" }}Snippet Name:片段名称,主要给自己识别用。prefix:触发前缀,可以是字符串,也可以是数组。body:真正插入的代码,多行时用数组更清晰。description:提示列表里展示的说明,建议写得具体一点。如果一个片段有多个触发词,可以这样写:{ "Console Log": { "prefix": ["log", "clg"], "body": "console.log($1);", "description": "Insert console.log" }}前缀不宜太长,也不宜和语言关键字冲突。比如 class 虽然好记,但在 Python 或 JavaScript 里很容易和正常输入打架,团队项目里可以用 pyclass、rfc、useeffect 这类更明确的前缀。占位符和光标跳转怎么写占位符是 snippets 最有用的部分。$1、$2 表示光标跳转顺序,$0 表示最后停留的位置。{ "Function Template": { "prefix": "func", "body": [ "function ${1:functionName}(${2:parameters}) {", "\t$0", "}" ], "description": "Create a function" }}展开后,光标会先选中 functionName,按 Tab 跳到 parameters,最后停在函数体内。${1:functionName} 里的 functionName 是默认文本,可以直接覆盖。相同编号的占位符会同步修改,适合组件名、类名这类需要出现多次的内容:{ "Named Export Function": { "prefix": "nef", "body": [ "export function ${1:handler}(${2:params}) {", "\treturn ${1:handler}Result;", "}" ], "description": "Create named exported function" }}这里两处 ${1:handler} 会一起变化,少一次手动改名,也少一次拼写错误。Choice 选项适合写固定分支如果某个位置只允许几种固定值,可以用 choice 占位符:{ "Console Statement": { "prefix": "console", "body": [ "console.${1|log,warn,error,info|}($2);" ], "description": "Insert console statement" }}展开后,第一个位置会出现 log、warn、error、info 选项。它很适合日志级别、HTTP 方法、组件状态、CSS display 值这类内容。常用变量怎么用VS Code snippets 支持预定义变量,可以读取当前文件名、路径、日期、剪贴板等信息。{ "File Header": { "prefix": "header", "body": [ "// File: ${TM_FILENAME}", "// Author: ${TM_USERNAME}", "// Date: ${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE}", "", "$0" ], "description": "Insert file header comment" }}常用变量包括:| 变量 | 含义 ||---|---|| TM_FILENAME | 当前文件名,包含扩展名 || TM_FILENAME_BASE | 当前文件名,不包含扩展名 || TM_DIRECTORY | 当前文件目录 || TM_FILEPATH | 当前文件完整路径 || CLIPBOARD | 剪贴板内容 || CURRENT_YEAR | 当前年份 || CURRENT_MONTH | 当前月份 || CURRENT_DATE | 当前日期 |有些变量取不到值时,VS Code 会把它当作普通占位符处理,所以写完后最好在真实文件里展开一次,确认效果符合预期。JavaScript 和 TypeScript 片段示例React 项目里,组件模板是最常见的 snippet。下面这个例子保留了组件名复用、props 类型和默认导出:{ "React TypeScript Component": { "prefix": "rfc", "body": [ "import React from 'react';", "", "interface ${1:ComponentName}Props {", "\t${2:prop}: ${3:type};", "}", "", "const ${1:ComponentName}: React.FC<${1:ComponentName}Props> = ({ ${2:prop} }) => {", "\treturn (", "\t\t<div>", "\t\t\t${4:content}", "\t\t</div>", "\t);", "};", "", "export default ${1:ComponentName};", "$0" ], "description": "Create React functional component with TypeScript" }}实际项目里可以按团队规范调整,比如是否使用 React.FC、是否默认导出、是否引入样式文件。不要把模板写成唯一正确答案,snippet 应该服务于项目习惯。Python 片段示例Python 类模板可以把类名、文档字符串和初始化参数都做成占位符:{ "Python Class": { "prefix": "pyclass", "body": [ "class ${1:ClassName}:", "\t\"\"\"${2:Class description}\"\"\"", "", "\tdef __init__(self${3:, args}):", "\t\t${4:pass}", "\t\t$0" ], "description": "Create Python class template" }}注意缩进建议用 \t,VS Code 会按当前文件的缩进配置转换。团队如果强制空格缩进,也可以直接写空格,但不同编辑器设置下更容易不一致。HTML 片段示例HTML5 模板适合放在 HTML 语言片段里:{ "HTML5 Boilerplate": { "prefix": "html5", "body": [ "<!DOCTYPE html>", "<html lang=\"zh-CN\">", "<head>", "\t<meta charset=\"UTF-8\">", "\t<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">", "\t<title>${1:Page Title}</title>", "</head>", "<body>", "\t${2:content}", "</body>", "</html>" ], "description": "Create HTML5 boilerplate" }}如果你经常写中文页面,把 lang 默认成 zh-CN 会比复制英文模板后再改更省心。Transform 转换能处理文件名Transform 可以对变量或占位符做正则转换。它不适合写太复杂的逻辑,但用来处理文件名大小写很方便。{ "Import Current File Name": { "prefix": "impfile", "body": [ "import { ${TM_FILENAME_BASE/(.*)/${1:/capitalize}/} } from './${TM_FILENAME_BASE}';" ], "description": "Import with transformed file name" }}如果文件名是 button.ts,展开后会得到类似:import { Button } from './button';Transform 的语法可读性一般,建议只在收益明显时使用。复杂到需要反复解释的转换,不如写一个更直白的片段,维护成本更低。全局代码片段怎么共享全局片段文件通常命名为 global.code-snippets,可以在所有语言里生效。适合放 TODO、版权注释、通用注释块这类不依赖具体语言的模板。{ "TODO Comment": { "prefix": "todo", "body": [ "// TODO: ${1:description} - ${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE}" ], "description": "Insert TODO comment with date" }}但要注意,通用片段不一定适合所有语言。上面的 // 注释在 JavaScript、TypeScript、Go 里没问题,在 HTML 或 CSS 中就不合适。如果要跨语言使用,最好把前缀和描述写清楚,避免误触。项目代码片段更适合团队规范如果片段和某个仓库强相关,建议放到项目里:.vscode/ project.code-snippets示例:{ "Project API Handler": { "prefix": "api-handler", "body": [ "export async function ${1:handlerName}(req: Request) {", "\ttry {", "\t\t${2:// handle request}", "\t} catch (error) {", "\t\t${3:// handle error}", "\t}", "}" ], "description": "Create project API handler" }}这类片段可以和代码审查规则配合起来:模板里提前放好错误处理、日志字段、命名约定,少靠口头提醒。使用时容易踩的坑JSON 转义写错片段文件是 JSON。双引号要转义,反斜杠也要转义。比如 HTML 属性里的引号要写成 \",换行不要直接写在字符串中,多行内容用数组更稳。前缀和已有补全冲突如果 prefix 太短,可能被变量名、关键字、Emmet 或语言服务补全淹没。团队片段最好用有辨识度的前缀,例如 rfc、api-handler、pyclass。片段没有出现可以按顺序检查:当前文件语言模式是否正确,比如 .tsx 是否识别为 TypeScript React。snippet 是否写在对应语言文件或全局文件中。JSON 是否有语法错误。editor.snippetSuggestions 是否被关闭或排序靠后。当前输入位置是否允许代码补全。项目片段和用户片段重复当项目片段和用户片段前缀相同,提示列表里可能出现多个相似项。团队项目中最好约定前缀命名,个人片段避免覆盖团队片段。不要期待片段自动调用另一个片段VS Code snippets 本身不是宏系统,不能可靠地在一个 snippet 里自动展开另一个 snippet。可以把公共部分复制进模板,或者通过扩展、任务脚本解决更复杂的生成需求。什么时候不该用 snippets代码片段适合静态模板,不适合复杂生成逻辑。如果你需要根据接口定义生成类型、批量创建文件、读取项目配置再输出代码,脚手架或代码生成器更合适。一个简单判断是:只替换几个名称、参数、路径,用 snippets;需要计算、读取文件或跨目录生成,用脚本工具。写好 VS Code snippets 的几个原则把最常改的内容做成 $1、$2,不要让光标在模板里来回找。重复出现的名称使用同一个占位符编号,减少手动同步。description 写清楚用途,尤其是团队共享片段。片段要短而准,不要把一整套业务逻辑塞进去。保存后在真实文件里试一次,确认缩进、引号、Tab 跳转都正常。VS Code snippets 的价值不在于写出多花哨的语法,而是把每天重复输入、容易写错、团队需要统一的代码固定下来。配置几条真正高频的片段,通常比收藏一大堆用不上的模板更有效。
服务端阅读 06月18日 23:40

VS Code 远程开发如何配置 SSH、容器和 WSL?

如果你经常遇到“本地能跑,服务器上跑不了”“Windows 路径和 Linux 权限打架”“团队每个人 Node、Python、Docker 版本都不一样”,VS Code 远程开发能省掉不少环境折腾。它的工作方式很直接:VS Code 界面仍在本地,代码、终端、语言服务、调试器可以运行在 SSH 服务器、Docker 容器或 WSL 里。也就是说,你看到的是本地编辑器体验,实际执行环境却贴近生产或项目约定的开发环境。先判断该用哪种远程开发模式VS Code 远程开发常见有三种入口,不建议一上来全装全配,先按场景选。| 模式 | 适合场景 | 主要依赖 | 典型优势 || --- | --- | --- | --- || Remote SSH | 代码和服务在远程 Linux 服务器上 | SSH 服务、远程账号 | 接近线上环境,适合后端、算法、运维开发 || Dev Containers | 项目希望用容器固定依赖版本 | Docker、.devcontainer 配置 | 团队环境一致,换电脑也容易恢复 || WSL | Windows 用户需要 Linux 开发环境 | WSL 2、Linux 发行版 | 比虚拟机轻,适合前端、Node、Python、Go 等项目 |一句话选择:已有远程服务器就用 Remote SSH;想把项目依赖锁进容器就用 Dev Containers;Windows 本机开发 Linux 项目优先用 WSL。Remote SSH 如何配置Remote SSH 的重点不是“远程同步文件”,而是 VS Code 会在远程机器上安装一个 VS Code Server。你打开的文件、运行的终端、语言服务都在远程机器上执行,本地只负责显示界面。远程机器需要先满足这些条件连接前先确认几件事,比反复重装扩展更省时间:远程服务器开启了 SSH 服务,端口能从本机访问。远程账号有可写的 home 目录,VS Code Server 默认会装到 ~/.vscode-server。磁盘空间不能太紧张,语言服务、扩展和缓存会占用一定空间。Shell 环境正常,登录后能执行基础命令,如 sh、tar、uname。如果服务器在公司内网或云厂商安全组后面,要先放通对应端口。本地安装扩展并配置 SSH 主机在 VS Code 扩展市场安装 Remote - SSH。如果你安装的是 Remote Development 扩展包,也会包含它。然后编辑本机 SSH 配置文件:Host dev-linux HostName 192.168.1.100 User username Port 22 IdentityFile ~/.ssh/id_rsa ServerAliveInterval 30 ServerAliveCountMax 3几个字段的含义:Host 是你在 VS Code 里看到的别名,可以随便取。HostName 写服务器 IP 或域名。User 是远程登录用户。IdentityFile 指向私钥路径,建议用密钥登录,不要长期依赖密码。ServerAliveInterval 和 ServerAliveCountMax 可以减少空闲连接被断开的概率。如果还没有 SSH 密钥,可以先生成一对,再把公钥放到服务器的 ~/.ssh/authorized_keys。私钥权限过宽会导致 SSH 拒绝使用,常见修复是:chmod 700 ~/.sshchmod 600 ~/.ssh/id_rsachmod 600 ~/.ssh/config在 VS Code 中连接远程主机按 F1 或 Ctrl+Shift+P 打开命令面板,执行:Remote-SSH: Connect to Host选择刚才配置的 dev-linux。首次连接时,VS Code 会在远程机器下载并安装 VS Code Server。成功后左下角会显示远程主机名,这时再选择远程目录作为工作区。连接后要注意一个细节:集成终端已经在远程服务器上运行。你执行 npm install、pip install、go test,消耗的是远程机器资源,不是本机资源。远程开发时文件、扩展和终端怎么工作很多人第一次用 Remote SSH 会误以为 VS Code 在同步本地文件,其实不是。文件操作发生在远程机器你打开 /home/username/project 后,保存、搜索、Git 操作默认都针对远程目录。这样做的好处是不会出现“本地改了但服务器忘记同步”的问题。如果你需要把本地文件传到远程,建议用 Git、SCP、rsync 或直接在远程仓库拉代码,不要把 VS Code 当作文件同步工具。扩展分本地和远程两类VS Code 会把扩展大致分成两类:主题、图标、快捷键这类界面扩展留在本地运行。ESLint、TypeScript、Python、Go、调试器这类依赖项目环境的扩展会安装到远程。如果某个语言提示突然失效,先看扩展面板里它是不是装在远程侧。有些扩展旁边会出现 “Install in SSH: xxx” 的按钮,需要手动点一下。终端环境和远程登录环境不一定完全相同VS Code 集成终端通常会读取远程用户的 shell 配置,但它和你直接用系统终端 SSH 登录仍可能有差异。比如 Node 版本管理器、conda、nvm 的初始化脚本没有加载,就会出现命令找不到。遇到这种情况,先在 VS Code 终端里执行:which nodenode -vecho $SHELL确认路径和版本,再检查 .bashrc、.zshrc 或 profile 配置。Dev Containers 如何配置Dev Containers 适合“项目依赖比较重、团队环境容易不一致”的情况。它会把开发环境放进 Docker 容器,VS Code 连接到容器内部工作。先安装 Dev Containers 扩展。旧教程里常写 “Remote - Containers”,现在扩展名称已经改为 Dev Containers。项目根目录创建 .devcontainer/devcontainer.json:{ "name": "node-dev", "image": "mcr.microsoft.com/devcontainers/javascript-node:18", "customizations": { "vscode": { "extensions": ["dbaeumer.vscode-eslint"] } }, "postCreateCommand": "npm install", "remoteUser": "node"}配置含义如下:image 指定基础镜像,Node、Python、Go、Java 都有官方 devcontainer 镜像可选。customizations.vscode.extensions 指定容器内建议安装的 VS Code 扩展。postCreateCommand 在容器首次创建后执行,常用于安装依赖。remoteUser 避免容器内文件全由 root 创建,减少权限问题。配置好后,打开命令面板执行:Dev Containers: Reopen in ContainerVS Code 会构建或拉取镜像,把项目挂载进容器,再重新打开窗口。Dev Containers 常见坑容器开发最常见的慢,往往不是 VS Code 慢,而是构建上下文太大。把 node_modules、dist、日志文件、缓存目录写进 .dockerignore,能明显减少构建时间。如果每次重建容器都要重新安装依赖,可以考虑用 Docker volume 缓存依赖目录。Node 项目尤其要注意,不要把宿主机的 node_modules 直接带进 Linux 容器,平台差异会让二进制依赖出问题。端口方面,VS Code 通常会自动提示转发。比如容器里启动了 localhost:3000,本机浏览器可以通过转发端口访问。没有提示时,可以在 Ports 面板手动添加。WSL 远程开发如何配置WSL 适合 Windows 用户。它比传统虚拟机轻,也比把项目放在 Windows 文件系统里硬跑 Linux 工具顺手。基本步骤:安装 WSL 2 和一个 Linux 发行版,如 Ubuntu。在 VS Code 安装 WSL 扩展。打开 WSL 终端,进入项目目录后执行 code .。推荐把项目放在 WSL 的 Linux 文件系统里,例如:~/projects/my-app不建议长期放在 /mnt/c/Users/... 下开发。跨 Windows 文件系统访问会更慢,文件权限、大小写、监听变更也更容易出问题。前端项目的 node_modules 很多时,这个差异会被放大。如果你同时用 Docker Desktop,记得开启对应发行版的 WSL integration。否则 VS Code 在 WSL 里可能找不到 Docker,或者连接到了和预期不同的 Docker daemon。如何减少卡顿和连接中断远程开发是否顺手,很大程度取决于网络、文件监听和扩展数量。减少不必要的文件监听大型项目可以在工作区设置里排除构建产物和依赖目录:{ "files.watcherExclude": { "**/node_modules/**": true, "**/.git/objects/**": true, "**/dist/**": true, "**/build/**": true }, "search.exclude": { "**/node_modules": true, "**/dist": true, "**/build": true }}这不会删除文件,只是减少 VS Code 的监听和搜索负担。只在远程安装必要扩展语言服务、调试器、Lint 工具需要远程运行;主题、图标、Markdown 预览这类通常留在本地就够了。远程服务器配置不高时,少装几个后台常驻扩展,比改很多小参数更有效。SSH 连接优先用密钥和保持活跃配置密码登录不仅麻烦,也更容易受安全策略影响。推荐使用密钥加 ssh-agent。连接经常断开时,优先检查网络和安全组,再补充 keep alive 配置:Host * ServerAliveInterval 30 ServerAliveCountMax 3如果公司网络需要跳板机,可以在 SSH config 里配置 ProxyJump,这样 VS Code 也能复用同一套连接方式。常见问题怎么排查| 问题 | 优先检查 | 处理思路 || --- | --- | --- || 连接失败 | SSH 配置、端口、安全组、用户名 | 先用系统终端执行 ssh dev-linux,确认普通 SSH 能连通 || 一直卡在 Installing VS Code Server | 远程磁盘、home 权限、下载网络 | 清理 ~/.vscode-server 后重连,或检查服务器是否能访问下载源 || 终端命令找不到 | shell 初始化脚本、PATH、nvm/conda | 在 VS Code 终端和普通 SSH 终端分别对比 echo $PATH || 保存文件提示权限不足 | 项目目录所有者、容器用户、sudo 创建文件 | 修正目录 owner,Dev Containers 中设置 remoteUser || TypeScript、Python 提示失效 | 扩展是否安装在远程、解释器路径 | 在远程扩展区安装语言扩展,并选择正确解释器 || WSL 项目很慢 | 项目是否在 /mnt/c 下 | 把项目移动到 WSL Linux 文件系统内 || 容器反复重装依赖 | 镜像缓存、volume、postCreateCommand | 使用 .dockerignore 和依赖缓存,避免把无关文件放进构建上下文 |排查顺序建议从“普通 SSH 或普通终端是否正常”开始。VS Code 远程开发建立在这些基础能力之上,底层连接或环境本身不稳定,编辑器层面很难单独修好。安全和备份别忽略远程开发会让本地编辑器直接操作服务器文件,权限边界要想清楚。私钥不要提交到仓库,也不要放进项目目录。谨慎使用 SSH Agent Forwarding,只在确实需要远程服务器继续访问 Git 仓库时开启。生产服务器不建议直接当开发机,至少要使用低权限账号和独立目录。远程环境变量、密钥、数据库配置应放在服务器或密钥管理系统中,不要写进代码。远程代码也要走 Git,不要只依赖服务器上的一份工作区。最后怎么选Remote SSH 适合直接在远程 Linux 机器上开发和调试;Dev Containers 适合把依赖、工具链和扩展配置一起固化到项目里;WSL 适合 Windows 用户获得更自然的 Linux 开发体验。如果是个人项目,WSL 或 Remote SSH 通常最快上手。如果是团队项目,Dev Containers 的前期配置成本更高,但能减少“我这里跑不起来”的沟通成本。真正影响体验的不是装了多少远程扩展,而是环境边界是否清楚:文件在哪里,命令在哪里执行,依赖装在哪个系统里。
服务端阅读 06月18日 23:35

VS Code 多光标编辑技巧怎么用,新手该记哪些快捷键才不误改?

先确认:多光标适合改什么VS Code 的多光标很适合处理“文本长得一样、修改规则也一样”的小批量编辑,比如给多行末尾加逗号、把几个相同前缀改成新前缀、同时补齐对象属性名。它不适合替代语义级重命名。比如 TypeScript 里的变量、函数、类名重命名,更稳的做法通常是按 F2 使用 Rename Symbol,因为它会理解作用域和引用关系。多光标只看文本,选错一个位置就会把不该改的地方一起改掉。常用多光标快捷键怎么记不同系统的默认快捷键略有差异,尤其 Linux 和 macOS 容易被系统快捷键占用。下面按 VS Code 常见默认配置整理,实际以 Keyboard Shortcuts 面板为准。| 操作 | Windows / Linux | macOS | 适合场景 ||---|---|---|---|| 鼠标添加一个光标 | Alt + 点击 | Option + 点击 | 少量不连续位置 || 向上/下添加光标 | Ctrl + Alt + ↑/↓ | Option + Command + ↑/↓ | 连续多行同一列 || 撤销上一次光标操作 | Ctrl + U | Command + U 常见 | 多选错一个位置时回退 || 选中下一个相同词 | Ctrl + D | Command + D | 逐个确认是否要改 || 选中所有相同内容 | Ctrl + Shift + L | Command + Shift + L | 当前文件内全部都要改 || 选中当前词所有出现位置 | Ctrl + F2 | Command + F2 | 快速改同一个词 || 在已选多行行尾加光标 | Shift + Alt + I | Shift + Option + I | 给多行补后缀、逗号、分号 |如果 Ctrl + Alt + ↑/↓ 没反应,先别怀疑 VS Code。很多桌面环境会把它当成切换工作区或屏幕方向快捷键,改系统快捷键或在 VS Code 里重新绑定即可。Ctrl+D、Ctrl+Shift+L、Ctrl+F2 有什么区别Ctrl+D:边看边选,更安全先选中一个词,按一次 Ctrl + D 选中下一个相同内容,再按一次继续往后选。它适合“有些要改、有些不要改”的情况。例如代码里同时有 user 变量和 user 字符串文案,你不确定能不能全改,就用 Ctrl + D 一个个确认。Ctrl+Shift+L:一次全选,适合确定无误的文本Ctrl + Shift + L 会把当前选中的内容在文件里的所有匹配项都变成光标。它速度快,但风险也高。适合所有 console.log 都要改成 console.info、所有相同占位符都要替换这类场景。Ctrl+F2:按当前单词批量选中Ctrl + F2 会选中当前单词的所有实例,通常比手动框选再 Ctrl + Shift + L 更快。需要注意的是,它按“单词”匹配,不适合处理带符号、空格或复杂表达式的内容。几个最常见的批量编辑场景给多行末尾同时加内容假设要把几行字段都补上逗号:const fields = [ 'name' 'age' 'email']操作方式:选中 name、age、email 三行,按 Shift + Alt + I,每行末尾都会出现一个光标,再输入 ,。const fields = [ 'name', 'age', 'email',]这种做法比正则替换更直观,也比一行行移动光标快。批量改相同前缀const userName = 'John';const userAge = 25;const userEmail = 'john@example.com';如果只是想把这三个变量的前缀从 user 改成 customer,可以选中第一个 user,连续按 Ctrl + D 选中另外两个,再输入 customer。const customerName = 'John';const customerAge = 25;const customerEmail = 'john@example.com';这里不要直接选中 userName 再全改,因为 userAge、userEmail 并不完全相同。多光标的关键不是“多”,而是先选准那段共同变化的文本。给对象属性名批量加引号const obj = { name: 'John', age: 25, email: 'john@example.com'};如果只有几行,可以用列选择或多光标分别放在属性名前后,手动输入引号。行数多时,正则替换更稳:查找:^(\s*)([a-zA-Z_$][\w$]*):替换:$1'$2':const obj = { 'name': 'John', 'age': 25, 'email': 'john@example.com'};替换前先看预览,尤其对象里如果混有注释、展开运算符或已经带引号的属性,正则可能匹配到不该改的行。同时注释多行代码选中多行后按 Ctrl + /,VS Code 会按当前语言切换行注释:// const line1 = 'code';// const line2 = 'code';// const line3 = 'code';这不算典型多光标操作,但经常和多光标一起用:先批量选中位置,再统一注释、移动或删除。列选择什么时候更好用列选择适合处理“每一行的目标位置在同一列”的文本,比如日志、CSV、对齐过的配置片段。Shift + Alt + 拖动:拖出一个矩形选择区域。Ctrl + Shift + Alt + ↑/↓:向上或向下扩展列选择。macOS 通常使用 Shift + Option + 拖动 或对应的 Option/Command 组合。name stringage numberemail string想在每行类型前加上 :,列选择比逐行移动光标更舒服。它的限制也明显:只适合列对齐的文本,如果每行长度差很多,还是用搜索选择或正则更稳。智能选择能减少手动框选多光标之前经常要先选中一段代码。VS Code 的智能选择可以按语法单元扩展选择范围:Shift + Alt + →:扩大选择范围。Shift + Alt + ←:缩小选择范围。Ctrl + Shift + →/←:按单词扩展或收缩选择。比如光标在函数参数里,连续扩大选择可能依次选中变量名、参数表达式、整个参数列表。它比鼠标拖选更不容易少选一个括号或多选一个空格。多光标和查找替换怎么配合多光标适合“我想边看边改”,正则替换适合“规则非常确定”。两者可以组合使用:先用查找定位,再用多光标处理少量需要人工判断的匹配项。把 console.log 改成 console.info查找:console\.log\((.*)\)替换:console.info($1)console.log(user);console.log(order);替换后:console.info(user);console.info(order);如果项目里有些 console.log 是临时调试,有些是刻意保留,别直接全部替换。用查找结果逐个确认,或者先选中当前文件里的目标区域再替换。用捕获组调整调用形式查找:(\w+)\.(\w+)\((.*)\)替换:$2($1, $3)它可以把 obj.method(args) 改成 method(obj, args)。这个例子只适合非常简单的调用形式。真实代码里如果有嵌套括号、链式调用、字符串参数,正则很容易失手。复杂结构改写要考虑 AST 工具或编辑器重构能力,不要硬用一条正则扫全项目。快速跳转和行操作也能提速| 操作 | Windows / Linux | macOS 常见 ||---|---|---|| 跳转到指定行 | Ctrl + G | Control + G || 快速打开文件 | Ctrl + P | Command + P || 跳转到工作区符号 | Ctrl + T | Command + T || 跳转到当前文件符号 | Ctrl + Shift + O | Command + Shift + O || 向下插入新行 | Ctrl + Enter | Command + Enter || 向上插入新行 | Ctrl + Shift + Enter | Command + Shift + Enter || 删除当前行 | Ctrl + Shift + K | Command + Shift + K || 上下移动当前行 | Alt + ↑/↓ | Option + ↑/↓ || 复制当前行 | Shift + Alt + ↑/↓ | Shift + Option + ↑/↓ || 格式化文档 | Shift + Alt + F | Shift + Option + F || 格式化选中部分 | Ctrl + K, Ctrl + F | Command + K, Command + F |批量编辑后的一个好习惯是立刻看 Git diff。多光标最大的风险不是改得慢,而是改得太快,直到提交前才发现多改了一处字符串或注释。代码折叠适合先缩小编辑范围文件很长时,可以先用折叠把无关区域收起来:Ctrl + K, Ctrl + 0:折叠所有区域。Ctrl + K, Ctrl + J:展开所有区域。Ctrl + K, Ctrl + [:折叠当前区域。Ctrl + K, Ctrl + ]:展开当前区域。折叠不是为了好看,而是减少误选。尤其在处理大文件里的重复字段时,先把无关函数折起来,再用 Ctrl + D 或查找结果选择,出错概率会低很多。可以把常用多光标动作改成自己的快捷键如果你经常对选中多行的行尾插入光标,可以在 keybindings.json 里绑定一个更顺手的组合:[ { "key": "ctrl+shift+;", "command": "editor.action.insertCursorAtEndOfEachLineSelected", "when": "editorTextFocus" }]也可以在快捷键面板里搜索这些命令名:Add Cursor Above、Add Cursor Below、Add Selection To Next Find Match、Select All Occurrences of Find Match、Insert Cursor at End of Each Line Selected、Cursor Undo。多光标最容易踩的坑误把文本替换当成语义重命名同名文本不一定是同一个变量。同一个变量也可能在不同文件里有引用。涉及函数名、类名、导出符号时,优先用 F2,不要靠 Ctrl + Shift + L 硬改。选中内容带了多余空格多光标输入会替换每个选区。选区如果多带一个空格、逗号或换行,结果可能变得很奇怪。按键前看一眼高亮范围,特别是使用鼠标拖选时。粘贴行数和光标数不一致VS Code 支持把多行剪贴板内容分别粘到多个光标位置。比如 3 行内容粘到 3 个光标,会一一对应;如果行数和光标数对不上,结果就可能不是你以为的那样。批量粘贴前可以先在临时文件里试一下。大量光标会拖慢编辑器几百个光标同时编辑,语法高亮、补全和插件都可能变慢。数量很大时,查找替换、脚本或 codemod 往往更稳。正则替换没限制范围全文件替换前先选中目标区域,或者至少打开替换预览。很多事故不是正则写错,而是范围太大。什么时候别用多光标下面几种情况,多光标不是最优解:改变量、函数、类名:优先用 F2 重命名符号。改整个项目的 API 调用形式:优先考虑 AST 工具、codemod 或语言服务重构。只为统一格式:交给 formatter 或 linter。匹配规则复杂、有嵌套结构:不要用一条正则赌运气。多光标最舒服的用法,是处理那些规则简单、范围可控、肉眼能快速确认的小批量修改。语义重命名交给编辑器重构,格式统一交给工具,文本级重复劳动再交给多光标,这样分工更稳。
服务端阅读 06月18日 23:35

VS Code 扩展如何用 vsce 发布到 Marketplace?

发布前先确认这几件事把 VS Code 扩展发布到 Marketplace,本质上有三件事:扩展本身能正常运行,package.json 信息能被市场识别,发布者账号和访问令牌配置正确。很多发布失败并不是代码问题,而是 README、图标、publisher、PAT 权限这些细节没准备好。正式发布前,建议先在本地安装一次 .vsix 文件试用。如果本地都装不上,直接 publish 只会把问题推到 Marketplace 的校验阶段。发布前检查清单发布前至少检查这些内容:扩展功能已经跑通过,包括激活事件、命令、配置项和异常场景。README.md 能说明扩展解决什么问题、怎么安装、怎么使用。图标准备为 128x128 像素的 PNG,路径要和 package.json 中的 icon 一致。package.json 中的 name、displayName、description、publisher、version 都填写正确。仓库地址、许可证、分类和关键词不要空着。需要排除的源码、测试文件、截图源文件已写入 .vscodeignore,避免包体过大。如果扩展包含原生依赖,要确认是否需要按平台发布不同 target。一个常见判断标准是:别人只看 Marketplace 页面和 README,就能知道这个扩展值不值得安装。package.json 里哪些字段最容易出错package.json 是 Marketplace 读取扩展信息的核心文件。下面是一个较完整的示例:{ "name": "my-extension", "displayName": "My Extension", "description": "A useful VS Code extension", "version": "1.0.0", "publisher": "your-publisher-name", "engines": { "vscode": "^1.80.0" }, "categories": ["Other"], "keywords": ["utility", "productivity", "vscode-extension"], "icon": "icon.png", "repository": { "type": "git", "url": "https://github.com/username/my-extension" }, "license": "MIT"}几个字段要特别留意:name:扩展包名,发布后频繁改名会影响识别。displayName:展示给用户看的名称,可以比 name 更自然。publisher:必须和 Marketplace 发布者名称完全一致,不是 Azure DevOps 组织名随便填一个就行。version:每次正式发布都必须递增,否则会被拒绝。engines.vscode:表示扩展支持的 VS Code 版本范围,别写得过低,也别为了追新写得太高。categories:尽量选择贴近功能的分类,分类乱填会影响用户理解。keywords:写用户可能搜索的词,不要堆无关关键词。如果扩展有命令、配置项或菜单入口,还要确认 contributes 字段里的命令 ID、标题和实际代码一致。命令能注册成功,但标题写得模糊,也会影响用户第一次使用的体验。如何创建 Marketplace 发布者发布 VS Code 扩展需要先有发布者,也就是 publisher。通常流程是:打开 Visual Studio Marketplace 的管理页面:https://marketplace.visualstudio.com/manage使用 Microsoft 账号登录。创建一个新的 publisher,填写唯一的发布者名称、显示名称和联系邮箱。把创建好的 publisher 名称写入扩展的 package.json。这里最容易混淆的是发布者名称和显示名称。发布者名称是扩展唯一标识的一部分,例如 publisher.extension-name;显示名称只是市场页面上给人看的名字。真正发布时,package.json 里的 publisher 要填发布者名称。如何创建 Azure DevOps PATvsce publish 需要访问令牌,也就是 Azure DevOps 的 Personal Access Token。它不是 Microsoft 账号密码,也不建议使用权限过大的令牌。创建步骤大致如下:打开 https://dev.azure.com/ 并登录同一个 Microsoft 账号。进入用户设置中的 Personal access tokens。创建新 token。Organization 建议选择可访问的组织范围。Scope 选择 Marketplace 的 Manage 权限。生成后立刻复制保存,页面关闭后通常无法再次查看明文。令牌拿到后,不要写进仓库,也不要放到 README、CI 日志或截图里。最稳妥的做法是只在本机 vsce login 时粘贴,CI 发布则放到平台的 Secret 变量中。安装并登录 vscevsce 是发布 VS Code 扩展最常用的命令行工具,现在包名是 @vscode/vsce。npm install -g @vscode/vscevsce --versionvsce login your-publisher-name命令会要求输入 PAT。登录成功后,本机会保存认证信息。后续执行 vsce publish 时就不必每次重复输入。如果登录失败,优先检查三点:publisher 名称是否拼错、PAT 是否过期、PAT 是否包含 Marketplace Manage 权限。先打包成本地 VSIX 文件不要第一次就直接发布。先打包成本地 .vsix 文件,再安装验证,能省掉很多尴尬。vsce packagevsce package --out my-extension-1.0.0.vsix在 VS Code 中可以通过命令面板选择 “Extensions: Install from VSIX…” 安装本地包。重点测试这些内容:扩展是否能被激活。命令是否能执行。配置项是否显示正常。README、图标、仓库链接是否正确。打包后是否缺少运行时文件。如果发现包里混进了测试文件、源码草稿或大体积资源,可以通过 .vscodeignore 排除。例如:.vscode/**src/**test/***.map*.vsixnode_modules/.cache/**注意不要误删运行时需要的编译产物。如果扩展运行依赖 dist,就不能把 dist/** 排除掉。如何首次发布扩展确认本地 VSIX 没问题后,可以执行:vsce publish这个命令会读取 package.json,打包并上传到 Marketplace。首次发布成功后,市场页面不一定立刻完全可见,搜索索引和统计数据可能有延迟。如果希望发布时自动递增版本,可以使用:vsce publish patchvsce publish minorvsce publish majorvsce publish 1.1.0团队协作时要注意把版本变更提交到 Git,否则下一位同事可能基于旧版本继续发布,导致版本号冲突。预发布版本怎么发如果新功能还不想推给所有用户,可以发布预发布版本:vsce publish --pre-release预发布适合测试较大的功能改动,例如重构语言服务、改动配置格式、引入新 UI。它能让愿意尝鲜的用户提前安装,但不应该拿来替代正常测试。需要按平台发布时怎么处理纯 TypeScript 扩展通常不需要区分平台。但如果扩展包含原生依赖、平台二进制文件,或者对不同系统有不同产物,就要考虑 target。vsce package --target win32-x64vsce package --target linux-x64vsce package --target darwin-arm64vsce publish --target win32-x64是否需要多平台发布,取决于扩展是否真的包含平台相关文件。不要为了看起来专业而拆平台包,纯 JS 扩展拆了反而增加维护成本。版本管理应该怎么做VS Code 扩展一般使用语义化版本:major:有不兼容变更,例如配置字段改名、命令行为明显变化。minor:增加新功能,但不破坏现有用户使用方式。patch:修复 bug、优化文档、处理兼容性小问题。版本号不是装饰。用户看到频繁的 major 版本,会担心扩展不稳定;长期不更新 patch,又会怀疑项目没人维护。更新、下架和删除版本更新扩展的流程很简单:修改代码,更新版本,重新发布。vsce publish patch如果确实需要下架扩展,可以使用 unpublish。实际命令通常需要带上发布者和扩展名,格式类似:vsce unpublish your-publisher-name.my-extension下架是高风险操作。已经安装的用户、文档链接、依赖说明都会受到影响。除非扩展有严重安全问题、侵权问题或完全废弃,不建议轻易下架。Marketplace 页面怎么写更容易被安装用户通常会先看三样东西:扩展能解决什么问题、怎么用、是否可信。描述开头直接说用途:不要用“一个强大的插件”这类空话。README 放最短可用路径:安装后第一步点哪里、执行什么命令、预期看到什么结果。截图只放必要内容:截图要能解释功能,不要放大段无关 UI。关键词贴近搜索意图:例如 formatter、snippet、theme、debugger、productivity。仓库和许可证清楚:开发者工具类扩展尤其需要可信度。常见发布失败原因publisher 不匹配package.json 里的 publisher 和 Marketplace 创建的 publisher 不一致,会导致发布失败。大小写、短横线、拼写都要对齐。PAT 权限不够只创建了普通 Azure DevOps token,但没有 Marketplace Manage 权限,vsce login 或 vsce publish 会失败。重新生成 PAT 比反复改命令更快。version 没有递增同一个版本不能重复发布。修复一个错别字也要递增 patch 版本。.vscodeignore 排除了运行文件为了减小体积把 dist、out 或资源文件排除了,结果本地源码能跑,安装 VSIX 后不能跑。发布前安装本地包就是为了抓这类问题。包体里包含敏感信息不要把 .env、测试 token、私钥、内部接口文档打进扩展包。发布前可以解压 .vsix 看一眼内容,尤其是公司项目转开源时更要小心。一个更稳妥的发布流程实际项目里可以按这个顺序走:本地跑测试和 lint。检查 package.json、README、LICENSE、icon。更新 changelog 和版本号。执行 vsce package。在 VS Code 里安装生成的 VSIX。用真实场景试一遍核心功能。确认 .vsix 中没有敏感文件和无关大文件。执行 vsce publish。打开 Marketplace 页面检查展示效果。发布 VS Code 扩展不难,难的是把账号、令牌、版本、包内容这些细节处理稳定。流程固定下来后,每次发布只需要关注两个问题:这次改动是否值得发一个新版本,以及用户安装后能不能马上用起来。
服务端阅读 06月6日 20:29

Docker Swarm 怎么用?集群部署和运维命令

Docker Swarm 是 Docker 自带的容器编排工具——不需要额外安装,几条命令就能把多台服务器组成集群,部署高可用服务。和 Kubernetes 比,Swarm 简单得多,适合中小规模部署。Swarm vs Kubernetes:怎么选| 维度 | Docker Swarm | Kubernetes ||------|-------------|------------|| 复杂度 | 低(几条命令) | 高(概念多、配置复杂) || 学习成本 | 半天 | 几周 || 适用规模 | 3-50 节点 | 10-10000+ 节点 || 自动扩缩容 | 手动 | 自动(HPA) || 自愈能力 | 有(重启失败容器) | 强(多种控制器) || 生态 | Docker 原生 | CNCF 全家桶 |选 Swarm 的情况:团队小、服务器少、不想花时间学 K8s选 K8s 的情况:大规模部署、需要自动扩缩容、团队有 K8s 经验初始化集群Manager 节点# 在第一台服务器上初始化docker swarm init --advertise-addr 192.168.1.10# 输出加入命令# docker swarm join --token SWMTKN-xxx 192.168.1.10:2377Worker 节点# 在其他服务器上执行加入命令docker swarm join --token SWMTKN-xxx 192.168.1.10:2377查看集群状态# 查看所有节点docker node ls# 输出# ID HOSTNAME STATUS AVAILABILITY MANAGER STATUS# abc123 * node1 Ready Active Leader# def456 node2 Ready Active# ghi789 node3 Ready ActiveManager 节点负责调度和管理,Worker 节点只跑容器。Manager 也会跑容器——小集群不需要单独的管理节点。节点管理# 标记节点角色(让某些任务只跑在特定节点)docker node update --label-add role=backend node2docker node update --label-add role=database node3# 排空节点(维护时把容器迁移走)docker node update --availability drain node2# 恢复节点docker node update --availability active node2部署服务基本部署# 部署一个服务,3 个副本docker service create \ --name myapp \ --replicas 3 \ --publish 3000:3000 \ myapp:latestSwarm 自动把 3 个副本分散到不同节点,内置负载均衡——访问任意节点的 3000 端口都会被路由到健康的副本。使用 docker-compose 部署# docker-compose.ymlservices: api: image: myapp:latest ports: - "3000:3000" deploy: replicas: 3 update_config: parallelism: 1 # 每次更新 1 个副本 delay: 10s # 间隔 10 秒 failure_action: rollback # 失败自动回滚 rollback_config: parallelism: 0 # 回滚时一次全部替换 restart_policy: condition: on-failure delay: 5s max_attempts: 3 resources: limits: cpus: '1.0' memory: 2G redis: image: redis:7 deploy: replicas: 1 placement: constraints: - node.role == manager # 只跑在 manager 节点 postgres: image: postgres:16 volumes: - pg_data:/var/lib/postgresql/data deploy: replicas: 1 placement: constraints: - node.labels.role == database # 只跑在标记了 database 的节点volumes: pg_data:# 部署docker stack deploy -c docker-compose.yml myapp# 查看服务docker service ls# 查看某个服务的副本docker service ps myapp_api滚动更新# 更新镜像版本docker service update --image myapp:v2.0 myapp_api# 查看更新进度docker service ps myapp_apiupdate_config 控制更新策略:parallelism: 1 每次只更新 1 个副本delay: 10s 每个副本更新后等 10 秒再更新下一个failure_action: rollback 新版本启动失败时自动回滚到旧版本手动回滚# 回滚到上一版本docker service rollback myapp_apiOverlay 网络:跨主机通信Swarm 模式下的 Overlay 网络让不同主机上的容器直接通信:# 创建 Overlay 网络docker network create -d overlay my-netservices: api: networks: - my-net redis: networks: - my-netnetworks: my-net: external: trueapi 容器在 node1,redis 在 node2——通过 redis:6379 直接访问,和单机体验一样。配置和敏感信息Config(非敏感配置)# 创建配置echo "server.port=8080" | docker config create app_config -services: api: configs: - source: app_config target: /app/config.propertiesconfigs: app_config: external: trueSecret(敏感信息)# 创建 Secretecho "db_password_123" | docker secret create db_password -services: api: secrets: - db_passwordsecrets: db_password: external: trueSecret 在容器内挂载为 /run/secrets/db_password,只有容器内可读,不会出现在 docker inspect 里。常用运维命令# 查看服务日志docker service logs myapp_api# 扩缩容docker service scale myapp_api=5# 查看服务详情docker service inspect myapp_api# 删除服务docker service rm myapp_api# 删除整个 Stackdocker stack rm myapp# 查看集群事件docker events --filter type=service什么时候该从 Swarm 迁移到 K8s需要自动扩缩容(HPA)需要 CronJob(定时任务)需要 Ingress 控制器(7 层路由)需要 Pod 级别的健康检查集群超过 50 个节点在以上需求出现之前,Swarm 够用且省心。
服务端阅读 06月6日 20:29

Docker 怎么限制容器资源?CPU、内存和磁盘 IO 配置

不限制容器资源,一个失控的容器就能吃光宿主机内存,拖垮同一台机器上的所有服务。Docker 提供了 CPU、内存、磁盘 IO 的精细限制手段,核心是 docker run 的资源参数和 docker-compose 的 deploy.resources 配置。内存限制:最常用也最重要# 限制最大内存 4GBdocker run -d --name myapp --memory=4g myapp:latest# 限制内存 + 禁用 swapdocker run -d --name myapp --memory=4g --memory-swap=4g myapp:latest--memory-swap=4g 等于 --memory 的值意味着容器不能用 swap。如果 --memory-swap 比 --memory 大,差值就是允许的 swap 大小。docker-compose 配置services: app: image: myapp:latest deploy: resources: limits: memory: 4G # 硬上限,超过会被 OOM Kill reservations: memory: 1G # 软保底,调度时保证至少 1GOOM 时会发生什么容器内存超过 limits.memory 时,内核 OOM Killer 杀掉容器里内存占用最大的进程:# 检查容器是否被 OOM 杀掉docker inspect myapp --format '{{.State.OOMKilled}}'# true = 被 OOM 杀了# 查看 OOM 事件dmesg | grep -i oomOOM 优先级调整多个容器抢内存时,可以设 OOM 优先级:# 不容易被 OOM Kill(-1000 到 1000,越小越不容易被杀)docker run -d --name important-app --oom-score-adj=-500 myapp# 容易被 OOM Kill(优先杀这个)docker run -d --name cache-app --oom-score-adj=500 redis核心服务(数据库、API 网关)设低值,缓存类服务(Redis、CDN)设高值。CPU 限制限制 CPU 核数# 最多用 2 核docker run -d --cpus=2.0 myapp# 最多用 0.5 核docker run -d --cpus=0.5 myappservices: app: deploy: resources: limits: cpus: '2.0' reservations: cpus: '0.5'--cpus=2.0 不是绑核——容器可以在任意 2 个核心上运行,只是总使用时间不超过 200%。绑核用 --cpuset-cpus:# 只在第 0 和第 2 个核心上运行docker run -d --cpuset-cpus=0,2 myapp# 只在第 1-3 个核心上运行docker run -d --cpuset-cpus=1-3 myapp绑核适合对 CPU 缓存一致性敏感的应用(如高性能计算),一般 Web 服务不需要。CPU 权重多个容器抢 CPU 时,按权重分配:# 默认权重 1024# 高权重 = 抢到更多 CPU 时间docker run -d --cpu-shares=2048 high-priority-appdocker run -d --cpu-shares=512 low-priority-app注意:--cpu-shares 只在 CPU 资源紧张时生效。CPU 空闲时低权重容器也能用满 CPU。磁盘 IO 限制限制容器读写磁盘的速率,防止一个容器把磁盘 IO 吃光:# 限制写速率 10MB/sdocker run -d \ --device-write-bps /dev/sda:10mb \ myapp# 限制读速率 20MB/sdocker run -d \ --device-read-bps /dev/sda:20mb \ myapp# 限制 IOPS(每秒 IO 操作数)docker run -d \ --device-write-iops /dev/sda:1000 \ myapp磁盘 IO 限制在 docker-compose 里不支持——需要 docker run 方式启动。PIDs 限制:防止进程炸弹限制容器内的进程数,防止 fork 炸弹:# 最多 100 个进程docker run -d --pids-limit=100 myappservices: app: deploy: resources: limits: pids: 100没有这个限制,一个容器可以 fork 出几千个进程,耗尽宿主机的 PID 表。运行时修改资源限制不需要重建容器就能调整限制:# 动态调整内存限制docker update --memory=8g myapp# 动态调整 CPU 限制docker update --cpus=4.0 myapp# 同时调整多个docker update --memory=8g --cpus=4.0 myapp注意:docker update 不能修改 --pids-limit 和 --cpuset-cpus——这些需要重建容器。资源限制最佳实践| 容器类型 | 内存 | CPU | 其他 ||---------|------|-----|------|| Web API | 2-4G | 1-2 核 | pids-limit: 200 || 数据库 | 4-16G | 2-4 核 | 绑核、禁 swap || 缓存 Redis | 2-8G | 1-2 核 | 禁 swap || 日志采集 | 512M-1G | 0.5 核 | 限制磁盘 IO || 后台任务 | 1-2G | 0.5-1 核 | pids-limit: 50 |原则:所有生产容器都设内存限制,防止单个容器拖垮宿主机数据库容器禁 swap(--memory-swap 等于 --memory)核心服务设低 OOM 优先级不确定资源需求时先设宽松限制,用 docker stats 观察实际用量再收紧
服务端阅读 06月6日 20:29

Docker 私有仓库怎么搭建?Registry 和 Harbor 选型

Docker Hub 是公开的,你的私有镜像不想让外人看到。企业内部需要一个私有 Registry 存放自己的镜像,CI/CD 推送镜像到私有仓库,生产服务器从私有仓库拉取。最简方案:Docker 官方 RegistryDocker 官方提供了一个极简的 Registry 镜像,几分钟就能跑起来:# 启动私有仓库docker run -d -p 5000:5000 --name registry registry:2# 推送镜像docker tag myapp:latest localhost:5000/myapp:latestdocker push localhost:5000/myapp:latest# 拉取镜像docker pull localhost:5000/myapp:latest这就够了——一个能推能拉的私有仓库。但生产环境需要持久化存储、认证、TLS。持久化存储services: registry: image: registry:2 ports: - "5000:5000" volumes: - registry_data:/var/lib/registryvolumes: registry_data:默认镜像存在 /var/lib/registry,用 Volume 持久化防止容器重启后镜像丢失。启用 TLSHTTP 模式下 Docker 客户端会拒绝推送(安全限制)。加 TLS:services: registry: image: registry:2 ports: - "5000:5000" environment: REGISTRY_HTTP_TLS_CERTIFICATE: /certs/domain.crt REGISTRY_HTTP_TLS_KEY: /certs/domain.key volumes: - registry_data:/var/lib/registry - ./certs:/certs:ro# 自签名证书(测试用)mkdir certsopenssl req -newkey rsa:4096 -nodes -sha256 \ -keyout certs/domain.key \ -x509 -days 365 \ -out certs/domain.crt \ -subj "/CN=registry.example.com"所有拉取镜像的机器都要信任这个证书:# 把证书复制到 Docker 信任目录sudo mkdir -p /etc/docker/certs.d/registry.example.com:5000sudo cp certs/domain.crt /etc/docker/certs.d/registry.example.com:5000/ca.crtsudo systemctl restart docker基本认证# 创建用户密码文件mkdir authdocker run --entrypoint htpasswd httpd:2 -Bbn admin password123 > auth/htpasswdservices: registry: image: registry:2 environment: REGISTRY_AUTH: htpassd REGISTRY_AUTH_HTPASSWD_REALM: "Registry Realm" REGISTRY_AUTH_HTPASSWD_PATH: /auth/htpasswd volumes: - ./auth:/auth:ro# 登录后才能推送docker login registry.example.com:5000# Username: admin# Password: password123Harbor:企业级私有仓库Docker 官方 Registry 功能太简陋——没有 Web 界面、没有镜像扫描、没有 RBAC。Harbor 是 VMware 开源的企业级 Registry,补全了这些能力。Docker Compose 部署 Harbor# 下载 Harborwget https://github.com/goharbor/harbor/releases/download/v2.10.0/harbor-offline-installer-v2.10.0.tgztar xzf harbor-offline-installer-v2.10.0.tgzcd harbor# 编辑配置cp harbor.yml.tmpl harbor.yml# harbor.yml 关键配置hostname: registry.example.comhttp: port: 80https: port: 443 certificate: /certs/domain.crt private_key: /certs/domain.keyharbor_admin_password: Harbor12345data_volume: /data/harbor# 安装./install.sh# 访问 Web 界面# https://registry.example.com# 用户名: admin 密码: Harbor12345Harbor 的企业级功能| 功能 | 说明 ||------|------|| Web 管理界面 | 浏览镜像、标签、层信息 || RBAC | 项目级权限控制(只读/开发/管理员) || 镜像扫描 | Trivy 集成,自动扫描漏洞 || 镜像签名 | Docker Content Trust,防止篡改 || 垃圾回收 | 清理无引用的镜像层,回收磁盘 || 复制规则 | 跨 Registry 同步镜像 || 审计日志 | 记录所有推送/拉取操作 |项目和权限Harbor 用"项目"组织镜像,类似 GitHub 的仓库:项目: frontend ├── api-gateway:v1.2.3 ├── web-app:v2.0.0 └── auth-service:v1.0.0项目: backend ├── user-service:v3.1.0 └── order-service:v2.5.0每个项目可以设不同的成员和权限——前端团队只能推拉 frontend 项目,后端团队只能推拉 backend 项目。云厂商托管 Registry不想自己运维 Registry,用云厂商的托管服务:| 云厂商 | 服务名 | 特点 ||--------|--------|------|| AWS | ECR | 与 IAM 集成,按存储计费 || GCP | Artifact Registry | 与 GCP IAM 集成 || Azure | ACR | 与 Azure AD 集成 || 阿里云 | 容器镜像服务 | 国内访问快 |# AWS ECR 示例aws ecr get-login-password | docker login --username AWS --password-stdin 123456789.dkr.ecr.us-east-1.amazonaws.comdocker tag myapp:latest 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:latestdocker push 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:latest托管服务的优势:不需要维护服务器、自动 TLS、自动漏洞扫描。劣势:网络延迟(国内拉海外镜像慢)、费用随存储量增长。选择决策| 场景 | 推荐方案 ||------|---------|| 个人/小团队测试 | Docker 官方 Registry || 团队 5-20 人 | Harbor(Docker Compose 部署) || 企业生产环境 | Harbor(高可用部署) || 全部在云上 | 云厂商托管 Registry || 国内访问为主 | 阿里云容器镜像服务 |起步建议:先用官方 Registry 跑起来,等需要 Web 界面和权限管理时迁移到 Harbor。
服务端阅读 06月6日 20:29

Docker 容器日志怎么管理?轮转、结构化和聚合方案

容器日志管理不只是 docker logs ——那只能看单个容器的标准输出。生产环境需要日志轮转防止磁盘撑满、日志聚合实现集中查询、结构化日志方便检索。这篇从本地管理到集中式方案逐步展开。docker logs 的局限docker logs myapp # 查看日志docker logs -f myapp # 实时跟踪docker logs --tail 100 myapp # 最近 100 行问题:容器删了日志就没了多容器没法一起搜没有日志轮转,磁盘会被撑满没有结构化字段,搜索靠 grep本地日志轮转:防止磁盘撑满json-file 驱动的轮转配置services: app: image: myapp:latest logging: driver: json-file options: max-size: "10m" # 单个日志文件最大 10MB max-file: "3" # 最多保留 3 个文件这样每个容器最多占 30MB 日志(10MB × 3 个文件)。超过 10MB 自动轮转,超过 3 个文件自动删除最老的。local 驱动:更省磁盘services: app: logging: driver: local options: max-size: "10m" max-file: "5"local 驱动用压缩存储,同样内容比 json-file 省 50% 空间。而且日志格式更易读。全局配置不想每个容器都写 logging 配置?在 daemon.json 里设全局默认:// /etc/docker/daemon.json{ "log-driver": "local", "log-opts": { "max-size": "10m", "max-file": "3" }}重启 Docker 后所有容器都用这个配置。强烈建议加上——我见过太多服务器因为 Docker 日志占满磁盘而崩溃。结构化日志:让检索更高效非结构化日志只能全文搜索。结构化日志可以按字段过滤:# Python - 用 structlog 输出 JSONimport structloglogger = structlog.get_logger()logger.info("user_login", user_id=123, ip="1.2.3.4")// Node.js - 用 pino 输出 JSONconst pino = require('pino')()pino.info({ userId: 123, action: 'login' }, 'User logged in')输出示例:{"level":"info","time":1704067200,"userId":123,"action":"login","msg":"User logged in"}在日志聚合平台里可以按 userId=123 或 action=login 精确过滤,不用全文搜索。日志级别管理# 动态调整日志级别(不需要重启容器)# Spring Bootcurl -X POST http://localhost:8080/actuator/loggers/com.example \ -d '{"configuredLevel": "DEBUG"}'# Node.js(需要应用支持)# 通过环境变量控制LOG_LEVEL=debug node app.js生产环境默认 INFO 级别,排查问题时临时切 DEBUG,不需要重新部署。日志聚合:集中式管理轻量方案:Grafana LokiLoki 只索引标签不索引正文,存储成本是 ELK 的 1/10:services: loki: image: grafana/loki:2.9.0 ports: - "3100:3100" promtail: image: grafana/promtail:2.9.0 volumes: - /var/lib/docker/containers:/var/lib/docker/containers:ro - ./promtail.yml:/etc/promtail/config.yml command: -config.file=/etc/promtail/config.yml grafana: image: grafana/grafana:10.3.0 ports: - "3000:3000"Promtail 自动从 Docker 容器目录读取日志,推送到 Loki。Grafana 查询:{container_name="myapp"} |= "error" | json | level="error"重量方案:EFK Stack需要全文搜索、复杂聚合时用 EFK(Elasticsearch + Fluentd + Kibana):services: elasticsearch: image: elasticsearch:8.12.0 environment: - discovery.type=single-node - xpack.security.enabled=false volumes: - es_data:/usr/share/elasticsearch/data fluentd: image: fluent/fluentd:v1.16 volumes: - ./fluentd/conf:/fluentd/etc ports: - "24224:24224" kibana: image: kibana:8.12.0 ports: - "5601:5601"容器配置 Fluentd 日志驱动:services: app: logging: driver: fluentd options: fluentd-address: localhost:24224 tag: myappEFK 最少需要 4GB 内存。团队小于 10 人用 Loki 就够了。日志管理最佳实践| 检查项 | 建议 ||--------|------|| 日志轮转 | 全局配 max-size: 10m, max-file: 3 || 日志级别 | 生产用 INFO,排查切 DEBUG || 结构化 | 用 JSON 格式输出 || 敏感信息 | 不在日志里打印密码、token || 聚合 | 小团队用 Loki,大团队用 EFK || 持久化 | 关键日志用 Volume 存储,不依赖容器可写层 || 监控 | 对 ERROR 日志设置告警 |起步建议:先配好本地日志轮转(10 分钟的事),再按需加 Loki 聚合。
服务端阅读 06月6日 20:29

Docker 容器间怎么通信?同一项目、跨项目和跨主机方案

两个容器要互相访问,怎么连通?同一个 compose 项目的容器用服务名直接访问,不同项目的容器需要共享网络,跨主机通信就得用 Overlay 网络。这篇按场景从简单到复杂讲清楚。同一 docker-compose 项目:默认网络Docker Compose 自动为每个项目创建一个网络,项目内的容器可以互相用服务名访问:# docker-compose.ymlservices: api: image: myapp-api ports: - "3000:3000" redis: image: redis:7 postgres: image: postgres:16# api 容器内直接用服务名访问curl http://redis:6379 # 访问 Rediscurl http://postgres:5432 # 访问 PostgreSQL不需要 IP,不需要 --link——Docker 内置 DNS 自动把服务名解析为容器 IP。自定义网络名默认网络名是 项目名_default。如果要自定义:services: api: networks: - frontend - backend redis: networks: - backendnetworks: frontend: backend:api 同时在 frontend 和 backend 两个网络里——可以访问两边。redis 只在 backend 里——frontend 网络的容器访问不到 redis,实现网络隔离。不同 docker-compose 项目:外部网络两个独立的 compose 项目需要通信时,共享一个外部网络:# 创建共享网络docker network create shared-net# 项目 A: docker-compose.ymlservices: api: networks: - shared-netnetworks: shared-net: external: true# 项目 B: docker-compose.ymlservices: worker: networks: - shared-netnetworks: shared-net: external: true项目 A 的 api 和项目 B 的 worker 通过服务名互相访问。容器访问宿主机容器里需要访问宿主机上的服务(比如宿主机上的 MySQL):# 专用 DNS 名curl http://host.docker.internal:3306host.docker.internal 是 Docker Desktop 提供的特殊 DNS,自动解析为宿主机 IP。Linux 上需要手动添加:services: api: extra_hosts: - "host.docker.internal:host-gateway"容器间直接用 IP不推荐但有时需要:# 查看容器 IPdocker inspect myapp --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'# 查看所有容器的 IPdocker network inspect bridge --format '{{range .Containers}}{{.Name}}: {{.IPv4Address}}{{end}}'容器 IP 每次重启可能变化——硬编码 IP 是反模式,应该用服务名或 DNS。端口映射:容器对外暴露服务services: api: ports: - "3000:3000" # 宿主机 3000 → 容器 3000 - "8080:80" # 宿主机 8080 → 容器 80 - "127.0.0.1:3306:3306" # 只允许本机访问127.0.0.1:3306:3306 这种写法限制了只监听 loopback 接口——外部无法访问,只有宿主机本身可以连。适合数据库等不需要对外暴露的服务。端口冲突排查# 查看宿主机端口占用lsof -i :3000# 或ss -tlnp | grep 3000# Docker 占用的端口docker port myapp网络模式选择| 模式 | 说明 | 适用场景 ||------|------|---------|| bridge(默认) | 容器有独立 IP,通过 NAT 访问外部 | 大部分场景 || host | 容器直接用宿主机网络栈,无隔离 | 需要极致网络性能 || none | 无网络 | 离线计算任务 || overlay | 跨主机容器通信 | Docker Swarm / 多主机 |host 模式services: api: network_mode: hosthost 模式下容器没有独立 IP,直接用宿主机的端口和网络。好处是没有 NAT 性能损耗,坏处是端口冲突风险高(容器和宿主机共享端口空间)。不要在生产环境用 host 模式——失去了网络隔离,一个容器被攻破等于宿主机被攻破。跨主机通信:Overlay 网络Docker Swarm 多主机环境下,不同主机上的容器需要通信:# 创建 Overlay 网络docker network create -d overlay my-overlay# 在 Overlay 网络上启动服务docker service create --network my-overlay --name api myappdocker service create --network my-overlay --name worker myworkerOverlay 网络底层用 VXLAN 隧道——api 和 worker 即使跑在不同的物理机上,也能通过服务名直接通信,和单机体验一致。DNS 排查容器间访问不通时,先排查 DNS:# 进入容器测试 DNS 解析docker exec myapp nslookup redisdocker exec myapp ping redis# 查看 DNS 配置docker exec myapp cat /etc/resolv.conf# 临时指定 DNSdocker run --dns 8.8.8.8 myapp常见问题:自定义了 docker-compose.yml 的 networks 但忘了在服务里引用,或者两个服务不在同一个网络里。