5

Calling all Spring Framework experts,

Scenario

User A performs GET request to Spring Controller which makes another GET request to remote host to get a file which content is streamed (buffer byte copy) to initial user A as a response.

PS: Spring Controller acts like a proxy

Using

  • Spring WebMVC 4.3.3.RELEASE
  • Apache Tomcat 8.5.5

Issue

Seems like there is no Servlet request thread available or something else is blocked...

First user is able to initiate downloading of a file in all cases listed below. Unfortunately, Spring stops to invoke controller download method until first download has been finished (but, sometimes, it invokes it after XX seconds of user's wait time). Tried approaches with

  • @Async on a Service which contains RestTemplate approach - throws NullPointerException during byte copy operation as response output buffer is null (at org.apache.coyote.http11.Http11OutputBuffer$SocketOutputBuffer.doWrite(Http11OutputBuffer.java:561)). Service - download method.
  • StreamingResponseBody also doesn't resolve concurrent download issue even when wrapped in @Async on service level with AsyncResult<StreamingResponseBody> return. Service - downloadAsync method.

Maybe someone knows a better way of doing this in Spring?

Approaches

  • RestTemplate which performs FileCopyUtils.copy(downloadResponse.getBody(), userResponse.getOutputStream()); within ResponseExtractor.
  • HttpsURLConnection which performs FileCopyUtils.copy(downloadResponse.getBody(), userResponse.getOutputStream()); within controller's return StreamingResponseBody

All of the listed approaches do work, they transmit the file content from one server response's InputStream to the user's response OutputStream. However, there is an issue with concurrent downloads (Spring stops invoking controller's download method).

Sources

Private info (urls, authentication and etc...) was replaced, code is written strictly for question demonstration purpose

Dispatcher

Constructed by extending AbstractAnnotationConfigDispatcherServletInitializer.

Controller

Contains 3 endpoints for different approaches.

    @RequestMapping(value = "/download/way1", method = RequestMethod.GET)
    public void requestDownloadPage(final HttpServletResponse downloadResponse, final HttpServletRequest downloadRequest) throws ExecutionException, InterruptedException, URISyntaxException {
         dwn.download(downloadResponse);
    }

    @RequestMapping(value = "/download/way2", method = RequestMethod.GET)
    public StreamingResponseBody requestDownloadPage2(final HttpServletResponse response) throws ExecutionException, InterruptedException, URISyntaxException {
       return dwn.downloadAsync(response).get();
    }

    @RequestMapping(value = "/download/way3", method = RequestMethod.GET)
    public StreamingResponseBody requestDownloadPage3(final HttpServletResponse response) throws ExecutionException, InterruptedException, URISyntaxException, IOException {
        return outputStream -> {
            URL url = new URL("https://some/url/path/veryBig.zip");
            HttpsURLConnection connection = (HttpsURLConnection) url.openConnection();
            connection.setDoOutput(false);
            connection.setDoInput(true);
            connection.setUseCaches(false);
            connection.setRequestProperty ("Authorization", "Basic " + Base64.getEncoder().encodeToString("username:password".getBytes(StandardCharsets.UTF_8)));
            connection.setRequestMethod("GET");

            response.addHeader(HttpHeaders.CONTENT_TYPE, connection.getHeaderField(HttpHeaders.CONTENT_TYPE));
            response.addHeader(HttpHeaders.CONTENT_LENGTH, connection.getHeaderField(HttpHeaders.CONTENT_LENGTH));
            String disposition = connection.getHeaderField(HttpHeaders.CONTENT_DISPOSITION);
            if (disposition != null && !disposition.isEmpty()) {
                response.addHeader(HttpHeaders.CONTENT_DISPOSITION, disposition);
            }

            try (InputStream inputStream = connection.getInputStream();) {
                FileCopyUtils.copy(inputStream, outputStream);

            } catch (Throwable any) {
                // failed
            }
        };
    }

Service

public void download(HttpServletResponse downloadResponse) {
    final RestTemplate restTemplate = new RestTemplate();
    RequestCallback requestCallback = new RequestCallback() {
        @Override
        public void doWithRequest(ClientHttpRequest request) throws IOException {
            request.getHeaders().set(HttpHeaders.AUTHORIZATION, "Basic " + Base64.getEncoder().encodeToString("username:password".getBytes(StandardCharsets.UTF_8)));
        }
    };

    ResponseExtractor<Void> responseExtractor = response -> {

        List<String> type = response.getHeaders().get(HttpHeaders.CONTENT_TYPE);
        List<String> length = response.getHeaders().get(HttpHeaders.CONTENT_LENGTH);
        List<String> disposition = response.getHeaders().get(HttpHeaders.CONTENT_DISPOSITION);

        downloadResponse.addHeader(HttpHeaders.CONTENT_TYPE, type.get(0));
        downloadResponse.addHeader(HttpHeaders.CONTENT_LENGTH, length.get(0));
        if (disposition != null && !disposition.isEmpty()) {
            downloadResponse.addHeader(HttpHeaders.CONTENT_DISPOSITION, disposition.get(0));
        }

        FileCopyUtils.copy(response.getBody(), downloadResponse.getOutputStream());

        return null;
    };


    restTemplate.execute("https://some/url/path/veryBig.zip",
            HttpMethod.GET,
            requestCallback,
            responseExtractor);
}

@Async
public Future<StreamingResponseBody> downloadAsync(HttpServletResponse response) {
    final RestTemplate restTemplate = new RestTemplate();
    RequestCallback requestCallback = new RequestCallback() {
        @Override
        public void doWithRequest(ClientHttpRequest request) throws IOException {
            request.getHeaders().set(HttpHeaders.AUTHORIZATION, "Basic " + Base64.getEncoder().encodeToString("username:password".getBytes(StandardCharsets.UTF_8)));
        }
    };

    ResponseExtractor<StreamingResponseBody> responseExtractor = responseOwnCloud -> {

        List<String> type = responseOwnCloud.getHeaders().get(HttpHeaders.CONTENT_TYPE);
        List<String> length = responseOwnCloud.getHeaders().get(HttpHeaders.CONTENT_LENGTH);
        List<String> disposition = responseOwnCloud.getHeaders().get(HttpHeaders.CONTENT_DISPOSITION);

        response.setHeader(HttpHeaders.CONTENT_TYPE, type.get(0));
        response.setHeader(HttpHeaders.CONTENT_LENGTH, length.get(0));
        if (disposition != null && !disposition.isEmpty()) {
            response.setHeader(HttpHeaders.CONTENT_DISPOSITION, disposition.get(0));
        }

        return outputStream -> {
            FileCopyUtils.copy(responseOwnCloud.getBody(), response.getOutputStream());
        };
    };


    return new AsyncResult<>(restTemplate.execute("https://some/url/path/veryBig.zip",
            HttpMethod.GET,
            requestCallback,
            responseExtractor));

}

Spring Async Configuration

@Configuration
@ComponentScan(basePackages = { "some.service"})
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(4);
        executor.setMaxPoolSize(4);
        executor.setQueueCapacity(500);
        executor.setThreadNamePrefix("AsyncExec-");
        executor.initialize();
        return executor;
    }

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return null;
    }
}
  • 1
    Ed, did you ever sort this out? I am looking at almost the exact same scenario right now and trying to come up with a good scalable solution. – Mike Oct 02 '18 at 18:06
  • If you ever come over here looking for the answer for **Spring 5** instead, you should consider using **WebClient** to request file data and write it down to `HttpServletResponse` using `DataBufferUtils#write` - you can find e.g. [here](https://stackoverflow.com/a/57425230/2004186) how to do it. – Krzysztof Skrzynecki Aug 09 '19 at 08:18

0 Answers0