設計模式第八講-觀察者模式
- 2019 年 11 月 28 日
- 筆記
前言
設計模式停更了好久, 發現兜兜轉轉回來, 還是離開不了那些個套路.
今天我們主要講解下 觀察者模式, 可能你聽這個名字感覺很熟, 如果給你說下還可以稱它為 發布訂閱 模式的話, 相信你對它就毫不陌生了.
觀察者模式定義了一種一對多的依賴關係, 讓多個觀察者對象同時監聽某一個主題對象, 這個主題發生變化時, 通知所有的觀察對象. 總的來說, 觀察者是解除耦合的重要手段.

V1 這個需求很簡單, 怎麼實現他不管
叮叮叮..
- 產品經理: 我需要實現一個用戶登陸的介面, 這個介面很重要也很簡單, 趕緊實現下, 上去一梭子搞完, 節前就上線.
- 小明: 好的, 經理
登陸程式碼:
class Login { /** * 處理登陸 * * @return array */ public function handleLogin($param) { $isLogin = false; //執行登陸 switch ($this->doLogin($param)) { case 0: $message = '登陸成功'; $isLogin = true; break; case 1: $message = '帳號或密碼不對'; break; case 2: $message = '帳號已失效'; break; default: $message = '登陸失敗'; } return [ 'isLogin' => $isLogin, 'message' => $message, ]; } /** * 執行具體登陸操作 * * @return int */ public function doLogin($param) { //dododo return rand(0, 2); } }
「這裡我們為了演示, 在實際執行登陸方法中隨機返回 0~2,對應返回不回的提示資訊.
執行如下
$result = (new Login)->handleLogin(['email'=>'[email protected]','passwd'=>'123456']); echo json_encode($result, JSON_UNESCAPED_UNICODE); output: {"isLogin":false,"message":"帳號或密碼不對"} {"isLogin":true,"message":"登陸成功"} {"isLogin":false,"message":"帳號已失效"}
V2 我們還得記錄下登陸的一些資訊
叮叮叮…
- 產品經理: 登陸是實現了, 但我們還需要一些數據用作分析, 這個需求同樣很簡單, 直接在上一次的登陸程式碼那插入一個保存就可以
- 小明: 好的呢, 經理.
public function handleLogin($param) { $isLogin = false; //執行登陸 switch ($this->doLogin($param)) { ..... } $param['isLogin'] = $message; $this->_saveLoginLog($param); return [ 'isLogin' => $isLogin, 'message' => $message, ]; } /** * 添加登陸日誌 * * @param $param * @return bool */ private function _saveLoginLog($param) { $param['client_ip'] = $this->get_real_ip(); $this->loginLogModel->insert($param); return true; }
V3 我們得給再做更多的事情
叮叮叮…
- 產品經理: 還是得升級下這個登陸介面, 這個系統數據很重要, 我們應該給系統管理員發郵件資訊, 再給歸屬帳號發條登陸簡訊提醒安全係數就會更高了, 再在那加點邏輯, 這個功能不複雜.
- 小明: 這個事情不好做, 這樣改下去會很亂.
- 產品經理: 你們怎麼架構是你們的事情, 你實現這個功能需要多久
- 小明: 經理給我一天可以嗎
- 產品經理: 我不認為這個事情有多難, 找個實習生最多一小時搞定, 這個功能很緊急, 如果做不了的話, 我們可以把你領導叫來一起溝通下, 你的時間我接受不了

- 小明: 別呀, 我試試還不行嘛.
「開發至今, 我才發現這是一個越來越大的陷阱, 我沒有意識到這點, 即使一個簡單的登陸介面, 每次改完都得重測一遍, 一直變來變去, 思考著該怎麼去重構我得程式碼.
觀察者模式 v1
class Login implements LoginSubjectInterface { private $observers; public function __construct() { $this->observers = []; } /** * 加入觀察者 * * @param LoginObserverInterface $loginSubject */ public function attach(LoginObserverInterface $loginObserver) { $this->observers[] = $loginObserver; } /** * 移除觀察者 * * @param LoginObserverInterface $loginObserver */ public function detach(LoginObserverInterface $loginObserver) { //todo } /** * 通知觀察者事件 */ public function notify() { foreach ($this->observers as $observer) { $observer->doNotify(); } } public function notify() { foreach ($this->observers as $observer) { $observer->doNotify(); } } /** * 處理登陸 * * @return array */ public function handleLogin($param) { $isLogin = false; //執行登陸 switch ($this->doLogin($param)) { case 0: $message = '登陸成功'; $isLogin = true; break; case 1: $message = '帳號或密碼不對'; break; case 2: $message = '帳號已經被禁用'; break; default: $message = '登陸失敗'; } $this->notify(); return [ 'isLogin' => $isLogin, 'message' => $message, ]; } }
登陸成功觀察者 interface
namespace App; interface LoginObserverInterface { public function doNotify(); }
發送郵件登陸提醒類
class LoginEmailNotify implements LoginObserverInterface { private $loginSubject; public function __construct(LoginSubjectInterface $loginSubject) { $this->loginSubject = $loginSubject; $this->loginSubject->attach($this); } public function doNotify() { echo '發送郵件登陸提醒' . PHP_EOL; } }
發送簡訊通知類
class LoginDisable implements LoginObserverInterface { private $loginSubject; public function __construct(LoginSubjectInterface $loginSubject) { $this->loginSubject = $loginSubject; $this->loginSubject->attach($this); } public function doNotify() { echo '微信推送充值鏈接' . PHP_EOL; } }
測試
$loginObject = new Login(); new LoginEmailNotify($loginObject); new LoginPhoneMsgNotify($loginObject); $result = $loginObject->handleLogin(['email' => '[email protected]', 'passwd' => '123456']); output: 發送郵件登陸提醒 發送簡訊通知 {"isLogin":false,"message":"帳號已經被禁用"}
「我們發現了在觀察者類中有一部分重複程式碼, 每個觀察者類中, 就是向被觀察者業務類執行 attch 操作, 這部分可以抽出基類作為封裝。另外一點沒實現的就是 doNotify 必須作為一個參數將當前登陸的帳號或手機號傳遞過去, 作為發送簡訊依據.
「我們大部分使用觀察者都是使用推的模式, 被動介面 notify, 其實還有一種模式為 拉 模式, 其實核心就是在 notify 中返向調用符合自身業務的介面去處理自己的邏輯.
我們嘗試使用 SPL 來優化觀察者
SPL提供了一組標準數據結構, 下面使用了觀察者相關的 SplSubject、SplObserver兩種介面使用方式
subject 業務主類
use SplObserver; use SplObjectStorage; class Login implements SplSubject { private $observers; public function __construct() { $this->observers = new SplObjectStorage(); } /** * 加入觀察者 * * @param SplObserver $loginSubject */ public function attach(SplObserver $loginObserver) { $this->observers->attach($loginObserver); } /** * 移除觀察者 * * @param SplObserver $loginObserver */ public function detach(SplObserver $loginObserver) { $this->observers->detach($loginObserver); } /** * 通知觀察者事件 */ public function notify() { foreach ($this->observers as $observer) { $observer->update($this); } } /** * 處理登陸 * * @return array */ public function handleLogin($param) { $isLogin = false; //執行登陸 switch ($this->doLogin($param)) { case 0: $message = '登陸成功'; $isLogin = true; break; case 1: $message = '帳號或密碼不對'; break; case 2: $message = '帳號已經被禁用'; break; default: $message = '登陸失敗'; } $this->notify(); return [ 'isLogin' => $isLogin, 'message' => $message, ]; } /** * 執行具體登陸操作 * * @return int */ public function doLogin($param) { //dododo return rand(0, 2); } }
郵件通知類
use SplObserver; use SplSubject; class LoginEmailNotify implements SplObserver { private $loginSubject; public function __construct(SplSubject $loginSubject) { $this->loginSubject = $loginSubject; $this->loginSubject->attach($this); } public function update(SplSubject $subject) { echo '發送郵件登陸提醒' . PHP_EOL; } }
簡訊通知類
use SplObserver; use SplSubject; class LoginPhoneMsgNotify implements SplObserver { private $loginSubject; public function __construct(SplSubject $loginSubject) { $this->loginSubject = $loginSubject; $this->loginSubject->attach($this); } public function update(SplSubject $subject) { echo '發送簡訊通知' . PHP_EOL; } }
「以上程式碼我們使用了php spl內部封裝好的 SplSubject、SplObserver的介面, 以及 SplObjectStorage 對象存儲類. 當然在方便的同時也帶來了缺失部分靈活性, 例如通知觀察者只能實現 update 類介面.

結論
小明和產品經理結局是?