设计原则与思想【面向对象、设计原则、编程规范、重构技巧】

一、高质量代码的评判标准:

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

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

  1. 可扩展性:代码预留了一些功能扩展点,你可以把新功能代码,直接插到扩展点上,而不需要因为要添加一个功能而大动干戈,改动大量的原始代码。“对修改关闭,对扩展开放”这条设计原则。

  1. 灵活性:

①当我们添加一个新的功能代码的时候,原有的代码已经预留好了扩展点,我们不需要修改原有的代码,只要在扩展点上添加新的代码即可

②当我们要实现一个功能的时候,发现原有代码中,已经抽象出了很多底层可以复用的模块、类等代码,我们可以拿来直接使用。

③当我们使用某组接口的时候,如果这组接口可以应对各种使用场景,满足各种不同的需求

  1. 简洁性:我们在编写代码的时候,往往也会把简单、清晰放到首位。

  1. 可复用性:

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

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

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

  1. 可测试性:单元测试比较容易编写

二、面向对象:

  1. 封装:封装也叫作信息隐藏或者数据访问保护

  1. 抽象:例如调用者在使用图片存储功能的时候,只需要了解 IPictureStorage 这个接口类暴露了哪些方法就可以了,不需要去查看 PictureStorage 类里的具体实现逻辑。

  1. 继承:单继承表示一个子类只继承一个父类,多继承表示一个子类可以继承多个父类,比如猫既是哺乳动物,又是爬行动物。

  1. 多态:在实际的代码运行过程中,调用子类新的功能逻辑,而不是在原有代码上做修改。这就遵从了“对修改关闭、对扩展开放”的设计原则,提高代码的扩展性。

三、面向对象设计理念:

1、相关联的数据封装在一个类里,例如在 ShoppingCart 类中定义一个 clear() 方法,将清空购物车的业务逻辑封装在里面(itemsCount、totalPrice、items 三者数据同步),透明地给调用者使用。

2、我们可以通过 Java 提供的 Collections.unmodifiableList() 方法,让 getter 方法返回一个不可被修改的 UnmodifiableList 集合容器

四、面向过程设计理念:

  1. 针对不同的功能,静态方法无需属性,设计不同的 Utils 类,比如 FileUtils、IOUtils、StringUtils、UrlUtils 等,不要设计一个过于大而全的 Utils 类。

五、接口VS抽象类的区别

1、特性区别:

抽象类特性:

①抽象类不允许被实例化,只能被继承。也就是说,你不能 new 一个抽象类的对象出来(Logger logger = new Logger(...); 会报编译错误)。

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

③子类继承抽象类,必须实现抽象类中的所有抽象方法。

接口特性:

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

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

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

2、使用区别:

抽象类使用:

①在 Logger 中定义一个空的方法,会影响代码的可读性。

②当创建一个新的子类继承 Logger 父类的时候,我们有可能会忘记重新实现 log() 方法。之前基于抽象类的设计思路,编译器会强制要求子类重写 log() 方法,否则会报编译错误

③不能实例化,减少误用风险

接口使用:

①接口就更侧重于解耦。接口是对行为的一种抽象,相当于一组协议或者契约,你可以联想类比一下 API 接口。

运用场景:

①如果我们要表示一种 is-a 的关系,并且是为了解决代码复用的问题,我们就用抽象类;

②如果我们要表示一种 has-a 关系,并且是为了解决抽象而非代码复用的问题,那我们就可以使用接口。

3、设计思路区别:

①抽象类是一种自下而上的设计思路,先有子类的代码重复,然后再抽象成上层的父类(也就是抽象类)。

②而接口正好相反,它是一种自上而下的设计思路。我们在编程的时候,一般都是先设计接口,再去考虑具体的实现。

六、接口

  1. ”基于接口而非实现编程”的原则:

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

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

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

七、多用组合少用继承:

1、继承最大的问题就在于:继承层次过深、继承关系过于复杂会影响到代码的可读性和可维护性

2、通过组合和委托技术来消除代码重复



public class Url {
  //...省略属性和方法
}

public class Crawler {
  private Url url; // 组合
  public Crawler() {
    this.url = new Url();
  }
  //...
}

public class PageAnalyzer {
  private Url url; // 组合
  public PageAnalyzer() {
    this.url = new Url();
  }
  //..
}

3、使用场景:

①如果类之间的继承结构稳定(不会轻易改变),继承层次比较浅(比如,最多有两层继承关系),继承关系不复杂,我们就可以大胆地使用继承。

②反之,系统越不稳定,继承层次很深,继承关系复杂,我们就尽量使用组合来替代继承。

例如多态:FeignClient 是一个外部类,我们没有权限去修改这部分代码,但是我们希望能重写这个类在运行时执行的 encode() 函数。这个时候,我们只能采用继承来实现了。

八、贫血模型MVC

1、组织代码:

①UserEntity 和 UserRepository 组成了数据访问层

②UserBo 和 UserService 组成了业务逻辑层

③UserVo 和 UserController 在这里属于接口层。

像 UserBo 这样,只包含数据,不包含业务逻辑的类,就叫作贫血模型(Anemic Domain Model)。同理,UserEntity、UserVo 都是基于贫血模型设计的。这种贫血模型将数据与操作分离,破坏了面向对象的封装特性,是一种典型的面向过程的编程风格。

九、充血模型DDD

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

2、在基于充血模型的 DDD 开发模式中,Service 层包含 Service 类和 Domain 类两部分。Domain 就相当于贫血模型中的 BO。不过,Domain 与 BO 的区别在于它是基于充血模型开发的,既包含数据,也包含业务逻辑。

3、基于贫血模型的传统的开发模式,重 Service 轻 BO;基于充血模型的 DDD 开发模式,轻 Service 重 Domain。

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

5、DDD中Domain做数据联动处理

6、充血模型DDD中service的功能:

①Service 类负责与 Repository 交流。

②Service 类负责跨领域模型的业务聚合功能。VirtualWalletService 类中的 transfer() 转账函数会涉及两个钱包的操作,因此这部分业务逻辑无法放到 VirtualWallet 类中,所以,我们暂且把转账业务放到 VirtualWalletService 类中了。

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

十、需求拆解

1、拆解出来的每个功能点要尽可能的小。每个功能点只负责做一件很小的事情(专业叫法是“单一职责) 2、针对这种复杂的需求开发,我们首先要做的是进行模块划分,将需求先简单划分成几个小的、独立的功能模块,然后再在模块内部,应用我们刚刚讲的方法,进行面向对象设计。而模块的划分和识别,跟类的划分和识别,是类似的套路。

3、识别出需求描述中的动词,作为候选的方法,再进一步过滤筛选。类比一下方法的识别,我们可以把功能点中涉及的名词,作为候选属性,然后同样进行过滤筛选。

例如:

第一个细节:并不是所有出现的名词都被定义为类的属性,比如 URL、AppID、密码、时间戳这几个名词,我们把它作为了方法的参数。

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

第三个细节:我们还给 AuthToken 类添加了一个功能点描述中没有提到的方法 getToken()。

第一个细节告诉我们,从业务模型上来说,不应该属于这个类的属性和方法,不应该被放到这个类里。比如 URL、AppID 这些信息,从业务模型上来说,不应该属于 AuthToken,所以我们不应该放到这个类中。

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

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

泛化(Generalization)可以简单理解为继承关系。

实现(Realization)一般是指接口和实现类之间的关系。

组合(Composition)也是一种包含关系。只要 B 类对象是 A 类对象的成员变量,那我们就称,A 类跟 B 类是组合关系,比如鸟与翅膀之间的关系

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

十二、单一职责原则:

1、类中的代码行数、函数或属性过多,会影响代码的可读性和可维护性,我们就需要考虑对类进行拆分;

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

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

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

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

十三、对扩展开放、对修改关闭原则:

1、方法:多态、依赖注入、基于接口而非实现编程

2、两种意识:

① 封装意识:入参传入对象

② 扩展意识、抽象意识:处理方法使用数组遍历和多态

十四、里式替换原则:

1、行为约定包括:

函数声明要实现的功能;对输入、输出、异常的约定;甚至包括注释中所罗列的任何特殊说明。实际上,定义中父类和子类之间的关系,也可以替换成接口和实现类之间的关系。

2、违背里氏替换原则例子:

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

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

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

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

3、验证方法:

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

十五、接口隔离原则:

1、把“接口”理解为一组 API 接口集合:

在设计微服务或者类库接口的时候,如果部分接口只被部分调用者使用,那我们就需要将这部分接口隔离出来,单独给对应的调用者使用,而不是强迫其他调用者也依赖这部分不会被用到的接口。

2、把“接口”理解为单个 API 接口或函数:

函数的设计要功能单一,不要将多个不同的功能逻辑在一个函数中实现。

注意:接口隔离原则与单一职责的区别:

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

而接口隔离原则相对于单一职责原则,一方面它更侧重于接口的设计,另一方面它的思考的角度不同。

它提供了一种判断接口是否职责单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。

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

①接口的设计要尽量单一,不要让接口的实现类和调用者,依赖不需要的接口函数。

②如果我们的接口粒度比较小,那涉及改动的类就比较少。

十六、控制反转:

1、控制反转(IOC):

这里的“控制”指的是对程序执行流程的控制,而“反转”指的是在没有使用框架之前,程序员自己控制整个程序的执行。在使用框架之后,整个程序的执行流程可以通过框架来控制。流程的控制权从程序员“反转”到了框架。

2、依赖注入:

不通过 new() 的方式在类内部创建依赖类对象,而是将依赖的类对象在外部创建好之后,通过构造函数、函数参数等方式传递(或注入)给类使用。

public class StupidStudent {
    private SmartStudent smartStudent;
    // 通过构造函数、函数参数方式传入给类使用
    public StupidStudent(SmartStudent smartStudent) {
        this.smartStudent = smartStudent;
    }
    
    public doHomewrok() {
        smartStudent.doHomework();
        System.out.println("学渣抄作业");
    }
}
public class StudentTest {
    public static void main(String[] args) {
        SmartStudent smartStudent = new SmartStudent();
        // 通过构造函数、函数参数方式传入给类使用
        StupidStudent stupidStudent = new StupidStudent(smartStudent);
        stupidStudent.doHomework();
    }
}

3、依赖注入框架(DI Framework):

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

4、依赖反转原则(DIP)

依赖反转原则也叫作依赖倒置原则。这条原则跟控制反转有点类似,主要用来指导框架层面的设计。

高层模块不依赖低层模块,它们共同依赖同一个抽象。

抽象不要依赖具体实现细节,具体实现细节依赖抽象。

十七、KISS原则:

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

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

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

十八、YAGNI原则:

1、不要去设计当前用不到的功能;不要去编写当前用不到的代码。这条原则的核心思想就是:不要做过度设计。

2、YAGNI与KISS原则的区别:

① KISS 原则讲的是“如何做”的问题(尽量保持简单)

② 而 YAGNI 原则说的是“要不要做”的问题(当前不需要的就不要做)。

十九、DRY原则:

1、实现逻辑重复:

尽管代码的实现逻辑是相同的,但语义不同,我们判定它并不违反 DRY 原则。对于包含重复代码的问题,我们可以通过抽象成更细粒度函数的方式来解决。比如将校验只包含 a~z、0~9、dot 的逻辑封装成 boolean onlyContains(String str, String charlist); 函数。

2、功能语义重复:

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

3、代码执行重复:

去除两次重复执行的代码逻辑

二十、代码复用性:

1、如何提高代码复用性:

①减少代码耦合

②满足单一职责原则

③模块化

④业务与非业务逻辑分离

⑤通用代码下沉

⑥继承、多态、抽象、封装

⑦应用模板等设计模式

2、复用意识也非常重要:

在设计每个模块、类、函数的时候,要像设计一个外部 API 一样去思考它的复用性。

二十一、迪米特法则:

1、高内聚:

①所谓高内聚,就是指相近的功能应该放到同一个类中,不相近的功能不要放到同一个类中。

②相近的功能往往会被同时修改,放到同一个类中,修改会比较集中,代码容易维护。

③单一职责原则是实现代码高内聚非常有效的设计原则。

2、松耦合:

①在代码中,类与类之间的依赖关系简单清晰。

②即使两个类有依赖关系,一个类的代码改动不会或者很少导致依赖类的代码改动。

③依赖注入、接口隔离、基于接口而非实现编程,以及今天讲的迪米特法则,都是为了实现代码的松耦合

3、迪米特法则:

①不该有直接依赖关系的类之间,不要有依赖;

②有依赖关系的类之间,尽量只依赖必要的接口(通过两个接口实现)

4、既满足迪米特,同时满足高内聚


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;
  }
  //...
}

二十二、系统设计

1. 合理地将功能划分到不同模块

2. 设计模块与模块之间的交互关系

上下层系统之间的调用倾向于通过同步接口,同层之间的调用倾向于异步消息调用。

比如,营销系统和积分系统是上下层关系,它们之间就比较推荐使用同步接口调用。

订单系统就跟营销系统完全解耦,使用异步消息调用

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

二十三、 facade(外观)设计模式:

在职责单一的细粒度接口之上,再封装一层粗粒度的接口给外部使用。

二十四、为什么要分 MVC 三层开发:

1. 分层能起到代码复用的作用:

同一个 Repository 可能会被多个 Service 来调用,同一个 Service 可能会被多个 Controller 调用。

2. 分层能起到隔离变化的作用:

①分层体现了一种抽象和封装的设计思想。比如,Repository 层封装了对数据库访问的操作,提供了抽象的数据访问接口。

②基于接口而非实现编程的设计思想,Service 层使用 Repository 层提供的接口,并不关心其底层依赖的是哪种具体的数据库。当我们需要替换数据库的时候,比如从 MySQL 到 Oracle,从 Oracle 到 Redis,只需要改动 Repository 层的代码,Service 层的代码完全不需要修改。

③分层之后,Controller 层中代码的频繁改动并不会影响到稳定的 Repository 层。

3. 分层能起到隔离关注点的作用:

①Repository 层只关注数据的读写。

②Service 层只关注业务逻辑,不关注数据的来源。

③Controller 层只关注与外界打交道,数据校验、封装、格式转换,并不关心业务逻辑。

④三层之间的关注点不同,分层之后,职责分明,更加符合单一职责原则,代码的内聚性更好。

4. 分层能提高代码的可测试性:

单元测试不依赖不可控的外部组件,比如数据库。分层之后,Repsitory 层的代码通过依赖注入的方式供 Service 层使用,当要测试包含核心业务逻辑的 Service 层代码的时候,我们可以用 mock 的数据源替代真实的数据库,注入到 Service 层代码中。

5. 分层能应对系统的复杂性:

①水平方向基于业务来做拆分,就是模块化;

②垂直方向基于流程来做拆分,就是这里说的分层。

二十五、BO、VO、Entity 存在的意义是什么:

1、推荐每层都定义各自的数据对象这种设计思路:

①VO、BO、Entity 并非完全一样。

②VO、BO、Entity 三个类虽然代码重复,但功能语义不重复,从职责上讲是不一样的。

③分层清晰:为了尽量减少每层之间的耦合,把职责边界划分明确,每层都会维护自己的数据对象,层与层之间通过接口交互。数据从下一层传递到上一层的时候,将下一层的数据对象转化成上一层的数据对象,再继续处理

2、既然 VO、BO、Entity 不能合并,那如何解决代码重复的问题呢:

这里我们还可以将公共的字段抽取到公共的类中,VO、BO、Entity 通过组合关系来复用这个类的代码。

3、代码重复问题解决了,那不同分层之间的数据对象该如何互相转化呢:

①当下一层的数据通过接口调用传递到上一层之后,我们需要将它转化成上一层对应的数据对象类型。

②比如,Service 层从 Repository 层获取的 Entity 之后,将其转化成 BO,再继续业务逻辑的处理。

③Java 中提供了多种数据对象转化工具,比如 BeanUtils、Dozer 等

4、VO、BO、Entity 都是基于贫血模型的,我们还需要定义每个字段的 set 方法,会导致数据被随意修改。那到底该怎么办好呢?

Entity 和 VO 的生命周期是有限的,都仅限在本层范围内。设计的问题本身就没有最优解,只有权衡,BO特殊处理

二十六:非功能性需求分析

1、框架的易用性:

框架是否易集成、易插拔、跟业务代码是否松耦合、提供的接口是否够灵活

2、性能:

一方面,我们希望它是低延迟的,也就是说,统计代码不影响或很少影响接口本身的响应时间;

另一方面,我们希望框架本身对内存的消耗不能太大。

3、扩展性:

这里所说的扩展是从框架使用者的角度来说的,特指使用者可以在不修改框架源码,甚至不拿到框架源码的情况下,为框架扩展新的功能。这就有点类似给框架开发插件

例如:


Feign feign = Feign.builder()
        .logger(new CustomizedLogger())
        .encoder(new FormEncoder(new JacksonEncoder()))
        .decoder(new JacksonDecoder())
        .errorDecoder(new ResponseErrorDecoder())
        .requestInterceptor(new RequestHeadersInterceptor()).build();

public class RequestHeadersInterceptor implements RequestInterceptor {  
  @Override
  public void apply(RequestTemplate template) {
    template.header("appId", "...");
    template.header("version", "...");
    template.header("timestamp", "...");
    template.header("token", "...");
    template.header("idempotent-token", "...");
    template.header("sequence-id", "...");
}

public class CustomizedLogger extends feign.Logger {
  //...
}

public class ResponseErrorDecoder implements ErrorDecoder {
  @Override
  public Exception decode(String methodKey, Response response) {
    //...
  }
}

4、容错性:

我们要对框架可能存在的各种异常情况都考虑全面,对外暴露的接口抛出的所有运行时、非运行时异常都进行捕获处理。

5、通用性:

为了提高框架的复用性,能够灵活应用到各种场景中。

二十七、框架设计:

  1. 借鉴 TDD(测试驱动开发)和 Prototype(最小原型)的思想,先聚焦于一个简单的应用场景,基于此设计实现一个简单的原型,是迭代设计的基础。

二十八、面向对象设计与实现:

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

2. 定义类及属性和方法,定义类与类之间的关系:

动词是方法、名词是属性

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

二十九、重构

1、重构的意义:

① 首先,重构是时刻保证代码质量的一个极其有效的手段,不至于让代码腐化到无可救药的地步。

② 其次,优秀的代码或架构不是一开始就能完全设计好的,就像优秀的公司和产品也都是迭代出来的。我们无法 100% 遇见未来的需求,也没有足够的精力、时间、资源为遥远的未来买单,所以,随着系统的演进,重构代码也是不可避免的。

③ 最后,重构是避免过度设计的有效手段。在我们维护代码的过程中,真正遇到问题的时候,再对代码进行重构,能有效避免前期投入太多时间做过度的设计,做到有的放矢。

2、重构规模:

①大规模高层次重构包括对代码分层、模块化、解耦、梳理类之间的交互关系、抽象复用组件等等。这部分工作利用的更多的是比较抽象、比较顶层的设计思想、原则、模式。

注意:大规模高层次的重构难度比较大,需要组织、有计划地进行,分阶段地小步快跑,时刻让代码处于一个可运行的状态。

②小规模低层次的重构包括规范命名、注释、修正函数参数过多、消除超大类、提取重复代码等等编程细节问题,主要是针对类、函数级别的重构。小规模低层次的重构更多的是利用编码规范这一理论知识。

注意:而小规模低层次的重构,因为影响范围小,改动耗时短,所以,只要你愿意并且有时间,随时随地都可以去做。

三十、单元测试

1、认知:

①编写单元测试尽管繁琐,但并不是太耗时;

②我们可以稍微放低单元测试的质量要求;

③覆盖率作为衡量单元测试好坏的唯一标准是不合理的;写单元测试一般不需要了解代码的实现逻辑;

④单元测试框架无法测试多半是代码的可测试性不好。

2、原因:

单元测试除了能有效地为重构保驾护航之外,也是保证代码质量最有效的两个手段之一(另一个是 Code Review)

3、方式:

写单元测试就是针对代码设计各种测试用例,以覆盖各种输入、异常、边界情况,并将其翻译成代码。

三十一、提高代码可测试性:

1、使用依赖注入:将控制反转,控制权给到上层

2、测试性不好的代码:

① 未决行为:

所谓的未决行为逻辑就是,代码的输出是随机或者说不确定的,比如,跟时间、随机数有关的代码。

② 全局变量:

滥用全局变量也让编写单元测试变得困难

③ 静态方法:

只有在这个静态方法执行耗时太长、依赖外部资源、逻辑复杂、行为未决等情况下,我们才需要在单元测试中 mock 这个静态方法

④ 复杂继承:

如果父类需要 mock 某个依赖对象才能进行单元测试,那所有的子类、子类的子类……在编写单元测试的时候,都要 mock 这个依赖对象。

⑤ 高耦合代码

如果一个类职责很重,需要依赖十几个外部对象才能完成工作,代码高度耦合,那我们在编写单元测试的时候,可能需要 mock 这十几个依赖的对象。

三十二、解耦:

1、解耦方法:

①封装和抽象:

封装和抽象可以有效地隐藏实现的复杂性,隔离实现的易变性,给依赖的模块提供稳定且易用的抽象接口。

②中间层:

第一阶段:引入一个中间层,包裹老的接口,提供新的接口定义。

第二阶段:新开发的代码依赖中间层提供的新接口。

第三阶段:将依赖老接口的代码改为调用新接口。

第四阶段:确保所有的代码都调用新接口之后,删除掉老的接口。

③模块化:

将每个模块都当作一个独立的 lib 一样来开发,只提供封装了内部实现细节的接口给其他模块使用

④其他设计思想和原则:

单一职责原则

基于接口而非实现编程

依赖注入

多用组合少用继承

迪米特法则

三十三:编码规范:

1、命名:

① 命名的一个原则就是以能准确达意为目标

② 利用上下文简化命名:


User user = new User();
user.getName(); // 借助user对象这个上下文

③ 命名要可读、可搜索:统一规约是很重要的,能减少很多不必要的麻烦,例如统一用get前缀

④ 如何命名接口和抽象类:加前缀“I”,表示一个 Interface。比如 IUserService,对应的实现类命名为 UserService。

2、注释:

① 注释比代码承载的信息更多

② 注释起到总结性作用、文档的作用

③ 一些总结性注释能让代码结构更清晰

④ 注释的内容主要包含这样三个方面:做什么、为什么、怎么做。

注意:对于一些复杂的类和接口,我们可能还需要写明“如何用”。

3、代码风格:

① 函数不超过50行

② 善用空行分割单元块:

在类内部,成员变量与函数之间、静态成员变量与普通成员变量之间、函数之间,甚至成员变量之间,都可以通过添加空行的方式,让不同模块的代码之间的界限更加明确。

③ Java 语言倾向于两格缩进,一定不要用 tab 键缩进。

④ Java 程序员喜欢把大括号跟上一条语句放到一起

⑤ 类中成员的排列顺序:

在类中,成员变量排在函数的前面。

成员变量之间或函数之间,都是按照“先静态(静态函数或静态成员变量)、后普通(非静态函数或非静态成员变量)”的方式来排列的。

除此之外,成员变量之间或函数之间,还会按照作用域范围从大到小的顺序来排列,先写 public 成员变量或函数,然后是 protected 的,最后是 private 的。

4、把代码分割成更小的单元块:函数是否职责单一

5、避免函数参数过多:将函数的参数封装成对象。

6、函数设计要职责单一

7、移除过深的嵌套层次,方法包括:去掉多余的 if 或 else 语句,使用 continue、break、return 关键字提前退出嵌套,调整执行顺序来减少嵌套,将部分嵌套逻辑抽象成函数。

8、学会使用解释性变量:常量取代魔法数字

9、一定要制定统一的编码规范,并且通过 Code Review 督促执行,

三十四:代码质量审核:

技术:

1、目录设置是否合理、模块划分是否清晰、代码结构是否满足“高内聚、松耦合”?

2、是否遵循经典的设计原则和设计思想(SOLID、DRY、KISS、YAGNI、LOD 等)?

3、设计模式是否应用得当?

4、是否有过度设计?

5、代码是否容易扩展?如果要添加新功能,是否容易实现?

6、代码是否可以复用?

7、是否可以复用已有的项目代码或类库?是否有重复造轮子?

8、代码是否容易测试?

9、单元测试是否全面覆盖了各种正常和异常的情况?

10、代码是否易读?

11、是否符合编码规范(比如命名和注释是否恰当、代码风格是否一致等)?

业务:

1、代码是否实现了预期的业务需求?

2、逻辑是否正确?

3、是否处理了各种异常情况?

4、日志打印是否得当?

5、是否方便 debug 排查问题?

6、接口是否易用?

7、是否支持幂等、事务等?

8、代码是否存在并发问题?

9、是否线程安全?

10、性能是否有优化空间,比如,SQL、算法是否可以优化?

11、是否有安全漏洞?

12、比如输入输出校验是否全面?

三十五、重构步骤:

1、第一轮重构:提高代码的可读性

2、第二轮重构:提高代码的可测试性

依赖注入之所以能提高代码可测试性,主要是因为,通过这样的方式我们能轻松地用 mock 对象替换依赖的真实对象。

3、第三轮重构:编写完善的单元测试

4、第四轮重构:所有重构完成之后添加注释

三十六、函数出错应该返回啥

1、错误码:编程语言中有异常这种语法机制,那就尽量不要使用错误码。

2、NULL 值:如果某个函数有可能返回 NULL 值,我们在使用它的时候,忘记了做 NULL 值判断,就有可能会抛出空指针异常(Null Pointer Exception,缩写为 NPE)。

3、空对象

4、异常对象:

编译时异常(Compile Exception):受检异常(Checked Exception)

运行时异常(Runtime Exception):非受检异常(Unchecked Exception)

三十七、如何处理函数抛出的异常?

1、直接吞掉:

如果 func1() 抛出的异常是可以恢复,且 func2() 的调用方并不关心此异常,我们完全可以在 func2() 内将 func1() 抛出的异常吞掉;

2、原封不动地 re-throw:

如果 func1() 抛出的异常对 func2() 的调用方来说,也是可以理解的、关心的 ,并且在业务概念上有一定的相关性,我们可以选择直接将 func1 抛出的异常 re-throw;

3、包装成新的异常 re-throw:

如果 func1() 抛出的异常太底层,对 func2() 的调用方来说,缺乏背景去理解、且业务概念上无关,我们可以将它重新包装成调用方可以理解的新异常,然后 re-throw。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值