生成式软件工程
搜索
开始阅读/第 4 部分 · 工程判断/第 10 章

需求:从愿望到规格

「做一个教务系统」这句话里藏着几百个没人做过的决定,需求工程干的就是把它们一个个挖出来。

这一页讲什么
  • 教务系统为什么是一片需求泥潭,几条规则一相遇会发生什么;
  • 一个虚拟培养方案怎样把 160 学分、课程替代、先修课、交换学分、重修计分搅在一起;
  • 一个特殊值怎样让整个系统瘫掉;
  • 最小可行产品怎样帮我们发现需求,又怎样掩盖困难;
  • 用例、决策表、基于性质的测试、需求可追踪性,以及为什么「暗箱操作」也是一种需求。

一、一句「做个教务系统」拆出多少人

需求工程在学校里一向不好讲:学生通常见不到真正的客户,课程作业也很少大到需要认真处理需求冲突。现在做出一个能用的原型容易多了,人人都有机会体验一次产品经理的工作,而身边的教务系统就是最好的靶子。

从入学到毕业,学生会经历学籍登记、培养方案选择、选课、考试、成绩认定、毕业审核;教师要开课、管理名单、登记成绩;教务员要排课、处理学籍变化和各类申请;教务处要执行政策、汇总统计;财务和辅导员各有各的业务。表面上是几张表、几个页面,背后牵着一大群角色。

按经典做法把需求方逐一访谈一遍,可能期末考试都结束了。更麻烦的是三条现实约束:

生活类比 寝室四个人商量「周末出去吃一顿」,听起来是个小事。有人说吃火锅,有人说吃清淡点,有人说预算人均五十,有人说要能带外校来的朋友,还有人说最好离地铁近。每个人说的都对,可这些要求凑在一起时,那家火锅店既清淡不了、也压不到五十。需求澄清的功夫,就在把这些条件摆到同一张桌面上。

二、一份虚拟培养方案:规则相遇的地方

为了感受这种复杂性,构造一份虚拟培养方案就够了。假设一位 2024 级学生在 2026 年转入计算机专业,适用目标专业的 2024 版培养方案:

每一条单独看都不难,写个 if 判断就完了。真正的困难在它们相遇的那一刻:一位转专业学生恰好参加过交换,交换课程还没有完成认定,先修课正在等待缓考成绩,选课窗口却马上关闭。四条规则同时压在一个人身上,系统该给出什么结论?

更现实的一层:总有某种经过线下批准的特殊情况,没来得及变成系统里的规则。最后只能留一个「视为满足」的兜底入口,也就是大家调侃的暗箱操作。这个入口同样有它的需求:谁有权批准、豁免哪一项要求、适用于哪个学生和哪一版方案、理由是什么。如果系统表达不了这些内容,工程师就只能直接改数据库,把眼前的问题先救过去。

flowchart TB
  A["160 学分 四类分别达标"] --> R["毕业审核结论"]
  B["数学要求 高数A 或 高数B加桥接课"] --> R
  C["先修课 算法要求先过数据结构"] --> R
  D["交换课程合抵 同一份学分不重复计"] --> R
  E["重修只计一次 成绩单留历次"] --> R
  F["人工豁免 谁批 批了什么 为什么"] --> R
关键结论 需求工程的难点几乎都落在「规则的组合」和「规则之外的例外」上,单条规则通常不难写。每多一条规则,组合情况按乘法增长;每多一类例外,就多出一个必须有明确处理方式的分支。把这两件事显式写出来,是把泥潭变成工程的第一步。

三、一个特殊值让整个系统瘫掉

有一件小事很有冲击力。有人绕过前端页面的校验,把一位同学的缺课次数直接改成了 -1。结果,教务系统里所有会读取这位同学选课记录的地方,全都出了问题。

没有定位到某一处具体的代码错误,因为错误的来源不在某一行。某个看起来只属于这个字段的假设——次数是自然数——已经被许多功能共同依赖:统计出勤率时做除法、生成预警名单时比较大小、显示在表格里时按位数留宽度。一个特殊值进入系统,问题就沿着这些依赖扩散出去。

这件事暴露的是「隐含假设没有表达」。字段类型写着整数,可整数允许负数;校验只在前端做,绕过页面就没有第二道关。系统里没有一处地方写明白「这个值必须非负」,于是每个依赖它的功能都各自默认它非负,一旦不成立,全线失守。

注意 靠前端拦住的规则,等于没有规则。任何一个能直接改数据、能调接口、能跑脚本的人都能绕过去,而 Agent 恰恰是最擅长绕过去的角色:它会找到最直接的路。把约束写进必须经过的那一层,并且让违反时立刻报错,才叫真的写下了这条规则。

四、MVP 帮我们发现需求,也掩盖困难

AI 让最小可行产品变得非常容易。先实现最重要的功能,把一个能点的东西交给用户,往往比继续讨论抽象需求有效得多。看到课表、选课按钮和毕业审核结果,用户马上就能指出来:「这门课我在交换的时候已经修过了,为什么系统还要求我再修一次?」试用本身就在帮忙发现没有说出口的需求。

它同时也掩盖困难。原型走通的是正常路径,转专业、课程替代、规则追溯、人工批准这些角落还没有露出獠牙。第一次演示顺利,只说明这些默认选择在演示场景下能凑成一个可运行的系统。

更大的考验是需求变了以后怎么办。把「一门交换课程替代一门本校课程」改成「允许两门交换课程合抵一门必修课」,受牵连的地方遍布各处:课程对应关系、学分计算、毕业审核、成绩单显示,还有一串测试。更难的是一堆必须回答的问题:

有一种说法叫「没有银弹」,借的是传说中能杀死狼人的特殊武器,形容人们期待一招制胜的心态。快速生成原型很有价值,需求冲突、历史包袱和持续变化仍然要有人处理。

五、用例与决策表

把愿望变成规格,需要一些具体的表达工具。两个最实用的分别是用例(use case)和决策表(decision table)。

1. 用例:谁在什么情况下想做成什么

用例不描述界面长什么样,它描述一次完整的使用过程:谁发起、想达到什么、正常路径怎么走、岔路有哪些。以「学生申请课程替代」为例:

用例:申请课程替代
参与者:学生(发起)、教务员(审批)
前置条件:学生已修完待替代的课程并取得成绩
主流程:
  1. 学生选择一门已修课程和一门培养方案中的必修课
  2. 系统按培养方案版本检查这两门课是否允许互相替代
  3. 学生填写申请理由并提交
  4. 教务员查看成绩单与培养方案,作出批准或驳回
  5. 系统记录审批人、时间、适用的方案版本与依据
备选流程:
  2a. 系统判定不允许替代 → 提示原因,允许学生转人工申请
  4a. 教务员驳回 → 记录驳回理由,学生可补充材料后重新提交
后置条件:批准后该必修课在毕业审核中视为已满足

写用例的过程就在发现问题。第 5 步的「适用的方案版本」是一句看起来平淡的话,它意味着系统必须能回答「当时按哪一版规则批的」,否则三年后没人解释得清这个结论为什么成立。

2. 决策表:把条件的组合摆到桌面上

决策表专门对付「几个条件同时起作用」的场合。以学校超市的优惠为例:「学生有折扣」「满一定金额有优惠」,这两句话放在一起,立刻逼出几个必须由店家拍板的决定:学生折扣能不能和满减叠加?满减的门槛按原价算还是按折后价算?

条件与动作情形一情形二情形三情形四
是否学生是是否否
原价是否满额是否是否
先打学生折扣是是否否
再减满减金额是否是否

表格本身不复杂,它的价值在于把含糊的组合情况摆到人面前,让「能不能叠加」「门槛按哪个价」这类问题无法再被跳过。培养方案那张表也一样:学分够不够、类别齐不齐、替代批没批、先修过没过,四个条件一交叉,就是十几行必须逐行给出答案的决策。

3. 基于性质的测试

规格还该顺手写下一类要求:不管输入是什么,输出都应该满足什么。基于性质的测试(property-based testing)就是先表达这类普遍性质,再自动生成大量输入去找反例。以排序为例,「输出是有序的」这条性质还不够,因为一个永远返回空列表的函数也满足它;还需要补一条:输出必须保留输入中的每个元素及其出现次数。

写性质的过程往往就是在发现自己究竟想让程序做什么。「任何一次课程替代批准,都必须在毕业审核里体现为对应必修课已满足」「任何一次成绩更正,都不能让已通过的课程变回未通过而不留记录」,这两句话比一堆界面截图更能定义系统该有的行为。

关键区别 用例说清「一次使用过程怎么走」,决策表说清「条件组合各是什么结论」,性质说清「所有情况下都必须成立什么」。三者面对的是同一份需求的不同侧面:前者管流程与例外,中者管分支覆盖,后者管不变量。三样都写下来,实现者才有机会不用猜。

六、需求可追踪性:让改动找得到落点

需求写完不算结束。假设退课规则改成「仅开学第一周允许」,你需要知道哪些地方会受影响:界面上的退课按钮、权限检查、状态迁移逻辑、还有对应的测试。如果当初没建立联系,只能靠搜索关键词和记忆去捞。

生活类比 寝室搬家前给每个纸箱贴一张便利贴,写清「装了谁的什么东西」。搬完要找一个充电器,看便利贴就知道在几号箱;哪一箱丢了,也知道丢的是什么。不贴的话,箱子上写「杂物」,五个箱子一模一样,就只能一个个拆开翻。

需求可追踪性(requirements traceability)关心的就是这件事:需求、设计、实现、测试之间是否还保持联系。它的实用价值体现在三个时刻:改需求时知道要动哪里,出问题时知道是哪个需求被违反了,验收时知道每一条需求有没有对应的验证手段。

建立联系的方法没有多神秘。给每条需求一个稳定的编号,让设计文档、代码注释、测试名称引用这个编号,就是最朴素也最有效的一种。重要的是编号要稳定:需求文字可以改,编号尽量别动,否则引用全部失效。

flowchart LR
  N["需求编号 R-12 退课仅开学第一周"] --> D["设计 状态迁移与权限检查"]
  D --> I["实现 退课接口的时间判定"]
  D --> T["测试 第一周内可退 第二周拒绝"]
  I --> V["验证记录 通过"]
  T --> V

七、为什么「暗箱操作」也有需求

很多人第一次做需求分析时,会本能地把「特批」「走个后门」「手工改数据」当成脏东西,想把它们从系统里赶出去。这个想法在纸面上很干净,放到现实里会立刻翻车。

现实的条件是:政策会更新,领导会批准,学生的事今天不办就要耽误一年。系统还没表达这项规则,可业务已经发生了。这时候工程师能做的无非两种:给一个受控的例外入口,或者直接改数据库。后者看起来省事,代价是几个月后再也没人分得清那个「已满足毕业条件」是正常审核、正式特批,还是一次救火时的误操作。

所以例外的需求该这样写:

把这五项写下来,例外就从「偷偷改库」变成了系统里一种可查询、可审计、可解释的记录。软件只是现实过程在信息系统里的一个有损投影,现实里已经发生的事,系统迟早要表达;主动给它一个正当位置,比假装它不存在要好得多。

关键结论 需求写到最后,最有价值的部分常常是那些「大家心照不宣、从没写下来」的约定:特殊情况的处理、人工介入的边界、谁在什么时候可以破例。这些东西不写进规格,就会以改数据库、改代码、口头通知的形式散落在各处;写进规格,它们才成为可以讨论、可以检查、可以交接的东西。
现在就做 三件事,三十分钟:
① 挑一个你熟悉的校园场景(食堂充值、图书馆借书、寝室报修都行),写一份用例:参与者、前置条件、主流程五步、两条备选流程、后置条件;
② 为「退课」列一张决策表:条件用「是否开学第一周」「是否已过期中」「是否有未结清费用」,四到八行,逼自己给每一种组合一个结论;
③ 写下两条性质,要求它们在任何情况下都成立,例如「任何一次成绩更正,都必须留下更正前后的记录」。写完问一个 AI 助手:这三样东西里,哪一处最可能在实现时被悄悄替你做决定。

自测

1. 虚拟培养方案里,每条规则单独都不难,困难为什么会突然出现?
困难出现在规则相遇的时候:一位转专业学生恰好参加过交换,交换课程还没完成认定,先修课正在等缓考成绩,选课窗口马上关闭。多条规则同时约束同一个人,组合情况按乘法增长;再加上总有线下批准的特殊情况还没变成系统规则,最终只能依赖一个「视为满足」的兜底入口。需求工程的难点落在规则组合与例外处理上。
2. 把缺课次数改成 -1,为什么许多功能一起出问题?
因为「次数是非负整数」这个假设被许多功能共同依赖:算出勤率要做除法、生成预警要比较大小、显示要按位数留宽度。特殊值进入系统后,问题沿着这些依赖扩散。字段类型写着整数,可整数允许负数;校验只在前端做,绕过页面就没有第二道关。系统里没有一处写明「必须非负」,每个功能就各自默认它成立。
3. 最小可行产品怎样帮忙发现需求,又掩盖了什么?需求一变,有哪些连锁问题要回答?
帮忙的地方在于:用户看到能点的东西,马上就能指出没说出口的要求,比如交换过的课为什么还要再修。掩盖的地方在于:原型走通的是正常路径,转专业、课程替代、规则追溯、人工批准这些角落还没露头。需求变化后的连锁问题包括:已经认定过的学分要不要按新规则重算,已经批准的特例在新规则下是否继续有效,成绩单上按旧规则算出的结论显示时要不要改。
4. 用例、决策表、基于性质的测试各自解决哪一类问题?
用例说清一次完整的使用过程:谁发起、正常路径怎么走、岔路有哪些、前后置条件是什么,管的是流程与例外。决策表把几个条件的组合摆到桌面上,逼着每一种组合都给出结论,管的是分支覆盖。基于性质的测试先表达「任何输入下都必须成立什么」,再自动生成输入去找反例,管的是不变量。三者面对同一份需求的不同侧面。
5. 需求可追踪性在什么时刻派上用场?最朴素的建立办法是什么?
派上用场的三个时刻:改需求时知道要动哪些地方(界面、权限、状态迁移、测试),出问题时知道是哪条需求被违反,验收时知道每条需求有没有对应的验证手段。最朴素的办法是给需求一个稳定的编号,让设计文档、代码注释、测试名称引用这个编号;编号要稳定,需求文字可以改,编号尽量别动。
6. 为什么「暗箱操作」也需要写成需求?写的时候要包含哪几项?
因为现实里政策会更新、领导会批准、学生的事今天不办就耽误一年,而系统还没表达这项规则。不给受控的例外入口,工程师只能直接改数据库;几个月后就分不清那个「已满足毕业条件」是正常审核、正式特批,还是救火时的误操作。要写清:谁有权批准、豁免了哪一条、适用于谁与哪一版方案、依据是什么、有效期到什么时候。