Eine Klasse pro Rails-Controller-Aktion mit Aldous

Controller sind oft der Schandfleck einer Rails-Anwendung. Controller-Aktionen sind aufgebläht trotz unserer Versuche, sie dünn zu halten, und selbst wenn sie dünn aussehen, ist dies oft eine Illusion. Wir verschieben die Komplexität auf verschiedene before_actions, ohne die Komplexität zu reduzieren. In der Tat erfordert es oft ein beträchtliches Umgraben und eine geistige Kompilierung, um ein Gefühl für den Kontrollfluss einer bestimmten Aktion zu bekommen. 

Nachdem wir Service-Objekte für eine Weile im Tuts + dev-Team verwendet hatten, wurde klar, dass wir möglicherweise einige der gleichen Prinzipien auf Controller-Aktionen anwenden können. Wir haben schließlich ein Muster gefunden, das gut funktionierte und es in Aldous steckte. Heute werde ich auf Aldous-Controller-Aktionen und deren Vorteile für Ihre Rails-Anwendung eingehen.

Der Fall, in dem jede Controller-Aktion in eine Klasse aufgeteilt wird

Jede Aktion in eine separate Klasse aufzuteilen, war das erste, woran wir gedacht hatten. Einige der neueren Frameworks wie Lotus tun dies sofort, und Rails könnte dies mit ein bisschen Arbeit auch nutzen.

Controller-Aktionen, die einzeln sind ansonsten Aussage sind ein Strohmann. Selbst bescheidene Apps haben viel mehr zu bieten und schleichen sich in die Controller-Domäne ein. Es gibt Authentifizierung, Autorisierung und verschiedene Geschäftsregeln auf Controller-Ebene (z. B. wenn eine Person hierher geht und sie nicht angemeldet ist, bringen Sie sie zur Anmeldeseite). Einige Controller-Aktionen können recht komplex werden, und die gesamte Komplexität liegt im Bereich der Controller-Ebene.

In Anbetracht dessen, wofür eine Controller-Aktion verantwortlich sein kann, erscheint es nur natürlich, dass wir das alles in einer Klasse zusammenfassen. Wir können die Logik dann viel einfacher testen, da wir hoffentlich mehr Kontrolle über den Lebenszyklus dieser Klasse hätten. Dies würde uns auch erlauben, diese Controller-Aktionsklassen wesentlich kohärenter zu gestalten (komplexe REST-fähige Controller mit einer ganzen Reihe von Aktionen neigen dazu, den Zusammenhalt ziemlich schnell zu verlieren).. 

Bei Rails-Controllern gibt es andere Probleme, wie z. B. die Proliferation von Status auf Controller-Objekten über Instanzvariablen, die Tendenz zur Bildung komplexer Vererbungshierarchien usw. Wenn Sie Controller-Aktionen in ihre eigenen Klassen verschieben, können wir auch einige dieser Probleme ansprechen.

Was ist mit dem tatsächlichen Rails-Controller zu tun?

Bild von Mack Male

Ohne viel komplexes Hacken auf den Rails-Code können wir Controller in ihrer aktuellen Form nicht wirklich loswerden. Was wir tun können, ist, sie in Boilerplate umzuwandeln, und zwar mit ein wenig Code, der an die Aktionsklassen des Controllers delegiert werden kann. In Aldous sehen Controller so aus:

Klasse TodosController < ApplicationController include Aldous::Controller controller_actions :index, :new, :create, :edit, :update, :destroy end

Wir fügen ein Modul hinzu, damit wir Zugriff auf die haben Controller_actions Methode, und wir geben dann an, welche Aktionen der Controller haben soll. Intern ordnet Aldous diese Aktionen den entsprechend benannten Klassen im zu controller_actions / todos_controller Mappe. Dies ist noch nicht konfigurierbar, kann aber leicht gemacht werden und ist eine sinnvolle Standardeinstellung.

Eine einfache Aldous-Controller-Aktion

Das erste, was wir tun müssen, ist, Rails mitzuteilen, wo wir unsere Controller-Aktion finden sollen (wie ich oben erwähnt habe), also ändern wir unsere app / config / application.rb wie so:

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

Wir sind jetzt bereit, Aldous-Controller-Aktionen zu schreiben. Eine einfache könnte so aussehen:

Klasse TodosController :: Index < BaseAction def perform build_view(Todos::IndexView) end end

Wie Sie sehen, ähnelt es einem Service-Objekt, das von Entwurf ist. Konzeptionell ist eine Aktion im Wesentlichen ein Dienst, daher ist es sinnvoll, dass sie eine ähnliche Schnittstelle haben.

Es gibt jedoch zwei Dinge, die sofort nicht offensichtlich sind:

  • woher BaseAction kommt aus und was drin ist
  • Was build_view ist

Wir werden abdecken BaseAction kurz. Bei dieser Aktion werden jedoch auch Aldous-Ansichtsobjekte verwendet build_view kommt von. Wir behandeln hier keine Aldous-Ansichtsobjekte und Sie müssen sie nicht verwenden (obwohl Sie dies ernsthaft in Erwägung ziehen sollten). Ihre Aktion kann stattdessen einfach so aussehen:

Klasse TodosController :: Index < BaseAction def perform controller.render template: 'todos/index', locals:  end end

Dies ist mehr vertraut und wir werden von jetzt an daran festhalten, um das Wasser nicht mit dem Blick auf das Wasser zu trüben. Aber woher kommt die Controller-Variable??

Wie der Konstruktor für eine Aktion aussieht

Lass uns über das sprechen BaseAction dass wir oben gesehen haben. Es ist das Aldous-Äquivalent von ApplicationController, Daher wird dringend empfohlen, dass Sie eine haben. Nackte Knochen BaseAction ist:

Klasse BaseAction < ::Aldous::ControllerAction end

Es erbt von :: Aldous :: ControllerAction und eines der Dinge, die es erbt, ist ein Konstruktor. Alle Aldous-Controller-Aktionen haben dieselbe Konstruktorsignatur:

attr_reader: controller def initialize (controller) @controller = Controller-Ende

Welche Daten sind direkt von der Controller-Instanz verfügbar?

Da sie das sind, was sie sind, haben wir Aldous-Aktionen eng an einen Controller gekoppelt, sodass sie nahezu alles tun können, was ein Rails-Controller tun kann. Offensichtlich haben Sie Zugriff auf die Controller-Instanz und können von dort beliebige Daten abrufen. Sie möchten jedoch nicht alles auf der Controller-Instanz aufrufen - das wäre ein Nachteil für allgemeine Dinge wie Params, Header usw. Durch ein wenig Aldous-Zauber sind die folgenden Dinge direkt in der Aktion verfügbar:

  • Params
  • Überschriften
  • anfordern
  • Antwort
  • Kekse

Und Sie können auch mehr Objekte auf dieselbe Weise über einen Initialisierer verfügbar machen config / initializers / aldous.rb:

Aldous.configuration do aldous | aldous.controller_methods_exposed_to_action + = [: current_user] end

Mehr zu Aldous Views oder nicht

Aldous-Controller-Aktionen funktionieren gut mit Aldous-Ansichtsobjekten. Sie können die Ansichtsobjekte jedoch nicht verwenden, wenn Sie einige einfache Regeln beachten.

Aldous Controller-Aktionen sind keine Controller. Sie müssen daher immer den vollständigen Pfad zu einer Ansicht angeben. Sie können nicht tun:

Controller.Render: Index

Stattdessen musst du Folgendes tun:

Vorlage für Controller.render: 'todos / index'

Da Aldous-Aktionen keine Controller sind, können Sie Instanzvariablen dieser Aktionen nicht automatisch in den Ansichtsvorlagen verfügbar machen. Daher müssen Sie alle Daten als Locals angeben, z.

Vorlage für Controller.render: 'todos / index', Ortsansässige: todos: Todo.all

Wenn Sie den Status über Instanzvariablen nicht teilen, kann dies nur den Ansichtscode verbessern. Auch das explizite Rendern schadet nicht allzu sehr.

Eine komplexere Aldous-Controller-Aktion

Bild von Howard Lake

Schauen wir uns eine komplexere Aldous-Controller-Aktion an und sprechen wir über einige andere Dinge, die Aldous uns bietet, sowie über einige bewährte Methoden zum Schreiben von Aldous-Controller-Aktionen.

Klasse TodosController :: Update < BaseAction def default_view_data super.merge(todo: todo) end def perform controller.render(template: 'home/show', locals: default_view_data) and return unless current user controller.render(template: 'defaults/bad_request', locals: errors: [todo_params.error_message]) and return unless todo_params.fetch controller.render(template: 'todos/not_found', locals: default_view_data.merge(todo_id: params[:id])) and return unless todo controller.render(template: 'default/forbidden', locals: default_view_data) and return unless current_ability.can?(:update, todo) if todo.update_attributes(todo_params.fetch) controller.redirect_to controller.todos_path else controller.render(template: 'todos/edit', locals: default_view_data) end end private def todo @todo ||= Todo.where(id: params[:id]).first end def todo_params TodosController::TodoParams.build(params) end end

Der Schlüssel hier ist für die ausführen Methode, um die gesamte oder einen Großteil der relevanten Logik auf Controller-Ebene zu enthalten. Zunächst haben wir einige Zeilen, um mit den lokalen Voraussetzungen umzugehen (d. H. Dinge, die wahr sein müssen, damit die Aktion überhaupt Erfolg haben kann). Dies sollten alle Einzeiler sein, die den oben gezeigten ähneln. Das einzig Unansehnliche ist das "und", das wir ständig hinzufügen müssen. Dies wäre kein Problem, wenn wir Aldous-Ansichten verwenden würden, aber im Moment bleiben wir dabei. 

Wenn die Bedingungslogik für die lokale Vorbedingung zu komplex wird, sollte sie in ein anderes Objekt extrahiert werden, das ich Prädikatobjekt nenne. Auf diese Weise kann die komplexe Logik problemlos gemeinsam genutzt und getestet werden. Prädikateobjekte können irgendwann in Aldous zu einem Begriff werden.

Nachdem die lokalen Vorbedingungen behandelt wurden, müssen wir die Kernlogik der Aktion ausführen. Hierfür gibt es zwei Möglichkeiten. Wenn Ihre Logik wie oben beschrieben einfach ist, führen Sie sie einfach dort aus. Wenn es komplexer ist, verschieben Sie es in ein Serviceobjekt und führen Sie den Service aus. 

Meistens ist unsere Aktion ausführen Die Methode sollte der oben beschriebenen ähneln oder sogar weniger komplex sein, je nachdem, wie viele lokale Voraussetzungen Sie haben und wie viel Fehler es gibt.

Umgang mit starken Params

Eine andere Sache, die Sie in der obigen Aktionsklasse sehen, ist:

TodosController :: TodoParams.build (Params)

Dies ist ein weiteres Objekt, das von einer Aldous-Basisklasse erbt, und diese sind hier, damit mehrere Aktionen eine starke Params-Logik gemeinsam nutzen können. Es sieht so aus:

Klasse TodosController :: TodoParams < Aldous::Params def permitted_params params.require(:todo).permit(:description, :user_id) end def error_message 'Missing param :todo' end end

Sie geben Ihre Params-Logik in einer Methode und eine Fehlermeldung in einer anderen an. Sie instanziieren dann einfach das Objekt und rufen den Abruf auf, um die zulässigen Parameter zu erhalten. Es wird wiederkommen Null im fehlerfall.

Daten an Views übergeben

Eine weitere interessante Methode in der oben genannten Aktionsklasse ist:

def default_view_data super.merge (todo: todo) end

Wenn Sie Aldous-Ansichtsobjekte verwenden, gibt es einige magische Methoden, die diese Methode verwenden, aber wir verwenden sie nicht. Daher müssen wir sie einfach als lokalen Hash an jede Ansicht übergeben, die wir rendern. Die Basisaktion überschreibt auch diese Methode:

Klasse BaseAction < ::Aldous::ControllerAction def default_view_data  current_user: current_user, current_ability: current_ability,  end def current_user @current_user ||= FindCurrentUserService.perform(session).user end def current_ability @current_ability ||= Ability.new(current_user) end end

Aus diesem Grund müssen wir sicherstellen, dass es verwendet wird Super wenn wir es wieder in untergeordneten Aktionen überschreiben.

Handhabung vor Aktionen über Vorbedingungsobjekte

Alle oben genannten Dinge sind großartig, aber manchmal gibt es globale Voraussetzungen, die alle oder die meisten Aktionen im System beeinflussen müssen (z. B. möchten wir etwas mit der Sitzung tun, bevor eine Aktion ausgeführt wird usw.). Wie gehen wir damit um??

Dies ist ein guter Teil des Grunds für eine BaseAction. Aldous hat ein Konzept von Vorkonditionsobjekten - dies sind im Wesentlichen Controller-Aktionen in allem außer dem Namen. Sie konfigurieren, welche Aktionsklassen vor jeder Aktion in einer Methode auf der Datenbank ausgeführt werden sollen BaseAction, und Aldous erledigt dies automatisch für Sie. Werfen wir einen Blick:

Klasse BaseAction < ::Aldous::ControllerAction def preconditions [Shared::EnsureUserNotDisabledPrecondition] end def current_user @current_user ||= FindCurrentUserService.perform(session).user end def current_ability @current_ability ||= Ability.new(current_user) end end

Wir überschreiben die Methode der Vorbedingungen und geben die Klasse unseres Vorbedingungsobjekts an. Dieses Objekt könnte sein:

Klasse Shared :: EnsureUserNotDisabledPrecondition < BasePrecondition delegate :current_user, :current_ability, to: :action def perform if current_user && current_user.disabled && !current_ability.can?(:manage, :all) controller.render template: 'default/forbidden', status: :forbidden, locals: errors: ['Your account has been disabled'] end end end

Die obige Voraussetzung erbt von BasePrecondition, Das ist einfach:

Klasse BasePrecondition < ::Aldous::Controller::Action::Precondition end

Sie brauchen das nicht wirklich, es sei denn, alle Ihre Voraussetzungen müssen Code freigeben. Wir schaffen es einfach, weil wir schreiben BasePrecondition ist einfacher als :: Aldous :: Controller :: Action :: Voraussetzung.

Die obige Voraussetzung beendet die Ausführung der Aktion, da sie eine Ansicht erzeugt. Aldous übernimmt dies für Sie. Wenn Ihre Vorbedingung nichts rendert oder umleitet (z. B. Sie setzen einfach eine Variable in der Sitzung), wird der Aktionscode ausgeführt, nachdem alle Vorbedingungen erfüllt sind. 

Wenn Sie möchten, dass eine bestimmte Aktion nicht von einer bestimmten Vorbedingung beeinflusst wird, verwenden Sie dazu einfach Ruby. Überschreiben Sie die Voraussetzung Methode in Ihrer Aktion und lehnen Sie ab, welche Voraussetzungen Sie möchten:

def Vorbedingungen super.reject | klass | klass == Shared :: EnsureUserNotDisabledPrecondition end

Nicht ganz anders als normale Rails before_actions, aber in eine schöne "objekty" Schale gewickelt.

Fehlerfreie Aktionen

Bild von Duncan Hull

Das letzte, was zu beachten ist, ist, dass Controller-Aktionen genau wie Serviceobjekte fehlerfrei sind. Sie müssen niemals Code in der Controller-Action-Methode retten, die Aldous für Sie erledigt. Wenn ein Fehler auftritt, rettet Aldous ihn und verwendet den default_error_handler um mit der Situation fertig zu werden.

Das default_error_handler ist eine Methode, die Sie in Ihrer BaseAction überschreiben können. Wenn Sie Aldous-Ansichtsobjekte verwenden, sieht es folgendermaßen aus:

def default_error_handler (Fehler) Defaults :: ServerErrorView end

Da wir es nicht sind, können Sie dies stattdessen tun:

def default_error_handler (error) controller.render (template: 'defaults / server_error', status:: internal_server_error, local: errors: [error]) end

Sie behandeln also die nicht schwerwiegenden Fehler für Ihre Aktion als lokale Voraussetzungen und lassen Aldous sich über unerwartete Fehler sorgen.

Fazit

Mit Aldous können Sie Ihre Rails-Controller durch kleinere, kohärentere Objekte ersetzen, die weniger von einer Blackbox sind und die viel einfacher zu testen sind. Als Nebeneffekt können Sie die Kopplung in Ihrer gesamten Anwendung reduzieren, die Arbeit mit Ansichten verbessern und die Wiederverwendung von Logik in Ihrer Controller-Schicht durch Komposition fördern.

Noch besser: Aldous-Controller-Aktionen können mit Vanilla Rails-Controllern ohne zu viel Code-Duplizierung existieren, sodass Sie sie in jeder vorhandenen App verwenden können, mit der Sie gerade arbeiten. Sie können auch Aldous-Controller-Aktionen verwenden, ohne sich dazu zu verpflichten, Ansichtsobjekte oder Services zu verwenden, sofern Sie dies nicht möchten. 

Aldous hat es uns ermöglicht, unsere Entwicklungsgeschwindigkeit von der Größe der Anwendung, an der wir arbeiten, zu entkoppeln, während wir auf lange Sicht eine bessere, besser organisierte Codebase erhalten. Hoffentlich kann es dasselbe für Sie tun.