如影随形

影子是一个会撒谎的精灵,它在虚空中流浪和等待被发现之间;在存在与不存在之间....

您现在的位置是:首页>技术文章>详情

PHP FFI详解 - 一种全新的PHP扩展方式

发布时间:2020-06-04文章类型:转载浏览(3368)




原文地址:https://www.laruence.com/2020/03/11/5475.html

    随着PHP7.4而来的有一个我认为非常有用的一个扩展:PHP FFI(Foreign Function interface), 引用一段PHP FFI RFC中的一段描述:

    For PHP, FFI opens a way to write PHP extensions and bindings to C libraries in pure PHP.

    是的,FFI提供了高级语言直接的互相调用,而对于PHP来说,FFI让我们可以方便的调用C语言写的各种库。

    其实现有大量的PHP扩展是对一些已有的C库的包装,比如常用的mysqli, curl, gettext等,PECL中也有大量的类似扩展。

    传统的方式,当我们需要用一些已有的C语言的库的能力的时候,我们需要用C语言写wrapper,把他们包装成扩展,这个过程中就需要大家去学习PHP的扩展怎么写,当然现在也有一些方便的方式,比如Zephir. 但总还是有一些学习成本的,而有了FFI以后,我们就可以直接在PHP脚本中调用C语言写的库中的函数了。

    而C语言几十年的历史中,积累了大量的优秀的库,FFI直接让我们可以方便的享受这个庞大的资源了。

    言归正传,今天我用一个例子来介绍,我们如何使用PHP来调用libcurl,来抓取一个网页的内容,为什么要用libcurl呢? PHP不是已经有了curl扩展了么? 嗯,首先因为libcurl的api我比较熟,其次呢,正是因为有了,才好对比,传统扩展方式和FFI方式直接的易用性不是?

    首先,比如我们就拿当前你看的这篇文章为例,我现在需要写一段代码来抓取它的内容,如果用传统的PHP的curl扩展,我们大概会这么写:

    1. <?php
    2.  
    3. $url = "https://www.laruence.com/2020/03/11/5475.html";
    4. $ch = curl_init();
    5.  
    6. curl_setopt($ch, CURLOPT_URL, $url);
    7. curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0);
    8.  
    9. curl_exec($ch);
    10.  
    11. curl_close($ch);

    (因为我的网站是https的,所以会多一个设置SSL_VERIFYPEER的操作)那如果是用FFI呢?

    首先要启用PHP7.4的ext/ffi,需要注意的是PHP-FFI要求libffi-3以上。

    然后,我们需要告诉PHP FFI我们要调用的函数原型是咋样的,这个我们可以使用FFI::cdef, 它的原型是:

    1. FFI::cdef([string $cdef = "" [, string $lib = null]]): FFI

    在string $cdef中,我们可以写C语言函数式申明,FFI会parse它,了解到我们要在string $lib这个库中调用的函数的签名是啥样的,在这个例子中,我们用到三个libcurl的函数,它们的申明我们都可以在libcurl的文档里找到,比如对于curl_easy_init.

    具体到这个例子,我们写一个curl.php, 包含所有要申明的东西,代码如下:

    1. $libcurl = FFI::cdef(<<<CTYPE
    2. void *curl_easy_init();
    3. int curl_easy_setopt(void *curl, int option, ...);
    4. int curl_easy_perform(void *curl);
    5. void curl_easy_cleanup(void *handle);
    6. CTYPE
    7.  , "libcurl.so"
    8.  );

    这里有个地方是,文档中写的是返回值是CURL *,但事实上因为我们的例子中不会解引用它,只是传递,那就避免麻烦就用void *代替。

    然而还有个麻烦的事情是,PHP预定义好了CURLOPT_等option的值,但现在我们需要自己定义,简单的办法就是查看curl的头文件,找到对应的值,然后我们把值给加进去:

    1. <?php
    2. const CURLOPT_URL = 10002;
    3. const CURLOPT_SSL_VERIFYPEER = 64;
    4.  
    5. $libcurl = FFI::cdef(<<<CTYPE
    6. void *curl_easy_init();
    7. int curl_easy_setopt(void *curl, int option, ...);
    8. int curl_easy_perform(void *curl);
    9. void curl_easy_cleanup(void *handle);
    10. CTYPE
    11.  , "libcurl.so"
    12.  );
    13.  

    好了,定义部分就算完成了,现在我们完成实际逻辑部分,整个下来的代码会是:

    1. <?php
    2. require "curl.php";
    3.  
    4. $url = "https://www.laruence.com/2020/03/11/5475.html";
    5.  
    6. $ch = $libcurl->curl_easy_init();
    7. $libcurl->curl_easy_setopt($ch, CURLOPT_URL, $url);
    8. $libcurl->curl_easy_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0);
    9.  
    10. $libcurl->curl_easy_perform($ch);
    11.  
    12. $libcurl->curl_easy_cleanup($ch);

    怎么样,相比使用curl扩展的方式, 是不是一样简练呢?

    接下来,我们稍微弄的复杂一点,也即使,如果我们不想要结果直接输出,而是返回成一个字符串呢, 对于PHP的curl扩展来说,我们只需要调用curl_setop 把CURLOPT_RETURNTRANSFER为1,但在libcurl中其实并没有直接返回字符串的能力,而是提供了一个WRITEFUNCTION的回调函数,在有数据返回的时候,libcurl会调用这个函数, 事实上PHP curl扩展也是这么做的.

    目前我们并不能直接把一个PHP函数作为回调函数通过FFI传递给libcurl, 那我们会有俩种方式来做:

    1. 采用WRITEDATA, 默认的libcurl会调用fwrite作为回调函数,而我们可以通过WRITEDATA给libcurl一个fd,让它不要写入stdout,而是写入到这个fd
    2. 我们自己编写一个C到简单函数,通过FFI引入进来,传递给libcurl.

    我们先用第一种方式,首先我们需要使用fopen,这次我们通过定义个C的头文件来申明原型(file.h):

    1. void *fopen(char *filename, char *mode);
    2. void fclose(void * fp);

    像file.h一样,我们把所有的libcurl的函数申明也放到curl.h中去

    1. #define FFI_LIB "libcurl.so"
    2.  
    3. void *curl_easy_init();
    4. int curl_easy_setopt(void *curl, int option, ...);
    5. int curl_easy_perform(void *curl);
    6. void curl_easy_cleanup(CURL *handle);

    然后我们就可以使用FFI::load来加载.h文件:

    1. static function load(string $filename): FFI;

    但是怎么告诉FFI加载那个对应的库呢?如上面,我们通过定义了一个FFI_LIB的宏,来告诉FFI这些函数来自libcurl.so, 当我们用FFI::load加载这个h文件的时候,PHP FFI就会自动载入libcurl.so

    那为什么fopen不需要指定加载库呢,那是因为FFI也会在全局符号表中查找符号,而fopen是一个标准库函数,它早就存在了。

    好,现在整个代码会是:

    1. <?php
    2. const CURLOPT_URL = 10002;
    3. const CURLOPT_SSL_VERIFYPEER = 64;
    4. const CURLOPT_WRITEDATA = 10001;
    5.  
    6. $libc = FFI::load("file.h");
    7. $libcurl = FFI::load("curl.h");
    8.  
    9. $url = "https://www.laruence.com/2020/03/11/5475.html";
    10. $tmpfile = "/tmp/tmpfile.out";
    11.  
    12. $ch = $libcurl->curl_easy_init();
    13. $fp = $libc->fopen($tmpfile, "a");
    14.  
    15. $libcurl->curl_easy_setopt($ch, CURLOPT_URL, $url);
    16. $libcurl->curl_easy_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0);
    17. $libcurl->curl_easy_setopt($ch, CURLOPT_WRITEDATA, $fp);
    18. $libcurl->curl_easy_perform($ch);
    19.  
    20. $libcurl->curl_easy_cleanup($ch);
    21.  
    22. $libc->fclose($fp);
    23.  
    24. $ret = file_get_contents($tmpfile);
    25. @unlink($tmpfile);

    但这种方式呢就是需要一个临时的中转文件,还是不够优雅, 现在我们用第二种方式,要用第二种方式,我们需要自己用C写一个回调函数传递给libcurl:

    1. #include <stdlib.h>
    2. #include <string.h>
    3. #include "write.h"
    4.  
    5. size_t own_writefunc(void *ptr, size_t size, size_t nmember, void *data) {
    6.         own_write_data *d = (own_write_data*)data;
    7.         size_t total = size * nmember;
    8.  
    9.         if (d->buf == NULL) {
    10.                 d->buf = malloc(total);
    11.                 if (d->buf == NULL) {
    12.                         return 0;
    13.                 }
    14.                 d->size = total;
    15.                 memcpy(d->buf, ptr, total);
    16.         } else {
    17.                 d->buf = realloc(d->buf, d->size + total);
    18.                 if (d->buf == NULL) {
    19.                         return 0;
    20.                 }
    21.                 memcpy(d->buf + d->size, ptr, total);
    22.                 d->size += total;
    23.         }
    24.  
    25.         return total;
    26. }
    27.  
    28. void * init() {
    29.         return &own_writefunc;
    30. }
    注意此处的init函数,因为在PHP FFI中,就目前的版本(2020-03-11)我们没有办法直接获得一个函数指针,所以我们定义了这个函数,返回own_writefunc的地址。

    最后我们定义上面用到的头文件write.h:

    1. #define FFI_LIB "write.so"
    2.  
    3. typedef struct _writedata {
    4.         void *buf;
    5.         size_t size;
    6. } own_write_data;
    7.  
    8. void *init();

    注意到我们在头文件中也定义了FFI_LIB, 这样这个头文件就可以同时被write.c和接下来我们的PHP FFI共同使用了。

    然后我们编译write函数为一个动态库:

    1. gcc -O2 -fPIC -shared -g write.c -o write.so

    好了, 现在整个的代码会变成:

    1. <?php
    2. const CURLOPT_URL = 10002;
    3. const CURLOPT_SSL_VERIFYPEER = 64;
    4. const CURLOPT_WRITEDATA = 10001;
    5. const CURLOPT_WRITEFUNCTION = 20011;
    6.  
    7. $libcurl = FFI::load("curl.h");
    8. $write = FFI::load("write.h");
    9.  
    10. $url = "https://www.laruence.com/2020/03/11/5475.html";
    11.  
    12. $data = $write->new("own_write_data");
    13.  
    14. $ch = $libcurl->curl_easy_init();
    15.  
    16. $libcurl->curl_easy_setopt($ch, CURLOPT_URL, $url);
    17. $libcurl->curl_easy_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0);
    18. $libcurl->curl_easy_setopt($ch, CURLOPT_WRITEDATA, FFI::addr($data));
    19. $libcurl->curl_easy_setopt($ch, CURLOPT_WRITEFUNCTION, $write->init());
    20. $libcurl->curl_easy_perform($ch);
    21.  
    22. $libcurl->curl_easy_cleanup($ch);
    23.  
    24. ret = FFI::string($data->buf, $data->size);

    此处, 我们使用FFI::new ($write->new)来分配了一个struct _write_data的内存:

    1. function FFI::new(mixed $type [, bool $own = true [, bool $persistent = false]]): FFI\CData

    $own表示这个内存管理是否采用PHP的内存管理,默认的情况下,我们申请的内存会经过PHP的生命周期管理,不需要主动释放,但是有的时候你也可能希望自己管理,那么可以设置$own为flase,那么在适当的时候,你需要调用FFI::fr

关键字词:PHP FFI详解