面试题手册

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

服务端阅读 06月5日 21:48

Next.js App Router 国际化实战:路由、翻译、SEO 和语言切换

多语言网站不是翻译几个字符串就完事——路由怎么设计、翻译文件怎么组织、SEO 的 hreflang 怎么配、切换语言时状态怎么保持,每个环节都有坑。Next.js App Router 提供了灵活的路由机制,但国际化方案需要自己搭。这篇给出一个完整的实战方案。路由结构设计App Router 推荐用动态路由段 [lang] 承载语言前缀:app/ [lang]/ layout.tsx # 语言布局(加载翻译、设置 lang 属性) page.tsx # 首页 about/ page.tsx # 关于页 layout.tsx # 根布局URL 效果:/zh/about、/en/about、/ja/aboutlayout.tsx 加载翻译并设置语言// app/[lang]/layout.tsximport { notFound } from 'next/navigation'import { dictionaries } from '@/lib/dictionaries'export const supportedLocales = ['zh', 'en', 'ja'] as constexport type Locale = typeof supportedLocales[number]export function generateStaticParams() { return supportedLocales.map((lang) => ({ lang }))}export default async function LangLayout({ children, params: { lang },}: { children: React.ReactNode params: { lang: string }}) { if (!supportedLocales.includes(lang as Locale)) { notFound() } return ( <html lang={lang}> <body>{children}</body> </html> )}generateStaticParams 让 Next.js 在构建时为每种语言生成静态页面。notFound() 处理非法语言前缀。翻译文件组织按语言分文件dictionaries/ zh.json en.json ja.json// dictionaries/zh.json{ "nav": { "home": "首页", "about": "关于", "contact": "联系我们" }, "home": { "title": "欢迎使用我们的产品", "description": "一站式解决方案" }}加载翻译的工具函数// lib/dictionaries.tsconst dictionaries = { zh: () => import('@/dictionaries/zh.json').then((m) => m.default), en: () => import('@/dictionaries/en.json').then((m) => m.default), ja: () => import('@/dictionaries/ja.json').then((m) => m.default),}export type Dictionary = Awaited<ReturnType<typeof dictionaries.zh>>export async function getDictionary(lang: string): Promise<Dictionary> { if (!(lang in dictionaries)) { return dictionaries.zh() // 回退到默认语言 } return dictionaries[lang]()}用动态 import() 按需加载——用户访问 /en/about 时只加载 en.json,不会把所有语言的翻译都打到一个包里。在 Server Component 中使用// app/[lang]/page.tsximport { getDictionary } from '@/lib/dictionaries'export default async function Home({ params: { lang } }: { params: { lang: string } }) { const t = await getDictionary(lang) return ( <main> <h1>{t.home.title}</h1> <p>{t.home.description}</p> </main> )}Server Component 里直接用 await,不需要状态管理。翻译在服务端完成,客户端拿到的就是渲染好的 HTML。语言切换组件// components/lang-switcher.tsx'use client'import { usePathname, useRouter } from 'next/navigation'import { supportedLocales, type Locale } from '@/app/[lang]/layout'const localeNames: Record<Locale, string> = { zh: '中文', en: 'English', ja: '日本語',}export function LangSwitcher({ currentLang }: { currentLang: Locale }) { const pathname = usePathname() const router = useRouter() function switchLang(newLang: Locale) { // 替换 URL 中的语言段 const segments = pathname.split('/') segments[1] = newLang router.push(segments.join('/')) } return ( <select value={currentLang} onChange={(e) => switchLang(e.target.value as Locale)}> {supportedLocales.map((locale) => ( <option key={locale} value={locale}>{localeNames[locale]}</option> ))} </select> )}切换语言就是替换 URL 的第一段——/zh/about 变 /en/about。Next.js 会重新加载对应语言的页面。SEO:hreflang 标签搜索引擎需要知道不同语言版本的对应关系:// app/[lang]/layout.tsx 中添加 metadataexport async function generateMetadata({ params: { lang } }: { params: { lang: string } }) { const t = await getDictionary(lang) return { title: t.home.title, alternates: { canonical: `https://example.com/${lang}`, languages: { 'zh': 'https://example.com/zh', 'en': 'https://example.com/en', 'ja': 'https://example.com/ja', 'x-default': 'https://example.com/en', // 默认语言 }, }, }}x-default 告诉搜索引擎:无法匹配用户语言时,显示这个版本。通常选英语或主要目标语言。中间件:自动重定向到用户首选语言// middleware.tsimport { NextRequest, NextResponse } from 'next/server'import { supportedLocales } from '@/app/[lang]/layout'function getLocale(request: NextRequest): string { const acceptLanguage = request.headers.get('accept-language') if (!acceptLanguage) return 'zh' // 解析 Accept-Language 头,匹配支持的语言 const preferred = acceptLanguage .split(',') .map((lang) => lang.split(';')[0].trim().substring(0, 2).toLowerCase()) return preferred.find((lang) => supportedLocales.includes(lang as any)) || 'zh'}export function middleware(request: NextRequest) { const { pathname } = request.nextUrl // 已有语言前缀,不处理 if (supportedLocales.some((locale) => pathname.startsWith(`/${locale}`))) { return } // 根目录访问,重定向到首选语言 const locale = getLocale(request) request.nextUrl.pathname = `/${locale}${pathname}` return NextResponse.redirect(request.nextUrl)}export const config = { matcher: ['/((?!api|_next|favicon.ico).*)'],}用户首次访问 example.com/about 时,中间件检测到 URL 没有语言前缀,根据浏览器 Accept-Language 重定向到 /en/about 或 /zh/about。Client Component 中使用翻译Client Component 不能直接 await 翻译,需要通过 props 传入或用 Context:// providers/dictionary-provider.tsx'use client'import { createContext, useContext } from 'react'import type { Dictionary } from '@/lib/dictionaries'const DictionaryContext = createContext<Dictionary | null>(null)export function DictionaryProvider({ dictionary, children,}: { dictionary: Dictionary children: React.ReactNode}) { return ( <DictionaryContext.Provider value={dictionary}> {children} </DictionaryContext.Provider> )}export function useDictionary() { const dictionary = useContext(DictionaryContext) if (!dictionary) throw new Error('useDictionary must be used within DictionaryProvider') return dictionary}在 layout 里提供:// app/[lang]/layout.tsximport { DictionaryProvider } from '@/providers/dictionary-provider'export default async function LangLayout({ children, params: { lang } }) { const dictionary = await getDictionary(lang) return ( <DictionaryProvider dictionary={dictionary}> {children} </DictionaryProvider> )}Client Component 里直接 useDictionary() 取翻译,不需要每次 prop drilling。翻译中的插值和复数纯 JSON 翻译文件不支持插值,需要一个小工具函数:// lib/i18n.tsexport function interpolate(template: string, params: Record<string, string | number>): string { return template.replace(/\{(\w+)\}/g, (_, key) => String(params[key] ?? `{${key}}`))}// 复数处理(简单版)export function pluralize(count: number, singular: string, plural: string): string { return count === 1 ? singular : plural}{ "cart": { "items": "购物车中有 {count} 件商品", "item_single": "1 件商品", "item_plural": "{count} 件商品" }}const text = interpolate(t.cart.items, { count: 3 }) // "购物车中有 3 件商品"复杂的复数规则(阿拉伯语、俄语等)建议用 intl-messageformat 库。常见问题翻译键找不到怎么办开发时加一个 fallback 机制:export function t(dictionary: Dictionary, path: string): string { const keys = path.split('.') let result: any = dictionary for (const key of keys) { result = result?.[key] } return result ?? path // 找不到翻译时返回键名,而不是报错}翻译文件太大怎么办按页面拆分翻译文件,按需加载:dictionaries/ zh/ common.json home.json about.json en/ common.json home.json about.jsonSEO 的 hreflang 和 canonical 同时存在冲突吗不冲突。canonical 指向当前语言版本的规范 URL,hreflang 指向其他语言版本。两者配合告诉搜索引擎:这些 URL 是同一个内容的不同语言版本。完整方案清单| 检查项 | 配置 ||--------|------|| 路由结构 | app/[lang]/ 动态路由 || 翻译加载 | 动态 import() 按需加载 || 语言切换 | 替换 URL 语言段 || 自动重定向 | middleware 检测 Accept-Language || SEO | generateMetadata 配置 hreflang + canonical || Client Component | DictionaryProvider + useDictionary || 非法语言 | notFound() 处理 || 翻译插值 | interpolate 工具函数 |
服务端阅读 06月5日 21:43

TypeORM 查询缓存实战:Redis 配置、主动失效和策略选择

数据库查询是后端应用最常见的性能瓶颈。TypeORM 内置了查询缓存,支持内存缓存和 Redis 缓存两种存储后端,能在不改动业务代码的情况下大幅降低数据库负载。这篇讲清楚怎么配、怎么用、以及缓存策略的选择。两种缓存存储:数据库表 vs Redis默认方案:数据库表缓存不配置任何东西,TypeORM 默认用数据库的一张表存缓存:const dataSource = new DataSource({ type: 'mysql', host: 'localhost', username: 'root', password: 'password', database: 'myapp', cache: true, // 开启缓存,默认 1000ms 过期})TypeORM 会自动创建 query-result-cache 表,把查询 SQL 和结果序列化后存进去。下次同样的查询直接从这张表取,不执行 SQL。问题:缓存本身也存数据库里——等于用数据库查数据库,只是从业务表换到了缓存表。单实例够用,分布式部署时每个实例有自己的缓存表,互相看不到。推荐方案:Redis 缓存const dataSource = new DataSource({ type: 'mysql', cache: { type: 'redis', options: { host: 'localhost', port: 6379, password: 'redis-password', db: 0, }, duration: 30000, // 默认缓存 30 秒 },})Redis 的优势:快:内存读取,微秒级延迟共享:多个应用实例访问同一个 Redis,缓存一致可控:Redis 的内存管理、过期策略、持久化都很成熟Redis 集群场景用 ioredis:cache: { type: 'ioredis/cluster', options: { startupNodes: [ { host: '10.0.0.1', port: 7000 }, { host: '10.0.0.2', port: 7000 }, { host: '10.0.0.3', port: 7000 }, ], },}查询级缓存:精确控制哪些查询缓存全局开缓存后,不是所有查询都会缓存——需要显式指定。Repository 方式// 缓存 30 秒const users = await userRepository.find({ cache: 30000,})// 给缓存一个 ID,方便后续清除const users = await userRepository.find({ cache: { id: 'users_list', milliseconds: 30000, },})// findAndCount 也支持const [users, count] = await userRepository.findAndCount({ cache: { id: 'users_paginated', milliseconds: 30000, },})QueryBuilder 方式const posts = await dataSource .createQueryBuilder(Post, 'post') .where('post.isPublished = :published', { published: true }) .cache('published_posts', 60000) // 缓存 ID + 过期时间 .getMany()缓存 ID 的作用缓存 ID 是手动控制缓存的关键——通过 ID 可以精确清除某类查询的缓存:// 清除指定 ID 的缓存await dataSource.queryResultCache.remove(['users_list'])// 数据变更后清除相关缓存async createUser(dto: CreateUserDto) { const user = await this.userRepo.save(dto) // 用户列表缓存失效 await this.dataSource.queryResultCache.remove(['users_list', 'users_paginated']) return user}原则:所有需要缓存的查询都应该指定 ID,否则你无法在数据变更时精确失效缓存——只能等过期。缓存策略选择按数据变化频率决定缓存时长| 数据特征 | 缓存时长 | 例子 ||---------|---------|------|| 几乎不变 | 5-30 分钟 | 省份列表、配置项 || 偶尔变化 | 30-60 秒 | 文章列表、商品分类 || 频繁变化 | 5-15 秒 | 实时排行榜、库存 || 实时性要求高 | 不缓存 | 支付状态、账户余额 |主动失效 vs 被动过期被动过期:设一个 duration,到期自动清除。简单,但数据变更后到过期前这段时间,用户看到的可能是旧数据主动失效:数据变更时手动 remove() 缓存 ID。更精确,但代码更复杂生产环境推荐两者结合:设一个较长的 duration 做兜底,数据变更时主动失效。这样即使忘了失效,缓存最多存在 duration 时间也不会永远不过期。// 查询时设较长缓存const users = await this.userRepo.find({ cache: { id: 'users_list', milliseconds: 300000 }, // 5 分钟兜底})// 变更时主动失效async updateUser(id: number, dto: UpdateUserDto) { await this.userRepo.update(id, dto) await this.dataSource.queryResultCache.remove(['users_list'])}缓存与事务TypeORM 的查询缓存在事务内不会自动失效。这可能导致一个问题:// 事务外查询 → 走缓存const user = await userRepo.findOne({ where: { id: 1 }, cache: 30000 })// 事务内更新await dataSource.transaction(async (manager) => { await manager.update(User, 1, { name: 'new name' }) // 此时缓存里还是旧数据!})// 事务提交后再查 → 可能还是缓存旧数据解决方案:事务提交后手动清除缓存:await dataSource.transaction(async (manager) => { await manager.update(User, 1, { name: 'new name' })})await dataSource.queryResultCache.remove(['user_1'])缓存清理清除指定缓存// 按 ID 清除await dataSource.queryResultCache.remove(['users_list', 'posts_list'])// 清除所有缓存await dataSource.queryResultCache.clear()命令行清除npx typeorm cache:clear自动清理Redis 的 TTL 机制会自动清理过期缓存,不需要手动管理。数据库表缓存 TypeORM 也会定期清理过期记录。ignoreErrors:缓存降级缓存不可用时不应该让业务请求也失败:cache: { type: 'redis', options: { host: 'localhost', port: 6379 }, ignoreErrors: true, // Redis 挂了不报错,直接查数据库}ignoreErrors: true 是生产环境必须加的——Redis 重启或网络抖动时,查询会降级到直接访问数据库,而不是直接 500。完整配置示例// data-source.tsimport { DataSource } from 'typeorm'export const dataSource = new DataSource({ type: 'mysql', host: process.env.DB_HOST, port: 3306, username: process.env.DB_USER, password: process.env.DB_PASS, database: 'myapp', cache: { type: 'redis', options: { host: process.env.REDIS_HOST || 'localhost', port: parseInt(process.env.REDIS_PORT || '6379'), password: process.env.REDIS_PASSWORD, db: 0, }, duration: 30000, // 默认 30 秒 ignoreErrors: true, // 缓存故障降级 tableName: 'query_result_cache', // 数据库表缓存的表名(如果用数据库缓存) },})缓存策略速查| 场景 | 策略 ||------|------|| 配置数据、字典表 | 长缓存(5 分钟+)+ 变更时手动失效 || 列表查询 | 中缓存(30-60 秒)+ 分页参数加入缓存 ID || 详情页 | 短缓存(15-30 秒)+ 更新时失效 || 实时数据 | 不缓存,或 5 秒极短缓存 || 多实例部署 | 必须用 Redis,数据库表缓存不共享 || 缓存故障容忍 | ignoreErrors: true || 事务内更新 | 事务提交后手动清除缓存 |
服务端阅读 06月5日 21:42

npm Scripts 进阶:生命周期钩子、参数传递和跨平台写法

npm scripts 是 Node.js 项目里最朴素的自动化工具——在 package.json 里写一行命令,npm run xxx 就能执行。但它能做的远不止 npm run dev,生命周期钩子和参数传递这两个特性,很多人不知道。基础用法{ "scripts": { "dev": "nodemon index.js", "build": "webpack --mode production", "start": "node dist/index.js", "test": "jest --coverage", "lint": "eslint src/ --ext .ts" }}npm run dev # 运行 dev 脚本npm run build # 运行 build 脚本npm test # test 是特殊脚本,不需要 runnpm start # start 也是特殊脚本,不需要 runtest、start、restart、stop 是 npm 的特殊脚本名——可以直接 npm xxx 执行,不用加 run。其他自定义脚本都要 npm run xxx。生命周期钩子:自动执行的脚本npm 为每个脚本提供了 pre 和 post 钩子——脚本执行前后自动运行同名前缀的脚本:{ "scripts": { "prebuild": "rimraf dist", "build": "tsc", "postbuild": "echo Build completed" }}执行 npm run build 时,实际执行顺序是:prebuild → build → postbuild。实用的钩子组合{ "scripts": { "pretest": "npm run lint", "test": "jest", "prebuild": "npm test", "build": "webpack --mode production", "postbuild": "npm run size" }}跑 npm run build 的完整流程:lint → test → build → size check。任何一步失败,后续步骤不会执行。注意:npm v7+ 取消了 install 的 pre/post 钩子npm v7 起,preinstall、postinstall 等钩子不再自动执行(安全原因)。如果你的脚本需要依赖安装后执行,用 prepare:{ "scripts": { "prepare": "husky install" }}prepare 在以下时机自动执行:npm install 之后npm publish 之前git clone 后执行 npm install 时内置生命周期脚本npm 定义了几个特殊脚本,在特定时机自动触发:| 脚本名 | 触发时机 | 典型用途 ||--------|----------|----------|| prepare | install 后 / publish 前 | 初始化 husky、编译 || prepublishOnly | publish 前(仅 publish) | 编译、跑测试 || prepack | npm pack 前 | 编译 || postinstall | install 后 | 原生模块编译 || version | npm version 改版本号后 | 自动 commit changelog |区分 prepublishOnly 和 prepareprepublishOnly:只在 npm publish 时执行,npm install 不执行prepare:npm publish 和 npm install 都会执行库项目用 prepublishOnly 做发布前检查(跑测试、确保编译),用 prepare 做初始化工作。传递参数# 错误写法——参数传给了 npm,不是脚本npm run test --coverage# 正确写法——用 -- 分隔npm run test -- --coveragenpm run test -- --watchAll=false-- 后面的参数会原样追加到脚本命令后面。所以 npm run test -- --coverage 等于执行 jest --coverage。脚本里也可以用 --:{ "scripts": { "test": "jest", "test:watch": "npm run test -- --watch", "test:ci": "npm run test -- --ci --coverage" }}这样不用重复写基础命令,只追加不同参数。跨平台兼容在 scripts 里写 shell 命令要注意跨平台——Windows 没有 rm -rf,也没有 && 的可靠支持。用跨平台工具替代| Unix 命令 | 跨平台替代 | 安装 ||-----------|-----------|------|| rm -rf | rimraf | npm i -D rimraf || mkdir -p | mkdirp | npm i -D mkdirp || cp -r | cpy-cli | npm i -D cpy-cli || && | npm-run-all | npm i -D npm-run-all |{ "scripts": { "clean": "rimraf dist coverage", "build": "rimraf dist && tsc", "build:safe": "npm-run-all clean build" }}npm-run-all 比 && 更可靠——它在所有平台上都能工作,还支持并行执行:{ "scripts": { "lint:js": "eslint src/", "lint:css": "stylelint src/", "lint": "npm-run-all --parallel lint:*" }}--parallel 让 lint:js 和 lint:css 同时跑,速度翻倍。环境变量npm scripts 里可以直接使用环境变量:{ "scripts": { "start": "NODE_ENV=production node dist/index.js", "dev": "NODE_ENV=development nodemon src/index.js" }}但 NODE_ENV=xxx 在 Windows 上不工作。跨平台方案用 cross-env:npm install -D cross-env{ "scripts": { "start": "cross-env NODE_ENV=production node dist/index.js" }}组合脚本的模式实际项目里的 scripts 通常这样组织:{ "scripts": { "dev": "nodemon src/index.ts", "build": "tsc", "start": "node dist/index.js", "lint": "eslint src/ --ext .ts", "test": "jest", "test:watch": "npm test -- --watch", "test:ci": "npm test -- --ci --coverage", "clean": "rimraf dist coverage", "prebuild": "npm run clean", "prepublishOnly": "npm-run-all lint test build", "release": "npm version patch && npm publish" }}release 脚本组合了版本号更新和发布——npm version patch 自动改版本号并创建 git tag,npm publish 推到 registry。常见问题脚本里的命令找不到npm scripts 执行时会把 node_modules/.bin 加到 PATH 里——所以可以直接用 jest、eslint、webpack,不需要写 ./node_modules/.bin/jest。但如果你用 bash -c "jest" 或在某些 CI 环境里,可能找不到。解决:用 npx 前缀。脚本太长不好维护拆成独立文件:{ "scripts": { "build": "bash scripts/build.sh", "deploy": "bash scripts/deploy.sh" }}scripts/ 目录下放脚本文件,package.json 里只做调度。
服务端阅读 06月5日 21:40

npm 依赖类型全解析:dependencies、devDependencies 和 peerDependencies 怎么选

package.json 里有 dependencies、devDependencies、peerDependencies、optionalDependencies——都叫依赖,到底什么区别?该往哪个里装?装错了会怎样?这篇一次讲清楚。dependencies vs devDependencies:唯一的本质区别生产环境装不装——就这么简单。| | dependencies | devDependencies ||---|---|---|| npm install | 安装 | 安装 || npm install --production | 安装 | 不安装 || npm ci --production | 安装 | 不安装 || NODE_ENV=production npm install | 安装 | 不安装 |dependencies:应用运行时必需的包(express、axios、lodash)devDependencies:只在开发和构建时需要的包(jest、eslint、typescript、webpack)怎么判断放哪里问自己一个问题:这个包如果不在,应用还能跑吗?能跑 → devDependencies(测试框架、代码检查、构建工具)不能跑 → dependencies(Web 框架、数据库驱动、日期库)一个容易搞混的例子TypeScript 放哪?应用项目:devDependencies——运行时不需要 TypeScript,只需要编译产物库项目(npm 包):devDependencies——用户装你的包不需要 TypeScript@types/xxx 呢?也是 devDependencies——类型声明只在编译时用。peerDependencies:我需要你,但我不装你peerDependencies 是给库/插件用的,告诉宿主项目"你需要安装这个依赖,我自己不装"。// react-component-lib 的 package.json{ "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" }}为什么不直接放 dependencies?因为 React 只能有一个实例。如果组件库自己装了一份 React,应用也装了一份,运行时会有两个 React 副本——hooks 会炸。npm v7 以前 vs 现在npm v6:peerDependencies 不满足只会警告,照样安装npm v7+:peerDependencies 不满足会报错,安装失败这导致很多老项目升级 npm 后突然装不上依赖了。解决方案:npm install --legacy-peer-deps # 回退到 v6 的行为常见需要 peerDependencies 的场景UI 组件库依赖 React/Vue/AngularBabel 插件依赖 @babel/coreESLint 插件依赖 eslintWebpack loader 依赖 webpack原则:你的包作为插件扩展另一个包时,被扩展的包放在 peerDependencies。optionalDependencies:装不上也没关系{ "optionalDependencies": { "fsevents": "^2.3.0" }}安装失败不会中断整个 npm install——只是这个包不可用,调用时需要自己做容错:let fsevents;try { fsevents = require('fsevents');} catch { // 回退到其他方案}典型场景:fsevents 只在 macOS 上可用,Linux/Windows 上装不了但也不影响功能——用其他文件监听方案兜底。注意:不要滥用。大部分依赖是必须的,装不上就应该报错而不是静默跳过。bundledDependencies:打包进你的发布包{ "bundledDependencies": ["my-helper-lib"]}正常情况下 npm install 你的包时,依赖会从 registry 下载。但 bundledDependencies 里的包会被直接打包到你的发布文件中,安装时不需要从 registry 下载。用途很少——主要是某些包不在公共 registry 上,又不想让用户单独配置私有源。版本号规则:^ vs ~ vs 精确版本{ "dependencies": { "express": "^4.18.0", "lodash": "~4.17.0", "react": "18.2.0" }}| 写法 | 允许的版本范围 | 例子 ||------|--------------|------|| ^4.18.0 | 兼容的次版本更新 | 4.18.0 ~ 4.x.x(不会升到 5.0) || ~4.17.0 | 兼容的修订版本更新 | 4.17.0 ~ 4.17.x(不会升到 4.18) || 4.18.0 | 精确版本 | 只能用 4.18.0 |^ 是默认行为(npm install 自动加),意味着次版本和修订版本的更新都会被接受。这通常没问题,但如果某个次版本更新引入了 bug,你的项目可能在别人那能跑在你这跑不了——这就是为什么需要 package-lock.json 锁定精确版本。实际项目中的依赖配置建议应用项目(Web 应用、后端服务)dependencies:运行时必需的包devDependencies:构建工具、测试、lint不需要 peerDependencies 和 optionalDependencies库项目(npm 包、组件库)dependencies:库运行时必需且不会被宿主重复安装的包devDependencies:构建工具、测试、文档peerDependencies:宿主项目应该提供的包(React、Webpack 等)optionalDependencies:平台特定的可选增强依赖类型选择流程这个包运行时需要吗?├── 不需要 → devDependencies├── 需要 → 宿主项目可能已经安装了吗?│ ├── 是 → peerDependencies│ └── 否 → 装不上也行吗?│ ├── 是 → optionalDependencies│ └── 否 → dependencies
服务端阅读 06月5日 21:39

npm 包发布全流程:从零发布到私有 Registry 配置

写好了一个工具库想发到 npm 上?或者公司内部需要搭建私有 npm 仓库管理通用组件?这篇讲清楚从零发布 npm 包的完整流程,以及私有 registry 的配置方式。发布前的准备1. 注册 npm 账号npm adduser# 按提示输入用户名、密码、邮箱# 验证登录npm whoami2. package.json 必填字段{ "name": "@your-scope/package-name", "version": "1.0.0", "description": "一句话描述包的功能", "main": "dist/index.js", "types": "dist/index.d.ts", "files": ["dist"], "keywords": ["utility", "format", "date"], "license": "MIT", "repository": { "type": "git", "url": "https://github.com/you/package-name" }}几个容易忽略但很关键的字段:files:指定发布时包含哪些文件。不写的话 npm 会把项目根目录下几乎所有文件都打进去(包括测试文件、配置文件)。写了 ["dist"] 就只发布编译产物,安装的人不会下载到源码和测试main:CommonJS 入口,require() 时加载这个文件types:TypeScript 类型声明文件入口。没有这个字段,TypeScript 用户用你的包会没有类型提示name 里的 @your-scope/ 是作用域包——避免和别人的包名冲突,也支持发到私有 registry3. .npmignore 控制排除项src/test/.github/.eslintrctsconfig.json*.tsbuildinfo和 .gitignore 类似,但专门控制 npm 发布时排除的文件。如果同时有 .npmignore 和 files 字段,files 优先级更高。构建和发布TypeScript 项目的标准构建流程{ "scripts": { "build": "tsc", "prepublishOnly": "npm run build" }}prepublishOnly 是 npm 生命周期钩子——执行 npm publish 前自动跑 npm run build,确保发布的是编译后的代码而不是源码。发布版本# 首次发布npm publish# 作用域包默认是私有的,要公开需要加 --accessnpm publish --access public# 后续更新:先改版本号再发布npm version patch # 1.0.0 → 1.0.1(修复 bug)npm version minor # 1.0.1 → 1.1.0(新功能,向后兼容)npm version major # 1.1.0 → 2.0.0(破坏性变更)npm publishnpm version 会同时更新 package.json 的版本号并创建一个 git commit + tag——一步到位,不需要手动改版本号。不要发布的文件确保这些不会被打包发布:.env 文件(可能含密钥)node_modules/测试文件和 mock 数据IDE 配置(.vscode/、.idea/)CI 配置(.github/workflows/)用 npm pack --dry-run 可以预览将要发布的文件列表,不会真正打包:npm pack --dry-run# 输出类似:# npm notice 📦 @your-scope/utils@1.0.0# npm notice Tarball Contents# npm notice 1.2kB dist/index.js# npm notice 0.8kB dist/index.d.ts# npm notice 1.1kB package.json语义化版本(SemVer)版本号格式:主版本.次版本.修订版本(Major.Minor.Patch)Patch(修订):修复 bug,不改变 API → npm version patchMinor(次版本):新增功能,向后兼容 → npm version minorMajor(主版本):破坏性变更,不向后兼容 → npm version major原则:用户在 package.json 里写了 "^1.2.0",你发布 1.3.0 时他们自动升级,但发布 2.0.0 时不会——所以破坏性变更一定要升 Major。私有 Registry 配置企业内部不想把包发到公网,需要私有 registry。使用 Verdaccio(轻量自建方案)# 安装npm install -g verdaccio# 启动(默认 4873 端口)verdaccio# 创建配置文件 ~/.config/verdaccio/config.yaml# config.yamlstorage: ./storageplugins: ./pluginsauth: htpasswd: file: ./htpasswd max_users: 100uplinks: npmjs: url: https://registry.npmjs.org/packages: '@company/*': access: $authenticated publish: $authenticated unpublish: $authenticated '**': access: $all proxy: npmjs # 非 @company 包代理到 npm 官方源这个配置的意思是:@company/* 作用域的包只存在本地私有仓库,其他包自动代理到 npm 官方源。开发者不需要切换 registry——私有包和公共包都能装。项目级配置# 所有 @company 作用域的包走私有 registrynpm config set @company:registry http://your-registry:4873# 或在 .npmrc 文件中@company:registry=http://your-registry:4873发布到私有 registrynpm publish --registry=http://your-registry:4873或者在 package.json 中指定:{ "name": "@company/utils", "publishConfig": { "registry": "http://your-registry:4873" }}publishConfig 比命令行参数更可靠——不会因为忘了加 --registry 而误发到公网。CI 中自动发布# GitHub Actions 示例- name: Publish to npm run: npm publish env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}在 npm 网站上生成 Access Token(Settings → Access Tokens),添加到 GitHub Secrets 里。CI 环境不需要 npm login,靠 token 认证。本地配置 token:# .npmrc//registry.npmjs.org/:_authToken=${NPM_TOKEN}常见问题包名已被占用换成作用域包:@your-name/package-name。作用域包的命名空间归你所有,不会和别人冲突。发布后想撤回# 24 小时内可以撤回(npm 官方限制)npm unpublish @your-scope/package-name@1.0.0# 撤回整个包(慎用)npm unpublish @your-scope/package-name --force超过 24 小时就撤不回了。所以发布前用 npm pack --dry-run 确认内容,用 npm publish --tag beta 先发预览版。发错版本到生产用 dist-tag 管理:# 发布为 beta 版本npm publish --tag beta# 安装 beta 版本npm install @your-scope/package-name@beta# 正式版才用 latest(默认)npm publish # 默认 tag 是 latest这样 npm install 只会安装 latest 版本,beta 需要显式指定。
服务端阅读 06月5日 21:38

npm 缓存机制详解:4 个方法加速依赖安装

每次 npm install 都从网络下载包?不是的——npm 会把下载过的包缓存在本地,下次安装同一个版本时直接从缓存读取,跳过网络请求。理解缓存机制,能让 CI 构建更快、排查依赖问题更精准。缓存存在哪里npm config get cache# macOS/Linux: ~/.npm# Windows: %AppData%/npm-cache缓存目录结构:~/.npm/_cacache/├── content-v2/ # 包的原始内容(按 hash 存储)├── index-v5/ # 包的元数据索引└── tmp/ # 临时文件_cacache 是 npm 缓存的核心——它基于 content-addressable storage(内容寻址存储),每个文件按内容的 hash 命名,相同内容只存一份。缓存怎么工作的安装一个包时,npm 的流程是:查询 registry 获取包的元数据(版本号、tarball 地址)检查本地缓存中是否已有该 tarball(通过 hash 比对)缓存命中 → 直接从本地解压,跳过下载缓存未命中 → 下载 tarball,存入缓存,再解压# 强制忽略缓存,全部重新下载npm install --no-cache# 验证缓存完整性npm cache verifynpm cache verify 会检查缓存文件的完整性,删除损坏的条目并输出统计信息。如果遇到安装报错怀疑是缓存损坏,先跑一次这个命令。加速依赖安装的 4 个方法1. 锁文件是第一优先级# 有 package-lock.json 时,npm 按锁文件精确安装,不需要重新解析依赖npm cinpm ci 比 npm install 快 2-3 倍——它直接按 package-lock.json 安装,跳过依赖解析,而且会先删掉 node_modules 保证干净环境。CI 环境永远用 npm ci,不用 npm install。2. 配置 registry 镜像国内访问 npm 官方源经常超时,换成镜像源能大幅加速:# 淘宝镜像(最常用)npm config set registry https://registry.npmmirror.com# 验证npm config get registry# 临时使用npm install --registry=https://registry.npmmirror.com3. 利用 CI 缓存目录GitHub Actions / GitLab CI 都支持缓存目录。把 npm 缓存目录缓存下来,下次构建就能直接复用:# GitHub Actions 示例- name: Cache npm uses: actions/cache@v3 with: path: ~/.npm key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-npm-- run: npm ci锁文件没变时 key 完全匹配,缓存命中率 100%。锁文件变了也会用 restore-keys 匹配部分缓存——大部分包的版本没变,仍然能命中。4. 优先使用本地缓存在 monorepo 或频繁切换分支的场景,不同项目的依赖大量重叠:# 查看缓存大小npm cache ls # 旧版du -sh ~/.npm # 直接看目录大小# 不要随便清缓存!npm cache clean --force # 除非确认缓存损坏,否则别跑这个很多开发者习惯性地 npm cache clean --force,然后重新安装——这等于把缓存全部清空,下次所有包都要重新下载。除非缓存验证报错,不要清缓存。package-lock.json 和缓存的关系package-lock.json 记录了每个依赖的精确版本和 integrity hash。npm 安装时会用这个 hash 校验缓存中的文件是否完整:// package-lock.json 片段"node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v3kCN8h1WT3dbmE...=="}integrity 字段就是内容的 SHA-512 hash。如果缓存中的文件 hash 不匹配,npm 会重新下载——这保证了缓存不会返回被篡改的包。所以:package-lock.json 一定要提交到 git。没有锁文件,npm 每次都要重新解析依赖、查询 registry,安装慢且结果不确定。常见问题安装一直失败,报 EINTEGRITY缓存文件损坏了,integrity 校验不通过:npm cache verify # 先验证# 如果还不行npm cache clean --force # 清缓存重试CI 里 npm ci 比 npm install 还慢大概率是缓存没配。检查 CI 配置是否缓存了 ~/.npm 目录。另外确认没有用 --no-cache 参数。磁盘空间被缓存占了太多# 查看缓存大小du -sh ~/.npm# 清理npm cache verify # 先验证(会自动清理损坏条目)npm cache clean --force # 强制清空长期不清理的缓存可能占几个 GB。npm cache verify 比 clean 更温和——只清理损坏和过期的条目,保留有用的缓存。缓存策略速查| 场景 | 建议 ||------|------|| 日常开发 | 不用管,缓存自动工作 || CI 构建 | npm ci + 缓存 ~/.npm 目录 || 国内网络慢 | 换淘宝镜像源 || 安装报 EINTEGRITY | npm cache verify → 不行再 clean --force || 切换分支后安装慢 | 正常,不同分支依赖可能不同,缓存命中率低 || monorepo 多项目 | 依赖重叠多,缓存命中率高,不要频繁清缓存 |
服务端阅读 06月5日 20:04

NestJS 拦截器和异常过滤器:响应转换、日志、缓存和统一错误处理

拦截器和异常过滤器是 NestJS 请求处理管道中的两个关键环节。拦截器在请求成功时介入,做响应转换、日志记录、缓存等;异常过滤器在请求出错时介入,统一错误格式。两者配合,就能控制 API 返回给客户端的所有内容。先搞清执行顺序NestJS 处理一个请求的完整流程:请求 → Middleware → Guard → Interceptor(前) → Controller → Interceptor(后) → 响应 ↓ (异常) Exception Filter → 错误响应拦截器包住了 Controller——请求进来时先执行拦截器的"前"逻辑,Controller 处理完后执行"后"逻辑。如果 Controller 抛异常,跳过"后"逻辑,直接进异常过滤器。拦截器:请求前后的横切逻辑响应格式统一后端 API 最常见的需求:所有响应都包成统一格式 { code, data, message }:// interceptors/transform.interceptor.tsimport { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';import { map } from 'rxjs/operators';export interface ApiResponse<T> { code: number; data: T; message: string;}@Injectable()export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> { intercept(context: ExecutionContext, next: CallHandler): Observable<ApiResponse<T>> { return next.handle().pipe( map(data => ({ code: 0, data, message: 'success', })), ); }}next.handle() 返回的是 Controller 的原始响应 Observable,用 map 操作符在"后"阶段转换格式。请求耗时日志@Injectable()export class LoggingInterceptor implements NestInterceptor { private readonly logger = new Logger('HTTP'); intercept(context: ExecutionContext, next: CallHandler): Observable<any> { const request = context.switchToHttp().getRequest(); const { method, url } = request; const now = Date.now(); this.logger.log(`${method} ${url} - Started`); return next.handle().pipe( tap(() => { this.logger.log(`${method} ${url} - ${Date.now() - now}ms`); }), ); }}tap 操作符只观察不修改——适合做日志、指标采集等副作用操作。缓存拦截器@Injectable()export class CacheInterceptor implements NestInterceptor { private cache = new Map<string, { data: any; expire: number }>(); intercept(context: ExecutionContext, next: CallHandler): Observable<any> { const request = context.switchToHttp().getRequest(); const key = `${request.method}:${request.url}`; const cached = this.cache.get(key); if (cached && cached.expire > Date.now()) { return of(cached.data); // 命中缓存,直接返回,不进 Controller } return next.handle().pipe( tap(data => { this.cache.set(key, { data, expire: Date.now() + 60000 }); // 缓存 1 分钟 }), ); }}这个拦截器的特别之处:of(cached.data) 直接返回数据,next.handle() 根本不会执行——Controller 被完全跳过了。这就是拦截器的强大之处:它可以选择不调用 Controller。超时控制import { timeout, TimeoutError } from 'rxjs/operators';import { throwError } from 'rxjs';@Injectable()export class TimeoutInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable<any> { return next.handle().pipe( timeout(5000), // 5 秒超时 catchError(err => { if (err instanceof TimeoutError) { return throwError(() => new RequestTimeoutException('请求超时')); } return throwError(() => err); }), ); }}绑定拦截器// 方法级别@UseInterceptors(LoggingInterceptor)@Get('users')getUsers() {}// Controller 级别@UseInterceptors(TransformInterceptor)@Controller('users')export class UserController {}// 全局app.useGlobalInterceptors(new TransformInterceptor());全局绑定时用 useGlobalInterceptors 简单直接,但如果拦截器需要依赖注入,必须用 Module 方式注册:// 在任意 Module 中providers: [ { provide: APP_INTERCEPTOR, useClass: TransformInterceptor, },]异常过滤器:统一错误处理基本异常过滤器NestJS 内置了默认的异常处理,但返回格式不够友好。自定义过滤器可以统一错误格式:// filters/http-exception.filter.tsimport { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';import { Response } from 'express';@Catch(HttpException)export class HttpExceptionFilter implements ExceptionFilter { catch(exception: HttpException, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse<Response>(); const status = exception.getStatus(); const exceptionResponse = exception.getResponse(); response.status(status).json({ code: status, message: typeof exceptionResponse === 'string' ? exceptionResponse : (exceptionResponse as any).message || exception.message, data: null, timestamp: new Date().toISOString(), }); }}捕获所有异常@Catch() 不带参数捕获一切异常——包括非 HttpException 的错误(如数据库异常、第三方库异常):@Catch()export class AllExceptionsFilter implements ExceptionFilter { private readonly logger = new Logger('ExceptionFilter'); catch(exception: unknown, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse(); const request = ctx.getRequest(); const status = exception instanceof HttpException ? exception.getStatus() : 500; // 非业务异常记录完整堆栈 if (!(exception instanceof HttpException)) { this.logger.error(exception instanceof Error ? exception.stack : String(exception)); } response.status(status).json({ code: status, message: status === 500 ? '服务器内部错误' : (exception as any).message, data: null, path: request.url, timestamp: new Date().toISOString(), }); }}500 错误不要把内部细节暴露给客户端——统一返回"服务器内部错误",详细信息只写日志。业务异常的优雅处理定义业务异常码,让过滤器按类型处理:// 自定义业务异常export class BusinessException extends HttpException { private readonly errorCode: string; constructor(errorCode: string, message: string, statusCode = 400) { super(message, statusCode); this.errorCode = errorCode; } getErrorCode() { return this.errorCode; }}// 在 Service 中使用async transfer(fromId: number, toId: number, amount: number) { if (amount <= 0) { throw new BusinessException('INVALID_AMOUNT', '转账金额必须大于0'); } const from = await this.findOne(fromId); if (from.balance < amount) { throw new BusinessException('INSUFFICIENT_BALANCE', '余额不足'); } // ... 执行转账}// 过滤器中区分处理@Catch(BusinessException)export class BusinessExceptionFilter implements ExceptionFilter { catch(exception: BusinessException, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse(); response.status(exception.getStatus()).json({ code: exception.getErrorCode(), // 'INSUFFICIENT_BALANCE' message: exception.message, // '余额不足' data: null, }); }}绑定过滤器// 方法级别@UseFilters(HttpExceptionFilter)@Get('users')getUsers() {}// 全局app.useGlobalFilters(new AllExceptionsFilter());// 全局(支持依赖注入)providers: [ { provide: APP_FILTER, useClass: AllExceptionsFilter, },]拦截器 vs 异常过滤器的边界| 场景 | 用哪个 | 原因 ||------|--------|------|| 响应格式统一 | 拦截器 | 处理正常响应 || 日志记录 | 拦截器 | 记录请求和响应 || 缓存 | 拦截器 | 控制是否调用 Controller || 超时控制 | 拦截器 | 用 RxJS timeout 操作符 || 错误格式统一 | 异常过滤器 | 处理异常响应 || 错误日志 | 异常过滤器 | 记录异常堆栈 || 错误码映射 | 异常过滤器 | 把技术异常翻译成业务错误码 |一个常见错误:在拦截器里用 catchError 处理异常。虽然技术上可行,但违反了职责分离——拦截器负责"正常路径",过滤器负责"异常路径"。混在一起会让代码难以维护。全局注册的最佳实践在 main.ts 中统一注册全局拦截器和过滤器:// main.tsconst app = await NestFactory.create(AppModule);app.useGlobalInterceptors( new TransformInterceptor(), new LoggingInterceptor(),);app.useGlobalFilters( new AllExceptionsFilter(), new BusinessExceptionFilter(),);await app.listen(3000);需要注意注册顺序:过滤器按注册顺序反向执行(后注册的先执行),拦截器按注册顺序正向执行。所以 AllExceptionsFilter 放最后——它兜底处理所有未捕获的异常。
服务端阅读 06月5日 20:02

NestJS 集成 GraphQL:代码优先模式实战指南

NestJS 对 GraphQL 的支持是所有 Node.js 框架里做得最顺的——代码优先模式下,你只写 TypeScript 类,Schema 自动生成,不用手写 .graphql 文件也不用维护两套类型定义。这篇讲清楚两种集成方式的选择、核心配置、以及 Resolver/ObjectType/InputType 的写法。代码优先 vs Schema 优先NestJS 支持两种 GraphQL 集成方式,90% 的项目应该选代码优先:| | 代码优先(推荐) | Schema 优先 ||---|---|---|| 工作流 | 写 TypeScript → 自动生成 Schema | 写 Schema → 自动生成 TypeScript 类型 || 类型安全 | 天然安全,TypeScript 是单一事实来源 | 需要生成类型定义,可能和手写代码不同步 || 维护成本 | 低——只维护一份代码 | 高——Schema 和代码要同步 || 适合场景 | 纯 TypeScript 项目 | 多语言共享 Schema、已有 Schema 的项目 |安装和基本配置npm install @nestjs/graphql @nestjs/apollo graphql @apollo/server代码优先配置// app.module.tsimport { GraphQLModule } from '@nestjs/graphql';import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';import { join } from 'path';@Module({ imports: [ GraphQLModule.forRoot<ApolloDriverConfig>({ driver: ApolloDriver, autoSchemaFile: join(process.cwd(), 'src/schema.gql'), sortSchema: true, // 生成的 Schema 按字母排序,减少 git diff 噪音 playground: true, // 开发环境启用 GraphQL Playground }), ],})export class AppModule {}autoSchemaFile 是代码优先模式的关键——NestJS 会根据你的 TypeScript 装饰器自动生成 GraphQL Schema 文件。sortSchema: true 让生成的 Schema 内容顺序稳定,避免每次重新生成都产生无意义的 diff。Schema 优先配置GraphQLModule.forRoot<ApolloDriverConfig>({ driver: ApolloDriver, typePaths: ['./**/*.graphql'], // 指向你的 .graphql 文件 definitions: { path: join(process.cwd(), 'src/graphql.ts'), // 生成的 TypeScript 类型 outputAs: 'class', },})定义 ObjectType(对应数据库模型)ObjectType 是 GraphQL 的返回类型,相当于 REST 里的响应 DTO:// models/user.model.tsimport { ObjectType, Field, Int } from '@nestjs/graphql';@ObjectType()export class User { @Field(() => Int) id: number; @Field() email: string; @Field({ nullable: true }) nickname?: string; @Field(() => Int) age: number; @Field() createdAt: Date;}注意:TypeScript 的 number 对应 GraphQL 的 Int 和 Float 两种类型,必须显式指定 @Field(() => Int) 或 @Field(() => Float),否则 NestJS 不知道该映射到哪个。nullable: true 标记可空字段。默认所有字段都是非空的——这比 REST 的 JSON 响应更严格,客户端能明确知道哪些字段一定有值。定义 InputType(对应请求参数)InputType 是 GraphQL 的输入类型,相当于 REST 的请求 DTO:// dto/create-user.input.tsimport { InputType, Field } from '@nestjs/graphql';@InputType()export class CreateUserInput { @Field() email: string; @Field({ nullable: true }) nickname?: string; @Field(() => Int) age: number;}ObjectType 和 InputType 的区别:ObjectType 用于返回数据,InputType 用于接收参数。GraphQL 规范要求输入和输出类型必须分开定义——虽然字段可能一样,但不能复用同一个类。部分更新用 Patch@InputType()export class UpdateUserInput { @Field(() => Int) id: number; @Field({ nullable: true }) email?: string; @Field({ nullable: true }) nickname?: string; @Field(() => Int, { nullable: true }) age?: number;}所有字段都设为 nullable: true,客户端只需传要更新的字段。在 Service 层判断哪些字段有值就更新哪些。写 Resolver(对应 Controller)Resolver 是 GraphQL 的"控制器",处理查询和变更请求:// resolvers/user.resolver.tsimport { Resolver, Query, Mutation, Args } from '@nestjs/graphql';@Resolver(() => User)export class UserResolver { constructor(private readonly userService: UserService) {} @Query(() => [User]) async users(): Promise<User[]> { return this.userService.findAll(); } @Query(() => User, { nullable: true }) async user(@Args('id', { type: () => Int }) id: number): Promise<User | null> { return this.userService.findOne(id); } @Mutation(() => User) async createUser( @Args('input') input: CreateUserInput, ): Promise<User> { return this.userService.create(input); } @Mutation(() => User) async updateUser( @Args('input') input: UpdateUserInput, ): Promise<User> { return this.userService.update(input); } @Mutation(() => Boolean) async deleteUser(@Args('id', { type: () => Int }) id: number): Promise<boolean> { return this.userService.remove(id); }}@Query 用于读取数据(对应 REST GET)@Mutation 用于修改数据(对应 REST POST/PUT/DELETE)@Args 提取参数,和 Controller 里的 @Param/@Body 类似{ nullable: true } 表示查询可能返回 null(比如按 ID 查不到)关联查询(关系型数据)用户和文章是一对多关系,GraphQL 的优势是客户端可以按需获取关联数据:// models/post.model.ts@ObjectType()export class Post { @Field(() => Int) id: number; @Field() title: string; @Field(() => Int) authorId: number; @Field(() => User) author: User; // 关联字段}// resolvers/post.resolver.ts@Resolver(() => Post)export class PostResolver { @ResolveField('author', () => User) async getAuthor(@Parent() post: Post): Promise<User> { return this.userService.findOne(post.authorId); }}@ResolveField 是懒加载——只有客户端请求了 author 字段时才会执行。如果客户端只查 title,就不会触发额外的数据库查询。N+1 问题关联查询的陷阱是 N+1:查 10 篇文章的作者,会执行 1 次查文章 + 10 次查作者 = 11 次查询。解决方案是用 DataLoader 批量加载:import * as DataLoader from 'dataloader';// 在 Module 中注册 DataLoaderproviders: [ { provide: 'USER_LOADER', useFactory: (userService: UserService) => { return new DataLoader(async (ids: number[]) => { const users = await userService.findByIds(ids); return ids.map(id => users.find(u => u.id === id)); }); }, inject: [UserService], },]// 在 Resolver 中使用@ResolveField('author', () => User)async getAuthor( @Parent() post: Post, @Inject('USER_LOADER') userLoader: DataLoader<number, User>,) { return userLoader.load(post.authorId);}DataLoader 会把同一批次的所有 load() 调用合并成一次 findByIds() 查询,11 次变 2 次。认证和授权在 Resolver 层面守卫@UseGuards(GqlAuthGuard)@Mutation(() => Post)async createPost( @Args('input') input: CreatePostInput, @CurrentUser() user: User,): Promise<Post> { return this.postService.create(user.id, input);}GqlAuthGuard 需要从 GraphQL 上下文中提取用户信息,和 REST 的 AuthGuard 略有不同:@Injectable()export class GqlAuthGuard extends AuthGuard('jwt') { getRequest(context: ExecutionContext) { const ctx = GqlExecutionContext.create(context); return ctx.getContext().req; // GraphQL 请求对象 }}分页查询GraphQL 常用的游标分页(Relay 风格):@ObjectType()class PaginatedUsers { @Field(() => [User]) items: User[]; @Field(() => Int) totalCount: number; @Field() hasNextPage: boolean; @Field(() => String, { nullable: true }) cursor?: string;}@Query(() => PaginatedUsers)async users( @Args('limit', { type: () => Int, defaultValue: 10 }) limit: number, @Args('cursor', { type: () => String, { nullable: true } }) cursor?: string,): Promise<PaginatedUsers> { return this.userService.findPaginated(limit, cursor);}客户端可以灵活选择每页数量和起始位置,不像 REST 分页那么死板。调试技巧在 Playground 里测试启动应用后访问 http://localhost:3000/graphql,可以直接写查询测试。比用 Postman 方便得多——左侧写查询,右侧看结果,还有自动补全。查看生成的 Schema代码优先模式下,autoSchemaFile 指向的文件就是生成的 Schema。如果查询报类型错误,先看看生成的 Schema 是否符合预期——可能是装饰器写错了。常见报错"Cannot determine a GraphQL type for User":忘了加 @ObjectType() 装饰器"Query root type must be provided":没有任何 @Query 装饰器,Schema 是空的Circular dependency:两个 ObjectType 互相引用,用 () => import('./other.model').then(m => m.OtherType) 延迟引用解决快速配置清单| 检查项 | 配置 ||--------|------|| 选择模式 | 代码优先(autoSchemaFile) || Driver | ApolloDriver || 数字类型 | 显式指定 () => Int 或 () => Float || 可空字段 | { nullable: true } || 关联查询 | @ResolveField + DataLoader 防 N+1 || 认证 | GqlAuthGuard 从 GqlExecutionContext 取 req || Schema 排序 | sortSchema: true 减少 diff 噪音 |
服务端阅读 06月5日 19:59

Electron 架构详解:Chromium + Node.js 如何协同工作

Electron 让你用 JavaScript、HTML、CSS 写桌面应用——VS Code、Discord、Slack 都是它做的。它不是什么黑科技,本质就是把 Chromium(浏览器内核)和 Node.js(服务端运行时)打包在一起,让 Web 页面能调用系统级 API。三层架构Electron 的架构可以拆成三层来看:┌──────────────────────────────────────────────┐│ 你的应用代码 │├──────────────────┬───────────────────────────┤│ Chromium │ Node.js ││ (渲染 UI) │ (系统 API、文件、网络) │├──────────────────┴───────────────────────────┤│ 操作系统 (Windows / macOS / Linux) │└──────────────────────────────────────────────┘Chromium:负责把 HTML/CSS/JS 渲染成用户看到的界面。Electron 内嵌了完整的 Chromium,所以你的应用就是一个独立的浏览器窗口Node.js:负责和操作系统打交道——读写文件、创建子进程、访问网络、操作剪贴板等。Electron 内嵌了完整的 Node.js 运行时你的代码:运行在上述两个环境之上,通过 Electron 提供的 API 把两者串联起来这就是 Electron 的核心卖点:一个代码库同时拥有浏览器的渲染能力和 Node.js 的系统能力,编译一次就能跑在 Windows、macOS、Linux 上。多进程模型Electron 继承了 Chromium 的多进程架构。这不是随意设计——Chromium 之所以用多进程,是因为单个网页崩溃不应该拖垮整个浏览器。Electron 同理:一个窗口崩了,其他窗口和主进程还能继续工作。主进程 (Node.js)├── 渲染进程 1 (Chromium) → 窗口 A├── 渲染进程 2 (Chromium) → 窗口 B├── 渲染进程 3 (Chromium) → 窗口 C└── GPU 进程 (共享)主进程应用入口,package.json 的 main 字段指向的文件运行在 Node.js 环境中,可以使用 fs、path、child_process 等所有 Node.js 模块负责创建 BrowserWindow、管理应用生命周期(app.whenReady()、app.quit())处理原生对话框、系统菜单、托盘图标渲染进程每个 BrowserWindow 对应一个独立的渲染进程运行在 Chromium 环境中,就是一个完整的网页运行环境默认不能直接使用 Node.js API(安全原因),需要通过 preload 脚本桥接GPU 进程Chromium 自动管理,负责图形渲染(硬件加速)一般不需要手动干预,但如果 GPU 加速导致问题,可以通过 app.disableHardwareAcceleration() 关闭两个运行时怎么协作Chromium 和 Node.js 各自有一套事件循环。Electron 在底层把它们整合到了一起:Node.js 的 libuv 事件循环 和 Chromium 的消息循环 被合并,两个环境的 I/O 和定时器可以互不阻塞地运行你可以在同一个 JavaScript 上下文中既用 setTimeout(浏览器 API)又用 fs.readFile(Node.js API)但在渲染进程中,出于安全考虑,这种融合是被限制的——contextIsolation 默认开启,Node.js API 被隔离。你只能通过 preload 脚本和 IPC 通信来间接使用系统能力。IPC 通信机制主进程和渲染进程之间通过 IPC(进程间通信)传递消息:// 主进程ipcMain.handle('read-config', async () => { const data = await fs.promises.readFile('config.json', 'utf-8') return JSON.parse(data)})// 渲染进程(通过 preload 暴露的 API)const config = await window.electronAPI.readConfig()通信是异步的,数据经过结构化克隆序列化——所以不能传函数、DOM 节点或循环引用的对象。Electron 的优势和代价优势跨平台:一套代码跑三个系统,开发成本远低于分别写原生应用前端友好:会写网页就能写桌面应用,团队不需要学 Swift / C# / Qt生态丰富:npm 上所有包都能用,Web 社区的 UI 库(React、Vue)直接拿来用自动更新:内置 autoUpdater,不需要自己实现增量更新代价包体积大:内嵌 Chromium + Node.js,最小打包也要 100MB+内存占用高:每个渲染进程就是一个 Chromium 标签页,开多窗口内存涨得很快性能不如原生:JS 的执行效率和内存管理比 C++/Rust 差,CPU 密集型任务会卡顿启动慢:冷启动需要加载 Chromium 和 Node.js,比原生应用慢这些代价不是"优化一下就好了"——它们是 Electron 架构的固有特征。如果你的应用对包体积、内存或启动速度有硬性要求(比如轻量工具类应用),Electron 可能不是最佳选择。Tauri(Rust + WebView)和 Wails(Go + WebView)是更轻量的替代方案。什么时候该用 Electron| 适合 | 不适合 ||------|--------|| 内容型应用(编辑器、笔记、聊天) | 轻量工具(计算器、截图) || 需要丰富 UI 的桌面端 | 对启动速度极敏感 || 团队是前端为主 | 需要极低内存占用 || 需要快速跨平台上线 | 大量原生系统集成 |Electron 最大的价值是降低桌面应用的开发门槛。如果团队已经会写 Web 应用,Electron 能让你们在几周内交付一个可用的桌面端——这个效率优势是原生开发无法比拟的。
服务端阅读 06月5日 19:58

Electron 主进程 vs 渲染进程:职责、API 和通信机制全对比

Electron 应用跑起来后,打开任务管理器你会看到好几个进程——一个主进程加上若干渲染进程。它们各司其职,也各有局限,搞不清谁负责什么,写出的代码就会出各种诡异 bug。一句话区分主进程:管窗口、管系统、管生命周期,是整个应用的"大管家"渲染进程:管页面、管 UI、管用户交互,是每个窗口的"画师"主进程只有一个,渲染进程可以有多个(每个 BrowserWindow 一个)。运行环境对比| | 主进程 | 渲染进程 ||---|---|---|| 运行环境 | Node.js | Chromium(浏览器) || 可用 API | 全部 Node.js API + Electron 主进程 API | Web API + 部分通过 preload 暴露的 Node.js API || 进程数量 | 1 个 | 每个窗口 1 个,相互隔离 || 职责 | 窗口管理、系统交互、原生菜单/对话框 | 渲染 UI、处理用户交互 || 能直接操作 DOM 吗 | 不能 | 能 || 能直接读文件吗 | 能 | 不能(需要通过 IPC 请求主进程) || 默认能访问 Node.js 吗 | 能 | 不能(contextIsolation 默认开启) |这个表基本说明了一切:主进程有系统权限但不会画界面,渲染进程会画界面但没系统权限。两者必须协作。主进程做什么主进程是 Electron 应用的入口,package.json 的 main 字段指向的那个文件就是主进程代码:// main.js - 主进程const { app, BrowserWindow, ipcMain, dialog } = require('electron')let mainWindowapp.whenReady().then(() => { mainWindow = new BrowserWindow({ webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false // 安全:不让渲染进程直接用 Node.js } }) mainWindow.loadFile('index.html')})// 处理系统级操作ipcMain.handle('open-file-dialog', async () => { const result = await dialog.showOpenDialog({ properties: ['openFile'] }) return result.filePaths[0]})主进程的核心职责:创建和管理窗口:new BrowserWindow() 只能在主进程调用控制应用生命周期:app.whenReady()、app.quit() 等事件系统级交互:文件对话框、系统菜单、托盘图标、通知IPC 中转:处理渲染进程发来的请求,或协调多个窗口间的通信渲染进程做什么每个 BrowserWindow 加载的网页就运行在一个独立的渲染进程里:// renderer.js - 渲染进程(通过 preload 暴露的 API 与主进程通信)const filePath = await window.electronAPI.openFileDialog()document.getElementById('file-path').textContent = filePath渲染进程的核心职责:渲染 UI:HTML + CSS + JavaScript,和写网页一模一样处理用户交互:按钮点击、表单提交、滚动等数据展示:从主进程获取数据后渲染到界面渲染进程之间是相互隔离的——窗口 A 的渲染进程不能直接访问窗口 B 的 DOM 或变量,必须通过主进程中转。preload 脚本:两个世界之间的桥梁渲染进程默认无法访问 Node.js API(这是安全设计),但很多场景又确实需要调用系统功能。preload 脚本就是解决这个问题的:// preload.js - 运行在渲染进程的上下文中,但有 Node.js 访问权限const { contextBridge, ipcRenderer } = require('electron')contextBridge.exposeInMainWorld('electronAPI', { openFileDialog: () => ipcRenderer.invoke('open-file-dialog'), readFile: (path) => ipcRenderer.invoke('read-file', path), onSave: (callback) => { ipcRenderer.on('trigger-save', (event, data) => callback(data)) }})preload 的执行时机很特殊:它在渲染进程的网页加载之前运行,既可以用 Node.js API,又可以访问渲染进程的 window 对象。contextBridge.exposeInMainWorld 把方法安全地挂到 window 上,让网页代码能调用。常见的坑在主进程里操作 DOM不行。主进程没有 DOM 环境。如果你想修改 UI,必须在渲染进程里操作,或者通过 IPC 通知渲染进程去改。在渲染进程里直接 require('fs')默认不行。nodeIntegration 默认关闭,contextIsolation 默认开启——这是 Electron 的安全最佳实践。需要读文件时,通过 preload 暴露方法,让主进程来读。多窗口间的全局状态渲染进程之间不共享内存。如果你需要一个全局状态(比如用户登录信息),有三种方案:主进程做状态中心:所有窗口通过 IPC 读写主进程中的变量LocalStorage/IndexDB:同一 origin 下共享,但只适合简单场景共享内存/文件:性能要求高时使用渲染进程崩溃不影响主进程这是一个设计优势——某个网页崩溃了,主进程可以检测到并重新创建窗口,整个应用不会挂掉:mainWindow.webContents.on('crashed', () => { mainWindow.destroy() mainWindow = new BrowserWindow({ ... }) mainWindow.loadFile('index.html')})进程架构图┌─────────────────────────────────┐│ 主进程 (1个) ││ app / BrowserWindow / ipcMain ││ 文件系统 / 系统对话框 / 原生菜单 │└──────────┬──────────────────────┘ │ IPC 通信 ┌──────┴──────┬──────────────┐ ▼ ▼ ▼┌────────┐ ┌────────┐ ┌────────┐│渲染进程1│ │渲染进程2│ │渲染进程3││窗口 A │ │窗口 B │ │窗口 C ││DOM/JS │ │DOM/JS │ │DOM/JS │└────────┘ └────────┘ └────────┘理解了这个架构,就明白了 Electron 开发的核心原则:系统操作在主进程,UI 操作在渲染进程,两者通过 IPC 通信。
服务端阅读 06月5日 19:54

Electron IPC 通信实战:send、invoke 和 contextBridge 用法对比

Electron 的主进程和渲染进程跑在不同的环境里——一个有 Node.js 权限,一个只有浏览器能力。它们要协作,就得靠 IPC(进程间通信)。理解 IPC 的几种通信模式和它们的取舍,是写好 Electron 应用的基本功。IPC 通信的三种模式1. send/on —— 单向通知,不等回复渲染进程向主进程"喊一声",不等回应:// 渲染进程const { ipcRenderer } = require('electron')ipcRenderer.send('log:message', { level: 'info', text: '用户点击了导出按钮' })// 主进程const { ipcMain } = require('electron')ipcMain.on('log:message', (event, data) => { logger.log(data.level, data.text)})主进程也可以主动向渲染进程推消息:// 主进程mainWindow.webContents.send('update:progress', { percent: 75 })// 渲染进程ipcRenderer.on('update:progress', (event, data) => { progressBar.style.width = data.percent + '%'})适用场景:日志记录、状态通知、进度更新——发送方不需要知道处理结果。2. invoke/handle —— 请求-响应,现代写法渲染进程发起请求,主进程处理后返回结果:// 渲染进程const filePath = await ipcRenderer.invoke('dialog:openFile')console.log('选择的文件:', filePath)// 主进程const { dialog } = require('electron')ipcMain.handle('dialog:openFile', async () => { const result = await dialog.showOpenDialog({ properties: ['openFile'] }) return result.filePaths[0] || null})invoke 返回 Promise,handle 里可以写异步逻辑。这比老式的 send + event.reply 干净太多,是 Electron 官方推荐的双向通信方式。为什么比 send/on 好:代码更简洁——不用手动配对 send 和 on天然支持 async/await错误能通过 catch 捕获,不会被静默吞掉适用场景:需要主进程执行操作并返回结果——打开文件对话框、读写本地文件、调用系统 API。3. send + event.reply —— 老式双向通信Electron 7 之前的双向通信方式:// 渲染进程ipcRenderer.send('get-file-content', { path: '/data/config.json' })ipcRenderer.on('file-content-response', (event, data) => { console.log(data)})// 主进程ipcMain.on('get-file-content', (event, data) => { const content = fs.readFileSync(data.path, 'utf-8') event.reply('file-content-response', content)})现在不建议用了——invoke/handle 是更好的替代。event.reply 的主要问题是:渲染进程需要额外监听一个回调通道,代码更散乱,也不支持 async/await。安全:contextBridge 是必须的从 Electron 12 开始,contextIsolation 默认开启。这意味着渲染进程的 JavaScript 不能直接 require('electron')——必须通过 preload 脚本和 contextBridge 安全地暴露 API。// preload.jsconst { contextBridge, ipcRenderer } = require('electron')contextBridge.exposeInMainWorld('electronAPI', { // 只暴露方法,不暴露 ipcRenderer 对象本身 openFile: () => ipcRenderer.invoke('dialog:openFile'), readFile: (path) => ipcRenderer.invoke('file:read', path), onProgress: (callback) => { const handler = (event, data) => callback(data) ipcRenderer.on('update:progress', handler) return () => ipcRenderer.removeListener('update:progress', handler) }})// 渲染进程(普通网页环境)const filePath = await window.electronAPI.openFile()const content = await window.electronAPI.readFile(filePath)// 监听进度const cleanup = window.electronAPI.onProgress((data) => { updateUI(data.percent)})// 组件卸载时清理cleanup()安全原则永远不要把整个 ipcRenderer 暴露给渲染进程——只暴露具体方法通道名用命名空间:dialog:openFile 比 openFile 更不容易冲突主进程必须验证输入——渲染进程的代码可能被 XSS 篡改,不能信任传来的数据窗口间通信多个 BrowserWindow 之间不能直接通信,需要主进程中转:// 主进程作为消息中转ipcMain.on('message:forward', (event, data) => { // 转发给所有其他窗口 BrowserWindow.getAllWindows().forEach(win => { if (win.webContents !== event.sender) { win.webContents.send('message:broadcast', data) } })})或者用 MessagePort 建立两个渲染进程之间的直接通道:// 主进程:给两个窗口搭桥const { MessageChannelMain } = require('electron')ipcMain.on('setup-channel', (event) => { const [port1, port2] = new MessageChannelMain() // port1 给请求方 event.sender.postMessage('channel-established', null, [port1]) // port2 给目标窗口 targetWindow.webContents.postMessage('channel-established', null, [port2])})// 渲染进程 AipcRenderer.on('channel-established', (event) => { const port = event.ports[0] port.postMessage({ type: 'hello', from: 'window-a' }) port.onmessage = (e) => console.log('Received:', e.data)})MessagePort 的优势是两个窗口直接通信,不经过主进程中转,延迟更低。适合窗口间需要频繁交互的场景(比如编辑器的主窗口和面板窗口)。IPC 性能优化批量发送,别逐条发// 差:1000 次 IPC 调用items.forEach(item => ipcRenderer.send('process', item))// 好:1 次 IPC 调用ipcRenderer.invoke('process-batch', items)每次 IPC 调用都有序列化和跨进程传输的开销。批量处理能减少调用次数,显著提升性能。大数据走文件系统或共享内存IPC 传输大量数据(比如图片、大文件内容)会很慢,因为要经过结构化克隆序列化。正确的做法是:// 渲染进程请求文件路径,自己读取const filePath = await ipcRenderer.invoke('file:getPath')// 用 Node.js(preload 暴露的方法)读取文件const content = await window.electronAPI.readFile(filePath)// 或者用共享内存const sharedBuffer = await ipcRenderer.invoke('buffer:getShared')耗时任务用 Worker Threads不要在主进程里跑 CPU 密集任务——会阻塞所有窗口的 IPC 处理:// 主进程const { Worker } = require('worker_threads')ipcMain.handle('heavy-task', async (event, data) => { return new Promise((resolve, reject) => { const worker = new Worker('./heavy-worker.js', { workerData: data }) worker.on('message', resolve) worker.on('error', reject) })})TypeScript 类型安全给 IPC 通道加上类型定义,能在编译时捕获参数错误:// ipc-types.tsexport interface IpcChannels { 'dialog:openFile': { args: void; result: string | null } 'file:read': { args: { path: string }; result: string } 'log:message': { args: { level: string; text: string }; result: void }}// preload.tscontextBridge.exposeInMainWorld('electronAPI', { invoke: <K extends keyof IpcChannels>( channel: K, ...args: IpcChannels[K]['args'] extends void ? [] : [IpcChannels[K]['args']] ): Promise<IpcChannels[K]['result']> => ipcRenderer.invoke(channel, ...args)})这样渲染进程调用 window.electronAPI.invoke('file:read', { path: 123 }) 时,TypeScript 会报错——path 应该是 string 不是 number。IPC 通道设计清单| 检查项 | 说明 ||--------|------|| 通道名有命名空间 | file:read 而不是 readFile || 用 invoke/handle 不用 send/reply | 更安全、更简洁 || contextBridge 只暴露方法 | 不暴露 ipcRenderer 对象 || 主进程验证输入 | 不信任渲染进程的数据 || 大数据不走 IPC | 走文件系统或共享内存 || 耗时任务用 Worker | 不阻塞主进程 || 监听器记得清理 | 组件卸载时 removeListener |
服务端阅读 06月5日 18:34

TensorFlow Callbacks 实战:5 个必备回调 + 自定义回调写法

回调函数是 TensorFlow 训练过程中最灵活的钩子——它让你在不修改训练循环代码的情况下,介入训练的每个阶段:每个 epoch 开始前、每个 batch 结束后、训练结束时……几乎所有"想在训练过程中做点什么"的需求,都可以用回调实现。最常用的 5 个内置回调不用全记住,先把这 5 个用熟:1. EarlyStopping —— 训练自动刹车from tensorflow.keras.callbacks import EarlyStoppingearly_stop = EarlyStopping( monitor="val_loss", patience=5, restore_best_weights=True, mode="min")patience=5 表示连续 5 个 epoch 验证损失没有改善就停。restore_best_weights=True 是关键——不加它,模型停在最后一个 epoch 的权重上,可能已经过拟合了。常见错误:patience 设太小(2-3),训练还在正常波动就停了。大部分任务 5-10 是合适的起点。2. ModelCheckpoint —— 自动存档from tensorflow.keras.callbacks import ModelCheckpoint# 只保存验证集上最好的模型checkpoint = ModelCheckpoint( filepath="best_model.h5", monitor="val_loss", save_best_only=True, mode="min", verbose=1)# 只保存权重(更省磁盘)checkpoint = ModelCheckpoint( filepath="weights_{epoch:02d}.h5", save_weights_only=True, save_freq="epoch")save_best_only=True 比 save_freq="epoch" 更实用——前者只在模型刷新最优记录时保存,不会占满磁盘。训练时间长的任务务必加上这个回调,防止中途断线或 OOM 白跑。3. ReduceLROnPlateau —— 损失停滞时自动降学习率from tensorflow.keras.callbacks import ReduceLROnPlateaureduce_lr = ReduceLROnPlateau( monitor="val_loss", factor=0.1, # 学习率乘以 0.1 patience=3, # 连续 3 个 epoch 没改善就降 min_lr=1e-7, # 最低不低于这个值 verbose=1)这个回调和 EarlyStopping 配合使用效果最好:先用 ReduceLROnPlateau 降学习率尝试突破瓶颈,如果降了好几次还是没改善,EarlyStopping 再出手停止训练。4. TensorBoard —— 训练可视化from tensorflow.keras.callbacks import TensorBoardimport datetimelog_dir = "logs/" + datetime.datetime.now().strftime("%Y%m%d-%H%M%S")tensorboard = TensorBoard( log_dir=log_dir, histogram_freq=1, write_graph=True, update_freq="epoch")启动 TensorBoard:tensorboard --logdir=logs/,浏览器打开 localhost:6006。histogram_freq=1 会记录每层权重的分布变化,对调试梯度消失/爆炸特别有用——如果某层权重分布越来越窄,说明那层基本没在学。5. CSVLogger —— 训练日志留底from tensorflow.keras.callbacks import CSVLoggercsv_logger = CSVLogger("training_log.csv")最不起眼但最实用。训练跑完几小时后想回看每个 epoch 的 loss/accuracy 变化,CSV 日志比 TensorBoard 更方便做数据分析和画图。5 个回调的标准组合callbacks = [ EarlyStopping(monitor="val_loss", patience=10, restore_best_weights=True), ModelCheckpoint("best_model.h5", monitor="val_loss", save_best_only=True), ReduceLROnPlateau(monitor="val_loss", factor=0.5, patience=3, min_lr=1e-7), TensorBoard(log_dir="logs/" + datetime.datetime.now().strftime("%Y%m%d-%H%M%S")), CSVLogger("training_log.csv")]model.fit(x_train, y_train, epochs=200, validation_data=(x_val, y_val), callbacks=callbacks)这套组合覆盖了:自动刹车 + 自动存档 + 自动降学习率 + 可视化 + 日志记录。日常训练够用了。回调的执行顺序:按列表顺序依次执行。如果你的自定义回调依赖 ModelCheckpoint 的保存结果,确保 ModelCheckpoint 排在前面。其他内置回调:什么时候才需要LearningRateScheduler —— 自定义学习率曲线from tensorflow.keras.callbacks import LearningRateSchedulerdef lr_schedule(epoch, lr): if epoch < 10: return 0.001 elif epoch < 30: return 0.0005 else: return 0.0001lr_callback = LearningRateScheduler(lr_schedule, verbose=1)和 ReduceLROnPlateau 的区别:LearningRateScheduler 按预定计划降(不看指标),ReduceLROnPlateau 根据指标自适应降。大多数情况 ReduceLROnPlateau 更好用——你不需要提前猜学习率该什么时候降。BackupAndRestore —— 训练中断恢复from tensorflow.keras.callbacks import BackupAndRestorebackup = BackupAndRestore(backup_dir="backup", save_freq="epoch")长时间训练(几小时甚至几天)时加上这个,遇到 OOM 或手动中断后可以从上次保存的 epoch 继续。配合 ModelCheckpoint 使用不冲突——BackupAndRestore 只保存训练状态(优化器状态等),ModelCheckpoint 保存模型权重。LambdaCallback —— 最简自定义from tensorflow.keras.callbacks import LambdaCallback# 只想在某个时机做一件简单的事print_callback = LambdaCallback( on_epoch_end=lambda epoch, logs: print(f"Epoch {epoch}: lr={float(model.optimizer.lr):.6f}"))一行 lambda 搞定,不需要写完整的 Callback 子类。缺点是不能保存状态,复杂逻辑还是要用类。自定义回调:真实场景的写法场景 1:梯度裁剪监控训练不稳定时,想知道是不是梯度爆炸了:class GradientMonitor(tf.keras.callbacks.Callback): def on_batch_end(self, batch, logs=None): if batch % 100 != 0: return grads = self.model.optimizer.get_gradients( self.model.total_loss, self.model.trainable_weights ) grad_norms = [tf.norm(g).numpy() for g in grads if g is not None] if grad_norms: max_grad = max(grad_norms) if max_grad > 10.0: print(f" Batch {batch}: max gradient norm = {max_grad:.2f} (potential explosion)")如果频繁打印爆炸警告,说明需要加梯度裁剪:optimizer = Adam(clipnorm=1.0)。场景 2:验证集上计算自定义指标TensorFlow 内置的验证指标有限,想算 F1、AUC 或业务指标时:from sklearn.metrics import f1_scoreclass F1ScoreCallback(tf.keras.callbacks.Callback): def __init__(self, validation_data): super().__init__() self.x_val, self.y_val = validation_data def on_epoch_end(self, epoch, logs=None): y_pred = self.model.predict(self.x_val, verbose=0) y_pred_labels = (y_pred > 0.5).astype(int) f1 = f1_score(self.y_val, y_pred_labels, average="macro") print(f" val_f1: {f1:.4f}") logs["val_f1"] = f1 # 写入 logs,TensorBoard 和 CSVLogger 会自动记录把自定义指标写入 logs 字典后,TensorBoard 和 CSVLogger 会自动记录它,不需要额外代码。场景 3:动态冻结/解冻层迁移学习中常用:先只训练顶层几轮,再解冻全部层精调。class UnfreezeCallback(tf.keras.callbacks.Callback): def __init__(self, unfreeze_at_epoch=5): super().__init__() self.unfreeze_at_epoch = unfreeze_at_epoch def on_epoch_begin(self, epoch, logs=None): if epoch == self.unfreeze_at_epoch: for layer in self.model.layers: layer.trainable = True # 重新编译模型以应用更改 self.model.compile( optimizer=self.model.optimizer.__class__(learning_rate=1e-5), loss=self.model.loss, metrics=["accuracy"] ) print(f" Unfreezed all layers at epoch {epoch}, lr reduced to 1e-5")场景 4:训练达到目标精度后自动停止比 EarlyStopping 更精确的停止条件:class TargetAccuracyCallback(tf.keras.callbacks.Callback): def __init__(self, target=0.95): super().__init__() self.target = target def on_epoch_end(self, epoch, logs=None): if logs.get("val_accuracy", 0) >= self.target: print(f" Reached {self.target*100}% val accuracy, stopping training") self.model.stop_training = Trueself.model.stop_training = True 是在回调中中断训练的标准方式,所有回调都能用。自定义回调的完整生命周期Callback 基类提供了这些钩子方法,按需重写:class FullLifecycleCallback(tf.keras.callbacks.Callback): def on_train_begin(self, logs=None): """训练开始前,初始化状态""" def on_train_end(self, logs=None): """训练结束后,收尾工作""" def on_epoch_begin(self, epoch, logs=None): """每个 epoch 开始前""" def on_epoch_end(self, epoch, logs=None): """每个 epoch 结束后,最常用""" def on_batch_begin(self, batch, logs=None): """每个 batch 开始前""" def on_batch_end(self, batch, logs=None): """每个 batch 结束后,注意频率别打印太多""" def on_predict_begin(self, logs=None): """推理开始前""" def on_predict_end(self, logs=None): """推理结束后"""on_epoch_end 是用得最多的——大部分监控和决策都在 epoch 级别做。on_batch_end 谨慎使用,如果一个 epoch 有 10000 个 batch,每个 batch 都执行你的回调逻辑,开销不小。回调使用中的常见问题多个回调修改学习率会冲突吗?会。ReduceLROnPlateau 和 LearningRateScheduler 同时使用时,后者会覆盖前者的调整。只用其中一个。回调里能修改模型结构吗?不建议。回调里修改模型层(增删层、改激活函数)会导致计算图和优化器状态不一致。但修改 trainable 属性是可以的——只要随后重新编译。回调里访问训练数据的正确方式回调的 logs 字典里只有 loss 和 metrics,不包含训练数据。如果回调需要访问数据(如计算自定义指标),在 __init__ 中传入:class MyCallback(tf.keras.callbacks.Callback): def __init__(self, validation_data): super().__init__() self.x_val, self.y_val = validation_data不要通过 self.model 反向获取训练数据——模型对象里不存这些。
服务端阅读 06月5日 18:27

TensorFlow 优化器怎么选?Adam vs SGD 实战对比和选择指南

优化器决定了模型参数更新的方向和步长——选错了,再好的模型架构也训不出好结果。TensorFlow 提供了十几种优化器,但 90% 的场景你只需要在 Adam 和 SGD 之间做选择。这篇文章不罗列 API,而是讲清楚每个优化器的原理差异、什么时候用哪个、以及一些实战中容易踩的坑。先搞懂优化器在做什么优化器的核心工作就一件事:根据梯度更新参数。区别在于"怎么用梯度"——SGD:梯度指向哪,就往那走一步,步长固定Adam:记住历史梯度的方向和大小,自适应调整步长其他优化器:在这两个思路之间做各种变体理解了这个本质,选优化器就不是背表格了。SGD —— 简单但被低估from tensorflow.keras.optimizers import SGD# 基本 SGDoptimizer = SGD(learning_rate=0.01)# 带动量——实际使用时的标准配置optimizer = SGD(learning_rate=0.01, momentum=0.9)# Nesterov 动量——更激进的变体optimizer = SGD(learning_rate=0.01, momentum=0.9, nesterov=True)为什么 SGD 值得重视纯 SGD(无动量)确实慢,但加上 momentum 之后完全不同。动量的效果是:梯度方向一致时加速(积累动量),方向变化时减速(动量抵消),帮助逃出局部最优和鞍点。SGD 最大的优势是泛化性能。大量研究表明,虽然 Adam 收敛更快,但 SGD(+momentum)最终往往能达到更好的泛化结果。原因在于 SGD 的更新路径更"曲折",更容易跳出尖锐的局部最优,找到更平坦的最优解——平坦的最优解泛化性更好。什么时候用 SGD追求最终精度(打比赛、生产部署)数据量大(>100K 样本),有足够时间训练你愿意花时间调学习率学习率调参是 SGD 的主要成本SGD 需要手动设置学习率,而且不同阶段需要不同的学习率。典型做法是配合学习率衰减:from tensorflow.keras.optimizers.schedules import CosineDecaylr_schedule = CosineDecay(initial_learning_rate=0.1, decay_steps=50000)optimizer = SGD(learning_rate=lr_schedule, momentum=0.9)0.1 是 SGD 的经典初始学习率(配合 momentum),比 Adam 的 0.001 大很多——因为 SGD 没有自适应机制,需要更大的步长来补偿。Adam —— 不用动脑的默认选择from tensorflow.keras.optimizers import Adamoptimizer = Adam(learning_rate=0.001)Adam 为什么好用Adam 维护了两个移动平均:一阶矩(梯度的指数平均,即方向)和二阶矩(梯度平方的指数平均,即大小)。然后用一阶矩除以二阶矩的平方根来更新参数——效果是梯度大时步长自动缩小,梯度小时步长自动放大。这带来两个实际好处:几乎不需要调学习率:0.001 对大多数任务都工作每个参数有独立的学习率:稀疏特征也能得到合理的更新Adam 的坑权重衰减实现有 bug:标准 Adam 把 L2 正则化加到了梯度里,而不是直接惩罚权重。这导致正则化效果被自适应学习率削弱。解决方案是用 AdamW:from tensorflow.keras.optimizers import AdamWoptimizer = AdamW(learning_rate=0.001, weight_decay=0.01)AdamW 在 Transformer 类模型(BERT、ViT 等)中几乎是标配。有时泛化不如 SGD:Adam 收敛快,但可能收敛到尖锐的最优解,测试集表现反而不如 SGD。Adam vs SGD:到底选哪个这是最常见的问题,直接给结论:| 维度 | Adam | SGD + Momentum ||------|------|---------------|| 收敛速度 | 快(通常快 2-5 倍) | 慢 || 调参难度 | 低(lr=0.001 开箱即用) | 高(需调 lr + schedule) || 最终精度 | 一般 | 通常更高 || 泛化性能 | 稍差 | 更好 || 稀疏数据 | 好 | 差 || 显存占用 | 高(额外存储一阶/二阶矩) | 低 |实战建议项目初期 / 快速验证:用 Adam,快速跑出基线结果追求最佳精度:先 Adam 预训练,再切换 SGD 精调数据稀疏(NLP、推荐):Adam 或 Adagrad显存紧张:SGDAdam 预训练 + SGD 精调的混合策略这是竞赛和工业界常用的套路:# 阶段 1:Adam 快速收敛model.compile(optimizer=Adam(learning_rate=0.001), loss="...")model.fit(x_train, y_train, epochs=20)# 阶段 2:切换 SGD 精调model.compile(optimizer=SGD(learning_rate=0.001, momentum=0.9), loss="...")model.fit(x_train, y_train, epochs=30)切换时学习率通常设为 Adam 最终学习率的 1/10 到 1/100,让 SGD 在 Adam 找到的最优解附近精细搜索。其他优化器:什么时候才需要RMSprop —— RNN 的老搭档from tensorflow.keras.optimizers import RMSpropoptimizer = RMSprop(learning_rate=0.001)RMSprop 是 Adam 的前身之一,只维护二阶矩(不做一阶矩的指数平均)。在 RNN/LSTM 训练中曾有不错的效果,但现在基本被 Adam 替代了。如果你没有特别理由,不需要选 RMSprop。Adagrad —— 稀疏特征的经典选择from tensorflow.keras.optimizers import Adagradoptimizer = Adagrad(learning_rate=0.01)Adagrad 对频繁出现的特征用小学习率,对罕见特征用大学习率。适合处理极度稀疏的数据(比如广告点击率预测,特征空间几百万维但每条样本只激活几十个)。缺点是学习率只减不增,训练后期可能过早衰减到接近 0。Adadelta 是 Adagrad 的改进版,限制了累积历史的影响,但实际效果不如 Adam。Nadam —— Adam 的 Nesterov 版本from tensorflow.keras.optimizers import Nadamoptimizer = Nadam(learning_rate=0.001)Nadam 把 Nesterov 动量的思路融入 Adam——在计算梯度时先用当前动量"往前看一步"。理论上收敛更快,但实际差异很小。如果你对 Adam 的收敛速度不满意,Nadam 可以试一下,但别期望质变。Ftrl —— 大规模稀疏场景专用from tensorflow.keras.optimizers import Ftrloptimizer = Ftrl(learning_rate=0.01, l1_regularization_strength=0.01)Ftrl(Follow-the-Regularized-Leader)是 Google 为点击率预测设计的优化器,天生支持 L1/L2 正则化,适合在线学习场景。只在推荐系统/广告的工业级部署中才会用到。学习率调参实战优化器选对了,学习率没调好还是白搭。几个实用经验:学习率太大 vs 太小的信号太大:Loss 震荡不下降,或直接 NaN太小:Loss 下降极慢,几百个 epoch 还在慢慢爬刚好:Loss 在前几个 epoch 快速下降,然后稳定收敛学习率预热(Warmup)大模型训练的标准操作——前 N 步用很小的学习率,线性增加到目标值:warmup_steps = 1000total_steps = 50000def warmup_cosine_schedule(step): lr = 0.001 if step < warmup_steps: return lr * (step / warmup_steps) # 之后余弦衰减 progress = (step - warmup_steps) / (total_steps - warmup_steps) return lr * 0.5 * (1 + tf.cos(3.14159 * progress))optimizer = Adam(learning_rate=warmup_cosine_schedule)预热避免训练初期参数还很随机时,大梯度导致的不稳定更新。Transformer 类模型几乎必用。快速决策参考| 你的情况 | 推荐优化器 | 学习率 ||---------|-----------|--------|| 刚开始,不确定 | Adam | 0.001 || 追求最高精度 | SGD + Momentum | 0.1 + 余弦衰减 || 大模型微调 | AdamW | 0.001, weight_decay=0.01 || NLP/稀疏特征 | Adam | 0.001 || 推荐系统/广告 | Ftrl | 0.01 || 显存不够 | SGD + Momentum | 0.1 || 想两全其美 | Adam → SGD | Adam: 0.001, SGD: 0.001 |别在优化器上纠结太久——先选 Adam 跑出结果,再根据需要切换。大部分性能提升来自数据和模型架构,不是优化器。
服务端阅读 06月5日 18:23

TensorFlow 模型过拟合怎么破?7 种正则化技术实战对比

训练集准确率 99%,测试集只有 70%——这就是过拟合。模型把训练数据"背"下来了,遇到新数据就懵。TensorFlow 提供了一堆正则化工具,但问题不是没有工具,而是不知道什么时候用哪个、哪些能组合、哪些会冲突。先判断是不是真的过拟合别急着加正则化——先确认问题确实出在过拟合上:训练 Loss 持续下降,验证 Loss 开始上升:典型的过拟合信号训练和验证的差距持续增大:模型在训练集上越来越"专精",泛化越来越差训练集远小于模型容量:1 万条数据训练 100 万参数的模型,不过拟合才奇怪如果训练和验证都在高位下不去,那是欠拟合——加正则化只会更差。先解决欠拟合(加层、加节点、换更好的特征),再考虑正则化。L1 vs L2:权重惩罚的两种思路两种正则化都是给损失函数加惩罚项,限制权重大小,但效果有本质区别。L2 正则化(权重衰减)—— 最常用的默认选择from tensorflow.keras import regularizersmodel = tf.keras.Sequential([ layers.Dense(128, activation="relu", kernel_regularizer=regularizers.l2(0.01)), layers.Dense(10, activation="softmax")])L2 惩罚权重的平方和,效果是让所有权重都变小但不会变成 0。系数 0.01 是典型起点——太小没效果,太大欠拟合。调参时按 10 倍调:0.001 → 0.01 → 0.1。L1 正则化 —— 需要特征选择时才用model = tf.keras.Sequential([ layers.Dense(128, activation="relu", kernel_regularizer=regularizers.l1(0.01)), layers.Dense(10, activation="softmax")])L1 惩罚权重的绝对值和,能把不重要的权重压到精确的 0,起到自动特征选择的作用。但缺点也很明显:会让模型变得不稳定——微小的数据变化可能导致不同的特征被选中。怎么选| 场景 | 选择 | 原因 ||------|------|------|| 一般深度学习 | L2 | 稳定,效果好 || 特征很多,想自动筛选 | L1 | 稀疏化,自动选特征 || 不确定 | L1 + L2(Elastic Net) | 两种好处都占 |# Elastic Netkernel_regularizer=regularizers.l1_l2(l1=0.01, l2=0.01)Dropout —— 最简单粗暴也最有效Dropout 的原理一句话就能说清楚:训练时随机"关闭"一部分神经元,让模型不能依赖任何一条路径,必须学到冗余的特征表示。model = tf.keras.Sequential([ layers.Dense(256, activation="relu"), layers.Dropout(0.5), # 训练时随机丢弃 50% layers.Dense(128, activation="relu"), layers.Dropout(0.3), # 丢弃 30% layers.Dense(10, activation="softmax")])Dropout 的实战经验Dropout 率不是越高越好:0.5 是全连接层的常见选择,但超过 0.5 会让模型容量不足,反而欠拟合靠近输入的层用较低的 Dropout:前几层提取的是基础特征,丢失太多会影响后续所有层卷积层一般不用 Dropout:卷积层参数少,本身不容易过拟合。如果非要加,用 0.1-0.2 的小比例,或者用 SpatialDropout(整通道丢弃)代替推理时 Dropout 自动关闭:training=False 时 Dropout 不生效,不需要手动处理SpatialDropout2D —— 卷积网络的 Dropout 变体from tensorflow.keras.layers import SpatialDropout2Dmodel = tf.keras.Sequential([ layers.Conv2D(64, 3, activation="relu"), SpatialDropout2D(0.2), # 随机丢弃整个特征图通道 layers.Conv2D(128, 3, activation="relu"),])普通 Dropout 在卷积层效果不好——相邻像素高度相关,丢掉零散的像素意义不大。SpatialDropout2D 丢弃整张特征图,强制模型不依赖某个特定通道,效果更好。Batch Normalization —— 不只是正则化Batch Norm 的初衷是加速训练(解决内部协变量偏移),但它有一个副作用:每个 mini-batch 的均值和方差带有随机性,相当于给每层输出加了噪声,起到了类似 Dropout 的正则化效果。model = tf.keras.Sequential([ layers.Dense(128), layers.BatchNormalization(), layers.Activation("relu"), layers.Dense(10, activation="softmax")])Batch Norm 的位置有讲究把 Batch Norm 放在激活函数之前(Dense → BN → ReLU)而不是之后(Dense → ReLU → BN),这是学术界验证过的最佳实践。ReLU 会截断负值,放在 BN 之前会让 BN 看到的输入分布更完整。Batch Norm 和 Dropout 的关系这是一个常见的困惑:两者都有正则化效果,能不能一起用?全连接网络:可以一起用,但有时 BN 的正则化效果已经够强,加 Dropout 反而过度正则化。建议先只用 BN,验证集表现不够再加 Dropout卷积网络:BN 基本够了,通常不需要再加 Dropout小 batch size(:BN 的均值/方差估计不稳定,正则化效果打折。这时 Dropout 更可靠Early Stopping —— 最被低估的正则化手段说句大实话:大部分过拟合问题,Early Stopping 就能解决。from tensorflow.keras.callbacks import EarlyStoppingearly_stop = EarlyStopping( monitor="val_loss", patience=5, # 连续 5 个 epoch 没改善就停 restore_best_weights=True, # 回到最优权重 mode="min")model.fit(x_train, y_train, epochs=200, # 设大点,让 EarlyStopping 决定什么时候停 validation_data=(x_val, y_val), callbacks=[early_stop])patience 是关键参数:设太小(如 2)可能训练还没充分收敛就停了;设太大(如 20)可能已经过拟合很久才停。5-10 是大多数任务的甜区。restore_best_weights=True 很重要——不加这个,模型会在停止时保持最后一个 epoch 的权重(可能已经过拟合),而不是验证集上表现最好的那个 epoch。数据增强 —— 用更多数据打败过拟合所有正则化方法都是在有限数据上做文章,数据增强则是直接从源头解决问题:人工扩充训练数据量。# 图像数据增强data_augmentation = tf.keras.Sequential([ layers.RandomFlip("horizontal"), layers.RandomRotation(0.1), layers.RandomZoom(0.1), layers.RandomContrast(0.1),])# 作为模型的第一层model = tf.keras.Sequential([ data_augmentation, layers.Conv2D(32, 3, activation="relu"), layers.MaxPooling2D(), layers.Flatten(), layers.Dense(10, activation="softmax")])数据增强的度增强力度太弱等于没做,太强会生成不真实的图片。旋转超过 30 度、缩放超过 50% 的图像看起来已经不像原来的物体了,反而会误导模型。实际操作中,人眼看起来"还是同一张图"的增强幅度最合适。NLP 数据增强的不同思路:文本数据不能旋转翻转,常用的方法是同义词替换、随机删词、回译(翻译成英文再翻回中文)。学习率衰减 —— 间接正则化学习率本身不是正则化手段,但衰减策略对过拟合有间接影响:后期降低学习率让参数更新幅度变小,相当于在最优解附近"精细调整"而不是大幅震荡。from tensorflow.keras.optimizers.schedules import CosineDecaylr_schedule = CosineDecay( initial_learning_rate=0.001, decay_steps=10000)optimizer = tf.keras.optimizers.Adam(learning_rate=lr_schedule)余弦衰减比阶梯衰减更平滑,训练后期学习率趋近于 0,参数更新越来越小,不容易在最优解附近来回跳。标签平滑 —— 防止模型过于自信def label_smoothing_loss(smoothing=0.1): def loss(y_true, y_pred): num_classes = tf.shape(y_pred)[-1] y_true = tf.one_hot(tf.cast(y_true, tf.int32), num_classes) y_true = y_true * (1 - smoothing) + smoothing / tf.cast(num_classes, tf.float32) return tf.keras.losses.categorical_crossentropy(y_true, y_pred) return lossmodel.compile(optimizer="adam", loss=label_smoothing_loss(smoothing=0.1))标签平滑把 one-hot 标签从 [0, 1, 0] 变成 [0.033, 0.9, 0.033](3 类、smoothing=0.1 时),不让模型对任何一个类别有 100% 的信心。这个技巧在图像分类比赛中几乎是被标配的——见效快、无副作用。实战:组合使用正则化的策略不是所有正则化方法都要一股脑加上。根据经验,推荐以下组合策略:小数据集(< 10K 样本)数据增强(图像)/ 回译增强(文本)Early Stopping(patience=5-10)Dropout(0.3-0.5)L2 正则化(0.01)中等数据集(10K - 100K 样本)Early StoppingBatch Normalization轻度 Dropout(0.1-0.3)大数据集(> 100K 样本)Early StoppingBatch Normalization学习率衰减数据量越大,越不需要激进的正则化——数据本身就是最好的正则化。一个完整的过拟合诊断流程假设你发现模型过拟合了,按这个顺序排查:检查数据量 vs 模型大小:参数量远超样本量 → 减少模型层数或节点数加 Early Stopping:最简单,效果立竿见影加 Batch Norm:如果网络里还没有的话加 Dropout:0.3 起步,逐步增大直到验证集表现不再提升加 L2 正则化:0.01 起步数据增强:如果适用的话每加一种正则化,观察训练/验证曲线的变化。不要一次加好几种——你分不清哪个在起作用,出了问题也不知道该调哪个。
服务端阅读 06月5日 18:20

TensorFlow 张量操作效率指南:避开这些坑,训练速度翻倍

张量操作写起来简单,但写对和写快是两回事。很多 TensorFlow 新手习惯用 Python 循环逐个处理数据,结果训练速度慢得离谱——原因往往不是模型复杂,而是张量操作没写对。这篇文章不讲 API 速查,讲的是怎么写出让 GPU 跑满的张量代码。创建张量:选对方式省内存基础创建import tensorflow as tf# 从列表创建a = tf.constant([1, 2, 3])# 指定数据类型——省内存从创建开始b = tf.constant([1, 2, 3], dtype=tf.float16) # 比 float32 省一半内存# 常用初始化zeros = tf.zeros([256, 512]) # 全零ones = tf.ones([128, 64]) # 全一range_t = tf.range(0, 100, 2) # 步长序列随机张量——初始化权重用得最多# 正态分布初始化权重weights = tf.random.normal([784, 256], mean=0.0, stddev=0.05)# 截断正态——比普通正态更稳,避免极端值初始化weights = tf.random.truncated_normal([784, 256], stddev=0.05)# 均匀分布uniform = tf.random.uniform([100, 50], minval=-0.1, maxval=0.1)效率要点:用 tf.random.truncated_normal 而不是 tf.random.normal 初始化权重——截断版本不会产生极端值,训练初期更稳定,不容易梯度爆炸。形状操作:reshape 和 transpose 的性能差异reshape —— 视图变换,不复制数据x = tf.random.normal([32, 28, 28, 3]) # batch of images# reshape 不复制数据,只是换个视角看同一块内存flat = tf.reshape(x, [32, 28 * 28 * 3]) # → [32, 2352]# 顺序很重要:先展平再 reshape 和直接 reshape 可能结果不同wrong = tf.reshape(x, [32, -1]) # 自动推算,等价于 [32, 2352]reshape 是 O(1) 操作——它不移动数据,只改元数据。所以遇到需要改变形状的场景,放心用 reshape,不用担心性能问题。transpose —— 真正的数据重排# NHWC → NCHW(某些 GPU 算子要求 NCHW 格式更快)x = tf.random.normal([32, 28, 28, 3]) # NHWCx_nchw = tf.transpose(x, [0, 3, 1, 2]) # → [32, 3, 28, 28] NCHW和 reshape 不同,transpose 需要真正移动数据,是 O(n) 操作。在性能敏感的代码里,能用 reshape 解决的就不要用 transpose。expand_dims 和 squeeze —— 加减维度# 加维度(常用于给单个样本加 batch 维度)image = tf.random.normal([28, 28, 3])batch = tf.expand_dims(image, 0) # → [1, 28, 28, 3]# 去维度prediction = tf.random.normal([1, 10])squeezed = tf.squeeze(prediction, 0) # → [10]expand_dims 和 squeeze 都是视图操作,和 reshape 一样不复制数据。广播机制:写少量代码做大量计算广播(broadcasting)是 TensorFlow 里最容易被忽视的效率神器。它让不同形状的张量直接做运算,不需要手动扩展。# 给每个样本加上偏置——不用循环,广播自动处理features = tf.random.normal([128, 512]) # 128 个样本,512 维特征bias = tf.random.normal([512]) # 偏置向量result = features + bias # 自动广播,等价于对每行加 bias# 标量运算也是广播scaled = features * 0.5 # 每个元素乘 0.5广播的隐含代价广播方便,但需要注意内存:# 这样写没问题a = tf.ones([100, 1])b = tf.ones([1, 100])c = a + b # 结果 [100, 100],但中间不会真的把 a 和 b 扩展到 [100, 100]# 但如果你主动 tile 了,就是真复制a_tiled = tf.tile(a, [1, 100]) # 真正复制数据到 [100, 100]原则:让 TensorFlow 自动广播,不要手动 tf.tile——tile 是真复制数据,广播是虚拟扩展。索引和切片:避免 Python 循环基本切片x = tf.random.normal([1000, 100])# NumPy 风格切片——GPU 上原生执行,很快first_10 = x[:10] # 前 10 行every_5 = x[::5] # 每隔 5 行取一个last_col = x[:, -1] # 最后一列用 tf.gather 和 tf.gather_nd 做高级索引# 取指定行data = tf.random.normal([10000, 128])indices = tf.constant([0, 5, 10, 999])selected = tf.gather(data, indices) # 取第 0、5、10、999 行# 取指定位置的元素(多维索引)coords = tf.constant([[0, 1], [2, 3], [4, 0]])elements = tf.gather_nd(data[:5, :4], coords) # 取 (0,1), (2,3), (4,0)用 tf.boolean_mask 做条件筛选# 筛选大于阈值的样本scores = tf.random.uniform([1000])high_scores = tf.boolean_mask(scores, scores > 0.8)# 在原始数据上应用同样的 maskdata = tf.random.normal([1000, 128])filtered = tf.boolean_mask(data, scores > 0.8) # 只保留高分样本效率关键:用 tf.gather、tf.boolean_mask 代替 Python for 循环筛选。循环是在 CPU 上逐个执行的,Tensor 原生操作在 GPU 上并行。数学运算:向量化 vs 循环这是性能差距最大的地方。反面教材:Python 循环逐个计算# 慢!不要这样写result = []for i in range(len(data)): result.append(data[i] * 2 + 1)result = tf.stack(result)正面教材:向量化运算# 快!一次操作搞定全部result = data * 2 + 1向量化版本在 10 万条数据上可能快 100 倍以上。常用数学运算a = tf.constant([1.0, 2.0, 3.0])tf.sqrt(a) # [1.0, 1.414, 1.732]tf.square(a) # [1.0, 4.0, 9.0]tf.exp(a) # 指数tf.math.log(a) # 自然对数tf.abs(a) # 绝对值矩阵运算a = tf.random.normal([256, 512])b = tf.random.normal([512, 128])# 矩阵乘法——最常用的线性代数操作c = tf.matmul(a, b) # [256, 128]# 或用 @ 运算符c = a @ b矩阵乘法是 GPU 最擅长的操作之一,务必用 tf.matmul 而不是手动实现点积循环。规约运算x = tf.random.normal([32, 100])tf.reduce_mean(x) # 全局均值tf.reduce_mean(x, axis=0) # 每列均值 → [100]tf.reduce_mean(x, axis=1) # 每行均值 → [32]tf.reduce_sum(x, axis=1) # 每行求和tf.reduce_max(x, axis=1) # 每行最大值拼接和堆叠:选对操作a = tf.ones([32, 100])b = tf.ones([32, 100])# concat:沿已有维度拼接joined = tf.concat([a, b], axis=1) # [32, 200] 横向拼接joined = tf.concat([a, b], axis=0) # [64, 100] 纵向拼接# stack:创建新维度堆叠stacked = tf.stack([a, b], axis=0) # [2, 32, 100]区别:concat 拼在已有维度上(不增加维度数),stack 堆出新维度(多一个维度)。搞混了会导致 shape 对不上,是新手常见 bug 来源。类型转换:小心隐式转换的性能陷阱# tf.cast 做显式类型转换x_int = tf.constant([1, 2, 3], dtype=tf.int32)x_float = tf.cast(x_int, tf.float32)# 混合类型运算会触发隐式转换——慢a = tf.constant([1, 2, 3], dtype=tf.float32)b = tf.constant([4, 5, 6], dtype=tf.float64)c = a + b # a 被隐式转为 float64,多一次转换操作原则:保持运算中所有张量类型一致。混合 float32 和 float64 会让 TensorFlow 额外做类型提升,在 GPU 上这种隐式转换尤其慢。数据搬运:CPU ↔ GPU 之间的隐性开销# 检查张量所在设备with tf.device("/GPU:0"): gpu_tensor = tf.random.normal([1000, 1000])# 拷回 CPU——只有需要用 NumPy 处理时才做cpu_tensor = gpu_tensor.numpy() # GPU → CPU 拷贝,有开销# 避免:频繁在 GPU 和 CPU 之间搬运小张量# 每次调用 .numpy() 或 tf.constant(numpy_array) 都是一次数据拷贝效率建议:数据预处理尽量用 tf.data 流水线完成,保持在 GPU 上只在最终输出时才 .numpy() 转回 CPU避免在训练循环里反复 .numpy() 再 tf.constant()实战:把循环改成向量化操作假设你要对一批向量做归一化:data = tf.random.normal([10000, 128])# 反面:Python 循环,极慢normalized = []for i in range(data.shape[0]): row = data[i] norm = tf.sqrt(tf.reduce_sum(row ** 2)) normalized.append(row / (norm + 1e-8))result = tf.stack(normalized)# 正面:向量化,快几十倍norms = tf.sqrt(tf.reduce_sum(data ** 2, axis=1, keepdims=True))result = data / (norms + 1e-8)关键技巧:keepdims=True 保持维度,让除法能正确广播。效率检查清单| 操作 | 推荐做法 | 避免的做法 ||------|---------|------------|| 扩展维度 | tf.expand_dims / reshape | tf.tile(真复制数据) || 批量运算 | 向量化 x * 2 | Python 循环 || 类型一致 | 统一 dtype | 混合 float32/float64 || 形状变换 | reshape(O(1)) | transpose(O(n),必要时才用) || 索引筛选 | tf.gather / tf.boolean_mask | Python for 循环 || GPU 数据 | 保持在 GPU 上 | 频繁 .numpy() 和 tf.constant() || 初始化权重 | truncated_normal | normal(可能产生极端值) |
服务端阅读 06月5日 18:15

TensorFlow 损失函数怎么选?一张决策图搞定回归、分类和不平衡数据

损失函数决定了模型往哪个方向优化——选错了,训练再久也是白费。TensorFlow 内置了十几种损失函数,加上自定义能力,选择面很广,但真正常用且需要搞清楚的也就那么几类。先搞清楚你的任务类型选损失函数的第一步不是看哪个函数厉害,而是明确你的任务:回归(预测连续值,比如房价、温度)→ MSE / MAE / Huber二分类(是或否,比如垃圾邮件检测)→ Binary Crossentropy多分类(多个互斥类别,比如手写数字识别)→ Categorical / Sparse Categorical Crossentropy特殊场景(类别不平衡、图像分割、生成模型)→ Focal Loss / Dice Loss / KL Divergence这个分类不是随便列的——同一类里的函数互相之间有明确的取舍逻辑,下面挨个说清楚。回归损失:MSE、MAE 和 Huber 的取舍三个函数各有脾气,选谁取决于你的数据长什么样。MSE(均方误差)—— 默认选择,但对异常值过敏model.compile(optimizer="adam", loss="mse")MSE 对大误差施加二次惩罚——预测偏差 10 的样本,惩罚是偏差 1 的 100 倍。这意味着如果你的数据里有几个极端异常值(比如房价数据里突然混入一栋别墅),MSE 会拼命去拟合它们,结果把整体预测带偏。什么时候用:数据干净、分布均匀,且你确实想对大误差更严格。MAE(平均绝对误差)—— 异常值多时的保底方案model.compile(optimizer="adam", loss="mae")MAE 的惩罚和误差成线性关系,异常值不会像 MSE 那样获得不成比例的影响力。代价是在 0 点处不可导,梯度始终相同,收敛可能比 MSE 慢一些。什么时候用:数据有明显异常值,或者你不想让少数极端样本主导训练方向。Huber —— 两者的折中from tensorflow.keras.losses import Hubermodel.compile(optimizer="adam", loss=Huber(delta=1.0))Huber 的设计很直觉:误差小于 delta 时按 MSE 算(收敛快),误差大于 delta 时切换成 MAE(不被异常值绑架)。delta 就是那个分界线,调大调小直接影响模型对异常值的容忍度。什么时候用:数据有异常值但你仍然想保留 MSE 在小误差时的收敛优势。实际上大多数回归任务 Huber 都是个比 MSE 更稳的选择,只是很多人不知道。一个实际例子:预测用户付费金额时,90% 的用户付费在 0-100 元,但有个别用户付费过万。用 MSE 会导致模型过度关注那些大额用户,预测结果偏高;用 Huber(delta=10)就能忽略极端值的影响,同时保证小额预测的精度。分类损失:交叉熵家族分类任务几乎都用交叉熵(Crossentropy),区别在于标签格式和分类数量。二分类 → Binary Crossentropymodel.compile(optimizer="adam", loss="binary_crossentropy")输出层用 sigmoid 激活,标签是 0 或 1。这是二分类的标准配置,没什么好犹豫的。注意:如果你的正负样本比例悬殊(比如欺诈检测,正常交易 99.9%,欺诈 0.1%),直接用 Binary Crossentropy 会让模型倾向于全预测为多数类。这时候需要 Focal Loss 或加权交叉熵。多分类 → 两种 Crossentropy这是很多人搞混的地方:| | Categorical Crossentropy | Sparse Categorical Crossentropy ||---|---|---|| 标签格式 | one-hot 编码,如 [0, 1, 0] | 整数,如 1 || 输出层激活 | softmax | softmax || 适合场景 | 类别少、标签已 one-hot | 类别多、不想手动 one-hot |# 标签是 one-hoty_train = [[0, 1, 0], [1, 0, 0], [0, 0, 1]]model.compile(optimizer="adam", loss="categorical_crossentropy")# 标签是整数y_train = [1, 0, 2]model.compile(optimizer="adam", loss="sparse_categorical_crossentropy")经验:类别超过 10 个时,Sparse 版本更省内存也更好用。功能上完全等价,只是输入格式不同。特殊场景的损失函数Focal Loss —— 类别不平衡的杀手锏def focal_loss(gamma=2.0, alpha=0.25): def loss(y_true, y_pred): y_true = tf.cast(y_true, tf.float32) epsilon = tf.keras.backend.epsilon() y_pred = tf.clip_by_value(y_pred, epsilon, 1.0 - epsilon) cross_entropy = -y_true * tf.math.log(y_pred) weight = alpha * tf.pow(1.0 - y_pred, gamma) return tf.reduce_mean(tf.reduce_sum(weight * cross_entropy, axis=1)) return lossmodel.compile(optimizer="adam", loss=focal_loss(gamma=2.0, alpha=0.25))Focal Loss 的核心思想:模型已经分对的样本,少花点力气;分不对的样本,加大火力。gamma 控制对易分类样本的抑制程度(越大抑制越强),alpha 控制正类的权重。什么时候用:目标检测、欺诈检测、罕见病诊断——任何正负样本比例超过 10:1 的场景。Dice Loss —— 图像分割标配def dice_loss(smooth=1.0): def loss(y_true, y_pred): y_true = tf.cast(y_true, tf.float32) y_pred = tf.cast(y_pred, tf.float32) intersection = tf.reduce_sum(y_true * y_pred) union = tf.reduce_sum(y_true) + tf.reduce_sum(y_pred) dice = (2.0 * intersection + smooth) / (union + smooth) return 1.0 - dice return lossmodel.compile(optimizer="adam", loss=dice_loss(smooth=1.0))图像分割任务中,前景像素通常远少于背景像素。Dice Loss 基于 Dice 系数(衡量两个区域的重叠度),对小目标分割更友好。实际项目中经常和 Crossentropy 组合使用:total_loss = bce + dice_loss。KL Divergence —— 生成模型专用model.compile(optimizer="adam", loss="kld")KL 散度衡量两个概率分布的差异,在 VAE(变分自编码器)中让编码分布逼近标准正态分布,在知识蒸馏中让学生模型模仿教师模型的输出分布。日常分类回归任务用不上它。自定义损失函数内置函数覆盖不了所有场景。两种写法:函数式——简单直接def custom_loss(y_true, y_pred): mse = tf.reduce_mean(tf.square(y_true - y_pred)) reg = tf.reduce_mean(tf.square(y_pred)) return mse + 0.01 * regmodel.compile(optimizer="adam", loss=custom_loss)类式——需要传参数时class WeightedMSE(tf.keras.losses.Loss): def __init__(self, weight=1.0, name="weighted_mse"): super().__init__(name=name) self.weight = weight def call(self, y_true, y_pred): return self.weight * tf.reduce_mean(tf.square(y_true - y_pred))model.compile(optimizer="adam", loss=WeightedMSE(weight=2.0))类式写法的好处是参数可以在 __init__ 中初始化,不用 functools.partial 那种绕弯路的方式。而且保存模型时能正确序列化。多任务学习中的损失组合一个模型同时预测多个目标时,需要组合多个损失函数。关键问题是权重怎么设——最简单的做法是手动调,更科学的方法是用 Uncertainty Weighting:def multi_task_loss(y_true, y_pred): cls_pred, reg_pred = y_pred[:, :10], y_pred[:, 10:] cls_true, reg_true = y_true[:, :10], y_true[:, 10:] cls_loss = tf.keras.losses.categorical_crossentropy(cls_true, cls_pred) reg_loss = tf.keras.losses.mse(reg_true, reg_pred) return 0.5 * cls_loss + 0.5 * reg_lossmodel.compile(optimizer="adam", loss=multi_task_loss)手动设 0.5/0.5 是起点,实际项目里通常需要根据各任务的收敛速度调整——哪个任务 loss 下降太快就降低权重,反之加大,保持各任务梯度量级大致相当。损失函数调试要点选完损失函数不代表万事大吉,训练过程中需要关注几个信号:Loss 值是否在合理范围:MSE 在房价预测时可能几百,在手写数字分类时可能 0.01,这都正常。但如果 Binary Crossentropy 跑到负数,说明标签或预测值有问题。训练/验证 Loss 的差距:训练 Loss 持续下降但验证 Loss 开始上升,不是损失函数的问题,是过拟合——该加正则化或早停,不是换损失函数。Loss 突然变 NaN:学习率太大或交叉熵里预测值出现了 0——加上 epsilon 裁剪:tf.clip_by_value(y_pred, 1e-7, 1 - 1e-7)。快速选择参考| 你的任务 | 数据特点 | 推荐损失函数 ||---------|---------|------------|| 回归 | 数据干净 | MSE || 回归 | 有异常值 | Huber(delta 根据异常值大小设) || 回归 | 异常值很多 | MAE || 二分类 | 样本均衡 | Binary Crossentropy || 二分类 | 样本不平衡 | Focal Loss || 多分类 | 标签 one-hot | Categorical Crossentropy || 多分类 | 标签整数 | Sparse Categorical Crossentropy || 图像分割 | 前景小 | Dice Loss + BCE 组合 || 生成模型 | VAE/GAN | KL Divergence |选损失函数不需要一步到位——先用最简单的(回归用 MSE,分类用 Crossentropy),跑出基线结果,再根据训练曲线和业务需求调整。大部分情况下换个损失函数带来的提升远不如调数据和特征。
服务端阅读 06月5日 14:00

Next.js测试策略:服务端组件、API路由、Server Actions和E2E

Next.js 应用的测试比纯 React 复杂——它有服务端组件、客户端组件、API 路由、Server Actions、还有 SSR/SSG 渲染模式。照搬 React 的测试方法会踩很多坑。这篇文章按组件类型分类,把每类该测什么、用什么工具、怎么 Mock 讲清楚。测试工具选择| 工具 | 用途 | 替代方案 ||------|------|----------|| Jest + React Testing Library | 组件单元测试 | Vitest(更快,配置类似) || MSW (Mock Service Worker) | Mock API 请求 | nock(只 Node 环境) || Playwright | E2E 测试 | Cypress || @testing-library/user-event | 模拟用户交互 | fireEvent(不如 user-event 贴近真实) |Next.js 项目推荐用 Vitest 代替 Jest——Vite 生态集成更好,速度快 3-5 倍:npm install -D vitest @vitejs/plugin-react @testing-library/react @testing-library/jest-dom @testing-library/user-eventVitest 配置// vitest.config.tsimport { defineConfig } from 'vitest/config';import react from '@vitejs/plugin-react';import path from 'path';export default defineConfig({ plugins: [react()], test: { environment: 'jsdom', setupFiles: ['./vitest.setup.ts'], include: ['**/*.{test,spec}.{js,jsx,ts,tsx}'], }, resolve: { alias: { '@': path.resolve(__dirname, './src'), }, },});// vitest.setup.tsimport '@testing-library/jest-dom';客户端组件测试客户端组件(文件顶部有 'use client')的测试和普通 React 组件一样:// components/Counter.tsx'use client';import { useState } from 'react';export function Counter() { const [count, setCount] = useState(0); return ( <div> <span data-testid="count">{count}</span> <button onClick={() => setCount(c => c + 1)}>+1</button> </div> );}// components/__tests__/Counter.test.tsximport { render, screen } from '@testing-library/react';import userEvent from '@testing-library/user-event';import { Counter } from '../Counter';describe('Counter', () => { it('increments count on click', async () => { const user = userEvent.setup(); render(<Counter />); expect(screen.getByTestId('count')).toHaveTextContent('0'); await user.click(screen.getByRole('button', { name: '+1' })); expect(screen.getByTestId('count')).toHaveTextContent('1'); });});Mock hooks测试依赖 useSearchParams 等 Next.js hooks 的组件:// Mock next/navigationvi.mock('next/navigation', () => ({ useSearchParams: () => new URLSearchParams('tab=settings'), useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), usePathname: () => '/dashboard',}));Next.js 的 hooks 在测试环境里不工作——它们依赖 Next.js 的路由上下文。Mock 是唯一的方式。服务端组件测试服务端组件(默认,没有 'use client')不能直接用 React Testing Library 渲染——它们是异步函数,返回的不是标准 JSX。方案一:抽取逻辑为纯函数最佳做法——把业务逻辑从服务端组件里抽出来,单独测试:// lib/filterProducts.tsexport function filterProducts(products: Product[], category: string) { return products.filter(p => p.category === category);}// lib/__tests__/filterProducts.test.tsimport { filterProducts } from '../filterProducts';describe('filterProducts', () => { it('filters by category', () => { const products = [ { id: 1, name: 'Widget', category: 'tools' }, { id: 2, name: 'Book', category: 'books' }, ]; expect(filterProducts(products, 'tools')).toHaveLength(1); });});服务端组件本身只是数据获取 + 渲染的组合,逻辑都在纯函数里——纯函数好测、快、不依赖任何框架。方案二:测试渲染输出如果必须测服务端组件的渲染结果,用 renderToString:import { renderToString } from 'react-dom/server';import { ProductList } from '../components/ProductList';it('renders product names', async () => { const products = [{ id: 1, name: 'Widget' }]; const html = renderToString(await ProductList({ products })); expect(html).toContain('Widget');});这种方式比较粗糙——只能断言 HTML 字符串包含什么,不能用 screen.getByRole 等 DOM 查询 API。适合快速验证组件不会报错、渲染了关键内容。API 路由测试Next.js App Router 的 API 路由是 route.ts 文件,导出 GET、POST 等函数。测试时直接调用这些函数:// app/api/users/route.tsimport { NextResponse } from 'next/server';export async function GET(request: Request) { const { searchParams } = new URL(request.url); const page = searchParams.get('page') || '1'; const users = await fetchUsers(Number(page)); return NextResponse.json({ users, page: Number(page) });}// __tests__/api/users.test.tsimport { GET } from '@/app/api/users/route';// Mock 数据获取vi.mock('@/lib/data', () => ({ fetchUsers: vi.fn().mockResolvedValue([{ id: 1, name: 'Alice' }]),}));describe('GET /api/users', () => { it('returns users with page number', async () => { const request = new Request('http://localhost/api/users?page=2'); const response = await GET(request); const data = await response.json(); expect(response.status).toBe(200); expect(data.page).toBe(2); expect(data.users).toHaveLength(1); });});直接构造 Request 对象传入——不需要启动服务器,测试跑在 Node 环境里,速度极快。Mock 外部 APIAPI 路由通常要调外部服务。用 MSW 拦截请求:import { setupServer } from 'msw/node';import { http, HttpResponse } from 'msw';const server = setupServer( http.get('https://api.example.com/users', () => { return HttpResponse.json([{ id: 1, name: 'Mocked User' }]); }),);beforeAll(() => server.listen());afterEach(() => server.resetHandlers());afterAll(() => server.close());MSW 在 Node 层面拦截 HTTP 请求——不需要改业务代码,测试完自动恢复。Server Actions 测试Server Actions 是 Next.js 13+ 的服务端函数,在客户端通过 useServer 调用。测试方式和 API 路由类似——直接调用函数:// app/actions/createPost.ts'use server';export async function createPost(formData: FormData) { const title = formData.get('title') as string; if (!title || title.length < 3) { return { error: '标题至少 3 个字符' }; } await db.post.create({ data: { title } }); return { success: true };}import { createPost } from '@/app/actions/createPost';vi.mock('@/lib/db', () => ({ db: { post: { create: vi.fn().mockResolvedValue({ id: 1 }) } },}));describe('createPost', () => { it('rejects short titles', async () => { const formData = new FormData(); formData.set('title', 'ab'); const result = await createPost(formData); expect(result.error).toBeDefined(); }); it('creates post with valid title', async () => { const formData = new FormData(); formData.set('title', 'My Post'); const result = await createPost(formData); expect(result.success).toBe(true); });});E2E 测试(Playwright)单元测试验证组件逻辑,E2E 测试验证完整用户流程——从打开页面到完成操作。基础配置// playwright.config.tsimport { defineConfig } from '@playwright/test';export default defineConfig({ testDir: './e2e', baseURL: 'http://localhost:3000', use: { locale: 'zh-CN', },});页面测试// e2e/home.spec.tsimport { test, expect } from '@playwright/test';test('homepage shows products', async ({ page }) => { await page.goto('/'); await expect(page.getByText('热门商品')).toBeVisible(); await expect(page.getByTestId('product-card')).toHaveCount(10);});test('can add product to cart', async ({ page }) => { await page.goto('/'); await page.getByTestId('product-card').first().getByRole('button', { name: '加入购物车' }).click(); await expect(page.getByTestId('cart-count')).toHaveText('1');});E2E 测试需要 Next.js 服务器在跑。Playwright 的 webServer 配置可以自动启动:export default defineConfig({ webServer: { command: 'npm run dev', port: 3000, reuseExistingServer: !process.env.CI, },});测试策略总结| 层级 | 测试什么 | 工具 | 占比 ||------|----------|------|------|| 纯函数/工具 | 业务逻辑、数据转换 | Vitest | 40% || 客户端组件 | 交互、状态、渲染 | RTL + Vitest | 25% || API 路由/Actions | 请求处理、验证、错误 | Vitest + MSW | 20% || E2E | 关键用户流程 | Playwright | 15% |服务端组件不直接测——逻辑抽到纯函数,渲染验证留给 E2E。这样 85% 的测试跑在 Vitest 里(< 100ms/个),只有 15% 需要启动浏览器。
服务端阅读 06月5日 13:57

TypeORM事务基础:三种写法、常见陷阱和NestJS集成

数据库事务保证一组操作要么全部成功、要么全部回滚——转账是最经典的例子:A 扣 100 元和 B 加 100 元必须在同一个事务里,不能只执行一半。TypeORM 提供三种写事务的方式,这篇文章把每种的使用场景和容易踩的坑讲清楚。为什么需要事务不用事务会怎样:// ❌ 没有事务保护async function transfer(fromId: number, toId: number, amount: number) { const from = await userRepo.findOne({ where: { id: fromId } }); from.balance -= amount; await userRepo.save(from); // 成功 const to = await userRepo.findOne({ where: { id: toId } }); to.balance += amount; await userRepo.save(to); // 如果这里报错,A 扣了钱但 B 没收到}事务保证:要么两步都成功,要么两步都回滚。方式一:DataSource.transaction()(日常首选)最简洁的写法——传一个回调,回调里所有操作自动在一个事务里:await dataSource.transaction(async (manager) => { const from = await manager.findOne(User, { where: { id: fromId } }); const to = await manager.findOne(User, { where: { id: toId } }); if (from.balance < amount) { throw new Error('余额不足'); // 抛异常 → 自动 rollback } from.balance -= amount; to.balance += amount; await manager.save([from, to]); // 正常返回 → 自动 commit});最关键的规则:回调里必须用 manager 参数操作数据库,不能用全局的 userRepository。// ❌ 错误:全局 repository 不在事务里await dataSource.transaction(async (manager) => { await manager.save(from); // 在事务里 ✅ await userRepository.save(to); // 不在事务里 ❌});全局 Repository 拿到的连接不在事务的连接上,它的操作不受事务保护。这是最常见的事务 bug——代码不报错,但数据不一致。回调正常返回 → commitawait dataSource.transaction(async (manager) => { await manager.save(user); // 没有 throw → 自动 commit});回调抛异常 → rollbackawait dataSource.transaction(async (manager) => { await manager.save(user); throw new Error('出错了'); // → 自动 rollback,上面的 save 被撤销});不需要手动 commit 或 rollback——TypeORM 自动处理。方式二:QueryRunner(精细控制)当你需要手动控制 commit/rollback 时机、设隔离级别、或同一个连接上跑多个事务时:const queryRunner = dataSource.createQueryRunner();await queryRunner.connect(); // 获取数据库连接await queryRunner.startTransaction(); // 开启事务try { await queryRunner.manager.save(user1); await queryRunner.manager.save(user2); // 可以在这里做其他判断,决定 commit 还是 rollback if (someCondition) { await queryRunner.commitTransaction(); } else { await queryRunner.rollbackTransaction(); }} catch (err) { await queryRunner.rollbackTransaction(); throw err;} finally { await queryRunner.release(); // 必须释放连接!}release() 是必须的——QueryRunner 持有数据库连接,不释放会泄漏连接池。finally 块保证即使 commit 失败也能释放。什么时候用 QueryRunner需要手动控制 commit 时机(如根据业务逻辑决定)需要设置隔离级别:queryRunner.startTransaction('SERIALIZABLE')需要在同一个连接上执行原生 SQL 和 ORM 操作批量操作需要分批 commit(避免单个大事务锁表太久)分批 commit 的模式const queryRunner = dataSource.createQueryRunner();await queryRunner.connect();const users = await queryRunner.manager.find(User, { take: 10000 });for (let i = 0; i < users.length; i += 1000) { await queryRunner.startTransaction(); try { const batch = users.slice(i, i + 1000); for (const user of batch) { user.processed = true; await queryRunner.manager.save(user); } await queryRunner.commitTransaction(); } catch (err) { await queryRunner.rollbackTransaction(); }}await queryRunner.release();每 1000 条一个事务,而不是 10000 条一个大事务。大事务持有锁太久会阻塞其他查询。方式三:NestJS + TypeORMNestJS 项目里注入 DataSource 用事务:import { Injectable } from '@nestjs/common';import { InjectDataSource } from '@nestjs/typeorm';import { DataSource } from 'typeorm';@Injectable()export class OrderService { constructor( @InjectDataSource() private dataSource: DataSource, ) {} async createOrder(dto: CreateOrderDto) { return this.dataSource.transaction(async (manager) => { const order = manager.create(Order, dto); await manager.save(order); for (const item of dto.items) { const product = await manager.findOne(Product, { where: { id: item.productId }, }); product.stock -= item.quantity; await manager.save(product); } return order; }); }}常见陷阱事务里调用了事务外的方法// ❌ 事务里调用了不在事务里的方法async function updateProfile(userId: number) { await userRepo.update(userId, { name: 'new' }); // 不在事务里}await dataSource.transaction(async (manager) => { await manager.save(order); await updateProfile(userId); // 这步不在事务保护下!});解决:把需要事务保护的操作都放在回调里,或者把 manager 传进去:async function updateProfile(manager: EntityManager, userId: number) { await manager.update(User, userId, { name: 'new' });}await dataSource.transaction(async (manager) => { await manager.save(order); await updateProfile(manager, userId); // ✅ 在事务里});事务里做了网络请求// ❌ 事务里调第三方 APIawait dataSource.transaction(async (manager) => { await manager.save(order); await fetch('https://payment-gateway/charge', { ... }); // 网络请求可能超时 await manager.save(payment);});网络请求可能耗时几秒甚至超时——事务期间数据库锁一直持有,其他请求被阻塞。正确做法:先完成数据库事务,再调外部 API,失败时用补偿机制回滚。忘记 await// ❌ 没有 await,事务还没 commit 就返回了await dataSource.transaction(async (manager) => { manager.save(user); // 没有 await!});manager.save() 返回 Promise,没有 await 的话,事务 commit 时 save 可能还没执行完。TypeScript 的 async 函数里忘记 await 不会报错——这是个无声的 bug。事务 vs 批量操作不是所有操作都需要事务。批量插入、批量更新如果不需要原子性,可以不用事务——更快:// 不需要事务:批量插入日志await logRepo.insert(logs);// 需要事务:转账(必须保证原子性)await dataSource.transaction(async (manager) => { // ...});判断标准:这些操作是否必须"全部成功或全部失败"?是 → 用事务,否 → 不用。
服务端阅读 06月5日 13:56

TypeORM迁移完整指南:自动生成、数据迁移和生产部署策略

synchronize: true 在开发时很方便——改实体自动同步表结构。但生产环境这么做会丢数据:删字段时直接 DROP COLUMN,重命名字段被当作"删旧的加新的"。迁移(Migration)是生产环境管理数据库结构变更的唯一正确方式。这篇文章把迁移的完整流程和常见坑都讲清楚。迁移的工作原理迁移就是一个类,有 up() 和 down() 两个方法——up() 执行变更,down() 回滚变更。TypeORM 按顺序执行迁移文件,并在数据库里记录哪些已经跑过。数据表 _migration:┌──────────────────────────────┬────────────┐│ id │ timestamp │├──────────────────────────────┼────────────┤│ InitSchema1700000000000 │ 2024-01-01 ││ AddUserEmail1700000000001 │ 2024-01-02 │└──────────────────────────────┴────────────┘每次 migration:run,TypeORM 对比已执行的迁移和待执行的迁移文件,只跑新的。创建迁移方式一:自动生成(推荐)修改实体后,让 TypeORM 自动对比生成迁移:npx typeorm migration:generate src/migration/AddUserEmail -d src/data-source.tsTypeORM 会对比实体定义和当前数据库结构,生成差量的迁移文件。这是最安全的方式——不会漏字段、不会写错类型。前提:数据库必须和当前代码的实体一致(上次迁移已执行)。如果数据库和实体不同步,生成会报错。方式二:手动创建npx typeorm migration:create src/migration/AddUserEmail创建空模板,自己写 SQL:import { MigrationInterface, QueryRunner } from 'typeorm';export class AddUserEmail1700000000001 implements MigrationInterface { name = 'AddUserEmail1700000000001'; public async up(queryRunner: QueryRunner): Promise<void> { await queryRunner.addColumn( 'user', new TableColumn({ name: 'email', type: 'varchar', length: '255', isUnique: true, }), ); } public async down(queryRunner: QueryRunner): Promise<void> { await queryRunner.dropColumn('user', 'email'); }}手动迁移用于自动生成搞不定的场景:数据迁移(把数据从一列搬到另一列)、复杂的 schema 重构。执行迁移# 执行所有未执行的迁移npx typeorm migration:run -d src/data-source.ts# 回滚最后一次迁移npx typeorm migration:revert -d src/data-source.ts# 查看迁移状态npx typeorm migration:show -d src/data-source.tsmigration:show 输出:[X] InitSchema1700000000000 # 已执行[X] AddUserEmail1700000000001 # 已执行[ ] AddPostTags1700000000002 # 未执行在 NestJS 里执行// main.ts 或专门的迁移脚本import { DataSource } from 'typeorm';import { AppDataSource } from './data-source';async function runMigrations() { await AppDataSource.initialize(); await AppDataSource.runMigrations(); await AppDataSource.destroy();}runMigrations();或在启动时自动跑:async function bootstrap() { const app = await NestFactory.create(AppModule); // 启动时自动执行迁移 const dataSource = app.get(DataSource); await dataSource.runMigrations(); await app.listen(3000);}注意:生产环境自动跑迁移有风险——如果迁移有 bug,服务启动就失败。更安全的做法是在部署流程里单独跑迁移,确认成功后再部署新代码。常见迁移场景加列public async up(queryRunner: QueryRunner): Promise<void> { await queryRunner.addColumn('user', new TableColumn({ name: 'email', type: 'varchar', isNullable: true, // 先允许 NULL,后续填充数据后再设 NOT NULL }));}安全做法:新列先 isNullable: true,应用层填充数据后,再用另一个迁移改为 NOT NULL。直接 NOT NULL 会导致已有行插入失败。删列public async up(queryRunner: QueryRunner): Promise<void> { await queryRunner.dropColumn('user', 'deprecatedField');}删列不可逆——数据丢了就没了。确保没有代码引用这个列后再删。建议先在代码里移除对列的引用,部署一版,确认没有报错,再加迁移删列。改列类型public async up(queryRunner: QueryRunner): Promise<void> { await queryRunner.changeColumn('user', 'age', new TableColumn({ name: 'age', type: 'int', // 从 smallint 改为 int isNullable: false, }));}类型变更可能导致数据丢失——varchar(255) 改 varchar(50) 会截断数据。改之前检查现有数据是否都在新范围内。重命名列public async up(queryRunner: QueryRunner): Promise<void> { await queryRunner.renameColumn('user', 'name', 'fullName');}renameColumn 比 "删旧列+加新列" 安全——它用 ALTER TABLE RENAME COLUMN,数据不丢失。数据迁移纯 schema 迁移只改表结构,不改数据。数据迁移需要在 up() 里写 SQL:public async up(queryRunner: QueryRunner): Promise<void> { // 把 name 拆成 firstName 和 lastName await queryRunner.addColumn('user', new TableColumn({ name: 'firstName', type: 'varchar', isNullable: true })); await queryRunner.addColumn('user', new TableColumn({ name: 'lastName', type: 'varchar', isNullable: true })); // 数据迁移 await queryRunner.query(` UPDATE "user" SET "firstName" = split_part("name", ' ', 1), "lastName" = split_part("name", ' ', 2) `); // 数据填充完后删除旧列 await queryRunner.dropColumn('user', 'name');}数据迁移要注意性能——百万级表的 UPDATE 可能跑几十分钟。大表迁移分批执行:// 分批更新,每批 1000 行await queryRunner.query(` UPDATE "user" SET "firstName" = split_part("name", ' ', 1) WHERE "firstName" IS NULL LIMIT 1000`);迁移最佳实践每个迁移只做一件事——加列是一个迁移,改类型是另一个。出错时可以精确回滚先加列后删列——加列不影响现有代码,删列必须先改代码再跑迁移生产迁移先在 staging 测试——同样的迁移在测试环境跑一遍确认没有报错永远不要手动修改 _migration 表——TypeORM 靠它判断哪些迁移已执行,手动改会导致迁移混乱迁移文件提交到 git——团队成员 pull 后跑 migration:run 就能同步数据库结构down() 必须正确实现——回滚时 down() 是唯一的恢复手段部署流程里迁移先行——先跑迁移再部署新代码,避免新代码期望新列但列还不存在
服务端阅读 06月5日 13:55

TypeORM N+1查询:relations、leftJoinAndSelect和DataLoader方案对比

N+1 查询是 ORM 里最经典的性能坑:查 100 个用户,再查 100 次每个用户的文章——1 次主查询 + N 次关联查询 = N+1 次数据库往返。TypeORM 默认不加载关联数据,所以 N+1 不是 bug 而是默认行为——你得主动告诉 TypeORM 你要哪些关联数据。这篇文章讲清楚 N+1 怎么产生的、怎么解决、以及各种方案的取舍。N+1 是怎么产生的// 查 100 个用户:1 次 SQLconst users = await userRepository.find();// 每个用户查文章:100 次 SQLfor (const user of users) { user.posts = await postRepository.find({ where: { authorId: user.id } });}// 总共 101 次数据库查询100 个用户 101 次查询,1000 个用户 1001 次——线性增长。数据库连接池很快耗尽,API 响应从 50ms 飙到 5s。解决方案一:relations 选项最简单的方案,在 find 时声明要加载的关联:const users = await userRepository.find({ relations: ['posts', 'profile'],});生成的 SQL:SELECT * FROM user;SELECT * FROM post WHERE authorId IN (1, 2, 3, ...);SELECT * FROM profile WHERE userId IN (1, 2, 3, ...);3 条 SQL——不管有多少用户。TypeORM 先查主表,拿 ID 列表,再用 IN 查关联表,最后在内存里组装关系。嵌套关联const users = await userRepository.find({ relations: ['posts', 'posts.comments'],});加载用户的文章,以及文章的评论。每多一层嵌套多一条 SQL,但仍是固定数量。只加载部分关联字段const users = await userRepository.find({ relations: ['posts'], select: { id: true, name: true, posts: { id: true, title: true, // 只加载文章的 id 和 title }, },});解决方案二:leftJoinAndSelectQueryBuilder 版本,更灵活:const users = await userRepository .createQueryBuilder('user') .leftJoinAndSelect('user.posts', 'post') .leftJoinAndSelect('user.profile', 'profile') .getMany();和 relations 的区别:| | relations | leftJoinAndSelect ||---|---|---|| SQL 数量 | 多条(主查询 + 每个关联一条) | 一条(JOIN 合并) || 大数据量性能 | 更好(IN 查询,无重复行) | 可能差(JOIN 产生笛卡尔积) || 灵活性 | 只能加载,不能过滤 | 可以加 WHERE、ORDER BY || 去重 | 自动 | 自动(TypeORM 处理) |什么时候用 leftJoinAndSelect:需要对关联数据做过滤或排序时。// 只加载已发布的文章const users = await userRepository .createQueryBuilder('user') .leftJoinAndSelect('user.posts', 'post', 'post.published = :published', { published: true }) .getMany();第三个参数是 JOIN 条件——只有 published=true 的文章会被加载。relations 做不到这个。什么时候用 relations:只是加载数据不过滤时。relations 在数据量大时性能更好——JOIN 1000 个用户每人 10 篇文章会产生 10000 行中间结果,IN 查询只有 1000 + 10 = 1010 行。解决方案三:Eager 加载在实体定义里设置 eager: true,每次查用户自动加载文章:@Entity()export class User { @OneToMany(() => Post, post => post.author, { eager: true }) posts: Post[];}// 自动加载 posts,不需要显式声明const users = await userRepository.find();不推荐。eager: true 让你失去控制——有时候你只需要用户名,结果把文章也查出来了。而且 eager 关联嵌套时,层层加载,性能不可预测。只在一种场景下用 eager:关联数据总是需要一起加载。比如 User 和 UserProfile(一对一,用户资料总是要一起展示的)。解决方案四:DataLoader(GraphQL 场景)GraphQL 的查询深度不确定——客户端可能查用户文章,也可能不查。用 relations 或 leftJoinAndSelect 会过度加载。DataLoader 按需批量加载:import DataLoader from 'dataloader';const postLoader = new DataLoader(async (authorIds: number[]) => { // 一次查出所有作者的文章 const posts = await postRepository.find({ where: { authorId: In(authorIds) }, }); // 按 authorId 分组 return authorIds.map(id => posts.filter(p => p.authorId === id));});// 在 resolver 里使用const resolvers = { User: { posts: (user) => postLoader.load(user.id), },};DataLoader 自动合并同一个请求里的多次 load() 调用——10 个用户查文章,只触发 1 次数据库查询。常见陷阱忘记加 relations 就访问关联属性const users = await userRepository.find();console.log(users[0].posts); // undefined 或 []没加 relations,关联属性不会自动加载。TypeORM 不会报错——它返回 undefined 或空数组,你的代码以为没有数据。leftJoin vs leftJoinAndSelect// leftJoin:只 JOIN 不过 SELECT,关联数据不返回.leftJoin('user.posts', 'post')// leftJoinAndSelect:JOIN 并且 SELECT,关联数据返回.leftJoinAndSelect('user.posts', 'post')用了 leftJoin 但没 addSelect,关联属性始终为空——很多人卡在这里。过度加载// ❌ 加载了所有关联,但只需要文章数const users = await userRepository.find({ relations: ['posts'] });const postCounts = users.map(u => u.posts.length);// ✅ 用子查询只拿计数const users = await userRepository .createQueryBuilder('user') .loadRelationCountAndMap('user.postCount', 'user.posts') .getMany();loadRelationCountAndMap 只查 COUNT,不加载完整的关联数据——1000 个用户只需 1 条额外的 COUNT 查询。