Ruby on Rails
Best practices voor Rails
Zoeken…
Don't Repeat Yourself (DRY)
Rails volgt het DRY-principe om schone code te behouden.
Het omvat waar mogelijk het hergebruiken van zoveel mogelijk code in plaats van het dupliceren van vergelijkbare code op meerdere plaatsen (bijvoorbeeld het gebruik van gedeeltelijke). Dit vermindert fouten , houdt uw code schoon en handhaaft het principe van code eenmaal schrijven en vervolgens hergebruiken. Het is ook eenvoudiger en efficiënter om code op één plaats bij te werken dan om meerdere delen van dezelfde code bij te werken. Daardoor wordt uw code modulair en robuuster.
Ook Fat Model, Skinny Controller is DRY, omdat je de code in je model schrijft en in de controller alleen de aanroep doet, zoals:
# Post model
scope :unpublished, ->(timestamp = Time.now) { where('published_at IS NULL OR published_at > ?', timestamp) }
# Any controller
def index
....
@unpublished_posts = Post.unpublished
....
end
def others
...
@unpublished_posts = Post.unpublished
...
end
Dit helpt ook bij het leiden tot een API-gestuurde structuur waarbij interne methoden verborgen zijn en wijzigingen worden bereikt door parameters op een API-manier door te geven.
Conventie over configuratie
In Rails merk je dat je kijkt naar controllers, views en modellen voor je database.
Om de noodzaak van zware configuratie te verminderen, implementeert Rails regels om het werken met de applicatie te vergemakkelijken. U kunt uw eigen regels definiëren, maar voor het begin (en voor later) is het een goed idee om vast te houden aan conventies die Rails biedt.
Deze conventies versnellen de ontwikkeling, houden uw code beknopt en leesbaar en bieden u een eenvoudige navigatie in uw toepassing.
Conventies verlagen ook de toetredingsdrempels voor beginners. Er zijn zoveel conventies in Rails dat een beginner niet eens iets hoeft te weten, maar er gewoon van kan profiteren in onwetendheid. Het is mogelijk om geweldige applicaties te maken zonder te weten waarom alles is zoals het is.
Bijvoorbeeld
Als u een databasetabel met de naam orders
met de primaire sleutel- id
, wordt het overeenkomende model order
en wordt de controller die alle logica verwerkt, orders_controller
genoemd. De weergave is opgesplitst in verschillende acties: als de controller een new
en edit
heeft, is er ook een new
en edit
.
Bijvoorbeeld
Om een app te maken, voer je eenvoudig rails new app_name
. Dit genereert ongeveer 70 bestanden en mappen die de infrastructuur en basis vormen voor uw Rails-app.
Het bevat:
- Mappen voor uw modellen (databaselaag), controllers en weergaven
- Mappen om eenheidstests voor uw toepassing te houden
- Mappen om uw webactiva zoals Javascript en CSS-bestanden te bewaren
- Standaardbestanden voor HTTP 400-antwoorden (bestand niet gevonden)
- Vele anderen
Vet model, magere controller
"Fat Model, Skinny Controller" verwijst naar hoe de M- en C-delen van MVC idealiter samenwerken. Elke niet-responsgerelateerde logica moet namelijk in het model worden opgenomen, idealiter in een mooie, testbare methode. Ondertussen is de 'skinny' controller gewoon een mooie interface tussen het beeld en het model.
In de praktijk kan dit een aantal verschillende soorten refactoring vereisen, maar het komt allemaal neer op één idee: door elke logica te verplaatsen die niet gaat over de reactie op het model (in plaats van de controller), heb je niet alleen hergebruik gepromoot waar mogelijk, maar u hebt het ook mogelijk gemaakt om uw code buiten de context van een verzoek te testen.
Laten we eens kijken naar een eenvoudig voorbeeld. Stel dat u deze code hebt:
def index
@published_posts = Post.where('published_at <= ?', Time.now)
@unpublished_posts = Post.where('published_at IS NULL OR published_at > ?', Time.now)
end
U kunt dit als volgt wijzigen:
def index
@published_posts = Post.published
@unpublished_posts = Post.unpublished
end
Vervolgens kunt u de logica naar uw postmodel verplaatsen, waar deze er mogelijk als volgt uitziet:
scope :published, ->(timestamp = Time.now) { where('published_at <= ?', timestamp) }
scope :unpublished, ->(timestamp = Time.now) { where('published_at IS NULL OR published_at > ?', timestamp) }
Pas op voor default_scope
ActiveRecord bevat default_scope
, om een model standaard standaard te reiken.
class Post
default_scope ->{ where(published: true).order(created_at: :desc) }
end
De bovenstaande code dient berichten die al zijn gepubliceerd wanneer u een query op het model uitvoert.
Post.all # will only list published posts
Dat bereik, hoewel onschadelijk ogend, heeft meerdere verborgen neveneffecten die je misschien niet wilt.
default_scope
en order
Aangezien u een verklaarde order
in de default_scope
, roepen order
op Post
zal worden toegevoegd als extra bestellingen in plaats van overschrijven van de standaard.
Post.order(updated_at: :desc)
SELECT "posts".* FROM "posts" WHERE "posts"."published" = 't' ORDER BY "posts"."created_at" DESC, "posts"."updated_at" DESC
Dit is waarschijnlijk niet het gedrag dat je wilde; u kunt dit overschrijven door de order
uit te sluiten van het bereik
Post.except(:order).order(updated_at: :desc)
SELECT "posts".* FROM "posts" WHERE "posts"."published" = 't' ORDER BY "posts"."updated_at" DESC
default_scope
en modelinitialisatie
Zoals bij elke andere ActiveRecord::Relation
, zal default_scope
de standaardstatus wijzigen van de geïnitialiseerde modellen.
In het bovenstaande voorbeeld heeft Post
where(published: true)
standaard ingesteld, en dus hebben nieuwe modellen van Post
het ook ingesteld.
Post.new # => <Post published: true>
unscoped
default_scope
kan nominaal worden gewist door eerst unscoped
aan te roepen, maar dit heeft ook bijwerkingen. Neem bijvoorbeeld een soa-model:
class Post < Document
default_scope ->{ where(published: true).order(created_at: :desc) }
end
Standaard queries tegen Post
zal worden binnen het bereik van type
kolommen met 'Post'
. Maar unscoped
zal dit samen met je eigen default_scope
wissen, dus als je unscoped
gebruikt, unscoped
je dit ook onthouden.
Post.unscoped.where(type: 'Post').order(updated_at: :desc)
unscoped
en Model Associaties
Overweeg een relatie tussen Post
en User
class Post < ApplicationRecord
belongs_to :user
default_scope ->{ where(published: true).order(created_at: :desc) }
end
class User < ApplicationRecord
has_many :posts
end
Door een individuele User
, kunt u de bijbehorende berichten zien:
user = User.find(1)
user.posts
SELECT "posts".* FROM "posts" WHERE "posts"."published" = 't' AND "posts"."user_id" = ? ORDER BY "posts"."created_at" DESC [["user_id", 1]]
Maar je wilt het wissen default_scope
uit de posts
relatie, zodat u gebruik unscoped
user.posts.unscoped
SELECT "posts".* FROM "posts"
Hiermee wordt zowel de user_id
voorwaarde als de default_scope
.
Een voorbeeld use-case voor default_scope
Ondanks dit alles zijn er situaties waarin het gebruik van default_scope
gerechtvaardigd is.
Overweeg een multi-tenant systeem waarbij meerdere subdomeinen worden bediend vanuit dezelfde applicatie maar met geïsoleerde gegevens. Een manier om dit isolement te bereiken is via default_scope
. De nadelen worden in andere gevallen hier kanten.
class ApplicationRecord < ActiveRecord::Base
def self.inherited(subclass)
super
return unless subclass.superclass == self
return unless subclass.column_names.include? 'tenant_id'
subclass.class_eval do
default_scope ->{ where(tenant_id: Tenant.current_id) }
end
end
end
Het enige dat u hoeft te doen, is Tenant.current_id
ergens in het begin van het verzoek instellen en elke tabel met tenant_id
wordt automatisch zonder extra code bereikt. Het instantiëren van records zal automatisch de tenant-ID erven waaronder ze zijn gemaakt.
Het belangrijkste van deze use-case is dat de scope eenmaal per aanvraag wordt ingesteld en niet verandert. De enige gevallen die u hier niet nodig unscoped
, zijn speciale gevallen zoals achtergrondwerkers die buiten een aanvraagbereik vallen.
Je hebt het niet nodig (YAGNI)
Als je 'YAGNI' (je hebt het niet nodig) kunt zeggen over een functie, kun je deze beter niet implementeren. Er kan veel ontwikkeltijd worden bespaard door te focussen op eenvoud. Het implementeren van dergelijke functies kan sowieso tot problemen leiden:
Problemen
Overengineering
Als een product gecompliceerder is dan het moet zijn, is het meer dan ontwikkeld. Gewoonlijk zullen deze "nog niet gebruikte" functies nooit worden gebruikt op de beoogde manier waarop ze zijn geschreven en moeten ze opnieuw worden bewerkt als ze ooit worden gebruikt. Voortijdige optimalisaties, met name prestatie-optimalisaties, leiden vaak tot ontwerpbeslissingen die in de toekomst onjuist blijken te zijn.
Code bloat
Code Bloat betekent onnodig gecompliceerde code. Dit kan bijvoorbeeld gebeuren door abstractie, redundantie of onjuiste toepassing van ontwerppatronen. De codebasis wordt moeilijk te begrijpen, verwarrend en duur om te onderhouden.
Feature Creep
Feep Creep verwijst naar het toevoegen van nieuwe functies die verder gaan dan de kernfunctionaliteit van het product en leiden tot een onnodig hoge complexiteit van het product.
Lange ontwikkeltijd
De tijd die kan worden gebruikt om de benodigde functies te ontwikkelen, wordt besteed aan het ontwikkelen van onnodige functies. Het leveren van het product duurt langer.
Oplossingen
KUS - Houd het simpel, dom
Volgens KISS werken de meeste systemen het beste als ze eenvoudig zijn ontworpen. Eenvoud moet een primair ontwerpdoel zijn om de complexiteit te verminderen. Dit kan bijvoorbeeld worden bereikt door het 'Single Responsibility Principle' te volgen.
YAGNI - Je hebt het niet nodig
Minder is meer. Denk aan elke functie, is het echt nodig? Als je iets kunt bedenken dat het YAGNI is, laat het dan weg. Het is beter om het te ontwikkelen wanneer het nodig is.
Voortdurende refactoring
Het product wordt gestaag verbeterd. Met refactoring kunnen we ervoor zorgen dat het product volgens de beste praktijk wordt uitgevoerd en niet degenereert tot een patch-werk.
Domeinobjecten (geen vetmodellen meer)
"Fat Model, Skinny Controller" is een zeer goede eerste stap, maar het schaalt niet goed zodra je codebase begint te groeien.
Laten we nadenken over de enkele verantwoordelijkheid van modellen. Wat is de enige verantwoordelijkheid van modellen? Is het om bedrijfslogica vast te houden? Is het om niet-respons-gerelateerde logica vast te houden?
Nee. Het is zijn verantwoordelijkheid om de persistentielaag en zijn abstractie te hanteren.
Bedrijfslogica, evenals alle niet-respons-gerelateerde logica en niet-persistentie-gerelateerde logica, moeten in domeinobjecten worden opgenomen.
Domeinobjecten zijn klassen die zijn ontworpen om slechts één verantwoordelijkheid in het domein van het probleem te hebben. Laat je klassen " hun architectuur schreeuwen " voor de problemen die ze oplossen.
In de praktijk moet u streven naar magere modellen, magere weergaven en magere controllers. De architectuur van uw oplossing mag niet worden beïnvloed door het framework dat u kiest.
Bijvoorbeeld
Stel dat u een marktplaats bent die een vaste commissie van 15% aan uw klanten in rekening brengt via Stripe. Als u een vaste commissie van 15% in rekening brengt, betekent dit dat uw commissie verandert afhankelijk van het bedrag van de bestelling, omdat Stripe 2,9% + 30 ¢ in rekening brengt.
Het bedrag dat u als commissie in rekening brengt, moet zijn: amount*0.15 - (amount*0.029 + 0.30)
.
Schrijf deze logica niet in het model:
# app/models/order.rb
class Order < ActiveRecord::Base
SERVICE_COMMISSION = 0.15
STRIPE_PERCENTAGE_COMMISSION = 0.029
STRIPE_FIXED_COMMISSION = 0.30
...
def commission
amount*SERVICE_COMMISSION - stripe_commission
end
private
def stripe_commission
amount*STRIPE_PERCENTAGE_COMMISSION + STRIPE_FIXED_COMMISSION
end
end
Zodra u integreert met een nieuwe betaalmethode, kunt u deze functionaliteit niet in dit model schalen.
Ook, zodra je begint te meer business logica te integreren, uw Order
zal object beginnen te verliezen cohesie .
Geef de voorkeur aan domeinobjecten, waarbij de berekening van de commissie volledig is geabstraheerd van de verantwoordelijkheid voor aanhoudende bestellingen:
# app/models/order.rb
class Order < ActiveRecord::Base
...
# No reference to commission calculation
end
# lib/commission.rb
class Commission
SERVICE_COMMISSION = 0.15
def self.calculate(payment_method, model)
model.amount*SERVICE_COMMISSION - payment_commission(payment_method, model)
end
private
def self.payment_commission(payment_method, model)
# There are better ways to implement a static registry,
# this is only for illustration purposes.
Object.const_get("#{payment_method}Commission").calculate(model)
end
end
# lib/stripe_commission.rb
class StripeCommission
STRIPE_PERCENTAGE_COMMISSION = 0.029
STRIPE_FIXED_COMMISSION = 0.30
def self.calculate(model)
model.amount*STRIPE_PERCENTAGE_COMMISSION
+ STRIPE_PERCENTAGE_COMMISSION
end
end
# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
def create
@order = Order.new(order_params)
@order.commission = Commission.calculate("Stripe", @order)
...
end
end
Het gebruik van domeinobjecten heeft de volgende architecturale voordelen:
- het is buitengewoon eenvoudig om eenheden te testen, omdat er geen armaturen of fabrieken nodig zijn om de objecten met de logica te instantiëren.
- werkt met alles dat het bericht accepteert
amount
. - houdt elk domeinobject klein, met duidelijk omschreven verantwoordelijkheden en met een grotere samenhang.
- gemakkelijk schaalbaar met nieuwe betaalmethoden door toevoeging, geen aanpassing .
- stopt de neiging om een steeds groter wordend
User
in elke Ruby on Rails-toepassing te hebben.
Persoonlijk plaats ik graag domeinobjecten in lib
. Als u dit doet, vergeet dan niet om het toe te voegen aan autoload_paths
:
# config/application.rb
config.autoload_paths << Rails.root.join('lib')
U kunt er ook de voorkeur aan geven domeinobjecten actiegerichter te maken, volgens het Command / Query-patroon. In dergelijke gevallen kan het beter zijn om deze objecten in app/commands
te plaatsen, omdat alle app
submappen automatisch worden toegevoegd aan het autoload-pad.