我是否使我的课程过于细腻?单一责任原则应如何应用?


9

我编写了许多涉及三个基本步骤的代码。

  1. 从某处获取数据。
  2. 转换数据。
  3. 将该数据放在某处。

我通常最终使用三种类型的类-受它们各自的设计模式的启发。

  1. 工厂-从某些资源构建对象。
  2. 调解员-要使用工厂,执行转换,然后使用指挥官。
  3. 指挥官-将数据放在其他地方。

我的班级往往很小,通常是一个(公共)方法,例如获取数据,转换数据,执行工作,保存数据。这导致类的激增,但总体上效果很好。

我在进行测试时遇到的困难是,我最终会紧密耦合测试。例如;

  • 工厂-从磁盘读取文件。
  • 指挥官-将文件写入磁盘。

没有其他人,我无法测试。我可以编写其他“测试”代码来进行磁盘读/写操作,但是随后我要重复一遍。

看看.Net,File类采用了不同的方法,它将(我的)工厂和指挥官的职责结合在一起。它具有“创建”,“删除”,“存在”和“全部读取”功能。

我是否应该遵循.Net的示例并将我的类结合在一起(尤其是在处理外部资源时)?它仍然耦合了代码,但是它更具故意性-它发生在原始实现中,而不是在测试中。

我在这里是否过于狂热地应用了“单一责任原则”?我有负责读和写的单独的类。当我可以拥有一个负责处理特定资源(例如系统磁盘)的组合类时。



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.-请注意,您要将“责任”与“要做的事情”混为一谈。责任更像是“关注的领域”。File类的职责是执行文件操作。
罗伯特·哈维

1
在我看来,您状况良好。您只需要一个测试介体(或者,如果您更喜欢的话,就可以针对每种转换类型)。测试中介可以使用.net的File类读取文件以验证其正确性。从SOLID角度来看,这没有问题。
马丁·马特

1
正如@Robert Harvey提到的那样,SRP的名称很cr脚,因为它与责任无关。它是关于“封装和抽象可能会改变的棘手/困难的单个问题”。我想STDACMC太长了。:-)也就是说,我认为将您分成三部分似乎很合理。
user949300

1
使用FileC#在您的库中的重要一点是,就我们所知,File该类可能只是一个外观,将所有文件操作放在一个位置-该类中,但是可以在内部使用与您的类类似的读/写类。实际上包含用于文件处理的更复杂的逻辑。这样的类File仍将遵循SRP,因为实际使用文件系统的过程将在另一层之后抽象-最有可能使用统一接口。没有说是这样,但是可能是这样。:)
Andy

Answers:


5

遵循“单一责任”原则可能是指导您此事的地方,但是您所在的位置却有不同的名称。

命令查询职责隔离

去研究一下,我想您会以一种熟悉的方式找到它,并且您并不孤单地想知道要走多远。严峻的考验是,遵循此规则是否会为您带来真正的好处,或者只是遵循盲目的口头禅,因此您无需思考。

您已经表达了对测试的担忧。我认为遵循CQRS并不排除编写可测试的代码。您可能只是以使代码不可测试的方式遵循CQRS。

它有助于了解如何使用多态来反转源代码依赖性,而无需更改控制流。我不太确定您在编写测试方面的技能。

请注意,遵循您在库中发现的习惯并不是最佳选择。图书馆有自己的需求,并且很老旧。因此,即使最好的例子也只是那时以来的最好的例子。

这并不是说没有不遵循CQRS的完全有效的示例。跟随它总是有些痛苦。这并不总是值得付出的。但是,如果您需要它,您会很高兴使用它。

如果您确实使用它,请注意以下警告:

特别是,CQRS应该仅用于系统的特定部分(DDD术语中的BoundedContext),而不应用于整个系统。用这种思维方式,每个有界上下文都需要自己决定如何建模。

马丁·福勒(Martin Flowler):CQRS


有趣的是以前没有看过CQRS。该代码是可测试的,更多的是试图找到更好的方法。我会尽可能使用模拟和依赖项注入(我认为这是您所指的)。
James Wood

第一次阅读此内容时,我确实在我的应用程序中发现了类似的问题:处理灵活的搜索,可过滤/可排序的多个字段(Java / JPA)令人头疼,并且导致大量的样板代码,除非您创建了一个基本的搜索引擎即可会为您处理这些事情(我使用rsql-jpa)。尽管我有相同的模型(例如,两者都具有相同的JPA实体),但搜索是在专用的通用服务上提取的,模型层不必再处理它了。
Walfrat

3

您需要更广阔的视野来确定代码是否符合“单一职责原则”。仅仅通过分析代码本身并不能解决问题,您必须考虑什么力量或参与者可能导致将来的需求发生变化。

假设您将应用程序数据存储在XML文件中。哪些因素可能导致您更改与阅读或写作有关的代码?一些可能性:

  • 将新功能添加到应用程序后,应用程序数据模型可能会更改。
  • 可以将新类型的数据(例如图像)添加到模型中
  • 存储格式可以独立于应用程序逻辑进行更改:由于互操作性或性能方面的考虑,从XML到JSON或二进制格式。

在所有这些情况下,你将不得不改变在阅读和写作的逻辑。换句话说,它们不是单独的职责。

但是让我们想象一个不同的场景:您的应用程序是数据处理管道的一部分。它读取由单独系统生成的一些CSV文件,进行一些分析和处理,然后输出另一个文件,以供第三系统处理。在这种情况下,阅读和写作是独立的责任,应该分开。

底线:一般而言,您不能说读写文件是否是单独的职责,它取决于应用程序中的角色。但是根据您对测试的提示,我认为这只是您的责任。


2

通常,您有正确的想法。

从某处获取数据。转换数据。将该数据放在某处。

听起来您有三个职责。国际海事组织的“调解人”可能做了很多事情。我认为您应该首先对三个职责进行建模:

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

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

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

那么程序可以表示为:

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

这导致班级激增

我认为这不是问题。IMO的许多小内聚性,可测试类优于大而内聚性较低的类。

我在进行测试时遇到的困难是,我最终会紧密耦合测试。没有其他人,我无法测试。

每一块都应该可以独立测试。通过以上建模,可以将对文件的读/写表示为:

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
}

您可以编写集成测试来测试这些类,以验证它们是否读写文件系统。其余逻辑可以写为转换。例如,如果文件是JSON格式,则可以转换String

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

然后,您可以转换为适当的对象:

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

这些都可以独立测试。您还可以进行单元测试program上面的嘲讽readertransformer以及writer


那就是我现在所在的位置。我可以分别测试每个功能,但是通过测试它们会耦合在一起。例如,要测试FileWriter,则必须读取其他内容,显而易见的解决方案是使用FileReader。首先,调解员经常做其他事情,例如应用业务逻辑,或者可能由基本应用程序Main函数代表。
James Wood

1
@JamesWood集成测试通常是这种情况。你不具有耦合类测试不过。您可以FileWriter通过直接从文件系统读取而不是使用进行测试FileReader。您的目标是否真正取决于您。如果使用FileReader,则测试将在FileReaderFileWriter断开的情况下中断-调试可能需要更多时间。
塞缪尔

另请参阅stackoverflow.com/questions/1087351/…这可能有助于使您的测试更好
Samuel

那就是我现在所处的位置 -不是100%正确。您说您正在使用Mediator模式。我认为这在这里没有用。当您有许多不同的对象以非常混乱的流程相互交互时,将使用此模式;您将调解员放置在此处,以促进所有关系并在一处实现它们。这似乎不是您的情况;您的小型单位定义非常明确。另外,就像@Samuel的上述评论一样,您应该测试一个单元,并在不调用其他单元的情况下进行声明
Emerson Cardoso

@EmersonCardoso; 我在某种程度上简化了我的问题。我的某些调解人虽然很简单,但其他调解人却比较复杂,经常使用多个工厂/指挥官。我试图避免单个方案的细节,但我对可以应用于多个方案的更高级别的设计体系结构更感兴趣。
James Wood

2

我最终将紧密耦合测试。例如;

  • 工厂-从磁盘读取文件。
  • 指挥官-将文件写入磁盘。

因此,这里的重点是将它们耦合在一起的方法。您是否在两者之间传递了一个对象(例如File?),然后是它们所耦合的文件,而不是彼此。

从您所说的,您已经分开了您的课程。陷阱在于您正在一起测试它们,因为它比较容易或“合理”

为什么需要输入Commander来自磁盘?它只关心使用特定输入进行写入,然后可以使用测试中的内容验证它是否正确写入了文件。

您要测试的实际部分Factory是“它将正确读取此文件并输出正确的东西”吗?因此,在测试中读取文件之前先对文件进行模拟。

另外,将Factory和Commander耦合在一起时进行测试的方法很好-与集成测试非常愉快。这里的问题更多是您是否可以对它们进行单独的单元测试。


在该特定示例中,将它们联系在一起的是资源-例如系统磁盘。否则,这两个类之间没有交互作用。
James Wood

1

从某处获取数据。转换数据。将该数据放在某处。

这是一个典型的程序方法,一个是戴维·帕纳斯写回在1972年你专注于如何事情发生的事情。您将问题的具体解决方案视为更高级别的模式,这总是错误的。

如果您追求面向对象的方法,那么我宁愿专注于您的领域。这是什么一回事呢?系统的主要职责是什么?您的网域专家使用的语言中有哪些主要概念?因此,了解您的领域,将其分解,将更高级别的职责范围视为您的模块,将以名词表示的低级别概念视为您的对象。这是我为最近的一个问题提供的示例,它非常相关。

凝聚力有一个明显的问题,您自己提到过。如果您对输入逻辑进行一些修改并对其进行编写测试,则绝不能证明您的功能有效,因为您可能会忘记将该数据传递给下一层。看到,这些层本质上是耦合的。人工去耦使情况变得更糟。我知道我自己:7岁的项目,我的肩膀完全落后了100个人年。如果可以,请远离它。

而就整个SRP而言。这一切都凝聚适用于您的问题空间,即域名。这是SRP背后的基本原则。这导致对象很聪明,并履行了自己的职责。没有人控制它们,没有人向他们提供数据。它们结合了数据和行为,仅公开了后者。因此,您的对象结合了原始数据验证,数据转换(即行为)和持久性。看起来可能如下所示:

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

结果,存在很多代表某些功能的内聚类。请注意,验证通常用于价值对象-至少在DDD方法中。


1

我在进行测试时遇到的困难是,我最终会紧密耦合测试。例如;

  • 工厂-从磁盘读取文件。
  • 指挥官-将文件写入磁盘。

在使用文件系统时要小心泄漏的抽象-我经常看到它被忽视了,而且它具有您所描述的症状。

如果该类对来自/进入这些文件的数据进行操作,则文件系统将成为实现细节(I / O),应与之分开。除非它们的唯一工作是存储/读取提供的数据,否则这些类(工厂/指挥官/调解员)不应该知道文件系统。处理文件系统的类应封装特定于上下文的参数,例如路径(可能会通过构造函数传递),因此接口不会显示其本质(接口名称中的“文件”一词在大多数时候都是一种气味)。


“这些类(工厂/指挥官/调解员)不应知道文件系统,除非它们的唯一工作是存储/读取提供的数据。” 在此特定示例中,这就是他们所做的全部。
James Wood

0

在我看来,听起来您已经开始走上正确的道路,但是您还没有走足够远的路。我认为将功能分解为可以做一件事并且做得很好的不同类是正确的。

要更进一步,您应该为Factory,Mediator和Commander类创建接口。然后,您可以在编写单元测试以使用其他类的具体实现时使用这些类的模拟版本。通过模拟,您可以验证方法的调用顺序和参数正确,并且被测代码在不同的返回值下可以正常运行。

您还可以查看抽象的数据读取/写入。您现在要使用文件系统,但是将来可能希望使用数据库或套接字。如果数据的源/目标已更改,则您的调解器类不必更改。


1
YAGNI是您应该考虑的事情。
whatsisname
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.