Rails-Serviceobjekte: Ein umfassender Leitfaden

Veröffentlicht: 2022-03-11

Ruby on Rails wird mit allem geliefert, was Sie brauchen, um Ihre Anwendung schnell zu prototypisieren, aber wenn Ihre Codebasis zu wachsen beginnt, werden Sie auf Szenarien stoßen, in denen das herkömmliche „Fat Model, Skinny Controller“-Mantra bricht. Wenn Ihre Geschäftslogik weder in ein Modell noch in einen Controller passt, kommen Serviceobjekte ins Spiel und lassen uns jede Geschäftsaktion in ein eigenes Ruby-Objekt unterteilen.

Ein Beispiel für einen Anforderungszyklus mit Rails-Dienstobjekten

In diesem Artikel erkläre ich, wann ein Dienstobjekt erforderlich ist; wie man saubere Service-Objekte schreibt und sie gruppiert, um die Vernunft der Mitwirkenden zu gewährleisten; die strengen Regeln, die ich meinen Dienstobjekten auferlege, um sie direkt mit meiner Geschäftslogik zu verknüpfen; und wie Sie Ihre Dienstobjekte nicht in eine Müllhalde für all den Code verwandeln, mit dem Sie nichts anzufangen wissen.

Warum brauche ich Serviceobjekte?

Versuchen Sie Folgendes: Was tun Sie, wenn Ihre Anwendung den Text von params[:message] twittern muss?

Wenn Sie bisher Vanilla Rails verwendet haben, haben Sie wahrscheinlich so etwas getan:

 class TweetController < ApplicationController def create send_tweet(params[:message]) end private def send_tweet(tweet) client = Twitter::REST::Client.new do |config| config.consumer_key = ENV['TWITTER_CONSUMER_KEY'] config.consumer_secret = ENV['TWITTER_CONSUMER_SECRET'] config.access_token = ENV['TWITTER_ACCESS_TOKEN'] config.access_token_secret = ENV['TWITTER_ACCESS_SECRET'] end client.update(tweet) end end

Das Problem hier ist, dass Sie Ihrem Controller mindestens zehn Zeilen hinzugefügt haben, aber sie gehören nicht wirklich dorthin. Und was wäre, wenn Sie die gleiche Funktionalität in einem anderen Controller verwenden wollten? Verschieben Sie dies zu einem Anliegen? Warten Sie, aber dieser Code gehört überhaupt nicht in Controller. Warum kann die Twitter-API nicht einfach ein einzelnes vorbereitetes Objekt enthalten, das ich aufrufen kann?

Als ich das das erste Mal tat, hatte ich das Gefühl, etwas Schmutziges getan zu haben. Meine zuvor so schön mageren Rails-Controller wurden langsam dick und ich wusste nicht, was ich tun sollte. Schließlich habe ich meinen Controller mit einem Dienstobjekt repariert.

Bevor Sie mit dem Lesen dieses Artikels beginnen, stellen wir uns vor:

  • Diese Anwendung verwaltet ein Twitter-Konto.
  • The Rails Way bedeutet „die herkömmliche Vorgehensweise von Ruby on Rails“, und das Buch existiert nicht.
  • Ich bin ein Rails-Experte … was mir jeden Tag gesagt wird, aber ich habe Schwierigkeiten, es zu glauben, also lass uns einfach so tun, als wäre ich wirklich einer.

Was sind Serviceobjekte?

Service-Objekte sind Plain Old Ruby Objects (PORO), die darauf ausgelegt sind, eine einzelne Aktion in Ihrer Domänenlogik auszuführen, und zwar gut. Betrachten Sie das obige Beispiel: Unsere Methode hat bereits die Logik, um eine einzige Sache zu tun, und das ist, einen Tweet zu erstellen. Was wäre, wenn diese Logik in einer einzigen Ruby-Klasse gekapselt wäre, die wir instanziieren und für die wir eine Methode aufrufen können? Etwas wie:

 tweet_creator = TweetCreator.new(params[:message]) tweet_creator.send_tweet # Later on in the article, we'll add syntactic sugar and shorten the above to: TweetCreator.call(params[:message])

Das ist so ziemlich alles; unser TweetCreator kann, sobald es einmal erstellt ist, von überall aus aufgerufen werden, und es würde diese eine Sache sehr gut machen.

Erstellen eines Dienstobjekts

Lassen Sie uns zuerst einen neuen TweetCreator in einem neuen Ordner namens app/services erstellen:

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

Und lassen Sie uns einfach unsere gesamte Logik in einer neuen Ruby-Klasse ablegen:

 # app/services/tweet_creator.rb class TweetCreator def initialize(message) @message = message end def send_tweet client = Twitter::REST::Client.new do |config| config.consumer_key = ENV['TWITTER_CONSUMER_KEY'] config.consumer_secret = ENV['TWITTER_CONSUMER_SECRET'] config.access_token = ENV['TWITTER_ACCESS_TOKEN'] config.access_token_secret = ENV['TWITTER_ACCESS_SECRET'] end client.update(@message) end end

Dann können Sie überall in Ihrer App TweetCreator.new(params[:message]).send_tweet , und es wird funktionieren. Rails lädt dieses Objekt auf magische Weise, weil es alles unter app/ automatisch lädt. Überprüfen Sie dies, indem Sie Folgendes ausführen:

 $ rails c Running via Spring preloader in process 12417 Loading development environment (Rails 5.1.5) > puts ActiveSupport::Dependencies.autoload_paths ... /Users/gilani/Sandbox/nazdeeq/app/services

Möchten Sie mehr darüber erfahren, wie autoload funktioniert? Lesen Sie den Leitfaden zum automatischen Laden und Neuladen von Konstanten.

Hinzufügen von syntaktischem Zucker, um Rails-Service-Objekte weniger lästig zu machen

Theoretisch fühlt sich das großartig an, aber TweetCreator.new(params[:message]).send_tweet ist nur ein Bissen. Es ist viel zu ausführlich mit überflüssigen Wörtern … ähnlich wie HTML (ba-dum tiss! ). Aber im Ernst, warum verwenden die Leute HTML, wenn es HAML gibt? Oder sogar schlank. Ich denke, das ist ein anderer Artikel für ein anderes Mal. Zurück zur eigentlichen Aufgabe:

TweetCreator ist ein netter kurzer Klassenname, aber der zusätzliche Aufwand um das Instanziieren des Objekts und das Aufrufen der Methode ist einfach zu lang! Wenn es in Ruby nur Vorrang dafür gäbe, etwas aufzurufen und es sofort mit den angegebenen Parametern ausführen zu lassen … oh Moment, das gibt es! Es ist Proc#call .

Proccall ruft den Block auf und setzt die Parameter des Blocks auf die Werte in params, wobei etwas verwendet wird, das der Methodenaufruf-Semantik nahe kommt. Sie gibt den Wert des letzten im Block ausgewerteten Ausdrucks zurück.

 aproc = Proc.new {|scalar, values| values.map {|value| valuescalar } } aproc.call(9, 1, 2, 3) #=> [9, 18, 27] aproc[9, 1, 2, 3] #=> [9, 18, 27] aproc.(9, 1, 2, 3) #=> [9, 18, 27] aproc.yield(9, 1, 2, 3) #=> [9, 18, 27]

Dokumentation

Wenn Sie das verwirrt, lassen Sie es mich erklären. Eine proc kann mit -ed call werden, um sich selbst mit den angegebenen Parametern auszuführen. Das heißt, wenn TweetCreator ein proc wäre, könnten wir ihn mit TweetCreator.call(message) und das Ergebnis wäre äquivalent zu TweetCreator.new(params[:message]).call , was unserem unhandlichen alten TweetCreator.new(params[:message]).send_tweet ziemlich ähnlich sieht TweetCreator.new(params[:message]).send_tweet .

Lassen Sie uns also dafür sorgen, dass sich unser Dienstobjekt mehr wie ein proc verhält!

Da wir dieses Verhalten wahrscheinlich für alle unsere Dienstobjekte wiederverwenden möchten, lehnen wir uns zunächst an Rails Way an und erstellen eine Klasse namens ApplicationService :

 # app/services/application_service.rb class ApplicationService def self.call(*args, &block) new(*args, &block).call end end

Hast du gesehen, was ich da gemacht habe? Ich habe eine Klassenmethode namens call hinzugefügt, die eine neue Instanz der Klasse mit den übergebenen Argumenten oder Blöcken erstellt und call für die Instanz aufruft. Genau das, was wir wollten! Als letztes müssen Sie die Methode unserer Klasse TweetCreator in call umbenennen und die Klasse von ApplicationService erben lassen:

 # app/services/tweet_creator.rb class TweetCreator < ApplicationService attr_reader :message def initialize(message) @message = message end def call client = Twitter::REST::Client.new do |config| config.consumer_key = ENV['TWITTER_CONSUMER_KEY'] config.consumer_secret = ENV['TWITTER_CONSUMER_SECRET'] config.access_token = ENV['TWITTER_ACCESS_TOKEN'] config.access_token_secret = ENV['TWITTER_ACCESS_SECRET'] end client.update(@message) end end

Und zum Schluss lassen Sie uns das abschließen, indem wir unser Dienstobjekt im Controller aufrufen:

 class TweetController < ApplicationController def create TweetCreator.call(params[:message]) end end

Gruppieren ähnlicher Serviceobjekte für Sanity

Das obige Beispiel hat nur ein Dienstobjekt, aber in der realen Welt können die Dinge komplizierter werden. Was wäre zum Beispiel, wenn Sie Hunderte von Diensten hätten und die Hälfte davon verwandte Geschäftsaktivitäten wären, z. B. einen Follower -Dienst, der einem anderen Twitter-Konto folgt? Ehrlich gesagt würde ich verrückt werden, wenn ein Ordner 200 einzigartig aussehende Dateien enthalten würde, also gut, dass es ein anderes Muster aus dem Rails Way gibt, das wir kopieren können – ich meine, als Inspiration verwenden: Namensräume.

Stellen wir uns vor, wir hätten den Auftrag, ein Dienstobjekt zu erstellen, das anderen Twitter-Profilen folgt.

Schauen wir uns den Namen unseres vorherigen Dienstobjekts an: TweetCreator . Es klingt wie eine Person oder zumindest eine Rolle in einer Organisation. Jemand, der Tweets erstellt. Ich benenne meine Serviceobjekte gerne so, als wären sie genau das: Rollen in einer Organisation. Dieser Konvention folgend nenne ich mein neues Objekt: ProfileFollower .

Da ich nun der oberste Oberherr dieser App bin, werde ich eine Führungsposition in meiner Diensthierarchie schaffen und die Verantwortung für diese beiden Dienste an diese Position delegieren. Ich nenne diese neue Führungsposition TwitterManager .

Da dieser Manager nichts anderes tut als zu verwalten, machen wir ihn zu einem Modul und verschachteln unsere Dienstobjekte unter diesem Modul. Unsere Ordnerstruktur sieht nun so aus:

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

Und unsere Serviceobjekte:

 # services/twitter_manager/tweet_creator.rb module TwitterManager class TweetCreator < ApplicationService ... end end
 # services/twitter_manager/profile_follower.rb module TwitterManager class ProfileFollower < ApplicationService ... end end

Und unsere Aufrufe werden jetzt zu TwitterManager::TweetCreator.call(arg) und TwitterManager::ProfileManager.call(arg) .

Dienstobjekte zur Abwicklung von Datenbankoperationen

Das obige Beispiel hat API-Aufrufe ausgeführt, aber Dienstobjekte können auch verwendet werden, wenn alle Aufrufe an Ihre Datenbank statt an eine API gehen. Dies ist besonders hilfreich, wenn einige Geschäftsaktionen mehrere Datenbankaktualisierungen erfordern, die in eine Transaktion eingeschlossen sind. Dieser Beispielcode würde beispielsweise Dienste verwenden, um einen stattfindenden Währungsumtausch aufzuzeichnen.

 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 ... end end

Was gebe ich von meinem Serviceobjekt zurück?

Wir haben besprochen, wie wir unser Dienstobjekt call , aber was soll das Objekt zurückgeben? Es gibt drei Möglichkeiten, dies zu erreichen:

  • Geben Sie true oder false zurück
  • Geben Sie einen Wert zurück
  • Geben Sie eine Aufzählung zurück

Geben Sie true oder false zurück

Dieser ist einfach: Wenn eine Aktion wie beabsichtigt funktioniert, geben Sie true zurück; andernfalls false zurückgeben:

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

Geben Sie einen Wert zurück

Wenn Ihr Dienstobjekt Daten von irgendwoher abruft, möchten Sie wahrscheinlich diesen Wert zurückgeben:

 def call ... return false unless exchange_rate exchange_rate end

Antworten Sie mit einer Enum

Wenn Ihr Dienstobjekt etwas komplexer ist und Sie verschiedene Szenarien handhaben möchten, können Sie einfach Aufzählungen hinzufügen, um den Fluss Ihrer Dienste zu steuern:

 class ExchangeRecorder < ApplicationService RETURNS = [ SUCCESS = :success, FAILURE = :failure, PARTIAL_SUCCESS = :partial_success ] def call foo = do_something return SUCCESS if foo.success? return FAILURE if foo.failure? PARTIAL_SUCCESS end private def do_something end end

Und dann können Sie in Ihrer App Folgendes verwenden:

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

Sollte ich Service-Objekte nicht in lib/services anstelle von app/services ?

Dies ist subjektiv. Die Meinungen der Menschen darüber, wo sie ihre Serviceobjekte platzieren sollen, gehen auseinander. Einige Leute legen sie in lib/services , während andere app/services erstellen. Ich falle in das letztere Lager. Der Erste-Schritte-Leitfaden von Rails beschreibt den Ordner lib/ als den Ort, an dem Sie „erweiterte Module für Ihre Anwendung“ ablegen können.

Meiner bescheidenen Meinung nach bedeutet „erweiterte Module“ Module, die keine Kerndomänenlogik kapseln und im Allgemeinen projektübergreifend verwendet werden können. In den weisen Worten einer zufälligen Stack Overflow-Antwort, fügen Sie dort Code ein, der „möglicherweise zu einem eigenen Juwel werden kann“.

Sind Serviceobjekte eine gute Idee?

Es hängt von Ihrem Anwendungsfall ab. Schauen Sie – die Tatsache, dass Sie diesen Artikel gerade lesen, deutet darauf hin, dass Sie versuchen, Code zu schreiben, der nicht genau in ein Modell oder einen Controller gehört. Ich habe kürzlich diesen Artikel darüber gelesen, wie Service-Objekte ein Anti-Pattern sind. Der Autor hat seine Meinung, aber ich widerspreche respektvoll.

Nur weil eine andere Person Service-Objekte überstrapaziert hat, heißt das nicht, dass sie von Natur aus schlecht sind. Bei meinem Startup Nazdeeq verwenden wir Service-Objekte sowie Nicht-ActiveRecord-Modelle. Aber der Unterschied zwischen dem, was wohin gehört, war mir schon immer klar: Ich halte alle Geschäftsaktionen in Dienstobjekten, während ich Ressourcen behalte, die nicht wirklich Persistenz in Nicht-ActiveRecord-Modellen benötigen. Am Ende des Tages musst du entscheiden, welches Muster gut für dich ist.

Aber halte ich Service-Objekte im Allgemeinen für eine gute Idee? Absolut! Sie sorgen dafür, dass mein Code ordentlich organisiert ist, und was mich bei der Verwendung von POROs zuversichtlich macht, ist, dass Ruby Objekte liebt. Nein, im Ernst, Ruby liebt Objekte. Es ist verrückt, total verrückt, aber ich liebe es! Fallbeispiel:

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

Sehen? 5 ist buchstäblich ein Objekt.

In vielen Sprachen sind Zahlen und andere primitive Typen keine Objekte. Ruby folgt dem Einfluss der Smalltalk-Sprache, indem es allen seinen Typen Methoden und Instanzvariablen gibt. Dies erleichtert die Verwendung von Ruby, da Regeln, die für Objekte gelten, für ganz Ruby gelten. Ruby-lang.org

Wann sollte ich ein Dienstobjekt nicht verwenden?

Das ist einfach. Ich habe diese Regeln:

  1. Behandelt Ihr Code Routing, Parameter oder andere Controller-artige Dinge?
    Verwenden Sie in diesem Fall kein Dienstobjekt – Ihr Code gehört in den Controller.
  2. Versuchen Sie, Ihren Code in verschiedenen Controllern zu teilen?
    Verwenden Sie in diesem Fall kein Dienstobjekt, sondern ein Anliegen.
  3. Ist Ihr Code wie ein Modell, das keine Persistenz benötigt?
    Verwenden Sie in diesem Fall kein Dienstobjekt. Verwenden Sie stattdessen ein Nicht-ActiveRecord-Modell.
  4. Handelt es sich bei Ihrem Code um eine bestimmte Geschäftsaktion? (z. B. „Müll rausbringen“, „PDF mit diesem Text erstellen“ oder „Zoll mit diesen komplizierten Regeln berechnen“)
    Verwenden Sie in diesem Fall ein Dienstobjekt. Dieser Code passt wahrscheinlich weder in Ihren Controller noch in Ihr Modell.

Dies sind natürlich meine Regeln, die Sie gerne an Ihre eigenen Anwendungsfälle anpassen können. Diese haben für mich sehr gut funktioniert, aber Ihre Laufleistung kann variieren.

Regeln zum Schreiben guter Serviceobjekte

Ich habe vier Regeln zum Erstellen von Dienstobjekten. Diese sind nicht in Stein gemeißelt, und wenn Sie sie wirklich brechen wollen, können Sie das tun, aber ich werde Sie wahrscheinlich bitten, sie in Code-Reviews zu ändern, es sei denn, Ihre Argumentation ist fundiert.

Regel 1: Nur eine öffentliche Methode pro Dienstobjekt

Serviceobjekte sind einzelne Geschäftsaktionen. Sie können den Namen Ihrer öffentlichen Methode ändern, wenn Sie möchten. Ich bevorzuge die Verwendung von call , aber die Codebasis von Gitlab CE nennt es execute und andere Leute können perform verwenden. Verwenden Sie, was immer Sie wollen – Sie könnten es von mir aus nermin nennen. Erstellen Sie nur nicht zwei öffentliche Methoden für ein einzelnes Dienstobjekt. Brechen Sie es bei Bedarf in zwei Objekte auf.

Regel 2: Benennen Sie Service-Objekte wie dumme Rollen in einem Unternehmen

Serviceobjekte sind einzelne Geschäftsaktionen . Stellen Sie sich vor, Sie würden eine Person im Unternehmen einstellen, um diese eine Aufgabe zu erledigen, wie würden Sie sie nennen? Wenn es ihre Aufgabe ist, Tweets zu erstellen, nennen Sie sie TweetCreator . Wenn es ihre Aufgabe ist, bestimmte Tweets zu lesen, nennen Sie sie TweetReader .

Regel 3: Erstellen Sie keine generischen Objekte, um mehrere Aktionen auszuführen

Serviceobjekte sind einzelne Geschäftsaktionen . Ich habe die Funktionalität in zwei Teile aufgeteilt: TweetReader und ProfileFollower . Was ich nicht getan habe, ist, ein einzelnes generisches Objekt namens TwitterHandler zu erstellen und die gesamte API-Funktionalität darin abzulegen. Bitte tun Sie dies nicht. Dies widerspricht der Denkweise „Business Action“ und lässt das Serviceobjekt wie die Twitter-Fee aussehen. Wenn Sie Code zwischen den Geschäftsobjekten freigeben möchten, erstellen Sie einfach ein BaseTwitterManager Objekt oder -Modul und mischen Sie es in Ihre Dienstobjekte.

Regel 4: Behandeln Sie Ausnahmen innerhalb des Dienstobjekts

Zum x-ten Mal: ​​Service-Objekte sind einzelne Geschäftsvorgänge. Ich kann das nicht genug sagen. Wenn Sie eine Person haben, die Tweets liest, wird sie Ihnen entweder den Tweet geben oder sagen: „Dieser Tweet existiert nicht.“ Lassen Sie Ihr Serviceobjekt ebenso nicht in Panik geraten, springen Sie auf den Schreibtisch Ihres Controllers und sagen Sie ihm, dass er die gesamte Arbeit anhalten soll, weil „Fehler!“ Geben Sie einfach false zurück und lassen Sie den Controller von dort aus weitermachen.

Danksagungen und nächste Schritte

Dieser Artikel wäre ohne die erstaunliche Community von Ruby-Entwicklern bei Toptal nicht möglich gewesen. Wenn ich jemals auf ein Problem stoße, ist die Community die hilfreichste Gruppe talentierter Ingenieure, die ich je getroffen habe.

Wenn Sie Dienstobjekte verwenden, fragen Sie sich möglicherweise, wie Sie beim Testen bestimmte Antworten erzwingen können. Ich empfehle, diesen Artikel darüber zu lesen, wie man Schein-Dienstobjekte in Rspec erstellt, die immer das gewünschte Ergebnis zurückgeben, ohne das Dienstobjekt tatsächlich zu treffen!

Wenn Sie mehr über Ruby-Tricks erfahren möchten, empfehle ich Creating a Ruby DSL: A Guide to Advanced Metaprogramming von Toptaler Mate Solymosi. Er erläutert, warum sich die Datei routes.rb nicht wie Ruby anfühlt, und hilft Ihnen beim Aufbau Ihres eigenen DSL.