Portage d'API vers TypeScript comme solutionneur de problèmes

Le frontend React du programme Execute a été converti de JavaScript en TypeScript. Mais le backend, écrit en Ruby, n'a pas touché. Cependant, les problèmes associés à ce backend ont amené les développeurs du projet à penser à passer de Ruby à TypeScript. La traduction du matériel que nous publions aujourd'hui est consacrée à l'histoire du portage du backend Execute Program de Ruby à TypeScript et aux problèmes que cela a permis de résoudre.



En utilisant le backend Ruby, nous oublions parfois que certaines propriétés API stockent un tableau de chaînes, pas une simple chaîne. Parfois, nous avons changé un fragment d'API accessible à différents endroits, mais nous avons oublié de mettre à jour le code dans l'un de ces endroits. Ce sont les problèmes habituels d'un langage dynamique qui sont caractéristiques de tout système dont le code n'est pas couvert à 100% par des tests. (Ceci, bien que moins courant, se produit lorsque le code est entièrement couvert par des tests.)

En même temps, ces problèmes ont disparu du frontend depuis que nous l'avons basculé en TypeScript. J'ai plus d'expérience en programmation serveur qu'en client, mais malgré cela, j'ai fait plus d'erreurs en travaillant avec le backend, et non avec le frontend. Tout cela a indiqué que le backend devrait également être converti en TypeScript.

J'ai porté le backend de Ruby vers TypeScript en mars 2019 en environ 2 semaines. Et tout a fonctionné comme il se doit! Nous avons déployé un nouveau code en production le 14 avril 2019. C'était une version bêta disponible pour un nombre limité d'utilisateurs. Après cela, rien ne s'est cassé. Les utilisateurs n'ont même rien remarqué. Voici un graphique illustrant l'état de notre base de code avant et immédiatement après la transition. L'axe des x représente le temps (en jours), l'axe des y représente le nombre de lignes de code.


Traduction du frontend de JavaScript en TypeScript, et traduction du backend de Ruby en TypeScript

Pendant le processus de portage, j'ai écrit une grande quantité de code auxiliaire. Nous avons donc notre propre outil pour effectuer des tests avec un volume de 200 lignes. Nous avons une bibliothèque de 120 lignes pour travailler avec la base de données, ainsi qu'une plus grande bibliothèque de routage pour l'API, reliant le code frontal et principal.

Dans notre propre infrastructure, la chose la plus intéressante à parler est le routeur. Il s'agit d'un wrapper pour Express, garantissant l'application correcte des types utilisés dans le code client et serveur. Cela signifie que lorsqu'une partie de l'API change, l'autre ne compile même pas sans y apporter de modifications pour éliminer les différences.

Voici un gestionnaire d'arrière-plan qui renvoie une liste de billets de blog. C'est l'un des fragments de code similaires les plus simples du système:

router.handleGet(api.blog, async () => {
  return {
    posts: blog.posts,
  }
})

Si nous changeons le nom postsde la clé en blogPosts, nous obtenons une erreur de compilation, dont le texte est affiché ci-dessous (ici, par souci de concision, les informations sur les types d'objets sont omises.)

Property 'posts' is missing in type '...' but required in type '...'.

Chaque point de terminaison est défini par un objet de vue api.someNameHere. Cet objet est partagé par le client et le serveur. Notez que les types ne sont pas directement mentionnés dans la déclaration du gestionnaire. Ils sont tous déduits de l'argument api.blog.

Cette approche fonctionne pour les points de terminaison simples, tels que le point de terminaison décrit ci-dessus blog. Mais il convient aux points finaux plus complexes. Par exemple, une API de noeud final pour travailler avec des leçons a une clé profondément imbriquée de type logique .lesson.steps[index].isInteractive. Grâce à tout cela, il est désormais impossible de faire les erreurs suivantes:

  • Si nous essayons d'accéder isinteractiveau client, ou essayons de renvoyer une telle clé à partir du serveur, le code ne sera pas compilé. Le nom de la clé doit ressembler isInteractive, avec une majuscule I.
  • isInteractive — .
  • isInteractive number, , , .
  • API, , isInteractive — , , , , , , , .

Notez que tout cela inclut la génération de code. Cela se fait en utilisant io-ts et quelques centaines de lignes de code provenant de notre propre routeur.

La déclaration des types d'API nécessite un travail supplémentaire, mais le travail est simple. Lors du changement de la structure de l'API, nous devons savoir comment la structure du code change. Nous apportons des modifications aux déclarations de l'API, puis le compilateur nous indique tous les endroits où le code doit être corrigé.

Il est difficile d'apprécier l'importance de ces mécanismes avant de les utiliser pendant un certain temps. Nous pouvons déplacer de gros objets d'un endroit dans l'API à un autre, renommer les clés, nous pouvons diviser de gros objets en parties, fusionner de petits objets en un seul objet, diviser ou fusionner des points de terminaison entiers. Et nous pouvons faire tout cela sans nous soucier du fait que nous avons oublié d'apporter les modifications appropriées au code client ou serveur.

Voici un vrai exemple. J'ai récemment passé environ 20 heures sur quatre jours de repos pour repenser le programme d'exécution d' API . Toute la structure de l'API a changé. Lors de la comparaison du nouveau code client et serveur avec l'ancien, des dizaines de milliers de changements de ligne ont été enregistrés. J'ai repensé le code de routage côté serveur (comme ci-dessushandleGet) J'ai réécrit toutes les déclarations de type pour l'API, apportant pour beaucoup d'entre elles d'énormes changements structurels. De plus, j'ai réécrit toutes les parties du client dans lesquelles les API modifiées ont été appelées. Au cours de ce travail, 246 des 292 fichiers source ont été modifiés.

Dans la plupart de ces travaux, je ne comptais que sur un système de type. Dans la dernière heure de ce cas de 20 heures, j'ai commencé à exécuter des tests qui, pour la plupart, se sont terminés avec succès. À la toute fin, nous avons effectué une série complète de tests et trouvé trois petites erreurs.

Ce sont toutes des erreurs logiques: des conditions qui ont accidentellement conduit le programme au mauvais endroit. En règle générale, un système de saisie n'aide pas à trouver de telles erreurs. Il a fallu plusieurs minutes pour corriger ces erreurs. Cette API repensée a été déployée il y a quelques mois. Lorsque vous lisez quelque chose surnotre site - c'est cette API qui publie les documents pertinents.

Cela ne signifie pas que le système de type statique garantit que le code sera toujours correct. Ce système ne permet pas de se passer de tests. Mais cela simplifie considérablement le refactoring.

Je vais vous parler de la génération automatique de code. À savoir, nous utilisons des schémas pour générer des définitions de type à partir de la structure de notre base de données. Le système se connecte à la base de données Postgres, analyse les types de colonnes et écrit les définitions de type TypeScript correspondantes dans le fichier normal .d.tsutilisé par l'application.

Un fichier avec les types de schéma de base de données est tenu à jour par notre script de migration à chaque lancement. Pour cette raison, nous n'avons pas à prendre en charge manuellement ces types. Les modèles utilisent des définitions de types de base de données pour garantir que le code d'application accède correctement à tout ce qui est stocké dans la base de données. Il n'y a ni tables manquantes, ni colonnes manquantes, ni entrées nulldans les colonnes non prises en charge null. Nous nous souvenons de traiter correctement nulldans les colonnes supportant null. Et tout cela est vérifié statiquement au moment de la compilation.

Tout cela ensemble crée une chaîne fiable de transfert d'informations de type statique, s'étendant de la base de données aux propriétés des composants React dans le frontend:

  • , ( API) , .
  • API , API, ( ) .
  • React- , API, .

En travaillant sur ce matériel, je ne pouvais pas me souvenir d'un seul cas d'incohérence dans le code associé à l'API qui a réussi la compilation. Nous n'avons pas eu d'échecs de production car le code client et serveur lié à l'API avait des idées différentes sur le formulaire de données. Et tout cela n'est pas le résultat de tests automatisés. Pour l'API elle-même, nous n'écrivons pas de tests.

Cela nous place dans une position extrêmement agréable: nous pouvons nous concentrer sur les parties les plus importantes de l'application. Je passe très peu de temps à faire des conversions de types. Beaucoup moins que ce que j'ai dépensé pour identifier les causes d'erreurs déroutantes qui ont traversé des couches de code écrites en Ruby ou JavaScript, puis ont provoqué d'étranges exceptions quelque part très loin de la source de l'erreur.

Voici à quoi ressemble le projet après avoir traduit le backend en TypeScript. Comme vous pouvez le voir, beaucoup de code a été écrit depuis la transition. Nous avons eu suffisamment de temps pour évaluer les conséquences de la décision.


TypeScript est utilisé sur le frontend et le backend du projet.

Ici, nous n'avons pas soulevé la question habituelle pour de telles publications, qui est d'obtenir les mêmes résultats non pas en tapant, mais en utilisant des tests. De tels résultats ne peuvent être obtenus en utilisant uniquement des tests. Nous, très probablement, en parlerons davantage.

Chers lecteurs! Avez-vous traduit des projets écrits dans d'autres langages en TypeScript?


All Articles