《设计模式之美》是极客时间上的一个代码学习系列,在学习之后特在此做记录和总结。

一、设计原则

1)SRP

  单一职责原则(Single Responsibility Principle,SRP)是指一个类或者模块只负责完成一个职责(或者功能),模块可看作比类更加粗粒度的代码块,模块中包含多个类,多个类组成一个模块。

  一个类包含了两个或者两个以上业务不相干的功能,那就说它职责不够单一,应该将它拆分成多个功能更加单一、粒度更细的类。

  判断类的职责是否足够单一,需要根据具体的应用场景和阶段需求,例如。

  (1)如果在社交产品中,用户的地址信息只是单纯地用来展示,那 UserInfo 可包含地址信息。

  (2)如果社交产品中添加了电商模块,用户的地址信息还会用在电商物流中,那最好将地址信息从 UserInfo 中拆分出来。

  由此可知,评价一个类的职责是否足够单一,并没有一个非常明确的、可以量化的标准。

  下面这几条拆分判断原则,要更有指导意义、更具有可执行性:

  (1)类中的代码行数、函数或属性过多,会影响代码的可读性和可维护性,行数最好不超过 200 行,函数个数及属性个数都最好不超过 10 个。

  (2)类依赖的其他类过多,或者依赖类的其他类过多,不符合高内聚、低耦合的设计思想。

  (3)私有方法过多,就要考虑能否将私有方法独立到新的类中,设置为 public 方法,提高代码的复用性。

  (4)比较难给类起一个合适名字,很难用一个业务名词概括,这就说明类的职责定义得可能不够清晰。

  (5)类中大量的方法都是集中操作类中的某几个属性,那就可以考虑将这几个属性和对应的方法拆分出来。

2)OCP

  开闭原则(Open Closed Principle,OCP)是指添加一个新的功能,在已有代码基础上扩展代码(新增模块、类、方法等),而非修改已有代码(修改模块、类、方法等)。

  注意,没必要纠结某个代码改动是“修改”还是“扩展”,更没必要太纠结它是否违反“开闭原则”。只要没有破坏原有代码和单元测试的正常运行,就可以说,这是一次合格的代码改动。

  不过,有些修改是在所难免的,是可以被接受的。尽量让修改操作更集中、更少、更上层,尽量让最核心、最复杂的那部分逻辑代码满足开闭原则。

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

  实际上,多态、依赖注入、基于接口而非实现编程,以及抽象意识,说的都是同一种设计思路:提升代码扩展性,只是从不同的角度、不同的层面来阐述而已。

  基于接口而非实现编程的设计初衷是,将接口和实现相分离,封装不稳定的实现,暴露稳定的接口。要遵从该原则,需要做到下面这 3 点。

  (1)函数的命名不能暴露任何实现细节。比如, uploadToAliyun() 就不符合要求,改为更加抽象的命名方式:upload()。

  (2)封装具体的实现细节。例如对上传(或下载)流程进行封装,对外提供一个包裹所有上传(或下载)细节的方法,给调用者使用。

  (3)为实现类定义抽象的接口。使用者依赖接口,而不是具体的实现类来编程。

  如何在项目中灵活运用 OCP:

  (1)对于一些比较确定的、短期内可能就会扩展,或者需求改动对代码结构影响比较大的情况,或者实现成本不高的扩展点,在编写代码的时候,就可以事先做些扩展性设计。

  (2)但对于一些不确定未来是否要支持的需求,或者实现起来比较复杂的扩展点,可以等到有需求驱动的时候,再通过重构代码的方式来支持扩展的需求。

3)LSP

  里式替换原则(Liskov Substitution Principle,LSP)是指子类对象能够替换程序中父类对象出现的任何地方,并且保证原来程序的逻辑行为不变及正确性不被破坏。

  里式替换原则就是子类完美继承父类的设计初衷,并做了增强。

  与多态的区别:

  (1)多态是面向对象编程的一大特性,也是面向对象编程语言的一种语法和代码实现的思路。

  (2)里式替换是一种设计原则,用来指导继承关系中子类该如何设计的。

  按照协议来设计:

  (1)子类在设计的时候,要遵守父类的行为约定(或者叫协议)。

  (2)父类定义了函数的行为约定,那子类可以改变函数的内部实现逻辑,但不能改变函数原有的行为约定。这里的行为约定包括:

    a、函数声明要实现的功能;

    b、对输入、输出、异常的约定;

    c、甚至包括注释中所罗列的任何特殊说明。

  实际上,定义中父类和子类之间的关系,也可以替换成接口和实现类之间的关系。

4)ISP

  接口隔离原则(Interface Segregation Principle,ISP)是指接口的调用者或使用者不应该强迫依赖它不需要的接口。

  接口可理解为下面三种东西:

  (1)一组 API 接口集合。

  例如将删除接口单独放到另外一个接口 RestrictedUserService 中,而不是 UserService 中,只打包提供给后台管理系统来使用。

  (2)单个 API 接口或函数。

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

  (3)OOP 中的接口概念。

  例如设计一个功能单一的接口:Updater。ScheduledUpdater 只依赖 Updater 这个跟热更新相关的接口,不需要被强迫去依赖不需要的 Viewer 接口。

  与单一职责原则的区别:

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

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

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

5)DIP

  (1)依赖倒置原则(Dependency Inversion Principle,DIP)

  高层模块不要依赖低层模块。高层模块和低层模块应该通过抽象来互相依赖。除此之外,抽象不要依赖具体实现细节,具体实现细节依赖抽象。

  调用者属于高层,被调用者属于低层。在平时的业务代码开发中,高层模块依赖底层模块是没有任何问题的。

  这条原则主要还是用来指导框架层面的设计。

  (2)控制反转(Inversion Of Control,IOC)

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

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

  控制反转并不是一种具体的实现技巧,而是一个比较笼统的设计思想,一般用来指导框架层面的设计。

  (3)依赖注入(Dependency Injection,DI)

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

6)KISS和YAGNI

  KISS 原则的英文描述有好几个版本。

  (1)Keep It Simple and Stupid.

  (2)Keep It Short and Simple.

  (3)Keep It Simple and Straightforward.

  它们要表达的意思其实差不多,翻译成中文就是:尽量保持简单。代码足够简单,也就意味着很容易读懂,bug 比较难隐藏。即便出现 bug,修复起来也比较简单。

  指导如何开发出 KISS 原则的方法论:

  (1)代码行数越少并不是就越“简单”。

  (2)代码逻辑复杂不违背 KISS 原则。

  如何写出满足 KISS 原则:

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

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

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

  注意,在做开发的时候,一定不要过度设计,不要觉得简单的东西就没有技术含量。实际上,越是能用简单的方法解决复杂的问题,越能体现一个人的能力。

  YAGNI(You Ain’t Gonna Need It)是指不要去设计当前用不到的功能;不要去编写当前用不到的代码。其核心思想就是:不要做过度设计。

  例如不要在项目中提前引入不需要依赖的开发包,在未用到 ZooKeeper 之前没必要提前编写这部分代码。

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

7)DRY

  DRY(Don’t Repeat Yourself)的定义非常简单,三种典型的代码重复情况:

  (1)实现逻辑重复

  将 isValidUserName() 和 isValidPassword() 两个函数中的重复代码合并到 isValidUserNameOrPassword() 函数,负责两件事情,违反了“单一职责原则”和“接口隔离原则”。

  虽然从代码实现逻辑上看起来它们是重复的,但是从语义上并不重复。

  所谓“语义不重复”指的是:从功能上来看,这两个函数干的是完全不重复的两件事情,一个是校验用户名,另一个是校验密码。

  尽管在目前的设计中,两个校验逻辑是完全一样的,但如果按照第二种写法,将两个函数的合并,那就会存在潜在的问题。

  (2)功能语义重复

  在同一个项目代码中有两个函数:isValidIp() 和 checkIfIpValid()。尽管两个函数的命名不同,实现逻辑不同,但功能是相同的,都是用来判定 IP 地址是否合法的。

  在这个例子中,尽管两段代码的实现逻辑不重复,但语义重复,也就是功能重复,可以认为它违反了 DRY 原则。

  (3)代码执行重复

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

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

  除此之外,代码中还有一处比较隐蔽的执行重复,login() 函数并不需要调用 checkIfUserExisted() 函数,只需要调用一次 getUserByEmail() 函数,从数据库中获取到用户的 email、password 等信息,然后跟用户输入的 email、password 信息做对比,依次判断是否登录成功。

  三个概念:

  (1)代码复用(Code Resue)表示一种行为:在开发新功能的时候,尽量复用已经存在的代码。

  (2)代码复用性(Code Reusability)表示一段代码可被复用的特性或能力:在编写代码的时候,让代码尽量可复用。

  (3)DRY 原则是一条原则:不要写重复的代码。

  区分:

  (1)首先,“不重复”并不代表“可复用”。

  在一个项目代码中,可能不存在任何重复的代码,但也并不表示里面有可复用的代码,不重复和可复用完全是两个概念。

  (2)其次,“复用”和“可复用性”关注角度不同。

  代码“可复用性”是从代码开发者的角度来讲的,“复用”是从代码使用者的角度来讲的。

  比如,A 同事编写了一个 UrlUtils 类,代码的“可复用性”很好。B 同事在开发新功能的时候,直接“复用”A 同事编写的 UrlUtils 类。

  尽管复用、可复用性、DRY 原则这三者从理解上有所区别,但实际上要达到的目的都是类似的,都是为了减少代码量,提高代码的可读性、可维护性。

  “复用”这个概念不仅可以指导细粒度的模块、类、函数的设计开发,实际上,一些框架、类库、组件等的产生也都是为了达到复用的目的。比如,Spring 框架、Google Guava 类库、UI 组件等等。

  提高代码复用性 7 个方法:

  (1)减少代码耦合。

  对于高度耦合的代码,当希望复用其中的一个功能,想把这个功能的代码抽取出来成为一个独立的模块、类或者函数的时候,往往会发现牵一发而动全身。

  (2)满足单一职责原则。

  越细粒度的代码,代码的通用性会越好,越容易被复用。

  (3)模块化。

  可将模块理解为单个类、函数。独立的模块就像一块一块的积木,更加容易复用,可以直接拿来搭建更加复杂的系统。

  (4)业务与非业务逻辑分离。

  越是跟业务无关的代码越是容易复用,越是针对特定业务的代码越难复用。

  (5)通用代码下沉。

  从分层的角度来看,越底层的代码越通用、会被越多的模块调用,越应该设计得足够可复用。为了避免交叉调用导致调用关系混乱,只允许上层代码调用下层代码及同层代码之间的调用,杜绝下层代码调用上层代码。

  (6)继承、多态、抽象、封装。

  利用继承,可以将公共的代码抽取到父类,子类复用父类的属性和方法。利用多态,可以动态地替换一段代码的部分逻辑,让这段代码可复用。越抽象、越不依赖具体的实现,越容易复用。代码封装成模块,隐藏可变的细节、暴露不变的接口,就越容易复用。

  (7)应用模板等设计模式。

  模板模式利用了多态来实现,可以灵活地替换其中的部分代码,整个流程模板代码可复用。

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

  除此之外,有一个著名的原则,叫作“Rule of Three”。第一次编写代码的时候,不考虑复用性;第二次遇到复用场景的时候,再进行重构使其复用。

8)LOD

  迪米特法则(Law of Demeter,LOD)也叫最小知识原则(The Least Knowledge Principle),是指每个模块只应该了解那些与它关系密切的模块的有限知识。或者说,每个模块只和自己的朋友“说话”,不和陌生人“说话”。

  换句话说,就是不该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口(也就是定义中的“有限知识”)。

  迪米特法则是希望减少类之间的耦合,让类越独立越好。每个类都应该少了解系统的其他部分。一旦发生变化,需要了解这一变化的类就会比较少。

  单一职责原则、接口隔离原则、基于接口而非实现编程和迪米特法则,目的都是实现高内聚低耦合,但是出发的角度不一样,单一职责是从自身提供的功能出发,迪米特法则是从关系出发,针对接口而非实现编程是使用者的角度,殊途同归。

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

  (2)松耦合,在代码中,类与类之间的依赖关系简单清晰。即使两个类有依赖关系,一个类的代码改动不会或者很少导致依赖类的代码改动。

9)积分系统

  (1)合理地将功能划分到不同模块。

  为了避免业务知识的耦合,让下层系统更加通用,一般来讲,不希望下层系统(也就是被调用的系统)包含太多上层系统(也就是调用系统)的业务信息,但是,可以接受上层系统包含下层系统的业务信息。

  比如,订单系统、优惠券系统、换购商城等作为调用积分系统的上层系统,可以包含一些积分相关的业务信息。但是,反过来,积分系统中最好不要包含太多跟订单、优惠券、换购等相关的信息。

  (2)设计模块与模块之间的交互关系。

  交互方式有两种,一种是同步接口调用,另一种是利用消息中间件异步调用。第一种方式简单直接,第二种方式的解耦效果更好。

  比如,用户下订单成功之后,订单系统推送一条消息到消息中间件,营销系统订阅订单成功消息,触发执行相应的积分兑换逻辑。这样订单系统就跟营销系统完全解耦,订单系统不需要知道任何跟积分相关的逻辑,而营销系统也不需要直接跟订单系统交互。

  (3)设计模块的接口、数据库、业务模型。

  数据库和接口的设计非常重要,一旦设计好并投入使用之后,这两部分都不能轻易改动。改动数据库表结构,需要涉及数据的迁移和适配;改动接口,需要推动接口的使用者作相应的代码修改。

    a、数据库的设计比较简单。实际上,只需要一张记录积分流水明细的表就可以了。

    b、为了兼顾易用性和性能,可以借鉴 facade(外观)设计模式,在职责单一的细粒度接口之上,再封装一层粗粒度的接口给外部使用。

    c、将它跟营销系统放到一个项目中开发部署,只要做好代码的模块化和解耦即可。

  为什么要分 MVC 三层开发?

  (1)分层能起到代码复用的作用。

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

  (2)分层能起到隔离变化的作用。

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

  (3)分层能起到隔离关注点的作用。

  Repository 层只关注数据的读写。Service 层只关注业务逻辑,不关注数据的来源。Controller 层只关注与外界打交道,数据校验、封装、格式转换,并不关心业务逻辑。

  (4)分层能提高代码的可测试性。

  Repsitory 层的代码通过依赖注入的方式供 Service 层使用,当要测试包含核心业务逻辑的 Service 层代码的时候,可以用 mock 的数据源替代真实的数据库,注入到 Service 层代码中。

  (5)分层能应对系统的复杂性。

  拆分有垂直和水平两个方向。水平方向基于业务来做拆分,就是模块化;垂直方向基于流程来做拆分,就是这里说的分层。

10)统计系统

  对于这样一个通用的框架的开发,还需要考虑很多非功能性的需求。

  (1)易用性,框架是否易集成、易插拔、跟业务代码是否松耦合、提供的接口是否够灵活等等。

  (2)性能,一方面,希望它低延迟,即统计代码不影响或很少影响接口本身的响应时间;另一方面,希望框架本身对内存的消耗不能太大。

  (3)扩展性,从框架使用者的角度来说,可以在不修改框架源码的情况下,为框架扩展新的功能,类似给框架开发插件。

  (4)容错性,不能因为框架本身的异常导致接口请求出错。

  (5)通用性,除了接口统计这样一个需求,还可以适用到其他哪些场景中,比如 SQL 请求时间的统计信息、业务统计信息(比如支付成功率)等。

  框架设计:

  对于稍微复杂系统的开发,很多人觉得不知从何开始。可以借鉴 TDD(测试驱动开发)和 Prototype(最小原型)的思想,先聚焦于一个简单的应用场景,基于此设计实现一个简单的原型。

  把整个框架分为四个模块:数据采集、存储、聚合统计、显示。

  接下来,就按照之前讲的面向对象设计的几个步骤,来重新划分、设计类。

  (1)划分职责进而识别出有哪些类。

  MetricsCollector类、MetricsStorage接口、Aggregator类、ConsoleReporter类和EmailReporter类。

  (2)定义类及类与类之间的关系。

  先在 IDE 中创建好这几个类,然后开始试着定义它们的属性和方法。在设计类、类与类之间交互的时候,不断地用之前学过的设计原则和思想来审视设计是否合理。

  比如,是否满足单一职责原则、开闭原则、依赖注入、KISS 原则、DRY 原则、迪米特法则,是否符合基于接口而非实现编程思想,代码是否高内聚、低耦合,是否可以抽象出可复用代码等等。

  (3)将类组装起来并提供执行入口。

  有两个执行入口:一个是 MetricsCollector 类,提供了一组 API 来采集原始数据;另一个是 ConsoleReporter 类和 EmailReporter 类,用来触发统计显示。

二、规范与重构

1)重构

  (1)重构目的(why)

  重构是一种对软件内部结构的改善,目的是在不改变软件可见行为的情况下,使其更易理解,修改成本更低。即在保持功能不变的前提下,利用设计思想、原则、模式、编程规范等理论来优化代码,修改设计上的不足,提高代码质量。

  (2)重构对象(what)

  可以笼统地分为大规模高层次重构(以下简称为“大型重构”)和小规模低层次的重构(以下简称为“小型重构”)。

  大型重构指的是对顶层代码设计的重构,包括:系统、模块、代码结构、类与类之间的关系等的重构,重构的手段有:分层、模块化、解耦、抽象可复用组件等等。这类重构的工具就是学习过的那些设计思想、原则和模式。

  小型重构指的是对代码细节的重构,主要是针对类、函数、变量等代码级别的重构,比如规范命名、规范注释、消除超大类或函数、提取重复代码等等。小型重构更多的是利用后面要讲到的编码规范。

  (3)重构时机(when)

  提倡的重构策略是持续重构。平时没有事情的时候,可以看看项目中有哪些写得不够好的、可以优化的代码,主动去重构一下。或者在修改、添加某个功能代码的时候,也可以顺手把不符合编码规范、不好的设计重构一下。

  (4)重构方法(how)

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

  除了人工去发现低层次的质量问题,还可以借助很多成熟的静态代码分析工具(比如 CheckStyle、FindBugs、PMD),来自动发现代码中的问题,然后针对性地进行重构优化。

2)单元测试

  单元测试(Unit Testing)由研发工程师自己来编写,用来测试自己写的代码的正确性。

  单元测试的好处:

  (1)单元测试能有效地帮你发现代码中的 bug。

  (2)写单元测试能帮你发现代码设计上的问题。比如没有使用依赖注入、大量使用静态函数、全局变量、代码高度耦合等。

  (3)单元测试是对集成测试的有力补充。大部分异常情况都比较难在测试环境中模拟,比如除数未判空、网络超时。

  (4)写单元测试的过程本身就是代码重构的过程。相当于对代码的一次自我 Code Review。

  (5)阅读单元测试能帮助你快速熟悉代码。

  (6)单元测试是测试驱动开发(Test-Driven Development,TDD)可落地执行的改进方案。

  编写单元测试的经验总结包括以下几点:

  (1)尽管单元测试的代码量可能是被测代码本身的 1~2 倍,写的过程很繁琐,但并不是很耗时。

  (2)对单元测试代码的质量可以放低一些要求。命名稍微有些不规范,代码稍微有些重复,也都是没有问题的。

  (3)不管覆盖率的计算方式如何高级,将覆盖率作为衡量单元测试质量的唯一标准是不合理的。

  (4)单元测试不要依赖被测试函数的具体实现逻辑,它只关心被测函数实现了什么功能。

  (5)团队内部需要统一单元测试框架。

  单元测试为何难落地执行?

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

3)可测试性

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

  依赖注入是编写可测试性代码的最有效手段。通过依赖注入,在编写单元测试的时候,可以通过 mock 的方法依赖外部服务。

  注意,只往里写入数据,并不读取数据,不参与业务逻辑的执行,不会影响代码逻辑的正确性,这些对象没有必要 mock。除此之外,一些只是为了存储数据的值对象,比如 String、Map、UseVo,也没必要通过依赖注入的方式来创建,直接在类中通过 new 创建就可以了。

  常见的测试不友好的代码有下面这 5 种:

  (1)代码中包含未决行为逻辑,即代码的输出是随机或者说不确定的,比如,跟时间、随机数有关的代码。

  (2)滥用可变全局变量。

  (3)滥用静态方法,因为静态方法很难 mock。

  (4)使用复杂的继承关系,如果父类需要 mock 某个依赖对象才能进行单元测试,那么底层子类要一个一个 mock 很多依赖对象。

  (5)高度耦合的代码,在编写单元测试的时候,可能需要 mock 这十几个依赖的对象。

4)解耦

  代码是否需要解耦?

  间接的衡量标准有很多,比如,看修改代码会不会牵一发而动全身。还有一个直接的衡量标准,那就是把模块与模块之间、类与类之间的依赖关系画出来,根据依赖关系图的复杂性来判断是否需要解耦重构。

  如何进行解耦:

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

  (2)中间层,简化模块或类之间的依赖关系,并能起到过渡的作用,让开发和重构同步进行,不互相干扰。

  

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

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

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

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

  (3)模块化,将系统划分成各个独立的模块,让不同的人负责不同的模块,这样即便在不了解全部细节的情况下,管理者也能协调各个模块,让整个系统有效运转。

  (4)其他设计思想和原则,单一职责原则,基于接口而非实现编程,依赖注入,多用组合少用继承,迪米特法则。

5)编码规范

  (1)命名

  以能准确达意为目标,利用上下文简化命名,命名要可读、可搜索。

  对于接口的命名,一种是加前缀“I”,另一种是加后缀“Impl”;对于抽象类的命名,一种是带上前缀“Abstract”,另一种是不带前缀。

  (2)注释

  注释的内容主要包含这样四个方面:做什么、为什么、怎么做、怎么用。对一些边界条件、特殊情况进行说明,以及对函数输入、输出、异常进行说明。

  写一些总结性的说明、特殊情况的说明。对于逻辑比较复杂的代码或者比较长的函数,借助总结性的注释来让代码结构更清晰、更有条理。

  类和函数一定要写注释,而且要写得尽可能全面、详细,而函数内部的注释要相对少一些,一般都是靠好的命名、提炼函数、解释性变量、总结性注释来提高代码的可读性。

  (3)类、函数规模

  当一个类的代码读起来让你感觉头大了,实现某个功能时不知道该用哪个函数了,想用哪个函数翻半天都找不到了,只用到一个小功能要引入整个类(类中包含很多无关此功能实现的函数)的时候,这就说明类的行数过多了。

  对于函数代码行数的最大限制,网上有一种说法,那就是不要超过一个显示屏的垂直高度。

  (4)一行代码的长度

  一行代码最长不能超过 IDE 显示的宽度。需要滚动鼠标才能查看一行的全部代码,显然不利于代码的阅读。

  (5)善用空行分割单元块

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

  (6)缩进

  Java 语言倾向于两格缩进,PHP 语言倾向于四格缩进。

  (7)大括号是否要另起一行

  PHP 程序员喜欢另起一行,Java 程序员喜欢跟上一条语句放到一起。

  (8)类中成员的排列顺序

  在 Java 类文件中,先要书写类所属的包名,然后再罗列 import 引入的依赖类。在 Google 编码规范中,依赖类按照字母顺序从小到大排列。

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

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

  (9)把代码分割成更小的单元块

  要有模块化和抽象思维,善于将大块的复杂逻辑提炼成类或者函数,屏蔽掉细节,让阅读代码的人不至于迷失在细节中。

  (10)避免函数参数过多

  函数包含 3、4 个参数的时候还是能接受的。针对参数过多的情况,一般有 2 种处理方法。

    a、考虑函数是否职责单一,是否能通过拆分成多个函数的方式来减少参数。

    b、将函数的参数封装成对象。

  (11)勿用函数参数来控制逻辑

  不要在函数中使用布尔类型的标识参数来控制内部逻辑,true 的时候走这块逻辑,false 的时候走另一块逻辑。

  这明显违背了单一职责原则和接口隔离原则,建议将其拆成两个函数。

  (12)函数设计要职责单一

  相对于类和模块,函数的粒度比较小,代码行数少,所以在应用单一职责原则的时候,没有像应用到类或者模块那样模棱两可,能多单一就多单一。

  (13)移除过深的嵌套层次

  嵌套最好不超过两层,超过两层之后就要思考一下是否可以减少嵌套。有下面 4 种常见的思路。

    a、去掉多余的 if 或 else 语句。

    b、使用编程语言提供的 continue、break、return 关键字,提前退出嵌套。

    c、调整执行顺序来减少嵌套。

    d、将部分嵌套逻辑封装成函数调用,以此来减少嵌套。

  (14)学会使用解释性变量

  常用的用解释性变量来提高代码的可读性的情况有下面 2 种。

    a、常量取代魔法数字。

    b、使用解释性变量来解释复杂表达式。

6)代码质量问题清单

  下面 7 个是通用的关注点,可以作为一些常规检查项,套用在任何代码的重构上。

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

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

  (3)设计模式是否应用得当?是否有过度设计?

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

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

  (6)代码是否容易测试?单元测试是否全面覆盖了各种正常和异常的情况?

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

  还要关注代码实现是否满足业务本身特有的功能和非功能需求。一些比较共性的关注点如下所示:

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

  (2)逻辑是否正确?是否处理了各种异常情况?

  (3)日志打印是否得当?是否方便 debug 排查问题?

  (4)接口是否易用?是否支持幂等、事务等?

  (5)代码是否存在并发问题?是否线程安全?

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

  (7)是否有安全漏洞?比如输入输出校验是否全面?

7)异常

  关于函数出错返回数据类型,总结了 4 种情况:

  (1)返回错误码。

  (2)返回 NULL 值,用 NULL 来表示“不存在”这种语义。

  (3)返回空对象,当函数返回的数据是字符串类型或者集合类型的时候,可以用空字符串或空集合替代 NULL 值,来表示不存在的情况。

  (4)抛出异常对象。

  针对函数抛出的异常的三种处理方式:直接吞掉、原封不动地抛出和包裹成新的异常抛出。

  运行时异常叫作非受检异常(Unchecked Exception),编译时异常叫作受检异常(Checked Exception)。

  对于代码 bug(比如数组越界)以及不可恢复异常(比如数据库连接失败),即便捕获了,也做不了太多事情,所以,倾向于使用非受检异常。

  对于可恢复异常、业务异常,比如提现金额大于余额的异常,更倾向于使用受检异常,明确告知调用者需要捕获处理。

  Java 支持的受检异常一直被人诟病,理由主要有以下三个。

  (1)受检异常需要显式地在函数定义中声明。

  (2)编译器强制必须显示地捕获所有的受检异常,代码实现会比较繁琐。

  (3)受检异常的使用违反开闭原则。如果给某个函数新增一个受检异常,这个函数所在的函数调用链上的所有位于其之上的函数都需要做相应的代码修改,直到调用链中的某个函数将这个新增的异常 try-catch 处理掉为止。

 

 posted on 2020-11-02 09:55  咖啡机(K.F.J)  阅读(876)  评论(0编辑  收藏  举报