Gọi API khác: RestTemplate, RestClient, WebClient, @HttpExchange
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 đề
new RestTemplate()trong method: mỗi lần tạo client mới, không pool, không timeout.- Không timeout: payment-service treo → thread Tomcat của shop-app treo theo → hết 200 thread → shop-app chết dây chuyền (xem Tomcat threads, timeouts).
- Lỗi 402 thành
HttpClientErrorExceptionchung chung, controller trả 500. - Retry bừa bãi POST thanh toán → trừ tiền 2 lần.
3. Giải pháp
| Client | Kiểu | Trạng thái |
|---|---|---|
| RestTemplate | Blocking, API template cũ | Maintenance mode: không thêm tính năng |
| RestClient (Boot 3.2+) | Blocking, fluent API | Lựa chọn mặc định cho MVC |
| WebClient | Non-blocking, Reactor | Cho WebFlux / streaming (xem WebClient) |
| @HttpExchange | Interface khai báo | Chạy trên RestClient hoặc WebClient |
| OpenFeign | Interface khai báo, Spring Cloud | Xem 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
| RestTemplate | RestClient | WebClient | @HttpExchange | |
|---|---|---|---|---|
| Blocking | Có | Có | Không (có .block()) | Theo adapter |
| API | getForObject, exchange | Fluent | Fluent + Mono/Flux | Interface |
| Thêm dependency | Không | Không | webflux | Không |
| Code mới | Không nên | Nê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
- MVC, blocking (kể cả virtual threads): RestClient.
- Gọi song song nhiều service hoặc streaming: WebClient, hoặc RestClient + virtual threads / CompletableFuture.
- Code cũ RestTemplate: không cần viết lại gấp;
RestClient.create(restTemplate)giúp chuyển dần. - Không thêm webflux vào app MVC chỉ để có WebClient.
- Không retry POST không idempotent; luôn đặt timeout; thêm circuit breaker khi phụ thuộc quan trọ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
- Pool cạn (mặc định HttpClient chỉ 5 connection/route!) mà không có connectionRequestTimeout → thread xếp hàng vô hạn.
- Host sập, gói tin bị drop → không có connect timeout thì chờ tới timeout của OS (có thể vài phút).
- Payment-service nhận rồi xử lý chậm → read 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
- MVC → RestClient; WebFlux → WebClient; nhiều endpoint → @HttpExchange.
- Một bean, có pool; ba timeout.
- onStatus → exception nghiệp vụ.
- Retry chỉ lỗi tạm thời + idempotent.
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).