Статьи

Использование шаблона State в дизайне, управляемом доменом

Проектирование на основе доменов (DDD) — это подход к разработке программного обеспечения, в котором сложность проблемы решается путем соединения реализации с развивающейся моделью основных бизнес-концепций. Термин был придуман Эриком Эвансом, и существует специальный сайт DDD, который способствует его использованию. Согласно их определению ( Глоссарий терминов проектирования на основе доменов ), DDD — это подход к разработке программного обеспечения, который предполагает, что:

  1. для большинства программных проектов основное внимание должно уделяться домену и доменной логике
  2. сложные проекты доменов должны основываться на модели.

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

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

Очевидно, что DDD и шаблон проектирования состояния тесно связаны. Я новичок в DDD, поэтому я позволю одному из наших лучших партнеров по JCG , Томашу Нуркевичу , познакомить вас с DDD с примером, в котором используется шаблон State State .

(ПРИМЕЧАНИЕ: оригинальный пост был слегка отредактирован для улучшения читабельности)

Некоторые доменные объекты во многих корпоративных приложениях включают понятие состояния. Государство имеет две основные характеристики:

  • поведение объекта домена (как он реагирует на бизнес-методы) зависит от его состояния
  • бизнес-методы могут изменять состояние объекта, заставляя объект вести себя по-разному после вызова определенного метода.

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

  1. ИМЕЕТСЯ В НАЛИЧИИ
  2. Сдан
  3. ОТСУТСТВУЕТ

Очевидно, что автомобиль, находящийся в состоянии RENTED или MISSING, не может быть арендован в данный момент, и метод rent () должен завершиться неудачно. Но когда автомобиль возвращается и имеет статус ДОСТУПНЫЙ, вызов метода rent () в экземпляре Car должен, кроме того, чтобы помнить клиента, который арендовал автомобиль, изменить статус автомобиля на RENTED. Флаг состояния (возможно, один символ или целое число в вашей базе данных) является примером состояния объектов, так как он влияет на бизнес-методы и наоборот, бизнес-методы могут изменить его.

Теперь подумайте, как бы вы реализовали этот сценарий, который, я уверен, вы видели много раз на работе. У вас есть много бизнес-методов в зависимости от текущего состояния и, возможно, ряда состояний. Если вы любите объектно-ориентированное программирование, вы можете сразу подумать о наследовании и создании классов AvailableCar, RentedCar и MissingCar, расширяющих Car. Это выглядит хорошо, но очень непрактично, особенно когда автомобиль — постоянный объект. И на самом деле этот подход не очень хорошо продуман: меняется не весь объект, а лишь часть его внутреннего состояния — мы не заменяем объект, а только меняем его. Возможно, вы подумали о каскаде if-else-if-else … в каждом методе, выполняющем разные задачи в зависимости от состояния. Не иди туда, поверь мне, это путь в ад обслуживания кода.

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

Поток жизненного цикла прост: когда резервирование создано, оно имеет НОВЫЙ статус (состояние). Затем какое-то уполномоченное лицо может принять бронирование, например, временно зарезервировав место и отправив пользователю электронное письмо с просьбой оплатить бронирование. Затем, когда пользователь выполняет перевод денег, деньги учитываются, распечатывается билет и клиенту отправляется второе электронное письмо.

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

Чтобы решить эту проблему, я не буду объяснять исходный шаблон проектирования GoF State. Вместо этого я представлю свой небольшой вариант этого шаблона, используя возможности перечисления Java . Вместо создания абстрактного класса / интерфейса для абстракции состояний и написания реализации для каждого состояния, я просто создал перечисление, содержащее все доступные состояния / состояния:

1
2
3
4
5
6
public enum ReservationStatus {
 NEW,
 ACCEPTED,
 PAID,
 CANCELLED;
}

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

1
2
3
4
5
public interface ReservationStatusOperations {
 ReservationStatus accept(Reservation reservation);
 ReservationStatus charge(Reservation reservation);
 ReservationStatus cancel(Reservation reservation);
}

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

01
02
03
04
05
06
07
08
09
10
public class Reservation {
 private int id;
 private String name;
 private Calendar date;
 private BigDecimal price;
 private ReservationStatus status = ReservationStatus.NEW;
 
 //getters/setters
 
}

Если Reservation является постоянным доменным объектом, его статус (ReservationStatus), очевидно, также должен быть постоянным. Это наблюдение приводит нас к первому большому преимуществу использования enum вместо абстрактного класса: JPA / Hibernate может легко сериализовать и сохранять перечисления Java в базе данных, используя имя или порядковый номер enum (по умолчанию). В исходном паттерне GoF мы бы предпочли поместить ReservationStatusOperations непосредственно в объект домена и переключать реализации при изменении состояния. Я предлагаю использовать enum и изменять только значение enum. Другое (менее рамочное и более важное) преимущество использования enum заключается в том, что все возможные состояния перечислены в одном месте. Вам не нужно сканировать исходный код в поисках всех реализаций базового класса State — все можно увидеть в одном списке, разделенном запятыми.

Хорошо, сделайте глубокий вдох, теперь я объясню, как все части работают вместе и почему, на практике, бизнес-операции в ReservationStatusOperations возвращают ReservationStatus. Во-первых, вы должны вспомнить, что на самом деле перечисления. Они не просто набор констант в одном пространстве имен, как в C / C ++. В Java enum — это скорее закрытый набор классов, которые наследуются от общего базового класса (например, ReservationStatus), который, в свою очередь, наследуется от Enum . Поэтому, используя перечисления, мы можем воспользоваться преимуществами полиморфизма и наследования:

01
02
03
04
05
06
07
08
09
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
public enum ReservationStatus implements ReservationStatusOperations {
 
NEW {
 public ReservationStatus accept(Reservation reservation) {
 //..
 }
 
 public ReservationStatus charge(Reservation reservation) {
 //..
 }
 
 public ReservationStatus cancel(Reservation reservation) {
 //..
 }
},
 
ACCEPTED {
 public ReservationStatus accept(Reservation reservation) {
 //..
 }
 
 public ReservationStatus charge(Reservation reservation) {
 //..
 }
 
 public ReservationStatus cancel(Reservation reservation) {
 //..
 }
},
 
PAID {/*...*/},
 
CANCELLED {/*...*/};
 
}

Хотя соблазнительно писать ReservationStatusOperations таким образом, это плохая идея для долгосрочной разработки. Не только исходный код enum будет очень длинным (общее количество реализованных методов будет равно количеству состояний, умноженному на целый ряд бизнес-методов), но и плохо спроектированным (бизнес-логика для всех состояний в одном классе). Кроме того, enum, реализующий интерфейс вместе с остальным этим причудливым синтаксисом, может быть нелогичным для тех, кто не сдал экзамен SCJP в последние 2 недели. Вместо этого мы обеспечим простой уровень косвенности, потому что « любая проблема в информатике может быть решена с помощью другого уровня косвенности ».

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public enum ReservationStatus implements ReservationStatusOperations {
 
 NEW(new NewRso()),
 ACCEPTED(new AcceptedRso()),
 PAID(new PaidRso()),
 CANCELLED(new CancelledRso());
 
 private final ReservationStatusOperations operations;
 
 ReservationStatus(ReservationStatusOperations operations) {
  this.operations = operations;
 }
 
 @Override
 public ReservationStatus accept(Reservation reservation) {
  return operations.accept(reservation);
 }
 
 @Override
 public ReservationStatus charge(Reservation reservation) {
  return operations.charge(reservation);
 }
 
 @Override
 public ReservationStatus cancel(Reservation reservation) {
  return operations.cancel(reservation);
 }
 
}

Это последний исходный код для нашего перечисления ReservationStatus (реализация ReservationStatusOperations не требуется). Проще говоря: каждое значение перечисления имеет свою собственную реализацию ReservationStatusOperations (Rso для краткости). Эта реализация передается в качестве аргумента конструктора и присваивается конечному полю с именем операции. Теперь всякий раз, когда бизнес-метод вызывается в enum, он делегируется реализации ReservationStatusOperations, посвященной этому enum:

1
2
ReservationStatus.NEW.accept(reservation);       // will call NewRso.accept()
ReservationStatus.ACCEPTED.accept(reservation);  // will call AcceptedRso.accept()

Последняя часть головоломки — это объект домена Reservation, включая бизнес-методы:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
public void accept() {
  setStatus(status.accept(this));
}
 
public void charge() {
  setStatus(status.charge(this));
}
 
public void cancel() {
  setStatus(status.cancel(this));
}
 
public void setStatus(ReservationStatus status) {
  if (status != null && status != this.status) {
     log.debug("Reservation#" + id + ": changing status from " +
  this.status + " to " + status);
     this.status = status;
  }

Что здесь происходит? При вызове любого бизнес-метода в экземпляре объекта домена Reservation соответствующий метод вызывается для значения перечисления ReservationStatus. В зависимости от текущего состояния будет вызываться другой метод (с другой реализацией ReservationStatusOperations). Но здесь нет случая переключения или конструкции if-else, только чистый полиморфизм. Например, если вы вызываете обвинение (), когда поле состояния указывает на ReservationStatus.ACCEPTED, вызывается AcceptedRso.charge (), с клиента, который сделал резервирование, будет снята оплата, и статус бронирования изменится на PAID.

Но что произойдет, если мы вызовем обвинение () снова в том же экземпляре? Поле состояния теперь указывает на ReservationStatus.PAID, поэтому будет выполнен PaidRso.charge (), что приведет к возникновению бизнес-исключения (взимание платы за уже оплаченное бронирование является недействительным). Без условного кода мы реализовали объектный объект с поддержкой состояния с бизнес-методами, включенными в сам объект.

Одна вещь, которую я еще не упомянул, это как изменить статус с бизнес-метода. Это второе отличие от оригинального шаблона GoF. Вместо того чтобы передавать экземпляр StateContext каждой операции, учитывающей состояние (например, accept () или charge ()), которую можно использовать для изменения статуса, я просто возвращаю новый статус из бизнес-метода. Если состояние не равно нулю и отличается от предыдущего (метод setStatus ()), резервирование переходит к данному состоянию. Давайте посмотрим, как он работает с объектом AcceptedRso (его методы выполняются, когда Reservation находится в состоянии ReservationStatus.ACCEPTED):

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
public class AcceptedRso implements ReservationStatusOperations {
 
 @Override
 public ReservationStatus accept(Reservation reservation) {
  throw new UnsupportedStatusTransitionException("accept", ReservationStatus.ACCEPTED);
 }
 
 @Override
 public ReservationStatus charge(Reservation reservation) {
  //charge client's credit card
  //send e-mail
  //print ticket
  return ReservationStatus.PAID;
 }
 
 @Override
 public ReservationStatus cancel(Reservation reservation) {
  //send cancellation e-mail
  return ReservationStatus.CANCELLED;
 }
 
}

За поведением Reservation в статусе ACCEPTED можно легко следить, просто прочитав приведенный выше класс: попытка принять во второй раз (когда резервирование уже принято) вызовет исключение, при зарядке будет снята кредитная карта клиента, распечатана его заявка и отправка электронной почты и т. д. Кроме того, при начислении платы возвращается статус ОПЛАТА, в результате чего резервирование переходит в это состояние. Это означает, что другой вызов поручить () будет обработан другой реализацией ReservationStatusOperations (PaidRso) без условного кода.

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

Я не показывал все реализации ReservationStatusOperations, но если вы хотите представить этот подход в своем приложении Java EE на основе Spring или EJB, вы, вероятно, заметили большую ложь. Я прокомментировал, что должно происходить в каждом бизнес-методе, но не предоставил реальных реализаций. Я этого не сделал, потому что столкнулся с большой проблемой: экземпляр Reservation создается вручную (с использованием new) или средой персистентности, такой как Hibernate. Он использует статически созданный enum, который создает вручную реализации ReservationStatusOperations. Невозможно внедрить в эти классы какие-либо зависимости, DAO и сервисы, поскольку их жизненный цикл контролируется вне контекста контейнера Spring или EJB. На самом деле, есть простое, но мощное решение, использующее Spring и AspectJ. Но наберитесь терпения, я скоро объясню это подробнее в следующем посте, добавив в наше приложение некоторый предметный аспект.

Вот и все. Очень интересный пост о том, как использовать модель состояния в рамках подхода DDD от нашего партнера по JCG Томаша Нуркевича . Я определенно с нетерпением жду следующей части этого урока, которая будет размещена на JavaCodeGeeks через несколько дней. ОБНОВЛЕНИЕ: Следующая часть — Проект, управляемый доменом, со Spring и AspectJ .