Dann schaut man mal in die Logdateien der Anwendung und findet zuhauf “N+1”-Probleme, also Massen von Datenbankabfragen, die sich durch eager loading von assoziierten Objekten in Listenansichten verhindern ließen. Doch beim genauen Hinschauen fällt einem auf, dass ein Großteil der geladenen Objekte gar nie wirklich verwendet (i.S.v. dem Benutzer angezeigt) werden, sondern nur mal eben benötigt werden, um zu bestimmen, ob der aktuell mit der Anwendung interagierende Benutzer überhaupt den “Beschreibung bearbeiten”-Link und den “Freigabe erteilen”-Button in dem Dropdown-Menü sehen soll, welches nur für diejenigen aktiv sein dürfte, welche mindestens drei Wochen vor dem letzten Osterdatum ihr Konto aktiviert und all ihre Rechnungen innerhalb der von fünf anderen Entitäten abhängigen Frist (…) bezahlt haben. Oder (temporäre) Adminrechte im Kontext der übergeordneten Entität haben. Oder.
Kurzum: Zeit für ein massives Refactoring. In diesem Fall von Zugriffsrechtedefinitionen.
Das Modell
Wer Webanwendungen programmiert, wird das kennen: Ein Objekt, z.B. eine Rechnung, erlaubt verschiedene Interaktionen, abhängig von dessen internem Status und Benutzerrolle des Anwenders. Fangen wir mit dem (fast) einfachst-denkbaren Fall an:
Die Berechtigungen
Nehmen wir an, dass User mit der Rolle ‘principal’ ihre eigenen Rechnungen bearbeiten und freigeben können, aber nur solange diese nicht freigegeben oder versandt sind. Nehmen wir weiter an, dass User mit der Rolle ‘backoffice’ alle Rechnungen bearbeiten können, solange diese nicht versandt sind, und versenden können, wenn diese freigegeben (aber noch nicht versandt) sind. Diese Anforderungen lassen sich mit CanCan einfach abbilden, und zwar auf mehrere Arten.
Eine Möglichkeit wäre, die Block-Notation zu verwenden (do ... end
), diese erlaubt die uneingeschränkte Verwendung der Business-Logik des betreffenden Objektes:
Bei einem einfachen Beispiel wie dem hier gegebenen, wo wir es lediglich mit drei Status und jeweils einer einfachen Status-Bedingung zu tun haben, wäre jede weitere Überlegung zur Vereinfachung unnötige Zeitverwschwendung. Leider sieht der Alltag in der Rechnungserstellungsbranche anders aus und die Liste der Bedingungen, Status und Transitionsmöglichkeiten wächst schnell. Für jede Berechtigung eine zugehörige Status-Liste zu pflegen, dazu noch außerhalb des betreffenden Objektes, könnte man als Verletzung des Geheimnisprinzips sehen. Außerdem ist die Abbildung eines Status auf eine Liste von Transitionen zumindest meinem Hirn leichter begreiflich als der umgekehrte Fall.
Die Maschine
Eine formale Statusmaschine wie AASM oder Workflow hilft erheblich dabei, fachlich bedingte Zugriffsrechte auf Transitionen im Modell zu definieren und diese von den durch Benutzerrollen vorgegebenen Zugriffsrechten zu trennen. Zugegeben, die skizzierte Trennung ist manchmal nicht so eindeutig, aber deswegen ganz darauf zu verzichten, macht den Code nicht verständlicher.
Jedenfalls lässt sich das Initialbeispiel mit Hilfe von Workflow auch folgendermaßen definieren:
Die wesentliche Fachlogik und das Interface der Klasse sind identisch mit dem ersten Beispiel. Der Unterschied ist nun aber, dass Workflow den Status nicht aus den approved_at
- und sent_at
-Attributen ‘berechnet’, sondern der Status im Attribut workflow_state
maßgeblich ist. Das ist, streng genommen, nicht ganz DRY; auf dieses Problem werden wir ganz am Schluss nochmal zurückkommen.
Soweit ist scheinbar nicht viel gewonnen. Doch weit gefehlt: Workflow stellt sicher, dass nur Transitionen aufgerufen werden können, die gemäß aktuellem Status zulässig sind und stellt entsprechende Abfrage-Methoden zur Verfügung: @invoice.approve! if @invoice.can_approve?
ruft die Transition ‘approve’ nur dann auf, wenn der Status (egal welcher!) diese erlaubt.
Damit ist der fachlich motivierte Berechtigungs-Teil ins Fachmodell gewandert, wo er hingehört. Die Benutzer-Rechtedefinition vereinfacht sich damit etwas:
Alles super, oder? Jein. Schwierigkeiten treten dann auf, sobald Objektlisten aus der Datenbank geladen werden sollen, auf denen der aktuelle Benutzer bestimmte Zugriffsrechte hat.
Die Liste
Um bei unserem Beispiel zu bleiben: Die Liste aller durch den aktuellen Benutzer freizugebenden Rechnungen erfordert die Definition eines neuen Model-Scopes, der wiederum in der CanCan-Rechtedefinition verwendet werden kann:
Damit können Übersichten passend zur Berechtigung geladen, als auch Berechtigungen pro Objekt abgefragt werden:
Allerdings hat sich damit schon wieder eine zweimalige Implementierung der gleichen Logik eingeschlichen (Statusmaschine und Scope-Definition), womit Bugs bei komplexeren Szenarien vorprogrammiert sind.
Der Hash
CanCan ermöglicht aber neben der ‘Scope-und-Block’-Notation auch eine Hash-Notation, die an sich kompakt und DRY aussieht:
… allerdings tauchen jetzt wieder die Status-Innereien auf, die wir doch gerade mit der Statusmaschine weggekapselt haben wollten! Damit ist das alles wieder nicht DRY und auch sonst zum verzweifeln!
Die Datenbank
Bleibt also nur noch die Verlagerung der Statusmaschine in die Datenbank! Dazu werfen wir das Workflow-Gem wieder weg, workflow_state
wird ersetzt durch ein InvoiceStatus
-Objekt mit einem Namen und einer Liste von Booleans zur Beschreibung der erlaubten Übergänge und ein kleinwenig (naive) Transitions-Logik wird selbst geschrieben:
Als nächstes muss für jeden möglichen Status ein InvoiceStatus
-Record in der Datenbank abgelegt werden, wo die zulässigen Transitionen definiert sind:
Damit reduziert sich die Ability-Definition auf ein lesbares Minimum:
Diese Zugriffsrechtedefinition lässt deutlich erkennen, welche Teile Benutzer- und welche Statusabhängig sind. Die Implementierung der Statusmaschine in den Modell-Klassen lässt sich durch ein wenig Metaprogrammierung auch kompakter gestalten. Und was wir hierdurch geschenkt bekommen, ist die Möglichkeit, zulässige Transitionen in einer Admin-Oberfläche der Anwendung durch Setzen von Checkboxen zu definieren. Ob das ein tatsächlicher Mehrwert ist, hängt natürlich stark vom Einzelfall ab.
Nachteilig fällt allerdings auf, dass der Code alleine nicht mehr ausreicht, um die Statuslogik komplett zu verstehen. Da das bei komplexeren Anwendungsfällen aber ohnehin schwierig wird, ist das womöglich ein akzeptabler Preis. Je nach Betrachtungsweise wurde hier aber auch eine grundlegendere Grenze aufgeweicht: “was ist” (=Status) sollte in den Daten stehen und “was passieren kann” (=Verhalten) sollte im Code definiert sein[1]. Dass mögliches Verhalten nun in der Datenbank steht, lässt sich m.M.n. nur dadurch rechtfertigen, dass es sich um Caching zwecks Performance-Optimierung handelt.
Da war noch was mit DRY-Status!
Wer aufmerksam mitgelesen und -gedacht hat, wird sich denken: ein sent_at
- und ein invoice_status
-Feld entbehrt auch nicht einer gewissen Redundanz, wo im Initialbeispiel das sent_at
-Feld doch ausgereichend war, um den Status “sent” vollumfänglich zu definieren. Das ist vollkommen richtig, und daher bin ich persönlich ein Fan davon, den Status beim Laden eines Objektes zum Zwecke der (Benutzer-)Interaktion nochmal explizit zu berechnen. Das ‘Status’-Feld in der Datenbank ist damit nicht die Quelle aller Wahrheit, sondern nur ein gecacheter Wert, der zwar in 99.9…% der Fälle korrekt ist, aber gerade in Langzeit-Entwicklungsprojekten mit einer Historie von teilweise verpatzten Datenmigrationen und sich immer mal wieder ändernden Statusmaschinendetails gelegentlich daneben liegen kann. Ein fehlerhafter Status von “alten” Objekten ist meist verschmerzbar, da diese oft nur noch statistische Relevanz in Reports haben. Das Status-Feld “aktueller” Objekte wird hingegen spätestes beim nächsten Speichervorgang transparent korrigiert. Konkret könnte so etwas bei unserem Beispiel wie folgt aussehen:
Die Methode workflow_state
wird nur bei geladenen Objekten aufgerufen, sofern diese nicht readonly
geladen werden[2]. Ansonsten liefert es garantiert den korrekten aggregierten Objektstatus zurück. Die Abfragemethoden sent?
und approved?
prüfen jeweils eine unabhängige Bedingung, was weniger Information vernichtet und komplexeres Verhalten erlaubt[3].
-
scream ↩
-
Es kann aus Performance–Gründen ratsam sein, Collections immer readonly zu laden, um sich den Overhead von Dirty–Tracking und ähnliches zu sparen. Wie im aufgeführten Beispiel hat der Anwendungsprogrammierer es selbst in der Hand, in diesem Zuge weitere Optimierungen zu implementieren. ↩
-
Nicht jede versandte Rechnung muss z.B. zwangsweise freigegeben sein, auch wenn der gegenwärtig definierte Workflow das vielleicht so vorschreibt. ↩