设计模式这个东西,书里介绍的很多,但是多数都是一些很抽象的例子,看完马上就忘,实际写起来往往都忘记那些东西了。反正对于我来说,我在写代码的时候,除了工厂,单例,建造者,观察者,这四种用的还比较熟练以外,其他几乎都是缺失的,甚至在很多时候都做不到针对接口编程,往往都是针对实现编程。 这次有意识的从实际业务上的代码来入手,系统的梳理一下相关的知识。
理解单一职责原则(SRP)
这条规则比较简单,就是一个类只完成一个职责或者功能(SRP)。但是实际上真正在写业务代码的时候,往往这个职责单一的判定很模糊。简单举个例子:
public class UserInfo { private long mUserId; private String mUserName; private String mEmail; private String mPhoneNumber; private String mAvatarUrl; private String mCity;//用户所属城市 private String mProvicne;//省份 private String mRegion;//地区}你看这个类, 上半部分都是用户id,电话,头像等, 下半部分就是用户所属的地址信息了。(千万别告诉我你在项目中没有写过类似的代码)。 对于这段代码来说,我们怎么分析他是不是 遵循了 单一职责原则呢?
正方观点: 用户地址信息也属于用户信息啊,这个类没问题,符合单一职责原则。 反方观点:你这个类总共就8个属性,你地址信息3个属性都快占到一半了,显然要单独抽一个UserAddress来,不然明显不符合单一职责原则。
更加好一点的处理方式是看这个类所属的业务模型是什么?举个例子,如果这个类所属的业务模型是一个im应用,社交属性极强的产品,那这个地址信息就不需要拆出来。因为在社交产品里面,地址信息就是一个纯展示的模块,和
头像,电话号码,userID之类的没有区别。 但是如果这个类所属的业务模型是一个电商引用,地址信息是用来交易用的,那显然就要把他拆分出来作为一个单独的类。因为你这个地址信息还有其他比如交易系统要用到。
此外,在有的时候业务迭代到一定程度以后,这个类是否符合单一职责原则也会发生变化,例如这个类所属的社交im,经过一段时间的发展,用户变多了,我们现在要做Oauth 登录,也就是说其他应用app,其他系统也可以用我们的账
号去登录了,那显然这个时候 地址信息就要单独拆分出来做一个类了,因为这个时候UserInfo 还要对外提供,显然对于外部app来说,他不需要这些地址信息。
所以,我们在设计一个类的时候,可以先从粗粒度的类开始设计,等到业务发展到一定规模,我们发现这个粗粒度的类方法和属性太多,且经常修改的时候,我们就可以对这个类进行重构了,将这个类拆分成粒度更细的类,这就是所谓的持续重构。
这里有几个判断原则,可以让你来判断这个类是否到了需要重构的时候:
- 类中的方法和属性太多了,感觉可读性很差,这个时候 就需要对这个类进行重构了。
- 类中的私有方法太多,这个时候就需要重构以后,将这些私有方法抽到另外一个类中,作为public方法,提高方法的可复用性
- 类中大量的方法都在操作某几个属性,需要将这个类重构,将这几个频繁操作的属性 抽出来作为一个单独的类。
理解开闭原则(OCP)
“对扩展开放,对修改关闭”。这是设计一个类或者一个系统的最重要的原则,没有之一。其实大部分的设计模式都是围绕这个原则展开。甚至很多系统的诞生也是为了解决这个问题。比如现在后端开发常用的消息队列,其中最大的一个好处就是使用消息队列以后,就可以很好的支撑OCP。我们只用发一个消息给对方,让对方自己处理即可。如果是我们直接调用对方的api,那么一旦对方的api发生变更,我们这里也要进行修改。
简单来说就是,一个好的系统应该能做到:当添加一个新功能的时候,在已有代码的基础上扩展代码(新增类,模块,方法等),而不是修改已有代码(修改类,模块,方法等)。这样做的好处就是bug会减少许多,我们都知道新增的代码是很难影响到老系统的,只有修改了老代码才会影响到老系统。而且,OCP的原则能让后来者轻松维护一个系统,而不需要花大量时间去看老代码。
简单举个例子,我们经常会对客户端的网络情况进行监控,然后上报异常:
public class Alert { public void check(String url, int httpCode, long responseTime) { //针对404的情况 告警 if (httpCode == 404) { //notify ----- 404 } //针对响应时间超过300ms的情况进行告警 if (responseTime > 300) { //notify responseTime>300 } } }比如这是我们的第一个版本,监控了404 和 响应时间的异常上报,过了一阵子,有了新需求,我们需要对这个接口返回内容的体积做监控,例如超出1mb流量的response 也要做监控。那么很多人就会这么改:
public class Alert { public void check(String url, int httpCode, long responseTime, long trafficSize) { //针对404的情况 告警 if (httpCode == 404) { //notify ----- 404 } //针对响应时间超过300ms的情况进行告警 if (responseTime > 300) { //notify responseTime>300 } if (trafficSize > 1024 * 1024) { //notify 此请求的返回超过1mb的体积 } } }你看,这次为了完成这个新增的需求,我们要修改原来的方法,增加了一个参数,同时对方法体内部也进行了逻辑上的变更。这样的修改 涉及到的范围就非常广了,首先我们增加了方法的参数,所有调用到这个方法的地方都要修改,
其次我们还对这个check方法内部的逻辑 进行了修改,这个时候就要告知测试回归验证一下 以前的逻辑有没有被影响。 一来二去的,麻烦不说,改的多了,自己心里还没底。
所以针对上面的痛点,也为了更加符合OCP原则,我们来适当修改一下这个功能的实现:
/** * 首先我们把这些需要统计的信息 单独做成一个类,这样以后新增统计信息的时候 直接来改这个类即可 */public class ApiErrorInfo { private String url; private int httpCode; private long responseTime; private long trafficSize;}然后我们把处理的流程 抽象出一个抽象类和抽象方法
/** * 定义一个抽象类,里面有个抽象方法 */public abstract class AlertHandler { //这个抽象方法讲白了就是实现 对应的 监控策略的 abstract void check(ApiErrorInfo apiErrorInfo);}最后看一下新版本的实现:
public class Alert { private List<AlertHandler> alertHandlerList = new ArrayList(); //新版本的改完以后,我们再完成前面的需求,只需要实现一个AlertHandler的子类,ApiErrorInfo新增一个字段, // 然后调用一下addAlertHandler方法即可 public void addAlertHandler(AlertHandler alertHandler) { alertHandlerList.add(alertHandler); } public void check(ApiErrorInfo apiErrorInfo) { for (AlertHandler alertHandler : alertHandlerList) { alertHandler.check(apiErrorInfo); } }} //404错误class HttpCode404Error extends AlertHandler { @Override void check(ApiErrorInfo apiErrorInfo) { if (apiErrorInfo.getHttpCode() == 404) { //上报这个404信息 //写入日志 等 } }} //超时错误class OverResponseTime extends AlertHandler { @Override void check(ApiErrorInfo apiErrorInfo) { if (apiErrorInfo.getResponseTime() > 300) { //上报这个超时的接口 } }}你看,经过我们重构后的监控代码,在一定程度上就非常契合OCP的原则了,我们今后在维护这个系统的时候,不管你怎么新增新需求,我们的修改都比较有限,而且不会涉及到之前的逻辑,自己写的轻松,新维护的哥们轻松,测试也轻松。
提高代码的可扩展性最重要的一点就是 针对接口编程而不是针对实现编程。有兴趣的同学可以在这里深挖(装饰、策略、模板、职责链、状态)模式。
理解里式替换原则(LSP)
这个整体上比较简单,翻译过来就是 子类对象能够替换程序中父类对象出现的任何地方,并且保证原来程序的逻辑行为不变及正确性不被破坏。 多数人可能未必能分清多态和LSP的区别,从而在实际应用中出现了偏差
虽然这种偏差多数是无心之举,但这往往是埋坑的地方,多数时候这种偏差都会带来很多补丁性质的代码。
讲白了,LSP的描述中,只有前半句是代表多态的意思。而后半句跟多态实际上没啥关系。 我举个例子,还是上面的alert Handler,这次我们添加一个对tcp超时次数过多的告警
class SocketTimeOutError extends AlertHandler { @Override void check(ApiErrorInfo apiErrorInfo) { //超过三次 就埋点上报 if (apiErrorInfo.getTimeOutCount() > 3) { //埋点上报 } if (apiErrorInfo.getTimeOutCount() > 5) { // step 1: 埋点上报 // step 2: 抛异常 throw new RuntimeException("timeout count over 5 times"); } }}继承自alertHandler这个类对吧,肯定符合多态的特征了,那么这个符合LSP么?不符合,因为这个check方法里面 抛出了异常,那你就改变了原来的程序的逻辑了,甚至可能干扰到整个系统的实现。
所以LSP的精髓 实际上是在后半句,就是你的子类在设计的时候,要符合父类的行为约定和描述,子类只能改变函数的内部实现逻辑,但你不能改变之前父类函数的一些通用约定,比如对输入输出格式的约定,异常的捕获,等等。
下面总结几个容易违反LSP的情况,今后在实现子类的时候,有意识的往这边靠拢即可。
- 子类违背父类声明要实现的功能。
举例:比如父类某个排序方法是从小到大来排序,你子类的方法竟然写成了从大到小来排序。 - 子类违背父类对输入输出和异常的约定
举例:异常的例子我们上面已经说过了,这里不再重复。输入输出的最常见的例子就是比如toString这个方法,大家一般都是默认用ide生成的,用来打印这个类的属性信息,结果有些人非要在这个toString里面打印json的信息。
这也是不合格的实现。 - 子类违背父类注释中所罗列的任何特殊说明
举例 : 比如说一个支付功能,父类注释上说好了只有金额小于用户余额的才能支付成功。结果子类在实现的时候说,余额不够,自动跳转到 花呗支付 也可以支付成功,那么现在这个子类的实现就是不合格的。
理解接口隔离原则(ISP)
接口的调用者或者使用者不应该强迫依赖它不需要的接口,简称ISP. 这个地方我觉得android工程师 ,尤其是很多喜欢用MVP的android工程师都会有这个问题。而且比较严重。举个例子,某个activity的某个业务功能用了MVP实现以后,抽象出了一堆方法,然后这些方法都只写在一个接口里面。问题是如果这个业务功能要复用的话,就是一场灾难,因为你这么多方法都写在一个接口里面,对于其他业务方来说,他很可能不需要这么多方法,只需要其中某几个,于是那些业务activity 就不得不实现一堆空的接口方法。。。
下面我们给出一个例子,来体会一下,这个ISP原则,比如客户端要设计一个支付功能。支付渠道当然有很多了,所以我们设计一个接口。
public interface Pay { void paySuccess();//支付成功 几乎所有支付方式都有支付成功和失败 void payFailed();//支付失败 void overPay();//支付超出限额 一般来说只有直接调用银行的支付系统 才会报余额不足的情况 void creditCheck();//检查信用 一般来说也只有alipay才有信用度这个设定 void loanPay();//贷款支付 //一般来说 也只有银行或者阿里这样的有一定金融小额贷款牌照的才有小贷支付这种功能}看上去好像都是支付的方法,但是 细看 却有很多冗余。比如我们实现一下微信支付
class WechatPay implements Pay{ @Override public void paySuccess() { //do somthing } @Override public void payFailed() { //do somthing } @Override public void overPay() { //do nothing } @Override public void creditCheck() { //do nothing } @Override public void loanPay() { // do nothing }}你看,对于微信支付来说,他就有overPay creditCheck loadPay 这三个方法 是空的,因为对于微信支付来说 他不存在这三种状态。 这就违反了ISP原则 ,我们适当修改以后。
public interface Pay { void paySuccess();//支付成功 几乎所有支付方式都有支付成功和失败 void payFailed();//支付失败}public interface LoanPay { void loanPay();//贷款支付 //一般来说 也只有银行或者阿里这样的有一定金融小额贷款牌照的才有小贷支付这种功能}public interface CreditCheck { void creditCheck();//检查信用 一般来说也只有alipay才有信用度这个设定}这样我们所有的支付实体类,只要按需选择我们应该支持的接口就好。
到这里实际上有人会觉得这个ISP和SRP怎么有点一样,SRP针对的是模块、类、接口的设计。ISP相对于单一职责原则,一方面更侧重于接口的设计,另一方面它的思考角度也是不同的。ISP提供了一种判断接口的职责是否单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。
理解依赖反转原则(DIP)
先看下依赖注入:不通过 new() 的方式在类内部创建依赖类对象,而是将依赖的类对象在外部创建好之后,通过构造函数、函数参数等方式传递(或注入)给类使用。 这句话粗看还是挺难理解的,换个角度拆解一下:
- 高层模块不应该直接依赖于底层模块的具体实现,而应该依赖于底层的抽象。换言之,模块间的依赖是通过抽象发生,实现类之间不发生直接的依赖关系,其依赖关系是通过接口或抽象类产生的。
- 接口和抽象类不应该依赖于实现类,而实现类依赖接口或抽象类。
这里其实还是在强调一个针对接口编程,而不是针对实现编程。
/** * 实现一个监控客户端 所有请求的功能,针对不一样的域名 有不一样的操作 */public class NetListenerTest { public static void main(String[] args) { DomainService d=new DomainService(); d.register("shop"); }} //域名处理入口class DomainService { ShopDomain shopDomain; UserInfoDomain userInfoDomain; public void register(String domain) { if (domain.equals("shop")) { shopDomain.register(); } else if (domain.equals("user")) { userInfoDomain.register(); } } } //商城域名class ShopDomain { public void register() { } public void path() { }}//账号域名class UserInfoDomain { public void register() { } public void path() { }}肉眼可见的,如果要监控的域名多了以后,这里的if else 很快就会爆炸(别说你项目里没有类似的写法)。而且你看 这个程序的入口 service里面 还是依赖的是类的实例,是直接new出来的对象。明显违反了我们的DIP原则。
要改起来也很简单,加个接口就行。这样service就不会依赖实现了,只会依赖接口。
/** * 实现一个监控客户端 所有请求的功能,针对不一样的域名 有不一样的操作 */public class NetListenerTest { public static void main(String[] args) { DomainService d = new DomainService(); d.register(new ShopDomain()); }} interface DomainII { void register(); void path();} //域名处理入口class DomainService { public void register(DomainII domainII) { domainII.register(); } } //商城域名class ShopDomain implements DomainII { public void register() { } public void path() { }} //账号域名class UserInfoDomain implements DomainII { public void register() { } public void path() { }}这里针对上述的改进,我们来总结一下优点:上述解决方案中,我们定义了一个DomainII 接口,用于上层服务的依赖,也就是说,上层服务(这里指DomainService )仅仅依赖DomainII 接口,对于具体的实现类我们是不管的,只要接口的行为不发生变化,增加新的设备类型后,上层服务不用做任何的修改。这样设计降低了层与层之间的耦合,能很好地适应需求的变化,大大提高了代码的可维护性。
DIP这个规则,理解起来不好理解,但是用起来其实就是“针对接口编程而非实现编程”就好。大可不必深入到细节。我个人感觉是没必要的。
对于Android工程师来说,DIP的深层应用 其实就是很多著名的依赖注入框架,比如Dagger2,ButterKnife等。有用过的同学仔细体会一下,用这些框架前后的区别相信就能更深一步理解好DIP了.
参考资料:
time.geekbang.org/column/intr…