Паттерн состояния – это один из лучших способов добавить в модели поведение, зависящее от состояния, сохраняя при этом их чистоту.
В этой главе мы поговорим о шаблоне состояния и, в частности, о том, как применять его к моделям. Вы можете рассматривать эту главу как продолжение прошлой главы, где я писал о том, как мы стремимся сделать наши классы моделей управляемыми, не позволяя им обрабатывать бизнес-логику.
Перемещение бизнес-логики из моделей создает проблему, хотя и с очень распространенным случаем использования: Что делать с состояниями модели?
Счет-фактура может быть отложена или оплачена, платеж может быть неудачным или успешным. В зависимости от состояния модель должна вести себя по-разному; как мы можем преодолеть этот разрыв между моделями и бизнес-логикой?
Состояния и переходы между ними часто используются в крупных проектах; настолько часто, что они заслуживают отдельной главы.
Шаблон состояния
По своей сути шаблон состояния-это простой шаблон, но он обеспечивает очень мощную функциональность. Давайте снова возьмем пример счетов-фактур: счет может быть отложен или оплачен. Для начала я приведу очень простой пример, потому что я хочу, чтобы вы поняли, как шаблон состояния позволяет нам большую гибкость.
Скажем, при просмотре счета-фактуры должен показываться значок, представляющий состояние этого счета, он окрашен в оранжевый цвет, когда находится в ожидании, и зеленый, когда оплачен.
Наивный подход жирной модели что-то вроде этого:
class Invoice extends Model
{
// …
public function getStateColour(): string
{
if ($this->state->equals(InvoiceState::PENDING())) {
return 'orange';
}
if ($this->state->equals(InvoiceState::PAID())) {
return 'green';
}
return 'gray';
}
}
Поскольку мы используем какой-то класс перечисления для представления значения состояния, мы могли бы улучшить это следующим образом:
class Invoice extends Model
{
// …
public function getStateColour(): string
{
return $this->state->getColour();
}
}
/**
* @method static self PENDING()
* @method static self PAID()
*/
class InvoiceState extends Enum
{
private const PENDING = 'pending';
private const PAID = 'paid';
public function getColour(): string
{
if ($this->value === self::PENDING) {
return 'orange';
}
if ($this->value === self::PAID) {
return 'green'
}
return 'gray';
}
}
В качестве примечания: я предлагаю в этом случае использовать пакет myclabs/php-enum.
Еще одно предложение для хорошего кода: мы могли бы написать вышеизложенное немного короче, используя массивы.
class InvoiceState extends Enum
{
public function getColour(): string
{
return [
self::PENDING => 'orange',
self::PAID => 'green',
][$this->value] ?? 'gray';
}
}
Какой бы подход вы ни выбрали, в сущности вы перечисляете все доступные варианты, проверяете, соответствует ли один из них текущему и делаете что-то, основываясь на результате.
Используя этот подход, мы добавляем ответственность либо к модели, либо к классу enum: он должен знать, что должно делать конкретное состояние, он должен знать, как работает состояние. Каждое состояние представлено отдельным классом, и каждый из этих классов действует на субъекта.
Трудно понять? Давайте сделаем это шаг за шагом.
Мы начинаем с абстрактного класса InvoiceState
, этот класс описывает все функциональные возможности, которые могут предоставить конкретные состояния счета-фактуры. В нашем случае мы хотим, чтобы состояние обеспечивало цвет.
abstract class InvoiceState
{
abstract public function colour(): string;
}
Далее мы делаем два класса, каждый из которых представляет конкретное состояние.
class PendingInvoiceState extends InvoiceState
{
public function colour(): string
{
return 'orange';
}
}
class PaidInvoiceState extends InvoiceState
{
public function colour(): string
{
return 'green';
}
}
Первое, что нужно заметить, это то, что каждый из этих классов может быть легко протестирован самостоятельно.
class InvoiceStateTest extends TestCase
{
/** @test */
public function the_colour_of_pending_is_orange
{
$state = new PendingInvoiceState();
$this->assertEquals('orange', $state->colour());
}
}
Во-вторых, вы должны заметить, что цвета-это наивный пример, используемый для объяснения. Вы также можете иметь более сложную бизнес-логику, инкапсулированную состоянием. Возьмем такой пример: должен ли быть оплачен счет? Это, конечно, зависит от состояния, был ли он уже оплачен или нет, но также может зависеть от типа счета, с которым мы имеем дело. Скажем, наша система поддерживает кредитные предложения, которые не нужно оплачивать, или она позволяет выставлять счета с ценой 0. Эта бизнес-логика может быть инкапсулирована классами состояний.
Однако для того, чтобы эта функциональность работала, не хватает одной вещи: нам нужно иметь возможность смотреть на модель из нашего класса состояний, если мы собираемся решить, должен ли этот счет быть оплачен. Вот почему у нас есть наш абстрактный InvoiceState
родительский класс; Давайте добавим туда необходимые методы.
abstract class InvoiceState
{
/** @var Invoice */
protected $invoice;
public function __construct(Invoice $invoice) { /* … */ }
abstract public function mustBePaid(): bool;
// …
}
И реализуют их для каждого конкретного состояния.
class PendingInvoiceState extends InvoiceState
{
public function mustBePaid(): bool
{
return $this->invoice->total_price > 0
&& $this->invoice->type->equals(InvoiceType::DEBIT());
}
// …
}
class PaidInvoiceState extends InvoiceState
{
public function mustBePaid(): bool
{
return false;
}
// …
}
Опять же, мы можем написать простые модульные тесты для каждого состояния, и наша модель счета-фактуры может просто сделать это.
class Invoice extends Model
{
public function getStateAttribute(): InvoiceState
{
return new $this->state_class($this);
}
public function mustBePaid(): bool
{
return $this->state->mustBePaid();
}
}
Наконец, в базе данных мы можем сохранить конкретный класс состояния модели в state_class
поле и все готово. Очевидно, что выполнение этого сопоставления вручную (сохранение и загрузка из базы данных и в базу данных) очень быстро становится утомительным. Вот почему я написал пакет, который берет на себя всю тяжелую работу для вас.
Поведение, зависящее от конкретного состояния, другими словами “шаблона состояния” – это только половина решения; нам все еще нужно справиться с переходом состояния счета-фактуры из одного в другое и обеспечить, чтобы только определенные состояния могли переходить в другие. Итак, давайте рассмотрим переходы состояний.
Переходы между состояниями
Помните, как я говорил о том, чтобы отодвинуть бизнес-логику от моделей и позволить им только предоставлять данные в работоспособном виде из базы данных? То же самое мышление можно применить к состояниям и переходам. Мы должны избегать побочных эффектов при использовании состояний, таких как внесение изменений в базу данных, отправка почты и т. д. Состояния должны использоваться для чтения и предоставления данных. С другой стороны, переходы ничего не дают. Скорее, они следят за тем, чтобы наше модельное состояние правильно переходило от одного к другому, приводя к приемлемым побочным эффектам.
Разделение этих двух проблем на отдельные классы дает нам те же преимущества, о которых я писал снова и снова: лучшую тестируемость и меньшую когнитивную нагрузку. Разрешение классу иметь только одну ответственность облегчает разделение сложной задачи на несколько простых для понимания.
Итак, переходы: класс, который возьмет модель, в нашем случае счет-фактуру, и изменит состояние этого счета на другое, если это разрешено. В некоторых случаях могут быть небольшие побочные эффекты, такие как запись журнала сообщений или отправка уведомления о переходе состояния. Минимальная реализация может выглядеть примерно так.
class PendingToPaidTransition
{
public function __invoke(Invoice $invoice): Invoice
{
if (! $invoice->mustBePaid()) {
throw new InvalidTransitionException(self::class, $invoice);
}
$invoice->status_class = PaidInvoiceState::class;
$invoice->save();
History::log($invoice, "Pending to Paid");
}
}
Опять же есть много вещей, которые вы можете сделать с этим базовым шаблоном:
- Определить все разрешенные переходы состояния модели
- Переход из одного состояния непосредственно в другое, используя класс перехода под капотом
- Автоматическое определение состояния перехода на основе набора параметров
Опять же, пакет, о котором я упоминал ранее, добавляет поддержку переходов, а также базовое управление переходами. Однако, если вам нужны сложные машины состояний, вы можете посмотреть на другие пакеты.
Состояния без переходов
Когда мы думаем о состоянии, мы часто думаем, что они не могут существовать без переходов. Однако это не так: объект может иметь состояние, которое никогда не изменяется, и переходы не требуются для применения шаблона состояния. Почему это так важно? Ну, взгляните еще раз на нашу PendingInvoiceState::mustBePaid
реализацию:
class PendingInvoiceState extends InvoiceState
{
public function mustBePaid(): bool
{
return $this->invoice->total_price > 0
&& $this->invoice->type->equals(InvoiceType::DEBIT());
}
}
Поскольку мы хотим использовать шаблон состояния для уменьшения хрупких блоков if / else в нашем коде, можете ли вы догадаться, к чему я клоню? Задумывались ли вы о том, что $this->invoice->type->equals(InvoiceType::DEBIT())
на самом деле это спрятанные условие if?
InvoiceType
на самом деле вполне мог бы также применить шаблон состояния! Это просто состояние, которое, вероятно, никогда не изменится для данного объекта. Взгляните на это.
abstract class InvoiceType
{
/** @var Invoice */
protected $invoice;
// …
abstract public function mustBePaid(): bool;
}
class CreditInvoiceType extends InvoiceType
{
public function mustBePaid(): bool
{
return false
}
}
class DebitInvoiceType extends InvoiceType
{
public function mustBePaid(): bool
{
return true;
}
}
Теперь мы можем отрефакторить наш PendingInvoiceState::mustBePaid
примерно так.
class PendingInvoiceState extends InvoiceState
{
public function mustBePaid(): bool
{
return $this->invoice->total_price > 0
&& $this->invoice->type->mustBePaid();
}
}
Сокращение операторов if / else в нашем коде позволяет этому коду быть более простым, что в свою очередь, облегчает понимание. Я бы очень рекомендовал взглянуть на выступление Санди Метц именно на эту тему.
Шаблон состояния, на мой взгляд, потрясающий. Вы никогда больше не застрянете в написании огромных операторов if/else. В реальной жизни часто существует более двух состояний счета-фактуры и это позволяет создавать чистый и тестируемый код.
Это шаблон, который вы можете постепенно вводить в существующие базы кода и я уверен, что это будет огромной помощью в поддержании проекта в долгосрочной перспективе.