随着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语言写包装器,把他们包装成扩展,这个过程中就需要大家去学习PHP的扩展怎么写,当然现在也有一些方便的方式,某种Zephir 。但总还是有一些学习成本的,而有了FFI之后,我们就可以直接在PHP脚本中调用C语言写的库中的函数了。
而C语言几十年的历史中,积累积累的优秀的库,FFI直接让我们可以方便的享受这个庞大的资源了。
言归正传,今天我用一个例子来介绍,我们如何使用PHP来调用libcurl,来抓取一个网页的内容,为什么要用libcurl呢?PHP不是已经有了curl扩展了么?嗯,首先因为libcurl的api我比较熟,其次呢,正是因为有了,才好对比,传统扩展方式AS和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);
(因为我的网站是https的,所以会多一个设置SSL_VERIFYPEER 的操作)那如果是用FFI呢?
首先要启用PHP7.4的ext / ffi,需要注意的是PHP-FFI要求libffi-3以上。
然后,我们需要告诉PHP FFI我们要调用的函数原型是咋样的,这个我们可以使用FFI :: cdef ,它的原型是:
FFI::cdef([string $cdef = "" [,string $lib = null]]): FFI
在字符串$cdef 中,我们可以写C语言函数式申明,FFI会parse 它,了解到我们要在字符串$lib 这个库中调用的函数的签名是啥样的,在这个例子中,我们用到三一个libcurl的函数,它们的申明我们都可以在libcurl的文档里找到,某些关于curl_easy_init 。
具体到这个例子,我们写一个curl.php ,包含所有要申明的东西,代码如下:
$libcurl = FFI::cdef(<<<CTYPE
void *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预定义好了:
<?php
const CURLOPT_URL = 10002;
const CURLOPT_SSL_VERIFYPEER = 64;
$libcurl = FFI::cdef(<<<CTYPE
void *curl_easy_init();
int curl_easy_setopt(void *curl,"libcurl.so"
);
好了,定义部分就算完成了,现在我们完成实际逻辑部分,整个下来的代码会是:
<?php
require "curl.php";
$url = "https://www.laruence.com/2020/03/11/5475.html";
$ch = $libcurl->curl_easy_init();
$libcurl->curl_easy_setopt($ch,$url);
$libcurl->curl_easy_setopt($ch,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 ,而是写入到这个fd
2.我们自己编写一个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 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 是一个标准库函数,它早就存在了。
好,现在整个代码会是:
<?php
const 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,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,total);
d->size += total;
}
return total;
}
void * init() {
return &own_writefunc;
}
注意此处的初始函数,因为在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
好了,现在整个的代码会变成:
<?php
const 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,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) 来分配了一个结构_write_data 的内存:
function FFI::new(mixed $type [,bool $own = true [,bool $persistent = false]]): FFICData
$own 表示这个内存管理是否采用PHP的内存管理,有时的情况下,我们申请的内存会经过PHP的生命周期管理,不需要主动释放,但是有的时候你也可能希望自己管理,那么可以设置$own 为flase ,那么在适当的时候,你需要调用FFI :: free 去主动释放。
然后我们把$data 作为WRITEDATA 传递给libcurl,这里我们使用了FFI :: addr 来获取$data 的实际内存地址:
static function addr(FFICData $cdata): FFICData;
然后我们把own_write_func 作为WRITEFUNCTION 传递给了libcurl,这样再有返回的时候,libcurl就会调用我们的own_write_func 来处理返回,同时会把write_data 作为自定义参数传递给我们的替代函数。
最后我们使用了FFI :: string 来把一段内存转换成PHP的string :
static function FFI::string(FFICData $src [,int $size]): string
好了,跑一下吧?
然而毕竟直接在PHP中每次请求都加载so的话,会是一个很大的性能问题,所以我们也可以采用preload 的方式,这种模式下,我们通过opcache.preload 来在PHP启动的时候就加载好:
ffi.enable=1
opcache.preload=ffi_preload.inc
ffi_preload.inc:
<?php
FFI::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 curl_easy_perform(void *curl);
void curl_easy_cleanup(void *handle);
对应的我们给write.h 也加入FFI_SCOPE 为“ write”,然后我们的脚本现在看起来应该是这样的:
<?php
const 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,$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
<?php
class 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) : FFICData {
return $write->new("own_write_data");
}
function get_write() : FFI {
return FFI::scope("write");
}
function get_data_addr($data) : FFICData {
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,CURLOPT::SSL_VERIFYPEER,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 (编辑:李大同)
【声明】本站内容均来自网络,其相关言论仅代表作者个人观点,不代表本站立场。若无意侵犯到您的权利,请及时与联系站长删除相关内容!
|