Experiência do Rambler Group: como começamos a controlar completamente a formação e o comportamento dos componentes React do front-end


Existem várias maneiras de criar um aplicativo Web moderno, mas cada equipe enfrenta inevitavelmente o mesmo conjunto de perguntas: como distribuir responsabilidades de frente e verso, como minimizar a aparência de lógica duplicada - por exemplo, ao validar dados, quais bibliotecas usar para trabalhar, como garantir uma confiabilidade e transporte transparente entre a frente e as costas e documente o código.

Em nossa opinião, conseguimos implementar um bom exemplo de uma solução equilibrada em complexidade e lucro, que usamos com sucesso na produção baseada no Symfony e no React.

Que tipo de formato de troca de dados podemos escolher ao planejar o desenvolvimento da API de back-end em um produto da Web desenvolvido ativamente que contém formulários dinâmicos com campos relacionados e lógica de negócios complexa?

  • O SWAGGER é uma boa opção, há documentação e ferramentas de depuração convenientes. Além disso, existem bibliotecas para o Symfony que automatizam o processo, mas infelizmente o JSON Schema se mostrou preferível;
  • Esquema JSON - essa opção foi oferecida por desenvolvedores de front-end. Eles já tinham bibliotecas que lhes permitiam exibir formulários. Isso determinou a nossa escolha. O formato permite descrever as verificações primitivas que podem ser feitas no navegador. Também há documentação que descreve todas as opções possíveis para o esquema;
  • O GraphQL é bem jovem. Não há muitas bibliotecas do lado do servidor e de front-end. No momento em que o sistema foi criado, não era considerado, no futuro - a melhor maneira de criar uma API, haverá um artigo separado sobre isso;
  • SOAP - possui uma digitação estrita de dados, a capacidade de criar documentação, mas não é tão fácil fazer amizade com a frente do React. O SOAP também possui uma sobrecarga maior para a mesma quantidade utilizável de dados transmitidos;

Como todos esses formatos não atendiam completamente às nossas necessidades, tive que escrever minha própria colheitadeira. Uma abordagem semelhante pode fornecer soluções altamente eficazes para qualquer aplicativo específico, mas isso traz riscos:

  • alta probabilidade de erros;
  • frequentemente não 100% de documentação e cobertura de teste;
  • baixa "modularidade" devido ao fechamento da API do software. Normalmente, essas soluções são escritas sob um monólito e não implicam compartilhamento entre projetos na forma de componentes, pois isso requer uma construção arquitetônica especial (leia o custo do desenvolvimento);
  • alto nível de entrada de novos desenvolvedores. Pode levar muito tempo para entender toda a frescura de uma bicicleta;

Portanto, é uma boa prática usar bibliotecas comuns e estáveis ​​(como o teclado esquerdo do npm) pela regra - o melhor código é o que você nunca escreveu, mas resolveu o problema comercial. O desenvolvimento do back-end de aplicativos da web nas tecnologias de publicidade do Rambler Group é realizado no Symfony. Não vamos nos debruçar sobre todos os componentes usados ​​da estrutura, abaixo falaremos sobre a parte principal, com base na qual o trabalho é implementado - forma Symfony . O front-end usa React e a biblioteca correspondente que estende o JSON Schema para detalhes da WEB - React JSON Schema Form .

Esquema geral de trabalho:



Essa abordagem tem muitas vantagens:

  • a documentação é gerada imediatamente, assim como a capacidade de criar testes automáticos - novamente de acordo com o esquema;
  • todos os dados transmitidos são digitados;
  • É possível transmitir informações sobre as regras básicas de validação;
    Rápida integração da camada de transporte no React - devido à biblioteca Mozilla React JSON Schema;
  • a capacidade de gerar componentes da Web front-end a partir da caixa através da integração de inicialização;
  • agrupamento lógico, um conjunto de validações e possíveis valores de elementos HTML, bem como toda a lógica de negócios é controlada em um único ponto - no back-end, não há duplicação de código;
  • é o mais simples possível portar o aplicativo para outras plataformas - a parte de visualização é separada da de controle (consulte o parágrafo anterior), em vez de React e do navegador, o aplicativo Android ou iOS pode processar e processar solicitações de usuários;

Vejamos os componentes e o esquema de sua interação com mais detalhes.

Inicialmente, o JSON Schema permite descrever verificações primitivas que podem ser feitas no cliente, como vincular ou digitar várias partes do 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 trabalhar com esquemas de front-end, existe a popular biblioteca React JSON Schema Form que fornece os complementos necessários para o esquema JSON para desenvolvimento na Web:

uiSchema - O próprio esquema JSON determina o tipo de parâmetros a serem passados, mas isso não é suficiente para criar um aplicativo da web. Por exemplo, um campo do tipo String pode ser representado como <input ... /> ou como <textarea ... />, essas são nuances importantes, levando em consideração que você precisa desenhar corretamente um diagrama para o cliente. O UiSchema também serve para transmitir essas nuances, por exemplo, para o esquema JSON apresentado acima, você pode especificar o componente da web visual do uiSchema a seguir:

 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" } } } 

O exemplo do Live Playground pode ser visto aqui .

Com esse uso do esquema, a renderização do front-end será implementada pelos componentes padrão do bootstrap em várias linhas:

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

Se os widgets padrão que acompanham o bootstrap não se adequarem a você e você precisar de personalização - para alguns tipos de dados, você poderá especificar seus próprios modelos no uiSchema, no momento da gravação, a string , o número , o número inteiro , o booleano serão suportados.

FormData - contém dados do formulário, por exemplo:

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

Após a renderização, os widgets serão preenchidos com esses dados - úteis para editar formulários, bem como para alguns mecanismos personalizados que adicionamos para campos relacionados e formulários complexos, mais sobre isso abaixo.

Você pode ler mais sobre todas as nuances da configuração e uso das seções descritas acima na página do plugin .

Prontamente, a biblioteca permite que você trabalhe apenas com essas três seções, mas para um aplicativo da Web completo, você precisa adicionar vários recursos:

Erros - também é necessário poder transferir erros de várias verificações de back-end para renderização para o usuário, e os erros podem ser simples ou de validação - por exemplo, a exclusividade do logon ao registrar o usuário ou mais complexos com base na lógica de negócios - ou seja, precisamos poder personalizar o número (erros) e os textos das notificações exibidas. Para fazer isso, além dos descritos acima, a seção Erros foi adicionada ao conjunto de dados transmitidos - para cada campo, uma lista de erros para renderização é definida aqui

Ação , Método - para enviar dados preparados pelo usuário para o back-end, foram adicionados dois atributos contendo o URL do back-end do controlador que executa o processamento e o método de entrega HTTP

Como resultado, para a comunicação entre a frente e as costas, obtivemos json com as seguintes seções:

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

Mas como gerar esses dados no back-end? No momento da criação do sistema, não havia bibliotecas prontas, permitindo converter o Symfony Form para o JSON Schema. Agora eles já apareceram, mas têm suas desvantagens - por exemplo, o LiformBundle interpreta o JSON Schema livremente e altera o padrão a seu critério, então, infelizmente, tive que escrever minha própria implementação.

Como base para a geração, o formulário padrão do Symfony é usado . Basta usar o construtor e adicionar os campos necessários:
Exemplo de formulário
 $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', ], ]); 


Na saída, este formulário é convertido em um circuito do formulário:
Exemplo 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 o código que converte formulários em JSON é fechado e é usado apenas no Rambler Group. Se a comunidade tiver interesse neste tópico, o refatoraremos no formato de pacote configurável em nosso repositório do github .

Vejamos mais alguns aspectos sem os quais é difícil criar um aplicativo Web moderno:

Validação de campo


É definido usando o validador symfony , que descreve as regras para validar um objeto, um exemplo de 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> 


Neste exemplo, uma restrição do tipo NotBlank modifica o esquema, adicionando um campo à matriz de campos obrigatórios do esquema, e uma restrição do tipo Length adiciona os atributos schema-> properties-> title-> maxLength e schema-> properties-> title-> minLength, cuja validação já deve levar em consideração no front-end.

Agrupando itens


Na vida real, formas simples são mais provavelmente uma exceção à regra. Por exemplo, um projeto pode ter um formulário com um grande número de campos e fornecer tudo em uma lista sólida não é a melhor opção - precisamos cuidar dos usuários do nosso aplicativo:

A decisão óbvia é dividir o formulário em grupos lógicos de elementos de controle, para que seja mais fácil para o usuário navegar e cometer menos erros:

Como você sabe, os recursos do Symfony Form prontos para o uso são bastante grandes - por exemplo, os formulários podem ser herdados de outros formulários, isso é conveniente, mas, no nosso caso, há desvantagens. Na implementação atual, a ordem no esquema JSON determina a ordem na qual o elemento do formulário é desenhado no navegador; a herança pode violar essa ordem. Uma opção era agrupar elementos, por exemplo:

Exemplo de formulário aninhado
 $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 formulário será convertido em um circuito do formulário:

Exemplo JsonSchema aninhado
 "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" } 


e uiSchema correspondente
 "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" } 


Esse método de agrupamento não nos convinha, pois o formulário para os dados começa a depender da apresentação e não pode ser usado, por exemplo, na API ou em outros formulários. Foi decidido usar parâmetros adicionais no uiSchema sem quebrar o padrão atual do esquema JSON. Como resultado, opções adicionais do tipo a seguir foram adicionadas ao formulário de sinfonia:

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

Isso será convertido para o seguinte esquema:

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


Versão completa do esquema e do 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 no lado frontal a biblioteca do React que usamos não suporta isso imediatamente, tive que adicionar essa funcionalidade. Com a adição de um novo elemento "ui: group", temos a oportunidade de controlar totalmente o processo de agrupamento de elementos e formulários usando a API atual.

Formas dinâmicas


E se um campo depende de outro, por exemplo, uma lista suspensa de subcategorias depende da categoria selecionada?



O Symfony FORM nos permite criar formulários dinâmicos usando Eventos, mas, infelizmente, o JSON Schema não suportava esse recurso no momento da implementação, embora esse recurso apareça nas versões recentes. Inicialmente, a ideia era fornecer a lista inteira a um objeto Enum e EnumNames, com base nos quais 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" } 

Porém, com essa abordagem, para cada um desses elementos é necessário escrever seu próprio processamento no front-end, sem mencionar o fato de que tudo se torna muito complicado quando existem vários desses objetos ou um elemento depende de várias listas. Além disso, a quantidade de dados enviados para o front-end está crescendo significativamente para o processamento e a renderização corretos de todas as dependências. Por exemplo, imagine um desenho de um formulário que consiste em três campos interconectados - países, cidades, ruas. A quantidade de dados iniciais que precisam ser enviados ao back-end para o front-end pode perturbar os thin clients e, como você se lembra, devemos cuidar de nossos usuários. Portanto, decidiu-se implementar a dinâmica adicionando atributos personalizados:

  • SchemaID - um atributo do esquema, contém o endereço do controlador para processar os FormData inseridos atuais e atualizar o esquema do formulário atual, se exigido pela lógica de negócios;
  • Recarregar - um atributo informando ao front-end que uma alteração nesse campo inicia uma atualização no circuito enviando dados do formulário para o back-end;

A presença de um SchemaID pode parecer uma duplicação - afinal, existe um atributo de ação , mas aqui estamos falando sobre a divisão de responsabilidades - o controlador SchemaID é responsável pela atualização intermediária do esquema e do UISchema , e o controlador de ação executa a ação comercial necessária - por exemplo, cria ou atualiza um objeto e não permite que parte do formulário seja enviada como produz verificações de validação.Com essas adições, o esquema começa a ficar assim:

 { "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" } 

No caso de alterar o campo "gênero", o front-end envia o formulário inteiro com os dados atuais para o back-end, recebe em resposta um conjunto de seções necessárias para renderizar o formulário:

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

e renderize em vez do formulário atual. O que exatamente mudará após o envio é determinado pelo verso, a composição ou o número de campos pode mudar, etc. - qualquer alteração exigida pela lógica comercial do aplicativo.

Conclusão


Devido a uma pequena extensão da abordagem padrão, temos vários recursos adicionais que nos permitem controlar totalmente a formação e o comportamento dos componentes React front-end, criar circuitos dinâmicos com base na lógica de negócios, ter um ponto único para a formação de regras de validação e a capacidade de criar novas peças VIEW com rapidez e flexibilidade - por exemplo, móveis ou desktop aplicações. Entrando em experiências tão ousadas, é preciso lembrar o padrão com base no qual você trabalha e manter a compatibilidade com ele. Em vez de React, qualquer outra biblioteca pode ser usada no frontend, o principal é gravar um adaptador de transporte no JSON Schema e conectar alguma biblioteca de renderização de formulário. O Bootstrap funcionou bem com o React porque tivemos experiência em trabalhar com essa pilha de tecnologias, mas a abordagem de que falamos não o limita na escolha de tecnologias. No lugar do Symfony, também pode haver qualquer outra estrutura que permita converter formulários para o formato JSON Schema.

Upd: você pode ver o nosso relatório sobre o Symfony Moscow Meetup # 14 sobre isso a partir de 1:15:00.

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


All Articles