Ta lekcja poświęcona jest operacjom na plikach — wgrywaniu, wyświetlaniu (lub umożliwieniu pobierania) a także podstawowym zasadom przy pracy z plikami.
Większość aplikacji webowych pozwala na upload plików w ten czy inny sposób — najczęściej jest to zdjęcie profilowe, załącznik do jakiegoś rekordu (np. skan faktury w programie księgowym), czy po prostu dokument (w programie do zarządzania dokumentami w firmie). Z technicznego punktu widzenia jednak jest to ta sama czynność — użytkownik wysyła nam plik, my go przechowujemy i na żądanie zwracamy (także z użyciem protokołu HTTP). Dzisiaj nauczymy się jak to robić, a także poznamy kilka dobrych praktyk/sugestii związanych z operacjami na plikacji.
Lekcja
UWAGA! W tej lekcji przykłady są opisane w oparciu o standardowe pliki trzymane na serwerze. To z wielu powodów bardzo złe wyjście (w treści więcej informacji o tym, dlaczego), podobnie jak umożliwienie ich pobierania za pomocą metody kontrolera. Ma to na celu jedynie uproszczenie tej lekcji, prawidłowej obsługi będziemy się uczyć w kolejnej, poświęconej tylko temu zagadnieniu. Dzisiejsza lekcja ma na celu przedstawienie głównie mechanizmu obsługi przesyłania plików w Springu.
Jak można przesłać pliki w aplikacjach webowych
Są dwie podstawowe metody przesyłania plików — POST oraz PUT (dokładnie tak jak myślisz, mówimy o metodach protokołu HTTP — jeśli jednak nie pamiętasz co to, zajrzyj do lekcji #9). Różnią się one (z punktu widzenia użyteczności) tym, że używając metody POST możemy przesłać w ramach formularz dodatkowe dane (np. opis, komentarz, informację itp). Z tego względu jest to najczęściej używana metoda i z niej będziemy korzystać. Jej minusem jest trochę większa komplikacja samego mechanizmu — zamiast po prostu przesłać zawartość pliku w treści zapytania HTTP, musimy rozróżnić dane formularza od danych pliku (plików). Jest to realizowane za pomocą atrybutu enctype = multipart/form-data tagu form, jak na poniższym przykładzie:
<form enctype="multipart/form-data" method="post">
<input type="file" name="plik" />
<input type="text" name="opis" />
</form>
Taki kod od strony HTML jest wystarczający, żeby wysłać plik na serwer.
Ponieważ nie będziemy się zajmować bezpośrednio kodowaniem/rozkodowaniem multipart, powiemy sobie tylko w skrócie co to jest. Multipart to nic innego jak podział wiadomości do przesłania (w tym wypadku plik + dane formularza) na kilka części (ale nadal wysyłanych w jednym zapytaniu HTML — po prostu każda część może być innego ‘typu’, czyli być albo plikiem albo danymi formularza). Dzięki temu serwer może ‘odtworzyć’ zarówno dane jak i przesłany plik.
Uwaga praktyczna: istnieje wiele ‘ładnych’ elementów do przesyłania plików bazujących na HTML5 czy JS — powyższy przykład wyświetli jedynie ‘standardowe’ okienko. Z punktu widzenia serwera, pliki są wysyłane identycznie, różnica jest jedynie w sposobie wyświetlania. Zachęcam do zapoznania się z kilkoma narzedziami w sekcji ‘linki’ i samodzielnemu rozwijaniu swojej aplikacji o ‘ładne’ kontrolki.
Konfiguracja Spring’a
Aby skonfigurować naszą aplikację tak, by umożliwiała przesyłanie plików, poza modyfikacją widoków potrzebujemy kilku prostych kroków:
Obsługa multipart/form-data (filtr + bean)
Pierwszy element, filtr, definiujemy w pliku web.xml. Teoretycznie od Javy 8 wszystkie serwery aplikacji powinny samodzielnie być w stanie obsługiwać takie zapytania i poniższa deklaracja nie jest potrzebna. W praktyce nie mamy pewności, co do oprogramowania, które będzie obsługiwało naszą aplikację, a dodanie tego filtru nie zaszkodzi w takim wypadku. Poniższy fragment dodajemy w pliku web.xml NAD filtr Spring Security.
<filter>
<display-name>springMultipartFilter</display-name>
<filter-name>springMultipartFilter</filter-name>
<filter-class>org.springframework.web.multipart.support.MultipartFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>springMultipartFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
Dzięki temu nasza aplikacja powinna radzić sobie z plikami multipart.
Druga część konfiguracji dotyczy samego Spring’a, w którym to także definiujemy beana do obsługi formularzy multipart. Uwaga, ta deklaracja musi się znaleźć w kontekście głównym (w naszym przypadku jest to applicationContext.xml).
<bean id="filterMultipartResolver"
class="org.springframework.web.multipart.commons.CommonsMultipartResolver">
<beans:property name="maxUploadSize" value="10000000" />
</bean>
Parametr maxUploadSize to maksymalny rozmiar pliku, jaki chcemy obsługiwać — w tym wypadku jest to 10MB, co powinno w zupełności wystarczyć.
Metoda kontrolera
Drugim niezbędnym elementem jest metoda kontrolera — może ona wyglądać tak, jak na poniższym przykładzie:
@RequestMapping(value="/upload", method=RequestMethod.POST)
public String handleFileUpload(@RequestParam("plik") MultipartFile file){
if (!file.isEmpty()) {
try {
UUID uuid = UUID.randomUUID();
String filename = "/uploads/upload_"+uuid.toString();
byte[] bytes = file.getBytes();
File fsFile = new File(filename);
fsFile.createNewFile();
BufferedOutputStream stream =
new BufferedOutputStream(new FileOutputStream(fsFile));
stream.write(bytes);
stream.close();
logger.info("File {} has been successfully uploaded as {}", new Object[] {file.getOriginalFilename(), filename});
} catch (Exception e) {
logger.error("File has not been uploaded", e);
}
} else {
logger.error("Uploaded file is empty");
}
return "redirect:/";
}
W powyższym kodzie dzieje się kilka rzeczy. Po pierwsze, generujemy unikalną nazwę dla pliku, który zapisujemy lokalnie, za pomocą klasy UUID. Klasa ta pozwala wygenerować losowy i globalnie unikalny identyfikator w postaci ciągu znaków. To jest ważne niezależnie gdzie zapisujemy plik — ostatnią rzeczą, którą byśmy chcieli to nadpisanie innego pliku nowym tylko dlatego, że mają takie same nazwy (no dobra, ostatnią rzeczą, którą byście chcieli jest tłumaczenie szefowi/klientowi dlaczego tak się stało, ale sam fakt ma solidne drugie miejsce!). Dalej przepisujemy otrzymane dane do pliku — pracujemy tutaj na strumieniach, tzn. nie przetwarzamy ‘całego’ pliku na raz wczytując go najpierw do pamięci, ale pracujemy na pojedynczych ‘fragmentach’ pliku przepisując je po kolei. Pod koniec przykład wykorzystania metadanych — w tym wypadku oryginalnej nazwy pliku (wysyłamy ją do logów).
Co z oryginalną nazwą pliku?
To bardzo dobre pytanie, wiemy już dlaczego jej nie wykorzystujemy ale pytanie co się z nią dzieje, i jak odnosimy się do tego pliku w przyszłości.
W tym miejscu wprowadźmy pojęcie metadanych — w skrócie metadane to dane o danych, czyli takie dane, które nie niosą właściwej informacji (np. samej zawartości pliku), ale niosą informacje o innych danych (np. ich typie). W przypadku plików przesyłanych do aplikacji webowej najciekawsze są dla nas nastepujące informacje:
- oryginalna nazwa pliku — najprawdopodobniej chcemy pokazać ją użytkownikowi (wyjątkiem są najczęściej zdjęcia)
- rozmiar pliku — chcąc go wyświetlić (np. na liście plików do pobrania) nie chcemy odczytywać całego pliku tylko po to, żeby podac jego rozmiar
- typ pliku — tzw. mime type, mówi o tym, co zawiera plik; może to być np. plik tekstowy (text/plain), plik html (text/html), grafika (image/…) . W systemach Windows zwyczajowo typ pliku określa rozszerzenie, ale nie jest to uniwersalne we wszystkich systemach operacyjnych! Warto przechowywać tą informację np. aby wyświetlić ikonkę, odpowiedni podglad ale też aby zwrócić przy pobieraniu pliku odpowiedni typ przeglądarce użytkownika (dzięki temu dostanie on np. propozycję otwarcia pliku PDF lub wydruku obrazka po kliknięciu na link w przeglądarce)
Poza powyższymy warto zapisywać jeszcze dwie rzeczy:
- nazwę, pod jaką zapisaliśmy plik na dysku/w innym miejscu (ta wygenerowana z pomocą UUID)
- opis, dostarczony przez użytkownika który wgrywa plik (opcjonalnie, ale warto go przewidzieć wcześniej nawet jeśli początkowo nie planujemy go używać)
Pytanie tylko gdzie zapisujemy te informacje? Najlepszym miejscem jest baza danych. Tworzymy klasę o nazwie np. Attachment, która posiada wszystkie te pola i przesyłając plik (i zapisując go na dysku) tworzymy taki właśnie rekord równocześnie z samym plikiem.
Po co to wszystko?
W tej chwili możesz się zastanawiać po co tyle zachodu, nazwy plików ‘raczej’ się nigdy nie powtórzą, użytkownik nie musi wiedzieć co to jest, bo zawsze będzie to obrazek itp.
Odpowiedź jest prosta — dla bezpieczeństwa. Tworząc aplikacje webowe musisz założyć, że ktoś będzie próbował tak zmodyfikować wpisane dane, żeby spowodować problem w Twojej aplikacji i potencjalnie uzyskać dostęp do rzeczy, do których dostępu mieć nie powinien. To także zabezpieczenie przed przypadkowym usunięciem (nadpisaniem) danych (poprzez losową nazwę), ale też zabezpieczenie serwera przed przeciążeniem — operacje na dysku (odczyt/zapis plików) są najbardziej kosztowne z punktu widzenia serwera, i są blokujące — tzn. np. dwa procesy nie mogą jednoczesnie pracować na tych samych plikach (a więc np. 2 użytkowników chcących wyświetlić listę plików w jakimś katalogu na serwerze albo zawartość jakiegoś pliku będą obsłużeni ‘po kolei’, mimo że teoretycznie współczesne serwery pozwalają na ‘obsługę’ wielu takich użytkowników jednocześnie). Z tego powodu unikamy niepotrzebnych zapisów/odczytów, przechowując metadane (które potencjalnie będziemy odczytywali/zapisywali częściej, niż sam plik) w ‘tańszym’ (szybszym) miejscu — np. bazie danych.
Odpowiadając od razu na pytanie, które pewnie ciśnie Ci się na usta — tak, można zapisywac pliki (ich zawartość) do bazy danych. Nie jest to zbyt eleganckie rozwiązanie i niesie za sobą spory koszt (potencjalnie duże obciążenie połaczeń z bazą danych) ale czasami też jest stosowane :)
Dzięki naszym zabiegom, plik mamy przedstawiony jako zwykły obiekt, który pobieramy i zapisujemy do bazy danych. Samą zawartość pliku dotykamy dopiero, kiedy naprawdę tego potrzebujemy.
Pobieranie plików
Skoro wiemy już jak zapisywać plik, nauczmy się jak go odczytywać i zwracać do użytkownika.
Przede wszystkim — pliki obsługujemy po identyfikatorach! Nigdy po nazwach systemowych (ścieżkach): obsługa plików za pomocą nazw systemowych (ścieżki) niesie ryzyko tego, że ktoś może pobrać plik np. z danymi do bazy danych jeśli nie zabezpieczymy odpowiednio aplikacji (to oczywiście skrajny przypadek, który zakłada wiedzę atakującego o nazwach i lokalizacjach plików, ale nie możemy go wykluczyć).
Przykładowa metoda kontrolera wygląda następująco:
@RequestMapping(method = RequestMethod.GET)
public void pobierz(@PathVariable("attchamentId") Long attachmentId,
HttpServletResponse response) throws IOException {
Attachement attachement = ...; //pobierz na podstawie id
FileInputStream inputStream = new FileInputStream(attachment.getFileLocation());
response.setContentType(attachment.getMimeType());
response.setContentLength(attachment.getSize());
String headerValue = String.format("attachment; filename=\"%s\"",
attachment.getOriginalFilename());
response.setHeader("Content-Disposition", headerValue);
OutputStream outStream = response.getOutputStream();
byte[] buffer = new byte[BUFFER_SIZE];
int bytesRead = -1;
// czytamy w pętli po fragmencie, który następnie przepisujemy do strumienia wyjściowego
while ((bytesRead = inputStream.read(buffer)) != -1) {
outStream.write(buffer, 0, bytesRead);
}
inputStream.close();
outStream.close();
}
Jak widzimy wykorzystujemy tutaj metadane z bazy danych do ustawienia nagłówków (pozwala to na wyświetlenie odpowiedniej ikonki w przeglądarce użytkownika, poprawnej nazwy, rozmiaru itp), a dopiero później wysyłamy samą zawartość pliku (ponownie z użyciem strumieni, a nie wczytując cały plik do bazy danych).
Uwaga końcowa: w zależności od typu pliku (a bardziej zabezpieczeń) możemy wykorzystywać inne mechanizmy, które nie wymagają pisania metody kontrolera z naszej strony. Ma to jednak zastosowanie tylko w przypadku plików publicznych (np. zdjęć w serwisach społecznościowych itp) — jeśli dostęp do pliku musi być ograniczony, wtedy używamy tego rodzaju logiki (wraz z odpowiednimi zabezpieczeniami, czy to za pomocą adnotacji, czy też implementując zabezpieczenia w kodzie)
Potencjalne problemy z plikami trzymanymi na serwerze
Podsumowując (i rozwijając) problemy, jakie możemy napotkać zapisując pliki na lokalnym serwerze:
- potencjalne problemy z nazwami (znaki specjalne itp) — różne systemy operacyjne różnie traktują znaki specjalne w nazwach plików
- możliwy nieuprawniony dostęp do plików systemowych przez nieodpowiednie zabezpieczenie aplikacji
- ograniczone miejsce (nie da się w prosty sposób ‘powiększyć’ dysku w trakcie działania aplikacji)
- awaria komputera powoduje utratę danych
- spowolnienie pracy (przy wielu zapytaniach równocześnie, prędkość odczytu z dysku może być zauważalnie niższa — trzymając pliki w chmurze unikamy tego problemu / przerzucamy jego rozwiązanie na kogoś innego)
- wyższy koszt — licząc cenę prądu za działanie komputera przechowującego 250GB danych vs cena przechowywania w chmurze daje wymierną różnicę (przy małych ilościach danych jeszcze większą)
- skalowanie prawie niemożliwe — jeśli będziesz chciała dodać kolejny serwer, to rozwiązanie takie nie zadziała
Należy też wspomnieć, że być może w Twojej aplikacji z jakiegoś powodu jes to wskazane / akceptowalne — dopóki zdajesz sobie sprawę z ryzyk i zagrożeń i jest to świadoma decyzja, nie ma w tym nic złego.
Może Ci się wydawać, że przechowywanie w chmurze jest przesadą, że to niepotrzebne. Ale wydaje mi się, że nawet w małej aplikacji koszty są minimalne, korzyści (potencjalne) znaczące i przede wszystkim — warto wyrabiać sobie dobre praktyki.
Podsumowanie
Po dzisiejszej lekcji powinnaś zapamiętać, czym jest multipart oraz jakie elementy są potrzebne, żeby go obsługiwać. Warto mieć też w pamięci zagrożenia i problemy związane z obsługą plików na serwerze — zagadnienia te mogą Ci się przydać nie tylko przy obsłudze plików. No i ostatecznie — powinnaś pamiętać, że nie przechowujemy żadnych danych użytkownika (w tym też jego plików) lokalnie na serwerze ;) To prosty psosób na kłopoty w przyszłości (a jak temu zaradzić nauczymy się już w kolejnej lekcji).
Pamiętaj, że praca z plikami na dysku zawsze wiąże się ze zwiększonym ryzykiem! Upewnij się, że Twój kod jest poprawnie napisany i nie pozwala na żadne nadużycia.
Zadanie
Zmodyfikuj program, który już napisałeś tak, aby umożliwiał upload plików zapisując ich metadane do bazy danych. Dodaj możliwość wgrania zdjęcia kota.
Kroki do wykonania:
- Skonfiguruj aplikację aby było możliwe przesyłanie plików
- Dodaj odpowiednią encję wraz z mapowaniami JPA
- do klasy Kot dodaj pole tego typu nazywając je np. zdjecie
- Dodaj widok do przesyłania / zmiany zdjęcia oraz jego obsługę w kontrolerze
- Dodaj w kontrolerze obsługę wyświetlania plików
Jeśli uważasz powyższą lekcję za przydatną, mamy małą prośbę: polub nasz fanpage. Dzięki temu będziesz zawsze na bieżąco z nowymi treściami na blogu ( i oczywiście, z nowymi częściami kursu Javy). Dzięki!