Backend

99 Ngày Spring — Ngày 13: @RequestBody & DTO — đừng công khai entity ra API

SSite Admin
9 tháng 08, 2026 6 phút đọc 9 lượt xem
99 Ngày Spring — Ngày 13: @RequestBody & DTO — đừng công khai entity ra API

Ngày 12 API mới biết đọc — hôm nay nó học nhận hàng: client gửi cả một khối JSON để tạo bài viết mới, và @RequestBody biến khối đó thành object Java trước khi bạn kịp chớp mắt. Nhưng nhân vật chính hôm nay thật ra là một chữ viết tắt: DTO — Data Transfer Object, cùng nguyên tắc sống còn đừng bao giờ công khai entity ra ngoài API — bài học mà rất nhiều hệ thống thật đã trả giá đắt để học.

Sketchnote Ngày 13: @RequestBody, DTO vs entity, lỗ hổng mass assignment và Jackson annotations

@RequestBody — nhận JSON vào object

// DTO cho dữ liệu ĐI VÀO — chỉ những field client ĐƯỢC PHÉP gửipublic record CreatePostRequest(String title, String content) { }

@RestController@RequestMapping("/api/posts")public class PostController {

    @PostMapping                              // POST /api/posts
    public Post create(@RequestBody CreatePostRequest req) {
        return postService.create(req);       // req đã là object Java xịn
    }
}

// Client gửi:// POST /api/posts// Content-Type: application/json// { "title": "Ngày 13", "content": "RequestBody & DTO" }//// Jackson DESERIALIZE: JSON → CreatePostRequest — chiều ngược của Ngày 11// Body không phải JSON hợp lệ? → 400 Bad Request, method không thèm chạy
  • Đây là chiều ngược của Ngày 11: hôm đó Jackson serialize object → JSON; hôm nay nó deserialize JSON → object. Một thư viện, hai chiều, đều tự động.

  • POST xuất hiện đúng vai trong bảng động từ Ngày 12: tạo mới tài nguyên — dữ liệu nằm trong body, không phải URL (URL có độ dài giới hạn, lộ trên log, và không chứa nổi cấu trúc lồng nhau).

  • record + Jackson là cặp trời sinh cho DTO: gọn một dòng, bất biến, tên field thành tên key.

  • Mẹo thử nhanh: dùng curl -X POST -H "Content-Type: application/json" -d '{...}' hoặc file .http trong IDE — nhớ đúng Content-Type, thiếu nó là 415 Unsupported Media Type.

Vì sao không nhận thẳng entity? Mass assignment!

Cám dỗ lớn nhất của người mới: "class Post có sẵn rồi, nhận luôn cho nhanh". Đây là một trong những lỗ hổng bảo mật phổ biến nhất thế giới thực:

// Entity — mô hình DỮ LIỆU đầy đủ trong hệ thống (mai mốt: bảng DB, Ngày 22)public class Post {
    private Long id;
    private String title;
    private String content;
    private String authorEmail;      // nhạy cảm!
    private boolean published;
    private int secretScore;         // nội bộ!
}

// ✗ NHẬN THẲNG ENTITY: public Post create(@RequestBody Post post)//// Client "tốt bụng" gửi thêm:// { "title": "...", "published": true, "secretScore": 9999, "id": 1 }// → Jackson điền TẤT CẢ field khớp tên — kể cả thứ bạn không mời!// → Lỗ hổng có tên tuổi: MASS ASSIGNMENT//   (cùng họ với chuyện field public hôm nay bên series Java!)//// ✓ DTO CreatePostRequest chỉ có title + content// → published, secretScore, id... KHÔNG TỒN TẠI trong hợp đồng// → client gửi thừa: Jackson lặng lẽ bỏ qua — cửa đóng từ thiết kế
  • Jackson không biết field nào "nhạy cảm" — nó chỉ khớp tên. Hợp đồng API của bạn chính là DTO: field không khai trong DTO thì không tồn tại với client — an toàn từ thiết kế, không phải từ cảnh giác.

  • GitHub từng dính đúng lỗ này năm 2012 — một nhà nghiên cứu tự thêm public key vào tổ chức Rails bằng cách gửi thừa field. Chuyện thật, không phải ví dụ giáo khoa.

  • Đồng điệu thú vị: cùng ngày hôm nay, series Java học đóng gói — che field, mở cửa có kiểm soát. DTO chính là đóng gói ở biên giới hệ thống.

DTO chiều ra — lộ đúng thứ muốn lộ

// DTO cho chiều ĐI RA — cũng chọn lọc nốt:public record PostResponse(Long id, String title, String content,
                           String authorName) {          // KHÔNG authorEmail!

    static PostResponse from(Post post) {                // map entity → DTO
        return new PostResponse(post.getId(), post.getTitle(),
                post.getContent(), post.getAuthorName());
    }
}

@GetMapping("/{id}")public PostResponse detail(@PathVariable Long id) {
    return PostResponse.from(postService.find(id));      // lộ đúng thứ muốn lộ
}

// Một tài nguyên — nhiều DTO tùy ngữ cảnh là BÌNH THƯỜNG:// PostSummary (list, không content) ≠ PostResponse (detail, đầy đủ)// Map tay 3 dòng là đủ cho hôm nay — MapStruct tự sinh code map: Ngày 17
  • Chiều ra còn dễ quên hơn chiều vào: trả thẳng entity là rò rỉ dữ liệu (email tác giả, cờ nội bộ…) và khóa cứng schema DB vào hợp đồng API — sau này đổi cột DB là vỡ client.

  • Request DTO và Response DTO khác nhau là chuyện thường: client gửi title + content, nhận về thêm id + authorName — hai hợp đồng, hai mục đích.

  • Map tay hôm nay hơi thủ công? Đúng — và đó là lý do Ngày 17 dành riêng cho MapStruct: sinh code map lúc biên dịch.

Jackson annotations — tinh chỉnh JSON

public record CreatePostRequest(
    @JsonProperty("tieu_de") String title,     // JSON tên khác ↔ field tên khác
    String content,
    @JsonFormat(pattern = "dd/MM/yyyy") LocalDate publishOn  // parse ngày kiểu VN
) { }

// Chiều ra — giấu field null cho JSON gọn:@JsonInclude(JsonInclude.Include.NON_NULL)public record PostResponse(Long id, String title, String draftNote) { }// draftNote = null → JSON không có key draftNote luôn

// Cấu hình Jackson TOÀN CỤC qua application.yml (Ngày 08 tái xuất!):// spring.jackson.property-naming-strategy: SNAKE_CASE   → title_en, author_name// spring.jackson.default-property-inclusion: non_null
  • @JsonProperty gỡ kẹt khi tên JSON (snake_case, tiếng Việt, tên cũ kỹ) không khớp quy ước Java; @JsonFormat thuần hóa ngày giờ.

  • Chuẩn chung cho cả API (như snake_case toàn bộ)? Đặt một dòng trong application.yml — đúng bài cấu hình ngoài code của Ngày 08, đừng rải annotation từng field.

  • Nguyên tắc chốt: DTO là hợp đồng, annotation là điều khoản — mọi tinh chỉnh nằm trên DTO, entity không bao giờ phải biết JSON là gì.

Bài tập nhỏ

  • Thêm POST /api/books nhận CreateBookRequest(title, price) — thử gửi thêm field "id": 999 và xác nhận nó bị lặng lẽ bỏ qua.

  • Tạo BookResponse giấu một field "nội bộ" (ví dụ costPrice) — so sánh JSON trước/sau khi chuyển từ trả entity sang trả DTO.

  • Gửi JSON hỏng (thiếu ngoặc) và JSON sai kiểu ("price": "abc") — quan sát hai kiểu 400 khác nhau của Boot.

  • Bật SNAKE_CASE toàn cục qua yml — xem toàn bộ API đổi họ tên key trong một dòng cấu hình.

Kết luận

API đã biết nhận hàng đúng chuẩn: @RequestBody + Jackson đưa JSON vào object, DTO là hợp đồng chặn mass assignment ở chiều vào và rò rỉ dữ liệu ở chiều ra, còn Jackson annotations tinh chỉnh từng điều khoản. Nhưng hợp đồng mới kiểm soát hình dạng, chưa kiểm soát chất lượng: title rỗng tuếch hay dài 10 nghìn ký tự vẫn lọt cửa. Ngày 14: Bean Validation@Valid, @NotBlank, @Size… tuyến phòng thủ khai báo ngay trên DTO. Hẹn gặp lại!

S

Site Admin

Engineer and writer. Building things with TypeScript and distributed systems.

Bình luận (0)

Bạn cần đăng nhập bằng Google để bình luận.

Hãy là người bình luận đầu tiên.

Bài viết liên quan

99 Ngày Spring — Ngày 14: Bean Validation — @Valid & bộ constraint chuẩn

Ngày 14 của 99 Ngày Spring: Bean Validation khai báo luật ngay trên DTO — @Valid kích hoạt, bộ constraint chuẩn (@NotBlank vs @NotEmpty vs @NotNull, @Size, @Email, @Min/@Max), bẫy int vs Integer, validate object lồng nhau không tự lan, và lỗi 400 gom một lượt.

10 thg 8, 20265 phút0
99 Ngày Java — Ngày 14: Kế thừa (Inheritance)

Ngày 14 của 99 Ngày Java: extends trao gia tài từ lớp cha, override với @Override và super., thứ tự khai sinh cha trước con sau qua super(...), protected trả nợ Ngày 13, và phép thử is-a vs has-a — khi nào nên composition thay vì kế thừa.

10 thg 8, 20266 phút0
99 Ngày Java — Ngày 13: Đóng gói (Encapsulation)

Ngày 13 của 99 Ngày Java: vì sao public field là thảm họa, công thức private + getter + nghiệp vụ có kiểm soát (không setter bừa bãi), bốn mức access modifier với chiến lược kín nhất có thể, và quy ước JavaBean bắt tay framework.

9 thg 8, 20266 phút19