设计模式理论基础

1、代码质量的评判及如何写出好代码

代码质量的评价有很强的主观性,描述代码质量的词汇也有很多,比如可读性、可维护性、灵活、优雅、简洁等,这些词汇是从不同的维度去评价代码质量的。它们之间有互相作用,并不是独立的,比如,代码的可读性好、可扩展性好就意味着代码的可维护性好。代码质量高低是一个综合各种因素得到的结论。我们并不能通过单一的维度去评价一段代码的好坏。

1.评价代码质量的常用标准

最常用到几个评判代码质量的标准是:可维护性、可读性、可扩展性、灵活性、简洁性、可复用性、可测试性。其中,可维护性、可读性、可扩展性又是提到最多的、最重要的三个评价标准。

a、可维护性(maintainability)

i.维护:修改 bug、修改老的代码、添加新的代码之类的工作

ii.代码易维护: 在不破坏原有代码设计、不引入新的 bug 的情况下,能够快速地修改或者添加代码

iii.代码不易维护:修改或者添加代码需要冒着极大的引入新 bug 的风险,并且需要花费很长的时间才能完成

iv.代码的可维护性是由很多因素协同作用的结果。代码的可读性好、简洁、可扩展性好,就会使得代码易维护;相反,就会使得代码不易维护。 如果代码分层清晰、模块化好、高内聚低耦合、遵从基于接口而非实现编程的设计原则等等,那就可能意味着代码易维护。代码的易维护性还跟项目代码量的多少、业务的复杂程度、利用到的技术的复杂程度、文档是否全面、团队成员的开发水平等诸多因素有关。

b、可读性(readability)

i.需要看代码是否符合编码规范、命名是否达意、注释是否详尽、函数是否长短合适、模块划分是否清晰、是否符合高内聚低耦合等等

ii. code review 是一个很好的测验代码可读性的手段

iii.代码的可读性在非常大程度上会影响代码的可维护性

c、可扩展性(extensibility)

i.代码的可扩展性:在不修改或少量修改原有代码的情况下,通过扩展的方式添加新的功能代码;代码预留了一些功能扩展点,你可以把新功能代码,直接插到扩展点上,而不需要因为要添加一个功能而大动干戈,改动大量的原始代码(表示我们的代码应对未来需求变化的能力)

ii.代码是否易扩展也很大程度上决定代码是否易维护

d、灵活性(flexibility)

如果一段代码易扩展、易复用或者易用,我们都可以称这段代码写得比较灵活

e、简洁性(simplicity)

代码简单、逻辑清晰,也就意味着易读、易维护; 代码要尽量写得简洁,符合 KISS 原则

f、可复用性(reusability)

i.可复用性:尽量减少重复代码的编写,复用已有的代码

ii.当讲到面向对象特性的时候,我们会讲到继承、多态存在的目的之一,就是为了提高代码的可复用性;

iii.当讲到设计原则的时候,我们会讲到单一职责原则也跟代码的可复用性相关;

iv.当讲到重构技巧的时候,我们会讲到解耦、高内聚、模块化等都能提高代码的可复用性。

v.代码可复用性跟 DRY(Don’t Repeat Yourself)这条设计原则的关系挺紧密的

g、可测试性(testability)

i.代码的可测试性是一个相对较少被提及,但又非常重要的代码质量评价标准。代码可测试性的好坏,能从侧面上非常准确地反应代码质量的好坏。代码的可测试性差,比较难写单元测试,那基本上就能说明代码设计得有问题。

2.如何写出高质量代码

i.要写出满足这些评价标准的高质量代码,我们需要掌握一些更加细化、更加能落地的编程方法论,包括面向对象设计思想、设计原则、设计模式、编码规范、重构技巧等

ii.面向对象中的继承、多态能让我们写出可复用的代码;

iii.编码规范能让我们写出可读性好的代码;

iv.设计原则中的单一职责、DRY、基于接口而非实现、里式替换原则等,可以让我们写出可复用、灵活、可读性好、易扩展、易维护的代码;

v.设计模式可以让我们写出易扩展的代码;

vi.持续重构可以时刻保持代码的可维护性等等

2、面向对象、设计原则、设计模式、编程规范、重构的关系

1.面向对象

a、主流的编程范式或者是编程风格有三种:面向过程(最主流)、面向对象和函数式编程

b、面向对象编程因为其具有丰富的特性(封装、抽象、继承、多态),可以实现很多复杂的设计思路,是很多设计原则、设计模式编码实现的基础

c、七大知识点:

i. 面向对象的四大特性:封装、抽象、继承、多态

ii. 面向对象编程与面向过程编程的区别和联系

iii. 面向对象分析、面向对象设计、面向对象编程

iv. 接口和抽象类的区别以及各自的应用场景

v. 基于接口而非实现编程的设计思想

vi. 多用组合少用继承的设计思想

vii. 面向过程的贫血模型和面向对象的充血模型

2.设计原则

a、设计原则是指导我们代码设计的一些经验总结;定义描述都比较模糊抽象。

b、学习技巧:掌握它的设计初衷,能解决哪些编程问题,有哪些应用场景

c、几个常用的设计原则:

i. SOLID 原则 -SRP 单一职责原则

ii. SOLID 原则 -OCP 开闭原则

iii. SOLID 原则 -LSP 里式替换原则

iv. SOLID 原则 -ISP 接口隔离原则

vi. SOLID 原则 -DIP 依赖倒置原则

vii. DRY 原则、KISS 原则、YAGNI 原则、LOD 法则

3.设计模式

a、设计模式是针对软件开发中经常遇到的一些设计问题,总结出来的一套解决方案或者设计思路。大部分设计模式要解决的都是代码的可扩展性问题

b、学习技巧:了解它们都能解决哪些问题,掌握典型的应用场景,并且懂得不过度应用

c、按照类型和是否常用的三大分类:

i. 创建型

常用的有:单例模式、工厂模式(工厂方法和抽象工厂)、建造者模式。

不常用的有:原型模式。

ii. 结构型

常用的有:代理模式、桥接模式、装饰者模式、适配器模式。

不常用的有:门面模式、组合模式、享元模式。

iii. 行为型

常用的有:观察者模式、模板模式、策略模式、职责链模式、迭代器模式、状态模式。

不常用的有:访问者模式、备忘录模式、命令模式、解释器模式、中介模式。

4.编程规范

a、编程规范主要解决的是代码的可读性问题。编码规范相对于设计原则、设计模式,更加具体、更加偏重代码细节

b、学习技巧:掌握基本的编码规范,比如,如何给变量、类、函数命名,如何写代码注释,函数不宜过长、参数不能过多等等。

5.重构技巧

a、重构是软件开发中非常重要的一个环节。持续重构是保持代码质量不下降的有效手段,能有效避免代码腐化到无可救药的地步

b、重构的工具:面向对象设计思想、设计原则、设计模式、编码规范

c、设计思想、设计原则、设计模式一个最重要的应用场景就是在重构的时候

d、重构的知识点:

i.重构的目的(why)、对象(what)、时机(when)、方法(how);

ii.保证重构不出错的技术手段:单元测试和代码的可测试性;

iii.两种不同规模的重构:大重构(大规模高层次)和小重构(小规模低层次)。

6.五者之间的联系

a、面向对象编程因为其具有丰富的特性(封装、抽象、继承、多态),可以实现很多复杂的设计思路,是很多设计原则、设计模式等编码实现的基础

b、设计原则是指导我们代码设计的一些经验总结,对于某些场景下,是否应该应用某种设计模式,具有指导意义。比如,“开闭原则”是很多设计模式(策略、模板等)的指导原则

c、 设计模式是针对软件开发中经常遇到的一些设计问题,总结出来的一套解决方案或者设计思路。应用设计模式的主要目的是提高代码的可扩展性。从抽象程度上来讲,设计原则比设计模式更抽象。设计模式更加具体、更加可执行。

d、编程规范主要解决的是代码的可读性问题。编码规范相对于设计原则、设计模式,更加具体、更加偏重代码细节、更加能落地。持续的小重构依赖的理论基础主要就是编程规范。

e、重构作为保持代码质量不下降的有效手段,利用的就是面向对象、设计原则、设计模式、编码规范这些理论

设计模式理论基础_第1张图片

 

面向对象

3、面向对象的一些概念和知识点

1.面向对象编程和面向对象编程语言

a、面向对象编程中有两个非常重要、非常基础的概念:类(class)和对象(object)

类(class):类是一个模板,它描述一类对象的行为和状态。男孩(boy)女孩(girl)为类(class),而具体的每个人为该类的对象(object)

public class Dog {
    String breed;
    int size;
    String colour;
    int age;
 
    void eat() {
    }
 
    void run() {
    }
 
    void sleep(){
    }
 
    void name(){
    }
}

局部变量:在方法、构造方法或者语句块中定义的变量被称为局部变量。变量声明和初始化都是在方法中,方法结束后,变量就会自动销毁。

成员变量(属性):成员变量是定义在类中,方法体之外的变量。这种变量在创建对象的时候实例化。成员变量可以被类中方法、构造方法和特定类的语句块访问。

类变量:类变量也声明在类中,方法体之外,但必须声明为 static 类型。

对象(object):对象是类的一个实例,有状态和行为。例如,一条狗是一个对象,它的状态有:颜色、名字、品种;行为有:摇尾巴、叫、吃等。

创建对象:

对象是根据类创建的。在Java中,使用关键字 new 来创建一个新的对象。创建对象需要以下三步:

  • 声明:声明一个对象,包括对象名称和对象类型。

  • 实例化:使用关键字 new 来创建一个对象。

  • 初始化:使用 new 创建对象时,会调用构造方法初始化对象。

public class Puppy{
   public Puppy(String name){
      //这个构造器仅有一个参数:name
      System.out.println("小狗的名字是 : " + name ); 
   }
   public static void main(String[] args){
      // 下面的语句将创建一个Puppy对象
      Puppy myPuppy = new Puppy( "tommy" );
   }
}

构造方法:构造方法的名称必须与类同名,一个类可以有多个构造方法。

public class Puppy{
    public Puppy(){
    }
 
    public Puppy(String name){
        // 这个构造器仅有一个参数:name
    }
}

访问实例变量和方法放入示例:

public class Puppy{
   int puppyAge;
   public Puppy(String name){
      // 这个构造器仅有一个参数:name
      System.out.println("小狗的名字是 : " + name ); 
   }
 
   public void setAge( int age ){
       puppyAge = age;
   }
 
   public int getAge( ){
       System.out.println("小狗的年龄为 : " + puppyAge ); 
       return puppyAge;
   }
 
   public static void main(String[] args){
      /* 创建对象 */
      Puppy myPuppy = new Puppy( "tommy" );
      /* 通过方法来设定age */
      myPuppy.setAge( 2 );
      /* 调用另一个方法获取age */
      myPuppy.getAge( );
      /*你也可以像下面这样访问成员变量 */
      System.out.println("变量值 : " + myPuppy.puppyAge ); 
   }
}

b、面向对象编程(Object Oriented Programming ): 是一种编程范式或编程风格。它以类或对象作为组织代码的基本单元,并将封装、抽象、继承、多态四个特性,作为代码设计和实现的基石

c、面向对象编程语言(Object Oriented Programming Language ):是支持类或对象的语法机制,并有现成的语法机制,能方便地实现面向对象编程四大特性(封装、抽象、继承、多态)的编程语言

d、理解面向对象编程及面向对象编程语言概念的关键:理解面向对象编程的每种特性讲的是什么内容、存在的意义以及能解决什么问题

2.面向对象编程语言的判定

a、如果按照严格的的定义,需要有现成的语法支持类、对象、四大特性才能叫作面向对象编程语言。如果放宽要求的话,只要某种编程语言支持类、对象语法机制,那基本上就可以说这种编程语言是面向对象编程语言了,不一定非得要求具有所有的四大特性

3.面向对象分析和面向对象设计

a、面向对象分析和设计:围绕着对象或类来做需求分析和设计;面向对象分析就是要搞清楚做什么,面向对象设计就是要搞清楚怎么做,面向对象编程就是将分析和设计的的结果翻译成代码的过程

b、两个阶段最终的产出:类的设计,包括程序被拆解为哪些类,每个类有哪些属性方法、类与类之间如何交互等等。

c、面向对象分析和设计与其他分析和设计最大的不同点:它们比其他的分析和设计更加具体、更加落地、更加贴近编码,更能够顺利地过渡到面向对象编程环节。

4、面向对象的四大特性

目标:理解每个特性的定义、存在的意义和目的,以及它们能解决哪些编程问题。

1.封装(Encapsulation)

a、封装:信息隐藏或者数据访问保护。类通过暴露有限的访问接口,授权外部仅能通过类提供的方式(或者叫函数)来访问内部信息或者数据 (将抽象得到的数据和行为(或功能)相结合,形成一个有机的整体,也就是将数据与操作数据的源代码进行有机的结合,形成“类”,其中数据和函数都是类的成员。)

b、封装的实现步骤:

i. 修改属性的可见性来限制对属性的访问(一般限制为private),例如:

public class Person {
    private String name;
    private int age;
}

ii. 对每个值属性提供对外的公共方法访问,也就是创建一对赋取值方法,用于对私有属性的访问,例如:

public class Person{
    private String name;
    private int age;
​
    public int getAge(){
      return age;
    }
​
    public String getName(){
      return name;
    }
​
    public void setAge(int age){
      this.age = age;
    }
​
    public void setName(String name){
      this.name = name;
    }
}

c、简化版的虚拟钱包的代码实现:

public class Wallet {
   //成员变量(属性)
   private String id; //唯一编号
   private long createTime; //创建时间
   private BigDecimal balance; //余额
   private long balanceLastModifiedTime; //上次钱包余额变更的时间
   // ... 省略其他属性...
   
   public Wallet() {
      this.id = IdGenerator.getInstance().generate();
      this.createTime = System.currentTimeMillis();
      this.balance = BigDecimal.ZERO;
      this.balanceLastModifiedTime = System.currentTimeMillis();
} 
​
   // 注意:下面对 get 方法做了代码折叠,是为了减少代码所占文章的篇幅
   public String getId() { return this.id; }
   public long getCreateTime() { return this.createTime; }
   public BigDecimal getBalance() { return this.balance; }
   public long getBalanceLastModifiedTime() { return this.balanceLastModifiedTime
   
   public void increaseBalance(BigDecimal increasedAmount) {
       if (increasedAmount.compareTo(BigDecimal.ZERO) < 0) {
          throw new InvalidAmountException("...");
       }
       this.balance.add(increasedAmount);
       this.balanceLastModifiedTime = System.currentTimeMillis();
   } 
   
   public void decreaseBalance(BigDecimal decreasedAmount) {
      if (decreasedAmount.compareTo(BigDecimal.ZERO) < 0) {
         throw new InvalidAmountException("...");
      }
      if (decreasedAmount.compareTo(this.balance) > 0) {
         throw new InsufficientAmountException("...");
      }
      this.balance.subtract(decreasedAmount);
      this.balanceLastModifiedTime = System.currentTimeMillis();
   }
}

d、调用者只允许通过下面这六个方法来访问或者修改钱包里的数据:

String getId()
long getCreateTime()
BigDecimal getBalance()
long getBalanceLastModifiedTime()
void increaseBalance(BigDecimal increasedAmount)
void decreaseBalance(BigDecimal decreasedAmount)

e、id、createTime 在创建钱包的时候就确定好了,之后不应该再被改动,所以并没有在 Wallet 类中,暴露 id、createTime 这两个属性的任何修改方法,比如 set 方法。这两个属性的初始化设置,对于 Wallet 类的调用者来说,也应该是透明的,所以,我们在 Wallet 类的构造函数内部将其初始化设置好,而不是通过构造函数的参数来外部赋值。对于钱包余额 balance 这个属性,从业务的角度来说,只能增或者减,不会被重新设置。所以在 Wallet 类中,只暴露了 increaseBalance() 和 decreaseBalance() 方法,并没有暴露 set 方法。对于balanceLastModifiedTime 这个属性,它完全是跟 balance 这个属性的修改操作绑定在一起的。只有在 balance 修改的时候,这个属性才会被修改。所以,我们把 balanceLastModifiedTime 这个属性的修改操作完全封装在了 increaseBalance() 和 decreaseBalance() 两个方法中,不对外暴露任何修改这个属性的方法和业务细节。这样可以保证 balance 和 balanceLastModifiedTime 两个数据的一致性

f、封装特性需要编程语言提供访问权限控制:private、public 等关键字就是 Java 语言中的访问权限控制语法。private 关键字修饰的属性只能类本身访问,可以保护其不被类之外的代码直接访问

g、封装特性存在的意义:一方面是保护数据不被随意修改,提高代码的可维护性;另一方面是仅暴露有限的必要接口,提高类的易用性

2.抽象(Abstraction)

a、抽象:如何隐藏方法的具体实现,让调用者只需要关心方法提供了哪些功能,并不需要知道这些功能是如何实现的。

b、抽象的实现:常借助编程语言提供的接口类(比如 Java 中的 interface 关键字语法)或者抽象类(比如 Java 中的 abstract 关键字语法)这两种语法机制,来实现抽象特性。(这个特性也并不需要编程语言提供特殊的语法机制来支持,只需要提供“函数”这一非常基础的语法机制,就可以实现抽象特性)

c、代码:

public interface IPictureStorage {
  void savePicture(Picture picture);
  Image getPicture(String pictureId);
  void deletePicture(String pictureId);
  void modifyMetaInfo(String pictureId, PictureMetaInfo metaInfo);
}
public class PictureStorage implements IPictureStorage {
  // ... 省略其他属性...
  @Override
  public void savePicture(Picture picture) { ... }
  @Override
  public Image getPicture(String pictureId) { ... }
  @Override
  public void deletePicture(String pictureId) { ... }
  @Override
  public void modifyMetaInfo(String pictureId, PictureMetaInfo metaInfo){ ...
}

d、利用 Java 中的 interface 接口语法来实现抽象特性。调用者在使用图片存储功能的时候,只需要了解 IPictureStorage 这个接口类暴露了哪些方法就可以了,不需要去查看 PictureStorage 类里的具体实现逻辑。 抽象这个特性是非常容易实现的,并不需要非得依靠接口类或者抽象类这些特殊语法机制来支持。换句话说,并不是说一定要为实现类(PictureStorage)抽象出接口类(IPictureStorage),才叫作抽象。即便不编写 IPictureStorage 接口类,单纯的PictureStorage 类本身就满足抽象特性。

e、类的方法是通过编程语言中的“函数”这一语法机制来实现的。通过函数包裹具体的实现逻辑,这本身就是一种抽象。调用者在使用函数的时候,并不需要去研究函数内部的实现逻辑,只需要通过函数的命名、注释或者文档,了解其提供了什么功能,就可以直接使用了。比如,我们在使用 C 语言的 malloc() 函数的时候,并不需要了解它的底层代码是怎么实现的。

f、抽象这个概念是一个非常通用的设计思想,并不单单用在面向对象编程中,也可以用来指导架构设计等。而且这个特性也并不需要编程语言提供特殊的语法机制来支持,只需要提 供“函数”这一非常基础的语法机制,就可以实现抽象特性、所以,它没有很强的“特异 性”,有时候并不被看作面向对象编程的特性之一

g、抽象的意义:一方面是提高代码的可扩展性、维护性,修改实现不需要改变定义,减少代码的改动范围;另一方面,它也是处理复杂系统的有效手段,能有效地过滤掉不必要关注的信息。

3.继承(Inheritance)

a、继承:是用来表示类之间的 is-a 关系,分为两种模式:单继承和多继承。单继承表示一个子类只继承一个父类,多继承表示一个子类可以继承多个父类。(子类继承父类的特征和行为,使得子类对象(实例)具有父类的实例域和方法,或子类从父类继承方法,使得子类具有父类相同的行为。)

b、继承的实现:编程语言需要提供特殊的语法机制来支持,比如 Java 使用extends 关键字来实现继承,C++ 使用冒号(class B : public A),Python 使用paraentheses(),Ruby 使用 <。不过,有些编程语言只支持单继承,不支持多重继承,比如 Java、PHP、C#、Ruby 等,而有些编程语言既支持单重继承,也支持多重继承,比如C++、Python、Perl 等。

c、类的继承格式:

在 Java 中通过 extends 关键字可以申明一个类是从另外一个类继承而来的,一般形式如下:

class 父类 {
}
 
class 子类 extends 父类 {
}

d、继承类型:

需要注意的是 Java 不支持多继承,但支持多重继承。

设计模式理论基础_第2张图片

 

e、继承的特性:

  • 子类拥有父类非 private 的属性、方法。

  • 子类可以拥有自己的属性和方法,即子类可以对父类进行扩展。

  • 子类可以用自己的方式实现父类的方法。

  • Java 的继承是单继承,但是可以多重继承,单继承就是一个子类只能继承一个父类,多重继承就是,例如 B 类继承 A 类,C 类继承 B 类,所以按照关系就是 B 类是 C 类的父类,A 类是 B 类的父类,这是 Java 继承区别于 C++ 继承的一个特性。

  • 提高了类之间的耦合性(继承的缺点,耦合度高就会造成代码之间的联系越紧密,代码独立性越差)。

f、继承关键字:

继承可以使用 extends 和 implements 这两个关键字来实现继承,而且所有的类都是继承于 java.lang.Object,当一个类没有继承的两个关键字,则默认继承object(这个类在java.lang 包中,所以不需要 import)祖先类。

extends关键字

在 Java 中,类的继承是单一继承,也就是说,一个子类只能拥有一个父类,所以 extends 只能继承一个类。

public class Animal { 
    private String name;   
    private int id; 
    public Animal(String myName, int myid) { 
        //初始化属性值
    } 
    public void eat() {  //吃东西方法的具体实现  } 
    public void sleep() { //睡觉方法的具体实现  } 
} 
 
public class Penguin  extends  Animal{ 
}

implements关键字

使用 implements 关键字可以变相的使java具有多继承的特性,使用范围为类继承接口的情况,可以同时继承多个接口(接口跟接口之间采用逗号分隔)。

public interface A {
    public void eat();
    public void sleep();
}
 
public interface B {
    public void show();
}
 
public class C implements A,B {
}

super 与 this 关键字

super关键字:我们可以通过super关键字来实现对父类成员的访问,用来引用当前对象的父类。

this关键字:指向自己的引用。

class Animal {
  void eat() {
    System.out.println("animal : eat");
  }
}
 
class Dog extends Animal {
  void eat() {
    System.out.println("dog : eat");
  }
  void eatTest() {
    this.eat();   // this 调用自己的方法
    super.eat();  // super 调用父类方法
  }
}
 
public class Test {
  public static void main(String[] args) {
    Animal a = new Animal();
    a.eat();
    Dog d = new Dog();
    d.eatTest();
  }
}

final 关键字

final 可以用来修饰变量(包括类属性、对象属性、局部变量和形参)、方法(包括类方法和对象方法)和类。

final 含义为 "最终的"。

使用 final 关键字声明类,就是把类定义定义为最终类,不能被继承,或者用于修饰方法,该方法不能被子类重写:

  • 声明类:

    final class 类名 {//类体}
  • 声明方法:

    修饰符(public/private/default/protected) final 返回值类型 方法名(){//方法体}

注: final 定义的类,其中的属性、方法不是 final 的。

构造函数

子类是不继承父类的构造器(构造方法或者构造函数)的,它只是调用(隐式或显式)。如果父类的构造器带有参数,则必须在子类的构造器中显式地通过 super 关键字调用父类的构造器并配以适当的参数列表。

如果父类构造器没有参数,则在子类的构造器中不需要使用 super 关键字调用父类构造器,系统会自动调用父类的无参构造器。

g、 为了实现继承这个特性,编程语言需要提供特殊的语法机制来支持。继承主要是用来解决代码复用的问题

4.多态(Polymorphism)

a、多态:指子类可以替换父类,在实际的代码运行过程中,调用子类的方法实现。

b、实例:

//父类
public class DynamicArray {
  private static final int DEFAULT_CAPACITY = 10;
  protected int size = 0;
  protected int capacity = DEFAULT_CAPACITY;
  protected Integer[] elements = new Integer[DEFAULT_CAPACITY];
  
  public int size() { return this.size; }
  public Integer get(int index) { return elements[index];}
  //... 省略 n 多方法...
  
  public void add(Integer e) {
    ensureCapacity();
    elements[size++] = e;
  } 
  
  protected void ensureCapacity() {
    //... 如果数组满了就扩容... 代码省略...
  }
} 
​
//子类继承父类
public class SortedDynamicArray extends DynamicArray {
  @Override //方法重写
  public void add(Integer e) {
    ensureCapacity();
    for (int i = size-1; i>=0; --i) { // 保证数组中的数据有序
      if (elements[i] > e) {
        elements[i+1] = elements[i];
      } else {
        break;
      }
     }
     elements[i+1] = e;
     ++size;
  }
} 
​
public class Example {
  public static void test(DynamicArray dynamicArray) {
    dynamicArray.add(5);
    dynamicArray.add(1);
    dynamicArray.add(3);
    for (int i = 0; i < dynamicArray.size(); ++i) {
      System.out.println(dynamicArray[i]);
    }
  } 
  
  public static void main(String args[]) {
    DynamicArray dynamicArray = new SortedDynamicArray(); //父类引用子类,声明的是父类,但是指向的却是子类
    test(dynamicArray); // 打印结果:1、3、5
  }
}

c、多态的实现原理:

i.编程语言要支持父类对象可以引用子类对象,也就是可以将SortedDynamicArray 传递给 DynamicArray

ii.编程语言要支持继承,也就是 SortedDynamicArray 继承了DynamicArray,才能将 SortedDyamicArray 传递给 DynamicArray

iii.编程语言要支持子类可以重写(override)父类中的方法,也就是SortedDyamicArray 重写了 DynamicArray 中的 add() 方法。

iv. 通过这三种语法机制配合在一起,我们就实现了在 test() 方法中,子类SortedDyamicArray 替换父类 DynamicArray,执行子类 SortedDyamicArray 的 add()方法,也就是实现了多态特性

d、多态特性的实现方式:

i.继承加方法重写

ii.利用接口类语法

实现:

//接口类Iterator,定义了一个可以遍历集合数据的迭代器
public interface Iterator {
  String hasNext();
  String next();
  String remove();
}
//Array实现了接口类 Iterator
public class Array implements Iterator {
  private String[] data;
  
  public String hasNext() { ... }
  public String next() { ... }
  public String remove() { ... }
  //... 省略其他方法...
} 
//LinkedList 实现了接口类 Iterator
public class LinkedList implements Iterator {
  private LinkedListNode head;
  
  public String hasNext() { ... }
  public String next() { ... }
  public String remove() { ... }
//... 省略其他方法...
} 
​
public class Demo {
  private static void print(Iterator iterator) {
    while (iterator.hasNext()) {
      System.out.println(iterator.next());
    }
  } 
  public static void main(String[] args) {
    //通过传递不同类型的实现类(Array、LinkedList)到 print(Iterator iterator) 函数中,支持动态的调用不同的 next()、hasNext() 实现
    Iterator arrayIterator = new Array();
    print(arrayIterator);
    
    Iterator linkedListIterator = new LinkedList();
    print(linkedListIterator);
  }
}

当我们往 print(Iterator iterator) 函数传递 Array 类型的对象的时候,print(Iterator iterator) 函数就会调用 Array 的 next()、hasNext() 的实现逻辑;当我们往print(Iterator iterator) 函数传递 LinkedList 类型的对象的时候,print(Iterator iterator)函数就会调用 LinkedList 的 next()、hasNext() 的实现逻辑

iii.利用 duck-typing 语法

实现:

class Logger:
  def record(self):
      print(“I write a log into file.”)
class DB:
  def record(self):
      print(“I insert data into db. ”)
def test(recorder):
    recorder.record()
def demo():
    logger = Logger()
    db = DB()
    test(logger)
    test(db)

duck-typing 实现多态的方式非常灵活。Logger 和 DB 两个类没有任何关系,既不是继承关系,也不是接口和实现的关系,但是只要它们都有定义了record() 方法,就可以被传递到 test() 方法中,在实际运行的时候,执行对应的 record()方法。

只要两个类具有相同的方法,就可以实现多态,并不要求两个类之间有任何关系,这就是所谓的 duck-typing,是一些动态语言所特有的语法机制。而像 Java 这样的静态语言,通过继承实现多态特性,必须要求两个类之间有继承关系,通过接口实现多态特性,类必须实现对应的接口。

f、多态的意义:多态可以提高代码的扩展性和复用性,是很多设计模式、设计原则、编程技巧的代码实现基础

5、面向对象和面向过程

1.什么是面向过程编程与面向过程编程语言?

a、面向对象:

i.面向对象编程是一种编程范式或编程风格。它以类或对象作为组织代码的基本单元,并将封装、抽象、继承、多态四个特性,作为代码设计和实现的基石

ii.面向对象编程语言是支持类或对象的语法机制,并有现成的语法机制,能方便地实现面向对象编程四大特性(封装、抽象、继承、多态)的编程语言

  public String formatToText() {
    // 将类 User 格式化成文本(" 小王\t28\t 男 ")
  }
} 
public class UserFileFormatter {
  public void format(String userFile,   String formattedUserFile) {
   // Open files...
   List users = new ArrayList<>();
   while (1) { // read until file is empty
    // read from file into userText...
    User user = User.parseFrom(userText);
    users.add(user);
   }
   // sort users by age...
   for (int i = 0; i < users.size(); ++i) {
     String formattedUserText = user.formatToText();
     // write to new file...
   }
  // close files...
 }
} 
public class MainApplication {
  public static void main(Sring[] args) {
    UserFileFormatter userFileFormatter = new UserFileFormatter();
    userFileFormatter.format("/home/zheng/users.txt", "/home/zheng/formatted_us
   }
}

b、面向过程:

i.面向过程编程也是一种编程范式或编程风格。它以过程(可以为理解方法、函数、操作)作为组织代码的基本单元,以数据(可以理解为成员变量、属性)与方法相分离为最主要的特点。面向过程风格是一种流程化的编程风格,通过拼接一组顺序执行的方法来操作数据完成一项功能

ii.面向过程编程语言首先是一种编程语言。它最大的特点是不支持类和对象两个语法概念,不支持丰富的面向对象编程特性(比如继承、多态、封装),仅支持面向过程编程

struct User {
  char name[64];
  int age;
  char gender[16];
};
struct User parse_to_user(char* text) {
  // 将 text(“小王 &28& 男”) 解析成结构体 struct User
} 
char* format_to_text(struct User user) {
  // 将结构体 struct User 格式化成文本(" 小王\t28\t 男 ")
} 
void sort_users_by_age(struct User users[]) {
  // 按照年龄从小到大排序 users
} 
void format_user_file(char* origin_file_path, char* new_file_path) {
  // open files...
  struct User users[1024]; // 假设最大 1024 个用户
  int count = 0;
  while(1) { // read until the file is empty
    struct User user = parse_to_user(line);
    users[count++] = user;
  } 
  
  sort_users_by_age(users);
 
  for (int i = 0; i < count; ++i) {
    char* formatted_user_text = format_to_text(users[i]);
    // write to new file...
  }
  // close files...
} 
​
int main(char** args, int argv) {
  format_user_file("/home/zheng/user.txt", "/home/zheng/formatted_users.txt");
}

c、面向过程和面向对象最基本的区别:代码的组织方式不同。面向过程风格的代码被组织成了一组方法集合及其数据结构(struct User),方法和数据结构的定义是分开的。面向对象风格的代码被组织成一组类,方法和数据结构被绑定一起,定义在类中

2.面向对象编程相比面向过程编程有哪些优势?

a、OOP 更加能够应对大规模复杂程序的开发

i.对于简单程序的开发来说,不管是用面向过程编程风格,还是用面向对象编程风格,差别确实不会很大,甚至有的时候,面向过程的编程风格反倒更有优势

ii.对于大规模复杂程序的开发来说,整个程序的处理流程错综复杂,并非只有一条主线。如果把整个程序的处理流程画出来的话,会是一个网状结构。如果我们再用面向过程编程这种流程化、线性的思维方式,去翻译这个网状结构,去思考如何把程序拆解为一组顺序执行的方法,就会比较吃力。这个时候,面向对象的编程风格的优势就比较明显了

iii.面向对象编程是以类为思考对象。在进行面向对象编程的时候,我们并不是一上来就去思考,如何将复杂的流程拆解为一个一个方法,而是采用曲线救国的策略,先去思考如何给业务建模,如何将需求翻译为类,如何给类之间建立交互关系,而完成这些工作完全不需要考虑错综复杂的处理流程。当我们有了类的设计之后,然后再像搭积木一样,按照处理流程,将类组装起来形成整个程序。这种开发模式、思考问题的方式,能让我们在应对复杂程序开发的时候,思路更加清晰

iv.面向对象编程还提供了一种更加清晰的、更加模块化的代码组织方式。类就是一种非常好的组织这些函数和数据结构的方式,是一种将代码模块化的有效手段。

b、OOP 风格的代码更易复用、易扩展、易维护

i.面向过程编程是一种非常简单的编程风格,并没有像面向对象编程那样提供丰富的特性。而面向对象编程提供的封装、抽象、继承、多态这些特性,能极大地满足复杂的编程需求,能方便我们写出更易复用、易扩展、易维护的代码

ii.封装特性是面向对象编程相比于面向过程编程的一个最基本的区别,因为它基于的是面向对象编程中最基本的类的概念。面向对象编程通过类这种组织代码的方式,将数据和方法绑定在一起,通过访问权限控制,只允许外部调用者通过类暴露的有限方法访问数据,而不会像面向过程编程那样,数据可以被任意方法随意修改。因此,面向对象编程提供的封装特性更有利于提高代码的易维护性

iii.函数本身就是一种抽象,它隐藏了具体的实现。我们在使用函数的时候,只需要了解函数具有什么功能,而不需要了解它是怎么实现的。不管面向过程编程还是是面向对象编程,都支持抽象特性。不过,面向对象编程还提供了其他抽象特性的实现方式。这些实现方式是面向过程编程所不具备的,比如基于接口实现的抽象。基于接口的抽象,可以让我们在不改变原有实现的情况下,轻松替换新的实现逻辑,提高了代码的可扩展性

iv.继承特性是面向对象编程相比于面向过程编程所特有的两个特性之一(另一个是多态)。如果两个类有一些相同的属性和方法,我们就可以将这些相同的代码,抽取到父类中,让两个子类继承父类。这样两个子类也就可以重用父类中的代码,避免了代码重复写多遍,提高了代码的复用性。

v.多态特性:基于这个特性,我们在需要修改一个功能实现的时候,可以通过实现一个新的子类的方式,在子类中重写原来的功能逻辑,用子类替换父类。在实际的代码运行过程中,调用子类新的功能逻辑,而不是在原有代码上做修改。这就遵从了“对修改关闭、对扩展开放”的设计原则,提高代码的扩展性。除此之外,利用多态特性,不同的类对象可以传递给相同的方法,执行不同的代码逻辑,提高了代码的复用性

c、OOP 语言更加人性化、更加高级、更加智能

二进制指令、汇编语言、面向过程编程语言是一种计算机思维方式,而面向对象是一种人类的思维方式。我们在用前面三种语言编程的时候,我们是在思考,如何设计一组指令,告诉机器去执行这组指令,操作某些数据,帮我们完成某个任务。而在进行面向对象编程时候,我们是在思考,如何给业务建模,如何将真实的世界映射为类或者对象,这让我们更加能聚焦到业务本身,而不是思考如何跟机器打交道

3.有哪些看似是面向对象实际是面向过程风格的代码?

a、滥用 getter、setter 方法

i.违反了面向对象编程的封装特性,相当于将面向对象编程风格退化成了面向过程编程风格

ii.示例代码:

//ShoppingCart 是一个简化后的购物车类,有三个私有(private)属性:itemsCount、totalPrice、items。对于 itemsCount、totalPrice 两个属性,我们定义了它们的 getter、setter 方法。
public class ShoppingCart {
  private int itemsCount;
  private double totalPrice;
  private List items = new ArrayList<>();
  
  public int getItemsCount() {
    return this.itemsCount;
  } 
  
  public void setItemsCount(int itemsCount) {
    this.itemsCount = itemsCount;
  }
  
  public double getTotalPrice() {
    return this.totalPrice;
  } 
  
  public void setTotalPrice(double totalPrice) 
    this.totalPrice = totalPrice;
  } 
  
  public List getItems() {
    return this.items;
  } 
  
  public void addItem(ShoppingCartItem item) {
    items.add(item);
    itemsCount++;
    totalPrice += item.getPrice();
  }
  // ... 省略其他方法...
}

iii.前两个属性,itemsCount 和 totalPrice。虽然我们将它们定义成 private 私有属性,但是提供了 public 的 getter、setter 方法,这就跟将这两个属性定义为 public 公有属性,没有什么两样了。外部可以通过 setter 方法随意地修改这两个属性的值。除此之外,任何代码都可以随意调用 setter 方法,来重新设置 itemsCount、totalPrice 属性的值,这也会导致其跟 items 属性的值不一致

iv.对于 items 这个属性,我们定义了它的getter 方法和 addItem() 方法,并没有定义它的 setter 方法。 items 属性的 getter 方法,返回的是一个 List集合容器。外部调用者在拿到这个容器之后,是可以操作容器内部数据的,也就是说,外部代码还是能修改 items 中的数据

v.修改:通过 Java 提供的Collections.unmodifiableList() 方法,让 getter 方法返回一个不可被修改的UnmodifiableList 集合容器,而这个容器类重写了 List 容器中跟修改数据相关的方法,比如 add()、clear() 等方法。一旦我们调用这些修改数据的方法,代码就会抛出UnsupportedOperationException 异常,这样就避免了容器中的数据被修改

vi.在设计实现类的时候,除非真的需要,否则,尽量不要给属性定义 setter 方法。尽管getter 方法相对 setter 方法要安全些,但是如果返回的是集合容器(比如例子中的 List 容器),也要防范集合内部数据被修改的危险

b、 滥用全局变量和全局方法

i.在面向对象编程中常见的全局变量有:单例类对象、静态成员变量、常量等,常见的全局方法有静态方法。单例类对象在全局代码中只有一份,所以,它相当于一个全局变量。静态成员变量归属于类上的数据,被所有的实例化对象所共享,也相当于一定程度上的全局变量。而常量是一种非常常见的全局变量,比如一些代码中的配置参数,一般都设置为常量,放到一个 Constants 类中。静态方法一般用来操作静态变量或者外部数据;静态方法将方法与数据分离,破坏了封装特性,是典型的面向过程风格

ii.一种常见的 Constants 类的定义方法:

public class Constants {
  public static final String MYSQL_ADDR_KEY = "mysql_addr";
  public static final String MYSQL_DB_NAME_KEY = "db_name";
  public static final String MYSQL_USERNAME_KEY = "mysql_username";
  public static final String MYSQL_PASSWORD_KEY = "mysql_password";
  public static final String REDIS_DEFAULT_ADDR = "192.168.7.2:7234";
  public static final int REDIS_DEFAULT_MAX_TOTAL = 50;
  public static final int REDIS_DEFAULT_MAX_IDLE = 50;
  public static final int REDIS_DEFAULT_MIN_IDLE = 20;
  public static final String REDIS_DEFAULT_KEY_PREFIX = "rt:";
  
  // ... 省略更多的常量定义...
}

iii.把程序中所有用到的常量,都集中地放到这个 Constants 类中的缺点:

影响代码的可维护性:查找修改某个常量也会变得比较费时,而且还会增加提交代码冲突的概率

增加代码的编译时间:当 Constants 类中包含很多常量定义的时候,依赖这个类的代码就会很多。那每次修改 Constants 类,都会导致依赖它的类文件重新编译,因此会浪费很多不必要的编译时间

影响代码的复用性:如果我们要在另一个项目中,复用本项目开发的某个类,而这个类又依赖 Constants 类。 即便这个类只依赖 Constants 类中的一小部分常量,我们仍然需要把整个 Constants 类也一并引入,也就引入了很多无关的常量到新的项目中。

iv.改进思路:将 Constants 类拆解为功能更加单一的多个类,比如跟 MySQL 配置相关的常量,我们放到 MysqlConstants 类中;跟 Redis 配置相关的常量,我们放到RedisConstants 类中;并不单独地设计 Constants 常量类,而是哪个类用到了某个常量,我们就把这个常量定义到这个类中

c、Utils 类

i.Utils 类的出现是基于这样一个问题背景:如果我们有两个类 A 和 B(A和B无继承关系),它们要用到一块相同的功能逻辑,为了避免代码重复,我们不应该在两个类中,将这个相同的功能逻辑,重复地实现两遍

ii.设计 Utils 类的时候,最好也能细化一下,针对不同的功能,设计不同的 Utils 类,比如 FileUtils、IOUtils、StringUtils、UrlUtils 等,不要设计一个过于大而全的 Utils 类

d、定义数据和方法分离的类

i.数据定义在一个类中,方法定义在另一个类中

ii.传统的 MVC 结构分为 Model 层、Controller 层、View 层这三层。不过,在做前后端分离之后,三层结构在后端开发中,被分为 Controller 层、Service 层、Repository 层。Controller 层负责暴露接口给前端调用,Service 层负责核心业务逻辑,Repository 层负责数据读写。而在每一层中,我们又会定义相应的 VO(View Object)、 BO(Business Object)、Entity。一般情况下,VO、BO、Entity 中只会定义数据,不会定义方法,所有操作这些数据的业务逻辑都定义在对应的 Controller 类、Service 类、Repository 类中

iii.这种开发模式叫作基于贫血模型的开发模式,也是我们现在非常常用的一种 Web项目的开发模式

4.在面向对象编程中,为什么容易写出面向过程风格的代码?

面向过程编程风格恰恰符合人的这种流程化思维方式。而面向对象编程风格正好相反。它是一种自底向上的思考方式;它不是先去按照执行流程来分解任务,而是将任务翻译成一个一个的小的模块(也就是类),设计类之间的交互,最后按照流程将类组装起来,完成整个任务。

在面向对象编程中,类的设计还是挺需要技巧,挺需要一定设计经验的。你要去思考如何封装合适的数据和方法到一个类里,如何设计类之间的关系,如何设计类之间的交互等等诸多设计问题。

6、接口和抽象类

抽象类和接口是两个经常被用到的语法概念,是面向对象四大特性,以及很多设计模式、设计思想、设计原则编程实现的基础;使用接口来实现面向对象的抽象特性、多态特性和基于接口而非实现的设计原则,使用抽象类来实现面向对象的继承特性和模板设计模式等等

1.抽象类和接口的语法特性

a、抽象类的定义:

// Logger 是一个记录日志的抽象类
public abstract class Logger {
  private String name;
  private boolean enabled;
  private Level minPermittedLevel;
  
  public Logger(String name, boolean enabled, Level minPermittedLevel) {
    this.name = name;
    this.enabled = enabled;
    this.minPermittedLevel = minPermittedLevel;
  } 
  public void log(Level level, String message) {
    boolean loggable = enabled && (minPermittedLevel.intValue() <= level.intVal
    if (!loggable) return;
    doLog(level, message);
  }
  protected abstract void doLog(Level level, String message);
}
// 抽象类的子类:输出日志到文件
public class FileLogger extends Logger {
  private Writer fileWriter;
  
  public FileLogger(String name, boolean enabled,
    Level minPermittedLevel, String filepath) {
    super(name, enabled, minPermittedLevel);
    this.fileWriter = new FileWriter(filepath);
  } 
​
  @Override
  public void doLog(Level level, String mesage) {
    // 格式化 level 和 message, 输出到日志文件
    fileWriter.write(...);
  }
}
// 抽象类的子类: 输出日志到消息中间件 (比如 kafka)
public class MessageQueueLogger extends Logger {
  private MessageQueueClient msgQueueClient;
  
  public MessageQueueLogger(String name, boolean enabled,
    Level minPermittedLevel, MessageQueueClient msgQueueClient) {
    super(name, enabled, minPermittedLevel);
    this.msgQueueClient = msgQueueClient;
  } 
  
  @Override
  protected void doLog(Level level, String mesage) {
    // 格式化 level 和 message, 输出到消息中间件
    msgQueueClient.send(...);
  }
}

b、Logger 是一个记录日志的抽象类,FileLogger 和 MessageQueueLogger 继承 Logger,分别实现两种不同的日志记录方式:记录日志到文件中和记录日志到消息队列中。FileLogger 和MessageQueueLogger 两个子类复用了父类 Logger 中的 name、enabled、minPermittedLevel 属性和 log() 方法,但因为这两个子类写日志的方式不同,它们又各自重写了父类中的 doLog() 方法

c、抽象类的三个特性:

i.抽象类不允许被实例化,只能被继承(不能 new 一个抽象类的对象出来(Logger logger = new Logger(…); 会报编译错误) )

ii.抽象类可以包含属性和方法。方法既可以包含代码实现(比如 Logger 中的 log() 方 法),也可以不包含代码实现(比如 Logger 中的 doLog() 方法)。不包含代码实现的 方法叫作抽象方法

iii.子类继承抽象类,必须实现抽象类中的所有抽象方法。对应到例子代码中就是,所有继 承 Logger 抽象类的子类,都必须重写 doLog() 方法。

d、接口的定义:

// 接口
public interface Filter {
  void doFilter(RpcRequest req) throws RpcException;
}
// 接口实现类:鉴权过滤器
public class AuthencationFilter implements Filter {
  @Override
  public void doFilter(RpcRequest req) throws RpcException {
    //... 鉴权逻辑..
  }
}
// 接口实现类:限流过滤器
public class RateLimitFilter implements Filter {
  @Override
  public void doFilter(RpcRequest req) throws RpcException {
    //... 限流逻辑...
  }
}
// 过滤器使用 demo
public class Application {
  // filters.add(new AuthencationFilter());
  // filters.add(new RateLimitFilter());
  private List filters = new ArrayList<>();
  
  public void handleRpcRequest(RpcRequest req) {
    try {
      for (Filter filter : fitlers) {
        filter.doFilter(req);
      }
     } catch(RpcException e) {
       // ... 处理过滤结果...
     }
     // ... 省略其他处理逻辑...
  }
}

e、通过 Java 中的 interface 关键字定义了一个 Filter 接口。AuthencationFilter 和 RateLimitFilter 是接口的两个实现类,分别实现了对 RPC 请求鉴权和限流的过滤功能。

f、接口的三个特性:

i.接口不能包含属性(也就是成员变量)。

ii.接口只能声明方法,方法不能包含代码实现。

iii.类实现接口的时候,必须实现接口中声明的所有方法。

g、抽象类实际上就是类,只不过是一种特殊的类,这种类不能被实例化为对象,只能被子类继承。我们知道,继承关系是一种 is-a 的关系,那抽象类既然属于类,也表示一种 is-a 的关系。相对于抽象类的 is-a 关系来说,接口表示一种 has-a 关系,表示具有某些功能。对于接口,有一个更加形象的叫法,那就是协议(contract)。

2.抽象类和接口存在的意义

a、抽象类不能实例化,只能被继承。继承能解决代码复用的问题。抽象类也是为代码复用而生的。多个子类可以继承抽象类中定义的属性和方法,避免在子类中,重复编写相同的代码

b、抽象类更多的是为了代码复用,而接口就更侧重于解耦。接口是对行为的一种抽象,相当于一组协议或者契约,你可以联想类比一下 API 接口。调用者只需要关注抽象的接口,不需要了解具体的实现,具体的实现代码对调用者透明。接口实现了约定和实现相分离,可以降低代码间的耦合性,提高代码的可扩展性。

c、抽象类是对成员变量和方法的抽象,是一种 is-a 关系,是为了解决代码复用问题。接口仅仅是对方法的抽象,是一种 has-a 关系,表示具有某一组行为特性,是为了解决解耦问题,隔离接口和具体的实现,提高代码的扩展性。

3.模拟抽象类和接口

a、可以通过抽象类来模拟接口

class Strategy { // 用抽象类模拟接口
  public:
    ~Strategy();
    virtual void algorithm()=0;
  protected:
    Strategy();
};

b、抽象类 Strategy 没有定义任何属性,并且所有的方法都声明为 virtual 类型(等同于 Java中的 abstract 关键字),这样,所有的方法都不能有代码实现,并且所有继承这个抽象类的子类,都要实现这些方法。从语法特性上来看,这个抽象类就相当于一个接口。

c、用普通类来模拟接口:

public class MockInteface {
  protected MockInteface() {}
  public void funcA() {
    throw new MethodUnSupportedException();
  }
}

d、类中的方法必须包含实现,这个不符合接口的定义。但是,我们可以让类中的方法抛出 MethodUnSupportedException 异常,来模拟不包含实现的接口,并且能强迫子类在继承这个父类的时候,都去主动实现父类的方法,否则就会在运行时抛出异常。那又如何避免这个类被实例化呢?实际上很简单,我们只需要将这个类的构造函数声明为 protected访问权限就可以了。

4.抽象类和接口的应用场景区别

判断的标准很简单。如果我们要表示一种 is-a 的关系,并且是为了解决代码复用的问题,我们就用抽象类;如果我们要表示一种 has-a 关系,并且是为了解决抽象而非代码复用的问题,那我们就可以使用接口。

从类的继承层次上来看,抽象类是一种自下而上的设计思路,先有子类的代码重复,然后再抽象成上层的父类(也就是抽象类)。而接口正好相反,它是一种自上而下的设计思路。我们在编程的时候,一般都是先设计接口,再去考虑具体的实现。

7、基于接口而非实现编程

1.如何解读原则中的“接口”二字?

a、从本质上来看,“接口”就是一组“协议”或者“约定”,是功能提供者提供给使用者的一个“功能列表”。“接口”在不同的应用场景下会有不同的解读,比如服务端与客户端之间的“接口”,类库提供的“接口”,甚至是一组通信的协议都可以叫作“接口”。

b、“基于接口而非实现编程”这条原则中的“接口”,可以理解为编程语言中的接口或者抽象类

c、接口有效地提高代码质量:应用这条原则,可以将接口和实现相分离,封装不稳定的实现,暴露稳定的接口。上游系统面向接口而非实现编程,不依赖不稳定的实现细节,这样当实现发生变化的时候,上游系统的代码基本上不需要做改动,以此来降低耦合性,提高扩展性

d、 基于接口而非实现编程”=“基于抽象而非实现编程” ;越抽象、越顶层、越脱离具体某一实现的设计,越能提高代码的灵活性,越能应对未来的需求变化。好的代码设计,不仅能应对当下的需求,而且在将来需求发生变化的时候,仍然能够在不破坏原有代码设计的情况下灵活应对。而抽象就是提高代码扩展性、灵活性、可维护性最有效的手段之一

2.如何将这条原则应用到实战中

a、实战案例: 假设我们的系统中有很多涉及图片处理和存储的业务逻辑。图片经过处理之后被上传到阿里云上。为了代码复用,我们封装了图片存储相关的代码逻辑,提供了一个统一的AliyunImageStore 类,供整个系统来使用

public class AliyunImageStore {
  //... 省略属性、构造函数等...
  
  public void createBucketIfNotExisting(String bucketName) {
    // ... 创建 bucket 代码逻辑...
    // ... 失败会抛出异常..
  }
  
  public String generateAccessToken() {
    // ... 根据 accesskey/secrectkey 等生成 access token
  }
  
  public String uploadToAliyun(Image image, String bucketName, String accessTok
   //... 上传图片到阿里云...
   //... 返回图片存储在阿里云上的地址 (url)...
  }
  public Image downloadFromAliyun(String url, String accessToken) {
    //... 从阿里云下载图片...
  }
}
​
//AliyunImageStore 类的使用举例
public class ImageProcessingJob {
  private static final String BUCKET_NAME = "ai_images_bucket";
  //... 省略其他无关代码...
  
  public void process() {
    Image image = ...; // 处理图片,并封装为 Image 对象
    AliyunImageStore imageStore = new AliyunImageStore(/* 省略参数 */);
    imageStore.createBucketIfNotExisting(BUCKET_NAME);
    String accessToken = imageStore.generateAccessToken();
    imagestore.uploadToAliyun(image, BUCKET_NAME, accessToken);
  }
}

b、整个上传流程包含三个步骤:创建 bucket(你可以简单理解为存储目录)、生成 access token 访问凭证、携带 access token 上传图片到指定的 bucket 中。

c、我们自建了私有云,不再将图片存储到阿里云了,而是将图片存储到自建私有云上:

i.要求必须将 AliyunImageStore 类中所定义的所有 public 方法,在 PrivateImageStore 类中都逐一定义并重新实现一遍

ii.AliyunImageStore 类中有些函数命名暴露了实现细节

iii.将图片存储到阿里云的流程,跟存储到私有云的流程,可能并不是完全一致的

d、解决这个问题的根本方法就是要遵从“基于接口而非实现编程”的原则:

i.函数的命名不能暴露任何实现细节。比如,前面提到的 uploadToAliyun() 就不符合要求,应该改为去掉 aliyun 这样的字眼,改为更加抽象的命名方式,比如:upload()。

ii.封装具体的实现细节。比如,跟阿里云相关的特殊上传(或下载)流程不应该暴露给调用者。我们对上传(或下载)流程进行封装,对外提供一个包裹所有上传(或下载)细节的方法,给调用者使用。

iii.为实现类定义抽象的接口。具体的实现类都依赖统一的接口定义,遵从一致的上传功能协议。使用者依赖接口,而不是具体的实现类来编程

e、通过实现类来反推接口的定义。先把实现类写好,然后看实现类中有哪些方法,照抄到接口定义中。如果按照这种思考方式,就有可能导致接口定义不够抽象,依赖具体的实现; 只是将实现类的方法搬移到接口定义中的时候,要有选择性的搬移,不要将跟具体实现相关的方法搬移到接口中

f、在做软件开发的时候,一定要有抽象意识、封装意识、接口意识。在定义接口的时候,不要暴露任何实现细节。接口的定义只表明做什么,而不是怎么做。而且,在设计接口的时候,我们要多思考一下,这样的接口设计是否足够通用,是否能够做到在替换具体的接口实现的时候,不需要任何接口定义的改动。

3.是否需要为每个类定义接口?

a、这条原则的设计初衷是:将接口和实现相分离,封装不稳定的实现,暴露稳定的接口。上游系统面向接口而非实现编程,不依赖不稳定的实现细节,这样当实现发生变化的时候,上游系统的代码基本上不需要做改动,以此来降低代码间的耦合性,提高代码的扩展性

b、如果在我们的业务场景中,某个功能只有一种实现方式,未来也不可能被其他实现方式替换,那我们就没有必要为其设计接口,也没有必要基于接口编程,直接使用实现类就可以了

c、越是不稳定的系统,我们越是要在代码的扩展性、维护性上下功夫。相反,如果某个系统特别稳定,在开发完之后,基本上不需要做维护,那我们就没有必要为其扩展性,投入不必要的开发时间。

d、“基于接口而非实现编程”这条原则,不仅仅可以指导非常细节的编程开发,还能指导更加上层的架构设计、系统设计等。比如,服务端与客户端之间的“接口”设计、类库的“接口”设计。

8、多用组合少用继承

1.为什么不推荐使用继承?

继承是面向对象的四大特性之一,用来表示类之间的 is-a 关系,可以解决代码复用的问题。虽然继承有诸多作用,但继承层次过深、过复杂,也会影响到代码的可维护性。在这种情况下,我们应该尽量少用,甚至不用继承。

2.组合相比继承有哪些优势?

a、继承主要有三个作用:表示 is-a 关系,支持多态特性,代码复用。is-a 关系,我们可以通过组合和接口的 has-a 关系来替代;多态特性我们可以利用接口来实现;代码复用我们可以通过组合和委托来实现。 而这三个作用都可以通过组合、接口、委托三个技术手段来达成。除此之外,利用组合还能解决层次过深、过复杂的继承关系影响代码可维护性的问题。

b、 接口表示具有某种行为特性,接口只声明方法,不定义实现。针对接口再定义实现类,然后,通过组合和委托技术来消除代码重复。

3.如何判断该用组合还是继承?

要根据具体的情况,来选择该用继承还是组合。如果类之间的继承结构稳定,层次比较浅,关系不复杂,我们就可以大胆地使用继承。反之,我们就尽量使用组合来替代继承。除此之外,还有一些设计模式、特殊的应用场景,会固定使用继承或者组合

9、贫血模型和充血模型

1.什么是贫血模型?什么是充血模型?

a、MVC 三层架构:MVC 三层架构中的 M 表示 Model,V 表示 View,C 表示 Controller。它将整个项目分 为三层:展示层、逻辑层、数据层。MVC 三层开发架构是一个比较笼统的分层方式,落实到具体的开发层面,很多项目也并不会 100% 遵从 MVC 固定的分层方式,而是会根据具体的项目需求,做适当的调整。

b、现在很多 Web 或者 App 项目都是前后端分离的,后端负责暴露接口给前端调用。一般就将后端项目分为 Repository 层、Service 层、Controller 层。其中,Repository 层负责数据访问,Service 层负责业务逻辑,Controller 层负责暴露接口。

c、贫血模型:

// Controller+VO(View Object) //
///接口层/
public class UserController {
  private UserService userService; // 通过构造函数或者 IOC 框架注入
​
  public UserVo getUserById(Long userId) {
    UserBo userBo = userService.getUserById(userId);
    UserVo userVo = [...convert userBo to userVo...];
    return userVo;
  }
}
  public class UserVo {// 省略其他属性、get/set/construct 方法
    private Long id;
    private String name;
    private String cellphone;
} 
// Service+BO(Business Object) //
///业务逻辑层/
public class UserService {
  private UserRepository userRepository; // 通过构造函数或者 IOC 框架注入
  
  public UserBo getUserById(Long userId) {
    UserEntity userEntity = userRepository.getUserById(userId);
    UserBo userBo = [...convert userEntity to userBo...];
    return userBo;
  }
} 
​
  public class UserBo {// 省略其他属性、get/set/construct 方法
    private Long id;
    private String name;
    private String cellphone;
} 
​
// Repository+Entity //
///数据访问层/
  public class UserRepository {
    public UserEntity getUserById(Long userId) { //... }
} 
  
  public class UserEntity {// 省略其他属性、get/set/construct 方法
    private Long id;
    private String name;
    private String cellphone;
  }

d、数据访问层(Repository):UserEntity 和UserRepository

业务逻辑层(Service):UserBo 和 UserService ;UserBo 是一个纯粹的数据结构,只包含数据,不包含任何业务逻辑。业务逻辑集中在 UserService 中,通过 UserService 来操作 UserBo

接口层(Controller):UserVo和 UserController

e、Service 层的数据和业务逻辑,被分割为 BO 和 Service 两个类中。像 UserBo 这样,只包含数据,不包含业务逻辑的类,就叫作贫血模型。UserEntity、UserVo 都是基于贫血模型设计的。这种贫血模型将数据与操作分离,破坏了面向对象的封装特性,是一种典型的面向过程的编程风格

2.什么是基于充血模型的 DDD 开发模式?

a、充血模型(Rich Domain Model):数据和对应的业务逻辑被封装到同一个类中

b、领域驱动设计: 即 DDD,主要是用来指导如何解耦业务系统,划分业务模块,定义业务领域模型及其交互

c、做好领域驱动设计的关键是:对自己所做业务的熟悉程度,而并不是对领域驱动设计这个概念本身的掌握程度。即便你对领域驱动搞得再清楚,但是对业务不熟悉,也并不一定能做出合理的领域设计

d、基于充血模型的 DDD 开发模式实现的代码,也是按照 MVC 三层架构分层的。Controller 层还是负责暴露接口,Repository 层还是负责数据存取,Service 层负责核心业务逻辑。它跟基于贫血模型的传统开发模式的区别主要在 Service 层

e、贫血模型和充血模型的区别:

贫血模型:Service 层包含 Service 类和 BO 类两部分,BO 是贫血模型,只包含数据,不包含具体的业务逻辑。业务逻辑集中在 Service 类中

充血模型:Service 层包含 Service 类和 Domain 类两部分。Domain 就相当于贫血模型中的 BO。不过,Domain 与 BO 的区别在于它是基于充血模型开发的,既包含数据,也包含业务逻辑。而 Service 类变得非常单薄

区别: 基于贫血模型的传统的开发模式,重 Service 轻 BO;基于充血模型的 DDD 开发模式,轻 Service 重Domain

3.为什么基于贫血模型的传统开发模式如此受欢迎?

a、贫血模型缺点:基于贫血模型的传统开发模式,将数据与业务逻辑分离,违反了 OOP 的封装特性,实际上是一种面向过程的编程风格。数据和操作分离之后,数据本身的操作就不受限制了。任何代码都可以随意修改数据

b、贫血模型受欢迎的三点原因:

i.大部分情况下,我们开发的系统业务可能都比较简单,简单到就是基于SQL 的 CRUD 操作,所以,我们根本不需要动脑子精心设计充血模型,贫血模型就足以应付这种简单业务的开发工作

ii.充血模型的设计要比贫血模型更加有难度。因为充血模型是一种面向对象的编程风格。我们从一开始就要设计好针对数据要暴露哪些操作,定义哪些业务逻辑。而不是像贫血模型那样,我们只需要定义数据,之后有什么功能开发需求,我们就在 Service 层定义什么操作,不需要事先做太多设计。

iii.思维已固化,转型有成本。基于贫血模型的传统开发模式经历了这么多年,已经深得人心、习以为常。

4.什么项目应该考虑使用基于充血模型的 DDD 开发模式?

a、基于贫血模型的传统的开发模式,比较适合业务比较简单的系统开发。相对应的,基于充血模型的 DDD 开发模式,更适合业务复杂的系统开发。比如,包含各种利息计算模型、还款模型等复杂业务的金融系统

b、代码层面的区别:一个将业务逻辑放到Service 类中,一个将业务逻辑放到 Domain 领域模型中

c、其他区别:两种不同的开发模式会导致不同的开发流程。

d、基于贫血模型的传统的开发模式实现功能需求: 平时的开发,大部分都是 SQL 驱动(SQL-Driven)的开发模式。我们接到一个后端接口的开发需求的时候,就去看接口需要的数据对应到数据库中,需要哪张表或者哪几张表,然后思考如何编写 SQL 语句来获取数据。之后就是定义 Entity、BO、VO,然后模板式地往对应的 Repository、Service、Controller 类中添加代码。业务逻辑包裹在一个大的 SQL 语句中,而 Service 层可以做的事情很少。SQL 都是针对特定的业务功能编写的,复用性差。当我要开发另一个业务功能的时候,只能重新写满足新需求的 SQL 语句,这就可能导致各种长得差不多、区别很小的 SQL 语句满天飞

e、应用基于充血模型的 DDD 的开发模式的开发流程:需要事先理清楚所有的业务,定义领域模型所包含的属性和方法。领域模型相当于可复用的业务中间层。新功能需求的开发,都基于之前定义好的这些领域模型来完成

f、越复杂的系统,对代码的复用性、易维护性要求就越高,我们就越应该花更多的时间和精力在前期设计上。而基于充血模型的 DDD 开发模式,正好需要我们前期做大量的业务调研、领域模型设计,所以它更加适合这种复杂系统的开发

10、利用基于充血模型的DDD开发一个虚拟钱包系统

1.钱包业务背景介绍

很多具有支付、购买功能的应用(比如淘宝、滴滴出行、极客时间等)都支持钱包的功能。应用为每个用户开设一个系统内的虚拟钱包账户,支持用户充值、提现、支付、冻结、透支、转赠、查询账户余额、查询交易流水等操作。

设计模式理论基础_第3张图片

 

每个虚拟钱包账户都会对应用户的一个真实的支付账户,有可能是银行卡账户,也有可能是三方支付账户(比如支付宝、微信钱包)

a、充值

i.过程:用户通过三方支付渠道,把自己银行卡账户内的钱,充值到虚拟钱包账号中

ii.操作流程:第一个操作是从用户的银行卡账户转账到应用的公共银行卡账户;第二个操作是将用户的充值金额加到虚拟钱包余额上;第三个操作是记录刚刚这笔交易流水。

设计模式理论基础_第4张图片

 

b、支付

用户用钱包内的余额,支付购买应用内的商品。实际上,支付的过程就是一个转账的过程。支付的过程就是一个转账的过程,从用户的虚拟钱包账户划钱到商家的虚拟钱包账户上,然后触发真正的银行转账操作,从应用的公共银行账户转钱到商家的银行账户(注意,这里并不是从用户的银行账户转钱到商家的银行账户)。除此之外,我们也需要记录这笔支付的交易流水信息。

设计模式理论基础_第5张图片

 

c、提现

用户还可以将虚拟钱包中的余额,提现到自己的银行卡中。这个过程实际上就是扣减用户虚拟钱包中的余额,并且触发真正的银行转账操作,从应用的公共银行账户转钱到用户的银行账户。同样,我们也需要记录这笔提现的交易流水信息。

设计模式理论基础_第6张图片

 

d、查询余额

查询余额功能比较简单,我们看一下虚拟钱包中的余额数字即可。

e、查询交易流水

查询交易流水也比较简单。我们只支持三种类型的交易流水:充值、支付、提现。在用户充值、支付、提现的时候,我们会记录相应的交易信息。在需要查询的时候,我们只需要将之前记录的交易流水,按照时间、类型等条件过滤之后,显示出来即可。

2.钱包系统的设计思路

a、根据业务实现流程和数据流转图,可以把整个钱包系统的业务划分为两部分,其中一部分单纯跟应用内的虚拟钱包账户打交道,另一部分单纯跟银行账户打交道。我们基于这样一个业务划分,给系统解耦,将整个钱包系统拆分为两个子系统:虚拟钱包系统和三方支付系统

设计模式理论基础_第7张图片

 

b、虚拟钱包系统的设计与实现:

设计模式理论基础_第8张图片

 

虚拟钱包系统要支持的操作非常简单,就是余额的加加减减。充值、提现、查询余额三个功能,只涉及一个账户余额的加减操作,而支付功能涉及两个账户的余额加减操作:一个账户减余额,另一个账户加余额。

c、交易流水的记录和查询:

交易流水数据格式:

设计模式理论基础_第9张图片

①格式包含两个钱包账号兼容支付这种涉及两个账户的交易类型,对于充值、提现这两种交易类型来说,我们只需要记录一个钱包账户信息就够了,所以,这样的交易流水数据格式的设计稍微有点浪费存储空间。

②把“支付”这个交易类型,拆为两个子类型:支付和被支付。支付单纯表示出账,余额扣减,被支付单纯表示入账,余额增加。这样我们在设计交易流水数据格式的时候,只需要记录一个账户信息即可。

交易流水有两个功能:一个是业务功能,比如,提供用户查询交易流水信息;另一个是非业务功能,保证数据的一致性。这里主要是指支付操作数据的一致性。 ①更能保证数据的一致性。

d、保证数据一致性的方法:

i.依赖数据库事务的原子性,将两个操作放在同一个事务中执行。缺点:做法不够灵活,因为我们的有可能做了分库分表,支付涉及的两个账户可能存储在不同的库中,无法直接利用数据库本身的事务特性,在一个事务中执行两个账户的操作

ii.一些支持分布式事务的开源框架,但是,为了保证数据的强一致性,它们的实现逻辑一般都比较复杂、本身的性能也不高,会影响业务的执行时间

iii.不保证数据的强一致性,只实现数据的最终一致性,也就是我们刚刚提到的交易流水要实现的非业务功能。

iv.对于支付这样的类似转账的操作,我们在操作两个钱包账户余额之前,先记录交易流水,并且标记为“待执行”,当两个钱包的加减金额都完成之后,我们再回过头来,将交易流水标记为“成功”。在给两个钱包加减金额的过程中,如果有任意一个操作失败,我们就将交易记录的状态标记为“失败”。我们通过后台补漏 Job,拉取状态为“失败”或者长时间处于“待执行”状态的交易记录,重新执行或者人工介入处理。

v. 虚拟钱包系统不应该感知具体的业务交易类型。虚拟钱包支持的操作,仅仅是余额的加加减减操作,不涉及复杂业务概念,职责单一、功能通用。如果耦合太多业务概念到里面,势必影响系统的通用性,而且还会导致系统越做越复杂

e、用户查询交易流水的时候,如何显示每条交易流水的交易类型: 通过记录两条交易流水信息的方式来解决。我们前面讲到,整个钱包系统分为两个子系统,上层钱包系统的实现,依赖底层虚拟钱包系统和三方支付系统。对于钱包系统来说,它可以感知充值、支付、提现等业务概念,所以,我们在钱包系统这一层额外再记录一条包含交易类型的交易流水信息,而在底层的虚拟钱包系统中记录不包含交易类型的交易流水信息。

设计模式理论基础_第10张图片

 

通过查询上层钱包系统的交易流水信息,去满足用户查询交易流水的功能需求,而虚拟钱包中的交易流水就只是用来解决数据一致性问题。实际上,它的作用还有很多,比如用来对账等

3.基于贫血模型的传统开发模式

典型的 Web 后端项目的三层结构。其中,Controller 和 VO 负责暴露接口,具体的代码实现如下所示。注意,Controller 中,接口实现比较简单,主要就是调用 Service的方法:

Controller 和 VO 负责暴露接口,代码:主要是调用Service的方法

public class VirtualWalletController {
  // 通过构造函数或者 IOC 框架注入
  private VirtualWalletService virtualWalletService;
  
  public BigDecimal getBalance(Long walletId) { ... } // 查询余额
  public void debit(Long walletId, BigDecimal amount) { ... } // 出账
  public void credit(Long walletId, BigDecimal amount) { ... } // 入账
  public void transfer(Long fromWalletId, Long toWalletId, BigDecimal amount) {
}

Service 和 BO 负责核心业务逻辑,Repository 和 Entity 负责数据存取。Repository 层的代码实现比较简单

Service 层的代码 :UserBo 是一个纯粹的数据结构,只包含数据,不包含任何业务逻辑。业务逻辑集中在 UserService 中,通过 UserService 来操作 UserBo

///BO:数据结构
public class VirtualWalletBo {// 省略 getter/setter/constructor 方法
  private Long id; //钱包ID
  private Long createTime; //创建时间
  private BigDecimal balance; //余额
} 
///VirtualWalletService:主要是业务逻辑
public class VirtualWalletService {
  // 通过构造函数或者 IOC 框架注入
  private VirtualWalletRepository walletRepo;
  private VirtualWalletTransactionRepository transactionRepo;
  
  ///VirtualWalletBo的构造方法///
  public VirtualWalletBo getVirtualWallet(Long walletId) {
    VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
    VirtualWalletBo walletBo = convert(walletEntity);
    return walletBo;
  } 
 
 ///查询余额/
 public BigDecimal getBalance(Long walletId) {
  return virtualWalletRepo.getBalance(walletId);
 } 
 出账
 public void debit(Long walletId, BigDecimal amount) {
   VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
   BigDecimal balance = walletEntity.getBalance();
   if (balance.compareTo(amount) < 0) {
      throw new NoSufficientBalanceException(...);
   }
   walletRepo.updateBalance(walletId, balance.subtract(amount));
 }
 入账/
 public void credit(Long walletId, BigDecimal amount) {
   VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
   BigDecimal balance = walletEntity.getBalance();
   walletRepo.updateBalance(walletId, balance.add(amount));
 }
 支付
 public void transfer(Long fromWalletId, Long toWalletId, BigDecimal amount) {
   VirtualWalletTransactionEntity transactionEntity = new VirtualWalletTransac
   transactionEntity.setAmount(amount);
   transactionEntity.setCreateTime(System.currentTimeMillis());
   transactionEntity.setFromWalletId(fromWalletId);
   transactionEntity.setToWalletId(toWalletId);
   transactionEntity.setStatus(Status.TO_BE_EXECUTED);
   Long transactionId = transactionRepo.saveTransaction(transactionEntity);
   try {
      debit(fromWalletId, amount);
      credit(toWalletId, amount);
   } catch (InsufficientBalanceException e) {
     transactionRepo.updateStatus(transactionId, Status.CLOSED);
     ...rethrow exception e...
   } catch (Exception e) {
     transactionRepo.updateStatus(transactionId, Status.FAILED);
     ...rethrow exception e...
   }
     transactionRepo.updateStatus(transactionId, Status.EXECUTED);
   }
}

4.基于充血模型的 DDD 开发模式

基于充血模型的 DDD 开发模式,跟基于贫血模型的传统开发模式的主要区别就在 Service 层,Controller 层和 Repository 层的代码基本上相同

Service 层按照基于充血模型的 DDD 开发模式该如何来实现

我们把虚拟钱包 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.subtract(amount);
  } 
  
  public void credit(BigDecimal amount) {
    if (amount.compareTo(BigDecimal.ZERO) < 0) {
     throw new InvalidAmountException(...);
    }
    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 virtualWalletRepo.getBalance(walletId);
} 
  public void debit(Long walletId, BigDecimal amount) {
    VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
    VirtualWallet wallet = convert(walletEntity);
    wallet.debit(amount); //区别:直接调用wallet的debit函数
    walletRepo.updateBalance(walletId, wallet.balance());
  } 
  
  public void credit(Long walletId, BigDecimal amount) {
    VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
    VirtualWallet wallet = convert(walletEntity);
    wallet.credit(amount);//区别:直接调用wallet的credit函数
    walletRepo.updateBalance(walletId, wallet.balance());
} 
  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.subtract(amount);
  } 
  
  public void credit(BigDecimal amount) {
    if (amount.compareTo(BigDecimal.ZERO) < 0) {
     throw new InvalidAmountException(...);
    }
    this.balance.add(amount);
  }
}

领域模型 VirtualWallet 类添加了简单的冻结和透支逻辑之后,功能看起来就丰富了很多,代码也没那么单薄了。如果功能继续演进,我们可以增加更加细化的冻结策略、透支策略、支持钱包账号(VirtualWallet id 字段)自动生成的逻辑(不是通过构造函数经外部传入ID,而是通过分布式 ID 生成算法来自动生成 ID)等等。VirtualWallet 类的业务逻辑会变得越来越复杂,也就很值得设计成充血模型了。

5.辩证思考与灵活应用

a、在基于充血模型的 DDD 开发模式中,将业务逻辑移动到Domain 中,Service 类变得很薄,但在我们的代码设计与实现中,并没有完全将Service 类去掉,这是为什么?或者说,Service 类在这种情况下担当的职责是什么?哪些功能逻辑会放到 Service 类中?

区别于 Domain 的职责,Service 类的几个主要职责:

i.Service 类负责与 Repository 交流。在我的设计与代码实现中,VirtualWalletService类负责与 Repository 层打交道,调用 Respository 类的方法,获取数据库中的数据,转化成领域模型 VirtualWallet,然后由领域模型 VirtualWallet 来完成业务逻辑,最后调用Repository 类的方法,将数据存回数据库。

ii.之所以让 VirtualWalletService 类与 Repository 打交道,而不是让领域模型 VirtualWallet 与 Repository 打交道,那是因为我们想保持领域模型的独立性,不与任何其他层的代码(Repository 层的代码)或开发框架(比如 Spring、MyBatis)耦合在一起,将流程性的代码逻辑(比如从 DB 中取数据、映射数据)与领域模型的业务逻辑解耦,让领域模型更加可复用

iii.Service 类负责跨领域模型的业务聚合功能。VirtualWalletService 类中的 transfer() 转账函数会涉及两个钱包的操作,因此这部分业务逻辑无法放到 VirtualWallet 类中,所以,我们暂且把转账业务放到 VirtualWalletService 类中了。当然,虽然功能演进,使得转账业务变得复杂起来之后,我们也可以将转账业务抽取出来,设计成一个独立的领域模型。

iv.Service 类负责一些非功能性及与三方系统交互的工作。比如幂等、事务、发邮件、发消息、记录日志、调用其他系统的 RPC 接口等,都可以放到 Service 类中

b、在基于充血模型的 DDD 开发模式中,尽管 Service 层被改造成了充血模型,但是 Controller 层和 Repository 层还是贫血模型,是否有必要也进行充血领域建模呢?

i.没有必要。Controller 层主要负责接口的暴露,Repository 层主要负责与数据库打交道,这两层包含的业务逻辑并不多,前面我们也提到了,如果业务逻辑比较简单,就没必要做充血建模,即便设计成充血模型,类也非常单薄,看起来也很奇怪。

ii.Repository 的 Entity 来说,即便它被设计成贫血模型,违反面相对象编程的封装特性,有被任意代码修改数据的风险,但 Entity 的生命周期是有限的。一般来讲,我们把它传递到 Service 层之后,就会转化成 BO 或者 Domain 来继续后面的业务逻辑。Entity 的生命周期到此就结束了,所以也并不会被到处任意修改

iii.Controller 层的 VO。实际上 VO 是一种 DTO(Data Transfer Object,数据传输对象)。它主要是作为接口的数据传输承载体,将数据发送给其他系统。从功能上来讲,它理应不包含业务逻辑、只包含数据。所以,我们将它设计成贫血模型也是比较合理的。

11、如何进行面向对象分析、设计与编程

1.面向对象分析:需求分析

面向对象分析的产出是详细的需求描述

a、案例介绍和难点剖析

案例介绍:“为了保证接口调用的安全性,我们希望设计实现一个接口调用鉴权功能,只有经过认证之后的系统才能调用我们的接口,没有认证过的系统调用我们的接口会被拒绝。

难点剖析:

i.需求不明确

需求过于模糊、笼统,不够具体、细化,离落地到设计、编码还有一定的距离。而人的大脑不擅长思考这种过于抽象的问题。这也是真实的软件开发区别于应试教育的地方。真实的软件开发中,需求几乎都不是很明确。

面向对象分析主要的分析对象是“需求”,因此,面向对象分析可以粗略地看成“需求分析”。实际上,不管是需求分析还是面向对象分析,我们首先要做的都是将笼统的需求细化到足够清晰、可执行。我们需要通过沟通、挖掘、分析、假设、梳理,搞清楚具体的需求有哪些,哪些是现在要做的,哪些是未来可能要做的,哪些是不用考虑做的

ii.缺少锻炼

相比单纯的业务 CRUD 开发,鉴权这个开发任务,要更有难度。鉴权作为一个跟具体业务无关的功能,我们完全可以把它开发成一个独立的框架,集成到很多业务系统中。而作为被很多系统复用的通用框架,比起普通的业务代码,我们对框架的代码质量要求要更高

开发这样通用的框架,对工程师的需求分析能力、设计能力、编码能力,甚至逻辑思维能力的要求,都是比较高的。如果你平时做的都是简单的 CRUD 业务开发,那这方面的锻炼肯定不会很多,所以,一旦遇到这种开发需求,很容易因为缺少锻炼,脑子放空,不知道从何入手,完全没有思路。

b、对案例进行需求分析

i.针对框架、组件、类库等非业务系统的开发,我们一定要有组件化意识、框架意识、抽象意识,开发出来的东西要足够通用,不能局限于单一的某个业务需求

ii.多跟业务团队聊聊天,甚至自己去参与几个业务系统的开发,只有这样,我们才能真正知道业务系统的痛点,才能分析出最有价值的需求。

iii.先从最简单的方案想起,然后再优化。

1)第一轮基础分析

最简单的解决方案就是,通过用户名加密码来做认证。

给每个允许访问我们服务的调用方,派发一个应用名(或者叫应用 ID、AppID)和一个对应的密码(或者叫秘钥)。调用方每次进行接口请求的时候,都携带自己的 AppID 和密码。微服务在接收到接口调用请求之后,会解析出 AppID 和密码,跟存储在微服务端的AppID 和密码进行比对。如果一致,说明认证成功,则允许接口调用请求;否则,就拒绝接口调用请求

2)第二轮分析优化

这样的验证方式,每次都要明文传输密码。密码很容易被截获,是不安全的。那如果我们借助加密算法(比如SHA),对密码进行加密之后,再传递到微服务端验证,是不是就可以了呢?实际上,这样也是不安全的,因为加密之后的密码及 AppID,照样可以被未认证系统(或者说黑客)截获,未认证系统可以携带这个加密之后的密码以及对应的AppID,伪装成已认证系统来访问我们的接口。这就是典型的“重放攻击” (攻击者发送一个目的机已接收过的包,来达到欺骗系统的目的,主要用于身份认证过程,破坏认证的正确性。重放攻击可以由发起者,也可以由拦截并重发该数据的敌方进行。攻击者利用网络监听或者其他方式盗取认证凭据,之后再把它重新发给认证服务器。)

解决方法:可以借助 OAuth 的验证思路来解决。调用方将请求接口的 URL 跟 AppID、密码拼接在一起,然后进行加密,生成一个 token。调用方在进行接口请求的的时候,将这个 token 及AppID,随 URL 一块传递给微服务端。微服务端接收到这些数据之后,根据 AppID 从数据库中取出对应的密码,并通过同样的 token 生成算法,生成另外一个 token。用这个新生成的 token 跟调用方传递过来的 token 对比。如果一致,则允许接口调用请求;否则,就拒绝接口调用请求

流程图:

设计模式理论基础_第11张图片

 

3)第三轮分析优化

缺点:这样的设计仍然存在重放攻击的风险,还是不够安全。每个 URL 拼接上 AppID、密码生成的 token 都是固定的。未认证系统截获 URL、token 和 AppID 之后,还是可以通过重放攻击的方式,伪装成认证系统,调用这个 URL 对应的接口。

解决方法:进一步优化 token 生成算法,引入一个随机变量,让每次接口请求生成的 token 都不一样。我们可以选择时间戳作为随机变量。原来的 token 是对URL、AppID、密码三者进行加密生成的,现在我们将 URL、AppID、密码、时间戳四者进行加密来生成 token。调用方在进行接口请求的时候,将 token、AppID、时间戳,随URL 一并传递给微服务端。

微服务端在收到这些数据之后,会验证当前时间戳跟传递过来的时间戳,是否在一定的时间窗口内(比如一分钟)。如果超过一分钟,则判定 token 过期,拒绝接口请求。如果没有超过一分钟,则说明 token 没有过期,就再通过同样的 token 生成算法,在服务端生成新的 token,与调用方传递过来的 token 比对,看是否一致。如果一致,则允许接口调用请求;否则,就拒绝接口调用请求。

设计模式理论基础_第12张图片

 

4)第四轮分析优化

还是不够安全,攻与防之间,本来就没有绝对的安全。我们能做的就是,尽量提高攻击的成本。这个方案虽然还有漏洞,但是实现起来足够简单,而且不会过度影响接口本身的性能(比如响应时间)。所以,权衡安全性、开发成本、对系统性能的影响,这个方案算是比较折中、比较合理的了。

如何在微服务端存储每个授权调用方的AppID 和密码:最容易想到的方案就是存储到数据库里,比如MySQL。不过,开发像鉴权这样的非业务功能,最好不要与具体的第三方系统有过度的耦合。

针对 AppID 和密码的存储,我们最好能灵活地支持各种不同的存储方式,比如ZooKeeper、本地配置文件、自研配置中心、MySQL、Redis 等。我们不一定针对每种存储方式都去做代码实现,但起码要留有扩展点,保证系统有足够的灵活性和扩展性,能够在我们切换存储方式的时候,尽可能地减少代码的改动。

5)最终确定需求

调用方进行接口请求的时候,将 URL、AppID、密码、时间戳拼接在一起,通过加密算法生成 token,并且将 token、AppID、时间戳拼接在 URL 中,一并发送到微服务端。

微服务端在接收到调用方的接口请求之后,从请求中拆解出 token、AppID、时间戳。

微服务端首先检查传递过来的时间戳跟当前时间,是否在 token 失效时间窗口内。如果已经超过失效时间,那就算接口调用鉴权失败,拒绝接口调用请求。

如果 token 验证没有过期失效,微服务端再从自己的存储中,取出 AppID 对应的密码,通过同样的 token 生成算法,生成另外一个 token,与调用方传递过来的 token 进行匹配;如果一致,则鉴权成功,允许接口调用,否则就拒绝接口调用。

2.面向对象设计

面向对象设计的产出就是类。在面向对象设计环节,我们将需求描述转化为具体的类的设计

a、划分职责进而识别出有哪些类

i.类是现实世界中事物的一个建模。但是,并不是每个需求都能映射到现实世界,也并不是每个类都与现实世界中的事物一一对应。对于一些抽象的概念,我们是无法通过映射现实世界中的事物的方式来定义类的。

ii.识别类的方法:

1)把需求描述中的名词罗列出来,作为可能的候选类,然后再进行筛选

2) 根据需求描述,把其中涉及的功能点,一个一个罗列出来,然后再去看哪些功能点职责相近,操作同样的属性,可否应该归为同一个类

iii.逐句阅读上面的需求描述,拆解成小的功能点,一条一条罗列下来。注意,拆解出来的每个功能点要尽可能的小。每个功能点只负责做一件很小的事情(专业叫法 是“单一职责”,后面章节中我们会讲到)

iv.功能点列表:

1)把 URL、AppID、密码、时间戳拼接为一个字符串;

2)对字符串通过加密算法加密生成 token;

3)将 token、AppID、时间戳拼接到 URL 中,形成新的 URL;

4)解析 URL,得到 token、AppID、时间戳等信息;

5)从存储中取出 AppID 和对应的密码;

6)根据时间戳判断 token 是否过期失效;

7)验证两个 token 是否匹配;

v.初步类的划分:

从上面的功能列表中,我们发现,1、2、6、7 都是跟 token 有关,负责 token 的生成、验证;3、4 都是在处理 URL,负责 URL 的拼接、解析;5 是操作 AppID 和密码,负责从存储中读取 AppID 和密码。所以,我们可以粗略地得到三个核心的类:AuthToken、Url、CredentialStorage。AuthToken 负责实现 1、2、6、7 这四个操作;Url 负责 3、4两个操作;CredentialStorage 负责 5 这个操作。

vi.针对这种复杂的需求开发,我们首先要做的是进行模块划分,将需求先简单划分成几个小的、独立的功能模块,然后再在模块内部,应用我们刚刚讲的方法,进行面向对象设计。而模块的划分和识别,跟类的划分和识别,是类似的套路。

b、定义类及其属性和方法

i.通过分析需求描述,识别出了三个核心的类,它们分别是 AuthToken、Url 和 CredentialStorage。每个类都有哪些属性和方法。我们还是从功能点列表中挖掘。

ii. AuthToken 类相关的功能点有四个:

把 URL、AppID、密码、时间戳拼接为一个字符串;

对字符串通过加密算法加密生成 token;

根据时间戳判断 token 是否过期失效;

验证两个 token 是否匹配

iii.方法和属性的识别:

1)识别出需求描述中的动词,作为候选的方法,再进一步过滤筛选。

2)可以把功能点中涉及的名词,作为候选属性,然后同样进行过滤筛选

iv. AuthToken 类的属性和方法的识别:

设计模式理论基础_第13张图片

 

细节:

1)并不是所有出现的名词都被定义为类的属性,比如 URL、AppID、密码、时间戳这几个名词,我们把它作为了方法的参数 (从业务模型上来说,不应该属于这个类的属性和方法,不应该被放到这个类里。比如 URL、AppID 这些信息,从业务模型上来说,不应该属于 AuthToken,所以我们不应该放到这个类中 )

2)我们还需要挖掘一些没有出现在功能点描述中属性,比如 createTime,expireTimeInterval,它们用在 isExpired() 函数中,用来判定 token 是否过期。

3)我们还给 AuthToken 类添加了一个功能点描述中没有提到的方法getToken()

(第二、第三个细节告诉我们,在设计类具有哪些属性和方法的时候,不能单纯地依赖当下的需求,还要分析这个类从业务模型上来讲,理应具有哪些属性和方法。这样可以一方面保证类定义的完整性,另一方面不仅为当下的需求还为未来的需求做些准备

v.Url 类相关的功能点有两个:

将 token、AppID、时间戳拼接到 URL 中,形成新的 URL;

解析 URL,得到 token、AppID、时间戳等信息。

虽然需求描述中,我们都是以 URL 来代指接口请求,但是,接口请求并不一定是以 URL的形式来表达,还有可能是 dubbo RPC 等其他形式。为了让这个类更加通用,命名更加贴切,我们接下来把它命名为 ApiRequest。下面是我根据功能点描述设计的 ApiRequest类。

设计模式理论基础_第14张图片

 

vi.CredentialStorage 类相关的功能点有一个:

从存储中取出 AppID 和对应的密码。

CredentialStorage 类非常简单,类图如下所示。为了做到抽象封装具体的存储方式,我们将 CredentialStorage 设计成了接口,基于接口而非具体的实现编程。

设计模式理论基础_第15张图片

 

c、定义类与类之间的交互关系

UML 统一建模语言中定义了六种类之间的关系。它们分别是:泛化、实现、关联、聚合、组合、依赖。

i.泛化(Generalization)可以简单理解为继承关系。具体到 Java 代码就是下面这样:

public class A { ... }
public class B extends A { ... }

ii.实现(Realization)一般是指接口和实现类之间的关系。具体到 Java 代码就是下面这样:

public interface A {...}
public class B implements A { ... }

iii.聚合(Aggregation)是一种包含关系,A 类对象包含 B 类对象,B 类对象的生命周期可以不依赖 A 类对象的生命周期,也就是说可以单独销毁 A 类对象而不影响 B 对象,比如课程与学生之间的关系。具体到 Java 代码就是下面这样: (B类是A类对象构造函数的参数)

public class A {
private B b;
public A(B b) {
this.b = b;
}
}

iv.组合(Composition)也是一种包含关系。A 类对象包含 B 类对象,B 类对象的生命周期跟依赖 A 类对象的生命周期,B 类对象不可单独存在,比如鸟与翅膀之间的关系。具体到Java 代码就是下面这样:

public class A {
private B b;
public A() {
this.b = new B();
}
}

v.关联(Association)是一种非常弱的关系,包含聚合、组合两种关系。具体到代码层面,如果 B 类对象是 A 类的成员变量,那 B 类和 A 类就是关联关系。具体到 Java 代码就是下面这样:

public class A {
private B b;
public A(B b) {
this.b = b;
}
}
或者
public class A {
private B b;
public A() {
this.b = new B();
}

vi.依赖(Dependency)是一种比关联关系更加弱的关系,包含关联关系。不管是 B 类对象是 A 类对象的成员变量,还是 A 类的方法使用 B 类对象作为参数或者返回值、局部变量,只要 B 类对象和 A 类对象有任何使用关系,我们都称它们有依赖关系。具体到 Java 代码就是下面这样:

public class A {
private B b;
public A(B b) {
this.b = b;
}
}
或者
public class A {
private B b;
public A() {
this.b = new B();
}
}
或者
public class A {
public void func(B b) { ... }
}

vii.从更加贴近编程的角度,对类与类之间的关系做了调整,只保留了四个关系:泛化、实现、组合、依赖

组合关系替代 UML 中组合、聚合、关联三个概念,也就相当于重新命名关联关系为组合关系,并且不再区分 UML 中的组合和聚合两个概念。之所以这样重新命名,是为了跟我们前面讲的“多用组合少用继承”设计原则中的“组合”统一含义只要 B 类对象是 A 类对象的成员变量,那我们就称,A 类跟 B 类是组合关系。

d、将类组装起来并提供执行入口

i.因为目前只有三个核心的类,所以只用到了实现关系,也即 CredentialStorage 和MysqlCredentialStorage 之间的关系。接下来讲到组装类的时候,我们还会用到依赖关系、组合关系,但是泛化关系暂时没有用到。

ii.要将所有的类组装在一起,提供一个执行入口。这个入口可能是一个 main() 函数,也可能是一组给外部用的 API 接口。通过这个入口,我们能触发整个代码跑起来。

iii.接口鉴权并不是一个独立运行的系统,而是一个集成在系统上运行的组件,所以,我们封装所有的实现细节,设计了一个最顶层的 ApiAuthencator 接口类,暴露一组给外部调用者使用的 API 接口,作为触发执行鉴权逻辑的入口。具体的类的设计如下所示:

设计模式理论基础_第16张图片

 

3.面向对象编程

面向对象设计完成之后,我们已经定义清晰了类、属性、方法、类之间的交互,并且将所有的类组装起来,提供了统一的执行入口。接下来,面向对象编程的工作,就是将这些设计思路翻译成代码实现。有了前面的类图,这部分工作相对来说就比较简单了。

ApiAuthencator 的实现:

public interface ApiAuthencator {
  void auth(String url);
  void auth(ApiRequest apiRequest);
} 
public class DefaultApiAuthencatorImpl implements ApiAuthencator {
  private CredentialStorage credentialStorage;
  public ApiAuthencator() {
  this.credentialStorage = new MysqlCredentialStorage();
} 
  public ApiAuthencator(CredentialStorage credentialStorage) {
    this.credentialStorage = credentialStorage;
  } 
  @Override
  public void auth(String url) {
    ApiRequest apiRequest = ApiRequest.buildFromUrl(url);
    auth(apiRequest);
  } 
  @Override
  public void auth(ApiRequest apiRequest) {
    String appId = apiRequest.getAppId();
    String token = apiRequest.getToken();
    long timestamp = apiRequest.getTimestamp();
    String originalUrl = apiRequest.getOriginalUrl();
    
    AuthToken clientAuthToken = new AuthToken(token, timestamp);
    if (clientAuthToken.isExpired()) {
       throw new RuntimeException("Token is expired.");
    } 
    
    String password = credentialStorage.getPasswordByAppId(appId);
    AuthToken serverAuthToken = AuthToken.generate(originalUrl, appId, password
    if (!serverAuthToken.match(clientAuthToken)) {
       throw new RuntimeException("Token verfication failed.");
    }
  }
}

4.辩证思考与灵活应用

面向对象分析、设计、实现,每个环节的界限划分都比较清楚。而且,设计和实现基本上是按照功能点的描述,逐句照着翻译过来的。这样做的好处是先做什么、后做什么,非常清晰、明确,有章可循,即便是没有太多设计经验的初级工程师,都可以按部就班地参照着这个流程来做分析、设计和实现

即便我们在写代码之前,花很多时间做分析和设计,绘制出完美的类图、UML 图,也不可能把每个细节、交互都想得很清楚。在落实到代码的时候,我们还是要反复迭代、重构、打破重写。

整个软件开发本来就是一个迭代、修修补补、遇到问题解决问题的过程,是一个不断重构的过程。我们没法严格地按照顺序执行各个步骤。

设计原则

12、S:单一职责原则

定义、解决问题、应用场景

1.如何理解单一职责原则(SRP)?

a、单一职责原则的描述:一个类或者模块只负责完成一个职责(或者功能)

b、原则描述的对象包含两个:一个是类(class),一个是模块(module)。关于这两个概念,在专栏中,有两种理解方式。一种理解是:把模块看作比类更加抽象的概念,类也可以看作模块。另一种理解是:把模块看作比类更加粗粒度的代码块,模块中包含多个类,多个类组成一个模块

c、一个类只负责完成一个职责或者功能。也就是说,不要设计大而全的类,要设计粒度小、功能单一的类。换个角度来讲就是,一个类包含了两个或者两个以上业务不相干的功能,那我们就说它职责不够单一,应该将它拆分成 多个功能更加单一、粒度更细的类。

2.如何判断类的职责是否足够单一?

a、大部分情况下,类里的方法是归为同一类功能,还是归为不相关的两类功能,并不是那么容易判定的。在真实的软件开发中,对于一个类是否职责单一的判定,是很难拿捏的

b、UserInfo 类:

public class UserInfo {
  private long userId;
  private String username;
  private String email;
  private String telephone;
  private long createTime;
  private long lastLoginTime;
  private String avatarUrl;
  private String provinceOfAddress; // 省
  private String cityOfAddress; // 市
  private String regionOfAddress; // 区
  private String detailedAddress; // 详细地址
  // ... 省略其他属性和方法...
}

c、两种不同的观点:

i. UserInfo 类包含的都是跟用户相关的信息,所有的属性和方法都隶属于用户这样一个业务模型,满足单一职责原则;

ii. 地址信息在 UserInfo 类中,所占的比重比较高,可以继续拆分成独立的 UserAddress类,UserInfo 只保留除 Address 之外的其他信息,拆分之后的两个类的职责更加单一

d、要从中做出选择,我们不能脱离具体的应用场景。如果在这个社交产品中,用户的地址信息跟其他信息一样,只是单纯地用来展示,那 UserInfo 现在的设计就是合理的; 如果这个社交产品发展得比较好,之后又在产品中添加了电商的模块,用户的地址信息还会用在电商物流中,那我们最好将地址信息从 UserInfo 中拆分出来,独立成用户物流信息(或者叫地址信息、收货信息等)

e、 不同的应用场景、不同阶段的需求背景下,对同一个类的职责是否单一的判定,可能都是不一样的。在某种应用场景或者当下的需求背景下,一个类的设计可能已经满足单一职责原则了,但如果换个应用场景或着在未来的某个需求背景下,可能就不满足了,需要继续拆分成粒度更细的类

f、评价一个类的职责是否足够单一,我们并没有一个非常明确的、可以量化的标准,可以说,这是件非常主观、仁者见仁智者见智的事情。我们可以先写一个粗粒度的类,满足业务需求。随着业务的发展,如果粗粒度的类越来越庞大,代码越来越多,这个时候,我们就可以将这个粗粒度的类,拆分成几个更细粒度的类。这就是所谓的持续重构

g、类单一职责的判断标准:

i.类中的代码行数(不超过200行)、函数或属性过多(不超过10个),会影响代码的可读性和可维护性,我们就需要考虑对类进行拆分;

ii.类依赖的其他类过多,或者依赖类的其他类过多,不符合高内聚、低耦合的设计思想,我们就需要考虑对类进行拆分;

iii.私有方法过多,我们就要考虑能否将私有方法独立到新的类中,设置为 public 方法,供更多的类使用,从而提高代码的复用性;

iv.比较难给类起一个合适名字,很难用一个业务名词概括,或者只能用一些笼统的Manager、Context 之类的词语来命名,这就说明类的职责定义得可能不够清晰;

v.类中大量的方法都是集中操作类中的某几个属性,比如,在 UserInfo 例子中,如果一半的方法都是在操作address 信息,那就可以考虑将这几个属性和对应的方法拆分出来。

3.类的职责是否设计得越单一越好?

a、单一职责原则通过避免设计大而全的类,避免将不相关的功能耦合在一起,来提高类的内聚性。同时,类职责单一,类依赖的和被依赖的其他类也会变少,减少了代码的耦合性,以此来实现代码的高内聚、低耦合。但是,如果拆分得过细,实际上会适得其反,反倒会降低内聚性,也会影响代码的可维护性

b、不管是应用设计原则还是设计模式,最终的目的还是提高代码的可读性、可扩展性、复用性、可维护性等。我们在考虑应用某一个设计原则是否合理的时候,也可以以此作为最终的考量标准

13、O:开闭原则

1.如何理解“对扩展开放、修改关闭”?

a、开闭原则(Open Closed Principle)的描述:简写为 OCP,软件实体(模块、类、方法等)应该“对扩展开放、对修改关闭”。即添加一个新的功能应该是,在已有代码基础上扩展代码(新增模块、类、方法等),而非修改已有代码(修改模块、类、方法等)。

b、API 接口监控告警的代码:

public class Alert {
  private AlertRule rule;
  private Notification notification;
  
  public Alert(AlertRule rule, Notification notification) {
    this.rule = rule;
    this.notification = notification;
  } 
  
  public void check(String api, long requestCount, long errorCount, long durationOfSeconds){
    long tps = requestCount / durationOfSeconds;
    if (tps > rule.getMatchedRule(api).getMaxTps()) {
      notification.notify(NotificationEmergencyLevel.URGENCY, "...");
     }
     if (errorCount > rule.getMatchedRule(api).getMaxErrorCount()) {
       notification.notify(NotificationEmergencyLevel.SEVERE, "...");
     }
  }
}

AlertRule 存储告警规则,可以自由设置。Notification 是告警通知类,支持邮件、短信、微信、手机等多种通知渠道。NotificationEmergencyLevel 表示通知的紧急程度,包括 SEVERE(严重)、URGENCY(紧急)、NORMAL(普通)、TRIVIAL(无关紧要),不同的紧急程度对应不同的发送渠道。

业务逻辑主要集中在 check() 函数中。当接口的 TPS 超过某个预先设置的最大值时,以及当接口请求出错数大于某个最大允许值时,就会触发告警,通知接口的相关负责人或者团队。

c、如果我们需要添加一个功能,当每秒钟接口超时请求个数,超过某个预先设置的最大阈值时,我们也要触发告警发送通知。主要的改动有两处:第一处是修改 check() 函数的入参,添加一个新的统计数据 timeoutCount,表示超时接口请求数;第二处是在 check() 函数中添加新的告警逻辑。具体的代码改动如下所示:

public class Alert {
  // ... 省略 AlertRule/Notification 属性和构造函数...
  
  // 改动一:添加参数 timeoutCount
  public void check(String api, long requestCount, long errorCount, long durationOfSeconds, long timeoutCount){
    long tps = requestCount / durationOfSeconds;
    if (tps > rule.getMatchedRule(api).getMaxTps()) {
      notification.notify(NotificationEmergencyLevel.URGENCY, "...");
}
     if (errorCount > rule.getMatchedRule(api).getMaxErrorCount()) {
       notification.notify(NotificationEmergencyLevel.SEVERE, "...");
}
     // 改动二:添加接口超时处理逻辑
     long timeoutTps = timeoutCount / durationOfSeconds;
      if (timeoutTps > rule.getMatchedRule(api).getMaxTimeoutTps()) {
        notification.notify(NotificationEmergencyLevel.URGENCY, "...");
      }
   }
}

d、基于“修改”的方式来实现新功能的代码修改实际上存在挺多问题的。一方面,我们对接口进行了修改,这就意味着调用这个接口的代码都要做相应的修改。另一方面,修改了 check() 函数,相应的单元测试都需要修改(关于单元测试的内容我们在重构那部分会详细介绍)

e、通过“扩展”的方式修改代码:

先重构一下之前的 Alert 代码,让它的扩展性更好一些。重构的内容主要包含两部分:

第一部分是将 check() 函数的多个入参封装成 ApiStatInfo 类; 第二部分是引入 handler 的概念,将 if 判断逻辑分散在各个 handler 中。

public class Alert {
  private List alertHandlers = new ArrayList<>();
  
  public void addAlertHandler(AlertHandler alertHandler) {
    this.alertHandlers.add(alertHandler);
  } 
  
  public void check(ApiStatInfo apiStatInfo) {
    for (AlertHandler handler : alertHandlers) {
      handler.check(apiStatInfo);
    }
  }
} 
//将check() 函数的多个入参封装成ApiStatInfo类
public class ApiStatInfo {// 省略 constructor/getter/setter 方法
  private String api;
  private long requestCount;
  private long errorCount;
  private long durationOfSeconds;
} 
​
//抽象类AlertHandler
public abstract class AlertHandler {
  protected AlertRule rule;
  protected Notification notification;
  public AlertHandler(AlertRule rule, Notification notification) {
    this.rule = rule;
    this.notification = notification;
  }
  public abstract void check(ApiStatInfo apiStatInfo);
} 
//类TpsAlertHandler表示接口Tps超过某个预先设置的最大值时触发告警
public class TpsAlertHandler extends AlertHandler {
  public TpsAlertHandler(AlertRule rule, Notification notification) {
    super(rule, notification);
  } 
  //check函数根据具体需求进行重写
  @Override
  public void check(ApiStatInfo apiStatInfo) {
    long tps = apiStatInfo.getRequestCount()/ apiStatInfo.getDurationOfSeconds
    if (tps > rule.getMatchedRule(apiStatInfo.getApi()).getMaxTps()) {
      notification.notify(NotificationEmergencyLevel.URGENCY, "...");
     }
  }
} 
//类ErrorAlertHandler表示接口请求出错数超过某个最大允许值时触发告警
public class ErrorAlertHandler extends AlertHandler {
  public ErrorAlertHandler(AlertRule rule, Notification notification){
    super(rule, notification);
  } 
  //check函数根据具体需求进行重写
  @Override
  public void check(ApiStatInfo apiStatInfo) {
    if (apiStatInfo.getErrorCount() > rule.getMatchedRule(apiStatInfo.getApi())
      notification.notify(NotificationEmergencyLevel.SEVERE, "...");
     }
  }
}

f、重构之后的 Alert的具体使用代码:

public class ApplicationContext {
  private AlertRule alertRule;
  private Notification notification;
  private Alert alert;
  
  public void initializeBeans() {
    alertRule = new AlertRule(/*. 省略参数.*/); // 省略一些初始化代码
    notification = new Notification(/*. 省略参数.*/); // 省略一些初始化代码
    alert = new Alert();
    alert.addAlertHandler(new TpsAlertHandler(alertRule, notification));
    alert.addAlertHandler(new ErrorAlertHandler(alertRule, notification));
  }
  public Alert getAlert() { return alert; }
  
  // 饿汉式单例
  private static final ApplicationContext instance = new ApplicationContext();
  private ApplicationContext() {
     instance.initializeBeans();
  }
  public static ApplicationContext getInstance() {
    return instance;
  }
} 
​
public class Demo {
  public static void main(String[] args) {
    ApiStatInfo apiStatInfo = new ApiStatInfo();
    // ... 省略设置 apiStatInfo 数据值的代码
    ApplicationContext.getInstance().getAlert().check(apiStatInfo);
  }
}

g、基于重构之后的代码修改:

第一处改动是:在 ApiStatInfo 类中添加新的属性 timeoutCount。

第二处改动是:添加新的 TimeoutAlertHander 类。

第三处改动是:在 ApplicationContext 类的 initializeBeans() 方法中,往 alert 对象中注册新的 timeoutAlertHandler。

第四处改动是:在使用 Alert 类的时候,需要给 check() 函数的入参 apiStatInfo 对象设置 timeoutCount 的值。

改动之后的代码:

public class Alert { // 代码未改动... }
public class ApiStatInfo {// 省略 constructor/getter/setter 方法
  private String api;
  private long requestCount;
  private long errorCount;
  private long durationOfSeconds;
  private long timeoutCount; // 改动一:添加新字段
}
public abstract class AlertHandler { // 代码未改动... }
public class TpsAlertHandler extends AlertHandler {// 代码未改动...}
public class ErrorAlertHandler extends AlertHandler {// 代码未改动...}
// 改动二:添加新的 handler
public class TimeoutAlertHandler extends AlertHandler {// 省略代码...}
​
public class ApplicationContext {
  private AlertRule alertRule;
  private Notification notification;
  private Alert alert;
  
  public void initializeBeans() {
    alertRule = new AlertRule(/*. 省略参数.*/); // 省略一些初始化代码
    notification = new Notification(/*. 省略参数.*/); // 省略一些初始化代码
    alert = new Alert();
    alert.addAlertHandler(new TpsAlertHandler(alertRule, notification));
    alert.addAlertHandler(new ErrorAlertHandler(alertRule, notification));
    // 改动三:注册 handler
    alert.addAlertHandler(new TimeoutAlertHandler(alertRule, notification));
  }
  //... 省略其他未改动代码...
} 
public class Demo {
  public static void main(String[] args) {
    ApiStatInfo apiStatInfo = new ApiStatInfo();
    // ... 省略 apiStatInfo 的 set 字段代码
    apiStatInfo.setTimeoutCount(289); // 改动四:设置 tiemoutCount 值
    ApplicationContext.getInstance().getAlert().check(apiStatInfo);
}

h、重构之后的代码更加灵活和易扩展。如果我们要想添加新的告警逻辑,只需要基于扩展的方式创建新的 handler 类即可,不需要改动原来的 check() 函数的逻辑。而且,我们只需要为新的 handler 类添加单元测试,老的单元测试都不会失败,也不用修改。

2.修改代码就意味着违背开闭原则吗?

a、在添加新的告警逻辑的时候,尽管改动二(添加新的 handler 类)是基于扩展而非修改的方式来完成的,但改动一、三、四貌似不是基于扩展而是基于修改的方式来完成的,那改动一、三、四不就违背了开闭原则吗?

b、改动一:往 ApiStatInfo 类中添加新的属性 timeoutCount(给类中添加新的属性和方法,算作“修改”还是“扩展”?)

i.不仅往 ApiStatInfo 类中添加了属性,还添加了对应的 getter/setter 方法。

ii.开闭原则的定义:软件实体(模块、类、方法等)应该“对扩展开放、对修改关闭”。从定义中,我们可以看出,开闭原则可以应用在不同粒度的代码中,可以是模块,也可以类,还可以是方法(及其属性)。同样一个代码改动,在粗代码粒度下,被认定为“修改”,在细代码粒度下,又可以被认定为“扩展”。比如,改动一,添加属性和方 法相当于修改类,在类这个层面,这个代码改动可以被认定为“修改”;但这个代码改动并没有修改已有的属性和方法,在方法(及其属性)这一层面,它又可以被认定为“扩展”。

iii.没必要纠结某个代码改动是“修改”还是“扩展”,更没必要太纠结它是否违反“开闭原则”。我们回到这条原则的设计初衷:只要它没有破坏原有的代码的正常运行,没有破坏原有的单元测试,我们就可以说,这是一个合格的代码改动

c、改动三和改动四:在 ApplicationContext 类的 initializeBeans() 方法中,往 alert 对象中注册新的 timeoutAlertHandler;在使用 Alert 类的时候,需要给check() 函数的入参 apiStatInfo 对象设置 timeoutCount 的值。

i.这两处改动都是在方法内部进行的,不管从哪个层面(模块、类、方法)来讲,都不能算是“扩展”,而是地地道道的“修改”。不过,有些修改是在所难免的,是可以被接受的。

ii.添加一个新功能,不可能任何模块、类、方法的代码都不“修改”,这个是做不到的。类需要创建、组装、并且做一些初始化操作,才能构建成可运行的的程序,这部分代码的修改是在所难免的。我们要做的是尽量让修改操作更集中、更少、更上层,尽量让最核心、最复杂的那部分逻辑代码满足开闭原则。

3.如何做到“对扩展开放、修改关闭”?

a、偏向顶层的指导思想:

i.为了尽量写出扩展性好的代码,我们要时刻具备扩展意识、抽象意识、封装意识。这些“潜意识”可能比任何开发技巧都重要

ii.要多花点时间往前多思考一下,这段代码未来可能有哪些需求变更、如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,不需要改动代码整体结构、做到最小代码改动的情况下,新的代码能够很灵活地插入到扩展点上,做到“对扩展开放、对修改关闭”

iii.在识别出代码可变部分和不可变部分之后,我们要将可变部分封装起来,隔离变化,提供抽象化的不可变接口,给上层系统使用。当具体的实现发生变化的时候,我们只需要基于相同的抽象接口,扩展一个新的实现,替换掉老的实现即可,上游系统的代码几乎不需要修改

b、具体的方法论:

i.最常用来提高代码扩展性的方法有:多态、依赖注入、基于接口而非实现编程,以及大部分的设计模式(比如,装饰、策略、模板、职责链、状态等)。

ii.多态、依赖注入、基于接口而非实现编程,以及前面提到的抽象意识,说的都是同一种设计思路,只是从不同的角度、不同的层面来阐述而已。这也体现了“很多设计原则、思想、模式都是相通的”这一思想。

iii.如何利用多态、基于接口而非实现编程以及抽象意识 来实现“对扩展开放、对修改关闭” :

代码中通过 Kafka 来发送异步消息。对于这样一个功能的开发,我们要学会将其抽象成一组跟具体消息队列(Kafka)无关的异步消息接口。所有上层系统都依赖这组抽象的接口编程,并且通过依赖注入的方式来调用。当我们要替换新的消息队列的时候,比如将 Kafka 替换成 RocketMQ,可以很方便地拔掉老的消息队列实现,插入新的消息队列实现。具体代码如下所示:

// 这一部分体现了抽象意识
public interface MessageQueue { //... }
public class KafkaMessageQueue implements MessageQueue { //... }
public class RocketMQMessageQueue implements MessageQueue {//...}
​
public interface MessageFromatter { //... }
public class JsonMessageFromatter implements MessageFromatter {//...}
public class ProtoBufMessageFromatter implements MessageFromatter {//...}
​
public class Demo {
  private MessageQueue msgQueue; // 基于接口而非实现编程
  public Demo(MessageQueue msgQueue) { // 依赖注入
    this.msgQueue = msgQueue;
  } 
  // msgFormatter:多态、依赖注入
  public void sendNotification(Notification notification, MessageFormatter msg
   //...
  }
}

4.如何在项目中灵活应用开闭原则?

a、写出支持“对扩展开放、对修改关闭”的代码的关键是预留扩展点

如何才能识别出所有可能的扩展点:

b、如果你开发的是一个业务导向的系统,比如金融系统、电商系统、物流系统等,要想识别出尽可能多的扩展点,就要对业务有足够的了解,能够知道当下以及未来可能要支持的业务需求。如果你开发的是跟业务无关的、通用的、偏底层的系统,比如,框架、组件、类库,你需要了解“它们会被如何使用?今后你打算添加哪些功能?使用者未来会有哪些更多的功能需求?”等问题。

c、“唯一不变的只有变化本身”。即便我们对业务、对系统有足够的了解,那也不可能识别出所有的扩展点,即便你能识别出所有的扩展点,为这些地方都预留扩展点,这样做的成本也是不可接受的。我们没必要为一些遥远的、不一定发生的需求去提前买单,做过度设计。

d、对于一些比较确定的、短期内可能就会扩展,或者需求改动对代码结构影响比较大的情况,或者实现成本不高的扩展点,在编写代码的时候之后,我们就可以事先做些扩展性设计。但对于一些不确定未来是否要支持的需求,或者实现起来比较复杂的扩展点,我们可以等到有需求驱动的时候,再通过重构代码的方式来支持扩展的需求。

e、代码的扩展性会跟可读性相冲突。为了更好地支持扩展性,我们对代码进行了重构,重构之后的代码要比之前的代码复杂很多,理解起来也更加有难度。很多时候,我们都需要在扩展性和可读性之间做权衡

14、L:里式替换原则

1.如何理解“里式替换原则”?

a、里式替换原则(Liskov Substitution Principle):缩写为 LSP,子类对象(object of subtype/derived class)能够替换程序(program)中父类对象(object of base/parent class)出现的任何地方,并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏

b、例子:

public class Transporter {
  private HttpClient httpClient;
  
  public Transporter(HttpClient httpClient) {
    this.httpClient = httpClient;
  } 
  
  public Response sendRequest(Request request) {
    // ...use httpClient to send request
  }
} 
​
public class SecurityTransporter extends Transporter {
  private String appId;
  private String appToken;
  
  public SecurityTransporter(HttpClient httpClient, String appId, String appTok
    super(httpClient);
    this.appId = appId;
    this.appToken = appToken;
  } 
  @Override
  public Response sendRequest(Request request) {
    if (StringUtils.isNotBlank(appId) && StringUtils.isNotBlank(appToken)) {
     request.addPayload("app-id", appId);
     request.addPayload("app-token", appToken);
    }
    return super.sendRequest(request);
  }
} 
​
public class Demo {
  public void demoFunction(Transporter transporter) {
    Reuqest request = new Request();
    //... 省略设置 request 中数据值的代码...
    Response response = transporter.sendRequest(request);
    //... 省略其他逻辑...
  }
} 
// 里式替换原则
Demo demo = new Demo();
demo.demofunction(new SecurityTransporter(/* 省略参数 */););

父类 Transporter 使用 org.apache.http 库中的 HttpClient 类来传输网络数据。子类 SecurityTransporter 继 承父类 Transporter,增加了额外的功能,支持传输 appId 和 appToken 安全认证信息。

子类 SecurityTransporter 的设计完全符合里式替换原则,可以替换父类出现的任何位置,并且原来代码的逻辑行为不变且正确性也没有被破坏

c、需要对 SecurityTransporter 类中sendRequest() 函数稍加改造一下。改造前,如果 appId 或者 appToken 没有设置,我们就不做校验;改造后,如果 appId 或者 appToken 没有设置,则直接抛出NoAuthorizationRuntimeException 未授权异常。改造前后的代码对比如下所示:

// 改造前:
public class SecurityTransporter extends Transporter {
  //... 省略其他代码..
  @Override
  public Response sendRequest(Request request) {
    if (StringUtils.isNotBlank(appId) && StringUtils.isNotBlank(appToken)) {
      request.addPayload("app-id", appId);
      request.addPayload("app-token", appToken);
    }
    return super.sendRequest(request);
  }
} 
​
// 改造后:
public class SecurityTransporter extends Transporter {
  //... 省略其他代码..
  @Override
  public Response sendRequest(Request request) {
    if (StringUtils.isBlank(appId) || StringUtils.isBlank(appToken)) {
      throw new NoAuthorizationRuntimeException(...);
    }
    request.addPayload("app-id", appId);
    request.addPayload("app-token", appToken);
    return super.sendRequest(request);
  }
}

d、在改造之后的代码中,如果传递进 demoFunction() 函数的是父类 Transporter 对象,那demoFunction() 函数并不会有异常抛出,但如果传递给 demoFunction() 函数的是子类SecurityTransporter 对象,那 demoFunction() 有可能会有异常抛出。尽管代码中抛出的是运行时异常(Runtime Exception),我们可以不在代码中显式地捕获处理,但子类替换父类传递进 demoFunction 函数之后,整个程序的逻辑行为有了改变。

e、改造之后的代码仍然可以通过 Java 的多态语法,动态地用子类SecurityTransporter来替换父类 Transporter,也并不会导致程序编译或者运行报错。但是,从设计思路上来讲,SecurityTransporter 的设计是不符合里式替换原则的

f、多态和里式替换的区别:

i.关注的角度是不一样的。多态是面向对象编程的一大特性,也是面向对象编程语言的一种语法。它是一种代码实现的思路

ii.里式替换是一种设计原则,是用来指导继承关系中子类该如何设计的,子类的设计要保证在替换父类的时候,不改变原有程序的逻辑以及不破坏原有程序的正确性。

2.哪些代码明显违背了 LSP?

a、里式替换原则还有另外一个更加能落地、更有指导意义的描述,那就是“Design By Contract”,中文翻译就是“按照协议来设计”

b、子类在设计的时候,要遵守父类的行为约定(或者叫协议)。父类定义了函数的行为约定,那子类可以改变函数的内部实现逻辑,但不能改变函数原有的行为约定。这里的行为约定包括:函数声明要实现的功能;对输入、输出、异常的约定;甚至包括注释中所罗列的任何特殊说明。实际上,定义中父类和子类之间的关系,也可以替换成接口和实现类之间的关系。

几个违反里式替换原则的例子 :

c、子类违背父类声明要实现的功能

父类中提供的 sortOrdersByAmount() 订单排序函数,是按照金额从小到大来给订单排序的,而子类重写这个 sortOrdersByAmount() 订单排序函数之后,是按照创建日期来给订单排序的。那子类的设计就违背里式替换原则。

d、子类违背父类对输入、输出、异常的约定

在父类中,某个函数约定:运行出错的时候返回 null;获取数据为空的时候返回空集合(empty collection)。而子类重载函数之后,实现变了,运行出错返回异常(exception),获取不到数据返回 null。那子类的设计就违背里式替换原则。

在父类中,某个函数约定,输入数据可以是任意整数,但子类实现的时候,只允许输入数据是正整数,负数就抛出,也就是说,子类对输入的数据的校验比父类更加严格,那子类的设计就违背了里式替换原则。

在父类中,某个函数约定,只会抛出 ArgumentNullException 异常,那子类的设计实现中只允许抛出 ArgumentNullException 异常,任何其他异常的抛出,都会导致子类违背里式替换原则。

e、子类违背父类注释中所罗列的任何特殊说明

父类中定义的 withdraw() 提现函数的注释是这么写的:“用户的提现金额不得超过账户余额……”,而子类重写 withdraw() 函数之后,针对 VIP 账号实现了透支提现的功能,也就是提现金额可以大于账户余额,那这个子类的设计也是不符合里式替换原则的

f、判断子类的设计实现是否违背里式替换原则的小窍门: 拿父类的单元测试去验证子类的代码。如果某些单元 测试运行失败,就有可能说明,子类的设计实现没有完全地遵守父类的约定,子类有可能违背了里式替换原则。

15、I:接口隔离原则

1.如何理解“接口隔离原则”?

a、接口隔离原则(Interface Segregation Principle):缩写为 ISP, 客户端不应该强迫依赖它不需要的接口。其中的“客户端”,可以理解为接口的调用者或者使用者。

b、“接口” :在软件开发中,我们既可以把它看作一组抽象的约定,也可以具体指系统与系统之间的 API接口,还可以特指面向对象编程语言中的接口等

不同的场景下接口隔离原则的解读和应用:

2.把“接口”理解为一组 API 接口集合

微服务用户系统提供了一组跟用户相关的 API 给其他系统使用,比如:注册、登录、获取用户信息等。具体代码如下所示:

public interface UserService {
  boolean register(String cellphone, String password);
  boolean login(String cellphone, String password);
  UserInfo getUserInfoById(long id);
  UserInfo getUserInfoByCellphone(String cellphone);
} 
​
public class UserServiceImpl implements UserService {
  //...
}

后台管理系统要实现删除用户的功能,希望用户系统提供一个删除用户的接口。最简单的操作是在 UserService 中新添加一个 deleteUserByCellphone() 或 deleteUserById() 接口,但是存在不加限制地被其他业务系统调用,就有可能导致误删用户的安全隐患。

解决方案:从架构设计的层面,通过接口鉴权的方式来限制接口的调用。不过,如果暂时没有鉴权框架来支持,我们还可以从代码设计的层面,尽量避免接口被误用。我们参照接口隔离原则,调用者不应该强迫依赖它不需要的接口,将删除接口单独放到另外一个接口 RestrictedUserService 中,然后将 RestrictedUserService 只打包提供给后台管理系统来使用。具体的代码实现如下所示:

public interface UserService {
  boolean register(String cellphone, String password);
  boolean login(String cellphone, String password);
  UserInfo getUserInfoById(long id);
  UserInfo getUserInfoByCellphone(String cellphone);
}
​
public interface RestrictedUserService {
  boolean deleteUserByCellphone(String cellphone);
  boolean deleteUserById(long id);
}
​
public class UserServiceImpl implements UserService, RestrictedUserService {
  // ... 省略实现代码...
}

把接口隔离原则中的接口,理解为一组接口集合,它可以是某个微服务的接口,也可以是某个类库的接口等等。在设计微服务或者类库接口的时候,如果部分接口只被部分调用者使用,那我们就需要将这部分接口隔离出来,单独给对应的调用者使用,而不是强迫其他调用者也依赖这部分不会被用到的接口。

3.把“接口”理解为单个 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 dataSet) {
  Statistics statistics = new Statistics();
  //... 省略计算逻辑...
  return statistics;
}

count() 函数的功能不够单一,包含很多不同的统计功能,比如,求最大值、最小值、平均值等等。按照接口隔离原则,我们应该把 count() 函数拆成几个更小粒度的函数,每个函数负责一个独立的统计功能。拆分之后的代码如下所示:

public Long max(Collection dataSet) { //... }
public Long min(Collection dataSet) { //... }
public Long average(Colletion dataSet) { //... }
// ... 省略其他统计函数...

在某种意义上讲,count() 函数也不能算是职责不够单一,毕竟它做的事情只跟统计相关。我们在讲单一职责原则的时候,也提到过类似的问题。实际上,判定功能是否单一,除了很强的主观性,还需要结合具体的场景。

接口隔离原则跟单一职责原则有点类似,不过稍微还是有点区别。单一职责原则针对的是模块、类、接口的设计。接口隔离原则相对于单一职责原则,一方面它更侧重于接口的设计,另一方面它的思考的角度不同。它提供了一种判断接口是否职责单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的 部分功能,那接口的设计就不够职责单一

4.把“接口”理解为 OOP 中的接口概念

项目中用到了三个外部系统:Redis、MySQL、Kafka。每个系统都对应一系列配置信息,比如地址、端口、访问超时时间等。为了在内存中存储这些配置信息,供项目中的其他模块来使用,我们分别设计实现了三个 Configuration 类:RedisConfig、MysqlConfig、KafkaConfig。具体的代码实现如下所示:

public class RedisConfig {
  private ConfigSource configSource; // 配置中心(比如 zookeeper)
  private String address;
  private int timeout;
  private int maxTotal;
  // 省略其他配置: maxWaitMillis,maxIdle,minIdle...
  
  public RedisConfig(ConfigSource configSource) {
     this.configSource = configSource;
  } 
  
  public String getAddress() {
     return this.address;
  }
  //... 省略其他 get()、init() 方法...
  
  public void update() {
     // 从 configSource 加载配置到 address/timeout/maxTotal...
  }
} 
​
public class KafkaConfig { //... 省略... }
public class MysqlConfig { //... 省略... }

有一个新的功能需求,希望支持 Redis 和 Kafka 配置信息的热更新。所谓“热更新(hot update)”就是,如果在配置中心中更改了配置信息,我们希望在不用重启系统的情况下,能将最新的配置信息加载到内存中(也就是 RedisConfig、KafkaConfig 类中)。但是,因为某些原因,我们并不希望对 MySQL 的配置信息进行热更新。

修改方法: 设计一个“热更新”的接口,让RedisConfig、KafkaConfig类都实现这个接口;然后设计实现了一个 ScheduledUpdater 类,以固定时间频率(periodInSeconds)来调用RedisConfig、KafkaConfig 的 update() 方法更新配置信息。

public interface Updater {
  void update();
} 
​
public class RedisConfig implemets Updater {
  //... 省略其他属性和方法...
  @Override
  public void update() { //... }
} 
​
public class KafkaConfig implements Updater {
  //... 省略其他属性和方法...
  @Override
  public void update() { //... }
} 
​
public class MysqlConfig { //... 省略其他属性和方法... }
​
public class ScheduledUpdater {
  private final ScheduledExecutorService executor = Executors.newSingleThread
  private long initialDelayInSeconds;
  private long periodInSeconds;
  private Updater updater;
  
  public ScheduleUpdater(Updater updater, long initialDelayInSeconds, long pe
    this.updater = updater;
    this.initialDelayInSeconds = initialDelayInSeconds;
    this.periodInSeconds = periodInSeconds;
  }
  
  public void run() {
    executor.scheduleAtFixedRate(new Runnable() {
        @Override
        public void run() {
           updater.update();
        }
    }, this.initialDelayInSeconds, this.periodInSeconds, TimeUnit.SECONDS)
  }
} 
​
public class Application {
  ConfigSource configSource = new ZookeeperConfigSource(/* 省略参数 */);
  public static final RedisConfig redisConfig = new RedisConfig(configSource);
  public static final KafkaConfig kafkaConfig = new KakfaConfig(configSource);
  public static final MySqlConfig mysqlConfig = new MysqlConfig(configSource);
  
  public static void main(String[] args) {
    ScheduledUpdater redisConfigUpdater = new ScheduledUpdater(redisConfig, 300
    redisConfigUpdater.run();
    ScheduledUpdater kafkaConfigUpdater = new ScheduledUpdater(kafkaConfig, 60
    redisConfigUpdater.run();
 }
}

新增一个监控功能需求:设计了两个功能非常单一的接口:Updater 和 Viewer。ScheduledUpdater 只依赖Updater 这个跟热更新相关的接口,不需要被强迫去依赖不需要的 Viewer 接口,满足接口隔离原则。同理,SimpleHttpServer 只依赖跟查看信息相关的 Viewer 接口,不依赖不需要的 Updater 接口,也满足接口隔离原则

5.总结

a、 如何理解“接口隔离原则”?

理解“接口隔离原则”的重点是理解其中的“接口”二字。这里有三种不同的理解。如果把“接口”理解为一组接口集合,可以是某个微服务的接口,也可以是某个类库的接口等。如果部分接口只被部分调用者使用,我们就需要将这部分接口隔离出来,单独给这部分调用者使用,而不强迫其他调用者也依赖这部分不会被用到的接口。

如果把“接口”理解为单个 API 接口或函数,部分调用者只需要函数中的部分功能,那我们就需要把函数拆分成粒度更细的多个函数,让调用者只依赖它需要的那个细粒度函数。

如果把“接口”理解为 OOP 中的接口,也可以理解为面向对象编程语言中的接口语法。那接口的设计要尽量单一,不要让接口的实现类和调用者,依赖不需要的接口函数。

b、 接口隔离原则与单一职责原则的区别

单一职责原则针对的是模块、类、接口的设计。

接口隔离原则相对于单一职责原则,一方面更侧重于接口的设计,另一方面它的思考角度也是不同的。接口隔离原则提供了一种判断接口的职责是否单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。

16、D:依赖反转原则

1.控制反转(IOC)

实际上,控制反转是一个比较笼统的设计思想,并不是一种具体的实现方法,一般用来指导框架层面的设计。这里所说的“控制”指的是对程序执行流程的控制,而“反转”指的是在没有使用框架之前,程序员自己控制整个程序的执行。在使用框架之后,整个程序的执行流程通过框架来控制。流程的控制权从程序员“反转”给了框架。

具体例子:

控制翻转之前:所有的流程都由程序员来控制

public class UserServiceTest {
  public static boolean doTest() {
    // ...
  } 
  
  public static void main(String[] args) {// 这部分逻辑可以放到框架中
    if (doTest()) {
      System.out.println("Test succeed.");
    } else {
      System.out.println("Test failed.");
    }
  }
}

控制翻转之后:

框架:

public abstract class TestCase {
  public void run() {
    if (doTest()) {
      System.out.println("Test succeed.");
    } else {
      System.out.println("Test failed.");
    }
  } 
  
  public abstract void doTest();
} 
​
public class JunitApplication {
  private static final List testCases = new ArrayList<>();
  
  public static void register(TestCase testCase) {
    testCases.add(testCase);
} 
  
  public static final void main(String[] args) {
    for (TestCase case: testCases) {
      case.run();
    }
  }
}

我们只需要在框架预留的扩展点,也就是TestCase 类中的 doTest() 抽象函数中,填充具体的测试代码就可以实现之前的功能了,完全不需要写负责执行流程的 main() 函数了:

public class UserServiceTest extends TestCase {
  @Override
  public boolean doTest() {
    // ...
  }
} 
​
// 注册操作还可以通过配置的方式来实现,不需要程序员显示调用 register()
JunitApplication.register(new UserServiceTest());

典型的通过框架来实现“控制反转”的例子。框架提供了一个可扩展的代码骨架,用来组装对象、管理整个执行流程。程序员利用框架进行开发的时候,只需要往预留的扩展点上,添加跟自己业务相关的代码,就可以利用框架来驱动整个程序流程的执行。

2.依赖注入(DI)

依赖注入和控制反转恰恰相反,它是一种具体的编码技巧。我们不通过 new 的方式在类内部创建依赖类的对象,而是将依赖的类对象在外部创建好之后,通过构造函数、函数参数等方式传递(或注入)给类来使用。

Notification 类负责消息推送,依赖MessageSender 类实现推送商品促销、验证码等消息给用户。我们分别用依赖注入和非依赖注入两种方式来实现一下。具体的实现代码如下所示:

// 非依赖注入实现方式
public class Notification {
  private MessageSender messageSender;
  
  public Notification() {
    this.messageSender = new MessageSender(); // 此处有点像 hardcode
  } 
  
  public void sendMessage(String cellphone, String message) {
    //... 省略校验逻辑等...
    this.messageSender.send(cellphone, message);
  }
} 
​
public class MessageSender {
  public void send(String cellphone, String message) {
    //....
  }
}
// 使用 Notification
Notification notification = new Notification();
​
// 依赖注入的实现方式
public class Notification {
  private MessageSender messageSender;
  
  // 通过构造函数将 messageSender 传递进来
  public Notification(MessageSender messageSender) {
    this.messageSender = messageSender;
  } 
  
  public void sendMessage(String cellphone, String message) {
    //... 省略校验逻辑等...
    this.messageSender.send(cellphone, message);
  }
}
// 使用 Notification
MessageSender messageSender = new MessageSender();
Notification notification = new Notification(messageSender);

3.依赖注入框架(DI Framework)

我们通过依赖注入框架提供的扩展点,简单配置一下所有需要的类及其类与类之间依赖关系,就可以实现由框架来自动创建对象、管理对象的生命周期、依赖注入等原本需要程序员来做的事情。

现成的依赖注入框架有很多,比如 Google Guice、Java Spring、Pico Container、Butterfly Container 等。不过,如果你熟悉 Java Spring 框架,你可能会说,Spring 框架自己声称是控制反转容器(Inversion Of Control Container)。

只是控制反转容器这种表述是一种非常宽泛的描述,DI 依赖注入框架的表述更具体、更有针对性。因为我们前面讲到实现控制反转的方式有很多,除了依赖注入,还有模板模式等,而 Spring 框架的控制反转主要是通过依赖注入来实现的

4.依赖反转原则(DIP)

依赖反转原则也叫作依赖倒置原则。这条原则跟控制反转有点类似,主要用来指导框架层面的设计。高层模块(调用者)不依赖低层模块(被调用者),它们共同依赖同一个抽象。抽象不要依赖具体实现细节,具体实现细节依赖抽象

Tomcat 是运行 Java Web 应用程序的容器。我们编写的 Web 应用程序代码只需要部署在Tomcat 容器下,便可以被 Tomcat 容器调用执行。按照之前的划分原则,Tomcat 就是高层模块,我们编写的 Web 应用程序代码就是低层模块。Tomcat 和应用程序代码之间并没有直接的依赖关系,两者都依赖同一个“抽象”,也就是 Sevlet 规范。Servlet 规范不依赖具体的 Tomcat 容器和应用程序的实现细节,而 Tomcat 容器和应用程序依赖 Servlet规范。

17、KISS 原则和YAGNI 原则

KISS原则:尽量保持简单

KISS 原则是保持代码可读和可维护的重要手段。KISS 原则中的“简单”并不是以代码行数来考量的。代码行数越少并不代表代码越简单,我们还要考虑逻辑复杂度、实现难度、代码的可读性等。而且,本身就复杂的问题,用复杂的方法解决,并不违背 KISS 原则。除此之外,同样的代码,在某个业务场景下满足 KISS 原则,换一个应用场景可能就不满足了。

如何写出满足 KISS 原则的代码:

a、不要使用同事可能不懂的技术来实现代码。比如前面例子中的正则表达式,还有一些编程语言中过于高级的语法等。

b、不要重复造轮子,要善于使用已经有的工具类库。经验证明,自己去实现这些类库,出bug 的概率会更高,维护的成本也比较高

c、不要过度优化。不要过度使用一些奇技淫巧(比如,位运算代替算术运算、复杂的条件语句代替 if-else、使用一些过于底层的函数等)来优化代码,牺牲代码的可读性。

评判代码是否简单的间接方法:code review。

YAGNI 原则:不要去设计当前用不到的功能;不要去编写当前用不到的代码。 不要做过度设计

提前往项目里引入大量常用的 library 包。实际上也是违背 YAGNI 原则的。

YAGNI 原则跟 KISS 原则的区别:KISS 原则讲的是“如何做”的问题(尽量保持简单),而 YAGNI 原则说的是“要不要做”的问题(当前不需要的就不要做)

18、DRY 原则

1.DRY 原则(Don’t Repeat Yourself)

我们今天讲了三种代码重复的情况:实现逻辑重复、功能语义重复、代码执行重复。实现逻辑重复,但功能语义不重复的代码,并不违反 DRY 原则。实现逻辑不重复,但功能语义重复的代码,也算是违反 DRY 原则。除此之外,代码执行重复也算是违反 DRY 原则。

实现逻辑重复:

public class UserAuthenticator {
  public void authenticate(String username, String password) {
    if (!isValidUsername(username)) {
       // ...throw InvalidUsernameException...
    }
    if (!isValidPassword(password)) {
      // ...throw InvalidPasswordException...
    }
    //... 省略其他代码...
  } 
  
  private boolean isValidUsername(String username) {
     // check not null, not empty
     if (StringUtils.isBlank(username)) {
       return false;
     }
     // check length: 4~64
     int length = username.length();
     if (length < 4 || length > 64) {
        return false;
     }
     // contains only lowcase characters
    if (!StringUtils.isAllLowerCase(username)) {
      return false;
     }
    // contains only a~z,0~9,dot
    for (int i = 0; i < length; ++i) {
     char c = username.charAt(i);
     if (!(c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.') {
       return false;
      }
     }
     return true;
  } 
  
  private boolean isValidPassword(String password) {
     // check not null, not empty
     if (StringUtils.isBlank(password)) {
       return false;
     }
     // check length: 4~64
     int length = password.length();
     if (length < 4 || length > 64) {
        return false;
     }
    // contains only lowcase characters
    if (!StringUtils.isAllLowerCase(password)) {
        return false;
     }
    // contains only a~z,0~9,dot
    for (int i = 0; i < length; ++i) {
      char c = password.charAt(i);
      if (!(c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.') {
         return false;
       }
     }
     return true;
  }
}

isValidUserName() 函数和 isValidPassword() 函数从代码实现逻辑上看起来是重复的,但是从语义上并不重复。所谓“语义不重复”指的是:从功能上来看,这两个函数干的是完全不重复的两件事情,一个是校验用户名,另一个是校验密码,它并不违反 DRY 原则

功能语义重复:在同一个项目代码中有下面两个函数:isValidIp() 和checkIfIpValid()。尽管两个函数的命名不同,实现逻辑不同,但功能是相同的,它违反了 DRY 原则。

我们应该在项目中,统一一种实现思路,所有用到判断 IP 地址是否合法的地方,都统一调用同一个函数

代码执行重复:UserService 中 login() 函数用来校验用户登录是否成功。如果失败,就返回异常;如果成功,就返回用户信息

public class UserService {
  private UserRepo userRepo;// 通过依赖注入或者 IOC 框架注入
  
  public User login(String email, String password) {
    boolean existed = userRepo.checkIfUserExisted(email, password);
    if (!existed) {
      // ... throw AuthenticationFailureException...
    }
    User user = userRepo.getUserByEmail(email);
    return user;
  }
} 
​
public class UserRepo {
  public boolean checkIfUserExisted(String email, String password) {
    if (!EmailValidation.validate(email)) {
      // ... throw InvalidEmailException...
    } 
    
    if (!PasswordValidation.validate(password)) {
      // ... throw InvalidPasswordException...
    } 
    
    //...query db to check if email&password exists...
 } 
​
 public User getUserByEmail(String email) {
   if (!EmailValidation.validate(email)) {
     // ... throw InvalidEmailException...
   }
   //...query db to get user by email...
 }
}

既没有逻辑重复,也没有语义重复,但仍然违反了 DRY 原则。这是因为代码中存在“执行重复” 。

明显重复的地方:在 login() 函数中,email 的校验逻辑被执行了两次。一次是在调用 checkIfUserExisted() 函数的时候,另一次是调用 getUserByEmail() 函数的时候。

隐蔽重复的地方:login()函数并不需要调用 checkIfUserExisted() 函数,只需要调用一次 getUserByEmail() 函数,从数据库中获取到用户的 email、password 等信息,然后跟用户输入的 email、password 信息做对比,依次判断是否登录成功。

因为 checkIfUserExisted() 函数和 getUserByEmail()函数都需要查询数据库,而数据库这类的 I/O 操作是比较耗时的。我们在写代码的时候,应当尽量减少这类 I/O 操作

代码重构一下,移除“重复执行”的代码,只校验一次 email和 password,并且只查询一次数据库。重构之后的代码如下所示:

public class UserService {
  private UserRepo userRepo;// 通过依赖注入或者 IOC 框架注入
  
  public User login(String email, String password) {
    if (!EmailValidation.validate(email)) {
      // ... throw InvalidEmailException...
    }
    if (!PasswordValidation.validate(password)) {
     // ... throw InvalidPasswordException...
    }
    User user = userRepo.getUserByEmail(email);
    if (user == null || !password.equals(user.getPassword()) {
      // ... throw AuthenticationFailureException...
    }
    return user;
 }
} 
​
public class UserRepo {
  public boolean checkIfUserExisted(String email, String password) {
   //...query db to check if email&password exists
  } 
  public User getUserByEmail(String email) {
    //...query db to get user by email...
  }
}

2.代码复用性(Code Reusability)

尽管复用、可复用性、DRY 原则这三者从理解上有所区别,但实际上要达到的目的都是类似的,都是为了减少代码量,提高代码的可读性、可维护性。除此之外,复用已经经过测试的老代码,bug 会比从零重新开发要少

提高代码可复用性的一些方法:

减少代码耦合:对于高度耦合的代码,当我们希望复用其中的一个功能,想把这个功能的代码抽取出来成为一个独立的模块、类或者函数的时候,往往会发现牵一发而动全身。移动一点代码,就要牵连到很多其他相关的代码。所以,高度耦合的代码会影响到代码的复用性,我们要尽量减少代码耦合。

满足单一职责原则:越细粒度的代码,代码的通用性会越好,越容易被复用。

模块化:这里的“模块”,不单单指一组类构成的模块,还可以理解为单个类、函数。我们要善于将功能独立的代码,封装成模块。独立的模块就像一块一块的积木,更加容易复用,可以直接拿来搭建更加复杂的系统。

业务与非业务逻辑分离:越是跟业务无关的代码越是容易复用,越是针对特定业务的代码越难复用。所以,为了复用跟业务无关的代码,我们将业务和非业务逻辑代码分离,抽取成一些通用的框架、类库、组件等。

通用代码下沉:从分层的角度来看,越底层的代码越通用、会被越多的模块调用,越应该设计得足够可复用。一般情况下,在代码分层之后,为了避免交叉调用导致调用关系混乱,我们只允许上层代码调用下层代码及同层代码之间的调用,杜绝下层代码调用上层代码。所以,通用的代码我们尽量下沉到更下层

继承、多态、抽象、封装:在讲面向对象特性的时候,我们讲到,利用继承,可以将公共的代码抽取到父类,子类复用父类的属性和方法。利用多态,我们可以动态地替换一段代码的部分逻辑,让这段代码可复用。除此之外,抽象和封装,从更加广义的层面、而非狭义的面向对象特性的层面来理解的话,越抽象、越不依赖具体的实现,越容易复用。代码封装成模块,隐藏可变的细节、暴露不变的接口,就越容易复用。

应用模板等设计模式: 一些设计模式,也能提高代码的复用性。比如,模板模式利用了多态来实现,可以灵活地替换其中的部分代码,整个流程模板代码可复用。

3.辩证思考和灵活应用

第一次编写代码的时候,我们不考虑复用性;第二次遇到复用场景的时候,再进行重构使其复用。需要注意的是,“Rule of Three”中的“Three”并不是真的就指确切的“三”,这里就是指“二”。

19、迪米特法则(LOD)

1.何为“高内聚、松耦合”?

a、“高内聚”用来指导类本身的设计,“松耦合”用来指导类与类之间依赖关系的设计。高内聚有助于松耦合,松耦合又需要高内聚的支持。

b、高内聚:就是指相近的功能应该放到同一个类中,不相近的功能不要放到同一个类中。相近的功能往往会被同时修改,放到同一个类中,修改会比较集中,代码容易维护。单一职责原则是实现代码高内聚非常有效的设计原则

c、松耦合:在代码中,类与类之间的依赖关系简单清晰。即使两个类有依赖关系,一个类的代码改动不会或者很少导致依赖类的代码改动。实际上,我们前面讲的依赖注入、接口隔离、基于接口而非实现编程,以及今天讲的迪米特法则,都是为了实现代码的松耦合。

设计模式理论基础_第17张图片

 

左边:类的粒度小,每个类职责单一,高内聚,低耦合。

右边:类的粒度大,低内聚,功能大而全。

2.“迪米特法则”理论描述

迪米特法则(Law of Demeter):缩写是 LOD,最小知识原则。不该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口(也就是定义中的“有限知识”)。迪米特法则包含前后两部分,这两部分讲的是两件事情。

3.理论解读与代码实战一

“不该有直接依赖关系的类之间,不要有依赖”

a、实现简化版的搜索引擎爬取网页的功能。代码中包含三个主要的类。

//NetworkTransporter 类负责底层网络通信,根据请求获取数据;
public class NetworkTransporter {
   // 省略属性和其他方法...
   public Byte[] send(HtmlRequest htmlRequest) {
     //...
   }
} 
//HtmlDownloader类用来通过 URL 获取网页
public class HtmlDownloader {
  private NetworkTransporter transporter;// 通过构造函数或 IOC 注入
  
  public Html downloadHtml(String url) {
    Byte[] rawHtml = transporter.send(new HtmlRequest(url));
    return new Html(rawHtml);
  }
} 
//Document 表示网页文档,后续的网页内容抽取、分词、索引都是以此为处理对象
public class Document {
  private Html html;
  private String url;
  
  public Document(String url) {
    this.url = url;
    HtmlDownloader downloader = new HtmlDownloader();
    this.html = downloader.downloadHtml(url);
  }
  //...
}

b、代码缺陷:

NetworkTransporter 类: 作为一个底层网络通信类,我们希望它的功能尽可能通用,而不只是服务于下载 HTML,所以,我们不应该直接依赖太具体的发送对象HtmlRequest。从这一点上讲,NetworkTransporter 类的设计违背迪米特法则,依赖了不该有直接依赖关系的 HtmlRequest 类

重构:应该把 HtmlRequest 里的 address 和 content 对象 交给 NetworkTransporter,而非是直接把 HtmlRequest 交给NetworkTransporter

public class NetworkTransporter {
  // 省略属性和其他方法...
  public Byte[] send(String address, Byte[] data) {
    //...
  }
}

c、HtmlDownloader 类:

这个类的设计没有问题。不过,我们修改了NetworkTransporter 的 send() 函数的定义,而这个类用到了 send() 函数,所以我们需要对它做相应的修改,修改后的代码如下所示:

public class HtmlDownloader {
  private NetworkTransporter transporter;// 通过构造函数或 IOC 注入
  
  // HtmlDownloader 这里也要有相应的修改
  public Html downloadHtml(String url) {
    HtmlRequest htmlRequest = new HtmlRequest(url);
    Byte[] rawHtml = transporter.send(
      htmlRequest.getAddress(), htmlRequest.getContent().getBytes());
    return new Html(rawHtml);
  }
}  

d、Document 类: 这个类的问题比较多,主要有三点。

第一,构造函数中的 downloader.downloadHtml() 逻辑复杂,耗时长,不应该放到构造函数中,会影响代码的可测试性。代码的可测试性我们后面会讲到,这里你先知道有这回事就可以了。

第二,HtmlDownloader 对象在构造函数中通过 new 来创建,违反了基于接口而非实现编程的设计思想,也会影响到代码的可测试性。

第三,从业务含义上来讲,Document 网页文档没必要依赖 HtmlDownloader 类,违背了迪米特法则

public class Document {
  private Html html;
  private String url;
  
  public Document(String url, Html html) {
    this.html = html;
    this.url = url;
  }
  //...
} 
​
// 通过一个工厂方法来创建 Document
public class DocumentFactory {
  private HtmlDownloader downloader;
  
  public DocumentFactory(HtmlDownloader downloader) {
    this.downloader = downloader;
} 
  
  public Document createDocument(String url) {
    Html html = downloader.downloadHtml(url);
    return new Document(url, html);
  }
}

4.理论解读与代码实战二

“有依赖关系的类之间,尽量只依赖必要的接口”

代码:

//Serialization 类负责对象的序列化和反序列化  
public class Serialization {
  public String serialize(Object object) {
    String serializedResult = ...;
    //...
    return serializedResult;
  } 
​
  public Object deserialize(String str) {
    Object deserializedResult = ...;
    //...
    return deserializedResult;
  }
}

代码缺陷:基于迪米特法则后半部分“有依赖关系的类之间,尽量只依赖必要的接口”,只用到序列化操作的那部分类不应该依赖反序列化接口。同理,只用到反序列化操作的那部分类不应该依赖序列化接口

代码改进:将 Serialization 类拆分为两个更小粒度的类,一个只负责序列化(Serializer 类),一个只负责反序列化(Deserializer 类)。拆分之后,使用序列化操作的类只需要依赖 Serializer 类,使用反序列化操作的类只需要依赖 Deserializer 类。拆分之后的代码如下所示:

public class Serializer {
  public String serialize(Object object) {
    String serializedResult = ...;
    ...
    return serializedResult;
  }
} 
public class Deserializer {
  public Object deserialize(String str) {
    Object deserializedResult = ...;
    ...
     return deserializedResult;
  }
}

修改后的代码满足了迪米特法则,但却违背了高内聚的设计思想。

二次修改: 通过引入两个接口就能轻松解决这个问题

public interface Serializable {
  String serialize(Object object);
} 
public interface Deserializable {
  Object deserialize(String text);
} 
public class Serialization implements Serializable, Deserializable {
  @Override
  public String serialize(Object object) {
    String serializedResult = ...;
    ...
    return serializedResult;
  } 
  
  @Override
  public Object deserialize(String str) {
    Object deserializedResult = ...;
    ...
    return deserializedResult;
  }
} 
​
public class DemoClass_1 {
  private Serializable serializer;
  
  public Demo(Serializable serializer) {
    this.serializer = serializer;
  }
  //...
}
public class DemoClass_2 {
  private Deserializable deserializer;
  
  public Demo(Deserializable deserializer) {
    this.deserializer = deserializer;
  }
  //...
}

往 DemoClass_1 的构造函数中,传入包含序列化和反序列化的Serialization 实现类,但是,我们依赖的 Serializable 接口只包含序列化操作,DemoClass_1 无法使用 Serialization 类中的反序列化接口,对反序列化操作无感知,这也就符合了迪米特法则后半部分所说的“依赖有限接口”的要求。

5.辩证思考与灵活应用

不要为了应用设计原则而应用设计原则,我们在应用设计原则的时候,一定要具体问题具体分析

20、实战1:针对业务系统的开发,如何做需求分析和设计?

积分兑换系统的开发实战:一个业务系统从需求分析到上线维护的整个开发套路

面向对象设计聚焦在代码层面(主要是针对类),那系统设计就是聚焦在架构层面(主要是 针对模块)

1.需求分析

a、积分赚取和兑换规则

b、积分消费和兑换规则

c、积分及其明细查询

2.系统设计

a、合理地将功能划分到不同模块

面向对象设计的本质就是把合适的代码放到合适的类中。合理地划分代码可以实现代码的高内聚、低耦合,类与类之间的交互简单清晰,代码整体结构一目了然,那代码的质量就不会差到哪里去

b、设计模块与模块之间的交互关系

设计系统之间的交互,也就是确定有哪些系统跟积分系统之间有交互以及如何进行交互。

比较常见的系统之间的交互方式有两种,一种是同步接口调用,另一种是利用消息中间件异步调用。

c、设计模块的接口、数据库、业务模型

业务系统本身的设计无外乎有这样三方面的工作要做:接口设计、数据库设计和业务模型设计(业务逻辑)。

数据库设计:

接口设计:

业务模型设计:

21、实战2:非业务的通用框架开发

1.需求分析

a、功能性需求分析

接口统计信息:包括接口响应时间的统计信息,以及接口调用次数的统计信息等。 统计信息的类型:max、min、avg、percentile、count、tps 等。 统计信息显示格式:Json、Html、自定义显示格式。 统计信息显示终端:Console、Email、HTTP 网页、日志、自定义显示终端。

b、非功能性需求

易用性、性能、扩展性、容错性 、通用性

2.框架设计

画产品线框图、聚焦简单应用场景、设计实现最小原型、画系统设计图等。这些方法的目的 都是为了让问题简化、具体、明确,提供一个迭代设计开发的基础,逐步推进。

小步快跑、逐步迭代

面向对象设计与实现:

划分职责进而识别出有哪些类

定义类及类与类之间的关系:面向对象设计和实现要做的事情,就是把合适的代码放到合适的类中

将类组装起来并提供执行入口

规范与重构

22、重构的概括性介绍

1.重构的目的

在保持功能不变的前提下,利用设计思想、原则、模式、编程规范等理论来优化代码,修改设计上的不足,提高代码质量

重构可以保持代码质量持续处于一个可控状态

2.重构的对象

按照重构的规模,我们可以将重构大致分为大规模高层次的重构和小规模低层次的重构。大规模高层次重构包括对代码分层、模块化、解耦、梳理类之间的交互关系、抽象复用组件等等。这部分工作利用的更多的是比较抽象、比较顶层的设计思想、原则、模式。小规模低层次的重构包括规范命名、注释、修正函数参数过多、消除超大类、提取重复代码等等编程细节问题,主要是针对类、函数级别的重构。小规模低层次的重构更多的是利用编码规范这一理论知识

3.重构的时机

一定要建立持续重构意识,把重构作为开发必不可少的部分,融入到日常开发中

4.重构的方法

大规模高层次的重构难度比较大,需要组织、有计划地进行,分阶段地小步快跑,时刻让代码处于一个可运行的状态。而小规模低层次的重构,因为影响范围小,改动耗时短,所以,只要你愿意并且有时间,随时随地都可以去做。

你可能感兴趣的:(设计模式,设计模式)