Datenmigration – aus ZODB exportieren und in Drupal importieren

Die Datenübernahme aus dem alten Blog machte einige Probleme. Vielleicht ist meine Lösung auch für andere interessant

Vorgeschichte

Als ich 2006 mit meinem Blog startete, basierten meine Webseiten hauptsächlich auf Zope, da ich ohnehin einige Plone-Seiten für Kunden zur Verfügung stellte. Seitdem hat sich vieles getan – einige Kunden sind abgewandert, andere auf alternative Content-Management-Systeme umgestiegen etc. – und nicht zuletzt lag mein Tätigkeitsschwerpunkt in den letzten Jahren hauptsächlich im Bereich Drupal, sodass es Sinn machte, auch meine eigenen Seiten in einem Drupal-System zusammenzufassen.

Die Migration der eigentlichen Webseite machte nicht viel Schwierigkeiten, da einige Inhalte ohnehin überarbeitet werden mussten und andere einfach per Copy & Paste übernommen werden konnten. Blieb nur der Blog, der faktisch seit Anfang 2010 funktionsuntüchtig war.

Problem bei dem Blog war, dass ich mich damals auf das Zope-Produkt COREBlog entschieden habe, aber die Entwicklung bereits kurz nach dem Start meines Blogs eingestellt wurde. Da ich wegen der anderen Portale den Plone- und auch Zope-Kern aktualisieren musste, traten beim Blog immer mehr Fehler auf. Anfangs arbeitete ich mich in den Quelltext ein und korrigierte die Fehler, aber irgendwann war ein Punkt erreicht, indem der Blog einfach gar nichts mehr machte. So beließ ich es notgedrungen auf eine rein lesbare Version und verzichtete auf neue Beiträge.

Nun wollte ich im Rahmen der neuen Webseite die Altdaten aus COREBlog exportieren, aber musste feststellen, dass das ZMI rein gar nichts mehr anzeigte. Ein Rauskopieren direkt aus der ZODB-Datei wie bei meinen Kurzgeschichten war angesichts der Masse an Einträgen auch keine Alternative, also blieb mir nur, ein Export-Script in Python zu schreiben.

Datenexport aus einer ZODB-Datei

Die Zope-Datenbank ZODB ist eine transaktionsbasierte Datenbank, die im Prinzip serialisierte Python-Objekte speichert. Im Gegensatz etwa zu relationalen Datenbanken gibt es also keine Tabellen, sondern nur Objekte, welche beispielsweise auch Container für andere Objekte sein können. Jede Änderung wird als neue Transaktion hinten an die Datei angehangen, aber der restliche Inhalt nicht verändert. Das führt dazu, dass man auf jede alte Version jedes Objektes zugreifen kann, aber auch, dass die Datenbank mit jeder Änderung etwas größer wird. Durch den Vorgang des »Packens« werden alte Objekte entfernt und nur die aktuellste Version gespeichert.

Wenn man nun direkt mittels Python auf die Daten zugreifen möchte, genügt es, die entsprechenden ZODB-Module zu laden. Man kann sich sehr schön mit der interaktiven Python-Shell durchhangeln, indem man einfach im Verzeichnis der Zope-Instanz die passenden Umgebungsvariablen exportiert (zu finden am Anfang von bin/runzope) und Python unmittelbar aufruft:

{syntaxhighlighter class="brush: bash"}
/opt/Zope-2.10.9-final/data$ PYTHON="/opt/python-2.4.6/bin/python"
/opt/Zope-2.10.9-final/data$ ZOPE_HOME="/opt/Zope-2.10.9-final"
/opt/Zope-2.10.9-final/data$ INSTANCE_HOME="/opt/Zope-2.10.9-final/data"
/opt/Zope-2.10.9-final/data$ CONFIG_FILE="/opt/data/etc/zope.conf"
/opt/Zope-2.10.9-final/data$ SOFTWARE_HOME="/opt/Zope-2.10.9-final/lib/python"
/opt/Zope-2.10.9-final/data$ PYTHONPATH="$SOFTWARE_HOME:$PYTHONPATH"
/opt/Zope-2.10.9-final/data$ export PYTHONPATH INSTANCE_HOME SOFTWARE_HOME
/opt/Zope-2.10.9-final/data$ $PYTHON
Python 2.4.6 (#1, Jun 22 2012, 16:59:35)
[GCC 4.6.3] on linux3
Type "help", "copyright", "credits" or "license" for more information.
>>>
{/syntaxhighlighter}

Wie man sehen kann, war ich hier auf eine alte Zope-Version und damit auch auf eine alte Python-Version angeweisen, die ich mir zunächst kompilieren musste. Ich erspare mir hier Hinweise, wie man dies tut. ;-)

Nun können wir die nötigen Klassen importieren und schauen, was passiert:

{syntaxhighlighter class="brush: bash"}
>>> from ZODB import FileStorage, DB
>>> storage = FileStorage.FileStorage('var/Data.fs')
>>> db = DB(storage)
>>> conn = db.open()
>>> root = conn.root()
>>> root
{'Application': , 'ZGlobals': }
{/syntaxhighlighter}

Die Klasse FileStorage ist für eine Datenbank direkt in einer Datei gedacht (und nicht etwa via ZEO), was der Standardfall für die allermeisten sein sollte. Mittels der Klasse DB initialisiere ich dann die Datenbank, hole mir eine Verbindung (conn) und dann das Wurzelobjekt der Datenbank.

An der Ausgabe kann man auch direkt sehen, dass Python offensichtlich Probleme hat, die Klasse Application zu finden bzw. korrekt zu initialisieren, was vermutlich auch der Grund dafür ist, dass ich im ZMI nichts mehr zu Gesicht bekomme. Kennt man den direkten Pfad zu den gewünschten Objekten, ist dies kein Problem, doch gehen wir mal davon aus, dass dies nicht der Fall ist. Zum Glück erlaubt uns Zope, den Inhalt von Containern aufzulisten, selbst wenn die eigentliche Klasse unbekannt ist:

{syntaxhighlighter class="brush: bash"}
>>> root['Application'].objectIds()
['Control_Panel', 'temp_folder', 'session_data_manager', 'browser_id_manager', 'error_log', 'standard_html_header', 'standard_template.pt', 'standard_error_message', 'index_html', 'standard_html_footer', 'virtual_hosting', 'acl_users', 'homepage', …]
{/syntaxhighlighter}

Unter der ID homepage hatte ich einen einfachen Zope-Ordner und darunter meine eigentlichen Websites. Schauen wir einmal hinein:

{syntaxhighlighter class="brush: bash"}
>>> root['Application'].homepage

{/syntaxhighlighter}

Scheint so, als sei Zope derselben Meinung wie ich. Nun können wir das Spielchen mit der Methode objectIds() wiederholen und uns so durch den Objektbaum hangeln. Ich kürze einmal ab:

{syntaxhighlighter class="brush: bash"}
>>> root['Application']['homepage']['alex-privat.eu']

{/syntaxhighlighter}

Man beachte, dass die Pfadschritte sowohl als Objektnamen, als auch als Array-Keys notiert werden können.

Ich hab nun also den Blog gefunden. Ab jetzt reichte ein Blick in den Quelltext, um herauszubekommen, wie ich an die eigentlichen Daten heran komme. Der Rest war dann ein einfaches Python-Script, dass die Daten (auf zugegeben sehr primitive Weise) in eine Textdatei exportierte:

{syntaxhighlighter class="brush: python"}
from ZODB import FileStorage, DB
from datetime import datetime
storage = FileStorage.FileStorage('var/Data.fs')
db = DB(storage)
conn = db.open()
root = conn.root()
blog = root['Application']['homepage']['alex-privat.eu']

f = open('blog-dump.txt', 'w')

for entry in blog.entry_items():
f.write('--ENTRY--\n')
f.write(entry.title + '\n')
f.write(entry.subtitle + '\n')
f.write(datetime.fromtimestamp(entry.created).strftime('%Y-%m-%d %H:%M:%S') + '\n')
f.write(entry.CookedBody() + '\n')

f.close()
{/syntaxhighlighter}

Die Zeilen 1–7 sollten klar sein, denn das ist genau das, was ich oben schon beschrieben hatte. Danach wird eine einfache Textdatei geöffnet und dann über alle Blog-Einträge iteriert und diese ausgegeben. Natürlich hätte ich mir das Formatieren des Erzeugungsdatums auch sparen können, da Drupal ohnehin wieder einen Timestamp verlangt, aber ich wollte das der Vollständigkeit halber angeben.

Das Format der Textdatei besteht immer aus einer Zeile --ENTRY--, gefolgt von Titel, Untertitel und Erstellungszeitpunkt im ISO-Format. Alles danach bis zu einem erneutem --ENTRY-- ist dann der eigentliche Text des Blog-Eintrags. Dies erlaubt es, die Textdatei in PHP mittels eines einfachen Zustandsautomaten einzulesen:

{syntaxhighlighter class="brush: php"}
<?php

$blogdata = file('blog-dump.txt');
$entrys = array();
$entry = NULL;
$status = 'start';

foreach ($blogdata as $line) {
$line = substr($line, 0, -1);
if ($line == "--ENTRY--") {
if ($status == 'body') {
$entrys[] = $entry;
}
$entry = array('body' => '');
$status = 'title';
}
else
switch ($status) {
case 'title':
$entry['title'] = $line;
$status = 'subtitle';
break;

case 'subtitle':
$entry['subtitle'] = $line;
$status = 'date';
break;

case 'date':
$entry['date'] = $line;
$status = 'body';
break;

case 'body':
$entry['body'] .= $line;
break;
}
}

print_r($entrys);
{/syntaxhighlighter}

Man beachte, dass in Zeile 9 das LF (\n) bei jeder eingelesenen Zeile entfernt werden muss, damit es keine unschönen Effekte bei der Weiterverarbeitung gibt.

Nun sind alle Einträge sauber in einem PHP-Array und können weiter verarbeitet werden:

{syntaxhighlighter class="brush: bash"}
/opt/Zope-2.10.9-final/data$ php blog-import.php
Array
(
[0] => Array
(
[body] =>

So, irgendwann musste ich ja mal damit anfangen. Ab heute habe ich auch einen Blog. Mal schauen, ob sich irgendwer hierhin verirrt. ;-)

Als Basis verwende ich COREBlog auf Zope 2.9 – bin mal gespannt, wie lange.

[title] => Blog gestartet
[subtitle] => Ab heute habe ich auch meinen Weblog.
[date] => Array
(
[tm_sec] => 7
[tm_min] => 36
[tm_hour] => 2
[tm_mday] => 5
[tm_mon] => 8
[tm_year] => 106
[tm_wday] => 2
[tm_yday] => 247
[unparsed] =>
)

)


)
{/syntaxhighlighter}

Datenimport in Drupal

Der Datenimport in Drupal bzw. das programmatische Anlegen von Nodes ist erstaunlich einfach, wenn man erst einmal in den Tiefen des API die passenden Funktionen gefunden hat. Leider ist googlen da nicht sehr effektiv, sodass ich es einfach kurz hier erkläre:

{syntaxhighlighter class="brush: php"}
$node = (object) NULL;
$node->type = 'article';
node_object_prepare($node);

$node->language = 'de';
$node->status = 0;
$node->title = 'Ein neuer Blog-Eintrag';

node_save($node);

print "Gespeichert als Node {$node->nid}.";
{/syntaxhighlighter}

Die erste Zeile mag zunächst verwirrend wirken, ist aber den Programmierparadigma von Drupal geschuldet, auf Klassenkonstrukte zu verzichten und stattdessen Module und Einhängepunkte (»Hooks«) zu verwenden. Im Prinzip wird hier sehr ähnlich dem JavaScript-Prototyping ein »leeres« Objekt erzeugt, ein Typ zugewiesen (Zeile 2) und anschließend basierend darauf Standardwerte mittels node_object_prepare() eingetragen. Dies entspricht einem Konstruktor im OOP-Kontext.

Zeilen 5–7 sind Beispiele dafür, wie der neue Knoten mit Daten gefüllt weden kann, bevor er dann mittels node_save() in die Datenbank abgespeichert wird. Drupal erkennt hier automatisch, dass es ein neuer Knoten ist (durch das Fehlen der nid) und legt einen neuen Datensatz an. Ist das Speichern erfolgreich, kann die neue ID anschließend ausgelesen werden, wie ich es in der letzten Zeile beispielhaft tue.

So weit so gut, aber wie sorge ich nun dafür, dass mir die ganzen Funktionen aus diesem Code-Schnipsel auch zur Verfügung stehen? Natürlich könnte man das einfach in irgend ein Drupal-Modul packen und dieses über die Webseite aufrufen, allerdings gibt es einen sehr viel eleganteren Ansatz.

Drush – ein Akronym für Drupal Shell – ist eine Sammlung von Werkzeugen, um sich unkompliziert mittels Kommandozeile in einer Drupal-Instanz verschiedene Operationen durchzuführen. Analog zu Drupal selbst kann Drush mittels Modulen und Hooks erweitert werden. Ich erspare mir hier eine umfangreiche Einführung und erkläre nur das, was ich hier benötige.

Um nicht immer alle Verbindungsdaten eingeben zu müssen, sollte man im Drush-Verzeichnis eine aliases.drushrc.php anlegen und die »…« mit den passenden Zugangsdaten füllen, z. B.:

{syntaxhighlighter class="brush: php"}
<?php

$aliases['homepage'] = array(
'uri' => 'http://alex.nofftz.name',
'root' => '/var/www/…,
'db-url' => 'mysqli://…@localhost/…',
);
{/syntaxhighlighter}

Nun mal schauen, ob alles korrekt ist:

{syntaxhighlighter class="brush: bash"}
~/drush$ ./drush @homepage status
Drupal version : 7.14
Site URI : alex.nofftz.name
Database driver : mysql
Database hostname : localhost
Database username : …
Database name : …
Database : Connected
Drupal bootstrap : Successful
Drupal user : Gast
Default theme : aki_homepage
Administration theme : garland
PHP configuration : /etc/php5/cli/php.ini
Drush version : 4.0-rc9
Drush configuration :
Drush alias files : …
Drupal root : …
Site path : sites/default
File directory path : sites/default/files
Private file directory path : sites/default/files
{/syntaxhighlighter}

Sollte deutlich weniger angezeigt werden, heißt dies vermutlich, dass entweder die Datenbank-Zugangsdaten oder aber der Pfad zur Drupal-Instanz nicht korrekt sind.

Als nächstes kann man unter commands ein neues Verzeichnis anlegen und ein neues Modul anlegen. Ich war da nicht sonderlich kreativ und hab einfach eine commands/aki/aki.drush.inc angelegt. Bitte darauf achten, dass die Datei auf .drush.inc enden muss, damit Drush diese findet.

{syntaxhighlighter class="brush: php"}
<?php

function aki_drush_command() {
$items = array();

$items['aki-blog'] = array(
'description' => 'Datenmigration Akis Blog',
'bootstrap' => DRUSH_BOOTSTRAP_MAX,
);

return $items;
}

function drush_aki_blog() {
$blogdata = file('/tmp/blog-dump.txt');
$entrys = array();
$entry = NULL;
$status = 'start';

foreach ($blogdata as $line) {
$line = substr($line, 0, -1);
if ($line == "--ENTRY--") {
if ($status == 'body') {
$entrys[] = $entry;
}
$entry = array('body' => '');
$status = 'title';
}
else switch ($status) {
case 'title':
$entry['title'] = $line;
$status = 'subtitle';
break;

case 'subtitle':
$entry['subtitle'] = $line;
$status = 'date';
break;

case 'date':
$entry['date'] = $line;
$status = 'body';
break;

case 'body':
$entry['body'] .= $line;
break;
}
}

foreach($entrys as $entry)
drush_aki_process_blog_entry($entry);
}

function drush_aki_process_blog_entry($entry) {
extract($entry);

$node = (object) NULL;
$node->type = 'article';
node_object_prepare($node);
$node->language = 'de';
$node->uid = 1;
$node->status = 0;
$node->promote = 0;
$node->sticky = 0;
$node->title = $title;
$node->body['und'][0] = array('value' => $body, 'format' => 'full_html');
$node->field_untertitel['und'][0]['value'] = $subtitle;
$node->teaser = '';
$node->created = strtotime($date);

if (!drush_get_context('DRUSH_SIMULATE'))
node_save($node);

drush_print("$title (nid: {$node->nid})");
}
{/syntaxhighlighter}

Die erste Funktion listet auf, welche Befehle dieses Modul definiert. Da ich einen einfachen Datenimport durchführe, komme ich ohne umfangreiche Konfiguration aus. Wichtig ist hier nur der Eintrag 'bootstrap' => DRUSH_BOOTSTRAP_MAX, der dafür sorgt, dass im Kommando selbst die komplette Datenbank und alle benötigten Module zur Verfügung stehen.

Die Funktion drush_aki_blog() tut im Prinzip dasselbe, das ich oben schon beschrieben hatte. Neu ist hier nur, dass anschließend für jeden Blog-Eintrag die eigentliche Verarbeitungsfunktion aufgerufen wird.

In dieser wird zunächst wie oben beschrieben ein neues Node-Objekt angelegt und dann mit den passenden Daten aus dem Datenimport gefüllt. Falls Drush nicht im Simulationsmodus (--simulate) ist, wird der Knoten dann gespeichert.

Ich hatte ursprünglich den Textkörper und das zusätzliche CCK-Untertitel-Feld analog zum Titel direkt gefüllt und wunderte mich, warum zwar die Einträge angelegt werden, jedoch diese Felder leer sind. Nach langem Brüten in der API-Dokumentation und im Drupal-Quelltext fiel mir auf, dass im Gegensatz zu Version 6 Drupal 7 Datenfelder üblicherweise immer mehrsprachig verarbeitet, selbst wenn auf der Seite nur eine Sprache vorhanden ist, um es sich nicht unnötig kompliziert zu machen, kann man einfach den Schlüssel und für »undefined« (undefiniert) verwenden, was hervorragend klappte. Außerdem habe ich den Status aller importierten Einträge auf 0 gesetzt, was »nicht veröffentlicht« entspricht. So konnte ich alle Beiträge durchgehen, den Import überprüfen und passende Tags und Menüeinträge zuteilen, und ja, das hat sehr lange gedauert … ›.‹

Nun können wir starten:

{syntaxhighlighter class="brush: bash"}
~/drush$ ./drush -u 1 -s @homepage aki-blog
Blog gestartet (nid: )

{/syntaxhighlighter}

Man beachte, dass ich hier mittels -u 1 explizit den ersten Benutzer angebe, damit die Blog-Beiträge nicht unter dem Gast-Benutzer importiert werden. Außerdem werden natürlich keine nids ausgegeben, da ich in diesem Aufruf nur simuliert habe. Sollte alles ohne Fehlermeldungen durchlaufen, kann man den Aufruf ohne -s wiederholen und danach die importierten Blog-Beiträge mit den Drupal-Mitteln sichten und veröffentlichen.

Schlusswort

Angesichts der umfangreichen Thematik habe ich vieles nur anschneiden können, mich aber dennoch bemüht, fertige Code-Schnipsel zu präsentieren, die kopiert und schnell an die eigenen Anforderungen angepasst werden können. Für umfangreichere Änderungen kann ich nur an die API von Zope, Drupal und Drush verweisen.

Flattr