设计模式浅谈

前言

  进入职场一年半以来,Shaun 完全独立从 0 到 1 开发了 1.5 个项目(当然也有参与其它项目,但不是 Shaun 独立从 0 到 1 开发的,没多少控制权,就不谈了),一个网页版的高精地图编辑器,半个地图可视化系统,这里面 Shaun 用了不少设计模式,这篇就谈谈 Shaun 用过的和没用过的一些设计模式。

  以「Head First 设计模式」为参考,Shaun 用 C++ 实现了一遍书中的例子(代理模式及其后面的模式除外),下面进入正文。

模式篇

策略模式

  Shaun 个人认为最能体现面向对象编程思想(抽象封装继承多态)的一种模式,换句话说,只要真正理解和运用面向对象编程,一定会自然而然的用到策略模式。Shaun 在做高精地图编辑器时,需要设计一个渲染模块,渲染模块会包含高亮行为,高亮有两种,一种是直接改变颜色,一种是使用后期处理(OutlinePass 或 UnrealBloomPass 等)进行高亮,这时就需要在渲染类中组合高亮行为。

  策略模式中涉及到的原则有:1、封装变化;2、多用组合,少用继承;3、针对接口编程,不针对实现编程。封装变化这点很考验程序员的经验水平,在写代码之初,往往预料不到变化,所以这一点一般是在编码过程中逐渐完善的,不断进行抽象,从而生成比较合理的基类;第二点一般也是对的,但有时在编码过程中难免会碰到到底是用继承还是组合的问题,这时候可以多想想,组合并不是万能的,有时继承更合适,这时可以请教身边更有经验的程序员,组合的优势在于当子类不想要这个对象时,可以随时丢弃,而继承的优势在于,当子类不想实现这个行为时,可以有默认的行为,而且有些时候只能用继承;针对接口编程没啥好说的,就是抽象。

观察者模式

  这个模式如果在分布式系统中又叫发布订阅模式,该模式常用于消息通知。前端有个 RxJS 的库将这一模式玩出花来了,Shaun 在高精地图编辑器的事件流管理中就使用了该库。在 threejs 中所有渲染对象的都有一个统一的基类 EventDispatcher,该类中就实现了一个简单的观察者模式,用来处理事件监听触发和通知,和真正的观察者相比,区别在于观察者只是一个函数,而不是一个类,其实浏览器的事件监听机制实现方式也和这个类差不多。

  观察者模式中涉及到原则有:松耦合。这里的松耦合是指主题和观察者之间是隔离的,观察者可自行实现自己的更新行为,而主题同样可实现自己的通知机制,两者虽有关联但互不影响。松耦合原则说起来人人会说,但真正能实现松耦合的却不多,实现松耦合的关键在于怎样分离两个系统,两个系统的连接点在哪,这有时很难理清,从而造成逻辑混乱,bug 丛生。

装饰者模式

  利用该模式可以很方便的扩展一些方法和属性,尤其是遇到主体和配件这样的关系时,可以很轻松的添加配件到主体上。Shaun 没用过这个模式,本来在扩展 threejs 一个类时想用,但确实没找到非常明确的主体和配件这样的关系,最后还是简单的使用继承了。

  装饰者模式涉及到的原则有:开放——封闭原则。设计一个类需要考虑对扩展开放,对修改关闭。修改和扩展看似矛盾,但实则可以独立存在,装饰者的配件可以无限加,这是扩展,是开放,而在加配件时无需修改现有代码,这是封闭。当然这一原则并不独属于装饰者模式,应该说是只要用到面向对象的思想开发程序,就一定会用到该原则,否则也就失去了面向对象的意义。但有时这个原则又没必要贯彻彻底,因为对于有些需求可能很难弄清修改和扩展的界限,这时就达到能尽量重用父类的方法就好。

工厂模式

  该模式在稍微大一点的系统中应该都会用到,根据不同的输入,生成不同的对象,这就是工厂模式的本质。至于工厂模式的实现方式一般会根据需求的复杂度来决定:1、只有一个工厂,一类产品,只是为了集中一层 if-else,可用简单工厂模式,甚至一个 builder 函数即可;2、有多个工厂,还是只有一类产品,用工厂模式,多个工厂继承一个工厂父类即可,相当于多个简单工厂组合;3、有多个工厂,多类产品,哪个工厂生产什么产品可能有变化,这时需要用到抽象工厂模式,除正常的继承之外,还需使用组合,组合组成产品的父类,相当于再组合一个工厂。Shaun 在高精地图编辑器中当然是大量使用的工厂模式和简单工厂模式,主要是为了集中 if-else 的处理,比如根据不同的数据类型创建不同的属性栏界面(枚举用下拉框,字符串用文本框,数字用数字栏等),根据不同的路网元素创建对应的渲染器对象以及对应的属性界面等。

  工厂模式涉及到的原则有:依赖倒置原则。尽量依赖抽象,而不是具体类。这其实也是抽象一种作用或好处,即在使用过程中尽量使用最上层的父类,具体类只在创建实例时使用。

单例模式

  写程序的基本都会用到该模式,主要用来创建全局唯一对象,可用来存储和处理系统中常用的各个模块都会用到的一些数据。Shaun 在编辑器中单例模式用了好几个,比如全局唯一的 viewport,用力绘制 3d 图形;全局唯一的路网数据;当然系统中存在太多的单例模式也不好,最好是只有一个,如 Shaun 的编辑器中最好的模式就是创建一个单例的 Editor 类,需要做单例的对象都可以放在该类中,如此保证系统中只有一个单例类,以进行统一管理。

  该模式与面向对象倒是没多大关系了,可以认为是全局变量的优化版,毕竟大的系统中全局变量基本不可避免,这时就可以使用单例模式。

命令模式

  该模式主要用来将函数方法封装成类,这样做的好处就是可以更灵活的执行该方法(将方法放进队列中依次执行,将方法持久化以便系统启动执行),同时也可以保存该方法的一些中间状态,以便撤销操作,回到系统执行该方法前的状态。Shaun 在编辑器中主要用命令模式做撤销重做功能,这基本也是编辑器的必备功能了,可以说没有撤销重做功能的编辑器是不完整的,要实现撤销重做功能除了基本的命令模式之外,还要提供撤销和重做两个栈以保存操作命令。

  该模式与面向对象也没很大关系,只是提供了一个实现一些特殊功能的标准或通用方案。

适配器模式

  该模式正如其名,主要用来转换接口,将一个类的方法用其它类封装一下,以达到兼容其它类接口的目的,同时对外可接口保持不变,该模式通过对象组合实现。Shaun 没使用过该模式,就 Shaun 感觉这个模式应该可以用在维护了好几年的系统上,当新作的东西需要兼容老接口时,可以用适配器模式将新街口封装一下。

  该模式同样只是提供了一种新接口兼容老接口的一种优良方案,当然实际使用过程中可能很难这么完美,但算是一种思路。

外观模式

  该模式算是封装的一种体现。当一个功能需要经过多次函数调用才能完成时,这时可以用另一个方法将这些函数都封装起来,从而简化调用方式。Shaun 用该模式处理整个渲染模块的初始化和资源释放,因为初始化时需要分配好很多东西(光照,viewport,固定图层,地面,天空盒等),而释放时同样需要释放这些东西。该模式同样只能算是提供了一种好的编程实践,实际使用过程可能每个函数都有很多参数,调用顺序可能有变,这时简化调用反而没有必要,让用户自己决定怎样调用更好。

  外观模式涉及到的原则有:最少知识原则。该原则主要用来减少对象依赖,即尽量不将类中组合的对象直接暴露出去,而应该将组合对象的方法再简单封装一下,再将封装后的方法暴露出去,以减少另外的类又依赖类中组合对象的现象。该原则可以适当遵守,因为有时直接使用更方便一点,多次封装之后反而显得逻辑混乱,增加系统的复杂度。

模板方法模式

  该模式是抽象的一种体现。首先抽象出一套固定化的流程,流程中每个步骤的具体行为并一致,有些默认,有些可以重写,父类固定流程,子类负责重写流程中每个步骤,这就时模板方法模式。Shaun 没写过完全符合该模式的代码,只是写了个类似该模式的模块,该模块有三个功能(编辑道路节点,编辑车道节点,编辑车道线),做完前两个功能后,发现这里有一套逻辑是通用的,那就是滑过节点高亮,选择节点,出现 gizmo,拖动 gizmo,完成编辑(当然还有选择节点后不拖动 gizmo 等一套 if-else 中间处理状态),于是 Shaun 把这一套流程抽象出来,固化方法,这三个功能都继承该类,方法该重写的重写,不仅减少了代码量,同时整个流程也更清晰了,很快完成了第三个功能。

  模板方法涉及到的原则有:好莱坞原则。即由父类负责函数调用,而子类负责重写被调用的函数,不用管父类的调用逻辑,也最好不要调用父类的函数。该原则用来理清流程很方便,只需要看父类即可,但实际编程过程中可能也会遇到子类不可避免的会调用父类的一些公共函数的情况,Shaun 觉得只要流程没问题的话,调用父类函数也能接受,并不需要严格遵守模式。

迭代器模式

  迭代器,即对遍历进行封装,一般只能顺序执行,提供 next() 方法获取下一个元素,集合容器的遍历方式一般都会用迭代器进行封装。Shaun 在这一个半项目里没写过迭代器,毕竟这是非常底层的一个模式,语言库本身有的数据结构大多自己实现了迭代器,除非需要设计一个新的集合或容器数据结构,才需要提供相应的迭代器。因为 js 没有 SortedMap 数据结构,为了高效分配路网元素 id,Shaun 利用 object 简单实现了一个,提供了相应的 forEach 方法。

  迭代器模式涉及到的原则有:单一责任原则。即一类只做一件事,这个原则对于涉及最最底层的接口很实用,而大多具体类很难只做一件事。迭代器模式对于顺序访问来说还是非常有用的,毕竟使用迭代器的人不需要管底层到底用的什么数据结构,反正可以顺序遍历即可。

组合模式

  组合模式与其说是一种模式,更不如说就是一颗树,只是树的节点都是可供继承的类。在标准的组合模式中,父类中一定会有全部子类的全部函数,即所有子类的函数要么是继承自父类,要么是重写父类函数的,这其实是违背上面单一责任原则的,因为这必然会造成有些子类不需要这么多函数。而从组合模式会存储孩子节点这点来看,和装饰者模式有点类似,只不过装饰者只会存一个孩子,而组合模式可能会存多个,当然两者做的事是不一样,只是实现手法类似而已。Shaun 没写过标准的组合模式,如果只要符合树形模式都可认为是组合模式,那在高精地图编辑器中,所有路网元素都会继承一个父类,而道路中又包含车道簇,车道簇中包含车道,这也算组合模式。在 threejs 中有个 Object3D 的基类,所有渲染对象都会继承该类,该类中又包含若干孩子,threejs 计算 Model 矩阵时就是一层层孩子的 Model 矩阵乘下去,直到最后的孩子,结果就是最后 Shader 中的 Model 矩阵。

状态模式

  状态机的状态转移可以说是程序设计中最麻烦的部分之一了,这部分如果写不好的话,根本没法修改维护,同时会造成 bug 频发。在高精地图编辑器中鼠标操作有两类模式,一种是选择模式,另一种是编辑模式,选择模式又分为点选和框选,而编辑模式就非常多了,针对路网的不同元素,编辑模式的具体实现都不会一样,Shaun 首先使用 RxJS 封装了一个鼠标操作类(左键右键中键移动等),后续的鼠标操作都继承自该类,可以算是状态模式的父类,后续的鼠标操作就针对相应的需求实现相应的方法即可,当然其中鼠标操作自身也存在状态转移(左键到右键,左键到鼠标移动等),这些一般都是针对特定需求来的,所以这些小的状态转移一般在鼠标操作内部实现,但需要支持随时从编辑模式到选择模式,这意味着编辑模式编辑到一半的东西都需要支持随时释放,恢复编辑前的样子,这算是一个麻烦的地方,有时忘了释放就会出现问题。

  状态模式算是为解决状态转移问题提供一种理想的方案,但其具体实现并不一定要和书上一样,Shaun 在用 C++ 实现时就采用另一套方案,状态类是独立的,控制状态转移的代码都在状态机内,而不是书中这种直接在状态类中控制状态机。好处坏处都有,看具体需求,Shaun 的方式就是状态类和状态机是分离的,状态类不需要管状态机怎么实现的,只需要管当前状态的情况,但需要在状态机中管理状态转移,而书中实现方式状态机的状态转移放到状态类中了,也因此状态类需要依赖状态机。


剩下的模式,Shaun 就没直接写代码实践了,因为大多都需要跨模块实现,有的甚至就是个小项目了,所以就简要谈谈 Shaun 的个人理解

代理模式

  主要可以用来做权限控制,在模块与模块之间的调用过程中,有时不想要一个模块可以访问另一个模块的全部内容,这时可以使用代理模式,创建一个中间模块,避免两个模块直接调用,同时进行访问控制。代理模式在如今的互联网时代不可避免的会用到,或直接或间接,往最小的说,对象组合也可用来实现代理模式。

复合模式

  将多种模式组合在一起使用,比如 MVC 模式,这种模式与其说是模式,更不如说就是一种架构,一种开发包含客户端系统的通用架构,当然每一层都会有很多模式进行组合,从而造成具体实现差异非常大。

反模式

  反模式指的是用“不好的解决方案”去解决一个问题,Shaun 主要想谈谈开发反模式,因为这非常常见。有时候一个解决方案好不好要从多个角度进行衡量,比如现有技术,长期短期,上手难度,开发效率,维护难度等角度,当出现一个新问题时,往往意味着就有解决方案有缺陷,这种缺陷可能很容易弥补,更可能很难,当很难解决时,往往要采用全新的解决方案,这时团队对新解决方案可能都不熟,也没有魄力去采用新解决方案,只能去老解决方案继续强行打补丁,直到最后没法维护,白白浪费了大量的人力和时间,这是非常典型的一种反模式。

桥接模式

  将抽象接口和实现分开,并独立派生,以支持抽象和实现的同时改变,并相互独立,可适用在需要跨平台跨不同设备的系统上。

生成器模式

  有点像是模板方法模式和工厂模式的结合版,使用多个步骤创建一个对象,但步骤没有固定顺序,可适用于流程复杂的规划系统中。

责任链模式

  可以认为是模板方法模式的进阶版,只是模板的步骤方法变成了一个个对象,并且支持步骤的增加和删除,以及更换顺序,一旦某个步骤成功执行,则整个链条终止,可适用于消除显式的 if-else,处理键盘或鼠标事件时,需要针对不同按键触发不同操作,这时可以采用该模式,缺点是链条很长时,要写很多类,导致执行啥很不直观。

蝇量模式

  这个模式算是一种优化内存占用的方案,通过牺牲类的独立性来减少内存,更彻底一点就是不创建类,直接用函数调用来处理就行。

解释器模式

  可用来实现简单语法规则语言的解释器或编译器,每个语法规则都由一个类进行解析,然后组合。

中介者模式

  可认为是状态模式和代理模式的结合版,不过各个状态可以是不同类,由中介者控制系统流转,集中控制逻辑,使被控制对象解耦,但可能会造成中介者本身非常复杂。

备忘录模式

  可用于系统(游戏)存档,存储系统中关键对象的运行状态,通常实现的方案一般是序列化/持久化,为了效率考虑,难的是有时需要增量存档。

原型模式

  js 的原型链应该是原型模式的典型,不仅实现了动态扩展实例,更实现了动态扩展对象,即继承。在高精地图编辑器中,由于需要做自动保存,所以在做序列化和反序列化的同时也简单实现了对象的 clone(),即从当前实例中创建一个完全一样的实例,可认为是 C++ 中的深拷贝。

访问者模式

  相当于加个中间层,从而以最小的代价修改现有系统(一般是增加一个方法),达到外部可以取得系统内部信息的目的。

后记

  曾看过这样一句话:抽象能力决定编程能力,Shaun 个人认为,所谓抽象即提炼事物的共同点,这也是设计模式中反复使用接口的原因,接口即一组具体类的共同点,接口中的函数和变量即为这些具体类共有的,虽然具体行为可以不一样,但行为本身总是存在的。而又有这样一句话:程序等于数据结构加算法,Shaun 的理解是,狭义上的程序确实是这样,一段代码解决一个问题,这是程序,多段代码解决一个问题,这也是程序,多段代码解决多个问题,这亦是程序,一个软件,一个系统,一个平台,都是程序,但显然这些程序不是简单的数据结构和算法就能概括的,其内部必然有一套合理的逻辑进行组织,这套逻辑可以称之为“设计模式”,但这个“设计模式”不仅仅是上面谈的这些模式概念。Shaun 认为好的数据结构和算法确实能使程序达到当前最优,但对于一个大型系统或平台来说,这种最优只能是一种局部最优,这对整个系统的全局最优来说可能是微不足道的,而“设计模式”要解决的是怎样使一个系统达到全局最优,怎么合理组织局部最优。面对现代的超大型系统或平台,传统意义上的设计模式也只能达到局部最优,全局最优基本很少有人能驾驭,都是针对特定的业务需要,不断的试错改进优化,逐渐趋于稳定,但这种稳定可能很难抽象,放进其它的业务中,又得花费大量的人力物力去修改。

  Shaun 个人对现代大型系统架构的理解就是分层分模块,功能太多分模块,模块太多就分层,一层不够分两层,两层不够分三层,三层不够继续分,根据数据流的处理分层,根据功能的不同分模块,模块内部依靠设计模式进行组织,设计模式调度的就是数据结构与算法。Shaun 目前的设计原则就是:每层一个独立的服务控制模块,每个模块一个对外服务功能(或事件或 socket ),同层的各模块之间尽量保持独立,不相互依赖,若各模块存在共同点,则将共同点抽出来,将其作为公共模块或独立为小层,层与层之间通过服务控制模块进行数据流的传输,除服务控制模块之外,模块之间尽量避免相互通信,即每个模块的对外服务功能一般只对本层服务控制模块提供服务,最好是只负责接收数据。如果系统实在太大,就只能保持纵向分层,横向保证各模块间数据流依次传输,并在特定模块节点上进行上下层的数据传输。

  数据结构与算法重不重要?当然重要,数据结构与算法不过关,面试都过不去 ( ╯□╰ ),工作都没有,还何谈什么设计模式,什么架构。设计模式重不重要?当然也重要,不会合理使用设计模式,写出一堆别人没法维护的垃圾代码(当然,这或许是好事 :p ),改个需求要半天,加个功能要半个月,效率太低,这样即使有好的数据结构与算法作用也不大。但是设计模式也不是万能的,针对不同的需求,同一种设计模式有不同的实现方式,所以书中的设计模式也仅供参考而已,与其说设计模式重要,还不如说书中那几个设计原则更重要些。同时一味的追求设计模式也不见得是件好事,设计模式可以参考,但不能生搬硬套,毕竟人是活的,需求也是活的,固定的模式也需要有所改变,总而言之,能以最小的代价解决问题完成需求的模式就是好模式。