Top 10 des erreurs de framework Spring les plus courantes
Publié: 2022-03-11Spring est sans doute l'un des frameworks Java les plus populaires, et aussi une bête puissante à apprivoiser. Bien que ses concepts de base soient assez faciles à comprendre, devenir un développeur Spring puissant nécessite du temps et des efforts.
Dans cet article, nous aborderons certaines des erreurs les plus courantes de Spring, spécifiquement orientées vers les applications Web et Spring Boot. Comme l'indique le site Web de Spring Boot, Spring Boot adopte un point de vue avisé sur la manière dont les applications prêtes pour la production doivent être créées. Cet article tentera donc d'imiter ce point de vue et de fournir un aperçu de quelques conseils qui s'intégreront bien dans le développement d'applications Web Spring Boot standard.
Si vous n'êtes pas très familier avec Spring Boot mais que vous souhaitez tout de même essayer certaines des choses mentionnées, j'ai créé un référentiel GitHub accompagnant cet article. Si vous vous sentez perdu à un moment quelconque de l'article, je vous recommande de cloner le référentiel et de jouer avec le code sur votre ordinateur local.
Erreur courante n° 1 : Aller à un niveau trop bas
Nous nous entendons avec cette erreur courante parce que le syndrome du "pas inventé ici" est assez courant dans le monde du développement logiciel. Les symptômes incluent la réécriture régulière de morceaux de code couramment utilisés et de nombreux développeurs semblent en souffrir.
Bien que la compréhension des éléments internes d'une bibliothèque particulière et de sa mise en œuvre soit généralement bonne et nécessaire (et peut également constituer un excellent processus d'apprentissage), il est préjudiciable à votre développement en tant qu'ingénieur logiciel de s'attaquer constamment à la même implémentation de bas niveau. des détails. Il y a une raison pour laquelle des abstractions et des frameworks tels que Spring existent, qui est précisément pour vous séparer du travail manuel répétitif et vous permettre de vous concentrer sur des détails de niveau supérieur - vos objets de domaine et votre logique métier.
Adoptez donc les abstractions - la prochaine fois que vous serez confronté à un problème particulier, effectuez d'abord une recherche rapide et déterminez si une bibliothèque résolvant ce problème est déjà intégrée à Spring; de nos jours, il y a de fortes chances que vous trouviez une solution existante appropriée. Comme exemple de bibliothèque utile, j'utiliserai les annotations du projet Lombok dans les exemples pour le reste de cet article. Lombok est utilisé comme générateur de code passe-partout et le développeur paresseux en vous, espérons-le, ne devrait pas avoir de problème pour se familiariser avec la bibliothèque. À titre d'exemple, découvrez à quoi ressemble un « bean Java standard » avec Lombok :
@Getter @Setter @NoArgsConstructor public class Bean implements Serializable { int firstBeanProperty; String secondBeanProperty; }
Comme vous pouvez l'imaginer, le code ci-dessus se compile en :
public class Bean implements Serializable { private int firstBeanProperty; private String secondBeanProperty; public int getFirstBeanProperty() { return this.firstBeanProperty; } public String getSecondBeanProperty() { return this.secondBeanProperty; } public void setFirstBeanProperty(int firstBeanProperty) { this.firstBeanProperty = firstBeanProperty; } public void setSecondBeanProperty(String secondBeanProperty) { this.secondBeanProperty = secondBeanProperty; } public Bean() { } }
Notez cependant que vous devrez très probablement installer un plugin au cas où vous auriez l'intention d'utiliser Lombok avec votre IDE. La version du plugin d'IntelliJ IDEA peut être trouvée ici.
Erreur courante n° 2 : ?
Exposer votre structure interne n'est jamais une bonne idée car cela crée de la rigidité dans la conception des services et favorise par conséquent les mauvaises pratiques de codage. Les « fuites » internes se manifestent en rendant la structure de la base de données accessible à partir de certains points de terminaison de l'API. A titre d'exemple, disons que le POJO suivant ("Plain Old Java Object") représente une table dans votre base de données :
@Entity @NoArgsConstructor @Getter public class TopTalentEntity { @Id @GeneratedValue private Integer id; @Column private String name; public TopTalentEntity(String name) { this.name = name; } }
Supposons qu'il existe un point de terminaison qui doit accéder aux données TopTalentEntity
. Aussi tentant que cela puisse être de renvoyer des instances TopTalentEntity
, une solution plus flexible consisterait à créer une nouvelle classe pour représenter les données TopTalentEntity
sur le point de terminaison de l'API :
@AllArgsConstructor @NoArgsConstructor @Getter public class TopTalentData { private String name; }
De cette façon, apporter des modifications à votre back-end de base de données ne nécessitera aucune modification supplémentaire dans la couche de service. Considérez ce qui se passerait dans le cas de l'ajout d'un champ 'mot de passe' à TopTalentEntity
pour stocker les hachages de mot de passe de vos utilisateurs dans la base de données - sans un connecteur tel que TopTalentData
, oublier de changer le service frontal exposerait accidentellement des informations secrètes très indésirables !
Erreur courante n° 3 : ne pas séparer les préoccupations
Au fur et à mesure que votre application grandit, l'organisation du code devient de plus en plus une question de plus en plus importante. Ironiquement, la plupart des bons principes d'ingénierie logicielle commencent à s'effondrer à grande échelle, en particulier dans les cas où peu d'attention a été accordée à la conception de l'architecture de l'application. L'une des erreurs les plus courantes auxquelles les développeurs ont alors tendance à succomber est de mélanger les problèmes de code, et c'est extrêmement facile à faire !
Ce qui rompt généralement la séparation des préoccupations, c'est simplement de "déverser" de nouvelles fonctionnalités dans des classes existantes. C'est, bien sûr, une excellente solution à court terme (pour commencer, cela nécessite moins de frappe) mais cela devient inévitablement un problème plus tard, que ce soit pendant les tests, la maintenance ou quelque part entre les deux. Considérez le contrôleur suivant, qui renvoie TopTalentData
depuis son référentiel :
@RestController public class TopTalentController { private final TopTalentRepository topTalentRepository; @RequestMapping("/toptal/get") public List<TopTalentData> getTopTalent() { return topTalentRepository.findAll() .stream() .map(this::entityToData) .collect(Collectors.toList()); } private TopTalentData entityToData(TopTalentEntity topTalentEntity) { return new TopTalentData(topTalentEntity.getName()); } }
Au début, il peut sembler qu'il n'y ait rien de particulièrement mal avec ce morceau de code ; il fournit une liste de TopTalentData
qui est récupérée à partir des instances de TopTalentEntity
. En y regardant de plus près, cependant, nous pouvons voir qu'il y a en fait quelques choses que TopTalentController
exécute ici; à savoir, il mappe les demandes à un point de terminaison particulier, récupère les données d'un référentiel et convertit les entités reçues de TopTalentRepository
dans un format différent. Une solution «plus propre» serait de séparer ces préoccupations dans leurs propres classes. Cela pourrait ressembler à ceci :
@RestController @RequestMapping("/toptal") @AllArgsConstructor public class TopTalentController { private final TopTalentService topTalentService; @RequestMapping("/get") public List<TopTalentData> getTopTalent() { return topTalentService.getTopTalent(); } } @AllArgsConstructor @Service public class TopTalentService { private final TopTalentRepository topTalentRepository; private final TopTalentEntityConverter topTalentEntityConverter; public List<TopTalentData> getTopTalent() { return topTalentRepository.findAll() .stream() .map(topTalentEntityConverter::toResponse) .collect(Collectors.toList()); } } @Component public class TopTalentEntityConverter { public TopTalentData toResponse(TopTalentEntity topTalentEntity) { return new TopTalentData(topTalentEntity.getName()); } }
Un avantage supplémentaire de cette hiérarchie est qu'elle nous permet de déterminer où réside la fonctionnalité simplement en inspectant le nom de la classe. De plus, pendant les tests, nous pouvons facilement remplacer n'importe laquelle des classes par une implémentation fictive si le besoin s'en fait sentir.
Erreur courante n° 4 : incohérence et mauvaise gestion des erreurs
Le sujet de la cohérence n'est pas nécessairement exclusif à Spring (ou Java, d'ailleurs), mais reste une facette importante à prendre en compte lorsque vous travaillez sur des projets Spring. Alors que le style de codage peut être sujet à débat (et est généralement une question d'accord au sein d'une équipe ou au sein d'une entreprise entière), avoir une norme commune s'avère être une excellente aide à la productivité. Cela est particulièrement vrai avec des équipes composées de plusieurs personnes ; la cohérence permet au transfert de se produire sans que de nombreuses ressources soient dépensées pour tenir la main ou fournir de longues explications concernant les responsabilités des différentes classes
Considérez un projet Spring avec ses différents fichiers de configuration, services et contrôleurs. Être sémantiquement cohérent dans leur dénomination crée une structure facilement consultable où tout nouveau développeur peut se débrouiller dans le code ; en ajoutant des suffixes Config à vos classes de configuration, des suffixes Service à vos services et des suffixes Controller à vos contrôleurs, par exemple.
Étroitement liée au sujet de la cohérence, la gestion des erreurs côté serveur mérite une attention particulière. Si vous avez déjà dû gérer des réponses d'exception à partir d'une API mal écrite, vous savez probablement pourquoi - il peut être pénible d'analyser correctement les exceptions, et encore plus pénible de déterminer la raison pour laquelle ces exceptions se sont produites en premier lieu.
En tant que développeur d'API, vous souhaitez idéalement couvrir tous les points de terminaison exposés aux utilisateurs et les traduire dans un format d'erreur commun. Cela signifie généralement avoir un code d'erreur générique et une description plutôt que la solution de contournement de a) renvoyer un message "500 Internal Server Error", ou b) simplement vider la trace de la pile à l'utilisateur (ce qui devrait en fait être évité à tout prix car il expose vos composants internes en plus d'être difficile à gérer côté client).
Voici un exemple de format de réponse d'erreur courant :
@Value public class ErrorResponse { private Integer errorCode; private String errorMessage; }
Quelque chose de similaire à cela est couramment rencontré dans les API les plus populaires et a tendance à bien fonctionner car il peut être facilement et systématiquement documenté. La traduction des exceptions dans ce format peut être effectuée en fournissant l'annotation @ExceptionHandler
à une méthode (un exemple d'annotation se trouve dans Common Mistake #6).
Erreur courante n° 5 : mauvaise gestion du multithreading
Qu'il soit rencontré dans les applications de bureau ou Web, Spring ou non Spring, le multithreading peut être un problème difficile à résoudre. Les problèmes causés par l'exécution parallèle de programmes sont angoissants et souvent extrêmement difficiles à déboguer - en fait, en raison de la nature du problème, une fois que vous réalisez que vous avez affaire à un problème d'exécution parallèle, vous allez probablement devez renoncer entièrement au débogueur et inspecter votre code "à la main" jusqu'à ce que vous trouviez la cause première de l'erreur. Malheureusement, il n'existe pas de solution à l'emporte-pièce pour résoudre ces problèmes ; selon votre cas particulier, vous allez devoir évaluer la situation puis attaquer le problème sous l'angle que vous jugez le meilleur.
Idéalement, vous voudriez, bien sûr, éviter complètement les bogues de multithreading. Encore une fois, une approche unique n'existe pas pour le faire, mais voici quelques considérations pratiques pour le débogage et la prévention des erreurs de multithreading :
Éviter l'état global
Tout d'abord, souvenez-vous toujours de la question de « l'état global ». Si vous créez une application multithread, absolument tout ce qui est globalement modifiable doit être surveillé de près et, si possible, complètement supprimé. S'il y a une raison pour laquelle la variable globale doit rester modifiable, utilisez soigneusement la synchronisation et suivez les performances de votre application pour confirmer qu'elle n'est pas lente en raison des périodes d'attente nouvellement introduites.
Éviter la mutabilité
Celui-ci vient tout droit de la programmation fonctionnelle et, adapté à la POO, stipule que la mutabilité des classes et le changement d'état doivent être évités. En bref, cela signifie renoncer aux méthodes setter et avoir des champs finaux privés sur toutes vos classes de modèle. La seule fois où leurs valeurs sont mutées, c'est pendant la construction. De cette façon, vous pouvez être certain qu'aucun problème de conflit ne survient et que l'accès aux propriétés de l'objet fournira les valeurs correctes à tout moment.

Enregistrez des données cruciales
Évaluez où votre application pourrait causer des problèmes et enregistrez de manière préventive toutes les données cruciales. Si une erreur se produit, vous serez reconnaissant d'avoir des informations indiquant quelles demandes ont été reçues et d'avoir une meilleure idée de la raison pour laquelle votre application s'est mal comportée. Il est à nouveau nécessaire de noter que la journalisation introduit des E/S de fichiers supplémentaires et ne doit donc pas être abusée car elle peut gravement affecter les performances de votre application.
Réutiliser les implémentations existantes
Chaque fois que vous avez besoin de générer vos propres threads (par exemple pour faire des requêtes asynchrones à différents services), réutilisez les implémentations sûres existantes plutôt que de créer vos propres solutions. Cela signifiera, pour la plupart, l'utilisation d'ExecutorServices et de CompletableFutures de style fonctionnel soigné de Java 8 pour la création de threads. Spring permet également le traitement asynchrone des requêtes via la classe DeferredResult.
Erreur courante n° 6 : ne pas utiliser la validation basée sur les annotations
Imaginons que notre service TopTalent de la version précédente nécessite un point de terminaison pour ajouter de nouveaux Top Talents. De plus, disons que, pour une raison vraiment valable, chaque nouveau nom doit comporter exactement 10 caractères. Une façon de procéder pourrait être la suivante :
@RequestMapping("/put") public void addTopTalent(@RequestBody TopTalentData topTalentData) { boolean nameNonExistentOrHasInvalidLength = Optional.ofNullable(topTalentData) .map(TopTalentData::getName) .map(name -> name.length() == 10) .orElse(true); if (nameNonExistentOrInvalidLength) { // throw some exception } topTalentService.addTopTalent(topTalentData); }
Cependant, ce qui précède (en plus d'être mal construit) n'est pas vraiment une solution "propre". Nous vérifions plus d'un type de validité (à savoir, que TopTalentData
n'est pas nul, et que TopTalentData.name
n'est pas nul, et que TopTalentData.name
a une longueur de 10 caractères), ainsi qu'une exception si les données sont invalides .
Cela peut être exécuté beaucoup plus proprement en utilisant le validateur Hibernate avec Spring. Commençons par refactoriser la méthode addTopTalent
pour prendre en charge la validation :
@RequestMapping("/put") public void addTopTalent(@Valid @NotNull @RequestBody TopTalentData topTalentData) { topTalentService.addTopTalent(topTalentData); } @ExceptionHandler @ResponseStatus(HttpStatus.BAD_REQUEST) public ErrorResponse handleInvalidTopTalentDataException(MethodArgumentNotValidException methodArgumentNotValidException) { // handle validation exception }
De plus, nous allons devoir indiquer quelle propriété nous souhaitons valider dans la classe TopTalentData
:
public class TopTalentData { @Length(min = 10, max = 10) @NotNull private String name; }
Désormais, Spring interceptera la demande et la validera avant que la méthode ne soit invoquée - il n'est pas nécessaire d'utiliser des tests manuels supplémentaires.
Une autre façon dont nous aurions pu obtenir la même chose est de créer nos propres annotations. Bien que vous n'utilisiez généralement des annotations personnalisées que lorsque vos besoins dépassent l'ensemble de contraintes intégré d'Hibernate, pour cet exemple, supposons que @Length n'existe pas. Vous créeriez un validateur qui vérifie la longueur de la chaîne en créant deux classes supplémentaires, une pour valider et une autre pour annoter les propriétés :
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER}) @Retention(RetentionPolicy.RUNTIME) @Documented @Constraint(validatedBy = { MyAnnotationValidator.class }) public @interface MyAnnotation { String message() default "String length does not match expected"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; int value(); } @Component public class MyAnnotationValidator implements ConstraintValidator<MyAnnotation, String> { private int expectedLength; @Override public void initialize(MyAnnotation myAnnotation) { this.expectedLength = myAnnotation.value(); } @Override public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) { return s == null || s.length() == this.expectedLength; } }
Notez que dans ces cas, les meilleures pratiques sur la séparation des préoccupations exigent que vous marquiez une propriété comme valide si elle est nulle ( s == null
dans la méthode isValid
), puis utilisez une annotation @NotNull
si c'est une exigence supplémentaire pour le biens:
public class TopTalentData { @MyAnnotation(value = 10) @NotNull private String name; }
Erreur courante n° 7 : (encore) utiliser une configuration basée sur XML
Alors que XML était une nécessité pour les versions précédentes de Spring, aujourd'hui, la plupart de la configuration peut être effectuée exclusivement via du code Java / des annotations ; Les configurations XML se présentent simplement comme du code passe-partout supplémentaire et inutile.
Cet article (ainsi que son référentiel GitHub qui l'accompagne) utilise des annotations pour configurer Spring et Spring sait quels beans il doit câbler car le package racine a été annoté avec une annotation composite @SpringBootApplication
, comme ceci :
@SpringBootApplication public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }
L'annotation composite (vous pouvez en savoir plus à ce sujet dans la documentation Spring donne simplement à Spring un indice sur les packages à analyser pour récupérer les beans. Dans notre cas concret, cela signifie que le package suivant sous le package supérieur (co.kukurin) sera utilisé pour le câblage :
-
@Component
(TopTalentConverter
,MyAnnotationValidator
) -
@RestController
(TopTalentController
) -
@Repository
(TopTalentRepository
) -
@Service
(TopTalentService
)
Si nous avions des classes annotées @Configuration
supplémentaires, elles seraient également vérifiées pour la configuration basée sur Java.
Erreur courante #8 : Oublier les profils
Un problème souvent rencontré dans le développement de serveurs consiste à faire la distinction entre différents types de configuration, généralement vos configurations de production et de développement. Au lieu de remplacer manuellement diverses entrées de configuration chaque fois que vous passez du test au déploiement de votre application, un moyen plus efficace serait d'utiliser des profils.
Prenons le cas où vous utilisez une base de données en mémoire pour le développement local, avec une base de données MySQL en production. Cela signifierait essentiellement que vous utiliserez une URL différente et (espérons-le) des informations d'identification différentes pour accéder à chacun des deux. Voyons comment cela pourrait être fait avec deux fichiers de configuration différents :
fichier application.yaml
# set default profile to 'dev' spring.profiles.active: dev # production database details spring.datasource.url: 'jdbc:mysql://localhost:3306/toptal' spring.datasource.username: root spring.datasource.password:
fichier application-dev.yaml
spring.datasource.url: 'jdbc:h2:mem:' spring.datasource.platform: h2
Vraisemblablement, vous ne voudriez pas effectuer accidentellement des actions sur votre base de données de production tout en modifiant le code, il est donc logique de définir le profil par défaut sur dev. Sur le serveur, vous pouvez ensuite remplacer manuellement le profil de configuration en fournissant un paramètre -Dspring.profiles.active=prod
à la JVM. Vous pouvez également définir la variable d'environnement de votre système d'exploitation sur le profil par défaut souhaité.
Erreur courante n° 9 : ne pas accepter l'injection de dépendance
Utiliser correctement l'injection de dépendances avec Spring signifie lui permettre de connecter tous vos objets ensemble en analysant toutes les classes de configuration souhaitées. cela s'avère utile pour découpler les relations et facilite également beaucoup les tests. Au lieu de classes de couplage serré en faisant quelque chose comme ceci :
public class TopTalentController { private final TopTalentService topTalentService; public TopTalentController() { this.topTalentService = new TopTalentService(); } }
Nous laissons Spring faire le câblage pour nous :
public class TopTalentController { private final TopTalentService topTalentService; public TopTalentController(TopTalentService topTalentService) { this.topTalentService = topTalentService; } }
La conférence Google de Misko Hevery explique en profondeur les "pourquoi" de l'injection de dépendances, alors voyons plutôt comment elle est utilisée dans la pratique. Dans la section sur la séparation des préoccupations (Common Mistakes #3), nous avons créé une classe de service et de contrôleur. Disons que nous voulons tester le contrôleur en supposant que TopTalentService
se comporte correctement. Nous pouvons insérer un objet fictif à la place de l'implémentation réelle du service en fournissant une classe de configuration distincte :
@Configuration public class SampleUnitTestConfig { @Bean public TopTalentService topTalentService() { TopTalentService topTalentService = Mockito.mock(TopTalentService.class); Mockito.when(topTalentService.getTopTalent()).thenReturn( Stream.of("Mary", "Joel").map(TopTalentData::new).collect(Collectors.toList())); return topTalentService; } }
Ensuite, nous pouvons injecter l'objet fictif en disant à Spring d'utiliser SampleUnitTestConfig
comme fournisseur de configuration :
@ContextConfiguration(classes = { SampleUnitTestConfig.class })
Cela nous permet ensuite d'utiliser la configuration de contexte pour injecter le bean personnalisé dans un test unitaire.
Erreur courante n° 10 : Absence de tests ou tests inappropriés
Même si l'idée des tests unitaires nous accompagne depuis longtemps, de nombreux développeurs semblent soit "oublier" de le faire (surtout si ce n'est pas obligatoire ), soit simplement l'ajouter après coup. Ce n'est évidemment pas souhaitable puisque les tests ne doivent pas seulement vérifier l'exactitude de votre code, mais aussi servir de documentation sur la façon dont l'application doit se comporter dans différentes situations.
Lorsque vous testez des services Web, vous effectuez rarement des tests unitaires exclusivement "purs", car la communication via HTTP vous oblige généralement à appeler le DispatcherServlet
de Spring et à voir ce qui se passe lorsqu'un HttpServletRequest
réel est reçu (ce qui en fait un test d' intégration , traitant de la validation, de la sérialisation , etc). REST Assured, un DSL Java pour tester facilement les services REST, en plus de MockMVC, s'est avéré offrir une solution très élégante. Considérez l'extrait de code suivant avec injection de dépendance :
@RunWith(SpringJUnit4ClassRunner.class) @ContextConfiguration(classes = { Application.class, SampleUnitTestConfig.class }) public class RestAssuredTestDemonstration { @Autowired private TopTalentController topTalentController; @Test public void shouldGetMaryAndJoel() throws Exception { // given MockMvcRequestSpecification givenRestAssuredSpecification = RestAssuredMockMvc.given() .standaloneSetup(topTalentController); // when MockMvcResponse response = givenRestAssuredSpecification.when().get("/toptal/get"); // then response.then().statusCode(200); response.then().body("name", hasItems("Mary", "Joel")); } }
SampleUnitTestConfig
connecte une implémentation fictive de TopTalentService
à TopTalentController
tandis que toutes les autres classes sont connectées à l'aide de la configuration standard déduite des packages d'analyse enracinés dans le package de la classe Application. RestAssuredMockMvc
est simplement utilisé pour configurer un environnement léger et envoyer une requête GET
au point de terminaison /toptal/get
.
Devenir maître du printemps
Spring est un cadre puissant avec lequel il est facile de démarrer mais qui nécessite un certain dévouement et du temps pour atteindre une maîtrise complète. Prendre le temps de vous familiariser avec le framework améliorera certainement votre productivité à long terme et vous aidera finalement à écrire du code plus propre et à devenir un meilleur développeur.
Si vous recherchez des ressources supplémentaires, Spring In Action est un bon livre pratique couvrant de nombreux sujets de base sur le printemps.