ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spec系统:构建能力单一真相源,解决需求不一致

Spec系统:构建能力单一真相源,解决需求不一致

1. 为什么“能力”需要一个单一真相源

做技术管理或者系统架构的人,大概都经历过这样的场景:产品经理拿着一份需求文档说“这个功能要支持批量导入”,后端开发说“接口文档里写的是单条提交”,前端说“我看到的原型图上只有一个上传按钮”,测试说“验收标准里没提批量的事”。四个人手里四份文档,四份文档四个版本,最后上线的东西跟谁说的都不一样。

这不是谁不负责任,而是信息在传递过程中天然会发散。每经过一个人的手,就多一层理解偏差;每经过一次文档更新,就多一个可能不同步的副本。Spec 系统要解决的就是这个问题——让“能力”这件事只有一个真相源,所有人看同一份东西,所有人基于同一份东西做判断。

所谓“能力”,在这个语境下可以理解为一个系统对外承诺能做的事情。比如“支持批量导入”“支持按标签筛选”“支持导出为 CSV”,这些都是能力。能力不是代码,不是接口,不是 UI,而是介于需求和实现之间的一层抽象描述。它比需求更具体,比代码更稳定,比接口更贴近业务语义。

为什么这一层特别需要单一真相源?因为它是所有下游工作的起点。接口设计要从能力推导,测试用例要从能力推导,用户文档要从能力推导,甚至销售跟客户承诺什么也要从能力推导。如果能力的描述本身就有多个版本,那下游所有东西都会跟着乱。

我见过一个团队,能力描述散落在三个地方:Jira 的 epic 描述里、Confluence 的 PRD 里、以及某个资深工程师的脑子里。前两个经常不同步,第三个只在他不请假的时候有效。结果就是每次排期都要先花半小时对齐“这个功能到底包不包含某某场景”,效率极低。

Spec 系统的核心思路很简单:把能力从各种文档和脑子里抽出来,放到一个结构化的、可版本控制的、机器可读的地方。这个地方就是单一真相源。所有其他文档要么引用它,要么从它生成,不允许手写副本。

这个思路跟数据库设计里的“第三范式”有点像——消除冗余,让每个事实只存一处。只不过这里的事实不是数据行,而是“系统能做什么”这件事。

2. Spec 系统的结构长什么样

2.1 从自然语言到结构化声明

Spec 系统不是让你写更长的文档,而是让你用更结构化的方式写更短的声明。核心是把“系统应该做什么”拆成一条条独立的、可验证的声明。

每条声明包含几个要素:能力标识、行为描述、前置条件、预期结果、验收标准。听起来像测试用例?确实像,但粒度更粗,更偏向业务语义而不是技术实现。

举个例子。假设我们要描述“用户可以用邮箱登录”这个能力。在 Spec 系统里,它可能长这样:

能力: 邮箱登录 前置: 用户已注册且邮箱已验证 行为: 用户提交邮箱和密码 预期: 系统验证凭证,成功则返回会话令牌,失败则返回错误提示 验收: - 正确凭证返回 200 和令牌 - 错误密码返回 401 和“密码错误” - 未验证邮箱返回 403 和“请先验证邮箱”

注意这里没有提任何技术细节——没说用什么加密算法,没说令牌是 JWT 还是别的,没说数据库怎么查。这些是实现层的事,Spec 层只关心“系统承诺做什么”。

这种写法的好处是,产品、开发、测试都能看懂,而且都能基于它做自己的工作。产品确认行为描述是否符合业务预期,开发确认前置条件和预期结果是否可实现,测试直接把验收标准转成测试用例。

2.2 关键词 GWT 与 SHALL 的用法

在 Spec 系统里,GWT 和 SHALL 是两个高频出现的词。GWT 是 Given-When-Then 的缩写,是一种描述行为的模板。Given 描述前置状态,When 描述触发动作,Then 描述预期结果。

上面那个邮箱登录的例子,用 GWT 重写就是:

Given 用户已注册且邮箱已验证 When 用户提交正确的邮箱和密码 Then 系统返回 200 和会话令牌

这种写法比自由文本更严谨,因为它强制你把“在什么条件下”和“发生什么动作”分开。很多需求文档的歧义就来自这两者混在一起。比如“用户登录后可以看到订单列表”,这句话里“登录后”是条件还是动作?不清楚。用 GWT 拆开就清楚了。

SHALL 则是用来表达强制性的。在 Spec 里,SHALL 表示“必须做到”,SHOULD 表示“建议做到”,MAY 表示“可选”。这跟 RFC 2119 里的用法一致。

为什么要在 Spec 里区分 SHALL 和 SHOULD?因为不是所有能力都是同等重要的。有些是核心承诺,做不到就是 bug;有些是锦上添花,做不到也能上线。如果不区分,测试就不知道哪些失败要阻塞发布,哪些可以记录后放行。

我自己的习惯是,每个 Spec 文件里 SHALL 级别的声明不超过十条。超过十条说明这个能力太笼统了,应该拆成多个能力。比如“用户管理”这个能力如果写了二十条 SHALL,那大概率应该拆成“用户注册”“用户登录”“用户信息修改”“用户注销”四个能力。

2.3 文件组织与命名约定

Spec 文件怎么组织,直接决定了它能不能长期维护。我见过两种极端:一种是所有 Spec 塞在一个大文件里,几千行,改一处要翻半天;另一种是每个小功能一个文件,结果几百个文件散在目录里,找都找不到。

比较合理的做法是按业务域分目录,每个能力一个文件。比如:

specs/ auth/ email-login.spec phone-login.spec password-reset.spec order/ create-order.spec cancel-order.spec query-order.spec payment/ alipay-payment.spec wechat-payment.spec

文件名用 kebab-case,跟能力标识保持一致。这样从能力名就能直接定位到文件,从文件也能反推能力名。

每个文件内部的结构也要统一。我通常要求包含这几个部分:能力概述、前置条件、行为声明(GWT 列表)、验收标准、依赖关系、变更记录。变更记录很重要,因为 Spec 是单一真相源,它的历史就是系统能力演化的历史。哪天产品问“这个能力是什么时候加的”,翻变更记录比翻聊天记录靠谱得多。

注意:Spec 文件不要写实现细节。我见过有人在 Spec 里写“用 bcrypt 加密密码”,这是实现决策,不是能力声明。能力声明只说“系统验证凭证”,怎么验证是开发的事。把实现写进 Spec 会导致两个问题:一是限制了开发的技术选型自由,二是实现变了 Spec 就要跟着改,失去了稳定性。

3. 单一真相源怎么落地而不是停留在口号

3.1 从“写文档”到“写声明”的思维转变

大部分团队说要做单一真相源,最后都变成了“我们再建一个 Confluence 空间吧”。这不是单一真相源,这只是又一个副本。真正的单一真相源要求:其他所有地方都不允许手写能力描述,只能引用或生成。

这个要求听起来简单,执行起来阻力很大。因为写自由文档比写结构化声明舒服多了。自由文档可以含糊其辞,可以“大概”“可能”“视情况而定”,结构化声明不行,你必须把条件写清楚。

我推动这个转变的时候,用的办法是“先做减法再做加法”。先让团队把现有文档里关于能力的描述全部删掉,换成一句“能力定义见 Spec 系统”。然后逼着大家去 Spec 系统里补。补的过程中自然就会发现哪些能力描述不清楚,哪些有歧义。

这个过程大概会持续两到三周,期间会有各种不适。开发说“我就想直接写代码不想写 Spec”,产品说“我写 PRD 的时候顺手就把能力写了为什么还要单独写”。这时候需要一点强制力——比如把 Spec 完成度纳入排期准入条件,Spec 没写完不允许进入开发。

3.2 版本控制与变更流程

Spec 系统必须放在版本控制里,这是底线。放在 Confluence 或者在线文档里的 Spec 不是真正的单一真相源,因为你看不到 diff,不知道谁在什么时候改了什么。

用 Git 管理 Spec 有几个好处。第一,每次变更都有记录,谁改的、改了什么、为什么改,都在 commit message 里。第二,可以走 Pull Request 流程,变更需要 review 才能合并。第三,可以跟代码仓库放在一起,Spec 变更和代码变更在同一个 PR 里,保证同步。

变更流程我建议这样设计:任何人可以提 Spec 变更的 PR,但合并需要至少一个产品和一个开发的 approve。产品确认业务语义没问题,开发确认技术可实现。合并后自动触发下游同步——比如生成测试用例骨架、更新 API 文档的能力列表、通知相关方。

这里有个坑:不要允许直接 push 到主分支。我见过一个团队为了图方便,允许直接改 Spec 文件,结果两个月后 Spec 和实际能力完全对不上,因为改的人从来不通知别人。单一真相源的前提是变更可见,直接 push 就破坏了可见性。

3.3 与需求管理工具的边界

Spec 系统和 Jira 这类需求管理工具是什么关系?我的观点是:Jira 管“做什么、谁来做、什么时候做完”,Spec 管“系统承诺什么”。两者有交集但不重合。

一个 Jira ticket 可能对应多个 Spec 变更,一个 Spec 变更也可能跨多个 ticket。比如“支持批量导入”这个能力,可能拆成三个 ticket:后端接口、前端页面、导入模板校验。但 Spec 里只有一条能力声明。

所以不要把 Spec 塞进 Jira 的描述字段里。Jira 的描述字段是给这个 ticket 的执行者看的,Spec 是给所有相关方看的。混在一起会导致 Spec 被 ticket 的临时上下文污染。

比较合理的做法是:Jira ticket 里引用 Spec 文件路径和具体的能力标识,但不复制内容。执行者点链接跳到 Spec 系统看详情。这样 Spec 更新了,所有引用它的 ticket 自动看到最新版。

4. 实操中容易踩的五个坑

4.1 坑一:Spec 写成了实现说明书

这是最常见的坑。写着写着就开始写“系统调用某某接口”“数据存到某某表”“用某某算法”。一旦 Spec 里出现技术名词,它就不再是能力声明了,而是实现文档。

为什么这个坑容易踩?因为写 Spec 的人往往是开发,开发脑子里全是实现细节,不写出来难受。而且写实现细节显得很专业,写纯行为描述反而显得“虚”。

判断标准很简单:如果换一种技术栈实现同样的能力,Spec 需不需要改?如果需要改,说明写进实现细节了。比如“用户提交邮箱和密码”不需要改,“用 bcrypt 验证密码”就需要改。

4.2 坑二:验收标准写成了测试步骤

验收标准和测试步骤的区别是什么?验收标准说“系统应该返回 401”,测试步骤说“打开 Postman,输入 URL,选择 POST 方法,填入 body,点击 Send,检查状态码”。前者是能力的一部分,后者是验证能力的方法。

把测试步骤写进 Spec 会导致 Spec 变得极其冗长,而且跟具体测试工具绑定。换一个测试框架,Spec 就要重写。更糟的是,测试步骤会掩盖真正的验收标准——读者要在几十行操作步骤里找那一句“应该返回 401”。

我的做法是:Spec 里只写验收标准,测试步骤放在测试代码里。测试代码引用 Spec 的能力标识,这样 Spec 变了测试代码能感知到,但 Spec 本身保持简洁。

4.3 坑三:SHALL 滥用导致优先级失效

前面说了 SHALL 表示必须做到。但很多团队写着写着,所有声明都变成 SHALL,因为“这个也很重要”“那个也不能少”。结果就是没有优先级,所有失败都阻塞发布,团队被 Spec 绑架。

我见过一个极端案例:一个中等复杂度的能力写了四十多条 SHALL,结果每次发版前测试都要跑几百个用例,跑完要两天。后来我让他们重新评审,把 SHALL 压到八条,其余改成 SHOULD,发布效率立刻上来了。

压 SHALL 的方法:问“如果这条做不到,用户会不会完全无法使用这个能力?”如果答案是“不会,只是体验差一点”,那就改成 SHOULD。如果答案是“会,整个能力就废了”,才保留 SHALL。

4.4 坑四:Spec 和代码不同步

Spec 是单一真相源,但如果代码改了 Spec 没改,那 Spec 就变成了“单一谎言源”。这个问题在快速迭代的团队里特别严重,因为改代码的人往往不是写 Spec 的人。

解决这个问题的根本办法是把 Spec 变更纳入 Definition of Done。一个任务完成的标志不是代码合并,而是代码合并且 Spec 已更新。如果 Spec 不需要更新,要在 PR 里明确说明“本次变更不涉及能力变化”。

另一个辅助手段是自动化检查。比如在 CI 里加一步,检查本次 PR 修改的代码文件是否关联了 Spec 变更。如果没有关联,给出警告但不阻塞。这个警告能提醒开发者“你是不是忘了改 Spec”。

4.5 坑五:把 Spec 当成一次性文档

有些团队把 Spec 当成项目启动时的文档,写完就扔在一边,后续迭代只改代码不改 Spec。这等于白做。

Spec 是活的,它应该跟代码一样频繁更新。每次能力有变化,Spec 就要变。每次发现 Spec 描述不准确,就要修正。Spec 的价值不在于写出来的那一刻,而在于持续维护的过程中形成的共识。

我自己的习惯是每周花半小时过一遍最近变更的 Spec,看看有没有描述过时或者歧义的地方。这个习惯坚持了两年,Spec 的准确率一直保持在 95% 以上。

5. 让 Spec 系统真正产生价值的三个杠杆

5.1 从 Spec 生成测试用例骨架

Spec 里的 GWT 声明天然就是测试用例的模板。Given 对应测试的前置准备,When 对应测试的执行动作,Then 对应断言。所以可以写一个简单的生成器,把 Spec 文件转成测试框架的骨架代码。

比如上面邮箱登录的例子,生成出来的 pytest 骨架大概是:

def test_email_login_success(): # Given 用户已注册且邮箱已验证 user = create_verified_user(email="test@example.com", password="correct") # When 用户提交正确的邮箱和密码 response = client.post("/login", json={"email": "test@example.com", "password": "correct"}) # Then 系统返回 200 和会话令牌 assert response.status_code == 200 assert "token" in response.json()

开发只需要填充 Given 和 When 的具体实现,Then 的断言已经生成好了。这能省掉大量写测试样板代码的时间,而且保证测试覆盖了所有 Spec 声明。

提示:生成器不需要很复杂,一个几百行的脚本就够。关键是 Spec 的格式要统一,否则解析起来很麻烦。这也是为什么前面强调文件结构要规范。

5.2 用 Spec 做变更影响分析

当某个能力要变更时,怎么知道会影响哪些下游?如果 Spec 里记录了依赖关系,这个问题就很好回答。

比如“邮箱登录”这个能力依赖“用户注册”和“邮箱验证”两个能力。如果“邮箱验证”的流程变了,“邮箱登录”的验收标准可能需要跟着调整。通过 Spec 的依赖图,可以快速找到所有受影响的能力。

更进一步,如果测试用例是从 Spec 生成的,那 Spec 变更后可以自动标记哪些测试用例需要重新评审。这比人工判断靠谱得多,也快得多。

5.3 把 Spec 作为新人入职的第一份材料

新人入职最怕什么?怕没人告诉他系统到底能做什么。看代码太细,看 PRD 太旧,问老人太打扰。Spec 系统正好填补这个空白。

一个维护良好的 Spec 系统,新人花半天就能通读一遍,知道系统有哪些能力、每个能力的边界在哪、能力之间怎么依赖。这比任何入职文档都管用。

我带的每个新人,第一周的任务就是读 Spec 并提问题。读不懂的地方就是 Spec 写得不好的地方,顺手就改了。这样 Spec 在服务新人的同时也得到了持续改进。

6. 关于 Spec 系统的一些个人体会

Spec 系统不是银弹,它解决的是“能力描述不一致”这个问题,不解决“需求不合理”或者“排期太紧”的问题。如果团队本身沟通就很顺畅,Spec 系统的收益可能没那么明显。但如果团队超过十个人,或者系统能力超过五十个,Spec 系统的价值就会指数级上升。

我自己的经验是,Spec 系统的投入产出比在第三个月开始转正。前两个月主要是建设成本,写 Spec、搭工具、改流程,感觉在额外做功。第三个月开始,因为对齐成本下降、返工减少、新人上手加快,收益开始超过成本。

另一个体会是,Spec 系统要“轻”。不要试图把什么都塞进去,不要追求大而全。从最核心的十个能力开始,把格式跑通,把流程跑顺,再逐步扩展。一上来就搞几百个 Spec,大概率会烂尾。

最后说一个反直觉的点:Spec 系统最大的价值可能不是“让所有人看同一份文档”,而是“逼着所有人把模糊的地方想清楚”。写 Spec 的过程本身就是一次深度思考,很多歧义在写的过程中就暴露了,根本等不到下游发现。这个价值比文档同步本身更大。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进