Pull to refresh

Про модель, логику, ООП, разработку и остальное

Reading time 29 min
Views 108K
Часто ли вы задумываетесь – почему что-то сделано так или иначе? Почему у вас микросервисы или монолит, двухзвенка или трехзвенка? Зачем вам многослойная архитектура и сколько у вас вообще слоев? Что такое бизнес-логика, логика приложения, презентационная логика и почему все так разделено? Посмотрите на свое приложение – как оно вообще спроектировано? Что в нем и где находится, почему это сделано именно так?
Потому что так написано в книжках или так говорят авторитетные личности? Какие ВАШИ проблемы решает тот или иной подход/паттерн?
Даже то, что на первый взгляд кажется очевидным, порой бывает очень сложно объяснить. А иногда, в попытке объяснения, приходит понимание того, что очевидные мысли были и вовсе ошибочны.
Давайте попробуем взять какой-нибудь пример и изучить на нем эти вопросы со всех сторон.

Игрушечный город
Виртуальный город
SOLID как много в этом слове …
Предметная область
Презентационная логика
Сохранение состояния
Многослойность
2-tier
N-tier
2 как 3
Сервисы
Инструменты
Теория и практика
Итог

Игрушечный город


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

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


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

Виртуальный город


Для начала нужно сделать самое главное – создать модель нашего виртуального города. Хоть это может и показаться чем-то простым, на самом деле, в этом кроется большинство проблем и сложностей. Но начинать все равно надо, так что приступим.

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

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

В качестве таких частей мы возьмем то, что легко отделяется друг от друга при взгляде на наш реальный город — отдельные объекты этого города (дом культуры, красная БМВ на перекрестке, Петрович, бегущий по своим делам). Описание каждого объекта в виртуальном мире представляет собой описание его свойств (цвет, модель, название, расположение и т.п.). Чтобы не повторяться и не описывать одинаковые свойства для похожих объектов каждый раз, мы выделим группу таких свойств и назовем их типом объекта. Хорошими кандидатами являются такие общие типы, как машина, дом, человек и т.п. Они позволят сосредоточить в себе описание основных свойств. А различные виды машин, например, будут дополнять базовый тип «машина» своим уникальным набором свойств, создавая целый набор новых типов. Такие новые типы по отношению к исходным типам называются наследниками. А сам процесс создание нового типа, на основе существующего — наследованием.


Все созданные нами типы объектов будут представлять модель нашего города.

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

И вот вроде бы все расставлено по своим местам, группа машин стоит на перекрестке в ожидании зеленого сигнала светофора, девочка Юля — в ожидании своего лифта, и даже вода застыла в трубах огромного небоскреба. Мы наполнили нашу модель состоянием, повторив состояние нашего реального города за какой-то определенный момент времени.

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

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

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

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

Благодаря этому, для публичного доступа можно будет оставить только используемые другими свойства и методы, скрыв знания о внутреннем устройстве объекта. Такой процесс сокрытия внутренних принципов работы называется инкапсуляция. Например, мы хотим переместить лифт на несколько этажей. Для этого нужно проверить состояние дверей – открыты или закрыты, запустить и остановить двигатель и т.п. Вся эта логика будет просто скрыта за действием «переместиться на этаж». В итоге получается, что тип объекта представляет собой набор свойств и процедур, меняющих эти свойства.

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

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

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

SOLID как много в этом слове …


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

Single Responsibility Principle (принцип единственной ответственности) — у каждого объекта должна быть только одна причина для изменения.

Для того, чтобы в каждой комнате каждого дома можно было использовать и интернет, и электричество — была создана единая розетка с разъемами для интернет-провода и электрического провода.

public class Socket 
{
  private PowerWire _powerWire;
  private EthernetWire _ethernetWire;

  public Socket(PowerWire powerWire, EthernetWire ethernetWire)
  {
    _powerWire = powerWire;  
    _ethernetWire = ethernetWire;
  }
...
}

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

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

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

public class Socket 
{
  private PowerWire _powerWire;
  private EthernetWire _ethernetWire;
  private GroundWire _groundWire;
  
  public Socket(PowerWire powerWire, EthernetWire ethernetWire, GroundWire groundWire)
  {
    _powerWire = powerWire;
    _ethernetWire = ethernetWire;
    _groundWire = groundWire;
  }
...
}

Но последней каплей стало требование — поменять для всех интернет-розеток интернет-провод на новый стандарт. А так как вообще все розетки являются одновременно еще и интернет-розетками – то пришлось опять менять ВСЕ. Хотя объем работ мог бы быть гораздо меньше, так как количество розеток, используемых для интернета, в разы меньше количества всех розеток.

public class Socket 
{
  private PowerWire _powerWire;
  private SuperEnthernetWire _superEnthernetWire;
  private GroundWire _groundWire;
  
  public Socket(PowerWire powerWire, SuperEthernetWire superEthernetWire, GroundWire  groundWare)
  {
    _powerWire = powerWire;
    _superEnthernetWire = superEnthernetWire;
    _groundWare = groundWare;
  }
...
}

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

Чтобы избежать таких проблем — розетка должна была быть разделена на две, независимые друг от друга, части – электрическую розетку и интернет-розетку:

public PowerSocket 
{
  private PowerWire _powerWire;
  private GroundWare _groundWare;

  public PowerSocket(PowerWire powerWire, GroundWare groundWare)
  {
    _powerWire = powerWire;
    _groundWare = groundWare;
  }
  ...
}

public class EthernetSocket 
{
  private SuperEthernetWire _superEthernetWire;

  public EthernetSocket (SuperEthernetWire _superEthernetWire)
  {
    _superEthernetWire = superEthernetWire;
  } 
  ...
}

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

public PowerEthernetSocket 
{
  private PowerSocket _powerSocket;
  private EthernetSocket _ethernetSocket;

  public PowerEthernetSocket (PowerSocket powerSocket, EthernetSocket ethernetSocket)
  {
    _powerSocket = powerWire;
    _ethernetSocket = ethernetSocket;
  }
...
}

Open-closed principle (принцип открытости-закрытости) – объекты должны быть закрыты для модификаций, но открыты для расширений.

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

public class Message 
{
  public string Text { get; set; }
}

public class BigDisplay
{
  public void DisplayMessage(Message message)
  {
    PrintText(message.Text);
  }
  
  public void PrintText(string text)
  {
    ...
  }
}

Спустя какое-то время появился новый вид сообщений — сообщения, содержащие дату. И для таких сообщений на экране было необходимо отображать и дату и текст. Доработку можно было выполнить различными способами.
1. Создать новый тип, производный от типа «сообщение», добавить ему атрибут «дата» и поменять процедуру отображения сообщений.

public class Message 
{
  public string Text { get; set; }
}
public class MessageWithDate : Message
{
  public DateTime Date { get; set; }
}
...
public void DisplayMessage(Message message)
{
  if (message is MessageWithDate)
    PrintText(message.Date + Message.Text)
  else
    PrintText(message.Text);
}

Но такой способ плох тем, что придется менять поведение всех типов, которые каким-либо образом выводят сообщение. И если в дальнейшем появится еще какой-то новый, особый тип сообщений — все придется менять еще раз.

2.Добавить свойство «дата» в тип «сообщение» и поменять способ получения текста, чтобы получилось так:

public class Message 
{
  private string _text;
  public string Text 
  {
    get 
    {
      if(Date.HasValue)
        return Date.Value + _text;
      else
        return _text;
    }
    set { _text = value; }
  }
  public DateTime? Date { get; set; }
}

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

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

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

public class Message 
{
  public string Text { get; set; }
  public virtual string GetDisplayText()
  {
    return Text;
  }
}
public class MessageWithDate : Message
{
  public DateTime Date { get; set; }
  public override string GetDisplayText()
  {
     return Date + Text;
  }
}
...
public void DisplayMessage(Message message)
{
   PrintText(message.GetDisplayText());
}

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

Liskov substitution principle (принцип подстановки Барбары Лисков) — функции, которые используют базовый тип, должны иметь возможность использовать подтипы базового типа, не зная об этом.

После добавления в программу типа «велосипед» пришло время добавить еще один тип – «мопед». Мопед — он же как велосипед, только еще лучше. Значит велосипед отлично подойдет в качестве базового типа для мопеда. Сказано-сделано, и в программе появился еще один тип «мопед» — производный от типа «велосипед».

public class Bicycle 
{
  public int Location;
  public void Move (int distance) { ... }
}

public class MotorBicycle : Bicycle 
{
  public int Fuel;
  public override void Move(int distance) 
  {
    if (fuel == 0) 
      return;
     ...
    fuel = fuel – usedFuel;
  }
}

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

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

public void LongJourney (int to, Biker biker, Bicycle bicycle)
{ 
  while(bicycle.location < to)
  {
    int distance = to - bicycle.location;
    if (distance > 1000)
      distance = 1000;
    bicycle.move(distance);
    biker.sleep();
  }
}

Когда в метод вместо велосипеда передавался мопед с недостаточным количеством бензина, он зависал навсегда из-за того, что мопед не продвигался ни на сантиметр, даже если такое действие вызывали явно. Чтобы исправить эту проблему — можно было бы сделать так, конечно:

public void LongJourney (int to, Biker biker, Bicycle bicycle)
{ 
  while(bicycle.location < to)
  {
    int distance = to - bicycle.location;
    if (distance > 1000)
      distance = 1000;
    if (bicycle is MotorBicycle && bicycle.Fuel == 0)
      FillFuel((MotorBicycle)bicycle);
    bicycle.move(distance);
    biker.sleep();
  }
}

public void FillFuel(MotorBicycle motorBicycle)
{
  ...
} 

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

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

Interface segregation principle (принцип разделения интерфейсов) — клиенты не должны зависеть от методов, которые они не используют.

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

public void CheckIntersect(Car car, People[] people)
{
  ...
  if (Intersect(car, people))
  {
    car.Stop();
    car.Beep();
  }
}

public bool Intersect(Car car, People[] people)
{
  ...
}

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

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

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

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

Мы можем определить контракт, который должен соблюдать тип, который можно использовать в этом методе и реализовать его во всех транспортных средствах.

public interface IVehicle 
{
  ...
  void Stop();
  void Beep();
}
public class Car : IVehicle { ... }
public class Bycycle : IVehicle { ... }
public void CheckIntersect(IVehicle vehicle, People[] peoples)
{
  ...
  if (Intersect(vehicle, peoples))
  {
    vehicle.Stop();
    vehicle.Beep();
  }
}

public bool Intersect(IVehicle vehicle, People[] people)
{
  ...
}

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

Dependency Inversion Principle (принцип инверсии зависимостей) — абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.

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

public class NotifySystem
{
  private SpeakerSystem _speakerSystem = new SpeakerSystem();

  public void NotifyOnWarning(string message)
  {
    _speakerSystem.Play(message);
  }
}

Все работает прекрасно, но требованиям свойственно меняться. Теперь мы должны иногда передавать сообщения не через систему динамиков, а через мобильные телефоны, отправляя их в виде смс. Конечно, мы можем сделать еще один объект для отправки уведомлений о сообщениях через смс, но для этого придется дублировать большую часть логики. Чтобы этого избежать — мы пойдем другим путем. Системе оповещения, в принципе, все равно как ее сообщения будут отображаться. Самое главное – это их отправка. Поэтому, мы можем сделать так:

public class NotifySystem
{
  private IMessageDelivery _messageDelivery;

  public public NotifySystem(IMessageDelivery messageDelivery)
  {
    _messageDelivery = messageDelivery;
  }

  public void NotifyOnWarning(string message)
  {
    _messageDelivery.SendMessage(message);
  }
}

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

— А можно было бы просто не заморачиваться всеми этими принципами и писать код как получится?
— Конечно!
— И все равно все бы так же работало?
— Естественно!
— Для чего же тогда нужно использовать все эти сложности в виде ООП, ООД? Если я не буду думать обо всех этих сложностях — я гораздо быстрее напишу программу! А чем быстрее напишу, тем лучше!
— Если программа достаточно проста, а ее дальнейшее развитие, доработка или исправление ошибок не предвидится, то все это, в принципе, и не нужно. Но! Если программа сложная, планируется ее доработка и поддержка, то применение всех этих «лишних сложностей» будет напрямую влиять на самое главное – на количество потраченных ресурсов, требуемых для доработок и поддержки, а это значит – на стоимость.


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

Предметная область


— Запомни, если плохой алгоритм будет стоить тебе тысячу, то плохая архитектура будет стоить тебе миллион.

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

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

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

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

Пользователь высказал пожелание о том, что в комнаты домов днем должен попадать дневной свет так, чтобы все было видно. Разработчик, недолго думая, выдолбил в стене круглую дыру и проверил — оказалось, что света все еще недостаточно. Тогда разработчик выдолбил еще две дыры рядом и опять проверил. Убедившись, что света достаточно, разработчик решил, что задача выполнена. По этому принципу дыры были выдолблены во всех домах. Требования были соблюдены.
Но лето кончилось и наступила зима, а вместе с ней и холода. Злой пользователь прибежал и стал ругаться на то, что в комнатах стало ужасно холодно. При выяснении причин стало ясно, что из-за дыр, выдолбленных под солнечный свет, в комнату попадает много холодного воздуха с улицы и она промерзает. После выяснений обстоятельств оказалось, что пользователь хотел обычное окно, но какие-то требования забыл упомянуть он, а что-то по-своему передал аналитик, и получилось то, что получилось. Так как все промерзло – решать проблему пришлось на ходу, времени на разработку хороших окон и переделку дыр под окна в зимний период уже не было. Поэтому было принято решение обойтись «костылем» и заделать дыры полиэтиленом. Такой способ помог избавиться от сильного промерзания и позволил оставить солнечный свет днем. Но кто знает, сколько новых проблем неполное понимание изначальных требований еще принесет…


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

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

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

Презентационная логика


Несмотря на все наши успехи, пользователи продолжают нетерпеливо ждать перед погасшим монитором. Мы создали модель нашего города, вдохнули в него жизнь, описав логику его изменения, проделав тем самым очень важный объем работы. Теперь нам осталось дать возможность пользователям «увидеть» то, что мы сделали. Для этого нам нужно научить наше приложение взаимодействовать с пользователями. Во-первых, пользователь хочет смотреть, что происходит с его городом, как двигаются машинки, как загораются ночные окна, как куда-то спешат люди. А во-вторых, он хочет влиять на город — переключать светофор, менять день на ночь, создавать новые дома и т.п.

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

Например, мы хотим регулировать светофор на перекресте. Для этого нам не нужно знать какие дома стоят вокруг, где работают или живут люди, которые ждут зеленый сигнал светофора или то, что происходит на другом конце города. Все, что нам нужно для принятия решения — это информация о перекресте и о том, что на нем находится. Сколько машин и на каких полосах стоит, сколько людей стоит в ожидании на пешеходном переходе и какой сигнал у светофора. Перекресток в разрезе этой задачи — это корневой объект, с которым связаны другие объекты (машины, светофор, люди). В случае другой задачи, например, управления всего транспортного потока города, наш перекресток будет всего лишь частью другого корневого объекта – транспортной инфраструктуры всего города.

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

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

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


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


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

А если взять что-нибудь, типа Microsoft HoloLens, то вообще можно отображать наш виртуальный город так же, как он выглядел в реальности или даже еще удобнее.

Сохранение состояния


После нескольких часов непрерывной работы пользователь, с радостью от проделанной работы, откидывается на спинку стула.
— Уффф – произносит усталый пользователь.
— Чпок – отвечают ему пробки и свет в мониторе гаснет.
В глазах разработчика появляется вселенская грусть. Ничего не подозревающий пользователь включает свет обратно и запускает компьютер. Запустив свой виртуальный город — пользователь видит, что все что он успел сделать – пропало. Город выглядит точно так же, как и в момент первого запуска …


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

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

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

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

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

Еще одним интересным способом сохранения является хранение не самого состояния модели, а описания действий, которые это состояние поменяли. Это напоминает redo-лог в базах данных или запись шахматной партии, в виде последовательности ходов. Такой подход называется event sourcing.

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


Многослойность


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

Бизнес-логика

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

Презентационная логика

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

Логика доступа к данным

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

Логика приложения

Это та логика, которая связывает все воедино, как клей. Ей известно и о бизнес-логике, и о логике доступа к данным, и о презентационной логике. Она объединяет их, помогает им и налаживает друг с другом взаимодействие.

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



2-tier


— Эй! – крикнул один пользователь другому. – Ты же должен был добавить новое здание в этом месте!
— Так я его и добавил, вот, посмотри. – ответил второй первому.
— Действительно… – почесал в затылке первый. — Странно, что я его не вижу у себя.
— Ничего странного, – ответил им разработчик. – ведь у каждого из вас своя база данных, т.е., по сути, у каждого свой собственный город.
— А зачем нам каждому свой город? – спросили раздраженные пользователи. – ведь он у нас был один, общий и мы хотим один, общий.
В глазах разработчика снова появилась вселенская грусть.


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

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

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

После физического разделения программы на два компьютера — у нас получилась клиент-серверная (двухзвенная) архитектура.

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

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

N-tier


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


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

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

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


2 как 3


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

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

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


Сервисы


— У нас тут вчера сломалось управление транспортом в городе и из-за этого не работала вся программа. – начал один из пользователей, – Но, с управлением транспорта никак не связано управление электроэнергией или водопроводной системой – продолжил он. – Можно как-нибудь сделать так, чтобы возможность их использования не зависела от проблем в несвязанных с ними областях? – спросил пользователь в итоге.
— У меня тоже есть просьба – продолжил другой пользователь – Можно сделать так, чтобы для обновления различных возможностей программы не нужно было бы ждать довольно долго обновления всей программы? – спросил он.


Потратив достаточно времени в самом начале и хорошо поняв предметную область, мы смогли выделить группу сервисов. Например, сервис управления светофорами, сервис управления электроэнергией или даже сервис управления зданиями. Все эти сервисы являются частью нашего виртуального города, но между собой они связаны достаточно мало, а зачастую, не связаны вовсе. Поэтому код программы был написан так же, с разбивкой на сервисы. Чтобы этого достичь — мы выделили необходимый для каждого сервиса публичный набор методов, спрятав все остальное. Таким образом, мы превратили наш сервис в «черный ящик». Этого легко удалось достичь, используя различные средства языка, такие как: модификаторы доступа методов, интерфейсы, абстрактные классы и т.п.


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

Чтобы справится с этой проблемой, мы физически разделили программу на несколько частей. Таким физическим разделением может являться выделение сервисов в различные файлы (библиотеки). Это позволило разбить программу на несколько файлов – основной запускаемый файл и набор библиотек.

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



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

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

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

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


— Слушай, а что всё так носятся с этими микросервисами?
— Разработчики думают, что физически разделив бизнес-логику, смогут писать слабосвязанные, легкозаменяемые устойчивые модули.
— Как будто им раньше что-то мешало…


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

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

Инструменты


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


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

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

Но, среди разработчиков существует один очень плохой синдром под названием – «Not invented here». Его смысл в том, что вместо того, чтобы взять какой-либо готовый инструмент для решения определенного круга задач, разработчики начинают изобретать свой. Наиболее сильно это может проявляться в желании создания своей платформы.

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

Теория и практика


В теории, теория и практика одно и тоже. На практике — нет.

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

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

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

Итог


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

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

И только от разработчика зависит насколько хорошим будет этот путь. Поэтому в разработке программ люди — это главный и самый важный ресурс.
Tags:
Hubs:
+47
Comments 34
Comments Comments 34

Articles