Service-objecten met rails met Aldous

Een van de concepten waar we in het Tuts + -team veel succes mee hebben gehad, zijn service-objecten. We hebben servicevoorwerpen gebruikt om koppelingen in onze systemen te verminderen, ze meer testbaar te maken en belangrijke bedrijfslogica duidelijker te maken voor alle ontwikkelaars in het team. 

Dus toen we besloten om enkele van de concepten die we in onze Rails-ontwikkeling gebruikten te coderen in een Ruby-edelsteen (Aldous genaamd), waren service-objecten bovenaan de lijst.

Wat ik vandaag zou willen doen, is een snel overzicht geven van service-objecten zoals we deze in Aldous hebben geïmplementeerd. Hopelijk zal dit u de meeste dingen vertellen die u moet weten om Aldous-serviceobjecten in uw eigen projecten te gebruiken.

De anatomie van een basisdienstobject

Foto door Dennis Skley

Een serviceobject is in feite een methode die in een object is ingepakt. Soms kan een serviceobject verschillende methoden bevatten, maar de eenvoudigste versie is slechts een klasse met één methode, bijvoorbeeld:

class DoSomething def perform # do stuff end end

We zijn allemaal gewend aan het gebruik van zelfstandige naamwoorden om onze objecten te benoemen, maar soms kan het moeilijk zijn om een ​​goed zelfstandig naamwoord te vinden om een ​​concept te vertegenwoordigen, terwijl praten over het in termen van een actie (of werkwoord) eenvoudig en natuurlijk is. Een service-object is wat we krijgen als we 'meegaan met de stroom' en gewoon het werkwoord in een object veranderen.

Natuurlijk kunnen we, gegeven de bovenstaande definitie, elke actie / methode in een service-object veranderen als we dat willen. Het volgende…

klasse Customer def createPurchase (order) # doe het einde van het spul

... kan worden omgezet in:

klasse CreateCustomerPurchase def initialize (klant, order) einde def voer # do stuff end end

We kunnen verschillende andere berichten schrijven over het effect dat serviceobjecten kunnen hebben op het ontwerp van uw systeem, de verschillende compromissen die u zult maken, enz. Laten we ons er nu van bewust zijn als een concept en beschouwen ze als gewoon een ander hulpmiddel we hebben in ons arsenaal.

Waarom Service-objecten gebruiken in rails

Naarmate Rails-apps groter worden, neigen onze modellen behoorlijk groot te worden, en dus zoeken we naar manieren om bepaalde functionaliteit uit hen te verwijderen in 'hulp' -objecten. Maar dit is vaak makkelijker gezegd dan gedaan. Rails heeft geen concept in de modellaag, dat korreliger is dan een model. Dus je moet heel veel oordeelsoproepen doen:

  • Maak je een PORO-model of maak je een klasse in de lib map?
  • Met welke methoden ga je naar deze klas?
  • Hoe noem je deze klas verstandig, gezien de methoden die we erin hebben opgenomen? 

Je moet nu communiceren met wat je hebt gedaan met de andere ontwikkelaars in je team en met nieuwe mensen die later lid worden. En natuurlijk, geconfronteerd met een vergelijkbare situatie, kunnen andere ontwikkelaars verschillende oordeelsoproepen maken, wat leidt tot inconsistenties die binnensluipen.

Servicevoorwerpen geven ons een concept dat gedetailleerder is dan een model. We kunnen een consistente locatie hebben voor al onze services en u kunt slechts één methode naar een service verplaatsen. U noemt deze klasse na de actie / methode die deze zal vertegenwoordigen. We kunnen functionaliteit uitpakken in meer korrelige objecten zonder al te veel oordeelsvragen, waardoor het hele team op dezelfde pagina blijft, zodat we verder kunnen met het bouwen van een geweldige applicatie. 

Het gebruik van service-objecten vermindert de koppeling tussen uw Rails-modellen en de resulterende services zijn zeer herbruikbaar vanwege hun kleine formaat / lichte voetafdruk. 

Servicevoorwerpen zijn ook zeer testbaar, omdat ze meestal niet zoveel test-boilerplate nodig hebben als zwaardere objecten en je je alleen zorgen hoeft te maken over het testen van de enige methode die het object bevat. 

Zowel de service-objecten als hun tests zijn gemakkelijk te lezen en te begrijpen, omdat ze zeer coherent zijn (ook een neveneffect van hun kleine formaat). Je kunt ook beide servicevoorwerpen en hun tests bijna willekeurig weggooien en herschrijven, omdat de kosten hiervoor relatief laag zijn en het zeer eenvoudig is om hun interface te onderhouden.

Servicevoorwerpen hebben zeker veel te bieden, vooral als je ze introduceert in je Rails-apps. 

Service-objecten met Aldous

Foto door Trevor Leyenhorst

Aangezien service-objecten zo eenvoudig zijn, waarom hebben we dan zelfs een edelsteen nodig? Waarom niet gewoon PORO's maken, en dan hoeft u zich geen zorgen te maken over een andere afhankelijkheid? 

Je zou dat zeker kunnen doen, en in feite deden we dit al een tijdje in Tuts +, maar door uitgebreid gebruik ontwikkelden we een paar patronen voor diensten die ons leven net iets eenvoudiger maakten, en dit is precies wat we hebben geduwd in Aldous. Deze patronen zijn licht en bevatten niet veel magie. Ze maken ons leven een beetje eenvoudiger, maar we behouden alle controle als we het nodig hebben.

Waar ze zouden moeten leven

Eerste dingen eerst, waar zouden uw diensten moeten leven? We hebben de neiging ze in te voeren app / services, dus je hebt het volgende nodig in je app / config / application.rb:

config.autoload_paths + =% W (# config.root / app / services) config.eager_load_paths + =% W (# config.root / app / services)

Wat ze moeten worden genoemd

Zoals ik hierboven al heb genoemd, neigen we ernaar servicedoelwerken een naam te geven na acties / werkwoorden (bijv. CreateUser, RefundPurchase), maar we hebben ook de neiging om 'service' toe te voegen aan alle klassenamen (bijv. CreateUserService, RefundPurchaseService). Op deze manier maakt het niet uit in welke context je zit (kijkend naar de bestanden op het bestandssysteem, kijkend naar een serviceklasse ergens in de codebase) weet je altijd dat je te maken hebt met een serviceobject.

Dit juweel wordt op geen enkele manier afgedwongen door het juweel, maar het is de moeite waard om als een les te beschouwen.

Service-objecten zijn onveranderlijk

Als we onveranderlijk zeggen, bedoelen we dat nadat het object is geïnitialiseerd, de interne status niet langer zal veranderen. Dit is echt geweldig omdat het het veel eenvoudiger maakt om te redeneren over de status van elk object en het systeem als geheel.

Om ervoor te zorgen dat het bovenstaande waar is, kan de serviceobjectmethode de status van het object niet wijzigen, dus alle gegevens moeten worden geretourneerd als een uitvoer van de methode. Dit is moeilijk direct af te dwingen, omdat een object altijd toegang heeft tot zijn eigen interne staat. Met Aldous proberen we het via conventie en onderwijs af te dwingen, en de volgende twee secties zullen je laten zien hoe.

Vertegenwoordigen van succes en falen

Een Aldous-serviceobject moet altijd twee typen objecten retourneren:

  • Aldous :: service :: Resultaat :: Succes
  • Aldous :: service :: Resultaat :: Failure

Hier is een voorbeeld:

class CreateUserService < Aldous::Service def perform user = User.new(user_data_hash) if user.save Result::Success.new else Result::Failure.new end end end

Omdat we erven van Aldous :: Dienst, we kunnen onze terugkeerobjecten construeren als Resultaat :: Succes. Als we deze objecten gebruiken als retourwaarden, kunnen we dingen doen als:

hash =  result = CreateUserService.perform (hash) als result.success? # doe succes anders # resultaat.failure? # einde falen dingen

We zouden in theorie gewoon waar of onwaar kunnen retourneren en hetzelfde gedrag krijgen als we hierboven hebben, maar als we dat deden, konden we geen extra gegevens meenemen met onze retourwaarde, en we willen vaak gegevens meenemen.

DTO's gebruiken

Het succes of falen van een operatie / service is slechts een deel van het verhaal. Vaak hebben we een object gemaakt dat we willen retourneren of hebben we enkele fouten gemaakt waarvan we de aanroepcode willen melden. Dit is de reden waarom het retourneren van objecten nuttig is, zoals we hierboven hebben laten zien. Deze objecten worden niet alleen gebruikt om succes of mislukking aan te duiden, het zijn ook objecten voor gegevensoverdracht.

Met Aldous kunt u een methode in de basisserviceklasse overschrijven om een ​​set standaardwaarden op te geven die door de service geretourneerde objecten bevatten, bijvoorbeeld:

class CreateUserService < Aldous::Service attr_reader :user_data_hash def initialize(user_data_hash) @user_data_hash = user_data_hash end def default_result_data user: nil end def perform user = User.new(user_data_hash) if user.save Result::Success.new(user: user) else Result::Failure.new end end end

De hash-sleutels in default_result_data worden automatisch methoden op de Resultaat :: Succes en Resultaat :: Failure objecten geretourneerd door de service. En als u een andere waarde opgeeft voor een van de sleutels in die methode, zal deze de standaardwaarde overschrijven. Dus in het geval van de bovenstaande klasse:

hash =  result = CreateUserService.perform (hash) als result.success? result.user # is een instantie van Gebruiker result.blah # zou een fout veroorzaken anders # result.failure? result.user # is nul. BL # heeft een foutmelding opgeleverd

In feite zijn de hekjes in de default_result_data methode zijn een contract voor de gebruikers van het serviceobject. We garanderen dat u elke sleutel in die hash kunt gebruiken als methode voor elk resultaatobject dat uit de service komt.

Foutloze API's

Afbeelding door Roberto Zingales

Wanneer we het hebben over foutloze API's, bedoelen we methoden die nooit fouten veroorzaken, maar altijd een waarde retourneren om succes of mislukking aan te geven. Ik heb eerder geschreven over foutloze API's. Aldous-services zijn foutloos, afhankelijk van hoe u ze belt. In het bovenstaande voorbeeld: 

result = CreateUserService.perform (hash)

Dit zal nooit een fout veroorzaken. Intern neemt Aldous je perform-methode op in a redden blokkeren en als uw code een foutmelding genereert, wordt a geretourneerd Resultaat :: Failure met de default_result_data als gegevens. 

Dit is behoorlijk bevrijdend, omdat je niet langer hoeft na te denken over wat er mis kan gaan met de code die je hebt geschreven. U bent alleen geïnteresseerd in het succes of falen van uw service en elke fout resulteert in een fout. 

Dit is geweldig voor de meeste situaties. Maar soms wilt u een fout gegenereerd. Het beste voorbeeld hiervan is wanneer u een serviceobject in een achtergrondwerker gebruikt en een fout ertoe leidt dat de achtergrondwerker het opnieuw probeert. Daarom krijgt een Aldous-service ook op magische wijze een uitvoeren! methode en kunt u een andere methode uit de basisklasse overschrijven. Dit is weer ons voorbeeld:

class CreateUserService < Aldous::Service attr_reader :user_data_hash def initialize(user_data_hash) @user_data_hash = user_data_hash end def raisable_error MyApplication::Errors::UserError end def default_result_data user: nil end def perform user = User.new(user_data_hash) if user.save Result::Success.new(user: user) else Result::Failure.new end end end

Zoals u kunt zien, hebben we nu de raisable_error methode. Soms willen we dat er een fout wordt gemaakt, maar we willen ook niet dat er een fout optreedt. Anders moet onze belcode zich bewust zijn van elke mogelijke fout die de service kan veroorzaken, of gedwongen worden om een ​​van de basisfouttypen te vangen. Dit is de reden waarom wanneer u de uitvoeren! methode, Aldous zal nog steeds alle fouten voor je vangen, maar zal dan de raisable_error u hebt opgegeven en de oorspronkelijke fout als oorzaak ingesteld. Je zou nu dit kunnen hebben:

hash =  begin service = CreateUserService.build (hash) result = service.perform! rescue service.raisable_error => e # fout spul einde

Aldous-serviceobjecten testen

U hebt misschien het gebruik van de fabrieksmethode opgemerkt:

CreateUserService.build (hash) CreateUserService.perform (hash)

U zou deze altijd moeten gebruiken en nooit servicedoeleinden direct bouwen. Met de fabrieksmethoden kunnen we de aardige functies zoals automatische redding en het toevoegen van de default_result_data.

Als het echter om testen gaat, hoeft u zich geen zorgen te maken over hoe Aldous de functionaliteit van uw service-objecten vergroot. Test dus eenvoudig de objecten met behulp van de constructor en test vervolgens uw functionaliteit. Je krijgt specificaties voor de logica die je hebt geschreven en vertrouwt erop dat Aldous zal doen wat het zou moeten doen (Aldous heeft hier zijn eigen tests voor) als het gaat om productie.

Conclusie

Hopelijk heeft dit je een idee gegeven van hoe serviceobjecten (en vooral Aldous service-objecten) een mooi hulpmiddel kunnen zijn in je arsenaal wanneer je met Ruby / Rails werkt. Geef Aldous een kans en laat ons weten wat je ervan vindt. Kijk ook eens naar de Aldous-code. We hebben het niet alleen geschreven om nuttig te zijn, maar ook om leesbaar en gemakkelijk te begrijpen / te wijzigen te zijn.