面试题手册

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

前端阅读 05月27日 20:01

什么是 Garfish 微前端框架,它的核心特点和应用场景是什么?

Garfish 是字节跳动开源的微前端框架,主要解决大型前端应用在跨团队协作、技术栈多样化和独立部署方面的痛点。与 qiankun 等方案相比,Garfish 在沙箱隔离和依赖共享上有独特设计,适合对隔离性和性能有更高要求的企业级场景。Garfish 的核心架构Garfish 的整体架构由以下核心模块组成:Loader(加载器):负责子应用资源的获取和解析,支持异步加载和预加载策略Sandbox(沙箱):隔离子应用的 JavaScript 执行环境,防止全局变量污染Router(路由):管理子应用的路由注册、匹配和切换Store(状态管理):提供跨应用的通信机制下面通过一个最小接入示例说明 Garfish 的基本用法:import Garfish from 'garfish';Garfish.run({ basename: '/', domGetter: '#sub-app', apps: [ { name: 'vue-app', entry: 'http://localhost:8080', activeWhen: '/vue', }, { name: 'react-app', entry: 'http://localhost:3000', activeWhen: '/react', }, ],});主应用只需配置子应用的名称、入口地址和激活路由,Garfish 会自动处理加载、挂载和卸载。六大核心特点详解1. 沙箱隔离Garfish 提供两种沙箱模式:快照沙箱(Snapshot Sandbox):在子应用挂载前快照 window 对象,卸载后恢复。适用于不支持 Proxy 的浏览器,但无法处理动态添加的全局变量。VM 沙箱(Proxy Sandbox):基于 ES6 Proxy 实现,为每个子应用创建一个代理 window 对象,真正实现了全局变量的隔离。这是 Garfish 推荐的方式。// VM 沙箱原理示意const proxyWindow = new Proxy(window, { get(target, key) { // 优先从子应用自己的状态中读取 return ownState[key] ?? target[key]; }, set(target, key, value) { ownState[key] = value; // 写入子应用独立状态 return true; },});VM 沙箱的优势在于多个子应用可以同时运行而互不干扰,这也是 Garfish 支持多实例的基础。2. 依赖共享Garfish 支持子应用之间共享公共依赖(如 React、Vue、Lodash 等),避免重复加载同一库的多个副本:Garfish.run({ apps: [ { name: 'app1', entry: 'http://localhost:8081', props: { react: require('react'), // 共享 React 实例 }, }, ],});依赖共享能显著降低整体包体积和加载时间,对于同时运行多个 React 子应用的场景尤为明显。3. 预加载策略Garfish 内置智能预加载机制,会在浏览器空闲时提前获取子应用资源:自动记录用户访问习惯,为高频使用的子应用增加预加载权重支持 prefetch 配置项,可自定义预加载行为预加载的资源包括 HTML、JS、CSS 等子应用入口依赖Garfish.run({ prefetch: true, // 开启预加载 // 也可传入函数自定义预加载逻辑 // prefetch: (apps) => apps.filter(app => app.name === 'high-priority-app'),});4. 框架无关Garfish 对子应用的技术栈没有限制,React、Vue、Angular、Svelte 等均可接入。子应用只需导出固定的生命周期钩子:// 子应用需要导出的生命周期export function provider({ dom, basename, props }) { return { mount() { /* 挂载逻辑 */ }, unmount() { /* 卸载逻辑 */ }, update() { /* 可选,接收父应用传参更新 */ }, };}5. 多实例支持不同于部分微前端方案只允许单个子应用运行,Garfish 支持在同一页面中同时运行多个子应用实例:// 同一页面同时挂载两个子应用Garfish.run({ domGetter: '#container', apps: [ { name: 'sidebar', activeWhen: '/', entry: '...' }, { name: 'main-content', activeWhen: '/', entry: '...' }, ],});这在后台管理系统等需要布局嵌套的场景中非常实用。6. 样式隔离Garfish 提供多种样式隔离方案:CSS Scoped:为子应用的样式自动添加作用域前缀Shadow DOM:利用浏览器原生的 Shadow DOM 实现完全隔离CSS Modules:配合构建工具使用,从源头避免样式冲突Garfish vs Qiankun:如何选择?| 对比维度 | Garfish | Qiankun ||---------|---------|---------|| 出品方 | 字节跳动 | 蚂蚁集团 || 沙箱方案 | 快照 + VM 双模式 | 快照 + Proxy 双模式 || 依赖共享 | 原生支持 | 需额外配置 || 多实例 | 原生支持 | 有限支持 || 预加载 | 内置智能预加载 | 需手动配置 || 社区规模 | ~2.9k Stars | ~16k Stars || 文档完善度 | 中等 | 较完善 || 适用场景 | 隔离性要求高、需多实例 | 通用场景、快速接入 |选择建议:选 Garfish:项目需要强隔离、多实例共存、依赖共享,或已在字节生态内选 Qiankun:追求社区支持、开箱即用,或团队微前端经验较少典型应用场景企业级后台管理系统多个业务团队各自维护独立子应用(权限管理、数据分析、运营工具等),通过 Garfish 统一接入主框架,实现独立开发、独立部署。电商平台活动页、商品详情、购物车等模块由不同团队负责,使用 Garfish 的预加载和依赖共享优化首屏性能。大型 SaaS 产品不同功能模块(CRM、BI、工单系统)采用不同技术栈,Garfish 的框架无关特性允许各模块选择最合适的技术方案。接入注意事项子应用改造:需要导出 provider 生命周期函数,并在打包配置中设置 libraryTarget: 'umd'跨域配置:子应用需配置 CORS 头,允许主应用跨域获取资源环境变量:子应用中访问 window 时需注意沙箱代理,避免直接操作导致泄漏公共路径:子应用的静态资源路径需正确配置 publicPath,防止资源加载失败总结Garfish 作为字节跳动出品的微前端框架,在沙箱隔离、依赖共享和多实例方面有独到优势。如果你的项目对应用隔离有较高要求,或者需要在一个页面中同时运行多个子应用,Garfish 是值得考虑的方案。但在社区生态和文档完善度上,它目前仍落后于 qiankun,团队在选型时需要权衡技术优势与社区支持之间的取舍。
服务端阅读 05月27日 20:01

Hardhat Network 的特点和优势是什么?

Hardhat Network 是 Hardhat 框架内置的本地以太坊开发网络,专为智能合约开发、测试和调试而设计。它让开发者无需部署到真实链上即可完成全流程开发验证,是以太坊开发工具链中的核心组件。核心特性1. 即时挖矿(Automining)Hardhat Network 默认启用自动挖矿模式——每笔交易提交后立即被打包进下一个区块,无需等待出块时间:// hardhat.config.jsmodule.exports = { networks: { hardhat: { mining: { auto: true, // 默认开启,交易即时确认 interval: 5000 // 也可设为定时出块(毫秒) } } }};关闭自动挖矿后,交易会进入内存池(mempool),行为与 Geth 客户端一致,适合测试交易排序和 MEV 场景。2. 预置测试账户启动时自动生成 20 个测试账户,每个账户预分配 10000 ETH:const [deployer, user1, user2] = await hre.ethers.getSigners();console.log("Deployer address:", deployer.address);console.log("Balance:", hre.ethers.formatEther(await hre.ethers.provider.getBalance(deployer.address)));// 输出: Balance: 10000.0还可自定义账户配置:networks: { hardhat: { accounts: { count: 5, // 只生成 5 个账户 accountsBalance: "100000000000000000000000" // 每个账户 100000 ETH } }}3. 状态快照与回滚evm_snapshot 和 evm_revert 允许在测试中保存和恢复网络状态,避免每次测试都重新部署:describe("Token 测试", function () { let snapshotId; before(async function () { // 部署合约 this.token = await Token.deploy(); // 保存初始状态快照 snapshotId = await hre.network.provider.send("evm_snapshot"); }); afterEach(async function () { // 每个测试用例后恢复快照 await hre.network.provider.send("evm_revert", [snapshotId]); snapshotId = await hre.network.provider.send("evm_snapshot"); }); it("转账测试", async function () { await this.token.transfer(user1.address, 100); // 测试结束后状态自动回滚 });});4. Solidity 调试工具console.log 调试在合约中直接输出调试信息,无需触发交易:// SPDX-License-Identifier: MITpragma solidity ^0.8.20;import "hardhat/console.sol";contract DebugExample { function calculate(uint256 a, uint256 b) public pure returns (uint256) { console.log("Input a:", a); console.log("Input b:", b); uint256 result = a * b + a; console.log("Result:", result); return result; }}堆栈追踪交易失败时,Hardhat Network 提供组合 JavaScript 和 Solidity 的完整调用栈:Error: VM Exception while processing transaction: reverted with reason string 'Insufficient balance' at Token.transfer (contracts/Token.sol:45) at process._tickCallback (internal/process/next_tick.js:68:7)5. 主网分叉(Mainnet Forking)基于真实链上状态创建本地分叉,可直接与已部署的 DeFi 协议交互:networks: { hardhat: { forking: { url: "https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY", enabled: true, // 可选:锁定到特定区块,确保测试可复现 blockNumber: 18500000 } }}分叉环境下的典型用法——与 Uniswap 交互测试:it("在分叉网络上交换代币", async function () { // 模拟持有 USDC 的鲸鱼账户 await hre.network.provider.request({ method: "hardhat_impersonateAccount", params: ["0x55fe002..."], // USDC 鲸鱼地址 }); const whale = await hre.ethers.getSigner("0x55fe002..."); // 用鲸鱼账户执行交易 const usdc = await hre.ethers.getContractAt("IERC20", USDC_ADDRESS, whale); await usdc.transfer(user1.address, hre.ethers.parseUnits("1000", 6));});6. 时间操控与挖矿控制在测试中灵活操控区块时间和出块:// 前进指定时间await hre.network.provider.send("evm_increaseTime", [3600]); // 快进 1 小时await hre.network.provider.send("evm_mine"); // 挖一个新区块// 设置到具体时间戳await hre.network.provider.send("evm_setNextBlockTimestamp", [1700000000]);// 重置网络状态await hre.network.provider.send("hardhat_reset");7. Gas 报告结合 hardhat-gas-reporter 插件,量化合约函数的 Gas 消耗:// 安装:npm install hardhat-gas-reporterrequire("hardhat-gas-reporter");module.exports = { gasReporter: { enabled: true, currency: "USD", gasPrice: 20 // Gwei }};输出示例:·--------------------------------|---------------------------|-------------|-----------------------------|| Solc version: 0.8.20 · Gas price: 20 gwei · USD/ETH: 3800 ||--------------------------------|---------------------------|-------------|-----------------------------|| Method · Min · Max · Avg ||································|···························|·············|·····························|| transfer · 51654 · 61654 · 53987 || approve · 46263 · 46263 · 46263 ||--------------------------------|---------------------------|-------------|-----------------------------|与其他本地网络的对比| 特性 | Hardhat Network | Ganache | Anvil (Foundry) ||------|----------------|---------|-----------------|| 即时挖矿 | 默认开启 | 默认开启 | 默认开启 || Solidity 堆栈追踪 | 完整支持 | 不支持 | 支持 || console.log | 原生支持 | 不支持 | 支持 || 主网分叉 | 原生内置 | 需配置 | 原生内置 || 快照/回滚 | 支持 | 支持 | 支持 || 账户模拟 | hardhat_impersonateAccount | 不直接支持 | vm.prank || TypeScript 集成 | 深度集成 | 弱 | 弱 || 运行性能 | 中等(EDR 加速) | 中等 | 极快(Rust 原生) || 插件生态 | 最丰富 | 有限 | 增长中 |典型使用场景单元测试与集成测试describe("MyContract", function () { it("应正确执行质押", async function () { const [owner, staker] = await hre.ethers.getSigners(); const token = await Token.deploy(); const staking = await Staking.deploy(token.target); await token.connect(staker).approve(staking.target, hre.ethers.parseEther("100")); await staking.connect(staker).stake(hre.ethers.parseEther("100")); expect(await staking.stakedBalance(staker.address)).to.equal(hre.ethers.parseEther("100")); });});DeFi 协议集成测试通过主网分叉测试与 Aave、Uniswap 等协议的交互逻辑,无需在测试网排队或消耗真实 Gas。时间依赖逻辑验证利用 evm_increaseTime 和 evm_mine 测试锁仓期、投票截止时间等时间相关功能。Gas 优化迭代通过 Gas 报告对比不同实现的 Gas 消耗,持续优化合约效率。常见配置参考// hardhat.config.js — 完整开发配置require("@nomicfoundation/hardhat-toolbox");require("hardhat-gas-reporter");module.exports = { solidity: "0.8.24", networks: { hardhat: { chainId: 31337, mining: { auto: true }, forking: process.env.FORK_URL ? { url: process.env.FORK_URL, blockNumber: 18500000 } : undefined, accounts: { count: 20, accountsBalance: "10000000000000000000000" // 10000 ETH }, allowBlocksWithSameTimestamp: true, throwOnTransactionFailures: true, throwOnCallFailures: true } }, gasReporter: { enabled: process.env.REPORT_GAS === "true" }};总结Hardhat Network 的核心优势在于将开发调试效率最大化:即时确认交易、Solidity 级堆栈追踪、主网状态分叉、灵活的时间与账户操控,这些能力让开发者能在本地完成绝大部分验证工作,显著减少对测试网的依赖。虽然 Foundry/Anvil 在执行速度上有优势,但 Hardhat Network 凭借 TypeScript 深度集成和丰富的插件生态,仍是复杂 DApp 开发场景下的首选本地网络。
服务端阅读 05月27日 19:59

如何在 Hardhat 中编写智能合约测试?

在以太坊智能合约开发中,测试是保障合约安全性和功能正确性的关键环节。Hardhat 内置了基于 Mocha 和 Chai 的测试框架,配合 ethers.js 提供了强大的合约交互能力。本文将从基础到进阶,系统讲解如何在 Hardhat 中编写高质量的智能合约测试。一、测试环境搭建确保项目已安装 Hardhat 及相关依赖:npm install --save-dev hardhat @nomicfoundation/hardhat-toolboxhardhat-toolbox 集成了 ethers.js、chai、hardhat-network-helpers 等常用测试工具,推荐直接使用。测试文件统一放在 test/ 目录下,以 .js 或 .ts 为后缀。二、测试文件基本结构一个典型的 Hardhat 测试文件结构如下:const { expect } = require("chai");const { ethers } = require("hardhat");describe("TokenContract", function () { let TokenContract; let token; let owner; let addr1; let addr2; // 每个测试用例执行前部署全新合约 beforeEach(async function () { [owner, addr1, addr2] = await ethers.getSigners(); TokenContract = await ethers.getContractFactory("TokenContract"); token = await TokenContract.deploy(); await token.waitForDeployment(); }); it("部署后应正确设置 owner", async function () { expect(await token.owner()).to.equal(owner.address); }); it("应能正确转账", async function () { await token.transfer(addr1.address, 100); expect(await token.balanceOf(addr1.address)).to.equal(100); });});关键要点:describe 对测试用例分组,it 定义单个测试beforeEach 确保每个测试在干净状态运行getSigners() 获取测试账户,owner 是默认的第一个账户Hardhat 2.x 使用 deployed(),3.x/最新版推荐 waitForDeployment()三、核心测试技巧1. 合约部署与初始化describe("部署", function () { it("部署时传入初始参数", async function () { const Token = await ethers.getContractFactory("MyToken"); const token = await Token.deploy("MyToken", "MTK", 1000000); await token.waitForDeployment(); expect(await token.name()).to.equal("MyToken"); expect(await token.symbol()).to.equal("MTK"); expect(await token.totalSupply()).to.equal(1000000); });});2. 状态读取与断言it("应正确记录余额", async function () { // 写入操作 const tx = await token.connect(addr1).mint(500); await tx.wait(); // 等待交易确认 // 读取状态并断言 expect(await token.balanceOf(addr1.address)).to.equal(500);});使用 .connect(signer) 切换调用者身份,模拟不同用户操作。3. 事件测试验证合约是否正确触发事件及其参数:it("转账时应触发 Transfer 事件", async function () { await expect(token.transfer(addr1.address, 100)) .to.emit(token, "Transfer") .withArgs(owner.address, addr1.address, 100);});4. 异常与回滚测试测试 revert 是安全审计的重点。常见的三种断言方式:// 匹配特定错误消息await expect(token.connect(addr1).mint(100)) .to.be.revertedWith("Only owner can mint");// 匹配自定义错误(Solidity 0.8.16+)await expect(token.connect(addr1).mint(100)) .to.be.revertedWithCustomError(token, "Unauthorized");// 仅验证是否 revert(不关心具体消息)await expect(token.transfer(addr1.address, 0)) .to.be.reverted;5. 使用快照重置状态当测试用例需要共享状态但又要在某些场景重置时,使用快照:describe("快照测试", function () { let snapshotId; beforeEach(async function () { snapshotId = await ethers.provider.send("evm_snapshot", []); }); afterEach(async function () { await ethers.provider.send("evm_revert", [snapshotId]); }); it("状态修改后可回滚", async function () { await token.transfer(addr1.address, 100); expect(await token.balanceOf(addr1.address)).to.equal(100); // afterEach 会自动回滚状态 });});6. 时间操作测试锁仓、投票等时间相关逻辑:const { time } = require("@nomicfoundation/hardhat-network-helpers");it("锁仓到期后应能提取", async function () { const lock = await Lock.deploy(oneYearInSec, { value: ethers.parseEther("1") }); // 快进时间 await time.increase(oneYearInSec); // 验证到期后可提取 await expect(lock.withdraw()).not.to.be.reverted;});推荐使用 hardhat-network-helpers 提供的 time.increase()、time.increaseTo()、helpers.mine() 等方法,比直接发送 evm_increaseTime 更直观。四、进阶测试场景Gas 消耗测试监控关键函数的 Gas 消耗,防止异常飙升:it("转账 Gas 消耗应在合理范围", async function () { const tx = await token.transfer(addr1.address, 100); const receipt = await tx.wait(); console.log(`Gas used: ${receipt.gasUsed.toString()}`); expect(receipt.gasUsed).to.be.below(80000); // 设定合理上限});权限与访问控制测试describe("访问控制", function () { it("非 admin 不能调用受限函数", async function () { await expect(token.connect(addr1).setPause(true)) .to.be.revertedWithCustomError(token, "AccessControlUnauthorizedAccount"); }); it("admin 可授权后调用", async function () { await token.grantRole(ADMIN_ROLE, addr1.address); await expect(token.connect(addr1).setPause(true)).not.to.be.reverted; });});可升级合约测试const { upgrades } = require("@openzeppelin/hardhat-upgrades");it("应能升级合约并保留状态", async function () { const V1 = await ethers.getContractFactory("TokenV1"); const proxy = await upgrades.deployProxy(V1, ["MTK"]); await proxy.waitForDeployment(); // 写入一些状态 await proxy.mint(addr1.address, 100); // 升级到 V2 const V2 = await ethers.getContractFactory("TokenV2"); const upgraded = await upgrades.upgradeProxy(await proxy.getAddress(), V2); // 验证状态保留 expect(await upgraded.balanceOf(addr1.address)).to.equal(100); // 验证新功能可用 expect(await upgraded.version()).to.equal(2);});五、常见陷阱与排查| 陷阱 | 说明 | 解决方式 ||------|------|----------|| 忘记 await tx.wait() | 只发送交易未等待确认就断言 | 写操作后必须 await tx.wait() || 使用 this 在箭头函数中 | Mocha 箭头函数中 this 不指向 Mocha 上下文 | 使用 describe(function() {}) 而非箭头函数 || Gas 估算失败 | 交易本身会 revert 导致无法估算 Gas | 直接用 expect(...).to.be.reverted 捕获 || BigInt 比较 | ethers v6 返回 BigInt | 使用 expect(value).to.equal(n) 而非 === || 测试间状态污染 | beforeEach 未正确重置 | 确保每个测试前重新部署或使用快照 |六、测试最佳实践测试覆盖要全面 — 每个公开函数至少一个正向用例和一个异常用例边界值测试 — 测试 0、最大值、溢出边界等极端情况保持测试独立性 — 不依赖其他测试的执行顺序使用有意义的描述 — it("应在余额不足时 revert") 而非 it("test1")模拟真实场景 — 不要只测理想路径,考虑前端调用方式、多签名流程等集成 CI — 在 GitHub Actions 中自动运行测试,配合 hardhat coverage 输出覆盖率报告使用 hardhat-gas-reporter — 持续监控 Gas 变化趋势,及时发现性能退化运行全部测试:npx hardhat test运行特定文件并输出 Gas 报告:REPORT_GAS=true npx hardhat test test/Token.test.js查看测试覆盖率:npx hardhat coverage
服务端阅读 05月27日 19:59

Hardhat 主网分叉功能如何使用?

Hardhat 主网分叉(Mainnet Forking)允许开发者在本地复刻以太坊主网或测试网的当前状态,在无需花费真实 Gas 的前提下,与链上已部署的真实合约进行交互。这对测试 DeFi 协议集成、调试复杂交易路径、验证合约升级影响等场景至关重要。工作原理主网分叉的本质是:Hardhat 节点通过 RPC 节点读取主网的归档数据,在本地模拟出与主网一致的状态。你的本地交易不会影响真实链上数据,但可以读取主网上合约的存储值、余额等状态。核心依赖:一个支持归档数据的 RPC 节点(如 Alchemy、Infura、QuickNode)足够的本地内存(分叉会占用较多 RAM)基础配置在 hardhat.config.js(或 .ts)中添加分叉配置:module.exports = { solidity: "0.8.20", networks: { hardhat: { forking: { url: process.env.MAINNET_RPC_URL, // 如 https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY } } }};使用 .env 文件管理 RPC URL:MAINNET_RPC_URL=https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY指定分叉区块号固定区块号可以确保每次测试的环境一致,提高测试的可重复性:networks: { hardhat: { forking: { url: process.env.MAINNET_RPC_URL, blockNumber: 19000000 // 锁定在此区块的主网状态 } }}为什么要固定区块号?如果不指定,每次启动都会分叉最新区块,合约状态(如代币价格、流动性池余额)可能发生变化,导致测试间歇性失败。模拟账户与代币水龙头分叉环境中,你可以直接扮演主网上持有大量代币的账户,这是主网分叉最强大的能力之一:const { ethers } = require("hardhat");async function main() { // 模拟 Whales 账户 const whaleAddress = "0x47ac0Fb4F2D84898e4D9E7b4DaB3C24507a6D503"; // Binance 热钱包 await network.provider.request({ method: "hardhat_impersonateAccount", params: [whaleAddress], }); const whale = await ethers.getSigner(whaleAddress); // 查询该账户的 ETH 余额 const ethBalance = await ethers.provider.getBalance(whaleAddress); console.log("ETH Balance:", ethers.formatEther(ethBalance)); // 查询该账户的 USDT 余额 const usdt = await ethers.getContractAt("IERC20", "0xdAC17F958D2ee523a2206206994597C13D831ec7"); const usdtBalance = await usdt.balanceOf(whaleAddress); console.log("USDT Balance:", ethers.formatUnits(usdtBalance, 6));}main();给任意地址注入 ETH(无需真实挖矿):// 给指定地址转入 100 ETHawait network.provider.send("hardhat_setBalance", [ "0xYourAddress", "0x56BC75E2D63100000", // 100 ETH 的十六进制(单位 wei)]);实战案例:在分叉环境测试 Uniswap 交易以下是一个完整的测试流程,在分叉环境中用模拟账户执行真实的 Uniswap V2 交易:const { ethers } = require("hardhat");describe("Uniswap V2 Swap on Forked Mainnet", function () { const WHALE = "0x47ac0Fb4F2D84898e4D9E7b4DaB3C24507a6D503"; const USDT = "0xdAC17F958D2ee523a2206206994597C13D831ec7"; const ROUTER = "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D"; let whale, usdt, router; before(async function () { // 模拟鲸鱼账户 await network.provider.request({ method: "hardhat_impersonateAccount", params: [WHALE], }); whale = await ethers.getSigner(WHALE); usdt = await ethers.getContractAt("IERC20", USDT); router = await ethers.getContractAt( [ "function swapExactTokensForTokens(uint,uint,address[],address,uint) external returns (uint[])", ], ROUTER ); }); it("should swap USDT for WETH", async function () { // 授权 Router 使用 USDT const swapAmount = ethers.parseUnits("1000", 6); // 1000 USDT await usdt.connect(whale).approve(ROUTER, swapAmount); // 执行兑换 const tx = await router.connect(whale).swapExactTokensForTokens( swapAmount, 0, // 最少输出(生产环境应设置滑点保护) [USDT, "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"], // USDT -> WETH whale.address, Math.floor(Date.now() / 1000) + 60 * 10 // 10 分钟 deadline ); const receipt = await tx.wait(); console.log("Swap succeeded, gas used:", receipt.gasUsed.toString()); });});测试 Aave 协议集成const { ethers } = require("hardhat");describe("Aave V3 Integration Test", function () { const WHALE = "0x47ac0Fb4F2D84898e4D9E7b4DaB3C24507a6D503"; const AAVE_POOL = "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2"; const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; it("should deposit USDC into Aave V3", async function () { await network.provider.request({ method: "hardhat_impersonateAccount", params: [WHALE], }); const whale = await ethers.getSigner(WHALE); const usdc = await ethers.getContractAt("IERC20", USDC); const pool = await ethers.getContractAt( [ "function deposit(address,uint256,address,uint16) external returns (bool)", ], AAVE_POOL ); const amount = ethers.parseUnits("10000", 6); // 10,000 USDC await usdc.connect(whale).approve(AAVE_POOL, amount); await pool.connect(whale).deposit(USDC, amount, whale.address, 0); console.log("Deposit succeeded"); });});动态启用/禁用分叉在测试中按需切换分叉,避免所有测试都承受分叉的性能开销:describe("With forking enabled", function () { before(async function () { await network.provider.request({ method: "hardhat_reset", params: [ { forking: { jsonRpcUrl: process.env.MAINNET_RPC_URL, blockNumber: 19000000, }, }, ], }); }); after(async function () { // 禁用分叉,恢复普通 Hardhat 网络 await network.provider.request({ method: "hardhat_reset", params: [], }); }); it("interacts with mainnet contract", async function () { // 你的分叉测试逻辑 });});RPC 节点选择| 服务商 | 免费额度 | 归档数据支持 | 推荐场景 ||--------|---------|------------|---------|| Alchemy | 300M CU/月 | 支持 | 开发调试首选 || Infura | 100K 请求/天 | 支持 | 轻量测试 || QuickNode | 免费套餐 | 支持 | 高性能需求 || Chainstack | 3M 请求/月 | 支持 | 多链支持 |注意:必须选择支持归档数据的套餐,否则无法指定历史区块号进行分叉。常见问题Q: 分叉启动后报错 "Missing trie node"?A: 你的 RPC 节点不支持指定区块号的归档数据,请切换到支持归档的套餐或去掉 blockNumber 使用最新区块。Q: 分叉环境交易回执显示 gas 极高?A: 这是正常现象,分叉环境中的 gas 计算可能与主网不一致,不影响功能测试。Q: impersonateAccount 后交易失败?A: 部分合约有合约白名单或代理限制。确保模拟的账户确实有权限调用目标合约,同时检查该账户在分叉区块号时是否持有足够的 ETH 支付 gas。Q: 如何在 CI/CD 中使用分叉?A: 使用固定的 blockNumber 并配合缓存(如缓存 RPC 响应),避免每次 CI 都大量请求 RPC 节点。Alchemy 的归档节点配合 blockNumber 是最稳定的 CI 方案。最佳实践使用环境变量存储 RPC URL,绝不将 API Key 硬编码提交到代码仓库固定 blockNumber 确保测试可重复,团队协作时所有人使用相同区块号只在需要主网状态的测试中启用分叉,普通单元测试使用本地 Hardhat 网络即可模拟高价值账户(Whales)获取测试代币,而非自己部署模拟 ERC20在 CI 中配合缓存机制减少 RPC 请求量,避免超出免费额度定期更新分叉区块号以测试与最新链上状态的兼容性
服务端阅读 05月27日 19:58

Jest 代码覆盖率怎么配置?四个指标分别是什么意思?

Jest 内置了代码覆盖率收集功能,基于 Istanbul(Babel provider)或 V8 引擎实现。运行 jest --coverage 即可生成报告,四种核心指标:语句覆盖率(Statements)衡量代码语句执行比例,分支覆盖率(Branches)衡量 if/switch 等分支走过了多少,函数覆盖率(Functions)统计函数调用比例,行覆盖率(Lines)统计代码行执行比例。四个指标中分支覆盖率通常最低,也最值得重点关注——因为未覆盖的分支意味着逻辑路径没被测到。配置方面,collectCoverageFrom 控制统计范围,coverageThreshold 设置门槛,coverageReporters 选择输出格式(text 控制台、lcov 给 CI、html 可视化浏览)。阈值支持全局和按文件/目录设置,还能用负数表示"最多允许 N 个未覆盖项"。追问Statements 和 Lines 有什么区别?不都是行吗?不是。一行代码可以包含多条语句,比如 let a = 1, b = 2; 是一条行但两条语句。反过来,一条 if 判断如果跨行书写,行覆盖率可能覆盖了但分支没覆盖。实际项目中这两个数字通常很接近,差异大说明代码风格比较紧凑。覆盖率到了 100% 就说明测试充分吗?不是。覆盖率只衡量"有没有被执行过",不衡量"有没有被正确验证"。比如一个函数返回值你从没断言,但函数被调用了,语句覆盖率照样算通过。另外边界值、异常路径、并发场景这些覆盖率工具本身很难捕捉。80% 是常见基线,核心模块可以要求更高。babel provider 和 v8 provider 怎么选?Babel provider 是默认选项,通过代码插桩(instrumentation)收集覆盖率,支持 /* istanbul ignore next */ 跳过指定行。V8 provider 利用 V8 引擎原生覆盖率 API,速度更快但不支持 Istanbul 忽略注释(改用 /* c8 ignore next */)。大型项目如果 Babel provider 跑覆盖率太慢,可以试 coverageProvider: "v8",但注意 V8 provider 是实验性功能,输出精度在某些边界场景有差异。CI 里覆盖率检查不通过怎么排查?先看 HTML 报告里标红的文件,重点看分支覆盖——很多是 else 分支或三元表达式的某一端没走到。常见原因:错误处理路径没测、环境判断(if (process.env.NODE_ENV === "production"))在测试环境走不到、死代码没排除。用 collectCoverageFrom 排除配置文件和类型定义,用负数阈值给特定模块放宽限制,比如 { "./src/legacy/**/*.js": { statements: -20 } } 允许老代码最多 20 个语句未覆盖。写段代码// jest.config.jsmodule.exports = { collectCoverage: true, coverageProvider: "v8", // 或 "babel" collectCoverageFrom: [ "src/**/*.{js,ts}", "!src/**/*.d.ts", "!src/index.ts", ], coverageThreshold: { global: { branches: 80, functions: 80, lines: 80, statements: 80 }, "./src/core/**/*.ts": { branches: 90 }, // 核心模块更严格 }, coverageReporters: ["text-summary", "lcov", "html"],};
服务端阅读 05月27日 19:58

如何在 Jest 中进行参数化测试?如何使用 test.each 和 describe.each?

为什么需要参数化测试写测试的时候,经常会遇到同一套逻辑需要用不同数据反复验证的情况。比如一个加法函数,你要测正数、负数、零、边界值,如果每组数据都单独写一个 test,代码会变得冗长且难以维护。参数化测试就是为了解决这个问题——把数据和断言逻辑分离,用一份测试代码覆盖多组输入。Jest 提供了 test.each 和 describe.each 两个 API 来实现参数化测试。前者对单条测试用例做参数化,后者对整组测试做参数化,两者搭配可以显著减少重复代码。test.each 的基本用法test.each 接收一个数组,数组中的每个元素代表一组测试数据,Jest 会为每组数据生成一条独立的测试用例。用二维数组传入参数,这是最直接的写法:test.each([ [1, 1, 2], [1, 2, 3], [2, 1, 3],])('adds %i + %i = %i', (a, b, expected) => { expect(add(a, b)).toBe(expected);});注意测试名称中的 %i 是占位符,Jest 会按顺序用数组元素替换它们。常用的占位符有:%s(字符串)、%i(整数)、%d(数字)、%p(pretty-format)、%#(测试索引)。用对象数组提高可读性二维数组的参数顺序容易搞混,特别是参数多的时候。用对象数组可以让每组数据的含义一目了然:test.each([ { a: 1, b: 1, expected: 2 }, { a: 1, b: 2, expected: 3 }, { a: 2, b: 1, expected: 3 },])('$a + $b = $expected', ({ a, b, expected }) => { expect(add(a, b)).toBe(expected);});对象数组的测试名称用 $key 的语法引用对象属性,比位置占位符更清晰。如果某个属性值是对象或数组,用 $key 也能自动展开显示。表格语法的写法Jest 还支持用模板字符串写表格式的参数化数据,可读性更好,特别适合数据量较多的场景:test.each` a | b | expected ${1} | ${1} | ${2} ${1} | ${2} | ${3} ${2} | ${1} | ${3}`('returns $expected when $a is added to $b', ({ a, b, expected }) => { expect(add(a, b)).toBe(expected);});表格语法有几个要点:表头行定义变量名,用 | 分隔;数据行中 JavaScript 表达式必须用 ${} 包裹;字符串值可以不用 ${},直接写即可。这种方式在测试报告里看起来像一张表格,维护和审查都很方便。describe.each 分组参数化当你需要针对不同环境或配置运行一整套测试时,describe.each 就派上用场了。它为每组数据生成一个 describe 块,里面可以包含多条测试:describe.each([ ['node', 'node'], ['jsdom', 'browser'],])('test environment: %s', (env, type) => { test(`runs in ${type} environment`, () => { expect(process.env.NODE_ENV).toBeDefined(); }); test('has correct global scope', () => { if (env === 'jsdom') { expect(window).toBeDefined(); } else { expect(global).toBeDefined(); } });});这个例子中,两组环境配置各自生成一个 describe 块,每个块里有两条测试。describe.each 同样支持对象数组和表格语法,用法和 test.each 一致。参数化测试边界情况和错误处理参数化测试不只是测正常路径,更实用的场景是批量覆盖边界值和异常输入:test.each([ [0, 0, 0], [Number.MAX_SAFE_INTEGER, 1, Number.MAX_SAFE_INTEGER + 1], [Number.MIN_SAFE_INTEGER, -1, Number.MIN_SAFE_INTEGER - 1],])('handles edge cases: %i + %i = %i', (a, b, expected) => { expect(add(a, b)).toBe(expected);});test.each([ [undefined, 'input is required'], [null, 'input is required'], ['', 'input cannot be empty'],])('throws error for invalid input: %p', (input, expectedError) => { expect(() => validate(input)).toThrow(expectedError);});把正常值、边界值、异常值分不同的 test.each 组织,测试报告里失败用例一目了然,比把所有数据塞进一个 each 更容易定位问题。常见踩坑点占位符和参数数量不匹配。测试名称里的 %s、%i 等占位符数量必须和数组元素个数一致,多一个少一个都会报错。如果嫌数占位符麻烦,推荐用对象数组加 $key 的方式。异步测试忘记返回 Promise。参数化测试中的回调函数如果是异步的,和普通测试一样需要返回 Promise 或使用 async/await,这个容易遗漏:test.each([ [1, 2], [3, 4],])('async test for %i and %i', async (a, b) => { const result = await asyncAdd(a, b); expect(result).toBe(a + b);});表格语法中的类型陷阱。表格语法里不加 ${} 的值会被当作字符串处理,所以数字、布尔值、对象必须用 ${} 包裹,否则拿到的是字符串类型的值,断言结果可能不符合预期。实战建议在实际项目中,参数化测试用得好可以大幅提升测试覆盖率和可维护性,但也要注意分寸。一组测试数据建议控制在 10 条以内,超过这个数量就要考虑是否该拆分场景。数据太多时测试报告可读性会下降,调试也不方便。选择哪种语法形式可以按场景来:两三个简单参数用二维数组就够了;参数多或者含义不明显时用对象数组;数据量大、需要表格化展示时用模板字符串语法。test.each 和 describe.each 也可以嵌套使用,外层用 describe.each 按环境或配置分组,内层用 test.each 跑具体数据,这样测试结构既清晰又紧凑。
服务端阅读 05月27日 19:55

Jest 断言方法有哪些?expect 和匹配器怎么用?

Jest 断言就一个套路:expect(实际值).匹配器(期望值)。匹配器决定怎么比,面试常考的分这几类:相等性:toBe 用 ===,只适合基本类型;toEqual 递归比较对象和数组每个属性,比对象首选它。两个高频坑:expect({a:1}).toBe({a:1}) 永远失败(引用不同);toEqual 会忽略 undefined 属性,需要严格比较用 toStrictEqual。toMatchObject 只匹配属性子集,适合只关心部分字段。真假值:toBeNull/toBeUndefined/toBeDefined 各自只匹配一个值;toBeTruthy/toBeFalsy 按 JS 强制布尔转换——0、""、null、undefined、NaN 是 falsy,其余 truthy。别混用:toBeFalsy 比 toBeUndefined 宽泛得多。数字:toBeGreaterThan/toBeLessThan 及 OrEqual 变体。浮点数必须 toBeCloseTo——0.1 + 0.2 !== 0.3 是 JS 经典问题,用 toBe 比浮点数会翻车。字符串与容器:toMatch 匹配正则或子串;toContain 检查数组含元素或字符串含子串;toHaveLength 检查长度;toHaveProperty 检查对象属性。异常:toThrow 断言函数抛错,可匹配错误消息(字符串或正则)。必须传函数引用 expect(fn).toThrow(),传调用结果 expect(fn()).toThrow() 会在 expect 执行前就崩了。异步:resolves/rejects 断言 Promise 结果,必须 await——忘了 await 是新手最常犯的错,断言还没完成测试就静默通过了。否定修饰:任何匹配器前加 .not 取反。但别滥用:expect(x).not.toBeUndefined() 不如直接 expect(x).toBeDefined()。Mock:toHaveBeenCalledWith 检查调用参数;toHaveBeenCalledTimes 检查调用次数;toMatchSnapshot 做 UI 渲染快照回归。追问toBe 和 toEqual 有什么区别?什么时候用哪个?toBe 是引用相等(===),基本类型值相同就过,对象必须同一引用才过。toEqual 递归比较每个属性,结构相同就过。一句话:基本类型用 toBe,对象数组用 toEqual。面试里 90% 的坑就是拿 toBe 比对象然后一脸懵。Jest 异步测试怎么写?三种方式:回调用 done 参数,Promise 用 resolves/rejects,async/await 同样配 resolves/rejects。最大坑是忘 await——expect(promise).resolves.toBe(x) 不加 await,断言没跑完测试就 passed 了。正确写法:await expect(fetchData()).resolves.toEqual(data)。toThrow 有什么注意点?两个坑:一、必须传函数引用不是调用结果,前面说了;二、只捕获同步错误,异步错误得用 rejects.toThrow()。还有个细节:toThrow 匹配的是 error message 不是 error 类型,要精确匹配传字符串或正则。.not 能和所有匹配器组合吗?语法上可以,但语义上别乱用。expect(x).not.toBeUndefined() 和 expect(x).toBeDefined() 结果一样,后者更清晰。.not 用在"不应该发生"的场景:函数不应抛错、返回不应为 null、mock 不应被调用。项目里哪些匹配器用得最多?toEqual 和 toBe 占七成以上——几乎所有测试都在比较值;toHaveBeenCalledWith 和 toThrow 是第二梯队——验证 mock 和错误分支;toMatchSnapshot 在组件测试中大量使用。掌握这几个就能覆盖日常 80% 的断言场景。写段代码// toBe vs toEqualexpect(1 + 1).toBe(2);expect({ name: 'a' }).not.toBe({ name: 'a' }); // 引用不同,失败expect({ name: 'a' }).toEqual({ name: 'a' }); // 深度相等,通过// 异步断言必须 awaitawait expect(api.getUser(1)).resolves.toEqual({ id: 1 });// toThrow 传函数引用,匹配错误消息expect(() => JSON.parse('invalid')).toThrow();expect(() => risky()).toThrow(/permission denied/);// Mock 验证expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');expect(mockFn).toHaveBeenCalledTimes(2);
服务端阅读 05月27日 19:54

如何在 Jest 中测试 React Hooks?renderHook 和 act 怎么用?

测试 React Hooks 的核心工具是 renderHook 和 act。React 18 之后,renderHook 已从废弃的 @testing-library/react-hooks 迁移到 @testing-library/react,用法也有变化。核心思路renderHook:在测试环境中渲染 Hook,返回 result(当前返回值)、rerender(重新渲染)、unmount(卸载)act:包裹所有会导致状态更新的操作,确保 React 完成渲染后再执行断言waitFor:处理异步状态更新,替代旧版的 waitForNextUpdate安装依赖npm install --save-dev jest @testing-library/react @testing-library/jest-dom 注意:@testing-library/react-hooks 已废弃,React 18+ 请统一使用 @testing-library/react。测试 useStateimport { renderHook, act } from '@testing-library/react';function useCounter(initialValue = 0) { const [count, setCount] = useState(initialValue); const increment = () => setCount(c => c + 1); const decrement = () => setCount(c => c - 1); return { count, increment, decrement };}test('useCounter 初始值和更新', () => { const { result } = renderHook(() => useCounter(0)); // 验证初始状态 expect(result.current.count).toBe(0); // 用 act 包裹状态更新 act(() => { result.current.increment(); }); expect(result.current.count).toBe(1);});关键点:任何触发 setState 的调用都必须包裹在 act() 中,否则 React 会发出警告,断言也可能基于未更新的状态。测试 useEffectimport { renderHook, act } from '@testing-library/react';function useDocumentTitle(title) { useEffect(() => { document.title = title; return () => { document.title = 'default'; }; }, [title]);}test('useEffect 设置和清理', () => { const { result, unmount, rerender } = renderHook( ({ title }) => useDocumentTitle(title), { initialProps: { title: 'Hello' } } ); expect(document.title).toBe('Hello'); // 依赖变化时 effect 重新执行 rerender({ title: 'World' }); expect(document.title).toBe('World'); // 卸载时执行清理函数 unmount(); expect(document.title).toBe('default');});关键点:用 rerender 测试依赖变化,用 unmount 测试清理逻辑。测试 useContextimport { renderHook } from '@testing-library/react';const ThemeContext = createContext('light');function useTheme() { return useContext(ThemeContext);}test('useContext 读取 Provider 值', () => { const wrapper = ({ children }) => ( <ThemeContext.Provider value="dark"> {children} </ThemeContext.Provider> ); const { result } = renderHook(() => useTheme(), { wrapper }); expect(result.current).toBe('dark');});关键点:Hook 依赖 Context 时,通过 wrapper 选项注入 Provider,renderHook 会自动用 wrapper 包裹组件树。测试异步 Hookimport { renderHook, waitFor, act } from '@testing-library/react';function useFetch(url) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; fetch(url) .then(res => res.json()) .then(json => { if (!cancelled) { setData(json); setLoading(false); } }) .catch(err => { if (!cancelled) { setError(err); setLoading(false); } }); return () => { cancelled = true; }; }, [url]); return { data, loading, error };}test('useFetch 异步请求', async () => { // 用 jest.fn mock fetch global.fetch = jest.fn(() => Promise.resolve({ json: () => Promise.resolve({ name: 'test' }) }) ); const { result } = renderHook(() => useFetch('/api/data')); // 初始状态 expect(result.current.loading).toBe(true); // 等待异步完成 await waitFor(() => { expect(result.current.loading).toBe(false); }); expect(result.current.data).toEqual({ name: 'test' }); expect(result.current.error).toBeNull();});关键点:用 waitFor 等待异步更新,不要在 act 里 await waitFor(那是反模式)异步 Hook 需要处理竞态:组件卸载后不应再 setState,用 cancelled 标志位或 AbortController测试自定义 Hookfunction useDebounce(value, delay) { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const timer = setTimeout(() => setDebouncedValue(value), delay); return () => clearTimeout(timer); }, [value, delay]); return debouncedValue;}test('useDebounce 防抖', () => { jest.useFakeTimers(); const { result, rerender } = renderHook( ({ value }) => useDebounce(value, 500), { initialProps: { value: 'hello' } } ); expect(result.current).toBe('hello'); // 快速更新值,防抖未到期 rerender({ value: 'world' }); expect(result.current).toBe('hello'); // 还是旧值 // 快进 500ms act(() => { jest.advanceTimersByTime(500); }); expect(result.current).toBe('world'); jest.useRealTimers();});关键点:涉及定时器的 Hook,用 jest.useFakeTimers() + act(() => jest.advanceTimersByTime(ms)) 精确控制时间。常见报错排查"not wrapped in act()" 警告原因:状态更新发生在 act() 之外(如异步回调、定时器未用 fake timers)。解决:异步操作用 waitFor 或 await act(async () => ...)定时器用 jest.useFakeTimers() 并在 act 中推进时间确保所有 setState 调用都在 act 内"Can't perform a React state update on an unmounted component"原因:异步操作完成后组件已卸载,仍然调用了 setState。解决:在 useEffect 清理函数中取消异步操作(cancelled 标志位 / AbortController)。最佳实践用 @testing-library/react 的 renderHook,不要再用废弃的 @testing-library/react-hooks所有状态更新包裹 act,同步用 act(fn),异步用 await act(async fn) 或 waitFor测试行为不测实现:关注 Hook 的输入输出,不关注内部状态变量名测试边界:初始值、空值、错误状态、并发场景用 rerender 测试依赖变化,用 unmount 测试清理逻辑Mock 外部依赖(API、定时器、DOM API),不 Mock React 内置 Hook
服务端阅读 05月27日 19:52

Jest 怎么测试 setTimeout 和 setInterval?fake timers 怎么用?

Jest 用 jest.useFakeTimers() 把 setTimeout、setInterval 替换成模拟实现,然后通过 jest.runAllTimers()、jest.advanceTimersByTime() 等方法手动推进时间,不用真等。核心流程就三步:开启假定时器 → 写业务代码 → 手动推进时间并断言。jest.useFakeTimers();const callback = jest.fn();setTimeout(callback, 1000);jest.advanceTimersByTime(1000);expect(callback).toHaveBeenCalledTimes(1);runAllTimers 会一口气跑完所有待执行的定时器,包括嵌套的。如果你的代码里定时器会不断递归注册自己(比如轮询),用 runAllTimers 会死循环——这种情况用 runOnlyPendingTimers 只跑当前这轮。advanceTimersByTime(ms) 更精确,只推进指定毫秒数,适合测"3 秒后应该执行了 3 次"这类场景:const cb = jest.fn();setInterval(cb, 1000);jest.advanceTimersByTime(3000);expect(cb).toHaveBeenCalledTimes(3);每个测试用例结束记得恢复真实定时器:jest.useRealTimers(),不然会影响后续测试。推荐放 afterEach 里统一清理。追问useFakeTimers 和手动 mock setTimeout 有什么区别?useFakeTimers 是 Jest 内置的,会替换全局的 setTimeout/setInterval/clearTimeout/clearInterval/setImmediate 等,提供 runAllTimers、advanceTimersByTime 等控制 API。手动 mock 只替换你 spyOn 的那一个函数,控制力更弱,需要自己模拟时间推进。fake timers 和 Promise 混用时有什么坑?这是最常见的坑:jest.useFakeTimers() 默认也会 fake 掉 process.nextTick 和微任务队列,导致 Promise.resolve().then(...) 里的回调不执行。Jest 27+ 可以用 jest.useFakeTimers({ doNotFake: ['nextTick'] }) 排除 nextTick,或者手动 await new Promise(process.nextTick) 让微任务跑完再推进时间。jest.advanceTimersByTime 和 jest.runTimersToTime 有什么区别?runTimersToTime 是旧 API(Jest 22 及之前),行为和 advanceTimersByTime 基本一致但语义模糊。Jest 23+ 推荐用 advanceTimersByTime,旧 API 仅为向后兼容保留。实际项目里测定时器最容易犯什么错?忘记在 beforeEach 里开启 fake timers,导致前一个测试的真实定时器泄漏到下一个测试;或者用 runAllTimers 跑有递归定时器的代码导致栈溢出。另一个常见问题是 afterEach 里只调了 useRealTimers 但没调 clearAllTimers,残留的定时器可能干扰后续用例。
服务端阅读 05月27日 19:51

Jest 中有哪些测试匹配器(Matchers)?如何使用自定义匹配器?

为什么匹配器是 Jest 测试的核心写测试本质上就是做断言——拿实际结果和期望结果比对。匹配器(Matchers)就是 Jest 提供的断言语言,决定了你能用多自然、多精确的方式表达"我期望这段代码的行为是什么"。如果你只会 toBe 和 toEqual,很多场景要么写不出断言,要么写得很别扭。掌握完整的匹配器体系,加上自定义匹配器的能力,才能写出既清晰又健壮的测试。相等性匹配器:判断值是否如你所料最基础也是用得最多的一组:toBe(value) — 严格相等,即 ===。适合原始类型(number、string、boolean)和 null/undefined 的比较。注意:对象比较的是引用,不是内容。expect(1 + 1).toBe(2);expect(null).toBe(null);toEqual(value) — 深度递归比较。对象和数组逐字段比对,是测试复杂数据结构的首选。expect({ name: 'Jest', version: 29 }).toEqual({ name: 'Jest', version: 29 });// 通过:内容一致即可,不要求同一引用toStrictEqual(value) — 比 toEqual 更严格:undefined 属性、稀疏数组空位、Date 实例等都会纳入比较。当你需要确保数据结构完全一致、没有多余属性时使用。expect({ a: undefined, b: 1 }).not.toStrictEqual({ b: 1 });// toEqual 会认为两者相同,toStrictEqual 不会toMatchObject(object) — 部分匹配,只检查给定的属性是否存在且值相等,忽略对象中的其他属性。适合只关心几个关键字段的场景。const user = { id: 1, name: 'Alice', email: 'alice@test.com', role: 'admin' };expect(user).toMatchObject({ name: 'Alice', role: 'admin' });// 只验证这两个字段,其余忽略真值匹配器:处理 null、undefined 和真假值JavaScript 的真假值规则经常让人踩坑,Jest 专门提供了一组匹配器:| 匹配器 | 通过条件 | 典型用途 ||--------|---------|---------|| toBeNull() | 仅 null | 区分 null 和 undefined || toBeUndefined() | 仅 undefined | 检测未赋值变量 || toBeDefined() | 非 undefined | 确认变量已定义 || toBeTruthy() | 真值(!!value === true) | 检查非空字符串、非零数字等 || toBeFalsy() | 假值(0、''、null、undefined、false) | 检查空值或无数据状态 |// 常见场景:函数返回 null 表示未找到expect(findUser(-1)).toBeNull();// 常见场景:检查可选配置项是否存在expect(config.timeout).toBeDefined();// 常见场景:检查有内容(非空字符串、非零数字)expect(response.body).toBeTruthy();一个常见的坑:toBeTruthy() 对 0 和空字符串返回 false。如果你确实需要区分 0 和 undefined,别用 toBeTruthy,用 toBeDefined。数字匹配器:比较大小和精度toBeGreaterThan(n) / toBeGreaterThanOrEqual(n) — 大于 / 大于等于toBeLessThan(n) / toBeLessThanOrEqual(n) — 小于 / 小于等于toBeCloseTo(n, precision) — 浮点数近似比较,避免精度问题expect(0.1 + 0.2).not.toBe(0.3); // JavaScript 浮点精度问题expect(0.1 + 0.2).toBeCloseTo(0.3, 5); // 正确做法:指定精度比较toBeCloseTo 是处理浮点运算的必备匹配器,第二个参数是小数点后的精度位数,默认是 2。如果测试中涉及金额计算或科学计算,务必用它替代 toBe。字符串匹配器toMatch(regexp | string) — 匹配正则或包含子串toContain(item) — 包含子字符串expect('Hello, Jest!').toMatch(/jest/i);expect('error: file not found').toContain('error');toMatch 支持正则,比 toContain 更灵活。需要模式匹配时用 toMatch,只需判断是否包含子串时用 toContain。数组匹配器toContain(item) — 数组中是否包含某元素(用 === 比较)toContainEqual(item) — 数组中是否包含深度相等的元素toHaveLength(n) — 数组/字符串长度const users = [{ id: 1 }, { id: 2 }];expect(users).toContainEqual({ id: 1 }); // 深度比较,通过expect(users).not.toContain({ id: 1 }); // 引用比较,不通过expect(users).toHaveLength(2);toContain 对对象用的是引用比较,如果数组里存的是对象字面量,一定要用 toContainEqual,否则断言会失败。对象匹配器toHaveProperty(keyPath, value?) — 检查对象是否有指定属性路径,可选检查值toMatchObject(object) — 部分匹配(上文已介绍)const config = { db: { host: 'localhost', port: 5432 } };expect(config).toHaveProperty('db.port', 5432); // 支持点号路径expect(config).toHaveProperty(['db', 'host']); // 也支持数组路径toHaveProperty 的 keyPath 参数支持点号分隔的字符串或字符串数组,可以深层数据校验。函数匹配器:验证函数调用行为这组匹配器配合 jest.fn() 或 jest.spyOn() 使用,是 Mock 测试的核心工具:toHaveBeenCalled() — 函数被调用过toHaveBeenCalledWith(...args) — 用特定参数调用过toHaveBeenCalledTimes(n) — 调用了 n 次toHaveLastReturnedWith(value) — 最后一次返回值toHaveNthReturnedWith(n, value) — 第 n 次返回值toHaveReturned() — 成功返回过(没抛错)toHaveReturnedWith(value) — 返回过指定值const onClick = jest.fn();button.click();button.click();expect(onClick).toHaveBeenCalledTimes(2);expect(onClick).toHaveBeenCalledWith(); // 无参数调用// 带参数的场景const save = jest.fn();save({ name: 'Alice' });expect(save).toHaveBeenCalledWith({ name: 'Alice' });一个实用技巧:toHaveBeenCalledWith 只检查某一次调用是否匹配,不要求所有调用都匹配。如果需要验证所有调用的参数序列,可以用 expect(fn.mock.calls).toEqual([[arg1], [arg2]])。异常匹配器:测试错误抛出toThrow(error?) — 函数抛出错误,可匹配错误消息或类型toThrowErrorMatchingSnapshot() — 错误消息快照function divide(a, b) { if (b === 0) throw new Error('Division by zero'); return a / b;}expect(() => divide(1, 0)).toThrow('Division by zero');expect(() => divide(1, 0)).toThrow(/zero/);expect(() => divide(1, 0)).toThrow(Error);关键点:toThrow 的参数必须是包裹在函数中的(expect(() => fn()) 而不是 expect(fn())),否则错误会在 expect 执行前直接抛出,测试框架捕获不到。否定匹配器:用 .not 取反所有匹配器都可以通过 .not 前缀取反:expect(value).not.toBe(42);expect(array).not.toContain('deprecated');expect(fn).not.toHaveBeenCalled();.not 链式调用让断言的语义更自然。当 not 加上语义明确的匹配器仍不够用时,就是自定义匹配器登场的时候了。快照匹配器:捕获和比对输出toMatchSnapshot(propertyMatchers?, hint?) — 与存储的快照比对toThrowErrorMatchingSnapshot() — 异常消息快照expect(component.render()).toMatchSnapshot();// 首次运行会生成快照文件,后续运行自动比对// 输出变化时测试失败,需用 --updateSnapshot 更新快照适合测试稳定的序列化输出(如组件渲染结果、配置对象)。不适合频繁变化的数据,否则快照文件会不断需要更新,失去测试价值。异步匹配器:处理 Promiseresolves — 期望 Promise 成功 resolverejects — 期望 Promise 被 reject// 测试异步函数成功返回await expect(fetchUser(1)).resolves.toEqual({ id: 1, name: 'Alice' });// 测试异步函数抛错await expect(fetchUser(-1)).rejects.toThrow('User not found');使用 resolves / rejects 时必须加 await,否则 Jest 无法正确捕获异步结果,测试会提前结束并始终通过。自定义匹配器:让断言更贴合业务语义当内置匹配器无法精确表达你的断言意图时,expect.extend() 允许你创建自己的匹配器。基本结构自定义匹配器接收 received(expect() 传入的值)和自定义参数,返回一个包含 pass 和 message 的对象:expect.extend({ toBeWithinRange(received, floor, ceiling) { const pass = received >= floor && received <= ceiling; return { pass, message: () => pass ? `Expected ${received} NOT to be within range ${floor}–${ceiling}` : `Expected ${received} to be within range ${floor}–${ceiling}`, }; },});test('score is within passing range', () => { expect(85).toBeWithinRange(60, 100); expect(30).not.toBeWithinRange(60, 100);});message 函数要同时处理通过和不通过两种场景。pass 为 true 时,message 描述的是 .not 取反后的预期(因为 .not 让通过的变成失败),反之亦然。在 TypeScript 项目中使用自定义匹配器需要扩展 jest.Matchers 接口,否则 TypeScript 会报类型错误:// 在 jest.d.ts 或 global.d.ts 中声明declare global { namespace jest { interface Matchers<R> { toBeWithinRange(floor: number, ceiling: number): R; } }}实际案例:验证日期范围expect.extend({ toBeDateAfter(received, baseline) { const pass = received instanceof Date && baseline instanceof Date && received > baseline; return { pass, message: () => pass ? `Expected ${received.toISOString()} NOT to be after ${baseline.toISOString()}` : `Expected ${received.toISOString()} to be after ${baseline.toISOString()}`, }; },});test('expiry date is after creation date', () => { const created = new Date('2025-01-01'); const expires = new Date('2026-01-01'); expect(expires).toBeDateAfter(created);});自定义匹配器的最佳实践命名要语义化:toBeValidEmail 比 toMatchEmailRegex 更易读,测试代码读起来像自然语言。输入校验不能省:对 received 做类型检查,遇到非法输入抛出有意义的错误,而不是返回莫名其妙的 pass: false。配合 setupFilesAfterEnv 全局注册:把 expect.extend() 放在独立的 setup 文件中,在 Jest 配置的 setupFilesAfterEnv 里引入,避免每个测试文件重复注册。优先组合内置匹配器:如果只是 expect(a).toBeGreaterThan(x) 和 expect(a).toBeLessThan(y) 的组合,直接用 .and 或写两行断言就够了,不必自定义。自定义匹配器的价值在于表达内置匹配器无法简洁描述的业务规则。选择匹配器的思路遇到断言需求时,按这个顺序选择:值比较 — toBe / toEqual / toStrictEqual类型或存在性 — toBeDefined / toBeNull / toBeTruthy大小或范围 — toBeGreaterThan / toBeCloseTo包含关系 — toContain / toContainEqual / toMatchObject函数行为 — toHaveBeenCalledWith / toThrow异步结果 — resolves / rejects内置都不合适 — expect.extend() 自定义匹配器选对了,测试的可读性和维护性都会上一个台阶。不必死记硬背所有匹配器,理解每个类别的适用场景,需要时查阅即可。自定义匹配器则是把反复出现的断言模式封装成可复用工具,在项目规模变大时尤其值得投入。
服务端阅读 05月27日 19:51

什么是 Jest 快照测试?如何使用快照测试来验证组件输出?

Jest 快照测试(Snapshot Testing)是前端测试中一种高效的质量保障手段,它通过"拍照对比"的方式确保组件输出和数据结构不会发生意外变化。本文将从原理、用法、进阶技巧到常见踩坑,全面讲解快照测试的实践方法。快照测试的工作原理快照测试的核心思路是"第一次运行时记录预期输出,后续运行时与预期比对":首次运行:Jest 将组件的渲染输出序列化为字符串,保存到 __snapshots__/ 目录下的 .snap 文件中后续运行:重新渲染组件,将输出与已保存的快照进行逐行对比差异处理:如果输出与快照不一致,测试失败并在终端展示 diff 信息;开发者确认变更合理后,可更新快照与传统的断言式测试相比,快照测试无需手写每个期望值,尤其适合 UI 组件这种结构复杂的输出对象。基本用法:React 组件快照使用 react-test-renderer 创建组件的渲染树,再调用 toMatchSnapshot() 生成快照:import renderer from 'react-test-renderer';import UserProfile from './UserProfile';test('UserProfile renders correctly', () => { // 创建组件的渲染树 const tree = renderer .create(<UserProfile name="Alice" role="admin" />) .toJSON(); // 首次运行:生成快照文件;后续运行:与快照比对 expect(tree).toMatchSnapshot();});首次运行后,Jest 会在 __snapshots__/UserProfile.test.js.snap 中生成类似以下的快照:exports[`UserProfile renders correctly 1`] = `<div className="user-profile"> <h2> Alice </h2> <span className="role" > admin </span></div>`;如果后续修改了组件结构,快照测试会立即捕获变化并报告差异。使用 React Testing Library 进行快照在现代 React 项目中,更推荐使用 @testing-library/react 结合快照测试:import { render } from '@testing-library/react';import NavMenu from './NavMenu';test('NavMenu snapshot', () => { const { container } = render(<NavMenu items={['Home', 'About', 'Contact']} />); expect(container.firstChild).toMatchSnapshot();});这种方式更贴近用户的真实交互方式,渲染结果也更接近浏览器中的实际 DOM。内联快照:toMatchInlineSnapshottoMatchInlineSnapshot() 将快照内容直接写在测试文件中,而不是单独的 .snap 文件,适合输出较短的场景:test('formatUserInfo returns correct structure', () => { const result = formatUserInfo({ name: 'Bob', age: 28 }); expect(result).toMatchInlineSnapshot(` { "age": 28, "displayName": "Bob", "isActive": true } `);});内联快照的优势在于:快照与测试代码在同一文件中,code review 时更直观;不会产生额外的快照文件。但输出较长时不建议使用,会让测试文件变得臃肿。属性匹配器:处理动态数据当快照中包含动态生成的值(时间戳、UUID、随机数)时,每次运行快照都会不同,导致测试误报。使用属性匹配器可以优雅地解决这个问题:test('user creation response matches expected structure', () => { const response = createUser({ name: 'Charlie', email: 'charlie@example.com' }); expect(response).toMatchSnapshot({ id: expect.any(String), // id 是动态生成的,只验证类型 createdAt: expect.any(Date), // 时间戳也是动态的 token: expect.any(String), // JWT token 每次不同 }); // 其余字段会进行精确匹配});快照文件中对应字段会记录为 Any<String>、Any<Date>,后续运行只校验类型而不校验具体值。自定义序列化器当组件中包含无法直接序列化的对象(如 CSS-in-JS 的样式对象、Moment.js 日期对象)时,可以编写自定义序列化器:// customSerializer.jsconst styleSerializer = { // 判断是否需要自定义序列化 test: (val) => val && val.$$typeof === Symbol.for('react.element'), // 自定义序列化逻辑 print: (val, serialize) => { // 移除动态生成的 className,避免快照频繁变化 const props = { ...val.props }; delete props.className; return serialize({ ...val, props }); },};// 在 jest.config.js 中配置module.exports = { snapshotSerializers: ['./customSerializer.js'],};快照更新的正确姿势当有意修改组件导致快照测试失败时,需要更新快照:# 交互式更新(推荐):逐个确认是否更新jest --updateSnapshot# 简写jest -u# 只更新匹配特定测试名的快照jest -u --testNamePattern="UserProfile"# CI 环境中禁止意外更新jest --ci重要提醒:在 CI/CD 流水线中务必使用 --ci 标志,防止快照被意外更新而掩盖真正的 bug。Vue 组件的快照测试Vue 项目中使用 @vue/test-utils 进行快照测试:import { mount } from '@vue/test-utils';import TodoItem from './TodoItem.vue';test('TodoItem snapshot', () => { const wrapper = mount(TodoItem, { props: { title: 'Learn Jest', completed: false } }); expect(wrapper.html()).toMatchSnapshot();});Vue 的快照通常基于渲染后的 HTML 字符串,比 React 的虚拟 DOM 树更加可读。常见踩坑与解决方案1. 快照文件体积膨胀大组件的快照可能长达数百行,diff 审查成本高。解决方案:将大组件拆分为子组件分别测试;使用 toMatchSnapshot({ mode: 'deep' }) 控制序列化深度。2. 快照测试频繁误报包含动态数据的组件每次渲染输出不同,快照测试反复失败。解决方案:使用属性匹配器(Property Matchers)忽略动态字段;使用自定义序列化器过滤不稳定属性。3. 快照更新沦为"无脑确认"开发者遇到快照失败时不审查 diff,直接 jest -u 更新,导致快照测试失去意义。解决方案:在 CI 中强制使用 --ci 标志;团队 code review 时要求检查快照变更;定期清理过时快照(jest --listTests 配合 --findRelatedTests)。4. 快照测试运行缓慢组件依赖过多,渲染链路长导致快照测试耗时。解决方案:使用 shallow 渲染(浅渲染)代替 mount(全渲染),只渲染当前组件而不渲染子组件。快照测试的适用场景与局限| 适用场景 | 不适用场景 ||---------|-----------|| UI 组件结构回归测试 | 需要验证交互行为(点击、输入) || API 响应数据结构验证 | 需要验证计算逻辑正确性 || 配置文件结构检查 | 频繁变化的动态内容 || 序列化/格式化函数输出验证 | 需要精确数值断言的场景 |快照测试是回归测试的好帮手,但不能替代行为测试和单元测试。推荐将快照测试与 fireEvent、waitFor 等交互测试结合使用,形成完整的测试覆盖。总结快照测试通过"首次记录、后续比对"的方式高效检测 UI 和数据结构的意外变化使用 toMatchSnapshot() 生成外部快照,toMatchInlineSnapshot() 生成内联快照属性匹配器解决动态数据问题,自定义序列化器处理特殊对象CI 中务必使用 --ci 标志,团队 review 流程中必须审查快照变更快照测试适合结构回归,不适合验证交互行为和计算逻辑
服务端阅读 05月27日 19:51

什么是 Jest 测试框架?它有哪些核心特性?

Jest 是由 Facebook(Meta)开发的 JavaScript 测试框架,凭借零配置、内置工具链和出色的开发者体验,已成为前端领域使用率最高的测试框架。根据 State of JS 调查,Jest 的使用率从 2016 年的 8% 增长到 2021 年的 73%,被 Facebook、Airbnb、Spotify 等公司广泛采用。核心特性1. 零配置开箱即用Jest 内置了测试运行器、断言库、Mock 系统、代码覆盖率工具和快照测试功能,无需安装和配置额外依赖即可开始编写测试:npm install --save-dev jest在 package.json 中添加测试脚本后即可运行:{ "scripts": { "test": "jest" }}2. 内置断言库与丰富的匹配器Jest 提供了 expect 断言函数和大量匹配器(matchers),覆盖常见断言场景:test('基础匹配器示例', () => { // 相等性判断 expect(1 + 1).toBe(2); // 严格相等 === expect({ a: 1 }).toEqual({ a: 1 }); // 深度相等 // 真值判断 expect(true).toBeTruthy(); expect(null).toBeFalsy(); expect(undefined).toBeUndefined(); expect(1).toBeDefined(); // 数字比较 expect(0.1 + 0.2).toBeCloseTo(0.3); // 浮点数近似 expect(5).toBeGreaterThan(3); // 字符串匹配 expect('hello world').toMatch(/world/); // 数组与异常 expect([1, 2, 3]).toContain(2); expect(() => { throw new Error('err'); }).toThrow('err');});3. 强大的 Mock 系统Mock 是 Jest 最核心的能力之一,可以模拟函数、模块和定时器,隔离被测代码的外部依赖:// 模拟函数const mockFn = jest.fn();mockFn.mockReturnValue(42);mockFn(); // 返回 42// 验证调用情况expect(mockFn).toHaveBeenCalled();expect(mockFn).toHaveBeenCalledTimes(1);// 模拟模块jest.mock('axios');axios.get.mockResolvedValue({ data: { name: 'test' } });// 模拟实现const calc = jest.fn((a, b) => a + b);calc(1, 2); // 返回 3expect(calc).toHaveBeenCalledWith(1, 2);4. 快照测试快照测试用于捕获组件或函数的输出,在后续运行中对比是否发生变化,特别适合 UI 组件测试:test('按钮组件快照', () => { const { container } = render(<Button label="Submit" />); expect(container).toMatchSnapshot();});首次运行会生成 .snap 快照文件,后续运行自动对比。如果变化是预期的,使用 jest --updateSnapshot 更新。5. 并行执行与性能优化Jest 自动并行执行测试文件,利用 Worker 进程充分使用多核 CPU,显著提高测试速度。还支持以下优化策略:--onlyChanged:只运行受改动影响的测试文件--findRelatedTests:运行与指定文件相关的测试缓存机制:自动缓存未变更文件的测试结果6. 内置代码覆盖率无需额外安装 Istanbul 等工具,Jest 内置覆盖率统计:jest --coverage可生成行覆盖率、分支覆盖率、函数覆盖率和语句覆盖率报告,支持 HTML 可视化输出。核心概念测试组织结构describe('Calculator', () => { beforeAll(() => { /* 所有测试前执行一次 */ }); afterAll(() => { /* 所有测试后执行一次 */ }); beforeEach(() => { /* 每个测试前执行 */ }); afterEach(() => { /* 每个测试后执行 */ }); test('adds two numbers', () => { expect(add(1, 2)).toBe(3); }); test('subtracts two numbers', () => { expect(subtract(5, 3)).toBe(2); });});describe:将相关测试用例分组,支持嵌套test/it:定义单个测试用例钩子函数:beforeAll/afterAll/beforeEach/afterEach 管理测试生命周期异步测试Jest 支持多种异步测试方式:// 回调方式test('callback', (done) => { fetchData((data) => { expect(data).toBe('result'); done(); });});// Promise 方式test('promise', () => { return fetchData().then(data => { expect(data).toBe('result'); });});// async/await 方式(推荐)test('async/await', async () => { const data = await fetchData(); expect(data).toBe('result');});// resolves/rejects 匹配器test('resolves matcher', () => { return expect(fetchData()).resolves.toBe('result');});面试常见追问Jest 的测试隔离机制是什么?每个测试文件在独立的模块作用域中执行,beforeEach/afterEach 确保测试之间状态不共享。Jest 通过 jest.isolateModules() 或自动的模块注册表隔离来防止测试间污染。spyOn 和 jest.fn() 有什么区别?jest.fn() 创建全新的模拟函数,不保留原始实现jest.spyOn(object, method) 包装对象的现有方法,保留原始实现,可通过 .mockImplementation() 替换,用 .mockRestore() 恢复const spy = jest.spyOn(console, 'log').mockImplementation(() => {});// 测试结束后恢复spy.mockRestore();快照测试的局限是什么?快照可能过于宽泛,导致即使有 bug 也通过对比大型快照难以 review,容易盲目更新不适合频繁变更的 UI 或动态数据最佳实践:保持快照小而精确,使用 toMatchSnapshot 配合自定义匹配器。与其他框架对比| 特性 | Jest | Mocha | Vitest ||------|------|-------|--------|| 配置 | 零配置 | 需搭配 chai/sinon/nyc | 兼容 Jest API,零配置 || 断言库 | 内置 | 需额外安装 | 内置 || Mock | 内置 | 需搭配 Sinon | 内置 || 快照测试 | 内置 | 需额外插件 | 内置 || 执行速度 | 快(并行) | 较慢 | 最快(ESM 原生) || ESM 支持 | 实验性 | 支持 | 原生支持 || 生态成熟度 | 最成熟 | 成熟 | 快速增长 |Vitest 是 Jest 的新兴替代,与 Vite 生态深度整合,在 ESM 原生支持和执行速度上有优势,但 Jest 的生态和社区资源仍然最为丰富。总结Jest 的核心优势在于"一站式"测试体验——内置断言、Mock、快照和覆盖率,零配置即可运行,并行执行保证速度。面试中需重点掌握 Mock 系统(jest.fn/jest.spyOn/jest.mock)、异步测试三种方式和快照测试原理。在新项目中如果使用 Vite,可以优先考虑 Vitest 作为替代。
服务端阅读 05月27日 19:50

Jest Mock 怎么用?从 Mock 函数到模块替换全解析

为什么需要 Mock?在单元测试中,被测代码往往依赖外部模块(如 API 请求、数据库、第三方库)。直接调用这些依赖会导致测试变慢、不稳定、难以控制返回值。Jest 的 Mock 功能可以替换依赖的行为,让测试专注于被测逻辑本身。一、创建 Mock 函数jest.fn() 是创建 Mock 函数最基本的方式,它会生成一个空函数并记录所有调用信息:const mockFn = jest.fn();mockFn('hello');mockFn('world');console.log(mockFn.mock.calls);// [['hello'], ['world']]console.log(mockFn.mock.results);// [{ type: 'return', value: undefined }, { type: 'return', value: undefined }]mockFn.mock 对象包含三个关键属性:| 属性 | 说明 ||------|------|| mock.calls | 每次调用的参数列表 || mock.results | 每次调用的返回值 || mock.instances | 每次调用时的 this 值 |二、控制 Mock 返回值mockReturnValue — 固定返回值const getAge = jest.fn().mockReturnValue(25);console.log(getAge()); // 25console.log(getAge()); // 25(每次都返回相同值)mockReturnValueOnce — 一次性返回值const getRandom = jest.fn() .mockReturnValueOnce(1) .mockReturnValueOnce(2) .mockReturnValue(0);console.log(getRandom()); // 1console.log(getRandom()); // 2console.log(getRandom()); // 0(Once 用完后回落到 mockReturnValue)mockResolvedValue — 异步返回值const fetchUser = jest.fn().mockResolvedValue({ name: 'Alice' });// 在测试中使用 async/awaitconst user = await fetchUser(1);expect(user).toEqual({ name: 'Alice' });mockResolvedValueOnce 同理,仅生效一次。三、自定义 Mock 实现当需要根据参数动态返回不同值时,使用 mockImplementation:const calculate = jest.fn().mockImplementation((a, b) => a + b);expect(calculate(1, 2)).toBe(3);也可以在 jest.fn() 中直接传入实现:const greet = jest.fn(name => `Hello, ${name}!`);进阶用法 — 根据调用次数返回不同值:const fn = jest.fn() .mockImplementationOnce(() => 'first') .mockImplementationOnce(() => 'second') .mockImplementation(() => 'default');四、Mock 整个模块这是实际项目中最常用的场景 — 替换外部模块的导出:替换默认导出// api.jsexport default function fetchData() { return fetch('/api/data');}// __tests__/component.test.jsjest.mock('../api', () => ({ __esModule: true, default: jest.fn(() => Promise.resolve({ data: 'mocked' }))}));import fetchData from '../api';test('使用模拟的 API 数据', async () => { const result = await fetchData(); expect(result).toEqual({ data: 'mocked' });});替换命名导出// utils.jsexport function formatDate(date) { /* ... */ }export function parseJSON(str) { /* ... */ }// 仅 Mock formatDate,保留 parseJSON 原始实现(Partial Mock)jest.mock('../utils', () => ({ ...jest.requireActual('../utils'), formatDate: jest.fn(() => '2026-01-01')}));使用 __mocks__ 目录自动 Mock在模块同目录下创建 __mocks__/api.js:// __mocks__/api.jsexport default function fetchData() { return Promise.resolve({ data: 'from automock' });}测试文件只需声明 jest.mock('../api'),Jest 会自动查找 __mocks__ 目录。五、SpyOn — 监视真实函数jest.spyOn 在不替换原函数的情况下追踪调用,也可以按需 Mock:const math = { add: (a, b) => a + b,};test('spy 追踪调用但不改变行为', () => { const spy = jest.spyOn(math, 'add'); expect(math.add(1, 2)).toBe(3); // 原函数正常执行 expect(spy).toHaveBeenCalledWith(1, 2); // 同时记录了调用});test('spy 也可以临时替换实现', () => { jest.spyOn(math, 'add').mockReturnValue(999); expect(math.add(1, 2)).toBe(999); // 被替换了 math.add.mockRestore(); // 恢复原函数});六、常用断言| 断言 | 说明 ||------|------|| toHaveBeenCalled() | 至少被调用一次 || toHaveBeenCalledTimes(n) | 被调用了 n 次 || toHaveBeenCalledWith(...args) | 曾用指定参数调用 || toHaveBeenLastCalledWith(...args) | 最后一次调用的参数 || toHaveReturnedWith(value) | 曾返回指定值 || toHaveLastReturnedWith(value) | 最后一次返回的值 || toHaveReturnedTimes(n) | 成功返回了 n 次 |七、清理 Mock测试之间未清理的 Mock 会导致状态泄漏,务必在 afterEach 或 afterAll 中清理:afterEach(() => { jest.clearAllMocks(); // 清除所有 mock.calls、mock.results,但保留实现});afterAll(() => { jest.restoreAllMocks(); // 恢复所有 spyOn 的原始实现});| 方法 | 效果 ||------|------|| jest.clearAllMocks() | 清除调用记录,保留 mock 实现 || jest.resetAllMocks() | 清除调用记录 + 清除 mock 实现(恢复为空函数) || jest.restoreAllMocks() | 恢复 spyOn 的原始实现 |八、常见问题与最佳实践问题1:Mock 不生效jest.mock 会被提升(hoisted)到文件顶部,如果回调中使用了变量,该变量可能尚未定义。解决方案:// 错误 — mockFactory 尚未定义const mockFactory = () => jest.fn();jest.mock('../module', mockFactory);// 正确 — 使用动态函数jest.mock('../module', () => ({ myMethod: jest.fn()}));问题2:Timer Mock测试 setTimeout、setInterval 相关逻辑时:jest.useFakeTimers();test('延迟执行', () => { const callback = jest.fn(); setTimeout(callback, 1000); jest.advanceTimersByTime(1000); expect(callback).toHaveBeenCalled();});最佳实践Mock 外部依赖,不 Mock 被测代码本身 — 否则测试失去意义优先使用 spyOn 而非 jest.fn 替换 — 便于恢复原始行为每个测试前确保 Mock 状态干净 — 避免测试间相互影响Mock 的行为应尽量贴近真实 — 否则测试通过但代码可能在生产环境失败不要过度 Mock — 如果一个测试中 Mock 了超过 3 个依赖,考虑是否测试粒度不对
服务端阅读 05月27日 19:50

Jest 生命周期钩子有哪些?beforeAll、afterAll、beforeEach 和 afterEach 怎么用?

Jest 提供了四个生命周期钩子函数,用于在测试的不同阶段执行设置和清理操作。理解它们的执行时机和使用场景,是编写可靠测试的基础。四个钩子函数概览| 钩子 | 执行时机 | 典型用途 ||------|---------|---------|| beforeAll | 当前 describe 块所有测试运行前,仅执行一次 | 建立数据库连接、启动服务器 || afterAll | 当前 describe 块所有测试运行后,仅执行一次 | 关闭数据库连接、停止服务器 || beforeEach | 当前 describe 块每个测试运行前,每次都执行 | 重置状态、初始化数据 || afterEach | 当前 describe 块每个测试运行后,每次都执行 | 清除 Mock、还原定时器 |beforeAll 与 afterAll:一次性设置与清理beforeAll 适合需要一次投入成本的场景,避免在每个测试前重复执行:let db;beforeAll(async () => { db = await connectDatabase('test_db'); await db.createTables();});afterAll(async () => { await db.dropTables(); await db.close();});test('should insert user', async () => { await db.insert('users', { name: 'Alice' }); const users = await db.query('SELECT * FROM users'); expect(users).toHaveLength(1);});注意: beforeAll 中如果发生错误,该 describe 块内的所有测试都会失败。beforeEach 与 afterEach:逐测试隔离beforeEach 和 afterEach 保证每个测试在独立环境中运行,是测试隔离的核心手段:let users;beforeEach(() => { users = [{ id: 1, name: 'Alice' }];});afterEach(() => { jest.clearAllMocks(); jest.useRealTimers();});test('should add user', () => { users.push({ id: 2, name: 'Bob' }); expect(users).toHaveLength(2);});test('should not be affected by previous test', () => { // beforeEach 重置了 users,这里仍然是初始状态 expect(users).toHaveLength(1);});钩子的执行顺序当存在嵌套 describe 时,钩子按照从外到内的顺序执行 setup,从内到外的顺序执行 teardown:describe('Outer', () => { beforeAll(() => console.log('Outer beforeAll')); beforeEach(() => console.log('Outer beforeEach')); afterEach(() => console.log('Outer afterEach')); afterAll(() => console.log('Outer afterAll')); describe('Inner', () => { beforeAll(() => console.log('Inner beforeAll')); beforeEach(() => console.log('Inner beforeEach')); afterEach(() => console.log('Inner afterEach')); afterAll(() => console.log('Inner afterAll')); test('example', () => { console.log('--- test runs ---'); }); });});执行顺序输出:Outer beforeAllInner beforeAllOuter beforeEachInner beforeEach--- test runs ---Inner afterEachOuter afterEachInner afterAllOuter afterAll关键规则: 外层 beforeEach 先于内层执行,外层 afterEach 后于内层执行——这保证了内层可以依赖外层的设置,同时内层的清理不会影响外层。异步钩子钩子函数支持异步操作,三种写法均可:// 方式一:async/await(推荐)beforeAll(async () => { await initializeService();});// 方式二:返回 PromisebeforeAll(() => { return fetch('/api/setup').then(res => res.json());});// 方式三:单个参数 done 回调beforeAll((done) => { startServer(done);});如果异步钩子超时,可以设置自定义超时时间:beforeAll(async () => { await heavySetup();}, 30000); // 30 秒超时常见陷阱1. 在 beforeAll 中修改共享状态,在 afterEach 中忘记清理// 错误:beforeAll 修改了全局状态,但 afterAll 没有还原beforeAll(() => { process.env.NODE_ENV = 'test';});// 其他测试文件可能受到影响// 正确:配对使用 afterAll 还原beforeAll(() => { originalEnv = process.env.NODE_ENV; process.env.NODE_ENV = 'test';});afterAll(() => { process.env.NODE_ENV = originalEnv;});2. 混淆 beforeAll 和 beforeEach 的使用场景beforeAll:设置成本高、测试之间可共享(数据库连接、服务器启动)beforeEach:每个测试需要独立副本(状态重置、数据初始化)如果测试之间有依赖或顺序敏感,优先使用 beforeEach 保证隔离。3. 钩子中的错误导致测试全部跳过beforeAll 抛出错误时,该 describe 块内所有测试直接失败。如果部分初始化失败不应阻断所有测试,考虑将初始化移入 beforeEach 并做容错处理。最佳实践优先使用 beforeEach/afterEach 保证隔离,仅在设置成本确实很高时才用 beforeAllafterEach 中务必清理 Mock 和定时器:jest.clearAllMocks() + jest.useRealTimers()保持钩子函数简洁,复杂逻辑拆分为辅助函数配对使用:有 before 就有对应的 after,确保资源不泄漏避免在钩子间传递状态,每个测试应能独立运行
服务端阅读 05月27日 19:46

Koa 中如何管理 Cookie 和 Session?

Cookie 和 Session 到底有什么区别?HTTP 协议是无状态的,服务器收到请求后没办法知道这个请求是谁发的。Cookie 和 Session 都是为了解决这个问题,但路子完全不同:Cookie 是服务器写给浏览器的一小段数据,浏览器每次请求自动带上,容量约 4KBSession 是服务器自己存的数据,通过一个 Session ID 跟浏览器对应起来,大小没限制两者的配合方式:服务端创建 Session,生成唯一的 Session ID,通过 Set-Cookie 响应头下发给浏览器;后续每次请求浏览器自动带着这个 Cookie,服务器拿 Session ID 去查对应的 Session 数据。一个容易混淆的点:Session ID 本身就是通过 Cookie 传递的,所以 Session 依赖 Cookie,但 Cookie 可以独立使用(比如存用户偏好、主题设置等)。Koa 里怎么读写 Cookie?Koa 内置了 Cookie 支持,不需要额外装中间件,直接用 ctx.cookies 就行。设置 Cookieapp.use(async (ctx) => { // 最简单的写法 ctx.cookies.set('name', 'value'); // 带完整选项 ctx.cookies.set('token', 'abc123', { maxAge: 86400000, // 有效期,单位毫秒,这里是一天 expires: new Date('2026-12-31'), // 过期时间点,和 maxAge 二选一 path: '/', // 生效路径,默认 / domain: '.example.com', // 生效域名 secure: true, // 只在 HTTPS 下传输 httpOnly: true, // JS 不能读,防 XSS sameSite: 'strict', // 同源才带,防 CSRF signed: true // 签名防篡改 }); ctx.body = 'Cookie 已设置';});几个选项容易踩坑:httpOnly: true 不是可选项,是必选项。没有它,一段 XSS 脚本就能用 document.cookie 把你的登录凭证偷走sameSite 有三个值:strict(最严,跨站一律不带)、lax(导航到目标站点的 GET 请求会带,是浏览器默认值)、none(都带,但必须配 secure: true)signed: true 依赖 app.keys,没设置 keys 会报错。签名防的是篡改,不是加密——签过名的 Cookie 值客户端仍然能解码看到读取 Cookieapp.use(async (ctx) => { const name = ctx.cookies.get('name'); ctx.body = `你好,${name}`;});设置了 signed: true 的 Cookie,ctx.cookies.get() 会自动校验签名。签名不对返回 undefined,不是报错——这一点要留意,调试时别以为是自己没存上。删除 Cookiectx.cookies.set('name', null, { maxAge: 0 });把 maxAge 设成 0 就行。有个细节:path 和 domain 必须跟设置时完全一致,否则浏览器匹配不到那个 Cookie,删除操作会静默失败。这个坑在本地调试时特别容易遇到——设置了 /api 路径的 Cookie,删除时没带路径,结果怎么也删不掉。Koa 中 Session 怎么用?Koa 核心不带 Session,需要装 koa-session 中间件。安装和基本配置npm install koa-sessionconst session = require('koa-session');// 必须先设置 keys,用于 Cookie 签名app.keys = ['some-secret-key'];app.use(session({ key: 'koa.sess', // Cookie 里存 Session ID 的字段名 maxAge: 86400000, // Session 有效期,毫秒 httpOnly: true, // JS 不可读 signed: true, // 签名防篡改 rolling: false, // 每次请求是否重置过期倒计时 renew: false // 快过期时是否自动续期}, app));app.keys 支持数组,用于密钥轮换:app.keys = ['new-key', 'old-key'];签名用第一个密钥,校验按顺序尝试。换密钥时把新密钥放前面、旧密钥保留一段时间,已有的 Session 不会突然失效。两个容易忽略的配置项:rolling: true -- 每次请求都刷新 Cookie 的过期时间,适合需要保持活跃会话的场景(比如后台管理系统),但会增加 Cookie 写入频率renew: true -- 只在 Session 快过期时自动续期,比 rolling 更轻量,是大多数场景的推荐选项读写 Session// 登录 —— 写入 Sessionapp.use(async (ctx) => { if (ctx.path === '/login' && ctx.method === 'POST') { const { username, password } = ctx.request.body; const user = await authenticate(username, password); if (user) { ctx.session.user = { id: user.id, name: user.name }; ctx.body = { message: '登录成功' }; } else { ctx.throw(401, '用户名或密码错误'); } }});// 受保护页面 —— 读取 Sessionapp.use(async (ctx) => { if (ctx.path === '/profile') { if (!ctx.session.user) { ctx.throw(401, '请先登录'); } ctx.body = `欢迎,${ctx.session.user.name}`; }});// 登出 —— 销毁 Sessionapp.use(async (ctx) => { if (ctx.path === '/logout') { ctx.session = null; ctx.body = '已登出'; }});销毁 Session 用 ctx.session = null 就够了,不需要逐个删属性。koa-session 会同时清掉服务端的 Session 数据和浏览器端的 Cookie。koa-session 默认把 Session 数据存在哪里?这是个关键问题:koa-session 默认把 Session 数据序列化后直接塞进 Cookie 里。也就是说,浏览器每次请求都带着完整的 Session 数据。这种默认行为有三个问题:4KB 上限 -- Cookie 有大小限制,Session 数据稍大就会被截断,而且报错不明显,容易排查半天才发现是 Cookie 溢出数据可读 -- 签名只防篡改,不防窥探。Session 数据只是 Base64 编码,浏览器开发者工具里一眼就能看到内容,敏感信息绝不能放进去带宽浪费 -- 每次请求都带着全量 Session 数据往返,用户量大了以后带宽开销不小开发阶段用默认配置图方便没问题,上线之前必须换外部存储。生产环境怎么用 Redis 存储 Session?为什么选 Redis纯内存操作,读写延迟在微秒级,Session 是高频读写场景,非常匹配原生支持 TTL 过期,和 Session 的生命周期管理天然吻合支持多实例共享,部署多个 Node 进程时只要连同一个 Redis 就行配置方式npm install koa-session ioredisconst session = require('koa-session');const Redis = require('ioredis');const redis = new Redis({ host: '127.0.0.1', port: 6379, password: 'your-password', db: 0});// koa-session 需要的 store 接口只有三个方法const redisStore = { async get(key) { const data = await redis.get(`session:${key}`); return data ? JSON.parse(data) : null; }, async set(key, sess, maxAge) { await redis.set(`session:${key}`, JSON.stringify(sess), 'EX', maxAge / 1000); }, async destroy(key) { await redis.del(`session:${key}`); }};app.use(session({ store: redisStore, key: 'koa.sess', maxAge: 86400000, httpOnly: true, signed: true}, app));配置 Redis 之后,Cookie 里只剩一个 Session ID,真正的数据全在 Redis 里。应用部署多个实例也没问题,只要连的是同一个 Redis 集群,Session 就能跨实例共享。几个生产环境的注意点:Redis 连接建议用连接池或集群模式,单点 Redis 挂了 Session 全丢key 的前缀(上面的 session:)按业务区分,避免和其他 Redis 数据冲突maxAge / 1000 是把毫秒转成秒,Redis 的 EX 参数单位是秒,这里容易写错其他存储方案除了 Redis,常见的还有:MongoDB -- 用 connect-mongo 之类的适配器,适合已经有 MongoDB 的项目,但性能不如 RedisMySQL -- 不推荐,关系型数据库做高频 Session 读写是大材小用,性能也跟不上Memcached -- 和 Redis 类似的内存缓存,但不如 Redis 生态完善,现在用的人少了怎么实现登录认证中间件?认证中间件的核心就是一件事:检查 Session 里有没有用户信息,没有就拦截。async function authRequired(ctx, next) { if (!ctx.session.user) { ctx.throw(401, '未登录'); } await next();}// 只对需要认证的路由生效router.get('/api/profile', authRequired, async (ctx) => { ctx.body = ctx.session.user;});router.get('/api/settings', authRequired, async (ctx) => { ctx.body = await getUserSettings(ctx.session.user.id);});中间件放在路由处理函数前面,没登录的请求在中间件层就打回去了,不会进业务逻辑。更完善的做法是加上角色校验:function roleRequired(...roles) { return async (ctx, next) => { if (!ctx.session.user) { ctx.throw(401, '未登录'); } if (!roles.includes(ctx.session.user.role)) { ctx.throw(403, '权限不足'); } await next(); };}router.delete('/api/users/:id', roleRequired('admin'), async (ctx) => { // 只有 admin 角色能访问});Session 和 JWT 该怎么选?两者不是非此即彼,但在不同场景下各有优势:| 对比维度 | Session | JWT ||---------|---------|-----|| 存储位置 | 服务端(内存/Redis) | 客户端(Cookie/Header) || 状态 | 有状态,服务端维护会话 | 无状态,服务端不存数据 || 水平扩展 | 需要共享存储(Redis) | 天然支持,哪里都能验 || 主动失效 | 删掉服务端 Session 就行 | 做不到,只能等过期 || 数据安全 | 数据在服务端,客户端看不到 | Payload 只是 Base64,谁都能解码 || 实现复杂度 | 需要维护存储和清理 | 签发即忘,但吊销很麻烦 |选型建议:传统 Web 应用(SSR) -- 用 Session。浏览器自动管理 Cookie,登出即失效,权限变更即时生效,开发体验最简单前后端分离 / API 服务 -- 用 JWT。无状态减少服务端压力,适合微服务架构,客户端自己存 token高安全要求 -- 两者结合:JWT 做接口认证(短期有效),关键操作再校验 Session(服务端可控)。银行、支付这类场景经常这么干一个常见误区:觉得 JWT 无状态就一定比 Session 好。实际上 JWT 做不到主动失效,一旦签发就无法撤回。如果你需要"踢人下线"或"立即撤销权限"的能力,Session 反而更合适。Cookie 和 Session 的安全防护有哪些要点?Cookie 安全清单httpOnly: true -- 必设项。没有这个,XSS 攻击能直接偷 Cookiesecure: true -- 生产环境必须开。确保 Cookie 只在 HTTPS 下传输,防止中间人窃听sameSite: 'strict' 或 'lax' -- 阻止跨站请求携带 Cookie,从源头防 CSRF。strict 最安全但可能影响从外链跳转的体验,lax 是较好的折中signed: true -- 签名防篡改,客户端改了 Cookie 值服务端能发现Cookie 前缀 -- __Host- 前缀强制 secure、不设 domain、path 为 /;__Secure- 前缀强制 secure。浏览器会自动执行这些约束,推荐用在敏感 Cookie 上Session 安全清单app.keys 用强随机字符串,至少 32 位,从环境变量读取,不要硬编码在代码里设置合理的 maxAge,不要设成永不过期。通常 1-7 天,根据业务调整登出必须 ctx.session = null 彻底销毁,别只删 ctx.session.user生产环境必须用 Redis 等外部存储,内存存储重启就丢,也没法跨进程共享Session Fixation 防护Session Fixation 攻击的原理是:攻击者获取一个有效的 Session ID,诱骗受害者使用这个 ID 登录,攻击者就能用同一个 Session ID 访问受害者的会话。防护方法:登录成功后重新生成 Session ID。app.use(async (ctx) => { if (ctx.path === '/login' && ctx.method === 'POST') { const user = await authenticate(ctx.request.body); if (user) { // 登录成功,先销毁旧 Session 再创建新的 ctx.session = null; ctx.session.user = { id: user.id, name: user.name }; // koa-session 会在响应时生成新的 Session ID ctx.body = { message: '登录成功' }; } }});其他防护措施限制同一账号的并发 Session 数量,防止 Session 被盗用后长期使用记录认证日志(登录 IP、时间、设备),异常行为可以及时发现实现登录失败次数限制和延迟,5 次失败后锁定 15 分钟,防暴力破解Session ID 要足够长且随机,用 crypto.randomBytes(32) 生成,避免被猜测或碰撞敏感操作(修改密码、绑定手机)要求重新验证身份,不要仅依赖已有 Session
服务端阅读 05月27日 19:41

Koa 洋葱模型的执行机制是怎样的?有哪些实际应用场景

Koa 洋葱模型到底是什么先看一个现象:在 Koa 里写三个中间件,控制台打印的顺序是 1-前置 → 2-前置 → 3-核心处理 → 2-后置 → 1-后置。请求像穿透洋葱一样从外层进到最里层,再从里层一层层返回——这就是"洋葱模型"的名字由来。这个机制不是 Koa 凭空发明的,它借鉴了 koa-compose 的函数组合思想,核心就一句话:每个中间件拿到 next 函数,调用它就进入下一层,await 它返回后就执行后置逻辑。执行流程拆解用一个最小可运行的例子说明:const Koa = require('koa');const app = new Koa();app.use(async (ctx, next) => { console.log('1-前置'); await next(); console.log('1-后置');});app.use(async (ctx, next) => { console.log('2-前置'); await next(); console.log('2-后置');});app.use(async (ctx) => { console.log('3-核心处理'); ctx.body = 'Hello Koa';});app.listen(3000);请求进来后,执行路径是这样的:进入第一个中间件,执行 console.log('1-前置')遇到 await next(),暂停当前中间件,进入第二个中间件执行 console.log('2-前置'),再遇到 await next(),进入第三个中间件第三个中间件没有调用 next,设置 ctx.body 后返回回到第二个中间件,执行 await next() 之后的 console.log('2-后置')回到第一个中间件,执行 console.log('1-后置')关键点在于 await next() 这一行。它不是简单的函数调用,而是一个 Promise——下一个中间件(以及它后续的所有中间件)全部执行完毕后,这个 Promise 才 resolve。所以 await 之后的代码天然就在所有下游中间件之后执行。compose 函数怎么实现的洋葱模型的本质是 koa-compose,核心代码不到 30 行:function compose(middleware) { return function (context, next) { let index = -1; function dispatch(i) { if (i <= index) return Promise.reject(new Error('next() called multiple times')); index = i; let fn = i === middleware.length ? next : middleware[i]; if (!fn) return Promise.resolve(); try { return Promise.resolve(fn(context, dispatch.bind(null, i + 1))); } catch (err) { return Promise.reject(err); } } return dispatch(0); };}dispatch(i) 取出第 i 个中间件,把 dispatch(i+1) 作为 next 传进去。每个中间件内部 await next() 就是 await dispatch(i+1),递归调用下一层。当 i 等于 middleware.length 时,fn 为 next(外层传入的,通常为 undefined),递归终止。有一个容易忽略的细节:index 变量用来检测 next() 是否被调用了多次。同一个中间件里调用两次 next() 会抛错,因为第二次调用时 i <= index 成立。这是有意为之——多次调用 next() 会导致下游中间件重复执行,产生不可预期的行为。和 Express 中间件有什么区别Express 的中间件是线性的:调用 next() 之后,控制权交给下一个中间件,不会再回来。Koa 的洋葱模型让控制权"去了又回",这是最根本的区别。// Express 风格app.use((req, res, next) => { console.log('前置'); next(); // 交出控制权,不再回来 console.log('这行也会执行,但响应可能已经发出');});// Koa 风格app.use(async (ctx, next) => { console.log('前置'); await next(); // 等下游全部完成,控制权回来 console.log('后置,此时可以修改响应');});这意味着在 Koa 里,后置逻辑可以可靠地操作响应——比如统一格式化返回值、记录响应日志、计算耗时。Express 里 next() 后面的代码虽然也能执行,但响应可能已经被下游发出了,再改就晚了。另一个区别是错误处理。Express 需要在中间件链末尾放一个四个参数的错误处理中间件 (err, req, res, next) => {}。Koa 只需要在最外层 try-catch:app.use(async (ctx, next) => { try { await next(); } catch (err) { ctx.status = err.status || 500; ctx.body = { error: err.message }; }});因为洋葱模型保证了外层中间件的后置逻辑一定会执行,所以 try-catch 能捕获到任何内层抛出的异常。实际项目中怎么用洋葱模型请求耗时统计app.use(async (ctx, next) => { const start = Date.now(); await next(); const ms = Date.now() - start; ctx.set('X-Response-Time', `${ms}ms`);});前置逻辑记录开始时间,后置逻辑计算差值并写入响应头。这是洋葱模型最直观的用法——前置做初始化,后置做收尾。统一错误处理app.use(async (ctx, next) => { try { await next(); } catch (err) { ctx.status = err.statusCode || 500; ctx.body = { code: ctx.status, message: err.message }; // 生产环境不暴露堆栈 if (process.env.NODE_ENV !== 'production') { ctx.body.stack = err.stack; } }});放在最外层,任何内层抛出的异常都会被捕获。不需要在每个路由里单独 try-catch。认证与权限控制app.use(async (ctx, next) => { const token = ctx.headers.authorization; if (!token) { ctx.throw(401, '未登录'); } try { ctx.state.user = jwt.verify(token.replace('Bearer ', ''), SECRET); } catch { ctx.throw(401, 'token 无效'); } await next();});如果认证失败,直接抛错不调用 next(),下游中间件不会执行。这是洋葱模型的另一个特性:中间件可以选择"截断"请求,不往下传。响应格式统一app.use(async (ctx, next) => { await next(); if (ctx.body && !ctx.body.code) { ctx.body = { code: 0, data: ctx.body, message: 'success' }; }});后置逻辑里检查 ctx.body,如果路由返回的是裸数据,就包装成统一格式。业务代码不需要关心响应结构。使用洋葱模型容易踩的坑忘记 await next()app.use(async (ctx, next) => { console.log('前置'); next(); // 忘记 await console.log('后置'); // 会立即执行,不等下游完成});next() 返回 Promise,不加 await 后置逻辑会立即执行,洋葱模型失效。更严重的是,如果下游中间件是异步操作(查数据库、调接口),后置逻辑执行时响应可能还没准备好。中间件顺序搞反洋葱模型里,先注册的中间件包裹在后注册的外面。所以日志和错误处理要放最前面,路由放最后面。顺序写反了,错误处理就捕获不到路由层的异常。在后置逻辑里修改请求有些开发者习惯在后置逻辑里继续操作 ctx.request,但此时请求已经处理完了,修改请求对象没有意义。后置逻辑应该只操作 ctx.response 或 ctx.body。洋葱模型适用于哪些场景不是所有场景都需要洋葱模型。如果你的应用只有简单的请求-响应,Express 的线性中间件更直观。洋葱模型的优势在于需要在请求前后都执行逻辑的场景:日志、计时、错误兜底、认证拦截、响应包装。中间件越多、前后置逻辑越复杂,洋葱模型的价值越大。理解洋葱模型的关键不是记住执行顺序,而是理解 await next() 是一个分界线——之前的代码在请求进入时执行,之后的代码在响应返回时执行。把握住这一点,写中间件就不会出错。
服务端阅读 05月27日 19:41

Kubernetes Ingress 是什么?它如何实现外部访问集群内服务?

Kubernetes Ingress 是什么当你把一个 Web 应用部署到 Kubernetes 集群后,集群外部的用户怎么访问它?最直接的方式是用 Service 的 NodePort 或 LoadBalancer 类型暴露端口,但前者端口范围有限且不安全,后者每个 Service 都要占用一个云厂商的负载均衡器,成本很高。Ingress 就是解决这个问题的方案。它是一种 API 对象,在集群入口处统一管理 HTTP 和 HTTPS 路由规则,根据域名和路径把流量分发到不同的 Service。你可以把它理解成集群的"前台接待"——所有外部请求先到 Ingress,再由它根据规则转给对应的后端服务。Ingress 能做的事情包括:基于域名的路由:api.example.com 走 A 服务,web.example.com 走 B 服务,共享同一个入口 IP基于路径的路由:example.com/api 走后端 API 服务,example.com/app 走前端服务TLS 终止:在 Ingress 层处理 HTTPS 握手,后端 Service 只需跑 HTTP,证书管理集中化路径重写:把 /v2/api 重写为 /api 再转发给后端负载均衡:在多个 Pod 副本之间分发请求一个请求从到达到转发的完整链路理解 Ingress 工作方式的关键是搞清楚请求的完整路径:外部客户端发送请求到 https://api.example.com/usersDNS 将域名解析到 Ingress Controller 暴露的 IP(通常是一个 LoadBalancer Service)请求到达 Ingress Controller,Controller 检查 TLS 证书完成 HTTPS 握手Controller 根据 Host 头和路径匹配 Ingress 规则,找到对应的 ServiceController 将请求转发给 Service 关联的某个 Pod(通过 Endpoints 列表)Pod 处理请求并返回响应注意:Ingress 资源本身只是规则定义,真正干活的是 Ingress Controller。没有 Controller,Ingress 规则就是一纸空文。Ingress Controller 选型Ingress Controller 是 Ingress 功能的实际执行者,它监听集群中 Ingress 资源的变化,动态更新自己的配置(比如 NGINX 的 upstream 配置),然后按照规则转发流量。NGINX Ingress Controller社区使用最广泛的方案,基于 NGINX/OpenResty 实现。功能成熟、社区活跃、文档齐全,支持限流、认证、CORS、自定义错误页、灰度发布等高级特性。如果你没有特殊需求,选它基本不会出错。Traefik云原生设计,原生支持自动服务发现和 Let's Encrypt 自动证书申请,配置方式比 NGINX 更直观(支持文件和 CRD 两种 provider)。适合追求配置简洁和自动化的团队。HAProxy Ingress基于 HAProxy 实现,强项是高性能和丰富的负载均衡算法(加权轮询、最少连接、一致性哈希等)。对性能有极致要求时可以考虑。Istio Gateway服务网格方案中的入口网关,除了基本路由还支持 mTLS、流量镜像、故障注入、熔断等服务网格能力。如果集群已经用了 Istio,直接用它就够了,不需要再额外部署 Ingress Controller。AWS ALB Ingress Controller专为 AWS 设计,直接创建 ALB 资源作为入口,和 AWS 的 WAF、ACM 证书、CloudWatch 等服务深度集成。纯 AWS 环境下的首选。Ingress 资源配置实战最基本的路由规则下面这个示例把 example.com/app1 和 example.com/app2 分别路由到两个不同的 Service:apiVersion: networking.k8s.io/v1kind: Ingressmetadata: name: simple-ingress annotations: nginx.ingress.kubernetes.io/rewrite-target: /spec: rules: - host: example.com http: paths: - path: /app1 pathType: Prefix backend: service: name: app1-service port: number: 80 - path: /app2 pathType: Prefix backend: service: name: app2-service port: number: 80pathType 是 v1 版本必须指定的字段,有三个可选值:Exact:精确匹配,只有路径完全一致才命中。/app 只匹配 /app,不匹配 /app/ 或 /app1Prefix:前缀匹配,按 / 分段进行前缀判断。/app 能匹配 /app、/app/、/app/user,但不匹配 /app1ImplementationSpecific:由 Controller 自行决定匹配逻辑配置 HTTPS(TLS 终止)要启用 HTTPS,需要先在集群中创建 TLS 证书的 Secret,然后在 Ingress 中引用:apiVersion: networking.k8s.io/v1kind: Ingressmetadata: name: tls-ingressspec: tls: - hosts: - secure.example.com secretName: tls-secret rules: - host: secure.example.com http: paths: - path: / pathType: Prefix backend: service: name: secure-service port: number: 80Ingress Controller 会在 443 端口处理 TLS 握手,解密后以 HTTP 转发给后端 Service,后端无需关心证书。默认后端(Default Backend)当请求没有匹配到任何规则时,Ingress Controller 会把流量发给默认后端。通常是一个返回 404 的简单服务:apiVersion: networking.k8s.io/v1kind: Ingressmetadata: name: default-backend-ingressspec: defaultBackend: service: name: default-service port: number: 80 rules: - host: example.com http: paths: - path: /api pathType: Prefix backend: service: name: api-service port: number: 80IngressClass:多 Controller 共存的关键一个集群中可能部署了多个 Ingress Controller(比如 NGINX 处理外部流量,Traefik 处理内部流量)。IngressClass 用来指定一个 Ingress 资源由哪个 Controller 处理:apiVersion: networking.k8s.io/v1kind: IngressClassmetadata: name: nginx-external annotations: ingressclass.kubernetes.io/is-default-class: "true"spec: controller: k8s.io/ingress-nginx---apiVersion: networking.k8s.io/v1kind: Ingressmetadata: name: my-ingressspec: ingressClassName: nginx-external rules: - host: example.com http: paths: - path: / pathType: Prefix backend: service: name: my-service port: number: 80标注 is-default-class: "true" 的 IngressClass 会被自动分配给没有指定 ingressClassName 的 Ingress 资源。常用 Annotations 配置Annotations 是 Ingress 的核心扩展机制,不同 Controller 支持不同的注解。以下是 NGINX Ingress Controller 最常用的几个:路径重写:把匹配到的路径部分替换后再转发nginx.ingress.kubernetes.io/rewrite-target: /$2强制 HTTPS 重定向:HTTP 请求自动跳转到 HTTPSnginx.ingress.kubernetes.io/ssl-redirect: "true"限流配置:限制每个客户端的请求频率和并发连接数nginx.ingress.kubernetes.io/limit-rps: "10"nginx.ingress.kubernetes.io/limit-connections: "5"CORS 跨域:前后端分离场景经常需要nginx.ingress.kubernetes.io/enable-cors: "true"nginx.ingress.kubernetes.io/cors-allow-origin: "https://example.com"Basic 认证:简单的访问控制nginx.ingress.kubernetes.io/auth-type: basicnginx.ingress.kubernetes.io/auth-secret: basic-auth自定义错误页面:统一展示 404、503 等错误页nginx.ingress.kubernetes.io/custom-http-errors: "404,503"Ingress 和 Service、LoadBalancer 的区别很多初学者容易混淆这三者的关系,这里做个明确对比。Ingress vs Service:Service 是四层(L4)负载均衡,基于端口和 IP 转发 TCP/UDP 流量,不关心请求内容。Ingress 是七层(L7)负载均衡,能识别 HTTP 的 Host 头和 URL 路径,做更精细的路由。Service 是必须的(Ingress 最终还是把流量转给 Service),Ingress 是可选的增强。Ingress vs LoadBalancer:LoadBalancer 类型的 Service 每个都占用一个云厂商负载均衡器,成本高且没有域名路由能力。Ingress 只需要一个 LoadBalancer(给 Ingress Controller 用),然后通过规则复用这个入口给多个 Service 使用。| 维度 | Ingress | Service (ClusterIP/NodePort) | Service (LoadBalancer) ||------|---------|------|------|| 层级 | L7 | L4 | L4 || 路由能力 | 域名 + 路径 | 端口 | 端口 || TLS 终止 | 支持 | 不支持 | 部分支持 || 成本 | 低(共享入口) | 低 | 高(每 Service 一个 LB) || 协议 | HTTP/HTTPS | TCP/UDP | TCP/UDP |部署 NGINX Ingress Controller用 Helm 安装是最快的方式:# 添加 Helm 仓库helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginxhelm repo update# 安装到独立命名空间helm install ingress-nginx ingress-nginx/ingress-nginx \ --namespace ingress-nginx \ --create-namespace安装完成后验证:kubectl get pods -n ingress-nginxkubectl get svc -n ingress-nginx如果看到 Service 有一个 EXTERNAL-IP(云环境)或 NodePort(本地环境),说明 Controller 已经就绪。生产环境的最佳实践命名空间隔离:把 Ingress Controller 部署在独立命名空间(如 ingress-nginx),和业务负载分开管理。资源限制:Ingress Controller 是集群流量的咽喉,必须设置合理的 CPU 和内存 requests/limits,避免被其他 Pod 抢占资源。监控告警:重点监控连接数、请求延迟、4xx/5xx 错误率、证书过期时间。Prometheus + Grafana 是主流方案。证书管理:生产环境推荐用 cert-manager 自动签发和续期 Let's Encrypt 证书,避免手动管理证书过期。健康检查:确保后端 Service 配置了正确的 readinessProbe,Ingress Controller 只会把流量发给就绪的 Pod。配置备份与版本管理:Ingress 规则属于基础设施即代码的一部分,应该用 Git 管理 YAML 文件,而不是直接 kubectl edit。灰度发布:利用 nginx.ingress.kubernetes.io/canary 系列注解实现金丝雀发布,按权重或 Header 把部分流量导向新版本。常见故障排查排查 Ingress 问题有一个清晰的思路:从外到内,逐层验证。第一层:Ingress 规则是否正确kubectl get ingresskubectl describe ingress <ingress-name>检查 Rules 中的 Host、Path、Backend 是否符合预期,特别注意 pathType 是否匹配。第二层:Controller 是否正常工作kubectl logs -n ingress-nginx <pod-name> --tail=100看日志中是否有配置加载错误或后端连接失败的记录。第三层:DNS 是否解析正确nslookup example.comdig example.com确认域名指向 Ingress Controller 的外部 IP。第四层:后端 Service 和 Pod 是否健康kubectl get svckubectl get endpoints <service-name>kubectl get pods -l app=<your-app>Endpoints 列表为空说明没有 Pod 通过了 readinessProbe,流量无处可去。第五层:TLS 证书是否有效kubectl get secret tls-secret -o yamlopenssl x509 -in <cert-file> -text -noout检查证书是否过期、域名是否匹配、Secret 是否在正确的命名空间。按照这个顺序逐层排查,绝大多数 Ingress 问题都能定位到原因。
服务端阅读 05月27日 19:40

Kubernetes Deployment 的作用是什么?它如何实现滚动更新和回滚?

Kubernetes Deployment 是用来管理无状态应用生命周期的核心控制器。它围绕 ReplicaSet 实现了声明式部署、滚动更新和版本回滚,是日常使用频率最高的 K8s 工作负载类型。Deployment 到底管什么Deployment 并不直接管理 Pod。它管理的是 ReplicaSet,再由 ReplicaSet 来确保 Pod 的副本数。这种两层结构是理解 Deployment 更新和回滚机制的关键——每次更新 Pod 模板,Deployment 都会创建一个新的 ReplicaSet,逐步把流量从旧 ReplicaSet 迁移到新 ReplicaSet。一个最小化的 Deployment 定义:apiVersion: apps/v1kind: Deploymentmetadata: name: nginx-deploymentspec: replicas: 3 selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - name: nginx image: nginx:1.14.2 ports: - containerPort: 80注意 selector.matchLabels 必须和 template.metadata.labels 匹配,否则 Deployment 无法关联到自己的 Pod,这是一个常见的新手错误。滚动更新是怎么发生的当你修改了 Deployment 的 Pod 模板(比如换了镜像版本),Kubernetes 不会一次性替换所有 Pod,而是按策略逐步替换。默认使用 RollingUpdate 策略,整个过程依赖两个参数:maxUnavailable:更新过程中最多允许多少个 Pod 处于不可用状态。默认值是 25%,即 3 副本的 Deployment 最多允许 1 个 Pod 不可用。maxSurge:更新过程中最多允许超出期望副本数多少个 Pod。默认值也是 25%。以 3 副本为例,默认配置下的滚动更新过程大致如下:Kubernetes 先创建 1 个新 Pod(因为 maxSurge=25%,3 的 25% 向上取整为 1),等新 Pod 就绪后,再终止 1 个旧 Pod,如此循环直到全部替换完成。这里有一个容易忽略的细节:所谓"就绪",依赖的是 readinessProbe。如果你没有配置 readinessProbe,Kubernetes 只要看到容器启动就认为 Pod ready,这可能导致流量打到还没准备好的新 Pod 上。生产环境中务必配置 readinessProbe。查看更新状态:kubectl rollout status deployment/nginx-deploymentRecreate 策略什么时候用除了 RollingUpdate,还有一种 Recreate 策略:先杀掉所有旧 Pod,再创建新 Pod。这会带来停机时间,看起来不如 RollingUpdate,但有些场景必须用它:应用不支持多版本同时运行(比如数据库 schema 变更后旧代码会报错)新旧版本共享的资源无法兼容(比如同一个 ConfigMap 被新旧版本以不同方式解析)设置方式:spec: strategy: type: Recreate选择策略时问自己一个问题:新旧 Pod 能不能同时对外服务?能就用 RollingUpdate,不能就用 Recreate。回滚机制的底层逻辑每次更新 Pod 模板,Deployment 都会创建一个新的 ReplicaSet,旧的 ReplicaSet 不会被删除,而是保留作为回滚的锚点。Kubernetes 用 revisionHistoryLimit 控制保留多少个旧 ReplicaSet,默认值是 10。查看更新历史:kubectl rollout history deployment/nginx-deployment回滚到上一版本:kubectl rollout undo deployment/nginx-deployment回滚到指定版本:kubectl rollout undo deployment/nginx-deployment --to-revision=2回滚的本质是什么?是把当前 Deployment 的 Pod 模板替换成目标 revision 对应的 ReplicaSet 的 Pod 模板,然后走一遍正常的滚动更新流程。所以回滚不是"魔法还原",它和正向更新走的是同一条路径,同样受 maxUnavailable 和 maxSurge 约束。一个常见问题:如果你发现更新出错了,想暂停更新怎么办?kubectl rollout pause deployment/nginx-deployment暂停后可以做多次修改,确认没问题后再恢复:kubectl rollout resume deployment/nginx-deployment扩缩容:手动和自动手动扩缩容:kubectl scale deployment/nginx-deployment --replicas=5自动扩缩容需要 HPA(HorizontalPodAutoscaler)。HPA 根据 CPU、内存或自定义指标自动调整 Deployment 的副本数:apiVersion: autoscaling/v2kind: HorizontalPodAutoscalermetadata: name: nginx-hpaspec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: nginx-deployment minReplicas: 2 maxReplicas: 10 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 50需要注意的是,HPA 扩容是立刻生效的,但缩容有一个默认 5 分钟的稳定窗口(behavior.scaleDown.stabilizationWindowSeconds),防止指标波动导致副本数来回抖动。Deployment 和其他控制器的选择面试中常问的一个问题是:什么时候用 Deployment,什么时候用 StatefulSet 或 DaemonSet?Deployment 适用于无状态应用——Pod 之间没有差异,任何一个 Pod 都能处理任何请求。Web 服务、API 网关、微服务实例都属于这一类。StatefulSet 适用于有状态应用——每个 Pod 有稳定的网络标识和持久化存储。数据库主从集群、ZooKeeper、Kafka 集群需要用 StatefulSet。DaemonSet 确保每个节点上运行一个 Pod 副本,常用于日志采集、监控 Agent、网络插件等节点级服务。选错控制器的后果很直接:用 Deployment 跑数据库,Pod 重建后数据丢失;用 StatefulSet 跑无状态 Web 服务,滚动更新变慢且没有收益。生产环境中的几个注意点资源限制必须设置。没有 requests 和 limits 的 Pod 可能抢占节点资源,导致其他 Pod 被驱逐。至少设置 requests,让调度器能正确决策。健康检查不能省。livenessProbe 检测进程死锁,readinessProbe 控制流量接入。只配 livenessProbe 不配 readinessProbe,是导致滚动更新期间 502 的常见原因。不要用 latest 标签。image: nginx:latest 意味着每次拉取可能拿到不同版本,这会让 Deployment 的声明式管理失去意义。用明确的版本号,变更时改 YAML 走正常的更新流程。revisionHistoryLimit 不要设成 0。有些团队为了"清理资源"把它设成 0,结果是无法回滚。旧 ReplicaSet 里的 Pod 都是 0 副本,占用的资源微乎其微,保留回滚能力的收益远大于节省的那点开销。掌握了 Deployment 的滚动更新机制、回滚原理和与其他控制器的选型逻辑,面试中关于工作负载的大部分问题都能从容应对。
服务端阅读 05月27日 18:31

什么是 Kubernetes?它的核心概念和工作原理是什么?

Kubernetes 是什么?Kubernetes(常缩写为 K8s)是一个开源的容器编排平台,用于自动化容器化应用的部署、扩展和运维管理。它最初由 Google 基于内部运行大规模容器的经验(Borg/Omega 系统)设计并开发,于 2014 年开源,随后成为 Cloud Native Computing Foundation(CNCF)的旗舰项目。简单来说,当你从"在一台机器上跑几个容器"发展到"在几百台机器上跑几千个容器,还要保证服务不中断、能自动扩缩容、出了故障能自愈"时,Kubernetes 就是解决这个问题的工具。核心概念详解Pod — 最小调度单元Pod 是 Kubernetes 中最小的可部署单元。一个 Pod 包含一个或多个紧密耦合的容器,它们共享网络命名空间(同一个 IP 地址和端口空间)和存储卷。大多数情况下,一个 Pod 只运行一个容器;多容器 Pod 的典型场景是 Sidecar 模式,比如主容器运行业务逻辑,Sidecar 容器负责日志收集或代理网络请求。apiVersion: v1kind: Podmetadata: name: my-appspec: containers: - name: app image: my-app:1.0 ports: - containerPort: 8080Node — 工作节点Node 是集群中实际运行工作负载的机器,可以是物理机或虚拟机。每个 Node 上运行着三个关键组件:kubelet:负责管理本 Node 上 Pod 的生命周期,向控制平面汇报状态kube-proxy:维护节点上的网络规则,实现 Service 的负载均衡容器运行时:实际运行容器的软件,如 containerd、CRI-OCluster — 集群Cluster 由一组 Node 组成,是 Kubernetes 管理的计算资源池。一个集群通常包含多个 Worker Node 和至少一个 Master Node(控制平面)。集群是 Kubernetes 运维的基本单元——所有的应用部署、资源分配、网络策略都在集群范围内定义和管理。Service — 服务发现与负载均衡Pod 的 IP 地址是临时的——每次 Pod 重建后 IP 都会变化。Service 通过标签选择器(Label Selector)匹配一组 Pod,并为它们提供一个稳定的虚拟 IP(ClusterIP)和 DNS 名称,解决了"如何找到一组随时可能变化的 Pod"这个问题。常见的 Service 类型:ClusterIP:默认类型,仅在集群内部可访问NodePort:通过每个 Node 的指定端口暴露服务LoadBalancer:向云厂商请求外部负载均衡器Deployment — 声明式应用管理Deployment 是最常用的工作负载控制器,它管理 ReplicaSet,而 ReplicaSet 管理 Pod 副本数量。你只需要声明"我需要 3 个副本运行 my-app:2.0 镜像",Kubernetes 就会自动完成从旧版本到新版本的滚动更新,并且在更新出问题时支持一键回滚。apiVersion: apps/v1kind: Deploymentmetadata: name: my-appspec: replicas: 3 selector: matchLabels: app: my-app template: metadata: labels: app: my-app spec: containers: - name: app image: my-app:2.0ConfigMap 与 Secret — 配置管理ConfigMap 用于存储非敏感的配置数据(如应用配置文件、环境变量),Secret 用于存储敏感信息(如数据库密码、TLS 证书)。将配置与镜像解耦后,同一份镜像可以在开发、测试、生产环境中复用,只需替换不同的 ConfigMap 和 Secret 即可。Namespace — 资源隔离Namespace 在同一个物理集群中划分出多个逻辑上的"虚拟集群"。不同 Namespace 下的资源名称可以重复,资源配额(ResourceQuota)和访问控制(RBAC)也可以按 Namespace 粒度设置。常见做法是为每个团队或每个环境(dev/staging/prod)创建独立的 Namespace。Kubernetes 的工作原理Kubernetes 采用经典的主从架构,分为控制平面(Control Plane)和数据平面(Worker Node)两部分。控制平面(Control Plane)控制平面是集群的"大脑",负责全局决策和响应集群事件:kube-apiserver:集群的统一入口,所有组件之间的通信都通过 REST API 经由 apiserver 完成。它是唯一直接与 etcd 交互的组件。etcd:分布式键值存储,保存集群的全部状态数据。etcd 的数据就是集群的"唯一真相来源"(Single Source of Truth)。kube-scheduler:监听新创建且尚未被调度的 Pod,根据资源需求、亲和性规则、污点容忍等策略为 Pod 选择合适的 Node。kube-controller-manager:运行各种控制器(Deployment Controller、ReplicaSet Controller、Node Controller 等),通过控制循环不断将集群的当前状态向期望状态收敛。工作节点(Worker Node)工作节点是集群的"手脚",负责实际运行业务容器:kubelet:在每个 Node 上运行的代理,接收 PodSpec 并确保容器按照规范运行,同时向 apiserver 汇报 Node 和 Pod 的状态。kube-proxy:在每个 Node 上维护网络规则(默认使用 iptables 或 IPVS 模式),实现 Service 到 Pod 的请求转发和负载均衡。容器运行时:负责拉取镜像、启动和停止容器。Kubernetes 通过 CRI(Container Runtime Interface)与运行时交互,不再直接依赖 Docker。一个请求的完整流程当你执行 kubectl apply -f deployment.yaml 时,发生了什么:kubectl 将 YAML 发送到 kube-apiserverapiserver 对请求进行认证、鉴权和准入控制后,将数据写入 etcdkube-scheduler 监听到未调度的 Pod,为其选择 Node 并将结果写回 etcd目标 Node 上的 kubelet 监听到有 Pod 分配给自己,调用容器运行时启动容器kube-controller-manager 不断监控实际副本数与期望副本数是否一致,如有偏差则创建或删除 Pod这个"声明式 + 控制循环"的设计思想是 Kubernetes 最核心的理念——你只需要告诉它"我想要什么",而不是"怎么做"。Kubernetes 的主要特性自动化部署和回滚:通过声明式配置实现滚动更新,支持按比例控制升级速度,出错时可一键回滚到上一版本服务发现和负载均衡:自动为 Service 分配 ClusterIP 和 DNS 记录,内置轮询式负载均衡自动扩缩容:Horizontal Pod Autoscaler(HPA)根据 CPU/内存使用率或自定义指标自动增减 Pod 副本数自愈能力:自动重启失败容器、替换无响应节点上的 Pod、杀死未通过健康检查的容器存储编排:通过 PV/PVC 机制自动挂载各种存储后端(本地磁盘、NFS、云盘等),无需关心底层实现配置和密钥管理:ConfigMap 和 Secret 将配置与镜像解耦,支持热更新而不需要重新构建镜像典型应用场景微服务架构:每个微服务独立部署为一个 Deployment,通过 Service 互相调用,配合 Istio 等 Service Mesh 实现流量治理CI/CD 流水线:利用 Kubernetes 的声明式特性,将构建、测试、部署全流程容器化,实现 GitOps 工作流批处理和定时任务:Job 和 CronJob 控制器支持一次性任务和定时调度任务机器学习训练:利用 GPU 调度、分布式训练框架(如 Kubeflow)在 Kubernetes 上运行大规模模型训练掌握 Kubernetes 的核心概念和工作原理,是理解整个云原生技术栈的基础。从 Pod 到 Deployment,从 Service 到 Namespace,每一个概念都对应着生产环境中真实存在的问题和解决方案。建议在学习理论的同时动手搭建一个多节点集群,用 kubectl 完成一次完整的部署、扩容和滚动更新,这样对这些概念的理解才会从"知道"变成"会用"。
服务端阅读 05月27日 18:31

Kubernetes 污点(Taints)和容忍度(Tolerations)是什么?如何使用它们控制 Pod 调度?

Kubernetes 集群中并非所有节点都一样——有的挂了 GPU,有的专门跑监控组件,有的正在维护。如何让 Pod "知道"哪些节点该避开、哪些节点可以进入?答案就是污点(Taints)和容忍度(Tolerations)。这对机制从节点侧和 Pod 侧分别控制调度行为,是 Kubernetes 调度体系中不可绕过的一环。污点是什么——节点说"别来"污点是打在节点上的标记,告诉调度器:"除非 Pod 明确声明能容忍我,否则别往这儿调度。"一个完整的污点由三部分组成:Key:污点的键,必填。比如 dedicated、node-role.kubernetes.io/masterValue:污点的值,选填。比如 gpu、control-planeEffect:污点生效的方式,必填。决定"不匹配时怎么办"三种 Effect 的区别这是面试中最常被追问的细节,三种 Effect 行为差异很大:NoSchedule——硬性拒绝新 Pod 调度到该节点,但已经在跑的 Pod 不受影响。这是最常见的用法,典型场景是为专用节点(GPU、Ingress)加锁:没写容忍度的 Pod 根本进不来。PreferNoSchedule——软性偏好,调度器会尽量避开,但如果集群资源紧张,还是可能把 Pod 放过来。适合那种"最好别来,但来了也行"的场景,比如想让某节点尽量只跑日志采集组件,但不强制。NoExecute——最严厉的一种。不仅阻止新 Pod 调度进来,还会把已经在跑但没有匹配容忍度的 Pod 驱逐走。这是唯一会"赶人"的 Effect,通常用于节点故障或维护场景。Kubernetes 控制面默认用这种 Effect 处理 NotReady 和 Unreachable 节点。污点的增删查添加污点用 kubectl taint:# 给 node1 添加 NoSchedule 污点kubectl taint nodes node1 dedicated=gpu:NoSchedule# 没有值的污点也可以kubectl taint nodes node1 special:NoSchedule查看节点的污点:# 查看单个节点kubectl describe node node1 | grep Taints# 列出所有节点的污点kubectl get nodes -o custom-columns=NAME:.metadata.name,TAINTS:.spec.taints删除污点时在键后面加减号:# 删除指定污点kubectl taint nodes node1 dedicated=gpu:NoSchedule-# 删除该键下所有 Effectkubectl taint nodes node1 dedicated-容忍度是什么——Pod 说"我能进"容忍度写在 Pod 的 spec.tolerations 里,告诉调度器:"这个污点我能接受,可以调度到对应节点。"容忍度的字段一个容忍度包含以下字段:| 字段 | 说明 | 是否必填 ||------|------|----------|| key | 要容忍的污点键 | 否,为空时匹配所有键 || operator | Equal 或 Exists | 否,默认 Equal || value | 污点的值 | Equal 时必填 || effect | 污点的 Effect | 否,为空时匹配所有 Effect || tolerationSeconds | 容忍多久后被驱逐 | 仅 NoExecute 有效 |两种 Operator 的匹配逻辑Equal——精确匹配,key、value、effect 三者都要对上才算匹配成功:tolerations:- key: "dedicated" operator: "Equal" value: "gpu" effect: "NoSchedule"这条容忍度只能匹配 dedicated=gpu:NoSchedule 这一个污点,少一个字段都不行。Exists——只检查 key 是否存在,不关心 value 是什么:tolerations:- key: "dedicated" operator: "Exists" effect: "NoSchedule"这条能匹配 dedicated=gpu:NoSchedule、dedicated=cpu:NoSchedule 等所有 key 为 dedicated 且 effect 为 NoSchedule 的污点。两个极端写法也值得记住:operator: "Exists" 且不写 key,匹配一切污点;只写 operator: "Exists" 连 effect 也不写,匹配所有污点的所有 Effect。tolerationSeconds 的作用这个字段只对 NoExecute 生效。假设节点出了问题被自动打上污点,Pod 不会立刻被驱逐,而是等 tolerationSeconds 秒后再驱逐。这给应用留了缓冲时间做优雅退出:tolerations:- key: "node.kubernetes.io/not-ready" operator: "Exists" effect: "NoExecute" tolerationSeconds: 300 # 节点 NotReady 后等 5 分钟再驱逐如果不设置 tolerationSeconds,Pod 会一直容忍该污点,不会被驱逐。匹配规则:调度器怎么判断调度器的匹配逻辑可以简化为三步:取出节点上所有污点逐个检查 Pod 的容忍度能否匹配每个污点(忽略能匹配的)剩下未匹配的污点中,如果有 NoSchedule 或 PreferNoSchedule,影响调度决策;如果有 NoExecute,直接驱逐几个容易混淆的边界情况:容忍度的 key 为空且 operator 为 Exists,匹配所有污点(包括后面新增的)容忍度的 effect 为空,匹配该 key 下的所有 Effect多个污点之间是"与"的关系:Pod 必须容忍节点的所有污点才能被调度,容忍一个不够控制面自动添加的污点Kubernetes 控制面会在特定条件下自动给节点打污点,这些污点是内置的,了解它们对排查调度问题至关重要:| 污点键 | Effect | 触发条件 ||--------|--------|----------|| node.kubernetes.io/not-ready | NoExecute | 节点 NotReady || node.kubernetes.io/unreachable | NoExecute | 节点不可达 || node.kubernetes.io/memory-pressure | NoSchedule | 节点内存压力 || node.kubernetes.io/disk-pressure | NoSchedule | 节点磁盘压力 || node.kubernetes.io/pid-pressure | NoSchedule | PID 资源不足 || node.kubernetes.io/network-unavailable | NoSchedule | 节点网络不可用 || node.kubernetes.io/unschedulable | NoSchedule | 节点被 cordon |Kubernetes 还默认为 Pod 添加了对 not-ready 和 unreachable 的容忍度,tolerationSeconds 为 300 秒。这就是为什么节点故障后 Pod 不会立刻被驱逐,而是等 5 分钟。这个默认行为可以通过在 Pod 中显式声明容忍度来覆盖。实战场景专用节点隔离集群里有几台 GPU 机器,只想让需要 GPU 的 Pod 调度上去,普通 Pod 不要占位置。做法是给 GPU 节点打污点,给 GPU Pod 加容忍度:# 节点侧kubectl taint nodes gpu-node dedicated=gpu:NoSchedule# Pod 侧spec: tolerations: - key: "dedicated" operator: "Equal" value: "gpu" effect: "NoSchedule" containers: - name: gpu-app image: nvidia/cuda:11.0.3-base-ubuntu20.04但注意,只加容忍度只能让 Pod "可以进",不能保证 Pod "一定进"。如果要让 GPU Pod 只调度到 GPU 节点,还需要配合 nodeAffinity 或 nodeSelector 一起使用。污点是"拒绝"机制,不是"吸引"机制。节点维护与驱逐需要对节点做内核升级,先标记为不可调度并驱逐工作负载:# cordon 阻止新 Pod 调度kubectl cordon node1# drain 驱逐现有 Pod(忽略 DaemonSet)kubectl drain node1 --ignore-daemonsets --delete-emptydir-datakubectl drain 的本质就是给节点加 node.kubernetes.io/unschedulable:NoSchedule 污点,然后驱逐所有不匹配的 Pod。DaemonSet 的 Pod 默认带有对这些污点的容忍度,所以 drain 不会驱逐它们。DaemonSet 为什么不怕污点DaemonSet 控制器会自动为管理的 Pod 添加以下容忍度:tolerations:- key: "node.kubernetes.io/not-ready" operator: "Exists" effect: "NoExecute"- key: "node.kubernetes.io/unreachable" operator: "Exists" effect: "NoExecute"- key: "node.kubernetes.io/disk-pressure" operator: "Exists" effect: "NoSchedule"- key: "node.kubernetes.io/memory-pressure" operator: "Exists" effect: "NoSchedule"# ... 还有更多这就是为什么日志采集、监控 Agent 这类 DaemonSet 的 Pod 在节点出问题时仍然留在节点上——它们天生容忍这些污点。污点容忍度与节点亲和性的配合污点和亲和性解决的是不同方向的问题:污点:从节点出发,"我不想要谁"亲和性:从 Pod 出出发,"我想去哪里"两者配合才能实现完整的调度控制。一个常见模式是:污点把不该来的 Pod 挡在外面,亲和性把该来的 Pod 拉到正确位置。比如 GPU 场景——污点阻止普通 Pod,亲和性确保 GPU Pod 优先去 GPU 节点。单独使用污点有一个隐患:Pod 加了容忍度后能进节点,但不一定只进这个节点。它可能被调度到任何有匹配容忍度的节点。所以污点适合"排他",亲和性适合"定向",两者结合才是完整方案。TaintBasedEviction 机制当节点进入 NotReady 或 Unreachable 状态时,Kubernetes 不是立刻驱逐 Pod,而是基于 TaintBasedEviction 机制工作:节点控制器检测到节点异常,给节点打上对应污点Pod 上的容忍度开始倒计时(tolerationSeconds)倒计时结束,Pod 被标记为驱逐驱逐由 kubelet 执行优雅终止这个机制比旧版的基于 NodeCondition 的驱逐更灵活,因为你可以为不同的 Pod 设置不同的容忍时间。关键服务给较长缓冲,非关键服务快速驱逐。排查调度问题的思路Pod 调度失败时,按这个顺序排查:# 1. 查看 Pod 的调度失败事件kubectl describe pod <pod-name> | grep -A 20 Events# 2. 检查目标节点的污点kubectl describe node <node-name> | grep Taints# 3. 对比 Pod 的容忍度是否匹配kubectl get pod <pod-name> -o jsonpath='{.spec.tolerations}'# 4. 检查调度器日志kubectl logs -n kube-system -l component=kube-scheduler常见的调度失败提示比如 node(s) had taints that the pod didn't tolerate,说明 Pod 缺少对应污点的容忍度,要么给 Pod 加容忍度,要么去掉节点上的污点。污点和容忍度的核心思路就是"节点标记排斥,Pod 声明接受"。三种 Effect 的行为差异、控制面内置污点的触发条件、与亲和性的配合关系、以及 tolerationSeconds 带来的驱逐缓冲——这些是面试和实战中真正需要掌握的要点。理解了这套机制,就能在专用节点隔离、节点维护、故障处理这些场景下做出合理的调度决策。