Es bastante probable que en el trabajo te hayas encontrado muchas veces con modelos sobrecargados y un gran número de llamadas en los controladores. Basándome en el conocimiento en el entorno Rails, en este artículo, voy a proponer una solución sencilla a este problema.
Un aspecto muy importante de la aplicación Rails es minimizar el número de dependencias redundantes, razón por la cual todo el entorno Rails ha estado promoviendo recientemente el enfoque de objetos de servicio y el uso del método PORO (Pure Old Ruby Object). Encontrarás una descripción de cómo utilizar una solución de este tipo en aquí. En este artículo, resolveremos el concepto paso a paso y lo adaptaremos al problema.
Problema
En una aplicación hipotética, estamos tratando con un complicado sistema de transacciones. Nuestro modelo, que representa cada transacción, tiene un conjunto de ámbitos, que le ayudan a obtener datos. Es una gran facilitación del trabajo, ya que se puede encontrar en un solo lugar. Sin embargo, esto no dura mucho tiempo. Con el desarrollo de la aplicación, el proyecto es cada vez más complicado. Los ámbitos ya no tienen simples referencias "dónde", nos faltan datos y empezamos a cargar relaciones. Después de un tiempo, recuerda a un complicado sistema de espejos. Y, lo que es peor, ¡no sabemos cómo hacer una lambda multilínea!
A continuación, encontrará un modelo de aplicación ya ampliado. En él se almacenan las transacciones del sistema de pago. Como se puede ver en el siguiente ejemplo:
clase Transacción { where(visible: true) }
scope(:active, lambda do
joins(<<-SQL
LEFT OUTER JOIN origen ON transacciones.origen_id = origen.id
AND source.accepted_at IS NOT NULL
SQL
end)
fin
El modelo es una cosa, pero a medida que aumenta la escala de nuestro proyecto, los controladores también empiezan a hincharse. Veamos el ejemplo siguiente:
clase TransactionsController < ApplicationController
def index
@transacciones = Transacción.para_editores
.activo
.visible
.joins("LEFT JOIN withdrawal_items ON withdrawal_items.transaction_id = transactions.id")
.joins("LEFT JOIN retiradas ON retiradas.id = retiradas_items.retiradas_id OR
(retiradas.id = fuente.resource_id AND fuente.resource_type = 'Retirada')")
.order(:fecha_creada)
.page(parámetros[:página])
.per(parámetros[:página])
@transacciones = apply_filters(@transacciones)
fin
fin
Aquí podemos ver muchas líneas de métodos encadenados junto con joins adicionales que no queremos realizar en muchos sitios, sólo en este en particular. Los datos adjuntos son utilizados posteriormente por el método apply_filters, que añade el filtrado de datos apropiado, basado en los parámetros GET. Por supuesto, podemos transferir algunas de estas referencias al ámbito, pero ¿no es este el problema que en realidad estamos tratando de resolver?
Solución
Como ya sabemos que tenemos un problema, debemos resolverlo. Basándonos en la referencia de la introducción, utilizaremos aquí el enfoque PORO. En este caso concreto, este enfoque se denomina objeto de consulta, que es un desarrollo del concepto de objetos de servicio.
Vamos a crear un nuevo directorio llamado "services", situado en el directorio apps de nuestro proyecto. Allí crearemos una clase llamada TransactionsQuery
.
clase TransactionsQuery
fin
Como siguiente paso, necesitamos crear un inicializador donde se creará una ruta de llamada por defecto para nuestro objeto
clase TransactionsQuery
def initialize(ámbito = Transacción.todo)
@ámbito = ámbito
end
end
Gracias a esto, podremos transferir la relación del registro activo a nuestra instalación. Ahora podemos transferir todos nuestros ámbitos a la clase, que sólo se necesitan en el controlador presentado.
clase TransactionsQuery
def initialize(ámbito = Transacción.todo)
@ámbito = ámbito
end
privado
def activo(ámbito)
scope.joins(<<-SQL
LEFT OUTER JOIN origen ON transacciones.origen_id = origen.id
AND source.accepted_at IS NOT NULL
SQL
end
def visible(ámbito)
scope.where(visible: true)
fin
def para_editores(ámbito)
scope.select("transacciones.*")
.joins(:cuenta)
.where("cuentas.tipo_propietario = 'Editor'")
.joins("JOIN publishers ON owner_id = publishers.id")
fin
fin
Aún nos falta la parte más importante, es decir, reunir los datos en una cadena y hacer pública la interfaz. El método donde pegaremos todo se llamará "llamada".
Lo realmente importante es que allí utilizaremos la variable de instancia @scope, donde se encuentra el ámbito de nuestra llamada.
clase TransactionsQuery
...
def call
visible(@ámbito)
.then(&method(:active))
.then(&method(:for_publishers))
.order(:fecha_creación)
end
privado
...
end
Toda la clase se presenta como sigue:
clase TransactionsQuery
def initialize(ámbito = Transacción.todo)
@ámbito = ámbito
end
def call
visible(@ámbito)
.then(&method(:active))
.then(&method(:for_publishers))
.order(:fecha_creación)
end
privado
def activo(ámbito)
scope.joins(<<-SQL
LEFT OUTER JOIN origen ON transacciones.origen_id = origen.id
AND source.accepted_at IS NOT NULL
SQL
end
def visible(ámbito)
scope.where(visible: true)
fin
def para_editores(ámbito)
scope.select("transacciones.*")
.joins(:cuenta)
.where("cuentas.tipo_propietario = 'Editor'")
.joins("JOIN publishers ON owner_id = publishers.id")
fin
fin
Tras nuestra limpieza, el modelo parece definitivamente más ligero. Ahí nos centramos únicamente en la validación de los datos y las relaciones entre otros modelos.
clase Transacción < ActiveRecord::Base
belongs_to :cuenta
has_one :elemento_retirada
end
El controlador ya ha implementado nuestra solución; hemos movido todas las consultas adicionales a una clase separada. Sin embargo, las llamadas, que no teníamos en el modelo, siguen siendo un problema sin resolver. Después de algunos cambios, nuestra acción de índice se ve así:
clase TransactionsController < ApplicationController
def índice
@transacciones = TransactionsQuery.new
.call
.joins("LEFT JOIN withdrawal_items ON withdrawal_items.accounting_event_id = transactions.id")
.joins("LEFT JOIN retiradas ON retiradas.id = retiradas_items.retiradas_id OR
(retiradas.id = fuente.resource_id AND fuente.resource_type = 'Retirada')")
.order(:fecha_creada)
.page(parámetros[:página])
.per(parámetros[:página])
@transacciones = apply_filters(@transacciones)
fin
fin
Solución
En el caso de aplicar buenas prácticas y convenciones, una buena idea puede ser sustituir todas las apariciones similares de un problema determinado. Por lo tanto, trasladaremos la consulta SQL de la acción de índice al objeto de consulta independiente. Lo llamaremos TransactionsFilterableQuery
clase. El estilo con el que prepararemos la clase será similar al presentado en TransactionsQuery
. Como parte del código cambios, se introducirá de contrabando un registro más intuitivo de las consultas SQL de gran tamaño, utilizando cadenas de caracteres de varias líneas denominadas heredoc. La solución disponible la encontrará a continuación:
clase TransactionsFilterableQuery
def initialize(ámbito = Transacción.todo)
@ámbito = ámbito
end
def call
retirada(@ámbito).then(&método(:elementos_retirada))
end
privado
def retirada(ámbito)
scope.joins(<<-SQL
LEFT JOIN retiradas ON retiradas.id = retiradas_items.withdrawal_id OR
(retiradas.id = fuente.resource_id AND fuente.resource_type = 'Retirada')
SQL
fin
def elementos_retirada(ámbito)
scope.joins(<<-SQL
LEFT JOIN artículos_retirada ON artículos_retirada.accounting_event_id = transacciones.id
SQL
end
fin
En caso de cambios en el controlador, reducimos la masa de líneas añadiendo el objeto query. Es importante que separemos todo excepto la parte responsable de la paginación.
clase TransactionsController < ApplicationController
def index
@transactions = TransactionsQuery.new.call.then do |ámbito|
TransactionsFilterableQuery.new(ámbito).call
end.page(params[:page]).per(params[:page])
@transacciones = aplicar_filtros(@transacciones)
end
end
Resumen
El objeto de consulta cambia mucho el enfoque a la hora de escribir consultas SQL. En ActiveRecord, es muy fácil colocar toda la lógica de negocio y de base de datos en el modelo, ya que todo está en un solo lugar. Esto funcionará bastante bien para aplicaciones pequeñas. A medida que aumenta la complejidad del proyecto, colocamos la lógica en otros lugares. El mismo objeto de consulta permite agrupar las consultas de los miembros en un problema específico.
Gracias a esto, tenemos una fácil posibilidad de la herencia posterior del código y debido a duck typing, también puede utilizar estas soluciones en otros modelos. La desventaja de esta solución es una mayor cantidad de código y la fragmentación de la responsabilidad. Sin embargo, si queremos asumir tal reto o no, depende de nosotros y de lo mucho que nos molesten los modelos gordos.