Cookies
Diese Website verwendet Cookies und ähnliche Technologien für Analyse- und Marketingzwecke. Durch Auswahl von Akzeptieren stimmen Sie der Nutzung zu, alternativ können Sie die Nutzung auch ablehnen. Details zur Verwendung Ihrer Daten finden Sie in unseren Datenschutz­hinweisen, dort können Sie Ihre Einstellungen auch jederzeit anpassen.

Engineering

Strukturierung von organisch gewachsenen Rails-Anwendungen
Minuten Lesezeit
Blog Post - Strukturierung von organisch gewachsenen Rails-Anwendungen
Martin Honermeyer

Ab einer bestimmten Anwendungsgröße treten jedoch Probleme auf, die sich bei Einhaltung der „reinen MVC-Lehre“ nicht verhindern lassen. Zum Beispiel wachsen Model-Klassen soweit an, dass sie unübersichtlich werden und außerdem Funktionalitäten übernehmen, die nicht mehr unbedingt direkt dem jeweiligen Model zuzurechnen sind. Sie werden zu sogenannten Gottobjekten. In den meisten Fällen betrifft das insbesondere die User-Klasse, die mit Aufgaben überladen wird, da der User in den meisten Anwendungen eine Vielzahl von Interaktionen mit anderen Objekten der Geschäftslogik eingeht.

Oft lässt sich auch keine klare Zuständigkeit für eine Funktionalität finden, so dass die jeweilige Methode einfach in die nächstliegende Klasse übernommen wird. Der Einsatz der ORM-Schicht ActiveRecord führt, da sie keine klare Abgrenzung zwischen Logik und Persistenz bietet, vielfach zu einer Vermischung unterschiedlicher Aufgabenbereiche. Auch werden Hilfsmethoden, die eigentlich nur bei der Darstellung im View helfen sollen, direkt im jeweiligen Model untergebracht, um sie anschließend im Template zu verwenden.

Mit diesen Verletzungen des Single Responsibility Principle verliert das Projekt an Übersichtlichkeit und Klarheit in der Struktur. Insbesondere neue Entwickler tun sich dann schwer, in eine bestehende Code-Basis einzusteigen.

Der Werkzeugkasten

Im Folgenden sollen Techniken vorgestellt werden, mit denen diese Probleme eingedämmt werden können. Eine der besten Ressourcen für Rails-Entwickler zum Thema ist übrigens 7 Patterns to Refactor Fat ActiveRecord Models. Dort finden sich einige der meist genutzten Lösungen aus der Rails-Praxis. Einige der dort vorgestellten Techniken finden sich auch hier wieder.

Modules (Concerns)

Oft lassen sich innerhalb eines Models bestimmte Methoden, Konstanten etc. gruppieren, da sie funktional einen bestimmten Bereich abdecken. Zum Beispiel wäre es denkbar, ein Modul Addressable anzulegen, welches Validierungen für eine Adresse enthält. Dieses Modul ließe sich dann sowohl innerhalb einer Customer- als auch in einer Order-Klasse inkludieren, um die gleiche Validierungsfunktionalität sowohl für die hinterlegte Wohnadresse des Kunden als auch für die Lieferadresse von Bestellungen nutzen.

module Adressable
  extend ActiveSupport::Concern

  included do
    validates :street, :zip_code, presence: true
    validates :zip_code, :numericality
    # ...
  end
end

class Customer < ActiveRecord::Base
  include Addressable
  # ...
end

class Order < ActiveRecord::Base
  include Addressable
  # ...
end

Es gibt allerdings auch Fälle, bei denen der Einsatz von Concerns fragwürdig ist. Wenn eine bestimmte Klasse (meist die User-Klasse) zu umfangreich wird, können zusammengehörige Methoden und Konstanten mittels Concerns in separate Modul-Dateien ausgelagert werden. Während man so zwar bestimmte Funktionalitäten gruppieren kann, leidet die Übersicht. Oft ist nicht mehr klar, in welchem Modul eine bestehende Methode zu finden ist oder in welchem der bestehenden Module eine neue Methode sinnvollerweise unterzubringen ist. Mit Concerns sollte daher sparsam umgegangen werden.

Form-Objekte

Form-Objekte sind ein Werkzeug, welches bei komplizierten, eventuell mehrschrittigen Formularen zum Einsatz kommen kann. Oft werden in solchen Formularen Felder verschiedener Models zur Bearbeitung ausgegeben. Mit einem Form-Objekt können deren Werte gesammelt in einer Klasse angenommen und validiert werden, bevor sie in die jeweiligen Objektinstanzen geschrieben werden. Die Verarbeitung von Angaben wie z.B. die bei der Registrierung zu abonnierenden Newsletter können so aus der User-Klasse fern gehalten werden.

Auch ist ist mit dieser Technik leicht möglich, virtuelle Felder einzuführen. Ein Beispiel ist das Akzeptieren von Allgemeinen Geschäftsbedingungen. Solche Angaben müssen meist nur einmalig geprüft, aber nicht über ein ORM-Model persistiert werden.

class UserRegistrationForm
  include ActiveModel::Model

  attr_accessor :name, :email, :age, :terms_of_service, :newsletters_to_subscribe

  validates :name, :email, presence: true
  validates :age, numericality: { greater_than_or_equal_to: 18 }
  validates :terms_of_service, acceptance: true

  def save!
    user = User.create! name: name, email: email
    newsletters_to_subscribe.each do |newsletter|
      NewsletterSubscription.create! user: user, newsletter: newsletter
    end
  end
end

View-Objekte (Presenter)

Auf der View-Ebene einer MVC-Anwendung besteht häufig das Problem, dass bestimmte Ausgaben auf der Website eine nicht-triviale Anzeigelogik erfordern. Diese ist dann nur an dieser Stelle erforderlich und wird nicht an anderen Stellen in der Anwendung benötigt. In solchen Fällen bietet sich die Nutzung sogenannter View-Objekte oder Presenter an. Das sind meist einfache Klassen oder Module, die Methoden anbieten, die ausschließlich in den Views genutzt werden. Sie werden meist im Controller instanziiert und kapseln das jeweilige Model. Mittels Delegation oder Decoration können die Methoden dieser View-Objekte transparent an Stelle des jeweils gekapselten Objekts aufgerufen werden. Man verwendet für diese speziellen Decorators, die nur in Views genutzt werden, auch den Begriff Presenter.

# app/presenters/user_presenter.rb
class UserPresenter < Struct.new(:user)
  def name_with_salutation
     [salutation, first_name, last_name].join(‘ ‘)
   end
end

# app/controllers/users_controller.rb
# ...
  def show
    @user = UserPresenter.new(current_user)
  end

# app/views/users/show.html.erb
Name: <%= @user.name_with_salutation %>

Service-Objekte

In Anwendungen, die wie bei Ruby on Rails objektorientiert programmiert sind, basieren viele Geschäftsvorfälle auf der Interaktion zwischen Instanzen unterschiedlicher Objektklassen. Bei einfachen Aktionen wie z.B. dem Login eines Nutzers sind nur ein bis maximal zwei Objekte am Ablauf der jeweiligen Geschäftslogik beteiligt. In solchen Fällen ist meist schnell klar, in welcher Klasse eine entsprechende Methode zu implementieren ist.

Dann wiederum gibt es viele Fälle, in denen mehrere Objekte miteinander kommunizieren und bei denen nicht klar ist, welches das Subjekt und welches das Objekt ist. Als Beispiel kann hier das Versenden von E-Mail-Benachrichtigungen aufgeführt werden. Zum Beispiel können in einem Shop verschiedene Benachrichtigungen ausgelöst werden, wenn sich der Status seiner Bestellung ändert. Der Versand einer entsprechenden Benachrichtigung lässt sich nicht zweifelsfrei dem Bestell- oder dem Nutzer-Objekt zuweisen.

Als saubere Lösung kann hier eine Service-Klasse implementiert werden. Diese kapselt die Logik des Benachrichtigungsversands. Order- und Customer-Objekt werden dieser neuen Klasse übergeben. Das hat den Vorteil, dass diese Objekte in Tests durch Pseudoinstanzen, sogenannte Mocks, ersetzt werden können. Dadurch lässt sich der produzierte Code sehr feingranular testen.

class OrderStatusNotifier
  def initialize(customer:, order:)
    @customer, @order = customer, order
  end

  def notify_ordered
      CustomerMailer.order_received_notification(@customer.email, @order).deliver
      EmployeeMailer.new_order_notification(@order).deliver
  end   

  def notify_delivered
    # ...
  end
end

# z.B. im Controller
OrderStatusNotifier.notify_ordered(customer: customer, order: order)

Data, Context And Interaction (DCI)

Sobald die Geschäftslogik einen gewissen Komplexitätsgrad erreicht und die Interaktion zwischen Objekten je nach aktuellem Status unterschiedliche Wege nehmen kann, bietet es sich an, Prozesse als zusammengehörige Einheit zu modellieren. Im Buch Clean Ruby beschreibt Jim Gay, wie sich das Pattern Data, Context and Interaction (DCI) im Kontext von Ruby-On-Rails-Anwendungen gewinnbringend einsetzen lässt.

  • Weitergehend als bei Service-Objekten wird hier ein in sich geschlossener Kontext eingeführt, innerhalb dessen die interagierenden Objektinstanzen um bestimmte Funktionalitäten erweitert werden.
  • Außerdem steht ein Kontext-Objekt innerhalb eines DCI-Moduls als globale Variable zur Verfügung, damit teilnehmende Objekte problemlos auf andere teilnehmende Objekte zugreifen können.
  • Durch die Übergabe des Controllers als Listener-Objekt oder die Nutzung einer Multiblock-Syntax kann die aufrufende Steuereinheit (z.B. der Controller) je nach Ergebnis der Verarbeitung die passenden Rückgabewerte erhalten.

Mit Hilfe der extend-Funktionalität von Ruby lässt sich nun der komplette für einen Geschäftsvorfall verwendete Code innerhalb einer Datei unterbringen: (Dieses Beispiel ist nicht optimal umgesetzt und soll nur die Möglichkeiten demonstrieren.)

# booking_order.rb
# (benötigt Gem "multiblock")
class BookingOrder
  include Context

  attr_accessor :order, :customer

  def initialize(order, customer)
    @order = order.extend BookableOrder
    @customer = customer.extend BuyingCustomer
  end

  def book
    wrapper = Multiblock.wrapper

    in_context do
      booking = transaction do
        order.book.tap do |booking|
          customer.paid_for booking
        end
      end
      CustomerMailer.booking_confirmation(booking).deliver
      wrapper.call :success, booking
    rescue
      wrapper.call :failure
    end
  end

  module BookableOrder
    include ContextAccessor

    def book
      Booking.create! customer: context.customer, order: self, amount: booking_total
    end

    def booking_total
      items.sum(&:price) + shipping_costs
    end
  end

  module BuyingCustomer
    def paid_for(booking)
      decrement :balance, booking.amount
    end
  end
end

# booking_controller.rb
# ...
  def new
    BookingOrder.new(order, customer).book do |on|
      on.success do |booking|
        # ...
      end
      on.failure do |booking|
        # ...
      end
    end
  end
# ...

Ein Vorteil von DCI ist, dass sich die Kontext-Klassen wegen der Nutzung des Listener-Patterns auch außerhalb von Controllern, also z.B. in automatisierten Hintergrund-Jobs oder in der Rails-Console, nutzen lassen.

Leider ergeben sich, durch die Eigenheiten von Ruby on Rails bedingt, einige Limitierungen. Beispielsweise lassen sich Validations in ActiveRecord nicht kontextspezifisch für einzelne Objektinstanzen definieren. Sie müssen somit zentral in der zu Grunde liegenden Klasse definiert sein. Um Validierungen nur innerhalb eines DCI-Kontext durchführen zu können, muss man daher auf Lösungen wie das Definieren einer eigenen validate-Methode zurückgreifen.

Fazit

Für die meisten Probleme gibt es Lösungen und Patterns, um sie einzudämmen oder gar komplett zu verhindern. Wie sich in der Praxis zeigt, gibt es jedoch keine One-Size-Fits-All-Lösung. Je nach konkretem Anwendungsfall muss entschieden werden, welches Pattern am Erfolg versprechendsten ist. Oftmals führt der Weg dann auch in eine Sackgasse, so dass das Ergebnis noch komplexer wird als die bereits vorhandene Variante.

Dieses Problem lässt sich nur mit der Zeit und durch das Sammeln von Erfahrungen eindämmen. Voraussetzung ist allerdings die Kenntnis möglichst vieler hilfreicher Patterns, um einen Werkzeugkasten zu haben, aus dem man sofern möglich immer das passende Tool for the Job ziehen kann.

Ihr sucht den richtigen Partner für eure digitalen Vorhaben?

Lasst uns reden.