Я делаю свои занятия слишком гранулированными? Как следует применять принцип единой ответственности?

9

Я пишу много кода, который включает в себя три основных шага.

  1. Получить данные откуда-то.
  2. Преобразуйте эти данные.
  3. Положите эти данные куда-нибудь.

Я обычно заканчиваю тем, что использую три типа классов - вдохновленные их соответствующими шаблонами дизайна.

  1. Фабрики - построить объект из какого-то ресурса.
  2. Посредники - использовать фабрику, выполнить трансформацию, затем использовать командира.
  3. Командиры - разместить эти данные где-то еще.

Мои классы, как правило, довольно малы, часто представляют собой один (общедоступный) метод, например, получение данных, преобразование данных, выполнение работы, сохранение данных. Это приводит к увеличению числа классов, но в целом работает хорошо.

Когда я прихожу на тестирование, я бьюсь о том, чтобы в конечном итоге я получал тесно связанные тесты. Например;

  • Factory - читает файлы с диска.
  • Commander - записывает файлы на диск.

Я не могу проверить одно без другого. Я мог бы написать дополнительный «тестовый» код для чтения и записи на диск, но потом я повторяюсь.

Глядя на .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
Мне кажется, ты в хорошей форме. Все, что вам нужно, это тестовый посредник (или один для каждого типа конверсии, если вам это нравится больше). Посредник теста может читать файлы, чтобы проверить их правильность, используя класс File .net. Там нет проблем с этим с твердой точки зрения.
Мартин Маат
1
Как упомянул @Robert Harvey, у SRP есть дрянное имя, потому что на самом деле речь идет не об обязанностях. Речь идет о «инкапсуляции и абстрагировании одной сложной / сложной области, которая может измениться». Я думаю, STDACMC было слишком долго. :-) Тем не менее, я думаю, что ваше разделение на три части кажется разумным.
user949300
1
Важным моментом в вашей Fileбиблиотеке из C # является то, что, насколько мы знаем, Fileкласс может быть просто фасадом, помещающим все файловые операции в одном месте - в классе, но может внутренне использовать классы чтения / записи, аналогичные вашим, что на самом деле содержат более сложную логику для обработки файлов. Такой класс (the File) все еще будет придерживаться SRP, потому что процесс фактической работы с файловой системой будет абстрагирован за другим уровнем - скорее всего, с объединяющим интерфейсом. Не сказать, что это так, но это может быть. :)
Энди

Ответы:

5

Следование принципу Единой Ответственности, возможно, было тем, что направляло вас сюда, но где вы находитесь, имеет другое имя.

Распределение ответственности по командным запросам

Изучите это, и я думаю, вы найдете его по знакомой схеме, и вы не одиноки в размышлениях о том, как далеко вы можете это сделать. Кислотный тест заключается в том, что следование этому дает вам реальную выгоду, или если вы просто следите за слепой мантрой, чтобы вам не приходилось думать.

Вы выразили беспокойство по поводу тестирования. Я не думаю, что следование CQRS исключает написание тестируемого кода. Вы можете просто следовать CQRS таким образом, чтобы ваш код не был тестируемым.

Это помогает узнать, как использовать полиморфизм для инвертирования зависимостей исходного кода без необходимости изменения потока управления. Я не совсем уверен, где ваши навыки написание тестов.

Предостережение: следовать привычкам, которые вы найдете в библиотеках, не оптимально. Библиотеки имеют свои потребности и, откровенно говоря, старые. Так что даже самый лучший пример - это только лучший пример того времени.

Это не значит, что не существует вполне достоверных примеров, которые не соответствуют CQRS. После этого всегда будет немного больно. Это не всегда стоит платить. Но если вам это нужно, вы будете рады, что использовали это.

Если вы используете его, прислушайтесь к этому слову предупреждения:

В частности, CQRS следует использовать только в определенных частях системы (BoundedContext на языке DDD), а не в системе в целом. При таком мышлении каждый ограниченный контекст нуждается в своих собственных решениях о том, как его следует моделировать.

Мартин Флаулер: CQRS

candied_orange
источник
Интересно не видел CQRS раньше. Код тестируемый, это больше о попытке найти лучший способ. Когда я могу, я использую насмешки и инъекцию зависимостей (я думаю, это то, на что вы ссылаетесь).
Джеймс Вуд
Впервые читая об этом, я обнаружил нечто подобное в своем приложении: обрабатывать гибкий поиск, множественные поля, фильтруемые / сортируемые, (Java / JPA) - головная боль, которая приводит к множеству шаблонного кода, если вы не создадите базовую поисковую систему, которая будет обрабатывать эти вещи для вас (я использую rsql-jpa). Хотя у меня одна и та же модель (скажем, одни и те же сущности JPA для обоих), поиски извлекаются на выделенном универсальном сервисе, и слой модели больше не должен обрабатывать ее.
Вальфрат
3

Вам нужна более широкая перспектива, чтобы определить, соответствует ли код принципу единой ответственности. На это нельзя ответить, просто проанализировав сам код, вы должны рассмотреть, какие силы или действующие лица могут привести к изменению требований в будущем.

Допустим, вы храните данные приложения в файле XML. Какие факторы могут заставить вас изменить код, связанный с чтением или письмом? Некоторые возможности:

  • Модель данных приложения может измениться при добавлении новых функций в приложение.
  • Новые виды данных - например, изображения - могут быть добавлены в модель
  • Формат хранения может быть изменен независимо от логики приложения: скажем, с XML на JSON или в двоичный формат из-за проблем совместимости или производительности.

Во всех этих случаях вам придется изменить как чтение и логику написания. Другими словами, они не являются отдельными обязанностями.

Но давайте представим другой сценарий: ваше приложение является частью конвейера обработки данных. Он считывает некоторые CSV-файлы, сгенерированные отдельной системой, выполняет некоторый анализ и обработку, а затем выводит другой файл для обработки третьей системой. В этом случае чтение и письмо являются самостоятельными обязанностями и должны быть отделены друг от друга.

Итог: вы вообще не можете сказать, если чтение и запись файлов являются отдельными обязанностями, это зависит от ролей в приложении. Но, исходя из вашего намека на тестирование, я полагаю, что в вашем случае это единственная ответственность.

JacquesB
источник
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, вы можете преобразовать Strings.

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выше, насмехаясь reader, transformerи writer.

Самуил
источник
Вот где я сейчас нахожусь. Я могу проверить каждую функцию в отдельности, однако, проверяя их, они становятся связанными. Например, для проверки FileWriter, затем нужно прочитать что-то еще, очевидное решение - использование FileReader. Между прочим, посредник часто делает что-то еще, например, применяет бизнес-логику или, возможно, представлен основной функцией приложения.
Джеймс Вуд
1
@JamesWood это часто бывает с интеграционными тестами. Вы не должны соединять классы в тесте как бы то ни было. Вы можете проверить FileWriter, читая непосредственно из файловой системы вместо использования FileReader. Это действительно зависит от ваших целей. Если вы используете FileReader, тест будет прерван, если один FileReaderили FileWriterсломан - что может занять больше времени для отладки.
Самуил
Также см. Stackoverflow.com/questions/1087351/… Это может помочь сделать ваши тесты приятнее
Самуил
Вот где я сейчас нахожусь - это не на 100% верно. Вы сказали, что используете шаблон «Посредник». Я думаю, что это не полезно здесь; этот шаблон используется, когда у вас много разных объектов, взаимодействующих друг с другом в очень запутанном потоке; вы помещаете туда посредника, чтобы облегчить все отношения и реализовать их в одном месте. Кажется, это не ваш случай; у вас есть небольшие единицы очень хорошо определены. Кроме того, как и комментарий выше @Samuel, вы должны протестировать один юнит и выполнить свои утверждения, не вызывая другие юниты
Emerson Cardoso
@EmersonCardoso; Я несколько упростил сценарий в моем вопросе. В то время как некоторые из моих посредников довольно просты, другие являются более сложными и часто используют несколько заводов / командиров. Я пытаюсь избежать детализации одного сценария, меня больше интересует архитектура проектирования более высокого уровня, которая может быть применена к нескольким сценариям.
Джеймс Вуд
2

Я в итоге получу тесно связанные тесты. Например;

  • Factory - читает файлы с диска.
  • Commander - записывает файлы на диск.

Итак, основное внимание здесь уделяется тому, что их объединяет . Вы передаете объект между ними (например File,?) Тогда это файл, с которым они связаны, а не друг с другом.

Из того, что ты сказал, ты отделил свои классы. Ловушка в том, что вы тестируете их вместе, потому что это проще или «имеет смысл» .

Зачем вам нужен вход, Commanderчтобы прийти с диска? Все, что его волнует - это запись с использованием определенного ввода, а затем вы можете проверить, правильно ли он записал файл, используя то, что находится в тесте .

Фактическая часть, которую вы проверяете, Factoryэто «правильно ли он прочитает этот файл и выведет правильную»? Так что издевайтесь над файлом, прежде чем читать его в тесте .

В качестве альтернативы, проверка того, что Factory и Commander работают, когда они связаны друг с другом, - это хорошо - это вполне устраивает Integration Testing. Вопрос здесь больше в том, может ли ваш юнит тестировать их по отдельности.

Эрдрик Айронроуз
источник
В этом конкретном примере их связывает ресурс - например, системный диск. В противном случае нет взаимодействия между двумя классами.
Джеймс Вуд
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

Когда я прихожу на тестирование, я бьюсь о том, чтобы в конечном итоге я получал тесно связанные тесты. Например;

  • Factory - читает файлы с диска.
  • Commander - записывает файлы на диск.

Следите за утечками абстракций при работе с файловой системой - я слишком часто пренебрегал ею, и у нее есть симптомы, которые вы описали.

Если класс оперирует данными, которые поступают из этих файлов или поступает в них, тогда файловая система становится деталью реализации (I / O) и должна быть отделена от нее. Эти классы (фабрика / командир / посредник) не должны знать о файловой системе, если их единственной задачей является хранение / чтение предоставленных данных. Классы, которые имеют дело с файловой системой, должны инкапсулировать специфичные для контекста параметры, такие как пути (могут передаваться через конструктор), чтобы интерфейс не раскрывал свою природу (слово «Файл» в имени интерфейса в большинстве случаев является запахом).

содрогаться
источник
«Эти классы (фабрика / командир / посредник) не должны знать о файловой системе, если их единственной задачей является хранение / чтение предоставленных данных». В этом конкретном примере это все, что они делают.
Джеймс Вуд
0

На мой взгляд, звучит так, как будто вы начали идти по правильному пути, но недостаточно далеко. Я думаю, что правильное разбиение функциональности на разные классы, которые делают одно и делают это хорошо.

Чтобы продвинуться дальше, вы должны создать интерфейсы для ваших классов Factory, Mediator и Commander. Затем вы можете использовать макеты версий этих классов при написании ваших модульных тестов для конкретных реализаций других. С помощью макетов вы можете проверить, что методы вызываются в правильном порядке и с правильными параметрами, и что тестируемый код работает правильно с различными возвращаемыми значениями.

Вы также можете посмотреть на абстрагирование чтения / записи данных. Сейчас вы переходите на файловую систему, но, возможно, захотите в будущем обратиться к базе данных или даже к сокету. Ваш класс-посредник не должен изменяться, если изменяется источник / назначение данных.

Ричард Уэллс
источник
1
Ягни - это то, о чем ты должен думать.
whatsisname