開放封閉原則(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 行為:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| $.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 層的行為:
1 2 3 4 5 6 7 8 9 10 11 12
| <?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class HelloController extends Controller { public function index(Request $request){ return 'Hello World!'; } }
|
框架層面:React.js
透過繼承 React.Component
類別,擴充 Component 的行為:
1 2 3 4 5
| 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. 最初需求:寄送使用者昨天收到的系統通知。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| class Send_today_mail extends MX_Controller { public function index() { $system_notifies = $this->notify_api->get_yesterday_notify();
$system_notifies = $this->group_system_notify_by_email($system_notifies);
$mail_contents = $this->make_mail_contents($system_notifies);
$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 訊息
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| class Send_today_mail extends MX_Controller { public function index() { $system_notifies = $this->notify_api->get_yesterday_notify(); $system_notifies = $this->group_system_notify_by_email($system_notifies);
list($message_users, $group_ids) = $this->message_api->get_all_message_users(); $messages = $this->get_yesterday_message($group_ids);
$mail_contents = $this->make_mail_contents($system_notifies, $messages, $message_users); $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 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. 第三需求:寄送明日課程內容給教師
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
| class Send_today_mail extends MX_Controller { public function index() { $system_notifies = $this->notify_api->get_yesterday_notify(); $system_notifies = $this->group_system_notify_by_email($system_notifies);
list($message_users, $group_ids) = $this->message_api->get_all_message_users(); $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);
$mail_contents = $this->make_mail_contents($system_notifies, $messages, $message_users, $tomorrow_course, $teachers); $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 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 進行一次重構。這次重構的目的將是引入抽象,拆散 隨著時間增加的附加邏輯。
第一次重構:拆散職責
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| class Send_today_mail extends MX_Controller {
public function index() { $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(); $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
注入多個附加邏輯類別的原因。
下面附上重構後的範例程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| interface Daily_email { public function get_email_content(); public function make_email_template_variables(); public function make_email_content(); }
class Today_email_maker { 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; } }
|
附加邏輯如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| 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 內容。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| private function make_mail_contents($system_notifies, $messages, $message_users, $tomorrow_course, $teachers){} { $tplVar = $this->make_notifies_template_variables($notifies); $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) { $mail_contents[$target_mail] = $this->load->view('send_today_notify_mail/mail_template', $template_data, true); }
return $mail_contents; }
|
重構後,不管再新增多少種類的信件,Today_email_maker
都不需修改任何程式碼(封閉修改)。只需新增實作 Daily_email 介面的附加邏輯即可完成新需求(開放擴充)。而且還可以隨時移除任何一種信件種類。這就是利用開放封閉原則的成果,讓程式碼可以適應需求變化。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| 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 建議可以接受不合理的程式碼帶來的第一次愚弄。在最初寫程式的時候,可以先假設變化永遠不會發生,這有利於我們迅速完成需求。當變化發生並且對我們接下來的工作造成影響的時候,再回過頭來封裝這些變化的地方。確保未來不會掉進同一個坑里。
結論
在寫程式的時候,可以把開放封閉原則當作目標,因為設計良好的程式通常都經得起開放封閉原則的考驗。也有人說設計模式就是幫良好的設計取個名字,因為設計模式幾乎都是遵守開放封閉原則的。開放封閉原則延伸出單一職責原則、依賴倒置原則等其他設計原則,其實都只是為了完成開放封閉原則這個目標的過程。
開放封閉原則是終極目標,很少人可以百分之百做到,但只要朝著原則的方向努力,就可以不斷改善系統的架構,讓程式碼可以“擁抱變化“。
系列文章:
- 淺談物件導向 SOLID 原則對工程師的好處與如何影響能力
- 再談 SOLID 原則,Why SOLID?
- 物件導向設計原則:單一職責原則,定義、解析與實踐
- 物件導向設計原則:開放封閉原則,定義、解析與實踐
- 物件導向設計原則:裡氏替換原則,定義、解析
推薦閱讀: