Ruby on Rails sisältää kaiken, mitä tarvitset sovelluksen nopeaan prototyypitykseen, mutta kun koodipohja alkaa kasvaa, törmäät tilanteisiin, joissa perinteinen Fat Model, Skinny Controller -mantra rikkoutuu. Kun liiketoimintalogiikkasi ei mahdu malliin tai kontrolleriin, palveliobjektit tulevat kuvioihin ja antavat meille mahdollisuuden erottaa jokainen liiketoimintatoimi omaksi Ruby-objektikseen.
Tässä artikkelissa selitän, milloin palveluobjekti on tarpeellinen; miten kirjoittaa siistejä palveluobjekteja ja ryhmitellä ne yhteen, jotta ne olisivat järkeviä avustajien kannalta; tiukat säännöt, joita asetan palveluobjekteilleni sitoakseni ne suoraan liiketoimintalogiikkaan; ja miten palveluobjekteista ei tule kaatopaikkaa kaikelle koodille, jonka kanssa et tiedä, mitä tehdä.
- Mihin tarvitsen palvelukohteita?
- Mitä ovat palveluobjektit?
- Palveluobjektin luominen
- Adding Syntactic Sugar to Make Rails Service Objects Suck Less
- Samankaltaisten palvelukohteiden ryhmittely tervejärkisyydestä
- Palvelukohteet tietokantaoperaatioiden käsittelyyn
- Mitä palautan palvelukohteeltani?
- Palauta true tai false
- Palauta arvo
- Respond with an Enum
- Eikö minun pitäisi laittaa palvelukohteet tiedostoon lib/services eikä app/services?
- Onko Service Objects hyvä idea?
- Milloin ei pitäisi käyttää palveluobjekteja?
- Säännöt hyvien palveluobjektien kirjoittamiseen
- Sääntö 1: Vain yksi julkinen metodi per palveluobjekti
- Sääntö 2: Nimeä palvelukohteet kuten tyhmät roolit yrityksessä
- Sääntö 3: Älä luo yleisiä objekteja suorittamaan useita toimintoja
- Sääntö 4: Käsittele poikkeukset palvelukohteen sisällä
Mihin tarvitsen palvelukohteita?
Kokeile tätä: Mitä teet, kun sovelluksesi tarvitsee twiitata tekstiä params
?
Jos olet tähän asti käyttänyt vanilla Railsia, olet luultavasti tehnyt jotain tällaista:
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
Ongelma tässä on se, että olet lisännyt ohjaimeesi ainakin kymmenen riviä, mutta ne eivät oikeastaan kuulu sinne. Entä jos haluaisit käyttää samaa toiminnallisuutta toisessa ohjaimessa? Siirrätkö tämän johonkin huolenaiheeseen? Odota, mutta tämä koodi ei oikeastaan kuulu ollenkaan kontrollereihin. Miksei Twitter-API:n mukana voi tulla vain yksi valmis objekti, jota voin kutsua?
Ensimmäisellä kerralla kun tein tämän, minusta tuntui kuin olisin tehnyt jotain likaista. Aiemmin kauniisti hoikat Rails-ohjaimeni olivat alkaneet lihoa, enkä tiennyt, mitä tehdä. Lopulta korjasin kontrollerini palvelu-objektilla.
Valmistellaanpa ennen kuin alat lukea tätä artikkelia:
- Tämä sovellus käsittelee Twitter-tiliä.
- The Rails Way tarkoittaa “tavanomaista Ruby on Rails -tapaa tehdä asioita” ja kirjaa ei ole olemassa.
- Olen Rails-asiantuntija… mitä minulle sanotaan joka päivä, että olen, mutta minun on vaikea uskoa sitä, joten teeskennellään, että todella olen.
Mitä ovat palveluobjektit?
Palveluobjektit ovat Plain Old Ruby Objects (PORO) -objekteja (Plain Old Ruby Objects, PORO), jotka on suunniteltu suoriutumaan yhden ainoan toiminnon suorittamisesta toimialueen logiikassasi, ja suorittamaan sen hyvin. Mieti yllä olevaa esimerkkiä: Metodissamme on jo logiikka, joka tekee yhden ainoan asian, ja se on twiitin luominen. Entä jos tämä logiikka olisi kapseloitu yhteen ainoaan Ruby-luokkaan, jonka voimme instansoida ja kutsua metodia? Jotain sellaista kuin:
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)
Tämä on aika pitkälti se; kerran luotua TweetCreator
-palveluobjektiamme voidaan kutsua mistä tahansa, ja se tekisi tämän yhden asian erittäin hyvin.
Palveluobjektin luominen
Luotaan ensin uusi TweetCreator
uuteen kansioon nimeltä app/services
:
$ mkdir app/services && touch app/services/tweet_creator.rb
Ja dumpataan kaikki logiikkamme uuden Ruby-luokan sisälle:
# 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
Silloin voit kutsua TweetCreator.new(params).send_tweet
minne tahansa sovelluksessasi, ja se toimii. Rails lataa tämän objektin maagisesti, koska se lataa kaiken automaattisesti alle app/
. Varmista tämä ajamalla:
$ 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
Tahdotko tietää lisää siitä, miten autoload
toimii? Lue Autoloading and Reloading Constants Guide.
Adding Syntactic Sugar to Make Rails Service Objects Suck Less
Katso, tämä tuntuu teoriassa hyvältä, mutta TweetCreator.new(params).send_tweet
on vain suupielessä. Se on aivan liian pitkäveteinen ja sisältää turhia sanoja… aivan kuten HTML (ba-dum tiss!). Vakavasti puhuen, miksi ihmiset käyttävät HTML:ää, kun HAML on olemassa? Tai jopa Slim. Se taitaa olla toinen artikkeli toiseen kertaan. Takaisin käsillä olevaan tehtävään:
TweetCreator
on kiva lyhyt luokkanimi, mutta objektin instanttisointiin ja metodin kutsumiseen liittyvä ylimääräinen roska on aivan liian pitkä! Kunpa Rubyssä olisi etusija sille, että jotain kutsutaan ja se suorittaa itsensä välittömästi annetuilla parametreilla… oi odota, onpas! Se on Proc#call
.
Proccall
kutsuu lohkoa, asettaen lohkon parametrit paramsin arvoihin käyttäen jotain lähellä metodin kutsumisen semantiikkaa. Se palauttaa lohkossa viimeksi evaluoidun lausekkeen arvon.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) #=>
Dokumentaatio
Jos tämä hämmentää sinua, selitän. A proc
voidaan call
-edistää suorittamaan itseään annettujen parametrien avulla. Mikä tarkoittaa, että jos TweetCreator
olisi proc
, voisimme kutsua sitä TweetCreator.call(message)
:llä ja tulos vastaisi TweetCreator.new(params).call
:tä, joka näyttää melko samankaltaiselta kuin raskas vanha TweetCreator.new(params).send_tweet
:mme.
Tehdään siis palvelukohteestamme enemmän proc
:n kaltainen!
Aluksi, koska haluamme luultavasti käyttää tätä käyttäytymistä uudelleen kaikissa palveluobjekteissamme, lainataan Railsin tapaa ja luodaan luokka nimeltä ApplicationService
:
# app/services/application_service.rbclass ApplicationService def self.call(*args, &block) new(*args, &block).call endend
Näitkö mitä tein? Lisäsin luokan metodin nimeltä call
, joka luo uuden instanssin luokasta sille antamillasi argumenteilla tai lohkolla ja kutsuu call
instanssille. Juuri sitä, mitä halusimme! Viimeiseksi nimeämme TweetCreator
-luokkamme metodin uudelleen call
:ksi ja annamme luokan periytyä ApplicationService
:stä:
# 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
Ja lopuksi kääritään tämä loppuun kutsumalla palvelukohteemme ohjaimessa:
class TweetController < ApplicationController def create TweetCreator.call(params) endend
Samankaltaisten palvelukohteiden ryhmittely tervejärkisyydestä
Ylläolevassa esimerkissä on vain yksi palvelukohde, mutta todellisessa maailmassa asiat voivat olla monimutkaisempia. Entä jos sinulla olisi esimerkiksi satoja palveluita, ja puolet niistä olisi toisiinsa liittyviä liiketoimintoja, esimerkiksi Follower
-palvelu, joka seuraa toista Twitter-tiliä? Rehellisesti sanottuna tulisin hulluksi, jos kansiossa olisi 200 yksilöllisen näköistä tiedostoa, joten hyvä, että Rails Wayssä on toinenkin malli, jonka voimme kopioida – siis käyttää inspiraationa: nimienvälitys.
Toteutetaan, että meille on annettu tehtäväksi luoda palvelukohde, joka seuraa muita Twitter-profiileja.
Katsotaanpa edellisen palvelukohteemme nimeä: TweetCreator
. Se kuulostaa henkilöltä tai ainakin roolilta organisaatiossa. Joku, joka luo twiittejä. Haluan nimetä palvelukohteeni ikään kuin ne olisivat juuri sitä: rooleja organisaatiossa. Tätä käytäntöä noudattaen kutsun uutta objektiani seuraavasti: ProfileFollower
.
Nyt, koska olen tämän sovelluksen ylin herra, luon palveluhierarkiassani esimiesaseman ja siirrän vastuun molemmista palveluista tälle asemalle. Kutsun tätä uutta johtavaa asemaa nimellä TwitterManager
.
Koska tämä johtaja ei tee muuta kuin hallinnoi, tehdään siitä moduuli ja sijoitetaan palvelukohteemme tämän moduulin alle. Kansiorakenteemme näyttää nyt seuraavalta:
services├── application_service.rb└── twitter_manager ├── profile_follower.rb └── tweet_creator.rb
Ja palvelukohteemme:
# services/twitter_manager/tweet_creator.rbmodule TwitterManager class TweetCreator < ApplicationService ... endend
# services/twitter_manager/profile_follower.rbmodule TwitterManager class ProfileFollower < ApplicationService ... endend
Ja kutsuistamme tulee nyt TwitterManager::TweetCreator.call(arg)
, ja TwitterManager::ProfileManager.call(arg)
.
Palvelukohteet tietokantaoperaatioiden käsittelyyn
Ylläolevassa esimerkissä tehtiin API-kutsuja, mutta palvelukohteita voidaan käyttää myös silloin, kun kaikki kutsut kohdistuvat tietokantaan API:n sijaan. Tämä on erityisen hyödyllistä, jos jotkin liiketoimintatoimet vaativat useita tietokantapäivityksiä käärittynä transaktioon. Esimerkiksi tässä esimerkkikoodissa käytettäisiin palveluita tallentamaan tapahtuva valuutanvaihto.
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
Mitä palautan palvelukohteeltani?
Olemme keskustelleet siitä, miten call
palvelukohteemme luodaan, mutta mitä kohteen pitäisi palauttaa? On kolme tapaa lähestyä tätä:
- Palauta
true
taifalse
- Palauta arvo
- Palauta Enum
Palauta true
tai false
Tämä on yksinkertainen: Jos toiminto toimii tarkoitetulla tavalla, palauta true
; muussa tapauksessa palauta false
:
def call ... return true if client.update(@message) false end
Palauta arvo
Jos palvelukohteesi noutaa dataa jostain, haluat luultavasti palauttaa kyseisen arvon:
def call ... return false unless exchange_rate exchange_rate end
Respond with an Enum
Jos palvelukohteesi on hieman monimutkaisempi ja haluat käsitellä erilaisia skenaarioita, voit vain lisätä enumeja ohjaamaan palvelujesi kulkua:
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
Ja sitten sovelluksessasi voit käyttää:
case ExchangeRecorder.call when ExchangeRecorder::SUCCESS foo when ExchangeRecorder::FAILURE bar when ExchangeRecorder::PARTIAL_SUCCESS baz end
Eikö minun pitäisi laittaa palvelukohteet tiedostoon lib/services eikä app/services?
Tämä on subjektiivista. Ihmisten mielipiteet eroavat toisistaan siitä, mihin palveluobjekteja kannattaa laittaa. Jotkut laittavat ne paikkaan lib/services
, kun taas jotkut luovat app/services
. Minä kuulun jälkimmäiseen leiriin. Railsin Getting Started Guide kuvaa kansiota lib/
paikaksi, johon laitetaan “sovelluksen laajennetut moduulit.”
Minun vaatimattomassa mielipiteessäni “laajennetut moduulit” tarkoittavat moduuleja, jotka eivät kapseloi keskeistä toimialueen logiikkaa ja joita voidaan yleensä käyttää eri projekteissa. Satunnaisen Stack Overflow -vastauksen viisaiden sanojen mukaan, laita sinne koodia, josta “voi potentiaalisesti tulla oma helmi.”
Onko Service Objects hyvä idea?
Se riippuu käyttötapauksesta. Katso – se, että luet tätä artikkelia juuri nyt, viittaa siihen, että yrität kirjoittaa koodia, joka ei varsinaisesti kuulu malliin tai kontrolleriin. Luin hiljattain tämän artikkelin siitä, miten palveluobjektit ovat anti-kuvio. Kirjoittajalla on mielipiteensä, mutta minä olen kunnioittavasti eri mieltä.
Johtuen siitä, että joku muu henkilö käytti palveluobjekteja liikaa, se ei tarkoita, että ne ovat luonnostaan huonoja. Minun startupissani, Nazdeeqissa, käytämme palveluobjekteja sekä muita kuin ActiveRecord-malleja. Mutta ero sen välillä, mikä menee minne, on aina ollut minulle selvä: Pidän kaikki liiketoimintatoimet palveluobjekteissa ja resurssit, jotka eivät oikeastaan tarvitse pysyvyyttä, muissa kuin ActiveRecord-malleissa. Loppujen lopuksi on sinun tehtäväsi päättää, mikä malli on sinulle hyvä.
Mutta pidänkö palveluobjekteja yleisesti ottaen hyvänä ideana? Ehdottomasti! Ne pitävät koodini siististi järjestyksessä, ja se, mikä saa minut luottamaan POROjen käyttööni, on se, että Ruby rakastaa objekteja. Ei, vakavasti, Ruby rakastaa objekteja. Se on mieletöntä, täysin hullua, mutta minä rakastan sitä! Esimerkkitapaus:
> 5.is_a? Object # => true > 5.class # => Integer > class Integer?> def woot?> 'woot woot'?> end?> end # => :woot > 5.woot # => "woot woot"
See? 5
on kirjaimellisesti objekti.
Monissa kielissä numerot ja muut primitiiviset tyypit eivät ole objekteja. Ruby seuraa Smalltalk-kielen vaikutusta antamalla metodeja ja instanssimuuttujia kaikille tyypeilleen. Tämä helpottaa Rubyn käyttöä, koska objekteihin sovellettavat säännöt koskevat koko Rubya.Ruby-lang.org
Milloin ei pitäisi käyttää palveluobjekteja?
Tämä on helppoa. Minulla on nämä säännöt:
- Käsitteleekö koodisi reititystä, parametreja tai tekeekö se muita kontrollerin kaltaisia asioita?
Jos näin on, älä käytä palveluobjektia – koodisi kuuluu kontrolleriin. - Yritätkö jakaa koodisi eri kontrollereissa?
Tällöin älä käytä palveluobjekti-käytä huolenaihetta. - Onko koodisi mallin kaltaista, joka ei tarvitse pysyvyyttä?
Jos näin on, älä käytä palveluobjektia. Käytä sen sijaan muuta kuin ActiveRecord-mallia. - Onko koodisi tietty liiketoimintatoimi? (esim. “Vie roskat ulos”, “Luo PDF-tiedosto käyttäen tätä tekstiä” tai “Laske tullimaksu käyttämällä näitä monimutkaisia sääntöjä”)
Tässä tapauksessa käytä palveluobjektia. Tuo koodi ei luultavasti sovi loogisesti sen enempää controlleriin kuin malliinkaan.
Nämä ovat tietysti minun sääntöjäni, joten voit mieluusti mukauttaa niitä omiin käyttötapauksiisi. Minulle nämä ovat toimineet erittäin hyvin, mutta sinun mittarisi voivat vaihdella.
Säännöt hyvien palveluobjektien kirjoittamiseen
Minulla on neljä sääntöä palveluobjektien luomiseen. Nämä eivät ole kiveen hakattuja, ja jos todella haluat rikkoa niitä, voit rikkoa niitä, mutta todennäköisesti pyydän sinua muuttamaan niitä koodikatselmuksissa, ellei perustelusi ole hyvä.
Sääntö 1: Vain yksi julkinen metodi per palveluobjekti
Palveluobjektit ovat yksittäisiä liiketoimintatoimintoja. Voit halutessasi muuttaa julkisen metodisi nimen. Itse käytän mieluiten call
, mutta Gitlab CE:n koodipohja kutsuu sitä execute
ja muut saattavat käyttää perform
. Käytä mitä haluat – voit kutsua sitä vaikka nermin
. Älä vain luo kahta julkista metodia yhdelle palvelukohteelle. Jaa se tarvittaessa kahteen objektiin.
Sääntö 2: Nimeä palvelukohteet kuten tyhmät roolit yrityksessä
Palvelukohteet ovat yksittäisiä liiketoimintatoimia. Kuvittele, että jos palkkaisit yrityksessä yhden henkilön tekemään tätä yhtä työtä, miksi kutsuisit häntä? Jos hänen tehtävänsä on luoda twiittejä, kutsu häntä TweetCreator
. Jos hänen tehtävänsä on lukea tiettyjä twiittejä, kutsu häntä TweetReader
.
Sääntö 3: Älä luo yleisiä objekteja suorittamaan useita toimintoja
Palvelukohteet ovat yksittäisiä liiketoimintatoimia. Jaoin toiminnallisuuden kahteen osaan: TweetReader
ja ProfileFollower
. En luonut yhtä ainoaa geneeristä objektia nimeltä TwitterHandler
ja dumppasin kaiken API-toiminnallisuuden sinne. Älä tee näin. Tämä on vastoin “business action” -ajattelutapaa ja tekee palvelukohteesta Twitter-keijun näköisen. Jos haluat jakaa koodia liiketoimintaobjektien kesken, luo vain BaseTwitterManager
-objekti tai -moduuli ja sekoita se palvelukohteisiisi.
Sääntö 4: Käsittele poikkeukset palvelukohteen sisällä
Viimeisen kerran: Palveluobjektit ovat yksittäisiä liiketoimintatoimia. En voi sanoa tätä tarpeeksi usein. Jos sinulla on henkilö, joka lukee twiittejä, hän joko antaa sinulle twiitin tai sanoo: “Tätä twiittiä ei ole olemassa”. Vastaavasti älä anna palvelukohteesi panikoida, hypätä ohjaimesi pöydälle ja käskeä sitä pysäyttämään kaikki työt, koska “Virhe!”. Palauta vain false
ja anna kontrollerin jatkaa siitä eteenpäin.
Tämä artikkeli ei olisi ollut mahdollinen ilman Toptalin mahtavaa Ruby-kehittäjien yhteisöä. Jos joskus törmään ongelmaan, yhteisö on auttavaisin ryhmä lahjakkaita insinöörejä, joita olen koskaan tavannut.
Jos käytät palveluobjekteja, saatat miettiä, miten pakottaa tietyt vastaukset testauksen aikana. Suosittelen lukemaan tämän artikkelin siitä, miten luoda Rspecissä mock-palveluobjekteja, jotka palauttavat aina haluamasi tuloksen ilman, että osut itse palveluobjektiin!
Jos haluat oppia lisää Ruby-temppuja, suosittelen kollega Toptalerin Máté Solymosin kirjoittamaa teosta Creating a Ruby DSL: A Guide to Advanced Metaprogramming. Hän selvittää, miten routes.rb
-tiedosto ei tunnu Rubylta ja auttaa sinua rakentamaan oman DSL:n.