Principe de responsabilité unique : une recette pour un excellent code
Publié: 2022-03-11Indépendamment de ce que nous considérons comme un excellent code, il requiert toujours une qualité simple : le code doit être maintenable. Une indentation correcte, des noms de variables soignés, une couverture de test à 100%, etc. ne peuvent vous mener que très loin. Tout code qui n'est pas maintenable et qui ne peut pas s'adapter à l'évolution des exigences avec une relative facilité est un code qui ne demande qu'à devenir obsolète. Nous n'avons peut-être pas besoin d'écrire du bon code lorsque nous essayons de construire un prototype, une preuve de concept ou un produit minimum viable, mais dans tous les autres cas, nous devons toujours écrire du code qui est maintenable. C'est quelque chose qui devrait être considéré comme une qualité fondamentale de l'ingénierie et de la conception de logiciels.
Dans cet article, je vais expliquer comment le principe de responsabilité unique et certaines techniques qui en découlent peuvent donner à votre code cette qualité même. Écrire un bon code est un art, mais certains principes peuvent toujours aider à donner à votre travail de développement la direction vers laquelle il doit se diriger pour produire un logiciel robuste et maintenable.
Le modèle est tout
Presque tous les livres sur un nouveau framework MVC (MVP, MVVM ou autre M **) sont jonchés d'exemples de mauvais code. Ces exemples tentent de montrer ce que le framework a à offrir. Mais ils finissent aussi par donner de mauvais conseils aux débutants. Des exemples comme "disons que nous avons cet ORM X pour nos modèles, le moteur de template Y pour nos vues et nous aurons des contrôleurs pour tout gérer" n'aboutissent à rien d'autre que des contrôleurs énormes.
Bien que pour défendre ces livres, les exemples sont destinés à démontrer la facilité avec laquelle vous pouvez démarrer avec leur cadre. Ils ne sont pas destinés à enseigner la conception de logiciels. Mais les lecteurs qui suivent ces exemples réalisent, seulement après des années, à quel point il est contre-productif d'avoir des morceaux de code monolithiques dans leur projet.
Les modèles sont au cœur de votre application. Si vous avez des modèles séparés du reste de la logique de votre application, la maintenance sera beaucoup plus facile, quelle que soit la complexité de votre application. Même pour les applications compliquées, une bonne implémentation du modèle peut aboutir à un code extrêmement expressif. Et pour y parvenir, commencez par vous assurer que vos modèles ne font que ce qu'ils sont censés faire, et ne vous préoccupez pas de ce que fait l'application construite autour d'eux. De plus, il ne se préoccupe pas de la couche de stockage de données sous-jacente : votre application s'appuie-t-elle sur une base de données SQL ou stocke-t-elle tout dans des fichiers texte ?
Au fur et à mesure que nous poursuivrons cet article, vous réaliserez à quel point le code est important pour la séparation des préoccupations.
Principe de responsabilité unique
Vous avez probablement entendu parler des principes SOLID : responsabilité unique, ouvert-fermé, substitution liskov, ségrégation d'interface et inversion de dépendance. La première lettre, S, représente le principe de responsabilité unique (SRP) et son importance ne peut être surestimée. Je dirais même que c'est une condition nécessaire et suffisante pour un bon code. En fait, dans tout code mal écrit, vous pouvez toujours trouver une classe qui a plus d'une responsabilité - form1.cs ou index.php contenant quelques milliers de lignes de code n'est pas quelque chose de si rare à trouver et nous tous probablement vu ou fait.
Examinons un exemple en C# (ASP.NET MVC et Entity framework). Même si vous n'êtes pas un développeur C #, avec une certaine expérience OOP, vous pourrez suivre facilement.
public class OrderController { ... public ActionResult CreateForm() { /* * View data preparations */ return View(); } [HttpPost] public ActionResult Create(OrderCreateRequest request) { if (!ModelState.IsValid) { /* * View data preparations */ return View(); } using (var context = new DataContext()) { var order = new Order(); // Create order from request context.Orders.Add(order); // Reserve ordered goods …(Huge logic here)... context.SaveChanges(); //Send email with order details for customer } return RedirectToAction("Index"); } ... (many more methods like Create here) }
Il s'agit d'une classe OrderController habituelle, sa méthode Create illustrée. Dans des contrôleurs comme celui-ci, je vois souvent des cas où la classe Order elle-même est utilisée comme paramètre de requête. Mais je préfère utiliser des classes de demande spéciale. Encore une fois, SRP !
Remarquez dans l'extrait de code ci-dessus comment le contrôleur en sait trop sur "passer une commande", y compris, mais sans s'y limiter, le stockage de l'objet Order, l'envoi d'e-mails, etc. C'est tout simplement trop de tâches pour une seule classe. Pour chaque petit changement, le développeur doit changer le code entier du contrôleur. Et juste au cas où un autre contrôleur aurait également besoin de créer des commandes, le plus souvent, les développeurs auront recours au copier-coller du code. Les contrôleurs ne doivent contrôler que le processus global et ne pas héberger chaque élément logique du processus.
Mais aujourd'hui est le jour où nous arrêtons d'écrire ces énormes contrôleurs !
Extrayons d'abord toute la logique métier du contrôleur et déplaçons-la vers une classe OrderService :
public class OrderService { public void Create(OrderCreateRequest request) { // all actions for order creating here } } public class OrderController { public OrderController() { this.service = new OrderService(); } [HttpPost] public ActionResult Create(OrderCreateRequest request) { if (!ModelState.IsValid) { /* * View data preparations */ return View(); } this.service.Create(request); return RedirectToAction("Index"); }
Une fois cela fait, le contrôleur ne fait plus que ce qu'il est censé faire : contrôler le processus. Il ne connaît que les vues, les classes OrderService et OrderRequest - le moins d'informations nécessaires pour faire son travail, qui consiste à gérer les demandes et à envoyer des réponses.
De cette façon, vous changerez rarement le code du contrôleur. D'autres composants tels que les vues, les objets de demande et les services peuvent encore changer car ils sont liés aux exigences de l'entreprise, mais pas aux contrôleurs.
C'est de cela qu'il s'agit, et il existe de nombreuses techniques d'écriture de code qui répondent à ce principe. Un exemple de ceci est l'injection de dépendances (quelque chose qui est également utile pour écrire du code testable).
Injection de dépendance
Il est difficile d'imaginer un grand projet basé sur le principe de responsabilité unique sans injection de dépendance. Reprenons notre classe OrderService :
public class OrderService { public void Create(...) { // Creating the order(and let's forget about reserving here, it's not important for following examples) // Sending an email to client with order details var smtp = new SMTP(); // Setting smtp.Host, UserName, Password and other parameters smtp.Send(); } }
Ce code fonctionne, mais n'est pas tout à fait idéal. Pour comprendre le fonctionnement de la classe OrderService de la méthode de création, ils sont obligés de comprendre les subtilités de SMTP. Et, encore une fois, le copier-coller est le seul moyen de reproduire cette utilisation de SMTP partout où cela est nécessaire. Mais avec un peu de refactorisation, cela peut changer :
public class OrderService { private SmtpMailer mailer; public OrderService() { this.mailer = new SmtpMailer(); } public void Create(...) { // Creating the order // Sending an email to client with order details this.mailer.Send(...); } } public class SmtpMailer { public void Send(string to, string subject, string body) { // SMTP stuff will be only here } }
Bien mieux déjà ! Mais la classe OrderService en sait encore beaucoup sur l'envoi d'e-mails. Il a besoin exactement de la classe SmtpMailer pour envoyer des e-mails. Et si nous voulions le changer à l'avenir ? Et si nous voulions imprimer le contenu de l'e-mail envoyé dans un fichier journal spécial au lieu de l'envoyer réellement dans notre environnement de développement ? Et si nous voulions tester unitairement notre classe OrderService ? Continuons le refactoring en créant une interface IMailer :

public interface IMailer { void Send(string to, string subject, string body); }
SmtpMailer implémentera cette interface. De plus, notre application utilisera un conteneur IoC et nous pourrons le configurer pour que IMailer soit implémenté par la classe SmtpMailer. OrderService peut alors être modifié comme suit :
public sealed class OrderService: IOrderService { private IOrderRepository repository; private IMailer mailer; public OrderService(IOrderRepository repository, IMailer mailer) { this.repository = repository; this.mailer = mailer; } public void Create(...) { var order = new Order(); // fill the Order entity using the full power of our Business Logic(discounts, promotions, etc.) this.repository.Save(order); this.mailer.Send(<orders user email>, <subject>, <body with order details>); } }
Maintenant, nous arrivons quelque part ! J'en ai profité pour faire un autre changement. Le OrderService s'appuie désormais sur l'interface IOrderRepository pour interagir avec le composant qui stocke toutes nos commandes. Il ne se soucie plus de la façon dont cette interface est implémentée et de la technologie de stockage qui l'alimente. Désormais, la classe OrderService n'a que du code qui traite de la logique métier de la commande.
De cette façon, si un testeur devait trouver quelque chose qui ne se comporte pas correctement avec l'envoi d'e-mails, le développeur sait exactement où chercher : la classe SmtpMailer. Si quelque chose n'allait pas avec les remises, le développeur, encore une fois, sait où chercher : OrderService (ou si vous avez adopté le SRP par cœur, il peut s'agir du code de classe DiscountService).
Architecture pilotée par les événements
Cependant, je n'aime toujours pas la méthode OrderService.Create :
public void Create(...) { var order = new Order(); ... this.repository.Save(order); this.mailer.Send(<orders user email>, <subject>, <body with order details>); }
L'envoi d'un e-mail ne fait pas vraiment partie du flux principal de création de commande. Même si l'application ne parvient pas à envoyer l'e-mail, la commande est toujours créée correctement. Imaginez également une situation où vous devez ajouter une nouvelle option dans la zone des paramètres utilisateur qui leur permet de refuser de recevoir un e-mail après avoir passé une commande avec succès. Pour incorporer cela dans notre classe OrderService, nous devrons introduire une dépendance, IUserParametersService. Ajoutez la localisation dans le mélange, et vous avez encore une autre dépendance, ITranslator (pour produire des messages électroniques corrects dans la langue de choix de l'utilisateur). Plusieurs de ces actions sont inutiles, notamment l'idée d'ajouter ces nombreuses dépendances et de se retrouver avec un constructeur qui ne tient pas à l'écran. J'ai trouvé un excellent exemple de cela dans la base de code de Magento (un CMS de commerce électronique populaire écrit en PHP) dans une classe qui a 32 dépendances !
Parfois, il est tout simplement difficile de comprendre comment séparer cette logique, et la classe de Magento est probablement victime de l'un de ces cas. C'est pourquoi j'aime la méthode événementielle :
namespace <base namespace>.Events { [Serializable] public class OrderCreated { private readonly Order order; public OrderCreated(Order order) { this.order = order; } public Order GetOrder() { return this.order; } } }
Chaque fois qu'une commande est créée, au lieu d'envoyer un e-mail directement à partir de la classe OrderService, la classe d'événement spéciale OrderCreated est créée et un événement est généré. Quelque part dans l'application, les gestionnaires d'événements seront configurés. L'un d'eux enverra un e-mail au client.
namespace <base namespace>.EventHandlers { public class OrderCreatedEmailSender : IEventHandler<OrderCreated> { public OrderCreatedEmailSender(IMailer, IUserParametersService, ITranslator) { // this class depend on all stuff which it need to send an email. } public void Handle(OrderCreated event) { this.mailer.Send(...); } } }
La classe OrderCreated est volontairement marquée comme sérialisable. Nous pouvons gérer cet événement immédiatement, ou le stocker en série dans une file d'attente (Redis, ActiveMQ ou autre) et le traiter dans un processus/thread distinct de celui qui gère les requêtes Web. Dans cet article, l'auteur explique en détail ce qu'est l'architecture pilotée par les événements (veuillez ne pas prêter attention à la logique métier dans OrderController).
Certains diront qu'il est maintenant difficile de comprendre ce qui se passe lorsque vous créez la commande. Mais cela ne peut pas être plus éloigné de la vérité. Si vous pensez cela, profitez simplement des fonctionnalités de votre IDE. En trouvant toutes les utilisations de la classe OrderCreated dans l'IDE, nous pouvons voir toutes les actions associées à l'événement.
Mais quand dois-je utiliser l'injection de dépendance et quand dois-je utiliser une approche basée sur les événements ? Il n'est pas toujours facile de répondre à cette question, mais une règle simple qui peut vous aider est d'utiliser l'injection de dépendance pour toutes vos activités principales au sein de l'application, et l'approche événementielle pour toutes les actions secondaires. Par exemple, utilisez Dependecy Injection avec des choses comme la création d'une commande dans la classe OrderService avec IOrderRepository, et déléguez l'envoi d'e-mails, quelque chose qui n'est pas une partie cruciale du flux de création de commande principal, à un gestionnaire d'événements.
Conclusion
Nous avons commencé avec un contrôleur très lourd, une seule classe, et nous nous sommes retrouvés avec une collection élaborée de classes. Les avantages de ces changements ressortent clairement des exemples. Cependant, il existe encore de nombreuses façons d'améliorer ces exemples. Par exemple, la méthode OrderService.Create peut être déplacée vers sa propre classe : OrderCreator. Étant donné que la création de commande est une unité indépendante de la logique métier suivant le principe de responsabilité unique, il est naturel qu'elle ait sa propre classe avec son propre ensemble de dépendances. De même, la suppression et l'annulation de commandes peuvent chacune être mises en œuvre dans leurs propres classes.
Lorsque j'ai écrit du code hautement couplé, quelque chose de similaire au tout premier exemple de cet article, toute petite modification de l'exigence pouvait facilement entraîner de nombreux changements dans d'autres parties du code. SRP aide les développeurs à écrire du code découplé, où chaque classe a son propre travail. Si les spécifications de ce travail changent, le développeur apporte des modifications à cette classe spécifique uniquement. Le changement est moins susceptible de casser l'ensemble de l'application car les autres classes devraient continuer à faire leur travail comme avant, à moins bien sûr qu'elles aient été cassées en premier lieu.
Développer du code à l'avance en utilisant ces techniques et en suivant le principe de responsabilité unique peut sembler une tâche ardue, mais les efforts seront certainement payants à mesure que le projet grandit et que le développement se poursuit.