Ứng dụng nào cũng gọi ra ngoài. Bài này về cách làm việc đó trong Spring, và về con số làm tôi dừng lại.
Phép chờ mặc định
Tôi dựng một endpoint ngủ 5 giây, rồi gọi nó bằng bốn cách:
RestTemplate mặc định 5027 ms ok: xong
RestTemplate timeout 1s 1003 ms ResourceAccessException: Read timed out
RestClient mặc định 5027 ms ok: xong
WebClient responseTimeout 1s 1020 ms TimeoutException
Hai dòng "mặc định" chờ đủ 5 giây rồi trả về thành công. Không có phép chờ nào cả.
Endpoint của tôi ngủ 5 giây. Một dịch vụ thật bị treo sẽ giữ luồng của bạn tới khi tầng TCP bỏ cuộc — trên Linux mặc định là khoảng 15 phút.
Không có phép chờ mặc định là quyết định thiết kế có lý do (thư viện không biết dịch vụ của bạn nhanh chậm ra sao), nhưng nó nghĩa là bạn phải đặt, mỗi lần, không có ngoại lệ.
Đặt phép chờ
@Bean
RestClient restClient(RestClient.Builder b) {
var f = new SimpleClientHttpRequestFactory();
f.setConnectTimeout(2000); // bắt tay TCP
f.setReadTimeout(5000); // chờ dữ liệu về
return b.requestFactory(f).baseUrl("https://api.doi-tac.com").build();
}
Hai phép chờ khác nhau và đều cần: connectTimeout nên ngắn (2 giây là nhiều — nối được thì nối ngay), readTimeout tuỳ theo dịch vụ.
Với RestTemplateBuilder thì gọn hơn:
@Bean RestTemplate rt(RestTemplateBuilder b) {
return b.connectTimeout(Duration.ofSeconds(2))
.readTimeout(Duration.ofSeconds(5)).build();
}
Luôn tiêm builder thay vì new. Builder mang theo mọi cấu hình chung — interceptor, bộ chuyển đổi, đo lường bằng Micrometer. new RestClient() là mất hết.
Chọn client nào
| Client | Trạng thái | Dùng khi |
|---|---|---|
RestTemplate |
bảo trì, không bỏ | mã cũ, đừng viết mới |
RestClient |
khuyên dùng từ Spring 6.1 | mặc định cho mọi lời gọi đồng bộ |
WebClient |
tích cực | reactive, hoặc cần đối xử luồng dữ liệu |
@HttpExchange |
khuyên dùng | client theo kiểu khai báo |
RestClient là câu trả lời cho phần lớn trường hợp: API kiểu fluent như WebClient nhưng đồng bộ, không kéo theo cả ngăn xếp reactive.
var don = client.get().uri("/don/{id}", id).retrieve().body(Don.class);
WebClient vẫn đúng khi bạn gọi hàng nghìn lời gọi song song, hoặc xử lý luồng sự kiện. Nhưng dùng nó rồi .block() ngay là mang phức tạp mà không được gì — đúng cái tôi làm trong phép đo trên, và đó là ví dụ về việc không nên làm trong mã thật.
Client khai báo
Cách tôi thích nhất, có từ Spring 6:
interface DichVuDon {
@GetExchange("/don/{id}")
Don lay(@PathVariable String id);
@PostExchange("/don")
Don tao(@RequestBody DonMoi d);
}
@Bean DichVuDon dichVuDon(RestClient.Builder b) {
var client = b.baseUrl("https://api.doi-tac.com").build();
var f = HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(client)).build();
return f.createClient(DichVuDon.class);
}
Giao diện mô tả API, Spring sinh phần cài đặt. Nó cho bạn một kiểu để tiêm và để giả lập trong test — thay vì rải chuỗi URL khắp mã nghiệp vụ.
Nếu đã quen Feign của Spring Cloud thì đây là bản có sẵn trong Spring, không cần phụ thuộc thêm.
Lỗi từ phía kia
RestClient gặp 500 -> InternalServerError: 500 Internal Server Error: "{...}"
retrieve() ném ngoại lệ với mã 4xx và 5xx. Ba nhánh xử lý:
client.get().uri("/don/{id}", id)
.retrieve()
.onStatus(s -> s.value() == 404, (rq, rs) -> { throw new KhongTimThay(id); })
.body(Don.class);
Với 404, ném ngoại lệ riêng thường đúng hơn là để NotFound của Spring lan ra.
Muốn tự xử lý hoàn toàn thì dùng .exchange() thay .retrieve().
Ba thứ nữa phải có
Thử lại, nhưng chỉ với thao tác lặp lại được. GET thì an toàn; POST tạo đơn hàng mà thử lại là tạo hai đơn. Spring Retry:
@Retryable(retryFor = ResourceAccessException.class,
maxAttempts = 3, backoff = @Backoff(delay = 200, multiplier = 2))
Don lay(String id) { ... }
multiplier = 2 là lùi theo cấp số nhân — quan trọng, vì thử lại ngay lập tức chỉ làm dịch vụ đang quá tải thêm nặng.
Cầu dao (circuit breaker). Khi dịch vụ kia hỏng, thử lại 3 lần chỉ làm chậm gấp ba. Cầu dao mở ra sau N lần hỏng và trả lỗi ngay, cho dịch vụ kia thời gian hồi phục:
@CircuitBreaker(name = "doiTac", fallbackMethod = "duPhong")
Don lay(String id) { ... }
Ghi log và đo. Mỗi lời gọi ra ngoài cần có mã tương quan đi kèm (bài 17) và một chỉ số độ trễ theo phân vị. Micrometer làm sẵn nếu bạn dùng builder Spring cấp.
Pool kết nối
SimpleClientHttpRequestFactory dùng HttpURLConnection — đơn giản nhưng không gộp kết nối. Với lưu lượng thật, mỗi request là một lượt bắt tay TCP và TLS mới.
Đổi sang Apache HttpClient hoặc JdkClientHttpRequestFactory:
var http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(2))
.build();
var f = new JdkClientHttpRequestFactory(http);
f.setReadTimeout(Duration.ofSeconds(5));
java.net.http.HttpClient của JDK 11+ gộp kết nối sẵn và hỗ trợ HTTP/2 — không cần phụ thuộc ngoài.
Và như bài 66 sê-ri Java đã nói về pool kết nối CSDL: kích thước pool là trần thông lượng. Pool 10 kết nối với dịch vụ trả lời trong 100 ms cho tối đa 100 lời gọi mỗi giây, dù bạn có bao nhiêu luồng.
Thử ba mươi giây
grep -rn 'new RestTemplate()\|RestClient.create()' --include='*.java' src/main
Mỗi kết quả là một client không có phép chờ — nó sẽ chờ tới khi hệ điều hành bỏ cuộc. Bảng đầu bài cho thấy 5 giây chờ đủ 5 giây; một dịch vụ treo thật thì con số đó là mười lăm phút.
Ngày mai: thiết kế REST API — và những chỗ mà "chuẩn REST" không giúp được gì.