Maciej Soliński
Wróć do bloga

Jak zwracać obrazy z REST API w Spring Boot?

Obraz jako odpowiedź HTTP czy adres w JSON? Przykład Spring MVC, przechowywanie plików i zasady cache.

Jak zwracać obrazy z REST API w Spring Boot?

Produkt ma zdjęcie, użytkownik awatar, a dokument miniaturę. Gdy projektujemy API, pojawia się pytanie: czy endpoint powinien zwrócić sam plik, czy jego adres?

Oba rozwiązania są poprawne. Często występują razem: odpowiedź z danymi produktu zawiera URL, a osobne żądanie pod ten URL pobiera obraz. Decyzja dotyczy przede wszystkim miejsca przechowywania pliku i sposobu kontrolowania dostępu.

JSON opisuje zasób, HTTP przesyła plik

Przykładowa odpowiedź produktowa może wyglądać tak:

{
  "id": "123",
  "name": "Notebook",
  "imageUrl": "/api/products/123/image"
}

Pole imageUrl jest adresem HTTP, który klient potrafi wykorzystać. Nie powinno zawierać ścieżki typu /var/app/uploads/photo.jpg: to szczegół serwera, niedostępny dla przeglądarki.

Żądanie obrazu może zwrócić Content-Type: image/jpeg i binarną treść. Umieszczanie obrazu jako Base64 w JSON zwiększa rozmiar niekompresowanej reprezentacji o około jedną trzecią dla większych danych i wiąże pobranie obrazu z pobraniem reszty odpowiedzi. Dla galerii lub zdjęć produktów osobny zasób jest zwykle wygodniejszy.

Mały przykład w Spring MVC

Poniższy kontroler udostępnia jeden publiczny, zaufany plik images/sample.jpg z katalogu src/main/resources. Nie przyjmuje ścieżek od użytkownika i nie jest systemem obsługi uploadów. Wymaga Spring MVC: w Boot 3 typowo startera web, a w Boot 4 startera webmvc.

import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.concurrent.TimeUnit;

@RestController
public class ImageController {

    @GetMapping(value = "/api/images/sample",
            produces = MediaType.IMAGE_JPEG_VALUE)
    public ResponseEntity<Resource> image() {
        Resource resource = new ClassPathResource("images/sample.jpg");
        if (!resource.exists()) {
            return ResponseEntity.notFound().build();
        }

        return ResponseEntity.ok()
                .contentType(MediaType.IMAGE_JPEG)
                .cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS)
                        .cachePublic())
                .body(resource);
    }
}

Resource pozwala Springowi przekazać zawartość przez mechanizm konwersji odpowiedzi, bez tworzenia w kontrolerze tablicy z całym plikiem. Nie oznacza to automatycznie transferu zero-copy. Szczegóły obsługi zasobów opisuje dokumentacja @ResponseBody.

Nagłówek JPEG jest tutaj właściwy, ponieważ znamy plik. Dla różnych formatów typ trzeba ustalić na podstawie zweryfikowanej zawartości lub zaufanych metadanych. Sama nazwa przesłana przez użytkownika nie jest wystarczającym źródłem informacji.

Prosty sprawdzian po uruchomieniu aplikacji:

curl -i http://localhost:8080/api/images/sample -o response.txt
curl -f http://localhost:8080/api/images/sample -o downloaded.jpg

Pierwsza komenda zapisuje nagłówki razem z binarnym body, więc response.txt nie jest czystym obrazem. Druga zapisuje sam plik. Jeśli w aplikacji działa Spring Security, dostęp będzie zależał również od jego konfiguracji.

Gdzie trzymać obrazy użytkowników?

Zasób w classpath pasuje do pliku dołączonego do aplikacji. Zdjęcia przesyłane przez użytkowników mają inny cykl życia i nie powinny wymagać przebudowania JAR-a.

Rozwiązanie Kiedy rozważyć Co zaplanować
Dysk lub wolumen Prosty system i kontrolowane środowisko Trwałość, kopie zapasowe, dostęp wielu instancji
Object storage, np. S3 lub zgodne API Wiele plików, oddzielne skalowanie Uprawnienia, koszty, retencję i adresy pobierania
Baza danych Plik silnie związany z transakcją danych Rozmiar bazy, transfer i wykonywanie backupów

Backend może przechowywać identyfikator obiektu i metadane, a klientowi zwracać adres do pobrania. W zależności od potrzeb plik może dostarczać backend, object storage albo CDN. Nie trzeba tworzyć osobnego mikroserwisu tylko dlatego, że aplikacja posiada zdjęcia.

Cache i dostęp projektuj razem

Dla publicznego zdjęcia produktu cache w przeglądarce lub CDN może ograniczyć transfer. Wersjonowany adres, zmieniany razem z zawartością, upraszcza odświeżanie obrazu. ETag i żądania warunkowe umożliwiają odpowiedź 304, gdy klient ma aktualną wersję. Mechanizmy te opisuje dokumentacja HTTP caching.

Prywatny dokument nie powinien dziedziczyć cachePublic() z przykładu. Potrzebuje kontroli dostępu oraz świadomego wyboru private lub no-store. Podpisany URL daje dostęp posiadaczowi linku przez określony czas, więc należy chronić go przed niepotrzebnym ujawnieniem. Nie jest równoważny sprawdzaniu uprawnień przy każdym odczycie.

Przy odbiorze uploadów trzeba ograniczyć rozmiar i wymiary obrazu, sprawdzić format oraz nadawać własne klucze przechowywania. Unikaj bezpośredniego łączenia katalogu z nazwą pliku od klienta.

Dobrym początkiem jest URL w odpowiedzi JSON i prosty endpoint pobierania. Gdy rośnie liczba plików lub transfer, można przenieść ich dostarczanie do odpowiedniej infrastruktury bez zmiany całego modelu danych produktu.