Principio di responsabilità unica: una ricetta per un grande codice
Pubblicato: 2022-03-11Indipendentemente da quello che consideriamo un ottimo codice, richiede sempre una semplice qualità: il codice deve essere manutenibile. Una corretta indentazione, nomi di variabili accurati, copertura del test al 100% e così via possono solo portarti così lontano. Qualsiasi codice che non è gestibile e non può adattarsi a requisiti mutevoli con relativa facilità è codice che aspetta solo di diventare obsoleto. Potremmo non aver bisogno di scrivere un codice eccezionale quando stiamo cercando di costruire un prototipo, una prova di concetto o un prodotto minimo praticabile, ma in tutti gli altri casi dovremmo sempre scrivere codice che sia gestibile. Questo è qualcosa che dovrebbe essere considerato una qualità fondamentale dell'ingegneria e della progettazione del software.
In questo articolo, discuterò di come il Principio di responsabilità unica e alcune tecniche che ruotano attorno ad esso possono conferire al tuo codice questa stessa qualità. Scrivere un ottimo codice è un'arte, ma alcuni principi possono sempre aiutare a dare al tuo lavoro di sviluppo la direzione verso cui deve andare per produrre un software robusto e manutenibile.
Il modello è tutto
Quasi tutti i libri su alcuni nuovi framework MVC (MVP, MVVM o altri M**) sono pieni di esempi di codice errato. Questi esempi cercano di mostrare ciò che il framework ha da offrire. Ma finiscono anche per fornire cattivi consigli ai principianti. Esempi come "diciamo che abbiamo questo ORM X per i nostri modelli, il motore di modelli Y per le nostre viste e avremo controller per gestire tutto" non ottengono altro che enormi controller.
Sebbene a difesa di questi libri, gli esempi hanno lo scopo di dimostrare la facilità con cui puoi iniziare con il loro framework. Non hanno lo scopo di insegnare la progettazione di software. Ma i lettori che seguono questi esempi si rendono conto, solo dopo anni, di quanto sia controproducente avere blocchi di codice monolitici nel loro progetto.
I modelli sono il cuore della tua app. Se hai modelli separati dal resto della logica dell'applicazione, la manutenzione sarà molto più semplice, indipendentemente da quanto complicata diventa la tua applicazione. Anche per applicazioni complicate, una buona implementazione del modello può portare a un codice estremamente espressivo. E per raggiungere questo obiettivo, inizia assicurandoti che i tuoi modelli facciano solo ciò per cui sono destinati e non si preoccupino di ciò che fa l'app costruita attorno ad esso. Inoltre, non si preoccupa di quale sia il livello di archiviazione dei dati sottostante: la tua app si basa su un database SQL o memorizza tutto in file di testo?
Continuando questo articolo, ti renderai conto di quanto sia importante il codice sulla separazione delle preoccupazioni.
Principio di responsabilità unica
Probabilmente hai sentito parlare dei principi SOLID: responsabilità singola, aperto-chiuso, sostituzione liskov, segregazione dell'interfaccia e inversione delle dipendenze. La prima lettera, S, rappresenta il principio di responsabilità unica (SRP) e la sua importanza non può essere sopravvalutata. Direi anche che è una condizione necessaria e sufficiente per un buon codice. In effetti, in qualsiasi codice scritto male, puoi sempre trovare una classe che ha più di una responsabilità: form1.cs o index.php che contiene poche migliaia di righe di codice non è una cosa così rara e tutti noi probabilmente l'ho visto o fatto.
Diamo un'occhiata a un esempio in C# (ASP.NET MVC ed Entity Framework). Anche se non sei uno sviluppatore C#, con un po' di esperienza OOP sarai in grado di seguire facilmente.
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) }
Questa è una normale classe OrderController, viene mostrato il suo metodo Create. In controller come questo, vedo spesso casi in cui la classe Order stessa viene utilizzata come parametro di richiesta. Ma preferisco usare classi su richiesta speciale. Ancora una volta, SRP!
Nota nello snippet di codice sopra come il controller sa troppo sull'"effettuare un ordine", incluso ma non limitato alla memorizzazione dell'oggetto Order, all'invio di e-mail, ecc. Sono semplicemente troppi lavori per una singola classe. Per ogni piccola modifica, lo sviluppatore deve modificare l'intero codice del controller. E nel caso in cui anche un altro Controller abbia bisogno di creare ordini, il più delle volte gli sviluppatori ricorreranno al copia-incolla del codice. I controller dovrebbero controllare solo l'intero processo e non ospitare effettivamente tutta la logica del processo.
Ma oggi è il giorno in cui smettiamo di scrivere questi enormi controller!
Estraiamo prima tutta la logica aziendale dal controller e la spostiamo in una 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"); }
Fatto ciò, il controller ora fa solo ciò che è destinato a fare: controllare il processo. Conosce solo le viste, le classi OrderService e OrderRequest, l'insieme minimo di informazioni necessarie per svolgere il proprio lavoro, ovvero la gestione delle richieste e l'invio delle risposte.
In questo modo cambierai raramente il codice del controller. Altri componenti come viste, oggetti di richiesta e servizi possono ancora cambiare poiché sono collegati ai requisiti aziendali, ma non ai controller.
Questo è ciò che riguarda SRP e ci sono molte tecniche per scrivere codice che soddisfano questo principio. Un esempio di ciò è l'iniezione di dipendenza (qualcosa che è utile anche per scrivere codice verificabile).
Iniezione di dipendenza
È difficile immaginare un grande progetto basato sul principio di responsabilità unica senza l'iniezione di dipendenza. Diamo nuovamente un'occhiata alla nostra 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(); } }
Questo codice funziona, ma non è proprio l'ideale. Per capire come funziona il metodo di creazione della classe OrderService, sono costretti a comprendere le complessità di SMTP. E, ancora, il copia-incolla è l'unico modo per replicare questo uso di SMTP ovunque sia necessario. Ma con un piccolo refactoring, ciò può cambiare:
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 } }
Già molto meglio! Ma la classe OrderService sa ancora molto sull'invio di e-mail. Ha bisogno esattamente della classe SmtpMailer per inviare e-mail. E se volessimo cambiarlo in futuro? E se volessimo stampare il contenuto dell'e-mail inviata in un file di registro speciale invece di inviarlo effettivamente nel nostro ambiente di sviluppo? E se volessimo testare la nostra classe OrderService? Continuiamo con il refactoring creando un'interfaccia IMailer:

public interface IMailer { void Send(string to, string subject, string body); }
SmtpMailer implementerà questa interfaccia. Inoltre, la nostra applicazione utilizzerà un contenitore IoC e possiamo configurarlo in modo che IMailer sia implementato dalla classe SmtpMailer. OrderService può quindi essere modificato come segue:
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>); } }
Adesso stiamo andando da qualche parte! Ho colto l'occasione per fare anche un'altra modifica. OrderService ora si basa sull'interfaccia IOrderRepository per interagire con il componente che memorizza tutti i nostri ordini. Non si preoccupa più di come viene implementata quell'interfaccia e di quale tecnologia di archiviazione la alimenta. Ora la classe OrderService ha solo codice che si occupa della logica aziendale dell'ordine.
In questo modo, se un tester dovesse trovare qualcosa che si comporta in modo errato con l'invio di e-mail, lo sviluppatore sa esattamente dove cercare: classe SmtpMailer. Se qualcosa non andava con gli sconti, lo sviluppatore, ancora una volta, sa dove cercare: OrderService (o nel caso in cui hai abbracciato SRP a memoria, allora potrebbe essere DiscountService) codice classe.
Architettura guidata dagli eventi
Tuttavia, non mi piace ancora il metodo 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'invio di un'e-mail non fa proprio parte del flusso di creazione dell'ordine principale. Anche se l'app non riesce a inviare l'e-mail, l'ordine viene comunque creato correttamente. Inoltre, immagina una situazione in cui devi aggiungere una nuova opzione nell'area delle impostazioni dell'utente che consenta loro di annullare la ricezione di un'e-mail dopo aver effettuato un ordine con successo. Per incorporare questo nella nostra classe OrderService, dovremo introdurre una dipendenza, IUserParametersService. Aggiungi la localizzazione al mix e hai ancora un'altra dipendenza, ITranslator (per produrre messaggi di posta elettronica corretti nella lingua scelta dall'utente). Molte di queste azioni non sono necessarie, in particolare l'idea di aggiungere queste numerose dipendenze e finire con un costruttore che non si adatta allo schermo. Ho trovato un ottimo esempio di questo nella codebase di Magento (un popolare CMS di e-commerce scritto in PHP) in una classe che ha 32 dipendenze!
A volte è solo difficile capire come separare questa logica e la classe di Magento è probabilmente una vittima di uno di quei casi. Ecco perché mi piace il modo guidato dagli eventi:
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; } } }
Ogni volta che viene creato un ordine, invece di inviare un'e-mail direttamente dalla classe OrderService, viene creata una classe evento speciale OrderCreated e viene generato un evento. Da qualche parte nell'applicazione verranno configurati gestori di eventi. Uno di loro invierà un'e-mail al cliente.
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 è contrassegnata apposta come serializzabile. Possiamo gestire questo evento immediatamente o archiviarlo serializzato in una coda (Redis, ActiveMQ o altro) ed elaborarlo in un processo/thread separato da quello che gestisce le richieste web. In questo articolo l'autore spiega in dettaglio cos'è l'architettura event-driven (non prestare attenzione alla logica di business all'interno di OrderController).
Alcuni potrebbero obiettare che ora è difficile capire cosa sta succedendo quando si crea l'ordine. Ma questo non può essere più lontano dalla verità. Se ti senti così, sfrutta semplicemente la funzionalità del tuo IDE. Trovando tutti gli utilizzi della classe OrderCreated nell'IDE, possiamo vedere tutte le azioni associate all'evento.
Ma quando dovrei usare Dependency Injection e quando dovrei usare un approccio basato sugli eventi? Non è sempre facile rispondere a questa domanda, ma una semplice regola che può aiutarti è utilizzare l'inserimento delle dipendenze per tutte le attività principali all'interno dell'applicazione e l'approccio basato sugli eventi per tutte le azioni secondarie. Ad esempio, usa Dependecy Injection con cose come la creazione di un ordine all'interno della classe OrderService con IOrderRepository e delega l'invio di e-mail, qualcosa che non è una parte cruciale del flusso di creazione dell'ordine principale, a qualche gestore di eventi.
Conclusione
Abbiamo iniziato con un controller molto pesante, solo una classe, e siamo finiti con un'elaborata raccolta di classi. I vantaggi di queste modifiche sono abbastanza evidenti dagli esempi. Tuttavia, ci sono ancora molti modi per migliorare questi esempi. Ad esempio, il metodo OrderService.Create può essere spostato in una classe a sé stante: OrderCreator. Poiché la creazione dell'ordine è un'unità indipendente della logica aziendale che segue il principio di responsabilità unica, è naturale che abbia una propria classe con il proprio insieme di dipendenze. Allo stesso modo, la rimozione dell'ordine e l'annullamento dell'ordine possono essere implementate ciascuna nelle proprie classi.
Quando ho scritto codice altamente accoppiato, qualcosa di simile al primo esempio in questo articolo, qualsiasi piccola modifica ai requisiti potrebbe facilmente portare a molte modifiche in altre parti del codice. SRP aiuta gli sviluppatori a scrivere codice disaccoppiato, in cui ogni classe ha il proprio lavoro. Se le specifiche di questo lavoro cambiano, lo sviluppatore apporta modifiche solo a quella classe specifica. È meno probabile che la modifica rompa l'intera applicazione poiché le altre classi dovrebbero continuare a svolgere il proprio lavoro come prima, a meno che ovviamente non siano state interrotte in primo luogo.
Lo sviluppo del codice in anticipo utilizzando queste tecniche e seguendo il Principio di responsabilità unica può sembrare un compito arduo, ma gli sforzi saranno sicuramente ripagati man mano che il progetto cresce e lo sviluppo continua.