Inhalt

  1. Einführung
  2. Setup
  3. Eine grundlegende Flask-Applikation
  4. Datenbankanbindung und Serialisierung
  5. Grundlegende CRUD Endpunkte
  6. Weiterführendes Material

Einführung

Flask ist ein Python Web-Framework und gehört mit Django zu den beiden bekanntesten solcher Frameworks. Mit Flask ist es möglich ganze Webseiten zu erstellen, da das Framework z.B. auch mit einer Templating-Engine (JinJa2) daherkommt. In diesem Artikel soll es allerdings nur um die Verwendung von Flask im Backend gehen. Hierzu bietet das Framework viele Funktionlitäten um eine vollwertige REST (Representational State Transfer) API (Application Programming Interface) zu entwickeln.

Das schöne an Flask ist, dass es erstmal ohne viele, häufig unbenutzte, Extras läuft und von sehr vielen Entwicklern immer wieder als äußerst "lightweight" bezeichnet wird. Dies wird im Vergleich sehr deutlich wenn man sich mal mit Django außeinandersetzt. Trotzdem bietet Flask auch viel Extra-Funktionalität, die durch verschiedenste Module hinzugefügt werden kann. Eines dieser extra Packages ist "SQLAlchemy" welches ich auch in den folgenden Beispielen verwenden werde um eine Datenbank einzubinden. Was wäre ein Backend schon ohne Datenbank. Ein weiteres interessantes Package, welches auch in den Beispielen wichtig wird, ist "Marshmallow". Es wird verwendet um Objekte in Python-Datentypen zu konvertieren, und andersherum. Man spricht hier auch von "Serialisierung". Häufig sieht man Marshmallow im Verwendung zusammen mit SQLAlchemy.

Ich sollte noch erwähnen, dass ich in dieser Arbeit nicht auf einige "fortgeschrittene" Features einer Backend REST API, wie z.B. ein User-Login System, eingehen werde, sondern mich auf die Kerninhalte einer simplen REST API beschränke. Am Ende des Artikels werde ich aber einige Quellen verlinken, die noch tiefer in die Materie tauchen und beispielsweise auch auf Module eingehen, die solche Login Systeme bereits abdecken.

Setup

Um mit Flask zu arbeiten bietet es sich, wie bei eigentlich jedem Python Projekt, an eine virtuelle Umgebung aufzusetzen, um so einen Überblick über die Abhängigkeiten des Projektes zu behalten. Voraussetzung hierfür ist der Package Installer for Python (kurz PIP). Für virtuelle Umgebungen verwende ich das Paket "virtualenv", aber es gibt natürlich noch andere Optionen. Installiert wird das Paket mit folgendem Befehl:

pip3 install virtualenv

Als nächstes muss man eine neue Umgebung erstellen:

python3 -m venv <path to new environment>/<env-name>

Nun wird automatisch ein Ordner für die neue Umgebung erstellt, in dem einige Scripts abgespeichert sind und in dem später auch alle Abhängigkeiten gespeichert werden. Jetzt muss man die Umgebung noch aktivieren, um sie zu nutzen. Das geschieht unter Linux/Mac mittels:

source <env-name>/bin/activate

Unter Windows sieht das ganze so aus:

<env-name>\Scripts\activate

Jetzt ist die Umgebung aktiv und es können Pakete installiert werden. Es folgen alle Pakete die für die Beispiele installiert werden müssen:

pip3 install flask flask-sqlalchemy flask-marshmallow marshmallow-sqlalchemy

Mit PIP ist es möglich eine Liste aller in einer Umgebung installierten Paktete zu erzeugen:

pip3 freeze

Dieser Befehl erzeugt die Datei "requirements.txt" So kann man später aus dieser Liste heraus alle bennötigten Komponenten installieren:

pip3 install -r requirements.txt

Um API Endpunkte zu testen kann man später entweder seinen Browser nutzen, oder man greift auf ein Tool wie z.B. Postman zurück. Das ist aber natürlich jedem selbst überlassen.

Eine grundlegende Flask-Applikation

Eine REST API wird (meistens) in mehrere Endpunkte gegliedert. Jeder Endpunkt liefert oder empfängt gewisse Ressourcen. Dies geschieht in der Regel im JSON Format. Wie bereits erwähnt braucht es nicht viel um eine sehr simple Flask Applikation zum laufen zu bringen, da man sehr intuitiv Endpunkte definieren kann.

Fangen wir aber noch schlanker an. Im folgenden Beispiel wird eine Flask Applikation, ohne jegliche Funktionalität, gebaut und über einen Developement-Server gestartet. Dazu muss erwähnt werden, dass dieser Server nicht für Produktionszwecke geeignet ist. Hier sollte man auf eine andere Lösung, wie beispielsweise das Hosting via UWSGI, zurückgreifen. Nun aber zum Beispiel:

from flask import Flask

#App initialisieren
app = Flask(__name__)

#Developement-Server starten
if __name__ == "__main__":
    app.run(debug=True)

Wenn diese Datei nun mit Python ausgeführt wird, startet der Server und die Anwendung ist unter "localhost:5000/" erreichbar.

Eine einfache Route erzeugt man wie folgt:

from flask import Flask, jsonify

#App initialisieren
app = Flask(__name__)

#Routen
@app.route("/", methods=['GET'])
def home():
    return jsonify({'message':'hello'})
    
#Developement-Server starten
if __name__ == "__main__":
    app.run(debug=True)

Was genau passiert hier? Es wird eine Route mit der dazugehörigen Funktionalität definiert. Dafür erstellt man mit einem Flask-eigenen Decorator die Route und legt die HTTP Methoden fest, welche auf diese Route anwendbar sind. Unterhalb dieses Decorators definiert man dann die dazugehörige Funktionalität in einer Funktion. Ein solche Funktion liefert immer einen String. In diesem Fall, und im Falle der meisten REST API's, wird in diesem String aber JSON zurückgegeben. In Python kann man JSON sehr einfach mit einem Dictionary darstellen, welches dann mit der Funktion "jsonify()" geparsed wird.

Ruft man nach Ausführen der Datei nun den Localhost am Port 5000 auf, egal ob im Browser, oder mit Postman, wird man {'message' : 'hello'} im JSON Format dargestellt sehen können.

Allein mit dieser Funktionalität lässt sich schon viel anstellen, vor allem da man in Python sehr einfach mit Dictionaries arbeiten kann. Aber natürlich wollen wir noch ein wenig tiefer in die Materie eintauchen. Daher komme ich jetzt zur Datenbankanbindung und Serialisierung.

Datenbankanbindung und Serialisierung

Um eine Datenbank an eine Flask-Applikation anzubinden eignet sich SQL-Alchemy, da es hier ein extra Paket zur Verwendung mit Flask gibt. SQL-Alchemy implementiert einen sogenannten ORM (Object-Relational-Mapper), welcher eine weitere Abstraktionsebenene zwischen der Applikation und der Datenbank (SQL) darstellt. Man kann diesen ORM wie ein gewöhnliches Objekt, ganz nach allen Regeln des OOP, verwenden und seine Datenbank ohne SQL Queries verwalten. Man verwendet hierfür die Methoden und Attribute des ORM. Diese extra Abstraktionsebene ermöglicht es uns als Endnutzern, unseren Code zur Datenbankinteraktion zu schreiben OHNE uns vorher auf eine bestimmte Datenbank festlegen zu müssen, da SQL-Alchemy's Interface mit den meisten gängigen SQL Datenbanken zurechtkommt. In den weiteren Beispielen verwende ich beispielsweise SQLite, was sich super zum schnellen, lokalen Testen und Entwickeln eignet. Um in Produktion zu gehen kann man dann aber ohne Probleme auf eine MySQL, oder MariaDB Datenbank umsteigen.

Die Daten aus der Datenbank kann man nun als Klasse darstellen lassen, möchte sie aber vielleicht auch als einfache Datentypen ausgeben können. Hierfür verwenden wir Marshmallow um die Daten zu Serialisieren. Dafür erstellt man ein festes Schema, nach welchem Marshmallow dann automatisch umformen kann.

Es folgt ein größeres Beispiel, an welchem gleich die Prinzipien von SQLAlchemy und Marshmallow erklärt werden:

from flask import Flask, jsonify
from flask_sqlalchemy import SQLAlchemy
from flask_marshmallow import Marshmallow
import os

#App initialisieren
app = Flask(__name__)
basedir = os.path.abspath(os.path.dirname(__file__))

#Datenbank Setup
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + os.path.join(basedir, 'db.sqlite')
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False

#Datenbank initialisieren
db = SQLAlchemy(app)

#Marshmallow initialisieren
ma = Marshmallow(app)

#Modell für eine Task-Ressource
class Task(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(100), unique=True)
    description = db.Column(db.String(200))
    
    def __init__(self, name, description):
        self.name = name
        self.description = description
        
#Task Schema
class TaskSchema(ma.Schema):
    class Meta:
        fields = ('id','name','description')

#Schema initialisieren        
task_schema = ProductSchema(strict=True)
tasks_schema = ProductSchema(many=True, strict=True)

#Developement-Server starten
if __name__ == "__main__":
    app.run(debug=True)

Zu Beginn müssen einige Konfigurationen für die Datenbank vorgenommen werden. Am wichtigsten ist hier die "DATABASE_URI". Diese URI ist quasi der Pfad zur Datenbank. Je nach verwendeter Datenbank muss diese URI natürlich angepasst werden, um z.B. auch Nutzerdaten mit zu übergeben. Desweiteren müssen sowohl SQLAlchemy als auch Marshmallow initialisiert werden.

Nun bennötigt der ORM ein Modell der Daten, welche in der Datenbank abgebildet werden. In unserem Fall ist das die Ressource "Task". Ein Task besteht aus einer ID, welche auch der Primary-Key ist, dem Namen und einer kurzen Beschreibung. Außerdem braucht das Modell noch einen Konstruktor, in dem diese Werte dem "Objekt" zugewiesen werden. Man beachte, dass die ID hier nicht zugewiesen wird, da sie von SQLAlchemy automatisch verwaltet wird. In unserem Fall werden nur einfache Datentypen für die Spalten der Datenbank verwendet, aber natürlich sind beispielsweise auch Relationships mit SQLAlchemy abbildbar. Mehr dazu findet sich in der offiziellen Dokumentation.

Zuletzt muss Marshmallow noch informiert werden, wie das Schema für die Serialisierung auszusehen hat. Den Großteil übernimmt es bei so simplen Modellen von alleine, aber in einer Subklasse "Meta" müssen alle Felder angegeben werden, welche bei der Konvertierung berücksichtigt werden sollen. Dieses Schema muss jetzt nurnoch initialisiert werden. Hierbei ist darauf zu achten, das Schema für die Ressource einmal für die Rückgabe eines einzelnen Objektes, als auch für die Rückgabe mehrerer Objekte auf einmal zu initialisieren.

Bei größeren Projekten bietet es sich außerdem an, diese verschiedenen Konfigurationen und Modelle in verschiedenen Dateien zu schreiben.

Falls die Datenbank nicht schon bereit steht lässt sie sich sehr einfach mit Hilfe von SQLAlchemy generieren, sodass alle beschriebenen Modelle als einzelne Tabellen erzeugt werden und einsatzbereit sind. Dazu öffnet man ein neues Terminal und startet eine neue Python Shell. Nun folgt als erstes ein Import der Datenbank aus der geschriebenen Datei (die Datei heißt hier app.py):

from app import db

Jetzt kann die Datenbank mit einem einfachen Befehl erzeugt werden:

db.create_all()

Damit werden alle Modelle, die in der Datei beschrieben sind erzeugt und im Falle von SQLite sollte in der aktuellen Directory nun die Datei "db.sqlite" angelegt worden sein.

Grundlegende CRUD Endpunkte

Da das Setup der Datenbank erfolgt ist können die ersten Endpunkte erstellt werden. Die folgenden Beispiele sind der Übersicht halber vom Rest des Codes getrennt, müssen für eine funktionstüchtige API aber natürlich zum Code "Grundgerüst" hinzugefügt werden. Wo und wie man eine Route definiert wurde bereits dargestellt.

In den folgenden Beispielen schauen wir die volle Funktionalität einer klassischen API nach "CRUD" (Create, Read, Update, Delete), anhand des Beispiels der Task-Ressource an. So können am Ende neue Tasks erstellt, aber auch gelesen, geupdated und gelöscht werden.

Beginnen wir mit CREATE:

# CREATE Task
@app.route('/task', methods=['POST'])
def add_task():
  name = request.json['name']
  description = request.json['description']

  new_task = Product(name, description)

  db.session.add(new_task)
  db.session.commit()

  return product_schema.jsonify(new_task)

Ein CREATE Endpunkt funktioniert natürlich als POST Methode, da neue Daten zum Server "geposted" werden.
Alles was nun getan werden muss, ist die bennötigten Daten aus diesem POST-Request zu lesen. Dafür verwendet man das Attribut "request" und liest aus diesen, wie aus einem Dictionary. Als nächstes erzeugt man ein Objekt, welches man anschließend zur Datenbank hinzufügt. Das Hinzufügen funktioniert in zwei Schritten. Als erstes wird das Objekt der aktuellen Session hinzugefügt, und dann wird diese Session zur Datenbank commited. Der Rückgabewert dieses Endpunktes sollen die einzelnen Attribute der Daten im JSON Format sein. Dafür verwendet man das Marshmallow-Schema, welches man direkt in JSON umwandeln kann. Dieser Endpunkt wäre so nun einsatzbereit und kann mit Hilfe von Postman getestet werden.

Als nächstes folgen zwei Endpunkte für die READ/GET Funktionalität:

# GET (alle Tasks)
@app.route('/task', methods=['GET'])
def get_tasks():
  all_tasks = Task.query.all()
  result = tasks_schema.dump(all_tasks)
  return jsonify(result.data)
  
# GET (einzelner Task)
@app.route('/task/<id>', methods=['GET'])
def get_task(id):
  task = Task.query.get(id)
  return task_schema.jsonify(task) 

Natürlich werden diese Endpunkte durch GET-Requests angesprochen. Wenn alle Tasks auf einmal abgefragt werden sollen geht das ziemlich schnell, da man der Datenbank nur den Befehl geben muss um alle Tasks zurückzugeben. Für solche Queries hat der ORM von SQLAlchemy sehr intuitive Methoden. Anschließend werden diese Daten nach dem Schema umgeformt und im JSON Format zurückgeliefert.
Für einen einzelnen Task muss man nun noch einen Query-Parameter übergeben. Im Beispiel wird anhand der ID eines Tasks gesucht. Dafür wird der Methode das ID Parameter mitgegeben, welches aus der URL gelesen wird. Innerhalb des Decorators kann man mit den Zeichen "< >" deutlich machen, dass es sich um Parameter handelt.

Kommen wir zur UPDATE Funktionalität:

# UPDATE
@app.route('/task/<id>', methods=['PUT'])
def update_task(id):
  task = Task.query.get(id)

  name = request.json['name']
  description = request.json['description']
  
  task.name = name
  task.description = description

  db.session.commit()

  return product_schema.jsonify(task)

Die Methode eines solchen Enpunkt ist PUT. Prinzipiell verbindet man hier GET und POST ein wenig. Zuerst holen wir und den Task der geupdatet werden soll und lesen dann die neuen Daten aus dem Request. Mit diesen neuen Daten verändert man dann das aus der Datenbank geladene Objekt und committed diese Veränderungen dann wieder. Zum Schluss gibt man dann das Schema des veränderten Tasks zurück.

Als letztes schauen wir uns DELETE an:

# DELETE
@app.route('/task/<id>', methods=['DELETE'])
def delete_task(id):
  task = Task.query.get(id)
  db.session.delete(task)
  db.session.commit()

  return product_schema.jsonify(task)

Hierfür verwendet man die DELETE-Methode. Auch bei diesem Endpunkt muss erst einmal der gewünschte Task geladen werden. Dieser kann dann sehr simpel gelöscht und die Änderungen an der Datenbank committed werden.

Weiterführendes Material

Jetzt ist der Punkt erreicht, an dem die sehr simple CRUD REST API voll funktionsfähig ist. Natürlich gibt es aber noch weitere Themen um noch tiefer in das Thema einzutauchen. Daher hier einige Links:

Ich hoffe ich konnte einigen Lesern mit diesem kurzen Artikel weiterhelfen. Vielen Dank für's Lesen!