Eén klasse per Rails-controlleractie met Aldous

Controllers zijn vaak de doorn in het oog van een Rails-applicatie. Acties van de controller zijn opgeblazen, ondanks onze pogingen om ze mager te houden, en zelfs als ze er mager uitzien, is het vaak een illusie. We verplaatsen de complexiteit naar verschillende before_actions, zonder de genoemde complexiteit te verminderen. In feite vereist het vaak veel graven en mentale compilatie om een ​​gevoel te krijgen voor de controlestroom van een bepaalde actie. 

Na een tijdje gebruik te hebben gemaakt van service-objecten in het Tuts + dev-team, werd het duidelijk dat we mogelijk enkele van dezelfde principes op controlleracties kunnen toepassen. Uiteindelijk kwamen we met een patroon dat goed werkte en het in Aldous duwde. Vandaag zal ik kijken naar Aldous-controlleracties en de voordelen die ze kunnen bieden voor uw Rails-applicatie.

De zaak voor het doorbreken van elke controlleractie in een klasse

Elke actie uit elkaar halen in een aparte klas was het eerste waar we aan dachten. Sommige van de nieuwere frameworks zoals Lotus doen dit uit de doos, en met een beetje werk Rails kunnen ook hiervan profiteren.

Controlleracties die één zijn als ... anders verklaring is een stroman. Zelfs apps van een bescheiden omvang hebben veel meer spullen dan dat, kruipen in het domein van de controller. Er zijn verificatie-, autorisatie- en verschillende bedrijfsregels op controllerniveau (bijvoorbeeld als een persoon hier naartoe gaat en niet is aangemeld, neemt u deze mee naar de aanmeldingspagina). Sommige acties van de controller kunnen behoorlijk ingewikkeld worden en alle complexiteit zit stevig in het domein van de controllerlaag.

Gezien hoe ver een controlleractie verantwoordelijk kan zijn, lijkt het niet meer dan logisch dat we dat allemaal in een klasse indelen. We kunnen de logica dan veel gemakkelijker testen, omdat we hopelijk meer controle hebben over de levenscyclus van die klasse. Het zou ons ook in staat stellen om deze controller-actieklassen veel meer samenhangend te maken (complexe RESTful-controllers met een volledig complement van acties hebben de neiging om de cohesie vrij snel te verliezen). 

Er zijn nog andere problemen met Rails-controllers, zoals de proliferatie van de staat op controllerobjecten via instantievariabelen, de neiging om complexe overervingshiërarchieën te vormen, enz. Door controller-acties in hun eigen klassen te duwen, kunnen we ook enkele van die acties aanpakken.

Wat te doen met de Actual Rails Controller

Afbeelding door Mack Man

Zonder veel complex hacken op de Rails-code, kunnen we niet echt ontdoen van controllers in hun huidige vorm. Wat we kunnen doen is ze in boilerplate veranderen met een kleine hoeveelheid code om te delegeren naar de actieklassen van de controller. In Aldous zien controllers er als volgt uit:

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

We nemen een module op zodat we toegang hebben tot de controller_actions methode, en we geven vervolgens aan welke acties de controller zou moeten hebben. Intern zal Aldous deze acties toewijzen aan corresponderende klassen in de controller_actions / todos_controller map. Dit is nog niet configureerbaar, maar kan eenvoudig zo worden gemaakt, en het is een verstandige standaard.

A Basic Aldous Controller Action

Het eerste wat we moeten doen is Rails vertellen waar onze controlleractie te vinden is (zoals ik hierboven heb genoemd), dus we passen onze app / config / application.rb zoals zo:

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

We zijn nu klaar om Aldous-controlleracties te schrijven. Een eenvoudige kan er als volgt uitzien:

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

Zoals je ziet lijkt het enigszins op een service-object, dat is ontworpen. Conceptueel is een actie in feite een service, dus is het logisch dat ze een vergelijkbare interface hebben.

Er zijn echter twee dingen die meteen niet voor de hand liggen:

  • waar BaseAction komt van en wat zit erin
  • wat build_view is

We zullen dekken BaseAction binnenkort. Maar deze actie gebruikt ook Aldous-weergaveobjecten, en dat is waar build_view komt van. We behandelen Aldous-weergaveobjecten hier niet en je hoeft ze niet te gebruiken (hoewel je dit serieus moet overwegen). Je actie kan er eenvoudig als volgt uitzien:

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

Dit is meer bekend en we houden ons hier vanaf nu aan, om de wateren niet te vervuilen met aan het beeld gerelateerde spullen. Maar waar komt de regelaarvariabele vandaan??

Hoe de constructeur voor een actie eruit ziet

Laten we het hebben over de BaseAction dat we hierboven zagen. Het is het Aldous equivalent van ApplicationController, dus het wordt sterk aanbevolen om er een te hebben. Een kale botten BaseAction is:

klasse BaseAction < ::Aldous::ControllerAction end

Het erft van :: Aldous :: ControllerAction en een van de dingen die het erft is een constructeur. Alle Aldous-controlleracties hebben dezelfde constructorsignatuur:

attr_reader: controller def initialize (controller) @controller = controller-einde

Welke gegevens zijn direct beschikbaar vanaf de controller-instance

Als wat ze zijn, hebben we Aldous-acties nauw gekoppeld aan een controller en kunnen ze zo ongeveer alles doen wat een Rails-controller kan doen. Uiteraard hebt u toegang tot de controllerinstantie en kunt u vanaf daar elke gewenste gegevens ophalen. Maar je wilt niet alles op de controllerinstantie oproepen - dat zou een sleur zijn voor algemene dingen zoals params, headers, enz. Dus via een klein beetje Aldous-magie zijn de volgende dingen direct beschikbaar over de actie:

  • params
  • headers
  • verzoek
  • antwoord
  • koekjes

En je kunt via een initializer op dezelfde manier meer dingen beschikbaar maken config / initialiseerders / aldous.rb:

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

Meer over Aldous Views of Not

Aldous-controlleracties zijn ontworpen om goed samen te werken met Aldous-weergaveobjecten, maar u kunt ervoor kiezen de weergave-objecten niet te gebruiken als u een paar eenvoudige regels volgt.

Aldous controlleracties zijn geen controllers, dus je moet altijd het volledige pad naar een weergave bieden. Je kunt niet doen:

controller.render: index

In plaats daarvan moet je doen:

controller.render-sjabloon: 'todos / index'

Aangezien Aldous-acties geen controllers zijn, kunt u ook niet toestaan ​​dat instantievariabelen van deze acties automatisch beschikbaar zijn in de weergavesjablonen, dus moet u alle gegevens als locals opgeven, bijvoorbeeld:

template controller.render: 'todos / index', locals: todos: Todo.all

Als u de status niet deelt via instantievariabelen, kan dit uw weergavecode alleen verbeteren, en een explicietere weergave zal ook niet veel pijn doen.

Een complexere Aldous-controlleractie

Afbeelding door Howard Lake

Laten we eens kijken naar een complexere Aldous-controlleractie en praten over enkele andere dingen die Aldous ons geeft, evenals enkele van de beste manieren om Aldous-controlleracties te schrijven.

class 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

De sleutel hier is voor de uitvoeren methode om alle of de meeste van de relevante controller-level logica te bevatten. Eerst hebben we een paar regels om de lokale randvoorwaarden aan te pakken (dat wil zeggen dingen die waar moeten zijn om de actie zelfs een kans van slagen te geven). Deze moeten allemaal oneliners zijn, vergelijkbaar met wat u hierboven ziet. Het enige onooglijke is het 'en terugkomen' dat we moeten blijven toevoegen. Dit zou geen probleem zijn als we Aldous-meningen zouden gebruiken, maar voorlopig zitten we ermee vast. 

Als de conditionele logica voor de lokale preconditie te ingewikkeld wordt, moet deze worden geëxtraheerd in een ander object, dat ik een predikaatobject noem - op deze manier kan de complexe logica gemakkelijk worden gedeeld en getest. Voorspellingsobjecten kunnen op een gegeven moment een begrip binnen Aldous worden.

Nadat de lokale randvoorwaarden zijn behandeld, moeten we de kernlogica van de actie uitvoeren. Er zijn twee manieren om dit aan te pakken. Als uw logica eenvoudig is, zoals het hierboven is, voer het dan gewoon uit. Als het complexer is, duwt u het in een serviceobject en voert u de service vervolgens uit. 

Meestal is onze actie uitvoeren methode moet vergelijkbaar zijn met bovenstaande, of zelfs minder complex, afhankelijk van het aantal lokale randvoorwaarden dat u heeft en de mogelijkheid van falen.

Omgaan met sterke params

Een ander ding dat je ziet in de bovenstaande actieklasse is:

TodosController :: TodoParams.build (params)

Dit is een ander object dat erft van een Aldous-basisklasse, en deze zijn hier om meerdere acties in staat te stellen om sterke params-logica te delen. Het ziet er zo uit:

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

U levert uw params-logica in de ene methode en een foutmelding in een andere. Vervolgens plaatst u het object eenvoudig en belt u het op om de toegestane params te verkrijgen. Het zal terugkeren nul in het geval van een fout.

Gegevens doorgeven aan weergaven

Een andere interessante methode in de actieklasse hierboven is:

def default_view_data super.merge (todo: todo) einde

Wanneer u Aldous-weergaveobjecten gebruikt, is er enige magie die deze methode gebruikt, maar we gebruiken ze niet, dus we moeten deze gewoon als een hash van een lokale gebruiker doorgeven aan elke weergave die we renderen. De basisactie vervangt ook deze 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

Dit is waarom we ervoor moeten zorgen om te gebruiken super wanneer we het opnieuw opheffen in kinderacties.

Afhandeling vóór acties via precondition-objecten

Al het bovenstaande is geweldig, maar soms heb je globale randvoorwaarden, die van invloed moeten zijn op alle of de meeste acties in het systeem (we willen bijvoorbeeld iets doen met de sessie voordat een actie wordt uitgevoerd, enz.). Hoe pakken we dat aan??

Dit is een goed deel van de reden voor het hebben van een BaseAction. Aldous heeft een concept van precondition-objecten - dit zijn in feite controller-acties in alles behalve naam. U configureert welke actieklassen moeten worden uitgevoerd vóór elke actie in een methode op de BaseAction, en Aldous zal dit automatisch voor u doen. Laten we eens kijken:

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

We overschrijven de methode voor randvoorwaarden en leveren de klasse van ons precondition-object. Dit object kan zijn:

class Shared :: ClaimUserNotDisabledPrecondition < 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

De bovenstaande voorafgaande voorwaarde erft van BasePrecondition, wat eenvoudig is:

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

Je hebt dit niet echt nodig, tenzij al je randvoorwaarden een code moeten delen. We creëren het gewoon omdat we schrijven BasePrecondition is gemakkelijker dan :: Aldous :: Controller :: Actie :: Voorwaarde.

De bovenstaande voorwaarde sluit de uitvoering van de actie af omdat het een weergave oplevert - Aldous zal dit voor u doen. Als uw voorwaarde niet wordt gerenderd of omgeleid (bijvoorbeeld als u eenvoudig een variabele in de sessie instelt), wordt de actiecode uitgevoerd nadat aan alle voorwaarden is voldaan. 

Als u wilt dat een bepaalde actie niet wordt beïnvloed door een bepaalde voorwaarde, gebruiken we basale Ruby om dit te bereiken. Overschrijf de eerste vereiste methode in uw actie en verwerpt welke randvoorwaarden u ook leuk vindt:

def preconditions super.reject | klass | klass == Shared :: AllowUserNotDisabledPrecondition end

Niet zo heel anders dan reguliere Rails before_actions, maar gewikkeld in een mooie 'obscene' schaal.

Foutloze acties

Beeld door Duncan Hull

Het laatste waar u rekening mee moet houden, is dat controlleracties foutloos zijn, net als service-objecten. Je hoeft nooit code te redden in de controller action-actiemethode - Aldous zal dit voor je regelen. Als er een fout optreedt, zal Aldous deze redden en de default_error_handler om de situatie aan te pakken.

De default_error_handler is een methode die u kunt negeren op uw BaseAction. Bij het gebruik van Aldous view-objecten ziet het er als volgt uit:

def default_error_handler (error) Defaults :: ServerErrorView end

Maar aangezien we dat niet zijn, kunt u dit in plaats daarvan doen:

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

Dus behandel de niet-fatale fouten voor uw actie als lokale randvoorwaarden en laat Aldous zich zorgen maken over de onverwachte fouten.

Conclusie

Met Aldous kunt u uw Rails-controllers vervangen door kleinere, meer samenhangende objecten die een stuk minder een zwarte doos zijn en veel gemakkelijker te testen zijn. Als een neveneffect kunt u de koppeling tijdens uw gehele toepassing verminderen, de manier waarop u werkt met weergaven verbeteren en het hergebruik van logica in uw controllerkaart promoten via de compositie.

Beter nog, Aldous-controlleracties kunnen naast elkaar bestaan ​​met vanille Rails-controllers zonder al te veel codeduplicatie, dus u kunt ze gaan gebruiken in elke bestaande app waarmee u werkt. U kunt ook Aldous controller-acties gebruiken zonder zich te verplichten om view-objecten of -services te gebruiken, tenzij u dat wilt. 

Aldous heeft ons in staat gesteld om onze ontwikkelingssnelheid te ontkoppelen van de grootte van de applicatie waaraan we werken, terwijl we op de lange termijn een betere, meer georganiseerde codebase krijgen. Hopelijk kan het hetzelfde voor u doen.