iT邦幫忙

2

物件導向設計原則:開放封閉原則,定義、解析與實踐

系列文章

  1. 淺談物件導向 SOLID 原則對工程師的好處與如何影響能力
  2. 再談 SOLID 原則,Why SOLID?
  3. 物件導向設計原則:單一職責原則,定義、解析與實踐
  4. 物件導向設計原則:開放封閉原則,定義、解析與實踐

開放封閉原則(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 層的行為:

<?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 的行為:

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) {/** ... */}
}

第一版本的程式碼中可以看見寄信功能主要分兩個部分:

  1. 撈取信件的內容,並產生信件 HTML
  2. 寄送信件

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() 則是在流程中不變的邏輯,故可視為 業務邏輯

找出 業務邏輯附加邏輯 後,即可將邏輯拆分成下面結構:

  1. 將產生多個信件 HTML 內容的 make_mail_contents() 搬移至 Today_email_maker 類別。
  2. 負責 撈取各種信件種類內容 的邏輯則拆散至各自的類別:
  • 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 建議可以接受不合理的程式碼帶來的第一次愚弄。在最初寫程式的時候,可以先假設變化永遠不會發生,這有利於我們迅速完成需求。當變化發生並且對我們接下來的工作造成影響的時候,再回過頭來封裝這些變化的地方。確保未來不會掉進同一個坑里。

結論

在寫程式的時候,可以把開放封閉原則當作目標,因為設計良好的程式通常都經得起開放封閉原則的考驗。也有人說設計模式就是幫良好的設計取個名字,因為設計模式幾乎都是遵守開放封閉原則的。開放封閉原則延伸出單一職責原則、依賴倒置原則等其他設計原則,其實都只是為了完成開放封閉原則這個目標的過程。

開放封閉原則是終極目標,很少人可以百分之百做到,但只要朝著原則的方向努力,就可以不斷改善系統的架構,讓程式碼可以“擁抱變化“。

推薦閱讀:


尚未有邦友留言

立即登入留言