Het is heel waarschijnlijk dat je op je werk al heel vaak te maken hebt gehad met overbelaste modellen en een enorm aantal aanroepen in de controllers. Op basis van de kennis in de Rails-omgeving ga ik in dit artikel een eenvoudige oplossing voor dit probleem voorstellen.
Een heel belangrijk aspect van een Rails-applicatie is het minimaliseren van het aantal overbodige afhankelijkheden. Daarom promoot de hele Rails-omgeving sinds kort de service object-benadering en het gebruik van de PORO-methode (Pure Old Ruby Object). Een beschrijving van het gebruik van een dergelijke oplossing vind je hier hier. In dit artikel zullen we het concept stap voor stap oplossen en aanpassen aan het probleem.
Probleem
In een hypothetische toepassing hebben we te maken met een ingewikkeld transactiesysteem. Ons model, dat elke transactie voorstelt, heeft een set scopes die je helpen om gegevens te krijgen. Dit vergemakkelijkt het werk enorm, omdat het op één plek te vinden is. Dit duurt echter niet lang. Met de ontwikkeling van de applicatie worden de project wordt steeds ingewikkelder. Scopes hebben niet langer eenvoudige 'waar'-verwijzingen, we missen gegevens en beginnen relaties te laden. Na een tijdje doet het denken aan een ingewikkeld systeem van spiegels. En, wat nog erger is, we weten niet hoe we een lambda met meerdere regels moeten maken!
Hieronder vind je een al uitgebreid toepassingsmodel. De transacties van het betalingssysteem worden hierin opgeslagen. Zoals je in het onderstaande voorbeeld kunt zien:
Klasse Transactie { where(visible: true) }
scope(:active, lambda do
joins(<<-SQL
LEFT OUTER JOIN bron OP transacties.bron_id = bron.id
AND source.accepted_at IS NOT NULL
SQL
einde)
einde
Het model is één ding, maar als de schaal van ons project toeneemt, beginnen ook de controllers te zwellen. Laten we eens kijken naar het voorbeeld hieronder:
klasse TransactiesController < ApplicationController
def index
@transacties = Transactie.voor_uitgevers
.actief
.zichtbaar
.joins("LEFT JOIN withdrawal_items ON withdrawal_items.transaction_id = transactions.id")
.joins("LEFT JOIN withdrawals ON withdrawals.id = withdrawal_items.withdrawal_id OR
(opnames.id = bron.resource_id AND bron.resource_type = 'Opname')")
.order(:created_at)
.page(params[:pagina])
.per(params[:pagina])
@transacties = apply_filters(@transacties)
einde
einde
Hier zien we veel regels van geketende methoden naast extra joins die we niet op veel plaatsen willen uitvoeren, alleen op deze specifieke plaats. De bijgevoegde gegevens worden later gebruikt door de methode apply_filters, die de juiste gegevensfiltering toevoegt, gebaseerd op de GET-parameters. Natuurlijk kunnen we een aantal van deze verwijzingen overbrengen naar de scope, maar is dit niet het probleem dat we eigenlijk proberen op te lossen?
Oplossing
Omdat we al weten dat we een probleem hebben, moeten we dit oplossen. Op basis van de verwijzing in de inleiding gebruiken we hier de PORO-benadering. In dit exacte geval wordt deze aanpak het query object genoemd, wat een ontwikkeling is van het service objecten concept.
Laten we een nieuwe map aanmaken met de naam "services" in de map apps van ons project. Daar maken we een klasse met de naam TransactiesQuery
.
klasse TransactiesQuery
einde
Als volgende stap moeten we een initializer maken waarin een standaard aanroeppad voor ons object wordt gemaakt
klasse TransactiesQuery
def initialiseer(scope = Transaction.all)
@scope = scope
einde
einde
Hierdoor kunnen we de relatie van het actieve record overbrengen naar onze faciliteit. Nu kunnen we al onze scopes overbrengen naar de klasse, die alleen nodig zijn in de gepresenteerde controller.
klasse TransactiesQuery
def initialiseer(scope = Transaction.all)
@scope = scope
einde
privé
def active(scope)
scope.joins(<<-SQL
LEFT OUTER JOIN bron OP transacties.bron_id = bron.id
AND source.accepted_at IS NOT NULL
SQL
einde
def zichtbaar(scope)
scope.where(visible: true)
einde
def for_publishers(scope)
bereik.select("transacties.*")
.joins(:account)
.where("accounts.owner_type = 'Publisher'")
.joins("JOIN uitgevers OP eigenaar_id = uitgevers.id")
einde
einde
We missen nog steeds het belangrijkste deel, namelijk het verzamelen van gegevens in één string en het openbaar maken van de interface. De methode waarin we alles samenvoegen, wordt een "call" genoemd.
Wat echt belangrijk is, is dat we daar de instantievariabele @scope gebruiken, waar het bereik van onze aanroep zich bevindt.
klasse TransactiesQuery
...
aanroepen
zichtbaar(@scope)
.then(&method(:active))
.then(&methode(:voor_uitgevers))
.order(:created_at)
einde
privé
...
einde
De hele klas presenteert zich als volgt:
klasse TransactiesQuery
def initialiseer(scope = Transaction.all)
@scope = scope
einde
def aanroep
zichtbaar(@scope)
.then(&methode(:active))
.then(&methode(:voor_uitgevers))
.order(:created_at)
einde
privé
def active(scope)
scope.joins(<<-SQL
LEFT OUTER JOIN bron OP transacties.bron_id = bron.id
AND source.accepted_at IS NOT NULL
SQL
einde
def zichtbaar(scope)
scope.where(visible: true)
einde
def for_publishers(scope)
bereik.select("transacties.*")
.joins(:account)
.where("accounts.owner_type = 'Publisher'")
.joins("JOIN uitgevers OP eigenaar_id = uitgevers.id")
einde
einde
Na het opschonen ziet het model er duidelijk lichter uit. Daar richten we ons alleen op de gegevensvalidatie en relaties tussen andere modellen.
Klasse Transactie < ActiveRecord::Base
behoort_tot :rekening
heeft_één :opname_item
einde
De controller heeft onze oplossing al geïmplementeerd; we hebben alle aanvullende query's naar een aparte klasse verplaatst. De aanroepen die we niet in het model hadden, blijven echter een onopgelost probleem. Na enkele wijzigingen ziet onze indexactie er als volgt uit:
klasse TransactiesController < ApplicationController
def index
@transacties = TransactiesQuery.new
.aanroepen
.joins("LEFT JOIN withdrawal_items ON withdrawal_items.accounting_event_id = transactions.id")
.joins("LEFT JOIN withdrawals ON withdrawals.id = withdrawal_items.withdrawal_id OR
(opnames.id = bron.resource_id AND bron.resource_type = 'Opname')")
.order(:created_at)
.page(params[:pagina])
.per(params[:pagina])
@transacties = apply_filters(@transacties)
einde
einde
Oplossing
In het geval van het implementeren van goede praktijken en conventies, kan het een goed idee zijn om alle gelijksoortige voorkomens van een bepaald probleem te vervangen. Daarom verplaatsen we de SQL query van de indexactie naar het aparte queryobject. We noemen dit een TransactiesFilterbareQuery
les. De stijl waarin we de les voorbereiden zal vergelijkbaar zijn met die in TransactiesQuery
. Als onderdeel van de code veranderingen, zal een meer intuïtieve registratie van grote SQL-queries worden gesmokkeld, met behulp van meerregelige tekenreeksen genaamd heredoc. De beschikbare oplossing vind je hieronder:
klasse TransactiesFilterbareQuery
def initialiseer(scope = Transaction.all)
@scope = scope
einde
def aanroep
terugtrekking(@scope).dan(&methode(:terugtrekking_items))
einde
privé
def terugtrekking(scope)
scope.joins(<<-SQL
LEFT JOIN onttrekkingen OP onttrekkingen.id = onttrekking_items.onttrekking_id OR
(intrekkingen.id = bron.resource_id AND bron.resource_type = 'Intrekking')
SQL
einde
def opname_items(scope)
scope.joins(<<-SQL
LEFT JOIN opname_items OP opname_items.accounting_event_id = transacties.id
SQL
einde
einde
Bij wijzigingen in de controller verminderen we de massa regels door het query-object toe te voegen. Het is belangrijk dat we alles scheiden, behalve het deel dat verantwoordelijk is voor de paginering.
klasse TransactiesController < ApplicationController
def index
@transacties = TransactiesQuery.new.call.then do |scope|
TransactionsFilterableQuery.new(scope).call
end.page(params[:page]).per(params[:page])
@transacties = toepassen_filters(@transacties)
einde
einde
Samenvatting
Query object verandert veel in de benadering van het schrijven van SQL-queries. In ActiveRecord is het heel eenvoudig om alle bedrijfs- en databaselogica in het model te plaatsen, omdat alles op één plek staat. Dit werkt heel goed voor kleinere toepassingen. Naarmate het project complexer wordt, plaatsen we de logica op andere plaatsen. Met hetzelfde query-object kun je query's van leden groeperen in een specifiek probleem.
Dankzij dit hebben we een gemakkelijke mogelijkheid om de code later te laten overerven en vanwege duck typing kun je deze oplossingen ook gebruiken in andere modellen. Het nadeel van deze oplossing is een grotere hoeveelheid code en versnippering van verantwoordelijkheid. Maar of we een dergelijke uitdaging willen aangaan of niet, hangt af van onszelf en hoe erg we ons storen aan vette modellen.