PHP扩展初探

前言

本文主要简单介绍(本人水平有限,也是边学习边写)一下PHP的生命周期,扩展的开发步骤,以及扩展开发一些常用的宏,最后小试牛刀,写一个函数。阅读本文前,您需要掌握基础的C/C++的知识。

我们先把php的源码克隆下来

git clone https://github.com/php/php-src.git
cd php-src
git checkout -b my_ext

进入ext目录,我们会看见两个文件ext_skel和ext_skel_win32.php,从文件名我们就可以看出一个是在*unix系统运行,另外一个是在windows环境下运行。这个文件是生成扩展目录的脚手架,用这个脚本生成的扩展目录里面包含了扩展所需要的符合php扩展开发规范的基本文件及文件夹。比如我们的扩展名字叫myExt

./ext_skel --extname=my_ext

我们在查看下ext目录,是不是多了一个myExt目录,进入这个目录,我们修改config.m4文件,找到第16和18行,把前面的注释符dnl去掉,现在我们返回根目录生成configure脚本并编译PHP

./buildconf
./configure --enable-debug --enable-my_ext
make

这样我们就简单的完成的PHP的编译。(如果报错,请确定已经安装了php的基础依赖库。比如re2c, bison, 实在解决不了,我们可以相互交流:)现在我们来测试下php是否已经载入刚刚生成的扩展。首先,用以下命令看下我们的扩展是否已经编译

image.png

可以看到,扩展已经能被php所列出,表示编译成功。但这并不能表示扩展已经成功载入,扩展文件在生成的时候,有一个以php为后缀的文件,这是为我们生成的测试文件,我们可以执行下这个文件看下输出

image.png

输出的信息我想大家都看的懂。扩展已经载入成功。如果你还不放心,我们可以采用php通用的运行测试的办法来测试下,扩展目录下的tests文件夹就是脚手架给我们生成放测试用例的,里面已经有了一个随扩展生成的测试用例(我们的扩展改变了,那我们也应该在这个目录写相应的测试用例),运行以下命令并查看输出

PHP扩展初探_第1张图片
image.png

我们可以看到测试用例通过。(以上操作和php版本无关,同样可以运行在php5上)

准备工作已经完成,下面我们来介绍一下扩展的两个主要文件my_ext.cphp_my_ext.h,这两个文件符合php的扩展命名规范,源文件$extname.c,头文件php_$extname.h,扩展的入口就是这两个文件了。所以可想而知,我们只要修改这两个文件就行了。我们简单介绍下my_ext.c文件。打开这个文件,把鼠标以每小时180公里的速度移动到最下面,我们会看到这么一个结构体(忽略第一个和最后一个,这是默认信息,主要看中间的)

zend_module_entry my_ext_module_entry = {
    STANDARD_MODULE_HEADER,
    "my_ext",                /*扩展名称*/
    my_ext_functions,        /*扩展的函数数组*/
    PHP_MINIT(my_ext),       /*扩展初始化函数*/
    PHP_MSHUTDOWN(my_ext),   /*扩展关闭时调用函数(php-fpm关闭时)*/
    PHP_RINIT(my_ext),      /* Replace with NULL if there's nothing to do at request start */
    PHP_RSHUTDOWN(my_ext),  /* Replace with NULL if there's nothing to do at request end */
    PHP_MINFO(my_ext),      /*扩展信息,就是phpinfo将会输出的信息*/
    PHP_MY_EXT_VERSION,     /*扩展版本*/
    STANDARD_MODULE_PROPERTIES
};

这里简单的介绍下php的生命周期,在单进程模式(cli)下,在如下图:

PHP扩展初探_第2张图片
image.png

我们可以看到,进程启动和销毁时执行MODULE INITMODULE SHUTDOWN,他们在整个进程的生命周期内只执行一次(比如扩展配置文件的载入就是发生在这个函数内),当有请求到来和结束时分别执行REQUEST INITREQUEST SHUTDOWN,他们在每次请求的生命周期内都执行。每个阶段分别对应上面的函数宏。(多进程模式就是把上图大矩形的内容平行复制多个,多线程模式就是把请求生命周期在一个进程内复制多个)

说到这是不是对扩展的载入流程有个初步的了解呢?我们继续看下面这段代码

PHP_FUNCTION(confirm_my_ext_compiled)
{
    char *arg = NULL;
    size_t arg_len, len;
    zend_string *strg;

    if (zend_parse_parameters(ZEND_NUM_ARGS(), "s", &arg, &arg_len) == FAILURE) {
        return;
    }

    strg = strpprintf(0, "Congratulations! You have successfully modified ext/%.78s/config.m4. Module %.78s is now compiled into PHP.", "my_ext", arg);

    RETURN_STR(strg);
}

这段代码,大家一眼看去可能就会想这大概是实现一个phpconfirm_my_ext_compiled函数。对,聪明的你们猜的一点都没错。你们是不是对这个函数的输出信息有点熟悉?上面我们执行过一个.php文件,其实那个文件就是对这个函数测试。那我们照葫芦画瓢,也开始写一个。

不知道你们在日常编程中,是否遇到过这种情况,我们经常需要将一个二维数组按照某个字段分组,然后返回新的数组。比如,需要将查出的订单数据按照订单的发起人分组,按照商家分组等等。我们先设计下这个函数,这个函数需要按照某个字段分组,那肯定需要一个参数key,然后就是待分组的数组input,最后我们是否需要在分组后的数组中保留原先的字段key,它显然是一个Bool类型的, 而且默认为false。所以函数原型就出来了:

array_groupBy(string $key, array $input, $forget = false):array

我们根据原型然后综合上面测试函数的例子把函数框架先搭起来,看起来是这样的

PHP_FUNCTION(array_groupBy){

    zend_string *key;
    zval *input, keyZval, forgetZval;
    zend_bool forget = 0;
    HashTable *ht;

    if(zend_parse_parameters(ZEND_NUM_ARGS(), "Sa|b", &key, &input, &forget) == FAILURE){
        return;
    }
}

我们找到以下代码,并注册我们写的函数

const zend_function_entry my_ext_functions[] = {
    PHP_FE(confirm_my_ext_compiled, NULL)       /* For testing, remove later. */
    PHP_FE(array_groupBy, NULL)
    PHP_FE_END  /* Must be the last line in my_ext_functions[] */
};

首先,我们函数进入第一步要做什么?毫无疑问,我们要接收实参。扩展自动生成的测试函数,给了我们很好的例子。我们用如下函数来接收实参。

int zend_parse_parameters(int num_args, const char *type_spec, ...); //这个函数是一个可变参数的函数
  • num_args 实参数目
  • type_spec 这是类型说明符,全部的类型说明符我们可以在根目录下的 README.PARAMETER_PARSING_API 文件可以找到,并且都有详细的说明和例子,这里不再一一阐述。
  • 类型说明符对应的本地变量(可变参数,C的可变参数如何实现的,已超出本文的讲解范围,请自己通过google关键字搜索,相信你一定可以看的懂)。

我们简单的修改下代码,把传入的参数以数组的形式返回,看看到底接受到实参了没有:

PHP_FUNCTION(array_groupBy){

    zend_string *key;
    zval *input, keyZval, forgetZval;
    zend_bool forget = 0;
    HashTable *ht;

    if(zend_parse_parameters(ZEND_NUM_ARGS(), "Sa|b", &key, &input, &forget) == FAILURE){
        return;
    }

    ht = emalloc(sizeof(HashTable)); // 给HashTable分配一块内存
    zend_hash_init(ht, 3, NULL, ZVAL_PTR_DTOR, 0); //初始化HashTable,设置一些基础值什么的
    
    //将传入的key转化成一个zval*类型,因为下面的hash方法参数只支持zval*,尴尬了。
    //不过在zend_API.h有相应的宏可以替代下面的方法,我就不写了。
    ZVAL_STR(&keyZval, key);
    ZVAL_BOOL(&forgetZval, forget);
    convert_to_long(&forgetZval);
    
    zend_hash_str_add_new(ht, "key", strlen("key"), &keyZval);
    zend_hash_str_add_new(ht, "input", strlen("input"), input);
    zend_hash_str_add_new(ht, "forget", strlen("forget"), &forgetZval);
    
    RETURN_ARR(ht);
}

我们重新回到根目录下make项目,然后进入扩展目录下写一个a.php文件。

$key = 'birthday';
$input = [
    [
        'name' => 'fangxing',
        'birthday' => 1993
    ],
    [
        'name' => 'marco',
        'birthday' => 1990
    ]
];
$forget = false;
print_r(array_groupBy($key, $input, $forget));

运行

./sapi/cli/php ext/my_ext/a.php

输出

PHP扩展初探_第3张图片
image.png

这是PHP5的接收参数的形式,在php7还有一种宏实现。直接上代码,具体的实现可以去看zend_API.h.

ZEND_PARSE_PARAMETERS_START(2, 3)
    Z_PARAM_STR(key)
    Z_PARAM_ARRAY(input)
    Z_PARAM_OPTIONAL
    Z_PARAM_BOOL(forget)
ZEND_PARSE_PARAMETERS_END();

现在把上面接收参数的替换成这个,再make你会发现效果是一样的。换汤不换药,之前是在解析类型说明符的时候调用对应的函数,现在是每个宏里面调用对应的函数,省了一步解析操作,代码多了N行。

回到我们要实现的函数,要将现有的数组按照key的值分组,分组后的数组肯定是一个至少三维的数组,如下:

$output = [
    'group_value1' => [
        //符合条件的值
    ],
    'group_value2' => [
        //符合条件的值
    ]
];

那么外围的数组肯定要分配内存,里面每个group_valueN对应的数组也要分配内存,是否要分配内存取决于外围数组是否已经有相同的group_valueN存在。如果forget为真的话,我们还需要将原数组当中的每一项分离出来,然后再删除key。话不多说,我们上代码:

PHP_FUNCTION(array_groupBy){

    zend_string *key;
    zval *input, *val, *key_zval;
    zval group_zval, copy;
    zend_bool forget = 0;
    HashTable *ht;
     //接收参数
    ZEND_PARSE_PARAMETERS_START(2, 3)
        Z_PARAM_STR(key)
        Z_PARAM_ARRAY(input)
        Z_PARAM_OPTIONAL
        Z_PARAM_BOOL(forget)
    ZEND_PARSE_PARAMETERS_END();
  
    //给第一维数组分配内存,并初始化
    ht = (HashTable *)emalloc(sizeof(HashTable));
    zend_hash_init(ht, 0, NULL, ZVAL_PTR_DTOR, 0);
    
    ZEND_HASH_FOREACH_VAL(Z_ARR_P(input), val){
      
        ZVAL_COPY(©, val); ////将val指向的HashTable地址拷贝一份给copy
        key_zval = zend_symtable_find(Z_ARR_P(val), key);
      
        convert_to_string(key_zval);  //强转所要分组的key对应的val

        if(zend_hash_exists(ht, Z_STR_P(key_zval))){
            group_zval = *zend_hash_find(ht, Z_STR_P(key_zval));
        }else{
             //初始化第二维数组,并将地址添加到第一维数组中
            array_init(&group_zval);
            zend_hash_add_new(ht, Z_STR_P(key_zval), &group_zval);
        }

        if(forget){
            SEPARATE_ARRAY(©); //为了不改变原来的数组,需要分离数组
            zend_symtable_del(Z_ARR(copy), key); //在分离后的数组中删除key对应的val
        }
        add_next_index_zval(&group_zval, ©); //将copy对应的val地址拷贝到第二维数组中

    }ZEND_HASH_FOREACH_END();

    RETURN_ARR(ht);
}

这还不能满足我们的需求,有些时候需要对分组的key对应的val进行处理,比如我们数据库里取出来的date是2017-07-12,但是我们的需求是把这些数据按月分组,这时,你直接传date作为key,恐怕就无能为力了。那我们能不能让传一个可调用类型(闭包函数/[object, method])进去呢?答案显然是可以的,我们增加一个参数callable。代码如下:

PHP_FUNCTION(array_groupBy){

    zend_string *key;
    zval *input, *val, *key_zval;
    zval group_zval, copy, retval, copy_key_zval;
    zend_bool forget = 0, have_callback = 0;
    HashTable *ht;
    zend_fcall_info fcall_info = empty_fcall_info;
    zend_fcall_info_cache fcall_info_cache = empty_fcall_info_cache;
    int ret;

    ZEND_PARSE_PARAMETERS_START(2, 4)
        Z_PARAM_STR(key)
        Z_PARAM_ARRAY(input)
        Z_PARAM_OPTIONAL
        Z_PARAM_BOOL(forget)
        Z_PARAM_FUNC(fcall_info, fcall_info_cache)//接收一个可调用类型(闭包函数 or [o, m])
    ZEND_PARSE_PARAMETERS_END();

    if(ZEND_NUM_ARGS() > 3){
        have_callback = 1;
    }

    ht = (HashTable *)emalloc(sizeof(HashTable));
    zend_hash_init(ht, 0, NULL, ZVAL_PTR_DTOR, 0);

    ZEND_HASH_FOREACH_VAL(Z_ARR_P(input), val){
        ZVAL_COPY(©, val);
        key_zval = zend_symtable_find(Z_ARR_P(val), key);
        if(have_callback){
            ZVAL_COPY(©_key_zval, key_zval);
            fcall_info.retval = &retval; //绑定函数返回值地址
            fcall_info.params = &key_zval; //绑定函数参数地址
            fcall_info.no_separation = 0; 
            fcall_info.param_count = 1; //参数个数
            ret = zend_call_function(&fcall_info, &fcall_info_cache);
            zval_ptr_dtor(©_key_zval);
            if(ret != SUCCESS || Z_TYPE(retval) == IS_UNDEF){
                zend_array_destroy(ht);
                RETURN_NULL();
            }
            ZVAL_STR(©_key_zval, Z_STR(retval));
        }else{
            ZVAL_STR(©_key_zval, zend_string_dup(Z_STR_P(key_zval), 0));
        }
        convert_to_string(©_key_zval);

        if(zend_hash_exists(ht, Z_STR(copy_key_zval))){
            group_zval = *zend_hash_find(ht, Z_STR(copy_key_zval));
        }else{
            array_init(&group_zval);
            zend_hash_add_new(ht, Z_STR(copy_key_zval), &group_zval);
        }
        zval_ptr_dtor(©_key_zval); //释放copy_key_zval

        if(forget){
            SEPARATE_ARRAY(©);
            zend_symtable_del(Z_ARR(copy), key);
        }
        add_next_index_zval(&group_zval, ©);

    }ZEND_HASH_FOREACH_END();

    RETURN_ARR(ht);
}

到这,我们已经实现了我们要的函数。里面的分离机制理解起来有些困难,我会专门抽一节来和大家探讨探讨。(也许你看其他人写的关于分离/引用的文章很好理解,但实际用起来可不是这样哦,反正我是理解了好久)。还有一些心得和大家分享下,学习扩展其实没有必要刚开始就深入php的内核,我们重点关注这几个文件zend_API.h,zend_API.c,zend_type.h,zend_hash.c就差不多了,遇到不懂的我们可以去看原函数的实现,看不懂的地方,我们根据它的函数命名去猜想是干什么的(函数命名很重要哦),没有必要去深究(因为深究的话,你会晕掉的,反正我会晕 →_ →)。实在不懂可以Google,如果google不到的话可以去stackoverflow上去提问,不过提问最好用英语,不然国外的大神看不懂,也就没办法回答你了。

函数多了怎么办?零零散散的总不好,下一节将和大家讨论下,如何把函数封装成一个类 :)。

参考书籍:

《Extending and embedding PHP》

备注:如果有错误或者有更好的实现方式,请读者们不吝指出,感激不尽。
本文版权归作者所有,转载请注明出处。

你可能感兴趣的