系列文章
浅谈物件导向 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_handler
Message_handler
Course_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 建议可以接受不合理的程式码带来的第一次愚弄。在最初写程式的时候,可以先假设变化永远不会发生,这有利于我们迅速完成需求。当变化发生并且对我们接下来的工作造成影响的时候,再回过头来封装这些变化的地方。确保未来不会掉进同一个坑里。
结论
在写程式的时候,可以把开放封闭原则当作目标,因为设计良好的程式通常都经得起开放封闭原则的考验。也有人说设计模式就是帮良好的设计取个名字,因为设计模式几乎都是遵守开放封闭原则的。开放封闭原则延伸出单一职责原则、依赖倒置原则等其他设计原则,其实都只是为了完成开放封闭原则这个目标的过程。
开放封闭原则是终极目标,很少人可以百分之百做到,但只要朝着原则的方向努力,就可以不断改善系统的架构,让程式码可以“拥抱变化“。