
O backend moderno é diverso, mas ainda obedece a algumas regras não ditas. Muitos de nós que desenvolvemos aplicativos de servidor enfrentamos abordagens geralmente aceitas, como Arquitetura Limpa, SOLID, Ignorância de Persistência, Injeção de Dependência e outros. Muitos dos atributos do desenvolvimento de servidores são tão comuns que não levantam dúvidas e são usados sem pensar. Eles falam muito sobre alguns, mas nunca o usam. O significado do resto é incorretamente interpretado ou distorcido. O artigo fala sobre como construir uma arquitetura de back-end simples e completamente típica, que não só pode seguir os preceitos de famosos teóricos da programação sem nenhum dano, mas também pode melhorá-los até certo ponto.
Dedicado a todos aqueles que não pensam em programação sem beleza e não aceitam a beleza em meio ao absurdo.Modelo de domínio
A modelagem é onde o desenvolvimento de software em um mundo ideal deve começar. Mas nem todos somos perfeitos, falamos muito sobre isso, mas fazemos tudo como sempre. Muitas vezes, o motivo é a imperfeição das ferramentas existentes. E para ser sincero, nossa preguiça e medo de assumir a responsabilidade de fugir das "melhores práticas". Em um mundo imperfeito, o desenvolvimento de software começa, na melhor das hipóteses, com andaimes e, na pior, com otimização de desempenho, nada. No entanto, eu gostaria de descartar os exemplos difíceis de arquitetos "destacados" e especular sobre coisas mais comuns.
Portanto, temos uma tarefa técnica e até um design de interface do usuário (ou não, se a interface do usuário não for fornecida). A próxima etapa é refletir os requisitos no modelo de domínio. Para começar, você pode esboçar um diagrama de objetos de modelo para maior clareza:

Então, como regra, começamos a projetar o modelo nos meios de sua implementação - uma linguagem de programação, um conversor objeto-relacional (ORM), ou em algum tipo de estrutura complexa como ASP.NET MVC ou Ruby on Rails, em outras palavras - comece a escrever o código. Nesse caso, seguimos o caminho da estrutura, que eu acho que não está correta na estrutura de desenvolvimento baseada no modelo, por mais conveniente que possa parecer inicialmente. Aqui você faz uma suposição enorme, que posteriormente nega os benefícios do desenvolvimento baseado em domínio. Como uma opção mais livre, não limitada pelo escopo de qualquer ferramenta, eu sugeriria o uso de apenas ferramentas sintáticas de uma linguagem de programação para construir um modelo de objeto de um domínio. No meu trabalho, uso várias linguagens de programação - C #, JavaScript, Ruby. O destino decretou que os ecossistemas Java e C # são minha inspiração, JS é minha principal receita e Ruby é a linguagem que eu gosto. Portanto, continuarei mostrando exemplos simples em Ruby: Estou convencido de que isso não fará com que os desenvolvedores de outros idiomas entendam problemas. Portanto, mova o modelo para a classe Invoice no Ruby:
class Invoice attr_reader :amount, :date, :created_at, :paid_at def initialize(attrs, payment_service) @created_at = DateTime.now @paid_at = nil @amount = attrs[:amount] @date = attrs[:date] @subscription = attrs[:subscription] @payment_service = payment_service end def pay credit_card = @subscription.customer.credit_card amount = @subscription.plan.price @payment_service.charge(credit_card, amount) @paid_at = DateTime.now end end
I.e. temos uma classe cujo construtor aceita um hash de atributos, dependências de objetos e inicializa seus campos e um método de pagamento que pode alterar o estado do objeto. Tudo é muito simples. Agora, não pensamos em como e onde exibiremos e armazenaremos esse objeto. Apenas existe, podemos criá-lo, mudar seu estado, interagir com outros objetos. Observe que o código não contém artefatos estrangeiros como BaseEntity e outro lixo que não esteja relacionado ao modelo. Isso é muito importante. A propósito, nesta fase, já podemos começar o desenvolvimento por meio de testes (TDD), usando objetos stub em vez de dependências como payment_service:
RSpec.describe Invoice do before :each do @payment_service = double(:payment_service) allow(@payment_service).to receive(:charge) @amount = 100 @credit_card = CreditCard.new({...}) @customer = Customer.new({credit_card: @credit_card, ...}) @subscription = Subscription.new({customer: customer, ...}) @invoice = Invoice.new({amount: @amount, date: DateTime.now, @subscription: subscription}, payment_service) end describe 'pay' do it "charges customer's credit card" do expect(@payment_service).to receive(:charge).with(@credit_card, @amount) @invoice.pay end it 'makes the invoice paid' do expect(@invoice.paid_at).not_to be_nil @invoice.pay end end end
ou até mesmo brincar com o modelo no intérprete (irb para Ruby), que pode muito bem ser, embora não seja muito amigável, a interface do usuário:
irb > invoice = Invoice.new({amount: @amount, date: DateTime.now, @subscription: subscription}, payment_service) irb > invoice.pay
Por que é tão importante evitar "artefatos estrangeiros" nesse estágio? O fato é que o modelo não deve ter nenhuma idéia de como será salvo ou se será salvo. No final, para alguns sistemas, o armazenamento de objetos diretamente na memória pode ser bastante adequado. No momento da modelagem, devemos abstrair completamente desse detalhe. Essa abordagem é chamada de ignorância de persistência. Deve-se enfatizar que não ignoramos os problemas de trabalhar com o repositório, seja um banco de dados relacional ou qualquer outro banco de dados, apenas negligenciamos os detalhes de interagir com ele no estágio de modelagem. Ignorância de persistência significa a eliminação intencional de mecanismos para trabalhar com o estado do modelo, bem como todos os tipos de metadados relacionados a esse processo, a partir do próprio modelo. Exemplos:
Essa abordagem também se deve a razões fundamentais - conformidade com o princípio de responsabilidade exclusiva (princípio de responsabilidade única, S no SOLID). Se o modelo, além de seu componente funcional, descreve os parâmetros de preservação do estado e também lida com a preservação e o carregamento, então obviamente ele tem muitas responsabilidades. A vantagem resultante e não a última da Ignorância de persistência é a capacidade de substituir a ferramenta de armazenamento e até o tipo de armazenamento em si durante o processo de desenvolvimento.
Model-View-Controller
O conceito MVC é tão popular no ambiente de desenvolvimento de vários aplicativos, não apenas de servidores, em diferentes idiomas e plataformas que não pensamos mais no que é e por que é necessário. Eu tenho o maior número de perguntas desta abreviação que se chama “Controller”. Do ponto de vista da organização da estrutura do código, é bom agrupar ações no modelo. Mas o controlador não deve ser uma classe, deve ser um módulo que inclui métodos para acessar o modelo. Não é só isso, deveria haver um lugar para estar? Como desenvolvedor que seguiu o caminho do .NET -> Ruby -> Node.js, fiquei impressionado com os controladores JS (ES5) que implementam na estrutura do express.js. Tendo a capacidade de resolver a tarefa atribuída aos controladores em um estilo mais funcional, os desenvolvedores, como enfeitiçados, escrevem o “Controlador” mágico repetidas vezes. Por que um controlador típico é ruim?
Um controlador típico é um conjunto de métodos que não estão intimamente relacionados entre si, unidos por apenas um - uma certa essência do modelo; e às vezes não apenas um, pior. Cada método individual pode exigir dependências diferentes. Observando um pouco à frente, observo que sou um defensor da prática da inversão de dependência (Inversão de Dependência, D no SOLID). Portanto, preciso inicializar essas dependências em algum lugar externo e passá-las ao construtor do controlador. Por exemplo, ao criar uma nova conta, tenho que enviar notificações ao contador, para o qual preciso de um serviço de notificação e, em outros métodos, não:
class InvoiceController def initialize(invoice_repository, notification_service) @repository = invoice_repository @notification_service = notification_service end def index @repository.get_all end def show(id) @repository.get_by_id(id) end def create(data) @repository.create(data) @notification_service.notify_accountant end end
Aqui, a idéia implora para ser dividida em métodos para trabalhar com o modelo em classes separadas, e por que não?
class ListInvoices def initialize(invoice_repository) @repository = invoice_repository end def call @repository.get_all end end class CreateInvoice def initialize(invoice_repository, notification_service) @repository = invoice_repository @notification_service = notification_service end def call @repository.create(data) @notification_service.notify_accountant end end
Bem, em vez do controlador, agora existe um conjunto de "funções" para acessar o modelo, que, a propósito, também podem ser estruturadas usando diretórios do sistema de arquivos, por exemplo. Agora você precisa "abrir" esses métodos para o exterior, ou seja, organizar algo como um roteador. Como uma pessoa tentada com qualquer tipo de DSL (Linguagem Específica de Domínio), eu preferiria ter uma descrição mais visual das instruções para o aplicativo Web do que truques em Ruby ou outra linguagem de uso geral para definir rotas:
`HTTP GET /invoices -> return all invoices` `HTTP POST /invoices -> create new invoice`
ou pelo menos
`HTTP GET /invoices -> ./invoices/list_invoices` `HTTP POST /invoices -> ./invoices/create`
Isso é muito semelhante a um roteador típico, com a única diferença: ele interage não com os controladores, mas diretamente com as ações no modelo. É claro que, se queremos enviar e receber JSON, devemos cuidar da serialização e desserialização de objetos e muito mais. De uma maneira ou de outra, podemos nos livrar dos controladores, mudar parte de sua responsabilidade para a estrutura de diretórios e o roteador mais avançado.
Injeção de dependência
Eu escrevi deliberadamente um "roteador mais avançado". Para que o roteador possa realmente permitir o fluxo de ações no modelo usando o mecanismo de injeção de dependência no nível declarativo, provavelmente deve ser bastante complexo por dentro. O esquema geral de seu trabalho deve ser algo como isto:

Como você pode ver, meu roteador inteiro está cheio de injeção de dependência usando um contêiner de IoC. Por que isso é necessário? O conceito de "injeção de dependência" remonta à técnica de Inversão de Dependência, projetada para reduzir a conectividade de objetos, movendo a inicialização de dependência para fora do escopo de seu uso. Um exemplo:
class Repository; end
Essa abordagem ajuda muito aqueles que usam o Desenvolvimento Orientado a Testes. No exemplo acima, podemos facilmente colocar um esboço no construtor, em vez do objeto de repositório real correspondente à sua interface, sem "invadir" o modelo de objeto. Este não é o único bônus de DI: quando aplicado corretamente, essa abordagem trará muita magia agradável à sua aplicação, mas primeiro as primeiras coisas. Injeção de Dependência é uma abordagem que permite integrar a técnica de Inversão de Dependência em uma solução arquitetônica completa. A ferramenta de implementação é geralmente um contêiner IoC- (Inversion of Control). Existem toneladas de contêineres IoC realmente legais no mundo Java e .NET, existem dezenas deles. Em JS e Ruby, infelizmente, não há opções adequadas para mim. Em particular, observei o contêiner
seco (contêiner
seco ). Seria assim que minha classe usaria:
class Invoice include Import['payment_service'] def pay credit_card = @subscription.customer.credit_card amount = @subscription.plan.price @payment_service.charge(credit_card, amount) end end
Em vez do uso esbelto do construtor, sobrecarregamos a classe introduzindo nossas próprias dependências, que no estágio inicial nos afastam de um modelo limpo e independente. Bem, alguma coisa, e o modelo não deve saber nada sobre IoC! Isso é verdade para ações como CreateInvoice. Para o caso em questão, nos meus testes já sou obrigado a usar a IoC como algo inalienável. Isso está totalmente errado. Os objetos de aplicativo, na maior parte, não devem saber sobre a existência de IoC. Depois de pesquisar e pensar muito,
esbocei minha IoC , o que não seria tão intrusivo.
Salvando e carregando um modelo
Persistência A ignorância requer um transformador de objeto discreto. Neste artigo, quero dizer trabalhando com um banco de dados relacional, os principais pontos serão verdadeiros para outros tipos de armazenamento. Um conversor objeto-relacional - ORM (Object Relational Mapper) é usado como um conversor semelhante para bancos de dados relacionais. No mundo do .NET e Java, há uma abundância de ferramentas ORM verdadeiramente poderosas. Todos eles têm algumas ou outras pequenas falhas nas quais você pode fechar os olhos. Não há boas soluções em JS e Ruby. Todos eles, de uma maneira ou de outra, vinculam rigidamente o modelo à estrutura e forçam a declaração de elementos estranhos, sem mencionar a inaplicabilidade da Ignorância de Persistência. Como no caso da IoC, pensei em implementar o ORM por conta própria, esse é o estado das coisas no Ruby. Não fiz tudo do zero, mas tomei como base uma simples ORM Sequel, que fornece ferramentas discretas para trabalhar com diferentes DBMSs relacionais. Antes de tudo, eu estava interessado na capacidade de executar consultas na forma de SQL regular, recebendo uma matriz de strings (objetos hash) na saída. Restou apenas implementar seu Mapper e fornecer Ignorância de Persistência. Como já mencionei, eu não gostaria de misturar campos de mapeamento no modelo de domínio, então implementei o Mapper para que ele use um arquivo de configuração separado no formato de tipo:
entity Invoice do field :amount field :date field :start_date field :end_date field :created_at field :updated_at reference :user, type: User reference :subscription, type: Subscription end
A ignorância de persistência é bastante simples de implementar usando um objeto externo do tipo Repositório:
repository.save(user)
Mas iremos além e implementaremos o padrão da Unidade de Trabalho. Para fazer isso, você precisa destacar o conceito de uma sessão. Uma sessão é um objeto que existe ao longo do tempo, durante o qual um conjunto de ações é executado no modelo, que é uma única operação lógica. Ao longo de uma sessão, pode ocorrer o carregamento e a alteração de objetos de modelo. No final da sessão, ocorre a conservação transacional do estado do modelo.
Exemplo de unidade de trabalho:
user = session.load(User, id: 1) plan = session.load(Plan, id: 1) subscription = Subscription.new(user, plan) session.attach(subscription) invoice = Invoice.new(subscription) session.attach(invoice)
Como resultado, 2 instruções serão executadas no banco de dados em vez de 4 e ambas serão executadas na mesma transação.
E, de repente, lembre-se dos repositórios! Aqui há uma sensação de déjà vu, como acontece com os controladores: o repositório não é a mesma entidade rudimentar? Olhando para o futuro, vou responder - sim, é. O principal objetivo do repositório é evitar que a camada da lógica de negócios interaja com o armazenamento real. Por exemplo, no contexto de bancos de dados relacionais, significa escrever consultas SQL diretamente no código da lógica de negócios. Sem dúvida, esta é uma decisão muito razoável. Mas voltando ao momento em que nos livramos do controlador. Do ponto de vista do OOP, o repositório é essencialmente o mesmo controlador - o mesmo conjunto de métodos, não apenas para processar solicitações, mas para trabalhar com o repositório. O repositório também pode ser dividido em ações. Por todas as indicações, essas ações não diferem de forma alguma daquilo que propusemos em vez do controlador. Ou seja, podemos recusar o Repositório e o Controlador em favor de uma única Ação unificada!
class LoadPlan def initialize(session) @session = session end def call sql = <<~SQL SELECT p.* AS ENTITY plan FROM plans p WHERE p.id = 1 SQL @session.fetch(Plan, sql) end end
Você provavelmente notou que eu uso SQL em vez de algum tipo de sintaxe de objeto. Isso é uma questão de gosto. Eu prefiro o SQL porque é uma linguagem de consulta, um tipo de DSL para trabalhar com dados. É claro que é sempre mais fácil escrever Plan.load (id) do que o SQL correspondente, mas isso é para casos triviais. Quando se trata de coisas um pouco mais complexas, o SQL se torna uma ferramenta muito bem-vinda. Às vezes, você amaldiçoa outro ORM ao tentar fazê-lo como SQL puro, que "eu escreveria em alguns minutos". Para quem está em dúvida, sugiro consultar a
documentação do
MongoDB , onde as explicações são fornecidas em um formato semelhante ao SQL, que parece muito engraçado! Portanto, a interface para consultas no
ORM JetSet , que escrevi para meus propósitos, é SQL com impregnações mínimas, como "AS ENTITY". A propósito, na maioria dos casos, não uso objetos de modelo, vários DTOs etc. para exibir dados tabulares - basta escrever uma consulta SQL, obter uma matriz de objetos hash e exibi-la em exibição. De uma forma ou de outra, poucas pessoas conseguem “rolar” o big data projetando tabelas relacionadas em um modelo. Na prática, a projeção plana (visualização) é mais provavelmente usada, e produtos muito maduros chegam ao estágio de otimização quando soluções mais complexas como o CQRS (Segregação de Responsabilidade por Comando e Consulta) começam a ser usadas.
Juntando tudo
Então, o que temos:
- descobrimos como carregar e salvar o modelo, também projetamos uma arquitetura aproximada da ferramenta de entrega na Web do modelo, um determinado roteador;
- chegamos à conclusão de que toda lógica que não faz parte da área de assunto pode ser retirada em Actions (Actions) em vez de controladores e repositórios;
- As ações devem suportar a injeção de dependência
- ferramenta decente Injeção de Dependência implementada;
- O ORM necessário é implementado.
A única coisa que resta é implementar o mesmo "roteador". Como nos livramos de repositórios e controladores em favor de ações, é óbvio que, para uma solicitação, precisaremos executar várias ações. As ações são autônomas e não podemos investir uma na outra. Portanto, como parte da
estrutura Dandy, implementei um roteador que permite criar cadeias de ações. Exemplo de configuração (preste atenção em / planos):
:receive .-> :before -> common/open_db_session GET -> welcome -> :respond <- show_welcome /auth -> :before -> current_user@users/load_current_user /profile -> GET -> plan@plans/load_plan \ -> :respond <- users/show_user_profile PATCH -> users/update_profile /plans -> GET -> current_plan@plans/load_current_plan \ -> plans@plans/load_plans \ -> :respond <- plans/list :catch -> common/handle_errors
"GET / auth / planos" exibe todos os planos de assinatura disponíveis e "destaca" o atual. O seguinte acontece:
- ": before -> common / open_db_session" - abrindo uma sessão do JetSet
- / auth ": before -> current_user @ users / load_current_user" - carrega o usuário atual (por tokens). O resultado é registrado no contêiner de IoC como current_user (current_user @ instrução).
- / auth / plans "current_plan @ planos / load_current_plan" - carrega o plano atual. Para isso, o valor @current_user é obtido do contêiner. O resultado é registrado no contêiner de IoC como current_plan (current_plan @ instrução):
class LoadCurrentPlan def initialize(current_user, session) @current_user = current_user @session = session end def call sql = <<~SQL SELECT p.* AS ENTITY plan FROM plans p INNER JOIN subscriptions s ON s.user_id = :user_id AND s.current = 't' WHERE p.id = :user_id LIMIT 1 SQL @session.execute(sql, user_id: @current_user.id) do |row| map(Plan, row, 'plan') end end end
- "Planos @ planos / load_plans" - carregando uma lista de todos os planos disponíveis. O resultado é registrado no contêiner de IoC como planos (a instrução plans @).
- ": responda <- planos / lista" - o ViewBuilder registrado, por exemplo JBuilder, desenha a vista 'planos / lista' do tipo:
json.plans @plans do |plan| json.id plan.id json.name plan.name json.price plan.price json.active plan.id == @current_plan.id end
Como @plans e @current_plan, os valores registrados nas etapas anteriores são recuperados do contêiner. No construtor Action, em geral, você pode "ordenar" tudo o que precisa, ou melhor, tudo o que está registrado no contêiner. Um leitor atento provavelmente terá uma pergunta, mas há isolamento de tais variáveis no modo "multiusuário"? Sim sim. O fato é que o contêiner Hypo IoC tem a capacidade de definir a vida útil dos objetos e, além disso, vinculá-lo à vida útil de outros objetos. No Dandy, variáveis como @plans, @current_plan, @current_user são vinculadas ao objeto de solicitação e serão destruídas no momento em que a solicitação for concluída. A propósito, a sessão JetSet também está vinculada à solicitação - uma redefinição de seu estado também será executada quando a solicitação Dandy for concluída. I.e. Cada solicitação possui seu próprio contexto isolado. Hypo governa todo o ciclo de vida de Dandy, não importa o quão divertido esse trocadilho tenha sido na tradução literal dos nomes.
Conclusões
Dentro da estrutura da arquitetura fornecida, eu uso o modelo de objeto para descrever a área de assunto; Eu uso práticas apropriadas como injeção de dependência; Eu posso até usar herança. Mas, ao mesmo tempo, todas essas ações são essencialmente funções comuns que podem ser encadeadas em um nível declarativo. Obtivemos o back-end desejado em um estilo funcional, mas com todas as vantagens da abordagem de objeto, quando você não tem problemas com abstrações e testando seu código. Usando o roteador DSL Dandy como exemplo, podemos criar os idiomas necessários para descrever rotas e muito mais.
Conclusão
Como parte deste artigo, conduzi uma espécie de tour pelos aspectos fundamentais da criação de um back-end como eu o vejo. Repito, o artigo é superficial, não abordou muitos tópicos importantes, como, por exemplo, otimização de desempenho. Tentei me concentrar apenas naquelas coisas que podem realmente ser úteis para a comunidade como alimento para o pensamento, e não mais uma vez derramar de vazio para vazio, o que é SOLID, TDD, como é o esquema do MVC e assim por diante. Definições estritas desses e de outros termos usados por um leitor curioso podem ser facilmente encontradas na vasta rede, sem mencionar colegas da loja, para os quais essas abreviações fazem parte do discurso cotidiano. E, finalmente, enfatizo, tente não se concentrar nas ferramentas que eu precisava implementar para resolver os problemas colocados. , . - , .