天下武林,林林总总。名门正宗如少林武当,诚然名扬天下,而武林之大,但凡修得暗镖神剑者,亦可独步江湖。所以门派无尊贵,只有适合不适合。设计的本质:即使最懂设计的人,也要心胸坦荡,认识到自我局限性,不可以名门正宗自居,须认识到获得真理是一个学无止境、永远追求的过程。
十八般武艺样样精通,仅出现在武侠传说中, 现实中即使是全栈工程师也不敢说十八般武艺样样精通, 对于普通的IT从业者来说, 如何从码农变为一个合格的工程师来说, 软件设计是必经之路。 当然要软件设计一定要遵循设计的六大原则。在工作的初期,我们可能会经常有这样的感受,自己的代码接口设计混乱、代码耦合较为严重、一个类的代码过多等等,当自己回头再看这些代码时可能会感慨,怎么能写成这个鸟样。再看那些知名的开源库,它们大多有整洁的代码、清晰简单的接口、职责单一的类,这个时候我们会通常会捶胸顿足而感慨:什么时候老夫才能写出这样的代码!
在做开发的这些年中,我渐渐的感觉到,其实国内的一些初、中级工程师写的东西不规范或者说不够清晰的原因是缺乏一些指导规则。他们手中挥舞着面向对象的大旗,写出来的东西却充斥着面向过程的气味。也许是他们不知道有这些规则,也许是他们知道但是不能很好的运用到实际的代码中,亦或是他们没有在实战项目中体会到这些原则能够带来的优点,以至于他们对这些原则并没有足够的重视。
设计的六大原则
在此之前,有一点需要大家知道,熟悉这些原则并不是说你写出的程序就一定灵活、清晰,只是为你优秀的代码之路铺上了一层栅栏,在这些原则的指导下,你才能避免陷入一些常见的代码泥沼,从而让你写出优秀的东西。
单一原则(Single Responsibility Principle)
单一职责原则的英文名称是 Single Responsibility Principle,简称是 SPR,简单地说就是一个类只做一件事,这个设计原则备受争议却又极其重要。只要你想和别人争执、怄气或者是吵架,这个原则是屡试不爽的。因为单一职责的划分界限并不是如马路上的行车道那么清晰,很多时候都是需要个人经验来界定。当然,最大的问题就是对职责的定义,什么是类的职责,以及怎么划分类的职责。这跟我们社会分工一样, 一些人干这个, 另一些人干那个,只有大家都这样做了, 我们的社会才更和谐。
试想一下,如果你遵守了这个原则,那么你的类就会划分的很细,每个类都有比较单一的职责,这不就是高内聚、低耦合么!当然,如何界定类的职责就需要你的个人经验了。
当然,软件设计真正要做的许多内容,就是发现职责并把那些职责相互分离,就是抽象的能力。其实要去判断是否应该分离出类来,也不难,那就是如果你能想到多于一个的动机去改变一个类,那么这个类就具有多于一个的职责。
我们来定义一个网络类,代码如下:
我们可以看到,IHttp 只有一个 sendRequest 函数,它的职责就是执行网络请求并且返回一个 Response,它的职责很单一,这样在需要修改执行网络请求的相关代码时,只需要修改实现 Http 接口的类,而不会影响其他类的代码。如果某个类的职责包含有执行网络请求、解析网络请求、进行 gzip 压缩、封装请求参数等,那么在你修改某处代码时就必须谨慎,以免修改的代码影响了其它的功能。当你修改的代码能够基本上不影响其他功能。这就一定程度上保证了代码的可维护性。注意,单一职责原则并不是一个类只能有一个函数,而是说这个类中的函数所做的工作是高度相关的,也就是高内聚。
基本判断原则, 就是一个特定的类,当确认以后, 它的责任就确定了,不能增加它行为以外的功能。 例如一般我们定义 API 接口的时候,如果这个接口干了很多事情, 就是一些隐含的事情,我们就认为它设计没有遵循单一原则。
优点
类的复杂性降低,实现什么职责都有清晰明确的定义。
可读性提高,复杂性降低,那当然可读性提高了。
可维护性提高,可读性提高了,那当然更容易维护了。
变更引起的风险降低,变更是必不可少的,如果接口的单一职责做得好,一个接口修改只对应的实现类有影响,对其他的接口无影响,这对系统的扩展性、维护性都有非常大的帮助。
里氏替换原则(Liskov Substitution Principle)
里氏替换原则本质就是继承和多态的应用。继承作为面向对象三大特性之一,在给程序设计带来巨大便利的同时,也带来了弊端。比如使用继承会给程序带来侵入性,程序的可移植性降低,增加了对象间的耦合性,如果一个类被其他的类所继承,则当这个类需要修改时,必须考虑到所有的子类,并且父类修改后,所有涉及到子类的功能都有可能会产生故障。
里氏替换原则通俗的来讲就是:子类可以扩展父类的功能,但不能改变父类原有的功能。它包含以下4层含义:
子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法。
子类中可以增加自己特有的方法。
当子类的方法重载父类的方法时,方法的前置条件(即方法的形参)要比父类方法的输入参数更宽松。
当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格。
还拿上面的 Http 类举例, 我们用 Restful API 方式进行 Http 网络请求,代码如下:
上述代码中, RestfulClient 类中依赖的是 IHttp 接口,而通过 createHttp 函数返回的是 IHttp 的实现类 HttpClientImpl 或 HttpUrlConnImpl。这就是所谓的里氏替换原则,任何父类、父接口出现的地方子类都可以出现,这不就保证了可扩展性吗!如果我们现在将网络请求库改成 OKHttp, 是不是将依赖传进去就行了。
优点:
代码共享,减少创建类的工作量,每个子类都拥有父类的方法和属性。
提高代码的重用性。
提高代码的可扩展性,实现父类的方法就可以“为所欲为”了,很多开源框架的扩展接口都是通过继承父类来完成的。
提高产品或项目的开放性。
缺点:
继承是侵入性的。只要继承,就必须拥有父类所有的属性和方法。
降低了代码的灵活性。子类必须父类的属性和方法,让子类自由的世界中多了些约束。
增强了耦合性。当父类的常亮、变量和方法被修改时,必须要考虑子类的修改,而且在缺乏规范的环境下,这种修改可能带来非常糟糕的后果—-大量的代码需要重构。
依赖倒置原则(Dependence Inversion Principle)
电脑在以前维修的话是根本不可能的事,可是现在却特别容易,比如说内存坏了,买个内存条,硬盘坏了,买个硬盘换上。为啥这么方便?从修电脑里面就有面相对象的几大设计原则,比如单一职责原则,内存坏了,不应该成为更换CPU的理由,它们各自的职责是明确的。再比如开放-封闭原则,内存不够只要插槽足够就可以添加。还有依赖倒转原则,原话解释是抽象不应该依赖细节,细节应该依赖于抽象,说白了,就是要针对接口编程,不要对实现编程,无论主板,CPU,内存,硬盘都是针对接口设计的,如果是针对实现来设计,内存就要对应的某个品牌的主板,那就会出现换内存需要把主板也换了的尴尬。
为什么叫反转呢?
面对过程开发时,为了使得常用代码可以复用,一般都会把这些常用代码写成许许多多函数的程序库,这样我们做新项目时,去调用这些底层的函数就可以了。比如我们做的项目大多要访问数据库,所以我们就把访问数据库的代码写成了函数,每次做新项目时就去调用,这就叫做高层模块依赖底层模块。
但是要做新项目是 业务逻辑的高层模块都是一样的,客户却希望使用不同的数据库或存储信息方式,这时出现麻烦了。我们希望能再次利用这些高层模块,但高层模块都是与底层的访问数据库绑定在一起的,没办法复用这些高层模块,这就非常糟糕了。就像刚才说的,PC里如果CPU,内存,硬盘都是需要依赖具体的主板,主板一坏,所有的部件都没法用了,显然不合理,而如果不管高层模块还是底层模块,它们都依赖于抽象,具体一点就是接口或者抽象类,只要接口是稳定的,那么任何一个的更改都不用担心其它受影响,这就使得无论高层模块还是底层模块都可以很容易被复用,这才是最好的办法。
在前面我们的例子中, RefusClient 实现类依赖于 IHttp 接口(抽象),而不依赖于 HttpClientImpl 与 HttpUrlConnImpl 实现类(细节),这就是依赖倒置原则的体现。
传递依赖关系有三种方式,以上的例子中使用的方法是接口传递,另外还有两种传递方式:构造方法传递和 setter 方法传递,相信用过 Dagger、Spring 框架的,对依赖的传递方式一定不会陌生。 在实际编程中,我们一般需要做到如下:
低层模块尽量都要有抽象类或接口,或者两者都有。
变量的声明类型尽量是抽象类或接口。
使用继承时遵循里氏替换原则。 依赖倒置原则的核心就是要我们面向接口编程。
优点:可扩展性好
耦合度低
接口隔离原则(Interface Segregation Principle)
接口隔离原则(英语:interface-segregation principles, 缩写:ISP)指明没有客户(client)应该被迫依赖于它不使用方法。接口隔离原则(ISP)拆分非常庞大臃肿的接口成为更小的和更具体的接口,这样客户将会只需要知道他们感兴趣的方法。这种缩小的接口也被称为角色接口(role interfaces)。接口隔离原则(ISP)的目的是系统解开耦合,从而容易重构,更改和重新部署。接口隔离原则是在SOLID (面向对象设计)中五个面向对象设计(OOD)的原则之一,类似于在GRASP (面向对象设计)中的高内聚性。
下面是看到网上的举的例子:
可能描述起来不是很好理解,我们还是以示例来加强理解吧。 我们知道,在网络框架中,网络队列中是会对请求进行排序的。内部使用 PriorityBlockingQueue 来维护网络请求队列,PriorityBlockingQueue 需要调用 Request 类的排序方法就可以了,其他的接口他根本不需要,即 PriorityBlockingQueue 只需要 compareTo 这个接口,而这个 compareTo 方法就是我们所说的最小接口方法,而是 Java 中的 Comparable 接口,但我们这里是指为了学习,至于哪里定义的无关紧要。
在元素排序时,PriorityBlockingQueue 只需要知道元素是个 Comparable 对象即可,不需要知道这个对象是不是 Request 类以及这个类的其他接口。它只需要排序,因此,只要知道它是实现了 Comparable 对象即可,Comparable 就是它的最小接口,也是通过 Comparable 隔离了 PriorityBlockingQueue 类对 Request 类的其他方法的可见性。
很多人会觉的接口隔离原则跟之前的单一职责原则很相似,其实不然。其一,单一职责原则原注重的是职责;而接口隔离原则注重对接口依赖的隔离。其二,单一职责原则主要是约束类,其次才是接口和方法,它针对的是程序中的实现和细节;而接口隔离原则主要约束接口接口,主要针对抽象,针对程序整体框架的构。 采用接口隔离原则对接口进行约束时,要注意以下几点:
接口尽量小,但是要有限度。对接口进行细化可以提高程序设计灵活性是不挣的事实,但是如果过小,则会造成接口数量过多,使设计复杂化。所以一定要适度。
为依赖接口的类定制服务,只暴露给调用的类它需要的方法,它不需要的方法则隐藏起来。只有专注地为一个模块提供定制服务,才能建立最小的依赖关系。
提高内聚,减少对外交互。使接口用最少的方法去完成最多的事情。 运用接口隔离原则,一定要适度,接口设计的过大或过小都不好。设计接口的时候,只有多花些时间去思考和筹划,才能准确地实践这一原则。
迪米特原则( Law of Demeter)
迪米特原则(Law of Demeter,缩写LoD)等同于“最少知识原则(Principle of Least Knowledge)”,是一种软件开发的设计指导原则,特别是面向对象的程序设计。迪米特原则是松耦合的一种具体案例。该原则是美国东北大学在1987年末在发明的,可以简单地以下面任一中方式总结。
每个单元对于其他的单元只能拥有有限的知识:只是与当前单元紧密联系的单元; 每个单元只能和它的朋友交谈:不能和陌生单元交谈; 只和自己直接的朋友交谈。 这个原理的名称来源于希腊神话中的农业女神,孤独的得墨忒耳。
很多面向对象程序设计语言用”.”表示对象的域的解析算符,因此迪米特原则可以简单地陈述为“只使用一个.算符”。因此,a.b.Method()违反了此定律,而a.Method()不违反此定律。一个简单例子是,人可以命令一条狗行走(walk),但是不应该直接指挥狗的腿行走,应该由狗去指挥控制它的腿如何行走。
通俗地讲,一个类应该对自己需要耦合或者调用的类知道得最少,这有点类似于接口隔离原则中的最小接口的概念。类的内部如何实现、如何复杂都与调用者或者依赖者没有关系,调用者或者依赖者只需要知道它需要它需要的方法即可,其他的一概不关心。类与类之间的关系越密切,耦合度越大,当一个类发生改变时,对另一个类的影响也越大。
优点:
开闭原则(Open-Close Principle)
开闭原则是 Java 世界里最基础的设计原则,它指导我们如何建立一个稳定的、灵活的系统。开闭原则的定义是:一个软件实体类,模块和函数应该对扩展开放,对修改关闭。在软件的生命周期内,因为变化、升级和维护等原因,需要对软件原有的代码进行修改时,可能会给旧代码引入错误。因此,当软件需要变化时,我们应该尽量通过扩展的方式来实现变化,而不是通过修改已有的代码来实现。
在软件开发过程中,永远不变的就是变化。开闭原则是使我们的软件系统拥抱变化的核心原则之一。对扩展开放,对修改关闭这样的高层次概括,即在需要对软件进行升级、变化时应该通过扩展的形式来实现,而非修改原有代码。当然这只是一种比较理想的状态,是通过扩展还是通过修改旧代码需要依据代码自身来定。
还是上面的例子, HttpUrlConnImpl 和 HttpClientImpl 实现了 IHttp 接口, 当我们实现 OKHttp 的时候就可以实现 HttpOKImpl, 这样通过扩展的形式来应对软件的变化或者说用户需求的多样性,既避免了破坏原有系统,又保证了软件系统的可维护性。依赖于抽象,而不依赖于具体,使得对扩展开放,对修改关闭。开闭原则与依赖倒置原则,里氏替换原则一样,实际上都遵循一句话:面向接口编程。
优点:
避免掉进过度设计的怪圈
当你掌握一些设计模式或者手法之后,比较容易出现的问题就是过度设计。有的人甚至在一个应用中一定要将 23 种常见的设计模式运用上,这就本末倒置了。设计模式的四大要素中就明确指出,模式的运用应该根据软件系统所面临的问题来决定是否需要使用现有的设计。也就是说,再出现问题或者你预计会出现那样的问题时,才推荐使用特定的设计模式,而不是将各种设计模式套进你的软件中。
不管在设计、实现、测试之间有多少时间都应该避免过度设计,它会打破你的反馈回路,使你的设计得不到反馈,从而慢慢陷入危险中。所以你只需要保持简单的设计,这样就有时间来测试该设计是否真的可行,然后作出最后的决策。
当设计一款软件时,从整体高度上设定一种架构模式,确定应用的整体架构,然后再分析一些重要的设计思路,并且保证他们的简单性、清晰性,如果有时间可以使用 Java 代码模拟一个简单的原型,确保设计是可行的,最后就可以付诸行动了。切实不要过度的追求设计,适当就好,当我们发现或者预计到将要出现问题时,在判断是否需要运用设计模式。