Gọi API bên ngoài với RestClient (Spring Boot 3.2+)
1. Tổng quan
Từ Spring Framework 6.1 (đi kèm Spring Boot 3.2), Spring có thêm RestClient. Đây là HTTP client đồng bộ (blocking), dùng API fluent giống WebClient nhưng không cần Reactor. Bài này hướng dẫn bạn tạo RestClient, gọi GET/POST, xử lý lỗi, cấu hình timeout, thêm interceptor và viết test. Đọc xong, bạn có thể thay RestTemplate bằng RestClient trong dự án mới.
2. Cài đặt
RestClient nằm trong module spring-web. Starter web đã có sẵn module này và Jackson để chuyển đổi JSON:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
Xuyên suốt bài, ta gọi một API đơn hàng giả định tại https://api.shop.example. Ta khai báo dữ liệu bằng record:
public record Order(Long id, String customerName, BigDecimal total, String status) {}
public record CreateOrderRequest(String customerName, BigDecimal total) {}
Base URL được đặt trong application.properties:
shop.api.base-url=https://api.shop.example
3. Tạo RestClient
3.1. Cách nhanh nhất
RestClient restClient = RestClient.create("https://api.shop.example");
Cách này phù hợp khi viết thử hoặc chạy script. Trong ứng dụng Spring Boot, bạn nên dùng builder do Boot cung cấp.
3.2. Dùng RestClient.Builder của Spring Boot
Spring Boot tự cấu hình một bean RestClient.Builder. Builder này đã gắn sẵn các HttpMessageConverter của ứng dụng, ví dụ ObjectMapper mà bạn đã tùy chỉnh. Nó cũng hỗ trợ observability qua Micrometer.
@Component
public class OrderClient {
private final RestClient restClient;
public OrderClient(RestClient.Builder builder,
@Value("${shop.api.base-url}") String baseUrl) {
this.restClient = builder
.baseUrl(baseUrl)
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.build();
}
}
Giải thích:
baseUrl(...): các lời gọi sau chỉ cần truyền đường dẫn tương đối như/orders/42.defaultHeader(...): header này được gắn vào mọi request.build(): tạo instanceRestClient. Instance này thread-safe, nên bạn tạo một lần và dùng lại.
Lưu ý: Bean
RestClient.Buildercó scopeprototype. Mỗi lần inject, bạn nhận một builder mới. Vì vậy, cấu hình ởOrderClientkhông ảnh hưởng tới client khác. Muốn áp dụng cấu hình chung cho mọi builder, hãy khai báo beanRestClientCustomizer.
4. Gửi request GET
4.1. Lấy body thành object
public Order getOrder(Long id) {
return restClient.get()
.uri("/orders/{id}", id)
.retrieve()
.body(Order.class);
}
get(): chọn phương thức HTTP.uri("/orders/{id}", id): biến{id}được thay và encode tự động. Kết quả làGET https://api.shop.example/orders/42.retrieve(): gửi request và chuẩn bị đọc response.body(Order.class): Jackson chuyển JSON thành recordOrder.
4.2. Lấy cả status và header
Khi cần status code hoặc header, bạn dùng toEntity:
ResponseEntity<Order> response = restClient.get()
.uri("/orders/{id}", 42)
.retrieve()
.toEntity(Order.class);
System.out.println(response.getStatusCode());
System.out.println(response.getHeaders().getContentType());
System.out.println(response.getBody());
200 OK
application/json
Order[id=42, customerName=Lan, total=250000, status=PAID]
4.3. Lấy danh sách và query param
Kiểu generic như List<Order> bị xóa ở runtime. Vì vậy, bạn cần ParameterizedTypeReference:
public List<Order> findOrders(String status, int page) {
return restClient.get()
.uri(uriBuilder -> uriBuilder
.path("/orders")
.queryParam("status", status)
.queryParam("page", page)
.build())
.retrieve()
.body(new ParameterizedTypeReference<List<Order>>() {});
}
Lời gọi findOrders("PAID", 1) tạo ra request GET /orders?status=PAID&page=1. Base URL vẫn được giữ khi bạn dùng uriBuilder.
5. Gửi request POST, PUT, DELETE
public Order createOrder(CreateOrderRequest request) {
return restClient.post()
.uri("/orders")
.contentType(MediaType.APPLICATION_JSON)
.body(request)
.retrieve()
.body(Order.class);
}
public void cancelOrder(Long id) {
restClient.delete()
.uri("/orders/{id}", id)
.retrieve()
.toBodilessEntity();
}
body(request): object được serialize thành JSON nhờcontentTypeđã khai báo.toBodilessEntity(): dùng khi bạn không cần đọc body. Phương thức này vẫn kích hoạt request và xử lý lỗi.
put() và patch() dùng y hệt post().
Lưu ý: Nếu bạn gọi
retrieve()mà không gọi tiếpbody(...),toEntity(...)haytoBodilessEntity(), request sẽ không được gửi. Đây là lỗi hay gặp với các lời gọi DELETE.
6. Xử lý lỗi
6.1. Hành vi mặc định
Mặc định, retrieve() ném exception khi response có status lỗi:
- 4xx: ném
HttpClientErrorException, ví dụHttpClientErrorException.NotFound. - 5xx: ném
HttpServerErrorException.
Lỗi kết nối hoặc timeout sẽ ném ResourceAccessException.
6.2. Tùy biến với onStatus
Bạn thường muốn đổi lỗi HTTP thành exception của nghiệp vụ:
public class OrderNotFoundException extends RuntimeException {
public OrderNotFoundException(Long id) {
super("Không tìm thấy đơn hàng " + id);
}
}
public Order getOrder(Long id) {
return restClient.get()
.uri("/orders/{id}", id)
.retrieve()
.onStatus(status -> status.value() == 404, (request, response) -> {
throw new OrderNotFoundException(id);
})
.onStatus(HttpStatusCode::is5xxServerError, (request, response) -> {
throw new IllegalStateException(
"Shop API lỗi: " + response.getStatusCode());
})
.body(Order.class);
}
Các handler được kiểm tra theo thứ tự khai báo. Handler đầu tiên khớp với status sẽ được chạy. Nếu không handler nào khớp, hành vi mặc định ở mục 6.1 được áp dụng.
6.3. Toàn quyền với exchange
Khi cần đọc body lỗi hoặc rẽ nhánh theo status, bạn dùng exchange:
public Optional<Order> findOrder(Long id) {
return restClient.get()
.uri("/orders/{id}", id)
.exchange((request, response) -> {
if (response.getStatusCode().value() == 404) {
return Optional.empty();
}
if (response.getStatusCode().isError()) {
throw new IllegalStateException("Lỗi " + response.getStatusCode());
}
return Optional.ofNullable(
response.bodyTo(Order.class));
});
}
Lưu ý: Trong
exchange, các status handler mặc định không được áp dụng. Bạn phải tự kiểm tra status. Nếu quên, body lỗi có thể bị parse thànhOrdervới các fieldnull.
7. Timeout và HTTP client bên dưới
Mặc định, RestClient chọn thư viện HTTP có trên classpath theo thứ tự: Apache HttpClient 5, Jetty, Reactor Netty, java.net.http.HttpClient của JDK, rồi SimpleClientHttpRequestFactory. Bạn nên luôn đặt timeout. Nếu không, một API chậm có thể giữ thread của bạn rất lâu.
@Configuration
public class ShopClientConfig {
@Bean
public RestClientCustomizer timeoutCustomizer() {
return builder -> {
var factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(Duration.ofSeconds(2));
factory.setReadTimeout(Duration.ofSeconds(5));
builder.requestFactory(factory);
};
}
}
RestClientCustomizer được áp dụng cho mọi RestClient.Builder mà Spring Boot tạo ra. Nếu chỉ muốn đặt timeout cho một client, bạn gọi builder.requestFactory(factory) ngay trong constructor của client đó.
Với API chậm, request sẽ thất bại sau 5 giây:
org.springframework.web.client.ResourceAccessException: I/O error on GET request
for "https://api.shop.example/orders/42": Read timed out
Từ Spring Boot 3.4, bạn cũng có thể khai báo timeout bằng property spring.http.client.connect-timeout và spring.http.client.read-timeout.
8. Interceptor: gắn token, ghi log
ClientHttpRequestInterceptor cho phép bạn can thiệp vào mọi request. Ví dụ, đoạn code sau gắn API key và ghi log thời gian phản hồi:
@Slf4j
public class ApiKeyInterceptor implements ClientHttpRequestInterceptor {
private final String apiKey;
public ApiKeyInterceptor(String apiKey) {
this.apiKey = apiKey;
}
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body,
ClientHttpRequestExecution execution) throws IOException {
request.getHeaders().set("X-Api-Key", apiKey);
long start = System.currentTimeMillis();
ClientHttpResponse response = execution.execute(request, body);
log.info("{} {} -> {} ({} ms)", request.getMethod(), request.getURI(),
response.getStatusCode(), System.currentTimeMillis() - start);
return response;
}
}
Bạn đăng ký interceptor khi build client:
this.restClient = builder
.baseUrl(baseUrl)
.requestInterceptor(new ApiKeyInterceptor(apiKey))
.build();
INFO ApiKeyInterceptor : GET https://api.shop.example/orders/42 -> 200 OK (87 ms)
Lưu ý: Đừng đọc
response.getBody()trong interceptor để ghi log. Stream chỉ đọc được một lần. Nếu bắt buộc phải đọc, hãy bọc request factory bằngBufferingClientHttpRequestFactory.
9. HTTP interface: khai báo client bằng interface
Thay vì tự viết từng lời gọi, bạn có thể mô tả API bằng interface. Spring sẽ sinh phần cài đặt dựa trên RestClient:
public interface OrderApi {
@GetExchange("/orders/{id}")
Order getOrder(@PathVariable Long id);
@PostExchange("/orders")
Order createOrder(@RequestBody CreateOrderRequest request);
}
@Configuration
public class OrderApiConfig {
@Bean
public OrderApi orderApi(RestClient.Builder builder,
@Value("${shop.api.base-url}") String baseUrl) {
RestClient restClient = builder.baseUrl(baseUrl).build();
return HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(restClient))
.build()
.createClient(OrderApi.class);
}
}
Giờ bạn chỉ cần inject OrderApi và gọi orderApi.getOrder(42L). Cách này gọn khi API có nhiều endpoint. Timeout, interceptor và status handler vẫn được cấu hình trên RestClient bên dưới.
10. Viết test với @RestClientTest
Từ Spring Boot 3.2, @RestClientTest hỗ trợ RestClient. Annotation này chỉ khởi tạo các bean liên quan tới HTTP client. Nó cũng nối MockRestServiceServer vào RestClient.Builder, nên không có request thật nào được gửi ra ngoài.
Ta test OrderClient với phiên bản getOrder có onStatus ở mục 6.2:
@RestClientTest(components = OrderClient.class,
properties = "shop.api.base-url=https://api.shop.example")
class OrderClientTest {
@Autowired
private OrderClient orderClient;
@Autowired
private MockRestServiceServer server;
@Test
void givenExistingOrder_whenGetOrder_thenReturnsOrder() {
server.expect(requestTo("https://api.shop.example/orders/42"))
.andExpect(method(HttpMethod.GET))
.andRespond(withSuccess("""
{"id":42,"customerName":"Lan","total":250000,"status":"PAID"}
""", MediaType.APPLICATION_JSON));
Order order = orderClient.getOrder(42L);
assertThat(order.id()).isEqualTo(42L);
assertThat(order.customerName()).isEqualTo("Lan");
assertThat(order.total()).isEqualByComparingTo("250000");
server.verify();
}
@Test
void givenMissingOrder_whenGetOrder_thenThrowsOrderNotFound() {
server.expect(requestTo("https://api.shop.example/orders/99"))
.andRespond(withStatus(HttpStatus.NOT_FOUND));
assertThatThrownBy(() -> orderClient.getOrder(99L))
.isInstanceOf(OrderNotFoundException.class)
.hasMessage("Không tìm thấy đơn hàng 99");
}
}
Các import tĩnh cần dùng nằm trong MockRestRequestMatchers và MockRestResponseCreators thuộc package org.springframework.test.web.client.match và org.springframework.test.web.client.response.
server.expect(...): khai báo request mà test mong đợi.andRespond(...): trả về response giả cho request đó.server.verify(): kiểm tra mọi request đã khai báo đều thực sự được gửi.
Lưu ý:
MockRestServiceServerchỉ hoạt động khi client được tạo từRestClient.Buildermà Spring inject. Nếu bạn gọiRestClient.create(...)bên trong class, test sẽ gửi request thật ra mạng.
11. Khi nào không nên dùng RestClient
- Ứng dụng WebFlux (reactive):
RestClientchặn thread. Nếu gọi nó trong pipeline reactive, event loop sẽ bị nghẽn. Hãy dùngWebClient. - Cần stream dữ liệu lớn hoặc Server-Sent Events không chặn:
WebClientphù hợp hơn. - Code cũ chạy ổn với
RestTemplate: bạn không bắt buộc phải viết lại.RestTemplatevẫn được hỗ trợ, chỉ không nhận tính năng mới. Bạn có thể chuyển dần bằngRestClient.create(restTemplate)để giữ nguyên cấu hình hiện có.
Nếu ứng dụng chạy trên Java 21 với virtual thread (spring.threads.virtual.enabled=true), mô hình blocking của RestClient gần như không còn nhược điểm về khả năng mở rộng. Code vẫn đơn giản và dễ debug.
12. Kết luận
Trong bài này, bạn đã dùng RestClient để gọi API bên ngoài với API fluent và dễ đọc. Ta đã đi qua các bước: tạo client từ builder của Spring Boot, gửi GET/POST, đổi lỗi HTTP thành exception nghiệp vụ bằng onStatus hoặc exchange, đặt timeout, thêm interceptor và khai báo client bằng HTTP interface. Cuối cùng, bạn đã viết test với @RestClientTest và MockRestServiceServer mà không cần gọi mạng thật.
Với dự án Spring Boot 3.2+ theo mô hình servlet, RestClient nên là lựa chọn mặc định thay cho RestTemplate. Hãy nhớ hai điều: luôn đặt timeout, và luôn kết thúc chuỗi retrieve() bằng một phương thức đọc response.