Suis-je en train de rendre mes cours trop précis? Comment appliquer le principe de responsabilité unique?


9

J'écris beaucoup de code qui implique trois étapes de base.

  1. Obtenez des données quelque part.
  2. Transformez ces données.
  3. Mettez ces données quelque part.

Je finis généralement par utiliser trois types de classes - inspirées de leurs modèles de conception respectifs.

  1. Usines - pour construire un objet à partir d'une ressource.
  2. Médiateurs - pour utiliser l'usine, effectuer la transformation, puis utiliser le commandant.
  3. Commandants - pour mettre ces données ailleurs.

Mes classes ont tendance à être assez petites, souvent une seule méthode (publique), par exemple obtenir des données, transformer des données, travailler, enregistrer des données. Cela conduit à une prolifération de classes, mais fonctionne généralement bien.

Là où je me bats, c'est quand je viens aux tests, je me retrouve avec des tests étroitement couplés. Par exemple;

  • Usine - lit les fichiers du disque.
  • Commander - écrit des fichiers sur le disque.

Je ne peux pas tester l'un sans l'autre. Je pourrais écrire du code «test» supplémentaire pour lire / écrire sur le disque également, mais je me répète.

En regardant .Net, la classe File adopte une approche différente, elle combine les responsabilités (de mon) usine et commandant ensemble. Il a des fonctions pour créer, supprimer, existe et tout lire en un seul endroit.

Dois-je chercher à suivre l'exemple de .Net et combiner - en particulier lorsqu'il s'agit de ressources externes - mes classes ensemble? Le code est toujours couplé, mais il est plus intentionnel - il se produit lors de l'implémentation d'origine, plutôt que dans les tests.

Est-ce que mon problème ici est que j'ai appliqué le principe de responsabilité unique de manière un peu trop zélée? J'ai des classes distinctes responsables de la lecture et de l'écriture. Quand je pourrais avoir une classe combinée chargée de gérer une ressource particulière, par exemple le disque système.



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.- Notez que vous confondez «responsabilité» avec «chose à faire». Une responsabilité ressemble plus à un «domaine de préoccupation». La responsabilité de la classe File consiste à effectuer des opérations sur les fichiers.
Robert Harvey

1
Il me semble que tu es en forme. Tout ce dont vous avez besoin est un médiateur de test (ou un pour chaque type de conversion si vous préférez cela). Le médiateur de test peut lire les fichiers pour vérifier leur exactitude, en utilisant la classe File de .net. Il n'y a aucun problème avec cela d'un point de vue SOLIDE.
Martin Maat

1
Comme mentionné par @Robert Harvey, SRP a un nom de merde parce qu'il ne s'agit pas vraiment de responsabilités. Il s'agit "d'encapsuler et d'abstraire un seul sujet de préoccupation délicat / difficile qui pourrait changer". Je suppose que STDACMC était trop long. :-) Cela dit, je pense que votre division en trois parties semble raisonnable.
user949300

1
Un point important dans votre Filebibliothèque de C # est, pour tout ce que nous savons, la Fileclasse pourrait simplement être une façade, mettant toutes les opérations sur les fichiers en un seul endroit - dans la classe, mais pourrait utiliser en interne une classe de lecture / écriture similaire à la vôtre qui contiennent en fait la logique la plus compliquée pour la gestion des fichiers. Une telle classe (la File) adhérerait toujours au SRP, parce que le processus de travail avec le système de fichiers serait abstrait derrière une autre couche - très probablement avec une interface unificatrice. Je ne dis pas que c'est le cas, mais ça pourrait l'être. :)
Andy

Réponses:


5

Suivre le principe de la responsabilité unique a peut-être été ce qui vous a guidé ici mais où vous êtes a un nom différent.

Séparation des responsabilités de requête de commande

Allez étudier cela et je pense que vous le trouverez suivant un schéma familier et que vous n'êtes pas seul à vous demander jusqu'où aller. Le test d'acide est si le fait de suivre cela vous apporte de réels avantages ou si c'est juste un mantra aveugle que vous suivez afin que vous n'ayez pas à réfléchir.

Vous avez exprimé votre inquiétude concernant les tests. Je ne pense pas que suivre CQRS empêche d'écrire du code testable. Vous pouvez simplement suivre CQRS d'une manière qui rend votre code non testable.

Il permet de savoir comment utiliser le polymorphisme pour inverser les dépendances du code source sans avoir besoin de modifier le flux de contrôle. Je ne sais pas vraiment où se situe votre compétence en matière de rédaction de tests.

Un mot d'avertissement, suivre les habitudes que vous trouvez dans les bibliothèques n'est pas optimal. Les bibliothèques ont leurs propres besoins et sont franchement vieilles. Ainsi, même le meilleur exemple n'est que le meilleur exemple de l'époque.

Cela ne veut pas dire qu'il n'y a pas d'exemples parfaitement valides qui ne suivent pas le CQRS. Le suivre sera toujours un peu pénible. Ce n'est pas toujours une valeur à payer. Mais si vous en avez besoin, vous serez heureux de l'avoir utilisé.

Si vous l'utilisez, tenez compte de ce mot d'avertissement:

En particulier, CQRS ne doit être utilisé que sur des parties spécifiques d'un système (un BoundedContext dans le jargon DDD) et non sur le système dans son ensemble. Dans cette façon de penser, chaque contexte délimité a besoin de ses propres décisions sur la façon dont il doit être modélisé.

Martin Flowler: CQRS


Intéressant pas vu CQRS avant. Le code est testable, il s'agit plus d'essayer de trouver un meilleur moyen. J'utilise des simulacres et l'injection de dépendances quand je le peux (ce à quoi je pense que vous faites référence).
James Wood

La première fois que j'ai lu à ce sujet, j'ai identifié quelque chose de similaire dans mon application: gérer des recherches flexibles, plusieurs champs filtrables / triables, (Java / JPA) est un casse-tête et conduit à des tonnes de code standard, sauf si vous créez un moteur de recherche de base qui va gérer ce genre de choses pour vous (j'utilise rsql-jpa). Bien que j'aie le même modèle (disons les mêmes entités JPA pour les deux), les recherches sont extraites sur un service générique dédié et la couche modèle n'a plus à le gérer.
Walfrat

3

Vous avez besoin d'une perspective plus large pour déterminer si le code est conforme au principe de responsabilité unique. Il ne peut être répondu simplement en analysant le code lui-même, vous devez considérer quelles forces ou quels acteurs pourraient faire évoluer les exigences à l'avenir.

Disons que vous stockez les données d'application dans un fichier XML. Quels facteurs pourraient vous amener à modifier le code lié à la lecture ou à l'écriture? Quelques possibilités:

  • Le modèle de données de l'application peut changer lorsque de nouvelles fonctionnalités sont ajoutées à l'application.
  • De nouveaux types de données - par exemple des images - pourraient être ajoutés au modèle
  • Le format de stockage peut être modifié indépendamment de la logique d'application: dites de XML à JSON ou à un format binaire, en raison de problèmes d'interopérabilité ou de performances.

Dans tous ces cas, vous devrez le changer à la fois la lecture et la logique d'écriture. En d'autres termes, ce ne sont pas des responsabilités séparées.

Mais imaginons un scénario différent: votre application fait partie d'un pipeline de traitement de données. Il lit certains fichiers CSV générés par un système distinct, effectue une analyse et un traitement, puis génère un fichier différent à traiter par un troisième système. Dans ce cas, la lecture et l'écriture sont des responsabilités indépendantes et doivent être découplées.

Conclusion: vous ne pouvez généralement pas dire si la lecture et l'écriture de fichiers sont des responsabilités distinctes, cela dépend des rôles dans l'application. Mais sur la base de votre conseil sur les tests, je suppose que c'est une seule responsabilité dans votre cas.


2

En général, vous avez la bonne idée.

Obtenez des données quelque part. Transformez ces données. Mettez ces données quelque part.

On dirait que vous avez trois responsabilités. L'OMI, le «médiateur», fait peut-être trop. Je pense que vous devriez commencer par modéliser vos trois responsabilités:

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

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

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

Ensuite, un programme peut être exprimé comme:

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

Cela conduit à une prolifération de classes

Je ne pense pas que ce soit un problème. Beaucoup de petites classes cohésives et testables de l'OMI sont meilleures que les grandes classes moins cohésives.

Là où je me bats, c'est quand je viens aux tests, je me retrouve avec des tests étroitement couplés. Je ne peux pas tester l'un sans l'autre.

Chaque pièce doit pouvoir être testée indépendamment. Modélisé ci-dessus, vous pouvez représenter la lecture / écriture dans un fichier comme:

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
}

Vous pouvez écrire des tests d'intégration pour tester ces classes afin de vérifier qu'elles lisent et écrivent dans le système de fichiers. Le reste de la logique peut être écrit sous forme de transformations. Par exemple, si les fichiers sont au format JSON, vous pouvez transformer le Strings.

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

Ensuite, vous pouvez vous transformer en objets appropriés:

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

Chacun de ces éléments peut être testé indépendamment. Vous pouvez également tester l' unité programci - dessus en se moquant reader, transformeret writer.


C'est à peu près où je suis en ce moment. Je peux tester chaque fonction individuellement, mais en les testant, elles se couplent. Par exemple, pour FileWriter à tester, puis quelque chose d'autre doit lire ce qui a été écrit, la solution évidente est d'utiliser FileReader. Fwiw, le médiateur fait souvent autre chose comme appliquer la logique métier ou est peut-être représenté par la fonction principale de l'application de base.
James Wood

1
@JamesWood c'est souvent le cas avec les tests d'intégration. Cependant, vous n'avez pas besoin de coupler les classes dans le test. Vous pouvez tester FileWriteren lisant directement à partir du système de fichiers au lieu d'utiliser FileReader. C'est vraiment à vous de déterminer quels sont vos objectifs. Si vous utilisez FileReader, le test se cassera si l'un FileReaderou l' autre FileWriterest rompu - ce qui peut prendre plus de temps pour déboguer.
Samuel

Voir également stackoverflow.com/questions/1087351/… cela peut aider à rendre vos tests plus agréables
Samuel

C'est à peu près là où je suis en ce moment - ce n'est pas vrai à 100%. Vous avez dit que vous utilisez le modèle Mediator. Je pense que ce n'est pas utile ici; ce modèle est utilisé lorsque de nombreux objets différents interagissent les uns avec les autres dans un flux très confus; vous y mettez un médiateur afin de faciliter toutes les relations et de les mettre en place en un seul endroit. Cela ne semble pas être votre cas; vous avez de petites unités très bien définies. En outre, comme le commentaire ci-dessus de @Samuel, vous devriez tester une unité et faire vos affirmations sans appeler d'autres unités
Emerson Cardoso

@EmersonCardoso; J'ai quelque peu simplifié le scénario dans ma question. Alors que certains de mes médiateurs sont assez simples, d'autres sont plus compliqués et utilisent souvent plusieurs usines / commandants. J'essaie d'éviter le détail d'un seul scénario, je suis plus intéressé par l'architecture de conception de niveau supérieur qui peut être appliquée à plusieurs scénarios.
James Wood

2

Je finis par des tests étroitement couplés. Par exemple;

  • Usine - lit les fichiers du disque.
  • Commander - écrit des fichiers sur le disque.

Donc, l'accent est mis ici sur ce qui les relie . Passez-vous un objet entre les deux (comme un File?) Ensuite, c'est le fichier avec lequel ils sont couplés, pas les uns aux autres.

De ce que vous avez dit, vous avez séparé vos classes. Le piège est que vous les testez ensemble parce que c'est plus facile ou «logique» .

Pourquoi avez-vous besoin de l'entrée Commanderpour provenir d'un disque? Tout ce qui compte c'est d'écrire en utilisant une certaine entrée, alors vous pouvez vérifier qu'il a écrit le fichier correctement en utilisant ce qui est dans le test .

La partie réelle que vous testez Factoryest «est-ce qu'il lira correctement ce fichier et affichera la bonne chose»? Donc, moquez le fichier avant de le lire dans le test .

Alternativement, tester que Factory et Commander fonctionnent lorsqu'ils sont couplés ensemble est très bien - cela correspond parfaitement aux tests d'intégration. La question ici est plutôt de savoir si vous pouvez ou non les tester séparément.


Dans cet exemple particulier, la ressource qui les relie est la ressource, par exemple le disque système. Sinon, il n'y a pas d'interaction entre les deux classes.
James Wood

1

Obtenez des données quelque part. Transformez ces données. Mettez ces données quelque part.

C'est une approche procédurale typique, dont David Parnas a écrit en 1972. Vous vous concentrez sur la façon dont les choses se passent. Vous prenez la solution concrète de votre problème comme un modèle de niveau supérieur, ce qui est toujours faux.

Si vous poursuivez une approche orientée objet, je préfère me concentrer sur votre domaine . C'est à propos de quoi? Quelles sont les principales responsabilités de votre système? Quels sont les principaux concepts qui se présentent dans la langue de vos experts de domaine? Donc, comprenez votre domaine, décomposez-le, traitez les domaines de responsabilité de niveau supérieur comme vos modules , traitez les concepts de niveau inférieur représentés comme des noms comme vos objets. Voici un exemple que j'ai donné à une question récente, il est très pertinent.

Et il y a un problème évident de cohésion, vous en avez parlé vous-même. Si vous apportez des modifications à une logique d'entrée et écrivez des tests dessus, cela ne prouve en aucun cas que votre fonctionnalité fonctionne, car vous pourriez oublier de passer ces données à la couche suivante. Vous voyez, ces couches sont intrinsèquement couplées. Et un découplage artificiel aggrave encore les choses. Je sais que moi-même: projet de 7 ans avec 100 années-homme derrière mes épaules écrit entièrement dans ce style. Fuyez-le si vous le pouvez.

Et dans l'ensemble SRP. Il s'agit de cohésion appliquée à votre espace problématique, c'est-à-dire au domaine. C'est le principe fondamental derrière SRP. Il en résulte que les objets sont intelligents et assument leurs responsabilités pour eux-mêmes. Personne ne les contrôle, personne ne leur fournit de données. Ils combinent les données et le comportement, exposant uniquement ces derniers. Vos objets combinent donc à la fois la validation des données brutes, la transformation des données (c'est-à-dire le comportement) et la persistance. Cela pourrait ressembler à ceci:

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

En conséquence, il existe un certain nombre de classes cohérentes représentant certaines fonctionnalités. Notez que la validation va généralement aux objets de valeur - au moins dans l' approche DDD .


1

Là où je me bats, c'est quand je viens aux tests, je me retrouve avec des tests étroitement couplés. Par exemple;

  • Usine - lit les fichiers du disque.
  • Commander - écrit des fichiers sur le disque.

Faites attention aux abstractions qui fuient lorsque vous travaillez avec un système de fichiers - je l'ai vu trop souvent négligé, et il présente les symptômes que vous avez décrits.

Si la classe opère sur des données provenant de / allant dans ces fichiers, le système de fichiers devient un détail d'implémentation (E / S) et doit en être séparé. Ces classes (usine / commandant / médiateur) ne devraient pas être au courant du système de fichiers à moins que leur seul travail soit de stocker / lire les données fournies. Les classes qui traitent du système de fichiers doivent encapsuler des paramètres spécifiques au contexte comme les chemins (peuvent être passés par le constructeur), donc l'interface n'a pas révélé sa nature (le mot "Fichier" dans le nom de l'interface est une odeur la plupart du temps).


"Ces classes (usine / commandant / médiateur) ne devraient pas être au courant du système de fichiers à moins que leur seul travail soit de stocker / lire les données fournies." Dans cet exemple particulier, c'est tout ce qu'ils font.
James Wood

0

À mon avis, il semble que vous ayez commencé à emprunter la bonne voie, mais vous ne l'avez pas assez loin. Je pense que diviser la fonctionnalité en différentes classes qui font une chose et le font bien est correct.

Pour aller plus loin, vous devez créer des interfaces pour vos classes Factory, Mediator et Commander. Ensuite, vous pouvez utiliser des versions simulées de ces classes lors de l'écriture de vos tests unitaires pour les implémentations concrètes des autres. Avec les simulations, vous pouvez valider que les méthodes sont appelées dans le bon ordre et avec les bons paramètres et que le code testé se comporte correctement avec différentes valeurs de retour.

Vous pouvez également envisager d'abstraire la lecture / écriture des données. Vous allez maintenant dans un système de fichiers, mais vous voudrez peut-être aller dans une base de données ou même un socket dans le futur. Votre classe de médiateur ne devrait pas avoir à changer si la source / destination des données change.


1
YAGNI est une chose à laquelle vous devriez penser.
whatsisname
En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.