É muito provável que no trabalho já se tenha deparado várias vezes com modelos sobrecarregados e um grande número de chamadas nos controladores. Baseando-me no conhecimento do ambiente Rails, neste artigo, vou propor uma solução simples para este problema.
Um aspeto muito importante do carris é minimizar o número de dependências redundantes, e é por isso que todo o ambiente Rails tem promovido recentemente a abordagem de objeto de serviço e o uso do PORO (Pure Old Rubi Object). Uma descrição de como utilizar esta solução pode ser encontrada em aqui. Neste artigo, vamos resolver o conceito passo a passo e adaptá-lo ao problema.
Problema
Numa aplicação hipotética, estamos a lidar com um sistema de transacções complicado. O nosso modelo, que representa cada transação, tem um conjunto de âmbitos que o ajudam a obter dados. É uma excelente forma de facilitar o trabalho, uma vez que os dados podem ser encontrados num único local. No entanto, isto não dura muito tempo. Com o desenvolvimento da aplicação, o projeto está a tornar-se cada vez mais complicado. Os âmbitos de aplicação já não têm referências "onde" simples, faltam-nos dados e começamos a carregar relações. Ao fim de algum tempo, faz lembrar um complicado sistema de espelhos. E, o que é pior, não sabemos como fazer um lambda com várias linhas!
Abaixo, encontrará um modelo de aplicação já expandido. As transacções do sistema de pagamento são armazenadas. Como pode ver no exemplo abaixo:
class Transação { where(visible: true) }
âmbito(:ativo, lambda do
junta(<<-SQL
LEFT OUTER JOIN source ON transactions.source_id = source.id
AND source.accepted_at IS NOT NULL
SQL
end)
end
O modelo é uma coisa, mas à medida que a escala do nosso projeto aumenta, os controladores também começam a inchar. Vejamos o exemplo abaixo:
classe TransactionsController < ApplicationController
def index
@transactions = Transaction.for_publishers
.ativo
.visível
.joins("LEFT JOIN withdrawal_items ON withdrawal_items.transaction_id = transactions.id")
.joins("LEFT JOIN withdrawals ON withdrawals.id = withdrawal_items.withdrawal_id OR
(withdrawals.id = source.resource_id AND source.resource_type = 'Withdrawal')")
.order(:created_at)
.page(params[:page])
.per(params[:page])
@transacções = apply_filters(@transacções)
fim
fim
Aqui podemos ver muitas linhas de métodos encadeados juntamente com junções adicionais que não queremos efetuar em muitos sítios, apenas neste em particular. Os dados anexados são usados posteriormente pelo método apply_filters, que adiciona a filtragem de dados apropriada, com base nos parâmetros GET. Claro que podemos transferir algumas destas referências para o âmbito, mas não é este o problema que estamos a tentar resolver?
Solução
Uma vez que já sabemos que temos um problema, temos de o resolver. Com base na referência da introdução, utilizaremos aqui a abordagem PORO. Neste caso concreto, esta abordagem é designada por objeto de consulta, que é um desenvolvimento do conceito de objectos de serviço.
Vamos criar um novo diretório chamado "services", localizado no diretório apps do nosso projeto. Lá, criaremos uma classe chamada Consulta de transacções.
classe TransactionsQuery
fim
Como próximo passo, precisamos de criar um inicializador onde será criado um caminho de chamada predefinido para o nosso objeto
classe TransactionsQuery
def initialize(scope = Transaction.all)
@scope = scope
end
fim
Graças a isto, poderemos transferir a relação do registo ativo para a nossa instalação. Agora podemos transferir todos os nossos âmbitos para a classe, que só são necessários no controlador apresentado.
classe TransactionsQuery
def initialize(scope = Transaction.all)
@scope = scope
fim
private
def active(scope)
scope.joins(<<-SQL
LEFT OUTER JOIN source ON transactions.source_id = source.id
AND source.accepted_at IS NOT NULL
SQL
end
def visible(scope)
scope.where(visible: true)
end
def for_publishers(scope)
scope.select("transacções.*")
.junta(:conta)
.where("accounts.owner_type = 'Publisher'")
.junta("JOIN publishers ON owner_id = publishers.id")
fim
fim
Ainda nos falta a parte mais importante, ou seja, reunir os dados numa string e tornar a interface pública. O método em que juntaremos tudo será designado por "call".
O que é realmente importante é que utilizaremos a variável de instância @scope, onde se encontra o âmbito da nossa chamada.
classe TransactionsQuery
...
def call
visível(@escopo)
.then(&method(:active))
.then(&method(:for_publishers))
.order(:created_at)
fim
privado
...
end
Toda a classe se apresenta como o seguinte:
classe TransactionsQuery
def initialize(scope = Transaction.all)
@scope = scope
fim
def call
visible(@scope)
.then(&method(:active))
.then(&method(:for_publishers))
.order(:created_at)
fim
privado
def active(scope)
scope.joins(<<-SQL
LEFT OUTER JOIN source ON transactions.source_id = source.id
AND source.accepted_at IS NOT NULL
SQL
end
def visible(scope)
scope.where(visible: true)
end
def for_publishers(scope)
scope.select("transacções.*")
.junta(:conta)
.where("accounts.owner_type = 'Publisher'")
.junta("JOIN publishers ON owner_id = publishers.id")
fim
fim
Após a nossa limpeza, o modelo parece definitivamente mais leve. Neste caso, concentramo-nos apenas na validação dos dados e nas relações entre outros modelos.
class Transação < ActiveRecord::Base
pertence a :conta
has_one :withdrawal_item
fim
O controlador já implementou a nossa solução; transferimos todas as consultas adicionais para uma classe separada. No entanto, as chamadas, que não tínhamos no modelo, continuam a ser uma questão por resolver. Após algumas alterações, a nossa ação de indexação tem o seguinte aspeto:
classe TransactionsController < ApplicationController
def index
@transactions = TransactionsQuery.new
.call
.joins("LEFT JOIN withdrawal_items ON withdrawal_items.accounting_event_id = transactions.id")
.joins("LEFT JOIN retiradas ON retiradas.id = retiradas_itens.retirada_id OR
(withdrawals.id = source.resource_id AND source.resource_type = 'Withdrawal')")
.order(:created_at)
.page(params[:page])
.per(params[:page])
@transacções = apply_filters(@transacções)
fim
fim
Solução
No caso da implementação de boas práticas e convenções, uma boa ideia pode ser substituir todas as ocorrências semelhantes de um determinado problema. Por isso, vamos mover a consulta SQL da ação de índice para o objeto de consulta separado. Chamaremos a isto um TransactionsFilterableQuery aula. O estilo em que preparamos a aula será semelhante ao apresentado em Consulta de transacções. No âmbito do código será introduzido um registo mais intuitivo das grandes consultas SQL, utilizando cadeias de caracteres de várias linhas denominadas heredoc. A solução disponível encontra-se abaixo:
classe TransactionsFilterableQuery
def initialize(scope = Transaction.all)
@scope = scope
fim
def call
withdrawal(@scope).then(&method(:withdrawal_items))
end
privado
def withdrawal(scope)
scope.joins(<<-SQL
LEFT JOIN retiradas ON retiradas.id = itens_retirada.withdrawal_id OR
(withdrawals.id = source.resource_id AND source.resource_type = 'Withdrawal')
SQL
final
def withdrawal_items(scope)
scope.joins(<<-SQL
LEFT JOIN withdrawal_items ON withdrawal_items.accounting_event_id = transactions.id
SQL
end
end
No caso de alterações no controlador, reduzimos a massa de linhas adicionando o objeto de consulta. É importante que separemos tudo, exceto a parte responsável pela paginação.
classe TransactionsController < ApplicationController
def index
@transactions = TransactionsQuery.new.call.then do |scope|
TransactionsFilterableQuery.new(scope).call
end.page(params[:page]).per(params[:page])
@transactions = apply_filters(@transactions)
fim
fim
Resumo
O objeto de consulta muda muito a abordagem à escrita de consultas SQL. No ActiveRecord, é muito fácil colocar toda a lógica comercial e de base de dados no modelo, uma vez que tudo se encontra num único local. Isto funciona muito bem para aplicações mais pequenas. À medida que a complexidade do projeto aumenta, colocamos a lógica noutros locais. O mesmo objeto de consulta permite-lhe agrupar consultas de membros num problema específico.
Graças a isto, temos uma possibilidade fácil de herança posterior do código e, devido à tipagem de patos, também é possível utilizar estas soluções noutros modelos. A desvantagem desta solução é uma maior quantidade de código e a fragmentação da responsabilidade. No entanto, o facto de querermos ou não aceitar este desafio depende de nós e o quanto somos perturbados por modelos gordos.