目录
面向对象与面向过程
面向对象四大基本特性
接口与抽象类的区别
接口
贫血模型与充血模型
领域驱动设计
案例:虚拟钱包系统
7个设计原则
简单介绍7大原则
案例描述7大设计原则
单一职责
开闭原则
依赖倒置原则
里氏替换原则
组合优于继承
规范与重构
扩展性
复用性
解耦
三大类设计模式
创建者模式
单例模式
工厂模式
简单工厂:
工厂方法
抽象工厂
原型模式
场景
浅拷贝
编辑编辑
深拷贝
建造者模式
建造者模式
建造者模式总结编辑
类图
结构型模式
装饰器模式
案例
装饰器模式小结:
装饰器模式UML类图
装饰器之IO流案例
适配器模式
应用场景
门面模式
代理模式
动态代理语法
反射基础知识
Springmvc拦截器实现原理编辑
Spring Aop的实现原理
代理模式UML类图
总结
适配器模式与代理模式区别
装饰器模式与代理模式区别
桥接模式
组合模式
享元模式
行为型模式
策略模式
前戏铺垫
策略模式上场
策略模式UML图
迭代器模式
组合模式
观察者模式
模板模式
模板模式与回调函数的区别
职责链模式(责任链模式)
状态模式
备忘录模式
命令模式
解释器模式
中介模式
总结回顾 23 种经典设计模式
参考路径:1、23种设计模式前言_哔哩哔哩_bilibili
对于所有编程语言最终目的是两种:提高硬件的运行效率、提高程序员的开发效率。然而这两种很难兼得
使用任何一个编程语言编写的程序,最终执行上都要落实到CPU一条一条指令的执行(无论通过虚拟机解释执行,还是直接编译为机器码),CPU看不到是使用何种语言编写的程序。对于所有编程语言最终目的是两种:提高硬件的运行效率和提高程序员的开发效率。然而这两种很难兼得。 C语言在效率方面几乎做到了极致,它更适合挖掘硬件的价值,如:C语言用数组char a[8],经过编译以后变成了(基地址+偏移量)的方式。对于CPU来说,没有运算比加法更快,它的执行效率的算法复杂度是O(1)的。从执行效率这个方面看,开发操作系统和贴近硬件的底层程序,C语言是极好的选择。 C语言带来的问题是内存越界、野指针、内存泄露等。它只关心程序飞的高不高,不关心程序猿飞的累不累。为了解脱程序员,提高开发效率,设计了OOP等更“智能”的编程语言,但是开发容易毕竟来源于对底层的一层一层又一层的包装。完成一个特定操作有了更多的中间环节, 占用了更大的内存空间, 占用了更多的CPU运算。从这个角度看,OOP这种高级语言的流行是因为硬件越来越便宜了。我们可以想象如果大众消费级的主控芯片仍然是单核600MHz为主流,运行Android系统点击一个界面需要2秒才能响应,那我们现在用的大部分手机程序绝对不是使用JAVA开发的,Android操作系统也不可能建立起这么大的生态。
什么是好代码、高质量的代码?
为了做到有的放矢、 有重点地学习, 我挑选了其中几个最常用的、 最重要的评价标准, 来详 细讲解, 其中就包括: 可维护性、 可读性、 可扩展性、 灵活性、 简洁性(简单、 复杂) 、 可 复用性、 可测试性。 接下来, 我们逐一讲解一下。 1. 可维护性( maintainability ) 我们首先来看, 什么是代码的“可维护性” ? 所谓的“维护代码” 到底包含哪些具体工作? 落实到编码开发, 所谓的“维护”无外乎就是修改 bug、 修改老的代码、 添加新的代码之 类的工作。 所谓 “代码易维护” 就是指, 在不破坏原有代码设计、 不引入新的 bug 的情况 下, 能够快速地修改或者添加代码。 所谓“代码不易维护”就是指, 修改或者添加代码需要 冒着极大的引入新 bug 的风险, 并且需要花费很长的时间才能完成
2. 可读性( readability )
需要看代码是否符合编码规范、 命名是否达意、 注释是否详尽、 函数是否长短合适、 模 块划分是否清晰、 是否符合高内聚低耦合等等。 你应该也能感觉到, 从正面上, 我们很难给 出一个覆盖所有评价指标的列表
3. 可扩展性( extensibility )
可扩展性也是一个评价代码质量非常重要的标准。 它表示我们的代码应对未来需求变化的能 力。 跟可读性一样, 代码是否易扩展也很大程度上决定代码是否易维护。那到底什么是代码 的可扩展性呢? 代码的可扩展性表示, 我们在不修改或少量修改原有代码的情况下, 通过扩展的方式添加新 的功能代码。 说直白点就是, 代码预留了一些功能扩展点, 你可以把新功能代码, 直接插到 扩展点上, 而不需要因为要添加一个功能而大动干戈, 改动大量的原始代码。 关于代码的扩展性, 在后面讲到“对修改关闭, 对扩展开放” 这条设计原则的时候
4. 灵活性( flexibility)
实际上, 灵活性是一个挺抽象的评价标准, 要给灵活性下个定义也是挺难的。 不过, 我们可以想一下, 什么情况下我们才会说代码写得 好灵活呢?我这里罗列了几个场景, 希望能引发你自己对什么是灵活性的思考。 从刚刚举的场景来看, 如果一段代码易扩展、 易复用或者易用, 我们都可以称这段代码写得 比较灵活。 所以, 灵活这个词的含义非常宽泛, 很多场景下都可以使用。
5. 简洁性( simplicity )
有一条非常著名的设计原则, 你一定听过, 那就是 KISS 原则: “Keep It Simple,Stupid” 。 这个原则说的意思就是, 尽量保持代码简单。 代码简单、 逻辑清晰, 也就意味 着易读、 易维护。 我们在编写代码的时候, 往往也会把简单、 清晰放到首位。 不过, 很多编程经验不足的程序员会觉得, 简单的代码没有技术含量, 喜欢在项目中引入一 些复杂的设计模式, 觉得这样才能体现自己的技术水平。 实际上, 思从深而行从简, 真正的 高手能云淡风轻地用最简单的方法解决最复杂的问题。这也是一个编程老手跟编程新手的本 质区别之一。 除此之外, 虽然我们都能认识到, 代码要尽量写得简洁, 符合 KISS 原则, 但怎么样的代码 才算足够简洁?不是每个人都能很准确地判断出来这一点。所以, 在后面的章节中, 当我们 讲到 KISS 原则的时候, 我会通过具体的代码实例, 详细给你解释, “为什么 KISS 原则看 似非常简单、 好理解, 但实际上用好并不容易” 。 今天, 我们就暂且不展开详细讲解了。
6. 可复用性( reusability )
当我们添加一个新的功能代码的时候, 原有的代码已经预留好了扩展点, 我们不需要修 改原有的代码, 只要在扩展点上添加新的代码即可。 这个时候, 我们除了可以说代码易 扩展, 还可以说代码写得好灵活。 当我们要实现一个功能的时候, 发现原有代码中, 已经抽象出了很多底层可以复用的模块、 类等代码, 我们可以拿来直接使用。 这个时候, 我们除了可以说代码易复用之外, 还可以说代码写得好灵活。 当我们使用某组接口的时候, 如果这组接口可以应对各种使用场景, 满足各种不同的需 求, 我们除了可以说接口易用之外, 还可以说这个接口设计得好灵活或者代码写得好灵活。
常提到“可复用性”这一代码评价标准。 比如, 当讲到面向对象特性的时候, 我们会讲到继承、 多态存在的目的之一, 就是为了提高 代码的可复用性; 当讲到设计原则的时候, 我们会讲到单一职责原则也跟代码的可复用性相 关; 当讲到重构技巧的时候, 我们会讲到解耦、 高内聚、 模块化等都能提高代码的可复用 性。 可见, 可复用性也是一个非常重要的代码评价标准, 是很多设计原则、 思想、 模式等所 要达到的最终效果。 实际上, 代码可复用性跟 DRY ( Don’ t Repeat Yourself) 这条设计原则的关系挺紧密 的, 所以, 在后面的章节中, 当我们讲到 DRY 设计原则的时候, 我还会讲更多代码复用相 关的知识, 比如, “有哪些编程方法可以提高代码的复用性”等。
7. 可测试性( testability )
代码可测试性的好坏, 能从侧面上非常准确地反应代码质量的好坏。 代码的可测试性差, 比较难写单元测试,那基本上就能说明代码设计得有问题
如何才能写出高质量的代码?
刚刚讲到了七个最常用、 最重 要的评价指标。 所以, 问如何写出高质量的代码, 也就等同于在问, 如何写出易维护、 易读、 易扩展、 灵活、 简洁、 可复用、 可测试的代码。
要写出满足这些评价标准的高质量代码, 我们需要掌握一些更加细化、 更加能落地的编程方 法论, 包括面向对象设计思想、 设计原则、 设计模式、 编码规范、 重构技巧等。 而所有这些 编程方法论的最终目的都是为了编写出高质量的代码。 比如, 面向对象中的继承、多态能让我们写出可复用的代码; 编码规范能让我们写出可读性 好的代码; 设计原则中的单一职责、DRY、基于接口而非实现、 里式替换原则等, 可以让我们写出可复用、灵活、可读性好、易扩展、易维护的代码; 设计模式可以让我们写出易扩展的代码; 持续重构可以时刻保持代码的可维护性等等
关于面向对象、设计原则、设计模式、 编程规范和代码重构
这五者的关系我们总结梳理一下。
重构的目的 (why) 、 对象(what) 、 时机(when) 、 方法(how) ; 保证重构不出错的技术手段: 单元测试和代码的可测试性; 两种不同规模的重构: 大重构(大规模高层次)和小重构(小规模低层次) 。
面向对象编程因为其具有丰富的特性(封装、 抽象、 继承、 多态) , 可以实现很多复杂 的设计思路, 是很多设计原则、 设计模式等编码实现的基础。
设计原则是指导我们代码设计的一些经验总结,对于某些场景下,是否应该应用某种设计模式, 具有指导意义。 比如, “开闭原则” 是很多设计模式(策略、模板等) 的指导原则。
设计模式是针对软件开发中经常遇到的一些设计问题,总结出来的一套解决方案或者设 计思路。 应用设计模式的主要目的是提高代码的可扩展性。从抽象程度上来讲, 设计原 则比设计模式更抽象。 设计模式更加具体、 更加可执行。
编程规范主要解决的是代码的可读性问题。 编码规范相对于设计原则、 设计模式, 更加 具体、 更加偏重代码细节、 更加能落地。 持续的小重构依赖的理论基础主要就是编程规 范。
重构作为保持代码质量不下降的有效手段, 利用的就是面向对象、 设计原则、 设计模 式、 编码规范这些理论
实际上, 面向对象、 设计原则、 设计模式、 编程规范、 代码重构, 这五者都是保持或者提高 代码质量的方法论, 本质上都是服务于编写高质量代码这一件事的。 当我们追本逐源, 看清 这个本质之后, 很多事情怎么做就清楚了, 很多选择怎么选也清楚了。 比如, 在某个场景 下, 该不该用这个设计模式, 那就看能不能提高代码的可扩展性; 要不要重构, 那就看重代 码是否存在可读、 可维护问题等。
面向对象分析与设计
之所以在前面加“面向对象”这几个字, 是因为我们是围绕着对象或类来做需求分析和设计 的。 分析和设计两个阶段最终的产出是类的设计, 包括程序被拆解为哪些类, 每个类有哪些属性方法, 类与类之间如何交互等等。 它们比其他的分析和设计更加具体、 更加落地、 更加 贴近编码, 更能够顺利地过渡到面向对象编程环节。 这也是面向对象分析和设计, 与其他分 析和设计最大的不同点。 看到这
面向对象设计思想
面向对象设计和实现,就是把合适的代码放到合适的类中,至于到底选哪种划分方法,判断的标准就是:尽量让代码高内聚、低耦合、单一职责,对扩展开放对修改关闭、等等前面提到的设计原则和思想,尽量让代码易读、易复用、易扩展、易维护
面向过程风格的代码被组织成了一组方法集合及其数据结构(struct User),方法和数据结构的定义是分开的。面向对象风格的代码被组织成一组类,方法和数据结构被绑定一起,定义在类中
面向对象编程是以类为思考对象。在进行面向对象编程的时候,我们并不是一上来就去思考,如何将复杂的流程拆解为一个一个方法,而是采用曲线救国的策略,先去思考如何给业务建模,如何将需求翻译为类,如何给类之间建立交互关系,而完成这些工作完全不需要考虑错综复杂的处理流程。当我们有了类的设计之后,然后再像搭积木一样,按照处理流程,将类组装起来形成整个程序。这种开发模式、思考问题的方式,能让我们在应对复杂程序开发的时候,思路更加清晰
除此之外,面向对象编程还提供了一种更加清晰的、更加模块化的代码组织方式
OOP 风格的代码,因为有面向对象四大特性封装抽象继承多态的加持,使得程序更易复用、易扩展、易维护
oop编程更高级,更接近人的思维方式
今天你要掌握的重点内 容是三种违反面向对象编程风格的典型代码设计。 1 . 滥用 getter、 setter 方法 在设计实现类的时候, 除非真的需要, 否则尽量不要给属性定义 setter 方法。 除此之外, 尽管 getter 方法相对 setter 方法要安全些, 但是如果返回的是集合容器, 那也要防范集合 内部数据被修改的风险。 2.Constants 类、 Utils 类的设计问题 对于这两种类的设计, 我们尽量能做到职责单一, 定义一些细化的小类, 比如 RedisConstants、 FileUtils, 而不是定义一个大而全的 Constants 类、 Utils 类。 除此之 外, 如果能将这些类中的属性和方法, 划分归并到其他业务类中, 那是最好不过的了, 能极 大地提高类的内聚性和代码的可复用性。 3. 基于贫血模型的开发模式 关于这一部分, 我们只讲了为什么这种开发模式是彻彻底底的面向过程编程风格的。这是因 为数据和操作是分开定义在 VO/BO/Entity 和 Controler/Service/Repository 中的。 今 天, 你只需要掌握这一点就可以了。 为什么这种开发模式如此流行?如何规避面向过程编程 的弊端?有没有更好的可替代的开发模式?相关的更多问题, 我们在面向对象实战篇中会一 一讲解。
面向过程编程及面向过程编程语言就真的无用武之地了吗?
前面我们讲了面向对象编程相比面向过程编程的各种优势, 又讲了哪些代码看起来像面向对 象风格, 而实际上是面向过程编程风格的。 那是不是面向过程编程风格就过时了被淘汰了 呢? 是不是在面向对象编程开发中, 我们就要杜绝写面向过程风格的代码呢? 前面我们有讲到, 如果我们开发的是微小程序, 或者是一个数据处理相关的代码, 以算法为 主, 数据为辅, 那脚本式的面向过程的编程风格就更适合一些。 当然, 面向过程编程的用武 之地还不止这些。 实际上, 面向过程编程是面向对象编程的基础, 面向对象编程离不开基础 的面向过程编程。 为什么这么说?我们仔细想想, 类中每个方法的实现逻辑, 不就是面向过 程风格的代码吗? 除此之外, 面向对象和面向过程两种编程风格, 也并不是非黑即白、 完全对立的。 在用面向 对象编程语言开发的软件中, 面向过程风格的代码并不少见, 甚至在一些标准的开发库(比 如 JDK、 Apache Commons、 Google Guava) 中, 也有很多面向过程风格的代码。 不管使用面向过程还是面向对象哪种风格来写代码, 我们最终的目的还是写出易维护、 易 读、 易复用、 易扩展的高质量代码。 只要我们能避免面向过程编程风格的一些弊端,控制好 它的副作用, 在掌控范围内为我们所用, 我们就大可不用避讳在面向对象编程中写面向过程风格的代码
为什么人们更容易写出面向过程的代码?
联想一下, 在生活中, 你去完成一个任务, 你一般都会思考, 应该先做什么、 后做什 么, 如何一步一步地顺序执行一系列操作, 最后完成整个任务。 面向过程编程风格恰恰符合 人的这种流程化思维方式。
而面向对象编程风格正好相反。 它是一种自底向上的思考方式,它不是先去按照执行流程来分解任务, 而是将任务翻译成一个一个的小的模块(也就是类) , 设计类之间的交互,最后按照流程将类组装起来,完成整个任务。 这样的思考路径比较适合复杂程序的开发, 但并不是特别符合人类的思考习惯。 除此之外, 面向对象编程要比面向过程编程难一些。 在面向对象编程中, 类的设计还是挺需要技巧, 挺需要一定设计经验的。 你要去思考如何封装合适的数据和方法到一个类里,如何设计类之间的关系,如何设计类之间的交互等等诸多设计问题。 所以, 基于这两点原因, 很多工程师在开发的过程, 更倾向于用不太需要动脑子的方式去实现需求,也就不由自主地就将代码写成面向过程风格的了
面向对象的总结完美符合 What/How/Why 模型,我按照模型作下梳理。
## 封装
What:隐藏信息,保护数据访问。
How:暴露有限接口和属性,需要编程语言提供访问控制的语法。
Why:提高代码可维护性;降低接口复杂度,提高类的易用性。 也提高了安全性,防止属性被随意奇葩的方式修改,防止修改的逻辑散落在代码各个角落
##抽象
What: 隐藏具体实现,使用者只需关心功能,无需关心实现。
How: 通过接口类或者抽象类实现,特殊语法机制非必须。
Why: 提高代码的扩展性、维护性;降低复杂度,减少细节负担。
##继承
What: 表示 is-a 关系,分为单继承和多继承。
How: 需要编程语言提供特殊语法机制。例如 Java 的 “extends”,C++ 的 “:”
Why: 解决代码复用问题。
##多态
What: 子类替换父类,在运行时调用子类的实现。
How: 需要编程语言提供特殊的语法机制。比如继承、接口类、duck-typing。
Why: 提高代码扩展性和复用性。利用多态特性,不同的类对象可以传递给相同的方法,执行不同的代码逻辑,提高了代码的复用性。
3W 模型的关键在于 Why,没有 Why,其它两个就没有存在的意义。从四大特性可以看出,面向对象的终极目的只有一个:可维护性。易扩展、易复用,降低复杂度等等都属于可维护性的实现方式。
封装,把方法加上private,从而隔离了变化,所有发生在此private 方法内部的变更细节,实现系列,客户端程序员都是无感知的,类的提供者只需要保证对外暴露的接口方法是不变的就行,
接口方法就应该保持稳定,接口方法就相当于一种对外的承诺,private方法是实现细节就可以变化;对外隐藏复杂的实现细节,只暴露出简单的接口方法
这里说的接口,不仅仅局限于java语法的interface接口,还包括类对外提供的公public方法,即public里面依赖的各个private方法可以随便变化,但是对外暴露的public方法,要保持不变,从而实现了隔离处于private方法中的变化,实现了封装变化
一旦你这样把age这个数据的访问方式,直接暴露出去,那么在不,就可以任意的践踏糟蹋这个数据,就像这里给年龄字段赋一个负值
而如果你让外部只能通过setAge方法访问内部数据,那么就不会出现年龄为负的情况,从而实现了数据保护
我们可以使用接口来实现面向对象的抽象特性、多态特性和基于接口而非实现的设计原则,
使用抽象类来实现面向对象的继承特性和模板设计模式等等
抽象类更多的是为了代码复用,而接口就更侧重于解耦
如果不用抽象类用普通类,虽然也能达到了代码复用的目的,但是无法使用多态特性了
我们在 Logger 父类中,定义一个空的 log() 方法,让子类重写父类的 log() 方法,实现自己的记录日志的逻辑,就能使用多态特性,这个设计思路能用,但是,它显然没有之前通过抽象类的实现思路优雅。主要有以下几点原因。
在 Logger 中定义一个空的方法,会影响代码的可读性。如果我们不熟悉 Logger 背后的设计思想,代码注释又不怎么给力,我们在阅读 Logger 代码的时候,就可能对为什么定义一个空的 log() 方法而感到疑惑,需要查看 Logger、FileLogger、MessageQueueLogger 之间的继承关系,才能弄明白其设计意图。
当创建一个新的子类继承 Logger 父类的时候,我们有可能会忘记重新实现 log() 方法。之前基于抽象类的设计思路,编译器会强制要求子类重写 log() 方法,否则会报编译错误。你可能会说,我既然要定义一个新的 Logger 子类,怎么会忘记重新实现 log() 方法呢?我们举的例子比较简单,Logger 中的方法不多,代码行数也很少。但是,如果 Logger 有几百行,有 n 多方法,除非你对 Logger 的设计非常熟悉,否则忘记重新实现 log() 方法,也不是不可能的。
Logger 可以被实例化,换句话说,我们可以 new 一个 Logger 出来,并且调用空的 log() 方法。这也增加了类被误用的风险。当然,这个问题可以通过设置私有的构造函数的方式来解决。不过,显然没有通过抽象类来的优雅。
1. 抽象类和接口的语法特性
抽象类不允许被实例化,只能被继承。它可以包含属性和方法。方法既可以包含代码实现,也可以不包含代码实现。不包含代码实现的方法叫作抽象方法。子类继承抽象类,必须实现抽象类中的所有抽象方法。接口不能包含属性,只能声明方法,方法不能包含代码实现。类实现接口的时候,必须实现接口中声明的所有方法。
2. 抽象类和接口存在的意义
抽象类是对成员变量和方法的抽象,是一种 is-a 关系,是为了解决代码复用问题。接口仅仅是对方法的抽象,是一种 has-a 关系,表示具有某一组行为特性,是为了解决解耦问题,隔离接口和具体的实现,提高代码的扩展性。
3. 抽象类和接口的应用场景区别
什么时候该用抽象类?什么时候该用接口?实际上,判断的标准很简单。如果要表示一种 is-a 的关系,并且是为了解决代码复用问题,我们就用抽象类;如果要表示一种 has-a 关系,并且是为了解决抽象而非代码复用问题,那我们就用接口
例如:
解决复用问题:java中的子类FileInputStream和PipeInputStream等继承抽象类InputStream。重写了read(source)方法,InputStream 中还包含其他方法,FileInputStream继承抽象类复用了父类的其他方法。 解决抽象问题:抽象类InputStream实现了Closeable接口,该接口中包含close()抽象方法。Closeable这个接口还在很多其他类中实现了,例如Channel,Socket中都有close() 关闭这个功能,但具体实现每个类又各有不同的实现,这个就是抽象(抽象是在抽象什么,就是抽象出一个个的上层功能点,一个个的能力)
网友回复:
我觉得接口和抽象类都可以解决复用和抽象的问题。谁说抽象问题就要用接口不能用抽象类呢,复用问题就要用抽象类不能用接口呢?文中也说了很多语言没有接口的概念,JAVA也可以用抽象类来模拟接口。 把抽象类理解成接口也没有毛病
两者的区别关键在于抽象类表示的is-a关系,例如FileInputStearm和PipleInputStearm都是InputStearm。 而接口表示的是has-a,也就是具有某种能力,例如Compareable接口表示可排序,Serializable接口表示可序列化。 为什么接口可以实现多个,而抽象类不行?我认为接口has-a表示的是具有某种能力或功能,是更细粒度的。比如某个抽象类同时实现Compareable和Serializable表示这个类同时具有两种能力。技能可以有很多种,但爸爸只能有一个。
“接口”的定义吗?从本质上来看,“接口”就是一组“协议”或者“约定”,是功能提供者提供给使用者的一个“功能列表”
接口的定义,要有抽象意识:只表明做什么,而不表明怎么做。
基于接口而不是实现编程,应用这条原则,可以将接口和实现相分离,封装不稳定的实现,暴露稳定的接口。上游系统面向接口而非实现编程,不依赖不稳定的实现细节,这样当实现发生变化的时候,上游系统的代码基本上不需要做改动,以此来降低耦合性,提高扩展性。
典型案例
springmvc的DispatcherServlet的doDispatch方法,就是上层代码,这个上层代码就是针对接口进行的编程。doDispatch方法中有一步,调用HandlerAdapter接口来执行Handler处理器,因为处理器的种类多种多样,有的是实现的Controller接口,有的是标注的@Controller,需要把他们用处理器适配器统一成一个统一的接口方法,doDispatch方法就只用针对这个统一的适配器方法编程,而不用管以后再来多少新种类的处理器
实际上,“基于接口而非实现编程”这条原则的另一个表述方式,是“基于抽象而非实现编程”。后者的表述方式其实更能体现这条原则的设计初衷。
越抽象、越顶层、越脱离具体某一实现的设计,越能提高代码的灵活性,越能应对未来的需求变化。好的代码设计,不仅能应对当下的需求,而且在将来需求发生变化的时候,仍然能够在不破坏原有代码设计的情况下灵活应对。而抽象就是提高代码扩展性、灵活性、可维护性最有效的手段之一
面向接口而不是实现编程原则的设计初衷是,将接口和实现相分离,封装不稳定的实现,暴露稳定的接口。上游系统面向接口而非实现编程,不依赖不稳定的实现细节,这样当实现发生变化的时候,上游系统的代码基本上不需要做改动,以此来降低代码间的耦合性,提高代码的扩展性。
从这个设计初衷上来看,如果在我们的业务场景中,某个功能只有一种实现方式,未来也不可能被其他实现方式替换,那我们就没有必要为其设计接口,也没有必要基于接口编程,直接使用实现类就可以了
总结
我们在做软件开发的时候,一定要有抽象意识、封装意识、接口意识。在定义接口的时候,不要暴露任何实现细节(如果是和某一个具体平台具体实现绑定的步骤功能点,就不要让它出现在接口类中,而让它以private方法的形式,出现在具体接口实现类中)。接口的定义只表明做什么,而不是怎么做。而且,在设计接口的时候,我们要多思考一下,这样的接口设计是否足够通用,是否能够做到在替换具体的接口实现的时候,不需要任何接口定义的改动。
有的说法将接口是behaves like,也就是多重继承的真义实际上是behaves like,也就是接口的意义。A vampire behaves like humans and bats. 而这是接口能多重的原因,一个类可以具有多重行为,但是不能是多种东西。 所以其实也就是说,只有当前模块涉及到抽象行为的时候,才有必要设计接口,才有可能利用接口多重继承的特性来更好的将各种行为分组
贫血模型(Anemic Domain Model)。传统的web开发模式这种UserEntity、UserVo 都是基于贫血模型设计的。这种贫血模型将数据与操作分离,破坏了面向对象的封装特性,是一种典型的面向过程的编程风格
贫血模型中,数据和业务逻辑被分割到不同的类中。充血模型(Rich Domain Model)正好相反,数据和对应的业务逻辑被封装到同一个类中。因此,这种充血模型满足面向对象的封装特性,是典型的面向对象编程风格。
面向过程编程风格有种种弊端,比如,数据和操作分离之后,数据本身的操作就不受限制了。任何代码都可以随意修改数据。
第二点原因是,充血模型的设计要比贫血模型更加有难度。因为充血模型是一种面向对象的编程风格。我们从一开始就要设计好针对数据要暴露哪些操作,定义哪些业务逻辑。而不是像贫血模型那样,我们只需要定义数据,之后有什么功能开发需求,我们就在 Service 层定义什么操作,不需要事先做太多设计。
我们知道微服务开发,除了监控、调用链追踪、API 网关等服务治理系统的开发之外,微服务还有另外一个更加重要的工作,那就是针对公司的业务,合理地做微服务拆分。而领域驱动设计恰好就是用来指导划分服务的。所以,微服务加速了领域驱动设计的盛行。
它跟基于贫血模型的传统开发模式的区别主要在 Service 层。
在基于贫血模型的传统开发模式中,Service 层包含 Service 类和 BO 类两部分,BO 是贫血模型,只包含数据,不包含具体的业务逻辑。业务逻辑集中在 Service 类中。在基于充血模型的 DDD 开发模式中,Service 层包含 Service 类和 Domain 类两部分。Domain 就相当于贫血模型中的 BO。不过,Domain 与 BO 的区别在于它是基于充血模型开发的,既包含数据,也包含业务逻辑。而 Service 类变得非常单薄。总结一下的话就是,基于贫血模型的传统的开发模式,重 Service 轻 BO;基于充血模型的 DDD 开发模式,轻 Service 重 Domain。
这两种开发模式,落实到代码层面,区别不就是一个将业务逻辑放到 Service 类中,一个将业务逻辑放到 Domain 领域模型中吗?为什么基于贫血模型的传统开发模式,就不能应对复杂业务系统的开发?而基于充血模型的 DDD 开发模式就可以呢?
不夸张地讲,我们平时的开发,大部分都是 SQL 驱动(SQL-Driven)的开发模式。我们接到一个后端接口的开发需求的时候,就去看接口需要的数据对应到数据库中,需要哪张表或者哪几张表,然后思考如何编写 SQL 语句来获取数据。之后就是定义 Entity、BO、VO,然后模板式地往对应的 Repository、Service、Controller 类中添加代码。
业务逻辑包裹在一个大的 SQL 语句中,而 Service 层可以做的事情很少。SQL 都是针对特定的业务功能编写的,复用性差。当我要开发另一个业务功能的时候,只能重新写个满足新需求的 SQL 语句,这就可能导致各种长得差不多、区别很小的 SQL 语句满天飞
如果我们在项目中,应用基于充血模型的 DDD 的开发模式,那对应的开发流程就完全不一样了。在这种开发模式下,我们需要事先理清楚所有的业务,定义领域模型所包含的属性和方法。领域模型相当于可复用的业务中间层。新功能需求的开发,都基于之前定义好的这些领域模型来完成。
我们知道,越复杂的系统,对代码的复用性、易维护性要求就越高,我们就越应该花更多的时间和精力在前期设计上。而基于充血模型的 DDD 开发模式,正好需要我们前期做大量的业务调研、领域模型设计,所以它更加适合这种复杂系统的开发。
一般来讲,每个虚拟钱包账户都会对应用户的一个真实的支付账户,有可能是银行卡账户,也有可能是三方支付账户(比如支付宝、微信钱包)。为了方便后续的讲解,我们限定钱包暂时只支持充值、提现、支付、查询余额、查询交易流水这五个核心的功能,其他比如冻结、透支、转赠等不常用的功能,我们暂不考虑。为了让你理解这五个核心功能是如何工作的,接下来,我们来一块儿看下它们的业务实现流程。
1. 充值
用户通过三方支付渠道,把自己银行卡账户内的钱,充值到虚拟钱包账号中。这整个过程,我们可以分解为三个主要的操作流程:第一个操作是从用户的银行卡账户转账到应用的公共银行卡账户;第二个操作是将用户的充值金额加到虚拟钱包余额上;第三个操作是记录刚刚这笔交易流水。
2. 支付
用户用钱包内的余额,支付购买应用内的商品。实际上,支付的过程就是一个转账的过程,从用户的虚拟钱包账户划钱到商家的虚拟钱包账户上。除此之外,我们也需要记录这笔支付的交易流水信息。
3. 提现
除了充值、支付之外,用户还可以将虚拟钱包中的余额,提现到自己的银行卡中。这个过程实际上就是扣减用户虚拟钱包中的余额,并且触发真正的银行转账操作,从应用的公共银行账户转钱到用户的银行账户。同样,我们也需要记录这笔提现的交易流水信息。
4. 查询余额
查询余额功能比较简单,我们看一下虚拟钱包中的余额数字即可。
5. 查询交易流水
查询交易流水也比较简单。我们只支持三种类型的交易流水:充值、支付、提现。在用户充值、支付、提现的时候,我们会记录相应的交易信息。在需要查询的时候,我们只需要将之前记录的交易流水,按照时间、类型等条件过滤之后,显示出来即可。
钱包系统的设计思路
根据刚刚讲的业务实现流程和数据流转图,我们可以把整个钱包系统的业务划分为两部分,其中一部分单纯跟应用内的虚拟钱包账户打交道,另一部分单纯跟银行账户打交道。我们基于这样一个业务划分,给系统解耦,将整个钱包系统拆分为两个子系统:虚拟钱包系统和三方支付系统。
总体功能
交易流水表
交易流水管理表,有两个功能: 一个是业务功能, 比如, 提供用户查询交易流水信息; 另一个是非业务功能,保证数据的一致性。 这里主要是指支付操作数据的一致性。 支付实际上就是一个转账的操作, 在一个账户上加上一定的金额, 在另一个账户上减去相应的金额。 我们需要保证加金额和减金额这两个操作,要么都成功,要么都失败。 如果一个成功,一个失败,就会导致数据的不一致,一个账户明明减掉了钱, 另一个账户却没有收到钱。
保证数据一致性的方法有很多,比如依赖数据库事务的原子性,将两个操作放在同一个事务 中执行。 但是,这样的做法不够灵活,因为我们的有可能做了分库分表,支付涉及的两个账户可能存储在不同的库中,无法直接利用数据库本身的事务特性,在一个事务中执行两个账户的操作。当然, 我们还有一些支持分布式事务的开源框架, 但是, 为了保证数据的强一致性, 它们的实现逻辑一般都比较复杂、 本身的性能也不高, 会影响业务的执行时间
更加权衡的一种做法就是,不保证数据的强一致性, 只实现数据的最终一致性, 也就是我们刚刚提到的交易流水要实现的非业务功能。 对于支付这样的类似转账的操作, 我们在操作两个钱包账户余额之前, 先记录交易流水, 并且标记为“待执行” ,当两个钱包的加减金额都完成之后, 我们再回过头来, 将交易流水标记为 “成功” 。 在给两个钱包加减金额的过程中, 如果有任意一个操作失败, 我们就将交易 记录的状态标记为 “失败” 。 我们通过后台补漏 Job, 拉取状态为 “失败” 或者长时间处 于“待执行”状态的交易记录, 重新执行或者人工介入处理
现在, 我们再思考这样一个问题:充值、提现、支付这些业务交易类型,是否应该让虚拟钱 包系统感知?换句话说,我们是否应该在虚拟钱包系统的交易流水中记录这三种类型? 答案是否定的。 虚拟钱包系统不应该感知具体的业务交易类型。我们前面讲到,虚拟钱包支 持的操作, 仅仅是余额的加加减减操作, 不涉及复杂业务概念, 职责单一、 功能通用。 如果 耦合太多业务概念到里面, 势必影响系统的通用性, 而且还会导致系统越做越复杂。 因此, 我们不希望将充值、 支付、 提现这样的业务概念添加到虚拟钱包系统中。 但是, 如果我们不在虚拟钱包系统的交易流水中记录交易类型,那在用户查询交易流水的时 候, 如何显示每条交易流水的交易类型呢? 从系统设计的角度, 我们不应该在虚拟钱包系统的交易流水中记录交易类型。从产品需求的 角度来说, 我们又必须记录交易流水的交易类型。 听起来比较矛盾, 这个问题该如何解决 呢? 我们可以通过记录两条交易流水信息的方式来解决。 我们前面讲到, 整个钱包系统分为两个子系统, 上层钱包系统的实现, 依赖底层虚拟钱包系统和三方支付系统。 对于钱包系统来 说, 它可以感知充值、 支付、 提现等业务概念, 所以, 我们在钱包系统这一层额外再记录一条包含交易类型的交易流水信息, 而在底层的虚拟钱包系统中记录不包含交易类型的交易流水信息
分层架构的设计思想,实际上也就类似于一个业务中台,和另两个更底层的单一职责的底层支撑系统
在这种开发模式下,我们把虚拟钱包 VirtualWallet 类设计成一个充血的 Domain 领域模型,并且将原来在 Service 类中的部分业务逻辑移动到 VirtualWallet 类中,让 Service 类的实现依赖 VirtualWallet 类。
public class VirtualWallet { // Domain领域模型(充血模型)
private Long id;
private Long createTime = System.currentTimeMillis();;
private BigDecimal balance = BigDecimal.ZERO;
public VirtualWallet(Long preAllocatedId) {
this.id = preAllocatedId;
}
public BigDecimal balance() {
return this.balance;
}
public void debit(BigDecimal amount) {
if (this.balance.compareTo(amount) < 0) {
throw new InsufficientBalanceException(...);
}
this.balance = this.balance.subtract(amount);
}
public void credit(BigDecimal amount) {
if (amount.compareTo(BigDecimal.ZERO) < 0) {
throw new InvalidAmountException(...);
}
this.balance = this.balance.add(amount);
}
}
public class VirtualWalletService {
// 通过构造函数或者IOC框架注入
private VirtualWalletRepository walletRepo;
private VirtualWalletTransactionRepository transactionRepo;
public VirtualWallet getVirtualWallet(Long walletId) {
VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
VirtualWallet wallet = convert(walletEntity);
return wallet;
}
public BigDecimal getBalance(Long walletId) {
return walletRepo.getBalance(walletId);
}
@Transactional
public void debit(Long walletId, BigDecimal amount) {
VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
VirtualWallet wallet = convert(walletEntity);
wallet.debit(amount);
VirtualWalletTransactionEntity transactionEntity = new VirtualWalletTransactionEntity();
transactionEntity.setAmount(amount);
transactionEntity.setCreateTime(System.currentTimeMillis());
transactionEntity.setType(TransactionType.DEBIT);
transactionEntity.setFromWalletId(walletId);
transactionRepo.saveTransaction(transactionEntity);
walletRepo.updateBalance(walletId, wallet.balance());
}
@Transactional
public void credit(Long walletId, BigDecimal amount) {
VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
VirtualWallet wallet = convert(walletEntity);
wallet.credit(amount);
VirtualWalletTransactionEntity transactionEntity = new VirtualWalletTransactionEntity();
transactionEntity.setAmount(amount);
transactionEntity.setCreateTime(System.currentTimeMillis());
transactionEntity.setType(TransactionType.CREDIT);
transactionEntity.setFromWalletId(walletId);
transactionRepo.saveTransaction(transactionEntity);
walletRepo.updateBalance(walletId, wallet.balance());
}
@Transactional
public void transfer(Long fromWalletId, Long toWalletId, BigDecimal amount) {
//...跟基于贫血模型的传统开发模式的代码一样...
}
}
看了上面的代码,你可能会说,领域模型 VirtualWallet 类很单薄,包含的业务逻辑很简单。相对于原来的贫血模型的设计思路,这种充血模型的设计思路,貌似并没有太大优势。你说得没错!这也是大部分业务系统都使用基于贫血模型开发的原因。不过,如果虚拟钱包系统需要支持更复杂的业务逻辑,那充血模型的优势就显现出来了。比如,我们要支持透支一定额度和冻结部分余额的功能。这个时候,我们重新来看一下 VirtualWallet 类的实现代码。
public class VirtualWallet {
private Long id;
private Long createTime = System.currentTimeMillis();;
private BigDecimal balance = BigDecimal.ZERO;
private boolean isAllowedOverdraft = true;
private BigDecimal overdraftAmount = BigDecimal.ZERO;
private BigDecimal frozenAmount = BigDecimal.ZERO;
public VirtualWallet(Long preAllocatedId) {
this.id = preAllocatedId;
}
public void freeze(BigDecimal amount) { ... }
public void unfreeze(BigDecimal amount) { ...}
public void increaseOverdraftAmount(BigDecimal amount) { ... }
public void decreaseOverdraftAmount(BigDecimal amount) { ... }
public void closeOverdraft() { ... }
public void openOverdraft() { ... }
public BigDecimal balance() {
return this.balance;
}
public BigDecimal getAvaliableBalance() {
BigDecimal totalAvaliableBalance = this.balance.subtract(this.frozenAmount);
if (isAllowedOverdraft) {
totalAvaliableBalance += this.overdraftAmount;
}
return totalAvaliableBalance;
}
public void debit(BigDecimal amount) {
BigDecimal totalAvaliableBalance = getAvaliableBalance();
if (totoalAvaliableBalance.compareTo(amount) < 0) {
throw new InsufficientBalanceException(...);
}
this.balance = this.balance.subtract(amount);
}
public void credit(BigDecimal amount) {
if (amount.compareTo(BigDecimal.ZERO) < 0) {
throw new InvalidAmountException(...);
}
this.balance = this.balance.add(amount);
}
}
领域模型 VirtualWallet 类添加了简单的冻结和透支逻辑之后,功能看起来就丰富了很多,代码也没那么单薄了。如果功能继续演进,我们可以增加更加细化的冻结策略、透支策略、支持钱包账号(VirtualWallet id 字段)自动生成的逻辑(不是通过构造函数经外部传入 ID,而是通过分布式 ID 生成算法来自动生成 ID)等等。VirtualWallet 类的业务逻辑会变得越来越复杂,也就很值得设计成充血模型了。
Domain 的职责和Service 类主要有下面这样几个职责的差别:
1.Service 类负责与 Repository 交流。在我的设计与代码实现中,VirtualWalletService 类负责与 Repository 层打交道,调用 Respository 类的方法,获取数据库中的数据,转化成领域模型 VirtualWallet,然后由领域模型 VirtualWallet 来完成业务逻辑,最后调用 Repository 类的方法,将数据存回数据库。
这里我再稍微解释一下,之所以让 VirtualWalletService 类与 Repository 打交道,而不是让领域模型 VirtualWallet 与 Repository 打交道,那是因为我们想保持领域模型的独立性,不与任何其他层的代码(Repository 层的代码)或开发框架(比如 Spring、MyBatis)耦合在一起,将流程性的代码逻辑(比如从 DB 中取数据、映射数据)与领域模型的业务逻辑解耦,让领域模型更加可复用。
2.Service 类负责跨领域模型的业务聚合功能。VirtualWalletService 类中的 transfer() 转账函数会涉及两个钱包的操作,因此这部分业务逻辑无法放到 VirtualWallet 类中,所以,我们暂且把转账业务放到 VirtualWalletService 类中了。当然,虽然功能演进,使得转账业务变得复杂起来之后,我们也可以将转账业务抽取出来,设计成一个独立的领域模型。
3.Service 类负责一些非功能性及与三方系统交互的工作。比如幂等、事务、发邮件、发消息、记录日志、调用其他系统的 RPC 接口等,都可以放到 Service 类中。
案例:接口鉴权
调用方进行接口请求的时候,将 URL、AppID、密码、时间戳拼接在一起,通过加密算法生成 token,并且将 token、AppID、时间戳拼接在 URL 中,一并发送到微服务端。
微服务端在接收到调用方的接口请求之后,从请求中拆解出 token、AppID、时间戳。
微服务端首先检查传递过来的时间戳跟当前时间,是否在 token 失效时间窗口内。如果已经超过失效时间,那就算接口调用鉴权失败,拒绝接口调用请求。
如果 token 验证没有过期失效,微服务端再从自己的存储中,取出 AppID 对应的密码,通过同样的 token 生成算法,生成另外一个 token,与调用方传递过来的 token 进行匹配;如果一致,则鉴权成功,允许接口调用,否则就拒绝接口调用。
这就是我们需求分析的整个思考过程,从最粗糙、最模糊的需求开始,通过“提出问题 - 解决问题”的方式,循序渐进地进行优化,最后得到一个足够清晰、可落地的需求描述。
注:
有个问题: 通过同样的 token 生成算法,在服务端生成新的 token,与调用方传递过来的 token 比对。这个做法是不是有点多余? 能把token解密难道不能说明token没有问题么?
token是单向加密算法生成的 无法解密的
面向对象设计
ApiAuthenticator作为功能的串联总入口
AuthToken,ApiRequest,CredentialsStorage
类和类之间的关系:
继承
关联:成员变量叫关联
组合:强关联关系,比如雄鹰和翅膀
聚合:弱关联关系,
依赖:成员方法的形参或局部变量叫依赖
23种设计模式
其实背后是7个设计原则,也就是说,每种设计模式都归属于一个或多个设计原则
7个设计原则背后又是一个字:分 --> 分就是,分离分开分裂,实现低耦合
分的方式
用接口分
工厂
配置文件
....
"设计原则和思想比设计模式更加普适和重要",被这句话一下子点醒了。 可以这样说,设计原则和思想是更高层次的理论和指导原则,不同设计模式只是这些理论和指导原则下,根据经验和不同场景,总结出来的一些针对特定场景的编程范式
实际上,设计原则和思想是心法,设计模式只是招式。 掌握心法以不变应万变无招胜有招。 所以,设计原则和思想比设计模式更加普适、重要。掌握了设计原则和思想,我们能更清楚地了解为什么要用某种设计模式,就能更恰到好处地应用设计模式,甚至我们还可以自己创造出来新的设计模式
单一职责
就是分,就是要把职责分开
一个方法只做一件事情,比如Math.sqrt
一个类只做一件事情,比如Reader类只用来读取字符
一个框架只做一件事情,比如springmvc只负责简化mvc模式的开发
里氏替换
任何能使用父类的地方,都可以透明的替换成子类对象
也就是说,子类可以随时随地替换父类,并且替换后,业务逻辑不会变化
里氏替换,防止了继承泛滥
依赖倒置
上层不应该直接依赖于下层
开闭原则
对扩展新功能开放
对修改原功能关闭
添加新功能应该是在已有代码的基础上扩展新的代码,而非修改已有代码的方式来完成
写出遵循开闭原则的代码,也可以理解成写出高扩展性的代码,23种设计模式大多也都是为了让程序更有扩展性而提出的
如何做到 “对扩展开放、修改关闭”?
我们要时刻具备扩展意识、抽象意识、封装意识。 在写代码的时候, 我们要多花点时间思考 一下, 这段代码未来可能有哪些需求变更, 如何设计代码结构, 事先留好扩展点, 以便在未 来需求变更的时候, 在不改动代码整体结构、 做到最小代码改动的情况下, 将新的代码灵活 地插入到扩展点上。 很多设计原则、 设计思想、 设计模式, 都是以提高代码的扩展性为最终目的的。 特别是 23 种经典设计模式, 大部分都是为了解决代码的扩展性问题而总结出来的, 都是以开闭原则为 指导原则的。
最常用来提高代码扩展性的方法有:多态、依赖注入、基于接口而非实现编程,以及大部分的设计模式(比如, 装饰、 策略、 模板、 职责链、 状态)
迪米特法则(最少知道原则)
讲的是封装,只和朋友通信
什么是朋友:本类中的成员变量,本类中方法的入参,本类中方法的返回值,本类中方法里实例化出来的对象
一个类,要对另一个类,知道的越少越好
不还有依赖的类,就不要有依赖。有依赖的类之间,尽量只依赖必要的接口。迪米特法则也叫最小接口原则,就是让类独立,提高内聚降低耦合
迪米特法则,也可以理解为“基于最小接口编程,而不是基于最大接口编程”
用户知道太多关于关机需要的细节,关机需要的步骤不好,如果用户不太了解,把这些步骤顺序搞反了,就更麻烦了
解决方案:
把细节步骤私有化,暴露一个大的上层的对外接口方法,给人直接调用,避免让人知道太多关机需要的细节。
接口隔离原则
使用多个专门的接口,比使用一个单一的总接口要好
避免一个总接口,造成子类实现其不需要的功能。也就是说,避免总接口中出现多种职责,出现职责耦合。有点类似于,接口的单一职责
分,应该把总接口分开,分成多个小接口,这样就可以灵活重组了,子类就可以有选择的实现某些子接口了
eg:
项目中的Dao,UserDao,BookDao等等,要分为多个Dao,而不是搞成一个大的Dao
接口隔离原则与单一职业原则:
两者都强调接口的设计,但是接口隔离原则的思考角度不同,它提供了一种判断接口职责是否单一的标准:通过调用者如何使用接口来间接判断,如果调用者只使用部分接口或接口的部分功能,那接口的设计职责就不够单一
组合优于继承
1. 为什么不推荐使用继承?
继承是面向对象的四大特性之一,用来表示类之间的 is-a 关系,可以解决代码复用的问题。虽然继承有诸多作用,但继承层次过深、过复杂,也会影响到代码的可维护性。在这种情况下,我们应该尽量少用,甚至不用继承。
2. 组合相比继承有哪些优势?
实际上,我们可以利用组合(composition)、接口、委托(delegation)三个技术手段,一块儿来解决刚刚继承存在的问题。
我们知道继承主要有三个作用:表示 is-a 关系,支持多态特性,代码复用。而这三个作用都可以通过其他技术手段来达成。比如 is-a 关系,我们可以通过组合和接口的 has-a 关系来替代;多态特性我们可以利用接口来实现;代码复用我们可以通过组合和委托来实现。所以,从理论上讲,通过组合、接口、委托三个技术手段,我们完全可以替换掉继承,在项目中不用或者少用继承关系,特别是一些复杂的继承关系。
3. 如何判断该用组合还是继承?
尽管我们鼓励多用组合少用继承,但组合也并不是完美的,继承也并非一无是处。从上面的例子来看,继承改写成组合意味着要做更细粒度的类的拆分。这也就意味着,我们要定义更多的类和接口。类和接口的增多也就或多或少地增加代码的复杂程度和维护成本。所以,在实际的项目开发中,我们还是要根据具体的情况,来具体选择该用继承还是组合。
如果类之间的继承结构稳定(不会轻易改变),继承层次比较浅(比如,最多有两层继承关系),继承关系不复杂,我们就可以大胆地使用继承。反之,系统越不稳定,继承层次很深,继承关系复杂,我们就尽量使用组合来替代继承。
除此之外,还有一些设计模式会固定使用继承或者组合。比如,装饰者模式(decorator pattern)、策略模式(strategy pattern)、组合模式(composite pattern)等都使用了组合关系,而模板模式(template pattern)使用了继承关系。
还有一些特殊的场景要求我们必须使用继承。如果你不能改变一个函数的入参类型,而入参又非接口,为了支持多态,只能采用继承来实现。比如下面这样一段代码,其中 FeignClient 是一个外部类,我们没有权限去修改这部分代码,但是我们希望能重写这个类在运行时执行的 encode() 函数。这个时候,我们只能采用继承来实现了
总结:
注:
依赖注入是一种具体的编程技巧,关注的是对象创建和类之间的关系,目的是为了提高代码的扩展性,我们可以灵活的替换依赖的类
基于接口而非实现编程,这是一种具体的设计原则,关注的是抽象实现,上下游的稳定性,目的是降低耦合,提高程序的扩展性
以上两者的联系是,都是基于开闭原则思路,来提高程序的扩展性
控制反转IOC这是一种编程思想,讲的是将程序的控制权交由程序员自己,还是交由第三方框架。依赖注入,则是一种具体典型的实现控制反转的方法。实现依赖注入的框架有很多,spring就是其中之一。
依赖注入的对象,要采用“基于接口而非实现”的原则,说白了就是要进行依赖反转。底层和高层都要依赖于抽象或者依赖于接口。
底层的实现要满足里氏替换原则,子类的可替换性,使得父类模块或者依赖于抽象的高层模块无须修改,就能实现程序的扩展性
以读取字符,字节为例子
字符流和字节流的区别:就在于读取时,是否去查码表,字符流读取是查码表的
那读取时,如何区分是中文字符还是英文字符:就看该字节的第一位是0还是1,如果是1则代表当前要读取的是一个中文字符,那么就把当前字节和当前字节的下一个字节作为一个整体一起读取,并查询gbk对应码表(utf8每个中文字符占用3个字节)
我们通过字节输出流,往一个1.txt中写一个97一个98数字时,用记事本打开会看到“ab”。往一个1.txt中写一个-97一个98数字时,用记事本打开会看到中文字“焍”。这是因为用记事本打开读取字节数据时,会去查码表,然后将对应的字符展示在屏幕上
Reader.read()方法,每次是读取一个字符,即看当前正在读取的字节的第一位是0还是1,如果是1则代表当前要读取的是一个中文字符,那么就把当前字节和当前字节的下一个字节作为一个整体一起读取,否则,就只读取当前字节本身。
byte m = -200;
输出m值会变成56
比如在记事本写一个“北”字,然后实际在记事本对应的硬盘上存的数字45489,然后java程序拿着45489查gbk码表知道是个北字,然后再拿着北字去查unicode码表得到数字21271,所以在屏幕上输出21271(无论底层用的什么码表,到了上层java,java都只认unicode编码)
可以看到,把北字转成int输出时,查的就是unicode的码表
举例:
输出“(char)65”,屏幕上就会打印出A,因为用char来进行解码转换时,是查的unicode码表。而65在unicode中对应的就是大写字母A
实现最简单的需求,统计字符数
实现,统计有多少单词
注意,要关闭流
统计有多少个句子
优点:
1. 代码重用性提高,将不变的部分抽出来
2. 代码可读性提高了,每个小方法只做一件事儿,然后用高层的大方法来组织各个小方法(此时的大方法,就像一本书的大纲目录一样,细节在小方法内部)
单一职责案例:
找出两个文件中的相同单词
利用求交集的api
如何将字符串数组,转成可读可写的list
通过继承类实现扩展功能,而不去修改原有类的功能
案例:API接口监控告警
核心是提供多种不同的Handler,每种Handler都有自己不同的告警check判断逻辑,不同的判断逻辑背后又对应着不同的告警通知渠道。最后,Alert类(也就是HandlerManager类)提供一个统一对外的大的check方法,该方法内部for循环遍历HandlerManager内部容器管理的所有Handler,依次执行每个Handler的具体check逻辑。以后,又有新的具体的check逻辑过来,只需要写一个新的Handler,并统一交给HandlerManager内部容器管理就好。通过这种方式,就实现了对扩展开放,对修改关闭(添加一个新的Handler,不会影响HandlerManager类提供一个统一对外的大的check方法)
上述的扩展思路,和Spring security的认证板块的总体设计非常类似,在Spring security的认证板块中,也可以同时为用户提供多种不同的认证方式,用户通过任何一种认证方式,都可以成功进入系统。在代码实现层面,也是提供了一个AuthenticationManager管理了所有不同的认证方式,AuthenticationManager是一个接口,这个接口对外也就是提供了一个统一的大的authenticate()方法
比如人,要喂狗,人类不能直接依赖于狗类,因为如果后面如果有新需求,出现要人喂猫,则代码就不满足了
此时代码违反了依赖倒置原则,导致每当下层变动,上层都要跟着变动(人调用狗的eat()方法喂狗,人是上层,狗是下层)
我们希望的是,当下层新增一个动物时,上层代码不用改动,此时就需要依赖倒置原则。
解决方案:新增一个Animal动物的接口,里面有一个eat方法,猫狗类都实现这个方法,人类只依赖Animal接口
反例
依赖倒置图
第一个图,所有箭头向下指。第二个图,所有箭头反过来向上指了 ,从而出现了倒置。
上层不依赖于下层,上层只依赖于抽象
下层也实现抽象,从而下层也依赖了抽象
还是一个分字,把上下层通过抽象接口分开,从而实现下层变化,上层的无感知!
案例:
老板开发一个项目,如果第一批人是三个会c sharp的,那先开发开发,然后三个人就跳槽了,还来了三个java的,那我这项目做不做,那三个java的又得把项目推倒重新开发一套。那如果三个java又走了,你换三个.net,这个项目又没法继续了,又得重新来一套。
这就不行,所以我们需要加一个中间抽象层,实现依赖倒置
设立一个20万的奖金池,让所有的开发者来实现这个主动找这个功能去实现,那我老板就不用管了,我就不用直接去和这些开发者打交道,我只用把这20万的奖金发出去。让他们来自己实现这个功能,谁实现好了,把这个钱给他就好了
构造代码块,总是随着构造方法的执行而执行,并且先于构造方法执行
引起类加载的四种方式:调用构造方法,Class.fotName,调静态字段,调静态方法
注意这个题目的执行顺序,即类的加载和实际话揉杂在一起时的情景
案例
方法重写的两条限制,是因为为了满足里氏替换原则
在计算面积的场景下,正方形能换成长方形,这种场景下,正方形就能继承长方形
反例,就是那个正方形不是长方形的案例
里氏替换与多态的差别:
从代码实现角度来看,两者是类似的。但是两者的关注角度不同,多态是面向对象编程的一种代码实现思路,而里氏替换原则,是一种设计原则,用来指导继承关系中的子类该如何设计
子类不能违背父类要实现的功能,子类不能违背父类对输入、输出、异常的约定,子类不能违背父类方法注释中描述的特殊说明
案例1
想统计所有曾经出现的元素,原生Set接口有add方法,我们重写了add方法。但是,Set集合添加元素的入口不止add方法一个,还有比如addAll()方法,如果我们没有同时重写addAll()方法,就会造成统计遗漏
这种,需要类的使用者熟读api文档,
使用继承时的禁忌
Stack为了想要复用Vector的add delete功能,而继承了Vector,但是Vector里面还有get remove方法(Stack本来只需要Vector的先进后出或者后进先出功能,但是它一继承Vector,那么就同时继承了Vector的随机访问,随机删除,这样就违反了栈的最基本后进先出特性)
栈本应该只有push pop两个功能,就是因为作者手贱继承了Vector,导致需要的不需要的方法,都继承了下来,
如果使用组合,那么就不会把父类的方法全盘接收。
所以现在,没有人用Stack这个类,因为它暴露了很多栈不应该有的功能
案例2
增加一个层抽象层
此时,底层CRUD变了,只需要修改右边的new A还是new B
如果我们,连new A还是new B都不想改,那么:可以使用工厂+配置文件
总结:
如果父类是别人写的,你不太懂里面全部的逻辑,而想复用该类的一个功能,这时你想复用不要用继承,用组合
父类是别人写的,那么父类作者不知道客户端程序中未来会重写自己的什么方法,这样就可能导致父类中依赖于被重写的方法的那些方法的功能发生崩塌(父类中方法A依赖于B,B依赖于C,如果子类重写了C,在子类中调用A,B可能就都会出问题)
子类作者也不知道,父类作者在未来的版本里会改写或者添加什么新方法(子类现在继承父类写一个方法D依赖于C,如果父类在以后的某个版本把C的功能改了,那子类的D方法也可能出问题)
对于如何写注释,需要写明三点:做什么 what,为什么做 why,怎么做 how
函数的设计要单一职责,一个函数多职责要拆分
防止嵌套过深,应尽可能用continue、break、return提前退出嵌套。把一系列的检验放在最前面,检验不通过尽早return
消除魔法数字,定义见名知意的常量
如何发现代码质量问题?
包括常规checkList和业务需求checkList
见《王争设计模式》34讲
函数执行出错时的4种返回方式:错误码,null,空对象,异常对象
针对程序中发生的异常的三种处理方式:直接吞,原封不动的抛,包装成上层易理解的异常后再抛
见《王争设计模式》36讲
异常什么时候直接返回,什么时候吃下,什么时候包装后再返回
见《王争设计模式》37讲
最常用的提高代码扩展性的方法有:多态、依赖注入、基于接口而非实现编程、以及大部分设计模式(比如,装饰、策略、模板、责任链、状态)
实现代码的扩展,也就是实现开闭原则,这两者基本可以划等号
多态、依赖注入、基于接口而非实现编程、以及前面提到的抽象意识,实际上说的都是同一种设计思想,只是从不同角度不同层面来阐述而已,这也体现了“很多设计原则、思想、模式都是相通的”
案例:消息队列
业务系统中消息发送板块,就可以学spring cloud 一样,抽象出一套统一的MessageQueue接口,该接口提供统一的send方法。客户端程序针对MessageQueue接口进行编程,通过多态、依赖注入的方式,注入RocketMQ、Kafka等多种不同的消息队列实现,从而就实现了客户端程序的高扩展性,不依赖某一具体的消息中间件,通过外界不同的依赖注入,就能无缝切换
学习设计模式,一定要时时刻刻,在脑子里记住下面的两点:
集中总结一下提高代码复用性的7条手段:
减少代码耦合
满足单一职责原则
模块化
业务与非业务模块分离
通用代码下沉
封装、抽象:继承、多态
应用模板等设计模式
实际上,还有一些跟编程语言有关的,比如泛型
MVC开发模式
为什么要分mvc三层开发模式,见极客时间设计模式24讲
实际上,程序模块化、分层一个是横向一个是纵向,本质都还是为了实现单一职责。模块化实质就是分而治之
封装与抽象:隐藏复杂、隔离变化
中间层
模块化
其他设计思想与原则:单一职责、基于接口而不是实现编程、依赖注入、多用组合少用继承、迪米特法则
注:
继承中,父子类是强依赖关系,父类修改直接影响子类
创建者模式
结构型模式
行为型模式
设计模式要干的事情就是解耦,也就是利用更好的代码结构将一大坨代码拆分成职责更单一的小类,让其满足高内聚低耦合等特性
创建型模式是将对象的创建和使用解耦, 结构型模式是将不同的功能代码解耦, 行为型模式是将不同的行为代码解耦。 而解耦的主要目的是应对代码的复杂性。设计模式就是为了解决复杂代码问题而产生的
主要用于处理对象的创建问题,封装复杂对象的创建过程,将对象的创建代码和对象的使用代码解耦
具体4种模式的作用概述,见王争《设计模式》48
如何用单例模式,处理资源访问冲突,实现自己的Logger类
学习饿汉式单例模式的好处,总体就是要提前做好准备、或者提前暴露问题
见《王争设计模式》36讲
重点掌握: 简单工厂 + 配置文件的实现方式
也就是ioc的最基本实现原理
案例
Jdk源码的Collection.iterator()方法,就使用了工厂方法模式
简单工厂的作用就是把具体产品的类名,从客户端程序员中,解耦出来
有面向接口编程的思想,才有了我们的简单工厂模式
源头:为何要使用工厂
面向接口编程示意图
依赖于抽象不依赖于具体实现,是依赖倒置原则
上述代码,违反了面向接口变成这一点,客户端代码除了和接口交互外,还和接口的具体实现类耦合在了一起,所以我们要把具体接口实现类剥离出去,让客户端代码中,看不到下层接口的具体实现类
针对上面的问题,引入简单工厂模式,让服务器代码自己提供简单工厂,避免客户端代码自己去new接口的具体实现类的实例(客户端,不用再去关心具体产品对应的类名)
以后,服务器端具体产品的类名变化,客户端代码也无感知
我们需要,想办法把客户端自己创建的凉皮实例,融入到工厂中去,但是工厂类在服务端jar包中,我们也改不了它的代码
当作者在服务端创建了几个具体产品和具体产品工厂不够用了,而我们客户端程序员又需要自己添加新的产品时,简单工厂不满足需求了,我们就使用工厂方法模式
工厂方法,就是给每个具体产品,都有一个对应的具体工厂
我们在客户端代码中,自己添加凉皮和凉皮工厂,来融入服务端工厂方法体系,还不需要修改服务端的工厂方法代码,完美的符合了开闭原则。
这个是,服务端作者自己写的,与工厂方法想配套的业务逻辑,如果我们想融入这个体系,就要自己创建凉皮,和凉皮工厂
如果我们,想让Business.taste方法接收Food接口的具体实际,那就又回到了最开始的问题,当服务端作者改了具体产品的类名,而客户端使用了该具体类名,那当服务端作者改了具体对象类名时,客户端代码又要同步修改,就又造成了耦合
类图:
扩展新产品时
当要生产的产品大类型太多时,使用工厂方法,就会产生很多的类
食物大类,有一个细分的工厂FoodFactory,饮料大类有一个细分的工厂DrinkFactory
将FoodFactory,和DrinkFactory合并为一个大接口
类图
相比于工厂方法的类图,抽象工厂也就是继续抽象了一把,把多个细分的工厂又进行了一次抽象,抽象成为一个更高层次的聚合工厂,从而工厂类的数量就大大减少了
一个工厂中只生产一个产品簇,就是工厂方法
一个工厂中生产多个产品簇,那就是抽象工厂
简单工厂的优点:将具体产品类名从客户端程序中解耦
工厂方法的优点:提高了程序的扩展性,可以在客户端扩展新的产品
小结:
1. 产品以后都不扩充了,就使用简单工厂
2. 产品以后经常要扩充,就用工厂方法
3. 产品等级很多,就用抽象工厂(如果产品等级经常变化,就不用抽象工厂)
所以,产品簇多时才考虑使用抽象工厂,只有一个产品簇时,那么就直接用工厂方法
6mm是一个产品簇,8mm是另一个产品簇
螺丝和螺母,是不同的产品等级
产品簇中的产品都是有内在联系的,比如多个电器都是美的的
这里会有一个抽象工厂类,5个抽象工厂的实现类:分别是腾讯工厂,华为工厂,美的工厂,海尔工厂,格力工厂。也即是腾讯工厂,就只放腾讯的产品簇
可以简单理解为:产品等级就是具体产品实体,产品簇就是对具体产品的皮肤修饰,产品品牌其实也就可以理解为该产品的皮肤
注:
掌握何时使用简单工厂,何时使用工厂方法,抽象工厂生产很少使用
当要创建的对象是一个大工程时,比如要创建的对象,依赖别的许多类,或者要创建的对象有很复杂的初始化逻辑,或者要创建对象的过程内部有很多的if else,这个时候,就需要使用工厂模式,来将对象的创建过程,和对象的使用过程进行解耦
掌握反射的执行原理,为什么需要反射?见王争《设计模式》45
习题:用LinkedList实现一个栈
LinkedList的remove()和poll()方法,都会删除并返回当前元素,区别在于:
remove()删到没有元素时,会抛异常;而poll()删到没有元素时会返回null;
Stack
如果只是为了重用,那么就使用组合,如果想使用多态,才考虑使用继承
习题:会话的开始和结束时间
初学者:从打开一个网站的任意一个jsp页面开始,到关闭这个网站的所有页面为止
真正的情况:
从调用request.getSession(true)开始,因为打开jsp页面就会调用request.getSession(true)
过了session的闲置时间,并且清理线程发现该闲置session并清理掉它,该session才算结束
每次需要新对象,但是每次只有一少部分字段的值在变,其他大部分不变,就用克隆,省得每次new一个新对象,都要重新赋一遍值。
修改为:
注意:这么做的前提是,WeekReport类要实现Cloneable接口,并覆写clone(),还要将修饰符改为public
wr.clone(),不会调用构造方法,而是直接复制内存中的二进制,所以效率较构造方法更高;
普通方式
通过第67行代码,使得wr和wr2各自拥有一个Date()对象,这两个对象拥有不同的地址空间,但是这两个对象中的日期值确实一模一样的,因为是直接通过克隆出来的;
深拷贝:序列化 + 反序列化
InputStream只能读取字节,不能直接读取对象,如果要想直接读取对象,就需要包装成为ObjectInputStream
深拷贝:通过往内存中序列化+反序列化(推荐写法)
ByteArrayOutputStream是往内存中写的流,
创建对象的四种方式
1. 调用构造器new对象
2. 类反射
3. 序列化
4. 克隆
克隆方法,是否会破坏单例模式
会破坏,写一个单例模式,然后用原型把单例对象克隆一下,会发现两个对象的地址不一样
反序列化,是否会破坏单例模式
会破坏,道理同上
注:
把数据放入HashMap,实际上就是在给数据建立hash索引
原型模式的应用场景
以关键词搜索识别模块的更新,与消除模块不可用时间为例,讲述何时需要使用到原型模式(如何最快速的clone出一个HashMap散列表)
见王争《设计模式》47讲
建造者模式有两种实现方式,一种是单独Builder类、另一种是将Builder实现为原始类的内部类
应用一:构建ofo单车,和摩拜单车的案例
应用二:学习如何可以通过链式编程的方式,创建一个对象并完成对象各字段属性的初始化, 解决构造函数入参,超过四五个的尴尬局面
i7 8750u,u结尾的就是低压版,功耗低,所以拿它打游戏是带不起来的;i5 8750hq,hq打游戏都比i7 8750u强
违反了迪米特法则,也就是违反了最少知道原则,客户端程序员需要去知道类的每个字段,并逐一给他们赋值
将来,如果建造一个对象需要几百行代码,那么这么封装的价值就体现出来了;
创建这个接口的作用:稳定创建过程,让任何一个创建过程都不会被遗漏
我们的最理想是,客户端不需要自己来“指挥”;
创建这个指挥者接口作用:把“指挥”创建的过程,从客户端分离出来。客户端以后就只用调用指挥者就好。这里把装机的整个过程,都封装了起来。
易于客户端自己扩展新的XxxComputerbuilder
将一个复杂的构建,与其表示相分离,使得相同的构建过程,可以创建出不同的表示。这里的表示,就是电脑的,高,中,低,中高配等等都是不同的表示;
适用于组装过程稳定,但是各个部件的细节会经常变化的场景;
上面也是一种依赖倒置,以后再增加具体的XxxComputerBuilder,都不需要改上层的代码
建造者模式,就是在Builder类中定义一份完全和要建造的对象一样的参数列表,然后把所有的参数值先set到Builder类中,并在Builder类中完成设置进来的这些参数的校验,校验都没问题后,再一次性设置到原对象中去,并把设置好的原对象,通过build()方法返回出来。
掌握,何时使用建造者模式
掌握,建造者模式,和工厂模式创建对象的区别
见《设计模式》46讲
主要研究的是“类和类组合或组装”问题,也就是总结了一些类或对象组合在一起的经典结构,这些经典的结构可以解决特定应用场景下的问题。
主要有:装饰器模式、适配器模式、桥接模式、组合模式、外观模式、享元模式、代理模式
Java IO库的众多类,通过装饰器模式组织起来,Java IO库的众多类如果是通过继承的方式组织起来,那就会出现类爆炸,这也是为什么不推荐使用继承的一个典型
IO流使用的是装饰者模式,可以层层增加功能,这就是装饰器的一大特点,装饰器类继承和原始类一样的父类,从而使得原始类可以进行无限的叠加装饰增强类
参考《head first设计模式》
Beverage是饮料的意思;
子类如果不显示写构造器,会有一个默认的无参构造器,并且里面默认会有一句super(),来调用父类的无参构造方法,此时,如果父类没有无参构造方法,编译就会报错;
解决方案1:
装饰器模式来解决问题
新增一个调料类
Condiment是调料的意思
装饰器模式的最重要特点:就是既继承了Beverage,同时又关联了Beverage
调料类要想去装饰饮料,那么调料既继承了饮料类,又关联了饮料类
这里的继承,违反了is a关系,不过特殊情况特殊对待;
继承了Beverage的目的是为了,能够继续套娃
关联了Beverage的目的是为了,能够调用被我套住的娃
再套一层
装饰器模式调用图解
使用上面的boolean值的方式,牛奶是不能加双份的
装饰器核心点是:
调料类Condiment类,既继承了Beverage饮料类,又关联了Beverage饮料类;
第20行的功能,就是一次从硬盘就读1个字节到内存
第21行加上后,就是一次能读多个字节到内存
第22行加上后,InputStreamReader就是字节到字符流的桥梁,既然是字符流读取那么就要查码表,所以我们要指定码表;
这里的InputStream就是前面的Beverage饮料类,
FilterInputStream就是前面的Comdiment调料类;
readLine()和getLineNumber()分开了,体现单一职责
一般来说,适配器模式,可以看作是一种“补偿模式”,用于补救设计上的缺陷,应用这个模式算是一种“无奈之举”
适配器模式的最典型案例,就是SpringMVC的HandlerAdapter处理器适配器,DispatcherServlet的dispatch分发请求流程,为了应对各式各样类型的Handler搞出了个处理器适配器,让上层分发流程,面向接口编程,也就是面向处理器适配器编程,从而通过处理器适配器,来屏蔽底层不同类型的处理器的差异
因为使用继承,可能会出现下面的情况
那么此时,依赖于父类的三个参数的add方法的那些别的方法,就垮了;
如果就为了复用,那么就是用组合,-- > 组合优于继承原则;
案例二:
节选自《java编程思想》第9章接口的9.3节:完全解耦
用的适配器模式,来解释的完全解耦。但是初学者学习完全解耦不适合用适配器设计模式。
一个方法的参数如果是类而不是接口,那么这个参数就只能接这个类或者这个类的子类。如果你要传一个不是这个类的子类,那么就会报错。如果这个方法的参数是接口,就可以很大程序上放宽这种限制,因此,使用接口可以使我们编写可复用性更好的代码。
比如一个方法m的参数是接口A,那么实现了A接口的子实现类都可以传进方法m。如果此时有一个继承体系,该继承体系的顶层父类是Filter类,若我们想让Filter体系也能传入方法m中,有两种方式都能达到此目的:
案例:
class Processor {
public String name(){
return this.getClass().getSimpleName();
}
public Object process(Object input){
return input;
}
}
class UpCase extends Processor{
public String process(Object input){
return ((String)input).toUpperCase();
}
}
class DownCase extends Processor{
public String process(Object input){
return ((String)input).toLowerCase();
}
}
class Splitter extends Processor{
public String process(Object input){
return Arrays.toString(((String)input).split(" "));
}
}
public class AppTest {
public static void main(String[] args) throws IOException {
String str = "How are U";
Processor p = new DownCase();
System.out.println("p.name() = " + p.name());
System.out.println(p.process(str));
Processor p1 = new UpCase();
System.out.println("p.name() = " + p.name());
System.out.println(p.process(str));
Processor p2 = new Splitter();
System.out.println("p.name() = " + p.name());
System.out.println(p.process(str));
}
}
这里闻到了坏代码的味道,因为看到了代码在重复;
抽取Apply公共方法,来消除重复代码
class Apply {
public static void process(Processor p , Object o){
System.out.println("p.name() = " + p.name());
System.out.println(p.process(o));
}
}
public class AppTest {
public static void main(String[] args) throws IOException {
String str = "How are U";
Apply.process(new DownCase(),str);
Apply.process(new UpCase(),str);
Apply.process(new Splitter(),str);
}
}
Apply.process()方法可以接收Processor的所有子实现类,插一句:像本例这样,创建一个能够根据所传递的参数对象的不同,而具有不同行为的方法,被称为策略设计模式。这类方法包含所要执行的算法中固定不变的部分,而“策略”包含了变化的部分,策略在这里就是传进方法里的对象。所以这里Processor子实现类对象就是一个个不同的策略。在上面的main方法中,可以看到三种不同的策略应用在了相同的String类型的对象str上面。
继续,
假如现在我们发现了一个别人写的类库,这个类库是电子滤波器相关的功能,它们看起来好像适用于Apply.process()方法:
class WaveForm {
// 这是id自增长的逻辑
private static long counter;
private final long id = counter++;
@Override
public String toString() {
return "WaveForm{" + "id=" + id + '}';
}
}
class Filter {
public String name(){
return this.getClass().getSimpleName();
}
public WaveForm process (WaveForm waveForm ){
return waveForm;
}
}
class LowPass extends Filter{
double cutoff;
LowPass(double cutoff){
this.cutoff = cutoff;
}
public WaveForm process (WaveForm waveForm ){
return waveForm; // dummy process
}
}
class HighPass extends Filter{
double highCutoff;
HighPass(double highCutoff){
this.highCutoff = highCutoff;
}
public WaveForm process (WaveForm waveForm ){
return waveForm; // dummy process
}
}
class BandPass extends Filter{
double highCutoff;
double lowCutoff;
BandPass(double highCutoff , double lowCutoff){
this.highCutoff = highCutoff;
this.lowCutoff = lowCutoff;
}
public WaveForm process (WaveForm waveForm ){
return waveForm; // dummy process
}
}
// -------------------------------------------------------------
class Apply {
public static void process(Processor p , Object o){
System.out.println("p.name() = " + p.name());
System.out.println(p.process(o));
}
}
public class AppTest {
public static void main(String[] args) throws IOException {
WaveForm waveForm = new WaveForm();
Filter f = new LowPass(1);
System.out.println("f = " + f.name());
System.out.println(f.process(waveForm));
Filter f1 = new HighPass(5);
System.out.println("f = " + f.name());
System.out.println(f.process(waveForm));
Filter f2 = new BandPass(1,5);
System.out.println("f = " + f.name());
System.out.println(f.process(waveForm));
}
}
Filter和Processor具有相同的方法,但是因为Filter并非继承自Processor,因为Filter的作者根本不知道你想把Filter用作Processor,因此你不能把将Filter应用于Apply.process()方法。
但是,上面又出现了坏代码的味道,又是重复的代码,重复的代码逻辑还和Apply.process()方法中的逻辑很类似,但是Filter却传不进Apply.process()方法中。
这里主要是因为Apply.process()方法和Processor之间耦合过紧,已经超过所需要的程度,这使得本应该复用Apply.process()方法的代码,复用却被限制了。
但是,如果这里的Processor是接口而不是类,那么这些限制就会变得松动,使得我们可以复用Apply.process()方法的代码,来消除上面又出现的重复坏代码。
interface Processor {
public String name();
public Object process(Object input);
}
class UpCase implements Processor {
public String name() {
return this.getClass().getSimpleName();
}
public String process(Object input) {
return ((String) input).toUpperCase();
}
}
class DownCase implements Processor {
public String name() {
return this.getClass().getSimpleName();
}
public String process(Object input) {
return ((String) input).toLowerCase();
}
}
class Splitter implements Processor{
public String name() {
return this.getClass().getSimpleName();
}
public String process(Object input) {
return Arrays.toString(((String) input).split(" "));
}
}
可以看到,三个不同实现类中,name()方法的代码,都出现了重复,所以又要做共性的上提:
interface Processor {
public String name();
public Object process(Object input);
}
abstract class StringProcessor {
public String name() {
return this.getClass().getSimpleName();
}
}
class UpCase extends StringProcessor {
public String process(Object input) {
return ((String) input).toUpperCase();
}
}
class DownCase extends StringProcessor {
public String process(Object input) {
return ((String) input).toLowerCase();
}
}
class Splitter extends StringProcessor{
public String process(Object input) {
return Arrays.toString(((String) input).split(" "));
}
}
复用代码的第一种方式,是让客户端程序员遵循该Processor接口,来编写他们自己的类。在这里就是要让Filter主动实现Processor接口,但是我们没有Filter的源代码,也就无法修改你想要使用的Filter类。例如,在电子滤波器的例子中,类库是被发现而不是被创建。在这种情况下,就可以使用适配器设计模式。适配器中的代码将接收你所拥有的接口,并产生i锁需要的接口。
class WaveForm {
// 这是id自增长的逻辑
private static long counter;
private final long id = counter++;
@Override
public String toString() {
return "WaveForm{" + "id=" + id + '}';
}
}
class Filter {
public String name() {
return this.getClass().getSimpleName();
}
public WaveForm process(WaveForm waveForm) {
return waveForm;
}
}
class LowPass extends Filter {
double cutoff;
LowPass(double cutoff) {
this.cutoff = cutoff;
}
public WaveForm process(WaveForm waveForm) {
return waveForm; // dummy process
}
}
class HighPass extends Filter {
double highCutoff;
HighPass(double highCutoff) {
this.highCutoff = highCutoff;
}
public WaveForm process(WaveForm waveForm) {
return waveForm; // dummy process
}
}
class BandPass extends Filter {
double highCutoff;
double lowCutoff;
BandPass(double highCutoff, double lowCutoff) {
this.highCutoff = highCutoff;
this.lowCutoff = lowCutoff;
}
public WaveForm process(WaveForm waveForm) {
return waveForm; // dummy process
}
}
// -------------------------------------------------------------
interface Processor {
public String name();
public Object process(Object input);
}
abstract class StringProcessor {
public String name() {
return this.getClass().getSimpleName();
}
}
class UpCase extends StringProcessor {
public String process(Object input) {
return ((String) input).toUpperCase();
}
}
class DownCase extends StringProcessor {
public String process(Object input) {
return ((String) input).toLowerCase();
}
}
class Splitter extends StringProcessor{
public String process(Object input) {
return Arrays.toString(((String) input).split(" "));
}
}
class FilterAdapter implements Processor{
// 适配器,就是给套一层马甲
private final Filter filter;
FilterAdapter(Filter filter){
this.filter = filter;
}
@Override
public String name() {
return filter.name();
}
@Override
public Object process(Object input) {
return filter.process((WaveForm) input);
}
}
class Apply {
public static void process(Processor p, Object o) {
System.out.println("p.name() = " + p.name());
System.out.println(p.process(o));
}
}
public class AppTest {
public static void main(String[] args) throws IOException {
WaveForm waveForm = new WaveForm();
Apply.process(new FilterAdapter(new LowPass(1)),waveForm);
Apply.process(new FilterAdapter(new HighPass(5)),waveForm);
Apply.process(new FilterAdapter(new BandPass(1, 5)),waveForm);
}
}
这样,Filter本来是进不去Apply.process()方法的,但是通过给Filter套了一层马甲,就让Filter成功复用了Apply.process()方法。
在这里,通过适配器设计模式,FilterAdapter的构造器接收你所拥有的Filter接口,然后产生你需要的Processor接口的对象。你可能还主要到了FilterAdapter中使用了代理模式,代理了Filter。
将接口从具体实现中解耦,使得接口可以应用于多种不同的具体实现,因此代码就更具有复用性。就比如这里的Apply.process()方法的复用性就进一步提高了。
适配器模式的应用场景是处理“接口不兼容”,那有下面几种情况,会引起接口不兼容:
封装有缺陷的接口设计
统一多个类的接口设计(SpringMVC的处理器适配器,就是这个应用场景)
替换依赖的外部系统
兼容老版本的接口
适配不同格式的数据
适配器模式,在Java日志框架中的应用
以后,所有的业务系统编写代码,都面向slf4提供的上层接口进行编程,并且slf4自身的包内,还贴心的为每个第三档日志组件都提供了与之唯一对应的适配器。这样,上层业务代码就稳如泰山,以后下层任意更换别的第三方日志组件,都有对应的适配器作为兜底,不至于影响到上层业务代码
个人建议使用slf4j的而不是直接使用 log4j, commons logging, logback 或者 java.util.logging,因为这样可以让你的程序适具有更多的扩展性。
在你的开源或内部类库中使用slf4j,会使得它独立于任何一个特定的日志实现,这意味着不需要管理多个日志配置或者多个日志类库,以后别人调用你的工具包时也可以不用关心日志组件问题,你的类库是基于slf4j开发的,这样你的类库被别的系统引入后,也能很快融入对方系统,slf4j会通过对方使用的底层日志框架对应的适配器,去调用对方的底层日志实现,从而输出日志
见王争《设计模式》51
用来解决接口易用性的问题
用来解决接口性能问题
接口设计的过大过小都不合适,需要设置合适的接口
门面模式和迪米特法则联系
门面模式和封装抽象的联系
见王争《设计模式》52
主要用于,在不改变原有类的情况下,给原有类附加新功能
什么时候是非动态代理不可,静态代理搞不定的?
静态代理,需要每来一个新业务类,就需要维护一个相应的静态代理类,就会造成静态代理类的类数量爆炸问题,此时,就可以用动态代理来解决这个问题
静态代理和动态代理主要有以下几点区别:
- 静态代理只能通过手动完成代理操作,如果被代理类增加了新的方法,则代理类需要同步增加,违背开闭原则。
- 动态代理采用在运行时动态生成代码的方式,取消了对被代理类的扩展限制,遵循开闭原则。
- 若动态代理要对目标类的增强逻辑进行扩展,结合策略模式,只需要新增策略类便可完成,无需修改代理类的代码。
弄一个类,继承目标类的缺点
method.invoke(target,args)利用反射,调用真实的目标对象的真实业务方法
总之,就记住,对代理对象的方法调用,都统统会转进调用处理器的invoke方法中;
这也就是,springmvc的拦截器的实现原理,拦截器就是动态代理 + 反射机制;
优点还有:目标类无感知,我们通过代理类增强了目标类后
小小封装一下,简化动态代理的代码
封装方式二,静态方法
上面代码,违反了单一职责原则
把加,减,除三个职责都放在了before一个方法里面
这里的层层套娃,就可以理解成为:“代理模式 + 策略模式 + 装饰器模式”的组合
代理模式生成的代理对象既实现了ICalc接口,也拥有了target目标对象的引用,这也就符合装饰器模式的两大要求了:调料类既继承了饮料类,又持有饮料类的实例引用;
结论:
即只要想对哪个类进行层层包装的,就要满足:既继承该类,又同时持有该类的实例引用
有点像代理链,每一层代理都是单一职责,每一层代理只管一件事儿
SpringMVC/Struts2的拦截器的实现原理就是,配置多层拦截器(多层代理),每层拦截器只做一件事儿
三层拦截器
再次封装
拦截器的集合,用配置文件来实现
现在,我们说白了,就是在给new出来的真实的CalcImpl对象加多个切面(一个切面也就可以理解为一层拦截器)
我们把最后这一点Test代码,也简化掉,直接用注解的方式,给真实的CalcImpl对象加切面,这也就是Spring aop的实现原理了;
我们在bean的生命周期中,检查 CalcImpl这个类有没有切面逻辑,如果有切面逻辑,那么就给它织入切面逻辑,形成动态代理对象,最后加入到ioc容器的是包裹了真实业务逻辑的动态代理对象了;
代理对象,具有对目标对象的访问权限控制
让代理对象实现与目标对象相同的接口,仅仅是为了让代理对象拥有和目标对象一样的方法,能够让客户端无差别的进行调用;
而让
可以在before通知中,通过session中是否有用户信息,来判断用户是否登录,来决定是否调用真实的目标对象的目标方法;
public boolean before(Method method,Object ages){
String user = request.getSession.getAttribute("user");
if (user == null || user.length() == 0){
return false;
}
return true;
}
springmvc的拦截器,是有传入HttpServletRequest对象的;
单点登录的三种实现方式:
1. session广播机制
2. cookie + redis
3. cookie + token
参考参见: 单点登录的三种方式_Liu风的博客-CSDN博客_单点登录
作业:
用动态代理 + 注解的实现方式,实现一个AOP缓存
jdk动态代理,仅仅是一个API,借助这个API可以实现动态代理模式而已;
让代理对象实现与目标对象相同的接口,仅仅是为了让代理对象拥有和目标对象一样的方法,能够让客户端无差别的进行调用;
而让代理对象拥有和目标对象一样的方法,不仅仅是实现相同的方法,通过继承也同样能达到目的;
这种也是静态代理模式
实际开发中,使用动态代理还是静态代理,还是要看开发的感觉经验;
springmvc的拦截器,spring aop,Mybatis生成Mapper的实现类也是动态代理模式
静态代理有两个缺点:有很多重复代码、需要为每个目标类都创建静态代理类。动态代理,就完美的解决了上面静态代理的两个缺点
代理模式,常用在业务系统中无侵入性的开发一些非业务功能性的需求,比如,监控、统计、鉴权、限流、事务、幂等、日志,可以将这些附加功能与业务功能解耦,放在代理类中统一处理,让程序员只需要专注于业务开发。代理模式,还可以用于RPC、缓存等场景
注:
接口性能统计计数器,与aop动态代理融合的案例
见王争《设计模式》48
他们的共同点:它们都是对象的组合
如果你想让目标对象和接收对象进行契合,那么就使用适配器模式做适配;适配器的案例,就参考Springmvc的处理器适配器
如果你想对目标对象的执行进行控制,那么就使用代理模式;
相同点
装饰器类和代理类,都持有目标对象的引用,也就是都是组合关系
不同点
代理有一方面是,强调对目标对象的控制
装饰器更多的强调给目标对象的功能进行增强。代理模式中,代理类增加的是与原始类无关的功能、而在装饰器设计模式中,装饰器类附加的是和原始类相关的增强功能(注意,代理模式是增加、装饰器模式是增强)
实际上,符合“组合关系”这种代码结构的设计模式有很多,比如代理模式、装饰器模式、桥接模式,尽管它们代码结构相同,但是每种设计模式的意图是不同的
习题:
给invoke的上一行,加一个map(map还是在调用处理器内部)
JDBC标准和各个数据库厂商的驱动实现,就是桥接模式的体现
桥接模式的案例:不同类型不同渠道的推送系统
王争《设计模式》49
主要用于处理树形结构的数据
比如文件系统,OA系统
享元,就是共享的单元,前提是不可变对象
享元模式的目的,就是为了共享对象,从而复用对象,节省内存
注意享元模式,和单例模式、缓存、对象池的区别
见王争《设计模式》54
享元模式在Integer和String类中的应用
描述类或对象怎样进行交互和职责分配
行为型设计模式有11个,
设计模式本质要做的事情就是解耦,创建型模式就是将对象的创建和使用解耦、结构型模式就是将不同功能的代码解耦、行为型模式就是将不同行为的代码解耦。具体到观察者模式,它是将观察者和被观察者解耦
借助于设计模式,我们利用更好的代码结构,将一大坨代码拆分成职责更单一的小类,让其满足开闭原则、高内聚低耦合的特性,以此来控制和应对代码的复杂性,提高代码的可扩展性
策略模式,很大的一个优点,运行时能进行动态的替换策略
比如,飞机大战,飞机吃一个道具,射击行为就从单发变成双发,再吃一个道具,就从双发变成了三发设计模式,这些都是在程序运行时动态地修改了射击行为
一提到策略模式,大家首先想到的就是利用它可以消除业务代码中的多if-else,这种理解是片面的,其实策略模式最主要的作用,还是为了将策略的定义、策略的创建、策略的使用三者解耦,从而控制代码的复杂度,让每一部分代码都不至于太过复杂、代码量过多
对于复杂代码,策略模式能让它最大程度的满足开闭原则,添加新策略时,只需要最小化,集中化的代码改动,减少bug产生的概率
策略的创建,就通过策略的工厂类,在工厂类中通过查表法存储着所有的策略实现
策略模式的一大功能,消除业务代码中的多if-else-switch,就是通过策略的工厂类,在工厂类中的查表存储结构
案例:
参考《head first设计模式》策略模式
直接改父类,这会影响整个继承体系(影响所有的子类),所以改父类是很危险的,要慎重
我们可以复写RubberDuck的fly()方法,让里面throws 一个异常,从而让它不能飞
此时,fly()方法是没有重用性的,所有能飞的鸭子,都要实现Flyable接口的fly()方法。如果又8个鸭子不会飞,另外有40个鸭子都会飞,那么你的fly()方法,就要在40个类中写40次;
上面的第一种使用继承的方式,至少还有一部分重用性,虽然都要进行判断鸭子会不会飞,会不会叫;
和上面不同的是,上面的那种方式是让鸭子直接实现飞行接口,现在是单独的不同的飞行行为来实现飞行接口,鸭子只是负责来组合不同的飞行行为,这就是策略模式和普通接口拆分的区别;
策略模式:就是将复杂的行为种类单独抽取出来,做成一个继承体系,再以组合的形式加入产品中;
策略模式的精髓就抓住这一点:如果你把performFly()方法内部的飞行方式写死,那么你在运行时就无法再动态的替换这个类的对象的飞行方式了,单式,使用策略模式,这里运行时就能动态的替换不同的飞行策略。
performFly()方法内部要干什么,要执行什么飞行方式,不要写死。performFly()方法要干什么要借助于FlyBehavior接口,接口接受的子实现类在运行时是可以动态替换的,这就叫运行时改变行为。
这样就实现了飞行行为代码的重用,
关键的不同是:原来是5个鸭子子类分别实现Flyable接口,那么用翅膀飞的飞行方式的代码,就要在5个鸭子子类中重复写5次。所以,别再让鸭子直接实现这个Flyable接口了,单独用一种用翅膀飞的飞行行为类实现Flyable接口,再把这个类组合进鸭子类中,就是由原来的鸭子类自己实现5次,变为5个鸭子类组合5次。把重复实现5次,替换成为重复组合5次,从而提高了重用性。
策略模式,其实也体现了依赖倒置的思想。上层的Duck父类和底层的各个飞行子类,都只依赖于中间隔离的FlyBehavior接口。上层的Duck父类不直接依赖于各个飞行子类。
策略模式,还符合开闭原则,提高了扩展性。随意扩展FlyBehavior接口的子类,上层Duck父类是无感知的。
小结:
一旦程序中出现了大量的if else,就要考虑设计模式了,策略模式就是解决if else的一种利器;
比如上面,让程序员自己判断这种鸭子是什么飞行行为,就给它的构造器中传入什么飞行行为对象,而不是在程序中显示的写 if 鸭子1是A飞行行为,那么就执行A飞行行为的代码、else if 鸭子1是B飞行行为,那么就执行B飞行行为的代码,从而飞行行为越多,else if就会越长;
案例:
class Role {
private String name;
public Role(String name){
this.name = name;
}
public fight(){
System.out.println(name+"用剑砍");
}
}
此时,fight的动作就写死了,程序运行时调用多少次fight()方法,动作都不会再改变;
class Role {
private String name;
private String weapon;
public Role(String name,String weapon){
this.name = name;
this.weapon = weapon;
}
public fight(){
if ("剑".equals(weapon)){
System.out.println(name+"用剑砍");
}else if ("斧".equals(weapon)){
System.out.println(name+"用剑砍");
}else if ("弓".equals(weapon)){
System.out.println(name+"用剑砍");
}
}
public void setWeapon(String weapon){
this.weapon = weapon;
}
}
此时,虽然能通过在程序运行时,调用setEeapon()方法,给Role对象变换武器。但是却失去了扩展性,如果这个Role类是别人写的类库,你没有源代码,那么你就无法在它原有代码的基础上,扩展新的武器。又或者你想修改别人的源代码,那么就破坏了开闭原则。
这就是前面说的:决定采用哪种算法/行为的判断逻辑,和算法/行为自身的实现逻辑,混在了一起,从而不可能再各自独立演化。
策略模式登场
interface Weapon{
public void attack();
}
class Sword implements Weapon{
public void attack(){
System.out.println("用剑砍");
}
}
class Axe implements Weapon{
public void attack(){
System.out.println("用斧劈");
}
}
class Gun implements Weapon{
public void attack(){
System.out.println("用枪击");
}
}
class Role {
private String name;
private Weapon weapon;
public Role(String name,String weapon){
this.name = name;
this.weapon = weapon;
}
public fight(){
// 策略模式的核心点就是这里,执行的是接口的的方式,而不再具体写死某种行为
weapon.attack();
}
public void setWeapon(String weapon){
this.weapon = weapon;
}
}
案例:如何实现一个给不同大小文件排序的小程序
见王争《设计模式》61
比如贷款系统中,业务系统需要查询不同渠道征信报告,但是各渠道查询方式是不一样的,就用策略模式做了处理,客户端传入渠道编号来获 取查询的渠道策略实现征信查询
很久没写工程代码,但接触的科研算法对比性能时一般采用策略模式。通过输入不同的参数调用不同的算法
2.if-else或switch中每个分支的实现业务上是由共性的,可以抽象出来,这样可以用策略模式来取代分支,但是没有必要所有的都去除
通过策略模式的学习,可以看出实现简单的话一开始是不用使用策略模式的。 i f else中实现的逻辑都差不多, 就是差不多都是干一件事(上节课例子中的购物,本节的文件排序) 短的 if else相对易读, 代码好理解。 使用了策略模式要小心过度设计和影响了
最近在做规则引擎, 前端用户通过页面设置出业务执行的流程图,而流程图中包含数据源节点、 条件判断节点、 业务分发节点、 业务事件执行节点。 每一种节点执行的业务校验规则都不同, 这个时候适合策略模式。 使用策略模式的好处是: 以后随着业务的发展很有可能出现其他类型的节点, 所以这个时候采用策略模式非常合适, 易扩展易维护。 另外在 整个流程流转的规则上采用了模板方法。 … 展开
迭代器模式,是为了让容器代码和遍历代码解耦,这也印证了前面讲的,设计模式就是为了解耦
除非是我们自己写一个集合,不然我们是没机会写迭代器的
foreach 循环只是一个语法糖而已,底层是基于迭代器来实现的。 也就是说,上面代码中的第二种遍历方式(foreach 循环代码) 的底层实现, 就是第三种遍历方式(迭代器遍历代码) ,这两种遍历方式可以看作同一种遍历方式, 也就是迭代器遍历方式。 从上面的代码来看, for 循环遍历方式比起迭代器遍历方式, 代码看起来更加简洁。
那我们 为什么还要用迭代器来遍历容器呢?为什么还要给容器设计对应的迭代器呢?原因有以下三 个。
首先,对于类似数组和链表这样的数据结构,遍历方式比较简单,直接使用 for 循环来遍历就足够了。 但是,对于复杂的数据结构(比如树、 图) 来说,有各种复杂的遍历方式。 比如,树有前中后序、 按层遍历, 图有深度优先、 广度优先遍历等等。 如果由客户端代码来实 现这些遍历算法, 势必增加开发成本,而且容易写错。如果将这部分遍历的逻辑写到容器类中,也会导致容器类代码的复杂性。 前面也多次提到,应对复杂性的方法就是拆分。 我们可以将遍历操作拆分到迭代器类中。 比 如, 针对图的遍历, 我们就可以定义 DFSIterator、 BFSIterator 两个迭代器类, 让它们分 别来实现深度优先遍历和广度优先遍历。
其次,将游标指向的当前位置等信息,存储在迭代器类中, 每个迭代器独享游标信息。 这样,我们就可以创建多个不同的迭代器, 同时对同一个容器进行遍历而互不影响。
最后,容器和迭代器都提供了抽象的接口, 方便我们在开发的时候,基于接口而非具体的实现编程。 当需要切换新的遍历算法的时候, 比如, 从前往后遍历链表切换成从后往前遍历链表, 客户端代码只需要将迭代器类从 LinkedIterator 切换为 ReversedLinkedIterator 即可, 其他代码都不需要修改。 除此之外, 添加新的遍历算法, 我们只需要扩展新的迭代器类, 也符合开闭原则
容器遍历集合一般有三种方式: for 循环、 foreach 循环、 迭代器遍历。 后两种本质上属于一 种, 都可以看作迭代器遍历。
相对于 for 循环遍历, 利用迭代器来遍历有下面三个优势:
在 Java 中, 如果在使用迭代器的同时删除容器中的元素, 会导致迭代器报错, 这是为什么呢?如何来解决这个问题呢?
除了编程语言中基础类库提供的针对集合对象的迭代器之外, 实际上, 迭代器还有其他 的应用场景, 比如 MySQL ResultSet 类提供的 first()、 l ast()、 previous() 等方法, 也 可以看作一种迭代器, 你能分析一下它的代码实现吗?
客户端自己实现过滤
第二项,知道了服务端的实现细节,就违反了迪米特法则
从而实现,使得单个对象和组合对象的使用,具有一致性
这样,客户端代码只依赖MenuComponent类,而不在知道Menu和MenuItem的存在了,这就符合了最少知道原则;
组合模式:
父类定义出所有子类的方法,然后各个子类只重写自己需要的业务方法,从而方便客户端代码只针对父类编程,不关心各个子类;
就是通过栈,自己写一个后序遍历
MenuComponent类:
给Menu类加:
hasNext()内部的递归逻辑,是关键点
客户端代码:
以上就是:组合模式 + 迭代器模式
就是为了让观察者和被观察者的代码,相互解耦
pull拉模式 和push推模式
观察者模式,是用的push推模式
也就是,被观察者只告诉观察者们,自己已经变化了,具体什么变化了,让观察者们自己去查看;
案例2:
参考《head first设计模式 - 观察者模式》
实际案例:
Redis的发布与订阅,MQ多个客户端订阅某个队列
观察者被观察者模式有很多变种,有进程内的,也有跨进程的,有同步阻塞式的,也有异步非阻塞式的实现
Google的EventBus事件总线,是一种经典好用的观察者被观察者模式的实现,可以同时支持同步和异步模式
具体实现代码见王争《设计模式》57
主要用于解决复用和扩展两个问题
复用,指的是所有子类都可以复用父类中的模板方法逻辑
扩展,指的是框架通过模板方法提供的某些步骤作为子类覆写的扩展点,让框架的使用者可以在不修改框架源码的基情况下,基于扩展点定制化框架的功能
复用的案例:InputStream#read(),AbstractList#addAll()
扩展的案例:Java Servlet,Junit TestCase
回调函数,分为同步回调和异步回调
同步回调能起到和模板模式相同的作用,但是两者是使用的不同的方式,一个是使用的组合,另一个是使用的继承
案例,Spring为了简化程序的编写,提前写好非业务代码,让程序员只需要专注于自己的业务,而提供的XxxxTemplate类,就都是使用的同步回调,而不是使用的模板模式
比如,Spring提供的JdbcTemplate,RedisTemplate,RestTemplate等。
JdbcTemplate,将不变的部分抽到模板方法excute中,变化的部分也就是程序员写的不同的业务sql,就以回调函数StatementCallback的形式来让用户定制,并传进excute(StatementCallback action),让excute在它内部,回调执行action
异步回调,类似于设计模式中的观察者模式,当被调用端观测到订阅的事件时,才会触发执行调用端传进来的回调函数
模板模式,策略模式,职责链模式这三个模式的共同特点:复用、扩展
职责链模式,在实际的项目开发中比较常用,特别是框架开发中, 我们可以利用它们来提供框架的扩展点,能够让框架的使用者在不修改框架源码的情况下,基于扩展点定制化框架的功能
案例:如何实现可灵活扩展的敏感词过滤框架?
有多种不同类型的敏感词过滤规则,每一类敏感词过滤抽象成一个类
应用职责链模式也不例外。 实际上,我们在讲策略模式的时候,也讲过类似的问题, 比如,为什么要用策略模式?当时的给出的理由,与现在应用职责链模式的理由,几乎是一样的,你可以结合着当时的讲解一块来看下。 首先,我们来看, 职责链模式如何应对代码的复杂性。将大块代码逻辑拆分成函数, 将大类拆分成小类,是应对代码复杂性的常用方法。
应用职责链模式,我们把各个敏感词过滤函数继续拆分出来,设计成独立的类,进一步简化了 SensitiveWordFilter 类, 让 SensitiveWordFilter 类的代码不会过多,过复杂。 其次, 我们再来看,职责链模式如何让代码满足开闭原则,提高代码的扩展性。当我们要扩展新的过滤算法的时候, 比如,我们还需要过滤特殊符号, 按照非职责链模式的代码实现方式,我们需要修改 SensitiveWordFilter 的代码, 违反开闭原则。 不过, 这样的修改还算比较集中, 也是可以接受的。 而职责链模式的实现方式更加优雅, 只需要新添加一个Filter 类, 并且通过 addFilter() 函数将它添加到 FilterChain 中即可,其他代码完全不需要修改。
不过你可能会说,即便使用职责链模式来实现, 当添加新的过滤算法的时候,还是要修改客户端代码(ApplicationDemo),这样做也没有完全符合开闭原则。 实际上细化一下的话, 我们可以把上面的代码分成两类:框架代码和客户端代码,其中 ApplicationDemo 属于客户端代码,也就是使用框架的代码。 除 ApplicationDemo 之外 的代码属于敏感词过滤框架代码。
假设敏感词过滤框架并不是我们开发维护的, 而是我们引入的一个第三方框架, 我们要扩展 一个新的过滤算法,不可能直接去修改框架的源码。 这个时候利用职责链模式就能达到开篇所说的,在不修改框架源码的情况下,基于职责链模式提供的扩展点,来扩展新的功能。 换句话说, 我们在框架这个代码范围内实现了开闭原则。 除此之外, 利用职责链模式相对于不用职责链的实现方式, 还有一个好处, 那就是配置过滤算法更加灵活, 可以只选择使用某几个过滤器
职责链模式常用在框架的开发中, 为框架提供扩展点, 让框架的使 用者在不修改框架源码的情况下, 基于扩展点添加新的功能。 实际上, 更具体点来说, 职责链模式最常用来开发框架的过滤器和拦截器
过滤器:Servlet Filter
拦截器:Spring Interceptor
Servlet Filter
Servlet Filter 是 Java Servlet 规范中定义的组件, 翻译成中文就是过滤器, 它可以实现对 HTTP 请求的过滤功能, 比如鉴权、 限流、 记录日志、 验证参数等等。 因为它是 Servlet 规 范的一部分, 所以, 只要是支持 Servlet 的 Web 容器 (比如, Tomcat、 Jetty 等) , 都支持过滤器功能
FilterChain的递归实现,又分成了两种不同的实现方式,一种只能拦截请求、另一种可以既拦截请求又拦截返回回来的响应
代码见《设计模式》63
前面在讲代理模式的时候, 我们提到, Spring AOP 是基于代理模式来实现的。 在实际的项目开发中, 我们可以利用 AOP 来实现访问控制功能, 比如鉴权、 限流、 日志等。 今天我们又讲到, Servlet Filter、 Spring Interceptor 也可以用来实现访问控制。 那在项目开发中,类似权限这样的访问控制功能, 我们该选择三者(AOP、 Servlet Filter、 Spring Interceptor) 中的哪个来实现呢?有什么参考标准吗?
Filter 可以拿到原始的http请求, 但是拿不到你请求的控制器和请求控制器中的方法的信 息;
Interceptor 可以拿到你请求的控制器和方法, 却拿不到请求方法的参数;
Aop 可以拿到方法的参数, 但是却拿不到http请求和响应的对象
除了我们讲到的 Servlet Filter、 Spring Interceptor 之外, Dubbo Filter、 Netty ChannelPipeline 也是职责链模式的实际应用案例
实现责任链模式的几种方式
- 递归
- 循环
- 动态代理
在这三种应用场景中,职责链模式的实现思路都不大一样。其中,Servlet Filter 采用递归来实现拦截方法前后添加逻辑。Spring Interceptor 的实现比较简单,把拦截方法前后要添加的逻辑放到两个方法中实现。MyBatis Plugin 采用嵌套动态代理的方法来实现拦截方法前后添加逻辑,实现思路很有技巧
状态模式是状态机的一种实现方法,它通过将事件触发的状态转移和动作执行,拆分到不同的状态类中,以此来避免状态机类中的的分支判断逻辑,应对状态机类代码的复杂性。 这里有两种角色,多种不同的状态类和总的状态机类
游戏、工作流引擎中常用的状态机是如何实现的?
实际上,像游戏这种比较复杂的状态机,包含的状态比较多,我优先推荐使用查表法,而状态模式会引入非常多的状态类,会导致代码比较难维护。
相反像电商下单、 外卖下单这种类型的状态机,它们的状态并不多,状态转移也比较简单,但事件触发执行的动作包含的业务逻辑可能会比较复杂, 所以更加推荐使用状态模式来实现
只要记住状态模式是状态机的一种实现方式可,状态机又叫有限状态机, 它有 3 个部分组成: 状态、事件、动作。 其中事件也称为转移条件。事件触发状态的转移及动作的执行。 不过,动作不是必须的, 也可能只转移状态, 不执行任何动作
针对状态机, 今天我们总结了三种实现方式:
第一种实现方式叫分支逻辑法。 利用 if-else 或者 switch-case 分支逻辑, 参照状态转移 图, 将每一个状态转移原模原样地直译成代码。 对于简单的状态机来说, 这种实现方式最简 单、 最直接, 是首选。
第二种实现方式叫查表法。 对于状态很多、 状态转移比较复杂的状态机来说, 查表法比较合 适。 通过二维数组来表示状态转移图, 能极大地提高代码的可读性和可维护性
第三种实现方式叫状态模式。 对于状态并不多、 状态转移也比较简单, 但事件触发执行的动 作包含的业务逻辑可能比较复杂的状态机来说, 我们首选这种实现方式
具体代码见,
王争《设计模式》64
状态模式的代码实现还存在一些问题, 比如, 状态接口中定义了所有的事件函数,这就导致, 即便某个状态类并不需要支持其中的某个或者某些事件,但也要实现所有的事件函数。 不仅如此, 添加一个事件到状态接口, 所有的状态类都要做相应的修改。 针对这些问题,你有什么解决方案?
可以在接口和实现类中间加一层抽象类解决此问题, 抽象类实现状态接口,状态类继承抽象类, 只需要重写需要的方法即可
注:
从这里,也可以总结抽象类的作用之一
备忘录模式,应用场景比较明确和有限,主要是用来防丢失、撤销、恢复等
对于大对象的备份与恢复,如何优化内存与时间消耗?
备忘录模式,还有一个跟它很类似的概念,“备份” , 它在我们平时的开发中更常听到。 那备忘录模式跟“备份”有什么区别和联系呢?实际上, 这两者的应用场景很类似, 都应用在防丢失、恢复、撤销等场景中。 它们的区别在于,备忘录模式更侧重于代码的设计和实现,备份更侧重架构设计或产品设计
如何优化内存和时间消耗?前面我们只是简单介绍了备忘录模式的原理和经典实现, 现在我们再继续深挖一下。 如果要备份的对象数据比较大, 备份频率又比较高, 那快照占用的内存会比较大,备份和恢复的耗时会比较长,这个问题该如何解决呢? 不同的应用场景下有不同的解决方法。 比如, 我们前面举的那个例子, 应用场景是利用备忘录来实现撤销操作, 而且仅仅支持顺序撤销, 也就是说, 每次操作只能撤销上一次的输入, 不能跳过上次输入撤销之前的输入。 在具有这样特点的应用场景下, 为了节省内存, 我们不 需要在快照中存储完整的文本, 只需要记录少许信息, 比如在获取快照当下的文本长度, 用 这个值结合 InputText 类对象存储的文本来做撤销操作。 我们再举一个例子。 假设每当有数据改动, 我们都需要生成一个备份, 以备之后恢复。 如果 需要备份的数据很大, 这样高频率的备份, 不管是对存储(内存或者硬盘) 的消耗, 还是对 时间的消耗, 都可能是无法接受的。 想要解决这个问题, 我们一般会采用“低频率全量备 份” 和 “高频率增量备份” 相结合的方法。
全量备份就不用讲了,它跟我们上面的例子类似, 就是把所有的数据“拍个快照”保存下 来。 所谓 “增量备份” , 指的是记录每次操作或数据变动。 当我们需要恢复到某一时间点的备份的时候, 如果这一时间点有做全量备份, 我们直接拿来 恢复就可以了。 如果这一时间点没有对应的全量备份, 我们就先找到最近的一次全量备份, 然后用它来恢复,之后执行此次全量备份跟这一时间点之间的所有增量备份,也就是对应的操作或者数据变动。 这样就能减少全量备份的数量和频率,减少对时间、 内存的消耗
命令模式的主要作用和应用场景,是用来控制命令的执行, 比如,异步、延迟、排队执行命令、 撤销重做命令、存储命令、给命令记录日志等等,这才是命令模式能发挥独一无二作用的地方
案例:如何利用命令模式实现一个手游后端架构?
一般来说,游戏客户端和服务器之间的数据交互是比较频繁的, 所以为了节省网络连接建立的开销, 客户端和服务器之间一般采用长连接的方式来通信。通信的格式有多种, 比如 Protocol Buffer、 JSON、 XML,甚至自定义数据格式
不管客户端发送的是什么格式的数据,数据中一般都会包含两个部分:指令、数据。其中指令也叫做事件,数据是执行这条指令所所需的数据
服务器在接收到客户端的请求之后, 会解析出指令和数据, 并且根据指令的不同, 执行不同 的处理逻辑。 对于这样的一个业务场景, 一般有两种架构实现思路。 常用的一种实现思路是利用多线程。 一个线程接收请求, 接收到请求之后, 启动一个新的线 程来处理请求。 具体点讲, 一般是通过一个主线程来接收客户端发来的请求。每当接收到一 个请求之后, 就从一个专门用来处理请求的线程池中, 捞出一个空闲线程来处理。 另一种实现思路是在一个线程内轮询接收请求和处理请求。这种处理方式不太常见。尽管它 无法利用多线程多核处理的优势, 但是对于 IO 密集型的业务来说, 它避免了多线程不停切 换对性能的损耗, 并且克服了多线程编程 Bug 比较难调试的缺点, 也算是手游后端服务器 开发中比较常见的架构模式了。 我们接下来就重点讲一下第二种实现方式。 整个手游后端服务器轮询获取客户端发来的请求,获取到请求之后, 借助命令模式, 把请求 包含的数据和处理逻辑封装为命令对象, 并存储在内存队列中。 然后,再从队列中取出一定 数量的命令来执行。 执行完成之后,再重新开始新的一轮轮询
刚开始可能会觉得,命令模式跟策略模式、工厂模式非常相似啊, 那它们的区别在哪里呢?刚开始感觉学过的很多模式都很相似,不知道你有没有类似的感觉呢?
实际上,每个设计模式都应该由两部分组成:第一部分是应用场景, 即这个模式可以解决哪类问题; 第二部分是解决方案, 即这个模式的设计思路和具体的代码实现。 不过,代码实现并不是模式必须包含的。 如果你单纯地只关注解决方案这一部分,甚至只关注代码实现,就会产生大部分模式看起来都很相似的错觉。
实际上设计模式之间的主要区别还是在于设计意图,也就是应用场景。 单纯地看设计思路或者代码实现,有些模式确实很相似,比如策略模式和工厂模式。 之前讲策略模式的时候,我们有讲到策略模式包含策略的定义、创建和使用三部分, 从代码结构上来,它非常像工厂模式,它们的区别在于,策略模式侧重“策略”或“算法”这个特定的应用场景,用来解决根据运行时状态从一组策略中选择不同策略的问题, 而工厂模式侧重封装对象的创建过程, 这里的对象没有任何业务场景的限定, 可以是策略, 但也可以是其他东西。 从设计意图上来,这两个模式完全是两回事儿
有了刚刚的铺垫, 接下来我们再来看命令模式跟策略模式的区别。 你可能会觉得,命令的执行逻辑也可以看作策略,那它是不是就是策略模式了呢?实际上,这两者有一点细微的区别。在策略模式中,不同的策略具有相同的目的、不同的实现、 互相之间可以替换。 比如BubbleSort、 SelectionSort 都是为了实现排序的,只不过一个是用冒泡排序算法来实现 的, 另一个是用选择排序算法来实现的。 而在命令模式中,不同的命令具有不同的目的,对应不同的处理逻辑,并且互相之间不可替换
上一节课, 我们学习了命令模式。 命令模式将请求封装成对象,方便作为函数参数传递和赋值给变量。 它主要的应用场景是给命令的执行附加功能,换句话说,就是控制命令的执行,比如, 排队、异步、延迟执行命令、给命令执行记录日志、撤销重做命令等等。总体上来讲,命令模式的应用范围并不广
解释器模式,它用来描述如何构建一个简单的“语言”解释器。 比起命令模式,解释器模式更加小众,只在一些特定的领域会被用到, 比如编译器、规则引擎、正则表达式。 所以解释器模式也不是我们学习的重点, 你稍微了解一下就可以了
案例:如何设计实现一个自定义接口告警规则功能?
在我们平时的项目开发中,监控系统非常重要, 它可以时刻监控业务系统的运行情况,及时将异常报告给开发者。 比如,如果每分钟接口出错数超过 100, 监控系统就通过短信、微 信、邮件等方式发送告警给开发者。
一般来讲,监控系统支持开发者自定义告警规则, 比如我们可以用下面这样一个表达式,来表示一个告警规则, 它表达的意思是: 每分钟 API 总出错数超过 100 或者每分钟 API 总调 用数超过 10000 就触发告警。
在监控系统中, 告警模块只负责根据统计数据和告警规则, 判断是否触发告警。 至于每分钟 API 接口出错数、 每分钟接口调用数等统计数据的计算, 是由其他模块来负责的。 其他模块 将统计数据放到一个 Map 中 (数据的格式如下所示) , 发送给告警模块。 接下来, 我们只 关注告警模块。
注意,加上括号后,又该如何处理?
解释器模式的代码实现比较灵活,没有固定的模板。 我们前面说过,应用设计模式主要是应对代码的复杂性,解释器模式也不例外。它的代码实现的核心思想,就是将语法解析的工作 拆分到各个小类中,以此来避免大而全的解析类。 一般的做法是,将语法规则拆分一些小的独立的单元, 然后对每个单元进行解析,最终合并为对整个语法规则的解析
主要包括:原理、 背后的思想、 应用场景