Requêtes asynchrones avec PHP

Et bien si, c'est possible, et sans aucun service tiers. Le principe est assez simple :

Ouverture d'un socket vers le serveur.
Construction de la requête HTTP avec les bons en-têtes.
Envoi de la requête via le socket.
Fermeture du socket, laissant le serveur traiter la requête en arrière-plan sans que l'on attende la réponse.

Et c'est finalement très simple à mettre en place.

Inconvénient principal :

  • On ne peut pas exploiter la réponse (en tous cas pas directement)

Service de requêtes asynchrone

<?php

namespace App\Service\Async;

/**
 * this service allows you to set up asynchronous requests
 */
class Fetch
{

    public function __construct()
    {
    }

    /**
     * @param string $addr
     * @param array|null $data
     *
     * @return int
     *
     * Fetch with GET method
     */
    public function get(string $addr, array | null $data = null): int
    {
        return $this->fetch($addr, 'GET', $data);
    }

    /**
     * @param string $addr
     * @param array|null $data
     *
     * @return int
     *
     * Fetch with POST method
     */
    public function post(string $addr, array | null $data = null): int
    {
        return $this->fetch($addr, 'POST', $data);
    }

    /**
     * @param string $addr
     * @param array|null $data
     *
     * @return int
     *
     * Fetch with PUT method
     */
    public function put(string $addr, array | null $data = null): int
    {
        return $this->fetch($addr, 'PUT', $data);
    }

    /**
     * @param string $addr
     * @param array|null $data
     *
     * @return int
     *
     * Fetch with PATCH method
     */
    public function patch(string $addr, array | null $data = null): int
    {
        return $this->fetch($addr, 'PATCH', $data);
    }

    /**
     * @param string $addr
     * @param array|null $data
     *
     * @return int
     *
     * Fetch with DELETE method
     */
    public function delete(string $addr, array | null $data = null): int
    {
        return $this->fetch($addr, 'DELETE', $data);
    }

    /**
     * @param string $addr
     * @param string $method
     * @param array|null $data
     *
     * @return int
     */
        private function fetch(string $url, string $method, array | null $data = null): int
        {
            $postString = $data ? json_encode($data): '';

            $urlData = parse_url($url);

            if (!isset($urlData['host']) || !isset($urlData['path'])) {
                error_log("URL invalide : " . $url);
                return 0;
            }

            $errorCode    = '';
            $errorMessage = '';

            $scheme = $urlData['scheme'] ?? 'http';
            $host   = $urlData['host'];
            $path   = $urlData['path'] ?: '/';
            $port   = $urlData['port'] ?? ($scheme === 'https' ? 443 : 8000);
            $transport = $scheme === 'https' ? 'ssl://' : '';

            $fp = stream_socket_client("$transport$host:$port", $errorCode, $errorMessage, 30);

            if (!$fp) {
                error_log(sprintf('Impossible to open socket whith  %s %s %s %s', $url, $transport, $host, $port));
                return 0;
            }

            $out = sprintf(
                "%s %s HTTP/1.1\r\n" .
                "Host: %s\r\n" .
                "Content-Type: application/json\r\n" .
                "Content-Length: %d\r\n" .
                "Connection: Close\r\n\r\n" .
                "%s",
                strtoupper($method), $path, $host, strlen($postString), $postString
            );

            fwrite($fp, $out);
            fclose($fp);

            return 1;

        }
}

Exemple d'utilisation dans un controller symfony

On crée une route normale

#[Route('/async', name: 'async')]
    public function index(Fetch $fetch)
    {
        $t1 = $this->shortTask();

        /**
         * Ici on appelle la route asynchrone qui se charge des traitements lourds
         */
        $fetch->post('localhost:8000/async/treatment');

        /**
         * La réponse est renvoyée même si le traitement lourd n'est pas fini
         */
        return new JsonResponse([
            't1' => $t1,
        ]);
    }

On a deux fonctions. L'une s’exécute instantanément, l'autre met 10 secondes à retourner un résultat. C'est cette dernière que l'on appelle dans la route /async/treatment de manière asynchrone.

 private function shortTask(): int
    {
        return 6 * 3;
    }

    private function longTask(int $a): int
    {
        sleep(10);
        return $a * 3;
    }

Cette route se charge donc de récupérer la requête envoyée en asynchrone et de faire les traitements voulus.

/**
     * ROUTE ASYNCHRONE
     */
    #[Route('/async/treatment', name: 'async_treatment', methods: ['POST'])]
    public function async(KernelInterface $kernel): JsonResponse
    {
        /**
         * Méthode très longue à traiter (voir ci-dessus)
         */
        $t = $this->longTask(4);

        $data = ['total' => $t];
        $path = $kernel->getProjectDir() . '/public/test.json';
        file_put_contents($path, json_encode($data));
        return new JsonResponse(null);
    }

En résumé

On se rend sur la route /async et on affiche instantanément la réponse JSON. Mais grâce à $fetch->post('route_asynchrone') on a lancé une requête asynchrone qui fait un long calcul. Et 10 secondes plus tard, notre fichier JSON est mis à jour. Pour envoyer un mail par exemple, cela reste une méthode très pratique.