Expérience du Groupe Rambler: comment nous avons commencé à contrôler complètement la formation et le comportement des composants frontaux React


Il existe de nombreuses façons de créer une application Web moderne, mais chaque équipe est inévitablement confrontée au même ensemble de questions: comment répartir les responsabilités avant et arrière, comment minimiser l'apparence de la logique en double - par exemple, lors de la validation des données, quelles bibliothèques utiliser pour travailler, comment garantir la fiabilité et un transport transparent entre l'avant et l'arrière et documenter le code.

À notre avis, nous avons réussi à mettre en œuvre un bon exemple de solution équilibrée en complexité et en profit, que nous utilisons avec succès dans une production basée sur Symfony et React.

Quel type de format d'échange de données pouvons-nous choisir lors de la planification du développement de l'API backend dans un produit Web activement développé qui contient des formulaires dynamiques avec des champs connexes et une logique métier complexe?

  • SWAGGER est une bonne option, il existe une documentation et des outils de débogage pratiques. De plus, il existe des bibliothèques pour Symfony qui automatisent le processus, mais malheureusement le schéma JSON s'est avéré préférable;
  • Schéma JSON - cette option a été proposée par les développeurs frontaux. Ils avaient déjà des bibliothèques leur permettant d'afficher des formulaires. Cela a déterminé notre choix. Le format vous permet de décrire les contrôles primitifs qui peuvent être effectués dans le navigateur. Il existe également une documentation qui décrit toutes les options possibles pour le schéma;
  • GraphQL est assez jeune. Pas tellement de bibliothèques côté serveur et frontend. Au moment où le système a été créé, il n'était pas considéré, à l'avenir - la meilleure façon de créer une API, il y aura un article séparé à ce sujet;
  • SOAP - a un typage strict des données, la capacité de construire de la documentation, mais il n'est pas si facile de se faire des amis avec le front React. SOAP a également un surcoût plus important pour la même quantité utilisable de données transmises;

Tous ces formats ne couvraient pas complètement nos besoins, j'ai donc dû écrire ma propre moissonneuse. Une approche similaire peut fournir des solutions très efficaces pour une application particulière, mais cela comporte des risques:

  • forte probabilité de bugs;
  • souvent pas une documentation à 100% et une couverture de test;
  • faible "modularité" en raison de la fermeture de l'API logicielle. Typiquement, ces solutions sont écrites sous un monolithe et n'impliquent pas de partage entre projets sous forme de composants, car cela nécessite une construction architecturale particulière (lire le coût de développement);
  • niveau élevé d'entrée de nouveaux développeurs. Cela peut prendre beaucoup de temps pour comprendre toute la fraîcheur d'un vélo;

Par conséquent, il est recommandé d'utiliser des bibliothèques communes et stables (comme le pavé gauche de npm) selon la règle - le meilleur code est celui que vous n'avez jamais écrit, mais qui a résolu le problème commercial. Le développement du backend d'application web dans les technologies publicitaires du Groupe Rambler est réalisé chez Symfony. Nous ne nous attarderons pas sur tous les composants utilisés du framework, ci-dessous nous parlerons de la partie principale, sur la base de laquelle le travail est mis en œuvre - forme Symfony . Le frontend utilise React et la bibliothèque correspondante qui étend le schéma JSON pour les spécificités WEB - React JSON Schema Form .

Schéma général de travail:



Cette approche présente de nombreux avantages:

  • la documentation est générée à partir de la boîte, tout comme la possibilité de créer des tests automatiques - toujours selon le schéma;
  • toutes les données transmises sont saisies;
  • Il est possible de transmettre des informations sur les règles de validation de base;
    Intégration rapide de la couche de transport dans React - grâce à la bibliothèque de schémas JSON de Mozilla React;
  • la possibilité de générer des composants Web frontaux à partir de la boîte grâce à l'intégration de bootstrap;
  • regroupement logique, un ensemble de validations et les valeurs possibles des éléments HTML, ainsi que toute la logique métier est contrôlée en un seul point - sur le backend, il n'y a pas de duplication de code;
  • il est aussi simple que possible de porter l'application sur d'autres plates-formes - la partie vue est séparée de celle de contrôle (voir le paragraphe précédent), au lieu de React et du navigateur, l'application Android ou iOS peut restituer et traiter les demandes des utilisateurs;

Examinons plus en détail les composants et le schéma de leur interaction.

Initialement, le schéma JSON vous permet de décrire les vérifications primitives qui peuvent être effectuées sur le client, telles que la liaison ou la saisie de différentes parties du schéma:

const schema = { "title": "A registration form", "description": "A simple form example.", "type": "object", "required": [ "firstName", "lastName" ], "properties": { "firstName": { "type": "string", "title": "First name" }, "lastName": { "type": "string", "title": "Last name" }, "password": { "type": "string", "title": "Password", "minLength": 3 }, "telephone": { "type": "string", "title": "Telephone", "minLength": 10 } } } 

Pour travailler avec des schémas frontaux, il existe la bibliothèque populaire React JSON Schema Form qui fournit les modules complémentaires nécessaires au schéma JSON pour le développement Web:

uiSchema - Le schéma JSON détermine lui-même le type de paramètres à transmettre, mais cela ne suffit pas pour créer une application Web. Par exemple, un champ de type String peut être représenté comme <entrée ... /> ou <textarea ... />, ce sont des nuances importantes, en tenant compte du fait que vous devez dessiner correctement un diagramme pour le client. UiSchema sert également à transmettre ces nuances, par exemple, pour le schéma JSON présenté ci-dessus, vous pouvez spécifier le composant Web visuel de l'uSchema suivant:

 const uiSchema = { "firstName": { "ui:autofocus": true, "ui:emptyValue": "" }, "age": { "ui:widget": "updown", "ui:title": "Age of person", "ui:description": "(earthian year)" }, "bio": { "ui:widget": "textarea" }, "password": { "ui:widget": "password", "ui:help": "Hint: Make it strong!" }, "date": { "ui:widget": "alt-datetime" }, "telephone": { "ui:options": { "inputType": "tel" } } } 

Un exemple de Live Playground peut être vu ici .

Avec cette utilisation du schéma, le rendu frontal sera implémenté par les composants de bootstrap standard sur plusieurs lignes:

 render(( <Form schema={schema} uiSchema={uiSchema} /> ), document.getElementById("app")); 

Si les widgets standard fournis avec bootstrap ne vous conviennent pas et que vous avez besoin de personnalisation - pour certains types de données, vous pouvez spécifier vos propres modèles dans uiSchema, au moment de l'écriture, la chaîne , le nombre , l' entier , le booléen sont pris en charge.

FormData - contient des données de formulaire, par exemple:

 { "firstName": "Chuck", "lastName": "Norris", "age": 78, "bio": "Roundhouse kicking asses since 1940", "password": "noneed" } 

Après le rendu, les widgets seront remplis de ces données - utiles pour éditer des formulaires, ainsi que pour certains mécanismes personnalisés que nous avons ajoutés pour les champs associés et les formulaires complexes, plus à ce sujet ci-dessous.

Vous pouvez en savoir plus sur toutes les nuances de la configuration et de l'utilisation des sections décrites ci-dessus sur la page du plugin .

Hors de la boîte, la bibliothèque vous permet de travailler uniquement avec ces trois sections, mais pour une application Web à part entière, vous devez ajouter un certain nombre de fonctionnalités:

Erreurs - il est également nécessaire de pouvoir transférer les erreurs de divers contrôles backend pour le rendu à l'utilisateur, et les erreurs peuvent être de simples validations - par exemple, l'unicité de la connexion lors de l'enregistrement de l'utilisateur, ou plus complexes basées sur la logique métier - c'est-à-dire nous devons être en mesure de personnaliser leur nombre (erreurs) et les textes des notifications affichées. Pour ce faire, en plus de celles décrites ci-dessus, la section Erreurs a été ajoutée à l'ensemble des données transmises - pour chaque champ, une liste d'erreurs de rendu est définie ici

Action , méthode - pour envoyer les données préparées par l'utilisateur au backend, deux attributs ont été ajoutés contenant l'URL du backend du contrôleur effectuant le traitement et la méthode de livraison HTTP

En conséquence, pour la communication entre l'avant et l'arrière, nous avons obtenu json avec les sections suivantes:

 { "action": "https://...", "method": "POST", "errors":{}, "schema":{}, "formData":{}, "uiSchema":{} } 

Mais comment générer ces données sur le backend? Au moment de la création du système, il n'existait aucune bibliothèque prête à l'emploi permettant de convertir Symfony Form en schéma JSON. Maintenant, ils sont déjà apparus, mais ont leurs inconvénients - par exemple, le LiformBundle interprète le schéma JSON assez librement et modifie le standard à sa discrétion, donc, malheureusement, j'ai dû écrire ma propre implémentation.

Comme base de génération, le formulaire Symfony standard est utilisé . Il suffit d'utiliser le générateur et d'ajouter les champs nécessaires:
Exemple de formulaire
 $builder ->add('title', TextType::class, [ 'label' => 'label.title', 'attr' => [ 'title' => 'title.title', ], ]) ->add('description', TextareaType::class, [ 'label' => 'label.description', 'attr' => [ 'title' => 'title.description', ], ]) ->add('year', ChoiceType::class, [ 'choices' => range(1981, 1990), 'choice_label' => function ($val) { return $val; }, 'label' => 'label.year', 'attr' => [ 'title' => 'title.year', ], ]) ->add('genre', ChoiceType::class, [ 'choices' => [ 'fantasy', 'thriller', 'comedy', ], 'choice_label' => function ($val) { return 'genre.choice.'.$val; }, 'label' => 'label.genre', 'attr' => [ 'title' => 'title.genre', ], ]) ->add('available', CheckboxType::class, [ 'label' => 'label.available', 'attr' => [ 'title' => 'title.available', ], ]); 


En sortie, cette forme est convertie en un circuit de la forme:
Exemple JsonSchema
 { "action": "//localhost/create.json", "method": "POST", "schema": { "properties": { "title": { "maxLength": 255, "minLength": 1, "type": "string", "title": "label.title" }, "description": { "type": "string", "title": "label.description" }, "year": { "enum": [ "1981", "1982", "1983", "1984", "1985", "1986", "1987", "1988", "1989", "1990" ], "enumNames": [ "1981", "1982", "1983", "1984", "1985", "1986", "1987", "1988", "1989", "1990" ], "type": "string", "title": "label.year" }, "genre": { "enum": [ "fantasy", "thriller", "comedy" ], "enumNames": [ "genre.choice.fantasy", "genre.choice.thriller", "genre.choice.comedy" ], "type": "string", "title": "label.genre" }, "available": { "type": "object", "title": "label.available" } }, "required": [ "title", "description", "year", "genre", "available" ], "type": "object" }, "formData": { "title": "", "description": "", "year": "", "genre": "" }, "uiSchema": { "title": { "ui:help": "title.title", "ui:widget": "text" }, "description": { "ui:help": "title.description", "ui:widget": "textarea" }, "year": { "ui:widget": "select", "ui:help": "title.year" }, "genre": { "ui:widget": "select", "ui:help": "title.genre" }, "available": { "ui:help": "title.available", "ui:widget": "checkbox" }, "ui:widget": "mainForm" } } 


Tout le code qui convertit les formulaires en JSON est fermé et n'est utilisé que dans le groupe Rambler, si la communauté s'intéresse à ce sujet, nous le remanierons au format bundle dans notre référentiel github .

Examinons quelques autres aspects sans lesquels il est difficile de créer une application Web moderne:

Validation sur le terrain


Il est défini à l'aide du validateur symfony , qui décrit les règles de validation d'un objet, un exemple de validateur:

 <property name="title"> <constraint name="Length"> <option name="min">1</option> <option name="max">255</option> <option name="minMessage">title.min</option> <option name="maxMessage">title.max</option> </constraint> <constraint name="NotBlank"> <option name="message">title.not_blank</option> </constraint> </property> 


Dans cet exemple, une contrainte de type NotBlank modifie le schéma en ajoutant un champ au tableau des champs obligatoires du schéma, et une contrainte de type Longueur ajoute les attributs schema-> properties-> title-> maxLength et schema-> properties-> title-> minLength, dont la validation doit déjà tenir compte à l'extrémité avant.

Regroupement d'éléments


Dans la vraie vie, les formes simples sont plus susceptibles de faire exception à la règle. Par exemple, un projet peut avoir un formulaire avec un grand nombre de champs et tout donner dans une liste solide n'est pas la meilleure option - nous devons prendre soin des utilisateurs de notre application:

La décision évidente est de diviser le formulaire en groupes logiques d'éléments de contrôle afin qu'il soit plus facile pour l'utilisateur de naviguer et de faire moins d'erreurs:

Comme vous le savez, les capacités du formulaire Symfony prêt à l'emploi sont assez grandes - par exemple, les formulaires peuvent être hérités d'autres formulaires, c'est pratique, mais dans notre cas, il y a des inconvénients. Dans l'implémentation actuelle, l'ordre dans le schéma JSON détermine l'ordre dans lequel l'élément de formulaire est dessiné dans le navigateur; l'héritage peut violer cet ordre. Une option était de regrouper des éléments, par exemple:

Exemple de formulaire imbriqué
 $info = $builder ->create('info',FormType::class,['inherit_data'=>true]) ->add('title', TextType::class, [ 'label' => 'label.title', 'attr' => [ 'title' => 'title.title', ], ]) ->add('description', TextareaType::class, [ 'label' => 'label.description', 'attr' => [ 'title' => 'title.description', ], ]); $builder ->add($info) ->add('year', ChoiceType::class, [ 'choices' => range(1981, 1990), 'choice_label' => function ($val) { return $val; }, 'label' => 'label.year', 'attr' => [ 'title' => 'title.year', ], ]) ->add('genre', ChoiceType::class, [ 'choices' => [ 'fantasy', 'thriller', 'comedy', ], 'choice_label' => function ($val) { return 'genre.choice.'.$val; }, 'label' => 'label.genre', 'attr' => [ 'title' => 'title.genre', ], ]) ->add('available', CheckboxType::class, [ 'label' => 'label.available', 'attr' => [ 'title' => 'title.available', ], ]); 


Ce formulaire sera converti en un circuit du formulaire:

Exemple JsonSchema imbriqué
 "schema": { "properties": { "info": { "properties": { "title": { "type": "string", "title": "label.title" }, "description": { "type": "string", "title": "label.description" } }, "required": [ "title", "description" ], "type": "object" }, "year": { "enum": [ "1981", "1982", "1983", "1984", "1985", "1986", "1987", "1988", "1989", "1990" ], "enumNames": [ "1981", "1982", "1983", "1984", "1985", "1986", "1987", "1988", "1989", "1990" ], "type": "string", "title": "label.year" }, "genre": { "enum": [ "fantasy", "thriller", "comedy" ], "enumNames": [ "genre.choice.fantasy", "genre.choice.thriller", "genre.choice.comedy" ], "type": "string", "title": "label.genre" }, "available": { "type": "object", "title": "label.available" } }, "required": [ "info", "year", "genre", "available" ], "type": "object" } 


et uiSchema correspondant
 "uiSchema": { "info": { "title": { "ui:help": "title.title", "ui:widget": "text" }, "description": { "ui:help": "title.description", "ui:widget": "textarea" }, "ui:widget": "form" }, "year": { "ui:widget": "select", "ui:help": "title.year" }, "genre": { "ui:widget": "select", "ui:help": "title.genre" }, "available": { "ui:help": "title.available", "ui:widget": "checkbox" }, "ui:widget": "group" } 


Cette méthode de regroupement ne nous convenait pas puisque le formulaire pour les données commence à dépendre de la présentation et ne peut pas être utilisé, par exemple, dans l'API ou d'autres formulaires. Il a été décidé d'utiliser des paramètres supplémentaires dans uiSchema sans rompre la norme actuelle du schéma JSON. Par conséquent, des options supplémentaires de ce type ont été ajoutées au formulaire symphonie:

 'fieldset' => [ 'groups' => [ [ 'type' => 'base', 'name' => 'info', 'fields' => ['title', 'description'], 'order' => ['title', 'description'] ] ], 'type' => 'base' ] 

Cela sera converti dans le schéma suivant:

 "ui:group": { "type": "base", "groups": [ { "type": "group", "name": "info", "title": "legend.info", "fields": [ "title", "description" ], "order": [ "title", "description" ] } ], "order": [ "info" ] }, 


Version complète du schéma et uiSchema
 "schema": { "properties": { "title": { "maxLength": 255, "minLength": 1, "type": "string", "title": "label.title" }, "description": { "type": "string", "title": "label.description" }, "year": { "enum": [ "1989", "1990" ], "enumNames": [ "1989", "1990" ], "type": "string", "title": "label.year" }, "genre": { "enum": [ "fantasy", "thriller", "comedy" ], "enumNames": [ "genre.choice.fantasy", "genre.choice.thriller", "genre.choice.comedy" ], "type": "string", "title": "label.genre" }, "available": { "type": "boolean", "title": "label.available" } }, "required": [ "title", "description", "year", "genre", "available" ], "type": "object" } 

 "uiSchema": { "title": { "ui:help": "title.title", "ui:widget": "text" }, "description": { "ui:help": "title.description", "ui:widget": "textarea" }, "year": { "ui:widget": "select", "ui:help": "title.year" }, "genre": { "ui:widget": "select", "ui:help": "title.genre" }, "available": { "ui:help": "title.available", "ui:widget": "checkbox" }, "ui:group": { "type": "base", "groups": [ { "type": "group", "name": "info", "title": "legend.info", "fields": [ "title", "description" ], "order": [ "title", "description" ] } ], "order": [ "info" ] }, "ui:widget": "fieldset" } 


Étant donné que du côté frontal, la bibliothèque React que nous utilisons ne prend pas cela en charge, j'ai dû ajouter cette fonctionnalité nous-mêmes. Avec l'ajout d'un nouvel élément «ui: group», nous avons la possibilité de contrôler pleinement le processus de regroupement des éléments et des formulaires à l'aide de l'API actuelle.

Formes dynamiques


Que faire si un champ dépend d'un autre, par exemple, une liste déroulante de sous-catégories dépend de la catégorie sélectionnée?



Symfony FORM nous donne la possibilité de créer des formulaires dynamiques à l'aide d'événements, mais, malheureusement, au moment de la mise en œuvre, cette fonctionnalité n'était pas prise en charge par JSON Schema, bien que cette fonctionnalité soit apparue dans les versions récentes. Initialement, l'idée était de donner la liste entière à un objet Enum et EnumNames, en fonction de laquelle filtrer les valeurs:

 { "properties": { "genre": { "enum": [ "fantasy", "thriller", "comedy" ], "enumNames": [ "genre.choice.fantasy", "genre.choice.thriller", "genre.choice.comedy" ], "type": "string", "title": "label.genre" }, "sgenre": { "enum": [ "eccentric", "romantic", "grotesque" ], "enumNames": [ { "title": "sgenre.choice.eccentric", "genre": "comedy" }, { "title": "sgenre.choice.romantic", "genre": "comedy" }, { "title": "sgenre.choice.grotesque", "genre": "comedy" } ], "type": "string", "title": "label.genre" } }, "type": "object" } 

Mais avec cette approche, pour chacun de ces éléments, il est nécessaire d'écrire son propre traitement sur le front-end, sans parler du fait que tout devient très compliqué lorsqu'il y a plusieurs de ces objets ou qu'un élément dépend de plusieurs listes. De plus, la quantité de données envoyées au frontend augmente considérablement pour le traitement et le rendu corrects de toutes les dépendances. Par exemple, imaginez un dessin d'une forme composée de trois champs interconnectés - pays, villes, rues. La quantité de données initiales qui doivent être envoyées au backend au front end peut perturber les clients légers et, comme vous vous en souvenez, nous devons prendre soin de nos utilisateurs. Par conséquent, il a été décidé d'implémenter la dynamique en ajoutant des attributs personnalisés:

  • SchemaID - un attribut du schéma, contient l'adresse du contrôleur pour traiter le FormData actuellement entré et mettre à jour le schéma du formulaire actuel, si la logique métier l'exige;
  • Recharger - un attribut indiquant au frontend qu'un changement dans ce champ déclenche une mise à jour du circuit en envoyant des données de formulaire au backend;

La présence d'un SchemaID peut sembler être une duplication - après tout, il y a un attribut d' action , mais ici nous parlons de la division des responsabilités - le contrôleur SchemaID est responsable de la mise à jour intermédiaire du schéma et de UISchema , et le contrôleur d' action effectue l'action commerciale nécessaire - par exemple, crée ou met à jour un objet et ne permet pas qu'une partie du formulaire soit envoyée en tant que produit des contrôles de validation. Avec ces ajouts, le schéma commence à ressembler à ceci:

 { "schemaId": "//localhost/schema.json", "properties": { "genre": { "enum": [ "fantasy", "thriller", "comedy" ], "enumNames": [ "genre.choice.fantasy", "genre.choice.thriller", "genre.choice.comedy" ], "type": "string", "title": "label.genre" }, "sgenre": { "enum": [], "enumNames": [], "type": "string", "title": "label.sgenre" } }, "uiSchema": { "genre": { "ui:options": { "reload": true }, "ui:widget": "select", "ui:help": "title.genre" }, "sgenre": { "ui:widget": "select", "ui:help": "title.sgenre" }, "ui:widget": "mainForm" }, "type": "object" } 

En cas de modification du champ «genre», le frontend envoie le formulaire complet avec les données actuelles au backend, reçoit en réponse un ensemble de sections nécessaires au rendu du formulaire:

 { action: “https://...”, method: "POST", schema:{} formData:{} uiSchema:{} } 

et rendre à la place du formulaire actuel. Ce qui changera exactement après l'envoi est déterminé par le verso, la composition ou le nombre de champs peut changer, etc. - tout changement que la logique métier de l'application nécessitera.

Conclusion


En raison d'une petite extension de l'approche standard, nous avons obtenu un certain nombre de fonctionnalités supplémentaires qui nous permettent de contrôler pleinement la formation et le comportement des composants frontaux React, de créer des circuits dynamiques basés sur la logique métier, d'avoir un point unique pour la formation des règles de validation et la possibilité de créer rapidement et de manière flexible de nouvelles pièces VIEW - par exemple, mobiles ou de bureau applications. Dans ces expériences audacieuses, vous devez vous souvenir de la norme sur la base de laquelle vous travaillez et maintenir une compatibilité descendante avec elle. Au lieu de React, toute autre bibliothèque peut être utilisée sur le frontend, l'essentiel est d'écrire un adaptateur de transport sur le schéma JSON et de connecter une bibliothèque de rendu de formulaire. Bootstrap a bien fonctionné avec React car nous avions de l'expérience avec cette pile technologique, mais l'approche dont nous avons parlé ne vous limite pas dans le choix des technologies. Au lieu de Symfony, il pourrait également y avoir tout autre framework qui vous permet de convertir des formulaires au format de schéma JSON.

Mise à jour: vous pouvez voir notre rapport sur le Meetup # 14 de Symfony Moscou à ce sujet à partir de 1:15:00.

Source: https://habr.com/ru/post/fr417023/


All Articles