[转]利用 PHP 的 SPL 快速实现 Observer 设计模式
作者:胡 屹, PHP工程师, 北京国微集成技术有限公司
来源:http://www.ibm.com/developerworks/cn/opensource/os-cn-observerspl/
SPL(Standard PHP Library)即标准 PHP 库,是 PHP 5 在面向对象上能力提升的真实写照,它由一系列内置的类、接口和函数构成。SPL 通过加入集合,迭代器,新的异常类型,文件和数据处理类等提升了 PHP 语言的生产力。它还提供了一些十分有用的特性,如本文要介绍的内置 Observer 设计模式。
本文介绍如何通过使用 SPL 提供的SplSubject
和SplObserver
接口以及SplObjectStorage
类,快速实现
Observer 设计模式。
SPL 在大多数 PHP 5 系统上都是默认开启的,尽管如此,由于 SPL 的功能在 PHP 5.2 版本发生了引人注目的改进,所以建议读者在实践本文内容时,使用不低于 PHP 5.2 的版本。
Observer 设计模式定义了对象间的一种一对多的依赖关系,当被观察的对象发生改变时,所有依赖于它的对象都会得到通知并被自动更新,而且被观察的对象和观察者之间是松耦合的。在该模式中,有目标(Subject)和观察者(Observer)两种角色。目标角色是被观察的对象,持有并控制着某种状态,可以被任意多个观察者作为观察的目标,SPL 中使用SplSubject
接口规范了该角色的行为:
表 1. SplSubject 接口中的方法
方法声明 | 描述 |
---|---|
abstract public void attach ( SplObserver $observer )
|
添加(注册)一个观察者 |
abstract public void detach ( SplObserver $observer )
|
删除一个观察者 |
abstract public void notify ( void )
|
当状态发生改变时,通知所有观察者 |
观察者角色是在目标发生改变时,需要得到通知的对象。SPL 中用SplObserver
接口规范了该角色的行为:
表 2. SplObserver 中的方法
方法声明 | 描述 |
---|---|
abstract public void update ( SplSubject $subject )
|
在目标发生改变时接收目标发送的通知;当关注的目标调用其notify() 时被调用 |
该设计模式的核心思想是,SplSubject
对象会在其状态改变时调用notify()
方法,一旦这个方法被调用,任何先前通过attach()
方法注册上来的SplObserver
对象都会以调用其update()
方法的方式被更新。
SplObjectStorage
类实现了以对象为键的映射(map)或对象的集合(如果忽略作为键的对象所对应的数据)这种数据结构。这个类的实例很像一个数组,但是它所存放的对象都是唯一的。这个特点就为快速实现 Observer 设计模式贡献了不少力量,因为我们不希望同一个观察者被注册多次。该类的另一个特点是,可以直接从中删除指定的对象,而不需要遍历或搜索整个集合。
SplObjectStorage
类的实例之所以能够只存储唯一的对象,是因为其SplObjectStorage::attach()
方法的实现中先判断了指定的对象是否已经被存储:
清单 1. SplObjectStorage::attach() 方法的部分源代码
function attach($obj, $inf = NULL) { if (is_object($obj) && !$this->contains($obj)) { $this->storage[] = array($obj, $inf); } } |
下面我们通过一个模拟案例来演示 SPL 在实现 Observer 设计模式上的威力。该案例模拟了一个网站的用户管理模块,该模块包括 3 个主要功能:
- 新增 1 个用户
- 把指定用户的密码变更为他所指定的新密码
- 在用户忘记密码时重置其密码
每当这些功能完成后,都需要将密码告知用户。除了传统的向用户发送 Email 这种手段外,我们还需要向用户的手机发送短信,让他们更加方便地知道密码是什么。假设我们的网站还有一套站内的消息系统,我们称之为小纸条,在用户变更或重置密码后,向他们发送小纸条会令他们高兴的。
经过分析,该案例适合使用 Observer 设计模式解决,因为将密码告知用户的多种手段与用户密码的改变——无论是从无到有,用户主动变更,还是系统重置——形成了多对一的关系。
我们决定定义一个 User 类表示用户,实现需求中的 3 个功能。该类就是 Observer 设计模式中的目标(Subject)角色。我们还需要一组类,实现利用各种手段向用户发送新密码的功能,这些类就充当了 Observer 设计模式中的观察者(Observer)角色。
经过简单地分析后,我们画出 UML 类图:
图 1. 模拟案例的 UML 类图
根据 UML 类图,首先,定义 1 个名为 User 的类模拟案例中的用户。尽管实际网站中的用户要有更多的属性,特别是通常需要用 ID 来标识每个用户,但是我们为了突出本文的主题,只保留了案例所需的属性。
清单 2. User 类的源代码
<?php class User implements SplSubject { private $email; private $username; private $mobile; private $password; /** * @var SplObjectStorage */ private $observers = NULL; public function __construct($email, $username, $mobile, $password) { $this->email = $email; $this->username = $username; $this->mobile = $mobile; $this->password = $password; $this->observers = new SplObjectStorage(); } public function attach(SplObserver $observer) { $this->observers->attach($observer); } public function detach(SplObserver $observer) { $this->observers->detach($observer); } public function notify() { $userInfo = array( 'username' => $this->username, 'password' => $this->password, 'email' => $this->email, 'mobile' => $this->mobile, ); foreach ($this->observers as $observer) { $observer->update($this, $userInfo); } } public function create() { echo __METHOD__, PHP_EOL; $this->notify(); } public function changePassword($newPassword) { echo __METHOD__, PHP_EOL; $this->password = $newPassword; $this->notify(); } public function resetPassword() { echo __METHOD__, PHP_EOL; $this->password = mt_rand(100000, 999999); $this->notify(); } } |
User 类要想充当目标角色,就需要实现SplSubject
接口,而按照实现接口的法则,attach()
、detach()
和notify()
就必须被实现。请注意,由于在SplSubject
接口中,attach
()
和
detach
() 的参数都使用了类型提示(
type hinting),在实现这两个方法时,也
不能
省略参数前面的类型。
我们还使用了$
observers
实例属性保存一个SplObjectStorage
对象,用来存放所有注册上来的观察者。
的确,一个数组就能解决问题,但是很快就可以发现,使用了SplObjectStorage
之后删除一个观察者实现起来是多么简单,直接委托给SplObjectStorage
对象!是的,不需要再使用最原始的for
语句遍历观察者数组或者使用array_search
函数,1
行搞定。
接下来分别定义充当观察者角色的 3 个信息发送类。为了简单,我们只是通过输出文本来假装发送信息。可即使是假装,依然需要知道用户的信息。可看看SplObserver
接口update
()
方法的签名,多么令人沮丧,它无法接受目标角色通过调用其notify
()
方法发送通告时给出的参数。如果你试图在重写update
()
方法时加上第 2 个参数,会得到一个类似
Fatal error: Declaration of EmailSender::update() must be compatible with that of SplObserver::update() 的错误而使代码执行终止。
其实,当目标所持有的状态(在本例中是用户的密码)更新时,如何通知观察者有两种方法。“拉”的方法和“推”的方法。SPL 使用的是“拉”的方法,观察者需要通过目标的引用(作为update()
方法的参数传入)来访问其属性。“拉”的方法需要让观察者更了解目标都拥有哪些属性,这增加了它们耦合度。而且主题也要对观察者门户大开,违背了封装性。解决的方法是在目标中提供一系列
getter 方法,如getPassword()
来让观察者获得用户的密码。
虽然“拉”的方法可能被认为更加正确,但是我们觉得让主题把用户的信息“推”过来更加方便。既然通过在重写update
()
方法时加上第 2 个参数是行不通的,那么就从别的方向上着手。好在 PHP 在方法调用上有这样的特性,只要给定的参数(实参)不少于定义时指定的必选参数(没有默认值的参数),PHP
就不会报错。传入一个方法的参数个数,可以通过func_num_args
() 函数获取;
多余的参数可以使用func_get_arg
()
函数读取。注意该函数是从
0 开始计数的,即 0 表示第 1 个实参。利用这个小技巧,update
()
方法可以通过func_get_arg(1)
接收一个用户信息的数组,有了这个数组,就能知道邮件该发给谁,新密码是什么了。为了节约篇幅,而且三个信息发送类非常相像,下面只给出其中一个的源代码,完整的源代码可以下载本文的附件得到。
清单 3. Email_Sender 类的源代码
<?php class EmailSender implements SplObserver { public function update(SplSubject $subject) { if (func_num_args() === 2) { $userInfo = func_get_arg(1); echo "向 {$userInfo['email']} 发送电子邮件成功。内容是:你好 {$userInfo['username']}" . "你的新密码是 {$userInfo['password']},请妥善保管", PHP_EOL; } } } |
最后我们写一个测试脚本test.php
。建议使用 CLI 的方式php – f test.php
来执行该脚本,但由于设置了Content-Type
响应头部字段为text/plain
,在浏览器中应该也能看到一行一行显示的结果(因为没有用<br
/>
做换行符而是使用常量PHP_EOL
,所以不设置Content-Type
的话,就不能正确分行显示了)。
清单 4. 用于测试的脚本
<?php header('Content-Type: text/plain'); function __autoload($class_name) { require_once "$class_name.php"; } $email_sender = new EmailSender(); $mobile_sender = new MobileSender(); $web_sender = new WebsiteSender(); $user = new User('[email protected]', '张三', '13610002000', '123456'); // 创建用户时通过 Email 和手机短信通知用户 $user->attach($email_sender); $user->attach($mobile_sender); $user->create($user); echo PHP_EOL; // 用户忘记密码后重置密码,还需要通过站内小纸条通知用户 $user->attach($web_sender); $user->resetPassword(); echo PHP_EOL; // 用户变更了密码,但是不要给他的手机发短信 $user->detach($mobile_sender); $user->changePassword('654321'); echo PHP_EOL; |
清单 5. 运行结果
User::create 向 [email protected] 发送电子邮件成功。内容是:你好张三你的新密码是 123456,请妥善保管 向 13610002000 发送短消息成功。内容是:你好张三你的新密码是 123456,请妥善保管 User::resetPassword 向 [email protected] 发送电子邮件成功。内容是:你好张三你的新密码是 363989,请妥善保管 向 13610002000 发送短消息成功。内容是:你好张三你的新密码是 363989,请妥善保管 这是 1 封站内小纸条。你好张三,你的新密码是 363989,请妥善保管 User::changePassword 向 [email protected] 发送电子邮件成功。内容是:你好张三你的新密码是 654321,请妥善保管 这是 1 封站内小纸条。你好张三,你的新密码是 654321,请妥善保管 |
我们看到,用户“
张三”
可以通过多种手段知道他的密码是什么。
对于经验丰富的开发者,即使不使用 SPL 也可以轻松实现 Observer 设计模式,但是使用 SPL 带来了更高的效率,特别在结合了SplObjectStorage
之后,注册和删除观察者都由它的实例代理完成。虽然在使用“推”的方式更新 Observer 时,SplObserver
的update
()
方法只接受
1 个参数显得美中不足,或者说 SPL 内置的 Observer 设计模式只支持通过“拉模式”获取通知,但是通过本文的介绍的小技巧即可弥补。因此,SPL 在快速实现 Observer 设计模式上成为了首选。
描述 | 名字 | 大小 | 下载方法 |
---|---|---|---|
样例代码 | observer_pattern.rar | 26KB | HTTP |
学习
-
阅读图书《 Head First Design Pattern 》(中译本《深入浅出设计模式》)第 2 章,了解 Observer 设计模式。
-
阅读图书《设计模式——可复用面向对象软件的基础》第 5 章 5.7 小节,更加深入理解 Observer 设计模式。
-
阅读图书《 Pro PHP Patterns, Frameworks, Testing and More 》第 9 章,更多地了解 SPL 的用途和特点。
-
随时关注 developerWorks技术活动和网络广播。
-
访问 developerWorksOpen source 专区获得丰富的 how-to 信息、工具和项目更新以及最受欢迎的文章和教程,帮助您用开放源码技术进行开发,并将它们与
IBM 产品结合使用。