ASCII���Screenshot

Web App Game

Ziel dieses Blocks ist, die Grundlagen der Web-App-Programmierung zu kennen. Dazu programmieren wir Schritt für Schritt ein einfaches Spiel (Vier gewinnt), das im Browser (inklusive Mobiltelefon) gegeneinander gespielt werden kann.

Das Verteilen und Verkaufen von Programmen war über Jahrzehnte aufwändig, fehleranfällig und teuer. Eine Web-App hingegen kann jede:r im hintersten Winkel der Erde programmieren und auf einem Server für 4 Milliarden Internetnutzer 1) bereitstellen. Eine Web-App ist sehr niederschwellig, da die Benutzer nichts installieren müssen. Eine gute Web-App läuft auf allen Plattformen und Betriebssystemen (Desktop, iOS, Android…).

Eine Web-App erlaubt es, neue Funktionen zuerst mit einer kleinen Anzahl von Benutzern auszuprobieren, was das iterative Verbessern einer Anwendung ermöglicht.

Wir gehen Schritt für Schritt vor:

  1. UI: User Interface mit HTML und CSS entwerfen (1 Woche).
  2. Server-less Game: Logik mit Javascript in Browser implementieren (2 Wochen).
  3. Server-based Game mit Flask:
    1. HTML mit Templates vereinfachen (1 Woche).
    2. Server-Schnittstelle mit JSON entwerfen (1 Woche).
    3. Gameplay auf den Server verschieben (1 Woche).
    4. Deployment

Web-Basics: HTML & CSS

HTML und CSS sind die Sprachen des Web. Mit ihnen werden (fast) alle Webseiten auf eine Weise beschrieben, dass jeder Browser weiss, wie sie angezeigt werden sollen. Viele werden beidem schon begegnet sein - wem sie noch etwas fremd sind, findet hier eine gute Einführung. Als Referenz-Werk für alle Elemente und Attribute empfehle ich SelfHtml.

ASCII���Screenshot

  • Erstelle ein github Repository für das Projekt.
  • Erstelle eine neue Datei, z.B. connect_four.html mit dem HTML Inhalt für das Spiel Vier-Gewinnt.
  • Hinweise:
    • Ich empfehle, für den Raster zwei Ebenen von <div> Elementen zu verwenden und auf ein <table> zu verzichten.
    • Da die einzelnen Felder gedrückt werden können, bietet sich ein <button> Element an.
    • Standard-Vier-Gewinnt hat 6 Reihen à 7 Spalten.
  • Binde eine CSS-Datei ein (z.B. connect_four.css).
  • Experimentiere mit CSS, um das User Interface etwas ansprechender zu gestalten. Das Resultat sollte sich an ein richtiges Vier-Gewinnt Spiel anlehnen.
  • Teile das Repository mit deinen Lehrpersonen per Email.
  • Hinweise:
    • Benutze die DevTools in Chrome oder Edge.
    • In den Dev-Tools kannst du das CSS live verändern.
    • Für den Anfang die wichtigsten CSS-Eigenschaften sind:
      • background-color
      • height
      • min-width
      • margin
      • border
      • border-radius
      • box-shadow
    • Mit den CSS-Funktionen calc() und var() erleichterst du dir das Leben…
  • Erreichst du für deine Webseite die folgenden Eigenschaften?
    • Sie hat ein Favicon.
    • Sie sieht auf einem Mobiltelefon gut aus.
    • Sie passt sich an die Grösse des Fensters (des Mobilgeräts) an.

Mein Code als Zip-Archiv hier. Wichtig: es gibt nicht nur eine Möglichkeit, und meine Lösung stellt nur eine von vielen dar. Ich fand zum Beispiel die relativen Grössenangaben mit vw und vh eine gute Alternative zu Viewport-Queries. ksr_ef_viergewinnt-css_and_html.zip

Javascript

Die meisten Browser können Javascript (oder ECMAScript) ausführen, um Webseiten dynamisch zu gestalten. Die Syntax ist ähnlich wie C++, C# oder Java, am bestem am Beispiel:

connect_four.js
let game = new ConnectFourModel();
 
/**
 * Returns the parent div element containing the Connect-Four grid.
 *
 * @param {Element} parent
 */
function findFourConnectUi(parent) {
    return parent.getElementById("grid");
}
 
const view = new ConnectFourView(findFourConnectUi(document));
view.connectToGame(game);
  • Variablen durch let deklariert, Konstanten durch const.
  • Ausdrücke werden durch ein Semikolon beendet.
  • Auf den DOM (also, das HTML) kann über die globale document Variable zugegriffen werden.
  • Ein gesuchtes HTML-Element kann z.B. über den Tag-Namen (getElementsByName()) oder eine Id (getElementById()) gefunden werden.
  • Javascript ist nicht typisiert - Variablen und Funktionsparameter haben keinen deklarierten Typ. Um Fehler zu vermeiden kann der Typ im Kommentar angegeben werden: @param {Element} parent.
view.js
/**
 * The UI of a game of connect-four.
 */
class ConnectFourView {
    /**
     * Creates a new view that will update the given button grid and winner area.
     *
     * @param {Element} grid 
     */
    constructor(grid) {
        this.grid = grid;
    }
}
  • Klassen kapseln Zustand und Funktion (in Methoden) und können einen Constructor haben.
  • Felder (this.grid) werden durch Zuweisung erzeugt und müssen nicht deklariert werden.
  • Grundsätzlich sind alle Members (Felder und Methoden) öffentlich.
view.js
// ... in class ConnectFourView
    /**
     * Connects this view to the given game.
     * 
     * @param {ConnectFourModel} game 
     */
    connectToGame(game) {
        let index = 0;
        for (let button of this.grid.getElementsByTagName("button")) {
            // Use a constant value that will be captured in the 
            // event listener. Use modulo operator to compute the column
            // from the button index.
            const column = index % game.width;
            button.addEventListener("click", () => {
                game.insertPiece(column);
                this.fillHtml(game);
            });
            index++;
        }        
    }
 
    /**
     * Updates the view (button elements) to match the game state.
     * 
     * @param {ConnectFourModel} game 
     */
    fillHtml(game) {
        // Modify the HTML DOM to match the game state, using the following properties:
        //   element.setAttribute(<attribute>, "value")
        //   element.innerHTML = "content"
        //   element.classList.add()
    }
  • Reaktion auf Benutzerverhalten durch Callbacks, die mit addEventListener an ein Signal gebunden wird (im obigen Beispiel wird eine Funktion an den click Event des Button Elements gebunden).
  • Um Probleme mit this zu vermeiden, empfehle ich, für Callbacks sogenannte Arrow-Functions zu verwenden.
  • Binde eine Javascript-Datei connect_four.js in die HTML-Datei ein.
  • Schreibe Javascript-Code mit folgenden Eigenschaften:
    • Wird auf einer der Buttons gedrückt, wird in der entsprechenden Spalte ein Stein des momentanen Spielers eingefügt.
    • Gewinner werden identifiziert.
  • Hinweise:
    • Verwende die obigen Code-Schnipsel als Startgerüst, wenn der Anfang schwierig ist.
    • Javascript-Referenz bei Mozilla

Wenn das alles zu einfach ist, oder du Lust auf mehr hast:

  • Schaffst du es, dasselbe Spiel auf zwei separaten Teilen der Seite darzustellen?
    • Tipp: Separiere das Modell des Spiels (model) von der Darstellung (view).
    • Das Muster (en. pattern) dahinter heisst Model-View-Controller und ist in vielen Variationen in der Informatik weit verbreitet.
    • Wenn du zusätzlich zu Model und View noch den Controller separierst, wäre es möglich zum Beispiel eine Read-only-View darzustellen, die sich nicht klicken lässt…

Als Vorbereitung für den nächsten Schritt:

  • Füge noch eine weitere Indirektion hinzu: der View soll statt eines Javascript models nur ein JSON-Objekt erhalten, um seine Darstellung zu ändern.
  • JSON wird die Sprache sein, die wir vom Web-Server an die App ausliefern werden.
  • Hinweis: JSON Referenz

Wie immer gilt: es gibt unendlich viele korrekte Lösungen. ksr_ef_viergewinnt-js_game.zip

HTTP

Das HyperText Transfer Protocol wird benützt, um Webseiten (und CSS und JS und Bilder…) zwischen einem Client (z.B. dem Browser) und einem Server auszutauschen. Wir schauen uns an, was passiert, wenn wir auf unsere Webapp zugreifen. In den DevTools zeigen wir den Network Tab, wo die Folge der HTTP Requests angezeigt werden.

Jeder Request verlangt eine bestimmte Ressource mit einer bestimmten Method. Die Method beschreibt die Aktion, typischerweise GET, um eine Ressource zu holen, POST um eine Ressource auf dem Server zu verändern.

Der Request enthält eine Anzahl zusätzlicher Informationen als Headers, insbesondere:

  • If-modified-since: <timestamp>
    • Das Dokument nur zurückgeben, wenn es seit dem Zeitstempel verändert wurde.
    • Sonst antwortet der Server mit einem 304, um Caching zu ermöglichen, so dass nicht immer die ganze Seite übertragen werden muss.
  • Referer: Welche Seite oder Host hat die Anfrage ausgelöst.
    • wird dazu verwendet, das Surfverhalten von Nutzern zu analysieren
  • User-agent:
    • Enthält Informationen zum Browser des Benutzers.
    • Kann zum Beispiel verwendet werden, um ein Mobiltelefon von einem Desktop-Computer zu unterscheiden.

Der Server antwortet mit einem Response Code und zusätzlichen Informationen, zum Beispiel der angeforderten Ressource. Die wichtigsten Codes sind:

  • 200 (OK): Anfrage ist erfolgreich, die Ressource ist im Response Body enthalten.
  • 304 (UNMODIFIED): Anfrage ist in Ordnung, die Ressource ist unverändert und wird darum nicht zurückgegeben (Nur bei GET, nicht bei POST)
  • 404 (NOT FOUND): Die Ressource wurde nicht gefunden.

Zusätzlich kann der Server noch Cookies mitliefern, die der Browser speichert und beim nächsten Request an den gleichen Server im Request mitgeliefert werden.

Session-Cookies leben nur, solange das Browser-Fenster offen ist und sind in der Regel unproblematisch. Long-lived cookies können über Jahre installiert bleiben und bei jedem Seitenbesuch erneuert werden. Sie werden zum Teil benützt, um Benutzer über Webseiten hinweg zu verfolgen, und haben zur ganzen Diskussion um Cookies geführt. Um den Benutzer zu identifizieren (zum Beispiel nach erfolgreichem Login) sind sie aber unverzichtbar.

Web-App mit Flask

Bislang haben wir den Inhalt unserer App lokal von unserem Gerät geladen. Das hat ein paar Nachteile:

  • Nicht jedermann darauf Zugriff, die App ist also noch sehr begrenzt nutzbar.
  • Der Inhalt ist statisch, d.h. wir können das HTML nicht je nach Benutzeraktion frei zusammenstellen.
  • Den Inhalt von connect_four.html könnten wir viel kompakter Darstellen, wenn wir Schleifen benützen könnten.
  • Es gibt keinen globalen Zustand, der zwischen mehreren Benutzern geteilt werden könnte. Nix Multiplayer.

Um diese Problem zu lösen, werden wir die App auf einen Web-Server verschieben.

  • Install Flask mit pip3 install flask.
  • Use the quick start guide to help you with the following tasks.
  • Transfer existing code to web app:
    • Move static files to /static.
    • Create app.py in root folder to create the simple-most web app.
app.py
from flask import Flask
 
app = Flask(__name__)
 
@app.route("/")
def hello_world():
    return "<p>Hello, World!</p>"
  • Turn HTML into a Jinja2 template.
  • Create a connect-four game model in python.
  • Our App will communicate with the Server through JSON endpoints
  • Instead of holding game state on the client side, fetch it from the server.

Weitere Ideen:

  • Long polling für den Game-State
  • Websockets (nicht direkt unterstützt in Flask, aber in Flask-SocketIO oder Quart)

Javascript 2

Requests werden im klassischen Web für eine HTML-Ressource und die darin eingebundenen Ressourcen (Stylesheets, Bilder, Javascript) ausgelöst. Sie können aber auch vom Javascript-Code angefordert werden. Dieser Mechanismus (Fetch) wird von allen modernen Webapps benutzt, um Inhalte dynamisch nachzuladen. Beispielsweise lädt Google Maps Kartenkacheln nach, wenn der Kartenausschnitt durch Zoomen oder Verschieben verändert wird.

Wir werden diese Technik verwenden, um den Spielstand auf dem Server nachzuladen, wenn der andere Spieler gespielt hat.

Eine Herausforderung in Javascript (und nicht nur dort) ist der Umgang mit längeren Aufgaben. Reagiert unser Code ja auf einen Event (z.B. einen Klick) und führt dann eine kurze Aufgabe (z.B. den Game-State ändern) aus, ist das kein Problem. Wollen wir eine potentiell längere Aufgabe (insbesondere: Network-Traffic über fetch(), oder eine Animation) ausführen, reklamiert der Browser, weil wir damit den Event-Thread blockieren. Das heisst, dass andere Events nicht richtig ausgeliefert werden können, und die App „eingefroren“ ist.

Wir wollen längere Aufgaben asynchron oder nebenläufig ausführen können. Modernes Javascript unterstützt die Programmierung von asynchronem Code mit zwei Konstrukten: Promises und den async / await Keywords, die auf Promise aufbauen.

Eine Promise ist ein Versprechen für das Resultat einer nebenläufigen Aufgabe. Die Promise ist zu beginn im Zustand pending und geht von da entweder über zu fulfilled (die Aufgabe war erfolgreich) oder rejected (die Aufgabe ist fehlgeschlagen). fulfilled und rejected werden zusammen als settled bezeichnet.

Mit Promises kann ich eine Sequenz von voneinander abhängigen Aufgaben zusammenstellen, ohne dass diese voneinander wissen müssen. Dafür nutzt man die Methoden then() und catch(), die beide als Argument eine Funktion haben, die erst ausgeführt wird, wenn die Promise fullfilled (bei then) oder rejected (bei catch) wird:

class ConnectFourView {
  updateUi(json) {
    // Updates the view using json.cells
  }
 
  fetchUpdateFromServer() {
    fetchPromise = fetch('/gamestate');
    jsonPromise = fetchPromise.then(response => response.json());
    updatePromise = jsonPromise.then(json => updateUi(json));
    updatePromise.catch(error => console.log(error));
  }
}

Was geht in fetchUpdateFromServer vor?

  • Fetch beginnt den gegebenen URL zu laden und gibt eine Promise zurück, die in Zukunft erfüllt werden wird.
    • Ist fetch erfolgreich, wird eine Response zurückgegeben.
  • Mit then hängen wir der Promise eine weitere Funktion an, die erst ausgeführt wird, wenn die erste erfolgreich war.
    • Die Funktion erhält als Argument das Resultat der vorangehenden Promise, also die Response
    • Response.json() gibt eine Promise zurück auf den geparsten JSON-Inhalt der Response.
  • Ist die zweite Promise erfolgreich, passen wir die View anhand des JSON-Inhalts an.
    • Die letzte Promise erhält einen Failure-Handler (catch). Schlägt eine der vorgelagerten Promises fehl, wird auch die letzte Promise rejected, der Fehler wird in der Konsole ausgegeben.

Merke:

  • Wenn die Funktion fetchUpdateFromServer fertig ausgeführt wird, ist das Update noch lange nicht erfolgt. Die Promise-Kette wird nebenläufig ausgeführt, die Seite „gefriert“ nicht.

Das Ganze lässt sich mit Chaining auch kompakter darstellen:

  fetchUpdateFromServer() {
    fetch('/gamestate')
      .then(response => response.json())
      .then(json => updateUi(json))
      .catch(error => console.log(error));
  }

Promises sind grossartig, aber der Code ist etwas gewöhnungsbedürftig. Seit 2017 kann dasselbe eleganter geschrieben werden:

  async fetchUpdateFromServer() {
    try {
      const response = await fetch('/gamestate');
      const json = await response.json();
      updateUi(json));
    } catch (error) {
      console.log(error);
    }
  }

Merke:

  • await wartet auf das Fulfilment einer Promise. Das bedeutet auch, dass die Methode lange dauern könnte.
  • await ist deshalb nur in async Funktionen erlaubt. Async Funktionen geben immer eine Promise zurück, auch wenn das im Code nicht mehr sichtbar ist.
  • Fehler können mit dem gewohnten try-catch gefangen werden.


  • ef_informatik/web_app.txt
  • Zuletzt geändert: 2021-12-15 13:41
  • von hof