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 Controller – Fat 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.
Schlagworte: composition, fat model, kate moss, refactoring, skinny controller


13. März 2009 um 10:08
Hey Max,
sehr guter und interessanter Beitrag!
Grüße
Daniel
13. März 2009 um 10:12
voila:
668 app/models/user.rb
4111 total
Schöner Artikel!
Marco
13. März 2009 um 13:05
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
13. März 2009 um 13:38
app1:
232 app/models/user.rb
app2:
97 app/models/user.rb
aber beides zugegeben sehr überschauliche Anwendungen (SMS-Versand usw)
13. März 2009 um 15:34
Zwei Jahre gewucherte Anwendung:
752 app/models/user.rb
mea culpa!
17. März 2009 um 19:36
Falls evtl. jmd doch schon mal namespaces verwendet:
wc -l app/models/**/* | sort