目录
单一职责原则(Single Resbosibility Principle)
享元模式(Flyweight Design Pattern)
观察者模式(Observe Design Pattern)/发布订阅者模式(Publish-Subscribe Design Pattern)——EventBus
模板模式(Template Method Design Pattern)
设计模式
面向对象编程
- 以类和对象作为基本单元,封装/抽象/继承/多态为特性
- 封装。实现信息隐藏、保护数据功能。暴露有限的访问接口,授权外部仅能通过类提供的方法访问内部数据或信息,需要编程语言提供权限访问控制来支持。
- 抽象。只关注功能不关注具体实现的设计思路。提高代码的扩展性和维护性,修改方法具体实现而不用修改定义。
- 继承。实现代码复用
- 多态。继承+方法重写 | 接口类语法 | duck-typing。利用多态特性,可以把不同的类对象传给相同的方法,执行不同的逻辑。
设计原则
单一职责原则(Single Resbosibility Principle)
- 如何判断一个类的职责足够专一?
- 类中的代码行数、函数或属性过多,会影响代码的可读性和可维护性,我们就需要考虑对类进行拆分;
- 类依赖的其他类过多,或者依赖类的其他类过多,不符合高内聚、低耦合的设计思想,我们就需要考虑对类进行拆分;
- 私有方法过多,我们就要考虑能否将私有方法独立到新的类中,设置为 public 方法,供更多的类使用,从而提高代码的复用性;
- 比较难给类起一个合适名字,很难用一个业务名词概括,或者只能用一些笼统的 Manager、Context 之类的词语来命名,这就说明类的职责定义得可能不够清晰;
- 类中大量的方法都是集中操作类中的某几个属性,比如,在 UserInfo 例子中,如果一半的方法都是在操作 address 信息,那就可以考虑将这几个属性和对应的方法拆分出来。
- 单一职责原则是为了实现代码的高内聚、低耦合,提高代码的复用性、可读性、可维护性。
开闭原则(Open Closed Principle)
- 对扩展开放,对修改关闭。原则上都是提高代码的扩展性,增加新的功能,应该是通过在已有的代码基础上扩展代码(新增模块、类、方法、属性等),而非修改已有代码的方式来完成。
- 方法:最常用来提高代码扩展性的方法有——多态/依赖注入/基于接口而非实现编程
里式替换原则
- 里式替换原则:子类在设计的时候,要遵守父类的行为约定(协议)。父类定义了函数的行为约定,那子类可以改变函数的内部实现逻辑,但不能改变函数原有的 行为约定。行为约定包括:
- 函数声明要实现的功能;
- 对输入、输出、异常的约定;
- 包括注释中所罗列的任何特殊说明;
- 里式替换原则与多态的不同:
- 多态 是面向对象编程的一大特性,也是面向对象编程语言的一种语法;
- 里式替换 是一种设计原则,用来指导继承关系中子类该如何设计,子类的设计要保证在替换父类的时候,不改变原有程序的逻辑及不破坏原有程序的正确性;
接口隔离原则
理解 接口
-
一组API接口集合
举个例子。微服务用户系统提供了一组API,包括注册、登陆、获取用户信息等。现在后台管理系统需要用户系统需要删除用户的接口。但是这个接口只希望后台管理系统来调用,因此,可以将对应的api放在另一个单独的interface。
-
单个API接口或函数
单个函数的功能不要太复杂。比如
public class Statistics { private Long max; private Long min; private Long average; private Long sum; private Long percentile99; private Long percentile999; //...省略constructor/getter/setter等方法... } public Statistics count(Collection<Long> dataSet) { Statistics statistics = new Statistics(); //...省略计算逻辑... return statistics; }
现在的接口的count函数功能性太复杂,包含了很多不同的统计功能,按照接口隔离原则,应该把count()函数拆成几个更小粒度的函数。满足接口隔离原则之后,可以改进为:
public Long max(Collection<Long> dataSet) { //... } public Long min(Collection<Long> dataSet) { //... } public Long average(Colletion<Long> dataSet) { //... } // ...省略其他统计函数...
不过,这也需要结合具体场景。比如,在业务场景中,每个统计需求Statistics定义的那几个统计信息都有涉及,那原来的count()函数设计就是合理的;如果每个统计需求只涉及到其中一部分,而count()函数都需要把所有信息都算一遍,这就会影响代码性能。
-
OOP中的接口概念
可以理解为面向对象编程中的接口语法,那接口的设计要尽量单一,不要让接口的实现类或者调用者,依赖不需要的接口。
依赖反转原则
控制反转(IOC)
控制反转是一个比较笼统的设计思想,并不是一种具体的实现方法,一般用来指导框架层面的设计。我们按照框架设计的接口或者定义的规则编码,框架会自动调用我们的代码。比如Spring的初始化,我们实现一些Component/Configuration,框架会自动帮我们注册。
依赖注入(DI)
这是一种具体的编程技巧。简单来说就是,不通过new的方式在类的内部创建依赖类的对象,而是在外部创建好,然后通过构造函数/函数参数等方式传递(注入)给类来使用。
依赖注入框架(DI Framework)
我们通过依赖注入框架提供的扩展点,简单配置一下所需要的类及其类与类之间依赖关系,就可以实现由框架来自动创建对象、管理对象的生命周期、依赖注入等原本需要程序员来做的事情。
依赖反转原则(DIP)
高层模块不依赖底层模块,他们共同依赖同一个抽象。抽象不要依赖具体的实现细节,具体实现细节依赖抽象。
KISS、YAGNI原则
KISS(Keep It Simple and Stupid)
- 不要使用同事可能不懂的技术来实现代码;
- 不要重复造轮子,要善于使用已经有的工具类库
- 不要过度优化
YAGNI(You Ain't Gonna Need It)
不要做过度设计。不要去设计当前用不到的功能,不要编写当前用不到的代码,可以留好扩展点。
DRY原则
DRY(Don't Repeat Yourself)
代码重复的三种情况:实现逻辑重复、功能语义重复、代码执行重复。
迪米特法则(LOD, Law of Demeter)
高内聚,松耦合
- 何为“高内聚”。相近的功能应该放到同一个类中(往往会被同时修改),不相近的功能不要放到同一个类中。单一职责原则是实现高内聚的设计原则。
- 何为“松耦合”。在代码中,类与类之间的依赖关系简单清晰。一个类的代码改动不会活很少导致依赖类的代码改动。依赖注入、接口隔离、基于接口而非实现编程,以及迪米特法则,都是为了实现代码的松耦合
- 单一职责原则
- 适用对象:模块,类,接口
- 侧重点:高内聚,低耦合
- 思考角度:自身
- 接口隔离原则
- 适用对象:接口,函数
- 侧重点:低耦合
- 思考角度:调用者
- 基于接口而非实现编程
- 适用对象:接口,抽象类
- 侧重点:低耦合
- 思考角度:调用者
- 迪米特法则
- 适用对象:模块,类
- 侧重点:低耦合
- 思考角度:类关系
规范与重构
单元测试
单元测试 VS 集成测试
集成测试的测试对象是整个系统或者某个功能模块,比如测试用户注册、登录功能是否正常,是一种端到端(end to end)的测试。
单元测试的测试对象是类或者函数,用来测试一个类和函数是否都按照预期的逻辑执行。这是代码层级的测试
编程规范
命名
- 对于大家熟悉的词,推荐用缩写。比如sec->second, str->string...
- 利用上下文简化命名。如User类里面的字段,可以直接叫
name
,而不是userName
;函数参数也可以借助函数这个上下文来简化命名,比如:
public void uploadUserAvatarImageToAliyun(String userAvatarImageUri); //利用上下文简化为: public void uploadUserAvatarImageToAliyun(String imageUri);
- 命名要可读可搜索。可读指的是,不要用生僻、难发音的英文单词来命名;可搜索指的是,在命名的时候,最好能符合整个项目的命名习惯,大家都用“selectXXX”表示查询,你就不要用“queryXXX”;大家都用“insertXXX”表示插入一条数据,你就要不用“addXXX”
注释
注释的内容主要包含这样三个方面:做什么、为什么、怎么做。对于一些复杂的类和接口,我们可能还需要写明“如何用”。
设计模式与范式
设计模式要干的事情就是解耦,创建型模式是将创建和使用代码解耦,结构型模式是将不同功能代码解耦,行为型模式是将不同的行为代码解耦 。
单例模式
单例设计模式。一个类只允许创建一个对象(或者实例),这个类就是一个单例类,这种设计模式就叫单例设计模式,简称单例模式。
工厂模式
简单工厂模式
工厂模式
当创建逻辑比较复杂,是一个“大工程”的时候,我们就考虑使用工厂模式,封装对象的创建过程,将对象的创建和使用相分离。
何为创建逻辑比较复杂?
- 第一种:类似规则配置解析的例子,代码中存在if-else分支判断,动态地根据不同的类型创建不同的对象。可以将这一大坨if-else创建对象的代码抽离出来,放到工厂类中。
- 第二种:尽管我们不需要根据不同的类型创建不同的对象,但是,单个对象本身的创建过程比较复杂,比如前面提到的要组合其他类对象,做各种初始化操作。在这种情况下我们也可以考虑使用工厂模式,将对象的创建过程封装到工厂类中。
对于第一种情况,如果创建对象过程不复杂,用简单工厂模式,如果创建对象过程比较复杂,为了避免设计一个过于庞大的简单工厂类,推荐使用工厂模式,将创建逻辑拆分得更细,每个对象的创建逻辑独立到各自的工厂类中。对于第二种,工厂模式。
工厂模式
- 封装变化:创建逻辑有可能变化,封装成工厂类之后,创建逻辑的变更对调用者透明
- 代码复用:创建逻辑的代码 抽离到独立的工厂类之后可以复用
- 隔离复杂性:封装复杂的创建逻辑,调用者无需了解如何创建对象
- 控制复杂度:将创建代码抽离出来,让本来的函数或职责更单一,代码更简洁
Dependency Injection框架
DI容器底层最基本的设计思路就是基于工厂模式。DI容器相当于一个大的工厂类
构造者模式
什么情况下使用建造者模式?
- 我们规定在创建对象的时候就强制需要设置某些值时。如果参数过多,放在构造函数里面太长了;如果用set方法去设置,那一些参数的校验就无处安放了。这种情况下就考虑使用建造者模式。
- 如果类属性之间有一定的依赖关系或者约束条件,我们如果继续使用构造函数配合set()方法的思路,那这些依赖关系和约束条件的校验逻辑就无处安放了。
- 如果我们希望创建不可变对象,也就是说,对象在创建好之后,就不能再修改内部的属性值,要实现这个功能,就不能在类中暴露set()方法。
工厂模式 VS 建造者模式 工厂模式是用来创建不同但是相关类型的对象(继承同一父类或者接口的一组子类),根据给定参数来决定创建哪种类型的对象。 建造者模式是用来创建一种类型的复杂对象,可以通过设置不同的可选参数,“定制化”地创建不同的对象。
建造者模式
什么情况下使用建造者模式?
- 我们规定在创建对象的时候就强制需要设置某些值时。如果参数过多,放在构造函数里面太长了;如果用set方法去设置,那一些参数的校验就无处安放了。这种情况下就考虑使用建造者模式。
- 如果类属性之间有一定的依赖关系或者约束条件,我们如果继续使用构造函数配合set()方法的思路,那这些依赖关系和约束条件的校验逻辑就无处安放了。
- 如果我们希望创建不可变对象,也就是说,对象在创建好之后,就不能再修改内部的属性值,要实现这个功能,就不能在类中暴露set()方法。
工厂模式 VS 建造者模式
工厂模式是用来创建不同但是相关类型的对象(继承同一父类或者接口的一组子类),根据给定参数来决定创建哪种类型的对象。
建造者模式是用来创建一种类型的复杂对象,可以通过设置不同的可选参数,“定制化”地创建不同的对象。
原型模式(不常用)
原型模式:如果对象的创建成本比较大,而同一个类的不同对象之间差别不大(大部分字段都相同),在这种情况下,我们可以利用对已有对象(原型)进行复制(拷贝)的方式来创建新对象,以达到节省创建时间的目的。
原型模式的两种实现方法:
- 浅拷贝。只会复制对象中基本数据类型数据和引用对象的内存地址,不会递归地复制引用对象,以及引用对象的引用对象···
- 深拷贝。得到的是一个完全独立的对象。
虽然深拷贝比浅拷贝更加耗时,耗内存空间,但是对于可变对象来说,浅拷贝得到的对象和原始对象会共享部分数据,有可能出现数据被修改的风险,所以除非深拷贝非常耗时,不推荐浅拷贝。
代理模式(Proxy Design Pattern)
代理模式在不改变原石类(或叫被代理类)代码的情况下,通过引入代理类来给原始类附加功能。
- 静态代理:让代理类和原始类实现同样的接口,如果原始类没有定义接口并且原始类代码并不是我们开发维护的,可以通过让代理类继承原始类的方法来实现代理模式。
- 动态代理:静态代理需要针对每个类都创建一个代理类,并且每个代理类中的代码都有点像模版式的“重复”代码,增加了维护成本和开发成本。动态代理就可以,不用事先为每个原始类编写代理类,而是在运行的时候动态地创建原始类对应的代理类,然后在系统中用代理类替换掉原始类。
应用场景:常用在业务系统中开发一些非功能性需求:监控、统计、鉴权、限流、事务、幂等、日志。除此之外,代理模式还可以用在RPC、缓存等应用场景。
桥接模式(Bridge Design Pattern)
将抽象和实现解耦,让它们可以独立变化。比较难理解 可以结合JDBC驱动理解桥接模式。
装饰器模式
装饰器模式和静态代理模式很像。主要作用是给原始类增强功能,装饰器类需要跟原始类继承相同的抽象类或者接口。
适配者模式(Adapter Design Pattern)
这个模式就是用来做接口适配的,他将不兼容的接口转换为可兼容的接口,类比USB转换器。它有两种实现方式:类适配器 & 对象适配器
类适配器:使用继承关系来实现
对象适配器:使用组合方式来实现
怎么选择实现方式?
- 如果Adaptee接口不多,两种方式都可以
- 如果Adaptee接口很多,而且Adaptee和ITarget接口大部分定义都相同,推荐使用类适配器。因为Adapter复用父类Adaptee的接口,比起对象适配器的方式,Adapter的代码要少一些。
- 如果Adaptee接口很多,而且Adaptee和ITarget接口大部分不相同,推荐使用对象适配器。因为组合结构相对于继承更加灵活。
// 类适配器: 基于继承
public interface ITarget {
void f1();
void f2();
void fc();
}
public class Adaptee {
public void fa() { //... }
public void fb() { //... }
public void fc() { //... }
}
public class Adaptor extends Adaptee implements ITarget {
public void f1() {
super.fa();
}
public void f2() {
//...重新实现f2()...
}
// 这里fc()不需要实现,直接继承自Adaptee,这是跟对象适配器最大的不同点
}
// 对象适配器:基于组合
public interface ITarget {
void f1();
void f2();
void fc();
}
public class Adaptee {
public void fa() { //... }
public void fb() { //... }
public void fc() { //... }
}
public class Adaptor implements ITarget {
private Adaptee adaptee;
public Adaptor(Adaptee adaptee) {
this.adaptee = adaptee;
}
public void f1() {
adaptee.fa(); //委托给Adaptee
}
public void f2() {
//...重新实现f2()...
}
public void fc() {
adaptee.fc();
}
}
- 封装有缺陷的接口设计——二次封装
- 统一多个类的接口设计—— 某个功能的实现依赖多个外部系统(或者说类)。通过适配器模式,将它们的接口适配为统一的接口定义,然后我们就可以使用多态的特性来复用代码逻辑。
- 替换依赖的外部系统
- 兼容老版本接口——老版本接口里实际上调用的是新方法
- 适配不同格式的数据
代理、桥接、装饰器、适配器 4 种设计模式的区别
尽管四种模式的代码结构相似,但是他们要解决的问题、应用场景完全不同。
代理模式在不改变原始类接口的条件下,为原始类定义一个代理类,主要目的是控制访问,而非加强功能,这是它跟装饰器模式最大的不同。
桥接模式的目的是将接口部分和实现部分分离,从而让它们可以较为容易、也相对独立地加以改变。
装饰者模式在不改变原始类接口的情况下,对原始类功能进行增强,并且支持多个装饰器的嵌套使用。
适配器模式是一种事后的补救策略。适配器提供跟原始类不同的接口,而代理模式、装饰器模式提供的都是跟原始类相同的接口。
门面模式
门面模式为子系统提供一组统一的接口,定义一组高层接口让子系统更易用。
适配器模式:接口转换,解决的是原接口和目标接口不匹配的问题。
门面模式:接口整合,解决的是多接口调用带来的问题。
组合模式
组合模式的设计思路,与其说是一种设计模式,倒不如说是对业务场景的一种数据结构和算法的抽象。其中,数据可以表示成树这种数据结构,业务需求可以通过在树上的递归遍历算法来实现。
享元模式(Flyweight Design Pattern)
享元,顾名思义就是被共享的单元。享元模式的意图就是复用对象,节省内存,前提是享元对象是不可变对象。
棋牌游戏(象棋),在游戏大厅里,有成千上万个房间,对应成千上万个棋局,每个棋局需要保存几十个棋子的信息,包括棋子的类型(象、车、士等),棋子的颜色,棋子的位置,如果不用享元模式,那保存这么多棋局对象就会消耗大量的内存。在上述的实现方式中,在内存中会有大量的相似对象,这些相似对象的id/text/color都是一样的,只是positionX/positionY不一样,那我们可以把前面的属性抽出来,设计成独立的类,并且作为享元供多个棋局复用。
// 享元类
public class ChessPieceUnit {
private int id;
private String text;
private Color color;
public ChessPieceUnit(int id, String text, Color color) {
this.id = id;
this.text = text;
this.color = color;
}
public static enum Color {
RED, BLACK
}
// ...省略其他属性和getter方法...
}
public class ChessPieceUnitFactory {
private static final Map<Integer, ChessPieceUnit> pieces = new HashMap<>();
static {
pieces.put(1, new ChessPieceUnit(1, "車", ChessPieceUnit.Color.BLACK));
pieces.put(2, new ChessPieceUnit(2,"馬", ChessPieceUnit.Color.BLACK));
//...省略摆放其他棋子的代码...
}
public static ChessPieceUnit getChessPiece(int chessPieceId) {
return pieces.get(chessPieceId);
}
}
public class ChessPiece {
private ChessPieceUnit chessPieceUnit;
private int positionX;
private int positionY;
public ChessPiece(ChessPieceUnit unit, int positionX, int positionY) {
this.chessPieceUnit = unit;
this.positionX = positionX;
this.positionY = positionY;
}
// 省略getter、setter方法
}
public class ChessBoard {
private Map<Integer, ChessPiece> chessPieces = new HashMap<>();
public ChessBoard() {
init();
}
private void init() {
chessPieces.put(1, new ChessPiece(
ChessPieceUnitFactory.getChessPiece(1), 0,0));
chessPieces.put(1, new ChessPiece(
ChessPieceUnitFactory.getChessPiece(2), 1,0));
//...省略摆放其他棋子的代码...
}
public void move(int chessPieceId, int toPositionX, int toPositionY) {
//...省略...
}
}
- 享元 vs 单例
单例模式下,一个类只能创建一个对象;在享元模式下,一个类可以创建多个对象,每个对象被多处代码引用共享,有点类似于单例的变体:多例。从设计意图上看,应用享元模式是为了对象复用,节省内存,而应用多例模式是为了限制对象的个数。
- 享元 vs 缓存
缓存主要是为了提高访问效率,而非复用。
- 享元 vs 对象池
池化技术中的“复用”理解为重复使用,主要是为了节省时间。
观察者模式(Observe Design Pattern)/发布订阅者模式(Publish-Subscribe Design Pattern)——EventBus
观察者模式是为了接耦观察者和被观察者代码。比如用户注册的例子,当一个新的用户注册后,我们需要给用户下发消费券,同时还要发送一封欢迎信,那我们可以定义一个接口,接口包含一个f()方法,对于新用户注册,每当我们需要新增一个事件,可以看成需要新增一个观察者,比如赠送积分,那可以新增一个该接口的实现,在用户注册(调用方法register())时,这些观察者都做出对应的操作,即f()。
这个模式在不同场景下可以有不用的实现方式,可以是同步阻塞,观察者和被观察者代码在同一个线程内执行,被观察者一直阻塞,直到所有的观察者代码都执行完成之后,才执行后续的代码。也可以是异步非阻塞,可以在执行f()时,都创建一个新的线程去执行,或者更优雅的方法,比如EventBus。
模板模式(Template Method Design Pattern)
模板模式的作用:复用和扩展。模板方法模式在一个方法中定义一个算法骨架,并将某些步骤推迟到子类中实现。模板方法模式可以让子类在不改变算法整体结构的情况下,重新定义算法中的某些步骤。
其中,复用指的是,所有的子类可以复用父类中提供的模板方法的代码。扩展指的是,框架通过模板模式提供功能扩展点,让框架用户可以在不修改框架源码的情况下,基于扩展点定制化框架的功能。
策略模式
策略模式定义一族算法类,将每个算法分别封装起来,让他们可以互相替换。策略模式可以使算法的变化独立于使用他们的客户端(使用算法的代码)。
策略模式用来解耦策略的定义、创建、使用。实际上,一个完整的策略模式就会是由这三个部分组成的。
- 策略类的定义,包含一个策略接口和一组实现这个接口的策略类;
- 策略的创建由工厂类来完成,封装策略创建的细节;
- 策略模式包含一组策略可选,客户端代码如何选择使用哪个策略,有两种确定方法:编译时静态确定和运行时动态确定。其中,“运行时动态确定”才是策略模式最典型的应用场景。
(mark:公司代码里,handler继承或者扩展一个接口/类,同时实现/重写ableHandle()就是使用了策略模式)
职责链模式(Chain of Responsibility Design Pattern)
职责链模式将请求和接受解耦,让多个接受对象都有机会处理这个请求。将这些接受对象串成一条链,并沿着这条链传递这个请求,直到链上的某个接受对象能够处理它为止。
有两种实现方式,例子:Handler是所有处理器类的抽象父类,handle()是抽象方法。每个具体的处理器类(HandlerA\HandlerB)的handle()函数的代码结构类似,如果它能处理该请求,就不继续往下传递,如果不能,则交给后面的处理器来处理。
第一种是HandlerChain是处理器链,记录了head和tail,记录tail是为了方便添加处理器。
第二种是HandlerChain类用数组而非链表来保存所有的处理器,在HandlerChain的handle()函数中,一次调用每个处理器的handle()函数。
还有一种变体是,请求会被所有的处理器都处理一遍,不存在中途终止的情况。
- 敏感信息过滤
- 过滤器
- 拦截器
状态模式
是状态机的一种实现方式。
状态机又叫有限状态机,它有3个部分组成:状态、事件、动作。
- 分支逻辑法。利用if-else或者switch-case分之逻辑,参照状态转移图,将每一个状态转移原模原样地直译成代码。
- 查表法。对于状态很多、状态转移比较复杂的状态机来说,查表法比较合适。通过二维数组来表示状态转移图,能极大地提高代码的可读性和可维护性。
- 状态模式。对于状态不多、状态转移也比较简单,但事件触发执行的动作包含的业务逻辑可能比较复杂的状态机来说,首选这种方式。
迭代器模式(Iterator Design Pattern)/游标模式(Cursor Design Pattern)
一个完整的迭代器模式,一般会涉及容器和容器迭代器两部分内容。为了达到基于接口而非实现编程的目的,容器又包含容器接口、容器实现类,迭代器又包含迭代器接口、迭代器实现类。容器中需要定义 iterator() 方法,用来创建迭代器。迭代器接口中需要定义 hasNext()、currentItem()、next() 三个最基本的方法。容器对象通过依赖注入传递到迭代器类中。
遍历集合一般有三种方式:for 循环、foreach 循环、迭代器遍历。后两种本质上属于一种,都可以看作迭代器遍历。相对于 for 循环遍历,利用迭代器来遍历有下面三个优势:
- 迭代器模式封装集合内部的复杂数据结构,开发者不需要了解如何遍历,直接使用容器提供的迭代器即可;
- 迭代器模式将集合对象的遍历操作从集合类中拆分出来,放到迭代器类中,让两者的职责更加单一;
- 迭代器模式让添加新的遍历算法更加容易,更符合开闭原则。除此之外,因为迭代器都实现自相同的接口,在开发中,基于接口而非实现编程,替换迭代器也变得更加容易。
使用Iterator遍历集合的同时,如果增删集合元素会发生什么?
在java的Iterator实现里面,当调用next()方法时会去判断modCount和expectedModCount是否相等,如果不相等,会抛出ConcurrentModificationException
异常。