State machines в Rails: как объект проходит жизненный цикл
Статья для тех, кто не писал на Ruby, но хочет понять, как в Rails-приложениях описывают жизненный цикл бизнес-объектов и почему это почти всегда конечный автомат.
Примеры упрощены до сути. Домен взят самый понятный - заказ в интернет-магазине.
Начнём с боли
Почти в каждом приложении есть объект, который живёт не мгновение, а неделями. Заказ. Подписка. Заявка на отпуск. Статья в редакции. Такой объект проходит через фазы, и в базе для этого заводят колонку status или state.
Первая версия кода всегда выглядит так:
order.state = 'complete'
order.save
Работает. Проблемы приходят позже, и их три.
Первая: нет запрета на невозможные переходы. Пустая корзина может стать завершённым заказом. Отменённая подписка - снова активной. Приложение не знает, что так нельзя, потому что вы нигде этого не написали. Знание живёт в голове разработчика, а голов со временем становится больше.
Вторая: побочные эффекты расползаются. Завершение заказа должно выписать счёт, списать товар со склада, отправить письмо. Первый раз вы пишете это в контроллере. Потом появляется админка, где менеджер завершает заказ руками. Потом - импорт из внешней системы. Потом - rake-задача для миграции старых данных. Четыре места, где надо не забыть про письмо. Забудут в третьем.
Третья: непонятно, откуда объект пришёл. Строка 'canceled' в базе не хранит, была отмена до оплаты или после. А бизнес это различает.
Конечный автомат (finite state machine, FSM) - это способ записать все три вещи в одном месте. Он состоит из четырёх понятий:
- состояния - конечный список фаз;
- события - действия, запускающие смену фазы;
- переходы - правила «из состояния A по событию X можно попасть в B»;
- реакции - код, выполняемый в момент перехода.
Вот как выглядит жизненный цикл заказа, который мы будем разбирать дальше:
stateDiagram-v2
[*] --> cart
cart --> payment: checkout
payment --> complete: pay
cart --> canceled: cancel
payment --> canceled: cancel
complete --> canceled: cancel if cancelable?
complete --> [*]
canceled --> [*]
Дальше по тексту - как это выглядит в Rails, что происходит под капотом и где на этом обычно обжигаются.
Три способа хранить состояние в Rails
Прежде чем брать гем, стоит знать альтернативы. Их фактически три.
Строковая колонка и константы. Самый простой вариант, никаких зависимостей.
class Order < ApplicationRecord
STATES = %w[cart address payment complete canceled].freeze
validates :state, inclusion: { in: STATES }
end
Даёт список допустимых значений, но не даёт ни правил перехода, ни реакций. Годится, если фаз три и переходы очевидны.
enum из самого Rails. Встроенный механизм, появился в Rails 4.1.
class Order < ApplicationRecord
enum state: { cart: 0, payment: 1, complete: 2, canceled: 3 }
end
Вы бесплатно получаете order.complete?, order.complete!, скоуп Order.complete и красивый маппинг «символ в коде - число в базе» (документация). Но enum знает только про значения. Он с удовольствием переведёт заказ из cart сразу в complete, потому что понятия «переход» у него нет.
Гем с конечным автоматом. Тут есть из чего выбирать, и выбор влияет на то, как будет выглядеть весь код модели:
| Гем | Чем интересен |
|---|---|
state_machines-activerecord |
Наследник исторического state_machine, самого первого популярного гема в этой нише. Богатый DSL, состояние в колонке модели. Часто встречается в старых проектах и в Spree/Solidus. |
aasm |
Сегодня самый распространённый выбор для нового кода. Тот же набор идей, чуть более лаконичный синтаксис, живая поддержка. |
statesman |
От платёжной компании GoCardless. Главное отличие: каждый переход пишется отдельной строкой в таблицу переходов, а текущее состояние - это последняя запись. История изменений получается бесплатно, что ценно в финансовых системах. |
workflow |
Ветеран, компактный и с минимумом магии. Встречается редко, но код читается легко. |
Дальше я показываю синтаксис state_machines, потому что он самый «разговорчивый» и на нём лучше видно устройство DSL. Всё сказанное переносится на aasm почти дословно - меняются только имена ключевых слов.
| Задача | enum |
гем-автомат |
|---|---|---|
| Список допустимых значений | да | да |
Методы-вопросы complete? |
да | да |
Скоупы Order.complete |
да | да (в state_machines тоже есть) |
| Правила «откуда куда можно» | нет | да |
| Условия на переход | нет | да |
| Код на момент перехода | нет (только общие callbacks модели) | да |
Практический совет: если у объекта есть слово «нельзя» в описании («нельзя отменить после отправки») - берите автомат. Если состояние это просто метка для фильтра в списке - хватит enum.
Где физически живёт состояние
Здесь никакой магии, и это важно понять сразу. Состояние - обычный столбец:
CREATE TABLE orders (
id bigserial PRIMARY KEY,
state character varying(255),
...
);
Гем читает и пишет ту же самую колонку, что и вы. Ни отдельной таблицы, ни хитрого формата. Разница только в дисциплине: писать напрямую вы больше не должны. Вместо order.state = 'complete' вы вызываете событие, а гем сначала проверяет, разрешён ли переход, и только потом меняет значение.
Отсюда сразу два вывода, которые пригодятся:
Order.where(state: 'complete')продолжает работать как обычный SQL-запрос. Автомат ничего не ломает в запросах.order.update_column(:state, 'complete')обойдёт автомат целиком. Это иногда нужно (миграции данных, фикстуры), но об этом отдельно в разделе про грабли.
Исключение - statesman: там текущее состояние вычисляется из таблицы переходов. Для остальных гемов из списка выше картина ровно как на схеме выше - одна колонка, одно значение.
Ruby DSL: что это вообще такое
Вот минимальный автомат целиком:
class Order < ApplicationRecord
state_machine :state, initial: :cart do
event :checkout do
transition cart: :payment
end
event :pay do
transition payment: :complete
end
event :cancel do
transition [:cart, :payment, :complete] => :canceled, if: :cancelable?
end
after_transition to: :complete, do: :send_confirmation
end
end
Читается почти как техническое задание: «заказ начинается в корзине; событие checkout переводит его из корзины в оплату; pay - из оплаты в завершение; cancel переводит в отменённый из любой из трёх фаз, если объект отвечает, что отмена разрешена; после попадания в complete отправляем подтверждение».
Для человека, пришедшего из Java, C#, Go или TypeScript, здесь надо понять одну вещь, и она снимает 80% недоумения при чтении Ruby-кода:
Это не специальный синтаксис языка и не конфигурационный файл. Это обычные вызовы методов.
Разберём построчно, что происходит на самом деле:
| Строка в DSL | Что это на самом деле |
|---|---|
state_machine :state, initial: :cart do ... end |
вызов метода state_machine с двумя аргументами (символ и хеш {initial: :cart}) и блоком кода |
:cart |
символ (Symbol) - неизменяемая строка-идентификатор, аналог enum-константы; дешевле обычной строки |
event :checkout do ... end |
вызов метода event, блок исполняется в контексте объекта-строителя события |
transition cart: :payment |
вызов метода transition с хешем; ключ - исходное состояние, значение - целевое |
[:cart, :payment] => :canceled |
тот же хеш, но ключ - массив: «из любого из этих» |
if: :cancelable? |
символ вместо функции: «когда придёт время, вызови метод с таким именем на объекте» |
after_transition to: :complete, do: :send_confirmation |
ещё один вызов метода, регистрирующий обработчик |
Работает это благодаря двум свойствам Ruby, которых нет в большинстве привычных языков:
Скобки при вызове метода необязательны. puts "hi" и puts("hi") - одно и то же. Поэтому event :checkout выглядит как ключевое слово, а на деле это event(:checkout).
Любому методу можно передать блок кода. Блок - это кусок кода в do ... end или { ... }, который метод может выполнить когда захочет и в каком захочет контексте. Гем принимает ваш блок и исполняет его на своём объекте-строителе, поэтому внутри блока внезапно доступны методы event и transition, которых у модели нет.
Из этих двух свойств и вырастают DSL - «языки внутри языка». Вы их уже видели, даже если не знали названия: has_many :comments, validates :email, presence: true, before_action :authenticate - всё это ровно такие же обычные вызовы методов.
Практический вывод для чтения незнакомого Ruby-кода: если строка непонятна, спрашивайте не «что это за синтаксис», а «какой метод здесь вызывается и с какими аргументами». Ответ почти всегда находится в документации гема, а не в справочнике по языку.
Что гем пишет за вас
Объявив автомат, вы получаете набор методов, которых нет в исходниках. Они создаются во время загрузки класса - это называется метапрограммированием, и в Rails оно повсюду.
Для события pay и состояния complete появятся:
| Метод | Что делает |
|---|---|
order.complete? |
предикат - проверка текущего состояния, true или false |
order.pay |
пытается выполнить переход; при неудаче возвращает false и ничего не меняет |
order.pay! |
то же, но при неудаче бросает исключение |
order.can_pay? |
можно ли выполнить событие прямо сейчас, не выполняя его |
order.state_transitions |
список доступных отсюда переходов |
Order.with_state(:complete) |
скоуп для запроса |
Три замечания, каждое из которых экономит час недоумения.
Про восклицательный знак. pay! и pay - два разных метода, ! это часть имени, а не оператор. В Ruby есть соглашение: версия с ! опаснее версии без него. Что именно значит «опаснее», зависит от библиотеки. Здесь - «бросит исключение вместо тихого false». В ActiveRecord save! против save - ровно та же пара. Выбор простой: pay! там, где невозможность перехода это баг и надо падать; pay там, где это нормальный сценарий и вы проверяете результат.
Про grep. Поиск по проекту def pay! не найдёт ничего. Метод не написан, он сгенерирован. Для новичка в Ruby это самая частая точка растерянности: метод вызывается, работает, а определения нет. Лечится привычкой: не нашли определение - ищите декларацию (state_machine, has_many, enum, delegate), которая его породила. Помогает и консоль: order.method(:pay!).source_location покажет, из какого файла гема метод пришёл.
Про can_pay?. Самый недооценённый метод из списка. Он отвечает на вопрос «получится ли», не делая попытки, и потому идеален для двух вещей: показать или спрятать кнопку в интерфейсе, и проверить актуальность в фоновой задаче.
class OrderPaymentJob
def perform(order_id)
order = Order.find(order_id)
# между постановкой в очередь и исполнением прошло время,
# заказ могли отменить руками в админке
return unless order.can_pay?
order.pay!
end
end
Это классическая проблема одной фоновой задачи: между постановкой в очередь и исполнением проходит время, за которое мир меняется. Автомат даёт дешёвый способ проверить, что задача всё ещё имеет смысл.
Ловушка с похожими именами
Автомат генерирует предикат по имени состояния. Разработчики параллельно пишут свои предикаты руками. Имена получаются похожими, а смысл разный:
# сгенерирован автоматом: state == 'complete'
order.complete?
# написан руками где-то в модели: заполнена ли колонка с датой
def completed?
completed_at.present?
end
Обычно они совпадают, но это два разных источника правды, и в момент рассинхронизации вы будете долго смотреть на код. Правило чтения: увидели предикат - сначала выясните, рукописный он или сгенерированный. Если grep не нашёл def - это состояние.
Guard-условия: где живёт слово «нельзя»
Guard (сторожевое условие) - это проверка, привязанная к переходу. Если она вернула ложь, перехода не будет.
event :cancel do
transition [:cart, :payment, :complete] => :canceled, if: :cancelable?
end
def cancelable?
return false if canceled?
shipment.nil? || shipment.pending?
end
Бизнес-правило «нельзя отменить заказ, который уже уехал» записано ровно один раз. Дальше оно действует везде: в контроллере, в админке, в API, в rake-задаче. Никто не может случайно обойти его, потому что обходить нечего - проверка встроена в сам переход.
Что происходит при неудаче:
order.cancel # => false, состояние не изменилось
order.cancel! # => исключение StateMachines::InvalidTransition
order.reload.state # => 'complete', как и было
Guard можно задать двумя способами, и они взаимозаменяемы:
transition complete: :canceled, if: :cancelable? # символ = имя метода
transition complete: :canceled, if: ->(order) { order.paid? } # лямбда
Лямбда - это анонимная функция как объект: её можно положить в переменную, передать аргументом и вызвать позже. Гем сохраняет её при загрузке класса и дёргает в момент попытки перехода. Символ по сути то же самое, просто короче; используйте лямбду, когда условие одноразовое, и метод, когда его хочется переиспользовать или протестировать отдельно.
Важное различие, которое часто путают:
- валидация отвечает на вопрос «данные корректны?» (
validates :email, presence: true); - guard отвечает на вопрос «это действие применимо к объекту в его текущем состоянии?».
Заказ без адреса доставки невалиден. Заказ, который уже уехал, вполне валиден - просто его нельзя отменить. Разные вопросы, разные механизмы.
Отдельно стоит заметить: у события cancel в примере перечислены исходные состояния, но их можно и не указывать.
event :cancel do
transition to: :canceled, if: :cancelable?
end
Тогда перейти можно из любого состояния, а всю фильтрацию делает guard. Так пишут, когда список исходных состояний длинный и его неудобно поддерживать. Цена - правило перестаёт быть видно в декларации, надо идти читать метод.
Реакции на переход: before и after
Callbacks - это «а что произойдёт при». Их два вида, и разница между ними принципиальная.
state_machine :state, initial: :cart do
before_transition to: :complete do |order|
order.charge_payment! # вернул false -> перехода не будет
end
after_transition to: :complete, do: :send_confirmation
after_transition to: :canceled, do: :restock_items
end
before_transition выполняется до смены состояния и может её отменить: если блок вернул false, переход не состоится, объект останется как был. Сюда кладут то, без чего переход не имеет смысла. Списание денег - каноничный пример: нет оплаты, нет завершённого заказа.
after_transition выполняется после того, как состояние уже изменилось, и отменить переход не может. Сюда кладут последствия: письма, документы, уведомления в очередь.
sequenceDiagram
participant App as order.pay!
participant Guard as guard / before
participant DB as колонка state
participant After as after_transition
App->>Guard: can_pay? и before_transition
alt before вернул false
Guard-->>App: переход отменён
else всё ок
Guard->>DB: payment -> complete
DB->>After: finalize, invoice, enqueue...
After-->>App: готово (внутри той же транзакции)
end
Простое правило выбора: если ответ на вопрос «а если это упадёт, переход должен отмениться?» - да, значит before. Если нет - after.
Порядок и невидимые подписчики
Callbacks выполняются в порядке регистрации. Пока их два, это не важно. Проблема появляется в живом проекте, где на один и тот же переход подписываются из разных файлов - из модели, из подключённых модулей (concerns), из декораторов сторонних гемов:
# order.rb
after_transition to: :complete, do: :finalize!
# order/invoiceable.rb (concern)
after_transition to: :complete, do: :create_invoice
# order/loyalty.rb (concern)
after_transition to: :complete, do: :award_points
Формально всё нормально. Практически - добавляя пятый callback, вы не видите четырёх остальных. Порядок их исполнения зависит от порядка загрузки файлов, а он в Rails не всегда очевиден. Чтобы узнать, что реально произойдёт при завершении заказа, приходится делать grep по after_transition и складывать картину в голове.
Это не повод не использовать callbacks. Это повод держать их количество под контролем и время от времени спрашивать себя: то, что я добавляю, - неотъемлемое следствие перехода или отдельный бизнес-процесс, который просто начинается в этот момент? Первое - callback. Второе лучше вызвать явно из сервисного объекта, где всю последовательность видно глазами.
Сквозной сюжет: оплата, счёт, письмо
Соберём типичный маршрут целиком. Он встречается почти в каждом магазине, и на нём удобно показать главную проблему связки «автомат плюс фоновые задачи».
Клиент вернулся от платёжного шлюза. Контроллер делает две строки:
payment.complete!
order.pay!
Дальше запускается цепочка, которую полезно один раз увидеть целиком:
flowchart TD
A[Клиент вернулся от платёжного шлюза] --> B["контроллер: order.pay!"]
B --> TX
subgraph TX["Транзакция БД"]
direction TB
C["before_transition to: complete<br/>charge_payment!"]
C -->|false| X[переход отменён]
C -->|ok| D["UPDATE state:<br/>payment -> complete"]
D --> E["after_transition"]
E --> E1[finalize!]
E --> E2[create_invoice]
E --> E3[reserve_stock]
E --> E4["enqueue email ← ОПАСНОЕ МЕСТО"]
end
TX --> F[COMMIT]
F --> G[Sidekiq: другой процесс, другое соединение]
G --> H[ConfirmationEmailJob]
H --> I["Order.find(id) - увидит ли он заказ?"]
I --> J[письмо у клиента]
Ключевой момент диаграммы - рамка транзакции. Переход состояния и все его callbacks выполняются внутри одной транзакции БД. Пока не случился COMMIT, изменений для внешнего мира не существует.
А Sidekiq - это внешний мир. Отдельный процесс, отдельное соединение с базой, отдельная очередь в Redis. Из этого следует главная ловушка всей темы, ей посвящён следующий раздел.
Что не стоит копировать бездумно
Дальше - три вещи, которые в реальных проектах встречаются регулярно, работают годами и при этом являются компромиссами, а не образцами. Если вы пишете новый код, смотрите на них внимательно.
1. Побочные эффекты внутри транзакции перехода
Это ошибка номер один, и она коварна тем, что на машине разработчика не воспроизводится.
after_transition to: :complete do |order|
ConfirmationEmailJob.perform_async(order.id) # плохо
end
Что происходит: perform_async кладёт задачу в Redis мгновенно. Транзакция БД ещё открыта. Sidekiq-воркер в другом процессе просыпается через миллисекунды, делает Order.find(id) со своим соединением и не видит незакоммиченных изменений. В лучшем случае он прочитает старое состояние и отправит письмо «ваш заказ оплачен» про заказ, который в базе ещё в статусе payment. В худшем - если запись создавалась в этой же транзакции - получит ActiveRecord::RecordNotFound.
sequenceDiagram
participant C as Controller
participant DB as Транзакция БД
participant R as Redis
participant W as Sidekiq worker
C->>DB: order.pay!
Note over DB: state уже complete в этой транзакции
DB->>R: perform_async(order.id)
R->>W: job стартует слишком рано
W->>DB: Order.find(id)
Note over W,DB: worker не видит uncommitted данные
DB-->>C: COMMIT
Локально этого не видно: на пустой базе транзакция коммитится за микросекунды, а часто Sidekiq в разработке вообще работает синхронно. Ошибка вылезает в production под нагрузкой, плавает и выглядит необъяснимой.
Первое, что придумывают, столкнувшись с проблемой, - задержка:
after_transition to: :complete do |order|
ConfirmationEmailJob.perform_at(30.seconds.from_now, order.id) # тоже плохо
end
Иногда такое живёт в проектах годами. 30 секунд здесь не бизнес-требование, а запас времени на коммит. Это гонка с таймером: под нагрузкой длинная транзакция может не уложиться, а в нормальном случае клиент лишние полминуты ждёт письмо без всякой причины. Если видите в чужом коде необъяснимую задержку перед постановкой задачи - вы почти наверняка смотрите на этот обход.
Правильных решений два.
after_commit - хук ActiveRecord, срабатывающий гарантированно после успешного коммита. В callback перехода ставим только флаг в памяти объекта, а в очередь отправляем уже снаружи транзакции:
class Order < ApplicationRecord
state_machine :state, initial: :cart do
after_transition to: :complete do |order|
# не ставим задачу здесь: транзакция ещё не закоммичена
order.instance_variable_set(:@send_confirmation_after_commit, true)
end
end
after_commit :enqueue_confirmation
private
def enqueue_confirmation
return unless @send_confirmation_after_commit
@send_confirmation_after_commit = false
ConfirmationEmailJob.perform_async(id)
end
end
Выглядит многословнее, чем одна строка с perform_async, зато работает детерминированно.
Транзакционная очередь. Если очередь хранится в той же базе, что и данные (solid_queue в Rails 8, good_job, delayed_job), задача записывается той же транзакцией и проблема исчезает по построению: не закоммитилось - значит и задачи нет. Это одна из главных причин, по которым проекты уходят с Sidekiq на очередь в Postgres.
Правило: из callback перехода задача в Redis-очередь ставится только через after_commit.
У той же транзакции есть вторая грань: исключение внутри after_transition откатит и сам переход.
after_transition to: :complete, do: :generate_pdf_invoice # рискованно
Упала генерация PDF - заказ не завершился. Клиент заплатил, а в базе состояние payment. Сбой второстепенной функции уронил основную. Вывод тот же самый: чем дальше побочный эффект от транзакции, тем меньше он может ей навредить. Вынесенная через after_commit фоновая задача решает обе проблемы разом.
Два смежных правила про сами задачи, раз уж мы здесь:
- Передавайте
id, а не объект. Во-первых, в очередь сериализуется только JSON (числа, строки, массивы, хеши). Во-вторых, объект, пролежавший минуту в очереди, всё равно устарел - воркер должен читать свежие данные. - Задача должна быть готова выполниться дважды. Очереди гарантируют «хотя бы один раз», а не «ровно один раз», и вдобавок Sidekiq по умолчанию повторяет упавшую задачу до 25 раз. Простейшая защита от дубля письма - колонка с датой отправки и проверка перед отправкой.
2. Прямая запись в колонку состояния
order.update_column(:state, 'complete') # мимо автомата
Это не всегда ошибка. В миграции данных или в фабрике для теста расчёта итогов - ровно то, что нужно: быстро получить объект в нужном состоянии без побочных эффектов.
Но два следствия надо держать в голове.
Ни один callback не сработает. Заказ станет завершённым, а счёта и письма не будет. В тестах это иногда создаёт ложное ощущение, что переход протестирован: состояние в базе правильное, а перехода не было.
Проверка допустимости не сработает тоже. Так можно получить объект в комбинации, которую процесс не предусматривал, и обнаружить это через полгода в отчёте.
Практическое правило: прямая запись допустима там, где вы готовите данные, и недопустима там, где вы обрабатываете бизнес-событие.
3. Автомат как замена сервисному слою
Последнее - не про конкретный код, а про меру. Автомат хорош, пока описывает жизненный цикл. Он становится плох, когда в него въезжает весь бизнес-процесс.
Симптомы, что пора остановиться: у одного перехода десяток подписчиков; порядок callbacks приходится задавать вручную; в guard одного callback появляется проверка на результат другого; чтобы понять, что делает order.pay!, надо открыть шесть файлов.
Лечение обычно одно: оставить в автомате смену состояния и то, что от неё неотделимо, а остальное собрать в явный сервисный объект, где последовательность шагов видно сверху вниз:
class CompleteOrder
def call(order)
order.pay! # автомат отвечает только за состояние
InvoiceCreator.new.call(order)
StockReserver.new.call(order)
ConfirmationEmailJob.perform_async(order.id)
end
end
Читается за десять секунд, отлаживается пошагово, тестируется по частям. Скучно - и в этом достоинство.
Как это проверить тестом
Тесты на автомат делятся на три группы, и все три проверяют наблюдаемое поведение, а не внутреннее устройство.
Первое: переход разрешён из правильных состояний. Самый дешёвый тест, побочных эффектов нет вообще:
RSpec.describe Order do
it 'allows cancelling a fresh order' do
order = create(:order, state: 'payment')
expect(order.can_cancel?).to be true
end
end
Второе: guard действительно блокирует. Проверяем и возвращаемое значение, и то, что состояние не поехало:
it 'refuses to cancel a shipped order' do
order = create(:order, :shipped)
expect(order.cancel).to be false
expect(order.reload.state).to eq('complete')
end
reload здесь не формальность: он перечитывает объект из базы и доказывает, что в базе тоже ничего не изменилось, а не только в памяти.
Третье: побочный эффект перехода произошёл. Главный тест сюжета этой статьи:
RSpec.describe Order do
describe 'transition to complete' do
let(:order) { create(:order, :ready_for_payment) }
it 'creates an invoice' do
expect { order.pay! }.to change { order.invoices.count }.by(1)
end
it 'enqueues the confirmation email' do
expect { order.pay! }
.to change(ConfirmationEmailJob.jobs, :size).by(1)
end
end
end
Три замечания к этому набору.
Про фабрику. :ready_for_payment должна собрать заказ, который реально может перейти в complete: с позициями, адресом, доставкой и платежом. Соблазн сократить до create(:order, state: 'payment') велик, но тогда guard упрётся в отсутствующие данные, и тест будет проверять не то, что вы думаете. Заказ, состояние которому проставили напрямую, для теста перехода бесполезен: перехода не было.
Про change. Матчер expect { ... }.to change { ... }.by(1) измеряет значение до и после блока. Он проверяет результат, а не способ его достижения, поэтому переживёт рефакторинг внутренностей.
Чего делать не надо. Не проверяйте, что был вызван внутренний метод, и не мокайте воркер:
# не надо: тест привязан к текущей реализации
expect(order).to receive(:create_invoice)
expect(InvoiceJob).to receive(:perform_async)
Через год цепочка изменится - счёт будет создавать другой класс, задача поедет через after_commit - и такой тест сломается, хотя поведение осталось правильным. Проверяйте то, что видит внешний мир: строку в базе, размер очереди, тело ответа.
Глоссарий
| Термин | Значение |
|---|---|
| State machine (конечный автомат) | Модель процесса: конечный набор состояний плюс правила перехода между ними. В Rails реализуется гемами state_machines-activerecord или aasm. |
| State (состояние) | Именованная фаза жизни объекта: cart, complete, canceled. Обычно хранится строкой в колонке БД. |
| Event (событие) | Именованное действие, запускающее переход: pay, cancel. Порождает методы pay, pay!, can_pay?. |
| Transition (переход) | Правило «из состояний A, B можно попасть в C», объявляется внутри события. Не описанный правилом переход невозможен. |
| Guard (сторожевое условие) | Условие if: / unless: на переходе. Ложно - перехода не будет. Отвечает на вопрос «применимо ли действие», в отличие от валидации, которая проверяет корректность данных. |
| Predicate (предикат) | Метод-вопрос, возвращающий true/false: complete?. Сгенерированные автоматом предикаты не найти поиском по def. |
Bang-метод (pay!) |
Соглашение Ruby: версия метода, которая при неудаче бросает исключение вместо возврата false. Восклицательный знак - часть имени, а не оператор. |
| Callback | Код, привязанный к моменту перехода. before_transition выполняется до смены состояния и может её отменить; after_transition - после, отменить не может. |
| DSL (domain-specific language) | «Язык внутри языка». В Ruby - обычные вызовы методов с блоками, которые за счёт необязательных скобок читаются как декларативное описание. |
Symbol (:complete) |
Неизменяемый идентификатор-строка. Дешевле обычной строки, используется как имя состояния, события или метода. |
| Lambda / Proc | Анонимная функция как объект: можно сохранить в переменную, передать аргументом и вызвать позже. На этом держатся guard-условия. |
| Метапрограммирование | Создание методов во время выполнения. Причина, по которой pay! работает, но его определения нет в коде. |
| Worker / Job (Sidekiq) | Класс с методом perform, исполняемый в отдельном процессе через очередь в Redis. Принимает только JSON-совместимые аргументы, поэтому передают id, а не объект. |
after_commit |
Хук ActiveRecord, срабатывающий после успешного коммита транзакции. Единственное безопасное место для постановки задачи в Redis-очередь. |
Что читать дальше
state_machinesиaasm- синтаксис отличается, идеи одинаковые; полезно пролистать оба README подряд, чтобы отделить концепцию от конкретного API.statesman- посмотрите, даже если не собираетесь использовать: хранение переходов отдельной таблицей меняет взгляд на задачу. Состояние перестаёт быть значением и становится следствием истории.- ActiveRecord::Enum - если после статьи кажется, что автомат вам не нужен, это честный и правильный вывод; начните отсюда.
after_commitи остальные callbacks ActiveRecord - раздел про транзакционные хуки стоит прочитать целиком.- Рекомендуемое продолжение темы: у одного объекта автоматов обычно несколько (
Order,Payment,Shipment), и они не синхронизированы между собой. «Заказ завершён» не значит «деньги получены», а «деньги получены» не значит «посылка уехала». Именно на стыке этих автоматов живут самые дорогие баги.