← Voir tous les articlesJoris PISSONJoris PISSON - 14 févr. 2023

La programmation orientée aspect - Proxy

Cette suite d'articles explore les concepts de programmations orientées aspect (POA) à travers une présentation de cas d'usages. La semaine dernière était purement théorique; aujourd'hui, place à la pratique avec les Proxy.

La programmation orientée aspect appliqué au Frontend

N.B : Certains exemples servent à démontrer ce qu'il ne faut pas faire ou servent uniquement de piste de réflexion.

TLDR;

La programmation orientée aspect est un outil puissant qui nécessite rigueur et précautions. Je vous recommande fortement de vous documenter sur les Proxy, la Reflect API et les Decorators. Les Proxy conviennent à de multiples usages, notamment la méta-programmation, ils peuvent être utilisés pour modifier une librairie externe sans modifier son code d'origine.

Voici le lien vers le projet d'exemple (C'est une application à but didactique créée pour cet article, j'y fais volontairement un usage abusif de POA, et évidemment, cette application pourrait être améliorée).

Qu'est-ce qu'un Proxy ?

Un Proxy vous permet de créer un "objet" utilisable en lieu et place de votre cible d'origine; mais le Proxy peut très bien redéfinir fondamentalement la cible qu'il surcouche dans ses opérations 'get'/'set' ; Tout comme les propriétés et méthodes qu'il expose, sans pour autant modifier cible d'origine.

const proxy = new Proxy(cible, gestionnaire)

Target : La cible (Object/Class/Tableau/Function) à enrichir d'un nouveau comportement.

Handler : Le gestionnaire a une structure Objet dans laquelle on implémente la logique comportementale. (Nous utiliserons surtout "get", "set" et "has", mais sachez qu'il en existe d'autres). [Proxy]

Reflect : En combinaison avec les Proxy, la réflexion est très utile lors de l'implémentation de gestionnaires. [Reflect]

Vous devriez utiliser la Reflect API dans vos gestionnaires, même si cela semble plus simple d'utiliser l'indexation indirecte pour accéder à une propriété, la réflexion vous permettra d'éviter d'avoir à gérer des cas particuliers. Notamment, il vous faut avoir à l'esprit certaines limitations lorsque vous utilisez les Proxy.

Comment l'intégrer à mon application ?

La couche Data - Gestionnaire d'état applicatif

Tel que Vuex ou Redux qui, de par leur structure, pourraient être de bons candidats.

Github code

Si vous décomposez la construction de votre "Store" en de multiples fichiers, vous pouvez aisément enrober vos "actions" / "mutations" / "state" / "getters" avec un Proxy. Mais beaucoup d'applications n'ont pas besoin de "Store".

La couche Service - les Class business

Vous pouvez ajouter vos Aspects sur les instances de vos Class business.

Dans les deux cas, les Aspects sont définis une fois et utilisables là où vous en avez besoin.

Crédit à "souryoukomi"

Par où commencer ?

Un premier exemple très simple de Proxy.

Github code

Lorsque l'on accède à une propriété de la cible via le Proxy, l'appel à cette propriété est intercepté par le gestionnaire du Proxy, qui va écrire dans la console quelle est la cible sur laquelle est appliqué cet Aspect et quelle est la propriété à laquelle on accède. Puis nous utilisons la réflexion pour obtenir la valeur de la propriété de la cible d'origine.

Le bon, la brute et le truand

Le bon, la brute et le truand, citation peinte sur un casque.

N.B: C'est un exemple de ce qu'il ne faut pas faire.

Github code

Le bon : la première chose à retenir c'est que vous pouvez tout à fait imbriquer plusieurs Proxy. L'ordre d'appel est respecté comme le montrent les traces dans la console (notez l'ordre d'appel).

La brute : le comportement original de la cible a été modifié par le Proxy ; Les propriétés de la cible ne sont plus accessibles directement, car l'appel passe à travers une chaîne de Promises. Il faut maintenant utiliser Promise.prototype.then() ou Async/Await pour obtenir la valeur de la propriété.

Le Truand : les différents gestionnaires sont quasiment identiques ; ne vous répétez pas, prenez le temps de "refactorer" votre code.

Exemple de Profiling et Logging avec Obfuscation

En quelques mots, le Profiling est utilisé pour mesurer le temps écoulé pour réaliser une action ; le Logging sert, quant à lui, à garder une trace de ce qui s'est passé.

Github code

J'utilise ici uuidv4 pour la consistance de logs avec "console.time()". D'autres préféreront conserver un timestamp pour calculer le temps d'exécution manuellement et améliorer la lisibilité des logs (sans les uuid).

Je ne recommande pas l'utilisation de cette solution en production sans réduire au préalable le scope de vos profilling au minimum en utilisant une stratégie de log cohérente et des transporteurs de logs correctement configurés.

Vous pouvez également "templatiser" vos logs pour y intégrer un "request ID" propre à une action unique d'un utilisateur qui sera réutilisé par toutes vos couches front et back afin de consolider la traçabilité d'une action au sein d'un système distribué (plusieurs µServices).

Pour l'obfuscation, j'utilise ici un Set, mais vous pourriez utiliser n'importe quel type de collection qui vous permet de lister de manière exhaustive la liste des propriétés sensibles à obfusquer tel que "creditCard"…

Ou bien utiliser une stratégie de matching en corrélation avec une stratégie de nommage de vos propriétés pour utiliser String.prototype.startsWith() ou String.prototype.includes()

Les deux se basent sur la propriété "prop" du getter et du setter de votre gestionnaire.

get(target, prop, receiver)
set(target, prop, value, receiver)

Pour consommer ces deux aspects nous pouvons procéder comme cela.

Github code

Exemple de Memoïzation

Memoïzation is an optimization technique where expensive function calls are cached such that the result can be immediately returned the next time the function is called with the same arguments.

-Jonathan Lehman

La mémoïzation est conçue pour fonctionner au sein de fonction dite "pure" (dont le résultat est reproductible, basé uniquement sur ses paramètres d'entrée et aucunement sur un état externe). Les fonctions dites "pures" sont relativement bien connues et utilisées, mais je recommande toutefois de correctement documenter votre aspect.

Cela vous paraîtra plus simple d'utiliser la mémoïzation avec les Decorators plutôt que les Proxy lorsque vous en avez la possibilité. En effet, cela demande moins d'abstraction lors de la conception, si on le veut réellement réutilisable par rapport aux différentes cibles auxquelles il pourrait être appliqué.

Github code

C'est un exemple très basique auquel il faudrait ajouter une limitation (absolue et/ou temporelle) à la taille du cache pour éviter les problèmes de mémoire. Surtout dans le cas où votre cible n'est pas "pure", synchroniser l'invalidation de cache peut s'avérer être une tâche compliquée. Mais pour toutes autres raisons vous pourriez préférer invalider votre cache par rapport à la temporalité de la data (une donnée qui est en cache depuis plus de X secondes/minutes devrait être invalidée dans le cache puis recalculée)…

Illustration de Memoïzation, crédit à « Dan Bejar/The iSpot ».
Crédit à "Dan Bejar/The iSpot"

Perfomances & limitations

Preformance : Les Proxy ouvrent de nombreuses possibilités mais celles-ci ont un coup non négligeable en fonction de leur implémentation en comparaison avec l'accès à une propriété d'un Objet (~86x à ~175x plus coûteux).

Tableau de benchmark comparant les performances en matière de « property access »

Crédit à Adrien Maret pour le benchmark

Ce coût ne proscrit pas pour autant l'usage des Proxy, c'est avant tout une question de contexte.

Limitations:

  • Target : Le proxy ne fonctionne pas (sans Handler) sur des cible de types primitif contenant des "internal slots" tel que Map / Set / Date … ou encore les noeuds du DOM. Essayez par exemple d'ajouter une valeur dans une Map à travers un Proxy, vous obtiendrez une erreur.
  • Private : Pour une Class contenant des champs privés, ils n'ont par nature aucune vocation à être exposés et les proxy ne vous permettent pas d'accéder à ces propriétés.

(À moins que vous utilisiez la Reflection dans un Handler en contextualisant le type manipulé)

Conclusions

La programmation orientée aspect est un outil très riche, de par son usage avec les Proxy qui permettent un haut niveau d'abstraction et qui se combinent parfaitement avec la réflexion (Reflect API) mais son ambivalence induit également un effort supplémentaire lors de sa conception, sans oublier son coût sur les performances. Retenez toutefois qu'ils sont applicables à différents types et du fait qu'ils s'appliquent à une instance, ils peuvent être utilisés pour enrichir une librairie externe dont vous ne maitrisez pas le fonctionnement tout comme votre propre code.

Dans le prochain article, nous explorerons des concepts similaires avec les Decorators sur des exemples plus avancés.

Vous devriez explorer ce repo Git vous-même, à la façon d'un "donjon crawler": "Die and retry" (à comprendre par essais / erreurs), c'est une façon d'apprendre et de vous approprier ces concepts afin de les repartager avec vos pairs.