從實例學設計模式
威力加強版
使用 PHP
Jace Ju
KKBOX Senior Engineer
![](https://s3.amazonaws.com/media-p.slid.es/uploads/15840/images/1504287/small.jpg)
Agenda
- 物件導向令人頭大的問題
- 遵守堅實的原則
- 到底該用什麼模式?
- 對於模式我該注意什麼?
![](https://s3.amazonaws.com/media-p.slid.es/uploads/15840/images/1754530/uu.jpg)
記得第一次學會物件導向
萬能類別
什麼都寫在一個類別裡面很方便呀!
![](https://s3.amazonaws.com/media-p.slid.es/uploads/15840/images/1490911/9a27c506f97898ccbbfb420fcb178180.jpg)
全端工程師,無所不能!
只是每次合併同事的修改,總是會讓這個類別產生程式碼衝突...
啊!一定是同事的程式邏輯不好的緣故。
臭蟲製造機
為什麼需求不一次講完?結果害我的方法裡有好多地方要針對不同條件做判斷,現在每加一種類型,要改的地方很多。
![](https://s3.amazonaws.com/media-p.slid.es/uploads/15840/images/1490967/bug-free.gif)
我們目標是做零 bug 的軟體。所以當你們找出並修正一個 bug ,會有十元的獎勵。
我們有錢了!
我希望這能驅使你們有正確的態度
我下午要為
自己寫出一輛休旅車
而且改越多,我就越怕帶來新的 bug 。
繼承用起來很不順
![](https://s3.amazonaws.com/media-p.slid.es/uploads/15840/images/1491012/i-am-your-father.jpg)
![](https://s3.amazonaws.com/media-p.slid.es/uploads/15840/images/1491019/nooooooooo.jpeg)
NO!!!!!!!!
為什麼不能繼承 A 類別又繼承 B 類別?兩邊的程式我都想重用呀!
不是說繼承是重用的快速捷徑?怎麼比複製貼上還難用呀?
要知道的太多
這個類別做件事要這麼多步驟,沒看文件的話,誰知道啊?
![](https://s3.amazonaws.com/media-p.slid.es/uploads/15840/images/1491047/interface.jpg)
那個... 你能再說一次降落的 SOP 嗎?
每次想把重複使用這個流程,都得再回頭看一次,超麻煩的呀!
給多用少
這個物件可以做的事好多!感覺都是我要的,就拿進來用吧!
![](https://s3.amazonaws.com/media-p.slid.es/uploads/15840/images/1491217/treadmillbike04.jpg)
我只是想去巷口買個早餐呀!
唉呀... 其實我好像只是需要其中一個方法而已。
根深蒂固
為何前人寫的程式會被綁死在舊的 library 上呢?難道想要換成功能更強的 library 錯了嗎?
![](https://s3.amazonaws.com/media-p.slid.es/uploads/15840/images/1491241/maxresdefault.jpg)
遠雄:你跟我說這裡拆掉要改種樹?
好想死呀!也好想讓前人死呀!
如果你會遇到開發上的問題
那一定不是物件導向的問題
通常這句是用來引戰的
你只是學會物件導向的語法
沒有理解物件導向的精神
也有人說學習物件導向
應該要先學設計模式
對也不對,看的高度不同而已
其實你該先學的不是
設計模式
而是物件導向開發原則
SRP OCP
LSP LKP
ISP DIP
SOLID
SRP
Single Responsibility Principle
單一職責原則
應該且僅有一個原因引起類別的變更
![](https://s3.amazonaws.com/media-p.slid.es/uploads/15840/images/1506146/great_power_great_resposibility.jpg)
也就是讓類別只有一種職責
-
關注點分離
-
不要貪方便就全塞在一起
-
依照 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() { /* ... */}
}
重構後的 Order 類別
獨立出訂單類別後,修改購物車不會影響訂單功能
class Cart
{
// 加入項目
public function addItem($sn, $quantity) { /* ... */}
// 移除項目
public function removeItem($sn) { /* ... */}
// 取得購物車內項目
public function getItems() { /* ... */}
}
重構後的 Cart 類別
購物車類別職責減輕、專一
-
設計階段就可以避開類別職責太大的問題
-
但小心在維護階段受到誘惑又讓類別職責變多
小提醒
OCP
Open-Closed Principle
開放封閉原則
軟體中的對象 (類別,函數等等)
對於擴展是開放的,對於修改是封閉的
就像讓程式可以插入外掛而不用動到程式本身
![](https://s3.amazonaws.com/media-p.slid.es/uploads/15840/images/1510193/robot.jpg)
日本機器人打不贏敵人時,
就會出現新機台來合體,
以得到新武器
- 只考慮抽象層級的介面互動
- 把變化委託給其他類別處理
- 只要異動 metadata 或 config
秘訣
購物車加入商品時有不同促銷活動的處理方式,而活動類型很難確定,每加一個活動,要改動好幾個地方
範例
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
-
可能一開始無法預想到需要擴充,但可以透過重構完成。
-
不要用過度繼承的方式來擴充
小提醒
LSP
Liskov Substitution Principle
里氏替換原則
白馬是馬,能用來騎
斑馬也是馬,卻不能用來騎
所有參照基礎類別的地方,必須可以透明地
使用衍生類別的物件代替,而不需要任何改變
使用父類別的地方也要對子類別一視同仁
王子騎「馬」
- Design by Contract
- 方法簽名、回傳值與丟出的異常要一致
- PHP 5.x 只能靠 Type Hint 和 docblock 的 annotation 來約束
秘訣
購物車加入商品時會查看商品的狀態來決定是否導回購物車列表頁,或是 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');
活動購物車 (反例)
繼承購物車類別後,因為便宜行事而造成非預期的流程,而不是原來的回傳值
(使用的類別不知道)
- 依賴子類別的地方不能用父類別取代,白馬王子裡的白馬非馬
- 拋棄繼承,思考可否改用組合的方式
小提醒
LKP
Least Knowledge Principle
也叫 LoD (Law of Demeter)
最小知識原則
開車上路
一個物件應該對其他物件有最少的瞭解
也就是儘可能減少要知道的類別,降低類別對陌生類別的耦合度。
只需要知道開車的流程
不需要知道車子的引擎實際上做了什麼事
- 需要操作內部類別的流程就封裝成 public method
- 外在類別對內部類別細節知道的越少,對內部類別耦合度就越低
秘訣
訂單結帳的流程放在 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 方法
ISP
Interface Segregation Principle
介面隔離原則
依賴的介面都是有其必要性
用戶端程式碼不應該依賴它用不到的介面
沒用到的介面比沒有介面還糟
- 把 Interface 當成「可以做什麼」,而不是「是一個什麼」
- 減少讓每個 Interface 可以做的事
- 如果發現有空實作時,就表示 Interface 可以再細化
秘訣
舊的程式裡,在訂單完成後會寄出確認信。
但是我們想要把這個寄信功能拿到別的地方使用。
範例
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 方法。
DIP
Dependency Inversion Principle
依賴反轉原則
高階模組不該依賴低階模組,
兩者都應該要依賴其抽象
抽象不要依賴細節,細節要依賴抽象
不要把程式碼寫死某種實作上
用 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 :避免高階程式因為低階程式改變而被迫改變。
是否實作設計模式
就不是那麼重要了
只要遵守 SOLID
-
解決問題的經驗
-
方便開發時的溝通
為什麼還是要有
設計模式?
電影公式:動作片主角如何耍酷?
![](https://s3.amazonaws.com/media-p.slid.es/uploads/15840/images/1513671/Iron-man-Explosion.jpg)
![](https://s3.amazonaws.com/media-p.slid.es/uploads/15840/images/1513673/Wolverine-Explosion.jpg)
例如:在爆破的場景裡,主角一定要頭也不回地轉身帥氣離開。
-
Strategy Pattern
-
Factory Method Pattern
-
Adapter Pattern
-
Decorator Pattern
-
Chain of Responsibility Pattern
在 PHP 常見的模式
Strategy Pattern
策略模式
同一個行為有數種方案可以選擇,使用者
可以在執行時期再決定用哪一個。
情境
秘訣
- 出現用 switch case 來切換行為的場合可以使用。
- 將行為定義成介面,實作出同一體系的類別。
- 在方法參數中注入。
訂單希望提供數種物流選項以計算運費,但每加一種都要修改訂單類別的程式碼。
範例
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 介面的類別就可以當做新的物流
Simple Factory Pattern
簡單工廠模式
把生成同一體系物件的判斷封裝在某個方法中,
透過參數來決定要生成哪一個物件
情境
秘訣
- 捨棄使用 new 的誘惑。
- 用一個 create 方法來建立同一體系的物件。
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;
}
}
}
重構 step 1
先提煉成 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.');
}
}
}
重構 step 2
再用 PHP 的語法特性建立物件
Adapter Pattern
轉接器模式
已經有寫好的第三方程式碼,但它的 API 使用方式跟主要程式無法搭配。
情境
秘訣
- 先將依賴實作的程式碼改成抽象介面。
- 實作 Adapter 類別來滿足介面。
金流廠商有提供 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);
}
重構 step 1
改讓程式相依於 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);
}
}
重構 step 2
先封裝舊的 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 使用方式封裝在我們的介面之後
Decorator Pattern
裝飾者模式
臨時想要外加一些資訊在物件的公開 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);
重構後
讓行為元件繼承抽象類別
利用組合的方式傳入
Chain of Responsibility
Pattern
責任鏈模式
資料需要經過一連串的關卡,如果能夠處理,就直接回傳結果,否則就交給下一棒繼續。
情境
秘訣
- 出現用 if ... elseif ... else 來判斷資訊如何處理的場合時。
- 通常最後要有預設的處理機制。
多個活動同時進行的期間,購物車中的商品需要一一檢查是否屬於某個活動。
範例
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);
重構後
將節點依順序串起來後,注入給購物車使用
複合模式
不見得一個問題只能用一種模式;分析需求時,通常會發現要結合多個模式才能解決問題。
![](https://s3.amazonaws.com/media-p.slid.es/uploads/15840/images/1499412/knife.jpg)
有些模式是多個基本模式所結合出來的,它就被稱為複合模式。例如傳統 MVC 就會用到 Strategy 模式、 Observer 模式、 Composite 模式等。
其他模式請自行參考相關書籍
既然說是設計模式
是不是一定要
在設計時考慮進去呢?
只是你很有機會套用到
錯誤的模式
想清楚你的需求
不要把模式硬套上去
黃金鎚子
只學到一個模式後,遇到任何問題就覺得可以用這個模式來解決。
![](https://s3.amazonaws.com/media-p.slid.es/uploads/15840/images/1499313/hammer.jpg)
設計模式告訴我們怎麼做比較好,但有時候我們可能要知道別怎麼做比較好。
忘了模式就學會模式
有時無招勝有招,先看清楚問題是什麼,先用自己熟悉的做法去解決它。
再以 SOLID 原則為基礎,之後在重構時就會讓模式浮現。
![](https://s3.amazonaws.com/media-p.slid.es/uploads/15840/images/1753759/kkbox.png)
工商服務時間
需要 PHP 工程師
地方的
資深網站後端工程師
PHP 開發工程師
謝謝
從實例學設計模式
By Jace Ju
從實例學設計模式
- 32,829