Webprojekt von Contao zu Ruby on Rails
Warum? Die Story zur Umstellung
Ist Contao nicht genug? Muss es unbedingt ein Ruby on Rails Projekt sein? Diese Frage steht natürlich im Raum.
Ich betreibe das nicht-kommerzielle Projekt (Stand 2024) Die Grüne Welt. Das ist eine Webseite über Zimmer- und Gartenpflanzen mit einem Lexikon und Tipps dazu, wie man das Grün in der Wohnung und im Garten fördern und erhalten kann.
Früher wurde darauf Werbung geschaltet um ein paar Kosten wieder einzuspielen. Durch umfangreiche andere Webseiten und SEO (Suchmaschienenoptimierung) Maßnahmen, die man als kleiner Webseitenbetreiber vom Aufwand nicht stemmen kann, lohnt es sich nicht weitere Artikel zu erstellen. Die Webseite hat kaum noch Geld eingebracht.
Durch ein ohnehin schon kritisches Verhältnis zu Werbung auf Webseiten habe ich die Werbung entfernt. Die Inhalte sind handgeschrieben - also viel zu Schade um die Seite offline zu nehmen.
Betrieben wurde die Seite mit Contao + Metamodels. Leider wird MetaModels, obwohl es ein Open Source Projekt ist, sehr träge öffentlich geupdatet. Wenn man Geld bezahlt, bekommt man den Zugriff auf die aktuellen Sourcen von MetaModels. Die Investition rechnet sich bei diesen Projekt aber nicht.
Daraus folgt, dass auch Contao nicht auf dem aktuellen Stand gehalten werden kann (durch die Abhänigkeiten zum veralteten Metamodels) .
Es ist generell für Web-Projekte eine schlechte Idee, veraltete, nicht supportete Versionen zu betreiben.
Somit kam der Entschluss das selbst in die Hand zu nehmen und nebenbei etwas Komplexität abzubauen. Ruby on Rails ist das Framework der Wahl da ich es kenne, aktiv weiter entwickelt wird und die Speerspitze der Frameworks für Webanwendungen bildet.
Vorraussetzungen
Zum Einsatz kommt das im November 2024 aktuellste Ruby on Rails 8.0 und die Ruby Version 3.3.6.
Was brauchen wir noch an Infrastruktur?
- MySQL (MariaDB)
- AVO als Backend Gem
- HighVoltage Gem für die statischen Seiten
- Search Cop Gem für die Volltextsuche
- meta-tag Gem für Suchmaschienendinge
- devise für die Authentifizierung
- capistrano fürs Deployment
Welche Dinge müssen bei der Umsetzungen beachtet werden?
- das Layout soll weitestgehend gleich bleiben
- die URLs sollen gleich bleiben (außer für die statischen Seiten)
- die Bilder sollen unter dem gleichen Namen erreichbar sein (Suchmaschinen verlinken in der Bildersuche darauf)
Legacy Datenbestand aus Contao in Ruby on Rails nutzen
Contao und Rails sind erstmal inkompatibel. Die Aufgabe ist, Rails kompatibel für den Legacy Datenbestand zu machen.
Anmerkung: Es gibt hier unterschiedliche Möglichkeiten das zu erreichen. Ich habe mich dafür entschieden eher wenig Zeit zu investieren um ein schnelles Ergebnis zu erreichen.
Als allererstes wird die Datenbank von Contao so wie sie ist importiert und für alle Tabellen, auf die wir zugreifen wollen, Modelle angelegt (auch tl_files) z.B. für die Pflanzen:
rails g model Plant
Die Inhalte sind über mehrere Tabellen verteilt (das ist ein früherer Design Fehler beim Datenbankdesign). Meine Tabellennamen von Metamodels haben deutsche Namen. Da ich aber später plane (man weiß ja nie) das Rails Projekt zu erweitern habe ich auf englische Namen gesetzt. Metamodels Tablellen habe das Format mm_<tabellenname>
.
Um die Tabelle vorerst nicht über eine Migration umzubenennen (das kann man später machen) nutze ich die deutschen Tabellennamen im Model. Dazu reicht folgender Eintrag in dem Rails Modell self.table_name = "mm_pflanzen"
. Ab jetzt hat das Modell Zugriff auf die Tabelle und kann damit arbeiten.
Die Texte liegen im Plaintext vor aber Bilder sind in die zentrale tl_files
Tabelle von Contao verlinkt. Als Schlüssel wird eine UUID genutzt, die in binärer Form vorliegt.
CREATE TABLE `tl_files` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`pid` binary(16) DEFAULT NULL,
`tstamp` int(10) unsigned NOT NULL DEFAULT 0,
`uuid` binary(16) DEFAULT NULL,
`type` varchar(16) NOT NULL DEFAULT '',
`path` varchar(1022) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '',
`extension` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '',
`hash` varchar(32) NOT NULL DEFAULT '',
`found` char(1) NOT NULL DEFAULT '1',
`name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '',
`meta` blob DEFAULT NULL,
`importantPartX` double unsigned NOT NULL DEFAULT 0,
`importantPartY` double unsigned NOT NULL DEFAULT 0,
`importantPartWidth` double unsigned NOT NULL DEFAULT 0,
`importantPartHeight` double unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uuid` (`uuid`),
KEY `path` (`path`(768)),
KEY `pid` (`pid`),
KEY `extension` (`extension`)
);
Die Tabelle, in der die Einträge für die Pflanzen aus dem Pflanzenlexikon enthalten sind, referenziert ein Teaserbild hier:
`teaserimg` blob DEFAULT NULL,
Nun tritt die erste Schwierigkeit des Projektes auf: wie geht man mit den Binär Blobs um damit man auf den Eintrag in tl_files
Zugriff bekommt?
Mit Rails ist das recht einfach:
belongs_to :old_teaserimg, class_name: "TlFile", foreign_key: :teaserimg, primary_key: "uuid", optional: true
Ich benutze anstatt dem normalen ".teaserimg
" Attribute ".old_teaserimage
" für die Referenz auf den Eintrag in tl_files und ".img_teaserimg
" für den Eintrag von ActiveStorage als Modelattribut um späterer Verwirrung vorzubeugen. ".teaserimg
" nutze ich nicht weiter, da es auf den Binärblob verweist und damit nicht zu gebrauchen ist.
Warum das alles?
Es sollen in Zukunft alle Bilder über ActiveStorage verwaltet werden weil das einfacher ist als die alten Strukturen von Contao weiter zu nutzen. Wie wird das erreicht?
Ein Callback nach jedem "find" Aufruf hilft hierbei
after_find do |lex|
if lex.send("img_#{imagesym}").blank?
Dgw::Download.image(lex,imagesym)
end
end
Was ist Dgw::Download
?
In /lib/dgw/download.rb
liegt folgendes Modul:
require "uri"
module Dgw
class Download
class << self
def image(model,imageattribute)
new
if model.send("img_#{imageattribute}").blank? and model.send("old_#{imageattribute}").present?
filename = model.send("old_#{imageattribute}").path
url = URI::DEFAULT_PARSER.escape "https://<hostname>.de/#{filename}".strip
begin
puts "Download: " + url
resource = Faraday.get(url.to_s)
imagefile = StringIO.new(resource.body)
puts "Size: " + imagefile.length.to_s
image_name = File.basename(filename)
image_mime = Mime::Type.lookup_by_extension(File.extname(filename).delete("."))
model.send("img_#{imageattribute}".to_sym).attach(io: imagefile,
filename: image_name,
content_type: image_mime.to_s,identify: false)
puts "Attaching: " + image_name
puts "Mime: " + image_mime.to_s
rescue StandardError => e
puts "An error occurred: #{e.message}"
end
end
end
end
end
end
Dieses kleine Modul zieht die Bilder aus dem Webspace und speichert sie als ActiveStorage Objekt. Das ist natürlich nur für den Übergang des Projektes gedacht denn später, wenn der Datenbestand übernommen ist, ist das Modul nicht mehr notwendig und kann entfernt werden.
AVO als Admininterface
AVO ist eine Entdeckung die ich erst kürzlich machte. Man kann mit AVO sehr sehr schnell ein Admin Interface zusammen bauen. Das Projekt soll nicht viel Zeit verschlingen aber benutzbar sein und auch zum Editieren ein Interface bieten, dass auch noch irgendwie nett aussieht. AVO bietet das.
bundle add avo und bin/rails generate avo:install
und schon ist AVO installiert.
Die AVO Ressourcen werden so erstellt:
bin/rails generate avo:resource Plant --model-class Plant
Eine Avo Ressource definiert kurz gesagt die verfügbaren Felder im Admin Interface sowie deren Darstellung und das Verhalten der ganzen Ressource etc.
Ein Beispiel davon sieht so aus
class Avo::Resources::Post < Avo::BaseResource
self.title = :id
self.includes = []
def fields
field :id, as: :id
field :name, as: :text, required: true
field :body, as: :trix, placeholder: "Add the post body here", always_show: false
field :cover_photo, as: :file, is_image: true, link_to_record: true
field :is_featured, as: :boolean
field :is_published, as: :boolean do
record.published_at.present?
end
field :user, as: :belongs_to, placeholder: "—"
end
end
Was ist bei dem Projekt zu beachten gewesen?
Die Texteinträge aus der Tabelle mm_pflanzen sollen einen WYSIWYG Editor bekommen ... also
field :teasertext, as: :trix, always_show: true
(Trix ist der Editor der von ActionText - einem Rails Bestandteil - bereitgestellt wird) und es soll immer angezeigt werden.
Die Bilder müssen auch noch als Dateien gekennzeichnet werden ungefähr so
field :img_teaserimg, as: :file, is_image: true
Noch zwei interessante Faktoren sind die Sortierung. Hier wird auf die vorhandene Sortierspalte aufgebaut mit dem Eintrag self.default_sort_column = :sorting
.
Dargestellt soll es alles als Grid mit self.default_view_type = :grid sowie
self.grid_view = {
card: -> do {
cover_url:
if record.img_teaserimg.attached?
main_app.url_for(record.img_teaserimg.url)
end,
title: record.pflanzentitel.html_safe,
body: record.teasertext.html_safe
}
end
}
Die Ansicht die AVO generiert sieht dann so aus (Teil des Admin Interfaces).
Zugriffsschutz mit Devise
Damit es schnell geht wird Devise eingesetzt. Ich wollte zwar die neue Möglichkeit von Rails 8, eine Authentifizierung selbst zu schreiben, testen aber das funktionierte nicht sofort mit der Integration von AVO also habe ich mich für Devise entschieden.
Das heißt Devise GEM zum Rails Projekt hinzufügen, Installation durchführen, Migration anlegen in den Seeds Accounts anlegen und danach AVO über die Routen einbinden:
authenticate :user do
mount Avo::Engine, at: Avo.configuration.root_path
end
(Siehe dazu etwas weiter oben der Edit Redirect für AVO)
URLs sollen gleich bleiben - Routen anpassen
Um die schon vorhandenen Seiten in den Indices der Suchmaschinen da zu belassen und nicht in einer Hölle an 301 Redirects unterzugehen, werden die Rails Routen so angepasst, dass sie dem alten URL Schema entsprechen.
Das sieht wie folgt exemplarisch für das Pflanzen-ABC so aus:
get "/pflanzen-abc-uebersicht.html", to: "plants#index", as: "pflanzenabc"
resources :plants, path: "pflanze", only: [:index, :show], format: false
Hier wird als erstes die Index Seite auf den die Indexmethode des Controllers gelegt und ein name für den Pfad vergeben sodass ich ihn später im Code als pflanzenabc_path oder pflanzenabc_url ansprechen kann.
Die zweite Anweisung bewirkt, dass alle Pflanzen unter /pflanze/<urlalias>.html aufrufbar sind. In Kombination dazu gehört in das Plant Model noch folgende Methode:
def to_param
"#{urlalias}.html"
end
AVO hat in der Zwischenzeit noch eine weitere Route erfordert die vor das Einbinden der Engine gehört:
get "/avo/resources/:resource/:id.html/edit", to: redirect("/avo/resources/%{resource}/%{id}/edit")
Diese Route ist notwendig, damit man in dem Projekt Ressourcen editieren kann ansonsten wird die Edit Route nicht gefunden. Ob das ein Bug ist oder nicht, ist noch nicht ganz klar aber es liegt zumindest an der ".html
" Endung in der "to_param
" Methode der Modelle. Die Entwickler sind informiert und mal sehen ... vielleicht ist diese Route irgendwann nicht mehr nötig.
Statische Seiten
Die statischen Seiten wollte ich erst über eine Art selbstgeschriebenes CMS verwalten. Dann bin ich aber eher zufällig über das High Voltage GEM von Thought Bot gestoßen. Damit kann man statische Seiten ganz einfach verwalten. Also installiert und konfiguriert.
Das bedeutet folgender Initializer:
HighVoltage.configure do |config|
config.routes = false
end</code
Das wird deswegen gemacht, weil ich die Routen selbst setzen muss um die alte URL zu erhalten:
get '/projekte-events.html' => 'high_voltage/pages#show', id: 'projekte-events'
get "/news/:id.html" => "high_voltage/pages#show", as: :page, format: false
Der Inhalt der Seiten wird in /app/views/pages/
als html.erb
Datei angelegt und ganz normal HTML rein geschrieben. Das wars.
Bilder
Dann sind da allerdings noch Bilder verlinkt ... was wird mit denen gemacht? Ich habe es mir einfach gemacht und alle Bilder aus dem /files
Verzeichnis nach /public/files
kopiert und die von Contao kleiner gerechneten Ansichten aus /assets/images
nach /public/assets/images
kopiert (ob dass man hier nicht mit rake assets:clobber
alles löscht ... vielleicht wäre hier noch ein Redirect nicht schlecht).
Volltextsuche über die Pflanzen
Ich wollte eigentlich eine Volltextsuche wie in Contao über alles. Aber Contao z.B. indiziert die Webseiten "von vorne" was bedeutet, dass ein Bot die schon generierten Seiten aufruft und dann darüber einen Index erstellt und auch darüber gesucht wird.
Bei Rails hätte ich, um über mehrere Modelle zu suchen (also z.B. Plant and Gardenlexicon) eine Elastic Search Instanz einsetzen müssen (vielleicht geht es auch einfacher aber ich wollte nicht weiter darüber nachdenken weil das vielleicht in Zukunft sowieso anders gemacht wird). ElasticSearch würde aber alles verkomplizieren und viel zu viel Zeit fressen. Diese ganze Misere ist dem etwas seltsamen Datenbankdesign geschuldet.
Also habe ich mich dazu entschieden, nur über die Pflanzen eine Volltextsuche zu machen. Dazu kommt das SearchCop GEM zum Einsatz. Das GEM wird installiert, ein Controller angelegt, ein paar Ausgabe- und Eingabeseiten und/oder -partials erstellt und fertig ist die Suche.
Die Konfiguration von Search Cob im Model sieht wie folgt aus:
search_scope :search do
attributes :pflanzentitel,
:pflanzenname_deutsch,
:pflanzenname_latein,
:teasertext,
:standort_und_pflege,
:krankheiten_und_schaedlinge,
:vermehrung,
:weitere_infos
options [:pflanzentitel,
:pflanzenname_deutsch,
:pflanzenname_latein,
:teasertext,
:standort_und_pflege,
:krankheiten_und_schaedlinge,
:vermehrung,
:weitere_infos], type: :fulltext
end
Mehr braucht es nicht für eine Suche. Die ganze Suche kann aber mit ein paar Indices in der Datenbank noch optimiert werden. Ich habe darauf jetzt verzichtet weil das etwas für die Zukunft ist und gerade nicht notwendig.
SEO
SEO Maßnahmen (also primär anständige Titel und Meta Descriptions) wurden in der Contao-Ausführung der Webseite von mir etwas vernachlässigt (es macht einfach keinen Spaß PHP Templates anzupassen). Das habe ich jetzt nachgeholt.
Dazu kommt das GEM "meta-tags" zum Einsatz, dass ein paar nette Helfer für Rails zur Verfügung stellt um allerlei nützliche Tags im HTML Header zu generieren.
Auf Details davon gehe ich hier mal nicht weiter ein weil es reichlich uninteressant ist und nach ein paar Minuten die Bedienung des meta-tags GEMs beherrscht werden kann.
Deployment & Abschließende Worte
Das Deployment wollte ich mit Kamal2 machen. Es erschien mir aber bei meiner bestehenden Konstellation als zu komplex und ich kenne Capistrano schon. Also habe ich mich für den alten Bekannten "Capistrano" entschieden. Prinzipiell ist es das übliche ... man schiebt seinen Branch per Git auf den Server und beim Deployment mit Capistrano wird aus dem Git der aktuelle HEAD gezogen und alle Maßnahmen für die neue Version getroffen. Das alles läuft über SSH.
Ansonsten wird ein NGINX mit einem Reverse Proxy ausgestattet der Lokal auf die Rails Instanz weiterleitet. Gestartet wir die Rails Instanz über lokale Systemd Anweisungen beim Boot des Servers (hier sollte man tatsächlich Linger aktivieren wie sonst die Rails Instanz wieder gestoppt wird sobald man sich ausloggt)
Alles in allem war es ein interessantes Projekt wo mit begrenzten Zeiteinsatz mal eine Webseite mit einem Legacy Datenbestand wieder online gebracht werden sollte. In Zukunft wird die Seite vielleicht noch erweitert und/oder interne Strukturen verbessert.
Das Hauptziel ist aber erreicht: Die Webseite ist unabhängig von den Entwicklungsintentionen externer Projekte. Sie ist dadurch weiterhin auf dem letzten Stand haltbar und weniger im engen Rahmen von Contao erweiterbar.
Fragen gerne an Mich