PHP FFI詳解 - 一種全新的PHP擴充套件方式

fzpying發表於2021-03-03

隨著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語言寫wrapper,把他們包裝成擴充套件,這個過程中就需要大家去學習PHP的擴充套件怎麼寫,當然現在也有一些方便的方式,比如Zephir. 但總還是有一些學習成本的,而有了FFI以後,我們就可以直接在PHP指令碼中呼叫C語言寫的庫中的函式了。

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

言歸正傳,今天我用一個例子來介紹,我們如何使用PHP來呼叫libcurl,來抓取一個網頁的內容,為什麼要用libcurl呢? PHP不是已經有了curl擴充套件了麼? 嗯,首先因為libcurl的api我比較熟,其次呢,正是因為有了,才好對比,傳統擴充套件方式和FFI方式直接的易用性不是?

首先,比如我們就拿當前你看的這篇文章為例,我現在需要寫一段程式碼來抓取它的內容,如果用傳統的PHP的curl擴充套件,我們大概會這麼寫:

<?php
$url = "https://learnku.com/articles/50321";
$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

在string $cdef中,我們可以寫C語言函式式申明,FFI會parse它,瞭解到我們要在string $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預定義好了CURLOPT_等option的值,但現在我們需要自己定義,簡單的辦法就是檢視curl的標頭檔案,找到對應的值,然後我們把值給加進去:

<?php
const CURLOPT_URL = 10002;
const CURLOPT_SSL_VERIFYPEER = 64;
$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"
);

好了,定義部分就算完成了,現在我們完成實際邏輯部分,整個下來的程式碼會是:

<?php
require "curl.php";
$url = "https://learnku.com/articles/50321";
$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 *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://learnku.com/articles/50321";
$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;
}

注意此處的init函式,因為在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://learnku.com/articles/50321";

 $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)來分配了一個struct _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

當不提供$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:

  #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://learnku.com/articles/50321";

 $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://learnku.com/articles/50321";

  $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 ?

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章