Discover Meteor

Building Real-Time JavaScript Web Apps

Einleitung

1

Lass uns ein kleines Gedankenexperiment durchführen. Stell dir vor du öffnest denselben Ordner in zwei unterschiedlichen Fenstern auf deinem Computer.

Jetzt löschst du eine Datei in einem der beiden Fenster. Ist diese Datei im zweiten Fenster ebenfalls verschwunden?

Du musst diese beiden Schritte nicht ausführen, um das Ergebnis zu kennen. Wenn wir auf dem lokalen Dateisystem etwas ändern, werden diese Änderungen überall übernommen - ohne dass zusätzliche Schritte oder Aktualisierungen notwendig sind. Es passiert einfach.

Aber wie würde dies im Web funktionieren? Nimm mal an, du öffnest die Administrations-Oberfläche einer WordPress-Installation in zwei Browser-Fenstern und erstellst einen neuen Blog-Eintrag in einem der beiden Fenster. Anders als beim ersten Beispiel mit dem lokalen Dateisystems, wird die Änderung im zweiten Fenster nicht angezeigt - egal wie lange du wartest. Es sei denn, du aktualisierst das Fenster.

Im Laufe der Jahre haben wir uns daran gewöhnt, dass wir mit einer Webseite in kurzen, unabhängigen Aktionen kommunizieren.

Aber Meteor ist Teil einer neuen Art von Frameworks und Technologien, die versuchen, das Web reaktionsfreudiger (reactive) und Echtzeit-fähig zu machen.

Was ist Meteor?

Meteor ist eine Plattform, die auf Node.js aufsetzt, um Echtzeit-Web-Apps zu ermöglichen. Meteor sitzt zwischen der Datenbank deiner App und dem Benutzer-Interface und stellt dabei sicher, dass beide synchron sind.

Da es auf Node.js aufsetzt, verwendet Meteor sowohl auf dem Server als auch auf dem Client JavaScript. Besser noch - Meteor kann denselben Code sogar mit beiden Seiten teilen.

Das Ergebnis ist eine Plattform die sehr leistungsfähig und dabei doch einfach ist. Sie erspart dir viele Schwierigkeiten und Fallstricke der Echtzeit-Web-App-Entwicklung.

Warum Meteor?

Aber warum solltest du Zeit aufwenden, um Meteor - statt einem anderen Web-Framework - zu lernen? Einmal abgesehen von den verschiedenen Funktionen von Meteor selbst glauben wir, dass es auf eines besonders ankommt: Meteor ist einfach zu lernen.

Mehr als jedes andere Framework, ermöglicht es Meteor, eine Echtzeit-Web-Anwendung in wenigen Stunden zu erstellen. Und falls du schon mal eine Benutzer-Schnittstelle für Webbrowser erstellt hast, kennst du JavaScript sowieso - du musst also nicht einmal etwas Neues lernen.

Meteor könnte also das ideale Framework für deine Zwecke sein - oder auch nicht. Aber da man mit Meteor nach ein paar Abenden oder einem Wochenende bereits gut arbeiten kann, kann man es doch wenigstens mal ausprobieren, oder nicht?!

Warum dieses Buch?

In den letzten Jahren haben wir an zahlreichen Meteor Projekten gearbeitet, sowohl Web-Apps also auch mobile Anwendungen, kommerziell und Open-Source.

Dabei haben wir einen Haufen gelernt, aber es war nicht immer einfach gleich eine passende Antwort auf unsere Fragen zu finden. Oft mussten wir viele kleine Teile aus unterschiedlichen Quellen zusammenstückeln. Oder manchmal auch was ganz neues erfinden. Mit diesem Buch wollen wir genau diese Erfahrungen teilen und auf Basis einer einfachen Schritt-für-Schritt-Anleitung eine vollwertige Meteor App von Grund auf erstellen.

Die App die wir hier bauen, ist eine etwas vereinfachte Version von Social News Websites wie Hacker News der Reddit, die wir ‘Microscope’ nennen (dieser Name nimmt Bezug auf den 'großen Bruder’ dieser Anwendung, die Meteor-basierte Open-Source Anwendung Telescope). Während wir diese erstellen, zeigen wir alle Elemente, die am Ende eine Meteor-App ausmachen; wie z.B. Benutzer-Konten, Meteor-Collections, Routing und mehr.

Für wen ist dieses Buch geschrieben?

Eines unserer Ziele beim Schreiben dieses Buches war, die Dinge zugänglich und verständlich zu machen. Du solltest auch ohne Erfahrung mit Meteor, node.js, MVC frameworks oder serverseitiger Programmierung folgen können.

Die einzigen Vorkenntnisse, die wir voraussetzen, sind eine gewisse Vertrautheit mit grundlegender JavaScript Syntax und Konzepten. Aber wenn du jemals irgendetwas mit ein paar Zeilen jQuery Code zusammengebastelt hast oder ein wenig mit der Entwickler-Konsole eines Web-Browser herumgespielt hast, solltest du keine Probleme haben.

Über die Autoren

Falls du dich fragst, wer wir eigentlich sind und warum du uns glauben sollst, gibt es hier noch ein paar Hintergrundinformationen über uns beide.

Tom Coleman ist Mitbetreiber von Percolate Studio, einer Web-Entwicklungs-Agentur mit Fokus auf Qualität und Benutzerfreundlichkeit. Er betreut das Atmosphere Pakete-Repository und zählt zu den Köpfen vieler anderer Open-Source-Projekte von Meteor (wie beispielsweise Iron Router).

Sacha Greif hat bei vielen Startups, wie z.B. Hipmunk und RubyMotion als Produkt- oder Web-Designer mitgewirkt. Er hat Telescope und Sidebar geschrieben (Welches wiederum auf Telescope aufsetzt). Außerdem ist er der Gründer von Folyo.

Kapitel & Sidebars

Wir wollen, dass dieses Buch sowohl für neue Meteor-Anwender, wie auch für erfahrene Entwickler hilfreich ist, deshalb haben wir die Kapitel in zwei Kategorien unterteilt: - Normale Kapitel (von 1 bis 14) - Sidebars (halbe Nummern - also ,5)

Die normalen Kapitel führen Dich durch die Erstellung der App und versuchen, dir nur das Wichtigste beizubringen. Um dir ein schnelles Vorankommen zu ermöglichen, wurden in diesen Abschnitten Details bewusst auslassen, die dich nur unnötig aufhalten.

Die Sidebars gehen hingegen mehr ins Detail und helfen dir, ein besseres Verständnis dafür zu bekommen, was hinter den Funktionen wirklich steckt.

Wenn du also Neuling bist, dann kannst du zunächst die Seitenleisten überspringen und später dein Wissen vertiefen.

Commits & Live Instanzen

Mache dir keine Gedanken, falls du dem Buch folgst und plötzlich feststellst, dass dein Code nicht mehr mit den Beispielen übereinstimmt und nichts so funktioniert, wie es eigentlich sollte.

Dafür haben wir ein GitHub-Repository für Microscope angelegt und für alle paar Änderungen, direkte Links zu Git-Commits bereitgestellt. Außerdem verweist jeder Commit auf eine Live-Instanz der App, die zu diesem Commit gehört. So kannst du diesen mit deiner lokalen Kopie vergleichen.

Das sieht dann zum Beispiel so aus:

Commit 11-2

Benachrichtigungen im Header anzeigen.

Das soll aber jetzt nicht heißen, dass du einfach von einem git checkout zum nächsten hüpfen sollst. Du lernst viel besser etwas, wenn du dir die Zeit nimmst und den Code von Hand eingibst.

Ein paar weitere Hilfsmittel

Möchtest du mehr über die einzelnen Aspekt von Meteor wissen, dann ist die offizielle Meteor Dokumentation der beste Ort um nachzuschlagen.

Für Fragen und Problemlösungen empfehlen wir auch Stack Overflow und den IRC Kanal #meteor, solltest du schnelle Hilfe benötigen.

Brauche ich Git?

Es ist nicht nötig, dass man sich mit Git und der Versionsverwaltung auskennt, um mit diesem Buch zu arbeiten - wir empfehlen es jedoch.

Für einen schnellen Einstieg empfehlen wir Nick Farinas Git Is Simpler Than You Think.

Den Git-Neulingen empfehlen wir außerdem die GitHub for Mac App. Mit dieser kann man Repositorien auch ohne die Kommandozeile verwalten.

Getting in Touch

Vorbereitung

2

Der erste Eindruck ist entscheidend und Meteors Installationsprozess sollte relativ schmerzfrei sein. Normalerweise solltest du in weniger als 5 Minuten loslegen können.

Du kannst Meteor installieren, in dem du das Terminal öffnest und Folgendes eingibst:

$ curl https://install.meteor.com | sh

Dies installiert das ausführbare Programm meteor auf deinem System und erlaubt Dir Meteor zu benutzen.

Meteor nicht installieren

Wenn du Meteor nicht lokal installieren kannst (oder willst), empfehlen wir dir Nitrous.io.

Auf Nitrous.io kannst du deine App laufen lassen und den Code direkt im Browser bearbeiten. Wir haben dazu eine kurze Anleitung geschrieben, um die Installation zu vereinfachen.

Du kannst einfach dieser Anleitung folgen bis einschließlich dem Abschnitt “Installing Meteor”. Danach folgst du wieder ab dem Abschnitt “Creating a Simple App” in diesem Kapitel.

Eine einfache App erzeugen

Nun da wir Meteor installiert haben, erstellen wir eine App. Dazu benutzen wir Meteors Kommandozeilenwerkzeug meteor:

$ meteor create microscope

Dieser Befehl lädt Meteor herunter und erstellt ein einfaches, lauffähiges Meteor Projekt für dich. Sobald es fertig ist, solltest du ein Verzeichnis microscope/ mit folgendem Inhalt sehen:

.meteor
microscope.css  
microscope.html 
microscope.js   

Die App die Meteor für dich erstellt hat ist eine einfache Boilerplate-App, die einige einfache Strukturen enthält.

Obwohl die App noch nicht viel macht, können wir sie trotzdem starten. Um die App zu starten, wechsle zurück in das Terminal und gib Folgendes ein:

$ cd microscope
$ meteor

Öffne nun in deinem Browser die URL http://localhost:3000/ (oder auch http://0.0.0.0:3000/). Du soltest nun ungefähr dies sehen:

Meteors Hello World.
Meteors Hello World.

Commit 2-1

Einfaches Microscope-Projekt erzeugt.

Gratuliere! Deine erste Meteor App ist nun lauffähig. Um die App wieder zu stoppen, wechsle zurück in das Terminal (in dem die App läuft) und drücke ctrl+c.

Falls du Git benutzt ist das jetzt ein guter Zeitpunkt um Dein Repo mit ‘git init’ zu initialisieren.

Auf Wiedersehen Meteorite

Es gab eine Zeit zu der Meteor einen externen Package Manager mit dem Namen Meteorite genutzt hat. Seit Meteor Version 0.9.0 wird Meteorite nicht mehr gebraucht, da all dessen Funktionen in Meteor integriert worden sind.

Wenn Du also in diesem Buch oder auch im Netz irgendwelche Code-Schnipsel mit dem 'mrt’ Kommando findest, kannst du diese einfach mit dem 'meteor’-Befehl ersetzen.

Hinzufügen einer Package

Nun werden wir Meteors Package System nutzen um das Bootstrap Framework zu unserem Projekt hinzuzufügen.

Dabei passiert zunächst einmal nichts anderes, als Bootstrap auf die herkömmliche Weise hinzuzufügen, also die entsprechenden CSS und JavaScript Dateien in unser Projekt einzufügen. Der Vorteil liegt darin, dass sich nun Meteor um das Updaten dieser Package kümmert. Vielen Dank an das Mitglied der Meteor-Gemeinschaft Andrew Mao ( “mizzao” in mizzao:bootstrap-3 ist der Username des Authors einer Package)!

Und wenn wir schon mal dabei sind, fügen wir auch noch die Underscore Package hinzu. Underscore ist eine JavaScript Utility Bibliothek, die sehr nützlich zur Manipulation von JavaScript Datenstrukturen ist.

Aktuell ist die 'underscore’ Package Teil der “offiziellen” Meteor Packages und hat deshalb keinen Author.

meteor add mizzao:bootstrap-3
meteor add underscore

Bitte beachte, dass wir Bootstrap 3 hinzufügen. Einige der Screenshots in diesem Buch wurden mit einer älteren Version von Microscope gemacht, die noch auf Bootstrap 2 basierte, weshalb sie vielleicht ein wenig anders aussehen.

Commit 2-2

Bootstrap und underscore Package hinzugefügt.

Sobald Du die Bootstrap package hinzugefügt hast, sollte sich das Aussehen der Basis-App ändern:

Mit Bootstrap.
Mit Bootstrap.

Anders als beim “traditionellen” Weg externe Assets einzubinden, mussten wir keine CSS oder JavaScript Dateien verlinken, da Meteor sich um alles kümmert! Das ist nur eine der vielen Vorteile der Meteor Packages.

Eine Anmerkung zu Packages

Wenn wir von Packages im Kontext von Meteor sprechen müssen wir etwas genauer sein. Meteor nutzt fünf Basis-Typen von Packages:

  • Der Meteor-Core selbst ist in verschiedene Meteor Plattform Packages unterteilt. Sie sind in jeder Meteor-App integriert und du musst dich eigentlich nie um diese kümmern.
  • Reguläre Meteor Packages werden “isopacks”, oder “isomorphic packages” genannt. Das bedeutet, dass sie sowohl auf dem Client als auch auf dem Server funktionieren. First-party packages wie z.B. accounts-ui oder appcache werden vom Meteor Core Team gepflegt und sind in Meteor eingebunden.
  • Third-party Packages sind isopacks die von anderen Benutzern entwickelt und auf Meteors Package Server hochgeladen worden sind. Du kannst sie dir auf Atmosphere oder mit dem meteor search Befehl anschauen. – Lokale Packages sind eigenerstellte Packages, welche du selbst erstellen und im Verzeichnis /packages ablegen kannst.
  • NPM Packages (Node.js Packaged Modules) sind Node.js-Packages. Obwohl diese nicht einfach so mit Meteor funktionieren, können sie von den oben genannten Typen von Packages genutzt werden.

Die Datei-Struktur einer Meteor-App

Bevor wir mit dem Programmieren anfangen, müssen wir unser Projekt korrekt aufsetzen. Um einen problemlosen Build-Prozess zu gewährleisten, öffnen wir das Verzeichnis microscope und löschen die Dateien microscope.html, microscope.js und microscope.css.

Danach erstellen wir vier Verzeichnisse in /microscope: /client, /server, /public und /lib.

Weiterhin legen wir die leeren Dateien main.html und main.js in /client an. Keine Sorge, wenn du die App dadurch vorübergehend unbrauchbar machst - wir befüllen die Dateien im nächsten Kapitel.

Erwähnt werden sollte auch, dass einige dieser Verzeichnisse speziell sind. In Bezug auf Dateien hat Meteor folgende Regeln:

  • Code im Verzeichnis /server wird nur auf dem Server ausgeführt.
  • Code im Verzeichnis /client läuft nur auf dem Client.
  • Dateien an anderen Orten werden sowohl auf dem Client als auch auf dem Server ausgeführt.
  • Deine statischen Assets (Schriften, Bilder etc.) gehören in das Verzeichnis /public.

Es ist auch nützlich zu wissen, in welcher Reihenfolge Meteor die Dateien einer App lädt:

  • Dateien in /lib werden immer zuerst geladen.
  • Alle main.* Dateien werden als Letzes geladen.
  • Alle anderen Dateien werden auf Basis des Dateinamens in alphabetischer Reihenfolge geladen.

Beachte, dass obwohl Meteor diese Regeln hat, du trotzdem nicht zwingend diese vordefinierte Dateistruktur für deine App verwenden musst. Die Struktur, die wir vorschlagen ist nur ein möglicher Weg und ist nicht in Stein gemeisselt.

Lies doch auch die offizielle Meteor Dokumentation, wenn du mehr Informationen hierzu benötigst.

Ist Meteor MVC?

Wenn du andere Frameworks wie Ruby on Rails benutzt, fragst du dich vielleicht, ob Meteor-Apps das MVC-Pattern (Model View Controller) verwenden.

Die kurze Antwort ist: Nein. Im Gegensatz zu Rails zwingt dich Meteor nicht eine vorgegeben Struktur für deine App zu nutzen. In diesem Buch strukturieren wir den Code, wie er uns am meisten Sinn macht, ungeachtet etwaiger Akronyme.

Nicht öffentlich?

Ok, wir haben etwas gelogen. Wir brauchen das Verzeichnis /public eigentlich nicht. Dies hat den einfachen Grund, dass Microscope keine statischen Assets benutzt. Da aber die meisten anderen Meteor-Apps mindestens einige Bilder einbinden, wollen wir es kurz erwähnen.

Vielleicht hast du das versteckte Verzeichnis .meteor bemerkt. Dort speichert Meteor seinen eigenen Code. Dort Änderungen vorzunehmen ist normalerweise eine schlechte Idee. Grundsätzlich musst du dieses Verzeichnis nicht kennen. Die einzigen Ausnahmen sind die Dateien .meteor/packages und `.meteor/release’. Diese können genutzt werden, um die verwendeten Smart Packages aufzulisten und die benutzte Version von Meteor zu spezifizieren. Wenn du Packages hinzufügst oder den Meteor-Version änderst, kann es hilfreich sein, die Änderungen in diesen Dateien zu überprüfen.

Unterstriche oder CamelCase

Das Einzige, was wir zu der uralten Debatte Unterstriche (my_variable) vs. CamelCase (myVariable) sagen, ist, dass es nicht darauf ankommt welches du wählst, solange du dich für eine Variante entscheidest.

In diesem Buch verwenden wir CamelCase, da es üblicherweise so in JavaScript gemacht wird. (Schliesslich heisst es JavaScript und nicht java_script!).

Die einzige Ausnahme zu dieser Regel sind Dateinamen, in welchen Unterstriche (my_file.js) verwendet werden, sowie CSS Klassen die Bindestriche (.my-class) benutzen. Der Grund dafür ist, dass im Dateisystem Unterstriche geläufig sind, während in der CSS Syntax sich Bindestriche durchgesetzt haben (font-family, text-align, etc.).

Verwendung von CSS

Dieses Buch behandelt kein CSS. Um dich nicht auch noch mit Styling-Details zu belästigen, haben wir uns entschieden, das gesamte Stylesheet von Anfang vorzugeben. darüber musst du Dir keine Sorgen machen.

CSS wird automatisch von Meteor geladen und komprimiert. Deshalb muss es im Gegensatz zu den anderen statischen Assets im Verzeichnis /client und nicht im Verzeichnis /public abgelegt werden. Erstelle nun das Verzeichnis client/stylesheets/ und speichere die Datei styles.css mit folgendem Inhalt:

.grid-block, .main, .post, .comments li, .comment-form {
  background: #fff;
  -webkit-border-radius: 3px;
  -moz-border-radius: 3px;
  -ms-border-radius: 3px;
  -o-border-radius: 3px;
  border-radius: 3px;
  padding: 10px;
  margin-bottom: 10px;
  -webkit-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.15);
  -moz-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.15);
  box-shadow: 0 1px 1px rgba(0, 0, 0, 0.15); }

body {
  background: #eee;
  color: #666666; }

.navbar {
  margin-bottom: 10px; }
  /* line 32, ../sass/style.scss */
  .navbar .navbar-inner {
    -webkit-border-radius: 0px 0px 3px 3px;
    -moz-border-radius: 0px 0px 3px 3px;
    -ms-border-radius: 0px 0px 3px 3px;
    -o-border-radius: 0px 0px 3px 3px;
    border-radius: 0px 0px 3px 3px; }

#spinner {
  height: 300px; }

.post {
  /* For modern browsers */
  /* For IE 6/7 (trigger hasLayout) */
  *zoom: 1;
  position: relative;
  opacity: 1; }
  .post:before, .post:after {
    content: "";
    display: table; }
  .post:after {
    clear: both; }
  .post.invisible {
    opacity: 0; }
  .post.instant {
    -webkit-transition: none;
    -moz-transition: none;
    -o-transition: none;
    transition: none; }
  .post.animate{
    -webkit-transition: all 300ms 0ms;
    -webkit-transition-delay: ease-in;
    -moz-transition: all 300ms 0ms ease-in;
    -o-transition: all 300ms 0ms ease-in;
    transition: all 300ms 0ms ease-in; }
  .post .upvote {
    display: block;
    margin: 7px 12px 0 0;
    float: left; }
  .post .post-content {
    float: left; }
    .post .post-content h3 {
      margin: 0;
      line-height: 1.4;
      font-size: 18px; }
      .post .post-content h3 a {
        display: inline-block;
        margin-right: 5px; }
      .post .post-content h3 span {
        font-weight: normal;
        font-size: 14px;
        display: inline-block;
        color: #aaaaaa; }
    .post .post-content p {
      margin: 0; }
  .post .discuss {
    display: block;
    float: right;
    margin-top: 7px; }

.comments {
  list-style-type: none;
  margin: 0; }
  .comments li h4 {
    font-size: 16px;
    margin: 0; }
    .comments li h4 .date {
      font-size: 12px;
      font-weight: normal; }
    .comments li h4 a {
      font-size: 12px; }
  .comments li p:last-child {
    margin-bottom: 0; }

.dropdown-menu span {
  display: block;
  padding: 3px 20px;
  clear: both;
  line-height: 20px;
  color: #bbb;
  white-space: nowrap; }

.load-more {
  display: block;
  -webkit-border-radius: 3px;
  -moz-border-radius: 3px;
  -ms-border-radius: 3px;
  -o-border-radius: 3px;
  border-radius: 3px;
  background: rgba(0, 0, 0, 0.05);
  text-align: center;
  height: 60px;
  line-height: 60px;
  margin-bottom: 10px; }
  .load-more:hover {
    text-decoration: none;
    background: rgba(0, 0, 0, 0.1); }

.posts .spinner-container{
  position: relative;
  height: 100px;
}

.jumbotron{
  text-align: center;
}
.jumbotron h2{
  font-size: 60px;
  font-weight: 100;
}

@-webkit-keyframes fadeOut {
  0% {opacity: 0;}
  10% {opacity: 1;}
  90% {opacity: 1;}
  100% {opacity: 0;}
}

@keyframes fadeOut {
  0% {opacity: 0;}
  10% {opacity: 1;}
  90% {opacity: 1;}
  100% {opacity: 0;}
}

.errors{
  position: fixed;
  z-index: 10000;
  padding: 10px;
  top: 0px;
  left: 0px;
  right: 0px;
  bottom: 0px;
  pointer-events: none;
}
.alert {
          animation: fadeOut 2700ms ease-in 0s 1 forwards;
  -webkit-animation: fadeOut 2700ms ease-in 0s 1 forwards;
     -moz-animation: fadeOut 2700ms ease-in 0s 1 forwards;
  width: 250px;
  float: right;
  clear: both;
  margin-bottom: 5px;
  pointer-events: auto;
}
client/stylesheets/style.css

Commit 2-3

Modifizierte Datei-Struktur.

Eine Anmerkung zu CoffeeScript

In diesem Buch verwenden wir pures JavaScript. Solltest du CoffeeScript bevorzugen, so ist Meteor auch dazu fähig. Füge einfach das Package CoffeeScript hinzu:

meteor add coffeescript

Deployment

Sidebar 2.5

Manche Leute arbeiten gerne an einem Projekt, bis es perfekt ist. Andere wiederum können es kaum erwarten, das Projekt möglichst früh der Welt zu präsentieren.

Wenn du zum ersten Typ gehörst und lieber lokal arbeitest, kannst du dieses Kapitel auch überspringen. Du kannst dir aber auch die Zeit nehmen, um zu lernen, wie du mit deiner Meteor App online gehst.

Du wirst verschiedene Arten kennenlernen, um eine Meteor App zu deployen (veröffentlichen). Du kannst jede dieser Arten an jedem Punkt in deinem Entwicklungsprozess nutzen, egal ob du an Microscope oder einer anderen Meteor App arbeitest. Lass uns anfangen!

Erklärung zu Sidebars

Dies ist ein Sidebar Kapitel. Sidebars geben, unabhängig vom Rest des Buches, einen tieferen Einblick in allgemeine Meteor Themen.

Möchtest du lieber an Microscope weiterentwickeln, kannst du dieses Kapitel auch zunächst überspringen und es später lesen.

Deployen auf Meteor

Auf eine Meteor Subdomain zu deployen (z.B. http://myapp.meteor.com) ist die einfachste und erste Möglichkeit, die wir versuchen werden. Dies kann nützlich sein, um die App in einem frühen Stadium anderen zu zeigen oder um schnell einen Staging Server einzurichten (ein Staging Server ist ein Server, auf dem eine Entwicklungsversion unter Live-Bedingungen getestet werden kann).

Auf Meteor zu deployen ist ziemlich einfach. Öffne dein Terminal, wechsle in dein Meteor App Verzeichnis und tippe:

$ meteor deploy myapp.meteor.com

Natürlich musst du “myapp” mit einem Namen deiner Wahl ersetzen, möglichst einem der noch nicht vergeben ist.

Wenn du zum ersten mal eine App deployst, wirst du aufgefordert, einen Meteor Account zu erstellen. Wenn alles funktioniert, kannst du deine App nach einigen Sekunden unter http://myapp.meteor.com aufrufen.

In der offiziellen Dokumentation findest du weitere Informationen zu Themen wie direktem Zugriff zu der gehosteten Datenbankinstanz oder dem Konfigurieren einer eigenen Domain für deine App.

Deployen auf Modulus

Modulus ist eine grossartige Möglichkeit, um Node.js Apps zu deployen. Es ist eine der wenigen PaaS (platform-as-a-service) Anbieter, welche offiziell Meteor unterstützt und es gibt bereits einige Leute, die ihre Meteor App auf Modulus im Produktionsbetrieb bereitstellen.

Demeteorizer

Modulus bietet ein Open-Source Werkzeug namens demeteorizer an, welches deine Meteor App in eine Standard Node.js App konvertiert.

Starte mit dem Erstellen eines Accounts. Um die App auf Modulus zu deployen, musst du das Modulus Kommandozeilen-Tool installieren.

$ npm install -g modulus

Und dann authentifizieren mit:

$ modulus login

Wir erstellen nun ein Modulus Projekt (du kannst dies auch im Modulus Web Dashboard machen):

$ modulus project create

Der nächste Schritt ist das Erstellen einer MongoDB Datenbank für unsere App. Wir könnnen eine MongoDB Datenbank mit Modulus selbst erstellen, MongoHQ oder mit einem anderen Cloud MongoDB Anbieter.

Haben wir unsere MongDB Datenbank erstellt, können wir die MONGO_URL für unsere Datenbank vom Modulus Web UI abrufen (gehe zu Dashboard > Database > Select your database > Administration), nutze diese um deine App folgendermassen zu konfigurieren:

$ modulus env set MONGO_URL "mongodb://<user>:<pass>@mongo.onmodulus.net:27017/<database_name>"

Es ist nun an der Zeit, unsere App zu deployen. Und so simpel ist es:

$ modulus deploy

Wir haben nun erfolgreich unsere App auf Modulus deployed. In der Modulus Dokumentation findest du weitere Informationen über den Zugriff auf Log-Dateien, dem Einrichten eigener Domains und SSL.

Meteor Up

Obwohl täglich neue Cloud Lösungen auftauchen, haben alle ihre eigenen Probleme und Beschränkungen. Aktuell ist das Deployen auf den eigenen Server die beste Möglichkeit, um eine Meteor Applikation im Produktionsbetrieb einzusetzen. Nur ist dies nicht ganz so einfach, vor allem wenn du professionell deployen willst.

Meteor Up (kurz mup) ist ein weiterer Versuch, dieses Problem zu beheben. Dies funktioniert mit einem Kommandozeilen-Tool, welches die Installation und das Deployment für dich übernimmt. Sehen wir uns an, wie Microscope mit Meteor Up deployet werden kann.

Als Allererstes benötigen wir einen Server, auf welchen wir deployen können. Wir empfehlen entweder Digital Ocean, auf denen man ab 5$ pro Monat deployen kann oder Amazon Webservices (AWS), die Micro Instanzen gratis anbieteen (damit wirst du schnell an Skalierungsprobleme stossen, aber um mit Meteor Up herumzuspielen sollte es genügen).

Unabhängig vom gewählten Service solltest du nun drei Sachen haben: eine Server IP-Adresse, ein Login (normalerweise root oder ubuntu) und ein Kennwort. Bewahre diese sicher auf, wir werden sie bald benötigen.

Meteor Up initialisieren

Zum Starten müssen wir Meteor Up via npm folgendermassen installieren:

$ npm install -g mup

Nun erstellen wir ein spezielles, separated Verzeichnis, dass unsere Meteor Up Einstellungen für ein bestimmtes Deployment enthält. Aus zwei Gründen nutzen wir ein separates Verzeichnis: Erstens ist es normalerweise das beste, private Anmeldedaten nicht im Git Repository zu speichern, vor allem wenn dein Code öffentlich ist.

Zweitens sind wir mit mehreren separaten Verzeichnissen in der Lage, mehrere Meteor Up Kongurationen parallel zu verwalten. Dies ist nützlich um z.B. auf Produktions- und Staging Instanzen zu deployen.

Erstellen wir also das neue Verzeichnis und nutzen es um das neue Meteor Up Projekt zu initialisieren:

$ mkdir ~/microscope-deploy
$ cd ~/microscope-deploy
$ mup init

Austausch mit Dropbox

Ein guter Weg, um sicherzustellen, dass alle in deinem Team dieselben Deployment-Einstellungen verwenden, ist einfach ein Meteor Up Verzeichnis in deiner Dropbox (oder einem ähnlichen Service) zu erstellen.

Meteor Up Konfiguration

Beim Initialisieren eines neuen Projekts erstellt Meteor Up zwei Dateien für dich: mup.json und settings.json.

mup.json enthält alle deployment-spezifischen Einstellungen, während settings.json alle app-spezifischen Einstellungen enthält (OAuth Tokens, Analytics Tokens, etc.).

Der nächste Schritt ist deine mup.json Datei zu konfigurieren. Standardmässig wird die mup.json Datei mit mup init erstellt. Nun musst du nur noch die leeren Zeilen befüllen:

{
  //server authentication info
  "servers": [{
    "host": "hostname",
    "username": "root",
    "password": "password"
    //or pem file (ssh based authentication)
    //"pem": "~/.ssh/id_rsa"
  }],

  //install MongoDB in the server
  "setupMongo": true,

  //location of app (local directory)
  "app": "/path/to/the/app",

  //configure environmental
  "env": {
    "ROOT_URL": "http://supersite.com"
  }
}
mup.json

Gehen wir durch jede dieser Einstellungen.

Server Authentifikation

Du wirst sehen, dass Meteor Up kennwortbasierte und private-key (PEM) basierte Authentifizierung unterstützt. Somit kann es fast mit jedem Cloud Anbieter verwendet werden.

Wichtige Notiz: Nutzt du die kennwortbasierte Authentifizierung, musst du zuerst sshpass installieren (siehe Anleitung).

MongoDB Konfiguration

Der nächste Schritt ist eine MongoDB Datenbank für die App zu konfigurieren. Wir empfehen MongoHQ oder einen anderen Cloud MongoDB Anbieter zu nutzen, da diese professionellen Support und bessere Management Tools anbieten.

Entscheidest du dich für MongoDB, setze setupMongo zu false and füge die MONGO_URL bei den Umgebungsvariablen in mup.json’s env Block hinzu. Wenn du dich dafür entscheidest, MongoDB mit Meteor Up zu hosten, setze einfach setupMongo als true and Meteor Up wird sich um den Rest kümmern.

Meteor App Pfad

Da unsere Meteor Up Konfiguration in zwei unterschiedlichen Verzeichnissen existiert, müssen wir Meteor Up zu unserer App mit der app Einstellung verweisen. Gib deinen absoluten lokalen Pfad an, den du mit dem pwd Befehl im Termin herausfinden kannst, wenn du dich im App-Verzeichnis befindest.

Umgebungsvariablen

Hier kannst du alle Umgebungsvariablen für deine App (wie ROOT_URL, MAIL_URL, MONGO_URL, etc.) im env Block definieren.

Setup und Deploying

Bevor wir deployen können müssen wir den Server aufsetzen, damit er bereit ist, um Meteor Apps zu hosten. Meteor Up vereinfacht diesen komplexen Prozess in einen einzigen Befehl!

$ mup setup

Dies dauert einige Minuten, abhängig von der Server Performance und der Netzwerkverbindung. Wurde das Setup erfolgreich durchgeführt, können wir unsere App mit folgenden Befehl deployen.

$ mup deploy

Dies bündelt die Meteor App und deployt sie zum eben eingerichteten Server.

Anzeigen von Logs

Logs sind ziemlich wichtig und Meteor Up bietet einen einfach Weg an um diese abzurufen, in dem es den tail -f Befehl emuliert. Gib dazu folgendes ein:

$ mup logs -f

All dies kann Meteor Up für uns machen. Für mehr Informationen empfehlen wir Meteor Up’s GitHub Repository.

Diese drei Möglichkeiten, eine Meteor App zu deployen, sollten für die meisten Anwendungsfälle reichen. Natürlich wissen wir, dass einige unter euch es bevorzugen, die komplette Kontrolle über ihr Meteor Server von Beginn an zu haben. Aber dies ist ein Thema für ein ander Mal… oder für ein anderes Buch!

Templates

3

„“ Um den Einstieg in die Entwicklung mit Meteor zu erleichtern, verfolgen wir einen Outside-In-Ansatz. In anderen Worten: wir bauen zuerst eine „dumme“ äußere Schale aus HTML/JavaScript und verknüpfen sie dann später mit der inneren Logik unserer App.

Das bedeutet, dass wir uns in diesem Kapitel nur mit dem beschäftigen werden, was in dem Verzeichnis /client vor sich geht.

Wenn das noch nicht geschehen ist, dann erzeuge im Verzeichnis /client eine neue Datei mit dem Namen main.html und fülle sie mit dem folgenden Code:

<head>
  <title>Microscope</title>
</head>
<body>
  <div class="container">
    <header class="navbar navbar-default" role="navigation">
      <div class="navbar-header">
        <a class="navbar-brand" href="/">Microscope</a>
      </div>
    </header>
    <div id="main">
      {{> postsList}}
    </div>
  </div>
</body>
client/main.html

Dies wird unser Haupt-Template. Wie du siehst, ist es reines HTML bis auf ein einzelnes Tag, das das Template einführt: {{> postsList}}. Es dient als Einfügepunkt für das zukünftige Template postsList. Für den Moment werden wir einige weitere Templates erzeugen.

Meteor Templates

In ihrem Kern besteht eine Soziale News-Site aus Postings, die in Listen organisiert sind und genau so werden wir unsere Templates organisieren.

Lass’ uns ein Verzeichnis /templates unterhalb von /client anlegen. Dort werden wir alle unsere Templates einfügen und um die Dinge schön geordnet zu halten, legen wir noch ein Verzeichnis /posts unterhalb von /templates an, um die Templates für Postings zu speichern.

Dateien finden

Meteor ist gut im Finden von Dateien. Unabhängig davon, wo im Verzeichnis /client sich dein Code befindet, wird Meteor ihn finden und korrekt kompilieren. Das heißt, dass du keine Include-Pfade für JavaScript- oder CSS-Dateien von Hand festlegen brauchst.

Es heißt auch, dass du genauso gut alle Dateien in das selbe Verzeichnis legen könntest oder sogar den ganzen Code in eine einzige Datei. Weil Meteor jedoch ohnehin alles in eine einzige minifizierte Datei kompiliert, halten wir die Dinge lieber gut organisiert und verwenden eine saubere Dateistruktur.

Wir sind nun so weit, unser zweites Template anzulegen. In client/templates/posts, erzeuge posts_list.html:

<template name="postsList">
  <div class="posts">
    {{#each posts}}
      {{> postItem}}
    {{/each}}
  </div>
</template>
client/templates/posts/posts_list.html

und post_item.html:

<template name="postItem">
  <div class="post">
    <div class="post-content">
      <h3><a href="{{url}}">{{title}}</a><span>{{domain}}</span></h3>
    </div>
  </div>
</template>
client/templates/posts/post_item.html

Beachte das Attribut name="postsList" des Elementes template. Das ist der Name, der von Meteor verwendet wird, um nachzuverfolgen, welches Template wo eingesetzt ist (beachte, dass der eigentliche Dateiname keine Bedeutung hat).

Nun ist es an der Zeit, Meteors Template-System Spacebars vorzustellen. Spacebars besteht aus HTML mit drei zusätzlichen Dingen: inclusions (manchmal auch „partials“ genannt), expressions (Ausdrücke) und block helpers.

Inclusions verwenden die Syntax {{> templateName}} und teilen Meteor lediglich mit, dass die inclusion durch das Template gleichen Namens ersetzt werden soll (in unserem Fall postItem).

Expressions, wie {{title}} rufen entweder eine property des aktuellen Objekts oder den Rückgabewert eines Template-Helpers, wie er in dem aktuellen Template-Manager definiert ist (dazu später mehr).

Schließlich sind block helpers besondere Tags, die den Ablauf innerhalb des Templates steuern, wie {{#each}}…{{/each}} oder {{#if}}…{{/if}}.

Weitere Informationen

Um mehr über Spacebars zu lernen, lies die Spacebars-Documentation

Mit diesem Wissen konnen wir anfangen, zu verstehen, was hier passiert.

Zuerst iterieren wir im Template postsLists mittels des Block-Helpers {{#each}}…{{/each}} über ein Objekt posts. Dann fügen wir für jede Iteration das Template postItem ein.

Woher kommt dieses Objekt posts? Gute Frage. Eigentlich ist es ein Template-Helper und du kannst es als einen Platzhalter für einen dynamischen Wert ansehen.

Das Template postItem selbst ist ziemlich einfach: Es benutzt lediglich drei Expressions: {{url}} und {{title}}liefern Properties des Dokuments und {{domain}} ruft einen Template-Helper auf.

Template-Helper

Bis jetzt haben wir uns mit Spacebars beschäftigt, was kaum mehr als HTML mit ein paar zusätzlichen Tags ist. Anders als andere Sprachen wie PHP (oder sogar gewöhnliche HTML-Seiten, die JavaScript einbinden können), separiert Meteor Templates und deren Logik, so dass Templates selbst nicht viel machen.

Um zum Leben erweckt zu werden, braucht ein Template helper. Man kann sich diese Helper wie Köche vorstellen, die rohe Zutaten (deine Daten) nehmen und verarbeiten, bevor sie das fertige Gericht (das Template) and den Kellner übergeben, der es dir präsentiert.

In anderen Worten: während die Aufgabe des Templates darauf beschränkt ist, Variablen anzuzeigen oder darüber zu iterieren, sind es die Helper, die den Hauptteil der Arbeit erledigen indem sie jeder Variable einen Wert zuweisen.

Controller?

Man könnte meinen, dass die Datei mit den Template-Helpern eine Art Controller darstellt. Doch dies ist missverständlich (zumindest im Sinne von MVC), da Controller sonst eine etwas andere Rolle spielen.

Wir haben uns deshalb entschieden, diesen Begriff zu vermeiden und einfach auf “Template-Helper” oder “Template-Logik” zu verweisen, wenn es um den, mit Templates verknüpften, JavaScript Code geht.

Um die Dinge zu vereinfachen, übernehmen wir die Konvention und benennen die Datei mit den Helpern nach dem zugehörigen Template, aber mit einer .js Endung. Also erstellen wir jetzt die Datei posts_list.js im Verzeichnis client/templates/posts und beginnen darin unseren ersten Helper zu erstellen:

var postsData = [
  {
    title: 'Introducing Telescope',
    url: 'http://sachagreif.com/introducing-telescope'
  },
  {
    title: 'Meteor',
    url: 'http://meteor.com'
  },
  {
    title: 'The Meteor Book',
    url: 'http://themeteorbook.com'
  }
];
Template.postsList.helpers({
  posts: postsData
});
client/templates/posts/posts_list.js

Wenn man alles richtig gemacht hat, sollte das im Browser ungefähr so aussehen:

Unser erstes Template mit statischen Daten
Unser erstes Template mit statischen Daten

Wir machen hier zwei Dinge: zuerst schreiben wir Beispieldaten in das Array postsData. Diese Daten würden normalerweise aus einer Datenbank kommen, aber da wir noch nicht gelernt haben wie das funktioniert (kommt im nächsten Kapitel!), bedienen wir uns dieses “Tricks” und benutzen statische Daten.

Dann benutzen wir Meteors Funktion Template.postsList.helpers() um einen Template-Helper namens posts zu erstellen, der das Array postsData zurückgibt, dasss wir gerade definiert haben.

Wie du dich vielleicht erinnern kannst, benutzen wir den Helper posts bereits in unserem postsList Template:

<template name="postsList">
  <div class="posts page">
    {{#each posts}}
      {{> postItem}}
    {{/each}}
  </div>
</template>
client/templates/posts/posts_list.html

Da wir den Helper postsList definiert haben, steht er nun im Template zur Verfügung, wo wir nun über unser ArraypostsData iterieren und jedes Objekt, das darin enthalten ist, an das Template postItem übergeben lassen können.

Commit 3-1

Added basic posts list template and static data

Der domain Helper

Ähnlich wie zuvor, erstellen wir nun die Datei post_item.js, die die Logik des Templates postItem enthalten soll:

Template.postItem.helpers({
  domain: function(){
    var a = document.createElement('a');
    a.href = this.url;
    return a.hostname;
  }

});
client/templates/posts/post_item.js

Dieses mal ist der Wert des Helpers domain kein Array, sondern eine anonyme Funktion. Im Vergleich zu unserem vorigen Beispiel mit den statischen Beispieldaten, ist dieses Muster viel gebräuchlicher (und nützlicher).

Domains für jeden Link darstellen
Domains für jeden Link darstellen

Durch ein bisschen JavaScript Magie, nimmt der Helper domain eine URL und gibt deren Domain zurück. Aber woher nimmt er diese URL überhaupt?

Um diese Frage zu beantworten, müssen wir uns noch einmal unser Template posts_list.html ansehen. Der Block-Helper {{#each}} iteriert nicht nur über unser Array, sondern er setzt auch den Wert von this inerhalb des Blocks auf das aktuell zurückgegebene Objekt.

Das bedeutet, dass zwischen beiden {{#each}} Tags, jeder Post nacheinander this zugewiesen wird und das setzt sich auch in der inkludierten Template-Logik post_item.js fort.

Jetzt verstehen wir warum this.url, die URL des aktuellen Posts zurückgibt. Desweiteren weiß Meteor, dass wenn wir {{title}} oder {{url}} in unserem Template post_item.html benutzen, wir eigentlich this.title und this.url meinen und gibt so die korrekten Werte zurück.

Commit 3-2

Setup a `domain` helper on the `postItem`

JavaScript Magic

Obwohl nicht Meteor-spezifisch, findest du hier eine kurze Erklärung der oben stehenden “JavaScript Magie”. Zuerst erstellen wir einen leeres HTML-Link Element (a) und speichern es in einer Variable.

Dann setzen wir das href Attribut gleich der URL des aktuellen Posts. (wie wir gerade gesehen haben, repräsentiert this in einem Helper das Objekt, mit dem gerade gearbeitet wird).

Zum Schluss machen wir uns die spezielle Eigenschaft hostname des a Elements zu nutzen und erhalten so die Domain ohne den Rest der URL.

Wenn man bis hier korrekt mitgearbeitet hat, sollte man eine Liste von Posts im Browser angezeigt bekommen. Diese Liste ist statisch und nutzt noch keine von Meteors echtzeit Features. Wie du das ändern kannst, zeigen wir dir im nächsten Kapitel!

Hot Code Reload

Vielleicht ist dir schon aufgefallen, dass du deine Seite im Browser nicht manuell neu laden musst, nachdem du eine Änderung an der Datei vorgenommen hast.

Das liegt daran, dass Meteor ständig alle Dateien im Projekt-Verzeichnis überwacht und automatisch den Browser neu läd, sobald eine Datei verändert wird.

Meteors hot code reload ist ziemlich intelligent, so dass es sogar den Zustand deiner App zwischen zwei Aktualisierungen der Seite aufrecht erhält!

Using Git & GitHub

Sidebar 3.5

////

////

Being Committed

////

////

////

A Git commit as shown on GitHub.
A Git commit as shown on GitHub.

////

////

Modifying code.
Modifying code.

////

////

Deleting code.
Deleting code.

////

Browsing A Commit’s Code

////

////

The Browse code button.
The Browse code button.

////

The repository at commit 3-2.
The repository at commit 3-2.

////

The repository at commit 14-2.
The repository at commit 14-2.

Accessing A Commit Locally

////

////

$ git clone git@github.com:DiscoverMeteor/Microscope.git github_microscope

////

////

$ cd github_microscope

////

////

$ git checkout chapter3-1
Note: checking out 'chapter3-1'.

You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by performing another checkout.

If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -b with the checkout command again. Example:

  git checkout -b new_branch_name

HEAD is now at a004b56... Added basic posts list template and static data.

////

////

////

////

Finding a commit hash.
Finding a commit hash.

////

$ git checkout c7af59e425cd4e17c20cf99e51c8cd78f82c9932
Previous HEAD position was a004b56... Added basic posts list template and static data.
HEAD is now at c7af59e... Augmented the postsList route to take a limit

////

$ git checkout master

Historical Perspective

////

////

GitHub's History button.
GitHub’s History button.

////

Displaying a file's history.
Displaying a file’s history.

The Blame Game

////

GitHub's Blame button.
GitHub’s Blame button.

////

GitHub's Blame view.
GitHub’s Blame view.

////

Collections

4

Im ersten Kapitel haben wir über Meteor’s Kernfeature gesprochen, die automatische Synchronisierung von Daten zwischen Client und Server.

In diesem Kapitel werfen wir einen genaueren Blick darauf, wie das funktioniert und betrachten die Arbeitsweise der wichtigsten Technologie, die dies ermöglicht, der Meteor Collection.

Eine Collection ist eine spezielle Datenstruktur, die sich um das Speichern von Daten in der permanenten, serverseitigen MongoDB Datenbank, sowie deren Synchronisation mit dem Browser jedes verbundenen Users in Echtzeit kümmert.

Wir wollen, dass unsere Posts dauerhaft gepsichert sind und zwischen Usern geteilt werden, also beginnen wir damit eine Collection namens Posts zu erstellen um sie dort zu speichern.

Collections sind ein zentraler Bestandteil jeder App, um also sicherzustellen, dass sie immer zu Beginn definiert werden, erstellen wir sie in der lib directory. Sofern noch nicht geschehen, erstelle im Ordner lib einen Unterordner collections/ und darin eine Datei posts.js. Dann füge dieser Datei folgende Zeile hinzu:

Posts = new Meteor.Collection('posts');
lib/collections/posts.js

Commit 4-1

Added a posts collection

////

To Var Or Not To Var?

In Meteor wird das Schlüsselwort var benutzt um den Geltungsbereich eines Objekts auf die aktuelle Datei zu beschränken. In unserem Beispiel, wollen wir die Collection Posts aber in unserer ganzen App verfügbar machen, weshalb wir var nicht benutzen.

Daten speichern

Webanwendungen verfügen über drei grundsätzliche Möglichkeiten ihre Daten zu speichern, jede hat dabei eine andere Aufgabe:

  • Browser (Memory): Dinge wie JavaScript Variablen werden im temporären Speicher (Memory) des Browsers gespeichert, diese Daten sind nicht permanent: sie sind lokal im aktuellen Browser-Tab und gehen verloren, sobald man es schließt
  • Browser (Storage): Browser können Daten auch dauerhaft speichern indem sie z.B.Cookies oder den permanenten Speicher (Local Storage) benutzen. Auch wenn diese Daten von Session zu Session bestehen bleiben, sind sie lokal beim jeweiligen Benutzer (aber über alle Tabs verfügbar) und können deshalb nur schwer mit anderen Nutzern geteilt werden.
  • Serverseitige Datenbank: der beste Ort um Daten dauerhaft zu speichern, die man mehr als einem Benutzer zugänglich machen möchte, ist eine gute alte Datenbank. (MongoDB ist die Standardlösung bei Meteor)

Meteor benutzt alle drei Varianten und synchronisiert zeitweise von einem zum anderen Speicherort (wie wir bald sehen werden). Davon abgesehen, bleibt die Datenbank die “kanonische” Quelle, die den Stamm der Daten enthält.

Client & Server

Code, der sich in anderen Ordnern als clients/ oder server/ befindet, wird in beiden Kontexten ausgeführt. Damit ist beispielsweise die Collection Posts für Client und Server verfügbar. Nichtsdestotrotz kann das was die Collection tut, in beiden Umgebungen sehr unterschiedlich sein.

Auf dem Server ist es die Aufgabe der Collection mit der MongoDB Datenbank zu kommunizieren und Änderungen zu lesen oder zu schreiben. In dieser Hinsicht kann sie mit einer Standard Datenbank-Bibliothek verglichen werden.

Beim Client hingegen ist die Collection eine Kopie einer Teilmenge der realen originalen Stammdaten. Die clientseitige Collection wird permanent und (meist) transparent in echtzeit mit der Teilmenge synchronisiert.

Console vs Console vs Console

In diesem Kapitel beginnen wir die Browser Konsole zu benutzen, die man nicht mit dem terminal, der Meteor Shell oder der Mongo Shell verwechseln darf. Hier ist eine kurze Einführung zu jedem davon:

Terminal

Das Terminal
Das Terminal
  • wird im Betriebssystem aufgerufen
  • serverseitiges console.log() wird hier ausgegeben
  • Prompt: $
  • auch bekannt als: Shell, Bash

Browser Console

Die Browser Konsole
Die Browser Konsole
  • wird innerhalb des Browsers aufgerufen, führt JavaScript Code aus
  • clientseitiges console.log() wird hier ausgegeben
  • Prompt:
  • auch bekannt als: JavaScript Konsole, Dev-Tools Konsole

Meteor Shell

Die Meteor Shell
Die Meteor Shell
  • wird im Terminal mit meteor shell aufgerufen
  • gewährt direkten Zugang zum serverseitigen Code der Anwendung
  • Prompt: >

Mongo Shell

Die Mongo Shell
Die Mongo Shell
  • wird im Terminal mit meteor mongo aufgerufen
  • gewährt direkten Zugang zur Datenbank der Anwendung
  • Prompt: >
  • auch bekannt als: Mongo Konsole

Beachte, dass man in keinem Fall als Teil eines Befehls das Prompt Zeichen ($, , oder >) eingibt. Auch kann man davon ausgehen, dass jede Zeile, die nicht mit dem Prompt beginnt, eine Ausgabe des vorangegangen Befehls ist.

Server-Side Collections

Kommen wir nun zurück zum Server, wo sich die Collection wie eine API in die Mongo Datenbank verhält. Im serverseitigen code, ermöglicht uns das, Mongo Befehle wie Posts.insert() oder Posts.update() zu schreiben, die dann Änderungen an der posts Collection in Mongo bewirken.

Um einen Blick in die Mongo Datenbank zu werfen, öffne ein zweites Terminal Fenster (während meteor noch im ersten Fenster läuft) und begib dich in das Verzeichnis deiner App. Führe dann den Befehl meteor mongo aus um eine Mongo shell zu starten in der man standard Mongo Befehle ausführen (und wie gewohnt mit strg+c beenden) kann. Lass uns z.B. einen neuen Post einfügen:

> db.posts.insert({title: "A new post"});

> db.posts.find();
{ "_id": ObjectId(".."), "title" : "A new post"};
Die Mongo Shell

Mongo auf Meteor.com

Beachte, dass bei Apps, die auf *.meteor.com gehostet werden, ein Zugriff auf die Mongo shell der installierten Anwendung mit dem Befehl meteor mongo myApp möglich ist.

Wo wir schon dabei sind, mit folgendem Befehl kannst du die Logs deiner Anwendung einsehen: meteor logs myApp.

Mongos Syntax wirkt vertraut, da er eine JavaScript Schnittstelle nutzt. Wir werden zwar keine weiteren Daten über die Mongo Shell verändern, aber wir werden ab und zu mal einen Blick hinein werfen, um zu sehen was sich darin befindet.

Client-Side Collections

Clientseitig werden die Collections interessanter. Deklariert man Posts = new Mongo.Collection('posts'); auf der Clientseite, erzeugt man eine lokale, im Browsercache liegende, Kopie der realen Mongo Collection. Wenn wir von einer clientseitigen Collection als “cache” sprechen, meinen wir damit, dass sie eine Teilmenge der Daten enhält und einen sehr schnellen Zugriff bietet.

Es ist wichtig diese Punkte zu verstehen, da sie die Grundlage dafür bilden, wie Meteor funktioniert. Im Allgemeinen besteht eine clientseitige Collection aus einer Teilmenge aller Dokumente aus einer mongo Collection (schließlich wollen wir normalerweise nicht unsere gesamte Datenbank an den Client schicken.).

Zum Zweiten befinden sich diese Dokumente dann im Browser Speicher und es kann praktisch ohne Verzögerung darauf zugegriffen werden. Es gibt also keine langsames hin- und her Senden mit dem Server oder der Datenbank um Daten zu erhalten, wenn man Posts.find() auf der Clientseite aufruft, da die Daten bereits vorher geladen wurden.

Introducing MiniMongo

Meteors clientseitige Mongo Implementierung heißt MiniMongo. Es ist noch keine perfekte Implementierung und es kann vorkommen, dass bestimmte Mongo Features nicht in MiniMongo funktionieren. Nichtsdestotrotz funktionieren alle Funktionen, die in diesem Buch erklärt werden sowohl in Mongo als auch in MiniMongo

Client-Server Communication

Der wichtigste Teil von alledem ist wie die clientseitige Collection ihre Daten mit der serverseitigen Collection gleichen Namens (posts in unserem Fall) synchronisiert. Anstatt das im Detail zu erklären, schauen wir uns einfach an was passiert.

Öffne dazu zwei Browser Fenster und jeweils die Browser Konsole. Öffne die Mongo shell in der Komandozeile.

Jetzt sollten wir in der Lage sein, das einzelne Dokument zu finden, das wir vorhin in allen drei Kontexten erstellt haben (beachte, dass das User Interface unserer App immernoch die drei statischen Datensätze anzeigt. Wir ignorieren das erst einmal).

> db.posts.find();
{title: "A new post", _id: ObjectId("..")};
Die Mongo Shell
 Posts.findOne();
{title: "A new post", _id: LocalCollection._ObjectID};
Erste Browser Konsole

Erstellen wir nun einen neuen Post, indem wir in einem der Browserfenster den Insert Befehl ausführen:

 Posts.find().count();
1
 Posts.insert({title: "A second post"});
'xxx'
 Posts.find().count();
2
Erste Browser Konsole

Wie nicht anders zu erwarten, ist der Post in der lokalen Collection gelandet. Nun sehen wir uns die Mongo Datenbank an.

❯ db.posts.find();
{title: "A new post", _id: ObjectId("..")};
{title: "A second post", _id: 'yyy'};
Die Mongo Shell

Wie man sehen kann, ist der Post bis in die Mongo Datenbank gelangt, ohne dass wir auch nur eine Zeile Code geschrieben haben um unseren Clienten mit dem Server zu verbinden. (streng genommen, haben wir eine Zeile Code geschrieben: new Mongo.Collection('posts')). Doch das ist noch nicht alles!

Gib nun im zweiten Browserfenster folgendes in die Konsole ein:

 Posts.find().count();
2
Zweite Browser Konsole

Der Post ist auch hier! Obwohl wir das zweite Fenster nie neu geladen oder überhaupt irgendwie damit in Berührung gekommen sind und neuen Code, der Updates betrifft haben wir auch nicht geschrieben. Es ist alles magisch passiert - und unverzüglich, aber das wird später noch klarer werden.

Was passiert ist, ist, dass unsere serverseitige Collection von einer clientseitigen Collection über einen neuen Post informiert wurde, diesen in die Mongo Datenbank geschrieben und an alle anderen verbundenen posts Collections geschickt hat.

Doch Posts in der Browser Konsole abzufragen ist nicht wirklich hilfreich. Bald schon werden wir lernen, wie man diese Daten mit unseren Templates verbindet und dabei unseren einfachen HTML Prototypen in eine funktionierende echtzeit Webanwendung verwandeln.

Die Datenbank befüllen

Sich den Inhalt unserer Collections in der Browser Konsole anzusehen ist die eine Sache, aber was wir wirklich machen wollen ist, die Daten und die Änderungen daran auf dem Bildschirm anzuzeigen. Auf diese Weise werden wir unsere App von einer einfachen Web-Seite, die statische Daten anzeigt, zu einer echtzeit Web-Anwendung mit dynamischen, wechselnden Daten weiterentwickeln.

Zunächst einmal befüllen wir die Datenbank mit einigen Daten. Wir machen das über eine fixture Datei, die eine Sammlung von strukturierten Daten in die Posts Collection läd, wenn der Server das erste Mal startet.

Erst einmal sollte sichergestellt sein, dass die Datenbank leer ist. Wir benutzen meteor reset um die Datenbank zu löschen und das Projekt zurückzusetzen. Natürlich sollte man mit diesem Befehl sehr vorsichtig sein, wenn man beginnt an realen Projekten zu arbeiten.

Stoppe jetzt den Meteor Server mit strg+c und führe in der Komandozeile folgenden Befehl aus:

$ meteor reset

Der Reset-Befehl leert die Mongo Datenbank komplett. Er stellt einen nützlichen Befehl bei der Entwicklung dar, denn das Risiko ist groß, dass die Datenbank sonst inkonsistent wird.

Starten wir unsere Meteor App nun wieder:

$ meteor

Jetzt wo die Datenbank leer ist, können wird folgenden Code hinzufügen, der drei Posts einfügt, sobald der Server gestartet wird und die Posts Collection leer ist:

if (Posts.find().count() === 0) {
  Posts.insert({
    title: 'Introducing Telescope',
    url: 'http://sachagreif.com/introducing-telescope/'
  });

  Posts.insert({
    title: 'Meteor',
    url: 'http://meteor.com'
  });

  Posts.insert({
    title: 'The Meteor Book',
    url: 'http://themeteorbook.com'
  });
}
server/fixtures.js

Commit 4-2

Added data to the posts collection.

Wir haben diese Datei im Server Verzeichnis abgelegt, damit sie niemals in einem Client-Browser geladen wird. Der Code wird sofort ausgeführt, wenn der Server startet und ruft insert auf um die drei einfachen Datensätze in unsere posts Collection zu speichern.

Starte jetzt den Server wieder mit dem Befehl meteor und die drei Posts werden in die Datenbank geladen.

Dynamische Daten

Wenn wir eine Browser Konsole öffnen, sehen wir wie alle drei Posts in MiniMongo geladen werden:

 Posts.find().fetch();
Browser Konsole

Um diese drei Posts in HTML gerendert zu bekommen, benutzen wir unseren Freund den Template Helper.

In Kapitel 3 haben wir bereits gesehen wie Meteor uns ermöglicht Daten mit unseren Spacebars Templates zu verknüpfen um HTML Ansichten einfacher Datenstrukturen zu erzeugen. Wir können nun Daten aus unserer Collection analog dazu einbinden. Dazu ersetzen wir einfach unser statisches JavaScript Objekt postsDatadurch eine dynamische Collection.

Wo wir gerade davon sprechen, du kannst nun den Code von postsDatalöschen. So sollte die posts_list.jsjetzt aussehen:

Template.postsList.helpers({
  posts: function() {
    return Posts.find();
  }
});
client/templates/posts/posts_list.js

Commit 4-3

Wired collection into `postsList` template.

Find & Fetch

In Meteor gibt find()einen Cursor zurück, was eine reaktive Datenquelle darstellt. Wenn wir dessen Inhalte darstellen wollen, können wir fetch auf den Cursor anwenden um ihn in ein Array umzuwandeln.

Innerhalb einer App ist Meteor intelligent genug zu wissen wie man über einen Cursor iteriert ohne dass man jedes mal explizit eine Umwandlung in ein Array vornehmen muss. Das ist der Grund warum man fetch nur selten im Meteor Code findet (und warum wir es im Beispiel oben nicht benutzt haben).

Anstatt eine Liste von Posts als ein statisches Array aus einer variable zu laden, geben wir nun einen Cursor an unseren Helper posts zurück (obwohl alles sehr ähnlich aussehen wird, da wir immernoch die gleichen Daten nutzen):

////

Mit 'live' Daten
Mit ‘live’ Daten

Unser Helper {{#each}} hat über all unsere Posts iteriert und zeigt sie auf dem Bildschirm an. Die serverseitige Collection hat die Posts von Mongo bezogen, an unsere clientseitige Collection übergeben und unsere Spacebars Helper haben sie ins Template übermittelt.

Jetzt gehen wir noch einen Schritt weiter; wir fügen einen weiteren Post über die Konsole hinzu:

 Posts.insert({
  title: 'Meteor Docs', 
  author: 'Tom Coleman', 
  url: 'http://docs.meteor.com'
});
Browser Konsole

Zurück im Browser sollte das nun etwa so aussehen:

Posts über die Konsole hinzufügen
Posts über die Konsole hinzufügen

Du hast gerade zum ersten mal die Reaktivität in Aktion gesehen. Da wir Spacebars befohlen haben über den Cursor Posts.find() zu iterieren, hat es gewusst wie der Cursor auf Veränderungen überwacht werden muss und wie das HTML möglichst einfach anzupassen ist um die korrekten Daten darzustellen.

DOM Veränderungen untersuchen

In diesem Fall war die einfachste mögliche Veränderung ein weiteres <div class="post">...</div> hinzuzufügen. Wenn du sichergehen möchtest, dass dies wirklich die einzige Veränderung ist, öffne den DOM Inspector und wähle ein <div> eines bestehenden Posts.

In der JavaScript Konsole fügst du nun einen weiteren Post hinzu. Wenn du zurück zum Inspector wechselst, siehst du ein weiteres <div> für den neuen Post, doch das ausgewählte ´

` ist unverändert. Dies ist eine gute Methode um festzustellen, ob Elemente neu gerendert wurden oder unverändert geblieben sind.

Collections verbinden: Publications und Subscriptions

Bis jetzt hatten wir das autopublish Paket aktiviert, das nicht für den Einsatz bei Produktiv-Apps gedacht ist. Wie der Name schon sagt, bewirkt dieses Paket, dass jede Collection komplett mit jedem Client verbunden wird. Da dies nicht wirklich das ist, was wir wollen, entfernen wir es.

Öffne ein neues Terminal Fenster und führe folgenden Befehl aus:

$ meteor remove autopublish

Dies hat eine sofortige Wirkung auf unser Projekt. Wenn du nun in den Browser schaust, wirst du sehen, dass alle Posts verschwunden sind! Das liegt daran, dass wir uns auf autopublish verlassen haben, das sichergestellt hat, dass unsere Client Collection der Posts eine 1:1 Kopie aller Posts in der Datenbank war. Schlussendlich müssen wir sicherstellen, dass wir nur die Posts übertragen, die der Benutzer auch wirklich sehen muss (Dinge wie Pagination berücksichtigt). Doch für den Moment richten wir Posts so ein, dass es komplett übergeben wird.

Um dies zu erreichen, erstellen wir eine Funktion publish() die einen Cursor zurückgibt, der alle Posts referenziert:

Meteor.publish('posts', function() {
  return Posts.find();
});
server/publications.js

Beim Client müssen wir ein subscribe für die Publication einrichten. Dazu fügen wir einfach folgende Zeile in die main.js Datei ein:

Meteor.subscribe('posts');
client/main.js

Commit 4-4

Removed `autopublish` and set up a basic publication.

Wenn wir nun in den Browser zurückkehren, sind unsere Posts wieder da. Puh!

Zusammenfassung

Was haben wir also erreicht? Obwohl wir noch keine Benutzeroberfläche haben, verfügen wir über eine funktionierende Web-Anwendung. Wir könnten die App im Internet veröffentlichen und (über die Browser Konsole) anfangen Geschichten zu posten, welche dann in den Browsern der Benutzer in der ganzen Welt erscheinen würden.

Publications and Subscriptions

Sidebar 4.5

Veröffentlichungen und Abonnements (publications/subscriptions) sind eines der grundlegenden und wichtigen Konzepte in Meteor. Wenn du dich erst kurze Zeit mit Meteor beschäftigst, dann kann es sein, dass das Konzept nicht leicht zu verstehen ist.

Das hat zu einer Reihe von Missverständnissen geführt, etwa dass Meteor unsicher sei oder dass Meteor-Apps nicht in der Lage seien, mit großen Datenmengen umzugehen.

Ein Großteil der Verwirrung geht auf das Konto der „Magie“, die Meteor für uns ausführt. Wenn diese auch letztendlich sehr nützlich ist, kann sie verschleiern, was wirklich hinter den Kulissen vor sich geht. (Was in der Natur von Magie liegt.) Lass uns also die magischen Schichten abtragen um zu verstehen, was da vor sich geht.

In grauer Vorzeit

Lasst uns zunächst auf die gute alte Zeit von 2011 zurückblicken, als es Meteor noch nicht gab. Nehmen wir an, du entwickelst eine einfache Rails-App. Wenn ein User auf deine Seite kommt, dann sendet der Client (z. B. der Browser) eine Anfrage (request) an deine App, die sich auf dem Server befindet.

Das erste, was die App unternimmt, ist, herauszufinden, was der User sehen möchte. Das könnte Seite 12 von einer Menge von Suchergebnissen sein, die Profildaten von Mary, Bobs 20 neueste Tweets und so weiter. Stell dir einen Angestellten in einem Buchladen vor, der durch die Regalreihen geht, um das Buch, nach dem du gefragt hast, herauszusuchen.

Sobald die richtigen Daten ermittelt sind, ist die zweite Aufgabe der App, diese in hübsches, lesbares HTML umzuformen (oder JSON, wenn ein API im Spiel ist).

Im Bild des Buchladens würde das gewünschte Buch eingepackt und in eine Tüte gesteckt. Das entspricht dem „View“-Teil des bekannten Entwurfsmusters Model-View-Controller.

Schließlich nimmt die App den HTML-Code und schickt ihn an den Browser. Die App hat damit ihre Arbeit getan und nun, wo sie ihre virtuellen Hände wieder frei hat, kann sie sich mit einem Bier zurücklehnen während sie auf die nächste Anfrage wartet.

Wie Meteor es macht

Schauen wir uns nun an, was Meteor im Vergleich dazu so besonders macht. Wie wir gesehen haben, besitzt Meteor im Gegensatz zur Rails-App, die nur auf dem Server existiert, auch einen Anteil auf dem Client (dem Browser). Das ist die grundlegende Innovation, die Meteor mitbringt.

Eine Untermenge der Datenbank zum Client senden.
Eine Untermenge der Datenbank zum Client senden.

Das ist wie der Angestellte, der dir nicht nur das richtige Buch heraussucht, sondern dich auch nach Hause begleitet um es dir abends vorzulesen (zugegeben, das klingt etwas gruselig).

Diese Architektur lässt Meteor viele erstaunliche Dinge tun, hauptsächlich das, was Meteor database everywhere nennt. Vereinfacht gesagt nimmt Meteor einen Teil der Datenbank und kopiert ihn auf den Client.

Daraus folgt zweierlei: erstens überträgt eine Meteor-App anstatt HTML-Code die eigentlichen, unformatierten Daten zum Client und überlässt es diesem, wie damit umzugehen ist (data on the wire). Zweitens hast du unmittelbaren Zugriff auf diese Daten, ohne erst auf eine Server-Antwort zu warten (latency compensation).

Veröffentlichen

Die Datenbank einer App kann zehntausende von Dokumenten enthalten, von denen einige auch privater oder vertraulicher Natur sein mögen. Deshalb ist es offensichtlich, dass wir nicht einfach den gesamten Datenbankinhalt auf den Client spiegeln – aus Gründen der Sicherheit und der Stabilität.

Wir brauchen daher eine Methode, wie wir Meteor mitteilen, welche Untermenge des Datenbestandes zum Client zu schicken ist. Das erreichen wir duch eine Veröffentlichung.

Gehen wir noch einmal zu Microscope zurück. Hier sind alle Beiträge unserer App in der Datenbank gespeichert:

Alle Beiträge in der Datenbank.
Alle Beiträge in der Datenbank.

Auch wenn diese Möglichkeit, wie wir zugeben, in Microscope nicht existiert, stellen wir uns einmal vor, dass einige unserer Beiträge wegen darin verwendeter Schimpfworte markiert worden sind. Auch wenn wir sie in der Datenbank behalten wollen, sollten die für die User nicht verfügbar sein, also nicht zum Client gesendet werden.

Unsere erste Aufgabe wird sein, Meteor klarzumachen, was wir zum Client senden wollen. Wir sagen Meteor, nur nicht markierte Beiträge zu veröffentlichen.

Markierte Beiträge ausschließen.
Markierte Beiträge ausschließen.

Hier ist der entsprechende Code, der auf dem Server sitzen würde:

// on the server
Meteor.publish('posts', function() {
  return Posts.find({flagged: false}); 
});

Damit ist sichergestellt, dass ein Client auf keinen Fall auf einen markierten Beitrag zugreifen kann. Genau so wird eine Meteor-App sicher gemacht: Stelle sicher, dass nur die Daten veröffentlicht werden, der der Client auch sehen soll.

DDP

Im Grunde ist das System, das hinter dem Veröffentlichen und Abonnieren (publication/subscription) steht, wie ein Trichter, durch den von einer serverseitigen Collection zu einer clientseitigen Collection transportiert werden.

Das Protokoll, mit dem dieser Trichter arbeitet heißt DDP. Das steht für Distributed Data Protocol, auf deutsch etwa Protokoll für verteilte Daten. Um mehr darüber zu erfahren, schaue dir diesen Vortrag von Matt DeBergalis auf der Real-time-Konferenz an oder diesen Screencast von Chris Mather, die das Konzept von DDP näher beleuchten.

Abonnieren

Selbst wenn wir jeden nicht markierten Beitrag auf dem Client verfügbar machen wollten, können wir nicht einfach tausende Beiträge auf einmal versenden. Wir brauchen eine Methode, mittels derer der Client bestimmen kann welche Teilmenge der Daten in einem bestimmten Augenblick benötigt wird. Genau dafür gibt es das Abonnieren.

Jegliche abonnierte (subscribed) Daten werden auf den Client gespiegelt. Das ist Minimongo zu verdanken, Meteors Clientseitige Implementierung der MongoDB.

Wir schauen uns beispielsweise gerade die Profilseite von Bob Smith an und da wollen wir nur seine Beiträge sehen.

Abonnieren von Bobs Beiträgen spiegelt diese auf den Client.
Abonnieren von Bobs Beiträgen spiegelt diese auf den Client.

Zunächst würden wir unsere Veröffentlichung (publication) um einen Parameter erweitern:

// on the server
Meteor.publish('posts', function(author) {
  return Posts.find({flagged: false, author: author});
});

Und anschließend würden wir diesen Parameter benutzen, wenn wir die Veröffentlichung im clientseitigen Code abonnieren:

// on the client
Meteor.subscribe('posts', 'bob-smith');

Auf diese Weise wird eine Meteor-App auf der Clientseite skalierbar: anstatt alle verfügbaren Daten zu abonnieren, wählen wir die Teile aus, die wir gerade brauchen. So wird vermieden, dass der Speicher des Browsers überlastet wird, egal wie groß die serverseitige Datenbank ist.

Finden

Jetzt ist es so, dass Bobs Beiträge über mehrere Kategoien verteilt sind (z. B.: „JavaScript“, „Ruby“ und „Python“). Angenommen, wir wollen jetzt zwar alle Beiträge von Bob im Speicher haben aber nur die der Kategorie „JavaScript“ anzeigen. Hier kommt das „Finden“ ins Spiel.

Eine Teilmenge von Dokumenten vom Client aus selektieren.
Eine Teilmenge von Dokumenten vom Client aus selektieren.

Genau wie auf dem Server benutzen wir die Funktion Posts.find(), um eine Teilmenge unserer Daten zu selektieren.

// on the client
Template.posts.helpers({
  posts: function(){
    return Posts.find(author: 'bob-smith', category: 'JavaScript');
  }
});

Jetzt haben wir einen Begriff davon, welche Rolle Veröffentlichungen und Abonnements haben. Zeit, tiefer zu graben und einige häufig benutzte Implementierungsmuster anzusehen.

Autopublish

Wenn du ein neues Meteor-Projekt anslegst (mittels meteor create), dann ist das Package autopublish automatisch mit installiert. Wozu ist es gut? Lass es uns einmal ansehen.

Die Zielsetzung von autopublish ist, den Start in die Programmierung einer Meteor-App sehr einfach zu gestalten. Dazu werden einfach alle Daten vom Server auf den Client gespiegelt. autopublish übernimmt also das Veröffentlichen und Abonnieren für dich.

Autopublish
Autopublish

Wie geht das? Angenommen, du hast eine Collection namens 'posts' auf dem Server. Dann wird autopublish automatisch jeden Eintrag den es in der Mongo-Collection 'posts' findet, an eine Collection 'posts' auf dem Client senden, sofern diese existiert.

Wenn du also autopublish benutzt, dann brauchst du dir über das Veröffentlichen keine Gedanken machen. Daten sind überall vorhanden und die Dinge liegen sehr einfach. Natürlich gibt es die offensichtlichen Probleme mit einer vollständigen Kopie der Datenbank auf dem Rechner jedes einzelnen Benutzers.

Deshalb ist autopublish nur während des Anfangs der Entwicklung sinnvoll, solange du dir noch keine Gedanken über ein Veröffentlichungskonzept gemacht hast.

Vollständige Collections veröffentlichen

Sobald du das Package autopublish aus deinem Projekt entfernt hast, stellst du schnell fest, dass alle Daten vom Client verschwunden sind. Eine einfache Methode, sie zurückzubekommen ist, den Mechanismus von autopublish zu kopieren und einfach die ganze Collection zu veröffentlichen. Zum Beispiel:

Meteor.publish('allPosts', function(){
  return Posts.find();
});
Eine ganze Collection veröffentlichen
Eine ganze Collection veröffentlichen

Wir veröffentlichen zwar immer noch vollständige Collections aber immerhin haben wir die Kontrolle darüber, welche wir veröffentlichen und welche nicht. In diesem Fall veröffentlichen wir die Collection Posts aber nicht Comments.

Teile einer Collections veröffentlichen

Die nächste Ebene von Kontrolle ist es, nur Teile einer Collection zu veröffentlichen. Beispielsweise die Beiträge eines bestimmten Autors:

Meteor.publish('somePosts', function(){
  return Posts.find({'author':'Tom'});
});
Teile einer Collection veröffentlichen
Teile einer Collection veröffentlichen

Hinter den Kulissen

Wenn du die Meteor-Dokumentation zum Veröffentlichen gelesen hast, dann warst du vermutlich überwältigt von dem Gerede über die verwendung von added() und ready(), um Attribute von Datensätzen auf dem Client zu setzen und davon, das mit den Meteor-Apps die du bislang gesehen hast in Einklang zu bringen, bei denen du diese Methoden nicht angewandt findest.

Das liegt daran, dass Meteor eine wichtige Komfort-Funktion bereitstellt: die Methode _publishCursor(). Die hast du auch noch nie gesehen? Vielleicht nicht direkt, aber wenn du in einer Veröffentlichungsfunktion einen Cursor zurückgibst (z.B. Posts.find({'author':'Tom'})), dann ist das genau das, was Meteor intern verwendet.

Wenn Meteor erkennt, dass die Veröffentlichung somePosts einen Cursor zurückgegeben hat, dann ruft es die Methode _publishCursor(), um – richtig geraten – diesen Cursor automatisch zu veröffentlichen.

Hier ist, was _publishCursor() tut:

  • Prüfen des Namens der serverseitigen Collection
  • Holen aller passenden Dokumente aus dem Cursor und senden dieser an die gleichnamige clientseitige Collection. (Mittels .added())
  • Wann immer ein Dokument dazukommt, entfernt wird oder verändert: Senden dieser Veränderungen an die clientseitige Collection. (Mittels .observe() am Cursor bzw. .added(), .changed() und removed())

In dem Beispiel von oben stellen wir damit sicher, dass für den Benutzer nur die Beiträge, die ihn im Moment interessieren, nämlich die von Tom verfassten, in seinem clientseitigen Cache verfügbar sind.

Einzelne Properties veröffentlichen

Wir haben eben gesehen, wie wir nur einige der Beiträge veröffentlichen können. Wir können die Daten aber auch noch feiner zerteilen. Schauen wir uns an, wie wir lediglich bestimmte Properties veröffentlichen.

Wie zuvor, benutzen wir find(), um einen Cursor zurückzugeben. Diesmal aber schließen wir bestimmte Felder aus:

Meteor.publish('allPosts', function(){
  return Posts.find({}, {fields: {
    date: false
  }});
});
Einzelne Properties veröffentlichen
Einzelne Properties veröffentlichen

Selbstvertändlich können wir beide Techniken kombinieren. Wenn wir z.B. alle Beträge von Tom aber nicht deren Datum veröffentlichen wollen, können wir folgendermaßen schreiben:

Meteor.publish('allPosts', function(){
  return Posts.find({'author':'Tom'}, {fields: {
    date: false
  }});
});

Zusammenfassung

Wir haben gesehen, wie wir von autopublish, dem Veröffentlichen aller Properties aller Dokumente aus allen Collections zum Veröffenlichen mancher Properties mancher Dokumente aus manchen Collektions kommen.

Das sind die Basisfunktionen dessen, was du mit Veröffentlichungen bei Meteor machen kannst und diese einfachen Techniken sollten für die allermeisten Anwendungsfälle ausreichen.

Manchmal allerdings musst du darüber hinausgehen und Veröffentlichungen miteinander kombinieren, sie verbinden oder zusammenführen. Das werden wir in einem späteren Kapitel behandeln.

Routing

5

Da wir jetzt eine Liste von Posts (die am Ende von Benutzern erstellt werden) haben, brauchen wir eine eigene Post Seite, auf der Nutzer die Möglichkeit haben über jeden Post zu diskutieren.

Wir möchten diese Seiten via Permalink erreichbar machen, das ist eine URL der Form http://myapp.com/posts/xyz (xyz ist eine MongoDB _id) welche jeden Post eindeutig identifiziert.

Das bedeutet, dass wir eine Art Routing benötigen, um die URL aus dem Browser auszulesen und den zugehörigen Inhalt anzuzeigen.

Hinzufügen des Iron Router Packages

Iron Router ist ein Routing Package, das speziell für Meteor-Apps konzipiert wurde.

Es hilft nicht nur beim Routing (Pfade aufsetzen), sondern es kümmert sich auch um Filter (Zuweisung von Aktionen auf eben jene Pfade) und verwaltet sogar Subscriptions (Steuerung welche Route auf welche Daten zugreifen darf). (Notiz: Iron Router wurde teilweise von Discover Meteor co-author Tom Coleman entwickelt.)

Zunächst installieren wir das Package von Atmosphere:

$ meteor add iron:router
Terminal

Dieser Befehl lädt das iron-router Package in unsere App herunter und installiert es vollständig einsatzbereit. Beachte, dass du manchmal deine Meteor-App neustarten musst, (mit strg+c um den Prozess zu beenden, dann meteor zum starten) bevor ein Package benutzt werden kann.

Router Vokabular

Wir schneiden in diesem Kapitel eine Menge verschiedener Features des Routers an. Falls du Erfahrung mit einem Framework wie z.B. Rails hast, wirst du mit den meisten Konzepten bereits vertraut sein. Falls nicht, befindet sich hier ein kleines Glossar um dir den Start zu erleichtern:

  • Routes: Eine Route ist der Basisbaustein des Routing. Es ist im Grunde genommen die Reihe von Anweisungen, die der App mitteilt, wohin die Route zeigt und was zu tun ist, wenn sie auf eine URL trifft.
  • Paths: Ein Pfad ist eine URL innerhalb der App. Sie kann statisch sein (/terms_of_service) oder dynamisch (/posts/xyz), und sogar Query-Parameter beinhalten (/search?keyword=meteor).
  • Segments: Die einzelnen Teile des Pfades, getrennt durch Slashes (/).
  • Hooks: Hooks sind Aktionen, welche du vor, nach, oder sogar während des Routing-Prozesses ausführen möchtest. Ein typisches Beispiel ist das Überprüfen der Userrechte vor dem Anzeigen einer Seite.
  • Filter: Filter sind Hooks die global für eine oder mehrere Routen definiert werden.
  • Route Templates: Jede Route muss auf ein Template verweisen. Wenn Du keines definierst, wird der Router nach einem Template mit dem Namen der Route suchen.
  • Layouts: Man kann sich Layouts als Rahmen für den Inhalt vorstellen. Sie enthalten den gesamten HTML-Code welche das aktuelle Template umgeben und ändern sich nicht, selbst wenn das Template sich verändert.
  • Controller: Manchmal wird dir auffallen, dass viele deiner Templates die gleichen Parameter benutzen. Anstelle deinen Code zu kopieren, kannst du diese Routen von einem Routing Controller erben lassen, welcher die gesamte Routinglogik enthält.

Für weitere Informationen zum Iron Router, schau dir die volle Dokumentation auf GitHub an.

Routing: URLs mit Templates verknüpfen

Bisher haben wir unser Layout mittels statischen Includes (wie z.B. {{>postsList}}). Also obwohl sich der Inhalt unserer App verändern kann, bleibt die grundlegende Struktur immer die Gleiche: eine Kopfzeile mit einer Liste von Posts darunter.

Iron Router ermöglicht uns aus dieser Form auszubrechen, indem er steuert, was innerhalb des HTML <body> Tags gerendert wird. Wir legen den Inhalt dieses Tags also nicht selbst fest, wie man das normalerweise bei einer HTML Seite machen würde. Anstatt dessen, lassen wir den Router auf ein spezielles Layout Template zeigen, das einen Template Helper anthält: {{>yield}}

Dieser {{> yield}} Helper definiert einen speziellen dynamischen Bereich, der automatisch mit dem Template gerendert wird, das der aufgerufenen Route zugeordnet ist (als Konvention bezeichnen wir dieses spezielles Template ab jetzt als “Route Template”):

Layouts und Templates.
Layouts und Templates.

Wir beginnen damit, dass wir unser Layout erstellen und den {{yield}} Helper hinzufügen. Zunächst entfernen wir den HTML <body> Tag in der Datei main.html und verschieben den Inhalt in ein eigenes Template, layout.html (die in ein neues Verzeichnis gespeichert wird: client/templates/application).

Iron Router erledigt das Einbinden unseres Layouts in das gekürzte main.html Template, das nun so aussieht:

<head>
  <title>Microscope</title>
</head>
client/main.html

Wohingegen die neu erstelle `layout.html" nun das äußere Layout der Anwendung enthält:

<template name="layout">
  <div class="container">
    <header class="navbar navbar-default" role="navigation">
      <div class="navbar-header">
        <a class="navbar-brand" href="/">Microscope</a>
      </div>
    </header>
    <div id="main">
      {{> yield}}
    </div>
  </div>
</template>
client/templates/application/layout.html

Wie du bemerkt haben wirst, haben wir den Include des postsList Templates durch einen Aufruf des yield Helpers.

Nach dieser Anpassung zeigt unser Browser Tab die Standard Iron Router Hilfe-Seite. Das liegt daran, dass wir dem Router noch nicht mitgeteilt haben was er mit der / URL machen soll, also wird einfach ein leeres Template präsentiert.

Zuerst können wir das ursprüngliche Verhalten wieder herstellen, indem wir die URL / mit dem Template postsList verknüpfen. Wir erstellen eine neue Datei, router.js, innerhalb des Verzeichnisses /lib im Stammverzeichnis unseres Projekts.

Router.configure({
  layoutTemplate: 'layout'
});

Router.route('/', {name: 'postsList'});
lib/router.js

Wir haben zwei wichtige Dinge getan. Zuerst haben wir dem Router mitgeteilt, das gerade erstellte layout Template als Standard für alle Routen festzulegen.

Danach haben wir eine neue Route namens postsListdefiniert und mit dem Root Pfad / verknüpft.

Das /lib Verzeichnis

Alles, was man im /lib Verzeichnis ablegt, wir vor allem anderen in der App geladen (mit einem möglichen Ausnahmen, Smart Packages. Deshlab ist dies ein guter Ort um Helper Code zu speichern, der jederzeit verfügbar sein soll.

Jedoch eine kleine Warnung: beachte, dass sich der Ordner /lib weder im Verzeichnis /client noch im Verzeichnis /server befindet und die Inhalte somit beiden Umgebungen zur Verfügung stehen

Benannte Routen

Hier gilt es eine Unklarheit zu beseitigen. Wir haben unsere Route postsList genannt, aber wir haben auch ein Template namens postsList. Was ist hier also los?

Standardmäßig sucht Iron Router nach einem Template vom gleichen Namen wie die Route. Tatsächlich kann er sogar vom angegebenen Pfad auf den Namen schließen. Auch wenn das in diesem Fall nicht funktionieren würde (da unser Pfad / ist), hätte Iron Router das richtige Template gefunden, wenn wir http://localhost:3000/postsList als Pfad genutzt hätten.

Du fragst dich vielleicht warum man Routen dann überhaupt benennen muss. Das Benennen der Routen ermöglicht es uns, einige Iron Router Features zu nutzen, die es erleichtern Links innerhalb der App zu erzeugen. Die hilfreichste Funktion ist der Spacebars Helper {{pathFor}}, der die URL Pfad Komponente jeder Route zurückgibt.

Wir möchten, dass unser “Home” Link uns zurück zur Posts Liste führt, also anstatt die statische URL / festzulegen, können wir auch den Spacebars Helper nutzen. Das Resultat ist das Gleiche, doch dieser Ansatz gibt uns mehr Flexibilität, da der Helper stets die richtige URL ausgeben wird, selbst wenn wir später einmal den Pfad der Route im Router ändern.

<header class="navbar navbar-default" role="navigation">
  <div class="navbar-header">
    <a class="navbar-brand" href="{{pathFor 'postsList'}}">Microscope</a>
  </div>
</header>

//...
client/templates/application/layout.html

Commit 5-1

Very basic routing.

Auf Daten warten

Veröffentlicht man die aktuelle Version der App (oder öffnet wie Web-Instanz über den Link oben), bemerkt man, dass die Liste für einen kurzen Moment leer zu sein scheint, bevor die Posts erscheinen. Das passiert, da es noch keine Posts anzuzeigen gibt wenn die Seite das erste Mal geladen wird, solange bis die posts Subscription die Daten vom Server geladen hat.

Es wäre benutzerfreundlicher ein visuelles Feedback zu geben, dass irgendetwas passiert und dass der Benutzer einen Moment warten soll.

Zum Glück ermöglicht uns Iron Router das einfach umzusetzen: wir können einrichten, dass er auf die Subscription wartet.

Wir beginnen damit unsere Subscription posts aus der Datei main.js in den Router zu verlagern:

Router.configure({
  layoutTemplate: 'layout',
  waitOn: function() { return Meteor.subscribe('posts'); }
});

Router.router('/', {name: 'postsList'});
lib/router.js

Hier legen wir fest, dass jede Route auf der Seite (im Moment gibt es nur eine, do bald werden es mehr!) die Subscription posts nutzen soll.

Der hauptsächliche Unterschied zwischem dem was wir jetzt haben und dem alten Code (als sich die Subscription noch in der main.js befand, die nun leer sein und entfernt werden sollte) ist, dass Iron Router nun genau weiß wann die Route “bereit” ist - nämlich genau dann, wenn sie alle Daten hat, die zum anzeigen benötigt werden.

Get A Load Of This (Kaum zu glauben)

Genau zu wissen wann die Route postsList bereit ist, bringt uns nicht wirklich viel, wenn wir dann bloß ein leeres Template anzeigen. Glücklicherweise verfügt Iron Router über eine eingebaute Funktion, die das Anzeigen eines Templates verzögert, bis die aufgerufene Route bereit ist und zeigt in der Zwischenzeit ein loading Template.

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  waitOn: function() { return Meteor.subscribe('posts'); }
});

Router.router('/', {name: 'postsList'});
lib/router.js

Man beachte, dass wir die Funktion waitOn global auf der Router-Ebene definieren, weshalb diese Sequenz nur einmal ausgeführt wird, wenn ein Benutzer die App zum aller ersten Mal aufruft. Danach befinden sich die Daten bereits im Speicher des Browsers und der Router muss nicht mehr darauf warten.

Das letzte Puzzelstück ist das eigentliche laden des Templates. Wir benutzen dafür das Package spin um einen schöne Lade-Animation zu erstellen. Wir fügen es mit dem Befehl meteor add sacha:spin zum Projekt hinzu und erstellen dann das Template loading wie folgt im Verzeichnis ´ client/templates/includes:

<template name="loading">
  {{>spinner}}
</template>
client/templates/includes/loading.html

Man beachte, dass {{>spinner}} ein Partial aus dem spin Package ist. Obwohl dieses Partial von außerhalb unsere App kommt, können wir es wie jedes andere Template auch includen.

Für gewöhnlich ist es eine gute Idee auf Subscriptions zu warten, nicht nur der Benutzerfreundlichkeit wegen, sondern auch weil man dann mit Sicherheit weiß, dass die Daten im Template immer verfügbar sind. Das macht es überflüssig sich darum kümmern zu müssen, ob ein Template angezeigt werden darf, bevor die benötigten Daten noch nicht verfügbar sind, was oft komplizierter Workarounds bedarf.

Commit 5-2

Wait on the post subscription.

Ein ertster Einblick in Reaktivität

Reaktivität ist ein Teil des Kerns von Meteor und obwohl wir noch nicht wirklich damit in Berührung gekommen sind, gewährt uns das loading-Template einen ersten Blick auf das Konzept.

Auf ein Lade-Template weiterzuleiten, wenn die Daten noch nicht verfügbar sind, ist ja schön und gut aber woher weiß der Router überhaupt wann er den Benutzer zurück auf die richtige Seite schicken soll, wenn die Daten erstmal da sind.

Für den Moment, sagen wir einfach einmal, dass ist genau der Punkt an dem die Reaktivität ins Spiel kommt und belassen es dabei. Aber keine Sorge, du wirst bald mehr darüber erfahren!

Zu einem bestimmten Post routen

Jetzt, da wir gesehen haben, wie man eine Route zum Template postsList erstellt, setzten wir eine Route auf um die Eigenschaften eines bestimmten Posts anzuzeigen.

Es gibt da nur ein Problem: wir können nicht einfach hingehen und für jeden Post eine eigene Route erstellen, denn das können viele hundert sein. Also müssen wir einen Weg finden, eine einzige dynamische Route zu erstellen, die dann den Post anzeigt, den wir sehen möchten.

Zunächst erstellen wir ein neues Template, das einfach das gleiche Post-Template anzeigt, dass wir zuvor für die Liste der Posts benutzt haben.

<template name="postPage">
  <div class="post-page page"
    {{> postItem}}
  </div>
</template>
client/templates/posts/post_page.html

Wir werden dem Template später noch mehr Elemente (wie z.B. Kommentare), doch für den Moment, bildet es nur einen Rahmen für den Include ovn {{> postItem}}.

Wir erstellen also eine weitere benannte Route, dieses Mal zeigen URL-Pfade der From /posts/<ID> auf das Template ´postPage`:

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  waitOn: function() { return Meteor.subscribe('posts'); }
});

Router.route('/', {name: 'postsList'});
Router.route('/posts/:_id', {
  name: 'postPage'
});

lib/router.js

Der spezielle Syntax :_id teilt dem Router zwei Dinge mit: erstens soll jede Route der Form posts/xyz, bei der “xyz” alles mögliche sein kann, erkannt werden. Zweitens muss, was auch immer an Stelle von “xyz” übermittelt wird, innerhalb einer Property _id im Router Array params gespeichert werden.

Wir benutzen _id hier nur aus praktischen Gründen. Der Router weiß nicht, ob wir tatsächliche eine ID übergeben oder eine zufällige Anzahl an Zeichen.

Jetzt routen wir schon auf das korrekte Template, doch etwas fehlt trotzdem noch: der Router kennt zwar die _id des Posts, den wir anzeigen wollen schon, aber das Template noch nicht. Wie schließen wir diese Lücke?

Zum Glück verfügt der Router über eine einfache Lösung: er ermöglicht nämlich das festlegen eines data context für ein Template. Einen data context kann man sich als Füllung in einem leckeren Kuchen aus Templates und Layouts vorstellen. Einfach gesagt, ist es das womit mein sein Template auffüllt:

Der data context.
Der data context.

In unserem Fall erhalten wir den richtigen data context, indem wir mit der _id aus der URL nach dem Post suchen:

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  waitOn: function() { return Meteor.subscribe('posts'); }
});

Router.route('/', {name: 'postsList'});
Router.route('/posts/:_id', {
  name: 'postPage',
  data: function() { return
  Posts.findOne(this.params._id); }
});

lib/router.js

Jedes Mal wenn ein Nutzer also diese Route aufruft, finden wir den betreffenden Post und übergeben ihn an das Template. Bedenke, dass findOne einen einzelnen Post zurückgibt, der den Suchkriterien entspricht, und dass das angeben einer ìdals einziger Parameter die Kurzform für{_id: id} ist.

Innerhalb der data Funktion einer Route repräsentiert this die aktuelle Route und man kann this.params nutzen um die benannten Teile der Route (wir haben das gemacht indem wir in unserem path ein : vorangestellt haben) auszulesen.

Mehr zu Data Contexts

Legt man einen data context für ein Template fest, kann man den Inhalt von this innerhalb des Template-Helpers kontrollieren.

Das macht man normalerweise implizit mit dem {{#each}} Iterator, der automatisch den data context jeder Iteration auf das aktuell iterierte Objekt setzt:

{{#each widgets}}
  {{> widgetItem}}
{{/each}}

Wir können das alles aber auch explizit mit {{#with}} erledigen, das einfach sagt “nimm dieses Objekt und wende folgendes Template darauf an”. Wir können zum Beispiel schreiben:

{{#with myWidget}}
  {{> widgetPage}}
{{/with}}

Man kann sogar das Gleiche erreichen, wenn man den context als Argument im Aufruf des Templates übermittelt. Man kann also den letzten Code-Block wie folgt umschreiben:

{{> widgetPage myWidget}}

Für eine weiterführende Untersuchung von data contexts, empfehlen wir unseren Blog-Post zu dem Thema zu lesen.

Einsatz eines dynamisch benannten Route-Helpers

Zum Schluss erstellen wir einen “Discuss” Button, der auf unsere eigene Post Seite verlinkt. Nochmal, wird könnten soetwas machen wie: <a href="/posts/{{_id}}"> aber ein Route-Helper ist einfach die verlässlichere Variante.

WIr haben die Post-Route postPage genannt, also können wir den {{pathFor 'postPage'}} Helper benutzen:

<template name="postItem">
  <div class="post">
    <div class="post-content">
      <h3><a href="{{url}}">{{title}}</a><span>{{domain}}</span></h3>
    </div>
    <a href="{{pathFor 'postPage'}}" class="discuss btn btn-default">Discuss</a>
  </div>
</template>
client/templates/posts/post_item.html

Commit 5-3

Routing to a single post page.

Aber halt, woher weiß der Router woher er den Teil xyz aus /posts/xyz bekommt? Schließlich übergeben wir keine _id.

Es zeigt sich, dass Iron Router so schlau ist, es selbst herauszufinden. Wir befehlen dem Router die Route postPage zu benutzen und er weiß, dass diese Route irgendeine Form einer _id benötigt (denn so haben wir unseren path definiert).

Der Router sucht deshalb nach dieser _id an der logischsten Stelle: der data context des Helpers {{pathForpostPage}}oder anders gesagtthis. Undthisentspricht eben einem Post, das (Überraschung!) über eine_id` Property verfügt.

Alternativ kann man dem Router auch explizit mitteilen, dass er nach einer _id Property suchen soll, indem man dem Helper ein zweites Argument übergibt. (z.B. {{pathFor 'postPage' someOtherPost}}. Ein praktisches Beispiel für diese Methode ist, wenn man den Link zum vorherigen oder nächsten Post einer Liste laden möchte.

Um zu überprüfen ob alles korrekt funktioniert, navigiere mit dem Browser zur Posts-Liste und klicke auf einen der “Discuss” Links. Es sollte dann ungefähr so aussehen:

Ein Seite eines einzelnen Posts
Ein Seite eines einzelnen Posts

HTML5 pushState

Eines sollte man sich vor Augen führen: das Ändern der URL passiert durch den Gebrauch von HTML5 pushState.

Der Router greift Klicks auf URLs innerhalb der Seite ab und verhindert, dass der Browser die App verlässt und anstatt dessen nur die nötigen Änderungen am Status der App durchführt.

Wenn alles richtig funktioniert, sollte das Wechseln von Seiten ohne Verzögerung passieren. In Wirklichkeit, passieren manche Dinge zu schnell, dass man sich Gedanken über einen Übergang zwischen den Seite machen muss. Doch das würde über den Inhalt dieses Kapitel weit hinausgehen, obwohl es ein sehr spannendes Thema ist.

Post nicht gefunden

Wir sollten nicht vergessen, dass Routing in zwei Richtungen funktioniert: es kann die URL verändern, wenn wir eine Seite besuchen, aber es kann auch eine andere Seite anzeigen, wenn wir die URL verändern. Also müssen wir herausfinden, was passiert, wenn jemand eine falsche URL eingibt.

Glücklicherweise erledigt Iron Router das mit der Funktion notFoundTemplate für uns.

Zunächst erstellen wir ein neues Template, das eine einfache 404 Fehlermeldung anzeigt:

<template name="not Found">
  <div class="not-found page jumbotron">
    <h2>404</h2>
    <p>Sorry, we couldn't find a page at this address.</p>
  </div>
</template>
client/templates/application/not_found.html

Dann lassen wir Iron Router einfach auf dieses Template zeigen:

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  notFoundTemplate: 'notFound',
  waitOn: function() { return Meteor.subscribe('posts'); }
});

//...

lib/router.js

Um unsere neue Fehlerseite zu testen, versuchen wir eine zufällige URL aufzurufen, wie z.B. http://localhost:3000/nothing-here.

Aber stopp. Was passiert, wenn jemand eine URL der Form http://localhost:3000/posts/xyz der xyz keine gültige _id eines Posts ist? Es handelt sich schließlich um eine valide Route, die nur keine Daten findet. Iron Router bemerkt das zum Glück, wenn wir einen speziellen Hook dataNotFound am Ende der Datei router.js platzieren:

//...

Router.onBeforeAction('dataNotFound',
{only: 'postPage'});
lib/router.js

Damit weiß Iron Router, dass er die Fehlerseite nicht nur bei ungültigen Routen, sondern auch für die postPage Route, wenn die Funktion data ein “False” Objekt (null, false, undefined oder leer) zurückgeben soll.

Commit 5-4

Added not found template.

Warum eigentlich “Iron”?

Vielleicht fragst du dich was hinter dem Namen “Iron Router” steckt. Laut Chris Mather, dem Entwickler von Iron Router liegt dem zu Grunde, dass Meteoriten hauptsächlich aus Eisen (Iron) bestehen.

Die Session

Sidebar 5.5

Meteor ist ein reaktives Framework. Das bedeutet, ohne dass man explizit etwas tun muss, ändern sich Dinge in der Anwendung sobald sich Daten ändern.

Wir haben bereits in Aktion gesehen wie sich unsere Templates verändern, wenn sich Daten oder Routen ändern.

Wir werden uns in späteren Kapiteln ausführlicher damit befassen, wie dies genau funktioniert, doch jetzt möchten wir erst einmal einige grundlegende reaktive Features vorstellen, die in den meisten Apps extrem hilfreich sind.

The Meteor Session

Im Moment ist der gesamte Zustand der Benutzeranwendung in Microscope über die URL und die Datenbank definiert.

Doch in vielen Fällen muss man kurzzeitig einige Daten speichern, die nur für die Version der Anwendung, die der Nutzer gerade betrachtet, relevant sind (beispielsweise, ob ein Element angezeigt oder versteckt werden soll). Die Session bietet eine praktische Möglichkeit das zu tun.

Die Session ist ein globaler, reaktiver Datenspeicher. Sie ist global im Sinne eines globalen Singleton-Objects: es gibt eine Session, auf die von überall zugegriffen werden kann. Globale Variablen werden normalerweise als etwas Schlechtes betrachtet, doch in diesem Fall kann die Session als zentrale Datensammlung für verschiedene Teile der Anwendung genutz werden.

Die Session verändern

Die Session ist clientseitig überall als Session Objekt verfügbar. Um einen Wert in der Session zu setzen, geht man wie folgt vor:

 Session.set('pageTitle', 'A different title');
Browser Konsole

Mit Session.get('mySessionProperty'); kann man die Daten wieder auslesen. Es handelt sich dabei um eine reaktive Datenquelle, was bedeutet, dass wenn man sie in einem Helper benutzt, würde sich die Ausgabe ändern, sobald sich die Session Variable ändert.

Um das zu testen, füge den folgenden code ins Layout Template ein:

<header class="navbar navbar-default" role="navigation">
  <div class="navbar-header">
    <a class="navbar-brand" href="{{pathFor 'postsList'}}">{{pageTitle}}</a>
  </div>
</header>
client/templates/application/layout.html
Template.layout.helpers({
  pageTitle: function() { return Session.get('pageTitle'); }
});
client/templates/application/layout.js

Ein Hinweis zu Sidebar-Code

Beachte, dass Code in Sidebar-Kapiteln nicht Teil des eigentlichen Verlaufs des Buches ist. Also entweder erstellst du jetzt eine neue Branch (wenn du Git benutzt) oder du machst alle Änderungen am Code am Ende dieses Kapitels wieder rückgängig.

Meteors automatischer Reload (auch bekannt als “hot code reload” oder HCR) erhält Session Variablen, weswegen wir jetzt “A different title” in der nav-bar angezeigt bekommen sollten. Falls nicht, führe einfach den Session.set() Befehl erneut aus.

Dazu kommt, dass wenn wir den Wert ein weiteres Mal verändern (in der Browser Konsole), sollten wir wieder einen anderen Titel sehen:

 Session.set('pageTitle', 'A brand new title');
Browser Konsole

Da die Session global ist, können solche Änderungen an jeder Stelle in der App vorgenommen werden. Das gibt uns viele Möglichkeiten but kann auch zur Falle werden, wenn man es zu oft nutzt.

Im Übrigen ist es wichtig zu erwähnen, dass das Session Objekt nicht unter den Benutzern geteilt wird, ja noch nicht einmal unter verschiedenen Browser-Tabs eines einzelnen Nutzers. Deshalb erhält man eine leere Seite, wenn man die Anwendung nun in einem neuen Tab öffnet.

Redundante Veränderungen

Verändert man die Session Variable mit Session.set() und setzt den ohnehin schon gespeicherten Wert, ist Meteor intelligent genug, die reaktive Kette zu umgehen und unnötige Funktionsaufrufe zu vermeiden.

Einführung in Autorun

Wir haben bereits ein Beispiel reaktiver Datenquellen betrachtet und haben sie in einem Template-Helper in Aktion gesehen. Doch obwohl Meteor an manchen Stellen (z.B. bei Template-Helpern) grundsätzlich reaktiv ist, ist der Großteil des Codes einer Meteor Anwendung trotzdem herkömmliches, nicht-reaktives JavaScript.

Betrachten wir folgenden Code-Schnipsel irgendwo in unserer App:

helloWorld = function() {
  alert(Session.get('message'));
}

Obwohl wir eine Session Variable aufrufen, ist der context des Aufrufs nicht reaktiv. Das bedeutet, dass wir nicht jedes mal ein neues alert bekommen, wenn wir die Session Variable ändern.

Hier kommt Autorun ins Spiel. Wie der Name schon vermuten lässt, wird der Code innerhalb eines autorun Blocks automatisch ausgeführt und das jedes Mal, wenn die reaktive Datenquelle darin sich ändert.

Gib das in die Browser Konsole ein:

 Tracker.autorun( function() {
console.log('Value is: ' +
Session.get('pageTitle')); } );
Value is: A brand new title
Browser Konsole

Wie du vielleicht vermutet hast, wird der Code innerhalb von autorun einmal ausgeführt und gibt den Titel in der Konsole aus. Jetzt ändern wir den Titel:

 Session.set('pageTitle', 'Yet another value');
Value is: Yet another value
Browser Konsole

Magie! Weil sich der Wert in der Session verändert hat, wusste autorun, dass es seinen Inhalt zum wiederholten Mal ausführen muss und so gelangt der neue Wert in die Konsole.

Tracker.autorun(function() {
  alert(Session.get('message'));
});

Wie wir nun gesehen haben, kann autorun sehr nützlich sein, wenn es darum geht, reaktive Datenquellen zu überwachen und geeignet zu reagieren.

Hot Code Reload

Während unserer Arbeit an Microscope haben wir eines von Meteors zeitsparanden Features genutzt: hot code reload (HCR). Sobald wir eine unsere Quelltextdateien speichern, erkennt Meteor Veränderungen, startet den Meteor Server neu und informiert jeden Client, dass die Seite neu geladen werden muss.

Dieses Verhalten ähnelt dem automatischen Reload der Seite, mit einem entscheidenden Unterschied.

Um herauszufinden worin dieser Unterschied besteht, setzen wir zunächst die Session Variable zurück:

 Session.set('pageTitle', 'A brand new title');
 Session.get('pageTitle');
'A brand new title'
Browser Konsole

Würden wir jetzt die Seite in unserem Browser Fenster manuell neu laden, wären unsere Session Daten natürlich verloren (da eine neue Session erzeugt werden würde). Lösen wir allerdings einen hot code reload aus (z.B. indem wir eine unserer Quelltext Dateien speichern), wird die Seite ebenfalls neu geladen, doch die Session bleibt erhalten. Probiers aus!

 Session.get('pageTitle');
'A brand new title'
Browser Konsole

Benutzen wir also Session Variablen um nachzuvollziehen was der Benutzer macht, würde der HCR für ihn fast völlig unbemerkt vonstatten gehen, da die Werte der Session Variablen erhalten bleiben. Das ermöglicht uns, neue Produktiv-Versionen von Meteor zu veröffentlichen mit der Gewissheit, dass unsere Nutzer fast nicht gestört werden.

Überdenke das für einen Moment. Wenn es möglich ist, der gesamten Status über die URL und die Session zu erfassen, können wir unbemerkt den laufenden Quellcode der App ändern ohne den Nutzer zu beeinträchtigen.

Sehen wir uns an was passiert wenn wir die Seite manuell neu laden:

 Session.get('pageTitle');
null
Browser Konsole

Wenn wir die Seite neu laden, geht die Session verloren. Bei einem HCR speichert Meteor die Session in den lokalen Speicher des Browsers und läd sie wieder nach dem Reload. Dennoch ergibt auch das Verhalten bei einem manuellen Reload Sinn: wenn ein Benutzer die Seite neu läd, ist das als würde er die URL neu aufrufen und die Seite sollte so präsentiert werden wie jedem anderen User, der diese URL aufruft.

Die wichtigsten Punke dabei sind:

  1. Speichere den Zustand von Benutzern immer in Sessions oder URLs, damit sie bei einem hot code reload nicht beeinträchtigt sind.
  2. Speichere jeden Zustand, den die Benutzer untereinandere Teilen können sollen in die URL selbst.

Damit beschließen wir unseren Streifzug durch die Session, einem von Meteors praktischsten Feature. Vergiss nicht die Änderungen am Code rückgängig zu machen bevor zu zum nächsten Kapitel gelangst.

Benutzer hinzufügen

6

Bisher haben wir es geschafft, statische fixture Daten zu kreieren und anzuzeigen und diese in Form eines einfachen Prototypen zusammenzubauen.

Wir haben auch gesehen, dass unser UI sich reaktiv gegenüber Datenänderungen verhält und wie eingefügte or geänderte Daten sofort erscheinen. Weil wir selber aber keine Daten einfügen können, ist unsere Site mehrheitlich nutzlos.

Lass uns sehen wie wir das Problem lösen können.

Accounts: Benutzerkonten einfach gemacht

In den meisten Web Frameworks ist das Hinzufügen von Benutzerkonten ein mühseliger Vorgang, der jedoch in fast jedem Projekt von Nöten ist. Sobald man sich mit OAuth oder anderen Authentifizierungs-Methoden auseindandersetzen muss, findet man sich schnell im Chaos wieder.

Zum Glück hält Meteor hierfür eine Lösung parat. Dank der Art und Weise wie Meteor packages dem Server (JavaScript) und dem Client (Javascript, HTML und CSS) Code zur Verfügung stellt, erhalten wir ein Benutzerkonten-Sytem zum Nulltarif.

Wir könnten hierfür Meteor’s hausgeigenes UI für Benutzerkonten einsetzen (meteor add accounts-ui), aber da wir unsere ganze App mit Bootstrap aufgebaut haben, werden wir das accounts-ui-bootstrap-deopdown package dafür verwenden. (Keine Bange, der einzige Unterschied ist das unterschiedliche Styling). In der Kommandozeile gebe Folgendes ein:

$ meteor add accounts-ui-bootstrap-dropdown
$ meteor add accounts-password
Terminal

Diese zwei Befehle stellen uns die Benutzerkonten Templates zur Verfügung. Wir können diese in userer Site mit dem {{loginButtons}} helper einfügen. Dazu ein nützlicher Tipp: Du kannst kontrollieren, auf welcher Seite das Log-in Dropdown angezeigt wird, nämlich indem du das align Attribut dem helper mitgibst. (Zum Beispiel: {{loginButtons align="right"}})

Wir fügen die Buttons in unseren Header ein. Da dieser immer grösser wird, lagern wir ihn in sein eigenes Template aus (client/views/includes). Weiter benutzen wir zusätzlichen Markup und ein paar Bootstrap Klassen um alles schön anschaubar zu machen.

<template name="layout">
  <div class="container">
    {{>header}}
    <div id="main" class="row-fluid">
      {{yield}}
    </div>
  </div>
</template>
client/views/application/layout.html
<template name="header">
  <header class="navbar">
    <div class="navbar-inner">
      <a class="btn btn-navbar" data-toggle="collapse" data-target=".nav-collapse">
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
      </a>
      <a class="brand" href="{{pathFor 'postsList'}}">Microscope</a>
      <div class="nav-collapse collapse">
        <ul class="nav pull-right">
          <li>{{loginButtons}}</li>
        </ul>
      </div>
    </div>
  </header>
</template>
client/views/includes/header.html

Wenn wir jetzt zu unserer App browsen sehen wir die Login-Buttons in der oberen rechten Ecke unserer Site erscheinen.

Meteor's built-in accounts UI
Meteor’s built-in accounts UI

Wir können sie für folgende Aktionen benutzen: Anmelden, Einloggen, Passwortänderung und alles was eine einfache Site für Benutzerauthentifizierung braucht.

Um unser Benutzersystem so zu konfigurieren, dass Benutzer sich mit einem Benutzernamen auf unserer Site anmelden müssen, brauchen wir einen Accounts.ui Konfigurationsblock in die Datei config.js (client/helpers/) einzufügen:

Accounts.ui.config({
  passwordSignupFields: 'USERNAME_ONLY'
});
client/helpers/config.js

Commit 6-1

Added accounts and added template to the header

Unseren ersten Benutzer erstellen

Melde nun ein neues Benutzerkonto an: Sobald erfolgt, ändert der Button und zeigt deinen Benutzernamen. Das zeigt auf, dass ein Benutzer für dich angelegt wurde. Aber woher kommt dieses Benutzerkonto?

Mit dem Hinzufügen des accounts packages, hat Meteor eine neue spezielle Collection erzeugt, welche mit Meteor.users aufgerufen werden kann. Um diese zu überprüfen, öffne die Browser-Konsole und tippe:

 Meteor.users.findOne();
Browser console

Die Konsole sollte ein Objekt mit deinem User enthaltend zurückgeben. Wenn du näher hinschaust, kannst du sehen dass sowohl dein Benutzernamen, als auch eine _id zur eindeutigen Identifizierung darin enthalten sind. Den gegenwärtig eingeloggten User kannst du auch mit dem Befehl Meteor.user() ermitteln.

Logge dich aus und melde ein zweites Benutzerkonto mit einem anderem Benutzernamen an. Meteor.user() sollte jetzt einen zweiten Benutzer zurückgeben. Lass uns das überprüfen:

 Meteor.users.find().count();
1
Browser console

Die Konsole gibt 1 zurück. Sollten das nicht 2 sein? Wurde der erste Benutzer etwa gelöscht? Wenn du versuchst mit dem ersten Benutzer einzuloggen, siehst du das dies nicht der Fall ist.

Lass uns das verifizieren, indem wir den anerkannten Datenspeicher, die Mongo DB anschauen. Wir loggen in die Mongo DB ein (meteor mongo in der Konsole) und überprüfen:

> db.users.count()
2
Mongo console

Es sind also tatsächlich zwei Benutzer vorhanden. Weshalb können wir im Browser dann nur Einen zur selben Zeit sehen?

Eine “Mystery Publication”!

Wenn du zurück an Kapitel 4 denkst, erinnerst du dich vielleicht daran, dass wir mit dem Ausschalten von autopublish allen Collections mitteilten, dass sie aufhören sollen automatisch alle Daten vom Server an jeden verbundenen Client in dessen lokale Version der Collection zu senden. Wir mussten eine Publication und eine Subscription erstellen um den Datenaustausch aufrecht zu erhalten.

Doch die Tatsache dass wir nie eine Benutzer Publication erstellt haben, wirft die Frage auf, wie wir dann Benutzerdaten empfangen und darstellen können?

Die Antwort hierfür liegt im accounts package versteckt. Dieses macht eine “Auto-Publikation” des aktuell eingeloggten Benutzers. Würde es dies nicht tun, könnte der Benutzer sich gar nicht erst einloggen.

Das accounts package publiziert allerdings nur den aktuell eingeloggten Benutzer. Das erklärt, wieso ein eingeloggter Benutzer nicht Benutzerkonten-Details eines anderen Benutzers einsehen kann.

Die Publication publiziert also nur ein Benutzer-Objekt (und nichts, wenn du nicht eingeloggt bist).

Weiter noch: Dokumente in unserer Benutzer Collection scheinen nicht dieselben Felder auf dem Server, wie auf dem Client zu beinhalten. In der Mongo-DB hat ein Benutzer viele Datenfelder, welche dem Benutzer-Objekt auf dem Client fehlen. Um dies zu sehen gehe zurück in das Mongo Terminal und tippe:

> db.users.findOne()
{
  "createdAt" : 1365649830922,
  "_id" : "kYdBd9hr3fWPGPcii",
  "services" : {
    "password" : {
      "srp" : {
        "identity" : "qyFCnw4MmRbmGyBdN",
        "salt" : "YcBjRa7ArXn5tdCdE",
        "verifier" : "df2c001edadf4e475e703fa8cd093abd4b63afccbca48fad1d2a0986ff2bcfba920d3f122d358c4af0c287f8eaf9690a2c7e376d701ab2fe1acd53a5bc3e843905d5dcaf2f1c47c25bf5dd87764d1f58c8c01e4539872a9765d2b27c700dcdedadf5ac82521467356d3f91dbeaf9848158987c6d359c5423e6b9cabf34fa0b45"
      }
    },
    "resume" : {
      "loginTokens" : [
        {
          "token" : "BMHipQqjfLoPz7gru",
          "when" : 1365649830922
        }
      ]
    }
  },
  "username" : "tmeasday"
}
Mongo console

Auf dem Client im Browser ist das Benutzer-Objekt aufs Wesentliche gekürzt. Gib folgende ein:

 Meteor.users.findOne();
Object {_id: "kYdBd9hr3fWPGPcii", username: "tmeasday"}
Browser console

Dieses Beispiel zeigt uns wie eine lokale Collection ein sicheres Subset einer Datenbank Collection sein kann. Der eingeloggt Benutzer sieht gerade genug Daten seines gesamten Datensets, um eine Aktion auszuführen (in diesem Fall das Einloggen). Wie wir später noch sehen werden, ist dies ein nützliches Muster von dem du noch viel Lernen kannst.

Dies heisst allerdings nicht, dass es unmöglich ist mehr Benutzerdaten zu publizieren, sofern du das möchtest. Konsultiere die Meteor docs um zu sehen wie du zusätzliche Felder aus der Meteor.users Collection publizieren kannst.

Reactivity

Sidebar 6.5

Wenn Collections Meteor’s Kernfeature sind, dann ist Reaktivität die Hülle die den Kern nützlich macht.

Collections verändern die Art und Weise wie deine Applikation mit Datenänderungen umgeht radikal. Anders als Datenänderungen manuell zu überprüfen (z.B. mit einem AJAX Aufruf) und danach in das HTML einzufügen, können Datenänderungen in Meteor jederzeit reflektiert und im UI angezeigt werden.

Nimm dir kurz Zeit und denke nach, was das bedeutet: Hinter der Kulisse kann Meteor jede Änderung im UI anzeigen, sobald die dazugehörige Collection einer Änderung unterzogen wird.

Der unumgängliche Weg dies zu tun wäre die .observe() Funktion zu benutzen; eine Cursor Funktion die callbacks aufruft, sobald Dokumente ändern, die dem Cursor angehören. Wir könnten dann mit diesen callbacks Änderungen am DOM vornehmen. Der daraus resultierende Code würde etwa so aussehen:

Posts.find().observe({
  added: function(post) {
    // when 'added' callback fires, add HTML element
    $('ul').append('<li id="' + post._id + '">' + post.title + '</li>');
  },
  changed: function(post) {
    // when 'changed' callback fires, modify HTML element's text
    $('ul li#' + post._id).text(post.title);
  },
  removed: function(post) {
    // when 'removed' callback fires, remove HTML element
    $('ul li#' + post._id).remove();
  }
});

Vielleicht kannst du jetzt schon sehen, dass solcher Code ziemlich schnell ziemlich komplex werden kann. Stell dir vor alle Attributänderungen eines Post’s müssten observiert und innerhalb seines <li>-Elements komplizierte HTML Änderungen vornehmen. Ganz zu schweigen von den Grenzfällen, die auftreten können, wenn wir beginnen uns darauf zu verlassen, dass unterschiedliche Daten-Quellen zu Echtzeit geändert werden.

Wann sollten wir observe() benutzen?

Manchmal jedoch ist es notwendig, das oben erwähnte Pattern zu verwenden, speziell im Umgang mit 3rd-part Widgets. Zum Beispiel beim Hinzufügen und Entfernen von Pins (aus Collection Datensätzen) auf Karten (beispielsweise um den Standort von gegenwärtig eingeloggten Benutzern anzuzeigen).

Um zu wissen wie auf Datenänderungen zu reagieren ist, brauchst du in solchen Fällen die observe() callbacks zur Sicherstellung der Kommunikation zwischen der Karte und der Meteor Collection. Zum Beispiel musst du auf die added und removed callbacks referenzieren um die API-eigenen dropPin()oder removePin() -Methoden aufzurufen.

Ein Deklaratives Vorgehen

Meteor stellt uns eine bessere Option zur Verfügung: Reactivity, welche in ihrem Kern ein deklaratives Vorgehen ist. Anstelle einer Beschreibung von Verhalten für jede möglich Änderung, definieren wir mittels der Deklaration die Beziehung zwischen den Objekten einmal und können uns von nun an darauf verlassen, dass ihr Zustand synchron bleibt.

Dies ist ein mächtiges Konzept, denn ein Echtzeit-System hat verschiedene Zugänge, die alle zu unvorhersagbarer Zeit Änderungen unterzogen werden können. Meteor löst für uns die Aufgabe diese Quellen zu überwachen und übernimmt die fehleranfällige Aufgabe das UI up to date zu halten.

Anstelle von Denken an observe callback Funktionen, lässt Meteor uns schreiben:

<template name="postsList">
  <ul>
    {{#each posts}}
      <li>{{title}}</li>
    {{/each}}
  </ul>
</template>

Und dann hol die Liste mit Posts so:

Template.postsList.helpers({
  posts: function() {
    return Posts.find();
  }
});

Hinter den Kulissen, hört Meteor die observe() callbacks für uns ab und passt die von reaktiven Datenänderungen betroffenen HTML-Stellen im Dokument an.

Abhängigkeits Tracking in Meteor: Computations

Auch wenn Meteor ein Echtzeit-Framework ist, ist nicht jeder Code in einer Meteor Application auch reaktiv. Wenn dies der Fall wäre, würde die ganze Applikation jedesmal neu ausgeführt wenn eine Datenänderung vorliegt. Stattdessen wird Meteor’s Reaktivität nur auf einzelne Gebiete des Codes angewendet. Diese nennen wir Computations.

Oder anders ausgedrückt: Eine Computation ist ein Code-Block, der jedesmal ausgeführt wird wenn eine in Abhängigkeit stehende Datenquelle ändert. Wenn du eine reaktive Datenquelle hast (zum Beispiel eine Session-Variabel) und möchtest dass diese reaktiv reagiert, musst du eine Computation dafür aufsetzen.

Wichtig: Normalerweise musst du diese Computation nicht explizit erstellen, da Meteor im Hintergrund für jedes gerenderte Template eine eigene Computation kreiert (das heisst dass Code in Template Helperfunktionen und Callbacks schon standartmässig reaktiv sind).

Jede reaktive Datenquelle verfolgt alle Computations die sich auf sie selber beziehen, damit sie die Computation wissen lassen kann, wann seine eigenen Werte geändert werden. Dies geschieht indem auf der zugehörigen Computation die invalidate() Methode aufgerufen wird.

Computations sind in der Regel so aufgebaut, dass sie ihren Inhalt immer dann neu evaluieren, wenn ihre invalidate() Funktion aufgerufen wird. Genau das passiert mit den Template Computations (Zusätzlich machen Template Computations noch weitere Magic um die Seite schneller neu aufzubauen). Auch wenn du bei Bedarf mehr Kontrolle über die Computations und deren Verhalten bei Invalidierung nehmen kannst, ist dies selten nötig, da in der Praxis das gewünschte Verhalten schon entsprechend eingebaut ist.

Eine Computation aufsetzen

Da wir jetzt die Theorie hinter Computations verstehen, können wir mit verhältnismässig wenig Aufwand solche erstellen. Wir brauchen einfach den Code Block einer Computation in die Deps.autorun Funktion zu packen um diesen reaktiv zu machen.

Deps.autorun(function() {
  console.log('There are ' + Posts.find().count() + ' posts');
});

Hinter den Kulissen kreiert autorun eine Computation und übergibt ihr Verantwortung zur Neu-Evaluierung, wann immer die abhängige Datenquelle ändert. Wir haben es beim obigen Beispiel mit einer sehr simplen Computation zu tun, die bloss die Anzahl von Posts in der Konsole ausgibt. Da Posts.find() eine reaktive Datenquelle ist, kümmert sie sich selber darum, der Computation mitzuteilen dass sie sich immer dann neu evaluiert, wenn ein Post ändert.

> Posts.insert({title: 'New Post'});
There are 4 posts.

Dies führt dazu, dass wir Code welcher reaktive Daten verwendet, mit relativ wenig Aufwand schreiben können. Wir wissen dass im Hintergrund das Abhängigkeitssystem sich um die erneute Ausführung zur richtigen Zeit kümmern wird.

Beiträge erstellen

7

Wir haben nun gesehen wie einfach es ist, Beiträge über die Konsole mit dem Datenbankaufruf Posts.insert anzulegen. Aber wir können natürlich nicht erwarten, dass unsere Benutzer die Konsole benutzen.

Also brauchen wir ein Benutzer-Interface, um unseren Benutzern die Möglichkeit zu geben, neue Beiträge anzulegen.

Aufbau der Seite zum Anlegen eines Beitrages

Wir fangen an, indem wir eine neue Route für eine Seite definieren:

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  waitOn: function() { return Meteor.subscribe('posts'); }
});

Router.map(function() {
  this.route('postsList', {path: '/'});

  this.route('postPage', {
    path: '/posts/:_id',
    data: function() { return Posts.findOne(this.params._id); }
  });

  this.route('postSubmit', {
    path: '/submit'
  });
});
lib/router.js

Wir benutzen die Funktion data des Routers, um den Kontext postPage des Templates zu setzen. Zur Erinnerung: Was immer wir in den Kontext legen, wird als this innerhalb des Template-Helpers verfügbar sein.

Hinzufügen eines Links zur Überschrift

Da die Route nun angelegt ist, können wir nun einen Link darauf in der Navigation unserer Seite hinzufügen:

<template name="header">
  <header class="navbar">
    <div class="navbar-inner">
      <a class="btn btn-navbar" data-toggle="collapse" data-target=".nav-collapse">
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
      </a>
      <a class="brand" href="{{pathFor 'postsList'}}">Microscope</a>
      <div class="nav-collapse collapse">
        <ul class="nav">
          <li><a href="{{pathFor 'postSubmit'}}">New</a></li>
        </ul>
        <ul class="nav pull-right">
          <li>{{loginButtons}}</li>
        </ul>
      </div>
    </div>
  </header>
</template>
client/views/includes/header.html

Das Anlegen der Route bedeutet, dass wenn der Benutzer die URL /submit aufruft, Meteor das Template postsubmit anzeigen wird. Wir legen dieses Template nun an:

<template name="postSubmit">
  <form class="main">
    <div class="control-group">
        <label class="control-label" for="url">URL</label>
        <div class="controls">
            <input name="url" type="text" value="" placeholder="Your URL"/>
        </div>
    </div>

    <div class="control-group">
        <label class="control-label" for="title">Title</label>
        <div class="controls">
            <input name="title" type="text" value="" placeholder="Name your post"/>
        </div>
    </div>

    <div class="control-group">
        <label class="control-label" for="message">Message</label>
        <div class="controls">
            <textarea name="message" type="text" value=""/>
        </div>
    </div> 

    <div class="control-group">
        <div class="controls">
            <input type="submit" value="Submit" class="btn btn-primary"/>
        </div>
    </div>
  </form>
</template>

client/views/posts/post_submit.html

Bemerkung: Das ist einiges an Markup. Das wird durch die Benutzung von Twitters Bootstrap verursacht. Eigentlich sind nur die Formularelemente wichtig. Das restliche Markup hilft aber, unser App ein wenig besser aussehen zu lassen. Das gerenderte Template sollte nun wie folgt aussehen:

Das Formular zum Anlegen von Beiträgen
Das Formular zum Anlegen von Beiträgen

Das ist ein einfaches Formular. Wir brauchen uns über die ´Action´ des Formulars keine Gedanken zu machen. Den Submit-Event des Formulars werden wir ohnehin abfangen und die Daten per JavaScript aktualisieren. (Es macht auch keinen Sinn einen Fallback für nicht vorhandenes JavaScript im Browser einzurichten, da eine Meteor-App ohne JavaScript nicht funktioniert.)

Das Anlegen von Beiträgen

Wir verbinden nun das Formular mit einem Event-Handler. Am besten verwenden wir dafür den submit-Event (anstatt z.B. den Click-Event des Buttons). Das hat den Vorteil, dass alle möglichen Wege für das Absenden des Formulars abgedeckt werden (wie zB. das Benutzen der Return-Taste im URL-Feld).

Template.postSubmit.events({
  'submit form': function(e) {
    e.preventDefault();

    var post = {
      url: $(e.target).find('[name=url]').val(),
      title: $(e.target).find('[name=title]').val(),
      message: $(e.target).find('[name=message]').val()
    }

    post._id = Posts.insert(post);
    Router.go('postPage', post);
  }
});
client/views/posts/post_submit.js

Commit 7-1

Added a submit post page and linked to it in the header.

Diese Funktion verwendet jQuery, um die Werte aus den verschiedenen Formularfeldern zu entnehmen und ein neues Beitrags-Objekt anzulegen. Wir müssen dabei beachten, dass die Methode preventDefault() auf dem Parameter event in unserem Handler aufgerufen wird. Ansonsten würde der Browser versuchen, dass Formular abzusenden.

Am Ende können wir per Router auf die Seite des neuen Beitrag umleiten. Die Funktion insert() einer Collection gibt die erzeugte id des Objektes zurück, welche in die Datenbank eingefügt wurde. Die Funktion go() des Routers erzeugt uns die URL aus ihren Parametern.

Das Resultat ist, dass wenn der Benutzer auf den Absende-Button drückt, ein Beitrag angelegt wird und der Benutzer auf die Diskussionsseite für diesen Beitrag gelangt.

Etwas mehr Sicherheit

Das Anlegen von Beiträgen funktioniert soweit. Aber wir wollen nicht, dass jeder x-beliebige Besucher der Webseite Beiträge anlegen kann. Wir möchten, dass Benutzer dafür eingeloggt sein müssen. Sicherlich könnten wir das Formular zum Anlegen von neuen Beiträgen vor ausgeloggten Benutzern verstecken. Aber ein findiger Benutzer könnte die Beiträge immer noch auf der Konsole anlegen. Das wollen wir natürlich nicht.

Datensicherheit ist glücklicherweise direkt in den Meteor Collections eingebaut. Sie ist nur standardmässig deaktiviert, wenn ein neues Projekt angelegt wurde. Das erlaubt es einfach mit dem Aufbau der App anzufangen und den langweiligen Teil später zu erledigen.

Diese Stützräder braucht unsere App jetzt nicht mehr. Wir nehmen sie nun weg. Wir entfernen das Package insecure:

$ meteor remove insecure
Terminal

Danach wirst du feststellen, das das Formular nicht mehr funktioniert. Ohne das Package insecure sind client-seitige Einfügeoperationen in Collections nicht mehr erlaubt. Wir müssen entweder explizite Regeln anlegen, um Meteor mitzuteilen, dass es OK ist auf dem Client Objekte anzulegen oder wir müssen das Einfügen auf die Server-Seite verlegen.

Das Einfügen von Beiträgen wieder erlauben

Wir zeigen nun, wie man das client-seitige Anlegen von Beiträgen erlaubt. Damit wird das Formular wieder funktionsfähig. Später werden wir zwar eine andere Technik benutzen, aber im Moment ist der folgende Code der einfachere Weg:

Posts = new Meteor.Collection('posts');

Posts.allow({
  insert: function(userId, doc) {
    // only allow posting if you are logged in
    return !! userId;
  }
});
collections/posts.js

Commit 7-2

Removed insecure, and allowed certain writes to posts.

Wir rufen die Methode allow() auf der Posts-Collection auf. Diese teilt Meteor mit unter welchen Umständen Clients einen Beitrag einfügen dürfen. In diesem Fall sagen wir “Clients dürfen neue Beiträge anlegen, wenn sie eine userId besitzen”.

Die userId des Benutzers, der das Anlegen des Beitrags vornimmt, wird an die Aufrufe von allow und deny weitergeleitet (oder liefert null, wenn kein Benutzer eingeloggt ist). Da die Benutzer-Accounts im Kern von Meteor verankert sind, können wir uns darauf verlassen, dass die userId immer korrekt gesetzt ist.

Wir haben also sichergestellt, dass der Benutzer immer eingeloggt sein muss, um einen Beitrag anzulegen. Versuch einmal, dich auszuloggen und einen Beitrag anzulegen. Du solltest Folgendes in der Konsole sehen:

Insert failed: Access denied
Insert failed: Access denied

Aber wir sind noch nicht fertig:

  • Ausgeloggte Benutzer können immer noch das Anlegeformular sehen.
  • Der Beitrag ist noch nicht mit dem Benutzer verknüpft (und es gibt keinen Code auf dem Server um dies sicherzustellen)
  • Mehrere Beiträge können angelegt werden, die die selbe URL enthalten.

Versuchen wir diese Probleme anzugehen.

Absichern des Zugangs zum Anlegeformular eines Beitrages

Wir fangen an, indem wir ausgeloggte Benutzer daran hindern, das Anlegeformular aufzurufen. Wir werden dies im Router implementieren, in dem wir einen route hook definieren.

Ein Hook fängt den Routing-Prozess ab und kann die Aktionen, die ein Router vornimmt verändern. Du kannst es als eine Art Wachmann betrachten, der deinen Ausweis überprüft, bevor er Dich hereinlässt (oder abweist).

Dazu müssen wir überprüfen, ob ein Benutzer eingeloggt ist. Wenn nicht muss anstelle des Templates postSubmit das Template accessDenied ausgegeben werden. Der Router wird dazu wie folgt angepasst:

Router.configure({
  layoutTemplate: 'layout'
});

Router.map(function() {
  this.route('postsList', {path: '/'});

  this.route('postPage', {
    path: '/posts/:_id',
    data: function() { return Posts.findOne(this.params._id); }
  });

  this.route('postSubmit', {
    path: '/submit'
  });
});

var requireLogin = function() {
  if (! Meteor.user()) {
    this.render('accessDenied');
    this.stop();
  }
}

Router.before(requireLogin, {only: 'postSubmit'});
lib/router.js

Wir legen jetzt ein Template für den verweigerten Zugriff an:

<template name="accessDenied">
  <div class="alert alert-error">You can't get here! Please log in.</div>
</template>
client/views/includes/access_denied.html

Commit 7-3

Denied access to new posts page when not logged in.

Wenn du nun http://localhost:3000/submit/ aufrufst ohne eingeloggt zu sein, solltest du Folgendes sehen:

Das Template für den verweigerten Zugriff
Das Template für den verweigerten Zugriff

Was Routing-Hooks richtig nett macht ist, dass sie reaktiv sind. Das bedeutet, wir können deklarativ arbeiten und müssen uns nicht um Callbacks oder ähnliches kümmern, wenn der Benutzer sich einloggt. Wenn sich der Login-Zustand eines Benutzers ändert, wird das Seitentemplate sofort von accessDenied zu postSubmit geändert - ohne das wir explizit Code dafür schreiben müssen.

Log dich ein und versuch dann die Seite neu zu laden. Du wirst eventuell das Template “Zugriff verweigert” für einen kurzen Moment aufflackern sehen, bevor das Anlageformular erscheint. Der Grund dafür ist, dass Meteor Templates so früh wie möglich rendert. Es kann sein, dass dies früher geschieht als der Server mitteilen kann, ob der Benutzer eingeloggt ist. (Der Zustand wird übrigens im LocalStorage des Browsers zwischengespeichert.)

Um dieses Problem zu verhindern (es handelt sich um ein Problem, welches Du häufiger sehen wirst, wenn Du Dich mit den Eigenheiten von Latenz zwischen Server und Client beschäftigst), werden wir für eine kurze Zeit einen Ladebildschirm anzeigen und warten bis feststeht, ob der Benutzer eingeloggt ist oder nicht.

Dies ist notwendig, da wir ohne den Server nicht entscheiden können, ob der Benutzer eine korrekte Authorisierung vorgenommen hat oder nicht. Solange können wir weder das Template accessDenied noch das Template postSubmit anzeigen.

Wir ändern also unseren Hook, um unser Lade-Template anzuzeigen. Dies bleibt solange Meteor.loggingIn() den Wert true zurückliefert:

Router.map(function() {
  this.route('postsList', {path: '/'});

  this.route('postPage', {
    path: '/posts/:_id',
    data: function() { return Posts.findOne(this.params._id); }
  });

  this.route('postSubmit', {
    path: '/submit'
  });
});

var requireLogin = function() {
  if (! Meteor.user()) {
    if (Meteor.loggingIn())
      this.render(this.loadingTemplate);
    else
      this.render('accessDenied');

    this.stop();
  }
}

Router.before(requireLogin, {only: 'postSubmit'});
lib/router.js

Commit 7-4

Show a loading screen while waiting to login.

Links verstecken

Die einfachste Möglichkeit ausgeloggte Benutzer daran zu hindern, versehentlich das Anlegeformular zu erreichen, ist es den Link zu verstecken. Das geht ziemlich einfach:

<ul class="nav">
  {{#if currentUser}}<li><a href="{{pathFor 'postSubmit'}}">Submit Post</a></li>{{/if}}
</ul>
client/views/includes/header.html

Commit 7-5

Only show submit post link if logged in.

Der Helper currentUser wird durch das Package accounts und sein Handlebar-Äquivalent Meteor.user() zur Verfügung gestellt. Weil der Helper ebenfalls reaktiv ist, wird der Link angezeigt, sobald Du Dich einloggst. Wenn Du Dich ausloggst verschwindet er automatisch.

Die Meteor-Methode: Bessere Abstraktion und Sicherheit

Wir haben es geschafft, den Zugriff auf das Anlegeformular für ausgeloggte Benutzer zu beschränken. Auch ist es jetzt für sie nicht mehr möglich, auf der Konsole Beiträge anzulegen. Aber ein paar Dinge bleiben noch übrig, um die wir uns kümmern müssen:

  • Zeitstempel für die Beiträge
  • Verhindern von zwei Beiträgen, die die selbe URL benutzen
  • Details über den Author des Beitrags (id, Benutzername, usw.)

Du wirst dir vielleicht denken, dass wir dass alles im Event-Handler submit erledigen können. Realistisch betrachtet, würde das allerdings zu einer Menge Probleme führen.

  • Für den Timestamp müssten wir uns darauf verlassen, dass der Computer des Benutzers die korrekte Uhrzeit hat. Das ist leider nicht immer der Fall.
  • Clients kennen nur einen Teil der benutzten URLS. Nämlich die, deren Beiträge sie sehen können (wir werden uns nachher anschauen, wie das genau funktioniert). Also gibt es keinen zuverlässigen Weg, client-seitig die Eindeutigkeit der URL zu überprüfen.
  • Schließlich, obwohl wir die Benutzerdaten client-seitig hinzufügen könnten, können wir nicht gewährleisten, dass alle Angaben stimmen. Das könnte von findigen Personen in der Browser-Konsole missbraucht werden.

Aus all diesen Gründen ist es besser, wenn wir unsere Event-Handler einfach halten - und wenn wir mehr als einfachste Einfüge- oder Update-Operationen benötigen, benutzen wir eine Methode

Eine Meteor-Methode ist eine serverseitige Funktion, die vom Client aufgerufen wird. Genau genommen kennen wir sie schon – hinter den Kulissen sind die insert, update und remove-Funktionen der Collection allesamt Methoden. Schauen wir uns mal an, wie wir selber welche erzeugen können.

Lass uns dazu noch mal die Datei post_submit.js anschauen. Anstelle direkt in die Collection Posts einzufügen, rufen wir nun eine Methode namens post auf:

Template.postSubmit.events({
  'submit form': function(e) {
    e.preventDefault();

    var post = {
      url: $(e.target).find('[name=url]').val(),
      title: $(e.target).find('[name=title]').val(),
      message: $(e.target).find('[name=message]').val()
    }

    Meteor.call('post', post, function(error, id) {
      if (error)
        return alert(error.reason);

      Router.go('postPage', {_id: id});
    });
  }
});
client/views/posts/post_submit.js

Die Funktion Meteor.call ruft eine Methode auf, die durch ihren ersten Parameter spezifiziert wird. Du kannst weitere Parameter (in diesem Fall das Objekt post, welches wir aus dem Formular zusammengebaut haben) und einen Callback übergeben. Der Callback wird ausgeführt, wenn die Methode auf dem Server abgearbeitet ist. In diesem Fall geben wir Rückmeldung an den Benutzer, ob Probleme aufgetreten sind oder leiten ihn auf die Diskussionsseite für den Beitrag weiter.

Danach definieren wir die neue Methode in der Datei collections/posts.js. Wir entfernen den Block allow(), weil Meteor-Methoden diesen ohnehin umgehen. Du erinnerst dich vielleicht: Methoden werden auf dem Server ausgeführt. Meteor nimmt an, dass diese deshalb vertrauenswürdig sind.

Posts = new Meteor.Collection('posts');

Meteor.methods({
  post: function(postAttributes) {
    var user = Meteor.user(),
      postWithSameLink = Posts.findOne({url: postAttributes.url});

    // ensure the user is logged in
    if (!user)
      throw new Meteor.Error(401, "You need to login to post new stories");

    // ensure the post has a title
    if (!postAttributes.title)
      throw new Meteor.Error(422, 'Please fill in a headline');

    // check that there are no previous posts with the same link
    if (postAttributes.url && postWithSameLink) {
      throw new Meteor.Error(302, 
        'This link has already been posted', 
        postWithSameLink._id);
    }

    // pick out the whitelisted keys
    var post = _.extend(_.pick(postAttributes, 'url', 'title', 'message'), {
      userId: user._id, 
      author: user.username, 
      submitted: new Date().getTime()
    });

    var postId = Posts.insert(post);

    return postId;
  }
});
collections/posts.js

Commit 7-6

Use a method to submit the post.

Diese Methode ist ein wenig komplizierter, aber wir hoffen du kannst uns folgen.

Zuerst definieren wir die Variable user. Wir überprüfen, ob ein Beitrag mit dem selben Link schon existiert. Dann wird geschaut, ob der Benutzer eingeloggt ist. Ein Fehler wird geworfen, wenn das nicht der Fall ist (der Fehler kann später im Browser angezeigt werden). Wir validieren danach den Beitrag auf einfache Weise, um sicher zu gehen, dass der Beitrag einen Titel hat.

Als Nächstes, falls ein weiterer Beitrag mit der selben URL existiert, werfen wir einen Fehler 302 (der einen Redirect entspricht). Dadurch können wir dem Benutzer mitteilen, dass er sich den vorherigen Beitrag anschauen soll.

Meteors Klasse Error nimmt drei Parameter auf. Der erste (error) ist in diesem Fall der numerische Code 302. Der Zweite (reason) ist eine kurze menschenlesbare Fassung des Fehlers. Der dritte (details) kann dazu genutzt werden hilfreiche zusätzliche Information weiterzugeben.

In unserem Fall, benutzen wir den dritten Parameter, um die ID des bereits existierenden Beitrags weiterzureichen. Spoiler: Wir werden dies später benutzen, um den Benutzer auf die Seite des vorherigen Beitrags weiterzuleiten.

Wenn all diese Überprüfungen erfolgreich waren, übernehmen wir lediglich die Felder des Objektes, die wir einfügen wollen (um zu vermeiden, dass der Benutzer weitere Felder in unsere Datenbank einfügen kann, z.B. in dem er die Konsole verwendet). Ausserdem fügen wir zusätzliche Information über den Benutzer, sowie den Zeitpunkt des Anlegens in den Beitrag ein.

Als Letztes fügen wir den Beitrag ein und geben die ID des erzeugten Objekts zurück.

Sortieren von Beiträgen

Jetzt, da alle Beiträge über ein Datum verfügen, macht es Sinn danach zu sortieren. Um das zu erreichen, können wir Mongos Operator sort verwenden. Dieser erwartet, dass ein Objekt aus Schlüsseln besteht, nach deren Werten sortiert werden kann. Zusätzlich gibt ein Vorzeichen an, ob wir aufsteigend oder absteigend sortieren.

Template.postsList.helpers({
  posts: function() {
    return Posts.find({}, {sort: {submitted: -1}});
  }
});
client/views/posts/posts_list.js

Commit 7-7

Sort posts by submitted timestamp.

Es hat ein wenig Arbeit erfordert, aber wir haben nun ein Benutzerinterface, welches es unseren Benutzer erlaubt, auf abgesicherte Art und Weise Daten in unsere App einzugeben.

Aber jede App, die den Benutzer Inhalte anlegen lässt, muss auch einen Weg anbieten diese zu ändern und zu löschen. Das werden wir im Kapitel “Ändern von Beiträgen” behandeln.

Latency Compensation

Sidebar 7.5

Im letzten Kapitel führten wir ein neues Konzept der Meteor-Welt ein: Methoden.

Without latency compensation
Without latency compensation

Eine Meteor Methode ist die Möglichkeit, eine Abfolge von Befehlen auf dem Server in einer strukturierten Weise auszuführen. In unserem Beispiel brauchten wir eine Methode, weil wir sicherstellen wollten, dass dem neuen Post auch der Name des Autors und die aktuelle Serverzeit hinzugefügt wird.

Wenn eine Meteor Methode allerdings einfach ausgeführt wird, haben wir ein Problem. Stelle dir folgenden Ablauf vor: (Anmerkung: Die Timestamps sind zufällig generiert und nur für illustrative Zwecke gedacht)

  • +0ms: Der Benutzer klickt den Senden-Button und der Browser feuert einen Methodenaufruf.
  • +200ms: Der Server macht Änderungen an der Mongo Datenbank.
  • +500ms: Der Client erhält diese Änderungen und aktualisiert das UI.

Wenn Meteor so operieren würde, dann wäre da eine kurze Verzögerung zwischen der Ausführung solcher Aktionen und der Abbildung der Änderungen (Diese Verzögerung wäre je nach geografischer Nähe zum Server grösser oder kleiner). Für eine moderne Webapplikation ist dies nicht akzeptabel.

Latenz Kompensierung

With latency compensation
With latency compensation

Um diese Probleme zu vermeiden, führt Meteor das Konzept der Latenz-Kompensation ein. Die Definition der post Methode wurde in einer Datei im collection/ Ordner gemacht. Das heisst, dass der Code auf dem Server und dem Client verfügbar ist und auf beiden Seiten zur selben Zeit ausgeführt wird.

Wenn du eine Methode aufrufst, sendet der Client diesen Aufruf an den Server, simuliert aber simultan dazu die Aktion auch auf der Client Collection aus. Somit sieht unser Workflow wie folgt aus:

  • +0ms: Der Benutzer klickt auf den Senden Knopf und der Browser feuert den Methodenaufruf ab.
  • +0ms: Der Client simuliert die Aktion des Methodenaufrufs auf der Client Collection und ändert das UI entsprechend.
  • +200ms: Der Server macht Änderungen an der Mongo Datenbank.
  • +500ms: Der Client erhält diese Änderungen unmittelbar vom Server zurück, macht die simulierten Änderungen rückgängig und ersetzt sie mit denen vom Server (In der Regel sind diese identisch). Das UI ändert dementsprechend.

Latenz-Kompensation beobachten

Wir können eine kleine Änderung an der post Methode vornehmen, um das Konzept in Aktion zu beobachten. Dafür müssen wir fortgeschrittene Coding-Technik im npm futures Package anwenden, damit das Einfügen von Objekten in die Server Collection in unserer Methode künstlich verzögert wird.

Wir benützen isSimulation um Meteor zu fragen, ob die Methode im aktuellen Kontext als “stub” ausgeführt wird. Ein stub ist eine Methodensimulation, welche Meteor auf dem Client ausführt, während die “wirkliche” Methode auf dem Server ausgeführt wird.

So werden wir Meteor fragen, ob der Code gerade auf dem Client ausgeführt wird. Wenn ja fügen wir den String am Ende des Titels vom Post hinzu, wenn nicht, fügen wir den String auf dem Server hinzu:

Meteor.methods({
  post: function(postAttributes) {
    // […]

    // pick out the whitelisted keys
    var post = _.extend(_.pick(postAttributes, 'url', 'message'), {
      title: postAttributes.title + (this.isSimulation ? '(client)' : '(server)'),
      userId: user._id, 
      author: user.username, 
      submitted: new Date().getTime()
    });

    // wait for 5 seconds
    if (! this.isSimulation) {
      var Future = Npm.require('fibers/future');
      var future = new Future();
      Meteor.setTimeout(function() {
        future.return();
      }, 5 * 1000);
      future.wait();
    }

    var postId = Posts.insert(post);

    return postId;
  }
});
collections/posts.js

Bemerkung: Für den Fall dass es dich wundert, das this in this.isSimulation ist ein Methodenaufruf-Objekt das Zugriff auf verschiedene nützliche Variablen zur Verfügung stellt.

Wie genau Futures funktioniert, ist ausserhalb des Scopes dieses Buches, wir haben einfach ausgedrückt Meteor mitgeteilt, 5 Sekunden Pause zu machen bevor die Daten in die Server-Collection eingefügt werden.

Wir machen auch einen redirect zur Post Liste:

Template.postSubmit.events({
  'submit form': function(event) {
    event.preventDefault();

    var post = {
      url: $(event.target).find('[name=url]').val(),
      title: $(event.target).find('[name=title]').val(),
      message: $(event.target).find('[name=message]').val()
    }

    Meteor.call('post', post, function(error, id) {
      if (error)
        return alert(error.reason);
    });
    Router.go('postsList');
  }
});
client/views/posts/post_submit.js

Commit 7-5-1

Demonstrate the order that posts appear using a sleep.

Wenn wir jetzt einen neuen Post kreieren, sehen wir die Latenz-Kompensation eindeutig. Zuerst wird ein Post clientseitig eingefügt (client) im Titel (als erster Post, linkt zu github)

Our post as first stored in the client collection
Our post as first stored in the client collection

Dann 5 Sekunden später wird dieser sauber mit dem Dokument vom Server ersetzt:

Our post once the client receives the update from the server collection
Our post once the client receives the update from the server collection

Client Collection Methoden

Man könnte denken Methoden seien kompliziert, aber in der Tat sind sie ziemlich simpel. Wir haben nämlich schon drei sehr einfache Methoden kennengelernt: Die Collection Mutations-Methoden: insert, update und remove.

Wenn du eine Server-Collection namens posts kreierst, definierst du automatisch drei Methoden: posts/insert, posts/update und posts/delete. Mit anderen Worten: Wenn du Posts.insert() in einer Client-Collection aufrufst, rufst du eine latenz-kompensierte Methode auf, die zwei Dinge macht:

  1. Sie überprüft, ob wir die Mutation vornehmen dürfen (mittels allow und deny feedback (dies braucht in der Simulation allerdings nicht passieren))
  2. Sie führt die eigentliche Modifikation auf dem Data-Store aus.

Methoden rufen Methoden auf

Wenn du aufgepasst hast, hast du vielleicht festgestellt, dass unsere post Methode eine andere Methode (posts/insert) aufruft, sobald wir unseren post einfügen. Wie funktioniert das?

Wenn die Simulation (Client-side Version der Methode) ausgeführt ist, führen wir insert’s Simulation aus (also wir fügen den post in unsere Client Collection ein), wenn die Server-side post Methode insert aufgerufen wird, brauchen wir uns nicht um die Simulation zu kümmern und das Einfügen findet lautlos statt.

Beiträge ändern

8

Da wir nun Beiträge anlegen können, ist der nächste Schritt, sie änderbar und löschbar zu machen. Der Code für das UI ist relativ einfach, deshalb nutzen wir die Gelegenheit, um in diesem Kapitel darüber zu sprechen, wie Meteor mit Benutzerberechtigungen umgeht.

Im ersten Schritt erweitern wir unseren Router. Wir fügen eine Route zur Seite zum Ändern von Beitragen ein und setzen den Kontext dieser Seite entsprechend.

Router.configure({
  layoutTemplate: 'layout'
});

Router.map(function() {
  this.route('postsList', {path: '/'});

  this.route('postPage', {
    path: '/posts/:_id',
    data: function() { return Posts.findOne(this.params._id); }
  });

  this.route('postEdit', {
    path: '/posts/:_id/edit',
    data: function() { return Posts.findOne(this.params._id); }
  });

  this.route('postSubmit', {
    path: '/submit'
  });
});

var requireLogin = function() {
  if (! Meteor.user()) {
    if (Meteor.loggingIn())
      this.render('loading')
    else
      this.render('accessDenied');

    this.stop();
  }
}

Router.before(requireLogin, {only: 'postSubmit'});
lib/router.js

Das Template für die Beitragsänderung

Jetzt können wir uns auf das dazugehörige Template konzentrieren. Unser Template postEdit wird ein ziemlich gewöhnliches Formular:

<template name="postEdit">
  <form class="main">
    <div class="control-group">
        <label class="control-label" for="url">URL</label>
        <div class="controls">
            <input name="url" type="text" value="{{url}}" placeholder="Your URL"/>
        </div>
    </div>

    <div class="control-group">
        <label class="control-label" for="title">Title</label>
        <div class="controls">
            <input name="title" type="text" value="{{title}}" placeholder="Name your post"/>
        </div>
    </div>

    <div class="control-group">
        <div class="controls">
            <input type="submit" value="Submit" class="btn btn-primary submit"/>
        </div>
    </div>
    <hr/>
    <div class="control-group">
        <div class="controls">
            <a class="btn btn-danger delete" href="#">Delete post</a>
        </div>
    </div>
  </form>
</template>
client/views/posts/post_edit.html

Und hier ist der dazu gehörige Manager post_edit.js:

Template.postEdit.events({
  'submit form': function(e) {
    e.preventDefault();

    var currentPostId = this._id;

    var postProperties = {
      url: $(e.target).find('[name=url]').val(),
      title: $(e.target).find('[name=title]').val()
    }

    Posts.update(currentPostId, {$set: postProperties}, function(error) {
      if (error) {
        // display the error to the user
        alert(error.reason);
      } else {
        Router.go('postPage', {_id: currentPostId});
      }
    });
  },

  'click .delete': function(e) {
    e.preventDefault();

    if (confirm("Delete this post?")) {
      var currentPostId = this._id;
      Posts.remove(currentPostId);
      Router.go('postsList');
    }
  }
});
client/views/posts/post_edit.js

Mittlerweile sollte dir der meiste Code bekannt vorkommen.

Wir haben zwei Event-Callbacks für das Template definiert: eines für das submit Event des Formulars und eines für das click Event des Löschen-Links.

Der Callback zum Löschen ist sehr einfach aufgebaut: Nach dem Unterdrücken des Default-Events, wird eine Bestätigung durch den Benutzer angefordert. Wenn wir diese erhalten, holen wir die ID des aktuellen Beitrags aus dem Kontext und löschen den Beitrag unter Verwendung dieser ID. Am Ende wird der Benutzer zur Homepage geleitet.

Der Callback zum Aktualisieren ist ein wenig länger, aber nicht wesentlich komplizierter: Nach dem Unterdrücken des Default-Events und dem Ermitteln der ID des Beitrags, entnehmen wir die neuen Werte aus dem Zielobjekt des Events und speichern diese in einem Objekt namens postProperties.

Dieses Objekt übergeben wir an die Meteor-Methode Collection.update(), zusammen mit einem Callback als letzten Parameter der Methode. Der Callback wird am Ende der Operation ausgeführt. Im Fehlerfall wird der Grund für den Fehler ausgegeben. Im Falle eines Erfolgs leitet der Callback den Benutzer auf die Ansichts-Seite des Beitrags zurück.

Hinzufügen von Links

Es fehlen noch Berarbeiten-Links in unseren Beiträgen. Erst mit diesen, haben unsere Benutzer die Möglichkeit die Seite für das Ändern von Beiträgen zu erreichen.

<template name="postItem">
  <div class="post">
    <div class="post-content">
      <h3><a href="{{url}}">{{title}}</a><span>{{domain}}</span></h3>
      <p>
        submitted by {{author}}
        {{#if ownPost}}<a href="{{pathFor 'postEdit'}}">Edit</a>{{/if}}
      </p>
    </div>
    <a href="{{pathFor 'postPage'}}" class="discuss btn">Discuss</a>
  </div>
</template>
client/views/posts/post_item.html

Natürlich wollen wir nur einen Bearbeiten-Link anzeigen, wenn dem Benutzer der Beitrag auch gehört. Das erledigen wir mit dem Helper ownPost:

Template.postItem.helpers({
  ownPost: function() {
    return this.userId == Meteor.userId();
  },
  domain: function() {
    var a = document.createElement('a');
    a.href = this.url;
    return a.hostname;
  }
});
client/views/posts/post_item.js
Formular für das Ändern von Beiträgen.
Formular für das Ändern von Beiträgen.

Commit 8-1

Added edit posts form.

Unser Änderungsformular für Beiträge sieht gut aus. Aber das eigentliche Ändern der Beiträge ist noch gar nicht möglich. Warum ist das so?

Einrichtung der Berechtigungen

Da wir im letzten Kapitel das Package insecure entfernt haben, werden alle client-seitigen Änderungen derzeit abgewiesen.

Um Änderungen wieder zu ermöglichen, werden wir Berechtigungsregeln anlegen. Zunächst erzeuge die neue Datei permissions.js im Verzeichnis lib. Diese wird unsere Berechtigungslogik als Erstes laden und ist sowohl auf dem Server als auch auf dem Client verfügbar.

// check that the userId specified owns the documents
ownsDocument = function(userId, doc) {
  return doc && doc.userId === userId;
}
lib/permissions.js

Im Kapitel Beiträge anlegen, haben wir die Methode allow() entfernt, da wir neue Posts nur noch per Server-Methode angelegt haben (auf diese Weise umgehen wir allow() sowieso).

Aber jetzt, wo wir Beiträge im Client ändern und löschen, schauen wir uns noch mal die Datei posts.js an. Wir fügen den folgenden Block allow() wieder hinzu:

Posts = new Meteor.Collection('posts');

Posts.allow({
  update: ownsDocument,
  remove: ownsDocument
});

Meteor.methods({
  ...
collections/posts.js

Commit 8-2

Added basic permission to check the post’s owner.

Einschränkung von Änderungen

Nur weil du deine Beiträge ändern darfst soll das nicht heissen, dass dies für jede Eigenschaft des Beitrags gilt. Zum Beispiel wollen wir nicht, dass ein Benutzer einen Beiträg erstellen kann und ihn dann jemand anderem zuweist.

Wir benutzen hierfür Meteors Callback deny() um sicherzustellen, dass Benutzer nur angegebene Felder ändern können:

Posts = new Meteor.Collection('posts');

Posts.allow({
  update: ownsDocument,
  remove: ownsDocument
});

Posts.deny({
  update: function(userId, post, fieldNames) {
    // may only edit the following two fields:
    return (_.without(fieldNames, 'url', 'title').length > 0);
  }
});
collections/posts.js

Commit 8-3

Only allow changing certain fields of posts.

Als Parameter erhalten wir das Array fieldNames, dass eine Liste der Felder enthält, die geändert werden sollen. Durch die Verwendung von Underscores Methode without() reduzieren wir das Array um die Werte url und title.

Im Normalfall sollte dieses Ergebnis-Array leer sein, denn nur url und title sollen geändert werden können. Wenn jemand etwas komisches probiert, wird die Länge des Arrays grösser gleich 1 sein. Dann liefert der Callback den Wert true zurück (und verhindert die Änderung).

Methodenaufrufe vs. Clientseitige Datenmanipulation

Um Beiträge zu erzeugen, benutzen wir die Server-Methode post. Aber zum Ändern und Löschen, rufen wir die Methoden update und remove direkt auf dem Client auf und regeln die Berechtigung mit allow und deny.

Wann ist es sinnvoll das Eine und wann das Andere zu verwenden?

Wenn der Sachverhalt unkompliziert ist und du die Regeln mit allow und deny adequat festlegen kannst, ist es normalerweise einfacher und weniger aufwendig, diese Dinge direkt auf dem Client zu regeln.

Das Manipulieren der Daten auf dem Client trägt zum Gefühl von Direktheit und einem besseren Benutzererlebnis bei. Fehlerzustände müssen aber gesondert berücksichtigt und elegant eingeflochten werden. Zum Beispiel: der Server meldet sich asynchron zurück und teilt mit, dass die Änderung doch nicht stattgefunden hat.

Aber, die Erstellung und Änderung von Daten, die der Benutzer nicht direkt manipulieren darf (zum Beispiel die Vergabe von Zeitstempeln oder die Zuordnung eines Benutzers), sollten besser in einer Server-Methode erfolgen.

Server-Methoden sind auch in folgenden Szenarien eher angebracht:

  • Wenn du Daten und Ergebnisse direkt per Callback benötigst und nicht darauf warten willst, dass diese über den Reaktivitätsmechanismus synchronisiert werden.
  • Für Datenbankoperationen, bei denen viele Elemente geändert werden und damit eine große Menge an Daten übertragen werden muss.
  • Um Daten zusammenzufassen oder zu aggregieren (z.B. Anzahl der Elemente, Mittelwerte oder Summen)

Allow and Deny

Sidebar 8.5

Meteors Security System gibt uns die Kontrolle über Datenbankmodifikationen, ohne jedesmal Methoden zu definieren, wenn wir Änderungen machen.

Es machte viel Sinn, Helferaufgaben für Posts, wie Duplikatskontrolle von URLs oder das Hinzufügen von Zusatzeigenschaften, bei der Erstellung von Posts zu implementieren.

Auf der anderen Seite haben wir nicht wirklich neue Methoden für das Aktualisieren oder das Löschen von Posts geschrieben. Wir mussten nur überprüfen, ob der Benutzer die Berechtigung besitzt, diese Aktionen auszuführen. Dies wurde uns einfach gemacht mit der Benutzung der allow und deny Callbacks.

Wenn wir diese Callbacks benutzen, sind wir explizit bezüglich Datenbank-Änderungen und definieren welche Arten von Aktualisierungen benutzt werden dürfen. Die Tatsache dass sie im Accounts-System integriert sind, ist ein zusätzliches Plus.

Mehrfach Callbacks

Wir können so viele allow Callbacks definieren wie wir brauchen. Nur einer von Ihnen muss true zurückgeben. Wenn also Posts.insert() im Browser aufgerufen wird (egal ob von unserer App’s Client Code oder von der Console), ruft der Server alle allow Definitionen auf, bis er eine findet die true zurückgibt. Wenn er keine findet, erlaubt er den Insert nicht und wirft eine 403 Fehlermeldung.

Ähnlich dazu können wir eine oder mehrere deny Callbacks definieren. Wenn ein Callback true zurückgibt, wird die Änderung abgebrochen und eine 403 Fehlermeldung zurückgegeben. Die Logik dazu lautet wie folgt: Für eine erfolgreiche Änderung werden alle allow insert Callbacks sowie alle deny insert Callbacks ausgeführt.

Note: n/e stands for Not Executed
Note: n/e stands for Not Executed

In anderen Worten: Meteor geht die callback-Liste von oben nach unten durch, zuerst die deny Callbacks, dann die allow Callbacks und führt jeden callback solange aus bis einer true zurückgibt?!

Ein praktisches Beispiel dafür könnte so aussehen: Wir haben zwei allow() callbacks, einer der prüft ob der post dem Benutzer gehört und einen zweiten der prüft ob der Benutzer Administratorenrechte hat. Wenn der Benutzer Administratorenrechte besitzt, kann er alle posts aktualisieren, da zumindest einer der Callbacks true zurückgibt.

Latenz-Kompensation

Erinnere dich daran dass Datenbank-Mutationen, wie alle Methoden, latenz-kompensiert sind. Wenn du also via Browser-Console versuchst einen post zu löschen, der nicht dir gehört, siehst du den post kurz verschwinden (da er aus der lokalen Collection rausgelöscht wird), dann aber wieder auftauchen, da der Server den Client informiert, dass das Dokument nicht gelöscht wurde.

Dieses Verhalten ist natürlich kein Problem, wenn es von der Browser-Console aus ausgelöst wird (Wenn User versuchen mit der Console zu experimentieren ist das nicht unser Problem, was in ihrem Browser geschieht). Wir sollten jedoch solche Aktionen im UI unterbinden, indem wir den Löschen Knopf gar nicht erst einblenden, sollte ein Benutzer nicht über die nötigen Rechte verfügen.

Da der Code der die Rechte regelt zwischen Client und Server geteilt wird, könnten wir zum Beispiel eine library Funktion canDeletePost(user, post) schreiben und diese in den shared Folder /lib legen. Solche Praktiken generieren in der Regel nicht all zuviel Zusatzcode.

Server-seitige Rechte

Erinnere dich, dass das Rechte-System nur für Datenbankmutationen welche vom Client ausgelöst werden gültig sind. Auf dem Server geht Meteor davon aus, dass alle Operationen erlaubt sind.

Das heisst, dass wenn du auf dem Server eine deletePost Meteor Methode schreibst, die vom Client her aufgerufen werden kann, kann jedermann jeden post löschen. Dies ist höchstwahrscheinlich nicht in deinem Sinne, es sei denn die Benutzerrechte werden in dieser Methode mitgeprüft.

Deny als Callback verwenden

Ein Trick den du mit deny machen kannst, ist es als “onX” callback einzusetzen. Zum Beispiel könntest du einen lastModified Timestamp mit folgendem Code integrieren:

Posts.deny({
  update: function(userId, doc, fields, modifier) {
    doc.lastModified = +(new Date());
    return false;
  },
  transform: null
});

Da deny Callbacks bei jedem erfolgreichen update ausgeführt werden, wissen wir, dass dieser Callback ausgeführt wird und am Dokument Änderungen in einer strukturierten Art und Weist vornimmt.

Zugegebenermassen ist diese Technik ein Hack, so wäre es wohl schöner eine Methode bei Updates auszuführen. Nichtsdestotrotz ist es nützlich zu wissen und in Zukunft hoffen wir, dass ein beforeUpdate Callback in Meteor verfügbar sein wird.

Fehler

9

Die Standardfunktion des Browsers alert() ist eine unbefriedegende Lösung um dem Benutzer mitzuteilen, dass ein Problem vorliegt und ist aus Sicht der User-Experience sicher nicht optimal Das können wir besser.

Wir bauen anstatt dessen ein vielseitigeres Fehler-Reporting, dass dem Benutzer genau mitteilt was passiert ohne den Ablauf dert App zu behindern.

Zu diesem Zweck implementieren wir ein einfaches System, das neue Fehlermeldungen in der oberen rechten Ecke des Fensters anzeigt, wie bei der bekannten Mac OS App Growl

Einführung in lokale Collections

Zu Beginn erstellen wir eine neue lokale Collection um unsere Fehler darin zu speichern. Vorausgesetzt Fehler sind nur für die aktuelle Session relevant und müssen nicht nachhaltig in irgendeiner Form gespeichert werden, machen wir etwas neues, wir erstellen eine lokale Collection. Das heißt, dass die Errors Collection nur im Browser existiert und keine Anstallten macht sich mit dem Server zu synchronisieren.

Das zu erreichen ist ziemlich leicht: wir erzeugen die Fehlermeldungen in einer Datei, im Verzeichnis client (um die Collection lokal zu machen). Dabei wird der Name der MongoDB Collection auf null gesetzt (da die Daten dieser Collection niemals in der serverseitigen Datenbank gespeichert werden):

// Local (client-only) collection
Errors = new Mongo.Collection(null);
client/helpers/errors.js

Da die Collection jetzt erzeugt ist, können wir die Funktion throwError erstellen um Fehler hinzuzufügen. Wir müssen uns nicht über allow, deny oder ähnliches kümmern, da die Collection “lokal” ist.

throwError = function(message) {
  Errors.insert({message: message})
}
client/helpers/errors.js

Der Vorteil einer lokalen Collection um die Fehler zu speichern, ist, dass diese - wie alle Collections - reaktive sind. Das bedeutet, dass wir Fehler auf die gleiche Art und Weise, reaktiv anzeigen können, wie wir es von anderen Daten aus Collections gewohnt sind.

Das Anzeigen von Fehlern

Wir zeigen die Fehler im oberen Teil unseres Hauptlayouts an:

<template name="layout">
  <div class="container">
    {{> header}}
    {{> errors}}
    <div id="main">
      {{> yield}}
    </div>
  </div>
</template>
client/templates/application/layout.html

Nun legen wird die Templates errors und error in der Datei errors.html an:

<template name="errors">
  <div class="errors">
    {{#each errors}}
      {{> error}}
    {{/each}}
  </div>
</template>

<template name="error">
  <div class="alert alert-danger" role="danger">
    <button type="button" class="close" data-dismiss="alert">&times;</button>
    {{message}}
  </div>
</template>
client/templates/includes/errors.html

Doppel-Templates

Dir ist vielleicht aufgefallen, dass wir zwei Templates in eine Datei gelegt haben. Bisher haben wir uns an eine “Eine Datei - ein Template”-Konvention gehalten. Aber was Meteor betrifft, würde eine einzelne Datei genausogut funktionieren (wobei das eine sehr unübersichtliche main.html Datei wäre!).

In diesem Fall machen wir eine Ausnahme, da beide Templates sehr kurz sind. Dadurch wird unser Repository ein wenig übersichtlicher.

Jetzt implementieren wir unseren Template-Helper und schon kann es losgehen!

Template.errors.helpers({
  errors: function() {
    return Errors.find();
  }
});
client/templates/includes/errors.js

Man kann die Fehlermeldungen jetzt schon manuell testen. Öffne einfach die Browser Konsole und gibt ein:

 throwError("I'm an error!");
Fehlermeldung testen
Fehlermeldung testen

Commit 9-1

Basic error reporting.

Zwei Arten von Fehlern

An diesem Punkt muss man eine wichtige Unterscheidung machen zwischen App-Level Fehlern und Code-Level Fehlern.

App-Level Fehler werden normalerweise vom Benutzer ausgelöst, die dann ebenfalls die Möglichkeit haben darauf zu reagieren. Dazu gehören Validierungs-Fehler, Berechtigungs-Fehler, “not found”-Fehler und so weiter. Das sind die Art von Fehlern, die man dem Nutzer anzeigen sollte um zu helfen, Probleme zu lösen.

Code-Level Fehler sind unerwartete Fehler aufgrund von Bugs im Code und man sollte sie dem Benutzer nicht direkt anzeigen, sondern mit einer Error-Tracing Lösung eines Drittanbieters abfangen (wie z.B. Kadira).

In diesem Kapitel behandeln wir den ersten Typ von Fehlern und nicht wie man Bugs abfängt.

Fehler erzeugen

Wir wissen jetzt, wie man Fehler anzeigt aber wir müssen sie zunächst auslösen, bevor wir etwas sehen. Wir haben bereits ein gutes Fehler-Szenario implementiert: unsere Doppelpost-Warnung. Wir ersetzen einfach die Aufrufe von alert im Eventhelper postSubmit durch die neue Funktion throwError, die wir gerade erstellt haben:

Template.postSubmit.events({
  'submit form': function(e) {
    e.preventDefault();

    var post = {
      url: $(e.target).find('[name=url]').val(),
      title: $(e.target).find('[name=title]').val(),
      message: $(e.target).find('[name=message]').val()
    };

    Meteor.call('postInsert', post, function(error, result) {
      // display the error to the user and abort
      if (error) {
        return throwError(error.reason);

        //show this result but route anyway
        if (result.postExists)
          throwError('This link has already been posted');

        Router.go('postPage', {_id: result._id});
    });
  }
});
client/templates/posts/post_submit.js

Wo wir schon dabei sind, passen wir auch den Eventhelper postEdit an:

Template.postEdit.events({ 
  'submit form': function(e) {
    e.preventDefault();

    var currentPostId = this._id;

    var postProperties = {
      url: $(e.target).find('[name=url]').val(), 
      title: $(e.target).find('[name=title]').val()
    }
    Posts.update(currentPostId, {$set: postProperties}, function(error) { 
      if (error) {
        // display the error to the user
        throwError(error.reason); 
        } else {
        Router.go('postPage', {_id: currentPostId});
      }
    });
  },
//...
});
client/templates/posts/post_edit.js

Commit 9-2

Actually use the error reporting.

Versuchen wir es mal: erstellt man ein Post mit der URL http://meteor.com, sieht man folgendes:

Einen Fehler auslösen
Einen Fehler auslösen

Fehlermeldungen wieder entfernen

Du hast vielleicht schon bemerkt, dass die Fehlermeldungen nach einigen Sekunden selbstständig wieder verschwinden. Dafür ist ein bisschen CSS-Magie verantwortlich, die wir ganz am Anfang dieses Buches hinzugefügt haben:

@keyframes fadeOut { 
  0% {opacity: 0;}
  10% {opacity: 1;}
  90% {opacity: 1;}
  100% {opacity: 0;}
}
//...
.alert {
  animation: fadeOut 2700ms ease-in 0s 1 forwards; 
  //...
}
client/stylesheets/style.css

Wir definieren eine CSS-Animation fadeOut, die unsere Keyframes für die Opacity-Eigenschaft festlegt (bei 0%, 10%, 90% und 100% der gesamten Animationsdauer) und wenden diese Animation auf die Klasse alert an.

Die Animation läuft insgesamt für 2700 Millisekunden, benutzt ein ease-in Timing, läuft mit einer Verzögerung von 0 Sekunden, wird einmalig ausgeführt und verharrt im letzten Keyframe, wenn sie fertig ist.

Animation vs Animation

Es mag vielleicht überraschen, dass wir CSS-Animationen benutzen (die festgelegt sind und außerhalb der Konstroller der App liegen) anstatt die Meteor-eigene Animationskontrolle zu nutzen.

Obwohl Meteor das Einfügen von Anomationen unterstützt, wollen wir uns in diesem Kapitel nur mit Fehlern beschäftigen. Also belassen wir es vorerst bei den “dummen” Css-Animationen und bewahren uns die Leckerbissen für das Kapitel Animationen auf.

Das funktioniert nun soweit, doch sobald man mehrere Fehler auslöst (indem man z.B. den gleichen Link dreimal abschickt), wird deutlich, dass sie sich stapeln:

Stack-Overflow
Stack-Overflow

Das passiert, da die .alert Elemente zwar ausgebelndet werden, aber immernoch im DOM vorhanden sind. Das sollten wir beheben.

Dies ist eine der Situationen wo Meteor glänzen kann. Da die Collection Errors reaktiv ist, brauchen wir lediglich die alten Fehler aus der Collection zu entfernen!

Wir nutzen Meteor.setTimeout um eine Callback-Funktion festzulegen, die nach dem Timeout ausgefürt wird (in diesem Fall 3000 Millisekunden).

Template.errors.helpers({ 
  errors: function() {
    return Errors.find(); 
  }
});

Template.error.onRendered(function() { 
  var error = this.data; 
  Meteor.setTimeout(function () {
    Errors.remove(error._id);
  }, 3000);
});

client/templates/includes/errors.js

Commit 9-3

Clear errors after 3 seconds.

Der Callback onRendered wird aufgerufen, sobald unser Template im Browser gerendered wurde. In diesem Callback verweist this auf die derzeitige Template-Instanz und this.data erlaubt es uns auf die Daten des Objekts zuzugreifen, das gerade gerendert wird (in unserem Fall handelt es sich um ein Fehlerobjekt).

Validierung

Bisher haben validieren wir das Formular noch überhaupt nicht. Benutzer sollen aber zumindest eine URL und einen Titel für einen neuen Post eingeben. Also stellen wir sicher, dass sie das auch machen.

Wr machen zwei Dinge um auf fehlende Eingaben hinzuweisen: zunächst wenden wir die spezielle CSS-Klasse has-error auf jedes fehlerhafte Eingabefeld an. Dann zeigen wir eine hilfreiche Fehlermeldung unter dem Feld an.

Wir bereiten unser Template postSubmit vor, um die neuen Helper einsetzen zu können:

<template name="postSubmit">
  <form class="main form page">
    <div class="form-group {{errorClass 'url'}}">
      <label class="control-label" for="url">URL</label>
      <div class="controls">
          <input name="url" id="url" type="text" value="" placeholder="Your URL
" class="form-control"/>
          <span class="help-block">{{errorMessage 'url'}}</span>
      </div>
    </div>
    <div class="form-group {{errorClass 'title'}}">
      <label class="control-label" for="title">Title</label>
      <div class="controls">
          <input name="title" id="title" type="text" value="" placeholder="Name
 your post" class="form-control"/>
          <span class="help-block">{{errorMessage 'title'}}</span>
      </div>
</div>
    <input type="submit" value="Submit" class="btn btn-primary"/>
  </form>
</template>
client/templates/posts/post_submit.html

Man beachte, dass wir Parameter (wie url und title) an jeden Helper übergeben. Das ermöglicht uns den selben Helper in beiden Fällen zu benutzen indem wir die Parameter jeweils anpassen.

Nun kommt der spaßige Teil: die Helper zum Leben erwecken.

Wir benutzen die Session um das Objekt postSubmitErrors mit Fehlermeldungen zu befüllen. Interagiert der Nutzer mit dem Formular, verändert sich dieses Formular, was zu reaktiven Veränderungen in der Anzeige des Formular führt.

Zuerst initialisieren wir das Objekt, sobald das Template postSubmit erzeugt wird. So erreichen wir, dass dem Benutzer keine Fehlermeldung von einem vorigen Besuch der Seite angezeigt werden.

Dann definieren wir die zwei Template-Helper. Beide beziehen sich auf die Eigenschaft field von Session.get('postSubmitErrors') (wofieldentweder dieurloder dertitle` ist, je nachdem von wo wir den Helper aufrufen).

Während errorMessage nur die Fehlermeldung selbst zurückgibt, überprüft errorClass die Existenz einer solchen und returnt has-error wenn eine Meldung vorliegt.

Template.postSubmit.onCreated(function() { 
  Session.set('postSubmitErrors', {});
});

Template.postSubmit.helpers({ 
  errorMessage: function(field) {
    return Session.get('postSubmitErrors')[field]; },
  errorClass: function (field) {
    return !!Session.get('postSubmitErrors')[field] ? 'has-error' : '';
  } 
});
//...
client/templates/posts/post_submit.js

Um zu überprüfen, dass der Helper korrekt funktioniert, öffnen wir die Konsole des Browsers und geben folgenden Code ein:

 Session.set('postSubmitErrors', {title: 'Warning! Intruder detected. Now relea
sing robo-dogs.'});
Browser Konsole
Alarm! Alarm!
Alarm! Alarm!

Im nächsten Schritt verbinden wir das Formular mit dem Objekt postSubmitErrors der Session.

Bevor wir das allerdings tun, erzeugen wir eine neue Funktion validatePost, die ein Objekt posts überprüft und ein Objekt errors zurückgibt, wenn Fehler gefunden wurden (also wenn eines der Felder title oder url fehlt), in der Datei posts.js:

//...

validatePost = function (post) { 
  var errors = {};

  if (!post.title)
    errors.title = "Please fill in a headline";

  if (!post.url)
    errors.url = "Please fill in a URL";

  return errors; 
}

//...
lib/collections/posts.js

Diese Funktion rufen wir im Event-Helper postSubmit auf:

Template.postSubmit.events({ 
  'submit form': function(e) {
    e.preventDefault();

  var post = {
    url: $(e.target).find('[name=url]').val(), title: $(e.target).find('[name=title]').val()
  };

  var errors = validatePost(post); 
  if (errors.title || errors.url)
    return Session.set('postSubmitErrors', errors);

  Meteor.call('postInsert', post, function(error, result) { 
    // display the error to the user and abort
    if (error)
      return throwError(error.reason);

    // show this result but route anyway
    if (result.postExists)
      throwError('This link has already been posted');
      Router.go('postPage', {_id: result._id});
    });
  } 
});
client/templates/post_submit.js

Beachte, dass wir return benutzen um die Ausführung des Helpers abzubrechen, weil ein Fehler vorliegt nicht weil wir den Wert wirklich zurückgeben wollen.

Erwischt!
Erwischt!

Serverseitige Validierung

Wir sind aber noch nicht ganz fertig. Wir validieren die Existenz einer URL und eines Titels beim Client aber was ist mit dem Server? Schließlich könnte jemand versuchen einen leeren Post über die Browser Konsole mit postInsert einzuschläusen.

Auch wenn wir auf dem Server keine Fehlermeldung ausgeben müssen, können wir trotzdem die selbe Funktion validatePost nutzen. Nur dass wir sie dieses Mal auch in der Methode postInsert ausführen und nicht nur im Event-Helper:

Meteor.methods({
  postInsert: function(postAttributes) {
    check(this.userId, String); check(postAttributes, {
      title: String,
      url: String
    });

    var errors = validatePost(postAttributes); 
    if (errors.title || errors.url)
      throw new Meteor.Error('invalid-post', "You must set a title and URL for your post");

    var postWithSameLink = Posts.findOne({url: postAttributes.url}); 
    if (postWithSameLink) {
      return {
        postExists: true,
        _id: postWithSameLink._id
      } 
    }

    var user = Meteor.user();
    var post = _.extend(postAttributes, {
      userId: user._id, 
      author: user.username, 
      submitted: new Date()
    });

    var postId = Posts.insert(post);

    return {
      _id: postId
    }; 
  }
});
lib/collections/posts.js

Nochmal: Benutzer sollten die Nachricht “You must set a title and URL for your post” normalerweise nie zu sehen bekommen. Sie wird nur ausgegeben, wenn jemand versucht unsere mühevoll gestaltete Benutzeroberfläche zu umgehen und an deren Stelle die Browser Konsole benutzt.

Um das zu testen, öffnen wir die Browser Konsole und versuchen einen Post ohne URL zu erstellen:

Meteor.call('postInsert', {url: '', title: 'No URL here!'});

Wenn wir unseren Job gut gemacht haben, wird jetzt eine große Menge Code zurückgegeben, mit der Nachricht “You must set a title and URL for your post”.

Commit 9-4

Validate post contents on submission

Validierung beim Bearbeiten

Um das Ganze abzurunden, wenden wir die Validierung auch auf das Formular für die Post-Bearbeitung an. Das Code sieht sehr ähnlich aus. Zuerst das Template:

<template name="postEdit">
  <form class="main form page">
    <div class="form-group {{errorClass 'url'}}">
      <label class="control-label" for="url">URL</label>
      <div class="controls">
          <input name="url" id="url" type="text" value="{{url}}" placeholder="Y
our URL" class="form-control"/>
          <span class="help-block">{{errorMessage 'url'}}</span>
      </div>
    </div>
    <div class="form-group {{errorClass 'title'}}">
      <label class="control-label" for="title">Title</label>
      <div class="controls">
          <input name="title" id="title" type="text" value="{{title}}" placehol
der="Name your post" class="form-control"/>
          <span class="help-block">{{errorMessage 'title'}}</span>
      </div>
    </div>
    <input type="submit" value="Submit" class="btn btn-primary submit"/>
    <hr/>
    <a class="btn btn-danger delete" href="#">Delete post</a>
  </form>
</template>
client/templates/posts/post_edit.html

Dann die Template-Helper:

Template.postEdit.onCreated(function() { 
  Session.set('postEditErrors', {});
});

Template.postEdit.helpers({ 
  errorMessage: function(field) {
    return Session.get('postEditErrors')[field]; },
  errorClass: function (field) {
    return !!Session.get('postEditErrors')[field] ? 'has-error' : '';
  } 
});

Template.postEdit.events({ 
  'submit form': function(e) {
    e.preventDefault();

    var currentPostId = this._id;

    var postProperties = {
      url: $(e.target).find('[name=url]').val(), 
      title: $(e.target).find('[name=title]').val()
    }

    var errors = validatePost(postProperties); 
    if (errors.title || errors.url)
      return Session.set('postEditErrors', errors);

    Posts.update(currentPostId, {$set: postProperties}, function(error) { 
      if (error) {
        // display the error to the user
        throwError(error.reason);
        } else {
          Router.go('postPage', {_id: currentPostId});
        }
    }); 
  },

  'click .delete': function(e) { 
    e.preventDefault();

    if (confirm("Delete this post?")) { 
      var currentPostId = this._id; 
      Posts.remove(currentPostId); 
      Router.go('postsList');
    } 
  }
});
client/templates/posts/post_edit.js

Genau wie beim Absende-Formular wollen wir auch hier unsere Posts zusätzlich auf dem Server validieren. Da wir aber keine Methode benutzen um Posts zu bearbeiten, sondern direkt ein update beim Client durchführen, müssen wir ein neues Callback deny hinzufügen:

//...

Posts.deny({
  update: function(userId, post, fieldNames, modifier) {
    var errors = validatePost(modifier.$set);
    return errors.title || errors.url; 
  }
});

//...
lib/collections/posts.js

Beachten sollte man, dass das Argument post auf einen existierenden Posts verweist. In diesem Fall wollen wir ein update validieren, weshalb wir validatePost auf den Inhalt der Eigenschaft $set von modifier anwenden (wie bei Posts.update({$set: {title: ..., url: ...}})).

Das funktioniert, da modifier.$set die gleichen Eigenschaften title und url beinhaltet wie das ganze Objekt post. Das bedeutet natürlich auch, dass ein teilweises Update von title oder url fehlschlagen würde, doch dies sollte in der Praxis kein Problem darstellen.

Dir ist vielleicht aufgefallen, dass dies unser zweites deny Callback ist. Setzt man mehrer deny Callbacks ein, schlägt die Operation fehl sobald eine von ihnen true zurückgibt. In diesem Fall bedeutet das, dass das update nur ausgeführt wird, wenn es sich auf die Felder title und url bezieht und keiner der beiden leer ist.

Commit 9-5

Validate post contents when editing

Creating a Meteorite Package

Sidebar 9.5

Wir haben ein wiederverwendbares Pattern in unserer Error-Anzeige, deshalb wollen wir das in ein Smartpackage verpacken und mit der Meteor Community teilen.

Zuerst müssen wir eine Struktur für unser Package kreieren. Wir legen das Package in ein Ordner namens packages/errors/. Dies kreiert ein custom Package das automatisch von der App benutzt wird. (Vielleicht ist dir aufgefallen dass Meteorite Packages via Symlinks in den packages/ Ordner hineinspeichert).

Dann speichern wir eine package.js in diesen Ordner ab. Dieses File gibt Meteor Auskunft darüber, wie das Package benutzt werden soll.

Package.describe({
  summary: "A pattern to display application errors to the user"
});

Package.on_use(function (api, where) {
  api.use(['minimongo', 'mongo-livedata', 'templating'], 'client');

  api.add_files(['errors.js', 'errors_list.html', 'errors_list.js'], 'client');

  if (api.export) 
    api.export('Errors');
});
packages/errors/package.js

Nun fügen wir drei Files dem Package hinzu. Wir können diese Files ohne grosse Änderungen aus der bestehenden Microscope App hinausziehen, lediglich den Namespace und die API müssen wir leicht anpassen:

Errors = {
  // Local (client-only) collection
  collection: new Meteor.Collection(null),

  throw: function(message) {
    Errors.collection.insert({message: message, seen: false})
  },
  clearSeen: function() {
    Errors.collection.remove({seen: true});
  }
};

packages/errors/errors.js
<template name="meteorErrors">
  {{#each errors}}
    {{> meteorError}}
  {{/each}}
</template>

<template name="meteorError">
  <div class="alert alert-error">
    <button type="button" class="close" data-dismiss="alert">&times;</button>
    {{message}}
  </div>
</template>
packages/errors/errors_list.html
Template.meteorErrors.helpers({
  errors: function() {
    return Errors.collection.find();
  }
});

Template.meteorError.rendered = function() {
  var error = this.data;
  Meteor.defer(function() {
    Errors.collection.update(error._id, {$set: {seen: true}});
  });
};
packages/errors/errors_list.js

Das Package in Microscope ausprobieren

Wir werden nun das Ganze lokal mit Microscope ausprobieren, um sicherzustellen, dass unser geänderter Code auch funktioniert. Um das Package in das Projekt zu linken müssen wir meteor add errors ausführen. Dann müssen wir die existierenden Files die in der Zwischenzeit redundant geworden sind entfernen:

$ rm client/helpers/errors.js
$ rm client/views/includes/errors.html
$ rm client/views/includes/errors.js
removing old files on the bash console

Was wir auch noch machen müssen, ist unsere API leicht anpassen:

Router.before(function() { Errors.clearSeen(); });
lib/router.js
  {{> header}}
  {{> meteorErrors}}
client/views/application/layout.html
Meteor.call('post', post, function(error, id) {
  if (error) {
    // display the error to the user
    Errors.throw(error.reason);

client/views/posts/post_submit.js
Posts.update(currentPostId, {$set: postProperties}, function(error) {
  if (error) {
    // display the error to the user
    Errors.throw(error.reason);
client/views/posts/post_edit.js

Commit 9-5-1

Created basic errors package and linked it in.

Sobald diese Änderungen vorgenommen sind, sollten wir unsere ursprüngliche Funktionalität wieder haben.

Tests schreiben

Der erste Schritt beim Development ist das Package in eine App zu integrieren. Der nächste Schritt ist dann, einen Test zu Schreiben, um das Package und sein Verhalten zu testen. Meteor hat eine built-in Testing Suite (Tynitest), die es einfach macht, solche Tests auszuführen und uns mit gutem Gewissen unseren Code mit anderen teilen lässt.

Lass uns einen Test mit Tinytest schreiben, der die “Errors”-Codebase testet.

Tinytest.add("Errors collection works", function(test) {
  test.equal(Errors.collection.find({}).count(), 0);

  Errors.throw('A new error!');
  test.equal(Errors.collection.find({}).count(), 1);

  Errors.collection.remove({});
});

Tinytest.addAsync("Errors template works", function(test, done) {  
  Errors.throw('A new error!');
  test.equal(Errors.collection.find({seen: false}).count(), 1);

  // render the template
  OnscreenDiv(Spark.render(function() {
    return Template.meteorErrors();
  }));

  // wait a few milliseconds
  Meteor.setTimeout(function() {
    test.equal(Errors.collection.find({seen: false}).count(), 0);
    test.equal(Errors.collection.find({}).count(), 1);
    Errors.clearSeen();

    test.equal(Errors.collection.find({seen: true}).count(), 0);
    done();
  }, 500);
});
packages/errors/errors_tests.js

In diesen Tests überprüfen wir die grundsätzlichen Meteor.Errors Funktionen und als Doppelcheck, dass der Code im Template immer noch funktioniert.

Wir beschäftigen uns hier nicht mit den Spezifikationen wie man Meteor Packages testet (die API ist noch nicht ganz fertiggestellt und ändert zur Zeit täglich), aber hoffentlich ist das Arbeitsprinzip zu weiten Graden selbsterklärend.

Um Meteor mitzuteilen wie es die Tests in package.js ausführen soll, benützen wir folgenden Code:

Package.on_test(function(api) {
  api.use('errors', 'client');
  api.use(['tinytest', 'test-helpers'], 'client');  

  api.add_files('errors_tests.js', 'client');
});
packages/errors/package.js

Commit 9-5-2

Added tests to the package.

Dann können wir die eigentlichen Tests wie folgt laufen lassen:

$ meteor test-packages errors
Terminal
Passing all tests
Passing all tests

Das Package publizieren

Jetzt wollen wir das Package publizieren, um es mit der Meteor Welt zu teilen. Dies machen wir über die Meteor Package Plattform Atmosphere.

Zuerst müssen wir ein smart.json hinzufügen, damit Atmosphere die wichtigen Details unseres Packages kennt.

{
  "name": "errors",
  "description": "A pattern to display application errors to the user",
  "homepage": "https://github.com/tmeasday/meteor-errors",
  "author": "Tom Coleman <tom@thesnail.org>",
  "version": "0.1.0",
  "git": "https://github.com/tmeasday/meteor-errors.git",
  "packages": {
  }
}
packages/errors/smart.json

Commit 9-5-3

Added a smart.json

Wir schreiben einige grundsätzliche Metadaten, um Informationen über das Package zur Verfügung zu stellen. Dies wären: Informationen über was es tut, die git location wo wir es hosten und eine initiale Verisonsnummer. Wenn unser Package Abhängigkeiten zu anderen Atmosphere Packages hat, könnten wir auch die “packages” section benützen, um diese Abhängigkeiten festzulegen.

Wenn das alles an Ort und Stelle ist, ist das Publizieren einfach. Wir müssen ein Git Repository erstellen, zu einem Remote Git pushen und auf diese location in der smart.json verlinken.

Der Prozess dafür auf GitHub ist: Zuerst das neue Repository erstellen und dann dem Standard Workflow zum Zurverfügungstellen des Codes im Repository folgen. Dann können wir den mrt release Befehl brauchen um es zu Publizieren.

$ git init
$ git add -A
$ git commit -m "Created Errors Package"
$ git remote add origin https://github.com/tmeasday/meteor-errors.git
$ git push origin master
$ mrt release .
Done!
Terminal (run from within `packages/errors`)

Bemerkung: Package Namen müssen einzigartig sein. Wenn du bisher Wort-für-Wort gefolgt bist und denselben Package Namen benutzt, wird es einen Konflikt geben. In Zukunft wird Atmosphere allerdings einen Autoren-Namespace haben. Du kannst also damit rechnen, dass es da noch Änderungen gibt.

Zweite Bemerkung: Du musst auf http://atmosphere.meteor.com/accounts einloggen und einen Usernamen und Password erstellen. Diese brauchst du wenn du auf der Command Line mrt release aufrufst.

Jetzt wenn das Package publiziert ist, können wir es aus dem Projekt löschen und wieder über Meteorite als offizielles Package einfügen:

$ rm -r packages/errors
$ mrt add errors
Terminal (run from the top level of the app)

Commit 9-5-4

Removed package from development tree.

Jetzt sollte Meteorite unser Package zum ersten mal herunterladen. Herzliche Gratulation!

Denormalization

Sidebar 10.5

Daten zu denormalisieren, bedeutet Daten nicht in einer “normalen” Form zu speichern. Mit anderen Worten bedeutet eine Denormalisierung, dass man mehrere Kopien, der gleichen Daten hat.

Im letzten Kapitel haben wir die Anzahl der Kommentare in das Post-Objekt denormalisiert, um nicht jedes mal alle Kommentare laden zu müssen. Im Sinne der Datenmodellierung ist dies redundant, denn wir könnten genau so gut jederzeiz die Anzahl der Kommentare bestimmen (Performance-Gesichtspunkte außen vor).

Eine Denormalisierung, bedeutet oft Mehrarbeit für den Entwickler. In unserem Beispiel müssen wir, jedes Mal wenn wir einen Post hinzufügen oder Entfernen auch beachten, den entsprechenden Post zu aktualisieren um sicherzustellen, dass das Feld commentsCount korrekt befüllt ist. Das ist der Grund warum dieses Herangehen bei relationalen Datenbanken wie MySql nicht gerne gesehen wird.

Trotzdem hat auch die herkömmliche Vorgehensweise Nachteile: ohne eine Eigenschaft ´commentsCount` müssten wir jedes Mal alle Kommentare laden nur um sie zählen zu können, so wie wir es auch am Anfang gemacht haben. Das Denormalisieren ermöglicht es uns das komplett zu umgehen.

Eine besondere Publication

Es wäre möglich eine besondere Art von Publication zu erstellen, die ausschließlich die Anzahl der Kommentare, an denen wir interessiert sind, übermittelt (z.B. die Anzahl der Kommentare, die man momentan sehen kann, über aggregierte Queries auf dem Server).

Man muss jedoch abwägen, ob die Komplexität solcher Publications die Nachteile einer Denormalisierung nicht übertreffen würde…

Diese Überlegungen hängen natürlich von der Anwendung ab: schreibt man Code, bei dem die Integrität der Daten von größter Wichtigkeit ist, sollte die Vermeidung von Inkonsistenzen eine höhere Priorität als Zugewinne bei der Performance haben.

Documente einbetten oder mehrere Collections verwenden

Wer Erfahrung mit Mongo hat, ist vielleicht überrascht, dass wir eine zweite Collection nur für Kommentare erstellt haben: warum nicht einfach die Kommentare im Dokument Post einbetten?

Es hat sich herausgestellt, dass viele der Werkzeuge, die Meteor uns zur Verfügung stellt, viel besser auf der Collection-Ebene funktionieren. Zum Beispiel:

  1. Der Helper {{#each}} ist sehr effizient darin über einen Cursor (dem Ergebnis von Collection.find()) zu iterieren. Das Gleiche gilt nicht beim iterieren übern ein Array von Objekten in einem großen Dokument.

  2. allow und deny operieren auf Dokumenten-Level und vereinfachen deshlab auf einer Art und Weise sicherzustellen, dass jede Veränderung einzelner Kommentare korrekt durchgeführt wird, die auf Post-Level komplizierter wäre.

  3. DDP operiert auf dem Level der Top-Level Attribute eines Dokuments - das würde bedeuten, dass wenn comments eine Eigenschaft eines post wäre, würde bei jedem erstellen Kommentar, die Liste aller Posts vom Server für jeden verbundenen Client bezogen werden müssen.

  4. Publications und Subscriptions können viel leichter auf dem Dokumenten-Level gesteuert werden. Wenn wir zum Beispiel eine Pagination für Kommentare einrichten wollen, wäre die schwer möglich, wenn Kommentare keine eigene Collection hätten.

Mongo empfiehlt das Einbetten von Dokumenten um die Anzahl aufwändiger Queries um Dokumente zu laden zu reduzieren. Nichtsdestotrotz wird dies selten zum Problem, wenn wir Meteors Architektur bedenken: die meiste Zeit durchsuchen wir Collections auf Seite des Client, wo der Datenbank-Zugriff praktisch keine Resourcen kostet.

Die Nachteile der Denormalisierung

Es gibt gute Gründe Daten nicht zu denormalisieren. Um einen guten Überblick zu erhalten, wann Denormalisierung nicht benutzt werden sollte, empfehlen wir den Artikel Why You Should Never Use MongoDB von Sarah Mei

Notifications

11

Da Benutzer jetzt Inhalte von anderen Benutzern kommentieren können, wäre es sinnvoll, diese wissen zu lassen, wann eine Konversation über einen von ihren Posts begonnen hat.

Um dies zu tun, werden wir den Verfasser eines Posts benachrichtigen, wenn ein anderer Benutzer einen Kommentar dazu gemacht hat und linken in der Benachrichtigung auf den Kommentar hinaus.

Dies ist eine Eigenschaft in der Meteor hinaussticht: Da Meteor standartmässig realtime ist, können wir diese Benachrichtigungen auch unmittelbar anzeigen. Der Benutzer braucht weder die Seite neu zu laden, noch sonst eine Aktion auszuführen, die neue Benachrichtigung erscheint sofort, ohne dafür zusätzlichen Code schreiben zu müssen.

Benachrichtigungen kreieren

Wir erstellen eine neue Benachrichtigungen, sobald jemand deinen Post kommentiert. Künftig können diese Benachrichtigungen erweitert werden, um auch andere Szenarios abzudecken, aber für den Moment ist es ausreichend, Benutzer entsprechend zu informieren, wenn etwas passiert.

Wir erstellen unsere Notifications Collection plus eine createCommentNotification Funktion, welche eine Benachrichtigung erstellt sobald es ein neuer Kommentar gibt.

Notifications = new Meteor.Collection('notifications');

Notifications.allow({
  update: ownsDocument
});

createCommentNotification = function(comment) {
  var post = Posts.findOne(comment.postId);
  if (comment.userId !== post.userId) {
    Notifications.insert({
      userId: post.userId,
      postId: post._id,
      commentId: comment._id,
      commenterName: comment.author,
      read: false
    });
  }
};
collections/notifications.js

Genau wie bei Posts oder Comments, wird die Notifications Collection auf beiden Seiten (Server und Client) vorhanden sein. Da wir Benachrichtigungen aktualisieren wollen, sobald der Benutzer sie gelesen hat, müssen wir auch sicherzustellen, dass die Aktualisierungen nur auf Dokumenten ausgeführte werden können, die auch dem Benutzer gehören.

Wir erstellen auch eine einfache Funktion die auf den Post schaut, zu dem ein Kommentar erstellt wird, und daraus schliesst, welcher Benutzer von hier aus benachrichtigt werden soll.

Wir haben das Erstellen von Kommentaren schon serverseitig implementiert, folglich können wir diese Funktionalität einfach ein bisschen erweitern. Wir ersetzten return Comments.insert(comment); mit comment._id = Comments.insert(comment) um die _id des neuen Kommentars in eine Variabel zu speichern, um dann unsere createCommentNotification aufzurufen:

Comments = new Meteor.Collection('comments');

Meteor.methods({
  comment: function(commentAttributes) {

    // [...]

    // create the comment, save the id
    comment._id = Comments.insert(comment);

    // now create a notification, informing the user that there's been a comment
    createCommentNotification(comment);

    return comment._id;
  }
});
collections/comments.js

Wir müssen die Benachrichtigungen auch noch publizieren (publish) und auf dem Client subscriben.

// [...]

Meteor.publish('notifications', function() {
  return Notifications.find();
});
server/publications.js
Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  waitOn: function() { 
    return [Meteor.subscribe('posts'), Meteor.subscribe('notifications')]
  }
});
lib/router.js

Commit 11-1

Added basic notifications collection.

Benachrichtigungen anzeigen

Jetzt können wir eine Liste mit Benachrichtigungen im Header anzeigen.

<template name="header">
  <header class="navbar">
    <div class="navbar-inner">
      <a class="btn btn-navbar" data-toggle="collapse" data-target=".nav-collapse">
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
      </a>
      <a class="brand" href="{{pathFor 'postsList'}}">Microscope</a>
      <div class="nav-collapse collapse">
        <ul class="nav">
          {{#if currentUser}}
            <li>
              <a href="{{pathFor 'postSubmit'}}">Submit Post</a>
            </li>
            <li class="dropdown">
              {{> notifications}}
            </li>
          {{/if}}
        </ul>
        <ul class="nav pull-right">
          <li>{{loginButtons}}</li>
        </ul>
      </div>
    </div>
  </header>
</template>
client/views/includes/header.html

Wir brauchen dazu noch die beiden Templates notifications und notification (Beide sind im selben notifications.html untergebracht):

<template name="notifications">
  <a href="#" class="dropdown-toggle" data-toggle="dropdown">
    Notifications
    {{#if notificationCount}}
      <span class="badge badge-inverse">{{notificationCount}}</span>
    {{/if}}
    <b class="caret"></b>
  </a>
  <ul class="notification dropdown-menu">
    {{#if notificationCount}}
      {{#each notifications}}
        {{> notification}}
      {{/each}}
    {{else}}
      <li><span>No Notifications</span></li>
    {{/if}}
  </ul>
</template>

<template name="notification">
  <li>
    <a href="{{notificationPostPath}}">
      <strong>{{commenterName}}</strong> commented on your post
    </a>
  </li>
</template>
client/views/notifications/notifications.html

Wir sehen, dass das Ziel ist, dass jede Benachrichtigung einen Link zu dem Post der kommentiert wurde und den Namen des Benutzers der kommentiert hat beinhält.

Als nächstes müssen wir noch sicherstellen, dass wir die richtige Liste von Benachrichtigungen auswählen und die Benachrichtigung als gelesen markieren, sobald ein Benutzer auf den korrespondierenden Link klickt.

Template.notifications.helpers({
  notifications: function() {
    return Notifications.find({userId: Meteor.userId(), read: false});
  },
  notificationCount: function(){
    return Notifications.find({userId: Meteor.userId(), read: false}).count();
  }
});

Template.notification.helpers({
  notificationPostPath: function() {
    return Router.routes.postPage.path({_id: this.postId});
  }
})

Template.notification.events({
  'click a': function() {
    Notifications.update(this._id, {$set: {read: true}});
  }
})
client/views/notifications/notifications.js

Commit 11-2

Display notifications in the header.

Man könnte denken, dass Benachrichtigungen sich gar nicht so sehr von den Errors unterscheiden. Tatsächlich ist ihre Struktur sehr ähnlich, aber es gibt einen markanten Unterschied: Für die Benachrichtigungen haben wir eine eigene Client-Server synchronisierbare Collection erstellt. Das heisst unsere Benachrichtigungen sind persistiert und existieren in verschiedenen Browser-Instanzen gleichzeitig (sofern wir mit demselben Benutzer eingeloggt sind).

Versuch das mal aus: Öffne einen zweiten Browser (zB. Firefox), erstelle einen Benutzeraccount und kommentiere einen Post den du mit deinem Haupt-Benutzer erstellt hast (den wir in Chrome aktuell offen haben). Du solltest so etwas Ähnliches sehen:

Displaying notifications.
Displaying notifications.

Zugriff auf Benachrichtigungen haben

Benachrichtigungen scheinen gut zu funktionieren. Es gibt allerdings ein Problem: die Benachrichtigungen sind öffentlich.

Wenn du den zweiten Browser immernoch offen hast, versuche in dessen Konsole folgenden Code auszuführen.

 Notifications.find().count();
1
Browser console

Dieser neue Benutzer (der den Kommentar verfasst hat) sollte keine Benachrichtigungen haben. Die Benachrichtigung die er sehen kann gehört eigentlich unserem Hauptbenutzer.

Mal abgesehen von Datenschutzgründen, ist es auch performance-technisch nicht sehr wirtschaftlich, jedem Benutzer alle Notifications aller anderen Benutzer zu laden. Auf einer grossen Seite kann das zur Überladung des verfügbaren Speichers und ernsthaften Performance Problemen führen.

Wir lösen diese Problem über die Publications. Wir können unsere Publications dafür gebrauchen, genau anzugeben welche Teile aus unserer Collection mit dem Browser geteilt wird

Um dies zu erreichen, müssen wir in unserer Publication einen anderen Cursor als Notfications.find() zurückgeben. Nämlich: Einen Cursor der nur die Benachrichtigungen des aktuellen Users beinhält.

Dies ist ziemlich einfach zu bewerkstelligen, da die publish Funktion die _id des aktuellen Benutzers als this.userId zur Verfügung hat.

Meteor.publish('notifications', function() {
  return Notifications.find({userId: this.userId});
});
server/publications.js

Commit 11-3

Only sync notifications that are relevant to the user.

Dies können wir jetzt in beiden Browser-Fenster überprüfen, wir sollten zwei verschiedenen Benachrichtigungs Collections sehen.

 Notifications.find().count();
1
Browser console (user 1)
 Notifications.find().count();
0
Browser console (user 2)

Die Liste der Benachrichtigungen sollte sogar ändern, während du dich aus- und einloggst. Das ist der Fall, weil die Publication automatisch neu-publiziert wird, sobald der User Account ändert.

Unsere App wird immer wie mehr funktional, und je mehr Benutzer sich einschreiben und Links posten, desto mehr laufen wir Gefahr, dass wir eine nie endende Homepage haben werden. Um genau das werden wir uns im nächsten Kapitel kümmern: wir implementieren eine Paginierung.

Advanced Reactivity

Sidebar 11.5

Es kommt eher selten vor, dass man Code für das Abhängigkeitstracking selbst erstellt. Aber es ist sicherlich hilfreich, den zugrundeliegenden Mechanismus zu verstehen, um den Ablauf der Abhängigkeitsauflösung nachvollziehen zu können.

Stellen wir uns vor, wir möchten die Anzahl der Facebook-Freunde unseres aktuellen Benutzers, denen ein Post in Microscope gefällt, nachverfolgen. Nehmen wir an, wir haben es bereits geschafft, den Benutzer gegenüber Facebook zu authentifizieren, die entsprechenden API-Aufrufe auszuführen und die relevanten Daten zu parsen. Wir haben nun eine asynchrone, clientseitige Funktion, welche die Anzahl der “Gefällt mir”-Angaben zurückgibt: getFacebookLikeCount(user, url, callback).

Es ist wichtig, sich in Erinnerung zu rufen, dass eine derartige Funktion in hohem Maße nicht-reaktiv ist und sich nicht in Echtzeit mit Facebook synchronisiert. Sie wird zunächst einen HTTP-Request an Facebook senden, die Antwortdaten empfangen und diese dann der Applikation über einen asynchronen Callback zur Verfügung stellen. Sie wird sich jedoch nicht von selbst erneut aufrufen, wenn sich die “Gefällt mir”-Anzahl bei Facebook ändert, und unser UI wird sich nicht anpassen, wenn sich die zugrundeliegenden Daten ändern.

Um dies zu ändern, beginnen wir zunächst damit, unsere Funktion über setInterval alle paar Sekunden neu aufzurufen:

currentLikeCount = 0;
Meteor.setInterval(function() {
  var postId;
  if (Meteor.user() && postId = Session.get('currentPostId')) {
    getFacebookLikeCount(Meteor.user(), Posts.find(postId).url, 
      function(err, count) {
        if (!err)
          currentLikeCount = count;
      });
  }
}, 5 * 1000);

Wir können jetzt davon ausgehen, dass wir jedes Mal, wenn wir die Variable currentLikeCount auswerten, die korrekte Anzahl erhalten — mit einem Ungenauigkeitszeitfenster von fünf Sekunden. Diese Variable können wir nun wie folgt in einem Helper verwenden:

Template.postItem.likeCount = function() {
  return currentLikeCount;
}

Allerdings gibt es noch niemanden, der unser Template zur Aktualisierung veranlasst, sobald sich currentLikeCount ändert. Auch wenn sich die Variable jetzt quasi in Echtzeit aktualisiert, ist sie noch nicht reaktiv und kann deshalb nicht in der erforderlichen Art und Weise mit dem Rest des Meteor-Ökosystems kommunizieren.

Reaktivitätstracking mittels Computations

Meteors Reaktivität ist über Dependencies (Abhängigkeiten) realisiert: Datenstrukturen, die eine Menge von Computations (Berechnungen) nachverfolgen. Wie wir in der Sidebar über Reaktivität bereits gesehen haben, besteht eine Computation aus einem Codeabschnitt, der reaktive Daten verwendet. In unserem Beispiel gibt es bereits eine Computation, die implizit für das Template postItem erzeugt wurde, und jeder Helper im zugehörigen Template-Manager besitzt ebenfalls seine eigene Computation.

Du kannst Dir eine Computation als einen Codeabschnitt vorstellen, der sich um reaktive Daten “kümmert”. Wenn sich die Daten ändern, wird es diese Computaion sein, die darüber informiert wird (über invalidate()), und es ist auch die Aufgabe dieser Computation zu entscheiden, ob etwas zu tun ist.

Eine Variable in eine reaktive Funktion überführen

Um unsere Variable currentLikeCount in eine reaktive Datenquelle zu verwandeln, müssen wir alle Computations nachverfolgen, welche eine Abhängigkeit zu unserer Variable besitzen. Dies setzt voraus, dass wir die Variable in eine Funktion überführen (die einen Wert zurückgibt):

var _currentLikeCount = 0;
var _currentLikeCountListeners = new Tracker.Dependency();

currentLikeCount = function() {
  _currentLikeCountListeners.depend();
  return _currentLikeCount;
}

Meteor.setInterval(function() {
  var postId;
  if (Meteor.user() && postId = Session.get('currentPostId')) {
    getFacebookLikeCount(Meteor.user(), Posts.find(postId), 
      function(err, count) {
        if (!err && count !== _currentLikeCount) {
          _currentLikeCount = count;
          _currentLikeCountListeners.changed();
        }
      });
  }
}, 5 * 1000);

Hier haben wir eine Dependency namens _currentLikeCountListeners erstellt, welche sämtliche Computations nachverfolgt, in denen currentLikeCount() verwendet wird. Ändert sich der Wert von _currentLikeCount, rufen wir die Funktion changed() dieser Dependency auf, welche alle nachverfolgten Computations als ungültig kennzeichnet.

Diese Computations können nun von Fall zu Fall entscheiden, wie sie mit der Änderung umgehen.

Wenn dies auf dich jetzt wie ein Haufen Boilerplate-Code wirkt, hast du vollkommen recht. Meteor hat deshalb einige Werkzeuge eingebaut, um das Ganze ein bisschen zu vereinfachen (genauso wie du Computations in der Regel nicht direkt verwendest, du verwendest einfach autorun). Es gibt ein Plattformpaket namens reactive-var, welches sich exakt wie unsere Funktion currentLikeCount() verhält. Wenn wir dieses Paket hinzufügen:

meteor add reactive-var

können wir unseren Code ein bisschen vereinfachen:

var currentLikeCount = new ReactiveVar();

Meteor.setInterval(function() {
  var postId;
  if (Meteor.user() && postId = Session.get('currentPostId')) {
    getFacebookLikeCount(Meteor.user(), Posts.find(postId), 
      function(err, count) {
        if (!err) {
          currentLikeCount.set(count);
        }
      });
  }
}, 5 * 1000);

Um die Variable zu verwenden, rufen wir currentLikeCount.get() in unserem Helper auf und alles funktioniert wie zuvor. Es gibt ein weiteres Plattformpaket namens reactive-dict, das einen reaktiven Schlüssel-Wert-Speicher bietet (nahezu identisch wie die Session) und ebenfalls nützlich sein kann.

Vergleich zwischen Tracker und Angular

Angular ist eine rein clientseitige Rendering Library, die von Google entwickelt wird. Zur besseren Anschaulichkeit ist es hilfreich, Meteors Ansatz des Abhängigkeitstrackings mit dem von Angular zu vergleichen, da sich beide Ansätze deutlich voneinander unterscheiden.

Wir haben gesehen, dass die Meteor-Variante Codeblöcke verwendet, die wir als Computations bezeichnen. Diese Codeblöcke werden von speziellen “reaktiven” Datenquellen (Funktionen) überwacht, welche die Computations als ungültig erklären, sofern dies erforderlich ist. Die Datenquellen informieren also alle ihre Abhängigkeiten explizit, sobald invalidate() auszuführen ist. Beachte dabei: Auch wenn dies im Allgemeinen nur dann geschieht, wenn sich die Daten geändert haben, könnte die Datenquelle grundsätzlich auch aus anderen Gründen die Ungültigkeit veranlassen.

Und auch wenn die Computations als Reaktion auf die Ungültigkeitserklärung normalerweise einfach erneut ausgeführt werden, könntest du prinzipiell ein ganz anderes Verhalten implementieren. All dies ermöglicht dir ein hohes Maß an Kontrolle über die Reaktivität.

In Angular wird die Reaktivität über das scope-Objekt abgebildet. Einen Scope kann man sich als einfaches JavaScript-Objekt vorstellen, das über ein paar spezielle Methoden verfügt. Wenn du eine reaktive Abhängigkeit zu einem Wert innerhalb des Scopes herstellen möchtest, rufst Du scope.$watch auf und übergibst den Ausdruck, an dem Du interessiert bist (d.h. du legst fest, welcher Teil des Scopes für dich wichtig ist) und außerdem eine Listener-Funktion, die bei jeder Änderung des Ausdrucks ausgeführt wird. Du sagst also explizit, was zu tun ist, wenn sich der Wert des Ausdrucks ändert.

Um bei unserem Facebook-Beispiel zu bleiben, würden wir in Angular schreiben:

$rootScope.$watch('currentLikeCount', function(likeCount) {
  console.log('Current like count is ' + likeCount);
});

Natürlich würdest du $watch in Angular nicht allzu oft direkt aufrufen (so wie du in Meteor nur selten Computations einrichtest), da ng-model-Direktiven und {{expressions}} automatisch Beobachter einrichten, die sich bei Änderungen um das erneute Rendering kümmern.

Wenn sich ein solcher reaktiver Wert geändert hat, muss anschließend scope.$apply() aufgerufen werden. Dies wertet jeden Beobachter des Scope neu aus, ruft aber nur die Listener-Funktionen jener Beobachter auf, deren Werte sich auch geändert haben.

scope.$apply() ist also in etwa vergleichbar mit dependency.changed(), mit dem Unterschied, dass es auf Ebene des Scopes arbeitet und dir nicht die Kontrolle darüber gibt, im Detail festzulegen, welche Listener neu ausgewertet werden sollen. Diese geringfügige Einschränkung der Konrollmöglichkeit ermöglicht es Angular, sehr schlau und effizient präzise zu entscheiden, welche Listener neu ausgewertet werden müssen.

In Angular würde unsere Funktion getFacebookLikeCount() in etwa so aussehen:

Meteor.setInterval(function() {
  getFacebookLikeCount(Meteor.user(), Posts.find(postId), 
    function(err, count) {
      if (!err) {
        $rootScope.currentLikeCount = count;
        $rootScope.$apply();
      }
    });
}, 5 * 1000);

Zugegebenermaßen übernimmt Meteor die Schwerstarbeit für uns und lässt uns von Reaktivität profitieren, ohne dass wir selbst allzu viel dafür tun müssen. Aber hoffentlich wird es sich für dich als nützlich erweisen, diese Muster kennengelernt zu haben, wenn du einmal in die Gelegenheit kommen solltest, die Dinge etwas weiter treiben zu müssen.

Pagination

12

Alles sieht gut aus bei Microscope und wir können damit rechnen, dass es sich zu einem grossen Hit entwickelt, sobald es released wird.

Deswegen müssen wir etwas vorausdenken, und uns um Performance Auswirkungen, der Anzahl der neuen Posts, die eingetragen werden kümmern.

Wir haben bisher angeschaut, wie eine Client Collection nur ein Teilsatz der Daten vom Server enthalten soll. Dies haben wir für die Benachrichtigungs- und Comments Collection schon implementiert.

Jedoch: Zur Zeit publizieren wir immer noch alle Posts an alle verbundenen Benutzer. Sollten eines Tages tausende von Links geposted werden, wird das problematisch. Um dieses Problem anzugehen bauen wir eine Paginierung der Posts ein.

Mehr Posts hinzufügen

Zuerst müssen wir unsere Fixtures anpassen und genug Posts laden damit Paginierung auch Sinn macht.

// Fixture data 
if (Posts.find().count() === 0) {

  //...

  Posts.insert({
    title: 'The Meteor Book',
    userId: tom._id,
    author: tom.profile.name,
    url: 'http://themeteorbook.com',
    submitted: now - 12 * 3600 * 1000,
    commentsCount: 0
  });

  for (var i = 0; i < 10; i++) {
    Posts.insert({
      title: 'Test post #' + i,
      author: sacha.profile.name,
      userId: sacha._id,
      url: 'http://google.com/?q=test-' + i,
      submitted: now - i * 3600 * 1000,
      commentsCount: 0
    });
  }
}
server/fixtures.js

Nachdem wir meteor reset ausgeführt haben, müssen wir so etwas schreiben:

Displaying dummy data.
Displaying dummy data.

Commit 12-1

Added enough posts that pagination is necessary.

Unendliche Paginierung

Wir werden eine unendliche Paginierung implementieren. Das heisst, dass wir zuerst einmal nur 10 Posts auf den Screen rendern und einen Link “load more” zur Verfügung stellen. Sobald wir diesen Link klicken, sollen 10 weitere Posts der Liste hinzugefügt werden. Dieser Prozess soll unendlich wiederholbar sein. Das heisst, dass wir unsere gesamte Paginierung mit einem einzigen Parameter (der Anzahl Posts) steuern können.

Wir müssen dem Server nun Informationen über diesen Parameter liefern, damit er weiss, wieviele Posts er an den Client ausliefern soll. Da wir im Router schon eine Subscription zu den posts haben, ist das auch der Ort wo wir diese Informationen weiterleiten.

Der einfachste Weg dies zu vollziehen, ist die Einschränkung der Anzahl der Posts über einen Pfad-Parameter an den Server zu übergeben. Dies ergibt uns solche URLs: http://localhost:3000/25. Ein Vorteil die URL Steuerung anderen Methoden vorzuziehen, ist die Tatsache dass wir den Zustand der Applikation in gewisser Art und Weise in der URL persistiert haben, sollte ein Benutzer die Seite aus Versehen neu laden, werden nach wie vor 25 Posts angezeigt.

Um dies sauber aufzuziehen müssen wir unsere Subscription etwas abändern. Genau so wie im Comments Kapitel, müssen wir unseren Subscription Code von der Router Ebene auf die Route Ebene herunterzügeln.

Das mag ein bisschen viel aufs Mal zu sein, aber es wird bestimmt klarer mit dem Code.

Zuerst müssen wir die Subscription zu den Posts in Router.configure() stoppen. Lösche Meteor.subscribe('posts'). Es bleibt nur die notifications subscription:

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  waitOn: function() { 
    return [Meteor.subscribe('notifications')]
  }
});
lib/router.js

Dann müssen wir einen postsLimit Paramater dem Pfad anhängen. Das ? nach dem Parameter bedeutet dass der Parameter optional ist. Somit matcht unsere Route nicht nur http://localhost:3000/50, sondern auch http://localhost:3000.

Router.map(function() {
  //...

  this.route('postsList', {
    path: '/:postsLimit?'
  });
});
lib/router.js

An dieser Stelle wichtig zu erwähnen dass ein Pfad mit der Formel /:parameter? jeden möglichen Pfad matcht. Da jede Route nacheinander geparsed wird, um zu sehen ob ein Match mit dem jetzigen Pfad vorliegt, müssen wir sicherstellen, dass wir unsere Routes so arrangieren dass ihre Spezifizität abnimmt.

Anders ausgedrückt, Routes die spezifischere Muster wie /posts/:_id abfangen sollen, sollten im Code zuerst kommen. Unsere postsList Route sollte ans Ende des Files verschoben werden, da diese so ziemlich alles matcht.

Es ist nun Zeit die Knacknuss des Subscriben und des Auffindens der richtigen Daten zu lösen. Da wir auch den Fall abfangen wollen, wo kein postsLimit Paramater vorhanden ist, müssen wir einen default Wert definieren. Wir tragen dafür mal “5” ein, um mit der Applikation herumzuspielen.

Router.map(function() {
  //..

  this.route('postsList', {
    path: '/:postsLimit?',
    waitOn: function() {
      var postsLimit = parseInt(this.params.postsLimit) || 5; 
      return Meteor.subscribe('posts', {sort: {submitted: -1}, limit: postsLimit});
    }
  });
});
lib/router.js

Nun geben wir mit der Posts Publication auch noch ein JavaScript Objekt ({limit: postsLImit}) mit. Dieses Objekt dient als Options-Objekt für die serverseitige Posts.find() Methode. Wir implementieren dort folgenden Code:

Meteor.publish('posts', function(options) {
  return Posts.find({}, options);
});

Meteor.publish('comments', function(postId) {
  return Comments.find({postId: postId});
});

Meteor.publish('notifications', function() {
  return Notifications.find({userId: this.userId});
});
server/publications.js

Parameter übergeben

Unser Publications Code lässt den Server wissen, dass er allen JavaScript Objekten trauen kann die der Client ihm zusendet (hier, {limit: postsLimit}) und für die options des find() Statements gebrauchen kann. Dies macht es für Benutzer möglich jegliche Optionen via Browser Console abzusenden.

In unserem Beispiel ist dies ziemlich harmlos, da alles was ein Benutzer tun könnte, eine andere Anordnung der Posts erzwingen ist. (Oder er könnte die Limite anders setzen, was ja eigentliche unser initiales Ziel war).

Dieses Muster sollte allerdings vermieden werden, sobald private Daten in nicht publizierten Feldern gespeichert werden. Solche Felder könnten vom Benutzer manipuliert werden. Aus denselben Gründen sollte auch für das Selector Argument des find() Statements sollte diese Anwendung vermieden werden.

Ein sichereres Pattern könnte es sein die Parameter separat zu übergeben (anstatt in Form eines Objekts), um sicher zu sein dass wir die volle Kontrolle über die Daten behalten:

Meteor.publish('posts', function(sort, limit) {
  return Posts.find({}, {sort: sort, limit: limit});
});

Da wir jetzt nun auf dem Route-Level subscriben, würde es Sinn machen den data context auch hier festzulegen. wir weiche ein wenig vom vorherigen Pattern ab und erstellen eine data Funktion die anstelle eines Cursor ein JavaScript Objekt zurückliefert. So können wir dem data context den Namen posts geben.

Das heisst, dass anstelle der impliziten Verfügbarkeit von this im Template, unser data context wird als posts verfügbar sein. Abgesehen von diesem kleinen Unterschied, sollte dir der Code bekannt vorkommen:

Router.map(function() {
  this.route('postsList', {
    path: '/:postsLimit?',
    waitOn: function() {
      var limit = parseInt(this.params.postsLimit) || 5; 
      return Meteor.subscribe('posts', {sort: {submitted: -1}, limit: limit});
    },
    data: function() {
      var limit = parseInt(this.params.postsLimit) || 5; 
      return {
        posts: Posts.find({}, {sort: {submitted: -1}, limit: limit})
      };
    }
  });

  //..
});
lib/router.js

Da wir den data context im Route Level zur Verfügung haben, können wir den Template helper vom posts Template getrost löschen. Und da unser data context auch noch den selben Namen (nämlich posts) hat, brauchen wir nicht einmal unser postsList Template anzupassen.

Wir rekapitulieren. So sieht unsere neue, verbesserte router.js aus:

Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  waitOn: function() { 
    return [Meteor.subscribe('notifications')]
  }
});

Router.map(function() {
  //...

  this.route('postsList', {
    path: '/:postsLimit?',
    waitOn: function() {
      var limit = parseInt(this.params.postsLimit) || 5; 
      return Meteor.subscribe('posts', {sort: {submitted: -1}, limit: limit});
    },
    data: function() {
      var limit = parseInt(this.params.postsLimit) || 5; 
      return {
        posts: Posts.find({}, {sort: {submitted: -1}, limit: limit})
      };
    }
  });
});
lib/router.js

Commit 12-2

Augmented the postsList route to take a limit.

Versuchen wir unser brandneues Paginierungssystem aus. Wir könnnen nun eine beliebige Anzahl von Posts auf unserer Homepage laden, indem wir einfach am URL Parameter rumschrauben. Zum Beispiel: http://localhost:3000/3, sollte in etwa so aussehen:

Controlling the number of posts on the homepage.
Controlling the number of posts on the homepage.

Wieso keine Pages?

Wieso implementieren wir eine “unendliche Paginierung” und nicht eine fortlaufende Paginierung mit jeweils 10 Posts, so wie die Google Suchabfrage? Die Antwort lautet: wegen dem real-time Paradigma das Meteor verfolgt.

Stell dir vor wir paginieren unsere Posts Collection mit dem Google Suchresultate-Paginierungspattern und dass wir uns gerade auf der zweiten Seite befinden. Diese zeigt Suchresultate 10 bis 20. Was passiert nun wenn ein anderer Benutzer gleichzeitig die 10 vorherigen Posts löscht?

Da unsere App real-time ist, würde unser Datenstamm sich dynamisch verändern. Post 10 wäre jetzt post 9 und aus unserer View hinausfliegen, während post 11 in unserem Range wäre. Das Resultat wäre, dass der Benutzer plötzlich die Posts ändern sehen würde, ohne nachvollziehbaren Grund.

Selbst wenn wir diese UX-Eigenart akzeptieren würden, wäre eine Implementierung von traditionellem Paging aus technischer Sicht schwierig.

Zurück zu unserem vorherigen Beispiel. Wir publizieren Posts 10 bis 20 von unserer Posts Collection, aber wie soll der Client diese Posts ausfindig machen? Es ist nicht möglich Posts 10 bis 20 herauszupicken da im Client-seitigen Datensatz total nur 10 Posts vorhanden sind.

Eine Lösung hierzu wäre einfach die 10 Posts auf dem Server zu publizieren und dann einen Posts.find() auf dem Client ausführen um alle publizierten Posts zu laden.

Dies funktioniert aber nur wenn es eine Einzel-Subscription ist. Aber was wenn es mehr als eine Post Subscription gibt wie wir bald sehen werden?

Nehmen wir einmal an eine Subscription fragt die Posts 10 bis 20 an, eine andere die Posts 30 bis 40. Also werden insgesamt 20 Posts client-seitig geladen, ohne dass wir wissen welche Posts zu welcher Subscription gehören.

Für all diese Gründe macht die traditionelle Paginierung mit Meteor keinen Sinn.

Einen Route Controller erstellen

Vielleicht hast du festgestellt dass wir die Zeile var limit = parseInt(this.params.postsLimit) || 5; zweimal wiederholen. Auch das hard-coding der Anzahl “5” ist nicht gerade ideal. Das ist nicht das Ende der Welt, aber da es besser ist dem DRY (Don’t repeat Yourself) Prinzip zu folgen, sollten wir unseren Code refactorn.

Wir stellen einen neuen Aspekt von Iron Router vor: Route Controllers. Ein Route Controller ist einfach eine Art, Routing-Features in einer wiederverwendbaren Art zu verpacken und wovon jede Route erben kann. Hier brauchen wir es nur für eine Single Route, aber schon im nächsten Kapitel gebrauchen wir das Feature auf äusserst praktische Art und Weise.

PostsListController = RouteController.extend({
  template: 'postsList',
  increment: 5, 
  limit: function() { 
    return parseInt(this.params.postsLimit) || this.increment; 
  },
  findOptions: function() {
    return {sort: {submitted: -1}, limit: this.limit()};
  },
  waitOn: function() {
    return Meteor.subscribe('posts', this.findOptions());
  },
  data: function() {
    return {posts: Posts.find({}, this.findOptions())};
  }
});

Router.map(function() {
  //...

  this.route('postsList', {
    path: '/:postsLimit?',
    controller: PostsListController
  });
});
lib/router.js

Gehen wir die Schritte durch. Indem wir den RouteController erweitern erstellen wir unseren Controller. Dann setzen wir die template property genau wie vorher und fügen noch die increment property dazu.

Weiter definieren wir eine limit Funktion, welche die aktuelle Limite zurückliefert und eine findOptions Funktion, die ein options Objekt zurückliefert. Dies scheint wie eine Zusatzstufe, aber wir brauchen sie später noch.

Danach definieren wir noch unsere waitOn und data Funktionen genauso wie vorher, ausser dass diese nun auf unsere neue findOptions Funktion zugreifen.

Zuletzt müssen wir noch der postsList route mitteilen, dass sie unser neuen Controller benutzen soll. Dies machen wir über die controller property.

Commit 12-3

Refactored postsLists route into a RouteController.

Einen Load More Link hinzufügen

Jetzt haben wir eine funktionierende Paginierung und unser Code schaut soweit gut aus. Das einzige Problem: Die einzige Möglichkeit die Paginierung zu benutzen ist über die URL. Dies ist definitiv keine tolle User Experience. Das kriegen wir besser hin.

Was wir tun ist ziemlich einfach. Wir fügen einen “load more” Button am Ende der Post Liste hinzu. Dieser inkrementiert die Anzahl der aktuell angezeigten Posts bei jedem Klick um fünf. Wenn ich zur Zeit also auf der URL URL http://localhost:3000/5 bin und auf “load more” klicke, soll die URL auf URL http://localhost:3000/10 ändern. Wenn du es in diesem Buch bis hierher gebracht hast, sind wir zuversichtlich, dass du auch diese kleine arithmetische Aufgabe meistern wirst.

Wie vorher fügen wir unsere Paginierungs-Logik in unsere Route. Erinnere dich das wir unseren data context explizit benannt haben, anstatt einen anonymen Cursor zu verwenden. Da es keine Regel gibt die vorschreibt, dass mein der data Funktion nur Cursor übergeben kann, benutzen wir dieselbe Technik um die URL für den “load more” Button zu erstellen.

PostsListController = RouteController.extend({
  template: 'postsList',
  increment: 5, 
  limit: function() { 
    return parseInt(this.params.postsLimit) || this.increment; 
  },
  findOptions: function() {
    return {sort: {submitted: -1}, limit: this.limit()};
  },
  waitOn: function() {
    return Meteor.subscribe('posts', this.findOptions());
  },
  posts: function() {
    return Posts.find({}, this.findOptions());
  },
  data: function() {
    var hasMore = this.posts().fetch().length === this.limit();
    var nextPath = this.route.path({postsLimit: this.limit() + this.increment});
    return {
      posts: this.posts(),
      nextPath: hasMore ? nextPath : null
    };
  }
});
lib/router.js

Nun schauen wir etwas tiefer in diese Router Magie. Die postsList Route, die vom PostsListController den wir aktuell verwenden erbt, nimmt einen postsLimit Parameter an.

Wenn wir also this.route.path() mit {postsLimit: this.limit() + this.increment} füttern, sagen wir der postsList Route, sie soll ihren eigenen Pfad mit dem übergebenen Javascript Objekt zusammenbauen.

Dies ist genau dasselbe Prinzip wie die Benutzung vom {{pathFor 'postsList'}}-Spacebars helper, ausser dass wir das implizite this mit unserem selbstgemachten data context ersetzten.

Wir nehmen den Pfad und fügen ihn zum data context unseres Templates hinzu, aber nur wenn es noch mehr Posts zum anzeigen gibt. Wie war das machen ist etwas knifflig.

Wir wissen, dass this.limit() die aktuelle Anzahl die wir anzeigen möchten zurückgibt. Dies ist entweder der Wert der aus der aktuellen URL kommt, oder unser Default Wert (5) falls die URL keine Parameter hat.

Dann haben wir noch this.posts, welches zum aktuellen Cursor zeigt. Also referenziert this.posts.count() auf die Anzahl von Posts, die aktuell im Cursor enthalten sind.

Was wir hiermit sagen wollen: Wenn wir eine Anfrage für n Posts machen und n Posts zurückerhalten, dann zeigen wir den “load more” Link an. Wenn wir aber eine Anfrage für n Posts machen und weniger als n Posts zurückerhalten, heisst das, dass wir die Limite erreicht haben und den Button nicht mehr anzuzeigen brauchen.

In einem Fall versagt aber unser System noch: Wenn die Anzahl Posts in der Datenbank genau n ist. In diesem Fall fragt der Client n Posts an und erhält n Posts zurück. Der “load more” Button wird aber immer noch gerendert, nicht wissend dass es nicht mehr Posts gibt.

Dummerweise gibt es keine einfachen Workarounds für dieses Problem, deswegen müssen wir uns für den Moment mit einer nicht-ganz-perfekten Lösung zufrieden geben.

Was wir noch machen müssen, ist sicherstellen dass der “load more” Link am Ende der Postsliste anzuzeigen, aber nur wenn wir keine Posts mehr zu laden haben.

<template name="postsList">
  <div class="posts">
    {{#each posts}}
      {{> postItem}}
    {{/each}}

    {{#if nextPath}}
      <a class="load-more" href="{{nextPath}}">Load more</a>
    {{/if}}
  </div>
</template>
client/views/posts/posts_list.html

So sollte die Post-Liste aussehen:

The “load more” button.
The “load more” button.

Commit 12-4

Added nextPath() to the controller and use it to step thr…

Einen besseren Fortschrittsbalken

Unsere Paginierung funktioniert nun anständig, hat aber noch eine Macke: Jedesmal wenn wir “load more” klicken und der Router mehr Posts von der DB anfordert, werden wir zum loading Template gesendet, während wir auf die neuen Daten warten. Wir werden also jedesmal zum Seitenanfang geschickt und müssen zurückscrollen wo wir eigentlich waren.

Es wäre natürlich viel besser, wenn wir während dem ganzen Vorgang an Ort und Stelle bleiben würden, aber trotzdem eine Art von Feedback erhalten, dass zur Zeit gerade Daten geladen werden. Zum Glück gibt es das iron-router-progress Package, dass genau für diesen Fall gemacht wurde.

Ähnlich wie in iOS’s Safari oder auf Sites wie Medium oder YouTube, iron-router-progress fügt eine kleine Loading-Bar an den Bildschirmanfang. Die Implementation ist ähnlich einfach wie das Hinzufügen des Packages zur Applikation.

mrt add iron-router-progress
bash console

Durch die Magie von smart packages, funktioniert unser Fortschritts-Balken wie von Geisterhand. Sie wird für alle Routes aktiviert und hört automatisch auf zu laden, sobald der Ladeprozess fertig ist.

Wir machen noch eine Verbesserung: Wir schalten das iron-router-progress Package für die postSubmint Route ab, da hier nicht auf Subscription-Daten gewartet wird (es ist ja bloss ein Formular):

Router.map(function() {

  //...

  this.route('postSubmit', {
    path: '/submit',
    disableProgress: true
  });
});
lib/router.js

Commit 12-5

Use the iron-router-progress package to make pagination n…

Auf irgendeinen Post zugreifen

Zur Zeit laden wir die fünf neusten Posts standardmässig, was passiert aber wenn jemand auf irgendeine Post-Page zugreifen möchte?

An empty template.
An empty template.

Wenn das zur Zeit versucht wird, wird das leere Post-Template angezeigt. Das macht Sinn, denn wir haben dem Router mitgeteilt er soll zur Post-List subscriben, sobald die postList Route aufgerufen wird, aber wir machen keine Angaben, was zu tun ist, wenn die postPage geroutet wird.

Bisher wissen wir nur wie man eine Liste der letzten n Posts subscribed. Wie fordern wir vom Server einen einzelnen Post an? Es ist Zeit ein kleines Geheimnis zu lüften: Du kannst mehr als eine Publication für jede Collection haben.

Damit wir die fehlenden Posts wieder abrufen können, erstellen wir ganz einfache eine separate singlePost Publication die nur einen Post publiziert, der über die _id identifiziert wird.

Meteor.publish('posts', function(options) {
  return Posts.find({}, options);
});

Meteor.publish('singlePost', function(id) {
  return id && Posts.find(id);
});
server/publications.js

Nun müssen wir im Client noch für die richtigen Posts subscriben. Wir haben schon eine Subscription auf die Comments Publication auf der postPage Route laufen, deswegen können wir die singlePost-Subscription hier hinzufügen. Auch dürfen wir nicht vergessen unsere Subscription ebenfalls der postEdit Route zur Verfügung zu stellen, da diese auch auf diese Daten zugreifen will.

Router.map(function() {

  //...

  this.route('postPage', {
    path: '/posts/:_id',
    waitOn: function() {
      return [
        Meteor.subscribe('singlePost', this.params._id),
        Meteor.subscribe('comments', this.params._id)
      ];
    },
    data: function() { return Posts.findOne(this.params._id); }
  });

  this.route('postEdit', {
    path: '/posts/:_id/edit',
    waitOn: function() { 
      return Meteor.subscribe('singlePost', this.params._id);
    },
    data: function() { return Posts.findOne(this.params._id); }    
  });

  /...

});
lib/router.js

Commit 12-6

Use a single post subscription to ensure that we can alwa…

Mit der Implementierung der Paginierung haben wir die Skalierbarkeits-Probleme der App behoben. Unsere Benutzer werden uns das mit dem Posten von mehr Links denn je danken. Wäre es nicht toll ein Bewertungssystem für die Posts zu haben? Genau um das geht es im nächsten Kapitel.

Abstimmung

13

Da unsere Seite jetzt immer beliebter wird, wird es immer schwieriger die besten Links zu finden. Was wir brauchen ist irgend eine Form von Bewertungssystem um unsere Posts zu ordnen.

Man könnte ein komppliziertes System mit Karma, zeitabhängigen Verfall von Punkten und viele andere Dinge (die meisten davon finden sich bei Telescope, Microscopes großem Bruder) einbauen. Doch für unsere App halten wir die Dinge einfach und bewerten die Posts einfach nach der Anzahl der Stimmen, die sie erhalten haben.

Lass uns damit beginnen Benutzern zu ermöglichen Posts zu bewerten.

Datenmodell

Wir speichern eine Liste von Befürwortern für jeden Post, damit wir wissen welchen Nutzern der Upvote-Button angezeigt werden soll, außerdem können wir so verhindern, dass Personen doppelt abstimmen.

Datenschutz & Publications

Da wir die Liste von Befürwortern allen Nutzern anzeigen werden, werden alle diese Daten auch öffentlich über die Browser Konsole zugänglich sein.

Dies ist die Form von Datenschutz-Problem, die durch Collections entstehen kann. Wollen wir beispielsweise, dass Personen die Möglichkeit haben herauszufinden, wer für ihre Posts gestimmt hat? In unserem Fall hat es keine wirklichen Konsequenzen, wenn wir die Informationen öffentlich zugänglich machen, aber es ist wichtig dieses Problem anzuerkennen.

Wir werden außerdem die Gesamtzahl von Befürwortern eines Posts denormalisieren, um diese Größe einfacher zugänglich zu machen. Also fügen wir unseren Posts zwei Attribute hinzu, upvoters und votes. Zunächst fügen wir sie in die Fixture-Datei ein:

// Fixture data 
if (Posts.find().count() === 0) {
  var now = new Date().getTime();

  // create two users
  var tomId = Meteor.users.insert({
    profile: { name: 'Tom Coleman' }
  });
  var tom = Meteor.users.findOne(tomId);
  var sachaId = Meteor.users.insert({
    profile: { name: 'Sacha Greif' }
  });
  var sacha = Meteor.users.findOne(sachaId);

  var telescopeId = Posts.insert({
    title: 'Introducing Telescope',
    userId: sacha._id,
    author: sacha.profile.name,
    url: 'http://sachagreif.com/introducing-telescope/',
    submitted: new Date(now - 7 * 3600 * 1000),
    commentsCount: 2,
    upvoters: [],
    votes: 0
  });

  Comments.insert({
    postId: telescopeId,
    userId: tom._id,
    author: tom.profile.name,
    submitted: new Date(now - 5 * 3600 * 1000),
    body: 'Interesting project Sacha, can I get involved?'
  });

  Comments.insert({
    postId: telescopeId,
    userId: sacha._id,
    author: sacha.profile.name,
    submitted: new Date(now - 3 * 3600 * 1000),
    body: 'You sure can Tom!'
  });

  Posts.insert({
    title: 'Meteor',
    userId: tom._id,
    author: tom.profile.name,
    url: 'http://meteor.com',
    submitted: new Date(now - 10 * 3600 * 1000),
    commentsCount: 0,
    upvoters: [],
    votes: 0
  });

  Posts.insert({
    title: 'The Meteor Book',
    userId: tom._id,
    author: tom.profile.name,
    url: 'http://themeteorbook.com',
    submitted: new Date(now - 12 * 3600 * 1000),
    commentsCount: 0,
    upvoters: [],
    votes: 0
  });

  for (var i = 0; i < 10; i++) {
    Posts.insert({
      title: 'Test post #' + i,
      author: sacha.profile.name,
      userId: sacha._id,
      url: 'http://google.com/?q=test-' + i,
      submitted: new Date(now - i * 3600 * 1000),
      commentsCount: 0,
      upvoters: [],
      votes: 0
    });
  }
}
server/fixtures.js

Wie üblich stoppen wir unsere App, führen meteor reset aus, starten sie wieder und erstellen einen neues Benutzerkonto. Dann stellen wir aber auch noch sicher, dass diese zwei Eigenschaften initialisiert werden, wenn Posts erstellt werden:

//...

var postWithSameLink = Posts.findOne({url: postAttributes.url});
if (postWithSameLink) {
  return {
    postExists: true
    _id: postWithSameLink._id
  }
}

var user = Meteor.user();
var post = _.extend(postAttributes, {
  userId: user._id, 
  author: user.username, 
  submitted: new Date(),
  commentsCount: 0,
  upvoters: [], 
  votes: 0
});

var postId = Posts.insert(post);

return {
  _id: postId
};

//...
collections/posts.js

Templates für Bewertungen

Zunächst einmal fügen wir einen Upvote-Button in unser Post-Partial ein und zeigen die Zahl der Befürworter in den Metadaten eines Posts:

<template name="postItem">
  <div class="post">
    <a href="#" class="upvote btn btn-de-fault"></a>
    <div class="post-content">
      <h3><a href="{{url}}">{{title}}</a><span>{{domain}}</span></h3>
      <p>
        {{votes}} Votes,
        submitted by {{author}},
        <a href="{{pathFor 'postPage'}}">{{commentsCount}} comments</a>
        {{#if ownPost}}<a href="{{pathFor 'postEdit'}}">Edit</a>{{/if}}
      </p>
    </div>
    <a href="{{pathFor 'postPage'}}" class="discuss btn btn-default">Discuss</a>
  </div>
</template>
client/templates/posts/post_item.html
Der Upvote-Button
Der Upvote-Button

Als nächstes rufen wir eine serverseitige Upvote-Methode auf, wenn der Nutzer auf den Button klickt:

//...

Template.postItem.events({
  'click .upvote': function(e) {
    e.preventDefault();
    Meteor.call('upvote', this._id);
  }
});
client/templates/posts/post_item.js

Letztendlich kehren wir zurück zur Datei lib/collections/posts.js und fügen eine Meteor serverseitige Methode ein, die es ermöglicht Posts zu bewerten.

//...

Meteor.methods({
  post: function(postAttributes) {
    //...
  },

  upvote: function(postId) {
    check(this.userId, String);
    check(postId, String);

    var post = Posts.findOne(postId);
    if (!post)
      throw new Meteor.Error('invalid', 'Post not found');

    if (_.include(post.upvoters, this.userId))
      throw new Meteor.Error('invalid', 'Already upvoted this post');

    Posts.update(post._id, {
      $addToSet: {upvoters: this.userId},
      $inc: {votes: 1}
    });
  }
});
lib/collections/posts.js

Commit 13-1

Added basic upvoting algorithm.

Diese Methode ist ziemlich überschaubar. Wir führen einige defensive Checks durch um sicherzustellen, dass der Benutzer eingeloggt ist und dass der Post wirklich existiert. Dann überprüfen wir genau, dass der Benutzer nicht schon abgestimmt hat und falls nicht, erhöhen wir die Anzahl der Befürworter und fügen den Benutzer dazu. Der letzte Schritt ist interessant, da wir einige spezielle Mongo Operatoren genutz haben. Es gibt noch viele weitere zu lernen, doch diese zwei sind extrem hilfreich: $addToSet fügt ein Item einer Array Property hinzu, wenn es noch nicht vorhanden ist und $inc erhöht einfach ein Integer-Feld.

Benutzeroberflächen-Optimierung

Wenn der Benutzer nicht eingeloggt ist oder bereits für den Post gestimmt hat, kann er nicht abstimmen. Um dem in der UI Rechnung zu tragen, nutzen wir einen Helper um ein disabled, konditional in die CSS Klasse des Upvote-Buttons einzuzufügen.

<template name="postItem">
  <div class="post">
    <a href="#" class="upvote btn btn-default {{upvotedClass}}"></a>
    <div class="post-content">
      //...
  </div>
</template>
client/templates/posts/post_item.html
Template.postItem.helpers({
  ownPost: function() {
    //...
  },
  domain: function() {
    //...
  },
  upvotedClass: function() {
    var userId = Meteor.userId();
    if (userId && !_.include(this.upvoters, userId)) {
      return 'btn-primary upvotable';
    } else {
      return 'disabled';
    }
  }
});

Template.postItem.events({
  'click .upvotable': function(e) {
    e.preventDefault();
    Meteor.call('upvote', this._id);
  }
});
client/templates/posts/post_item.js

Wir ändern die Klasse .upvote zu .upvotable, also sollte man nicht vergessen den Eventhandler “click” ebenfalls zu ändern.

Upvote-Buttons ausgrauen
Upvote-Buttons ausgrauen

Commit 13-2

Grey out upvote link when not logged in / already voted.

Jetzt fällt dir vielleicht auf, dass Posts mit nur einer Stimme mit “1 votes” gekennzeichnet werden, also sollten wir uns die Zeit nehmen die Label ordentlich zu pluralisieren. Pluralisierung kann ein komplizierter Vorgang sein, aber fürs erste setzen wir es ziemlich einfach um. Wir erstellen einen, von überall erreichbaren, Spacebars Helper:

Handlebars.registerHelper('pluralize', function(n, thing) {
  // fairly stupid pluralizer
  if (n === 1) {
    return '1 ' + thing;
  } else {
    return n + ' ' + thing + 's';
  }
});
client/helpers/handlebars.js

Die Helper, die wir bisher erstellt haben, waren alle an das dazugehörige Template gebunden. Indem wir Template.registerHelper benutzt haben, haben wir einen globalen Helper erstellt, den wir in jedem Template benutzen können:

<template name="postItem">
//...

<p>
  {{pluralize votes "Vote"}},
  submitted by {{author}},
  <a href="{{pathFor 'postPage'}}">{{pluralize commentsCount "comment"}}</a>
  {{#if ownPost}}<a href="{{pathFor 'postEdit'}}">Edit</a>{{/if}}
</p>

//...

</template>
client/templates/posts/post_item.html
Perfektioniere passende Pluralisierung (sag das zehn Mal!)
Perfektioniere passende Pluralisierung (sag das zehn Mal!)

Commit 13-3

Added pluralize helper to format text better.

Wir sollten nun 1 vote sehen.

Intelligenterer Abstimmungs-Algorithmus

Unser Code für die Abstimmung sieht gut aus, doch es geht noch besser. In der Upvote-Methode befinden sich zwei Mongo Abfragen: einer um den Post zu laden, den anderen um ihn zu updaten.

Es gibt dabei zwei Probleme. Erstens ist es irgendwie ineffizient die Datenbank zweimal abzufragen. Doch viel wichtiger ist, dass eine Race COndition vorliegt. Wir folgen dem Algorithmus:

  1. Lade den Post aus der Datenbank
  2. Überprüfe ob der Benutzer schon abgestimmt hat
  3. Falls nicht, zähle eine Stimme

Was wenn der selbe Benutzer für den Post zwischen Schritt 1 und 3 nochmal abstimmt? Unser jetziger Code ermöglicht es Nutzern für ein Post doppelt abzustimmen. Zum Glück gestattet uns MongoDB schlauer zu sein und Schritt 1-3 in einen einfachen Befehl zu kombinieren:

//...

Meteor.methods({
  post: function(postAttributes) {
    //...
  },

  upvote: function(postId) {
    check(this.userId, String);
    check(postId, String);

    var affected = Posts.update({
      _id: postId, 
      upvoters: {$ne: this.userId}
    }, {
      $addToSet: {upvoters: this.userId},
      $inc: {votes: 1}
    });

    if (! affected)
      throw new Meteor.Error('invalid', "You weren't able to upvote that post");
  }
});
collections/posts.js

Commit 13-4

Better upvoting algorithm.

Was wir sagen ist “finde alle Posts mit dieser id für die der Benutzer noch nicht abgestimmt hat und update sie wie folgt”. Hat der Nutzer noch nicht abgestimmt, wird natürlich der Post mit der idgefunden. Hat er aber schon abgestimmt, wird kein Dokument gefunden und nichts geschieht.

Latency Compensation

Nehmen wir an du hättest betrogen und einen deiner Posts an die Spitze der Liste beförder, indem du die Anzahl der Stimmen manipuliert hast:

> Posts.update(postId, {$set: {votes: 10000}});
Browser Konsole

(Dabei ist postId, die Id von einem deiner Posts.

Dieser dreiste Versuch, das System zu umgehen wäre vom Callback deny() abgefangen (in collection/posts.js, erinnerst du dich?) und sofort abgelehnt worden.

Schaut man jedoch genauer hin, kann man die Latency-Compensation in Aktion sehen. Sie ist zwar schnell, aber der Post wird kurz an die Spitze der Liste springen, bevor er wieder an den alten Platz zurückkehr.

Was ist passiert? In der lokalen Posts Collection wurde das update ohne Fehler ausgeführt. Das passiert sofort, also springt der Post an die Spitze und in der Zwischenzeit, wird das update auf dem Server abgelehnt. Kurz darauf (im Bereich von Millisekunden, wenn man Meteor auf dem eigenen Rechner betreibt), hat der Server einen Fehler übermittelt und der lokalen Collection mitgeteilt in den Urzustand zurückzukehren.

Das Resultat: während das User Interface auf den Server wartet, muss es der lokalen Collection vertrauen. Sobald der Server geantwortet und die Modifikation abgelehnt hat, reagiert die Benutzeroberfläche darauf.

Rangfolge der besten Posts

Da wir nun für jeden Post einen Wert haben, wie viele Stimmen abgegeben wurden, wollen wir eine Liste mit den besten Posts anzeigen. Um das zu tun, werden wir lernen wie man zwei unabhängige Subscriptions auf die Post-Collection verwaltet und wie wir unser Template postsList etwas allgemeiner gestallten können.

Zu Anfang brauchen wir zwei Subscriptions, eine für jede Sortierung. Der Trick dabei ist, dass beide Subscriptions die selbe Publication benutzen, lediglich mit unterschiedlichen Argumenten!

Wir erstellen auch zwei neue Routern namens newPosts und bestPosts, erreichbar über die URLs /new und best (und natürlich /new/5 und /best/5 für die Pagination).

Zu diesem Zweck erweitern wir den PostsListController um zwei unterschiedliche Controller: NewPostsListController und BestPostsListController. Damit können wir genau die selben Routen-Optionen für die Routen home und newPosts benutzen, indem wir einen einzigen NewPostsListController haben, von dem geerbt werden kann. Außerdem ist es eine gute Domonstration der Flexibilität von Iron Router.

Wir ersetzen also die SOrtierung {{submitted: -1}} in PostsListController durch this.sort, das von NewPostsListController und BestPostsListController zur Verfügung gestellt wird:

//...

PostsListController = RouteController.extend({
  template: 'postsList',
  increment: 5, 
  postsLimit: function() { 
    return parseInt(this.params.postsLimit) || this.increment; 
  },
  findOptions: function() {
    return {sort: this.sort, limit: this.postsLimit()};
  },
  subscriptions: function(){
    this.postsSub = Meteor.subscribe('posts', this.findOptions());
  },
  posts: function() {
    return Posts.find({}, this.findOptions());
  },
  data: function() {
    var hasMore = this.posts().count() === this.postsLimit();
    return {
      posts: this.posts(),
      nextPath: hasMore ? this.nextPath() : null
    };
  }
});

NewPostsController = PostsListController.extend({
  sort: {submitted: -1, _id: -1},
  nextPath: function() {
    return Router.routes.newPosts.path({postsLimit: this.postsLimit() + this.increment})
  }
});

BestPostsController = PostsListController.extend({
  sort: {votes: -1, submitted: -1, _id: -1},
  nextPath: function() {
    return Router.routes.bestPosts.path({postsLimit: this.postsLimit() + this.increment})
  }
});

Router.route('/', {
  name: 'home',
  controller: NewPostsCollection
});

Router.route('/new/:postsLimit?', {name: 'newPosts'});
Router.route('/best/:postsLimit?', {name: 'bestPosts'});

lib/router.js

Da es nun mehr als eine Route gibt verschieben wir die Logik nextPath aus dem PostsListController in den NewPostsController da der Pfad in jedem Fall unterschiedlich ist.

Wenn wir außerdem nach votes sortieren, haben wir weitere Bedingungen wie der Zeitstempel und letzlich _id damit die Reihenfolge komplett definiert ist.

Mit den neuen Controllern können wir die Route postsList jetzt entfernen. Lösche einfach den folgenden Code:

Router.route('/:postsLimit?', {
  name: 'postsList'
})
lib/router.js

Dem Header fügen wir die Links hinzu:

<template name="header">
  <header class="navbar navbar-default" role="navigation">
    <nav class="navbar-header">
      <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navigation">
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
      </button>
      <a class="navbar-brand" href="{{pathFor 'home'}}">Microscope</a>
      <div class="collapse navbar-collapse">
        <ul class="nav navbar-nav">
          <li>
            <a href="{{pathFor 'newPosts'}}">New</a>
          </li>
          <li>
            <a href="{{pathFor 'bestPosts'}}">Best</a>
          </li>
          {{#if currentUser}}
            <li>
              <a href="{{pathFor 'postSubmit'}}">Submit Post</a>
            </li>
            <li class="dropdown">
              {{> notifications}}
            </li>
          {{/if}}
        </ul>
        <ul class="nav navbar-nav navbar-right">
          <li>{{loginButtons}}</li>
        </ul>
      </div>
    </nav>
</template>
client/templates/include/header.html

Zum Schluss müssen wir noch unseren Post-Delete Eventhandler anpassen:

'click .delete': function(e) {
  e.preventDefault();

  if(confirm("Delete this post?")) {
    var currentPostId = this._id;
    Posts.remove(currentPostId);
    Router.go('home');
  }
}
client/templates/posts/posts_edit.js

Damit haben wir nun eine Liste mit den besten Posts:

Sortiert nach Punkten
Sortiert nach Punkten

Commit 13-5

Added routes for post lists, and pages to display them.

Ein besserer Header

Da wir jetzt zwei Listen mit Posts haben, kann es schwierig sein zu erkennen auf welcher man sich gerade befindet. Also passen wir den Header an um es offensichtlicher zu machen. Wir erstellen einen header.js Manager und einen Helper, der den gegenwärtigen Pfad nutzt und eine oder mehrere benannte Routen um eine Klasse in den Navigations-Elementen auf aktive zu setzen:

Der Grund warum wir mehrere benannte Routen unterstürzen sollten ist, dass sowohl die Route für home als auch die für newPosts (die den URLs /und /new entsprechen) das selbe Template aufrufen. Das bedeutet, dass unsere activeRouteClass intelligent genug sein sollte jeden der beiden <li> Tags im richtigen Moment aktiv zu setzen.

<template name="header">
  <header class="navbar navbar-default" role="navigation">
    <nav class="navbar-header">
      <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navigation">
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
        <span class="icon-bar"></span>
      </button>
      <a class="navbar-brand" href="{{pathFor 'home'}}">Microscope</a>
      <div class="collapse navbar-collapse">
        <ul class="nav navbar-nav">
          <li class="{{activeRouteClass 'home' 'newPosts'}}">
            <a href="{{pathFor 'newPosts'}}">New</a>
          </li>
          <li class="{{activeRouteClass 'bestPosts'}}">
            <a href="{{pathFor 'bestPosts'}}">Best</a>
          </li>
          {{#if currentUser}}
            <li class="{{activeRouteClass 'postSubmit'}}">
              <a href="{{pathFor 'postSubmit'}}">Submit Post</a>
            </li>
            <li class="dropdown">
              {{> notifications}}
            </li>
          {{/if}}
        </ul>
        <ul class="nav navbar-nav navbar-right">
          <li>{{loginButtons}}</li>
        </ul>
      </div>
    </nav>
</template>
client/templates/include/header.html
Template.header.helpers({
  activeRouteClass: function(/* route names */) {
    var args = Array.prototype.slice.call(arguments, 0);
    args.pop();

    var active = _.any(args, function(name) {
      return Router.current() && 
       Router.current().route.name === name
    });

    return active && 'active';
  }
});
client/templates/includes/header.js
Die aktive Seite anzeigen
Die aktive Seite anzeigen

Helper Argumente

Dieses spezielle Muster haben wir bislang noch nicht benutzt, doch genau wie jeder andere Spacebar Tag können Helper Tags Argumente haben.

Man kann natürlich benannte Argumente an die Funktion übergeben, aber man kann auch eine beliebige Anzahl von anonymen Parametern mitgeben und abrufen indem man innerhalb der Funktion das Objekt arguments aufruft.

Im letzten Fall sollte man das Objekt arguments in ein herkömmliches JavaScript Array konvertieren und dann pop() darauf anwenden um den Hash loszuwerden, den Spacebars am Ende eingefügt hat.

Der Helper activeRouteClass nimmt für jedes Element der Navigation eine Liste von Routen-Namen an und überprüft mit dem Underscore-Helper any() ob eine der Routen passt (zum Beispiel ob die URL der aktuell aufgerufenen entspricht). Stimmt eine der Routen mit der aktuellen überein, wird ein true zurückgegeben. Schließlich nutzen wir noch das JavaScript Pattern boolean && string, bei dem false && myString false zurückgibt, aber true && myString gibt myString zurück.

Commit 13-6

Added active classes to the header.

Da Benutzer Posts jetzt in Echtzeit bewerten können, springen die Posts auf der Seite hin und her, wenn sich die Anzahl der Bewertungen ändert. Wäre es nicht schön, wenn dies alles flüssig passieren würde mit einer passenden Animation?

Advanced Publications

Sidebar 13.5

////

Publishing a Collection Multiple Times

////

////

////

////

////

Publishing a collection twice
Publishing a collection twice
Meteor.publish('allPosts', function() {
  return Posts.find({}, {fields: {title: true, author: true}});
});

Meteor.publish('postDetail', function(postId) {
  return Posts.find(postId);
});

////

////

////

////

Meteor.publish('newPosts', function(limit) {
  return Posts.find({}, {sort: {submitted: -1}, limit: limit});
});

Meteor.publish('bestPosts', function(limit) {
  return Posts.find({}, {sort: {votes: -1, submitted: -1}, limit: limit});
});
server/publications.js

Subscribing to a Publication Multiple Times

////

////

////

Subscribing twice to one publication
Subscribing twice to one publication

////

Meteor.publish('posts', function(options) {
  return Posts.find({}, options);
});

////

Meteor.subscribe('posts', {submitted: -1, limit: 10});
Meteor.subscribe('posts', {baseScore: -1, submitted: -1, limit: 10});

////

////

Multiple Collections in a Single Subscription

////

////

////

////

////

////

////

Two collections in one subscription
Two collections in one subscription
Meteor.publish('topComments', function(topPostIds) {
  return Comments.find({postId: topPostIds});
});

////

////

Meteor.publish('topPosts', function(limit) {
  var sub = this, commentHandles = [], postHandle = null;

  // send over the top two comments attached to a single post
  function publishPostComments(postId) {
    var commentsCursor = Comments.find({postId: postId}, {limit: 2});
    commentHandles[post._id] = 
      Meteor.Collection._publishCursor(commentsCursor, sub, 'comments');
  }

  postHandle = Posts.find({}, {limit: limit}).observeChanges({
    added: function(id, post) {
      publishPostComments(post._id);
      sub.added('posts', id, post);
    },
    changed: function(id, fields) {
      sub.changed('posts', id, fields);
    },
    removed: function(id) {
      // stop observing changes on the post's comments
      commentHandles[id] && commentHandles[id].stop();
      // delete the post
      sub.removed('posts', id);
    }
  });

  sub.ready();

  // make sure we clean everything up (note `_publishCursor`
  //   does this for us with the comment observers)
  sub.onStop(function() { postsHandle.stop(); });
});

////

////

////

Linking different collections

////

One collection for two subscriptions
One collection for two subscriptions

////

////

////

////

  Meteor.publish('videos', function() {
    var sub = this;

    var videosCursor = Resources.find({type: 'video'});
    Meteor.Collection._publishCursor(videosCursor, sub, 'videos');

    // _publishCursor doesn't call this for us in case we do this more than once.
    sub.ready();
  });

////

////

Animations

14

////

Meteor & the DOM

////

////

////

////

  1. ////
  2. ////
  3. ////
  4. ////
  5. ////
  6. ////

////

Swtiching two posts
Swtiching two posts

////

////

////

Proper Timing

////

////

////

////

////

////

CSS Positioning

////

////

////

////

////

.post{
  position:relative;
  transition:all 300ms 0ms ease-in;
}
client/stylesheets/style.css

////

////

Position:absolute

////

////

Total Recall

////

////

////

////

////

Ranking Posts

////

////

////

////

Template.postsList.helpers({
  postsWithRank: function() {
    this.posts.rewind();
    return this.posts.map(function(post, index, cursor) {
      post._rank = index;
      return post;
    });
  }
});
/client/views/posts/posts_list.js

////

////

<template name="postsList">
  <div class="posts">
    {{#each postsWithRank}}
      {{> postItem}}
    {{/each}}

    {{#if nextPath}}
      <a class="load-more" href="{{nextPath}}">Load more</a>
    {{/if}}
  </div>
</template>
/client/views/posts/posts_list.html

Be Kind, Rewind

////

////

////

Putting it together

////

Template.postItem.helpers({
  //...
});

Template.postItem.rendered = function(){
  // animate post from previous position to new position
  var instance = this;
  var rank = instance.data._rank;
  var $this = $(this.firstNode);
  var postHeight = 80;
  var newPosition = rank * postHeight;

  // if element has a currentPosition (i.e. it's not the first ever render)
  if (typeof(instance.currentPosition) !== 'undefined') {
    var previousPosition = instance.currentPosition;
    // calculate difference between old position and new position and send element there
    var delta = previousPosition - newPosition;
    $this.css("top", delta + "px");
  }

  // let it draw in the old position, then..
  Meteor.defer(function() {
    instance.currentPosition = newPosition;
    // bring element back to its new original position
    $this.css("top",  "0px");
  }); 
};

Template.postItem.events({
  //...
});
/client/views/posts/post_item.js

Commit 14-1

Added post reordering animation.

////

////

////

Animating New Posts

////

////

  1. ////
  2. ////

////

////

Template.postItem.helpers({
  //...
});

Template.postItem.rendered = function(){
  // animate post from previous position to new position
  var instance = this;
  var rank = instance.data._rank;
  var $this = $(this.firstNode);
  var postHeight = 80;
  var newPosition = rank * postHeight;

  // if element has a currentPosition (i.e. it's not the first ever render)
  if (typeof(instance.currentPosition) !== 'undefined') {
    var previousPosition = instance.currentPosition;
    // calculate difference between old position and new position and send element there
    var delta = previousPosition - newPosition;
    $this.css("top", delta + "px");
  } else {
    // it's the first ever render, so hide element
    $this.addClass("invisible");
  }

  // let it draw in the old position, then..
  Meteor.defer(function() {
    instance.currentPosition = newPosition;
    // bring element back to its new original position
    $this.css("top",  "0px").removeClass("invisible");
  }); 
};

Template.postItem.events({
  //...
});
/client/views/posts/post_item.js

Commit 14-2

Fade items in when they are drawn.

////

CSS & JavaScript

////

////

////

Weiterkommen

14.5

Die letzten Kapitel haben dir hoffentlich einen Überblick darüber verschafft, was es heißt, eine Meteor App zu entwickeln. Wie geht es jetzt für dich weiter?

Weitere Kapitel

Falls du es noch nicht getan hast, kannst du die Komplett oder Premium Editionen erwerben und weitere Kapitel freischalten. Diese Kapitel beschäftigen sich mit bekannten Anwendungsszenarien, wie beispielweise die Erstellung einer API für deine App, die Integration von anderer Software, oder der Migration von bereits vorhandenen Daten.

Anleitung von Meteor

Die offizielle Anleitung und Dokumentation von Meteor beschreiben beispielsweise die Themen Tracker oder Blaze noch umfangreicher.

Evented Mind

Möchtest du einen noch tieferen Blick in die Einzelheiten von Meteor werfen, dann empfehlen wir dir sehr die Videoplattform von Chris Mathers Evented Mind. Dort erwarten Dich über 50 individuelle Videos rund um Meteor und es kommen wöchentlich weitere dazu.

MeteorHacks

Die beste Möglichkeit, um Meteor auch weiterhin verfolgen zu können, ist sich an Arunoda Susiripalas MeteorHacks wöchentlichen Newsletter anzumelden. Der MeteorHacks ist eine weitere großartige Anlaufstelle für fortgeschrittene Tipps rund um Meteor.

Atmosphere

Atmosphere ist ein inoffizielles Package Repoistory für Meteor und ebenfalls ein toller Weg um Neues zu lernen: du kannst neue Packages entdecken und direkt einen Blick in dessen Code hineinwerfen und schauen, nach welchem Muster andere Personen dieses verwenden.

(Randnotiz: Atmosphere wird unter anderem von Tom Coleman bereut, er ist einer der Autoren von diesem Buch.)

Meteorpedia

Meteorpedia ist ein Wiki, welches sich mit allen Dingen rund um Meteor beschäftigt. Natürlich wurde es mit Meteor gebaut!

BulletProof Meteor

Eine weitere Initiative von Arunodas MeteorHacks ist BulletProof Meteor. Dort wirst du durch eine Handvoll Meteor Lektionen geführt, um deine App noch leistungsstärker und effektiver zu machen. Nach jeder Lektion wird dein Wissen mit Fragen auf die Probe gestellt, die von dir beantwortet werden müssen.

Der Meteor Podcast

Josh und Ry vom Meteor Shop Differential nehmen [den wöchentlichen Meteor Podcast] auf. Dies ist ein großartiger Weg, um auf dem neuesten Stand zu bleiben und zu wissen, was bei Meteor und der Community passiert.

Weitere Hilfsquellen

Stephan Hochhaus hat eine sehr ausführliche List von Meteor Resources zusammengestellt.

Der Blog von Manuel Schoebel verfügt über weitere hilfreiche Beiträge rund um Meteor. Das Gleiche gilt für den Gentlenode Blog.

Erhalte Hilfe

Solltest du einmal über ein Hindernis stolpern und alleine nicht mehr weiterkommen, dann ist der beste Ort für deine Fragen Stack Overflow. Bitte stelle sicher, dass du deine Frage mit dem Tag meteor versiehst.

Community

Bleibe aktiv in der Community, um stets auf dem aktuellsten Stand zu bleiben. Wir empfehlen, dass du dich bei der Mailing List von Meteor einträgst und Meteor Core sowie Meteor Talk Google Groups im Auge behältst. Du kannst dich auch gerne im Meteor Forum Crater.io registrieren.

Meteor-Glossar

99

Client

////

Collection

////

Computation

////

Cursor

////

DDP

////

Deps

////

Document

////

Helpers

////

Latency Compensation

////

Meteor Development Group (MDG)

////

Method

////

MiniMongo

////

Package

////

Publication

////

Server

////

Session

////

Subscription

////

Template

////

Template Data Context

////

Kommentare

10

Das Ziel einer Social-Media-News-Site ist es eine aktive Benutzer-Community zu erschaffen. Damit dieses Ziel erreicht werden kann, ist es unerlässlich, dass sich die Benutzer austauschen können. Dazu lass uns in diesem Kapitel eine Kommentarfunktion hinzufügen.

Comments = new Meteor.Collection('comments');
collections/comments.js
// Fixture data 
if (Posts.find().count() === 0) {
  var now = new Date().getTime();

  // create two users
  var tomId = Meteor.users.insert({
    profile: { name: 'Tom Coleman' }
  });
  var tom = Meteor.users.findOne(tomId);
  var sachaId = Meteor.users.insert({
    profile: { name: 'Sacha Greif' }
  });
  var sacha = Meteor.users.findOne(sachaId);

  var telescopeId = Posts.insert({
    title: 'Introducing Telescope',
    userId: sacha._id,
    author: sacha.profile.name,
    url: 'http://sachagreif.com/introducing-telescope/',
    submitted: now - 7 * 3600 * 1000
  });

  Comments.insert({
    postId: telescopeId,
    userId: tom._id,
    author: tom.profile.name,
    submitted: now - 5 * 3600 * 1000,
    body: 'Interesting project Sacha, can I get involved?'
  });

  Comments.insert({
    postId: telescopeId,
    userId: sacha._id,
    author: sacha.profile.name,
    submitted: now - 3 * 3600 * 1000,
    body: 'You sure can Tom!'
  });

  Posts.insert({
    title: 'Meteor',
    userId: tom._id,
    author: tom.profile.name,
    url: 'http://meteor.com',
    submitted: now - 10 * 3600 * 1000
  });

  Posts.insert({
    title: 'The Meteor Book',
    userId: tom._id,
    author: tom.profile.name,
    url: 'http://themeteorbook.com',
    submitted: now - 12 * 3600 * 1000
  });
}
server/fixtures.js

Wir dürfen nicht vergessen die neue Collection im Server zu veröffentlichen (publish) und im Router zu abonnieren (subscribe):

Meteor.publish('posts', function() {
  return Posts.find();
});

Meteor.publish('comments', function() {
  return Comments.find();
});
server/publications.js
Router.configure({
  layoutTemplate: 'layout',
  loadingTemplate: 'loading',
  waitOn: function() { 
    return [Meteor.subscribe('posts'), Meteor.subscribe('comments')];
  }
});
lib/router.js

Commit 10-1

Added comments collection, pub/sub and fixtures.

Damit der Fixture-Code ausgeführt wird, musst du vorher die Datenbank mit: meteor reset entleeren. Denk daran einen neuen Benutzer nach dem Reset anzulegen.

Im Fixture-Code legen wir als Erstes ein paar Dummy-Benutzer an, fügen sie zur Datenbank hinzu und verwenden die generierten ids um sie anschließend wieder aus der Datenbank abzurufen. Anschließend fügen wir für jeden Benutzer einen Kommentar am ersten Beitrag hinzu. Die Verbindung vom Kommentar zum Beitrag stellen wir mit dem Attribut postId her und die Verbindung zum Benutzer analog mit dem Attribut userId. Weiterhin bekommen unsere Komentare ein Anlagedatum (submitted), ein Attribut für den Kommentartext (body) und den Namen des Authors (ein denormalisiertes Feld).

Bleibt anzumerken, dass wir unseren Router so erweitert haben, dass nun auf die Initialisierung der posts als auch der comments gewartet wird.

Displaying comments

Wir schreiben die Kommentare nun erfolgreich in die Datenbank, aber wir müssen sie auch auf der Diskussions-Seite zur Anzeige bringen. Die notwendigen Schritte um dies zu erreichen, sollten dir inzwischen ziemlich vertraut sein.

<template name="postPage">
  {{> postItem}}

  <ul class="comments">
    {{#each comments}}
      {{> comment}}
    {{/each}}
  </ul>
</template>
client/views/posts/post_page.html
Template.postPage.helpers({
  comments: function() {
    return Comments.find({postId: this._id});
  }
});
client/views/posts/post_page.js

Im Template postPage fügen wir den Block {{#each comments}} ein. Innerhald des Helpers comments entspricht this damit einem Beitrag. Um die zugehörigen Kommenentare zu finden selektieren wir die Kommentare nach der ID des Beitrags.

Berücksichtigen wir, was wir über Helpers und Handlebars gelernt haben, gestaltet sich das Rendering eines Kommentars als ziemlich unkompliziert. Wir legen ein neues Verzeichnis namens comments im views Verzeichnis an. Dort werden alle Informationen zu unseren Kommentaren gespeichert.

<template name="comment">
  <li>
    <h4>
      <span class="author">{{author}}</span>
      <span class="date">on {{submittedText}}</span>
    </h4>
    <p>{{body}}</p>
  </li>
</template>
client/views/comments/comment.html

Lass uns einen einfachen Template-Helper definieren, der dazu dient das Anlagedatum in einem menschenlesbaren Format auszugeben (es sei denn, du gehörst zu denen, die UNIX-Timestamps und hexadezimale Farbencodes flüssig lesen und schreiben können.)

Template.comment.helpers({
  submittedText: function() {
    return new Date(this.submitted).toString();
  }
});
client/views/comments/comment.js

////

<template name="postItem">
  <div class="post">
    <div class="post-content">
      <h3><a href="{{url}}">{{title}}</a><span>{{domain}}</span></h3>
      <p>
        submitted by {{author}},
        <a href="{{pathFor 'postPage'}}">{{commentsCount}} comments</a>
        {{#if ownPost}}<a href="{{pathFor 'postEdit'}}">Edit</a>{{/if}}
      </p>
    </div>
    <a href="{{pathFor 'postPage'}}" class="discuss btn">Discuss</a>
  </div>
</template>
client/views/posts/post_item.html

////

Template.postItem.helpers({
  ownPost: function() {
    return this.userId == Meteor.userId();
  },
  domain: function() {
    var a = document.createElement('a');
    a.href = this.url;
    return a.hostname;
  },
  commentsCount: function() {
    return Comments.find({postId: this._id}).count();
  }
});
client/views/posts/post_item.js

Commit 10-2

Display comments on `postPage`.

Die Fixture-Kommentare solltest du nun zur Anzeige bringen können. Das Ganze sollte in etwa so aussehen:

Displaying comments
Displaying comments

Submitting Comments

Lass uns einen Weg bereitstellen, der es den Benutzern erlaubt neue Kommentare hinzuzufügen. Dies gestaltet sich ziemlich ähnlich zur Vorgehensweise, die wir angewandt haben, um das Anlegen neue Beiträgen zu ermöglichen.

Wir beginnen mit dem Hinzufügen eines Kommentarbereichs am Ende eines jeden Beitrags.

<template name="postPage">
  {{> postItem}}

  <ul class="comments">
    {{#each comments}}
      {{> comment}}
    {{/each}}
  </ul>

  {{#if currentUser}}
    {{> commentSubmit}}
  {{else}}
    <p>Please log in to leave a comment.</p>
  {{/if}}
</template>
client/views/posts/post_page.html

Und erzeugen dann das Formular-Template zur Anlage eines Kommentars.

<template name="commentSubmit">
  <form name="comment" class="comment-form">
    <div class="control-group">
        <div class="controls">
            <label for="body">Comment on this post</label>
            <textarea name="body"></textarea>
        </div>
    </div>
    <div class="control-group">
        <div class="controls">
            <button type="submit" class="btn">Add Comment</button>
        </div>
    </div>
  </form>
</template>
client/views/comments/comment_submit.html
The comment submit form
The comment submit form

Zum Übermitteln unseres Kommentars wird die Methode comment im Manager commentSubmit aufgerufen. Dieser arbeitet analog zu dem Manager postSubmit:

Template.commentSubmit.events({
  'submit form': function(e, template) {
    e.preventDefault();

    var $body = $(e.target).find('[name=body]');
    var comment = {
      body: $body.val(),
      postId: template.data._id
    };

    Meteor.call('comment', comment, function(error, commentId) {
      if (error){
        throwError(error.reason);
      } else {
        $body.val('');
      }
    });
  }
});
client/views/comments/comment_submit.js

Genau wie wir vorher die serverseitige Meteor-Methode post definiert haben, legen wir nun die Meteor-Methode comment an, wir überprüfen die übermittelten Daten, erzeugen einen Kommentar-Objekt und fügen dieses in die Kommentar-Collection ein.

Comments = new Meteor.Collection('comments');

Meteor.methods({
  comment: function(commentAttributes) {
    var user = Meteor.user();
    var post = Posts.findOne(commentAttributes.postId);
    // ensure the user is logged in
    if (!user)
      throw new Meteor.Error(401, "You need to login to make comments");

    if (!commentAttributes.body)
      throw new Meteor.Error(422, 'Please write some content');

    if (!post)
      throw new Meteor.Error(422, 'You must comment on a post');

    comment = _.extend(_.pick(commentAttributes, 'postId', 'body'), {
      userId: user._id,
      author: user.username,
      submitted: new Date().getTime()
    });

    return Comments.insert(comment);
  }
});
collections/comments.js

Commit 10-3

Created a form to submit comments.

Es passieren nicht wirklich komplizierte Dinge: wir prüfen, dass ein Benutzer angemeldet ist, dass der Kommentar einen body hat und dass der Kommentar auf einen existierenden Beitrag verweist.

Controlling the Comments Subscription

Momentan veröffentlichen wir alle Kommentare aller Beiträge an alle verbundenen Clients. Das erscheint etwas verschwenderisch. Denn eigentlich verwenden wir ja nur einen kleinen Anteil der Daten zu jedem gegebenen Zeitpunkt. Nachfolgend werden wir unsere Publication und Subscription anpassen, um genau zu kontrollieren welche Kommentare veröffentlicht werden.

Wenn wir darüber nachdenken, dann ist der einzige Zeitpunkt an dem wir überhaupt auf Kommentare zugreifen müssen, der Moment in dem wir einen individuellen Beitrag anzeigen. Und wir müssen nur die Kommentare laden, die zu diesem Beitrag gehören.

Im ersten Schritt werden wir die Art und Weise ändern, wie wir Kommentare abonnieren (subscribe). Bisher haben wir die Kommentare auf der Router-Ebene abonniert, was bedeutet, dass wir die gesamten Daten laden, wenn wir den Router initialisieren.

Aber jetzt wollen wir, dass die Subscription abhängig von einem Pfad-Parameter ist. Dieser Parameter kann sich offensichtlich zu jedem Zeitpunkt ändern. Also müssen wir unsere Subscription von der Router-Ebene auf die Route-Ebene verschieben.

Daraus ergibt, dass wir unsere Daten nicht mehr beim Initialisieren unserer App laden, sondern wenn die Route aufgerufen wird. Daraus resultieren Wartezeiten beim Browsen innerhalb unsere Anwendung. Was jedoch unvermeidlich ist, wenn wir nicht sämtliche Daten bereits beim Initialiesern unserer App laden wollen.

So sieht der Code unserer neuen Route-Level-Funktion waitOn aus:

Router.map(function() {

  //...

  this.route('postPage', {
    path: '/posts/:_id',
    waitOn: function() {
      return Meteor.subscribe('comments', this.params._id);
    },
    data: function() { return Posts.findOne(this.params._id); }
  });

  //...

});
lib/router.js

Dir ist sicher aufgefallen, dass wir this.params._id als Argument an die Subscription übergeben. Also lass uns nun diese Information benutzen um die Menge der Kommentare auf die einzuschränken, die zum aktuellen Beitrag gehören.

Meteor.publish('posts', function() {
  return Posts.find();
});

Meteor.publish('comments', function(postId) {
  return Comments.find({postId: postId});
});
server/publications.js

Commit 10-4

Made a simple publication/subscription for comments.

Es gibt eigentlich nur ein Problem: wenn wir wieder auf die Homepage zurückkehren, behauptet unsere App, dass alle Beiträge null Kommentare haben:

Our comments are gone!
Our comments are gone!

Counting Comments

Der Grund dafür wird schnell klar: wir haben zu jedem Zeitpunkt die Kommentare von maximal einem unserer Beiträge geladen. Wenn wir also Comments.find({postId: this._id}) im Helper commentsCount im Manager post_item aufrufen, kann Meteor die notwendigen client-seitigen Daten nicht finden und damit auch nicht als Resultat zur Verfügung stellen.

Der beste Weg damit umzugehen ist die Anzahl der Kommentare zu denormalisieren, also als Attribut des Beitrags zu speichern (keine Sorge, falls Du nicht sicher bist was damit gemeint ist, die nächste Sidebar hilft Dir weiter.) Auch wenn wir, wie wir gleich sehen werden, eine geringfügig höhere Komplexität in Kauf nehmen, so gewinnen wir doch erhebliche Performancevorteile, dadurch, dass wir nicht alle Kommentare veröffentlichen müssen um die Liste der Beiträge anzuzeigen.

Wir erreichen dies in dem wir das Attribut commentsCount zur Datenstruktur post hinzufügen. Wir beginnen beim Aktualisieren des Fixture-Codes unseres Beitrags (und meteor reset um die Beiträge erneut zu laden – denk daran die Benutzerkennung wiederherzustellen):

var telescopeId = Posts.insert({
  title: 'Introducing Telescope',
  ..
  commentsCount: 2
});

Posts.insert({
  title: 'Meteor',
  ...
  commentsCount: 0
});

Posts.insert({
  title: 'The Meteor Book',
  ...
  commentsCount: 0
});
server/fixtures.js

Dann stellen wir sich, dass allen neuen Beiträge mit 0 Kommentaren erstellt werden:

// pick out the whitelisted keys
var post = _.extend(_.pick(postAttributes, 'url', 'title', 'message'), {
  userId: user._id, 
  author: user.username, 
  submitted: new Date().getTime(),
  commentsCount: 0
});

var postId = Posts.insert(post);
collections/posts.js

Und dann aktualisieren wir den commentsCount wenn wir einen neuen Kommentar erstellen, indem wir Mongo’s $inc Operator (welcher ein numerisches Feld um den Wert eins erhöht) verwenden:

// update the post with the number of comments
Posts.update(comment.postId, {$inc: {commentsCount: 1}});

return Comments.insert(comment);
collections/comments.js

Schlussendlich können wir den Helper commentsCount aus client/views/posts/post_item.js entfernen, da das Feld nun direkt am Beitrag verfügbar ist.

Commit 10-5

Denormalized the number of comments into the post.

Nun, da die Benutzer miteinander sprechen können, wäre es eine Schande wenn sie neue Kommentare verpassen würden. Tja, jetzt kannst Du vielleicht den Inhalt des nächsten Kapitels erraten: wir werden Benachrichtigungen implementieren.