Sto rendendo le mie lezioni troppo granulari? Come dovrebbe essere applicato il principio di responsabilità singola?


9

Scrivo un sacco di codice che prevede tre passaggi di base.

  1. Ottieni dati da qualche parte.
  2. Trasforma quei dati.
  3. Metti quei dati da qualche parte.

In genere finisco per usare tre tipi di lezioni, ispirate ai rispettivi modelli di progettazione.

  1. Fabbriche - per costruire un oggetto da una risorsa.
  2. Mediatori: per usare la fabbrica, eseguire la trasformazione, quindi usare il comandante.
  3. Comandanti - per mettere quei dati altrove.

Le mie lezioni tendono a essere piuttosto piccole, spesso un singolo metodo (pubblico), ad esempio ottenere dati, trasformare dati, lavorare, salvare dati. Questo porta a una proliferazione di classi, ma in generale funziona bene.

Il punto in cui faccio fatica quando vengo ai test, finirò per fare test strettamente collegati. Per esempio;

  • Factory: legge i file dal disco.
  • Commander: scrive i file sul disco.

Non posso testarne uno senza l'altro. Potrei scrivere un ulteriore codice 'test' per fare anche la lettura / scrittura del disco, ma poi mi sto ripetendo.

Guardando .Net, la classe File adotta un approccio diverso, combina le responsabilità (della mia) fabbrica e comandante insieme. Ha funzioni per Crea, Elimina, Esiste e Leggi tutto in un unico posto.

Dovrei cercare di seguire l'esempio di .Net e combinare, in particolare quando si tratta di risorse esterne, le mie lezioni insieme? Il codice è ancora accoppiato, ma è più intenzionale: succede nell'implementazione originale, piuttosto che nei test.

Il mio problema qui è che ho applicato il principio della responsabilità singola in modo un po 'troppo zelante? Ho classi separate responsabili di lettura e scrittura. Quando potrei avere una classe combinata che è responsabile della gestione di una particolare risorsa, ad esempio il disco di sistema.



6
Looking at .Net, the File class takes a different approach, it combines the responsibilities (of my) factory and commander together. It has functions for Create, Delete, Exists, and Read all in one place.- Nota che stai unendo "responsabilità" con "cosa da fare". Una responsabilità è più simile a una "area di interesse". La responsabilità della classe File sta eseguendo operazioni sui file.
Robert Harvey,

1
Mi sembra che tu sia in buona forma. Tutto ciò che serve è un mediatore di prova (o uno per ogni tipo di conversione se ti piace di più). Il mediatore del test può leggere i file per verificarne la correttezza, utilizzando la classe File .net. Non vi è alcun problema da un punto di vista SOLIDO.
Martin Maat,

1
Come menzionato da @Robert Harvey, SRP ha un nome schifoso perché non si tratta davvero di responsabilità. Si tratta di "incapsulare e astrarre una singola area delicata / difficile che potrebbe cambiare". Immagino che STDACMC fosse troppo lungo. :-) Detto questo, penso che la tua divisione in tre parti sembra ragionevole.
user949300,

1
Un punto importante nella tua Filelibreria di C # è che, per quanto ne sappiamo, la Fileclasse potrebbe essere solo una facciata, mettendo tutte le operazioni sui file in un unico posto - nella classe, ma potrebbe essere internamente utilizzare una classe di lettura / scrittura simile alla tua che in realtà contiene la logica più complicata per la gestione dei file. Tale classe (la File) aderirebbe comunque all'SRP, perché il processo di funzionamento effettivo con il filesystem verrebbe astratto dietro un altro livello, molto probabilmente con un'interfaccia unificante. Non dire che è il caso, ma potrebbe essere. :)
Andy,

Risposte:


5

Seguire il principio della responsabilità singola potrebbe essere stato ciò che ti ha guidato qui, ma dove ti trovi ha un nome diverso.

Segregazione responsabilità responsabilità query comandi

Vai a studiarlo e penso che lo troverai seguendo uno schema familiare e che non sei il solo a chiederti fino a che punto prendere questo. Il test dell'acido è se seguire questo ti sta dando benefici reali o se è solo un mantra cieco che segui, quindi non devi pensare.

Hai espresso preoccupazione per i test. Non credo che seguire CQRS precluda la scrittura di codice testabile. Potresti semplicemente seguire CQRS in modo da rendere il tuo codice non testabile.

Aiuta a sapere come usare il polimorfismo per invertire le dipendenze del codice sorgente senza dover cambiare il flusso di controllo. Non sono sicuro di dove siano le tue abilità nello scrivere test.

Un avvertimento, seguendo le abitudini che trovi nelle biblioteche non è ottimale. Le biblioteche hanno i loro bisogni e sono francamente vecchie. Quindi anche il miglior esempio è solo il miglior esempio di allora.

Questo non vuol dire che non ci sono esempi perfettamente validi che non seguono CQRS. Seguire sarà sempre un po 'un dolore. Non è sempre uno che vale la pena pagare. Ma se ne hai bisogno, sarai felice di averlo usato.

Se lo usi, fai attenzione a questo avvertimento:

In particolare, CQRS dovrebbe essere usato solo su parti specifiche di un sistema (un BoundedContext nel linguaggio DDD) e non sul sistema nel suo insieme. In questo modo di pensare, ogni Contesto Limitato ha bisogno delle proprie decisioni su come dovrebbe essere modellato.

Martin Flowler: CQRS


CQRS interessante non visto prima. Il codice è testabile, si tratta più di cercare di trovare un modo migliore. Uso derisioni e iniezione di dipendenza quando posso (che penso sia ciò a cui ti riferisci).
James Wood,

La prima volta che ho letto questo, ho identificato qualcosa di simile attraverso la mia applicazione: gestire ricerche flessibili, campi multipli filtrabili / ordinabili, (Java / JPA) è un mal di testa e porta a tonnellate di codice boilerplate, a meno che non si crei un motore di ricerca di base che gestirò queste cose per te (io uso rsql-jpa). Sebbene io abbia lo stesso modello (ad esempio le stesse entità JPA per entrambi), le ricerche vengono estratte su un servizio generico dedicato e il livello del modello non deve più gestirlo.
Walfrat,

3

È necessaria una prospettiva più ampia per determinare se il codice è conforme al principio della responsabilità singola. Non è possibile rispondere semplicemente analizzando il codice stesso, è necessario considerare quali forze o attori potrebbero far cambiare i requisiti in futuro.

Supponiamo che tu memorizzi i dati dell'applicazione in un file XML. Quali fattori potrebbero farti cambiare il codice relativo alla lettura o alla scrittura? Alcune possibilità:

  • Il modello di dati dell'applicazione potrebbe cambiare quando vengono aggiunte nuove funzionalità all'applicazione.
  • Nuovi tipi di dati, ad esempio immagini, potrebbero essere aggiunti al modello
  • Il formato di archiviazione potrebbe cambiare indipendentemente dalla logica dell'applicazione: dire da XML a JSON o in un formato binario, a causa di problemi di interoperabilità o prestazioni.

In tutti questi casi, dovrai cambiare sia la logica di lettura che quella di scrittura. In altre parole, essi sono non responsabilità distinte.

Ma immaginiamo uno scenario diverso: l'applicazione fa parte di una pipeline di elaborazione dati. Legge alcuni file CSV generati da un sistema separato, esegue alcune analisi ed elaborazioni e quindi emette un file diverso da elaborare da un terzo sistema. In questo caso la lettura e la scrittura sono responsabilità indipendenti e dovrebbero essere disaccoppiate.

In conclusione: in generale non si può dire se la lettura e la scrittura di file sono responsabilità separate, dipende dai ruoli nell'applicazione. Ma in base al tuo suggerimento sui test, immagino che sia una singola responsabilità nel tuo caso.


2

Generalmente hai l'idea giusta.

Ottieni dati da qualche parte. Trasforma quei dati. Metti quei dati da qualche parte.

Sembra che tu abbia tre responsabilità. IMO il "Mediatore" potrebbe fare molto. Penso che dovresti iniziare modellando le tue tre responsabilità:

interface Reader[T] {
    def read(): T
}

interface Transformer[T, U] {
    def transform(t: T): U
}

interface Writer[T] {
    def write(t: T): void
}

Quindi un programma può essere espresso come:

def program[T, U](reader: Reader[T], 
                  transformer: Transformer[T, U], 
                  writer: Writer[U]): void =
    writer.write(transformer.transform(reader.read()))

Ciò porta a una proliferazione di classi

Non penso che questo sia un problema. Molte classi IMO di piccole dimensioni coesive e testabili sono migliori delle classi grandi e meno coesive.

Il punto in cui faccio fatica quando vengo ai test, finirò per fare test strettamente collegati. Non posso testarne uno senza l'altro.

Ogni pezzo dovrebbe essere testato indipendentemente. Modellato in precedenza, è possibile rappresentare la lettura / scrittura in un file come:

class FileReader(fileName: String) implements Reader[String] {
    override read(): String = // read file into string
}

class FileWriter(fileName: String) implements Writer[String] {
    override write(str: String) = // write str to file
}

È possibile scrivere test di integrazione per testare queste classi per verificare che leggano e scrivano nel filesystem. Il resto della logica può essere scritto come trasformazioni. Ad esempio, se i file sono in formato JSON, è possibile trasformare la Strings.

class JsonParser implements Transformer[String, Json] {
    override transform(str: String): Json = // parse as json
}

Quindi puoi trasformarti in oggetti corretti:

class FooParser implements Transformer[Json, Foo] {
    override transform(json: Json): Foo = // ...
}

Ognuno di questi è indipendentemente testabile. È inoltre possibile unit test programsopra deridendo reader, transformere writer.


È praticamente dove sono adesso. Posso testare ciascuna funzione singolarmente, tuttavia testandole diventano accoppiate. Ad esempio, per FileWriter da testare, quindi qualcos'altro deve leggere ciò che è stato scritto, la soluzione ovvia sta usando FileReader. In seguito, il mediatore spesso fa qualcos'altro come applicare la logica aziendale o è forse rappresentato dalla funzione principale dell'applicazione di base.
James Wood,

1
@JamesWood è spesso il caso dei test di integrazione. Tuttavia, non è necessario abbinare le classi in prova. Puoi provare FileWriterleggendo direttamente dal filesystem invece di usarlo FileReader. Dipende da te quali sono i tuoi obiettivi in ​​prova. Se lo usi FileReader, il test si interromperà se uno FileReadero FileWriterè rotto, il che potrebbe richiedere più tempo per il debug.
Samuel,

Vedi anche stackoverflow.com/questions/1087351/… che può aiutare a rendere più piacevoli i tuoi test
Samuel,

È praticamente dove sono adesso - non è vero al 100%. Hai detto che stai usando il modello del mediatore. Penso che questo non sia utile qui; questo modello viene utilizzato quando si hanno molti oggetti diversi che interagiscono tra loro in un flusso molto confuso; ci metti un mediatore per facilitare tutte le relazioni e implementarle in un unico posto. Questo non sembra essere il tuo caso; hai piccole unità molto ben definite. Inoltre, come il commento sopra di @ Samuel, dovresti testare un'unità e fare le tue affermazioni senza chiamare altre unità
Emerson Cardoso,

@EmersonCardoso; Ho in qualche modo semplificato lo scenario nella mia domanda. Mentre alcuni dei miei mediatori sono piuttosto semplici, altri sono più complicati e spesso usano più fabbriche / comandanti. Sto cercando di evitare i dettagli di un singolo scenario, sono più interessato all'architettura di progettazione di livello superiore che può essere applicata a più scenari.
James Wood,

2

Finirò test strettamente accoppiati. Per esempio;

  • Factory: legge i file dal disco.
  • Commander: scrive i file sul disco.

Quindi l'attenzione qui è su cosa li sta accoppiando insieme . Passi un oggetto tra i due (come un File?) Quindi è il File a cui sono accoppiati, non l'uno con l'altro.

Da quello che hai detto di aver separato le tue lezioni. La trappola è che li stai testando insieme perché è più facile o "ha senso" .

Perché è necessario che l'input Commanderprovenga da un disco? Tutto quello che gli importa è scrivere usando un certo input, quindi puoi verificarlo correttamente usando il file presente nel test .

La parte effettiva per cui stai testando Factoryè "leggerà questo file correttamente e produrrà la cosa giusta"? Quindi prendi in giro il file prima di leggerlo nel test .

In alternativa, testare che Factory e Commander funzionano quando accoppiati va bene - si allinea in modo abbastanza soddisfacente con i test di integrazione. La domanda qui è più una questione se l'unità può o meno testarli separatamente.


In quel particolare esempio, la cosa che li unisce è la risorsa, ad esempio il disco di sistema. Altrimenti non c'è interazione tra le due classi.
James Wood,

1

Ottieni dati da qualche parte. Trasforma quei dati. Metti quei dati da qualche parte.

È un tipico approccio procedurale, di cui David Parnas ha scritto nel lontano 1972. Ti concentri su come vanno le cose. Prendi la soluzione concreta del tuo problema come un modello di livello superiore, che è sempre sbagliato.

Se persegui un approccio orientato agli oggetti, preferirei concentrarmi sul tuo dominio . Cos'è tutto questo? Quali sono le principali responsabilità del tuo sistema? Quali sono i concetti principali presenti nella lingua dei tuoi esperti di dominio? Quindi, comprendi il tuo dominio, scomponilo, tratta le aree di responsabilità di livello superiore come i tuoi moduli , tratta i concetti di livello inferiore rappresentati come sostantivi come i tuoi oggetti. Ecco un esempio che ho fornito a una domanda recente, è molto pertinente.

E c'è un problema evidente con la coesione, l'hai menzionato da solo. Se si apportano alcune modifiche è una logica di input e si scrivono test su di essa, ciò non dimostra in alcun modo che la funzionalità funzioni, poiché è possibile dimenticare di passare tali dati al livello successivo. Vedi, questi strati sono intrinsecamente accoppiati. E un disaccoppiamento artificiale peggiora le cose. Lo so anch'io: un progetto di 7 anni con 100 anni-uomo alle spalle scritto completamente in questo stile. Scappa da esso se puoi.

E nel complesso la cosa SRP. Riguarda la coesione applicata al tuo spazio problematico, ovvero al dominio. Questo è il principio fondamentale dietro SRP. Ciò si traduce in oggetti intelligenti e nell'implementazione delle loro responsabilità per se stessi. Nessuno li controlla, nessuno fornisce loro i dati. Combinano dati e comportamento, esponendo solo quest'ultimo. Quindi i tuoi oggetti combinano sia la convalida dei dati grezzi, la trasformazione dei dati (cioè il comportamento) e la persistenza. Potrebbe essere simile al seguente:

class FinanceTransaction
{
    private $id;
    private $storage;

    public function __construct(UUID $id, DataStorage $storage)
    {
        $this->id = $id;
        $this->storage = $storage;
    }

    public function perform(
        Order $order,
        Customer $customer,
        Merchant $merchant
    )
    {
        if ($order->isExpired()) {
            throw new Exception('Order expired');
        }

        if ($customer->canNotPurchase($order)) {
            throw new Exception('It is not legal to purchase this kind of stuff by this customer');
        }

        $this->storage->save($this->id, $order, $customer, $merchant);
    }
}

(new FinanceTransaction())
    ->perform(
        new Order(
            new Product(
                $_POST['product_id']
            ),
            new Card(
                new CardNumber(
                    $_POST['card_number'],
                    $_POST['cvv'],
                    $_POST['expires_at']
                )
            )
        ),
        new Customer(
            new Name(
                $_POST['customer_name']
            ),
            new Age(
                $_POST['age']
            )
        ),
        new Merchant(
            new MerchantId($_POST['merchant_id'])
        )
    )
;

Di conseguenza ci sono alcune classi coerenti che rappresentano alcune funzionalità. Si noti che la convalida in genere va a oggetti valore - almeno nell'approccio DDD .


1

Il punto in cui faccio fatica quando vengo ai test, finirò per fare test strettamente collegati. Per esempio;

  • Factory: legge i file dal disco.
  • Commander: scrive i file sul disco.

Fai attenzione alle astrazioni che perdono quando lavori con il file system: l'ho visto trascurato troppo spesso e presenta i sintomi che hai descritto.

Se la classe opera su dati che provengono / vanno in questi file, il file system diventa dettaglio di implementazione (I / O) e deve essere separato da esso. Queste classi (fabbrica / comandante / mediatore) non dovrebbero essere a conoscenza del file system a meno che il loro unico compito sia quello di archiviare / leggere i dati forniti. Le classi che si occupano del file system dovrebbero incapsulare parametri specifici del contesto come i percorsi (potrebbero essere passati attraverso il costruttore), quindi l'interfaccia non ha rivelato la sua natura (la parola "File" nel nome dell'interfaccia è un odore il più delle volte).


"Queste classi (fabbrica / comandante / mediatore) non dovrebbero essere a conoscenza del file system a meno che il loro unico compito sia quello di archiviare / leggere i dati forniti." In questo esempio particolare, è tutto ciò che stanno facendo.
James Wood,

0

Secondo me sembra che tu abbia iniziato a percorrere la strada giusta ma non l'hai portato abbastanza lontano. Penso che rompere la funzionalità in diverse classi che fanno una cosa e la fanno bene è corretto.

Per fare un ulteriore passo in avanti devi creare interfacce per le tue classi Factory, Mediator e Commander. Quindi puoi usare versioni derise di quelle classi quando scrivi i tuoi test unitari per le implementazioni concrete delle altre. Con i mock puoi confermare che i metodi sono chiamati nell'ordine corretto e con i parametri corretti e che il codice in prova si comporta correttamente con valori di ritorno diversi.

Potresti anche considerare l'astrazione della lettura / scrittura dei dati. Stai andando a un file system ora, ma potresti voler andare a un database o addirittura a un socket in futuro. La tua classe di mediatore non dovrebbe cambiare se cambia l'origine / destinazione dei dati.


1
YAGNI è qualcosa a cui dovresti pensare.
whatsisname
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.