Principiul responsabilității unice: o rețetă pentru un mare cod

Publicat: 2022-03-11

Indiferent de ceea ce considerăm a fi un cod grozav, acesta necesită întotdeauna o calitate simplă: codul trebuie să poată fi întreținut. Indentarea corectă, nume ordonate ale variabilelor, acoperirea testului 100% și așa mai departe vă pot duce doar atât de departe. Orice cod care nu poate fi întreținut și nu se poate adapta la cerințele în schimbare cu relativă ușurință este un cod care așteaptă să devină învechit. S-ar putea să nu avem nevoie să scriem cod grozav atunci când încercăm să construim un prototip, o dovadă de concept sau un produs minim viabil, dar în toate celelalte cazuri ar trebui să scriem întotdeauna cod care poate fi întreținut. Acesta este ceva care ar trebui considerat o calitate fundamentală a ingineriei și proiectării software.

Principiul responsabilității unice: o rețetă pentru un mare cod

În acest articol, voi discuta despre modul în care Principiul responsabilității unice și unele tehnici care se învârt în jurul acestuia pot oferi codului dumneavoastră această calitate. Scrierea unui cod grozav este o artă, dar unele principii vă pot ajuta întotdeauna să vă oferiți activității de dezvoltare direcția spre care trebuie să se îndrepte pentru a produce software robust și ușor de întreținut.

Modelul este totul

Aproape fiecare carte despre un cadru MVC nou (MVP, MVVM sau alt M**) este plină de exemple de cod prost. Aceste exemple încearcă să arate ce are de oferit cadrul. Dar ajung și să ofere sfaturi proaste pentru începători. Exemple precum „să spunem că avem acest ORM X pentru modelele noastre, motorul de șabloane Y pentru vederile noastre și vom avea controlere pentru a gestiona totul” nu realizează nimic altceva decât controlere uriașe.

Deși în apărarea acestor cărți, exemplele sunt menite să demonstreze ușurința cu care puteți începe cu cadrul lor. Ele nu sunt menite să predea design software. Dar cititorii care urmează aceste exemple realizează, abia după ani, cât de contraproductiv este să aibă bucăți monolitice de cod în proiectul lor.

Modelele sunt inima aplicației dvs.

Modelele sunt inima aplicației dvs. Dacă aveți modele separate de restul logicii aplicației, întreținerea va fi mult mai ușoară, indiferent de cât de complicată devine aplicația dvs. Chiar și pentru aplicații complicate, o bună implementare a modelului poate duce la un cod extrem de expresiv. Și pentru a realiza acest lucru, începeți prin a vă asigura că modelele dvs. fac doar ceea ce sunt menite să facă și nu vă preocupați de ceea ce face aplicația construită în jurul ei. În plus, nu se preocupă de ceea ce este stratul de stocare a datelor de bază: aplicația dvs. se bazează pe o bază de date SQL sau stochează totul în fișiere text?

Pe măsură ce continuăm acest articol, veți realiza cât de grozav este codul despre separarea preocupărilor.

Principiul responsabilității unice

Probabil ați auzit despre principiile SOLID: responsabilitate unică, deschis-închis, substituție liskov, segregarea interfeței și inversarea dependenței. Prima literă, S, reprezintă principiul responsabilității unice (SRP) și importanța sa nu poate fi exagerată. Aș susține chiar că este o condiție necesară și suficientă pentru un cod bun. De fapt, în orice cod scris prost, puteți găsi întotdeauna o clasă care are mai mult de o responsabilitate - form1.cs sau index.php care conține câteva mii de linii de cod nu este ceva atât de rar întâlnit și noi toți probabil că l-am văzut sau făcut.

Să aruncăm o privire la un exemplu în C# (ASP.NET MVC și Entity framework). Chiar dacă nu sunteți un dezvoltator C#, cu ceva experiență OOP, veți putea urmări cu ușurință.

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

Aceasta este o clasă obișnuită OrderController, fiind afișată metoda sa Create. În astfel de controlere, văd adesea cazuri în care clasa Order în sine este folosită ca parametru de cerere. Dar prefer să folosesc clase de solicitare specială. Din nou, SRP!

Prea multe locuri de muncă pentru un singur controler

Observați în fragmentul de cod de mai sus cum controlorul știe prea multe despre „plasarea unei comenzi”, inclusiv, dar fără a se limita la stocarea obiectului Comanda, trimiterea de e-mailuri etc. Este pur și simplu prea multe locuri de muncă pentru o singură clasă. Pentru fiecare modificare mică, dezvoltatorul trebuie să schimbe întregul cod al controlerului. Și în cazul în care un alt Controller trebuie să creeze comenzi, de cele mai multe ori, dezvoltatorii vor recurge la copierea și lipirea codului. Controlorii ar trebui să controleze doar întregul proces și nu ar trebui să găzduiască de fapt fiecare parte de logică a procesului.

Dar astăzi este ziua în care încetăm să scriem aceste controlere uriașe!

Să extragem mai întâi toată logica de afaceri din controler și să o mutăm într-o clasă 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"); }

După ce a făcut acest lucru, controlerul face acum doar ceea ce este destinat să facă: controlează procesul. Știe doar despre vizualizări, clase OrderService și OrderRequest - cel mai mic set de informații necesare pentru a-și face treaba, care este gestionarea cererilor și trimiterea răspunsurilor.

În acest fel, rareori vei schimba codul controlerului. Alte componente, cum ar fi vizualizările, obiectele de solicitare și serviciile se pot modifica în continuare, deoarece sunt legate de cerințele de afaceri, dar nu de controlori.

Despre aceasta este SRP și există multe tehnici de scriere a codului care respectă acest principiu. Un exemplu în acest sens este injecția de dependență (ceva care este util și pentru scrierea codului testabil).

Injecție de dependență

Este greu de imaginat un proiect mare bazat pe principiul responsabilității unice fără injecția de dependență. Să aruncăm o privire la clasa noastră OrderService din nou:

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

Acest cod funcționează, dar nu este chiar ideal. Pentru a înțelege cum funcționează metoda de creare a clasei OrderService, aceștia sunt forțați să înțeleagă complexitățile SMTP. Și, din nou, copy-paste este singura modalitate de a replica această utilizare a SMTP oriunde este nevoie. Dar cu puțină refactorizare, asta se poate schimba:

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

Deja mult mai bine! Dar, clasa OrderService știe încă multe despre trimiterea de e-mailuri. Are nevoie de clasa SmtpMailer pentru a trimite e-mail. Dacă vrem să-l schimbăm în viitor? Ce se întâmplă dacă dorim să tipărim conținutul e-mailului care este trimis într-un fișier jurnal special în loc să le trimitem efectiv în mediul nostru de dezvoltare? Ce se întâmplă dacă vrem să testăm clasa noastră OrderService? Să continuăm cu refactorizarea creând o interfață IMailer:

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

SmtpMailer va implementa această interfață. De asemenea, aplicația noastră va folosi un container IoC și îl putem configura astfel încât IMailer să fie implementat de clasa SmtpMailer. OrderService poate fi apoi modificat după cum urmează:

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

Acum ajungem undeva! Am profitat de această șansă pentru a face și o altă schimbare. OrderService se bazează acum pe interfața IOrderRepository pentru a interacționa cu componenta care stochează toate comenzile noastre. Nu-i mai pasă de modul în care acea interfață este implementată și de ce tehnologie de stocare o alimentează. Acum clasa OrderService are doar cod care se ocupă de logica comercială a comenzii.

În acest fel, dacă un tester ar găsi ceva care se comportă incorect la trimiterea de e-mailuri, dezvoltatorul știe exact unde să caute: clasa SmtpMailer. Dacă ceva a fost în neregulă cu reducerile, dezvoltatorul, din nou, știe unde să caute: OrderService (sau în cazul în care ați îmbrățișat SRP pe de rost, atunci poate fi DiscountService) cod de clasă.

Arhitectură condusă de evenimente

Cu toate acestea, încă nu îmi place 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>); }

Trimiterea unui e-mail nu face parte din fluxul principal de creare a comenzii. Chiar dacă aplicația nu reușește să trimită e-mailul, comanda este totuși creată corect. De asemenea, imaginați-vă o situație în care trebuie să adăugați o nouă opțiune în zona setărilor utilizatorului, care să le permită să renunțe la primirea unui e-mail după plasarea cu succes a unei comenzi. Pentru a încorpora acest lucru în clasa noastră OrderService, va trebui să introducem o dependență, IUserParametersService. Adăugați localizarea în combinație și aveți încă o dependență, ITranslator (pentru a produce mesaje de e-mail corecte în limba aleasă de utilizator). Câteva dintre aceste acțiuni sunt inutile, în special ideea de a adăuga aceste multe dependențe și de a ajunge la un constructor care nu se potrivește pe ecran. Am găsit un exemplu grozav în acest sens în baza de cod a Magento (un CMS popular de comerț electronic scris în PHP) într-o clasă care are 32 de dependențe!

Un constructor care nu se potrivește pe ecran

Uneori este greu să ne dai seama cum să separăm această logică, iar clasa Magento este probabil o victimă a unuia dintre aceste cazuri. De aceea îmi place modul bazat pe evenimente:

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

Ori de câte ori este creată o comandă, în loc să trimiteți un e-mail direct din clasa OrderService, este creată clasa de evenimente specială OrderCreated și este generat un eveniment. Undeva în aplicație vor fi configurați handlere de evenimente. Unul dintre ei va trimite un e-mail clientului.

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

Clasa OrderCreated este marcată ca Serializabil în mod intenționat. Putem gestiona acest eveniment imediat, sau îl putem stoca serializat într-o coadă (Redis, ActiveMQ sau altceva) și îl putem procesa într-un proces/thread separat de cel care gestionează cererile web. În acest articol, autorul explică în detaliu ce este arhitectura bazată pe evenimente (vă rugăm să nu acordați atenție logicii de afaceri din cadrul OrderController).

Unii ar putea argumenta că acum este dificil să înțelegeți ce se întâmplă atunci când creați comanda. Dar asta nu poate fi mai departe de adevăr. Dacă simțiți așa, pur și simplu profitați de funcționalitatea IDE-ului dvs. Găsind toate utilizările clasei OrderCreated în IDE, putem vedea toate acțiunile asociate evenimentului.

Dar când ar trebui să folosesc Dependency Injection și când ar trebui să folosesc o abordare bazată pe evenimente? Nu este întotdeauna ușor să răspunzi la această întrebare, dar o regulă simplă care te poate ajuta este să folosești Dependency Injection pentru toate activitățile tale principale din cadrul aplicației și abordarea bazată pe evenimente pentru toate acțiunile secundare. De exemplu, utilizați Dependecy Injection cu lucruri precum crearea unei comenzi în cadrul clasei OrderService cu IOrderRepository și delegați trimiterea de e-mail, ceva care nu este o parte esențială a fluxului principal de creare a comenzii, unui handler de evenimente.

Concluzie

Am început cu un controler foarte greu, o singură clasă și am ajuns cu o colecție elaborată de clase. Avantajele acestor modificări sunt destul de evidente din exemple. Cu toate acestea, există încă multe modalități de a îmbunătăți aceste exemple. De exemplu, metoda OrderService.Create poate fi mutată într-o clasă proprie: OrderCreator. Deoarece crearea comenzii este o unitate independentă a logicii de afaceri care urmează principiul responsabilității unice, este firesc ca aceasta să aibă propria sa clasă cu propriul set de dependențe. De asemenea, eliminarea comenzii și anularea comenzii pot fi implementate fiecare în propriile clase.

Când am scris cod foarte cuplat, ceva similar cu primul exemplu din acest articol, orice modificare mică a cerinței ar putea duce cu ușurință la multe modificări în alte părți ale codului. SRP îi ajută pe dezvoltatori să scrie coduri care sunt decuplate, unde fiecare clasă are propria sa sarcină. Dacă specificațiile acestui loc de muncă se modifică, dezvoltatorul face modificări numai la acea clasă specifică. Este mai puțin probabil ca schimbarea să distrugă întreaga aplicație, deoarece alte clase ar trebui să-și facă treaba ca înainte, cu excepția cazului în care, desigur, au fost rupte în primul rând.

Dezvoltarea codului în avans folosind aceste tehnici și respectarea principiului responsabilității unice poate părea o sarcină descurajantă, dar eforturile vor fi cu siguranță răsplătite pe măsură ce proiectul crește și dezvoltarea continuă.