Rails-palvelukohteet: A Comprehensive Guide

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.

Esimerkki pyyntösyklistä Rails-palveluobjekteilla

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?

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 tai false
  • 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:

  1. Käsitteleekö koodisi reititystä, parametreja tai tekeekö se muita kontrollerin kaltaisia asioita?
    Jos näin on, älä käytä palveluobjektia – koodisi kuuluu kontrolleriin.
  2. Yritätkö jakaa koodisi eri kontrollereissa?
    Tällöin älä käytä palveluobjekti-käytä huolenaihetta.
  3. Onko koodisi mallin kaltaista, joka ei tarvitse pysyvyyttä?
    Jos näin on, älä käytä palveluobjektia. Käytä sen sijaan muuta kuin ActiveRecord-mallia.
  4. 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.

Vastaa

Sähköpostiosoitettasi ei julkaista.