From 0391cfff6590478abc97253ef676589e6545a06a Mon Sep 17 00:00:00 2001 From: Dave Syer Date: Fri, 13 Mar 2015 16:35:46 +0000 Subject: [PATCH] Allow streaming of multipart requests in Zuul proxy It turns out that the suckiness of Zuul with multipart requests comes almost entirely from the Multipart handling in Spring's DispatcherServlet. This change makes the proxy routes available on an alternative path /zuul/ (where /zuul is the default value of zuul.servletPath). I have tested those with 800MB file uploads using the main method in the FormZuulServletProxyApplicationTests and the main observation is that there is no OutOfMemory error (no-one tries to download the complete request body). It works with Ribbon and with the simple (HttpClient) filter. With Ribbon you will need to set some timeouts if you want to upload files as large as that, e.g. see application.yml in the tests: hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds: 60000 ribbon: ConnectTimeout: 3000 ReadTimeout: 60000 You need to set "Transfer-Encoding: chunked" in the incoming request. Chrome does not do this by default apparently, but I was able to test with curl, e.g. $ curl -v -H "Transfer-Encoding: chunked" \ -F "file=@mylarg.iso" \ localhost:9999/zuul/direct/file The old proxy paths through the DispatcherServlet are still available (for backwards compatibility and for convenience of having the paths available at the root of the context path). Fixes gh-254 --- .../main/asciidoc/spring-cloud-netflix.adoc | 31 +++ .../cloud/netflix/zuul/ZuulConfiguration.java | 7 + .../netflix/zuul/filters/ZuulProperties.java | 13 + .../filters/pre/FormBodyWrapperFilter.java | 11 +- .../zuul/FormZuulProxyApplicationTests.java | 11 +- .../FormZuulServletProxyApplicationTests.java | 237 ++++++++++++++++++ .../src/test/resources/application.yml | 4 + .../src/test/resources/static/index.html | 14 +- 8 files changed, 322 insertions(+), 6 deletions(-) create mode 100644 spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/FormZuulServletProxyApplicationTests.java diff --git a/docs/src/main/asciidoc/spring-cloud-netflix.adoc b/docs/src/main/asciidoc/spring-cloud-netflix.adoc index 7693ba89..b2e525e6 100644 --- a/docs/src/main/asciidoc/spring-cloud-netflix.adoc +++ b/docs/src/main/asciidoc/spring-cloud-netflix.adoc @@ -860,6 +860,37 @@ An application with the `@EnableZuulProxy` could act as a standalone server if you set a default route ("/"), for example `zuul.route.home: /` would route all traffic (i.e. "/**") to the "home" service. +=== Uploading Files through Zuul + +If you `@EnableZuulProxy` you can use the proxy paths to +upload files and it should just work as long as the files +are small. For large files there is an alternative path +which bypasses the Spring `DispatcherServlet` (to +avoid multipart processing) in "/zuul/*". I.e. if +`zuul.routes.customers=/customers/**` then you can +POST large files to "/zuul/customers/*". The servlet +path is externalized via `zuul.servletPath`. Extremely +large files will also require elevated timeout settings +if the proxy route takes you through a Ribbon load +balancer, e.g. + +.application.yml +[source,yaml] +---- +hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds: 60000 +ribbon: + ConnectTimeout: 3000 + ReadTimeout: 60000 +---- + +Note that for streaming to work with large files, you need to use chunked encoding in the request (which some browsers +do not do by default). E.g. on the command line: + +---- +$ curl -v -H "Transfer-Encoding: chunked" \ + -F "file=@mylarge.iso" localhost:9999/zuul/simple/file +---- + === Plain Embedded Zuul You can also run a Zuul server without the proxying, or switch on parts of the proxying platform selectively, if you diff --git a/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/ZuulConfiguration.java b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/ZuulConfiguration.java index 08f1d4e1..a7851d76 100644 --- a/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/ZuulConfiguration.java +++ b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/ZuulConfiguration.java @@ -20,6 +20,7 @@ import java.util.Map; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.context.embedded.ServletRegistrationBean; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.cloud.context.scope.refresh.RefreshScopeRefreshedEvent; import org.springframework.cloud.netflix.zuul.filters.RouteLocator; @@ -73,6 +74,12 @@ public class ZuulConfiguration { return new ZuulRefreshListener(); } + @Bean + public ServletRegistrationBean zuulServlet() { + return new ServletRegistrationBean(new ZuulServlet(), + this.zuulProperties.getServletPattern()); + } + // pre filters @Bean diff --git a/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/ZuulProperties.java b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/ZuulProperties.java index 3f9e7ea6..c064f9ee 100644 --- a/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/ZuulProperties.java +++ b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/ZuulProperties.java @@ -51,6 +51,8 @@ public class ZuulProperties { private List ignoredServices = new ArrayList(); + private String servletPath = "/zuul"; + @PostConstruct public void init() { for (Entry entry : this.routes.entrySet()) { @@ -132,4 +134,15 @@ public class ZuulProperties { } + public String getServletPattern() { + String path = this.servletPath; + if (!path.startsWith("/")) { + path = "/" + path; + } + if (!path.contains("*")) { + path = path.endsWith("/") ? (path + "*") : (path + "/*"); + } + return path; + } + } diff --git a/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/pre/FormBodyWrapperFilter.java b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/pre/FormBodyWrapperFilter.java index be238dff..eb023cef 100644 --- a/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/pre/FormBodyWrapperFilter.java +++ b/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/zuul/filters/pre/FormBodyWrapperFilter.java @@ -37,6 +37,7 @@ import org.springframework.util.MultiValueMap; import org.springframework.util.ReflectionUtils; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartRequest; +import org.springframework.web.servlet.DispatcherServlet; import com.netflix.zuul.ZuulFilter; import com.netflix.zuul.context.RequestContext; @@ -76,17 +77,23 @@ public class FormBodyWrapperFilter extends ZuulFilter { if (contentType == null) { return false; } - // Only use this filter for form data + // Only use this filter for form data and only for multipart data in a + // DispatcherServlet handler try { MediaType mediaType = MediaType.valueOf(contentType); return MediaType.APPLICATION_FORM_URLENCODED.includes(mediaType) - || MediaType.MULTIPART_FORM_DATA.includes(mediaType); + || (isDispatcherServletRequest(request) && MediaType.MULTIPART_FORM_DATA + .includes(mediaType)); } catch (InvalidMediaTypeException ex) { return false; } } + private boolean isDispatcherServletRequest(HttpServletRequest request) { + return request.getAttribute(DispatcherServlet.WEB_APPLICATION_CONTEXT_ATTRIBUTE) != null; + } + @Override public Object run() { RequestContext ctx = RequestContext.getCurrentContext(); diff --git a/spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/FormZuulProxyApplicationTests.java b/spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/FormZuulProxyApplicationTests.java index 7cab1ed8..234522f2 100644 --- a/spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/FormZuulProxyApplicationTests.java +++ b/spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/FormZuulProxyApplicationTests.java @@ -34,6 +34,7 @@ import org.springframework.boot.test.IntegrationTest; import org.springframework.boot.test.SpringApplicationConfiguration; import org.springframework.boot.test.TestRestTemplate; import org.springframework.cloud.netflix.ribbon.RibbonClient; +import org.springframework.cloud.netflix.ribbon.RibbonClients; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpEntity; @@ -137,7 +138,9 @@ public class FormZuulProxyApplicationTests { @EnableAutoConfiguration @RestController @EnableZuulProxy -@RibbonClient(name = "simple", configuration = FormRibbonClientConfiguration.class) +@RibbonClients({ + @RibbonClient(name = "simple", configuration = FormRibbonClientConfiguration.class), + @RibbonClient(name = "psimple", configuration = FormRibbonClientConfiguration.class) }) @Slf4j class FormZuulProxyApplication { @@ -211,8 +214,10 @@ class FormZuulProxyApplication { public static void main(String[] args) { new SpringApplicationBuilder(FormZuulProxyApplication.class).properties( - "zuul.routes.simple:/simple/**", "multipart.maxFileSize:4096MB", - "multipart.maxRequestSize:4096MB").run(args); + "zuul.routes.simple:/simple/**", + "zuul.routes.direct.url:http://localhost:9999", + "multipart.maxFileSize:4096MB", "multipart.maxRequestSize:4096MB").run( + args); } } diff --git a/spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/FormZuulServletProxyApplicationTests.java b/spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/FormZuulServletProxyApplicationTests.java new file mode 100644 index 00000000..53855a25 --- /dev/null +++ b/spring-cloud-netflix-core/src/test/java/org/springframework/cloud/netflix/zuul/FormZuulServletProxyApplicationTests.java @@ -0,0 +1,237 @@ +/* + * Copyright 2013-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.netflix.zuul; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; +import java.util.Map; + +import lombok.extern.slf4j.Slf4j; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.actuate.trace.InMemoryTraceRepository; +import org.springframework.boot.actuate.trace.TraceRepository; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.boot.test.IntegrationTest; +import org.springframework.boot.test.SpringApplicationConfiguration; +import org.springframework.boot.test.TestRestTemplate; +import org.springframework.cloud.netflix.ribbon.RibbonClient; +import org.springframework.cloud.netflix.ribbon.RibbonClients; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import org.springframework.test.context.web.WebAppConfiguration; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import com.netflix.appinfo.EurekaInstanceConfig; +import com.netflix.loadbalancer.BaseLoadBalancer; +import com.netflix.loadbalancer.ILoadBalancer; +import com.netflix.loadbalancer.Server; +import com.netflix.zuul.ZuulFilter; + +import static org.junit.Assert.assertEquals; + +@RunWith(SpringJUnit4ClassRunner.class) +@SpringApplicationConfiguration(classes = FormZuulServletProxyApplication.class) +@WebAppConfiguration +@IntegrationTest({ "server.port:0", "zuul.routes.simple:/zuul/simple/**" }) +@DirtiesContext +public class FormZuulServletProxyApplicationTests { + + @Value("${local.server.port}") + private int port; + + @Test + public void postWithForm() { + MultiValueMap form = new LinkedMultiValueMap(); + form.set("foo", "bar"); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + ResponseEntity result = new TestRestTemplate().exchange( + "http://localhost:" + this.port + "/zuul/simple/form", HttpMethod.POST, + new HttpEntity>(form, headers), + String.class); + assertEquals(HttpStatus.OK, result.getStatusCode()); + assertEquals("Posted! {foo=[bar]}", result.getBody()); + } + + @Test + public void postWithMultipartForm() { + MultiValueMap form = new LinkedMultiValueMap(); + form.set("foo", "bar"); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + ResponseEntity result = new TestRestTemplate().exchange( + "http://localhost:" + this.port + "/zuul/simple/form", HttpMethod.POST, + new HttpEntity>(form, headers), + String.class); + assertEquals(HttpStatus.OK, result.getStatusCode()); + assertEquals("Posted! {foo=[bar]}", result.getBody()); + } + + @Test + public void postWithMultipartFile() { + MultiValueMap form = new LinkedMultiValueMap(); + HttpHeaders part = new HttpHeaders(); + part.setContentType(MediaType.TEXT_PLAIN); + part.setContentDispositionFormData("file", "foo.txt"); + form.set("foo", new HttpEntity("bar".getBytes(), part)); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + ResponseEntity result = new TestRestTemplate().exchange( + "http://localhost:" + this.port + "/zuul/simple/file", HttpMethod.POST, + new HttpEntity>(form, headers), + String.class); + assertEquals(HttpStatus.OK, result.getStatusCode()); + assertEquals("Posted! bar", result.getBody()); + } + + @Test + public void postWithUTF8Form() { + MultiValueMap form = new LinkedMultiValueMap(); + form.set("foo", "bar"); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType + .valueOf(MediaType.APPLICATION_FORM_URLENCODED_VALUE + "; charset=UTF-8")); + ResponseEntity result = new TestRestTemplate().exchange( + "http://localhost:" + this.port + "/zuul/simple/form", HttpMethod.POST, + new HttpEntity>(form, headers), + String.class); + assertEquals(HttpStatus.OK, result.getStatusCode()); + assertEquals("Posted! {foo=[bar]}", result.getBody()); + } +} + +// Don't use @SpringBootApplication because we don't want to component scan +@Configuration +@EnableAutoConfiguration +@RestController +@EnableZuulProxy +@RibbonClients(@RibbonClient(name = "simple", configuration = ServletFormRibbonClientConfiguration.class)) +@Slf4j +class FormZuulServletProxyApplication { + + @RequestMapping(value = "/form", method = RequestMethod.POST) + public String accept(@RequestParam MultiValueMap form) + throws IOException { + return "Posted! " + form; + } + + // TODO: Why does this not work if you add @RequestParam as above? + @RequestMapping(value = "/file", method = RequestMethod.POST) + public String file(@RequestParam(required = false) MultipartFile file) + throws IOException { + byte[] bytes = new byte[0]; + if (file != null) { + if (file.getSize() > 1024) { + bytes = new byte[1024]; + InputStream inputStream = file.getInputStream(); + inputStream.read(bytes); + byte[] buffer = new byte[1024 * 1024 * 10]; + while (inputStream.read(buffer) >= 0) { + log.info("Read more bytes"); + } + } + else { + bytes = file.getBytes(); + } + } + return "Posted! " + new String(bytes); + } + + @Bean + public ZuulFilter sampleFilter() { + return new ZuulFilter() { + + @Override + public String filterType() { + return "pre"; + } + + @Override + public boolean shouldFilter() { + return true; + } + + @Override + public Object run() { + return null; + } + + @Override + public int filterOrder() { + return 0; + } + + }; + } + + @Bean + public TraceRepository traceRepository() { + return new InMemoryTraceRepository() { + @Override + public void add(Map map) { + if (map.containsKey("body")) { + map.get("body"); + } + super.add(map); + } + }; + } + + public static void main(String[] args) { + new SpringApplicationBuilder(FormZuulProxyApplication.class).properties( + "zuul.routes.simple:/zuul/simple/**", + "zuul.routes.direct.url:http://localhost:9999", + "zuul.routes.direct.path:/zuul/direct/**", + "multipart.maxFileSize:4096MB", "multipart.maxRequestSize:4096MB").run( + args); + } + +} + +// Load balancer with fixed server list for "simple" pointing to localhost +@Configuration +class ServletFormRibbonClientConfiguration { + + @Bean + public ILoadBalancer ribbonLoadBalancer(EurekaInstanceConfig instance) { + BaseLoadBalancer balancer = new BaseLoadBalancer(); + balancer.setServersList(Arrays.asList(new Server("localhost", instance + .getNonSecurePort()))); + // balancer.setServersList(Arrays.asList(new Server("localhost", 8000))); + return balancer; + } + +} diff --git a/spring-cloud-netflix-core/src/test/resources/application.yml b/spring-cloud-netflix-core/src/test/resources/application.yml index 7c8e58f6..080dafad 100644 --- a/spring-cloud-netflix-core/src/test/resources/application.yml +++ b/spring-cloud-netflix-core/src/test/resources/application.yml @@ -14,6 +14,10 @@ eureka: fetchRegistry: false #error: # path: /myerror +hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds: 60000 +ribbon: + ConnectTimeout: 3000 + ReadTimeout: 60000 management: context-path: /admin endpoints: diff --git a/spring-cloud-netflix-core/src/test/resources/static/index.html b/spring-cloud-netflix-core/src/test/resources/static/index.html index c7dbaf3c..27f58190 100644 --- a/spring-cloud-netflix-core/src/test/resources/static/index.html +++ b/spring-cloud-netflix-core/src/test/resources/static/index.html @@ -4,7 +4,19 @@ action="/simple/file"> File to upload:
Name:

Press here to upload the file via proxy! + value="Upload"> Press here to upload the file via ribbon proxy! + +
+ File to upload:
Name:

Press here to upload the file via direct proxy! +
+
+ File to upload:
Name:

Press here to upload the file via proxy servlet!