搜尋
首頁後端開發PHP7PHP7.4 全新擴充方式 FFI 詳解

隨著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_setopCURLOPT_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 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的生命周期管理,不需要主动释放,但是有的时候你也可能希望自己管理,那么可以设置$ownflase,那么在适当的时候,你需要调用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.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 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中文網其他相關文章!

陳述
本文轉載於:laruence。如有侵權,請聯絡admin@php.cn刪除

熱AI工具

Undresser.AI Undress

Undresser.AI Undress

人工智慧驅動的應用程序,用於創建逼真的裸體照片

AI Clothes Remover

AI Clothes Remover

用於從照片中去除衣服的線上人工智慧工具。

Undress AI Tool

Undress AI Tool

免費脫衣圖片

Clothoff.io

Clothoff.io

AI脫衣器

AI Hentai Generator

AI Hentai Generator

免費產生 AI 無盡。

熱工具

記事本++7.3.1

記事本++7.3.1

好用且免費的程式碼編輯器

SublimeText3 Mac版

SublimeText3 Mac版

神級程式碼編輯軟體(SublimeText3)

Dreamweaver Mac版

Dreamweaver Mac版

視覺化網頁開發工具

WebStorm Mac版

WebStorm Mac版

好用的JavaScript開發工具

禪工作室 13.0.1

禪工作室 13.0.1

強大的PHP整合開發環境