如何提高代码质量——《设计模式之美》笔记

218 阅读1小时+

什么是高质量代码?有哪些特点?

  • 可维护性(maintainability)

    • bug 容易修复、功能容易添加——可读性好、简洁、可扩展性好、分层清晰、模块化好、高内聚低耦合、遵从基于接口而非实现、项目代码量、业务的复杂程度、技术的复杂程度、文档是否全面、等诸多因素有关。
  • 可读性(readability)

    • 编码规范、命名、注释、函数长短、模块划分、高内聚低耦合等。
  • 可扩展性(extensibility)

    • 容易增加新功能而不用大改。
  • 灵活性(flexibility)

    • 易扩展、易复用或者易用。
  • 简洁性(simplicity)

    • KISS 原则:“Keep It Simple,Stupid”。
  • 可复用性(reusability)

    • 尽量减少重复代码的编写,复用已有的代码。
  • 可测试性(testability)

怎么写出高质量代码?有哪些方法?

  • 面向对象:主流的编程范式之一,具有丰富的特性(封装、抽象、继承、多态),是很多设计原则、设计模式编码实现的基础。

  • 设计原则:指导我们代码设计的一些经验总结。

    • SRP 单一职责原则
    • OCP 开闭原则
    • LSP 里式替换原则
    • ISP 接口隔离原则
    • DIP 依赖倒置原则
    • DRY 原则、KISS 原则、YAGNI 原则、LOD 法则
  • 设计模式:设计模式是针对软件开发中经常遇到的一些设计问题,总结出来的一套解决方案或者设计思路。

    • 创建型:单例模式、工厂模式(工厂方法和抽象工厂)、建造者模式。不常用的有:原型模式。
    • 结构型:代理模式、桥接模式、装饰者模式、适配器模式。不常用的有:门面模式、组合模式、享元模式。
    • 行为型:观察者模式、模板模式、策略模式、职责链模式、迭代器模式、状态模式。不常用的有:访问者模式、备忘录模式、命令模式、解释器模式、中介模式。
  • 编程规范:更加偏重代码细节。比如,如何给变量、类、函数命名,如何写代码注释,函数不宜过长、参数不能过多等等。

  • 代码重构:解决原有设计的不足,利用那些面向对象设计思想、设计原则、设计模式、编码规范进行重构。

    • 重构的目的(why)、对象(what)、时机(when)、方法(how);
    • 保证重构不出错的技术手段:单元测试和代码的可测试性;
    • 两种不同规模的重构:大重构(大规模高层次)和小重构(小规模低层次)。

面向对象?

什么是面向对象编程和面向对象编程语言?

  • 面向对象编程是一种编程范式或编程风格。以对象为组织代码的基本单元,并将封装、抽象、继承、多态四个特性,作为代码设计和实现的基石 。
  • 面向对象编程语言是能方便进行面向对象编程的语言。不充分不必要。
  • 在技术圈里,封装、抽象、继承、多态也并不是固定地被叫作“四大特性”(features),也有人称它们为面向对象编程的四大概念(concepts)、四大基石(cornerstones)、四大基础(fundamentals)、四大支柱(pillars)等等。

什么是面向对象分析和面向对象设计?

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

封装、抽象、继承、多态是什么?

  • 封装(Encapsulation):只暴露必要的操作或属性。

    • 封装也叫作信息隐藏或者数据访问保护。类通过暴露必要的操作,授权外部仅能通过类提供的方式(或者叫函数)来访问内部信息或者数据。
    • 需要编程语言本身提供一定访问权限控制来支持,对类中属性的访问做些限制。
  • 抽象(Abstraction):隐藏实现细节。

    • 抽象是隐藏方法非关键性的实现细节,让调用者只需要关心方法提供了哪些功能,并不需要知道这些功能是如何实现的。用函数就能实现。
    • 在命名类的方法的时候,也要有抽象思维,不要在方法定义中,暴露太多的实现细节,以保证在某个时间点需要改变方法的实现逻辑的时候,不用去修改其名字。
  • 继承(Inheritance):复用属性和方法。

    • 需要特殊的语法机制。比如 Java 使用extends 关键字来实现继承,C++ 使用冒号(class B : public A)
  • 多态(Polymorphism):使用统一的接口可以处理不同类型的对象。

什么是 UML?我们是否需要 UML?

  • UML(Unified Model Language),统一建模语言。常用它来画图表达面向对象或设计模式的设计思路。实际上,UML 是一种非常复杂的东西。它不仅仅包含常提到类图,还有用例图、顺序图、活动图、状态图、组件图等
  • UML 在互联网公司的项目开发中,用处可能并不大。为了文档化软件设计或者方便讨论软件设计,大部分情况下,我们随手画个不那么规范的草图,能够达意,方便沟通就够了,而完全按照 UML 规范来将草图标准化,所付出的代价是不值得的。

哪些代码设计看似是面向对象,实际是面向过程的?

  • 滥用 getter、setter 方法,违反了面向对象编程的封装特性,相当于将面向对象编程风格退化成了面向过程编程风格

  • 滥用全局变量和全局方法

  • 定义数据和方法分离的类

    • 传统的 MVC 结构分为 Model 层、Controller 层、View 层这三层。不过,在做前后端分离之后,三层结构在后端开发中,会稍微有些调整,被分为 Controller 层、Service 层、Repository 层。Controller 层负责暴露接口给前端调用,Service 层负责核心业务逻辑,Repository 层负责数据读写。而在每一层中,又会定义相应的 VO(View Object)、BO(Business Object)、Entity。一般情况下,VO、BO、Entity 中只会定义数据,不会定义方法,所有操作这些数据的业务逻辑都定义在对应的 Controller 类、Service 类、Repository 类中。这就是典型的面向过程的编程风格。
    • 这种开发模式叫作基于贫血模型的开发模式,也是现在非常常用的一种 Web项目的开发模式

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

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

接口vs抽象类的区别?

  • 抽象类:是一种不能被实例化的类,它只能被继承。抽象类可以包含抽象方法和非抽象方法。
  • 接口:是一种定义了一组方法、属性或事件的规范,用于描述对象应该具有的行为。
  • 接口只定义方法的签名,不提供具体实现;抽象类可以包含具体的方法实现。
  • 一个类可以实现多个接口,但只能继承一个抽象类。

抽象类和接口能解决什么编程问题?

  • 抽象类也是为代码复用而生的。使用抽象类可以定义一个类的通用行为和属性,子类通过继承抽象类来实现具体的行为。
  • 接口就更侧重于解耦。使用接口可以定义一个对象的行为规范,类通过实现接口来遵循这个规范。

为什么 “基于接口/抽象,而非实现编程”“Program to an interface, not an implementation”?

  • 从本质上来看,“接口”就是一组“协议”或者“约定”,是功能提供者提供给使用者的一个“功能列表”。“接口”在不同的应用场景下会有不同的解读,比如服务端与客户端之间的“接口”,类库提供的“接口”,甚至是一组通信的协议都可以叫作“接口”。“基于接口而非实现编程”这条原则中的“接口”,可以理解为编程语言中的接口或者抽象类。
  • 应用这条原则,可以将接口和实现相分离,封装不稳定的实现,暴露稳定的接口。上游系统面向接口而非实现编程,不依赖不稳定的实现细节,这样当实现发生变化的时候,上游系统的代码基本上不需要做改动,以此来降低耦合性,提高扩展性。
  • “基于接口而非实现编程”这条原则的另一个表述方式,是“基于抽象而非实现编程”。越抽象、越顶层、越脱离具体某一实现的设计,越能提高代码的灵活性,越能应对未来的需求变化。

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

  • 函数的命名不能暴露任何实现细节。uploadToAliyun() 改为更加抽象的命名方式如:upload()。
  • 接口的定义只表明做什么,而不是怎么做。接口设计要足够通用,是否能够做到在替换具体的接口实现的时候,不需要任何接口定义的改动。

每个类都需要接口吗?

  • 如果在业务场景中,某个功能只有一种实现方式,未来也不可能被其他实现方式替换,那就没有必要为其设计接口,也没有必要基于接口编程,直接使用实现类就可以了。
  • 除此之外,越是不稳定的系统,越是要在代码的扩展性、维护性上下功夫。相反,如果某个系统特别稳定,在开发完之后,基本上不需要做维护,那就没有必要为其扩展性,投入不必要的开发时间。

为何说要多用组合少用继承?如何决定该用组合还是继承?

  • 为什么不推荐使用继承?

    • 继承是面向对象的四大特性之一,用来表示类之间的 is-a 关系,可以解决代码复用的问题。虽然继承有诸多作用,但继承层次过深、过复杂,也会影响到代码的可维护性。
    • 这种层次很深、很复杂的继承关系,一方面,会导致代码的可读性变差。因为我们要搞清楚某个类具有哪些方法、属性,必须阅读父类的代码、父类的父类的代码……一直追溯到最顶层父类的代码。另一方面,这也破坏了类的封装特性,将父类的实现细节暴露给了子类。子类的实现依赖父类的实现,两者高度耦合,一旦父类代码修改,就会影响所有子类的逻辑。
  • 组合相比继承有哪些优势?

    • 可以利用组合(composition)、接口、委托(delegation)三个技术手段,一块儿来解决刚刚继承存在的问题
    • 通过组合和委托技术来消除代码重复
    • 继承主要有三个作用:表示 is-a 关系,支持多态特性,代码复用。而这三个作用都可以通过其他技术手段来达成。比如 is-a 关系,我们可以通过组合和接口的 has-a 关系来替代;多态特性我们可以利用接口来实现;代码复用我们可以通过组合和委托来实现。所以,从理论上讲,通过组合、接口、委托三个技术手段,我们完全可以替换掉继承,在项目中不用或者少用继承关系,特别是一些复杂的继承关系。
  • 如何判断该用组合还是继承?

    • 继承改写成组合意味着要做更细粒度的类的拆分。这也就意味着,我们要定义更多的类和接口。类和接口的增多也就或多或少地增加代码的复杂程度和维护成本。所以,在实际的项目开发中,我们还是要根据具体的情况,来具体选择该用继承还是组合。
    • 如果类之间的继承结构稳定(不会轻易改变),继承层次比较浅(比如,最多有两层继承关系),继承关系不复杂,我们就可以大胆地使用继承。反之,系统越不稳定,继承层次很深,继承关系复杂,我们就尽量使用组合来替代继承。
    • 还有一些设计模式会固定使用继承或者组合。比如,装饰者模式(decorator pattern)、策略模式(strategy pattern)、组合模式(composite pattern)等都使用了组合关系,而模板模式(template pattern)使用了继承关系
    • 利用继承特性,我们把相同的属性和方法,抽取出来,定义到父类中。子类复用父类中的属性和方法,达到代码复用的目的。但是,有的时候,从业务含义上,A 类和 B 类并不一定具有继承关系。这个时候,使用组合就更加合理、更加灵活
    • 还有一些特殊的场景要求我们必须使用继承。如果你不能改变一个函数的入参类型,而入参又非接口,为了支持多态,只能采用继承来实现。

实战:业务开发常用的基于贫血模型的MVC架构违背OOP吗?

  • 很多业务系统都是基于 MVC 三层架构来开发的。实际上,更确切点讲,这是一种基于贫血模型的 MVC 三层架构开发模式;虽然这种开发模式已经成为标准的 Web 项目的开发模式,但它却违反了面向对象编程风格,是一种彻彻底底的面向过程的编程风格,因此而被有些人称为反模式(antipattern)。特别是领域驱动设计(Domain Driven Design,简称 DDD)盛行之后,这种基于贫血模型的传统的开发模式就更加被人诟病。而基于充血模型的 DDD 开发模式越来越被人提倡。

  • 什么是基于贫血模型的传统开发模式?

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

    • 现在很多 Web 或者 App 项目都是前后端分离的,后端负责暴露接口给前端调用。这种情况下,一般就将后端项目分为 Repository 层、Service 层、Controller 层。其中,Repository 层负责数据访问,Service 层负责业务逻辑,Controller 层负责暴露接口。当然,这只是其中一种分层和命名方式。不同的项目、不同的团队,可能会对此有所调整。不过,万变不离其宗,只要是依赖数据库开发的 Web 项目,基本的分层思路都大差不差。

    • 实际上,你可能一直都在用贫血模型做开发,只是自己不知道而已。不夸张地讲,目前几乎所有的业务后端系统,都是基于贫血模型的

      • 平时开发 Web 后端项目的时候,基本上都是这么组织代码的。其中,UserEntity 和UserRepository 组成了数据访问层,UserBo 和 UserService 组成了业务逻辑层,UserVo和 UserController 在这里属于接口层。
      • UserBo 是一个纯粹的数据结构,只包含数据,不包含任何业务逻辑。业务逻辑集中在 UserService 中。我们通过 UserService 来操作 UserBo。换句话说,Service 层的数据和业务逻辑,被分割为 BO 和 Service 两个类中。像 UserBo 这样,只包含数据,不包含业务逻辑的类,就叫作贫血模型(Anemic Domain Model)。同理,UserEntity、UserVo 都是基于贫血模型设计的。这种贫血模型将数据与操作分离,破坏了面向对象的封装特性,是一种典型的面向过程的编程风格。
  • 什么是基于充血模型的 DDD 开发模式?

    • 在贫血模型中,数据和业务逻辑被分割到不同的类中。充血模型(Rich Domain Model)正好相反,数据和对应的业务逻辑被封装到同一个类中。因此,这种充血模型满足面向对象的封装特性,是典型的面向对象编程风格。

    • 什么是领域驱动设计?领域驱动设计,即 DDD,主要是用来指导如何解耦业务系统,划分业务模块,定义业务领域模型及其交互。领域驱动设计这个概念并不新颖,早在 2004 年就被提出了,到现在已经有十几年的历史了。不过,它被大众熟知,还是基于另一个概念的兴起,那就是微服务

      • 除了监控、调用链追踪、API 网关等服务治理系统的开发之外,微服务还有另外一个更加重要的工作,那就是针对公司的业务,合理地做微服务拆分。而领域驱动设计恰好就是用来指导划分服务的。所以,微服务加速了领域驱动设计的盛行
    • 实际上,基于充血模型的 DDD 开发模式实现的代码,也是按照 MVC 三层架构分层的。Controller 层还是负责暴露接口,Repository 层还是负责数据存取,Service 层负责核心业务逻辑。它跟基于贫血模型的传统开发模式的区别主要在 Service 层

      • 在基于贫血模型的传统开发模式中,Service 层包含 Service 类和 BO 类两部分,BO 是贫血模型,只包含数据,不包含具体的业务逻辑。业务逻辑集中在 Service 类中。在基于充血模型的 DDD 开发模式中,Service 层包含 Service 类和 Domain 类两部分。Domain 就相 当于贫血模型中的 BO。不过,Domain 与 BO 的区别在于它是基于充血模型开发的,既包含数据,也包含业务逻辑。而 Service 类变得非常单薄。
      • 总结一下的话就是,基于贫血模型的传统的开发模式,重 Service 轻 BO;基于充血模型的 DDD 开发模式,轻 Service 重Domain。
  • 为什么基于贫血模型的传统开发模式如此受欢迎?

    • 第一点原因是,大部分情况下,开发的系统业务可能都比较简单,简单到就是基于SQL 的 CRUD 操作,所以,根本不需要动脑子精心设计充血模型,贫血模型就足以应付这种简单业务的开发工作。除此之外,因为业务比较简单,即便使用充血模型,那模型本身包含的业务逻辑也并不会很多,设计出来的领域模型也会比较单薄,跟贫血模型差不多,没有太大意义。
    • 第二点原因是,充血模型的设计要比贫血模型更加有难度。因为充血模型是一种面向对象的编程风格。从一开始就要设计好针对数据要暴露哪些操作,定义哪些业务逻辑。而不是像贫血模型那样,我们只需要定义数据,之后有什么功能开发需求,就在 Service 层定义什么操作,不需要事先做太多设计。
    • 第三点原因是,思维已固化,转型有成本。基于贫血模型的传统开发模式经历了这么多年,已经深得人心、习以为常。随便问一个旁边的大龄同事,基本上他过往参与的所有 Web项目应该都是基于这个开发模式的,而且也没有出过啥大问题。如果转向用充血模型、领域驱动设计,那势必有一定的学习成本、转型成本。很多人在没有遇到开发痛点的情况下,是不愿意做这件事情的。
  • 什么项目应该考虑使用基于充血模型的 DDD 开发模式?

    • 基于充血模型的 DDD 开发模式,更适合业务复杂的系统开发。比如,包含各种利息计算模型、还款模型等复杂业务的金融系统

    • 除了能看到的代码层面的区别之外(一个业务逻辑放到 Service 层,一个放到领域模型中),还有一个非常重要的区别,那就是两种不同的开发模式会导致不同的开发流程。基于充血模型的 DDD 开发模式的开发流程,在应对复杂业务系统的开发的时候更加有优势。

      • 平时的开发,大部分都是 SQL 驱动(SQL-Driven)的开发模式。接到一个后端接口的开发需求的时候,就去看接口需要的数据对应到数据库中,需要哪张表或者哪几张表,然后思考如何编写 SQL 语句来获取数据。之后就是定义 Entity、BO、VO,然后模板式地往对应的 Repository、Service、Controller 类中添加代码。
      • 业务逻辑包裹在一个大的 SQL 语句中,而 Service 层可以做的事情很少。SQL 都是针对特定的业务功能编写的,复用性差。当要开发另一个业务功能的时候,只能重新写个满足新需求的 SQL 语句,这就可能导致各种长得差不多、区别很小的 SQL 语句满天飞。
      • 在这个过程中,很少有人会应用领域模型、OOP 的概念,也很少有代码复用意识。对于简单业务系统来说,这种开发方式问题不大。但对于复杂业务系统的开发来说,这样的开发方式会让代码越来越混乱,最终导致无法维护。
    • 如果在项目中,应用基于充血模型的 DDD 的开发模式,那对应的开发流程就完全不一样了。在这种开发模式下,需要事先理清楚所有的业务,定义领域模型所包含的属性和方法。领域模型相当于可复用的业务中间层。新功能需求的开发,都基于之前定义好的这些领域模型来完成。

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

实战:如何利用基于充血模型的DDD开发一个虚拟钱包系统?

  • 钱包业务背景介绍

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

    • 限定钱包暂时只支持充值、提现、支付、查询余额、查询交易流水这五个核心的功能

    • 充值

      • 通过三方支付渠道,把自己银行卡账户内的钱,充值到虚拟钱包账号中。这整个过程,可以分解为三个主要的操作流程:
      • 第一个操作是从用户的银行卡账户转账到应用的公共银行卡账户;
      • 第二个操作是将用户的充值金额加到虚拟钱包余额上;
      • 第三个操作是记录刚刚这笔交易流水。
    • 支付

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

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

      • 查询余额功能比较简单,看一下虚拟钱包中的余额数字即可
    • 查询交易流水

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

      • 可以把整个钱包系统的业务划分为两部分,其中一部分单纯跟应用内的虚拟钱包账户打交道,另一部分单纯跟银行账户打交道。基于这样一个业务划分,给系统解耦,将整个钱包系统拆分为两个子系统:虚拟钱包系统和三方支付系统。
      • 虚拟钱包系统要支持的操作非常简单,就是余额的加加减减。其中,充值、提现、查询余额三个功能,只涉及一个账户余额的加减操作,而支付功能涉及两个账户的余额加减操作:一个账户减余额,另一个账户加余额。
      • 交易流水的数据格式包含两个钱包账号,一个是入账钱包账号,一个是出账钱包账号
    • 充值、提现、支付这些业务交易类型,是否应该让虚拟钱包系统感知?是否应该在虚拟钱包系统的交易流水中记录这三种类型?

      • 虚拟钱包系统不应该感知具体的业务交易类型。我们前面讲到,虚拟钱包支持的操作,仅仅是余额的加加减减操作,不涉及复杂业务概念,职责单一、功能通用。如果耦合太多业务概念到里面,势必影响系统的通用性,而且还会导致系统越做越复杂。因此,不希望将充值、支付、提现这样的业务概念添加到虚拟钱包系统中。
      • 可以通过记录两条交易流水信息的方式来显示每条交易流水的交易类型。在钱包系统这一层额外再记录一条包含交易类型的交易流水信息,而在底层的虚拟钱包系统中记录不包含交易类型的交易流水信息。
  • 基于贫血模型的传统开发模式

    • Controller 和 VO 负责暴露接口,Service 和 BO 负责核心业务逻辑,Repository 和 Entity 负责数据存取。
  • 基于充血模型的 DDD 开发模式

    • 把虚拟钱包 VirtualWallet 类设计成一个充血的 Domain 领域模型,并且将原来在 Service 类中的部分业务逻辑移动到 VirtualWallet 类中,让 Service 类的实现依赖 VirtualWallet 类

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

    • 区别于 Domain 的职责,Service 类主要有下面这样几个职责。 1.Service 类负责与 Repository 交流

      2.Service 类负责跨领域模型的业务聚合功能

      3.Service 类负责一些非功能性及与三方系统交互的工作。

    • 基于充血模型的 DDD 开发模式跟基于贫血模型的传统开发模式相比,Controller 层和 Repository 层的代码基本上相同。这是因为,Repository 层的 Entity 生命周期有限,Controller 层的 VO 只是单纯作为一种 DTO。两部分的业务逻辑都不会太复杂。业务逻辑主要集中在 Service 层。所以,Repository 层和 Controller 层继续沿用贫血模型的设计思路是没有问题的。

实战:如何对接口鉴权这样一个功能开发做面向对象分析?

  • 案例介绍和难点剖析

    • 假设,正在参与开发一个微服务。微服务通过 HTTP 协议暴露接口给其他系统调用,说直白点就是,其他系统通过 URL 来调用微服务的接口。有一天,你的 leader 找到你说,“为了保证接口调用的安全性,我们希望设计实现一个接口调用鉴权功能,只有经过认证之后的系统才能调用我们的接口,没有认证过的系统调用我们的接口会被拒绝。”

    • 需求不明确

      • leader 给到的需求过于模糊、笼统,不够具体、细化,离落地到设计、编码还有一定的距离。而人的大脑不擅长思考这种过于抽象的问题
      • 面向对象分析主要的分析对象是“需求”,因此,面向对象分析可以粗略地看成“需求分析”。实际上,不管是需求分析还是面向对象分析,我们首先要做的都是将笼统的需求细化到足够清晰、可执行。我们需要通过沟通、挖掘、分析、假设、梳理,搞清楚具体的需求有哪些,哪些是现在要做的,哪些是未来可能要做的,哪些是不用考虑做的。
    • 缺少锻炼

      • 相比单纯的业务 CRUD 开发,鉴权这个开发任务,要更有难度。鉴权作为一个跟具体业务无关的功能,我们完全可以把它开发成一个独立的框架,集成到很多业务系统中。而作为被很多系统复用的通用框架,比起普通的业务代码,我们对框架的代码质量要求要更高。
      • 开发这样通用的框架,对工程师的需求分析能力、设计能力、编码能力,甚至逻辑思维能力的要求,都是比较高的
  • 对案例进行需求分析

    • 尽管针对框架、组件、类库等非业务系统的开发,我们一定要有组件化意识、框架意识、抽象意识,开发出来的东西要足够通用,不能局限于单一的某个业务需求,但这并不代表我们就可以脱离具体的应用场景,闷头拍脑袋做需求分析。多跟业务团队聊聊天,甚至自己去参与几个业务系统的开发,只有这样,我们才能真正知道业务系统的痛点,才能分析出最有价值的需求。

    • 第一轮基础分析

      • 对于如何做鉴权这样一个问题,最简单的解决方案就是,通过用户名加密码来做认证。我们给每个允许访问我们服务的调用方,派发一个应用名(或者叫应用 ID、AppID)和一个对应的密码(或者叫秘钥)。调用方每次进行接口请求的时候,都携带自己的 AppID 和密码。微服务在接收到接口调用请求之后,会解析出 AppID 和密码,跟存储在微服务端的AppID 和密码进行比对。如果一致,说明认证成功,则允许接口调用请求;否则,就拒绝接口调用请求
    • 第二轮分析优化

      • 这样的验证方式,每次都要明文传输密码。密码很容易被截获,是不安全的。那如果我们借助加密算法(比如 SHA),对密码进行加密之后,再传递到微服务端验证,是不是就可以了呢?实际上,这样也是不安全的,因为加密之后的密码及 AppID,照样可以被未认证系统(或者说黑客)截获,未认证系统可以携带这个加密之后的密码以及对应的AppID,伪装成已认证系统来访问我们的接口。这就是典型的“ 重放攻击”。
      • 提出问题,然后再解决问题,是一个非常好的迭代优化方法。对于刚刚这个问题,我们可以借助 OAuth 的验证思路来解决。调用方将请求接口的 URL 跟 AppID、密码拼接在一起,然后进行加密,生成一个 token。调用方在进行接口请求的的时候,将这个 token 及AppID,随 URL 一块传递给微服务端。微服务端接收到这些数据之后,根据 AppID 从数据库中取出对应的密码,并通过同样的 token 生成算法,生成另外一个 token。用这个新生成的 token 跟调用方传递过来的 token 对比。如果一致,则允许接口调用请求;否则,就拒绝接口调用请求
    • 第三轮分析优化

      • 这样的设计仍然存在重放攻击的风险,还是不够安全。每个 URL 拼接上 AppID、密码生成的 token 都是固定的。未认证系统截获 URL、token 和 AppID 之后,还是可以通过重放攻击的方式,伪装成认证系统,调用这个 URL 对应的接口。
      • 为了解决这个问题,可以进一步优化 token 生成算法,引入一个随机变量,让每次接口请求生成的 token 都不一样。我们可以选择时间戳作为随机变量。原来的 token 是对URL、AppID、密码三者进行加密生成的,现在我们将 URL、AppID、密码、时间戳四者进行加密来生成 token。调用方在进行接口请求的时候,将 token、AppID、时间戳,随URL 一并传递给微服务端。
      • 微服务端在收到这些数据之后,会验证当前时间戳跟传递过来的时间戳,是否在一定的时间窗口内(比如一分钟)。如果超过一分钟,则判定 token 过期,拒绝接口请求。如果没有超过一分钟,则说明 token 没有过期,就再通过同样的 token 生成算法,在服务端生成新的 token,与调用方传递过来的 token 比对,看是否一致。如果一致,则允许接口调用请求;否则,就拒绝接口调用请求。
    • 第四轮分析优化

      • 这样还是不够安全。未认证系统还是可以在这一分钟的 token 失效窗口内,通过截获请求、重放请求,来调用接口
      • 攻与防之间,本来就没有绝对的安全。我们能做的就是,尽量提高攻击的成本。这个方案虽然还有漏洞,但是实现起来足够简单,而且不会过度影响接口本身的性能(比如响应时间)。所以,权衡安全性、开发成本、对系统性能的影响,这个方案算是比较折中、比较合理的了。
      • 还有一个细节没有考虑到,那就是如何在微服务端存储每个授权调用方的AppID 和密码。当然,这个问题并不难。最容易想到的方案就是存储到数据库里,比如MySQL。不过,开发像鉴权这样的非业务功能,最好不要与具体的第三方系统有过度的耦合。
      • 针对 AppID 和密码的存储,我们最好能灵活地支持各种不同的存储方式,比如ZooKeeper、本地配置文件、自研配置中心、MySQL、Redis 等。我们不一定针对每种存储方式都去做代码实现,但起码要留有扩展点,保证系统有足够的灵活性和扩展性,能够在我们切换存储方式的时候,尽可能地减少代码的改动。
    • 最终确定需求

      • 调用方进行接口请求的时候,将 URL、AppID、密码、时间戳拼接在一起,通过加密算法生成 token,并且将 token、AppID、时间戳拼接在 URL 中,一并发送到微服务端。
      • 微服务端在接收到调用方的接口请求之后,从请求中拆解出 token、AppID、时间戳。
      • 微服务端首先检查传递过来的时间戳跟当前时间,是否在 token 失效时间窗口内。如果已经超过失效时间,那就算接口调用鉴权失败,拒绝接口调用请求。
      • 如果 token 验证没有过期失效,微服务端再从自己的存储中,取出 AppID 对应的密码,通过同样的 token 生成算法,生成另外一个 token,与调用方传递过来的 token 进行匹配;如果一致,则鉴权成功,允许接口调用,否则就拒绝接口调用。

实战:如何利用面向对象设计和编程开发接口鉴权功能?

  • 如何进行面向对象设计?

    • 面向对象分析的产出是详细的需求描述,那面向对象设计的产出就是类。在面向对象设计环节,我们将需求描述转化为具体的类的设计。我们把这一设计环节拆解细化一下,主要包含以下几个部分:

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

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

      • 功能点列表:

        1. 把 URL、AppID、密码、时间戳拼接为一个字符串;
        2. 对字符串通过加密算法加密生成 token;
        3. 将 token、AppID、时间戳拼接到 URL 中,形成新的 URL;
        4. 解析 URL,得到 token、AppID、时间戳等信息;
        5. 从存储中取出 AppID 和对应的密码;
        6. 根据时间戳判断 token 是否过期失效;
        7. 验证两个 token 是否匹配;
      • 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 这个操作。

    • 定义类及其属性和方法;

      • 识别出需求描述中的动词,作为候选的方法,再进一步过滤筛选。类比一下方法的识别,我们可以把功能点中涉及的名词,作为候选属性,然后同样进行过滤筛选。
    • 定义类与类之间的交互关系;

      • UML 统一建模语言中定义了六种类之间的关系。它们分别是:泛化、实现、关联、聚合、组合、依赖
      • 泛化(Generalization)可以简单理解为继承关系
      • 实现(Realization)一般是指接口和实现类之间的关系
      • 聚合(Aggregation)是一种包含关系,A 类对象包含 B 类对象,B 类对象的生命周期可以不依赖 A 类对象的生命周期,也就是说可以单独销毁 A 类对象而不影响 B 对象,比如课程与学生之间的关系
      • 组合(Composition)也是一种包含关系。A 类对象包含 B 类对象,B 类对象的生命周期跟依赖 A 类对象的生命周期,B 类对象不可单独存在,比如鸟与翅膀之间的关系。
      • 关联(Association)是一种非常弱的关系,包含聚合、组合两种关系。如果 B 类对象是 A 类的成员变量,那 B 类和 A 类就是关联关系
      • 依赖(Dependency)是一种比关联关系更加弱的关系,包含关联关系。不管是 B 类对象是 A 类对象的成员变量,还是 A 类的方法使用 B 类对象作为参数或者返回值、局部变量,只要 B 类对象和 A 类对象有任何使用关系,我们都称它们有依赖关系。
      • 贴近编程的角度,对类与类之间的关系做了调整,只保留了四个关系:泛化、实现、组合、依赖;泛化、实现、依赖的定义不变,组合关系替代 UML 中组合、聚合、关联三个概念,也就相当于重新命名关联关系为组合关系,并且不再区分 UML 中的组合和聚合两个概念
    • 将类组装起来并提供执行入口。

      • 这个入口可能是一个 main() 函数,也可能是一组给外部用的 API 接口。通过这个入口,我们能触发整个代码跑起来。
      • 接口鉴权并不是一个独立运行的系统,而是一个集成在系统上运行的组件,所以,我们封装所有的实现细节,设计了一个最顶层的 ApiAuthencator 接口类,暴露一组给外部调用者使用的 API 接口,作为触发执行鉴权逻辑的入口。
  • 如何进行面向对象编程?

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

设计原则?SOLID 原则

1 单一职责原则

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

  • Single Responsibility Principle:A class or module should have a single reponsibility
  • 一个类或者模块只负责完成一个职责(或者功能)。不要设计大而全的类,要设计粒度小、功能单一的类。

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

  • 可以先写一个粗粒度的类,满足业务需求。随着业务的发展,如果粗粒度的类越来越庞大,代码越来越多,这个时候,我们就可以将这个粗粒度的类,拆分成几个更细粒度的类。这就是所谓的持续重构

  • 下面这几条判断原则,比起很主观地去思考类是否职责单一,要更有指导意义、更具有可执行性:

    • 类中的代码行数、函数或属性过多,会影响代码的可读性和可维护性,我们就需要考虑对类进行拆分;
    • 类依赖的其他类过多,或者依赖类的其他类过多,不符合高内聚、低耦合的设计思想,我们就需要考虑对类进行拆分;
    • 私有方法过多,我们就要考虑能否将私有方法独立到新的类中,设置为 public 方法,供更多的类使用,从而提高代码的复用性;
    • 比较难给类起一个合适名字,很难用一个业务名词概括,或者只能用一些笼统的Manager、Context 之类的词语来命名,这就说明类的职责定义得可能不够清晰;
    • 类中大量的方法都是集中操作类中的某几个属性,比如,在 UserInfo 例子中,如果一半的方法都是在操作 address 信息,那就可以考虑将这几个属性和对应的方法拆分出来。

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

如果拆分得过细,实际上会适得其反,反倒会降低内聚性,也会影响代码的可维护性。

2 对扩展开放、修改关闭

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

  • Open Closed Principle:software entities (modules, classes, functions, etc.) should be open for extension , but closed for modification。
  • 软件实体(模块、类、方法等)应该“对扩展开放、对修改关闭”。
  • 添加一个新的功能应该是,在已有代码基础上扩展代码(新增模块、类、方法等),而非修改已有代码(修改模块、类、方法等)。
  • 开闭原则并不是说完全杜绝修改,而是以最小的修改代码的代价来完成新功能的开发。
  • 同样的代码改动,在粗代码粒度下,可能被认定为“修改”;在细代码粒度下,可能又被认定为“扩展”

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

  • 要时刻具备扩展意识、抽象意识、封装意识。在写代码的时候,我们要多花点时间思考一下,这段代码未来可能有哪些需求变更,如何设计代码结构,事先留好扩展点,以便在未 来需求变更的时候,在不改动代码整体结构、做到最小代码改动的情况下,将新的代码灵活地插入到扩展点上。
  • 在识别出代码可变部分和不可变部分之后,要将可变部分封装起来,隔离变化,提供抽象化的不可变接口,给上层系统使用。当具体的实现发生变化的时候,只需要基于相同的抽象接口,扩展一个新的实现,替换掉老的实现即可,上游系统的代码几乎不需要修改。
  • 很多设计原则、设计思想、设计模式,都是以提高代码的扩展性为最终目的的。特别是 23种经典设计模式,大部分都是为了解决代码的扩展性问题而总结出来的,都是以开闭原则为指导原则的。最常用来提高代码扩展性的方法有:多态、依赖注入、基于接口而非实现编程,以及大部分的设计模式(比如,装饰、策略、模板、职责链、状态)。

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

  • 如果你开发的是一个业务导向的系统,比如金融系统、电商系统、物流系统等,要想识别出尽可能多的扩展点,就要对业务有足够的了解,能够知道当下以及未来可能要支持的业务需求。
  • 如果你开发的是跟业务无关的、通用的、偏底层的系统,比如,框架、组件、类库,你需要了解“它们会被如何使用?今后你打算添加哪些功能?使用者未来会有哪些更多的功能需求?”等问题。
  • 最合理的做法是,对于一些比较确定的、短期内可能就会扩展,或者需求改动对代码结构影响比较大的情况,或者实现成本不高的扩展点,在编写代码的时候之后,我们就可以事先做些扩展性设计。但对于一些不确定未来是否要支持的需求,或者实现起来比较复杂的扩展点,我们可以等到有需求驱动的时候,再通过重构代码的方式来支持扩展的需求
  • 开闭原则也并不是免费的。有些情况下,代码的扩展性会跟可读性相冲突。比如,我们之前举的 Alert 告警的例子。为了更好地支持扩展性,我们对代码进行了重构,重构之后的代码要比之前的代码复杂很多,理解起来也更加有难度。很多时候,我们都需要在扩展性和可读性之间做权衡

3 里式替换(LSP)

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

  • Liskov Substitution Principle:Functions that use pointers of references to base classes must be able to use objects of derived classes without knowing it
  • 子类对象(object of subtype/derived class)能够替换程序(program)中父类对象(object of base/parent class)出现的任何地方,并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏。
  • 虽然从定义描述和代码实现上来看,多态和里式替换有点类似,但它们关注的角度是不一样的。多态是面向对象编程的一大特性,也是面向对象编程语言的一种语法。它是一种代码实现的思路。而里式替换是一种设计原则,是用来指导继承关系中子类该如何设计的,子类的设计要保证在替换父类的时候,不改变原有程序的逻辑以及不破坏原有程序的正确性。

哪些代码明显违背了 LSP?

  • 里式替换原则还有另外一个更加能落地、更有指导意义的描述,那就是“Design By Contract”,中文翻译就是“按照协议来设计”。
  • 子类在设计的时候,要遵守父类的行为约定(或者叫协议)。父类定义了函数的行为约定,那子类可以改变函数的内部实现逻辑,但不能改变函数原有的行为约定。这里的行为约定包括:函数声明要实现的功能;对输入、输出、异常的约定;甚至包括注释中所罗列的任何特殊说明。实际上,定义中父类和子类之间的关系,也可以替换成接口和实现类之间的关系。

4 接口隔离原则

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

  • “ Interface Segregation Principle”,缩写为 ISP;SOLID 原则中是这样定义它的:“Clients should not be forced to depend upon interfaces that they do not use。”即:客户端不应该强迫依赖它不需要的接口。其中的“客户端”,可以理解为接口的调用者或者使用者。

  • 实际上,“接口”这个名词可以用在很多场合中。生活中我们可以用它来指插座接口等。在软件开发中,我们既可以把它看作一组抽象的约定,也可以具体指系统与系统之间的 API接口,还可以特指面向对象编程语言中的接口等

  • 理解接口隔离原则的关键,就是理解其中的“接口”二字。在这条原则中,可以把“接口”理解为下面三种东西:

    • 一组 API 接口集合
    • 单个 API 接口或函数
    • OOP 中的接口概念
  • 把“接口”理解为一组 API 接口集合

    • 比如后台管理系统要实现删除用户的功能,希望用户系统提供一个删除用户的接口,现有一个UserService 系统具有注册、登录、获取用户信息等
    • 删除用户是一个非常慎重的操作,只希望通过后台管理系统来执行,所以这个接口只限于给后台管理系统使用。如果把它放到 UserService 中,那所有使用到 UserService 的系统,都可以调用这个接口。不加限制地被其他业务系统调用,就有可能导致误删用户
    • 最好的解决方案是从架构设计的层面,通过接口鉴权的方式来限制接口的调用。不过,如果暂时没有鉴权框架来支持,还可以从代码设计的层面,尽量避免接口被误用。参照接口隔离原则,调用者不应该强迫依赖它不需要的接口,将删除接口单独放到另外一个接口 RestrictedUserService 中,然后将 RestrictedUserService 只打包提供给后台管理系统来使用
    • 把接口隔离原则中的接口,理解为一组接口集合,它可以是某个微服务的接口,也可以是某个类库的接口等等。在设计微服务或者类库接口的时候,如果部分接口只被部分调用者使用,那我们就需要将这部分接口隔离出来,单独给对应的调用者使用,而不是强迫其他调用者也依赖这部分不会被用到的接口
  • 把“接口”理解为单个 API 接口或函数

    • 函数的设计要功能单一,不要将多个不同的功能逻辑在一个函数中实现,需要把函数拆分成粒度更细的多个函数,让调用者只依赖它需要的那个细粒度函数
    • 接口隔离原则跟单一职责原则有点类似,不过稍微还是有点区别。单一职责原则针对的是模块、类、接口的设计。而接口隔离原则相对于单一职责原则,一方面它更侧重于接口的设计,另一方面它的思考的角度不同。它提供了一种判断接口是否职责单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。
  • 把“接口”理解为 OOP 中的接口概念

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

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

      • Updater、Viewer 职责更加单一,单一就意味了通用、复用性好。比如,现在又有一个新的需求,开发一个 Metrics性能统计模块,并且希望将 Metrics 也通过 SimpleHttpServer 显示在网页上,以方便查看。这个时候,尽管 Metrics 跟 RedisConfig 等没有任何关系,但我们仍然可以让Metrics 类实现非常通用的 Viewer 接口,复用 SimpleHttpServer 的代码实现

5 控制反转、依赖反转、依赖注入

控制反转(IOC)

  • 控制反转的英文翻译是Inversion Of Control,缩写为 IOC
  • 框架提供了一个可扩展的代码骨架,用来组装对象、管理整个执行流程。程序员利用框架进行开发的时候,只需要往预留的扩展点上,添加跟自己业务相关的代码,就可以利用框架来驱动整个程序流程的执行。
  • 这里的“控制”指的是对程序执行流程的控制,而“反转”指的是在没有使用框架之前,程序员自己控制整个程序的执行。在使用框架之后,整个程序的执行流程可以通过框架来控制。流程的控制权从程序员“反转”到了框架

依赖注入(DI)

  • 依赖注入跟控制反转恰恰相反,它是一种具体的编码技巧。依赖注入的英文翻译是 Dependency Injection,缩写为 DI。
  • 用一句话来概括就是:不通过 new() 的方式在类内部创建依赖类对象,而是将依赖的类对象在外部创建好之后,通过构造函数、函数参数等方式传递(或注入)给类使用
  • 通过依赖注入的方式来将依赖的类对象传递进来,这样就提高了代码的扩展性,可以灵活地替换依赖的类

依赖注入框架(DI Framework)

  • 在采用依赖注入实现的 中,虽然我们不需要用类似 hard code 的方式,在类内部通过 new 来创建对象,但是,这个创建对象、组装(或注入)对象的工作仅仅是被移动到了更上层代码而已,还是需要程序员自己来实现
  • 只需要通过依赖注入框架提供的扩展点,简单配置一下所有需要创建的类对象、类与类之间的依赖关系,就可以实现由框架来自动创建对象、管理对象的生命周期、依赖注入等原本需要程序员来做的事情

依赖反转原则(DIP)

  • 依赖反转原则的英文翻译是 Dependency Inversion Principle,缩写为 DIP,
  • High-level modules shouldn’t depend on low-level modules. Both modules should depend on abstractions. In addition, abstractions shouldn’t depend on details. Details depend on abstractions.
  • 高层模块(high-level modules)不要依赖低层模块(low-level)。高层模块和低层模块应该通过抽象abstractions)来互相依赖。除此之外,抽象(abstractions)不要依赖具体实现细节(details),具体实现细节(details)依赖抽象(abstractions)。
  • 所谓高层模块和低层模块的划分,简单来说就是,在调用链上,调用者属于高层,被调用者属于低层。在平时的业务代码开发中,高层模块依赖底层模块是没有任何问题的。实际上,这条原则主要还是用来指导框架层面的设计

6 KISS、YAGNI原则看似简单

如何理解“KISS 原则”?

  • Keep It Simple and Stupid.尽量保持简单。KISS 原则算是一个万金油类型的设计原则,可以应用在很多场景中。
  • 代码的可读性和可维护性是衡量代码质量非常重要的两个标准。而 KISS 原则就是保持代码可读和可维护的重要手段。代码足够简单,也就意味着很容易读懂,bug 比较难隐藏。即便出现 bug,修复起来也比较简单
  • KISS 原则中的“简单”并不是以代码行数来考量的。代码行数越少并不代表代码越简单,还要考虑逻辑复杂度、实现难度、代码的可读性等。
  • 本身就复杂的问题,用复杂的方法解决,并不违背 KISS 原则

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

  • 不要使用同事可能不懂的技术来实现代码。比如正则表达式,还有一些编程语言中过于高级的语法等。
  • 不要重复造轮子,要善于使用已经有的工具类库。经验证明,自己去实现这些类库,出bug 的概率会更高,维护的成本也比较高。
  • 不要过度优化。不要过度使用一些奇技淫巧(比如,位运算代替算术运算、复杂的条件语句代替 if-else、使用一些过于底层的函数等)来优化代码,牺牲代码的可读性。
  • 代码是否足够简单是一个挺主观的评判。同样的代码,有的人觉得简单,有的人觉得不够简单。而往往自己编写的代码,自己都会觉得够简单。所以,评判代码是否简单,还有一个很有效的间接方法,那就是 code review。

YAGNI 跟 KISS 说的是一回事吗?

  • YAGNI 原则的英文全称是:You Ain’t Gonna Need It。直译就是:你不会需要它。这条原则也算是万金油了。当用在软件开发中的时候,它的意思是:不要去设计当前用不到的功能;不要去编写当前用不到的代码。实际上,这条原则的核心思想就是:不要做过度设计
  • YAGNI 原则跟 KISS 原则并非一回事儿。KISS 原则讲的 是“如何做”的问题(尽量保持简单),而 YAGNI 原则说的是“要不要做”的问题(当前不需要的就不要做)。

7 重复的代码就一定违背DRY吗?

DRY 原则。Don’t Repeat Yourself。即不要重复自己。将它应用在编程中,可以理解为:不要写重复的代码。

三种典型的代码重复情况

  • 实现逻辑重复

    • isValidUserName() 和 isValidPassword() 两个函数,虽然从代码实现逻辑上看起来是重复的,但是从语义上并不重复。所谓“语义不重复”指的是:从功能上来看,这两个函数干的是完全不重复的两件事情,一个是校验用户名,另一个是校验密码。尽管在目前的设计中,两个校验逻辑是完全一样的,但如果将两个函数的合并,那就会存在潜在的问题。在未来的某一天,如果我们修改了密码的校验逻辑,比如,允许密码包含大写字符,允许密码的长度为 8 到 64 个字符,那这个时候,isValidUserName() 和isValidPassword() 的实现逻辑就会不相同。我们就要把合并后的函数,重新拆成合并前的那两个函数。
    • 尽管代码的实现逻辑是相同的,但语义不同,判定它并不违反 DRY 原则。对于包含重复代码的问题,可以通过抽象成更细粒度函数的方式来解决
  • 功能语义重复

    • 在同一个项目代码中有下面两个函数:isValidIp() 和checkIfIpValid()。尽管两个函数的命名不同,实现逻辑不同,但功能是相同的,都是用来判定 IP 地址是否合法的。
    • 尽管两段代码的实现逻辑不重复,但语义重复,也就是功能重复,认为它违反了 DRY 原则。应该在项目中,统一一种实现思路,所有用到判断 IP 地址是否合法的地方,都统一调用同一个函数。
  • 代码执行重复

    • 代码中存在“执行重复”违反了 DRY 原则

怎么提高代码复用性?

  • 减少代码耦合

  • 满足单一职责原则

  • 模块化

  • 业务与非业务逻辑分离

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

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

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

    • 一些设计模式,也能提高代码的复用性。比如,模板模式利用了多态来实现,可以灵活地替换其中的部分代码,整个流程模板代码可复用
    • 还有一些跟编程语言相关的特性,也能提高代码的复用性,比如泛型编程等。
    • 复用意识也非常重要。在写代码的时候,我们要多去思考一下,这个部分代码是否可以抽取出来,作为一个独立的模块、类或者函数供多处使用。在设计每个模块、类、函数的时候,要像设计一个外部 API 那样,去思考它的复用性。

除非有非常明确的复用需求,否则,为了暂时用不到的复用需求,花费太多的时间、精力,投入太多的开发成本,并不是一个值得推荐的做法。这也违反之前讲到的YAGNI 原则。

  • 除此之外,有一个著名的原则,叫作“Rule of Three”
  • 我们在第一次写代码的时候,如果当下没有复用的需求,而未来的复用需求也不是特别明确,并且开发可复用代码的成本比较高,那我们就不需要考虑代码的复用性。在之后我们开发新的功能的时候,发现可以复用之前写的这段代码,那我们就重构这段代码,让其变得更加可复用。

8 用迪米特法则(LOD)实现“高内聚、松耦合”

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

    • “高内聚、松耦合”是一个非常重要的设计思想,能够有效地提高代码的可读性和可维护性,缩小功能改动导致的代码改动范围。很多设计原则都以实现代码的“高内聚、松耦合”为目的,比如单一职责原则、基于接口而非实现编程等。

    • “高内聚、松耦合”是一个比较通用的设计思想,可以用来指导不同粒度代码的设计与开发,比如系统、模块、类,甚至是函数,也可以应用到不同的开发场景中,比如微服务、框架、组件、类库等。

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

    • 所谓高内聚,就是指相近的功能应该放到同一个类中,不相近的功能不要放到同一个类中。相近的功能往往会被同时修改,放到同一个类中,修改会比较集中,代码容易维护。

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

    • “内聚”和“耦合”之间的关系:“高内聚”有助于“松耦合”,同理,“低内聚”也会导致“紧耦合”

      • 类的粒度比较小,每个类的职责都比较单一。相近的功能都放到了一个类中,不相近的功能被分割到了多个类中。这样类更加独立,代码的内聚性更好。因为职责单一,所以每个类被依赖的类就会比较少,代码低耦合。一个类的修改,只会影响到一个依赖类的代码改动。我们只需要测试这一个依赖类是否还能正常工作就行了。
      • 类粒度比较大,低内聚,功能大而全,不相近的功能放到了一个类中。这就导致很多其他类都依赖这个类。当我们修改这个类的某一个功能代码的时候,会影响依赖它的多个类。“牵一发而动全身”
  • “迪米特法则”理论描述

    • 迪米特法则:Law of Demeter,缩写是 LOD。也即最小知识原则:The Least Knowledge Principle
    • Each unit should have only limited knowledge about other units: only units “closely” related to the current unit. Or: Each unit should only talk to its friends; Don’t talk to strangers.
    • 每个模块(unit)只应该了解那些与它关系密切的模块(units: only units “closely” related to the current unit)的有限知识(knowledge)。或者说,每个模块只和自己的朋友“说话”(talk),不和陌生人“说话”(talk)
    • 不该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口(也就是定义中的“有限知识”)。
    • 迪米特法则是希望减少类之间的耦合,让类越独立越好。每个类都应该少了解系统的其他部分。一旦发生变化,需要了解这一变化的类就会比较少。
  • 拆分之后的代码更能满足迪米特法则,但却违背了高内聚的设计思想。高内聚要求相近的功能要放到同一个类中,这样可以方便功能修改的时候,修改的地方不至于过于分散

    • 既不想违背高内聚的设计思想,也不想违背迪米特法则,使用“基于接口而非实现编程”的设计原则

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

需求分析
  • 通过产品的线框图、用户用例(user case )或者叫用户故事(user story)来细化业务流程,挖掘一些比较细节的、不容易想到的功能点。
系统设计
  • 合理地将功能划分到不同模块
设计模块与模块之间的交互关系
  • 类比到系统设计,系统职责划分好之后,接下来就是设计系统之间的交互,也就是确定有哪些系统跟积分系统之间有交互以及如何进行交互。
  • 比较常见的系统之间的交互方式有两种,一种是同步接口调用,另一种是利用消息中间件异步调用。第一种方式简单直接,第二种方式的解耦效果更好。
  • 比如,用户下订单成功之后,订单系统推送一条消息到消息中间件,营销系统订阅订单成功消息,触发执行相应的积分兑换逻辑。这样订单系统就跟营销系统完全解耦,订单系统不需要知道任何跟积分相关的逻辑,而营销系统也不需要直接跟订单系统交互。
  • 除此之外,上下层系统之间的调用倾向于通过同步接口,同层之间的调用倾向于异步消息调用。比如,营销系统和积分系统是上下层关系,它们之间就比较推荐使用同步接口调用
设计模块的接口、数据库、业务模型
  • 数据库和接口的设计非常重要,一旦设计好并投入使用之后,这两部分都不能轻易改动。改动数据库表结构,需要涉及数据的迁移和适配;改动接口,需要推动接口的使用者作相应的代码修改。这两种情况,即便是微小的改动,执行起来都会非常麻烦。

  • 业务逻辑代码侧重内部实现,不涉及被外部依赖的接口,也不包含持久化的数据,所以对改动的容忍性更大。

  • 针对积分系统,如何设计数据库

    • 只需要一张记录积分流水明细的表就可以了。表中记录积分的赚取和消费流水。用户积分的各种统计数据,比如总积分、总可用积分等,都可以通过这张表来计算得到。
  • 如何设计积分系统的接口

    • 接口设计要符合单一职责原则,粒度越小通用性就越好。但是,接口粒度太小也会带来一些问题,影响性能,涉及分布式事务的数据一致性问题。所以,为了兼顾易用性和性能,可以借鉴facade(外观)设计模式,在职责单一的细粒度接口之上,再封装一层粗粒度的接口给外部使用。
  • 业务模型的设计

    • Controller 层负责接口暴露,Repository 层负责数据读写,Service 层负责核心业务逻辑,也就是这里说的业务模型。
  • 为什么要分 MVC 三层开发?

    • 分层能起到代码复用的作用

      • 同一个 Repository 可能会被多个 Service 来调用,同一个 Service 可能会被多个Controller 调用。
    • 分层能起到隔离变化的作用

      • 分层体现了一种抽象和封装的设计思想。比如,Repository 层封装了对数据库访问的操作,提供了抽象的数据访问接口。基于接口而非实现编程的设计思想,Service 层使用Repository 层提供的接口,并不关心其底层依赖的是哪种具体的数据库。
      • Controller、Service、Repository 三层代码的稳定程度不同、引起变化的原因不同,所以分成三层来组织代码,能有效地隔离变化。
    • 分层能起到隔离关注点的作用

      • Repository 层只关注数据的读写。Service 层只关注业务逻辑,不关注数据的来源。Controller 层只关注与外界打交道,数据校验、封装、格式转换,并不关心业务逻辑
    • 分层能提高代码的可测试性

    • 分层能应对系统的复杂性

      • 所有的代码都放到一个类中,那这个类的代码就会因为需求的迭代而无限膨胀。当一个类或一个函数的代码过多之后,可读性、可维护性就会变差。
      • 拆分有垂直和水平两个方向。水平方向基于业务来做拆分,就是模块化;垂直方向基于流程来做拆分,就是这里说的分层。
      • 不管是分层、模块化,还是 OOP、DDD,以及各种设计模式、原则和思想,都是为了应对复杂系统,应对系统的复杂性
  • BO、VO、Entity 存在的意义是什么?

    • 针对 Controller、Service、Repository 三层,每层都会定义相应的数据对象,它们分别是 VO(View Object)、BO(Business Object)、Entity,例如 UserVo、UserBo、UserEntity。在实际的开发中,VO、BO、Entity 可能存在大量的重复字段,甚至三者包含的字段完全一样。在开发的过程中,经常需要重复定义三个几乎一样的类,显然是一种重复劳动。
    • VO、BO、Entity 并非完全一样。比如,可以在 UserEntity、UserBo 中定义Password 字段,但显然不能在 UserVo 中定义 Password 字段,否则就会将用户的密码暴露出去。
    • VO、BO、Entity 三个类虽然代码重复,但功能语义不重复,从职责上讲是不一样的。所以,也并不能算违背 DRY 原则。在前面讲到 DRY 原则的时候,针对这种情况,如果合并为同一个类,那也会存在后期因为需求的变化而需要再拆分的问题。
    • 为了尽量减少每层之间的耦合,把职责边界划分明确,每层都会维护自己的数据对象,层与层之间通过接口交互。数据从下一层传递到上一层的时候,将下一层的数据对象转化成上一层的数据对象,再继续处理。虽然这样的设计稍微有些繁琐,每层都需要定义各自的数据对象,需要做数据对象之间的转化,但是分层清晰。对于非常大的项目来说,结构清晰是第一位的!
  • 既然 VO、BO、Entity 不能合并,那如何解决代码重复的问题呢?

    • 继承可以解决代码重复问题。我们可以将公共的字段定义在父类中,让VO、BO、Entity 都继承这个父类,各自只定义特有的字段。因为这里的继承层次很浅,也不复杂,所以使用继承并不会影响代码的可读性和可维护性。后期如果因为业务的需要,有些字段需要从父类移动到子类,或者从子类提取到父类,代码改起来也并不复杂
    • 组合也可以解决代码重复的问题,所以还可以将公共的字段抽取到公共的类中,VO、BO、Entity 通过组合关系来复用这个类的代码。
  • 代码重复问题解决了,那不同分层之间的数据对象该如何互相转化呢?

    • 当下一层的数据通过接口调用传递到上一层之后,我们需要将它转化成上一层对应的数据对象类型。比如,Service 层从 Repository 层获取的 Entity 之后,将其转化成 BO,再继续业务逻辑的处理。所以,整个开发的过程会涉及“Entity 到 BO”和“BO 到 VO”这两种转化。

    • 最简单的转化方式是手动复制。自己写代码在两个对象之间,一个字段一个字段的赋值。但这样的做法显然是没有技术含量的低级劳动。借鉴 Java 这些工具类的设计思路,自己在项目中实现对象转化工具类。

    • VO、BO、Entity 都是基于贫血模型的,而且为了兼容框架或开发库(比如 MyBatis、Dozer、BeanUtils),我们还需要定义每个字段的 set 方法。这些都违背 OOP 的封装特性,会导致数据被随意修改。那到底该怎么办好呢?

      • Entity 和 VO 的生命周期是有限的,都仅限在本层范围内。而对应的Repository 层和 Controller 层也都不包含太多业务逻辑,所以也不会有太多代码随意修改数据,即便设计成贫血、定义每个字段的 set 方法,相对来说也是安全的。
      • Service 层包含比较多的业务逻辑代码,所以 BO 就存在被任意修改的风险了。但是,设计的问题本身就没有最优解,只有权衡。为了使用方便,我们只能做一些妥协,放弃BO 的封装特性,由程序员自己来负责这些数据对象的不被错误使用。

实战二(上):针对非业务的通用框架开发,如何做需求分析和设计?

  • 项目背景

    • 希望设计开发一个小的框架,能够获取接口调用的各种统计信息,比如,响应时间的最大值(max)、最小值(min)、平均值(avg)、百分位值(percentile)、接口调用次数(count)、频率(tps) 等,并且支持将统计结果以各种显示格式(比如:JSON 格式、网页格式、自定义显示格式等)输出到各种终端(Console 命令行、HTTP 网页、Email、日志文件、自定义输出终端等),以方便查看
  • 需求分析

    • 功能性需求分析

      • 接口统计信息:包括接口响应时间的统计信息,以及接口调用次数的统计信息等。

      • 统计信息的类型:max、min、avg、percentile、count、tps 等。

      • 统计信息显示格式:Json、Html、自定义显示格式。

      • 统计信息显示终端:Console、Email、HTTP 网页、日志、自定义显示终端

      • 还可以借助设计产品的时候,经常用到的线框图,把最终数据的显示样式画出来,会更加一目了然;从线框图中,还能挖掘出了下面几个隐藏的需求

        • 统计触发方式:包括主动和被动两种。主动表示以一定的频率定时统计数据,并主动推送到显示终端,比如邮件推送。被动表示用户触发统计,比如用户在网页中选择要统计的时间区间,触发统计,并将结果显示给用户。
        • 统计时间区间:框架需要支持自定义统计时间区间,比如统计最近 10 分钟的某接口的tps、访问次数,或者统计 12 月 11 日 00 点到 12 月 12 日 00 点之间某接口响应时间的最大值、最小值、平均值等。
        • 统计时间间隔:对于主动触发统计,我们还要支持指定统计时间间隔,也就是多久触发一次统计显示。比如,每间隔 10s 统计一次接口信息并显示到命令行中,每间隔 24 小时发送一封统计信息邮件。
    • 非功能性需求分析

      • 易用性:易用性听起来更像是一个评判产品的标准。在开发这样一个技术框架的时候,也要有产品意识。框架是否易集成、易插拔、跟业务代码是否松耦合、提供的接口是否够灵活等等,都是应该花心思去思考和设计的。有的时候,文档写得好坏甚至都有可能决定一个框架是否受欢迎
      • 性能:对于需要集成到业务系统的框架来说,不希望框架本身的代码执行效率,对业务系统有太多性能上的影响。对于性能计数器这个框架来说,一方面,希望它是低延迟的,也就是说,统计代码不影响或很少影响接口本身的响应时间;另一方面,希望框架本身对内存的消耗不能太大。
      • 扩展性:从框架使用者的角度来说的,特指使用者可以在不修改框架源码,甚至不拿到框架源码的情况下,为框架扩展新的功能。这就有点类似给框架开发插件
      • 容错性:对于性能计数器框架来说,不能因为框架本身的异常导致接口请求出错。所以,要对框架可能存在的各种异常情况都考虑全面,对外暴露的接口抛出的所有运行时、非运行时异常都进行捕获处理。
      • 通用性:为了提高框架的复用性,能够灵活应用到各种场景中。框架在设计的时候,要尽可能通用。要多去思考一下,除了接口统计这样一个需求,还可以适用到其他哪些场景中,比如是否还可以处理其他事件的统计信息,比如 SQL 请求时间的统计信息、业务统计信息
  • 框架设计

    • 借鉴 TDD(测试驱动开发)和 Prototype(最小原型)的思想,先聚焦于一个简单的应用场景,基于此设计实现一个简单的原型。尽管这个最小原型系统在功能和非功能特性上都不完善,但它能够看得见、摸得着,比较具体、不抽象,能够很有效地帮助我缕清更复杂的设计思路,是迭代设计的基础。

      • 对于性能计数器这个框架的开发来说,我们可以先聚焦于一个非常具体、简单的应用场景,比如统计用户注册、登录这两个接口的响应时间的最大值和平均值、接口调用次数,并且将统计结果以 JSON 的格式输出到命令行中。
      • 要输出接口的响应时间的最大值、平均值和接口调用次数,我们首先要采集每次接口请求的响应时间,并且存储起来,然后按照某个时间间隔做聚合统计,最后才是将结果输出。在原型系统的代码实现中,可以把所有代码都塞到一个类中,暂时不用考虑任何代码质量、线程安全、性能、扩展性等等问题,怎么简单怎么来就行
    • 针对性能计数器框架画的一个粗略的系统设计图。图可以非常直观地体现设计思想,并且能有效地帮助我们释放更多的脑空间,来思考其他细节问题。

      • 整个框架分为四个模块:数据采集、存储、聚合统计、显示。
      • 数据采集:负责打点采集原始数据,包括记录每次接口请求的响应时间和请求时间。数据采集过程要高度容错,不能影响到接口本身的可用性。除此之外,因为这部分功能是暴露给框架的使用者的,所以在设计数据采集 API 的时候,我们也要尽量考虑其易用性。
      • 存储:负责将采集的原始数据保存下来,以便后面做聚合统计。数据的存储方式有多种,比如:Redis、MySQL、HBase、日志、文件、内存等。数据存储比较耗时,为了尽量地减少对接口性能(比如响应时间)的影响,采集和存储的过程异步完成。
      • 聚合统计:负责将原始数据聚合为统计数据,比如:max、min、avg、pencentile、count、tps 等。为了支持更多的聚合统计规则,代码希望尽可能灵活、可扩展。
      • 显示:负责将统计数据以某种格式显示到终端,比如:输出到命令行、邮件、网页、自定义显示终端等。

实战二(下):如何实现一个支持各种统计规则的性能计数器?

  • 应该分多个版本逐步完善这个框架。第一个版本可以先实现一些基本功能,对于更高级、更复杂的功能,以及非功能性需求不做过高的要求,在后续的 v2.0、v3.0……版本中继续迭代优化,在 v1.0 版本中,暂时只实现下面这些功能。

    • 数据采集:负责打点采集原始数据,包括记录每次接口请求的响应时间和请求时间。
    • 存储:负责将采集的原始数据保存下来,以便之后做聚合统计。数据的存储方式有很多种,暂时只支持 Redis 这一种存储方式,并且,采集与存储两个过程同步执行。
    • 聚合统计:负责将原始数据聚合为统计数据,包括响应时间的最大值、最小值、平均值、99.9 百分位值、99 百分位值,以及接口请求的次数和 tps。
    • 显示:负责将统计数据以某种格式显示到终端,暂时只支持主动推送给命令行和邮件。命令行间隔 n 秒统计显示上 m 秒的数据(比如,间隔 60s 统计上 60s 的数据)。邮件每日统计上日的数据。
  • 面向对象设计与实现

    • 在实际的软件开发中,面向对象设计与实现往往是交叉进行的。一般是先有一个粗糙的设计,然后着手实现,实现的过程发现问题,再回过头来补充修改设计

    • 划分职责进而识别出有哪些类,根据需求描述,先大致识别出下面几个接口或类

      • MetricsCollector 类负责提供 API,来采集接口请求的原始数据。可以为MetricsCollector 抽象出一个接口
      • MetricsStorage 接口负责原始数据存储,RedisMetricsStorage 类实现MetricsStorage 接口。这样做是为了今后灵活地扩展新的存储方法
      • Aggregator 类负责根据原始数据计算统计数据。
      • ConsoleReporter 类、EmailReporter 类分别负责以一定频率统计并发送统计数据到命令行和邮件
    • 定义类及类与类之间的关系

      • 在设计类、类与类之间交互的时候,不断地用之前学过的设计原则和思想来审视设计是否合理,比如,是否满足单一职责原则、开闭原则、依赖注入、KISS 原则、DRY 原则、迪米特法则,是否符合基于接口而非实现编程思想,代码是否高内聚、低耦合,是否可以抽象出可复用代码等等。

      • MetricsCollector 类和 MetricsStorage 类的设计思路比较简单,统计和显示这两个功能就不一样了,可以有多种设计思路。实际上,如果把统计显示所要完成的功能逻辑细分一下的话,主要包含下面 4 点:

        1. 根据给定的时间区间,从数据库中拉取数据;
        2. 根据原始数据,计算得到统计数据;
        3. 将统计数据显示到终端(命令行或邮件);
        4. 定时触发以上 3 个过程的执行。
      • 面向对象设计和实现要做的事情,就是把合适的代码 放到合适的类中,现在要做的工作就是,把以上的 4 个功能逻辑划分到几个类中。划分的方法有很多种,比如,可以把前两个逻辑放到一个类中,第 3 个逻辑放到 另外一个类中,第 4 个逻辑作为上帝类(God Class)组合前面两个类来触发前 3 个逻辑的执行。当然,也可以把第 2 个逻辑单独放到一个类中,第 1、3、4 都放到另外一个类中。

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

      • 这个框架稍微有些特殊,有两个执行入口:一个是 MetricsCollector 类,提供了一组API 来采集原始数据;另一个是 ConsoleReporter 类和 EmailReporter 类,用来触发统计显示。
  • Review 设计与实现

    • MetricsCollector 负责采集和存储数据,职责相对来说还算比较单一。它基于接口而非实现编程,通过依赖注入的方式来传递 MetricsStorage 对象,可以在不需要修改代码的情况下,灵活地替换不同的存储方式,满足开闭原则。
    • MetricsStorage 和 RedisMetricsStorage 的设计比较简单。当我们需要实现新的存储方式的时候,只需要实现 MetricsStorage 接口即可。因为所有用到 MetricsStorage 和RedisMetricsStorage 的地方,都是基于相同的接口函数来编程的,所以,除了在组装类的地方有所改动(从 RedisMetricsStorage 改为新的存储实现类),其他接口函数调用的地方都不需要改动,满足开闭原则。
    • Aggregator 类是一个工具类,里面只有一个静态函数,有 50 行左右的代码量,负责各种统计数据的计算。当需要扩展新的统计功能的时候,需要修改 aggregate() 函数代码,并且一旦越来越多的统计功能添加进来之后,这个函数的代码量会持续增加,可读性、可维护性就变差了。所以,从刚刚的分析来看,这个类的设计可能存在职责不够单一、不易扩展等问题,需要在之后的版本中,对其结构做优化。
    • ConsoleReporter 和 EmailReporter 中存在代码重复问题。在这两个类中,从数据库中取数据、做统计的逻辑都是相同的,可以抽取出来复用,否则就违反了 DRY 原则。而且整个类负责的事情比较多,职责不是太单一。特别是显示部分的代码,可能会比较复杂(比如Email 的展示方式),最好是将显示部分的代码逻辑拆分成独立的类。除此之外,因为代码中涉及线程操作,并且调用了 Aggregator 的静态函数,所以代码的可测试性不好。

重构?

“重构是一种对软件内部结构的改善,目的是在不改变软件的可见行为的情况下,使其更易理解,修改成本更低。”

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

为什么要重构(why)?

  • 重构是时刻保证代码质量的一个极其有效的手段
  • 随着系统的演进,重构代码也是不可避免的
  • 真正遇到问题的时候,再对代码进行重构,能有效避免前期投入太多时间做过度的设计,做到有的放矢。
  • 重构对一个工程师本身技术的成长也有重要的意义

到底重构什么(what)?

大型重构

  • 指的是对顶层代码设计的重构
  • 包括:系统、模块、代码结构、类与类之间的关系等的重构,
  • 重构的手段有:分层、模块化、解耦、抽象可复用组件等等。这类重构的工具就是我们学习过的那些设计思想、原则和模式。
  • 这类重构涉及的代码改动会比较多,影响面会比较大,所以难度也较大,耗时会比较长,引入 bug 的风险也会相对比较大。

小型重构

  • 指的是对代码细节的重构
  • 主要是针对类、函数、变量等代码级别的重构,比如规范命名、规范注释、消除超大类或函数、提取重复代码等等。
  • 小型重构更多的是利用我们能后面要讲到的编码规范。
  • 这类重构要修改的地方比较集中,比较简单,可操作性较强,耗时会比较短,引入 bug 的风险相对来说也会比较小。只需要熟练掌握各种编码规范,就可以做到得心应手

重构的时机:什么时候重构(when)?

  • 持续重构。闲得没事的时候,或者开发过程中顺手。
  • 时刻具有持续重构意识,才能避免开发初期就过度设计,避免代码维护的过程中质量的下降。

重构的方法:又该如何重构(how)?

  • 在进行大型重构的时候,要提前做好完善的重构计划,有条不紊地分阶段来进行。
  • 每个阶段完成一小部分代码的重构,然后提交、测试、运行,发现没有问题之后,再继续进行下 一阶段的重构,保证代码仓库中的代码一直处于可运行、逻辑正确的状态。
  • 每个阶段都要控制好重构影响到的代码范围,考虑好如何兼容老的代码逻辑,必要的时候还需要写一 些兼容过渡代码。
  • 只有这样,才能让每一阶段的重构都不至于耗时太长(最好一天就能完成),不至于与新的功能开发相冲突

为了保证重构不出错,有哪些非常能落地的技术手段?

单元测试

什么是单元测试?

  • 单元测试是代码层面的测试,由研发自己来编写,用于测试“自己”编写的代码的逻辑的正确性。单元测试顾名思义是测试一个“单元”,有别于集成测试,这个“单元”一般是类或函数,而不是模块或者系统。
  • 集成测试的测试对象是整个系统或者某个功能模块,比如测试用户注册、登录功能是否正常,是一种端到端(end to end)的测试。而单元测试的测试对象是类或者函数,用来测试一个类和函数是否都按照预期的逻辑执行。这是代码层级的测试。

为什么要写单元测试?

  • 单元测试能有效地帮你发现代码中的 bug

  • 写单元测试能帮你发现代码设计上的问题。代码的可测试性。

  • 单元测试是对集成测试的有力补充

  • 写单元测试的过程本身就是代码重构的过程

  • 阅读单元测试能帮助你快速熟悉代码

  • 单元测试是 TDD 可落地执行的改进方案

    • 测试驱动开发(Test-Driven Development,简称 TDD)是一个经常被提及但很少被执行的开发模式。它的核心指导思想就是测试用例先于代码编写

如何编写单元测试?

  • 写单元测试就是针对代码设计各种测试用例,以覆盖各种输入、异常、边界情况,并将其翻译成代码。我们可以利用一些测试框架来简化单元测试的编写。

  • 除此之外,对于单元测试,我们需要建立以下正确的认知:

    • 编写单元测试尽管繁琐,但并不是太耗时;
    • 我们可以稍微放低对单元测试代码质量的要求;
    • 覆盖率作为衡量单元测试质量的唯一标准是不合理的;
    • 单元测试不要依赖被测代码的具体实现逻辑;
    • 单元测试框架无法测试,多半是因为代码的可测试性不好
  • 单元测试为何难落地执行?

    • 一方面,写单元测试本身比较繁琐,技术挑战不大,很多程序员不愿意去写;
    • 另一方面,国内研发比较偏向“快、糙、猛”,容易因为开发进度紧,导致单元测试的执行虎头蛇尾。
    • 最后,关键问题还是团队没有建立对单元测试正确的认识,觉得可有可无,单靠督促很难执行得很好。

什么是代码的可测试性?如何写出可测试性好的代码?

  • 什么是代码的可测试性?

    • 粗略地讲,所谓代码的可测试性,就是针对代码编写单元测试的难易程度。对于一段代码,如果很难为其编写单元测试,或者单元测试写起来很费劲,需要依靠单元测试框架中很高级的特性,那往往就意味着代码设计得不够合理,代码的可测试性不好。
  • 编写可测试性代码的最有效手段

    • 依赖注入是编写可测试性代码的最有效手段。通过依赖注入,我们在编写单元测试的时候,可以通过 mock 的方法解依赖外部服务,这也是我们在编写单元测试的过程中最有技术挑战的地方。
    • 如果代码中依赖了外部系统或者不可控组件,比如,需要依赖数据库、网络通信、文件系统等,那我们就需要将被测代码与外部系统解依赖,而这种解依赖的方法就叫作 “mock”。所谓的 mock 就是用一个“假”的服务替换真正的服务。mock 的服务完全在我们的控制之下,模拟输出我们想要的数据。
  • 常见的 Anti-Patterns

    • 未决行为

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

      • 全局变量是一种面向过程的编程风格,有种种弊端。实际上,滥用全局变量也让编写单元测试变得困难。
    • 静态方法

      • 静态方法跟全局变量一样,也是一种面向过程的编程思维。在代码中调用静态方法,有时候会导致代码不易测试。主要原因是静态方法也很难 mock。但是,这个要 分情况来看。
      • 只有在这个静态方法执行耗时太长、依赖外部资源、逻辑复杂、行为未决等情况下,我们才需要在单元测试中 mock 这个静态方法。
      • 除此之外,如果只是类似Math.abs() 这样的简单静态方法,并不会影响代码的可测试性,因为本身并不需要mock。
    • 复杂继承

      • 相比组合关系,继承关系的代码结构更加耦合、不灵活,更加不易扩展、不易维护。实际上,继承关系也更加难测试。
      • 如果我们利用组合而非继承来组织类之间的关系,类之间的结构层次比较扁平,在编写单元测试的时候,只需要 mock 类所组合依赖的对象即可。
    • 高耦合代码

      • 如果一个类职责很重,需要依赖十几个外部对象才能完成工作,代码高度耦合,那我们在编写单元测试的时候,可能需要 mock 这十几个依赖的对象。不管是从代码设计的角度来说,还是从编写单元测试的角度来说,这都是不合理的。

解耦?

“解耦”为何如此重要?

  • 控制代码的复杂性最关键的就是解耦,保证代码松耦合、高内聚。
  • “高内聚、松耦合”能够在更高层次上提高代码的可读性和可维护性。
  • “高内聚、松耦合”的特性可以让我们聚焦在某一模块或类中,不需要了解太多其他模块或类的代码,让我们的焦点不至于过于发散,降低了阅读和修改代码的难度。
  • “高内聚、松耦合”的代码可测试性也更加好,容易 mock 或者很少需要 mock 外部依赖的模块或者类
  • “高内聚、松耦合”,也就意味着,代码结构清晰、分层和模块化合理、依赖关系简单、模块或类之间的耦合小,那代码整体的质量就不会差。

代码是否需要“解耦”?

  • 把模块与模块之间、类与类之间的依赖关系画出来,根据依赖关系图的复杂性来判断是否需要解耦重构。
  • 如果依赖关系复杂、混乱,那从代码结构上来讲,可读性和可维护性肯定不是太好,那就需要考虑是否可以通过解耦的方法,让依赖关系变得清晰、简单

如何给代码“解耦”?

封装与抽象

  • 封装和抽象作为两个非常通用的设计思想,可以应用在很多设计场景中,比如系统、模块、lib、组件、接口、类等等的设计。
  • 封装和抽象可以有效地隐藏实现的复杂性,隔离实现的易变性,给依赖的模块提供稳定且易用的抽象接口。

中间层

  • 引入中间层能简化模块或类之间的依赖关系

  • 除此之外,我们在进行重构的时候,引入中间层可以起到过渡的作用,能够让开发和重构同步进行,不互相干扰。为了让重构能小步快跑,可以分下面四个阶段来完成接口的修改。

    • 第一阶段:引入一个中间层,包裹老的接口,提供新的接口定义。
    • 第二阶段:新开发的代码依赖中间层提供的新接口。
    • 第三阶段:将依赖老接口的代码改为调用新接口。
    • 第四阶段:确保所有的代码都调用新接口之后,删除掉老的接口。

模块化

  • 模块化是构建复杂系统常用的手段,将系统划分成各个独立的模块,让不同的人负责不同的模块,这样即便在不了解全部细节的情况下,管理者也能协调各个模块,让整个系统有效运转。
  • 每个模块之间耦合很小,每个小的团队聚焦于一个独立的高内聚模块来开发,最终像搭积木一样将各个模块组装起来,构建成一个超级复杂的系统。

编程规范?

命名

\

注释

  • 做什么、为什么、怎么做。对于一些复杂的类和接口,我们可能还需要写明“如何用”。
  • 类和函数一定要写注释,而且要写得尽可能全面、详细,而函数内部的注释要相对少一些,一般都是靠好的命名、提炼函数、解释性变量、总结性注释来提高代码可读性。

大小

  • 函数的代码行数不要超过一屏幕的大小,比如 50 行。
  • 类的大小限制比较难确定。
  • 一行代码最好不要超过 IDE 显示的宽度。

其他

  • 把代码分割成更小的单元块,屏蔽掉细节

  • 避免函数参数过多:拆分成多个函数的方式来减少参数、将函数的参数封装成对象

  • 勿用函数参数来控制逻辑

  • 函数设计要职责单一

  • 移除过深的嵌套层次

  • 学会使用解释性变量

    • 常量取代魔法数字
    • 使用解释性变量来解释复杂表达式