Prawie każda aplikacja serwerowa prędzej czy później wymaga jakiejś formy przetwarzania plików wgrywanych przez użytkownika. O ile pliki są małe i w niewielkich ilościach, można uciec od zapisywania ich najpierw w pamięci RAM serwera. Problem zaczyna się, gdy stają się one większe niż rozmiar pamięci RAM. Może to spowodować awarie i przerwy w działaniu. Rozwiązaniem tego problemu jest zapisywanie plików bezpośrednio do pamięci długoterminowej.

Na potrzeby tego artykułu wystarczy zapisywanie plików na lokalnym magazynie serwera hostującego maszyny, ale przedstawione rozwiązanie można łatwo zmodyfikować do zapisywania plików w chmurze.

PlayFramework jest dostarczany ze świetną biblioteką o nazwie Akka. Akka Streams jest podmodułem Akki, który pomaga w przetwarzaniu strumieni danych – takich jak przesłany plik. Przedstawiony poniżej kod zapisze strumień bajtów – czyli uploadowany plik – bezpośrednio na dysku, bez zapisywania go najpierw jako całego pliku w pamięci RAM.

Sink

Aby zapisać pliki bezpośrednio na dysku wykorzystamy Akka Streams. Pierwszą rzeczą, której będziemy potrzebować jest Sink.

val basePath = "/path/to/files/dir"
def localFileSink(filename: String): Sink[ByteString, Future[java.io.File]] = {
    val timestamp: Long = System.currentTimeMillis
    new java.io.File(s"$basePath/$timestamp/").mkdirs() // Creates directory with timestamp as name
    val path = s"$basePath/$timestamp/$filename" // Path string to file
    val file = new java.io.File(path) // Creates new file

    FileIO.toPath(file.toPath).mapMaterializedValue { t => // Creates Sink from file
        Future.successful(file)
    }
}

Przedstawiona powyżej metoda utworzy i zwróci obiekt Sink. Sink zapisze wszystkie bajty do pliku o podanej nazwie. Pozwoli nam to na zachowanie oryginalnej nazwy pliku. Aby zapobiec kolizji nazw z innymi wgrywanymi plikami, umieszcza wgrywany plik w katalogu, którego nazwą jest timestamp.

Accumulator

Kolejnym krokiem jest stworzenie BodyParser’a, który będzie gromadził wszystkie bajty plików wysyłanych w żądaniu do wcześniej utworzonego Sink’a.

 def multipartFormDataToLocalFile: BodyParser[MultipartFormData[java.io.File]] = parse.using { request =>
    controllerComponents.parsers.multipartFormData { // Retrieves file data from request
        case FileInfo(partName, filename, contentType, dispositionType) =>
        val sink = fileService.localFileSink(filename).mapMaterializedValue(_.map { result => // Creates file sink for uploaded file
            FilePart(partName, filename, contentType, result)
        })

        Accumulator(sink) // Creates Accumulator which will transform Sing into Future object.
    }
}

 

Body parser

Następnym krokiem jest stworzenie punktu końcowego, który będzie obsługiwał wszystkie pliki przesyłane do serwera. Aby to osiągnąć stworzymy Action, który wykorzysta wcześniej stworzony BodyParser.

 def upload = Action(multipartFormDataToLocalFile).async { implicit request =>
    request.body.file("file") match { // Check body parser returned any file
        case Some(filePart) => // User uploaded some file
        fileService.doStuff(filePart).map { result =>
            Ok(Json.toJson(result))
        }
        case None =>
        Future.successful(NotFound) // No file was found in request.
    }
}

Aby uzyskać dostęp do tego endpointu musimy dodać go do pliku routes z metodą POST.

 POST /file/upload controllers.FileController.upload 

 

Form

Ostatnim krokiem jest stworzenie wieloczęściowego formularza HTML.

 @helper.form(action = routes.FileController.upload, Symbol("enctype") -> "multipart/form-data") {
    <input type="file" name="file">
    <p>
        <input type="submit">
    </p>
}

 

Aby uzyskać więcej informacji na temat Akka Streams sprawdź Akka Documentation