Rails Service Objects: A Comprehensive Guide

Ruby on Rails leveres med alt, hvad du har brug for til hurtigt at lave prototyper af din applikation, men når din kodebase begynder at vokse, vil du løbe ind i scenarier, hvor det konventionelle Fat Model, Skinny Controller-mantra bryder sammen. Når din forretningslogik ikke kan passe ind i hverken en model eller en controller, kommer serviceobjekter ind i billedet og lader os adskille hver enkelt forretningshandling i sit eget Ruby-objekt.

Eksempel på en forespørgselscyklus med Rails-serviceobjekter

I denne artikel forklarer jeg, hvornår et serviceobjekt er påkrævet, hvordan man skriver rene serviceobjekter og grupperer dem for at sikre bidragyderens sanitet, de strenge regler, jeg pålægger mine serviceobjekter for at binde dem direkte til min forretningslogik, og hvordan man ikke gør sine serviceobjekter til en losseplads for al den kode, man ikke ved, hvad man skal gøre med.

Hvorfor har jeg brug for serviceobjekter?

Prøv dette: Hvad gør du, når din applikation skal tweete teksten fra params?

Hvis du har brugt vanilla Rails indtil nu, har du sikkert gjort noget i stil med dette:

class TweetController < ApplicationController def create send_tweet(params) end private def send_tweet(tweet) client = Twitter::REST::Client.new do |config| config.consumer_key = ENV config.consumer_secret = ENV config.access_token = ENV config.access_token_secret = ENV end client.update(tweet) endend

Problemet her er, at du har tilføjet mindst ti linjer til din controller, men de hører ikke rigtig til der. Desuden, hvad nu hvis du ville bruge den samme funktionalitet i en anden controller? Flytter du dette til en bekymring? Vent, men denne kode hører egentlig slet ikke hjemme i controllere. Hvorfor kan Twitter API’et ikke bare komme med et enkelt forberedt objekt, som jeg kan kalde?

Den første gang jeg gjorde dette, følte jeg, at jeg havde gjort noget beskidt. Mine, tidligere, smukt slanke Rails-controllere var begyndt at blive fede, og jeg vidste ikke, hvad jeg skulle gøre. Til sidst fik jeg rettet min controller til med et serviceobjekt.

Hvor du begynder at læse denne artikel, skal vi lade som om:

  • Denne applikation håndterer en Twitter-konto.
  • The Rails Way betyder “den konventionelle Ruby on Rails-måde at gøre tingene på”, og bogen eksisterer ikke.
  • Jeg er en Rails-ekspert … hvilket jeg får at vide hver dag, at jeg er, men jeg har svært ved at tro på det, så lad os bare lade som om, at jeg virkelig er en.

Hvad er serviceobjekter?

Serviceobjekter er Plain Old Ruby Objects (PORO), der er designet til at udføre en enkelt handling i din domænelogik og gøre det godt. Tænk på eksemplet ovenfor: Vores metode har allerede logikken til at gøre én enkelt ting, og det er at oprette et tweet. Hvad nu hvis denne logik var indkapslet i en enkelt Ruby-klasse, som vi kan instantiere og kalde en metode til? Noget i stil med:

tweet_creator = TweetCreator.new(params)tweet_creator.send_tweet# Later on in the article, we'll add syntactic sugar and shorten the above to:TweetCreator.call(params)

Det er stort set det hele; vores TweetCreator serviceobjekt kan, når det først er oprettet, kaldes fra hvor som helst, og det ville gøre denne ene ting meget godt.

Skabelse af et serviceobjekt

Lad os først oprette en ny TweetCreator i en ny mappe kaldet app/services:

$ mkdir app/services && touch app/services/tweet_creator.rb

Og lad os bare dumpe al vores logik inde i en ny Ruby-klasse:

# app/services/tweet_creator.rbclass TweetCreator def initialize(message) @message = message end def send_tweet client = Twitter::REST::Client.new do |config| config.consumer_key = ENV config.consumer_secret = ENV config.access_token = ENV config.access_token_secret = ENV end client.update(@message) endend

Så kan du kalde TweetCreator.new(params).send_tweet hvor som helst i din app, og det vil virke. Rails vil indlæse dette objekt på magisk vis, fordi det autoloader alt under app/. Kontroller dette ved at køre:

$ rails cRunning via Spring preloader in process 12417Loading development environment (Rails 5.1.5) > puts ActiveSupport::Dependencies.autoload_paths.../Users/gilani/Sandbox/nazdeeq/app/services

Vil du vide mere om, hvordan autoload fungerer? Læs vejledningen om autoloading og genindlæsning af konstanter.

Syntaktisk sukker tilføjes for at få Rails-tjenesteobjekter til at sutte mindre

Hør, det føles godt i teorien, men TweetCreator.new(params).send_tweet er bare en stor mundfuld. Det er alt for mundret med overflødige ord … meget ligesom HTML (ba-dum tiss!). Men helt seriøst, hvorfor bruger folk HTML, når der findes HAML? Eller endda Slim. Det er vel en anden artikel til en anden gang. Tilbage til den aktuelle opgave:

TweetCreator er et dejligt kort klasse-navn, men det ekstra rod omkring instantiering af objektet og kald af metoden er bare for langt! Hvis bare der var præcedens i Ruby for at kalde noget og få det til at udføre sig selv med det samme med de givne parametre… åh vent, det er der! Det er Proc#call.

Proccall påkalder blokken og sætter blokens parametre til værdierne i params ved hjælp af noget, der ligger tæt på semantikken for metodeopkald. Den returnerer værdien af det sidste udtryk, der er evalueret i blokken.

aproc = Proc.new {|scalar, values| values.map {|value| valuescalar } }aproc.call(9, 1, 2, 3) #=> aproc #=> aproc.(9, 1, 2, 3) #=> aproc.yield(9, 1, 2, 3) #=> 

Dokumentation

Hvis dette forvirrer dig, så lad mig forklare. En proc kan call-es til at udføre sig selv med de givne parametre. Hvilket betyder, at hvis TweetCreator var en proc, kunne vi kalde den med TweetCreator.call(message), og resultatet ville svare til TweetCreator.new(params).call, hvilket ligner vores uhåndterlige gamle TweetCreator.new(params).send_tweet ganske meget.

Så lad os få vores serviceobjekt til at opføre sig mere som en proc!

Først, fordi vi sandsynligvis ønsker at genbruge denne adfærd på tværs af alle vores serviceobjekter, skal vi låne fra Rails Way og oprette en klasse kaldet ApplicationService:

# app/services/application_service.rbclass ApplicationService def self.call(*args, &block) new(*args, &block).call endend

Så du, hvad jeg gjorde der? Jeg tilføjede en klassemetode kaldet call, der opretter en ny instans af klassen med de argumenter eller den blok, du sender til den, og kalder call på instansen. Præcis det, vi vi ønskede! Det sidste, vi skal gøre, er at omdøbe metoden fra vores TweetCreator-klasse til call og få klassen til at arve fra ApplicationService:

# app/services/tweet_creator.rbclass TweetCreator < ApplicationService attr_reader :message def initialize(message) @message = message end def call client = Twitter::REST::Client.new do |config| config.consumer_key = ENV config.consumer_secret = ENV config.access_token = ENV config.access_token_secret = ENV end client.update(@message) endend

Og lad os til sidst afslutte dette ved at kalde vores serviceobjekt i controlleren:

class TweetController < ApplicationController def create TweetCreator.call(params) endend

Gruppering af lignende serviceobjekter for at sikre sanitet

Eksemplet ovenfor har kun ét serviceobjekt, men i den virkelige verden kan tingene blive mere komplicerede. Hvad nu, hvis du f.eks. havde hundredvis af tjenester, og halvdelen af dem var relaterede forretningshandlinger, f.eks. hvis du havde en Follower tjeneste, der fulgte en anden Twitter-konto? Helt ærligt, jeg ville blive sindssyg, hvis en mappe indeholdt 200 filer med et unikt udseende, så det er godt, at der er et andet mønster fra Rails Way, som vi kan kopiere – jeg mener, bruge som inspiration: namespacing.

Lad os lade som om, vi har fået til opgave at oprette et serviceobjekt, der følger andre Twitter-profiler.

Lad os se på navnet på vores tidligere serviceobjekt: TweetCreator. Det lyder som en person, eller i det mindste som en rolle i en organisation. En person, der opretter Tweets. Jeg kan godt lide at navngive mine serviceobjekter, som om de var netop det: roller i en organisation. I overensstemmelse med denne konvention kalder jeg mit nye objekt: ProfileFollower.

Nu, da jeg er den øverste overherre i denne app, opretter jeg en ledende stilling i mit tjenestehierarki og uddelegerer ansvaret for begge disse tjenester til denne stilling. Jeg kalder denne nye managerposition TwitterManager.

Da denne manager ikke gør andet end at administrere, gør vi den til et modul og placerer vores tjenesteobjekter under dette modul. Vores mappestruktur vil nu se således ud:

services├── application_service.rb└── twitter_manager ├── profile_follower.rb └── tweet_creator.rb

Og vores serviceobjekter:

# services/twitter_manager/tweet_creator.rbmodule TwitterManager class TweetCreator < ApplicationService ... endend
# services/twitter_manager/profile_follower.rbmodule TwitterManager class ProfileFollower < ApplicationService ... endend

Og vores kald vil nu blive TwitterManager::TweetCreator.call(arg), og TwitterManager::ProfileManager.call(arg).

Serviceobjekter til håndtering af databaseoperationer

Eksemplet ovenfor lavede API-kald, men serviceobjekter kan også bruges, når alle kald er til din database i stedet for et API. Dette er især nyttigt, hvis nogle forretningshandlinger kræver flere databaseopdateringer pakket ind i en transaktion. Denne eksempelkode ville f.eks. bruge services til at registrere en valutaveksling, der finder sted.

module MoneyManager # exchange currency from one amount to another class CurrencyExchanger < ApplicationService ... def call ActiveRecord::Base.transaction do # transfer the original currency to the exchange's account outgoing_tx = CurrencyTransferrer.call( from: the_user_account, to: the_exchange_account, amount: the_amount, currency: original_currency ) # get the exchange rate rate = ExchangeRateGetter.call( from: original_currency, to: new_currency ) # transfer the new currency back to the user's account incoming_tx = CurrencyTransferrer.call( from: the_exchange_account, to: the_user_account, amount: the_amount * rate, currency: new_currency ) # record the exchange happening ExchangeRecorder.call( outgoing_tx: outgoing_tx, incoming_tx: incoming_tx ) end end end # record the transfer of money from one account to another in money_accounts class CurrencyTransferrer < ApplicationService ... end # record an exchange event in the money_exchanges table class ExchangeRecorder < ApplicationService ... end # get the exchange rate from an API class ExchangeRateGetter < ApplicationService ... endend

Hvad returnerer jeg fra mit serviceobjekt?

Vi har diskuteret, hvordan vi call vores serviceobjekt, men hvad skal objektet returnere? Der er tre måder at gribe dette an på:

  • Returner true eller false
  • Returner en værdi
  • Returner en Enum

Returner true eller false

Denne er enkel: Hvis en handling fungerer efter hensigten, returneres true; ellers returneres false:

 def call ... return true if client.update(@message) false end

Returner en værdi

Hvis dit serviceobjekt henter data et sted fra, vil du sandsynligvis returnere denne værdi:

 def call ... return false unless exchange_rate exchange_rate end

Svar med et enum

Hvis dit tjenesteobjekt er lidt mere komplekst, og du ønsker at håndtere forskellige scenarier, kan du blot tilføje enums til at styre flowet af dine tjenester:

class ExchangeRecorder < ApplicationService RETURNS = def call foo = do_something return SUCCESS if foo.success? return FAILURE if foo.failure? PARTIAL_SUCCESS end private def do_something endend

Og i din app kan du så bruge:

 case ExchangeRecorder.call when ExchangeRecorder::SUCCESS foo when ExchangeRecorder::FAILURE bar when ExchangeRecorder::PARTIAL_SUCCESS baz end

Bør jeg ikke placere tjenesteobjekter i lib/services i stedet for app/services?

Dette er subjektivt. Folk har forskellige meninger om, hvor de skal placere deres tjenesteobjekter. Nogle mennesker lægger dem i lib/services, mens andre opretter app/services. Jeg falder i den sidstnævnte lejr. I Rails’ Getting Started Guide beskrives mappen lib/ som det sted, hvor du skal lægge “udvidede moduler til din applikation.”

Med “udvidede moduler” menes der efter min ydmyge mening moduler, der ikke indkapsler kerne-domænelogik og generelt kan bruges på tværs af projekter. Med de kloge ord fra et tilfældigt Stack Overflow-svar skal du lægge kode deri, der “potentielt kan blive sin egen perle.”

Er serviceobjekter en god idé?

Det afhænger af dit brugsscenarie. Hør – det faktum, at du læser denne artikel lige nu, tyder på, at du forsøger at skrive kode, der ikke ligefrem hører hjemme i en model eller controller. Jeg læste for nylig denne artikel om, hvordan serviceobjekter er et anti-mønster. Forfatteren har sine meninger, men jeg er respektfuldt uenig.

Det er ikke fordi en anden person har overbrugt serviceobjekter, at de i sig selv er dårlige. I min startup, Nazdeeq, bruger vi serviceobjekter såvel som ikke-ActiveRecord-modeller. Men forskellen mellem hvad der skal bruges hvor har altid været tydelig for mig: Jeg opbevarer alle forretningshandlinger i serviceobjekter, mens jeg opbevarer ressourcer, der ikke virkelig har brug for persistens, i non-ActiveRecord-modeller. I sidste ende er det op til dig at afgøre, hvilket mønster der er godt for dig.

Men synes jeg, at serviceobjekter generelt er en god idé? Absolut! De holder min kode pænt organiseret, og det, der gør mig sikker i min brug af PORO’er, er, at Ruby elsker objekter. Nej, seriøst, Ruby elsker objekter. Det er vanvittigt, fuldstændig vanvittigt, men jeg elsker det! Case in point:

 > 5.is_a? Object # => true > 5.class # => Integer > class Integer?> def woot?> 'woot woot'?> end?> end # => :woot > 5.woot # => "woot woot"

See? 5 er bogstaveligt talt et objekt.

I mange sprog er tal og andre primitive typer ikke objekter. Ruby følger indflydelsen fra Smalltalk-sproget ved at give metoder og instansvariabler til alle sine typer. Dette letter ens brug af Ruby, da de regler, der gælder for objekter, gælder for hele Ruby. ruby-lang.org

Hvornår skal jeg ikke bruge et serviceobjekt?

Dette er nemt. Jeg har disse regler:

  1. Håndterer din kode routing, params eller gør andre controller-agtige ting?
    Hvis det er tilfældet, skal du ikke bruge et serviceobjekt – din kode hører hjemme i controlleren.
  2. Forsøger du at dele din kode i forskellige controllere?
    I så fald skal du ikke bruge et serviceobjekt – brug en bekymring.
  3. Er din kode som en model, der ikke har brug for persistens?
    I så fald skal du ikke bruge et serviceobjekt. Brug i stedet en ikke-ActiveRecord-model.
  4. Er din kode en specifik forretningshandling? (f.eks. “Tag skraldet ud”, “Generer en PDF ved hjælp af denne tekst” eller “Beregn toldafgiften ved hjælp af disse komplicerede regler”)
    I dette tilfælde skal du bruge et serviceobjekt. Den kode passer sandsynligvis ikke logisk ind i hverken din controller eller din model.

Dette er naturligvis mine regler, så du er velkommen til at tilpasse dem til dine egne use cases. Disse har fungeret meget godt for mig, men din kilometervis kan variere.

Regler for at skrive gode serviceobjekter

Jeg har fire regler for at skabe serviceobjekter. Disse er ikke skrevet i sten, og hvis du virkelig ønsker at bryde dem, kan du gøre det, men jeg vil sandsynligvis bede dig om at ændre det i kodegennemgange, medmindre din begrundelse er sund.

Regel 1: Kun én offentlig metode pr. serviceobjekt

Serviceobjekter er enkelte forretningshandlinger. Du kan ændre navnet på din offentlige metode, hvis du vil. Jeg foretrækker at bruge call, men Gitlab CE’s kodebase kalder den execute, og andre folk bruger måske perform. Brug hvad du vil – du kunne kalde den nermin for min skyld. Du skal bare ikke oprette to offentlige metoder for et enkelt serviceobjekt. Opdel det i to objekter, hvis det er nødvendigt.

Regel 2: Navngiv serviceobjekter som dumme roller i et firma

Serviceobjekter er enkelte forretningshandlinger. Forestil dig, at du ansætter én person i virksomheden til at udføre denne ene opgave, hvad ville du så kalde dem? Hvis deres opgave er at oprette tweets, skal du kalde dem TweetCreator. Hvis deres opgave er at læse specifikke tweets, kalder du dem TweetReader.

Regel 3: Opret ikke generiske objekter til at udføre flere handlinger

Serviceobjekter er enkelte forretningshandlinger. Jeg opdelte funktionaliteten i to stykker: TweetReader, og ProfileFollower. Det, jeg ikke gjorde, var at oprette et enkelt generisk objekt kaldet TwitterHandler og dumpe al API-funktionaliteten deri. Lad venligst være med at gøre dette. Det går imod “business action”-tankegangen og får serviceobjektet til at ligne Twitter-feen. Hvis du vil dele kode mellem forretningsobjekterne, skal du blot oprette et BaseTwitterManager-objekt eller -modul og blande det ind i dine serviceobjekter.

Regel 4: Håndter undtagelser inde i serviceobjektet

For 117. gang: Tjenesteobjekter er enkelte forretningshandlinger. Jeg kan ikke sige det nok. Hvis du har en person, der læser tweets, vil de enten give dig tweetet eller sige: “Dette tweet findes ikke”. På samme måde skal du ikke lade dit serviceobjekt gå i panik, hoppe op på din controllers skrivebord og bede den om at stoppe alt arbejde, fordi “Error!” Du skal bare returnere false og lade controlleren gå videre derfra.

Denne artikel ville ikke have været mulig uden det fantastiske fællesskab af Ruby-udviklere hos Toptal. Hvis jeg nogensinde løber ind i et problem, er fællesskabet den mest hjælpsomme gruppe af talentfulde ingeniører, jeg nogensinde har mødt.

Hvis du bruger serviceobjekter, kan du måske undre dig over, hvordan du kan fremtvinge bestemte svar under testning. Jeg anbefaler at læse denne artikel om, hvordan du opretter mock serviceobjekter i Rspec, der altid returnerer det ønskede resultat uden at ramme serviceobjektet!

Hvis du vil lære mere om Ruby-tricks, anbefaler jeg Creating a Ruby DSL: A Guide to Advanced Metaprogramming af Toptaler-kollega Máté Solymosi. Han nedbryder, hvordan routes.rb-filen ikke føles som Ruby og hjælper dig med at opbygge dit eget DSL.

Skriv et svar

Din e-mailadresse vil ikke blive publiceret.