文章有点长,推荐先进行收藏!
精心编写,在迷茫的时候可以反复观看!
以下所有的原则,都不能脱离应用场景!!
不管是应用设计原则还是设计模式,最终的目的还是提高代码的可读性、可扩展性、复用性、可维护性等。
简单清晰的定义:一个类只负责完成一个职责或者功能。也就是说,不要设计大而全的类,要设计粒度小、功能单一的类。
如何判断一个类的职责是否足够单一?
举个例子:
public class UserInfo {
private long userId;
private String username;
private String email;
private String telephone;
private long createTime;
private long lastLoginTime;
private String avatarUrl;
private String provinceOfAddress; // 省
private String cityOfAddress; // 市
private String regionOfAddress; // 区
private String detailedAddress; // 详细地址
// ... 省略其他属性和方法...
}
对于这个类的设计,有两种观点:
第一种:如果我们从“用户”这个业务层面来看,UserInfo 包含 的信息都属于用户,满足职责单一原则。
第二种:如果我们从更加细分的“用户展示信息”“地址信 息”“登录认证信息”等等这些更细粒度的业务层面来看,那 UserInfo 就应该继续拆分。
综上所述,评价一个类的职责是否足够单一,我们并没有一个非常明确的、可以量化的标准,可以说,这是件非常主观、仁者见仁智者见智的事情。实际上,在真正的软件开发中, 我们也没必要过于未雨绸缪,过度设计。所以,我们可以先写一个粗粒度的类,满足业务需 求。随着业务的发展,如果粗粒度的类越来越庞大,代码越来越多,这个时候,我们就可以将这个粗粒度的类,拆分成几个更细粒度类。这就是所谓的持续重构(文章后面部分将会具体提及)。
对于单一职责原则的定义,我们不好判断是否满足条件。
所以我们可以从以下几条具体的指导原则来进行设计:
实际上,不管是应用设计原则还是设计模式,最终的目的还是提高代码的可读性、可扩展性、复用性、可维护性等。我们在考虑应用某一个设计原则是否合理的时候,也可以以此作为最终的考量标准。
总结 + 提问
1. 如何理解单一职责原则(SRP)?
一个类只负责完成一个职责或者功能。不要设计大而全的类,要设计粒度小、功能单一的类。单一职责原则是为了实现代码高内聚、低耦合,提高代码的复用性、可读性、可维护性。
2. 如何判断类的职责是否足够单一?
不同的应用场景、不同阶段的需求背景、不同的业务层面,对同一个类的职责是否单一,可能会有不同的判定结果。实际上,一些侧面的判断指标更具有指导意义和可执行性,比如,出现下面这些情况就有可能说明这类的设计不满足单一职责原则:
- 类中的代码行数、函数或者属性过多;
- 类依赖的其他类过多,或者依赖类的其他类过多;
- 私有方法过多;
- 比较难给类起一个合适的名字;
- 类中大量的方法都是集中操作类中的某几个属性。
3. 类的职责是否设计得越单一越好?
单一职责原则通过避免设计大而全的类,避免将不相关的功能耦合在一起,来提高类的内聚性。同时,类职责单一,类依赖的和被依赖的其他类也会变少,减少了代码的耦合性,以此来实现代码的高内聚、低耦合。但是,如果拆分得过细,实际上会适得其反,反倒会降低内聚性,也会影响代码的可维护性。
简单清晰的定义:对扩展开放,对修改关闭。
稍微详细一点的解读:添加一个新的功能应该是,在已有代码基础上扩展代码(新增模块、类、方法等),而非修改已有代码(修改模块、类、方法等)。
修改代码就意味着违背开闭原则吗?
我们再一块回忆一下开闭原则的定义:软件实体(模块、类、方法等)应该“对扩展开放、对修改关闭”。从定义中,我们可以看出,开闭原则可以应用在不同粒度的代码中,可以是模块,也可以类,还可以是方法(及其属性)。同样一个代码改动,在粗代码粒度下,被认定为“修改”,在细代码粒度下,又可以被认定为“扩展”。比如,在一个类中添加属性和方法相当于修改类,在类这个层面,这个代码改动可以被认定为“修改”;但这个代码改动并没有修改已有的属性和方法,所以在方法(及其属性)这一层面,它又可以被认定为“扩展”。
如何做到对扩展开放、修改关闭?
实际上,开闭原则讲的就是代码的扩展性问题,是判断一段代码是否易扩展的“金标准”。如果某段代码在应对未来需求变化的时候,能够做到“对扩展开放、对修改关闭”,那就说明这段代码的扩展性比较好。所以,问如何才能做到“对扩展开放、对修改关闭”,也就粗略地等同于在问,如何才能写出扩展性好的代码。
这里要先明确一个指导思想:
- 在写代码的时候后,我们要多花点时间往前多思考一下,这段代码未来可能有哪些需求变更、如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,不需要改动代码整体结构、做到最小代码改动的情况下,新的代码能够很灵活地插入到扩展点上,做到“对扩展开放、对修改关闭”。
- 其次,在识别出代码可变部分和不可变部分之后,我们要将可变部分封装起来,隔离变化,提供抽象化的不可变接口,给上层系统使用。当具体的实现发生变化的时候,我们只需要基于相同的抽象接口,扩展一个新的实现,替换掉老的实现即可,上游系统的代码几乎不需要修改。
在众多的设计原则、思想、模式中,最常用来提高代码扩展性的方法有:多态、依赖注入、基于接口而非实现编程,以及大部分的设计模式(比如,装饰、策略、模板、职责链、状态等)。
如何在项目中灵活应用开闭原则?
前面我们提到,写出支持“对扩展开放、对修改关闭”的代码的关键是预留扩展点。那问题是如何才能识别出所有可能的扩展点呢?
有一句话说得好,“唯一不变的只有变化本身”。即便我们对业务、对系统有足够的了解,那也不可能识别出所有的扩展点,即便你能识别出所有的扩展点,为这些地方都预留扩展点,这样做的成本也是不可接受的。我们没必要为一些遥远的、不一定发生的需求去提前买单,做过度设计。
针对这个情况我们要在合理的控制范围能进行一定的设计,并根据上面提到过的观点,针对后续的业务需求变更之后,再对代码的设计进行持续重构。
总结 + 问题
1. 如何理解“对扩展开放、对修改关闭”?
添加一个新的功能,应该是通过在已有代码基础上扩展代码(新增模块、类、方法、属性等),而非修改已有代码(修改模块、类、方法、属性等)的方式来完成。关于定义,我们有两点要注意。第一点是,开闭原则并不是说完全杜绝修改,而是以最小的修改代码的代价来完成新功能的开发。第二点是,同样的代码改动,在粗代码粒度下,可能被认定为“修改”;在细代码粒度下,可能又被认定为“扩展”。
2. 如何做到“对扩展开放、修改关闭”?
我们要时刻具备扩展意识、抽象意识、封装意识。在写代码的时候,我们要多花点时间思考一下,这段代码未来可能有哪些需求变更,如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,在不改动代码整体结构、做到最小代码改动的情况下,将新的代码灵活地插入到扩展点上。
很多设计原则、设计思想、设计模式,都是以提高代码的扩展性为最终目的的。特别是 23 种经典设计模式,大部分都是为了解决代码的扩展性问题而总结出来的,都是以开闭原则为 指导原则的。最常用来提高代码扩展性的方法有:多态、依赖注入、基于接口而非实现编 程,以及大部分的设计模式(比如,装饰、策略、模板、职责链、状态)。
简单清晰的定义:子类对象能够替换程序中父类对象出现的任何地方,并且保证原来程序的逻辑行为不变及正确性不被破坏。
核心思维:子类完美继承父类的设计初衷,并做了增强。
举个简单的例子来说明一下:
public class LiskovSubstitutionPrinciple {
public static void main(String[] args) throws Exception {
// Human student = new Student();
Human human = new Human();
Student student = new Student();
human.eat("bread");
human.eat("d");
student.eat("d");
}
}
class Human {
public void eat(String things) throws Exception {
System.out.println("I am eating " + things);
}
}
class Student extends Human{
@Override
public void eat(String things) throws Exception {
if (things.length() < 2) {
throw new Exception("You are eating shit ?");
}
System.out.println("I am eating " + things);
}
}
运行结果十分清晰明了:
I am eating bread
I am eating d
Exception in thread "main" java.lang.Exception: You are eating shit ?
at designpattern.solid.Student.eat(LiskovSubstitutionPrinciple.java:23)
at designpattern.solid.LiskovSubstitutionPrinciple.main(LiskovSubstitutionPrinciple.java:9)
上述现象是完全不满足里氏替换原则的,同一个函数在父类和子类声明的对象中调用获得了我们不想得到的结果。
更细致的解释一下:子类在设计的时候,应当遵守父类的行为约定(或者叫协议)。父类定义了函数的行为约定,那子类可以改变函数的内部实现逻辑,但不能改变函数原有的行为约定。**这里的行为约定包括:函数声明要实现的功能;对输入、输出、异常的约定;甚至包括注释中所罗列的任何特殊说明。**实际上,定义中父类和子类之间的关系,也可以替换成接口和实现类之间的关系。
总结
里式替换原则是用来指导,继承关系中子类该如何设计的一个原则。理解里式替换原则,最核心的就是理解“design by contract,按照协议来设计”这几个字。父类定义了函数 的“约定”(或者叫协议),那子类可以改变函数的内部实现逻辑,但不能改变函数原有的“约定”。这里的约定包括:函数声明要实现的功能;对输入、输出、异常的约定;甚至 包括注释中所罗列的任何特殊说明。
理解这个原则,我们还要弄明白里式替换原则跟多态的区别。虽然从定义描述和代码实现上来看,多态和里式替换有点类似,但它们关注的角度是不一样的。多态是面向对象编程的一大特性,也是面向对象编程语言的一种语法。它是一种代码实现的思路。而里式替换是一种设计原则,用来指导继承关系中子类该如何设计,子类的设计要保证在替换父类的时候,不改变原有程序的逻辑及不破坏原有程序的正确性。
接口隔离中对接口的三种理解:一组 API 接口集合、单个 API 接口、函数 OOP 中的接口概念。下面对这三种理解分别进行详细的阐述。
举个例子:
class User {
private int id;
private String username;
private String password;
private String phone;
}
interface UserService {
void login(String username, String password);
void register(User user);
User getUserById(int id);
User deleteUserById(int id);
User deleteUserByPhone(String phone);
}
根据代码,我们可以看出来,UserService接口中罗列了几个看起来没什么问题的方法。不过在实际生产需求之中,接口中的deleteUserById方法和deleteUserByPhone方法如果暴露给所有实现类,可能会因为接口全部暴露而导致误操作,而产生不必要的麻烦,这个时候我们根据接口隔离原则可以设计成如下的方式:
interface UserService {
void login(String username, String password);
void register(User user);
User getUserById(int id);
}
interface RestrictUserService {
User deleteUserById(int id);
User deleteUserByPhone(String phone);
}
这样,就很明朗了。讲不同级别的接口进行隔离开来,在需要实现的时候,再进行分别的引入。这样就可以使调用者可以根据需要进行引入,而不必强迫的实现不需要使用到的接口。
class DataInfo {
private long sum;
private long avg;
private long max;
private long min;
// 省略 getter,setter
}
class DataHandler {
public DataInfo DataCalc() {
DataInfo dataInfo = new DataInfo();
// 省略庞大的计算逻辑
dataInfo.setMax(...);
dataInfo.setMin(...);
dataInfo.setSum(...);
dataInfo.setAvg(...);
return dataInfo;
}
}
此时我们如果发现如果这个数据计算类这么设计,会使得整个方法十分的臃肿;如果我们讲各参数的计算分开设计,如下
interface DataCalc {
public long calcSum();
public long calcAvg();
public long calcMax();
public long calcMin();
}
class DataHandler implements DataCalc{
public DataInfo DataCalc() {
DataInfo dataInfo = new DataInfo();
dataInfo.setSum(calcSum());
// 省略其他参数的计算
return dataInfo;
}
// 分别重写实现方法,将具体业务逻辑分离开
@Override
public long calcSum() {
// 计算 calcSum 的逻辑代码
return 0;
}
// 这里省略其他的重写实现
}
不难发现,我们的代码优美了不少,而且今后在遇到业务逻辑需要变更的时候,可以更加清晰的对代码进行重构。
假设我们的项目中用到了三个外部系统:Redis、MySQL、Kafka。每个系统都对应一系列 配置信息,比如地址、端口、访问超时时间等。为了在内存中存储这些配置信息,供项目中 的其他模块来使用,我们分别设计实现了三个 Configuration 类:RedisConfig、MysqlConfig、KafkaConfig。每个类都有一些共有的一些功能比如update等,不过他们的逻辑实现分别不同。这个时候我们可以将他们共有的方法进行向上抽取出来,因为是OOP中的接口,我们之前也有提及,这里就不再进行过多的阐述了。
问题
接口隔离原则与单一职责原则的区别
单一职责原则针对的是模块、类、接口的设计。接口隔离原则相对于单一职责原则,一方面更侧重于接口的设计,另一方面它的思考角度也是不同的。接口隔离原则提供了一种判断接口的职责是否单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。
原文定义:高层模块不要依赖低层模块。高层模块和低层模块应该通过抽象来互相依赖。除此之外,抽象不要依赖具体实现细节,具体实现细节依赖抽象。
百度定义;依赖倒置原则(Dependence Inversion Principle)是程序要依赖于抽象接口,不要依赖于具体实现。简单的说就是要求对抽象进行编程,不要对实现进行编程,这样就降低了客户与实现模块间的耦合。
依赖倒置的核心思想是面向接口编程。
依赖倒转原则是基于这样的设计理念:相对于细节的多变性,抽象的东西要稳定的多。以抽象为基础搭建的架构比以细节为基础的架构要稳定的多。在java中,抽象指的是接口或抽象类,细节就是具体的实现类。
使用接口或抽象类的目的是:制定好规范,而不涉及任何具体的操作,把展现细节的任务交给他们的实现类去完成。
举一个生动形象的例子:
// 上层模块依赖下层实现
class UploadFile {
public void uploadFileToBaiduyun() {
// TODO 上传至百度云实现逻辑
}
}
大家应该都写过这样的案例,但是这样的实现方式对吗?这样的实现方式不是错误的,但是如果现在业务需求变更了,我们将上传至百度云更换为上传至阿里云了,这样子我们将要修改uploadfile类内的方法,如下:
// 上层模块依赖下层实现
class UploadFile {
// public void uploadFileToBaiduyun() {
// TODO 上传至百度云实现逻辑
// }
public void uploadFileToAliYun() {
// TODO 上传至阿里云实现逻辑
}
}
根据我们上面描述道的开闭原则:对扩展开放,对修改关闭。显然是有一些冲突的。因此我们根据依赖倒置原则,可以将代码进行一定的优化,使其变得更有扩展性和维护性,如下:
interface UploadFile {
public void uploadFile();
}
class UploadFileToBaiduYun implements UploadFile {
@Override
public void uploadFile() {
// TODO 上传至百度云实现逻辑
}
}
class UploadFileToAliYun implements UploadFile {
@Override
public void uploadFile() {
// TODO 上传至阿里云实现逻辑
}
}
这样后来需求增加上传什么云服务器的时候,我们都可以以扩展的方式加进来,而不用去修改以前的上传方式,这样随着产品的迭代,面对业务需求的变更时,我们就可以游刃有余的对项目进行扩展,而不是改来改去。
在除了设计模式的SOLID五大设计原则还有这么三个经典的设计原则,比较偏向具体编码细节:
KISS:Keep It Stupid and Simple。
保持简单,曾经读到的一本书有看过这么一句话: 在编写代码的时候大家往往喜欢追求“高大上”的代码,以为写出让人们很难看懂的代码,才是厉害的代码。恰恰相反,高手都是将最复杂的思想用最简单的代码清晰直观的表现出来。
上面这句话深深点醒了我,不知道大家编写代码的时候有没有这样的经历:
- 这个地方,我可以用位运算操作一手~ 效率杠杠的!
- 这个地方,我可以手写一个轮子,让项目更高效!
- 这个地方,我可以用正则进行匹配,代码简练清晰!
- 这个地方,我可以 … …
不是说这是错误的想法,恰恰相反在有些业务效率达到瓶颈的时候,这样细节部分的优化是有必要的。不过这样的代码,可读性就有一点令人堪忧了。所以如何去平衡效率与可读性呢。 在业务开发的时候,应该遵循这么几个思想:
- 这个地方可以用提供的工具类进行操作。
- 这个地方可以拆分成几个简单的逻辑类接口,还可以进行复用。
- 不要使用大家不懂的技术来实现代码
- 不要过度优化,不要使用奇淫技巧
其实这是一个十分主观的思想,因为不同的人对代码的理解能力是有差异的,很多人对位运算,正则,lambda等觉得十分清晰简单,而有的人却没有过深刻的了解,就导致不同人对代码可读性的评判是有区别的。因此我们要尽量在不牺牲可读性的前提下,对代码进行优化,提高团队开发效率。
YAGNI:You Ain’t Gonna Need It。
直译就是:你不会需要它。这个原则在提醒我们在设计开发的时候不要过度设计,不仅提高了时间成本,而且降低了开发效率。这样子是十分不必要的,我们可以在相应的地方留出一些扩展点即可,在业务需求迭代的时候,在进行加入适当的逻辑即可。
DRY:Don’t Repeat Yourself。
简单粗暴的理解:不要重复!
可以分为三个方向:逻辑实现重复、语义功能重复、代码执行重复。
// 简化版判断注册校验参数逻辑
public boolean validateRegisterParameter(String username, String password) {
if (validateUserName(username) && validatePassword(password)) return true;
return false;
}
public boolean validateUserName (String username) {
if (username == null) return false;
// TODO 对username 格式进行判断
// TODO 判断username是否满足6-18位
// TODO 判断username是否包含字母大小写
// TODO 判断username是否含有非法字符
return true;
}
public boolean validatePassword (String password) {
if (password == null) return false;
// TODO 对username 格式进行判断
// TODO 判断username是否满足6-18位
// TODO 判断username是否包含字母大小写
// TODO 判断username是否含有非法字符
return true;
}
看着是不是十分头大,一样的逻辑 分了两个不同的方法,这个时候,我们可以将相同部分以抽象的方式抽取出一个新的方法:
public boolean validateUserName (String username) {
if (!validateStringPattern(username)) return false;
// ...
return true;
}
public boolean validatePassword (String password) {
if (!validateStringPattern(password)) return false;
return true;
}
public boolean validateStringPattern(String s) {
if (s == null) return false;
// TODO 对s 格式进行判断
// TODO 判断s是否满足6-18位
// TODO 判断s是否包含字母大小写
// TODO 判断s是否含有非法字符
return true;
}
public void isValidData(Data data) {
// TODO 对data进行验证
}
public void checkIfDataValid(Data data) {
// TODO 对data进行验证
}
上面代码中,同一语义对方法名,会引起歧义,如果在其他业务中分别调用了不同的方法,在业务进行更迭重构的时候,会使不清楚的编程人员不明白这两个函数有什么区别,付出不必要的额外时间成本。其次,若今后对其中一个验证方法进行了重构,而忘记了对另一个方法进行重构,则会引起比较难以发现的bug。
class UserService {
private UserRepo userRepo = new UserRepo();
boolean login(User user) {
String phone = user.getPhone();
// 这里执行了一次 checkUserByPhone()
if(userRepo.checkUserByPhone(phone)) {
userRepo.register(user);
return true;
}
return false;
}
}
class UserRepo {
private UserDao userDao;
boolean checkUserByPhone(String phone) {
if (userdao.getUserByPhone(phone) == null) return true;
return false;
}
void register(User user) {
// 这里又执行了一个 checkUserByPhone()
if (checkUserByPhone(user.phone)) {
// TODO 注册逻辑
}
return;
}
}
上述代码就很明显的反应了代码执行重复这么一种情况,重复执行一段代码是很没有必要的,而且例子中的代码还是对数据库进行操作,更是极大的影响了程序执行的效率。
在介绍完经典的SOLID五大原则和KISS、YAGNI、DRY几个实用的原则后,我们来聊一聊这个超级优美的一个法则:迪米特法则。
至于为什么说迪米特法则十分的优美。我们来看一看迪米特法则要实现什么。
定义:迪米特法则(Law of Demeter)又叫作最少知识原则(The Least Knowledge Principle),一个类对于其他类知道的越少越好,就是说一个对象应当对其他对象有尽可能少的了解,只和朋友通信,不和陌生人说话。
核心思想:实现高内聚,低耦合
"高内聚、低耦合"是一个非常重要的设计思想,能够有效地提高代码的可读性和可维护性,缩小功能改动导致的代码改动范围。实际上,在前面的章节中,我们已经多次提到过这个设计思想。很多设计原则都以实现代码的“高内聚、松耦合”为目的,比如单一职责原则、基于接口而非实现编程等。
我们通过前面讲述的所有原则,基本上都可以直接或间接的通向这么一个道路 -> 实现高内聚,低耦合。
什么是“高内聚”?
所谓高内聚,就是指相近的功能应该放到同一个类中,不相近的功能不要放到同一个类中。 相近的功能往往会被同时修改,放到同一个类中,修改会比较集中,代码容易维护。实际 上,我们前面讲过的单一职责原则是实现代码高内聚非常有效的设计原则。
什么是“低耦合”?
所谓低耦合,在代码中,类与类之间的依赖关系简单清晰。即使两个类有依赖关系,一个类的代码改动不会或者很少导致依赖类的代码改动。实际上,我们前面讲的依赖注入、接口隔离、基于接口而非实现编程,以及今天讲的迪米特法则,都是为了实现代码的松耦合。
我们结合具体的代码进行分析:
class Serialization {
public String serialize(Object object) {
String serializeResult = "";
// TODO 计算值操作...
return serializeResult;
}
public Object deserialize(String str) {
Object deserializeResult = null;
// TODO 计算值操作...
return deserializeResult;
}
}
上述代码实现了序列化与反序列化的操作,乍一看,这个代码是没有任何问题的,不过站在高内聚和低耦合的角度上分析,还是有一定的优化空间的,根据最小知识原则:
在调用序列化的时候是完全不需要知道反序列化的操作是什么样子的,因此我们可能会做出如下设计:
class Serialize {
public String serialize(Object object) {
String serializeResult = "";
// TODO 计算值操作...
return serializeResult;
}
}
class Deserialize {
public Object deserialize(String str) {
Object deserializeResult = null;
// TODO 计算值操作...
return deserializeResult;
}
}
虽然满足的迪米特法则的最小知识原则了,但是不符合高内聚的这个思想。高内聚要求相近的功能要放到同一个类中,这样可以方便功能修改的时候,修改的 地方不至于过于分散。对于刚刚这个例子来说,如果我们修改了序列化的实现方式,比如从 JSON 换成了 XML,那反序列化的实现逻辑也需要一并修改。在未拆分的情况下,我们只需要修改一个类即可。在拆分之后,我们需要修改两个类。显然,这种设计思路的代码改动范围变大了。那么我们该如何改进呢?
为了既能实现高内聚,又能实现迪米特的最小知识原则。我们只需引入两个接口:
interface Serialize {
public String serialize(Object object);
}
interface Deserialize {
public Object deserialize(String str);
}
class Serialization implements serialize, deserialize{
@Override
public String serialize(Object object) {
// TODO 序列化
return null;
}
@Override
public Object deserialize(String str) {
// TODO 反序列化
return null;
}
}
是不是豁然开朗!!!
一切都是那么的清晰明了,在调用的时候仅仅需要:
Serialize serializeObject = new Serialization(); // 多态声明
serializeObject.serialize();
既隔离了接口,又内聚了模块。简直完美,这种高内聚,低耦合的思想在其他原则中也都有隐约的体现。
如果觉得文章对你有帮助,点个赞支持一下噻~~