这一讲的性质:浏览器端一个字都没改,全是新增的服务端程序。
我把 v28、v29 两个分支 diff 过:
v28 → v29 paintweb/www/下所有.js.htm(浏览器端)完全没变 paintdom/(服务端,Go)从无到有,623 行 paintweb/main.go改了几行——加了个反向代理 所以这一讲的重点不是代码量,是两件事:网络协议怎么设计、第一版服务端该怎么做。
这一讲的代码:
| 你要的东西 | 路径 |
|---|---|
| 新增的服务端(Go,657 行) | 代码/qpaint源码/v29-服务端/ |
| ↳ 协议路由表(就开头那七行) | service.go |
| ↳ Model:图形定义 · 多文档 | shape.go · drawing.go |
| ↳ 反向代理那几行 | main.go |
| 浏览器端(与 v28 完全相同) | 代码/qpaint源码/v29/ |
| 28→29 的浏览器端 diff | diff-dom-v28-v29.txt ——是空的 |
| 每个文件的结构地图 | 带读-源码导航 |
那份空 diff 就是这一讲的注脚:浏览器端一个字都没改。
一 · 先补一个概念:异步
之前说过 29 讲会碰到这个,就是它。读服务端相关的代码,绕不开。
1.1 问题:网络很慢
let 结果 = 发个请求给服务端() // ← 假设这句要等 200 毫秒
画界面(结果)
如果真这么写,那 200 毫秒里整个界面是死的——鼠标点了没反应、拖不动、什么都干不了。
因为浏览器只有一个消息循环(带读-00 · 1.1),它被你占住了。
1.2 解法:发出去就往下走,结果到了再回来
打个比方:
| 你在店里 | |
|---|---|
| 同步 | 下单,站在柜台前等,做好了拿走。这期间你什么都干不了 |
| 异步 | 下单,留个电话,回去干别的。做好了他打给你 |
回调函数就是那个电话号码。
callAsync("POST", "/api/drawings", [], null, function() { // ← 这个函数就是"电话号码"
let o = JSON.parse(http.responseText) // 服务端回话时才执行
doc.displayID = o.id
})
console.log("我先走了") // ← 【这一句先执行】,不等服务端
执行顺序是:"我先走了" 先打印,那个 function 里的代码后执行(可能是 200 毫秒之后)。
⚠️ 读代码时最容易栽在这里:写在后面的代码先跑了。
看到callAsync(...)/.onreadystatechange = function(){}/setTimeout(...)这类东西,
就要提醒自己:花括号里那段是"将来才跑"的。
1.3 它跟你早就懂的「盒子」是同一个东西
http.onreadystatechange = function() { // ← 往一个盒子里放函数
if (http.readyState != 4) return
if (http.status == 200) { ... }
}
这不就是带读-00 讲的事件盒子吗? 一模一样:
| 盒子 | 谁放进去 | 什么时候被调用 |
|---|---|---|
view.onmousedown |
Controller | 浏览器送来鼠标事件时 |
view.onControllerReset |
菜单 | Creator 调 fire... 时 |
http.onreadystatechange |
发请求的那段代码 | 服务端回话时 |
“异步"听着高级,机制上就是"把一个函数放进盒子,等着被回调”。
你已经懂这个机制了,只是这次触发它的是网络。
1.4 把 callAsync 拆开看
它本身就 25 行,拆开没什么神秘的。
五个参数
callAsync( method, url, headers, body, onOK )
callAsync( "POST", "/api/drawings", [], null, function(){...} )
| 参数 | 这次的值 | 是什么 |
|---|---|---|
method |
"POST" |
HTTP 方法(POST=建/改,GET=取,DELETE=删) |
url |
"/api/drawings" |
请求哪个地址 |
headers |
[] |
额外的请求头,这次没有 |
body |
null |
要发的数据——这次什么都不发(只是要个号) |
onOK |
function(){...} |
成功之后要执行的那段代码 |
关键:最后那个参数是一个函数。它现在不执行,等服务端回话、而且成功了,才执行。
内部三步
function callAsync(method, url, headers, body, onOK) {
let timeout = 1000
let doFunc = function() {
http.open(method, url) // ① 准备一个请求
http.setRequestHeader("Authorization", ...)
http.onreadystatechange = function() { // ② 把"回话时干什么"放进盒子
if (http.readyState != 4) return // 还没完事 → 不管
if (http.status == 200) {
onOK() // ★ 成功 → 调用传进来的那个函数
} else {
setTimeout(doFunc, timeout) // 失败 → 过一会儿【重试】
timeout *= 2 // 下次等的时间翻倍
}
}
http.send(body) // ③ 真的发出去
}
doFunc()
}
准备 → 把回调放进盒子 → 发出去。发完函数就返回了,不等结果。
| 细节 | 意思 |
|---|---|
readyState != 4 |
一个请求有 5 个阶段(0~4),每换一个阶段这个盒子里的函数就被调一次。4 = 完成,所以前面几次直接 return |
status == 200 |
HTTP 状态码。200 = 成功,404 = 找不到,500 = 服务器出错 |
timeout *= 2 |
指数退避重试:失败后 1 秒重试,再失败 2 秒、4 秒、8 秒…… 为什么翻倍?服务器要是挂了,所有客户端疯狂重试会把它压得更起不来。退避是在给对方喘息的机会。 |
执行顺序
console.log("A")
callAsync("POST", "/api/drawings", [], null, function() {
console.log("C") // ← 服务端回话时才跑
})
console.log("B")
// 打印顺序:A → B → ……(等 200 毫秒,这期间界面照常能用)…… → C
回头看 v28 那段代码,就通了
_loadBlank() {
this.localID = _makeLocalDrawingID() // 10001
this.displayID = "t" + this.localID // t10001 ← 先用临时名
window_setHash("#" + this.displayID) // URL: #t10001
this._newDoc() // 发请求,【不等】
} // ← 函数在这里就返回了,界面已经能画了
_newDoc() {
callAsync("POST", "/api/drawings", [], null, function() {
doc.displayID = o.id // 【几百毫秒后】才执行到这
window_setHash("#" + doc.displayID) // URL 悄悄变成 #507f1f77
})
}
这就解释了为什么非要有
t10001这个临时名:
请求发出去到回来这段时间里,程序不能停着等——它得有个名字先顶着,用户才能立刻开始画。
t10001不是设计上的冗余,它是"异步"逼出来的。
记住这个形状
someAsyncCall(参数1, 参数2, …, function() {
// ← 花括号里这段是【将来才跑】的
})
// ← 这里的代码【现在就跑】
看到最后一个参数是函数,就知道它是"完事之后干什么"。 这个形状在前端到处都是:
setTimeout(function(){ … }, 1000) // 1 秒后干什么
button.onclick = function(){ … } // 点了之后干什么
http.onreadystatechange = function(){ … } // 服务端回话后干什么
全是同一件事:把一个函数交出去,等着被回调。
就是带读-00 那个「盒子」,只是触发它的东西不同。
二 · 心智模型:服务端也是一个 MVC 程序
2.1 它分两层,少了 View
paintdom/ ← 服务端程序(Go)
├── shape.go (89 行) ┐
├── drawing.go (212 行) ┘ Model 层:纯业务逻辑,与网络无关
└── service.go (247 行) Controller 层:网络协议
为什么没有 View? 原文说得很干脆:
“首先服务端程序大部分情况下并不需要显示模块,所以不存在 View 层。”
为什么网络协议层算 Controller? 这是这一讲最有嚼头的一句:
“网络协议层为什么可以看作 Controller 层,是因为它负责接受用户输入。
只不过用户输入不是我们日常理解的用户交互,而是来自某个自动化控制程序的 API 请求。”
注意这是 Controller 的定义第三次被放宽:
讲 原来以为 Controller 是 被打破 26 “改 Model 的那个东西” MousePosTracker不碰 Model 也不碰 ViewModel27 “响应鼠标键盘的那个东西” 菜单响应的是按钮点击 29 “响应【人】的操作的那个东西” 它响应的是【另一个程序】发来的 HTTP 请求 最终的定义:Controller = 接受外界输入、把它翻译成对 Model 的操作的那一层。
"外界"是谁、"输入"长什么样,都不重要。
2.2 两边的 Model 长得不一样:服务端多一层
浏览器端(单文档) 服务端(多文档)
QPaintDoc Document ← 【新增的一层】所有图的集合
└── Shape └── Drawing ← 对应浏览器的 QPaintDoc
└── ShapeStyle └── Shape
└── ShapeStyle
原文特意点了一句:“浏览器端的 QPaintDoc,对应的是这里的 Drawing,而不是这里的 Document。”
为什么服务端要多这一层? 因为:
| 一次要管几篇文档 | |
|---|---|
| 浏览器 | 一个页面编辑一篇——所以 dom.js 是单文档设计,没有"所有文档的集合"这个概念 |
| 服务端 | 所有人的所有文档——必须有个东西装着它们,那就是 Document |
同一个业务,两端的 Model 层级不同——因为它们要回答的问题不同。
浏览器问"这张图里有什么",服务端问"我这儿有哪些图,这张图里有什么"。这跟带读-01 说的"Model 的形状由使用场景决定"是同一条。
三 · 网络协议
3.1 七条协议
原文列的服务端功能,落成协议就是这七条(paintdom/service.go 第 24 行那张路由表):
p.routeTable = RouteTable{
"POST/drawings": p.PostDrawings, // 新建一篇图
"GET/drawings/*": p.GetDrawing, // 取一篇图
"DELETE/drawings/*": p.DeleteDrawing, // 删一篇图
"POST/drawings/*/shapes": p.PostShapes, // 在图里加一个图形
"GET/drawings/*/shapes/*": p.GetShape, // 取一个图形
"POST/drawings/*/shapes/*": p.PostShape, // 改一个图形(含改 zorder)
"DELETE/drawings/*/shapes/*": p.DeleteShape, // 删一个图形
}
这张表就是整个协议。 七行,一眼看完。
3.2 URL 怎么设计的:名词 + 层级
/drawings 所有图
/drawings/507f1f77 其中一张
/drawings/507f1f77/shapes 那张图里的所有图形
/drawings/507f1f77/shapes/2 其中一个图形
规律:
| URL 里全是名词(drawings、shapes) | 不写动作。动作由 HTTP 方法表达:POST=建/改,GET=取,DELETE=删 |
| 层级 = 从属关系 | shapes 挂在某个 drawing 下面,因为图形属于某张图 |
| URL 长什么样,Model 就长什么样 | 对照 2.2 那棵树:Document → Drawing → Shape,跟 URL 层级一一对应 |
最后一条值得记:一个设计良好的 URL,读一眼就能画出对方的 Model 树。
反过来,如果你的 URL 长得像/getUserDataById?type=3&action=update——
那说明它没在描述"有什么",而在描述"干什么",Model 也就藏起来了。
3.3 一个 Shape 传上去长什么样
{
"id": "2",
"rect": {
"x": 10, "y": 10, "width": 50, "height": 40,
"style": { "lineWidth": 1, "lineColor": "black", "fillColor": "white" }
}
}
注意"是什么图形"是靠外层那个 key 表达的(rect / line / ellipse / path),
不是靠一个 "type": "rect" 字段。服务端的 Go 结构体就是照这个定的:
type Rect struct {
shapeBase `json:",inline"` // id 在外层
rectData `json:"rect"` // ← 这个 "rect" 就是 JSON 里那个 key
}
对照一下浏览器端:
localStorage里存的是{"type":"QRect", "x":..., ...}——用 type 字段。
两边形式不一样。 下一节讲为什么。
3.4 ★ 为什么网络协议要比 localStorage 严谨
原文这段很值钱:
“从结构化数据的 Schema 设计角度,localStorage 中的实现是无 Schema 模式,过于随意。
这是因为 localStorage 只是本地自己用的缓存,影响范围比较小,故而我们选择了怎么方便怎么来的模式。
而网络协议未来有可能作为业务的开放 API,需要严谨对待。”
| localStorage | 网络协议 | |
|---|---|---|
| 谁在用 | 只有我自己这段代码 | 可能是全世界的第三方程序 |
| 改一下要付什么代价 | 改两处代码,顺手 | 可能有人已经照着它写了程序 |
| 出了错谁受影响 | 只有这台机器 | 所有接入方 |
| 所以 | 怎么方便怎么来 | 严谨对待,而且要留版本号 |
一句话:接口的严谨程度,取决于"它一旦定错了,收回来有多贵"。
这条可以直接拿去用:内部函数、私有字段随便改;一旦某个东西"发出去了、别人开始依赖它",
它的成本就完全不同了。_shapes那个下划线(带读-01 · 2.6)讲的是同一件事的小号版本。
3.5 版本号:/v1/ 和 /v2/
原文建议协议带版本号:
POST /v1/objects
GET /v1/objects/<ObjectID>
不兼容的改动就升版本:
POST /v2/objects
GET /v2/objects/<ObjectID>
两个好处(原文的话):
| # | 好处 |
|---|---|
| 1 | 可以逐步下线旧版本的流量,一段时间内让两个版本并存 |
| 2 | 新老版本的业务服务器可以相互独立,前端由 nginx 之类的网关分派 |
第 2 条是关键:
/v1/和/v2/可以跑在两套完全不同的服务器上,
老代码一行不用动,新代码放开手脚重写。版本号不只是标记,它是一条部署上的分界线。(qpaint 是 DEMO,实际没加版本号——原文自己说了"这些常见问题并没有在考虑范围之内"。)
四 · Mock 版服务端:一个工程决策
这一节讲的不是代码,是怎么安排工作,我觉得是 29 讲最实用的一段。
4.1 两条路
第一版服务端怎么做?
| 做法 | 问题 | |
|---|---|---|
| A · 憋大招 | 业务架构设计 → 架构评审 → 编码 → 测试 → 上线 | 服务端有一堆跟业务无关的硬骨头要啃 |
| B · 先做 Mock | 一个只有业务逻辑、不接数据库、纯内存的最小版本 | 看起来是增加了工作量 |
原文说的"跟业务无关的硬骨头"是这两个:
| 要求 | |
|---|---|
| 高可靠 | 数据不能丢。硬盘坏了不能丢,甚至机房地震了也不能丢 |
| 高可用 | 服务不能有单点。停几台机器用户还得能用,有些业务(如支付宝)要跨机房异地双活 |
这些跟"画图"一点关系都没有,但不解决就不能上线。
4.2 Mock 版的两个价值
其一,让团队工作并行。
不同团队协作的基础就是网络协议。一个快速打造的 Mock,可以让前端不用等待后端;
而后端可以针对网络协议做很高覆盖率的单元测试,进度不受前端影响。其二,让业务逻辑最快被串联,快速验证网络协议的有效性。
中途如果发现协议不满足业务需求,可以及时调整过来。
用一张图说清楚为什么这很重要:
做法 A(憋大招)
前端 ─────────────等─────────────────▶ 联调 → 发现协议有问题 → 【后端大改】
后端 ─── 架构 → 高可靠 → 高可用 → 编码 ──▶
↑ 协议的问题在这里才暴露,已经晚了
做法 B(先 Mock)
协议定稿 ─┬─ 前端 ──────────────────▶ ┐
│ ├─ 早早就能联调,协议问题当场暴露
└─ 后端:Mock(几天) ───────▶ ┘
└─ 正式版(慢慢做,协议已经验过了)
核心不是"少写代码",是"把协议这个最贵的错误,提前到最便宜的时候暴露"。
呼应 3.4 那条:协议一旦发出去就收不回来。所以要在还没发出去时,赶紧用真实业务串一遍。
4.3 Mock 版长什么样
没有数据库,全在内存里(paintdom/drawing.go):
type Document struct {
drawings map[string]*Drawing // ← 就是一个 map,进程一停全没
mutex sync.Mutex
}
623 行 Go,跑起来就能用。 正式版(接 MongoDB、多租户授权)是后面"服务端开发篇"的事。
这就是 “Mock 不必考虑太多服务端领域的问题,它的核心价值就是串联业务”。
五 · 数据本身是怎么走的
带读-05 讲了 ID 怎么分发,但图形数据什么时候上传、什么时候下载在那边没讲——就在这里。
⚠️ 严格说这是 30 讲的同步器(
QSynchronizer),v29 还没有。
但放在这里看,前面那些 ID 才算落地。
5.1 先补一类之前没提的 localStorage 记录
| key | 是什么 | 拿谁拼的 |
|---|---|---|
dgBase |
本机号发到几了 | — |
dg:10001 |
文档目录 | localID |
10001:1 |
图形内容 | localID |
local:507f1f77 |
桥 | displayID |
base:507f1f77 |
服务端已经知道到第几版了 | displayID |
base:是增量同步的基准——“上次同步完,服务端手上是第 N 版”,下次只发ver > N的图形。
它拿displayID拼,因为它记的是"服务端知道多少",是那一头的事。
5.2 加上数据流的完整链路
阶段 1 · 出生
网络: POST /api/drawings ──▶ 「帮我建一篇空文档,给个号」
★【一个图形都没发】
阶段 2 · 画三个图形(displayID 还是 t10001)
本地: 写 dg:10001 · 10001:1 · 10001:2 · 10001:3
网络: 【什么都不发】——同步器开头就挡住了:
if (_isTempDoc(doc.displayID)) { return } // 还没转正,不同步
★ 数据先攒在本地。这是那个 t 前缀的另一个用处。
阶段 3 · 转正
网络: ◀── {"id":"507f1f77"}
名字: displayID 换掉 · 架桥 local:507f1f77 → 10001
★ _isTempDoc 变 false —— 同步器不再 return 了
阶段 3.5 · 第一次真正上传数据 ← 最容易被忽略的一环
你再动一下(比如挪个矩形)
→ baseVer = localStorage("base:507f1f77") → 没有,当 0
→ prepareSync(0) → 所有图形的 ver 都 > 0 → 【三个全进 changes】
→ POST /api/drawings/507f1f77/sync
{"shapes":["1","2","3"], "changes":[图1,图2,图3], "ver":1}
◀── 200 OK → 存 base:507f1f77 = 1
阶段 3.6 · 之后每次改动都是【增量】
改 2 号矩形 → 只有它的 ver > baseVer
→ POST …/sync {"shapes":["1","2","3"], "changes":[图2], "ver":2}
阶段 4 · 刷新页面
本地: 查桥 → 读缓存 → 【画面先出来】 ← 秒开,不等网络
网络: GET /api/drawings/507f1f77 → 拿最新的覆盖本地
阶段 5 · 同事打开
本地: 查桥 → null → 没缓存 → 画面暂时空白
网络: GET → 拿到整篇 → 发【他自己的】localID → 写进他的 localStorage
阶段 6 · 断网继续编辑
本地: 照样能读能改能写盘 ✓
网络: POST …/sync 失败
→ syncer.dirty = true
→ setTimeout(syncFunc, timeout); timeout *= 2 ← 指数退避重试
★ 变更没丢,攒在本地,等有网自动补推
5.3 什么上传、什么不上传
你的浏览器(localStorage) 服务端
┌─────────────────────────┐ ┌──────────────────────────┐
│ dgBase 10001 │ ✗ 不传 │ │
│ local:507f… → 10001 │ ✗ 不传 │ │
│ base:507f… → 2 │ ✗ 不传 │ drawing 507f1f77 │
│ dg:10001 │ │ │
│ shapes ["1","2","3"] ─┼─ 上传 ─▶ │ shapes ["1","2","3"] │
│ 10001:1 {…} ───────────┼─ 上传 ─▶ │ shape "1" {…} │
│ 10001:2 {…} ───────────┼─ 上传 ─▶ │ shape "2" {…} │
│ 10001:3 {…} ───────────┼─ 上传 ─▶ │ shape "3" {…} │
└─────────────────────────┘ └──────────────────────────┘
key 是拿 localID 拼的 按 displayID 组织
判据:换台设备还需要的,就上传;只对这台机器有意义的,就不传。
5.4 这跟 28 讲是同一个思路
| 为了什么 | 怎么做 | |
|---|---|---|
| 本地分层存储(28 讲) | 少写盘 | 改一个图形,只重写 10001:2 那一条 |
| 网络只发 changes(29/30 讲) | 少传数据 | 改一个图形,只把它放进 changes |
"只动变了的那部分"这条思路,先用在硬盘上,再用在网络上。
28 讲那个变更分级,就是同步的地基——这也是它要先讲的原因。
六 · 顺带看一眼服务端代码
不用细读,扫两眼知道它长什么样就行。
路由是怎么匹配的
func getRoute(req *http.Request) (route string, args []string) {
parts := strings.Split(req.URL.Path, "/") // "/drawings/507f/shapes/2" → ["", "drawings", "507f", "shapes", "2"]
parts[0] = req.Method // ["POST", "drawings", "507f", "shapes", "2"]
for i := 2; i < len(parts); i += 2 { // 把【偶数位】换成 *,同时把值收进 args
args = append(args, parts[i])
parts[i] = "*"
}
route = strings.Join(parts, "/") // "POST/drawings/*/shapes/*"
return
}
一个很省事的小技巧:URL 的偶数段一定是 ID(drawings/<id>/shapes/<id>),
所以直接按位置替换成 *,拼出来的字符串正好能去路由表里查。
这是 Mock 版才敢这么写——它假设了所有 URL 都是"名词/ID/名词/ID"的形状。
真实框架要处理各种形状,就得写正则或前缀树。Mock 版的价值就在于"够用就行"。
错误码怎么回
func ReplyError(w http.ResponseWriter, err error) {
if err == syscall.ENOENT { Reply(w, 404, ...) // 找不到
} else if err == syscall.EINVAL { Reply(w, 400, ...) // 参数不对
} else if err == syscall.EEXIST { Reply(w, 409, ...) // 已存在
} else { Reply(w, 500, ...) // 其他
}
}
Model 层抛的是操作系统的标准错误码,Controller 层把它翻译成 HTTP 状态码。
又一次"Model 与网络无关"——
drawing.go里返回syscall.ENOENT,它不知道有 HTTP 这回事。
翻译成 404 是service.go(Controller)的活。
七 · 验收
| # | 问题 | 答案在 |
|---|---|---|
| 1 | 29 讲浏览器端改了多少行?这一讲的产出是什么? | 序 |
| 2 | 服务端为什么没有 View 层?为什么说网络协议层就是 Controller 层? | 2.1 |
| 3 | 浏览器端的 Model 是三层,服务端是四层,多的那层是什么?为什么要多? | 2.2 |
| 4 | 为什么 localStorage 可以"怎么方便怎么来",网络协议却要严谨对待? | 3.4 |
| 5 | 第一版服务端为什么要先做 Mock?它解决的到底是什么问题? | 4.2 |
答案
1. 零行。 新增的全是服务端(paintdom/,623 行 Go)+ main.go 改几行加反向代理。
这一讲的产出不是代码,是两个决定:协议长什么样、第一版怎么做。
2. 服务端不需要显示模块,所以没有 View。网络协议层算 Controller,是因为
它负责接受外界输入——只不过输入不是人的鼠标键盘,而是另一个程序发来的 HTTP 请求。
Controller = 接受外界输入、翻译成对 Model 的操作的那一层,"外界"是谁不重要。
3. 多的是最外层的 Document(所有图的集合)。
因为浏览器一个页面只编辑一篇图(单文档设计),而服务端要管所有人的所有图。
QPaintDoc 对应的是服务端的 Drawing,不是 Document。
4. 因为 localStorage 只有自己这段代码在用,改了顺手就改;
而网络协议可能被第三方依赖,发出去就收不回来。
接口的严谨程度,取决于"一旦定错了,收回来有多贵"。
5. 它解决的是**“协议这个最贵的错误,什么时候暴露”**的问题。
Mock 让前端不用等后端、让业务逻辑最快串起来——协议有问题当场就发现,还来得及改。
如果憋大招,等后端把高可靠高可用都做完才联调,那时候发现协议不对,代价就大得多。
下一步
第 7 步 · 30 讲:把两边真正接起来
29 讲:协议定了,服务端能跑了,但两边【还没接上】
30 讲:
宏观的系统架构 —— 完整的一张图
计算变更 —— 我这边改了什么?(prepareSync 那套)
同步变更 —— 怎么发上去、冲突了怎么办
加载文档 —— 怎么从服务端拉回来
★ Model 层的厚度 —— 全系列的收口,回答"到底什么该放进 Model"
