隨著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 require "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
是一個標準函式庫函數,它早就存在了。好,現在整個程式碼會是:
<?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*)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; }注意此处的初始函数,因为在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, 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)
来分配了一个结构_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好了,跑一下吧?
然而毕竟直接在PHP中每次请求都加载so的话,会是一个很大的性能问题,所以我们也可以采用
preload
的方式,这种模式下,我们通过opcache.preload
来在PHP启动的时候就加载好:ffi.enable=1 opcache.preload=ffi_preload.incffi_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 option, ...); 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, 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
<?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) : 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
推荐PHP教程《PHP7》
以上是PHP7.4 全新擴充方式 FFI 詳解的詳細內容。更多資訊請關注PHP中文網其他相關文章!