7GUIs in Flutter (2/7)
Friday, 13. November 2020
Die sieben Aufgaben von 7GUIs sollen typische Problemstellungen bei der Anwendungsentwicklung widerspiegeln. Dann können die gebauten Lösungen dafür benutzt werden, verschiedene Programmiersprachen und Toolkits miteinander zu vergleichen.
Ich dachte, das ist eine gute Gelegenheit hier nochmal Flutter zu zeigen. Die Vorstellung letzten Monat zeigte relativ wenig von den damit baubaren Oberflächen.
Flutter ist ja deklarativ aufgebaut, das heißt die Oberfläche baut sich immer wieder neu und reagiert dann auf die neuen Variablenwerte. Man kann dafür StatefulWidgets
benutzen, die Variablen im Widget-Stateobjekt speichern und dann immer mit setState
anzeigen, dass die Oberfläche sich doch bitte neubauen soll. Ich werde stattdessen mit GetX der Oberfläche einen Controller zur Seite stellen, in dem die Variablen leben und der dafür sorgt, dass auch Widgets ohne reguläres Zustandsobjekt interaktiv sein können. Aber seht selbst:
1. Counter
Die erste Aufgabe ist ein Klickzähler. Doch einen Counter zu erstellen ist keine Herausforderung, das ist das Standardbeispiel auf der Flutterhomepage und bei Modulen wie GetX, die das Statemanagement übernehmen wollen. Entsprechend habe ich hier nur das GetX-Beispiel genommen und die Oberfläche angepasst.
So sieht es aus:
Die Oberfläche ist eine Row
, in der ein Textfeld und ein Button sind.
Das ist der Code:
import 'package:flutter/material.dart'; import 'package:get/get.dart'; void main() { runApp(MyApp()); } class MyApp extends StatelessWidget { // This widget is the root of your application. @override Widget build(BuildContext context) { return MaterialApp( title: 'Counter', theme: ThemeData( visualDensity: VisualDensity.adaptivePlatformDensity, ), home: MyHomePage(), ); } } class MyHomePage extends StatelessWidget { // Instantiate your class using Get.put() to make it available for all "child" routes there. final Controller c = Get.put(Controller()); @override Widget build(context) => Scaffold( appBar: AppBar(title: Text('Counter')), body: Center( child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ // Use Obx(()=> to update Text() whenever count is changed. Obx(() => Text("Clicks: ${c.count}")), RaisedButton(child: Text("Count"), onPressed: () => c.increment()), ], ))); } class Controller extends GetxController { var count = 0.obs; increment() => count++; }
Das Textfeld zeigt, da mit Obx
umschlossen, immer den aktuellen Wert der Zählvariable im Controller an. Ein Druck auf den Button erhöht diesen Wert.
Außer der Zählvariable im Controller und der Funktion increment()
hat die App keine weitere Funktionalität.
So funktioniert das dann in Bewegung:
Beachte auch, dass die Elemente wie die einer typischen Android-Anwendung aussehen. Das liegt schlicht daran, dass hier eine MaterialApp
gestartet wird.
2. Temperaturconverter
Die zweite Aufgabe ist eine Oberfläche zum Unwandeln von Celsius zu Fahrenheit und umgekehrt.
So sieht meine Lösung aus:
Das ist eine einzelne Row
, in der nacheinander je ein Texteingabefeld und ein Textanzeigefeld aufgereiht sind. Und ja, 0.0 bei beiden Feldern war nicht der richtige Defaultwert, wäre aber einfach änderbar.
Der Code:
import 'package:flutter/material.dart'; import 'package:get/get.dart'; // ... main() und MyApp sind weggekürzt class MyHomePage extends StatelessWidget { final Controller c = Get.put(Controller()); @override Widget build(context) => Scaffold( appBar: AppBar(title: Text('Temperature Converter')), body: Center( child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ SizedBox( width: 80, child: Obx(() => TextFormField( // to force Obx reload when value changes key: Key("C" + c.celsius.string), keyboardType: TextInputType.number, initialValue: c.celsius.value.toStringAsFixed(1), onChanged: (value) => c.fahrenheit.value = c.ctof(double.tryParse(value)), ))), Text("Celsius ="), SizedBox( width: 80, child: Obx(() => TextFormField( key: Key("F" + c.fahrenheit.string), keyboardType: TextInputType.number, initialValue: c.fahrenheit.value.toStringAsFixed(1), onChanged: (value) => c.celsius.value = c.ftoc(double.tryParse(value)), ))), Text("Fahrenheit"), ], ), )); } class Controller extends GetxController { var celsius = 0.0.obs; var fahrenheit = 0.0.obs; double ctof(double c) { return c * (9 / 5) + 32; } double ftoc(double f) { return (f - 32) * (5 / 9); } }
Hier passiert jetzt schon ein bisschen mehr. Der GetX-Controller hat zwei Variablen, celsius
und fahrenheit
, und zwei Funktionen zum Umwandeln der Werte. In der UI wird immer, wenn im Texteingabefeld etwas eingeben wird, mittels onChanged
im Controller der Wert des anderen Texteingabefeld geändert. Weil die Eingabefelder wieder mit Obx
umschlossen sind baut dann Flutter direkt die Oberfläche neu, mit dem neuen Celsius/Fahrenheit-Wert als initialValue
der Eingabefelder. Einen key
zu setzen hilft flutter bzw Obx dabei, zu erkennen wann die Oberfläche neu gebaut werden muss.
So sieht es in Bewegung aus:
Ich habe den Code auch auf Github hochgeladen, so kann jeder ihn direkt importieren und abändern. Es gibt noch fünf weitere Aufgaben, die immer komplizierter werden. Vielleicht mache eine Serie hierdraus, dann folgen sie später.
Flutter: Ein tolles Framework für mobile Apps
Monday, 19. October 2020
Flutter ist ein Framework zur Anwendungsentwicklung. Fokus sind Android und iOS-Apps, aber man kann stattdessen auch für das Web oder mittlerweile sogar den Desktop entwickeln. Es ist ein Google-Projekt, was aber nicht wirklich ein Nachteil ist wenn man mit Android sowieso für eine Google-Plattform entwickelt.
Das Framework ist schon wegen der Konzepte eine ziemliche Umstellung zur gewöhnlichen Android-Entwicklung, zusätzlich schreibt man die Anwendung dann auch noch in einer neuen Sprache, Dart. Doch Dart entpuppt sich als großer Vorteil, dazu unten mehr.
Flutter-Code und Konzepte
Flutter mag insgesamt eine große Umstellung sein, doch der Einstieg ist super simpel. Alles ist ein Widget, jede Oberfläche ist ein Widgetbaum, im Baum kombiniert man die Widgets frei. Zum Beispiel ist das hier das mitgelieferte Hello-World-Beispiel:
import 'package:flutter/widgets.dart'; void main() => runApp( const Center( child: Text('Hello, world!', key: Key('title'), textDirection: TextDirection.ltr ) ) );
So sieht das aus:
Auch als Anfänger ist das problemlos lesbar, die Elemente machen genau genau was man erwartet. main()
ist der Startpunkt der Anwendung, runApp
startet die App, das Center-Widget
zentriert, Text
zeigt Text an. Würde man den Code in eine Material-App packen, würde die App auch direkt aussehen wie eine typische Android-Anwendung.
Aber es ist purer Code, während man bei der Entwicklung mit Android-Studio Oberflächen mit XML-Layouts zusammenbauen konnte. Die Trennung nicht zu haben birgt die Gefahr, UI-Code und Programmlogik zu verschmelzen – was ja aber auch bei regulären Androidanwendungen nur zu schnell passiert. Flutter, und mehr noch die Community drumrum, kompensiert diesen Nachteil über mit einem exzessiven Fokus auf Zustandsmanagement.
Zustandmanagement
Zustandsmanagement ist die Verwaltung des States, das sind einfach die lokalen und globalen Variablen, die Einfluss auf die Oberfläche haben. Flutter versucht generell eine deklarative UI-Entwicklung vorzugeben: Die UI wird immer wieder neu gezeichnet, und wenn der Zustand sich verändert hat sieht die Oberfläche entsprechend anders aus.
Zuerst aber gibt es StatelessWidgets, die keinen eigenen Zustand haben. Holen sie sich die Variablen nicht von außen sehen sie immer gleich aus.
class GreenFrog extends StatelessWidget { const GreenFrog({ Key key }) : super(key: key); @override Widget build(BuildContext context) { return Container(color: const Color(0xFF2DBD3A)); } }
Das hier wäre einfach ein grünes Fenster.
Dann gibt es StatefulWidgets, die ihre eigenen änderbaren Variablen haben können.
class YellowBird extends StatefulWidget { const YellowBird({ Key key }) : super(key: key); @override _YellowBirdState createState() => _YellowBirdState(); } class _YellowBirdState extends State<YellowBird> { @override Widget build(BuildContext context) { return Container(color: const Color(0xFFFFE306)); } }
Das zeigt jetzt auch nur einen farbigen Bereich an. Aber der State-Klasse könnte man jetzt Variablen hinzufügen. Die Doku zeigt dieses Beispiel:
class Bird extends StatefulWidget { const Bird({ Key key, this.color = const Color(0xFFFFE306), this.child, }) : super(key: key); final Color color; final Widget child; _BirdState createState() => _BirdState(); } class _BirdState extends State<Bird> { double _size = 1.0; void grow() { setState(() { _size += 0.1; }); } @override Widget build(BuildContext context) { return Container( color: widget.color, transform: Matrix4.diagonal3Values(_size, _size, 1.0), child: widget.child, ); } }
Das ist also ein Widget mit einer festen Größe, das eine Funktion mitbringt um zu wachsen. setState(() { });
muss immer dann aufgerufen werden, wenn die Oberfläche die Zustandsvariablen neu evaluieren soll.
GetX
Es gibt Unmengen Lösungsansätze für die Zustandsverwaltung. Zum einen weil sie wichtig ist, denn mit ihr steuert man ja direkt das Verhalten der App. Aber auch, weil da die Einflüsse der Entwicklergruppen kollidieren: Da kommt das übliche mentale Chaos aus dem Javascript-Land angeschwemmt, vermischt mit Enterprise-Architekturen – passt ja zu einem Google-Framework. GetX ist anders. Es ist ein pragmatisches Werkzeugset, das nur unter anderem Statemanagment löst. Mit dem Flutter-Modul kann man jeder Oberfläche einen Controller zur Seite stellen, und mit dem Obx
-Widget die Oberfläche immer neu zeichnen lassen wenn sich eine der Variablen ändert. Im Flutter-Kontext ist das Magie, das Resultat ist einfach sauberer Code. So ist das Counter-Beispiel aus der Readme elegant und verständlich:
Du hast einen Controller, in der die Variable und eine optionale Manipulierfunktion definiert wird:
class Controller extends GetxController{ var count = 0.obs; increment() => count++; }
Und dazu das Widget, bei dem ein Druck auf den Button die Variable erhöht, wodurch direkt die Anzeige aktualisiert wird:
class Home extends StatelessWidget { // Instantiate your class using Get.put() to make it available for all "child" routes there. final Controller c = Get.put(Controller()); @override Widget build(context) => Scaffold( // Use Obx(()=> to update Text() whenever count is changed. appBar: AppBar(title: Obx(() => Text("Clicks: ${c.count}"))), // Replace the 8 lines Navigator.push by a simple Get.to(). You don't need context body: Center(child: RaisedButton( child: Text("Go to Other"), onPressed: () => Get.to(Other()))), floatingActionButton: FloatingActionButton(child: Icon(Icons.add), onPressed: c.increment)); } class Other extends StatelessWidget { // You can ask Get to find a Controller that is being used by another page and redirect you to it. final Controller c = Get.find(); @override Widget build(context){ // Access the updated count variable return Scaffold(body: Center(child: Text("${c.count}"))); } }
Das Beispiel zeigt noch zusätzlich die Navigationslösung, die man nicht nutzen muss. Trotzdem: Klarer geht es nicht.
GetX hat Nachteile. So sind die Details nicht ganz so simpel wie es zuerst scheint - wie zum Beispiel umsetzen, dass auf eine Objektvariable eines Listenelements reagiert wird, wenn doch List.obs
nur auf das Vergrößern und Verkleinern der Liste reagiert? Vor allem aber ist es ein junges Projekt, bei dem noch zu viele Commits hereinkommen, von zu wenigen Entwicklern, das Ding ist noch im Flux. Trotzdem ist es ein supermächtiges Werkzeug und erleichtert die Arbeit mit Flutter deutlich.
Paketverwaltung
Richtig nett an Flutter ist auch das darum aufgebaute Entwickleruniversum. Man findet Erklärvideos und -artikel. Am wichtigsten aber sind die Pakete. Auf https://pub.dev/ gibt es eine Suche, Module (für Flutter wie Dart) werden dort sauber vorgestellt, gewonnene Likes dienen als Orientierungshilfe. Bräuchte meine App beispielsweise eine animierbare Navigationszeile unten, ich könnte sie mit convex_bottom_bar direkt von dort beziehen. Eintragen in die pubspec.yaml, flutter pub get
ausführen, den Code einbauen, fertig. Eine solche Paketübersicht habe ich bei der direkten Androidentwicklung mit Java schmerzlich vermisst.
Dart
Dass Flutter direkt eine neue Programmiersprache erfordert ist erstmal total abschreckend. Doch Dart macht das ganz schnell wieder wett, denn die Sprache ist gelungen. Dart ist eine wilde Mischung, für mich fühlt es sich aber vor allem nach Ruby an. Was toll ist. Es fehlen allerdings die Blöcke. Dafür hat es async-Funktionen direkt mit dabei, samt await und Future. Wikipedia zählt Smalltalk und Erlang als weitere Einflüsse. Bestimmt ist da auch Python mit drin, Java und Javascript.
Dart hat mich bisher noch in keiner Situation enttäuscht. Die Sprache hat für mich als Ruby-Programmierer immer eine gute Lösung parat gehabt und mich seltenst negativ überrascht.
Die folgenden Beispiele kann man auch alle auf der Webseite ausprobieren.
Hallo-Welt:
main() { print("Hello, World!"); }
Zahlen:
int i = 1 + 2; print(i); // => 3
Strings:
final s = "abc" + "def"; print(s); // => abcdef
Es gibt Listen und Maps (Hashes):
var testliste = [1, 2, 3]; print(testliste[1]); // => 2 var testmap = {1: 'a', 2: 'b', 3: 'c'}; print(testmap[2]); // => b
Und map und fold:
var testliste = [1, 2, 3]; var result = testliste.map((element) => [element]); print(result); // => [[1], [2], [3]] var folded = result.fold(0, (prev, element) => prev + element.first); print(folded); // => 6
Dr ganze Bereich um async war ungewohnt und ist nicht einfach, aber das simple Beispiel ist klar:
Future<void> fetchUserOrder() { // Imagine that this function is fetching user info from another service or database. return Future.delayed(Duration(seconds: 2), () => print('Das hier folgt 2 Sekunden später')); } void main() { fetchUserOrder(); print('Das ist die erste Ausgabe.'); }
Und natürlich Objekte mit Funktionen:
class Sword { int damage = 5; use() => print("$this dealt $damage damage."); } main() { var sword = Sword(); sword.use(); // => Instance of 'Sword' dealt 5 damage. }
Dazu kommt noch viel mehr: Generic, Mixins, Named Parameters, Lambda hatte ich erwähnt, die Standardklassen haben hilfreiche Funktionen definiert, chaining mittels ..
– also das Aneinanderreihen von Funktionen an ein Objekt, obwohl die Funktionen eigentlich void
zurückgeben. Dart ist wirklich erstaunlich angenehm.
Und die Flutter-Programme, die mit Dart gebaut werden, laufen nicht etwa in einem Wrapper, sondern werden kompiliert und dann nativ auf dem Gerät ausgeführt. Dart kann auch nach Javascript transpilieren, was dann die Web-Unterstützung von Flutter erlaubt, aber das ist gefährlich – die so erstellten Webseiten sind bestimmt Javascript-Monster. Für einzelne Spezialfälle könnte aber auch das eine gute Lösung sein.
Fazit: Beachtenswert
Flutter verspricht, mobile Anwendungen viel schneller entwickelbar zu machen als wenn man sie mit den Bordmitteln schreibt. Wenn man das Framework bereits beherrscht mag das stimmen. Ist man neu, ist es mit der Fixierung auf indirekte Interaktivität nicht so einfach zu beherrschen und dieser Lernprozess dann auch nicht schnell. Aber: Dann ist es mächtig, und ich habe definitiv den Eindruck, dass man hiermit bessere Oberflächen und somit auch Apps bauen kann. Dass die dann auf beiden großen mobilen Betriebssystemen laufen können kommt noch dazu.
Dart ist dann die Krönung. Erwartet hatte ich ein alternatives Javascript – etwas, womit man arbeiten kann, aber woran man eher keine Freude hat. Stattdessen ist es nahe genug an Ruby und so ausgereift, dass ich fast frei meinen Programmcode schreiben kann. Sicher, mit der Zeit werden sich die Schwachstellen offenbaren, und eine so junge Sprache kann nicht an die Modulvielfalt von Ruby herankommen. Eine positive Überraschung war sie aber allemal.
Wenn Flutter die entsprechende Entwicklung stabilisiert (noch ist es alpha) könnte ich mir das glatt auch für Linuxanwendungen vorstellen. Die Entwicklung mit wxWidgets, GTK und Qt ist deutlich komplizierter. Derzeit greifen Entwickler dann stattdessen oft zu Electron und anderen Javascript-Desktopwrappern. Flutter mit Dart könnte die bessere Lösung sein. Ich bin gespannt, ob sich das bewahrheitet. Derzeit hat sich die Alpha an snap gekoppelt, was komplett inakzeptabel ist solange es die einzige Lösung bleibt. Aber Flutter-Appimages oder schlicht Binaries? Könnten eine tolle Sache sein.
Wer jetzt für Android entwickeln will, für den ist Flutter mit Dart auch jetzt schon einen Versuch wert.
Dark Mode mit CSS
Monday, 15. June 2020
Selten, dass ich ein Feature umsetze das unter Linux nicht richtig funktioniert. Aber der dunkle Systemmodus der anderen Betriebssysteme ist eine gute Idee. Besonders, wenn Webseiten den Systemzustand erkennen und sich ebenfalls abdunkeln können.
Ergebnis
Ich habe das auf pc-kombo umgesetzt. So sah die Seite vorher immer aus:
So sieht sie jetzt bei aktiviertem Dark Mode aus:
Nicht schlecht, oder?
Aktivieren
Im Firefox setzt man in about:config ui.systemUsesDarkTheme auf 1. In Chromium gibt es in den Einstellungen der Entwicklerwerkzeuge die Option Emulate CSS media feature prefers-color-scheme. Unter Windows würde ich erwarten, dass die Browser automatisch die Systemeinstellungen aufgreifen.
Die Webseite reagiert mit einem Mediaquery:
@media (prefers-color-scheme: dark) { … }
Außerhalb des CSS bleibt alles gleich.
Die Änderungen und ein paar Tricks
Am Ende der CSS-Datei angehängt überschrieb ich nun die CSS-Anweisungen, die der Webseite vorher ein helles Aussehen gaben. Manche davon kamen von mir, andere vom genutzten CSS-Framework Spectre. Ich stolperte dabei über ein paar nette Tricks.
Farben
Klar: Wenn vorher ein weißer Hintergrund gesetzt war musste der abgeändert werden. Normalerweise ist der Standardhintergrund hell und die Schrift dunkel:
body { background-color: #fbfbfb; color: #3b4351; }
Nun ist das umgedreht:
body { color: #dcdfe5; background-color: #37383a; }
Aber es ist nicht einfach weiß auf schwarz, so wie es ja auch vorher nicht einfach schwarz auf weiß war. Stattdessen ist es ein helles grau auf einem dunkleren grau. Der Kontrast ist hoch genug, und anders als bei einer rein schwarz-weißen Seite kann immer noch mit Farben gearbeitet werden. So ist pc-kombo weiterhin auch blau.
Es gab noch ein paar Farbanweisungen mehr zu setzen, aber es waren nicht viele und sie folgten dem gleichen Prinzip. Grau oder blau, abgedunkelte Versionen der vorher bereits genutzten Farben. An ein paar Stellen bewahrte ich auch die vorher genutzten blau-lilanen Hintergründe.
Bilder abdunkeln
Viele Produktbilder haben einen hellen Hintergrund und sind nicht von mir bearbeitbar, aber auch meine Bilder will ich nicht alle bearbeiten. Zum Glück kann CSS hier helfen:
img { opacity: .75; transition: opacity .5s ease-in-out; } img:hover { opacity: 1; }
Indem die Sichtbarkeit verringert wird werden die Bilder weniger hell, denn sie sind ja jetzt auf einem dunklen Hintergrund. Die Idee stammt aus diesem Artikel.
Bildern Hintergrund geben
Andererseits waren Bilder mit einem transparentem Hintergrund und dunklem Bildinhalt nun schwer sichtbar, das Amazon-Logo zum Beispiel. Solche Bilder bekommen mit CSS einen hellen Hintergrund zugewiesen (der wegen dem obigen Schritt nicht zu hell wird), durch etwas Padding wirken sie nicht abgeschnitten, und ein kaum sichtbarer schwarzer Rahmen verbessert nochmal die Abgrenzung:
#priceDetails img, #recommendation img:not([src=""]) { padding: 0.2em; background: white; border: 2px solid black; }
Das not([src=""])
verhindert, dass vorher unsichtbare leere Bilder jetzt durch Rahmen und Padding sichtbar werden.
Logo und Charts invertieren
Das Logo von PC-Kombo war nun dunkelgrau auf dunkelgrau, also nicht sichtbar. Und bei den Charts waren die schwarzen Linien und Bezeichnungen unsichtbar geworden. Hier hilft ein CSS-Filter:
.navbar-brand img, .apexcharts-canvas { filter: invert(); opacity: 1; }
Die invertierten dunklen Farben sind jetzt gut sichtbar, die hellen bleiben sichtbar.
Inputs einfärben
Da die inputs noch hell waren, mussten auch die angepasst werden. Mein CSS bezieht sich hier speziell auf Spectre:
.form-input, .input-group .input-group-addon { border: .05rem solid #5e6b80; } .form-input, .input-group .input-group-addon { background: #21325a; color: #b7becb; } .form-input:focus, .form-input:not(:placeholder-shown):invalid:focus { background: #0F1930; } .form-select:not([multiple]):not([size]) { background: #21325a url("data:image/svg+xml;charset=utf8,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%204%205'%3E%3Cpath%20fill='%23667189'%20d='M2%200L0%202h4zm0%205L0%203h4z'/%3E%3C/svg%3E") no-repeat right .35rem center/.4rem .5rem }
Grundsätzlich müssten sie aber auch geändert werden, wenn kein CSS-Framework ihnen bereits einen hellen Hintergrund und Grenzlinien gegeben hätte. Zumindest, solange auf Linux nicht auch diese Elemente automatisch abgedunkelt werden.
Bei der Nummerneingabe waren die Hoch-Runter-Buttons weiterhin hell und ließen sich nicht abändern. Also blendete ich sie aus:
input[type=number] { -moz-appearance: textfield; appearance: textfield; margin: 0; } input[type=number]::-webkit-inner-spin-button { -webkit-appearance: none; }
Die Buttons sind zwar eigentlich nett, aber das ganze Design zu zerstören sind sie nicht wert.
Einfacher als gedacht, aber Linux muss nachziehen
Ich war überrascht über den geringen Aufwand. Klar, es ist Designarbeit und jede Seite ist anders. Aber dank tollen Funktionen von CSS wie dem Farbfilter waren ansonsten problematische Ecken einfach zu lösen. Wenn das CSS einer Seite halbwegs organisiert ist oder Farben gar zentral definiert sind ist das Abdunkeln machbar.
Allerdings ärgerlich, dass Linux keinen definierten Mechanismus hat um den Dunkelmodus zu aktivieren. Es müsste eine Datei ~/.config/darkmode geben, die von Anwendungen und QT/GTK-Designs berücksichtigt wird. Klar kann man auch so ein dunkles Design auswählen, aber das weiß der Browserinhalt dann ja nicht.
Hier im Blog könnte ich jetzt ebenfalls relativ schnell ein dunkles Design anbieten, aber ich glaube ich warte damit bis Linux für den Dunkelmodus einen ordentlichen Mechanismus hat, oder bis die Browser die Einstellung regulär konfigurierbar machen.
Oga als Alternative zu Nokogiri
Friday, 22. May 2020
Der bekannteste XML-Verarbeitungshelfer im Rubyland ist sicherlich Nokogiri. Mit Nokogiri kann man schnell Informationen aus XML und HTML herausholen, per xpath oder CSS-Selektor. Ein Beispiel von der Webseite:
doc = Nokogiri::HTML(open('https://nokogiri.org/tutorials/installing_nokogiri.html')) puts "### Search for nodes by css" doc.css('nav ul.menu li a', 'article h2').each do |link| puts link.content end puts "### Search for nodes by xpath" doc.xpath('//nav//ul//li/a', '//article//h2').each do |link| puts link.content end
Gegen Nokogiri spricht eigentlich nur die Installation. Basierend auf der libxml, ist es desöfteren mindestens eine zusätzliche Abhängigkeit, an die man im Zweifel bei der Servereinrichtung denken muss. Manchmal führt es auch zu ganz komischen Problemen.
Hier setzt Oga an. Es hat diese Abhängigkeit einfach nicht. Das macht die Installation unproblematischer. Die API ist etwas anders als bei Nokogiri, einige der Unterschiede sind in einer Dokumentationsdatei beschrieben. Es ist aber schon sehr ähnlich:
onli@fallout:~$ irb 2.5.3 :001 > require 'oga' => true 2.5.3 :002 > xml = Oga.parse_xml('<a><b test="def">abc</b></a>') => Document( children: NodeSet(Element(name: "a" children: NodeSet(Element(name: "b" attributes: [Attribute(name: "test" value: "def")] children: NodeSet(Text("abc")))))) ) 2.5.3 :003 > xml.xpath('/a/b') => NodeSet(Element(name: "b" attributes: [Attribute(name: "test" value: "def")] children: NodeSet(Text("abc")))) 2.5.3 :004 > xml.at_xpath('/a/b') => Element(name: "b" attributes: [Attribute(name: "test" value: "def")] children: NodeSet(Text("abc"))) 2.5.3 :005 > xml.at_xpath('/a/b').get('test') => "def" 2.5.3 :006 > xml.css('b') => NodeSet(Element(name: "b" attributes: [Attribute(name: "test" value: "def")] children: NodeSet(Text("abc"))))
Überraschenderweise bin ich mit Oga selbst noch in keinerlei Probleme gelaufen, seitdem ich beispielsweise das Blogsystem ursprung darauf umgestellt habe. Es fehlt allerdings an Dokumentation und Beispielen. Bei Nokogiri ist die offizielle Dokumentation auch schon spärlich, bei Oga ist es nochmal weniger, und es fehlen die zu Nokogiri vorhandenen vielen Stackoverflowantworten. Wie beim SAX-Parser, als ich für Oga in den Quellcode schauen musste um die implementierten Events herauszusuchen und Nokogiri wenigstens einen erklärenden Absatz auf der Webseite hatte.
Trotzdem etabliert sich Oga mittlerweile als Bestandteil meiner Rubyprojekte. Installationsprobleme zu vermeiden ist mir sehr wertvoll. Wem das ähnlich geht oder wer eine Alternative zu Nokogiri sucht sollte Oga eine Chance geben.
Effizienter CSV-Dateien verarbeiten, mit Ruby und generell
Wednesday, 20. May 2020
Vor kurzem schrieb ich darüber, wie ich mit dem SAX-Parser besser mit XML-Dateien umgehen konnte. Besser bedeutete, mit weniger Speicherbedarf schneller die gesuchten Informationen aus teils relativ großen XML-Dateien zu holen. Es half, aber der Server hatte immer noch spürbare Last durch die anderen Datenquellen: Den CSV-Dateien. Sie benutzen manche der Datenausgeber statt XML, und auch bei ihnen führte das naive Vorgehen zu extremen Speicher- und Prozessorbedarf.
Das naive Vorgehen war grob so:
hardwares = Database.instance.getHardwares hardwares.each do |hardware| csv = cache.getset('csvApi') do csvGz = open("https://url/zur/csv.gz") unzippedCsv = Zlib::GzipReader.new(csvGz).read csv = CSV.parse(unzippedCsv, :headers => true) csv end return csv.detect{|line| line['id'] == hardware.id } end
Es gab also ein Array mit den Bezugsobjekten, zu denen die Zeile mit ihrer ID aus der CSV-Datei gezogen werden soll. Optimiert ist da bereits, dass die CSV-Datei nicht mehrfach heruntergeladen wird. Dafür sorgt der lru-cache.
Wie geht es besser?
1. Speichereffizienter parsen
Der erste Schritt ist das Parsen der CSV-Datei. Der bisherige Code macht das in einem Rutsch und baut – ähnlich wie bei XML-Dateien – ein CSV-Objekt. Wenn wir stattdessen Zeile für Zeile durchgehen entsteht eine Chance, den Speicherbedarf zu reduzieren. Dalibor Nasevic hat dazu Codebeispiele und Benchmarkergebnisse. Der Code ändert sich so:
unzippedCsv = Zlib::GzipReader.new(csvGz) csvFile = CSV.new(unzippedCsv, headers: true) while line = csvFile.shift # do something end
Der GzipReader liest nicht mehr die Datei auf einmal in den Speicher, mit diesem neuen Startpunkt geht der CSV-Parser zeilenweise durch die Datei. Wenn wir jetzt einfach das CSV-Objekt nachbauen bringt das nicht viel, aber es gibt uns die Möglichkeit etwas besseres zu bauen.
2. Mit fastcsv schneller parsen
Doch bleiben wir erstmal beim Parsen selbst. Derzeit benutzt der Code das in Ruby integrierte CSV-Modul. Doch es gibt Alternativen, insbesondere fastcsv. Das Gem kann in vielen Fällen das normale CSV-Modul direkt ersetzen und war in meinen Tests etwa doppelt so schnell.
require 'fastcsv' csvFile = FastCSV.new(unzippedCsv, headers: true)
Nett, aber das Parsen der CSV-Datei war gar nicht das Problem. Das sparte ein paar Sekunden. Das eigentliche Problem war das spätere Durchsuchen des erstellten CSV-Objekts.
3. Mit Hash nicht suchen, sondern nachschlagen
Das ist eine Optimierung, die in jeder Sprache funktionieren wird.
Wenn CSV.parse
ein CSV-Objekt erstellt, ist das im Grunde ein großes Array mit Arrays (headers: false
) oder Hashs (headers: true
) in den Arrayeinträgen. Entsprechend durchsucht der Code von oben dieses Array mit dem üblichen Enumerable.detect. Doch das bedeutet, dass für jedes Suchobjekt die CSV-Struktur durchgegangen werden muss, bis etwas gefunden wurde. Oder bis die Struktur durch ist und eben nichts gefunden wurde. Wenn es nur eine Datenstruktur gäbe, die für eine ID direkt die passende Zeile ausgeben könnte…
Die gibt es natürlich, genau das ist in Ruby der Hash. Da wir jetzt zeilenweise durch die CSV-Datei durchgehen und die Struktur selbst bauen können wir sie nutzen:
csv = cache.getset('csvApi') do … csv = {} while line = csvFile.shift csv[line['id']] = line end csv end return csv[hardware.id]
Das hier ist die große Optimierung. Anstatt mehrfach durch die riesige Datenmenge zu stöbern am Anfang mit etwas Mehraufwand die Hashstruktur zu erstellen spart danach so viel Zeit bei jedem Suchvorgang, dass minuten- bis stundenlange Prozesse in wenigen Sekunden fertig werden.
4. Ungenutzte Datenfelder rausschmeißen
Moment, da gibt es noch eine mögliche Optimierung, wieder völlig unabhängig von Ruby. Eventuell braucht es später gar nicht alle Felder, die in der CSV-Datei gespeichert sind. Vielleicht wird später nur nach price und available geschaut. Wenn dem so ist, dann ist genau hier der Moment die überflüssigen Felder zu entfernen und so den Speicherbedarf zu senken:
while line = csvFile.shift csv[line['id']] = line.to_h.keep_if{|k, _| k == 'price' || k == 'available' } end
Die Kombination dieser vier Schritte ist sehr mächtig. Was vorher viele Minuten rödelte und Prozessorkerne voll auslastete ist in ein paar Sekunden erledigt. Aber es ist ja auch ein Idealfall. Es gab genau eine ID, wir in einer Hashmap als Key nutzen und dann nachschlagen konnten. Was, wenn es mehr als einen Key gibt?
5. SQLite für mehrere IDs
In meinem Anwendungsfall gab es manchmal neben der id noch die sku, also einen zweiten Key. Dann reicht ein Hash nicht, denn es gibt keine mir bekannte Möglichkeit, einen zweiten Key einzusetzen. Klar, wir könnten einen zweiten Hash erstellen. Aber würde das nicht den Speicherbedarf verdoppeln? Nein, es wäre besser einen zweiten Key als Index über die alte Hashmap zu legen. In Ruby wüsste ich nicht wie das geht (wenn du schon: Ein Kommentar wäre klasse!). Aber SQLite macht das mit links und ist in jeder Sprache verfügbar.
Die Idee also ist: Statt einer Hashmap erstellen wir eine SQLite-Datenbank im Arbeitsspeicher. Primary Key
wird die id, aber für die sku baut SQLite einen Index. Das Durchsuchen geht dann mit ein bisschen SQL. YAML serialisiert die CSV-Zeile, die im Zweifel auch wieder wie in Schritt 4 speicheroptimiert werden könnte.
csv = cache.getset('csvApi') do … csvFile = FastCSV.new(result, :headers => true) db = SQLite3::Database.new(':memory:') db.execute "CREATE TABLE csv(id TEXT PRIMARY KEY, sku TEXT, line TEXT)" while line = csv.shift db.execute("INSERT OR IGNORE INTO csv(id, sku, line) VALUES(?, ?, ?)", line['id'], line['sku'], YAML::dump(line)) end db.execute "CREATE INDEX csv_sku ON csv(sku)" db.execute "ANALYZE" db end row = csv.execute("SELECT line FROM csv WHERE id = ?", hardware.id).first unless row row = csv.execute("SELECT line FROM csv WHERE sku = ?", hardware.sku).first end if row return YAML::load(row[0]) end
SQLite ist unheimlich schnell, die CSV-Datei wird in sekundenschnelle durchsucht sein, je nach Größe natürlich.
Fazit
Wenn man es mal richtig macht… Ich fand das ein gutes Beispiel für einen Anwendungsfall von Informatik-Grundkenntnissen. Statt ein Array zu durchsuchen die Datenstruktur zu ändern und eine Hashmap zu nehmen ist Grundlagenstoff des Studiums, Standardbeispiel für O(1) statt O(n). Aber ich brauchte einen Moment um zu erkennen, dass das hier möglich ist, das komfortable CSV.parse
hatte mir das versteckt. SQLite einzubauen und nach einem schnelleren Gem zu schauen ist dann vielleicht etwas mehr aus praktischer Erfahrung gezogen, aber liegt wenn man mal optimiert und und nach dem Datenbankkurs auch nicht mehr fern.
Mir hat dabei auch geholfen, diese Aufgabe als eigenes Projekt zu betrachten. Ursprünglich war das nur eine kleine Ecke im Code des Überprojekts (pc-kombo), schnell mal gebaut, abgewandelt aus Code der eine REST-API nach den Informationen fragt (wo solche Optimierungen nicht möglich sind). Jetzt ist die Ecke ausgelagert in ihr eigenes Git-Repository und der Code ist auf genau diese Aufgabe reduziert. Das macht es einfacher, solche Optimierungsmöglichkeiten zu sehen.
Auf jeden Fall lohnt sich der Aufwand. Zusammen mit der Reduzierung der Last durch XML-Dateien kann ich den großen Server bald wieder abschalten, der nach dem Scaleway-Umzug die temporäre Heimat dieses Mikroservice wurde. Aus dem Mikroservice wurde jetzt tatsächlich auch ein kleines Programm, das auf schmalerer Hardware wird laufen können. Das reduziert die Strom- oder die Hostingkosten dann schnell um ein paar hundert Euro im Jahr.
Victor: SVGs mit Ruby erstellen
Friday, 15. May 2020
Vom Code aus Bilder erstellen – da landet man dann schnell bei SVG. Um unter Ruby ein SVG zu erstellen kann victor benutzt werden. Das Readme zeigt direkt wie es geht. Dieser Code:
require 'victor' svg = Victor::SVG.new width: 140, height: 100, style: { background: '#ddd' } svg.build do rect x: 10, y: 10, width: 120, height: 80, rx: 10, fill: '#666' circle cx: 50, cy: 50, r: 30, fill: 'yellow' circle cx: 58, cy: 32, r: 4, fill: 'black' polygon points: %w[45,50 80,30 80,70], fill: '#666' 3.times do |i| x = 80 + i*18 circle cx: x, cy: 50, r: 4, fill: 'yellow' end end svg.save 'pacman'
erstellt diese Grafik:
Cool: Der Code hinter dem gem ist ziemlich minimal, das ist fast nur eine intelligent gestrickte kleine API, die genau richtig den Funken Komplexität versteckt, wegen dem man SVGs anonsten nicht per Hand schreiben will. Samt hilfreichen Beispielen wird es ein praktisches Werkzeug.
Ich finde es toll, wie SVG immer wieder in Projekte von mir reinrutscht. Nicht, weil es bei mir besonders beliebt wäre, sondern schlicht weil es immer wieder gut ein Problem löst.
Mit SAX effizienter XML parsen
Monday, 13. April 2020
Normalerweise nutze ich das DOM-basierte Einlesen von XML-Dateien. Mit oga unter Ruby zum Beispiel so:
require 'oga' doc = Oga.parse_xml('<people> <author> <name>onli</name> <real>yes</real> </author> <author> <name>Unsichtbares Einhorn</name> <real>no</real> </author> </people>')
doc
ist dann eine Baumstruktur, die z.B. mit xpath durchgegangen werden kann:
p doc.xpath("//name").map{|x| x.text } # => ["onli", "Unsichtbares Einhorn"]
So vorzugehen hat aber einen Nachteil: Den Speicherbedarf. Ich arbeitete vorhin mit einer XML-Datei, die als .gz heruntergeladen schlanke 13 MB wog. Entpackt waren es dann schon 164 MB. Der Speicherverbrauch beim direkten Einlesen nach obiger Methode? 4 GB. Viel zu viel für den kleinen Mini-PC, der später regelmäßig diese XML-Datei bearbeiten und bestimmte Einträge finden soll. Denn der hat nur 500 MB Ram.
Ich bin dann schließlich bei SAX gelandet. Grundsätzlich ist das nicht schneller, deswegen hatte ich diesen Ansatz nach einem Erstkontakt vor vielen Jahren nicht mehr in Betracht gezogen. Aber hier passt es: Anstatt die Datei auf einmal zu lesen und eine komplexe Struktur in den Speicher zu packen wird die XML-Datei Zeile für Zeile durchgegangen. Und bei jedem Schritt wird ein Handler benachrichtigt, welches Element das gerade ist. Der Handler kann dann alles ignorieren was für ihn nicht wichtig ist und seine Aufgabe erledigen. Das könnte z.B. sein, spezielle Elemente zu zählen, ein Standardbeispiel für SAX. Oder man kann es auch nutzen, um nur die Daten aus der XML-Datei herauszuholen die interessant sind. Entsprechend gering kann der Speicherverbrauch bleiben.
Wollte ich in der Beispieldatei von oben nur Autorennamen speichern, die real sind, dann könnte der SAX-Parser das so machen:
require 'oga' class RealAuthorNames attr_reader :names attr_reader :currentElement attr_reader :currentText attr_reader :name def initialize @names = end def on_element(namespace, name, attrs = {}) @currentElement = name end def on_text(text) unless text.strip.empty? @currentText = text @name = @currentText if currentElement == 'name' @names.push(@name) if @currentElement == 'real' && @currentText == 'yes' end end end handler = RealAuthorNames.new Oga.sax_parse_xml(handler, '<people> <author> <name>onli</name> <real>yes</real> </author> <author> <name>Unsichtbares Einhorn</name> <real>no</real> </author> </people>') p handler.names # => ['onli']
Die Liste der möglichen SAX-Ereignisse habe ich aus dem obersten Kommentar von ogas sax_parser.rb entnommen. Nokogiri hätte sie im Tutorial gelistet.
Der Code zeigt, warum ich SAX normalerweise vermeide: Schön ist das nicht. Selbst dieses simple Beispiel hat mehrere Zustandsvariablen und verlässt sich darauf, dass die XML-Datei regelmäßig aufgebaut ist. Aber es funktioniert eben: Die größere XML-Datei zu verarbeiten verbraucht jetzt nicht mehr 4 GB Speicher, wenn die für mich relevanten Daten rausgeholt werden, sondern der Arbeitsspeicherverbrauch bleibt unter 80 MB.
Bei hashchange den Viewport festhalten
Wednesday, 8. April 2020
Im Zuge der Javascript-Vermeidung beim Redesign von pc-kombo habe ich Tabs mit CSS umgesetzt. Das Tab ist ein a
-Element und zeigt auf den #Tabinhalt, also dessen id. Wenn auf das Tab geklickt wurde kann dann per CSS der Tabinhalt als :target
eingeblendet werden. Im Grunde wie hier von Tim Perry erklärt.
Ich wollte aber nun auch verhindern, dass der Browser nach dem Klick nach unten scrollt. Das wäre das normale Verhalten, wenn auf einen Link mit # geklickt wird. Hier aber stört das. Ich kam zuerst auf einen anderen Ansatz als im später gefundenen und verlinkten Artikel:
var tabs = document.querySelectorAll(".tab-item a"); var scrollX = 0; var scrollY = 0; var blockScroll = false; for (var i=0;i < tabs.length; i++) { tabs[i].addEventListener("click", function(e) { scrollX = window.pageXOffset; scrollY = window.pageYOffset; blockScroll = true; }); }; window.addEventListener('hashchange', function() { window.scrollTo(scrollX, scrollY); }, false); window.addEventListener('scroll', function() { if (blockScroll) { blockScroll = false; window.scrollTo(scrollX, scrollY); } });
Das Scrollen wird hier in drei Schritten und zwei Wegen blockiert bzw rückgängig gemacht:
- Zuerst wird beim Klick auf den Tab geschaut, wohin der Browser gerade gescrollt hat und das in zwei Variablen gespeichert.
- Wenn danach das
hashchange
-Event getriggert wird, scrollt das zur gespeicherten Position zurück. Das funktionierte oft, manchmal stimmt da aber die Abfolge nicht und es wird wohl verzögert trotzdem zum Tabinhalt gescrollt. - Deswegen fängt der zweite Ansatz den
scroll
-Event und scrollt auf die Ausgangsposition zurück.
Ja, das sieht ein bisschen gefährlich aus, als könnte man mit dem Code unabsichtlich ein Scrollereignis nach dem Tabwechsel blocken. In meinen Tests (in Firefox und Chromium) passiert das aber nicht, weil das Scroll-Ereignis immer einmal nach dem Klick ausgeführt und geblockt und daraufhin die Variable zurückgesetzt wird.
Der im Artikel vorgeschlagene Ansatz ist ein anderer: Bei Klick mache ein event.preventDefault()
, dann zweimal ein history.pushState()
zum Tabinhalt und gehe einmal zurück. Zweimal und zurück, weil wenn man einfach nur das Event unterdrückt und das target mit pushState() setzt der Browser das ignoriert. Er scrollt dann zwar nicht, aber deckt auch den Tabinhalt nicht auf. Mit dem doppelten Eintrag und einmaligen zurückgehen funktioniert das dagegen einwandfrei, und da flickerfrei besser als mit meinem ursprünglichen Ansatz. Der leicht angepasste Code:
var tabs = document.querySelectorAll(".tab-item a"); for (var i=0;i < tabs.length; i++) { tabs[i].addEventListener("click", function(e) { e.preventDefault(); history.pushState({}, "", e.target.getAttribute('href')); // Update the URL again with the same hash, then go back history.pushState({}, "", e.target.getAttribute('href')); history.back(); }); };
Das werde ich zusammen mit einer noch ausstehenden Änderung bald bei Hardwarelisten wie der für Prozessoren anwenden, sodass die Filterauswahl angenehmer zu benutzen ist.
Eine SQLite-Datenbank mit mehreren Prozessen teilen
Friday, 21. February 2020
In der FAQ von SQLite steht:
Multiple processes can have the same database open at the same time. Multiple processes can be doing a SELECT at the same time. But only one process can be making changes to the database at any moment in time, however.
Genau in dieser Situation war ich, und genau das ging nicht. In meinem Szenario läuft puma im Clustermodus, das heißt da gibt es mehrere Prozesse. Alle davon lesen die gleiche Datenbank. Zusätzlich gibt es einen schreibenden Prozess, der die Datenbank aktualisiert. Dank --preload
wird der configure
-Block der Sinatra-Anwendung nur einmal ausgeführt, dort startet die Aktualisierung in einem Thread. Also ist es genau wie in der FAQ beschrieben, ein schreibender Prozess, mehrere lesende. Doch es hagelte Fehler:
Error storing data: database is locked could not get data: database is locked
Probiere es selbst aus: Starte mit diesem Ruby-Skript einen Prozess, der in eine Datenbank schreibt:
require 'sqlite3' db = SQLite3::Database.new "test.db" db.execute "CREATE TABLE IF NOT EXISTS test(test INTEGER);" while true db.execute "INSERT INTO test VALUES(1);" db.execute "UPDATE test SET test = 2;" sleep 1 end
Und jetzt starte ein oder mehrere Prozesse, die diese Datenbank lesen:
require 'sqlite3' db = SQLite3::Database.new "test.db" while true p db.execute "SELECT * FROM test;" sleep 0.5 end
Und auch bei dir wird das sterben:
/home/onli/.rvm/gems/ruby-2.5.3/gems/sqlite3-1.3.13/lib/sqlite3/statement.rb:108:in `step': database is locked (SQLite3::BusyException)
In der FAQ kann doch nicht gemeint sein, dass mehrere Prozesse auf die Datenbank zugreifen können, solange sie haufenweise manuell solche Exceptions fangen und gescheiterte Zugriffe nochmal probieren? Was stimmt da nicht?
Des Rätsels Lösung: Der FAQ-Eintrag ist unvollständig.
SQLite kann tatsächlich über mehrere Prozesse hinweg Datenbankzugriffe teilen, und einer dieser Prozesse darf auch schreiben. Aber nicht in der Standardkonfiguration. Damit sich SQLite verhält wie beschrieben muss Write-Ahead Logging (WAL) aktiviert werden.
Wenn WAL aktiviert ist werden Änderungen nicht mehr direkt in die Datenbankdatei geschrieben, sondern erstmal in eine temporäre Datei. Ab und an wird dann synchronisiert. Es ist dieses Schema, das mehrere parallele Zugriffe über Prozessgrenzen hinweg ermöglicht.
Aber WAL ist standardmäßig aus. Um es zu aktivieren muss der Modus per PRAGMA aktiviert werden:
PRAGMA journal_mode=WAL;
Das ist persistent! Während andere Pragmas beim jeden Öffnen der Datenbank neu gesetzt werden müssen bleibt WAL aktiviert.
Und richtig: Die Testskripts von oben sterben nicht mehr, meine Anwendung beschwerte sich bisher nicht mehr über die gesperrte Datenbank.
Das ist wohl leider nicht 100%. In der Doku werden drei Szenarien beschrieben, in denen trotz WAL die Datenbank gesperrt sein kann:
- Wenn ein Prozess
PRAGMA locking_mode = EXCLUSIVE;
setzt. Simpel zu lösen: Mach das für den normalen Betrieb einfach nicht. - Wenn die letzte Verbindung (=Connection) zu einer Datenbank schließt und die WAL-Datei abgearbeitet wird. Das ist der Fall der Sorgen bereiten sollte, denn je nach Anwendung kann das regelmäßig passieren. https://sqlite.org/pragma.html#pragma_busy_timeout sollte dann eine Lösung sein.
- Wenn die letzte Verbindung zu einer Datenbank abstürzt und die nächste Verbindung die Datenbank wiederherstellt. Das sollte bei einer stabilen Anwendung nicht passieren und ist daher vermeidbar.
Sieht so aus, als könnte SQLite doch auch in diesem Szenario die Datenbank der Wahl bleiben. Andere Datenbanken mit einem Client/Servermodell haben hier zwar einen klaren Vorteil: Parallele Zugriffe sind gar kein Problem, auch mehrere schreibende nicht. Aber mit WAL und busy_timeout kann SQLite doch auch dieses Szenarien abdecken, und damit eines mehr als ursprünglich erwartet.
Spectre anpassen, trotz ReferenceError
Wednesday, 22. January 2020
Die Anleitung wie man das nette CSS-Framework Spectre manuell kompilieren und so anpassen kann ist nicht vollständig. Sie besagt:
npm install gulp build
Nach vorherigem Herunterladen der Quellcodedateien natürlich.
Stattdessen musste ich so vorgehen:
npm install npm-install-peers npm install
Danach warf npx gulp build
aber einen Fehler:
fs.js:27 const { Math, Object } = primordials; ^ ReferenceError: primordials is not defined at fs.js:27:26 at req_ (/home/onli/spectre/node_modules/natives/index.js:143:24) at Object.req [as require] (/home/onli/spectre/node_modules/natives/index.js:55:10) at Object.<anonymous> (/home/onli/spectre/node_modules/graceful-fs/fs.js:1:37)
Stackoverflow kennt die Antwort: Man muss eine Datei npm-shrinkwrap.json mit folgendem Inhalt anlegen:
{ "dependencies": { "graceful-fs": { "version": "4.2.2" } } }
Und dann nochmal npm install
ausführen.
Also: Gulp wird nicht automatisch vollständig installiert, um es auszuführen bedarf es npx
. Die Version die spectre braucht ist aber sowieso inkompatibel mit neueren node-Version (in meinem Fall: 13.2.0). Deswegen der Fix per shrinkwrap-Datei.
Ich hasse die npm-Welt aus vollem Herzen.
Zeitreihen in SQLite speichern
Friday, 17. January 2020
Es ist eigentlich Unsinn, Daten wie Zeitreihen in SQLite zu speichern, da Timeseries-Datenbanken noch vor kurzem ein kleines Hype-Thema waren. Es gibt also einige Alternativen. Jedoch fand ich keine zu meinem Projekt passende. Sie alle sind entweder gedacht zur Analyse von Serverevents, benutzen Java, werden nicht mehr gepflegt oder sind zu komplex aufzusetzen. rrdtool kommt einer simplen Lösung noch am nächsten, aber die nötige Vorgabe der Taktung macht es für mich unpassend. Mein Ziel, jahrelange Preishistorien mit jeweils unterschiedlicher Taktung und auch mal einigen Lücken effizient zu speichern ohne einen Kubernetes-Cluster laufen lassen zu müssen scheint außerhalb der Javawelt zu selten zu sein.
Also blieb mein Provisorium bestehen. Eine SQLite-Datenbank mit dieser Tabelle:
CREATE TABLE IF NOT EXISTS priceHistory( id TEXT, date INTEGER, vendor TEXT, price REAL );"
Später kam noch ein Index dazu, weil die Performance ohne zu schlecht war:
CREATE INDEX priceHistory_ean_vendor ON priceHistory(id, vendor);
Das funktioniert! Es ist sogar trotz steigender Datenmenge auch einige Monate später noch schnell genug.
Diese steigende Datenmenge ist aber doch ein Problem: Mittlerweile ist diese Datenbank schon über 4GB groß, die Hälfte davon ist der Index. Das müsste kein Problem sein, Speicherplatz ist ja billig. Nur stimmt das nicht; nicht relativ zu den Serverkosten eines kleinen Projekts und angesichts möglicher zukünftiger Einschränkungen bei der Serverwahl, wenn die Datenbank weiter wächst. Und unbeschränktes Wachstum mit einer dann immer schwieriger handhabbaren Datenbankdatei bereitet mir Kopfschmerzen. Gut für die Performance kann die Lösung auf Dauer auch nicht sein.
Also durchforstete ich nochmal das Angebot an Zeitreihendatenbank und fand wieder nichts genau passendes. Zeit, einen Schritt zurückzugehen und zu überlegen, ob das nicht in SQLite bleiben und trotzdem etwas optimiert werden kann.
Strategie: Nur speichern was nötig ist
Die Daten dienen nur einem Zweck: Den Preisverlauf von Produkten anzuzeigen. Das sieht zum Beispiel so aus:
Wir zeichnen also im Grunde Strecken, wirklich dynamisch ändern sich die Preise nicht. Für eine Strecke brauche ich aber nicht mehr als zwei Punkte. Im gezeigten Diagramm sind es dagegen viele Punkte für jeden Abschnitt, da sich über eine längeren Zeitraum am Preis genau gar nichts getan hat. Das ist das erste Einsparpotential: Speichere nur, wenn sich der Wert geändert hat.
An der Datenbank muss man dafür nichts ändern. Die Optimierung kann rein im Code beim Abspeichern eines neuen Preises passieren. Trotzdem änderte ich den Index, passend zu den Daten:
CREATE TABLE IF NOT EXISTS priceHistory( id TEXT, date INTEGER, vendor TEXT, price REAL, UNIQUE(id, date, vendor) );"
Unnötige Doppelungen zu verhindern kann nur helfen.
Dann muss die Logik beim Datenzufügen angepasst werden. Vorher war das ein einfaches INSERT
. Jetzt sieht der Code in etwa so aus:
def storePrice(id:, price:, vendor:) # first we see whether the new entry differs from the last two, because if not we replace the last one. We need only two points to paint a trend line segment lastEntries = @db.execute("SELECT rowid, price FROM pmdb.priceHistory WHERE id = ? AND vendor = ? ORDER BY date DESC LIMIT 2", id, vendor) if price == lastEntries[0]['price'] && price == lastEntries[1]['price'] @db.execute("DELETE FROM pmdb.priceHistory WHERE rowid = ?", lastEntries[0]['rowid']) end # regardless what happened before, now we can just insert @db.execute("INSERT INTO pmdb.priceHistory(id, price, vendor, date) VALUES(?, ?, ?, strftime('%s','now'))", id, price, vendor) end
Ich fand, ein paar Visualisierungen machen das schneller klar.
Die naive Lösung hat einfach alles eingefügt.
Jetzt aber schaut der Code, ob die vorherigen Datenpunkte den gleichen Preis anzeigten. Wenn ja, verlängert er die Strecke, indem der letzte Datenpunkt gelöscht und stattdessen der neue (mit gleichem Preis, aber späterem Zeitpunkt) abgespeichert wird.
Wenn aber der Preis sich geändert hat bleiben die alten Datenpunkte bestehen und der neue kommt hinzu.
Sogar die bestehende Datenbank kann so verkleinert werden. Ich muss nur alle gespeicherten Datenpunkte entnehmen und mit dieser Strategie einzeln in eine neue Datenbanktabelle einfügen. Das Ergebnis behält tatsächlich seine Form, von der nun störenden Linienglättung mal abgesehen:
Und die Datenbank schrumpfte von 4.8GB auf 384MB, sehr ordentlich!
Weitere Kompressionsstrategien
Der nächste Schritt wäre das Bilden von Durchschnittswerten über definierte Zeiträume, Downsampling. Vielleicht kriege ich derzeit, je nach Händler, jede Stunde oder jeden halben Tag einen neuen Preis. Sind zwei Monate vergangen interessiert nur noch, wie teuer das Produkt durchschnittlich an einem Tag war. Nach 6 Monaten reichen die durchschnittlichen Preise in einer Woche. Nach einem Jahr der Durchschnittspreis in einem Monat.
Je nach Art der Zeitreihendaten ist diese Strategie deutlich besser, oder nur unnötig kompliziert. Hängt ganz davon ab wie oft sich die Preise bzw die Zeitreihendatenwerte ändern.
Sie lässt sich mit Code umsetzen, der in regelmäßigen Abständen durch die Datenbank geht und die Daten zusammenfasst. Es wäre toll, wenn es eine kleine Datenbank gäbe, der man solche Daten geben kann und die solche Konsolidierungen automatisch durchführt. Influxdb kann das zum Beispiel (wenn es entsprechend konfiguriert wird), aber ich würde eine Lösung bevorzugen, die nicht als zu wartende Infrastruktur auf einem Port läuft, sondern wie SQLite oder rrdtool einbindbar ist.
Vielleicht gibt es eine passende Datenbank und ich kenne sie nur nicht?
Update 20.09.2020: Ich stolpere gerade via HN über DuckDB, was eine interessante Alternative zu SQLite für diesen Anwendungsfall sein könnte. In diesem Blogartikel finden sich Beispiele.
Rubys FastGettext ist tatsächlich schnell
Thursday, 19. December 2019
Ich war mal wieder am Performance-Debuggen von pc-kombo. Die Seite soll schneller laden, wenn der Cache noch nicht befüllt ist, was doch immer wieder Besucher trifft. Dabei stolperte ich über diesen Abschnitt des Flamegraphs:
tt
ist der Übersetzungshelfer, lru-redux der genutzte Cache, Grundlage das Gem i18n. Und dieser Abschnitt machte einen gewichtigen Teil des Seitenladevorgangs aus.
Also habe ich das alte Übersetzungssystem auf FastGettext umgestellt. Der Code in Sinatra sieht in etwa so aus:
helpers do include FastGettext::Translation def t(token, opts = {}) _(token.to_s) % opts end end configure do FastGettext.add_text_domain('pckombo', path: 'locales', type: :yaml) end before do FastGettext.text_domain = 'pckombo' if request.env['HTTP_ACCEPT_LANGUAGE'] languages = HTTP::Accept::Languages.parse(request.env['HTTP_ACCEPT_LANGUAGE']) languages.each do |language| case language.locale when /en[_]*/ FastGettext.locale = "us" break when /de[_]*/ FastGettext.locale = "de" break when /fr[_]*/ FastGettext.locale = "fr" break when /es[_]*/ FastGettext.locale = "es" break end end end end
Die alten yaml-Übersetzungen konnten weiterverwendet werden. So beginnt z.B. die locales/de.yml:
de: cpu: Prozessor
Es ist also fast eine einfach so einsetzbare Alternative mit minimalen Codeänderungen.
Das Ergebnis:
Die Übersetzungen beim ersten Laden brauchen nun einen Bruchteil der Zeit. Das beste daran: Das wird nicht nur den speziellen Seitenaufruf beschleunigen den ich da betrachtet hatte, sondern generell der gesamten Webseite helfen.
Wer Übersetzungen in Ruby umsetzen muss, für den ist FastGettext ist definitiv einen Blick wert.
Categories setzen mit Rubys RSS-Modul
Wednesday, 18. December 2019
Ruby kann ja RSS lesen und schreiben, aber die Dokumentation für das Modul ist ungenügend und es scheint generell wenig genutzt zu werden, sodass man auch wenig Hilfe findet. Ich stand jetzt vor einem Rätsel: Wie kann ich den Items in einem erstellten RSS-Feed Kategories hinzufügen, also category
setzen? Doch jetzt habe ich es gelöst. Und zwar geht das so:
rss = RSS::Maker.make("rss2.0") do |maker| … maker.items.new_item do |newItem| … categories = [{name: 'abc', scheme: 'https://www.example.com'}] categories.each do |category| target = newItem.categories.new_category target.content = category[:name] target.domain = category[:scheme] end end
Xapian/Omega in Ruby/Sinatra integrieren
Tuesday, 10. December 2019
Wie baut man am besten eine Suchmaschine für eine einzelne Webseite? In meinem Fall war das Szenario so: Ich habe eine Sammlung statischer HTML-Dokumente, will aber eine Suchfunktion anbieten. Also brauchte ich etwas, was die ganzen HTML-Dokumente indexiert und dann durchsuchen kann. Genau das kann Xapian. Speziell für Webseiten gibt es vom Xapian-Projekt die vorgefertigte Omega-Suchmachine.
Also, Xapian kann alles mögliche durchsuchen, man könnte dem Xapian-Index auch programmatisch Elemente der Datenbank hinzufügen. Und es muss nicht für eine Webseite sein, es kann auch sonstwo eingebunden werden – mancher mag sich an xapian als nervenden Prozess in Ubuntu erinnern. Ich aber wollte nur HTML-Seiten durchsuchbar machen und das online anbieten, und genau dafür ist Omega gedacht. Allerdings: Omega hat ein eigenes Webfrontend, das per CGI eingebunden wird. Das wollte ich nicht nutzen, denn ich habe sowieso schon ein zusätzliches dynamisches Backend laufen, und die Template-Funktion von Omega sah unschön und kompliziert aus.
Xapian und Xapian-Fu installieren
Xapian samt Omega sollte in den Quellen sein. Bei mir mit void:
sudo xbps-install xapian-omega
Dazu empfehle ich für die Sinatra-Integration das Gem xapian-fu. Also in die Gemfile:
gem 'xapian-fu'
und dann installieren mit bundle install
.
Allerdings reichte das nicht. Xapian-fu braucht die Ruby-bindings von xapian, und die waren bei mir nicht in den Quellen enthalten. Also muss in dem Fall noch das Archiv xapian-bindings heruntergeladen und die Ruby-Bindings kompiliert werden:
unp xapian-bindings-* cd xapian-bindings-VERSION ./configure --with-ruby cd ruby make sudo make install
Mit omindex indexieren
Omindex installierte sich in den PATH und ist einfach bedienbar. Meine HTML-Dateien liegen im Ordner public/, Bilder sollten ignoriert werden, dafür kam ich auf diesen Befehl:
omindex --db omega/data/default/ --filter image/*:skip public/
Der Code in Sinatra
Statt jetzt den CGI-Part von Omega zu nutzen übernimmt Sinatra mit Xapian-Fu die Suche:
require 'sinatra' require 'xapian-fu' include XapianFu get '/search' do searchterm = params['searchterm'] db = XapianDb.new(:dir => 'omega/data/default/', :create => false) results = db.search(searchterm).map{|match| {url: match.data.split("\n").detect{|x| x.start_with?('url=')}.to_s.sub('url=', ''), caption: match.data.split("\n").detect{|x| x.start_with?('caption=')}.to_s.sub('caption=', '')} } erb :search, locals: {searchresults: results, searchterm: searchterm} end
Man sieht: Der Code nimmt die Datenbank als Quelle, die vorher omindex angelegt hat.
Die Zuweisung in den results-Hash ist hässlich. Ich fand leider keinen Weg, mit xapian-fu die Suchergebnisse richtig strukturiert auszulesen. Nur match.data
ist zugänglich, womit man etwas anfangen kann. Der Weg über einen XapianDocValueAccessor, mit dem man wohl die Felder gezielt auslesen können sollte, funktionierte bei mir nicht. Eventuell codiert omindex den Suchindex zu speziell.
Die Suchergebnisse gehen dann an ein ERB-Template:
<h1>Search results for <%= h searchterm %></h1> <% if searchresults.size > 0 %> <ol id="searchresults"> <% searchresults.select{|item| ! item[:caption].empty? }.each do |result| %> <li> <a href="<%= result[:url] %>"> <span><%= result[:caption] %></span> </a> </li> <% end %> </ol>
Xapian/Omega ist keine Lösung, wenn die Webseite nur aus statischen HTML-Seiten bestehen darf. Aber es ist wohl eine gute Lösung wenn es einen echten Server gibt. Die Integration in Ruby/Sinatra ist relativ einfach.
Dass die xapian-bindings nicht einfach per gem installierbar sind ist der problematischste Punkt, auch das vorgelagerte indexieren wird nicht in jedes Projekt gut passen. Ohne statischen Webseitengenerator im Zentrum würde ich omega und omindex ignorieren und stattdessen nur mit xapian-fu arbeiten. Dann kämen die Daten eben direkt aus der Datenbank.
Hier aber war das Parsen der HTML-Seiten die perfekte Lösung.
Alternativer Startpunkt: Generator statischer Seiten
Tuesday, 5. November 2019
Ich versuche diesmal etwas anderes. Normalerweise baue ich Webanwendungen mit Ruby um Sinatra herum, zum Beispiel die Blogsoftware ursprung. Auch der PC-Hardwareempfehler pc-kombo ist im Grunde so eine Ruby/Sinatra-Anwendung. Aber man will ja nicht immer das gleiche machen, sondern dazulernen und neue Lösungen ausprobieren. Bei Pipes z.B. war das der grafische Editor. Gerade versuche ich mich daher an einem statischen Seitengenerator als technischen Kern eines neuen Projekts.
Worum geht es dabei? Performance und Architektur. Zum einen verspreche ich mir eine schnellere Seite, wenn ihr Kern statisches HTML ist und nur von nginx ausgeliefert wird, ohne Datenbankabfrage oder Kontakt mit Ruby. Zum anderen will ich auch sehen, wie anders die Daten strukturiert werden können und wie viel kleiner der Code wird, wenn der Fokus mehr auf das generierte HTML und CSS und eventuell Javascript liegt und beim Seitenaufruf nicht direkt auf die Datenbank zugegriffen werden muss.
Bisher setzte ich sehr auf Konfiguration. So gibt es einen Ordner pages/ mit JSON-Dateien, die bestimmen welche HTML-Seiten erstellt werden sollen. Als Beispiel die gpu.json:
{ "view": "gpus", "target": "gpus.html", "collection": "gpus" }
Die Felder hier bestimmen das Verhalten des Generators: view
welches Template verwendet wird, target
welche Datei erstellt werden soll, collection
welche Datenbankdaten dem Template mitgegeben werden sollen.
Dazu gehören noch zwei Templates: Zuerst die implizit aufgerufene layout.erb:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <title>generatortest</title> </head> <body> <%= yield %> </body> </html>
Und die gpus.erb:
<h2>Gpus</h2> All the gpus we know: <ol> <% collection.each do |item| %> <li><%= item[:name] %></li> <% end %> </ol>
All das beachtet die generator.rb:
require 'erb' require 'tilt' require 'json' require 'moneta' store = Moneta.new(:Memory) store[:gpus] = [{id: 1, ean: '123', name: 'WindForce RTX 2080'}, {id: 2, ean: '456', name: 'Sapphire RX 590'} ] # foreach page definition in pages, get the specified data collection, give it to the template and save the html at the target destination Dir.glob(File.expand_path('../pages/*.json', __FILE__)).each do |file| control = JSON.parse(File.new(file).read, symbolize_names: true) collection = store[control[:collection].to_sym] if control[:collection] view = File.join('../views/', control[:view] + '.erb') view = File.expand_path(view, __FILE__) if File.exists?(view) layoutpath = File.expand_path('../views/layout.erb', __FILE__) layout = Tilt::ERBTemplate.new(layoutpath) output = layout.render { # this construction ensures the layout is used template = Tilt::ERBTemplate.new(view) # the final render has nothing to evaluate but needs the # the local collection, thus the empty Object as param template.render(Object.new, collection: collection) } targetpath = File.join('../public/', control[:target]) targetpath = File.expand_path(targetpath, __FILE__) File.write(targetpath, output) end end
Man sieht: Das ist absolutes Anfangsstadium. Aber es funktioniert schonmal! Nach Aufruf des Generators landet fertiges HTML im Ordner public/. Was dann der wäre, den der Webserver ausliefern könnte, inklusive dieser gpus.html:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <title>generatortest</title> </head> <body> <h2>Gpus</h2> All the gpus we know: <ol> <li>WindForce RTX 2080</li> <li>Sapphire RX 590</li> </ol> </body> </html>
Überlegungen
Um zwei Entscheidungen habe ich mich gedrückt bzw sie ungewöhnlich flexibel gelassen. Mit Moneta vermeide ich die Entscheidung für eine Datenbank. Normalerweise wäre das sqlite, aber ich bin noch nicht sicher ob das hier wirklich passt oder ob es mehrere Prozesse geben wird. Deswegen landen die Datenbanktestdaten bisher auch nur im Arbeitsspeicher. Und tilt würde es später einfach machen, statt erb eine andere Templatesprache zu benutzen.
Bedenken habe ich noch wegen der Filter. Ich weiß, dass ich später Nutzer Listen – wie die im Beispiel erstellte – filtern lassen will. Soll das komplett per Javascript geschehen? Oder baue ich dafür Seiten ein, die mit Ajax auf die angepassten Datenbanksets zugreifen? Dass es einen dynamischen Teil der Anwendung geben wird ist immerhin bereits vorgesehen, als Backend für den Administrator.
Ob der Seitengenerator nicht ein Monster werden wird? Ich werde versuchen müssen ihn schlank zu halten. Andererseits sehe ich gerade nicht, dass ihm noch viel fehlen würde, um die Seite umzusetzen die ich bauen will.
Erstes Fazit
Das könnte funktionieren. Wie schlüssig sich das Konzept anfühlt und wie schnell dieser Prototyp zu bauen war erklärt mir etwas, warum die letzten Jahre diese static site generators so beliebt wurden. Es ist eben nicht nur etwas für Blogs oder eine Homepage. Die HTML-Erstellung nicht erst beim Seitenaufruf zu machen ist auch generell ein gutes Prinzip.
Aber es wird sich zeigen, ob mit diesem Weg dann auch wirklich eine performante und wartbare Webanwendung entsteht.