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中也有大量的类似扩展。

phpc扩展库PHP FFI详解  一种全新的PHP扩大方法 CSS

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

而C措辞几十年的历史中,积累了大量的精良的库,FFI直接让我们可以方便的享受这个弘大的资源了。

言归正传,本日我用一个例子来先容,我们如何利用PHP来调用libcurl,来抓取一个网页的内容,为什么要用libcurl呢? PHP不是已经有了curl扩展了么? 嗯,首先由于libcurl的api我比较熟,其次呢,正是由于有了,才比如较,传统扩展办法和FFI办法直接的易用性不是?

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

<?php$url = "https://www.laruence.com/2020/03/11/5475.html";$ch = curl_init();curl_setopt($ch, CURLOPT_URL, $url);curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0);curl_exec($ch);curl_close($ch);

<?php$url = "https://www.laruence.com/2020/03/11/5475.html";$ch = curl_init();curl_setopt($ch, CURLOPT_URL, $url);curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0);curl_exec($ch);curl_close($ch);

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

首先要启用PHP7.4的ext/ffi,须要把稳的是PHP-FFI哀求libffi-3以上。

然后,我们须要见告PHP FFI我们要调用的函数原型是若何的,这个我们可以利用FFI::cdef, 它的原型是:

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

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

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

$libcurl = FFI::cdef(<<<CTYPEvoid curl_easy_init();int curl_easy_setopt(void curl, int option, ...);int curl_easy_perform(void curl);void curl_easy_cleanup(void handle);CTYPE, "libcurl.so");

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

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

<?phpconst CURLOPT_URL = 10002;const CURLOPT_SSL_VERIFYPEER = 64;$libcurl = FFI::cdef(<<<CTYPEvoid curl_easy_init();int curl_easy_setopt(void curl, int option, ...);int curl_easy_perform(void curl);void curl_easy_cleanup(void handle);CTYPE, "libcurl.so");

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

<?phprequire "curl.php";$url = "https://www.laruence.com/2020/03/11/5475.html";$ch = $libcurl->curl_easy_init();$libcurl->curl_easy_setopt($ch, CURLOPT_URL, $url);$libcurl->curl_easy_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0);$libcurl->curl_easy_perform($ch);$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,而是写入到这个fd2. 我们自己编写一个C的大略函数,通过FFI引入进来,通报给libcurl.

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

void fopen(char filename, char mode);void fclose(void fp);

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

#define FFI_LIB "libcurl.so"void curl_easy_init();int curl_easy_setopt(void curl, int option, ...);int curl_easy_perform(void curl);void curl_easy_cleanup(CURL handle);

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

static function load(string $filename): FFI;

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

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

好,现在全体代码会是:

<?phpconst CURLOPT_URL = 10002;const CURLOPT_SSL_VERIFYPEER = 64;const CURLOPT_WRITEDATA = 10001;$libc = FFI::load("file.h");$libcurl = FFI::load("curl.h");$url = "https://www.laruence.com/2020/03/11/5475.html";$tmpfile = "/tmp/tmpfile.out";$ch = $libcurl->curl_easy_init();$fp = $libc->fopen($tmpfile, "a");$libcurl->curl_easy_setopt($ch, CURLOPT_URL, $url);$libcurl->curl_easy_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0);$libcurl->curl_easy_setopt($ch, CURLOPT_WRITEDATA, $fp);$libcurl->curl_easy_perform($ch);$libcurl->curl_easy_cleanup($ch);$libc->fclose($fp);$ret = file_get_contents($tmpfile);@unlink($tmpfile);

但这种办法呢便是须要一个临时的中转文件,还是不足优雅, 现在我们用第二种办法,要用第二种办法,我们须要自己用C写一个回调函数通报给libcurl:

#include <stdlib.h>#include <string.h>#include "write.h"size_t own_writefunc(void ptr, size_t size, size_t nmember, void data) { own_write_data d = (own_write_data)data; size_t total = size nmember; if (d->buf == NULL) { d->buf = malloc(total); if (d->buf == NULL) { return 0; } d->size = total; memcpy(d->buf, ptr, total); } else { d->buf = realloc(d->buf, d->size + total); if (d->buf == NULL) { return 0; } memcpy(d->buf + d->size, ptr, total); d->size += total; } return total;}void init() { return &own_writefunc;}

把稳此处的init函数,由于在PHP FFI中,就目前的版本(2020-03-11)我们没有办法直接得到一个函数指针,以是我们定义了这个函数,返回own_writefunc的地址。

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

#define FFI_LIB "write.so"typedef struct _writedata { void buf; size_t size;}own_write_data;void init();

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

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

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

好了, 现在全体的代码会变成:

<?phpconst CURLOPT_URL = 10002;const CURLOPT_SSL_VERIFYPEER = 64;const CURLOPT_WRITEDATA = 10001;const CURLOPT_WRITEFUNCTION = 20011;$libcurl = FFI::load("curl.h");$write = FFI::load("write.h");$url = "https://www.laruence.com/2020/03/11/5475.html";$data = $write->new("own_write_data");$ch = $libcurl->curl_easy_init();$libcurl->curl_easy_setopt($ch, CURLOPT_URL, $url);$libcurl->curl_easy_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0);$libcurl->curl_easy_setopt($ch, CURLOPT_WRITEDATA, FFI::addr($data));$libcurl->curl_easy_setopt($ch, CURLOPT_WRITEFUNCTION, $write->init());$libcurl->curl_easy_perform($ch);$libcurl->curl_easy_cleanup($ch);ret = FFI::string($data->buf, $data->size);

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

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

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

然后我们把$data作为WRITEDATA通报给libcurl, 此处我们利用了FFI::addr来获取$data的实际内存地址:

static function addr(FFI\CData $cdata): FFI\CData;

然后我们把own_write_func作为WRITEFUNCTION通报给了libcurl,这样再有返回的时候,libcurl就会调用我们的own_write_func来处理返回,同时会把write_data作为自定义参数传给我们的回调函数。

末了我们利用了FFI::string来把一段内存转换成PHP的string:

static function FFI::string(FFI\CData $src [, int $size]): string

当不供应$size的时候,FFI::string会在碰着Null-byte的时候停滞。

好了,跑一下吧?

然而毕竟直接在PHP中每次要求都加载so的话,会是一个很大的性能问题,以是我们也可以采取preload的办法,这种模式下, 我们通过opcache.preload来在PHP启动的时候就加载好:

ffi.enable=1opcache.preload=ffi_preload.inc

ffi_preload.inc:

<?phpFFI::load("curl.h");FFI::load("write.h");

但我们引用载入的FFI呢? 为此我们须要修正一下这两个.h头文件,加入FFI_SCOPE, 比如curl.h:

#define FFI_LIB "libcurl.so"#define FFI_SCOPE "libcurl"void curl_easy_init();int curl_easy_setopt(void curl, int option, ...);int curl_easy_perform(void curl);void curl_easy_cleanup(void handle);

对应的我们给write.h也加入FFI_SCOPE为"write", 然后我们的脚本现在看起来该当是这样:

<?phpconst CURLOPT_URL = 10002;const CURLOPT_SSL_VERIFYPEER = 64;const CURLOPT_WRITEDATA = 10001;const CURLOPT_WRITEFUNCTION = 20011;$libcurl = FFI::scope("libcurl");$write = FFI::scope("write");$url = "https://www.laruence.com/2020/03/11/5475.html";$data = $write->new("own_write_data");$ch = $libcurl->curl_easy_init();$libcurl->curl_easy_setopt($ch, CURLOPT_URL, $url);$libcurl->curl_easy_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0);$libcurl->curl_easy_setopt($ch, CURLOPT_WRITEDATA, FFI::addr($data));$libcurl->curl_easy_setopt($ch, CURLOPT_WRITEFUNCTION, $write->init());$libcurl->curl_easy_perform($ch);$libcurl->curl_easy_cleanup($ch);ret = FFI::string($data->buf, $data->size);

也便是,我们现在利用FFI::scope来代替FFI::load,引用对应的函数。

static function scope(string $name): FFI;

然后还有其余一个问题,FFI虽然给了我们很大的灵巧性,但是毕竟直接调用C库函数,还是非常具有风险性的,我们该当只容许用户调用我们确认过的函数,于是,ffi.enable=preload就该上场了,当我们设置ffi.enable=preload的话,那就只有在opcache.preload的脚本中的函数才能调用FFI,而用户写的函数是没有办法直接调用的。

我们轻微修正下ffi_preload.inc变成ffi_safe_preload.inc

<?phpclass CURLOPT { const URL = 10002;const SSL_VERIFYHOST = 81;const SSL_VERIFYPEER = 64;const WRITEDATA = 10001;const WRITEFUNCTION = 20011;}FFI::load("curl.h");FFI::load("write.h");function get_libcurl() : FFI { return FFI::scope("libcurl");}function get_write_data($write) : FFI\CData { return $write->new("own_write_data");}function get_write() : FFI { return FFI::scope("write");}function get_data_addr($data) : FFI\CData { return FFI::addr($data);}function paser_libcurl_ret($data) :string{ return FFI::string($data->buf, $data->size);}

也便是,我们把所有会调用FFI API的函数都定义在preload脚本中,然后我们的例子会变成(ffi_safe.php):

<?php$libcurl = get_libcurl();$write = get_write();$data = get_write_data($write);$url = "https://www.laruence.com/2020/03/11/5475.html";$ch = $libcurl->curl_easy_init();$libcurl->curl_easy_setopt($ch, CURLOPT::URL, $url);$libcurl->curl_easy_setopt($ch, CURLOPT::SSL_VERIFYPEER, 0);$libcurl->curl_easy_setopt($ch, CURLOPT::WRITEDATA, get_data_addr($data));$libcurl->curl_easy_setopt($ch, CURLOPT::WRITEFUNCTION, $write->init());$libcurl->curl_easy_perform($ch);$libcurl->curl_easy_cleanup($ch);$ret = paser_libcurl_ret($data);

这样一来通过ffi.enable=preload, 我们就可以限定,所有的FFI API只能被我们可掌握的preload脚本调用,用户不能直接调用。
从而我们可以在这些函数内做好尽可能的安全担保事情,从而担保一定的安全性。

好了,经由这个例子,大家该当对FFI有了一个比较深入的理解了,详细的PHP API解释,大家可以参考:PHP-FFI Manual, 有兴趣的话,就去找一个C库,试试吧?

本文的例子,你可以在我的github高下载到:FFI example

末了还是多说一句,例子只是为了演示功能,以是省却了很多缺点分支的判断捕获,大家自己写的时候还是要加入。
毕竟利用FFI的话,会让你会有1000种办法让PHP segfault crash,以是be careful