понедельник, 13 мая 2013 г.

Пример тестируемого дизайна

В комментариях к предыдущей заметке был задан такой вопрос:

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

Неужели надо вынести код чтения файла в отдельный класс, создать интерфейс и передать экземпляр через конструктор? Боюсь, если так буду делать, то не закончу и половины вовремя.»

Здесь очень мало информации, поэтому все мои рассуждения можно использовать лишь в качестве отправной точки дизайна и реализации. Любой исходный дизайн эволюционирует по мере развития требований и понимания задачи, но определенные подходы применимы даже при таком наборе «входных данных».

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

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

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

1. «Объектно-функциональный подход»

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

С первого взгляда кажется, что наиболее сложной частью модуля является парсинг данных, поэтому для него я бы сразу выделил отдельный класс CustomDataParser, который бы затем использовал из CustomDataFileParser:

class CustomDataParser
{
   
// Здесь будет сосредоточена большая часть логики модуля
    public CustomData Parse(string
str) { }

   
// Формально, метод нарушает Interface Segregation Principle,
    // но подобный метод может быть позеным, если именно этот
    // вариант будет наиболее часто используемым клиентами класса
    public IEnumerable<CustomData> ParseAll(IEnumerable<string
> seq)
    {
       
return
seq.Select(Parse);
    }
}

public class CustomDataFileParser : IDisposable
{
   
private readonly CustomDataParser _parser = new CustomDataParser
();

   
public CustomDataFileParser(string
fileName)
    { }

   
public IEnumerable<CustomData
> Parse()
    {
       
return
ReadFileLineByLine().Select(s => _parser.Parse(s));
    }

   
private IEnumerable<string> ReadFileLineByLine()
    { }
}

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

ПРИМЕЧАНИЕ
«Функциональность» этого решения весьма условна. Мы можем рассматривать класс CustomDataFileParser – как функцию преобразования строки с именем файла в последовательность CustomData. При этом суммарное поведение этой функции строится на двух вспомогательных функциях: функции чтения данных из файла (fun string -> sequence of string) и функции разбора строки (fun string -> CustomData). В результате, мы могли бы реализовать всю требуемую функциональность одним LINQ запросом с парой вспомогательных функций.

Конечно, это еще не все. Наш класс CustomDataFileParser достаточно протестирован, но он сам является «изменчивой» зависимостью, поскольку он работает с файловой системой. Нам нужно подумать не только о тестируемости своих классов, но и о тестируемости наших клиентов.

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

image

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

Pros:

  • Тестируемость;
  • Небольшое количество абстракций;

Cons:

  • Не pure ООП решение;
2. «DIP головного мозга»

Мы можем пойти «стандартным» путем для получения тестируемого дизайна и выделить сразу же 3 (!) стратегии: (1) стратегию чтения строк из файла (IFileReader); (2) стратегию парсинга строки (ICustomDataParser) и (3) стратегию парсинга строк из файла (ICustomDataFileParser):

image

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

Во-вторых, полученный дизайна является «плоским»: хотя ICustomDataFileParser является более высокоуровневой абстракцией по сравнению с IFileReader и ICustomDataParser, но ее название говорит о том, как она реализована, а не что она делает. При таком подходе к дизайну очень легко получить «плоскую» систему, в которой для решения любой задачи нужно думать о слишком большом количестве абстракций.

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

Pros:

  • решение отвечает всем ОО канонам;

Cons:

  • слишком большое количество абстракций;
  • преждевременное обобщение;
  • реализация может быть покрыта юнит-тестами, но не работать в боевых условиях.
  • можно попасть в западню бесконечного «инвертирования зависимостей»;
  • «плоский дизайн».

3. Метод шаблона

Если мы хотим лишь протестировать определенную функциональность класса, то вместо выделения интерфейса мы можем воспользоваться паттерном под названием “Extract and Override” (см. главу 3 книги Роя Ошерова “The Art of Unit Testing”):

Его идея заключается в том, что в тестируемом классе метод, работающий с внешними ресурсами мы делаем виртуальным. В результате, мы используем паттерн «Метод Шаблона», переменным шагом которого является работа с внешними ресурсами, который мы затем сможем «подделать» в тесте:

// Класс не может быть sealed!
public class CustomDataFileParser
{
   
private readonly string
_fileName;

   
public CustomDataFileParser(string
fileName)
    {
        _fileName = fileName;
    }

   
// Используем метод шаблона:
    // сам алгоритм является неизменным, при этом он содержит
    // шаги, имзеняемые наследниками
    public IEnumerable<CustomData
> GetCustomData()
    {
       
return
ReadFileLineByLine().Select(s => Parse(s));
    }

   
private CustomData Parse(string
str)
    { }

   
// Этот метод мы переопределим для тестирования граничных условий
    protected virtual IEnumerable<string
> ReadFileLineByLine()
    {
       
// Читаем данные из файла!
    }
}

Затем в тестах мы переопределим метод ReadFileLineByLine и «подсунуть» тестовые данные. Многие изоляционные фреймворки позволяют «мокать» (в данном случае «стабать») виртуальные методы, поэтому создавать наследников для каждого теста не придется:

[Test]
public void
TestParsing()
{
   
// Arrange
    var mock = new Mock<CustomDataFileParser
>();
    mock.Protected()
       
//  Да, имя защищенного метода указывается в виде строки
        .Setup<IEnumerable<string>>("ReadFileLineByLine"
)
       
// Возвращаем данные для проверки граничных условий
        .Returns(new List<string> { "SampleString with Id = 42"
});

   
// Act
    var
parsedData = mock.Object.GetCustomData().ToList();

   
// Assert
    Assert.That(parsedData[0].Id, Is.EqualTo(42));
}

Альтернативным подходом может быть вариация паттерна «Метод шаблона», на основе делегатов, а не наследования. Мы можем сделать конструктор, который принимает «переменный шаг метода шаблона» (реализацию метода ReadFileLIneByLine) в виде Func<IEnumerable<string>>.

Pros:

  • относительно легковесная техника;
  • отсутствие дополнительных абстракций;

Cons:

  • прогибаем дизайн под тестирование;
  • с помощью стабов не всегда удобно проверить все граничные условия класса;
  • решение половинчатое, поскольку абстракция парсера все равно была бы полезной.
4. Воспользоваться TypeMock Isolator или Microsoft Fakes

Некоторые «изоляционные фреймворки», такие как TypeMock и Shims из Microsoft Fakes, позволяют «подделывать» любые методы, включая статические и невиртуальные.

В результате, для тестов мы могли бы «замокать» File.ReadAllLines или любой другой метод, с помощью которого мы читаем файл. И хотя для тестирования легаси кода эти инструменты могут быть полезными, их однозначно не стоит использовать для новых модулей.

5. Интеграционное тестирование

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

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

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

Pros:

  • полезное дополнение к юнит-тестам;
  • может быть полезным, если бизнес-пользователи работают напрямую с файлами и могут прислать десятки примеров;

Cons:

  • стимулирует плохой дизайн;
  • потребует больше времени для проверки всех граничных условий;

UPDATE: Sergey Fetisov в комментарии предложил воспользоваться TextReader-ом для “абстрагирования” от конкретного источника данных, а также несколько усложнить задачу, предположив, что одной записи может соответствовать более одной строки в исходном потоке. Я предлагаю рассмотреть эти изменения.

image

Во-первых, Сергей предложил передавать парсеру TextReader, но я бы этого не делал. Я бы оставил парсер без изменения, поскольку проще думать о парсинге строк, а не об извлечении строк из TextReader-а с последующим парсингом (один класс – одна задача!).

Во-вторых, я бы выделил класс для поиска строк в исходном потоке . И хотя это тоже парсинг (в некотором роде), этот парсинг завязан на состояние (нашли начало записи, нашли середину записи, нашли конец записи); он достаточно сложен сам по себе, а главное, он может содержать свой собственный конечный автомат переходов, отличный от состояния CustomDataParser-а.

В-третьих, я добавил дополнительный конструктор классу CustomDataFileParser, принимающий TextReader (что позволит написать юнит-тесты для этого класса) и поскольку из-за этого пришлось бы открывать файл в конструкторе, то я сделал этот класс Disposable.

Обратите внимание, что наши требования изменились, но открытый интерфейс модуля (ICustomDataProvider и CustomDataProvider) не поменялся! Поменялась реализация модуля, она усложнилась, но наши клиенты от этого не пострадали.

Помните, что инкапсуляция существует не только на уровне классов, но и на уровне модулей. Использование фасадных классов часто помогает изменять “детали реализации модуля” не затрагивая его клиентов.

Заключение

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

P.S. Если у вас есть вопросы дизайна вашего приложения, которые вы бы хотели обсудить, присылайте мне их на почту: обсудим в переписке или на страницах этого блога.

12 комментариев:

  1. Серёж, почему ты так категорично пишешь про TypeMock Isolator: "И хотя для тестирования легаси кода эти инструменты могут быть полезными, их однозначно не стоит использовать для новых модулей." ?
    Ведь в пункте 4 ты сам пишешь "прогибаем дизайн под тестирование", а инструменты типа TypeMock Isolator как раз позволяют не прогибать дизайн под тестированию.

    ОтветитьУдалить
  2. Паш, давай рассмотрим сценарий использования TypeMock-а для этой задачи.

    Мы хотим протестировать, например, CustomDataFileParser. Мы на момент написания теста знаем, что этот класс использует класс File с методом ReadAllText. Мы мокаем File.ReadAllText и проверяем граничные условия.

    Затем, через время, я решаю изменить реализацию класса CustomDataFileParser и вместо File.ReadAlText воспользоваться асинхронным вводом-выводом или, банально, другой перегруженной версией того же метода.

    В результате наши тесты летят к чертям.

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

    В общем, здесь мы видим очередной компромисс: как сделать дизайн тестируемым, при этом не слишком прогибаться из-за этого. Мне, как я уже писал, нравится первый вариант, который решает основные проблемы;)

    ОтветитьУдалить
  3. Сергей, читаю Ваш блог давно и благодарен за интересные статьи. Со многим согласен, но данная статья оставляет двоякое впечатление.

    При разборе данной задачи вы искусственно создали себе проблему (как читать данные из файла?) и пытаетесь ее решить. На мой взгляд, стандартные операции ввода/вывода следует решать штатными средствами .Net и не изобретать велосипед.

    Исходя из условий, было бы достаточно использовать TextReader. Его наследники позволят реализовать как интеграционные тесты (StreamReader), так и легкое тестирование логики парсера без использования файлов (StringReader).

    Кроме того, в постановке задачи четко не оговорено, что данные обязательно должны быть представлены построчно. В вашем варианте переход на многострочный вариант хранения данных (например ini-файл) приведет к переработке не только CustomDataParser, но и CustomDataFileParser. Я бы разбор структуры файла так же инкапсулировал в CustomDataParser.

    По мне, следующая реализация позволит решить инфраструктурные вопросы и сконцентрироваться на самом парсере:

    interface IDataTextParser
    {
    IEnumerable Parse(TextReader reader);
    }

    public class CustomDataFileParser
    {
    public CustomDataFileParser(IDataTextParser parser, string fileName)
    {
    }

    public IEnumerable Parse()
    {
    using(var reader = new StreamReader(_fileName))
    return _parser.Parse(reader);
    }
    }


    Такая реализация CustomDataFileParser не требует тестирования, т.к. не содержит бизнес-логики. Разве что отработка ошибок (например: отсутствие файла), но это уже зависит от механизма отработок ошибок в Вашем приложении.

    ОтветитьУдалить
  4. +Sergey Fetisov. А теперь представим себе ситуацию, что мы парсим не файл, а данные, полученные из сокета. Будем менять IDataTextParser? По-моему, не лучший вариант. Я так понял, что основная мысль была разделить собственно парсинг и получение данных. Если я не прав, пусть меня поправят :). Ну и соответственно, тестить надо как юнит-тест - сам парсер, а получение данных - как интеграционный тест. Ну и то, если логика получения данных будет сложная. А так, на первом шаге - ограничиться юнит-тестами парсера, но зато ОЧЕНЬ серьезными (common cases and corner cases). В общем, если я не прав - пусть Сергей поправит... :)
    P.S. По поводу TypeMock Isolator. Хорошо, когда есть пистолет, из которого можно отстрелить себе ВСЕ :). Но как по мне - провоцирует на написание "специфического" кода. Т.е. побольше статических классов, методов. Чтобы легко писались тесты нужно чтобы интерфейс был "тестабельный", чтобы легко можно было использовать что-то вроде Moq. И знаю, звучит крамольно, но понимание, что надо будет использовать для тестов тот же Rhino Mock - заставляет писать код в пригодном для этого виде (ну если мы не про TDD говорим). А TypeMock - позволяет меньше напрягаться. :)

    ОтветитьУдалить
  5. Сергей, спасибо за дополнение.
    Я часть отвечу в комментарии, а часть - путем обновления поста.

    Да, использование TextReader-а - это отличный, да еще и очень распростраеннный подход к дизайну подобных задач. Многие классы в .NET Framework (типа XDocument.Load) содержат несколько перегрузок: одна принимает имя файла, а другая - TextReader.

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

    По поводу формата: да, никто нигде не говорит, что одна запись представлена одной строкой, но, с другой стороны, никто не говорит об обратном;)

    Если произойдет такое изменение требований, то решить это в рамках текущего дизайна будет не сложно (см. апдейт для подробностей).

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

    ОтветитьУдалить
  6. ... мне не понятно, почему вы абстрагировались от парсера.

    Как я понимаю, в вашем варианте под каждый формат данных нужно реализовывать весь набор: CustomDataProvider, CustomDataFileParser, CustomDataParser.

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

    ОтветитьУдалить
  7. Сергей, если не против, то можно на "ты".

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

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

    Как только у нас появится "общность", тогда будем перетасовывать все это дело.

    К тому же, у меня есть задел на то, что мы работаем не только с файлами, более того, сделан задел даже на то, что мы работаем с некоторым источником CustomData, при этом совсем не обязательно, что мы именно "парсим" эти данные из источника. Вполне могут появится реализации, которые вычитывают эти данные из базы данных (именно поэтому интерфейс называется ICustomDataProvider, а не ICustomDataParser).

    ОтветитьУдалить
  8. Сереж, ты пишешь:

    >Паш, давай рассмотрим сценарий использования TypeMock-а для этой задачи.

    >Мы хотим протестировать, например, CustomDataFileParser. Мы на момент написания теста знаем, что этот класс использует класс File с методом ReadAllText. Мы мокаем File.ReadAllText и проверяем граничные условия.

    >Затем, через время, я решаю изменить реализацию класса CustomDataFileParser и вместо File.ReadAlText воспользоваться асинхронным вводом-выводом или, банально, другой перегруженной версией того же метода.

    >В результате наши тесты летят к чертям.

    Не, ты например в пункте 3 мокаешь метод ReadFileLineByLine(). Если бы я использовал TypeMock, то я бы тоже этот метод замокал, а не File.ReadAllText() ;) При этом я бы мог оставить метод ReadFileLineByLine() приватным, а не менять сигнатуру под тест.

    ОтветитьУдалить
  9. >> Не, ты например в пункте 3 мокаешь метод ReadFileLineByLine(). Если бы я использовал TypeMock, то я бы тоже этот метод замокал, а не File.ReadAllText() ;) При этом я бы мог оставить метод ReadFileLineByLine() приватным, а не менять сигнатуру под тест.

    Да, этот подход разумнее. Но смотри, я работаю над рефакторингом этого класса. У меня даже намека нет на то, что закрытый метод ReadFileLineByLine особый. Я его меняю (инструменты рефакторинга не увидят, что метод используется в тестах, ведь он закрытый). И я ломаю этот тест.

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

    ОтветитьУдалить
  10. Спасибо автору, за труд. Статья оставила, двоякое впечатление. Логически связаный набор правильных мыслей.
    Замечания по имплементации.
    Я разберу первый пример, потому, что он самый правильный и боеспособный.

    1. private readonly CustomDataParser _parser = new CustomDataParser(); Даже если не хочется создавать фабрику или инвертировать зависимость, никто не отменял правило из GOF не создавать объект в классе, который его использует. Можно легко, перенести инстанцирование в сам класс CustomDataParser например создать static Create method и сделать его конструктор приватным. На первый взгляд это лишенное смысла решение, но оно позволяет инкапсулировать инстанцирование в наиболее легкий способ не занимаясь over design. Create method теперь может возвращать все, что угодно унаследованое от CustomDataParser. Пользуясь new ты создаешь сильную связь CustomDataParser и CustomDataFileParser, new возвращает всегда только то что возвращает. Create при надобности позволит превратить CustomDataParser в полиморфный сервис. Бенефит очевиден, при минимальных вложениях клиенты CustomDataParser не изменятся при переходе к полиморфной модели. После возможного дельнейшего рефакторинга, когда тебе покажется, что CustomDataParser должен превратиться из полиморфного сервиса в реальную фабрику, ты просто будешь делегировать из CustomDataParser, новой фабрике оставляя клиентов в невединии этого.

    2. Мне не понятно желание засунуть filename в конструктор класса (не велика зависимость зато приличное ограничение как в удобности сопровождения, так и в удобстве пользования самим классом). Инстанцировать много раз CustomDataFileParser с разным именем файла ... Мы не зависим от сервисов или статически конфигурируемых(инжектируемых в конструтор) данных. В таких ситуациях, мне видится, более логичным сделать filename параметром метода Parse.

    3.ICustomDataProvider ... Когда ты заботишся о клиенте, не стоит впаривать ему развязаную иерархию как first class solution... Обрати внимание как .NET Framework абстрагирует тебя от сложности. Самые абстрактные и неудобные API потребляются убоными и не абстрактыми фасадами более высокого уровня. File -> FileStream - Stream. Это справедливо не только для фреймворка. Твои клиенты обычно пишут неабстрактыне приложения делегирующие разную функциональность более обобщенным библиоткам. В нашем случае, мы почему то следуем этому правилу в одном месте. Вполне себе конкретный CustomDataFileParser делегирует более абстрактному CustomDataParser, а потом натягиваем на все это сверхабстрактый развязочный механизм...

    Идея со стратегией мне нравится и она должна быть доступна клиенту как более гибкий мезанизм работы с парсером, но это не должно быть написано поверх CustomDataFileParser. Это должна быть стратегия получения CustomData, которая будет использована в самом CustomDataFileParser. например ты можешь иметь стратегии разбора CustomData из файла, потока, сокета, базы данных e.t.c .. они будут делегировать CustomDataParser, а CustomDataFileParser будет соответственно инстанцировать нужную стратегию посредством фабрики или сервиса. Имеем вполне себе удобный CustomDataFileParser -> Парсер на основе стратегий -> CustomDataParser.
    4. Отдавать клиенту API выраженое через интерфейс вроде ICustomDataProvider не есть хорошая карма. С точки зрения версионности и с учетом возможной нестабильности интерфейса лучше вернуть абстрактный класс. Если ты будешь расширять интерфейс провайдера, то по крайней мере ты не сломаешь существующих клиентов. Это особенно важно если ты не имеешь достаточно времени сделать качественный дизайн изначально. Смотри Framework Design Guidelines (Brad Abrams, Krysztof Cwalina)
    В заключение: Дизайн инфраструктурного и библиотечного кода, часто сильно отличается от DDD, когда ты моделируешь бизнесс. Отсюда идет не правильное принятие дизайн решений. В случае с библиотеками, люди грешат не допроектированием, в случае DDD все наооборот, часто перепроектирование.

    ОтветитьУдалить
  11. Изучая ООП, сталкиваюсь с разными трактовками понятия объект.
    Интересно и важно мнение автора, должен ли объект ООП представлять объект реального мира.
    Включая лучшие практики и ваш опыт, как вы считаете, оправдано ли вместо объекта FileParser(который не имеет состояния и является процедурным стилем) использовать объект, например, ParsedFile
    (new ParsedFile(new File(path)).Read() логика парсинга в методе Read)
    Является ли более ООПшным использование объектов типа
    EstablishedSqlConnection и EstablishedSqlCommandChannel, new SomeCommand(establishedSqlCommandChannel).Execute()
    вместо объектов-стратегий типа DataProcessor, DataManager, Parser без состояния, предоставляющих интерфейс обработки неинкапсулированных данных.

    ОтветитьУдалить