Webprogrammierung in Python

In einem früheren Kapitel haben wir HTML zur Beschreibung von Webseiten kennengelernt und später auch dynamische Webseiten mit Hilfe von Javascript definiert, die im Browser ausgeführt werden. Neben dieser client-seitigen Webprogrammierung gibt es auch server-seitige Webprogrammierung. Dabei werden die dynamischen Anteile nicht im Browser ausgeführt sondern auf dem Webserver, so dass Webseiten aus dort (zum Beispiel in einer Datenbank) gespeicherten Daten generiert werden können.

HTML mit Python generieren

Wir wollen im Folgenden einen Webserver in Python implementieren. Zunächst sehen wir uns dazu an, wie wir HTML-Quelltext in Python generieren können. Da HTML-Quelltext Text ist, können wir ihn in Python als Zeichenkette darstellen. Zum Beispiel liefert die folgende Funktion ein <h1>-Tag mit übergebenem Inhalt dargestellt als Zeichenkette zurück:

def heading(title):
    return f'<h1>{title}</h1>'

Wir können diese Funktion im Python-Terminal mit dem folgenden Aufruf testen:

>>> heading('Hallo')
'<h1>Hallo</h1>'

Wenn wir als Argument der heading-Funktion HTML-Quelltext übergeben, wird dieser unverändert in das Ergebnis eingebaut:

>>> heading('</h1><script>fire_missiles();</script><h1>')
'<h1></h1><script>fire_missiles();</script><h1></h1>'

Dies kann in Kombination mit Benutzereingaben zu Sicherheitsproblemen führen, den sogenannten Script Injections: Das bedeutet, es ist möglich, potenziell bösartigen Javascript-Code in eine HTML-Seite einzubauen, der beim Abrufen der Seite im Webbrowser des Opfers ausgeführt wird. Später sehen wir, was wir dagegen tun können.

Zunächst wollen wir ein etwas komplexeres HTML-Fragment definieren. Die folgende Funktion erzeugt aus einer übergebenen Liste von Zeichenketten eine ungeordnete Liste mit entsprechenden Einträgen:

def unordered_list(items):
    result = '<ul>'
    for item in items:
        result = result + f'<li>{item}</li>'
    result = result + '</ul>'
    return result

Das Ergebnis wird hier schrittweise in der Variablen result zusammengebaut und am Ende des Funktionsrumpfes zurückgegeben. Der folgende Aufruf dokumentiert die Verwendung der definierten Funktion:

>>> unordered_list(['essen', 'lesen'])
'<ul><li>essen</li><li>lesen</li></ul>'
Vertiefung: Listenerstellung mit List Comprehension

Schließlich wollen wir nun die beiden definierten Funktionen verwenden, um ein komplettes HTML-Dokument zu generieren:

todo_list = ['essen', 'lesen']

def todo_page():
    return '''<!DOCTYPE html>
<html>
  <head>
    <title>Todo</title>
    <meta charset="utf-8">
  </head>
  <body>
    ''' + heading('Todo-Liste') + unordered_list(todo_list) + '''
  </body>
</html>'''

Das erzeugte Dokument sieht mit print(todo_page()) ausgegeben wie folgt aus:

<!DOCTYPE html>
<html>
  <head>
    <title>Todo</title>
    <meta charset="utf-8">
  </head>
  <body>
    <h1>Todo-Liste</h1><ul><li>essen</li><li>lesen</li></ul>
  </body>
</html>

Gegenüber einer Definition in HTML wirken die gezeigten Funktionen vergleichsweise kompliziert und unhandlich. Zum einen kommt das daher, dass sie durch die Parametrisierung allgemeiner sind als der konkrete HTML-Quelltext, der durch einen Aufruf der definierten Funktion entsteht. Aber auch das explizite Hantieren mit Zeichenketten verkompliziert die Definitionen. Wir werden später sehen, wie sich das dynamische Generieren von HTML mittels HTML-Templates vereinfachen lässt.

HTML-Erzeugung mit Dominate

In den Vorlesungen zur Weiterbildung wird dieser Abschnitt nicht behandelt. Stattdessen werden HTML-Templates verwendet, um HTML-Quelltext in Python zu erzeugen.

Mit Hilfe des Python-Pakets dominate können wir die gezeigten Funktionen leserlicher definieren. Um das Paket zu nutzen, installieren wir es über die Paketverwaltung und binden es anschließend zu Beginn unseres Python-Programms ein:

from dominate import *

Die Funktion zum Erzeugen der Überschrift sieht mit diesem Paket zum Beispiel wie folgt aus:

def heading_dom(title):
    result = tags.h1(title)
    return result

Die Klasse tags.h1 stellt ein <h1>-Element dar. Für jedes denkbare HTML-Element ist im Modul tags eine entsprechende Klasse definiert, die wir verwenden können, um HTML-Elemente zu erzeugen und zusammenzusetzen.

Als Rückgabewert wird nun ein Objekt zurückgegeben, das wir mit der str-Funktion in eine Zeichenkette umwandeln können. Hier ist ein Beispielaufruf der definierten Funktion:

>>> str(heading_dom('Hallo'))
'<h1>Hallo</h1>'

Durch Verwendung des dominate-Pakets wird nun HTML-Quelltext im Argument der Funktion anders behandelt als bei unserer vorherigen Definition:

>>> str(heading_dom('</h1><script>fire_missiles();<script><h1>'))
'<h1>&lt;/h1&gt;&lt;script&gt;fire_missiles();&lt;script&gt;&lt;h1&gt;</h1>'

Alle Sonderzeichen werden HTML-spezifisch so umgewandelt, dass die Überschrift später im Browser genau so aussieht, wie die Zeichenkette, die wir übergeben haben.

Die Funktion zum Erzeugen einer Liste können wir wie folgt anpassen:

def unordered_list_dom(items):
    result = tags.ul()
    for item in items:
        result += tags.li(item)
    return result

Hier wird erst ein <ul>-Element erzeugt (eine unsortierte Liste) und dann in einer for-Wiederholung mehrere Listenelemente <li> erzeugt und zu dieser Liste hinzugefügt. Wir können also beliebige Python-Konstrukte verwenden, um Anweisungen zur HTML-Generierung zu definieren. Die Implementierung mit Hilfe des dominate-Pakets stellt sicher, dass alle Elemente korrekt geschachtelt sind und dass schließende Tags zu den entsprechenden öffnenden Tags passen.

Auch Elemente mit Attributen lassen sich auf diese Weise erzeugen. Dazu passen wir die Zeile in der for-Wiederholung wie folgt an:

    result += tags.li(tags.a(item, href='#' + item))

Attribute werden also als benannte Argumente übergeben. Der Aufruf unordered_list_dom(['essen','lesen']) erzeugt jetzt ein Objekt, das mit print die folgende Ausgabe erzeugt (inkl. Zeilenumbrüchen und Einrückungen):

<ul>
  <li>
    <a href="#essen">essen</a>
  </li>
  <li>
    <a href="#lesen">lesen</a>
  </li>
</ul>

Schließlich wollen wir nun die beiden definierten Funktionen verwenden, um ein komplettes HTML-Dokument zu generieren:

todo_list = ['essen', 'lesen']

def todo_page():
    doc = document(title='Todo')
    doc.head += tags.meta(charset='utf-8')
    doc.body += heading_dom('Todo-Liste')
    doc.body += unordered_list_dom(todo_list)
    return str(doc)

Der Aufruf document(title='Todo') erzeugt ein HTML-Dokument mit dem als Argument angegebenen Titel. Dieses Dokument enthält Header- und Body-Elemente, auf die wir über die Attribute head und body zugreifen können. Zum Header wird noch ein <meta>-Element mit dem charset-Attribut hinzugefügt. Der Operator += fügt ein HTML-Element als neues Kindelement in ein anderes HTML-Element ein, in diesem Fall die Rückgaben der Funktionsaufrufe heading_dom und unordered_list_dom. Das erzeugte Dokument sieht mit print ausgegeben wie folgt aus:

<!DOCTYPE html>
<html>
  <head>
    <title>Todo</title>
    <meta charset="utf-8">
  </head>
  <body>
    <h1>Todo-Liste</h1>
    <ul>
      <li>
        <a href="#essen">essen</a>
      </li>
      <li>
        <a href="#lesen">lesen</a>
      </li>
    </ul>
  </body>
</html>

HTTP-Anfragen mit Python beantworten

Um ein Python-Programm zu schreiben, das HTTP-Anfragen mit generiertem HTML-Quelltext beantwortet, verwenden wir das Webentwicklungs-Framework bottle.

Nachdem wir das Paket installiert haben1 und es mit from bottle import * in unser Programm eingebunden haben, sorgt die folgende Anweisung dafür, dass Anfragen an den gestarteten Server mit der oben definierten Seite beantwortet werden, wenn der Pfad der zugehörigen URL / ist (also auf das Wurzelverzeichnis bzw. die Homepage der Webanwendung zugegriffen wird).

@get('/')
def get_index():
    return 'Hallo!'

Die Zeile @get('/') sorgt dafür, dass das Programm HTTP-GET-Anfragen an den Pfad / beantwortet.2 Bei jeder solchen Anfrage wird die Funktion ausgeführt, die direkt nach der Zeile @get definiert ist. In diesem Fall ist das die Funktion get_index (der Funktionsname kann beliebig gewählt werden), die als Ergebnis den Text “Hallo!” zurückgibt. Dieses Ergebnis des Funktionsaufrufs wird als Antwort an den Client geschickt, der die HTTP-GET-Anfrage gestellt hat.

Die so definierten Funktionen zum Beantworten von HTTP-Anfragen werden im Folgenden als Rückruffunktionen (engl. request callback) bezeichnet.

In einer Webanwendung sind üblicherweise mehrere solcher Funktionen mit @get mit verschiedenen Pfaden definiert. Bei einer Anfrage an eine URL wird die Rückruffunktion mit dem passenden Pfad aufgerufen, sofern eine vorhanden ist. Anderenfalls wird vom Server eine Fehlerseite als Antwort zurückgegeben (HTTP-Fehlercode 404: “Nicht gefunden”).

Im obigen Beispiel werden einfache Textdaten als Ergebnis zurückgegeben. Dieser Text kann natürlich auch HTML-Quelltext sein, der von der Rückruffunktion zusammengestellt wird (z. B. mit der Funktion todo_page() aus den obigen Beispielen) oder aus einer Datei gelesen wird.

Server starten

Um den HTTP-Server zu starten, muss am Ende des Programms noch die Funktion run aufgerufen werden.

run(host='localhost', port=8080)

In diesem Beispiel wird über die beiden benannten Argumente host und port angegeben, dass der HTTP-Server nur lokal erreichbar ist und über den Port 8080 Nachrichten empfängt.

Wir können das Programm wie folgt im Terminal starten, wenn wir es in einer Datei server.py abspeichern:

$ python server.py

Dieser Aufruf startet einen HTTP-Server auf Port 8080. Wir können also in einem Webbrowser über die URL localhost:8080 eine Anfrage stellen und bekommen dann die Todo-Liste angezeigt.3

Während der Entwicklung von Webanwendungen empfehlen wir, den Bottle-Server im Debug-Modus mit “Auto Reloading” zu starten: In diesem Modus startet der Server im laufenden Betrieb automatisch neu, wenn der Quellcode des Programms geändert und gespeichert wird, stellt umfangreichere Informationen zum Debugging (im Terminal-Log und in Fehlermeldungen) bereit, und deaktiviert Optimierungen, die während der Entwicklung störend sein können (z. B. Caching). Dazu wird das Programm mit dem folgenden Aufruf gestartet:

run(host='localhost', port=8080, debug=True, reloader=True)

Statische Dateien zurückliefern

Statt eines Strings, der HTML beschreibt, kann eine mit @get gekennzeichnete Funktion auf eine Anfrage auch eine statische Datei als Antwort zurückschicken, die auf dem Server liegt. Dazu wird die Funktion static_file aus dem Bottle-Modul aufgerufen, die einen Dateinamen und einen lokalen Ordnernamen als Argumente erwartet:

@get('/help')
def get_help():
    return static_file('help.html', 'static')

Nun liefert eine GET-Anfrage an den Pfad /help die HTML-Datei help.html als Antwort zurück, die im Ordner static (relativ zur Programmdatei) liegt.

Diese Methode lässt sich auch verwenden, um statische Dateien wie Bilder oder CSS-Dateien (Stylesheets) vom Server abzurufen:

@get('/static/logo.png')
def get_logo():
    return static_file('logo.png', 'static')

Pfade mit Platzhaltern

Es lassen sich auch Funktionen definieren, die nicht nur auf Anfragen an einen konkreten Pfad wie /, /help oder /static/logo.png antworten, sondern auf Pfade mit Platzhaltern. Solche Platzhalter werden im Pfad von @get in spitzen Klammern geschrieben und der dazugehörigen Rückruffunktion als Funktionsparameter hinzugefügt:

@get('/greeting/<name>')
def get_greeting(name):
    return 'Hallo, ' + name + '!'

Beim Aufruf der Funktion wird der konkrete Wert des Platzhalters als Argument übergeben und kann so im Funktionsrumpf verarbeitet werden. Hier wird jede GET-Anfrage an Pfade der Form /greeting/Zeichenkette durch die oben definierte Funktion beantwortet, wobei die Parametervariable name die Zeichenkette, die auf /greeting/ folgt, als Wert hat. Eine Anfrage an den Pfad /greeting/Alice liefert beispielsweise als Antwort den Text “Hallo, Alice!”.

Das ist besonders nützlich, um statische Dateien mit beliebigen Dateinamen abrufbar zu machen:

@get('/static/<filename>')
def get_static_file(filename):
    return static_file(filename, 'static')

Hier wird jede GET-Anfrage an Pfade der Form /static/Dateiname durch die oben definierte Funktion beantwortet, indem die Datei namens Dateiname aus dem lokalen Ordner static zurückgeschickt wird.

Die so definierte Rückruffunktion get_static_file erlaubt es uns, dass sich alle Dateien im Ordner static vom Webserver abrufen lassen, ohne dass wir für jede Datei eine separate Rückruffunktionen definieren müssen. Das ist beispielsweise hilfreich, wenn unsere Webanwendung viele Bilder enthält, die wir so einfach nur zum Ordner static hinzufügen müssen. Mit dem HTML-Element <a src="/static/Dateiname"> wird das Bild dargestellt.

HTML-Templates

Viele Webentwicklung-Framework stellen die Funktion bereit, HTML-Seiten dynamisch auf Grundlage von HTML-Vorlagen, sogenannten Templates zu erstellen. Ein HTML-Template in Bottle ist eine Datei, die neben HTML auch Python-Ausdrücke enthalten kann. Diese Datei wird nicht direkt zurückgegeben, sondern zuerst werden alle enthaltenen Ausdrücke ausgewertet und durch die Ergebnisse ersetzt. Dieser Prozess, aus dem Template den endgültigen HTML-Text zu erstellen, wird als “Rendering” bezeichnet.

Ein HTML-Template in Bottle kann außerdem sogar Python-Anweisungen enthalten, die beim Rendering ausgewertet werden – beispielsweise eine for-Wiederholung, um Teile des Templates mehrmals nacheinander zu rendern.

Diese Vorgehensweise bietet mehrere Vorteile gegenüber dem manuellen Zusammenstellen der HTML-Texte im Python-Code (wie oben): Zu einen sehen die HTML-Templates zu großen Teilen bereits aus wie der fertige HTML-Quellcode und lassen sich daher einfacher entwickeln. Zum anderen können wir so den Python-Code der Webanwendung und den HTML-Quellcode der Benutzeroberfläche in verschiedene Dateien aufteilen.

Zur Demonstration schreiben wir zuerst ein HTML-Template, das unsere Todo-Liste aus dem obigen Beispiel beschreibt:

<!DOCTYPE html>
<html>
  <head>
    <title>Todo</title>
    <meta charset="utf-8">
  </head>
  <body>
    <h1>{{title}}</h1>
    <ul>
% for item in items:
      <li>{{item}}</li>
% end
    </ul>
  </body>
</html>

Bottle erwartet, dass die Template-Dateien im selben Verzeichnis liegen wie das Server-Programm oder in einem Unterordner namens views. Der Übersichtlichkeit halber legen wir einen neuen Ordner namens views in dem Verzeichnis an, in dem unsere Datei server.py liegt, und speichern das HTML-Template in einer Datei namens index.tpl im Ordner views. Als Dateiendung für HTML-Templates ist .tpl oder .html üblich. Wir verwenden im Folgenden .tpl zur Unterscheidung der Templates von reinen HTML-Dateien.

Bis auf zwei Besonderheiten sieht das Template aus wie regulärer HTML-Quellcode: Wir erkennen zum einen eingebettete Python-Ausdrücke title und item, sowie eine Wiederholung mit for, die zur Unterscheidung vom HTML-Quellcode mit bestimmten Sonderzeichen markiert werden ({{}} um den Ausdruck bzw. % am Zeilenanfang der Anweisung), worauf wir später detaillierter eingehen werden.

Das Python-Programm server.py passen wir nun wie folgt an:

from bottle import *

todo_list = ['essen', 'lesen']

@get('/')
def get_index():
    return template('index.tpl', title='Todo-Liste', items=todo_list)

run(host='localhost', port=8080)

Der Aufruf der Funktion template aus dem Bottle-Modul “rendert” die angegebene Template-Datei (hier index.tpl) und schickt den resultierenden HTML-Text als Antwort zurück.Über zusätzliche benannte Argumente (hier: title='Todo-Liste' und items=todo_list) können Variablen definiert werden, die beim Rendering verwendet werden, um Ausdrücke im Template auszuwerten. Auf diese Weise lassen sich dynamische Inhalte in die Seite einbauen.

Weitere Details: Aufruf der Funktion template

Python-Ausdrücke in Templates

Um einen Python-Ausdruck in ein Template einzubetten, wird dieser in doppelten geschweiften Klammern geschrieben, z. B.:

<h1>{{title}}</h1>

Hier wird der Text im Absatz aus der Variablen title übernommen. Diese Variable muss entweder innerhalb des Templates definiert werden oder dem Template beim Aufruf der template-Funktion als benanntes Argument mitgegeben werden, z. B.:

@get('/')
def get_index():
    return template('index.tpl', title='Todo-Liste', items=todo_list)

Beim Rendern des HTML-Templates wird der Ausdruck ausgewertet, hier also durch den Text “Todo-Liste” ersetzt, da der Variablen title beim Aufruf die Zeichenkette 'Todo-Liste' als Wert zugewiesen wird.

Dabei werden alle Sonderzeichen im Auswertungsergebnis HTML-spezifisch so umgewandelt, dass die Überschrift später im Browser genau so aussieht, wie die Zeichenkette, die wir übergeben haben.

Python-Anweisungen in Templates

Um Python-Anweisungen in einem Template anzugeben, muss die entsprechende Zeile mit dem Zeichen % beginnen. Hier wird z. B. eine Variable num innerhalb des Templates definiert und anschließend in einem eingebetteten Ausdruck verwendet:

% num = len(items)
<p>Die Liste enthält {{num}} Elemente.</p>

Auf diese Weise lassen sich auch Kontrollstrukturen angeben, mit denen Teile des HTML-Templates mehrmals wiederholt vorkommen (while, for) oder in Abhängigkeit von einer Fallunterscheidung vorkommen oder weggelassen werden (if, elif, else).

Ein typischer Anwendungsfall ist, dass wir für jedes Element einer Liste ein HTML-Element erzeugen möchten. Das folgende Beispiel erzeugt für jedes Element der Liste items einen Listeneintrag mit dem entsprechenden Element als Text:

<ul>
% for item in items:
  <li>{{item}}</li>
% end
</ul>

Wenn beim Aufruf des Templates die Liste items=['eins', 'zwei', 'drei'] übergeben wurde, sieht das gerenderte Ergebnis folgendermaßen aus:

<ul>
  <li>eins</li>
  <li>zwei</li>
  <li>drei</li>
</ul>

Wir können hier zusätzlich eine Fallunterscheidung im Template verwenden, um statt der HTML-Liste einen Warnhinweis darzustellen, falls die Liste items keine Elemente enthält:

% if len(items) == 0:
<p>Die Liste ist leer.</p>
% else:
<ul>
%   for item in items:
  <li>{{item}}</li>
%   end
</ul>
% end

Nun wird entweder der Teil <p>Die Liste ist leer.</p> oder die Liste <ul> ... </ul> gerendert, je nachdem ob die beim Aufruf des Templates übergebene Liste items leer ist oder nicht.

Im Gegensatz zu “echtem” Python-Code wird bei den Python-Anweisungen in den HTML-Templates die Einrückung ignoriert. Damit trotzdem klar ist, wo ein Block endet, muss jeder Block hier explizit mit der Zeile % end abgeschlossen werden. Es empfiehlt sich aber der Übersichtlichkeit halber, Blöcke trotzdem wie gewohnt einzurücken.

Strukturierung mit HTML-Templates

HTML-Templates müssen nicht unbedingt vollständige HTML-Seiten enthalten, sondern können auch HTML-Teile enthalten, die sich in andere Templates einbetten lassen. Die folgenden Zeile in einem Template fügt beispielsweise an dieser Stelle den Inhalt aus einem anderen Template ein:

% include('sub.tpl')

Der Aufruf der speziellen Funktion include innerhalb eines Templates rendert das angegebene Untertemplate (hier sub.tpl) und fügt das Ergebnis an dieser Stelle in das aktuelle Template ein. Optional können hier (wie beim Aufruf der Funktion template) benannte Argumente angegeben werden, die als Variablen im Untertemplate vorkommen.

Auf diese Weise lassen sich Templates auf mehrere Dateien aufteilen, was die Übersichtlichkeit der einzelnen Template-Dateien erleichtert. Außerdem ist es so möglich, mit Hilfe von Templates wiederverwendbare Komponenten zu definieren. Wir können beispielsweise ein Template card.tpl schreiben, das den HTML-Code für eine “Karten”-Komponente mit einem Titel und Text enthält (hier mit CSS gestaltet):

<div style="border: 1px solid black; border-radius: 0.5rem; padding: 1rem;">
  <h5>{{card_title}}</h5>
  <p>{{card_text}}}}</p>
</div>

Diese Komponente kann nun mittels include in anderen Templates als Baustein verwendet werden. Dem Aufruf der Funktion include müssen hier noch die im inkludierten Template zusätzlich verwendeten Variablen (hier: card_title und card_text) als benannte Argumente übergeben werden, z. B.:

% include('card.tpl', card_title='Aufgabe #1', card_text='HTML-Templates in Komponenten aufteilen')

Andersherum kann sich ein Template auch selbst in ein anderes Template einbetten, sich also mit dem Inhalt eines anderen Templates “umgeben”. Dazu wird zu Beginn des Templates die spezielle Funktion rebase aufgerufen:

% rebase('super.tpl')

Beim Rendern des Templates wird nun auch das angegebene äußere Template (hier super.tpl) gerendert, und das Template an einer bestimmten Stelle dort eingefügt – nämlich dort, wo im äußeren Template {{!base}} steht. Genauer gesagt: Nachdem das Template selbst gerendert wurde, wird das Ergebnis nicht sofort zurückgegeben, sondern dem angegebenen äußeren Template (hier super.tpl) in einer Variablen namens base übergeben. Das äußere Templates wird nun gerendet (wobei der eingebettete Ausdruck {{!base}} durch das Ergebnis des inneren Templates ersetzt wird) und liefert das Ergebnis.

Der Standard-Verwendungszweck für rebase besteht darin, das HTML-Grundgerüst, das in jeder HTML-Seite der Webanwendung gleich ist, nur einmal in einem Template festzulegen und in allen anderen Templates nur noch den eigentlichen Seiteninhalt – das folgende Beispiel sollte diese Vorgehensweise klarer machen:

Wir schreiben eine Template-Datei namens base.tpl mit dem HTML-Grundgerüst als Inhalt:

<!DOCTYPE html>
<html>
  <head>
    <title>Todo</title>
    <meta charset="utf-8">
  </head>
  <body>
    {{!base}}
  </body>
</html>

Nun können wir in allen Templates mit Seiteninhalten das Grundgerüst weglassen und verwenden stattdessen rebase, um anzugeben, dass der äußere HTML-Code aus dem Template base.tpl übernommen werden soll. Der Inhalt der Templates selbst wird an der Stelle {{!base}} eingefügt. (Das Ausrufezeichen ist hier notwendig, damit der eingefügte HTML-Code unverändert bleibt und Sonderzeichen nicht durch die entsprechenden HTML-Escape-Sequenzen ersetzt werden, z. B. <h1> durch &lt;h1&gt;.)

% rebase('base.tpl')

<h1>{{title}}</h1>
<ul>
% for item in items:
  <li>{{item}}</li>
% end
</ul>

Wie bei include können beim Aufruf von rebase auch Variablen zum Rendern des äußeren Templates als benannte Argumente angegeben werden. Soll beispielsweise der Dokumententitel für jede Seite unserer Webanwendung unterschiedlich sein, ersetzen wir die entsprechende Zeile im Grundgerüst base.tpl durch:

    <title>{{doc_title}}</title>

und geben beim Aufruf von rebase den Dokumententitel mit an, der zum Rendern der Seite verwendet werden soll, z. B.:

% rebase('base.tpl', doc_title='Todo - Details')

Formulare verarbeiten

Im Kapitel HTML-Formulare für Benutzereingaben haben wir Formulare in HTML kennengelernt. Im Folgenden beschäftigen wir uns damit, wie sich Formulardaten serverseitig in Bottle verarbeiten lassen.

Zur Erinnerung: HTML-Formulare werden durch form-Bereiche definiert, die Eingabeelemente enthalten. Das Element input mit type="text" beschreibt ein Texteingabefeld. Ein Formular enthält in der Regel einen Button (Element button oder input mit type="submit") zum Abschicken der Formulardaten:

<form action="/newitem" method="post">
  <label>Beschreibung</label>
  <input type="text" name="itemtext">
  <button type="submit">Speichern</button>
</form>

Wird ein HTML-Formular abgeschickt, werden die im Formular eingegebenen Werte als Anfrageparameter mit der HTTP-Anfrage an den Webserver mitgeschickt. Die Parameternamen entsprechen den Namen der Formular-Eingabeelemente (name-Attribut). Ziel der Anfrage ist die URL bzw. der Pfad, der im action-Attribut angegeben ist. Das Attribut method legt hier fest, dass eine POST-Anfrage (statt einer GET-Anfrage) verschickt wird.

In der Rückruffunktion (mit @post markiert) für diesen Pfad können wir über das Objekt request.params auf die Anfrageparameter zugreifen:

@post('/newitem')
def save_new_item():
    new_todo = request.params.itemtext
    todo_list.append(new_todo)
    return template('index.tpl', title='Todo-Liste', items=todo_list)

Hier lesen wir den Wert des Texteingabefeldes und fügen ihn als neuen Eintrag in die Todo-Liste ein. Als Antwort wird die HTML-Seite mit der Todo-Liste zurückgegeben.

Das Objekt request.params enthält für jeden Anfrageparameter eine gleichnamige Attributvariable, auf die wir mit der Punkt-Schreibweise zugreifen können. Mit request.params.itemtext können wir also den Wert des Texteingabefeldes namens “itemtext” (im HTML-Formular mit name="itemtext" definiert) abfragen, der mit der Anfrage übermittelt wurde.

Falls es keinen Anfrageparameter mit dem angegebenen Namen geben sollte, wird die leere Zeichenkette '' als Wert zurückgegeben. Der zurückgegebene Wert ist immer eine Zeichenkette, unabhängig davon, ob Text oder Zahlenwerte in das Formular eingegeben wurden. Gegebenenfalls muss er also noch mit int oder float in einen Zahlenwert umgewandelt werden.

Weitere Details: Anfrageparameter für GET- und POST-Anfragen

Weitere Formularelemente

Auch die Werte von Auswahllisten, Radiobuttons und Checkboxen lassen sich auf diese Weise lesen. Bei all diesen Eingabeelementen gilt, dass im HTML-Quellcode ein Parametername mit dem Attribut name für das Element festgelegt wird, der innerhalb des Formulars eindeutig ist. Mit dem Attribut value wird der Wert festgelegt, den der Anfrageparameter beim Abschicken des Formulars erhalten soll.

Auswahllisten haben einen Namen und je einen Wert für jede Auswahloption:

<select name="status">
    <option value="open">offen</option>
    <option value="doing">in Bearbeitung</option>
    <option value="done">erledigt</option>
</select>

Radiobuttons stellen eine Alternative zur Auswahlliste dar:

<input type="radio" name="status" value="open"> offen
<input type="radio" name="status" value="doing"> in Bearbeitung
<input type="radio" name="status" value="done"> erledigt

Wenn das Formular abgeschickt wird, hat der Anfrageparameter request.params.status entweder den Wert 'open', 'doing' oder 'done', je nachdem, welche Option beim Abschicken ausgewählt war, oder '', falls keine Option ausgewählt war.

Bei einer Checkbox wird meistens nur der Name festgelegt, das Attribut value ist hier optional (wenn es fehlt wird standardmäßig der Wert “on” verwendet):

<input type="checkbox" name="urgent"> dringend

Wenn das Formular abgeschickt wird, hat der Anfrageparameter request.params.urgent den Wert 'on' (oder den mit value angegebenen Wert), falls die Checkbox angekreuzt war, sonst ''.

Client-seitige Validierung

Wird das boolesche Attribut required (“benötigt”) zu einem Eingabefeld hinzugefügt, wird beim Abschicken des Formulars über den Submit-Button in der Regel client-seitig durch den Webbrowser überprüft, ob das Feld leer. Ist das der Fall, wird ein Warnhinweis angezeigt und das Formular nicht abgeschickt.

<input type="text" name="itemtext" required>

Während type="text" für allgemeine Texteingaben verwendet wird, lassen sich auch Eingabefelder für spezielle Datentypen beschreiben, die meistens ein anderes Erscheinungsbild haben. Die geläufigsten Typen sind:

  • type="password": Password (eingegebene Zeichen werden als • dargestellt)
  • type="number": Ganzzahl (Minimum und Maximum lassen sich mit zusätzlichen Attributen min und max angeben)
  • type="date": Datum (Eingabe über kleinen Kalender möglich)
  • type="email": E-Mail-Adresse

Die eingegebenen Werte werden in der Regel außerdem client-seitig auf korrekte Syntax überprüft, bevor das Formular abgeschickt wird. Die Eingabe von “keine Angabe” in ein Feld vom Typ “E-Mail” führt etwa zu einer Warnung, da die Eingabe keine syntaktisch korrekte E-Mail-Adresse darstellt. Da diese Validierung allerdings nur client-seitig stattfindet, nicht von jedem Webbrowser unterstützt wird und leicht manipuliert werden kann, sollte die Serveranwendung sich nicht unbedingt darauf verlassen, dass die empfangenen Daten das richtige Format haben, und das Format sicherheitshalber auch server-seitig überprüfen.

Typische Fehler

Ber der Webprogrammierung mit Formularen können einige Fehler auftreten, die oft auf Inkosistenzen zwischen den Informationen im HTML-Quellcode und im Python-Code beruhen. Die folgende Tabelle listet typische Fehler auf und bietet Lösungen an:

ProblemLösung
Der Anfrageparameter request.params.xyz in der Rückruffunktion ist leer, obwohl ich etwas in das Formularfeld eingegeben habe.Stimmt der Parametername “xyz” mit dem name-Attribut des Formularelements überein?
Stimmt die Route der Rückruffunktion, in der die Abfrage stattfindet, mit dem action-Attribut des Formulars überein?
Wenn ich das Formular abschicke, erhalte ich die Fehlermeldung “405 Method Not Allowed”.Stimmt die HTTP-Methode der Rückruffunktion (@get oder @post) mit der Methode des Formulars (action="get" oder "post") überein?
Obwohl ich in das Formular eine Zahl eingebe / ein Zahlen-Eingabefeld <input type="number" name="xyz"> nutze, liefert der Abfrageparameter request.params.xyz eine Zeichenkette zurück.Anfrageparameterwerte sind immer Zeichenketten. Verwende die Funktion int oder float, um sie in der Rückruffunktion in eine Zahl umzuwandeln.
Es treten seltsame Zeichen auf: Wenn ich im Formular beispielsweise „Tschüß“ eingebe, erhalte ich in der Rückruffunktion die Zeichenkette „Tschü÷“.Anfrageparameter sollten immer mit request.params.xyz oder request.params.getunicode('xyz') abgefragt werden, nicht mit request.params.get('xyz') oder request.params['xyz'] – was auch möglich ist, aber ggf. eine andere Zeichencodierung verwendet, als von Python angenommen.

Webanwendung mit Datenbank

In diesem Abschnitt finden Sie jeweils zwei Versionen des Python-Codes: In der ersten Variante werden SQL-Anfragen direkt mit sqlite3 gestellt. Die zweite Variante basiert auf den Datenbank-Klassen, die wir im Kapitel Datenbankprogrammierung in Python entworfen haben, um SQL-Anfragen zu kapseln.

Wir wollen nun eine Webanwendung schreiben, die es erlaubt, auf unsere im vorigen Kapitel entwickelte Filmdatenbank zuzugreifen. Zur Erinnerung: Diese Datenbank enthält drei Tabellen (siehe auch untenstehendes Diagramm):

  • Die Tabelle Movie hat die Attribute id, title, year und directed_by, wobei das letzte Attribut einen Fremdschlüssel in die Tabelle Person darstellt (beschreibt die Person, die im Film Regie führt).
  • Die Tabelle Person hat die Attribute id und name.
  • Die Tabelle Is_Actor_In hat die Attribute id, person und movie, wobei die beiden letzten Attribute Fremdschlüssel in die entsprechenden Tabellen darstellen (beschreibt, dass eine Person in einem Film mitspielt).

Diagramm

Unser Hauptprogramm server.py beginnt wie folgt:

from bottle import *
import sqlite3

db = sqlite3.connect('movies.db')
from bottle import *
from database_sqlite import Db

db = Db('movies.db')

Zum Zugriff auf die Datenbank verwenden wir die früher entwickelte Datei database_sqlite.py mit Hilfsobjekten zum Zugriff auf SQLite-Datenbanken.

In der Variablen db wird ein Objekt zum Zugriff auf die Filmdatenbank erzeugt, das wir später verwenden, um Anfragen an den Server zu beantworten. Anfragen an den Wurzelpfad beantwortet unsere Anwendung mit einem Link zur Liste aller Filme.

@get('/')
def get_index():
    return template('index')

Das Template index.tpl sieht folgendermaßen aus (das Template base.tpl mit dem Grundgerüst ist wie wie oben definiert):

% rebase('base.tpl')

<h1>Filmdatenbank</h1>
<p><a href="/movies">zur Liste aller Filme</a></p>

Damit HTTP-GET-Anfragen an den Pfad /movies von unserer Anwendung beantwortet werden, fügen wir ihr den folgenden Aufruf hinzu.

@get('/movies')
def get_movies():
    cur = db.execute('SELECT * FROM Movie;')
    all_movies = cur.fetchall()
    return template('movie_list', movies=all_movies)
@get('/movies')
def get_movies():
    all_movies = db.table('Movie').all()
    return template('movie_list', movies=all_movies)

Das Template movie_list.tpl stellt die Liste der Filmdatensätze dar, die von der Datenbank abgefragt und beim Rendern des Templates über die Variable movies übergeben werden:

% rebase('base.tpl')

<h1>Alle Filme</h1>
<ul>
% for movie in movies:
  <li>
    <a href="/movies/{{movie['id']}}">{{movie['title']}} ({{movie['year']}})</a>
    % include('delete_button', movie_id=movie['id'])
  </li>
% end
</ul>

<h3>Neuen Film hinzufügen</h3>
<form action="/movies" method="post">
  <input type="text" name="title" placeholder="Titel">
  <input type="text" name="directed_by" placeholder="Regisseur/in">
  <input type="number" name="year">
  <input type="submit" value="Speichern">
</form>

Jeder Eintrag ist verlinkt zu einer Detail-Ansicht für Filme, deren Pfad die id des entsprechenden Films enthält. Zusätzlich wird hinter jedem Eintrag ein Knopf zum Löschen eingefügt, der im Untertemplate delete_button.tpl beschrieben wird, das wir später besprechen. Am Ende der Film-Liste definieren wir ein Formular zur Eingabe der Stammdaten eines neuen Filmes.

Der Link zur Detailansicht, der Knopf zum Löschen und das Formular zum Anlegen neuer Filme lösen alle neue HTTP-Anfragen aus, die wir mit unserem Programm beantworten müssen. Zunächst sehen wir uns die GET-Anfrage zur Detailansicht von Filmen an.

@get('/movies/<movie_id>')
def get_movie(movie_id):
    cur = db.execute('SELECT * FROM Movie WHERE id = ?;', [movie_id])
    movie = cur.fetchone()
    cur = db.execute('SELECT * FROM Person WHERE id = ?;', [movie['directed_by']])
    movie['directed_by'] = cur.fetchone()
    movie['actors'] = db_get_movie_actors(movie)
    return template('movie_details', movie=movie)
@get('/movies/<movie_id>')
def get_movie(movie_id):
    movie = db.table('Movie').get(movie_id)
    movie['directed_by'] = db.table('Person').get(movie['directed_by'])
    movie['actors'] = db_get_movie_actors(movie)
    return template('movie_details', movie=movie)

Hier steht im Pfad ein sogenannter Platzhalter <movie_id>. Dieses sogenannte Pfad-Muster passt also auf viele verschiedene Pfade. Die übergebene id wird der behandelnden Funktion als Variable movie_id übergeben. Diese Funktion erzeugt zunächst ein Dictionary movie, das Daten enthält, die später in der Detailansicht angezeigt werden sollen. Der Datensatz, der aus der Tabelle Movie gelesen wurden, wird dazu durch weitere Anfragen um Person-Datensätze für Regisseur/in und Schauspieler/innen erweitert.

Das Template movie_details.tpl stellt die Information des übergebenen Film-Datensatzes dar:

% rebase('base.tpl')

<h1>{{movie['title']}} ({{movie['year']}})</h1>
<p>von: {{movie['directed_by']['name']}}</p>
<p>mit:</p>
<ul>
% for actor in movie['actors']:
  <li>{{actor['name']}}</li>
% end
</ul>

<p><a href="/movies">zurück zur Filmliste</a></p>

Die Funktion db_get_movie_actors liefert eine Liste von Person-Datensätzen der Schauspieler/innen eines Films:

def db_get_movie_actors(movie):
    actors = []
    cur = db.execute('SELECT * FROM Is_Actor_In WHERE movie = ?;', [movie['id']])
    for row in cur.fetchall():
       cur = db.execute('SELECT * FROM Person WHERE id = ?;', [row['person']])
       actors.append(cur.fetchone())
    return actors
def db_get_movie_actors(movie):
    actors = []
    for row in db.table('Is_Actor_In').all_where('movie = ?', [movie['id']]):
        actors.append(db.table('Person').get(row['person']))
    return actors

Neben Regisseur/in und einer Liste von Schauspieler/innen zeigt diese Seite auch einen Link an, der auf die Liste aller Filme zurückverweist.

Das Formular versendet hier keine GET-Anfrage, wenn der Submit-Button gedrückt wird, sondern eine POST-Anfrage. Die HTTP-POST-Methode sollte immer dann verwendet werden, wenn eine Anfrage Daten serverseitig ändern kann, während GET nur bei rein lesenden Anfragen verwendet wird.

Wir legen nun fest, wie die POST-Anfrage zum Hinzufügen von Filmen verarbeitet wird:

@post('/movies')
def post_movies():
    dir_id = db_get_or_insert_person(request.params.directed_by)
    db.execute('INSERT INTO Movie (title, year, directed_by) VALUES (?, ?, ?);',
               [request.params.title, int(request.params.year), dir_id])
    db.commit()
    redirect('/movies')

Hierzu definieren wir noch eine Hilfsfunktion, mit der wir die ID einer Person über ihren Namen abfragen und die Person dabei in die Datenbank eintragen, wenn noch kein Eintrag mit diesem Namen vorhanden ist:

def db_get_or_insert_person(name):
    # ID der Person mit dem Namen abfragen
    cur = db.execute('SELECT id FROM Person WHERE name = ?;', [name])
    row = cur.fetchone()
    # Neuen Eintrag für Person hinzufügen, falls Abfrage ohne Ergebnis ist
    if row == None:
        db.execute('INSERT INTO Person (name) VALUES (?);', [name])
        db.commit()
        # ID nach dem Eintragen erneut abfragen
        cur = db.execute('SELECT id FROM Person WHERE name = ?;', [name])
        row = cur.fetchone()
    return row['id']
@post('/movies')
def post_movies():
    dir_id = db.table('Person').insert({'name': request.params.directed_by})
    movie = {
        'title': request.params.title,
        'year': int(request.params.year),
        'directed_by': dir_id
    }
    db.table('Movie').insert(movie)
    redirect('/movies')

Zur Erinnerung: Die insert-Methode unserer Datenbank-Klasse fügt nur dann einen neuen Datensatz ein, falls noch kein identischer Datensatz vorhanden ist. Sie liefert die id des vorhandenen oder eingefügten Datensatzes zurück.

Die entsprechende Funktion ist mit @post gekennzeichnet statt mit @get. Ihr Aufruf erzeugt zunächst einen neuen Datensatz aus den Formulareingaben, die als Anfrageparameter mit der POST-Anfrage übertragen wurden. Diese sind in Bottle-Anwendungen über das Objekt request.params verfügbar, das für jeden Anfrageparameter ein gleichnamiges Attribut besitzt. Auf die Attribute kann mit der Punkt-Schreibweise zugegriffen werden, z. B. request.params.directed_by für den Namen der regieführenden Person aus dem Formular. Wir sorgen anschließend dafür, dass ein entsprechender Datensatz in der Person-Tabelle vorhanden ist. Dazu fragen wir die id eine evtl. bereits vorhandenen Eintrag ab und fügen den Eintrag nachträglich hinzu, falls dieser Datensatz noch nicht existiert. Die id der Person verwenden wir dann als Attributwert des Fremdschlüssels directed_by im Datensatz für die Movie-Tabelle.

Nachdem der so erzeugte Datensatz in die Movie-Tabelle eingefügt wurde, senden wir mit dem Funktionsaufruf redirect als Antwort an den HTTP-Client eine sogenannte Weiterleitungs-Antwort. Diese fordert den Client auf, eine neue GET-Anfrage an die mitgegebene URL zu stellen. Dadurch wird hier wieder die Seite mit der Liste aller Filme aufgerufen, die nun den neu hinzugefügten Film anzeigt.

Schließlich diskutieren wir noch wie der Knopf zum Löschen von Filmen erzeugt und die zugehörige Anfrage behandelt wird. Das Untertemplate delete_button.tpl beschreibt einen Knopf mit einer bestimmten Beschriftung, mit dem eine POST-Anfrage an eine bestimmte URL zum Löschen eines Films gesendet werden kann. Die ID des Films wird über ein verstecktes Eingabefeld als Anfrageparameter mitgeschickt.

<form action="/movies/delete" method="post" style="display: inline">
  <input type="hidden" name="movie_id" value="{{movie_id}}">
  <input type="submit" value="Löschen">
</form>

Wir reagieren darauf wie in der folgenden Funktion, die mit @post gekennzeichnet ist, definiert ist.[^httpdelete] [^httpdelete] Es gibt in HTTP neben GET und POST auch DELETE-Anfragen, deren Zweck eigentlich das Löschen von Daten auf dem Server ist. Da Webbrowser ohne Javascript aber nur GET- und POST-Anfragen senden können, werden oft (wie hier auch) POST-Anfragen statt DELETE-Anfragen verwendet. Wenn wir stattdessen tatsächlich auf eine DELETE-Anfrage reagiert möchten, muss die entsprechende Funktion mit @delete gekennzeichnet werden statt mit @post. Eine weitere gängige HTTP-Methode, die von Webbrowsern jedoch wie DELETE nicht direkt unterstützt wird, ist PUT. Diese Methode wird verwendet, um Daten hinter einer URL zu ersetzen (z. B. Stammdaten eines Films ändern). In der Server-Anwendung behandeln wir solche Anfragen durch Funktionen, die mit @put gekennzeichnet sind.

@post('/movies/delete')
def delete_movies():
    movie_id = request.params.movie_id
    db.execute('DELETE FROM Is_Actor_In WHERE movie = ?;', [movie_id])
    db.execute('DELETE FROM Movie WHERE id = ?;', [movie_id])
    db.commit()
    redirect('/movies')
@post('/movies/delete')
def delete_movies():
    movie_id = request.params.movie_id
    db.execute('DELETE FROM Is_Actor_In WHERE movie = ?;', [movie_id])
    db.table('Movie').delete(movie_id)
    redirect('/movies')

In der Funktion werden zunächst aus der Is_Actor_In-Tabelle alle Datensätze gelöscht, die auf den zu löschenden Movie-Datensatz verweisen. Anschließend wird der über movie_id referenzierte Datensatz aus der Movie-Tabelle gelöscht.

Quellen und Lesetipps


  1. Es reicht aber auch, einfach die Datei bottle.py von der Projekt-Homepage herunterzuladen und im selben Verzeichnis zu speichern, in der das Python-Programm liegt, in dem wir unsere Webanwendung programmieren. ↩︎

  2. Zur Erinnerung: GET-Anfragen sind die Standard-Anfragen in HTTP und werden u. a. gesendet, wenn ein Hyperlink im Browser angeklickt wird oder eine URL über die Adresszeile des Browsers aufgerufen wird. ↩︎

  3. Soll der HTTP-Server dagegen für andere Rechner im Netzwerk erreichbar sein, muss bei host die IP-Adresse des Hostrechners, auf dem der Server läuft, angegeben werden (bzw. konkreter: die IP-Adresse seiner Netzwerkschnittstelle). Alternativ kann auch 0.0.0.0 (= alle verfügbaren Netzwerkschnittstellen) angegeben werden. Für port kann eine beliebige ungenutzte Portnummer verwendet werden. Die Webanwendung ist dann über die URL http://IP-Adresse:Port erreichbar. Wenn Port 80 (= Standard für HTTP) verwendet wird, kann die Portangabe in der URL auch weggelassen werden. ↩︎