极客时间 《设计模式之美》 学习笔记

本文是 极客时间设计模式之美 课程的学习笔记内容。

设计原则与思想:设计原则

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 依赖。

Spring 源码学习(五)循环依赖

46 | 建造者模式:详解构造函数、set方法、建造者模式三种对象创建方式

使用建造者模式创建对象,还能避免对象存在无效状态。

1
2
3
Rectangle r = new Rectange(); // r is invalid
r.setWidth(2); // r is invalid
r.setHeight(3); // r is valid

工厂模式是用来创建不同但是相关类型的对象(继承同一父类或者接口的一组子类),由给定的参数来决定创建哪种类型的对象。建造者模式是用来创建一种类型的复杂对象,通过设置不同的可选参数,“定制化”地创建不同的对象。

顾客走进一家餐馆点餐,我们利用工厂模式,根据用户不同的选择,来制作不同的食物,比如披萨、汉堡、沙拉。对于披萨来说,用户又有各种配料可以定制,比如奶酪、西红柿、起司,我们通过建造者模式根据用户选择的不同配料来制作披萨。

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 | 观察者模式(上):详解各种应用场景下观察者模式的不同实现方式

设计模式要干的事情就是解耦。创建型模式是将创建和使用代码解耦,结构型模式是将不同功能代码解耦,行为型模式是将不同的行为代码解耦,具体到观察者模式,它是将观察者和被观察者代码解耦。

参考资料

感谢您的阅读,本文由 董宗磊的博客 版权所有。如若转载,请注明出处:董宗磊的博客(https://dongzl.github.io/2019/12/26/08-The-Beauty-Of-Design-Pattern/
cread 系统畅读卡同步 action 使用 Spring 单例作用域引起的问题
Hello World