Хороший дизайн должен быть SOLID: TOP-5 архитектурных принципов
Что такое хороший дизайн? По каким критериям его оценивать, и каких правил придерживаться при разработке? Как обеспечить достаточный уровень гибкости, связанности, управляемости, стабильности и понятности кода? Роберт Мартин составил список, состоящий всего из пяти правил хорошего проектирования, которые известны, как принципы SOLID.
Достичь такой лаконичности удалось, использовав небольшую хитрость: дело в том, что термин SOLID – это аббревиатура, которая в свою очередь состоит из аббревиатур, за каждой из которых прячется целый класс паттернов. Ниже мы рассмотрим каждый из них.
SRP: Single Responsibility Principle (принцип единственной обязанности)
Об этом принципе говорят, что он является одним из простейших для понимания, но достаточно сложным для того, чтобы легко научиться им правильно пользоваться.
Рассмотрим пример. Пусть у нас есть класс, реализующий некоторую функциональность, связанную с банковским счетом:
1 2 3 4 5 6 7 8 9 10 |
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 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
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 (принцип открытия/закрытия)
Другими словами, нужно избегать случаев, когда появление новых требований к функциональности влечет за собой модификацию существующей логики, стараясь реализовать возможность ее расширения.
Рассмотрим простой пример. Пусть у нас в системе есть некий класс, отвечающий за просмотр логов:
1 2 3 4 5 6 |
class LogViewer { public IEnumerable<Transaction> GetByDate(DateTime dateTime){ ... } public IEnumerable<Transaction> GetByUser(string name){ ... } public IEnumerable<Transaction> GetByDateAndUser(DateTime dateTime, string name){ ... } } |
1 2 3 4 5 6 |
class LogViewer { public IEnumerable<Transaction> GetByDate(DateTime dateTime){ ... } public IEnumerable<Transaction> GetByUser(string name){ ... } public IEnumerable<Transaction> GetByDateAndUser(DateTime dateTime, string name){ ... } } |
Подобная эволюция дизайна является типичным примером нарушения принципа открытия/закрытия. А типичным решением этой проблемы мог бы стать следующий код:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
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())); } } |
Итак, можно сказать, что объект, спроектированный по принципу открытия/закрытия обладает следующими атрибутами:
- “Открыт для расширения”: поведение может быть расширено путем добавления новых объектов, реализующих новые аспекты поведения;
- “Закрыт для модификации”: в результате расширения поведения исходный или двоичный код объекта не может быть изменен.
LSP: Liskov Substitution Principle (принцип замещения Лисков)
Функции, которые используют ссылки на базовые классы, должны иметь возможность использовать объекты производных классов, не зная об этом.
Впервые этот принцип был упомянутБарбарой Лисков в 1987 году на научной конференции, посвященной объектно-ориентированному программированию.
Этот принцип является важнейшим критерием для оценки качества принимаемых решений при построении иерархий наследования. Сформулировать его можно в виде простого правила: тип S будет подтипом Т тогда и только тогда, когда каждому объекту oS типа S соответствует некий объект oT типа T таким образом, что для всех программ P, реализованных в терминах T, поведение P не будет меняться, если oT заменить на oS.
Классическим примером нарушения этого принципа является построение иерархии такого рода:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
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 (принцип изоляции интерфейса)
Другими словами этот принцип можно сформулировать так: зависимость между классами должна быть ограничена как можно более узким интерфейсом.
Пример нарушения этого принципа:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
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()); } } |
Решение этой проблемы заключается в проектировании грамотной иерархии интерфейсов для уменьшения такой зависимости:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
>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, которые выполняет одну простую вещь: рассчитывает стоимость заказа, учитывая возможную скидку и добавляя налоги, в зависимости от страны, в которой выполняется заказ. В первом приближении у нас может получиться такой код:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
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:
- Знает, как вычислить сумму заказа;
- Знает, как и каким калькулятором вычислить сумму скидки;
- Знает, что означают коды стран;
- Знает, каким образом вычислить сумму налога для той или иной страны;
- Знает формулу, по которой из всех слагаемых вычисляется стоимость заказа.
Для того, чтобы разрешить эту проблему, выделим из этого класса две стратегии, которые будут отвечать за расчеты скидки и налогов и перенесем туда соответствующую логику, а зависимость между классами установим через абстракции этих стратегий, реализованных в виде интерфейсов. После всех этих манипуляций мы увидим такой код
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
// Интерфейсы стратегий 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. Но во многом, это мнение является ошибочным, потому как разработка юнит-тестов сама по себе не является сложной задачей и начать писать тесты может каждый на любом этапе своего развития. Главное начать. И уже потом, в процессе поиска оптимальных способов разработки через тестирование будет достигнуто глубокое понимание всех тонкостей качественного проектирования.