Servidor de cliente sem costura

Qualquer projeto cliente-servidor implica uma separação clara da base de código em 2 partes (às vezes mais) - cliente e servidor. Freqüentemente, cada uma dessas partes é executada na forma de um projeto independente separado, suportado por sua própria equipe de desenvolvedores.

Neste artigo, proponho uma visão crítica da divisão rígida padrão do código em um back-end e um front-end. E considere uma alternativa em que o código não tenha uma linha clara entre o cliente e o servidor.



Contras da abordagem padrão


A principal desvantagem da separação padrão do projeto em duas partes é a erosão da lógica de negócios entre o cliente e o servidor. Editamos os dados no formulário no navegador, verificamos no código do cliente e enviamos para a vila do avô (para o servidor). O servidor já é outro projeto. Lá, você também precisa verificar a correção dos dados recebidos (ou seja, duplicar a funcionalidade do cliente), fazer algumas manipulações adicionais (salvar no banco de dados, enviar e-mail etc.).

Assim, para rastrear todo o caminho das informações, desde o formulário no navegador até o banco de dados no servidor, precisamos nos aprofundar em dois sistemas diversos. Se as funções são divididas em uma equipe e diferentes especialistas são responsáveis ​​pelo back-end e front-end, surgem problemas organizacionais adicionais relacionados à sua sincronização.

Vamos sonhar


Suponha que possamos descrever todo o caminho de dados do formulário no cliente para o banco de dados no servidor em um modelo. No código, pode ser algo como isto (o código não está funcionando):

class MyDataModel { //         verifyData(data) { //   .... return true; } //       client saveData(data) { if(this.verifyData(data)) this.writeDataToDb(data) else consol.log('error') } //  .     server writeDataToDb(data) { if(this.verifyData(data)) this.db.insert(data) else consol.log('error') } } 

Assim, toda a lógica de negócios do modelo está diante de nossos olhos. Manter esse código é mais fácil. Aqui estão as vantagens que a combinação de métodos cliente-servidor em um modelo pode trazer:

  1. A lógica de negócios está concentrada em um único local, não há necessidade de compartilhá-la entre o cliente e o servidor.
  2. Você pode transferir facilmente a funcionalidade de servidor para cliente ou de cliente para servidor durante o desenvolvimento do projeto.
  3. Não há necessidade de duplicar os mesmos métodos para o back-end e o front-end.
  4. Um único conjunto de testes para toda a lógica comercial do projeto.
  5. Substituição de linhas horizontais de delimitação de responsabilidade no projeto por linhas verticais.

Vou revelar o último ponto em mais detalhes. Imagine um aplicativo cliente-servidor comum na forma de um esquema desse tipo:



Vasya é responsável pelo frontend, Fedya - pelo backend. A linha de delineamento de responsabilidade é executada horizontalmente. Esse esquema tem as desvantagens de qualquer estrutura vertical - é difícil de dimensionar e tem baixa tolerância a falhas. Se o projeto estiver em expansão, você terá que fazer uma escolha bastante difícil: quem fortalecer Vasya ou Fedya? Ou, se Fedya ficar doente ou desistir, Vasya não poderá substituí-lo.

A abordagem proposta aqui permite expandir a linha de divisão de responsabilidade em 90 graus e transformar a arquitetura vertical em horizontal.



Essa arquitetura é muito mais fácil de dimensionar e mais tolerante a falhas. Vasya e Fedya tornam-se intercambiáveis.

Em teoria, parece bom, vamos tentar implementar tudo isso na prática, sem perder tudo o que nos dá a existência separada do cliente e do servidor ao longo do caminho.

Declaração do problema


Não precisamos absolutamente de um servidor cliente integrado no produto. Pelo contrário, essa decisão seria extremamente prejudicial de todos os pontos de vista. A tarefa é que, no processo de desenvolvimento, teríamos uma única base de código para modelos de dados para o back-end e o front-end, mas a saída seria um cliente e servidor independentes. Nesse caso, obteremos todas as vantagens da abordagem padrão e obteremos as comodidades listadas acima para o desenvolvimento e suporte do projeto.

Solução


Estou experimentando a integração de cliente e servidor em um arquivo há algum tempo. Até recentemente, o principal problema era que, no JS padrão, a conexão de módulos de terceiros no cliente e no servidor era muito diferente: exigem (...) no node.js, toda a mágica do AJAX no cliente. Tudo mudou com o advento dos módulos ES. Nos navegadores modernos, a "importação" é suportada há muito tempo. O Node.js está um pouco atrasado nesse aspecto e os módulos ES são suportados apenas com o sinalizador "--experimental-modules" ativado. Espera-se que, no futuro previsível, os módulos funcionem imediatamente no node.js. Além disso, é improvável que algo mude muito, porque nos navegadores, essa funcionalidade funciona por padrão há muito tempo. Eu acho que agora você pode usar os módulos ES, não apenas no cliente, mas também no lado do servidor (se você tiver contra-argumentos sobre esse assunto, escreva nos comentários).

O esquema da solução é assim:



O projeto contém três catálogos principais:

protegido - back-end;
público - frontend;
shared - modelos cliente-servidor compartilhados.

Um processo de observador separado monitora arquivos no diretório compartilhado e, com quaisquer alterações, cria versões do arquivo alterado separadamente para o cliente e separadamente para o servidor (nos diretórios protegidos / compartilhados e públicos / compartilhados).

Implementação


Considere o exemplo de um simples messenger em tempo real. Vamos precisar do node.js fresco (eu tenho a versão 11.0.0) e do Redis (instalá-los não é coberto aqui).

Clone um exemplo:

 git clone https://github.com/Kolbaskin/both-example cd ./both-example npm i 

Instale e execute o processo do observador (observador no diagrama):

 npm i both-js -g both ./index.mjs 

Se tudo estiver em ordem, o observador iniciará o servidor da web e começará a monitorar as alterações nos arquivos nos diretórios compartilhados e protegidos. Quando são feitas alterações no compartilhamento, são criadas as versões correspondentes dos modelos de dados para o cliente e o servidor. Após alterações em protegido, o inspetor reiniciará automaticamente o servidor da web.

Você pode ver o desempenho do messenger no navegador clicando no link

http://localhost:3000/index.html?token=123&user=Vasya

(token e usuário são arbitrários). Para emular vários usuários, abra a mesma página em outro navegador, especificando token e usuário diferentes.

Agora um pequeno código.

Servidor Web


protected / server.mjs

 import express from 'express'; import bodyParser from 'body-parser'; // -     //  -  import wsServer from './lib/wsServer.mjs'; const app = express(); //   - wsServer(app); //  mime  mjs express.static.mime.define({'application/javascript': ['js','mjs']}); app.use( bodyParser.json() ); app.use(bodyParser.urlencoded({ extended: true })); //      public app.use(express.static('public')); const server = app.listen(3000, () => { console.log('server is running at %s', server.address().port); }); 

Este é um servidor expresso regular, não há nada de interessante aqui. A extensão mjs é necessária para os módulos ES no node.js. Para maior consistência, usaremos essa extensão para o cliente.

Cliente


public / index.html

 <!DOCTYPE html> <html lang="en"> <head> ... <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script> <script src="/main.mjs" type="module"></script> </head> <body> ... <ul id="users"> <li v-for="user in users"> {{ user.name }} ({{user.id}}) </li> </ul> <div id="messages"> <div> <input type="text" v-model="msg" /> <button v-on:click="sendMessage()"></button> </div> <ul> <li v-for="message in messages">[{{ message.date }}] <strong>{{ message.text }}</strong></li> </ul> </div> </body> </html> 

Por exemplo, eu uso o Vue no cliente, mas isso não muda a essência. Em vez do Vue, pode haver qualquer coisa em que você possa separar o modelo de dados em uma classe separada (nocaute, angular).

public / main.mjs

 //      - import ws from "/lib/Ws.mjs"; //       import Messages from "./shared/messages/model/dataModel.mjs"; //    import Users from "./shared/users/model/dataModel.mjs"; //  - (     ) window.WS = new ws({ token: new URLSearchParams(document.location.search).get("token"), user: new URLSearchParams(document.location.search).get("user") }); //       new Messages({ el: '#messages' }) //       new Users({ el: '#users' }) 

main.mjs é um script que associa modelos de dados às visualizações correspondentes. Para simplificar o código, exemplos de representações da lista de usuários ativos e feeds de mensagens são criados diretamente no index.html

Modelo de dados


shared / messages / model / dataModel.mjs

 //    //          , //    import Base from '@root/lib/Base.mjs'; export default class dataModel extends Base { //!#client constructor(attr) { attr.data = { msg: '', messages: [] } super(attr); //     this.on('newmessage', (data) => { this.messages.push(data) }) } //!#client async sendMessage(e) { //    await this.$sendMessage(this.msg); this.msg = ''; } //!#server async $sendMessage(text) { //   newmessage     this.fireEvent('newmessage', 'all', { date: new Date(), text }) return true; } } 

Esses vários métodos implementam toda a funcionalidade de envio e recebimento de mensagens em tempo real. As diretivas! #Client e! #Server informam ao processo do observador qual método para qual parte (cliente ou servidor) se destina. Se não houver essas diretivas antes de definir um método, esse método estará disponível no cliente e no servidor. As barras de comentários antes da diretiva são opcionais e existem apenas para impedir que o IDE padrão xingue os erros de sintaxe.

A primeira linha do caminho usa a pesquisa & root. Ao gerar as versões do cliente e do servidor, o & root será substituído pelo caminho relativo para os diretórios público e protegido, respectivamente.

Outro ponto importante: no método cliente, você pode chamar apenas o método servidor, cujo nome começa com "$":

 ... //    async sendMessage(e) { await this.$sendMessage(this.msg); <-    this.msg = ''; } ... 

Isso é feito por razões de segurança: do lado de fora, você só pode recorrer a métodos especialmente projetados para isso.

Vejamos as versões dos modelos de dados que o observador gerou para o cliente e o servidor.

Cliente (público / compartilhado / mensagens / modelo / dataModel.mjs)

 import Base from '/lib/Base.mjs'; export default class dataModel extends Base { __getFilePath__() {return "messages/model/dataModel.mjs"} // constructor(attr) { attr.data = { msg: '', messages: [] } super(attr); //     this.on('newmessage', (data) => { this.messages.push(data) }) } // async sendMessage(e) { //    await this.$sendMessage(this.msg); this.msg = ''; } // ... async $sendMessage() {return await this.__runSharedFunction("$sendMessage",arguments)} } 

No lado do cliente, o modelo é um descendente da classe Vue (via Base.mjs). Assim, você pode trabalhar com ele como em um modelo de dados Vue comum. O observador adicionou o método __getFilePath__ à versão do cliente do modelo, que retorna o caminho para o arquivo de classe e substitui o código do método do servidor $ sendMessage por uma construção que, em essência, chamará o método necessário no servidor por meio do mecanismo rpc (__runSharedFunction é definido na classe pai).

Servidor (protegido / compartilhado / mensagens / modelo / dataModel.mjs)

 import Base from '../../lib/Base.mjs'; export default class dataModel extends Base { __getFilePath__() {return "messages/model/dataModel.mjs"} ...       ... // async $sendMessage(text) { //   newmessage     this.fireEvent('newmessage', 'all', { date: new Date(), text }) return true; } } 

Na versão do servidor, o método __getFilePath__ também foi adicionado e os métodos do cliente marcados com a diretiva foram removidos!

Nas duas versões geradas do modelo, todas as linhas excluídas são substituídas por vazias. Isso é feito para que a mensagem de erro no depurador possa encontrar facilmente a linha problemática no código-fonte do modelo.

Interação cliente-servidor


Quando precisamos chamar algum método de servidor no cliente, basta fazê-lo.
Se a chamada estiver dentro do mesmo modelo, tudo será simples:

 ... !#client async sendMessage(e) { await this.$sendMessage(this.msg); this.msg = ''; } !#server async $sendMessage(msg) { // -    } ... 

Você pode "puxar" outro modelo:

 import dataModel from "/shared/messages/model/dataModel.mjs"; var msg = new dataModel(); msg.$sendMessage('blah-blah-blah'); 

Na direção oposta, ou seja, Chamar algum método cliente no servidor não funciona. Tecnicamente, isso é viável, mas do ponto de vista prático não faz sentido, porque o servidor é um, mas há muitos clientes. Se precisarmos iniciar algumas ações no servidor no cliente, usamos o mecanismo de eventos:

 //    ... //!#client constructor(attr) { .... //       "newmessage" this.on('newmessage', (data) => { this.messages.push(data) }) } //!#server async $sendMessage(text) { //     newmessage     this.fireEvent('newmessage', 'all', { date: new Date(), text }) return true; } ... 

O método fireEvent usa três parâmetros: o nome do evento, a quem ele é endereçado e os dados. Você pode definir o destinatário de várias maneiras: palavra-chave “todos” - o evento será enviado a todos os usuários ou na matriz para listar os tokens de sessão dos clientes aos quais o evento é endereçado.

O evento não está vinculado a uma instância específica da classe do modelo de dados e os manipuladores serão acionados em todas as instâncias da classe na qual o fireEvent foi chamado.

Dimensionamento de back-end horizontal


A monoliticidade dos modelos cliente-servidor na implementação proposta, à primeira vista, deve impor restrições significativas à possibilidade de dimensionamento horizontal da parte do servidor. Mas não é assim: tecnicamente, o servidor é independente do cliente. Você pode copiar o diretório "public" em qualquer lugar e fornecer seu conteúdo através de qualquer outro servidor web (nginx, apache, etc.).

O lado do servidor pode ser facilmente expandido iniciando novas instâncias de back-end. Redis e o sistema de filas Kue são usados ​​para interagir com instâncias individuais.

API e clientes diferentes para um back-end


Em projetos reais, diversos clientes de servidor podem usar uma API de servidor - sites, aplicativos móveis, serviços de terceiros. Na solução proposta, tudo isso está disponível sem danças adicionais. Sob o capô de chamar métodos de servidor está o bom e velho rpc. O próprio servidor da web é um aplicativo expresso clássico. É suficiente adicionar um wrapper para rotas com a chamada dos métodos necessários dos mesmos modelos de dados.

Post scriptum


A abordagem proposta no artigo não pretende mudanças revolucionárias nos aplicativos cliente-servidor. Ele apenas adiciona um pouco de conforto ao processo de desenvolvimento, permitindo que você se concentre na lógica de negócios montada em um único local.

Este projeto é experimental, escreva nos comentários se, na sua opinião, vale a pena continuar esse experimento.

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


All Articles