WebClient实战
本文代码地址:https://github.com/bigbirditedu/webclient
文章目录
Spring Webflux 是 Spring Framework 5.0 的新特性,是随着当下流行的 Reactive Programming 而诞生的高性能框架。传统的 Web 应用框架,比如我们所熟知的 Struts2,Spring MVC 等都是基于 Servlet API 和 Servlet 容器之上运行的,本质上都是阻塞式的。Servlet 直到 3.1 版本之后才对异步非阻塞进行了支持。而 WebFlux天生就是一个典型的异步非阻塞框架,其核心是基于 Reactor 相关 API 实现的。相比传统的 Web 框架,WebFlux 可以运行在例如 Netty、Undertow 以及 Servlet 3.1 容器之上,其运行环境比传统 Web 框架更具灵活性。
WebFlux 的主要优势有:
- 非阻塞性:WebFlux 提供了一种比 Servlet 3.1 更完美的异步非阻塞解决方案。非阻塞的方式可以使用较少的线程以及硬件资源来处理更多的并发。
- 函数式编程:函数式编程是 Java 8 重要的特性,WebFlux 完美支持。
webclient的HTTP API请参考:https://github.com/bigbirditedu/webclient
服务端性能对比
比较的是Spring MVC 与 Spring WebFlux 作为HTTP 应用框架谁的性能更好。
Spring WebFlux
先看看Spring WebFlux
引入pom依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
编写http接口
@RestController
@RequestMapping("/webflux")
public class WebFluxController {
public static AtomicLong COUNT = new AtomicLong(0);
@GetMapping("/hello/{latency}")
public Mono<String> hello(@PathVariable long latency) {
System.out.println("Start:" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS")));
System.out.println("Page count:" + COUNT.incrementAndGet());
Mono<String> res = Mono.just("welcome to Spring Webflux").delayElement(Duration.ofSeconds(latency));//阻塞latency秒,模拟处理耗时
System.out.println("End: " + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS")));
return res;
}
}
启动服务器
可以看到webflux 默认选择Netty作为服务器
使用JMeter进行压测:File->新建测试计划->添加用户线程组->在线程组上添加一个取样器,选择Http Request
配置Http请求,并在HTTP Request上添加监听器;这里不做复杂的压测分析,选择结果树和聚合报告即可
设置http请求超时时间
设置并发用户数,60秒内全部启起来;
不断调整进行测试;每次开始前先Clear All清理一下旧数据,再点save保存一下,再点Start开始
1000用户,99线大约24毫秒的延迟
2000用户,99线大约59毫秒的延迟
3000用户,99线大约89毫秒的延迟
4000用户
webflux到4000并发用户时还是很稳
Spring MVC
再来看看SpringMVC的性能
引入pom文件
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
编写http接口
@RestController
@RequestMapping("/springmvc")
public class SpringMvcController {
public static AtomicLong COUNT = new AtomicLong(0);
@GetMapping("/hello/{latency}")
public String hello(@PathVariable long latency) {
System.out.println("Start:" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS")));
System.out.println("Page count:" + COUNT.incrementAndGet());
try {
//阻塞latency秒,模拟处理耗时
TimeUnit.SECONDS.sleep(latency);
} catch (InterruptedException e) {
e.printStackTrace();
return "Exception during thread sleep";
}
System.out.println("End:" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS")));
return "welcome to Spring MVC";
}
}
启动服务器。可以看到SpringMVC默认选择Tomcat作为服务器
设置请求路径
100用户
200用户
300用户
从300用户开始,响应时间就开始增加
400用户
500用户
550用户
本例中,传统Web技术(Tomcat+SpringMVC)在处理550用户并发时,就开始有超时失败的
600用户
在处理600用户并发时,失败率就已经很高;用户并发数更高时几乎都会处理不过来,接近100%的请求超时。
1000用户
2000用户
3000用户
4000用户
客户端性能比较
我们来比较一下HTTP客户端的性能。
先建一个单独的基于Springboot的Http Server工程提供标准的http接口供客户端调用。
/**
* Http服务提供方接口;模拟一个基准的HTTP Server接口
*/
@RestController
public class HttpServerController {
@RequestMapping("product")
public Product getAllProduct(String type, HttpServletRequest request, HttpServletResponse response) throws InterruptedException {
long start = System.currentTimeMillis();
System.out.println("Start:" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS")));
//输出请求头
Enumeration<String> headerNames = request.getHeaderNames();
while (headerNames.hasMoreElements()) {
String head = headerNames.nextElement();
System.out.println(head + ":" + request.getHeader(head));
}
System.out.println("cookies=" + request.getCookies());
Product product = new Product(type + "A", "1", 56.67);
Thread.sleep(1000);
//设置响应头和cookie
response.addHeader("X-appId", "android01");
response.addCookie(new Cookie("sid", "1000101111"));
System.out.println("End:" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS")));
System.out.println("cost:" + (System.currentTimeMillis() - start) + product);
return product;
}
@RequestMapping("products")
public List<Product> getAllProducts(String type) throws InterruptedException {
long start = System.currentTimeMillis();
List<Product> products = new ArrayList<>();
products.add(new Product(type + "A", "1", 56.67));
products.add(new Product(type + "B", "2", 66.66));
products.add(new Product(type + "C", "3", 88.88));
Thread.sleep(1000);
System.out.println("cost:" + (System.currentTimeMillis() - start) + products);
return products;
}
@RequestMapping("product/{pid}")
public Product getProductById(@PathVariable String pid, @RequestParam String name, @RequestParam double price) throws InterruptedException {
long start = System.currentTimeMillis();
Product product = new Product(name, pid, price);
Thread.sleep(1000);
System.out.println("cost:" + (System.currentTimeMillis() - start) + product);
return product;
}
@RequestMapping("postProduct")
public Product postProduct(@RequestParam String id, @RequestParam String name, @RequestParam double price) throws InterruptedException {
long start = System.currentTimeMillis();
Product product = new Product(name, id, price);
Thread.sleep(1000);
System.out.println("cost:" + (System.currentTimeMillis() - start) + product);
return product;
}
@RequestMapping("postProduct2")
public Product postProduct(@RequestBody Product product) throws InterruptedException {
long start = System.currentTimeMillis();
Thread.sleep(1000);
System.out.println("cost:" + (System.currentTimeMillis() - start) + product);
return product;
}
@RequestMapping("uploadFile")
public String uploadFile(MultipartFile file, int age) throws InterruptedException {
long start = System.currentTimeMillis();
System.out.println("age=" + age);
String filePath = "";
try {
String filename = file.getOriginalFilename();
//String extension = FilenameUtils.getExtension(file.getOriginalFilename());
String dir = "D:\\files";
filePath = dir + File.separator + filename;
System.out.println(filePath);
if (!Files.exists(Paths.get(dir))) {
new File(dir).mkdirs();
}
file.transferTo(Paths.get(filePath));
} catch (IOException e) {
e.printStackTrace();
}
Thread.sleep(1000);
System.out.println("cost:" + (System.currentTimeMillis() - start));
return filePath;
}
}
Tip
其它客户端代码请访问:https://github.com/bigbirditedu/webclient
webclient
和测试服务端时单独依赖不同的服务器相比,这次同时引入两个依赖。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
引入starter-web是为了启动Tomcat服务器,测试时统一使用Tomcat服务器跑http客户端应用程序;
引入starter-webflux是为了单独使用webclient api,而不是为了使用Netty作为Http服务器;
500用户(超时时间设置6秒)
1000用户(超时时间设置6秒)
1100用户(超时时间设置6秒)
可以看到已经开始有响应超时的了
1200用户(超时时间设置10秒)
resttemplate(不带连接池)
500用户(超时时间设置6秒)
1000用户并发(超时时间设置6秒)
1100用户并发(超时时间设置6秒)
1200用户(超时时间设置10秒),有少量响应超时
resttemplate(带连接池)
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.6</version>
</dependency>
500用户(超时时间设置6秒)
1000用户(超时时间设置6秒)
1100用户(超时时间设置6秒)
和 不带连接池相比,错误率减少
1200用户(超时时间设置10秒),效果比不带连接池的resttemplate好点,但是响应耗时普遍还是比带连接池的webclient高
综合来看,是否使用http连接池对于单个接口影响有限,池的效果不明显;在多http地址、多接口路由时连接池的效果可能更好。
webclient连接池
默认情况下,WebClient使用连接池运行。池的默认设置是最大500个连接和最大1000个等待请求。如果超过此配置,就会抛异常。
reactor.netty.internal.shaded.reactor.pool.PoolAcquirePendingLimitException: Pending acquire queue has reached its maximum size of 1000
报错日志显示已经达到了默认的挂起队列长度限制1000,因此我们可以自定义线程池配置,以获得更高的性能。
关于Reactor Netty连接池请参考Netty官方和Spring官方的文档:
https://projectreactor.io/docs/netty/snapshot/reference/index.html#_connection_pool_2
1000用户(超时时间设置6秒)
1100用户(超时时间设置6秒)
带连接池的效果好些,没有出现失败的
1200用户(超时时间设置10秒),响应延迟比默认配置的webclient好些
webclient阻塞方式获取结果;不自定义webclient线程池配置,2000用户(JMeter不配置超时时间)
webclient+CompletableFuture方式获取结果;不自定义webclient线程池配置,2000用户(JMeter不配置超时时间)
虽然测试效果几乎没有差别,但是我们要清楚地知道调用block方法是会引发实时阻塞的,会一定程度上增加对CPU的消耗;
实际开发中通常是为了使用异步特性才用webclient,如果用block方式就白瞎了webclient了,还不如直接用restTemplate。
2000用户性能比较
pooled webclient
rest
pooled rest
3000用户性能比较
pooled webclient
rest
pooled rest
webclient 的HTTP API
WebClient 作为一个 HTTP 客户端工具,其提供了标准 HTTP 请求方式,支持 Get、Post、Put、Delete、Head 等方法,可以作为替代 resttemplate 的一个强有力的工具。
API演示代码地址:https://github.com/bigbirditedu/webclient
小结
使用webClient在等待远程响应的同时不会阻塞本地正在执行的线程 ;本地线程处理完一个请求紧接着可以处理下一个,能够提高系统的吞吐量;而restTemplate 这种方式是阻塞的,会一直占用当前线程资源,直到http返回响应。如果等待的请求发生了堆积,应用程序将创建大量线程,直至耗尽线程池所有可用线程,甚至出现OOM。另外频繁的CPU上下文切换,也会导致性能下降。
但是作为上述两种方式的调用方(消费者)而言,其最终获得http响应结果的耗时并未减少。比如文章案例中,通过浏览器访问后端的的两个接口(SpringMVC、SpringWebFlux)时,返回数据的耗时相同。即最终获取(消费)数据的地方还会等待。
使用webclient替代restTemplate的好处是可以异步等待http响应,使得线程不需要阻塞;单位时间内有限资源下支持更高的并发量。但是建议webclient和webflux配合使用,使整个流程全异步化;如果单独使用webclient,笔者实测,和resttemplate差别不大!欢迎留言指教!
官方文档:https://www.baeldung.com/spring-5-webclient
附上webclient 工具类:
import io.netty.channel.ChannelOption;
import io.netty.handler.ssl.SslContextBuilder;
import io.netty.handler.ssl.util.InsecureTrustManagerFactory;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.util.DefaultUriBuilderFactory;
import reactor.core.publisher.Mono;
import reactor.netty.http.client.HttpClient;
import reactor.netty.resources.ConnectionProvider;
import javax.net.ssl.SSLException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
/**
* 可重试的、异步非阻塞的、高性能的 http客户端 工具类
* 可以使用此 http客户端 替代 普通的restTemplate(Apache HTTPClient)
* 微信:bigbird8848
*/
@Slf4j
public class WebClientUtil {
private WebClient webClient;
private List<HttpMethod> supports;
int maxInMemorySize = 0;
int connectTimeout = 5000;
int readTimeout = 5000;
int maxConnections = 1000;
int maxPendingCount = 2000;
public WebClientUtil() {
if (maxInMemorySize <= 0) {
maxInMemorySize = 10 * 1024 * 1024;//10MB
}
supports = new ArrayList<>();
supports.add(HttpMethod.GET);
supports.add(HttpMethod.POST);
supports.add(HttpMethod.PUT);
supports.add(HttpMethod.DELETE);
this.webClient = builder().build();
}
public WebClientUtil(int connectTimeout, int readTimeout) {
this(0, connectTimeout, readTimeout, 1000, 2000);
}
public WebClientUtil(int maxInMemorySize, int connectTimeout, int readTimeout,
int maxConnections, int maxPendingCount) {
if (maxInMemorySize <= 0) {
maxInMemorySize = 10 * 1024 * 1024;//10MB
}
if (connectTimeout <= 0 || connectTimeout > 60000) {
connectTimeout = 5 * 1000;
log.info("use default connectTimeout:{}", connectTimeout);
}
if (readTimeout <= 0 || readTimeout > 60000) {//如果没有设置http读取超时时间则默认15秒超时
readTimeout = 15 * 1000;
log.info("use default readTimeout:{}", readTimeout);
}
this.setMaxInMemorySize(maxInMemorySize);
this.setConnectTimeout(connectTimeout);
this.setReadTimeout(readTimeout);
this.setMaxConnections(maxConnections);
this.setMaxPendingCount(maxPendingCount);
supports = new ArrayList<>();
supports.add(HttpMethod.GET);
supports.add(HttpMethod.POST);
supports.add(HttpMethod.PUT);
supports.add(HttpMethod.DELETE);
this.webClient = builder().build();
log.info("webClient created:{}", this);
}
private WebClient.Builder builder() {
ConnectionProvider provider = ConnectionProvider.builder("snbWebClient")
.maxConnections(maxConnections)//http连接池可用的最大连接数,其默认值只是max(16,cpu核数*2)
.pendingAcquireMaxCount(maxPendingCount)//等待队列大小
.maxIdleTime(Duration.ofSeconds(20))//连接的最大空闲时间,空闲超过该时间则回收
.maxLifeTime(Duration.ofSeconds(60))//连接的最大生命周期时间
.pendingAcquireTimeout(Duration.ofSeconds(60))
.evictInBackground(Duration.ofSeconds(120))
.build();
HttpClient httpClient = HttpClient.create(provider)
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, connectTimeout)
.responseTimeout(Duration.ofMillis(readTimeout))
.compress(true)
.secure(t -> {
try {
t.sslContext(SslContextBuilder.forClient().trustManager(InsecureTrustManagerFactory.INSTANCE).build());
} catch (SSLException e) {
log.error("create sslWebClient error", e);
}
});//支持https
DefaultUriBuilderFactory ubf = new DefaultUriBuilderFactory();
ubf.setEncodingMode(DefaultUriBuilderFactory.EncodingMode.NONE);
return WebClient.builder()
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(maxInMemorySize))
.uriBuilderFactory(ubf)
.clientConnector(new ReactorClientHttpConnector(httpClient));
}
/**
* 普通GET请求
*
* @param uri
* @param headers
* @param cookies
* @param mediaType
* @param retryCount 重试次数
* @return
*/
public Mono<ResponseEntity<String>> get(String uri, Map<String, String> headers,
MultiValueMap<String, String> cookies, MediaType mediaType,
int retryCount) {
return invoke(HttpMethod.GET, uri, headers, cookies, "", mediaType, retryCount);
}
/**
* 普通post请求
*
* @param uri
* @param headers
* @param cookies
* @param mediaType
* @param bodyValue
* @param retryCount 重试次数
* @return
*/
public Mono<ResponseEntity<String>> post(String uri, Map<String, String> headers,
MultiValueMap<String, String> cookies,
MediaType mediaType, Object bodyValue,
int retryCount) {
return invoke(HttpMethod.POST, uri, headers, cookies, bodyValue, mediaType, retryCount);
}
/**
* CompletableFuture方式的异步调用
*
* @param method
* @param uri
* @param headers
* @param cookies
* @param bodyValue
* @param retryCount 重试次数
* @return
*/
public CompletableFuture<ResponseEntity<String>> invokeForFuture(HttpMethod method,
String uri,
Map<String, String> headers,
MultiValueMap<String, String> cookies,
MediaType mediaType,
String bodyValue,
int retryCount) {
return invoke(method, uri, headers, cookies, bodyValue, mediaType, retryCount).toFuture();
}
public CompletableFuture<ResponseEntity<String>> getForFuture(String uri, Map<String, String> headers,
MultiValueMap<String, String> cookies,
MediaType mediaType, int retryCount) {
return invokeForFuture(HttpMethod.GET, uri, headers, cookies, mediaType, "", retryCount);
}
public CompletableFuture<ResponseEntity<String>> postForFuture(String uri, Map<String, String> headers,
MultiValueMap<String, String> cookies,
String bodyValue, MediaType mediaType,
int retryCount) {
return invokeForFuture(HttpMethod.POST, uri, headers, cookies, mediaType, bodyValue, retryCount);
}
/**
* @param method
* @param uri
* @param headers
* @param cookies
* @param bodyValue
* @param mediaType
* @param retryCount 重试次数
* @return
*/
public Mono<ResponseEntity<String>> invoke(HttpMethod method,
String uri,
Map<String, String> headers,
MultiValueMap<String, String> cookies,
Object bodyValue, MediaType mediaType,
int retryCount) {
if (null == method) {
return Mono.error(new RuntimeException("http_method is empty"));
}
if (!supports.contains(method)) {
return Mono.error(new RuntimeException("http_webClient not support method"));
}
if (!StringUtils.hasText(uri)) {
return Mono.error(new RuntimeException("http_uri is empty"));
}
if (null == bodyValue) {
bodyValue = "";
}
Mono<ResponseEntity<String>> mono = webClient
.method(method)
.uri(uri)
.contentType(mediaType)
.headers(httpHeaders -> addHeaders(httpHeaders, headers))
.cookies(ck -> ck.addAll(cookies))
.bodyValue(bodyValue)
.retrieve()
.toEntity(String.class)
.retry(retryCount);
return mono;
}
void addHeaders(HttpHeaders httpHeaders, Map<String, String> headers) {
if (null != headers && headers.size() > 0) {
httpHeaders.setAll(headers);
}
}
public int getMaxInMemorySize() {
return maxInMemorySize;
}
public void setMaxInMemorySize(int maxInMemorySize) {
this.maxInMemorySize = maxInMemorySize;
}
public int getConnectTimeout() {
return connectTimeout;
}
public void setConnectTimeout(int connectTimeout) {
this.connectTimeout = connectTimeout;
}
public int getReadTimeout() {
return readTimeout;
}
public void setReadTimeout(int readTimeout) {
this.readTimeout = readTimeout;
}
public int getMaxConnections() {
return maxConnections;
}
public void setMaxConnections(int maxConnections) {
this.maxConnections = maxConnections;
}
public int getMaxPendingCount() {
return maxPendingCount;
}
public void setMaxPendingCount(int maxPendingCount) {
this.maxPendingCount = maxPendingCount;
}
@Override
public String toString() {
return "WebClientUtil{" +
"maxInMemorySize=" + maxInMemorySize +
", connectTimeout=" + connectTimeout +
", readTimeout=" + readTimeout +
", maxConnections=" + maxConnections +
", maxPendingCount=" + maxPendingCount +
'}';
}
}