物件导向设计原则:开放封闭原则,定义、解析与实践

系列文章

浅谈物件导向 SOLID 原则对工程师的好处与如何影响能力再谈 SOLID 原则,Why SOLID?物件导向设计原则:单一职责原则,定义、解析与实践物件导向设计原则:开放封闭原则,定义、解析与实践

开放封闭原则(Open-Closed Principle)

定义:

Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.
--
软体中的类别、模组、函式等等应该开放扩充,但是封闭修改。

白话版本为:

当系统需要扩充功能时,应该藉由 增加新的程式码 来扩充系统的功能,而 不是藉由修改原本已经存在的程式码 来扩充系统的功能。


开放封闭原则为软体开发的 首要原则,很多软体开发原则都是建构在这短短一句话之上,因此可以通过此原则引伸出其他原则。很多时候一个程式具有良好的设计,往往说明它是符合开放封闭原则。

目的

隔离业务逻辑与附加逻辑,使业务逻辑更易于扩充,以便因应需求变化。

解析

什么是业务逻辑?附加逻辑?

一个系统总有几个极具价值的核心逻辑,这些核心逻辑实现了企业或专案的业务规则(Business Rule)与 Know How。通常可以从核心逻辑延伸出更多功能,提供使用者的便利性,以下将这些核心业务逻辑简称为「业务逻辑」。也就是说系统中有可能 20% 是业务逻辑,剩下的 80% 是围绕着业务逻辑延伸出来的附加逻辑。

举例来说,一个诊所挂号系统一开始只有「挂号与叫号」功能。但若需要的话,也可以延伸出「叫号时发送简讯提醒患者」功能。挂号系统的案例中业务逻辑是「挂号与叫号」;而「叫号时发送简讯提醒患者」则是 随着时间与新需求延伸出来的附加逻辑。

为什么要隔离 业务逻辑 与 附加逻辑?

和软体複杂的特质 软体熵(Software entropy) 有关,指系统在经过修改后,程式码的无序程度(意图流失程度)与複杂程度皆会上昇。

需求变更和除错是系统修改的主因,系统会随着时间不断衍生出新需求。这些需求可能是工程浩大的新功能;也可能是为了某个特定案例只使用一次的需求。甚至客户往往在看见实际功能后,才想到有更好的解决方案或缺少哪些细项。于是刚释出的功能马上又进入重工(Rework)阶段。

若开发人员不懂得将业务逻辑与附加逻辑分开,往往为了完成新需求,把附加逻辑写在业务逻辑里面,替业务逻辑扩充行为。这种做法一但遇到需求不停出现时,业务逻辑 与 附加逻辑 会渐渐地糊在一起变成一个大泥团导致程式脆弱化。新增需求和除错更容易引入新的 Bug,解决新的 Bug 又引入更新的 Bug...。

图一:开发人员将附加逻辑写在业务逻辑里面,以扩充业务逻辑的行为来完成新需求。

(图一)中的程式码在专案中随处可见,当 附加逻辑 与 业务逻辑 耦合在一起时,业务逻辑 会变得很难除错、重複使用以及扩充,这些因素都会拉长开发时程,增加维护系统的成本。

因此开发人员应该要有个认知:

虽然需求并不是程式设计环节能控制的,但是程式码应该要能够适应快速多变的需求。

业务逻辑本身只需要关心业务规则(Business Rule),不应该和附加逻辑耦合在一起。一定要隔离业务逻辑与附加逻辑,才能确保业务逻辑的弹性。一旦业务逻辑有了弹性,程式就较容易面对需求变化。

开放扩充点,由外部注入附加逻辑

新需求不断出现,修改业务逻辑来扩充附加功能却会促进 软体熵 成长,增加维护系统的困难度。为了避免 软体熵 的问题,开放封闭原则指导开发人员在面对需求变化时应该要:

尽可能减少对既有程式码的修改,并开放扩充点,让新需求可以从外部扩充业务逻辑。

实际上 开放封闭原则的设计思维 早在物件导向技术出现之前就存在,并且被广泛应用在各种层面,从程式设计乃至框架、系统层级:

程式设计层面:jQuery ajax

透过 $.ajax 的 done, fail, always 等公开函式从外部注入闭包,扩充 $.ajax 行为:

$.ajax({  method: "POST",  url: "some.php",  data: { name: "John", location: "Boston" }})  .done(function() {    alert("success");  })  .fail(function() {    alert("error");  })   .always(function() {    alert("complete");  });

框架层面:Laravel Controller

透过继承 MVC 框架内建的 Controller 类别,扩充 Controller 层的行为:

<?phpnamespace App\Http\Controllers;use Illuminate\Http\Request;class HelloController extends Controller{    public function index(Request $request){        return 'Hello World!';    }}

框架层面:React.js

透过继承 React.Component 类别,扩充 Component 的行为:

class Welcome extends React.Component {  render() {    return <h1>Hello, {this.props.name}</h1>;  }}

其他範例:

JavaScript 透过注册 event 事件,扩充浏览器行为。浏览器透过安装扩充套件,扩充浏览器行为。手机透过安装 APP,扩充手机 OS 行为。...

上述这些耳熟能详的範例中,每个技术都被应用到成千上万个不同的需求。这些高弹性技术的共通点是:至少有一个开放的扩充点,让开发人员可以写入自己的逻辑来完成功能。

开放封闭原则 让开发人员不需要修改已经造好的轮子,就可以完成自己所需的功能。

这也是为什么软体技术能够以海量增长的原因。但是开放封闭原则的原理是什么呢?

原理:利用抽象隔离不相关的程式

解除耦合的方法,就是让程式码不知道彼此的存在。

程式码可以透过继承、引入介面或注入闭包等技术,让附加逻辑可以”共用公开的介面“。业务逻辑在需要扩充的时机,则须透过 统一的公开介面 来调用附加逻辑。

图二:在业务逻辑与附加逻辑之间引入抽象

这其实是利用 多型的特性,在业务逻辑和附加逻辑之间引入一个抽象(继承、介面、闭包等):

对业务逻辑来说,原本写死在业务逻辑里面的附加逻辑将被 抽象的变数 取代。只有等程式码运行中,藉由 当时实作抽象介面的实体(类别、闭包) 来决定附加逻辑的行为。对附加逻辑来说,只需要按照 抽象介面 的定义,实作完成新需求所需的程式。最后注入业务逻辑中,以便扩充业务逻辑。

找出业务逻辑与附加逻辑的边界

图三:业务逻辑与不相关的逻辑应该要有边界隔离彼此

开发人员必须懂得如何找出业务逻辑与附加逻辑的边界,才能从中开放扩充点引入抽象隔离彼此。

简单有效的方法是,把重要与不重要的事情分开。例如 UI 介面所需的逻辑与业务规则无关,所以它们之间应该要有一个边界。也可以 已变化为轴的地方 绘製边界,边界另一侧的元件将以不同的速率以及不同的原因改变:

附加逻辑 与 业务逻辑 相比,彼此在不同的时间以不同的速率改变,因此它们之间应该有个边界;附加逻辑 与 其他附加逻辑 相比,每个附加逻辑都在不同的时间和不同的原因改变,所以它们之间应该也要有边界。

说到底,其实一直都是 单一职责原则 指导我们应该如何切割边界。

图四:业务逻辑与附加逻辑之间,只能透过抽象介面与彼此互动

引入抽象后,业务逻辑与附加逻辑 只能透过抽象介面与彼此互动。如此一来,业务逻辑可以专注于本身的业务规则(Business Rule),而附加逻辑则可以随时被多个不同的实作替换掉,并且业务逻辑完全不需要关心这些事。

一但建立起开放封闭原则的架构(图四),就能拥有一个安全的防火墙。程式码之间的变动不会传播出去。附加逻辑的变动不会影响到业务逻辑。

事实上,软体开发技术的历史就是「如何方便地建立 Plugin 来奠定可扩展和可维护的系统架构」的故事 - Uncle Bob. 《Clean Architecture》

实践:每日信件功能

从原理中可以发现,开放封闭原则能够解除业务逻辑与附加逻辑之间的耦合,并且保持业务逻辑的弹性。接下来将透过一个「每日信件功能」的案例,讲解如何让开放封闭原则落地。


某校园系统中,有一个寄信排程会在每天凌晨寄送「每日信件」,最初的需求为:

1. 最初需求:寄送使用者昨天收到的系统通知。

class Send_today_mail extends MX_Controller{    public function index()    {        /** 1. 捞取信件的内容,并产生信件 HTML */        // 取得所有使用者昨天收到的系统通知        $system_notifies = $this->notify_api->get_yesterday_notify();        // 依照收件者的 email 分群通知讯息        $system_notifies = $this->group_system_notify_by_email($system_notifies);        // 产生信件 HTML 内容        $mail_contents = $this->make_mail_contents($system_notifies);        /** 2. 寄送信件 */        $this->send_mail($mail_contents);    }    /** 建立系统通知信件 */    private function get_yesterday_notify() {/** ... */}    private function group_system_notify_by_email($system_notifies) {/** ... */}    private function make_notifies_template_variables($notifies, $tplVar = array()) {/** ... */}    private function make_mail_contents($system_notifies){/** ... */}    private function send_mail($mail_contents) {/** ... */}}

第一版本的程式码中可以看见寄信功能主要分两个部分:

捞取信件的内容,并产生信件 HTML寄送信件

Send_today_mail 的最初版本中,总共只有 93 行程式码。

2. 第二需求:寄送使用者昨日收到的 Messenger 讯息

class Send_today_mail extends MX_Controller{    public function index()    {        /** 1. 捞取信件的内容,并产生信件 HTML */        // 取得所有使用者昨天收到的系统通知        $system_notifies = $this->notify_api->get_yesterday_notify();        // 依照收件者的 email 分群通知讯息        $system_notifies = $this->group_system_notify_by_email($system_notifies);        // 取得 Messenger 使用者、对话群组 id        list($message_users, $group_ids) = $this->message_api->get_all_message_users();        // 取得昨日的 Messages        $messages = $this->get_yesterday_message($group_ids);        // 产生信件 HTML 内容        $mail_contents = $this->make_mail_contents($system_notifies, $messages, $message_users);                /** 2. 寄送信件 */        $this->send_mail($mail_contents);    }        /** 建立系统通知信件 */    private function get_yesterday_notify() {/** ... */}    private function group_system_notify_by_email($system_notifies) {/** ... */}    private function make_notifies_template_variables($notifies, $tplVar = array()) {/** ... */}    /** 建立 Messenger 讯息信件 */    private function get_yesterday_message() {/** ... */}    private function message_filter($messages, $group_id) {/** ... */}    private function make_message_template_variables($messages, $message_users, $tplVar) {/** ... */}        /** 合併信件内容并寄送信件 */    private function make_mail_contents($system_notifies, $messages, $message_users){/** ... */}    private function send_mail($mail_contents) {/** ... */}}

第二版本加入了新需求,Send_today_mail 的程式码一下子从 93 行增加到 295 行。为了产生 系统通知 和 Messages 的信件 HTML 内容,make_mail_contents() 函式已经开始出现耦合。

3. 第三需求:寄送明日课程内容给教师

class Send_today_mail extends MX_Controller{    public function index()    {        /** 1. 捞取信件的内容,并产生信件 HTML */        // 取得所有使用者昨天收到的系统通知        $system_notifies = $this->notify_api->get_yesterday_notify();        // 依照收件者的 email 分群通知讯息        $system_notifies = $this->group_system_notify_by_email($system_notifies);        // 取得 Messenger 使用者、对话群组 id        list($message_users, $group_ids) = $this->message_api->get_all_message_users();        // 取得昨日的 Messages        $messages = $this->message_api->get_yesterday_message($group_ids);        // 取得明日的课程资讯        $tomorrow_course = $this->get_tomorrow_course();        // 取得课程教师资讯        $course_ids = array_column($tomorrow_course, 'course_id');        $teachers = $this->course_api->get_course_teachers($course_ids);        // 产生信件 HTML 内容        $mail_contents = $this->make_mail_contents($system_notifies, $messages, $message_users, $tomorrow_course, $teachers);                /** 2. 寄送信件 */        $this->send_mail($mail_contents);    }    /** 建立系统通知信件 */    private function get_yesterday_notify() {/** ... */}    private function group_system_notify_by_email($system_notifies) {/** ... */}    private function make_notifies_template_variables($notifies, $tplVar = array()) {/** ... */}    /** 建立 Messenger 讯息信件 */    private function get_yesterday_message() {/** ... */}    private function message_filter($messages, $group_id) {/** ... */}    private function make_message_template_variables($messages, $message_users, $tplVar) {/** ... */}        /** 建立 明日课程 信件 */    private function get_tomorrow_course() {/** ... */}    private function get_course_teachers(course_ids) {/** ... */}    private function make_course_start_template_variables() {/** ... */}        /** 合併信件内容并寄送信件 */    private function make_mail_contents($system_notifies, $messages, $message_users, $tomorrow_course, $teachers){/** ... */}    private function send_mail($mail_contents) {/** ... */}}

第三个版本,Send_today_mail 的总行数来到 504 行,make_mail_contents() 函式的耦合更加严重。

到目前为止,Send_today_mail 已经变得不太容易维护,这个 Controller 里面包含了 12 个函式,其中好几个函式却都是在做一样的事情:「捞取信件的内容,并产生信件 HTML」。

为了避免 Send_today_mail 因新需求的出现不断膨胀,接下来将开始替 Send_today_mail 进行一次重构。这次重构的目的将是引入抽象,拆散 随着时间增加的附加逻辑。

第一次重构:拆散职责

class Send_today_mail extends MX_Controller{    /**     * 寄送系统每日收到的所有通知讯息     */    public function index()    {        /** 1. 捞取信件的内容,并产生信件 HTML */        $email_maker = new Today_email_maker();        $email_maker->add_handler(new System_notify_handler());        $email_maker->add_handler(new Message_handler());        $email_maker->add_handler(new Course_start_handler());        $mail_contents = $email_maker->make_mail_contents();                /** 2. 寄送信件 */        $this->send_mail($email_contents);    }    private function send_mail($mail_contents) {/** ... */}}

上面是重构后的结果,Send_today_mail 的程式码大幅减少,可读性也有提高。

这样拆分职责的逻辑是「已变化为轴的地方划分界限」:
Send_today_mail 从第一次发布以来就一直新增 信件种类,这些 信件种类 最后都需要透过 make_mail_contents() 产生信件内容。那么随着新需求冒出来的信件种类,就是容易变动的地方,也就是 附加逻辑;负责产生信件 HTML 内容的 make_mail_contents() 则是在流程中不变的逻辑,故可视为 业务逻辑。

找出 业务逻辑 与 附加逻辑 后,即可将逻辑拆分成下面结构:

将产生多个信件 HTML 内容的 make_mail_contents() 搬移至 Today_email_maker 类别。负责 捞取各种信件种类内容 的逻辑则拆散至各自的类别:System_notify_handlerMessage_handlerCourse_start_handler

具体细节如下:

图五:重构后的程式码结构,引入抽象隔离业务逻辑与附加逻辑

在(图五)结构图中可以看见业务逻辑和附加逻辑之间引入一个抽象介面(Daily_email)。业务逻辑 透过公开 add_handler(Daily_email $handler) 函式,让 Controller 层可以从外部注入 附加逻辑。附加逻辑则须按照 Daily_email 介面的定义,实作完成新需求所需的程式码。

这是利用多型的特性,让 add_handler(Daily_email $handler) 可以接收任何有实作 Daily_email 介面的物件。这也是为什么 Controller 层可以对 Today_email_maker 注入多个附加逻辑类别的原因。

下面附上重构后的範例程式码:

interface Daily_email{    /** 取得今日信件内容 */    public function get_email_content();    /** 建立 Email HTML 样板变数 */    public function make_email_template_variables();    /** 建立 Email HTML 内容 */    public function make_email_content();}class Today_email_maker{    /** @var Daily_email[] */    private $handlers = array();    public function add_handler(Daily_email $handler)    {        array_push($this->handlers, $handler);    }    public function make_mail_contents()    {        $mail_contents = array();        foreach ($this->handlers as $handler) {            $handler->get_email_content();            $handler->make_email_template_variables();            array_push($mail_contents, $handler->make_email_content());        }                return $mail_contents;    }}

附加逻辑如下:

class System_notify_handler implements Daily_email{    public function get_email_content() { /** ... */}    public function make_email_template_variables() { /** ... */}    public function make_email_content() { /** ... */}    private function xxxx() { /** ... */}    /** ... */}class Message_handler implements Daily_email{    public function get_email_content() { /** ... */}    public function make_email_template_variables() { /** ... */}    public function make_email_content() { /** ... */}    private function xxxx() { /** ... */}    /** ... */}class Course_start_handler implements Daily_email{    public function get_email_content() { /** ... */}    public function make_email_template_variables() { /** ... */}    public function make_email_content() { /** ... */}    private function xxxx() { /** ... */}    /** ... */}

重构前,只要每新增一种信件,make_email_content 就会耦合新的信件种类资料,以便产生信件 HTML 内容。

    /** 重构前 Send_today_mail.php */    private function make_mail_contents($system_notifies, $messages, $message_users, $tomorrow_course, $teachers){/** ... */}    {        // 建立 Notifies 信件样板变数        $tplVar = $this->make_notifies_template_variables($notifies);        // 建立 Messages 信件样板变数        $tplVar = $this->make_message_template_variables($messages, $message_users, $tplVar);        // 建立 明日课程 信件样板变数        $tplVar = $this->make_tomorrow_course_template_variables($tomorrow_course, $teachers, $tplVar);        // 建立信件样板        $mail_contents = [];        foreach ($tplVar as $target_mail => $template_data) {            // 以使用者的 email 做区隔            $mail_contents[$target_mail] = $this->load->view('send_today_notify_mail/mail_template', $template_data, true);        }        return $mail_contents;    }

重构后,不管再新增多少种类的信件,Today_email_maker 都不需修改任何程式码(封闭修改)。只需新增实作 Daily_email 介面的附加逻辑即可完成新需求(开放扩充)。而且还可以随时移除任何一种信件种类。这就是利用开放封闭原则的成果,让程式码可以适应需求变化。

    /** 重构后 Today_email_maker.php */    public function add_handler(Daily_email $handler)    {        array_push($this->handlers, $handler);    }        public function make_mail_contents()    {        $mail_contents = array();        foreach ($this->handlers as $handler) {            $handler->get_email_content();            $handler->make_email_template_variables();            array_push($mail_contents, $handler->make_email_content());        }                return $mail_contents;    }

接受第一次愚弄

你可能已经发现了,引入抽象后程式码变得比重构前还要複杂。若每个新功能都要符合开放封闭原则,系统结构会变得极其複杂,而且还会有很多抽象没有实质效益。

因此 Uncle Bob 建议可以接受不合理的程式码带来的第一次愚弄。在最初写程式的时候,可以先假设变化永远不会发生,这有利于我们迅速完成需求。当变化发生并且对我们接下来的工作造成影响的时候,再回过头来封装这些变化的地方。确保未来不会掉进同一个坑里。

结论

在写程式的时候,可以把开放封闭原则当作目标,因为设计良好的程式通常都经得起开放封闭原则的考验。也有人说设计模式就是帮良好的设计取个名字,因为设计模式几乎都是遵守开放封闭原则的。开放封闭原则延伸出单一职责原则、依赖倒置原则等其他设计原则,其实都只是为了完成开放封闭原则这个目标的过程。

开放封闭原则是终极目标,很少人可以百分之百做到,但只要朝着原则的方向努力,就可以不断改善系统的架构,让程式码可以“拥抱变化“。

推荐阅读:

Clean Architecture 无瑕的程式码-整洁的软体设计与架构篇SOLID 之 开关原则(Open-Close principle)

关于作者: 网站小编

码农网专注IT技术教程资源分享平台,学习资源下载网站,58码农网包含计算机技术、网站程序源码下载、编程技术论坛、互联网资源下载等产品服务,提供原创、优质、完整内容的专业码农交流分享平台。

热门文章