Giới thiệu REST API
Xem thửĐội mobile phát hành bản cập nhật, và trong hai tuần có 312 khách bị trừ tiền hai lần cho cùng một đơn. Log của server không có lỗi nào: mỗi yêu cầu POST /orders đều trả 201. Nguyên nhân nằm ở hợp đồng giữa hai bên. Client của thư viện HTTP tự gửi lại yêu cầu khi hết thời gian chờ (mà phản hồi 201 đã bị mất trên đường về), còn server không có cách nào biết yêu cầu thứ hai là bản sao của yêu cầu đầu. Sáu tháng sau, đội backend đổi tên trường qty thành quantity và hỏng luôn ứng dụng đời cũ mà vài chục nghìn người dùng chưa cập nhật.
Hai sự cố khác nhau, một gốc: API là hợp đồng dài hạn giữa những bên không cùng triển khai. Bài này không dạy cú pháp Spring; nó dạy cách nghĩ để thiết kế hợp đồng đó cho đúng ngay từ đầu: ngữ nghĩa HTTP thật sự nói gì, mã trạng thái nào dùng khi nào, cách đặt tên và phân trang để API sống được nhiều năm, cách tiến hóa mà không phá client, và cách nhìn một API như một sản phẩm cần vận hành.
Sau bài này, bạn có thể
- Giải thích REST là một tập ràng buộc kiến trúc, phân biệt REST đầy đủ với kiểu "HTTP và JSON" thường gặp (mô hình trưởng thành Richardson).
- Dùng đúng tính chất safe, idempotent, cacheable của từng phương thức HTTP để quyết định ai được phép gửi lại và cache.
- Chọn mã trạng thái theo một cây quyết định thay vì theo thói quen.
- Thiết kế URI, phân trang (offset và keyset), lọc và sắp xếp cho một tài nguyên thực tế.
- Phiên bản hóa và tiến hóa API: thay đổi nào tương thích, thay đổi nào phá vỡ, và cách Spring Framework 7 hỗ trợ versioning.
- Xây dựng một danh sách kiểm tra "API sẵn sàng production".
REST thật sự là gì
Cách đọc hình
Bên trái là client; bên giữa là các phép toán (phương thức cộng URI); bên phải là kết quả. Điểm quan trọng nhất của hình là hai thứ không có mặt: tên bảng cơ sở dữ liệu và tên phương thức Java. Client chỉ biết tài nguyên (đơn hàng 42), thao tác chuẩn (đọc, tạo, thay thế, xóa) và biểu diễn (JSON) của tài nguyên đó.
REST (Representational State Transfer) do Roy Fielding mô tả trong luận án tiến sĩ năm 2000 như một phong cách kiến trúc, tức một tập ràng buộc, chứ không phải một giao thức hay thư viện. Các ràng buộc quan trọng:
- Client và server tách biệt: hai bên tiến hóa độc lập, miễn giữ hợp đồng.
- Phi trạng thái (stateless): mỗi yêu cầu mang đủ thông tin để server xử lý; server không giữ phiên giữa các yêu cầu. Đây là điều kiện để mở rộng ngang: yêu cầu nào cũng có thể đến bất kỳ instance nào.
- Cache được: phản hồi tự khai báo có được cache hay không, trong bao lâu.
- Giao diện thống nhất: định danh tài nguyên bằng URI, thao tác thông qua biểu diễn, thông điệp tự mô tả (phương thức, mã trạng thái,
Content-Type), và siêu phương tiện (HATEOAS: phản hồi chứa liên kết tới các hành động tiếp theo). - Hệ thống phân lớp: giữa client và server có thể có proxy, gateway, CDN mà không ai phải biết.
Mô hình trưởng thành Richardson chia các API theo mức tuân thủ:
| Mức | Đặc điểm | Ví dụ |
|---|---|---|
| 0 | Một URI, một phương thức (RPC qua HTTP) | POST /api với thân JSON chứa tên hành động |
| 1 | Có tài nguyên với URI riêng | POST /orders/42 mọi thao tác |
| 2 | Dùng đúng phương thức và mã trạng thái | GET, PUT, DELETE, 201, 404 |
| 3 | Siêu phương tiện (HATEOAS) | Phản hồi có _links tới hành động kế tiếp |
Phần lớn API công nghiệp dừng ở mức 2, và đó là lựa chọn thực dụng hợp lý: mức 2 đã cho bạn cache, retry an toàn và công cụ chung. Mức 3 có giá trị khi client là bên thứ ba không kiểm soát được (chuyển luồng nghiệp vụ theo liên kết), nhưng làm hợp đồng phức tạp hơn và hiếm có client thật sự tận dụng. Quan trọng là gọi đúng tên: một API mức 2 là "HTTP API theo kiểu tài nguyên", đừng đòi hỏi hay tuyên bố nó đạt toàn bộ REST.
Ngữ nghĩa HTTP: bảng quyết định cho retry và cache
RFC 9110 định nghĩa ba tính chất mà proxy, thư viện HTTP và gateway dựa vào để quyết định hành vi:
- Safe (an toàn): yêu cầu không đòi hỏi thay đổi trạng thái; có thể gọi mà không lo hậu quả.
- Idempotent: gửi một lần hay nhiều lần cho cùng trạng thái cuối trên server.
- Cacheable: phản hồi có thể được lưu và dùng lại.
| Phương thức | Safe | Idempotent | Cache được | Dùng cho |
|---|---|---|---|---|
| GET | Có | Có | Có | Đọc |
| HEAD | Có | Có | Có | Đọc header |
| OPTIONS | Có | Có | Không | Khám phá phương thức, CORS preflight |
| PUT | Không | Có | Không | Thay thế toàn bộ |
| DELETE | Không | Có | Không | Xóa |
| POST | Không | Không | Hiếm khi | Tạo, hành động |
| PATCH | Không | Không (tùy thiết kế) | Không | Cập nhật một phần |
Hệ quả vận hành của bảng này rất cụ thể. Thư viện HTTP và service mesh thường tự động gửi lại các phương thức idempotent khi lỗi mạng, và không gửi lại POST. Nếu bạn đặt thao tác thay đổi dữ liệu vào GET, một crawler, một trình duyệt tải trước (prefetch) hoặc proxy retry sẽ thực thi nó mà không ai hay biết. Nếu bạn dùng POST cho việc đọc, bạn mất cache và mất khả năng retry an toàn.
Cần phân biệt đúng: idempotent nói về trạng thái trên server, không phải về phản hồi. DELETE /orders/42 lần hai có thể trả 404 nhưng vẫn idempotent vì trạng thái cuối (không còn đơn 42) giống nhau.
Đang tải Animation…
Thử và quan sát
Chạy luồng POST: server đã ghi đơn, phản hồi bị mất, client gửi lại và database có hai đơn. Rồi bật PUT: cùng chuỗi sự kiện mạng nhưng trạng thái cuối chỉ có một đơn. Chú ý khoảnh khắc "mất phản hồi": client không thể phân biệt "server chưa nhận" với "server đã xử lý nhưng tôi không biết". Mọi thiết kế retry đều phải sống với sự không chắc chắn đó, và cách duy nhất giải quyết với POST là cho yêu cầu một khóa idempotency do client sinh (bài GET và POST cài đặt cơ chế này).
Chọn mã trạng thái theo cây quyết định
Mã trạng thái là một phần của hợp đồng mà client lập trình phản ứng theo (retry, hiển thị lỗi, đăng nhập lại). Chọn theo cây câu hỏi dưới đây, theo đúng thứ tự một server thực hiện các bước kiểm tra.
Đang tải sơ đồ…
Các cặp dễ nhầm và cách phân biệt:
- 401 và 403. 401 nghĩa là "tôi không biết bạn là ai" (thiếu hoặc sai thông tin xác thực; phải kèm header
WWW-Authenticate). 403 nghĩa là "tôi biết bạn là ai nhưng bạn không được phép". Dùng 404 thay 403 khi chính việc xác nhận tài nguyên tồn tại đã là rò rỉ thông tin. - 400, 422 và 409. 400 là yêu cầu hỏng về cú pháp hay kiểu (JSON sai,
quantitylà chuỗi). 422 (Unprocessable Content) là cú pháp đúng nhưng nội dung sai ngữ nghĩa nghiệp vụ. 409 là xung đột với trạng thái hiện tại của tài nguyên (email đã tồn tại, đơn đã bị hủy). Nhiều đội chỉ dùng 400 cho cả validation; chọn một quy ước và ghi vào tài liệu mới là điều quan trọng. - 404 và 410. 404 là "không tìm thấy", 410 là "đã từng có và đã bị gỡ vĩnh viễn"; 410 giúp client và công cụ lập chỉ mục ngừng thử lại.
- 412 và 428. 412 (Precondition Failed) khi điều kiện
If-MatchhayIf-Unmodified-Sincesai: bản của client đã cũ. 428 (Precondition Required) khi server đòi điều kiện nhưng client không gửi, dùng để buộc cập nhật có kiểm soát đồng thời. - 202. Đã nhận nhưng chưa xử lý xong. Trả kèm
Locationtới tài nguyên trạng thái để client truy vấn. - 429 và 503. 429 (Too Many Requests) khi client vượt hạn mức, kèm
Retry-After. 503 khi server tạm thời quá tải hoặc đang bảo trì, cũng nên cóRetry-After. Cả hai báo cho client biết nên chờ rồi thử lại, khác với 500 là lỗi không lường trước. - 500, 502 và 504. 500 do server; 502 gateway nhận phản hồi sai từ upstream; 504 upstream không trả lời kịp.
Thiết kế tài nguyên và URI
Danh từ cho tài nguyên, phương thức cho hành vi. Dùng danh từ số nhiều cho tập hợp (/orders), định danh cho từng phần tử (/orders/42). Tài nguyên con khi quan hệ sở hữu rõ (/orders/42/items), và giữ độ sâu không quá hai cấp: /customers/7/orders/42/items/3 là dấu hiệu của thiết kế sai, vì /orders/42/items đã đủ định danh.
Hành động không vừa với CRUD (hủy đơn, duyệt hồ sơ, đặt lại mật khẩu) có hai cách tốt: mô hình hóa hành động như một tài nguyên con được tạo ra (POST /orders/42/cancellation), hoặc coi nó như thay đổi trạng thái (PATCH /orders/42 với {"status":"CANCELLED"}) khi máy trạng thái đơn giản. Tránh POST /cancelOrder: nó lệch khỏi ngữ nghĩa HTTP và mỗi hành động thành một ngoại lệ.
Định danh. ID tuần tự (/orders/1, /orders/2) lộ quy mô kinh doanh và dễ bị dò (enumeration). UUID hoặc ID ngẫu nhiên dạng ngắn an toàn hơn; kết hợp với kiểm tra quyền truy cập từng đối tượng, vì ID khó đoán không thay thế được kiểm tra quyền.
Biểu diễn dữ liệu.
- Thời gian ở dạng ISO 8601, UTC (
2026-10-15T08:30:00Z); không dùng mốc thời gian không múi giờ. - Tiền tệ như chuỗi thập phân hoặc số nguyên đơn vị nhỏ nhất kèm mã tiền tệ; không dùng số thực nhị phân.
- Tên trường nhất quán một kiểu (camelCase hoặc snake_case) và ổn định; enum là chuỗi viết hoa rõ nghĩa.
- Phân biệt "trường vắng mặt", "null" và "rỗng" trong tài liệu; chúng có nghĩa khác nhau với cập nhật một phần.
Phân trang, lọc và sắp xếp
Danh sách không giới hạn là bom hẹn giờ: một bảng tăng trưởng sẽ làm chậm rồi làm sập endpoint. Mọi GET trả tập hợp phải phân trang và có trần kích thước trang. Có hai kỹ thuật chính:
Offset (trang theo số). GET /orders?page=3&size=20 tương ứng LIMIT 20 OFFSET 60. Đơn giản, cho phép nhảy tới trang bất kỳ. Nhược điểm: cơ sở dữ liệu phải quét và bỏ OFFSET hàng, nên trang càng sâu càng chậm; dữ liệu thêm hoặc xóa trong lúc người dùng lật trang làm trùng hoặc sót phần tử.
Keyset (con trỏ). Client gửi cursor chỉ vị trí của phần tử cuối cùng đã thấy:
SELECT id, created_at, total
FROM orders
WHERE (created_at, id) < (:lastCreatedAt, :lastId)
ORDER BY created_at DESC, id DESC
LIMIT 20;
Với chỉ mục trên (created_at, id), chi phí mỗi trang gần như không đổi dù sâu đến đâu, và kết quả ổn định khi có dữ liệu mới. Đánh đổi: không nhảy tới trang N, và cần sắp xếp theo khóa có thứ tự toàn phần (thêm id để phá hòa). Cursor nên là chuỗi mờ (opaque) mã hóa vị trí, để bạn đổi cách cài đặt mà client không hỏng.
Phản hồi nên chứa thông tin điều hướng ổn định, ví dụ {"items": [...], "page": {"size": 20, "nextCursor": "..."}}, hoặc dùng header Link theo RFC 8288 với rel="next". Với lọc và sắp xếp, dùng tham số truy vấn có tên rõ (status=PAID, createdFrom=..., sort=createdAt,desc), kiểm tra danh sách trường được phép sắp xếp (không nối chuỗi vào ORDER BY) và đặt trần cho size (ví dụ 100).
Phiên bản hóa và tiến hóa hợp đồng
Thay đổi tương thích (không cần phiên bản mới): thêm trường tùy chọn vào phản hồi, thêm endpoint, thêm giá trị enum mà client được yêu cầu bỏ qua nếu không biết, nới lỏng ràng buộc đầu vào. Thay đổi phá vỡ: xóa hoặc đổi tên trường, đổi kiểu hay nghĩa, siết chặt đầu vào, đổi mã trạng thái cho cùng tình huống, đổi thứ tự mặc định. Quy tắc Postel hữu ích ở đây: client nên bỏ qua trường lạ; server nên chấp nhận đầu vào hợp lý.
Các chiến lược phiên bản:
| Chiến lược | Ví dụ | Ưu | Nhược |
|---|---|---|---|
| Trong đường dẫn | /api/v2/orders | Dễ thấy, dễ cache và route | Phiên bản thành một phần của URI tài nguyên |
| Header tùy biến | X-API-Version: 2 | URI sạch | Khó thử bằng trình duyệt, cần cấu hình cache theo header |
| Media type | Accept: application/vnd.shop.v2+json | Đúng tinh thần REST | Phức tạp với công cụ |
| Tham số truy vấn | ?version=2 | Dễ thử | Dễ bị bỏ sót, lẫn với lọc |
Spring Framework 7 hỗ trợ versioning ở mức framework. Cấu hình một nguồn đọc phiên bản (header, tham số, đoạn đường dẫn hoặc tham số media type), rồi khai báo phiên bản ngay trên mapping:
@Configuration
class WebConfig implements WebMvcConfigurer {
@Override
public void configureApiVersioning(ApiVersionConfigurer configurer) {
configurer.useRequestHeader("X-API-Version")
.addSupportedVersions("1.0", "1.1", "2.0")
.setDefaultVersion("1.0");
}
}
@RestController
@RequestMapping("/api/orders")
class OrderController {
@GetMapping(path = "/{id}", version = "1.0")
OrderV1 getV1(@PathVariable Long id) { ... }
@GetMapping(path = "/{id}", version = "2.0+")
OrderV2 getV2(@PathVariable Long id) { ... }
}
Giá trị "1.2" khớp chính xác phiên bản đó; "1.2+" khớp từ 1.2 trở lên và Spring chọn phiên bản gần nhất không vượt quá phiên bản của request. Phiên bản ngoài danh sách hỗ trợ dẫn tới InvalidApiVersionException (400); thiếu phiên bản khi bắt buộc dẫn tới MissingApiVersionException (400). Spring Boot cho phép cấu hình bằng property: spring.mvc.apiversion.use.header và spring.mvc.apiversion.default. ApiVersionDeprecationHandler có sẵn bản StandardApiVersionDeprecationHandler gắn các header Deprecation, Sunset và Link (RFC 9745 và RFC 8594) để báo cho client về lịch ngừng hỗ trợ.
Phiên bản hóa chỉ là kỹ thuật. Phần khó là chính sách: hỗ trợ mỗi phiên bản bao lâu, thông báo thế nào, đo ai còn dùng phiên bản cũ (nhật ký theo header phiên bản), và khi nào thật sự tắt.
Hợp đồng lỗi, đồng thời và tác vụ dài
- Lỗi có cấu trúc. Dùng Problem Details (RFC 9457,
application/problem+json) vớitype,title,status,detail,instancecùng các trường riêng như mã lỗi máy đọc được và mã truy vết. Không bao giờ trả stack trace. - Kiểm soát đồng thời. Trả
ETagkhi đọc; yêu cầuIf-Matchkhi cập nhật. Sai phiên bản thì412, thiếu điều kiện khi server đòi thì428. Cách này ngăn người cập nhật sau ghi đè mất thay đổi của người trước (bài PUT và DELETE). - Tác vụ dài. Trả
202 AcceptedkèmLocation: /exports/9và để client truy vấnGET /exports/9cho tới khi hoàn thành; hoặc dùng webhook. Đừng giữ yêu cầu HTTP mở hàng phút. - Thao tác hàng loạt. Mô hình hóa thành tài nguyên (
POST /imports) và trả kết quả theo từng phần tử, thay vì một lần gọi tất-cả-hoặc-không.
Bảo mật và vận hành ở mức hợp đồng
TLS cho mọi kết nối; xác thực bằng token có hạn dùng ngắn; kiểm tra quyền trên từng đối tượng (không chỉ từng endpoint) để tránh lỗ hổng truy cập đối tượng không đúng quyền (BOLA, đứng đầu danh sách OWASP API Security). Đặt hạn mức tốc độ và trả 429 kèm Retry-After. Cấu hình CORS rõ ràng (không dùng dấu sao khi có thông tin xác thực). Đặt giới hạn kích thước thân yêu cầu và độ dài URI. Ghi log có mã truy vết và không ghi dữ liệu nhạy cảm. Mô tả hợp đồng bằng OpenAPI để sinh tài liệu, mã client và kiểm thử hợp đồng.
Bài thực hành: sửa một API tồi
Cho các endpoint sau của một hệ thống đặt hàng:
POST /getOrders body: {"userId": 5}
GET /deleteOrder?id=42
POST /orders/create trả 200 {"success": true, "id": 42}
POST /orders/42/update body: toàn bộ đơn
GET /orders trả toàn bộ 400.000 đơn
GET /orders/42 lỗi trả 200 {"success": false, "msg": "not found"}
Yêu cầu: thiết kế lại thành API đúng ngữ nghĩa HTTP. Tiêu chí hoàn thành:
- Mỗi endpoint dùng đúng phương thức, danh từ số nhiều và mã trạng thái (201 kèm
Location, 404, 204). - Danh sách có phân trang keyset và trần
size; ghi rõ chỉ mục cần có. - Cập nhật dùng
PUThoặcPATCHvớiETagvàIf-Match; mô tả mã trả về khi sai phiên bản. - Lỗi dùng Problem Details với mã lỗi máy đọc được.
- Thiết kế khóa idempotency cho
POST /ordersvà nêu cách xử lý khi hai yêu cầu cùng khóa đến đồng thời.
Gợi ý đáp án: GET /orders?userId=5&cursor=..., DELETE /orders/42 (204), POST /orders (201, Location: /orders/42, header Idempotency-Key), PUT /orders/42 với If-Match, GET /orders/42 trả 404 kèm Problem Details. Với khóa đồng thời: ràng buộc unique trên khóa, yêu cầu thứ hai nhận 409 hoặc chờ rồi trả kết quả đã lưu.
Những lỗi thiết kế thường gặp
- Trả
200cho mọi lỗi nghiệp vụ: client và công cụ giám sát không phân biệt được thành công với thất bại. - Dùng GET cho thao tác ghi: bị crawler, prefetch và retry thực thi.
- Danh sách không phân trang hoặc phân trang bằng offset sâu trên bảng lớn.
- Phản hồi là đồ thị entity: mọi thay đổi schema thành thay đổi hợp đồng.
- Đổi tên trường mà không phiên bản hóa; client cũ im lặng nhận giá trị mặc định.
- Không có khóa idempotency cho thao tác tạo tiền hoặc đơn hàng.
- Không có chính sách ngừng hỗ trợ: phiên bản cũ sống mãi và chi phí bảo trì tăng dần.
Tự kiểm tra
- Vì sao client không thể tự biết yêu cầu POST đã được xử lý khi phản hồi bị mất? Hai cơ chế nào giúp retry an toàn?
- Khi nào dùng 409, khi nào dùng 422, khi nào dùng 400? Nêu một ví dụ cho từng mã.
- Bảng có 10 triệu đơn; so sánh chi phí của trang thứ 50.000 với offset và với keyset. Cần chỉ mục nào?
- Thay đổi nào sau đây phá vỡ client: thêm trường vào phản hồi, đổi tên trường, thêm giá trị enum, siết chặt độ dài tối đa của đầu vào?
PUTđược gọi hai lần liên tiếp với cùng nội dung nhưng lần hai trả 204 còn lần đầu trả 201. Phương thức này có idempotent không?- Vì sao ID tuần tự và thiếu kiểm tra quyền trên từng đối tượng là cặp nguy hiểm?
Gợi ý đáp án. (1) Client chỉ thấy lỗi mạng, không phân biệt được "chưa nhận" và "đã xử lý"; dùng phương thức idempotent hoặc khóa idempotency cho POST. (2) 400 cú pháp hoặc kiểu sai (quantity là chuỗi); 422 ngữ nghĩa sai (ngày giao trước ngày đặt); 409 xung đột trạng thái (email đã tồn tại). (3) Offset quét 1 triệu hàng để bỏ đi, keyset đọc đúng 20 hàng nhờ chỉ mục (created_at, id). (4) Đổi tên trường và siết chặt đầu vào phá vỡ; thêm trường và thêm giá trị enum tương thích nếu client được yêu cầu bỏ qua điều lạ. (5) Có: idempotent xét theo trạng thái trên server, không theo mã phản hồi. (6) Kẻ tấn công đoán ID và truy cập dữ liệu của người khác; kiểm tra quyền từng đối tượng là lớp bảo vệ chính, ID ngẫu nhiên chỉ giảm bề mặt dò.
Điều cần nhớ
- REST là tập ràng buộc; API mức 2 là lựa chọn thực dụng, hãy gọi đúng tên.
- Safe, idempotent và cacheable quyết định ai được retry và cache; đừng vi phạm chúng.
- Mã trạng thái là một phần của hợp đồng; chọn theo cây quyết định và ghi quy ước.
- Phân trang keyset cho dữ liệu lớn; luôn đặt trần kích thước trang.
- Phiên bản hóa cần chính sách ngừng hỗ trợ, không chỉ cấu hình kỹ thuật.
Học liệu chọn lọc
- RFC 9110: HTTP Semantics
- RFC 9457: Problem Details for HTTP APIs
- RFC 8288: Web Linking
- Roy Fielding: Architectural Styles and the Design of Network-based Software Architectures, chương 5
- Martin Fowler: Richardson Maturity Model
- Spring Framework Reference: API Versioning
- OWASP API Security Top 10
- The Java EE 7 Tutorial (Oracle): RESTful Web Services with JAX-RS
- Sách: Cloud Native Spring in Action (Thomas Vitale); Designing Data-Intensive Applications (Martin Kleppmann), chương về mã hóa và tiến hóa schema.