Für die Verwaltung der Änderungen an der Datenbank gibt es in Rails das mächtige Konzept der Migrationsskripte. Da dies Ruby-Skripte sind, steht einem der volle Funktionsumfang von Ruby und Rails zur Verfügung. Allerdings benötigt man zum Durchführen einer Migration eine Rails-Umgebung mit Zugriff auf die Datenbank, in der man die Migrationsskripte ausführen kann. Während der Entwicklung ist das keine Hürde, aber bereits in Test- und spätestens in Produktionsumgebungen könnte das zum Problem werden.

Besonders wenn man Rails mittels JRuby in eine Java-Umgebung eingeführt hat, wird in der Produktionsumgebung auf der Konsole kein Rails verfügbar sein. Viele Unternehmen reglementieren zudem den Zugriff auf die Datenbank, so dass Entwickler die Produktionsdatenbank nicht erreichen können oder die Zugangsdaten nicht kennen. Die Anwendung kann aber auf die Datenbank zugreifen - warum nicht die Migration über die Anwendung auslösen?

Vorneweg: es gibt keine direkte API, die man verwenden könnte. Alle Beispiele basieren auf dem Code von Rails 2.2.2 und duplizieren die Funktionalität der Rake-Tasks.

Hello Migration

Anfangen kann man mit dem Äquivalent zu rake db:migrate. Dazu reicht folgender Code:

ActiveRecord::Migrator.migrate("db/migrate/")

So einfach kann es sein.

Es passiert das Gleiche wie bei Rake: es werden alle noch ausstehenden Migrationen ausgeführt und die Ausgabe ist auf der Konsole zu sehen.

Schreibt man diesen Code in einen eigenen Controller kann man ihn bequem aus der Anwendung heraus ausführen:

class DatabaseController < ApplicationController
  def migrate
    ActiveRecord::Migrator.migrate("db/migrate/")
  end
end

Nachdem man einen entsprechenden View (app/views/database/migrate.html.erb) angelegt hat, sollte ein Aufruf der URL http://localhost:3000/database/migrate erfolgreich sein.

Ausgabe umleiten

Wie erwähnt, die Ausgabe findet dabei auf der Konsole statt. Für eine Webanwendung ist das natürlich ungünstig. Es gibt allerdings keine API, über die man an die Ausgaben herankäme. Glücklicherweise schreibt ActiveRecord::Migration zwar mit puts direkt auf die Konsole, aber über eine eigene Klassenmethode namens write. Um die Ausgabe in die Webanwendung umzuleiten, kann man write überschreiben, um zum Beispiel die Ausgaben in einem Array zu sammeln und zusätzlich in das Log von Rails zu schreiben:

class ActiveRecord::Migration
  @@messages = []
  cattr_reader :messages
    
  # redefine method to collect and log messages
  def self.write(text="")
    messages << text
    Rails.logger.info(text)
  end
end

Im Controller muss man sich die Ausgaben jetzt noch abholen. Zur Sicherheit leert man das Array vor der Migration, in Produktion sieht man sonst unter Umständen alte Meldungen:

ActiveRecord::Migration.messages.clear
ActiveRecord::Migrator.migrate("db/migrate/")
@messages = ActiveRecord::Migration.messages

Nun kann migrate.html.erb angepasst werden, um die Meldungen anzuzeigen:

<% if @messages %>
  <% @messages.each do |message| %>
  
<%= h message %>
<% end %> <% end %>

Auf diese Weise werden die wichtigsten Ausgaben eingesammelt. Der ActiveRecord::Migrator schreibt zusätzlich noch Zeilen der Art "Migrating to CreateUsers (2)" in das Log - egal, ob die Migration ausgeführt wird oder nicht. Er führt quasi Buch, welche Migrationen er berücksichtigt.

Fehler behandeln

Wenn bei der Migration Fehler auftreten, möchte man diese natürlich auch entsprechend anzeigen - und zwar nicht nur die Exception (was in Produktion nur der Inhalt von 500.html wäre).

Dazu fängt man die Exception, holt sich zuerst die Ausgaben der erfolgreichen Migrationen und fügt dann die Fehlermeldung an. Den Stacktrace der Exception sammelt man besser in einer getrennten Instanzvariablen, dann kann man ihn auch getrennt anzeigen (z.B. in kleinerer Schrift).

Ergebnis

Der Controller könnte abschließend so aussehen:

class Admin::DatabaseController < ApplicationController

  skip_before_filter :authentication # may be required

  class ActiveRecord::Migration
    @@messages = []
    cattr_reader :messages
    
    # redefine method to collect and log messages
    def self.write(text="")
      messages << text
      Rails.logger.info(text)
    end
  end

  def db_migrate
    begin
      ActiveRecord::Migration.messages.clear
      ActiveRecord::Migrator.migrate("db/migrate/")
      @messages = ActiveRecord::Migration.messages
      flash.now[:notice] = "Database migration successful"
    rescue => e
      flash.now[:error] = "Database migration failed"
      @messages = ActiveRecord::Migration.messages
      @messages << e.message
      @backtrace = e.backtrace
      logger.error(e)
    end
  end
  
end

Im View kommt noch mal ein ähnlicher Block für den Stacktrace hinzu. Wegen besserer Übersicht mit geringerer Schriftgröße:

<% if @backtrace %>
  <% @backtrace.each do |message| %>
  
<%= h message %>
<% end %> <% end %>

Migration in Produktion

Die Rails-Anwendung kann jetzt selber die Migration der Datenbank anstoßen. Für die Verwendung in einer Produktionsumgebung muss man noch sicherstellen, dass die Migrationsskripte auch mit ausgeliefert werden. In unserem Fall (Warbler Plugin) musste in warble.rb das Verzeichnis db/migrate zu config.dirs hinzugefügt werden.

Die nächste Hürde könnten Benutzerprüfungen sein, die der ApplicationController durchführt. In unserem Fall gibt es den before_filter :authenticate, der mit skip_before_filter deaktiviert werden musste. Wer auf solche Prüfungen aus Sicherheitsgründen nicht verzichten möchte, sollte sich bewusst sein, dass er sich dann zumindest anmelden können muss - Änderungen an den Benutzern und allem, was die Authentifizierung betrifft, könnten den Zugriff auf die Migration vereiteln! Eine alternative Absicherung, die exklusiv für die Migration genutzt wird, ist da unter Umständen die bessere Lösung. Wir haben uns entschieden, das einfach zu halten und auf Prüfungen zu verzichten, da das db:migrate nur einmal etwas ausführt. So kann auch ein unberechtigter Benutzer nichts anrichten.

Die Idee, die Migration beim Deployment in der Produktivumgebung automatisch auszuführen, haben wir nicht umgesetzt. Ein wesentlicher Grund dafür war, dass man sich zwar den Aufruf einer URL spart, dann aber keinerlei Feedback hat. Stattdessen in ein Log sehen zu müssen wäre keine zufriedenstellende Alternative. Wer sich dafür entscheidet, könnte das zum Beispiel über die Definition eines ServletContextListeners für die Java Servlet Engine realisieren.

Migration während der Entwicklung

Falls man die Migration im Browser auch während der Entwicklung verwenden will, fehlt noch ein kleines Detail: Rake aktualisiert auch die Kopie des Schemas, welches in der Datei schema.rb abgelegt wird. Ohne diese Aktualisierung werden über kurz oder lang die Test fehlschlagen, da die Datenbank für die Testumgebung anhand der schema.rb aufgesetzt wird. Folgende Zeilen lösen das Problem:

File.open("#{RAILS_ROOT}/db/schema.rb", "w") do |file|
  ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection, file)
end
flash.now[:notice] += "; schema dump successful"
Wir haben die Zeilen nach der Migration eingefügt (über dem rescue-Block), aber man kann dafür natürlich auch eine eigene Methode spendieren und sollte dann natürlich die flash-Meldung anpassen.

Ausblick

Nachdem die Pflicht - die Migration auf den aktuellen Stand - erfüllt ist, kann man darüber nachdenken, für die Kür eine Datenbankmigrationsschnittstelle in die Anwendung zu integrieren. Bevor man allerdings weitreichende Kontrolle über die Migrationen erlaubt, sollte man das Thema Authentifizierung und Autorisierung bedenken. Ein (vollständiges) Rollback oder partielle Migrationen wird man in der Anwendung kaum erlauben wollen, wenn man andererseits den prinzipiellen Zugriff auf die Datenbank beschränkt.

Zusätzlich zum Nachahmen von rake db:migrate könnte man auch alle anderen datenbankbezogenen Tasks nachahmen. Als Referenz sei dabei databases.rake empfohlen, welches die entsprechenden Tasks definiert.

Wir haben zum Beispiel dem DatabaseController auch eine index-Aktion spendiert, die es uns erlaubt alle vorhandenen Migrationskripte und ihren Zustand (migriert?) anzuzeigen. Zudem erscheint der Knopf zum Migrieren nur, wenn auch Migrationen ausstehen. Die Methoden, die wir dafür verwendet haben sind migrations, migrated und pending_migrations - alle Instanzmethoden von ActiveRecord::Migrator (dank eines #:nodoc: gibt es dafür leider keine API-Dokumentation).

Rails Migration
Warbler Plugin
Java Servlet API: ServletContextListener
Thread in der jruby-user Mailingliste bzgl. Deployment mit JRuby on Rails