09.02 HTTP 接口设计:会话历史与消息提交的可核对合同
返回第九卷 · 前置:09.01 应用边界 · HTTP 前置:04.03
09.02 HTTP 接口设计:会话历史与消息提交的可核对合同
DeepTutor 初稿经技术与教学审阅后的静态课程。路径、报文、状态和 Go 实现计划均为虚构教学合同;本次没有启动 HTTP 服务、运行 Go/测试、发送网络请求或构建站点。
把两张用例卡变成调用者看得懂的接口
09.01 已写清两个业务任务:可信主体 u-a 读取会话 c-a 的部分历史;u-a 向该会话提交文本消息 m-a,成功点仅为当前教学进程的内存状态已受理。04.03 已介绍方法、请求、响应与 HTTP 状态。现在要决定:调用者用哪个资源路径、什么方法、怎样分页、错误体长什么样、哪些输入会被拒绝?
当前仓库仍只有本地 imhistory CLI,没有实现或启动以下网络接口。全部用户、会话、消息和 chat.example.test 域名为虚构数据。固定 OpenIM 项目仍是以后源码对照,不把本章纸上 API 当作它现有接口。
| 起步 | 原理 | 业务迁移 |
|---|---|---|
| 资源、方法、状态、JSON 表示 | 游标、过滤、内容类型、限额与错误体 | 响应丢失、重复提交、对象级权限负例 |
一、资源和方法从用例推出来
本章选择同一资源集合的两种操作:
| 用户目标 | 教学 HTTP 请求 | 应用层成功范围 |
|---|---|---|
查看 c-a 可见历史 | GET /v1/conversations/c-a/messages | 返回本次有权读取的一页内存快照 |
提交 m-a 到 c-a | POST /v1/conversations/c-a/messages | 在本进程内存中完成一次新消息追加 |
/v1 表示这组网络 API 的版本,不自动等于 01.12 本地文件里的 version:1。c-a 在路径里是目标会话 ID;请求体不再重复一份可以与它冲突的 conversation_id。u-a 的可信身份由 09.01 的服务端认证边界提供,不来自请求 JSON 中随意填写的 sender_id。S2 私有隔离练习可用固定虚构主体测试业务规则,不能把它作为开放网络服务部署。
GET 的目标是读取资源表示,按 HTTP 安全方法语义不应故意修改消息、已读或会话状态;POST 用于向消息集合提交一项创建请求。本章没有把每条聊天消息做成 URL 中的路径参数,也不把 WebSocket 101 握手当成 POST 的替代业务结果。方法、路径、请求体和响应体共同构成 API 合同,改变其中一项可能影响客户端,不能只在 handler 里悄悄改名。
先固定公共边界:成功响应为 application/json;私密历史响应加 Cache-Control: no-store;读取和提交都必须先核对可信主体对 c-a 的具体动作权限。请求失败时,不返回受保护历史,也不修改已受理消息状态。字段和限额在后文细化。
二、GET 历史:范围、顺序、过滤与空结果
查询示意为:
GET /v1/conversations/c-a/messages?limit=20&before=<opaque-cursor>&sender=u-blimit 缺失时默认 20,有效范围定为 1–100;非法数字、0、负数或大于 100 均按本 API 的输入错误处理。before 是服务产生的不透明游标,表示“从上次返回位置更早的记录继续”,客户端只应保存并原样传回,不能自己解析成消息 ID 或猜测其中的内存序号。sender 是可选过滤条件,若给 u-b,只返回当前主体有权在 c-a 看到、且发送者为 u-b 的记录;过滤不会绕过会话授权。未知过滤值怎样处理也要固定:本章拒绝不支持的字段或值,不默默忽略。
顺序约定为本进程内该会话成功受理时分配的局部序号从大到小。这是 S2 内存列表顺序,不能当作客户端发送时间、跨进程全局序号或重启后永久不变的分页键。处理顺序是:授权该会话的读取范围 → 应用允许的过滤 → 按局部序号降序 → 用游标排除不早于边界的项 → 取至多 limit 条。若客户端把从 c-a 取得的游标放进 c-b 请求,应拒绝或按明确错误合同处理,不得由游标越权读到别的会话。
成功返回一页,可采用:
{
"conversation_id": "c-a",
"messages": [],
"next_cursor": null
}messages:[] 表示本次授权、过滤和游标范围没有返回项;next_cursor:null 表示本次查询没有更早的下一页,不保证该会话从未有消息,也不保证以后不会新增。响应还应在完整 API 中约定时间范围和是否受权限变化影响;本章用最小字段先守住“空不等于失败、单页不等于全部”。缓存限制和 JSON 类型必须在实际实现中一致处理,不能仅靠客户端猜。
三、游标怎样避免简单重复,又有哪些局限
给一个固定的纸上内存状态:同一 c-a 下 m-a 的局部序号为 1,m-b 为 2,m-c 为 3。若按序号降序、limit=2、不加 sender 过滤,第一页是 [m-c,m-b],返回的游标内部可表示“只取序号小于 2”。实际游标文本应由服务编码为不透明字符串;这里不规定算法,不把 message_id 直接当永久游标。
初始:seq 3 m-c | seq 2 m-b | seq 1 m-a
第一页 limit=2:m-c, m-b;next_cursor 指向排他边界 seq<2
第二页带 before:m-a;next_cursor=null若两页之间又成功追加 m-d、局部序号为 4,第二页的 seq<2 条件仍不会把新项或第一页的 m-c/m-b 重复返回;但这不是对整个历史的一次不可变快照,新项可能需要另一个“最新消息”查询才能看到。若有删除、权限变化或进程重启,内存序号、可见范围和游标有效性都可能变;API 必须给出游标过期/无效类别,不能让客户端无限重试同一 token。
加 sender=u-b 时,应先过滤,再按相同排序和游标规则分页。例如 m-c 和 m-a 由 u-b 发送、m-b 由 u-a 发送,limit=1 的第一页是 m-c,第二页 m-a;游标需与会话、排序和过滤条件绑定,不能换筛选条件继续套用而静默跳项。游标“看起来不可读”也不等于认证凭据,服务仍要逐次核对主体和会话权限。
四、POST 提交:正文、大小、重复和确认点
发送示意为 POST /v1/conversations/c-a/messages,请求 Content-Type: application/json,正文只包含:
{"message_id":"m-a","body":"你好"}这里没有 sender_id;服务从可信认证边界得到 u-a,从路径得到 c-a,从服务端会话状态得到 kind=single,再创建 09.01 的领域命令。客户端自填的 sender_id 或未知字段在本章新接口中严格拒绝,避免两个身份来源冲突。请求体为合法 JSON 仍不代表业务合法:message_id、正文非空、会话权限和同会话重复都要分别检查。
本题约定两种独立限额:整个 HTTP 请求正文最多 4096 B,用于保护解析资源;消息正文最多 6 UTF-8 字节,沿用 10.01 的教学规则。因 JSON 标点、字段名也占字节,len(requestBody) 与 len(message.Body) 不相同。01.12 的 1 MiB 是本地 JSON 文件 CLI 的资源上限,不应照搬成网络默认值。4096 B 与 6 B 都是这套教材的练习参数,不是生产 IM 产品推荐上限。
若合法且该 ID 在 c-a 尚未使用,服务在 05.02 的内存锁区完成一次追加,返回本章选择的 200 OK 与显式状态,例如:
{"message_id":"m-a","status":"accepted_in_memory"}本章用 200 表示提交请求已处理并返回当前内存受理结果。没有选择 201 Created 是因为本练习尚未向客户端承诺一个稳定的新资源 URI/Location;这不是说 201 在 HTTP 中等于“磁盘已持久化”。也不选 202 Accepted 来装作“工作还在异步排队”,因为这次教学状态已经在返回前完成内存变化。以后若改为持久化或异步队列,必须重新评审状态、正文、确认点与兼容影响。
如果已有 c-a/m-a,第二次即使正文相同,本章仍按现有规则拒绝为 409 Conflict,原值和列表顺序不变。这是当前重复拒绝合同,尚未提供“重试自动取得第一次结果”的幂等语义。若服务内存已受理、响应却在网络中丢失,A 观察到的仍是未知结果;它不能凭没收到 200 认定没保存,也不能盲目换新 ID 重试并假称不会重复。S5 再设计查询和幂等确认。
五、HTTP 状态和机器可读错误要成对设计
HTTP 状态码给通用调用者一个粗类别,应用错误体再告诉课程客户端是哪条规则。先把本章选择写成表,后续实现不可在不同 handler 里临时换数字:
| 情况 | 本章选择 | 机器错误码或说明 | 内存状态 |
|---|---|---|---|
| 合法 GET,含空列表 | 200 | 正常表示与分页字段 | 不变 |
| 合法 POST,写入本进程内存 | 200 | accepted_in_memory | 新增一项 |
limit=0、正文空、JSON 损坏、未知字段 | 400 | BAD_LIMIT、EMPTY_BODY、BAD_JSON 等 | 不变 |
| 正文 UTF-8 字节数超过 6 B | 400 | TOO_LONG | 不变 |
| 可信身份缺失或无效 | 401 | UNAUTHENTICATED | 不变 |
| 已知成员但此动作被禁止 | 403 | FORBIDDEN | 不变 |
| 会话不存在,或对非成员隐藏其存在 | 404 | NOT_FOUND | 不泄露受保护内容 |
| 同会话同消息 ID 已存在 | 409 | DUPLICATE_MESSAGE | 原正文和顺序不变 |
| 原始 HTTP 正文超过 4096 B | 413 | REQUEST_TOO_LARGE | 不进入成功处理 |
| POST 的媒体类型不支持 | 415 | UNSUPPORTED_MEDIA_TYPE | 不变 |
Accept 明确排除本接口唯一支持的 JSON | 406 | NOT_ACCEPTABLE | 不变 |
| 未预期内部失败 | 500 | INTERNAL,不回显堆栈/正文 | 不报告成功;前后状态需按故障点核对 |
401 不是“用户登录后没权限”,后者应按授权策略走 403 或为了隐藏目标而用 404。真正发出 401 的 HTTP 实现还须遵守 HTTP 认证挑战相关要求,本章并未实现认证服务。404 的外部表现可能刻意合并“无此会话”与“无权知道它存在”;应用内部仍应保留可审计的分类,响应不泄露成员列表或消息正文。服务器或代理返回的 HTTP 状态也要核对响应者,不能把一个 404 自动归因到 History.List。
选择一个简单的本课程自定义 JSON 错误体,不把它冒充 RFC Problem Details:
HTTP/1.1 400 Bad Request
Content-Type: application/json
Cache-Control: no-store
{"error":{"code":"BAD_LIMIT","message":"limit must be between 1 and 100"}}这里 code 是供客户端分支处理的稳定标识;message 给人阅读,可以随语言和表述改进,客户端不应拿整段英文做精确匹配。实际 HTTP/1.1 字节报文还须按协议正确处理 CRLF 与正文长度;代码块只呈现字段外形。错误体不回显原始 JSON、真实令牌或敏感消息正文。若以后采用 RFC 9457 Problem Details,应另定 application/problem+json、type 作为主要问题类型标识和扩展字段的版本合同;不能只把当前 JSON 改个媒体类型就宣称符合该标准。
未收到 HTTP 响应时,根本没有可记录的状态码。DNS 失败、连接拒绝、客户端超时和连接中断,应作为不同阶段的观察写出;内部进程可能已经处理了请求,也可能尚未见到。给未知结果硬填 500,会误导重试和用户提示。
六、内容类型、过滤、版本与三个限额
本章的 POST 仅接受 Content-Type: application/json;成功及错误响应按本章自定义合同使用 application/json。GET 可通过 Accept: application/json 请求它;若客户端明确表示不能接收 JSON,本教学 API 选择 406,不偷偷返回另一种未定义格式。HTTP 状态、头部、正文必须一致:若报 application/json,正文要是可解析的 JSON,不能在写了一半成功状态后又附上一段普通错误文本。
| 限额 | 作用对象 | 本章数值 | 不能替代什么 |
|---|---|---|---|
| GET 页大小 | 本次返回条数 | 默认 20,1–100 | 历史总量限制 |
| POST 原始正文 | HTTP 请求的 JSON 字节 | 最多 4096 B | 单条文本业务长度 |
| 领域文本正文 | 解码后的 body UTF-8 字节 | 最多 6 B | 01.12 本地文件大小 |
这些都是教学参数,不是线上 IM 的通用推荐值。请求 JSON 可能比 body 多很多字节;4096 B 先保护解析入口,6 B 再表达本章继承的业务规则,两个检查都需要。若资源上限未来变化,要更新接口说明、客户端提示、预期边界和兼容计划,不应在代码里悄悄放宽。
/v1 是网络 API 的版本标签;本地历史文件 version:1 是另外一份格式合同;消息载荷若也有独立协议版本,则是第三份。它们可能同时都写“1”,却不能相互代替。未知 JSON 字段在本章选择严格拒绝,方便发现拼错字段;未来若要向前兼容,须另设计新旧客户端可接受字段的矩阵。
历史里可能包含敏感消息,所以 GET 和错误响应示例加 Cache-Control: no-store。它是缓存策略,不是授权或加密;服务仍须先核对可信主体与会话可见范围,传输机密性在 04.07 讨论。过滤与游标也不能成为绕开授权的后门:先确认访问 c-a 的权利,再在可见集合里过滤与分页。
七、响应丢失、取消与接口评审
把 09.01 的应用状态时间线接到 HTTP:客户端提交 POST,服务端完成输入和权限检查,锁内把 m-a 写入内存,准备返回 200。若响应在路上丢失,客户端只观察到超时;状态可能已经变化。context 取消也是协作信号,不会把已完成的内存变化自动回滚。若错误发生在进入锁前,状态可保持不变;若发生在提交后,必须按实际提交点记录结果,不能把所有 err != nil 合并成“未受理”。
请求尚未到达 ──结果未知于客户端,但服务端可能未见请求
已解析未提交 ──若按合同拒绝,内存状态不变
锁内提交完成 ──内存已受理
响应发送中丢失 ──服务端已受理;客户端仍只知道结果未知本章重复 ID 一律返回 409,不提供自动“同请求重试返回第一次 200”的能力。这使客户端在超时后需要另一条查验路径或先停下提示未知结果;后续 S5 才决定稳定请求身份、重试窗口和幂等响应。不能让客户端把每次超时都换成新 message_id 重发并显示“肯定没重复”。
评审一份接口说明时,应逐项问:GET 空结果是否有范围和游标?sender 过滤先于分页吗?游标是否绑定会话和过滤?POST 的发送者从哪里来?400/409/413 是否改变内存?404 是否泄露非成员会话?500 或断线时是否可能已提交?JSON 响应是否可解析且无敏感正文泄漏?这些问题比“是否返回了某个好看的状态码”更接近业务正确性。
八、完整契约矩阵与分层练习
下面是学习者后续在自己隔离环境应逐条实现和记录的合同,不是本轮已运行的 Go 服务:
| 请求 | 预期 HTTP/业务结果 | 关键前后状态 |
|---|---|---|
有权 GET 空 c-a,默认 limit | 200,messages:[],next_cursor:null | 不变 |
有权 GET,三条记录,limit=2 | 200,m-c/m-b,再以游标得到 m-a | 顺序与游标规则一致 |
非成员请求 c-a | 本章选择 404 隐藏目标 | 不返回消息或成员详情 |
limit=0 | 400 BAD_LIMIT | 不变 |
有权 POST m-a/你好 | 200 accepted_in_memory | 仅内存新增一项 |
| POST 同会话同 ID 不同正文 | 409 DUPLICATE_MESSAGE | 旧正文不被覆盖 |
| POST 原始正文 4097 B | 413 REQUEST_TOO_LARGE | 不新增 |
POST JSON 合法、body=你好呀 | 400 TOO_LONG | 不新增 |
| POST 提交后响应丢失 | 客户端无 HTTP 状态,结果未知 | 服务端可能已新增,需另行核对 |
分层练习:先回答,再看反馈
1. 读历史和提交消息分别选什么方法?
本章用 GET 读取集合表示、POST 向消息集合提交一条新消息。
2. `/v1` 与本地文件 `version:1` 是同一版本吗?
不是。前者属于网络 API,后者属于本地文件格式。
3. `c-a` 在路径里后,正文还要复制 `conversation_id` 吗?
本章不复制,避免路径与正文指向两个不同会话。
4. 客户端 POST 的 `sender_id` 能决定可信作者吗?
不能。本接口严格拒绝该字段,作者来自可信认证边界。
5. GET 省略 limit 时取多少?
本课程默认 20 条,合法范围 1–100。
6. limit=0 与 limit=101 怎样处理?
都不在合同范围,按本章 400 BAD_LIMIT 拒绝。
7. `messages:[]` 和 `next_cursor:null` 证明从未有历史吗?
不能。它们只描述本次授权、过滤和游标范围。
8. 游标为何不让客户端自己当消息 ID 解析?
服务需能改变内部表示,并绑定会话、排序、过滤与有效期;客户端只应原样传回。
9. 第一页 m-c/m-b 的排他游标后,第二页返回谁?
在本题固定状态中返回序号更小的 m-a。
10. 两页之间新增 m-d,旧游标会自动给一份历史快照吗?
不会。旧游标的下一页仍按“更早”走,新消息要从最新方向另查。
11. 把 `c-a` 的游标拿去查 `c-b` 合适吗?
不合适。服务应核对游标的会话和过滤约束,不能越权或静默跳项。
12. POST JSON 总长 4096 B 与正文上限 6 B 一样吗?
不一样。前者是 HTTP 原始正文资源限额,后者是解码后文本业务限额。
13. 01.12 的 1 MiB 可以直接当这套网络接口上限吗?
不能。它只属于本地文件 CLI,网络接口有独立合同。
14. POST 本章为何返回 200,而非用 202 表示排队?
本章返回前已完成内存受理;并没有只“接下异步处理任务”的承诺。
15. HTTP 201 一定表示磁盘持久化吗?
不一定。201 表达资源已创建;本章未承诺稳定的新资源 URI,也未选用它。
16. 同会话重复 m-a 何时返回 409?
本章只要同键已有消息就拒绝,原消息和列表顺序不变。
17. 401 与 403 的业务前提相同吗?
不相同。401 与认证需求相关;403 表示响应方理解请求却拒绝该动作。
18. 404 一定表示服务器没有 c-a 吗?
不能。它也可用于不披露被保护资源是否存在;还需确认响应方。
19. 本章自定义错误 JSON 自动符合 RFC 9457 吗?
不符合。Problem Details 有自己的媒体类型与 type 等合同。
20. 调用方能靠 `message` 文本做稳定分支吗?
不应如此。按稳定的机器 code 分支,文字可本地化或调整。
21. POST 已在内存提交但响应丢失,客户端能记 HTTP 500 吗?
不能。它没收到状态码,只能记录未知结果;服务端状态可能已变化。
22. 200 `accepted_in_memory` 能证明 u-b 已收到吗?
不能。它只覆盖本进程内存受理,设备接收和已读需独立证据。
来源与下一步
- RFC 9110:HTTP 语义:方法、状态、内容类型与资源约定。
- RFC 9457:Problem Details:若以后采用标准错误表示,必须重新定义类型与媒体合同。
- Go
net/http、OWASP 授权指南:后续个人实现和对象级权限负例的资料入口。
下一章09.03 程序组织才把这个接口合同拆为 handler、应用服务、领域规则和适配器;进入前应能给每个失败案例同时标出 HTTP 状态、稳定错误码和内存前后状态。