这一讲和前几讲不一样:它不解释机制,它给结论。
“服务端不存在临时状态” · “凡是想对 HTTP 取而代之的都会挂掉” · “AK/SK 不是公私钥” ·
“To B 用 AK/SK、To C 用 Token” · “GraphQL 不温不火” ——
每一句都是一个判断,但判断背后的推理被省掉了。而这一讲又是全章唯一讲"业务架构"的一讲,原文自己也承认"业务逻辑和行业相关,无法进一步展开"。
所以这份带读的价值全在两件事:把判断背后的机制补出来,以及站远一点看它。
怎么读这份带读
| 这一部分是 | 建议 | |
|---|---|---|
| 第一部分 · 骨架(一) | 这一讲在整章什么位置 + "无状态"这三个字的分量 | 先读,它是全篇地基 |
| 第二部分 · 机制(二~四) | 四条建议各自的"为什么",配可跑脚本 | 主体 |
| 第三部分 · 更广的视角(五~八) | ⚠ 非原文。2019 → 2026 这七年:哪些站住了、哪些要修正、原文没提但已成必需品的五件事 | 想要"更广更深"就是这里 |
★ 如果对 RESTful 本身没底(知道有这个词,但说不清它到底约束了什么),
先看第 2.1 节——它从零讲起:网络 API 是什么、HTTP 请求长什么样、RESTful 到底换了什么位。
这一讲的演示脚本(推荐按这个顺序跑):
| # | 脚本 | 演什么 | 配合 |
|---|---|---|---|
| 1 | 代码/restful是什么.py |
RESTful 到底是什么——真起一个 HTTP 服务、打印原始字节 · 状态码 | 二(先跑这个) |
| 2 | 代码/无状态到底是什么.py |
有状态死在哪 · 临时状态被赶到了两端 | 一 |
| 3 | 代码/动词不是命名规范.py |
动词 = 一份声明(能不能缓存、能不能重试) · 幂等键 | 二 |
| 4 | 代码/AKSK和Token.py |
HMAC 签名跑一遍 · 判据是"有几方"不是 To B/To C | 三 |
第一部分 · 骨架
一 · 这一讲在什么位置,以及"无状态"这三个字
python3 代码/无状态到底是什么.py
1.1 先定位:整章讲到这里为止的地图
| 讲 | 讲的是 | 在架构图的哪一块 |
|---|---|---|
| 34 | 服务端开发的宏观视角 | 全景 |
| 35 | 流量调度与负载均衡 | 入口那一层 |
| 36~39 | 存储中间件 · 数据库 · 对象存储 · 缓存 | 底下那一层 |
| 40(本讲) | 业务架构 | 中间那一块——终于轮到它了 |
而"业务架构"这块,原文按 24 讲的图分成两层,然后只讲下面那层:
| 层 | 是什么 | 本讲讲不讲 |
|---|---|---|
| Web 层(Session-based Model + ViewModel) | Model 是简单转译层;ViewModel 在胖前端下基本没有后端代码 | 不讲(作者把 ViewModel 归到桌面开发范畴) |
| Multi-User Model 层 | 多租户核心业务,对外一套 RESTful API | 本讲全部内容 |
★ 注意"Multi-User"这个词。 桌面程序的 Model 是单用户的(36 讲那棵 DOM 树只服务一个人);
服务端的 Model 从第一行代码起就是多租户的。
这一个词决定了后面所有事情:为什么要授权、为什么要限流、为什么要分片。
1.2 ★ 为什么服务端"只有 Model,没有 Controller"
原文的推理链:
桌面程序:Controller 把【多个连续的用户交互事件】拼成一项业务
→ 中间必然存在"临时状态"(画矩形:按下→拖动→松手)
服务端: 每个网络 API 请求都自带完成业务的完整参数
→ 没有临时状态 → 不需要 Controller → 服务端就是 Model 层
这就是 REST 名字里那个 State Transfer 的意思:状态是被"转移"过来的(客户端每次都把状态带来),不是"存在服务端等着凑齐"。
1.3 ★ 但"无状态"不是设计品味,是能不能加机器的前提
原文把无状态说成 REST 原则,读起来像个规范要求。它其实是 35 讲那个负载均衡的硬约束。 脚本演示了有状态版死在哪:
class 有状态服务器:
def __init__(self):
self.会话 = {} # ← 临时状态,活在本进程的内存里
张三在【甲机】登录,加购苹果、香蕉 —— 一切正常
① 下一个请求被 nginx 丢给【乙机】 → ✗ 乙机根本不认识这张票,购物车凭空消失
② 或者甲机重启(发版、OOM、故障) → ✗ 同样没了
于是你只有两条烂路:
| 烂路 | 后果 |
|---|---|
| 粘性会话 sticky session | 甲机一挂,它上面所有会话全丢;负载不再均衡;扩容加了机器,老用户一个都挪不过去 |
| 会话在机器间互相同步 | N 台机器两两同步,这是个分布式一致性问题(37 讲)。为了一个购物车你搭了一套 Raft |
★★ 所以:
无状态 = 任意一台机器都能处理任意一个请求 = 加机器就能扩容 有状态 = 请求必须回到"记得我"的那台机器 = 扩不动,而且单点这跟 37 讲"业务服务器无状态所以能随便加,存储有状态所以要搬数据"、
38 讲"去可变才能水平扩展"是同一个判断在不同层面的复现。
无状态之所以是原则,是因为它是"能加机器"的定义。
无状态版长这样——状态塞进请求本身(这就是 JWT 的原理):
class 无状态服务器:
def __init__(self, 名字):
self.名字 = 名字 # ← 没有任何 self.会话
def 加购(self, 令牌, 商品):
身份 = 验令牌(令牌) # 每个请求自带完整信息,就地验一下
...
甲机 ✓ 我认得出你是 张三
乙机 ✓ 我认得出你是 张三
刚重启的丙机 ✓ 我认得出你是 张三
这正是原文引的 REST 定义里最后半句的分量:
“服务器可以在请求之间的任何时间点重启,客户端不会得到通知。”
——重启这件事,对客户端来说无所谓。
1.4 ★ 但临时状态并没有消失,它被赶到了两端
购物车、多步表单、支付流程——这些明摆着就是"多个连续请求拼成一项业务",也就是 Controller 那种临时状态。原文说服务端"并不存在临时状态",这句话需要一个更准确的版本:
| 状态放在哪 | 谁能处理请求 | 活过重启吗 | 代价 |
|---|---|---|---|
| 进程内存(会话) | 只有那一台 | ✗ | 扩不动、单点 |
| 存储(资源) | 任意一台 | ✓ | 多一次存储访问 |
| 客户端(令牌) | 任意一台 | ✓ | 会被篡改(要签名)、请求变大 |
做法一:把临时状态变成存储里的一个资源
POST /carts → 创建资源 cart-1
PUT /carts/cart-1/items → 加苹果
PUT /carts/cart-1/items → 加香蕉 ← 换了一台机器,照样能用
★ 注意这里发生了什么:那个隐式的"会话状态",变成了一个有名字的、显式的资源。
于是它同时获得了持久性、可查询、可被用户自己看见(“我的购物车”)。原文说 REST 强调"一切抽象为对资源 URI 的操作"——这就是它真正的用处:
它逼你把隐式的会话状态,变成显式的资源。 不是为了 URL 好看。
做法二:交给客户端(多步表单把前面填过的带回来、分页让客户端带 cursor)。
★★ 所以准确的说法是:
会话状态可以有,但不能放在进程内存里。
放进存储,它就是 Model 的一条记录;放进令牌,它就是请求参数。
这两种都不影响"服务端是 Model 层"这个结论——原文那句"存在 Session 就太糟糕了"稍微绝对了些。
第二部分 · 四条建议背后的机制
二 · 网络协议:为什么是 RESTful
python3 代码/动词不是命名规范.py
2.1 ★ 从零说清:RESTful 到底是什么
python3 代码/restful是什么.py # ← 这个脚本会真起一个 HTTP 服务、真发请求,把原始字节打出来
如果对 RESTful 本身没底,从这一小节开始。 后面 2.4 起那些约束和判断,都是它的后果。
网络 API = 隔着网络调一个函数
本机调函数,你写:
def 查文章(id):
return 数据库[id]
查文章(1) # 直接调,Python 帮你把参数传进去
现在客户端在另一台机器上,它没法直接调你的函数。唯一能穿过网络的东西是一串字节。
★ 所以网络 API 的全部问题就一个:怎么把"要调哪个函数、参数是什么"编码成一段能在网络上传的文本?
HTTP 是一种编码约定;RESTful 是在 HTTP 之上再加的一层约定,规定"函数名和参数分别写在哪"。
一个 HTTP 请求就是几行纯文本
脚本真起了个服务器、真发了个请求,网络上跑的就是这些字节:
┌─ 客户端真的发出去的 ──────────────
│ GET /articles/1 HTTP/1.1
│ Host: 127.0.0.1:53211
│ User-Agent: Python-urllib/3.12
├─ 服务端真的回来的 ────────────────
│ HTTP/1.1 200
│ Content-Type: application/json; charset=utf-8
│
│ {"id": 1, "title": "原稿", "author": "张三"}
└──────────────────────────────────
第一行是「方法 + 路径」,后面几行是头,空一行,然后是 body。 就这么点东西——纯文本,telnet 手打都能发。HTTP 一点都不神秘。
于是问题变成:把"调哪个函数、参数是什么"写在这几行的哪里? 这里有得选。
★★ 两种写法,RESTful 只是其中一种
写法 A · RPC 风格——把函数名塞进路径,参数全放 body:
POST /getArticle {"id": 1}
POST /createArticle {"title": "新文章"}
POST /updateArticle {"id": 1, "title": "改了"}
POST /deleteArticle {"id": 1}
这完全能用。 很多老 API、很多内部接口就长这样。特征:路径里是动词,方法永远是 POST。
写法 B · RESTful——把"东西"放路径,动作交给 HTTP 方法:
GET /articles/1 (不需要 body)
POST /articles {"title": "新文章"}
PUT /articles/1 {"title": "改了"}
DELETE /articles/1 (不需要 body)
特征:路径里是名词,动作在方法上。
★★ RESTful 的全部内容,就是这一个换位:
路径里放名词(资源),动作交给 HTTP 方法。剩下所有的规矩(状态码、幂等、可缓存、无状态),都是这个换位带来的后果——从 2.2 起逐个看。
「方法」有两层意思,而它们一一对应
这里最容易卡住的是"方法"这个词,它在两个层面上都成立:
| "方法"指 | 是什么 | 例子 |
|---|---|---|
| HTTP 方法(协议里的词) | 写在请求第一行的那个词 | GET /articles/1 HTTP/1.1 里的 GET |
| 代码里的方法 / 函数 | 你写的处理函数 | do_GET |
而它们是一一对应的——看脚本 代码/restful是什么.py 里那个处理器:
def do_GET(self): ... # 收到 GET 就走这里
def do_POST(self): ... # 收到 POST 就走这里
def do_PUT(self): ... # 收到 PUT 就走这里
def do_DELETE(self): ... # 收到 DELETE 就走这里
★ 所以"动作在方法上"这句话,在协议层和代码层是同一件事:
HTTP 方法进来 → 分派到同名的代码方法。
那 /1 是什么:URL 就是一个「路径式的下标」
/1 是编号——“哪一个”。而整条 URL 其实就是一串下标:
文章库 = {1: {...}, 2: {...}} # 这就是 /articles
文章库[1] # 这就是 /articles/1
文章库[1]["comments"][5] # 这就是 /articles/1/comments/5
| URL | Python 里对应什么 |
|---|---|
/articles |
文章库 —— 整个集合 |
/articles/1 |
文章库[1] —— 其中一条 |
/articles/1/comments |
文章库[1]["comments"] —— 那条下面的评论集合 |
/articles/1/comments/5 |
文章库[1]["comments"][5] |
★
/articles/1读作"文章集合里的第 1 号"。斜杠就是往下钻一层,跟 Python 里连着写[...]一模一样。
组合起来:URL 说「对谁」,方法说「干什么」
GET /articles/1 → 读 文章库[1]
PUT /articles/1 → 替换 文章库[1] = 新值
DELETE /articles/1 → 删 del 文章库[1]
POST /articles → 加一个新的 文章库[新编号] = 值
每一行都是一句完整的话:动词 + 宾语。而这两半分别写在请求的两个不同位置。
用 FastAPI 写出来最清楚:
@app.get("/articles/{id}") # 方法=GET,路径里 {id} 是占位符
def 读文章(id: int): # ← 那个 "1" 变成了函数参数
return 文章库[id]
@app.delete("/articles/{id}") # 同一个路径,换个方法
def 删文章(id: int): # ← 走到完全不同的函数
del 文章库[id]
★★ 关键:同一个路径
/articles/1,配不同的 HTTP 方法,走到不同的函数。
路径决定"对谁",方法决定"干什么"——两个维度,交叉出一张表。
对比写法 A(RPC 风格),差别一眼就看出来了:
@app.post("/getArticle") # 路径里就写着 get
@app.post("/deleteArticle") # 路径里就写着 delete
@app.post("/updateArticle")
# ★ 方法全是 POST —— 那一维空着没用,路径承担了全部信息
把写法 B 真跑一遍
① POST /articles → HTTP/1.1 201
Location: /articles/2
{"title":"新文章","author":"李四","id":2}
注意两处:状态码是 201 Created 不是 200(“我给你造了个新东西”);响应头里有 Location: /articles/2(“新东西在这个地址”)。
★ 服务端在告诉你"下次去哪找它"——这就是"资源"这个概念的实际用处。
对比写法 A:POST /createArticle返回一个 id,客户端得自己知道"拿这个 id 该去调 getArticle"。
而 RESTful 把"它在哪"直接写在了响应里。
② PUT /articles/2 → 200,返回替换后的整个对象
③ DELETE /articles/2 → 204 No Content(成功了,而且没内容要给你)
④ GET /articles/2 → 404 {"error": "not found"}
一个你之后一定会遇到的不对称:为什么只有 POST 打集合
回头看上面那四个操作:只有 POST 打的是 /articles(集合),其他三个打的都是 /articles/1(单个)。
★ 因为新建的时候,你还不知道编号是多少——编号是服务端分配的。
所以你只能对着集合说"给我加一个",服务端造好之后再用响应头告诉你编号:POST /articles → 201 Created Location: /articles/2 ← "你要的东西在这个地址"这就是上面那个
201+Location的用处:它在补上你还不知道的那个/2。
而 PUT /articles/1 能直接打单个,是因为编号是你自己指定的(“把 1 号换成这个内容”)——所以它甚至可以用来创建:1 号不存在就建一个。
★★ 而这正好解释了 PUT 为什么幂等(第 2.5 节要用到):
你说了编号,做一次和做三次结果一样。
POST 不幂等,正因为它没说编号——每次都新分配一个,做三次就是三条。
| 打谁 | 编号谁定 | 幂等 | |
|---|---|---|---|
POST /articles |
集合 | 服务端(响应 Location 告诉你) |
✗ |
PUT /articles/1 |
单个 | 你自己 | ✓ |
DELETE /articles/1 |
单个 | 你自己 | ✓ |
GET /articles/1 |
单个 | 你自己 | ✓ |
顺带一句:
DELETE /articles/1是删那一条,DELETE /articles是删整个集合(清空所有文章)——
后者极少有 API 会真的实现,太危险了。"打集合"和"打单个"是两件完全不同的事。
2.2 状态码:又一份「声明」
RESTful 不只用 HTTP 的方法,也用它的状态码。五类:
| 类 | 含义 | 常见的 |
|---|---|---|
| 2xx | 成功 | 200 OK · 201 Created(新建了)· 204 No Content(成功但没内容) |
| 3xx | 重定向 | 301 永久搬走 · 304 Not Modified(★ 缓存用:你手上那份还是新的) |
| 4xx | 你错了 | 400 参数错 · 401 没登录 · 403 没权限 · 404 不存在 · 409 冲突 · 429 太频繁 |
| 5xx | 我错了 | 500 内部错误 · 502/503 暂时不可用 · 504 超时 |
★★ 4xx / 5xx 的分界不是"谁的锅"这种情绪问题,它是一份「要不要重试」的声明:
4xx → 别重试。你的请求本身有问题,重发一百次还是一样错。 5xx → 可以重试。是我这边暂时不行,过一会儿可能就好了。所以状态码分错了,客户端的重试逻辑就会错:
参数错了却返回 500 → 客户端一直重试把你打死;
服务器挂了却返回 400 → 客户端直接放弃,其实重试一下就成了。
这和方法是同一个套路(下一小节会把方法这一半讲透):
方法 声明"能不能缓存、能不能重试"
状态码 声明"这次失败了要不要重试"
★ RESTful 的本质,就是用 HTTP 里那些标准化的位置,去声明这些机器能读懂的意图。
2.3 什么不是 RESTful(其中一个真会出事)
POST /api/getUserInfo ← 路径里有动词,这是 RPC 风格
POST /api/user/update ← 同上
GET /api/deleteUser?id=1 ← ★★ 这个是真会出事的
为什么最后那个危险?因为全世界的软件都认为「GET 是只读的、可以随便调」:
| 谁 | 会对 GET 做什么 |
|---|---|
| 浏览器 | 预取页面上的链接,好让你点的时候快一点 |
| CDN / nginx | 缓存 GET 的结果 |
| 爬虫、安全扫描器、聊天软件的链接预览 | 自己去访问一遍 |
| 客户端库、网关 | 超时后自动重试 |
你把删除写成 GET,等于把"删除"这个动作交给以上所有东西去随便触发。
★ 这不是理论担忧。2005 年 Google 出过一个叫 Web Accelerator 的浏览器插件,它会预取网页上的链接。
结果一批网站的后台管理页面数据被大量删除——因为那些"删除"按钮是 GET 链接。
顺手记住三条 URL 惯例:
| 惯例 | |
|---|---|
| 集合用复数 | /articles 不是 /article |
| 路径里不出现动词 | 见到 /getArticles 就知道那不是 REST 风格 |
| 层级只表达真正的从属关系 | 评论属于文章 → /articles/1/comments;但别嵌到三四层 |
常见的完整模式(你在 API 文档里见到的就是这个):
GET /articles 列出所有
POST /articles 新建(服务端分配 id,返回 201 + Location)
GET /articles/1 读一个
PUT /articles/1 整体替换
PATCH /articles/1 只改几个字段
DELETE /articles/1 删除
GET /articles/1/comments 第 1 篇的评论 ← 嵌套资源
POST /articles/1/comments 给第 1 篇加评论
GET /articles?author=张三&page=2&size=20 ← 过滤/分页放查询串
2.4 REST 原本的六条约束
原文强调两点,其实 Fielding 那篇博士论文里有六条约束:
| # | 约束 | 原文提了吗 |
|---|---|---|
| 1 | 客户端-服务端分离 | 隐含 |
| 2 | 无状态(Stateless) | ✓ 重点讲了 |
| 3 | 可缓存(Cacheable) | ✗ 没提,但它是第 2.5 节的一半 |
| 4 | 统一接口(Uniform Interface) | ✓ 就是"Representational"那部分 |
| 5 | 分层系统(可以插网关、代理、CDN) | 提到了 nginx,但没点明这是一条约束 |
| 6 | 按需代码(可选) | ✗ |
★ 一个业内常识:大多数人说的"RESTful"其实只做到了"HTTP 动词 + 名词 URL",
远没到 Fielding 的标准(第 6 条之外还有 HATEOAS,即响应里带着可用操作的链接)。
Fielding 本人抱怨过这个词被滥用。
本讲不需要纠这个——但要知道 REST 的价值不在"优雅",在第 2、3、5 条给你的东西。
2.5 ★ 动词不是命名规范,是一份声明
这是全讲最值得挖的一处,而原文只说了"统一抽象为 GET/PUT/POST/DELETE"。
先看一个前提:网络会超时,而超时的含义是"不知道成功没有"。
你发了请求,等 30 秒没回复。三种可能:
① 请求没到服务端 → 该重试
② 到了,服务端还没处理完 → 重试可能重复执行
③ 处理完了,回复丢在路上 → 【重试一定重复执行】
★ 客户端【无法区分这三种】。所以它只能问:这个操作能安全重试吗?
脚本把四个动词各重试 3 次:
动词 重试 3 次之后 幂等?
GET 文章还是原样 ✓
PUT 文章是 {'标题': '改过的'} ✓
DELETE 文章是 {} ✓
POST 创建了 3 篇!id=[2, 3, 4] ✗
★ 所以"用哪个动词"不是好看不好看:
用 PUT / DELETE → 客户端和网关敢帮你重试;用 POST → 谁都不敢替你重试。一个很常见的设计错误:
POST /articles/1/like ← 不幂等,网络一抖就点了两个赞 PUT /articles/1/likes/张三 ← 幂等,重试多少次都是"张三赞过了"同一个业务,换个动词和 URI,就从"不能重试"变成"能重试"。
另一半是可缓存性。 脚本模拟了一个只缓存 GET 的网关,同一个"读文章"接口两种写法各来 100 个请求:
GET /articles/1 → 后端被调用 1 次
POST /getArticle → 后端被调用 100 次
★ 那 100 次里,GET 版有 99 次根本没到你的服务器——nginx / CDN / 浏览器就挡掉了。
39 讲花了一整讲讲"怎么给存储加缓存"。而这里:把动词写对,就白拿了一层缓存;写错了,这层白送的缓存直接没了。
这才是原文那句"只有 HTTP 协议,才有被广泛采纳的专门的应用层网关,比如 nginx 和 apache,
这一点千万不要忘记"的真实价值——那些网关看得懂你的动词,所以能替你干活。前提是你按规矩说话。
一张表收口:
| 动词 | 幂等 | 可缓存 | 于是你得到 / 失去什么 |
|---|---|---|---|
| GET | ✓ | ✓ | 网关/CDN/浏览器替你挡流量;超时随便重试 |
| HEAD | ✓ | ✓ | 同上(只要头不要体) |
| PUT | ✓ | ✗ | 超时可以重试 |
| DELETE | ✓ | ✗ | 超时可以重试 |
| POST | ✗ | ✗ | 超时不能重试 → 必须自己加 Idempotency-Key(第七节) |
| PATCH | ✗* | ✗ | *取决于 patch 语义,通常不幂等 |
2.6 ★ “凡是想对 HTTP 取而代之的,都会挂掉”——这句话的机制
原文列了候选,然后给了这句最狠的判断。它的机制不是技术优劣,是生态锁定:
HTTP 的价值不在协议本身,在它周围那一圈【免费的】东西:
nginx / apache / envoy ← 应用层网关,看得懂你的动词和路径
CDN ← 全球替你缓存 GET
浏览器 ← 直接能调,不用装任何东西
curl / Postman / 抓包工具 ← 调试、给客户演示、写文档
WAF / API 网关 / 限流器 ← 现成的防护和治理
所有语言的 HTTP 客户端库 ← 客户不用等你出 SDK
你换掉 HTTP,就同时放弃了这一圈。全都要自己造。
而 protobuf 只换掉了 body 的序列化格式,那一圈东西全都还在:
原文说得很准:“protobuf 是二进制的,但它取代的不是 HTTP 协议,而是 json、xml 或 Web 表单。”
“这可能也是 protobuf 还很活跃,而 thrift 已经半死不活的原因。”
2.7 ★ 这个判断的通用版本
这句话远不止关于 HTTP。它是 32 讲"使用界面"那个话题的一个推论:
| 你想换掉 | 同时放弃的那一圈 |
|---|---|
| SQL | 所有 BI 工具、ORM、报表、DBA 的全部经验 |
| POSIX 文件接口 | 所有命令行工具(grep、tar、rsync……) |
| Git | 整个 CI / 代码托管 / review 生态 |
| HTTP | 上面那一整张表 |
★★ 判据:你要替换的那一层,周围挂了多少别人写的免费东西?挂得越多,越换不动。
而"只换里面一层、保留接口"永远是可行的路——protobuf 走的就是这条。反过来说,这也解释了 32 讲那句"接口比实现值钱"为什么值钱:
接口值钱不是因为它设计得好,是因为它周围长满了别人的东西。
三 · 授权:AK/SK 与 Token
python3 代码/AKSK和Token.py
3.1 AK/SK 就是 HMAC 数字签名
原文说"AK/SK 授权的背后是数字签名"、“AK 是密钥提示(keyHint),SK 是数字签名的密钥(key)”,但没展开。脚本跑了一遍——客户端真正发出去的东西里没有 SK:
方法 POST
路径 /v1/transfer
时间戳 1788162825
body {"to":"李四","amount":100}
Authorization MY-HMAC AK=AKIDzhangsan001, Signature=0dc86e4a50af3c7b...
服务端验证 → ✓ 通过,确认是 AKIDzhangsan001 本人,且请求内容未被篡改
签名串 = 方法 + 路径 + 时间戳 + body 一起做 HMAC。于是三种攻击都挡住:
① 中间人把 100 改成 10000 → ✗ 签名不匹配(完整性)
② 中间人改了路径 → ✗ 签名不匹配(完整性)
③ 重放一个 999 秒前的请求 → ✗ 时间戳过期(新鲜性)
| 作用 | |
|---|---|
| AK | 公开的,就是个名字。唯一作用是让服务端知道"该拿哪把 SK 来验" |
| SK | 永远不上网。客户端用它算签名,服务端用同一把重算比对 |
3.2 ★ 为什么"不是公私钥",以及这个选择的代价
关键区别只有一句:服务端手上有没有那把能签名的密钥。
| AK/SK(对称 · HMAC) | 公私钥(非对称) | |
|---|---|---|
| 服务端存什么 | 必须存每个用户的 SK,还得能读出原文 | 只存公钥 |
| 密钥库泄露 | ✗ 所有用户的 SK 全泄露 | ✓ 完全无害 |
| 内部人员风险 | ✗ 能读那张表的人可以伪造任何请求 | ✓ 服务端根本不持有能伪造签名的东西 |
| 速度 | ✓ 一次几微秒 | ✗ 慢几个数量级 |
★ 那为什么云服务还是普遍用 AK/SK?因为每一个 API 请求都要验一次签。
对象存储一天可能几百亿次请求——这里的每微秒都是钱。业界的取舍是:用 HMAC 的速度,然后拿运维手段补它的短板——
SK 在服务端加密存储(KMS)、支持密钥轮换、提供临时凭证 STS(短期有效的临时 AK/SK/Token)、细粒度权限策略缩小泄露的杀伤面。★★ 这是一个很典型的架构取舍,值得记住它的形状:
不是选"更安全的那个",是选"够安全 + 性能扛得住"的那个,然后用运维手段补上缺口。
3.3 ★ 真正的判据不是 To B / To C,是"有几方"
原文说"AK/SK 多数发生在 To B,Token 多数发生在 To C"。这是现象。原因是这个:
AK/SK 是【两方】问题
企业自己的服务器 ──调用──▶ 你的 API
双方都是"自己人",SK 可以直接给对方保管。
没有第三方,也就没有"要保护谁的隐私"这个问题。
OAuth 是【三方】问题
用户 + 第三方应用 + 你的服务
场景:某个记账 App 想读用户在你这儿的账单。
没有 OAuth 的年代,第三方只能这么干:
记账App: "请输入你在【我们的服务】的用户名和密码,我帮你去取账单"
✗ 用户把密码交给了一个不认识的 App
✗ 这个 App 从此能干任何事:改密码、转账、删账号
✗ 用户想收回权限,唯一办法是【改密码】,而这会踢掉所有其他 App
✗ 这个 App 存了你的明文密码,它被黑 = 你被黑
有了 OAuth 2.0(授权码流程):
① 记账App 把用户【跳转】到你的服务的授权页
② 用户 在【你的页面上】登录(密码只给了你,App 全程看不到)
③ 你的服务 问用户:允许「记账App」读取你的账单吗?(只读,不含转账)
④ 用户 点「同意」
⑤ 你的服务 发一个授权码给记账App
⑥ 记账App 用授权码换到一个 access_token
⑦ 记账App 拿 token 调 API —— 只能读账单,权限就这么大
| 换来什么 | |
|---|---|
| 密码从未离开"你的服务" | 原文:“不用向第三方应用去暴露自己的用户隐私” |
| token 有范围(scope) | 只读账单,不能转账 |
| token 有有效期,可单独撤销 | 撤掉一个 App 不影响别的 |
| 服务端知道"谁在代表哪个用户调用" | 可以分别限流、审计 |
★★ 所以判据是这一句:有没有第三方要代表用户行事?
场景 几方 用什么 企业服务器调你的 API 两方 AK/SK 你自己的 App 调你自己的后端 两方 token(不需要完整 OAuth 跳转,但要支持登出/多端) 别人的 App 代表用户调你的 API 三方 OAuth 2.0 你的服务去调别人的 API 拿用户数据 三方(你是那个第三方) OAuth 2.0 没有第三方却上完整 OAuth,那套跳转纯属负担;有第三方却用 AK/SK,你就是在要用户的密码。
3.4 原文那句最容易跳过、但最重要的话
“我们更多要考虑的,反而是如何构建业务无关的用户帐号体系和授权系统。”
为什么这句最重要? 因为"选 AK/SK 还是 OAuth"是一次性选型,而帐号与授权是会被每一个业务、每一个接口依赖的子系统。它要是和业务缠在一起,后面每加一个业务都要重做一遍权限。
★ 这正是 32 讲那条判断的应用:认准稳定点(帐号、鉴权、权限模型),抽成独立子系统;
变化点(具体业务)挂在上面。
而 1.1 节说过,Multi-User Model 从第一行代码起就是多租户的——"谁是谁、谁能干什么"是这里最先稳定下来的东西。
四 · RPC 框架与单元测试
原文这两节推荐了七牛自家的两个库(qiniu/http 的 restrpc、qiniu/httptest)。具体的库不重要,重要的是它们各自在回答什么通用问题。
4.1 restrpc 那五条特性,翻译成通用需求
| 原文说的 | 通用需求 | 任何语言里对应什么 |
|---|---|---|
| URL 路由(手工 / 自动) | 把 URL 映射到函数 | Flask 的 @app.route、FastAPI 的 @app.get |
| 参数解析(json / form) | 把请求体变成业务对象 | pydantic model、dataclass |
| 返回值序列化(默认 json) | 把业务对象变成响应体 | 同上,反向 |
| 授权(开放机制) | 鉴权必须是可插拔的 | 中间件 / 依赖注入 |
| 适度的开放机制 | 框架要留扩展点 | 中间件链 |
# FastAPI 版:原文那五条特性,现在基本都是框架自带
@app.post("/v1/transfer") # 路由
def transfer(req: TransferReq, # 参数解析(pydantic)
who = Depends(验签)) -> TransferResp: # 授权(可插拔)+ 返回值序列化
...
★ 所以这一节今天读起来价值最低——它当年要自己写的东西,现在框架都给了。
但那条"授权要以开放框架的方式实现,以便用户选择自己的授权方式"依然是好判断:
鉴权是最容易被写死进框架、然后再也换不掉的东西。
4.2 ★ httptest 的核心思想:测试要打在"使用界面"上
原文说 httptest"最核心的逻辑是如何在不用写业务 API 的 Client SDK 的情况下,能够保持业务友好的方式来写测试案例"。
这句话值得翻译一下:常见的做法是先写一个 Client SDK,然后用 SDK 写测试。问题是:
| 那样做的问题 | |
|---|---|
| 你在测 SDK,不是在测 API | SDK 有 bug 会掩盖 API 的 bug,反之亦然 |
| SDK 要跟着 API 改 | 每加一个接口要动两处 |
| SDK 会替你"修正"请求 | 它可能自动补了默认值、自动重试——而真实客户端不会 |
httptest 的选择是直接对着 HTTP 报文写测试,但用一种业务友好的 DSL,让它读起来不像在拼字符串。
★ 这正是 32 讲那句话的测试版:
“你都不需要看实现细节,只需要看他定义的模块、类和函数的使用接口。”
测试也一样——要打在使用界面上,不要打在实现上。
对服务端来说,使用界面就是网络协议(32 讲那张表里明确说了这一条)。顺带一句:原文提到那个样例用了 mock 的授权机制,并说"这种 mock 授权非常适合用来做业务系统的单元测试"。
这条也是通用的:鉴权是集成测试的头号障碍,把它做成可替换的(第 4.1 节那条"开放授权"),
单元测试才跑得起来。两节其实是一件事。
第三部分 · 更广的视角:2019 → 2026
⚠ 这一部分是引申,不是原文内容。 原文写于 2019 年前后。
七年过去,它的判断哪些站住了、哪些要修正、哪些当年不存在但现在是必需品——
这才是"给我更广的视角"能给的东西。
五 · 站住了的判断(有些应验得超出预期)
5.1 ★ “凡是想对 HTTP 协议取而代之的,都会挂掉”
这条应验的程度可能超出作者预期。 看这七年新出现的每一个方案,没有一个在试图取代 HTTP,全都在往它更近的方向走:
| 方案 | 它对 HTTP 的态度 |
|---|---|
| thrift | 当年那个"自己搞一套传输"的 → 基本退出主流,成了遗留系统 |
| gRPC | 基于 HTTP/2 → 活得很好 |
| gRPC-Web | gRPC 在浏览器里用不了,必须加一个代理做协议转换 → 这就是"离 HTTP 太远"的罚单 |
| Connect(buf 出的) | 刻意做到"同一个服务,既能用 gRPC 调,也能直接 curl + JSON 调" → 主动往回靠 |
| tRPC(TypeScript 生态) | 本质就是 HTTP + JSON,只是把类型从服务端推导到客户端 |
★ 注意 gRPC-Web 和 Connect 这两行:它们是这条判断最有力的证据——
gRPC 强得不用死,但它离 HTTP 远的那一点点,逼出了两个专门用来"往回靠"的项目。
5.2 其他站住的
| 判断 | 七年后 |
|---|---|
| RESTful 是事实标准 | ✓ 仍是对外 API 的默认选择,而且 OpenAPI/Swagger 生态更强了 |
| “更要考虑的是业务无关的帐号与授权系统” | ✓ 成了标配:要么用 Keycloak / Auth0 / Cognito 这类现成的,要么自建 IAM 服务 |
| Token 授权推 OAuth 2.0 | ✓ OAuth 2.0 + OIDC 是绝对主流。OAuth 2.1 在做收敛(废弃隐式流、强制 PKCE) |
| GraphQL “不温不火” | ✓ 大体应验:它在特定场景站住了脚,但没成为通用默认 |
六 · 需要修正的三处
6.1 ★ GraphQL 的问题不在"概念复杂"
原文的解释是"理念虽然先进,但是概念复杂,并不易于掌握"。七年后回头看,这个解释不够到位。真正的原因是一句更结构性的话:
★★ GraphQL 把"谁决定查询形状"的权力,从后端交给了前端。
而后端因此失去了对查询代价的控制:
| 后端失去了什么 | 具体后果 |
|---|---|
| 控制查询数量 | 一个嵌套查询会退化成 N+1 次数据库查询 |
| 控制查询深度 | 一个恶意的深度嵌套查询能打死你的库(user{friends{friends{friends{...}}}}) |
| HTTP 缓存 | 所有查询都是 POST /graphql —— 第 2.5 节那层白送的网关/CDN 缓存,全丢 |
| 预估成本 | 你没法在执行前知道这个查询要花多少资源 |
要把它推上生产,得补回一整套:
DataLoader 批量加载,解决 N+1
查询深度 / 复杂度限制 防恶意查询
persisted query 把查询预先注册,客户端只发一个查询 ID
★ ——注意这一步其实是【退回到 RESTful 的样子】:
固定的、可枚举的、能被缓存的接口
★★ 所以:GraphQL 没有让复杂度消失,它只是把复杂度搬了个地方——
从"后端设计 API"搬到了"后端防御 API"。这跟 39 讲 groupcache 的模式一模一样:把难题推给使用方。
groupcache 把一致性推给业务层(你得写纯函数),GraphQL 把查询设计推给前端(后端得防着你)。
两者都因此变得"理论上更优雅、实践上更难用"。
而它真正赢的场景,是一个很窄但很真实的场景:
GraphQL 解决的是【聚合】问题,不是【API 设计】问题。
一个前端页面要拼 5 个后端服务的数据 → GraphQL / BFF 很合适
要对外暴露一张复杂的数据图(GitHub、Shopify)→ 合适
你只有一个后端、一套数据 → 不需要它
6.2 ★ 对外 API 和内部 RPC 是两个问题,答案不同
原文把"网络协议"当成一个问题在讨论,然后给了一个答案(RESTful)。七年后最重要的一条实践是:这是两个问题。
| 对外 API | 内部服务之间 | |
|---|---|---|
| 受众 | 客户、第三方开发者、浏览器 | 你自己的另一个服务 |
| 最看重 | 任何人 curl 就能调、浏览器能直连、网关能看懂、客户不用等你出 SDK |
强类型、代码生成、性能、流式 |
| 契约怎么演进 | 不能破坏(客户端不跟你一起升级) | 可以协同升级 |
| 七年后的主流选择 | REST/JSON + OpenAPI | gRPC / protobuf |
为什么原文没区分? 因为 2019 年七牛的主战场就是"对外 API"(云服务)。
而这七年微服务铺开之后,内部调用的量级远超对外调用,两者的诉求彻底分化了。★ 而这条修正本身是一个更普适的教训:任何选型问题,先问一句
“这是给谁用的接口?”——这正是 32 讲"使用界面"的核心问法。
同一个技术问题,受众不同,答案就不同。
6.3 ★ "自动路由"这个方向,七年后被反过来了
原文推荐 restrpc 的自动路由,并说"这样可以减少一些代码量,但是对路由 API 对应的实现方法的名字有要求,看起来不是那么美观"。
这是"代码优先":代码是真源,路由是从代码推出来的产物。 而七年后的主流是反的:
代码优先(原文) 代码 ──推导──▶ 路由 / 文档 / SDK
Schema 优先(现在) OpenAPI / .proto ──生成──▶ 代码 / 文档 / SDK / mock / 测试校验
为什么反过来了? 因为对外 API 的真正难点不是少写几行代码,是契约稳定。而一份契约需要能被:
| 需要 | 藏在代码的方法名里能做到吗 |
|---|---|
| 独立 review(“这个接口这么设计对吗”) | ✗ |
| 独立版本化(“v1 和 v2 差在哪”) | ✗ |
| 直接给客户看 / 生成多语言 SDK / 生成 mock | ✗ |
★★ 这其实是"谁是真源"的问题(这门课反复出现的问法):
契约的受众在你的代码库之外,所以契约不能是代码的副产品。顺带一提,原文自己也说了那个自动路由"看起来不是那么美观"——
那点不美观正是这个方向的味道:它把"给人看的契约"变成了"给机器推导的约定"。
七 · 原文完全没提、但这七年成了必需品的五件事
这一节是这份带读里"更广的视角"最实用的部分。每一条都是 Multi-User 这三个字的必然推论。
7.1 ★ API 版本与向后兼容(最难,而原文一句没提)
这是对外 API 最难的部分,因为它有一条铁律:
★ 客户端不会跟你一起升级。 你发布了一个接口,就有人的生产系统靠它跑着,
而你永远不知道那些人是谁、什么时候会改代码。
| 改动 | 安全吗 |
|---|---|
| 加一个可选字段 | ✓ 老客户端忽略它 |
| 加一个新接口 | ✓ |
| 加一个必填字段 | ✗ 老客户端不会传 |
| 删字段 / 改字段类型 | ✗ 老客户端会崩 |
改字段语义(status 从 0/1 变成字符串) |
✗✗ 最坏的一种——不报错,静静地算错 |
| 改错误码含义 | ✗ 老客户端的重试逻辑会错 |
protobuf 里有一条对应的规矩:字段编号删了不能复用。
message User {
string name = 1;
// int32 age = 2; ← 删掉了
reserved 2; // ★ 必须保留,不能把 2 给新字段
string email = 3;
}
为什么? 因为老客户端认的是编号不是名字。你把
2给了一个新的string,
老客户端会拿着"这是 int32 age"的认知去解析它——不报错,解析出垃圾。★ 这正好撞回 36 讲那条底线的另一面:
36 讲说"不许丢用户的数据";这里是**“不许悔改已发布的承诺”**。
一个已发布的 API 是一份不能撤回的承诺——而这也是为什么第六节那条"schema 优先"是对的。
7.2 幂等键(补上第 2.5 节那个缺口)
POST 天生不幂等,动词声明不了。现在的标准做法是让客户端生成一个唯一键:
POST /v1/orders
Idempotency-Key: req-abc-123
服务端记住"这个键处理过了,结果是 order-1",重复请求直接返回旧结果。
脚本演示的效果:同一个键请求 3 次,最终只有 1 个订单。
★ 这是 Stripe 带起来的惯例,现在支付、订单、消息发送类 API 基本都这么干。
机制朴素:把不幂等的操作,靠一张去重表变成幂等的。而 39 讲第 6.3 节说 Redis 的
LPUSH不能安全重试——解法完全一样(唯一 ID 去重)。
幂等这个问题,在这门课里已经出现三次了(39 讲的删缓存、39 讲的 Redis 命令、这里的 POST)。
7.3 限流与配额(Multi-User 的必然推论)
"多租户"这三个字有一个直接后果:一个租户不能拖垮别人。
| 维度 | 例子 |
|---|---|
| 按租户限流 | 每个 AK 每秒最多 1000 次请求 |
| 配额 | 每月最多 100 万次调用 / 最多存 1TB |
| 优先级/隔离 | 免费用户和付费用户不共享同一个资源池 |
★ 注意两个连接:
① 这是 39 讲那个过载保护的租户版——那里是"整体过载就丢请求",这里是"谁超了丢谁的"。
后者显然更好:它把损失限制在肇事者身上,而不是平摊给所有人。
② 而"按租户限流"要求你先能识别租户——所以它依赖第 3.4 节那个帐号与授权子系统。
原文那句"要构建业务无关的帐号体系",回报不只在鉴权,在这里也要用。
7.4 可观测性(原文讲了上线前,没讲上线后)
原文用两节讲单元测试和集成测试——都是上线之前。而线上出问题时你需要的是另一套东西(现在的事实标准是 OpenTelemetry):
| 三件套 | 回答什么问题 |
|---|---|
| Trace(链路追踪) | 这一个慢请求,时间花在哪个服务、哪次数据库查询上了 |
| Metrics(指标) | QPS、延迟分位(p50/p99)、错误率、缓存命中率(39 讲那个杠杆) |
| Log(日志) | 具体这一次到底发生了什么 |
★ 而多租户有一个特殊要求:日志和 trace 必须带上租户 ID。
否则出了问题你知道"有 5% 的请求失败了",但不知道是谁的 5%——
可能是 100 个客户各错一点(不严重),也可能是一个大客户全挂(严重事故)。
同一个数字,两种完全不同的事。
7.5 契约测试 / schema 校验(httptest 那个思想的现代版)
原文 httptest 的核心思想是"不写 Client SDK 也能用业务友好的方式写测试"。这个思想活得很好,只是有了新的形式:
| 现在的形式 | 干什么 |
|---|---|
| 用 OpenAPI schema 自动校验响应 | 测试里断言"响应符合契约",而不是逐个字段手写断言 |
| 契约测试(如 Pact) | 消费方声明"我依赖这几个字段",提供方 CI 里验证自己没破坏它 |
| Golden / snapshot 测试 | 把完整响应存成基线文件,改动时 diff |
★ 但核心判断没变,而且它就是 32 讲那句话:
测试要打在【使用界面】上,不要打在实现上。 对服务端来说,使用界面就是网络协议。顺带说清原文那个"先写 SDK 再用 SDK 测"为什么不好:
① 你在测 SDK 不是测 API;② SDK 要跟着 API 改,每加一个接口动两处;
③ SDK 会替你"修正"请求(自动补默认值、自动重试)——而真实客户端不会。
八 · 收口:这一讲真正在教的是什么
8.1 四条建议,背后是同一类判断
| 表面上是 | 背后其实是 | 出自哪一讲 |
|---|---|---|
| 选 RESTful | 选那个周围长满了免费基础设施的接口——生态判断,不是技术判断 | 32 讲(接口即使用界面) |
| 无状态 | 让状态离开进程,才能加机器 | 35 讲(负载均衡) |
| 帐号授权做成独立子系统 | 分清稳定点与变化点 | 32 讲 |
| httptest 打在 HTTP 上 | 测试要打在使用界面上 | 32 讲 |
8.2 ★ 为什么"业务架构"这一讲,讲的全是和业务无关的东西
原文自己说了:
“Model 层本身最重要的是自然体现业务逻辑,它和具体的行业的领域问题相关,对此我们无法进一步展开。”
这不是偷懒,这就是答案。
★★ 服务端业务架构里可复用的部分,恰恰全是"和业务无关"的那一圈:
协议、鉴权、路由、序列化、测试、版本兼容、限流配额、可观测性。而这一圈就是所谓的"框架"、“平台”、“中台”——
也是为什么每家做到一定规模的公司都会去建它:
业务逻辑没法复用(它是领域性的),但业务周围那一圈每个业务都要重造一遍。32 讲那句重话在这里得到了服务端版的印证:
“基础架构的影响面更广,选错产生的代价更高。架构师之间的差距,更大的是体现在其对待基础架构的态度和能力构建上。”
8.3 最后一句:为什么它叫"建议"而不是"原理"
看这一讲列的四样东西,有一个共同点:
网络协议 一旦发布,客户端就靠它跑着,改不动(7.1 节)
鉴权方式 一旦定了,所有接口都按它写,换不掉(4.1 节说的"写死进框架")
API 契约 已发布的承诺不能撤回(7.1 节)
帐号模型 每一个业务都依赖它(3.4 节)
★★ 它们全都是"一旦定下来就很难改"的东西。
而"很难改"的东西,就该在动手之前想清楚——
这正是 32 讲那句"没有需求分析,就没有业务架构;需求分析至少应该花费三分之一以上的精力"的服务端版本。所以这一讲叫"建议"是准确的:它不是教你怎么写代码,是教你在写第一行代码之前该定下哪几件事。
验收
骨架(一)
| # | 问题 | 答案在 |
|---|---|---|
| 1 | 为什么说"无状态"不是设计品味,而是能不能加机器的前提?有状态具体死在哪? | 一 |
| 2 | 购物车这种明摆着的"临时状态",在无状态服务端里去了哪?"服务端不存在临时状态"该怎么准确表述? | 一 |
机制(二~四)
| # | 问题 | 答案在 |
|---|---|---|
| 3 | RESTful 相对 RPC 风格,换位换的是什么? 4xx / 5xx 的分界对客户端意味着什么?为什么"用 GET 做删除"不只是不优雅、而是真会出事? | 二 |
| 4 | 为什么说 HTTP 动词不是命名规范而是一份声明?把"点赞"写成 POST 有什么真实代价? | 二 |
| 5 | “凡是想对 HTTP 取而代之的都会挂掉”,机制是什么?这个判断的通用版本怎么说? | 二 |
| 6 | AK/SK 为什么不是公私钥?服务端因此承担了什么风险,为什么还这么选? | 三 |
| 7 | 用 AK/SK 还是 OAuth,真正的判据是什么(不是 To B / To C)? | 三 |
更广的视角(五~八)
| # | 问题 | 答案在 |
|---|---|---|
| 8 | GraphQL 的真正问题是什么?它和 groupcache 犯的是同一个"错"吗? | 六 |
| 9 | 为什么"对外 API"和"内部 RPC"是两个问题?七年后各自的答案是什么? | 六 |
| 10 | 一个已发布的 API,哪些改动是安全的、哪些不是?protobuf 的字段编号为什么删了不能复用? | 七 |
| 11 | 为什么"业务架构"这一讲讲的全是和业务无关的东西? | 八 |
答案
1. 因为 无状态 = 任意一台机器都能处理任意一个请求 = 加机器就能扩容。有状态的死法有两个:nginx 把下一个请求丢给另一台机器(那台不认识这个会话),或者本机重启(内存清空)。补救只有两条烂路:粘性会话(一台挂了它上面全部会话丢、负载不均、扩容后老用户挪不过去)或会话跨机同步(为一个购物车搭一套分布式一致性)。这跟 37 讲"业务服务器无状态所以能随便加"、38 讲"去可变才能扩展"是同一个判断。
2. 被赶到了两端:要么进存储(POST /carts 让它变成一个有名字的资源,同时获得持久性和可查询),要么进客户端(令牌 / 隐藏字段 / cursor,代价是可被篡改、要签名)。准确表述是:会话状态可以有,但不能放在进程内存里——放进存储它就是 Model 的一条记录,放进令牌它就是请求参数,两种都不影响"服务端是 Model 层"这个结论。
另外这解释了 REST"一切抽象为资源"的真正用处:它逼你把隐式的会话状态变成显式的资源。
3. 换的是这一个位:路径里放名词(资源),动作交给 HTTP 方法。
RPC 风格是"路径里放动词、方法永远 POST"(POST /getArticle);RESTful 是 GET /articles/1。
剩下所有规矩(状态码、幂等、可缓存、无状态)都是这个换位的后果。
4xx / 5xx 是一份「要不要重试」的声明:4xx 别重试(请求本身有问题,重发一百次一样错),
5xx 可以重试(我这边暂时不行)。分错了客户端的重试逻辑就会错——参数错却返回 500,客户端会一直重试把你打死。
用 GET 做删除会出事,因为全世界的软件都认为 GET 只读、可以随便调:浏览器预取链接、
CDN/nginx 缓存、爬虫和链接预览自己访问一遍、网关超时自动重试。
2005 年 Google 的 Web Accelerator 插件预取链接,真删掉过一批网站的后台数据。
另外记住 URL 是一串路径式的下标:/articles/1 就是 文章库[1],/users/7/orders/3 就是 用户库[7]["orders"][3]。
URL 定位「对谁」,HTTP 方法说明「干什么」——两个维度,交叉出一张表。
4. 因为动词同时声明了幂等性和可缓存性,而沿途所有基础设施(浏览器、CDN、nginx、API 网关、客户端库)都按这个声明行事。GET/PUT/DELETE 幂等 → 别人敢帮你重试;GET 可缓存 → 脚本里 100 个请求只有 1 个到后端,POST /getArticle 则 100 个全部穿透。
把点赞写成 POST /articles/1/like 的代价:网络一抖就点两个赞,而且谁都不敢替你重试;改成 PUT /articles/1/likes/张三 就幂等了。同一个业务,换个动词和 URI,就从"不能重试"变成"能重试"。
5. 机制是生态锁定,不是技术优劣:HTTP 的价值在它周围那一圈免费的东西(nginx/CDN/浏览器/curl/WAF/所有语言的客户端库),换掉 HTTP 就同时放弃这一圈。而 protobuf 只换掉 body 的序列化,那一圈全都还在——所以它活了,thrift 没活。
通用版本:你要替换的那一层,周围挂了多少别人写的免费东西?挂得越多越换不动;而"只换里面一层、保留接口"永远可行。 这也解释了 32 讲"接口比实现值钱"为什么值钱——接口值钱不是因为设计得好,是因为它周围长满了别人的东西。
6. 因为 AK/SK 是对称的(HMAC):服务端必须存着每个用户的 SK 且能读出原文,才能重算签名比对。风险是密钥库泄露 = 所有 SK 泄露,而且内部任何能读那张表的人都能伪造请求。公私钥则只存公钥,泄露无害。
还这么选是因为每一个 API 请求都要验一次签,对象存储一天几百亿次——HMAC 一次几微秒,非对称慢几个数量级。取舍的形状是:不选"更安全的那个",选"够安全 + 性能扛得住"的那个,再用运维手段(KMS 加密存储、密钥轮换、临时凭证 STS、细粒度权限)补上缺口。
7. 有没有第三方要代表用户行事。 有 → OAuth(授权码流程 + scope + 有效期 + 可单独撤销,密码从不离开你的服务);没有 → AK/SK 签名就够了,OAuth 那套跳转纯属负担。
To B/To C 只是现象:企业服务器调你的 API 是两方,别人的 App 代表用户调你的 API 是三方。反过来,你的服务去调别人的 API 拿用户数据时,你就是那个第三方。
8. 真正的问题是 GraphQL 把"谁决定查询形状"的权力从后端交给了前端,后端因此失去对查询代价的控制:N+1、深度嵌套能打死库、而且所有查询都是 POST /graphql——第 2.5 节那层白送的 HTTP 缓存全丢。要上生产得补一整套(DataLoader、深度/复杂度限制、persisted query——而 persisted query 其实是退回到 RESTful 的样子)。
是同一个"错":和 groupcache 把一致性推给业务层一样,它没让复杂度消失,只是搬了个地方,于是"理论上更优雅、实践上更难用"。它真正赢的场景是聚合(BFF、对外暴露复杂数据图),不是"API 设计"。
9. 因为受众不同:对外 API 的受众是客户和浏览器,最看重"任何人 curl 就能调、不用等 SDK、网关能看懂、契约不能破坏";内部 RPC 的受众是你自己的另一个服务,最看重"强类型、代码生成、性能",而且契约可以协同升级。七年后的主流分层是对外 REST/JSON + OpenAPI,内部 gRPC/protobuf。
更普适的教训:任何选型先问"这是给谁用的接口"——同一个技术问题,受众不同,答案就不同(32 讲)。
10. 安全的:加可选字段、加新接口。不安全的:加必填字段、删字段、改类型、改字段语义(最坏——不报错,静静地算错)、改错误码含义。
protobuf 的字段编号删了不能复用(要写 reserved),因为老客户端认的是编号不是名字:你把 2 给了一个新的 string,老客户端会拿着"这是 int32 age"的认知去解析——不报错,解析出垃圾。
底线一句话:一个已发布的 API 是一份不能撤回的承诺(36 讲"不许丢数据"的另一面)。
11. 因为业务逻辑是领域性的,没法复用;而业务周围那一圈每个业务都要重造一遍——协议、鉴权、路由、序列化、测试、版本兼容、限流配额、可观测性。这一圈就是"框架/平台/中台",也是为什么每家做到规模的公司都会去建它。
原文那句"业务逻辑和行业相关,无法进一步展开"不是偷懒,就是答案。而 32 讲那句重话在这里得到服务端版印证:基础架构的影响面更广,选错代价更高。
下一步
41 讲开始进入服务端实战(把 29 讲那个 Mock 服务端做成正式版:数据库、多租户、高可靠)——本讲列的四件事会在那里逐个落地。
回看:
[带读 10 · 32 讲](…/32-架构:系统的概要设计/带读-10-32讲:系统的概要设计(附 AI coding 对照).md)(使用界面 · 稳定点与变化点——本讲第 2.7、3.4、4.2、8.1 节全指向它)
36 讲 · 存储中间件的由来(ok 不许是假的——第 7.1 节是它的另一面)
带读 13 · 39 讲(幂等 · 过载保护 · 把复杂度推给使用方——第 2.5、6.1、7.2、7.3 节都在接它)
