Zum Inhalt springen

Dateien und Storage

Kurzantwort: Die Bytes liegen im Dateisystem, die Metadaten in der Datenbank. Der Pfad ist <basePath>/[<tenant>/]<modellpfad>/<fileId> – der Dateiname im Pfad ist allein die fileId, der Anzeigename steht nur im Datensatz. Bei auditierten Modellen bleibt beim Überschreiben die alte Fassung als Version erhalten (umbenannt), ein Rollback kopiert sie zurück.


flowchart LR
    C[Client] -->|multipart oder base64| API[AbstractRestApi]
    API -->|Temp-Datei je Teil| SL[AbstractLayer]
    SL -->|Rechteprüfung, fileId| FC[LocalFSFileController]
    FC --> FU[FileUtils]
    RC[RequestContext] --> FU
    FP["FileProperties<br/>basePath"] --> FU
    MM["MetaClassInfo<br/>filePath, environment, audited"] --> FU
    FU --> FS[(Dateisystem / PVC)]
    SL -->|Metadaten| DB[cdms-database]
    DB --> RDB[(Datenbank)]
AufgabeOrt
Multipart entgegennehmen, Temp-Dateien schreibencdms-rest-api
Entscheiden, ob geschrieben/gelesen werden darf; fileId vergebencdms-system-layer
VertragFileStorageControllerInterface in cdms-commons
Pfad bilden, Bytes bewegen, Versionen haltencdms-localfs-storage
Metadaten persistierencdms-database

Das Speicher-Backend ist optional. Ein System, das keine Datei-Modelle hat, deklariert storage: NONE – dann nimmt der Generator keine Backend-Abhängigkeit in die pom.xml auf, und die Anwendung startet ohne eine Implementierung des Vertrags.

Das ist ein gültiger Zustand, kein halb konfigurierter: Eine Anwendung, die keine Blobs speichert, soll dafür auch keine Abhängigkeit tragen. Die Auswahl steht in system.yaml:

WertWirkung
LOCALFSDateisystem-Backend, siehe unten
S3, AZUREeigene Artefakte hinter demselben Vertrag
NONEkein Backend, keine Abhängigkeit, kein Bean

Fehlt das Backend und wird trotzdem eine Datei-Operation erreicht, schlägt sie fehl – mit file-storage-not-configured. Absicht: Ein stiller Leerlauf würde einen Upload annehmen und verwerfen. Die beiden Fälle „keine Datei-Modelle” und „Datei-Modelle ohne deklariertes Backend” sind erst in dem Moment unterscheidbar, in dem eine Datei-Operation läuft – deshalb wird dort geprüft und nicht beim Start.

<basePath>/[<tenant>/]<modellpfad>/<fileId>

Beispiel MULTI, Mandant t1, Modell FileReport:

/files/t1/filereport/910e2c19-d5d1-4dae-8229-ea5fcbcc6194

Dieselbe Datei in SINGLE:

/files/filereport/910e2c19-d5d1-4dae-8229-ea5fcbcc6194
BestandteilHerkunft
basePathcodamai.cdms.persistence.file.basePath, auf / normalisiert
tenantnur bei mandantengebundenen Modellen in MULTI
ModellpfadMetaClassInfo.getFilePath(), generiert aus dem API-Pfad
fileIdUUID, vom System-Layer vergeben, im Datensatz gespeichert

Der Anzeigename kommt im Pfad nicht vor. Früher hing er dort mit drin – dann verschob ein Umbenennen die Datei und ließ die alte verwaisen, weshalb Umbenennen unterbunden war. Heute ist der Pfad eine reine Funktion der unveränderlichen Identität, und Umbenennen ändert genau eine Spalte.

Zwei Folgen:

  1. Es gibt kein Sanitizing mehr – kein vom Benutzer eingegebener String erreicht das Dateisystem.
  2. Der MIME-Typ wird aus dem Anzeigenamen abgeleitet, weil die gespeicherte Datei keine Endung hat.

Die Trennung ist positional: eine zweite Prüfung beim Lesen gibt es nicht. Deshalb wird die Mandantenkennung geprüft, nicht bereinigt – sie muss [a-zA-Z0-9._-]+ entsprechen und darf kein .. enthalten. Bereinigen würde a/b und ab auf dasselbe Verzeichnis abbilden.

SituationErgebnis
Systemmodell, kein Request-Kontextaus dem gemeinsamen Wurzelverzeichnis bedient
mandantengebunden, kein KontextCDMS_FILE_CONTEXT_MISSING
mandantengebunden, Kontext ohne MandantCDMS_FILE_TENANT_REQUIRED
mandantengebunden, unzulässige KennungCDMS_FILE_TENANT_INVALID

Die Einordnung des Modells wird vor dem Request-Kontext geprüft – genau wie in der Datenbank. Ein Systemmodell bleibt ohne Kontext verarbeitbar.

USER-Modelle zählen als mandantengebunden: Der Owner-Filter verengt zusätzlich, er ersetzt die Mandantengrenze nicht.

void saveFile(ModelMetaInterface meta, FileInterface pFileMeta, Path path);
byte[] getFile(ModelMetaInterface meta, FileInterface pFileMeta);
void deleteFile(ModelMetaInterface meta, FileInterface pFileMeta);
List<Long> listFileVersions(ModelMetaInterface meta, FileInterface pFileMeta);
byte[] getFileVersion(ModelMetaInterface meta, FileInterface pFileMeta, long version);
OptionalLong rollbackFile(ModelMetaInterface meta, FileInterface pFileMeta, long version);
  1. Fehlt die Quelldatei, schlägt der Aufruf mit CDMS_FILE_SOURCE_MISSING fehl. Ein Speicheraufruf, der nichts speichert, darf keinen Erfolg melden – sonst persistiert der System-Layer Metadaten zu Inhalt, den es nie gab.
  2. Ohne fileId wird eine erzeugt (normalerweise hat das der System-Layer schon getan).
  3. Zielpfad berechnen, Verzeichnis mit ausdrücklichem Modus anlegen.
  4. Bei auditierten Modellen wird der bisherige Inhalt vorher als Version umbenannt.
  5. Der Inhalt wird unter einem privaten Namen im Zielverzeichnis zusammengeführt und per Rename auf den Zielpfad gehoben – innerhalb eines Dateisystems ist das atomar, ein Leser sieht also nie einen halb geschriebenen Stand.
  6. fileSize aus der abgelegten Datei, mimeType aus dem Anzeigenamen über URLConnection.guessContentTypeFromName(...), sonst application/octet-stream.
  7. IOExceptionFileStorageException("file-not-saved", …).

Die ermittelten Metadaten werden in das übergebene Objekt zurückgeschrieben; der System-Layer persistiert sie.

Die Datei wird vollständig in den Speicher gelesen und als byte[] zurückgegeben. Das begrenzt die Dateigröße praktisch:

  • Der Heap-Bedarf wächst mit der Dateigröße je gleichzeitigem Download.
  • byte[] endet bei 2 GiB, und fileSize ist ein Integer.

Erst werden alle aufbewahrten Versionen entfernt, dann die aktuelle Datei (deleteIfExists, idempotent). Gelöschte Inhalte dürfen nicht unter einer älteren Version lesbar bleiben – sonst wäre ein Löschbegehren nicht erfüllt.

Aufgerufen wird das aus AbstractLayer.recursiveDelete(...), für das Objekt selbst und jedes kaskadierte Kind, jeweils über dessen eigenen System-Layer.

Ob eine Datei Historie führt, entscheidet das audited-Flag des Modells – dasselbe Flag, das die Entity auditiert. Es gibt dafür keine eigene Property.

aktuell: <fileId>
aufbewahrt: <fileId>.1785574268973
  • Der aktuelle Inhalt trägt immer den schlichten Namen ohne Suffix.
  • Die Versionskennung wird beim Schreiben vergeben und im Feld fileVersion des Datensatzes gespeichert – Datensatzstand und Bytes benennen einander.
  • Der Wert sind Millisekunden seit Epoch zum Schreibzeitpunkt, bei Kollision hochgezählt: eindeutig je Datei, größer heißt neuer. Als Zeitstempel ist er nicht zu interpretieren; maßgeblich ist listFileVersions.
  • Versionen werden über die fileId gefunden, nicht über den Anzeigenamen – eine Umbenennung lässt die Historie intakt.

rollbackFile in zwei Schritten: erst wird der aktuelle Inhalt unter einer neuen Kennung aufbewahrt (ein Rollback ist eine Änderung), dann wird die gewünschte Version auf den schlichten Namen kopiert. Kopiert, nicht verschoben – die Version bleibt in der Historie und ist erneut wiederherstellbar.

Für ein nicht auditiertes Modell gilt nichts davon: Überschreiben ist endgültig, Wiederherstellung ist Sache der Infrastruktur-Sicherung.

Multipart: Teile data (JSON) und files. Die Zuordnung läuft über den Dateinamen; das Feld name im Datei-Objekt muss dem Originalnamen entsprechen. Doppelte oder fehlende Dateinamen werden abgelehnt.

Base64 im JSON: Jedes Objekt mit einem content-String wird – in beliebiger Tiefe – als Datei erkannt, dekodiert und in eine Temp-Datei geschrieben. Ein data:-Präfix wird entfernt.

Im Frontend: Eine File niemals als JSON serialisieren. Das ergibt "[object File]". Richtig ist echtes FormData.

Es gibt keine gemeinsame Transaktion. Reihenfolge: erst der Blob (im recursivePrepare), dann der Datensatz, Commit am Request-Ende.

FehlerErgebnis
Verschieben scheitertRollback → weder Datensatz noch Blob. Konsistent.
Verschieben klappt, Request rollt später zurückBlob verwaist
Datensatz gelöscht, Blob-Löschung scheitertBlob verwaist

Temp-Dateien werden nirgends ausdrücklich gelöscht – sie verschwinden dadurch, dass saveFile sie verschiebt. Jeder Pfad, der das Verschieben nicht erreicht, lässt eine Temp-Datei im System-Temp-Verzeichnis zurück.

Files.move ist nur innerhalb eines Dateisystems atomar. Im Container liegen /tmp (Overlay) und basePath (PVC/NFS) regelmäßig auf verschiedenen Mounts; ein Move quer über die Mount-Grenze wird zu einem Kopieren, und der Zielpfad ist sichtbar, während er wächst.

Genau deshalb wird der Inhalt nicht direkt auf den Zielpfad geschrieben: Er wird im Zielverzeichnis unter einem privaten Namen zusammengeführt und von dort umbenannt. Beide Pfade liegen dann im selben Dateisystem, und der letzte Schritt ist ein Rename – atomar, unabhängig davon, wo die Temp-Datei herkam.

Das schützt Leser, nicht Schreiber: Zwei gleichzeitige Schreiber benennen beide auf denselben Zielpfad um, der letzte gewinnt, und niemand bemerkt es. Das ordnet der Datensatz, nicht der Speicher.

Das Modul ist die LOCALFS-Implementierung eines Vertrags mit vorgesehenen Geschwistern (S3, AZURE, siehe Repos und Artefakte) und mit NONE als gültiger Antwort für Systeme ohne Dateien. Nichts hier ist so geschrieben, dass es „auch für S3 passt” – Objektspeicher-Semantik ist eine andere Implementierung hinter derselben Schnittstelle, kein Konfigurationsschalter.