DTO (Data Transfer Object) là một phương pháp phổ biến để có thể trả dữ liệu cho người dùng từ các Rest API mà không làm lộ các dữ liệu nhạy cảm. Nói cách khác là cung cấp vừa đủ những gì mà người dùng yêu cầu.

Giới thiệu:

Giả sử bạn được yêu cầu xây dựng chức năng quản lý nhân sự cho Hệ thống Thông tin của một công ty. Thông tin của một nhân viên ngoài các thông tin cá nhân nội bộ có thể được gọi là phổ biến mà những người khác cùng công ty có thể biết được như: họ tên, email, số điện thoại, vị trí công tác, ... còn có những thông tin mang tính nhạy cảm mà chỉ những người có thẩm quyền mới được biết, ví dụ như: thông tin về lương, chi trả bảo hiểm, tình trạng sức khoẻ...  Hoặc đơn giản hơn: Manager có quyền xem được username account của Nhân viên nhưng không bao giờ được show password, hay nói cách khác thông tin về mật khẩu của User sẽ không bao giờ được nằm trong bộ dữ liệu trả về của các Rest API dù cho Client là ai.

Thực tế không ít lập trình viên do không hiểu hoặc cố tình không để ý đến yêu cầu trên của Hệ Thống mà chỉ muốn việc lập trình của mình được nhanh hơn mà bất chấp để trả về toàn bộ dữ liệu.

DTO có tác dụng gì ?

Khi thiết kế Database có thể tất cả những thông tin trên của Nhân viên được lưu vào một chỗ tập trung, ví dụ là table Employee chẳng hạn nhưng khi query data thì tuỳ vào Client (ngừoi dùng) là ai mà chúng ta sẽ xây dựng bộ dữ liệu thích hơp tương ứng. Kỹ thuật trên gọi là DTO (Data Transfer Object). 

Hơn thế nữa, về mặt performance: khi nhu cầu của Client cần những thông tin gì, Server nên trả vừa đủ những thông tin đó, không nên kèm theo thông tin thừa để có thể tiết kiệm được thời gian, tăng performance cho Hệ Thống,  cải thiện trải nghiệm người dùng.

Một ví dụ cho một Hệ Thống mua bán online điển hình: chức năng Profile (hồ sơ) của ngừoi dùng chỉ nên load đúng những thông tin cá nhân, và những dữ liệu khác như hoá đơn mua bán hay các tương tác khác không cần thiết phải được trả kèm theo.

Nếu không dùng DTO sẽ gặp những vấn đề gì ?

Như vấn đề trình bày ở trên, người dùng sẽ bị lộ dữ liệu nhạy cảm đồng thời performance của Hệ thống cũng sẽ bị ảnh hưởng.

Hướng dẫn xây dựng DTO response trong Spring Boot:

Bài viết sẽ sử dụng bài toán quản lý thông tin người dùng như ở trên để làm ví dụ hướng dẫn các bạn cách xây dựng response DTO. Chúng ta sẽ lần lượt đi qua các phương pháp sau:

Phương pháp 1: Custom class và getter/setter 

Ví dụ chúng ta có class User như sau:

@Entity
@Table(name = "cr_user",
        indexes = {@Index(name = "email_index", columnList = "email", unique = true)})
public class User extends BaseEntity{
    @Column(name = "email")
    @NotNull
    @Email
    private String email;

    @Column
    @NotNull
    private String fullName;

    @Column
    @NotNull
    private String password;

    @Column
    private String phone;

    @Column
    private String avatar;

    @Column
    @NotNull
    private Integer status;

    //getters and setters

}

Nhưng khi trả về cho người dùng chúng ta sẽ ẩn đi các trường password, status.

Và chúng ta sẽ define UserDTO như sau:

public class UserDTO {
    private String email;

    private String phone;

    private String fullName;

}

Như các bạn thấy chúng ta chỉ trả về 3 trường như trên cần đúng cho business của người dùng.

Và tất nhiên chúng ta sẽ phải tự map các lại trường dữ liệu, define constructor như sau:

public UserDTO(User original){
        this.email = original.getEmail();
        this.fullName = original.getFullName();
        this.phone = original.getPhone();
}

Và ở tầng Rest Controller của Spring Boot:

 @GetMapping(value = "/list")
    public List<UserDTO> getUserList(){
        List<User> users = userRepository.findAll();
        List<UserDTO> dtoList = new ArrayList<>();
        for (User user : users){
            dtoList.add(new UserDTO(user));
        }
        
        return dtoList;
    }
    

Một điểm lợi nữa ở trên không nhắc tới là chúng ta có thể tuỳ chỉnh lại cấu trúc của data trả về khác với cấu trúc data thật sự của Server. Ví dụ : chúng ta quy định status của User: 1 là valid, khác 1 là invalid. Chúng ta có thể map lại giá trị status bằng số này thành text để thân thiện với người dùng hơn.

public class UserDTO {
    private Long id;

    private String email;

    private String phone;

    private String token;

    private String fullName;
    
    private String status;


public UserDTO(User original){
        this.email = original.getEmail();
        this.fullName = original.getFullName();
        this.phone = original.getPhone();
        Integer status = original.getStatus();

        if (status != null && status == 1){
            this.status = "VALID";             
        }else {
            this.status = "INVALID";
        }
    }
}

 

Phương pháp 2: Interface DTO cho Spring Data

Phương pháp này về bản chất cũng tương tự như phương pháp 1 nhưng khác ở chỗ chúng sẽ define một interface thay vì một class bình thường. Các bạn lưu ý rằng phương pháp này áp dụng cơ chế hỗ trợ DTO Interface của Spring Data.

Chúng ta define interface UserDTOInf như sau:

public interface UserDTOInf {
    String getFullName();
    
    String getEmail();
    
    String getPhone();
}

Các method chúng ta đặt giống như các setter trong class tương ứng để khi Spring Data query được dữ liệu sẽ tự động set vào các trường tương ứng với các method define ở trên. Vd: getFullName --> fullName; .. 

Và trong UserRepository chúng ta sẽ define method như sau:

@Query(value = "select u from User 
List<UserDTOInf> queryUserListSummary();

Các bạn lưu ý giá trị trả về lúc này của chúng ta đã là  List<UserDTOInf> và ở tầng Controller chúng ta không cần phải map lại bằng tay như ở trên nữa.

Phương pháp 3: Annotation @ControllerAdvice

Và cuối cùng thêm 1 phương pháp nữa dựa trên sự tận dụng Annotation @ControllerAdvice của Spring. @ControllerAdvice là method được thực thi "sau khi method ở Controller được thực thi". Các bạn cũng có thể gọi nó là call back của Controller cũng được.

Cơ chế parse DTO sẽ như sau: sau khi dữ liệu được xử lý hoàn tất xử lý ở tầng Controller và chuẩn bị return cho client thì sẽ đi qua thêm 1 tầng ControllerAdvice để chuyển đổi dữ liệu thô thành các object DTO như chúng ta mong muốn. Chúng ta sẽ define class ControllerAdvice như sau:

import org.modelmapper.ModelMapper;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.json.MappingJacksonValue;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.util.Assert;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.AbstractMappingJacksonResponseBodyAdvice;

import java.util.Collection;

@ControllerAdvice
public class DtoMapperResponseBodyAdvice extends AbstractMappingJacksonResponseBodyAdvice {
    private ModelMapper modelMapper = new ModelMapper();

    @Override
    public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
        return super.supports(returnType, converterType) && returnType.hasMethodAnnotation(Dto.class);
    }

    @Override
    protected void beforeBodyWriteInternal(MappingJacksonValue bodyContainer, MediaType contentType,
                                           MethodParameter returnType, ServerHttpRequest request,
                                           ServerHttpResponse response) {
        Dto ann = returnType.getMethodAnnotation(Dto.class);
        Assert.state(ann != null, "No Dto annotation");
        Class<?> dtoType = ann.value();
        Object value = bodyContainer.getValue();
        Object returnValue;

        if (value instanceof Collection) {
            returnValue = ((Collection<?>) value).stream().map(it -> modelMapper.map(it, dtoType));
        } else {
            returnValue = modelMapper.map(value, dtoType);
        }
        bodyContainer.setValue(returnValue);
    }
}

Chúng ta sẽ cần thêm 1 custom Annotation

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Dto {
    Class<?> value();
}

Mục đích của Annotation được dùng để đánh dấu cho các method của Controller thực thi chuyển đổi DTO vì trong toàn bộ các method không phải lúc nào cũng cần chuyển đổi dữ liệu DTO.

Bước cuối cùng cho Controller:

 @GetMapping(value = "/list")
 @Dto(value = UserDTO.class)
 public List<User> getUserList(){
        return userRepository.findAll();
 }

Như các bạn có thể thấy như trên, trong body của method rest api chúng ta không cần phải map thủ công từng trường như ở phương pháp 1 mà chỉ cần khai báo @Dto, là response sẽ lập tức được chuyển đổi. Một điều cần lưu ý ở phương pháp này chúng ta hoàn toàn có thể chuyển đổi qua các class DTO khác nhau miễn là trong class DTO đó phải có các hàm tương ứng với class gốc. Nếu dữ liệu các trường cần được xử lý đặc biệt thì chúng ta phải modify lại class ControllerAdvice, ví dụ: tính toán dựa theo một số trường hoặc từ các class khác, thì người viết khuyên các bạn nên dùng phương pháp 1 để dễ kiểm soát được logic hơn.

Chúc các bạn thành công.

AutoCode.VN

minhnhatict@gmail.com