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

9

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

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

In genere finisco per utilizzare tre tipi di classi, ispirati ai rispettivi modelli di progettazione.

  1. Fabbriche - per costruire un oggetto da qualche risorsa.
  2. Mediatori - per utilizzare la fabbrica, eseguire la trasformazione, quindi utilizzare il comandante.
  3. Comandanti - per mettere quei dati da qualche altra parte.

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

Dove lotto è quando vengo a test, finisco per test strettamente accoppiati. Ad esempio;

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

Non posso testare l'uno senza l'altro. Potrei scrivere un codice "test" aggiuntivo per fare anche lettura / scrittura su disco, ma poi mi sto ripetendo.

Guardando a .Net, il File class ha un approccio diverso, unisce le responsabilità (della mia) fabbrica e il comandante insieme. Ha funzioni per creare, eliminare, esistenti e leggere 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 - accade all'implementazione originale, piuttosto che nei test.

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

    
posta James Wood 06.11.2017 - 22:13
fonte

7 risposte

5

Seguire il principio della Responsabilità Unica può essere stato quello che ti ha guidato qui, ma dove hai un nome diverso.

Segregazione di responsabilità della query di comando

Studia questo studio e penso che lo troverai seguendo uno schema familiare e che non sei il solo a chiedermi quanto lontano ci vorrà. Il test dell'acido è se seguire questo ti sta dando dei veri benefici o se è solo un mantra cieco che segui in modo da non dover pensare.

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

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

Una parola di cautela, seguire le abitudini che trovi nelle biblioteche non è ottimale. Le biblioteche hanno i loro bisogni e sono francamente vecchie. Quindi anche l'esempio migliore è solo l'esempio migliore di allora.

Questo non vuol dire che non ci sono esempi perfettamente validi che non seguono CQRS. Seguirlo sarà sempre un po 'un dolore. Non sempre vale la pena pagare. Ma se ne hai bisogno sarai contento che tu l'abbia usato.

Se lo usi, fai attenzione a questo avvertimento:

In particular CQRS should only be used on specific portions of a system (a BoundedContext in DDD lingo) and not the system as a whole. In this way of thinking, each Bounded Context needs its own decisions on how it should be modeled.

Martin Flowler: CQRS

    
risposta data 06.11.2017 - 22:42
fonte
3

Hai bisogno di una prospettiva più ampia per determinare se il codice è conforme al Principio della singola responsabilità. Non si può rispondere solo analizzando il codice stesso, bisogna considerare quali forze o attori potrebbero far cambiare i requisiti in futuro.

Consente di memorizzare i dati dell'applicazione in un file XML. Quali fattori potrebbero causare la modifica del codice relativo alla lettura o alla scrittura? Alcune possibilità:

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

In tutti questi casi, dovrai modificare entrambi la lettura e la logica di scrittura. In altre parole, sono non responsabilità separate.

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 genera un file diverso per essere elaborato da un terzo sistema. In questo caso la lettura e la scrittura sono responsabilità indipendenti e dovrebbero essere disaccoppiati.

In conclusione: non è possibile dire in generale se la lettura e la scrittura di file sono responsabilità separate, dipende dai ruoli nell'applicazione. Ma sulla base del tuo suggerimento sui test, suppongo che sia una singola responsabilità nel tuo caso.

    
risposta data 15.11.2017 - 08:36
fonte
2

In genere hai l'idea giusta.

Get data from somewhere. Transform that data. Put that data somewhere.

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

This leads to a proliferation of classes

Non penso che questo sia un problema IMO molte classi coesive e testabili sono migliori di quelle grandi e meno coese.

Where I struggle is when I come to testing, I end up will tightly coupled tests. I can't test one without the other.

Ogni pezzo dovrebbe essere testabile indipendentemente. Modellato sopra, puoi rappresentare la lettura / scrittura su 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 sul filesystem. Il resto della logica può essere scritto come trasformazioni. Ad esempio, se i file sono in formato JSON, puoi trasformare String s.

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

Quindi puoi trasformare in oggetti appropriati:

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

Ciascuno di questi è testabile indipendentemente. Puoi anche eseguire il test unitario program di sopra del mocking reader , transformer e writer .

    
risposta data 06.11.2017 - 23:02
fonte
2

I end up will tightly coupled tests. For example;

  • Factory - reads files from disk.
  • Commander - writes files to disk.

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

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

Perché hai bisogno dell'input a Commander per venire da un disco? Tutto ciò che importa è scrivere utilizzando un determinato input, quindi è possibile verificare che abbia scritto il file correttamente utilizzando ciò che è nel test .

La parte che stai testando per 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 funzionino quando accoppiati insieme va bene - cade abbastanza in linea con Integration Testing. La domanda qui è più che altro se l'unità può testarli separatamente.

    
risposta data 07.11.2017 - 09:36
fonte
1

Get data from somewhere. Transform that data. Put that data somewhere.

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

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

E c'è un evidente problema di coesione, lo hai menzionato tu stesso. Se si apportano alcune modifiche è una logica di input e si scrivono test su di esso, non si 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 rende le cose ancora peggiori. Lo so anch'io: 7 anni progetto con 100 anni-uomo dietro le spalle scritto completamente in questo stile. Scappartene se puoi.

E su tutta la questione SRP. Si tratta di coesione applicato al tuo spazio problematico, cioè dominio. Questo è il principio fondamentale alla base di SRP. Ciò si traduce in oggetti intelligenti e nell'implementazione delle proprie responsabilità. Nessuno li controlla, nessuno fornisce loro i dati. Combinano dati e comportamenti, esponendo solo questi ultimi. Quindi i tuoi oggetti combinano sia la validazione dei dati grezzi, sia la trasformazione dei dati (ad esempio, il comportamento) e la persistenza. Potrebbe sembrare il 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 un bel po 'di classi coesive che rappresentano alcune funzionalità. Tieni presente che la convalida generalmente riguarda oggetti valore - almeno nell'approccio DDD .

    
risposta data 08.11.2017 - 09:48
fonte
1

Where I struggle is when I come to testing, I end up will tightly coupled tests. For example;

  • Factory - reads files from disk.
  • Commander - writes files to disk.

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

Se la classe opera su dati provenienti da / entra in questi file, il file system diventa dettaglio dell'implementazione (I / O) e deve essere separato da esso. Queste classi (factory / commander / mediator) non dovrebbero essere a conoscenza del file system a meno che il loro unico lavoro sia quello di memorizzare / leggere i dati forniti. Le classi che trattano il 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 per lo più).

    
risposta data 08.11.2017 - 12:14
fonte
0

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

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

Potresti anche considerare l'astrazione della lettura / scrittura dei dati. Ora stai andando in un file system, ma potresti voler andare in un database o persino in un socket in futuro. La tua classe di mediatore non dovrebbe cambiare se la fonte / destinazione dei dati cambia.

    
risposta data 07.11.2017 - 11:13
fonte

Leggi altre domande sui tag