Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Une histoire d'épouvante qui finit bien : récit...

Une histoire d'épouvante qui finit bien : récit d'une migration d'une API custom vers API Platform 2.x puis 3

> Lancer un nouveau projet avec API Platform 3 est une démarche bien documentée et relativement simple. Cependant, *migrer une API déjà bien établie vers API Platform 2 devient déjà plus complexe*. Et lorsque vient le moment de passer à la version 3, cela devient presque comme créer une toute nouvelle API.

> De la transformation du code personnalisé en milliers de lignes de YAML, puis de ces milliers de lignes en attributs, *la migration est semée d’embûches*. Les DataProviders et DataPersisters sont relégués au passé, tandis que les problèmes de performances font leur apparition.

> Quelles ont été les épreuves que nous avons traversées ? Comment avons-nous pu surmonter ces difficultés ? Et surtout, quelles leçons avons-nous tirées de cette expérience pour les partager avec la communauté ? Vous le découvrirez pendant cette conférence !

Bastien Jaillot

September 22, 2023
Tweet

More Decks by Bastien Jaillot

Other Decks in Programming

Transcript

  1. « Vous aviez à choisir entre les performances et les

    fonctionnalités offertes ; Vous avez choisi les fonctionnalités et vous aurez aussi les performances »
  2. Bastien Jaillot ✔ Cofondateur JoliCode ✔ J’aime la performance ✔

    Auteur d’un livre sur la dette technique https:/ /bastien.jaillot.fr/dette-technique-le-livre/ @bastnic jolicode.com
  3. Contexte ✔ Pas le projet le plus fun du monde

    ✔ Les clients B2B de nos APIs ne sont pas joueurs ✔ On n’est pas early adopter par ici ✔ Grosses cardinalités / gros trafic ✔ “La vraie vie”
  4. Soucis de la précédente version ✔ Que du code custom

    (alors que j’aime le code maintenu par d’autres) ✔ Beaucoup de duplication de code ✔ Difficulté de corréler la documentation avec le code ✔ Dette technique de partout donc
  5. Volonté de changement ✔ Nouveaux développeurs qui arrivent ✔ Arrêter

    le code custom, standardiser ✔ Coût d’opportunité : bénéficier de tous les atouts d’une suite logiciel bien équipée et qui évolue (contrairement à notre code custom qui nécessite que l’on travaille pour le faire avancer)
  6. Hypothèses ✔ Laravel ? Drupal ? FosRestBundle ✔ On est

    à l’API Platform conférence, donc ça ne va pas beaucoup vous étonner ✔ Standard de facto dans l’environnement Symfony `composer require api` ✔ Documentation API générée \o/ ✔ Intégration Varnish, Elasticsearch, mongodb (si mais on s’en fout)
  7. Timeline projet ✔ 2019-03 : POC 2.3-2.4 (carnage) ✔ 2019-10

    : dev et release 2.5 (ça paaaaasssse) ✔ 2021-04 : 2.6 (trop facile) ✔ 2022-09 : 2.7 (trop facile) ✔ 2023-03 : compatibilité 3.0 et release 3.1 (c’est là que ça se corse)
  8. POC avec 2.3 et 2.4 ✔ Totalement inutilisable niveau performance

    ✔ A donné lieu à des dizaines de PR, 4 conférences et 2 gros articles ✔ PRs : doctrine/orm, doctrineBundle, Symfony, apip/core ✔ Merci Blackfire, soyuka, dunglas, alanpoulin, teohhanhui et nicolasgrekas
  9. Blackfire for the win blackfire run bin/console blackfire run bin/console

    cache:warmup blackfire curl http://monsite.test/api/…
  10. Symfony#35252 Utilisation de isset sur une valeur qui peut valoir

    null. isset retourne toujours false dans ce cas, forçant le recalcul. Remplacement par array_key_exists et voilà! Ce qui met la puce à l'oreille ? La cardinalité du code “lourd” appelé, je n’avais pas des milliers de noms à convertir, donc le cache n’était pas efficace.
  11. Symfony#35079 Le cache optimisé généré par le warmup n’était pas

    mémoizé, et nécessitait donc d’aller chercher dans le cache backend à chaque appel. Sur un volume conséquent de données, c’est 10% de gagnés gratuitement… Ce qui met la puce à l’oreille ? La cardinalité + le nombre d’appels à apcu_fetch.
  12. ApiPlatform#3317 Encore une cardinalité qui semble off limit. Ça révèle

    que quelque chose doit-être caché. // <du code coûteux qui appelle avec plein de is_a>
  13. Les dépendances du main path : LAZY Certaines classes sur

    le chemin critique méritent votre attention, à minima : - EventListener / Subscriber - Normalizers Car ils dépendent du contenu de la requête, et pas d’un build : ils ont besoin d’ être bootés pour savoir s’ils sont nécessaire pour la req courante. S’ils ont des dépendances, elles seront chargées. Ca ralentit TOUTES les requêtes.
  14. doctrine/annotations#301 Doctrine a son propre watcher de fichiers, basé sur

    filemtime. Mais pour chaque fichier, il va chercher toutes ses interfaces, classes parentes, traits, et récupérer leur date de dernière modification. J’utilise beaucoup de traits et d’interfaces, ce qui fait que c’est ce qui me coûte le plus cher. On ajoute un cache statique.
  15. Symfony#35109 En dev, les fichiers Yaml/XML validator et serialization sont

    lus au runtime. Alors que s’ils sont modifiés, une nouvelle phase de build est lancée. On ajoute une phase de cache warmer. Récupération d’un code vu sur Api Platform dans Symfony. Merci Teoh.
  16. Varnish c’est bon, mangez en ✔ cache de tous les

    endpoints ✔ ajout de tags (l’iri de chaque ressource dans la page) sur la réponse, enregistrés par Varnish ✔ sur la mise à jour d’un contenu, APIP calcule tout seul les IRIs impactés et ban Varnish ✔ cache exact \o/
  17. « Chouette, l’API X a changé, je dois tout re-développer

    » Personne, jamais. (encore moins un DSI d’un grand groupe)
  18. Ce que l’on ne veut pas que le client de

    notre API se dise « Tant qu’à tout refaire, on va réfléchir à trouver une API plus stable »
  19. Prérequis ✔ Tests, tests (fonctionnels) partout ✔ Les tests unitaires

    partent à la poubelle vu que seuls les contrats d’APIs vont rester identiques ✔ Monitoring de la production (APM) ✔ Définir une stratégie de déploiement
  20. On commence simple en YAML resources: App\Entity\Customer: attributes: normalization_context: groups:

    ['read'] denormalization_context: groups: ['write'] collectionOperations: [] itemOperations: get: method: 'GET'
  21. Mais ça se complique très vite ############### WEBSITE ######################### website_get_customer_bookings:

    method: 'GET' path: 'website/users/bookings' output: App\Api\DTO\Output\BookingOutput normalization_context: groups: [ 'website:account:booking:read' ] openapi_context: tags: [ 'Website' ] summary: List account bookings description: > # List account bookings parameters: - in: header name: X-App-Language required: true
  22. Tout en « final » ✔ Déclarer une classe final

    est le rêve pour un mainteneur : en dehors du contrat public de l’interface, on peut tout changer à l’intérieur sans BC break ✔ Pour un utilisateur par contre, on peut ne pas étendre la classe, seulement la décorer ✔ Ou alors faire comme Kevin, et maintenir son propre fork (c’est tentant…)
  23. Métier : décorateur Apps\API\Swagger\SwaggerDecorator: decorates: 'api_platform.swagger.normalizer.documentation' arguments: [ '@Apps\API\Swagger\SwaggerDecorator.inner' ]

    autoconfigure: false Apps\API\ApiPlatform\Core\Bridge\Symfony\Routing\IriConverterDecorator: decorates: 'api_platform.iri_converter' Apps\API\ApiPlatform\Core\Bridge\Doctrine\EventListener\PurgeHttpCacheListener Decorator: decorates: 'api_platform.doctrine.listener.http_cache.purge' arguments: [ '@Apps\API\ApiPlatform\Core\Bridge\Doctrine\EventListener\PurgeHttpCacheListen erDecorator.inner', '@request_stack' ]
  24. /** * Purges responses containing modified entities from the proxy

    cache. * * Override to NOT compute all related data but use a blacklist instead. * * @author Kévin Dunglas <[email protected]> * * @experimental */ final class PurgeHttpCacheListener { // … }
  25. class IriConverterDecorator implements IriConverterInterface { public function __construct(private IriConverterInterface $decorated)

    { } public function getIriFromItem($item, int $referenceType = UrlGeneratorInterface::ABS_PATH): string { try { return $this->decorated->getIriFromItem($item, $referenceType); } catch (\TypeError $e) { throw new InvalidArgumentException(sprintf('Unable to generate an IRI for the item of type "%s"', get_class($item)), $e->getCode(), $e); } catch (\InvalidArgumentException $e) { return ''; } } }
  26. public function getItemFromIri(string $iri, array $context = []) { return

    $this->decorated->getItemFromIri($iri, $context); } public function getIriFromResourceClass(string $resourceClass, int $referenceType = UrlGeneratorInterface::ABS_PATH): string { return $this->decorated->getIriFromResourceClass($resourceClass, $referenceType); } public function getItemIriFromResourceClass(string $resourceClass, array $identifiers, int $referenceType = UrlGeneratorInterface::ABS_PATH): string { return $this->decorated->getItemIriFromResourceClass($resourceClass, $identifiers, $referenceType); } public function getSubresourceIriFromResourceClass(string $resourceClass, array $identifiers, int $referenceType = UrlGeneratorInterface::ABS_PATH): string { return $this->decorated->getSubresourceIriFromResourceClass($resourceClass, $identifiers, $referenceType); }
  27. Mise en production ! ✔ peu d’accrocs, merci aux tests

    fonctionnels ✔ pas si pire en performance, grâce à Varnish
  28. Nouveautés de la 2.6 ✔ Support des PHP Attributes ✔

    Simplification de la configuration avec la possibilité de définir des valeurs par défaut ✔ On verra par la suite que ça aurait été pertinent d’y passer
  29. Mise à jour vers 2.7 ✔ composer update et c’est

    tout. ✔ mais là on profite de rien
  30. Préparation vers la 3.0 ✔ chouette, il y a une

    commande de migration ✔ … mais rien ne se passe ? ✔ … elle n’est faite pour fonctionner que si on utilise les annotations / attributs alors que nous sommes en YAML ✔ il faut vraiment tout réécrire
  31. Yaml => Attributes ✔ 8000 lignes de YAML à convertir

    en attributes ✔ On introduit un super outil : le STAGIAIRE ✔ Il va tout faire à la main (yaml => entités), mais un script est en cours d’écriture, pour toutes les autres migrations à venir sur les autres projets ✔
  32. Provider => Processor ✔ C’est déclaratif et non plus magique

    <3. ✔ Il faut déplacer du code d’un seul fichier vers plein de fichiers (47 Providers => 68 Processors) ✔ C’est long ✔ C’est chronophage ✔ C’est source d’erreurs ✔ La PR est impossible à relire ✔ Les tests unitaires sont inutiles ✔ VIVE LES TESTS FONCTIONNELS !!!!
  33. Des erreurs silencieuses ✔ method HTTP custom "TEST", que le

    script a changé en GET. C'est pas standard, mais ça reste un truc cassé silencieusement ✔ une CollectionOperation en POST (exemple : un gros body de champ de recherche) : plus possible en 3.0. On a créé un attribut custom. Le script a bien créé un POST mais en ItemOperation ✔ Event subscriber sur event APIP avec filtre sur nom de l’opération. Avec l’upgrade, tous les noms ont changé : nos event subscribers sont devenus inactifs
  34. Opération custom namespace AppBundle\Api\Attribute; use ApiPlatform\Metadata\CollectionOperationInterface; use ApiPlatform\Metadata\HttpOperation; #[\Attribute(\Attribute::TARGET_CLASS |

    \Attribute::IS_REPEATABLE)] final class PostSearch extends HttpOperation implements CollectionOperationInterface { public function __construct(...$vars) { parent::__construct(...$vars); $this->method = 'POST'; } }
  35. Mais encore ✔ La migration vers processor et provider est

    chouette sur le principe, mais le fait que ce soit obligatoire en breaking change a demandé un gros refacto. Y avait surement un peu plus sympa à faire ✔ La perte du contexte depuis un provider vers un processor a posé quelques problèmes (on a des headers qui définissent la langue et la devise, mais dans un processor, on paume l'info, du coup il faut réinjecter la stack pour récupérer la requête d'origine en brut)
  36. On a failli casser la prod ✔ Upgrade Symfony 6.0

    => 6.3 ✔ Plein plein de nouveaux deprecated non traités ✔ Des téraoctets de logs générés, espace disque rempli ✔ 20 000€ de factures de log chez datadog en quelques jours
  37. Enseignements Ce qui nous a paru super cool et comment

    on s'en sert / vous pouvez vous servir
  38. ✔ encore plus important pour 2.6 => 3.0 que popo

    => 2.5 : TESTS TESTS TESTS TESTS TESTS ✔ passer d’un code procédural à APIP signifie avoir du code dans plusieurs endroits (dto, attributs, provider, processor). Donc moins facile de se repérer si quelque chose casse.
  39. ✔ Varnish + user context hash + invalidation via les

    IRIs <3 ✔ Utiliser des DTO dès le départ ✔ Privilégier des uids aux ids int ✔ Privilégier un Processor à un Controller custom
  40. ✔ Team super réactive et bienveillante : github + slack

    (#api-platform sur Symfony Devs) sont des vrais espaces d’échanges
  41. ✔ Tout est en final. On comprend le principe, mais

    bonjour la duplication bête de code pour changer un micro comportement. (ou alors faut contribuer) ✔ Mais POURQUOI, POURQUOI être parti sur du yaml ? Quatre fichiers pour une seule entité : sérial (yaml), validator (yaml), api (yaml), entité (php). Plein de fichiers dans l’éditeur, oubli, faut les parser
  42. ✔ Les performances en dev auront été un gros sujet

    ✔ Les routes/dataProviders sont dynamiques et donc pas facile d’y faire référence / être sûr de qui appelle quoi ✔ grrmblllllll les IRIs. Je ne pouvais plus les voir en peinture.
  43. ✔ Mettre à jour les autres projets ✔ Grandir un

    peu et utiliser autre chose que du json à la papa ✔ Faire plaisir à Kévin et utiliser / parler de FrankenPHP ✔ Fixer tous les deprecated que l’on a ignoré :( ✔ Boire des bières en bonne compagnie \o/
  44. Ce que l’on ne veut pas que le CTO se

    dise Tant qu’à tout refaire, on va réfléchir à remplacer APIP par plus stable
  45. Enfance Crise d'adolescence Âge adulte, maturité EOL ? symfony 1

    Drupal 7 APIP 2 Échelle non contractuelle Symfony 2 Drupal 8 APIP 3.0 Symfony 3, 4, 5, 6, 7… Drupal 9, 10… APIP 3.1, 3.2, 4… On casse tout, réécriture complète
  46. Migration APIP 4 ? du coup on est d’accord Soyuka

    : on ne pète pas tout cette fois hein ?
  47. (qui aime bien châtie bien) Un grand merci à tous

    les contributeurs ❤❤❤❤❤