注册

违反这些设计原则,系统就等着“腐烂”


分享是最有效的学习方式。



老猫的设计模式专栏已经偷偷发车了。不甘愿做crud boy?看了好几遍的设计模式还记不住?那就不要刻意记了,跟上老猫的步伐,在一个个有趣的职场故事中领悟设计模式的精髓吧。还等什么?赶紧上车吧


故事


这段时间以来,小猫按照之前的系统梳理方案【系统梳理大法&代码梳理大法】一直在整理着文档。


系统中涉及的业务以及模型也基本了然于胸,但是这代码写的真的是...


小猫也终于知道了为什么每天都有客诉,为什么每天都要去调用curl语句去订正生产的数据,为什么每天都在Hotfix...


整理了一下,大概出于这些原因,业务流程复杂暂且不议,光从技术角度来看,整个代码体系臃肿不堪,出问题之后定位困难,后面接手的几任开发为了解决问题都是“曲线救国”,不从正面去解决问题,为了解决一时的客诉问题而去解决问题,于是定义了各种新的修复流程去解决问题,这么一来,软件系统“无序”总量一直在增加,整个系统体系其实在初版之后就已经在“腐烂”了,如此?且抛开运维稳定性不谈,就系统本身稳定性而言,能好?


整个系统,除了堆业务还是堆业务,但凡有点软件设计原则,系统也不会写成这样了。


关于设计原则


大家在产品提出需求之后,一般都会去设计数据模型,还有系统流程。但是各位有没有深度去设计一下代码的实现呢?还是说上手就直接照着流程图开始撸业务了?估计有很多的小伙伴由于各种原因不会去考虑代码设计,其实老猫很多时候也一样。主要原因比如:项目催的紧,哪有时间考虑那么多,功能先做出来,剩下的等到后面慢慢优化。然而随着时间的推移,我们会发现我们一直很忙,说好的把以前的代码重构好一点,哪有时间!于是,就这样“技术债”越来越多,就像滚雪球一样,整个系统逐渐“腐烂”到了根。最终坑的可能是自己,也有可能是“下一个他”。


虽然在日常开发的时候项目进度比较紧张,我们很多时候也不去深度设计代码实现,但是我们在写代码的时候保证心中有一杆秤其实还是必要的。


那咱们就结合各种案来聊聊“这杆秤”————软件设计原则。


design_rule.png


下面我们通过各种小例子来协助大家理解软件设计原则,案例是老猫构想的,有的时候不要太过较真,主要目的是讲清楚原则。另外后文中也会有相关的类图表示实体之间的关系,如果大家对类图不太熟悉的,也可以看一下这里【类图传送门


开闭原则


开闭原则,英文(Open-Closed Principle,简称:OCP)。只要指一个软件实体(例如,类,模块和函数),应该对扩展开放,对修改关闭。其重点强调的是抽象构建框架,实现扩展细节,从而提升软件系统的可复用性以及可维护性。


概念是抽象,但是案例是具体的,所以咱们直接看案例,通过案例去理解可能更容易。


由于小猫最近在维护商城类业务,所以咱们就从商品折价售卖这个案例出发。业务是这样的,商城需要对商品进行做打折活动,目前针对不同品类的商品可能打折的力度不一样,例如生活用品和汽车用品的打折情况不同。
创建一个基础商品接口:


public interface IProduct {
String getSpuCode(); //获取商品编号
String getSpuName(); //获取商品名称
BigDecimal getPrice(); //获取商品价格
}

基础商品实现该接口,于是我们就有了如下代码:


/**
*
@Author: 公众号:程序员老猫
*
@Date: 2024/2/7 23:39
*/

public class Product implements IProduct {
private String spuCode;
private String spuName;
private BigDecimal price;
private Integer categoryTag;

public Product(String spuCode, String spuName, BigDecimal price, Integer categoryTag) {
this.spuCode = spuCode;
this.spuName = spuName;
this.price = price;
this.categoryTag = categoryTag;
}

public Integer getCategoryTag() {
return categoryTag;
}

@Override
public String getSpuCode() {
return spuCode;
}

@Override
public String getSpuName() {
return spuName;
}

@Override
public BigDecimal getPrice() {
return price;
}
}

按照上面的业务,现在搞活动,咱们需要针对不同品类的商品进行促销活动,例如生活用品需要进行折扣。当然我们有两种方式实现这个功能,如果咱们不改变原有代码,咱们可以如下实现。


public class DailyDiscountProduct extends Product {
private static final BigDecimal daily_discount_factor = new BigDecimal(0.95);
private static final Integer DAILY_PRODUCT = 1;

public DailyDiscountProduct(String spuCode, String spuName, BigDecimal price) {
super(spuCode, spuName, price, DAILY_PRODUCT);
}

public BigDecimal getOriginPrice() {
return super.getPrice();
}

@Override
public BigDecimal getPrice() {
return super.getPrice().multiply(daily_discount_factor);
}
}

上面我们看到直接打折的日常用品的商品继承了标准商品,并且对其进行了价格重写,这样就完成了生活用品的打折。当然这种打折系数的话我们一般可以配置到数据库中。


对汽车用品的打折其实也是一样的实现。继承之后重写价格即可。咱们并不需要去基础商品Product中根据不同的品类去更改商品的价格。


错误案例


如果我们一味地在原始类别上去做逻辑应该就是如下这样:



public class Product implements IProduct {
private static final Integer DAILY_PRODUCT = 1;
private static final BigDecimal daily_discount_factor = new BigDecimal(0.95);
private String spuCode;
private String spuName;
private BigDecimal price;
private Integer categoryTag;
....
@Override
public BigDecimal getPrice() {
if(categotyTag.equals(DAILY_PRODUCT)){
return price.multiply(daily_discount_factor);
}
return price;
}
}

后续随着业务的演化,后面如果提出对商品名称也要定制,那么咱们可能还是会动当前的代码,我们一直在改当前类,代码越堆越多,越来越臃肿,这种实现方式就破坏了开闭原则。


咱们看一下开闭原则的类图。如下:


kb_01.png


依赖倒置原则


依赖倒置原则,英文名(Dependence Inversion Principle,简称DIP),指的是高层模块不应该依赖低层模块,二者都应该依赖其抽象。通过依赖倒置,可以减少类和类之间的耦合性,从而提高系统的稳定性。这里主要强调的是,咱们写代码要面向接口编程,不要面向实现去编程。


定义看起来不够具体,咱们来看一下下面这样一个业务。针对不同的大客户,我们定制了很多商城,有些商城是专门售卖电器的,有些商城是专门售卖生活用品的。有个大客,由于对方是电器供应商,所以他们想售卖自己的电器设备,于是,我们就有了下面的业务。


//定义了一个电器设备商城,并且支持特有的电器设备下单流程
public class ElectricalShop {
public String doOrder(){
return "电器商城下单";
}
}
//用户进行下单购买电器设备
public class Consumer extends ElectricalShop {
public void shopping() {
super.doOrder();
}
}

我们看到,当客户可选择的只有一种商城的时候,这种实现方式确实好像没有什么问题,但是现在需求变了,马上要过年了,大客户不想仅仅给他们的客户提供电器设备,他们还想卖海鲜产品,这样,以前的这种下单模式好像会有点问题,因为以前我们直接继承了ElectricalShop,这样写的话,业务可拓展性就太差了,所以我们就需要抽象出一个接口,然后客户在下单的时候可以选择不同的商城进行下单。于是改造之后,咱们就有了如下代码:


//抽象出一个更高维度的商城接口
public interface Shop {
String doOrder();
}
//电器商城实现该接口实现自有下单流程
public class ElectricalShop implements Shop {
public String doOrder(){
return "电器商城下单";
}
}
//海鲜商城实现该接口实现自有下单流程
public class SeaFoodShop implements Shop{
@Override
public String doOrder() {
return "售卖一些海鲜产品";
}
}
//消费者注入不同的商城商品信息
public class Consumer {
private Shop shop;
public Consumer(Shop shop) {
this.shop = shop;
}
public String shopping() {
return shop.doOrder();
}
}
//消费者在不同商城随意切换下单测试
public class ConsumerTest {
public static void main(String[] args) {
//电器商城下单
Consumer consumer = new Consumer(new ElectricalShop());
System.out.println(consumer.shopping());
//海鲜商城下单
Consumer consumer2 = new Consumer(new SeaFoodShop());
System.out.println(consumer2.shopping());
}
}


上面这样改造之后,原本继承详细商城实现的Consumer类,现在直接将更高维度的商城接口注入到了类中,这样相信后面再多几个新的商城的下单流程都可以很方便地就完成拓展。


这其实也就是依赖倒置原则带来的好处,咱们最终来看一下类图。


DIP.png


单一职责原则


单一职责原则,英文名(SimpleResponsibility Pinciple,简称SRP)指的是不要存在多余一个导致类变更的原因。这句话看起来还是比较抽象的,老猫个人的理解是单一职责原则重点是区分业务边界,做到合理地划分业务,根据产品的需求不断去重新规划设计当前的类信息。关于单一职责老猫其实之前已经和大家分享过了,在此不多赘述,大家可以进入这个传送门【单一职责原则


接口隔离原则


接口隔离原则(Interface Segregation Principle,简称ISP)指的是指尽量提供专门的接口,而非使用一个混合的复杂接口对外提供服务。


聊到接口隔离原则,其实这种原则和单一职责原则有点类似,但是又不同:



  1. 联系:接口隔离原则和单一职责原则都是为了提高代码的可维护性和可拓展性以及可重用性,其核心的思想都是“高内聚低耦合”。
  2. 区别:针对性不同,接口隔离原则针对的是接口,而单一职责原则针对的是类。

下面,咱们用一个业务例子来说明一下吧。
我们用简单的动物行为这样一个例子来说明一下,动物从大的方面有能飞的,能吃,能跑,有的也会游泳等等。如果我们定义一个比较大的接口就是这样的。


public interface IAnimal {
void eat();
void fly();
void swim();
void run();
...
}

我们用猫咪实现了该方法,于是就有了。


public class Cat implements IAnimal{
@Override
public void eat() {
System.out.println("老猫喜欢吃小鱼干");
}
@Override
public void fly() {
}
@Override
public void swim() {
}
@Override
public void run() {
System.out.println("老猫还喜欢奔跑");
}
}

我们很容易就能发现,如果老猫不是“超人猫”的话,老猫就没办法飞翔以及游泳,所以当前的类就有两个空着的方法。
同样的如果有一只百灵鸟,那么实现Animal接口之后,百灵鸟的游泳方法也是空着的。那么这种实现我们发现只会让代码变得很臃肿,所以,我们发现IAnimal这个接口的定义太大了,我们需要根据不同的行为进行二次拆分。
拆分之后的结果如下:


//所有的动物都会吃东西
public interface IAnimal {
void eat();
}
//专注飞翔的接口
public interface IFlyAnimal {
void fly();
}
//专注游泳的接口
public interface ISwimAnimal {
void swim();
}

那如果现在有一只鸭子和百灵鸟,咱们分别去实现的时候如下:


public class Duck implements IAnimal,ISwimAnimal{
@Override
public void eat() {
System.out.println("鸭子吃食");
}

@Override
public void swim() {
System.out.println("鸭子在河里游泳");
}
}

public class Lark implements IAnimal,IFlyAnimal{
@Override
public void eat() {
System.out.println("百灵鸟吃食");
}

@Override
public void fly() {
System.out.println("百灵鸟会飞");
}
}

我们可以看到,这样在我们具体的实现类中就不会存在空方法的情况,代码随着业务的发展也不会变得过于臃肿。
咱们看一下最终的类图。


ISP.png


迪米特原则


迪米特原则(Law of Demeter,简称 LoD),指的是一个对象应该对其他对象保持最少的了解,如果上面这个原则名称不容易记,其实这种设计原则还有两外一个名称,叫做最少知道原则(Least Knowledge Principle,简称LKP)。其实主要强调的也是降低类和类之间的耦合度,白话“不要和陌生人说话”,或者也可以理解成“让专业的人去做专业的事情”,出现在成员变量,方法输入、输出参数中的类都可以称为成员朋友类,而出现在方法体内部的类不属于朋友类。


通过具体场景的例子来看一下。
由于小猫接手了商城类的业务,目前他对业务的实现细节应该是最清楚的,所以领导在向老板汇报相关SKU销售情况的时候总是会找到小猫去统计各个品类的sku的销售额以及销售量。于是就有了领导下命令,小猫去做统计的业务流程。


//sku商品
public class Sku {
private BigDecimal price;
public BigDecimal getPrice() {
return price;
}

public void setPrice(BigDecimal price) {
this.price = price;
}
}

//小猫统计总sku数量以及总销售金额
public class Kitty {
public void doSkuCheck(List skuList) {
BigDecimal totalSaleAmount =
skuList.stream().map(sku -> sku.getPrice()).reduce(BigDecimal::add).get();
System.out.println("总sku数量:" + skuList.size() + "sku总销售金额:" + totalSaleAmount);
}
}

//领导让小猫去统计各个品类的商品
public class Leader {
public void checkSku(Kitty kitty) {
//模拟领导指定的各个品类
List difCategorySkuList = new ArrayList<>();
kitty.doSkuCheck(difCategorySkuList);
}
}

//测试类
public class LodTest {
public static void main(String[] args) {
Leader leader = new Leader();
Kitty kitty = new Kitty();
leader.checkSku(kitty);
}
}


从上面的例子来看,领导其实并没有参与统计的任何事情,他只是指定了品类让小猫去统计。从而降低了类和类之间的耦合。即“让专门的人做专门的事”


我们看一下最终的类图。


LOD.png


里氏替换原则


里氏替换原则(Liskov Substitution Principle,英文简称:LSP),它由芭芭拉·利斯科夫(Barbara Liskov)在1988年提出。里氏替换原则的含义是:如果一个程序中所有使用基类的地方都可以用其子类来替换,而程序的行为没有发生变化,那么这个子类就遵守了里氏替换原则。换句话说,一个子类应该可以完全替代它的父类,并且保持程序的正确性和一致性。


上述的定义还是比较抽象的,老猫试着重新理解一下,



  1. 子类可以实现父类的抽象方法,但是不能覆盖父类的抽象方法。
  2. 子类可以增加自己特有的方法。
  3. 当子类的方法重载父类的方法的时,方法的前置条件(即方法的输入/入参)要比父类方法的输入参数更加宽松。
  4. 当子类的方法实现父类的方法时,方法的后置条件比父类更严格或者和父类一样。

里氏替换原则准确来说是上述提到的开闭原则的实现方式,但是它克服了继承中重写父类造成的可复用性变差的缺点。它是动作正确性的保证。即类的扩展不会给已有的系统引入新的错误,降低了代码出错的可能性。


下面咱们用里式替换原则比较经典的例子来说明“鸵鸟不是鸟”。我们看一下咱们印象中的鸟类:


class Bird {
double flySpeed;

//设置飞行速度
public void setSpeed(double speed) {
flySpeed = speed;
}

//计算飞行所需要的时间
public double getFlyTime(double distance) {
return (distance / flySpeed);
}
}
//燕子
public class Swallow extends Bird{
}
//由于鸵鸟不能飞,所以我们将鸵鸟的速度设置为0
public class Ostrich extends Bird {
public void setSpeed(double speed) {
flySpeed = 0;
}
}

光看这个实现的时候好像没有问题,但是我们调用其方法计算其指定距离飞行时间的时候,那么这个时候就有问题了,如下:


public class TestMain {
public static void main(String[] args) {
double distance = 120;
Ostrich ostrich = new Ostrich();
System.out.println(ostrich.getFlyTime(distance));

Swallow swallow = new Swallow();
swallow.setSpeed(30);
System.out.println(swallow.getFlyTime(distance));
}
}

结果输出:


Infinity
4.0

显然鸵鸟出问题了,



  1. 鸵鸟重写了鸟类的 setSpeed(double speed) 方法,这违背了里氏替换原则。
  2. 燕子和鸵鸟都是鸟类,但是父类抽取的共性有问题,鸵鸟的飞行不是正常鸟类的功能,需要特殊处理,应该抽取更加共性的功能。

于是我们进行对其进行优化,咱们取消鸵鸟原来的继承关系,定义鸟和鸵鸟的更一般的父类,如动物类,它们都有奔跑的能力。鸵鸟的飞行速度虽然为 0,但奔跑速度不为 0,可以计算出其奔跑指定距离所要花费的时间。优化之后代码如下:


//抽象出更高层次的动物类,定义内部的奔跑行为
public class Animal {
double runSpeed;

//设置奔跑速度
public void setSpeed(double speed) {
runSpeed = speed;
}
//计算奔跑所需要的时间
public double getRunTime(double distance) {
return (distance / runSpeed);
}
}
//定义飞行的鸟类
public class Bird extends Animal {
double flySpeed;
//设置飞行速度
public void setSpeed(double speed) {
flySpeed = speed;
}
//计算飞行所需要的时间
public double getFlyTime(double distance) {
return (distance / flySpeed);
}
}
//此时鸵鸟直接继承动物接口
public class Ostrich extends Animal {
}
//燕子继承普通的鸟类接口
public class Swallow extends Bird {
}

简单测试一下:


public class TestMain {
public static void main(String[] args) {
double distance = 120;
Ostrich ostrich = new Ostrich();
ostrich.setSpeed(40);
System.out.println(ostrich.getRunTime(distance));

Swallow swallow = new Swallow();
swallow.setSpeed(30);
System.out.println(swallow.getFlyTime(distance));
}
}

结果输出:


3.0
4.0

优化之后,优点:



  1. 代码共享,减少创建类的工作量,每个子类都拥有父类的方法和属性;
  2. 提高代码的重用性;
  3. 提高代码的可扩展性;
  4. 提高产品或项目的开放性;

缺点:



  1. 继承是侵入性的。只要继承,就必须拥有父类的所有属性和方法;
  2. 降低代码的灵活性。子类必须拥有父类的属性和方法,让子类自由的世界中多了些约束;
  3. 增强了耦合性。当父类的常量、变量和方法被修改时,需要考虑子类的修改,而且在缺乏规范的环境下,这种修改可能带来非常糟糕的结果————大段的代码需要重构。

最终我们看一下类图:


LSP.png


老猫觉得里氏替换原则是最难把握好的,所以到后续咱们再进行深入涉及模式回归的时候再做深入探究。


合成复用原则


合成复用原则(Composite/Aggregate Reuse Principle,英文简称CARP)是指咱们尽量要使用对象组合而不是继承关系达到软件复用的目的。这样的话系统就可以变得更加灵活,同时也降低了类和类之间的耦合度。


看个例子,当我们刚学java的时候都是从jdbc开始学起来的。所以对于DBConnection我们并不陌生。那当我们实现基本产品Dao层的时候,我们就有了如下写法:


public class DBConnection {
public String getConnection(){
return "获取数据库链接";
}
}
//基础产品dao层
public class ProductDao {
private DBConnection dbConnection;

public ProductDao(DBConnection dbConnection) {
this.dbConnection = dbConnection;
}

public void saveProduct(){
String conn = dbConnection.getConnection();
System.out.println("使用"+conn+"新增商品");
}
}

上述就是最简单的合成服用原则应用场景。但是这里有个问题,DBConnection目前只支持mysql一种连接DB的方式,显然不合理,有很多企业其实还需要支持Oracle数据库链接,所以为了符合之前说到的开闭原则,我们让DBConnection交给子类去实现。于是我们可以将其定义成抽象方法。


public abstract class DBConnection {
public abstract String getConnection();
}
//mysql链接
public class MySqlConnection extends DBConnection{
@Override
public String getConnection() {
return "获取mysql链接";
}
}
//oracle链接
public class OracleConnection extends DBConnection{
@Override
public String getConnection() {
return "获取Oracle链接方式";
}
}

最终的实现方式我们一起看一下类图。


CARP.png


总结


之前看过一个故事,一栋楼的破败往往从一扇破窗户开始,慢慢腐朽。其实代码的腐烂其实也是一样,往往是一段拓展性极差的代码开始。所以这要求我们研发人员还是得心中有杆“设计原则”的秤,咱们可能不会去做刻意的代码设计,但是相信有这么一杆原则的秤,代码也不致于会写得太烂。


当然我们也不要刻意去追求设计原则,要权衡具体的场景做出合理的取舍。
设计原则是设计模式的基础,相信大家在了解完设计原则之后对后续的设计模式会有更加深刻的理解。


作者:程序员老猫
来源:juejin.cn/post/7332858431572049947

0 个评论

要回复文章请先登录注册