page contents

PHP FFI详解——一种全新的PHP扩展方法

使用FFI的话,会让你会有1000种方式让PHP segfault崩溃,所以要小心。

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

对于PHP,FFI提供了一种在纯PHP中编写PHP扩展和对C库的绑定的方法。


attachments-2020-03-iUkiCP9d5e81620144c40.jpg


是的,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我比较熟,其次呢,正是因为有了,才好对比,传统扩展方式和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.php,包含所有要申明的东西,代码如下:

$ libcurl  = FFI :: cdef (<<< CTYPE
无效* curl_easy_init ();
int curl_easy_setopt ( void * curl , int选项, ...);
int curl_easy_perform ( void * curl );
void curl_easy_cleanup ( void * handle );
类型
 , “ libcurl.so”
 );

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

然而还有个麻烦的事情是,PHP预定义好了:

<?php
const CURLOPT_URL =  10002 ;
const CURLOPT_SSL_VERIFYPEER =  64 ;
 
$ libcurl  = FFI :: cdef (<<< CTYPE
无效* curl_easy_init ();
int curl_easy_setopt ( void * curl , int选项, ...);
int curl_easy_perform ( void * curl );
void curl_easy_cleanup ( void * handle );
类型
 , “ libcurl.so”
 );


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

<?php
需要 “ 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,而是写入到这个fd
2.我们自己编写一个C到简单函数,通过FFI日期进来,传递给libcurl。


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

void * fopen ( char *文件名, char *模式);
void fclose ( void * fp );

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

#定义 FFI_LIB “libcurl.so”
 
无效 * curl_easy_init ();
int  curl_easy_setopt (void  * curl , int选项, ...);
int  curl_easy_perform (void  * curl );
void  curl_easy_cleanup (CURL * handle ); 


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

静态 函数 加载(字符串$ 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 , 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 *)数据;  
        size_t  total =大小* nmember ;
 
        如果 ( d- > buf == NULL ) {
                d- > buf =  malloc ( total );
                如果 ( d- > buf == NULL ) {
                        返回 0 ;
                }
                d- > size = total ;
                memcpy ( d- > buf , ptr , total );
        }  其他 {
                d- > buf =重新 分配( d- > buf , d- > size + total );
                如果 ( d- > buf == NULL ) {
                        返回 0 ;
                }
                memcpy ( d- > buf + d- > size , ptr , total );
                d- > size + = total ;
        }
 
        回报总额;
}
 
无效 *  init () {
        return  & own_writefunc ;
}

注意此处的初始函数,因为在PHP FFI中,就目前的版本(2020-03-11)我们没有办法直接获得一个函数指针,所以我们定义了这个函数,返回own_writefunc的地址。


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

#定义 FFI_LIB “write.so”
 
typedef  struct _writedata {  
        无效 * buf ;
        size_t 大小;
} own_write_data ;
 
无效 * 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 , 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 :: 字符串($ data- > buf , $ data- > size );


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

函数 FFI :: 新(混合$ type  [, bool $ own  = true [, bool $ persistent  = false ]]): FFI \ CData

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


然后我们把$ data作为WRITEDATA传递给libcurl,这里我们使用了FFI :: addr来获取$ data的实际内存地址:

静态 函数 地址( FFI \ CData $ cdata ): FFI \ CData ;

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


最后我们使用了FFI :: string来把一段内存转换成PHP的string:

静态 函数 FFI :: 字符串( FFI \ CData $ src  [, int $ size ]):字符串

当不提供$ size的时候,FFI :: string会在遇到Null-byte的时候停止。


好了,跑一下吧?

然而毕竟直接在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:

#定义 FFI_LIB “libcurl.so”
#定义 FFI_SCOPE “的libcurl”
 
无效 * curl_easy_init ();
int  curl_easy_setopt (void  * curl , int选项, ...);
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 :: 范围(“ libcurl” );
$ write   = FFI :: 范围(“ 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 :: 字符串($ data- > buf , $ data- > size );


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

静态 函数 范围(字符串$ name ): FFI ;

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


我们稍微修改下ffi_preload.inc变成ffi_safe_preload.inc

<?php
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” );
 
函数 get_libcurl () : FFI {
     返回 FFI :: 范围(“ libcurl” );
}
 
函数 get_write_data ($ write ) : FFI \ CData {
     返回 $ write- > new (“ own_write_data” );
}
 
函数 get_write () : FFI {
     返回 FFI :: 范围(“ write” );
}
 
函数 get_data_addr ($ data ) : FFI \ CData {
     返回 FFI :: addr ($ data );
}
 
函数 paser_libcurl_ret ($ data ) :字符串{
     返回 FFI :: 字符串($ 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脚本调用,用户不能直接调用。从而我们可以在这些函数内部做好适当的安全保证工作,从而保证一定的安全性。



attachments-2020-03-FGVbxdNU5e8162d2985b8.jpg                      

  • 发表于 2020-03-30 11:09
  • 阅读 ( 441 )
  • 分类:PHP开发

你可能感兴趣的文章

相关问题

0 条评论

请先 登录 后评论
Pack
Pack

1135 篇文章

作家榜 »

  1. 轩辕小不懂 2403 文章
  2. 小柒 1470 文章
  3. Pack 1135 文章
  4. Nen 576 文章
  5. 王昭君 209 文章
  6. 文双 71 文章
  7. 小威 64 文章
  8. Cara 36 文章