Architektur im Detail: Modulschichten
Zweck
Dieser Abschnitt beschreibt den inneren Aufbau von src/thingsboard_frost_adapter/: die vier Schichten,
die Regel, in welche Richtung Importe zeigen, und die Verträge, die die Schichten einander geben.
Der Architekturüberblick
beschreibt die Komponenten um den Adapter herum; hier geht es um seinen Inhalt.
|
Das Schichtschema gilt für |
Die vier Schichten
| Schicht | Besitzt | Darf importieren |
|---|---|---|
|
Die HTTP-Oberfläche: Pfade, Statuscodes, Response-Modelle, das Auslesen des Request-Bodys. Eine Route ist ein Service-Aufruf. |
von den Schichten nur |
|
Die Entscheidungen: Geschäftsregeln, Reihenfolgen, Fehlerpolitik — und der Ort, an dem sich zwei Repositories treffen. |
|
|
Genau einen Zugriffsweg auf genau ein System: je Modul eine Postgres-Tabelle ( |
|
|
Den Aufbau einer Verbindung zu System X für Mandant Y, samt Authentifizierung und deren Zwischenspeicherung. |
|
Der Zugehörigkeitstest, wenn bei einem neuen Modul unklar ist, wohin es gehört:
-
Brauche ich
Request, einen Statuscode oder ein Response-Modell? →routers/ -
Wird hier etwas entschieden, oder treffen sich hier zwei Repositories? →
services/ -
Ist das eine Anfrage an ein System, deren Antwort ohne Regelwissen zustande kommt? →
repositories/ -
Ist das der Aufbau einer Verbindung oder einer Authentifizierung? →
clients/
db/, redis/, model/, exceptions/, configuration/ und bootstrap/ sind keine Schichten,
sondern Querschnittspakete. Sie dürfen von überall importiert werden.
Die Abhängigkeitsregel
Importe zeigen ausschließlich nach unten.
routers/
│ nur services/
▼
services/
┌──────┴───────┐
▼ │
repositories/ │
db/ external/ │
│ │
▼ ▼
clients/
-
routers/importiert von den Schichten nurservices/. -
services/importiertrepositories/db/,repositories/external/undclients/. -
repositories/external/importiertclients/—TenantFrostRepositorywird ausTenantClientskonstruiert und braucht den Typ für die eigene Annotation. -
Unterhalb von
services/importiert nichts eine Schicht über sich. Kein Repository und kein Client importiert jemalsservices/. -
Ein Repository importiert kein anderes Repository.
crudist kein Repository, sondern gemeinsame Mechanik, und deshalb erlaubt. -
Innerhalb von
services/sind Importe erlaubt und zyklenfrei; kein Modul im Baum braucht einen verzögerten Import.
bootstrap/ ist kein Router und importiert repositories/db/ direkt.
Der Paketbaum
src/thingsboard_frost_adapter/
clients/
frost_client.py thingsboard_client.py keycloak_client.py tenant_clients.py
repositories/
db/
crud.py
observed_property_repository.py telemetry_key_repository.py
unit_of_measurement_repository.py unit_of_measurement_key_repository.py
tenant_repository.py tenant_observed_property_repository.py
external/
frost_repository.py thingsboard_repository.py
services/
observed_property_service.py telemetry_key_service.py
unit_of_measurement_service.py unit_of_measurement_key_service.py
tenant_service.py tenant_observed_property_service.py
device_export_service.py tenant_healthcheck_service.py
cross_tenant.py
routers/ bootstrap/ main.py
db/ redis/ model/ exceptions/ configuration/ (Querschnitt)
Wo liegt was
Die Module, deren Zuschnitt sich nicht aus dem Namen allein ergibt:
| Modul | Subjekt |
|---|---|
|
|
|
|
|
|
|
Fünf generische Funktionen ( |
|
|
|
Die ThingsBoard-Geräte eines Mandanten, deren Assets und deren Messwerte in den FROST dieses
Mandanten zu schieben. Der heiße Pfad ist |
|
Die Kopien der globalen Observed Properties im FROST eines Mandanten — auflösen, anlegen,
aktualisieren. Nicht zu verwechseln mit |
|
Die kanonische Observed-Property-Zeile in Postgres und die Schleife über alle Mandanten. |
|
Die beiden mandantenübergreifenden Regeln: |
|
Mandantenzeile und Lebenszyklus in einem Modul: |
|
Die Erreichbarkeitsprüfung eines Mandanten. Kein Repository, sondern Konfigurationsvalidierung — sie gattert einen Schreibvorgang, bevor überhaupt eine Mandantenzeile entsteht. |
Namenskonventionen
Jedes Modul, das ein Subjekt besitzt, trägt seine Schicht als Suffix — _client,
_repository, _service, _router —, auch wenn das Paket sie bereits nennt. Dieselben
Substantive (tenant, frost, observed_property) wiederholen sich über alle vier Schichten;
ohne Suffix hätte man drei Editor-Tabs namens tenant.py.
Ein Modul ohne eigenes Subjekt trägt kein Suffix. Davon gibt es genau zwei: crud.py, benannt
nach einem Mechanismus, und cross_tenant.py, benannt nach einer Beziehung. Das fehlende Suffix
sagt: Das ist ein Helfer, kein Einstiegspunkt.
Bindung: an einen Mandanten gebunden → Objekt, konstruiert aus TenantClients. Global → Modul
von Funktionen. Postgres ist eine Engine für den ganzen Prozess, also ist repositories/db/ ein
Modul von Funktionen; FROST und ThingsBoard gibt es je Mandant, also ist repositories/external/
objektbasiert. Innerhalb von services/ und repositories/db/ gibt es keine Klassen und keine
Modul-Singletons.
Zu jeder mandantengebundenen Klasse steht eine Fabrik <name>_for(tenant) neben ihr. Es gibt drei:
frost_repository_for(tenant) # repositories/external/frost_repository.py
device_export_service_for(tenant) # services/device_export_service.py
tenant_observed_property_service_for(tenant) # services/tenant_observed_property_service.py
Die Verträge zwischen den Schichten
Ein Repository, eine Tabelle
Ein Repository setzt select() nur gegen seine eigene Tabelle ab und schreibt nur seine eigene
Tabelle. Einer Relationship zum Lesen zu folgen ist erlaubt — das ist das Lesen des Modells. Die
Zuweisung an eine Relationship-Collection ist ein Schreibzugriff auf die andere Tabelle und
verboten.
Ein tabellenübergreifender Lesezugriff liegt im Repository der referenzierenden Tabelle, etwa
telemetry_key_repository.find_by_observed_property_id. Wer die Abfrage aufruft, ist keine
Eigenschaft der Abfrage.
Die Session gehört dem Repository
Jede Repository-Funktion öffnet ihre eigene Session, committet und schließt sie. services/ öffnet
nie eine Session und bekommt nie eine — kein Session-Parameter oberhalb von repositories/.
Ein Repository gibt ein vollständig geladenes Objekt zurück, weil die Zeile nach dem Block
abgelöst ist. Zwei Wege dahin: lazy=False auf TelemetryKey.default_uom,
TelemetryKey.observed_property und UnitOfMeasurementKey.uom, sowie das Berühren des Attributs
innerhalb der Session für die beiden Lazy-Collections (_ = uom.telemetry_keys).
Die Tabellen und Beziehungen dahinter stehen in
Datenbankschema des Adapters.
Kein fsc-Typ oberhalb von services/
fsc-Entitäten (Thing, Datastream, Sensor, …) überqueren die Grenze zwischen
repositories/external/ und services/ frei in beide Richtungen. Was das Repository kapselt, ist
der Client — SensorThingsService, sein Query-Builder, seine OData-Filter und seine 404er.
Oberhalb von services/ erscheint kein fsc-Typ. Deshalb ist /telemetry ein einziger
export_telemetry-Aufruf: Die Zweischritt-Form hielte ein fsc.Thing in einer Routenvariablen.
Abwesenheit an der FROST-Grenze
find_ liefert None, wenn die Entität fehlt, und wirft, wenn das Fremdsystem krank ist.*
_is_not_found bleibt privat im FROST-Repository und verlässt das Modul nie. Die Heil-Politik
liest sich dadurch als Politik (if copy is None: heal) statt als Exception-Klempnerei.
Sperr-Reihenfolge
Sperren werden in beiden Schichten genommen:
| Sperre | genommen in |
|---|---|
|
|
|
|
|
|
|
|
|
Werden zwei Sperren gleichzeitig gehalten, ist |
Die Fehlergrenze
„Nur routers/ spricht HTTP" gilt hier nicht: HTTPException ist in
tenant_ingest_router.reported() tragendes Transportmittel — fangen, .status_code lesen, mit
angereichertem Detail erneut werfen —, und from fastapi import HTTPException zeigt seitwärts auf
das Framework, nicht nach oben.
| Ursprung | wirft | wird zu |
|---|---|---|
|
|
400 mit Nennung der fehlgeschlagenen Prüfung |
|
|
409 |
|
|
404 — siehe Die benannten Ausnahmen |
Stammdaten- und Fan-out-Wächter in |
|
422 über den Handler in |
|
|
500 über den Handler in |
unbekannte Id |
|
404, im Router |
Zielsystem nicht erreichbar |
|
502 über den Handler in |
Die benannten Ausnahmen
Vier Stellen brechen eine der obigen Regeln. Alle vier sind bewusst und abschließend:
| Stelle | Ausnahme |
|---|---|
|
wirft |
|
macht als einziges Modul in |
|
nehmen eine |
|
besteht aus fünf einzeiligen Weiterleitungen und sonst nichts. Es existiert trotzdem, damit „Router importieren nur `services/`" ein Satz ohne Ausnahme bleibt. |
Die Teststruktur
Die oberste Achse von tests/ ist die Abhängigkeit (unit / integration), das Schichtschema
ist der Baum darunter.
tests/
unit/ clients/ configuration/ model/ redis/
integration/ bootstrap/ clients/ routers/ services/
test_tenancy.py test_thingsboard_fixture.py
test_thingsboard_provisioning.py test_staging_happy_path.py
test_frost_housekeeping.py
conftest.py rows.py tenants.py
tenant_registry_data.py thingsboard_data.py
frost_housekeeping.py
Die Spiegelregel, die der Docstring in tests/unit/init.py hält:
-
Ein Test mit einem Modul unter Test liegt in dessen Schicht und ist nach ihm benannt — aufgelöst über das Modul unter Test, nicht über den obersten Import in der Datei.
-
Ein Test, dessen Subjekt eine Eigenschaft des Gesamtsystems ist, bleibt ungespiegelt in der Wurzel von
tests/integration/.
repositories/ erscheint in keinem der beiden Bäume: Nichts ruft ein Repository als seinen
Einstiegspunkt auf. Die Repository-Primitive sind durch den Service abgedeckt.
tests/integration/conftest.py gibt je Mandant ein Fixture pro Subjekt heraus, statt eines
Bündels, das alle Handles auf einmal trägt:
| Fixture | liefert |
|---|---|
|
die |
|
den rohen |
|
ein |
|
einen |
|
einen |
Durchsetzung
|
Die Schichtregel wird ausschließlich in Prosa durchgesetzt. Es gibt keine Werkzeugkette dafür —
kein |
Drei prüfbare Formen dienen als Leitfaden für das Code-Review — das, worauf man beim Lesen eines Diffs achtet:
| prüfbare Form | was sie schützt |
|---|---|
kein |
„Was das Repository kapselt, ist der Client" — der Query-Builder und die OData-Filter bleiben drin. |
kein |
die obere Grenze der FROST-Entitäten; der Grund, warum |
kein rohes |
die eine begründete Ausnahme bleibt die eine, siehe Die benannten Ausnahmen. |
|
Die erste Form ist als Lesehilfe formuliert und nicht als Grep: |
Alle drei binden src/ und nicht tests/. Das ist Absicht: Tests lesen FROST bewusst roh, damit
sie einen Schreibvorgang unabhängig von dem Code prüfen können, der ihn ausgelöst hat. Ein Test,
der dieselbe Abstraktion benutzt wie der Code unter Test, prüft sie nicht.