PersistentFSM
은 FSM으로 들어오는 메시지들을 다룰 때 사용한다. 일련의 변화에 따라 내부 상태가 유지되며, 도메인 이벤트에 의해 인용되어진다. 수신되는 메시지들과 FSM의 상태 및 변화, 도메인 이벤트의 영속성의 관계는 DSL에 의해 정의된다.
PersistentFSM
trait의 특징을 알기 위하여 웹 스토어 고객을 나타내는 액터를 선언한다고 생각하자. WebStoreCustomerFSMActor
는 다음과 같은 command를 따를 것이다.
sealed trait Command
case class AddItem(item: Item) extends Command
case object Buy extends Command
case object Leave extends Command
case object GetCurrentCart extends Command
AddItem
은 쇼핑 카트인 Buy
로 사용자가 선택한 아이템을 옮기는 클래스이며, 사용자가 구매를 완료하면 Leave
클래스가 실행된다. 만약 구매를 완료하지 않았다면, GetCurrentCart
가 실행되어 사용자의 장바구니 상태를 저장하는 역할을 담당한다.
고객들은 다음과 같은 상태를 지닐 수 있다.
sealed trait UserState extends FSMState
case object LookingAround extends UserState {
override def identifier: String = "Looking Around"
}
case object Shopping extends UserState {
override def identifier: String = "Shopping"
}
case object Inactive extends UserState {
override def identifier: String = "Inactive"
}
case object Paid extends UserState {
override def identifier: String = "Paid"
}
위의 객체들은 고객의 상태를 나타낸 객체이다. 즉, Buy
나 Leave
, GetCurrentCart
등과 같은 상태를 문자열로 표시해주는 역할이다.
❗️ 주의해야 할 점
PersistentFSM
상태들은PersistentFSM.FSMState
trait를 상속해야 하며def identifier:String
메서들을 Implementation해야 한다. 이들은 FSM 상태들을 직렬화를 간단하게 해준다. 또한,identifier
들은 고유해야 한다.
위의 예시에서UserState
가 FSMState를 상속받은 것이 그 이유이다.
장바구니에 있는 고객 상태 데이터는 다음과 같을 것이다.
case class Item(id: String, name: String, price: Float)
sealed trait ShoppingCart {
def addItem(item: Item): ShoppingCart
def empty(): ShoppingCart
}
case object EmptyShoppingCart extends ShoppingCart {
def addItem(item: Item) = NonEmptyShoppingCart(item :: Nil)
def empty() = this
}
case class NonEmptyShoppingCart(items: Seq[Item]) extends ShoppingCart {
def addItem(item: Item) = NonEmptyShoppingCart(items :+ item)
def empty() = EmptyShoppingCart
}
이제 전체적인 코드를 엮으면 다음과 같을 것이다.
startWith(LookingAround, EmptyShoppingCart)
when(LookingAround) {
case Event(AddItem(item), _) =>
goto(Shopping).applying(ItemAdded(item)).forMax(1 seconds)
case Event(GetCurrentCart, data) =>
stay().replying(data)
}
when(Shopping) {
case Event(AddItem(item), _) =>
stay().applying(ItemAdded(item)).forMax(1 seconds)
case Event(Buy, _) =>
goto(Paid).applying(OrderExecuted).andThen {
case NonEmptyShoppingCart(items) =>
reportActor ! PurchaseWasMade(items)
saveStateSnapshot()
case EmptyShoppingCart => saveStateSnapshot()
}
case Event(Leave, _) =>
stop().applying(OrderDiscarded).andThen {
case _ =>
reportActor ! ShoppingCardDiscarded
saveStateSnapshot()
}
case Event(GetCurrentCart, data) =>
stay().replying(data)
case Event(StateTimeout, _) =>
goto(Inactive).forMax(2 seconds)
}
when(Inactive) {
case Event(AddItem(item), _) =>
goto(Shopping).applying(ItemAdded(item)).forMax(1 seconds)
case Event(StateTimeout, _) =>
stop().applying(OrderDiscarded).andThen {
case _ => reportActor ! ShoppingCardDiscarded
}
}
when(Paid) {
case Event(Leave, _) => stop()
case Event(GetCurrentCart, data) =>
stay().replying(data)
}
EmptyShoppingCart
로 시작하여 LookingAround
인 상태로 고객은 시작한다. 그리고 LookingAround
를 하는 도중, 물건을 담는 이벤트가 발생하면 이를 실행할 이벤트를 담는다. 이렇게 사용자의 상태에 따라 이벤트들을 설정해주는 역할을 한다.
이때, applyEvent
메서드를 오버라이드하여 도메인 이벤트에 의해 어떻게 상태 데이터가 영향을 받는지 나타내줘야 한다.
override def applyEvent(event: DomainEvent, cartBeforeEvent: ShoppingCart): ShoppingCart = {
event match {
case ItemAdded(item) => cartBeforeEvent.addItem(item)
case OrderExecuted => cartBeforeEvent
case OrderDiscarded => cartBeforeEvent.empty()
case CustomerInactive => cartBeforeEvent
}
}
위와 같이 이벤트 처리 역시 완료되었다면, 이벤트의 영속성에 따라 어떤 액션들이 실행될 지 andThen
을 사용하여 정의할 수 있다. 이렇게 되면 로깅이나 메시지 전송과 같은 "side effect"를 편히 다룰 수 있다.
goto(Paid).applying(OrderExecuted).andThen {
case NonEmptyShoppingCart(items) =>
reportActor ! PurchaseWasMade(items)
}
또한, saveStateSnapshot()
메서드를 호출함으로서 상태 데이터의 스냅샷이 영속되어진다. 이 메서드는 다음과 같이 사용된다.
stop().applying(OrderDiscarded).andThen {
case _ =>
reportActor ! ShoppingCardDiscarded
saveStateSnapshot()
}
이렇게 스냅샷이 저장되었다면, 회복된 상태 데이터는 가장 최근의 스냅샷 데이터에 기반해서 초기화 된다. 그리고 남아있는 도메인 이벤트들이 다시 재생되면 applyEvent
를 다시 발생시킨다.