Gọi API khác: RestTemplate, RestClient, WebClient, @HttpExchange

4 câu hỏi~25 phútTừ khóa: RestTemplate, RestClient, WebClient, HTTP interface, @HttpExchange, HttpServiceProxyFactory, connection pool, connect timeout, read timeout, onStatus, retry, idempotency

Tiếp đi: shop-app gọi payment-service. Mình thấy có RestTemplate, RestClient, WebClient, rồi cả @HttpExchange, nên chọn cái nào?

Câu hỏi rất hay gặp. Trả lời ngắn: app Spring MVC trên Boot 3.2+ thì dùng RestClient, có thể bọc thêm @HttpExchange cho gọn. Giờ đi từ bài toán.

1. Bài toán

Khi khách thanh toán, OrderService gọi:

POST http://payment-service/api/payments
{ "orderId": 42, "amount": 250000, "currency": "VND" }
→ 201 { "paymentId": "pay_9x", "status": "AUTHORIZED" }
→ 402 { "type": ".../card-declined" }
→ 503 khi payment-service quá tải

2. Vấn đề

3. Giải pháp

ClientKiểuTrạng thái
RestTemplateBlocking, API template cũMaintenance mode: không thêm tính năng
RestClient (Boot 3.2+)Blocking, fluent APILựa chọn mặc định cho MVC
WebClientNon-blocking, ReactorCho WebFlux / streaming (xem WebClient)
@HttpExchangeInterface khai báoChạy trên RestClient hoặc WebClient
OpenFeignInterface khai báo, Spring CloudXem OpenFeign

Ba thứ phải cấu hình cho bất kỳ client nào: connection pool, timeout, xử lý lỗi. Retry chỉ thêm khi an toàn.

4. Code từng bước

Bước 1: Dependency

<dependency>
  <groupId>org.apache.httpcomponents.client5</groupId>
  <artifactId>httpclient5</artifactId>   <!-- có pool; Boot tự nhận -->
</dependency>
<dependency>
  <groupId>org.springframework.retry</groupId>
  <artifactId>spring-retry</artifactId>
</dependency>

Bước 2: Config

payment:
  base-url: ${PAYMENT_URL:http://payment-service:8080}
  api-key: ${PAYMENT_API_KEY}

Bước 3: Code Java

Một bean RestClient dùng chung (thread-safe), có pool và timeout:

@Configuration
public class PaymentClientConfig {

    @Bean
    RestClient paymentRestClient(RestClient.Builder builder,
                                 @Value("${payment.base-url}") String baseUrl,
                                 @Value("${payment.api-key}") String apiKey) {
        var pool = PoolingHttpClientConnectionManagerBuilder.create()
                .setMaxConnTotal(100)
                .setMaxConnPerRoute(50)                    // mặc định chỉ 5/route!
                .setDefaultConnectionConfig(ConnectionConfig.custom()
                        .setConnectTimeout(Timeout.ofSeconds(1))
                        .setSocketTimeout(Timeout.ofSeconds(3))
                        .build())
                .build();
        var httpClient = HttpClients.custom()
                .setConnectionManager(pool)
                .setDefaultRequestConfig(RequestConfig.custom()
                        .setConnectionRequestTimeout(Timeout.ofMilliseconds(500)) // chờ lấy connection từ pool
                        .build())
                .build();

        return builder                                  // builder của Boot: có Jackson, observation
                .baseUrl(baseUrl)
                .requestFactory(new HttpComponentsClientHttpRequestFactory(httpClient))
                .defaultHeader("X-Api-Key", apiKey)
                .defaultStatusHandler(HttpStatusCode::is5xxServerError, (req, res) -> {
                    throw new PaymentUnavailableException("payment-service " + res.getStatusCode());
                })
                .build();
    }
}

Gọi API, xử lý lỗi theo status bằng onStatus:

@Component
@RequiredArgsConstructor
public class PaymentGateway {

    private final RestClient paymentRestClient;

    public PaymentResult authorize(Order order) {
        return paymentRestClient.post()
                .uri("/api/payments")
                .header("Idempotency-Key", "order-" + order.getId())   // payment-service dedupe
                .contentType(MediaType.APPLICATION_JSON)
                .body(new PaymentRequest(order.getId(), order.getTotal(), "VND"))
                .retrieve()
                .onStatus(s -> s.value() == 402, (req, res) -> {
                    throw new PaymentDeclinedException(order.getId());
                })
                .onStatus(HttpStatusCode::is4xxClientError, (req, res) -> {
                    throw new PaymentRequestException(new String(res.getBody().readAllBytes()));
                })
                .body(PaymentResult.class);
    }
}

Retry chỉ cho lỗi tạm thời, và chỉ vì đã có Idempotency-Key:

@EnableRetry @Configuration class RetryConfig {}

@Retryable(retryFor = {PaymentUnavailableException.class, ResourceAccessException.class},
           maxAttempts = 3, backoff = @Backoff(delay = 200, multiplier = 2, random = true))
public PaymentResult authorize(Order order) { ... }
// ResourceAccessException = IO error / timeout. Không retry 4xx.

Gọn hơn bằng HTTP interface:

@HttpExchange("/api/payments")
public interface PaymentApi {
    @PostExchange
    PaymentResult authorize(@RequestHeader("Idempotency-Key") String key,
                            @RequestBody PaymentRequest request);

    @GetExchange("/{id}")
    PaymentResult get(@PathVariable String id);
}

@Bean
PaymentApi paymentApi(RestClient paymentRestClient) {
    return HttpServiceProxyFactory
            .builderFor(RestClientAdapter.create(paymentRestClient))
            .build()
            .createClient(PaymentApi.class);
}

Bước 4: Test bằng curl

curl -i -X POST localhost:8080/api/orders/42/pay
# payment OK       → 200 {"status":"PAID"}
# card declined    → 402 → PaymentDeclinedException → advice trả 422 cho client
# payment chết     → retry 3 lần (200ms, 400ms, ...) → 503 ProblemDetail
# payment treo 10s → read timeout 3s → ResourceAccessException → retry → 503

Trong test tự động, dùng @RestClientTest + MockRestServiceServer hoặc WireMock để giả 402/503/chậm.

5. Cách hoạt động

PaymentGateway.authorize()
   │
   ▼
RestClient ── Jackson ghi body ──► ClientHttpRequestFactory (HttpComponents)
                                        │
                        lấy connection từ pool (chờ ≤ 500ms)
                                        │ chưa có → TCP connect (≤ 1s)
                                        ▼
                              gửi request, chờ byte đầu (≤ 3s)
                                        │
   ◄── status ─────────────────────────┘
   onStatus(402)  → PaymentDeclinedException
   default 5xx    → PaymentUnavailableException → @Retryable thử lại
   2xx            → Jackson đọc PaymentResult
   connection trả về pool (keep-alive)

6. So sánh

RestTemplateRestClientWebClient@HttpExchange
BlockingCóCóKhông (có .block())Theo adapter
APIgetForObject, exchangeFluentFluent + Mono/FluxInterface
Thêm dependencyKhôngKhôngwebfluxKhông
Code mớiKhông nênNên (MVC)Nên (WebFlux)Nên khi nhiều endpoint

7. Khi nào nên dùng / không nên dùng

Bài 9.10 đã nói connect timeout và read timeout. Cấu hình RestClient còn có thêm connection request timeout, cái này là gì? Mình chỉ đặt một cái được không?

Không đủ. Ba cái canh ba giai đoạn khác nhau:

[chờ connection rảnh trong pool] → [mở TCP] → [chờ dữ liệu trả về]
   connectionRequestTimeout         connect      read/socket timeout

Lưu ý read timeout là thời gian giữa hai lần nhận byte, không phải tổng thời gian request. Muốn giới hạn tổng thì cần deadline ở tầng trên (ví dụ Resilience4j TimeLimiter). Chi tiết ở bài timeouts.

RestTemplate bị deprecated rồi đúng không? Dự án cũ phải bỏ hết à?

Chỉnh một điểm: RestTemplate chưa deprecated trong Spring 6, chỉ ở maintenance mode: vẫn được sửa lỗi, không thêm tính năng. Spring đã thông báo kế hoạch deprecate ở các bản sau, nên code mới đừng dùng.

Dự án cũ chuyển dần được vì RestClient dùng chung hạ tầng (ClientHttpRequestFactory, message converters, interceptors):

RestClient client = RestClient.create(existingRestTemplate);   // tái dùng cấu hình cũ
// hoặc
RestClient client = RestClient.builder(existingRestTemplate).baseUrl(...).build();

Phỏng vấn hỏi về gọi REST API từ Spring thì trả lời sao?

Q1. RestTemplate, RestClient, WebClient khác nhau thế nào?

🇻🇳 Tiếng Việt:

RestTemplate là client blocking kiểu cũ, đang ở maintenance mode. RestClient từ Spring 6.1 cũng blocking nhưng API fluent và dùng chung hạ tầng với RestTemplate, là lựa chọn mặc định cho app MVC. WebClient là non-blocking dựa trên Reactor, hợp với WebFlux hoặc streaming. Cả RestClient và WebClient đều làm backend được cho HTTP interface @HttpExchange.

🇬🇧 English:

RestTemplate is the old blocking client and is in maintenance mode. RestClient, since Spring 6.1, is also blocking but has a fluent API and shares the same infrastructure, so it is the default choice for MVC apps. WebClient is non-blocking and based on Reactor, which suits WebFlux or streaming. Both RestClient and WebClient can back an @HttpExchange HTTP interface.

Q2. Cấu hình gì để gọi service khác an toàn?

🇻🇳 Tiếng Việt:

Một client bean dùng chung với connection pool đủ lớn. Ba timeout: chờ connection từ pool, connect và read. Map lỗi theo status bằng onStatus thành exception nghiệp vụ. Retry có backoff chỉ cho lỗi tạm thời như 503 hay timeout, và chỉ với thao tác idempotent hoặc có Idempotency-Key. Thêm circuit breaker cho phụ thuộc quan trọng.

🇬🇧 English:

One shared client bean with a properly sized connection pool. Three timeouts: waiting for a pooled connection, connecting, and reading. Map errors by status with onStatus into domain exceptions. Retry with backoff only for transient failures like 503 or timeouts, and only for idempotent calls or calls with an idempotency key. Add a circuit breaker for critical dependencies.

Q3. Vì sao không retry POST thanh toán một cách mù quáng?

🇻🇳 Tiếng Việt:

Timeout không có nghĩa là thất bại: payment-service có thể đã trừ tiền nhưng response bị mất. Retry sẽ trừ lần hai. Cần Idempotency-Key để phía nhận nhận ra request lặp và trả lại kết quả cũ.

🇬🇧 English:

A timeout does not mean failure: the payment service may have charged the card and only the response was lost. A blind retry would charge again. An idempotency key lets the receiver detect the duplicate and return the original result.

Cách nhớ ngắn gọn


Tự kiểm tra

Vì sao không new RestTemplate() trong mỗi lần gọi?

Không tái dùng connection pool, không có timeout và cấu hình chung; tốn kết nối TCP mới mỗi lần.

Pool HttpClient 5 mặc định có bao nhiêu connection mỗi route?

5 (tổng 25), phải tăng maxConnPerRoute cho service gọi nhiều.

Timeout khi gọi RestClient ném exception gì?

ResourceAccessException (bọc IOException / SocketTimeoutException).

Retry nào an toàn: GET 503, POST 400, POST 503 có Idempotency-Key?

GET 503 và POST 503 có Idempotency-Key. Không retry 400.

Cần gì để dùng @HttpExchange với RestClient?

HttpServiceProxyFactory.builderFor(RestClientAdapter.create(restClient)).build().createClient(Api.class).