27 实战(二):怎么设计一个“画图”程序?

你好,我是七牛云许式伟。

上一讲开始,我们进入了实战模式。从目前看到的反馈看,我的预期目标并没有达到。

我复盘了一下,虽然这个程序看起来比较简单,但是实际上仍然有很多需要交代而没有交代清楚的东西。

我个人对这个例子的期望是比较高的。因为我认为 “画图” 程序非常适合作为架构实战的第一课。“画图” 程序需求的可伸缩性非常大,完完全全是一个迷你小 Office 程序,很适合由浅及深去谈架构的演进。

所以我今天微调了一下计划,把服务端对接往后延后一讲,增加一篇 “实战(中)” 篇。这个“中”篇一方面把前面 “实战(上)” 篇没有交代清楚的补一下,另一方面对 “画图” 程序做一次需求的迭代。

MVP 版画图程序

先回到 “实战(上)” 篇。这个版本对画图程序来说,基本上是一个 MVP 版本:只能增加新图形,没法删除,也没法修改。

怎么做?我们先看 Model 层,它的代码就是一个 dom.js 文件。从数据结构来说,它是一棵以 QPaintDoc 为根的 DOM 树。这个 DOM 树只有三级:Document -> Shape -> LineStyle。具体细节可以参阅下表:

这个表列出的是 Model 和 View、Controllers 的耦合关系:Model 都为它们提供了什么?可以看出,View 层当前对 Model 层除了绘制(onpaint),没有其他任何需求。而各个 Controller,对 Model 的需求看起来似乎方法数量不少,但是实质上目的也只有一个,那就是创建图形(addShape)。

我们再看 View 层。它的代码主要是一个 index.htm 文件和一个 view.js 文件。View 层只依赖 Model 层,并且只依赖一个 doc.onpaint 函数。所以我们把关注点放在 View 自身的功能。

View 层只有一个 QPaintView 类。我们将其功能分为了三类:属于 Model 层职责相关的,属于 View 自身职责相关的,以及为 Controller 层服务的,得到下表。

最后,我们来看 Controller 层。Controller 层的文件有很多,这还是一些 Controller 因为实现相近被合并到一个文件,如下所示。

Controller 位于 MVC 的最上层,我们对它的关注点就不再是它的规格本身,也没人去调用它的方法。所以我们把关注点放在了每个 Controller 都怎么用 Model 和 View 的。

我们列了个表,如下。注意 Controller 对事件(Event)的使用从 View 中单独列出来了。

通过以上三张表对照着看,可以清晰看出 Model、View、Controllers 是怎么关联起来的。

改进版的画图程序

MVP 版本的画图程序,用着就会发现不好用,毕竟图形创建完就没法改了。所以我们打算做一个新版本出来,功能上有这样一些改进。

怎么改我们的程序?

完整的差异对比,请参见:

下面,我们将详细讲解这些修改背后的思考。

我们先看 Model 层,新的规格见下表。

为了方便大家理解,我们做了一个 Model 的 ChangeNotes 表格,如下:

大部分是新功能的增加,不提。我们重点关注一个点:QLineStyle 改名为 QShapeStyle,且其属性 width、color 被改名为 lineWidth、lineColor。这些属于不兼容修改,相当于做了一次小重构。

重构关键是要及时处理,把控质量。尤其对 JavaScript 这种弱类型语言,重构的心智负担较大。为了保证质量仍然可控,最好辅以足够多的单元测试。

这也是我个人会更喜欢静态类型语言的原因,重构有任何遗漏,编译器会告诉你哪里漏改了。当然,这并不意味着单元测试可以省略,对每一门语言来说,自动化的测试永远是质量保障的重要手段。

话题回到图形样式。最初我们 new QLine、QRect、QEllipse、QPath 的时候,传入的最后一个参数是 QLineStyle,从设计上这是一次失误,这意味着后面这些构造还是都需要增加更多参数如 QFillStyle 之类。

把最后一个参数改为 QShapeStyle,这从设计上就完备了。后面图形样式就算有更多的演进,也会集中到 QShapeStyle 这一个类上。

当前 QShapeStyle 的数据结构是这样的:

class QShapeStyle {
  lineWidth: number
  lineColor: string
  fillColor: string
}

那么,这是合理的么?未来潜在的演进是什么?

对需求演进的推演,关键是眼光看多远。当前各类 GDI 对 LineStyle、FillStyle 支持都非常丰富。所以如果作为一个实实在在要去迭代的画图程序来说,上面这个 QShapeStyle 必然还会面临一次重构。变成如下这个样子:

class QLineStyle {
  width: number
  color: string  
}

class QFillStyle {
  color: string  
}

class QShapeStyle {
  line: any
  fill: any
}

为什么 QShapeStyle 里面的 line 不是 QLineStyle,fill 不是 QFillStyle,而是 any 类型?因为它们都只是简单版本的线型样式和填充样式。

举个例子,在 GDI 系统中,FillStyle 往往还可以是一张图片平铺,也可以是多个颜色渐变填充,这些都无法用 QFillStyle 来表示。所以这里的 QFillStyle 更好的叫法也许是 QSimpleFillStyle。

聊完了 Model 层,我们再来看 View 层。

View 层的变化不大。为了给大家更直观的感觉,我这里也列了一个 ChangeNotes 表格,如下:

其中,properties 改名为 style,以及删除了 get lineStyle(),和 properties 统一为 style。这个和我上面说的 Model 层的小重构相关,并不是本次新版本的功能引起的。

所以 View 层真正的变化是两个:

引入 selection 比较常规。View 变复杂了通常都会有 selection,唯一需要考虑的是 selection 会有什么样的变化,对于 Office 类程序,如果 selection 只允许是单 shape 这不太合理,但我们这里略过,不进行展开。

我们重点谈 onControllerReset 事件。

onControllerReset 事件是创建图形的 Controller(例如 QPathCreator、QRectCreator 等)发出,并由 Menu 这个 Controller 接收。

这就涉及了一个问题:类似情况还会有多少?以后是不是还会有更多的事件需要在 Controller 之间传递,需要 View 来中转的?

这个问题就涉及了 View 层事件机制的设计问题。和这个问题相关的有:

从最通用的角度,肯定是支持任意事件、支持多播。比如我们定义一个 QEventManager 类,规格如下。

class QEventManager {
  fire(eventName: string, params: ...any): void
  addListener(eventName: string, handler: Handler): void
  removeListener(eventName: string, handler: Handler): void
}

但是,View 的事件机制设定,需要在通用性与架构的可控性之平衡。一旦 View 聚合了这个 QEventManager,通用是通用了,但是 Controller 之间会有什么样的事件飞来飞去,就比较难去从机制上把控了。

代码即文档。如果能够用代码约束的事情,最好不要在文档中来约束。

所以,就算是我们底层实现 QEventManager 类,我个人也不倾向于在 View 的接口中直接将它暴露出去,而是定义更具体的 fireControllerReset、 onControllerReset/offControllerReset 方法,让架构的依赖直观化。

具体代码看起来是这样的:

class QPaintView {
  constructor() {
    this._eventManager = new QEventManager()
  }
  onControllerReset(handler) {
    this._eventManager.addListener("onControllerReset", handler)
  }
  offControllerReset(handler) {
    this._eventManager.removeListener("onControllerReset", handler)
  }
  fireControllerReset() {
    this._eventManager.fire("onControllerReset")
  }
}

聊完了 View 层,我们接着聊 Controller 层。我们也把每个 Controller 怎么用 Model 和 View 列了个表,如下。

内容有点多。为了更清楚地看到差异,我们做了 ChangeNotes 表格,如下:

首先,Menu、QPathCreator、QFreePathCreator、QRectCreator 的变更,主要因为引入了新的交互范式导致,我们为此引入了 onControllerReset 事件。还有一个变化是 QLineStyle 变 QShapeStyle,这一点前面已经详细讨论,不提。

所以 Controller 层的变化其实主要是两个。

其一,PropSelectors。这个 Controller 要比上一版本的复杂很多:之前只是修改 View 的 properties (现在是 style) 属性,以便于创建图形时引用。现在是改变它时还会作用于 selection (被选中的图形),改变它的样式;而且,在 selection 改变时,会自动更新界面以反映被选图形的样式。

其二,QShapSelector。这是新增加的 Controller,支持选择图形,支持删除、移动被选择的图形。

通过这次的需求迭代我们可以看出,目前 Model、View、Controller 的分工,可以使需求的分解非常正交。

Model 只需要考虑需求导致的数据结构演进,并抽象出足够自然的业务接口。View 层非常稳定,主要起到各类角色之间的桥接作用。Controller 层每个 Controller 各司其职,彼此之间不会受到对方需求的干扰。

结语

今天我们结合“画图” 程序重新梳理了一遍 MVC 架构。并且我们更进一步,通过对画图程序进行一次需求演进,来观察 MVC 架构各个角色对需求变更的敏感性。需要再次强调的是,虽然我们基于 Web 开发,但是我们当前给出的画图程序本质上还是单机版的。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将继续实战一个联网版本的画图程序。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。