Decider на Rust: decide, evolve, replay и явное время
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Decider на Rust: decide, evolve, replay и явное время
Сцена · команды и события это два разных языка
В прошлом уроке мы собрали словарь значений. Теперь оживим бронь. У неё есть жизненный цикл: её создают, гость заезжает, выезжает, иногда бронь отменяют. И тут возникает различие, которое легко проскочить, но оно несущее.
Команда это запрос в настоящем времени: «засели гостя». Её можно отвергнуть: гость уже выехал, заселять некого. Событие это факт в прошедшем времени: «гость заселён». Его отвергнуть нельзя, оно уже случилось. Из этого различия растёт event sourcing: источник истины это не снимок брони в таблице, а журнал её событий, а текущее состояние, это их свёртка.
Карта урока
- Состояние как конечный автомат.
enum ReservationState, где каждое имя, это одна точка FSM. - Тройка Decider.
decide,evolve,initial, канон Жереми Шассена. - Trait или struct? Решаем, как выразить Decider в Rust, и аргументируем.
- Чистая decide с явным временем. Почему время приходит параметром, а не из часов.
- evolve и replay. Сворачивание стрима в состояние, и закон, который их связывает.
- BDD-тест. given events, when command, then events, без всякой базы.
Раздел 1 · Состояние как конечный автомат
Бронь живёт по строгому маршруту: NotPlaced → Reserved → CheckedIn → CheckedOut, плюс Cancelled из Reserved. Это конечный автомат, и enum в Rust выражает его точно: каждый вариант это вершина, а поля внутри это то, что в этом состоянии известно.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum ReservationState {
#[default]
NotPlaced,
Reserved {
reservation_id: ReservationId,
guest: GuestEmail,
room: RoomNumber,
range: DateRange,
},
CheckedIn {
reservation_id: ReservationId,
guest: GuestEmail,
room: RoomNumber,
range: DateRange,
},
CheckedOut {
reservation_id: ReservationId,
},
Cancelled {
reservation_id: ReservationId,
},
}
Обрати внимание: «отменённая бронь с активным заселением» в этом типе невыразима, такого варианта нет. Невозможные состояния невыразимы, проверять их в рантайме некому. NotPlaced помечен #[default], и пустой стрим стартует с него:
impl ReservationState {
/// Начальное состояние пустого стрима.
pub fn initial() -> Self {
ReservationState::NotPlaced
}
/// Имя текущего варианта, для текста ошибки InvalidStateTransition.
pub fn tag_name(&self) -> &'static str {
match self {
ReservationState::NotPlaced => "NotPlaced",
ReservationState::Reserved { .. } => "Reserved",
ReservationState::CheckedIn { .. } => "CheckedIn",
ReservationState::CheckedOut { .. } => "CheckedOut",
ReservationState::Cancelled { .. } => "Cancelled",
}
}
}
Команды и события тоже enum. События несут occurred_at и сериализуются (они поедут в журнал), команды нет:
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ReservationEvent {
ReservationPlaced { reservation_id: ReservationId, guest: GuestEmail, room: RoomNumber, range: DateRange, occurred_at: i64 },
ReservationCancelled { reservation_id: ReservationId, occurred_at: i64 },
GuestCheckedIn { reservation_id: ReservationId, occurred_at: i64 },
GuestCheckedOut { reservation_id: ReservationId, occurred_at: i64 },
}
Атрибут #[serde(tag = "type")] даёт внутренне-тегированный JSON вида {"type":"ReservationPlaced", ...}, ровно то, что ляжет в jsonb журнала в следующем уроке.
Раздел 2 · Тройка decide, evolve, initial
Жереми Шассен заметил, что за агрегатом, процесс-менеджером и FSM стоит одна минимальная конструкция, Decider: три чистые функции. initial даёт стартовое состояние. decide(state, command) решает, какие события породить или отказать. evolve(state, event) применяет одно событие к состоянию. Из этой тройки собирается всё остальное, а её чистота делает домен тестируемым без базы.
Раздел 3 · Trait или struct? Решаем
В Rust у Decider есть две естественные формы, и выбор стоит проговорить.
Первая: struct из замыканий, по полю на каждую функцию (decide: Box<dyn Fn(...)> и так далее). Это буквальный перенос объекта из языков, где функции, это значения. Минус в Rust ощутимый: замыкания в полях тянут Box<dyn Fn>, динамическую диспетчеризацию и лишние аллокации на ровном месте, а сигнатуры в типе структуры читаются тяжело.
Вторая: трейт с ассоциированными типами. Decider становится интерфейсом, а конкретный домен, его реализацией. Это идиоматичнее: статическая диспетчеризация, ноль аллокаций, а ассоциированные типы Command, State, Event, Error делают контракт самодокументируемым. Выбираем трейт:
/// Обобщённый контракт Decider.
pub trait Decider {
type Command;
type State;
type Event;
type Error;
fn initial(&self) -> Self::State;
/// Чистое решение: по состоянию и команде вернуть новые события либо ошибку.
/// `now` передаётся явно, без скрытых часов.
fn decide(
&self,
state: &Self::State,
command: &Self::Command,
now: Timestamp,
) -> Result<Vec<Self::Event>, Self::Error>;
/// Применить одно событие к состоянию. Тотальная функция без отказов.
fn evolve(&self, state: Self::State, event: &Self::Event) -> Self::State;
}
ReservationDecider это zero-sized структура: вся логика, это чистые функции, состояние держать не нужно, экземпляр не занимает памяти.
#[derive(Debug, Clone, Copy, Default)]
pub struct ReservationDecider;
Выгода трейта проявится сразу в двух местах: replay напишем один раз для любого Decider, а домен сможет иметь несколько реализаций одного паттерна. Это проблема выражения в действии: трейт фиксирует операции, реализации свободно добавляются.
Раздел 4 · Чистая decide с явным временем
Событие несёт occurred_at, значит decide должна откуда-то взять текущее время. И тут принципиальный для тестируемости момент. Если decide дёрнет системные часы внутри, она перестанет быть чистой: один и тот же вход даст разный результат, и тест придётся гонять против живого времени. Поэтому время приходит явным параметром now: Timestamp:
/// Момент времени в миллисекундах UTC, который инжектится в decide.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct Timestamp(pub i64);
impl Timestamp {
pub fn millis(self) -> i64 {
self.0
}
}
Это сознательный приём: явная инъекция времени. Теперь decide это чистая функция от состояния, команды и времени, и тест подставит константу. Сама decide это исчерпывающий match по команде, где каждая ветка проверяет, разрешён ли переход из текущего состояния:
fn decide(
&self,
state: &ReservationState,
command: &ReservationCommand,
now: Timestamp,
) -> Result<Vec<ReservationEvent>, DomainError> {
let occurred_at = now.millis();
match command {
// Place разрешён только из NotPlaced.
ReservationCommand::PlaceReservation { reservation_id, guest, room, range } => match state {
ReservationState::NotPlaced => Ok(vec![ReservationEvent::ReservationPlaced {
reservation_id: reservation_id.clone(),
guest: guest.clone(),
room: room.clone(),
range: *range,
occurred_at,
}]),
_ => Err(Self::invalid_transition(state, command)),
},
// CheckIn разрешён только из Reserved.
ReservationCommand::CheckInGuest { reservation_id } => match state {
ReservationState::Reserved { .. } => Ok(vec![ReservationEvent::GuestCheckedIn {
reservation_id: reservation_id.clone(),
occurred_at,
}]),
_ => Err(Self::invalid_transition(state, command)),
},
// Cancel и CheckOut устроены так же: проверка состояния, потом событие.
// ... (полный код в examples/ddd-hotel/rust/src/domain/decider.rs)
}
}
Отказ это доменная ошибка, и она не та же, что ValueError из прошлого урока. Это DomainError: нарушение бизнес-правила, а не кривой ввод. Текст перехода собирает маленькая фабрика:
fn invalid_transition(state: &ReservationState, command: &ReservationCommand) -> DomainError {
DomainError::InvalidStateTransition {
reservation_id: command.reservation_id().as_str().to_owned(),
from: state.tag_name().to_owned(),
command: command.type_name().to_owned(),
}
}
Раздел 5 · evolve и replay, и закон между ними
evolve применяет уже случившееся событие к состоянию. В отличие от decide, она тотальна: событие, это факт, отвергать нечего, отказа нет. Она двигает FSM по ребру:
fn evolve(&self, state: ReservationState, event: &ReservationEvent) -> ReservationState {
match event {
ReservationEvent::ReservationPlaced { reservation_id, guest, room, range, .. } =>
ReservationState::Reserved {
reservation_id: reservation_id.clone(),
guest: guest.clone(),
room: room.clone(),
range: *range,
},
ReservationEvent::GuestCheckedIn { reservation_id, .. } => match state {
// CheckedIn наследует guest/room/range из Reserved.
ReservationState::Reserved { guest, room, range, .. } => ReservationState::CheckedIn {
reservation_id: reservation_id.clone(),
guest, room, range,
},
other => other, // битый стрим: evolve тотальна, возвращаем как есть
},
// Cancelled и CheckedOut переводят в терминальные состояния.
// ... полный код в decider.rs
}
}
Теперь главное. Раз состояние, это свёртка событий, восстановить его из журнала, это просто fold по evolve. Эту операцию, replay, пишем один раз для любого Decider, и трейт окупается:
/// Replay стрима до текущего состояния: буквально fold(initial, evolve).
pub fn replay<'a, D, E>(decider: &D, events: impl IntoIterator<Item = &'a E>) -> D::State
where
D: Decider<Event = E>,
E: 'a,
{
events.into_iter().fold(decider.initial(), |state, event| {
decider.evolve(state, event)
})
}
Decide и evolve связаны законом, и его стоит держать в голове: replay(events) равно состоянию, накопленному пошаговым evolve по мере выработки событий. Иначе говоря, чтение журнала и обработка команд не должны расходиться. Это не комментарий, а проверяемое свойство, и мы проверяем его property-тестом на случайных последовательностях команд:
proptest! {
#[test]
fn replay_equals_incremental_evolve(kinds in proptest::collection::vec(kind_strategy(), 0..32)) {
let decider = ReservationDecider;
let now = Timestamp(1_700_000_000_000);
let mut state = decider.initial();
let mut all_events = Vec::new();
for kind in kinds {
// отклонённые команды просто пропускаем: стрим не растёт
if let Ok(events) = decider.decide(&state, &command_of(kind), now) {
for event in &events {
state = decider.evolve(state.clone(), event);
}
all_events.extend(events);
}
}
// закон: replay всего стрима даёт ровно накопленное состояние
prop_assert_eq!(replay(&decider, &all_events), state);
}
}
Раздел 6 · BDD-тест: given, when, then
Чистая decide тестируется без базы, часов и токио, и тест читается как сценарий: дана история событий, подаём команду, ожидаем события или отказ. Это и есть verify(scenario) Шассена. Хелпер делает replay истории и зовёт decide на фиксированном времени:
const FIXED_NOW: Timestamp = Timestamp(1_700_000_000_000);
/// given history, when command, then result.
fn run_decide(history: &[ReservationEvent], command: ReservationCommand)
-> Result<Vec<ReservationEvent>, DomainError>
{
let decider = ReservationDecider;
let state = replay(&decider, history);
decider.decide(&state, &command, FIXED_NOW)
}
И сами сценарии, удачные и отказные, рядом:
#[test]
fn place_on_empty_history_yields_reservation_placed() {
let result = run_decide(&[], place());
assert_eq!(result, Ok(vec![placed()]));
}
#[test]
fn check_in_when_not_reserved_is_rejected() {
let result = run_decide(&[], check_in());
assert!(matches!(result, Err(DomainError::InvalidStateTransition { .. })));
}
#[test]
fn cancel_after_check_in_is_rejected_guest_already_in_room() {
let result = run_decide(&[placed(), checked_in()], cancel());
assert!(matches!(result, Err(DomainError::InvalidStateTransition { .. })));
}
Ни одной строчки persistence, а весь FSM брони проверен. Вот зачем decide держат чистой: бизнес-правила проверяются мгновенно и детерминированно. База появится в следующем уроке и не тронет эту чистоту.
Чек-лист
Домашка
Дальше
Следующий урок, Event store на Postgres. Чистый Decider есть, пора дать ему память: append-only журнал событий с оптимистичной блокировкой через sqlx, чтобы две параллельные команды на одну бронь не затёрли друг друга.