加载中...

这一讲的性质:浏览器端一个字都没改,全是新增的服务端程序。

我把 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 也不碰 ViewModel
27 “响应鼠标键盘的那个东西” 菜单响应的是按钮点击
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 的偶数段一定是 IDdrawings/<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"
公告栏
这是我的个人知识库。
记录技术,也记录生活 —— 读过的、试过的、想明白的,都堆在这儿。
最新文章
网站资讯
文章数目 :
5
已运行时间 :
本站总字数 :
15.7k
本站访客数 :
本站总访问量 :
最后更新时间 :
全局知识图谱
当前页面 已访问 文章 标签
ESC 关闭 · 滚轮缩放 · 拖拽移动 · Ctrl+G 开关