11min.

A la découverte des lazy proxy et lazy ghost objets de PHP 8.4

Parmi toutes les nouvelles fonctionnalités de PHP 8.4, nous souhaitions vous faire découvrir les différents usages de la RFC « Lazy Objects ». Cette RFC a été conduite par Arnaud Le Blanc, contributeur au cœur de PHP, et Nicolas Grekas, contributeur principal de Symfony.

Section intitulée que-signifie-em-lazy-loading-emQue signifie Lazy Loading ?

En informatique – mais pas uniquement, 👋 la procrastination – nous aimons délayer au plus tard possible l’utilisation de ressources. C’est-à-dire que nous souhaitons économiser au maximum des cycles CPU, de la consommation de mémoire, des IO tant que nous n’en avons pas besoin.

Prenons un exemple simple : sur une page web très longue, il est inutile de charger toutes les images dès l’ouverture. Il est probable que l’utilisateur ne scrolle jamais jusqu’en bas. En retardant le chargement des images, nous évitons plusieurs requêtes HTTP, ce qui allège le réseau.

C’est là qu’intervient le terme lazy. On reporte une tâche à plus tard. Par exemple, on ne charge les images que si l’utilisateur commence à faire défiler la page.

Dans cet article, nous allons voir ce que PHP 8.4 nous propose pour rendre notre code lazy.

Section intitulée un-peu-d-histoireUn peu d’histoire

Il n’y avait pas besoin d’attendre PHP 8.4 pour avoir des objets lazy. Nous pouvons déjà le faire dans notre propre code :

final readonly class CartRepository
{
    private Redis $redis;

    public function __construct(
        private string $host,
    ) {
    }

    public function isCartExpired(Cart $cart): bool
    {
        // de la logique qui n'a pas besoin de $this->redis

        return true;
    }

    public function save(Cart $cart)
    {
        $this->redis()->hMSet($cart->id, $cart->toArray());
    }

    public function get(string $cartId): Cart
    {
        return Cart::FromArray(...$this->redis()->hGetAll($cartId));
    }

    private function redis(): \Redis
    {
        if (isset($this->redis)) {
            return $this->redis;
        }

        $this->redis = new \Redis();
        $this->redis->connect($this->host);

        return $this->redis;
    }
}

Dans la classe CartRepository, la connexion au serveur Redis est ouverte uniquement lorsque le code en a réellement besoin. Un appel à la méthode isCartExpired() n’ouvrira pas la connexion, contrairement aux méthodes save() ou get().

Bien que fonctionnel, ce genre de code est pénible à écrire, source de discussions, et n’apporte pas beaucoup de valeur ajoutée au produit.

C’est pourquoi des bibliothèques ont vu le jour pour simplifier l’écriture de ce type de code. On peut citer :

Baptiste a d’ailleurs écrit un article sur l’utilisation de symfony/var-exporter.

Cependant, maintenir de telles bibliothèques en PHP est complexe. Certaines choses ne peuvent pas être faites en PHP pur, et il existe des cas où ces bibliothèques ne fonctionnent pas en raison de limitations techniques.

C’est pourquoi Nicolas et Arnaud ont travaillé ensemble pour intégrer ces fonctionnalités directement dans le cœur de PHP. Merci à eux 💛

Section intitulée qu-est-ce-qu-un-em-lazy-object-emQu’est-ce qu’un lazy object ?

Lorsque vous souhaitez rendre un objet lazy, disons une instance de MyClass, PHP crée un proxy qui a la même signature que MyClass. Vu de l’extérieur, rien ne distingue ce proxy d’un objet classique. C’est nécessaire pour respecter les types des arguments ou propriétés en PHP.

Le lazy object se comporte exactement comme l’objet réel. Toutefois, au premier accès à l’une de ses propriétés non initialisées, le lazy loading se déclenche :

class MyClass
{
    public Deps $deps;

    public function greet()
    {
        return 'Hello 👋';
    }
}

//  Nous verrons comment faire un lazy objet plus tard
$myObject = create_lazy_object();

// return true
$myObject instanceof MyClass;

// Le lazy loading n'est pas déclenché car greet n'a pas besoin de `$deps`
echo $myObject->greet();

// Le lazy loading est déclenché ici
$myObject->deps;

Section intitulée quand-utiliser-du-em-lazy-loading-emQuand utiliser du lazy loading ?

Une question fréquente lors de nos formations est :

Pourquoi ne pas activer le lazy loading partout ?

Comme souvent, une fonctionnalité a un coût. Activer le lazy loading ralentit l’instanciation de la classe. Il faut donc trouver le juste milieu entre instancier toutes les dépendances ou rendre la classe lazy.

Dans l’exemple précédent, nous avons fait un petit benchmark entre une instanciation classique (direct) et une instanciation d’un objet lazy (lazy). Dans chaque cas, nous récupérons la dépendance :

direct   0.141μs (±2.09%)
lazy     0.434μs (±2.84%)

Il n’y a pas de solution parfaite. Si une classe utilise toujours ses dépendances, il n’y a aucune raison d’activer le lazy loading. Cependant, si une classe a une dépendance qui effectue une requête SQL, la question mérite d’être posée.

C’est d’ailleurs l’une des utilisations classiques du lazy loading. Doctrine ORM l’utilise largement. Dès que notre code a besoin de la propriété d’une relation, Doctrine exécute une requête SQL pour récupérer cette relation :

class Category
{
    public string $name;
}

class Page
{
    public function __construct(
        public Category $category
    ) {
    }
}

// Une requête SQL pour récupérer la page avec l'ID 12345
$page = $pageRepository->find(12345);

// Une autre requête SQL pour récupérer la catégorie associée
$page->category->name;

Ici, $page->category est un lazy object.

Un autre cas d’utilisation est celui des services lazy dans Symfony :

class Foo
{
    public function __construct(
        #[Autowire(lazy: true)]
        private Bar $bar
    ) {
    }
}

Tant que la propriété $this->bar n’est pas utilisée, elle n’est pas instanciée.

Il existe une méthode plus globale :

#[Autoconfigure(lazy: true)]
class Bar
{
}

Ici, tous les services dépendant de Bar recevront un proxy. La classe Bar ne sera instanciée qu’au moment d’un appel sur ses propriétés ou méthodes.

Les frameworks et bibliothèques adopteront sûrement rapidement cette nouveauté de PHP 8.4. Vous n’aurez probablement pas à l’utiliser directement, mais il est toujours intéressant de comprendre comment cela fonctionne.

Section intitulée comment-faire-un-em-lazy-proxy-em-avec-php-8–4Comment faire un lazy proxy avec PHP 8.4 ?

Étudions ensemble le code suivant :

final readonly class Client
{
    public function __construct(
        private string $a,
        private string $very,
        private string $long,
        private string $list,
        private string $of,
        private string $constructor,
        private string $argument,
        private string $userAgent,
    ) {
    }

    public function fetch(int $id): array
    {
        // Ici, on imagine faire un appel HTTP vers une API distante
        // Et nous aurons besoin de certains dépendance de la class client
        $this->userAgent;

        return [
            'id' => $id,
            'name' => 'John Doe',
        ];
    }

    public function getName(): string
    {
        return 'The Super API Client';
    }
}

final readonly class UserRepository
{
    public function __construct(
        public Client $client,
    ) {
    }

    public function find(int $id): array
    {
        return $this->client->fetch($id);
    }

    public function isUserEnabled(User $user): void
    {
        // de la logique qui n'a pas besoin de $this->client

        return true;
    }
}

Nous avons deux classes :

  • Client effectue des requêtes HTTP vers une API ;
  • UserRepository expose des méthodes pour récupérer des utilisateurs.

L’instanciation de Client est complexe car elle nécessite de nombreux paramètres coûteux à obtenir. Rendons cette classe lazy grâce à la réflexion, qui est l’unique moyen de créer des objets lazy :

$reflector = new ReflectionClass(Client::class);

$client = $reflector->newLazyProxy(function (): Client {
    // Instanciation du vrai Client
    // Ce code ne sera appelé uniquement au moment où une propriété du **Client** sera utilisé
    return new Client(
        'a',
        'very',
        'long',
        'list',
        'of',
        'constructor',
        'argument',
        'Mozilla/5.0',
    );
});

Et voilà 🎉, ce n’était pas compliqué ?

Nous pouvons maintenant utiliser ce lazy proxy dans notre repository :

$repository = new UserRepository($client);

// A ce moment de l'exécution du code, le vrai Client n'a pas été instancié
$repository->isUserEnabled($user);

// A ce moment de l'exécution du code, le vrai Client n'a toujours pas été instancié
dump($repository->client->getName());

// Mais ce code va déclencher l'instantiation du vrai Client
dump($repository->find(42));

// Affichera :
// ^ array:2 [
//   "id" => 42
//   "name" => "John Doe"
// ]

Vous pouvez voir ce code en action sur 3v4l.org.

Le titre de l’article mentionne lazy ghost, mais nous n’en avons toujours pas parlé ! Il est temps de remédier à ça.

Les Ghost Objects sont des lazy objects d’un type un peu différent. Au lieu d’instancier l’objet, ils se concentrent sur l’initialisation de ses propriétés.

Cela peut être utile lorsque vous ne souhaitez pas lancer une logique complexe au début, mais juste initialiser une partie de votre objet.

Section intitulée comment-faire-un-em-lazy-ghost-emComment faire un lazy ghost ?

Cette fois-ci, étudions un code un peu différent :

final readonly class Post
{
    public int $id;
    public string $name;
    public string $email;

    public function greet(): string
    {
        return "Hello 👋";
    }
}

final readonly class PostRepository
{
    public function find(int $id): Post
    {
        // Fait des appels à la base de données
    }
}

Nous avons deux classes :

  • Post est un DTO qui contient des données
  • PostRepository est une classe qui expose des méthodes pour récupérer des posts.

Contrairement au chapitre précédent, nous n’avons pas un service que nous voulons rendre lazy. Ici, nous voulons rendre une instance de Post lazy. C’est-à-dire que nous voulons retourner une instance de Post de la méthode PostRepository::find(), mais que la requête SQL soit retardée jusqu’à l’utilisation d’une propriété de l’instance Post.

Implémentons la méthode find() ensemble, avec des commentaires dans le code :

final readonly class PostRepository
{
    private ReflectionClass $reflector;

    public function __construct()
    {
        // Nous aurons besoin de la réflexion, alors stockons immédiatement une instance dans le repository
        $this->reflector = new ReflectionClass(Post::class);
    }

    public function find(int $id): Post
    {
        // Contrairement à newLazyProxy(), nous recevons une instance de Post, mais entièrement vide
        $entity = $this->reflector->newLazyGhost(function (Post $entity) use ($id): void {
            // Nous récupérons les données stockées en base de données
            $data = $this->loadFromDatabase($id);

            // Nous assignons les données aux différentes propriétés
            $this->reflector->getProperty('name')->setValue($entity, $data['name']);
            $this->reflector->getProperty('email')->setValue($entity, $data['email']);
        });

        // Comme nous connaissons déjà l'ID du post, nous pouvons le définir dans la classe directement.
        // Un appel à cette propriété ne déclenchera pas le chargement
        $this->reflector->getProperty('id')->setRawValueWithoutLazyInitialization($entity, $id);

        return $entity;
    }

    private function loadFromDatabase(int $id): array
    {
        // Simule une requête à la base de données
        return [
            'name' => 'John Doe',
            'email' => 'john.doe@example.com',
        ];
    }
}

Et voilà 🎉, ce n’était pas compliqué ?

Nous pouvons maintenant utiliser ce ghost object dans notre code :

$em = new PostRepository();
$post = $em->find(42);

// À ce moment, la récupération en base de données n'a pas été effectuée
dump($post);
// Affiche
// ^ Post^ {#6
//   +id: 42
//   +name: ? string
//   +email: ? string
// }

// À ce moment, la récupération en base de données n'a toujours pas été effectuée
dump($post->greet());

// Mais ce code va déclencher la récupération en base de données
$post->email;

dump($post);
// Affiche
// ^ Post^ {#12
//   +id: 42
//   +name: "John Doe"
//   +email: "john.doe@example.com"
// }

Vous pouvez voir ce code en action sur 3v4l.org.

Section intitulée un-em-use-case-em-plus-realisteUn use case plus réaliste ?

Les deux exemples précédents ne sont pas très réalistes. En général, vous n’aurez pas à écrire ce code, car votre framework préféré, ainsi que votre ORM, le feront pour vous.

Nous avons réfléchi à un cas d’usage un peu plus réaliste. Laissez-nous poser le contexte. Sur un site à très fort trafic, il est peu recommandé de stocker les paniers dans une base de données, qui se remplirait trop vite, devenant trop gourmande en ressources et augmentant considérablement la facture. Une solution couramment utilisée est le stockage du panier dans Redis.

Dans l’exemple suivant, nous avons une classe Cart avec un id et une collection de Product. Ces produits sont néanmoins stockés en base de données, avec Doctrine. Pour optimiser l’application, nous ne voulons pas récupérer les produits tant qu’ils ne sont pas affichés. Souvent, le total ainsi que le nombre d’éléments sont suffisants.

Observons nos objets métier :

final readonly class Product
{
    public function __construct(
        public string $id,
        public string $name,
        public string $description,
        public int $price = 10,
    ) {
    }
}

final class Cart
{
    /**
     * @param Product[] $products
     */
    public function __construct(
        public string $id,
        public array $products = [],
        public int $total = 0,
        public int $quantity = 0,
    ) {
    }

    public function addProduct(Product $product): void
    {
        $this->products[] = $product;
        $this->quantity++;
        $this->total += $product->price;
    }
}

final readonly class ProductRepository
{
    public function find(string $id): Product
    {
        // Simule le chargement depuis la base de données
        return new Product($id, "product-$id", "description-$id");
    }

    public function findByIds(array $ids): array
    {
        // Simule le chargement depuis la base de données
        $products = [];
        foreach ($ids as $id) {
            $products[] = $this->find($id);
        }

        return $products;
    }
}

Rien de nouveau ici. Nous voulons utiliser ce type de code :

$productRepository = new ProductRepository();
$repository = new CartRepository($productRepository);

$cart = new Cart('#cart-01');
$cart->addProduct($productRepository->find('1'));
$cart->addProduct($productRepository->find('2'));
$cart->addProduct($productRepository->find('3'));

// C'est cette chaîne qui sera stockée dans Redis
$serialized = $repository->serialize($cart);

// Une nouvelle requête HTTP arrive

// On récupère la chaîne depuis Redis, et on hydrate le panier
// À ce moment, aucune requête SQL n'a été effectuée
$newCart = $repository->unserialize($serialized);

// À ce moment, toujours aucune requête SQL n'a été effectuée
dump($newCart->total);

// Mais ce code va déclencher une requête SQL
$newCart->products;

Maintenant que nous avons vu les objets métier ainsi que leur utilisation, voyons comment coder le CartRepository :

final readonly class CartRepository
{
    private ReflectionClass $reflector;

    public function __construct(
        private ProductRepository $productRepository,
    ) {
        $this->reflector = new ReflectionClass(Cart::class);
    }

    public function serialize(Cart $cart): string
    {
        return json_encode([
            'id' => $cart->id,
            'products' => array_column($cart->products, 'id'),
            'total' => $cart->total,
            'quantity' => $cart->quantity,
        ]);
    }

    public function unserialize(string $data): Cart
    {
        $previousCart = json_decode($data, true);

        $newCart = $this->reflector->newLazyGhost(function(Cart $newCart) use ($previousCart): void {
            $products = $this->productRepository->findByIds($previousCart['products']);
            $this->reflector->getProperty('products')->setValue($newCart, $products);
        });

        $this->reflector->getProperty('id')->setRawValueWithoutLazyInitialization($newCart, $previousCart['id']);
        $this->reflector->getProperty('total')->setRawValueWithoutLazyInitialization($newCart, $previousCart['total']);
        $this->reflector->getProperty('quantity')->setRawValueWithoutLazyInitialization($newCart, $previousCart['quantity']);

        return $newCart;
    }
}

Nous avons une méthode serialize qui nous permet d’obtenir une représentation sous forme de chaîne de notre panier. C’est elle qui sera stockée dans Redis. Elle ressemble à ceci :

"{"id":"#cart-01","products":["1","2","3"],"total":30,"quantity":3}"

Quand une nouvelle requête HTTP arrive, il faut désérialiser la représentation et reconstruire le panier. Comme dans le chapitre précédent, nous utilisons la réflexion.

Comme d’habitude, retrouvez le code en action ici.

Section intitulée conclusionConclusion

Les lazy ghosts et lazy proxies sont vraiment très intéressants. Avec une API simple, ils permettent de retarder l’instanciation de nos objets jusqu’à ce que nous en ayons vraiment besoin. L’API est assez intuitive et naturelle à prendre en main. Cependant, il est important de s’exercer un peu pour en comprendre les subtilités. Cet article n’a pas pu toutes les couvrir, ni montrer toutes les options disponibles. Nous vous invitons à lire la RFC pour en apprendre plus.

Quelques points pourraient encore être améliorés :

  • La possibilité de rendre une seule propriété lazy, et non toute la classe ;
  • La possibilité de rendre un objet déjà existant lazy, de sorte que si une propriété non initialisée est appelée, l’initialiseur soit déclenché.

PHP 8.4 n’étant toujours pas officiellement sorti, cette fonctionnalité n’est probablement pas encore utilisable. Mais nous sommes certains qu’elle le sera très rapidement par Doctrine et Symfony (Nicolas l’a conçue pour cela).

Commentaires et discussions

Ces clients ont profité de notre expertise