Zarządzanie plikami
Prawie wszystkie aplikacje internetowe korzystają z plików wgrywanych przez użytkownika. Od dokumentów, które muszą być połączone z jakimiś modelami w bazie danych, po obrazy jako awatary użytkowników. Im więcej użytkowników ma Twój serwis, tym więcej plików zgromadzi Twój serwer. Jeśli zaniedbasz zarządzanie plikami, wkrótce będziesz miał poważny problem z miejscem.
Gdzie przechowywać pliki użytkowników?
Istnieje kilka głównych sposobów przechowywania plików wgrywanych przez użytkownika: w systemie plików lub w bazie danych. Jak wszystko, oba te rozwiązania mają swoje wady i zalety.
Jeśli Twoja aplikacja posiada niewielką ilość raczej małych plików, które muszą być ściśle sprzężone z obiektami przechowywanymi w bazie danych, lub potrzebujesz bardziej bezpiecznego sposobu przechowywania wrażliwych plików, to powinieneś użyć do tego swojej bazy danych. Jednak to rozwiązanie znacznie zwiększy rozmiar kopii zapasowych bazy danych i może wymagać konwersji niektórych plików na bloby.
Przechowywanie plików w systemie plików ma więcej sensu, gdy masz do czynienia z dużymi plikami lub dużą liczbą użytkowników. Rozszerzenie rozmiaru systemu plików jest znacznie tańsze niż zakup większej ilości miejsca w bazie danych. Kolejną zaletą tego podejścia jest łatwość migracji. Jeśli w którymś momencie będziesz potrzebował zmigrować pliki wgrane przez użytkowników na inny serwer lub S3, o wiele łatwiej jest to zrobić, niż wyodrębnić je z bazy danych i następnie zmigrować.
Jak zarządzać plikami w systemie plików?
Wdrażając do aplikacji serwerowej funkcjonalność zarządzania plikami, należy pamiętać o kilku rzeczach:
- wszystkie zapisane pliki muszą być łatwe do zidentyfikowania
- należy usuwać nieużywane pliki
- należy ograniczyć dostęp do innych plików
Identyfikacja plików
Ponieważ prawdopodobnie będziesz musiał połączyć dane z bazy danych z określonymi plikami w systemie plików (na przykład łącząc użytkownika z jego awatarem) i będziesz musiał przechowywać pewne dane o pliku dla ułatwienia, częstą praktyką jest tworzenie modelu File i przechowywanie go w swojej bazie danych. Może to wyglądać tak:
case class File( id: UUID, contentType: String, path: String, filename: String)
Będziemy mogli połączyć użytkownika z jego awatarem, zapisując ID plików jako klucz obcy. W zmiennej path
path będziemy przechowywać względną ścieżkę do obrazu, contentType
będzie reprezentować typ MIMO pliku, a filename
będzie przechowywać oryginalną nazwę pliku.
Pomyślmy o scenariuszu, w którym dwóch użytkowników próbuje przesłać swój obraz awatara „image.jpg”. Co by się stało, gdybyśmy po prostu zapisali oba te pliki w tym samym katalogu? Jeden będzie nadpisywał drugi. Aby temu zapobiec, stworzymy osobny katalog dla każdego przesłanego pliku. Aby upewnić się, że jest on unikalny, użyjemy bieżącego znacznika czasu jako jego nazwy.
Wróćmy do modelu File
. Nasza aplikacja będzie miała wyznaczony katalog do zapisywania wszystkich plików (base_file_dir). Ponieważ wiemy, że każdy załadowany plik będzie przechowywany w osobnym podkatalogu, możemy ustawić path
na „timestamp/file_name”. Teraz, gdy chcemy uzyskać dostęp do tego pliku, możemy pobrać jego częściową lokalizację z naszej bazy danych i połączyć ją z katalogiem bazowym, aby uzyskać bezwzględną ścieżkę.
Ważne!
Podczas pobierania danych formularza nazwy pliku wysłanego przez użytkownika, musisz być ostrożny, aby go wyczyścić. Powinieneś wziąć tylko rozszerzenie i ostatnią część ścieżki i wyczyścić ją z „…”. Zapobiegnie to zapisywaniu przez użytkowników plików poza określonym katalogiem.
Czyszczenie plików
Powiedzmy, że nasz użytkownik wysłał formularz z załączonym do niego plikiem, ale formularz nie jest poprawny i nasz serwer zwraca błąd. Co powinno się stać z tym plikiem? Możesz zapisać go przed lub po walidacji formularza. Jeśli zapiszesz go po, użytkownik będzie musiał wysłać go ponownie po poprawieniu formularza. Jeśli zapiszesz go przed walidacją, użytkownik nie będzie musiał wysyłać go ponownie, ale jeśli nie wyśle poprawionego formularza, utkniesz z nieużywanym plikiem. Aby temu zapobiec powinniśmy zaimplementować jakąś formę tymczasowego przechowywania plików.
Plik tymczasowy będzie osobnym katalogiem w ramach base_file_dir
, nazwijmy go tmp
. Wszystkie pliki wysyłane na serwer będą początkowo zapisywane w tmp
(w osobnym katalogu ze znacznikiem czasu jako jego nazwą) i nie będzie jeszcze tworzony obiekt File
Gdy serwer stwierdzi, że nasz formularz jest poprawny, wówczas zapisany plik zostanie przeniesiony z tmp
do base_file_dir
, a obiekt File
zostanie utworzony ze ścieżką ustawioną na jego nową lokalizację.
Na razie nie ma różnicy między tymczasowym magazynem a bazowym. Aby stworzyć tmp
tymczasowym, musimy stworzyć metodę, która będzie uruchamiana okresowo, która usunie niektóre pliki z tmp
. Nie możemy usunąć wszystkich plików. Niektóre z nich mogą być związane z ważnymi formularzami, które są obecnie zatwierdzane lub z formularzami, które użytkownik właśnie poprawia. Aby temu zapobiec możemy usunąć pliki, które są starsze niż jakiś czas – powiedzmy 30m. Aby to zrobić stworzymy metodę taką jak ta poniżej i uruchomimy ją za pomocą Play ScheduleModule
.
def clearTempFiles(): Unit = { val currentTimestamp = LocalDateTime.now().minusSeconds(1800).atZone(ZoneId.systemDefault()).toInstant.toEpochMilli val tmpDirectory = new java.io.File(s"${base_file_dir}/tmp") // Base tmp file directory if (tmpDirectory.exists()) { // if tmp directory exists tmpDirectory.list().filter(_.toLong < currentTimestamp).map { subDirectory => // filter all files in tmp dir, that have timestamp lower then current time - 30min val fullDirPath = s"${tmpDirectory.toPath}/${subDirectory}" try { val dir = new Directory(new io.File(fullDirPath)) if (!dir.deleteRecursively()) { logger.error(s"Unable to delete directory: ${fullDirPath}") } } catch { case e: Throwable => logger.error("Delete file error!") } } }}
Serwowanie plików
Po zapisaniu pliku przychodzi czas na udostępnienie go użytkownikom. Jednym z najbezpieczniejszych sposobów dostępu do niego jest jego ID. Serwer szukałby w bazie danych obiektu File
o podanym ID, a następnie pobierałby plik z base_file_dir na podstawie względnej ścieżki zapisanej w bazie danych. Ponieważ użytkownicy nie mają dostępu do żadnej formy ścieżki do pliku, nie jest możliwe, aby uzyskać dostęp do jakiegokolwiek innego pliku.