Ruby/Sinatra-Anwendung für Heroku anpassen
Samstag, 6. April 2013
Heroku ist eine Cloud-Plattform und war eine ganze Weile die einzige mir bekannte Plattform mit einem kostenlosen Tarif (ohne behaupten zu wollen, dass es damals nichts anderes gab). Da mir Ruby und besonders Sinatra sehr gut gefällt und Heroku für Ruby eine Weile der Cloudhoster-Vorzeigekandidat war, wollte ich schon vor einiger Zeit eine Anwendung von mir dort laufen lassen. Damals scheiterte ich - das ganze Ruby-Universum war mir noch zu neu - aber inzwischen habe ich ein anderes Projekt erfolgreich portiert. Man muss allerdings einiges beachten, was genau halte ich hier mal fest.
Alles hier gilt für Ubuntu 12.04.
Heroku einrichten
Herokus Seite ist der einfache Part. Auf https://www.heroku.com/ einen Account anlegen und der Anleitung folgen:
wget -qO- https://toolbelt.heroku.com/install-ubuntu.sh | sh # Heroku-Tools installieren heroku login # einloggen und public ssh-keys hochladen
Nun der rubyspezifische Teil (diese Anleitung). Für die Verwaltung der gems muss eine Datei Gemfile im Anwendungsverzechnis angelegt werden. Der Inhalt:
source :rubygems gem 'sinatra' gem 'pg' # die weiteren genutzten gems
Wer aus der Rails-Welt kommt, kennt das wohl. Die Datei dann auch nutzen:
bundle install
Außerdem fehlt noch die config.ru mit diesem Inhalt:
web: bundle exec rackup config.ru -p $PORT
Damit sind die benötigten Dateien zusammen. Dies alles nun in git festhalten und heruko übergeben:
git init git add . git commit -m "init" heroku create git push heroku master
Um später Updates an heroku zu sende, diesen Schritt ohne git init und heroku create ausführen
Postgresql installieren
Leider kann heroku nicht die von mir bevorzugte SQL-Datenbank nutzen, sqlite. Stattdessen muss man Postgresql nutzen, was mir eigentlich gar nicht passte - mir sind die nicht-dateibasierten Datenbank zu konfigurationslastig. So ist auch postgresql etwas knifflig einzurichten. Immerhin sollte der Wechsel der Performance der Anwendung gut tun.
Auf Heroku ist die postgresql-Datenbank schnell aktiviert:
heroku addons:add heroku-postgresql:dev
Um aber den Code weiter lokal testen zu können und den Datenbank-Remotezugriff nutzen zu können, muss Postgresql auch lokal installiert werden. Dafür installiert man das Paket postgresql. Die Datenbank muss aber noch konfiguriert werden. Am einfachsten ist dieser Weg:
sudo -u postgres createuser --superuser $USER sudo -u postgres psql postgres=# \password $USERNAME # des Hauptnutzers, unter dem entwickelt wird postgres=# \q createdb $USER
Hier wird eine Datenbank so eingerichtet, dass der Hauptnutzer sich mit einem psql in die Datenbank einwählen kann.
Datenbankcode für Postgresql anpassen
Nun muss noch der Code angepasst werden. Wer hier ein ORM nutzt, findet dafür in der Doku Code. Wer wie ich direkt mit SQL-Statements arbeiten will, der hat es etwas schwerer. Funktionierenden Beispielcode fand ich gar nicht.
Zuerst braucht man den Datenbankpfad. Ein
heroku config | grep HEROKU_POSTGRESQL
sollte einen String der Form postgres://USERNAME:PASSWORD@ec2-107-...-213.compute-1.amazonaws.com:5432/d9...rnr zurückgeben. Diesem können nun die Datenbankdaten entnommen werden.
Im Ganzen: Ich habe eine database.rb, vorher ein Wrapper für sqlite. Vorher:
class Database
def initialize()
begin
@@db # create a singleton - if this class-variable is uninitialized, this will fail and can then be initialized
rescue
@@db = SQLite3::Database.new "rssnotifier.db"
begin
puts "creating Database"
@@db.execute "CREATE TABLE IF NOT EXISTS watches(
...
@@db.execute "PRAGMA foreign_keys = ON;"
@@db.results_as_hash = true
rescue => error
puts "error creating tables: #{error}"
end
end
end
def getPages(subscribed)
begin
pages = []
@@db.execute('SELECT DISTINCT url FROM watches WHERE subscribed = ?', subscribed ? 1 : 0) do |row|
pages.push(Page.new(row["url"]))
end
return pages
rescue => error
puts "error getting pages: #{error}"
end
end
...
Umgestellt auf postgresql:
class Database
def initialize()
db = URI.parse(ENV['DATABASE_URL'] || 'postgres://USERNAME:PASSWORD@ec2-107-...-213.compute-1.amazonaws.com:5432/d9...rnr')
@db = PG::Connection.open(:dbname => db.path[1..-1], :user => db.user, :password => db.password, :port => db.port, :host => db.host, :sslmode => 'require')
#@db = PG::Connection.open(:dbname => 'onli', :user => 'onli', :port => 5433) # lokaler Zugriff
begin
puts "creating Database"
@db.exec "CREATE TABLE IF NOT EXISTS watches(
....
rescue => error
puts "error creating tables: #{error}"
end
end
def getPages(subscribed)
begin
pages = []
@db.exec('SELECT DISTINCT url FROM watches WHERE subscribed = $1', [subscribed] ? [1] : [0]) do |results|
results.each do |row|
pages.push(Page.new(row["url"]))
end
end
return pages
rescue => error
puts "error getting pages: #{error}"
end
end
...
Eine Liste der Änderungen:
- Kein Singleton für die Verbindung nutzen! Das führte zu ziemlich widerlichen SSL-Fehlern und es dauerte ewig, bis ich darauf kam, dass es daran lag (übrigens kein Heroku-spezifischer Bug).
- Die Datenbankkonfiguration wird aus der Datenbankurl geparst (abgeleitet aus der Doku). Da habe ich mir Beispielcode (ohne ActiveRecord) in der Doku und einen schöneren Weg, ohne Parsen des Datenbankpfades, erhofft.
- exec ersetzt execute.
- Man beachte den zusätzlichen |results|-Zwischenschritt.
- (Nicht im Code) Postgresql beherrscht kein INSERT OR REPLACE. Stattdessen erst ein UPDATE, dann ein INSERT ausführen.
- Die Parameterersetzung hat eine andere Syntax: $X statt ? und Übergabe eines einzelnen Arrays statt der einzelnen Argumente.
- Den Code zum Zugriff auf die lokale Datenbank habe ich der Einfachheit halber hier festgehalten (den muss man nämlich auch erstmal finden). Man beachte den Port, der nicht der Standardport ist!
Die Anpassungen an den SQL-Queries selbst ist nicht zu schlimm, größtenteils scheint sqlite3 eine Submenge von postgresql zu sein. Bei den Datumsfunktionen bin ich dagegen direkt auf Rubycode umgestiegen.
Lohnt das?
Durchaus ein Haufen Aufwand, vor allem ohne diese Anleitung. Für meine Anwendung hat sich das ganze nichtmal gelohnt, heroku war schlicht zu langsam und die mit meiner Anwendung kommunizierenden Dienste liefen immer(!) in Timeouts. Wenn der freie Plan nichtmal zum Testen ohne jegliche Last reicht, ist das kein gutes Zeichen. Um festzustellen, an welcher Stelle die Anwendung hakt, bietet heroku mit New Relic die laut Liste nötigen Daten, ebenfalls mit einem kostenlosen Tarif - aber um den zu aktivieren, wird trotzdem eine Kreditkarte gefordert. Die ich weder besitze noch zu diesem Zeitpunkt angeben wollen würde.
Und so reizvoll die Skalierbarkeit solch einer Cloudplattform auch ist: Die Preise sind heftig. Ein eigener Server hat da echte Vorteile.
IndexedDB als Cache
Dienstag, 26. März 2013
Die IndexedDB als großer Datenspeicher eignet sich meiner Meinung nach ziemlich gut als generischer Cache. Localstorage ist nett, um einzelne Dinge zu speichern, z.B. den Inhalt eines einzigen Textfeldes oder die Position der Scrollbar eines Multiselect-Feldes, aber mit 2,5 MB ist der Nutzen begrenzt. IndexedDB, mit 50 MB im Firefox - mehr mit Nutzererlaubnis - und 20% des verfügbaren Speicherplatzes in Chrome - was die Hälfte der Festplatte sein dürfte, wenn ich das richtig lese - eignet sich da schon für mehr.
Beispielweise cache ich damit die Lyrics im music-streamer, und wenn der Browsersupport das hergibt, möchte ich damit die Lieder selbst cachen (das geht noch nicht).
Leider ist die indexedDB-Api meiner Meinung nach hässlich und kompliziert. Sie ist beherrschbar, aber ich musste mich da ein bisschen durchkämpfen. Herausgekommen ist folgender Code:
var indexedDB = window.indexedDB || window.mozIndexedDB || window.webkitIndexedDB || window.msIndexedDB;
function cache(id, data) {
var request = indexedDB.open("cache", 1);
request.onupgradeneeded = function (event) {
event.target.result.createObjectStore("cache");
};
request.onsuccess = function(event) {
var db = event.target.result;
var transaction = db.transaction(["cache"], 'readwrite');
transaction.objectStore("cache").put(data, id);
};
}
function getCached(id, success) {
var request = indexedDB.open("cache", 1);
request.onupgradeneeded = function (event) {
event.target.result.createObjectStore("cache");
};
request.onsuccess = function(event) {
var db = event.target.result;
var transaction = db.transaction(["cache"], 'readwrite');
transaction.objectStore("cache").get(id).onsuccess = function (event) {
success(event.target.result);
};
};
}
Damit kann nun beliebiges gecached und wieder geholt werden:
getCached(id, function(res) {
if (res == undefined) {
res = getData(id);
cache(id, res);
}
useData(res);
});
Die Datei liegt als cache.js auf github.
Bash: echo mit Umbrüchen
Samstag, 23. März 2013
Weil ich es das nächste mal sicher wieder brauche:
So gibt man einen String in der Originalformatierung aus:
test="abc def" echo -e "$test" abc def
Genauso in Backticks:
test2=$(echo -e "$test" | sed 's/[ad]/z/g') echo -e "$test2" zbc zef
Wobei auch die doppelten Anführungszeichen gehen:
test2="$(echo -e "$test" | sed 's/[ad]/z/g')" echo -e "$test2" zbc zef
Sinatra 1.4.1: Method set not found
Donnerstag, 21. März 2013
Für mein Blogsystem habe ich natürlich auch eine Möglichkeit vorgesehen, das Design zu wechseln. Dafür werden die Konfigurationsvariablen :views und :public_folder zum neuen Design umgebogen. Früher änderte das in der Datenbank die "design"-Option und rief danach einfach diese - außerhalb der helper definierte - Funktion auf, die auch beim Start von Sinatra ausgeführt wird:
def loadConfiguration()
design = Database.new.getOption("design")
set(:design_root) { File.join(File.dirname(app_file), "designs") }
set(:views) { File.join(design_root, design) }
set(:public_folder) { File.join(views, 'public') }
end
Das funktioniert mit der neuen Sinatra-Version nicht mehr. Die Funktion funktionierte nur noch, wenn sie zu Beginn im configure aufgerufen wird - aber später, wenn sie in einer Route (post /setOption) aufgerufen wird, findet Sinatra die set-Funktion nicht mehr:
#<NoMethodError: undefined method `set' for #<Sinatra::Application>
Da wurde wohl der Scope verändert - später, beim Befolgen der Route, ist man im Sinatra::Application-Objekt, beim Start jedoch in main. Entweder war das früher anders, oder die set-Funktion wurde früher dem Sinatra::Application-Objekt mitgegeben
Lösung für mich war, beim Start direkt den Code mit set auszuführen, später jedoch mit dem settings-Objekt zu arbeiten:
def loadConfiguration()
design = Database.new.getOption("design")
settings.design_root = File.join(File.dirname(settings.app_file), "designs")
settings.views = File.join(settings.design_root, design)
settings.public_folder = File.join(settings.views, 'public')
end
configure do
design = Database.new.getOption("design")
set(:design_root) { File.join(File.dirname(app_file), "designs") }
set(:views) { File.join(design_root, design) }
set(:public_folder) { File.join(views, 'public') }
end
crypt-gem: TypeError fixen
Samstag, 23. Februar 2013
Um den Blowfish-Algorithmus des crypt-gems in Ruby 1.9.x zu nutzen und die Fehlermeldung
TypeError: can't convert String into Integer
zu vermeiden, muss in Zeile 47 der blowfish.rb (bei mir: /var/lib/gems/1.9.1/gems/crypt-1.1.4/crypt/blowfish.rb) aus
data = ((data << 8) | @key[keypos]) % ULONG
data = ((data << 8) | @key[keypos].ord) % ULONG
gemacht, also das .ord hinzugefügt werden (Quelle).
HTML 5: Laden für Audio-Element abbrechen
Dienstag, 8. Januar 2013
Tatsächlich gibt es eine bessere Lösung für die ausgelasteten Verbindungen zum Server: Man muss die Verbindung clientseitig abbrechen. Das Audio-Element hat aber leider keine Funtion .abort() (ein Senden des abort-Events hilft nicht) oder .stop(). Lösung (via):
function abortLoad(player) {
player.pause();
player.src = "";
player.load();
}
Chrome Bug? Viele Audio-Elemente und der Browser-Cache
Montag, 7. Januar 2013
Für music-streamer versuche ich, einfach das HTML 5 Audio-Element zu nutzen und den Rest dem Browser und ihrer Weiterentwicklung zu überlassen. Funktionsweise: Mein Javascript erstellt immer ein Audio-Element für das derzeitige Lied, am Ende davon wird das Element mit einem neuen für das nächste Lied ersetzt usw. Natürlich kann man auch vor- und zurückwechseln.
Beim schnellen Wechseln passiert es manchmal, dass der generierte HTML-Code einwandfrei aussieht, das Audio-Element aber inaktiv bleibt (insbesondere signalisiert das ein durchgestrichener Lautsprecher bei der Laustärkeeinstellung). Was passiert da?
Mir fielen mehrere Möglichkeiten ein: Eventuell sind alle Verbindungen zum Server belegt, weil der Browser das Ersetzen des Audio-Elementes nicht mitbekommt und daher alle kurz geöffneten Musikstücke auf einmal versucht zu ziehen. Oder Chrome scheitert daran, den Rest der nur bruchstückhaft im Cache gespeicherte MP3 herunterzuladen.
Es kann auch eine Kombination sein, dass durch den Cache verhindert wird, dass nach dem Freiwerden einer Verbindung der Download fortgesetzt wird. Das ist meine derzeitige Vermutung, mein Workaround legt das nahe.
Aber erstmal die Problemdiagnostik. Gut zu sehen ist es auf diesem Netzwerkprotokoll:
Ich habe eine Reihe von Songs duchgewechselt. Track 24, 25, 26 und zurück zu 25. Und genau, Track 25 lädt nicht. Man sieht im Protokoll, dass der Ladebalken grau bleibt, also kein echter Request abgeschickt wird (oder was bedeutet es sonst, dass der Balken grau ist?). Hier ist das zugehörige Serverprotokoll:
requesting track 23 127.0.0.1 - - [07/Jan/2013 16:40:18] "GET /track/23 HTTP/1.1" 206 11062262 0.9775 requesting track 24 127.0.0.1 - - [07/Jan/2013 16:40:20] "GET /track/24 HTTP/1.1" 206 4937698 0.4218 requesting track 25 127.0.0.1 - - [07/Jan/2013 16:40:23] "GET /track/25 HTTP/1.1" 206 5535170 0.5492 requesting track 26 127.0.0.1 - - [07/Jan/2013 16:40:25] "GET /track/26 HTTP/1.1" 206 2987291 0.2839 localhost - - [07/Jan/2013:16:40:25 CET] "GET /track/26 HTTP/1.1" 206 2987291 http://localhost:4567/ -> /track/26
Am Server kommt nie ein zweiter Request für Track 25 an. Riecht also nach einem Cache/Browserproblem.
Der Code zum Senden der Tracks sieht so aus (music-streamer nutzt Ruby mit Sinatra):
get %r{/track/([0-9]+)} do |id|
puts "requesting track #{id}"
path = Database.new.getPath(id)
type = FileMagic.new(FileMagic::MAGIC_MIME).file(path)
content_type type
send_file path, :type => type
endDie Datei wird also mit dem richtigen Content-Type rausgesendet, viel mehr passiert hier nicht.
send_file kümmert sich darum, dass die nötigen Header richtig gesetzt werden. Insbesondere setzt es den Last-Modified Header auf das echte Datum, an dem die Datei das letzte Mal editiert wurde. Zusammen mit dem grauen Balken und der nichtvorhandenen Serveranfrage vermutete ich, dass der Browser damit nicht zurechtkommt.
Daher kam ich auf diesen Workaround: Browser-Caching verhindern, indem der Last-Modified-Header auf "jetzt" gesetzt wird:
send_file path, :type => type, :last_modified => DateTime.now.httpdate
So bleibt zwar immer noch etwas Wartezeit, wenn zu viele Tracks auf einmal gesendet werden, aber nach dieser Wartezeit wird der Track geladen.
Die richtige und bessere Lösung wäre wohl, den Browser dazu zu bringen, abgebrochene Lieder nicht weiter zu laden. Dann käme es gar nicht dazu, dass die Maximalanzahl an Verbindungen zum Server überschritten wird.
Es ist natürlich trotzdem möglich, dass der Fehler nur ausgelöst wird, weil im Javascript das alte Audio-Element noch herumfliegt und deswegen die Verbindung bestehen bleibt. Doch selbst wenn dem so wäre, sollte Chrome den Request neu abschicken, wenn das Audio-Element neu erstellt wird. Daher sieht das für mich sehr nach einem echten Bug in Chromium aus.
sinatra-browserid: can't convert nil into string
Sonntag, 30. Dezember 2012
Zu Dokumentationszwecken: Um ein funktionierendes browserid-gem für Sinatra unter ruby 1.9.x zu haben, muss man es mit einer gefixten Version ersetzen:
git clone https://github.com/passcod/sinatra-browserid.git cd sinatra-browserid gem1.9.x build sinatra-browserid.gemspec sudo gem1.9.x install sinatra-browserid-0.3.1.gem
Man vermeidet so die Fehlermeldung:
TypeError at /_browserid_assert can't convert nil into String
(Quelle)
HTML2Canvas Bookmarklet
Dienstag, 18. Dezember 2012
HTML2Canvas kann einen Screenshot einer kompletten Webseite machen. Es gibt mehrere Bookmarklets, die das Skript einbinden. Standardmäßig zeigen die das entstandene Bild in einem Overlay an, das bei mir aber nicht immer funktionierte. Deshalb hier meine Version (basierend auf diesem Original), die das Canvas unter die Seite hängt:
Rhythmbox Remember the Rhythm ohne Autoplay
Dienstag, 11. Dezember 2012
Dass Rhythmbox standardmäßig vergisst, was für Musik ausgewählt war, ist nervig. Abhilfe schafft das Plugin "Remember the Rhythm". Das aber erinnert sich nicht nur an die Musik, sondern setzt diese auch automatisch fort, sobald man den Player startet. Auch nicht gerade angenehm.
Um Remember the Rhythm das automatische Starten der Musik abzugewöhnen, editiert man die Datei /usr/lib/rhythmbox/plugins/remember-the-rhythm/remember-the-rhythm.py fügt in Zeile 77 self.shell_player.pause() ein. Also so:
try:
self.shell_player.pause()
self.shell_player.set_playing_time(self.playback_time)
except:
pass
Javascript Canvas skalieren
Montag, 3. Dezember 2012
Ein per Javascript gezeichnetes Canvas soll neben ein Element gesetzt werden und die gleiche Höhe bekommen. Dafür wird das Canvas mit beliebigen Werten (hier in einem Koordinatensystem von 0 bis 100) gezeichnet, aber vorher die y-Skalierung mit context.scale(x,y) angepasst:
var neighbour = document.querySelector('#neighbour');
var canvas = document.createElement("canvas");
var context = canvas.getContext('2d');
canvas.height = clientHeight;
canvas.width = 25;
var yScale = neighbour.clientHeight / 100;
context.scale(1, yScale);
context.beginPath();
context.moveTo(25, 0);
context.lineTo(0, 50);
context.lineTo(25, 100);
context.closePath();
context.fillStyle = "rgb(78, 193, 243)";
context.fill();Geht leider nicht nachträglich.
Javascript Tooltips
Samstag, 17. November 2012
Diese Javascript-Tooltips (Demo) machen auf mich einen guten Eindruck. Formatierbar mit HTML, ist es nicht zu schwer sie so zu erweitern, dass sie bei einem Klick auf das Element offen bleiben - denn darum geht es mir, Tooltips mit klickbaren Links.
Nutzen würde ich sie derzeit so:
dot.onmouseover=function(e) {
tooltip.show(tooltipText, e, false);
};
dot.onmouseout=function() {
tooltip.hide(false);
};
dot.onclick=function(e) {
tooltip.hide(false);
tooltip.show(tooltipText, e, true);
document.querySelector("#ttclose").onclick = function() {
tooltip.hide(true);
}
}
Und hier der von mir abgeänderte Code (rein experimentell und noch mit Formatierungsfehlern):
var tooltip=function(){
var id = 'tt';
var top = 3;
var left = 3;
var maxw = 300;
var speed = 10;
var timer = 20;
var endalpha = 95;
var alpha = 0;
var tt,c,h;
var x;
var perma = false;
var ie = document.all ? true : false;
return {
show:function(tooltipText, e, permanent){
if(tt == null){
tt = document.createElement('div');
tt.setAttribute('id',id);
c = document.createElement('div');
c.setAttribute('id',id + 'cont');
tt.appendChild(c);
document.body.appendChild(tt);
tt.style.opacity = 0;
tt.style.filter = 'alpha(opacity=0)';
}
tt.style.display = 'block';
c.innerHTML = tooltipText;
tt.style.width = e ? e + 'px' : 'auto';
if(!e && ie){
tt.style.width = tt.offsetWidth;
}
if(tt.offsetWidth > maxw){tt.style.width = maxw + 'px'}
h = parseInt(tt.offsetHeight) + top;
clearInterval(tt.timer);
tt.timer = setInterval(function(){tooltip.fade(1)},timer);
perma = permanent
if (permanent && tt.querySelector("#"+id + "close") == null) {
x = document.createElement('div');
x.innerHTML = "x";
x.setAttribute('id',id + "close");
tt.insertBefore(x,c);
}
this.pos(e);
},
pos:function(e){
var u = ie ? event.clientY + document.documentElement.scrollTop : e.pageY;
var l = ie ? event.clientX + document.documentElement.scrollLeft : e.pageX;
tt.style.top = (u - h) + 'px';
tt.style.left = (l + left) + 'px';
},
fade:function(d){
var a = alpha;
if((a != endalpha && d == 1) || (a != 0 && d == -1)){
var i = speed;
if(endalpha - a < speed && d == 1){
i = endalpha - a;
} else if(alpha < speed && d == -1){
i = a;
}
alpha = a + (i * d);
tt.style.opacity = alpha * .01;
tt.style.filter = 'alpha(opacity=' + alpha + ')';
}else{
clearInterval(tt.timer);
if(d == -1){tt.style.display = 'none'}
}
},
hide:function(permanent){
if (perma) {
if (permanent) {
clearInterval(tt.timer);
tt.timer = setInterval(function(){tooltip.fade(-1)},timer);
}
} else {
clearInterval(tt.timer);
tt.timer = setInterval(function(){tooltip.fade(-1)},timer);
}
}
};
}();
Stylesheet:
#tt {
position: absolute;
display: block;
}
#ttclose {
border-top-left-radius: 3px;
border-top-right-radius: 3px;
background: #666;
color: #fff;
}
#ttclose span {
cursor: pointer;
display: flex;
display: -moz-flex;
display: -webkit-flex;
justify-content: flex-end;
-moz-justify-content: flex-end;
-webkit-justify-content: flex-end;
margin-right: 0.5em;
}
#ttcont {
display: block;
padding: 2px 12px 3px 7px;
margin-left: 5px;
background: #666;
color: #fff;
text-align: left;
border-bottom-left-radius: 3px;
border-bottom-right-radius: 3px;
}
#ttcont a {
color: white;
}
#ttcont ul {
margin: 0;
padding-left: 1em;
}
#ttcont h5 {
margin-top: 0.1em;
margin-bottom: 0.3em;
}
Ergebnis:
CSS: Aus einer Checkbox einen Slider-Button machen
Montag, 12. November 2012
Aus einer einfachen Checkbox mit zwei Bildern einen Slider-Button (bei dem Begriff herrscht Verwirrung: Ist das nun ein Slider-Button? Oder ein Toggle-Switch? Ein Umschalter eben) machen, das ist hier das Ziel. Es soll ohne Javascript funktionieren, und erst recht ohne jQuery, daher fielen die Bootstrap toggle buttons weg. Und deswegen geht es auch rein um die Optik, den Umschalter wirklich ziehbar zu machen hielt ich für unangemessen aufwändig bis unmöglich.
Thema Optik: Es werden zwei Grafiken benutzt, beide stammen von Chris Norström.
Die HTML-Struktur ist eine normale Checkbox in einem Formular, nur dass hinten ein span angehängt wird.
<form>
<label>
<input type="checkbox" checked/>
<span></span>
</label>
</form>
Warum mit dem leeren span den sonst minimalen Code verschandeln? Firefox ist schuld. Im Folgenden wird mit :before das Bild des Buttons über die Checkbox gelegt. Das könnte man auch direkt mit dem input-Element machen, in Webkit-Browsern geht das auch, aber Firefox versteht die Spezifikation anders und lässt daher keinen Pseuso-Inhalt mittels :before zu. Daher das span als Behelfsziel. Man könnte auch mit dem Label arbeiten.
Da das span in dem label der checkbox ist, zählen Klicke auf das span als Klicks für die checkbox.
Deswegen geben wir dem span erstmal display: inline-block mit und setzen Breite und Höhe.
input[type="checkbox"] + span {
width: 84px;
height: 26px;
display: inline-block;
}
Wie gesagt, nun wird mit :before das Bild hineingelegt:
input[type="checkbox"] + span:before {
content:"";
display: inline-block;
width: 100%;
height: 100%;
background: url("/uploads/off.jpg") no-repeat 0 0;
background-size: 100% 100%;
}
Und der gedrückte Schalter, wenn die checkbox aktiviert ist:
input[type="checkbox"]:checked + span:before {
content:"";
display: inline-block;
width: 100%;
height: 100%;
background: url("/uploads/on.jpg") no-repeat 0 0;
background-size: 100% 100%;
}
Die Checkbox kann man dann ausblenden:
input[type="checkbox"] {
display: none;
}
Das schöne an der Lösung: Das skaliert mit. Ändert man Breite und Höhe des span, wird das Bild passend skaliert. Deswegen ist das Button-Bild auch als Hintergrundbild eingebunden statt als Pseudo-Content.
Cache für Sinatra
Sonntag, 28. Oktober 2012
Ich habe eine ziemlich funktionsfähige Blogsoftware mit Sinatra gebaut. Ein absolutes Pflichtfeature war ein Cache, also dass einmal generierte Seiten irgendwo gespeichert und nachher ohne Neuerstellung genutzt werden können. Caches bei Blogsoftware sind unumgänglich, werden Blogs doch viel öfter gelesen als beschrieben. In meinem Fall ist nicht memcached oder ähnliches der dafür geeignete Ort, sondern die auch für alles andere genutzte SQLite-Datenbank.
Grundlage
Ein Cache ist im einfachsten Fall ein einfacher Key-Value-Speicher. Soll er automatisch nicht unbegrenzt speichern, braucht er irgendein Mittel zur automatischen Cache-Invalidierung, also wird noch ein Time-To-Live-Feld (ttl) eingebaut, das automatisch befüllt wird. Insgesamt:
@db.execute "CREATE TABLE IF NOT EXISTS cache(
key TEXT PRIMARY KEY,
value TEXT,
ttl INTEGER DEFAULT (strftime('%s','now') + 604800)
);"
Die Funktionen zum Befüllen und Holen von Werten:
def cache(key, value)
begin
@db.execute("INSERT OR IGNORE INTO cache(key, value) VALUES(?, ?)", key, value)
@db.execute("UPDATE cache SET value = ?, ttl = (strftime('%s','now') + 604800) WHERE key = ?", value, key)
rescue => error
puts error
end
end
def getCache(key)
begin
return @db.execute("SELECT value FROM cache WHERE key = ? AND ttl > strftime('%s','now') LIMIT 1;", key)[0]['value']
rescue => error
puts error
end
end
In der restlichen Sinatraanwendung kann man nun alle GET-Requests durch den Cache laufen lassen: Nur wenn der keinen Inhalt hat wird der eigentliche Code ausgeführt und dessen Ergebnis dann gecached. Dafür ideal geeignet sind die before() und after() Funktionen von Sinatra, die vor und nach jedem Seitenaufruf ausgeführt werden.
Vor jedem Aufruf wird der Cache geprüft:
before do
@cacheContent = Database.new.getCache(request.path_info)
end
after do
Database.new.cache("request.path_info", body)
endIn den eigentlichen Funktionen muss nun vor der eigentlichen Codeausführung auf den Cache reagiert werden:
get '/' do
if @cacheContent != nil
return @cacheContent
end
...
endUnd, wichtig: Damit body während after() schon gesetzt ist, muss er explizit gesetzt werden:
get '/' do
if @cacheContent != nil
return @cacheContent
end
... # do stuff
body erb :index # instead of erb :index
end
Schon steht ein Cache.
Optimierungen
So wie oben reicht das natürlich noch nicht. Zum einen darf natürlich nicht jeder Request gecached werden, sondern nur GET-Requests:
before do
@cacheContent = nil
if request.request_method == "GET"
@cacheContent = Database.new.getCache(request.path_info)
end
end
after do
if @cacheContent == nil && request.request_method == "GET"
Database.new.cache(request.path_info, body)
end
end
Außerdem soll der Cache invalidiert werden, wenn mit einem POST-Request schreibend der Bloginhalt verändert wurde, sei es durch einen Kommentar oder einen neuen Artikel. Je granularer man hier arbeitet, desto besser wird der Cache funktionieren, aber im Zweifel muss eben der ganze Cache invalidiert werden. Entweder man führt dazu einen Cache-Instanzvariable ein, die dann verändert wird, sodass der Cacheinhalt nicht mehr gefunden wird. Oder man löscht einfach den gesamten Cacheinhalt:
def invalidateCache()
begin
return @db.execute("DELETE FROM cache WHERE key LIKE '/%'")
rescue => error
puts error
end
endDa der Cache in meinem Fall noch andere Elemente beinhaltet, wird hier das Löschen über /% auf Elemente mit führendem /, wie eben alle request.path_info-Keys aussehen, eingeschränkt.
Auf der Sinatra-Ebene wird das bei jedem POST ausgelöst (alternativ sicherer auf Datenbankebene bei jedem relevanten Write):
after do
if @cacheContent == nil && request.request_method == "GET"
Database.new.cache(request.path_info, body)
else
if request.request_method == "POST"
Database.new.invalidateCache
end
end
end
Ganz reicht das noch nicht. In meinem Blog gibt es zwei Nutzergruppen, die unterschiedliche Seiten präsentiert bekommen: Einfache Besucher und solche mit Schreibrechten, Admins. Derzeit würden noch beide die gleichen Seiten gecached bekommen, je nachdem, welche Gruppe die Seite zuerst aufruft. Um da zu unterscheiden wird am einfachsten der key angepasst:
before do
@cacheContent = nil
if request.request_method == "GET"
@cacheContent = Database.new.getCache("#{request.path_info}#{isAdmin?}")
end
end
after do
if @cacheContent == nil && request.request_method == "GET"
Database.new.cache("#{request.path_info}#{isAdmin?}", body)
else
if request.request_method == "POST"
Database.new.invalidateCache
end
end
endDas ist dann auch die derzeit finale Version.
Performance
Bringt das was? Benchmarken soll man zwar nie auf dem PC, auf dem der Server läuft, hier aber geht es nur um den Unterschied, daher halte ich das für legitim.
ab -n 1000 -c 5 http://localhost:4567:
Ohne Cache:
Requests per second: 23.58 [#/sec] (mean) Time per request: 212.042 [ms] (mean) Time per request: 42.408 [ms] (mean, across all concurrent requests) Transfer rate: 131.44 [Kbytes/sec] received
Mit Cache:
Requests per second: 124.77 [#/sec] (mean) Time per request: 40.074 [ms] (mean) Time per request: 8.015 [ms] (mean, across all concurrent requests) Transfer rate: 695.50 [Kbytes/sec] received
Grafisch:
Bei einem simulierten Benutzeransturm hilft der Cache definitiv (und mit 500% weit mehr als ich erhofft hatte).
Ohne Last sinkt die Wartezeit auf den Server, bei gefülltem Cache und laut Chrome, von 54ms auf 27ms. Auch nicht schlecht.




