加载中...

最后一讲。 29 讲协议定了、服务端能跑了,但两边还没接上。这一讲把它们接起来。

而且这一讲有一段全系列的收口:用五个版本的真实行数,回答"到底什么该放进 Model"。

这一讲的代码:

你要的东西 路径
最终版浏览器端dom.js 856 行) 代码/qpaint源码/v30/
29→30 的 Model diff(746 行,最大的一次) diff-dom-v29-v30.txt
29→30 的 ViewModel diff(只有 22 行 diff-view-v29-v30.txt
服务端加了什么(就多一条 /sync diff-service-v29-v30.txt
最终版服务端 代码/qpaint源码/v30-服务端/
每个文件的结构地图 带读-源码导航

本篇讲的东西在 v30/dom.js(856 行)里的位置:

 89  class QSerializer          ★ 注册式的反序列化(第四节)
179  callAsync                  ★ 异步请求(带读-06 · 1.4 拆过)
204  class QSynchronizer        ★ 同步器:算变更、发、失败重试(第三节)
  └─ fireChanged                   _isTempDoc 就 return / 指数退避
292  shapeChanged               ★ 那个 type hack 消失了(4.3)
387  qshapes.register("line")   ┐
458  qshapes.register("rect")   │ 四种图形各自登记(第四节)
538  qshapes.register("ellipse")│
648  qshapes.register("path")   ┘
782  QPaintDoc.prepareSync      ★ 用 ver > baseVer 算变更(第二节)

建议读法:先看 782prepareSync(14 行)和 89QSerializer(20 行)——
30 讲的两个核心就这 34 行,其余是围绕它们的配套。


一 · 宏观架构:paintweb 是「平庸」的

1.1 两个程序的分工

浏览器
   │  http://localhost:8888/…
   ▼
paintweb(Go,监听 8888)
   ├─ /            →  发 www/index.htm 和那堆 js       ← 静态文件服务器
   └─ /api/…       →  【原封不动转发】给 localhost:9999  ← 反向代理
                            │
                            ▼
                    paintdom(Go,监听 9999)
                       真正的业务:drawings、shapes、sync

paintweb 的服务端代码,去掉注释就这么点:

var apiReverseProxy = newReverseProxy("http://localhost:9999")
var wwwServer       = http.FileServer(http.Dir("www"))

func main() {
    go paintdom.Main()                                              // 起 paintdom
    http.Handle("/api/", http.StripPrefix("/api/", apiReverseProxy)) // 转发
    http.HandleFunc("/", handleDefault)                              // 发文件
    http.ListenAndServe(":8888", nil)
}

原文反复用了"平庸"这个词形容它,而且是褒义
“paintweb 的服务端完全是’平庸’的,与业务无关。具体的业务,都是通过 www 目录里面的文件来做到的。”

(DEMO 里 go paintdom.Main() 让两个程序同进程跑,只是为了调试时一起起停。
实际业务中它们是两个独立部署的软件。

1.2 为什么要这么设计——中间那层转发不多余吗

这个链路看着确实奇怪paintweb 收到 /api/… 什么都不干就转走了。
那为什么不让浏览器直接访问 9999?

五个理由,从最硬的说起。

① 浏览器不许跨域

页面是从 localhost:8888 下载的,那它发的请求默认只能发回 8888
发给 9999 属于跨域,浏览器直接拦掉。

两个解法:服务端配 CORS 放行,或者让请求走同一个地址、由服务端转发。qpaint 选了后者。

最直接的理由:让浏览器只跟一个地址打交道。

paintdom 不该直接暴露

paintdom 是业务服务器。它凭什么信任来的请求?
真实系统里,登录和鉴权是 paintweb 这层做的,paintdom 只处理"已经确认过身份"的请求——
代码里那个 Authorization: QPaintStub 1 就是个占位。

中间这层是一道门。 你不会把数据库直接暴露给公网,同理不会把业务服务器直接暴露。

③ 这一层现在是空的,但位置留出来了

现在(DEMO):
浏览器 ──▶ paintweb ─── 原封不动转发 ───▶ paintdom

将来(真实系统):
浏览器 ──▶ paintweb ─┬─ 查登录状态 ─────▶ 帐号服务
                     │
                     └─ 转译后再转发 ────▶ paintdom
                        "存我的这张图"
                          ↓ 转译
                        "存用户 42 的这张图"

加登录、加多租户时,改这一处,两边都不用动。

这是你已经见过三次的手法——口子先开在正确的位置,实现先给最笨的:

口子 哪一讲留的 当时的实现
invalidateRect(rect) 26 参数收了不用,全屏重画
hitCode 27 只有 0 和 1
paintweb 的转译层 30 原封不动转发

④ 两个程序可以独立演进、独立部署

paintweb paintdom
干什么 发静态文件 + 转发 真正的业务
多久改一次 几乎不改 天天改
挂了会怎样 整站打不开 页面还能开,只是 API 报错
要几台 1 台够了 可能要横向扩到 10 台

而且 29 讲说的 /v1/ /v2/ 版本分流,就在这一层做——老流量走老服务器,新流量走新服务器。

⑤ DEMO 里同进程,是妥协不是设计

func main() {
    go paintdom.Main()        // ← 把 paintdom 当 goroutine 起在同一个进程里
    ...
}

原文自己说了,这纯粹是为了调试时起停一个进程就行,让两个程序"同生共死"。
实际业务中它们是两个独立部署的软件。

收在一条你已经很熟的形状上

浏览器  ──▶  paintweb  ──▶  paintdom
       ✗ 浏览器不认识 paintdom(它只知道 /api/…)
       ✗ paintdom 不认识浏览器(它只处理 HTTP 请求,不管谁发的)

又一次"中间加一层,两头互相不认识"——跟 View 在两个 Controller 之间中转事件
(27 讲 onControllerReset)是同一个形状,只是这次隔的是两个进程。

这个中间层在业界有名字:网关 / BFF(Backend For Frontend)。真实系统里几乎都有。

1.3 paintweb 服务端的三件事——全都与业务无关

# 干什么 现在做了吗
1 托管前端文件(静态下载服务器)
2 支持帐号服务,实现 Web 用户登录 还没有
3 业务协议的转译:把 Session-based 的请求转成 Multi-User 的请求 现在退化成了原封不动转发

第 2 条为什么值得单独说:帐号服务是基础架构,不是业务。
一个公司会有很多业务,它们必须共享同一套帐号体系——
否则用户在你家一个公司要记好几套账号,肯定要骂人。

1.4 把 24 讲那段抽象的话落地

24 讲讲过一段很抽象的分层,30 讲用 QPaint 把它对上号了:

24 讲说的 QPaint 里是谁 干什么
Multi-User Model paintdom 多租户的业务服务器,实现自动化所需的 API
Session-based Model paintweb 的服务端 把多租户 API 转译成单租户场景(现在退化成转发)
Session-based ViewModel www/ 里那堆 js 真正的 Web 业务入口
View 浏览器 渲染

“Session-based Model 是 Multi-User Model 层的转译”——
服务端那头管的是"所有人的所有图",浏览器这头只关心"我的这一张"。
中间那层就是把’所有人’收窄成’我’。 因为现在还不支持多租户,这层就变成了空转发。

1.5 胖前端 vs 胖后端

原文顺带聊了一个真实的技术选型分歧:

胖前端(QPaint 选的) 胖后端(比如 PHP)
业务逻辑写在 浏览器的 js 里 服务端
代码安全(IT 资产) 别人能看到你全部业务逻辑 看不到
能不能离线 不能——断网就什么都干不了

QPaint 选胖前端,就是为了离线。 这条从 28 讲一路贯穿到这里。

有意思的是原文的态度:即便选胖后端,他也倾向于"用 PHP 这种胶水语言写 Web 后端",
这样 paintweb 自身仍然是业务无关的,只是多了个"支持 PHP"的职责。
他一直在守"承载业务的东西和托管它的东西要分开"这条线。


二 · 计算变更:三个思路

问题:断网编辑了半天,恢复联网时——怎么知道哪些内容变过?

2.1 思路一 · 每次都完整保存整篇文档

浪费。 而且不只是恢复联网那一次——平常每次编辑都要自动保存
一篇 500 个图形的文档,你改一个矩形的颜色就重传 500 个。

2.2 思路二 · 记录完整的编辑操作历史

“每做一个操作就记一条到 localStorage”。听起来更省,实际上常常更浪费

为什么
一个对象编辑多次 拖一个矩形拖了 200 下,就是 200 条操作记录——而它最终只是"坐标变了"
断网久了 操作累计下来,存储空间甚至可能超过文档本身

原文的评价:“这种方案缺乏很好的鲁棒性,在 badcase 情况下让人难以接受。”

值得记的是这个判断方式:不看平均情况,看最坏情况。
思路二在"改几下就同步"时确实省,但它的最坏情况没有上界——这才是它被否掉的理由。

2.3 思路三 · 给对象加版本号 ← 选这个

文档有一个基版本  baseVer   = 上一次同步完成时的版本
每个图形有自己的  shape.ver = 它最后一次被改时的版本

           shape.ver > baseVer   ⇒   上次同步之后,这个图形变过

代价:每个图形多存一个整数。收益:算变更是一次 O(n) 的遍历,而且最坏情况有上界
(最多就是全部图形,等于思路一,不会更差)。

prepareSync(baseVer) {
    let shapeIDs = [], changes = []
    for (let i in this._shapes) {
        let shape = this._shapes[i]
        if (shape.ver > baseVer) {      // ★ 就这一句判断
            changes.push(shape)
        }
        shapeIDs.push(shape.id)          // ID 列表是全的(很小)
    }
    this.ver++
    return { shapes: shapeIDs, changes: changes, ver: this.ver }
}

三个思路的取舍,是一道很典型的题

存什么 最好情况 最坏情况
一 · 全量 只存当前状态 和最好一样(有上界)
二 · 操作历史 存过程 很好 无上界(可能超过文档本身)
三 · 版本号 当前状态 + 一个整数 很好 不会比思路一差

思路三赢在"最好情况接近思路二,最坏情况不差于思路一"。


三 · ★ 同步变更:协议被推翻了

这是 30 讲最值得读的一段。

3.1 有了变更信息,怎么发给服务端?

自然的想法:把变更还原成一条条编辑操作发上去——正好 29 讲那七条接口就是干这个的:

POST   /drawings/507f/shapes            加一个图形
POST   /drawings/507f/shapes/2          改一个图形
DELETE /drawings/507f/shapes/3          删一个图形

但作者否掉了这条路。理由是:

“这些编辑操作一部分发送成功,一部分发送失败怎么办
这种部分成功的中间态是最挑战我们程序员的编程水平的,很烧脑。”

具体想一下就知道有多麻烦:你发了 5 条,第 3 条超时了。现在服务端处于什么状态?
前两条生效了、后两条呢?要不要回滚?回滚也可能失败。要不要重发第 3 条?
重发会不会重复执行?……每一个分支都要写代码、都要测。

3.2 一条准则

“我个人一贯坚持的架构准则是不要烧脑。
尤其对大部分非性能敏感的业务代码,简单易于实施为第一原则。”

这句值得单独抄下来。 它不是在说"别写复杂的代码",而是在说——
当一个方案会逼出大量的中间态和边界情况时,先回头看看能不能换个方案让这些情况根本不存在。

3.3 于是加了一条 /sync

把"一堆操作"换成"一次整体同步"

POST /drawings/507f1f77/sync
{
  "shapes":  ["1","2","3"],       ← 现在这篇图有哪些图形、什么顺序
  "changes": [ …变过的图形… ],     ← 这些图形的完整内容
  "ver": 7
}

服务端拿到就整体覆盖。要么整个成功,要么整个失败——没有中间态。

我 diff 了 v29 → v30 的服务端路由表,只多了一行

  "POST/drawings":              p.PostDrawings,
  "GET/drawings/*":             p.GetDrawing,
+ "POST/drawings/*/sync":       p.PostDrawingSync,      ← 新增的就这一条
  "POST/drawings/*/shapes":     p.PostShapes,
  ...

一个诚实的观察旧的七条接口并没有被删掉,只是浏览器端不再用它们了
它们仍然是"给自动化程序用的 API"(29 讲说的那个用途)。
所以准确说不是"推翻",是**“给同一个业务加了第二套、粒度完全不同的接口”**。

3.4 复盘:最初错了吗?

原文的自问自答很坦率:

"这很有趣。在我们讨论相互配合的接口时,我们非常尊重业务逻辑,按照我们对业务的理解,
定义了一系列的编辑操作。但是,到最后我们却发现,它们统统不管用,我们要的是一个同步协议

是最初我们错了吗?也不能这么说。
最初我们定义协议的逻辑并没有错,只是没有考虑到支持离线编辑这样的需求而已。"

两条复盘结论:

#
1 需求的预见性非常重要。 没预见到,大部分情况下要为"缺乏市场洞察"买单
2 及早推出 Mock,让前端快速迭代,进而及早发现协议的不足,是很有必要的。
越晚做出协议调整,事情就越难,也越低效

这一段是 29 讲那个 Mock 决策的直接回报。
29 讲说 Mock 的价值是"快速验证网络协议的有效性,发现不满足可以及时调整"——
30 讲就真的调整了。 如果当时憋大招做正式版(接数据库、做高可靠高可用),
等做完才发现协议粒度不对,那些工作要重来一大半。


四 · 加载文档:一次重构

4.1 问题:两套格式,两套加载代码

到 v29 为止,同一个图形有两种 JSON 写法

localStorage 里:  {"type":"QRect", "x":10, "y":10, "width":50, ...}
网络协议里:      {"id":"2", "rect": {"x":10, "y":10, "width":50, ...}}

于是要写两套"从 JSON 造回对象"的代码。而且——图形种类只会越来越多
每加一种就要在两个地方各改一次。

4.2 QSerializer:让图形自己登记

重构目标(原文):① 统一两种表示;② 加新图形要容易、代码内聚、不必到处改。

class QSerializer {
    constructor() { this.creators = {} }
    register(name, creator) { this.creators[name] = creator }
    create(json) {
        for (let key in json) {
            if (key != "id") {                    // ★ 除了 id,剩下那个 key 就是类型
                let creator = this.creators[key]
                if (creator) return creator(json)
                break
            }
        }
        alert("unsupport shape: " + JSON.stringify(json))
    }
}
var qshapes = new QSerializer()

各个图形在自己的文件位置登记自己:

qshapes.register("line",    function(json) { return new QLine(json) })
qshapes.register("rect",    function(json) { return new QRect(json) })
qshapes.register("ellipse", function(json) { return new QEllipse(json) })
qshapes.register("path",    function(json) { return new QPath(json) })

每个图形加两个方法就够了:

class QRect {
    constructor(r, style) {
        if (style) { …正常新建… }          // 有 style → 新建
        else       { …从 json 读… }        // 没 style → 从 JSON 反序列化
    }
    toJSON() {
        return { id: this.id, rect: { x:…, y:…, width:…, height:…, style:…, ver:… } }
    }
}

这个你早就见过——就是带读-00 · 2.3 讲的注册表 + 工厂
registerController 一模一样的套路,只不过这次注册的是"怎么从 JSON 造回对象"。

收益:加一种新图形 = 写一个类 + 一行 registerloadShape 那个 switch 消失了。

4.3 顺带:那个 hack 消失了

带读-05 · 5.3 吐槽过 v28 里 shape.type 被当两个东西用的 hack。v30 里它没了:

// v28(hack)
function shapeChanged(shape) {
    let parent = shape.type                    // type 被当父指针用
    shape.type = shape.constructor.name        // 临时换成类名
    let val = JSON.stringify(shape)
    shape.type = parent                        // 换回去
    ...
}

// v30(干净)
function shapeChanged(parent, shape, noSync) {
    shape.ver = parent.ver
    let val = JSON.stringify(shape)            // toJSON() 自己产出正确格式,不用手脚
    localStorage_setItem(parent.localID + ":" + shape.id, val)
    if (!noSync) parent.syncer.fireChanged(parent)
}

parent 变成了参数,格式由 toJSON() 负责。 那个将就没了。

重构的常见形态就是这样:不是"把烂代码改漂亮",而是"引入一个新概念(toJSON/QSerializer),
原来的将就自然就没地方待了"。

4.4 三种加载场景

方法 什么时候走 联网时 断网时
_loadBlank URL 没 hash(全新文档) 在服务端建一个新 drawing 本地建临时文档(t 开头)
_loadTempDoc URL 是 #t10001(一直没同步过) 在服务端建新 drawing,并把离线编辑的数据同步过去 加载离线数据,继续离线编辑
_loadRemote URL 是 #507f1f77 先加载本地缓存,再异步拉远程;成功后本地离线内容被放弃 只用本地缓存

注意 _loadRemote 那句"本地离线编辑的内容会被放弃"——这是一个很粗暴的冲突策略
远程赢。真正的协同编辑(多人同时改同一个图形)要复杂得多,qpaint 没走到那一步。

4.5 onload 事件:避免 Model 去理解 View

class QPaintView {
    constructor() {
        let view = this
        this.doc.onload = function() {         // ← View 事先挂一个函数进去
            view.invalidateRect(null)
        }
    }
}

为什么要有这个事件? 原文:

“向服务器的 ajax 请求,什么时候完成是比较难预期的,我们加载文档是在异步 ajax 完成之后。
这样来看,完成文档加载后发出 onload 事件,就可以避免 Model 层需要去理解 View 层的业务逻辑。”

这就是带读-05 第四部分讲的 DataChanged 那件事,第一次在代码里真的落地了。
Model 加载完了只管喊一声"我加载完了",它不知道听众是谁;View 事先挂了一个"那我重画"。

又一次:事件是唯一一种「我通知你,但我不认识你」的通信方式。


五 · ★ Model 层的厚度(全系列收口)

22 讲说"Model 层越厚越好",26~30 讲一路在实践它。现在用真实行数验一遍。

5.1 五个版本的真实行数(我数的,不是估的)

文件 v26 v27 v28 v29 v30 倍数
dom.js Model 109 306 497 497 856 ×7.9
view.js ViewModel 112 125 131 131 133 ×1.2
creator/rect.js Controller 108 93 93 93 93 ×0.9
creator/path.js Controller 90 91 91 91 91 ×1.0
creator/freepath.js Controller 71 72 72 72 72 ×1.0
accel/select.js Controller 91 91 91 93 新增
accel/menu.js Controller 86 156 156 156 156 ×1.8
index.htm 组装 18 19 19 19 19 ×1.1
服务端 paintdom/ Model+Ctrl 657 725 新增

(原文说"v26 约 120 行 → v30 约 860 行,7.x 倍"——我数出来是 109 → 856,×7.9,对得上。)

5.2 三个数字

# 数字 说明什么
1 Model:×7.9 五轮需求,重量全压在这一层
2 ViewModel:×1.2 五个版本累计只涨了 21 行(112 → 133)
3 原有的三个 Creator:269 → 256,反而少了 13 行 五轮需求迭代下来,三个画图工具的代码不增反减

第 3 条是最强的证据。
27 讲加选择工具、28 讲加离线存储、29 讲加服务端、30 讲加同步——
rect.jspath.jsfreepath.js 从头到尾几乎没被打扰过。
rect.js 反而变少,是因为 normalizeRect 被搬去了 Model——它是 Model 的活。

v29 看起来是个例外dom.js 一行没改),原文解释得很好:

实际上 v29 整个变更都是 Model 层的变更,因为是增加了服务端的 Model
(我们前面把它叫做 Multi-User Model)。”

5.3 Model 层的五项职责

原文总结的(这是全系列最该抄下来的一张表):

# 职责 哪一讲长出来的
1 业务逻辑,对外暴露业务接口(最本职的工作) 26
2 实现 View 层委托的 onpaint,完成绘制 26
3 实现 Controller 层的 hitTest,支持 selection 27
4 与服务端 Multi-User Model 通讯——View、Controller 都不需要感知服务端 29 / 30
5 离线编辑 localStorage 的存取 28

“除了少量 View(onpaint)、Controller(hitTest)的需求,大部分都是 Model 层的正常业务范畴。
这些职责已经很多,所以 Model 层自然会胖。”

注意第 4 条:整个联网、同步、离线重试,View 和 Controller 一无所知
这就是为什么 28 讲 Controller 零改动、29 讲浏览器端零改动。

5.4 推论:为什么必须内聚

"如果我们不是让 Model 层代码以内聚的方式放在一起,而是让它自由地散落于各处
那么我们的代码变更质量会非常不受控

为什么?Model 层总体来说是最容易测试的,因为它的环境依赖最小。
如果这些代码被分散到 View、Controller 层中,代码的阅读难度、维护难度、测试的难度都会大幅增加。"

把 856 行放在一个文件里,看着"很胖";散到八个文件里,看着"很均衡"——
但后者每一处都变得难测、难改。

"胖"不是缺点,"散"才是。


六 · 全系列回顾

新需求 被逼出来的 代价落在哪
26 能画图 三层分工、多态自绘、事件委托
27 能选中/拖动/改样式/删除 hitTest bound move setProp deleteShape、Selection、事件中转 Model +197,View +13,Creator 各 +1 行
28 关掉再打开还在、断网能编辑 对象 ID(两个)、分层存储、变更分级 Model +191,View +6,Controller 零改动
29 有个服务端 网络协议、Mock 版服务端 浏览器端零改动,新增 657 行 Go
30 两边真的接起来 版本号算变更、/sync 协议、QSerializer Model +359,View +2

贯穿五讲的几条:

#
1 架构设计 = 先问"这个需求要求程序回答什么它现在答不上来的问题",再把问题变成接口装到该装的层
2 好的设计决定会反复付利息——26 讲"让 Model 自己干",被复用成 hitTest/bound/move/setProp/toJSON
3 事件是唯一一种"我通知你但我不认识你"的通信方式——onControllerResetonSelectionChangedonloadDataChanged
4 接口的严谨程度,取决于"一旦定错了,收回来有多贵"——localStorage 随意,网络协议严谨
5 不要烧脑——一个方案会逼出大量中间态时,先看能不能换个方案让这些情况根本不存在
6 Model 层越厚越好,而且必须内聚——胖不是缺点,散才是

七 · 验收

# 问题 答案在
1 paintweb 的服务端为什么被称作"平庸"的?它干哪三件事?中间那层转发不多余吗? 1.1 ~ 1.3
2 算"哪些内容变过"的三个思路是什么?为什么选版本号?
3 29 讲精心设计的那七条编辑接口,为什么到 30 讲不用了?
4 QSerializer 解决了什么问题?它跟 registerController 是什么关系? 4.2
5 五个版本下来,Model / ViewModel / Controller 各涨了多少?说明什么? 5.1 · 5.2

答案

1. 因为它与业务无关——业务全在 www/ 的 js 和 paintdom 里。它只干三件事:
托管前端文件支持帐号服务(还没做)、业务协议转译(现在退化成原封不动转发)。
"平庸"在这里是褒义:它没有把业务逻辑吸进来。

2.全量保存——浪费;② 记操作历史——听着省,最坏情况没有上界(一个对象改 200 次就 200 条,
断网久了历史可能超过文档本身);③ 给对象加版本号ver > baseVer 就是变过的 ← 选这个。
它赢在"最好情况接近思路二,最坏情况不差于思路一"。

3. 因为一条条发编辑操作会产生部分成功的中间态——发了 5 条第 3 条超时,服务端是什么状态?
要不要回滚?重发会不会重复执行?每个分支都要写要测。
换成一次整体 /sync要么整个成功要么整个失败,没有中间态。
这就是那条准则:不要烧脑。
(旧的七条没删,仍是给自动化程序用的 API。)

4. 解决了**“同一个图形有两套 JSON 格式,要写两套加载代码”,而且加新图形要到处改**。
QSerializer 让每个图形自己登记怎么从 JSON 造回自己,加新图形 = 写一个类 + 一行 register
它跟 registerController 是同一个套路(注册表 + 工厂),只是注册的东西不同。

5. Model ×7.9(109→856)、ViewModel ×1.2(只涨 21 行)、
原有三个 Creator 269→256,反而少了 13 行
说明需求的重量几乎全压在 Model 上,上面两层几乎没被打扰——
这正是"Model 越厚越好"的实际收益。而 Model 必须内聚:散出去就变得难测、难改。


全系列到此结束

收尾总结在这里:带读-08 · 收尾:五讲带走了什么
——六个反复出现的手法、16 条值得背下来的判断、作者自己没做到的地方,以及"如果只记三条"。

带读-地图 / 源码导航     索引,随时回来看
带读-00                 前端底子(26~30 共用)
带读-01 ~ 03            26 讲:Model / 一次画矩形 / 收口
带读-04                 27 讲:加选择工具
带读-05                 28 讲:离线持久化与对象 ID
带读-06                 29 讲:网络协议与服务端
带读-07                 30 讲:对接、同步、Model 层的厚度   ← 你在这

这门课后面还会拿这个程序继续讲(31 讲辅助界面元素、32 讲概要设计、41~44 讲服务端实战),
到那时你手上已经有一份完整读过的代码了。

公告栏
这是我的个人知识库。
记录技术,也记录生活 —— 读过的、试过的、想明白的,都堆在这儿。
最新文章
网站资讯
文章数目 :
5
已运行时间 :
本站总字数 :
15.7k
本站访客数 :
本站总访问量 :
最后更新时间 :
全局知识图谱
当前页面 已访问 文章 标签
ESC 关闭 · 滚轮缩放 · 拖拽移动 · Ctrl+G 开关