Spring WebClient 使用指南

目录

什么是 WebClient

WebClient 是 Spring 5 引入的一个非阻塞、响应式的 HTTP 客户端,属于 Spring WebFlux 框架的一部分,是官方推荐的 RestTemplate 现代化替代方案。

核心特点

特点说明
非阻塞 I/O使用少量线程处理大量并发请求,资源利用率高
响应式编程基于 Reactor,返回 Mono/Flux,支持异步和流式处理
流式 API提供流畅的链式调用风格,代码简洁直观
功能丰富支持同步/异步、流式数据、大文件传输、超时重试、熔断等

适用场景

一、添加依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
注意:若在代码中直接使用 Jackson 的 JsonNode 解析响应,需确保 jackson-databind 依赖存在。较新版本 Spring Boot 可能不会自动传递引入,若报「程序包 com.fasterxml.jackson.databind 不存在」,需显式添加:
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
</dependency>

二、创建 WebClient 实例

// 方式一:默认设置(无基础 URL)
WebClient client = WebClient.create();

// 方式二:指定基础 URL
WebClient client = WebClient.create("https://api.example.com");

// 方式三:使用 Builder 自定义(推荐)
WebClient client = WebClient.builder()
        .baseUrl("https://api.example.com")
        .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
        .build();

Spring Boot 中推荐:通过 @Configuration 定义成 Bean,便于统一管理和注入

@Configuration
public class AppConfig {
    @Value("${external.api.base-url}")
    private String baseUrl;

    @Bean
    public WebClient webClient() {
        return WebClient.builder()
                .baseUrl(baseUrl)
                .build();
    }
}

三、发送请求

1. GET 请求(异步,返回单个对象)

public Mono<User> fetchUser(String userId) {
    return webClient.get()
            .uri("/users/{id}", userId)
            .retrieve()
            .bodyToMono(User.class);
}

2. GET 请求(异步,返回对象列表)

public Flux<Order> fetchUserOrders(String userId) {
    return webClient.get()
            .uri("/orders?userId={userId}", userId)
            .retrieve()
            .bodyToFlux(Order.class);
}

3. GET 请求(同步/阻塞)

public User fetchUserSync(String userId) {
    return webClient.get()
            .uri("/users/{id}", userId)
            .retrieve()
            .bodyToMono(User.class)
            .block();   // block() 阻塞等待结果,不推荐在响应式链路中使用
}

4. POST 请求(带请求体)

public Mono<User> createUser(User newUser) {
    return webClient.post()
            .uri("/users")
            .bodyValue(newUser)
            .retrieve()
            .bodyToMono(User.class);
}

5. 使用 UriBuilder 动态拼接 URL(推荐)

webClient.get()
        .uri(uriBuilder -> uriBuilder
                .path("/v1/search")
                .queryParam("name", location)   // 中文会自动 URL 编码
                .queryParam("count", 1)
                .build())
        .retrieve()
        .bodyToMono(JsonNode.class);

四、错误处理与空值处理(重要)

1. 处理 HTTP 4xx/5xx 状态码:onErrorMap / onStatus

retrieve() 遇到 4xx/5xx 会直接抛 WebClientResponseException,需转换成自己的业务异常,否则会被全局兜底当成 500 处理。

return webClient.get()
        .uri("/posts/{id}", id)
        .retrieve()
        .bodyToMono(PostVo.class)
        .onErrorMap(WebClientResponseException.NotFound.class,
                e -> new BusinessException("帖子不存在"));

2. 处理空响应体:switchIfEmpty

return webClient.get()
        .uri("/posts/{id}", id)
        .retrieve()
        .bodyToMono(PostVo.class)
        .switchIfEmpty(Mono.error(new BusinessException("帖子不存在")));

小结:

五、多个 WebClient Bean 的注入:@Qualifier

当项目中定义了多个类型相同的 WebClient Bean 时,Spring 无法确定注入哪一个,会报 NoUniqueBeanDefinitionException,需用 @Qualifier 按名字指定。

// 定义处(AppConfig)——给每个 Bean 起名字
@Bean
@Qualifier("weatherWebClient")
public WebClient weatherWebClient() { ... }

@Bean
@Qualifier("geocodingWebClient")
public WebClient geocodingWebClient() { ... }

// 注入处(Service)——手写构造器,给参数加 @Qualifier
public WeatherServiceImpl(
        @Qualifier("weatherWebClient") WebClient weatherWebClient,
        @Qualifier("geocodingWebClient") WebClient geocodingWebClient) {
    this.weatherWebClient = weatherWebClient;
    this.geocodingWebClient = geocodingWebClient;
}
注意:不能用 Lombok 的 @RequiredArgsConstructor 自动生成构造器,因为 Lombok 不会自动给参数加 @Qualifier,会导致多个同类型 Bean 注入失败。

六、高级配置(超时、重试、熔断)

WebClient 支持配置超时时间(如 HttpClient 的 responseTimeout),并能与 Resilience4j 等库集成,实现失败重试、熔断和超时控制。

七、实战示例:基于 WebClient 实现天气查询

需求

提供两个接口,以中文返回天气信息:

  1. 获取「当前位置」天气(根据请求方 IP 自动定位)
  2. 获取「输入地点」天气(根据地名查询)

涉及的三个外部 API(均免费、无需 API Key)

用途API 地址说明
天气数据https://api.open-meteo.com/v1/forecast只接受经纬度查询,返回温度、湿度、风速、天气代码等
地理编码https://geocoding-api.open-meteo.com/v1/search输入城市名,返回经纬度
IP 定位http://ip-api.com/json/{ip}输入 IP,返回经纬度和城市(免费版仅 HTTP)

示例请求地址:

# 天气数据
https://api.open-meteo.com/v1/forecast?latitude=39.90&longitude=116.40&current=temperature_2m,weather_code&timezone=auto

# 地理编码
https://geocoding-api.open-meteo.com/v1/search?name=%E5%8C%97%E4%BA%AC&count=1&language=zh&format=json

# IP 定位
http://ip-api.com/json/8.8.8.8?lang=zh-CN&fields=status,lat,lon,city

实现步骤

步骤 1:配置文件(application.properties)

spring.application.name=Thread

# 天气 API 相关地址
weather.api.base-url=https://api.open-meteo.com
weather.api.geocoding-url=https://geocoding-api.open-meteo.com
weather.api.ip-url=http://ip-api.com/json/{ip}?lang=zh-CN&fields=status,lat,lon,city

步骤 2:配置多个 WebClient Bean(AppConfig.java)

@Configuration
public class AppConfig {

    @Value("${weather.api.base-url}")
    private String weatherBaseUrl;

    @Value("${weather.api.geocoding-url}")
    private String geocodingBaseUrl;

    @Bean
    @Qualifier("weatherWebClient")
    public WebClient weatherWebClient() {
        return WebClient.builder().baseUrl(weatherBaseUrl).build();
    }

    @Bean
    @Qualifier("geocodingWebClient")
    public WebClient geocodingWebClient() {
        return WebClient.builder().baseUrl(geocodingBaseUrl).build();
    }
}

步骤 3:定义数据对象(dto / vo)

// dto:经纬度定位结果
public record GeoLocation(String city, double latitude, double longitude) {
}

// vo:返回给前端的天气信息(中文)
@Data
@Builder
public class WeatherVo {
    private String city;                 // 城市
    private Double latitude;             // 纬度
    private Double longitude;            // 经度
    private Double temperature;          // 当前温度
    private Double apparentTemperature;  // 体感温度
    private Integer humidity;            // 湿度
    private Double precipitation;        // 降水量
    private Double windSpeed;            // 风速
    private String weather;              // 天气现象
    private String weatherDetail;        // 详细描述
}

步骤 4:实现 Service(WeatherServiceImpl.java)

@Service
public class WeatherServiceImpl implements WeatherService {

    private final WebClient weatherWebClient;      // 查天气
    private final WebClient geocodingWebClient;    // 地名转经纬度
    private final WebClient externalWebClient;     // 发完整 URL(IP 定位)
    private final String ipUrl;

    public WeatherServiceImpl(
            @Qualifier("weatherWebClient") WebClient weatherWebClient,
            @Qualifier("geocodingWebClient") WebClient geocodingWebClient,
            @Qualifier("externalWebClient") WebClient externalWebClient,
            @Value("${weather.api.ip-url}") String ipUrl) {
        this.weatherWebClient = weatherWebClient;
        this.geocodingWebClient = geocodingWebClient;
        this.externalWebClient = externalWebClient;
        this.ipUrl = ipUrl;
    }

    // 输入地点天气:地名 -> 经纬度 -> 天气
    public Mono<WeatherVo> getWeatherByLocation(String location) {
        return resolveByLocation(location)   // 第一步:地名转经纬度
                .flatMap(this::fetchWeather) // 第二步:经纬度查天气
                .switchIfEmpty(Mono.error(new BusinessException("未找到该地点的天气信息")));
    }

    // 地名转经纬度
    private Mono<GeoLocation> resolveByLocation(String location) {
        return geocodingWebClient.get()
                .uri(uriBuilder -> uriBuilder
                        .path("/v1/search")
                        .queryParam("name", location)
                        .queryParam("count", 1)
                        .queryParam("language", "zh")
                        .queryParam("format", "json")
                        .build())
                .retrieve()
                .bodyToMono(JsonNode.class)
                .map(node -> {
                    JsonNode results = node.path("results");
                    if (!results.isArray() || results.isEmpty()) {
                        throw new BusinessException("未找到该地点:" + location);
                    }
                    JsonNode first = results.get(0);
                    double lat = first.path("latitude").asDouble();
                    double lon = first.path("longitude").asDouble();
                    String city = first.path("name").asText();
                    return new GeoLocation(city, lat, lon);
                });
    }

    // 查天气(经纬度 -> 天气,转换天气代码为中文)
    private Mono<WeatherVo> fetchWeather(GeoLocation geo) {
        return weatherWebClient.get()
                .uri(uriBuilder -> uriBuilder
                        .path("/v1/forecast")
                        .queryParam("latitude", geo.latitude())
                        .queryParam("longitude", geo.longitude())
                        .queryParam("current", "temperature_2m,apparent_temperature,relative_humidity_2m,precipitation,weather_code,wind_speed_10m")
                        .queryParam("timezone", "auto")
                        .build())
                .retrieve()
                .bodyToMono(JsonNode.class)
                .map(node -> {
                    JsonNode current = node.path("current");
                    int code = current.path("weather_code").asInt();
                    return WeatherVo.builder()
                            .city(geo.city())
                            .latitude(geo.latitude())
                            .longitude(geo.longitude())
                            .temperature(current.path("temperature_2m").asDouble())
                            .apparentTemperature(current.path("apparent_temperature").asDouble())
                            .humidity(current.path("relative_humidity_2m").asInt())
                            .precipitation(current.path("precipitation").asDouble())
                            .windSpeed(current.path("wind_speed_10m").asDouble())
                            .weather(WeatherCode.toText(code))
                            .weatherDetail(WeatherCode.toDetail(code))
                            .build();
                });
    }
}

步骤 5:实现 Controller(WeatherController.java)

@RestController
@RequestMapping("/api/weather")
@RequiredArgsConstructor
@Validated
public class WeatherController {

    private final WeatherService weatherService;

    // 获取当前所在地天气(根据请求方 IP 定位)
    @GetMapping("/current")
    public Mono<Result<WeatherVo>> currentWeather(ServerHttpRequest request) {
        String ip = resolveClientIp(request);
        return weatherService.getCurrentWeather(ip).map(Result::success);
    }

    // 根据输入地点获取天气
    @GetMapping("/location")
    public Mono<Result<WeatherVo>> weatherByLocation(
            @RequestParam @NotBlank(message = "地点不能为空") String location) {
        return weatherService.getWeatherByLocation(location).map(Result::success);
    }
}

调用示例(接口返回中文天气)

请求:

GET /api/weather/location?location=北京

返回:

{
    "code": "200",
    "message": "操作成功",
    "data": {
        "city": "北京",
        "latitude": 39.9042,
        "longitude": 116.4074,
        "temperature": 26.4,
        "apparentTemperature": 27.1,
        "humidity": 55,
        "precipitation": 0.0,
        "windSpeed": 12.6,
        "weather": "局部多云",
        "weatherDetail": "局部多云"
    }
}

关键要点总结

  1. 查天气必须先拿到经纬度:Open-Meteo 只认经纬度,不认地名或 IP
  2. 地名 -> 经纬度:用 geocoding API;IP -> 经纬度:用 ip-api.com
  3. 用 flatMap 串联「先定位、再查天气」两步异步操作
  4. 用 UriBuilder 拼接 URL,自动处理中文编码
  5. 天气代码(weather_code)需查表翻译成中文描述
  6. IP 定位在本地开发时(内网/回环地址)可能定位不准,生产环境(公网 IP)效果更好