Mache ich meinen Unterricht zu detailliert? Wie soll das Prinzip der Einzelverantwortung angewendet werden?

9

Ich schreibe viel Code, der drei grundlegende Schritte umfasst.

  1. Holen Sie sich Daten von irgendwoher.
  2. Transformiere diese Daten.
  3. Legen Sie diese Daten irgendwo ab.

Normalerweise verwende ich drei Arten von Klassen - inspiriert von ihren jeweiligen Designmustern.

  1. Fabriken - um ein Objekt aus einer Ressource zu erstellen.
  2. Mediatoren - Um die Fabrik zu nutzen, führen Sie die Transformation durch und verwenden Sie dann den Kommandanten.
  3. Kommandanten - um diese Daten woanders abzulegen.

Meine Klassen sind in der Regel recht klein, oft eine einzelne (öffentliche) Methode, z. B. Daten abrufen, Daten transformieren, arbeiten, Daten speichern. Dies führt zu einer Zunahme der Klassen, funktioniert aber im Allgemeinen gut.

Wenn ich zum Testen komme, habe ich Probleme damit, eng gekoppelte Tests durchzuführen. Beispielsweise;

  • Factory - Liest Dateien von der Festplatte.
  • Commander - schreibt Dateien auf die Festplatte.

Ich kann nicht eins ohne das andere testen. Ich könnte zusätzlichen 'Test'-Code schreiben, um auch das Lesen / Schreiben der Festplatte durchzuführen, aber dann wiederhole ich mich.

Mit Blick auf .Net verfolgt die File- Klasse einen anderen Ansatz. Sie kombiniert die Verantwortlichkeiten (meiner) Fabrik und des Kommandanten miteinander. Es verfügt über Funktionen zum Erstellen, Löschen, Vorhandensein und Lesen an einem Ort.

Sollte ich versuchen, dem Beispiel von .Net zu folgen und - insbesondere im Umgang mit externen Ressourcen - meine Klassen zusammen zu kombinieren? Der Code ist immer noch gekoppelt, aber eher beabsichtigt - er geschieht eher bei der ursprünglichen Implementierung als bei den Tests.

Ist mein Problem hier, dass ich das Prinzip der Einzelverantwortung etwas übereifrig angewendet habe? Ich habe separate Klassen, die für Lesen und Schreiben verantwortlich sind. Wenn ich eine kombinierte Klasse haben könnte, die für den Umgang mit einer bestimmten Ressource verantwortlich ist, z. B. einer Systemfestplatte.

James Wood
quelle
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.- Beachten Sie, dass Sie "Verantwortung" mit "zu tun" verbinden. Eine Verantwortung ist eher ein "Problembereich". Die Dateiklasse ist dafür verantwortlich , Dateivorgänge auszuführen.
Robert Harvey
1
Mir scheint, du bist in guter Verfassung. Alles, was Sie brauchen, ist ein Testmediator (oder einer für jede Art von Konvertierung, wenn Ihnen das besser gefällt). Der Testmediator kann Dateien mithilfe der Dateiklasse von .net lesen, um ihre Richtigkeit zu überprüfen. Aus SOLID-Sicht gibt es damit kein Problem.
Martin Maat
1
Wie von @Robert Harvey erwähnt, hat SRP einen beschissenen Namen, weil es nicht wirklich um Verantwortlichkeiten geht. Es geht darum, "einen einzelnen kniffligen / schwierigen Problembereich zu kapseln und zu abstrahieren, der sich ändern könnte". Ich denke, STDACMC war zu lang. :-) Trotzdem denke ich, dass Ihre Aufteilung in drei Teile vernünftig erscheint.
user949300
1
Ein wichtiger Punkt in Ihrer FileBibliothek von C # ist, Filesoweit wir wissen, dass die Klasse nur eine Fassade sein kann, die alle Dateioperationen an einem einzigen Ort - in der Klasse - platziert, aber intern ähnliche Lese- / Schreibklassen wie Ihre verwendet enthalten tatsächlich die kompliziertere Logik für die Dateiverwaltung. Eine solche Klasse (the File) würde immer noch die SRP einhalten, da der Prozess der tatsächlichen Arbeit mit dem Dateisystem hinter einer anderen Ebene abstrahiert würde - höchstwahrscheinlich mit einer einheitlichen Schnittstelle. Nicht zu sagen, dass es der Fall ist, aber es könnte sein. :)
Andy

Antworten:

5

Das Prinzip der Einzelverantwortung zu befolgen mag Sie hierher geführt haben, aber wo Sie sich befinden, hat einen anderen Namen.

Verantwortlichkeitstrennung für Befehlsabfragen

Studieren Sie das und ich denke, Sie werden es nach einem vertrauten Muster finden und Sie sind nicht allein, wenn Sie sich fragen, wie weit Sie gehen sollen. Der Härtetest ist, wenn das Befolgen dieses Tests Ihnen echte Vorteile bringt oder wenn es nur ein blindes Mantra ist, dem Sie folgen, damit Sie nicht nachdenken müssen.

Sie haben Bedenken hinsichtlich des Testens geäußert. Ich denke nicht, dass das Befolgen von CQRS das Schreiben von testbarem Code ausschließt. Möglicherweise folgen Sie CQRS einfach so, dass Ihr Code nicht testbar ist.

Es ist hilfreich zu wissen, wie man Polymorphismus verwendet, um Quellcode-Abhängigkeiten zu invertieren, ohne den Kontrollfluss ändern zu müssen. Ich bin mir nicht sicher, wo Ihre Fähigkeiten beim Schreiben von Tests liegen.

Ein Wort der Vorsicht, den Gewohnheiten zu folgen, die Sie in Bibliotheken finden, ist nicht optimal. Bibliotheken haben ihre eigenen Bedürfnisse und sind ehrlich gesagt alt. Selbst das beste Beispiel ist also nur das beste Beispiel von damals.

Dies bedeutet nicht, dass es keine perfekt gültigen Beispiele gibt, die CQRS nicht folgen. Es wird immer ein bisschen schmerzhaft sein, ihm zu folgen. Es ist nicht immer eine, die es wert ist, bezahlt zu werden. Aber wenn Sie es brauchen, werden Sie froh sein, dass Sie es benutzt haben.

Wenn Sie es verwenden, beachten Sie dieses warnende Wort:

Insbesondere sollte CQRS nur für bestimmte Teile eines Systems (ein BoundedContext in DDD-Jargon) und nicht für das gesamte System verwendet werden. In dieser Denkweise benötigt jeder Bounded Context seine eigenen Entscheidungen darüber, wie er modelliert werden soll.

Martin Flowler: CQRS

candied_orange
quelle
Interessantes CQRS noch nicht gesehen. Der Code ist testbar. Hier geht es mehr darum, einen besseren Weg zu finden. Ich benutze Mocks und Abhängigkeitsinjektion, wenn ich kann (worauf Sie sich meiner Meinung nach beziehen).
James Wood
Als ich das erste Mal darüber gelesen habe, habe ich in meiner Anwendung etwas Ähnliches festgestellt: Flexible Suchvorgänge verarbeiten, mehrere Felder filtern / sortieren (Java / JPA) bereiten Kopfschmerzen und führen zu einer Menge Code, sofern Sie keine einfache Suchmaschine erstellen wird dieses Zeug für Sie erledigen (ich benutze rsql-jpa). Obwohl ich dasselbe Modell habe (z. B. dieselben JPA-Entitäten für beide), werden die Suchvorgänge in einem dedizierten generischen Dienst extrahiert, und die Modellebene muss dies nicht mehr verarbeiten.
Walfrat
3

Sie benötigen eine breitere Perspektive, um festzustellen, ob der Code dem Prinzip der Einzelverantwortung entspricht. Es kann nicht nur durch die Analyse des Codes selbst beantwortet werden. Sie müssen überlegen, welche Kräfte oder Akteure dazu führen könnten, dass sich die Anforderungen in Zukunft ändern.

Nehmen wir an, Sie speichern Anwendungsdaten in einer XML-Datei. Welche Faktoren können dazu führen, dass Sie den Code zum Lesen oder Schreiben ändern? Einige Möglichkeiten:

  • Das Anwendungsdatenmodell kann sich ändern, wenn der Anwendung neue Funktionen hinzugefügt werden.
  • Dem Modell können neue Arten von Daten hinzugefügt werden, z. B. Bilder
  • Das Speicherformat kann unabhängig von der Anwendungslogik geändert werden: Aus Gründen der Interoperabilität oder der Leistung von XML zu JSON oder zu einem Binärformat.

In all diesen Fällen müssen Sie sowohl die Lese- als auch die Schreiblogik ändern . Mit anderen Worten, sie sind keine getrennten Verantwortlichkeiten.

Stellen wir uns jedoch ein anderes Szenario vor: Ihre Anwendung ist Teil einer Datenverarbeitungspipeline. Es liest einige CSV-Dateien, die von einem separaten System generiert wurden, führt einige Analysen und Verarbeitungen durch und gibt dann eine andere Datei aus, die von einem dritten System verarbeitet werden soll. In diesem Fall sind Lesen und Schreiben unabhängige Aufgaben und sollten entkoppelt werden.

Fazit: Sie können im Allgemeinen nicht sagen, ob das Lesen und Schreiben von Dateien separate Verantwortlichkeiten sind. Dies hängt von den Rollen in der Anwendung ab. Aber basierend auf Ihrem Hinweis zum Testen würde ich vermuten, dass es in Ihrem Fall eine einzige Verantwortung ist.

JacquesB
quelle
2

Im Allgemeinen haben Sie die richtige Idee.

Holen Sie sich Daten von irgendwoher. Transformiere diese Daten. Legen Sie diese Daten irgendwo ab.

Klingt so, als hätten Sie drei Verantwortlichkeiten. IMO der "Mediator" kann zu viel tun. Ich denke, Sie sollten zunächst Ihre drei Verantwortlichkeiten modellieren:

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

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

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

Dann kann ein Programm ausgedrückt werden als:

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

Dies führt zu einer Verbreitung von Klassen

Ich denke nicht, dass dies ein Problem ist. IMO viele kleine zusammenhängende, testbare Klassen sind besser als große, weniger zusammenhängende Klassen.

Wenn ich zum Testen komme, habe ich Probleme damit, eng gekoppelte Tests durchzuführen. Ich kann nicht eins ohne das andere testen.

Jedes Stück sollte unabhängig testbar sein. Wie oben modelliert, können Sie das Lesen / Schreiben in eine Datei wie folgt darstellen:

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
}

Sie können Integrationstests schreiben, um diese Klassen zu testen und zu überprüfen, ob sie in das Dateisystem lesen und in dieses schreiben. Der Rest der Logik kann als Transformationen geschrieben werden. Wenn die Dateien beispielsweise im JSON-Format vorliegen, können Sie die Strings transformieren .

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

Dann können Sie in richtige Objekte verwandeln:

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

Jedes davon ist unabhängig testbar. Sie können auch Unit - Test programoben durch spöttisch reader, transformerund writer.

Samuel
quelle
Dort bin ich gerade. Ich kann jede Funktion einzeln testen, aber durch Testen werden sie gekoppelt. Zum Beispiel, damit FileWriter getestet werden kann, muss etwas anderes lesen, was geschrieben wurde. Die offensichtliche Lösung ist die Verwendung von FileReader. Fwiw, der Mediator macht oft etwas anderes wie das Anwenden von Geschäftslogik oder wird möglicherweise durch die Hauptfunktion der Hauptanwendung dargestellt.
James Wood
1
@ JamesWood Das ist häufig bei Integrationstests der Fall. Sie müssen nicht haben zu koppeln , aber die Klassen im Test. Sie können testen, FileWriterindem Sie direkt aus dem Dateisystem lesen, anstatt zu verwenden FileReader. Es liegt wirklich an Ihnen, was Ihre Ziele im Test sind. Wenn Sie verwenden FileReader, wird der Test abgebrochen , wenn einer FileReaderoder FileWritermehrere Fehler auftreten. Das Debuggen kann länger dauern.
Samuel
Siehe auch stackoverflow.com/questions/1087351/… es kann helfen, Ihre Tests schöner zu machen
Samuel
Das ist ziemlich genau dort, wo ich gerade bin - das ist nicht 100% wahr. Sie sagten, Sie verwenden das Mediator-Muster. Ich denke, das ist hier nicht nützlich; Dieses Muster wird verwendet, wenn viele verschiedene Objekte in einem sehr verwirrenden Fluss miteinander interagieren. Sie setzen dort einen Mediator ein, um alle Beziehungen zu erleichtern und an einem Ort umzusetzen. Dies scheint nicht Ihr Fall zu sein; Sie haben kleine Einheiten sehr gut definiert. Wie im obigen Kommentar von @Samuel sollten Sie auch eine Einheit testen und Ihre Behauptungen aufstellen, ohne andere Einheiten anzurufen
Emerson Cardoso,
@ EmersonCardoso; Ich habe das Szenario in meiner Frage etwas vereinfacht. Während einige meiner Mediatoren recht einfach sind, sind andere komplizierter und verwenden oft mehrere Fabriken / Kommandeure. Ich versuche, die Details eines einzelnen Szenarios zu vermeiden. Ich interessiere mich mehr für die übergeordnete Entwurfsarchitektur, die auf mehrere Szenarien angewendet werden kann.
James Wood
2

Ich werde am Ende eng gekoppelte Tests. Beispielsweise;

  • Factory - Liest Dateien von der Festplatte.
  • Commander - schreibt Dateien auf die Festplatte.

Der Fokus liegt hier also darauf, was sie miteinander verbindet . Übergeben Sie ein Objekt zwischen den beiden (z. B. a File?). Dann ist es die Datei, mit der sie gekoppelt sind, nicht miteinander.

Von dem, was Sie gesagt haben, haben Sie Ihre Klassen getrennt. Die Falle ist, dass Sie sie zusammen testen, weil es einfacher oder "sinnvoll" ist .

Warum muss die Eingabe Commandervon einer Festplatte stammen? Alles, was es interessiert, ist das Schreiben mit einer bestimmten Eingabe. Dann können Sie überprüfen, ob die Datei korrekt geschrieben wurde, indem Sie die im Test enthaltenen Informationen verwenden .

Der eigentliche Teil, auf den Sie testen, Factorylautet: "Wird diese Datei korrekt gelesen und das Richtige ausgegeben?" Verspotten Sie die Datei, bevor Sie sie im Test lesen .

Alternativ ist es in Ordnung zu testen, ob Factory und Commander miteinander verbunden sind - dies entspricht recht gut dem Integrationstest. Die Frage hier ist eher eine Frage, ob Sie sie separat testen können oder nicht.

Erdrik Ironrose
quelle
In diesem speziellen Beispiel verbindet sie die Ressource - z. B. die Systemfestplatte. Ansonsten gibt es keine Interaktion zwischen den beiden Klassen.
James Wood
1

Holen Sie sich Daten von irgendwoher. Transformiere diese Daten. Legen Sie diese Daten irgendwo ab.

Es ist ein typischer prozeduraler Ansatz, über den David Parnas 1972 schrieb. Sie konzentrieren sich darauf, wie die Dinge laufen. Sie nehmen die konkrete Lösung Ihres Problems als ein übergeordnetes Muster, das immer falsch ist.

Wenn Sie einen objektorientierten Ansatz verfolgen, konzentriere ich mich lieber auf Ihre Domain . Worum geht es? Was sind die Hauptaufgaben Ihres Systems? Was sind die Hauptkonzepte in der Sprache Ihrer Domain-Experten? Verstehen Sie also Ihre Domäne, zerlegen Sie sie, behandeln Sie übergeordnete Verantwortungsbereiche als Ihre Module , behandeln Sie Konzepte auf niedrigerer Ebene, die als Substantive dargestellt werden, als Ihre Objekte. Hier ist ein Beispiel, das ich zu einer kürzlich gestellten Frage gegeben habe. Es ist sehr relevant.

Und es gibt ein offensichtliches Problem mit dem Zusammenhalt, Sie haben es selbst erwähnt. Wenn Sie eine Eingabelogik ändern und Tests darauf schreiben, beweist dies in keiner Weise, dass Ihre Funktionalität funktioniert, da Sie möglicherweise vergessen, diese Daten an die nächste Ebene weiterzugeben. Sehen Sie, diese Schichten sind intrinsisch gekoppelt. Und eine künstliche Entkopplung macht die Sache noch schlimmer. Ich weiß das selbst: 7 Jahre Projekt mit 100 Mannjahren hinter meinen Schultern, komplett in diesem Stil geschrieben. Lauf davon, wenn du kannst.

Und im Großen und Ganzen SRP-Sache. Es geht um Zusammenhalt , der auf Ihren Problembereich angewendet wird, dh auf Ihre Domäne. Das ist das Grundprinzip von SRP. Dies führt dazu, dass Objekte intelligent sind und ihre Verantwortung für sich selbst übernehmen. Niemand kontrolliert sie, niemand liefert ihnen Daten. Sie kombinieren Daten und Verhalten und legen nur letztere offen. Ihre Objekte kombinieren also sowohl Rohdatenvalidierung, Datentransformation (dh Verhalten) als auch Persistenz. Es könnte wie folgt aussehen:

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'])
        )
    )
;

Infolgedessen gibt es einige zusammenhängende Klassen, die einige Funktionen darstellen. Beachten Sie, dass die Validierung normalerweise für Wertobjekte gilt - zumindest beim DDD- Ansatz.

Vadim Samokhin
quelle
1

Wenn ich zum Testen komme, habe ich Probleme damit, eng gekoppelte Tests durchzuführen. Beispielsweise;

  • Factory - Liest Dateien von der Festplatte.
  • Commander - schreibt Dateien auf die Festplatte.

Achten Sie bei der Arbeit mit dem Dateisystem auf undichte Abstraktionen. Ich habe gesehen, dass diese viel zu oft vernachlässigt wurden und die von Ihnen beschriebenen Symptome aufweisen.

Wenn die Klasse Daten verarbeitet, die aus diesen Dateien stammen / in diese Dateien gelangen, wird das Dateisystem zum Implementierungsdetail (I / O) und sollte von diesen getrennt werden. Diese Klassen (Factory / Commander / Mediator) sollten das Dateisystem nicht kennen, es sei denn, ihre einzige Aufgabe besteht darin, die bereitgestellten Daten zu speichern / lesen. Klassen, die sich mit Dateisystemen befassen, sollten kontextspezifische Parameter wie Pfade (möglicherweise über den Konstruktor übergeben) enthalten, damit die Schnittstelle ihre Natur nicht preisgibt (das Wort "Datei" im Schnittstellennamen ist meistens ein Geruch).

schaudern
quelle
"Diese Klassen (Factory / Commander / Mediator) sollten das Dateisystem nicht kennen, es sei denn, ihre einzige Aufgabe besteht darin, die bereitgestellten Daten zu speichern / lesen." In diesem Beispiel ist das alles, was sie tun.
James Wood
0

Meiner Meinung nach klingt es so, als ob Sie den richtigen Weg eingeschlagen haben, aber nicht weit genug gegangen sind. Ich denke, es ist richtig, die Funktionalität in verschiedene Klassen aufzuteilen, die eines tun und es gut machen.

Um noch einen Schritt weiter zu gehen, sollten Sie Schnittstellen für Ihre Factory-, Mediator- und Commander-Klassen erstellen. Dann können Sie verspottete Versionen dieser Klassen verwenden, wenn Sie Ihre Komponententests für die konkreten Implementierungen der anderen schreiben. Mit den Mocks können Sie überprüfen, ob Methoden in der richtigen Reihenfolge und mit den richtigen Parametern aufgerufen werden und ob sich der zu testende Code mit unterschiedlichen Rückgabewerten ordnungsgemäß verhält.

Sie können auch das Lesen / Schreiben der Daten abstrahieren. Sie gehen jetzt zu einem Dateisystem, möchten aber möglicherweise irgendwann in der Zukunft zu einer Datenbank oder sogar zu einem Socket gehen. Ihre Mediatorklasse sollte sich nicht ändern müssen, wenn sich die Quelle / das Ziel der Daten ändert.

Richard Wells
quelle
1
YAGNI ist etwas, über das Sie nachdenken sollten.
Whatsisname