Rails Service Objects: A Comprehensive Guide

Ruby on Rails levereras med allt du behöver för att snabbt skapa en prototyp av din applikation, men när din kodbas börjar växa kommer du att stöta på scenarier där det konventionella mantrat Fat Model, Skinny Controller inte fungerar. När din affärslogik inte kan rymmas i vare sig en modell eller en controller är det då som serviceobjekten kommer in och låter oss separera varje affärsåtgärd i ett eget Ruby-objekt.

Ett exempel på en förfrågningscykel med Rails serviceobjekt

I den här artikeln förklarar jag när ett serviceobjekt behövs, hur man går till väga för att skriva rena serviceobjekt och gruppera dem för att få en bidragsgivare som är vettig, de strikta regler som jag inför för mina serviceobjekt för att binda dem direkt till min affärslogik, och hur man inte förvandlar serviceobjekten till en soptipp för all den kod man inte vet vad man ska göra med.

Varför behöver jag tjänsteobjekt?

Att prova det här: Vad gör du när din applikation behöver twittra texten från params?

Om du har använt vanilla Rails hittills har du förmodligen gjort något liknande:

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 här är att du har lagt till minst tio rader till din controller, men de hör egentligen inte hemma där. Dessutom, vad händer om du vill använda samma funktionalitet i en annan styrenhet? Flyttar du detta till ett problem? Vänta, men den här koden hör egentligen inte alls hemma i controllers. Varför kan inte Twitter API bara komma med ett enda förberett objekt som jag kan anropa?

Första gången jag gjorde det här kändes det som om jag hade gjort något smutsigt. Mina tidigare så vackert magra Rails-kontroller hade börjat bli feta och jag visste inte vad jag skulle göra. Så småningom fixade jag min controller med ett serviceobjekt.

Innan du börjar läsa den här artikeln, låt oss låtsas:

  • Den här applikationen hanterar ett Twitter-konto.
  • The Rails Way betyder “det konventionella Ruby on Rails-sättet att göra saker och ting” och boken finns inte.
  • Jag är en Rails-expert… vilket jag får höra varje dag att jag är, men jag har svårt att tro på det, så låt oss låtsas att jag verkligen är det.

Vad är serviceobjekt?

Tjänstobjekt är Plain Old Ruby Objects (PORO) som är utformade för att utföra en enda åtgärd i din domänlogik och göra det bra. Tänk på exemplet ovan: Vår metod har redan logiken för att göra en enda sak, nämligen att skapa en tweet. Tänk om denna logik kunde kapslas in i en enda Ruby-klass som vi kan instansiera och anropa en metod till? Något 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 här är i stort sett allt; vårt TweetCreator serviceobjekt kan, när det väl har skapats, anropas varifrån som helst, och det skulle göra denna enda sak mycket bra.

Skapa ett serviceobjekt

Först skapar vi ett nytt TweetCreator i en ny mapp som heter app/services:

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

Och låt oss bara dumpa all vår logik inne i en ny Ruby-klass:

# 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

Sedan kan du anropa TweetCreator.new(params).send_tweet var som helst i din app, och det kommer att fungera. Rails kommer att ladda det här objektet på ett magiskt sätt eftersom det automatiskt laddar allt under app/. Kontrollera detta genom att köra:

$ 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

Vill du veta mer om hur autoload fungerar? Läs guiden Autoloading and Reloading Constants Guide.

Adding Syntactic Sugar to Make Rails Service Objects Suck Less

Det här känns bra i teorin, men TweetCreator.new(params).send_tweet är bara en munsbit. Det är alldeles för spretigt med överflödiga ord… ungefär som HTML (ba-dum tiss!). Allvarligt talat, varför använder folk HTML när det finns HAML? Eller till och med Slim. Jag antar att det är en annan artikel för en annan gång. Tillbaka till den aktuella uppgiften:

TweetCreator är ett trevligt kort klassnamn, men det extra krånglet kring instantiering av objektet och anropande av metoden är alldeles för långt! Om det bara fanns ett företräde i Ruby för att anropa något och få det att exekvera sig själv omedelbart med de givna parametrarna… Åh vänta, det finns det! Det är Proc#call.

Proccall som anropar blocket och ställer in blockets parametrar till värdena i params med hjälp av något som ligger nära semantiken för metodanrop. Den returnerar värdet av det sista uttrycket som utvärderades i blocket.

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

Om detta förvirrar dig, låt mig förklara. En proc kan call-redigeras för att exekvera sig själv med de givna parametrarna. Vilket innebär att om TweetCreator var en proc skulle vi kunna anropa den med TweetCreator.call(message) och resultatet skulle vara likvärdigt med TweetCreator.new(params).call, vilket ser ganska likt vår otympliga gamla TweetCreator.new(params).send_tweet.

Så låt oss få vårt serviceobjekt att bete sig mer som en proc!

Först, eftersom vi förmodligen vill återanvända detta beteende i alla våra tjänsteobjekt, ska vi låna från Rails Way och skapa en klass som heter ApplicationService:

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

Såg du vad jag gjorde där? Jag lade till en klassmetod som heter call som skapar en ny instans av klassen med de argument eller block som du skickar till den och anropar call på instansen. Exakt vad vi ville ha! Det sista vi behöver göra är att byta namn på metoden från vår TweetCreator-klass till call och låta klassen ärva från 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

Och slutligen, låt oss avsluta det här genom att kalla vårt serviceobjekt i controllern:

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

Gruppering av liknande serviceobjekt för att få ordning på det hela

Exemplet ovan har bara ett serviceobjekt, men i verkligheten kan det bli mer komplicerat. Tänk om du till exempel har hundratals tjänster och hälften av dem är relaterade affärsåtgärder, t.ex. om du har en Follower tjänst som följer ett annat Twitter-konto? Ärligt talat skulle jag bli galen om en mapp innehöll 200 unika filer, så tur att det finns ett annat mönster från Rails Way som vi kan kopiera – jag menar, använda som inspiration: namespacing.

Låt oss låtsas att vi har fått i uppdrag att skapa ett tjänsteobjekt som följer andra Twitterprofiler.

Låt oss titta på namnet på vårt tidigare tjänsteobjekt: TweetCreator. Det låter som en person, eller åtminstone en roll i en organisation. Någon som skapar Tweets. Jag gillar att namnge mina tjänsteobjekt som om de vore just det: roller i en organisation. I enlighet med denna konvention kallar jag mitt nya objekt för: ProfileFollower.

Nu, eftersom jag är den högsta herren över den här appen, kommer jag att skapa en chefsposition i min tjänstehierarki och delegera ansvaret för båda de här tjänsterna till den positionen. Jag kallar denna nya chefsposition för TwitterManager.

Då den här chefen inte gör något annat än att hantera, gör vi den till en modul och placerar våra tjänsteobjekt under den här modulen. Vår mappstruktur kommer nu att se ut så här:

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

Och våra tjänsteobjekt:

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

Och våra anrop kommer nu att bli TwitterManager::TweetCreator.call(arg) och TwitterManager::ProfileManager.call(arg).

Tjänsteobjekt för att hantera databasoperationer

Exemplet ovan gjorde API-anrop, men tjänsteobjekt kan också användas när alla anrop sker till din databas istället för ett API. Detta är särskilt användbart om vissa affärsåtgärder kräver flera databasuppdateringar som förpackas i en transaktion. Den här exempelkoden skulle till exempel använda tjänster för att registrera en valutaväxling som äger rum.

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

Vad returnerar jag från mitt tjänsteobjekt?

Vi har diskuterat hur vi ska call vårt tjänsteobjekt, men vad ska objektet returnera? Det finns tre sätt att närma sig detta:

  • Returnera true eller false
  • Returnera ett värde
  • Returnera en Enum

Returnera true eller false

Detta är enkelt: Om en åtgärd fungerar som avsett returnerar du true, annars returnerar du false:

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

Returnera ett värde

Om ditt serviceobjekt hämtar data någonstans vill du förmodligen returnera det värdet:

 def call ... return false unless exchange_rate exchange_rate end

Svar med en enum

Om ditt tjänsteobjekt är lite mer komplext och du vill hantera olika scenarier kan du lägga till enum för att styra flödet av dina tjänster:

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

Och sedan kan du använda följande i din app:

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

Skulle jag inte placera tjänsteobjekten i lib/services i stället för app/services?

Detta är subjektivt. Folk har olika åsikter om var de ska lägga sina tjänsteobjekt. Vissa lägger dem i lib/services medan andra skapar app/services. Jag tillhör det senare lägret. I Rails Getting Started Guide beskrivs mappen lib/ som platsen för att lägga “utökade moduler för din applikation.”

I min ödmjuka åsikt betyder “utökade moduler” moduler som inte kapslar in domänens kärnlogik och som i allmänhet kan användas i flera projekt. Med de kloka orden från ett slumpmässigt svar från Stack Overflow: lägg in kod där som “potentiellt kan bli en egen pärla.”

Är serviceobjekt en bra idé?

Det beror på ditt användningsfall. Titta – det faktum att du läser den här artikeln just nu tyder på att du försöker skriva kod som inte precis hör hemma i en modell eller controller. Jag läste nyligen den här artikeln om hur serviceobjekt är ett anti-mönster. Författaren har sina åsikter, men jag håller respektfullt nog inte med.

Bara för att någon annan person har överanvänt serviceobjekt betyder inte att de i sig är dåliga. På mitt nystartade företag, Nazdeeq, använder vi serviceobjekt såväl som icke-ActiveRecord-modeller. Men skillnaden mellan vad som passar vart har alltid varit uppenbar för mig: Jag behåller alla affärshandlingar i serviceobjekt medan resurser som egentligen inte behöver persistera finns i icke-ActiveRecord-modeller. I slutändan är det upp till dig att avgöra vilket mönster som är bra för dig.

Men tycker jag att tjänsteobjekt i allmänhet är en bra idé? Absolut! De håller min kod snyggt organiserad, och det som gör mig säker på min användning av POROs är att Ruby älskar objekt. Nej, allvarligt talat, Ruby älskar objekt. Det är galet, helt galet, men jag älskar det! Ett exempel:

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

See? 5 är bokstavligen ett objekt.

I många språk är siffror och andra primitiva typer inte objekt. Ruby följer inflytandet från språket Smalltalk genom att ge metoder och instansvariabler till alla sina typer. Detta underlättar användandet av Ruby, eftersom regler som gäller för objekt gäller för hela Ruby.Ruby-lang.org

När ska jag inte använda ett serviceobjekt?

Den här är lätt. Jag har följande regler:

  1. Hanterar din kod routing, params eller gör andra controller-aktiga saker?
    Om så är fallet ska du inte använda ett serviceobjekt – din kod hör hemma i controllern.
  2. Försöker du dela din kod i olika kontrollanter?
    I så fall ska du inte använda ett tjänsteobjekt – använd en concern.
  3. Är din kod som en modell som inte behöver persistens?
    I så fall ska du inte använda ett tjänsteobjekt. Använd istället en icke-ActiveRecord-modell.
  4. Är din kod en specifik affärsåtgärd? (t.ex. “Ta ut soporna”, “Generera en PDF med hjälp av den här texten” eller “Beräkna tullavgiften med hjälp av de här komplicerade reglerna”)
    I det här fallet ska du använda ett tjänsteobjekt. Den koden passar förmodligen inte logiskt in i vare sig din controller eller din modell.

Självklart är detta mina regler, så du är välkommen att anpassa dem till dina egna användningsfall. De har fungerat mycket bra för mig, men din erfarenhet kan variera.

Regler för att skriva bra tjänsteobjekt

Jag har fyra regler för att skapa tjänsteobjekt. Dessa är inte skrivna i sten, och om du verkligen vill bryta mot dem kan du göra det, men jag kommer förmodligen att be dig ändra dem i kodgranskningar om inte ditt resonemang är bra.

Regel 1: Endast en offentlig metod per tjänsteobjekt

Tjänsteobjekt är enskilda affärsåtgärder. Du kan ändra namnet på din offentliga metod om du vill. Jag föredrar att använda call, men Gitlab CE:s kodbas kallar den execute och andra personer kan använda perform. Använd vad du vill – du kan kalla den nermin för min del. Skapa bara inte två offentliga metoder för ett enda tjänsteobjekt. Dela upp det i två objekt om du behöver det.

Regel 2: Namnge tjänsteobjekt som dumma roller på ett företag

Tjänsteobjekt är enskilda affärsåtgärder. Tänk om du anställde en person på företaget för att göra den enda uppgiften, vad skulle du kalla dem? Om deras uppgift är att skapa tweets kallar du dem TweetCreator. Om deras uppgift är att läsa specifika tweets, kalla dem TweetReader.

Regel 3: Skapa inte generiska objekt för att utföra flera åtgärder

Tjänsteobjekt är enskilda affärsåtgärder. Jag delade upp funktionaliteten i två delar: TweetReader och ProfileFollower. Vad jag inte gjorde var att skapa ett enda generiskt objekt som heter TwitterHandler och dumpa all API-funktionalitet i det. Gör inte detta. Det går emot tankesättet “affärsåtgärd” och får tjänsteobjektet att se ut som Twitterfeen. Om du vill dela kod mellan affärsobjekten skapar du bara ett BaseTwitterManager-objekt eller en BaseTwitterManager-modul och blandar in den i dina tjänsteobjekt.

Regel 4: Hantera undantag inne i tjänsteobjektet

För femtielfte gången: Tjänsteobjekt är enskilda affärshandlingar. Jag kan inte nog säga detta. Om du har en person som läser tweets kommer de antingen att ge dig tweeten eller säga: “Den här tweeten existerar inte”. På samma sätt ska du inte låta ditt serviceobjekt få panik, hoppa upp på kontrollantens skrivbord och be den stoppa allt arbete eftersom “Error!” Återge bara false och låt kontrollanten gå vidare därifrån.

Den här artikeln hade inte varit möjlig utan den fantastiska gemenskapen av Ruby-utvecklare på Toptal. Om jag någonsin stöter på ett problem är gemenskapen den mest hjälpsamma gruppen av begåvade ingenjörer jag någonsin träffat.

Om du använder serviceobjekt kanske du undrar hur du ska tvinga fram vissa svar när du testar. Jag rekommenderar att du läser den här artikeln om hur du skapar mock service objects i Rspec som alltid returnerar det resultat du vill ha, utan att faktiskt träffa serviceobjektet!

Om du vill lära dig mer om Ruby-knep rekommenderar jag Creating a Ruby DSL: A Guide to Advanced Metaprogramming av Toptalerkollegan Máté Solymosi. Han bryter ner hur routes.rb-filen inte känns som Ruby och hjälper dig att bygga din egen DSL.

Lämna ett svar

Din e-postadress kommer inte publiceras.