依赖 版本
springboot 版本 2.6.1
bash
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
webclient配置类
java
package com.huayi.iepms.common.feign.wyy;
import io.netty.channel.ChannelOption;
import io.netty.handler.timeout.ReadTimeoutHandler;
import io.netty.handler.timeout.WriteTimeoutHandler;
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.reactive.function.client.ExchangeStrategies;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.netty.http.client.HttpClient;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
/**
* WebClient配置类
*
* @author zrf
* @date 2026/09/04 11:00
*/
@Configuration
public class WebClientConfig {
/**
* 创建一个 WebClient.Builder 实例,并启用负载均衡
*/
@Bean
@LoadBalanced
public WebClient.Builder webClientBuilder() {
return WebClient.builder();
}
/**
* 创建 WebClient 实例并配置超时和缓冲区
*/
@Bean
public WebClient webClient(WebClient.Builder webClientBuilder) {
// 1. 配置底层 Netty HttpClient 超时参数
HttpClient httpClient = HttpClient.create()
// TCP 连接超时时间:10秒
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000)
// 读写超时时间:300秒(适用于大文件传输)
.doOnConnected(conn -> conn
.addHandlerLast(new ReadTimeoutHandler(300, TimeUnit.SECONDS))
.addHandlerLast(new WriteTimeoutHandler(300, TimeUnit.SECONDS))
)
// 整体响应超时(从发请求到接收完):300秒
.responseTimeout(Duration.ofSeconds(300));
// 2. 配置内存缓冲区大小(防止大文本/大 JSON 报错)
ExchangeStrategies exchangeStrategies = ExchangeStrategies.builder()
.codecs(configurer -> configurer.defaultCodecs()
// 设置为 50MB(默认是 256KB),可按需调整
.maxInMemorySize(50 * 1024 * 1024))
.build();
return webClientBuilder
.clientConnector(new ReactorClientHttpConnector(httpClient))
.exchangeStrategies(exchangeStrategies)
.build();
}
}
@LoadBalanced 注解说明
@LoadBalanced 是 Spring Cloud 提供的注解,用于标记 WebClient.Builder 或 RestTemplate 实例,使其具备客户端负载均衡能力。其核心作用如下:
- 服务名解析 :启用后,
WebClient在发起请求时,会将 URL 中的服务名(如http://iepms-file/file/upload)自动解析为实际的服务实例地址,而无需手动拼接 IP 和端口。 - 负载均衡策略 :当目标服务存在多个实例时,
@LoadBalanced会结合LoadBalancerClient或ReactiveLoadBalancer自动选择一个实例进行调用,默认采用轮询策略,也可通过配置切换为随机、权重等策略。 - 与注册中心集成 :该注解通常与 Nacos、Eureka 等注册中心配合使用,
WebClient会从注册中心获取服务实例列表,实现服务间的动态发现与调用。
注意 :
@LoadBalanced仅对WebClient.Builder生效,且必须在@Bean方法上标注。若直接使用WebClient.create()创建实例,则不具备负载均衡能力。
通过 application.yml 配置超时参数
WebClient 基于 Reactor Netty 实现,其连接超时、读取超时等参数可通过 application.yml 进行配置。示例配置如下:
yaml
spring:
codec:
max-in-memory-size: 50MB # 响应体最大内存限制,与代码中保持一致
# 自定义 WebClient 超时配置(推荐方式)
webclient:
connect-timeout: 10000 # 连接超时时间(毫秒),默认 5000
read-timeout: 300 # 读取超时时间(秒)
write-timeout: 300 # 写入超时时间(秒)
response-timeout: 300 # 整体响应超时时间(秒)
若需在代码中动态读取这些配置并应用到 WebClient,可在配置类中注入 @Value 或使用 @ConfigurationProperties:
java
@Configuration
public class WebClientConfig {
@Value("${webclient.connect-timeout:10000}")
private int connectTimeout;
@Value("${webclient.read-timeout:300}")
private int readTimeout;
@Value("${webclient.write-timeout:300}")
private int writeTimeout;
@Value("${webclient.response-timeout:300}")
private int responseTimeout;
@Bean
@LoadBalanced
public WebClient.Builder webClientBuilder() {
HttpClient httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, connectTimeout)
.doOnConnected(conn -> conn
.addHandlerLast(new ReadTimeoutHandler(readTimeout, TimeUnit.SECONDS))
.addHandlerLast(new WriteTimeoutHandler(writeTimeout, TimeUnit.SECONDS))
)
.responseTimeout(Duration.ofSeconds(responseTimeout));
return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient));
}
}
说明 :
ChannelOption.CONNECT_TIMEOUT_MILLIS控制连接超时,responseTimeout控制整体响应超时,ReadTimeoutHandler和WriteTimeoutHandler分别控制读写超时。建议将超时参数统一收敛到application.yml中,便于后续调整而无需重新编译。
webclient工具类
bash
package com.huayi.iepms.common.feign.wyy;
import com.huayi.satoken.utils.LoginUtils;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletResponse;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.Map;
import java.util.function.Consumer;
/**
* 通用 WebClient HTTP 工具类
*
* @author zrf
* @date 2026/09/04
*/
@Component
public class WebClientUtil {
@Resource
private WebClient webClient;
// ==================== 1. 基础 GET 请求 ====================
/**
* 同步 GET 请求(返回对象/Map/List等)
*/
public <T> T get(String url, Map<String, Object> queryParams, Class<T> responseType) {
return get(url, queryParams, null, responseType);
}
/**
* 同步 GET 请求(支持泛型,如 Result<List<User>>)
*/
public <T> T get(String url, Map<String, Object> queryParams, Map<String, String> headers, ParameterizedTypeReference<T> typeRef) {
return buildGetRequest(url, queryParams, headers)
.retrieve()
.bodyToMono(typeRef)
.block();
}
public <T> T get(String url, Map<String, Object> queryParams, Map<String, String> headers, Class<T> responseType) {
return buildGetRequest(url, queryParams, headers)
.retrieve()
.bodyToMono(responseType)
.block();
}
// ==================== 2. 基础 POST 请求 ====================
/**
* 同步 POST JSON 请求
*/
public <T> T postJson(String url, Object body, Class<T> responseType) {
return postJson(url, body, null, responseType);
}
public <T> T postJson(String url, Object body, Map<String, String> headers, Class<T> responseType) {
return webClient.post()
.uri(url)
.headers(applyHeaders(headers))
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(body != null ? body : Collections.emptyMap())
.retrieve()
.bodyToMono(responseType)
.block();
}
/**
* 同步 POST Form 表单请求
*/
public <T> T postForm(String url, Map<String, String> formData, Class<T> responseType) {
MultiValueMap<String, String> paramMap = new LinkedMultiValueMap<>();
if (formData != null) {
paramMap.setAll(formData);
}
return webClient.post()
.uri(url)
.headers(applyHeaders(null))
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(BodyInserters.fromFormData(paramMap))
.retrieve()
.bodyToMono(responseType)
.block();
}
// ==================== 3. 文件上传与下载 ====================
/**
* 单/多文件上传
*
* @param url 上传接口
* @param fileMap 文件表单键值对,例如 ("file", multipartFile)
* @param paramMap 附加的普通参数表单
*/
public <T> T uploadFiles(String url, Map<String, MultipartFile> fileMap, Map<String, Object> paramMap, Class<T> responseType) {
Assert.hasText(url, "URL不能为空");
Assert.notEmpty(fileMap, "上传文件不能为空");
MultipartBodyBuilder builder = new MultipartBodyBuilder();
// 填充文件
fileMap.forEach((key, file) -> {
if (file != null && !file.isEmpty()) {
String originalFilename = file.getOriginalFilename();
String contentType = file.getContentType();
builder.part(key, file.getResource())
.filename(StringUtils.hasText(originalFilename) ? originalFilename : "unknown")
.contentType(MediaType.parseMediaType(
StringUtils.hasText(contentType) ? contentType : MediaType.APPLICATION_OCTET_STREAM_VALUE
));
}
});
// 填充普通字段
if (paramMap != null) {
paramMap.forEach(builder::part);
}
return webClient.post()
.uri(url)
.contentType(MediaType.MULTIPART_FORM_DATA)
.headers(applyHeaders(null))
.body(BodyInserters.fromMultipartData(builder.build()))
.retrieve()
.bodyToMono(responseType)
.block();
}
/**
* 快捷单文件上传
*/
public String upload(String url, MultipartFile file) {
return uploadFiles(url, Collections.singletonMap("file", file), null, String.class);
}
/**
* 浏览器下载文件(直接写入 HttpServletResponse)
*/
public void downloadToResponse(String url, String fileName, HttpServletResponse response, Map<String, String> headers) {
Assert.hasText(url, "下载地址不能为空");
Assert.hasText(fileName, "文件名称不能为空");
Assert.notNull(response, "HttpServletResponse 不能为 null");
try {
response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
String encodedFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8.name()).replace("+", "%20");
response.setHeader(HttpHeaders.CONTENT_DISPOSITION,
String.format("attachment; filename=\"%s\"; filename*=UTF-8''%s", encodedFileName, encodedFileName));
Flux<DataBuffer> dataBufferFlux = downloadStream(url, headers);
OutputStream outputStream = response.getOutputStream();
// ========== 手动消费 buffer,避开 DataBufferUtils.write() ==========
dataBufferFlux
.doOnNext(buffer -> {
try {
int readable = buffer.readableByteCount();
if (readable > 0) {
byte[] bytes = new byte[readable];
buffer.read(bytes);
outputStream.write(bytes);
}
} catch (IOException e) {
throw new RuntimeException("写入响应流失败", e);
} finally {
// 必须释放,否则内存泄漏
DataBufferUtils.release(buffer);
}
})
.blockLast();
// ========================================================================
outputStream.flush();
} catch (Exception e) {
throw new RuntimeException("文件下载失败,url: " + url + ", fileName: " + fileName, e);
}
}
/**
* 获取文件流(响应式非阻塞,适用于服务间数据转发)
*/
public Flux<DataBuffer> downloadStream(String url, Map<String, String> headers) {
return webClient.get()
.uri(url)
.headers(applyHeaders(headers))
.retrieve()
.bodyToFlux(DataBuffer.class);
}
// ==================== 4. 辅助私有方法 ====================
private WebClient.RequestHeadersSpec<?> buildGetRequest(String url, Map<String, Object> queryParams, Map<String, String> headers) {
Assert.hasText(url, "URL不能为空");
return webClient.get()
.uri(uriBuilder -> {
uriBuilder.path(url);
if (queryParams != null) {
queryParams.forEach(uriBuilder::queryParam);
}
return uriBuilder.build();
})
.headers(applyHeaders(headers));
}
private Consumer<HttpHeaders> applyHeaders(Map<String, String> customHeaders) {
return httpHeaders -> {
if (customHeaders != null) {
customHeaders.forEach(httpHeaders::set);
}
// 自动补全全局系统 Token
String token = LoginUtils.getToken();
if (StringUtils.hasText(token)) {
httpHeaders.set("hy-token", token);
}
};
}
}
工具使用例子
bash
// 1. GET 请求带 Query 参数并解析为对象
UserDTO user = webClientUtil.get("http://service-b/user/info", Map.of("userId", 123), UserDTO.class);
// 2. GET 请求解析复杂的 List<DTO>
List<UserDTO> list = webClientUtil.get("http://service-b/user/list", null, null, new ParameterizedTypeReference<List<UserDTO>>() {});
// 3. POST 发送 JSON
ResultVO res = webClientUtil.postJson("http://service-b/user/create", createReq, ResultVO.class);
// 4. 上传文件
String result = webClientUtil.upload("http://service-b/file/upload", multipartFile);
// 5. 浏览器下载文件
webClientUtil.downloadToResponse("http://service-b/file/download?id=1", "账单.pdf", response, null);