Erste UI-Demo - die Berechnung wird zur Webanwendung
heat-conduction-app: Referenzprojekt für moderne Backend- und Webarchitektur
Server-Side Rendering (SSR) Test-Diven-Development (TDD) Flask Jinja2 Internationalisierung (i18n) Backend heat-conduction-app
← Zurück zur Übersicht | ← Meilenstein 2 - Erste Berechnung läuft
Einleitung
Im vorangegangenen Meilenstein 2 - erste Berechnung läuft wurde die erste lauffähige Berechnungsfunktion umgesetzt. Zunächst wurde die grundlegende Applikationsstruktur verbunden und darauf aufbauend die physikalische Berechnung als Service-Modul implementiert. Im Mittelpunkt standen dabei eine testgetriebene Umsetzung und eine klare Trennung der Verantwortlichkeiten zwischen Service-Layer und Routing.
Der Zugriff von außen erfolgt über eine einfache technische API-Schnittstelle. Diese dient in erster Linie der Testbarkeit der Anwendung und stellt noch keine REST-API dar.
In diesem Meilenstein folgt nun der nächste Schritt, eine erste Frontend-Integration. Ziel ist eine einfache Benutzeroberfläche auf Basis von HTML und JInja2 im Sinne Server-Side-Rendering (SSR). Der Fokus liegt dabei nicht auf Design oder Layout, sondern auf einer sauberen UI-Schicht und einer testbaren UI_route bei klarer Trennung von API und Benutzeroberfläche.
Zielbild der UI
Im ersten Schritt wird festgelegt, welche Funktionalität die Benutzeroberfläche bereitstellen soll. Da in diesem Meilenstein noch keine Datenbankanbindung implementiert wird, müssen alle für die Berechnung benötigten Daten über das Formular eingegeben werden.
Der Benutzer gibt folgende Parameter ein:
- Wanddicke
- Wandfläche
- Wärmeleitfähigkeit
- Temperatur 1 und 2
Auf Basis dieser Eingaben wird der stationäre Wärmestrom durch die Wand berechnet und aufgezeigt. Neben den Eingabegrößen müssen auch die verwendeten Einheiten definiert werden. In der ersten Version der Anwendung werden SI-Einheiten verwendet:
- Länge: m
- Fläche: m²
- Wärmeleitfähigkeit: W/(m*K)
- Temperatur: °C
Umsetzungsschritte
Routing vorbereiten
Die Kommunikation zwischen Benutzeroberfläche und Service-Layer
erfolgt über eine Route in app/main/routes.py. Für
die Startseite wird eine
Single-Route mit kombiniertem GET- und POST-Handling
implementiert und unter der Index-Route
/ registriert.
Die GET-Anfrage rendert die Seite mit dem Eingabeformular. Die POST-Anfrage validiert die Formulardaten und löst anschließend die Berechnung in der Service-Schicht aus. Dabei folgt die Implementierung dem Post-Redirect-Get Pattern (PRG).
PRG (Post-Redirect-Get)
1. POST verarbeitet Formular
2. Redirect
3. GET zeigt Ergebnis
Dies verhindert, dass ein erneutes Laden der Seite die POST-Anfrage erneut ausführt und bildet eine saubere Grundlage für spätere Erweiterungen, beispielsweise das Speichern von Berechnungen in einer Datenbank.
Wie im vorherigen Meilenstein erfolgt die Umsetzung testgetrieben. Daher wird zunächst eine minimale Routenimplementierung erstellt, die lediglch das Formular verarbeitet und den Redirect ausführt.
form = ConductionForm()
if form.validate_on_submit():
return redirect(url_for("main.conduction"))
return render_template("index.html.jinja", title=_("Home"), form=form)
Formklasse ConductionForm anlegen
Die Flask-Erweiterung Flask-WTF ermöglicht die Definition von Formularklassen, die sowohl im Routing als auch in den Jinja-Templates verwendet werden können. Dadurch lassen sich Formularlogik, Validierung und Darstellung strukturiert kapseln.
Ein weiterer Vorteil ist die integrierte serverseitige Validierung sowie der Schutz von Cross-Site Request Forgery (CSRF), einem wichtigen Sicherheitsmechanismus bei serverseitigen Webanwendungen.
Für die Validierung stellt wtforms verschiedene Validatoren bereit, beispielweise:
-
InputRequired- stellt sicher, dass ein Eingabewert vorhanden ist -
NumberRange- definiert einen zulässigen Wertebereich DaNumberRangenur inklusive Grenzen unterstützt, wurde für die Wanddicke ein eigener Validatoris_positiveimplementiert, um eine strikt positive Bedingung (>0) prüfen zu können.
Für numerische Eingaben wird der FloatField-typ aus
wtforms verwendet. Um zusätzlich die physikalische Einheit eines
Feldes abzubilden, wurde eine eigene Feldklasse
UnitFloatField implementiert. Diese erweitert
FloatField um ein zusätzliches Attribut
unit, das im Template für die Darstellung der
Einheit genutzt wird.
Templates - der Kern dieses Meilensteins
Während die bsiherigen Schritte ausschließlich Backend-Komponenten betrafen, entsteht nun die erste einfache Benutzeroberfläche der Anwendung. Diese basiert auf HTML-Templates mit Jinja2.
HTML definiert die Struktur und Semantik der Seite, während Jinja2 dynamische Inhalte und Interaktion ermöglicht. Für diese erste UI werden drei Templates verwendet:
Base-Template
Definiert den strukturellen Rahmen der Seite
- Metadaten
- Einbindung Bootstrap
- Grundlayout der Seite
Index-Template
Enthält den eigentlichen Seiteninhalt
- kurze Beschreibung der physikalischen Annahmen
- Eingabeformular
- Ergebnisbereich
Hilfs-Template
Stellt ein wiederverwendbares Jinja-Makro für die Formularfelder bereit. Dadurch bleiben Formularstruktur und Fehlerdarstellung zentral definiert.
Damit zeigt sich bereist ein typisches Template-Pattern: Die Index-Seite erbt vom Base-Template und importiert Makros aus dem Hilfs-Template.
JInja2 erweitert HTML um einfache Template-Syntax, beispielsweise:
{{ ... }}für Ausdrücke{% if ... %}für Bedingungen{% for ... %}für Schleifen
In Kombination mit Bootstrap lassen sich so bereits ohne JavaScript dynamische und responsive Webseiten erstellen, ein klassisches Server-Side Rendering (SSR).
Jinja-Filter für größenordnungsabhängige Darstellung
Die Berechnung des Wärmestroms liefert je nach Eingabedaten numerische Werte mit unterschiedlich vielen Nachkommastellen. Während bei großen Leistungen (z.B. 10000 W) zusätzliche Nachkommastellen wenig Aussagekraft haben, können sie bei kleinen Werten relevant sein.
Für eine konsistente Darstellung wird eine Helferfunktion verwendet, die Werte basierend auf signifikanten Stellen rundet. dadurch ergibt sich eine größenordnungsabhängige Darstellung der Ergebnisse.
Wichtig ist dabei die Trennung von Berechnung und Darstellung:
- Die Service-Schicht liefert den unveränderten numerischen Wert.
- Die Darstellungsschicht entscheidet über die Formatierung.
Statt die Rundung im Routing vorzunehmen, wird die Funktion daher als Jinja-Filter registriert.
from app.utils.formatting import format_value_dynamically
def create_app(config=Config) -> CoreApp:
app = CoreApp(__name__)
...
# Register Jinja filters
app.jinja_env.filters["format_value"] = format_value_dynamically
return app
Der Filter kann anschließend im Template verwendet werden:
<p>
<strong>
{{ _("The calculated heat flow is") }}
{{ result_data.result | format_value }} W.
</strong>
</p>
UI-Tests für die Route
Zu den Service und “API”-Tests aus dem vorherigen Umsetzungsschritt kommen nun Tests für Benutzeroberfläche und die zugehörige Route. Die Verwendung des Post-Redirect-Get Pattern in der Anwendung hat Einfluss auf die Ausgestaltung der Tests. Die Tests können in 4 Gruppen eingeteilt werden:
GET /Formularseite wird angezeigt-
POST /mit validen Daten → Redirect (PRG-Schritt) -
POST /mit validen Daten + Redirect → Ergebnis sichtbar -
POST /mit invaliden Daten → Formularfehler ohne Redirect
Im Gegensatz zu den Service-Tests liefert die Route keine strukturierten JSON-Response, sondern HTML. Neben der Prüfung des Statuscodes ist eine Kontrolle auf erwartete Textinhalte im Response sinnvoll.
Hinweis: Für die Tests wird CSRF in der
Testkonfiguration deaktiviert (WTF_CSRF_ENABLED = False). Alternativ könnte ein gültiger Token generiert und übergeben
werden, was den Test jedoch verkomplizieren würde.
class TestConductionUI:
def test_get(self, client):
response = client.get("/")
assert response.status_code == 200
content = response.data.decode("utf-8")
normalized = " ".join(content.split())
assert "Calculate the steady-state heat flow" in normalized
assert "Fourier's law of heat conduction" in normalized
def test_post_valid_redirect(self, client):
response = client.post(
"/",
data={
"thickness": 0.1,
"area": 1,
"conductivity": 1,
"t_1": 10,
"t_2": 5,
},
follow_redirects=False,
)
assert response.status_code == 302
assert len(response.history) == 0
def test_post_valid_prg_flow(self, client):
response = client.post(
"/",
data={
"thickness": 0.1,
"area": 1,
"conductivity": 1,
"t_1": 10,
"t_2": 5,
},
follow_redirects=True,
)
assert response.status_code == 200
assert len(response.history) == 1
assert b"calculated heat flow is" in response.data
assert b"50" in response.data
@pytest.mark.parametrize(
"thickness, area, conductivity, message",
[
(-0.1, 1, 1, b"Number must be greater than 0."),
(0, 1, 1, b"Number must be greater than 0."),
(0.1, -1, 1, b"Number must be at least 0."),
(0.1, 1, -1, b"Number must be at least 0."),
],
)
def test_post_invalid_values(self, client, thickness, area, conductivity, message):
response = client.post(
"/",
data={
"thickness": thickness,
"area": area,
"conductivity": conductivity,
"t_1": 10,
"t_2": 5,
},
follow_redirects=True,
)
assert response.status_code == 200
assert message in response.data
Die Tests bilden die funktionalen Anforderungen der Route vollständig ab. Im nächsten Schritt wird die Route implementiert, bis alle Tests erfolgreich durchlaufen.
Implementierung der Route
Nachdem Formular und Templates erstellt wurden, kann die Route vollständig implementiert werden.
Submit
↓
POST
↓
Form Validation
↓
Service Layer
↓
Redirect (302)
↓
GET
↓
Render Template
Nach erfolgreicher Validierung ruft die Route die
Service-Funktion heat_flow_stationary_1d auf und
übergibt die Formulardaten als Parameter. Das Ergebnis wird
anschließend zusammen mit den Eingabedaten in der
session gespeichert. Danach erfolgt, entsprechend
dem PRG-Pattern, ein Redirect auf dieselbe Route.
Beim anschließenden GET-Request prüft die Route, ob in der
Session ein result_data Objekt vorhanden ist und
falls ja wird dieses an das Template übergeben und dort
dargestellt.
Ein wichtiges Architekturprinzip bleibt dabei erhalten:
Die Route enthält keine physikalische Logik. Alle Berechnungen befinden sich ausschließlich im Service-Layer.
Nach der Implementierung laufen alle zuvor definierten Tests erfolgreich durch. Damit ist sichergestellt, dass die Formularvalidierung, der PRG-Ablauf und die Ergebnisdarstellung korrekt funktionieren.
Internationalisierung (i18n)
Mit den templates und der Route ist die erste UI-Demo funktional vollständig. Ein weiterer Aspekt , den ich an dieser Stelle ergänzen möchte, ist die Internationalisierung. Bereits in meinem Blogartikel zu Meilenstein 1 Projektgerüst habe ich erwähnt, dass es sinnvoll ist, Mehrsprachigkeit frühzeitig mitzudenken. Ich möchte hier kurz zeigen, welche Schritte dafür notwendig sind.
Übersetzbare Texte markieren
Zunächst wird die Bibliothek Flask-Babel integriert und in der Flask-Subklasse initialisiert.
def get_locale(self):
return request.accept_languages.best_match(
self.config["LANGUAGES"]
) or self.config.get("BABEL_DEFAULT_LOCALE", "en")
def init_extensions(self):
...
self.babel.init_app(app=self, locale_selector=self.get_locale)
Die verfügbaren Sprachen werden aus der Konfiguration geladen. In der heat-conduction-app sind das:
[en, de]
Englisch fungiert dabei als Standardsprache.
Damit Texte überstezbar sind, müssen sie explizit markiert werden. dafür stellt Flask-Babel zwei Funktionen bereit:
_()für Laufzeitübersetzungen-
lazy_gettextfür Lazy-Strings, z.B. bei Formularfeldern
Im Template:
<h2 class="card-header">{{ _("Input data") }}</h2>
Im Backend:
from flask_babel import _, lazy_gettext as _l
class ConductionForm(FlaskForm):
thickness = UnitFloatField(
_l("Thickness"),
unit="m",
validators=[InputRequired(), is_positive],
)
...
Das wirkt zunächst ungewohnt, stellt jedoch sicher, dass alle UI-Texte systematisch übersetzbar bleiben.
Übersetzungsworkflow mit Babel
Sind alle Texte markiert, können Übersetzungen mit dem Babel-Tooling erzeugt werde. Der typische Workflow besteht aus drei Schritten.
- Neue Sprache initialisieren
- Übersetzungen aktualisieren
- Übersetzungen kompilieren
Empfehlung
Die Einführung von Internationalisierung bedeutet zunächst zusätzlichen Aufwand: Texte müssen markiert und später übersetzt werden.
Trotzdem empfehle ich diesen Schritt möglichst früh im Projekt. Das Markieren der Texte muss nur einmal erfolgen, während Übersetzungen auch später ergänzt werden können. Wird i18n erst nachträglich eingeführt, müssen alle UI-Texte im Projekt nachträglich identifiziert und angepasst werden, ein deutlich größerer Aufwand.
Solange keine Übersetzung vorhanden ist, verwendet Flask-Babel automatisch die Default-Sprache, sodass der Entwicklungsprozess dadurch nicht blockiert wird.
Zusammenfassung und Ausblick
Nach diesem Meilenstein verfügt die heat-conduction-app über eine einfache, aber klar strukturierte UI-Schicht. Aufbauend auf dem bereits vorhandenen Service-Layer kann die Fachlogik nun über eine grafische Benutzeroberfläche angesteuert und genutzt werden.
Wie im vorherigen Schritt stand auch hier eine testgetriebene Implementierung im Mittelpunkt. Während die Template-Erstellung naturgemäß nur begrenzt automatisiert testbar ist, stellen die UI-tests sicher, dass die Route korrekt funktioniert. Das Post-Redirect-Get-Pattern, die serverseitige Formularvalidierung mit Flask-WTF und die Ergebnisdarstellung werden überprüft.
Ein zusätzlicher Aspekt dieses Meilensteins war die Vorbereitung der Internationalisierung. Durch die Integration von Flask-Babel und das Markieren übersetzbarer Texte ist die Anwendung bereits für mehrere Sprachen vorbereitet. Für das Projekt wurden Englisch als Standardsprache und Deutsch als zweite Sprache eingerichtet.
Damit ist das Projekt einen weiteren Schritt Schritt in Richtung eines funktionierenden Prototyps gegangen. Im nächsten Meilenstein folgt die Integration einer Datenbank, über die eine Materialdatenbank bereit gestellt wird. Anschließend wird Phase 1 des Projekts mit einem containerisierten Deployment abgeschlossen.
Feedback, Fragen oder Anregungen sind jederzeit willkommen, gerne per Email oder auch auf LinkedIn.