Ruby on Rails wird mit allem ausgeliefert, 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 Mantra Fat Model, Skinny Controller nicht mehr funktioniert. Wenn Ihre Geschäftslogik weder in ein Model noch in einen Controller passt, dann kommen Service-Objekte ins Spiel, mit denen wir jede Geschäftsaktion in ein eigenes Ruby-Objekt aufteilen können.
In diesem Artikel erkläre ich, wann ein Serviceobjekt erforderlich ist; wie man saubere Serviceobjekte schreibt und sie gruppiert, um die Übersichtlichkeit zu wahren; die strengen Regeln, die ich meinen Serviceobjekten auferlege, um sie direkt an meine Geschäftslogik zu binden; und wie man seine Serviceobjekte nicht in eine Müllhalde für den ganzen Code verwandelt, mit dem man nichts anzufangen weiß.
- Warum brauche ich Service-Objekte?
- Was sind Service-Objekte?
- Erstellen eines Service-Objekts
- Adding Syntactic Sugar to Make Rails Service Objects Suck Less
- Gruppierung ähnlicher Service-Objekte aus Gründen der Übersichtlichkeit
- Dienstobjekte für Datenbankoperationen
- Was gebe ich von meinem Dienstobjekt zurück?
- Return true oder false
- Return a Value
- Antworten Sie mit einem Enum
- Sollte ich Dienstobjekte nicht in lib/services statt in app/services ablegen?
- Sind Serviceobjekte eine gute Idee?
- Wann sollte ich kein Service-Objekt verwenden?
- Regeln für das Schreiben guter Service-Objekte
- Regel 1: Nur eine öffentliche Methode pro Serviceobjekt
- Regel 2: Benennen Sie Service-Objekte wie dumme Rollen in einer Firma
- Regel 3: Erstellen Sie keine generischen Objekte, um mehrere Aktionen durchzuführen
- Regel 4: Behandeln Sie Ausnahmen innerhalb des Serviceobjekts
Warum brauche ich Service-Objekte?
Versuchen Sie Folgendes: Was tun Sie, wenn Ihre Anwendung den Text aus params
tweeten muss?
Wenn Sie bisher Vanilla Rails verwendet haben, dann haben Sie wahrscheinlich so etwas gemacht:
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
Das Problem hier ist, dass Sie mindestens zehn Zeilen zu Ihrem Controller hinzugefügt haben, die dort eigentlich nicht hingehören. Und was wäre, wenn Sie die gleiche Funktionalität in einem anderen Controller verwenden wollten? Verschieben Sie dies in einen Bereich? Moment, aber dieser Code gehört doch eigentlich gar nicht in einen Controller. Warum kann die Twitter-API nicht einfach mit einem einzigen vorbereiteten Objekt geliefert werden, das ich aufrufen kann?
Als ich dies zum ersten Mal tat, hatte ich das Gefühl, etwas Schmutziges getan zu haben. Meine bis dahin wunderbar schlanken Rails-Controller waren fett geworden, und ich wusste nicht, was ich tun sollte. Schließlich habe ich meinen Controller mit einem Service-Objekt repariert.
Bevor du anfängst, diesen Artikel zu lesen, lass uns so tun, als ob:
- Diese Anwendung verwaltet einen Twitter-Account.
- Der Rails-Weg bedeutet “die konventionelle Ruby on Rails-Art, Dinge zu tun”, und das Buch existiert nicht.
- Ich bin ein Rails-Experte… was mir jeden Tag gesagt wird, aber ich habe Schwierigkeiten, es zu glauben, also tun wir einfach so, als ob ich wirklich einer wäre.
Was sind Service-Objekte?
Service-Objekte sind Plain Old Ruby Objects (PORO), die dazu gedacht sind, eine einzige Aktion in Ihrer Domänenlogik auszuführen und das gut. Betrachten Sie das obige Beispiel: Unsere Methode hat bereits die Logik, um eine einzige Sache zu tun, und zwar einen Tweet zu erstellen. Was wäre, wenn diese Logik in einer einzigen Ruby-Klasse gekapselt wäre, die wir instanziieren und eine Methode aufrufen können? So etwas wie:
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)
Das ist so ziemlich alles; unser TweetCreator
Service-Objekt kann, sobald es erstellt ist, von überall aus aufgerufen werden, und es würde diese eine Sache sehr gut erledigen.
Erstellen eines Service-Objekts
Zunächst erstellen wir ein neues TweetCreator
in einem neuen Ordner mit dem Namen app/services
:
$ mkdir app/services && touch app/services/tweet_creator.rb
Und wir legen unsere gesamte Logik in einer neuen Ruby-Klasse ab:
# 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
Dann können Sie TweetCreator.new(params).send_tweet
überall in Ihrer Anwendung aufrufen, und es wird funktionieren. Rails wird dieses Objekt auf magische Weise laden, da es alles automatisch unter app/
lädt. Überprüfen Sie dies durch Ausführen von:
$ 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
Wollen Sie mehr darüber erfahren, wie autoload
funktioniert? Lesen Sie den Autoloading and Reloading Constants Guide.
Adding Syntactic Sugar to Make Rails Service Objects Suck Less
Schauen Sie, das fühlt sich in der Theorie großartig an, aber TweetCreator.new(params).send_tweet
ist einfach ein Mund voll. Es ist viel zu langatmig mit überflüssigen Wörtern… ähnlich wie HTML (ba-dum tiss!). Aber mal ganz im Ernst: Warum benutzen die Leute HTML, wenn es HAML gibt? Oder sogar Slim. Ich denke, das ist ein anderer Artikel für ein anderes Mal. Zurück zur vorliegenden Aufgabe:
TweetCreator
ist ein schöner kurzer Klassenname, aber der zusätzliche Aufwand für die Instanziierung des Objekts und den Aufruf der Methode ist einfach zu lang! Wenn es in Ruby doch nur einen Vorrang für den Aufruf von etwas gäbe, das sich sofort mit den angegebenen Parametern selbst ausführt… ach, Moment, es gibt ihn! Proc#call
.
Proccall
ruft den Block auf und setzt die Parameter des Blocks auf die Werte in params, wobei eine Semantik verwendet wird, die dem Methodenaufruf nahe kommt. Er 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) #=> aproc #=> aproc.(9, 1, 2, 3) #=> aproc.yield(9, 1, 2, 3) #=>
Dokumentation
Wenn Sie das verwirrt, lassen Sie mich erklären. Ein proc
kann call
-selbst mit den angegebenen Parametern ausgeführt werden. Das heißt, wenn TweetCreator
ein proc
wäre, könnten wir es mit TweetCreator.call(message)
aufrufen und das Ergebnis wäre gleich TweetCreator.new(params).call
, was unserem unhandlichen alten TweetCreator.new(params).send_tweet
ziemlich ähnlich ist.
Lassen Sie uns also dafür sorgen, dass sich unser Dienstobjekt mehr wie ein proc
verhält!
Erstens, weil wir dieses Verhalten wahrscheinlich in allen unseren Serviceobjekten wiederverwenden wollen, nehmen wir uns ein Beispiel an Rails und erstellen eine Klasse namens ApplicationService
:
# app/services/application_service.rbclass ApplicationService def self.call(*args, &block) new(*args, &block).call endend
Haben Sie gesehen, was ich da gemacht habe? Ich habe eine Klassenmethode namens call
hinzugefügt, die eine neue Instanz der Klasse mit den Argumenten oder dem Block erstellt, die Sie ihr übergeben, und call
auf der Instanz aufruft. Das ist genau das, was wir wollten! Als Letztes müssen wir die Methode unserer Klasse TweetCreator
in call
umbenennen und die Klasse von ApplicationService
erben lassen:
# 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
Und zum Schluss rufen wir unser Service-Objekt im Controller auf:
class TweetController < ApplicationController def create TweetCreator.call(params) endend
Gruppierung ähnlicher Service-Objekte aus Gründen der Übersichtlichkeit
Das obige Beispiel hat nur ein Service-Objekt, 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 zusammenhängende Geschäftsaktionen wären, z. B. ein Follower
Dienst, der einem anderen Twitter-Konto folgt? Ehrlich gesagt würde ich verrückt werden, wenn ein Ordner 200 einzigartig aussehende Dateien enthielte. Gut, dass es ein weiteres Muster aus dem Rails Way gibt, das wir kopieren – ich meine, als Inspiration verwenden – können: namespacing.
Lassen Sie uns so tun, als ob wir die Aufgabe hätten, ein Service-Objekt zu erstellen, das anderen Twitter-Profilen folgt.
Lassen Sie uns den Namen unseres vorherigen Service-Objekts betrachten: TweetCreator
. Das klingt nach einer Person oder zumindest nach einer 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 werde ich mein neues Objekt so nennen: ProfileFollower
.
Nun, da ich der oberste Herr dieser Anwendung bin, werde ich in meiner Diensthierarchie eine Führungsposition einrichten und die Verantwortung für diese beiden Dienste an diese Position delegieren. Ich nenne diese neue Managerposition TwitterManager
.
Da dieser Manager nichts anderes tut als verwalten, machen wir ihn zu einem Modul und verschachteln unsere Dienstobjekte unter diesem Modul. Unsere Ordnerstruktur sieht nun wie folgt aus:
services├── application_service.rb└── twitter_manager ├── profile_follower.rb └── tweet_creator.rb
Und unsere Dienstobjekte:
# services/twitter_manager/tweet_creator.rbmodule TwitterManager class TweetCreator < ApplicationService ... endend
# services/twitter_manager/profile_follower.rbmodule TwitterManager class ProfileFollower < ApplicationService ... endend
Und unsere Aufrufe werden nun zu TwitterManager::TweetCreator.call(arg)
und TwitterManager::ProfileManager.call(arg)
.
Dienstobjekte für Datenbankoperationen
Im obigen Beispiel wurden API-Aufrufe getätigt, aber Dienstobjekte können auch verwendet werden, wenn alle Aufrufe an die Datenbank und nicht an eine API gerichtet sind. Dies ist besonders hilfreich, wenn einige Geschäftsvorgänge mehrere Datenbankaktualisierungen erfordern, die in eine Transaktion eingeschlossen sind. Dieser Beispielcode würde beispielsweise Dienste verwenden, um einen 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 ... endend
Was gebe ich von meinem Dienstobjekt zurück?
Wir haben besprochen, wie wir unser call
Dienstobjekt erstellen, aber was sollte das Objekt zurückgeben? Es gibt drei Möglichkeiten, dies anzugehen:
- Return
true
oderfalse
- Return a value
- Return an Enum
Return true
oder false
Dies ist einfach: Wenn eine Aktion wie vorgesehen funktioniert, geben Sie true
zurück; andernfalls geben Sie false
zurück:
def call ... return true if client.update(@message) false end
Return a Value
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 einem Enum
Wenn Ihr Dienstobjekt etwas komplexer ist und Sie verschiedene Szenarien behandeln möchten, können Sie einfach Enums hinzufügen, um den Fluss Ihrer Dienste zu steuern:
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
Und dann können Sie in Ihrer Anwendung Folgendes verwenden:
case ExchangeRecorder.call when ExchangeRecorder::SUCCESS foo when ExchangeRecorder::FAILURE bar when ExchangeRecorder::PARTIAL_SUCCESS baz end
Sollte ich Dienstobjekte nicht in lib/services statt in app/services ablegen?
Das ist subjektiv. Die Meinungen darüber, wo man seine Dienstobjekte ablegen sollte, gehen auseinander. Manche legen sie in lib/services
ab, während andere app/services
erstellen. Ich gehöre zum letzteren Lager. Im Getting Started Guide von Rails wird der Ordner lib/
als der Ort beschrieben, an dem “erweiterte Module für Ihre Anwendung” abgelegt werden.
Meiner bescheidenen Meinung nach sind mit “erweiterten Modulen” Module gemeint, die nicht die Kerndomänenlogik kapseln und im Allgemeinen projektübergreifend verwendet werden können. Um es mit den weisen Worten einer zufälligen Stack Overflow-Antwort zu sagen: Stellen Sie Code hinein, der “potenziell zu einem eigenen Edelstein werden kann.”
Sind Serviceobjekte eine gute Idee?
Das hängt von Ihrem Anwendungsfall ab. Sehen Sie – die Tatsache, dass Sie diesen Artikel gerade lesen, lässt vermuten, dass Sie versuchen, Code zu schreiben, der nicht unbedingt in ein Modell oder einen Controller gehört. Ich habe kürzlich diesen Artikel darüber gelesen, dass Serviceobjekte ein Anti-Pattern sind. Der Autor hat seine eigene Meinung, aber ich bin da ganz anderer Meinung.
Nur weil jemand anderes Serviceobjekte übermäßig benutzt hat, heißt das nicht, dass sie von Natur aus schlecht sind. In meinem Startup, Nazdeeq, verwenden wir sowohl Serviceobjekte als auch Nicht-ActiveRecord-Modelle. Aber der Unterschied zwischen dem, was wohin gehört, war für mich immer offensichtlich: Ich bewahre alle Geschäftsaktionen in Serviceobjekten auf, während ich Ressourcen, die keine Persistenz benötigen, in Nicht-ActiveRecord-Modellen aufbewahre. Letzten Endes müssen Sie selbst entscheiden, welches Muster für Sie gut ist.
Hältst du Serviceobjekte im Allgemeinen für eine gute Idee? Auf jeden Fall! Sie sorgen dafür, dass mein Code übersichtlich bleibt, und was mich bei der Verwendung von POROs zuversichtlich stimmt, ist, dass Ruby Objekte liebt. Nein, im Ernst, Ruby liebt Objekte. Es ist wahnsinnig, total verrückt, aber ich liebe es! Ein typisches Beispiel:
> 5.is_a? Object # => true > 5.class # => Integer > class Integer?> def woot?> 'woot woot'?> end?> end # => :woot > 5.woot # => "woot woot"
Siehst du? 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. Das erleichtert den Umgang mit Ruby, denn die Regeln, die für Objekte gelten, gelten für ganz Ruby.Ruby-lang.org
Wann sollte ich kein Service-Objekt verwenden?
Das ist ganz einfach. Ich habe diese Regeln:
- Handhabt dein Code Routing, Params oder andere Controller-ähnliche Dinge?
Wenn ja, verwende kein Service-Objekt – dein Code gehört in den Controller. - Versuchen Sie, Ihren Code in verschiedenen Controllern gemeinsam zu nutzen?
In diesem Fall verwenden Sie kein Serviceobjekt, sondern ein Anliegen. - Ist Ihr Code wie ein Modell, das keine Persistenz benötigt?
Wenn ja, verwenden Sie kein Serviceobjekt. Verwenden Sie stattdessen ein Nicht-ActiveRecord-Modell. - Handelt es sich bei Ihrem Code um eine bestimmte Geschäftsaktion? (z.B. “Bringen Sie den Müll raus”, “Erzeugen Sie ein PDF mit diesem Text” oder “Berechnen Sie die Zollgebühren mit diesen komplizierten Regeln”)
In diesem Fall verwenden Sie ein Serviceobjekt. Dieser Code passt wahrscheinlich logisch weder in Ihren Controller noch in Ihr Modell.
Natürlich sind dies meine Regeln, Sie können sie also gerne an Ihre eigenen Anwendungsfälle anpassen. Sie haben sich für mich sehr bewährt, aber Ihre Erfahrungen können variieren.
Regeln für das Schreiben guter Service-Objekte
Ich habe vier Regeln für die Erstellung von Service-Objekten. 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 bei Code-Reviews zu ändern, es sei denn, Ihre Argumentation ist stichhaltig.
Regel 1: Nur eine öffentliche Methode pro Serviceobjekt
Serviceobjekte sind einzelne Geschäftsaktionen. Sie können den Namen Ihrer öffentlichen Methode ändern, wenn Sie möchten. Ich bevorzuge call
, aber die Codebasis von Gitlab CE nennt sie execute
und andere verwenden vielleicht perform
. Verwenden Sie, was immer Sie wollen – von mir aus können Sie sie nermin
nennen. Erstellen Sie nur nicht zwei öffentliche Methoden für ein einziges Serviceobjekt. Teilen Sie es in zwei Objekte auf, wenn es nötig ist.
Regel 2: Benennen Sie Service-Objekte wie dumme Rollen in einer Firma
Service-Objekte sind einzelne Geschäftsaktionen. Stellen Sie sich vor, Sie stellen eine Person im Unternehmen ein, 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 ihre Aufgabe darin besteht, bestimmte Tweets zu lesen, nennen Sie sie TweetReader
.
Regel 3: Erstellen Sie keine generischen Objekte, um mehrere Aktionen durchzuführen
Serviceobjekte sind einzelne Geschäftsaktionen. Ich habe die Funktionalität in zwei Teile zerlegt: TweetReader
, und ProfileFollower
. Was ich nicht getan habe, ist, ein einziges generisches Objekt mit dem Namen TwitterHandler
zu erstellen und die gesamte API-Funktionalität darin zu deponieren. Bitte tun Sie das nicht. Das widerspricht der “Business Action”-Mentalität und lässt das Service-Objekt wie die Twitter-Fee aussehen. Wenn Sie Code zwischen den Geschäftsobjekten teilen wollen, erstellen Sie einfach ein BaseTwitterManager
Objekt oder Modul und mischen Sie es in Ihre Serviceobjekte.
Regel 4: Behandeln Sie Ausnahmen innerhalb des Serviceobjekts
Zum x-ten Mal: Serviceobjekte sind einzelne Geschäftsaktionen. Ich kann es nicht oft 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 auch nicht zu, dass Ihr Dienstobjekt in Panik gerät, auf den Schreibtisch Ihres Controllers springt und ihm sagt, er solle die Arbeit einstellen, weil “Fehler!” Geben Sie einfach false
zurück und lassen Sie den Controller von da an weitermachen.
Dieser Artikel wäre ohne die großartige Gemeinschaft 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 kennengelernt habe.
Wenn Sie Serviceobjekte verwenden, fragen Sie sich vielleicht, wie Sie bestimmte Antworten beim Testen erzwingen können. Ich empfehle, diesen Artikel zu lesen, in dem beschrieben wird, wie man Mock-Service-Objekte in Rspec erstellt, die immer das gewünschte Ergebnis zurückgeben, ohne das Service-Objekt tatsächlich zu treffen!
Wenn Sie mehr über Ruby-Tricks erfahren möchten, empfehle ich Ihnen Creating a Ruby DSL: A Guide to Advanced Metaprogramming von Toptaler-Kollege Máté Solymosi. Er erklärt, warum sich die routes.rb
-Datei nicht wie Ruby anfühlt und hilft dir, deine eigene DSL zu erstellen.