Раздел 23 · Rust

Decider на Rust: decide, evolve, replay и явное время

senior~60 мин

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

Decider на Rust: decide, evolve, replay и явное время

Сцена · команды и события это два разных языка

В прошлом уроке мы собрали словарь значений. Теперь оживим бронь. У неё есть жизненный цикл: её создают, гость заезжает, выезжает, иногда бронь отменяют. И тут возникает различие, которое легко проскочить, но оно несущее.

Команда это запрос в настоящем времени: «засели гостя». Её можно отвергнуть: гость уже выехал, заселять некого. Событие это факт в прошедшем времени: «гость заселён». Его отвергнуть нельзя, оно уже случилось. Из этого различия растёт event sourcing: источник истины это не снимок брони в таблице, а журнал её событий, а текущее состояние, это их свёртка.

Карта урока

  1. Состояние как конечный автомат. enum ReservationState, где каждое имя, это одна точка FSM.
  2. Тройка Decider. decide, evolve, initial, канон Жереми Шассена.
  3. Trait или struct? Решаем, как выразить Decider в Rust, и аргументируем.
  4. Чистая decide с явным временем. Почему время приходит параметром, а не из часов.
  5. evolve и replay. Сворачивание стрима в состояние, и закон, который их связывает.
  6. 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, чтобы две параллельные команды на одну бронь не затёрли друг друга.