由 Phalcon Model 的 “bug” 引发的对 PHP Iterator 和 ArrayAccess 的探索

0x00 奇怪的现象

最近在写 Phalcon ORM 查询结果集遍历的时候发现 Phalcon ORM 有个很神奇的现象:当你用 foreach 遍历 Model->find() 之后的结果(Phalcon\Mvc\Model\Resultset\Simple),如果在遍历中有访问下一个($key + 1)元素,就会自动执行 next(),下一个元素就不会出现在 foreach 的下一次遍历中了...

举个例子,比如说我们有一张表 users,我们创建一个 User model,执行如下代码:

<?php

$users = \Models\User::find([
    'conditions' => 'role = 1'    // 这里可以是别的查询条件
]);

// 这里假设查询出来的记录只有两条,即 count($users) == 2

foreach ($users as $key => $user) {
    echo "key: $key, user_id: {$user->id}\n";
    // 假设这里因为业务逻辑的需要访问下一个 model
    $foo = $users[$key + 1];
}

你可能会觉得上面的循环在执行到第二次会报 PHP Notice: Undefined offset,但实际上,它什么也没报,只是安安静静地输出了:

key: 0, user_id: 1

嗯?第二次循环的输出怎么不见了?试下将 $foo = $users[$key + 1]; 那一行去掉,看看输出什么:

key: 0, user_id: 1
key: 1, user_id: 2

0x01 实验

嗯??第二次循环的输出出现了!用 Xdebug 跟一下吧。

结果还是发现,只要在循环中访问了下一个元素,这个元素就不会再出现在遍历中了,amazing,好像跟下面这个代码的执行效果不一样啊

<?php
$foo = [
    0 => 'a',
    1 => 'b',
];

foreach ($foo as $k => $v) {
    echo "key: $k, value: $v\n";
    $bar = @$foo[$k + 1];
}
// 输出:
// key: 0, value: a
// key: 1, value: b

0x02 查文档,看源码

翻一下 Phalcon 的文档吧,于是在文档中看到 Resultset 是实现了 Iterator 的,所以它有个 key() 方法,把代码改成这样再看看:

foreach ($users as $key => $user) {
    echo "key: $key, key(): {$user->key()}, user_id: {$user->id}\n";
    // 假设这里因为业务逻辑的需要访问下一个 model
    $foo = $users[$key + 1];
    echo "key() again: {$user->key()}";
}

输出:

key: 0, key(): 0, user_id: 1
key() again: 1

明明没有显式执行 Iteratornext(),为啥 key 会加 1 了?!Iteratornext() 不应该是 foreach 结束后才执行的吗?找一下 Phalcon 的代码,看看是怎么实现的吧。

Resultset 里的 next() 也很正常,就是简单的一句让指针加一:

/**
 * Moves cursor to next row in the resultset
 */
public function next() -> void
{
    // Seek to the next position
    this->seek(
        this->pointer + 1
    );
}

0x03 真相大白

不过我发现,Resultset 还实现了 ArrayAccess,也就是说它有 offsetGet() 这个方法(这个方法允许我们以 [] 的方式去访问元素),我们看看它是怎么实现的:

/**
 * Gets row in a specific position of the resultset
 */
public function offsetGet(var index) -> <ModelInterface> | bool
{
    if unlikely index >= this->count {
        throw new Exception("The index does not exist in the cursor");
    }

    /**
     * Move the cursor to the specific position
     */
    this->seek(index);

    return this->{"current"}();
}

是用 seek() 这个方法来取得的,看下 seek() 是怎么实现的,首先看到 seek() 前的注释:

/**
 * Changes the internal pointer to a specific position in the resultset.
 * Set the new position if required, and then set this->row
 */
final public function seek(var position) -> void

what???将内部的指针直接指向指定的 position???看了一下代码(代码比较长,这里就不贴了,感兴趣的可以直接看上面链接指向的代码),还真是,而且,它的 key() 是这样实现的:

/**
 * Gets pointer number of active row in the resultset
 */
public function key() -> int | null
{
    if !this->valid() {
        return null;
    }

    return this->pointer;
}

也就是说 seek() 会影响到 key() 从而影响到 foreach() 的走向。。。真相大白了。。。

0x04 总结与思考

总结来说就是 Phalcon 对 ArrayAccess 的实现不规范,那我们该如何规避这个问题呢,不难,只需要遍历的时候,在我们需要通过 [] 访问非当前元素之前,用 key() 取得当前内部指针并存起来,在访问完之后使用 seek() 将内部指针指向该次循环的实际位置即可

0x06 思考题

最后留一个思考题吧

将最开始那一段代码中的 $foo = $users[$key + 1]; 改成 $foo = isset($users[$key + 1]);,为什么会有不一样的结果?

如果你仔细看了文中提到的代码,和一些相关的文档,你应该很快就能知道为什么 :-P

标签:php, phalcon

添加新评论