Hay muchas formas de crear una aplicación web moderna, pero cada equipo inevitablemente enfrenta el mismo conjunto de preguntas: cómo distribuir las responsabilidades frontales y posteriores, cómo minimizar la aparición de lógica duplicada, por ejemplo, al validar datos, qué bibliotecas usar para trabajar, cómo garantizar la confiabilidad y transporte transparente entre la parte delantera y trasera y documentar el código.
En nuestra opinión, logramos implementar un buen ejemplo de una solución equilibrada en complejidad y ganancias, que utilizamos con éxito en la producción basada en Symfony y React.
¿Qué tipo de formato de intercambio de datos podemos elegir al planificar el desarrollo de la API de back-end en un producto web desarrollado activamente que contiene formas dinámicas con campos relacionados y una lógica empresarial compleja?
- SWAGGER es una buena opción, hay documentación y herramientas de depuración convenientes. Además, existen bibliotecas para Symfony que automatizan el proceso, pero desafortunadamente el esquema JSON resultó ser preferible;
- Esquema JSON: esta opción fue ofrecida por los desarrolladores front-end. Ya tenían bibliotecas que les permitían mostrar formularios. Esto determinó nuestra elección. El formato le permite describir las comprobaciones primitivas que se pueden hacer en el navegador. También hay documentación que describe todas las opciones posibles para el esquema;
- GraphQL es bastante joven. No hay tantas bibliotecas frontales y del lado del servidor. En el momento en que se creó el sistema, no se consideró, en el futuro: la mejor manera de crear una API, habrá un artículo separado sobre esto;
- SOAP: tiene un tipo de datos estricto, la capacidad de crear documentación, pero no es tan fácil hacer amigos con el frente React. SOAP también tiene una sobrecarga mayor por la misma cantidad utilizable de datos transmitidos;
Todos estos formatos no cubrían completamente nuestras necesidades, así que tuve que escribir mi propia cosechadora. Un enfoque similar puede proporcionar soluciones altamente efectivas para cualquier aplicación en particular, pero esto conlleva riesgos:
- alta probabilidad de errores;
- a menudo no es 100% de documentación y cobertura de prueba;
- baja "modularidad" debido a la cercanía de la API del software. Típicamente, tales soluciones están escritas bajo un monolito y no implican compartir entre proyectos en forma de componentes, ya que esto requiere una construcción arquitectónica especial (lea el costo de desarrollo);
- Alto nivel de entrada de nuevos desarrolladores. Puede llevar mucho tiempo comprender toda la frescura de una bicicleta;
Por lo tanto, es una buena práctica utilizar bibliotecas comunes y estables (como el pad izquierdo de npm) según la regla: el mejor código es el que nunca escribió, pero resolvió el problema comercial. El desarrollo del backend de la aplicación web en las tecnologías publicitarias del Grupo Rambler se lleva a cabo en Symfony. No nos detendremos en todos los componentes utilizados del marco, a continuación hablaremos de la parte principal, sobre la base de la cual se implementa el trabajo: el
formulario de Symfony . La interfaz utiliza React y la biblioteca correspondiente que amplía el esquema JSON para los detalles WEB:
formulario React JSON Schema .
Esquema general de trabajo:

Este enfoque tiene muchas ventajas:
- la documentación se genera de forma inmediata, al igual que la capacidad de crear pruebas automáticas, nuevamente según el esquema;
- todos los datos transmitidos son mecanografiados;
- Es posible transmitir información sobre las reglas básicas de validación;
Integración rápida de la capa de transporte en React, debido a la biblioteca de esquemas JSON de Mozilla React; - la capacidad de generar componentes web front-end desde la caja a través de la integración de bootstrap;
- la agrupación lógica, un conjunto de validaciones y posibles valores de elementos HTML, así como toda la lógica de negocios se controlan en un solo punto: en el backend, no hay duplicación de código;
- es lo más simple posible portar la aplicación a otras plataformas: la parte de la vista está separada de la de control (consulte el párrafo anterior), en lugar de React y el navegador, la aplicación de Android o iOS puede procesar y procesar las solicitudes de los usuarios;
Veamos los componentes y el esquema de su interacción con más detalle.
Inicialmente, el
esquema JSON le permite describir comprobaciones primitivas que se pueden realizar en el cliente, como vincular o escribir varias partes del esquema:
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 } } }
Para trabajar con esquemas de front-end, existe la popular biblioteca
React JSON Schema Form que proporciona los complementos necesarios para
JSON Schema para el desarrollo web:
uiSchema : el esquema JSON mismo determina el tipo de parámetros a pasar, pero esto no es suficiente para crear una aplicación web. Por ejemplo, un campo de tipo Cadena se puede representar como <input ... /> o como <textarea ... />, estos son matices importantes, teniendo en cuenta que debe dibujar correctamente un diagrama para el cliente. UiSchema también sirve para transmitir estos matices, por ejemplo, para el esquema JSON presentado anteriormente, puede especificar el componente web visual del siguiente uiSchema:
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" } } }
El ejemplo de Live Playground
se puede ver aquí .
Con este uso del esquema, los componentes de arranque estándar implementarán el renderizado front-end en varias líneas:
render(( <Form schema={schema} uiSchema={uiSchema} /> ), document.getElementById("app"));
Si los widgets estándar que vienen con bootstrap no le convienen y necesita personalización, para algunos tipos de datos puede especificar sus propias plantillas en uiSchema, al momento de escribir,
se admiten
cadenas ,
números ,
enteros ,
booleanos .
FormData : contiene datos de formulario, por ejemplo:
{ "firstName": "Chuck", "lastName": "Norris", "age": 78, "bio": "Roundhouse kicking asses since 1940", "password": "noneed" }
Después de la representación, los widgets se llenarán con estos datos, útiles para editar formularios, así como para algunos mecanismos personalizados que agregamos para campos relacionados y formularios complejos, más sobre eso a continuación.
Puede leer más sobre todos los matices de la configuración y el uso de las secciones descritas anteriormente en la
página del complemento .
Fuera de la caja, la biblioteca le permite trabajar solo con estas tres secciones, pero para una aplicación web completa, necesita agregar una serie de características:
Errores : también es necesario poder transferir los errores de varias comprobaciones de back-end para presentar al usuario, y los errores pueden ser simples validaciones, por ejemplo, la unicidad del inicio de sesión al registrar al usuario, o errores más complejos basados en la lógica empresarial. debemos poder personalizar su número (de errores) y los textos de las notificaciones mostradas. Para hacer esto, además de los descritos anteriormente, la sección Errores se agregó al conjunto de datos transmitidos; para cada campo, aquí se define una lista de errores para la representación
Acción ,
Método : para enviar datos preparados por el usuario al backend, se agregaron dos atributos que contienen la URL del backend del controlador que realiza el procesamiento y el método de entrega HTTP
Como resultado, para la comunicación entre el frente y la parte posterior, obtuvimos json con las siguientes secciones:
{ "action": "https://...", "method": "POST", "errors":{}, "schema":{}, "formData":{}, "uiSchema":{} }
Pero, ¿cómo generar estos datos en el backend? En el momento de la creación del sistema, no había bibliotecas listas para usar que le permitieran convertir Symfony Form a JSON Schema. Ahora ya han aparecido, pero tienen sus inconvenientes: por ejemplo,
LiformBundle interpreta el esquema JSON con bastante libertad y cambia el estándar a su discreción, por lo que, desafortunadamente, tuve que escribir mi propia implementación.
Como base para la generación,
se utiliza la forma estándar de
Symfony . Es suficiente usar el generador y agregar los campos necesarios:
Ejemplo de formulario $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 la salida, este formulario se convierte en un circuito del formulario:
Ejemplo de 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" } }
Todo el código que convierte formularios a JSON está cerrado y solo se usa en el Grupo Rambler, si la comunidad tiene interés en este tema, lo
refactorizaremos en el formato de paquete en nuestro
repositorio de github .
Veamos algunos aspectos más sin los cuales es difícil construir una aplicación web moderna:
Validación de campo
Se configura con el
validador de Symfony , que describe las reglas para validar un objeto, un ejemplo de un validador:
<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>
En este ejemplo, una restricción de tipo NotBlank modifica el esquema agregando un campo a la matriz de campos obligatorios del esquema, y una restricción de tipo Longitud agrega los atributos esquema-> propiedades-> título-> maxLength y esquema-> propiedades-> título-> minLength, que la validación ya debería tener en cuenta en el frente
Agrupación de artículos
En la vida real, es más probable que las formas simples sean una excepción a la regla. Por ejemplo, un proyecto puede tener un formulario con una gran cantidad de campos y dar todo en una lista sólida no es la mejor opción; debemos cuidar a los usuarios de nuestra aplicación:

La decisión obvia es dividir el formulario en grupos lógicos de elementos de control para que sea más fácil para el usuario navegar y cometer menos errores:

Como sabe, las capacidades del Formulario Symfony listo para usar son bastante grandes; por ejemplo, los formularios se pueden heredar de otros formularios, esto es conveniente, pero en nuestro caso hay desventajas. En la implementación actual, el orden en el esquema JSON determina el orden en que se dibuja el elemento de formulario en el navegador; la herencia puede violar este orden. Una opción era agrupar elementos, por ejemplo:
Ejemplo de forma anidada $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', ], ]);
Este formulario se convertirá en un circuito de la forma:
Ejemplo de JsonSchema anidado "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" }
y uiSchema correspondiente "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" }
Este método de agrupación no nos convenía, ya que el formulario para los datos comienza a depender de la presentación y no puede usarse, por ejemplo, en la API u otros formularios. Se decidió utilizar parámetros adicionales en uiSchema sin romper el estándar actual del esquema JSON. Como resultado, se agregaron opciones adicionales del siguiente tipo al formulario sinfónico:
'fieldset' => [ 'groups' => [ [ 'type' => 'base', 'name' => 'info', 'fields' => ['title', 'description'], 'order' => ['title', 'description'] ] ], 'type' => 'base' ]
Esto se convertirá al siguiente esquema:
"ui:group": { "type": "base", "groups": [ { "type": "group", "name": "info", "title": "legend.info", "fields": [ "title", "description" ], "order": [ "title", "description" ] } ], "order": [ "info" ] },
Versión completa de schema y 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" }
Como en el lado frontal, la
biblioteca React que utilizamos no admite esto de forma inmediata, tuve que agregar esta funcionalidad nosotros mismos. Con la adición de un nuevo elemento "ui: group" tenemos la oportunidad de controlar completamente el proceso de agrupar elementos y formularios utilizando la API actual.
Formas dinámicas
¿Qué sucede si un campo depende de otro, por ejemplo, una lista desplegable de subcategorías depende de la categoría seleccionada?

Symfony FORM nos permite crear
formularios dinámicos usando Eventos, pero, desafortunadamente, JSON Schema no era compatible con esta característica en el momento de la implementación, aunque
esta característica apareció en versiones recientes. Inicialmente, la idea era dar la lista completa a un objeto Enum y EnumNames, en función del cual filtrar valores:
{ "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" }
Pero con este enfoque, para cada elemento es necesario escribir su propio procesamiento en el front-end, sin mencionar el hecho de que todo se vuelve muy complicado cuando hay varios de estos objetos o un elemento depende de varias listas. Además, la cantidad de datos enviados a la interfaz está creciendo significativamente para el procesamiento correcto y la representación de todas las dependencias. Por ejemplo, imagine un dibujo de un formulario que consta de tres campos interconectados: países, ciudades, calles. La cantidad de datos iniciales que deben enviarse al backend al front-end puede molestar a los clientes ligeros y, como recordará, debemos cuidar a nuestros usuarios. Por lo tanto, se decidió implementar la dinámica agregando atributos personalizados:
- SchemaID: un atributo del esquema, contiene la dirección del controlador para procesar el FormData ingresado actual y actualizar el esquema del formulario actual, si así lo requiere la lógica comercial;
- Recargar: un atributo que le dice a la interfaz que un cambio en este campo inicia una actualización del circuito enviando datos del formulario al back-end;
La presencia de un
SchemaID puede parecer una duplicación; después de todo, hay un atributo de
acción , pero aquí estamos hablando de la división de responsabilidad: el controlador de
SchemaID es responsable de la actualización intermedia del
esquema y
UISchema , y el controlador de
acción realiza la acción comercial necesaria; por ejemplo, crea o actualiza un objeto y no permite que parte del formulario se envíe como produce comprobaciones de validación. Con estas adiciones, el esquema comienza a verse así:
{ "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 caso de cambiar el campo "género", el frontend envía el formulario completo con los datos actuales al backend, recibe en respuesta un conjunto de secciones necesarias para representar el formulario:
{ action: “https://...”, method: "POST", schema:{} formData:{} uiSchema:{} }
y render en lugar del formulario actual. Lo que cambiará exactamente después del envío está determinado por la parte posterior, la composición o el número de campos pueden cambiar, etc. - cualquier cambio que requiera la lógica empresarial de la aplicación.
Conclusión
Debido a una pequeña extensión del enfoque estándar, obtuvimos una serie de características adicionales que nos permiten controlar por completo la formación y el comportamiento de los componentes de React front-end, construir circuitos dinámicos basados en la lógica empresarial, tener un único punto para la formación de reglas de validación y la capacidad de crear de manera rápida y flexible nuevas piezas VIEW, por ejemplo, móviles o de escritorio aplicaciones. Al entrar en experimentos tan audaces, debe recordar el estándar en función del cual trabaja y mantener la compatibilidad con él. En lugar de React, se puede usar cualquier otra biblioteca en la interfaz, lo principal es escribir un adaptador de transporte en el esquema JSON y conectar alguna biblioteca de representación de formularios. Bootstrap funcionó bien con React porque teníamos experiencia trabajando con esta pila de tecnología, pero el enfoque del que hablamos no lo limita a la hora de elegir tecnologías. En lugar de Symfony, también podría haber cualquier otro marco que le permita convertir formularios al formato de esquema JSON.
Upd: puedes ver nuestro informe sobre
Symfony Moscow Meetup # 14 sobre esto desde la 1:15:00.