威力加強版
使用 PHP
什麼都寫在一個類別裡面很方便呀!
全端工程師,無所不能!
只是每次合併同事的修改,總是會讓這個類別產生程式碼衝突...
啊!一定是同事的程式邏輯不好的緣故。
為什麼需求不一次講完?結果害我的方法裡有好多地方要針對不同條件做判斷,現在每加一種類型,要改的地方很多。
我們目標是做零 bug 的軟體。所以當你們找出並修正一個 bug ,會有十元的獎勵。
我們有錢了!
我希望這能驅使你們有正確的態度
我下午要為
自己寫出一輛休旅車
而且改越多,我就越怕帶來新的 bug 。
NO!!!!!!!!
為什麼不能繼承 A 類別又繼承 B 類別?兩邊的程式我都想重用呀!
不是說繼承是重用的快速捷徑?怎麼比複製貼上還難用呀?
這個類別做件事要這麼多步驟,沒看文件的話,誰知道啊?
那個... 你能再說一次降落的 SOP 嗎?
每次想把重複使用這個流程,都得再回頭看一次,超麻煩的呀!
這個物件可以做的事好多!感覺都是我要的,就拿進來用吧!
我只是想去巷口買個早餐呀!
唉呀... 其實我好像只是需要其中一個方法而已。
為何前人寫的程式會被綁死在舊的 library 上呢?難道想要換成功能更強的 library 錯了嗎?
遠雄:你跟我說這裡拆掉要改種樹?
好想死呀!也好想讓前人死呀!
通常這句是用來引戰的
你只是學會物件導向的語法
沒有理解物件導向的精神
也有人說學習物件導向
應該要先學設計模式
對也不對,看的高度不同而已
SRP OCP
LSP LKP
ISP DIP
單一職責原則
應該且僅有一個原因引起類別的變更
也就是讓類別只有一種職責
關注點分離
不要貪方便就全塞在一起
依照 domain 定義來拆開類別的職責
依照行為來決定類別的職責
第一次寫購物車就什麼功能全塞進去了
範例
class Cart
{
// 加入項目
public function addItem($sn, $quantity) { /* ... */}
// 移除項目
public function removeItem($sn) { /* ... */}
// 取得購物車內項目
public function getItems() { /* ... */}
// 設定訂單資訊
public function setOrderInfo($data) { /* ... */}
// 取得 ATM 付款資訊
public function getAtmPaymentInfo() { /* ... */}
// 設定物流
public function setShipment($shipment) { /* ... */}
// 儲存訂單
public function saveOrder() { /* ... */}
// 寄送訂單確認信件
public function sendMail() { /* ... */}
}
處理購物項目及訂單操作
如果有需要更改訂單功能
就要來改購物車類別 (怪怪的)
這應該是訂單的職責
class Order
{
// 設定訂單資訊
public function __construct($data) { /* ... */}
// 取得 ATM 付款資訊
public function getAtmPaymentInfo() { /* ... */}
// 設定物流
public function setShipment($shipment) { /* ... */}
// 儲存訂單
public function save() { /* ... */}
// 寄送訂單確認信件
public function sendMail() { /* ... */}
}
獨立出訂單類別後,修改購物車不會影響訂單功能
class Cart
{
// 加入項目
public function addItem($sn, $quantity) { /* ... */}
// 移除項目
public function removeItem($sn) { /* ... */}
// 取得購物車內項目
public function getItems() { /* ... */}
}
購物車類別職責減輕、專一
設計階段就可以避開類別職責太大的問題
但小心在維護階段受到誘惑又讓類別職責變多
開放封閉原則
軟體中的對象 (類別,函數等等)
對於擴展是開放的,對於修改是封閉的
就像讓程式可以插入外掛而不用動到程式本身
日本機器人打不贏敵人時,
就會出現新機台來合體,
以得到新武器
購物車加入商品時有不同促銷活動的處理方式,而活動類型很難確定,每加一個活動,要改動好幾個地方
範例
class Cart
{
public function addItem($sn, $qty)
{
$item = Item::create($sn, $qty);
// 加入購物車之前
if ('999999' === $sn) {
// 處理特別商品
} elseif ('99' === str_start($sn, 2)) {
// 處理活動商品
} else {
// 處理一般商品
}
// 處理加入購物車的步驟
$this->items[$sn] = $item;
// 加入購物車之後
if ('999999' === $sn) {
// 處理特別商品
} elseif ('99' === str_start($sn, 2)) {
// 處理活動商品
} else {
// 處理一般商品
}
}
}
每次有不同類型的活動就要改兩個地方,很容易忘記
class Cart
{
public function addItem($sn, $qty)
{
$item = Item::create($sn, $qty);
// 加入購物車之前
$this->plugins->beforeAddItem($item);
// 處理加入購物車的步驟
$this->items[$sn] = $item;
// 加入購物車之後
$this->plugins->afterAddItem($item);
}
}
$cart = new Cart;
$cart->addPlugins([
'Xmas', // 聖誕節活動
'Holiday', // 假日活動
'Freight', // 運費
]);
改讓 plugins 來處理,
addItem 方法不用修改
功能都由外部的 plugins 決定
新增功能只需要新增 plugins
不是所有程式都需要遵守 OCP
可能一開始無法預想到需要擴充,但可以透過重構完成。
不要用過度繼承的方式來擴充
里氏替換原則
白馬是馬,能用來騎
斑馬也是馬,卻不能用來騎
所有參照基礎類別的地方,必須可以透明地
使用衍生類別的物件代替,而不需要任何改變
使用父類別的地方也要對子類別一視同仁
王子騎「馬」
購物車加入商品時會查看商品的狀態來決定是否導回購物車列表頁,或是 500 錯誤頁
範例
class CartController
{
public function addItem($sn, $qty)
{
if (!$this->cart->addItem($sn, $qty)) {
return App::error('500')->withError('錯誤商品');
}
return redirect('/cart');
}
}
class Cart
{
public function addItem($sn, $qty)
{
$item = Item::create($sn, $qty);
if (null === $item) {
return false;
}
$this->items[$sn] = $item;
return true;
}
}
從 $this->cart->addItem 的回傳值判斷是否為錯誤商品或導向新頁面
addItem 應回傳 boolen 值
class CartController
{
public function addItem($sn, $qty, Cart $cart)
{
if (!$cart->addItem($sn, $qty)) {
return App::error('500')->withError('錯誤商品');
}
return redirect('/cart');
}
}
class EventCart extends Cart
{
public function addItem($sn, $qty)
{
if ('99' !== substr($sn, 0, 2)) {
die('非活動商品');
}
return parent::__construct($sn, $qty);
}
}
// 實際執行時用 EventCart 物件取代 Cart 物件
App::bind('Cart', 'EventCart');
繼承購物車類別後,因為便宜行事而造成非預期的流程,而不是原來的回傳值
(使用的類別不知道)
也叫 LoD (Law of Demeter)
最小知識原則
開車上路
一個物件應該對其他物件有最少的瞭解
也就是儘可能減少要知道的類別,降低類別對陌生類別的耦合度。
只需要知道開車的流程
不需要知道車子的引擎實際上做了什麼事
訂單結帳的流程放在 Controller 裡,
Controller 被強迫知道所有的細節
範例
class CheckoutController
{
public function checkout($sn, $payment, $shipment)
{
$order = new Order($sn);
$order->setPayment($payment); // 設定付款方式
$order->setShipment($shipment); // 設定物流
if ($order->save()) { // 儲存訂單資訊
$order->sendMail(); // 寄送訂單確認信件
}
}
}
Controller 知道太多 Order 的操作邏輯,如果其他地方要重複使用這套 SOP 就得再複製一次
class Order
{
public function checkout($payment, $shipment)
{
$this->setPayment($payment); // 設定付款方式
$this->setShipment($shipment); // 設定物流
if ($this->save()) { // 儲存訂單資訊
$this->sendMail(); // 寄送訂單確認信件
}
}
}
class CheckoutController
{
public function checkout($sn, $payment, $shipment)
{
$order = new Order($sn);
$order->checkout($payment, $shipment);
}
}
結帳 SOP 封裝在 Order 類別裡,流程有改的話,也只要更動 checkout 方法
現在 Controller 只要知道 Order 的 checkout 方法
介面隔離原則
依賴的介面都是有其必要性
用戶端程式碼不應該依賴它用不到的介面
舊的程式裡,在訂單完成後會寄出確認信。
但是我們想要把這個寄信功能拿到別的地方使用。
範例
class Mailer
{
public function send(IOrder $order)
{
$mail = $order->getReceiverMail();
// ...
}
}
class Message implements IOrder
{
public function getReceiverMail()
{
return $this->user->mail;
}
public function getOrderNumber()
{
}
}
想要重複使用 Mailer ,結果所有東西都得變成 IOrder ?
不得不實作的方法
class Mailer
{
public function send(Mailable $target)
{
$mail = $target->getMail();
// ...
}
}
interface Mailable
{
public function getMail();
}
class Order implements Mailable
{
public function getMail() { /* ... */ }
}
class Message implements Mailable
{
public function getMail()
{
return $this->user->mail;
}
}
想寄信實作 Mailable 介面就好
Mailer 只依賴在 Mailable 介面,
Mailable 只有一個 getMail 方法。
依賴反轉原則
高階模組不該依賴低階模組,
兩者都應該要依賴其抽象
抽象不要依賴細節,細節要依賴抽象
不要把程式碼寫死某種實作上
用 iPhone 跟家人通話
用 手機 跟家人通話
舊的程式裡,付款方式只有 ATM 一種。
但最近將會新增其他的付款方式。
範例
class Order
{
public function checkout($shipment)
{
$atm = new ATM;
$result = $atm->send($this->getOrderNumber(), $this->getTotal());
if ('99' === $result->errorCode) {
throw new PaymentException;
}
// ...
}
}
被綁死在 ATM 付款方式裡
class Order
{
public function checkout(PaymentInterface $payment, $shipment)
{
$paymentInfo = $payment->execute(); // 設定金流
$this->setShipment($shipment); // 設定物流
if ($this->save()) { // 儲存訂單資訊
$this->sendMail(); // 寄送訂單確認信件
}
}
}
class Atm implements PaymentInterface
{
public function execute()
{
$atm = new ATM;
$result = $atm->send($this->getOrderNumber(), $this->getTotal());
if ('99' === $result->errorCode) {
throw new PaymentException;
}
return new AtmInfo($result->getAccountInfo());
}
}
讓 Order 依賴 PaymentInterface ,
解除高層 (Order) 對低層 (ATM) 的依賴
SOLID 其實講的是同一件事
面對原始碼改變的策略
SRP :降低單一類別被「改變」所影響的機會。
OCP :讓主要類別不會因為新增需求而改變。
LSP :避免繼承時子類別所造成的「行為改變」。
LKP :避免曝露過多資訊造成用戶端因流程調整而改變。
ISP :降低用戶端因為不相關介面而被改變。
DIP :避免高階程式因為低階程式改變而被迫改變。
是否實作設計模式
就不是那麼重要了
解決問題的經驗
方便開發時的溝通
例如:在爆破的場景裡,主角一定要頭也不回地轉身帥氣離開。
Strategy Pattern
Factory Method Pattern
Adapter Pattern
Decorator Pattern
Chain of Responsibility Pattern
同一個行為有數種方案可以選擇,使用者
可以在執行時期再決定用哪一個。
情境
訂單希望提供數種物流選項以計算運費,但每加一種都要修改訂單類別的程式碼。
範例
class Order
{
public function calculateFee($shipmentName)
{
switch ($shipmentName) {
case 'Hsinchu': // 新竹貨運
$fee = .... ;
break;
case 'BlackCat': // 黑貓貨運
$fee = .... ;
break;
case 'PostOffice': // 郵局
$fee = .... ;
break;
default:
break;
}
}
}
增加物流就要修改這裡,有修改錯誤的風險。
class Order
{
public function calculateFee(IShipment $shipment)
{
$fee = $shipment->getFee();
}
}
interface IShipment
{
public function getFee();
}
class HsinchuShipment implements IShipment // 新竹貨運
{
public function getFee() { /* ... */ }
}
class BlackCatShipment implements IShipment // 黑貓快遞
{
public function getFee() { /* ... */ }
}
class PostOfficeShipment implements IShipment // 郵局
{
public function getFee() { /* ... */ }
}
改為依賴 IShipment 介面
只要實作 IShipment 介面的類別就可以當做新的物流
把生成同一體系物件的判斷封裝在某個方法中,
透過參數來決定要生成哪一個物件
情境
Controller 需要將物流處理物件注入訂單物件裡。
範例
class Controller
{
public function checkout($shipmentName)
{
$order = new Order;
switch ($shipmentName) {
case 'HsinChu':
$shipment = new HsinChuShipment;
break;
case 'BlackCat':
$shipment = new BlackCatShipment;
break;
case 'PostOffice':
$shipment = new PostOfficeShipment;
break;
default:
break;
}
$order->calculateFee($shipment);
}
}
class Order
{
public function calculateFee(IShipment $shipment) { /* ... */ }
}
生成物件的職責被放到 Controller 裡
class Controller
{
public function checkout($shipmentName)
{
$order = new Order;
$shipment = $this->createShipment($shipmentName);
$order->calculateFee($shipment);
}
public function createShipment($shipmentName)
{
switch ($shipmentName) {
case 'HsinChu':
return new HsinChuShipment;
break;
case 'BlackCat':
return new BlackCatShipment;
break;
case 'PostOffice':
return new PostOfficeShipment;
break;
default:
throw new Exception('Class does not exist.');
break;
}
}
}
先提煉成 createShipment 方法
class Controller
{
public function checkout($shipmentName)
{
$order = new Order;
$shipment = $this->createShipment($shipmentName);
$order->setShipment($shipment);
}
public function createShipment($shipmentName)
{
$className = $shipmentName . 'Shipment';
if (class_exists($className) {
return new $className;
} else {
throw new Exception('Class does not exist.');
}
}
}
再用 PHP 的語法特性建立物件
已經有寫好的第三方程式碼,但它的 API 使用方式跟主要程式無法搭配。
情境
金流廠商有提供 PHP 版的金流程式套件,但 API 跟我們的介面不相容。
範例
class OrderController
{
public function postPayment(CreditCard $payment)
{
// 導向信用卡付款頁
return $payment->send($this->sn, $this->total);
}
public function getPaymentResult(CreditCard $payment)
{
if ('success' === $payment->result()) {
// 後續動作
}
}
// CreditCard 套件
class CreditCard
{
public function send($sn, $total) { /* ... */ }
}
我們的程式原本相依於 CreditCard
class OrderController
{
public function postPayment(IPayment $payment)
{
// 導向信用卡付款頁
return $payment->send($this->sn, $this->total);
}
}
interface IPayment
{
public function send($sn, $total);
}
改讓程式相依於 IPayment
class CreditCardPayment implements IPayment
{
private $provider = null;
public function __construct(CreditCard $provider)
{
$this->provider = $provider;
}
public function send($sn, $total)
{
return $this->provider->send($sn, $total);
}
}
先封裝舊的 CreditCard 操作
// 虛擬的付款服務:貝寶
class PayBallPayment implements IPayment
{
private $provider = null;
public function __construct(ATM $provider)
{
$this->provider = $provider;
}
public function send($sn, $total)
{
$this->provider->setOrderNumber($sn);
$this->provider->setSum($total);
$this->provider->createConnection('http://...');
}
}
把不相容的 API 使用方式封裝在我們的介面之後
臨時想要外加一些資訊在物件的公開 API 上,但又希望不修改用戶端的程式碼。
情境
在加入購物車時,商品有數個搭配的促銷活動會影響價格。
範例
class Cart
{
public static addItem(Item $item, $qty)
{
// ...
$this->total += $item->getPrice() * $qty;
}
}
Cart 類別相依於 Item
interface IItem
{
public function getPrice();
}
class Cart
{
public static addItem(IItem $item, $qty) { /* ... */ }
}
class Item implements IItem
{
public function getPrice()
{
return $this->price;
}
}
class AbstractItem implements IItem
{
private $item;
public function __construct(IItem $item)
{
$this->item = $item;
}
}
改讓程式相依於 IItem
關鍵抽象類別
class SpecialItem extends AbstractItem
{
public function getPrice()
{
$price = $this->item->getPrice();
if ($price > 2000) {
$price -= 200;
}
return $price;
}
}
class DiscountItem extends AbstractItem
{
public function getPrice()
{
return $this->item->getPrice() * 0.8;
}
}
$item = new DiscountItem(
new SpecialItem(
Item::create('000001')));
$cart->addItem($item);
讓行為元件繼承抽象類別
利用組合的方式傳入
資料需要經過一連串的關卡,如果能夠處理,就直接回傳結果,否則就交給下一棒繼續。
情境
多個活動同時進行的期間,購物車中的商品需要一一檢查是否屬於某個活動。
範例
class Cart
{
private $total = 0;
public function calculate()
{
foreach ($this->items as $item) {
if ('999999' === $item->sn) {
$this->total += 100;
} elseif ('99' === substr($item->sn, 0, 2)) {
$this->total += ceil($item->price * 0.5);
} elseif (in_array($item->sn, ['555555', '666666'])) {
$this->total += ceil($item->price * 0.8);
} else {
$this->total += $item->price;
}
}
}
}
容易看錯與改錯的判斷式
abstract class Calculator
{
private $next = null;
public function setNext(Calculator $cal)
{
$this->next = $cal;
return $this->next;
}
public function next(Item $item)
{
if ($this->next) {
return $this->next->calculate($item);
}
}
abstract public function calculate(Item $item);
}
依照 CoR 模式的通用解法建立一個抽象類別,每個子類別都是一個節點。
子類別裡的 calculate 方法可以決定如何處理資料,或是交給下一棒
class FreightCalculator extends Calculator
{
public function calculate(Item $item)
{
return ('999999' === $item->sn)
? 100 : $this->next($item);
}
}
class HalfPriceCalculator extends Calculator
{
public function calculate(Item $item)
{
return ('99' === substr($item->sn, 0, 2))
? ceil($item->price * 0.5) : $this->next($item);
}
}
class DiscountCalculator extends Calculator
{
public function calculate(Item $item)
{
return (in_array($item->sn, ['555555', '666666']))
? ceil($item->price * 0.8) : $this->next($item);
}
}
原本在 if .. else 裡的判斷式都搬到子類別裡
class NormalCalculator extends Calculator
{
public function calculate(Item $item)
{
return $item->price;
}
}
$cal = (new FreightCalculator)
->setNext(new HalfPriceCalculator)
->setNext(new DiscountCalculator)
->setNext(new NormalCalculator);
$cart = new Cart;
$cart->calculate($cal);
將節點依順序串起來後,注入給購物車使用
不見得一個問題只能用一種模式;分析需求時,通常會發現要結合多個模式才能解決問題。
有些模式是多個基本模式所結合出來的,它就被稱為複合模式。例如傳統 MVC 就會用到 Strategy 模式、 Observer 模式、 Composite 模式等。
其他模式請自行參考相關書籍
既然說是設計模式
是不是一定要
在設計時考慮進去呢?
只是你很有機會套用到
錯誤的模式
想清楚你的需求
不要把模式硬套上去
只學到一個模式後,遇到任何問題就覺得可以用這個模式來解決。
設計模式告訴我們怎麼做比較好,但有時候我們可能要知道別怎麼做比較好。
有時無招勝有招,先看清楚問題是什麼,先用自己熟悉的做法去解決它。
再以 SOLID 原則為基礎,之後在重構時就會讓模式浮現。
工商服務時間
資深網站後端工程師
PHP 開發工程師