第 18 章:功能发布:A/B 测试与功能开关
欢迎来到第七部分:高级集成与架构实践。在第六部分中,我们建立了一个完整的 DevOps 和可观测性堆栈。我们现在拥有:
第 18 章:功能发布:A/B 测试与功能开关
欢迎来到第七部分:高级集成与架构实践。在第六部分中,我们建立了一个完整的 DevOps 和可观测性堆栈。我们现在拥有:
- 一个安全的 CI/CD 流水线,能在代码合并前自动运行测试 (第 14 章)。
- 一个全面的可观测性堆栈,能告诉我们生产环境中的性能、错误和用户行为 (第 17 章)。
我们已经解决了“如何安全地部署代码”的问题。但一个新的、更高级的问题出现了:我们如何安全地发布功能?
“部署”(Deployment) 和“发布”(Release) 是两个不同的概念:
- 部署:将新代码推送到生产服务器。这是一个_技术_动作。
- 发布:将新功能开放给真实用户。这是一个_商业_动作。
在传统的 Python/Django 部署流程中,这两个动作通常是绑定的:代码一旦 git pull 并重启 Gunicorn,新功能就对 100% 的用户可见了。这是一种“大爆炸”(Big Bang) 式的发布,风险极高。如果新功能有隐藏的 Bug、性能瓶颈,或者干脆不受用户欢迎,你唯一的选择就是紧急回滚。
现代 SAAS 架构的核心原则是将部署与发布解耦。我们希望能够将新代码 100% 部署到生产环境,但只将其发布给 0%、1%、10% 或 50% 的用户。这使我们能够“小步快跑”,在可控的范围内测试新功能,并根据第 17 章中Sentry 和 Plausible/Umami 反馈的数据来决定是全面铺开还是紧急“关闭”它。
本章,我们将探讨实现这一目标的两种关键技术:A/B 测试 (用于验证假设) 和 功能开关 (用于安全发布)。
18.1. [Skill 实战]:利用 Middleware 重写实现 A/B 测试
A/B 测试 是一种受控实验,用于比较一个功能的两个(或多个)版本,以确定哪个版本表现更佳。
场景:我们的营销团队不确定 src/app/[locale]/pricing/page.tsx (第 12 章) 的定价页面设计是否最优。他们设计了一个全新的页面 src/app/[locale]/pricing-variant-b/page.tsx,他们假设这个新页面能将“点击升级按钮”的转化率提高 10%。
我们如何验证这个假设?
我们不能简单地把旧页面换成新页面。我们需要:
- 将 50% 的网站访问者随机分配到“A 组”(Control),他们看到旧页面。
- 将另外 50% 的访问者随机分配到“B 组”(Variant),他们看到新页面。
- 两组用户看到的 URL 必须完全相同 (例如
.../pricing),以避免混淆和 SEO 问题。 - 使用我们的分析工具 (第 17 章) 跟踪两组的转化率。
在 Next.js App Router 中,实现这一目标的最佳场所是 Middleware。
[Skill 实战:claude-nextjs-skills/nextjs-advanced-routing/SKILL.md]
这个 Skill 演示了如何使用 NextResponse.rewrite() 来实现高级路由。rewrite (重写) 是一种强大的机制,它允许你在服务器上_内部_将用户请求转发到另一个页面,同时保持浏览器地址栏中的 URL 不变。
让我们看看如何将其应用于 A/B 测试。
src/middleware.ts 代码解析
// src/middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { get } from '@vercel/edge-config'; // (可选) 使用 Vercel Edge Config 动态控制
// 定义我们的 A/B 测试配置
const AB_TEST_COOKIE = 'ab_test_pricing_page';
const PAGE_TO_TEST = '/pricing';
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 1. 检查是否是我们想测试的页面
// (注意: 这里需要处理 [locale] 国际化路径)
if (!pathname.endsWith(PAGE_TO_TEST)) {
return NextResponse.next();
}
// (可选) 我们可以从 Vercel Edge Config 动态读取是否开启 A/B 测试
// const abTestEnabled = await get('enablePricingAbTest');
// if (!abTestEnabled) return NextResponse.next();
// 2. 检查用户是否已经有分组 Cookie
let bucket = request.cookies.get(AB_TEST_COOKIE)?.value;
let hasBucket = !!bucket;
// 3. 如果没有,随机分配一个分组
if (!bucket) {
const random = Math.random();
bucket = random < 0.5 ? 'control' : 'variant';
}
// 4. 使用 NextResponse.rewrite() 来显示变体
// (我们假设 i18n 路径是 `/en/pricing`, `/jp/pricing` 等)
const [_, locale] = pathname.match(/\/([a-z]{2})\/pricing/) || [];
const url = request.nextUrl.clone(); // 复制 URL 对象
if (bucket === 'variant') {
// 关键!重写到 B 组页面
url.pathname = `/${locale}/pricing-variant-b`;
} else {
// A 组用户,什么也不做 (或显式重写到原页面)
// url.pathname = `/${locale}/pricing`; // 这是默认行为
}
// 5. 创建响应
// NextResponse.rewrite() 会返回一个特殊的响应,告诉 Next.js 渲染另一个页面
const response = NextResponse.rewrite(url);
// 6. 如果是新用户,设置 Cookie,以便他们下次访问时保持同一分组
if (!hasBucket) {
response.cookies.set(AB_TEST_COOKIE, bucket, {
httpOnly: true,
maxAge: 60 * 60 * 24 * 7, // 7 天
});
}
return response;
}
// Edge 运行时配置
export const config = {
matcher: [
/*
* 匹配所有请求路径,但排除:
* - /api (API 路由)
* - /_next/static (静态文件)
* - /_next/image (图片优化)
* - /favicon.ico (图标)
*/
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
};文件结构
我们的 App Router 目录现在看起来像这样:
src/app/[locale]/
├── pricing/
│ └── page.tsx # A 组 (Control) 看这个
├── pricing-variant-b/
│ └── page.tsx # B 组 (Variant) 看这个
└── ...通过这个设置,两组用户都只会在浏览器中看到 https://yourdomain.com/en/pricing,但 Middleware 会在服务器端根据 Cookie 决定是渲染 pricing/page.tsx 还是 pricing-variant-b/page.tsx。
最后,在 pricing-variant-b/page.tsx 中,我们会触发一个特定的分析事件,以便在 Plausible/Umami 中比较两个组的表现:
// src/app/[locale]/pricing-variant-b/page.tsx
'use client';
import { useEffect } from 'react';
import { trackEvent } from '@/analytics/events';
import { PricingTable } from '@/components/payment/PricingTable'; // (可能是新版的定价表)
export default function PricingVariantPage() {
useEffect(() => {
// 报告该用户看到了 B 组页面
trackEvent('view_pricing_page', { variant: 'b' });
}, []);
// ... 渲染新的定价页面 UI
return (
<div>
<h1>Our New Pricing!</h1>
{/* <NewPricingTable /> */}
</div>
);
}18.2. [代码解析]:功能开关 (Feature Flags)
功能开关 (Feature Flags) 是一种更简单、更直接的发布控制技术。它不是为了“测试”,而是为了“安全”。
功能开关是一个布尔值(true / false),它允许你在代码中包裹一个新功能,从而在_不重新部署_的情况下打开或关闭该功能。
场景:我们开发了一个全新的 AI 驱动的“仪表盘摘要”功能 (<DashboardSummary />)。这个功能依赖于一个昂贵的第三方 AI API,我们不确定它在生产环境中的性能和成本。
我们希望:
- 部署包含
<DashboardSummary />组件的代码,但默认“关闭”它。 - 先为内部员工(或特定测试用户)“打开”它进行“狗啃”(Dogfooding)。
- 在 Sentry (第 17 章) 中监控没有错误后,为 10% 的用户打开它。
- 如果 Sentry 报错激增,立即“关闭”它,而不需要紧急回滚代码。
“静态” vs “动态” 开关
1. 静态开关 (Static Flags) - 本书实战 这是最简单的实现。开关定义在代码库的配置文件中。
src/config/website.tsx (或 features.ts****)
// src/config/website.tsx
export const featureFlags = {
// 描述: 启用新的 AI 仪表盘摘要功能
// 状态: 默认关闭,等待生产环境验证
enableAiDashboard: process.env.NEXT_PUBLIC_FLAG_AI_DASHBOARD === 'true' || false,
// 描述: 启用新的导航栏设计
// 状态: 已向 100% 用户发布
enableNewNavbar: true,
// 描述: 允许用户购买“终身版”
// 状态: 仅在 .env.local 中为 'true',用于本地开发
enableLifetimeDeal: process.env.NODE_ENV === 'development',
};如何使用它?
-
在服务器组件 (RSC) 中:
// src/app/[locale]/dashboard/page.tsx import { featureFlags } from '@/config/website'; import { DashboardSummary } from '@/components/dashboard/DashboardSummary'; export default async function DashboardPage() { // 在服务器上直接读取配置 const showAiSummary = featureFlags.enableAiDashboard; return ( <div> <h1>Your Dashboard</h1> {/* 功能开关包裹新组件 */} {showAiSummary && <DashboardSummary />} {/* 旧的组件 */} {!showAiSummary && <OldDashboardMetrics />} </div> ); } -
在客户端组件 (
"use client") 中:// src/components/layout/Navbar.tsx 'use client'; import { featureFlags } from '@/config/website'; import { OldNavbar } from './OldNavbar'; import { NewNavbar } from './NewNavbar'; export function Navbar() { // 客户端组件可以直接导入和读取 // (前提是 flag 来自 NEXT_PUBLIC_ 或在构建时已确定) if (featureFlags.enableNewNavbar) { return <NewNavbar />; } return <OldNavbar />; }
这种“静态”开关的优点是零成本、零依赖、实现简单。缺点是“关闭”功能(即改变 process.env.NEXT_PUBLIC_FLAG_AI_DASHBOARD 的值)仍然需要你重新构建和部署应用 (在 Vercel 上)。这不能实现“即时关闭”。
2. 动态开关 (Dynamic Flags) - Vercel Flags / LaunchDarkly
为了实现“即时关闭”,你需要一个动态功能开关系统。
- Vercel Flags:Vercel 提供的功能,与 Vercel Edge Config (一个超低延迟的键值存储) 集成。
- LaunchDarkly / PostHog:第三方的专业功能开关平台。
使用这些系统,featureFlags 对象不再从本地文件读取,而是从一个外部 SDK 实时获取:
// 伪代码:使用 Vercel Flags (动态)
import { checkFlag } from '@vercel/flags';
export default async function DashboardPage() {
// 每次请求都会实时检查 Edge Config
const showAiSummary = await checkFlag('enableAiDashboard');
return (
<div>
{showAiSummary && <DashboardSummary />}
{/* ... */}
</div>
);
}现在,如果你在 Sentry 中看到错误激增,你只需登录 Vercel (或 LaunchDarkly) 的仪表盘,将 enableAiDashboard 开关拨到 "off",下一次用户刷新页面时,该功能就会立即消失。
对于我们的 SAAS 模板,从 src/config/website.tsx 开始的静态开关是一个完美的起点,它已经解决了 90% 的安全发布问题。
第 18 章总结
在本章中,我们掌握了安全、渐进式功能发布的两种核心技术,彻底将“部署”与“发布”解耦。
- A/B 测试 (Middleware):我们利用 Next.js Middleware 和
NextResponse.rewrite()实现了一个强大的 A/B 测试系统。这使我们能向不同用户群体展示页面的不同版本(而 URL 保持不变),并通过分析数据来驱动设计和商业决策。 - 功能开关 (Feature Flags):我们分析了“静态” (基于
config.ts) 和“动态” (基于 Vercel Flags/LaunchDarkly) 两种功能开关。通过在代码中(无论是在服务器组件还是客户端组件中)使用简单的if (featureFlags.myFeature)判断,我们获得了对功能上线的精细控制。
这为我们的 DevOps 流程提供了最后一块拼图。我们现在不仅可以持续集成 (CI, Ch 14)、持续部署 (CD, Ch 14) 和持续监控 (Observability, Ch 17),我们还可以持续发布与验证 (Flags & A/B Tests, Ch 18)。这个完整的循环使我们的 SAAS 团队能够以极高的速度和极低的风险进行创新。
更多文章
第 11 章:支付与订阅 (Stripe)
欢迎来到第五部分,也是我们 SAAS 应用的核心商业逻辑。在前面的章节中,我们构建了坚实的应用基础——从前端 UI、RSC 数据流、安全的 Server Actions 到类型安全的 Drizzle ORM 和 'better-auth' 认证。现在,是时候将我们的应用从一个“项目”转变为一个“产品”了:实现付费订阅。
第 19 章:集成 AI 能力 (Vercel AI SDK)
在本书的前 18 章中,我们已经构建了一个极其坚实的 SAAS 基础。我们拥有了支付 (Stripe)、认证 (better-auth)、数据库 (Drizzle)、DevOps (GitHub Actions) 和功能发布 (Feature Flags) 的所有核心组件。现在,是时候为我们的 SAAS 注入“智...
第 7 章:架构师的十字路口:BaaS vs. ORM
欢迎来到第四部分。在这里,我们将从 '如何实现' 暂时跳出,进入 '为何这样选' 的架构师思维。对于一个全栈 SAAS 应用,有两个最关键的决策将决定你的开发速度、扩展性和长期成本:数据库和认证。