Refactoring von Rails-Anwendungen: Was tun wenn der Laufsteg durchbricht?

Maximilian Schöfmann, 12. März 2009 23:05

Bevor ich mit dem eigentlichen Artikel beginne, möchte ich den geneigten Leser bitten, kurz folgenden Befehl im Root-Verzeichnis seiner aktuellen Rails-Anwendung einzugeben:

wc -l app/models/* | sort

Fertig? Ok. Alle Nicht-Windows-User sollten eine hübsche Liste an Zahlen und Dateinamen zurück bekommen haben. Alle Leser, bei denen in den letzten 3 Einträgen der Liste die Datei user.rb auftaucht, oder ein Eintrag mindestens vierstellig ist, würde ich bitten diese Zeile in einen Kommentar zu pasten. Danke.

Warum Kate Moss keine Rails-Entwicklerin wurde

Im Lebenszyklus einer Webanwendung werden ständig neue Features entwickelt. Nach dem Credo “Skinny ControllerFat Model” wandert die Business-Logik der Features in das Model, und das ist auch richtig so. Für viele Rails-Entwickler ist Model aber gleichbedeutend mit ActiveRecord, und so wachsen diese Klassen oft auf unübersichtliche Größen an. Das User-Model ist hier ein besonders populäres Opfer, insbesondere wenn es nicht um ein Profile- oder Person-Model ergänzt wurde.

Höchste Zeit also für ein wenig Refactoring! Und das geht so:

Ruby-Lego: Module und Mixins

Die einfachste Möglichkeit besteht darin, thematisch verwandte Methoden in Modulen zu sammeln und per include im Model verfügbar zu machen. Idealerweise werden diese Module dann in einem separeten Namespace gruppiert:

# app/models/user.rb
class User < ActiveRecord::Base
  include UserStuff::Admin
end
 
# app/models/user_stuff/admin.rb
module UserStuff
  module Admin
    def schedule_backup
      # ...
    end
  end
end

Wird die gleiche Funktionalität von verschiedenen Klassen benötigt, lassen sich solche Mixins bei entsprechendem Design auch wiederverwenden:

# app/models/user.rb
class User < ActiveRecord::Base
  include Authentication
end
 
# app/models/api_client.rb
class ApiClient
  include Authentication
end
 
# lib/authentication.rb
module Authentication
  def authenticate(credentials)
    # authenticate via ldap, kerberos etc.
  end
end

Der Unterschied zwischen Klassen in Ruby und Postschaltern zur Mittagszeit

Als Alternative zu Modulen, lässt sich der Umstand ausnutzen, dass Klassen in Ruby immer offen sind. Es können jederzeit weitere Methoden hinzugefügt werden, wodurch sich lange Klassendefinitionen einfach aufzusplitten lassen um sie in separaten Dateien abzulegen:

# app/models/user.rb
class User < ActiveRecord::Base
  def name
    "#{first_name} #{last_name}"
  end
end
require_dependency 'user/admin'
 
# app/models/user/admin.rb
class User < ActiveRecord::Base
  def schedule_backup
    # ...
  end
end

Jake Howerton hat dieses Muster in seinem (ganze 7 LOC umfassenden) Gem, concerned_with, mit etwas syntaktischem Zucker versehen:

class User < ActiveRecord::Base
  concerned_with :admin, :validations, ... 
  # ...
end

Hier werden automatisch die Dateien app/models/user/admin.rb und app/models/user/validations.rb geladen.

Kosmetik für Models

Sowohl Module als auch “concerned_with” haben leider einen gravierenden Nachteil: Es ist meist reine Kosmetik. Die Methoden werden lediglich in eine andere Datei verschoben, verschmutzen aber weiterhin den Namensraum der ursprünglichen Klasse. Werden Methoden der Urprungsklasse überschrieben, gibt es auch keine einfache Möglichkeit auf die Originalimplementierung zurückzugreifen. Allein die Lösung mit Modulen lässt zumindest zu, eine von der Klasse überschriebene, aber in einem Modul definierte Methode mittels super zu referenzieren. In der Regel ist aber eher der umgekehrte Fall erwünscht, nämlich die Methode der Klasse zu überschreiben. Zudem führt ein solches Vorgehen zu einer unschönen Kopplung von Modul und Klasse – die Methode der Klasse muß von der Existenz des Moduls wissen.

Ein weitaus größerer Nachteil ist jedoch, dass die Methoden selbst dafür sorgen müssen, nie im falschen Kontext aufgerufen zu werden. Im obigen Beispiel würde dies vermutlich wie folgt aussehen:

def schedule_backup
  raise "Security Error!" unless has_role?(:admin)
  #...
end

Die Methode muß sich hier unnötigerweise mit administrativer Logik herumschlagen, die nichts zu ihrem eigentlich Auftrag, dem Anstoßen eines Backups, beiträgt. Möchte man nicht auch noch Aspektorientierung aus der Trickkiste hervorholen, müsste der entsprechende Code zudem in jeder verwandten Methode dupliziert werden – ein klarer Verstoß des in Rails heiligen DRY-Prinzips.

Zur Verteidigung von “concerned_with” darf nicht unerwähnt bleiben, dass das kanonische Beispiel für dieses Gem die Auslagerung von Validations und ähnlichem ist. Für solche Zwecke ist es bei komplexen Klassen durchaus nützlich.

Erbschaften sind immer willkommen!

Eine klassische, objektorientierte Herangehensweise an solche Probleme ist ohne Zweifel Vererbung:

# app/models/user.rb
class User < ActiveRecord::Base
  def name
    "#{first_name} #{last_name}"
  end
end
 
# app/models/admin.rb
class Admin < User
  def schedule_backup
    # ...
  end
end

Durch diesen Ansatz wird sichergestellt, dass jedes Objekt nur die Methoden besitzt, die es auch besitzen darf. Zusätzliche Prüfungen im Code, wie im obigen Beispiel, sind daher nicht notwenig. Aus überschriebenen Methoden in Admin lässt sich auch einfach per super die gleichnamige Originalmethode in User aufrufen.

Rails unterstützt Vererbung in ActiveRecord-Models sehr bequem mittels STI (Single Table Inheritance) durch Hinzufügen einer type-Spalte zur jeweiligen Tabelle.
Dieser Ansatz ist aber nur sinnvoll, wenn tatsächlich eine “is_a”-Beziehung vorliegt. Er funktioniert auch nur 1x pro Klasse, da Ruby keine direkte, mehrfache Vererbung erlaubt. Letzteres lässt sich in manchen Fällen durch komplexe Vererbungshierarchien abbilden, aber unnötige Komplexität ist ja gerade das, was wir mit Ruby vermeiden wollen.

“Das Ganze ist mehr als die Summe seiner Teile”

Entscheidend für ein gelungenes Refactoring ist oft die Erkenntnis dass der bestehende Code nicht mehr hinreichend die Realität abbildet.
Dinge in der realen Welt bestehen immer aus anderen Dingen, die wiederum aus weiteren Dingen bestehen.
Solche Beziehungen werden in Rails üblicherweise durch die gängigen Assoziationsmakros has_many, has_one usw. implementiert. Diese Makros eignen sich jedoch nur für persistente ActiveRecord-Objekte. Da der gemeine Rails-Entwickler an sich eher faul ist, ignoriert er mangels bequemer Makros gerne die Tatsache, dass nicht alle Teile einer solchen Composition persistent sein müssen.

Jamis Buck hat im Blog von 37signals ein sehr schönes Beispiel veröffentlicht, wie er vormalig im User-Model definierte Methoden zur Manipulation von Avataren in eine eigene, nicht persistente, Avatar-Klasse auslagern konnte. Im obigen Beispiel würde sich diese Lösung wie folgt gestalten:

# app/models/user.rb
class User < ActiveRecord::Base
  # ...
  def admin
    @admin ||= AdminCapability.new(self) if has_role?(:admin)
  end
end
 
# app/models/admin_capability.rb
class AdminCapability
  def initialize(user)
    @user = user
  end
 
  def schedule_backup
    # ...
  end
  # ...
end

Um dem ganzen mehr Composition-Character zu verleihen, sind administrative Methoden jetzt in der AdminCapability-Klasse untergebracht. Aufgerufen werden sie wie folgt: user.admin.schedule_backup. Der Vorteil gegenüber der Lösung mit Vererbung liegt auf der Hand: Wir können beliebig weitere Capabilities erfinden und sauber vom Code der User-Klasse getrennt implementieren.

Für komplexere Szenarion bietet ActiveRecord im Aggregations-Modul das composed_of-Makro. Zweck von Aggregations ist es hauptsächlich Value-Objects abzubilden. Für den Anwendungsfall, “Avatar”, von Jamis Buck wäre es also durchaus ebenso geeignet.
Ein weiteres, beliebtes Beispiel sind Adressen: Möchte man, z.B. aus Performance-Gründen, keine separate addresses-Tabelle, können die Felder in der users-Tabelle angelegt und mittels composed_of als Address-Objekt zur Verfügung gestellt werden. Die Address-Klasse selbst kann dann komplexe Methoden – etwa Geocoding – implementieren.

Verlängerte Beziehung

Beziehungen zwischen Klassen benötigen häufig Funktionalität die in Beziehung stehenden Objekte zu manipulieren oder zu verwalten. Die gängigsten Operationen, wie das erzeugen, löschen oder finden assoziierter Objekte, werden durch die Association-Makros (has_many etc.) bereits zur Verfügung gestellt:

user = User.first
user.addresses.create :street => "Musterstr. 2", :city => "München"
user.addresses.destroy_all

Funktionen die darüber hinausgehen und selbst implementiert werden müssen, landen allzuoft einfach in der Klasse selbst, bei unseren Codebeispielen war dies etwa has_role?.
Dabei bietet ActiveRecord eine sehr saubere Möglichkeit diese assoziationsspezifische Logik zu kapseln: Association Extensions.

Solche Extensions können entweder als Blockparameter des Assoziationsmakros, oder in eigenständigen Modulen implementiert sein. In unserem Beispiel sähe dies so aus:

# app/models/user.rb
class User < ActiveRecord::Base
  has_and_belongs_to_many :roles, :extend => RoleManagement
end
 
# app/models/role_management.rb
module RoleManagement
  def got?(role_name)
    exists? :name => role_name.to_s
  end
end

Anstatt user.has_role?(:admin) kann das Vorhandensein der Rolle nun mit user.roles.got?(:admin) geprüft werden.

Automatische Beziehungsverlängerung

Mit einem kleinen Griff in die Metaprogrammierungs-Trickkiste lässt sich aber noch mehr erreichen. In unserem Beispiel gibt es sowohl eine Rolle namens “admin”, als auch eine Klasse, zuletzte AdminCapability genannt, die die dazugehörige Business-Logik implementiert.

Wäre es nicht nett wenn je nach zugewiesenen Rollen automatisch die entsprechenden Capability-Objekte zur Verfügung stünden?

Um es kurz zu machen: Ja – das wäre nett! Und so gehts:

# app/models/role_management.rb
module RoleManagement
  def method_missing(method, *args)
    @capabilities ||= {}
    @capabilities[method] ||= begin
      return super if method.to_s == 'exists?' || !exists?(:name => method.to_s)
      "#{method.to_s.camelize}Capability".constantize.new(proxy_owner)
    end
  end
end

method_missing sorgt hier dafür dass falls der proxy_owner, also hier der User, über die gegebene Rolle verfügt, exists?(:name => method.to_s), ein entsprechendes Capability-Objekt erzeugt wird. Wurde es bereits erzeugt und im @capabilities-Hash abgelegt, wird einfach dieses zurückgegeben.
Andernfalls wird mittels super die nächste Implementierung von method_missing aufgerufen. Dies ist wichtig, da die Association-Proxies von ActiveRecord selbst sehr viel mit method_missing arbeiten: Z.B. wird auch der Aufruf von exists? über diesen Mechanismus abgefangen, daher muß er hier explizit behandelt werden.

Angenommen wir fügen jetzt noch eine Rolle “author” und die dazugehörige Klasse AuthorCapability ein, lässt sich diese neue API wie folgt nutzen:

user.roles.admin.schedule_backup  #=> ...
user.roles.author                 #=> NoMethodError: undefined method author...
user.roles << Role.find_by_name("author")
user.roles.author                 #=> #<AuthorCapability:0x22384d8>
user.roles.author.publish_post!   #=> ...

Kommen wir endlich zum Schluß

Ich hoffe, ich konnte in diesem Artikel zeigen, dass Models nicht zwingend an Übergewicht leiden müssen. Auch wenn es vielleicht nicht für Heidi Klums Show reicht, so können unsere Models mit der Modul-Diät, der concerned_with-Kur, der Composition-Diät oder der Association-Extension-Formel genug überflüssige Pfunde verlieren um eine erfolgreiche Karriere auf den virtuellen Laufstegen des Webs machen.

Bookmark and Share

Schlagworte: , , , ,

Autor: Maximilian Schöfmann,

Maximilian Schöfmann ist Leiter der Entwicklung bei einem Münchner Startup.

Artikel bewerten:

1 Sterne2 Sterne3 Sterne4 Sterne5 Sterne (4 Bewertung(en), durchschnittlich: 5.00 (max. 5)
Loading ... Loading ...

6 Kommentare zu “Refactoring von Rails-Anwendungen: Was tun wenn der Laufsteg durchbricht?”

  1. Daniel Mattes schreibt:

    Hey Max,
    sehr guter und interessanter Beitrag!
    Grüße
    Daniel

  2. Marco Otte-Witte schreibt:

    voila:

    668 app/models/user.rb
    4111 total

    Schöner Artikel!

    Marco

  3. Thomas R. Koll schreibt:

    app1
    95 app/models/user.rb
    190 app/models/location.rb
    app2
    24 app/models/user.rb
    27 app/models/comment.rb
    34 app/models/post.rb
    49 app/models/site.rb
    146 total
    app3
    2 app/models/weblink.rb
    3 app/models/article.rb
    7 app/models/user.rb
    9 app/models/photo.rb
    9 app/models/topic.rb
    16 app/models/video.rb
    66 app/models/item.rb
    98 app/models/feed.rb
    210 total

    Der User ist bei mir immer schön ausgelagert :)

  4. Roland Moriz schreibt:

    app1:
    232 app/models/user.rb

    app2:
    97 app/models/user.rb

    aber beides zugegeben sehr überschauliche Anwendungen (SMS-Versand usw)

  5. Malte schreibt:

    Zwei Jahre gewucherte Anwendung:

    752 app/models/user.rb

    mea culpa!

  6. Peter Schrammel schreibt:

    Falls evtl. jmd doch schon mal namespaces verwendet:

    wc -l app/models/**/* | sort