Single-Responsibility-Prinzip: Ein Rezept für großartigen Code

Veröffentlicht: 2022-03-11

Unabhängig davon, was wir als großartigen Code betrachten, erfordert er immer eine einfache Eigenschaft: Der Code muss wartbar sein. Korrekte Einrückungen, ordentliche Variablennamen, 100 % Testabdeckung und so weiter können Sie nur so weit bringen. Jeder Code, der nicht wartbar ist und sich nicht relativ einfach an sich ändernde Anforderungen anpassen kann, ist Code, der nur darauf wartet, obsolet zu werden. Wir müssen vielleicht keinen großartigen Code schreiben, wenn wir versuchen, einen Prototypen, einen Proof of Concept oder ein Minimum Viable Product zu bauen, aber in allen anderen Fällen sollten wir immer Code schreiben, der wartbar ist. Dies ist etwas, das als grundlegende Eigenschaft von Software-Engineering und -Design betrachtet werden sollte.

Single-Responsibility-Prinzip: Ein Rezept für großartigen Code

In diesem Artikel werde ich erörtern, wie das Single-Responsibility-Prinzip und einige Techniken, die sich darum drehen, Ihrem Code genau diese Qualität verleihen können. Das Schreiben von großartigem Code ist eine Kunst, aber einige Prinzipien können immer dabei helfen, Ihrer Entwicklungsarbeit die Richtung zu geben, in die sie sich bewegen muss, um robuste und wartbare Software zu produzieren.

Modell ist alles

Fast jedes Buch über ein neues MVC-Framework (MVP, MVVM oder andere M**) ist mit Beispielen für schlechten Code übersät. Diese Beispiele versuchen zu zeigen, was das Framework zu bieten hat. Aber sie liefern am Ende auch schlechte Ratschläge für Anfänger. Beispiele wie „sagen wir, wir haben dieses ORM X für unsere Modelle, Template-Engine Y für unsere Ansichten und wir werden Controller haben, um alles zu verwalten“ erreichen nichts anderes als riesige Controller.

Obwohl diese Bücher verteidigt werden sollen, sollen die Beispiele zeigen, wie einfach Sie mit ihrem Framework beginnen können. Sie sind nicht dazu gedacht, Softwaredesign zu lehren. Aber Leser, die diesen Beispielen folgen, erkennen erst nach Jahren, wie kontraproduktiv es ist, monolithische Code-Blöcke in ihrem Projekt zu haben.

Modelle sind das Herzstück Ihrer App.

Modelle sind das Herzstück Ihrer App. Wenn Sie Modelle vom Rest Ihrer Anwendungslogik getrennt haben, wird die Wartung viel einfacher, unabhängig davon, wie kompliziert Ihre Anwendung wird. Selbst bei komplizierten Anwendungen kann eine gute Modellimplementierung zu einem äußerst ausdrucksstarken Code führen. Um dies zu erreichen, stellen Sie zunächst sicher, dass Ihre Modelle nur das tun, wofür sie bestimmt sind, und kümmern Sie sich nicht darum, was die darauf basierende App tut. Darüber hinaus kümmert es sich nicht darum, was die zugrunde liegende Datenspeicherschicht ist: Verlässt sich Ihre App auf eine SQL-Datenbank oder speichert sie alles in Textdateien?

Während wir diesen Artikel fortsetzen, werden Sie feststellen, wie gut Code in hohem Maße von der Trennung von Anliegen abhängt.

Grundsatz der Einzelverantwortung

Sie haben wahrscheinlich schon von den SOLID-Prinzipien gehört: Single Responsibility, Open-Closed, Liskov Substitution, Interface Segregation und Dependency Inversion. Der erste Buchstabe S steht für das Single Responsibility Principle (SRP) und seine Bedeutung kann nicht genug betont werden. Ich würde sogar argumentieren, dass dies eine notwendige und hinreichende Bedingung für guten Code ist. Tatsächlich kann man in jedem schlecht geschriebenen Code immer eine Klasse finden, die mehr als eine Verantwortlichkeit hat – form1.cs oder index.php, die ein paar tausend Codezeilen enthalten, kommen nicht so selten vor und wir alle wahrscheinlich gesehen oder getan.

Sehen wir uns ein Beispiel in C# (ASP.NET MVC und Entity Framework) an. Auch wenn Sie kein C#-Entwickler sind, können Sie mit etwas OOP-Erfahrung leicht folgen.

 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) }

Dies ist eine übliche OrderController-Klasse, deren Create-Methode gezeigt wird. In Controllern wie diesem sehe ich oft Fälle, in denen die Order-Klasse selbst als Anfrageparameter verwendet wird. Aber ich ziehe es vor, spezielle Anforderungsklassen zu verwenden. Nochmal SRP!

Zu viele Jobs für einen einzelnen Controller

Beachten Sie im obigen Code-Snippet, dass der Controller zu viel über das „Aufgeben einer Bestellung“ weiß, einschließlich, aber nicht beschränkt auf das Speichern des Order-Objekts, das Senden von E-Mails usw. Das sind einfach zu viele Jobs für eine einzelne Klasse. Für jede kleine Änderung muss der Entwickler den gesamten Controller-Code ändern. Und nur für den Fall, dass ein anderer Controller auch Bestellungen erstellen muss, greifen Entwickler meistens auf das Kopieren und Einfügen des Codes zurück. Controller sollten nur den Gesamtprozess steuern und nicht wirklich jede Logik des Prozesses beherbergen.

Aber heute ist der Tag, an dem wir aufhören, diese riesigen Controller zu schreiben!

Lassen Sie uns zunächst die gesamte Geschäftslogik aus dem Controller extrahieren und in eine OrderService-Klasse verschieben:

 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"); }

Damit tut der Controller nur noch das, was er tun soll: den Prozess steuern. Es kennt nur Views, OrderService- und OrderRequest-Klassen – die wenigsten Informationen, die für seine Aufgabe erforderlich sind, nämlich die Verwaltung von Anfragen und das Senden von Antworten.

Auf diese Weise werden Sie den Controller-Code selten ändern. Andere Komponenten wie Ansichten, Anforderungsobjekte und Dienste können sich noch ändern, da sie mit Geschäftsanforderungen verknüpft sind, jedoch nicht mit Controllern.

Darum geht es bei SRP, und es gibt viele Techniken zum Schreiben von Code, die diesem Prinzip entsprechen. Ein Beispiel hierfür ist die Abhängigkeitsinjektion (etwas, das auch zum Schreiben von testbarem Code nützlich ist).

Abhängigkeitsspritze

Ein großes Projekt, das auf dem Single-Responsibility-Prinzip basiert, ist ohne Dependency Injection schwer vorstellbar. Schauen wir uns noch einmal unsere Klasse OrderService an:

 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(); } }

Dieser Code funktioniert, ist aber nicht ganz ideal. Um zu verstehen, wie die OrderService-Klasse der Erstellungsmethode funktioniert, müssen sie die Feinheiten von SMTP verstehen. Und noch einmal: Kopieren und Einfügen ist der einzige Ausweg, um diese Verwendung von SMTP überall dort zu replizieren, wo sie benötigt wird. Aber mit ein wenig Refactoring kann sich das ändern:

 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 } }

Schon viel besser! Aber die OrderService-Klasse weiß immer noch viel über das Senden von E-Mails. Es benötigt genau die SmtpMailer-Klasse, um E-Mails zu senden. Was, wenn wir es in Zukunft ändern wollen? Was ist, wenn wir den Inhalt der gesendeten E-Mail in eine spezielle Protokolldatei drucken möchten, anstatt sie tatsächlich in unsere Entwicklungsumgebung zu senden? Was ist, wenn wir unsere OrderService-Klasse einem Unit-Test unterziehen wollen? Lassen Sie uns mit dem Refactoring fortfahren, indem wir eine Schnittstelle IMailer erstellen:

 public interface IMailer { void Send(string to, string subject, string body); }

SmtpMailer implementiert diese Schnittstelle. Außerdem verwendet unsere Anwendung einen IoC-Container und wir können ihn so konfigurieren, dass IMailer von der SmtpMailer-Klasse implementiert wird. OrderService kann dann wie folgt geändert werden:

 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>); } }

Jetzt kommen wir irgendwo hin! Ich habe diese Chance genutzt, um noch eine weitere Änderung vorzunehmen. Der OrderService verlässt sich jetzt auf die IOrderRepository-Schnittstelle, um mit der Komponente zu interagieren, die alle unsere Bestellungen speichert. Es kümmert sich nicht mehr darum, wie diese Schnittstelle implementiert ist und welche Speichertechnologie sie antreibt. Jetzt hat die OrderService-Klasse nur Code, der sich mit der Auftragsgeschäftslogik befasst.

Auf diese Weise weiß der Entwickler genau, wo er suchen muss, wenn ein Tester beim Senden von E-Mails etwas falsch verhält: SmtpMailer-Klasse. Wenn etwas mit Rabatten nicht stimmt, weiß der Entwickler wiederum, wo er suchen muss: OrderService (oder falls Sie SRP auswendig angenommen haben, dann kann es DiscountService sein) Klassencode.

Ereignisgesteuerte Architektur

Allerdings gefällt mir die Methode OrderService.Create immer noch nicht:

 public void Create(...) { var order = new Order(); ... this.repository.Save(order); this.mailer.Send(<orders user email>, <subject>, <body with order details>); }

Das Versenden einer E-Mail gehört nicht unbedingt zum Hauptablauf der Auftragserstellung. Auch wenn die App die E-Mail nicht senden kann, wird die Bestellung trotzdem korrekt erstellt. Stellen Sie sich auch eine Situation vor, in der Sie eine neue Option im Bereich der Benutzereinstellungen hinzufügen müssen, die es ihnen ermöglicht, sich vom Erhalt einer E-Mail abzumelden, nachdem sie erfolgreich eine Bestellung aufgegeben haben. Um dies in unsere OrderService-Klasse zu integrieren, müssen wir eine Abhängigkeit, IUSerParametersService, einführen. Fügen Sie der Mischung Lokalisierung hinzu, und Sie haben noch eine weitere Abhängigkeit, ITranslator (um korrekte E-Mail-Nachrichten in der Sprache der Wahl des Benutzers zu erstellen). Einige dieser Aktionen sind unnötig, insbesondere die Idee, diese vielen Abhängigkeiten hinzuzufügen und mit einem Konstruktor zu enden, der nicht auf den Bildschirm passt. Ich habe ein großartiges Beispiel dafür in der Codebasis von Magento (einem beliebten E-Commerce-CMS, das in PHP geschrieben ist) in einer Klasse mit 32 Abhängigkeiten gefunden!

Ein Konstruktor, der nicht auf den Bildschirm passt

Manchmal ist es einfach schwierig herauszufinden, wie man diese Logik trennen kann, und Magentos Klasse ist wahrscheinlich ein Opfer eines dieser Fälle. Deshalb mag ich den ereignisgesteuerten Weg:

 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; } } }

Immer wenn eine Bestellung erstellt wird, wird anstelle des direkten Sendens einer E-Mail von der OrderService-Klasse die spezielle Ereignisklasse OrderCreated erstellt und ein Ereignis generiert. Irgendwo in der Anwendung werden Event-Handler konfiguriert. Einer von ihnen sendet eine E-Mail an den Kunden.

 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(...); } } }

Die Klasse OrderCreated ist absichtlich als Serializable gekennzeichnet. Wir können dieses Ereignis sofort verarbeiten oder es serialisiert in einer Warteschlange (Redis, ActiveMQ oder etwas anderes) speichern und es in einem Prozess/Thread verarbeiten, der von dem getrennt ist, der Webanforderungen verarbeitet. In diesem Artikel erklärt der Autor ausführlich, was ereignisgesteuerte Architektur ist (bitte nicht auf die Geschäftslogik innerhalb des OrderControllers achten).

Einige mögen argumentieren, dass es jetzt schwierig ist zu verstehen, was vor sich geht, wenn Sie die Bestellung erstellen. Aber das kann nicht weiter von der Wahrheit entfernt sein. Wenn Sie so denken, nutzen Sie einfach die Funktionalität Ihrer IDE. Indem wir alle Verwendungen der OrderCreated-Klasse in der IDE finden, können wir alle mit dem Ereignis verbundenen Aktionen sehen.

Aber wann sollte ich Dependency Injection und wann einen ereignisgesteuerten Ansatz verwenden? Es ist nicht immer einfach, diese Frage zu beantworten, aber eine einfache Regel, die Ihnen helfen kann, ist die Verwendung von Dependency Injection für alle Ihre Hauptaktivitäten innerhalb der Anwendung und ein ereignisgesteuerter Ansatz für alle sekundären Aktionen. Verwenden Sie beispielsweise die Abhängigkeitsinjektion mit Dingen wie dem Erstellen einer Bestellung innerhalb der OrderService-Klasse mit IOrderRepository und delegieren Sie das Senden von E-Mails, etwas, das kein entscheidender Teil des Hauptablaufs der Bestellungserstellung ist, an einen Ereignishandler.

Fazit

Wir begannen mit einem sehr schweren Controller, nur einer Klasse, und endeten mit einer ausgeklügelten Sammlung von Klassen. Die Vorteile dieser Änderungen sind aus den Beispielen deutlich ersichtlich. Es gibt jedoch noch viele Möglichkeiten, diese Beispiele zu verbessern. Beispielsweise kann die Methode OrderService.Create in eine eigene Klasse verschoben werden: OrderCreator. Da die Auftragserstellung eine eigenständige Einheit der Geschäftslogik nach dem Single-Responsibility-Prinzip ist, ist es nur natürlich, dass sie eine eigene Klasse mit eigenen Abhängigkeiten hat. Ebenso können Auftragslöschung und Auftragsstornierung jeweils in eigenen Klassen implementiert werden.

Als ich stark gekoppelten Code schrieb, ähnlich dem allerersten Beispiel in diesem Artikel, konnte jede kleine Änderung der Anforderung leicht zu vielen Änderungen in anderen Teilen des Codes führen. SRP hilft Entwicklern, entkoppelten Code zu schreiben, bei dem jede Klasse ihre eigene Aufgabe hat. Wenn sich die Spezifikationen dieses Jobs ändern, nimmt der Entwickler nur Änderungen an dieser bestimmten Klasse vor. Es ist weniger wahrscheinlich, dass die Änderung die gesamte Anwendung beschädigt, da andere Klassen weiterhin ihre Arbeit wie zuvor erledigen sollten, es sei denn, sie wurden von vornherein beschädigt.

Die Entwicklung von Code im Voraus mit diesen Techniken und der Einhaltung des Single-Responsibility-Prinzips kann wie eine entmutigende Aufgabe erscheinen, aber die Bemühungen werden sich sicherlich auszahlen, wenn das Projekt wächst und die Entwicklung fortgesetzt wird.