Erstellen Sie elegante Rails-Komponenten mit einfachen alten Ruby-Objekten

Veröffentlicht: 2022-03-11

Ihre Website gewinnt an Bedeutung und Sie wachsen schnell. Ruby/Rails ist die Programmiersprache Ihrer Wahl. Ihr Team ist größer und Sie haben „fette Modelle, dünne Controller“ als Designstil für Ihre Rails-Apps aufgegeben. Trotzdem möchten Sie die Verwendung von Rails nicht aufgeben.

Kein Problem. Heute besprechen wir, wie Sie die Best Practices von OOP verwenden können, um Ihren Code sauberer, isolierter und entkoppelter zu machen.

Ist Ihre App ein Refactoring wert?

Sehen wir uns zunächst an, wie Sie entscheiden sollten, ob Ihre App ein guter Kandidat für das Refactoring ist.

Hier ist eine Liste von Metriken und Fragen, die ich mir normalerweise stelle, um festzustellen, ob mein Code umgestaltet werden muss oder nicht.

  • Langsame Unit-Tests. PORO-Einheitentests laufen normalerweise schnell mit gut isoliertem Code, daher können langsam laufende Tests oft ein Indikator für ein schlechtes Design und übermäßig gekoppelte Verantwortlichkeiten sein.
  • FAT-Modelle oder Controller. Ein Modell oder Controller mit mehr als 200 Codezeilen (LOC) ist im Allgemeinen ein guter Kandidat für das Refactoring.
  • Übermäßig große Codebasis. Wenn Sie ERB/HTML/HAML mit mehr als 30.000 LOC oder Ruby-Quellcode (ohne GEMs) mit mehr als 50.000 LOC haben, besteht eine gute Chance, dass Sie refaktorisieren sollten.

Versuchen Sie, etwas wie das Folgende zu verwenden, um herauszufinden, wie viele Zeilen Ruby-Quellcode Sie haben:

find app -iname "*.rb" -type f -exec cat {} \;| wc -l

Dieser Befehl durchsucht alle Dateien mit der Erweiterung .rb (Ruby-Dateien) im Ordner /app und gibt die Anzahl der Zeilen aus. Bitte beachten Sie, dass diese Zahl nur ungefähr ist, da Kommentarzeilen in diesen Summen enthalten sind.

Eine weitere präzisere und informativere Option ist die Verwendung der Rails-Rake-Task- stats , die eine schnelle Zusammenfassung der Codezeilen, der Anzahl der Klassen, der Anzahl der Methoden, des Verhältnisses von Methoden zu Klassen und des Verhältnisses von Codezeilen pro Methode ausgibt:

 bundle exec rake stats +----------------------+-------+-----+-------+---------+-----+-------+ | Name | Lines | LOC | Class | Methods | M/C | LOC/M | +----------------------+-------+-----+-------+---------+-----+-------+ | Controllers | 195 | 153 | 6 | 18 | 3 | 6 | | Helpers | 14 | 13 | 0 | 2 | 0 | 4 | | Models | 120 | 84 | 5 | 12 | 2 | 5 | | Mailers | 0 | 0 | 0 | 0 | 0 | 0 | | Javascripts | 45 | 12 | 0 | 3 | 0 | 2 | | Libraries | 0 | 0 | 0 | 0 | 0 | 0 | | Controller specs | 106 | 75 | 0 | 0 | 0 | 0 | | Helper specs | 15 | 4 | 0 | 0 | 0 | 0 | | Model specs | 238 | 182 | 0 | 0 | 0 | 0 | | Request specs | 699 | 489 | 0 | 14 | 0 | 32 | | Routing specs | 35 | 26 | 0 | 0 | 0 | 0 | | View specs | 5 | 4 | 0 | 0 | 0 | 0 | +----------------------+-------+-----+-------+---------+-----+-------+ | Total | 1472 |1042 | 11 | 49 | 4 | 19 | +----------------------+-------+-----+-------+---------+-----+-------+ Code LOC: 262 Test LOC: 780 Code to Test Ratio: 1:3.0
  • Kann ich wiederkehrende Muster in meiner Codebasis extrahieren?

Entkopplung in Aktion

Beginnen wir mit einem realen Beispiel.

Stellen Sie sich vor, wir wollten eine Anwendung schreiben, die die Zeit für Jogger erfasst. Auf der Hauptseite kann der Benutzer die eingegebenen Zeiten sehen.

Jeder Zeiteintrag hat ein Datum, eine Distanz, eine Dauer und zusätzliche relevante „Status“-Informationen (z. B. Wetter, Art des Geländes usw.) und eine Durchschnittsgeschwindigkeit, die bei Bedarf berechnet werden kann.

Wir brauchen eine Berichtsseite, die die durchschnittliche Geschwindigkeit und Distanz pro Woche anzeigt.

Wenn die Durchschnittsgeschwindigkeit für den Eintrag höher ist als die Gesamtdurchschnittsgeschwindigkeit, benachrichtigen wir den Benutzer mit einer SMS (in diesem Beispiel verwenden wir die Nexmo RESTful API zum Senden der SMS).

Auf der Startseite können Sie die Distanz, das Datum und die Zeit beim Joggen auswählen, um einen ähnlichen Eintrag zu erstellen:

Wir haben auch eine statistics , die im Grunde ein wöchentlicher Bericht ist, der die durchschnittliche Geschwindigkeit und die pro Woche zurückgelegte Distanz enthält.

  • Hier können Sie sich das Online-Beispiel ansehen.

Der Code

Die Struktur des app Verzeichnisses sieht in etwa so aus:

 ⇒ tree . ├── assets │ └── ... ├── controllers │ ├── application_controller.rb │ ├── entries_controller.rb │ └── statistics_controller.rb ├── helpers │ ├── application_helper.rb │ ├── entries_helper.rb │ └── statistics_helper.rb ├── mailers ├── models │ ├── entry.rb │ └── user.rb └── views ├── devise │ └── ... ├── entries │ ├── _entry.html.erb │ ├── _form.html.erb │ └── index.html.erb ├── layouts │ └── application.html.erb └── statistics └── index.html.erb

Ich werde das User nicht diskutieren, da es nichts Besonderes ist, da wir es mit Devise verwenden, um die Authentifizierung zu implementieren.

Das Entry enthält die Geschäftslogik für unsere Anwendung.

Jeder Entry gehört einem User .

Wir validieren das Vorhandensein der Attribute distance , time_period , date_time und status für jeden Eintrag.

Jedes Mal, wenn wir einen Eintrag erstellen, vergleichen wir die Durchschnittsgeschwindigkeit des Benutzers mit dem Durchschnitt aller anderen Benutzer im System und benachrichtigen den Benutzer per SMS mit Nexmo (wir werden nicht darauf eingehen, wie die Nexmo-Bibliothek verwendet wird, obwohl ich wollte um einen Fall zu demonstrieren, in dem wir eine externe Bibliothek verwenden).

  • Gist-Beispiel

Beachten Sie, dass das Entry mehr als nur die Geschäftslogik enthält. Es verarbeitet auch einige Validierungen und Rückrufe.

Die entries_controller.rb hat die wichtigsten CRUD-Aktionen (allerdings kein Update). EntriesController#index die Einträge für den aktuellen Benutzer ab und ordnet die Datensätze nach Erstellungsdatum, während EntriesController#create einen neuen Eintrag erstellt. Keine Notwendigkeit, das Offensichtliche und die Verantwortlichkeiten von EntriesController#destroy zu diskutieren:

  • Gist-Beispiel

Während statistics_controller.rb für die Berechnung des Wochenberichts zuständig ist, ruft StatisticsController#index die Einträge für den angemeldeten Benutzer ab und gruppiert sie nach Wochen, wobei die Methode #group_by verwendet wird, die in der Enumerable-Klasse in Rails enthalten ist. Es versucht dann, die Ergebnisse mit einigen privaten Methoden zu dekorieren.

  • Gist-Beispiel

Wir diskutieren hier nicht viel über die Ansichten, da der Quellcode selbsterklärend ist.

Unten ist die Ansicht zum Auflisten der Einträge für den eingeloggten Benutzer ( index.html.erb ). Dies ist die Vorlage, die verwendet wird, um die Ergebnisse der Indexaktion (Methode) im Einträge-Controller anzuzeigen:

  • Gist-Beispiel

Beachten Sie, dass wir partielle render @entries , um den freigegebenen Code in eine partielle Vorlage _entry.html.erb zu ziehen, damit wir unseren Code TROCKEN und wiederverwendbar halten können:

  • Gist-Beispiel

Dasselbe gilt für den Teil _form . Anstatt denselben Code mit (Neu- und Bearbeiten-)Aktionen zu verwenden, erstellen wir ein wiederverwendbares Teilformular:

  • Gist-Beispiel

Was den Seitenaufruf des wöchentlichen Berichts betrifft, zeigt statistics/index.html.erb einige Statistiken und meldet die wöchentliche Leistung des Benutzers, indem einige Einträge gruppiert werden:

  • Gist-Beispiel

Und schließlich enthält der Helfer für Einträge, entries_helper.rb , zwei Helfer readable_time_period und readable_speed , die die Attribute für Menschen lesbarer machen sollten:

  • Gist-Beispiel

Bisher nichts Besonderes.

Die meisten von Ihnen werden argumentieren, dass dies gegen das KISS-Prinzip verstößt und das System komplizierter macht.

Braucht diese Anwendung also wirklich ein Refactoring?

Absolut nicht , aber wir betrachten es nur zu Demonstrationszwecken.

Wenn Sie sich schließlich den vorherigen Abschnitt und die Merkmale ansehen, die darauf hindeuten, dass eine App umgestaltet werden muss, wird deutlich, dass die App in unserem Beispiel kein gültiger Kandidat für eine Umgestaltung ist.

Lebenszyklus

Beginnen wir also damit, die Musterstruktur von Rails MVC zu erklären.

Normalerweise beginnt es damit, dass der Browser eine Anfrage stellt, wie z. B. https://www.toptal.com/jogging/show/1 .

Der Webserver empfängt die Anfrage und verwendet routes , um herauszufinden, welcher controller verwendet werden soll.

Die Controller erledigen die Arbeit des Analysierens von Benutzeranfragen, Datenübermittlungen, Cookies, Sitzungen usw. und bitten dann das model , die Daten abzurufen.

Die models sind Ruby-Klassen, die mit der Datenbank kommunizieren, Daten speichern und validieren, die Geschäftslogik ausführen und ansonsten die schwere Arbeit erledigen. Ansichten sind das, was der Benutzer sieht: HTML, CSS, XML, Javascript, JSON.

Wenn wir den Ablauf eines Rails-Request-Lebenszyklus darstellen wollen, würde das etwa so aussehen:

Schienen entkoppeln den MVC-Lebenszyklus

Was ich erreichen möchte, ist, mehr Abstraktion mit einfachen alten Ruby-Objekten (POROs) hinzuzufügen und das Muster für create/update etwa wie folgt zu gestalten:

Schienendiagramm-Formular erstellen

Und so etwas wie das Folgende für list/show Aktionen:

Rails-Diagrammlistenabfrage

Indem wir POROs-Abstraktionen hinzufügen, stellen wir eine vollständige Trennung zwischen den SRP-Verantwortlichkeiten sicher, etwas, in dem Rails nicht sehr gut ist.

Richtlinien

Um das neue Design zu erreichen, werde ich die unten aufgeführten Richtlinien verwenden, aber bitte beachten Sie, dass dies keine Regeln sind, die Sie bis ins kleinste Detail befolgen müssen. Betrachten Sie sie als flexible Richtlinien, die das Refactoring einfacher machen.

  • ActiveRecord-Modelle können Assoziationen und Konstanten enthalten, aber sonst nichts. Das bedeutet also keine Rückrufe (verwenden Sie Dienstobjekte und fügen Sie die Rückrufe dort hinzu) und keine Validierungen (verwenden Sie Formularobjekte, um Benennungen und Validierungen für das Modell einzuschließen).
  • Halten Sie Controller als dünne Schichten und rufen Sie immer Service-Objekte auf. Einige von Ihnen werden fragen, warum überhaupt Controller verwendet werden, da wir weiterhin Dienstobjekte aufrufen möchten, um die Logik zu enthalten? Nun, Controller sind ein guter Ort, um das HTTP-Routing, das Analysieren von Parametern, die Authentifizierung, die Inhaltsaushandlung, das Aufrufen des richtigen Dienst- oder Editorobjekts, das Abfangen von Ausnahmen, das Formatieren von Antworten und das Zurückgeben des richtigen HTTP-Statuscodes zu haben.
  • Dienste sollten Abfrageobjekte aufrufen und keinen Status speichern. Verwenden Sie Instanzmethoden, keine Klassenmethoden. Es sollte nur sehr wenige öffentliche Methoden geben, die SRP entsprechen.
  • Abfragen sollten in Abfrageobjekten erfolgen. Abfrageobjektmethoden sollten ein Objekt, einen Hash oder ein Array zurückgeben, keine ActiveRecord-Zuordnung.
  • Vermeiden Sie die Verwendung von Helfern und verwenden Sie stattdessen Dekorateure. Warum? Eine häufige Falle bei Rails-Helfern besteht darin, dass sie sich in einen großen Haufen von Nicht-OO-Funktionen verwandeln können, die sich alle einen Namensraum teilen und aufeinander treten. Aber viel schlimmer ist, dass es keine großartige Möglichkeit gibt, irgendeine Art von Polymorphismus mit Rails-Helfern zu verwenden – unterschiedliche Implementierungen für verschiedene Kontexte oder Typen bereitzustellen, Helfer zu überschreiben oder zu unterteilen. Ich denke, die Rails-Hilfsklassen sollten im Allgemeinen für Hilfsmethoden verwendet werden, nicht für bestimmte Anwendungsfälle, wie z. B. das Formatieren von Modellattributen für jede Art von Präsentationslogik. Halte sie leicht und luftig.
  • Vermeiden Sie Bedenken und verwenden Sie stattdessen Dekorateure/Delegatoren. Warum? Schließlich scheinen Bedenken ein zentraler Bestandteil von Rails zu sein und können Code austrocknen, wenn er von mehreren Modellen geteilt wird. Das Hauptproblem besteht jedoch darin, dass Bedenken das Modellobjekt nicht kohärenter machen. Der Code ist einfach besser organisiert. Mit anderen Worten, es gibt keine wirkliche Änderung an der API des Modells.
  • Versuchen Sie, Wertobjekte aus Modellen zu extrahieren , um Ihren Code sauberer zu halten und verwandte Attribute zu gruppieren.
  • Übergeben Sie immer eine Instanzvariable pro Ansicht.

Refactoring

Bevor wir anfangen, möchte ich noch etwas besprechen. Wenn Sie mit dem Refactoring beginnen, fragen Sie sich normalerweise: „Ist das wirklich gutes Refactoring?“

Wenn Sie das Gefühl haben, die Verantwortlichkeiten stärker zu trennen oder zu isolieren (auch wenn das bedeutet, dass mehr Code und neue Dateien hinzugefügt werden), ist dies normalerweise eine gute Sache. Schließlich ist das Entkoppeln einer Anwendung eine sehr gute Praxis und macht es uns einfacher, ordnungsgemäße Komponententests durchzuführen.

Ich werde Dinge wie das Verschieben von Logik von Controllern zu Modellen nicht besprechen, da ich annehme, dass Sie dies bereits tun und mit Rails (normalerweise Skinny Controller und FAT-Modell) vertraut sind.

Um diesen Artikel straff zu halten, werde ich hier nicht auf das Testen eingehen, aber das bedeutet nicht, dass Sie nicht testen sollten.

Im Gegenteil, Sie sollten immer mit einem Test beginnen , um sicherzustellen, dass alles in Ordnung ist, bevor Sie fortfahren. Dies ist ein Muss, insbesondere beim Refactoring.

Dann können wir Änderungen implementieren und sicherstellen, dass alle Tests für die relevanten Teile des Codes bestanden werden.

Extrahieren von Wertobjekten

Erstens, was ist ein Wertobjekt?

Martin Fowler erklärt:

Wertobjekt ist ein kleines Objekt, z. B. ein Geld- oder Datumsbereichsobjekt. Ihre Schlüsseleigenschaft besteht darin, dass sie eher der Wertesemantik als der Referenzsemantik folgen.

Manchmal kann es vorkommen, dass ein Konzept eine eigene Abstraktion verdient und dessen Gleichheit nicht auf Wert, sondern auf Identität basiert. Beispiele wären Rubys Datum, URI und Pfadname. Die Extraktion in ein Wertobjekt (oder Domänenmodell) ist sehr praktisch.

Warum die Mühe?

Einer der größten Vorteile eines Value-Objekts ist die Aussagekraft, die sie in Ihrem Code erreichen helfen. Ihr Code wird tendenziell viel klarer sein, oder zumindest kann es sein, wenn Sie gute Benennungspraktiken haben. Da das Wertobjekt eine Abstraktion ist, führt es zu saubererem Code und weniger Fehlern.

Ein weiterer großer Gewinn ist die Unveränderlichkeit. Die Unveränderlichkeit von Objekten ist sehr wichtig. Wenn wir bestimmte Datensätze speichern, die in einem Wertobjekt verwendet werden könnten, möchte ich normalerweise nicht, dass diese Daten manipuliert werden.

Wann ist das sinnvoll?

Es gibt keine allgemein gültige Antwort. Tun Sie, was für Sie am besten ist und was in der jeweiligen Situation Sinn macht.

Darüber hinaus gibt es jedoch einige Richtlinien, die ich verwende, um mir bei dieser Entscheidung zu helfen.

Wenn Sie an eine Gruppe von Methoden denken, die verwandt sind, sind sie mit Wertobjekten ausdrucksstärker. Diese Aussagekraft bedeutet, dass ein Value-Objekt einen eindeutigen Datensatz darstellen sollte, den Ihr durchschnittlicher Entwickler einfach aus dem Namen des Objekts ableiten kann.

Wie wird das gemacht?

Wertobjekte sollten einigen Grundregeln folgen:

  • Wertobjekte sollten mehrere Attribute haben.
  • Attribute sollten während des gesamten Lebenszyklus des Objekts unveränderlich sein.
  • Die Gleichheit wird durch die Attribute des Objekts bestimmt.

In unserem Beispiel erstelle ich ein EntryStatus -Wertobjekt, um die Attribute Entry#status_weather und Entry#status_landform in ihre eigene Klasse zu abstrahieren, die etwa so aussieht:

  • Gist-Beispiel

Hinweis: Dies ist nur ein Plain Old Ruby Object (PORO), das nicht von ActiveRecord::Base erbt. Wir haben Lesemethoden für unsere Attribute definiert und weisen sie bei der Initialisierung zu. Wir haben auch ein vergleichbares Mixin verwendet, um Objekte mit der Methode (<=>) zu vergleichen.

Wir können das Entry -Modell ändern, um das von uns erstellte Wertobjekt zu verwenden:

  • Gist-Beispiel

Wir können auch die EntryController#create Methode ändern, um das neue Wertobjekt entsprechend zu verwenden:

  • Gist-Beispiel

Dienstobjekte extrahieren

Was ist also ein Service-Objekt?

Die Aufgabe eines Service-Objekts besteht darin, den Code für einen bestimmten Teil der Geschäftslogik zu speichern. Im Gegensatz zum „Fat Model“ -Stil, bei dem eine kleine Anzahl von Objekten viele, viele Methoden für die gesamte erforderliche Logik enthält, führt die Verwendung von Service-Objekten zu vielen Klassen, von denen jede einem einzigen Zweck dient.

Warum? Was sind die Vorteile?

  • Entkopplung. Dienstobjekte helfen Ihnen dabei, eine stärkere Isolierung zwischen Objekten zu erreichen.
  • Sichtweite. Dienstobjekte (sofern sie gut benannt sind) zeigen, was eine Anwendung tut. Ich kann einfach einen Blick auf das Diensteverzeichnis werfen, um zu sehen, welche Funktionen eine Anwendung bereitstellt.
  • Aufräumen von Modellen und Controllern. Controller wandeln die Anfrage (Parameter, Sitzung, Cookies) in Argumente um, leiten sie an den Dienst weiter und leiten sie entsprechend der Dienstantwort um oder rendern sie. Während sich Modelle nur mit Assoziationen und Persistenz befassen. Das Extrahieren von Code aus Controllern/Modellen in Dienstobjekte würde SRP unterstützen und den Code stärker entkoppeln. Die Verantwortung des Modells würde dann nur darin bestehen, sich mit Assoziationen und dem Speichern/Löschen von Datensätzen zu befassen, während das Dienstobjekt eine einzige Verantwortung (SRP) hätte. Dies führt zu einem besseren Design und besseren Unit-Tests.
  • DRY und Embrace ändern. Ich halte Serviceobjekte so einfach und klein wie möglich. Ich komponiere Dienstobjekte mit anderen Dienstobjekten und verwende sie wieder.
  • Bereinigen und beschleunigen Sie Ihre Testsuite. Dienste sind einfach und schnell zu testen, da es sich um kleine Ruby-Objekte mit einem einzigen Einstiegspunkt (der Aufrufmethode) handelt. Komplexe Dienste werden mit anderen Diensten zusammengestellt, sodass Sie Ihre Tests einfach aufteilen können. Außerdem erleichtert die Verwendung von Service-Objekten das Mocking/Stubing verwandter Objekte, ohne dass die gesamte Rails-Umgebung geladen werden muss.
  • Von überall aufrufbar. Service-Objekte werden wahrscheinlich von Controllern sowie anderen Service-Objekten, DelayedJob / Rescue / Sidekiq-Jobs, Rake-Tasks, Konsole usw. aufgerufen.

Andererseits ist nichts jemals perfekt. Ein Nachteil von Service-Objekten ist, dass sie für eine sehr einfache Aktion zu viel des Guten sein können. In solchen Fällen kann es passieren, dass Sie Ihren Code eher komplizieren als vereinfachen.

Wann sollten Sie Dienstobjekte extrahieren?

Auch hier gibt es keine feste Regel.

Normalerweise sind Service-Objekte besser für mittlere bis große Systeme geeignet; diejenigen mit einer anständigen Menge an Logik, die über die Standard-CRUD-Operationen hinausgeht.

Wenn Sie also denken, dass ein Code-Snippet möglicherweise nicht zu dem Verzeichnis gehört, in dem Sie es hinzufügen wollten, ist es wahrscheinlich eine gute Idee, es sich noch einmal zu überlegen und zu prüfen, ob es stattdessen in ein Dienstobjekt verschoben werden sollte.

Hier sind einige Indikatoren dafür, wann Service-Objekte verwendet werden sollten:

  • Die Aktion ist komplex.
  • Die Aktion erstreckt sich über mehrere Modelle.
  • Die Aktion interagiert mit einem externen Dienst.
  • Die Aktion ist kein Kernanliegen des zugrunde liegenden Modells.
  • Es gibt mehrere Möglichkeiten, die Aktion auszuführen.

Wie sollten Sie Serviceobjekte entwerfen?

Das Entwerfen der Klasse für ein Dienstobjekt ist relativ unkompliziert, da Sie keine besonderen Edelsteine ​​benötigen, keine neue DSL erlernen müssen und sich mehr oder weniger auf Ihre bereits vorhandenen Softwaredesign-Fähigkeiten verlassen können.

Normalerweise verwende ich die folgenden Richtlinien und Konventionen, um das Dienstobjekt zu entwerfen:

  • Zustand des Objekts nicht speichern.
  • Verwenden Sie Instanzmethoden, keine Klassenmethoden.
  • Es sollte nur sehr wenige öffentliche Methoden geben (vorzugsweise eine zur Unterstützung von SRP.
  • Methoden sollten Rich-Ergebnisobjekte und keine booleschen Werte zurückgeben.
  • Dienste befinden sich im app/services Verzeichnis. Ich ermutige Sie, Unterverzeichnisse für Domänen mit starker Geschäftslogik zu verwenden. Beispielsweise definiert die Datei app/services/report/generate_weekly.rb Report::GenerateWeekly , während app/services/report/publish_monthly.rb Report::PublishMonthly definiert.
  • Dienste beginnen mit einem Verb (und enden nicht mit Dienst): ApproveTransaction , SendTestNewsletter , ImportUsersFromCsv .
  • Dienste reagieren auf die Aufrufmethode. Ich habe festgestellt, dass die Verwendung eines anderen Verbs es etwas überflüssig macht: ApproveTransaction.approve() liest sich nicht gut. Außerdem ist die Call-Methode die De-facto-Methode für Lambda-, Procs- und Methodenobjekte.

Wenn Sie sich StatisticsController#index ansehen, werden Sie eine Gruppe von Methoden ( weeks_to_date_from , weeks_to_date_to , avg_distance usw.) bemerken, die mit dem Controller gekoppelt sind. Das ist nicht wirklich gut. Berücksichtigen Sie die Auswirkungen, wenn Sie den wöchentlichen Bericht außerhalb von statistics_controller generieren möchten.

In unserem Fall erstellen wir Report::GenerateWeekly und extrahieren die Berichtslogik aus StatisticsController :

  • Gist-Beispiel

So sieht StatisticsController#index jetzt sauberer aus:

  • Gist-Beispiel

Durch die Anwendung des Service-Objektmusters bündeln wir Code um eine bestimmte, komplexe Aktion und fördern die Erstellung kleinerer, klarerer Methoden.

Hausaufgabe: Erwägen Sie die Verwendung des Value-Objekts für den WeeklyReport anstelle von Struct .

Abfrageobjekte aus Controllern extrahieren

Was ist ein Query-Objekt?

Ein Abfrageobjekt ist ein PORO, das eine Datenbankabfrage darstellt. Es kann an verschiedenen Stellen in der Anwendung wiederverwendet werden und gleichzeitig die Abfragelogik verbergen. Es bietet auch eine gute isolierte Einheit zum Testen.

Sie sollten komplexe SQL/NoSQL-Abfragen in eine eigene Klasse extrahieren.

Jedes Abfrageobjekt ist dafür verantwortlich, eine Ergebnismenge basierend auf den Kriterien/Geschäftsregeln zurückzugeben.

In diesem Beispiel haben wir keine komplexen Abfragen, daher ist die Verwendung des Abfrageobjekts nicht effizient. Lassen Sie uns jedoch zu Demonstrationszwecken die Abfrage in Report::GenerateWeekly#call extrahieren und generate_entries_query.rb erstellen:

  • Gist-Beispiel

Und in Report::GenerateWeekly#call ersetzen wir:

 def call @user.entries.group_by(&:week).map do |week, entries| WeeklyReport.new( ... ) end end

mit:

 def call weekly_grouped_entries = GroupEntriesQuery.new(@user).call weekly_grouped_entries.map do |week, entries| WeeklyReport.new( ... ) end end

Das Abfrageobjektmuster trägt dazu bei, dass Ihre Modelllogik strikt auf das Verhalten einer Klasse bezogen bleibt, während gleichzeitig Ihre Controller dünn gehalten werden. Da sie nichts anderes als einfache alte Ruby-Klassen sind, müssen Abfrageobjekte nicht von ActiveRecord::Base erben und sollten nur für das Ausführen von Abfragen verantwortlich sein.

Extrahieren Sie den Eintrag zum Erstellen eines Dienstobjekts

Lassen Sie uns nun die Logik zum Erstellen eines neuen Eintrags für ein neues Dienstobjekt extrahieren. Lassen Sie uns die Konvention verwenden und CreateEntry erstellen:

  • Gist-Beispiel

Und jetzt ist unser EntriesController#create wie folgt:

 def create begin CreateEntry.new(current_user, entry_params).call flash[:notice] = 'Entry was successfully created.' rescue Exception => e flash[:error] = e.message end redirect_to root_path end

Verschieben Sie Validierungen in ein Formularobjekt

Nun, hier beginnen die Dinge interessanter zu werden.

Denken Sie daran, dass wir uns in unseren Richtlinien darauf geeinigt haben, dass Modelle Assoziationen und Konstanten enthalten sollen, aber sonst nichts (keine Validierungen und keine Rückrufe). Beginnen wir also damit, Callbacks zu entfernen und stattdessen ein Form-Objekt zu verwenden.

Ein Form-Objekt ist ein Plain Old Ruby Object (PORO). Es übernimmt vom Controller/Dienstobjekt überall dort, wo es mit der Datenbank kommunizieren muss.

Warum Formularobjekte verwenden?

Wenn Sie Ihre App umgestalten möchten, ist es immer eine gute Idee, das Single-Responsibility-Prinzip (SRP) im Auge zu behalten.

SRP hilft Ihnen, bessere Entwurfsentscheidungen darüber zu treffen, wofür eine Klasse verantwortlich sein sollte.

Ihr Datenbanktabellenmodell (ein ActiveRecord-Modell im Kontext von Rails) stellt beispielsweise einen einzelnen Datenbankeintrag im Code dar, sodass es keinen Grund gibt, sich mit irgendetwas zu befassen, was Ihr Benutzer tut.

Hier kommen Formularobjekte ins Spiel.

Ein Formularobjekt ist für die Darstellung eines Formulars in Ihrer Anwendung verantwortlich. Somit kann jedes Eingabefeld als Attribut in der Klasse behandelt werden. Es kann validieren, ob diese Attribute einige Validierungsregeln erfüllen, und es kann die „sauberen“ Daten dorthin weiterleiten, wo sie benötigt werden (z. B. Ihre Datenbankmodelle oder vielleicht Ihr Suchabfrage-Generator).

Wann sollten Sie ein Form-Objekt verwenden?

  • Wenn Sie die Validierungen aus Rails-Modellen extrahieren möchten.
  • Wenn mehrere Modelle durch eine einzige Formularübermittlung aktualisiert werden können, möchten Sie möglicherweise ein Form-Objekt erstellen.

Dadurch können Sie die gesamte Formularlogik (Namenskonventionen, Validierungen usw.) an einem Ort unterbringen.

Wie erstellt man ein Form-Objekt?

  • Erstellen Sie eine einfache Ruby-Klasse.
  • ActiveModel::Model (in Rails 3 müssen Sie stattdessen Naming, Conversion und Validations einschließen)
  • Beginnen Sie mit der Verwendung Ihrer neuen Formularklasse, als ob es sich um ein reguläres ActiveRecord-Modell handeln würde, wobei der größte Unterschied darin besteht, dass Sie die in diesem Objekt gespeicherten Daten nicht beibehalten können.

Bitte beachten Sie, dass Sie das Reform-Juwel verwenden können, aber wir bleiben bei POROs und erstellen entry_form.rb , das so aussieht:

  • Gist-Beispiel

Und wir werden CreateEntry ändern, um mit der Verwendung des Formularobjekts CreateEntry zu EntryForm :

 class CreateEntry ...... ...... def call @entry_form = ::EntryForm.new(@params) if @entry_form.valid? .... else .... end end end

Hinweis: Einige von Ihnen würden sagen, dass es nicht notwendig ist, vom Service-Objekt aus auf das Form-Objekt zuzugreifen, und dass wir das Form-Objekt einfach direkt vom Controller aufrufen können, was ein gültiges Argument ist. Ich hätte jedoch lieber einen klaren Fluss, und deshalb rufe ich das Form-Objekt immer vom Service-Objekt aus auf.

Rückrufe in das Dienstobjekt verschieben

Wie wir bereits vereinbart haben, möchten wir nicht, dass unsere Modelle Validierungen und Rückrufe enthalten. Wir haben die Validierungen mithilfe von Form-Objekten extrahiert. Aber wir verwenden immer noch einige Rückrufe ( after_create im Entry -Modell compare_speed_and_notify_user ).

Warum wollen wir Rückrufe von Modellen entfernen?

Rails-Entwickler bemerken normalerweise Callback-Probleme während des Testens. Wenn Sie Ihre ActiveRecord-Modelle nicht testen, werden Sie später Probleme bemerken, wenn Ihre Anwendung wächst und mehr Logik erforderlich ist, um den Rückruf aufzurufen oder zu vermeiden.

after_* Callbacks werden hauptsächlich in Bezug auf das Speichern oder Persistieren des Objekts verwendet.

Sobald das Objekt gespeichert ist, ist der Zweck (dh Verantwortlichkeit) des Objekts erfüllt. Wenn also immer noch Callbacks aufgerufen werden, nachdem das Objekt gespeichert wurde, sehen wir wahrscheinlich Callbacks, die außerhalb des Verantwortungsbereichs des Objekts liegen, und dann stoßen wir auf Probleme.

In unserem Fall senden wir eine SMS an den Benutzer, nachdem wir einen Eintrag gespeichert haben, der nicht wirklich mit der Domain von Entry zusammenhängt.

Eine einfache Möglichkeit, das Problem zu lösen, besteht darin, den Rückruf auf das zugehörige Dienstobjekt zu verschieben. Schließlich bezieht sich das Versenden einer SMS für den Endbenutzer auf das CreateEntry Service Object und nicht auf das Entry-Modell selbst.

Dadurch müssen wir in unseren Tests nicht mehr die Methode " compare_speed_and_notify_user " ausschließen. Wir haben es einfach gemacht, einen Eintrag zu erstellen, ohne dass eine SMS gesendet werden muss, und wir folgen einem guten objektorientierten Design, indem wir sicherstellen, dass unsere Klassen eine einzige Verantwortung (SRP) haben.

Also sieht unser CreateEntry jetzt ungefähr so ​​​​aus:

  • Gist-Beispiel

Verwenden Sie Dekorateure anstelle von Helfern

Während wir die Draper-Sammlung von Ansichtsmodellen und Dekorateuren problemlos verwenden können, bleibe ich für diesen Artikel bei POROs, wie ich es bisher getan habe.

Was ich brauche, ist eine Klasse, die Methoden für das dekorierte Objekt aufruft.

Ich kann method_missing verwenden, um das zu implementieren, aber ich werde Rubys Standardbibliothek SimpleDelegator .

Der folgende Code zeigt, wie SimpleDelegator verwenden, um unseren Basis-Decorator zu implementieren:

 % app/decorators/base_decorator.rb require 'delegate' class BaseDecorator < SimpleDelegator def initialize(base, view_context) super(base) @object = base @view_context = view_context end private def self.decorates(name) define_method(name) do @object end end def _h @view_context end end

Warum also die _h Methode?

Diese Methode fungiert als Proxy für den Ansichtskontext. Standardmäßig ist der Ansichtskontext eine Instanz einer Ansichtsklasse, wobei die Standardansichtsklasse ActionView::Base ist. Sie können wie folgt auf Ansichtshelfer zugreifen:

 _h.content_tag :div, 'my-div', class: 'my-class'

Um es bequemer zu machen, fügen wir ApplicationHelper eine decorate hinzu:

 module ApplicationHelper # ..... def decorate(object, klass = nil) klass ||= "#{object.class}Decorator".constantize decorator = klass.new(object, self) yield decorator if block_given? decorator end # ..... end

Jetzt können wir EntriesHelper Helfer zu Dekorateuren verschieben:

 # app/decorators/entry_decorator.rb class EntryDecorator < BaseDecorator decorates :entry def readable_time_period mins = entry.time_period return Time.at(60 * mins).utc.strftime('%M <small>Mins</small>').html_safe if mins < 60 Time.at(60 * mins).utc.strftime('%H <small>Hour</small> %M <small>Mins</small>').html_safe end def readable_speed "#{sprintf('%0.2f', entry.speed)} <small>Km/H</small>".html_safe end end

Und wir können readable_time_period und readable_speed wie folgt verwenden:

 # app/views/entries/_entry.html.erb - <td><%= readable_speed(entry) %> </td> + <td><%= decorate(entry).readable_speed %> </td>
 - <td><%= readable_time_period(entry) %></td> + <td><%= decorate(entry).readable_time_period %></td>

Struktur nach dem Refactoring

Am Ende hatten wir mehr Dateien, aber das ist nicht unbedingt eine schlechte Sache (und denken Sie daran, dass wir von Anfang an anerkannt haben, dass dieses Beispiel nur zu Demonstrationszwecken diente und nicht unbedingt ein guter Anwendungsfall für das Refactoring war):

 app ├── assets │ └── ... ├── controllers │ ├── application_controller.rb │ ├── entries_controller.rb │ └── statistics_controller.rb ├── decorators │ ├── base_decorator.rb │ └── entry_decorator.rb ├── forms │ └── entry_form.rb ├── helpers │ └── application_helper.rb ├── mailers ├── models │ ├── entry.rb │ ├── entry_status.rb │ └── user.rb ├── queries │ └── group_entries_query.rb ├── services │ ├── create_entry.rb │ └── report │ └── generate_weekly.rb └── views ├── devise │ └── .. ├── entries │ ├── _entry.html.erb │ ├── _form.html.erb │ └── index.html.erb ├── layouts │ └── application.html.erb └── statistics └── index.html.erb

Fazit

Auch wenn wir uns in diesem Blogbeitrag auf Rails konzentriert haben, ist RoR keine Abhängigkeit der beschriebenen Service-Objekte und anderer POROs. Sie können diesen Ansatz mit jedem Webframework, jeder Mobil- oder Konsolen-App verwenden.

Durch die Verwendung von MVC als Architektur von Web-Apps bleibt alles gekoppelt und macht Sie langsamer, da die meisten Änderungen Auswirkungen auf andere Teile der App haben. Außerdem zwingt es Sie dazu, darüber nachzudenken, wo Sie Geschäftslogik platzieren sollten – sollte sie in das Modell, den Controller oder die Ansicht aufgenommen werden?

Durch die Verwendung einfacher POROs haben wir die Geschäftslogik auf Modelle oder Dienste verlagert, die nicht von ActiveRecord erben, was bereits ein großer Gewinn ist, ganz zu schweigen davon, dass wir einen saubereren Code haben, der SRP und schnellere Einheitentests unterstützt.

Saubere Architektur zielt darauf ab, die Anwendungsfälle in der Mitte/oben in Ihrer Struktur zu platzieren, damit Sie leicht sehen können, was Ihre App tut. Es erleichtert auch die Übernahme von Änderungen, da es viel modularer und isolierter ist.

Ich hoffe, ich habe gezeigt, wie die Verwendung von Plain Old Ruby Objects und mehr Abstraktionen Bedenken entkoppelt, das Testen vereinfacht und dazu beiträgt, sauberen, wartbaren Code zu erstellen.

Siehe auch: Was sind die Vorteile von Ruby on Rails? Nach zwei Jahrzehnten Programmieren verwende ich Rails