📚 本系列系统梳理了 Java 开发的详细知识点,从基础语法到工程实践层层递进,内容详实成体系,建议先收藏再慢慢阅读,方便日后随时回顾查阅。

前言

广告系统需要频繁调用外部接口——向 DSP 发送竞价请求、调用素材审核服务、对接第三方数据平台。Java 中发起 HTTP 请求的方式经历了 HttpURLConnectionHttpClientRestTemplateWebClient 的演进。这篇文章讲后两者——它们是 Spring 生态中的标准选择。

1. RestTemplate vs WebClient

维度 RestTemplate WebClient
引入版本 Spring 3(2009) Spring 5(2017)
请求模式 同步阻塞 异步非阻塞(也支持同步)
底层 JDK HttpURLConnection / Apache HttpClient Reactor Netty
官方态度 维护模式(不再新增功能) 推荐,未来方向
适用场景 简单同步调用、传统项目 高并发、响应式、新项目
学习曲线 中(需了解响应式概念)

怎么选? 传统 Spring MVC 项目两个都行,RestTemplate 上手更快。新项目或高并发场景优先 WebClient。

2. RestTemplate

2.1 注册为 Bean

@Configuration
public class RestClientConfig {
    @Bean
    public RestTemplate restTemplate(RestTemplateBuilder builder) {
        return builder
            .connectTimeout(Duration.ofSeconds(3))
            .readTimeout(Duration.ofSeconds(10))
            .build();
    }
}

2.2 GET 请求

@Service
public class UserClient {
    private final RestTemplate restTemplate;

    public UserClient(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    // 基础 GET
    public String getHtml() {
        return restTemplate.getForObject("https://example.com", String.class);
    }

    // GET + 返回 Java 对象(自动 JSON 反序列化)
    public User getUser(Long id) {
        return restTemplate.getForObject(
            "https://api.example.com/users/{id}",
            User.class,
            id    // 路径变量替换
        );
    }

    // GET + 获取完整响应(状态码、响应头、响应体)
    public User getUserWithStatus(Long id) {
        ResponseEntity<User> response = restTemplate.getForEntity(
            "https://api.example.com/users/{id}",
            User.class,
            id
        );
        HttpStatusCode status = response.getStatusCode();  // 200
        HttpHeaders headers = response.getHeaders();
        User body = response.getBody();
        return body;
    }

    // GET + 查询参数
    public List<User> searchUsers(String name, int page) {
        String url = UriComponentsBuilder
            .fromHttpUrl("https://api.example.com/users")
            .queryParam("name", name)
            .queryParam("page", page)
            .queryParam("size", 10)
            .toUriString();
        // https://api.example.com/users?name=Alice&page=1&size=10

        User[] users = restTemplate.getForObject(url, User[].class);
        return Arrays.asList(users);
    }
}

2.3 POST 请求

// POST JSON
public User createUser(String name, String email) {
    Map<String, String> body = Map.of("name", name, "email", email);
    return restTemplate.postForObject(
        "https://api.example.com/users",
        body,       // 自动序列化为 JSON
        User.class  // 响应体反序列化为 User
    );
}

// POST + 自定义请求头
public User createUserWithHeaders(CreateUserRequest req, String token) {
    HttpHeaders headers = new HttpHeaders();
    headers.setContentType(MediaType.APPLICATION_JSON);
    headers.setBearerAuth(token);

    HttpEntity<CreateUserRequest> entity = new HttpEntity<>(req, headers);

    ResponseEntity<User> response = restTemplate.exchange(
        "https://api.example.com/users",
        HttpMethod.POST,
        entity,
        User.class
    );
    return response.getBody();
}

// POST 表单
public String submitForm(String username, String password) {
    HttpHeaders headers = new HttpHeaders();
    headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

    MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
    form.add("username", username);
    form.add("password", password);

    HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(form, headers);

    return restTemplate.postForObject(
        "https://api.example.com/login",
        entity,
        String.class
    );
}
自定义请求头:postForObject 还是 exchange?

上面两个带请求头的例子,一个用 exchange、一个用 postForObject,容易让人以为"自定义请求头必须用 exchange"——其实不是。postForObject 本身也支持传 HttpEntity("POST 表单"那个例子就是这么写的),所以 createUserWithHeaders 改成下面这样同样可以工作:

return restTemplate.postForObject(
    "https://api.example.com/users",
    entity,     // HttpEntity<CreateUserRequest>,带 headers
    User.class
);

两者真正的区别在返回值

方法 返回值 能拿到什么
postForObject T(只有响应体) 反序列化后的 body
exchange ResponseEntity<T> body + 状态码 + 响应头

如果只要响应体,postForObject 更简洁;如果要读响应头(比如创建资源后从 Location 头取新资源地址)或检查状态码是不是 201 Created,就必须用 exchange,因为 postForObject 把这些信息都丢弃了。

另外,exchange 是唯一支持所有 HTTP 方法的方法——2.4 节的 PATCH 没有对应的 patchForObject,只能用 exchange。所以很多人习惯统一用 exchange,不用记 postForObject/putForObject 各自的参数顺序。

2.4 PUT / DELETE / PATCH

// PUT
public void updateUser(Long id, User user) {
    restTemplate.put("https://api.example.com/users/{id}", user, id);
}

// DELETE
public void deleteUser(Long id) {
    restTemplate.delete("https://api.example.com/users/{id}", id);
}

// PATCH(需要用 exchange)
public User patchUser(Long id, Map<String, Object> fields) {
    HttpEntity<Map<String, Object>> entity = new HttpEntity<>(fields);
    ResponseEntity<User> response = restTemplate.exchange(
        "https://api.example.com/users/{id}",
        HttpMethod.PATCH,
        entity,
        User.class,
        id
    );
    return response.getBody();
}

2.5 异常处理

// RestTemplate 在 4xx/5xx 时默认抛异常
try {
    User user = restTemplate.getForObject(url, User.class);
} catch (HttpClientErrorException e) {
    // 4xx 错误
    if (e.getStatusCode() == HttpStatus.NOT_FOUND) {
        return null;
    }
    throw e;
} catch (HttpServerErrorException e) {
    // 5xx 错误
    log.error("服务端错误: {}", e.getResponseBodyAsString());
    throw e;
} catch (ResourceAccessException e) {
    // 连接超时、读取超时
    log.error("请求超时: {}", e.getMessage());
    throw e;
}

// 或者自定义 ErrorHandler 统一处理
@Bean
public RestTemplate restTemplate() {
    RestTemplate rt = new RestTemplate();
    rt.setErrorHandler(new DefaultResponseErrorHandler() {
        @Override
        public void handleError(ClientHttpResponse response) throws IOException {
            if (response.getStatusCode().is4xxClientError()) {
                // 自定义 4xx 处理
            } else {
                super.handleError(response);
            }
        }
    });
    return rt;
}

2.6 拦截器(统一加请求头、日志)

@Bean
public RestTemplate restTemplate() {
    RestTemplate rt = new RestTemplate();
    rt.setInterceptors(List.of((request, body, execution) -> {
        // 统一加请求头
        request.getHeaders().set("X-App-Name", "my-app");
        request.getHeaders().set("X-Request-Id", UUID.randomUUID().toString());

        // 日志
        log.info("HTTP {} {}", request.getMethod(), request.getURI());
        long start = System.currentTimeMillis();

        ClientHttpResponse response = execution.execute(request, body);

        log.info("HTTP {} {} → {} ({}ms)",
            request.getMethod(), request.getURI(),
            response.getStatusCode(), System.currentTimeMillis() - start);

        return response;
    }));
    return rt;
}

3. WebClient(Spring 5+)

3.1 依赖

WebClient 来自 spring-webflux 模块,所以要引入 spring-boot-starter-webflux

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

Gradle 写法:

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-webflux")
}

注意:引入 webflux 不代表你的项目变成了"响应式项目"——你完全可以在一个普通的 Spring MVC(spring-boot-starter-web)项目里,只用 WebClient 来发请求,其他部分照常写同步代码。两个 starter 可以共存,互不冲突。

3.2 创建 WebClient

WebClientWebClient.builder() 这种"建造者模式"来配置——把所有公共配置(域名、默认请求头、日志/鉴权逻辑)在创建时一次性定好,之后每次发请求就不用重复写这些东西了。

@Configuration
public class WebClientConfig {
    @Bean
    public WebClient webClient() {
        return WebClient.builder()
            .baseUrl("https://api.example.com")
            .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
            .defaultHeader("X-App-Name", "my-app")
            .filter(logFilter())     // 日志过滤器
            .build();
    }

    private ExchangeFilterFunction logFilter() {
        return ExchangeFilterFunction.ofRequestProcessor(request -> {
            log.info("HTTP {} {}", request.method(), request.url());
            return Mono.just(request);
        });
    }
}

逐项拆解 builder() 上配置的几个方法:

方法 作用 为什么要配置它
baseUrl("https://api.example.com") 设置这个 WebClient 实例的"基础域名" 之后每次请求只需要写 /users/{id} 这种相对路径,不用每次都拼完整 URL;更重要的是,域名可以做成配置项@Value 注入),不同环境(测试/预发/生产)指向不同的 DSP 地址,代码完全不用改
defaultHeader(...) 给这个 WebClient 发出的所有请求都自动加上某个请求头 比如 Content-Type: application/json、自定义的 X-App-Name 标识——这些是"每次请求都一样"的信息,写一次,所有请求自动带上,避免每个方法里重复写
.filter(...) 注册一个"请求/响应处理函数",所有请求都会经过它 用于日志、鉴权、统一处理这类"横切逻辑",下面单独讲
ExchangeFilterFunction 是什么

ExchangeFilterFunction 可以理解为 WebClient 专属的"过滤器"——和 29 Tomcat 与 Servlet 里讲的 Filter 是同一个思路:每个请求发出去之前、响应回来之后,都会先经过这一层,可以在这里"加点东西"或者"看一眼"

它本质上是一个函数式接口,长这样:

Mono<ClientResponse> filter(ClientRequest request, ExchangeFunction next);
  • request:即将发出的请求
  • next:调用 next.exchange(request) 才会真正把请求发出去
  • 返回值:最终的响应

为了不用每次都写这么复杂的签名,Spring 提供了两个常用的静态工厂方法:

方法 时机 典型用途
ExchangeFilterFunction.ofRequestProcessor(req -> ...) 请求发出前 打印请求日志、注入 token/签名、记录开始时间
ExchangeFilterFunction.ofResponseProcessor(resp -> ...) 响应返回后 打印响应日志、记录耗时、统一处理某些状态码

上面例子里的 logFilter() 用的是 ofRequestProcessor——每次请求发出前,打印一行 HTTP GET /users/1 的日志,然后 return Mono.just(request) 把请求原样传给下一环节(不修改它)。

什么时候调用? 和 Filter 链一样,注册了几个 .filter(...),就有几层"包裹",按注册顺序依次执行"请求前"逻辑,再发出真实请求,响应回来后再按相反顺序执行"响应后"逻辑。日常最常见的用法就是统一打日志统一往请求头里塞鉴权 token(比如调用第三方接口需要签名时)。

3.3 同步调用(block)

调用链每一步返回的是什么

webClient.get().uri(...).retrieve().bodyToMono(User.class).block() 这一长串,每个方法返回的对象都不一样,是一步步"换挡"的过程:

调用 返回类型 这一步在干什么
.get() RequestHeadersUriSpec 声明这是一个 GET 请求,还没指定地址
.uri("/users/{id}", id) RequestHeadersSpec 指定了请求地址,可以继续加请求头,但还没真正发出请求
.retrieve() ResponseSpec 表示"配置完了,准备发起请求并处理响应"——但这一步返回的还不是响应数据,而是一个"响应规格",可以在它上面声明"4xx/5xx 时怎么处理"(即 3.5 节的 onStatus
.bodyToMono(User.class) Mono<User> 这一步才是真正"取数据"的关键:告诉 WebClient"把响应体的 JSON 反序列化成 User 对象",并包装成 Mono<User>
.block() User 阻塞等待,把 Mono<User> 拆开,拿到真正的 User 对象

为什么 retrieve() 后面一定要接一个 bodyTo... 因为 retrieve() 本身只是"发起请求 + 准备好处理响应状态码",它不知道你想把响应体解析成什么类型——是单个对象(User)还是列表(List<User>)、还是干脆不需要返回体(Void)。bodyToMono(Class) / bodyToFlux(Class) / toBodilessEntity() 这些方法的作用就是告诉 WebClient 该用哪种方式解析响应体,所以 retrieve() 必须搭配其中一个一起用,缺了它,请求虽然能发出去,但你拿不到任何结果。

Mono 与 Flux 的区别

可以类比"取餐小票":请求刚发出去,菜还没做好,WebClient 先给你一张小票,等结果真的回来了,再把值"填"进这张小票里。区别在于小票对应几份餐

Mono<T> Flux<T>
代表多少个结果 0 或 1 个 0 到 N 个(一个序列/数据流)
类比 一张小票,对应一份餐 一张小票,对应一筐餐——会一份一份地往外发
典型用法 请求一个用户,响应体是单个 JSON 对象 → bodyToMono(User.class) 请求用户列表,响应体是 JSON 数组 → bodyToFlux(User.class),每个数组元素就是流里的一个元素
拆出最终值 .block() 拿到一个 T(或 null .collectList().block() 把流里所有元素收集成 List<T> 再拿出来;也可以 .toStream() 等方式逐个处理

为什么要区分这两种? 因为"一个结果"和"一连串结果"在响应式编程里处理方式不同——Mono 只需要关心"有没有/到了没",Flux 还需要关心"还有没有下一个、什么时候算结束"。把这两种情况用不同的类型表达出来,编译器和 API 就能帮你避免"把单个结果当成列表处理"之类的错误。

方法 作用
.retrieve() 发起请求,并声明"我要处理这个响应"。如果响应是 4xx/5xx,retrieve() 会自动抛异常(3.5 节会细讲)
.bodyToMono(User.class) 把响应体的 JSON 反序列化成一个 User 对象,包装成 Mono<User>
.bodyToFlux(User.class) 响应是一个 JSON 数组时,反序列化成多个 User,包装成 Flux<User>
.block() 阻塞当前线程,等待 Mono/Flux 真正拿到结果,再把值"拆出来"返回

在传统的 Spring MVC 项目里(每个请求一个线程,本身就是同步模型),调用 .block() 把异步结果转换成同步返回值是很常见的——Controller 方法本身要 return User,不能返回一个"小票"给前端。

@Service
public class UserClient {
    private final WebClient webClient;

    public UserClient(WebClient webClient) {
        this.webClient = webClient;
    }

    // GET
    public User getUser(Long id) {
        return webClient.get()
            .uri("/users/{id}", id)
            .retrieve()
            .bodyToMono(User.class)
            .block();  // 阻塞等待结果(在传统 MVC 项目中常用)
    }

    // GET 列表
    public List<User> listUsers() {
        return webClient.get()
            .uri("/users")
            .retrieve()
            .bodyToFlux(User.class)
            .collectList()
            .block();
    }

    // POST
    public User createUser(CreateUserRequest req) {
        return webClient.post()
            .uri("/users")
            .bodyValue(req)
            .retrieve()
            .bodyToMono(User.class)
            .block();
    }

    // PUT
    public User updateUser(Long id, UpdateUserRequest req) {
        return webClient.put()
            .uri("/users/{id}", id)
            .bodyValue(req)
            .retrieve()
            .bodyToMono(User.class)
            .block();
    }

    // DELETE
    public void deleteUser(Long id) {
        webClient.delete()
            .uri("/users/{id}", id)
            .retrieve()
            .toBodilessEntity()
            .block();
    }
}

3.4 异步调用(不阻塞)

如果项目本身是响应式的(比如用 WebFlux 写 Controller),就不应该调用 .block()——一旦 block(),当前线程就被"卡住"等结果,违背了响应式"不阻塞线程"的初衷。这种场景下,方法直接返回 Mono/Flux,把"什么时候要结果"的决定权交给调用者(最终由 WebFlux 框架在合适的时机去"拆小票")。

// 返回 Mono / Flux,由调用者决定何时获取结果
public Mono<User> getUserAsync(Long id) {
    return webClient.get()
        .uri("/users/{id}", id)
        .retrieve()
        .bodyToMono(User.class);
}

// 并发调用多个接口
public Mono<UserPage> getUserPage(Long userId) {
    Mono<User> userMono = webClient.get()
        .uri("/users/{id}", userId)
        .retrieve().bodyToMono(User.class);

    Mono<List<Order>> ordersMono = webClient.get()
        .uri("/users/{id}/orders", userId)
        .retrieve().bodyToFlux(Order.class).collectList();

    // 两个请求并发执行,全部完成后合并
    return Mono.zip(userMono, ordersMono, (user, orders) ->
        new UserPage(user, orders));
}

Mono.zip(...) 的作用:手里有两张"小票"(两个还没到的结果),等两张小票都兑换成功后,用提供的函数把两个结果合并成一个新对象,再包成一张新的"小票"返回。两个请求是并发发出的,不是"等第一个完成再发第二个"——这正是异步调用相比 block() 的优势:不需要为了等第一个结果而占用线程。

3.5 异常处理

.retrieve() 本身就有"默认的异常处理"——响应状态码是 4xx 或 5xx 时,会自动抛出 WebClientResponseException(不需要你手动判断状态码)。.onStatus(...) 是用来覆盖默认行为的:对特定的状态码,转换成你自己定义的异常类型,方便上层用 catch 区分处理。

public User getUser(Long id) {
    return webClient.get()
        .uri("/users/{id}", id)
        .retrieve()
        .onStatus(HttpStatusCode::is4xxClientError, response -> {
            if (response.statusCode() == HttpStatus.NOT_FOUND) {
                return Mono.error(new ResourceNotFoundException("User not found: " + id));
            }
            return response.bodyToMono(String.class)
                .flatMap(body -> Mono.error(new RuntimeException("Client error: " + body)));
        })
        .onStatus(HttpStatusCode::is5xxServerError, response ->
            Mono.error(new RuntimeException("Server error: " + response.statusCode())))
        .bodyToMono(User.class)
        .block();
}

3.6 超时设置

超时其实分两个层次,作用范围不一样:

层次 设置方式 含义
WebClient 级别(创建时配置一次) HttpClient.create().option(CONNECT_TIMEOUT_MILLIS, ...) + .responseTimeout(...) 对这个 WebClient 发出的所有请求生效——CONNECT_TIMEOUT是"建立TCP连接"的超时,responseTimeout是"等响应头返回"的超时
单次请求级别 在调用链上加 .timeout(Duration...) 只对这一次调用生效,控制"从发出请求到拿到完整响应体"的端到端总时间

实际项目里通常两者都配:WebClient 级别设置一个"兜底"的较大值,单次请求再根据具体接口的 SLA 设置更精确的超时(比如竞价请求要求 100ms 内必须返回,远小于全局默认值)。

@Bean
public WebClient webClient() {
    HttpClient httpClient = HttpClient.create()
        .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000)
        .responseTimeout(Duration.ofSeconds(10));

    return WebClient.builder()
        .baseUrl("https://api.example.com")
        .clientConnector(new ReactorClientHttpConnector(httpClient))
        .build();
}

// 单个请求设置超时
webClient.get()
    .uri("/slow-endpoint")
    .retrieve()
    .bodyToMono(String.class)
    .timeout(Duration.ofSeconds(5))   // 5 秒超时
    .block();

4. 重试与熔断

4.1 WebClient 内置重试

public User getUserWithRetry(Long id) {
    return webClient.get()
        .uri("/users/{id}", id)
        .retrieve()
        .bodyToMono(User.class)
        .retryWhen(Retry.backoff(3, Duration.ofSeconds(1))  // 最多重试 3 次,指数退避
            .filter(ex -> ex instanceof WebClientResponseException.ServiceUnavailable)
            .onRetryExhaustedThrow((spec, signal) ->
                new RuntimeException("重试 3 次后仍然失败")))
        .block();
}

4.2 RestTemplate 重试(Spring Retry)

dependencies {
    implementation("org.springframework.retry:spring-retry")
    implementation("org.springframework:spring-aspects")
}
@Service
public class UserClient {
    private final RestTemplate restTemplate;

    @Retryable(
        retryFor = ResourceAccessException.class,   // 只对超时重试
        maxAttempts = 3,
        backoff = @Backoff(delay = 1000, multiplier = 2)  // 1s → 2s → 4s
    )
    public User getUser(Long id) {
        return restTemplate.getForObject(
            "https://api.example.com/users/{id}", User.class, id);
    }

    @Recover  // 重试全部失败后的兜底方法
    public User getUserFallback(ResourceAccessException e, Long id) {
        log.error("获取用户失败,返回默认值: id={}", id);
        return new User("Unknown", "unknown@example.com");
    }
}

5. 广告系统中的 HTTP 调用实战

5.1 竞价请求(低延迟要求)

@Service
public class BidClient {
    private final WebClient webClient;

    public BidClient(WebClient.Builder builder) {
        HttpClient httpClient = HttpClient.create()
            .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 500)   // 连接超时 500ms
            .responseTimeout(Duration.ofMillis(100));            // 响应超时 100ms

        this.webClient = builder
            .clientConnector(new ReactorClientHttpConnector(httpClient))
            .build();
    }

    // 并发向多个 DSP 发送竞价请求
    public List<BidResponse> sendBidRequests(BidRequest request, List<String> dspUrls) {
        List<Mono<BidResponse>> monos = dspUrls.stream()
            .map(url -> webClient.post()
                .uri(url)
                .bodyValue(request)
                .retrieve()
                .bodyToMono(BidResponse.class)
                .timeout(Duration.ofMillis(150))
                .onErrorResume(e -> {
                    log.warn("DSP {} 请求失败: {}", url, e.getMessage());
                    return Mono.empty();  // 某个 DSP 失败不影响其他
                }))
            .collect(Collectors.toList());

        return Flux.merge(monos)
            .collectList()
            .block(Duration.ofMillis(200));  // 最多等 200ms
    }
}

5.2 素材审核回调(可靠性要求)

@Service
public class AuditCallbackClient {
    private final RestTemplate restTemplate;

    @Retryable(maxAttempts = 5, backoff = @Backoff(delay = 2000, multiplier = 2))
    public void notifyAuditResult(String callbackUrl, AuditResult result) {
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        headers.set("X-Signature", sign(result));

        HttpEntity<AuditResult> entity = new HttpEntity<>(result, headers);
        restTemplate.postForEntity(callbackUrl, entity, Void.class);
    }
}

6. RestTemplate vs WebClient 选型速查

场景 推荐
简单的同步调用 RestTemplate(代码最少)
需要并发调用多个接口 WebClient(Mono.zip / Flux.merge)
低延迟高并发(竞价) WebClient(非阻塞,不占线程)
传统 Spring MVC 项目 都行,WebClient + block() 也可以
响应式项目(WebFlux) 只能用 WebClient
需要重试 / 熔断 WebClient 内置 retryWhen;RestTemplate 用 Spring Retry

7. 小结

主题 关键要点
RestTemplate 同步阻塞,API 直观;getForObject / postForObject / exchange
请求定制 HttpHeaders + HttpEntity 控制请求头和请求体
异常处理 4xx → HttpClientErrorException,5xx → HttpServerErrorException
拦截器 统一加请求头、日志、监控
WebClient 异步非阻塞,链式 API;.block() 可以当同步用
并发调用 Mono.zip 合并多个请求,Flux.merge 取最快返回的
超时设置 连接超时 + 响应超时分开设置
重试 WebClient 用 retryWhen,RestTemplate 用 @Retryable
广告场景 竞价请求用 WebClient 并发 + 短超时;回调通知用 RestTemplate + 重试

系列完结。从 Java 语言基础到 Spring Boot 实战、缓存、HTTP 客户端,整个系列覆盖了一个 Java 后端开发者入职前需要掌握的完整知识体系。


🎯 如果这篇文章对你有帮助,别忘了点赞、收藏、关注三连!关注我,让你在 Java 学习的道路上不迷路,持续为你带来成体系的 Java 干货~

更多推荐