一、高质量代码的评判标准:
可维护性:在不破化原有代码设计、不引入新的bug的情况下,能够快速的修改或者添加代码
可读性:我们需要看代码是否符合编码规范、命名是否达意、注释是否详尽、函数是否长短合适、模块划分是否清晰、是否符合高内聚低耦合等等
可扩展性:代码预留了一些功能扩展点,你可以把新功能代码,直接插到扩展点上,而不需要因为要添加一个功能而大动干戈,改动大量的原始代码。“对修改关闭,对扩展开放”这条设计原则。
灵活性:
①当我们添加一个新的功能代码的时候,原有的代码已经预留好了扩展点,我们不需要修改原有的代码,只要在扩展点上添加新的代码即可
②当我们要实现一个功能的时候,发现原有代码中,已经抽象出了很多底层可以复用的模块、类等代码,我们可以拿来直接使用。
③当我们使用某组接口的时候,如果这组接口可以应对各种使用场景,满足各种不同的需求
简洁性:我们在编写代码的时候,往往也会把简单、清晰放到首位。
可复用性:
①比如,当讲到面向对象特性的时候,我们会讲到继承、多态存在的目的之一,就是为了提高代码的可复用性;
②当讲到设计原则的时候,我们会讲到单一职责原则也跟代码的可复用性相关;
③当讲到重构技巧的时候,我们会讲到解耦、高内聚、模块化等都能提高代码的可复用性。
可测试性:单元测试比较容易编写
二、面向对象:
封装:封装也叫作信息隐藏或者数据访问保护
抽象:例如调用者在使用图片存储功能的时候,只需要了解 IPictureStorage 这个接口类暴露了哪些方法就可以了,不需要去查看 PictureStorage 类里的具体实现逻辑。
继承:单继承表示一个子类只继承一个父类,多继承表示一个子类可以继承多个父类,比如猫既是哺乳动物,又是爬行动物。
多态:在实际的代码运行过程中,调用子类新的功能逻辑,而不是在原有代码上做修改。这就遵从了“对修改关闭、对扩展开放”的设计原则,提高代码的扩展性。
三、面向对象设计理念:
1、相关联的数据封装在一个类里,例如在 ShoppingCart 类中定义一个 clear() 方法,将清空购物车的业务逻辑封装在里面(itemsCount、totalPrice、items 三者数据同步),透明地给调用者使用。
2、我们可以通过 Java 提供的 Collections.unmodifiableList() 方法,让 getter 方法返回一个不可被修改的 UnmodifiableList 集合容器
四、面向过程设计理念:
针对不同的功能,静态方法无需属性,设计不同的 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、设计思路区别:
①抽象类是一种自下而上的设计思路,先有子类的代码重复,然后再抽象成上层的父类(也就是抽象类)。
②而接口正好相反,它是一种自上而下的设计思路。我们在编程的时候,一般都是先设计接口,再去考虑具体的实现。
六、接口
”基于接口而非实现编程”的原则:
①命名要足够通用,函数的命名不能暴露任何实现细节。比如,前面提到的 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、通用性:
为了提高框架的复用性,能够灵活应用到各种场景中。
二十七、框架设计:
借鉴 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。