PHP FFI詳解-一種全新的PHP擴展方式

隨着PHP7.4而來的有一個我認爲很是有用的一個擴展:PHP FFI(Foreign Function interface),引用一段PHP FFI RFC中的一段描述:php

對於PHP,FFI提供了一種在純PHP中編寫PHP擴展和對C庫的綁定的方法。

是的,FFI提供了高級語言直接的互相調用,而對於PHP而言,FFI讓咱們能夠方便的調用C語言寫的各類庫。html

其實現有大量的PHP擴展是對一些已有的C庫的包裝,某些經常使用的mysqli,curl,gettext等,PECL中也有大量的相似擴展。mysql

傳統的方式,當咱們須要用一些已有的C語言的庫的能力的時候,咱們須要用C語言寫包裝器,把他們包裝成擴展,這個過程當中就須要你們去學習PHP的擴展怎麼寫,固然如今也有一些方便的方式,某種Zephir。但總仍是有一些學習成本的,而有了FFI以後,咱們就能夠直接在PHP腳本中調用C語言寫的庫中的函數了。laravel

而C語言幾十年的歷史中,積累積累的優秀的庫,FFI直接讓咱們能夠方便的享受這個龐大的資源了。git

言歸正傳,今天我用一個例子來介紹,咱們如何使用PHP來調用libcurl,來抓取一個網頁的內容,爲何要用libcurl呢?PHP不是已經有了curl擴展了麼?嗯,首先由於libcurl的api我比較熟,其次呢,正是由於有了,纔好對比,傳統擴展方式和FFI方式直接的易用性不是?github

首先,某些咱們就拿當前你看的這篇文章爲例,我如今須要寫一段代碼來抓取它的內容,若是用傳統的PHP的curl擴展,咱們大概會這麼寫:sql

<?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呢?shell

首先要啓用PHP7.4的ext / ffi,須要注意的是PHP-FFI要求libffi-3以上。api

而後,咱們須要告訴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
無效* 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腳本調用,用戶不能直接調用。從而咱們能夠在這些函數內部作好適當的安全保證工做,從而保證必定的安全性。

好了,經歷了這個例子,你們應該對FFI有一個比較深刻的理解了,詳細的PHP API說明,你們能夠參考:PHP-FFI Manual,有興趣的話,就去找一個C庫,試試吧?

本文的例子,你能夠在個人github上下載到:FFI example

最後仍是多說一句,示例只是爲了演示功能,因此省掉了不少錯誤分支的判斷捕獲,你們本身寫的時候仍是要加入。畢竟使用FFI的話,會讓你會有1000種方式讓PHP segfault崩潰,因此要當心。

以上內容但願幫助到你們,不少PHPer在進階的時候總會遇到一些問題和瓶頸,業務代碼寫多了沒有方向感,不知道該從那裏入手去提高,對此我整理了一些資料,包括但不限於:分佈式架構、高可擴展、高性能、高併發、服務器性能調優、TP6,laravel,YII2,Redis,Swoole、Swoft、Kafka、Mysql優化、shell腳本、Docker、微服務、Nginx等多個知識點高級進階乾貨須要的能夠免費分享給你們須要請戳這裏

相關文章
相關標籤/搜索