Das URL-Input-Element verbessern: http:// voranstellen
Sunday, 17. November 2013
HTML5 ist mit all den verschiedenen type-Attributen für das Input-Element auf einem guten Weg. So toll es aber auch ist, Client-Validierung für Email/Telefon/URL-Eingaben zu haben, so ärgerlich ist es, wenn dies eher zur Hürde wird. Beim s9y-Alphatest passierte dies - die URL werde als nicht valid angemeckert, obwohl sie richtig sei, war eine berechtigte Beschwerde. Das lag am fehlenden http://. Wenn man aus Chrome und FF eine URL kopiert, wird das zwar vorgestellt. Aber angezeigt wird es nicht mehr. In den Köpfen einiger Nutzer - und auch ich selbst bin darüber schon gestolpert - ist dieses http:// nicht mehr präsent. Was ja auch richtig ist, muss man es doch zurecht nirgends mehr eingeben, ist es doch immer impliziert. Außer beim URL-Input-Element von HTML5.
Leider gibt es dafür wohl auch keine integrierte Lösung. Es muss wieder Javascript herhalten, um das http:// voranzustellen, wenn es vom Nutzer vergessen wurde. Dafür eignet sich das change-Event:
if (document.getElementById('urlInput') != null) { var urlInput = document.getElementById('urlInput'); urlInput.addEventListener('change', function() { if (urlInput.value != "" && ! (urlInput.value.substr(0,7) == "http://" || urlInput.value.substr(0,8) == "https://")) { urlInput.value = "http://" + urlInput.value; } }); }
HTML5: Autovervollständigung für Texteingabefelder mit simuliertem multiple
Monday, 11. November 2013
Browser können schon länger einmal in Dateifelder eingegebene Daten speichern, z.B. den Loginnamen und Passwort, und das kann im HTML mit autocomplete
gesteuert werden. Dies ist aber nicht, was ich im Folgenden mit Autovervollständigung meine. Ich wollte eine Tageingabe bauen und dafür im System vorhandene Tags autovervollständigen. Das kann HTML5, dafür gibt es das datalist-Element. Das sollte in modernen Browsern auch durchaus funktionieren:
Nur: Wie kann so eine kommaseparierte Liste vervollständigt werden? Wie kann mehr als ein einzelnes Wort vervollständigt werden? Zwar gibt es das Attribut multiple
, doch ist das für type="text"
nicht gültig. Mit Hausmitteln kann HTML5 das also nicht.
Aber: Mit ein bisschen Javascript kann das nachgerüstet werden. Die Idee samt Demo fand ich hier: Sobald ein Wort eingegeben wurde, bzw immer wenn ein Separator getippt wurde, muss nur die Datalist mit der bisherigen Eingabe ergänzt werden.
Also: gegeben sei ein Eingabefeld und eine Dataliste<input class="entryTagInput" type="text" list="tags" name="tags" placeholder="tag1, tag2, …"></input> <datalist id="tags> <option value="tag1"> <option value="tag2"> <option value="tag3"> </datalist>Dann braucht es nur noch diesen Brocken Javascript, um eine kommaseparierte Tagliste autozuvervollständigen
var tagInput = document.querySelector('.entryTagInput'); var rawTags = document.querySelector('#tags').cloneNode(); var tags = document.querySelector('#tags'); var oldTagInputValue = ""; tagInput.addEventListener('input', function(evt) { var autocompleteOccured = false; if (tagInput.value.length - oldTagInputValue.length > 1) { autocompleteOccured = true; tagInput.value += ", "; } if (tagInput.value.substr(-1) == ',' || autocompleteOccured) { tagOptions = []; for (var i=0;i < rawTags.options.length;i++) { var newTag = document.createElement('option'); if (autocompleteOccured) { newTag.value = tagInput.value + rawTags.options[i].value; } else { newTag.value = tagInput.value + " " + rawTags.options[i].value; } tagOptions[i] = newTag; } while (tags.hasChildNodes()) { tags.removeChild(tags.lastChild); } for (var i=0;i < tagOptions.length;i++) { tags.appendChild(tagOptions[i]); } } if (tagInput.value == "") { // user has deleted all tags tags.parentNode.appendChild(rawTags); tags.parentNode.removeChild(tags); tags = document.querySelector('#tags'); rawTags = document.querySelector('#tags').cloneNode(); } oldTagInputValue = tagInput.value; });In der
rawlist
-Datalist bleiben die originalen Tags, in tags
werden die neuen Permutationen gespeichert, was im Input-Event der Tageingabe immer dann geschieht, wenn ein Komma eingegeben oder die Autovervollständigung genutzt wurde. Dass sie genutzt wurde kann leider nur festgestellt werden, indem geschaut wird, wieviele Zeichen auf einmal eingegeben wurde. Da gibt es leider scheinbar kein eigenes Event für.
Ajax Datei-Upload mit HTML5 (und Sinatra)
Sunday, 10. November 2013
Bilder ohne Formular hochladen, ohne auf Flash zurückzugreifen, am besten mehrere auf einmal. Das geht mit HTML 5 und einer Prise Javascript recht einfach.
Die Grundidee
Das Input-Element von HTML 5 kann mehrere Dateien hochladen. Daher wird das genutzt, aber versteckt und aktiviert, indem ein anderes Element angeklickt und dann dieses den click
-Event des File-Input-Elements auslöst. Nach der Dateiauswahl wird den change
-Event ausgelöst, an der Stelle können die Daten der Dateien abgefangen, einem FileReader
übergeben und dann an den Server gesendet werden.
Anleitung
Zuerst braucht es ein Datei-Input-Element. Da das nur mit aktiviertem Javascript funktionieren wird, füge ich das auch per JS ein:
var imgButtonInput = document.createElement("input"); imgButtonInput.className = "imgButtonInput"; imgButtonInput.type = "file"; imgButtonInput.multiple = "multiple"; imgButtonInput.names = "images"; imgButtonInput.accept = "image"; body.appendChild(imgButtonInput);
Das Input-Element als Formularelement ist aber zur Darstellung hier relativ ungeeignet. Deshalb wird das versteckt und ein alternatives Element hinzugefügt:
.imgButtonInput { position: absolute; left: -9999em }
var imgButton = document.createElement("span"); imgButton.innerHTML = "IMG"; body.appendChild(imgButton);
Ein Klick darauf soll das File-Input-Element auslösen:
document.querySelector('.imgButton').addEventListener('click', function(evt) imgButtonInput.click(); };
Dessen change-Event muss dann die Dateien dem Server übergeben:
imgButtonInput.addEventListener('change', function() { for (var i = 0; i < files.length; i++) { var f = files[i]; (function(f) { var reader = new FileReader(); reader.addEventListener("load", function(event) { object = {}; object.filename = f.name; object.data = event.target.result; var options = { method: 'post', url: '/file', data: object } snack.request(options, function (err, res) { if (err) { alert("error uploading file: " + err); } alert("file uploaded"); }); }); reader.readAsDataURL(f); })(f); } });
Ich benutze hier snack, um den Ajax-Request etwas zu verschönern, das geht mit jQuery fast äquivalent und der Code ohne Library sollte auch keine Problem sein.
Auf Serverseite, bei mir Ruby/Sinatra, müssen die Dateien dann nur noch entgegengenommen und gespeichert werden:
post '/file' do protected! data = params[:data] filename = params[:filename].gsub("..", "") data_index = data.index('base64') + 7 filedata = data.slice(data_index, data.length) decoded_image = Base64.decode64(filedata) target = File.join(settings.public_folder, filename) until ! File.exists?(target) if target.scan(".").size > 1 # assume the filename is a classical xy.abc target = target.reverse.sub('.','._').reverse else target = target + "_" end end file = File.new(target, "w+") file.write(decoded_image) target.gsub(settings.public_folder, "") end
Quellen:
- http://www.skuunk.com/2011/04/reading-ajax-xhr-file-uploads-in.html
- http://www.nickdesteffen.com/blog/file-uploading-over-ajax-using-html5
- https://developer.mozilla.org/en-US/docs/Using_files_from_web_applications
Alternativ kann man natürlich auch eine vorgefertigte Lösung wie den fine-uploader benutzen. Und sich damit rumärgern, von der Githubseite für die Installationsanweisung zur Webseite verwiesen zu werden, die wiederum auf die Downloadseite verweist, wo es aber nur die Bezahlversion gibt. Um dann das Github-Repo zu ziehen und festzustellen, dass das bescheuerte Build-System unter Ubuntu nicht funktioniert.
Schlanke Controller und Fehlende Manager
Saturday, 9. November 2013
Auf Wunsch beschreibe ich im Folgenden, was ich mit diesem Satz über dsnblog meinte:
Dazu kam der Versuch, eine bestimmte Form der objektorientierten Programmierung durchzuziehen: ohne Managerklassen, und mit einem kleineren Controller durch Initialisierung bei Objekterstellung in den Objekten selbst
Dazu muss ich ein bisschen ausholen, denn ohne Kenntnis des Hintergrunds ergibt das alles wahrscheinlich wenig Sinn.
Ich habe Informatik studiert, aber Programmieren schon vorher gelernt - auch wenn ich im Studium natürlich viel dazugelernt habe. Natürlich wurden wir im Studium mit Patterns konfrontiert, aber von denen war und bin ich nicht überzeugt. Klar, ein Singleton kann nützlich sein, und das benutze ich auch. Aber Dinge wie eine Factory oder eine Factory-Factory bringen mich dazu, schreiend davonzurennen. Vielleicht ist das gar nicht so unüblich für Menschen, die Programmieren größtenteils mit Bash gelernt haben…
Ich bin überzeugt, dass viele der Patterns - so wie man sie an der Uni lernt zumindest - dazu führen, dass der produzierte Code tausendmal schlechter ist, als wenn ein halbwegs ordentlicher Programmierer sich auf seine Intuition verlässt. Ich muss wohl hinzufügen, dass ich mich für einen nicht besonders guten Programmierer halte, daher schon länger mir Hilfen zusammensuche, um meine Intuition zu ergänzen.
Meine Ablehnung von Pattern schließt das MVC-Pattern ein, aber nur teilweise. Denn ich finde die Grundidee gut: Die Präsentation von der Logik trennen, das macht meistens Sinn. Aber die konkrete Ausgestaltung des Patterns variiert in jeder Implementation, es ist meiner Wahrnehmung nach das meistbenutzte Pattern überhaupt, das keine zwei Programmierer im Universum gleich verstehen. Bedeutet für mich: Wenn ich meinen Code für meine Projekte halbwegs ordentlich strukturieren will, muss ich meine eigene Architektur schaffen, die ich für gut erachte - denn am MVC kann ich mich nur an der Grundidee grob orientieren.
Das ist die Ausgangslage: Gute Architektur durch Trennung von Datenbank-Ebene, Code-Ebene und Präsentation, dann ein klar definiertes Kontrollschema, und das ganze so objektorientiert wie sinnvoll, das ist das Ziel.
Fehlende Managerklassen
Einer dieser Grundpfeiler meiner eigenen Architektur ist die Database-Klasse, bei meinen Ruby-Projekten in der database.rb. Meistens benutze ich sqlite, aber ich möchte meinen Code schon so schreiben, dass ich die Datenbank leicht auswechseln kann. Deshalb beinhaltet die Klasse eine Reihe von Funktionen, mit der auf die Daten zugegriffen wird - beispielsweise eine addEntry(entry)
. Dem Rest des Programms ist es dadurch egal, was genau die Datenbank da macht, und beinhaltet keinen einzigen Fitzel SQL.
Nur: Wer spricht mit der Datenbankklasse?
Die erste Version meines Hardwareempfehlers war ein Java-Programm. Auch dort gab es diese Database-Klasse, aber zusätzlich gab es eine zweite Zwischenebene: Die Warehouse-Klasse, eine Managerklasse, die beispielsweise die gesamte Hardware unter einem bestimmten Preis holte, sortierte und in passender Form für den Recommender aufbearbeitete. Ein Request eines Nutzers ging also erst an den Server, dann an den Recommender, dann an das Warehouse, dann an die Datenbank, dann konnte der Recommender arbeiten, und schließlich der Server die Seite rendern. Das gefiel mir nie so richtig, schon weil das Warehouse immer mehr an Funktionen in sich sammelte und noch dazu die Klassen der Parameter (Hardware, Cpu oder Gpu? Oder Hardware samt generic?) irritierend wurden.
Ich glaube, man sieht leicht: Das war weder schön noch simpel noch effektiv. Und das ist sogar noch unvollständig. Einfach keine gute Architektur.
Dann stolperte ich über diesen Ratschlag:
Don't make objects that end with 'er'.
Und ich sponn das weiter. Es geht mir nicht um die Benennung, und ich glaube, es geht auch den Autor des Artikels nicht um die Benennung. Es geht darum, Objekte zu bauen, die mehr sind als ein Datenbehälter mit vielleicht ein-zwei Funktionen, die von einer Managerklasse gesteuert werden. Es geht darum, komplette und lebensfähige (self-sufficient) Objekte zu bauen, die ihre Funktion soweit möglich komplett beinhalten.
Mit Ausnahme von Dingen wie der Trennung zwischen Anwendungsebene und Datenbank, natürlich.
Für den Hardwareempfehler fiel dadurch die Warehouse-Klasse weg, und der Recommender wäre das nächste Ziel, wollte ich konsequent sein. Das war zuviel Aufwand, aber für dsnblog konnte ich es von Anfang an beherzigen, daher ist das näher an dem Ideal.
Dementsprechend gibt es in dsnblog keine "EntryManager"-Klasse, die dafür sorgt, dass die Entry-Objekte mit den richtigen Daten initialisiert und geordnet werden und wann sie in der Datenbank gespeichert werden und wann nicht. Teilweise spielt da zwar die server.rb rein, der Controller, aber generell ist damit gemeint, dass natürlich die entry.rb eine Funktion sendTrackbacks(request)
hat, statt dass eine Trackbackmanagerklasse einen Entry - oder gar eine Liste von Links - bekommt und die Trackbacks dann absendet.
Das ist ein fortwährender Abwägungsprozess. Folgt man dem Motto zu arg, kann es passieren, dass Funktionalität doppelt geschrieben wird. Oder die Erweiterbarkeit nicht so einfach gegeben ist. Beispiel dafür ist meine Markup-Sprache: Momentan ist das auch im Entry, und man kann durchaus argumentieren, dass das auf die Template-Seite gehört, und natürlich muss das für Kommentare auch noch kopiert werden. Andererseits ist es doch desöfteren so, dass es da kleine Unterschiede gibt - Kommentare müssen z.B. keine Bilder einbinden können, sollten es sogar nicht können. Also ist es vielleicht gar nicht so schlecht, wenn sie direkt ihre eigene Formatierungsfunktion dafür bekommen. Wie gesagt, ständiger Abwägungsprozess.
Bei dsnblog war ich im Endeffekt sehr glücklich mit dem Ergebnis dieser Regel, weil es dazu führte, dass ich neben database.rb und server.rb nur die Kernklassen hatte: Eine Klasse Entry und eine Klasse Comments, dazu eine Klasse CommentAuthor (die ich im Nachhinein nicht so toll finde). Genau das, woraus ein Blog besteht, und alles, was in der Datenbank gespeichert und in der View abgebildet werden muss.
Schlanke Controller
Eine weitere Konstante meiner momentanen Architektur ist die server.rb. In Ruby/Sinatra ist die bei der klassischen Variante wohl generell üblich, aber ich hatte genau so eine Klasse auch schon im Java-Code. Für mich ist sie der Controller: Hier wird festgelegt, was bei Aufruf welcher URL passiert. Dazu kommen ein paar Hilfs-Funktionen und der Cache ist hier implementiert, aber URL->Funktionalität, das ist die Grundaufgabe.
Diesen Controller schlank zu halten ist eine stete Herausforderung. Denn es ist ziemlich verlockend, relativ viel Funktionalität einfach hier zu erledigen. Aber zum einen könnte man dann auf Objekte gleich verzichten, und man schafft einen Blob, eine viel zu große Sammlung an Code.
Stattdessen versuchte ich in dsnblog, alle Funktionalität auszulagern. Ein Beispiel:
post '/addEntry' do protected! entry = Entry.new(params, request) redirect "/#{entry.id}/#{URI.escape(entry.title)}" end
Das ist der gesamte Code des Controllers, um einen Artikel anzulegen. Es wird einfach ein Entry-Objekt erstellt, und das Objekt weiß selbst, was es zu tun hat. Das funktioniert natürlich besonders gut in Verbindung mit dem Verzicht auf Managerklassen von oben, aber auch mit Managerklassen wäre das möglich - die Daten könnten ja auch einer EntryFactoryManagerFactory übergeben werden.
Ruby macht das nicht unbedingt ganz einfach, denn die entry.rb soll darauf reagieren, wieviele Argumente es erhält. Bekommt es nur eins, weiß die Klasse, dass es eine ID bekommen hat, und kann sich mithilfe der ID aus der Datenbank initialisieren. Bekommt es zwei, weiß sie, dass es um einen neuen Eintrag geht. Aber Ruby kann keine Funktionen überladen, auch nicht den Konstruktor. Deshalb muss ich den so definieren:
def initialize(*args) case args.length when 1 initializeFromID(args[0]) when 2 # creating entry from params and save in database … end end
Muss man übrigens auch nicht schön finden, das über die Anzahl der Argumente zu steuern, aber alternativ könnte man ja auch den Inhalt des Arguments nehmen, wie das beispielsweise die Preview derzeit macht. Wobei die Ansätze zu mischen an sich unschön ist, aber ich brauchte einen Toggle, um das Abspeichern in der Datenbank und Absenden der Trackbacks zu verhindern und trotzdem den Konstruktor nutzen zu können.
Ich habe mich da übrigens nicht immer konsequent dran gehalten. Die Datei-Hochladenfunktion ist beispielsweise komplett in der server.rb implementiert, weil es dazu keine passende Klasse gibt, oder ich sie nicht sah, und der nötige Code so kurz ist.
Aber trotz der Inkonsequenz: Das sofortige Weiterreichen der Eingabedaten an die Konstuktoren der Objekte, statt leere Objekte zu konstruieren und dann im Controller zu befüllen und am besten dort noch zu managen (z.B. indem sie danach der Datenbank zum Speichern übergeben werden), führt dazu, dass die server.rb mit ihren 400 Zeilen vertretbar schlank ist.
Fazit
Ich kann das ganze auch kurz fassen: Ich suche immer noch nach einer guten Standardarchitektur für Webanwendungen, und fand es bei dsnblog sinnvoll, Logik statt in Managerklassen in die Objekte selbst zu legen und den Controller Daten einfach nur durchreichen zu lassen, sodass die Objekte sich auch noch selbst initialisieren müssen. Die so entstehende objektorientierte Architektur gefällt mir gut, insbesondere für einen Blog, dessen Einträge und Kommentare in dieser Form schön abbildbar sind.
Ich bin dabei wahrscheinlich beeinflusst durch eine Reihe von Artikeln, über die ich durch HN gestolpert bin, die ich aber nicht aufzählen kann. Wobei viele auf HN vorgestellte Projekte das sowieso völlig anders machen.
Event-Handler verschwinden durch innerHTML
Sunday, 7. July 2013
Ich wollte eine einfache Liste mit Eingabefeldern bauen und per Javascript jeweils einen Entfernen-Button hinzufügen, und unten einen globalen Hinzufügen-Button, der weitere Eingabefelder produziert. Also im Grunde
<ol> <li class="guestInput">test1 x </li> <li class="guestInput">test2 x </li> <li class="guestInput">test3 x </li> + </ol>
mit Buttons.
Das war auch eigentlich kein Problem. Hier die Funktion zum Spawnen des Entfernen-Buttons:
function addRemoveButton(index) { var removeButton = document.createElement("button"); removeButton.type = "button"; removeButton.className = "remove icon-trash"; document.querySelectorAll(".guestInput")[index].appendChild(removeButton); removeButton.addEventListener("click", function() { this.parentNode.parentNode.removeChild(this.parentNode); }); }
Und so sah der JS-Code des Hinzufügen-Buttons aus:
var add = document.createElement("button"); add.type = "button"; add.className = "icon-plus"; add.addEventListener("click", function() { var http = new XMLHttpRequest(); http.onreadystatechange = function() { if (http.readyState == 4 && http.status == 200) { document.querySelector("#guestList").innerHTML += http.responseText; addRemoveButton(document.querySelectorAll(".guestInput").length-1); } } currentLength = document.querySelectorAll(".guestInput").length; http.open("GET","/guestInput?index="+ currentLength, true); http.send(); });
Per Ajax wird das HTML eines Eingabefelder geholt und dieses dann der Liste hinzugefügt.
Das Problem war, dass es so nicht funktioniert. Bei jedem Hinzufügen eines Eingabefeldes verschwanden alle alten Event-Handler.
Meine erste Lösung sah so aus:
function addRemoveButton(i) { var removeButton = document.createElement("button"); removeButton.type = "button"; removeButton.className = "remove icon-trash"; removeButton.dataset["index"] = i; document.querySelectorAll(".guestInput")[i].appendChild(removeButton); removeButtons = document.querySelectorAll(".remove"); for (var j = 0; j < removeButtons.length; j++) { removeButtons[j].addEventListener("click", function() { document.querySelectorAll(".guestInput")[this.dataset["index"]].parentNode.removeChild(document.querySelectorAll(".guestInput")[this.dataset["index"]]); }); } }
Da sind zwei Lösungsansätze vermischt:
- Falles es ein Scope-Problem sein sollte, werden die Index-Positionen im Dataset gespeichert und daraus gelesen.
- Als das nicht half kam die Brechstange: Jedes mal alle Event-Handler neu setzen
Absolut hässlich, aber ich kam einfach nicht darauf, wo das Problem liegt.
Dank einer Reihe von Fiddles kam ich nun darauf: Das Problem ist oben, beim Handler des Hinzufügen-Buttons:
document.querySelector("#guestList").innerHTML += http.responseText;
Durch die Manipulation per innerHTML werden die Event-Handler gelöscht. Die Lösung ist, regulär ein Element zu erstellen:
var newGuestInput = document.createElement("li"); newGuestInput.className = "guestInput"; newGuestInput.innerHTML = http.responseText; document.querySelector("#guestList").appendChild(newGuestInput);
Blöd ist, dass dafür Teile (der Klassenname und das li) statt per Template per Javascript gesetzt werden müssen. Aber trotzdem ist das Ergebnis wesentlich schöner als der Murks vorher.
Chrome: Audio-Event ended wird nicht gesendet
Wednesday, 3. July 2013
Ein Problem mit dem music-streamer war von Anfang an, dass es gar nicht so einfach war, nach Ende eines Liedes das nächste zu laden. Anfangs lud desöfteren ein gewähltes Lied einfach nicht und ich begann, last-modified Header passend zu setzen und mit dem Caching rumzuspielen, um das wenigstens nicht zu einem dauerhaften nicht ladenden - weil nur als Bruchstück gecachetem - Lied werden zu lassen. Außerdem reduzierte ich die Zahl parallel offener HTTP-Verbindungen zum Server. Trotzdem, manchmal klappte es einfach nicht.
Deswegen dachte ich mir wenig dabei, dass das Problem bestehen blieb. Manchmal lud einfach das nächste Lied nicht. Doch in den letzten Tagen hatte sich irgendwas verändert, ungefähr 50% der Lieder blieben stecken, und sie luden nicht nicht, sie wurden einfach nie ausgewählt - die Playlist blieb beim gerade gespielten Lied stehen. Da sich auf dem Server nichts geändert hatte, musste es am Browser liegen. Gestern konnte ich mir mal die Zeit nehmen und den Javascript-Code des Players vereinfachen und so ausschließen, dass es am Scheitern des Prebuffern des nächsten Liedes scheiterte, denn tatsächlich:
Chromium 28 sendet manchmal das ended-Event nicht, wenn das Lied zuende ist.
Sehr ärgerlich, weil im Code keine Beliebigkeit ist, das Event wird nicht nur manchmal gesetzt und ich sehe auch nichts, was wegen schlechtem Timing den Fehler manchmal auslösen könnte:
var player = document.createElement("audio"); player.id = "player"; player.setAttribute("controls", true); player.setAttribute("preload", "auto"); var source = document.createElement("source"); player.addEventListener('ended', function() { removeOldControls(); var newPlayer = createPlayer(index+1, songs); newPlayer.play(); insertOrReplace('#player', newPlayer); });
Das wurde mir jetzt zu blöd und ich untersuchte, ob ich das manuell fixen kann. Und ja, das geht, denn obwohl das ended-Event nicht gefeuert wird, die ended-Property wird trotzdem und scheinbar zuverlässig auf true gesetzt. Man kann also alle paar ms das Audio-Element fragen, ob es fertig ist, und wenn es eine Weile fertig ist ohne den nächsten Track auszuwählen, manuell das ended-Event abfeuern:
setInterval(function() { // occasionally, the end event is not triggered. Detect this and start the next track var player = document.querySelector('#player'); if (player.ended) { var event = document.createEvent("HTMLEvents"); event.initEvent("ended", true, true); player.dispatchEvent(event); } }, 200);
Seitdem blieb kein Lied mehr hängen.
Ruby/Sinatra-Anwendung für Heroku anpassen
Saturday, 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
Tuesday, 26. March 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
Saturday, 23. March 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
Thursday, 21. March 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
Saturday, 23. February 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
Tuesday, 8. January 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
Monday, 7. January 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 end
Die 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
Sunday, 30. December 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
Tuesday, 18. December 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: