#19 — upload i pobieranie plików

By 19 February 2015 February 27th, 2015 Kurs Javy

Ta lekc­ja poświę­cona jest oper­acjom na plikach — wgry­wa­niu, wyświ­et­la­niu (lub umożli­wie­niu pobiera­nia) a także pod­sta­wowym zasadom przy pra­cy z plikami.

Więk­szość aplikacji webowych pozwala na upload plików w ten czy inny sposób — najczęś­ciej jest to zdję­cie pro­filowe, załącznik do jakiegoś reko­r­du (np. skan fak­tu­ry w pro­gramie księ­gowym), czy po pros­tu doku­ment (w pro­gramie do zarządza­nia doku­men­ta­mi w fir­mie). Z tech­nicznego punk­tu widzenia jed­nak jest to ta sama czyn­ność — użytkown­ik wysyła nam plik, my go prze­chowu­je­my i na żądanie zwracamy (także z uży­ciem pro­tokołu HTTP). Dzisi­aj nauczymy się jak to robić, a także poz­namy kil­ka dobrych praktyk/sugestii związanych z oper­ac­ja­mi na plikacji.

Lekcja

UWAGA! W tej lekcji przykłady są opisane w opar­ciu o stan­dar­d­owe pli­ki trzy­mane na ser­w­erze. To z wielu powodów bard­zo złe wyjś­cie (w treś­ci więcej infor­ma­cji o tym, dlaczego), podob­nie jak umożli­wie­nie ich pobiera­nia za pomocą metody kon­trol­era. Ma to na celu jedynie uproszcze­nie tej lekcji, praw­idłowej obsłu­gi będziemy się uczyć w kole­jnej, poświę­conej tylko temu zagad­nie­niu. Dzisiejsza lekc­ja ma na celu przed­staw­ie­nie głównie mech­a­niz­mu obsłu­gi przesyła­nia plików w Springu.

Jak można przesłać pliki w aplikacjach webowych

Są dwie pod­sta­wowe metody przesyła­nia plików — POST oraz PUT (dokład­nie tak jak myślisz, mówimy o meto­dach pro­tokołu HTTP — jeśli jed­nak nie pamię­tasz co to, zajrzyj do lekcji #9). Różnią się one (z punk­tu widzenia użytecznoś­ci) tym, że uży­wa­jąc metody POST może­my przesłać w ramach for­mu­la­rz dodatkowe dane (np. opis, komen­tarz, infor­ma­cję itp). Z tego wzglę­du jest to najczęś­ciej uży­wana meto­da i z niej będziemy korzys­tać. Jej minusem jest trochę więk­sza kom­p­likac­ja samego mech­a­niz­mu — zami­ast po pros­tu przesłać zawartość pliku w treś­ci zapy­ta­nia HTTP, musimy rozróżnić dane for­mu­la­rza od danych pliku (plików). Jest to real­i­zowane za pomocą atry­bu­tu enc­type = mul­ti­part/­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 wystar­cza­ją­cy, żeby wysłać plik na serwer.

Ponieważ nie będziemy się zaj­mować bezpośred­nio kodowaniem/rozkodowaniem mul­ti­part, powiemy sobie tylko w skró­cie co to jest. Mul­ti­part to nic innego jak podzi­ał wiado­moś­ci do przesła­nia (w tym wypad­ku plik + dane for­mu­la­rza) na kil­ka częś­ci (ale nadal wysyłanych w jed­nym zapy­ta­niu HTML — po pros­tu każ­da część może być innego ‘typu’, czyli być albo plikiem albo dany­mi for­mu­la­rza). Dzię­ki temu ser­w­er może ‘odt­worzyć’ zarówno dane jak i przesłany plik.

Uwa­ga prak­ty­cz­na: ist­nieje wiele ‘ład­nych’ ele­men­tów do przesyła­nia plików bazu­ją­cych na HTML5 czy JS — powyższy przykład wyświ­etli jedynie ‘stan­dar­d­owe’ okienko. Z punk­tu widzenia ser­w­era, pli­ki są wysyłane iden­ty­cznie, różni­ca jest jedynie w sposo­bie wyświ­et­la­nia. Zachę­cam do zapoz­na­nia się z kilko­ma narzedzi­a­mi w sekcji ‘lin­ki’ i samodziel­ne­mu rozwi­ja­niu swo­jej aplikacji o ‘ładne’ kontrolki.

Konfiguracja Spring’a

Aby skon­fig­urować naszą aplikację tak, by umożli­wiała przesyłanie plików, poza mody­fikacją widoków potrze­bu­je­my kilku prostych kroków:

Obsługa multipart/form-data (filtr + bean)

Pier­wszy ele­ment, fil­tr, defini­u­je­my w pliku web.xml. Teo­re­ty­cznie od Javy 8 wszys­tkie ser­w­ery aplikacji powin­ny samodziel­nie być w stanie obsługi­wać takie zapy­ta­nia i poniższa deklarac­ja nie jest potrzeb­na. W prak­tyce nie mamy pewnoś­ci, co do opro­gramowa­nia, które będzie obsługi­wało naszą aplikację, a dodanie tego fil­tru nie zaszkodzi w takim wypad­ku. Poniższy frag­ment doda­je­my w pliku web.xml NAD fil­tr 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 aplikac­ja powin­na radz­ić sobie z plika­mi multipart.

Dru­ga część kon­fig­u­racji doty­czy samego Spring’a, w którym to także defini­u­je­my beana do obsłu­gi for­mu­la­rzy mul­ti­part. Uwa­ga, ta deklarac­ja musi się znaleźć w kon­tekś­cie głównym (w naszym przy­pad­ku jest to applicationContext.xml).

<bean id="filterMultipartResolver"
class="org.springframework.web.multipart.commons.CommonsMultipartResolver">
<beans:property name="maxUploadSize" value="10000000" />
</bean>

Para­metr max­U­pload­Size to maksy­mal­ny rozmi­ar pliku, jaki chce­my obsługi­wać — w tym wypad­ku jest to 10MB, co powin­no w zupełnoś­ci wystarczyć.

Metoda kontrolera

Drugim niezbęd­nym ele­mentem jest meto­da kon­trol­era — 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ę kil­ka rzeczy. Po pier­wsze, generu­je­my unikalną nazwę dla pliku, który zapisu­je­my lokalnie, za pomocą klasy UUID. Klasa ta pozwala wygen­erować losowy i glob­al­nie unikalny iden­ty­fika­tor w postaci ciągu znaków. To jest ważne nieza­leżnie gdzie zapisu­je­my plik — ostat­nią rzeczą, którą byśmy chcieli to nad­pisanie innego pliku nowym tylko dlat­ego, że mają takie same nazwy (no dobra, ostat­nią rzeczą, którą byś­cie chcieli jest tłu­macze­nie szefowi/klientowi dlaczego tak się stało, ale sam fakt ma solidne drugie miejsce!). Dalej przepisu­je­my otrzy­mane dane do pliku — pracu­je­my tutaj na stru­mieni­ach, tzn. nie przetwarza­my ‘całego’ pliku na raz wczy­tu­jąc go najpierw do pamię­ci, ale pracu­je­my na poje­dynczych ‘frag­men­tach’ pliku przepisu­jąc je po kolei. Pod koniec przykład wyko­rzys­ta­nia metadanych — w tym wypad­ku ory­gi­nal­nej nazwy pliku (wysyłamy ją do logów).

Co z oryginalną nazwą pliku?

To bard­zo dobre pytanie, wiemy już dlaczego jej nie wyko­rzys­tu­je­my ale pytanie co się z nią dzieje, i jak odnosimy się do tego pliku w przyszłości.

W tym miejs­cu wprowadźmy poję­cie metadanych — w skró­cie metadane to dane o danych, czyli takie dane, które nie niosą właś­ci­wej infor­ma­cji (np. samej zawartoś­ci pliku), ale niosą infor­ma­c­je o innych danych (np. ich typ­ie). W przy­pad­ku plików przesyłanych do aplikacji webowej naj­ciekawsze są dla nas nastepu­jące informacje:

  • ory­gi­nal­na nazwa pliku — najpraw­dopodob­niej chce­my pokazać ją użytkown­ikowi (wyjątkiem są najczęś­ciej zdjęcia)
  • rozmi­ar pliku — chcąc go wyświ­etlić (np. na liś­cie plików do pobra­nia) nie chce­my odczy­ty­wać całego pliku tylko po to, żeby podac jego rozmiar
  • typ pliku — tzw. mime type, mówi o tym, co zaw­iera plik; może to być np. plik tek­stowy (text/plain), plik html (text/html), grafi­ka (image/…) . W sys­temach Win­dows zwycza­jowo typ pliku określa rozsz­erze­nie, ale nie jest to uni­w­er­salne we wszys­t­kich sys­temach oper­a­cyjnych! Warto prze­chowywać tą infor­ma­cję np. aby wyświ­etlić ikonkę, odpowied­ni podglad ale też aby zwró­cić przy pobiera­niu pliku odpowied­ni typ przeglą­darce użytkown­i­ka (dzię­ki temu dostanie on np. propozy­cję otwar­cia pliku PDF lub wydruku obraz­ka po kliknię­ciu na link w przeglądarce)

Poza powyższymy warto zapisy­wać jeszcze dwie rzeczy:

  • nazwę, pod jaką zapisal­iśmy plik na dysku/w innym miejs­cu (ta wygen­erowana z pomocą UUID)
  • opis, dostar­c­zony przez użytkown­i­ka który wgry­wa plik (opcjon­al­nie, ale warto go przewidzieć wcześniej nawet jeśli początkowo nie planu­je­my go używać)

Pytanie tylko gdzie zapisu­je­my te infor­ma­c­je? Najlep­szym miejscem jest baza danych. Tworzymy klasę o nazwie np. Attach­ment, która posi­a­da wszys­tkie te pola i przesyła­jąc plik (i zapisu­jąc go na dysku) tworzymy taki właśnie reko­rd równocześnie z samym plikiem.

Po co to wszystko?

W tej chwili możesz się zas­tanaw­iać po co tyle zachodu, nazwy plików ‘raczej’ się nigdy nie powtórzą, użytkown­ik nie musi wiedzieć co to jest, bo zawsze będzie to obrazek itp.

Odpowiedź jest pros­ta — dla bez­pieczeńst­wa. Tworząc aplikac­je webowe musisz założyć, że ktoś będzie próbował tak zmody­fikować wpisane dane, żeby spowodować prob­lem w Two­jej aplikacji i potenc­jal­nie uzyskać dostęp do rzeczy, do których dostępu mieć nie powinien. To także zabez­piecze­nie przed przy­pad­kowym usunię­ciem (nad­pisaniem) danych (poprzez losową nazwę), ale też zabez­piecze­nie ser­w­era przed prze­ciąże­niem — oper­ac­je na dysku (odczyt/zapis plików) są najbardziej kosz­towne z punk­tu widzenia ser­w­era, i są bloku­jące — tzn. np. dwa pro­cesy nie mogą jed­noczes­nie pra­cow­ać na tych samych plikach (a więc np. 2 użytkown­ików chcą­cych wyświ­etlić listę plików w jakimś kat­a­logu na ser­w­erze albo zawartość jakiegoś pliku będą obsłużeni ‘po kolei’, mimo że teo­re­ty­cznie współczesne ser­w­ery pozwala­ją na ‘obsługę’ wielu takich użytkown­ików jed­nocześnie). Z tego powodu unikamy niepotrzeb­nych zapisów/odczytów, prze­chowu­jąc metadane (które potenc­jal­nie będziemy odczytywali/zapisywali częś­ciej, niż sam plik) w ‘tańszym’ (szyb­szym) miejs­cu — np. bazie danych.

Odpowiada­jąc od razu na pytanie, które pewnie ciśnie Ci się na usta — tak, moż­na zapisywac pli­ki (ich zawartość) do bazy danych. Nie jest to zbyt ele­ganck­ie rozwiązanie i niesie za sobą spory koszt (potenc­jal­nie duże obciąże­nie połaczeń z bazą danych) ale cza­sa­mi też jest stosowane :)

Dzię­ki naszym zabiegom, plik mamy przed­staw­iony jako zwykły obiekt, który pobier­amy i zapisu­je­my do bazy danych. Samą zawartość pliku dotykamy dopiero, kiedy naprawdę tego potrzebujemy.

Pobieranie plików

Sko­ro wiemy już jak zapisy­wać plik, nauczmy się jak go odczy­ty­wać i zwracać do użytkownika.

Przede wszys­tkim — pli­ki obsługu­je­my po iden­ty­fika­torach! Nigdy po nazwach sys­te­mowych (ścieżkach): obsłu­ga plików za pomocą nazw sys­te­mowych (ścież­ki) niesie ryzyko tego, że ktoś może pobrać plik np. z dany­mi do bazy danych jeśli nie zabez­pieczymy odpowied­nio aplikacji (to oczy­wiś­cie skra­jny przy­padek, który zakła­da wiedzę ataku­jącego o nazwach i lokaliza­c­jach plików, ale nie może­my go wykluczyć).

Przykład­owa meto­da kon­trol­era 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 widz­imy wyko­rzys­tu­je­my tutaj metadane z bazy danych do ustaw­ienia nagłówków (pozwala to na wyświ­etle­nie odpowied­niej ikon­ki w przeglą­darce użytkown­i­ka, poprawnej nazwy, rozmi­aru itp), a dopiero później wysyłamy samą zawartość pliku (ponown­ie z uży­ciem stru­mieni, a nie wczy­tu­jąc cały plik do bazy danych).

Uwa­ga koń­cowa: w zależnoś­ci od typu pliku (a bardziej zabez­pieczeń) może­my wyko­rzysty­wać inne mech­a­nizmy, które nie wyma­ga­ją pisa­nia metody kon­trol­era z naszej strony. Ma to jed­nak zas­tosowanie tylko w przy­pad­ku plików pub­licznych (np. zdjęć w ser­wisach społecznoś­ciowych itp) — jeśli dostęp do pliku musi być ogranic­zony, wtedy uży­wamy tego rodza­ju logi­ki (wraz z odpowied­ni­mi zabez­pieczeni­a­mi, czy to za pomocą adno­tacji, czy też imple­men­tu­jąc zabez­pieczenia w kodzie)

Potencjalne problemy z plikami trzymanymi na serwerze

Pod­sumowu­jąc (i rozwi­ja­jąc) prob­le­my, jakie może­my napotkać zapisu­jąc pli­ki na lokalnym serwerze:

  • potenc­jalne prob­le­my z nazwa­mi (zna­ki spec­jalne itp) — różne sys­te­my oper­a­cyjne różnie trak­tu­ją zna­ki spec­jalne w nazwach plików
  • możli­wy nieuprawniony dostęp do plików sys­te­mowych przez nieod­powied­nie zabez­piecze­nie aplikacji
  • ogranic­zone miejsce (nie da się w prosty sposób ‘pow­ięk­szyć’ dysku w trak­cie dzi­ała­nia aplikacji)
  • awaria kom­put­era powodu­je utratę danych
  • spowol­nie­nie pra­cy (przy wielu zapy­ta­ni­ach równocześnie, pręd­kość odczy­tu z dysku może być zauważal­nie niższa — trzy­ma­jąc pli­ki w chmurze unikamy tego prob­le­mu / prz­erzu­camy jego rozwiązanie na kogoś innego)
  • wyższy koszt — licząc cenę prą­du za dzi­ałanie kom­put­era prze­chowu­jącego 250GB danych vs cena prze­chowywa­nia w chmurze daje wymierną różnicę (przy małych iloś­ci­ach danych jeszcze większą)
  • skalowanie praw­ie niemożli­we — jeśli będziesz chci­ała dodać kole­jny ser­w­er, to rozwiązanie takie nie zadziała

Należy też wspom­nieć, że być może w Two­jej aplikacji z jakiegoś powodu jes to wskazane / akcep­towalne — dopó­ki zda­jesz sobie sprawę z ryzyk i zagrożeń i jest to świado­ma decyz­ja, nie ma w tym nic złego.

Może Ci się wydawać, że prze­chowywanie w chmurze jest prze­sadą, że to niepotrzeb­ne. Ale wyda­je mi się, że nawet w małej aplikacji kosz­ty są min­i­malne, korzyś­ci (potenc­jalne) znaczące i przede wszys­tkim — warto wyra­bi­ać sobie dobre praktyki.

Podsumowanie

Po dzisiejszej lekcji powin­naś zapamię­tać, czym jest mul­ti­part oraz jakie ele­men­ty są potrzeb­ne, żeby go obsługi­wać. Warto mieć też w pamię­ci zagroże­nia i prob­le­my związane z obsługą plików na ser­w­erze — zagad­nienia te mogą Ci się przy­dać nie tylko przy obsłudze plików. No i ostate­cznie — powin­naś pamię­tać, że nie prze­chowu­je­my żad­nych danych użytkown­i­ka (w tym też jego plików) lokalnie na ser­w­erze ;) To prosty psosób na kłopo­ty w przyszłoś­ci (a jak temu zaradz­ić nauczymy się już w kole­jnej lekcji).

Pamię­taj, że pra­ca z plika­mi na dysku zawsze wiąże się ze zwięk­szonym ryzykiem! Upewnij się, że Twój kod jest poprawnie napisany i nie pozwala na żadne nadużycia.

Zadanie

Zmody­fikuj pro­gram, który już napisałeś tak, aby umożli­wiał upload plików zapisu­jąc ich metadane do bazy danych. Dodaj możli­wość wgra­nia zdję­cia kota.

Kro­ki do wykonania:

  1. Skon­fig­u­ruj aplikację aby było możli­we przesyłanie plików
  2. Dodaj odpowied­nią encję wraz z mapowa­ni­a­mi JPA 
    1. do klasy Kot dodaj pole tego typu nazy­wa­jąc je np. zdjecie
  3. Dodaj widok do przesyła­nia / zmi­any zdję­cia oraz jego obsługę w kontrolerze
  4. Dodaj w kon­trol­erze obsługę wyświ­et­la­nia plików

Licencja Creative Commons

Jeśli uważasz powyższą lekcję za przy­dat­ną, mamy małą prośbę: pol­ub nasz fan­page. Dzię­ki temu będziesz zawsze na bieżą­co z nowy­mi treś­ci­a­mi na blogu ( i oczy­wiś­cie, z nowy­mi częś­ci­a­mi kur­su Javy). Dzięki!