Salta el contingut

Nous DTOS

1. Patrò de disseny DTO

El DTO (Data Transfer Object) és un patró de disseny que ens permet transferir dades entre diferents capes d'una aplicació (per exemple, entre la capa de servei i la capa de presentació) sense exposar les entitats de base de dades directament. Això ens ajuda a mantenir un bon encapsulament, millorar la seguretat i optimitzar les respostes enviant només els camps necessaris.

Problema amb exposar entitats:

  1. Seguretat: L'entitat pot tenir camps sensibles (passwords, tokens) que no vols enviar al client ni que eel client puga modificar (over-posting).
  2. Encapsulament: L'entitat (@Entity) està lligada a la base de dades i pot canviar amb el temps i AcoblamentSi l'exposes directament, qualsevol canvi a la BD trenca l'API pública.
  3. Recursions: Department té List i Employee té Department → JSON infinit. D'alguna manera haurem d'evitar-ho, moltes veegades aplanant les relacions. Per exemple, en comptes de enviar tot el Department dins Employee, només enviar el nom del departament.
  4. Sobrecàrrega de dades: Potser el client només necessita id i nom, i estàs enviant-li tot l'objecte.

Per això, en comptes d'exposar les entitats, creem classes DTO que només contenen els camps que volem transferir. Així, el client només veu el que necessita i podem controlar millor la nostra API.

Els objectes DTO seràn molt (però que molt) similars a les entitats, però només amb els camps que volem exposar. El que si necessitarem és una manera de convertir entre entitats i DTOs (i viceversa) quee siga ràpida i àgil.

2. Creació clàssica de DTO

Anem a explicar la creació de DTO amb unes classes clàssiques a la literatura de Java dins del mon empresarial, les classes Empleat i Departament. Partim de les seues entitats i després crearem els DTOs corresponents.

Java
@Entity
public class Empleat {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String nom;
    private String email;
    private double salari;

    @ManyToOne
    private Departament departament;

    // getters, setters, constructors...
}
Java
@Entity
public class Departament {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String nom; 

    @OneToMany(mappedBy = "departament")
    private List<Empleat> empleats; 
    // getters, setters, constructors...
}

Com podem veure, a banda dels camps, aquestes entitas tenen una relació bidireccional que pot provocar problemes de recursió infinita a l'hora de convertir a JSON. Per això, en els DTOs, normalment "aplanarem" aquesta relació i només inclourem el nom del departament dins l'empleat, i per exemple el número d'empleats del departament.

Així les classes DTO podrien quedar així:

Java
1
2
3
4
5
6
7
8
9
public class EmpleatDTO {
    private Long id;
    private String nom;
    private String email;
    private double salari;
    private String nomDepartament; // Nom del departament en comptes de l'objecte

    // getters, setters, constructors...
}
Java
1
2
3
4
5
6
public class DepartamentDTO {
    private Long id;
    private String nom;
    private int numeroEmpleats; // Número d'empleats en comptes de la llista    
    // getters, setters, constructors...
}

Ens falta crear els mètodes de conversió entre Empleat i EmpleatDTO, i entre Departament i DepartamentDTO. Dins de les maneres clàssiques, podem fer-ho mitjançant constructors que reben l'entitat, o bé mitjançant un mètode estàtic a la classe DTO.

Java
1
2
3
4
5
6
7
public EmpleatDTO(Empleat empleat) {
    this.id = empleat.getId();
    this.nom = empleat.getNom();
    this.email = empleat.getEmail();
    this.salari = empleat.getSalari();
    this.nomDepartament = empleat.getDepartament().getNom(); // Aplanem la relació
}
Java
1
2
3
4
5
6
7
8
9
public static EmpleatDTO fromEntity(Empleat empleat) {
    EmpleatDTO dto = new EmpleatDTO();
    dto.setId(empleat.getId());
    dto.setNom(empleat.getNom());
    dto.setEmail(empleat.getEmail());
    dto.setSalari(empleat.getSalari());
    dto.setNomDepartament(empleat.getDepartament().getNom());
    return dto;
}

Respecte dels departaments:

Java
1
2
3
4
5
public DepartamentDTO(Departament departament) {
    this.id = departament.getId();
    this.nom = departament.getNom();
    this.numeroEmpleats = departament.getEmpleats().size(); // Aplanem la relació
}
Java
1
2
3
4
5
6
7
public static DepartamentDTO fromEntity(Departament departament) {
    DepartamentDTO dto = new DepartamentDTO();
    dto.setId(departament.getId());
    dto.setNom(departament.getNom());       
    dto.setNumeroEmpleats(departament.getEmpleats().size());
    return dto;
}

2.1. Builders

Amb la potència de Lombok, s'inclouen els builders, que són una manera molt còmoda de crear objectes complexos sense haver de passar molts paràmetres al constructor. Amb un builder, pots construir l'objecte pas a pas, i és especialment útil quan tens molts camps opcionals.

El builder és un mètodee que s'invoca de manera estàtica i que retorna un objecte que té mètodes per a cada camp, i finalment un build() que crea l'objecte final. Això fa que el codi sigui molt més llegible i fàcil de mantenir. Està present a Lombok mitjançant l'anotació @Builder.

Java
@Builder
public class EmpleatDTO {
    private Long id;
    private String nom;
    private String email;
    private double salari;
    private String nomDepartament; 

    // getters, setters, constructors...
}
Java
1
2
3
4
5
6
7
EmpleatDTO dto = EmpleatDTO.builder()
    .id(empleat.getId())
    .nom(empleat.getNom())
    .email(empleat.getEmail())
    .salari(empleat.getSalari())
    .nomDepartament(empleat.getDepartament().getNom())
    .build();

El del departament seria similar.

2.2. Processament amb Streams

Imaginem que tenim una col·lecció d'empleats i volem convertir-los a DTOs. Podem fer-ho de manera molt eficient i elegant amb Streams, evitant bucles i codi repetitiu. Hem d'aplicar el mètode de conversió (constructor o mètode estàtic) a cada element de la col·lecció d'entitats.

Conversió d'una llista d'Empleats a EmpleatDTOs amb Streams
1
2
3
4
5
List<Empleat> empleats = ... // (1)!

List<EmpleatDTO> dtos = empleats.stream()   // (2)!
    .map(EmpleatDTO::fromEntity) // (3)! Aplica el mètode de conversió a cada empleat
    .collect(Collectors.toList()); // (4)!
  1. Obtenim la llista d'empleats de la base de dades. Com veurem a continuació, això es podrà fer des de un repositori.
  2. Inicia el Stream a partir de la llista d'empleats
  3. Aplica el mètode de conversió a cada empleat, transformant cada Empleat en un EmpleatDTO. El resultat és un Stream de EmpleatDTO.
  4. Finalment, recollim el resultat en una llista de DTOs que podem retornar al client.

3. Jackson ObjectMapper

Com hem vist, els mètodes manuals de conversió, be al constructor o be a través d'un mètode estàtic, poden ser molt repetitius i tediosos, especialment quan tenim moltes entitats i molts camps, és el que es coneix com boilerplate code.

Per això, una alternativa molt popular és utilitzar la classe ObjectMapper de la biblioteca Jackson, que Spring Boot ja inclou per defecte. El requisit és simple: tu donam dos classes distintes que tinguen camps que es diguen igual. A partir d'aquí, ObjectMapper farà la conversió automàtica entre les dues classes, sense necessitat de que escrigues codi de conversió manual.

I que passa quan els camps no es diueen igual o volem fer algun aplanat? No tindrem més remei que programar manualment aquestes parts, però hem eliminat la major part del codi repetitiu.

Conversió amb ObjectMapper
@Autowired
private ObjectMapper objectMapper; // (1)!

public EmpleatDTO fromEntity(Empleat empleat) {
    EmpleatDTO dto = objectMapper.convertValue(empleat, EmpleatDTO.class); // (2)!

    // Si hi ha camps que no coincideixen, els podem configurar manualment
    dto.setNomDepartament(empleat.getDepartament().getNom()); // (3)!
    return dto;
}
  1. Inyectem el ObjectMapper que Spring Boot ja té configurat.
  2. Utilitzem el mètode convertValue() per a convertir l'objecte empleat en un objecte EmpleatDTO. Aquest camps (id, nom, email, salari) es convertiran automàticament.
  3. Aplanem lo distints: li assignem a nomDepartament el nom del departament.
Conversió amb ObjectMapper
1
2
3
4
5
6
7
8
@Autowired
private ObjectMapper objectMapper;

public DepartamentDTO fromEntity(Departament departament) {
    DepartamentDTO dto = objectMapper.convertValue(departament, DepartamentDTO.class);
    dto.setNumeroEmpleats(departament.getEmpleats().size());
    return dto;
}

Existeixen algunes anotacions de Jackson que ens permeten personalitzar el comportament de la conversió:

  • @JsonIgnore per a ometre camps
  • @JsonProperty per a mapejar camps amb noms diferents.
  • En relacions bidireccionals, podem usar @JsonManagedReference i @JsonBackReference per a evitar la recursió infinita:
    • @JsonManagedReference s'aplica a la part "pare" de la relació (per exemple, a Empleat.departament). Això es fa que, per exemple de un empleat mostres el departament.
    • @JsonBackReference s'aplica a la part "fill" de la relació (per exemple, a Departament.empleats). Això fa que, per exemple, quan mostres un departament no mostres la llista d'empleats, evitant així la recursió infinita.

      Si has aplanat les relacions no necessites recòrrer a aquestes anotacions, ja que no hi ha cap camp que referencie l'altre objecte. Això caldrà quan lees relacions continuen en el DTO.

ObjectMapper i els serveis

Com hauràs vist al codi hem de injectar un instància de ObjectMapper. Això ens pot resultar interessant fer-ho a nivell de servei, ja que és on normalment realitzarem les conversions entre entitats i DTOs.

Per tant esperarem al servei si volem fer servir ObjectMapper per a les conversions.

Sí que pots implementar els mètodes de conversió dins de les classes DTO, però en crides a constructors o builders.

4. Exercici. Paquet DTO del nostre projecte

Ara es presenta l'exercici de crear els DTOs i els mètodes de conversió per a les entitats del projecte. El paquet on es trobaran els DTOs serà com.sreaming.dto. Intenta fer-ho per tu mateix abans de mirar la sol·lució, i després compara el teu codi amb el que es presenta a continuació.

Punt de Partida

Usuari.java
    package com.streaming.model;

import jakarta.persistence.*;
import lombok.Data;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

import com.fasterxml.jackson.annotation.JsonIgnore;

@Entity
@Table(name = "usuari")
@Data
@NoArgsConstructor
public class Usuari {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long idUsuari;

    @Column(nullable = false)
    private String nom;

    @Column(nullable = false, unique = true)
    private String email;

    @Column(name = "data_registre", nullable = false)
    private LocalDateTime dataRegistre;

    @Column
    private boolean isCreador;

    @ToString.Exclude
    @JsonIgnore
    @ManyToMany
    @JoinTable(
        name = "subscripcio",
        joinColumns = @JoinColumn(name = "idUsuari"),
        inverseJoinColumns = @JoinColumn(name = "idCanal")
    )
    private List<Canal> canalsSubscrits = new ArrayList<>();

    @ToString.Exclude
    @OneToMany(mappedBy = "usuari", cascade = CascadeType.PERSIST)
    private List<Visualitzacio> visualitzacions = new ArrayList<>();


    @ToString.Exclude
    @JsonIgnore
    @OneToMany(mappedBy = "creador",cascade = CascadeType.PERSIST)
    private List<Canal> canalsCreats=new ArrayList<>();


    @ToString.Exclude
    @JsonIgnore
    @OneToMany(mappedBy = "creador",cascade = CascadeType.ALL)
    private List<Video> videoCreats=new ArrayList<>();


    public void subscriure(Canal canal) {
        if (!canalsSubscrits.contains(canal)){
            canalsSubscrits.add(canal);
            canal.getSubscriptors().add(this);
        }
    }

    public void desubscriure(Canal canal) {
        canalsSubscrits.remove(canal);
        canal.getSubscriptors().remove(this);
    }

    public void visualitzar(Video video, boolean like, Integer valoracio, String comentari) {
        Visualitzacio v = new Visualitzacio(this, video, like, valoracio, comentari);
        visualitzacions.add(v);
        video.getVisualitzacions().add(v);
    }

    public void eliminarVisualitzacio(Video video) {
        visualitzacions.removeIf(v -> v.getVideo().equals(video));
        video.getVisualitzacions().removeIf(v -> v.getUsuari().equals(this));
    }

    public Usuari(String nom, String email, LocalDateTime dataRegistre,boolean isCreador) {
        this.nom = nom;
        this.email = email;
        this.dataRegistre = dataRegistre;
        this.isCreador = isCreador;
    }
}

ls Videos

Video.java
package com.streaming.model;

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

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

import com.fasterxml.jackson.annotation.JsonIgnore;

@Entity
@Table(name = "video")
@Data
@NoArgsConstructor
public class Video {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long idVideo;

    @Column(nullable = false)
    private String titol;

    @Column(nullable = false)
    private Integer duracio;

    @Column(length = 2000)
    private String descripcio;

    @Column(name = "data_creacio", nullable = false)
    private LocalDateTime dataCreacio;

    @ToString.Exclude
    @JsonIgnore
    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "idCreador", nullable = false)
    private Usuari creador;

    @ToString.Exclude
    @JsonIgnore
    @OneToMany(mappedBy = "video", cascade = CascadeType.PERSIST,orphanRemoval = true)
    private List<VideoCanal> videoCanals = new ArrayList<>();

    @ToString.Exclude
    @JsonIgnore
    @OneToMany(mappedBy = "video", cascade = CascadeType.PERSIST,orphanRemoval = true)
    private List<Visualitzacio> visualitzacions = new ArrayList<>();


    public Video(String titol, Integer duracio, String descripcio, LocalDateTime dataCreacio, Usuari creador) {
        this.titol = titol;
        this.duracio = duracio;
        this.descripcio = descripcio;
        this.dataCreacio = dataCreacio;
        this.creador = creador;
    }
}

Les visualitzacions:

Visualitzacio.java
package com.streaming.model;

import jakarta.persistence.*;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;

@Entity
@Table(name = "visualitzacio")
@Data
@NoArgsConstructor
public class Visualitzacio {

    @EmbeddedId
    private VisualitzacioId visualitzacioId=new VisualitzacioId();

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "idUsuari", nullable = false)
    @MapsId("idUsuari")
    private Usuari usuari;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "idVideo", nullable = false)
    @MapsId("idVideo")
    private Video video;

    @Column(name = "data_visualitzacio", nullable = false)
    private LocalDateTime dataVisualitzacio;

    @Column(nullable = false)
    private boolean likes = false;  // en plural per evitar paraula reservada SQL

    @Column(nullable = false)
    private Integer valoracio;

    @Column(length = 2000)
    private String comentari;

    public Visualitzacio(Usuari usuari, Video video, boolean likes, Integer valoracio, String comentari) {
        this.usuari = usuari;
        this.video = video;
        this.dataVisualitzacio = LocalDateTime.now();
        this.likes = likes;
        setValoracio(valoracio);
        this.comentari = comentari;
    }


    /**
     * Mètode que comprova que la valoració està entre 1 i 5
     * @param valoracio nova valoració
     */
    public void setValoracio(Integer valoracio) {
        if (valoracio != null && (valoracio < 1 || valoracio > 5)) {
            throw new IllegalArgumentException("La valoració ha d'estar entre 1 i 5");
        }
        this.valoracio = valoracio;
    }

}

amb la clau composta:

VisualitzacioId.java
package com.streaming.model;

import jakarta.persistence.Embeddable;
import lombok.ToString;
import lombok.Data;

@Data
@ToString
@Embeddable
public class VisualitzacioId {
    private Long idUsuari;
    private Long idVideo;
}

4.1. Sol·lució

Presentem ací la conversió de dos entitats a DTO, una en la que aplanarem les relacions en les que participa, i altra que mante algunes de les relacions.

S'ha inclos la anotació @Builder de Lombok per a facilitar la creació dels DTOs, i comprovar el seu funcionament.

UsuariDTO.java
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UsuariDTO {

    private Long idUsuari;      // (1)!
    private String nom;
    private String email;
    private LocalDateTime dataRegistre;
    private boolean isCreador;

    private int numCanalsSubscrit;  // (2)!
    private int numVisualitzacions;
    private int numCanalsCreats;
    private int numVideosCreats;

    public static UsuariDTO fromEntity(Usuari usuari) {
        if (usuari == null) {   // (3)!
            return null;
        }

        return UsuariDTO.builder()  // (4)!
                // Mapeo manual de propiedades base
                .idUsuari(usuari.getIdUsuari())
                .nom(usuari.getNom())
                .email(usuari.getEmail())
                .dataRegistre(usuari.getDataRegistre())
                .isCreador(usuari.isCreador())

                // Aplanado
                .numCanalsSubscrit(usuari.getCanalsSubscrits().size())
                .numCanalsCreats(usuari.getCanalsCreats().size() )
                .numVideosCreats(usuari.getVideoCreats().size())
                .numVisualitzacions(usuari.getVisualitzacions().size())

                // Construcción final del objeto
                .build();
    }
}
  1. Definim els camps que volem exposar al client. Els camps bàsics amb el mateix nom que l'entitat.
  2. Decidim aplanar les relacions, i en comptes de enviar les llistes d'objectes relacionats, enviem només el número d'elements d'aquestes llistes.
  3. Comprovem que l'objecte d'origen no siga null per a evitar errors.
  4. Utilitzem el builder per a construir l'objecte DTO. Assignem manualment els camps que tenen el mateix nom, i després els camps aplanats.

Més endavant veurem que el ObjectMapper de Jackson ens facilita la feina, sobretot en la part dels camps amb el mateix nom.

El Video és molt similar:

VideoDTO.java
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class VideoDTO {

    private Long idVideo;
    private String titol;
    private Integer duracio;
    private String descripcio;
    private LocalDateTime dataCreacio;

    private String nomCreador;
    private int numCanals;
    private int numVisualitzacions;

    public static VideoDTO fromEntity(Video video) {
        if (video==null)
            return null;

        return VideoDTO.builder()
                .idVideo(video.getIdVideo())
                .titol(video.getTitol())
                .duracio(video.getDuracio())
                .descripcio(video.getDescripcio())
                .dataCreacio(video.getDataCreacio())

                // aplanem
                .nomCreador(video.getCreador().getNom())
                .numCanals(video.getVideoCanals().size())
                .numVisualitzacions(video.getVisualitzacions().size())

                //construim
                .build();
    }
}

Vegem ara la visualització, que com conté un Usuari i un Video, hem decidit no aplanar les relacions, i per tant incloure dins del VisualitzacioDTO un UsuariDTO i un VideoDTO. Això ens permetrà mostrar més informació d'aquests objectes relacionats).

Per a crear un DTO convertirem altres DTO

Fixa't que ja farem ús de crides als mètodes que hem creat a UsuariDTO i VideoDTO per a convertir els objectes relacionats. Això és una bona pràctica, ja que ens permet reutilitzar el codi de conversió i mantenir una estructura clara i modular.

VisualitzacioDTO.java
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class VisualitzacioDTO {

    private UsuariDTO usuari;   // (1)!
    private VideoDTO video;

    private LocalDateTime dataVisualitzacio;    // (2)! 
    private boolean likes;  // en plural per evitar paraula reservada SQL
    private Integer valoracio;
    private String comentari;

    public static VisualitzacioDTO fromEntity(Visualitzacio visual) {
        if (visual==null)
            return null;
        return VisualitzacioDTO.builder()
                .usuari(UsuariDTO.fromEntity(visual.getUsuari()))   // (3)! 
                .video(VideoDTO.fromEntity(visual.getVideo()))  // (4)!
                .dataVisualitzacio(visual.getDataVisualitzacio())
                .likes(visual.isLikes())
                .valoracio(visual.getValoracio())
                .comentari(visual.getComentari())
                .build();       
    }
}
  1. Decidim no aplanar lees relacions, i per tant incloure dins del DTO un altre DTO per a cada objecte relacionat.
  2. Incloem els camps bàsics que volem exposar del Visualitzacio
  3. Ací convertim l'objecte Usuari relacionat a UsuariDTO mitjançant el mètode fromEntity() que vam crear a UsuariDTO.
  4. Ací convertim l'objecte Video relacionat a VideoDTO mitjançant el mètode fromEntity() que vam crear a VideoDTO.

Passe'm a veure un concepte avançat, com són els Streams Streams