Skip to main content

Command Palette

Search for a command to run...

ปัญหาสุด Classic ของ Spring boot กับสิ่งที่เรียกว่า DTOs - ตอนที่ 1

Updated
3 min read
ปัญหาสุด Classic ของ Spring boot กับสิ่งที่เรียกว่า DTOs - ตอนที่ 1

สวัสดีครับ วันนี้จะมาพูดถึงหนึ่งในปัญหาคลาสสิกที่นักพัฒนา Spring Boot ต้องเจอ นั่นคือการจัดการข้อมูลระหว่าง Server กับ Client


ลองนึกภาพว่าคุณส่งอ็อบเจกต์ Customer ทั้งก้อน ที่มีทั้งรหัสผ่าน, ข้อมูลบัตรเครดิต, หรือข้อมูลส่วนตัวอื่นๆ ออกไปให้ไคลเอนต์... ไอ้เลวนี่ มันคนดีนี้หว่า
วิธีที่นิยมที่สุดในการแก้ปัญหานี้ก็คือการใช้ DTOs (Data Transfer Objects) ครับ แต่ DTOs เป็นสิ่งจำเป็นจริงๆ หรือเป็นแค่การทำงานเกินความจำเป็น (overkill)? แล้วมีวิธีอื่นอีกไหม?

สมมติว่าเราเก็บข้อมูลแบบนี้

ColumnValueDescription
id1
firstNameสมปอง
lastNameใจดี
emailsompong@gmail.comSensitive data
passwordpass_sompongSensitive data

แล้ว code เราไม่ได้มีการ Map DTO ก่อน Response ออกไป ซึ่งมันก็คือการ Response ค่าออกไปทั้งหมด ซึ่งไม่ควร ตัวอย่างตามรูปข้างล่างนี้

{
  "id": 1,
  "firstName": "สมปอง",
  "lastName": "ใจดี",
  "email":"sompong@gmail.com",
  "password":"pass_sompong"
}

แต่แบบไหนละที่เราควรจะทำ แน่นวลครับ แบบที่เราควรทำคือ เราต้องรู้ไห้ได้ก่อนว่าข้อมูล column ไหนที่มีความสำคัญมาก ซึ่งห้ามรั่วไหล (Sensitive Data) แล้วเราติดข้อมูลนั้นออก ซึ่งจะไม่ถูก Response ออกไป ตัวอย่างตามรูปข้างล่างนี้

{
  "id": 1,
  "firstName": "สมปอง",
  "lastName": "ใจดี",
  "email":"sompong@gmail.com"
}

ถ้าหากมองภาพรวม จะเป็นประมาณนี้

Ok. ทีนี้พอเราเริ่มเข้าใจตรงกันแล้ว ซึ่งผมใช้อยู่ 3-4 กระบวนท่า แบ่ง Level ตามหน้างาน


DTOs (Data Transfer Objects) คืออะไร?

DTOs (Data Transfer Objects) พูดง่ายๆ ก็คือ คลาส Java (POJOs) ที่ทำหน้าที่เป็น "ตัวกลางบรรจุข้อมูล" (data carriers) เพื่อส่งผ่านไปมาระหว่างเลเยอร์ต่างๆ ของแอปพลิเคชัน (เช่น ระหว่าง Service กับ Controller) หรือระหว่างเซิร์ฟเวอร์กับไคลเอนต์

เป้าหมายเดียว ของมันคือการกำหนด "รูปร่าง" ของข้อมูลที่จะส่งไปหรือรับเข้ามาเท่านั้นครับ

ในบทความนี้ เราจะเรียกคลาสที่มี @Entity (JPA annotation) ว่า Entities และเรียกคลาสที่ไม่มี JPA annotation (ซึ่งเราจะใช้ record) ว่า DTOs

ทำไมเราถึงควรใช้ DTOs?

  1. การแยกส่วนการทำงาน (Separation of Concerns):

    • Entities ใช้แทนโครงสร้างของตารางในฐานข้อมูล (Relational Model)

    • DTOs ใช้เพื่อถ่ายโอนข้อมูลระหว่างเลเยอร์ต่างๆ

    • การทำเช่นนี้จะช่วยให้เราไม่เปิดเผยรายละเอียดภายในของฐานข้อมูล (DB) ออกไปให้ผู้บริโภค API (API consumers) เห็น

  2. เพิ่มความปลอดภัย (Security):

    • นี่คือเหตุผลหลักเลยครับ DTOs ช่วยให้เราสามารถ "เลือก" เปิดเผยเฉพาะข้อมูลที่จำเป็นเท่านั้น

    • ทำให้ข้อมูลที่ละเอียดอ่อน เช่น รหัสผ่าน หรือข้อมูลส่วนตัวอื่นๆ ถูกเก็บไว้ปลอดภัยบนเซิร์ฟเวอร์ ไม่หลุดออกไปที่ API

  3. ประสิทธิภาพ (Performance):

    • DTOs สามารถบรรจุข้อมูลเฉพาะฟิลด์ที่จำเป็นจริงๆ ช่วยลดขนาดข้อมูล (payload) ที่ต้องส่งผ่านเครือข่าย และลดภาระงาน (overhead) ในการ Serialize/Deserialize ข้อมูลที่ไม่จำเป็น

วิธีการใช้ DTOs กับ Spring Boot

บทความนี้จะเน้นไปที่การใช้ DTOs ร่วมกับ Spring Data JPA นะครับ

สิ่งที่ต้องมี:

  • Java 17 หรือสูงกว่า

  • IDE ที่คุณถนัด (เช่น IntelliJ IDEA)

  • Dependencies: Spring Web, Spring Data JPA, H2 Database, Validation


1. DTOs กับ Java ยุคใหม่: การใช้ Records

ตั้งแต่ Java 14+ เรามีฟีเจอร์ที่ชื่อว่า Records ซึ่งมันคือ "ตัวกลางบรรจุข้อมูล" ที่เป็นแบบ Immutable (แก้ไขค่าไม่ได้) โดยธรรมชาติ

Records ช่วยให้โค้ดกระชับมาก เพราะมันจะสร้าง private final fields, accessors (getters), constructor, equals(), hashCode(), และ toString() ให้เราอัตโนมัติ

DTOs จึงเข้ากันได้ดีมากกับ Records
ลองดูตัวอย่าง Entity Employee:

package com.ws.workshop.entity;

import jakarta.persistence.*;
import lombok.Data;

@Data
@Entity
@Table(name="employees")
public class Employee {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String firstName;
    private String lastName;
    private String email;
    private String password;
}

เราสามารถสร้าง Response DTO (สำหรับส่งข้อมูลกลับ) ได้ดังนี้:

// สังเกตว่าไม่มีฟิลด์ password
public record EmployeeDTO(Long id, String name, String email) { 
}

และถ้าข้อมูลที่รับเข้ามา (Request) ไม่เหมือนกับข้อมูลที่ส่งออกไป (Response) เราก็สามารถสร้าง Request DTO แยกต่างหากได้:

// DTO สำหรับรับข้อมูลเข้ามา (เช่น ตอนสร้าง Employee ใหม่)
public record EmployeeRequestDTO(
    @NotNull String name, 
    @NotNull String email, 
    @NotNull String password
) { 
}

ถ้ามี Entity ซ้อนกัน (Nested Entities) ล่ะ? เราก็แค่สร้าง DTOs ที่ซ้อนกันตามครับ (เรียกว่า Composite DTO)

// Entity Customer มี List<Order>
// Entity Order มี Customer

public record CustomerResponse(Long id, String name, String email, List orders) { } 
public record OrderResponse(Long id, double totalPrice) { }

2. การแปลงข้อมูล (Mapping) Entities ไปเป็น DTOs
เมื่อเรามี Entities และ DTOs แล้ว เราต้องมีตัวกลางในการ "แปลงค่า" ไปมาระหว่างกัน

วิธีที่ 1: การ Map ด้วยตนเอง (Manual Mapping)

นี่เป็นวิธีที่ตรงไปตรงมา เพราะควบคุมทุกอย่างเอง 100% ไม่ต้องพึ่งไลบรารีภายนอก
เราจะสร้างคลาส Mapper ขึ้นมาเอง:

// คลาส Java ธรรมดาๆ
public class ManualCustomerMapper {

    // แปลงจาก Entity -> DTO
    public CustomerResponse mapToCustomerResponse(Customer customer) {
        return new CustomerResponse(
            customer.getId(),
            customer.getName(),
            customer.getEmail(),
            customer.getOrders() // แปลง List ที่ซ้อนกันด้วย
                .stream()
                .map(this::mapToOrderResponse)
                .toList()
        );
    }

    public OrderResponse mapToOrderResponse(Order order) {
        return new OrderResponse(order.getId(), order.getTotalPrice());
    }

    // แปลงจาก DTO -> Entity
    public Customer mapToCustomer(CustomerRequest customerRequest) {
        Customer customer = new Customer();
        customer.setName(customerRequest.name());
        customer.setEmail(customerRequest.email());
        customer.setPassword(customerRequest.password());
        return customer;
    }
}

จากนั้นใน CustomerService ของ ก็แค่เรียกใช้ Mapper นี้:

package com.ws.workshop.services;

import com.ws.workshop.mapper.ManualCustomerMapper;
import com.ws.workshop.repository.CustomerRepository;
import com.ws.workshop.response.CustomerResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
@RequiredArgsConstructor
public class CustomerService {

    private final CustomerRepository customerRepository;
    private final ManualCustomerMapper customerMapper;

    public List<CustomerResponse> findAll(){
        return customerRepository.findAll()
                .stream()
                .map(customerMapper::mapToCustomerResponse)
                .toList();
    }
}

จากนั้นก็ลองเรียกใข้จาก Controller เพื่อดูผลลัพธ์ครับ

package com.ws.workshop.controller;

import com.ws.workshop.response.CustomerResponse;
import com.ws.workshop.services.CustomerService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequestMapping("/api/customer")
@RequiredArgsConstructor
public class CustomerController {

    private final CustomerService customerService;

    @GetMapping
    public List<CustomerResponse> getCustomerAll() {
        return customerService.findAll();
    }
}


» ต่อตอนที่ 2