30.3. 高级用法

虽然,在基本用法中讲到的利用Zend框架的会话管理的方法是完全可以接受的,但还有些最佳实践需要去考虑。考虑在Zend_Auth的例子中默认是怎样透明地使用了Zend_Session_Namespace来持久化鉴别标识符(authentication token)。这个例子展示了快速而简便的整合Zend_Session_NamespaceZend_Auth的一个方法。

30.3.1. 开启会话

如何你希望所有的请求都有个会话值并且使用Zend框架的会话管理,那么请在程序的引导文件中开启它:

例 30.6. 开启全局会话

<?php
...
require_once 'Zend/Session.php';
Zend_Session::start();
...
?>

在程序的引导文件中开启会话,可以避免引发会话开启之前已经有HTTP头发向用户浏览器的异常,那样可能会破坏web页面的美观。许多高级的特性需要先执行Zend_Session::start()(更多高级的特性之后会展开)。

使用Zend_Session组件,有4种开启会话的方法,其中2种是错误的。

  • 1. 错误的:没有在php.ini或.htaccess文件中设置session.auto_start选项。如果在php.ini中已经开启了该选项,而你又没有权限去关闭该选项,你可以在.htaccess文件(这个文件通常在HTML文档根目录下)中增加php_value session.auto_start 0这一句。

  • 2. 错误的:直接调用session_start()开启会话。如果你直接调用session_start()开启了会话,之后再使用Zend_Session_Namespace开启,那么Zend_Session::start()会抛出"会话已经开始"的异常。如果你在使用Zend_Session_Namespace或使用Zend_Session::start()直接开启会话后调用session_start()函数,那么会产生一个E_NOTICE级别的错误,且该调用将会被忽略。

  • 3. 正确的:使用Zend_Session::start()开启会话。如果你想让每个页面请求都开启会话,那么应该在ZF应用程序的引导文件(index.php)中尽早的调用这个函数。开启会话有些额外的开销,如果只有部分页面请求需要开启会话,那么就:

    • 在ZF应用程序的引导文件中,无条件的设置strict选项为true(参考Zend_Session::setOptions())。

    • 在需要使用会话的页面请求中,首次调用new Zend_Session_Namespace()之前调用Zend_Session::start()

    • 向往常一样,在需要会话的地方,使用new Zend_Session_Namespace(),但必须确认先前已经调用过Zend_Session::start()了。

    strict选项阻止了当调用new Zend_Session_Namespace()时自动调用Zend_Session::start()开启会话。因此,这个选项有利于ZF应用程序的开发者强制执行一个设计原则以避免在某些页面请求中使用会话,因为在调用Zend_Session::start()之前,又开启了strict选项,实例化Zend_Session_Namespace时,会抛出一个异常。不要在ZF的核心代码中开启这个选项,因为只有应用程序的开发者才能决定是否执行这个设计原则。同样地,所有“程序库”的开发者,需小心地考虑在它们库代码中使用Zend_Session::setOptions()方法所引起的冲突,因为这些选项具有全局副作用。

  • 4. 正确的:只要有需要使用会话的地方,就调用new Zend_Session_Namespace(),会话会由Zend_Session自动开启。这个简单的用法能在大多数的情形下很好地工作。然而,如果你使用地是默认的基于cookie的会话(强烈推荐使用这种方式),你必须确保在第一次调用new Zend_Session_Namespace()在任何向客户端输出(也就是HTTP headers之前。使用输出缓存(output buffering)可以预防这个问题,同时也有利于改善执行效率。例如,在php.ini中,"output_buffering = 65535"设置了输出缓存为64K。

30.3.2. 锁住会话命名空间

会话的命名空间可以加锁,以防止意外的变更该命名空间下的会话变量值。使用Zend_Session_Namespacelock()方法使某命名空间下会话变量为只读,unlock()方法使一个只读的名空间为可读写,isLocked()方法测试某命名空间是否已经被加锁。加锁是短暂的,且只在此页面请求内有效,不会持续到下一个页面请求。给命名空间加锁不会影响到存储在该命名空间下对象的setter方法,但是阻止了命名空间的setter方法的移除或替换对象。也就是说,虽给Zend_Session_Namespace的命名空间加了锁,但还是不能阻止它处同样引用了命名空间下数据的对它的变更(参见PHP references)。

例 30.7. 锁住会话命名空间

<?php
    // assuming:
    $userProfileNamespace = new Zend_Session_Namespace('userProfileNamespace');

    // marking session as read only locked
    $userProfileNamespace->lock();

    // unlocking read-only lock
    if ($userProfileNamespace->isLocked()) {
        $userProfileNamespace->unLock();
    }
?>

关于管理MVC模式中的模型(models)已经存在着大量不同的思想,包括创建视图所使用的表现层模型。有时已存在的数据,无论是否属于领域模型的数据,已经足够完成任务。为了阻止在视图中修改那些数据,可以考虑在允许视图访问表现层模型前给会话命名空间加锁。

例 30.8. 锁住视图中的会话

<?php
class FooModule_View extends Zend_View
{
    public function show($name)
    {
        if (!isset($this->mySessionNamespace)) {
            $this->mySessionNamespace = Zend::registry('FooModule');
        }

        if ($this->mySessionNamespace->isLocked()) {
            return parent::render($name);
        }

        $this->mySessionNamespace->lock();
        $return = parent::render($name);
        $this->mySessionNamespace->unLock();

        return $return;
    }
}
?>

30.3.3. 会话封装和控制器

命名空间可以被用来分离控制器对会话的访问,以免被污染。例如, 'Zend_Auth'控制器可以保持它的会话状态数据与其他控制器分离。

例 30.9. 带有生命期的控制器命名空间会话

<?php
require_once 'Zend/Session.php';
// question view controller
$testSpace = new Zend_Session_Namespace('testSpace');
$testSpace->setExpirationSeconds(300, "accept_answer"); // expire only this variable
$testSpace->accept_answer = true;

-- 

// answer processing controller
$testSpace = new Zend_Session_Namespace('testSpace');

if ($testSpace->accept_answer === true) {
    // within time
}
else {
    // not within time
}
?>

30.3.4. 限制每个命名空间的Zend_Session_Namespace实例

我们推荐使用会话加锁(上面的)来取代下面讲到的特性,因为它额外地加重了开发者的负担,需要传递Zend_Session_Namespace的实例给需要存取会话变量的函数和对象。

当你构造第一个Zend_Session_Namespace实例并把它绑定到一个具体的命名空间上时,你可以指出不允许在该命名空间下构造更多的Zend_Session_Namespace实例。因此,任何试图再次构造该命名空间下的Zend_Session_Namespace实例都将抛出一个异常。这样的行为是可选的,且不是默认的行为,是留给喜欢为每个命名空间传递一个单一实例的人用的。这增强了保护,免受来自其他组件的非法变更,因为他们很难访问。然而,限制一个命名空间只能有一个实例可能会导致编写更多的代码或使得代码复杂化,同时在第一个实例创建后,将不能调用$aNamespace = new Zend_Session_Namespace('aNamespace');,就像下面例子中所述的:

例 30.10. 限制为单一实例

<?php
    require_once 'Zend/Session.php';
    $authSpaceAccessor1 = new Zend_Session_Namespace('Zend_Auth');
    $authSpaceAccessor2 = new Zend_Session_Namespace('Zend_Auth', Zend_Session_Namespace::SINGLE_INSTANCE);
    $authSpaceAccessor1->foo = 'bar';
    assert($authSpaceAccessor2->foo, 'bar'); // passes
    doSomething($options, $authSpaceAccessor2); // pass the accessor to wherever it is needed
    .
    .
    .
    $aNamespaceObject = new Zend_Session_Namespace('Zend_Auth'); // this will throw an error
?>

上述构造器中的第二个参数告诉Zend_Session_Namespace,之后任何实例化命名空间为'Zend_Auth'的Zend_Session_Namespace对象都是不允许的,且将会抛出异常。因为,执行过上面一段代码后,将不允许执行new Zend_Session('Zend_Auth'),如果当前页面请求的剩余时间段还需要访问该命名空间,则开发人员有责任在某处储存该命名空间的实例对象(上例中的$authSpaceAccessor2)。例如,开发者可以把该实例对象储存在静态变量中,或者把它传递给需要访问该命名空间的函数。会话加锁(参见上面的)为限制访问命名空间,提供了一个更加轻便合理的方法。

30.3.5. 操作命名空间下的数组

要修改命名空间下的数组是行不通的。最简单的解决方案是在数组的所有值确定之后再存储它。文档ZF-800指出的一个已知问题,影响了许多使用魔术方法和数组的PHP应用程序。

例 30.11. 已知的命名空间下数组的问题

<?php
    $sessionNamespace = new Zend_Session_Namespace('Foo');
    $sessionNamespace->array = array();
    $sessionNamespace->array['testKey'] = 1; // does not work before PHP 5.2.1
?>

如果你需要数组在赋值到会话命名空间后修改数组的值,得先取出该数组,然后:

例 30.12. Workaround: 取出,修改,存回

<?php
    $sessionNamespace = new Zend_Session_Namespace('Foo');
    $sessionNamespace->array = array('tree' => 'apple');
    $tmp = $sessionNamespace->array;
    $tmp['fruit'] = 'peach';
    $sessionNamespace->array = $tmp;
?>

作为可选的方案,存储一个包含目标数组引用的数组,然后可直接访问目标数组。

例 30.13. Workaround: 存储包含目标数组引用的数组

<?php
    $myNamespace = new Zend_Session_Namespace('mySpace');

    // works, even for broken versions of PHP
    $a = array(1,2,3);
    $myNamespace->someArray = array( & $a ) ;
    $a['foo'] = 'bar';
?>

30.3.6. 在身份验证中使用会话

如果你的Zend_Auth身份验证的接配器返回的结果(Zend_Auth_Result)中授权标识(authorization identity)是个对象(不推荐),而不是数组,那么请确保在开启会话之前包含会话标识类的定义。我们推荐在身份验证接配器中计算出来的授权标识储存到会话命名空间一个众所周知的键名下面。例如,默认Zend_Auth把授权标识储存到'Zend_Auth'命名空间的'storage'键名下。

如果你告诉Zend_Auth不持久化身份验证标识到会话中,那么你可以手动地储存授权标识到某个会话命名空间的某个众所周知的键名下。常常,应用程序需要知道哪里储存了授权标识及其相关信息。在身份验证期间,应用程序常常把身份验证标识(如,用户名)映射为授权标识(如,一个被分配的唯一的整数),这通常发生在Zend_Auth身份验证接配器的authenticate()方法中。

例 30.14. 简化访问授权标识

<?php
    // pre-authentication request
    require_once 'Zend/Auth/Result.php';
    require_once 'Zend/Auth/Adapter/Digest.php';
    $adapter = new Zend_Auth_Adapter_Digest($filename, $realm, $username, $password);
    $result = $adapter->authenticate();
    require_once 'Zend/Session/Namespace.php';
    $namespace = Zend_Session_Namespace('Zend_Auth');
    if ($result->isValid()) {
        $namespace->authorizationId = $result->getIdentity();
        $namespace->date = time();
    } else {
        $namespace->attempts++;
    }

    // subsequent requests
    require_once 'Zend/Session.php';
    Zend_Session::start();
    $namespace = Zend_Session_Namespace('Zend_Auth');

    echo "Valid: ", (empty($namespace->authorizationId) ? 'No' : 'Yes'), "\n"';
    echo "Authorization / user Id: ", (empty($namespace->authorizationId)
        ? 'none' : print_r($namespace->authorizationId, true)), "\n"';
    echo "Authentication attempts: ", (empty($namespace->attempts)
        ? '0' : $namespace->attempts), "\n"';
    echo "Authenticated on: ",
        (empty($namespace->date) ? 'No' : date(DATE_ATOM, $namespace->date), "\n"';
?>

30.3.7. 在单元测试中使用会话

Zend Framework利用PHPUnit来促进自身代码的测试。大多数开发者在他们的应用程序中,扩展已有的一组单元测试,以覆盖测试他们的代码。在运行单元测试时,如果在结束会话之后使用了写相关的方法,那么会抛出"当前Zend_Session被标记为只读"的异常。在单元测试中使用Zend_Session需要额外的注意,因为在关闭会话(Zend_Session::writeClose()),或者摧毁一个会话(Zend_Session::destroy())之后,不允许再设置或注销任何一个Zend_Session_Namespace的键名了。 这样是由底层PHP的会话机制session_destroy()session_write_close()所直接引起的,因为它未提供“撤销”机制以便单元测试setup/teardown。

围绕这一工作,参见单元测试tests/Zend/Session/SessionTest.php中的testSetExpirationSeconds()方法和SessionTestHelper.php,利用了PHP的exec()发起一个独立的过程。新的过程准确地模拟了一个来自浏览器的继上次之后的第二个请求。独立请求始于一个“干净”的会话,就像为任一请求执行PHP脚本。同时,要使$_SESSION[]在子过程中可更改,那么需要在父过程执行exec()之前关闭会话。

例 30.15. 使用PHPUnit测试由Zend_Session*写成的代码

<?php
        // testing setExpirationSeconds()
        require 'tests/Zend/Session/SessionTestHelper.php'; // also see SessionTest.php in trunk/
        $script = 'SessionTestHelper.php';
        $s = new Zend_Session_Namespace('space');
        $s->a = 'apple';
        $s->o = 'orange';
        $s->setExpirationSeconds(5);

        Zend_Session::regenerateId();
        $id = Zend_Session::getId();
        session_write_close(); // release session so process below can use it
        sleep(4); // not long enough for things to expire
        exec($script . "expireAll $id expireAll", $result);
        $result = $this->sortResult($result);
        $expect = ';a === apple;o === orange;p === pear';
        $this->assertTrue($result === $expect,
            "iteration over default Zend_Session namespace failed; expecting result === '$expect', but got '$result'");

        sleep(2); // long enough for things to expire (total of 6 seconds waiting, but expires in 5)
        exec($script . "expireAll $id expireAll", $result);
        $result = array_pop($result);
        $this->assertTrue($result === '',
            "iteration over default Zend_Session namespace failed; expecting result === '', but got '$result')");
        session_start(); // resume artificially suspended session

        // We could split this into a separate test, but actually, if anything leftover from above
        // contaminates the tests below, that is also a bug that we want to know about.
        $s = new Zend_Session_Namespace('expireGuava');
        $s->setExpirationSeconds(5, 'g'); // now try to expire only 1 of the keys in the namespace
        $s->g = 'guava';
        $s->p = 'peach';
        $s->p = 'plum';

        session_write_close(); // release session so process below can use it
        sleep(6); // not long enough for things to expire
        exec($script . "expireAll $id expireGuava", $result);
        $result = $this->sortResult($result);
        session_start(); // resume artificially suspended session
        $this->assertTrue($result === ';p === plum',
            "iteration over named Zend_Session namespace failed (result=$result)");
?>