Blog-Archiv

Dienstag, 8. Februar 2022

Classic State Design Pattern in Java

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.

  1. Action-ID to action-method in StateMachine.transition()
  2. Action-method back to action-ID in perform-calls of create(), save(), release() etc.
  3. Action-methods enumerated as action-IDs in State.actionIds()
  4. 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: