PHP FFI: Creando un enlace con una biblioteca, QuickJS – Parte 5

Para concluir este mini archivo técnico sobre la interfaz de funciones foráneas de PHP, me propuse simplemente conectarme a una librería. Un tipo de desafío que podría al mismo tiempo ayudarme a progresar en el tema. Así que tuve que idear una idea de proyecto. Algo no demasiado pequeño o simple pero tampoco demasiado grande. Fallé, encontré una idea pero resulta ser mucho más compleja de lo que imaginé. No importa, aun así lo intenté. Así que para nuestro artículo, pensé que intentaría una integración más o menos exitosa y más o menos completa de QuickJS.

Instalación

Mi espacio de trabajo será simple. Un directorio con la fuente de QuickJS, un directorio con enlaces de fuente C (que nos ayudarán a simplificar nuestra primera versión) y un directorio para nuestra librería PHP.

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

Sobre mi espacio de trabajo, he creado una carpeta 'php' en la que inicializaré una arquitectura de composer. Esto me proporcionará un espacio de trabajo con todo lo que necesito para crear mis archivos de código, así como pruebas unitarias que confirmarán que todo funciona correctamente. Si lo deseas, puedes referirte al artículo Crear una librería con composer.

image-79

Llamando a QuickJS y ejecutando código JavaScript simple.

Durante la compilación de nuestra librería, creamos nuestra biblioteca pero también documentación que nos ayudará a comprender mejor cómo funciona este motor de JavaScript. Un rápido vistazo al índice y nos encontramos directamente en la descripción de la API que nos enseña que la función JS_EVAL() nos permite lanzar una evaluación de código JavaScript. Genial, eso es exactamente lo que estábamos buscando.

Y como nuestro primer objetivo intentaremos hacer 2*3=6 ? será un buen comienzo...

Vamos al archivo de encabezado para desenredar el ovillo de información que necesitamos:

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

Uh... bueno, esa cosa es tan larga como una autopista...

entonces en la línea 781 encontramos lo que buscamos:

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

Lo que esto nos enseña es que necesitamos un tipo que sería JSContext. Una rápida búsqueda de "struct JSContext" nos lleva a la línea 50. De acuerdo, tomaremos todo el bloque, debería ser útil...

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

Así que ahora, buscaremos cómo generar este famoso JSContext. y ahí notamos: línea 361

JSContext *JS_NewContext(JSRuntime *rt);

Ok... Así que ahora buscaremos quien genera JSRuntime... ¡esto es interminable!

Línea 331

JSRuntime *JS_NewRuntime(void);

Al final notamos que el retorno de la función eval() será una estructura de tipo JSValue compuesta por una union y un int.

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

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

Ok el contrato parece ser el siguiente:

Creamos un Runtime, creamos un contexto con nuestro runtime y luego podemos intentar pasar nuestra suma a js_eval.

Por lo tanto, podemos comenzar a crear nuestros primeros archivos, el código así como su prueba asociada.

Primera observación, no he encontrado cómo especificar el archivo de encabezado y la biblioteca por separado.

Tienes la elección entre

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

Esto te obliga a embarcar todo de una vez, incluso si significa tener definiciones de varias cientos de líneas. Personalmente, no me gusta.

O usar:

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

Con al principio de tu encabezado:

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

Esto no me parece particularmente flexible. Pues mi código puede ser malo, en este punto no importa. Por otro lado me molesta un poco no tener la flexibilidad para especificar lo que quiero usar. Así que recuperaré a través de un file_get_contents mis definiciones. Omitiremos la belleza del código por ahora.

Segunda observación, php::ffi no es capaz de trabajar de manera relativa. Además, si quieres pasar tu .h o tu .so con un ./lib/lib.so, no funcionará. Esta es una limitación de la lib ffi, no de php. Mientras tanto y para avanzar he puesto directamente la ruta en mi archivo. Añado esto a mi creciente lista de tareas pendientes cuando quiera hacer mi código más limpio y flexible.

Así que empiezo a hacer mi primera clase, un poco de cajón de sastre lo admito. Pero aún así, quiero probar lo que hago. Y luego, tengo preguntas:

¿Cómo probar \FFI::cdef? Puedes tener un error en tu definición, ya sea que no se encuentre o que sea inválida. Pero también puedes tener un error durante la carga de la biblioteca en sí. No he encontrado absolutamente ningún recurso sobre este tema. Aún así, les entrego lo que logré encontrar para satisfacer mi necesidad de pruebas:

        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

Como puedes ver, compruebo que está presente una instancia de la clase FFI. Pero si se produce un error, tengo dos soluciones. O bien probar el analizador que elevará una \FFI\ParserException, o verificar que no se haya generado una \FFI\Exception . En la actualidad, estas son las únicas dos excepciones existentes en el módulo FFI de PHP.

¿Cómo probar FFI::CData? De nuevo, estoy abriendo camino porque no puedo encontrar un ejemplo sobre el tema. La documentación sigue siendo muy escasa y los tutoriales inexisten. Para QuickJs tengo que inicializar un runtime y luego un contexto.

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

JS_NewRuntime() me da esto:

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

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

}

Entonces, puedo probar si la clase es de tipo CData pero también verificar que la estructura sea la esperada:

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

Lo mismo va para el contexto:

$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());

Aquí el truco es buscar el objeto \FFI\CType de tu \FFI\CData. Tendrás acceso a un montón de métodos.

getName() me bastará para verificar que el tipo esperado de cada una de mis instrucciones es de hecho conforme.

Continuamos en el montaje de nuestra llamada al motor JS. Al mirar nuestro header, entendemos que necesitamos pasar el contexto a nuestra función JS_EVAL(), por supuesto nuestro código JavaScript, el tamaño de nuestro código JS y los siguientes dos parámetros realmente no son necesarios para lo que queremos hacer. Básicamente podemos pasar un archivo con módulos adicionales (compilados con quickjs) y el último parámetro es una bandera para especificar algunas opciones (si estamos en un módulo, si queremos habilitar backtrace, etc.). No domino todas las sutilezas pero de cualquier manera estas opciones no son necesarias para nosotros.

Un método de este tipo debería ser suficiente para asegurar que nuestra conexión a la lib sea realmente buena.

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

El código para nuestra primera llamada:

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

Nos devuelve esto:

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)

}

Para decodificar vulgarmente, JS_Eval() siempre debe devolver una estructura de tipo JSValue. Lo que nos interesará aquí es el tipo y la dirección del puntero. El tipo nos dice si estamos en error o si tenemos un valor real. Y la dirección nos permitirá recuperar realmente el valor de nuestro retorno de JavaScript.

Aquí está la lista de tipos encontrados en el código:

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

Por lo tanto, somos del tipo JS_TAG_INT que es bastante buena noticia. QuickJS también está bien hecho. Cuando miramos el código fuente notamos que hay un método para recuperar cada valor para cada tipo. Aquí será JS_ToInt32()

Añadiremos por lo tanto la información necesaria a nuestro encabezado.

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

Así construiré un pequeño método para recuperar los datos.

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

Aprovecho la oportunidad para preparar el terreno para los próximos tipos a gestionar. No esta muy limpio pero me permite avanzar y concentrarme en la parte de QuickJS que no domino. Con un poco de suerte llegaré al final del tema y limpiaré todo el código. Mientras tanto, puedo escribir el artículo en paralelo y compartir con ustedes todos mis pequeños descubrimientos.

Para recuperar nuestro valor, es decir, dependiendo del tipo, recuperar el valor tipado correcto, tendré que probar en la etiqueta que me da su tipo y dependiendo del tipo pasar por el método interno correcto al motor JS. Este necesitará el contexto, el puntero de un CData que contendrá el resultado y finalmente nuestro JSValue que contiene el resultado de nuestra ejecución de JavaScript. Así que el resultado se podrá encontrar a través de $value->CData

Aquí está la prueba que configuré:

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

Este artículo está llegando a su fin. Hemos hecho correctamente nuestra primera llamada a la biblioteca QuickJS. Esto nos permitió establecer un embrión de código. Pero lo más importante, ver cómo realizar pruebas unitarias en nuestras diferentes llamadas.

En el próximo artículo rellenaremos un poco el código añadiendo todos los tipos de retorno y haciendo nuestras primeras Callbacks al código PHP. Sí, QuickJS podrá llamar al código PHP para recuperar de forma dinámica valores que se utilizarán en nuestro código JavaScript.

Les proporciono el código "completo" (¿debería decir incompleto?) utilizado para este artículo para entender mejor cómo se llaman los diferentes elementos.

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