PHP FFI: Creazione di un Collegamento con una Libreria, QuickJS – Parte 5

Per concludere questo mini dossier tecnico sull'interfaccia delle funzioni esterne di PHP, mi sono proposto di connettermi semplicemente a una lib. Una sorta di sfida che potesse al tempo stesso aiutarmi a progredire sull'argomento. Così ho dovuto proporre un'idea di progetto. Qualcosa non troppo piccolo o troppo semplice ma neanche troppo grande. Fallito, ho trovato un'idea ma si è rivelata molto più complessa di quanto immaginassi. Non importa, ho comunque provato. Quindi, per il nostro articolo, ho pensato di tentare una integrazione più o meno riuscita e più o meno completa di QuickJS.

Installazione

Il mio spazio di lavoro sarà semplice. Una directory con il sorgente di QuickJS, una directory con le binding del codice C (che ci aiuteranno a semplificare la nostra prima versione), e una directory per la nostra lib PHP.

Installazione di QuickJS:

sudo apt-get install gcc-multilib texlive texinfo
git clone https://github.com/bellard/quickjs
cd quickjs
./unicode_download.sh
./release.sh binary  quickjs
make build_doc
gcc -shared -o ../php/src/lib/libquickjs.so .obj/qjsc.o .obj/quickjs.o .obj/libregexp.o .obj/libunicode.o .obj/cutils.o .obj/quickjs-libc.o .obj/libbf.o
cd ..

Per quanto riguarda il mio spazio di lavoro, ho creato una cartella 'php' nella quale inizializzerò un'architettura composer. Questo mi fornirà uno spazio di lavoro completo di tutto ciò di cui ho bisogno per creare i miei file di codice così come i test unitari che confermeranno che tutto funziona correttamente. Se vuoi, puoi fare riferimento all'articolo Creare una libreria con composer.

image-79

Chiamare QuickJS ed eseguire codice JavaScript semplice.

Durante la compilazione della nostra libreria, abbiamo creato la nostra libreria ma anche una documentazione che ci aiuterà a capire meglio come funziona questo motore JavaScript. Uno sguardo veloce all'indice e ci troviamo direttamente nella descrizione dell'API che ci insegna che la funzione JS_EVAL() ci permette di lanciare una valutazione del codice JavaScript. Ottimo, è esattamente ciò che cercavamo.

E come nostro primo obiettivo cercheremo di fare 2*3=6 ? sarà un buon inizio...

Andiamo al file di intestazione per disvelare il groviglio di informazioni di cui abbiamo bisogno:

https://github.com/bellard/quickjs/blob/master/quickjs.h

Uh... ok, quella roba è lunga come un'autostrada...

quindi alla linea 781 troviamo ciò che cerchiamo:

JSValue JS_Eval(JSContext *ctx, const char *input, size_t input_len,
                const char *filename, int eval_flags);

Quello che ci insegna è che abbiamo bisogno di un tipo che sarebbe JSContext. Una veloce ricerca per "struct JSContext" ci indirizza alla linea 50. Ok, prenderemo l'intero blocco, potrebbe essere utile...

typedef struct JSRuntime JSRuntime;
typedef struct JSContext JSContext;
typedef struct JSObject JSObject;
typedef struct JSClass JSClass;
typedef uint32_t JSClassID;
typedef uint32_t JSAtom;

Ora, cercheremo come generare questo famoso JSContext. e lì notiamo: linea 361

JSContext *JS_NewContext(JSRuntime *rt);

Ok... Ora cercheremo chi genera JSRuntime...è infinita questa storia!

Linea 331

JSRuntime *JS_NewRuntime(void);

Alla fine notiamo che il ritorno della funzione eval() sarà una struttura di tipo JSValue composta da una unione e un int.

typedef struct JSValue {
    JSValueUnion u;
    int64_t tag;
} JSValue;

typedef union JSValueUnion {
    int32_t int32;
    double float64;
    void *ptr;
} JSValueUnion;

Ok il contratto sembra essere il seguente:

Creiamo un Runtime, creiamo un context con il nostro runtime e poi possiamo tentare di passare la nostra somma a js_eval.

Possiamo dunque iniziare a creare i nostri primi file, il codice così come il suo test associato.

Prima osservazione, non ho trovato come specificare il file di intestazione e la libreria separatamente.

Hai la scelta tra

FFI::cdef('VOS DEFINITIONS.h', 'VOTRE_POINT_SO.so');

Questo ti obbliga a prendere tutto in una volta, anche se significa avere definizioni di diverse centinaia di righe. Personalmente, non mi piace.

Oppure usare:

FFI::load('VOS DEFINITIONS.h');

Con all'inizio della tua intestazione:

#define FFI_LIB "/path/to/your_lib.so";

Questo non mi sembra particolarmente flessibile. Beh il mio codice potrebbe essere cattivo, a questo punto non importa. D'altra parte mi dà fastidio non avere la flessibilità di specificare quello che voglio usare. Quindi recupererò tramite un file_get_contents le mie definizioni. Ometteremo per ora la bellezza del codice.

Seconda osservazione, php::ffi non è in grado di lavorare relativamente. Inoltre, se vuoi passare il tuo .h o il tuo .so con un ./lib/lib.so, non funzionerà. Questa è una limitazione della lib ffi, non di php. Nell'interim e per andare avanti ho messo direttamente il percorso nel mio file. Aggiungo questo alla mia lista di cose da fare in crescita quando voglio rendere il mio codice più pulito e flessibile.

Allora inizio a fare la mia prima classe, un po' un'accozzaglia lo ammetto. Ma comunque, voglio testare quello che faccio. E poi, ho delle domande:

Come testare \FFI::cdef? Puoi avere un errore nella tua definizione, o non è trovata, o è invalida. Ma puoi anche avere un errore durante il caricamento della libreria stessa. Non ho trovato assolutamente nessuna risorsa in proposito. Vi consegno comunque quello che sono riuscito a trovare per soddisfare il mio bisogno di test:

        try{
            $ffi = new JsEvalService();
            $this->assertInstanceOf(\FFi::class, $ffi->init());
        }catch (\FFI\ParserException $e){
            $this->fail('FFI\ParserException: ' . $e->getMessage());
        }catch(\FFI\Exception $e){
            $this->fail('FFI\Exception: ' . $e->getMessage());
        }
image-76
image-77

Come puoi vedere, controllo che sia presente un'istanza della classe FFI. Ma se si verifica un errore, ho due soluzioni. O testare il parser che solleverà \FFI\ParserException, o verificare che non sia sollevata un'eccezione \FFI\Exception. Attualmente queste sono le uniche due eccezioni esistenti nel modulo FFI di PHP.

Come testare FFI::CData? Ancora, sto battendo la strada perché non riesco a trovare un esempio in proposito. La documentazione è ancora molto vuota e i tutorial inesistenti. Per QuickJs devo inizializzare un runtime e poi un context.

$this->runtime = $this->ffi->JS_NewRuntime();
$this->ffi->JS_NewContext($this->runtime)

JS_NewRuntime() mi dà questo:

.object(FFI\CData:struct JSRuntime*)#366 (1) {

[0]=> object(FFI\CData:struct JSRuntime)#365 (0) {}

}

Allora, posso testare se la classe è di tipo CData ma anche verificare che la struttura sia quella attesa:

$ffi = new JsEvalService();
$ffi->setRuntime();
$this->assertInstanceOf(\FFI\CData::class, $ffi->getRuntime());
$ctype = \FFI::typeof($ffi->getRuntime());
$this->assertEquals('struct JSRuntime*', $ctype->getName());

Lo stesso vale per il context:

$ffi = new JsEvalService();
$ffi->setRuntime();
$ffi->setContext();
$this->assertInstanceOf(\FFI\CData::class, $ffi->getContext());
$ctype = \FFI::typeof($ffi->getContext());
$this->assertEquals('struct JSContext*', $ctype->getName());

Qui l'astuzia è cercare l'oggetto \FFI\CType del tuo \FFI\CData. Avrai accesso a un mucchio di metodi.

getName() mi sarà sufficiente per verificare che il tipo atteso di ciascuna delle mie istruzioni sia effettivamente conforme.

Continuiamo nell'assemblaggio della nostra chiamata al motore JS. Guardando il nostro header, comprendiamo che dobbiamo passare il context alla nostra funzione JS_EVAL(), ovviamente il nostro codice JavaScript, la dimensione del nostro codice JS e i successivi due parametri non sono davvero necessari per quello che vogliamo fare. In sostanza possiamo passare un file con moduli aggiuntivi (compilati con quickjs) e l'ultimo parametro è un flag per specificare alcune opzioni (se siamo in un modulo, se vogliamo abilitare il backtrace, ecc.). Non padroneggio ancora tutte le sottigliezze ma in ogni caso queste opzioni non sono necessarie per noi.

Un metodo di questo tipo dovrebbe essere sufficiente per garantire che la nostra connessione alla lib sia davvero buona.

$this->ffi->JS_Eval( $this->context, $js, strlen($js) , "<evalScript>", 0);

Il codice per la nostra prima chiamata:

$jsString = '2 * 3';
$ffi = new JsEvalService();
$ffi->init();
$math = $ffi->eval($jsString);

Restituisce questo:

object(FFI\CData:struct JSValue)#354 (2) {

["u"]=>

object(FFI\CData:union JSValueUnion)#350 (3) {

["int32"]=>

int(6)

["float64"]=>

float(3.0E-323)

["ptr"]=>

string(3) "0x6"

}
["tag"]=>

int(0)

}

Per decodificare in modo volgare, JS_Eval() deve sempre restituire una struttura del tipo JSValue. Quello che ci interesserà qui è il tipo e l'indirizzo del puntatore. Il tipo ci dice se siamo in errore o se abbiamo un valore reale. E l'indirizzo ci permetterà di recuperare davvero il valore del nostro ritorno JavaScript.

Ecco l'elenco dei tipi trovati nel codice:

// quickjs.h:67
const JS_TAG_FIRST       = -11; /* first negative tag */
const JS_TAG_BIG_DECIMAL = -11;
const JS_TAG_BIG_INT     = -10;
const JS_TAG_BIG_FLOAT   = -9;
const JS_TAG_SYMBOL      = -8;
const JS_TAG_STRING      = -7;
const JS_TAG_MODULE      = -3; /* used internally */
const JS_TAG_FUNCTION_BYTECODE = -2; /* used internally */
const JS_TAG_OBJECT      = -1;
const JS_TAG_INT            = 0;
const JS_TAG_BOOL           = 1;
const JS_TAG_NULL           = 2;
const JS_TAG_UNDEFINED      = 3;
const JS_TAG_UNINITIALIZED  = 4;
const JS_TAG_CATCH_OFFSET   = 5;
const JS_TAG_EXCEPTION      = 6;
const JS_TAG_FLOAT64        = 7;

Siamo dunque di tipo JS_TAG_INT che è piuttosto una buona notizia. Anche QuickJS è ben fatto. Quando guardiamo il codice sorgente notiamo che c'è un metodo per recuperare ciascun valore per ciascun tipo. Qui sarà JS_ToInt32()

Aggiungeremo dunque le informazioni necessarie alla nostra intestazione.

typedef int32_t int32;
int JS_ToInt32(JSContext *ctx, int32_t *pres, JSValue val);

Costruirò così un piccolo metodo per recuperare i dati.

const JS_TAG_FIRST = -11; /* first negative tag */
const JS_TAG_BIG_DECIMAL = -11;
const JS_TAG_BIG_INT = -10;
const JS_TAG_BIG_FLOAT = -9;
const JS_TAG_SYMBOL = -8;
const JS_TAG_STRING = -7;
const JS_TAG_MODULE = -3; /* used internally */
const JS_TAG_FUNCTION_BYTECODE = -2; /* used internally */
const JS_TAG_OBJECT = -1;
const JS_TAG_INT = 0;
const JS_TAG_BOOL = 1;
const JS_TAG_NULL = 2;
const JS_TAG_UNDEFINED = 3;
const JS_TAG_UNINITIALIZED = 4;
const JS_TAG_CATCH_OFFSET = 5;
const JS_TAG_EXCEPTION = 6;
const JS_TAG_FLOAT64 = 7;   
 
public function getValue(\FFI\CData $jsValue){
        switch($jsValue->tag) {
            case self::JS_TAG_INT:
                $value = \FFI::new('int32_t');
                $this->ffi->JS_ToInt32($this->context, FFI::addr($value), $jsValue);
                $value = $value->cdata;
                break;
            default:
                break;
        }
        return $value;
    }

Colgo l'occasione per preparare il terreno per i prossimi tipi da gestire. Non è molto pulito ma mi permette di andare avanti e concentrarmi sulla parte di QuickJS che non padroneggio. Con un po' di fortuna arriverò alla fine dell'argomento e ripulirò l'intero codice. Nel frattempo, posso scrivere l'articolo in parallelo e condividere con voi tutti i miei piccoli ritrovamenti.

Per recuperare il nostro valore, cioè a seconda del tipo, recuperare il valore tipizzato corretto, dovrò testare sul tag che mi dà il suo tipo e a seconda del tipo passare attraverso il giusto metodo interno al motore JS. Quest'ultimo avrà bisogno del context, del puntatore di un CData che conterrà il risultato e infine del nostro JSValue che contiene il risultato della nostra esecuzione JavaScript. Quindi il risultato si può trovare tramite $value->CData

Ecco il test che ho impostato:

    public function testMath(){
        $jsString = '2 * 3';
        $ffi = new JsEvalService();
        $ffi->init();
        $jsValue = $ffi->eval($jsString);
        $this->assertEquals(JsEvalService::JS_TAG_INT, $jsValue->tag);
        $this->assertIsInt($ffi->getValue($jsValue));
        $this->assertEquals(6, $ffi->getValue($jsValue));
    }

Questo articolo sta giungendo al termine. Abbiamo correttamente effettuato la nostra prima chiamata alla libreria QuickJS. Ciò ci ha permesso di impostare un embrione di codice. Ma soprattutto, di vedere come eseguire test unitari sulle nostre diverse chiamate.

Nel prossimo articolo svilupperemo un po' il codice aggiungendo tutti i tipi di ritorno e facendo i nostri primi Callback al codice PHP. Sì, QuickJS sarà in grado di chiamare codice PHP per recuperare dinamicamente valori da utilizzare nel nostro codice JavaScript.

Vi fornisco il codice "completo" (dovrei dire incompleto) utilizzato per questo articolo per capire meglio come vengono chiamati i diversi elementi.

typedef struct JSRuntime JSRuntime;
typedef struct JSContext JSContext;
typedef struct JSObject JSObject;
typedef struct JSClass JSClass;
typedef uint32_t JSClassID;
typedef uint32_t JSAtom;

typedef union JSValueUnion {
int32_t int32;
double float64;
void *ptr;
} JSValueUnion;

typedef struct JSValue {
JSValueUnion u;
int64_t tag;
} JSValue;

JSRuntime *JS_NewRuntime(void);
JSContext *JS_NewContext(JSRuntime *rt);
JSValue JS_Eval(JSContext *ctx, const char *input, size_t input_len, const char *filename, int eval_flags);
typedef int32_t int32;
int JS_ToInt32(JSContext *ctx, int32_t *pres, JSValue val);
<?php
declare(strict_types=1);

namespace Partitech\PhpQuickjs;

Class JsEvalService
{
    const JS_TAG_FIRST = -11; /* first negative tag */
    const JS_TAG_BIG_DECIMAL = -11;
    const JS_TAG_BIG_INT = -10;
    const JS_TAG_BIG_FLOAT = -9;
    const JS_TAG_SYMBOL = -8;
    const JS_TAG_STRING = -7;
    const JS_TAG_MODULE = -3; /* used internally */
    const JS_TAG_FUNCTION_BYTECODE = -2; /* used internally */
    const JS_TAG_OBJECT = -1;
    const JS_TAG_INT = 0;
    const JS_TAG_BOOL = 1;
    const JS_TAG_NULL = 2;
    const JS_TAG_UNDEFINED = 3;
    const JS_TAG_UNINITIALIZED = 4;
    const JS_TAG_CATCH_OFFSET = 5;
    const JS_TAG_EXCEPTION = 6;
    const JS_TAG_FLOAT64 = 7;

    private string $libPath = '/home/geraud/Projets/Partitech/QuickJs/php/src/lib/';
    private string $quickjsSharedObjectName = 'libquickjs.so';
    private string $headerFileName = 'headers.h';
    private $ffi;
    private $runtime;
    private $context;
    public function __construct(){
        $headers = file_get_contents($this->getHeaderFilePath());
        $this->ffi = \FFI::cdef($headers, $this->getQuickjsSharedObjectPath());
    }

    public function getQuickjsSharedObjectPath(): string
    {
        return $this->libPath . $this->quickjsSharedObjectName;
    }

    public function getHeaderFilePath(): string
    {
        return  $this->libPath . $this->headerFileName;
    }

    public function setRuntime(): self
    {
        $this->runtime = $this->ffi->JS_NewRuntime();
        return $this;
    }
    public function getRuntime()
    {
        return $this->runtime;
    }
    public function setContext(): self
    {
        $this->context = $this->ffi->JS_NewContext($this->runtime);
        return $this;
    }
    public function getContext(){
        return $this->context;
    }

    public function init(){
        $this->setRuntime();
        $this->setContext();
        return $this->ffi;
    }

    public function eval($js)
    {
        return $this->ffi->JS_Eval( $this->context, $js, strlen($js) , '', 0);
    }

    public function getValue(\FFI\CData $jsValue){
        switch($jsValue->tag) {
            case self::JS_TAG_INT:
                $value = \FFI::new('int32_t');
                $this->ffi->JS_ToInt32($this->context, \FFI::addr($value), $jsValue);
                $value = $value->cdata;
                break;
            default:
                $value = null;
                break;
        }
        return $value;
    }
}
<?php
declare(strict_types=1);

namespace Partitech\PhpQuickjs\Tests;

use Partitech\PhpQuickjs\JsEvalService;

class JsEvalTest extends \PHPUnit\Framework\TestCase
{
    public function testRuntime()
    {
        $ffi = new JsEvalService();
        $ffi->setRuntime();
        $this->assertInstanceOf(\FFI\CData::class, $ffi->getRuntime());
        $ctype = \FFI::typeof($ffi->getRuntime());
        $this->assertEquals('struct JSRuntime*', $ctype->getName());
    }
    public function testContext()
    {
        $ffi = new JsEvalService();
        $ffi->setRuntime();
        $ffi->setContext();
        $this->assertInstanceOf(\FFI\CData::class, $ffi->getContext());
        $ctype = \FFI::typeof($ffi->getContext());
        $this->assertEquals('struct JSContext*', $ctype->getName());
    }

    public function testInit()
    {
        try{
            $ffi = new JsEvalService();
            $this->assertInstanceOf(\FFi::class, $ffi->init());
        }catch (\FFI\ParserException $e){
            $this->fail('FFI\ParserException: ' . $e->getMessage());
        }catch(\FFI\Exception $e){
            $this->fail('FFI\Exception: ' . $e->getMessage());
        }
    }

    public function testMath(){
        $jsString = '2 * 3';
        $ffi = new JsEvalService();
        $ffi->init();
        $jsValue = $ffi->eval($jsString);
        $this->assertEquals(JsEvalService::JS_TAG_INT, $jsValue->tag);
        $this->assertIsInt($ffi->getValue($jsValue));
        $this->assertEquals(6, $ffi->getValue($jsValue));
    }
}