@HttpExchange — khai báo interface, Spring sinh client
HTTP Interface biến một interface có @GetExchange thành client chạy được qua proxy. Khi nào declarative gọn hơn fluent, và khi nào nó che mất thứ bạn cần.
TL;DR: HTTP Interface là cách Spring Framework 6 cho bạn khai báo một client HTTP bằng interface Java thuần — gắn @GetExchange/@PostExchange/@HttpExchange lên từng method, thay vì viết tay từng lời gọi RestClient. HttpServiceProxyFactory dựng một proxy lúc chạy quanh interface đó, dịch mỗi lời gọi method thành một request HTTP thật, nhưng request vẫn đi qua đúng RestClient bạn đã cấu hình timeout và xử lý lỗi ở bài trước — hành vi đó không mất đi đâu cả. Chọn declarative khi hợp đồng gọi ổn định và lặp lại nhiều lần; giữ RestClient fluent khi cần điều khiển từng request theo runtime.
TaskFlow gọi notification-service để báo cho người dùng biết task vừa đổi trạng thái. Bài trước bạn đã dựng bean RestClient với timeout và bộ dịch lỗi tường minh. Vấn đề là mỗi endpoint downstream lại cần thêm một method viết tay, và năm method đó nhìn gần như giống hệt nhau.
Bài này giải thích cơ chế đứng sau @HttpExchange — vì sao khai báo một interface lại chạy được như một client thật, và khi nào nên giữ cách viết tay thay vì chuyển hẳn sang declarative.
1. Năm method giống hệt nhau
NotificationClient của TaskFlow gọi năm endpoint trên notification-service: lấy preference, liệt kê thông báo, gửi thông báo mới, cập nhật trạng thái đã đọc, xoá thông báo. Viết bằng RestClient thủ công như bài trước, mỗi method lặp lại đúng một khung: build URI, chọn verb, retrieve(), convert kiểu trả về.
class NotificationClient {
private final RestClient restClient; // bean cau hinh o bai truoc
NotificationPreference getPreferences(String userId) {
return restClient.get()
.uri("/notifications/preferences/{userId}", userId)
.retrieve()
.body(NotificationPreference.class);
}
NotificationDto send(NotificationRequest request) {
return restClient.post()
.uri("/notifications")
.body(request)
.retrieve()
.body(NotificationDto.class);
}
void delete(String id, String requestId) {
restClient.delete()
.uri("/notifications/{id}", id)
.header("X-Request-Id", requestId)
.retrieve()
.toBodilessEntity();
}
// listNotifications, updateStatus: cung khung GET/PATCH, chi khac path va kieu tra ve
}
Khác nhau đúng ba chỗ: path, HTTP verb, kiểu trả về. Phần còn lại — gọi retrieve(), convert body — lặp lại y hệt ở cả năm method. Khi một pattern lặp lại đều đặn thế này, đó là dấu hiệu nên khai báo thay vì viết tay từng lần: nói cho framework biết "tôi cần method này gọi tới endpoint kia", để nó tự sinh phần khung lặp lại.
2. HTTP Interface là gì?
HTTP Interface là một interface Java thuần, mỗi method gắn annotation @HttpExchange (mức method hoặc mức type) hoặc các biến thể theo verb: @GetExchange, @PostExchange, @PatchExchange, @DeleteExchange. Interface không có thân method — bạn chỉ khai báo chữ ký, Spring Framework (từ bản 6.0) sinh ra implementation lúc chạy và tự gọi HTTP thật khi method được invoke.
Đây chính là ý tưởng bạn đã gặp ở khoá trước với Spring Data repository: khai báo interface, framework sinh thân. Spring Data sinh implementation truy vấn database; HTTP Interface sinh implementation gọi HTTP. Cùng cơ chế "khai báo hợp đồng, để framework lo phần lặp lại", chỉ khác đích đến.
Thử tự viết method còn thiếu trước khi xem đáp án — hai method đầu đã có sẵn:
public interface NotificationClient {
@GetExchange("/notifications/preferences/{userId}")
NotificationPreference getPreferences(@PathVariable String userId);
@PostExchange("/notifications")
NotificationDto send(@RequestBody NotificationRequest request);
// TODO: viet method cho GET /notifications?userId=..&page=..&size=..
// tra ve Page<NotificationDto>
}
Dựa trên hai method mẫu ở trên, viết tiếp method listNotifications ánh xạ GET /notifications với ba query param userId, page, size. Annotation nào dùng cho tham số query thay vì path?
Đáp án:
@GetExchange("/notifications")
Page<NotificationDto> listNotifications(
@RequestParam String userId,
@RequestParam int page,
@RequestParam int size);
Hai method còn lại (updateStatus, delete) theo đúng khuôn đó — @PatchExchange với @PathVariable cộng @RequestBody, @DeleteExchange với @PathVariable cộng @RequestHeader cho header tuỳ biến:
@PatchExchange("/notifications/{id}/status")
void updateStatus(@PathVariable String id, @RequestBody NotificationStatusUpdate update);
@DeleteExchange("/notifications/{id}")
void delete(@PathVariable String id, @RequestHeader("X-Request-Id") String requestId);
Từ năm method viết tay với năm khối RestClient gần như giống nhau, giờ còn lại một interface với năm khai báo method — phần khung lặp lại (retrieve(), convert body) không còn hiện diện trong code bạn viết nữa.
3. Cơ chế bên dưới: proxy quanh RestClient
Interface không có thân method thì ai thực thi khi bạn gọi notificationClient.send(request)? Câu trả lời là một proxy — object được tạo lúc chạy, implement NotificationClient, và mỗi method của nó dịch lời gọi thành một request HTTP thật rồi giao cho RestClient gửi đi. Đây là đúng kỹ thuật JDK dynamic proxy bạn đã gặp ở AOP của spring-core, chỉ khác mục đích: AOP proxy chèn logic cross-cutting (log, transaction) quanh method có sẵn; proxy của HTTP Interface là toàn bộ implementation — method vốn không có thân, proxy tự dựng nó từ annotation.
Dựng proxy đó cần hai lớp trung gian:
RestClientAdapter— bọc mộtRestClientđã cấu hình sẵn, biến nó thành cầu nối màHttpServiceProxyFactoryhiểu được.HttpServiceProxyFactory— đọc annotation trên interface, sinh proxy, và implement method bằng cách gọi quaRestClientAdapterđó.
@Bean
NotificationClient notificationClient(RestClient notificationRestClient) {
RestClientAdapter adapter = RestClientAdapter.create(notificationRestClient);
HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(adapter).build();
return factory.createClient(NotificationClient.class);
}
Điểm mấu chốt: notificationRestClient tiêm vào đây đúng là bean bạn đã dựng ở bài trước, với connectTimeout/readTimeout khai tay và bộ onStatus dịch lỗi downstream sang exception nghiệp vụ. Proxy không thay thế RestClient đó — nó chỉ dịch method call thành request rồi giao lại toàn bộ việc gửi/nhận cho đúng RestClient đã cấu hình. Timeout vẫn áp dụng, lỗi downstream vẫn bị dịch đúng như trước; bạn chỉ bớt phải gõ tay phần lặp lại.
flowchart LR
A[NotificationClient interface] --> B[HttpServiceProxyFactory]
B --> C[proxy runtime]
C --> D[RestClientAdapter]
D --> E["RestClient bean bai 04<br/>timeout + onStatus"]
E --> F[HTTP request that toi notification-service]@HttpExchange và HttpServiceProxyFactory thuộc Spring Framework 6.0 trở đi, dùng được trong Spring Boot 3.x. Cách đăng ký bean thủ công ở trên luôn đúng bất kể phiên bản; bản Spring Framework gần đây có thêm cách đăng ký khai báo gọn hơn (@ImportHttpServices) — xem "Đào sâu" nếu project đã lên bản đó.
4. Ánh xạ tham số — chiều đọc ngược nhau
Bốn annotation tham số ở HTTP Interface trùng tên với annotation bạn đã dùng ở @RestController: @PathVariable, @RequestParam, @RequestBody, @RequestHeader. Trùng tên nhưng đọc ngược chiều nhau, vì một bên nhận request đến, một bên tạo request đi.
| Annotation | Ở @RestController (server) | Ở HTTP Interface (client) |
|---|---|---|
@PathVariable | ĐỌC giá trị từ URL request vừa tới | ĐIỀN giá trị vào URL trước khi gửi |
@RequestParam | ĐỌC query param request vừa tới | GẮN query param vào request sắp gửi |
@RequestBody | PARSE JSON request vừa tới thành object | SERIALIZE object thành JSON để gửi |
@RequestHeader | ĐỌC header request vừa tới | GẮN header vào request sắp gửi |
flowchart TB
subgraph Server["Server - RestController"]
S1["@PathVariable doc tu URL toi"]
S2["@RequestBody parse JSON toi"]
end
subgraph Client["Client - HTTP Interface"]
C1["@PathVariable dien vao URL di"]
C2["@RequestBody serialize JSON di"]
end
HTTP["HTTP request"]
HTTP --> S1
HTTP --> S2
C1 --> HTTP
C2 --> HTTPChốt một câu để không nhầm: cùng annotation, nhưng ở @RestController nó đọc từ request vừa tới, còn ở HTTP Interface nó ghi vào request sắp gửi đi.
5. Khi nào declarative, khi nào fluent
Trục quyết định: hợp đồng có ổn định và lặp lại nhiều lần không, hay bạn cần điều khiển từng request theo tình huống lúc chạy. NotificationClient hợp declarative vì năm endpoint cố định — path, verb, kiểu trả về không đổi theo request.
Nhưng interface chỉ có chữ ký method cố định lúc biên dịch, không có chỗ viết logic động. Ba tình huống sau vẫn cần giữ RestClient fluent:
- Header đổi theo runtime theo điều kiện phức tạp — ví dụ chọn giữa nhiều token tuỳ role, tuỳ feature flag. HTTP Interface vẫn nhận
@RequestHeaderlàm tham số, nhưng nếu logic chọn giá trị phức tạp hơn một tham số đơn thuần, viết bằng fluent rõ ràng hơn. - Xử lý response phân nhánh theo status code cụ thể — ví dụ 404 trả về
Optional.empty()thay vì ném exception, còn 5xx thì retry.RestClientcho bạnonStatus/exchange()để rẽ nhánh ngay tại chỗ gọi; HTTP Interface trả thẳng object hoặc ném exception, không có chỗ để chèn nhánh xử lý riêng cho từng method. - Streaming response — đọc dữ liệu theo luồng (chunked, Server-Sent Events) cần giữ control trên
InputStream/Flux, việc mà method interface trảTcố định không diễn đạt được gọn.
Không có cách nào tốt hơn tuyệt đối. TaskFlow vẫn giữ RestClient bean gốc bên cạnh NotificationClient — dùng interface cho năm endpoint cố định, và gọi thẳng RestClient khi cần một request đặc biệt ngoài khuôn đó.
Pitfall thường gặp
❌ Nhầm 1 — tưởng chuyển sang interface thì bỏ được bean RestClient ở bài trước:
// SAI: RestClient.create() mac dinh, mat het timeout + onStatus da cau hinh
RestClientAdapter.create(RestClient.create());
✅ Luôn tiêm đúng bean RestClient đã cấu hình ở bài trước vào RestClientAdapter — interface chỉ thay phần khung gọi, không thay cấu hình mạng.
❌ Nhầm 2 — dùng sai annotation cho tham số path:
// SAI: @RequestParam cho o von la path segment
@GetExchange("/notifications/preferences/{userId}")
NotificationPreference getPreferences(@RequestParam String userId);
✅ Chỉ @PathVariable điền được {userId} trong path template. Dùng nhầm @RequestParam khiến {userId} không được thay thế — request thật gửi đi nguyên chuỗi /notifications/preferences/{userId}, downstream trả 404 vì đó không phải path hợp lệ.
❌ Nhầm 3 — khai interface xong quên đăng ký bean:
Chỉ viết interface NotificationClient với annotation mà không có @Bean gọi factory.createClient(...) thì Spring không tự biết cần tạo proxy nào — nơi khác @Autowired NotificationClient sẽ nhận lỗi "No qualifying bean of type". Interface tự nó chỉ là khai báo; bước đăng ký ở mục 3 là bắt buộc, không tự động.
Đào sâu
- Spring Framework Reference — REST Clients, mục HTTP Interface — mô tả đầy đủ các annotation
@HttpExchange, danh sách tham số hỗ trợ, và cách đăng ký khai báo qua@ImportHttpServicesở các bản gần đây.
Ghi chú: trang này là nguồn xác nhận danh sách annotation tham số (@PathVariable, @RequestParam, @RequestBody, @RequestHeader cùng vài annotation ít dùng hơn) và ba adapter tương ứng ba client (RestClientAdapter, WebClientAdapter, RestTemplateAdapter) — đọc khi cần đối chiếu chữ ký method chính xác cho một trường hợp bạn chưa gặp ở bài này.
Liên hệ các bài khác
- Bài 04 — RestClient — bean
RestClient, timeout, và bộ dịch lỗi bạn vừa tái sử dụng nguyên vẹn quaRestClientAdapterở bài này. - Bài 06 — Sealed type cho kết quả nghiệp vụ — sau khi có client gọi downstream gọn, bài tiếp theo model kết quả trả về (thành công/lỗi) bằng sealed type thay vì ném exception rải rác.
Tóm tắt
- HTTP Interface là interface Java gắn
@GetExchange/@PostExchange/@HttpExchange; không viết thân method, Spring sinh implementation lúc chạy. HttpServiceProxyFactorydựng proxy bằng JDK dynamic proxy quanhRestClientAdapterbọcRestClientđã cấu hình — đó là lý do interface bắt buộc phải là interface, không dùng được cho class cụ thể.- Timeout và bộ dịch lỗi cấu hình ở bài trước không mất đi khi chuyển sang interface — chúng sống trong
RestClientđược bọc, proxy chỉ dịch method call thành request. - Bốn annotation tham số trùng tên với
@RestControllernhưng đọc ngược chiều: server đọc từ request tới, client ghi vào request đi. - Declarative hợp khi hợp đồng ổn định và lặp lại; giữ
RestClientfluent khi cần rẽ nhánh theo status code, header động phức tạp, hoặc streaming.
Tự kiểm tra
Q1Vì sao HTTP Interface bắt buộc phải là một interface, không thể khai báo trên một class cụ thể? Liên hệ tới cơ chế proxy đã học ở spring-core.▸
HttpServiceProxyFactory dựng implementation bằng JDK dynamic proxy (java.lang.reflect.Proxy) — cơ chế này chỉ tạo được object implement interface, không thể tạo subclass động của một class cụ thể. Đó cũng là lý do AOP của Spring cần CGLIB dự phòng khi bean target không có interface; HTTP Interface không có phương án dự phòng đó vì method vốn không có thân, nó phải dựa hoàn toàn vào interface để proxy biết chữ ký cần implement. Gọi factory.createClient() với một class cụ thể không hợp lệ vì API chỉ nhận Class của interface.Q2Bean RestClient ở bài 04 cấu hình connectTimeout 2 giây, readTimeout 5 giây, và onStatus dịch lỗi downstream sang exception nghiệp vụ. Sau khi refactor sang NotificationClient interface, cấu hình đó còn hiệu lực không? Giải thích bằng đúng cơ chế vừa học.▸
RestClientAdapter.create(notificationRestClient) bọc đúng bean RestClient đã cấu hình đó, và proxy dùng chính adapter này để thực thi mọi method. Proxy không tự tạo kết nối HTTP — mỗi lời gọi method chỉ dịch thành một lệnh gọi trên RestClient đã tiêm vào, nên timeout và bộ dịch lỗi chạy y hệt như khi gọi thủ công. Muốn đổi timeout thì sửa đúng chỗ khai báo bean RestClient, không sửa ở tầng interface.Q3Một method interface khai báo @GetExchange("/notifications/preferences/{userId}") nhưng tham số lại gắn @RequestParam String userId thay vì @PathVariable. Điều gì xảy ra khi gọi method này?▸
{userId} trong path template chỉ được thay thế bởi tham số gắn @PathVariable. Gắn nhầm @RequestParam khiến placeholder không được điền — request gửi đi giữ nguyên chuỗi literal /notifications/preferences/{userId}, còn giá trị userId truyền vào bị bỏ qua vì không có query param nào tên đó. Downstream nhận một path không tồn tại và trả 404, chứ không phải lỗi biên dịch — cả hai annotation đều hợp lệ cú pháp, sai chỉ lộ ra lúc chạy.Q4Cho một tình huống cụ thể mà bạn KHÔNG nên chuyển một lời gọi RestClient sang HTTP Interface, dù endpoint đó cố định. Giải thích vì sao declarative không diễn đạt được.▸
Optional.empty() thay vì để 404 văng thành exception, trong khi mọi status khác vẫn ném exception như bình thường. HTTP Interface chỉ cho khai báo kiểu trả về cố định của method, không có chỗ chèn nhánh xử lý riêng theo status. RestClient fluent giải quyết được vì onStatus()/exchange() cho viết logic rẽ nhánh ngay tại chỗ gọi, cho từng request cụ thể.Q5@RequestBody xuất hiện cả ở @RestController lẫn ở HTTP Interface. Giải thích khác biệt trong việc nó làm gì ở mỗi phía.▸
@RestController phía server, @RequestBody đọc phần body của request vừa tới rồi deserialize JSON đó thành object Java. Ở HTTP Interface phía client, cùng annotation đó làm ngược lại: lấy object bạn truyền vào method, serialize thành JSON, đặt vào body của request sắp gửi. Cùng tên annotation, cùng ý tưởng "đây là body", nhưng chiều dữ liệu chảy ngược nhau tuỳ bạn đang nhận hay đang gửi request.Q6Bạn tiêm @Autowired NotificationClient vào một service nhưng ứng dụng báo lỗi "No qualifying bean of type NotificationClient". Interface đã khai đủ annotation. Bước nào bị thiếu?▸
@GetExchange/@PostExchange chỉ mô tả hợp đồng, Spring không tự quét và tạo proxy cho mọi interface có annotation đó. Phải tự viết một @Bean gọi HttpServiceProxyFactory.builderFor(adapter).build().createClient(NotificationClient.class) thì container mới có instance thật để tiêm — khác với @Component/@Service quét tự động, vì HTTP Interface cần biết dùng RestClient/adapter nào, thông tin đó không suy ra được chỉ từ chính interface.Bài tiếp theo: Sealed type cho kết quả nghiệp vụ
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên