Rails Service Objects: A Comprehensive Guide

Ruby on Rails wordt geleverd met alles wat je nodig hebt om prototypes van uw aanvraag snel, maar als je codebase begint te groeien, zul je tegenkomen scenario’s waar de conventionele Fat Model, Skinny Controller mantra breekt. Wanneer je bedrijfslogica niet in een model of controller past, komen serviceobjecten om de hoek kijken en kunnen we elke bedrijfsactie in een eigen Ruby-object onderbrengen.

Een voorbeeld van een request-cyclus met Rails service-objecten

In dit artikel leg ik uit wanneer een service-object nodig is; hoe je schone service-objecten schrijft en hoe je ze groepeert voor een gezonde bijdrage; de strikte regels die ik aan mijn service-objecten opleg om ze direct aan mijn bedrijfslogica te koppelen; en hoe je je service-objecten niet verandert in een dumpplaats voor alle code waarvan je niet weet wat je ermee moet doen.

Waarom heb ik service objecten nodig?

Probeer dit eens: Wat doe je als je applicatie de tekst van params moet tweeten?

Als je tot nu toe vanilla Rails hebt gebruikt, dan heb je waarschijnlijk iets als dit gedaan:

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

Het probleem hier is dat je ten minste tien regels aan je controller hebt toegevoegd, maar ze horen daar niet echt thuis. En wat als je dezelfde functionaliteit in een andere controller zou willen gebruiken? Verplaats je dit naar een concern? Wacht, maar deze code hoort eigenlijk helemaal niet in controllers thuis. Waarom kan de Twitter API niet gewoon met een enkel prepared object komen dat ik kan aanroepen?

De eerste keer dat ik dit deed, voelde ik me alsof ik iets smerigs had gedaan. Mijn, voorheen, prachtig slanke Rails controllers waren dik geworden en ik wist niet wat ik moest doen. Uiteindelijk heb ik mijn controller gerepareerd met een service object.

Voordat je dit artikel gaat lezen, laten we doen alsof:

  • Deze applicatie behandelt een Twitter account.
  • The Rails Way betekent “de conventionele Ruby on Rails manier om dingen te doen” en het boek bestaat niet.
  • Ik ben een Rails expert… wat me elke dag wordt verteld, maar ik heb moeite om het te geloven, dus laten we gewoon doen alsof ik er echt een ben.

Wat zijn Service Objects?

Service objecten zijn Plain Old Ruby Objects (PORO) die zijn ontworpen om een enkele actie in je domein logica uit te voeren en het goed te doen. Beschouw het bovenstaande voorbeeld: Onze methode heeft al de logica om een enkel ding te doen, en dat is het maken van een tweet. Wat als deze logica was ingekapseld in een enkele Ruby klasse die we kunnen instantiëren en een methode kunnen aanroepen? Zoiets als:

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)

Dit is het zo’n beetje; ons TweetCreator service object, eenmaal gemaakt, kan overal vandaan worden aangeroepen, en het zou dit ene ding heel goed doen.

Een service-object maken

Laten we eerst een nieuwe TweetCreator maken in een nieuwe map genaamd app/services:

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

En laten we al onze logica in een nieuwe Ruby class dumpen:

# 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

Dan kun je TweetCreator.new(params).send_tweet overal in je app oproepen, en het zal werken. Rails zal dit object op magische wijze laden omdat het alles autoloadt onder app/. Controleer dit door te draaien:

$ 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

Wil je meer weten over hoe autoload werkt? Lees de Autoloading and Reloading Constants Guide.

Adding Syntactic Sugar to Make Rails Service Objects Suck Less

Look, dit voelt geweldig in theorie, maar TweetCreator.new(params).send_tweet is gewoon een mondvol. Het is veel te langdradig met overbodige woorden… net als HTML (ba-dum tiss!). Maar in alle ernst, waarom gebruiken mensen HTML als er HAML is? Of zelfs Slim. Ik denk dat dat een ander artikel is voor een andere keer. Terug naar de taak bij de hand:

TweetCreator is een mooie korte class naam, maar de extra rompslomp rond het instantiëren van het object en het aanroepen van de methode is gewoon te lang! Was er maar voorrang in Ruby om iets aan te roepen en het zichzelf onmiddellijk te laten uitvoeren met de gegeven parameters… oh wacht, die is er! Het is Proc#call.

Proccall roept het blok aan, waarbij de parameters van het blok worden ingesteld op de waarden in params met behulp van iets dat lijkt op methode-aanroep semantiek. Het geeft de waarde terug van de laatste expressie geëvalueerd in het blok.

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) #=> 

Documentatie

Als dit je verwart, laat het me uitleggen. Een proc kan worden call-ed om zichzelf uit te voeren met de gegeven parameters. Dat betekent, dat als TweetCreator een proc zou zijn, we het zouden kunnen aanroepen met TweetCreator.call(message) en het resultaat zou gelijk zijn aan TweetCreator.new(params).call, wat erg lijkt op onze logge oude TweetCreator.new(params).send_tweet.

Dus laten we ons service object zich meer gedragen als een proc!

Eerst, omdat we waarschijnlijk dit gedrag willen hergebruiken in al onze service objecten, laten we lenen van de Rails manier en een klasse maken genaamd ApplicationService:

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

Zie je wat ik daar deed? Ik voegde een klasse methode genaamd call die een nieuwe instantie van de klasse met de argumenten of blok dat u doorgeeft aan het creëert, en roept call op de instantie. Precies wat we wilden! Het laatste wat we moeten doen is de methode van onze TweetCreator klasse hernoemen naar call, en de klasse laten overerven van 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

En tenslotte, laten we dit afronden door ons service object in de controller aan te roepen:

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

Grouping Similar Service Objects for Sanity

Het bovenstaande voorbeeld heeft slechts één service object, maar in de echte wereld, kunnen de dingen ingewikkelder worden. Wat als je bijvoorbeeld honderden services had, en de helft daarvan waren gerelateerde zakelijke acties, bijvoorbeeld een Follower service die een ander Twitter account volgde? Eerlijk gezegd zou ik gek worden als een map 200 uniek uitziende bestanden bevatte, dus gelukkig is er een ander patroon van de Rails Way dat we kunnen kopiëren-ik bedoel, gebruiken als inspiratie: namespacing.

Laten we doen alsof we de opdracht hebben gekregen om een service object te maken dat andere Twitter profielen volgt.

Laten we eens kijken naar de naam van ons vorige service object: TweetCreator. Het klinkt als een persoon, of op zijn minst, een rol in een organisatie. Iemand die Tweets maakt. Ik hou ervan om mijn service objecten zo te noemen alsof ze precies dat zijn: rollen in een organisatie. Volgens deze conventie, noem ik mijn nieuwe object: ProfileFollower.

Nu, omdat ik de opperste baas van deze app ben, ga ik een manager positie in mijn service hiërarchie maken en delegeer de verantwoordelijkheid voor deze beide services naar die positie. Ik noem deze nieuwe manager positie TwitterManager.

Omdat deze manager niets anders doet dan beheren, maken we er een module van en nestelen we onze service objecten onder deze module. Onze folder structuur zal er nu uitzien als:

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

En onze service objecten:

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

En onze calls zullen nu TwitterManager::TweetCreator.call(arg), en TwitterManager::ProfileManager.call(arg).

Service Objects to Handle Database Operations

Het voorbeeld hierboven maakte API calls, maar service objecten kunnen ook worden gebruikt wanneer alle calls naar uw database zijn in plaats van een API. Dit is vooral nuttig als sommige zakelijke acties meerdere database updates vereisen die in een transactie zijn verpakt. Bijvoorbeeld, dit voorbeeld code zou services gebruiken om een wisseling van valuta vast te leggen.

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

Wat moet ik teruggeven van mijn service object?

We hebben besproken hoe we call ons service object moeten maken, maar wat moet het object teruggeven? Er zijn drie manieren om dit te benaderen:

  • Return true of false
  • Return een waarde
  • Return een Enum

Return true of false

Deze is eenvoudig: Als een actie werkt zoals bedoeld, retourneer true; anders retourneer false:

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

Return a Value

Als uw serviceobject ergens gegevens vandaan haalt, wilt u waarschijnlijk die waarde retourneren:

 def call ... return false unless exchange_rate exchange_rate end

Respond with an Enum

Als je service object wat complexer is, en je wilt verschillende scenario’s afhandelen, dan zou je gewoon enums kunnen toevoegen om de flow van je services te controleren:

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

En dan in je app, kun je:

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

Should’t I Put Service Objects in lib/services Instead of app/services?

Dit is subjectief. Mensen verschillen van mening over waar ze hun service-objecten moeten plaatsen. Sommige mensen zetten ze in lib/services, terwijl anderen app/services maken. Ik val in het laatste kamp. Rails ‘Getting Started Guide beschrijft de lib/ map als de plaats om “uitgebreide modules voor uw applicatie.”

In mijn bescheiden mening, “uitgebreide modules” betekent modules die niet inkapselen core domein logica en kan over het algemeen worden gebruikt over projecten. In de wijze woorden van een willekeurig Stack Overflow antwoord, stop daar code in die “potentieel zijn eigen juweeltje kan worden.”

Zijn Service Objects een goed idee?

Het hangt af van je use case. Kijk – het feit dat je dit artikel nu leest, suggereert dat je probeert code te schrijven die niet echt in een model of controller thuishoort. Ik las onlangs dit artikel over hoe service objecten een anti-patroon zijn. De auteur heeft zijn mening, maar ik ben het daar respectvol niet mee eens.

Omdat iemand anders service-objecten te veel gebruikt, wil nog niet zeggen dat ze inherent slecht zijn. Bij mijn startup, Nazdeeq, gebruiken we zowel service objecten als niet-ActiveRecord modellen. Maar het verschil tussen wat waar gaat is altijd duidelijk geweest voor mij: Ik hou alle business acties in service objecten terwijl ik resources die niet echt persistentie nodig hebben in niet-ActiveRecord modellen hou. Aan het eind van de dag is het aan jou om te beslissen welk patroon goed is voor jou.

Hoe dan ook, denk ik dat service objecten in het algemeen een goed idee zijn? Absoluut! Ze houden mijn code netjes georganiseerd, en wat me vertrouwen geeft in mijn gebruik van PORO’s is dat Ruby van objecten houdt. Nee, serieus, Ruby houdt van objecten. Het is krankzinnig, totaal gestoord, maar ik hou ervan! Een voorbeeld:

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

Zie je? 5 is letterlijk een object.

In veel talen zijn getallen en andere primitieve types geen objecten. Ruby volgt de invloed van de Smalltalk taal door methodes en instantie variabelen aan al zijn types te geven. Dit vergemakkelijkt het gebruik van Ruby, omdat regels die gelden voor objecten van toepassing zijn op heel Ruby.Ruby-lang.org

Wanneer moet ik een Service Object niet gebruiken?

Deze is gemakkelijk. Ik heb deze regels:

  1. Gaat je code om met routing, params of andere controller-achtige dingen?
    Zo ja, gebruik dan geen service object-je code hoort in de controller.
  2. Probeert u uw code in verschillende controllers te delen?
    Gebruik in dit geval geen service-object-gebruik een concern.
  3. Wordt uw code als een model gebruikt dat geen persistentie behoeft?
    Zo ja, gebruik dan geen service-object. Gebruik in plaats daarvan een niet-ActiveRecord-model.
  4. Is uw code een specifieke zakelijke actie? (Bijvoorbeeld: “Zet het vuilnis buiten”, “Genereer een PDF met behulp van deze tekst”, of “Bereken de douanerechten met behulp van deze ingewikkelde regels”)
    Gebruik in dit geval een serviceobject. Die code past waarschijnlijk niet logisch in je controller of in je model.

Natuurlijk zijn dit mijn regels, dus je mag ze gerust aanpassen aan je eigen use cases. Deze hebben zeer goed gewerkt voor mij, maar uw kilometers kan variëren.

Rules for Writing Good Service Objects

Ik heb een vier regels voor het maken van service-objecten. Deze zijn niet in steen geschreven, en als je ze echt wilt breken, kan dat, maar ik zal je waarschijnlijk vragen om ze te veranderen in code reviews, tenzij je redenering deugdelijk is.

Regel 1: Slechts één publieke methode per serviceobject

Serviceobjecten zijn enkelvoudige zakelijke acties. Je kunt de naam van je public method veranderen als je wilt. Ik gebruik het liefst call, maar Gitlab CE’s codebase noemt het execute en andere mensen gebruiken misschien perform. Gebruik wat je wilt-je zou het nermin kunnen noemen, wat mij betreft. Maak alleen geen twee publieke methoden voor een enkel service object. Verdeel het in twee objecten als dat nodig is.

Regel 2: Noem service-objecten zoals domme rollen in een bedrijf

Service-objecten zijn afzonderlijke zakelijke acties. Stel je voor dat je bij het bedrijf één persoon inhuurt om die ene taak te doen, hoe zou je die dan noemen? Als het hun taak is om tweets te maken, noem ze dan TweetCreator. Als het hun taak is om specifieke tweets te lezen, noem ze dan TweetReader.

Regel 3: Maak geen generieke objecten om meerdere acties uit te voeren

Service-objecten zijn enkelvoudige zakelijke acties. Ik heb de functionaliteit in twee stukken gesplitst: TweetReader, en ProfileFollower. Wat ik niet heb gedaan is een enkel generiek object TwitterHandler gemaakt en daar alle API functionaliteit in gedumpt. Doe dit alstublieft niet. Dit gaat in tegen de “business action” mentaliteit en laat het service object eruit zien als de Twitter Fairy. Als je code wilt delen tussen de business objecten, maak dan gewoon een BaseTwitterManager object of module en meng dat in je service objecten.

Regel 4: Behandel Excepties Binnen het Service Object

Voor de zoveelste keer: Service-objecten zijn enkelvoudige zakelijke acties. Ik kan dit niet genoeg zeggen. Als je een persoon hebt die tweets leest, zal hij je de tweet geven, of zeggen: “Deze tweet bestaat niet.” Op dezelfde manier, laat je service object niet in paniek raken, op het bureau van je controller springen, en hem vertellen dat hij al het werk moet stoppen want “Fout!” Geef gewoon false terug en laat de controller vanaf daar verder gaan.

Dit artikel zou niet mogelijk zijn geweest zonder de geweldige gemeenschap van Ruby-ontwikkelaars bij Toptal. Als ik ooit tegen een probleem aanloop, is de gemeenschap de meest behulpzame groep getalenteerde ingenieurs die ik ooit heb ontmoet.

Als je service objecten gebruikt, kun je jezelf afvragen hoe je bepaalde antwoorden kunt forceren tijdens het testen. Ik raad je aan dit artikel te lezen over hoe je mock service objecten in Rspec kunt maken die altijd het gewenste resultaat teruggeven, zonder het service object daadwerkelijk te raken!

Als je meer over Ruby trucs wilt leren, raad ik je Creating a Ruby DSL: A Guide to Advanced Metaprogramming van mede-Toptaler Máté Solymosi aan. Hij legt uit hoe het routes.rb bestand niet aanvoelt als Ruby en helpt je je eigen DSL te bouwen.

Geef een antwoord

Het e-mailadres wordt niet gepubliceerd.