The "State" Design Pattern is a quite complicated pattern. It implements a finite state machine, also called automaton. This article contains a small example how this can be implemented in Java, following the famous "Gang-of-Four" design patterns book.
I want to outline also the weaknesses of this example implementation, and I plan to introduce a more sophisticated one in a follow-up article, for comparing the two afterwards.
States and Actions
A state is a long-lasting condition a system is in.
An action is an atomic event that brings a system into a new state.
State machines combine states and actions to control workflows.
The term "event" (causing a state transition, → state/event table)
was replaced by "action-id" in the context of this article.
Workflow Example
My example state machine represents a workflow for e.g. online documentations. Such a documentation will be created, edited and saved several times, then released for public viewing. Maybe it will get deleted before being released, because it is of no use any more. A released documentation could be drawn back to be edited again, I called this "draft". The draft may be released once more, or even deleted.
Thus we have following states:
- initial (after creation, before first release)
- released
- draft (drawn back from release)
- deleted
With following actions:
- create (generate or clear, initialize with defaults)
- save
- read (load for modifications)
- delete
- release
- edit (draw back to draft)
The only difference between the initial and the draft state is that the draft state can not dispatch a create-action.
Mind that not every action leads to a new state. For instance, "save" and "read" actions would not alter the state, an initial documentation would stay in initial state, an edited one would stay in draft state.
State Machine Illustrations
A state-machine manages states and actions and allows to control which action is legal in which state. Here is our example's UML diagram:
The table below is a more accurate display. Every table cell represents a possible state transition to consider. Should the transition be illegal, put an "X" into it. Else define the action to perform, and the follower state to go to. If there is no follower state, the action causes no state change, this is legal. When no table cell is empty any more, you can transfer the state-machine to source code!
→ States ↓ Actions |
Initial | Released | Draft | Deleted |
---|---|---|---|---|
Create | CreateAcion → Initial | X | X | X |
Save | SaveAction | X | SaveAction | X |
Read | ReadAction | ReadAction | ReadAction | X |
Release | ReleaseAction → Released | X | ReleaseAction → Released | X |
Edit | X | EditAction → Draft | X | X |
Delete | DeleteAction → Deleted | DeleteAction → Deleted | DeleteAction → Deleted | X |
The header row enumerates all states, the first column in every row defines the arriving action. Any table cell then represents an arriving action in the state defined by the column header. Every possible combination of action and state is visible in this table. Empty "X" cells represent illegal combinations and should cause an exception.
Mind that "Initial" and "Draft" states are almost identical, just the "Create" action is not allowed in "Draft" state. This is sign that we will need some kind of code reusage on states.
Source Code Files and Folders
How to bring this into Java source code?
Very simple:
- → every state is represented by a class,
- → every action by a method in one or more state-classes.
Please click onto the expand controls below to see Java source code!
statemachine
classic
actions
Action.java
/** * Action events move a system from one state to another. * They always perform some work. */ public abstract class Action extends AbstractAction { public Object perform(AbstractState oldState, AbstractState newState) { throw new RuntimeException("Implement me!"); } }
CreateAction.java
/** * Generates data when not exists, else clears it ("reset"). * In any case it establishes default values. */ public class CreateAction extends Action { public static final String ID = "create"; @Override public String getId() { return ID; } }
DeleteAction.java
/** * Pushes data into a deleted state. */ public class DeleteAction extends Action { public static final String ID = "delete"; @Override public String getId() { return ID; } }
EditAction.java
/** * Brings published data back to draft state. */ public class EditAction extends Action { public static final String ID = "edit"; @Override public String getId() { return ID; } }
ReadAction.java
/** * Reads data from persistence. */ public class ReadAction extends Action { public static final String ID = "read"; @Override public String getId() { return ID; } }
ReleaseAction.java
/** * Publishes initial or draft data. */ public class ReleaseAction extends Action { public static final String ID = "release"; @Override public String getId() { return ID; } }
SaveAction.java
/** * Saves data to persistence. */ public class SaveAction extends Action { public static final String ID = "save"; @Override public String getId() { return ID; } }
states
State.java
/** * Superclass of all states. * A state contains one method for every action-event it supports. */ public abstract class State extends AbstractState { @Override protected Set<Action> getActions() { return Set.of( new CreateAction(), new DeleteAction(), new EditAction(), new ReadAction(), new ReleaseAction(), new SaveAction() ); } public State create() { throw exception(CreateAction.ID); } public State delete() { throw exception(DeleteAction.ID); } public State edit() { throw exception(EditAction.ID); } public State read() { final State followerState = this; performAction(ReadAction.ID, followerState); return followerState; } public State release() { throw exception(ReleaseAction.ID); } public State save() { throw exception(SaveAction.ID); } /** * @return all possible actions to be performed on this state, * derived from all action-methods that do not throw exception. */ public Set<String> actionIds() { return Set.of( ReadAction.ID); } }
DeletedState.java
public class DeletedState extends State { @Override public State read() { throw new IllegalStateException("Illegal action 'read' in state "+getClass().getSimpleName()); } @Override public Set<String> actionIds() { return Set.of(); // no actions when deleted } }
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 State create() { throw exception(CreateAction.ID); } @Override public Set<String> actionIds() { return remove( super.actionIds(), CreateAction.ID ); } }
InitialState.java
/** * After creation, data are in this initial state. */ public class InitialState extends State { @Override public State create() { final State followerState = this; performAction(CreateAction.ID, followerState); return followerState; } @Override public State save() { final State followerState = this; performAction(SaveAction.ID, followerState); return followerState; } @Override public State delete() { final State followerState = new DeletedState(); performAction(DeleteAction.ID, followerState); return followerState; } @Override public State release() { final State followerState = new ReleasedState(); performAction(ReleaseAction.ID, followerState); return followerState; } @Override public Set<String> actionIds() { return extend( super.actionIds(), // read CreateAction.ID, SaveAction.ID, DeleteAction.ID, ReleaseAction.ID ); } }
ReleasedState.java
/** * From initial state or draft state, data can be published. */ public class ReleasedState extends State { @Override public State edit() { final State followerState = new DraftState(); performAction(EditAction.ID, followerState); return followerState; } @Override public State delete() { final State followerState = new DeletedState(); performAction(DeleteAction.ID, followerState); return followerState; } @Override public Set<String> actionIds() { return extend( super.actionIds(), // read EditAction.ID, DeleteAction.ID ); } }
StateMachine.java
public class StateMachine { /** * Transitions from given state according to given action, performing that action. * @return the follower state resulting from done transition. */ public State transition(State state, String actionId) { switch (actionId) { case CreateAction.ID: return state.create(); case DeleteAction.ID: return state.delete(); case EditAction.ID: return state.edit(); case ReadAction.ID: return state.read(); case ReleaseAction.ID: return state.release(); case SaveAction.ID: return state.save(); default: throw new IllegalArgumentException("No action found for id "+actionId); } } /** @return the ids of actions that can be performed on given state. */ public Set<String> followerActionIds(State followerState) { return followerState.actionIds(); } }
AbstractAction.java
/** * Action events move a system from one state to another. * They always perform some work. */ public abstract class AbstractAction { public abstract String getId(); public abstract Object perform(AbstractState oldState, AbstractState newState); }
AbstractState.java
/** * Reusable superclass for all states. */ public abstract class AbstractState { /** @return all actions from all states. */ protected abstract Set<Action> getActions(); /** Performs given action in context of this state and given follower-state. */ protected final Object performAction(String actionId, AbstractState followerState) { for (Action action : getActions()) if (action.getId().equals(actionId)) return action.perform(this, followerState); throw new IllegalArgumentException("Action not found: "+actionId); } /** * Convenience method to avoid code duplications. * @param inheritedActions required, use <i>super.actionIds()</i> for that. * @param moreTransitions required, further actions to add. * @return the union of <i>inheritedActions</i> and <i>moreActions</i>. */ protected final Set<String> extend(Set<String> inheritedActions, String... moreActions) { final Set<String> extended = new HashSet<>(); extended.addAll(inheritedActions); for (String moreAction : moreActions) extended.add(moreAction); return Collections.unmodifiableSet(extended); } /** * Convenience method to avoid code duplications. * @param inheritedActions required, use <i>super.actionIds()</i> for that. * @param actionsToRemove required, action-ids to remove. * @return a new set consisting of the <i>inheritedActions</i> minus those * transitions that reference an action contained in <i>actionsToRemove</i>. */ protected final Set<String> remove(Set<String> inheritedActions, String... actionsToRemove) { final Set<String> toRemove = Set.of(actionsToRemove); final Set<String> reduced = new HashSet<>(); for (String inheritedAction : inheritedActions) if (false == toRemove.contains(inheritedAction)) reduced.add(inheritedAction); return Collections.unmodifiableSet(reduced); } protected final RuntimeException exception(String actionId) { return new IllegalStateException("Illegal action '"+actionId+"' in state "+getClass().getSimpleName()); } @Override public int hashCode() { return getClass().hashCode(); } @Override public boolean equals(Object o) { return getClass().equals(o.getClass()); } }
Before talking about sources above, here is a snippet showing how this state machine can be used:
@Test public void matchFollowerActionsOfReleaseOnInitialState_shouldSucceed() { final StateMachine stateMachine = new StateMachine(); final State followerState = stateMachine.transition(new InitialState(), ReleaseAction.ID); final Set<String> followerActionIds = stateMachine.followerActionIds(followerState); assertEquals(3, followerActionIds.size()); assertTrue(followerActionIds.contains(ReadAction.ID)); assertTrue(followerActionIds.contains(EditAction.ID)); assertTrue(followerActionIds.contains(DeleteAction.ID)); }
Mind that it is an important feature of a state machine to provide follower-actions to a caller. Such a caller may be a UI-client that wants to build action buttons with these action-ids.
Source Descriptions
The most important thing to know is that a state machine is understandable and controllable much better from its table representation than from source code. You always should have some tech-doc where you think about and perform your changes, only then transfer the change to source code.
The core concept of this implementation is that
every state extends a class that defines all possible action-methods
and throws an exception from them (see State.java
).
Every state class that extends it then overrides only those methods that it actually supports.
All other action-method calls on that state would cause an exception.
This handles illegal state-action combinations, rendered as empty "X" table cells.
Actions
The Action
classes may not be very interesting,
because they just provide their IDs, here they do nothing.
A real world action would need a constructor context
that enables it to read and write a documentation and its state.
Keep in mind that any Action
class holds a
static ID and provides it through a getId()
instance method,
and that this class lives in a method of same name that performs that action.
(You can see this e.g. in State.read()
,
which is a default action available in any state.)
Thus an action is represented three times, by XxxAction.ID
(usable as "event"),
by XxxAction.class
(actual work),
and by action-method xxx()
in 1-n states.
(Not really beautiful.)
States
The states extend the State
class that again extends AbstractState
,
which can be reused by different state machines.
States represent table columns.
If an action-event arrives, you would construct the documentation's state,
call a method relating to that action-event on it, and get back a follower state
that also provides follower actions.
Thus you have to relate the arriving action-event (ID) to an action-method in current state.
How do that? You see it in StateMachine.transition()
where a big
switch-case statement associates the two.
(Not really beautiful.)
Next station is the method's body that determines a follower state
and performs an action according to the method's name.
Look at all the action-methods in InitialState
like create()
.
They all do the same but use different action classes and IDs.
(Not really beautiful.)
The follower state is returned to the calling state machine,
which then ends by returning that to its caller.
Another thing is the actionIds()
responsibility in State
.
This is needed to determine the follower action IDs of a state.
So any state class must enumerate all its action-methods also as an action-ID set.
(Not really beautiful.)
Utilities
There are utilities in AbstractState
that let us reuse
super-implementations without looking into them:
extend()
and remove()
.
The first allows to add all supported action-ids of the super-class,
the second allows to remove certain action-ids from the set defined by super-class.
This is demonstrated in InitialState.actionIds()
and
DraftState.actionIds()
.
InitialState inherits "read" from super-class:
@Override public Set<String> actionIds() { return extend( super.actionIds(), // read CreateAction.ID, SaveAction.ID, DeleteAction.ID, ReleaseAction.ID ); }
DraftState supports all action-methods of the super-class except "create":
@Override public Set<String> actionIds() { return remove( super.actionIds(), CreateAction.ID ); }
Resume
This state machine example implementation cearly shows the weaknesses of the state design pattern: there are too many symbolic references.
- Action-ID to action-method in
StateMachine.transition()
- Action-method back to action-ID in perform-calls of
create()
,save()
,release()
etc. - Action-methods enumerated as action-IDs in
State.actionIds()
- When a state inherits an action-method from super-class but can not allow it,
it must (1) override the action-method to throw an exception,
and (2) remove the according action-ID from the set of supported action-IDs,
as to be seen in
DraftState
.
It would be even worse if there were no utility methods like
extend()
and remove()
that make it possible
to reuse enumerations of super-classes, like provided in AbstractState
.
In my next Blog I will introduce an implementation that may look more difficult but removes most of the redundancies listed here and thus actually is simpler.
Keine Kommentare:
Kommentar veröffentlichen