Trong kiến trúc RESTful, việc cập nhật tài nguyên một cách hiệu quả đòi hỏi sự phân biệt rõ ràng giữa các phương thức HTTP. Phương thức PATCH đóng vai trò then chốt khi chỉ cần thay đổi một số trường nhất định mà không ảnh hưởng đến phần còn lại của đối tượng.
1. Bản chất và vai trò của PATCH
PATCH là một phương thức HTTP dùng để thực hiện cập nhật từng phần (partial update) trên tài nguyên đã tồn tại. Khác với PUT — yêu cầu gửi toàn bộ biểu diễn của tài nguyên để thay thế hoàn toàn — PATCH cho phép gửi chỉ những trường cần chỉnh sửa.
Ví dụ: Với một tài nguyên người dùng có cấu trúc:
{
"id": 456,
"fullName": "Nguyễn Văn A",
"email": "a@example.com",
"status": "active",
"lastLogin": "2024-05-10T08:30:00Z"
}
Một yêu cầu PATCH chỉ cập nhật trạng thái:
PATCH /api/users/456
Content-Type: application/json
{"status": "inactive"}
Kết quả: Trường status được cập nhật; các trường khác giữ nguyên giá trị cũ.
2. So sánh PATCH và PUT
| Phương thức | Hành vi | Tính idempotent | Yêu cầu dữ liệu |
|---|---|---|---|
PUT |
Thay thế toàn bộ tài nguyên | Có (nếu dữ liệu giống nhau) | Phải gửi đầy đủ tất cả trường hợp lệ |
PATCH |
Cập nhật chỉ các trường được cung cấp | Không bắt buộc — phụ thuộc vào logic xử lý | Chỉ gửi các trường cần thay đổi |
Lưu ý quan trọng: Tính idempotent của PATCH không được đảm bảo bởi giao thức — nó phụ thuộc hoàn toàn vào cách triển khai phía máy chủ.
3. Triển khai PATCH trong Spring Boot
Spring MVC cung cấp chú giải @PatchMapping, nhưng không tự động áp dụng logic cập nhật từng phần. Việc này phải do lập trình viên kiểm soát ở tầng Service và Mapper.
3.1. Định nghĩa endpoint
@RestController
@RequestMapping("/api/users")
public class UserManagementController {
@PatchMapping("/{userId}")
public ResponseEntity<UserResponse> updateUserPartially(
@PathVariable Long userId,
@Valid @RequestBody UserUpdateRequest patchData) {
User updated = userService.applyPartialUpdate(userId, patchData);
return ResponseEntity.ok(UserResponse.from(updated));
}
}
3.2. DTO linh hoạt cho cập nhật từng phần
public record UserUpdateRequest(
String fullName,
String email,
String phone,
UserStatus status
) {}
Các trường đều có thể là null — điều này phản ánh đúng ngữ nghĩa của PATCH: chỉ cập nhật nếu giá trị được cung cấp.
3.3. Xử lý logic cập nhật trong Service
public User applyPartialUpdate(Long userId, UserUpdateRequest patch) {
User existing = userRepository.findById(userId)
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
// Chỉ gán giá trị nếu không null
Optional.ofNullable(patch.fullName()).ifPresent(existing::setFullName);
Optional.ofNullable(patch.email()).ifPresent(existing::setEmail);
Optional.ofNullable(patch.phone()).ifPresent(existing::setPhone);
Optional.ofNullable(patch.status()).ifPresent(existing::setStatus);
return userRepository.save(existing);
}
3.4. MyBatis Dynamic SQL hỗ trợ cập nhật có điều kiện
<update id="updateBySelective" parameterType="User">
UPDATE users
<set>
<if test="fullName != null">full_name = #{fullName},</if>
<if test="email != null">email = #{email},</if>
<if test="phone != null">phone = #{phone},</if>
<if test="status != null">status = #{status},</if>
updated_at = NOW()
</set>
WHERE id = #{id}
</update>
Đây là kỹ thuật selective update phổ biến, giúp tránh ghi đè giá trị bằng null khi trường không được gửi.
4. Xử lý giá trị null tường minh
Nếu nghiệp vụ yêu cầu đặt một trường về null (ví dụ: xoá số điện thoại), cách tiếp cận trên sẽ không đủ. Khi đó, nên sử dụng chuẩn application/merge-patch+json hoặc mô hình DTO riêng:
public record UserPatchPayload(
@Nullable String fullName,
@Nullable String email,
@Nullable Boolean clearPhone // đánh dấu yêu cầu xóa
) {}
Sau đó xử lý tương ứng trong Service:
if (patch.clearPhone() != null && patch.clearPhone()) {
existing.setPhone(null);
}
5. Kiểm tra đầu vào với Bean Validation
Để đảm bảo tính toàn vẹn dữ liệu, kết hợp @Valid với các chú giải xác thực:
public record UserUpdateRequest(
@Size(max = 100, message = "Họ tên quá dài") String fullName,
@Email(message = "Email không hợp lệ") String email,
@Pattern(regexp = "^\\+?[0-9]{10,15}$", message = "Số điện thoại sai định dạng")
String phone
) {}
Lưu ý: Các chú giải như @NotBlank chỉ phù hợp với chuỗi cần loại bỏ khoảng trắng trước khi kiểm tra, trong khi @NotEmpty chỉ đảm bảo chuỗi không null và độ dài > 0 — kể cả khi toàn khoảng trắng.
6. Thiết kế API cho tải tệp (avatar)
Khi cập nhật ảnh đại diện, tốt nhất nên tách biệt luồng xử lý:
- Database: Lưu duy nhất URL công khai (ví dụ:
https://cdn.example.com/avatars/u456.jpg) - Storage: Sử dụng dịch vụ lưu trữ đối tượng (AWS S3, OSS, COS) hoặc CDN tĩnh
- Upload flow: Ưu tiên client-side direct upload qua pre-signed URL để giảm tải server
Một endpoint PATCH cập nhật avatar có thể nhận URL trực tiếp:
PATCH /api/users/456
{"avatarUrl": "https://cdn.example.com/avatars/u456_v2.jpg"}
Hoặc tích hợp với quy trình upload hai bước nếu cần xử lý ảnh (nén, cắt, thêm watermark).
7. Truyền tham số kiểu query string
Khi tài liệu API ghi rõ "tham số kiểu query string", nghĩa là bạn phải truyền dữ liệu sau ký tự ? trong URL:
GET /api/users?role=admin&page=2&size=20
Đây là cách tiêu chuẩn cho các tham số tìm kiếm, phân trang, lọc — khác biệt rõ ràng với dữ liệu JSON trong body (@RequestBody) hay tham số đường dẫn (@PathVariable).
8. Các cách nhận dữ liệu JSON trong Controller
Tùy vào mức độ linh hoạt và kiểm soát yêu cầu, có thể chọn:
@RequestBody UserDTO: Phù hợp nhất cho API có cấu trúc cố định, hỗ trợ validation mạnh@RequestBody Map<String, Object>: Dùng khi cấu trúc JSON thay đổi thường xuyên@RequestBody JsonNode: Cần phân tích cây JSON thủ công hoặc xử lý schema động@RequestBody List<UserDTO>: Khi nhận mảng đối tượng (batch operation)
Không nên dùng @RequestParam cho dữ liệu JSON — vì nó dành riêng cho query string hoặc form data.