In this article I want to introduce a custom state machine that refines the classic "Gang-of-Four" State design pattern example that I presented in my last Blog.
For seeing the UML state diagram and the state/event table of the example's workflow, please refer to that article.
Why Customize the Pattern?
Advantage is that you ....
-
.... have a reusable
AbstractStateMachine
with a reusableTransition
class for implementing many state machines in a standard way -
.... don't need the big switch-case statement in
StateMachine
to associate an action-event with an action-method, because states do not contain action-methods any more, these methods have been replaced by a list ofTransition
instances - .... have a more flexible inheritance concept that allows to remove action-events (transitions) inherited from a super-class without having to override it (and throw an exception from the override)
- .... can provide the possible actions on a follower-state without enumerating implemented action-methods as a set of action-ids (which is logic-duplication)
UML Class Diagrams
The state machine can perform a state transition given by an input state and the ID of an action to perform on that state, and it will return a follower state. The caller is responsible for constructing the input state and determining the ID of the action (event). The state machine provides just one transition at a time, sequences of transitions are up to the caller.
Every state holds a set of possibe transitions,
every transition refers to an action (event) ID and a follower-state.
So performing a transition is just calling the action of the transition that refers to the ID of the action.
The AbstractState
provides means for managing inheritance of transitions.
Source Code Files and Folders
All classes in the actions
package are the same as in
my preceding article,
just using different imports, so I didn't repeat them here.
They just expose their static ID and do nothing concrete.
Please click onto the expand-controls to see source code of the classes that have not been opened automatically:
statemachine
custom
actions
actions
package contains the same classes as in classic state pattern,
please see my preceding article.
states
State.java
/** * Superclass of all states that defines transitions available in any state. */ public class State extends AbstractState { /** @return default transitions that should be possible in all states. */ @Override public Set<Transition> transitions() { return Set.of( new Transition( ReadAction.ID, // allow "read" action in any state, this) // and stay in that state afterwards ); } }
InitialState.java
/** * After creation, data are in this initial state. */ public class InitialState extends State { @Override public Set<Transition> transitions() { return extend( super.transitions(), // allow all transitions from super-class, like "read" new Transition( CreateAction.ID, // initial state must accept "create" action this), // stay in this state after creation new Transition( SaveAction.ID, // can save data this), // stay in this state afterwards new Transition( DeleteAction.ID, // can delete new DeletedState()), // go to deleted state state afterwards new Transition( ReleaseAction.ID, // can publish new ReleasedState()) // go to published state state afterwards ); } }
DraftState.java
/** * After publishing, data can be brought back to a draft state. * Nearly identical to initial state, but it doesn't allow "create" action. */ public class DraftState extends InitialState { @Override public Set<Transition> transitions() { return remove( super.transitions(), // inherit all super-transitions, CreateAction.ID // but don't allow "create" action ); } }
ReleasedState.java
/** * From initial state or draft state, data can be published. */ public class ReleasedState extends State { @Override public Set<Transition> transitions() { return extend( super.transitions(), new Transition( EditAction.ID, // "edit" is supported in published state only new DraftState()), // go to draft state afterwards new Transition( DeleteAction.ID, // "delete" is supported new DeletedState()) // go to deleted state afterwards ); } }
DeletedState.java
/** * Doesn't provide any transition. */ public class DeletedState extends State { @Override public Set<Transition> transitions() { return Set.of(); // support no further actions and states } }
StateMachine.java
public class StateMachine extends AbstractStateMachine { @Override public Set<AbstractAction> getActions() { return Set.of( new CreateAction(), new ReadAction(), new DeleteAction(), new SaveAction(), new ReleaseAction(), new EditAction() ); } }
AbstractAction.java
AbstractAction
is the same as in classic state pattern,
please see my preceding article.
AbstractState.java
/** * Reusable superclass for all states. A state aggregates transitions. */ public abstract class AbstractState { /** * @return transitions that should be possible in this state, * or an empty set when none, but never null. */ public abstract Set<Transition> transitions(); /** * @return all possible actions to be performed on this state, * derived from the action-ids in all possible transitions. */ public final Set<String> actionIds() { final Set<String> actionIds = new HashSet<>(); for (Transition transition : transitions()) actionIds.add(transition.actionId); return Collections.unmodifiableSet(actionIds); } /** * Convenience method to avoid code duplications. * @param inheritedTransitions required, use <i>super.transitions()</i> for that. * @param moreTransitions required, further transitions to add. * @return the union of <i>inheritedTransitions</i> and <i>moreTransitions</i>. */ protected final Set<Transition> extend(Set<Transition> inheritedTransitions, Transition... moreTransitions) { final Set<Transition> extended = new HashSet<>(); for (Transition inheritedTransition : inheritedTransitions) addButAssertNotContained(extended, inheritedTransition); for (Transition moreTransition : moreTransitions) addButAssertNotContained(extended, moreTransition); return Collections.unmodifiableSet(extended); } /** * Convenience method to avoid code duplications. * @param inheritedTransitions required, use <i>super.transitions()</i> for that. * @param actionIdsToRemove required, action-ids referencing transitions to remove. * @return a new set consisting of the <i>inheritedTransitions</i> minus those * transitions that reference an action contained in <i>actionsToRemove</i>. */ protected final Set<Transition> remove(Set<Transition> inheritedTransitions, String... actionIdsToRemove) { final Set<String> toRemove = Set.of(actionIdsToRemove); final Set<Transition> reduced = new HashSet<>(); for (Transition inheritedTransition : inheritedTransitions) if (false == toRemove.contains(inheritedTransition.actionId)) addButAssertNotContained(reduced, inheritedTransition); return Collections.unmodifiableSet(reduced); } /** @return the hash of this class. */ @Override public int hashCode() { return getClass().hashCode(); } /** @return the hash of this class. */ @Override public boolean equals(Object o) { return getClass().equals(o.getClass()); } private void addButAssertNotContained(Set<Transition> set, Transition transition) { if (set.contains(transition)) throw new IllegalArgumentException( "Transitions referring to the same action are not allowed in the same state: "+ transition.actionId); set.add(transition); } }
AbstractStateMachine.java
/** * Reusable abstraction of a state machine. */ public abstract class AbstractStateMachine { /** @return the actions this state machine supports. */ protected abstract Set<AbstractAction> getActions(); /** * Transitions from given state according to given action, performing that action. * @return the follower state resulting from done transition. */ public AbstractState transition(AbstractState state, String actionId) { for (Transition transition : state.transitions()) if (transition.actionId.equals(actionId)) return perform(state, transition, actionId); throw new IllegalStateException( "No transition in state "+state.getClass().getSimpleName()+ " can dispatch action "+actionId); } /** @return the ids of actions that can be performed on given state. */ public Set<String> followerActionIds(AbstractState followerState) { return followerState.actionIds(); } private AbstractState perform(AbstractState oldState, Transition transition, String actionId) { final AbstractAction action = findAction(actionId); final AbstractState newState = transition.followerState; action.perform(oldState, newState); return newState; } private AbstractAction findAction(String actionId) { for (AbstractAction action : getActions()) if (action.getId().equals(actionId)) return action; throw new IllegalArgumentException("No action found for id "+actionId); } }
Transition.java
/** * A transition connects exactly one action with one follower state, * and it represents exactly one state/event table cell. * Transitions are unique within one state, identified by their actionId, * so that one state can never have two transitions with same actionId. */ public class Transition { public final String actionId; public final State followerState; public Transition(String actionId, State followerState) { this.actionId = Objects.requireNonNull(actionId); this.followerState = Objects.requireNonNull(followerState); } /** @return hash of actionId. */ @Override public int hashCode() { return actionId.hashCode(); } /** @return true if local actionId is equal to that in given transition. */ @Override public boolean equals(Object o) { final Transition other = (Transition) o; return actionId.equals(other.actionId); } }
Source Descriptions
The essential difference to the classic State pattern is that action-events are no more implemented as methods in a state but represented by a list of transitions.
Transitions
A transition connects exactly one action-id with exactly one follower state, and it represents exactly one state/event table cell. Transitions are identified by their action-id, because they must be unique within one state (table column), so that one state can never have two transitions with the same action-id. (If your workflow requires such, you should consider to spawn another state that inherits from its parent.)
Transitions do not carry the action instance directly, they just refer to its ID,
so that the AbstractStateMachine
can perform a given action-id when it
knows all available connected action classes.
This is what happens in AbstractStateMachine.transition()
method.
The set of actions is provided by its workflow-specific sub-class StateMachine
in
getActions()
method.
Using transitions makes it easy to determine follower action-ids.
You just have to enumerate all transitions of the follower-state and collect their actionids.
This is what happens in AbstractStateMachine.followerActionIds()
method.
States
When you look at AbstractState
, you see the protected utility methods
extend()
and remove()
.
They serve to reuse a set of transitions inherited from a super-class.
(Being able to inherit from another state is an important feature I would never want to miss,
it prevents lots of code duplications!)
In our workflow it is the DraftState
that inherits from
InitialState
(because these two are almost identical),
and then uses remove()
to get rid of the "create" action.
-
The super-class
State
declares that the "read" action is possible in any state (should not the sub-class remove it) -
Then
InitialState
extends it and adds "create", "save", "delete" and "release" actions -
As
DraftState
doesn't allow "create", it inherits all actions fromInitialState
but removesCreateAction.ID
That way you can maintain the transitions in InitialState
without having to care for DraftState
, and vice-versa!
The methods extend()
and remove()
also check
that no ambiguous transition gets added, by calling addButAssertNotContained()
.
This state machine implementation does not allow to have two transitions with same action-id in one state.
That means, any action-id has exactly one follower-state in the context of its state.
State Machine
The AbstractStateMachine
is a base class for many different state machines
that can then be written in a standard way.
It requires that all possible actions are defined by a sub-class,
everything else can be done like implemented in its very simple
private perform()
method.
When you look at the concrete StateMachine
class that was made
for the documentation-workflow, you see that it provides the action instances
for that workflow.
Mind that the state machine is the only one that knows concrete action classes,
all other participants just use action-IDs.
Not even the caller of the state machine knows the action classes or can determine them.
That means there is a "loose binding" between transitions and actions, in other words,
you could provide different action semantics for a workflow by
using the same transitions but different action classes.
Just override StateMachine.getActions()
,
or use a protected
factory method for action instances there.
Resume
I hope I could show how the state design pattern can be customized for
better code reusage. The difference to the classic variant is not so big.
Using the Transition
API instead of defining action-methods
is just a matter of habit change.
Keine Kommentare:
Kommentar veröffentlichen