「网络这块差不多了」
卷二读到最后一章,团队常会给出一个模糊结论:「网络这块差不多了,可以看商业化了。」
这句话在评审会上很难被反驳,因为没有人能立刻说出「差不多」差在哪。于是下一次事故来临时,复盘才会发现:协议兼容没测、回滚没演练、下载失败没有降级路径。
本节的评审器不接受「差不多」。它只认三种状态,并且把没有证据的「通过」直接降级。
九组检查项
卷二的范围对应九组:
| 组 | 核心问题 | 对应章节 |
|---|---|---|
| 协议与兼容 | 新旧版本能不能互通 | 第 2 章 |
| 连接与重连 | 断了能不能回来 | 第 3 章 |
| 离线与冲突 | 离线操作会不会重复或丢失 | 第 4 章 |
| 同步与预测 | 客户端有没有越权 | 第 5 章 |
| 战报与赛季 | 结果能不能复盘 | 第 6 章 |
| 流量与心跳 | 连接成本是否可控 | 第 7 章 |
| 资源与分包 | 资源结构是否清晰 | 第 8–9 章 |
| 热更与灰度 | 更新能不能安全放量 | 第 10–11 章 |
| 下载与 CDN | 资源能不能到达 | 第 12 章 |
九组不是并列清单,而是卷二判断链的九个关节。任何一组全空,都说明那条链断了一节。
把门禁写成代码
评审器的规则只有三条:
pass必须有非空证据,否则降级为partial;partial与fail都是阻塞项;- 空评审直接判失败,而不是「默认通过」。
// file: src/lab/review-gate-net.ts
// tier: L1
// verify: node --test test/lab/review-gate-net.test.ts
export type ReviewStatus = "pass" | "partial" | "fail";
export interface ReviewItem {
readonly id: string;
readonly group: string;
readonly status: ReviewStatus;
readonly evidence: string;
}
export interface GroupSummary {
readonly group: string;
readonly pass: number;
readonly partial: number;
readonly fail: number;
}
export interface GateResult {
readonly passed: boolean;
readonly total: number;
readonly counts: Readonly<Record<ReviewStatus, number>>;
readonly groups: readonly GroupSummary[];
readonly blockers: readonly ReviewItem[];
readonly openGroups: readonly string[];
readonly reasons: readonly string[];
}
export const REVIEW_GROUPS = [
"协议与兼容",
"连接与重连",
"离线与冲突",
"同步与预测",
"战报与赛季",
"流量与心跳",
"资源与分包",
"热更与灰度",
"下载与 CDN",
] as const;
export function evaluateGate(items: readonly ReviewItem[]): GateResult {
const reasons: string[] = [];
const normalized: ReviewItem[] = [];
for (const item of items) {
if (item.status === "pass" && item.evidence.trim() === "") {
reasons.push(`${item.id}: pass without evidence counts as partial`);
normalized.push({ ...item, status: "partial" });
continue;
}
normalized.push(item);
}
const counts: Record<ReviewStatus, number> = { pass: 0, partial: 0, fail: 0 };
for (const item of normalized) {
counts[item.status] += 1;
}
const groups = REVIEW_GROUPS.map((group) => {
const scoped = normalized.filter((item) => item.group === group);
return {
group,
pass: scoped.filter((item) => item.status === "pass").length,
partial: scoped.filter((item) => item.status === "partial").length,
fail: scoped.filter((item) => item.status === "fail").length,
};
});
const blockers = normalized.filter((item) => item.status !== "pass");
const openGroups = groups.filter((group) => group.pass === 0).map((group) => group.group);
if (normalized.length === 0) {
reasons.push("no review items");
}
if (counts.partial > 0) {
reasons.push(`${counts.partial} partial item(s) block the gate`);
}
if (counts.fail > 0) {
reasons.push(`${counts.fail} failed item(s) block the gate`);
}
return {
passed: normalized.length > 0 && blockers.length === 0,
total: normalized.length,
counts,
groups,
blockers,
openGroups,
reasons,
};
}
export function failingGroups(result: GateResult): readonly string[] {
return result.groups.filter((group) => group.fail > 0).map((group) => group.group);
}
export function formatGate(result: GateResult): string {
const header = "group\tpass\tpartial\tfail";
const rows = result.groups.map(
(group) => `${group.group}\t${group.pass}\t${group.partial}\t${group.fail}`,
);
return [header, ...rows].join("\n");
}
export const DEFAULT_REVIEW: readonly ReviewItem[] = [
{ id: "protocol", group: "协议与兼容", status: "pass", evidence: "src/net/message-envelope.ts" },
{ id: "contract", group: "协议与兼容", status: "pass", evidence: "src/net/contract-check.ts" },
{ id: "reconnect", group: "连接与重连", status: "pass", evidence: "src/net/connection-fsm.ts" },
{ id: "backoff", group: "连接与重连", status: "pass", evidence: "src/net/backoff.ts" },
{ id: "offline", group: "离线与冲突", status: "pass", evidence: "src/net/offline-queue.ts" },
{ id: "sync", group: "同步与预测", status: "pass", evidence: "src/net/predict.ts" },
{ id: "report", group: "战报与赛季", status: "pass", evidence: "src/net/battle-report.ts" },
{ id: "traffic", group: "流量与心跳", status: "pass", evidence: "src/net/traffic-ledger.ts" },
{ id: "pack", group: "资源与分包", status: "pass", evidence: "src/resource/pack-plan.ts" },
{ id: "hotfix", group: "热更与灰度", status: "pass", evidence: "src/hotfix/rollout.ts" },
{ id: "cdn", group: "下载与 CDN", status: "pass", evidence: "src/cdn/downloader.ts" },
{ id: "telemetry", group: "下载与 CDN", status: "pass", evidence: "src/net/heartbeat.ts" },
];
evaluateGate 返回四类信息:是否通过、状态计数、按组汇总、阻塞项列表。另外两个派生视图很有用:
openGroups:一项都没通过的组,用来识别「完全没开始」;failingGroups:存在fail的组,用来识别「做了但坏了」。
两个口径不能混。一个组可能全是 partial(做了没证据),也可能有 pass 有 fail(部分可用部分坏)。前者是没验证,后者是验证出问题,处理方式完全不同。
一次方舟的评审记录
假设方舟在赛季前跑一次评审,12 项里有 2 项没过:
| 项 | 组 | 状态 | 证据 | 处理 |
|---|---|---|---|---|
| contract | 协议与兼容 | partial | 兼容用例只覆盖新增字段 | 补删除字段与未知字段用例 |
| hotfix | 热更与灰度 | fail | 回滚演练超时 | 阻塞版本,先修回滚链路 |
| 其余 10 项 | — | pass | 文件路径与测试输出 | 保持 |
结果:passed: false,blockers 是这两项;openGroups 为空(每一组至少有一项通过),failingGroups 是「热更与灰度」。
这个区分决定了处理顺序:热更组有 fail,说明能力存在但坏了,优先级最高;协议组只有 partial,说明能力没验证完,可以排在其后。先修坏的,再补没验的。
与卷一评审器的关系
卷一的评审器与本节结构相似,但分组完全不同:
| 维度 | 卷一 | 卷二 |
|---|---|---|
| 分组 | 分层、生命周期、配置、资源、UI、帧率、存档、区域化、可观测 | 协议、连接、离线、同步、战报、流量、资源、热更、下载 |
| 检查对象 | 客户端内部结构 | 客户端与外部系统的契约 |
| 典型证据 | 模块文件、状态机测试 | 契约测试、补丁测试、演练记录 |
| 空白组含义 | 内部能力缺失 | 跨边界能力缺失 |
两卷的门禁都要求「证据可复现」,但卷二的证据更多来自演练与线上查询,而不是单测。这不是标准降低了,而是检查对象变了:协议兼容与回滚能力,本来就不是一个纯函数能证明的。
进入卷三之前
卷三讨论商业化与发行工程。进入之前,有四个问题必须能回答:
- 支付与广告的客户端边界在哪? 如果卷二还没界定「哪些状态归服务端权威」,卷三的订单与对账就无从谈起。
- 活动配置走哪条更新通道? 热更、配置下发还是商店版本,决定了活动能不能按时上线。
- 归因与埋点如何穿过网络层? 弱网下事件丢失、重试重复,都会污染归因数据。
- 发行渠道的包体差异谁负责? 渠道 SDK、分包策略与审核周期,需要在卷三的发行工程里统一。
四个问题里,前两个直接依赖卷二;后两个是卷二向卷三的接口。答不上来,说明卷二的热更与流量章节还没落地。
双案例的收口表
把九组门禁套到两款产品上,得到两张可复核的表:
| 项目 | 消消 | 方舟 |
|---|---|---|
| 协议与兼容 | pass | pass |
| 连接与重连 | pass | pass |
| 离线与冲突 | pass | partial(离线仅云存档) |
| 同步与预测 | 不适用(回合制) | pass |
| 战报与赛季 | pass(轻量战报) | pass |
| 流量与心跳 | pass | pass |
| 资源与分包 | pass | pass |
| 热更与灰度 | pass | pass |
| 下载与 CDN | pass | pass |
「不适用」不是「通过」,评审时应记录为范围外,并在汇总里单列。把不适用算成 pass,会让通过率虚高,也会掩盖真正缺失的组。
带病进入卷三
如果只能回答其中两三个问题,也可以进入卷三,但必须把未答项登记为「带病进入」:
| 未答项 | 影响 | 修复版本 |
|---|---|---|
| 活动配置更新通道 | 活动可能延迟上线 | 卷三第 4 章前 |
| 归因事件去重 | 买量数据偏高 | 卷三第 6 章前 |
登记的意义不是免责,而是排期。允许带病进入,不允许假装没病——这句话在卷二同样适用。
与相邻章节的关系
本节依赖 13.1 的指标矩阵与 13.2 的交付流水线:前者提供数值证据,后者提供发布证据。附录 C 是本节检查项的来源,本节把清单变成可运行门禁。
读到这里,卷二的判断链已经闭合:从网络现实出发,经协议、连接、离线、同步、战报、流量,再到资源、分包、热更、灰度与下载,最后收口为指标与流水线两张表。卷三会在这条链上接入收入、渠道与发行。
小结
- 卷二评审有九组:协议、连接、离线、同步、战报、流量、资源、热更、下载。
- 门禁只认 pass/partial/fail,
pass无证据自动降级,空评审判失败。 openGroups识别完全没开始的组,failingGroups识别做了但坏了的组。- 先修
fail,再补partial;两类问题的处理顺序不能反。 - 「不适用」要单列,不能算成通过,否则通过率虚高。
- 进入卷三前回答四个问题:支付边界、活动通道、归因链路、渠道包体。
- 允许带病进入,但未答项必须登记并排期。
阅读导航:上一节:13.2 交付流水线与发布门禁 · 下一节:目录与学习路径 。
常见错误与边界
- 把评审做成打分。 平均分掩盖空白组;门禁只认通过与否,不做加权。
- 允许「部分通过」。 设计有、证据无,等于没验证;按规则直接降级。
- 证据写成「已确认」。 证据必须是路径、命令输出或线上查询,能被人复现。
- 把不适用算成通过。 消消没有强同步,不代表同步能力通过;应单列为范围外。
- 只看总分不看分组。 九组里一组全空,总分仍可能很高;
openGroups专门抓这个。 - 混淆空白组与失败组。 前者没开始,后者做坏了;处理优先级不同。
- 评审只做一次。 每个大版本重跑,保留历史,趋势比单次结论更有信息量。
- 把评审器当自动化测试。 它校验记录自洽,不替代契约测试、补丁测试与演练。
- 带病进入变成常态。 每个版本都带病,说明门禁没有约束力。
- 删除旧评审记录。 没有历史,就无法区分「一直如此」与「本版本退步」。
- 忽略演练证据。 回滚能力必须用演练证明,文档与流程图不算证据。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。