09.01 需求与应用边界:谁能给谁发消息、成功到哪一步
返回第九卷 · 需求前置:10.01 · HTTP 前置:04.03
09.01 需求与应用边界:谁能给谁发消息、成功到哪一步
DeepTutor 初稿经技术和教学审阅后的静态课程。请求、代码、状态和错误都是虚构教学设计;本次没有运行 Go、网络服务、数据库、测试或站点构建。
先回答“要给谁什么能力”,再写 handler
10.01 已教你从需求得出独立预期;04.03 已区分 HTTP 200、WebSocket 101、服务受理、设备收到与已读;05.01–05.03 已补齐任务、共享状态和取消。现在可以为未来教学 IM 写应用层的两项任务:u-a 读取会话 c-a 的某个历史范围;u-a 向该会话提交文本消息 m-a。这两项服务究竟允许谁调用、接收什么输入、改变什么状态、失败时承诺什么,需要先说清楚。
当前课程仓库只有本地 imhistory 文件 CLI,没有已经运行的 HTTP/WS 服务。本章的用户、消息和会话 u-a/u-b/m-a/c-a 全部虚构;S2 先设计隔离的内存小模型,S3 才补真实身份与持久存储,S5 才定义离线、重复、设备送达和已读。不能因为文档画了这些接口,就宣称项目已有相应保证。
| 第一遍 | 第二遍 | 练习交付物 |
|---|---|---|
| 用例、可信主体、输入与状态 | 边界转换、错误优先级、源码对照 | 两张用例卡、一张决策表和一条状态时间线 |
一、两个用例各自有输入、前提、结果和拒绝路径
一个用例用用户目标描述系统行为,不先等同于 URL、HTTP 方法或 Go 函数。下面两张卡是本章自定的教学合同草案,尚待后续 API 设计细化:
| 项目 | 读取 c-a 历史 | 提交 m-a 到 c-a |
|---|---|---|
| 发起者 | 已验证身份的 u-a | 已验证身份的 u-a |
| 业务前提 | u-a 对 c-a 具有读取资格 | u-a 有权在 c-a 发文本 |
| 关键输入 | 会话 ID、查询范围、页大小 | 会话 ID、消息 ID、正文 |
| 正常结果 | 返回明确范围、顺序和至多约定数量的可见消息 | 在本进程内存模型中记下一条合格消息 |
| 拒绝结果 | 未认证、无权限、范围无效、资源不可见 | 未认证、无权限、正文无效、同会话重复 ID |
| 拒绝后的状态 | 不暴露历史,也不修改会话状态 | 原有消息和顺序保持不变 |
“已验证身份”在 S2 是设计前提,不是本仓库已实现登录。个人隔离实验可由测试桩提供固定的虚构主体以练习后续业务规则,不能把这种桩公开为可用服务。S3 要用真实可信的认证边界替换它,并验证身份过期、伪造和撤销。读取资格与发言资格也可以不同:某用户也许能看旧消息,却因禁言不能再发,不能用一个笼统的 is_member 布尔值替所有权限。
先不要把“返回 200”写成用例的全部成功。读历史要问:返回了哪段历史、哪些记录可见、空数组表示哪种范围、如何继续翻页。提交消息要问:本次回答承诺的是“参数通过”“进入内存状态”“持久保存”,还是“B 设备收到”。本章选择进入进程内存状态作教学成功点;进程退出会丢失它。应用边界先定清,再让 09.02 选择 HTTP 方法、状态码和错误体。
二、可信主体来自认证边界,不来自客户端字段
认证回答“这次请求的主体是谁”,授权回答“该主体能否对这个资源执行这个动作”。u-a 是本课程的虚构身份;未来实际服务须从可靠的会话、令牌或其他认证机制得到它,不能从请求 JSON 的 sender_id:"u-a" 直接当真。客户端能编辑自己发来的字段,同样也能写 sender_id:"u-b" 或 is_member:true。
外部请求:路径含 c-a,正文含 message_id=m-a、body="你好"
可信边界:得到已验证主体 u-a(S3 才完整实现)
授权检查:u-a 对 c-a 是否允许发送?
领域命令:Actor=u-a、Conversation=c-a、Message=m-a、Body="你好"这条线里,Actor=u-a 由服务端边界填入,不是信任客户端自报。若请求带了不该出现的 sender_id,接口合同要明确是严格拒绝未知字段还是在受控兼容策略下忽略;本章建议新教学接口直接不接受该字段,避免两个“发送者”来源互相冲突。未来群聊还需成员状态、角色和禁言规则,不能凭有效登录就跳过每个会话的对象级授权。
读取历史也要做同一资源的授权:知道 c-a 这个 ID 并不授予查看其消息的权利。即使用户原先加入过会话,是否可读离开前后的历史,也要由产品合同定义。对无权访问者,有的系统可能使用“拒绝访问”,有的可能为避免泄露资源存在性而返回“未找到”;这是 09.02 要设计的外部表现,内部仍须准确区分主体、资源和拒绝原因。
认证失败与授权失败不是同一个状态:前者没有可信主体或凭据无效,后者有可信主体但缺少本动作权限。操作系统允许程序打开一个文件,也不等于 u-a 有权读取 IM 会话;Linux 文件许可与应用对象级授权处于不同层。OWASP 的授权指导强调对每个资源与动作持续检查,本章只用它确定需求与验收负例,不冒充已实现的安全控制。
三、传输 DTO、领域命令与内存消息各守一层
HTTP/WS 外部表示是面向传输的DTO(data transfer object):它承载路径、JSON 字段和版本。领域命令表示服务要执行的业务意图:可信主体是谁、作用于哪条会话、尝试保存哪个消息 ID 和正文。内存消息是成功检查后才进入本地状态的数据。三者字段可能相似,但所有权和可信程度不同。
// 仅供教学的类型形状,不是已实现的网络 API。
type SendDTO struct {
MessageID string `json:"message_id"`
Body string `json:"body"`
}
type SendCommand struct {
ActorID string // 由可信边界填写,不来自客户端 JSON。
ConversationID string // 由已解析且授权的目标确定。
MessageID string
Body string
}在未来的路径 /v1/conversations/c-a/messages 中,c-a 是请求目标;正文里不再复制一份会话 ID。这样可以避免路径说 c-a、JSON 却说 c-b 的歧义。SendDTO 没有 sender_id;服务在得到可信主体、解析路径和校验输入后才构造 SendCommand。若某个上游客户端格式已有不同字段,要单独做兼容层,不能偷偷把新接口改成“谁填 sender 就代表谁”。
本例的 kind=single 是服务端已有的 c-a 会话属性,不由这份 DTO 自行声明。因而第四节检查 kind 时读的是服务端掌握的会话类别;若将来产品允许客户端选择新会话类型,必须另立创建会话的合同,而不是把任意字符串直接当已存在会话的权威类别。
还要区分三种大小与格式:原始请求字节上限保护解析资源,正文 UTF-8 字节上限保护领域规则,01.12 的 1 MiB 只属于本地文件 CLI。10.01–10.02 的教学消息规则当前用 6 字节上限:你好 恰好 6 字节合法,你好呀 为 9 字节不合法,正文不自动去空格。网络 DTO 的 JSON 开销会让请求总字节数大于 len(body);不能用同一个整数不加说明地替代三个上限。
版本、未知字段、缺失字段和时间也要有边界。外部 JSON 可有版本字段并按合同拒绝未知版本;message_id 与正文必需、不能为空。客户端时间若被接收,只能作为声明数据,不能自动决定服务端受理次序或可信发送时间。业务可在构造领域状态时记录服务端自己的时间,具体来源与排序保证以后专章定义。本地 JSON v1 文件格式也不是这个未来网络 DTO 的自动协议。
四、从不可信字节到一次状态变化的检查顺序
一个可解释的教学处理链如下;这是设计选择,不是所有 HTTP 系统必须照抄的固定中间件顺序:
限制原始输入大小
→ 解码外部表示并核对字段/版本
→ 取得可信主体
→ 检查主体对目标会话的本动作权限
→ 校验 kind、body、message_id 与重复条件
→ 在同一内存临界区完成状态变化
→ 按完成到哪一步形成响应有些服务会先查认证再完整解码正文,以免给未认证者额外处理或信息;有些错误会为隐私目的合并外部表现。无论采用哪种顺序,都必须先有原始输入上限,明确错误优先级,且在任何拒绝路径上不把半份消息加入已受理状态。错误状态可以在应用内部保持精确分类,向外部披露多少细节则由 09.02 的接口与安全合同决定。
例如 kind=unknown, body="", maxBytes=0 同时无效。沿 10.01 的本地规则,先拒绝未知种类 BAD_KIND;若未来网络接口要改变优先级,必须写成新需求并更新测试,而不能只因重排 handler 中的 if 导致外部行为漂移。类似地,用户未认证且 c-a 不存在时,先报哪个外部错误会影响资源存在性的披露;没有独立需求之前不能随意规定。
内存版“检查重复 ID、写 map、追加列表”须按 05.02 同一锁区完成,不能把判重放在锁外。取消请求、依赖失败或响应丢失又可能使调用者不知道一次变化是否已经发生;不能只用 ctx.Err()!=nil 就倒推状态一定没变。先定义提交点,再决定如何记录结果与未来重试身份。
五、状态机:受理进内存不是设备送达
为避免一个模糊的“成功”,先给 S2 教学服务定一条很窄的状态线。对某个 (conversationID,messageID) 键,初始为不存在;输入和权限都通过后,在同一个内存锁区完成“查重→写 map→追加顺序”,状态变为本进程内存已受理。若此前任何检查失败,状态仍不存在;若同键已受理,第二次提交被拒绝,先保存的正文不变。
不存在 ──校验、授权、唯一性全部通过且内存写入完成──→ 内存已受理
│ │
└────任一前置检查拒绝:保持不存在 └─后续投递尝试另记结果这张图的“内存已受理”只表示当前进程的一份内存状态。进程退出后它可能消失;没有数据库事务,也没有跨节点对账。随后若尝试向 B 的 WebSocket 连接发送,可以成功、失败或结果未知,这不会自动改写此前“内存已受理”的事实;两种结果怎样向用户说明,需业务合同。S3 才增加持久保存的确认点,S5 才定义离线恢复、多设备和已读。
| 用户或系统看到的事件 | 至少能说明什么 | 不能越级声称 |
|---|---|---|
| 客户端构造并提交请求 | A 已发起尝试 | 服务端已收到 |
| 服务端返回本章的“内存已受理” | 该进程内教学状态完成一次变化 | 已持久化、B 已收到 |
| 持久层将来报告提交成功 | 达到具体存储合同的确认点 | 目标设备已显示 |
| B 设备有接收确认 | 特定设备按协议观察到消息 | u-b 本人已读 |
| 阅读事件被记录 | 特定阅读规则发生 | 所有设备始终一致 |
响应也可能丢失。服务已把 m-a 放进内存,但 A 没拿到回答,此时 A 只能说“本次结果未知”;若直接换新消息 ID 重发,可能出现两条业务消息。若用同一个 ID 重试,服务的重复规则会拒绝或由将来明确的幂等合同返回旧结果。哪种对用户更合适要等 S5 设计,不能从“有一个 map”推断跨重启或跨节点幂等。
六、从需求得出可核对的案例表
本章延续 10.01–10.02 的原则:预期来自规则,不照着当前实现的分支抄。下面假设 c-a 是虚构单聊,u-a/u-b 具备题设相应权限;正文上限 6 UTF-8 字节,同会话消息 ID 唯一。若要练习无权访问,可把某次请求的可信主体改成 u-x,它不在该会话授权集合中。HTTP 状态码与错误 JSON 将由 09.02 决定,这里先写领域类别和状态前后。
| 初始状态与请求 | 预期类别 | 内存状态与证据 |
|---|---|---|
u-a 查询 c-a,合法范围,当前无消息 | 成功、空列表 | 范围和顺序明确;会话状态不变 |
查询 limit=0 或超出约定上限 | 输入无效 | 不进入历史读取的成功结果 |
| 没有可信主体却请求历史 | 未认证 | 不泄露 c-a 的消息或存在性细节 |
可信 u-x 请求 c-a 历史 | 无权或按策略隐藏资源 | 不返回受保护历史 |
u-a 发 m-a,正文 你好 | 内存已受理 | 恰有一条,6 字节等于上限合法 |
u-a 发 m-a,正文 你好呀 | TOO_LONG | 9 字节被拒,不新增消息 |
u-a 发 m-a,正文空串 | EMPTY | 不新增消息;空格不自动裁剪 |
| 会话服务端类别不在允许集合 | BAD_KIND | 按本章规则先拒绝类别,不读写消息 |
已有 c-a/m-a,再送同键不同正文 | ErrDuplicate | 原正文和列表顺序不变 |
另一个会话 c-b 使用相同 m-a | 按会话范围可合法 | 若该会话也已授权,两会话各有自己的键 |
| 输入字节超出网络接口资源上限 | 请求体超限 | 解析前拒绝,不等于 6 字节正文上限 |
| 已受理后回答丢失,A 再观察到超时 | 本次外部结果未知 | 不能倒推内存没变;需同键查询/重试合同 |
这张表故意把原始网络请求大小上限与正文 6 字节规则分开,也不拿 01.12 的 1 MiB 本地文件上限当网络接口的默认值。会话 c-b 只是解释唯一性作用域,未在本章创建真实群组、成员或网络服务。所有拒绝前后状态应由学习者后来在隔离内存模型中独立核对,课程示例不是已运行的测试报告。
七、让领域规则与传输细节隔开,再对照公开源码
应用可以先按责任画路径:
HTTP/WS 边界:限量读入、解析 DTO、得到可信主体、映射外部错误
↓
应用服务:选择读历史或提交消息、做对象级授权、编排状态变化
↓
领域/内存状态:正文规则、会话内唯一性、map 与列表不变量
↓
以后才有的适配层:文件/数据库、队列、连接投递这里是教学设计图,不是一份已实现的目录树。领域规则不应为了判断 6 字节正文而依赖 HTTP 路径或状态码;handler 也不应直接绕过权限和锁去改内部 map。Go 层的 ErrDuplicate 是业务错误身份,未来 HTTP 表示可以把它映射为某种响应,但不能在本章把这个映射误当领域事实。错误文本与敏感正文不应在日志和响应里无界回显。
与公开项目对照时也要守住已阅读的范围。本课程固定 OpenIM 服务端提交 f6411a8…;此前 10.01 已核对到其 SendMsg 入口有请求消息数据存在性处理,并按 SessionType 进入不同分支。这个有限控制流事实可以提醒我们:真实服务会区分输入形状与会话类别。但只读这段入口,不能推出它已经完成本章设计的对象级授权、持久存储、投递或已读保证,更不能把教学 History 的 map 说成 OpenIM 的实现。
学习者可在自己的源码阅读记录中写明:版本、文件、读到的分支、尚未追踪的下游调用;等 S3 和 S5 补齐身份、存储和队列前置后,再逐段核对实际实现。以 OpenIM 为中心意味着它是可定位的公开对照项目,基础课仍要先独立解释机制与业务需求,而不是靠一个函数名替代整章教学。
八、完整应用合同与分层练习
在进入 09.02 前,学习者先交出一页“u-a 给 c-a 发送 m-a”的设计记录:用例目的与发起者;外部 DTO;可信主体来源;会话授权依据;6 字节正文和同会话 ID 唯一规则;内存受理的准确时刻;错误类别;拒绝后的状态;当前未覆盖的持久化、投递与已读。随后为“读 c-a 历史”写第二张卡,注明范围、顺序、空列表与授权。
分层练习:先答,再看反馈
1. 用例等于一个 URL 吗?
不等于。用例从用户目标、输入、前提、结果与失败承诺出发;URL 是后续传输表示。
2. `u-a` 写在 JSON 中,就完成认证了吗?
没有。客户端字段可伪造,可信主体需由服务端认证边界给出。
3. 认证与授权分别问什么?
认证问请求主体是谁;授权问它能否对 c-a 执行读取或发送。
4. 知道 `c-a` 的 ID 就可以读其历史吗?
不能。还需该主体对该会话的对象级读取权限。
5. 读取权限和发送权限一定一样吗?
不一定;禁言、离开会话和历史可见范围都可能使它们不同。
6. `SendDTO` 为什么不直接包含可信 `ActorID`?
可信主体应由服务端边界填入领域命令,不从客户端 JSON 获得。
7. 路径写 `c-a`、正文又写 `c-b` 会怎样?
产生两个冲突目标;本教学接口只在路径给会话 ID,或另行定义一致性校验。
8. 本地 JSON v1 自动是未来网络 DTO 吗?
不是。文件合同和网络表示分别版本化、校验与演进。
9. 原始请求上限等于正文 6 字节上限吗?
不等于。JSON、路径等也占字节;两项规则的对象和目的不同。
10. `你好` 在本教学正文规则下合法吗?
合法。UTF-8 为 6 字节,等于上限允许。
11. `你好呀` 的 9 字节正文会怎样?
超出 6 字节上限,被拒,内存消息状态不应改变。
12. 客户端时间能直接当作服务端受理顺序吗?
不能。它是客户端声明,服务端顺序需按自己的规则定义。
13. 为何先认证再解释 `c-a` 是否存在的外部错误?
资源存在性可能是敏感信息;外部错误披露顺序要有安全合同。
14. 两任务同时送 `c-a/m-a`,只在锁外判重够吗?
不够。判重和内存写入必须在同一临界区,否则两者可同时通过检查。
15. 同一消息 ID 在不同会话一定冲突吗?
本题唯一性以会话为作用域,若两个会话都授权,可以各有 m-a。
16. 内存已受理说明磁盘写入成功了吗?
不说明。本章没有持久层,进程退出还可能失去内存状态。
17. 服务已受理但响应丢失,A 能断言“没保存”吗?
不能。外部结果未知,需要后续查询、重试和幂等合同。
18. 业务拒绝后原消息应怎样?
保持原正文和列表顺序,不交付半份受理状态。
19. HTTP 200 一定等于 B 设备收到 `m-a` 吗?
不一定。HTTP 状态要按哪次请求与应用确认点解释。
20. 只有源码里的 `SendMsg` 名称能证明送达吗?
不能。已核对范围仅有部分输入处理和会话类型分支,后续路径需另查。
21. S2 用固定虚构主体的测试桩可以公开部署吗?
不可以。它只支持隔离教学练习,真实认证与授权在 S3 完成。
22. 本章状态图能证明已读吗?
不能。它只到本进程内存受理,设备接收与用户阅读均需独立证据。
来源与下一步
- OWASP ASVS与授权检查指南:对象级授权与负例的需求参照。
- RFC 9110、Go
net/http:传输语义与以后实现教学接口的官方入口。 - OpenIM 固定提交的
SendMsg:本章只采用已读过的输入存在性和SessionType分支事实。
下一章09.02 HTTP 接口设计再把用例转成方法、路径、状态和错误体;完成本章前先能准确说出“可信身份从哪来、拒绝后什么状态保持不变、S2 内存受理还不能证明哪些事”。