面试题手册

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

服务端阅读 05月28日 05:28

以太坊隐私保护技术有哪些?零知识证明与混合器原理解析

以太坊的公开透明特性意味着所有交易数据、地址余额和合约状态都对全网可见,这给用户隐私带来了根本性挑战。隐私保护技术旨在让用户在不暴露敏感信息的前提下完成链上交互,同时保持区块链的可验证性。零知识证明(ZKP)零知识证明是一种密码学协议,证明者可以向验证者证明某个陈述为真,但不泄露除"该陈述为真"之外的任何信息。它需要满足三个性质:完备性(真命题能被证明)、可靠性(假命题无法被证明)、零知识性(验证者无法获得额外信息)。在以太坊生态中,ZKP 主要有两种实现路径:zk-SNARKszk-SNARKs(零知识简洁非交互式知识论证)的特点是证明体积小、验证速度快,但需要一个可信设置(Trusted Setup)来生成公共参考字符串。如果可信设置的废料(toxic waste)未销毁,伪造证明就成为可能。Zcash 是最早大规模应用 zk-SNARKs 的项目,以太坊上的 Aztec Protocol 和 Tornado Cash 也基于此技术。Groth16 是目前最广泛使用的 zk-SNARKs 方案,证明仅包含三个椭圆曲线群元素,链上验证 Gas 消耗约 20-30 万。// Groth16 验证器示例(简化版)contract Groth16Verifier { // 验证密钥的配对参数 struct VerifyingKey { Pairing.G1Point alpha; Pairing.G2Point beta; Pairing.G2Point gamma; Pairing.G2Point delta; Pairing.G1Point[] gamma_abc; } struct Proof { Pairing.G1Point a; Pairing.G2Point b; Pairing.G1Point c; } function verify( Proof memory proof, uint256[] memory input, VerifyingKey memory vk ) public view returns (bool) { // 1. 验证输入与验证密钥的一致性 // 2. 执行双线性配对检验 e(A,B) = e(alpha,beta) * e(C,delta) * ... // 实际实现依赖以太坊预编译合约 0x08 (alt_bn128配对) return true; }}zk-STARKszk-STARKs(零知识可扩展透明知识论证)不需要可信设置,抗量子计算攻击,但证明体积较大(通常几十 KB)。StarkNet 和 StarkEx 采用此方案,通过递归证明压缩证明大小。两种方案的对比:zk-SNARKs 证明小验证快,但依赖可信设置;zk-STARKs 无需可信设置且抗量子,但证明体积大。选择时需要在信任假设、证明大小和验证成本之间权衡。混合器(Mixer)混合器的核心思想是将多个用户的资金汇集到同一个合约中,存款时生成一个 commitment(由 nullifier 和金额哈希得出),取款时通过零知识证明证明你知道某个 commitment 的 nullifier,而不暴露具体是哪个 commitment。这样存款地址和取款地址之间的关联就被切断了。Tornado Cash 是最典型的以太坊混合器。其工作流程为:用户向合约存入固定金额(如 1 ETH),获得一个加密票据(note);之后用新地址提交 ZKP 和 nullifier 提取资金。由于所有存入同等金额的 commitment 都在同一个 Merkle Tree 中,观察者无法确定提款对应哪笔存款。// Tornado Cash 核心逻辑简化版contract TornadoMixer { uint256 public constant DENOMINATION = 1 ether; IHasher public immutable hasher; uint256 public immutable levels; bytes32 public filledSubtrees; bytes32 public roots; mapping(bytes32 => bool) public nullifierHashes; mapping(bytes32 => bool) public commitments; function deposit(bytes32 _commitment) external payable { require(msg.value == DENOMINATION, "Incorrect amount"); require(!commitments[_commitment], "Commitment exists"); // 将 commitment 插入 Merkle Tree bytes32 root = _insert(_commitment); commitments[_commitment] = true; emit Deposit(_commitment, root); } function withdraw( bytes calldata _proof, bytes32 _root, bytes32 _nullifierHash, address payable _recipient ) external { require(nullifierHashes[_nullifierHash] == false, "Already spent"); require(isKnownRoot(_root), "Unknown root"); // 验证 zk-SNARK 证明: // 1. 证明者知道某 commitment 的 nullifier // 2. 该 commitment 在以 _root 为根的 Merkle Tree 中 require(verifier.verifyProof(_proof, [_root, _nullifierHash]), "Invalid proof"); nullifierHashes[_nullifierHash] = true; _recipient.transfer(DENOMINATION); emit Withdrawal(_recipient, _nullifierHash); }}Tornado Cash 在 2022 年被美国 OFAC 制裁后,社区开始探索合规隐私方案。Privacy Pools 允许用户通过零知识证明将自己与非法资金"解离"(disassociate),在保护隐私的同时满足合规要求。这代表了隐私技术从"绝对匿名"向"可选择性披露"的范式转变。环签名与同态加密环签名允许签名者在一组可能的签名者中隐藏自己的身份。验证者可以确认签名来自该组中的某个人,但无法确定具体是谁。Monero 是环签名的典型应用,通过隐地址(stealth address)和 RingCT 进一步增强隐私。同态加密允许在密文上直接执行计算,解密后得到与明文计算相同的结果。Zama 在 2025 年底上线了基于全同态加密(FHE)的主网,使得链上计算可以在不解密的情况下完成。FHE 的挑战在于计算开销极大,目前需要专用硬件加速才能满足实际性能需求。隐私技术选型与面试追问面试中常见的追问方向:追问:zk-SNARKs 和 zk-STARKs 该怎么选? 选择取决于信任模型和性能需求。如果应用场景可以接受可信设置(如由多方仪式生成),zk-SNARKs 的证明体积和验证成本更优,适合链上验证频繁的场景。如果信任假设要求最小化或面向未来考虑量子安全,zk-STARKs 更合适,但需要接受较大的证明体积。StarkNet 通过递归证明和 L2 执行环境缓解了这个问题。追问:混合器的隐私强度取决于什么? 匿名集(anonymity set)的大小。使用混合器的人数越多、资金池越大,单笔交易被关联的概率越低。这也是为什么 Tornado Cash 采用固定金额——所有存入相同金额的用户形成同一个匿名集。当匿名集较小时,通过时间关联分析、Gas 费来源追踪等手段仍可能去匿名化。追问:隐私和监管如何平衡? Privacy Pools 的解离机制是一个方向:用户可以证明自己的资金不来自已知的非法地址集,而不暴露具体的资金来源。另一个方向是选择性披露——用户只在必要时揭示必要的信息。技术上这可以通过 ZKP 的约束条件实现,政策上需要监管框架对"合理隐私"给出明确定义。以太坊隐私保护正处于从"技术可行"到"工程可用"的转折点。ZKP 的链上验证成本持续下降,FHE 开始进入生产环境,合规隐私方案的探索也在加速。对开发者而言,理解这些技术的原理和权衡,比记住几个项目名称重要得多。
服务端阅读 05月28日 05:27

如何配置 Jest?常用配置选项有哪些?

Jest 有三种配置方式:package.json 的 jest 字段、独立的 jest.config.js(或 .ts/.json/.mjs)文件、以及 CLI 参数 --config。实际项目中 90% 用 jest.config.js,因为可读性好、能写注释、支持条件逻辑。核心配置项按优先级说:testEnvironment — 决定测试运行环境。node 适合纯逻辑(工具函数、后端),jsdom 模拟浏览器 DOM(React 组件、DOM 操作)。选错会导致全局对象找不到或内存飙升。Next.js 项目用 @jest/globals 里的 customExportConditions 可以按组件区分环境。transform — 告诉 Jest 用什么转换器处理非 JS 文件。babel-jest 是默认值,TypeScript 项目换成 ts-jest 或用 @swc/jest 加速。配错了表现为 SyntaxError: Unexpected token。moduleNameMapper — 路径别名映射。配 Webpack/Vite 的 @/ 前缀、CSS/图片等静态资源的 mock 都靠它。最常见写法:'^@/(.*)$': '<rootDir>/src/$1',静态资源用 identity-obj-proxy。transformIgnorePatterns — 指定哪些文件不做转换。默认忽略整个 node_modules,但 ESM 包(如 lodash-es、axios)没编译成 CJS 就会报错。解法是用负向先行断言:'/node_modules/(?!(lodash-es|axios)/)'。setupFilesAfterEnv — 测试环境初始化后执行的脚本,用来引入 @testing-library/jest-dom 的扩展匹配器、全局 mock window.matchMedia 等。区别于 setupFiles(在测试框架加载前运行,一般用不到)。coverageThreshold — 覆盖率门禁。团队规范通常设 branches: 80, functions: 80, lines: 80,CI 中低于阈值直接失败。preset — 一键继承预置配置。ts-jest 提供 preset: 'ts-jest',React 项目用 react-app(CRA)或 @testing-library/react/jest-dom。preset 和手动配置重复时,手动配置优先。projects — monorepo 专属,每个子项目可以独立配置 testEnvironment、transform 等,Jest 并行跑所有项目。追问testEnvironment 选 node 还是 jsdom 怎么决定?跑纯函数、Node API 用 node;涉及 DOM 操作、React 渲染用 jsdom。jsdom 内存开销大,API 不完整(没有 canvas 布局、IntersectionObserver),需要额外 mock。同一个项目可以按目录分 projects 配不同环境。transformIgnorePatterns 配了但不生效怎么办?先跑 npx jest --showConfig 看实际合并后的配置,preset 可能覆盖了你的设置。常见坑:正则里的路径分隔符在 Windows 上不一致,或者忘了负向断言里的 /。清缓存 jest --clearCache 再试。Jest 跑 ESM 包一直报 SyntaxError 怎么排查?三步:1)确认 transform 配了对应转换器;2)检查 transformIgnorePatterns 是否把该包排除了忽略列表;3)如果包本身只导出 ESM,考虑用 moduleNameMapper 指向 CJS 入口或者直接 mock 掉。monorepo 里 packages 互相依赖怎么配 Jest?用 projects 配置,每个 package 指定自己的 rootDir 和 testMatch。packages 间依赖通过 moduleNameMapper 映射到源码目录而不是 dist,这样改了依赖包的代码测试立即生效。写段代码// jest.config.js — React + TS 项目典型配置module.exports = { testEnvironment: 'jsdom', transform: { '^.+\\.(js|jsx|ts|tsx)$': 'babel-jest' }, moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1', '\\.(css|less)$': 'identity-obj-proxy', }, transformIgnorePatterns: [ '/node_modules/(?!(lodash-es)/)', ], setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'], coverageThreshold: { global: { branches: 80, functions: 80, lines: 80 }, },};
前端阅读 05月28日 05:25

Expo 应用如何实现国际化?i18next 配置与 RTL 处理

Expo 国际化用 i18next + expo-localization,不要选 react-native-localize——它在 Expo Go 里直接报错,必须 eject 才能用。i18next 管翻译引擎(资源加载、变量插值、复数、语言切换),expo-localization 读设备语言和时区,两个配合才是正解。核心流程:getLocales() 拿设备语言 → i18next 加载翻译资源 → useTranslation() 的 t() 渲染文本。切换语言调 i18n.changeLanguage(),AsyncStorage 持久化偏好。i18next 的 init 必须在根组件渲染前执行——入口文件顶部 import 配置即可,否则子组件拿不到翻译,这是新手最常见的坑。追问i18next 和 expo-localization 分工是什么?能只用一个吗?不能替代。expo-localization 是只读工具——告诉你设备语言是 zh-Hans、时区是 Asia/Shanghai,不碰翻译。i18next 才是翻译引擎:翻译资源管理、{{变量}} 插值、单复数(one item / {{count}} items)、命名空间拆分、运行时语言切换全归它管。一个读信息,一个做翻译,职责不重叠。react-native-localize 比 expo-localization 好在哪?为什么不推荐?react-native-localize 能拿更多信息:日历类型、温度单位、24 小时制开关、度量衡。但代价是依赖原生模块——Expo Go 拒绝加载自定义原生代码,import 就报错,必须 npx expo run:ios 跑开发构建或 eject 到 bare workflow。还在 Expo Go 阶段的项目别碰它;bare workflow 项目两个随便选。Expo Router 里怎么做国际化?根 layout 用 useTranslation,t() 放在 options.tabBarLabel 和 options.title 里,切换语言后组件重渲染、标签名自动变。关键约束:Expo Router 基于文件系统路由,路径名不能动态改,所以路由文件名保持英文(app/settings.tsx),展示文本走 t() 翻译。useSegments() 拿当前路由做翻译 key 映射也是常见做法。RTL 语言(阿拉伯语、希伯来语)怎么办?I18nManager.forceRTL(true) 开启 RTL,但要重启才生效——调 Updates.reloadAsync() 即可。样式必须用逻辑属性:marginStart/marginEnd 替代 marginLeft/marginRight,paddingStart/paddingEnd 替代左右内边距,textAlign 用 'start' 不用 'left'。RTL 模式下布局自动翻转,零额外代码。忘了用逻辑属性的后果:文本翻了布局没翻,界面乱套。翻译资源多了怎么组织?5 种语言以内 JSON 文件放 locales/en.json,按功能分 key(auth.login、settings.theme)。语言多了用 i18next 命名空间拆分:common、auth、settings 各一个 namespace,懒加载减少首屏体积。改文案不发版的场景上 i18next-http-backend 从 CDN 拉翻译 JSON,AsyncStorage 缓存离线兜底。翻译量大需要协作时,Lokalise 或 Crowdin 配合 i18next 官方同步插件。开发阶段开 saveMissing: true,缺失 key 自动打 console 警告;上线后 i18next-scanner 扫代码提取 key,和翻译文件做 diff 排查遗漏。写段代码// i18n.ts — 入口文件顶部 importimport i18n from 'i18next';import { initReactI18next } from 'react-i18next';import { getLocales } from 'expo-localization';i18n.use(initReactI18next).init({ resources: { en: { translation: { welcome: 'Welcome', hello: 'Hello, {{name}}!' } }, zh: { translation: { welcome: '欢迎', hello: '你好,{{name}}!' } }, }, lng: getLocales()[0]?.languageCode ?? 'en', fallbackLng: 'en', saveMissing: __DEV__, interpolation: { escapeValue: false },});// 组件const { t, i18n } = useTranslation();<Text>{t('hello', { name: '用户' })}</Text><Button onPress={() => { i18n.changeLanguage('zh'); AsyncStorage.setItem('lang', 'zh');}} title="中文" />
服务端阅读 05月28日 05:24

Hardhat 配置文件有哪些核心配置项?

Hardhat 配置文件 hardhat.config.js(或 .ts)导出一个配置对象,核心配置项按使用频率排列:1. solidity — 指定编译器版本和优化设置。简写 "0.8.19" 或对象形式开启 optimizer:solidity: { version: "0.8.19", settings: { optimizer: { enabled: true, runs: 200 } }}runs 权衡部署 Gas 和执行 Gas:runs 越高,执行越省 Gas 但部署越贵。库合约建议设 999999,一次性合约设 1。支持多版本编译,用 overrides 按路径指定:solidity: { version: "0.8.19", overrides: { "contracts/legacy/": { version: "0.6.12" } }}2. networks — 定义连接的区块链网络。hardhat 是内置本地网络,其他网络需配 RPC 和账户:networks: { hardhat: { chainId: 31337 }, sepolia: { url: process.env.SEPOLIA_RPC_URL, accounts: [process.env.PRIVATE_KEY] }}accounts 支持私钥数组或助记词对象。敏感信息必须走环境变量,不要硬编码私钥。3. defaultNetwork — 不带 --network 参数时的默认网络,默认值 "hardhat"。4. paths — 自定义目录结构(sources、tests、cache、artifacts),默认值够用,多仓库 monorepo 才需要改。5. etherscan — 配置 API Key,部署后自动在 Etherscan 验证合约源码,省得手动提交。6. gasReporter — 测试时输出 Gas 消耗报告,currency: "USD" 显示费用估算,上线前评估成本用。7. mocha — 覆盖测试框架配置,常用 timeout 调整超时(合约测试默认 40000ms,复杂场景可能不够)。插件通过 require() 引入,写在配置文件顶部,不算配置项但必须在这里加载。追问optimizer runs 设成多少合适?看合约调用频次。高频调用设 200-999,让编译器多优化执行路径;一次性部署设 1 省部署费。Uniswap V3 的池子合约 runs 设了几千,因为每笔交易都要执行。hardhat 网络和 localhost 有什么区别?hardhat 是内存临时网络,每次 npx hardhat test 都重启,数据不持久。localhost 需要先 npx hardhat node 启动独立进程,数据跨命令保持,适合调试前端交互和合约状态持久化场景。配置文件用 JS 还是 TS?TS 更好——有类型提示,拼写错误编译期就能发现。Hardhat 3 已默认推荐 TS 配置。安装 ts-node 和 @types/node,文件改名为 hardhat.config.ts 即可,语法不变。怎么让配置文件不暴露私钥?用 dotenv 包从 .env 文件读取环境变量,.env 加入 .gitignore。生产环境用密钥管理服务(如 AWS Secrets Manager)替代 .env 文件。CI/CD 里通过 GitHub Secrets 注入。
前端阅读 05月28日 05:24

Expo OTA更新怎么工作?EAS Update怎么用?

Expo的OTA(Over-the-Air)更新让开发者绕过应用商店审核流程,直接向用户推送JavaScript和资源文件的更新。这项能力来自EAS Update服务,配合expo-updates原生模块在客户端完成检查、下载和应用更新的全流程。OTA更新的底层机制一次OTA更新涉及三个核心概念:分支(Branch)、通道(Channel)和运行时版本(Runtime Version)。分支是更新在服务端的组织方式。每次执行eas update,Expo会将打包后的JavaScript bundle和资源文件上传到指定分支,分支上的最新更新即为活跃更新。通道则是客户端与分支之间的桥梁——客户端通过app.json中配置的通道名称连接到对应分支,从而获取更新。运行时版本是兼容性的守门员:只有运行时版本匹配的更新才会被下载,防止含原生依赖变更的更新在不兼容的二进制上运行导致崩溃。更新下载分两个阶段进行。应用启动时,expo-updates先请求最新的更新清单(manifest),清单包含更新元数据和所需资源列表。接着只下载当前缓存中缺失的资源文件,已缓存的部分直接复用。SDK 55引入的bundle diffing机制进一步将更新体积缩小60%到80%——客户端只需下载新旧bundle之间的差异部分,而非整个bundle。EAS Update的完整配置流程1. 安装依赖并初始化npx expo install expo-updatesnpm install -g eas-clieas logineas initeas init会在app.json中写入EAS项目ID,expo-updates则是客户端检查和下载更新所依赖的原生模块。2. 配置app.json{ "expo": { "runtimeVersion": { "policy": "appVersion" }, "updates": { "url": "https://u.expo.dev/your-project-id", "enabled": true, "fallbackToCacheTimeout": 0, "checkAutomatically": "ON_LOAD" } }}checkAutomatically控制更新检查时机,ON_LOAD表示应用启动时自动检查,WIFI_ONLY仅在WiFi下检查。fallbackToCacheTimeout设为0表示不等待更新下载完成,直接加载缓存版本。3. 构建支持更新的二进制OTA更新只在production或preview构建中生效,Expo Go不支持:eas build --platform all --profile production构建时expo-updates被编译进二进制,并嵌入了构建时的初始更新作为回退版本。4. 发布更新# 向production通道发布eas update --branch production --message "修复登录按钮样式"# 指定运行时版本eas update --branch production --runtime-version 1.0.0# 向preview通道发布用于测试eas update --branch preview --message "测试新功能"5. 查看和回滚更新# 列出所有更新eas update:list# 查看指定分支的更新eas update:list --branch production# 回滚到上一版本eas update:rollback --channel production# 回滚到特定版本eas update:rollback --channel production --target-message "上一个稳定版本"运行时版本策略选择三种策略各有适用场景:appVersion(推荐):运行时版本跟随app.json中的version字段。每次改了原生依赖就升version,简单可靠,适合大多数团队。nativeVersion:跟随原生构建号,比appVersion更细粒度,适合频繁发版但原生变更不多的场景。自定义版本字符串:完全手动控制,灵活性最高但需要团队约定版本命名规范。关键原则:只要添加、删除或升级了原生依赖,就必须同步更新运行时版本,否则OTA更新可能导致原生模块找不到而崩溃。客户端程序化控制更新手动控制更新检查和应用的场景很常见,比如在设置页提供"检查更新"按钮,或在关键操作前确保代码是最新的:import * as Updates from 'expo-updates';async function checkAndApplyUpdate() { if (__DEV__) return; // 开发模式不支持OTA try { const update = await Updates.checkForUpdateAsync(); if (update.isAvailable) { const result = await Updates.fetchUpdateAsync(); if (result.isNew) { // 立即重载应用新版本 await Updates.reloadAsync(); } } } catch (error) { // 更新失败不影响正常使用,静默处理或上报 console.warn('更新检查失败:', error.message); }}监听更新事件可以在后台下载完成时通知用户:useEffect(() => { if (__DEV__) return; const subscription = Updates.addListener((event) => { if (event.type === Updates.UpdateEventType.DOWNLOAD_FINISHED) { // 提示用户下次启动将使用新版本 Alert.alert('更新已就绪', '重启应用以使用最新版本', [ { text: '稍后', style: 'cancel' }, { text: '立即重启', onPress: () => Updates.reloadAsync() }, ]); } if (event.type === Updates.UpdateEventType.ERROR) { console.warn('更新下载出错:', event.message); } }); return () => subscription.remove();}, []);OTA更新的边界与限制OTA能更新的是JavaScript业务逻辑、React组件树、样式、静态资源和导航结构。以下变更必须发新构建:新增或删除原生依赖、修改app.json中的原生字段(权限、URL Scheme等)、升级Expo SDK大版本、更换应用图标或启动屏。苹果和谷歌对OTA更新的政策有明确要求:更新不得改变应用的核心功能定位,不得绕过应用商店审核引入付费功能或隐私敏感变更。实际操作中,大多数UI修复和小功能调整都符合政策。通道与分支的运维实践通道和分支的典型组合:| 环境 | 分支 | 通道 | 用途 ||------|------|------|------|| 生产 | production | production | 面向所有用户 || 预发 | staging | staging | 内部测试验证 || 预览 | preview | preview | 功能预览 |通道还支持按比例灰度发布。通过eas channel:rollout命令可以逐步将新更新推送给一定比例的用户,观察错误率后再全量发布:# 先向20%用户推送eas channel:rollout production --percent 20# 确认无问题后扩大到50%eas channel:rollout production --percent 50# 全量发布eas channel:rollout production --percent 100错误恢复机制expo-updates内置了自动错误恢复:如果更新后的应用在启动时连续崩溃,模块会自动回退到上一个已缓存的可用版本。这为线上事故提供了兜底,但仍建议在发布前通过preview通道充分测试。手动回滚同样简单。EAS Update保留每个通道的完整更新历史,回滚只是将活跃指针指向上一个版本,客户端会在下次启动时下载并切换。面试追问OTA更新和发新版本各自的适用场景? JavaScript层面的bug修复、UI调整、文案改动适合OTA;新增原生模块、修改权限声明、升级SDK大版本必须发新构建。判断依据是变更是否涉及原生代码。运行时版本不匹配会发生什么? 客户端会忽略不匹配的更新,继续运行当前缓存版本。这保护了应用不会因缺少原生模块而崩溃,但也意味着如果忘记同步运行时版本,用户将收不到更新。如何保证OTA更新的安全性? EAS Update默认通过HTTPS传输更新包,expo-updates在加载前会校验更新签名。自托管更新服务器时需确保同样启用HTTPS和签名验证。
服务端阅读 05月28日 04:30

Koa 怎么写测试?从框架选型到中间件和 API 踩坑实录

Koa 项目写测试,很多人第一反应是"随便装个 Jest 就完事了"。但真正上手之后才会发现:中间件的洋葱模型怎么测?数据库操作怎么隔离?Supertest 和 Koa 的 callback 模式怎么配合才不会内存泄漏?这篇文章把框架选型的思路和实际项目中最容易踩的坑一次讲清。Jest 还是 Mocha?先想清楚再选选测试框架不需要纠结太久,关键是看你的项目阶段和团队习惯。Jest 的优势:零配置开箱即用,内置断言、mock、覆盖率报告。测试并行执行,速度快。遇到问题时错误信息比 Mocha 友好得多,直接告诉你哪个断言失败、期望值和实际值分别是什么。Mocha 的优势:灵活性高,断言库可以选 Chai、Should、Expect,mock 可以选 Sinon 或自己写。对于已有 Mocha 体系的存量项目,迁移成本为零。实际经验:新项目直接用 Jest,别犹豫。Mocha 需要额外配 Chai + Sinon + Istanbul,搭环境的时间够你写十几个测试用例了。唯一需要考虑 Mocha 的场景是,你的 CI 环境内存特别紧张——Jest 并行执行会吃更多内存,Mocha 串行跑更稳。安装和基础配置npm install --save-dev jest supertest @types/jest @types/supertestSupertest 是测试 Koa HTTP 接口的核心工具,它不需要真正启动服务器,直接调用 app.callback() 生成请求处理函数,避免了端口占用和进程管理的问题。// jest.config.jsmodule.exports = { testEnvironment: 'node', coverageDirectory: 'coverage', collectCoverageFrom: [ 'src/**/*.js', '!src/**/*.test.js' ], testMatch: [ '**/__tests__/**/*.js', '**/?(*.)+(spec|test).js' ]};一个容易忽略的配置:如果你的项目用了 Babel 或 TypeScript,需要额外配 transform 字段,否则 Jest 无法识别 ES Module 的 import 语法。路由测试:最基础也最容易出错const request = require('supertest');const app = require('../app');describe('Basic routes', () => { test('GET / should return Hello Koa', async () => { const response = await request(app.callback()) .get('/') .expect(200); expect(response.text).toBe('Hello Koa'); }); test('GET /not-found should return 404', async () => { await request(app.callback()) .get('/not-found') .expect(404); });});踩坑点:app.callback() 而不是 app.listen()。用 listen() 会在每个测试文件启动一个新的 HTTP 服务器,Jest 跑完不关的话,进程不会退出,CI 直接卡住。callback() 返回的是一个标准的 Node.js request handler,Supertest 内部会自己创建临时服务器并自动关闭,不会有泄漏问题。中间件测试:洋葱模型的坑Koa 中间件是洋葱模型——请求从外层进去,响应从内层出来。测试中间件时最常见的错误是手动构造 ctx 对象:// 错误写法:手动造 ctxconst ctx = { headers: {}, state: {}, throw: jest.fn() };await middleware(ctx, next);这种写法绕过了 Koa 的上下文封装,ctx 上少一堆属性和方法(ctx.request、ctx.response、ctx.set() 等),测试结果和生产环境完全不一致。某个中间件在测试里通过了,上了线照样炸。正确写法:创建一个最小的 Koa 实例,把中间件挂上去,用 Supertest 发请求:const Koa = require('koa');const authMiddleware = require('../middleware/auth');describe('Auth middleware', () => { test('should allow access with valid token', async () => { const app = new Koa(); app.use(authMiddleware); app.use(async (ctx) => { ctx.body = { user: ctx.state.user }; }); const response = await request(app.callback()) .get('/test') .set('Authorization', 'Bearer valid-token') .expect(200); expect(response.body.user).toBeDefined(); }); test('should deny access without token', async () => { const app = new Koa(); app.use(authMiddleware); app.use(async (ctx) => { ctx.body = 'ok'; }); await request(app.callback()) .get('/test') .expect(401); });});这样测试走的是完整的 Koa 请求生命周期,中间件拿到的 ctx 和生产环境一模一样。还有一个容易忽略的问题:中间件里 await next() 前后的代码分别对应请求进入和响应返回两个阶段。如果你的中间件在 await next() 之后做了什么操作(比如记录响应时间、修改响应头),测试时必须验证响应结果而不仅仅是 next() 是否被调用。数据库测试:隔离是第一优先级数据库相关的测试最容易污染环境。几个关键原则:用独立的测试数据库。永远不要在开发库里跑测试,knex 或 sequelize 配置里加一个 test 环境指向独立库。每个测试用例前清空数据。用 beforeEach + truncate 比 afterEach 更安全——如果测试中途挂了,afterEach 可能没执行,脏数据就留下了。事务回滚是个好办法,但有陷阱。把每个测试包在一个事务里,跑完回滚,这样数据库始终保持干净。但如果你用了多进程并发测试(Jest 默认行为),不同 worker 的事务可能互相看到未提交的数据,取决于数据库的隔离级别。PostgreSQL 默认的 Read Committed 读不到其他事务未提交的数据,问题不大;MySQL 的某些引擎就不是这样了。describe('User API with DB', () => { let db; beforeAll(async () => { db = require('../models'); await db.sequelize.sync({ force: true }); }); afterAll(async () => { await db.sequelize.close(); }); beforeEach(async () => { await db.User.destroy({ truncate: true, cascade: true }); }); test('POST /api/users should create user', async () => { const userData = { name: 'John Doe', email: 'john@example.com', password: 'password123' }; const response = await request(app.callback()) .post('/api/users') .send(userData) .expect(201); expect(response.body).toHaveProperty('id'); expect(response.body).not.toHaveProperty('password'); });});password 不应该出现在响应里——这种断言看似简单,但能抓住最常见的安全漏洞。如果你的 API 返回了密码字段(即使是哈希),这个测试会立刻暴露出来。Mock 策略:别过度 MockMock 是双刃剑。Mock 太多,测试变成了"验证我的 mock 逻辑",和真实代码没有任何关系;Mock 太少,测试依赖外部服务,CI 随时可能因为第三方 API 超时而失败。原则:只 Mock 跨越边界的调用——外部 API、文件系统、邮件发送。内部函数调用不要 Mock,否则你测的不是代码逻辑,而是你对代码逻辑的假设。// 合理的 Mock:模拟第三方 APIconst nock = require('nock');test('should fetch user from external service', async () => { nock('https://api.example.com') .get('/users/1') .reply(200, { id: 1, name: 'Test User' }); const response = await request(app.callback()) .get('/api/external-users/1') .expect(200); expect(response.body.name).toBe('Test User'); nock.cleanAll();});// 不推荐的 Mock:模拟内部数据库调用// jest.spyOn(User, 'findById').mockResolvedValue({ id: 1 });// 这样测的是 mock 的返回值,不是数据库查询逻辑文件上传测试文件上传是另一个容易遗漏的测试场景。Supertest 支持 .attach() 和 .field() 方法来模拟 multipart/form-data 请求:const path = require('path');describe('File upload', () => { test('should upload avatar and return URL', async () => { const response = await request(app.callback()) .post('/api/upload/avatar') .set('Authorization', `Bearer ${authToken}`) .attach('avatar', path.join(__dirname, 'fixtures/test-avatar.png')) .expect(200); expect(response.body).toHaveProperty('url'); expect(response.body.url).toMatch(/^https?:\/\//); }); test('should reject files over size limit', async () => { await request(app.callback()) .post('/api/upload/avatar') .set('Authorization', `Bearer ${authToken}`) .attach('avatar', path.join(__dirname, 'fixtures/large-file.png')) .expect(413); });});踩坑提醒:测试用的文件放在 __tests__/fixtures/ 目录下,别用线上真实用户上传的文件——一是隐私问题,二是文件可能随时被删导致测试莫名失败。错误处理测试错误处理是最容易遗漏的测试场景,但恰恰是线上出问题时最需要保障的部分:describe('Error handling', () => { test('should handle validation errors with 400', async () => { const response = await request(app.callback()) .post('/api/users') .send({ name: '', email: 'invalid' }) .expect(400); expect(response.body).toHaveProperty('code', 'VALIDATION_ERROR'); }); test('should handle unexpected errors with 500', async () => { // 模拟数据库异常 jest.spyOn(User, 'create').mockRejectedValue(new Error('DB connection lost')); const response = await request(app.callback()) .post('/api/users') .send({ name: 'Test', email: 'test@example.com' }) .expect(500); expect(response.body).toHaveProperty('code', 'INTERNAL_ERROR'); });});一个真实教训:某次部署后,数据库连接池耗尽导致所有 API 返回 500,但前端只显示"网络错误"。如果提前测了 500 的响应格式,前端至少能给用户一个像样的提示。集成测试:串起完整流程单元测试验证单个函数,集成测试验证整个流程能跑通。关键是在 beforeAll 里完成前置操作,afterAll 里清理数据:describe('Full user flow', () => { let authToken; beforeAll(async () => { const response = await request(app.callback()) .post('/api/auth/login') .send({ email: 'test@example.com', password: 'password123' }); authToken = response.body.token; }); afterAll(async () => { await request(app.callback()) .delete('/api/test/cleanup') .set('Authorization', `Bearer ${authToken}`); }); test('should create and retrieve a post', async () => { const createRes = await request(app.callback()) .post('/api/posts') .set('Authorization', `Bearer ${authToken}`) .send({ title: 'Test Post', content: 'Hello' }) .expect(201); const postId = createRes.body.id; const getRes = await request(app.callback()) .get(`/api/posts/${postId}`) .set('Authorization', `Bearer ${authToken}`) .expect(200); expect(getRes.body.title).toBe('Test Post'); });});集成测试不需要覆盖所有边界情况,那是单元测试的活。集成测试的价值在于确认"登录 -> 创建 -> 查询 -> 删除"这条主线不断。CI 里的测试配置测试写好了,CI 里跑不起来是最让人崩溃的。几个常见问题:Jest 超时:数据库操作默认 5 秒超时不够用,在测试文件顶部加 jest.setTimeout(30000) 或在配置里统一设置。端口冲突:如果某个测试用了 app.listen() 而不是 app.callback(),并行执行时端口会被占。全局搜一下 listen,确保测试里没有直接调用。数据库连接不关闭:afterAll 里必须 sequelize.close() 或 mongoose.disconnect(),否则 Jest 进程挂起不退出。环境变量:CI 里用 cross-env NODE_ENV=test jest,确保代码里读到的数据库配置是测试库而不是生产库。覆盖率方面,80% 是底线,但别追求 100%——有些代码(如启动脚本、配置文件)写测试纯属浪费时间。重点关注业务逻辑层和控制器的覆盖率。在 jest.config.js 里设置 coverageThreshold,低于阈值直接让 CI 失败:coverageThreshold: { global: { branches: 70, functions: 80, lines: 80, statements: 80 }}测试数据管理维护测试数据最省心的方式是用工厂函数,比硬编码 fixture 灵活,比每次手写对象不容易遗漏字段:const userFactory = (overrides = {}) => ({ name: 'Test User', email: `test${Date.now()}@example.com`, password: 'password123', ...overrides});test('should create user with custom name', async () => { const userData = userFactory({ name: 'Custom Name' }); const response = await request(app.callback()) .post('/api/users') .send(userData) .expect(201); expect(response.body.name).toBe('Custom Name');});工厂函数里 email 加了时间戳后缀,避免并发测试时邮箱唯一约束冲突。这个小技巧省了无数调试时间。写测试这件事,起步觉得麻烦,写顺手了你会发现:改代码的胆子大了很多,部署前不再心虚,凌晨三点的报警也少了。框架选型五分钟搞定,踩坑排查才花时间——把坑提前在测试里踩掉,比在线上踩便宜太多了。
服务端阅读 05月28日 04:27

Koa 文件上传实战:koa-body 与 koa-multer 的选择与安全防护

Koa 本身不管文件上传——它只处理 HTTP 请求流,解析 multipart 数据得靠中间件。实际项目中 koa-body 和 koa-multer 是两个最主流的选择,选错了后面改起来很痛苦。koa-body:开箱即用,大多数场景的首选koa-body 底层基于 formidable,既能解析普通请求体,又能处理文件上传,一个中间件搞定两件事。不需要额外装 body-parser,配置也少。安装与基本配置:npm install koa-bodyconst koaBody = require('koa-body');app.use(koaBody({ multipart: true, formidable: { maxFileSize: 100 * 1024 * 1024, // 100MB keepExtensions: true, uploadDir: './uploads', multiples: true }}));三个容易踩坑的配置项:multipart: true 必须显式开启,默认是 false,文件上传不生效时先检查这个maxFileSize 默认只有 2MB,上传个高清头像都不够,实际项目基本都要调大新版 koa-body 通过 ctx.request.files 获取文件,旧版用 ctx.request.body.files——升级时这片坑踩的人最多单文件上传:app.use(async (ctx) => { const file = ctx.request.files.file; if (!file) ctx.throw(400, 'No file uploaded'); ctx.body = { message: 'File uploaded successfully', file: { name: file.name, size: file.size, path: file.path, type: file.type } };});多文件上传——注意单文件和多文件的返回格式不一致:只上传一个文件时 ctx.request.files.files 返回的是对象,多个文件时返回数组。不统一处理的话,Array.map 在单文件场景会报 "file.map is not a function":app.use(async (ctx) => { const files = ctx.request.files.files; if (!files) ctx.throw(400, 'No files uploaded'); // 统一转数组,这是 formidable 的坑 const fileList = Array.isArray(files) ? files : [files]; const uploadedFiles = fileList.map(file => ({ name: file.name, size: file.size, path: file.path, type: file.type })); ctx.body = { message: `${uploadedFiles.length} files uploaded`, files: uploadedFiles };});koa-multer:精细控制文件名和存储路径koa-multer 基于 Express 生态的 multer 改造,核心优势是 diskStorage 可以精确控制文件命名和目录结构——比如按日期分目录、按用户 ID 分目录,koa-body 做不到这么灵活。安装与存储配置:npm install koa-multerconst multer = require('koa-multer');const path = require('path');const storage = multer.diskStorage({ destination: function (req, file, cb) { cb(null, './uploads/'); }, filename: function (req, file, cb) { const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); cb(null, file.fieldname + '-' + uniqueSuffix + path.extname(file.originalname)); }});const upload = multer({ storage: storage, limits: { fileSize: 100 * 1024 * 1024 }, fileFilter: function (req, file, cb) { const allowedTypes = ['image/jpeg', 'image/png', 'image/gif']; if (allowedTypes.includes(file.mimetype)) { cb(null, true); } else { cb(new Error('Invalid file type'), false); } }});一个关键区别:koa-multer 的文件挂在 ctx.req.file / ctx.req.files 上(走的是 Node 原生 http.IncomingMessage),不是 ctx.request(Koa 封装的)。拿文件的位置搞混是最常见的低级错误。单文件、多文件、混合上传:// 单文件app.use(upload.single('file'));app.use(async (ctx) => { const file = ctx.req.file; ctx.body = { message: 'File uploaded', file };});// 多文件(最多 10 个)app.use(upload.array('files', 10));app.use(async (ctx) => { const files = ctx.req.files; ctx.body = { message: `${files.length} files uploaded`, files };});// 混合上传:同一请求中不同字段接收不同数量的文件app.use(upload.fields([ { name: 'avatar', maxCount: 1 }, { name: 'documents', maxCount: 5 }]));app.use(async (ctx) => { const files = ctx.req.files; const body = ctx.req.body; ctx.body = { avatar: files.avatar[0], documents: files.documents, data: body };});koa-body 还是 koa-multer?简单场景用 koa-body——一个中间件同时处理请求体解析和文件上传,少装一个包。需要按日期/用户分目录、自定义文件命名规则、按字段分组上传时用 koa-multer。两者也能配合使用,koa-body 处理普通请求体,koa-multer 专门处理上传路由,但要注意中间件加载顺序。文件上传安全防线:三层校验缺一不可文件上传是 Web 应用最常见的攻击入口。2024 年的数据显示,约 40% 的 Web 应用安全漏洞与文件上传校验不足有关。前端的 accept 属性和 JS 校验形同虚设,攻击者用 curl 或 Postman 直接绕过。第一层:中间件级过滤koa-body 的 formidable.filter 和 koa-multer 的 fileFilter 是第一道关:app.use(koaBody({ multipart: true, formidable: { maxFileSize: 10 * 1024 * 1024, // 10MB keepExtensions: true, uploadDir: './uploads', filter: function ({ name, originalFilename, mimetype }) { const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf']; return allowedTypes.includes(mimetype); } }}));但这一层只检查 MIME type——MIME 是客户端声明的,可以伪造。攻击者把 PHP webshell 的 MIME 声明成 image/jpeg 就能过这一关。第二层:业务逻辑校验在业务层同时校验 MIME type 和文件扩展名,两个都对才放行:const path = require('path');const fs = require('fs');async function validateFile(ctx, next) { const file = ctx.request.files?.file; if (!file) ctx.throw(400, 'No file uploaded'); // 校验大小 const maxSize = 10 * 1024 * 1024; if (file.size > maxSize) { fs.unlinkSync(file.path); ctx.throw(400, 'File size exceeds limit'); } // 校验 MIME type const allowedTypes = ['image/jpeg', 'image/png', 'image/gif']; if (!allowedTypes.includes(file.type)) { fs.unlinkSync(file.path); ctx.throw(400, 'Invalid file type'); } // 校验扩展名(双重验证) const ext = path.extname(file.name).toLowerCase(); const allowedExts = ['.jpg', '.jpeg', '.png', '.gif']; if (!allowedExts.includes(ext)) { fs.unlinkSync(file.path); ctx.throw(400, 'Invalid file extension'); } await next();}app.use(validateFile);第三层:运维层防护代码层校验之外,还有几件事必须在运维层面做:随机文件名:不要用用户上传的原始文件名,防止路径遍历攻击(攻击者构造 ../../../etc/passwd 这样的文件名)上传目录隔离:不要把上传目录放在 Web 静态资源目录下,否则上传的 .html 文件可能被执行 XSS失败时清理临时文件:formidable 会先把文件写入 uploadDir,校验失败后不删就留在磁盘上了,日积月累会撑满磁盘速率限制:用 koa-ratelimit 限制单 IP 上传频率,防止恶意大文件轰炸图片处理:上传后的二次加工用户上传的图片通常需要压缩和生成缩略图。sharp 是 Node.js 生态里性能最好的图片处理库,基于 libvips,比 GraphicsMagick 快 4-5 倍,内存占用也更低。npm install sharpconst sharp = require('sharp');app.use(async (ctx) => { const file = ctx.request.files.file; if (!file) ctx.throw(400, 'No file uploaded'); // 生成缩略图 const thumbnailPath = file.path.replace(/(\.[\w\d]+)$/, '_thumb$1'); await sharp(file.path) .resize(200, 200, { fit: 'cover', position: 'center' }) .toFile(thumbnailPath); // 压缩原图 const compressedPath = file.path.replace(/(\.[\w\d]+)$/, '_compressed$1'); await sharp(file.path) .jpeg({ quality: 80 }) .toFile(compressedPath); ctx.body = { message: 'Image processed successfully', original: file.path, thumbnail: thumbnailPath, compressed: compressedPath };});一个实战经验:压缩质量 80 是性价比最高的档位——肉眼几乎看不出和原图的差别,但文件体积能缩小 60-70%。大文件分片上传超过 100MB 的文件不适合一次性上传,网络波动一个中断就从头再来。分片上传把大文件切成小块逐个上传,某片失败了只重传那一片,最后服务端按顺序合并。分片上传实现:const fs = require('fs');const path = require('path');app.use(async (ctx) => { const { chunkIndex, totalChunks, fileId } = ctx.request.body; const file = ctx.request.files.chunk; // 按 fileId 创建临时分片目录 const chunkDir = path.join('./uploads/chunks', fileId); if (!fs.existsSync(chunkDir)) { fs.mkdirSync(chunkDir, { recursive: true }); } // 保存当前分片 const currentChunkPath = path.join(chunkDir, `chunk_${chunkIndex}`); const reader = fs.createReadStream(file.path); const writer = fs.createWriteStream(currentChunkPath); await new Promise((resolve, reject) => { reader.pipe(writer); writer.on('finish', resolve); writer.on('error', reject); }); // 检查是否所有分片都已到达 const uploadedChunks = fs.readdirSync(chunkDir).length; if (uploadedChunks === parseInt(totalChunks)) { // 合并所有分片 const finalPath = path.join('./uploads', `${fileId}${path.extname(file.name)}`); const writeStream = fs.createWriteStream(finalPath); for (let i = 0; i < totalChunks; i++) { const chunkPath = path.join(chunkDir, `chunk_${i}`); const chunkData = fs.readFileSync(chunkPath); writeStream.write(chunkData); fs.unlinkSync(chunkPath); } writeStream.end(); fs.rmdirSync(chunkDir); ctx.body = { message: 'File upload completed', path: finalPath }; } else { ctx.body = { message: `Chunk ${chunkIndex} uploaded`, progress: `${uploadedChunks}/${totalChunks}` }; }});分片上传上生产之前,这几件事必须处理:断点续传:客户端上传前先请求服务端查已有分片列表,跳过已上传的分片,而不是从头开始分片过期清理:用户上传了 3 片然后关闭页面,分片永远留在磁盘上。设置定时任务(cron job),清理超过 24 小时未完成的分片目录并发写入:客户端用 Promise.all 同时传多个分片时,fs.readdirSync 读到的数量可能不准确,需要用文件锁或 Redis 计数器保证一致性完整性校验:合并完成后用 MD5 或 SHA256 校验文件哈希,和客户端传来的原始哈希对比,确保传输没有丢数据生产环境清单| 分类 | 要点 | 不做的后果 ||------|------|-----------|| 文件大小限制 | maxFileSize 或 limits.fileSize | 大文件撑爆内存或磁盘 || MIME + 扩展名双重校验 | 两个都检查,不能只靠一个 | 伪造 MIME 上传恶意文件 || 随机文件名 | UUID 或时间戳+随机数 | 路径遍历攻击、文件名冲突 || 上传目录隔离 | 不放在 static 目录下 | 上传的 HTML/JS 被直接执行 || 校验失败清临时文件 | fs.unlinkSync(file.path) | 磁盘被垃圾文件撑满 || 速率限制 | koa-ratelimit 限制单 IP | 恶意大文件轰炸 || 流式处理大文件 | createReadStream + pipe | 大文件一次性读进内存 OOM || 图片压缩 | sharp 质量设 80 | 原图直接存浪费存储和带宽 || 分片过期清理 | 定时任务清理 24h 未完成分片 | 孤立分片占满磁盘 || 友好错误提示 | "文件太大,最大 10MB" | 用户看到 400 Bad Request 一头雾水 |
服务端阅读 05月28日 04:27

如何在 Koa 中正确使用路由?@koa/router 详解与实战

Koa 核心不包含路由功能,需要通过中间件实现。最常用的路由中间件是 @koa/router(注意不是已停维的 koa-router),它提供了完整的路由定义、参数捕获、中间件编排等能力。本文从安装配置开始,逐步覆盖参数处理、路由嵌套、中间件链、模块化拆分这些实际项目必用的功能,同时补充常见踩坑点。安装和基本用法npm install @koa/router@koa/router 是 Koa 官方维护的路由库,原版 koa-router 已停止维护,新项目统一用 @koa/router。如果你的项目还在用 koa-router,建议尽快迁移——API 几乎一致,替换成本很低。最简路由示例:const Koa = require("koa");const Router = require("@koa/router");const app = new Koa();const router = new Router();router.get("/", async (ctx) => { ctx.body = "Hello Koa";});router.get("/users/:id", async (ctx) => { ctx.body = `User ${ctx.params.id}`;});app.use(router.routes());app.use(router.allowedMethods());app.listen(3000);两个关键点:router.routes() 注册路由中间件,router.allowedMethods() 自动处理不支持的 HTTP 方法(返回 405 或 501)。漏掉 allowedMethods() 不会报错,但未匹配的方法会静默返回 404,排查起来很困惑。路由参数的三种形式路径参数路径参数用 :param 语法捕获,通过 ctx.params 获取:router.get("/users/:id", async (ctx) => { const { id } = ctx.params; ctx.body = `User ID: ${id}`;});// 多个参数router.get("/posts/:postId/comments/:commentId", async (ctx) => { const { postId, commentId } = ctx.params; ctx.body = `Post: ${postId}, Comment: ${commentId}`;});踩坑提醒:@koa/router v15+ 不再支持路径参数中内嵌正则(如 :id(\\d+))),因为底层 path-to-regexp 升级到了 v8。如果你需要校验参数格式,把校验逻辑放到路由处理函数或中间件里:// v15+ 正确做法:在 handler 中校验router.get("/users/:id", async (ctx) => { const { id } = ctx.params; if (!/^\d+$/.test(id)) { ctx.throw(400, "id 必须是数字"); } ctx.body = `User ID: ${id}`;});查询参数查询参数通过 ctx.query 获取,它会自动解析为对象:router.get("/search", async (ctx) => { const { keyword, page = "1", limit = "10" } = ctx.query; ctx.body = { keyword, page: Number(page), limit: Number(limit) };});注意 ctx.query 的值都是字符串,需要手动转数字。ctx.querystring 则是原始查询字符串。正则表达式路由如果路径匹配逻辑比较特殊,可以直接用正则:router.get(/^\/users\/(\d+)$/, async (ctx) => { const id = ctx.params[0]; // 通过索引取捕获组 ctx.body = `User ID: ${id}`;});正则路由用得不多,大部分场景路径参数就够了。它的捕获组通过 ctx.params[0]、ctx.params[1] 按序获取。HTTP 方法与路由注册@koa/router 支持所有常用 HTTP 方法:router.get("/resource", handler); // 查询router.post("/resource", handler); // 创建router.put("/resource/:id", handler); // 全量更新router.patch("/resource/:id", handler); // 部分更新router.delete("/resource/:id", handler); // 删除router.all("/resource", handler); // 匹配所有方法router.all() 适合给一组路由加统一的预处理逻辑,比如鉴权:router.all("/admin/*", authMiddleware);路由前缀与嵌套路由前缀设置前缀后,该路由器下所有路径自动加上前缀:const apiRouter = new Router({ prefix: "/api/v1" });apiRouter.get("/users", handler); // 实际匹配 /api/v1/usersapiRouter.post("/login", handler); // 实际匹配 /api/v1/login也可以用 router.prefix() 方法动态设置:router.prefix("/api/v2");路由嵌套路由嵌套是把子路由挂载到父路由下,形成清晰的 URL 层级:const userRouter = new Router({ prefix: "/users" });userRouter.get("/", async (ctx) => { ctx.body = "User list";});userRouter.get("/:id", async (ctx) => { ctx.body = `User ${ctx.params.id}`;});const commentRouter = new Router({ prefix: "/:userId/comments" });commentRouter.get("/", async (ctx) => { ctx.body = `Comments for user ${ctx.params.userId}`;});userRouter.use(commentRouter.routes());app.use(userRouter.routes());嵌套路由中,子路由可以访问父路由的路径参数(如上面的 ctx.params.userId),这一点很实用。路由中间件路由中间件是在特定路由上挂载的处理函数,可以挂一个或多个,按顺序执行:单路由中间件router.get("/protected", authMiddleware, async (ctx) => { ctx.body = "Protected content";});多个中间件串联router.post("/admin", authMiddleware, // 先鉴权 adminCheckMiddleware, // 再检查权限 async (ctx) => { ctx.body = "Admin content"; });中间件链的核心是 await next()——只有调用 next,后面的中间件才会执行。忘记 await next 是最常见的 bug 之一:// 错误:没有 await next(),后续中间件不会执行async function badMiddleware(ctx, next) { console.log("before"); next(); // 缺少 await console.log("after"); // 会在后续中间件完成前就执行}// 正确写法async function goodMiddleware(ctx, next) { console.log("before"); await next(); console.log("after"); // 后续中间件完成后才执行}路由级中间件用 router.use() 给整个路由器加中间件,作用于该路由器下所有路由:router.use(async (ctx, next) => { const start = Date.now(); await next(); const ms = Date.now() - start; console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);});路由模块化拆分项目稍大一些,把所有路由写在一个文件里就很难维护了。标准做法是按功能模块拆分路由文件:// routes/users.jsconst Router = require("@koa/router");const router = new Router({ prefix: "/users" });router.get("/", async (ctx) => { ctx.body = "User list";});router.get("/:id", async (ctx) => { ctx.body = `User ${ctx.params.id}`;});router.post("/", async (ctx) => { ctx.body = { success: true, user: ctx.request.body };});module.exports = router;// app.jsconst userRoutes = require("./routes/users");const orderRoutes = require("./routes/orders");app.use(userRoutes.routes());app.use(userRoutes.allowedMethods());app.use(orderRoutes.routes());app.use(orderRoutes.allowedMethods());如果模块很多,可以用 require-directory 自动加载:const requireDirectory = require("require-directory");const modules = requireDirectory(module, "./routes");for (const name in modules) { const router = modules[name]; if (router.routes) { app.use(router.routes()); app.use(router.allowedMethods()); }}路由命名和重定向给路由起名字,可以通过名字生成 URL 或做重定向:router.get("user", "/users/:id", async (ctx) => { ctx.body = `User ${ctx.params.id}`;});// 重定向router.redirect("/old-path", "/new-path");router.redirect("/old-user", "user", { id: 123 });// 访问 /old-user 会 301 跳转到 /users/123路由命名在模板渲染时特别有用——改了路径只需改一处定义,所有引用自动更新。错误处理路由内抛错router.get("/users/:id", async (ctx) => { const user = await getUserById(ctx.params.id); if (!user) { ctx.throw(404, "User not found"); } ctx.body = user;});ctx.throw() 会中断后续执行,Koa 会把这个错误传给全局错误处理中间件。全局错误处理在路由之前注册一个 try-catch 中间件,统一捕获所有路由中的错误:app.use(async (ctx, next) => { try { await next(); } catch (err) { ctx.status = err.status || 500; ctx.body = { error: err.message, status: ctx.status }; // 触发 Koa 的 error 事件,方便日志记录 ctx.app.emit("error", err, ctx); }});app.use(router.routes());allowedMethods 的错误处理router.allowedMethods() 会自动处理不支持的 HTTP 方法:app.use(router.allowedMethods({ throw: true, // 不设置则返回 404,设置后抛出异常 notImplemented: () => new NotImplemented(), methodNotAllowed: () => new MethodNotAllowed()}));常见踩坑1. 忘记 await 导致 404这是最频繁遇到的问题。路由处理函数里用了 Promise 但没有 await,Koa 会在 Promise resolve 之前就结束请求:// 总是返回 404router.get("/data", async (ctx) => { db.query("SELECT * FROM users").then(users => { ctx.body = users; // 太晚了,请求已经结束 });});// 正确写法router.get("/data", async (ctx) => { ctx.body = await db.query("SELECT * FROM users");});2. 中间件注册顺序Koa 中间件是洋葱模型,注册顺序决定了执行顺序。如果 router.routes() 在业务中间件之前注册,业务中间件就拿不到路由处理后的 ctx:// 日志中间件放在路由之后,能记录到响应状态app.use(router.routes());app.use(logger()); // logger 拿不到路由设置的 ctx.status// 正确顺序app.use(logger());app.use(router.routes());3. 多个路由器的 allowedMethods每个路由器都应该调用自己的 allowedMethods(),否则一个路由器的路由匹配不上时,其他路由器的 HTTP 方法校验也不会生效。4. 路由参数是字符串ctx.params 和 ctx.query 的值永远是字符串类型,直接当数字用会出问题:router.get("/users/:id", async (ctx) => { // ctx.params.id 是字符串 "123",不是数字 123 const id = Number(ctx.params.id); // 需要手动转换});koa-router 与 @koa/router 的区别旧项目里可能还在用 koa-router,两者的主要差异:| 对比项 | koa-router | @koa/router ||--------|-----------|-------------|| 维护状态 | 已停维 | 官方维护 || TypeScript | 需要 @types/koa-router | 内置类型定义 || Node.js 版本 | 无明确要求 | v15+ 需要 Node ≥ 20 || 路径参数正则 | 支持 :id(\\d+) | v15+ 不支持内嵌正则 || API | 基本一致 | 基本一致 |迁移很简单:把 require("koa-router") 改成 require("@koa/router"),然后把 package.json 里的依赖名换掉。如果用了内嵌正则参数,需要把校验逻辑移到处理函数里。
服务端阅读 05月28日 04:27

Koa 性能优化实战:从中间件调优到多进程部署

Koa 应用跑起来容易,跑得快却要费一番功夫。中间件越堆越多、数据库查询越来越慢、内存一天比一天高——这些问题在开发环境里不容易暴露,一到生产环境就全冒出来了。这篇文章把 Koa 性能优化拆成几个实战方向,每个方向都从"问题是什么"讲到"怎么解决",跳过那些你本来就知道的基础知识,专注真正影响线上性能的环节。先弄清楚瓶颈在哪优化之前先测量,不然就是盲人摸象。Koa 应用最常见的性能瓶颈就这几个:中间件链过长,每个请求白白穿越十几层不需要的中间件数据库查询没做并行,串行 await 一个接一个响应体没压缩,几 KB 的 JSON 外加几十 KB 的冗余字段原样发出去静态资源全走 Node.js 进程,CPU 浪费在文件 I/O 上连接池配置不当,高并发时排队等连接用 koa-logger 或 pino 给每个请求打时间戳,先看哪个路由慢、哪个中间件耗时长,再针对性优化,别上来就乱改。中间件:少就是快Koa 的洋葱模型是它的灵魂,但也是性能陷阱。每个请求都要穿过整个中间件链,中间件越多,每个请求的额外开销越大。几个实用的优化策略:合并功能相近的中间件。安全相关的头(CSP、X-Frame-Options、HSTS)别一个一个挂,用一个 koa-helmet 搞定。CORS 和 body parser 也可以合并到统一的请求预处理中间件里。条件跳过不需要的中间件。不是每个路由都需要 session 解析、CSRF 校验、文件上传处理。根据路径提前判断,不走无用中间件:app.use(async (ctx, next) => { // 静态资源和健康检查不需要 session if (ctx.path.startsWith('/static') || ctx.path === '/health') { return await next(); } // 只有需要鉴权的路由才走 session 解析 await sessionMiddleware(ctx, next);});把轻量中间件放前面,重量级放后面。日志、CORS 这种几乎不耗时的放前面尽早执行;认证、数据库查询这种可能失败的放后面,这样失败的请求不会白跑前面的重逻辑。注意中间件里的隐式阻塞。在洋葱模型的 "upstream" 阶段(await next() 之后)执行重操作是最容易被忽略的性能坑:// 错误:在 upstream 做数据库查询,即使请求已经不需要了app.use(async (ctx, next) => { await next(); const user = await db.findUser(ctx.session.userId); // 白白查询 ctx.set('X-User', user.name);});异步:并行比串行快得多Koa 天生支持 async/await,但这不意味着你写的异步代码就一定高效。最常见的坑是把本该并行的查询写成了串行:// 串行:三个查询依次等待,总耗时 = A + B + Cconst user = await db.findUser(id);const posts = await db.findPosts(id);const stats = await db.findStats(id);// 并行:三个查询同时发出,总耗时 = max(A, B, C)const [user, posts, stats] = await Promise.all([ db.findUser(id), db.findPosts(id), db.findStats(id)]);看起来简单,实际项目中串行 await 的写法随处可见,尤其是跨服务的调用。养成习惯:多个不互相依赖的异步操作,一律 Promise.all。对于需要容错的场景,用 Promise.allSettled 替代,避免一个请求失败导致整个页面挂掉:const [userResult, postsResult] = await Promise.allSettled([ userService.fetch(id), postService.fetch(id)]);const user = userResult.status === 'fulfilled' ? userResult.value : null;const posts = postsResult.status === 'fulfilled' ? postsResult.value : [];缓存:别让重复查询拖垮响应生产环境别用 Map 做缓存,那只是示例代码。真实场景用 Redis,带上合理的过期策略:const redis = require('ioredis');const client = new redis({ host: '127.0.0.1', port: 6379 });async function cached(key, ttl, fetcher) { const cached = await client.get(key); if (cached) return JSON.parse(cached); const data = await fetcher(); await client.set(key, JSON.stringify(data), 'EX', ttl); return data;}// 使用app.use(async (ctx) => { const user = await cached(`user:${ctx.params.id}`, 300, () => db.findUser(ctx.params.id) ); ctx.body = user;});缓存策略的关键不是"加不加缓存",而是"什么时候让缓存失效"。写操作之后主动删缓存,比设一个固定 TTL 更可靠:// 更新后删缓存,下次查询自然刷新await db.updateUser(id, data);await client.del(`user:${id}`);数据库连接池:别省这口配置每个请求都新建数据库连接,连接建立的开销比查询本身还大。用连接池是基本操作,但很多人配了连接池却没调对参数:const { Pool } = require('pg');const pool = new Pool({ max: 20, // 最大连接数,不是越多越好 min: 5, // 最少保持 5 个空闲连接 idleTimeoutMillis: 30000, // 空闲 30 秒回收 connectionTimeoutMillis: 2000 // 2 秒连不上就报错,别让请求卡住});max 设多少合适?经验值是 CPU 核心数的 2-4 倍,再多了数据库端反而会因上下文切换变慢。connectionTimeoutMillis 一定要设,不然连接池耗尽时请求会无限等待,拖垮整个服务。用完连接必须 release(),不然连接泄漏,池子很快就干了。推荐用 pool.query() 自动管理连接生命周期,省心:// 自动获取和释放连接const result = await pool.query('SELECT * FROM users WHERE id = $1', [id]);响应压缩:几行代码换来 70% 体积缩减JSON API 的响应体通常有大量重复的 key 名和冗余空格,压缩效果非常明显。koa-compress 加上就行:const compress = require('koa-compress');const zlib = require('zlib');app.use(compress({ threshold: 1024, // 超过 1KB 才压缩,小响应不值得 gzip: { flush: zlib.constants.Z_SYNC_FLUSH }}));实际效果:一个 15KB 的 JSON 响应,gzip 后大约 3-4KB,减少 70% 以上传输量。对移动端用户尤其明显。如果 NGINX 已经做了压缩,Node.js 层就不用再压了,重复压缩反而浪费 CPU。静态资源:别让 Node.js 干 NGINX 的活Node.js 处理静态文件是出名的慢——单线程忙着读文件,API 请求就排队等着。生产环境静态资源应该交给 NGINX 或 CDN:# NGINX 配置location /static/ { alias /var/www/static/; expires 30d; add_header Cache-Control "public, immutable"; gzip on;}如果一定要在 Koa 里处理(开发环境或小项目),用 koa-static 并配上缓存头:const serve = require('koa-static');app.use(serve('./public', { maxage: 30 * 24 * 60 * 60 * 1000 // 30 天浏览器缓存}));但记住,这只是开发便利,不是生产方案。多进程:一个 CPU 核跑一个实例Node.js 是单线程的,一个实例只能用一个 CPU 核心。4 核服务器只跑一个 Koa 进程,75% 的算力白白闲置。用 cluster 模块最简单:const cluster = require('cluster');const os = require('os');if (cluster.isPrimary) { const cpus = os.cpus().length; for (let i = 0; i < cpus; i++) { cluster.fork(); } cluster.on('exit', (worker) => { console.log(`Worker ${worker.process.pid} 挂了,重启`); cluster.fork(); });} else { // 你的 Koa 应用 app.listen(3000);}生产环境更推荐用 PM2,自带进程管理、日志、监控和自动重启:pm2 start app.js -i max # 自动按 CPU 核心数启动监控:没有数据就没有优化性能优化不是一锤子买卖,上线后必须持续监控,否则优化效果没法量化,新出现的瓶颈也没法发现。用 prom-client 暴露指标,配合 Prometheus + Grafana 做可视化:const client = require('prom-client');const histogram = new client.Histogram({ name: 'http_request_duration_seconds', help: 'Request duration', labelNames: ['method', 'route', 'status']});app.use(async (ctx, next) => { const end = histogram.startTimer(); await next(); end({ method: ctx.method, route: ctx.path, status: ctx.status });});重点看这几个指标:P95/P99 延迟——平均数会掩盖长尾问题错误率——5xx 突然升高说明后端可能有瓶颈事件循环延迟——Node.js 自带的 perf_hooks 可以测,超过 100ms 说明主线程被阻塞了内存 RSS——持续增长不回落,大概率有泄漏内存泄漏排查可以用 heapdump 抓堆快照,用 Chrome DevTools 对比两次快照的差异,找出只增不减的对象。HTTP/2:条件成熟再上HTTP/2 的多路复用、头部压缩确实能减少延迟,但对 Koa 应用来说,如果前面有 NGINX 做反向代理(生产环境通常都有),那 NGINX 到浏览器的链路启用 HTTP/2 就够了,Node.js 内部通信走 HTTP/1.1 完全没问题。直接在 Koa 上启 HTTP/2 需要 TLS 证书,配置不简单,收益也有限。除非你的架构是 Node.js 直接对外暴露,否则优先级不高:const http2 = require('http2');const fs = require('fs');const server = http2.createSecureServer({ key: fs.readFileSync('server.key'), cert: fs.readFileSync('server.crt')}, app.callback());server.listen(3000);框架选择:该换就换Koa 的定位是轻量级中间件框架,它把灵活性做到了极致,但性能不是它的首要目标。如果你的项目对吞吐量有硬性要求(比如 API 网关、高并发微服务),值得认真考虑 Fastify:| 维度 | Koa | Fastify ||------|-----|---------|| 吞吐量 | 3-4.5 万 RPS | 5-8 万 RPS || 内存基线 | 30-50 MB | 50-80 MB || 中间件模型 | 洋葱模型(优雅) | 钩子模型(高效) || JSON 序列化 | 原生 JSON.parse | fast-json-stringify || 学习曲线 | 低 | 中 |Fastify 通过 JSON Schema 预编译序列化、更扁平的中间件架构,在纯性能上比 Koa 快 50-80%。如果你的 Koa 应用已经优化到位还是扛不住,换框架可能比继续调参更实际。这不是说 Koa 不好——它的洋葱模型在中间件编排上确实更优雅,错误处理也更自然。只是在极端性能场景下,架构选型的影响比代码优化大得多。
服务端阅读 05月28日 04:26

Koa 中间件怎么写?洋葱模型和常见中间件一次搞懂

Koa 中间件就是一个 async 函数,接收 ctx(请求上下文)和 next(调用下游中间件)两个参数。调用 await next() 把控制权交给下一个中间件,等下游全部执行完再返回——这种"先进后出"的执行流程叫洋葱模型。自定义中间件的关键是理解 await next() 这一行:它前面是请求阶段的逻辑,后面是响应阶段的逻辑。忘了 await,后置代码会和下游并发执行,时序全乱。async function myMiddleware(ctx, next) { // 请求阶段:进入洋葱 console.log(`${ctx.method} ${ctx.url}`); await next(); // 交出控制权,等下游执行完再回来 // 响应阶段:出洋葱 console.log(`status: ${ctx.status}`);}app.use(myMiddleware);中间件的注册顺序决定执行顺序——先 app.use 的先进入,但最后出来。实际项目中最常踩的坑:错误处理中间件没放在最前面,导致下游抛的异常它捕获不到。追问洋葱模型的执行顺序具体是怎样的?两个中间件就能看清楚:app.use(async (ctx, next) => { console.log('A 进'); await next(); console.log('A 出');});app.use(async (ctx, next) => { console.log('B 进'); await next(); console.log('B 出');});// 输出:A 进 → B 进 → B 出 → A 出底层实现是 koa-compose,它把中间件数组递归串联成一个 Promise 链。当你调用 await next() 时,实际调用的是 compose 生成的下一个函数;当所有中间件执行完,next 变成空函数,Promise 链开始回溯。如何写可配置的中间件?用工厂函数包一层,把配置项当参数传进去:function createLogger(opts = {}) { return async (ctx, next) => { const start = Date.now(); await next(); const ms = Date.now() - start; console.log(`${ctx.method} ${ctx.url} - ${ms}ms`); };}app.use(createLogger({ format: 'tiny' }));Koa 生态里的中间件(koa-bodyparser、koa-static 等)几乎都采用这个模式,好处是同一个中间件可以在不同路由上用不同配置。常见中间件有哪些?各自解决什么问题?| 类型 | 作用 | 代表实现 ||------|------|----------|| 错误处理 | 兜底捕获异常,统一错误响应格式 | 自写,放最前面 || 日志 | 记录请求方法、路径、耗时 | koa-logger || 认证 | 校验 token,拒绝未授权请求 | koa-jwt || CORS | 设置跨域响应头 | @koa/cors || 请求体解析 | 把请求体解析到 ctx.request.body | koa-bodyparser || 静态文件 | 托管静态资源 | koa-static || 会话 | 管理 cookie/session | koa-session || 路由 | 路径匹配和参数提取 | @koa/router |Koa 本身只封装了 ctx 和中间件机制,这些能力全部靠中间件补齐。和 Express 的区别是:Express 中间件是线性的,Koa 是洋葱式的,所以 Koa 的错误处理和响应修改更自然。next() 不加 await 会怎样?下游中间件照常执行,但你的后置逻辑会和下游并发运行,时序不可控。典型后果:日志中间件记录的 ctx.status 是默认的 404,因为真实状态码还没设置完你就读了;错误处理中间件捕获不到异常,因为错误还没抛出来你就已经退出了。只有一种场景可以不 await:发后即忘的埋点或异步通知,不阻塞响应。多个中间件怎么组合管理?用 koa-compose 把多个中间件合成一个,适合路由级别的中间件分组:const compose = require('koa-compose');const authChain = compose([authMiddleware, rbacMiddleware]);router.get('/admin', authChain, adminHandler);大项目里建议按目录组织:middleware/logger.js、middleware/auth.js,在 app.js 里按顺序引入。注册顺序就是执行顺序,搞错了很难排查,所以保持 app.use 列表简洁清晰很重要。
服务端阅读 05月28日 04:25

Koa 和 Express 有什么区别?洋葱模型原理是什么?

Koa 和 Express 最大的区别是中间件模型:Express 是线性顺序执行,Koa 是洋葱模型——请求先进后出,每个中间件可以同时在请求阶段和响应阶段插入逻辑。具体来说,Express 中间件按注册顺序依次执行,next() 调用后控制权交给下一个中间件,走完就结束了,不会再回来。Koa 通过 async/await 让 await next() 之后的代码在下游中间件执行完后继续运行,天然支持后置处理(比如统一加耗时日志、响应包装)。其他关键区别:体量:Express 自带路由、静态文件服务等;Koa 核心只有约 550 行代码,路由等全靠第三方中间件上下文:Express 分开操作 req 和 res;Koa 封装成单个 ctx 对象,ctx.request / ctx.response 提供更友好的 API,同时 ctx.req / ctx.res 仍可访问原生对象错误处理:Express 需要手动在回调里 if (err) 或写错误中间件;Koa 可以在整个中间件链最外层用 try/catch 统一捕获,也可以监听 app.on('error')异步:Express 基于回调,异步错误容易丢失;Koa 原生 async/await,异步流程可预测追问洋葱模型具体怎么执行的?app.use(async (ctx, next) => { console.log(1); // 请求进入 await next(); console.log(2); // 响应返回});app.use(async (ctx, next) => { console.log(3); await next(); console.log(4);});// 输出顺序:1 → 3 → 4 → 2await next() 是分界线,前面是请求阶段逻辑,后面是响应阶段逻辑。这就是"洋葱"——从外层穿到内层,再从内层穿回外层。为什么 Koa 不内置路由?设计哲学不同。Koa 团队认为框架核心应该只做 HTTP 请求/响应的流转控制,路由、模板、静态文件这些属于应用层关注点,交给社区按需选择。好处是不用为不需要的功能买单,坏处是新手需要自己拼装中间件栈,上手成本稍高。Express 中间件能迁移到 Koa 吗?不能直接迁移。Express 中间件签名是 (req, res, next),Koa 是 async (ctx, next),参数和异步模型完全不同。koa-connect 可以做桥接但不是长久之计,迁移基本等于重写。实际项目怎么选?快速出活、生态完善选 Express;需要精细控制请求生命周期、团队习惯 async/await 选 Koa。2026 年的新项目两者都不一定是首选——Fastify 性能更好,NestJS 提供更完整的工程化方案,按团队规模和技术偏好综合判断。
服务端阅读 05月28日 04:25

Jest 中 test.skip 和 test.only 有什么区别?

Jest 用 .skip 排除测试,用 .only 聚焦测试——两种思路,作用对象都可以是单个 test 或整个 describe。跳过(skip):标记的测试不执行,但会在报告中显示为 skipped。test.skip('暂时不跑', () => { ... }); // 等价于 xtest / xitdescribe.skip('整组跳过', () => { ... }); // 等价于 xdescribe聚焦(only):只执行标记的测试/套件,其余全部跳过。test.only('只跑这个', () => { ... }); // 等价于 fit / it.onlydescribe.only('只跑这组', () => { ... }); // 等价于 fdescribe关键区别:skip 是"排除法",only 是"聚焦法"。多个 only 会全部执行——它不是"仅这一个",而是"至少这些"。追问test.skip 和 describe.skip 什么时候用?单个用例有问题用 test.skip,整个模块依赖没准备好用 describe.skip。常见场景:某个 API 还没上线、测试依赖的外部服务挂了。但千万别把 skip 当摆设——CI 里积压的 skip 测试是技术债,团队应有清理机制。.only 提交到 CI 会怎样?CI 只跑被 only 标记的测试,大量测试被静默跳过,回归缺陷直接漏到线上。防御手段:eslint-plugin-jest 的 no-focused-tests 规则,在 pre-commit 或 CI 阶段拦截。也有团队在 CI 启动时用自定义 Jest Environment 强制把 .only 和 .skip 还原成普通函数,确保全量执行。条件性跳过怎么写?const skipInCI = process.env.CI ? test.skip : test;skipInCI('本地才跑的测试', () => { ... });或用 Jest 28+ 的 describe.skipIf / test.skipIf:test.skipIf(process.env.CI)('本地才跑', () => { ... });命令行过滤和 .only 有什么区别?jest --testNamePattern="should add" 是纯命令行行为,不改代码,不污染仓库。.only 写在代码里,容易误提交。日常调试优先用命令行参数或 --onlyChanged,只有需要在特定文件内反复调试时才用 .only。怎么防止团队积累大量 skip 测试?三招配合:1) ESLint 规则 no-disabled-tests 配合 warn,skip 超过阈值就 CI 失败;2) 要求 skip 必须带注释说明原因和预期恢复时间;3) 每次发版前用 jest --listTests --onlyFailures 扫一遍,skip 数量纳入代码健康指标。
服务端阅读 05月28日 04:23

Jest 如何测试异步代码?4 种方式与常见坑

Jest 测试异步代码有四种方式,按推荐优先级排列:async/await、resolves/rejects 匹配器、返回 Promise、done 回调。核心原则只有一个——让 Jest 知道测试什么时候算完。最常用的是 async/await,直接在 test 函数加 async,用 await 等待异步结果:test('fetches user', async () => { const user = await getUser(1); expect(user.name).toBe('Alice');});如果你不需要对结果做复杂断言,.resolves / .rejects 更简洁:test('resolves with data', () => { return expect(fetchData()).resolves.toBe('ok');});test('rejects on error', () => { return expect(fetchData()).rejects.toThrow('not found');});注意这里必须 return,否则 Jest 不会等 Promise 结束。老项目里遇到回调风格的异步代码,用 done 参数:test('callback style', done => { readFile('config.json', (err, data) => { if (err) { done(err); return; } try { expect(data.port).toBe(3000); done(); } catch (e) { done(e); } });});done 里面务必包 try-catch,否则 expect 失败会抛异常,done() 永远不被调用,你看到的不是断言错误而是超时错误,排查半天。追问忘记 return Promise 会怎样?测试立即通过——而且是假通过。Jest 认为同步部分执行完就算结束,Promise 还没 resolve 就已经收工了。这是异步测试里最常见的坑,排查时看测试函数有没有 return 或 await 就行。done 和 Promise 能混用吗?不能。Jest 检测到同一个测试既传了 done 又返回了 Promise,会直接抛错,防止内存泄漏。选一种用到底。async 函数抛错怎么测?expect(fn()).toThrow() 对 async 无效,因为 async 函数返回的是 Promise 而不是直接抛错。正确写法:await expect(getUser(-1)).rejects.toThrow('invalid id');或者用 try-catch 配合 expect.assertions(1) 确保断言真的被执行了。定时器相关的异步怎么测?用 jest.useFakeTimers() 把定时器替换成模拟的,然后手动推进时间,不用真等:jest.useFakeTimers();test('debounce fires after delay', () => { const fn = jest.fn(); debounce(fn, 300); jest.advanceTimersByTime(300); expect(fn).toHaveBeenCalled();});实际项目里哪种用得最多?async/await 占绝大多数场景,.resolves/.rejects 适合单行断言,done 基本只在对接老式回调 API 时才用。定时器模拟主要出现在防抖、轮询、超时重试这类逻辑里。
服务端阅读 05月28日 04:23

什么是以太坊跨链技术?请解释跨链桥和资产转移机制

跨链技术是连接不同区块链、实现资产与数据互操作的核心基础设施。面试中这道题考察的是对跨链原理的系统性理解,下面从核心机制、桥接模型、安全风险三个层面逐步拆解。跨链解决什么问题每条区块链都是独立的封闭系统,资产和状态无法直接跨链访问。跨链技术打破这种孤岛效应,让用户能在不同链之间转移资产、传递消息、调用合约。典型场景包括:将以太坊上的ETH转移到Polygon上使用DeFi协议、在Arbitrum和Optimism之间迁移流动性、通过中继链实现Cosmos生态与以太坊的消息互通。跨链桥的核心模型跨链桥是跨链资产转移最常用的实现方式。根据资产在两条链上的处理方式,主要有三种模型。锁定-铸造-销毁-解锁模型(Lock-Mint-Burn-Unlock)这是最常见的跨链桥模型,Wrapped BTC(WBTC)就是典型代表。流程:用户在源链将资产锁定到桥的智能合约中桥的验证者在目标链铸造等量的映射代币(wrapped token)用户在目标链使用映射代币参与DeFi等场景赎回时,用户在目标链销毁映射代币,桥在源链解锁原始资产优点: 资产总量守恒,源链锁定的资产始终作为目标链映射代币的1:1储备。缺点: 源链锁定的资产成为巨大的安全蜜罐,一旦合约被攻破,目标链的映射代币将归零。流动性池模型(Liquidity Pool)桥在多条链上各部署一个流动性池,用户在源链存入资产A,从目标链的流动性池中提取资产B。优点: 不需要铸造映射代币,用户直接获得目标链的原生资产,使用体验更自然。缺点: 流动性有限,如果目标链池子中资产不足,跨链交易会失败;需要激励流动性提供者。代表项目: Hop Protocol、Across Protocol、Stargate。原子交换模型(Atomic Swap / HTLC)通过哈希时间锁定合约实现无需信任第三方的跨链交换。流程:发送方在源链创建HTLC,锁定资产并生成哈希锁接收方在目标链创建对应的HTLC,锁定等值资产接收方用哈希原像(preimage)领取目标链资产发送方从链上获取原像后,领取源链资产如果超时未完成,双方都可取回各自资产优点: 无需信任第三方,通过密码学保证原子性。缺点: 只支持简单的资产互换,不支持通用消息传递;要求两条链都支持哈希锁和时间锁。contract HTLC { struct Swap { bytes32 hashLock; address sender; address receiver; uint256 amount; uint256 timelock; bool claimed; bool refunded; } mapping(bytes32 => Swap) public swaps; function createSwap( bytes32 hashLock, address receiver, uint256 timelock ) public payable { bytes32 swapId = keccak256(abi.encodePacked(msg.sender, receiver, block.timestamp)); swaps[swapId] = Swap({ hashLock: hashLock, sender: msg.sender, receiver: receiver, amount: msg.value, timelock: timelock, claimed: false, refunded: false }); } function claimSwap(bytes32 swapId, bytes32 preimage) public { Swap storage swap = swaps[swapId]; require(!swap.claimed && !swap.refunded); require(block.timestamp < swap.timelock, "Timelock expired"); require(keccak256(abi.encodePacked(preimage)) == swap.hashLock, "Invalid preimage"); swap.claimed = true; payable(swap.receiver).transfer(swap.amount); } function refundSwap(bytes32 swapId) public { Swap storage swap = swaps[swapId]; require(!swap.claimed && !swap.refunded); require(block.timestamp >= swap.timelock, "Timelock not expired"); swap.refunded = true; payable(swap.sender).transfer(swap.amount); }}跨链验证机制跨链桥的核心信任问题是:目标链如何验证源链上确实发生了某件事?根据验证方式的不同,分为以下几类。公证人方案(Notary / Multisig)由一组受信任的验证者监听源链事件,在目标链上用多重签名确认。大多数早期跨链桥(如Multichain)采用此方案。安全假设: 假设多数验证者是诚实的。一旦验证者私钥泄露或串谋,桥的资金就会被盗。轻客户端验证(Light Client)在目标链上部署源链的轻客户端合约,通过验证区块头和Merkle证明来确认源链交易。优点: 不依赖第三方信任,安全性由源链共识保证。缺点: 链上验证Gas开销大,每条源链都需要单独部署轻客户端合约。代表项目: Cosmos IBC、near Rainbow Bridge。中继网络(Relayer Network)由去中心化的中继者网络负责跨链消息传递,中继者需要质押代币作为担保,作恶会被罚没。代表项目: LayerZero、Axelar。LayerZero的架构值得关注:它将验证拆分为Oracle(提供区块头)和Relayer(提供交易证明)两个独立角色,两者串谋才能作恶,降低了信任假设。原生互操作协议链本身在设计上就支持跨链通信,而非依赖外部桥。Cosmos IBC:通过标准化的跨链通信协议,实现Cosmos生态内任意链之间的资产和消息传递Polkadot XCMP:通过中继链实现平行链之间的跨链消息路由Chainlink CCIP:基于Oracle网络的跨链互操作标准,支持任意消息传递跨链安全:不可回避的问题跨链桥是以太坊生态中安全问题最严重的领域之一。据统计,跨链桥攻击造成的损失占DeFi总损失的一半以上。重大安全事件Ronin Bridge(2022):攻击者获取9个验证者中5个的私钥,盗取6.24亿美元Wormhole(2022):签名验证逻辑漏洞,损失3.26亿美元Nomad(2022):初始化漏洞导致任何人都能伪造跨链消息,损失1.9亿美元Harmony Horizon(2022):2/5多签验证者私钥泄露,损失1亿美元安全风险根源验证者集中心化:多签阈值过低,少量私钥泄露即可控制整座桥合约逻辑漏洞:跨链合约复杂度高,容易引入签名验证、权限管理等bug紧急暂停机制缺失:异常发生时无法快速止损流动性集中:锁定的海量资产成为黑客的终极目标安全设计原则使用时间锁延迟大额提款,留出应急响应窗口多签阈值不低于2/3,验证者地理和机构分散设置交易限额和速率限制部署实时监控和异常检测系统定期进行安全审计,包括合约审计和验证者运维审计面试回答思路被问到这道题时,建议按以下结构组织答案:先说为什么需要跨链:区块链孤岛问题,多链生态需要互操作核心模型三选一讲透:锁定-铸造模型最常见,讲清流程和风险即可验证机制是区分深度的关键:能区分公证人/轻客户端/中继网络,说明你理解信任假设安全事件必须提:Ronin和Wormhole是最常被追问的案例新协议加分项:提到LayerZero或CCIP说明你关注行业进展追问准备Q:Lock-Mint模型的最大风险是什么?如何改进?最大风险是源链锁仓资产成为单点故障。改进方向:采用流动性池模型分散风险;使用MPC-TSS替代多签管理锁仓资金;引入保险机制覆盖极端情况。Q:LayerZero和传统跨链桥有什么区别?LayerZero将验证拆分为Oracle和Relayer两个独立角色,传统桥的验证者同时提供区块头和交易证明。分离后需要两者串谋才能伪造跨链消息,信任假设更弱。此外LayerZero是通用消息层,不限于资产转移。Q:如何设计一个安全的跨链桥?核心原则:最小信任假设(优先轻客户端验证)、最大去中心化(验证者集足够大且分散)、深度防御(时间锁+限额+监控+暂停)、透明可审计(所有操作链上可验证)。
服务端阅读 05月28日 04:22

Jest 测试怎么运行和调试?常用命令有哪些?

核心命令一览运行测试最常用的几个命令:# 运行所有测试npx jest# 运行指定文件npx jest path/to/test.spec.js# 运行匹配名称的用例npx jest --testNamePattern="should add"# 监听模式,文件变动自动重跑npx jest --watch# 只跑上次失败的用例npx jest --onlyFailures# 只跑和改动文件相关的用例npx jest --onlyChanged--watch 是日常开发最高频的选项,保存即跑,不用手动重复执行。--onlyFailures 在修复阶段很实用——测试多的时候不用每次全量跑一遍。运行测试的常见场景按文件或路径筛选# 跑某个目录下的所有测试npx jest src/utils/# 用正则匹配文件名npx jest --testPathPattern="auth"--testPathPattern 接收正则表达式,比手动拼路径灵活得多。比如项目里测试文件散落在多个目录,用 --testPathPattern="user" 就能一次跑完所有用户相关的测试。按用例名称筛选# 缩写形式npx jest -t "login"# 完整写法npx jest --testNamePattern="should handle error"-t 是 --testNamePattern 的缩写,匹配的是 describe 或 test 块的名字。注意它是正则匹配,写 "add" 会同时命中 "should add" 和 "should handle addError"。在 CI 环境中运行CI 环境和本地开发不同,通常需要关注几个问题:# CI 中推荐的做法npx jest --ci --coverage --forceExit --detectOpenHandles--ci:禁用快照交互提示,避免 CI 卡住--coverage:生成覆盖率报告,配合配置阈值可以在覆盖率不达标时让构建失败--forceExit:测试跑完强制退出进程,防止异步操作(定时器、未关闭的连接)导致进程挂起--detectOpenHandles:检测未关闭的句柄,帮你定位是哪个异步操作阻止了退出调试测试的实用方法用 console.log 快速排查最直接的方式,适合简单问题:test('计算结果验证', () => { const result = calculate(2, 3); console.log('结果:', result); // 快速看输出 expect(result).toBe(5);});注意 console.log 在并行模式下输出顺序可能混乱,调试时建议加 --runInBand。用 --runInBand 单线程运行这是调试的关键选项。Jest 默认用多个 worker 进程并行跑测试,这会导致断点无法命中、日志顺序错乱。--runInBand 让所有测试在同一个进程中顺序执行:npx jest --runInBand什么时候必须加 --runInBand:使用 debugger 断点调试时用 Chrome DevTools Inspector 时测试间有共享状态(虽然不推荐,但遗留项目常见)需要 console.log 输出按顺序排列时用 Node Inspector 调试在代码中加 debugger 语句,然后用 Node 的 Inspector 模式启动 Jest:node --inspect-brk ./node_modules/.bin/jest --runInBand--inspect-brk 会在第一行就暂停,给你时间打开调试工具。然后打开 Chrome,访问 chrome://inspect,点击 "inspect" 就能进入 DevTools 调试界面。用 VSCode 调试在 .vscode/launch.json 中添加配置:{ "type": "node", "request": "launch", "name": "Jest Current File", "program": "${workspaceFolder}/node_modules/.bin/jest", "args": ["${fileBasenameNoExtension}", "--runInBand"], "console": "integratedTerminal", "internalConsoleOptions": "neverOpen"}配好之后,打开测试文件直接按 F5 就能断点调试,比每次手敲命令方便很多。用 --verbose 查看详细输出npx jest --verbose--verbose 会让每个测试用例单独列出结果,包括嵌套的 describe 层级。默认输出只显示文件级别的通过/失败,用 --verbose 能快速定位是哪个用例出了问题。常用命令行选项速查| 选项 | 作用 | 使用场景 ||------|------|----------|| --runInBand | 单进程顺序执行 | 调试、需要稳定输出顺序 || --watch | 监听文件变化自动重跑 | 日常开发 || --onlyFailures | 只跑失败的用例 | 修复阶段 || --bail | 遇到失败立即停止 | 快速发现问题 || --coverage | 生成覆盖率报告 | CI 检查、质量把控 || --detectOpenHandles | 检测未关闭的句柄 | 进程挂起时排查 || --forceExit | 强制退出 | CI 环境、异步泄漏 || --verbose | 显示详细用例结果 | 定位具体失败用例 || --no-cache | 禁用缓存 | 怀疑缓存导致问题时 || --ci | CI 模式 | 持续集成环境 |常见问题排查测试跑不过的时候,按这个顺序排查:先加 --verbose 看清楚是哪个用例失败用 --runInBand 单线程重跑,排除并行导致的问题加 --no-cache 排除缓存干扰用 debugger 或 console.log 在失败处打断点如果进程卡住不退出,用 --detectOpenHandles 找到未关闭的资源记住一点:并行模式下测试通过但单线程失败,或者反过来,通常说明测试之间有隐式依赖,需要检查是否共享了状态或 mock 没有正确清理。
服务端阅读 05月28日 04:19

Hardhat、Foundry、Truffle 有什么区别?以太坊开发框架怎么选?

以太坊开发工具链的核心是三个框架:Hardhat、Foundry 和 Truffle。Truffle 已于 2023 年被 ConsenSys 停止维护,新项目不要再用。现在的选择基本就是 Hardhat 和 Foundry 二选一,或者混合使用。Hardhat 基于 JavaScript/TypeScript,插件生态最丰富,适合全栈 DApp 开发——你的测试脚本可以方便地调用前端库、操作 DOM、集成 CI/CD。Foundry 用 Rust 写的,测试直接用 Solidity,编译和跑测试比 Hardhat 快 10 倍以上,内置 fuzz 测试和 invariant 测试,安全审计团队几乎都用 Foundry。实际项目里的做法:很多团队用 Foundry 写测试(快、原生 Solidity、fuzz),用 Hardhat 做部署和集成(插件多、TypeScript 脚本灵活)。两者共享同一套合约代码,Hardhat 3 甚至能直接读 foundry.toml 配置。追问Hardhat 和 Foundry 的测试有什么本质区别?Hardhat 用 JS/TS 测试合约,通过 ethers.js 调用合约方法,测试文件和合约文件语言不同,需要频繁在两种语言间切换。Foundry 直接用 Solidity 写测试,测试合约和业务合约同语言,还能用 vm.prank、vm.deal 等 cheatcode 直接操纵 EVM 状态——比如模拟某个地址发起交易、强制给地址打 ETH,这在 Hardhat 里做不到。为什么 Truffle 被淘汰了?性能差(测试套件跑得慢)、插件生态不如 Hardhat 丰富、没有 fuzz 测试支持。最关键的是 ConsenSys 在 2023 年 9 月宣布停止维护,代码仓库已归档。还在用 Truffle 的项目建议尽快迁移到 Hardhat 或 Foundry。实际项目里怎么选?三个判断标准:团队技术栈(JS/TS 为主选 Hardhat,Solidity 为主选 Foundry)、项目类型(全栈 DApp 选 Hardhat,纯合约/DeFi 协议选 Foundry)、安全要求(高价值合约必选 Foundry,审计师默认要求 Foundry 测试)。不确定就两个都装,测试用 Foundry 跑,部署用 Hardhat 管。Hardhat 3 和 Foundry 的速度差距还大吗?Hardhat 3 用 Rust 重写了执行层,速度比 v2 快了很多,冷启动从十几秒降到 2 秒左右,但 Foundry 依然更快(冷启动约 1.3 秒)。日常开发体感差异不大,真正拉开差距的是大项目的完整测试套件和 fuzz 测试跑几千个 case 的时候。写段代码// Foundry fuzz 测试示例function testFuzzTransfer(address to, uint256 amount) public { vm.assume(to != address(0)); token.mint(address(this), amount); token.transfer(to, amount); assertEq(token.balanceOf(to), amount);}
服务端阅读 05月28日 04:17

以太坊改进提案 EIP 是什么?EIP-1559、ERC-20、ERC-721 有何区别?

EIP(Ethereum Improvement Proposal)是以太坊社区提出新功能、标准和流程改进的正式机制,类似于比特币的 BIP。任何人都能提交,但只有经过社区讨论、技术审查和共识达成后才会被纳入协议。EIP 分三类:标准跟踪型(影响协议或应用层,如 EIP-1559、ERC-20)、元 EIP(修改 EIP 流程本身)、信息性 EIP(指南性质,不涉及功能变更)。标准跟踪型下设 Core、Networking、Interface、ERC 四个子类型——其中 ERC(Ethereum Request for Comment)专指应用层标准,所以 ERC-20 的正式编号其实是 EIP-20,只是社区习惯了 ERC 的叫法。EIP-1559 改革了以太坊手续费市场。之前是首价拍卖模式——用户盲猜 gas 价格,出价低就等,出价高就亏。1559 引入了协议自动调节的 base fee:每个区块根据拥堵程度调整,增幅上限 12.5%。用户只需设置 maxFeePerGas(愿意支付的上限)和 maxPriorityFeePerGas(给验证者的小费)。关键变化:base fee 被销毁而非给矿工,上线一个月就烧掉超 20 万 ETH,累计已烧毁超 300 万 ETH,给 ETH 带来了通缩压力。交易等待时间也从约 17 秒降到约 10 秒。ERC-20 定义了同质化代币标准:totalSupply、balanceOf、transfer、approve、transferFrom 六个核心函数,加上 Transfer 和 Approval 两个事件。每个代币完全等价、可互换、可分割。USDT、UNI 等主流代币都基于此标准,是 DeFi 生态的基石。ERC-721 定义了非同质化代币(NFT)标准。每个 token 有唯一 tokenId,不可互换、不可分割。CryptoKitties 是最早出圈的应用,后来催生了整个 NFT 市场。追问EIP-1559 烧了这么多 ETH,矿工为什么还同意?矿工确实反对过,base fee 销毁直接砍掉了手续费收入。但 1559 通过的原因:一是改善了用户体验和费用可预测性,这是生态长期发展的刚需;二是 ETH 通缩预期推高了币价,矿工通过升值弥补了部分损失;三是 Vitalik 和核心开发者力推,矿工在治理中话语权有限。PoS 合并后验证者经济模型重新设计,1559 的收入影响进一步被稀释。ERC-20 的 approve 有什么经典漏洞?重置攻击(race condition):假设 A 给 B 授权了 100 USDT,A 想改成 50,先调 approve(50) 把额度从 100 降到 0——但在 0→50 之间,B 可以在额度归零瞬间抢先转走剩余的 100,再等 50 生效后又转走 50,总共 150。解决方案:用 OpenZeppelin 的 SafeERC20 库,或采用 increaseAllowance/decreaseAllowance 增量修改。ERC-1155 和 ERC-20/721 怎么选?ERC-1155 是多代币标准,一个合约管理多种同质化+非同质化代币,支持批量转账。游戏场景最合适——一笔交易转出金币(同质化)+ 三把武器(NFT),省大量 Gas。选型看三点:是否需要批量操作、Gas 敏感度、交易所兼容性(ERC-1155 的交易所支持远不如 ERC-20/721)。EIP 从 Draft 到 Final 一般多久?简单 ERC 标准可能几个月,核心协议变更动辄数年。EIP-1559 从 2019 年提出到 2021 年伦敦升级上线花了近两年,期间经历了激烈争议和多轮修改。越底层的变更,需要越多客户端团队实现、测试网验证和社区共识。EIP-3074 和 EIP-7702 是什么关系?都是账户抽象的提案,但路线不同。EIP-3074 让 EOA 通过 AUTH/AUTHCALL 指令委托智能合约执行操作,但存在安全风险(被委托的合约可以代替你做任何事)。EIP-7702 是 3074 的替代方案,在 Pectra 升级中上线,允许 EOA 临时设置为智能合约代码,更安全也更灵活。3074 已被废弃。
服务端阅读 05月28日 04:16

以太坊交易的结构、生命周期和费用机制是怎样的?

以太坊交易是从一个账户向另一个账户发起的状态变更请求。核心结构包含 nonce、to、value、data、gasLimit 等字段;生命周期经历创建→签名→广播→入池→打包→确认六个阶段;费用由 EIP-1559 的 Base Fee + Priority Fee 构成,Base Fee 会被销毁,Priority Fee 归验证者。一笔交易本质上是对以太坊全局状态的修改指令。发起者用自己的私钥签名授权,网络验证者执行后将状态变更写入区块。理解交易机制的关键在于搞清楚三个问题:交易长什么样、怎么从发出去到最终确认、手续费怎么算。追问EIP-1559 之前和之后的费用机制有什么区别?之前是纯竞价模式:用户自己设 gasPrice,出价高的先被打包,出价低的可能长时间Pending。问题是对用户不友好——你不知道该出多少钱,经常多付或卡住。EIP-1559 引入了 Base Fee(基础费用),由协议根据上一个区块的 Gas 使用量自动调整,用户无法控制。在 Base Fee 之上用户加一个 Priority Fee(小费)激励验证者优先打包。用户只需设 maxFeePerGas(愿意支付的上限)和 maxPriorityFeePerGas,钱包自动处理。Base Fee 部分会被销毁而非给验证者,这是 ETH 通缩效应的来源之一。| 对比项 | 旧模式 (Legacy) | EIP-1559 ||--------|-----------------|----------|| 费用组成 | gasPrice 单一价格 | Base Fee + Priority Fee || 费用去向 | 全部给矿工/验证者 | Base Fee 销毁,Priority Fee 给验证者 || 用户设置 | 手动猜 gasPrice | 设上限,钱包自动估算 || 可预测性 | 差 | 好 |Nonce 不匹配会怎样?怎么处理?Nonce 是账户级别的交易计数器,每笔交易必须严格递增。如果你发了 nonce=5 但上一个是 nonce=4 还没确认,nonce=5 会一直卡在交易池里等 4 先通过。常见坑:连续发多笔交易时,如果中间某笔 Gas 设太低一直 Pending,后面的交易全部排队。解决办法是用 eth_getTransactionCount 查询当前 nonce,或者用 nonce: "pending" 参数获取包含待确认交易的计数。加速或取消交易也是通过替换同一 nonce 的新交易实现的。交易在 Mempool 里待太久会怎样?Mempool(交易池)是待确认交易的暂存区,每个节点维护自己的 Mempool。交易在里面等待验证者挑选打包,Priority Fee 高的优先。如果 Gas 设太低,可能长时间不被打包。更糟的是,Mempool 里的交易对所有节点可见,这就催生了 MEV(最大可提取价值)——搜索者监控 Mempool 发现套利机会,通过更高 Priority Fee 抢跑。比如你大额兑换代币,MEV 机器人可以在你前面插队先交易,导致你拿到更差的价格(三明治攻击)。所以现在很多钱包和协议支持 Flashbots 等私有交易池,交易不进入公开 Mempool,直接发给验证者,减少被抢跑风险。交易失败 Gas 费还会扣吗?会。只要交易被纳入区块并执行了,不管成功失败,已消耗的 Gas 都不退。这是因为验证者已经付出了计算资源执行你的交易,即使最终 revert 了也得付费。唯一不扣费的情况是交易因为 nonce 不匹配、Gas Limit 太低(低于 intrinsic gas)等格式问题被节点直接拒绝,根本没进入执行阶段。交易类型 0/1/2 实际影响是什么?Type 0(Legacy):最老格式,只有 gasPrice,兼容所有链但费用效率差Type 1(EIP-2930):加了 accessList,提前声明要访问的合约地址和存储槽,命中声明的存储访问 Gas 打折。适合批量合约调用,单次转账没用Type 2(EIP-1559):当前主流,maxFeePerGas + maxPriorityFeePerGas,费用可预测。MetaMask 默认用 Type 2实际开发中大部分场景用 Type 2 就够了。Type 1 主要在批量调用合约且需要精细 Gas 优化时使用,普通转账感知不到差异。写段代码// EIP-1559 交易签名与发送(ethers.js v6)const tx = await wallet.sendTransaction({ to: recipient, value: parseEther("0.1"), maxFeePerGas: parseUnits("50", "gwei"), maxPriorityFeePerGas: parseUnits("2", "gwei"),});const receipt = await tx.wait(2); // 等待 2 个确认console.log(`Gas used: ${receipt.gasUsed.toString()}`);
服务端阅读 05月28日 04:16

以太坊智能合约怎么开发?从写代码到上链部署的完整流程

以太坊智能合约是部署在区块链上的自执行程序——条件满足就自动运行,没有人能中途拦住。开发流程分七步:选语言 → 搭环境 → 写合约 → 编译 → 测试 → 部署 → 验证,每一步都踩过坑才知道为什么这么排。选语言:Solidity 是绝对主流,语法像 JavaScript,生态最完善。Vyper 适合对安全性要求极高的场景(Python 风格,刻意去掉继承和重载),Yul 是底层优化用的,日常开发基本不会直接写。搭环境:三选一——Hardhat(JS/TS 开发者首选,插件生态好)、Foundry(Solidity 原生测试,编译速度快 10 倍以上,新项目越来越多用这个)、Remix(浏览器里写合约,不需要装任何东西,适合快速验证想法)。本地测试网络用 Hardhat Network 或 Anvil(Foundry 配套),别再装 Ganache 了,已经停止维护。写合约:pragma 声明版本号,contract 定义合约体,状态变量存链上数据,function 写逻辑。权限控制用 onlyOwner 修饰符(OpenZeppelin 的 Ownable),别自己手写。事件(event)用来记日志,前端监听事件做响应。pragma solidity ^0.8.20;import "@openzeppelin/contracts/access/Ownable.sol";contract Vault is Ownable { mapping(address => uint256) public balances; event Deposited(address indexed user, uint256 amount); event Withdrawn(address indexed user, uint256 amount); function deposit() external payable { balances[msg.sender] += msg.value; emit Deposited(msg.sender, msg.value); } function withdraw(uint256 amount) external onlyOwner { require(balances[msg.sender] >= amount, "Insufficient balance"); balances[msg.sender] -= msg.value; payable(msg.sender).transfer(amount); emit Withdrawn(msg.sender, amount); }}编译:solc 编译器把 .sol 文件编译成 EVM 字节码 + ABI。ABI 是合约的接口描述,前端和测试脚本靠它知道合约有哪些函数可以调用。Hardhat/Foundry 一条命令搞定编译。测试:写单元测试覆盖正常流程和边界情况——零值转账、溢出、权限越权调用。在本地网络跑完再上测试网(Sepolia),测试网用的 ETH 是免费的,从水龙头领。部署:本质是发一笔特殊交易——没有接收地址,data 字段放编译后的字节码。矿工打包后合约获得一个地址,之后就能调用了。Hardhat 用 Ignition 部署,Foundry 用 forge create,Remix 点按钮即可。主网部署要花真 ETH 做 Gas 费,合约越大越贵,所以务必在测试网充分验证后再上主网。验证:在 Etherscan 上验证源码,用户和审计方可以直接查看合约逻辑,否则只有字节码,没人敢用。追问Solidity 和 Vyper 有什么区别?| 维度 | Solidity | Vyper ||------|----------|-------|| 语法风格 | 类 JavaScript | 类 Python || 继承 | 支持 | 不支持 || 函数重载 | 支持 | 不支持 || 内联汇编 | 支持 | 不支持 || 适用场景 | 通用,生态最全 | 安全优先,逻辑简单可审计 |Vyper 去掉继承和重载是有意的——这两个特性是历史上多数合约漏洞的根源。主网部署前最容易忽略什么?Gas 估算。合约构造函数里如果有循环或复杂逻辑,实际 Gas 可能远超预估,交易直接 revert。本地测试 Gas 不准,要在测试网上用真实数据量跑一遍。另一个坑是构造函数参数写错——部署上去了改不了,只能重新部署再花一份 Gas。合约部署后能改 bug 吗?原生不能。合约代码上链后不可变。业界做法是代理模式(Proxy Pattern):用户调用的 Proxy 合约把调用转发给 Logic 合约,升级时只换 Logic 合约地址。OpenZeppelin 的 UUPS 和 Transparent Proxy 是两种主流实现,UUPS 更省 Gas 但逻辑合约要自己写升级函数。怎么判断合约安不安全?三个层级:静态分析工具(Slither、Mythril)扫已知漏洞模式;手动代码审计看业务逻辑;正式上线前找专业审计公司(Certik、Trail of Bits)。DeFi 项目不上审计基本没人敢用。常见漏洞类型:重入攻击、整数溢出(0.8.0 以下版本)、权限配置错误、闪电贷攻击。Foundry 和 Hardhat 到底选哪个?新项目推荐 Foundry:编译速度快,Solidity 原生测试不用切语言写 JS,forge fuzz test 发现边界问题很有效。Hardhat 优势在 JS 生态集成——如果团队前端是 TypeScript 全栈,部署脚本和前端代码共享类型会更方便。两个框架都能完成所有工作,不是谁替代谁的问题。
服务端阅读 05月28日 04:15

以太坊 Layer 2 扩容方案有哪些?Rollups 与状态通道原理对比

以太坊 Layer 2 是在主网(L1)之上构建的扩容层,核心思路是把大量交易的执行和计算挪到链下,只把最终结果提交回 L1,从而继承 L1 的安全性,同时把吞吐量提升 100 倍以上、Gas 费砍掉 90%-99%。主流 L2 方案按安全性从高到低排列:Rollups(继承 L1 安全)> Plasma(部分继承)> 状态通道(特定场景安全)> 侧链(独立安全)。Rollups 是目前绝对主流。它把上百笔交易在链下执行,压缩后打包提交到 L1。关键区分在验证方式:Optimistic Rollup(Arbitrum、Optimism):先假设交易都合法,有人质疑才跑欺诈证明。问题是提现要等约 7 天挑战期。2024 年 Dencun 升级(EIP-4844)引入 blob 空间后,OR 的数据发布成本进一步大幅下降。ZK-Rollup(zkSync、StarkNet、Polygon zkEVM):用零知识证明数学保证交易合法性,L1 验证证明后直接确认,提现分钟级。代价是生成证明的计算量大,且早期不完全兼容 EVM——现在 zkEVM 方案已经基本解决这个问题。状态通道适合高频双向支付场景(如 Raiden Network):参与方在链下签名交换状态,只在开/关通道时上链。优点是即时确认和极低成本,缺点是参与者必须在线、只能处理简单状态、资金被锁定,实际应用面窄。侧链(如 Polygon PoS)有独立共识,不继承 L1 安全性,严格说不算 L2。但生态成熟、成本低,很多开发者把它当 L2 用。Plasma 是早期方案,用 Merkle 树把子链状态根提交到 L1,但数据可用性问题导致退出机制极其复杂,基本已被 Rollup 取代。追问Optimistic Rollup 和 ZK-Rollup 怎么选?| 维度 | Optimistic Rollup | ZK-Rollup ||------|-------------------|-----------|| 提现速度 | ~7天(挑战期) | 分钟级 || EVM 兼容 | 完全兼容 | 基本兼容(zkEVM) || 证明成本 | 低(只在争议时生成) | 高(每批都生成) || 适用场景 | 通用 DApp | 支付/交易/高确定性需求 |实际选型看业务:DeFi 协议需要快速结算选 ZK,社交/游戏等对提现速度不敏感选 OR 更省成本。EIP-4844 对 L2 有什么影响?Dencun 升级引入的 blob 空间给 Rollup 专用的廉价数据通道,L2 的数据发布成本降了一个数量级。这就是为什么 2024 年后 L2 的 Gas 费能降到几美分。但 blob 空间有限,高峰期费用会回升——长期方案是完整的数据可用性采样(DAS)。状态通道为什么没成为主流?三个硬伤:1)参与者必须时刻在线监控,否则对方可能提交旧状态;2)只能处理固定参与方之间的状态,无法支持任意用户交互的 DApp;3)资金锁定成本高。支付通道在特定场景(如高频微支付)还有价值,但通用性远不如 Rollup。L2 的中心化风险在哪?大多数 L2 的排序器(sequencer)是中心化运行的,排序器可以审查交易或重新排序套利(MEV)。欺诈证明/有效性证明保证的是状态正确性,不保证抗审查。去中心化排序器是 2025-2026 各 L2 的重点方向,但真正落地的还很少。写段代码// 在 Arbitrum 上部署合约(与 L1 几乎一样)const contract = await ethers.getContractFactory("MyToken");const deployed = await contract.deploy("Test", "TST");// 区别:Gas 费约为 L1 的 1/10,确认时间约 10 分钟(软确认秒级)