PHP FFI : création d’un bind avec une lib, QuickJS – partie 5

Pour clôturer ce mini dossier technique sur les foreign functions interface de PHP, je me suis mis en tête de me connecter simplement à une lib. Une sorte de Challenge qui pourrait en même temps me faire progresser sur le sujet. Il m’a fallu donc trouver une idée de projet. Un truc pas trop petit ni trop simple mais pas non plus un truc trop gros. Raté, j’ai trouvé une idée mais celle ci s’avère bien plus complexe que je ne l’imaginais. Qu’à cela ne tienne, j’ai tout de même tenté. Donc pour notre article je me suis dit que j’allais tenter une intégration plus ou moins réussie et plus ou moins aboutie de QuickJS.

Le premier jet est assez simpliste. Et vous allez voir que pour le bien de cette série d’articles j’ai eu quelques complications. Dans un second temps je vais tenter de simplifier l’intégration et essayer d’aller beaucoup plus loin en tentant de faire une intégration avec PHP dans les retours d’appels (via un callback). Donc ma première étape : réussir à lancer du JavaScript et obtenir un retour.

Installation

Mon répertoire de travail va être simple. Un répertoire avec la source de QuickJs, un répertoire avec les sources C du binding (qui nous servira pour simplifier notre première version) et un répertoire pour notre lib PHP.

Installer 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 ..

Concernant mon répertoire de travail, je me suis créé un dossier ‘php’ dans lequel je vais initialiser une architecture composer. Ça aura l’avantage de me fournir un espace de travail avec tout ce dont j’ai besoin pour créer mes fichiers de code ainsi que des tests unitaires qui valideront que tout fonctionne convenablement. Vous pouvez si vous le désirez vous référer à l’article Creer une librairie avec composer.

image-79

Appeler QuickJS et exécuter un code JavaScript simple.

Lors de notre compilation de la librairie nous avons donc créé notre librairie mais aussi une documentation qui va nous permettre de mieux comprendre comment fonctionne ce moteur JavaScript.  Un bref aperçu de l’index et nous nous retrouvons directement dans le descriptif de l’API qui nous apprend que la fonction JS_EVAL() nous permet de lancer une évaluation d’un code JavaScript. Chouette c’est exactement ce que l’on cherche.

Et comme premier objectif on va tenter de faire 2*3=6 ? ce sera un bon début…

Allez hop on file dans le header file pour dérouler la pelote d’informations dont nous avons besoin :

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

Heu… ouai OK le machin il est long comme une autoroute…

donc à la ligne 781 on trouve donc ce que l’on cherche :

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

Ce que ça nous apprend c’est que nous avons besoin d’un type qui serait JSContext. Une petite recherche sur « struct JSContext » nous renvoit vers la ligne 50. Ok on va prendre tout le block ça doit servir…

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

Donc là, on va chercher comment générer ce fameux JSContext. et là on remarque :361

JSContext *JS_NewContext(JSRuntime *rt);

Ok… Donc maintenant nous allons chercher qui génère JSRuntime…c’est sans fin cette histoire !

Ligne 331

JSRuntime *JS_NewRuntime(void);

Pour finir on remarque que le retour de la fonction eval() sera une structure de type JSValue composée d’une union et d’un int.

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

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

Ok le contrat a l’air d’être le suivant :

On crée un Runtime, on crée un context avec notre runtime et ensuite on peut tenter de passer notre addition à js_eval.

Nous pouvons donc commencer à créer nos premiers fichiers, le code ainsi que son test associé.

Première remarque, je n’ai pas trouvé comment spécifier le header file et la librairie séparément.

Vous avez le choix entre

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

Ce qui vous oblige à tout embarquer d’un coup, quitte à avoir des définitions de plusieurs centaines de lignes. Perso j’aime pas.

Ou bien utiliser :

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

Avec en entête de votre header :

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

Ce qui ne me parait pas spécialement flexible. Bon mon code est peut-être dégueulasse, à ce stade ça n’est pas grave. En revanche ça me gonfle un poil de ne pas avoir la flexibilité de spécifier ce que je veux utiliser. Du coup je vais récupérer via un file_get_contents mes définitions. On fera l’impasse sur la beauté du code pour le moment.

Seconde remarque, php::ffi n’est pas capable de travailler en relatif. Aussi, si vous souhaitez passer votre .h ou votre .so avec un ./lib/lib.so, ça ne fonctionnera pas. C’est une limitation de la lib ffi, pas de php. Dans l’intérim et afin d’avancer j’ai directement mis le path dans mon fichier. J’ajoute cela à ma grandissante liste des todo lorsque je voudrai rendre mon code plus propre et plus flexible.

Donc je commence à faire ma première classe, un peu fourre tout j’avoue. Mais il n’empêche que je souhaite tout de même tester ce que je fais. Et là, je me pose des questions :

Comment tester \FFI::cdef ? Vous pouvez avoir une erreur dans votre définition, soit elle est non trouvée, soit elle est invalide. Mais vous pouvez aussi avoir une erreur lors du load de la librairie en elle même. Je n’ai trouvé absolument aucune ressource sur ce sujet. Je vous livre tout de même ce que j’ai réussi à trouver pour contenter mon besoin de tests :

        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

Comme vous pouvez le voir, je vérifie qu’une instance de la classe FFI est bien présente. Mais si une erreur survient, j’ai deux solutions. Soit tester le parser qui va lever une \FFI\ParserException, soit vérifier qu’une \FFI\Exception n’est pas levée. A l’heure actuelle ce sont les deux seules exceptions existantes dans le module FFI de PHP.

Comment tester FFI::CData ? Là encore, je défriche car je ne trouve pas d’exemple sur le sujet. La doc étant encore bien vide et les tutos inexistants. Pour QuickJs je dois donc initialiser un runtime et ensuite un context.

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

JS_NewRuntime() me renvoit ceci :

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

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

}

Du coup, je peux tester si la classe est de type CData mais aussi vérifier que la structure est bien celle attendue :

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

Idem pour le 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());

Ici l’astuce est d’aller chercher l’objet \FFI\CType de votre \FFI\CData. Vous aurez accès à tout un tas de méthodes.

getName() me suffira pour vérifier que le type attendu de chacune de mes instructions est bien conforme.

On poursuit dans le montage de notre appel au moteur JS. En regardant notre header on comprend bien que nous avons besoin de passer le contexte à notre fonction JS_EVAL(), bien entendu notre code javascript, la taille de notre code js et les deux paramètres suivants ne sont pas vraiment nécessaires pour ce que nous souhaitons faire. En gros nous pouvons passer un fichier avec des modules additionnels (compilés avec quickjs) et le dernier paramètre est un flag permettant de spécifier quelques options (si nous sommes dans un module, si nous voulons activer le backtrace, etc). Je ne maîtrise pas encore toutes les subtilités mais dans tous les cas ces options ne nous sont pas nécessaires.

Une méthode de ce type devrait suffire pour valider que notre connexion à la lib est vraiment bonne.

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

Le code de notre premier appel :

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

Nous renvoit ceci :

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)

}

Pour décoder vulgairement, JS_Eval() doit toujours renvoyer une structure de type JSValue. Ce qui va nous intéresser ici est le type et l’adresse du pointeur. Le type nous indique si nous sommes en erreur ou si nous avons une valeur réel. Et l’adresse va nous permettre de vraiment récupérer la valeur de notre retour de JavaScript.

Voici la liste des types trouvés dans le code :

// 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;

Nous sommes donc de type JS_TAG_INT ce qui est plutôt une bonne nouvelle. QuickJS est bien foutu, aussi. Lorsqu’on regarde le code source on remarque qu’il existe une méthode pour récupérer chaque valeur pour chacun des types. Ici ce sera JS_ToInt32()

Nous allons donc rajouter les informations nécessaires à notre header.

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

Je vais donc monter une petite méthode pour récupérer les données.

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;
    }

J’en profite pour préparer le terrain pour les prochains types à gérer. C’est pas bien propre mais ça me permet d’avancer et de me concentrer sur la partie QuickJS que je ne maîtrise pas. Avec un peu de chance j’arriverai au bout du sujet et je mettrai au propre l’ensemble du code. En attendant je peux rédiger l’article en parallèle et vous faire part de l’ensemble de mes petites trouvailles.

Pour récupérer notre valeur, c’est a dire en fonction du type, récupérer la bonne valeur typée, je vais devoir faire un teste sur le tag qui me donne son type et en fonction du type passer par la bonne méthode interne au moteur JS. Celle-ci aura besoin du contexte, du pointeur d’un CData qui contiendra le résultat et pour finir notre JSValue qui contient le resultat de notre execution javascript. On peut donc retrouver le résultat via $value->CData

Voici le test que je mets en place :

    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));
    }

Cet article touche à ça fin. Nous avons correctement effectué notre premier appel à la librairie QuickJS. Cela nous a permis de mettre en place un embryon de code. Mais surtout de voir comment effectuer des tests unitaires sur nos différents appels.

Dans le prochain article nous allons étoffer un peu le code en ajoutant l’ensemble des types de retours et effectuer nos premiers Callback vers du code PHP. Oui, QuickJS va être en mesure d’appeler du code PHP pour récupérer dynamiquement des valeurs à utiliser dans notre code JavaScript.

Je vous fournis le code « complet » (devrais-je dire incomplet) utilisé pour cet article afin de mieux cerner comment sont appelés les différents éléments.

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));
    }
}