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 76 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 Java — Ngày 58: Optional

Optional là công cụ cho kiểu trả về — không field, không tham số, không bọc collection; chuỗi map/flatMap/filter thay kim tự tháp if; orElse luôn tính tham số còn orElseGet lười, orElseThrow cho “không có là lỗi” — và các anti-pattern isPresent + get, orElse(null).

23 thg 9, 20269 phút9
99 Ngày Spring — Ngày 58: Spring Boot Actuator

Boot 3 mặc định chỉ lộ health; bản đồ endpoint an toàn và nguy hiểm; HealthIndicator tự viết với liveness tách khỏi readiness cho Kubernetes, info từ build và git, metrics Micrometer với tag hữu hạn — và ba lớp khóa: cổng riêng, SecurityFilterChain với EndpointRequest, exclude env/heapdump/threaddump.

23 thg 9, 202610 phút5
So sánh EasyExcel và Apache Fesod: khác biệt thật nằm ở đâu?

Fesod là hậu duệ của EasyExcel, cùng engine SAX/SXSSF. Khác biệt kiểm chứng được: vòng đời dự án, POI 5.2.5 vs 5.5.1, các API mới (numRows, csv(), headerMergeStrategy) và độ bền với file xấu — không phải hiệu năng gấp nhiều lần.

23 thg 9, 20268 phút1