Spring WebClient实战

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

https://docs.spring.io/spring-framework/docs/current/reference/html/web-reactive.html#webflux-client-builder-reactor-resources

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 +
                '}';
    }
}

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

程猿薇茑

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值