- SRP 单一职责原则
- OCP 开闭原则
- LSP 里氏替换原则
- ISP 接口隔离原则
- DIP 依赖反转原则
在架构之路上和代码设计上,我们一定要明白上面的几个原则,在这几个原则的指导下,才能设计出优良的架构,才能经得住撕逼。
SRP 单一职责原则
SRP是五大原则里最容易被误解的一个,很多程序员根据SRP这个名字想当然的认为这个原则就是指:每个模块都应该只做一件事。
但这是只是一个面向底层实现细节的设计原则,并不是SRP的全部。
在历史上,我们曾经这样描述SRP这一原则:任何一个软件模块都应该有且仅有一个被修改的原因。
在现实环境中,软件系统为了满足用户和所有者的要求,总是会面临这样那样的修改。而系统用户或者所有者就是该设计原则中所指的‘被修改的原因’。所以也可以这样描述SRP。
任何一个软件模块都应该只对一个用户或者系统相关者负责。
这里的‘用户’和‘系统相关者’在用词上也不完全准确,它们很有可能指的是一个或多个用户和利益相关者,只要这些人希望对系统进行的变更相似的,就可以归为一类或者称其为行为者。所以,SRP的最终描述变成了:
任何一个软件模块都应该只对一类行为者负责
这个软件模块指的是什么呢?可以一个源代码文件,或者是一组紧密相关的函数和数据结构。
我们看一下下面的商品类设计
类里有三个方法
- getPrice() 获取价格
- getOrderList() 获取订单列表
- getReturnList() 获取退货列表
消费者需要getPrice 函数 , 库房管理员需要getOrderList() 函数 ,售后人员 需要 getReturnList() 函数
这个类同事对三个相关者负责,可能因为商品订单函数的修改影响到售后。所以这个类违背了 单一职责原则 原则。 接下来对类按照行为拆分
商品类 对 消费者负责 订单类 对 库房管理员负责 收收类 对 售后人员负责 其中的每个类的修改都不会牵扯他人,也只对一类行为者负责,这原则极大的降低了代码的耦合。
OCP 开闭原则
OCP 开闭原则,看起来感觉很难懂,其实可以这么理解。
设计良好的软件应该易于扩展,同时抗拒修改
。
简单的说就是系统应该不需要修改的前提下就可以轻易扩展。这个原则是软件设计,系统架构中非常重要的原则。
接下来看一组架构
image.png
这里将PC 和WAP的数据展示放到了一个类里面,如果此时要产品要再加一种PAD的显示方式,就要修改展示代码,否则无法加入新的功能。
接下来按照OCP 原则优化
image.png
设计了一个数据运算层,也可以说是MVC中的M层,这个层主要生成格式化数据,给前端的展示层提供标准化数据,在这个结构中添加PAD展示类,无需修改任何代码,可以很容易添加。在再添加其他任何展示类都可以不用修改,从这个设计中可以看出是易于扩展,同时抗拒修改的。同时底层的数据发生变化,只用修改数据运算层,无需修改前端展示类,这样也解除了依赖,做到了依赖反转。
OCP 主要是让我们的系统已于扩展,同时限制其每次被修改的所影响的范围。
LSP 里氏替换原则
派生的子类应该是可替换基类的,也就是说任何基类可以出现的地方,子类一定可以出现。
简单的来说就是,当你通过继承实现多态行为时,如果派生类没有遵守LSP,可能会让系统引发异常。所以请谨慎使用继承,只有确定是“is-a”的关系时才使用继承。
我们来看一个经典的错误模型。
image.png
当用户调用矩形类时: 矩形 r = new 矩形() r.setH = 10 r.setW = 2 assert(r.area() == 20 ) 很显然换成 正方形 的类,用户这样调用就会存在问题。也就是说当 矩形 出现的地方不能替换成子类 正方形,这里就违背了里氏替换原则(LSP)。
我们来看一个正确的设计模型。
image.png
在警察检查身份证号,每个中国人出生就有一个身份证号,所以这里中国人的子类,工人或者司机都存在这个获取身份证号的方法,任何父类出现的地方子类都可以替换,这就是里氏替换原则。
LSP 可以且应该应用于原件架构的各个层面,因为一旦违背了可替换性,系统就不得不为此增加大量复杂的对应机制
ISP 接口隔离原则
接口隔离原则(ISP)表明类不应该被迫依赖他们不使用的方法。
我们先来看一个设计。
image.png
假设这里 User1 调用 op1 User2 调用 op2 User3 调用 op3 再假设这里的代码是java 这种编译语言写的,那么很明显User1虽然不用调用op2 op3,但在源代码上形成了依赖关系,这种依赖关系意味着我们队OPS 中op2所做的任何修改,即使不影响User1的功能,也会导致他需要重新被编译和部署。
这个问题可以通过接口隔离来解决。
image.png
现在User1 的源代码会依赖U1Ops 和op1 ,但不会依赖U2Ops op2,U2Ops做任何修改都不需要重新编译和部署User1。
看到这里大家是不是任务接口隔离只是对编译语言的一种优化,像PHP 和Python 就不需要这种设计呢? 这原理在软件架构中也有很大的意义。
image.png
我们来开系统S引入了框架F,框架F必须使用数据库D。那么就形成了S依赖于F,F依赖于D的关系。
在这种D中包含了F中的不需要的功能,那么这些功能也是S中不需要的。而我对D的修改会导致F可能会重新部署,接着又会导致S的重新部署。更可怕是D中的一个无关功能修改的错误,导致F和S都无法运行。
任何层次的软件设计如果依赖了它并不需要的东西,就会带来意料之外的麻烦
SRP 依赖反转原则
我们每次修改抽象接口的时候,一定回去修改对应的具体实现,但是反过来,当我们修改具体实现时,却很修改对应的抽象接口。所以我们认为接口比实现更加的稳定。
也就是说,如果想要在软件设计上追求稳定,就必须使用稳定的抽象接口,少依赖多变的具体实现。下面,我们将该设计的原则归结为以下几条具体守则。
- 应该在代码中多使用抽象接口,尽量避免使用那些多变的具体实现类。
- 不要在具体实现类上创建衍生类
- 不要覆盖包含具体实现的函数
- 避免在代码中写入与任何具体实现相关的名字,或者是其他容易变动的事务名称。
先不考虑依赖反转的设计,我们来看这样一个设计
image.png
代码语言:javascript复制<?php
class Driver
{
public function drive(BMW $bmw)
{
$bmw->run();
}
}
class BMW
{
public function run()
{
echo "宝马上路......";
}
}
class Client{
public function goToWork(){
$driver = new Driver();
$bmw = new BMW();
$driver->drive($bmw);
}
}
这样的设计乍一看好像也没有问题,开着宝马去上班,但是如果我们买了奔驰,想开奔驰去上班,这咋办呢?要去修改Driver类,才能使用奔驰车,这样的设计导致了代码耦合很高,具体实现类的修改,必须修改抽象逻辑。 我们按照依赖反转原则进行设计
image.png
代码语言:javascript复制<?php
abstract class CarBase
{
public abstract function run();
}
class BMW extends CarBase
{
public function run()
{
echo "宝马上路......";
}
}
class Benz extends CarBase
{
public function run()
{
echo "奔驰上路......";
}
}
abstract class DriverBase
{
public abstract function drive(CarBase $car);
}
class Driver extends DriverBase
{
public function drive(CarBase $car)
{
$car->run();
}
}
class Client
{
public function goToWork()
{
$driver = new Driver();
$bmw = new BMW();
$driver->drive($bmw);
/**
* 很轻松的添加车辆
*/
$benz = new Benz();
$driver->drive($benz);
}
}
抽象是对实现的约束,是对依赖者的一种契约,不仅仅约束自己,还同时约束自己与外部的关系,其目的就是保证所有的细节不脱离契约的范畴,确保约束双方按照规定好的契约(抽象)共同发展,只要抽象这条线还在,细节就脱离不了这个圈圈。
当学习完设计原则后,我发现依赖反转原则,其实是其他几个原则的综合,接口的设计保证了单一职责原则
,依赖反转的部分实现也满足了开闭原则
,通过抽象进行约束很大程度上也是一种里氏替换原则
,接口的设计又实现各个接口的隔离,这里也提现了接口隔离原则
。
综上所述可以得出,好的依赖隔离的设计是同时满足SOLID原则
的。那么反之可以得出如果其中任意原则实现的不好,我们就要反思依赖反转是否没有做好。
总结
SOLID设计原则,看起来说的都是一些老掉牙的原则,一些工作多年的工程师,都或多或少的使用了其中一些原则,但其中大部分都不能全部的说出这些原则的使用场景和使用方式。这就像一个行走多年的武林人士会很多的招式,但是知其然不知其所以然。 认真的学习设计的基本功,就像郭靖大侠,一步一步的打好基础,未来遇到更大更复杂的招式都可以化繁为简,快速学习。