[ PHP 内核与扩展开发系列] PHP 中的资源类型:复合数据类型 —— 资源

截止到现在,我们已经熟悉了 PHP 语言中的字符串、数字、布尔以及数组等数据类型了,接下来,我们将接触另外一种 PHP 独特的数据类型——资源(Resource)。

讲述之前,先描述下资源类型在内核中的结构:

typedef struct _zend_rsrc_list_entry { 
    void *ptr; 
    int type; 
    int refcount; 
} zend_rsrc_list_entry;

在真实世界中,我们经常需要操作一些不好用标量值表现的数据,比如某个文件的句柄,而对于 C 来说,它也仅仅是个指针而已。

#include <stdio.h>
int main(void)
{
    FILE *fd;
    fd = fopen("/home/jdoe/.plan", "r");
    fclose(fd);
    return 0;
}

C 语言中的文件描述符是与每个打开的文件相匹配的一个变量,它实际上是一个 FILE 类型的指针,它将在程序与硬件交互通讯时使用。我们可以使用 fopen 函数来打开一个文件获取句柄,之后只需把这个句柄传递给 feof()fread()fwrite()fclose()之类的函数,便可以对这个文件进行后续操作了。既然这个数据在 C 语言中就无法直接用标量数据来表示,那我们如何对其进行封装才能保证用户在 PHP 语言中也能使用到它呢?这便是 PHP 中资源类型变量的作用了!它也是通过一个 zval 结构来进行封装的。资源类型的实现并不复杂,它的值其实仅仅是一个整数,内核将根据这个整数值去一个类似资源池的地方寻找最终需要的数据。

注册资源类型

资源类型的变量在实现时也是有类型区分的,为了区分不同类型的资源,比如一个是文件句柄,一个是 MySQL 连接,我们需要为其赋予不同的分类名称。首先,我们需要先把这个分类添加到程序中去,这一步的操作可以在MINIT 中来做:

#define PHP_SAMPLE_DESCRIPTOR_RES_NAME "学院文件描述符"
static int academy_sample_descriptor;
ZEND_MINIT_FUNCTION(academy_sample_resource)
{
    academy_sample_descriptor = zend_register_list_destructors_ex(NULL, NULL, PHP_SAMPLE_DESCRIPTOR_RES_NAME, module_number);
    return SUCCESS;
}

其中用到的宏函数 zend_register_list_destructors_ex() 定义如下:

#define register_list_destructors(ld, pld) zend_register_list_destructors((void (*)(void *))ld, (void (*)(void *))pld, module_number);
ZEND_API int zend_register_list_destructors(void (*ld)(void *), void (*pld)(void *), int module_number);
ZEND_API int zend_register_list_destructors_ex(rsrc_dtor_func_t ld, rsrc_dtor_func_t pld, char *type_name, int module_number);

接下来,我们把定义好的 MINIT 阶段的函数添加到扩展的 module_entry 里去,只需要把原来的 "NULL, /* MINIT */" 一行替换掉即可:

ZEND_MINIT(academy_sample_resource), /* MINIT */

ZEND_MINIT_FUNCTION() 宏用来帮助我们定义 MINIT 阶段的函数,这我们已经在第一章里描述过了,但将会在后续章节有更详细的阐述,在这个时间点上我们需要知道的是 MINIT 方法会在扩展首次加载且任何请求都未被接收之前执行一次。这里已经使用这个机会来注册析构函数的 NULL 值,不过该值很快会被资源类型的唯一整型ID所替代。看到 zend_register_list_destructors_ex() 函数,你肯定会想是不是也存在一个 zend_register_list_destructors() 函数呢?是的,确实有这么一个函数,它的参数中比前者少了资源类别的名称。那这两者的区别在哪呢?看下面的例子:

echo $res_1;
//resource(4) of type (学院版File句柄)

echo $res_2;
//resource(4) of type (Unknown)

创建资源

我们在上面向内核中注册了一种新的资源类型,下一步便可以创建这种类型的资源变量了。接下来让我们简单的重新实现一个 fopen 函数,现在叫 academy_sample_open

PHP_FUNCTION(academy_sample_fopen)
{
    FILE *fp;
    char *filename, *mode;
    int filename_len, mode_len;
    if (zend_parse_parameters(ZEND_NUM_ARGS() TSRMLS_CC, "ss", &filename, &filename_len, &mode, &mode_len) == FAILURE)
    {
            RETURN_NULL();
    }
    if (!filename_len || !mode_len)
    {
           php_error_docref(NULL TSRMLS_CC, E_WARNING,"Invalid filename or mode length");
            RETURN_FALSE;
    }
    fp = fopen(filename, mode);
    if (!fp)
    {
        php_error_docref(NULL TSRMLS_CC, E_WARNING,"Unable to open %s using mode %s", filename, mode);
            RETURN_FALSE;
    }
    // 将fp添加到资源池并标记为academy_sample_descriptor类型
    ZEND_REGISTER_RESOURCE(return_value, fp, academy_sample_descriptor);
}

如果前面章节的知识你都看过的话,应该可以猜出最后一行代码是干啥的了。它创建了一个新的le_sample_descriptor 类型的资源,此资源的值是 fp,另外它把这个资源加入到一个存储资源的 HashTable 中,并把此资源在其中对应的数字 Key 赋给 return_value

资源并不局限于文件句柄,我们可以申请一块内存,然后将指向它的指针作为一种资源。所以资源可以对应任意类型的数据。

重新编译该扩展,编写如下测试代码:

<?php
    $fp = academy_sample_fopen("functions.php", "a");
    var_dump($fp);

执行该脚本,输出如下:

resource(4) of type (学院文件描述符)

销毁资源

世间万物皆有喜有悲,有生有灭,到了我们探讨如何销毁资源的时候了。

最简单的一种莫过于仿照 fclose 写一个 academy_sample_close() 函数,在它里面实现对某种资源(专指 PHP 的资源类型变量代表的值)的释放。但是,如果用户端的脚本通过 unset() 函数来释放某个资源类型的变量会如何呢?它们可不知道它的值最终对应一个 FILE 指针啊,所以也无法使用 fclose() 函数来释放它,这个 FILE 句柄很有可能会一直存在于内存中,直到 PHP 程序挂掉,由 OS 来回收,但在一个通常的 Web 环境中,我们的服务器都会长时间运行的。难道就没有解决方案了吗?当然不是,谜底就在那个 NULL 参数里,就是我们在上面为了生成新的资源类型,调用的 zend_register_list_destructors_ex() 函数的第一个参数和第二个参数。

这两个参数都各自代表一个回调函数:第一个回调函数会在脚本中相应类型的资源变量被释放掉的时候触发,比如作用域结束了,或者被 unset() 掉了;第二个回调函数则是用在一个类似于长连接类型的资源上的,也就是这个资源创建后会一直存在于内存中,而不会在请求结束后被释放掉。它将会在 Web 服务器进程终止时调用,相当于在 MSHUTDOWN 阶段被内核调用。有关持久化资源的事宜,我们将在下一节里详述。

我们先来定义第一个回调函数:

static void php_sample_descriptor_dtor(zend_rsrc_list_entry *rsrc TSRMLS_DC)
{
    FILE *fp = (FILE*)rsrc->ptr;
    fclose(fp);
}

然后用它替换掉 zend_register_list_destructors_ex() 函数的第一个参数 NULL

academy_sample_descriptor = zend_register_list_destructors_ex(
        php_sample_descriptor_dtor,
        NULL,
        PHP_SAMPLE_DESCRIPTOR_RES_NAME,
        module_number
);

现在,如果脚本中得到了一个上述类型的资源变量,当它被 unset 的时候,或者因为作用域执行完被内核释放掉的时候都会被内核调用底层的 php_sample_descriptor_dtor 来预处理它。这样一来,貌似我们根本就不需要 academy_sample_close() 函数了:

<?php
    $fp = academy_sample_fopen("functions.php", "a");
    unset($fp);
    var_dump($fp);
?>

此时,执行脚本后输出为 NULLunset($fp) 执行后,内核会自动调用 php_sample_descriptor_dtor 函数来清理这个变量对应的一些数据。当然,事情绝对没有这么简单,让我们先记住这个疑问,继续往下看。

解码资源

我们把资源变量比作书签,可如果仅有书签的话绝对没有任何作用啊!我们需要通过书签找到相应的页才行。对于资源变量,我们必须可以通过它找到相应的资源数据才行:

ZEND_FUNCTION(academy_sample_fwrite)
{
    FILE *fp;
    zval *file_resource;
    char *data;
    int data_len;
    if (zend_parse_parameters(ZEND_NUM_ARGS() TSRMLS_CC, "rs", &file_resource, &data, &data_len) == FAILURE )
    {
            RETURN_NULL();
    }

    // 使用 zval* 来验证资源类型并获取其指针
    ZEND_FETCH_RESOURCE(fp, FILE*, &file_resource, -1, PHP_SAMPLE_DESCRIPTOR_RES_NAME, academy_sample_descriptor);

    // 写入数据并返回成功写入文件的字节数
    RETURN_LONG(fwrite(data, 1, data_len, fp));
}

zend_parse_parameters() 函数中的 r 占位符代表着接收资源类型的变量,它的载体是一个zval*。然后让我们看一下 ZEND_FETCH_RESOURCE() 宏函数:

#define ZEND_FETCH_RESOURCE(rsrc, rsrc_type, passed_id, default_id, resource_type_name, resource_type)
    rsrc = (rsrc_type) zend_fetch_resource(passed_id TSRMLS_CC, default_id, resource_type_name, NULL, 1, resource_type);
    ZEND_VERIFY_RESOURCE(rsrc);

在我们的例子中,它是这样的:

fp = (FILE*) zend_fetch_resource(&file_resource TSRMLS_CC, -1, PHP_SAMPLE_DESCRIPTOR_RES_NAME, NULL, 1, academy_sample_descriptor);
if (!fp)
{
    RETURN_FALSE;
}

zend_fetch_resource() 是对 zend_hash_find() 的一层封装,它使用一个数字 key 去一个保存各种资源的 HashTable 中寻找最终需要的数据,找到之后,我们用 ZEND_VERIFY_RESOURCE() 宏函数校验一下这个数据。从上面的代码中我们可以看出,NULL0 是绝对不能作为一种资源的。

上面的例子中,zend_fetch_resource() 函数首先获取 academy_sample_descriptor 代表的资源类型,如果资源不存在或者接收的 zval 不是一个资源类型的变量,它便会返回 NULL,并抛出相应的错误信息。最后的 ZEND_VERIFY_RESOURCE() 宏函数如果检测到错误,便会自动返回,使我们可以从错误检测中脱离出来,更加专注于程序的主逻辑。现在我们已经获取到了相应的 FILE* 了,下面就用 fwrite() 向其中写入点数据吧。

重新编译扩展,编写一段测试脚本测试此功能:

<?php
    $fp = academy_sample_fopen("test", "a");
    $bytes = academy_sample_fwrite($fp, "Laravel Academy!");
    unset($fp);
    echo "写入成功字节数:" . $bytes . "\n";

运行脚本,输出如下:

写入成功字节数:16

要避免在 zend_fetch_resource() 在失败的时候生成错误,只需向 resource_type_name 参数传递 NULL 值即可。

我们也可以通过另一种方法来获取我们最终想要的数据:

ZEND_FUNCTION(academy_sample_fwrite)
{
    FILE *fp;
    zval *file_resource;
    char *data;
    int data_len, rsrc_type;
    if (zend_parse_parameters(ZEND_NUM_ARGS() TSRMLS_CC, "rs",&file_resource, &data, &data_len) == FAILURE ) {
        RETURN_NULL();
    }
    fp = (FILE*)zend_list_find(Z_RESVAL_P(file_resource), &rsrc_type);
    if (!fp || rsrc_type != academy_sample_descriptor) {
        php_error_docref(NULL TSRMLS_CC, E_WARNING,"Invalid resource provided");
        RETURN_FALSE;
    }
    RETURN_LONG(fwrite(data, 1, data_len, fp));
}

可以根据自己习惯来选择到底使用哪一种形式,不过推荐使用 ZEND_FETCH_RESOURCE() 宏函数。

强制销毁

在上面我们还有个疑问没有解决,就是类似于我们上面实现的 unset($fp) 真的是万能的么?当然不是,看一下下面的代码:

<?php
  $fp = academy_sample_fopen("/tmp/world_domination.log", "a");
  $evil_log = $fp;
  unset($fp);
?>

这次,$fp$evil_log 共用一个 zval,虽然 $fp 被释放了,但是它的 zval 并不会被释放,因为 $evil_log 还在用着。也就是说,现在 $evil_log 代表的文件句柄仍然是可以写入的!为了避免这种错误,需要我们手动来关闭它。academy_sample_close() 函数是必须存在的:

PHP_FUNCTION(academy_sample_fclose)
{
    FILE *fp;
    zval *file_resource;
    if (zend_parse_parameters(ZEND_NUM_ARGS() TSRMLS_CC, "r",&file_resource) == FAILURE ) {
        RETURN_NULL();
    }

    /** 
     * 尽管不必真的获取 FILE* 资源, 
     * 执行该操作可以为我们提供一个机会来验证关闭的是正确的资源类型
     */
    ZEND_FETCH_RESOURCE(fp, FILE*, &file_resource, -1,PHP_SAMPLE_DESCRIPTOR_RES_NAME, academy_sample_descriptor);

    // 强制资源进入自销毁模式
    zend_hash_index_del(&EG(regular_list), Z_RESVAL_P(file_resource));
    RETURN_TRUE;
}

这个删除操作也再次说明了资源数据是保存在 HashTable 中的。虽然我们可以通过zend_hash_index_find() 或者 zend_hash_next_index_insert() 之类的函数操作这个储存资源的 HashTable,但这绝不是一个好主意,因为在后续的版本中,PHP 可能会修改有关这一部分的实现方式,到那时上述方法便不起作用了,所以为了更好的兼容性,请使用标准的宏函数或者 API 函数。当我们在EG(regular_list) 这个 HashTable 中删除数据的时候,会调用一个 dtor 函数,它根据资源变量的类别来调用相应的 dtor 函数实现,就是我们调用 zend_register_list_destructors_ex() 函数时的第一个参数。

在很多地方,我们都会看到一个专门用来删除的 zend_list_delete() 宏函数,因为它考虑了资源数据自己的引用计数,所以我们将在后面的章节中介绍它。

学院君 has written 703 articles

资深PHP工程师,Laravel学院院长

One thought on “[ PHP 内核与扩展开发系列] PHP 中的资源类型:复合数据类型 —— 资源

发表评论

标记为*的字段是必填项(邮箱地址不会被公开)

你可以使用这些HTML 标签和属性: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>