架构师修炼 III - 掌握设计原则
- 单一职责原则 (SRP) - 就一个类而言,应该仅有一个引起它变化的原因
- 开-闭原则 (OCP)- 软件实体(类,模块,函数等)应该是可以扩展的,但是不可以修改
- 里氏替换原则 (LSP)- 子类必须能够替换它们的基类型
- 依赖倒置原则 (DIP)- 抽象不应该依赖于细节。细节应该依赖于抽象。
- 接口隔离原则 (ISP)- 不应该强迫客户依赖于它们不用的方法。接口属于客户,不属于它所在的类层次结构。
- 重用发布等阶原则 (REP)- 重用的粒度就是发布的粒度。
- 共同封闭原则 (CCP)- 包中的所有类对于同一类性质的变化应该是共同封闭的。一个变化若对一个包产生影响,则将对该包中的所有类产生影响,而对于其他的包不造成影响。
- 共同重用原则(CRP)- 一个包中所有类应该是共同重用的。如果重用了包中的一个类,那么就要重用包中的所有类。
- 无环依赖原则(ADP)- 在包的依赖关系图中不允许存在环。
- 稳定依赖原则 (SDP)- 朝着稳定的方向进行依赖。
- 稳定抽象原则(SAP)- 包的抽象程度应该和其稳定程度一致。
- 合成/聚合 复用原则(CARP)- 要尽量使用合成/聚合 ,尽量不要使用继承
- …..
意识 - 行为原则
坚持创新原则
首先谈谈模板式设计,我相信模板对于每一位开发人员和设计人员来说都是非常好的东西,因为它可以“快速”构建出“成熟”的代码、结构或UI。“拿来主义”在业界盛极不衰,对于架构师而言模板也有这种功效,在设计的过程中我们会经常遇到很多必须而不重要的“鸡肋”模块,没有它们系统会变得不完整,而它们的存在并不能为系统增加任何的“特色功能”,如:用户管理、角色管理或系统设置等。常见做法是,直接采用第三方模块或是从已有的其它项目中复用类似的模块,你是这样的吗 ?至少我是经常这样做的,因为我们的中国式项目通常是“验收驱动”,能通过验收、成熟可用就好。如果整个项目都只是由各类模板化的模块所构成,那么这个项目其实不需要架构师,因为不存在任何设计,所有的工作只是一种“融合”(Fusion)。可能这样说会有很多人会吐槽说这是一种“资源整合”能力,从“赶项目”的角度来说这无可口非,但从技术含量与本质上说确实不存在任何设计成分,这类拼装性或是“复制”性的项目只需要项目经理配备几个高级程序员就能完成了。
我曾在“表达思维与驾驭方法论”一文中提到与销售的沟通方法,其中就有一条:“至少说出系统的三个特色”,这个表述对销售具有市场意义以外 , 其实对于架构师是起到一个重要的提醒作用同时也是在建立一种设计原则:
架构设计中模板的拼装是不可避免的,重要的是必须加入属于你的特色设计
很难有人记得住整个软件的设计师,而却很容易记住某项极具特色功能的设计者。“特色” 是架构师在软件中所留下的一种重要的印记,也是在团队中配备架构师的意义所在。设计出完全可被模板化重用的设计是一功力,而当中小型企业内出现这样的设计之日就是架构师离开企业之时,或许这也是当下中国架构师之殇。保持特色保住饭碗,你懂的。
固守本质原则
唯一不变的就是变化本身 — Jerry Marktos《人月神话》
不变只是愿望,变化才是永恒 — Swift
- 用户的需求在变 - 他们需要增加更多的功能,要求更高质量的用户体验。
- 代码在变 - 不断的重构、测试,持续集成,让代码变得容读,稳定。
- 老板的想法在变 - 因为市场需求在变,需要为软件加入更多的特色满足市场。
- 架构在变 - 采用更新式的技术体系,获得更高效的生产力,更为稳定、安全的运行环境。
变化是表像,不稳定且可定制的;本质是核心,必须稳定,可扩展而不可修改;被固定的变化则可纳入核心。
“代码商人” 原则
永远不要投资未来,绝不设计没有回报的功能
不知道你是否拥有类似的经历:
- 在与客户的交流中,你的老板和经理在不断地向客户描绘“未来”图景,而在其中包含了很多几乎是客户没有需要的特色 ?
- 在你设计整体架构时,有一种冲动让你很想将某项由灵感触发对于系统“将来”的扩展需要很有用的功能或模块加入其中呢 ?
- 在你的代码里面有多少个方法或类是可以被删除,但你认为他们可以用于“以后”扩展而“手下留码”的呢 ?
- 你是否曾与经理或项目组长为了是否增加某个很有可能成为特色且被你实现出来的功能争论不休呢 ?
衡量标准的尺子掌握在架构师手中,如果设计中出现林林总总的这些“未来功能”您会如何来对待呢 ?是直接砍掉还是将其包装成为“特色”呢 ?此时架构师不单单是需要作为一名技术人员的角度考虑这个功能是否在将来可用,而更多的是需要考虑“成本”。每个功能甚至每行代码都需要付出“人-月”成本,一旦成本失控,软件就会化身“人狼”吞掉你的项目,而最后也只能后悔没有找到“银弹”。每个“未来”功能如何不能对现有项目带来即时性的回报,必须砍掉!即使这个功能有如何的美妙、高深或是在将来具有非凡的意义,还是将它放入“研究室”成为其它项目的技术储备吧。站在商人的立场:每一分钱的成本投入,都需要有足够的利益回报。
未来永远是美好的、丰满的同时也是浮云,而现实却往往是充满骨感。在架构或代码中透支未来极少数可获得回报,因为这些“投资”都具有不可预见性只是一些尝试,在产品中除了“市场策略”需要外的这类过分投资就得有陷入“维护未来”的心理觉悟。新的功能、未来的特色更应该收集起来,作为一下版本中可选项,通过详细的市场研究再考虑加入到产品中。当然,对于大型软件企业这个原则基本上是多余的,因为很多成熟的软件企业对需求的控制极其严格与规范。但如果你所在的企业还没有这样的管理意识,或具有超脱性的设计自由,那么这条原则是非常重要的,我们是用代码换钱的人,更少的代码换更多的钱才是我们最基本的生存需要。
重构优先原则
在没有代码的时候就应该重构,重构是写出优雅代码的方法而不单纯是修改代码的理论。
骆驼与帐篷的故事
在风沙弥漫的大沙漠,骆驼在四处寻找温暖的家。后来它终于找到一顶帐篷,可是,帐篷是别人的(也许你的处境跟它一样)!
最初,骆驼哀求说,主人,我的头都冻僵了,让我把头伸进来缓和暖和吧!主人可怜它,答应了。过了一阵子,骆驼又说,主人,我的肩膀都冻麻了,让我再进来一点吧!主人可怜它,又答应了。接着,骆驼不断的提出要求,想把整个身体都放进来。
主人有点犹豫,一方面,他害怕骆驼粗大的鼻孔;另一方面,外面的风沙那么大,他好像也需要这样一位伙伴,和他共同抵御风寒和危险。于是,他有些无奈地背转身去,给骆驼腾出更多的位子。等到骆驼完全精神并可以掌握帐篷的控制权的时候,它很不耐烦地说,主人,这顶帐篷是如此狭小以致连我转身都很困难,你就给我出去吧
这是一个很有寓意故事,如果将其比喻为开发过程也很有意思。对于“发臭”甚至“腐烂”代码我们会马上说“重构”,但重构是否能解决一切问题 ?你是否试过重构失败呢 ?重构在什么情况下是不可用的呢 ?如果这些问题在你心中是没有准确答案的话, 我建议可以重新去阅读一次《代码重构》一书。我认为重构不单纯是一种开发期与代码回顾期所使用的方法,而是一种设计与编码的思想指导!在设计期就应运用重构中的原则,那是否就可以“防腐”呢 ?答案显然是确定的。重构的往往不单纯是代码,而是开发人员、设计人员的思想,不执行甚至没有代码规范、随意命名、随意复制/粘贴、随意调用这些都必须被杜绝。我并不是指在设计重构就不需要重构,只是这样做的意义可以大量减少由于发现“臭”代码而去重构的成本 。
这也可以说是一个团队性的开发原则,在项目之始就得有统一的编码规范(直接使用官方规范),并将重构中的基本代码重构方法也纳入规范中,在开发过程中强制执行规范,对任何可能“腐化”的代码绝对的“零”容忍,痛苦只是一时,但好处却是长久的。
代码设计原则
开放-封闭原则
开放封闭原则又称 开-闭原则 Open-Closed Principle (OCP)
软件实体(如类,模块,函数等)应该是可以扩展的,但是不可以修改。
OCP是一个极为之出名的设计原则,简单的一句话就概括了可时该“开放”可时该“封闭”。这句话看起来很简单,一看似乎也会觉得自己领悟了什么,仔细咀嚼却觉得内中深意无限,到底应怎样理解这句话且将其应用于设计中呢 ? 我参考了不少国内的资料对此原则的总结,感觉就是雾里看花,没有办法找到最为贴切的解释。
我想分几个方面来诠释这个原则:
从类设计的角度
在类设计的应用中开-闭原则是一种对类的“多态”控制原则。开闭原则在基类或超类的设计中由为重要, 可以简单地理为对 成员对象的作用域 和 可“重载”成员 的控制指引原则。按 “里氏替换原则” 基类成员通常对于子类都应该可见,也就是说基类成员的作用域的最小作用范围应该是 protect , 如果出现大量的 private 成员时就应该考虑将private 成员们分离成其它的类,因为些成员都不适用于其子代而违反了“替换原则”,而更适用“合成/聚合原则“。
在运用 virtual 关键字时需甚重考虑,除了针对某些特殊的设计模式如 ”装饰“模式需要大量 virtual 的支持以外,在没有必要的情况下尽量避免。定义可重写的成员为子类预留了”改变行为“的余地,但同时也是为子类违反”替换原则“埋下了地雷。当子类中出现大量重写成员的时候就得考虑该子类是否还应该继承于此类族,因为子类在大量地违反”替换原则“时就意味着它满足了被分离出类族的条件。同理,在C#内一但需要在子类内部实现基类接口时也需要作出同样的考虑。
注:里氏替换原则是开-闭原则的一种重要补充,在类设计中一般是同时使用。
从模块设计的角度
模块设计的“开-闭原则”是侧重于对接口的控制。而这个在整个架构中也尤为重要,因为模块间的“开-闭”是直接影响系统级的耦合度。模块间的开闭需要“衡量成本”,并不是将所有的细节都开放使用模块具有极强的可扩展性就会有很高的重用度。首先要看了解几点:
开放性与维护成本成正比关系
接口的开放必须带有使用说明,这会增加团队开放的沟通成本同时一但接口发生改变将可能带来额外的“说明性重构”成本。在某些情况下我们很容易被“高扩展性”所引诱将很多“可能”被复用的功能通过扩展接口暴露出来。当这种高扩展性的诱惑主导了设计师的思维,随着模块的增多项目的变大、慢慢地设计师就会进入自己所创建的“注释恶梦”中。
开放性与耦合度成正比关系
模块的开放性接口是具有耦合传导效应的,控制模块间的耦合度就能在很大程度上控制了系统的耦合度。模块间的依赖性越小,耦合度越低才更易于变化尽量将耦合度集中在某一两个模块中(如:Facade 模式),而不是分散在各模块间。耦合度高的模块自然而然地成为“核心”模块,而其实的“外部”模块则需要保持自身的封闭性,这样的设计就很多容易适对未知的变化。
由这两个正比关系结合对实现成本的控制上我们做出两个最为简单可行的推论:
推论1:“正常情况下请保持封闭,没有必要的情况下绝不开放”。
推论2:“集中开放性,让模块间保持陌生”
开-闭原则从理论上来谈会有很多内容,但实现起来却很简单, 就以C#为例控制模块开放性的最简单办法就是控制作用域:internal , public。
3.从函数/方法设计的角度
我为认为OCP用到极至的情况就是应用于方法级,众所周知:参数越少的方法越好用。开-闭原则可以简单地理解为参数的多寡与返会值的控制
在此我更想谈谈“开-闭原则”在C#中的应用。首先在方法设计上,C# 给了设计人员与开发人员一个极大的空间,到了4.5我们甚至可以使用async 方法来简单控异步方法,那么先来总结一下C#的方法参数的种类。
- 固定参数:public void methodName(string a, out b, ref c);
- 动态参数:public void methodName(string a, string b=“defautlString”)
- 可变参数:public void methodName(params string[] a);
- 表达式参数(方法注入):public void methodName(Func<string> func, Action act);
- 泛型参数:public void methodName<T>( T a) where a : class;
在C#中我们则需要从“注入”这方面来思考和充分发挥语言自身的特性,以达到简化代码,增强易读性的效果。 这里谈的“注入”主要指两个方面,一 是 “代码注入”,二是 “类型注入”。
“代码注入”就是向方法传入“代理”类就是在方法内部开辟出某一“可扩展”的部分以执行未知、可变的功能 ,那么我们就可以对相对“封闭”的方法增强其“开放”性。
通过泛型方法的使用,我们可以在对类型“开放”的情况下对类型的通用操作相对地“封闭”起来,这样可以在很大程度上利用泛型复合取代类继承,降低类的多态耦合度。
里氏替换原则(LSP)
凡是基类适用的地方,子类一定适用
- 凡是出现大量子类不适用的成员,子类就应该脱离继承关系
- 基类中凡是出现大量虚成员,该类就失去成为基类的条件
依赖倒转原则(DIP)
要依赖抽象,不要依赖具体。
接口隔离原则 (ISP)
使用多个专门的接口比适用单一的接口要好
合成/聚合 复用原则(CARP)
要尽量使用合成/聚合 ,尽量不要使用继承
只有当以下Coad条件全部被满足时,才应当使用继承关系:
- 子类是超类的一个特殊种类,而不是超类的一个角色。区分“Has-A”和“Is-A”。只有“Is-A”关系才符合继承关系,“Has-A”关系应当用聚合来描述。
- “Is-A”代表一个类是另外一个类的一种;
- “Has-A”代表一个类是另外一个类的一个角色,而不是另外一个类的特殊种类。
- 永远不会出现需要将子类换成另外一个类的子类的情况。如果不能肯定将来是否会变成另外一个子类的话,就不要使用继承。
- 子类具有扩展超类的责任,而不是具有置换掉(override)或注销掉(Nullify)超类的责任。如果一个子类需要大量的置换掉超类的行为,那么这个类就不应该是这个超类的子类。 (注:在C# 中 含有 new 的方法、属性和内部实现单一基类接口就相当于Nullify)
- 只有在分类学角度上有意义时,才可以使用继承。不要从工具类继承。
合成 ( Composite ) - 值聚合 (Aggregation by value)
public class Keyboard{} public class Mouse{} public class Monitor{} public class Computer { private Keyboard keyboard; private Mouse mouse; private Monitor monitor; public Computer() { this.keyboard=new Keyboard(); this.mouse=new Mouse(); this.monitor=new Monitor(); } }
由这个例子可见,所谓的“值(Value)”通过构造函数合成为 “Computer”的内部成员,有如将各个功能单一的部件装配成为一个功能强大的产品。所有的依赖都被“关在”构造函数内,如果将依赖外置就可以运用工厂(Factory Pattern)和合成模式(Composite Pattern)进行演变。
public class Item{}; public class Keyboard:Item{} public class Mouse:Item {} public class Monitor:Item{} public ComputerFactory { public Item Keyboard() { return new Keyboard(); } public Item Monitor() { return new Monitor(); } public Item Mouse() { return new Mouse(); } } public class Computer { public List<Item> Items{get;set;} public Computer(ComputerFactory factory) { this.Items.Add(factory.Keyboard()); this.Items.Add(factory.Mouse()); this.Items.Add(factory.Monitor()); } }
聚合 ( Aggregation ) - 引用聚合(Aggregation by reference)
public class Computer { public Mouse Mouse{ get;set; } public Monitor Monitor{ get; set; } public Keyboard Keyboard {get;set;} } public class Host { public static void Main() { var computer=new Computer() { Mouse=new Mouse(), Monitor=new Monitor(), KeyBoard=new KeyBoard() }; } }
聚合类中Hold住的是实例化类的引用,不单是值。聚合类的意义在于将引用依赖集中一处,从某意义上说这个Computer类也是一个Facade 模式。这种方式常见于大规模对象模型的入口类,如Office的 Application 对象,这样的设计可以便于开发者“寻找”类的引用。同时也可以用作 上下文的设计 如:.net中的System.Web.HttpContext。值得注意的是:聚合类是需要慎用的,对于类本身是收敛类引用耦合,同时聚合类也具有耦合传导的特性,由其是构造函数。就拿EF说事吧,我们用EF访问数据库都需要这样的代码:
public void OrderManager { public List<Order> GetOrder() { using (var ctx=new DbContext( ) { //… } } }
当这个new 在代码各处出现时就坏菜了!构造引用的耦合度每调用一次就增加一分,当遍布整个访问层甚至系统时 DBContext就是一个不可变更的超巨型耦合肿瘤!要解决这个问题可以采用单件模式自构造或是用IoC、DI将构造移到一个集中的地方,防止构造耦合散播。