服务端面试题手册

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

服务端阅读 06月4日 23:11

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

FFmpeg Filter语法详解:缩放、裁剪、叠加和组合滤镜

FFmpeg 的 Filter 是它区别于其他转码工具的核心能力——不只是格式转换,而是对画面的像素、音频的采样点做任意变换。但 Filter 的语法是出了名的难读:方括号标签、分号逗号混用、多个输入输出的管线。这篇文章先把语法规则讲透,再按场景列举常用滤镜。Filter 语法:三分钟搞懂简单滤镜 vs 复杂滤镜-vf "滤镜链":简单视频滤镜,单个输入,单个输出-af "滤镜链":简单音频滤镜,同上-filter_complex "滤镜图":复杂滤镜,可以有多个输入输出、分支和合并能用 -vf/-af 解决的,不要用 -filter_complex——简单滤镜更快更不容易出错。语法规则# 逗号:串联同一链的滤镜,前一个的输出是后一个的输入-vf "scale=1280:720,crop=640:360"# 分号:分隔不同的滤镜链(用于 -filter_complex)-filter_complex "[0:v]scale=1280:720[v1];[1:v]scale=640:360[v2]"# 方括号标签:给输入/输出命名-filter_complex "[0:v]scale=1280:720[big];[1:v]scale=640:360[small];[big][small]overlay=10:10"[0:v] 表示第一个输入的视频流,[0:a] 表示第一个输入的音频流。[big]、[small] 是自定义标签,后续滤镜用这个标签引用。最常见的语法错误# 错误:简单滤镜里用了分号-vf "scale=1280:720;crop=640:360" # 报错# 正确:简单滤镜用逗号-vf "scale=1280:720,crop=640:360" # OK视频滤镜缩放(scale)# 固定分辨率ffmpeg -i input.mp4 -vf "scale=1280:720" output.mp4# 保持宽高比(只指定宽度,高度自动计算)ffmpeg -i input.mp4 -vf "scale=1280:-1" output.mp4# 等比缩小到一半ffmpeg -i input.mp4 -vf "scale=iw/2:ih/2" output.mp4# 限制最大尺寸(不超过 1280x720,小的保持原尺寸)ffmpeg -i input.mp4 -vf "scale='min(1280,iw)':'min(720,ih)'" output.mp4-1 是"自动计算"的写法,FFmpeg 根据原始宽高比算出高度。注意:某些编码器要求宽高都是偶数,如果算出来是奇数会报错,可以用 scale=1280:trunc(ow/2)*2 强制偶数。裁剪(crop)# 从中心裁剪 640x480(默认居中)ffmpeg -i input.mp4 -vf "crop=640:480" output.mp4# 指定裁剪起点(左上角偏移 100,50)ffmpeg -i input.mp4 -vf "crop=640:480:100:50" output.mp4# 自动检测黑边并裁掉ffmpeg -i input.mp4 -vf "cropdetect" -f null - 2>&1 | grep crop# 然后用输出的 crop 值ffmpeg -i input.mp4 -vf "crop=1920:800:0:140" output.mp4cropdetect 是调优利器——先跑一遍检测黑边参数,再用 crop 裁掉。叠加(overlay)给视频加 logo、画中画都用 overlay:# 左上角加 logoffmpeg -i video.mp4 -i logo.png -filter_complex "overlay=10:10" output.mp4# 右下角加 logo(W=视频宽, w=logo宽)ffmpeg -i video.mp4 -i logo.png -filter_complex "overlay=W-w-10:H-h-10" output.mp4# 居中画中画ffmpeg -i main.mp4 -i pip.mp4 -filter_complex "[1:v]scale=320:240[pip];[0:v][pip]overlay=(W-w)/2:(H-h)/2" output.mp4overlay 的坐标用 x:y 格式,支持表达式。W 和 H 是主视频的宽高,w 和 h 是叠加层的宽高。旋转(transpose)# 顺时针 90 度ffmpeg -i input.mp4 -vf "transpose=1" output.mp4# 逆时针 90 度ffmpeg -i input.mp4 -vf "transpose=2" output.mp4# 180 度(两次 90 度)ffmpeg -i input.mp4 -vf "transpose=1,transpose=1" output.mp4# 水平翻转(镜像)ffmpeg -i input.mp4 -vf "hflip" output.mp4旋转后分辨率会变(1080x1920 变 1920x1080),如果编码器有分辨率限制需要注意。文字水印(drawtext)# 静态文字ffmpeg -i input.mp4 -vf "drawtext=text='Hello':fontcolor=white:fontsize=24:x=10:y=10" output.mp3# 带阴影的文字(提高可读性)ffmpeg -i input.mp4 -vf "drawtext=text='Hello':fontcolor=white:fontsize=32:x=10:y=10:shadowcolor=black:shadowx=2:shadowy=2" output.mp4# 显示时间戳ffmpeg -i input.mp4 -vf "drawtext=text='%{pts\:hms}':fontcolor=white:fontsize=20:x=10:y=10" output.mp4drawtext 需要编译时启用 libfreetype。如果报 "Unknown filter 'drawtext'",说明你的 FFmpeg 没有这个支持。模糊和锐化# 高斯模糊ffmpeg -i input.mp4 -vf "gblur=sigma=2" output.mp4# 锐化ffmpeg -i input.mp4 -vf "unsharp=5:5:1.0" output.mp4模糊常用于背景虚化或隐私遮挡,锐化常用于低分辨率素材的提升(但过度锐化会产生光晕伪影)。音频滤镜音频滤镜用 -af,语法和视频一样:# 音量调整ffmpeg -i input.mp4 -af "volume=2.0" output.mp4# 淡入淡出ffmpeg -i input.mp4 -af "afade=t=in:st=0:d=3" output.mp4# 混合两路音频ffmpeg -i voice.mp3 -i bgm.mp3 -filter_complex "amix=inputs=2:duration=first" output.mp3# 延迟ffmpeg -i input.mp4 -af "adelay=500|500" output.mp4音频滤镜的详细用法参见"FFmpeg 音频处理"一文。组合滤镜实战四宫格ffmpeg -i v1.mp4 -i v2.mp4 -i v3.mp4 -i v4.mp4 -filter_complex "[0:v]scale=640:360[v0];[1:v]scale=640:360[v1];[2:v]scale=640:360[v2];[3:v]scale=640:360[v3]; [v0][v1]hstack[top];[v2][v3]hstack[bottom];[top][bottom]vstack" output.mp4hstack 水平拼接,vstack 垂直拼接。所有输入的分辨率必须一致。视频变速# 2 倍速ffmpeg -i input.mp4 -filter_complex "[0:v]setpts=0.5*PTS[v];[0:a]atempo=2.0[a]" -map "[v]" -map "[a]" output.mp4# 0.5 倍速ffmpeg -i input.mp4 -filter_complex "[0:v]setpts=2.0*PTS[v];[0:a]atempo=0.5[a]" -map "[v]" -map "[a]" output.mp4视频用 setpts 调速(乘以系数),音频用 atempo。atempo 范围 0.5-2.0,超出要链式:atempo=2.0,atempo=2.0 达到 4 倍速。加 logo + 时间戳 + 淡入ffmpeg -i input.mp4 -i logo.png -filter_complex "[0:v]drawtext=text='%{pts\:hms}':fontcolor=white:fontsize=20:x=10:y=10[vt]; [vt][1:v]overlay=W-w-10:10" -af "afade=t=in:st=0:d=2" output.mp4性能提示滤镜是 CPU 密集操作,优化思路:滤镜顺序:先 crop 再 scale,减少处理的像素量GPU 滤镜:NVIDIA 用 scale_npp 替代 scale,hwupload_cuda 后在 GPU 上做滤镜避免不必要的滤镜:每个滤镜都增加一帧的处理时间,去掉不必要的预览时降低分辨率:调参时加 scale=640:360 加快迭代,确认效果后再用原始分辨率输出
服务端阅读 06月4日 13:51

FFmpeg性能优化:硬件加速对比、preset选择和实测数据

FFmpeg 默认配置偏保守,处理 1080p 视频可能只有 0.5x 实时速度——1 小时的视频要转 2 小时。但调整几个参数就能提到 5x 甚至 20x。这篇文章从硬件加速、编码参数、多线程三个层面讲优化,并给出不同场景的推荐配置。硬件加速方案对比四种主流硬件加速,按你的 GPU 选择:| 方案 | 适用硬件 | 编码器 | 解码参数 | 特点 ||------|----------|--------|----------|------|| NVENC/NVDEC | NVIDIA GPU | h264nvenc, hevcnvenc | -hwaccel cuda | 生态最成熟,质量接近 x264 medium || Intel QSV | Intel 集显/独显 | h264qsv, hevcqsv | -hwaccel qsv | 低延迟,适合转码服务器 || AMD AMF | AMD GPU | h264amf, hevcamf | -hwaccel d3d11va | Windows 为主 || VideoToolbox | Apple 芯片/Mac | h264_videotoolbox | -hwaccel videotoolbox | macOS 原生,M 系列芯片性能极强 |NVIDIA GPU 加速# GPU 解码 + GPU 编码(全程不经过 CPU,最快)ffmpeg -hwaccel cuda -hwaccel_output_format cuda -i input.mp4 -c:v h264_nvenc -preset fast -b:v 5M output.mp4# GPU 解码 + CPU 编码(质量要求高时)ffmpeg -hwaccel cuda -i input.mp4 -c:v libx264 -preset slow output.mp4# 指定使用哪块 GPU(多卡服务器)ffmpeg -hwaccel_device 0 -hwaccel cuda -i input.mp4 -c:v h264_nvenc output.mp4-hwaccel_output_format cuda 让解码后的帧留在 GPU 显存里,避免 GPU→CPU→GPU 的数据搬运。全程 GPU 处理比"GPU 解码 + CPU 编码"快 2-3 倍。Intel QSV 加速ffmpeg -hwaccel qsv -i input.mp4 -c:v h264_qsv -preset fast -b:v 5M output.mp4QSV 在 Intel 集显上性能不错,低功耗场景(如 NAS 转码)比 NVIDIA 方案更省电。但编码质量不如 NVENC。Apple VideoToolboxffmpeg -i input.mp4 -c:v h264_videotoolbox -b:v 5M output.mp4M1/M2/M3 芯片上 VideoToolbox 编码 4K 视频可以跑到实时 10x 以上。但 FFmpeg 对 VideoToolbox 的参数控制不如 NVENC 丰富。硬件加速的局限硬件编码器的 CRF 控制不如 x264 精细,同码率下质量通常比 x264 slow 低一档硬件编码器的 B 帧策略和参考帧数量受限,某些高级参数不支持如果要用滤镜(如 drawtext、overlay),帧通常需要从 GPU 搬回 CPU,抵消加速效果不同硬件编码器的输出比特率波动大,不适合对码率有严格要求的场景编码参数优化x264 preset 选择# 追求速度(实时转码、预览)ffmpeg -i input.mp4 -c:v libx264 -preset veryfast -crf 23 output.mp4# 平衡速度和质量(通用场景)ffmpeg -i input.mp4 -c:v libx264 -preset medium -crf 23 output.mp4# 追求质量(离线存档)ffmpeg -i input.mp4 -c:v libx264 -preset slow -crf 18 output.mp4实测数据(1080p 25fps,i7-12700K):| preset | 编码速度 | 相同 CRF 下的体积 ||--------|----------|-------------------|| ultrafast | 120fps (5x) | +40% || veryfast | 80fps (3x) | +20% || fast | 55fps (2x) | +10% || medium | 35fps (1.4x) | 基准 || slow | 12fps (0.5x) | -8% |从 veryfast 到 slow,速度差 7 倍但体积只省 20%。大多数场景选 veryfast 或 fast 就够了。CRF 值选择CRF(Constant Rate Factor)是 x264 的质量控制参数,0 是无损,51 是最差:| CRF | 质量 | 场景 ||-----|------|------|| 18 | 视觉无损 | 存档、后期素材 || 23 | 默认 | 通用 || 28 | 明显压缩痕 | 预览、小体积 || 32 | 画质差 | 仅可辨认内容 |CRF 和 preset 是独立的:CRF 控制质量目标,preset 控制达到该目标的效率。同一 CRF 下,slow preset 的文件更小但质量一样。线程数# 自动检测(推荐,通常等于 CPU 核心数)ffmpeg -i input.mp4 -threads 0 -c:v libx264 output.mp4# 手动指定(给其他任务留核心)ffmpeg -i input.mp4 -threads 4 -c:v libx264 output.mp4x264 的线程数不是越多越好——超过 16 线程后编码效率开始下降(切片并行导致的参考帧问题)。4K 视频推荐 8-16 线程,1080p 推荐 4-8 线程。批量处理GNU Parallel 多文件并行# 同时处理 4 个文件,每个占一个 GPUfind input/ -name "*.mp4" | parallel -j 4 ffmpeg -i {} -c:v h264_nvenc output/{/.}.mp4多文件并行比单文件多线程更高效——FFmpeg 单进程的线程扩展性有限,但多个进程各占一个核心/GPU 可以线性扩展。分段处理大文件# 先分成小段ffmpeg -i input.mp4 -c copy -f segment -segment_time 60 segment_%03d.mp4# 并行转码每一段for f in segment_*.mp4; do ffmpeg -i "$f" -c:v libx264 -preset fast transcoded_"$f" &donewait# 合并echo "$(for f in transcoded_segment_*.mp4; do echo "file '$PWD/$f'"; done)" | ffmpeg -f concat -safe 0 -i - -c copy output.mp4分段处理要确保每段都有关键帧开头(-c copy 分段依赖关键帧),否则合并不连续。内存优化处理超大文件(4K+ 长视频)时内存可能不够:# 限制缓冲区大小ffmpeg -i input.mp4 -c:v libx264 -bufsize 2M output.mp4# 流式处理(不落盘)ffmpeg -i rtmp://source/live -c:v libx264 -f flv rtmp://target/live推荐配置速查| 场景 | 命令 ||------|------|| 实时转码 | -c:v h264_nvenc -preset fast -b:v 4M || 离线高质量 | -c:v libx264 -preset slow -crf 18 || 批量转码 | -c:v libx264 -preset veryfast -crf 23 + GNU Parallel || 小体积 | -c:v libx264 -preset medium -crf 28 || Mac 本地 | -c:v h264_videotoolbox -b:v 5M |
服务端阅读 06月4日 13:49

FFmpeg C API集成:解码、编码和最容易踩的坑

FFmpeg 的命令行工具够用的话,没人愿意碰它的 C API——函数多、生命周期复杂、版本间 API 变动频繁。但当你要做实时流处理、自定义滤镜链、或者把音视频能力嵌入产品时,命令行就不够了。这篇文章把 FFmpeg API 集成的核心流程讲清楚:从打开文件到解码、从编码到输出,以及最容易踩的坑。核心库和职责| 库 | 职责 | 你什么时候会用到 ||---|---|---|| libavformat | 封装格式(容器)读写 | 打开文件、读写 MP4/MKV/FLV 等 || libavcodec | 编解码 | 解码 H.264/AAC,编码 H.265/Opus || libavutil | 工具函数 | 内存分配、数学运算、日志 || libswscale | 图像缩放和色彩转换 | YUV → RGB、分辨率缩放 || libswresample | 音频重采样 | 采样率转换、声道布局转换 || libavfilter | 滤镜 | 视频加水印、音频降噪 |不需要全部链接,按需引入。只做解码的话,libavformat + libavcodec + libavutil 就够。解码流程:从文件到原始帧1. 初始化#include <libavformat/avformat.h>#include <libavcodec/avcodec.h>// FFmpeg 4.0+ 不需要手动注册,老版本需要:// av_register_all();avformat_network_init(); // 如果要处理网络流(RTMP/HLS)版本差异是最大的坑之一。FFmpeg 4.0 废弃了 av_register_all(),5.0 废弃了 avcodec_register_all(),新版自动注册所有内置编解码器。如果你的代码还要兼容 3.x,加版本判断:#if LIBAVFORMAT_VERSION_INT < AV_VERSION_INT(58, 0, 0) av_register_all();#endif2. 打开文件、找流AVFormatContext *fmt_ctx = NULL;int ret = avformat_open_input(&fmt_ctx, "input.mp4", NULL, NULL);if (ret < 0) { char errbuf[128]; av_strerror(ret, errbuf, sizeof(errbuf)); fprintf(stderr, "无法打开文件: %s\n", errbuf); return -1;}avformat_find_stream_info(fmt_ctx, NULL);avformat_open_input 只打开文件头,不读帧数据。avformat_find_stream_info 读几帧探测流信息(编码器、分辨率、帧率),如果省略这步,后续 codecpar 里的信息可能不完整。3. 找视频流、打开解码器// 找第一个视频流int video_idx = av_find_best_stream(fmt_ctx, AVMEDIA_TYPE_VIDEO, -1, -1, NULL, 0);if (video_idx < 0) { fprintf(stderr, "没找到视频流\n"); return -1;}// 打开解码器AVCodecParameters *codecpar = fmt_ctx->streams[video_idx]->codecpar;const AVCodec *codec = avcodec_find_decoder(codecpar->codec_id);AVCodecContext *codec_ctx = avcodec_alloc_context3(codec);avcodec_parameters_to_context(codec_ctx, codecpar);avcodec_open2(codec_ctx, codec, NULL);av_find_best_stream 比 手动遍历 nb_streams 更好——它会根据流的质量和语言偏好选最优的。4. 读取和解码帧FFmpeg 3.1+ 使用 send/receive 模型:AVPacket *pkt = av_packet_alloc();AVFrame *frame = av_frame_alloc();while (av_read_frame(fmt_ctx, pkt) >= 0) { if (pkt->stream_index != video_idx) { av_packet_unref(pkt); continue; } ret = avcodec_send_packet(codec_ctx, pkt); if (ret < 0 && ret != AVERROR(EAGAIN) && ret != AVERROR_EOF) { break; } while (ret >= 0) { ret = avcodec_receive_frame(codec_ctx, frame); if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) break; if (ret < 0) break; // frame->data[0] 就是 YUV 数据 // frame->width, frame->height, frame->format 可用 process_frame(frame); } av_packet_unref(pkt);}send/receive 是异步的:一个 packet 可能产生多个 frame(如 B 帧重排序),也可能多个 packet 才产出一个 frame(音频解码缓冲)。EAGAIN 不是错误,意思是"再发一个 packet 过来"。5. 冲刷解码器读取完所有帧后,解码器里可能还缓存着几帧,需要冲刷:avcodec_send_packet(codec_ctx, NULL); // NULL 触发冲刷while (avcodec_receive_frame(codec_ctx, frame) != AVERROR_EOF) { process_frame(frame);}编码流程:从原始帧到文件编码是解码的逆过程,但多了输出容器的初始化:// 创建输出上下文AVFormatContext *out_ctx = NULL;avformat_alloc_output_context2(&out_ctx, NULL, NULL, "output.mp4");// 添加视频流AVStream *out_stream = avformat_new_stream(out_ctx, NULL);const AVCodec *encoder = avcodec_find_encoder(AV_CODEC_ID_H264);AVCodecContext *enc_ctx = avcodec_alloc_context3(encoder);enc_ctx->width = 1280;enc_ctx->height = 720;enc_ctx->time_base = (AVRational){1, 25}; // 时间基准enc_ctx->framerate = (AVRational){25, 1};enc_ctx->pix_fmt = AV_PIX_FMT_YUV420P;enc_ctx->gop_size = 10;enc_ctx->max_b_frames = 1;// 如果输出是 MP4,需要把编码器参数拷到流里avcodec_open2(enc_ctx, encoder, NULL);avcodec_parameters_from_context(out_stream->codecpar, enc_ctx);// 打开输出文件avio_open(&out_ctx->pb, "output.mp4", AVIO_FLAG_WRITE);avformat_write_header(out_ctx, NULL);编码帧和写文件:AVPacket *enc_pkt = av_packet_alloc();// 对每个帧:avcodec_send_frame(enc_ctx, frame);while (avcodec_receive_packet(enc_ctx, enc_pkt) == 0) { enc_pkt->stream_index = out_stream->index; av_interleaved_write_frame(out_ctx, enc_pkt); av_packet_unref(enc_pkt);}// 冲刷编码器avcodec_send_frame(enc_ctx, NULL);while (avcodec_receive_packet(enc_ctx, enc_pkt) == 0) { enc_pkt->stream_index = out_stream->index; av_interleaved_write_frame(out_ctx, enc_pkt); av_packet_unref(enc_pkt);}// 写文件尾av_write_trailer(out_ctx);av_interleaved_write_frame 会自动按 DTS 排序后写入,比 av_write_frame 更安全。如果你不确定 DTS/PTS 的关系,用 interleaved 版本。资源释放FFmpeg 的资源释放顺序不能乱——先释放依赖项,再释放容器:av_frame_free(&frame);av_packet_free(&pkt);avcodec_free_context(&codec_ctx);avformat_close_input(&fmt_ctx); // 输入avcodec_free_context(&enc_ctx);av_write_trailer(out_ctx);avformat_close_input(&out_ctx); // 输出(如果用 avformat_close_input)// 或者:avio_closep(&out_ctx->pb);avformat_free_context(out_ctx);忘写 av_write_trailer 的话,MP4 文件的 moov atom 不会被写入,播放器无法打开。错误处理的正确姿势FFmpeg 的错误码是负数,用 av_strerror 转成可读字符串:char errbuf[AV_ERROR_MAX_STRING_SIZE] = {0};if (ret < 0) { av_strerror(ret, errbuf, sizeof(errbuf)); fprintf(stderr, "错误: %s (code=%d)\n", errbuf, ret);}常见错误码:AVERROR(EAGAIN) — 需要更多输入,不是真错误AVERROR_EOF — 流结束,正常退出条件AVERROR(EINVAL) — 参数无效,检查传参AVERROR(ENOMEM) — 内存不足,检查是否有泄漏AVERROR_EXIT — 被 callback 终止线程安全FFmpeg API 大部分不是线程安全的。多线程环境下:每个 AVCodecContext 只能被一个线程使用AVFormatContext 的读写操作需要加锁av_log 是线程安全的,可以放心在多线程中使用推荐模式:一个线程负责读取和解码,通过队列把帧传给另一个线程编码编译链接# 查看链接需要的库pkg-config --libs libavformat libavcodec libavutil# 典型编译命令gcc -o myapp myapp.c $(pkg-config --cflags --libs libavformat libavcodec libavutil libswscale)静态链接时注意依赖顺序:libavformat 依赖 libavcodec,libavcodec 依赖 libavutil,链接顺序要反过来写。
服务端阅读 06月4日 13:48

FFmpeg报错怎么排查?编码、格式和推流问题解决方案

用 FFmpeg 处理音视频,报错信息常常一句话带过,搜索引擎给你的答案又是"试试这条命令"却不解释原因。这篇文章按错误类型分类,每个问题先说原因再说解决方案。编码问题"Error while opening encoder"——编码器不可用原因:FFmpeg 编译时没包含该编码器,或者编码器名称拼写错了。# 先确认编码器是否可用ffmpeg -encoders | grep h264如果列表里没有 libx264,说明编译时没加 --enable-libx264。解决方案:重新编译 FFmpeg 或安装完整版。Ubuntu 上 sudo apt install ffmpeg 通常包含常用编码器;如果是从源码编译的,需要手动 enable。检查编码器名称也容易出错:NVIDIA GPU 编码器叫 h264_nvenc 不是 nvenc_h264,Intel QSV 叫 h264_qsv 不是 qsv_h264。编码速度太慢原因:默认 preset 是 medium,追求质量但速度一般。# 优先速度,牺牲一点质量ffmpeg -i input.mp4 -c:v libx264 -preset veryfast output.mp4# 极速(质量明显下降,适合预览)ffmpeg -i input.mp4 -c:v libx264 -preset ultrafast output.mp4# 有 GPU 的话直接用硬件编码ffmpeg -hwaccel cuda -i input.mp4 -c:v h264_nvenc output.mp4preset 从快到慢:ultrafast → veryfast → fast → medium → slow → veryslow。越慢质量越好、文件越小,但收益递减——slow 比 medium 慢 2-3 倍但只省 5-10% 体积。一般选 veryfast 或 fast 就够。音视频不同步原因通常是三种:源文件本身时间戳不对、编码时帧率设置错误、或者 -async 没处理音频漂移。# 方案一:强制音频同步(简单粗暴)ffmpeg -i input.mp4 -async 1 output.mp4# 方案二:重新编码音视频(更可靠)ffmpeg -i input.mp4 -c:v libx264 -c:a aac output.mp4# 方案三:指定正确帧率ffmpeg -i input.mp4 -r 25 output.mp4-async 1 让音频时间戳强制对齐到视频,简单有效但可能引入音频跳变。如果源文件 PTS(Presentation Time Stamp)本身错乱,需要用 -vsync cfr 强制固定帧率。格式问题"Unsupported codec"——格式不支持# 查看 FFmpeg 支持的所有格式ffmpeg -formats# 查看某格式支持的编码器ffmpeg -encoders | grep mp4最常见的场景:MKV 转 MP4 时 MKV 里有字幕流或特殊编码,MP4 容器不支持。解决方案是只保留音视频流:ffmpeg -i input.mkv -c:v libx264 -c:a aac -map 0:v:0 -map 0:a:0 output.mp4-map 手动选择要保留的流,忽略字幕等不兼容的。播放器兼容性差某些播放器(特别是老款电视、浏览器)对 MP4 的 H.264 profile 和 level 有要求:# 生成最大兼容性的 MP4ffmpeg -i input.mp4 -c:v libx264 -profile:v baseline -level 3.0 -c:a aac output.mp4baseline profile 去掉了 B 帧和高级编码特性,几乎所有设备都能播。代价是同质量下文件更大。加上 -movflags +faststart 让 moov atom 移到文件头部,网页播放不用等下载完:ffmpeg -i input.mp4 -c:v libx264 -c:a aac -movflags +faststart output.mp4流媒体问题RTMP 推流失败常见原因:网络不通、推流地址格式错误、编码格式不兼容。# 先确认网络连通ping rtmp.server.com# 推流时加 -re 按实际帧率发送(否则一口气全推出去,服务器跟不上)ffmpeg -re -i input.mp4 -c copy -f flv rtmp://server/live/stream_key# 加超时避免卡死ffmpeg -re -i input.mp4 -timeout 5000000 -c copy -f flv rtmp://server/live/stream_key-re 是推流的关键参数——不加的话 FFmpeg 会以最快速度把所有帧塞过去,服务器和观众端都会缓冲溢出。HLS 播放卡顿# 增加分片时长(默认 2 秒太短,改 10 秒减少请求次数)ffmpeg -i input.mp4 -f hls -hls_time 10 output.m3u8# 缩短 GOP(关键帧间隔),确保每个分片都有关键帧ffmpeg -i input.mp4 -c:v libx264 -g 25 -f hls -hls_time 10 output.m3u8-g 25 每 25 帧一个关键帧(25fps 下就是 1 秒一个),确保 HLS 分片边界对齐关键帧,否则切分时会出现花屏。滤镜问题"Invalid filterchain"——滤镜语法错误滤镜语法是 FFmpeg 里最容易出错的部分。简单滤镜用 -vf/-af,复杂滤镜用 -filter_complex:# 简单滤镜:单个输入ffmpeg -i input.mp4 -vf "scale=1280:720" output.mp4# 复杂滤镜:多个输入或需要中间标签ffmpeg -i input.mp4 -filter_complex "[0:v]scale=1280:720[v]" -map "[v]" output.mp4常见语法错误:标签没对应(定义了 [v] 但 -map 里写成 [out])、分号和逗号混用(逗号连接同一链内的滤镜,分号分隔不同链)。滤镜处理后速度变慢原因:滤镜是纯 CPU 计算,有些滤镜(如去隔行、缩放)非常吃资源。# 用 GPU 加速的缩放滤镜(NVIDIA)ffmpeg -hwaccel cuda -i input.mp4 -vf "scale_npp=1280:720" output.mp4# 优化滤镜顺序:先裁剪再缩放(处理更少的像素)ffmpeg -i input.mp4 -vf "crop=640:480,scale=320:240" output.mp4调试方法遇到问题先看详细日志:# 详细日志(看编码器选择、流信息)ffmpeg -v verbose -i input.mp4 output.mp4# 调试日志(看每一帧的处理)ffmpeg -v debug -i input.mp4 output.mp4# 只分析不编码(快速检查输入文件信息)ffmpeg -i input.mp4 -f null -性能基准测试:# 测试解码速度ffmpeg -benchmark -i input.mp4 -f null -# 测试编码速度ffmpeg -benchmark -i input.mp4 -c:v libx264 -f null --benchmark 会输出 utime(用户态耗时)、stime(内核态耗时)、rtime(实际耗时),用来对比不同参数的性能差异。
服务端阅读 06月4日 13:47

FFmpeg音频处理速查:格式转换、调音量、剪辑混音和降噪

FFmpeg 做音频处理,不需要打开 DAW,一条命令就能完成格式转换、音量调整、剪辑拼接、降噪混音。但很多教程只是罗列命令,参数什么意思、什么场景该选什么参数,一笔带过。这篇文章把常用音频操作按场景分类,每个命令都解释关键参数。提取和格式转换从视频里提取音频ffmpeg -i video.mp4 -vn -acodec copy audio.aac-vn 禁用视频流,-acodec copy 音频直接拷贝不重编码(速度极快,但输出格式必须和源一致)。如果要转格式,就不能 copy:ffmpeg -i video.mp4 -vn -c:a libmp3lame -b:a 192k audio.mp3-c:a 指定音频编码器,-b:a 指定比特率。192k 是 MP3 的甜点比特率,音质和体积平衡。常见格式互转# WAV → MP3(有损压缩,体积缩小约 10 倍)ffmpeg -i input.wav -c:a libmp3lame -b:a 192k output.mp3# FLAC → MP3(无损转有损,建议 320k 减少质量损失)ffmpeg -i input.flac -c:a libmp3lame -b:a 320k output.mp3# WAV → AAC(iOS/YouTube 常用)ffmpeg -i input.wav -c:a aac -b:a 128k output.m4a# WAV → Opus(同等音质下比特率最低,WebRTC 常用)ffmpeg -i input.wav -c:a libopus -b:a 64k output.opus选编码器的原则:兼容性选 AAC,体积最小选 Opus,无损存档选 FLAC,通用分享选 MP3。音量调整直接调整ffmpeg -i input.mp3 -af "volume=2.0" output.mp3 # 音量翻倍ffmpeg -i input.mp3 -af "volume=0.5" output.mp3 # 音量减半分贝调整ffmpeg -i input.mp3 -af "volume=3dB" output.mp3 # 增加 3dBffmpeg -i input.mp3 -af "volume=-3dB" output.mp3 # 减少 3dB分贝调整比倍数调整更直观——人耳对音量的感知是对数的,3dB 大约是"刚能听出差别"的增量。响度归一化ffmpeg -i input.mp3 -af "loudnorm" output.mp3loudnorm 把音频响度标准化到 EBU R128 标准(-16 LUFS)。批量处理多个文件时,用这个保证所有文件响度一致,避免切歌时音量忽大忽小。播客和音乐平台的响度标准都用它。采样率和声道调整采样率ffmpeg -i input.wav -ar 44100 output.wav # CD 音质ffmpeg -i input.wav -ar 48000 output.wav # 视频标准ffmpeg -i input.wav -ar 16000 output.wav # 语音识别常用(够用且省空间)采样率只能往下降不能无损往上升——44.1k 升 48k 不会凭空多出高频信息,反而可能引入重采样噪声。声道操作# 立体声转单声道ffmpeg -i input.mp3 -ac 1 output.mp3# 单声道转立体声(只是复制一份,不是真正的立体声)ffmpeg -i input.mp3 -ac 2 output.mp3# 提取左声道ffmpeg -i input.mp3 -af "pan=mono|c0=c0" output.mp3# 提取右声道ffmpeg -i input.mp3 -af "pan=mono|c0=c1" output.mp3剪辑和拼接按时间剪辑# 从第 10 秒开始,截取 30 秒ffmpeg -i input.mp3 -ss 00:00:10 -t 00:00:30 -c copy output.mp3# 从第 1 分钟到第 3 分钟ffmpeg -i input.mp3 -ss 00:01:00 -to 00:03:00 -c copy output.mp3-c copy 不重编码,秒级精度。如果需要帧级精度(某些格式),去掉 -c copy 让 FFmpeg 重编码,但速度会慢很多。拼接多个音频最可靠的方式是 concat 协议——先把文件列表写到文本里:echo "file 'part1.mp3'" > filelist.txtecho "file 'part2.mp3'" >> filelist.txtffmpeg -f concat -safe 0 -i filelist.txt -c copy output.mp3前提:所有片段的编码参数必须一致(采样率、声道数、编码器)。不一致的话用 -c copy 会出问题,去掉 copy 让 FFmpeg 重编码即可。音频特效淡入淡出# 开头淡入 3 秒ffmpeg -i input.mp3 -af "afade=t=in:st=0:d=3" output.mp3# 5 秒处开始淡出 3 秒ffmpeg -i input.mp3 -af "afade=t=out:st=5:d=3" output.mp3# 同时淡入淡出ffmpeg -i input.mp3 -af "afade=t=in:st=0:d=3,afade=t=out:st=7:d=3" output.mp3st 是起始时间,d 是持续时间。注意两个 afade 用逗号连在同一条 -af 里。混音# 两个音频混合,以第一个的时长为准ffmpeg -i bgm.mp3 -i voice.mp3 -filter_complex "amix=inputs=2:duration=first" output.mp3混合时音量会叠加,容易爆音。建议先降低各路音量再混合:ffmpeg -i bgm.mp3 -i voice.mp3 -filter_complex "[0:a]volume=0.3[bgm];[1:a]volume=1.0[voice];[bgm][voice]amix=inputs=2" output.mp3背景音乐 0.3 倍音量,人声 1.0 倍——播客和视频解说的典型配比。变速# 1.5 倍速ffmpeg -i input.mp3 -af "atempo=1.5" output.mp3# 0.75 倍速(慢放)ffmpeg -i input.mp3 -af "atempo=0.75" output.mp3atempo 的范围是 0.5 到 2.0。超出这个范围需要链式调用:# 4 倍速 = 2.0 × 2.0ffmpeg -i input.mp3 -af "atempo=2.0,atempo=2.0" output.mp3很多人不知道这个限制,直接写 atempo=4.0 会报错。降噪简单的滤波降噪:# 去除 200Hz 以下的低频噪声(电流声、风声)ffmpeg -i input.mp3 -af "highpass=f=200" output.mp3# 去除 3000Hz 以上的高频噪声(嘶嘶声)ffmpeg -i input.mp3 -af "lowpass=f=3000" output.mp3# 保留 200-3000Hz 的人声频段ffmpeg -i input.mp3 -af "highpass=f=200,lowpass=f=3000" output.mp3这种方式简单但粗糙——会把有效频段也一起砍掉。专业降噪需要用 Audition 或 RNNoise,FFmpeg 的滤波只能做初步处理。音频信息查看# 查看完整音频信息ffprobe -i input.mp3 -show_streams -select_streams a# 检测音量峰值和均值ffmpeg -i input.mp3 -af "volumedetect" -f null -volumedetect 输出 max_volume 和 mean_volume,用来判断是否需要调整音量。如果 max_volume 接近 0dB,说明已经接近削波,不要再增加音量。中间处理用无损格式多次处理同一个音频时(先调音量、再剪辑、再混音),每轮有损编码都会损失质量。正确做法:中间步骤用 WAV 或 FLAC,最后一步才转 MP3/AAC。# 第一步:调音量,输出无损 WAVffmpeg -i input.mp3 -af "volume=2.0" intermediate.wav# 第二步:剪辑 WAVffmpeg -i intermediate.wav -ss 10 -t 30 intermediate2.wav# 第三步:最终转 MP3ffmpeg -i intermediate2.wav -c:a libmp3lame -b:a 192k output.mp3