1. Grenzen des Anhangssubsystems
Wenn ein Anhang bereits list/detail/download/delete hat, tritt das nächste Problem nicht bei der URI oder den Statuscodes auf, sondern bei den Grenzen des Subsystems. Eine Datei ist kein „weiteres Feld im DTO“, sondern ein eigenes Universum: Speicher, Festplatten, Rechte, Namen, Typen, Ein-/Ausgabefehler und die ewige Frage „wo liegt sie überhaupt?“. Wenn man keine Grenzen setzt, beginnt das Projekt auszufransen: Controller werden dick, DTOs blähen sich auf und Fehler werden unvorhersehbar. In unserem Task Tracker API gestalten wir den Attachment-Teil bewusst klein, damit er anwendungsnah bleibt, aber nicht zu einer Mini-Cloud-Speicherlösung wird.
Eine gute Methode, um Grenzen einzuhalten, ist es, stets im Kopf zu behalten, wer für was verantwortlich ist. Der Controller ist für den HTTP-Vertrag und das Antwortformat zuständig. Der Service ist für das Szenario und die Koordination verantwortlich: Metadaten finden, Inhalt laden, beide löschen. Das Repository ist für Metadaten im Speicher zuständig. Der Storage ist für die Bytes auf der Festplatte zuständig. Wenn Sie das Bedürfnis verspüren, „mal schnell den Pfad zur Datei direkt im Controller nachzuschauen“ – das ist ungefähr so, als würde man „mal schnell Geschäftslogik in ein DTO stopfen“: zuerst scheint es praktisch, dann erkennt man sich selbst nicht wieder.
Um dies anschaulich festzuhalten, hier eine kleine Tabelle – ohne sie verliert man leicht den Überblick und beginnt, das Dateisystem durch den gesamten Code zu schleppen:
| Schicht | Was sie wissen sollte | Was sie nicht wissen sollte |
|---|---|---|
| Controller (api.controller) | URI, Status, Header, JSON vs. binär, ResponseEntity | Pfade auf der Festplatte, Files.readAllBytes(), Path.resolve(...) |
| Service (domain.service) | Szenario: Anhang finden, Regeln prüfen, Storage aufrufen | Wie genau der physische Pfad aufgebaut ist und wo sich das root-Verzeichnis befindet |
| Repository (infrastructure.repository.inmemory) | Speicherung von Metadaten-Objekten | Keine Resource, keine MultipartFile |
| Storage (infrastructure.storage) | Inhalt speichern/laden/löschen | Sollte keine HTTP-Antwort und keine Header bilden |
Diese Disziplin wirkt langweilig, bis zum ersten Fehler wie „funktioniert unter Windows, nicht unter Linux“ oder „bei kleinen Dateien ok, bei großen Absturz“. Und solche Fehler, glauben Sie mir, finden Menschen auch ohne Tests – einfach aus reiner Abenteuerlust.
2. Speicher und byte[]
Unser Basispfad für Downloads ist bereits festgelegt: Inhalt wird getrennt von Metadaten ausgeliefert und nicht in JSON gestopft. Jetzt ist es wichtig zu verstehen, warum die Variante mit byte[] so schnell wehtut.
Wenn Sie zum ersten Mal mit Datei-Downloads in Kontakt kommen, sieht der naheliegendste Weg so aus: „Datei in byte[] einlesen, byte[] als Antwort zurückgeben“. Das funktioniert, der Code ist kurz, das Gehirn ist zufrieden. Das Problem ist, dass der Speicher in Java nicht unbegrenzt ist und große Byte-Arrays nicht nur Platz belegen, sondern auch den GC belasten. Wenn Sie die Datei vollständig in ein Array einlesen, garantieren Sie sich, dass für das Herunterladen von 50 MB mindestens ein Array von 50 MB benötigt wird, plus etwas Overhead. Und wenn zwei Clients gleichzeitig herunterladen, haben Sie bereits einen Mini-Wettbewerb: „Wer schafft es, die Anwendung schneller zum Absturz zu bringen?“
Das Unangenehmste ist, dass der Fehler nicht sofort auftritt. Bei Testdateien von 10–100 KB sind Sie überzeugt, dass alles perfekt ist. Dann lädt jemand ein PDF mit gescannten Seiten hoch, und Ihr Dienst beginnt plötzlich langsam zu antworten oder stürzt mit OutOfMemoryError ab. Das ist der Fall, in dem „funktioniert auf meinem Rechner“ kein Scherz mehr ist, sondern eine Diagnose.
Hier ein typisches Anti-Beispiel (so sollte man es für einen Download-Endpunkt nicht machen):
import java.nio.file.Files;
import java.nio.file.Path;
public byte[] loadFile(Path path) throws Exception {
// Anti-Pattern für Download-Endpoint: liest die Datei vollständig in den Speicher und belastet den GC
return Files.readAllBytes(path);
}
Beim Upload-Szenario tritt ein ähnliches Problem auf, wenn Sie file.getBytes() verwenden und dann irgendwo speichern. Auch das materialisiert die gesamte Datei im Speicher. Manchmal macht man das, „um die Validierung zu vereinfachen“, und wundert sich dann, warum es dem Server schlecht geht.
In unserem Kurs halten wir an einem einfachen und klaren Prinzip fest: Metadaten sind JSON und kleine Felder, Inhalt ist ein Stream/eine Ressource, und wir müssen ihn nicht vollständig materialisieren. Spring MVC bietet uns dafür das praktische Modell – Resource.
3. Resource statt byte[]
Jetzt schlüsseln wir auf, was genau Resource auf der Grundlage dieses Vertrags bietet. Wenn Sie Resource in Spring zum ersten Mal sehen, könnte der Eindruck entstehen, es sei „ein Spring-Ding“. In Wirklichkeit ist es eine sehr praktische Abstraktion: „Ich habe etwas, das als Bytestrom gelesen werden kann“. Das kann eine Datei auf der Festplatte sein (FileSystemResource), eine Ressource aus dem Classpath, ein Input-Stream usw. Für einen Download-Endpunkt benötigen wir in erster Linie eine Datei auf der Festplatte, daher ist FileSystemResource die direkteste Variante.
Der entscheidende Vorteil ist, dass Resource es Spring MVC ermöglicht, den Antwortbody als Stream zu schreiben, anstatt uns zu zwingen, vorher ein Byte-Array zu erstellen. Wir bleiben im Rahmen einfachen Codes, verursachen aber keine „Speicherexplosion auf Knopfdruck“.
Eine minimale Implementierung von loadAsResource() im lokalen Storage könnte so aussehen:
import java.nio.file.Path;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
public Resource loadAsResource(Path root, String storageKey) {
// Storage ist nur für den Zugriff auf Bytes verantwortlich: Pfad erstellen und Resource zurückgeben
// Hier lesen wir die Datei nicht in den Speicher – Spring streamt den Inhalt selbst
return new FileSystemResource(root.resolve(storageKey));
}
Im echten Code ist es sinnvoll, zumindest die Existenz der Datei zu prüfen und einen kontrollierten Fehler auszugeben (und nicht darauf zu hoffen, dass immer alles vorhanden ist). Dies ist besonders in einem Studienprojekt wichtig, weil Studenten oft Verzeichnisse von Hand löschen und sich dann wundern, dass die Anwendung „von selbst kaputtgegangen ist“.
import java.nio.file.Path;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
public Resource loadAsResource(Path root, String storageKey) {
// Lösen storageKey innerhalb des root-Verzeichnisses auf und geben die Ressource zum Streamen zurück
Resource resource = new FileSystemResource(root.resolve(storageKey));
// Wichtig: Fehlen der Datei ist eine kontrollierte Situation, kein "zufälliger 500"
if (!resource.exists()) {
throw new IllegalStateException("Attachment content is missing");
}
return resource;
}
Damit wird die Grenze festgelegt: Storage gibt Resource zurück, und dem Controller bleibt nur der HTTP-Teil – Status, Header und Antwortbody. Das bedeutet, dass download weder zu einem byte[]-Endpunkt noch zu einem Ort wird, an dem der Controller selbst in das Dateisystem eingreift.
4. Header Content-Type und Content-Disposition
Resource löst das Problem des Antwortbodys und des Speichers, hebt aber nicht die HTTP-Header auf. Der Client benötigt immer noch Content-Type und Content-Disposition: Ersterer gibt an, was darin enthalten ist, Letzterer, dass die Antwort als Download zu behandeln ist und unter welchem Namen.
Die praktische Grundlinie bleibt einfach: Media-Type mit sicherem Fallback auf application/octet-stream parsen, und den Dateinamen über ContentDisposition zusammenbauen, nicht manuell als Zeichenkette. So vermeiden wir Abstürze aufgrund eines fehlerhaften contentType und Probleme mit Anführungszeichen, Leerzeichen und exotischen Dateinamen.
Genau dieses Zusammenspiel hält download in vernünftigen Grenzen: Der Body kommt als Resource, Header werden explizit gebildet, und der Controller wird nicht zu einem Mix aus byte[], manuellen Header-Strings und Low-Level-I/O.
5. Löschen: Metadaten und Inhalt
Nachdem list/detail/download bereits eingerichtet sind, eignet sich delete gut als Prüfstein für die gesamte Architekturdisziplin. Wenn das Löschen bei Ihnen über Controller, Repository und Storage verteilt ist, werden Sie früher oder später einen „halben“ Zustand erhalten: Metadaten gelöscht, Datei geblieben; oder Datei gelöscht, Metadaten geblieben. In der Produktion führt das zu Müll auf der Festplatte und seltsamen 404/500-Fehlern am Download-Endpunkt. In einem Studienprojekt führt das zu ewigen Fragen: „Warum zeigt die Anhangsliste einen Eintrag, aber der Download funktioniert nicht?“
Das richtige Modell im Rahmen des Kurses lautet: Ein Anhang wird mit einem einzigen Service-Vorgang gelöscht, auch wenn es intern zwei Schritte sind. Der Service holt zuerst die Metadaten, denn nur dort gibt es den storageKey. Dann beauftragt er den Storage, die physische Datei zu löschen. Anschließend löscht er die Metadaten aus dem Repository. Der Controller bleibt darüber dünn und gibt 204 No Content zurück.
Hier ein Beispiel für die Service-Koordination (die Reihenfolge ist bewusst „Datei zuerst, Metadaten danach“ – so hinterlässt man leichter keine „toten Verweise“ auf die Datei):
import com.example.tasktracker.domain.model.AttachmentMetadata;
public void deleteAttachment(String taskId, String attachmentId) {
// Unterressource ohne Eltern existiert nicht, daher bestätigen wir zuerst die Aufgabe.
requireTaskExists(taskId);
// Holen Metadaten, denn nur dort gibt es storageKey.
AttachmentMetadata meta = attachmentRepository.findByTaskIdAndId(taskId, attachmentId)
.orElseThrow(() -> new AttachmentNotFoundException(attachmentId));
// Zuerst den Inhalt löschen, um keinen 'kaputten' Metadaten-Eintrag zu hinterlassen.
storage.delete(meta.getStorageKey());
// Dann Metadaten aus dem Repository löschen.
attachmentRepository.delete(taskId, attachmentId);
}
Hier ist eine weitere Disziplin wichtig: Der Storage verbirgt bereits rohe java.nio-Fehler innerhalb von AttachmentStorageException, sodass der Service keinen IOException abfangen und keinen Low-Level-Fehlerbehandlungszweig manuell erstellen muss. Für die API ist dies einfach ein technischer Storage-Fehler, der dann in den allgemeinen Error-Flow einfließt.
Um die Reihenfolge visuell zu festigen, hier ein einfaches Schema:
sequenceDiagram
participant C as AttachmentController
participant S as AttachmentService
participant R as "AttachmentRepository (in-memory)"
participant ST as "AttachmentStorage (local)"
C->>S: deleteAttachment(taskId, attachmentId)
S->>R: findByTaskIdAndId(...)
R-->>S: metadata (storageKey)
S->>ST: delete(storageKey)
ST-->>S: ok
S->>R: delete(taskId, attachmentId)
S-->>C: done
Der Sinn des Schemas liegt nicht in der „Schönheit von Mermaid“, sondern darin, dass delete ein Szenario ist und im Service leben sollte, nicht im Controller.
Wie man das Attachment-API nicht aufbläht
Dateien verleiten zu endlosen Verbesserungen. Es ist sehr leicht, den Wunsch zu verspüren, „die Beschreibung eines Anhangs zu aktualisieren“, „die Datei umzubenennen“, „alle Anhänge als ZIP herunterzuladen“, „Multiupload“, „Versionierung von Anhängen“, „öffentliche Links“, „Thumbnails für Bilder“ und noch zwanzig weitere Punkte, nach denen Sie plötzlich Google Drive entwickeln – aber ohne das Gehalt von Google. Deshalb ist es in unserem Projekt wichtig, nicht nur die architektonischen Grenzen, sondern auch die produktbezogenen Grenzen einzuhalten.
In der kanonischen Version des Task Tracker API bleiben Anhänge eine unterstützende Subresource: Sie existieren nur im Kontext einer Aufgabe und lösen eine einzige anwendungsnahe Aufgabe – eine Datei anzuhängen und später wieder abzurufen. Daher reichen uns upload, list metadata, detail metadata, download content und delete. Diese Menge demonstriert bereits alles, was für einen REST-Kurs benötigt wird: korrekte binäre Antwort, Header, Trennung von Metadaten/Inhalt, Storage-Abstraktion und einheitliches Error-Handling-Verhalten. Alles andere ist entweder ein separates Subsystem, ein separater Kurs oder ein separates Problem, das wir bewusst nicht in einem Studienprojekt heranziehen.
Hier ist es nützlich, sich bei einem einfachen Gedanken zu ertappen: Wenn eine neue Funktion von Ihnen verlangt, dem Studenten drei weitere neue Konzepte, zwei weitere neue Fehlermodelle und einen weiteren neuen Satz von Endpunkten zu erklären – dann haben wir höchstwahrscheinlich den Rahmen des „Anhangssubsystems als Teil einer REST-API“ verlassen. In einem Studienprojekt ist es besser, weniger zu machen, aber so, dass es erwachsen und vorhersagbar aussieht, als „alles“ zu machen, aber chaotisch und mit versteckter Magie.
6. Typische Fehler bei der Arbeit mit Anhängen
Fehler Nr. 1: Den Dateiinhalt innerhalb von AttachmentResponse speichern und versuchen, „kundenfreundlich“ zu sein.
Anfangs erscheint es logisch: „Wenn der Client einen Anhang angefordert hat, geben wir sowohl Metadaten als auch Bytes zurück“. Das zerstört jedoch die Idee der Verträge. Der Metadaten-Endpunkt wird schwer, die Liste wird zur Katastrophe, und JSON beginnt, Dinge zu transportieren, die es nicht transportieren sollte. Am Ende erstellen Sie entweder ein 100-MB-JSON oder Sie fangen an, „manchmal ist das Feld da, manchmal nicht“ zu erfinden, und der Vertrag wird unvorhersehbar.
Fehler Nr. 2: Die Datei beim Download vollständig in byte[] einlesen und sich freuen, dass „der Code kurz ist“.
Das ist ein Klassiker, der genau bis zur ersten großen Datei oder dem ersten parallelen Download „funktioniert“. Das Problem ist nicht, dass Files.readAllBytes() eine schlechte Methode ist; das Problem ist, dass Sie ein ungeeignetes Datenverarbeitungsmodell gewählt haben. Ein Download-Endpunkt geht um einen Stream, nicht um ein Array. Resource ist hier keine Verzierung, sondern eine Möglichkeit, den Erfolg einer Anfrage nicht an die Menge des verfügbaren Speichers zu koppeln.
Fehler Nr. 3: Content-Disposition als Zeichenkette zusammenbauen und sich versehentlich mit dem Dateinamen ins eigene Bein schießen.
Die Verkettung "attachment; filename=\"" + name + "\"" wirkt harmlos, solange name kein Anführungszeichen, keinen Zeilenumbruch oder einfach nur seltsame Zeichen enthält. Im besten Fall erhält der Client einen falschen Namen, im schlimmsten Fall einen ungültigen Header. In einem Studienprojekt reicht es, sich anzugewöhnen: ContentDisposition über einen Builder zu erstellen und Header nicht zu einem „manuellen Protokoll“ zu machen.
Fehler Nr. 4: Dem contentType als Wahrheit vertrauen und abstürzen, wenn er fehlt oder fehlerhaft ist.
contentType sind Metadaten, kein Naturgesetz. Er kann null sein, kann „application/unknown“ sein, kann Müll sein. Wenn Ihr Endpunkt mit 500 abstürzt, weil der Media-Type nicht geparst werden konnte, bestrafen Sie den Client für etwas, das man mit einem einzigen Fallback hätte überleben können. Ein Fallback auf application/octet-stream ist eine einfache und erwachsene Lösung.
Fehler Nr. 5: Die Datei im Controller löschen, die Metadaten im Service und sich dann über Inkonsistenzen wundern.
Wenn delete „verteilt“ ist, erhalten Sie mit ziemlicher Sicherheit einen Zustand, in dem ein Teil der Operation durchgeführt wurde, ein Teil jedoch nicht. Für den Client sieht das wie ein Fehler aus: „Im der Liste vorhanden, Download nicht möglich“ oder „Download möglich, aber in der Liste nicht sichtbar“. Für Sie sieht es wie ein verlorener Abend aus. Die Abhilfe ist einfach: Die Koordination des Löschens muss in einer einzigen Service-Methode erfolgen, die das Löschen eines Anhangs als eine Operation betrachtet.
GO TO FULL VERSION