A Complete Guide to User Identity and Gray Release in Web Applications
📚 目录
- 前置知识:浏览器请求的生命周期
- 身份识别三剑客:URL、Cookie、Token
- [首次访问 vs 后续访问](#首次访问 vs 后续访问)
- [静态资源 vs 动态接口请求](#静态资源 vs 动态接口请求)
- 跨域场景下的身份传递
- 跨设备登录与账号同步
- 单点登录(SSO)原理
- 灰度发布:用户分流与版本管理
- 回滚机制与前后端交互
- 完整实战案例
1. 前置知识:浏览器请求的生命周期
1.1 从输入网址到页面显示
想象你在浏览器地址栏输入 https://www.myshop.com,会发生什么?
Step 1: 浏览器发送 HTTP 请求 (请求 HTML)
↓
Step 2: 服务器返回 HTML 文档
↓
Step 3: 浏览器解析 HTML,发现需要 CSS、JS、图片等资源
↓
Step 4: 浏览器再次发送多个请求,获取这些资源
↓
Step 5: 浏览器执行 JS 代码
↓
Step 6: JS 可能发起 API 请求(如获取用户信息)
关键点:
- Step 1 和 Step 2 :此时 JS 还没执行 ,浏览器拿不到
localStorage的数据 - Step 4:静态资源请求(CSS、JS、图片)也是在 JS 执行之前
- Step 6 :只有到这一步,JS 才能手动读取
localStorage并发送 API 请求
1.2 核心认知
💡 浏览器请求 HTML 和静态资源时,JS 还没有执行,所以无法访问
localStorage、sessionStorage或 JS 变量。
这就是为什么我们需要 Cookie 和 URL 参数 来在初始阶段传递信息。
2. 身份识别三剑客:URL、Cookie、Token
2.1 三者对比
| 特性 | URL 参数 | Cookie | Token (LocalStorage) |
|---|---|---|---|
| 自动发送 | ❌ 需要手动拼接 | ✅ 浏览器自动携带 | ❌ 需要 JS 手动添加到请求头 |
| HTML 请求可用 | ✅ | ✅ | ❌ |
| 静态资源请求可用 | ❌ | ✅ | ❌ |
| API 请求可用 | ✅ | ✅ | ✅ |
| 跨域传递 | ✅ (在 URL 中) | ❌ (默认不跨域) | ✅ (JS 手动设置) |
| 安全性 | ⚠️ 低 (URL 可见) | ✅ 可设置 HttpOnly | ⚠️ 易受 XSS 攻击 |
| 生命周期 | 一次性 | 可设置过期时间 | 永久 (除非清除) |
2.2 三者的角色定位
┌─────────────┬──────────────┬─────────────────────────┐
│ 技术 │ 角色 │ 比喻 │
├─────────────┼──────────────┼─────────────────────────┤
│ URL 参数 │ 引导者 │ 介绍信、秘密口令 │
│ Cookie │ 维持者 │ 会员手环、门禁卡 │
│ Token │ 认证者 │ 身份证、护照 │
└─────────────┴──────────────┴─────────────────────────┘
应用场景:
- URL 参数:首次访问引导、灰度版本指定、分享链接
- Cookie:会话保持、自动登录、版本标记
- Token:API 认证、跨域请求、服务间通信
3. 首次访问 vs 后续访问
3.1 第一次访问(匿名用户)
场景 :用户首次访问 https://www.myshop.com
javascript
// 浏览器请求
GET https://www.myshop.com
Headers:
(无 Cookie)
服务器能拿到什么?
- ✅ IP 地址
- ✅ User-Agent
- ✅ Referer (如果有)
- ❌ Cookie (首次访问没有)
- ❌ 用户 ID (还没登录)
- ❌ Token (JS 还没执行)
服务器如何处理?
- 按比例或规则分配版本(如 10% 灰度)
- 在响应中写入 Cookie 标记用户
javascript
// 服务器响应
HTTP/1.1 200 OK
Set-Cookie: user_version=1.0.5; Path=/; Max-Age=86400
Set-Cookie: user_id=guest_abc123; Path=/; Max-Age=86400
Content-Type: text/html
<html>...</html>
3.2 第二次访问(已有 Cookie)
场景:用户第二天再次访问
javascript
// 浏览器请求
GET https://www.myshop.com
Headers:
Cookie: user_version=1.0.5; user_id=guest_abc123
服务器能拿到什么?
- ✅ Cookie 中的
user_version和user_id - ✅ 可以根据 Cookie 确定用户版本,保持一致性
关键差异:
第一次:没有 Cookie → 按规则分配 → 写入 Cookie
第二次:有 Cookie → 直接读取 → 保持版本一致
3.3 URL 参数指定版本
场景 :测试人员访问 https://www.myshop.com?version=canary
javascript
// 服务器处理优先级
function getVersion(req) {
// 1. 优先:URL 参数(方便测试)
if (req.query.version) return req.query.version;
// 2. 其次:Cookie(保持一致性)
if (req.cookies.user_version) return req.cookies.user_version;
// 3. 最后:比例分配(新用户)
return randomProportionVersion();
}
4. 静态资源 vs 动态接口请求
4.1 静态资源请求(JS、CSS、图片)
特点:
- 通过
<script src="...">,<link href="...">,<img src="...">标签加载 - 浏览器 只会自动携带 Cookie
- 无法携带 LocalStorage 中的 Token
html
<!-- HTML 中的静态资源请求 -->
<script src="/assets/app.js"></script>
<link rel="stylesheet" href="/assets/style.css">
<img src="/assets/logo.png">
浏览器请求:
GET /assets/app.js
Headers:
Cookie: user_version=1.0.5 ← 自动携带
(无 Authorization header) ← 无法携带 Token
鉴权方式:
- ✅ Cookie
- ✅ Referer 检查
- ✅ Origin / Host 检查
- ✅ 签名 URL
- ❌ Token (无法自动携带)
4.2 动态接口请求(API)
特点:
- 通过
fetch()或XMLHttpRequest发起 - JS 可以 手动添加任何请求头
javascript
// JS 中的 API 请求
const token = localStorage.getItem('token');
fetch('/api/user/profile', {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`, // 手动添加 Token
'Content-Type': 'application/json'
},
credentials: 'include' // 同时携带 Cookie
});
浏览器请求:
GET /api/user/profile
Headers:
Cookie: user_version=1.0.5 ← 自动携带
Authorization: Bearer eyJhbGc... ← JS 手动添加
4.3 对照表
| 请求类型 | HTML 入口 | 静态资源 (JS/CSS/图片) | API 接口 |
|---|---|---|---|
| JS 是否执行 | ❌ | ❌ | ✅ |
| 自动携带 Cookie | ✅ | ✅ | ✅ |
| 可携带 Token | ❌ | ❌ | ✅ |
| 可用鉴权方式 | Cookie, URL | Cookie, Referer | Cookie, Token |
5. 跨域场景下的身份传递
5.1 同域请求(最简单)
场景 :前端 https://www.myshop.com 请求 https://www.myshop.com/api/user
javascript
fetch('https://www.myshop.com/api/user');
// Cookie 自动发送,无需任何配置
5.2 跨域请求的基础知识:简单请求 vs 预检请求
跨域请求分为两种类型:简单请求(Simple Request) 和 预检请求(Preflight Request)。
简单请求(Simple Request)
简单请求是浏览器可以直接发送的跨域请求,不会触发 OPTIONS 预检。
简单请求的条件(同时满足):
- 请求方法 :
GET、POST、HEAD之一 - 请求头 :只能是以下字段或浏览器自动添加的字段
AcceptAccept-LanguageContent-LanguageContent-Type(且值只能是以下之一)text/plainmultipart/form-dataapplication/x-www-form-urlencoded
- 无自定义请求头(除了上述允许的)
- 无事件监听器(XMLHttpRequestUpload 对象)
示例:简单请求
javascript
// ✅ 简单请求:GET 方法,无自定义头
fetch('https://api.myshop.com/user');
// ✅ 简单请求:POST + application/x-www-form-urlencoded
fetch('https://api.myshop.com/user', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: 'name=John&age=30'
});
// ❌ 不是简单请求:自定义头 Authorization
fetch('https://api.myshop.com/user', {
headers: {
'Authorization': 'Bearer token123' // 触发预检
}
});
// ❌ 不是简单请求:Content-Type 为 application/json
fetch('https://api.myshop.com/user', {
method: 'POST',
headers: {
'Content-Type': 'application/json' // 触发预检
},
body: JSON.stringify({ name: 'John' })
});
预检请求(Preflight Request)
预检请求是浏览器在发送实际请求前,先发送一个 OPTIONS 请求来询问服务器是否允许跨域。
触发预检请求的情况:
- 请求方法 :
PUT、DELETE、PATCH等 - 自定义请求头 :包含
Authorization、X-Custom-Header等 - Content-Type :
application/json、application/xml等 - 其他非简单请求的特征
预检请求流程:
1. 浏览器发送 OPTIONS 预检请求
↓
2. 服务器返回 CORS 响应头
↓
3. 浏览器检查响应头,判断是否允许
↓
4. 允许 → 发送实际请求
不允许 → 报错(CORS policy)
示例:预检请求
javascript
// 实际请求(会触发预检)
fetch('https://api.myshop.com/user', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer token123',
'X-Custom-Header': 'value'
},
body: JSON.stringify({ name: 'John' })
});
浏览器自动发送的 OPTIONS 请求:
http
OPTIONS /user HTTP/1.1
Host: api.myshop.com
Origin: https://www.myshop.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-type,authorization,x-custom-header
服务器必须响应的头:
http
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://www.myshop.com
Access-Control-Allow-Methods: PUT, POST, GET
Access-Control-Allow-Headers: Content-Type, Authorization, X-Custom-Header
Access-Control-Max-Age: 86400 // 预检结果缓存时间(秒)
后端配置示例(Express + CORS):
javascript
// 自动处理预检请求
app.use(cors({
origin: 'https://www.myshop.com',
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Custom-Header'],
maxAge: 86400 // 预检结果缓存 24 小时
}));
简单请求 vs 预检请求对比
| 特性 | 简单请求 | 预检请求 |
|---|---|---|
| 请求次数 | 1 次 | 2 次(OPTIONS + 实际请求) |
| 性能 | ✅ 更快 | ⚠️ 稍慢(多一次往返) |
| 适用场景 | GET、POST 表单提交 | PUT、DELETE、JSON 提交、自定义头 |
| 浏览器行为 | 直接发送 | 先发 OPTIONS 询问 |
最佳实践:
- 🎯 优先使用简单请求 :如果是 GET 或表单提交,尽量使用
application/x-www-form-urlencoded - 📝 需要 JSON 时 :接受预检请求的开销,使用
application/json - ⚡ 优化预检请求 :设置合理的
Access-Control-Max-Age,减少预检频率
5.3 跨域请求 Cookie(需要配置)
场景 :前端 https://www.myshop.com 请求 https://api.myshop.com/user
javascript
// 前端配置
fetch('https://api.myshop.com/user', {
credentials: 'include' // 允许跨域携带 Cookie
});
// 后端配置(必须)
app.use(cors({
origin: 'https://www.myshop.com', // 允许的源
credentials: true // 允许携带凭证
}));
注意:
- ⚠️ 跨域携带 Cookie 时,后端不能设置
Access-Control-Allow-Origin: * - ✅ 必须指定具体的
Origin - 💡 跨域 Cookie 请求可能是简单请求,也可能触发预检请求(取决于请求方式)
5.4 跨域请求 Token(更灵活)
场景 :前端 https://www.myshop.com 请求 https://another-service.com/api/data
javascript
const token = localStorage.getItem('token');
fetch('https://another-service.com/api/data', {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}` // Token 可以跨域传递
}
});
// 后端只需验证 Token,无需特殊 CORS 配置
Token 的优势:
- ✅ 可以在多个服务之间传递
- ✅ 不受同源策略限制
- ✅ 服务器之间也可以传递
5.5 服务器之间调用
场景:A 服务器需要调用 B 服务器
javascript
// ❌ 错误:浏览器的 Cookie 不会自动传递给 B 服务器
// A 服务器
app.get('/proxy', (req, res) => {
// req.cookies 是浏览器发给 A 的
// 但 A 调用 B 时,这些 Cookie 不会自动发送
axios.get('http://b-server.com/api/data');
});
// ✅ 正确:手动传递 Token 或使用内部密钥
app.get('/proxy', async (req, res) => {
const token = req.cookies.user_token; // 从浏览器 Cookie 获取
const response = await axios.get('http://b-server.com/api/data', {
headers: {
'Authorization': `Bearer ${token}` // 手动添加到请求头
}
});
res.json(response.data);
});
核心原则:
Cookie 是浏览器和服务器之间的约定,服务器之间通信必须手动传递 Token 或密钥。
6. 跨设备登录与账号同步
6.1 问题场景
用户在 A 设备 (手机)已登录游客账号,现在想在 B 设备(电脑)继续使用,但不想输入账号密码。
6.2 方案一:二维码同步(推荐)
流程图
┌─────────────┐ ┌─────────────┐
│ A 设备 │ │ B 设备 │
│ (已登录) │ │ (新设备) │
└──────┬──────┘ └──────┬──────┘
│ │
│ 1. 点击"同步到新设备" │
├─────────────────────────────────►│
│ │
│ 2. 生成一次性授权码 │
│ auth_code = XYZ789 │
│ (有效期 5 分钟) │
│ │
│ 3. 生成二维码显示 │
│ [QR Code: XYZ789] │
│ │
│◄─────────────────────────────────┤ 4. 扫描二维码
│ │ 获取 XYZ789
│ │
│ │ 5. 请求同步
│ │ POST /api/account/sync
│ │ { code: "XYZ789" }
│ │
│ 6. 后端验证 │
│ ✓ 授权码有效 │
│ ✓ 未过期 │
│ ✓ 查找对应账号 │
│ │
│ │◄─ 7. 返回 Token
│ │ { token: "..." }
│ │
│ │ 8. 登录成功 ✓
└──────────────────────────────────┘
后端实现
javascript
// 数据库表:sync_codes
// | id | auth_code | guest_account_id | status | expires_at |
// A 设备:生成同步码
app.post('/api/account/create-sync-code', async (req, res) => {
const userId = req.user.id; // 从 Token 获取
const authCode = generateRandomCode(); // 生成 6 位随机码
await db.insert('sync_codes', {
auth_code: authCode,
guest_account_id: userId,
status: 'pending',
expires_at: Date.now() + 5 * 60 * 1000 // 5 分钟后过期
});
res.json({ code: authCode });
});
// B 设备:使用同步码登录
app.post('/api/account/sync', async (req, res) => {
const { code } = req.body;
// 1. 查找授权码
const syncCode = await db.findOne('sync_codes', {
auth_code: code,
status: 'pending'
});
// 2. 验证
if (!syncCode) return res.status(400).json({ error: 'Invalid code' });
if (syncCode.expires_at < Date.now()) {
return res.status(400).json({ error: 'Code expired' });
}
// 3. 标记为已使用
await db.update('sync_codes', { id: syncCode.id }, { status: 'used' });
// 4. 生成新 Token 给 B 设备
const token = generateToken({ userId: syncCode.guest_account_id });
res.json({ token });
});
前端实现
javascript
// A 设备:生成二维码
import QRCode from 'qrcode';
async function createSyncQR() {
const res = await fetch('/api/account/create-sync-code', {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
const { code } = await res.json();
// 生成二维码
const qrDataURL = await QRCode.toDataURL(code);
document.getElementById('qr-code').src = qrDataURL;
}
// B 设备:扫描并登录
async function scanAndSync(scannedCode) {
const res = await fetch('/api/account/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: scannedCode })
});
const { token } = await res.json();
localStorage.setItem('token', token);
// 登录成功,刷新页面
location.reload();
}
6.3 方案二:魔法链接(备选)
场景:无法使用摄像头,通过链接同步
javascript
// A 设备:生成同步链接
const syncURL = `https://www.myshop.com/sync?token=abc123`;
// 用户复制链接,在 B 设备打开
// B 设备:检测 URL 参数
const urlParams = new URLSearchParams(window.location.search);
const syncToken = urlParams.get('token');
if (syncToken) {
// 自动调用同步接口
syncAccount(syncToken);
}
安全加固:
- ✅ 链接有效期 1-2 分钟
- ✅ A 设备二次确认(WebSocket 通知)
- ✅ 明确警告:"此链接包含登录权限,请勿分享!"
7. 单点登录(SSO)原理
7.1 什么是单点登录
定义:在一个地方登录,多个系统都能访问。
场景:
- 登录 Google 账号,可以访问 Gmail、YouTube、Drive
- 企业内部系统:登录 OA 后,ERP、CRM 都免登录
7.2 SSO 流程
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 应用 A │ │ 应用 B │ │ 认证中心 │
│ (未登录) │ │ (未登录) │ │ (SSO Server)│
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
│ 1. 访问应用 A │ │
│───────────────────────────────────────►│
│ │ │
│ 2. 发现未登录,重定向到认证中心 │
│◄──────────────────────────────────────┤
│ Redirect: https://sso.com/login │
│ ?redirect=https://app-a.com│
│ │ │
│ 3. 用户输入账号密码 │ │
│───────────────────────────────────────►│
│ │ │
│ 4. 认证成功,颁发 Ticket │
│◄──────────────────────────────────────┤
│ Redirect: https://app-a.com │
│ ?ticket=TGT-123456 │
│ │ │
│ 5. 应用 A 验证 Ticket │
│───────────────────────────────────────►│
│ │ │
│ 6. 返回用户信息,登录成功 │
│◄──────────────────────────────────────┤
│ │ │
│ ✓ 已登录 │ │
│ │ │
└───────────────────┼───────────────────┘
│
│ 7. 访问应用 B
│────────────────────────►│
│ │
│ 8. 检测到 SSO Cookie │
│ 自动登录,无需输入密码│
│◄────────────────────────┤
│ │
│ ✓ 已登录 │
7.3 核心机制
认证中心(SSO Server):
- 统一管理用户登录状态
- 颁发 Ticket 或 Token
- 维护全局会话
应用系统:
- 检测到未登录,重定向到认证中心
- 从 URL 获取 Ticket,向认证中心验证
- 验证通过,创建本地会话
关键 Cookie:
TGC (Ticket Granting Cookie):存在认证中心域名下,表示用户已登录- 应用 A、B 都会检查这个 Cookie,实现免登录
8. 灰度发布:用户分流与版本管理
8.1 什么是灰度发布
定义:新版本只发布给部分用户,观察效果后再全量上线。
优势:
- ✅ 降低风险(出问题影响范围小)
- ✅ 快速验证(真实用户反馈)
- ✅ 可回滚(随时切回旧版本)
8.2 灰度分配策略
优先级顺序
javascript
function getUserVersion(req) {
// 1. 优先:数据库固定分配(如内部员工)
const fixedVersion = await db.getUserVersion(req.user?.id);
if (fixedVersion) return fixedVersion;
// 2. 其次:URL 参数(测试用)
if (req.query.version) return req.query.version;
// 3. 再次:Cookie 标记(保持一致性)
if (req.cookies.user_version) return req.cookies.user_version;
// 4. 最后:比例分配(新用户)
return randomProportionVersion(req.ip, config.grayPercent);
}
比例分配算法
javascript
function randomProportionVersion(userId, grayPercent = 10) {
// 方案 1:哈希取模(稳定性好,同一用户永远分到同一组)
const hash = md5(userId);
const num = parseInt(hash.substring(0, 8), 16);
return (num % 100) < grayPercent ? '1.0.5' : '1.0.4';
// 方案 2:随机数(灵活但不稳定)
return Math.random() < (grayPercent / 100) ? '1.0.5' : '1.0.4';
}
8.3 版本资源管理
问题:旧版本资源 404
场景:
10:00 用户打开网页,加载 v1.0.4 的 HTML
10:10 发布 v1.0.5,删除 v1.0.4 的 JS/CSS
10:15 用户点击功能,请求 v1.0.4 的 chunk-abc123.js → 404 ❌
解决方案:资源聚合池
dist/
├─ current/ # 当前入口 HTML
│ ├─ index.html
│ └─ manifest.json
│
├─ assets/ # 最近 N 个版本的静态资源(聚合池)
│ ├─ chunk-abc123.js # v1.0.4
│ ├─ chunk-def456.js # v1.0.4
│ ├─ chunk-xyz789.js # v1.0.5
│ └─ chunk-qwe321.js # v1.0.5
│
└─ versions/ # 每个版本完整备份
├─ 1.0.4/
│ ├─ index.html
│ └─ assets/
└─ 1.0.5/
├─ index.html
└─ assets/
发布流程
bash
# 1. 构建新版本
npm run build # 产出 dist/
# 2. 部署到 current/
cp -r dist/* server/dist/current/
# 3. 聚合资源到 assets/(追加,不覆盖)
cp -r dist/assets/* server/dist/assets/
# 4. 备份完整版本
cp -r dist/ server/dist/versions/1.0.5/
# 5. 清理旧版本(保留最近 5 个版本)
cleanOldVersions(5);
8.4 Nginx 配置
nginx
server {
listen 80;
server_name www.myshop.com;
# 1. 入口 HTML(短缓存)
location / {
root /var/www/dist/current;
try_files $uri /index.html;
# 不缓存 HTML
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# 2. 静态资源(长缓存)
location /assets/ {
root /var/www/dist;
expires 1y;
add_header Cache-Control "public, immutable";
}
# 3. API 转发
location /api/ {
proxy_pass http://backend-server;
proxy_set_header X-Real-IP $remote_addr;
}
}
8.5 前端检测版本更新
javascript
// 方案 1:轮询检测
let currentVersion = window.APP_VERSION; // 从 HTML 注入
setInterval(async () => {
const res = await fetch('/manifest.json');
const data = await res.json();
if (data.version !== currentVersion) {
// 提示用户刷新
showUpdateNotification();
}
}, 60000); // 每分钟检测一次
// 方案 2:WebSocket 推送
const ws = new WebSocket('wss://www.myshop.com/version-notify');
ws.onmessage = (event) => {
const { version } = JSON.parse(event.data);
if (version !== currentVersion) {
showUpdateNotification();
}
};
// 刷新页面
function showUpdateNotification() {
if (confirm('New version available. Refresh now?')) {
location.reload(true); // 强制刷新
}
}
9. 回滚机制与前后端交互
9.1 为什么需要回滚
场景:
- 新版本上线后,发现严重 Bug
- 错误率突然升高
- 用户投诉激增
目标:
- 快速恢复到旧版本
- 最小化用户影响
9.2 回滚流程
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 监控系统 │ │ 后端服务 │ │ 前端用户 │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
│ 1. 检测到错误率升高 │ │
│────────────────────────►│ │
│ │ │
│ │ 2. 决策回滚 │
│ │ 切换版本配置 │
│ │ 1.0.5 → 1.0.4 │
│ │ │
│ │ 3. 清除用户版本映射 │
│ │ DELETE user_version │
│ │ │
│ │ 4. 通知所有在线用户 │
│ │───────────────────────►│
│ │ WebSocket / 轮询 │
│ │ { action: "reload" } │
│ │ │
│ │ │ 5. 前端刷新
│ │◄───────────────────────┤
│ │ GET /index.html │
│ │ │
│ │ 6. 返回旧版本 HTML │
│ │───────────────────────►│
│ │ v1.0.4 │
│ │ │
│ │ │ 7. 加载旧版本资源
│ │◄───────────────────────┤
│ │ GET /assets/chunk... │
│ │ │
│ │ 8. 返回旧版本资源 │
│ │───────────────────────►│
│ │ (从 assets/ 获取) │
│ │ │
│ │ │ ✓ 回滚完成
9.3 后端实现
javascript
// 1. 版本配置管理
const versionConfig = {
current: '1.0.5',
fallback: '1.0.4',
grayPercent: 10,
forceRollback: false // 回滚开关
};
// 2. 回滚 API
app.post('/admin/rollback', async (req, res) => {
// 切换当前版本
versionConfig.current = versionConfig.fallback;
versionConfig.forceRollback = true;
// 清除所有用户的版本映射
await db.clearAllUserVersions();
// 通知所有在线用户
notifyAllUsers({ action: 'reload', version: versionConfig.current });
res.json({ success: true, currentVersion: versionConfig.current });
});
// 3. WebSocket 通知
function notifyAllUsers(message) {
wss.clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(message));
}
});
}
// 4. 入口 HTML 返回
app.get('/', (req, res) => {
const version = versionConfig.forceRollback
? versionConfig.fallback
: getUserVersion(req);
const htmlPath = `/var/www/dist/versions/${version}/index.html`;
res.sendFile(htmlPath);
});
9.4 前端实现
javascript
// 1. WebSocket 连接
let ws;
function connectVersionNotifier() {
ws = new WebSocket('wss://www.myshop.com/version-notify');
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.action === 'reload') {
handleVersionChange(data.version);
}
};
ws.onclose = () => {
// 断线重连
setTimeout(connectVersionNotifier, 5000);
};
}
// 2. 处理版本变更
function handleVersionChange(newVersion) {
// 保存当前页面状态
const currentState = {
path: location.pathname,
scrollY: window.scrollY,
formData: getFormData() // 如果有表单
};
sessionStorage.setItem('pre-reload-state', JSON.stringify(currentState));
// 显示通知
showNotification('System updated, reloading...', 2000);
// 延迟刷新
setTimeout(() => {
location.reload(true); // 强制刷新,绕过缓存
}, 2000);
}
// 3. 恢复页面状态
window.addEventListener('load', () => {
const savedState = sessionStorage.getItem('pre-reload-state');
if (savedState) {
const state = JSON.parse(savedState);
// 恢复滚动位置
window.scrollTo(0, state.scrollY);
// 恢复表单数据
restoreFormData(state.formData);
// 清除状态
sessionStorage.removeItem('pre-reload-state');
}
});
// 4. 备用:轮询检测
function pollVersionCheck() {
setInterval(async () => {
try {
const res = await fetch('/api/current-version');
const { version } = await res.json();
if (version !== window.APP_VERSION) {
handleVersionChange(version);
}
} catch (err) {
console.error('Version check failed:', err);
}
}, 30000); // 每 30 秒检测一次
}
// 启动
connectVersionNotifier();
pollVersionCheck();
9.5 灰度回滚(部分用户)
javascript
// 场景:只回滚灰度用户,不影响稳定版用户
app.post('/admin/rollback-gray', async (req, res) => {
// 1. 查找所有灰度用户
const grayUsers = await db.findAll('user_versions', {
version: '1.0.5'
});
// 2. 将他们回滚到旧版本
await db.updateMany('user_versions',
{ version: '1.0.5' },
{ version: '1.0.4' }
);
// 3. 只通知灰度用户
grayUsers.forEach(user => {
notifyUser(user.id, { action: 'reload', version: '1.0.4' });
});
res.json({ success: true, affectedUsers: grayUsers.length });
});
10. 完整实战案例
10.1 场景描述
项目 :电商网站(www.myshop.com)
目标:发布新版商品详情页,灰度 10% 用户
10.2 技术架构
┌─────────────────────────────────────────────────────┐
│ 用户浏览器 │
│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
│ │ HTML │ │ JS/CSS │ │ localStorage │ │
│ │ (短缓存) │ │ (长缓存) │ │ token: "..." │ │
│ └──────────┘ └──────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────┘
│ │
↓ ↓
┌──────────────────────────────────────────────────────┐
│ Nginx (网关) │
│ • 静态资源:/assets/ → 长缓存 │
│ • 入口 HTML:/ → 短缓存 │
│ • API 转发:/api/ → 后端服务 │
└──────────────────────────────────────────────────────┘
│
↓
┌──────────────────────────────────────────────────────┐
│ Node.js 后端服务 │
│ • 用户身份识别 │
│ • 灰度版本分配 │
│ • API 业务逻辑 │
│ • WebSocket 推送 │
└──────────────────────────────────────────────────────┘
│
↓
┌──────────────────────────────────────────────────────┐
│ MySQL 数据库 │
│ • user_versions (用户版本映射) │
│ • sync_codes (同步码) │
│ • users (用户信息) │
└──────────────────────────────────────────────────────┘
10.3 完整代码实现
数据库设计
sql
-- 用户版本映射表
CREATE TABLE user_versions (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id VARCHAR(255) NOT NULL,
version VARCHAR(50) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP,
INDEX idx_user_id (user_id)
);
-- 同步码表(跨设备登录)
CREATE TABLE sync_codes (
id INT PRIMARY KEY AUTO_INCREMENT,
auth_code VARCHAR(20) NOT NULL UNIQUE,
guest_account_id VARCHAR(255) NOT NULL,
status ENUM('pending', 'used', 'expired') DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP,
INDEX idx_auth_code (auth_code)
);
-- 用户表
CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(255),
email VARCHAR(255),
password_hash VARCHAR(255),
is_guest BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
后端服务(Node.js + Express)
javascript
const express = require('express');
const cookieParser = require('cookie-parser');
const WebSocket = require('ws');
const crypto = require('crypto');
const app = express();
app.use(express.json());
app.use(cookieParser());
// ========== 配置 ==========
const config = {
versions: {
stable: '1.0.4',
canary: '1.0.5'
},
grayPercent: 10, // 灰度比例
forceRollback: false
};
// ========== 灰度分配逻辑 ==========
async function getUserVersion(req) {
// 1. 强制回滚
if (config.forceRollback) {
return config.versions.stable;
}
// 2. URL 参数指定
if (req.query.version) {
setVersionCookie(res, req.query.version);
return req.query.version;
}
// 3. Cookie 已分配
if (req.cookies.user_version) {
return req.cookies.user_version;
}
// 4. 数据库固定分配
if (req.user) {
const dbVersion = await db.query(
'SELECT version FROM user_versions WHERE user_id = ?',
[req.user.id]
);
if (dbVersion.length > 0) {
return dbVersion[0].version;
}
}
// 5. 比例分配
const userId = req.cookies.guest_id || generateGuestId();
const version = hashDistribute(userId, config.grayPercent);
return version;
}
// 哈希分配算法
function hashDistribute(userId, grayPercent) {
const hash = crypto.createHash('md5').update(userId).digest('hex');
const num = parseInt(hash.substring(0, 8), 16);
return (num % 100) < grayPercent
? config.versions.canary
: config.versions.stable;
}
// 设置版本 Cookie
function setVersionCookie(res, version) {
res.cookie('user_version', version, {
maxAge: 24 * 60 * 60 * 1000, // 24 小时
httpOnly: false, // 允许 JS 读取
sameSite: 'lax'
});
}
// ========== 路由 ==========
// 入口 HTML
app.get('/', async (req, res) => {
const version = await getUserVersion(req);
// 设置 Cookie
setVersionCookie(res, version);
// 返回对应版本的 HTML
const htmlPath = `/var/www/dist/versions/${version}/index.html`;
res.sendFile(htmlPath);
});
// 当前版本 API(前端轮询)
app.get('/api/current-version', (req, res) => {
res.json({
version: config.forceRollback
? config.versions.stable
: config.versions.canary
});
});
// 回滚 API(管理员)
app.post('/admin/rollback', async (req, res) => {
config.forceRollback = true;
// 清除所有用户版本映射
await db.query('DELETE FROM user_versions');
// 通知所有在线用户
notifyAllClients({
action: 'reload',
version: config.versions.stable
});
res.json({ success: true });
});
// 跨设备登录:生成同步码
app.post('/api/account/create-sync-code', authenticate, async (req, res) => {
const authCode = Math.random().toString(36).substring(2, 8).toUpperCase();
const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
await db.query(
'INSERT INTO sync_codes (auth_code, guest_account_id, expires_at) VALUES (?, ?, ?)',
[authCode, req.user.id, expiresAt]
);
res.json({ code: authCode });
});
// 跨设备登录:使用同步码
app.post('/api/account/sync', async (req, res) => {
const { code } = req.body;
const result = await db.query(
'SELECT * FROM sync_codes WHERE auth_code = ? AND status = "pending" AND expires_at > NOW()',
[code]
);
if (result.length === 0) {
return res.status(400).json({ error: 'Invalid or expired code' });
}
const syncCode = result[0];
// 标记为已使用
await db.query(
'UPDATE sync_codes SET status = "used" WHERE id = ?',
[syncCode.id]
);
// 生成 Token
const token = generateToken({ userId: syncCode.guest_account_id });
res.json({ token });
});
// ========== WebSocket 推送 ==========
const wss = new WebSocket.Server({ port: 8080 });
function notifyAllClients(message) {
wss.clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(message));
}
});
}
// ========== 启动服务 ==========
app.listen(3000, () => {
console.log('Server running on http://localhost:3000');
});
前端代码(Vue 3)
vue
<template>
<div id="app">
<!-- 版本更新提示 -->
<div v-if="showUpdateNotice" class="update-notice">
<p>New version available!</p>
<button @click="reloadApp">Refresh Now</button>
</div>
<!-- 主内容 -->
<router-view />
<!-- 跨设备同步 -->
<button @click="showSyncModal = true">Sync to Other Device</button>
<div v-if="showSyncModal" class="modal">
<h3>Scan QR Code to Sync</h3>
<canvas ref="qrCanvas"></canvas>
<p>Code: {{ syncCode }}</p>
<button @click="closeSyncModal">Close</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import QRCode from 'qrcode';
// ========== 版本检测 ==========
const showUpdateNotice = ref(false);
const currentVersion = window.APP_VERSION;
// WebSocket 连接
let ws;
function connectVersionNotifier() {
ws = new WebSocket('ws://localhost:8080');
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.action === 'reload' && data.version !== currentVersion) {
showUpdateNotice.value = true;
}
};
ws.onclose = () => {
setTimeout(connectVersionNotifier, 5000);
};
}
// 轮询备用
function pollVersionCheck() {
setInterval(async () => {
const res = await fetch('/api/current-version');
const { version } = await res.json();
if (version !== currentVersion) {
showUpdateNotice.value = true;
}
}, 60000);
}
// 刷新应用
function reloadApp() {
// 保存状态
sessionStorage.setItem('pre-reload-path', location.pathname);
location.reload(true);
}
// 恢复状态
onMounted(() => {
const savedPath = sessionStorage.getItem('pre-reload-path');
if (savedPath && savedPath !== location.pathname) {
router.push(savedPath);
sessionStorage.removeItem('pre-reload-path');
}
connectVersionNotifier();
pollVersionCheck();
});
// ========== 跨设备同步 ==========
const showSyncModal = ref(false);
const syncCode = ref('');
const qrCanvas = ref(null);
async function generateSyncCode() {
const token = localStorage.getItem('token');
const res = await fetch('/api/account/create-sync-code', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
}
});
const { code } = await res.json();
syncCode.value = code;
// 生成二维码
QRCode.toCanvas(qrCanvas.value, code, {
width: 200,
margin: 2
});
}
function closeSyncModal() {
showSyncModal.value = false;
syncCode.value = '';
}
// 监听模态框打开
watch(showSyncModal, (newVal) => {
if (newVal) {
nextTick(() => {
generateSyncCode();
});
}
});
</script>
<style scoped>
.update-notice {
position: fixed;
top: 20px;
right: 20px;
background: #4caf50;
color: white;
padding: 15px 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 9999;
}
.update-notice button {
margin-top: 10px;
background: white;
color: #4caf50;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
.modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
padding: 30px;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.2);
text-align: center;
z-index: 9999;
}
.modal canvas {
margin: 20px 0;
}
</style>
CI/CD 发布脚本
bash
#!/bin/bash
# deploy.sh - 灰度发布脚本
VERSION=$1
GRAY_PERCENT=${2:-10}
echo "========== Deploying version $VERSION =========="
# 1. 构建前端
echo "Building frontend..."
npm run build
# 2. 部署到服务器
echo "Deploying to server..."
# 备份完整版本
ssh user@server "mkdir -p /var/www/dist/versions/$VERSION"
scp -r dist/* user@server:/var/www/dist/versions/$VERSION/
# 更新 current 入口
scp -r dist/index.html user@server:/var/www/dist/current/
scp -r dist/manifest.json user@server:/var/www/dist/current/
# 聚合资源到 assets/
ssh user@server "cp -n /var/www/dist/versions/$VERSION/assets/* /var/www/dist/assets/ 2>/dev/null || true"
# 3. 更新后端配置
echo "Updating backend config..."
ssh user@server "curl -X POST http://localhost:3000/admin/update-config \
-H 'Content-Type: application/json' \
-d '{\"canary\":\"$VERSION\",\"grayPercent\":$GRAY_PERCENT}'"
# 4. 清理旧版本(保留最近 5 个)
echo "Cleaning old versions..."
ssh user@server "cd /var/www/dist/versions && ls -t | tail -n +6 | xargs rm -rf"
echo "========== Deployment complete =========="
echo "Current version: $VERSION"
echo "Gray percent: $GRAY_PERCENT%"
10.4 完整流程演示
场景 1:新用户首次访问
1. 用户访问 https://www.myshop.com
└─ 浏览器请求:GET /
Headers: (无 Cookie)
2. 后端处理:
✓ 检测无 Cookie
✓ 生成 guest_id: guest_abc123
✓ 哈希分配:hash(guest_abc123) % 100 = 7 < 10
✓ 分配版本:1.0.5 (灰度版)
3. 后端响应:
Set-Cookie: user_version=1.0.5; guest_id=guest_abc123
Content: /versions/1.0.5/index.html
4. 浏览器加载:
✓ 解析 HTML
✓ 请求资源:/assets/chunk-xyz789.js
✓ 执行 JS,连接 WebSocket
5. 用户正常使用 v1.0.5
场景 2:用户第二天再次访问
1. 用户访问 https://www.myshop.com
└─ 浏览器请求:GET /
Headers: Cookie: user_version=1.0.5; guest_id=guest_abc123
2. 后端处理:
✓ 读取 Cookie: user_version=1.0.5
✓ 直接返回对应版本
3. 用户继续使用 v1.0.5(版本保持一致)
场景 3:发现 Bug,紧急回滚
1. 监控系统检测到错误率升高(v1.0.5)
2. 管理员执行回滚:
POST /admin/rollback
3. 后端处理:
✓ 设置 forceRollback = true
✓ 清除数据库中所有 user_versions
✓ WebSocket 推送:{ action: 'reload', version: '1.0.4' }
4. 所有在线用户收到通知:
✓ 弹出提示:"System updated, reloading..."
✓ 2 秒后自动刷新
5. 用户刷新后:
└─ 浏览器请求:GET /
Headers: Cookie: user_version=1.0.5 (旧 Cookie)
└─ 后端检测到 forceRollback=true
✓ 忽略 Cookie
✓ 强制返回 v1.0.4
6. 用户加载 v1.0.4,Bug 解决 ✓
场景 4:跨设备登录
1. 用户在手机(A 设备)点击"同步到其他设备"
2. 前端请求:
POST /api/account/create-sync-code
Headers: Authorization: Bearer <token>
3. 后端生成同步码:
✓ auth_code: A3X9K2
✓ 有效期:5 分钟
✓ 返回前端
4. 前端生成二维码显示
5. 用户在电脑(B 设备)扫描二维码
6. B 设备请求:
POST /api/account/sync
Body: { code: "A3X9K2" }
7. 后端验证:
✓ 同步码有效
✓ 查找对应账号:guest_abc123
✓ 生成新 Token 返回
8. B 设备保存 Token,登录成功
✓ localStorage.setItem('token', token)
✓ 刷新页面,进入已登录状态
9. A、B 设备现在共享同一账号 ✓
11. 总结与最佳实践
11.1 核心概念总结
| 概念 | 核心作用 | 生命周期 |
|---|---|---|
| URL 参数 | 首次引导、版本指定 | 单次请求 |
| Cookie | 会话保持、自动携带 | 可设置过期 |
| Token | 身份认证、跨域传递 | 登录会话 |
| 灰度发布 | 降低风险、逐步验证 | 发布周期 |
| 回滚机制 | 快速恢复、止损 | 异常处理 |
11.2 最佳实践
✅ DO(推荐)
-
入口 HTML 短缓存,静态资源长缓存
nginxlocation / { add_header Cache-Control "no-cache"; } location /assets/ { add_header Cache-Control "max-age=31536000, immutable"; } -
保留历史版本资源,避免 404
bashdist/ ├─ assets/ # 聚合最近 5 个版本 └─ versions/ # 完整备份 -
Cookie + Token 组合使用
- Cookie:HTML 请求、静态资源
- Token:API 请求、跨域场景
-
灰度分配使用哈希算法,保持稳定性
javascripthash(userId) % 100 < grayPercent -
WebSocket + 轮询双保险
- 主要:WebSocket 实时推送
- 备用:轮询兜底
❌ DON'T(避免)
-
不要删除旧版本资源
bash# ❌ 错误:直接覆盖 rm -rf dist/assets/* cp new-assets/* dist/assets/ # ✅ 正确:追加保留 cp -n new-assets/* dist/assets/ -
不要在 URL 中传递敏感 Token
javascript// ❌ 错误 fetch(`/api/data?token=${token}`); // ✅ 正确 fetch('/api/data', { headers: { 'Authorization': `Bearer ${token}` } }); -
不要假设浏览器缓存会自动更新
- 始终考虑用户长时间不刷新的情况
- 通过版本检测主动通知
-
不要在静态资源请求中依赖 LocalStorage
<script>和<link>无法携带 Token- 静态资源鉴权使用 Cookie 或 Referer
11.3 常见问题 FAQ
Q1: 用户清除了 Cookie,会不会重新分配版本?
A: 是的。Cookie 被清除后,用户下次访问会被视为新用户,重新走分配逻辑。可以通过数据库持久化用户 ID 和版本映射来避免。
Q2: 灰度用户能否手动切回稳定版?
A: 可以。提供一个开关,让用户选择退出灰度:
javascript
// 用户点击"退出灰度"
fetch('/api/opt-out-gray', { method: 'POST' });
// 后端更新数据库
db.update('user_versions', { user_id }, { version: 'stable' });
Q3: Service Worker 会不会导致旧版本无法更新?
A: 可能会。建议:
- Service Worker 中检测版本变化,自动
skipWaiting() - 或者不缓存
index.html,只缓存静态资源
Q4: 多个后端服务如何共享用户版本信息?
A: 使用共享存储(Redis)或统一网关:
javascript
// Redis 存储
redis.set(`user:${userId}:version`, '1.0.5', 'EX', 86400);
// 其他服务读取
const version = await redis.get(`user:${userId}:version`);
Q5: 如何监控灰度效果?
A: 埋点统计:
javascript
// 前端上报
window.APP_VERSION = '1.0.5';
function reportMetric(event, data) {
fetch('/api/metrics', {
method: 'POST',
body: JSON.stringify({
version: window.APP_VERSION,
event,
data,
timestamp: Date.now()
})
});
}
// 使用
reportMetric('page_view', { page: '/product/123' });
reportMetric('error', { message: err.message });
12. 延伸阅读
推荐资源
-
灰度发布
-
前端缓存策略
-
身份认证
相关技术
- 特性开关(Feature Flags):比灰度发布更细粒度的功能控制
- A/B 测试:对比不同版本的用户行为和转化率
- 蓝绿部署:两套环境无缝切换
- 金丝雀部署:灰度发布的别名
结语
前端灰度发布和用户身份识别是一个系统工程,涉及:
- 浏览器机制:Cookie、LocalStorage、请求生命周期
- 网络协议:HTTP 缓存、跨域、WebSocket
- 后端架构:版本管理、资源聚合、数据库设计
- 运维流程:CI/CD、监控告警、回滚机制
理解这些概念的核心原理,才能在实际项目中灵活运用,打造稳定、可控、用户体验良好的发布流程。
希望这篇指南能帮助你从零开始,掌握现代 Web 应用的身份识别与灰度发布技术!
💡 提示:本文档使用 Markdown 编写,推荐使用支持 Mermaid 图表的阅读器查看完整效果。
📝 贡献:如有问题或建议,欢迎提交 Issue 或 Pull Request。