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 instance RestClient. Instance này thread-safe, nên bạn tạo một lần và dùng lại.

Lưu ý: Bean RestClient.Builder có scope prototype. Mỗi lần inject, bạn nhận một builder mới. Vì vậy, cấu hình ở OrderClient khô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 bean RestClientCustomizer.

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 record Order.

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ếp body(...), toEntity(...) hay toBodilessEntity(), 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ành Order với các field null.

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ằng BufferingClientHttpRequestFactory.

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 ý: MockRestServiceServer chỉ hoạt động khi client được tạo từ RestClient.Builder mà Spring inject. Nếu bạn gọi RestClient.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): RestClient chặn thread. Nếu gọi nó trong pipeline reactive, event loop sẽ bị nghẽn. Hãy dùng WebClient.
  • Cần stream dữ liệu lớn hoặc Server-Sent Events không chặn: WebClient phù 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. RestTemplate vẫn được hỗ trợ, chỉ không nhận tính năng mới. Bạn có thể chuyển dần bằng RestClient.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.

spring-bootrestclienthttprest-apitesting