воскресенье, 28 сентября 2008 г.

Хороший дизайн должен быть SOLID: TOP-5 архитектурных принципов

Что такое хороший дизайн? По каким критериям его оценивать, и каких правил придерживаться при разработке? Как обеспечить достаточный уровень гибкости, связанности, управляемости, стабильности и понятности кода? Роберт Мартин составил список, состоящий всего из пяти правил хорошего проектирования, которые известны, как принципы SOLID.

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

SRP: Single Responsibility Principle (принцип единственной обязанности)

Не должно существовать более одного мотива для изменения данного класса.

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

Рассмотрим пример. Пусть у нас есть класс, реализующий некоторую функциональность, связанную с банковским счетом:

class Account : ActiveRecord
{
    public Guid Id{ get{ ... } }
    public string Number { get{ ... } }
    public decimal CurrentBallance { get { ... } }
    public void Deposit(decimal amount){ ... }
    public void Withdraw(decimal amount){ ... }   
    public void Transfer(decimal amount, Account recipient){ ... }
    public TaxTable CalculateTaxes(int year){ ... }
}

Как видно, класс несет ответственность за:

  1. Персистентность;
  2. Логику управление балансом;
  3. Логику расчета налогов.

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

class Account
{
    public string Number { get{ ... } }
    public decimal CurrentBallance { get { ... } }
    public void Deposit(decimal amount){ ... }
    public void Withdraw(decimal amount){ ... }
    public void Transfer(decimal amount, Account recipient){ ... }
}

class AccountRepository
{
    public Account GetByNumber(string number){ ... }
    public void Save(Account acc){ ... }
}

class TaxCalculator
{
    public TaxTable CalculateTaxes(Account acc, int year){ ... }
}

А сложность в применении данного принципа заключается в том, что прежде всего нужно научиться правильно чувствовать границы его использования. Ведь даже в приведенном примере мы превратили паттерн Active Record в антипаттерн, разом перечеркнув все примеры его успешного применения.

OCP: Open/Closed Principle (принцип открытия/закрытия)

Объекты проектирования (классы, функции, модули и т.д.) должны быть открыты для расширения, но закрыты для модификации.

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

Рассмотрим простой пример. Пусть у нас в системе есть некий класс, отвечающий за просмотр логов:

class LogViewer
{
    public IEnumerable<Transaction> GetByDate(DateTime dateTime){ ... }
}

В один прекрасный день возникла необходимость реализовать возможность выборки транзакций по имени пользователя. Класс может быть модифицирован следующим образом:

class LogViewer
{
    public IEnumerable<Transaction> GetByDate(DateTime dateTime){ ... }
    public IEnumerable<Transaction> GetByUser(string name){ ... }
    public IEnumerable<Transaction> GetByDateAndUser(DateTime dateTime, string name){ ... }
}

Подобная эволюция дизайна является типичным примером нарушения принципа открытия/закрытия. А типичным решением этой проблемы мог бы стать следующий код:

class LogViewer
{
    public IEnumerable<Transaction> GetTransaction(GetSpecification spec){ ... }
}

abstract class GetSpecification
{
    public GetSpecification CombineWith(GetSpecification nextSpec){ ... }
    // ...
}

class GetByDateSpecification : GetSpecification
{
    // ...
}

class GetByUserSpecification : GetSpecification
{
    // ...
}

// Пример использования
class Client
{
    public void ShowLog()
    {
        var viewer = new LogViewer();
        var transactions = viewer.GetTransaction(
            new GetByDateSpecification()
            .CombineWith(new GetByUserSpecification()));
    }
}

Итак, можно сказать, что объект, спроектированный по принципу открытия/закрытия обладает следующими атрибутами:

  1. "Открыт для расширения": поведение может быть расширено путем добавления новых объектов, реализующих новые аспекты поведения;
  2. "Закрыт для модификации": в результате расширения поведения исходный или двоичный код объекта не может быть изменен.

LSP: Liskov Substitution Principle (принцип замещения Лисков)

Функции, которые используют ссылки на базовые классы, должны иметь возможность использовать объекты производных классов, не зная об этом.

Впервые этот принцип был упомянут Барбарой Лисков в 1987 году на научной конференции, посвященной объектно-ориентированному программированию.

Этот принцип является важнейшим критерием для оценки качества принимаемых решений при построении иерархий наследования. Сформулировать его можно в виде простого правила: тип S будет подтипом Т тогда и только тогда, когда каждому объекту oS типа S соответствует некий объект oT типа T таким образом, что для всех программ P, реализованных в терминах T, поведение P не будет меняться, если oT заменить на oS.

Классическим примером нарушения этого принципа является построение иерархии такого рода:

class Rectangle
{
    public virtual int Width { get; set; }
    public virtual int Height { get; set; }

    public int CalculateRectangleArea()
    {
        return Width*Height;
    }
}

class Square : Rectangle
{
    public override int Height
    {
        get{ return base.Height; }
        set
        {
            base.Height = value;
            base.Width = value;
        }
    }

    public override int Width
    {
        get{ return base.Width; }
        set
        {
            base.Width = value;
            base.Height = value;
        }
    }
}

class Program
{
    private static Rectangle CreateRecatgle()
    {
        return new Square();
    }

    static void Main()
    {
        Rectangle r = CreateRecatgle();
        r.Width = 3;
        r.Height = 2;

        Assert.AreEqual(6, r.CalculateRectangleArea());
    }
}

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

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

ISP: Interface Segregation Principle (принцип изоляции интерфейса)

Клиент не должен вынужденно зависеть от элементов интерфейса, которые он не использует.

Другими словами этот принцип можно сформулировать так: зависимость между классами должна быть ограничена как можно более узким интерфейсом.

Пример нарушения этого принципа:

abstract class ServiceClient
{
    public string ServiceUri{ get; set; }
    public abstract void SendData(object data);
    public abstract void Flush();
}

class HttpServiceClient : ServiceClient
{
    public override void SendData(object data)
    {
        var channel = OpenChannel(ServiceUri);
        channel.Send(data);
    }

    public override void Flush()
    {
        // Метод ничего не делает, но присутствует в классе
    }
}

class BufferingHttpServiceClient : ServiceClient
{
    public override void SendData(object data)
    {
        Buffer.Write(data);
    }

    public override void Flush()
    {
        var channel = OpenChannel(ServiceUri);
        channel.Send(Buffer.GetAll());
    }
}

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

>abstract class ServiceClient
{
    public string ServiceUri{ get; set; }
    public abstract void SendData(object data);
}

abstract class BufferingServiceClient : ServiceClient
{
    public abstract void Flush();
}

class HttpServiceClient : ServiceClient
{
    public override void SendData(object data){ ... }
}

class BufferingHttpServiceClient : BufferingServiceClient
{
    public override void SendData(object data){ ... }
    public override void Flush(){ ... }
}

Еще одним признаком потенциального нарушения этого принципа является наличие громоздких интерфейсов. Попробуйте реализовать MembershipProvider для ASP.NET, унаследовавшись от стандартного базового класса и вы поймете о чем речь :)

DIP: Dependency Inversion Principle (принцип обращения зависимости)

Модули верхних уровней не должны зависеть от модулей нижних уровней. Оба типа модулей должны зависеть от абстракций. Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.

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

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

Дизайн таких систем можно охарактеризовать следующими признаками:

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

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

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

public class OrderProcessor
{
    public decimal CalculateTotal(Order order)
    {
        decimal itemTotal = order.GetItemTotal();
        decimal discountAmount = DiscountCalculator.CalculateDiscount(order);

        decimal taxAmount = 0.0M;

        if (order.Country == "US")
            taxAmount = FindTaxAmount(order);
        else if (order.Country == "UK")
            taxAmount = FindVatAmount(order);

        decimal total = itemTotal - discountAmount + taxAmount;

        return total;
    }

    private decimal FindVatAmount(Order order)
    {
        return 10.0M;
    }

    private decimal FindTaxAmount(Order order)
    {
        return 12.0M;
    }
}

Возвращаясь к принципу единственной обязанности, перечислим все обязанности, которые выполняет класс OrderProcessor:

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

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

// Интерфейсы стратегий
public interface IDiscountCalculator
{
    decimal CalculateDiscount(Order order);
}

public interface ITaxStrategy
{
    decimal FindTaxAmount(Order order);
}

// Реализация стратегий
public class DiscountCalculatorAdapter : IDiscountCalculator
{
    public decimal CalculateDiscount(Order order)
    {
        return DiscountCalculator.CalculateDiscount(order);
    }
}

public class USTaxStrategy : ITaxStrategy
{
    public decimal FindTaxAmount(Order order){ ... }
}

public class UKTaxStrategy : ITaxStrategy
{
    public decimal FindTaxAmount(Order order){ ... }
}

// Облегченный код
public class OrderProcessor
{
    private readonly IDiscountCalculator _discountCalculator;
    private readonly ITaxStrategy _taxStrategy;

    public OrderProcessor(IDiscountCalculator discountCalculator, 
                          ITaxStrategy taxStrategy)
    {
        _taxStrategy = taxStrategy;
        _discountCalculator = discountCalculator;
    }

    public decimal CalculateTotal(Order order)
    {
        decimal itemTotal = order.GetItemTotal();
        decimal discountAmount = _discountCalculator.CalculateDiscount(order);

        decimal taxAmount = _taxStrategy.FindTaxAmount(order);

        decimal total = itemTotal - discountAmount + taxAmount;

        return total;
    }
}

Принцип обращения зависимостей лежит в основе архитектур многих каркасов приложений. Для автоматизации процесса управления зависимостями разработано множество утилит. Мартин Фаулер в своей статье рассмотрел всевозможные паттерны реализации механизма работы этого принципа.

Заключение

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

Отдельного внимания заслуживает подход к разработке через тестирование (TDD) и его связь с SOLID-принципами. Для многих именно незнание этих принципов является той самой непреодолимой стеной, которая мешает начать использовать TDD. Но во многом, это мнение является ошибочным, потому как разработка юнит-тестов сама по себе не является сложной задачей и начать писать тесты может каждый на любом этапе своего развития. Главное начать. И уже потом, в процессе поиска оптимальных способов разработки через тестирование будет достигнуто глубокое понимание всех тонкостей качественного проектирования.

Опубликовать

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

  1. В случае с LogViewer - не факт, что для 3-5 методов выборки есть смысл городить спецификацию.

    Почему
    abstract class ServiceClient
    а не
    interface ISeviceClient?

    ОтветитьУдалить
    Ответы
    1. потому что бубль гум. классы екстендятся а интерфейсы - имплементируются (а Java). а вообще-т опринципы - language-aware

      Удалить
  2. Дело не в количестве, а в качестве :)

    а abstract class ServiceClient, потому что там public string ServiceUri{ get; set; }

    ОтветитьУдалить
  3. Дело в деньгах клиента, который платит. Гибкое решение должно предполагать окупаемость.

    http://msdn.microsoft.com/en-us/library/64syzecx.aspx - на счет интерфейсов

    ОтветитьУдалить
  4. Дело не в гибкости для клиента. Вы же не станете утверждать, например, что IoC нужен только для того, чтобы клиент имел возможность гибкой конфигурации приложения?

    Да и по объему кода едва ли разница столь значима, чтобы говорить о значительных преимуществах одного из методов.

    Кроме того, если вы будете использовать TDD, то у вас такое решение со спецификациями получиться само собой :)

    А по поводу интерфейсов я не понял, к чему вы клоните. Лучше приведите код :)

    ОтветитьУдалить
  5. public interface IServiceClient
    {
    // Property declaration:
    string ServiceUri
    {
    get;
    set;
    }
    }

    - оно?

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

    Используя ТДД, я бы получил самое простое решение, которым Спецификация не является.

    ОтветитьУдалить
  6. Или я что-то не понимаю, но если я просто вместо abstract class напишу interface - то я получу ошибки в духе "'HttpServiceClient' does not implement interface member 'ServiceClient.ServiceUri'". Так что, наврерное, не оно :)

    ОтветитьУдалить
  7. А относительно TDD и спецификаций, то вы бы его получили на стадии рефакторинга в какой-то момоент времени, а певое приближение дейтсвительно было бы классом с кучей методов.

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

    ОтветитьУдалить
  8. "Или я что-то не понимаю, но если я просто вместо abstract class напишу interface - то я получу ошибки в духе "'HttpServiceClient' does not implement interface member 'ServiceClient.ServiceUri'". Так что, наврерное, не оно :)"
    - ну если не реализовать, то и не скомпилится.
    Я иммел ввиду, что предпочтительней использовать интерфейсы (к вопросу о гибкости). К тому же писать тесты к "интерфейсному коду" гораздо легче.

    ОтветитьУдалить
  9. Ну, если так, то в данном конкретном случае этот интерфейс не не нес бы никакой смысловой нагрузки для примера.

    По этой же причине реализации методов заменены тремя точками :)

    Но в реальном коде в нем был бы смысл, безусловно.

    ОтветитьУдалить
  10. >>Я иммел ввиду, что предпочтительней использовать интерфейсы (к вопросу о гибкости).
    >> К тому же писать тесты к "интерфейсному коду" гораздо легче.

    И в чем была бы разница? Мы же не далем гибкость ради гибкости. Если есть ситуации в которых абстраный луче (а в данном примере он действительно лучше), то зачем городить интерфейс, а потом еще абстракную реализацию?

    ОтветитьУдалить
  11. Seems like OOP languages themselves give us a good examples of LSP-conforming system designs. This is why developers can simply upcast their objects without noticing LSP in this act :)

    ОтветитьУдалить
  12. Well, I can't fully agree with this opinion. I.e. modern languages like C# can't check the behaviour aspects of the inheritance. Thats why the Design by contract approach with automated theormes proving is still on the very early stages of implementation.

    ОтветитьУдалить
  13. Поправьте, пожалуйста:
    Принцип замещения Лисков:
    ...поведение P не будет меняться, если o1 заменить на o2.
    вы перепутали о1 и о2 местами,
    и для понимания будет лучше, если, как в Википедии, объекты типа (Type) и подтипа (Subtype) обозначить oT и oS соответственно.

    ОтветитьУдалить
  14. Вопрос по принципу SRP (хочется уточнить, правильно ли я понимаю принцип?):
    Нужно максимально стремиться специализировать класс, например, отделить бизнес-логику от сохранения данных в БД.
    Также следует специализировать все открытые методы класса, которые составляют его интерфейс. Например, метод: int CalculateDiscount(int price) - вычисляет скидку и ничего более.
    Если же для успешной реализации такого метода требуется дополнительная бизнес-логика, то ее нужно вынести в отдельные вспомогательные private-методы, которые не являются открытым интерфейсом класса.

    ОтветитьУдалить
  15. IFeelGood,

    Слово "макстимально" тут лишнее. Что чрезмерно - то не здраво, и использование принципов ради принципов не дает хорошего результата. Добавление вороха лишних абстракций разделит обязанности, но привнесет дополнительную сложность, с которой вам придется бороться с куда большим усердием, чем с, о боже, реализацией active record :)

    Для оценки качества исполнения принципа SRP могу порекомендовать попытаться сформулировать обязанности класса обычным человеческим языком. Если если получается средних размеров повесть, значит все плохо :) В идеале все должно вписываться в одном предложение.

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

    Ну и последнее: штука в том, что сами по себе синтаксические конструкции со словом class в начале не всегда соответствуют логическому понятию класса объектов. Код - это достаточно низкий уровень дизайна системы из за бедности современных языков программирования. Классический пример - понятия agregate roots и entities в сравнениии с value objects в паттернах DDD: единственная точка входа в логику - это корень агрегата, который за собой может прятать с десяток других классов, но вместе образовывать тот самый единый класс в терминах теории ООП. Или взять тот же aggregate root, но реализованый в виде event source-а. Т.е. к этому агрегату добавляется еще пачка связанных событий и какой-то странный код по их обработке. В квадратиках все выглядит очень просто, но в коде получается куча синтаксического мусора. Кроме того, даже такие высокоуровневые логические понятия, как агрегаты тоже могут составлять конструкции еще более высоких уровней - домены логики, модули систем и т.д.

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

    ОтветитьУдалить
  16. Спасибо за ответ :)
    Я понимаю, что нужно использовать эти принципы с самого начала проектирования системы. Я сейчас тренируюсь в agile-разработке, и, соответственно, применяю все этапы проектирования: диаграммы, потом тесты, а потом уже код. И это буквально "вынуждает" писать гибкий код, используя все эти архитектурные принципы.
    Это я к тому, что часто бывает так: спроектированная поначалу бизнес-сущность выглядит как один модуль, а на этапе кодирования приходится разделять ее на несколько более мелких. Опять же, следуя принципу SRP.
    Пример, который я приводил в комментарии выше, является хорошим приемом организации SRP на уровне кодирования?

    ОтветитьУдалить
  17. Как учебный пример, может и нет. Но в качестве примера боевого коде - why not, вполне. Главное, не забывать про DRY и KISS при этом :)

    ОтветитьУдалить
  18. Анонимный3 июля 2013 г., 20:51

    "Классическим примером нарушения этого принципа является построение иерархии такого рода:" не понял где ошибка, и если есть ошибка то где пример правильного кода?

    ОтветитьУдалить
  19. Прочитав сатью испытал дежавю, ибо на все эти грабли можно наступить (и часто наступают) при проектировании БД. Корень зла - несоответсвие архитектуры и логики приложения физической сути вещей (сущностей), которые оно (приложение) описывет. Не вникая в предметную область (зачем? ведь есть ТЗ!) вы порешали, что "это" приватное свойство, а оказалось что "это" вовсе не приватное и даже не свойство, а отношение или хуже - другая сущность ... начало конца положено!
    Любое серьезное и даже очень простое, на первый взгляд, дело должно начинаться с ER-диаграммы - мозг прочищает основательно ... особенно после пятой переделки, и вот только тогда вырисовыватся те самые "монолитные" кубики LEGO, которые идеально подходят друг к другу и их никогда (есть право на надежу!) не придется переписывать. Просто в ООП много "халявы", в которой легко можно запутаться и использовать не по назначению. Кому интересно - есть статьи-комменты PerformanceDBA на stackoverflow (на англ.) с реальными примерами очень детальных и стройных ER-диаграмм. Человек 32 года проектирует БД = возраст SQL!

    ОтветитьУдалить
    Ответы
    1. Этот комментарий был удален автором.

      Удалить
    2. Этот комментарий был удален автором.

      Удалить
  20. Коллеги, подскажите, пожалуйста, менеджеру,
    есть ли тулза для контроля исполнения принципа DIP в проектах C# ?

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