21 | 开放封闭原则:不改代码怎么写新功能?

你好!我是郑晔。

上一讲,我们讲了一个最基础的设计原则:单一职责原则,从这个原则中,你知道了一个模块只应该包含来自同一个变化来源的内容。这一讲,我们来看下一个设计原则:开放封闭原则。

作为一名程序员,来了一个需求就要改一次代码,这种方式我们已经见怪不怪了,甚至已经变成了一种下意识的反应。修改也很容易,只要我们按照之前的惯例如法炮制就好了。

这是一种不费脑子的做法,却伴随着长期的伤害。每人每次都只改了一点点,但是,经过长期积累,再来一个新的需求,改动量就要很大了。而在这个过程中,每个人都很无辜,因为每个人都只是遵照惯例在修改。但结果是,所有人都受到了伤害,代码越来越难以维护。

既然“修改”会带来这么多问题,那我们可以不修改吗?开放封闭原则就提供了这样的一个新方向。

不修改代码

开放封闭原则是这样表述的:

软件实体(类、模块、函数)应该对扩展开放,对修改封闭。

这个说法是Bertrand Meyer在其著作《面向对象软件构造》(Object-Oriented Software Construction)中提出来的,它给软件设计提出了一个极高的要求:不修改代码。

或许你想问,不修改代码,那我怎么实现新的需求呢?答案就是靠扩展。用更通俗的话来解释,就是新需求应该用新代码实现。

开放封闭原则向我们描述的是一个结果,就是我们可以不修改代码而仅凭扩展就完成新功能。但是,这个结果的前提是要在软件内部留好扩展点,而这正是需要我们去设计的地方。因为每一个扩展点都是一个需要设计的模型。

举个例子,假如我们正在开发一个酒店预订系统,针对不同的用户,我们需要计算出不同的房价。比如,普通用户是全价,金卡是8折,银卡是9折,代码写出来可能是这样的:

class HotelService {
  public double getRoomPrice(final User user, final Room room) {
    double price = room.getPrice();
    if (user.getLevel() == Level.GOLD) {
      return price * 0.8;
    }
    
    if (user.getLevel() == Level.SILVER) {
      return price * 0.9;
    }
    
    return price;
  }
}

这时,新的需求来了,要增加白金卡会员,给出75折的优惠,如法炮制的写法应该是这样的:

class HotelService {
  public double getRoomPrice(final User user, final Room room) {
    double price = room.getPrice();
    if (user.getLevel() == UserLevel.GOLD) {
      return price * 0.8;
    }
    
    if (user.getLevel() == UserLevel.SILVER) {
      return price * 0.9;
    }
    
    if (user.getLevel() == UserLevel.PLATINUM) {
      return price * 0.75;
    }
    
    return price;
  }
}

显然,这种做法就是修改代码的做法,每增加一个新的类型就要修改一次代码。但是,一个有各种级别用户的酒店系统肯定不只是房价有区别,提供的服务也可能有区别。可想而知,每增加一个用户级别,我们要改的代码就漫山遍野。

那应该怎么办呢?我们应该考虑如何把它设计成一个可以扩展的模型。在这个例子里面,既然每次要增加的是用户级别,而且各种服务的差异都体现在用户级别上,我们就需要一个用户级别的模型。在前面的代码里,用户级别只是一个简单的枚举,我们可以给它丰富一下:

interface UserLevel {
  double getRoomPrice(Room room);
}

class GoldUserLevel implements UserLevel {
  public double getRoomPrice(final Room room) {
    return room.getPrice() * 0.8;
  }
}

class SilverUserLevel implements UserLevel {
  public double getRoomPrice(final Room room) {
    return room.getPrice() * 0.9;
  }
}

我们原来的代码就可以变成这样:

class HotelService {
  public double getRoomPrice(final User user, final Room room) {
    return user.getRoomPrice(room);
  }
}

class User {
  private UserLevel level;
  ...
  
  public double getRoomPrice(final Room room) {
    return level.getRoomPrice(room);
  }
}

这样一来,再增加白金用户,我们只要写一个新的类就好了:

class PlatinumUserLevel implements UserLevel {
  public double getRoomPrice(final Room room) {
    return room.getPrice() * 0.75;
  }

之所以我们可以这么做,是因为我们在代码里留好了扩展点:UserLevel。在这里,我们把原来的只支持枚举值的UserLevel升级成了一个有行为的UserLevel。

经过这番改造,HotelService的getRoomPrice这个方法就稳定了下来,我们就不需要根据用户级别不断地调整这个方法了。至此,我们就拥有了一个稳定的构造块,可以在后期的工作中把它当做一个稳定的模块来使用。

当然,在这个例子里,这个方法是比较简单的。而在实际的项目中,业务方法都会比较复杂。

构建扩展点

好,现在我们已经对开放封闭原则有了一个基本的认识。其实,我们都知道修改是不好的,道理我们都懂,就是在代码层面,有人就糊涂了。我做个类比你就知道了,比如说,如果我问你,你正在开发的系统有问题吗?相信大部人的答案都是有。

那我又问你,那你会经常性主动调整它吗?大部人都不会。为什么呢?因为它在线上运行得好好的,万一我调整它,调整坏了怎么办。是啊!你看,道理就是这么个道理,放在系统层面人人都懂,而在代码层面,却总是习惯性被忽视。

所以,我们写软件就应该提供一个又一个稳定的小模块,然后,将它们组合起来。一个经常变动的模块必然是不稳定的,用它去构造更大的模块,就是将隐患深埋其中。

你可能会说,嗯,我懂了,可我还是做不好啊!为什么我们懂了道理后,依旧过不好“这一关”呢?因为阻碍程序员们构造出稳定模块的障碍,其实是构建模型的能力。你可以回顾一下前面那段代码,看看让这段代码产生变化的UserLevel是如何升级成一个有行为的UserLevel的。

在讲封装的时候,我说过,封装的要点是行为,数据只是实现细节,而很多人习惯性的写法是面向数据的,这也是导致很多人在设计上缺乏扩展性思考的一个重要原因。

构建模型的难点,首先在于分离关注点,这个我们之前说过很多次了,不再赘述,其次在于找到共性

在多态那一讲,我们说过,要构建起抽象就要找到事物的共同点,有了这个理解,我们看前面的例子应该还算容易理解。而在一个业务处理的过程中,发现共性这件事对很多人来说就已经开始有难度了。

我们再来看个例子,下面是一个常见的报表服务,首先我们取出当天的订单,然后生成订单的统计报表,还要把统计结果发送给相关的人等:

class ReportService {
  public void process() {
    // 获取当天的订单
    List<Order> orders = fetchDailyOrders();
    // 生成统计信息
    OrderStatistics statistics = generateOrderStatistics(orders);
    // 生成统计报表
    generateStatisticsReport(statistics);
    // 发送统计邮件
    sendStatisticsByMail(statistics);
  }
}

很多人在日常工作中写出的代码都是与此类似的,但这个流程肯定是比较僵化的。出现一个新需求就需要调整这段代码。我们这就有一个新需求,把统计信息发给另外一个内部系统,这个内部系统可以把统计信息展示出来,供外部合作伙伴查阅。该怎么做呢?

我们先分析一下,发送给另一个系统的内容是统计信息,在原有的代码里,前面两步分别是获取源数据和生成统计信息,后面两步分别是,生成报表和将统计信息通过邮件发送出去。

也就是说,后两步和即将添加的步骤有一个共同点,都使用了统计信息,这样我们就找到了它们的共性,所以,我们就可以用一个共同的模型去涵盖它们,比如,OrderStatisticsConsumer:

interface OrderStatisticsConsumer {
  void consume(OrderStatistics statistics);
}

class StatisticsReporter implements OrderStatisticsConsumer {
  public void consume(OrderStatistics statistics) {
    generateStatisticsReport(statistics);
  }
}

class StatisticsByMailer implements OrderStatisticsConsumer {
  public void consume(OrderStatistics statistics) {
    sendStatisticsByMail(statistics);
  }
}

class ReportService {
  private List<OrderStatisticsConsumer> consumers;
  
  void process() {
    // 获取当天的订单
    List<Order> orders = fetchDailyOrders();
    // 生成统计信息
    OrderStatistics statistics = generateOrderStatistics(orders);
    
    for (OrderStatisticsConsumer consumer: consumers) {
        consumer.consume(statistics);
    }
  }
}

如此一来,我们的新需求也只要添加一个新的类就可以实现了:

class StatisticsSender implements OrderStatisticsConsumer {
  public void consume(final OrderStatistics statistics) {
    sendStatisticsToOtherSystem(statistics);
  }
}

你能看出来,在这个例子里,我们第一步做的事情还是分解,就是把一个一个的步骤分开,然后找出步骤之间相似的地方,由此构建出一个新的模型。

真实项目里的代码可能比这个代码要复杂,但其实,并不一定是业务逻辑复杂,而是代码本身写得复杂了。所以,我们要先根据上一讲的单一职责原则,将不同需求来源引起的变动拆分到不同的方法里,形成一个又一个的小单元,再来做我们这里的分析。

通过这个例子你也可以看出,在真实的项目中,想要达到开放封闭原则的要求并不是一蹴而就的。这里我们只是因为有了需求的变动,才提取出一个OrderStatisticsConsumer。

未来可能还会有其他的变动,比如,生成报表的逻辑。到那时,也许我们还会提取出一个新的OrderStatisticsGenerator的接口。但总的来说,我们每做一次这种模型构建,最核心的类就会朝着稳定的方向迈进一步。

所以,好的设计都会提供足够的扩展点给新功能去扩展。在《Unix 编程艺术》一书中,Unix编程就提倡“提供机制,而不是策略”,这就是开放封闭原则的一种体现。

同样的,我们知道很多系统是有插件机制的,比如,很多人使用的VIM和Emacs,离我们比较近的还有Eclipse和Visual Studio Code,它们都体现着开放封闭原则。去了解它们的接口,我们就可以看到这个软件给我们提供的各种能力,这也是一种很好的学习方式。

开放封闭原则还可以帮助我们改进自己的系统,我们可以通过查看自己的源码控制系统,找出那些最经常变动的文件,它们通常都是没有满足开放封闭原则的,而这可以成为我们改进系统的起点。

总结时刻

今天,我们讲了开放封闭原则,软件实体应该对扩展开放,对修改封闭。简单地说,就是不要修改代码,新的功能要用新的代码实现。

其实,道理大家都懂,但对很多人来说,做到是有难度的,尤其是在代码里留下扩展点,往往是需要有一定设计能力的。而构建模型的难点,首先就在于分离关注点,其次是找到共性。今天我们也讲了在一个真实项目中,怎样逐步地去构建扩展点,让系统稳定下来。

很多优秀的软件在设计上都给我们提供了足够的扩展能力,向这些软件的接口学习,我们可以学到更多的东西。

如果说单一职责原则主要看的还是封装,开放封闭原则就必须有多态参与其中了。显然,要想提供扩展点,就需要面向接口编程。但是,是不是有了接口,就是好的设计了呢?下一讲,我们来看设计一个接口还需要满足什么样的原则。

如果今天的内容你只能记住一件事,那请记住:设计扩展点,迈向开放封闭原则

思考题

最后,我想请你找一个提供了扩展点的开源项目,分析一下它是如何设计这个扩展点的。欢迎在留言区写下你的想法。

感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。

精选留言

  • 业余爱好者

    2020-07-15 08:23:55

    第一个案例感觉就是把user类改成了充血模型,这样确实合理一些,因为价格生成策略因用户不同而不同,同时又加入userlevel类,这样就更职责单一了。


    第二个案例从方法命名上就可以看出职责不单一了,连原作者都不知道这个方法干了什么,只好叫“process”这么泛泛的名字。

    Unix那个,是“提供策略,而不是机制”吧,可能记错了,一直搞不清机制和策略是什么意思。
    作者回复

    去看看《Unix 编程艺术》,非常值得读的一本好书。

    2020-07-16 09:52:43

  • 桃子-夏勇杰

    2020-07-28 06:46:40

    软件系统是变与不变的交融艺术,变化带来发展,不变的是本质,是共性。没有不变的变化只是绚丽的海市蜃楼,透过变化抓住不变,才是抓住了核心与要义。
    作者回复

    写出了一种诗意。

    2020-07-28 07:48:23

  • liliumss

    2020-07-19 09:46:01

    DDD的思路我觉得比较适合做,难就难到领域建模
    作者回复

    DDD只能帮助你把骨架建起来,其中的细节,还是需要遵循着设计原则进行调整。

    2020-07-20 07:38:06

  • Being

    2020-07-16 23:38:49

    可以简单说下我们公司GIS平台的框架,也是插件的扩展机制。比如对于不同文件的格式解析和保存,抽象出DataSource模型和Saver模型,作为一类数据源注册进插件模块来扩展,而框架则提供类似驱动的能力,由用户组合需要的数据源放入驱动,然后通过驱动,来获得按流程处理后的文件导出。
    作者回复

    很好的分享!

    2020-07-19 09:03:07

  • 阳仔

    2020-07-15 09:22:46

    1、识别修改点,构建模型,将原来静态的逻辑转为动态的逻辑
    2、构建模型的难点在于分离关注点,其次就是
    找到共性
    作者回复

    非常好的总结!

    2020-07-15 11:35:17

  • 2020-11-01 10:39:48

    看了两遍 觉得这个开闭原则和函数式编程很搭啊😂😂
    作者回复

    别说,在不改动这件事上,还真的是异曲同工。

    2020-11-01 22:38:17

  • 秃然的自我~

    2021-02-03 12:52:08

    那么用户等级和不同级别的类如何对应起来,用if else吗?
    作者回复

    工厂模式

    2021-02-03 16:20:45

  • PM2

    2020-08-07 11:54:40

    java的SPI给开发者提供了不错的扩展机制,像spring boot 和dubbo就在此基础上做了改进,各自提供了扩展点,spring boot允许用户自定义starter,dubbo可以自定义协议等
    作者回复

    很好的分享

    2020-08-08 09:11:46

  • 赵越

    2021-05-11 09:59:44

    开闭原则之前一直没理解,就在于这个名字翻译过来一点也不直观。今天才意识到应该这么说:“就是不要修改代码,新的功能要用新的代码实现。”。
    作者回复

    找到关键点了

    2021-05-12 08:15:24

  • escray

    2020-09-26 23:08:35

    相对于“对扩展开放,对修改封闭”,我觉的“新需求应该用新代码实现”更容易理解一些。

    在酒店预订系统的例子里面,如果采用枚举方式的 UserLevel 是否可行?又或者采用配置文件的方式,应该也算是对于“扩展开放”。

    如果 UserLevel 只和房价的折扣有关的话,那么应该没问题,如果还有其他的相关选项,可能会麻烦一些。

    文中说“很多人习惯性的写法是面向数据的”,这个估计也是面向数据库编程的由来。

    在第二个例子中,有一个小细节,StatisticsSender 类里面,consume 的参数带了 final 修饰,而之前定义的时候并没有,我觉的应该是前面的代码里面漏掉了吧,毕竟这里的不希望对 statistics 进行任何修改。

    看到留言里面有人说 process 的函数名不好,有同感,但是也想不出什么更好的名字来,reportProcess 也许好一点?

    在代码里留扩展点,可能需要对于业务比较熟悉,知道哪里有可能变化或者扩展;如果缺乏相关知识的话,就只能等需求出来以后再考虑了。比较极端的情况,到处留扩展点,但是最终却没有用到,可能也算过度设计。

    看到 Uncle Bob 的一篇文章,从开闭原则中可以推导出一些 OOD 的其他习惯用法,比如:所有的成员变量都是私有的(private),不使用全局变量,运行时类型识别 RTTI 有风险
  • hph

    2020-09-04 10:35:38

    在开发一个产品,在基于OPCUA数据采集的基础上,实现数据记录和输出,从功能上看比较简单,但是写代码的时候就有很多的扩展点需要构建模型(比如目标数据库,有sqlserver,oracle等, 条件的配置有时间触发,订阅变量的脚本触发等),由于只有一个人搞,时间就比较长,就经常被催,,,之前我总是说有代码很多,很多模块需要设计,其实就是设计扩展点(之前总是表达不出这个意思),因为一个扩展点的设计不光是接口的设计、每个实现类的编写和加载哪些扩展的定义,在完整的系统中还要有买个扩展的配置界面,以及在配置界面上也是通过扩展的方式将其配置的UI呈现,还有这些扩展的配置结果的工程文件的定义等,催的蛋疼。。。
  • 呆呆狗的兽

    2021-07-19 10:47:26

    上面例子中UserLevel那个例子,可以用枚举呀,枚举里写抽象方法,各自等级再去实现,不然新建类太浪费了,比如用户等级有上十个左右,什么公候伯子男爵等,等那天每个等级特定的相关逻辑多起来的时候 比如大于3个功能(方法)的时候,可以考虑重构为接口和实现类,那时候因为功能多起来了逻辑也更丰富(复杂)了,是值得的 否则直接枚举写方法搞定,而且很重要的一点是userLevel使用的时候还是要创建对象的,而枚举自身在项目启动时已经每一项都是对象了(当然枚举对象的大括号里要是写成员变量啥的,得考虑并发情况)
  • 第一装甲集群司令克莱斯特

    2020-12-15 19:39:13

    不是不让改代码,是不建议无设计的一团麻if else改代码,类内部膨胀,可读性,可维护性都不高。绝不再在类内部写硬代码,常量都抽取到配置文件去读取,拒绝硬代码!
    作者回复

    改能改得好,才是有价值的。

    2020-12-18 08:38:13

  • Janenesome

    2020-10-23 15:55:26

    找出变与不变、变的部分抽象出共性。

    我之前使用多态优化,最好找的就是各种 if-else 条件这个优化点。文章第二个案例的确很实用,因为我现在的代码就是这样的哈哈,以后看到同样的入参就的想下是不是能抽象出来。
  • 石马

    2020-08-04 23:22:21

    机制与策略的关系,在我的理解来看,机制就像是车的离合和油门,而策略则是不同的驾驶方式,配合就起来可以完成不同的飘移效果
    作者回复

    机制应该是引擎吧?

    2020-08-05 17:42:10

  • giteebravo

    2020-07-23 20:01:06


    第一个例子不太好理解,
    第二个就相对容易多了
  • Stay_Gold

    2025-01-17 17:19:40

    对应于《修改代码艺术》里面的,就是就是要找到接缝,这个是最重要的,合适的接缝能大大降低代码的修改难度。
  • 6点无痛早起学习的和尚

    2023-09-22 08:50:17

    看到这一节内容,想到了最近自己工作的一个代码,完成的逻辑就是:开户成功需要做一些流程:发送短信、发送邮件、发送 mq 通知别人,自己的代码就是:一个方法里写 sendSms();sendEmail();sendMq();如果当需要新增一个发送 xx,我就需要在这个方法里做修改,所以这里就是一个修改点
    看到这个内容之后,我就可以优化一个 send 接口,然后扩展点就是去实现这个 send 接口做不同的操作,思路来源于老师的案例 2.
  • KK

    2022-05-05 05:42:18

    比如说spring boot的自动配置机制: 只需要在spring.factories文件中配置好配置类, 就可以在模块被引入时做好自动配置.
  • 小凯

    2022-04-25 23:41:20

    第二个例子引入接口后的client代码能够实现和旧代码一样的功能吗?
    client直接调用consume方法会执行 每一个实现类中的consume方法吗?🤔