Modell-Validierungen in Ruby on Rails

Eine Stärke von Rails und insbesondere ActiveRecord war schon immer die deklarative API zur Modell-Validierung mit nahtloser Integration in FormBuilder und Controller-Logik. Vernünftige Modellvalidierungen können eine große Vielfalt an Sicherheits- und Konsistenzproblemen elegant verhindern. Der Übergang zwischen technischen[1] Validierungen wie “dieser Wert muss eine Zahl sein” und fachlichen[2] Validierungen wie “in Zustand XY muss Objekt A eine Beziehung zu B haben” ist fließend und damit sind beide Bereiche, sofern man in diesen Kategorien denken möchte, gleichermaßen abgedeckt. Mit der Möglichkeit, Objektvalidierungen in Abhängigkeit von Statuslogik ein- oder auszuschalten, hat man als Entwicker alle Werkzeuge an der Hand, um auch komplexe Weblog-Projekte erfolgreich umzusetzen. Bis diese Anforderung seitens der Fachseite kommt:

Ich möchte alle nicht-kritischen Fehlermeldungen auch weg klicken und das Formular dennoch speichern können, um meine Eingaben an einem anderen Tag von einem anderen Rechner aus fortsetzen zu können.

Wenn die Anzahl der “wegklickbaren” Validierungen sich im niedrigen einstelligen Bereich liegt, ist eine Lösung wie die folgende noch denkbar:

class MyModel < ActiveRecord::Base
  attribute :name, :string, default: ''
  attribute :ignore_empty_name, :boolean, default: false

  validates :name,
            presence: true,
            unless: :ignore_empty_name?
end
Triviale quittierbare Fehlermeldung

Mit dieser Lösung stößt man aber an gewisse Grenzen, wenn:

Nicht alle potentiell inkonsistenten Zustände sind in jedem Fall ein Problem: manchmal möchte oder muss der Benutzer (oder auch dessen Supervisor) diesen Zustand lediglich zur Kenntnis nehmen und kann im Prozess fortfahren.

Gib mir ein Beispiel!

Ein praxisnahes Minimalbeispiel könnte so aussehen: Eine Rechnung hat viele Rechnungsposten. Wenn eine Rechnung keine Rechnungsposten hat, dann ist das ein fachliches Problem, welches behoben werden sollte, sonst kann die Rechnung nicht gebucht werden. Wann es behoben wird, ist egal: Hauptsache vor der finalen Buchung.

Unser Domänenmodell sieht also zunächst folgendermaßen aus:

class InvoiceItem < ApplicationRecord
  attribute :description, :string
  attribute :unit_price,  :integer
  attribute :quantity,    :integer

  belongs_to :invoice, touch: true
end

class Invoice < ApplicationRecord
  attribute :booked_at, :datetime

  has_many :invoice_items

  def book!
    if self.invoice_items.any?
      self.update_attribute :booked_at, Time.zone.now
    else
      raise "You got issues!"
    end
  end
end
Domänenmodell für die Beispiele in diesem Artikel

Das funktioniert erst einmal. Allerdings möchte der Benutzer bereits vor dem Anstoßen des Buchungs-Vorgangs darüber informiert werden, dass da noch etwas im Argen ist. Mit der obigen Lösung müsste in einer entsprechenden View also so etwas stehen:

<% if @invoice.invoice_items.any? %>
  <%= link_to 'Rechnung buchen', new_invoice_booking_path(@invoice) %>
<% else %>
  <p class="warning">Sie können die Rechnung erst buchen, wenn mindestens
  ein Rechnungsposten vorhanden ist.</p>
<% end %>

Das sieht bereits nach einem beginnenden Code-Smell aus (DRY ist das jedenfalls nicht). Um diesen zu verdeutlichen, schauen wir uns die nächste Anforderung an: Vor dem Buchen einer Rechnung sollen nun zusätzlich alle Rechnungsposten einen unterschiedlichen Buchungstext und einen Einzelpreis ungleich 0 enthalten. Es gibt viele Möglichkeiten, das abzubilden. Eine wäre die folgende:

class InvoiceItem < ApplicationRecord
  def valid_for_booking?
    not self.unit_price.zero?
  end
end

class Invoice < ApplicationRecord
  def book!
    descriptions = self.invoice_items.map(&:description)
    if self.invoice_items.all?(&:valid_for_booking?) and descriptions.any? and
        descriptions.size == descriptions.uniq.size
      self.update_attribute :booked_at, Time.zone.now
    else
      raise "You got issues!"
    end
  end
end

Spätestens hier wird klar, dass wir alleine schon für die Darstellung der Fehlermeldungen eine generischere Lösung finden sollten. Außerdem ist abzusehen, dass im Laufe der Zeit weitere Validierungs-Bedingungen dazu kommen werden und dass diese sich auf verschiedene Arbeitsschritte im Lebenszyklus einer Rechnung beziehen können. Wir können auch davon ausgehen, dass irgendwo in der Benutzeroberfläche eine Rechnungs-Übersicht erwartet wird, wo auf einen Blick klar erkennbar sein soll, bei welchen Rechnungen welche Fehler vorliegen.

Persistente Fehlermeldungen

Der Einfachheit halber gehen wir auf Weiteres davon aus, dass fachlich inkonsistente Zustände nur bei Veränderung einer Rechnung eintreten können und nicht etwa durch die Veränderung eines Rechnungspostens. In der Praxis sieht das natürlich anders aus, darum werden wir später noch einmal darauf zurück kommen.

Fangen wir mit dem einfachst-möglichen Modell einer persistenten Fehlermeldung an:

class Issue  < ApplicationRecord
  attribute :message,         :string
  attribute :last_occurrence, :datetime

  belongs_to :target, polymorphic: true
end

class Invoice
  has_many :issues, as: :target, dependent: :destroy
end

Man könnte nun einen InvoiceObserver bauen, der die Rechnung beim Speichern (after_save-Callback) validiert und bei Bedarf neue Meldungen erstellt oder obsolete Meldungen entfernt. An dieser Stelle wollen wir aber einen explizit aufgerufenen “Validierungs-Dienst” zu verwenden, dessen Verwendung beim Speichern einer Rechnung optional ist. Grund dafür ist, dass später im Validierungskontext eine current_user-Referenz aus dem Controller-Kontext hinein gereicht werden soll.

Dieser Validerungsdienst wird im InvoicesController wie folgt verwendet:

def update
  @invoice = Invoice.find(params[:id])
  wrapper = ValidationService::Invoice.new(@invoice)
  if wrapper.update(invoice_params)
    # Erfolg, möglicherweise aber mit fachlichen Validierungsfehlern

  else
    # (technischer) Fehler auf Datenbank- oder Modellebene

  end
end

Wenn das Speichern erfolgreich war, aber fachliche Validierungsfehler aufgetreten sind, so enthält @invoice.issues eine Collection mit entsprechenden Issue-Records.

Schauen wir uns den Validierungsdienst mal genauer an. Die bequeme API zur Definition der Validierungen kommt in eine Basisklasse:

class ValidationService::Base
  class_attribute :validations
  self.validations = []

  Validation = Struct.new(:message, :assertion)

  # Verwendungsbeispiel: siehe ValidationService::Invoice

  def self.validate(message, &assertion_block)
    self.validations << Validation.new(message, assertion_block)
  end

  attr_accessor :target

  def initialize(target_record)
    self.target = target_record
  end
end
app/models/validation_service/base.rb

Die rechnungsspezifischen Validierungen werden in einer eigenen Unterklasse so definiert:

class ValidationService::Invoice < ValidationService::Base
  validate 'Sie können die Rechnung erst buchen, wenn mindestens ein Rechnungsposten vorhanden ist' do
    target.invoice_items.any?
  end

  validate 'Alle Rechnungsposten müssen einen Preis ungleich Null haben' do
    target.invoice_items.all?{|i| i.unit_price != 0 }
  end

  validate 'Alle Rechnungsposten müssen einen unterschiedlichen Buchungstext haben' do
    descriptions = target.invoice_items.map(&:description)
    descriptions.size == descriptions.uniq.size
  end
end
app/models/validation_service/invoice.rb

Wenn ein hier definierter validate-Block beim Speichern des target-Records aufgerufen wird und false zurück liefert, soll ein entsprechender Issue-Record mit dem angegebenen Text erstellt werden.

Kommen wir nun also zum Herzstück der ganzen Validierungslogik, der ValidationService::Base-Klasse:

class ValidationService::Base

  # ActiveModel-Interface

  def save(*args, **kwargs, &block)
    perform(:save, *args, **kwargs, &block)
  end

  # ActiveModel-Interface

  def update(*args, **kwargs, &block)
    perform(:update, *args, **kwargs, &block)
  end

  def valid?
    target.valid? and self.validations.all? {|v| self.instance_eval &v.assertion }
  end

  private

  # Ruft #save oder #update auf dem Zielobjekt auf und führt *im Erfolgsfall*

  # anschließend die fachlichen Validierungen durch.

  def perform(action, *args, **kwargs, &block)
    if self.target.public_send(*args, **kwargs, &block)
      run_validations!
      true
    else
      false
    end
  end

  def run_validations!
    existing_issues = Issue.where(target: self.target).to_a
    now = Time.zone.now

    self.validations.each do |validation|
      next if self.instance_eval &validation.assertion
      
      issue = existing_issues.find {|m| m.message == validation.message }
      issue ||= Issue.new(target: self.target, message: validation.message)
      issue.last_occurrence = now
      issue.save!
    end

    # Lösche alle obsoleten Issues

    existing_issues.each { |i| i.destroy unless i.last_occurrence == now }
  end
end
app/models/validation_service/base.rb

In der Benutzeroberfläche können die Validierungsmeldungen zu einer Rechnung nun unabhängig von deren fachlichem Inhalt angezeigt werden:

<% if @invoice.issues.none? %>
  <%= link_to 'Rechnung buchen', new_invoice_booking_path(@invoice) %>
<% else %>
  <ul>
    <% @invoice.issues.each do |issue| %>
      <li class="warning"><%= issue.message %></li>
    <% end %>
  </ul>
<% end %>
Irgendwo in einer View

Zu guter Letzt lässt sich das Buchen einer fachlich ungültigen Rechnung viel übersichtlicher unterbinden:

class Invoice

  def book!
    if self.issues.none?
      self.update_attribute :booked_at, Time.zone.now
    else
      raise "You got issues!"
    end
  end

end
Buchen einer Rechnung wenn keine Issues vorhanden sind

Internationalisierung

Ein offensichtlicher und trivialer Mangel an der bisherigen Lösung ist die fehlende Internationalisierung der Fehlermeldungen. Bereits subtile Änderungen in der Validierungsnachricht würden dazu führen, dass bestehende Issues bei Fortbestehen des Fehlerzustandes weggeworfen und neu erstellt würden.

Ohnehin ist es eine gute Idee, den Fehlerzuständen einen technischen Schlüssel zuzuordnen. Damit sind Auswertungen auf Datenbankebene wesentlich leichter. Also weg mit den konkreten Fehlernachrichten im Modell:

validate 'invoice.missing_items' do
  # here be Validierungslogik

end
app/models/validation_service/invoice.rb
class Issue
  def to_s
    I18n.t self.message, scope: 'validations'
  end
end
#to_s sollte menschenlesbar sein
Den restlichen I18n-Tanz kennt man zur Genüge:
de:

  issues:

    invoice:

      missing_items: 'Sie können die Rechnung erst buchen, wenn sie einen Rechnungsposten hat.'
config/locales/de.yml
<ul class="validations">
  <% @invoice.issues.each do |issue| %>
    <li class="warning"><%= issue.to_s %></li>
  <% end %>
 </ul>
view: alle Fehler zu einer Rechnung

Alle Rechnungen mit dem Problem ‘fehlende Rechnungsposten’ performant zu finden ist jetzt sehr einfach:

> Invoice.includes(:issues).where(issues: {message: 'invoices.missing_items'})
=> #<ActiveRecord::Relation [#<Invoice ....]>
IRB-Session

Veränderung von außen

Wie bereits weiter oben angesprochen kann es leicht Situationen geben, wo ein Objekt(-Geflecht) durch die Veränderung eines anderes Objektes in einen fachlich ungültigen Zustand gerät. Für diesen Umstand gibt es verschiedene Lösungsmuster:

Welchen Weg man gehen möchte, hängt von den konkreten Projektumständen ab. Im Projekt, wo die Idee für diese Lösung entstanden ist, haben wir den ersten Weg gewählt. Mit ActiveSupport::CurrentAttributes ließe sich auch ein klassisches Observer-Pattern umsetzen, was aber die MVC-Architektur unter Umständen stark aufweicht.

Nicht alle Probleme sind Blocker

Manche Zustände sind nur dann ein Problem, wenn Benutzer keine Kenntnis davon hat. Wenn dieser sich bewusst dazu entschließt, aus Gründen dennoch fortzufahren, dann soll dies unter Umständen möglich sein. Im nächsten Schritt soll also die Möglichkeit eingeführt werden, eine Fehlermeldung zu “quittieren”: die Meldung soll dann ausgeblendet werden um den weiteren Arbeitsablauf nicht zu behindern. Die wenigen dazu nötigen Änderungen sind schnell zusammengefasst:

Statt eines einfachen Booleans für den Quittierungs-Zustand zu verwenden, tut man gut daran, gleich eine Benutzerreferenz und einen Zeitstempel zu speichern. Auf diesem Fundament lässt sich aber noch viel mehr bauen, was wir uns im zweiten Teil anschauen werden: Kollaborative Problembehebung.

  1. In diesem Text wird der Begriff “technisch” für die Klasse von harten Modellvalidierungen verwendet, deren Missachtung einen “kaputten” Record oder ein Sicherheitsproblem zur Folge hätten: Wenn ein Wort–String einem Decimal–Feld übergeben wird oder die maximale Zeichenlänge eines Textfeldes überschritten wurde. Hier soll das Speichern des Records üblicherweise fehlschlagen.  ↩

  2. Als “fachlich” werden hier diejenigen Validierungen bezeichnet, die rein durch Benutzer–Anforderungen entstehen und aus Softwaresicht in dem Sinne arbiträr sind, dass auch eine Verletzung der Validierungsbedingung einen problemlosen Weiterbetrieb erlauben würde: ein Leeres Namensfeld, eine eine in der Zukunft liegende Datumsangabe. Oder anders: Wenn die Frage “darf diese Regel unter denkbaren Umständen verletzt werden?” eindeutig mit “nein” beantwortet werden muss, dann gilt die Regel hier als technische Validierung.  ↩