$30 off During Our Annual Pro Sale. View Details »

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

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. SEPTEMBER 21 -22, 2023 - LILLE, FRANCE & ONLINE

    View Slide

  2. Retour
    d’expérience
    Comment j’ai perdu mes cheveux en mettant
    à jour API Platform

    View Slide

  3. Retour
    d’expérience
    Comment j’ai perdu mes cheveux en mettant
    à jour API Platform

    View Slide

  4. « Vous aviez à choisir entre les
    performances et les
    fonctionnalités offertes ;
    Vous avez choisi les
    fonctionnalités et vous aurez
    aussi les performances »

    View Slide

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

    View Slide

  6. Timeline projet
    01
    02
    03
    04
    05
    L’avant
    Enseignements
    L’avenir
    Q&A avec Soyuka et Kevin

    View Slide

  7. L’avant
    Retour vers le futur : bienvenue en 2015

    View Slide

  8. 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”

    View Slide

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

    View Slide

  10. 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)

    View Slide

  11. 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)

    View Slide

  12. Timeline
    projet

    View Slide

  13. 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)

    View Slide

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

    View Slide

  15. View Slide

  16. View Slide

  17. View Slide

  18. Blackfire for the win
    blackfire run bin/console
    blackfire run bin/console cache:warmup
    blackfire curl http://monsite.test/api/…

    View Slide

  19. View Slide

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

    View Slide

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

    View Slide

  22. ApiPlatform#3317
    Encore une cardinalité qui
    semble off limit. Ça révèle que
    quelque chose doit-être caché.
    //

    View Slide

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

    View Slide

  24. View Slide

  25. View Slide

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

    View Slide

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

    View Slide

  28. migrer sur
    APIP 4
    Vous êtes sympas, vous pétez pas tout cette fois hein ?

    View Slide

  29. ✔ intégration Varnish / IRI

    View Slide

  30. 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/

    View Slide

  31. « Chouette, l’API X a
    changé, je dois tout
    re-développer »
    Personne, jamais.
    (encore moins un DSI d’un grand groupe)

    View Slide

  32. 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 »

    View Slide

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

    View Slide

  34. View Slide

  35. Installation de la 2.5
    composer require "api-platform/api-pack:^1.2"

    View Slide

  36. On commence simple en YAML
    resources:
    App\Entity\Customer:
    attributes:
    normalization_context:
    groups: ['read']
    denormalization_context:
    groups: ['write']
    collectionOperations: []
    itemOperations:
    get:
    method: 'GET'

    View Slide

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

    View Slide

  38. Et ça grossit fort
    335 lignes juste
    pour la ressource
    Customer
    8000 en tout

    View Slide

  39. 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…)

    View Slide

  40. 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' ]

    View Slide

  41. /**
    * 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
    *
    * @experimental
    */
    final class PurgeHttpCacheListener
    {
    // …
    }

    View Slide

  42. 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 '';
    }
    }
    }

    View Slide

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

    View Slide

  44. Mise en production !
    ✔ peu d’accrocs, merci aux tests fonctionnels
    ✔ pas si pire en performance, grâce à Varnish

    View Slide

  45. View Slide

  46. C’est reparti pour de la perf

    View Slide

  47. release

    View Slide

  48. Mise à jour vers 2.6

    View Slide

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

    View Slide

  50. Mise à jour vers 2.7
    ✔ composer update et c’est tout.
    ✔ mais là on profite de rien

    View Slide

  51. Mais…
    Add your text here
    Oups

    View Slide

  52. Appel pour une grosse
    régression de performance !

    View Slide

  53. View Slide

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

    View Slide

  55. Ressources
    ✔ https:/
    /api-platform.com/docs/extra/releases/
    ✔ https:/
    /api-platform.com/docs/core/upgrade-guide/
    ✔ https:/
    /dunglas.dev/2022/09/api-platform-3-is-released/
    ✔ https:/
    /soyuka.me/api-platform-3.1-whats-new/

    View Slide

  56. View Slide

  57. View Slide

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

    View Slide

  59. 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 !!!!

    View Slide

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

    View Slide

  61. 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';
    }
    }

    View Slide

  62. 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)

    View Slide

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

    View Slide

  64. Le ratio coût / complexité du
    patch est ❤

    View Slide

  65. Enseignements
    Ce qui nous a paru super cool et comment on s'en sert
    / vous pouvez vous servir

    View Slide

  66. ✔ 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.

    View Slide

  67. ✔ 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

    View Slide

  68. ✔ Team super réactive et bienveillante :
    github + slack (#api-platform sur Symfony Devs)
    sont des vrais espaces d’échanges

    View Slide

  69. Enseignements
    Ce que l'on aurait aimé avoir / savoir (RIP mes cheveux)

    View Slide

  70. ✔ 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

    View Slide

  71. ✔ 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.

    View Slide

  72. C’est souvent plus
    simple et moins
    coûteux de se faire
    accompagner par un
    expert.

    View Slide

  73. Enseignements
    Ce qu’il nous reste à faire

    View Slide

  74. ✔ 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/

    View Slide

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

    View Slide

  76. 2.x LTS ?

    View Slide

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

    View Slide

  78. Migration
    APIP 4 ?
    du coup on est d’accord Soyuka :
    on ne pète pas tout cette fois hein ?

    View Slide

  79. (qui aime bien châtie bien)
    Un grand merci à
    tous les
    contributeurs
    ❤❤❤❤❤

    View Slide

  80. Merci !
    Si vous me cherchez :
    @bastnic
    jolicode.com
    Des questions ?

    View Slide