Serviceobjekt med spår med Aldous

Ett av de begrepp vi har haft stor framgång med i Tuts + -teamet är tjänsteobjekt. Vi har använt serviceobjekt för att minska kopplingen i våra system, göra dem mer testbara och göra viktig affärslogik mer uppenbar för alla utvecklare på laget. 

Så när vi bestämde oss för att kodifiera några av de begrepp som vi har använt i vår Rails-utveckling till en Ruby-pärla (kallad Aldous), var serviceobjekten överst på listan.

Vad jag skulle vilja göra idag är att ge en snabb översikt över serviceobjekt som vi har implementerat dem i Aldous. Förhoppningsvis kommer detta att berätta för dig de flesta saker du behöver veta för att kunna använda Aldous serviceobjekt i dina egna projekt.

Anatomin för ett Basic Service Object

Foto av Dennis Skley

Ett serviceobjekt är i grunden en metod som är inslaget i ett objekt. Ibland kan ett serviceobjekt innehålla flera metoder, men den enklaste versionen är bara en klass med en metod, t.ex.

klass DoSomething def utför # gör slutänden på saker

Vi brukar använda substantiv för att namnge våra objekt, men ibland kan det vara svårt att hitta ett bra substantiv för att representera ett koncept, medan det är enkelt och naturligt att prata om det i form av en handling (eller verb). Ett serviceobjekt är vad vi får när vi går med flödet och bara sätter verbet i ett objekt.

Givet ovanstående definition kan vi naturligtvis vända varje åtgärd / metod till ett serviceobjekt om vi så önskar. Det följande…

klass Kund def createPurchase (order) # gör slutänden på saker

... kan omvandlas till:

klass CreateCustomerPurchase def initiera (kund, order) slut def utföra # göra slutänden på saker

Vi kan skriva flera andra inlägg om hur effekten tjänsten objekt kan ha på utformningen av ditt system, de olika avvägningarna du ska göra, etc. För nu låt oss bara vara medvetna om dem som koncept och betrakta dem bara ett annat verktyg vi har i vårt arsenal.

Varför använda serviceobjekt i rader

Eftersom Rails-program blir större, tenderar våra modeller att bli ganska stora, och vi letar efter sätt att driva lite funktionalitet ut av dem till "hjälpar" -objekt. Men det här är ofta lättare sagt än gjort. Rails har inte ett koncept, i modellskiktet, vilket är mer granulärt än en modell. Så du slutar behöva göra många domssamtal:

  • Skapar du en PORO-modell eller skapar du en klass i lib mapp?
  • Vilka metoder flyttar du in i den här klassen??
  • Hur heter du förnuftigt den här klassen med de metoder vi har flyttat till den? 

Du behöver nu kommunicera vad du har gjort för de andra utvecklarna på ditt lag och till nya personer som går med senare. Och naturligtvis, inför en liknande situation, kan andra utvecklare göra olika domsamtal, vilket leder till inkonsekvenser som kryper in.

Serviceobjekt ger oss ett koncept som är mer granulärt än en modell. Vi kan ha en konsekvent plats för alla våra tjänster och du flyttar bara en metod till en tjänst. Du namnger den här klassen efter den åtgärd / metod som den kommer att representera. Vi kan extrahera funktionalitet till mer granulerade objekt utan att för många domssamtal, vilket håller hela laget på samma sida, så att vi kan fortsätta med att bygga en bra applikation. 

Användning av serviceobjekt minskar kopplingen mellan dina Rails-modeller och de resulterande tjänsterna är mycket återanvändbara på grund av deras lilla storlek / ljusfotavtryck. 

Serviceobjekt är också mycket testbara, eftersom de vanligtvis inte kräver så mycket testpanna som fler tunga föremål, och du oroar dig bara för att testa den metod som objektet innehåller. 

Både serviceobjekten och deras tester är lätta att läsa / förstå eftersom de är mycket sammanhängande (även en bieffekt av deras lilla storlek). Du kan också kassera och skriva om båda serviceobjekten och deras test nästan i vila, eftersom kostnaden för att göra det är relativt lågt och det är mycket enkelt att behålla gränssnittet.

Serviceobjekt har definitivt mycket för dem, speciellt när du introducerar dem i dina Rails-appar. 

Serviceobjekt med Aldous

Foto av Trevor Leyenhorst

Med tanke på att serviceobjekt är så enkla, varför behöver vi till och med en pärla? Varför inte bara skapa POROs, och då behöver du inte oroa dig för ett annat beroende? 

Det kan du definitivt göra, och faktiskt gjorde vi det ganska länge i Tuts +, men genom en omfattande användning slutade vi utveckla några mönster för tjänster som gjort våra liv bara så lite enklare, och det är precis vad vi har tryckt in i Aldous. Dessa mönster är lätta och involverar inte mycket magi. De gör våra liv lite enklare, men vi behåller all kontroll om vi behöver det.

Var de borde leva

Första saker först, var ska dina tjänster lever? Vi brukar lägga dem in app / tjänster, så du behöver följande i din app / config / application.rb:

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

Vad de ska kallas

Som jag har nämnt ovan brukar vi namnge serviceobjekt efter handlingar / verb (t.ex.. Skapa användare, RefundPurchase), men vi brukar också lägga till "service" på alla klassnamn (t.ex.. CreateUserService, RefundPurchaseService). På det här sättet, oavsett vilket sammanhang du befinner dig i (titta på filerna på filsystemet, titta på en serviceklass någonstans i kodbasen) vet du alltid att du har ett serviceobjekt.

Detta verkställs inte av pärlan på något sätt, men värt att ta hänsyn till som en lärdom.

Serviceobjekt är omöjliga

När vi säger oföränderlig menar vi att efter att objektet initialiserats, kommer det inre tillståndet inte längre att förändras. Detta är jättebra eftersom det gör det mycket enklare att motivera varje objekts tillstånd såväl som systemet som helhet.

För att ovanstående kan vara sant kan tjänsteobjektmetoden inte ändra objektets tillstånd, så någon data måste returneras som en utgång från metoden. Detta är svårt att genomdriva direkt, eftersom ett objekt alltid kommer att ha tillgång till sitt eget interna tillstånd. Med Aldous försöker vi genomdriva det via konvention och utbildning, och i de följande två avsnitten kommer du att visa hur.

Representerar framgång och misslyckande

Ett Aldous serviceobjekt måste alltid returnera en av två typer av objekt:

  • Aldous :: service :: Result :: Framgång
  • Aldous :: service :: Result :: Fel

Här är ett exempel:

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

Eftersom vi ärva från Aldous :: service, vi kan konstruera våra returobjekt som Result :: Framgång. Genom att använda dessa objekt som returvärden kan vi göra saker som:

hash =  result = SkapaUserService.perform (hash) om result.success? # gör framgångssaker annars # result.failure? # misslyckas saker slut

Vi kan i teorin bara återvända sant eller falskt och få samma beteende som vi har ovan, men om vi gjorde det kunde vi inte bära några extra data med vårt returvärde och vi vill ofta bära data.

Använda DTOs

En operation / tjänstens framgång eller misslyckande är bara en del av berättelsen. Ofta kommer vi ha skapat ett objekt som vi vill återvända till, eller producerade några fel som vi vill meddela uppringningskoden till. Det är därför som återvändande objekt, som vi har visat ovan, är användbara. Dessa objekt används inte bara för att indikera framgång eller misslyckande, de är också dataöverföringsobjekt.

Aldous tillåter dig att åsidosätta en metod i basserviceklassen, för att ange en uppsättning standardvärden som objekt som returneras från tjänsten skulle innehålla, t.ex.

klass 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

Hacknycklarna i default_result_data kommer automatiskt att bli metoder på Result :: Framgång och Result :: Fel objekt som returneras av tjänsten. Och om du anger ett annat värde för en av tangenterna i den metoden kommer den att åsidosätta standardvärdet. Så i fallet med ovanstående klass:

hash =  result = SkapaUserService.perform (hash) om result.success? result.user # blir en förekomst av användarresultat.blah # skulle höja ett annat fel # result.failure? result.user # blir noll result.blah # skulle höja en feländning

I själva verket har hashnycklarna i default_result_data Metod är ett kontrakt för användarna av tjänsteobjektet. Vi garanterar att du skulle kunna ringa någon nyckel i den hash som en metod på ett resultatobjekt som kommer ut ur tjänsten.

Felfria API: er

Bild av Roberto Zingales

När vi pratar om felfria API-filer menar vi metoder som aldrig ger upphov till fel, men returnerar alltid ett värde för att indikera framgång eller misslyckande. Jag har skrivit om felfria API tidigare. Aldous-tjänster är felfri beroende på hur du ringer dem. I exemplet ovan: 

result = CreateUserService.perform (hash)

Detta kommer aldrig att orsaka ett fel. Innehåller Aldous din prestationsmetod i en rädda blockera och om din kod ger upphov till ett fel kommer det att returnera en Result :: Fel med default_result_data som data. 

Det här är ganska befriande eftersom du inte längre behöver tänka på vad som kan gå fel med koden du har skrivit. Du är bara intresserad av att din tjänst har blivit framgångsrik eller misslyckad, och eventuella fel kommer att leda till ett misslyckande. 

Detta är bra för de flesta situationer. Men ibland vill du skapa ett fel. Det bästa exemplet på detta är när du använder ett serviceobjekt i en bakgrundsarbetare och ett fel skulle orsaka att bakgrundsarbetaren försöker igen. Därför får en Aldous-tjänst också magiskt en utföra! metod och låter dig överväga en annan metod från basklassen. Här är vårt exempel igen:

klass 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

Som ni kan se har vi nu överskridit raisable_error metod. Vi vill ibland att ett fel ska produceras, men vi vill inte att det ska vara någon typ av fel. Annars måste vår uppringningskod bli medveten om alla möjliga fel som tjänsten kan producera eller vara tvungen att fånga en av basfeltyperna. Det är därför som du använder utföra! Metod, Aldous kommer fortfarande fånga alla fel för dig, men kommer sedan höja igen raisable_error du har angett och ställt in originalfelet som orsak. Du kan nu få det här:

hash =  start service = CreateUserService.build (hash) result = service.perform! räddningstjänst.raisable_error => e # felstopp slutar

Testa Aldous Service Objects

Du kanske har märkt användningen av fabriksmetoden:

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

Du ska alltid använda dessa och konstruera aldrig serviceobjekt direkt. Fabriksmetoderna är det som gör det möjligt för oss att rent krok i de fina funktionerna som automatisk räddning och lägga till default_result_data.

Men när det gäller test, vill du inte behöva oroa dig för hur Aldous förstärker funktionaliteten för dina serviceobjekt. Så, när du testar, konstruerar du bara objekten direkt med konstruktören och testar sedan din funktionalitet. Du kommer att få specifikationer för den logik du skrev och litar på att Aldous ska göra vad den ska göra (Aldous har egna test för detta) när det gäller produktion.

Slutsats

Förhoppningsvis har det gett dig en uppfattning om hur serviceobjekt (och särskilt Aldous serviceobjekt) kan vara ett bra verktyg i din arsenal när du arbetar med Ruby / Rails. Ge Aldous ett försök och låt oss veta vad du tycker. Titta gärna på Aldous-koden. Vi skrev inte bara den för att vara användbar, men också att vara läsbar och lätt att förstå / ändra.