Ecosyste.ms: Awesome
An open API service indexing awesome lists of open source software.
https://github.com/sideeffect-io/AsyncStateMachine
Async State Machine aims to provide a way to structure an application thanks to state machines. The goal is to identify the states and the side effects involved in each feature and to model them in a consistent and scalable way.
https://github.com/sideeffect-io/AsyncStateMachine
asyncsequence concurrency finite-state-machine fsm state-machine state-management swift
Last synced: 3 months ago
JSON representation
Async State Machine aims to provide a way to structure an application thanks to state machines. The goal is to identify the states and the side effects involved in each feature and to model them in a consistent and scalable way.
- Host: GitHub
- URL: https://github.com/sideeffect-io/AsyncStateMachine
- Owner: sideeffect-io
- License: mit
- Created: 2022-06-03T12:36:21.000Z (over 2 years ago)
- Default Branch: main
- Last Pushed: 2022-08-22T13:46:17.000Z (about 2 years ago)
- Last Synced: 2024-07-31T13:07:50.896Z (4 months ago)
- Topics: asyncsequence, concurrency, finite-state-machine, fsm, state-machine, state-management, swift
- Language: Swift
- Homepage:
- Size: 271 KB
- Stars: 43
- Watchers: 5
- Forks: 2
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
[![Build and test](https://github.com/sideeffect-io/AsyncStateMachine/actions/workflows/ci.yml/badge.svg)](https://github.com/sideeffect-io/AsyncStateMachine/actions/workflows/ci.yml)
[![Lint](https://github.com/sideeffect-io/AsyncStateMachine/actions/workflows/lint.yml/badge.svg)](https://github.com/sideeffect-io/AsyncStateMachine/actions/workflows/lint.yml)
[![Codecov](https://codecov.io/gh/sideeffect-io/AsyncStateMachine/branch/main/graph/badge.svg?token=CRAZF1LOM9)](https://codecov.io/gh/sideeffect-io/AsyncStateMachine)# Async State Machine
**Async State Machine** aims to provide a way to structure an application thanks to state machines. The goal is to identify the states and the side effects involved in each feature and to model them in a consistent and scalable way thanks to a DSL.```swift
let stateMachine = StateMachine(initial: .state1) {
When(state: .state1) {
Execute(output: .output1)
} transitions: {
On(event: .event1) { Transition(to: .state2) }
On(event: .event2) { Transition(to: .state3) }
On(event: .event3(value:)) { value in Transition(to: .state4(value)) }
}
When(state: .state2(value:)) { value in
Execute.noOutput
} transitions: { value in
…
}
}
```## Installation
```swift
let package = Package(
name: "Example",
dependencies: [
.package(url: "https://github.com/sideeffect-io/AsyncStateMachine.git", .upToNextMajor(from: "0.0.1"))
],
targets: [
.target(name: "Example", dependencies: ["AsyncStateMachine"])
]
)
```## Key points:
- Each feature is a [Moore state machine](https://en.wikipedia.org/wiki/Moore_machine): no need for a global store
- State machines are declarative: a DSL offers a natural and concise syntax
- Swift concurrency is at the core:
- A state machine is an `AsyncSequence`
- Each side effect runs inside a `Task` that benefits from cooperative cancellation
- Concurrent transitions can suspend
- State machines are built in complete isolation: tests dont require mocks
- Dependencies are injected per side effect: no global bag of dependencies
- State machines are not UI related: it works with UIKit or SwiftUI## A Simple Example
As a picture is worth a thousand words, here’s an example of a state machine that drives the opening of an elevator‘s door:![](Elevator.jpeg)
### How does it read?
- **INITIALLY**, the elevator is `open` with 0 person inside
- **WHEN** the state is `open`, **ON** the event `personsHaveEntered`, the new state is `open` with `n + x` persons.
- **WHEN** the state is `open`, **ON** the event `closeButtonWasPressed`, the new state is `closing` if there is less than 10 persons (elevator’s capacity is limited).
- **WHEN** the state is `closing`, the `close` action is executed (the door can close at different speeds).
- **WHEN** the state is `closing`, **ON** the event `doorHasLocked`, the new state is *closed*.### What defines this state machine?
- The elevator can be in 3 exclusive **states**: `open`, `closing` and `closed`. _This is the finite set of possible **states**. The initial state of the elevator is `open` with 0 person inside._
- The elevator can receive 3 **events**: `personsHaveEntered`, `closeButtonWasPressed` and `doorHasLocked`. _This is the finite set of possible **events**._
- The elevator can perform 1 **action**: `close` the door when the state is `closing` and the number of persons is less than 10. The speed of the doors is determined by the number of persons inside. _This is the finite set of possible **outputs**._
- The elevator can go from one state to another when events are received. _This is the finite set of possible **transitions**._The assumption we make is that almost any feature can be described in terms of state machines. And to make it as simple as possible, we use a Domain Specific Language.
## The state machine DSL
Here’s the translation of the aforementioned state machine using enums and the **Async State Machine** DSL:
```swift
enum State: DSLCompatible {
case open(persons: Int)
case closing(persons: Int)
case closed
}enum Event: DSLCompatible {
case personsHaveEntered(persons: Int)
case closeButtonWasPressed
case doorHasLocked
}enum Output: DSLCompatible {
case close(speed: Int)
}let stateMachine = StateMachine(initial: State.open(persons: 0)) {
When(state: State.open(persons:)) { _ in
Execute.noOutput
} transitions: { persons in
On(event: Event.personsHaveEntered(persons:)) { newPersons in
Transition(to: State.open(persons: persons + newPersons))
}On(event: Event.closeButtonWasPressed) {
Guard(predicate: persons < 10)
} transition: {
Transition(to: State.closing(persons: persons))
}
}
When(state: State.closing(persons:)) { persons in
Execute(output: Output.close(speed: persons > 5 ? 1 : 2))
} transitions: { _ in
On(event: Event.doorHasLocked) {
Transition(to: State.closed)
}
}
}
```The only requirement to be able to use enums with the DSL is to have them conform to *DSLCompatible* (which allows to use enums in a declarative manner, without the need for pattern matching).
## The Runtime
The DSL aims to describe a formal state machine: no side effects, only pure functions!
The `StateMachine` declares **output** _values_ to describe the _intent_ of side effects to be performed, but the _implementation_ of those side effects are declared in the `Runtime` where one can map outputs to side effect functions.
(Amongst other benefits, this decoupling allows for easier testing of your State Machine without depending on the implementation of the side effects.)
```swift
func close(speed: Int) async -> Event {
try? await Task.sleep(nanoseconds: UInt64(1_000_000_000 / speed))
return .doorHasLocked
}let runtime = Runtime()
.map(output: Output.close(speed:), to: close(speed:))
```Side effects are `async` functions that return either a single `Event`, or an `AsyncSequence`. Every time the state machine produces the expected `output`, the corresponding side effect will be executed.
In addition, the Runtime can register _middleware_ functions executed on any `state` or `event`:
```swift
let runtime = Runtime()
.map(output: Output.close(speed:), to: close(speed:))
.register(middleware: { (state: State) in print("State: \(state)") })
.register(middleware: { (event: Event) in print("Event: \(event)") })
```The `AsyncStateMachine` can then be instantiated:
```swift
let sequence = AsyncStateMachine(
stateMachine: stateMachine,
runtime: runtime
)for await state in sequence { … }
await sequence.send(Event.personsHaveEntered(persons: 3))
```## Swift concurrency at the core
**Async State Machine** is 100% built with the Swift 5.5 concurrency model in mind.
### Transitions
- Transitions defined in the DSL are `async` functions; they will be executed in a non blocking way.
- Transitions cannot If an event previously sent is being processed by a transition, the next call to `send(_:)` will `await`. This prevents concurrent transitions to happen simultaneously (which could otherwise lead to inconsistent states).### Side effects
- Side effects are `async` functions executed in the context of `Tasks`.
- Task priority can be set in the Runtime: `.map(output: Output.close(speed:), to: close(speed:), priority: .high)`.
- Collaborative task cancellation applies: when an `AsyncStateMachine` is deinit, all the pending side effect tasks will be marked as cancelled.### Async sequence
- `AsyncStateMachine` benefits from all the operators associated to `AsyncSequence` (`map`, `filter`, …). (See also [swift async algorithms](https://github.com/apple/swift-async-algorithms))
- `AsyncStateMachine` is compliant with a multiple producer / multiple consumer mode in a concurrent mode. Although to output is not shared (meaning each consumer will receive the successive versions of the state), the transitions are guaranteed concurrent-safe.## How to inject dependencies?
Most of the time, side effects will require dependencies to perform their duty. However, **Async State Machine** expects a side effect to be a function that eventually takes a parameter (from the `Output`) and returns an `Event` or an `AsyncSequence`. There is no place for dependencies in their signatures.
There are several ways to overcome this:
- Make a business object that captures the dependencies and declares a function that matches the side effect’s signature:
```swift
class ElevatorUseCase {
let engine: Engine
init(engine: Engine) { self.engine = engine }func close(speed: Int) async -> Event {
try? await Task.sleep(nanoseconds: UInt64(self.engine.delay / speed))
return .doorHasLocked
}
}let useCase = ElevatorUseCase(engine: FastEngine())
let runtime = Runtime()
.map(output: Output.close(speed:), to: useCase.close(speed:))
```- Make a factory function that provides a side effect, capturing its dependencies:
```swift
func makeClose(engine: Engine) -> (Int) async -> Event {
return { (speed: Int) in
try? await Task.sleep(nanoseconds: UInt64(engine.delay / speed))
return .doorHasLocked
}
}let close = makeClose(engine: FastEngine())
let runtime = Runtime()
.map(output: Output.close(speed:), to: close)
```- Use the provided `inject` function (preferred way verbosity wise):
```swift
func close(speed: Int, engine: Engine) async -> Event {
try? await Task.sleep(nanoseconds: UInt64(engine.delay / speed))
return .doorHasLocked
}let closeSideEffect = inject(dep: Engine(), in: close(speed:engine:))
let runtime = Runtime()
.map(output: Output.close(speed:), to: closeSideEffect)
```## Testable in complete isolation
State machine definitions do not depend on any dependencies, thus they can be tested without using mocks. **Async State Machine** provides a unit test helper making it even easier:
```swift
XCTStateMachine(stateMachine)
.assertNoOutput(when: .open(persons: 0))
.assert(
when: .open(persons: 0),
on: .personsHaveEntered(persons: 1),
transitionTo: .open(persons: 1)
)
.assert(
when: .open(persons: 5),
on: .closeButtonWasPressed,
transitionTo: .closing(persons: 5)
)
.assertNoTransition(when: .open(persons: 15), on: .closeButtonWasPressed)
.assert(when: .closing(persons: 1), execute: .close(speed: 2))
.assert(
when: .closing(persons: 1),
on: .doorHasLocked,
transitionTo: .closed
)
.assertNoOutput(when: .closed)
```## Using Async State Machine with SwiftUI and UIKit
No matter the UI framework you use, rendering a user interface is about interpreting a state. You can use an `AsyncStateMachine` as a reliable state factory. `ViewStateMachine` is a handy wrapper around `AsyncStateMachine` that eases the consumption of the state from a UI perspective.
A simple and naive SwiftUI usage could be:
```swift
struct ContentView: View {
@ObservedObject viewStateMachine: ViewStateMachinevar body: some View {
VStack {
Text(self.viewStateMachine.state.description)
Button {
self.viewStateMachine.send(Event.personsHaveEntered(persons: 1))
} label: {
Text("New person")
}
Button {
self.viewStateMachine.send(Event.closeButtonWasPressed)
} label: {
Text("Close the door")
}
}.task {
await self.viewStateMachine.start()
}
}
}…
let viewStateMachine = ViewStateMachine(asyncStateMachine: myAsyncStateMachine)
ContentView(viewStateMachine: viewStateMachine)
```With UIKit, a simple and naive approach would be:
```swift
let task: Task!
let viewStateMachine: ViewStateMachine!
let cancellable = AnyCancellable()override func viewDidLoad() {
super.viewDidLoad()
self.task = Task { [weak self] in
await self?.viewStateMachine.start()
}
self.cancellable = self.viewStateMachine.$state.sink { [weak self] state in
self?.render(state: state)
}
}func render(state: State) {
…
}func deinit() {
self.task.cancel()
}
```## Extras
Conditionally resumable `send()` function
Allows to send an event while awaiting for a specific state or set of states to resume.```swift
await viewStateMachine.send(
.closeButtonWasPressed,
resumeWhen: .closed
)`
```
Side effect cancellation
Make `close(speed:)` side effect execution be cancelled when the state machine produces any new states. It is also possible to cancel on a specific state.```swift
Runtime.map(
output: Output.close(speed:),
to: close(speed:),
strategy: .cancelWhenAnyState
)
```
States set
Allows to factorize the same transition for a set of states.```swift
When(states: OneOf {
State.closing(persons:),
State.closed
}) { _ in
Execute.noOutput
} transitions: {
On(event: Event.closeButtonWasPressed) { _ in
Transition(to: State.opening)
}
}`
```
SwiftUI bindings
Allows to create a SwiftUI binding on the current state, sending an Event when the binding changes.```swift
self.viewStateMachine.binding(send: .closeButtonWasPressed)
```Allows to create a SwiftUI binding on a property of the current state, sending an Event when the binding changes.
```swift
self.viewStateMachine.binding(
keypath: \.persons,
send: .closeButtonWasPressed
)
```Allows to create a SwiftUI binding on a property of the current state, sending an Event when the binding changes, debounced with the specified dueTime.
```swift
self.viewStateMachine
.binding(send: .closeButtonWasPressed)
.debounce(for: .seconds(1))
```
Connecting two state machines
This will send the event `OtherEvent.refresh` in the other state machine when the first state machine's state is `State.closed`.```swift
let channel = Channel()let runtime = Runtime()
...
.connectAsSender(to: channel, when: State.closed, send: OtherEvent.refresh)
let otherRuntime = Runtime()
...
.connectAsReceiver(to: channel)
```