Zasada pojedynczej odpowiedzialności: przepis na świetny kod

Opublikowany: 2022-03-11

Niezależnie od tego, co uważamy za świetny kod, zawsze wymaga on jednej prostej cechy: kod musi być łatwy w utrzymaniu. Właściwe wcięcie, zgrabne nazwy zmiennych, 100% pokrycia testami i tak dalej mogą zaprowadzić Cię tylko tak daleko. Każdy kod, który nie jest utrzymywany i nie może stosunkowo łatwo dostosować się do zmieniających się wymagań, jest kodem, który czeka, aż stanie się przestarzały. Możemy nie potrzebować pisać świetnego kodu, gdy próbujemy zbudować prototyp, dowód koncepcji lub minimalny opłacalny produkt, ale we wszystkich innych przypadkach powinniśmy zawsze pisać kod, który można konserwować. Jest to coś, co należy uznać za fundamentalną jakość inżynierii oprogramowania i projektowania.

Zasada pojedynczej odpowiedzialności: przepis na świetny kod

W tym artykule omówię, w jaki sposób zasada pojedynczej odpowiedzialności i niektóre techniki, które się na niej opierają, mogą nadać Twojemu kodowi taką jakość. Pisanie świetnego kodu to sztuka, ale niektóre zasady zawsze mogą pomóc nadać twojej pracy programistycznej kierunek, w którym powinna podążać, aby stworzyć solidne i łatwe w utrzymaniu oprogramowanie.

Model jest wszystkim

Prawie każda książka o jakimś nowym frameworku MVC (MVP, MVVM lub innym M**) jest zaśmiecona przykładami złego kodu. Te przykłady próbują pokazać, co ma do zaoferowania framework. Ale w końcu dają złe rady początkującym. Przykłady typu „powiedzmy, że mamy ten ORM X dla naszych modeli, silnik szablonów Y dla naszych poglądów i będziemy mieć kontrolery do zarządzania tym wszystkim” nie dają nic poza ogromnymi kontrolerami.

Chociaż w obronie tych książek, przykłady mają na celu zademonstrowanie łatwości, z jaką można rozpocząć pracę z ich ramami. Nie są przeznaczone do nauczania projektowania oprogramowania. Ale czytelnicy podążający za tymi przykładami dopiero po latach zdają sobie sprawę, jak bezproduktywne jest posiadanie w ich projekcie monolitycznych fragmentów kodu.

Modele są sercem Twojej aplikacji.

Modele są sercem Twojej aplikacji. Jeśli masz modele oddzielone od reszty logiki aplikacji, konserwacja będzie znacznie łatwiejsza, niezależnie od stopnia skomplikowania aplikacji. Nawet w przypadku skomplikowanych aplikacji dobra implementacja modelu może skutkować niezwykle ekspresyjnym kodem. Aby to osiągnąć, zacznij od upewnienia się, że Twoje modele robią tylko to, do czego są przeznaczone, i nie przejmują się tym, co robi aplikacja zbudowana wokół nich. Co więcej, nie dotyczy tego, czym jest podstawowa warstwa przechowywania danych: czy Twoja aplikacja opiera się na bazie danych SQL, czy przechowuje wszystko w plikach tekstowych?

Kontynuując ten artykuł, zdasz sobie sprawę, jak świetny kod polega na oddzieleniu troski.

Zasada pojedynczej odpowiedzialności

Prawdopodobnie słyszałeś o zasadach SOLID: pojedyncza odpowiedzialność, otwarte-zamknięte, substytucja liskov, segregacja interfejsów i inwersja zależności. Pierwsza litera, S, reprezentuje zasadę pojedynczej odpowiedzialności (SRP) i jej znaczenia nie można przecenić. Powiedziałbym nawet, że jest to warunek konieczny i wystarczający dla dobrego kodu. W rzeczywistości w każdym źle napisanym kodzie zawsze można znaleźć klasę, która ma więcej niż jedną odpowiedzialność - form1.cs lub index.php zawierające kilka tysięcy linijek kodu nie jest czymś tak rzadkim, że przychodzi nam wszystkim prawdopodobnie to widziałem lub zrobiłem.

Rzućmy okiem na przykład w C# (ASP.NET MVC i Entity framework). Nawet jeśli nie jesteś programistą C#, mając pewne doświadczenie w zakresie OOP, będziesz mógł łatwo śledzić.

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

Jest to zwykła klasa OrderController, pokazana jest jej metoda Create. W takich kontrolerach często widzę przypadki, w których sama klasa Order jest używana jako parametr żądania. Ale wolę używać klas specjalnych. Znowu SRP!

Zbyt wiele zadań dla jednego kontrolera

Zauważ we fragmencie kodu powyżej, że kontroler wie zbyt dużo o „złożeniu zamówienia”, w tym między innymi o przechowywaniu obiektu Order, wysyłaniu wiadomości e-mail itp. To po prostu zbyt wiele zadań dla jednej klasy. Przy każdej drobnej zmianie programista musi zmienić cały kod kontrolera. I na wypadek, gdyby inny kontroler również musiał tworzyć zamówienia, najczęściej programiści uciekają się do kopiowania i wklejania kodu. Kontrolery powinny kontrolować tylko cały proces, a nie wszystkie elementy logiki procesu.

Ale dzisiaj jest dzień, w którym przestajemy pisać te ogromne kontrolery!

Najpierw wyodrębnijmy całą logikę biznesową z kontrolera i przenieśmy ją do klasy 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"); }

Po wykonaniu tych czynności kontroler robi teraz tylko to, do czego jest przeznaczony: kontroluje proces. Wie tylko o widokach, klasach OrderService i OrderRequest - najmniejszym zestawie informacji wymaganych do wykonania swojej pracy, czyli zarządzania żądaniami i wysyłaniem odpowiedzi.

W ten sposób rzadko będziesz zmieniać kod kontrolera. Inne komponenty, takie jak widoki, obiekty żądań i usługi, mogą się nadal zmieniać, ponieważ są powiązane z wymaganiami biznesowymi, ale nie kontrolery.

Na tym polega SRP i istnieje wiele technik pisania kodu, które spełniają tę zasadę. Jednym z przykładów jest wstrzykiwanie zależności (coś, co jest również przydatne do pisania testowalnego kodu).

Wstrzykiwanie zależności

Trudno wyobrazić sobie duży projekt oparty na zasadzie Single Responsibility Principle bez Dependency Injection. Przyjrzyjmy się ponownie naszej klasie 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(); } }

Ten kod działa, ale nie jest idealny. Aby zrozumieć, jak działa metoda tworzenia klasy OrderService, zmuszeni są zrozumieć zawiłości SMTP. I znowu, kopiuj-wklej jest jedynym sposobem na powielenie tego użycia SMTP wszędzie tam, gdzie jest to potrzebne. Ale przy odrobinie refaktoryzacji może się to zmienić:

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

Już znacznie lepiej! Ale klasa OrderService nadal dużo wie o wysyłaniu wiadomości e-mail. Potrzebuje dokładnie klasy SmtpMailer do wysyłania wiadomości e-mail. A jeśli chcemy to zmienić w przyszłości? Co zrobić, jeśli chcemy wydrukować zawartość wysyłanej wiadomości e-mail do specjalnego pliku dziennika, zamiast faktycznie wysyłać je w naszym środowisku programistycznym? Co jeśli chcemy przetestować naszą klasę OrderService? Kontynuujmy refaktoryzację, tworząc interfejs IMailera:

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

SmtpMailer zaimplementuje ten interfejs. Ponadto nasza aplikacja będzie korzystała z kontenera IoC i możemy go skonfigurować tak, aby IMailer był zaimplementowany przez klasę SmtpMailer. OrderService można następnie zmienić w następujący sposób:

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

Teraz dokądś zmierzamy! Skorzystałem z tej szansy, aby dokonać kolejnej zmiany. OrderService opiera się teraz na interfejsie IOrderRepository do interakcji z komponentem, który przechowuje wszystkie nasze zamówienia. Nie dba już o to, jak ten interfejs jest zaimplementowany i jaka technologia pamięci masowej go napędza. Teraz klasa OrderService posiada tylko kod, który zajmuje się logiką biznesową zamówień.

W ten sposób, jeśli tester znajdzie coś niepoprawnie zachowującego się przy wysyłaniu e-maili, programista dokładnie wie, gdzie szukać: klasa SmtpMailer. Jeśli coś było nie tak z rabatami, programista znowu wie, gdzie szukać: kod klasy OrderService (lub w przypadku, gdy przyjąłeś SRP na pamięć, może to być kod klasy DiscountService).

Architektura oparta na zdarzeniach

Jednak nadal nie podoba mi się metoda OrderService.Create:

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

Wysyłanie wiadomości e-mail nie jest częścią głównego procesu tworzenia zamówienia. Nawet jeśli aplikacja nie wyśle ​​e-maila, zamówienie i tak jest tworzone poprawnie. Wyobraź sobie również sytuację, w której musisz dodać nową opcję w obszarze ustawień użytkownika, która pozwala mu zrezygnować z otrzymywania wiadomości e-mail po pomyślnym złożeniu zamówienia. Aby włączyć to do naszej klasy OrderService, będziemy musieli wprowadzić zależność IUserParametersService. Dodaj lokalizację do miksu, a otrzymasz kolejną zależność, ITranslator (aby generować prawidłowe wiadomości e-mail w wybranym języku użytkownika). Kilka z tych działań jest niepotrzebnych, zwłaszcza pomysł dodania tych wielu zależności i skończenie z konstruktorem, który nie mieści się na ekranie. Znalazłem świetny przykład w bazie kodu Magento (popularny CMS e-commerce napisany w PHP) w klasie, która ma 32 zależności!

Konstruktor, który nie mieści się na ekranie

Czasami po prostu trudno jest wymyślić, jak oddzielić tę logikę, a klasa Magento jest prawdopodobnie ofiarą jednego z tych przypadków. Dlatego lubię sposób oparty na zdarzeniach:

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

Za każdym razem, gdy tworzone jest zamówienie, zamiast wysyłać e-mail bezpośrednio z klasy OrderService, tworzona jest specjalna klasa zdarzenia OrderCreated i generowane jest zdarzenie. Gdzieś w aplikacji zostaną skonfigurowane programy obsługi zdarzeń. Jeden z nich wyśle ​​e-mail do klienta.

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

Klasa OrderCreated jest celowo oznaczona jako możliwa do serializacji. Możemy obsłużyć to zdarzenie natychmiast lub przechowywać je w postaci serializowanej w kolejce (Redis, ActiveMQ lub coś innego) i przetwarzać je w procesie/wątku innym niż ten, który obsługuje żądania internetowe. W tym artykule autor wyjaśnia szczegółowo, czym jest architektura sterowana zdarzeniami (proszę nie zwracać uwagi na logikę biznesową w OrderController).

Niektórzy mogą argumentować, że teraz trudno jest zrozumieć, co się dzieje podczas tworzenia porządku. Ale to nie może być dalsze od prawdy. Jeśli tak uważasz, po prostu skorzystaj z funkcjonalności swojego IDE. Odnajdując wszystkie zastosowania klasy OrderCreated w IDE, możemy zobaczyć wszystkie akcje związane ze zdarzeniem.

Ale kiedy powinienem używać Dependency Injection, a kiedy powinienem używać podejścia opartego na zdarzeniach? Odpowiedź na to pytanie nie zawsze jest łatwa, ale jedną prostą zasadą, która może ci pomóc, jest użycie Dependency Injection dla wszystkich głównych działań w aplikacji i podejścia opartego na zdarzeniach dla wszystkich działań drugorzędnych. Na przykład użyj Dependecy Injection z takimi rzeczami, jak tworzenie zamówienia w klasie OrderService za pomocą IOrderRepository i deleguj wysyłanie wiadomości e-mail, co nie jest kluczową częścią głównego przepływu tworzenia zamówienia, do niektórych programów obsługi zdarzeń.

Wniosek

Zaczęliśmy od bardzo ciężkiego kontrolera, tylko jednej klasy, a skończyliśmy z rozbudowaną kolekcją klas. Zalety tych zmian są dość oczywiste na przykładach. Jednak wciąż istnieje wiele sposobów na ulepszenie tych przykładów. Na przykład metodę OrderService.Create można przenieść do własnej klasy: OrderCreator. Ponieważ tworzenie zamówień jest niezależną jednostką logiki biznesowej zgodnie z zasadą pojedynczej odpowiedzialności, naturalne jest, że ma ona własną klasę z własnym zestawem zależności. Podobnie usuwanie zamówień i anulowanie zamówień mogą być realizowane we własnych klasach.

Kiedy pisałem wysoce powiązany kod, coś podobnego do pierwszego przykładu w tym artykule, każda niewielka zmiana w wymaganiach może łatwo prowadzić do wielu zmian w innych częściach kodu. SRP pomaga programistom pisać kod, który jest oddzielony, gdzie każda klasa ma swoje własne zadanie. Jeśli specyfikacje tego zadania ulegną zmianie, programista wprowadza zmiany tylko w tej konkretnej klasie. Zmiana jest mniej prawdopodobna, aby zepsuć całą aplikację, ponieważ inne klasy nadal powinny wykonywać swoją pracę tak jak wcześniej, chyba że zostały one zepsute w pierwszej kolejności.

Tworzenie kodu z góry przy użyciu tych technik i przestrzeganie zasady pojedynczej odpowiedzialności może wydawać się zniechęcającym zadaniem, ale wysiłki z pewnością się zwrócą wraz z rozwojem projektu i dalszym rozwojem.