My logo
Published on

设计模式原则与思考

00丨面向对象

面向对象中的继承、多态能让我们写出可复用的代码;编码规范能让我们写出可读性好的代码;设计原则中的单一职责、DRY、基于接口而非实现、里式替换原则等,可以让我们写出可复用、灵活、可读性好、易扩展、易维护的代码;设计模式可以让我们写出易扩展的代码;持续重构可以时刻保持代码的可维护性等等。具体这些编程方法论是如何提高代码的可维护性、可读性、可扩展性等等。

01丨设计模式学习导读

设计原则:

  • SOLID 原则 -SRP 单一职责原则
  • SOLID 原则 -OCP 开闭原则
  • SOLID 原则 -LSP 里式替换原则
  • SOLID 原则 -ISP 接口隔离原则
  • SOLID 原则 -DIP 依赖倒置原则
  • DRY 原则、KISS 原则、YAGNI 原则、LOD 法则

设计模式的分类:

创建型 常用的有:单例模式、工厂模式(工厂方法和抽象工厂)、建造者模式。 不常用的有:原型模式。 结构型 常用的有:代理模式、桥接模式、装饰者模式、适配器模式。 不常用的有:门面模式、组合模式、享元模式。 行为型 常用的有:观察者模式、模板模式、策略模式、职责链模式、迭代器模式、状态模式。 不常用的有:访问者模式、备忘录模式、命令模式、解释器模式、中介模式。

代码的重构:

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

二,设计原则与思想:面向对象

02丨理论一:当谈论面向对象的时候,我们到底在谈论什么

  • 面向对象编程是一种编程范式或编程风格。它以类或对象作为组织代码的基本单元,并将封装、抽象、继承、多态四个特性,作为代码设计和实现的基石 。
  • 面向对象编程语言是支持类或对象的语法机制,并有现成的语法机制,能方便地实现面向对象编程四大特性(封装、抽象、继承、多态)的编程语言。

03丨理论二:封装、抽象、继承、多态分别可以解决哪些编程问题

封装(Encapsulation)

如果我们对类中属性的访问不做限制,那任何代码都可以访问、修改类中的属性,虽然这样看起来更加灵活,但从另一方面来说,过度灵活也意味着不可控,属性可以随意被以各种奇葩的方式修改,而且修改逻辑可能散落在代码中的各个角落,势必影响代码的可读性、可维护性。比如某个同事在不了解业务逻辑的情况下,在某段代码中“偷偷地”重设了 wallet 中的balanceLastModifiedTime 属性,这就会导致balance 和balanceLastModifiedTime 两个数据不一致。 除此之外,类仅仅通过有限的方法暴露必要的操作,也能提高类的易用性。如果我们把类属性都暴露给类的调用者,调用者想要正确地操作这些属性,就势必要对业务细节有足够的了解。而这对于调用者来说也是一种负担。相反,如果我们将属性封装起来,暴露少许的几个必要的方法给调用者使用,调用者就不需要了解太多背后的业务细节,用错的概率就减少很多。这就好比,如果一个冰箱有很多按钮,你就要研究很长时间,还不一定能操作正确。相反,如果只有几个必要的按钮,比如开、停、调节温度,你一眼就能知道该如何来操作,而且操作出错的概率也会降低很多。

抽象(Abstraction)

实际上,如果上升一个思考层面的话,抽象及其前面讲到的封装都是人类处理复杂性的有效手段。在面对复杂系统的时候,人脑能承受的信息复杂程度是有限的,所以我们必须忽略掉一些非关键性的实现细节。而抽象作为一种只关注功能点不关注实现的设计思路,正好帮我们的大脑过滤掉许多非必要的信息。

除此之外,抽象作为一个非常宽泛的设计思想,在代码设计中,起到非常重要的指导作用。很多设计原则都体现了抽象这种设计思想,比如基于接口而非实现编程、开闭原则(对扩展开放、对修改关闭)、代码解耦(降低代码的耦合性)等。我们在讲到后面的内容的时候, 会具体来解释。

换一个角度来考虑,我们在定义(或者叫命名)类的方法的时候,也要有抽象思维,不要在方法定义中,暴露太多的实现细节,以保证在某个时间点需要改变方法的实现逻辑的时候, 不用去修改其定义。举个简单例子,比如 getAliyunPictureUrl() 就不是一个具有抽象思维的命名,因为某一天如果我们不再把图片存储在阿里云上,而是存储在私有云上,那这个命名也要随之被修改。相反,如果我们定义一个比较抽象的函数,比如叫作 getPictureUrl(),那即便内部存储方式修改了,我们也不需要修改命名。

多态(Polymorphism)

学习完了封装、抽象、继承之后,我们再来看面向对象编程的最后一个特性,多态。多态是指,子类可以替换父类,在实际的代码运行过程中,调用子类的方法实现。对于多态这种特性,多态特性能提高代码的可扩展性和复用性。

07丨理论四:哪些代码设计看似是面向对象,实际是面向过程的?

  1. 滥用 getter、setter 方法
  2. Constants 类、Utils 类的设计问题
  3. 基于贫血模型的开发模式

08丨理论五:接口vs抽象类的区别?如何用普通的类模拟抽象类和接口?

如何决定该用抽象类还是接口? 实际上,判断的标准很简单。如果我们要表示一种 is-a 的关系,并且是为了解决代码复用的问题,我们就用抽象类;如果我们要表示□一种 has-a 关系,并且是为了解决抽象而非代码复用的问题,那我们就可以使用接口。 从类的继承层次上来看,抽象类是一种自下而上的设计思路,先有子类的代码重复,然后再抽象成上层的父类(也就是抽象类)。而接口正好相反,它是一种自上而下的设计思路。我们在编程的时候,一般都是先设计接口,再去考虑具体的实现。

09丨理论六:为什么基于接口而非实现编程?有必要为每个类都定义接口吗?

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

  1. 函数的命名不能暴露任何实现细节。比如,前面提到的uploadToAliyun() 就不符合要求,应该改为去掉aliyun 这样的字眼,改为更加抽象的命名方式,比如:upload()。
  2. 封装具体的实现细节。比如,跟阿里云相关的特殊上传(或下载)流程不应该暴露给调用者。我们对上传(或下载)流程进行封装,对外提供一个包裹所有上传(或下载)细节的方法,给调用者使用。
  3. 为实现类定义抽象的接口。具体的实现类都依赖统一的接口定义,遵从一致的上传功能协议。使用者依赖接口,而不是具体的实现类来编程。

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

10丨理论七:为何说要多用组合少用继承?如何决定该用组合还是继承?

在面向对象编程中,有一条非常经典的设计原则,那就是:组合优于继承,多用组合少用继承。 我们知道继承主要有三个作用:表示 is-a 关系,支持多态特性,代码复用。而这三个作用都可以通过其他技术手段来达成。比如 is-a 关系,我们可以通过组合和接口的 has-a 关系来替代;多态特性我们可以利用接口来实现;代码复用我们可以通过组合和委托来实现。所以,从理论上讲,通过组合、接口、委托三个技术手段,我们完全可以替换掉继承,在项目中不用或者少用继承关系,特别是一些复杂的继承关系。

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

尽管我们鼓励多用组合少用继承,但组合也并不是完美的,继承也并非一无是处。从上面的例子来看,继承改写成组合意味着要做更细粒度的类的拆分。这也就意味着,我们要定义更多的类和接口。类和接口的增多也就或多或少地增加代码的复杂程度和维护成本。所以,在实际的项目开发中,我们还是要根据具体的情况,来具体选择该用继承还是组合。 如果类之间的继承结构稳定(不会轻易改变),继承层次比较浅(比如,最多有两层继承关系),继承关系不复杂,我们就可以大胆地使用继承。反之,系统越不稳定,继承层次很 深,继承关系复杂,我们就尽量使用组合来替代继承。 除此之外,还有一些设计模式会固定使用继承或者组合。比如,装饰者模式(decorator pattern)、策略模式(strategy pattern)、组合模式(composite pattern)等都使用了组合关系,而模板模式(template pattern)使用了继承关系。

11丨实战一(上):业务开发常用的基于贫血模型的MVC架构违背OOP吗? 我们平时做Web 项目的业务开发,大部分都是基于贫血模型的MVC 三层架构,在专栏中我把它称为传统的开发模式。之所以称之为“传统”,是相对于新兴的基于充血模型的 DDD 开发模式来说的。基于贫血模型的传统开发模式,是典型的面向过程的编程风格。相反,基于充血模型的 DDD 开发模式,是典型的面向对象的编程风格。 不过,DDD 也并非银弹。对于业务不复杂的系统开发来说,基于贫血模型的传统开发模式简单够用,基于充血模型的 DDD 开发模式有点大材小用,无法发挥作用。相反,对于业务复杂的系统开发来说,基于充血模型的 DDD 开发模式,因为前期需要在设计上投入更多时间和精力,来提高代码的复用性和可维护性,所以相比基于贫血模型的开发模式,更加有优势。

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

钱包业务背景介绍(实践) 很多具有支付、购买功能的应用(比如淘宝、滴滴出行、极客时间等)都支持钱包的功能。应用为每个用户开设一个系统内的虚拟钱包账户,支持用户充值、提现、支付、冻结、透支、转赠、查询账户余额、查询交易流水等操作。下图是一张典型的钱包功能界面,你可以直观地感受一下。

一般来讲,每个虚拟钱包账户都会对应用户的一个真实的支付账户,有可能是银行卡账户,也有可能是三方支付账户(比如支付宝、微信钱包)。为了方便后续的讲解,我们限定钱包暂时只支持充值、提现、支付、查询余额、查询交易流水这五个核心的功能,其他比如冻 结、透支、转赠等不常用的功能,我们暂不考虑。为了让你理解这五个核心功能是如何工作的,接下来,我们来一块儿看下它们的业务实现流程。

/**
 * @description: 基于充血模型的 DDD 开发模式 虚拟钱包功能
 * Domain 领域模型 (充血模型)
 * @author: wangyj
 * @date: 2022/8/12
 **/
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 BigDecimal balance() {
        return this.balance;
    }

    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 getAvaliableBalance() {
        BigDecimal totalAvaliableBalance = this.balance.subtract(this.frozenAmount);
        if (isAllowedOverdraft) {
            totalAvaliableBalance.add(this.overdraftAmount);
        }
        return totalAvaliableBalance;
    }

    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);
    }
}

Service 类负责与 Repository 交流。在我的设计与代码实现中,VirtualWalletService 类负责与Repository 层打交道,调用Respository 类的方法,获取数据库中的数据,转化成领域模型VirtualWallet,然后由领域模型VirtualWallet 来完成业务逻辑,最后调用Repository 类的方法,将数据存回数据库。 这里我再稍微解释一下,之所以让 VirtualWalletService 类与 Repository 打交道,而不是让领域模型 VirtualWallet 与 Repository 打交道,那是因为我们想保持领域模型的独立 性,不与任何其他层的代码(Repository 层的代码)或开发框架(比如 Spring、MyBatis)耦合在一起,将流程性的代码逻辑(比如从 DB 中取数据、映射数据)与领域模型的业务逻辑解耦,让领域模型更加可复用。 Service 类负责跨领域模型的业务聚合功能。VirtualWalletService 类中的transfer() 转账函数会涉及两个钱包的操作,因此这部分业务逻辑无法放到VirtualWallet 类中,所以,我们暂且把转账业务放到VirtualWalletService 类中了。当然,虽然功能演进,使得转账业务变得复杂起来之后,我们也可以将转账业务抽取出来,设计成一个独立的领域模型。 Service 类负责一些非功能性及与三方系统交互的工作。比如幂等、事务、发邮件、发消息、记录日志、调用其他系统的RPC 接口等,都可以放到 Service 类中。

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

下面重点演练开始编写功能的时候要做些什么,很重要。 对于“如何做需求分析,如何做职责划分?需要定义哪些类?每个类应该具有哪些属性、方法?类与类之间该如何交互?如何组装类成一个可执行的程序?”等等诸多问题,都没有清晰的思路,更别提利用成熟的设计原则、思想或者设计模式,开发出具有高内聚低耦合、易扩展、易读等优秀特性的代码了。 所以,我打算用两节课的时间,结合一个真实的开发案例,从基础的需求分析、职责划分、类的定义、交互、组装运行讲起,将最基础的面向对象分析、设计、编程的套路给你讲清 楚,为后面学习设计原则、设计模式打好基础。

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

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

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

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

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

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

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

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

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

关联(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();
}

依赖(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) { ... }
 }

看完了 UML 六种类关系的详细介绍,不知道你有何感受?我个人觉得这样拆分有点太细, 增加了学习成本,对于指导编程开发没有太大意义。所以,我从更加贴近编程的角度,对类与类之间的关系做了调整,只保留了四个关系:泛化、实现、组合、依赖,这样你掌握起来会更加容易。

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

15 | 理论一:对于单一职责原则,如何判定某个类的职责是否够“单一”

我们提到了 SOLID 原则,实际上,SOLID 原则并非单纯的 1 个原则,而是由 5 个设计原则组成的,它们分别是:单一职责原则、开闭原则、里式替换原则、接口隔离原则和依赖反转原则,依次对应 SOLID 中的 S、O、L、I、D 这 5 个英文字母。我们今天要学习的是 SOLID 原则中的第一个原则:单一职责原则。

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

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

2.如何理解开闭原则(OCP)

个人觉得,开闭原则是 SOLID 中最难理解、最难掌握,同时也是最有用的一条原则。 之所以说这条原则最有用,那是因为,扩展性是代码质量最重要的衡量标准之一。在23 种经典设计模式中,大部分设计模式都是为了解决代码的扩展性问题而存在的,主要遵从的设计原则就是开闭原则。 在写代码的时候后,我们要多花点时间往前多思考一下,这段代码未来可能有哪些需求变 更、如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,不需要改动代码整体结构、做到最小代码改动的情况下,新的代码能够很灵活地插入到扩展点上,做到“对扩展开放、对修改关闭”。

3.如何理解里氏替换原则(LSP)

这条原则用中文描述出来,是这样的:子类对象(object of subtype/derived class)能够替换程序(program)中父类对象(object of base/parent class)出现的任何地方,并且保证原来程序的逻辑行为(behavior)不变及正确性不被破 坏。 虽然从定义描述和代码实现上来看,多态和里式替换有点类似,但它们关注的角度是不一样的。多态是面向对象编程的一大特性,也是面向对象编程语言的一种语法。它是一种代码实现的思路**。而里式替换是一种设计原则,是用来指导继承关系中子类该如何设计的,子类的设计要保证在替换父类的时候,不改变原有程序的逻辑以及不破坏原有程序的正确性。** 实际上,里式替换原则还有另外一个更加能落地、更有指导意义的描述,那就是“Design By Contract”,中文翻译就是“按照协议来设计”。 看起来比较抽象,我来进一步解读一下。子类在设计的时候,要遵守父类的行为约定(或者叫协议)。父类定义了函数的行为约定,那子类可以改变函数的内部实现逻辑,但不能改变函数原有的行为约定。这里的行为约定包括:函数声明要实现的功能;对输入、输出、异常的约定;甚至包括注释中所罗列的任何特殊说明。实际上,定义中父类和子类之间的关系,也可以替换成接口和实现类之间的关系。 我们还要弄明白里式替换原则跟多态的区别。虽然从定义描述和代码实现上来看,多态和里式替换有点类似,但它们关注的角度是不一样的。多态是面向对象编程的一大特性,也是面向对象编程语言的一种语法。它是一种代码实现的思路。而里式替换是一种设计原则,用来指导继承关系中子类该如何设计,子类的设计要保证在替换父类的时候,不改变原有程序的逻辑及不破坏原有程序的正确性。

4.如何理解接口隔离原则(ISP)

理解“接口隔离原则”的重点是理解其中的“接口”二字。这里有三种不同的理解。 如果把“接口”理解为一组接口集合,可以是某个微服务的接口,也可以是某个类库的接口等。如果部分接口只被部分调用者使用,我们就需要将这部分接口隔离出来,单独给这部分调用者使用,而不强迫其他调用者也依赖这部分不会被用到的接口。 如果把“接口”理解为单个 API 接口或函数,部分调用者只需要函数中的部分功能,那我们就需要把函数拆分成粒度更细的多个函数,让调用者只依赖它需要的那个细粒度函数。 如果把“接口”理解为 OOP 中的接口,也可以理解为面向对象编程语言中的接口语法。那接口的设计要尽量单一,不要让接口的实现类和调用者,依赖不需要的接口函数。

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

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

5.如何理解依赖反转原则(DIP)

控制反转(IOC)

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

依赖注入(DI)

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

依赖注入框架

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

依赖反转原则

高层模块(high-level modules)不要依赖低层模块(low-level)。高层模块和低层模块应该通过抽象(abstractions)来互相依赖。除此之外,抽象(abstractions)不要依赖具体实现细节(details),具体实现细节(details)依赖抽象(abstractions)。 所谓高层模块和低层模块的划分,简单来说就是,在调用链上,调用者属于高层,被调用者属于低层。 Tomcat 是运行Java Web 应用程序的容器。我们编写的Web 应用程序代码只需要部署在Tomcat 容器下,便可以被Tomcat 容器调用执行。按照之前的划分原则,Tomcat 就是高层模块,我们编写的Web 应用程序代码就是低层模块。Tomcat 和应用程序代码之间并没有直接的依赖关系,两者都依赖同一个“抽象”,也就是 Sevlet 规范。Servlet 规范不依赖具体的Tomcat 容器和应用程序的实现细节,而Tomcat 容器和应用程序依赖Servlet 规范。

20丨理论六:我为何说KISS、YAGNI原则看似简单,却经常被用错?

写出满足KISS 原则的代码

  • 不要使用同事可能不懂的技术来实现代码。比如前面例子中的正则表达式,还有一些编程语言中过于高级的语法等。
  • 不要重复造轮子,要善于使用已经有的工具类库。经验证明,自己去实现这些类库,出bug 的概率会更高,维护的成本也比较高。
  • 不要过度优化。不要过度使用一些奇技淫巧(比如,位运算代替算术运算、复杂的条件语句代替 if-else、使用一些过于底层的函数等)来优化代码,牺牲代码的可读性。

写出满足YAGNI 原则的代码

YAGNI 原则的英文全称是:You Ain’t Gonna Need It。直译就是:你不会需要它。这条原则也算是万金油了。当用在软件开发中的时候,它的意思是:不要去设计当前用不到的功能;不要去编写当前用不到的代码。实际上,这条原则的核心思想就是:不要做过度设计。

总结

从刚刚的分析我们可以看出,YAGNI 原则跟 KISS 原则并非一回事儿。KISS 原则讲的是“如何做”的问题(尽量保持简单),而 YAGNI 原则说的是“要不要做”的问题(当前不需要的就不要做)。

21丨理论七:重复的代码就一定违背DRY吗?如何提高代码的复用性?

DRY 原则(Don’t Repeat Yourself)

DRY 原则的定义非常简单,我就不再过度解读。今天,我们主要讲三种典型的代码重复情况,它们分别是:实现逻辑重复、功能语义重复和代码执行重复。这三种代码重复,有的看似违反 DRY,实际上并不违反;有的看似不违反,实际上却违反了。

代码复用性(Code Reusability)

减少代码耦合 对于高度耦合的代码,当我们希望复用其中的一个功能,想把这个功能的代码抽取出来成为一个独立的模块、类或者函数的时候,往往会发现牵一发而动全身。移动一点代码,就要牵连到很多其他相关的代码。所以,高度耦合的代码会影响到代码的复用性,我们要尽量减少代码耦合。 满足单一职责原则 我们前面讲过,如果职责不够单一,模块、类设计得大而全,那依赖它的代码或者它依赖的代码就会比较多,进而增加了代码的耦合。根据上一点,也就会影响到代码的复用性。相反,越细粒度的代码,代码的通用性会越好,越容易被复用。 模块化 这里的“模块”,不单单指一组类构成的模块,还可以理解为单个类、函数。我们要善于将功能独立的代码,封装成模块。独立的模块就像一块一块的积木,更加容易复用,可以直接拿来搭建更加复杂的系统。 业务与非业务逻辑分离 越是跟业务无关的代码越是容易复用,越是针对特定业务的代码越难复用。所以,为了复用跟业务无关的代码,我们将业务和非业务逻辑代码分离,抽取成一些通用的框架、类库、组件等。 通用代码下沉 从分层的角度来看,越底层的代码越通用、会被越多的模块调用,越应该设计得足够可复 用。一般情况下,在代码分层之后,为了避免交叉调用导致调用关系混乱,我们只允许上层代码调用下层代码及同层代码之间的调用,杜绝下层代码调用上层代码。所以,通用的代码我们尽量下沉到更下层。 继承、多态、抽象、封装 在讲面向对象特性的时候,我们讲到,利用继承,可以将公共的代码抽取到父类,子类复用父类的属性和方法。利用多态,我们可以动态地替换一段代码的部分逻辑,让这段代码可复用。除此之外,抽象和封装,从更加广义的层面、而非狭义的面向对象特性的层面来理解的话,越抽象、越不依赖具体的实现,越容易复用。代码封装成模块,隐藏可变的细节、暴露不变的接口,就越容易复用。 应用模板等设计模式 一些设计模式,也能提高代码的复用性。比如,模板模式利用了多态来实现,可以灵活地替换其中的部分代码,整个流程模板代码可复用。关于应用设计模式提高代码复用性这一部 分,我们留在后面慢慢来讲解。 除此之外**,有一个著名的原则,叫作“Rule of Three”。**这条原则可以用在很多行业和场景中,你可以自己去研究一下。如果把这个原则用在这里,那就是说,我们在第一次写代码的时候,如果当下没有复用的需求,而未来的复用需求也不是特别明确,并且开发可复用代码的成本比较高,那我们就不需要考虑代码的复用性。在之后我们开发新的功能的时候,发现可以复用之前写的这段代码,那我们就重构这段代码,让其变得更加可复用。

22 | 理论八:如何用迪米特法则(LOD)实现“高内聚、松耦合”?

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

所谓高内聚,就是指相近的功能应该放到同一个类中,不相近的功能不要放到同一个类中。相近的功能往往会被同时修改,放到同一个类中,修改会比较集中,代码容易维护。实际 上,我们前面讲过的单一职责原则是实现代码高内聚非常有效的设计原则。 所谓松耦合是说,在代码中,类与类之间的依赖关系简单清晰。即使两个类有依赖关系,一个类的代码改动不会或者很少导致依赖类的代码改动。实际上,我们前面讲的依赖注入、接口隔离、基于接口而非实现编程,以及今天讲的迪米特法则,都是为了实现代码的松耦合。 迪米特法则的英文翻译是:Law of Demeter,缩写是LOD。单从这个名字上来看,我们完全猜不出这个原则讲的是什么。不过,它还有另外一个更加达意的名字,叫作最小知识原则,英文翻译为:The Least Knowledge Principle。 不该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口(也就是定义中的“有限知识”)。

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

需求分析 性能计数器作为一个跟业务无关的功能,我们完全可以把它开发成一个独立的框架或者类 库,集成到很多业务系统中。而作为可被复用的框架,除了功能性需求之外,非功能性需求也非常重要。所以,接下来,我们从这两个方面来做需求分析。

功能性需求分析 如下功能

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

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

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

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

**MetricsCollector **类负责提供 API,来采集接口请求的原始数据。我们可以为MetricsCollector 抽象出一个接口,但这并不是必须的,因为暂时我们只能想到一个MetricsCollector 的实现方式。 **MetricsStorage **接口负责原始数据存储,RedisMetricsStorage 类实现MetricsStorage 接口。这样做是为了今后灵活地扩展新的存储方法,比如用 HBase 来存储。 **Aggregator **类负责根据原始数据计算统计数据。 **ConsoleReporter **类、EmailReporter 类分别负责以一定频率统计并发送统计数据到命令行和邮件。至于 ConsoleReporter 和 EmailReporter 是否可以抽象出可复用的抽象类,或者抽象出一个公共的接口,我们暂时还不能确定。

2. 定义类及类与类之间的关系

大致地识别出几个核心的类之后,我的习惯性做法是,先在 IDE 中创建好这几个类,然后开始试着定义它们的属性和方法。在设计类、类与类之间交互的时候,我会不断地用之前学过的设计原则和思想来审视设计是否合理,比如,是否满足单一职责原则、开闭原则、依赖注入、KISS 原则、DRY 原则、迪米特法则,是否符合基于接口而非实现编程思想,代码是否高内聚、低耦合,是否可以抽象出可复用代码等等。

29丨理论三:什么是代码的可测试性?如何写出可测试性好的代码?

常见的 Anti-Patterns

  • 代码中包含未决行为逻辑
  • 滥用可变全局变量
  • 滥用静态方法
  • 使用复杂的继承关系
  • 高度耦合的代码

30丨理论四:如何通过封装、抽象、模块化、中间层等解耦代码?

那开发就跟重构冲突了。为了让重构能小步快跑,我们可以分下面四个阶段来完成接口的修改。 第一阶段:引入一个中间层,包裹老的接口,提供新的接口定义。 第二阶段:新开发的代码依赖中间层提供的新接口。 第三阶段:将依赖老接口的代码改为调用新接口。 第四阶段:确保所有的代码都调用新接口之后,删除掉老的接口。

31丨理论五:让你最快速地改善代码质量的20条编程规范(上) 重点

命名 利用上下文简化命名 命名要可读、可搜索 如何命名接口和抽象类 注释 注释的内容主要包含这样三个方面:做什么、为什么、怎么做。我来举一个例子给你具体解释一下。

/**
2 * (what) Bean factory to create beans.
3 *
4 * (why) The class likes Spring IOC framework, but is more lightweight.
5 *
6* (how) Create objects from different sources sequentially:
7* user specified object > SPI > configuration > default object.
8 */
9 public class BeansFactory {
10	// ...
11 }

33丨 理论五:让你最快速地改善代码质量的20条编程规范(下)

  1. 把代码分割成更小的单元块
  2. 避免函数参数过多
  3. 勿用函数参数来控制逻辑
  4. 函数设计要职责单一
  5. 移除过深的嵌套层次
  6. 学会使用解释性变量

34丨 实战一(上):通过一段ID生成器代码,学习如何发现代码质量问题

如何发现代码质量问题?

目录设置是否合理、模块划分是否清晰、代码结构是否满足“高内聚、松耦合”? 是否遵循经典的设计原则和设计思想(SOLID、DRY、KISS、YAGNI、LOD 等)?设计模式是否应用得当?是否有过度设计? 代码是否容易扩展?如果要添加新功能,是否容易实现? 代码是否可以复用?是否可以复用已有的项目代码或类库?是否有重复造轮子?代码是否容易测试?单元测试是否全面覆盖了各种正常和异常的情况? 代码是否易读?是否符合编码规范(比如命名和注释是否恰当、代码风格是否一致等)?

代码实现是否满足业务本身特有的功能和非功能需求。

代码是否实现了预期的业务需求? 逻辑是否正确?是否处理了各种异常情况? 日志打印是否得当?是否方便debug 排查问题?接口是否易用?是否支持幂等、事务等? 代码是否存在并发问题?是否线程安全? 性能是否有优化空间,比如,SQL、算法是否可以优化? 是否有安全漏洞?比如输入输出校验是否全面?

35丨 实战一(下):手把手带你将ID生成器代码从“能用”重构为“好用”

我们将上一节课中发现的代码质量问题, 分成四次重构来完成 第一轮重构:提高代码的可读性 第二轮重构:提高代码的可测试性 第三轮重构:编写完善的单元测试 第四轮重构:所有重构完成之后添加注释

实际上,通过这节课,我更想传达给你的是下面这样几个开发思想,我觉得这比我给你讲解具体的知识点更加有意义。

  1. 即便是非常简单的需求,不同水平的人写出来的代码,差别可能会很大。我们要对代码质量有所追求,不能只是凑活能用就好。花点心思写一段高质量的代码,比写100 段凑活能用的代码,对你的代码能力提高更有帮助。
  2. 知其然知其所以然,了解优秀代码设计的演变过程,比学习优秀设计本身更有价值。知道为什么这么做,比单纯地知道怎么做更重要,这样可以避免你过度使用设计模式、思想和原则。
  3. 设计思想、原则、模式本身并没有太多“高大上”的东西,都是一些简单的道理,而且知识点也并不多,关键还是锻炼具体代码具体分析的能力,把知识点恰当地用在项目中。
  4. 我经常讲,高手之间的竞争都是在细节。大的架构设计、分层、分模块思路实际上都差不多。没有项目是靠一些不为人知的设计来取胜的,即便有,很快也能被学习过去。所以,关键还是看代码细节处理得够不够好。这些细节的差别累积起来,会让代码质量有质的差别。所以,要想提高代码质量,还是要在细节处下功夫。

36丨 实战二(上):程序出错该返回啥?NULL、异常、错误码、空对象?

关于函数出错返回数据类型,我总结了 4 种情况,它们分别是:错误码、NULL 值、空对 ** 象、异常对象。接下来,我们就一一来看它们的用法以及适用场景。 1. 返回错误码 2. 返回Null值 如果某个函数有可能返回 NULL 值,我们在使用它的时候,忘记了做 NULL 值判断,就 有可能会抛出空指针异常(Null Pointer Exception,缩写为 NPE)。 如果我们定义了很多返回值可能为 NULL 的函数,那代码中就会充斥着大量的 NULL 值 判断逻辑,一方面写起来比较繁琐,另一方面它们跟正常的业务逻辑耦合在一起,会影响代码的可读性。 当函数返回的数据是字符串类型或者集合类型的时候,我们可以用空字符串或空集合替代 NULL 值**,来表示不存在的情况。这样,我们在使用函数的时候,就可以不用做 NULL 值 判断。 总之,是否往上继续抛出,要看上层代码是否关心这个异常。关心就将它抛出,否则就直接吞掉。是否需要包装成新的异常抛出,看上层代码是否能理解这个异常、是否业务相关。如 果能理解、业务相关就可以直接抛出,否则就封装成新的异常抛出。

37丨 实战二(下):重构ID生成器项目中各函数的异常处理代码

如果函数是 private 类私有的,只在类内部被调用,完全在你自己的掌控之下,自己保证在 调用这个 private 函数的时候,不要传递 NULL 值或空字符串就可以了。所以,我们可以 不在 private 函数中做 NULL 值或空字符串的判断。如果函数是 public 的,你无法掌控会 被谁调用以及如何调用(有可能某个同事一时疏忽,传递进了 NULL 值,这种情况也是存 在的),为了尽可能提高代码的健壮性,我们最好是在 public 函数中做 NULL 值或空字符 串的判断。