10.01 可验证需求:把 IM 消息规则写成可检查的约定
返回第十卷 · 先修:01.03 控制流与函数 · 学习路线
本章示例给出按约定推导的预期结果。学习时应把自己的实际观察另行记录;阅读代码、手工推演和真实执行是不同的证据。
本章怎样接上前面的知识
你在 01.03 已经用普通函数检查消息文本,认识了 if、switch、提前返回和多个返回值。现在向前追问一步:为什么这条消息应该通过,那条应该拒绝?判断依据从哪里来?
假设虚构用户 u-a 要给 u-b 准备一条消息,最初的要求只有一句:“支持正常聊天,消息不能太长。”两位工程师都可能写出能运行的程序,却对空格、中文、上限和成功状态作出不同解释。本章教你先把这些分歧变成可核对的约定,再用已经学过的 Go 表达其中一个小模型。
先修是 01.01–01.03 的起步内容:变量、字符串、比较与逻辑条件、普通函数和返回值。递归、闭包不属于本章必需先修。测试框架、集合、指针、网络和数据库将在后续展开;本章的用例先用纸面表格与手工观察表达。
| 学习层次 | 小节 | 这一遍留下的成果 |
|---|---|---|
| 起步 | 一、澄清需求;二、观察输入与状态 | 一页消息规则与术语解释 |
| 推导 | 三、决策表;四、边界与反例 | 条件组合表与预期用例 |
| 应用 | 五、失败承诺;六、普通函数实现 | 能解释成功与拒绝的状态变化 |
| 工程深化 | 七、证据与追溯 | 分清预期、实际、结论和版本 |
| 独立练习 | 八、分层练习 | 不看参考答案完成一次需求变更 |
可以分三次阅读:第一至三节写规则,第四至六节推演和实现,第七至八节完成审阅。每次先写自己的答案,再对照后面的解释。
一、从“能聊天”到可检查的规则
1.1 产品目标为什么还不能直接当作判断条件
需求描述期望的行为及其约束;规格是把这些要求明确记录下来的说明;验收是根据约定的条件和证据判断交付是否满足要求。这里的规格可以是一页文字和几张表,不需要先学复杂工具。测试则是选择输入、观察程序行为并与预期比较的一种检查活动;它可以为验收提供证据。
“用户能够交流”解释了要解决什么问题。“文本按原始字节计算,长度在 1 到配置上限之间”才使某一项判断有了清楚的依据。两者都需要:只有目标,容易各自理解;只有数值规则,也可能做出符合表格却不满足用户需要的功能。
先看最初的一句话:
支持正常聊天,消息不能太长。
| 待澄清部分 | 两种可能解释 | 本章选定的教学规则 |
|---|---|---|
| 正常文本 | 有可见文字;或者只要求有字节 | 原始字节数必须大于 0 |
| 太长 | 用户看到的字符数;或者字节数 | 使用原始字符串的字节数 |
| 上限 | 小于上限;或者允许恰好等于 | 允许等于上限 |
| 空白 | 删除首尾空白后判断;或者保持输入 | 保持输入,不自动删除 |
| 聊天种类 | 单聊、群聊、通知;或者其他类别 | 本章只认识 single、group |
| 大小写 | GROUP 与 group 相同;或者不同 | 必须准确匹配,大小写敏感 |
| 成功 | 本地符合规则;或者对方已收到 | 本章只确认本地校验通过 |
| 被拒绝后 | 清空输入;或者保留给用户修改 | 保留草稿与已有本地记录 |
这张表的第三列是课程作出的选择。真实工作中,工程师需要与需求负责人确认,并说明不同选择的影响。例如去除空格可能减少误发,也可能破坏用户有意排版的内容。把选择记录下来,后续才知道这是需求变化,还是程序违反了原约定。
1.2 先固定讨论边界
本章讨论的是:给出 kind、body 和 maxBytes,判断这份输入是否符合本地规则。kind 是会话种类,body 是正文,maxBytes 是最大字节数。u-a、u-b 帮助我们理解业务,但这里没有验证身份的代码,不能因为用了这两个名字就认为权限已经实现。
本地校验指在当前程序中检查输入。它的成功没有包含网络发送、持久保存、设备接收或已读反馈。以后学习这些能力时,会继续扩展约定与证据。
明确范围也帮助识别尚未解决的问题。例如同一文本提交两次,本章分别进行两次本地判断;没有消息身份或去重规则。不能因为正文相同,就擅自把第二次当作重复消息丢弃。
1.3 给规则编号,使讨论能指向同一件事
以下是教学规格 v1 的输入规则。规则编号是文档中的稳定名称,例如 R01。它不需要出现在最终用户界面上,作用是让需求、用例和修改记录彼此对应。
| 编号 | 输入规则 | 不满足时的结果标识 |
|---|---|---|
| R01 | maxBytes > 0 | BAD_LIMIT |
| R02 | kind 为 single 或 group | BAD_KIND |
| R03 | body 的原始字节数大于 0 | EMPTY |
| R04 | 字节数不超过 maxBytes,等号合法 | TOO_LONG |
| R08 | 同时违反多项时,按 R02→R01→R03→R04 报告第一项 | 第一项对应的标识 |
BAD_LIMIT 等只是本章约定的字符串结果标识。它们便于核对具体拒绝原因;中文界面文案可以另行说明。改变中文措辞与改变机器读取的标识可能影响不同的使用者,不能认为它们总是同一项变化。
检查顺序沿用 01.03 综合程序:先确定会话种类,再校验文本的配置、空值与长度。R08 的编号为后续状态规则 R05–R07 留出了位置。编号的大小只是定位约定,这里明确写出的顺序才决定检查先后。
1.4 例子能帮助澄清,但例子不等于完整规则
若上限为 6:
"hello"有 5 字节,应通过。"你好"有 6 字节,应通过。"你好呀"有 9 字节,应拒绝超限。""有 0 字节,应拒绝空文本。" "有 1 字节,应通过。
这些具体 UTF-8 字面量的字节数是确定的。Go 的 string 是字节序列,len 对字符串计算字节数;不能据此推出所有人眼看到的字符都占同样大小。Go 语言规范:字符串类型与长度
如果只记录 "hello" 成功、"你好呀" 失败,仍然无法判断 6 字节是否允许,也无法知道空白规则。规则负责说明一类输入,例子负责让解释变得具体,并检查我们有没有误解规则。
本节停下来做:把“正常消息”拆成三个需要澄清的问题,再为每个问题写一个能区分不同解释的输入。不要先看自己习惯怎样写
if。
二、用输入、输出和状态描述一次处理
2.1 输入与状态有什么区别
输入是一次操作直接接收的值。状态是操作开始前已经存在、操作结束后还需要观察的值。某个值是输入还是状态,取决于你正在讨论的边界。
对纯校验函数而言,kind、body、maxBytes 都是输入;函数返回 ok 与 reason。对整个本地演示而言,用户草稿和上一次校验记录属于需要保留的状态。
本章用三个普通变量表示状态:
| 名字 | 含义 | 示例初始值 |
|---|---|---|
draft | 用户当前草稿 | "你好" |
lastAccepted | 最近一次通过并记录的正文 | "上一条" |
acceptedCount | 本次本地演示已记录的通过次数 | 2 |
这里的次数没有跨重启保存,也不是服务端消息数。把它定义成一个小型本地记录,是为了观察拒绝前后有没有误改数据。
输出是操作直接产生、交给观察者的结果,例如函数的返回值或终端上打印的一行文本。状态变化也是需要观察的结果,但不能只读一行“成功”就忽略变量是否按约定更新。
2.2 先画纸面前后对照
上限为 6、会话为 single,一次操作的预期可以这样记录:
| 场景 | 操作前 | 返回与判断 | 操作后 |
|---|---|---|---|
| 合法文本 | 草稿 你好,次数 2,最近记录 上一条 | 通过 | 草稿仍为 你好,次数 3,最近记录 你好 |
| 超长文本 | 草稿 你好呀,次数 2,最近记录 上一条 | TOO_LONG | 三个值全部保持原样 |
| 未知会话 | 草稿 你好,次数 2,最近记录 上一条 | BAD_KIND | 三个值全部保持原样 |
每一行是独立场景,都重新设置自己的初始状态,不表示先跑第一行、再接着跑第二行。是否沿用上一操作的结果必须单独说明,否则观察到次数不同可能只是准备步骤不同。
2.3 前置条件、后置条件和接受条件要分清
前置条件是调用者在进入某个操作前需要保证的条件。后置条件是在约定的前置成立且操作按约定完成后,操作应当保证的结果。它们共同说明双方责任。
例如,一个很小的计算函数可以只对“参数在 0 到 9 之间”承诺加一后的结果。若调用者传入其他值,超出了那份契约的定义范围。另一种设计会主动检查所有可表示的整数,对无效值返回明确拒绝。两种都能说明白,但不能混着写。
本章的消息校验函数采用后一种方式:传入 maxBytes <= 0 时,也必须返回定义好的结果。“上限要大于 0”是接受条件;不能把它当成无需检查非法配置的理由。
本章的返回契约是:
| 情形 | ok | reason | 校验函数的其他动作 |
|---|---|---|---|
| 全部输入规则满足 | true | 空字符串 | 不打印,不修改业务状态 |
| 某项不满足 | false | 第一项拒绝标识 | 不打印,不修改业务状态 |
这使调用者能明确处理结果。false 配空原因、true 配拒绝原因,都会使约定难以解释;后面的用例也要检查结果之间是否一致。
2.4 不变量是反复操作中需要保持的性质
不变量是在指定观察点应当持续成立的性质。我们为本地演示加一条容易核对的状态要求:正常状态下,acceptedCount 必须在 0 到 10 之间,包含两端。
这里的观察点是一次处理开始与结束。单次成功最多加一;达到 10 后停止记录更多通过次数。这个 10 是为了把演示限制在可完整推演的范围内,不是 IM 消息容量建议。
状态规则如下:
| 编号 | 规则 |
|---|---|
| R05 | 次数小于 0 或大于 10,报告 BAD_STATE,保留原状态 |
| R06 | 次数等于 10 时状态合法但已满,报告 LOCAL_FULL,保留原状态 |
| R07 | 输入通过且次数在 0–9 时,次数加一、最近记录改为本次正文;草稿保留 |
| R09 | 任意拒绝都不改变草稿、次数、最近记录;可输出拒绝原因 |
为什么这能保持不变量?从合法初始状态出发,次数为 0–9 时成功后落在 1–10;次数为 10 时不再增加;拒绝时次数不变。每种允许路径结束后都仍在 0–10 内。
如果有人把初始次数设为 -1,R05 要求拒绝并保留 -1。这只能证明程序没有继续污染状态,不能说拒绝操作已经修复了不变量。发现坏状态和修复坏状态是两个不同的需求。
2.5 “什么都不改变”需要限定观察范围
副作用是除了返回结果之外对外部可观察状态或环境造成的影响,例如修改外部记录、写文件或打印日志。本章校验函数不产生这些动作,main 负责显示和修改自己的本地状态。
R09 的承诺对象是三个业务状态值。打印一行拒绝原因可以发生;终端输出自然有变化。如果把失败承诺写成“世界上任何状态都不改变”,程序甚至无法反馈错误,要求本身就不适用。
一个有用的写法是:“本次拒绝不修改草稿、最近记录和通过次数;允许显示原因。”这样,需求能指导我们观察具体对象。
三、决策表把组合条件和检查顺序说清楚
3.1 为什么只列成功条件还不够
“上限正确、会话认识、正文非空、长度合法就通过”说明了成功入口,但没有回答同时出错时报告什么。
例如 maxBytes=0、kind="other"、body="" 同时违反多个要求。按 R08,第一项结果是 BAD_KIND。如果产品希望把所有问题同时返回,就需要不同的输出形式和新的约定;当前一个 reason 只报告一个原因。
决策表把条件组合与结果对应起来。下面一行表示一类输入,而不是某个具体字符串。
3.2 五行覆盖纯校验函数的全部情况
| 规则行 | 会话认识 | 上限大于 0 | 正文非空 | 长度不超限 | 结果 |
|---|---|---|---|---|---|
| D1 | 否 | — | — | — | false, BAD_KIND |
| D2 | 是 | 否 | — | — | false, BAD_LIMIT |
| D3 | 是 | 是 | 否 | — | false, EMPTY |
| D4 | 是 | 是 | 是 | 否 | false, TOO_LONG |
| D5 | 是 | 是 | 是 | 是 | true, "" |
— 表示该列不影响本行已确定的结果。它并不宣告输入合法。例如 D1 中,即使配置无效或正文为空,结果仍然先报告未知会话。
这五行具有两个有用的性质:
- 互斥:一份具体输入只落在其中一行。D2 要求会话合法,所以不会与 D1 同时命中。
- 穷尽:任何已经获得的
kind、body、maxBytes都能沿着这组判断走到某行。每一项都回答“是”或“否”,没有第三种未定义结果。
这里的穷尽针对当前有限的判断条件。它没有证明读文件、解析请求、认证用户这些尚未建模的过程也被覆盖。
3.3 状态更新要有自己的决策表
纯校验函数不接收 acceptedCount。输入通过之后,main 再按下面的表判断是否记录:
| 输入校验结果 | 当前次数 | 完整处理结果 | 状态变化 |
|---|---|---|---|
| 拒绝 | 任意值 | 使用输入拒绝原因 | 不变 |
| 通过 | 小于 0 或大于 10 | BAD_STATE | 不变 |
| 通过 | 等于 10 | LOCAL_FULL | 不变 |
| 通过 | 0–9 | 本地校验通过并记录 | 按 R07 更新 |
完整处理的顺序因此是:先 R02→R01→R03→R04,再检查 R05/R06,最后执行 R07。若正文为空且次数已经为 10,当前结果是 EMPTY,不会先报告已满。这是本章明确选择的顺序。
纯校验通过与完整本地处理成功在这里有一个差别:次数已满时,消息输入仍然可能合法,但这次演示不再增加记录。把操作边界写清楚,才能解释两个结果为什么没有矛盾。
3.4 从决策表推出代码结构
D1–D4 都是拒绝后立即结束,适合写成一组带 return 的守卫条件。守卫条件就是先拦住当前操作不能继续的情况。01.03 已经使用过这种写法,现在能说明每个分支的需求依据了。
如果把空文本检查放到最前面,是否只是重构?对于只含单个错误的例子,结果可能不变;对于 maxBytes=0 且正文为空的例子,第一拒绝原因会变化。这已经改变了 R08 所定义的可观察行为,不能仅凭“通过与否相同”说外部行为完全没变。
3.5 练习:识别一张重叠的表
假设另一张表写成:
| 条件 | 结果 |
|---|---|
| 正文为空 | 拒绝 |
| 长度不超过 6 | 通过 |
空文本同时满足两行。表本身没有说优先哪一行,也没有要求第二行正文非空。修改方法是:将通过条件补为“正文非空且长度不超过 6”,并补齐配置与会话条件;或者像上面的 D1–D5 一样按有明确前提的完整规则行表达。
本节停下来做:用
kind="other"、空正文、上限 0,逐列走一遍 D1–D5,并解释为什么只报告一项问题。
四、从规则选择等价类、边界和反例
4.1 等价类让有限用例有选择依据
可能的字符串非常多,无法逐个列出。等价类在这里指按某条规则预计会产生同类判断的一组输入。例如固定上限为 6、会话合法时,字节长度可以分为:
- 长度为 0:被空文本规则拒绝。
- 长度为 1–6:满足正文长度要求。
- 长度大于 6:被超限规则拒绝。
从每类选代表值,比只随手选几个喜欢的句子更有方向。但这是基于规格的分类,真实实现可能错误地对某种编码、某个值或某个长度另作处理。类中的一个代表通过,不证明全类已经正确。
会话也可分为 single、group 和其他值。尽管前两个在本题都允许,仍各留一个例子,以发现程序是否只支持了其中之一。等价类服务于发现风险,并不要求把所有同结果输入都压缩成一个例子。
4.2 边界是最容易写错比较的位置
边界值是规则结果即将改变的位置及其邻近值。固定上限 6 时,先用 ASCII 文本让字节数容易数:
| 长度位置 | 输入 | 预期 |
|---|---|---|
| 最小值之前 | "",0 字节 | EMPTY |
| 最小允许值 | "a",1 字节 | 通过 |
| 上限之前 | "abcde",5 字节 | 通过 |
| 恰好上限 | "abcdef",6 字节 | 通过 |
| 刚超上限 | "abcdefg",7 字节 | TOO_LONG |
len(body) >= maxBytes 会错误地拒绝等于上限的输入。只检查长度 1 和 9,可能根本看不出这个错误。
对配置要检查负数、0 和正数;对状态要检查 -1、0、9、10、11。每组边界来自不同要求,不能只做正文边界就认为其他条件已检查。
如果上限改成 1,maxBytes-1 就是 0,maxBytes 就是 1,分别与最小值附近的例子重合。可以合并重复输入,但要在记录中保留它同时覆盖了哪些边界。
4.3 再改变编码和空白,检查对“长度”的解释
| 原始正文 | 字节数 | 上限 6 时结果 | 它区分了什么 |
|---|---|---|---|
"你好" | 6 | 通过 | 中文不能按两个字节计算 |
"你好呀" | 9 | TOO_LONG | 人眼看到的数量与字节上限不同 |
"x你好" | 7 | TOO_LONG | 混合文本仍按总字节数计算 |
" " | 1 | 通过 | 空格不同于空字符串 |
"\n" | 1 | 通过 | 换行是一个字节,当前没有禁止规则 |
" 你好 " | 8 | TOO_LONG | 当前不删除前后空格再计数 |
表中 "\n" 表示 Go 双引号文本里的一次换行转义,不是反斜线与字母 n 两个字节。若希望两个普通字节,可以写 "\\n",长度才是 2。
本章只规定非空和字节上限,没有检查输入是否为完整合法的 UTF-8。Go 字符串可以包含任意字节,不能因为参数类型叫 string 就附带推断“已经是合法文本”。后续文本章节会展开编码验证;现在应当在规格里准确记录已经保证的范围。
4.4 先做单项变化,再做错误组合
单项变化指从一个合法基准出发,每次只改一个与规则有关的因素。例如基准是 single、"你好"、上限 6、次数 2:
- 只把上限改成 0,检查 R01。
- 只把会话改成
GROUP,检查 R02 的精确匹配。 - 只把正文改成空,检查 R03。
- 只把上限改成 5,检查 R04。
这样,一次失败容易与具体规则对应。然后再补同时错误的组合,检查优先顺序。若所有负面用例都同时包含三项错误,一个最前面的拒绝条件就可能掩盖后面的缺失检查。
4.5 一张可以直接用于手工核对的验收表
以下每行单独开始,默认 kind=single、maxBytes=6、acceptedCount=2、lastAccepted="上一条",draft 与本行正文相同。状态列:保留指三个值不变;记录指草稿不变、次数加一、最近记录等于正文。
| 用例 | 本行改变或给定的输入 | 预期完整结果 | 预期状态 | 规则依据 |
|---|---|---|---|---|
| T01 | 正文 "a" | 通过并记录 | 记录,次数 3 | R03、R07 |
| T02 | 正文 "abcde" | 通过并记录 | 记录,次数 3 | R04、R07 |
| T03 | 正文 "abcdef" | 通过并记录 | 记录,次数 3 | R04 的等号 |
| T04 | 正文 "abcdefg" | TOO_LONG | 保留 | R04、R09 |
| T05 | 正文 "你好" | 通过并记录 | 记录,次数 3 | R04 的字节单位 |
| T06 | 正文 "你好呀" | TOO_LONG | 保留 | R04、R09 |
| T07 | 正文 "" | EMPTY | 保留 | R03、R09 |
| T08 | 正文 " " | 通过并记录 | 记录,次数 3 | 原始输入约定 |
| T09 | 正文 "\n" | 通过并记录 | 记录,次数 3 | 原始输入约定 |
| T10 | 正文 "x你好" | TOO_LONG | 保留 | R04、R09 |
| T11 | kind=group,正文 "你好" | 通过并记录 | 记录,次数 3 | R02、R07 |
| T12 | kind=GROUP,正文 "你好" | BAD_KIND | 保留 | R02、R09 |
| T13 | 上限 0,正文 "你好" | BAD_LIMIT | 保留 | R01、R09 |
| T14 | 上限 -1,正文 "你好" | BAD_LIMIT | 保留 | R01、R09 |
| T15 | 上限 1,正文 "a" | 通过并记录 | 记录,次数 3 | R01、R04 |
| T16 | 上限 0、会话 other、正文 "" | BAD_KIND | 保留 | R08 |
| T17 | 会话 other、正文 "" | BAD_KIND | 保留 | R08 |
| T18 | 次数 9,正文 "你好" | 通过并记录 | 记录,次数 10 | R07 |
| T19 | 次数 10,正文 "你好" | LOCAL_FULL | 保留,次数 10 | R06 |
| T20 | 次数 -1,正文 "你好" | BAD_STATE | 保留,次数 -1 | R05 |
| T21 | 次数 11,正文 "你好" | BAD_STATE | 保留,次数 11 | R05 |
| T22 | 次数 10,正文 "" | EMPTY | 保留,次数 10 | 完整处理顺序 |
| T23 | 次数 0,正文 "你好" | 通过并记录 | 记录,次数 1 | R07 的下边界 |
| T24 | 已知会话 single、上限 0、正文 "" | BAD_LIMIT | 保留 | R08 的文本检查顺序 |
这里还可以新增 maxBytes=1、正文 "ab" 的刚超限用例。表不是完成一次就永久封存的清单;发现新的风险、缺陷和需求变化后,需要继续补充。
4.6 反例用来推翻过强断言
反例是足以使一个普遍判断不成立的具体情况。有人说“只要长度不超过上限就是合法正文”,空字符串就是反例;有人说“中文两个字不可能超过上限 5”,"你好" 就是反例。
对“所有符合规格的输入都被正确处理”的断言,一个失败反例就能推翻它。但若 24 个用例都符合预期,也只能说明这些观察没有发现偏差,不能在没有其他论证时声称所有可能输入都已经证明正确。
这个区别很实用:测试和手工核对帮助我们持续发现错误;规则分析解释为什么选择这些例子,并指导哪里仍然缺少证据。
五、失败承诺与 IM 状态的含义
5.1 “打印了拒绝”不等于失败处理正确
先看一个故意错误的片段。它放在 main 中,假设已有 draft、lastAccepted、acceptedCount,并导入了 fmt:
lastAccepted = draft
acceptedCount = acceptedCount + 1
if draft == "" {
fmt.Println("拒绝:EMPTY")
return
}取初始状态 draft=""、lastAccepted="上一条"、acceptedCount=2。沿语句执行,最近记录先变成空,次数先变成 3,随后才打印拒绝。屏幕上的结论正确,状态却违反 R09。
“失败时保持状态”的要求因此给我们增加了独立观察点。如果只核对文字“拒绝”,这个错误就容易被漏掉。真正要检查的是结果、原因和前后状态三者能否共同符合约定。
5.2 本地小模型为什么采用先检查、后更新
状态迁移是一次操作使状态从一个取值变为另一个取值的过程。对本章演示,先完成所有可能拒绝的检查,再把成功变化集中在一个分支,能够使推理简明:
- 校验函数只返回判断,不改变记录。
- 校验拒绝时进入拒绝分支。
- 输入通过后,再检查次数是否有效、是否已满。
- 只有全部允许,才更新最近记录与次数。
- 最后打印状态,供观察者核对。
这解释了本地顺序程序的正确性,并没有承诺后续数据库写入、网络发送之间能自动共同成功。以后一个操作要跨多个组件时,“先检查”与“之后动作”之间还可能发生变化,需要进一步讨论并发、事务与恢复。当前结论的范围就是当前这段顺序执行的代码。
成功时保留草稿也是明确选择。若改为成功后清空,必须修改 R07 和对应预期;失败仍然保留的 R09 不会自动改变。用两个独立规则表达,能避免开发者把“成功后清空”误写成“尝试后总是清空”。
5.3 同一句“发送成功”可能指不同的事实
以下概念是为后续 IM 课程建立业务词汇,本章程序仅做到第一行:
| 业务词语 | 需要说明的事实与范围 | 可用来支持判断的证据示例 |
|---|---|---|
| 本地校验通过 | 这份输入符合当前本地规则 | 当前校验函数的结果 |
| 服务端受理 | 服务端按其约定接下了请求 | 与请求对应的服务端响应及接口承诺 |
| 持久保存 | 数据在约定的故障与恢复范围内可重新取得 | 持久化完成记录,结合存储与恢复约定 |
| 设备接收 | 某一指定设备的客户端确认接到了指定消息 | 关联消息身份与设备身份的确认 |
| 已读反馈 | 客户端按产品定义触发并报告了已读事件 | 已读事件及其关联的消息或范围 |
持久保存需要定义恢复边界,例如允许哪些组件故障、保留多久。单纯看见一行“写入成功”不能代替对这些保证的理解。已读反馈也只能说明产品所定义的事件已上报;它不能证明接收者理解了文字内容。
如果某个具体系统明确规定“受理响应只在持久保存完成后返回”,那么那个响应可以在该约定成立的前提下支持持久性判断。反过来,只知道返回名叫“成功”,却不知道它的契约,就不能擅自推断已保存。
因此,表格是在分清事实,不是在规定所有 IM 都必须先保存、后投递、再产生同一种回执。不同产品可能采用不同安排,需要结合实际实现核对。
5.4 同一条消息也可能有不同观察者的状态
u-b 的手机已接收,不等于平板也已接收;手机报告已读,也不意味着这个事实已经同步给 u-a。这里至少出现了三个不同问题:某设备发生了什么、系统已记录什么、发送者目前知道什么。
再设想后续的网络场景:u-a 等待响应,时间到了仍没收到。超时说明等待超出了规定时长;它本身无法区分请求未到达、处理未完成、处理已完成但响应未到达等情况。发送者的认识可能是“结果未知”。重试是否会重复产生消息,要到有消息身份、去重与恢复的阶段继续回答。
这类提问现在就能用来审阅需求,无须提前写网络代码:
- “成功”由谁报告,关联哪次操作?
- “送达”针对一台设备、任一设备,还是所有设备?
- 未收到回执时,允许显示什么?
- 用户重新点击是否创建新消息,还是查询原消息结果?
本地演示不会回答后面三项的实现,但你已经能识别需求缺少哪些条件。
六、把约定写成普通函数,再手工核对
6.1 先准备一个完整且有限的程序
可以新建 im-requirements 目录,按第一章的方法初始化模块 example.com/im-requirements,将下面完整程序放入 main.go。一次只保留这一份 main 与这一份同名校验函数。
本程序仅使用已经学过的类型、条件、普通函数和多返回值。fmt.Printf 的 %q 会给字符串加引号并转义换行等内容,便于观察空字符串、空格与换行;%d 用来显示整数。代码中的输出均在下文以预期列出。fmt:格式与动词
package main
import "fmt"
func validateMessage(kind string, body string, maxBytes int) (bool, string) {
if kind != "single" && kind != "group" {
return false, "BAD_KIND"
}
if maxBytes <= 0 {
return false, "BAD_LIMIT"
}
if len(body) == 0 {
return false, "EMPTY"
}
if len(body) > maxBytes {
return false, "TOO_LONG"
}
return true, ""
}
func main() {
kind := "single"
maxBytes := 6
draft := "你好"
body := draft
lastAccepted := "上一条"
acceptedCount := 2
fmt.Printf("之前:draft=%q count=%d last=%q\n",
draft, acceptedCount, lastAccepted)
ok, reason := validateMessage(kind, body, maxBytes)
if !ok {
fmt.Println("拒绝:", reason)
} else if acceptedCount < 0 || acceptedCount > 10 {
fmt.Println("拒绝:BAD_STATE")
} else if acceptedCount == 10 {
fmt.Println("拒绝:LOCAL_FULL")
} else {
lastAccepted = body
acceptedCount = acceptedCount + 1
fmt.Println("本地校验通过并记录")
}
fmt.Printf("之后:draft=%q count=%d last=%q\n",
draft, acceptedCount, lastAccepted)
}body := draft 取得当前草稿的字符串值作为本次输入。后面即使重新给 draft 赋值,也不会自动修改 body 的值。这里使用的是字符串与整数,不需要借此推断以后集合和指针的复制方式。
validateMessage 和 main 的职责不同:前者回答输入是否符合规则,后者根据结果决定是否更新本地记录。函数返回 true 时,调用者还会检查次数,所以一次输入校验的结论没有冒充完整业务处理结果。
6.2 逐行走一次成功路径
初始输入为 single、"你好"、6,次数为 2:
| 步骤 | 当前判断 | 下一步 |
|---|---|---|
| 1 | 会话既不是 single 又不是 group?否 | 继续 |
| 2 | 上限小于等于 0?否 | 继续 |
| 3 | 正文字节数为 0?否 | 继续 |
| 4 | 6 > 6?否 | 返回 true, "" |
| 5 | !ok?否 | 检查状态 |
| 6 | 次数不在 0–10?否 | 检查是否已满 |
| 7 | 次数等于 10?否 | 执行更新 |
| 8 | 最近记录改为正文,次数加一 | 打印之后的状态 |
预期输出:
之前:draft="你好" count=2 last="上一条"
本地校验通过并记录
之后:draft="你好" count=3 last="你好"这三行一起对应 R07。第一行显示准备的状态,第二行显示处理结论,第三行显示实际需要核对的结果状态。若只有第二行,不能知道最近记录是否更新正确。
6.3 再走一次拒绝路径
只把完整程序中的 draft := "你好" 改成 draft := "你好呀"。body 会取得新的草稿值,其长度为 9,上限仍为 6。
校验函数在长度判断处返回 false, "TOO_LONG"。控制回到 main 后,!ok 成立,执行第一个分支;由于后面是同一组 else if/else,更新分支不会执行。最后的状态打印位于整组分支之后,因此拒绝时也能观察到三个变量。
预期输出:
之前:draft="你好呀" count=2 last="上一条"
拒绝: TOO_LONG
之后:draft="你好呀" count=2 last="上一条"fmt.Println("拒绝:", reason) 在两个参数之间插入空格,所以拒绝标识前有一个空格。这里展示具体预期,不需要让学习者猜测这是否是程序多打了一个字符。
6.4 次数上限与坏状态是两条不同路径
恢复合法文本 "你好",分别从以下初始次数开始:
| 初始次数 | 完整处理结果 | 结束次数 | 原因 |
|---|---|---|---|
| 0 | 通过并记录 | 1 | 合法且未满 |
| 9 | 通过并记录 | 10 | 最后一次允许记录 |
| 10 | LOCAL_FULL | 10 | 状态有效,容量已满 |
| -1 | BAD_STATE | -1 | 初始状态无效,本操作不修复 |
| 11 | BAD_STATE | 11 | 初始状态无效,本操作不修复 |
只有 0–9 的值会执行加一。它们加一后的最大值为 10,因此没有通过无限制递增去赌 int 的范围。数值范围与更新条件共同支持这份推理。
如果正文同时为空且次数为 11,输入校验先拒绝 EMPTY,次数保持 11。程序仍没有把坏状态变成好状态,也没有因为这个输入就绕开空正文规则。
6.5 手工检查也需要可重复的步骤
先用 T05 做一次完整练习:
- 在不看代码分支的情况下,从规则表写下预期与三个结束状态。
- 按用例设置
kind、maxBytes、draft、lastAccepted、acceptedCount。 - 如具备本地环境,按前面章节的方法运行;将真实输出另行保存。
- 对照函数结果、处理结论和三个状态。需要观察函数结果时,可在调用后临时打印
ok、reason。 - 标明这是哪个源码版本、哪个规则版本的观察。
- 准备下一个独立用例时,恢复它要求的初始值,避免沿用上一例造成混淆。
10.02 测试基本方法会把这些准备、调用与比较交给测试程序重复执行。当前先练习把期望写清楚;自动重复一个没有正确依据的比较,也不能帮我们发现需求理解错误。
七、让验收证据和需求变化能够追溯
7.1 预期、实际、结论与证据分别填写
预期来自规则;实际来自对某次执行的观察;结论比较两者;证据使别人能够复核你的实际记录。追溯指沿着这些关联找到结果所依据的要求和实现版本。
以 T06 为例,尚未执行时应这样记:
| 记录项 | 内容 |
|---|---|
| 规则版本 | 教学规格 v1 |
| 用例与规则 | T06;R04、R09 |
| 输入与准备 | single、上限 6、草稿 你好呀、次数 2、最近记录 上一条 |
| 预期结果 | TOO_LONG;三个状态不变 |
| 实际结果 | 未执行 |
| 验收结论 | 未执行,尚无运行结论 |
| 证据 | 只有规则推导;没有本次运行输出 |
这是一条完整的待执行记录,不是假装通过的记录。静态审阅可以得出“按代码推演符合本条预期”的结论,但应保留“静态审阅”这个范围。
执行之后,实际列应记录观察到的返回、输出和状态,证据可以是保存的终端文本。结论才可能是通过、不符合,或者证据不足。若程序没有显示状态,看到拒绝文字也可能不足以判定 R09 已满足。
7.2 独立的预期依据怎样防止同错
测试真值来源,也常叫预期判定依据,指我们用什么判断结果是否正确。它可以来自已确认的规格、业务示例、独立推导或其他经过确认的依据。本章最直接的来源是 R01–R09 及其决策表。
一个危险办法是:先运行程序,把它当前返回的值抄进“预期”,以后只检查它有没有变化。这样可以发现行为变化,但如果最初实现就把 >= 写错了,错误结果也会被固定成所谓的正确答案。
更有力的做法是:先从“等于上限合法”推导 T03 应通过,再用它审阅实现。若实际拒绝,先记录差异,回到规格确认;不能为了使检查变绿就直接把预期改成拒绝。
同样,代码里先检查哪个条件不能自动成为需求依据。R08 已确认顺序时,实现必须与它相符;若需求尚未定,现有实现只能告诉我们当前行为,仍需讨论未来承诺。
7.3 上限从 6 改成 9,会影响什么
设教学配置从 6 改成 9,形成规格实例 v2。R04 的通用含义“不得超过配置上限”没有变,配置取值与相关例子的结果发生变化:
| 用例 | v1,上限 6 | v2,上限 9 | 后续动作 |
|---|---|---|---|
T03,abcdef,6 字节 | 通过 | 仍通过 | 保留,已不代表新上限 |
T04,abcdefg,7 字节 | TOO_LONG | 通过 | 更新预期与状态 |
T06,你好呀,9 字节 | TOO_LONG | 通过 | 更新预期,兼作新上限例子 |
| T07,空文本 | EMPTY | 仍为 EMPTY | 保留非空规则 |
| T12,未知大小写会话 | BAD_KIND | 仍为 BAD_KIND | 保留分类规则 |
新增,x你好呀,10 字节 | — | TOO_LONG | 覆盖新上限之后 |
还有新上限之前的 8 字节输入,例如 "abcdefgh",应通过。新规则需要的是 8、9、10 的边界组合,不能只把旧用例的标题改成“上限 9”。
当前完整程序的 validateMessage 接收上限参数,因此修改演示的 maxBytes := 6 为 9 就能改变这项配置,校验函数中的比较表达式本身无需改写。如果另一实现把数字硬编码进函数,才需要修改那个常量。追溯应落在真正受影响的位置,不能机械宣布所有代码都要改。
中文提示若显示具体数值,也要核对是否仍写着 6。结果标识 TOO_LONG 可以继续保持;空文本、大小写和失败保留状态的约定不会因为上限变大而自动改变。
7.4 需求之间冲突时,先把冲突具体化
假设后来同时出现两句话:“保持用户原始文本”和“发送前自动删除首尾空格”。它们是否冲突,取决于各自在说哪个对象。
一种可行的新约定是:草稿保留原样,提交正文使用清理后的值。另一种是编辑器直接修改草稿。两者的用户体验、失败恢复与显示方式不同,不能只在校验函数里加一行清理就认为需求已完成。
应继续问:先清理还是先检查上限?清理后为空返回什么?最近记录保存原始值还是清理后的值?错误时草稿是否保留?用 " 你好 " 与 " " 可以具体暴露这些差异。
本章 v1 没有选择自动清理。把讨论记录为未来变更,避免当前正文和用例在没有说明的情况下悄悄分叉。
7.5 对照 OpenIM:从一个真实入口提出正确问题
本课程固定参照 OpenIM 服务端 v3.8.3-patch.16,提交 f6411a8a1a31d3df36f4c2b3ad28481a94141e1f。实际核对的 SendMsg 先判断消息数据是否存在,在存在的分支里补齐消息数据,再按 SessionType 分流到单聊、通知或群聊;未知类型进入错误分支。固定版本的 SendMsg
当前只借这个入口练习提问:输入缺失时怎样结束?会话分类有哪些?未知分类有没有明确结果?分流前做过哪些处理?这些问题都能转成后续阅读时的需求与用例。
本章的 single/group 字符串、字节上限、结果标识和次数上限是教学设定。上游使用的类型、常量和跨服务调用,需要在前置齐备之后展开;不能把我们的字符串值或 6 字节限制说成该项目的实际协议。
一个入口的分支也不足以说明整条消息链路的持久、投递与已读保证。后续要继续沿着实际调用和响应位置寻找证据,当前不提前填写答案。
八、分层练习与参考反馈
先独立写答案。下面每道题的折叠部分是反馈,重点检查推理依据,而不是仅比对一个词。
练习 1:澄清“正常消息”
有人提出:“支持正常消息,太长时提示失败。”写出至少五项需要确认的问题,并为其中两项设计区分不同解释的输入。
参考反馈
可以询问:长度单位、上限取值、等号是否允许、空字符串、纯空格、会话种类、失败后草稿是否保留、成功保证到哪一步、多个错误的报告顺序。
用 "你好" 配上限 5,可以区分按字节与按人眼看到的数量;用 " " 配上限 6,可以区分禁止空字符串与禁止空白。只写“长度是否合理”仍然模糊,应问到能让具体输入得到结论的程度。
练习 2:找出输入、状态和输出
kind=group、body="你好"、上限 6,已有最近记录 "上一条"、次数 2。请按纯校验函数与完整本地处理两个边界分类这些值。
参考反馈
对纯校验函数,kind、body、上限是输入,ok 和 reason 是输出;它没有接收或更新最近记录与次数。
对完整本地处理,kind、正文与配置决定本次判断,最近记录与次数是需要观察的既有状态;处理结论、终端输出和结束状态共同构成验收对象。不要因为某值在 main 中是局部变量,就否认它在这个教学操作里承担了状态角色。
练习 3:前置与接受条件
同学说:“契约要求上限大于 0,所以传入 0 不用检查。”这个说法符合本章约定吗?
参考反馈
不符合。本章明确要求在会话合法、上限为 0 时返回 false, "BAD_LIMIT"。上限大于 0 是接受条件;校验函数也要处理不满足条件的输入。
如果另一个函数选择仅对正上限定义行为,应把这个责任写成真正的调用前提,并由适当边界负责检查。不能事后把应拒绝的输入排除掉,以逃避一项已承诺的行为。
练习 4:查找最小反例
一个实现把超限条件写成 len(body) >= maxBytes。会话为 single、上限为 1,请给出一个尽量简单的反例。
参考反馈
正文 "a" 有 1 字节,按 R04 应通过,但错误实现会拒绝。空正文会被 R03 先拒绝,不能有效区分这两种超限比较;特别长的输入也会被两种写法共同拒绝。
练习 5:推导错误优先顺序
推导三组输入的 reason:① other、空正文、上限 0;② single、空正文、上限 0;③ group、你好、上限 5。
参考反馈
依次为 BAD_KIND、BAD_LIMIT、TOO_LONG。先确定会话,再检查文本配置、空与长度;这是 R08 的明确顺序,沿用 01.03 综合程序的边界。
第一组同时有多项问题,不能随意选择最显眼的一项。如果想一次报告多项,应该先修改输出契约,再重新设计用例。
练习 6:补齐状态边界
合法正文、上限 6。初始次数分别为 9、10、11 时,应怎样改变最近记录与次数?哪种情况说明状态已满,哪种说明状态无效?
参考反馈
9 可以成功,最近记录更新、次数变 10;10 有效但已满,返回 LOCAL_FULL,两者不变;11 是无效初始状态,返回 BAD_STATE,两者也不变。拒绝后仍为 11,不代表已经修复。草稿在三个场景中都保留。
练习 7:审阅一个仅打印错误的片段
下面是 main 内的故意错误片段,已有校验结果与状态变量,并导入了 fmt。找出不符合 R09 的路径,给出两种修改方法。
if !ok {
fmt.Println("拒绝:", reason)
}
lastAccepted = body
acceptedCount = acceptedCount + 1参考反馈
当 ok 为 false 时,打印后仍然会向下执行,错误更新状态。一种修正是在拒绝分支里 return,另一种是把更新放入 else。完整程序还需要保留次数合法与已满检查,不能只修复这一行就删掉其他规则。
提前 return 还会跳过放在它后面的观察输出。如果需要记录拒绝后的状态,可像完整示例那样使用互斥分支后统一打印。代码结构既要满足约定,也要让证据可见。
练习 8:独立构造用例
把上限设置为 1,为正文与配置分别选择边界用例。解释哪些输入可以合并,哪些不能。
参考反馈
会话合法时,正文 ""、"a"、"ab" 分别代表 0、1、2 字节。1 同时是最小允许值和上限,可合并为一条但标明两个依据。
另用合法正文 "a" 搭配上限 -1、0、1,检查配置边界。正文为空且上限 0 可以作为组合用例,却不能替代所有单项用例;它先触发配置拒绝,无法独立证明空正文检查存在。
练习 9:写失败前后状态
draft=" 你好 "、上限 6、最近记录 "已记录"、次数 4、会话 single。写出字节数、reason 和三个结束状态。
参考反馈
正文 8 字节,返回 TOO_LONG。草稿仍是带两个边缘空格的原文,最近记录仍为 已记录,次数仍为 4。当前没有清理规则,不能先去空格变成 6 字节再宣布通过。
练习 10:完整处理成功是否等于纯校验通过
输入合法,但次数已经等于 10。两个层次分别应给出什么结论?这是否违背“相同输入得到相同校验结果”?
参考反馈
纯校验函数返回 true, "",完整处理因 R06 报告 LOCAL_FULL。不矛盾:次数不在纯函数的输入中,纯函数只判断输入规则;完整处理还观察本地状态。讨论可重复结果时必须同时说清操作边界和全部相关输入。
练习 11:完成一次上限变更
上限从 6 改到 9。写出三条预期会变化的现有或新用例、两条保持原结果的用例,并指出完整程序需要修改的位置。
参考反馈
7 字节 abcdefg、8 字节 abcdefgh、9 字节 你好呀 在上限 6 时都被拒绝,在上限 9 时都变为通过;这三项的预期发生变化。另补 10 字节 x你好呀 作为新上限之后的边界,它在两个版本下都应拒绝,不属于结果变化项。
空正文仍返回 EMPTY,未知会话仍返回 BAD_KIND。示例修改 maxBytes 的配置值;参数化函数中的 > 无需修改。用例预期状态也随通过/拒绝变化,不能只改结果文字。
练习 12:给证据限定范围
记录写着:“读过程序,24 个用例全部通过,消息保证送达。”请重写为符合当前证据的表述。
参考反馈
如果实际只做了纸面推演,可以写:“已按规则和程序静态推演 24 个用例;预期结果见表,运行状态均为未执行。当前程序仅处理本地校验与记录,没有设备接收证据。”
静态推演可以发现比较符、顺序和状态更新问题,本身有价值。它与真实执行覆盖的风险不同,不能用一个泛称“通过”掩盖这种区别。
练习 13:给新增会话类型写迁移要求
未来决定增加 notification。在写代码之前,需要更新什么?只把会话条件放宽是否足够?
参考反馈
先说明这类消息由谁提出、正文是否仍适用非空与字节上限、是否沿用成功与失败状态承诺。若本地模型选择沿用,就更新 R02、决策表解释,增加合法 notification、非法近似拼写、空与超长组合用例,再修改函数并记录版本。
只放宽分类无法回答真实系统里的身份与权限。当前还没有实现它们,应把这些问题留作明确的后续需求,不能因为允许一个字符串就声称普通用户具备发送通知的权限。
练习 14:评审一次“自动清理”需求
新要求是“纯空白禁止,成功后清空草稿”。请至少写出四项还要确认的细节,再给出一条失败状态承诺。
参考反馈
要确认空白包含哪些字符、是否删除首尾还是所有空白、先清理还是先检查长度、记录原文还是清理后的文本、超限如何反馈、草稿何时清空。不能用“调用某个清理函数”替代产品语义。
一种明确的失败承诺是:任何本地拒绝都保留原始草稿、最近记录和次数,并返回对应原因。成功后清空只能进入成功规则;失败分支不可共享这项清空动作。新语义涉及尚未学习的文本处理时,先写自然语言与输入输出表,后续再实现。
本章完成标准与下一步
请留下四份可以由别人检查的材料:
- 一页消息规则:输入、单位、边界、错误优先顺序与保证范围。
- 一张决策表和至少十条有具体输入的用例,包含等号、空白与多项错误。
- 成功和拒绝各一次前后状态推演;若自行执行,运行记录单独保留。
- 一份上限或会话类型变化的影响说明,指出哪些规则保持不变。
能解释“为什么应该得到这个结果”,再进入10.02 测试基本方法。完成 01.04–01.09 后,你将把多条消息与规则组织成集合和包,再学习测试入口及其语法,把这里的验收步骤自动化。10.03 Git帮助记录规则与实现的版本变化。
本章核对资料
- Go 语言规范:字符串类型:核对字符串与字节长度的关系。
- Go 语言规范:长度与容量:核对
len的含义。 - fmt 官方文档:核对
%q、%d与输出格式。 - OpenIM 固定提交的 SendMsg:本章仅对照输入存在性与会话分类的控制流。
本章的需求规格、状态模型、决策表和练习为教学设计。外部资料用于核对语言规则与上游事实,不代替这份教学需求本身。