Transfer plików pomiędzy serwerem a klientem z wykorzystaniem Spring boot REST

0

Cześć, potrzebuję napisać usługę API REST na serwerze, która będzie wywoływana przez aplikacje kliencką w celu pobrania pliku. Pliki jakie będą przesyłane to ok 1-5GB. Dodatkowo usługa powinna być odporna na słabe łącze i zerwanie połączenia(powinno wznawiać przesyłanie) . Napisałem taki serwis, który "bierze" plik i przesyła go dalej do klienta i w przypadku małych plików nie ma żadnego problemu, został przesłany. Problem pojawił się już przy pliku ok 100mb, bo nie dostałem odpowiedzi, a żądanie mieliło i mieliło...

Poniżej kod wysyłania pliku:

FileInputStream inputStream = new FileInputStream(file2download);
        response.setContentType(URLConnection.guessContentTypeFromName(file2download.getName()));
        response.setContentLength((int) file2download.length());
        String headerValue = String.format("attachment; filename=\"%s\"", file2download.getName());
        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();

Nie udało mi się znaleźć żadnych ciekawych bibliotek, które pomogły by mi rozwiązać mój problem, czy koś ma jakiś pomysł i może mi pomóc? :)

0

Może aplikacja kliencka wywala się, bo widzi Content-Length o wartości <dużo> i naiwnie próbuje zaalokować sobie tablicę bajtów o takim rozmiarze, co raczej nie może się dobrze skończyć. Na pewno po stronie klienta nie leci żadne exception? Spróbuj wysyłać odopowiedź HTTP chunked. Jakieś asynchroniczne IO byłoby też wskazane, może być nawet WriteListener z Servlet 3.1. Polecam też zero-copy.

0

Potrzebuję to zrobić po http, więc sockety itp odpadają.
Próbowałem odczytywać plik fragmentami i tylko odczytany fragment przesyłać, tak żeby w aplikacji klienckiej później posklejać w całość, ale pojawia się problem nadmiernego zużycia pamięci. Używając nio i FIleChannel można z jednego kanału do drugiego "przekopiować" plik bez ładowania do pamięci. Nie znalazłem, jednak takiego rozwiązania, żeby taki plik(ewentualnie część) zwracać w responsie http bez ładowania do pamięci aplikacji. Ktoś ma pomysł jak rozwiązać mój problem, lub inne pomocne rozwiązanie? :)

0

Ktoś ma pomysł jak rozwiązać mój problem, lub inne pomocne rozwiązanie? :)

Czemu tego pliku nie streamujesz tylko ładujesz cały do pamięci albo sklejasz kawałki po stronie klienta? Użytkownik może sobie spokojnie pobierać kawałki pliku ze streama i przeglądarka ogarnie mu poskładanie go do kupy.

0

W tym problem, że po żadnej ze stron nie ma przeglądarki. Jest aplikacja serwerowa i kliencka. Aplikacja kliencka jak się dowie, że w serwerowej został dodany nowy plik, to ma sobie sama go ściągnąć do siebie bez ingerencji użytkownika. Aplikacji klienckich będzie n :)

0

Więc nadal możesz streamować, tylko zapisem pliku ze streama musi zająć się aplikacja kliencka.
Co do zerwanych połączeń to nie mam pojęcia. Prawdopodobnie klient musiałby poprosić o ponowne przesłanie pliku wskazując miejsce, na którym skończył pobieranie (tu gdybam bo w takie coś się nie bawiłem)

0

A czy za pomocą streamowania mogę pobrać od miejsca x?
Mógłbym prosić o jakiś przykład takiego streamowania?

0

Zrobiłem prosty przykład serwera używając Netty w Kotlinie (wspiera zero-copy przez wrapowanie pliku w typ FileRegion) - w requeście podajesz nazwę pliku i offset od którego ma być wysyłany, np.: ?file=test.txt&from=10
Obsługę większości błędów i bezpieczeństwa ominąłem, być może trzeba też manualnie zwalniać jakieś resource. Na podstawie materiałów w sieci można sobie dopisać klienta do tego, który w razie potrzeby robi retry od pewnego offsetu:

package server

import io.netty.bootstrap.ServerBootstrap
import io.netty.buffer.Unpooled
import io.netty.channel.ChannelHandlerContext
import io.netty.channel.ChannelInboundHandlerAdapter
import io.netty.channel.ChannelInitializer
import io.netty.channel.DefaultFileRegion
import io.netty.channel.nio.NioEventLoopGroup
import io.netty.channel.socket.SocketChannel
import io.netty.channel.socket.nio.NioServerSocketChannel
import io.netty.handler.codec.http.*
import java.io.File
import java.io.PrintWriter
import java.io.StringWriter
import java.nio.file.Files

fun main() {
    ServerBootstrap()
        .group(NioEventLoopGroup(1), NioEventLoopGroup())
        .channel(NioServerSocketChannel::class.java)
        .childHandler(object : ChannelInitializer<SocketChannel>() {
            override fun initChannel(connection: SocketChannel) {
                connection.pipeline()
                    .addLast(HttpServerCodec())
                    .addLast(HttpObjectAggregator(Short.MAX_VALUE.toInt()))
                    .addLast(
                        object : ChannelInboundHandlerAdapter() {
                            override fun channelRead(context: ChannelHandlerContext, request: Any) {
                                request as FullHttpRequest
                                try {
                                    handleFileRequest(request, context)
                                } catch (ex: Exception) {
                                    handleError(request, ex, context)
                                }
                            }
                        }
                    )
            }
        })
        .bind(8080).sync().channel().closeFuture().sync()
}

fun handleFileRequest(request: HttpRequest, context: ChannelHandlerContext) {
    val params = QueryStringDecoder(request.uri()).parameters()
    val fileName = params["file"]?.firstOrNull() ?: error("File name not provided")
    val fromOffset = params["from"]?.run { firstOrNull()?.toLong() } ?: 0L
    val file = File(fileName)
    if (file.exists().not()) error("File does not exist: $file")
    val fileSize = file.length()
    val fileRegion = DefaultFileRegion(file, fromOffset, fileSize - fromOffset)
    val headers = DefaultHttpHeaders()
        .add(HttpHeaderNames.CONTENT_DISPOSITION, "attachment; filename=\"${file.name}\"")
        .add(HttpHeaderNames.CONTENT_TYPE, Files.probeContentType(file.toPath()))
        .add(HttpHeaderNames.TRANSFER_ENCODING, HttpHeaderValues.CHUNKED)
    val response = DefaultHttpResponse(
        request.protocolVersion(),
        HttpResponseStatus.OK,
        headers
    )
    with(context) {
        writeAndFlush(response)
        writeAndFlush(fileRegion)
        writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT)
    }
    println(fileRegion)
}

fun handleError(request: HttpRequest, error: Exception, context: ChannelHandlerContext) {
    val body =
        Unpooled.wrappedBuffer(
            StringWriter()
                .also { error.printStackTrace(PrintWriter(it)) }
                .toString()
                .toByteArray()
        )
    val headers = DefaultHttpHeaders()
        .add(HttpHeaderNames.CONTENT_TYPE, HttpHeaderValues.TEXT_PLAIN)
        .add(HttpHeaderNames.CONTENT_LENGTH, body.capacity())
    val response = DefaultFullHttpResponse(
        request.protocolVersion(),
        HttpResponseStatus.BAD_REQUEST,
        body,
        headers,
        EmptyHttpHeaders.INSTANCE
    )
    context.channel()
        .writeAndFlush(response)
}
0

SpringBoot/MVC domylsnie wspiera content-range header, więc można ściągać "od danego miejsca" ale to musisz klientem sobie ogarnąć.

0
Shalom napisał(a):

SpringBoot/MVC domylsnie wspiera content-range header, więc można ściągać "od danego miejsca" ale to musisz klientem sobie ogarnąć.

Ale w tedy aplikacja serwerowa musi obsługiwać odczytywanie danego zakresu i ładuje mi wszystko do pamięci, a przy założeniu, że pliki będą duże i wiele klientów, to prowadzi do wysypania się aplikacji serwerowej i OutofMemory.

0

ładuje mi wszystko do pamięci

Dlatego plik streamujesz z dysku (czy gdziekolwiek to trzymasz) i nie pakujesz wszystkiego do pamięci

a przy założeniu, że pliki będą duże i wiele klientów, to prowadzi do wysypania się aplikacji serwerowej i OutofMemory.

Wtedy skalujesz aplikację i nie ma OOM (zakładając, że nie wyrabia ci nawet przy streamowaniu)

0

Poszedłem teraz w kierunku streamowania za Twoją radą (używam spring boota):

FileInputStream inputStream = new FileInputStream(file21download);
        StreamingResponseBody stream = outputStream -> {
            int numberOfBytesToWrite;
            byte[] data = new byte[1024];
            while ((numberOfBytesToWrite = inputStream.read(data, 0, data.length)) != -1) {
                System.out.println("Writing some bytes..");
                outputStream.write(data, 0, numberOfBytesToWrite);
                outputStream.flush();
            }
        };

        return ResponseEntity.ok()
                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + file21download.getName())                
                .contentType(MediaType.parseMediaType("application/octet-stream"))
                .body(stream);

Testując, wywołuje api w przeglądarce pobierając plik ok 400mb, ale po przestremowaniu ok 60-100mb wysypuje mi się serwer:

2019-07-09 11:36:37.705  INFO 14476 --- [io-11015-exec-2] o.a.catalina.connector.CoyoteAdapter     : Encountered a non-recycled response and recycled it forcedly.

org.apache.catalina.connector.CoyoteAdapter$RecycleRequiredException: null
	at org.apache.catalina.connector.CoyoteAdapter.checkRecycled(CoyoteAdapter.java:501)
	at org.apache.coyote.http11.Http11Processor.recycle(Http11Processor.java:1708)
	at org.apache.coyote.AbstractProtocol$ConnectionHandler.release(AbstractProtocol.java:988)
	at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:877)
	at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1498)
	at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:49)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
	at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)
	at java.lang.Thread.run(Thread.java:748)

Próbowałem też użyć:

 FileInputStream inputStream = new FileInputStream(file21download);
        StreamingResponseBody stream = outputStream -> {
            Files.copy(file21download.toPath(), outputStream);           
        };

Ale efekt ten sam.
O to chodziło Ci ze streamowaniem? Dlaczego dostaje taki błąd?
Mam ustawiony parametr w application.properties spring.mvc.async.request-timeout = 3600000

0

Czemu nie zrobisz po prostu:

ResponseEntity.ok().body(new InputStreamResource(new FileInputStream("someFile")))
0

Robiłem prawie tak, z tymże jako osobny obiekt

InputStreamResource inputStreamResource = new InputStreamResource(new FileInputStream(file21download));
return ResponseEntity.ok()
                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + file21download.getName())               
                .contentLength(file21download.length())
                .contentType(MediaType.parseMediaType("application/octet-stream"))
                .body(stream);

I o ile pliki były kilka mb, to w odpowiedzi je dostawałem, jak próbowałem przesłać plik już powyżej 100mb, to nie dostawałem odpowiedzi żadnej. A stosując powyższy kod, nie potrafię zażądać w requeście tylko fragmentu pliku, tak żeby wywoływać usługę tyle razy, aż pobiorę bezpiecznie cały plik..

0

A stosując powyższy kod, nie potrafię zażądać w requeście tylko fragmentu pliku, tak żeby wywoływać usługę tyle razy, aż pobiorę bezpiecznie cały plik..

Tzn? Jak wysyłasz content range header to nie działa? Jesteś pewien?

edit: dobra sprawdziłem i to generalnie działa ale dla resource które są nawigowalne, więc jak dasz FileSystemResource to pyknie.

1 użytkowników online, w tym zalogowanych: 0, gości: 1