极客时间 《设计模式之美》 学习笔记
设计原则与思想:设计原则
15 | 理论一:对于单一职责原则,如何判定某个类的职责是否够“单一”?
单一职责原则:Single Responsibility Principle,缩写为 SRP。
我们可以现写一个粗粒度的类,满足业务需求。随着业务的发展,如果粗粒度的类越来越大,代码越来越多,这个时候,我们就可以将这个粗粒度的类,拆分成几个更细度的类。
如何理解单一职责(SRP)
一个类只负责完成一个职责或者功能。不要设计大而全的类,要设计粒度小、功能单一的类。单一职责原则是为了实现代码高内聚、低耦合,提高代码的复用性、可读性、可维护性。
如何判断类的职责是否足够单一
- 类中的代码行数、函数或者属性过多;
- 类依赖的其他类过多,或者依赖类的其他类过多;
- 私有方法过多;
- 比较难给类起一个合适的名字;
- 类中大量的方法都是集中操作类中的某几个属性。
16 | 理论二:如何做到“对扩展开放、修改关闭”?扩展和修改各指什么?
开闭原则:Open Closed Principle,简写为 OCP。
Software entities (modules, classes, functions, etc.) should be open for extension , but closed for modification。
我们把它翻译成中文就是:软件实体(模块、类、方法等)应该“对扩展开放、对修改关闭”。
17 | 理论三:里式替换(LSP)跟多态有何区别?哪些代码违背了LSP?
里式替换原则:Liskov Substitution Principle,缩写为 LSP。
Functions that use pointers of references to base classes must be able to use objects of derived classes without knowing it。
子类对象(object of subtype/derived class)能够替换程序(program)中父类对象(object of base/parent class)出现的任何地方,并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏。
违反里式替换原则的情况:
- 子类违背父类声明要实现的功能;
- 子类违背父类对输入、输出、异常的约定;
- 子类违背父类注释中所罗列的任何特殊说明。
多态 & 里式替换原则:
多态是面向对象编程的一大特性,也是面向对象编程语言的一种语法。它是一种代码实现的思路。而里式替换是一种设计原则,用来指导继承关系中子类该如何设计,子类的设计要保证在替换父类的时候,不改变原有程序的逻辑及不破坏原有程序的正确性。
18 | 理论四:接口隔离原则有哪三种应用?原则中的“接口”该如何理解?
接口隔离原则:Interface Segregation Principle”,缩写为 ISP。
Clients should not be forced to depend upon interfaces that they do not use。
如果把“接口”理解为一组接口集合,可以是某个微服务的接口,也可以是某个类库的接口等。如果部分接口只被部分调用者使用,我们就需要将这部分接口隔离出来,单独给这部分调用者使用,而不强迫其他调用者也依赖这部分不会被用到的接口。
如果把“接口”理解为单个 API 接口或函数,部分调用者只需要函数中的部分功能,那我们就需要把函数拆分成粒度更细的多个函数,让调用者只依赖它需要的那个细粒度函数。
如果把“接口”理解为 OOP 中的接口,也可以理解为面向对象编程语言中的接口语法。那接口的设计要尽量单一,不要让接口的实现类和调用者,依赖不需要的接口函数。
19 | 理论五:控制反转、依赖反转、依赖注入,这三者有何区别和联系?
依赖倒置原则:Dependency Inversion Principle,缩写为 DIP。
High-level modules shouldn’t depend on low-level modules. Both modules should depend on abstractions. In addition, abstractions shouldn’t depend on details. Details depend on abstractions.
高层模块(high-level modules)不要依赖低层模块(low-level)。高层模块和低层模块应该通过抽象(abstractions)来互相依赖。除此之外,抽象(abstractions)不要依赖具体实现细节(details),具体实现细节(details)依赖抽象(abstractions)。
20 | 理论六:我为何说KISS、YAGNI原则看似简单,却经常被用错?
Keep It Simple and Stupid. 尽量保持简单。
如何写出满足 KISS 原则的代码?
- 不要使用同事可能不懂的技术来实现代码。
- 不要重复造轮子,要善于使用已经有的工具类库。
- 不要过度优化。不要过度使用一些奇技淫巧来优化代码,牺牲代码的可读性。
YAGNI 原则(不要做过度设计)
YAGNI 原则的英文全称是:You Ain’t Gonna Need It。
21 | 理论七:重复的代码就一定违背DRY吗?如何提高代码的复用性?
DRY 原则(Don’t Repeat Yourself)
- 实现逻辑重复(不违反 DRY 原则)
- 功能语义逻辑(违反 DRY 原则)
- 代码执行重复(违反 DRY 原则)
提高代码可复用性的方法
- 减少代码耦合
- 满足单一职责原则
- 模块化
- 业务与非业务逻辑分离
- 通用代码下沉
- 继承、多态、抽象、封装
- 应用模板等设计模式
22 | 理论八:如何用迪米特法则(LOD)实现“高内聚、松耦合”?
迪米特法则:Law of Demeter,缩写是 LOD。
最小知识原则:The Least Knowledge Principle。
Each unit should have only limited knowledge about other units: only units “closely” related to the current unit. Or: Each unit should only talk to its friends; Don’t talk to strangers.
23 | 实战一(上):针对业务系统的开发,如何做需求分析和设计?
技术人也要有一些产品思维。对于产品设计、需求分析,我们要学会“借鉴”,一定不要自己闷头想。一方面这样做很难想全面,另一方面从零开始设计也比较浪费时间。除此之外,我们还可以通过线框图和用户用例来细化业务流程,挖掘一些比较细节的、不容易想到的功能点。
面向对象设计聚焦在代码层面(主要是针对类),那系统设计就是聚焦在架构层面(主要是针对模块),两者有很多相似之处。很多设计原则和思想不仅仅可以应用到代码设计中,还能用到架构设计中。实际上,我们可以借鉴面向对象设计的步骤,来做系统设计。
面向对象设计的本质就是把合适的代码放到合适的类中。合理地划分代码可以实现代码的高内聚、低耦合,类与类之间的交互简单清晰,代码整体结构一目了然。类比面向对象设计,系统设计实际上就是将合适的功能放到合适的模块中。合理地划分模块也可以做到模块层面的高内聚、低耦合,架构整洁清晰。在面向对象设计中,类设计好之后,我们需要设计类之间的交互关系。类比到系统设计,系统职责划分好之后,接下来就是设计系统之间的交互了。
24 | 实战一(下):如何实现一个遵从设计原则的积分兑换系统?
MVC 三层开发作用:
- 分层能起到代码复用的作用
- 分层能起到隔离变化的作用
- 分层能起到隔离关注点的作用
- 分层能提高代码的可测试性
- 分层能应对系统的复杂性
25 | 实战二(上):针对非业务的通用框架开发,如何做需求分析和设计?
对于非业务通用框架开发:
- 首先考虑功能性需求分析;
- 还要考虑框架的非功能性需求:易用性、性能、扩展性、容错性、通用性。
复杂框架设计技巧:
- 画产品线框图;
- 聚焦简单应用场景;
- 设计实现最小原型;
- 画系统设计图。
26 | 实战二(下):如何实现一个支持各种统计规则的性能计数器?
设计原则与思想:规范与重构
27 | 理论一:什么情况下要重构?到底重构什么?又该如何重构?
重构是一种对软件内部结构的改变,目的是在不改变软件的可见行为的情况下,使其更易理解,修改成本更低。
大型重构指的是对顶层代码设计的重构,包括:系统、模块、代码结构、类与类之间的关系等的重构,重构的手段有:分层、模块化、解耦、抽象可复用组件等等。
小型重构指的是对代码细节的重构,主要是针对类、函数、变量等代码级别的重构,比如规范命名、规范注释、消除超大类或函数、提取重复代码等等。
28 | 理论二:为了保证重构不出错,有哪些非常能落地的技术手段?
29 | 理论三:什么是代码的可测试性?如何写出可测试性好的代码?
常见的测试不友好的代码有下面这 5 种:
- 代码中包含未决行为逻辑
- 滥用可变全局变量
- 滥用静态方法
- 使用复杂的继承关系
- 高度耦合的代码
30 | 理论四:如何通过封装、抽象、模块化、中间层等解耦代码?
如何给代码“解耦”:
- 封装与抽象
- 中间层
- 模块化
- 其他设计思想和原值
- 单一职责原则
- 基于接口而非实现编程
- 依赖注入
- 对用组合少用继承
- 迪米特法则
31 | 理论五:让你最快速地改善代码质量的20条编程规范(上)
关于命名:
- 命名的关键是能准确达意。
- 借助类的信息来简化属性、函数的命名,利用函数的信息来简化函数参数的命名。
- 命名要可读、可搜索。
- 接口 & 抽象类命名方式。
关于注释:
- 注释的目的就是让代码更容易看懂。
- 注释本身有一定的维护成本,所以并非越多越好。
32 | 理论五:让你最快速地改善代码质量的20条编程规范(中)
- 合适的类、函数大小
- 一行代码最长不能超过 IDE 显示的宽度。
- 善用空行分割单元块
- 四格缩进还是两格缩进(分割统一,不用tab)
- 大括号所在位置(只要团队统一、业内统一、跟开源项目看齐)
- 类中成员的排列顺序(先变量后函数,先静态后普通,作用域由大到小)
33 | 理论五:让你最快速地改善代码质量的20条编程规范(下)
- 把代码分割成更小的单元块
- 避免函数参数过多
- 勿用函数参数来控制逻辑(参数中包含 true | false)
- 函数设计要职责单一
- 移除过深的嵌套层次
- 学会使用解释性变量
- 常量取代魔法数字。
- 使用解释性变量来解释复杂表达式。
34 | 实战一(上):通过一段ID生成器代码,学习如何发现代码质量问题
如何发现代码质量问题:
大的方向是否可读、可扩展、可维护、灵活、简洁、可复用、可测试
具体落实,通用的关注点有:
- 1)目录设置是否合理、模块划分是否清晰、代码结构是否满足“高内聚、松耦合”?
- 2)是否遵循经典的设计原则和设计思想(SOLID、DRY、KISS、YAGNI、LOD 等)?
- 3)设计模式是否应用得当?是否有过度设计?
- 4)代码是否容易扩展?如果要添加新功能,是否容易实现?
- 5)代码是否可以复用?是否可以复用已有的项目代码或类库?是否有重复造轮子?
- 6)代码是否容易测试?单元测试是否全面覆盖了各种正常和异常的情况?
- 7)代码是否易读?是否符合编码规范(比如命名和注释是否恰当、代码风格是否一致等)?
关于业务的一些通用关注点:
- 1)代码是否实现了预期的业务需求?
- 2)逻辑是否正确?是否处理了各种异常情况?
- 3)日志打印是否得当?是否方便 debug 排查问题?
- 4)接口是否易用?是否支持幂等、事务等?
- 5)代码是否存在并发问题?是否线程安全?
- 6)性能是否有优化空间,比如,SQL、算法是否可以优化?
- 7)是否有安全漏洞?比如输入输出校验是否全面?
35 | 实战一(下):手把手带你将ID生成器代码从“能用”重构为“好用”
- 即便是非常简单的需求,不同水平的人写出来的代码,差别可能会很大。
- 知其然知其所以然,了解优秀代码设计的演变过程,比学习优秀设计本身更有价值。
- 设计思想、原则、模式本身并没有太多“高大上”的东西,都是一些简单的道理。
- 高手之间的竞争都是在细节。
36 | 实战二(上):程序出错该返回啥?NULL、异常、错误码、空对象?
函数出错返回数据类型:
- 返回错误码
- 返回 NULL 值
- 返回空对象
- 抛出异常对象
- 如果 func1() 抛出的异常是可以恢复,且 func2() 的调用方并不关心此异常,我们完全可以在 func2() 内将 func1() 抛出的异常吞掉;
- 如果 func1() 抛出的异常对 func2() 的调用方来说,也是可以理解的、关心的 ,并且在业务概念上有一定的相关性,我们可以选择直接将 func1 抛出的异常 re-throw;
- 如果 func1() 抛出的异常太底层,对 func2() 的调用方来说,缺乏背景去理解、且业务概念上无关,我们可以将它重新包装成调用方可以理解的新异常,然后 re-throw。
37 | 实战二(下):重构ID生成器项目中各函数的异常处理代码
- 再简单的代码,看上去再完美的代码,只要我们下功夫去推敲,总有可以优化的空间,就看你愿不愿把事情做到极致。
- 如果你内功不够深厚,理论知识不够扎实,那你就很难参透开源项目的代码到底优秀在哪里。
- 作为一名程序员,起码对代码要有追求啊,不然跟咸鱼有啥区别!
设计原则与思想:总结课
38 | 总结回顾面向对象、设计原则、编程规范、重构技巧等知识点
最常用到几个评判代码质量的标准有:可维护性、可读性、可扩展性、灵活性、简洁性、可复用性、可测试性。
面向对象编程相比面向过程编程的优势主要有三个:
- 对于大规模复杂程序的开发,程序的处理流程并非单一的一条主线,而是错综复杂的网状结构。面向对象编程比起面向过程编程,更能应对这种复杂类型的程序开发。
- 面向对象编程相比面向过程编程,具有更加丰富的特性(封装、抽象、继承、多态)。利用这些特性编写出来的代码,更加易扩展、易复用、易维护。
- 从编程语言跟机器打交道方式的演进规律中,我们可以总结出:面向对象编程语言比起面向过程编程语言,更加人性化、更加高级、更加智能。
面向对象分析(OOA)、面向对象设计(OOD)、面向对象编程(OOP),是面向对象开发的三个主要环节。简单点讲,面向对象分析就是要搞清楚做什么,面向对象设计就是要搞清楚怎么做,面向对象编程就是将分析和设计的的结果翻译成代码的过程。
UML 统一建模语言中定义了六种类之间的关系。它们分别是:泛化、实现、关联、聚合、组合、依赖。我们从更加贴近编程的角度,对类与类之间的关系做了调整,保留了四个关系:泛化、实现、组合、依赖。
如果要表示一种 is-a 的关系,并且是为了解决代码复用问题,我们就用抽象类;如果要表示一种 has-a 关系,并且是为了解决抽象而非代码复用问题,那我们就用接口。
基于充血模型的 DDD 开发模式跟基于贫血模型的传统开发模式相比,Controller 层和 Repository 层的代码基本上相同。这是因为,Repository 层的 Entity 生命周期有限,Controller 层的 VO 只是单纯作为一种 DTO。两部分的业务逻辑都不会太复杂。业务逻辑主要集中在 Service 层。所以,Repository 层和 Controller 层继续沿用贫血模型的设计思路是没有问题的。
设计原则:
- SRP 单一职责原则
- OCP 开闭原则
- LSP 里氏替换原则
- ISP 接口隔离原则
- DIP 依赖倒置原则
- KISS、YAGNI 原则
- DRY 原则
- LOD 原则
39 | 运用学过的设计原则和思想完善之前讲的性能计数器项目(上)
40 | 运用学过的设计原则和思想完善之前讲的性能计数器项目(下)
设计模式与规范:创建型
41 | 单例模式(上):为什么说支持懒加载的双重检测不比饿汉式更优?
单例设计模式(Singleton Design Pattern)理解起来非常简单。一个类只允许创建一个对象(或者实例),那这个类就是一个单例类,这种设计模式就叫作单例设计模式,简称单例模式。
如何实现一个单例类?
- 构造函数需要是 private 访问权限的,这样才能避免外部通过 new 创建实例;- 考虑对象创建时的线程安全问题;
- 考虑是否支持延迟加载;
- 考虑 getInstance() 性能是否高(是否加锁)。
单例的实现:
- 饿汉式;
- 懒汉式;
- 双重检测;
- 静态内部类;
- 枚举。
42 | 单例模式(中):我为什么不推荐使用单例模式?又有何替代方案?
- 单例对 OOP 特性的支持不友好
- 单例会隐藏类之间的依赖关系
- 单例对代码的扩展性不友好
- 单例对代码的可测试性不友好
- 单例不支持有参数的构造函数
43 | 单例模式(下):如何设计实现一个集群环境下的分布式单例模式?
单例类中对象的唯一性的作用范围是进程内的,在进程间是不唯一的。
进程内唯一:使用单例模式;
线程内唯一:使用ThreadLocal;
进程间唯一:考虑使用分布式锁。
单例模式并不是严格意义的进程内唯一,而是同一个 ClassLoader 内唯一。
44 | 工厂模式(上):我为什么说没事不要随便用工厂模式创建对象?
- 简单工厂模式(静态工厂模式)
- 工厂模式
- 抽象工厂模式
当每个对象的创建逻辑都比较简单的时候,我推荐使用简单工厂模式,将多个对象的创建逻辑放到一个工厂类中。当每个对象的创建逻辑都比较复杂的时候,为了避免设计一个过于庞大的简单工厂类,我推荐使用工厂方法模式,将创建逻辑拆分得更细,每个对象的创建逻辑独立到各自的工厂类中。
- 封装变化:创建逻辑有可能变化,封装成工厂类之后,创建逻辑的变更对调用者透明。
- 代码复用:创建代码抽离到独立的工厂类之后可以复用。
- 隔离复杂性:封装复杂的创建逻辑,调用者无需了解如何创建对象。
- 控制复杂度:将创建代码抽离出来,让原本的函数或类职责更单一,代码更简洁。
45 | 工厂模式(下):如何设计实现一个Dependency Injection框架?
一个简单的 DI 容器的核心功能一般有三个:配置解析、对象创建和对象生命周期管理。
Spring 处理循环依赖:
- 通过构造器注入的循环依赖,是无法解决的。
- Spring 容器对原型作用域的 bean 是不进行缓存,因此无法提前暴露一个创建中的 bean,所以也是无法解决这种情况的循环依赖。
- 对于 setter 注入造成的依赖可以通过 Spring 容器提前暴露刚完成构造器注入但未完成其他步骤(如 setter 注入)的 bean 来完成,而且只能解决单例作用域的 bean 依赖。
46 | 建造者模式:详解构造函数、set方法、建造者模式三种对象创建方式
使用建造者模式创建对象,还能避免对象存在无效状态。
1 | Rectangle r = new Rectange(); // r is invalid |
工厂模式是用来创建不同但是相关类型的对象(继承同一父类或者接口的一组子类),由给定的参数来决定创建哪种类型的对象。建造者模式是用来创建一种类型的复杂对象,通过设置不同的可选参数,“定制化”地创建不同的对象。
顾客走进一家餐馆点餐,我们利用工厂模式,根据用户不同的选择,来制作不同的食物,比如披萨、汉堡、沙拉。对于披萨来说,用户又有各种配料可以定制,比如奶酪、西红柿、起司,我们通过建造者模式根据用户选择的不同配料来制作披萨。
47 | 原型模式:如何最快速地clone一个HashMap散列表?
如果对象的创建成本比较大,而同一个类的不同对象之间差别不大(大部分字段都相同),在这种情况下,我们可以利用对已有对象(原型)进行复制(或者叫拷贝)的方式,来创建新对象,以达到节省创建时间的目的。这种基于原型来创建对象的方式就叫作原型设计模式,简称原型模式。
在 Java 语言中,Object 类的 clone() 方法执行的就是我们刚刚说的浅拷贝。它只会拷贝对象中的基本数据类型的数据(比如,int、long),以及引用对象(SearchWord)的内存地址,不会递归地拷贝引用对象本身。
设计模式与规范:结构型
48 | 代理模式:代理在RPC、缓存、监控等场景中的应用
在不改变原始类(或叫被代理类)的情况下,通过引入代理类来给原始类附加功能。一般情况下,我们让代理类和原始类实现同样的接口。但是,如果原始类并没有定义接口,并且原始类代码并不是我们开发维护的。在这种情况下,我们可以通过让代理类继承原始类的方法来实现代理模式。
静态代理需要针对每个类都创建一个代理类,并且每个代理类中的代码都有点像模板式的”重复“代码,增加了维护成本和开发成本。对于静态代理存在的问题,我们可以通过动态代理来解决。我们不事先为每个原始类编写代理类,而是在运行的时候动态地创建原始类对应的代理类,然后在系统中用代理类替换掉原始类。
49 | 桥接模式:如何实现支持不同类型和渠道的消息推送系统?
Decouple an abstraction from its implementation so that the two can vary independently。(将抽象和实现解耦,让它们可以独立变化。)
50 | 装饰器模式:通过剖析Java IO类库源码学习装饰器模式
装饰器类和原始类继承同样的父类,这样我们可以对原始类“嵌套”多个装饰器类。
装饰器类是对功能的增强,这也是装饰器模式应用场景的一个重要特点。
代理模式中,代理类附加的是跟原始类无关的功能,而在装饰器模式中,装饰器类附加的是跟原始类相关的增强功能。
51 | 适配器模式:代理、适配器、桥接、装饰,这四个模式有何区别?
适配器模式有两种实现方式:类适配器和对象适配器。其中,类适配器使用继承关系来实现,对象适配器使用组合关系来实现。
代理模式:代理模式在不改变原始类接口的条件下,为原始类定义一个代理类,主要目的是控制访问,而非加强功能,这是它跟装饰器模式最大的不同。
桥接模式:桥接模式的目的是将接口部分和实现部分分离,从而让它们可以较为容易、也相对独立地加以改变。
装饰器模式:装饰者模式在不改变原始类接口的情况下,对原始类功能进行增强,并且支持多个装饰器的嵌套使用。
适配器模式:适配器模式是一种事后的补救策略。适配器提供跟原始类不同的接口,而代理模式、装饰器模式提供的都是跟原始类相同的接口。
52 | 门面模式:如何设计合理的接口粒度以兼顾接口的易用性和通用性?
门面模式:
- Provide a unified interface to a set of interfaces in a subsystem. Facade Pattern defines a higher-level interface that makes the subsystem easier to use.
- 门面模式为子系统提供一组统一的接口,定义一组高层接口让子系统更易用。
完成接口设计,就相当于完成了一半的开发任务。只要接口设计得好,那代码就差不到哪里去。
尽量保持接口的可复用性,但针对特殊情况,允许提供冗余的门面接口,来提供更易用的接口。
- 当要为一个复杂子系统提供一个简单接口时可以使用外观模式。
- 客户程序与多个子系统之间存在很大的依赖性。
- 使用外观模式定义系统中每一层的入口,层与层之间不直接产生联系,而通过外观类建立联系,降低层之间的耦合度。
增加新的子系统可能需要修改外观类或客户端的源代码,违背了“开闭原则”。
53 | 组合模式:如何设计实现支持递归遍历的文件系统目录树结构?
组合模式:Compose objects into tree structure to represent part-whole hierarchies. Composite lets client treat individual objects and compositions of objects uniformly.
将一组对象组织(Compose)成树形结构,以表示一种“部分 - 整体”的层次结构。组合让客户端(在很多设计模式书籍中,“客户端”代指代码的使用者。)可以统一单个对象和组合对象的处理逻辑。
54 | 享元模式(上):如何利用享元模式优化文本编辑器的内存占用?
享元模式(Flyweight Pattern):运用共享技术有效地支持大量细粒度对象的复用。(Use sharing to support large numbers of fine-grained objects efficiently.)
享元模式的代码实现非常简单,主要是通过工厂模式,在工厂类中,通过一个 Map 或者 List 来缓存已经创建好的享元对象,以达到复用的目的。
55 | 享元模式(下):剖析享元模式在Java Integer、String中的应用
JDK 设置 Integer 缓存参数:
- 方法一:-Djava.lang.Integer.IntegerCache.high=255
- 方法二:-XX:AutoBoxCacheMax=255
JDK 中 Integer 对象缓存,String 常量池,都是使用的享元模式。
设计模式与规范:行为型
56 | 观察者模式(上):详解各种应用场景下观察者模式的不同实现方式
设计模式要干的事情就是解耦。创建型模式是将创建和使用代码解耦,结构型模式是将不同功能代码解耦,行为型模式是将不同的行为代码解耦,具体到观察者模式,它是将观察者和被观察者代码解耦。