Skip to content

Commit

Permalink
Factor out a common parent interface for StatelessWorkflow and Workfl…
Browse files Browse the repository at this point in the history
…ow (now StatefulWorkflow).

tl;dr:
 - `Workflow` -> `StatefulWorkflow`
 - `Workflow` is an interface, `StatefulWorkflow` and `StatelessWorkflow` are abstract classes
   that both implement `Workflow`.

This change makes it much easier for consumers of this library to define public APIs with private
implementations. The public API is an interface that extends `Workflow`. The private
implementation is a class that subclasses either `StatefulWorkflow` or `StatelessWorkflow`,
depending on whether it needs to keep track of any state. Either way, the workflow's state type
is kept as a completely private concern.

`interface Workflow<I, O, R>`
-----------------------------

This is the main currency of this library. `Workflow`s can compose
other `Workflow`s. A `Workflow` is anything that can be expressed as a `StatefulWorkflow`.
It has a single method, `asStatefulWorkflow()`, that is responsible for expressing the
workflow in terms of a `StatefulWorkflow` – a workflow that has an internal state. This
commit renames what used to be `Workflow` to `StatefulWorkflow`.

`abstract class StatefulWorkflow<I, S, O, R>`
---------------------------------------------

This is what you subclass to write a `Workflow` that has internal state that persists
as long as the workflow is active in its parent. It knows about things like initial state
and snapshotting/restoring.

`abstract class StatelessWorkflow<I, O, R>`
-------------------------------------------

This is what you subclass to get a `Workflow` that only has a single `compose` method.
It can compose children workflows, transform inputs/outputs/renderings up/down the tree,
and listen to external events and subscriptions, but it has no state.

Closes #213.
  • Loading branch information
zach-klippenstein committed Mar 27, 2019
1 parent 6070518 commit b1f52c7
Show file tree
Hide file tree
Showing 20 changed files with 403 additions and 248 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/*
* Copyright 2019 Square Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.squareup.workflow

/**
* A composable, stateful object that can [handle events][WorkflowContext.onEvent],
* [delegate to children][WorkflowContext.compose], [subscribe][onReceive] to arbitrary streams from
* the outside world, and be [saved][snapshotState] to a serialized form to be
* [restored][restoreState] later.
*
* The basic purpose of a `Workflow` is to take some [input][InputT] and return a
* [rendering][RenderingT]. To that end, a workflow may keep track of internal [state][StateT],
* recursively ask other workflows to render themselves, subscribe to data streams from the outside
* world, and handle events both from its [renderings][WorkflowContext.onEvent] and from workflows
* it's delegated to (its "children"). A `Workflow` may also emit [output events][OutputT] up to its
* parent `Workflow`.
*
* Workflows form a tree, where each workflow can have zero or more child workflows. Child workflows
* are started as necessary whenever another workflow asks for them, and are cleaned up automatically
* when they're no longer needed. [Input][InputT] propagates down the tree, [outputs][OutputT] and
* [renderings][RenderingT] propagate up the tree.
*
* @param InputT Typically a data class that is used to pass configuration information or bits of
* state that the workflow can always get from its parent and needn't duplicate in its own state.
* May be [Unit] if the workflow does not need any input data.
*
* @param StateT Typically a data class that contains all of the internal state for this workflow.
* The state is seeded via [input][InputT] in [initialState]. It can be [serialized][snapshotState]
* and later used to [restore][restoreState] the workflow. **Implementations of the `Workflow`
* interface should not generally contain their own state directly.** They may inject objects like
* instances of their child workflows, or network clients, but should not contain directly mutable
* state. This is the only type parameter that a parent workflow needn't care about for its children,
* and may just use star (`*`) instead of specifying it. May be [Unit] if the workflow does not have
* any internal state (see [StatelessWorkflow]).
*
* @param OutputT Typically a sealed class that represents "events" that this workflow can send
* to its parent.
* May be [Nothing] if the workflow doesn't need to emit anything.
*
* @param RenderingT The value returned to this workflow's parent during [composition][compose].
* Typically represents a "view" of this workflow's input, current state, and children's renderings.
* A workflow that represents a UI component may use a view model as its rendering type.
*
* @see StatelessWorkflow
*/
abstract class StatefulWorkflow<
in InputT : Any,
StateT : Any,
out OutputT : Any,
out RenderingT : Any
> : Workflow<InputT, OutputT, RenderingT> {

/**
* Called when the state machine is first started to get the initial state.
*/
abstract fun initialState(input: InputT): StateT

/**
* Called whenever [WorkflowContext.compose] is about to get a new [input][InputT] value, to allow
* the workflow to modify its state in response. This method is called eagerly: `old` and `new` might
* be the same value, so it is up to implementing code to perform any diffing if desired.
*
* Default implementation does nothing.
*/
open fun onInputChanged(
old: InputT,
new: InputT,
state: StateT
): StateT = state

/**
* Called at least once† any time one of the following things happens:
* - This workflow's [input] changes (via the parent passing a different one in).
* - This workflow's [state] changes.
* - A child workflow's state changes.
*
* **Never call this method directly.** To get the rendering from a child workflow, pass the child
* and any required input to [WorkflowContext.compose].
*
* This method *should not* have any side effects, and in particular should not do anything that
* blocks the current thread. It may be called multiple times for the same state. It must do all its
* work by calling methods on [context].
*
* _† This method is guaranteed to be called *at least* once for every state, but may be called
* multiple times. Allowing this method to be invoked multiple times makes the internals simpler._
*/
abstract fun compose(
input: InputT,
state: StateT,
context: WorkflowContext<StateT, OutputT>
): RenderingT

/**
* Called whenever the state changes to generate a new [Snapshot] of the state.
*
* **Snapshots must be lazy.**
*
* Serialization must not be done at the time this method is called,
* since the state will be snapshotted frequently but the serialized form may only be needed very
* rarely.
*
* If the workflow does not have any state, or should always be started from scratch, return
* [Snapshot.EMPTY] from this method.
*
* @see restoreState
*/
abstract fun snapshotState(state: StateT): Snapshot

/**
* Deserialize a state value from a [Snapshot] previously created with [snapshotState].
*
* If the workflow should always be started from scratch, this method can just ignore the snapshot
* and return the initial state.
*
* @see snapshotState
*/
abstract fun restoreState(snapshot: Snapshot): StateT

/**
* Satisfies the [Workflow] interface by returning `this`.
*/
final override fun asStatefulWorkflow(): StatefulWorkflow<InputT, StateT, OutputT, RenderingT> = this
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,78 @@
package com.squareup.workflow

/**
* A [Workflow] that has no state.
* A composable object that can [handle events][WorkflowContext.onEvent],
* [delegate to children][WorkflowContext.compose], and [subscribe][onReceive] to arbitrary streams
* from the outside world.
*
* The basic purpose of a `Workflow` is to take some [input][InputT] and return a
* [rendering][RenderingT]. To that end, a workflow may recursively ask other workflows to render
* themselves, subscribe to data streams from the outside world, and handle events both from its
* [renderings][WorkflowContext.onEvent] and from workflows it's delegated to (its "children"). A
* `Workflow` may also emit [output events][OutputT] up to its parent `Workflow`.
*
* Workflows form a tree, where each workflow can have zero or more child workflows. Child workflows
* are started as necessary whenever another workflow asks for them, and are cleaned up automatically
* when they're no longer needed. [Input][InputT] propagates down the tree, [outputs][OutputT] and
* [renderings][RenderingT] propagate up the tree.
*
* @param InputT Typically a data class that is used to pass configuration information or bits of
* state that the workflow can always get from its parent and needn't duplicate in its own state.
* May be [Unit] if the workflow does not need any input data.
*
* @param OutputT Typically a sealed class that represents "events" that this workflow can send
* to its parent.
* May be [Nothing] if the workflow doesn't need to emit anything.
*
* @param RenderingT The value returned to this workflow's parent during [composition][compose].
* Typically represents a "view" of this workflow's input, current state, and children's renderings.
* A workflow that represents a UI component may use a view model as its rendering type.
*
* @see StatefulWorkflow
*/
typealias StatelessWorkflow<InputT, OutputT, RenderingT> =
Workflow<InputT, Unit, OutputT, RenderingT>
abstract class StatelessWorkflow<InputT : Any, OutputT : Any, RenderingT : Any> :
Workflow<InputT, OutputT, RenderingT> {

/**
* Called at least once any time one of the following things happens:
* - This workflow's [input] changes (via the parent passing a different one in).
* - A child workflow updates in some way (e.g. a stateful child's state changes).
*
* **Never call this method directly.** To get the rendering from a child workflow, pass the child
* and any required input to [WorkflowContext.compose].
*
* This method *should not* have any side effects, and in particular should not do anything that
* blocks the current thread. It may be called multiple times for the same state. It must do all its
* work by calling methods on [context].
*/
abstract fun compose(
input: InputT,
context: WorkflowContext<Nothing, OutputT>
): RenderingT

/**
* Satisfies the [Workflow] interface by wrapping `this` in a [StatefulWorkflow] with `Unit`
* state.
*/
final override fun asStatefulWorkflow(): StatefulWorkflow<InputT, *, OutputT, RenderingT> =
object : StatefulWorkflow<InputT, Unit, OutputT, RenderingT>() {
override fun initialState(input: InputT) = Unit

@Suppress("UNCHECKED_CAST")
override fun compose(
input: InputT,
state: Unit,
context: WorkflowContext<Unit, OutputT>
): RenderingT = compose(input, context as WorkflowContext<Nothing, OutputT>)

override fun snapshotState(state: Unit) = Snapshot.EMPTY
override fun restoreState(snapshot: Snapshot) = Unit
}
}

/**
* A convenience function to implement a [Workflow] that doesn't have any internal state. Such a
* workflow doesn't need to worry about initial state or snapshotting, so the entire workflow can
* be defined as a single [compose][Workflow.compose] function.
* A convenience function to implement [StatelessWorkflow] by just passing the
* [compose][StatelessWorkflow.compose] function as a lambda.
*
* Note that while a stateless workflow doesn't have any _internal_ state of its own, it may use
* [input][InputT] received from its parent, and it may compose child workflows that do have their own
Expand All @@ -34,29 +97,23 @@ typealias StatelessWorkflow<InputT, OutputT, RenderingT> =
fun <InputT : Any, OutputT : Any, RenderingT : Any> StatelessWorkflow(
compose: (
input: InputT,
context: WorkflowContext<Unit, OutputT>
context: WorkflowContext<Nothing, OutputT>
) -> RenderingT
): StatelessWorkflow<InputT, OutputT, RenderingT> =
object : Workflow<InputT, Unit, OutputT, RenderingT> {
override fun initialState(input: InputT) = Unit

object : StatelessWorkflow<InputT, OutputT, RenderingT>() {
override fun compose(
input: InputT,
state: Unit,
context: WorkflowContext<Unit, OutputT>
): RenderingT = compose(input, context)

override fun snapshotState(state: Unit) = Snapshot.EMPTY
override fun restoreState(snapshot: Snapshot) = Unit
context: WorkflowContext<Nothing, OutputT>
): RenderingT = compose.invoke(input, context)
}

/**
* A version of [StatelessWorkflow] that doesn't have any input data either.
*/
@Suppress("FunctionName")
fun <OutputT : Any, RenderingT : Any> StatelessWorkflow(
compose: (context: WorkflowContext<Unit, OutputT>) -> RenderingT
compose: (context: WorkflowContext<Nothing, OutputT>) -> RenderingT
): StatelessWorkflow<Unit, OutputT, RenderingT> =
StatelessWorkflow { _: Unit, context: WorkflowContext<Unit, OutputT> ->
StatelessWorkflow { _: Unit, context: WorkflowContext<Nothing, OutputT> ->
compose(context)
}
118 changes: 33 additions & 85 deletions kotlin/workflow-core/src/main/java/com/squareup/workflow/Workflow.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,107 +18,55 @@ package com.squareup.workflow
/**
* A composable, optionally-stateful object that can [handle events][WorkflowContext.onEvent],
* [delegate to children][WorkflowContext.compose], [subscribe][onReceive] to arbitrary streams from
* the outside world, and be [saved][snapshotState] to a serialized form to be
* [restored][restoreState] later.
* the outside world.
*
* The basic purpose of a `Workflow` is to take some [input][InputT] and return a
* [rendering][RenderingT]. To that end, a workflow may keep track of internal [state][StateT],
* recursively ask other workflows to render themselves, subscribe to data streams from the outside
* world, and handle events both from its [renderings][WorkflowContext.onEvent] and from workflows
* it's delegated to (its "children"). A `Workflow` may also emit [output events][OutputT] up to its
* parent `Workflow`.
* [rendering][RenderingT]. To that end, a workflow may keep track of internal
* [state][StatefulWorkflow], recursively ask other workflows to render themselves, subscribe to
* data streams from the outside world, and handle events both from its
* [renderings][WorkflowContext.onEvent] and from workflows it's delegated to (its "children"). A
* `Workflow` may also emit [output events][OutputT] up to its parent `Workflow`.
*
* Workflows form a tree, where each workflow can have zero or more child workflows. Child workflows
* are started as necessary whenever another workflow asks for them, and are cleaned up automatically
* when they're no longer needed. [Input][InputT] propagates down the tree, [outputs][OutputT] and
* [renderings][RenderingT] propagate up the tree.
* are started as necessary whenever another workflow asks for them, and are cleaned up
* automatically when they're no longer needed. [Input][InputT] propagates down the tree,
* [outputs][OutputT] and [renderings][RenderingT] propagate up the tree.
*
* ## Implementing `Workflow`
*
* The [Workflow] interface is useful as a facade for your API. You can publish an interface that
* extends `Workflow`, and keep the implementation (e.g. is your workflow state*ful* or
* state*less* a private implementation detail.
*
* ### [Stateful Workflows][StatefulWorkflow]
*
* If your workflow needs to keep track of internal state, implement the [StatefulWorkflow]
* interface. That interface has an additional type parameter, `StateT`, and allows you to specify
* [how to create the initial state][StatefulWorkflow.initialState] and how to
* [snapshot][StatefulWorkflow.snapshotState]/[restore][StatefulWorkflow.restoreState] your state.
*
* ### [Stateless Workflows][StatelessWorkflow]
*
* If your workflow simply needs to delegate to other workflows, maybe transforming inputs, outputs,
* or renderings, implement the [StatelessWorkflow] interface, or simply pass a lambda to the
* `StatelessWorkflow` function.
*
* @param InputT Typically a data class that is used to pass configuration information or bits of
* state that the workflow can always get from its parent and needn't duplicate in its own state.
* May be [Unit] if the workflow does not need any input data.
*
* @param StateT Typically a data class that contains all of the internal state for this workflow.
* The state is seeded via [input][InputT] in [initialState]. It can be [serialized][snapshotState]
* and later used to [restore][restoreState] the workflow. **Implementations of the `Workflow`
* interface should not generally contain their own state directly.** They may inject objects like
* instances of their child workflows, or network clients, but should not contain directly mutable
* state. This is the only type parameter that a parent workflow needn't care about for its children,
* and may just use star (`*`) instead of specifying it. May be [Unit] if the workflow does not have
* any internal state (see [StatelessWorkflow]).
*
* @param OutputT Typically a sealed class that represents "events" that this workflow can send
* to its parent.
* May be [Nothing] if the workflow doesn't need to emit anything.
*
* @param RenderingT The value returned to this workflow's parent during [composition][compose].
* Typically represents a "view" of this workflow's input, current state, and children's renderings.
* A workflow that represents a UI component may use a view model as its rendering type.
*
* @see StatefulWorkflow
* @see StatelessWorkflow
*/
interface Workflow<in InputT : Any, StateT : Any, out OutputT : Any, out RenderingT : Any> {

/**
* Called when the state machine is first started to get the initial state.
*/
fun initialState(input: InputT): StateT

/**
* Called whenever [WorkflowContext.compose] is about to get a new [input][InputT] value, to allow
* the workflow to modify its state in response. This method is called eagerly: `old` and `new` might
* be the same value, so it is up to implementing code to perform any diffing if desired.
*
* Default implementation does nothing.
*/
fun onInputChanged(
old: InputT,
new: InputT,
state: StateT
): StateT = state

/**
* Called at least once† any time one of the following things happens:
* - This workflow's [input] changes (via the parent passing a different one in).
* - This workflow's [state] changes.
* - A child workflow's state changes.
*
* **Never call this method directly.** To get the rendering from a child workflow, pass the child
* and any required input to [WorkflowContext.compose].
*
* This method *should not* have any side effects, and in particular should not do anything that
* blocks the current thread. It may be called multiple times for the same state. It must do all its
* work by calling methods on [context].
*
* _† This method is guaranteed to be called *at least* once for every state, but may be called
* multiple times. Allowing this method to be invoked multiple times makes the internals simpler._
*/
fun compose(
input: InputT,
state: StateT,
context: WorkflowContext<StateT, OutputT>
): RenderingT

/**
* Called whenever the state changes to generate a new [Snapshot] of the state.
*
* **Snapshots must be lazy.**
*
* Serialization must not be done at the time this method is called,
* since the state will be snapshotted frequently but the serialized form may only be needed very
* rarely.
*
* If the workflow does not have any state, or should always be started from scratch, return
* [Snapshot.EMPTY] from this method.
*
* @see restoreState
*/
fun snapshotState(state: StateT): Snapshot
interface Workflow<in InputT : Any, out OutputT : Any, out RenderingT : Any> {

/**
* Deserialize a state value from a [Snapshot] previously created with [snapshotState].
*
* If the workflow should always be started from scratch, this method can just ignore the snapshot
* and return the initial state.
*
* @see snapshotState
*/
fun restoreState(snapshot: Snapshot): StateT
fun asStatefulWorkflow(): StatefulWorkflow<InputT, *, OutputT, RenderingT>
}
Loading

0 comments on commit b1f52c7

Please sign in to comment.