命令模式
命令模式最初来源于图形化用户界面设计,但现在广泛应用于企业设计,特别促进了控制器(请求和分发处理)和领域模型(应用逻辑)的分离。命令模式有助于系统更好地进行组织,并易于扩展。
- 问题
所有系统都必须决定如何响应用户请求。在PHP中,这个决策过程通常是由分散的各个PHP页面来处理。比如当用户访问一个PHP页面时,用户明确地告诉系统他所要求的功能和接口。但现在PHP开发者日益倾向于在设计系统时采用单一入口的方式。无论是多个入口还是单个入口,接收者都必然将用户请求委托给一个更加关注于应用逻辑的层来进行处理。这个委托在用户请求不同页面时尤为重要。如果没有委托,代码重复将会不可避免地蔓延在整个项目中。
让我们想象一下,假设一个有很多任务要执行的项目,需要允许某些用户登录,某些用户可以提交反馈。我们可以分别创建login.php和feedback.php页面来处理这些任务,并实例化专门的类以完成任务。不过遗憾的是,系统中的用户界面很难被精确地一一对应到系统任务。比如我们可能要求每个页面都有登录和反馈的功能。如果页面必须处理很多不同的任务,就应该考虑将任务进行封装。封装之后,向系统增加新任务就会变得简单,并且可以将系统中的各部分分离开来。当然,这时我们可以使用命令模式。 - 实现
命令对象的接口极为简单,因为它只要求实现一个方法execute()。
在图中,Command被定义为一个抽象类。同样简单地,它也可以被定义为接口。将它定义为抽象类,因为有时基类也可以为它的衍生对象提供有用的公共功能。
命令模式由3部分组成:实例化命令对象的客户端(client)、部署命令对象的调用者(invoker)和接受命令的接收者(receiver)。
通过客户端,接收者可以在命令对象的构造方法中被传递给命令对象,或者通过某种工厂对象被获得。相对而言,后一种办法可以保持构造方法参数清晰明了,而且所有的Command对象都可以用完全相同的方式实例化。
创建一个具体的Command类:
abstract class Command{
abstract function execute(CommandContext $context);
}
class LoginCommand extends Command{
function execute(CommandContext $context){
$manger = Registry::getAccessManager();
$user = $context->get('username');
$pass = $context->get('pass');
$user_obj = $manager->login($user, $pass);
if(is_null($user_obj)){
$context->setError($manager->getError());
return false;
}
$context->addParam("user", $user_obj);
return true;
}
}
LoginCommand被设计为与AccessManager(访问管理器)对象一起工作。AccessManager是一个虚构出来的类,它的任务就是处理用户登录系统的具体细节。注意Command::execute()方法要求使用CommandContext对象作为参数。通过CommandContext机制,请求数据可以被传递给Command对象,同时相应也可以被返回到视图层。以这种方式使用对象是很有好处的,因为我们可以不破坏接口就把不同的参数传递给命令对象。从本质上说,CommandContext只是将关联数组变量包装而成的对象,但我们仍会经常扩展它来执行额外的任务。下面是一个简单的CommandContext实现:
class CommandContext{
private $params = array();
private $error = "";
function __construct(){
$this->params = $_REQUEST;
}
function addParam($key, $val){
$this->params[$key] = $val;
}
function get($key){
return $this->params[$key];
}
function setError($error){
$this->error = $error;
}
function getError(){
return $this->error;
}
}
因此通过使用CommandContext对象,LoginCommand能够访问请求数据:提交的用户名和密码。我们使用了一个简单的类Registry,它带有用于生成通用对象的静态方法,可以返回LoginCommand所需要的AccessManager对象。如果AccessManager报告一个错误,则LoginCommand保存错误信息到CommandContext对象中以供表现层使用并返回false。如果一切正常,LoginCommand只返回true。注意Command对象不应该执行太多的逻辑。它们应该负责检查输入、处理错误、缓存对象和调用其他对象来执行一些必要的操作。如果你发现应用逻辑过多地出现在Command类中,通常需要考虑重构代码。这样的代码会导致代码重复,因为它们不可避免地会在不同的Command类中被复制粘贴。你至少需要考虑这些应用逻辑的功能应该属于哪部分代码。最好把这样的代码迁移到业务对象中或者放入一个外观层中。现在我们仍然缺少客户端代码(即用于创建命令对象的类)及调用者类(使用生成的命令的类)。在一个Web项目中,选择实例化哪个命令对象的最简单的办法是根据请求本身的参数来决定。下面是一个简化的客户端代码:
class CommandNotFoundException extends Exception{}
class CommandFactory{
private static $dir = 'commands';
static function getCommand($action='Default'){
if(preg_match('/\W/',$action)){
throw new Exception("illegal characters in action");
}
$class = UCFirst(strtolower($action))."Command";
$file = self::$dir.DIRECTORY_SEPARATOR."{$class}.php";
if(!file_exists($file)){
throw new CommandNotFoundException("could not find '$file'");
}
require_once($file);
if(!class_exists($class)){
throw new CommandNotFoundException("no '$class' class located");
}
$cmd = new $class();
return $cmd;
}
}
CommandFactory类在commands目录里查找特定的类文件。文件名是通过CommandContext对象的$action参数来构造的,该参数是从请求中被传到系统中的。如果文件和类都存在,那么会返回命令对象给调用者。我们可以在这里添加更多的错误检查,比如保证找到的类是Command类的子类,保证构造方法没有参数等,但目前的版本对我们来说已经足够说明问题。这种方式的优点是你可以随时将新的Command类添加到commands目录下,然后系统便立即支持它了。
下面是一个简单的调用者:
class Controller{
private $context;
function __construct(){
$this->context = new CommandContext();
}
function getContext(){
return $this->context;
}
function process(){
$cmd = CommandFactory::getCommand($this->context->get('action'));
if(!cmd->execute($this->context)){
//处理失败
}else{
//成功
//现在分发试图
}
}
}
$controller = new Controller();
//伪造用户请求
$context = $controller->getContext();
$context->addParam('action', 'login');
$context->addParam('username', 'bob');
$context->addParam('pass', 'tiddles');
$controller->process();
在调用Controller::process()之前,我们通过在控制器的构造函数中实例化的CommandContext对象上设置参数伪造了一个Web请求。process()方法将实例化命令对象的工作委托给CommandFactory对象,然后它在返回的命令对象上调用execute()方法。注意,控制器对命令内部是一无所知的。因为命令执行的细节与控制器是相互独立的,所以我们可以随时添加新的Command类而对当前的结构影响很小。
让我们再创建一个Command类:
class FeedbackCommand extends Command{
function execute(CommandContext $context){
$msgSystem = Registry::getMessageSystem();
$email = $context->get('email');
$msg = $context->get('msg');
$topic = $context->get('topic');
$result = $msgSystem->send($email, $msg, $topic);
if(!$result){
$context->setError($msgSystem->getError());
return false;
}
return true;
}
}
当这个类以FeedbackCommand.php的文件名来保存,并保存在正确的Commands目录下时,它就会被调用来响应Action为feedback的请求,而不需要对控制器或者CommandFactory做任何修改。
图11-9展示了命令模式的各个部分。