本篇要回答的问题:怎么把前后端两个软件(paintdom、paintweb)真正对接起来,并在对接的同时保留离线编辑能力?为此网络协议要怎么调整、文档怎么加载、Model 层为什么应该"越厚越好"?
这是"画图"程序五讲实战的收官篇。上一讲我们定义了 paintdom(服务端)与 paintweb(前端)之间的网络协议并各自实现了第一版,但两者还没真正对接。本讲就完成对接——目标不是加编辑功能,而是让文档能存到服务端、随处可打开,同时断网能继续编辑、联网能自动同步。
宏观的系统架构:paintweb 服务端的"平庸"
paintweb(:8888)和 paintdom(:9999)在 DEMO 里同进程跑(paintdom 作为 goroutine),只为方便调试一起起停;现实中它们是不同软件,通过反向代理进程间协作。
paintweb 自身的服务端是业务无关、"平庸"的,它只做三件事:
| paintweb 服务端职责 | 说明 |
|---|---|
| 托管 Web 前端文件 | 一个普通静态文件下载服务器,吐 HTML+CSS+JS |
| 支持帐号服务 | 对接 Account Service,实现 Web 用户登录/登出(基础架构类服务,须全公司共享) |
| 业务协议转译 | 把 Session-based API 请求转为 Multi-User API 请求;本例不支持多租户,转译退化为简单转发 |
关键判断:业务逻辑的串联,靠的是 www 里的 js 文件 + paintdom 的 API,而不是 paintweb 的后端。后端越"平庸"、越业务无关越好。
把它对应到 24 讲讲过的分层模型:
| 本例角色 | 对应层 | 含义 |
|---|---|---|
| paintdom | Multi-User Model | 多租户业务服务器,实现 Automation 所需 API |
| paintweb 服务端 | Session-based Model | 负责 Session-based → Multi-User 的转译 |
| www 里的 HTML+JS+CSS | Session-based ViewModel | 真正的 Web 业务入口,遥控浏览器侧渲染 |
胖前端 vs 胖后端:
| 模式 | 业务逻辑在哪 | 优点 | 缺点 |
|---|---|---|---|
| 胖前端(本例) | JavaScript | 可离线 | Web 业务代码暴露 |
| 胖后端 | 如 PHP 跑在后端 | IT 资产保全更安全(别人看不到完整逻辑) | 无法离线,断网即不可用 |
计算变更:怎么知道离线改了什么?
对接后要做到:断网照常离线编辑保存;一联网,离线改动自动同步到 paintdom。难点是如何识别断网期间改过的内容。三种思路:
| 思路 | 做法 | 问题 |
|---|---|---|
| 一:全量保存 | 每次完整保存整篇文档 | 太浪费(平时每次编辑都自动保存) |
| 二:记录操作历史 | 每个编辑操作写入 localStorage | 同一对象多次编辑指令爆炸;断网久了甚至超过文档大小,缺鲁棒性 |
| 三:对象版本号 | 给对象加 ver,与文档基版本 baseVer 对比,ver > baseVer 即变更 |
选用此方案 |
prepareSync(baseVer) 遍历所有 shape,挑出 ver > baseVer 的放入 changes,返回 {shapes(全量ID列表), changes(变更), ver},并自增文档 ver。
同步变更:从"编辑操作"到"同步协议"
有了变更信息,怎么发给服务端?若把变更还原成一条条编辑操作发送,会遇到部分成功的中间态——最烧脑、最考验编程水平。
架构准则:不要烧脑。对大部分非性能敏感的业务代码,简单易实施是第一原则。
于是选择修改网络协议,新增一个同步接口,整体把变更 diff 推过去(由 QSynchronizer 类完成)。
复盘的洞见:最初按业务理解定义的"一系列编辑操作"协议没有错,只是没预见离线编辑需求。需求的预见性很重要;及早推出 Mock 让前端快速迭代,才能及早暴露协议不足——越晚调整协议,代价越大。
加载文档:统一图形表示 + 注册式可扩展
难点是根据服务端返回的 JSON 重建文档。原先网络协议的图形格式和 localStorage 里的不同,要写两套加载逻辑——没必要。结合"图形种类会越来越多"这个可预期的变化点,做了一次重构:
- 统一 localStorage 与网络协议中的图形表示;
- 新增图形种类要容易、代码内聚。
手段是引入 qshapes: QSerializer 全局变量,各图形类型把自己的 creator 注册进去(qshapes.register("rect", json => new QRect(json))),每个图形实现 constructor(json) 与 toJSON(),之后统一用 qshapes.create(json) 创建实例。
加载文档分三类场景:
| 场景 | 含义 | 联网时 | 非联网时 |
|---|---|---|---|
_loadBlank |
加载新文档 | 服务端创建新 drawing | 本地建临时文档(displayID 以 t 开头) |
_loadTempDoc |
加载一直离线编辑的临时文档 | 服务端创建 drawing 并同步离线数据 | 加载离线数据,继续离线编辑 |
_loadRemote |
加载远程文档 | 先加载本地离线缓存,再异步拉远程,成功后放弃本地离线内容 | 加载本地缓存 |
加载完成后
QPaintDoc发出 onload 消息,由QPaintView响应刷新界面。原因是 ajax 完成时机不可预期;用事件解耦,避免 Model 层去理解 View 层的业务逻辑。
Model 层的厚度:越厚越好
秉承"Model 层越厚越好"的理念(22 讲提出:Model 层最与框架无关、最易测试、最易跨平台)。两组观测数据印证:
| 观测 | 数据 |
|---|---|
| dom.js 行数 | MVP(v26)约 120 行 → 最新(v30)约 860 行,翻约 7.x 倍 |
| 各版本变更 | v27/v28/v30 主要改 Model;v29 看似没动 dom.js,其实整个变更都是服务端 Model(Multi-User Model) |
推论:若 Model 代码不内聚、散落各处,变更质量会非常不受控。Model 层环境依赖最小、最容易测试,一旦被打散到 View/Controller,阅读/维护/测试难度都会大增。
迭代后总结出的 Model 层职责(自然会"胖"):
| Model 层职责 | 说明 |
|---|---|
| 业务逻辑、对外暴露业务接口 | 最本职的工作 |
| 实现 View 委托的 onpaint | 完成绘制 |
| 实现 Controller 的 hitTest | 支持 selection |
| 与服务端 Multi-User Model 通讯 | View/Controller 都无需感知服务端 |
| 离线编辑 localStorage 存取 | 持久化 |
总结
本讲完成了画图程序前后端的对接,复杂度主要来自"支持离线编辑"。核心是:用对象版本号识别离线变更、用同步协议而非编辑操作流来回避部分成功的中间态、用注册式序列化统一图形表示并预留扩展、用 onload 事件解耦异步加载、最后用厚 Model 层承接绝大部分变化。
先把"是什么"回答清楚
| 概念 | 一句话说明 |
|---|---|
| Multi-User Model | 多租户业务服务器(paintdom),提供 Automation API |
| Session-based Model | Session→多租户的转译层(paintweb 服务端),本例退化为转发 |
| 胖前端 / 胖后端 | 业务逻辑放前端 JS(可离线)/ 放后端如 PHP(更安全但不可离线) |
| baseVer 版本对比 | 文档基版本,对象 ver > baseVer 即为离线期间发生过变更 |
| 同步协议 | 整体推送变更 diff 的接口,回避逐操作发送的部分成功中间态 |
| QSerializer | 图形类型注册 creator 的全局序列化器,统一图形表示、易扩展 |
一句话速记
离线编辑靠"版本号识别变更 + 同步协议整体推送",把烧脑的部分成功问题挡在门外;图形用注册式序列化统一表示;一切复杂度尽量压进最易测试、最该变厚的 Model 层。
几条值得记住的判断
- 不要烧脑:非性能敏感的业务代码,简单易实施优先于"看似省事"的复杂方案。
- 需求预见性 + 及早 Mock:协议越晚改越贵,Mock 能让前端先跑、尽早暴露协议缺陷。
- Model 层越厚越好:它最易测试、最跨平台,是内聚的天然落点。
思考题
回到你自己的项目:你的客户端与服务端协议,是按"业务操作"定义的,还是能从容应对"断网/重试/部分成功"这类工程现实?如果现在要支持离线编辑,你会发现哪些原先"正确"的协议设计其实是缺乏预见性的?





