Points Clés
- Comprendre les DTO
- Quand le DTO a t-il du sens ?
- Compromis avec les DTO
- Pour mapper le framework au DTO
- DTO et JAX-RS
Les objets de transfert de données, ou DTO (Data Transfert Object), font l'objet de nombreuses discussions lorsque l'on parle de développement d'applications Java. Les DTO sont nés dans le monde Java avec les Enterprise JavaBean (EJB) pour deux raisons. Premièrement, pour contourner le problème de la sérialisation des EJB ; deuxièmement, car ils définissent implicitement une phase d'assemblage où toutes les données qui seront utilisées pour la visualisation sont rassemblées avant d'aller dans la couche de présentation. Cependant, comme l'EJB n'est plus utilisé à grande échelle, les DTO peuvent-ils également être rendus obsolètes ? Le but de cet article est de parler de l'utilité des DTO et d'aborder cette question.
Après tout, dans un environnement où plusieurs nouveaux sujets sont abordés (par exemple, le cloud et les micro-services), cette couche a-t-elle un sens ? Lorsqu'on a une bonne architecture logicielle, la réponse est pratiquement unanime : cela dépend de la manière dont vous voulez que votre entité soit jumelée à la couche de visualisation.
Pour une architecture sous-jacente en couches, et en se divisant en trois parties interconnectées, nous avons le fameux MVC.
Il convient de noter que cette stratégie n'est pas exclusive à la pile d'applications web comme Spring MVC et JSF. En exposant vos données dans une application restful avec JSON, les données JSON fonctionnent comme une visualisation, même si elles ne sont pas facilement abordables pour un utilisateur typique.
Après avoir expliqué brièvement le MVC, nous parlerons des avantages et des inconvénients de l'utilisation de DTO. Concernant les applications à plusieurs niveaux, l'objectif des DTO est avant tout de séparer le modèle de la vue. Réflexion sur les problèmes de DTO :
- Augmente la complexité
- Il est possible de dupliquer le code
- Ajouter une nouvelle couche impose de la traverser, ce qui ajoute un délai et une possible perte de performances.
Dans les systèmes simples qui n'ont pas besoin d'un modèle riche comme prémisse, le fait de ne pas utiliser de DTO finit par apporter de grands avantages à l'application. Le point intéressant est que de nombreux frameworks de sérialisation finissent par forcer les attributs à avoir des méthodes d'accès ou de mises à jour qui sont toujours obligatoirement présentes et publiques, ce qui, à un moment donné, aura un impact sur l'encapsulation et la sécurité de l'application.
L'autre option consiste à ajouter la couche DTO, qui garantit essentiellement le découplage de la vue et du modèle, comme mentionné précédemment.
- Il indique clairement quels champs seront envoyés à la couche d'affichage. Oui, il y a plusieurs annotations dans divers frameworks qui indiquent quels champs ne seront pas affichés. Toutefois, si vous oubliez d’annoter, vous pouvez exporter un champ critique par accident, par exemple le mot de passe de l'utilisateur.
- Permet une conception plus respectueuse des principes de la programmation objet. Un des points que le code propre rend clair sur l'orientation des objets est que la Programmation Orientée Objet (POO) cache les données pour exposer le comportement et l'encapsulation aide à cela.
- Facilite la mise à jour de la base de données. Il est souvent indispensable de remanier, de migrer la base de données sans que ce changement n'ait d'impact sur le client. Cette séparation facilite les optimisations, les modifications de la base de données sans affecter la visualisation.
- Le versionnage, la rétrocompatibilité est un point important, surtout lorsque vous disposez d'une API à usage public et avec plusieurs clients. Il est donc possible d'avoir un DTO pour chaque version et de faire évoluer le modèle métier sans souci.
- Un autre bénéfice est qu'il est plus facile de travailler directement avec le modèle complet, ce qui permet la création d'une API que l'on peut valider point par point. Par exemple, dans mon modèle, je peux utiliser une API monétaire ; cependant, dans ma couche de visualisation, j'exporte comme un simple objet avec seulement la valeur monétaire pour la visualisation. C'est-à-dire, la bonne vieille chaîne de caractères en Java.
- CQRS. Oui, l'approche CQRS consiste à séparer les opérations d'écriture et de lecture de données. Comment réaliser cette séparation sans les DTOs ?
En général, ajouter une couche signifie découpler et faciliter la maintenance au détriment de l'ajout de classes et de complexité, car il faut aussi penser à l'opération de conversion entre ces couches. C'est la raison, par exemple, de l'existence de MVC. Il est donc très important de comprendre que tout est basé sur l'impact et les compromis ou sur ce qui fait mal dans une application ou une situation donnée. L'absence de ces couches est très mauvaise, elle peut aboutir à un modèle de type Highlander (il ne peut y en avoir qu'un seul) dont il existe une classe avec toutes les responsabilités. De la même manière, les couches en excès adoptent le modèle en oignon, où le développeur pleure en passant sur chaque couche.
Une critique plus fréquente concernant les DTO est pour effectuer la conversion. La bonne nouvelle est qu'il existe plusieurs frameworks de conversion, c'est-à-dire qu'il n'est pas nécessaire d'effectuer les mises à jours manuellement. Dans cet article, nous allons en choisir un qui est modelmapper.
La première étape consiste à définir les dépendances du projet, par exemple, dans le POM Maven :
<dependency>
<groupid>org.modelmapper</groupid>
<artifactid>modelmapper</artifactid>
<version>2.3.6</version>
</dependency>
Pour illustrer ce concept de DTO, nous allons créer une application utilisant JAX-RS connecté à MongoDB, tout cela grâce à Jakarta EE, en utilisant Payara comme serveur. Nous gérons un utilisateur avec son nom d'utilisateur, son salaire, son anniversaire et la liste des langues qu'il peut parler. Comme nous allons travailler avec MongoDB sur Jakarta EE, nous utiliserons Jakarta NoSQL.
import jakarta.nosql.mapping.Column;
import jakarta.nosql.mapping.Convert;
import jakarta.nosql.mapping.Entity;
import jakarta.nosql.mapping.Id;
import my.company.infrastructure.MonetaryAmountAttributeConverter;
import javax.money.MonetaryAmount;
import java.time.LocalDate;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@Entity
public class User {
@Id
private String nickname;
@Column
@Convert(MonetaryAmountAttributeConverter.class)
private MonetaryAmount salary;
@Column
private List<String> languages;
@Column
private LocalDate birthday;
@Column
private Map<String, String> settings;
//only getter
}
En général, il n'est pas logique que les entités aient des getters et des setters pour tous les attributs ; après tout, ce serait la même chose que de laisser l'attribut public directement. Comme notre article ne porte pas sur la DDD ou les modèles riches, nous omettrons les détails de cette entité. Pour notre DTO, nous aurons tous les champs de l'entité ; cependant, pour la visualisation, notre MonetaryAmount
sera une String
, et la date d'anniversaire suivra le même modèle.
import java.util.List;
import java.util.Map;
public class UserDTO {
private String nickname;
private String salary;
private List<String> languages;
private String birthday;
private Map<String, String> settings;
//getter and setter
}
Le grand avantage du mapper est que nous n'avons pas à nous soucier de le faire manuellement. Le seul point à noter est que certains types particuliers, par exemple le MonetaryAmount
de l’API "Money & Currency", devront créer une conversion pour devenir un String
et vice versa.
import org.modelmapper.AbstractConverter;
import javax.money.MonetaryAmount;
public class MonetaryAmountStringConverter extends AbstractConverter<MonetaryAmount, String> {
@Override
protected String convert(MonetaryAmount source) {
if (source == null) {
return null;
}
return source.toString();
}
}
import org.javamoney.moneta.Money;
import org.modelmapper.AbstractConverter;
import javax.money.MonetaryAmount;
public class StringMonetaryAmountConverter extends AbstractConverter<String, MonetaryAmount> {
@Override
protected MonetaryAmount convert(String source) {
if (source == null) {
return null;
}
return Money.parse(source);
}
}
Les convertisseurs sont prêts ; notre prochaine étape est d'instancier la classe qui effectue la conversionModelMapper. Un avantage de l'injection de dépendance est que l'on peut définir cette opération au niveau de l'application. À partir de maintenant, toute l'application peut utiliser le même mappeur ; pour cela, il suffit d'utiliser l'annotation
Inject` comme nous le verrons plus loin.
import org.modelmapper.ModelMapper;
import javax.annotation.PostConstruct;
import javax.enterprise.context.ApplicationScoped;
import javax.enterprise.inject.Produces;
import java.util.function.Supplier;
import static org.modelmapper.config.Configuration.AccessLevel.PRIVATE;
@ApplicationScoped
public class MapperProducer implements Supplier<ModelMapper> {
private ModelMapper mapper;
@PostConstruct
public void init() {
this.mapper = new ModelMapper();
this.mapper.getConfiguration()
.setFieldMatchingEnabled(true)
.setFieldAccessLevel(PRIVATE);
this.mapper.addConverter(new StringMonetaryAmountConverter());
this.mapper.addConverter(new MonetaryAmountStringConverter());
this.mapper.addConverter(new StringLocalDateConverter());
this.mapper.addConverter(new LocalDateStringConverter());
this.mapper.addConverter(new UserDTOConverter());
}
@Override
@Produces
public ModelMapper get() {
return mapper;
}
}
L'un des avantages significatifs de l'utilisation de Jakarta NoSQL est sa facilité d'intégration de la base de données. Par exemple, dans cet article, nous utiliserons le concept de référentiel à partir duquel nous créerons une interface pour laquelle Jakarta NoSQL se chargera de cette mise en œuvre.
import jakarta.nosql.mapping.Repository;
import javax.enterprise.context.ApplicationScoped;
import java.util.stream.Stream;
@ApplicationScoped
public interface UserRepository extends Repository<User, String> {
Stream<User> findAll();
}
Dans la dernière étape, nous lancerons notre appel avec JAX-RS. Le point critique est que l'exposition des données se fera entièrement à partir de DTO, c'est-à-dire qu'il est possible d'effectuer toute modification au sein de l'entité à l'insu du client, grâce au DTO. Comme mentionné, le mappeur a été injecté, et la méthode map
facilite grandement cette intégration entre le DTO et l'entité sans trop de code.
import javax.inject.Inject;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Path("users")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class UserResource {
@Inject
private UserRepository repository;
@Inject
private ModelMapper mapper;
@GET
public List<UserDTO> getAll() {
Stream<User> users = repository.findAll();
return users.map(u -> mapper.map(u, UserDTO.class))
.collect(Collectors.toList());
}
@POST
public void insert(UserDTO dto) {
User map = mapper.map(dto, User.class);
repository.save(map);
}
@POST
@Path("id")
public void update(@PathParam("id") String id, UserDTO dto) {
User user = repository.findById(id).orElseThrow(() ->
new WebApplicationException(Response.Status.NOT_FOUND));
User map = mapper.map(dto, User.class);
user.update(map);
repository.save(map);
}
@DELETE
@Path("id")
public void delete(@PathParam("id") String id) {
repository.deleteById(id);
}
}
La gestion des bases de données, du code et des intégrations est toujours difficile, même dans le cloud. En effet, le serveur est toujours là, et quelqu'un doit le surveiller, effectuer des installations et des sauvegardes, et maintenir sa bonne santé en général. Et l'APP à douze facteurs exige une séparation stricte entre la configuration et le code.
Heureusement, Platform.sh fournit un PaaS qui gère les services tels que les bases de données et les files d'attente de messages, avec un support dans plusieurs langages, y compris Java. Tout est construit sur le concept d'Infrastructure as Code (IaC), qui gère et fournit des services par le biais de fichiers YAML.
Dans les articles précédents, nous avons mentionné comment cela est fait sur Platform.sh, principalement avec trois fichiers :
Un pour définir les services utilisés par les applications (services.yaml).
mongodb:
type: mongodb:3.6
disk: 1024
Un pour définir les routes publiques (routes.yaml).
"https://{default}/":
type: upstream
upstream: "app:http"
"https://www.{default}/":
type: redirect
to: "https://{default}/"
Il est important de souligner que les routes sont destinées à des applications que nous voulons partager publiquement.
Platform.sh simplifie la configuration des applications individuelles et des micro-services grâce au fichier .platform.app.yaml
. Contrairement aux applications uniques, chaque application de microservice aura son propre répertoire à la racine du projet et son propre fichier .platform.app.yaml
associé à cette application unique. Chaque application décrira son langage et les services auxquels elle se connectera. Comme l'application cliente coordonnera chacun des microservices de notre application, elle spécifiera ces connexions en utilisant le bloc relationships
de son fichier .platform.app.yaml
.
name: app
type: "java:11"
disk: 1024
hooks:
build: mvn clean package payara-micro:bundle
relationships:
mongodb: 'mongodb:mongodb'
web:
commands:
start: |
export MONGO_PORT=`echo $PLATFORM_RELATIONSHIPS|base64 -d|json_pp|jq -r ".mongodb[0].port"`
export MONGO_HOST=`echo $PLATFORM_RELATIONSHIPS|base64 -d|json_pp|jq -r ".mongodb[0].host"`
export MONGO_ADDRESS="${MONGO_HOST}:${MONGO_PORT}"
export MONGO_PASSWORD=`echo $PLATFORM_RELATIONSHIPS|base64 -d|json_pp|jq -r ".mongodb[0].password"`
export MONGO_USER=`echo $PLATFORM_RELATIONSHIPS|base64 -d|json_pp|jq -r ".mongodb[0].username"`
export MONGO_DATABASE=`echo $PLATFORM_RELATIONSHIPS|base64 -d|json_pp|jq -r ".mongodb[0].path"`
java -jar -Xmx1024m -Ddocument.settings.jakarta.nosql.host=$MONGO_ADDRESS \
-Ddocument.database=$MONGO_DATABASE -Ddocument.settings.jakarta.nosql.user=$MONGO_USER \
-Ddocument.settings.jakarta.nosql.password=$MONGO_PASSWORD \
-Ddocument.settings.mongodb.authentication.source=$MONGO_DATABASE \
target/microprofile-microbundle.jar --port $PORT
Dans cet article, nous avons parlé de l'intégration d'une application avec DTO, en plus des outils permettant de livrer et de mapper le DTO avec votre entité de manière simple. Nous avons également abordé les avantages et les inconvénients de cette couche.